diff --git a/.buildkite/cache-builder.yml b/.buildkite/cache-builder.yml new file mode 100644 index 000000000000..4f3d3fafc7d2 --- /dev/null +++ b/.buildkite/cache-builder.yml @@ -0,0 +1,48 @@ +# This script is run via Buildkite's scheduled jobs feature. +# +# It's meant to rebuild various CI caches on a periodic async basis, so as +# not to waste time on every CI job updating the cache. + +# Nodes with values to reuse in the pipeline. +common_params: + # Common plugin settings to use with the `plugins` key. + - &common_plugins + - automattic/a8c-ci-toolkit#2.15.1 + - automattic/git-s3-cache#1.1.4: + bucket: "a8c-repo-mirrors" + repo: "automattic/wordpress-ios/" + # Common environment values to use with the `env` key. + - &common_env + # Be sure to also update the `.xcode-version` file when updating the Xcode image/version here + IMAGE_ID: xcode-14.2 + +steps: + + ################# + # Build the CocoaPods Base Cache + # + # This prevents the base cache from infinite growth caused by storing every + # version of every pod we've ever used. + ################# + - label: ":cocoapods: Rebuild CocoaPods cache" + command: | + echo "--- :rubygems: Setting up Gems" + install_gems + + echo "--- :cocoapods: Rebuilding Pod Cache" + cache_cocoapods_specs_repos + env: *common_env + plugins: *common_plugins + + ################# + # Build the Git Repo cache + # + # Because this repo is so large, we periodically create a Git Mirror and copy it to S3, + # from where it can be fetched by agents more quickly than from GitHub, and so that + # agents then have less new commits to `git pull` by using that cache as starting point + ################# + - label: ":git: Rebuild git cache" + command: "cache_repo a8c-repo-mirrors" + plugins: *common_plugins + agents: + queue: "default" diff --git a/.buildkite/commands/build-for-testing.sh b/.buildkite/commands/build-for-testing.sh new file mode 100755 index 000000000000..d6909bb59edf --- /dev/null +++ b/.buildkite/commands/build-for-testing.sh @@ -0,0 +1,28 @@ +#!/bin/bash -eu +APP=${1:-} + +# Run this at the start to fail early if value not available +if [[ "$APP" != "wordpress" && "$APP" != "jetpack" ]]; then + echo "Error: Please provide either 'wordpress' or 'jetpack' as first parameter to this script" + exit 1 +fi + +echo "--- :rubygems: Setting up Gems" +install_gems + +echo "--- :cocoapods: Setting up Pods" +install_cocoapods + +echo "--- :writing_hand: Copy Files" +mkdir -pv ~/.configure/wordpress-ios/secrets +cp -v fastlane/env/project.env-example ~/.configure/wordpress-ios/secrets/project.env + +echo "--- Installing Secrets" +bundle exec fastlane run configure_apply + +echo "--- :hammer_and_wrench: Building" +bundle exec fastlane build_${APP}_for_testing + +echo "--- :arrow_up: Upload Build Products" +tar -cf build-products-${APP}.tar DerivedData/Build/Products/ +upload_artifact build-products-${APP}.tar diff --git a/.buildkite/commands/lint-localized-strings-format.sh b/.buildkite/commands/lint-localized-strings-format.sh new file mode 100644 index 000000000000..4f0611a4ff73 --- /dev/null +++ b/.buildkite/commands/lint-localized-strings-format.sh @@ -0,0 +1,7 @@ +#!/bin/bash -eu + +echo "--- :writing_hand: Copy Files" +mkdir -pv ~/.configure/wordpress-ios/secrets +cp -v fastlane/env/project.env-example ~/.configure/wordpress-ios/secrets/project.env + +lint_localized_strings_format diff --git a/.buildkite/commands/prototype-build-jetpack.sh b/.buildkite/commands/prototype-build-jetpack.sh new file mode 100644 index 000000000000..6a6b28d571c9 --- /dev/null +++ b/.buildkite/commands/prototype-build-jetpack.sh @@ -0,0 +1,16 @@ +#!/bin/bash -eu + +# Sentry CLI needs to be up-to-date +brew upgrade sentry-cli + +echo "--- :rubygems: Setting up Gems" +install_gems + +echo "--- :cocoapods: Setting up Pods" +install_cocoapods + +echo "--- :closed_lock_with_key: Installing Secrets" +bundle exec fastlane run configure_apply + +echo "--- :hammer_and_wrench: Building" +bundle exec fastlane build_and_upload_jetpack_prototype_build diff --git a/.buildkite/commands/prototype-build-wordpress.sh b/.buildkite/commands/prototype-build-wordpress.sh new file mode 100644 index 000000000000..1be08a018a5e --- /dev/null +++ b/.buildkite/commands/prototype-build-wordpress.sh @@ -0,0 +1,16 @@ +#!/bin/bash -eu + +# Sentry CLI needs to be up-to-date +brew upgrade sentry-cli + +echo "--- :rubygems: Setting up Gems" +install_gems + +echo "--- :cocoapods: Setting up Pods" +install_cocoapods + +echo "--- :closed_lock_with_key: Installing Secrets" +bundle exec fastlane run configure_apply + +echo "--- :hammer_and_wrench: Building" +bundle exec fastlane build_and_upload_wordpress_prototype_build diff --git a/.buildkite/commands/release-build-jetpack.sh b/.buildkite/commands/release-build-jetpack.sh new file mode 100755 index 000000000000..b00ddc4e1c69 --- /dev/null +++ b/.buildkite/commands/release-build-jetpack.sh @@ -0,0 +1,18 @@ +#!/bin/bash -eu + +brew install imagemagick +brew install ghostscript +# Sentry CLI needs to be up-to-date +brew upgrade sentry-cli + +echo "--- :rubygems: Setting up Gems" +install_gems + +echo "--- :cocoapods: Setting up Pods" +install_cocoapods + +echo "--- :closed_lock_with_key: Installing Secrets" +bundle exec fastlane run configure_apply + +echo "--- :hammer_and_wrench: Building" +bundle exec fastlane build_and_upload_jetpack_for_app_store diff --git a/.buildkite/commands/release-build-wordpress-internal.sh b/.buildkite/commands/release-build-wordpress-internal.sh new file mode 100755 index 000000000000..6135d9b0153d --- /dev/null +++ b/.buildkite/commands/release-build-wordpress-internal.sh @@ -0,0 +1,21 @@ +#!/bin/bash -eu + +echo "--- :arrow_down: Installing Release Dependencies" +brew install imagemagick +brew install ghostscript +# Sentry CLI needs to be up-to-date +brew upgrade sentry-cli + +echo "--- :rubygems: Setting up Gems" +install_gems + +echo "--- :cocoapods: Setting up Pods" +install_cocoapods + +echo "--- :closed_lock_with_key: Installing Secrets" +bundle exec fastlane run configure_apply + +echo "--- :hammer_and_wrench: Building" +bundle exec fastlane build_and_upload_app_center \ + skip_confirm:true \ + skip_prechecks:true diff --git a/.buildkite/commands/release-build-wordpress.sh b/.buildkite/commands/release-build-wordpress.sh new file mode 100755 index 000000000000..429fe4ca0752 --- /dev/null +++ b/.buildkite/commands/release-build-wordpress.sh @@ -0,0 +1,23 @@ +#!/bin/bash -eu + +echo "--- :arrow_down: Installing Release Dependencies" +brew install imagemagick +brew install ghostscript +# Sentry CLI needs to be up-to-date +brew upgrade sentry-cli + +echo "--- :rubygems: Setting up Gems" +install_gems + +echo "--- :cocoapods: Setting up Pods" +install_cocoapods + +echo "--- :closed_lock_with_key: Installing Secrets" +bundle exec fastlane run configure_apply + +echo "--- :hammer_and_wrench: Building" +bundle exec fastlane build_and_upload_app_store_connect \ + skip_confirm:true \ + skip_prechecks:true \ + create_release:true \ + beta_release:${1:-true} # use first call param, default to true for safety diff --git a/.buildkite/commands/rubocop-via-danger.sh b/.buildkite/commands/rubocop-via-danger.sh new file mode 100755 index 000000000000..45cac5f21ae2 --- /dev/null +++ b/.buildkite/commands/rubocop-via-danger.sh @@ -0,0 +1,7 @@ +#!/bin/bash -eu + +echo "--- :rubygems: Setting up Gems" +install_gems + +echo "--- :rubocop: Run Rubocop via Danger" +bundle exec danger --fail-on-errors=true diff --git a/.buildkite/commands/run-ui-tests.sh b/.buildkite/commands/run-ui-tests.sh new file mode 100755 index 000000000000..a381bfa750be --- /dev/null +++ b/.buildkite/commands/run-ui-tests.sh @@ -0,0 +1,51 @@ +#!/bin/bash -eu + +DEVICE=$1 + +echo "Running UI tests on $DEVICE. The iOS version will be the latest available in the CI host." + +# Run this at the start to fail early if value not available +echo '--- :test-analytics: Configuring Test Analytics' +if [[ $DEVICE =~ ^iPhone ]]; then + export BUILDKITE_ANALYTICS_TOKEN=$BUILDKITE_ANALYTICS_TOKEN_UI_TESTS_IPHONE +else + export BUILDKITE_ANALYTICS_TOKEN=$BUILDKITE_ANALYTICS_TOKEN_UI_TESTS_IPAD +fi + +echo "--- 📦 Downloading Build Artifacts" +download_artifact build-products-jetpack.tar +tar -xf build-products-jetpack.tar + +echo "--- :rubygems: Setting up Gems" +install_gems + +echo "--- :cocoapods: Setting up Pods" +install_cocoapods + +echo "--- 🔬 Testing" +xcrun simctl list >> /dev/null +rake mocks & +set +e +bundle exec fastlane test_without_building name:Jetpack device:"$DEVICE" +TESTS_EXIT_STATUS=$? +set -e + +if [[ "$TESTS_EXIT_STATUS" -ne 0 ]]; then + # Keep the (otherwise collapsed) current "Testing" section open in Buildkite logs on error. See https://buildkite.com/docs/pipelines/managing-log-output#collapsing-output + echo "^^^ +++" + echo "UI Tests failed!" +fi + +echo "--- 📦 Zipping test results" +cd build/results/ && zip -rq JetpackUITests.xcresult.zip JetpackUITests.xcresult && cd - + +echo "--- 🚦 Report Tests Status" +if [[ $TESTS_EXIT_STATUS -eq 0 ]]; then + echo "UI Tests seems to have passed (exit code 0). All good 👍" +else + echo "The UI Tests, ran during the '🔬 Testing' step above, have failed." + echo "For more details about the failed tests, check the Buildkite annotation, the logs under the '🔬 Testing' section and the \`.xcresult\` and test reports in Buildkite artifacts." +fi +annotate_test_failures "build/results/report.junit" + +exit $TESTS_EXIT_STATUS diff --git a/.buildkite/commands/run-unit-tests.sh b/.buildkite/commands/run-unit-tests.sh new file mode 100755 index 000000000000..167a93073a86 --- /dev/null +++ b/.buildkite/commands/run-unit-tests.sh @@ -0,0 +1,38 @@ +#!/bin/bash -eu + +# Run this at the start to fail early if value not available +echo '--- :test-analytics: Configuring Test Analytics' +export BUILDKITE_ANALYTICS_TOKEN=$BUILDKITE_ANALYTICS_TOKEN_UNIT_TESTS + +echo "--- 📦 Downloading Build Artifacts" +download_artifact build-products-wordpress.tar +tar -xf build-products-wordpress.tar + +echo "--- :rubygems: Setting up Gems" +install_gems + +echo "--- 🔬 Testing" +set +e +bundle exec fastlane test_without_building name:WordPressUnitTests +TESTS_EXIT_STATUS=$? +set -e + +if [[ $TESTS_EXIT_STATUS -ne 0 ]]; then + # Keep the (otherwise collapsed) current "Testing" section open in Buildkite logs on error. See https://buildkite.com/docs/pipelines/managing-log-output#collapsing-output + echo "^^^ +++" + echo "Unit Tests failed!" +fi + +echo "--- 📦 Zipping test results" +cd build/results/ && zip -rq WordPress.xcresult.zip WordPress.xcresult && cd - + +echo "--- 🚦 Report Tests Status" +if [[ $TESTS_EXIT_STATUS -eq 0 ]]; then + echo "Unit Tests seems to have passed (exit code 0). All good 👍" +else + echo "The Unit Tests, ran during the '🔬 Testing' step above, have failed." + echo "For more details about the failed tests, check the Buildkite annotation, the logs under the '🔬 Testing' section and the \`.xcresult\` and test reports in Buildkite artifacts." +fi +annotate_test_failures "build/results/report.junit" + +exit $TESTS_EXIT_STATUS diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml new file mode 100644 index 000000000000..5bb28a440406 --- /dev/null +++ b/.buildkite/pipeline.yml @@ -0,0 +1,141 @@ +# Nodes with values to reuse in the pipeline. +common_params: + # Common plugin settings to use with the `plugins` key. + - &common_plugins + - automattic/a8c-ci-toolkit#2.15.1 + - automattic/git-s3-cache#1.1.4: + bucket: "a8c-repo-mirrors" + repo: "automattic/wordpress-ios/" + # Common environment values to use with the `env` key. + - &common_env + # Be sure to also update the `.xcode-version` file when updating the Xcode image/version here + IMAGE_ID: xcode-14.2 + +# This is the default pipeline – it will build and test the app +steps: + + ################# + # Create Prototype Builds for WP and JP + ################# + - group: "🛠 Prototype Builds" + steps: + - label: "🛠 WordPress Prototype Build" + command: ".buildkite/commands/prototype-build-wordpress.sh" + env: *common_env + plugins: *common_plugins + if: "build.pull_request.id != null || build.pull_request.draft" + notify: + - github_commit_status: + context: "WordPress Prototype Build" + + - label: "🛠 Jetpack Prototype Build" + command: ".buildkite/commands/prototype-build-jetpack.sh" + env: *common_env + plugins: *common_plugins + if: "build.pull_request.id != null || build.pull_request.draft" + notify: + - github_commit_status: + context: "Jetpack Prototype Build" + + ################# + # Create Builds for Testing + ################# + - group: "🛠 Builds for Testing" + steps: + - label: "🛠 :wordpress: Build for Testing" + key: "build_wordpress" + command: ".buildkite/commands/build-for-testing.sh wordpress" + env: *common_env + plugins: *common_plugins + notify: + - github_commit_status: + context: "WordPress Build for Testing" + + - label: "🛠 :jetpack: Build for Testing" + key: "build_jetpack" + command: ".buildkite/commands/build-for-testing.sh jetpack" + env: *common_env + plugins: *common_plugins + notify: + - github_commit_status: + context: "Jetpack Build for Testing" + + ################# + # Run Unit Tests + ################# + - label: "🔬 :wordpress: Unit Tests" + command: ".buildkite/commands/run-unit-tests.sh" + depends_on: "build_wordpress" + env: *common_env + plugins: *common_plugins + artifact_paths: + - "build/results/*" + notify: + - github_commit_status: + context: "Unit Tests" + - slack: + channels: + - "#mobile-apps-tests-notif" + if: build.state == "failed" && build.branch == "trunk" + + ################# + # UI Tests + ################# + - group: "🔬 UI Tests" + steps: + - label: "🔬 :jetpack: UI Tests (iPhone)" + command: .buildkite/commands/run-ui-tests.sh 'iPhone SE (3rd generation)' + depends_on: "build_jetpack" + env: *common_env + plugins: *common_plugins + artifact_paths: + - "build/results/*" + notify: + - github_commit_status: + context: "UI Tests (iPhone)" + - slack: + channels: + - "#mobile-apps-tests-notif" + if: build.state == "failed" && build.branch == "trunk" + + - label: "🔬 :jetpack: UI Tests (iPad)" + command: .buildkite/commands/run-ui-tests.sh 'iPad Air (5th generation)' + depends_on: "build_jetpack" + env: *common_env + plugins: *common_plugins + artifact_paths: + - "build/results/*" + notify: + - github_commit_status: + context: "UI Tests (iPad)" + - slack: + channels: + - "#mobile-apps-tests-notif" + if: build.state == "failed" && build.branch == "trunk" + + ################# + # Linters + ################# + - group: "Linters" + steps: + - label: "🧹 Lint Translations" + command: "gplint /workdir/WordPress/Resources/AppStoreStrings.po" + plugins: + - docker#v3.8.0: + image: "public.ecr.aws/automattic/glotpress-validator:1.0.0" + agents: + queue: "default" + notify: + - github_commit_status: + context: "Lint Translations" + # This step uses Danger to run RuboCop, but it's "agnostic" about it. + # That is, it outwardly only mentions RuboCop, not Danger + - label: ":rubocop: Lint Ruby Tooling" + command: .buildkite/commands/rubocop-via-danger.sh + plugins: *common_plugins + agents: + queue: "android" + - label: ":sleuth_or_spy: Lint Localized Strings Format" + command: .buildkite/commands/lint-localized-strings-format.sh + plugins: *common_plugins + env: *common_env diff --git a/.buildkite/release-builds.yml b/.buildkite/release-builds.yml new file mode 100644 index 000000000000..5666d3bb0006 --- /dev/null +++ b/.buildkite/release-builds.yml @@ -0,0 +1,42 @@ +# This pipeline is meant to be run via the Buildkite API, and is only used for release builds + +# Nodes with values to reuse in the pipeline. +common_params: + # Common plugin settings to use with the `plugins` key. + - &common_plugins + - automattic/a8c-ci-toolkit#2.15.1 + - automattic/git-s3-cache#1.1.4: + bucket: "a8c-repo-mirrors" + repo: "automattic/wordpress-ios/" + # Common environment values to use with the `env` key. + - &common_env + # Be sure to also update the `.xcode-version` file when updating the Xcode image/version here + IMAGE_ID: xcode-14.2 + +steps: + + - label: ":wordpress: :testflight: WordPress Release Build (App Store Connect)" + command: ".buildkite/commands/release-build-wordpress.sh $BETA_RELEASE" + # The TestFlight build has a priority of 2 so that it is higher than the AppCenter build + priority: 2 + env: *common_env + plugins: *common_plugins + notify: + - slack: "#build-and-ship" + + - label: ":wordpress: :appcenter: WordPress Release Build (App Center)" + command: ".buildkite/commands/release-build-wordpress-internal.sh" + priority: 1 + env: *common_env + plugins: *common_plugins + notify: + - slack: "#build-and-ship" + + - label: ":jetpack: :testflight: Jetpack Release Build (App Store Connect)" + command: ".buildkite/commands/release-build-jetpack.sh" + # The TestFlight build has a priority of 2 so that it is higher than the AppCenter build + priority: 2 + env: *common_env + plugins: *common_plugins + notify: + - slack: "#build-and-ship" diff --git a/.bundle/config b/.bundle/config new file mode 100644 index 000000000000..59c8ccc55a7d --- /dev/null +++ b/.bundle/config @@ -0,0 +1,5 @@ +--- +BUNDLE_PATH: "vendor/bundle" +BUNDLE_JOBS: "3" +BUNDLE_WITHOUT: "screenshots" +BUNDLE_RETRY: "3" diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 6ebdafb3f8b9..000000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,144 +0,0 @@ -version: 2.1 - -orbs: - # Using 1.0 of the Orbs means it will use the latest 1.0.x version from https://github.com/wordpress-mobile/circleci-orbs - ios: wordpress-mobile/ios@1.0 - git: wordpress-mobile/git@1.0 - -commands: - fix-path: - steps: - - run: - name: Fix $PATH - command: | - # Add `/usr/local/bin` to the Xcode 11.2 image's $PATH in order to be able to use dependencies - - if [ $(echo $PATH | ruby -e "puts Kernel.gets.include?('/usr/local/bin')") != "true" ]; then - echo 'export PATH=/usr/local/bin:$PATH' >> $BASH_ENV - echo "Manually added `/usr/local/bin` to the $PATH:" - echo $PATH - fi - - save-xcresult: - steps: - - run: - name: Zip xcresult - command: | - mkdir testresults - zip -r testresults/xcresult.zip test-without-building.xcresult - when: on_fail # Zips the .xcresult file when tests fail, so it can be saved - - store_artifacts: - name: Save xcresult - path: testresults - destination: logs - -jobs: - Build Tests: - executor: - name: ios/default - xcode-version: "11.2.1" - steps: - - git/shallow-checkout - - ios/install-dependencies: - bundle-install: true - pod-install: true - - ios/xcodebuild: - command: build-for-testing - arguments: -workspace 'WordPress.xcworkspace' -scheme 'WordPress' -configuration 'Debug' -sdk iphonesimulator -derivedDataPath DerivedData - - persist_to_workspace: - root: ./ - paths: - - DerivedData/Build/Products - - Pods/WordPressMocks - Unit Tests: - executor: - name: ios/default - xcode-version: "11.2.1" - steps: - - fix-path - - ios/boot-simulator: - xcode-version: "11.2.1" - device: iPhone 11 - - attach_workspace: - at: ./ - - ios/wait-for-simulator - - ios/xcodebuild: - command: test-without-building - arguments: -xctestrun DerivedData/Build/Products/WordPress_WordPressUnitTests_iphonesimulator13.2-x86_64.xctestrun -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" -resultBundlePath test-without-building.xcresult - - ios/save-xcodebuild-artifacts - - save-xcresult - UI Tests: - parameters: - device: - type: string - executor: - name: ios/default - xcode-version: "11.2.1" - steps: - - fix-path - - ios/boot-simulator: - xcode-version: "11.2.1" - device: << parameters.device >> - - attach_workspace: - at: ./ - - run: - name: Run mocks - command: ./Pods/WordPressMocks/scripts/start.sh 8282 - background: true - - ios/wait-for-simulator - - ios/xcodebuild: - command: test-without-building - arguments: -xctestrun DerivedData/Build/Products/WordPress_WordPressUITests_iphonesimulator13.2-x86_64.xctestrun -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" -resultBundlePath test-without-building.xcresult - - ios/save-xcodebuild-artifacts - - save-xcresult - Installable Build: - executor: - name: ios/default - xcode-version: "11.2.1" - steps: - - git/shallow-checkout - - ios/install-dependencies: - bundle-install: true - pod-install: true - - run: - name: Copy Secrets - command: bundle exec fastlane run configure_apply - - run: - name: Build - working_directory: Scripts - command: "bundle exec fastlane build_and_upload_installable_build build_number:$CIRCLE_BUILD_NUM" - - run: - name: Prepare Artifacts - command: | - mkdir -p Artifacts - mv "Scripts/fastlane/comment.json" "Artifacts/comment.json" - - store_artifacts: - path: Artifacts - destination: Artifacts - -workflows: - wordpress_ios: - jobs: - - Build Tests - - Unit Tests: - requires: [ "Build Tests" ] - - UI Tests: - name: UI Tests (iPhone 11) - device: iPhone 11 - requires: [ "Build Tests" ] - - UI Tests: - name: UI Tests (iPad Air 3rd generation) - device: iPad Air \\(3rd generation\\) - requires: [ "Build Tests" ] - Installable Build: - jobs: - - Hold: - type: approval - filters: - branches: - ignore: /pull\/[0-9]+/ - - Installable Build: - requires: [Hold] - filters: - branches: - ignore: /pull\/[0-9]+/ diff --git a/.configure b/.configure index 6a95995e6804..cdbdb6c0c62e 100644 --- a/.configure +++ b/.configure @@ -1,35 +1,45 @@ { "project_name": "WordPress-iOS", - "branch": "master", - "pinned_hash": "23f0d2c584e5a91542bd7dacc77463c662ef784e", + "branch": "trunk", + "pinned_hash": "f0aaaf24805b0aa258e09ec85b6977194e514027", "files_to_copy": [ { - "file": "iOS/WPiOS/wpcom_app_credentials", - "destination": ".configure-files/wpcom_app_credentials", + "file": "shared/google_cloud_keys.json", + "destination": "~/.configure/wordpress-ios/secrets/google_cloud_keys.json", "encrypt": true }, { - "file": "iOS/WPiOS/wpcom_alpha_app_credentials", - "destination": ".configure-files/wpcom_alpha_app_credentials", + "file": "iOS/WPiOS/project.env", + "destination": "~/.configure/wordpress-ios/secrets/project.env", "encrypt": true }, { - "file": "iOS/WPiOS/wpcom_internal_app_credentials", - "destination": ".configure-files/wpcom_internal_app_credentials", + "file": "iOS/app_store_connect_fastlane_api_key.json", + "destination": "~/.configure/wordpress-ios/secrets/app_store_connect_fastlane_api_key.json", "encrypt": true }, { - "file": "shared/google_cloud_keys.json", - "destination": ".configure-files/google_cloud_keys.json", + "file": "iOS/WPiOS/Secrets.swift", + "destination": "~/.configure/wordpress-ios/secrets/WordPress-Secrets.swift", "encrypt": true }, { - "file": "iOS/WPiOS/project.env", - "destination": ".configure-files/project.env", + "file": "iOS/WPiOS/Secrets-Internal.swift", + "destination": "~/.configure/wordpress-ios/secrets/WordPress-Secrets-Internal.swift", + "encrypt": true + }, + { + "file": "iOS/WPiOS/Secrets-Alpha.swift", + "destination": "~/.configure/wordpress-ios/secrets/WordPress-Secrets-Alpha.swift", + "encrypt": true + }, + { + "file": "iOS/JPiOS/Jetpack-Secrets.swift", + "destination": "~/.configure/wordpress-ios/secrets/Jetpack-Secrets.swift", "encrypt": true } ], "file_dependencies": [ ] -} \ No newline at end of file +} diff --git a/.configure-files/Jetpack-Secrets.swift.enc b/.configure-files/Jetpack-Secrets.swift.enc new file mode 100644 index 000000000000..e95f1bdb41e1 Binary files /dev/null and b/.configure-files/Jetpack-Secrets.swift.enc differ diff --git a/.configure-files/Secrets-Alpha.swift.enc b/.configure-files/Secrets-Alpha.swift.enc new file mode 100644 index 000000000000..76237c13e737 Binary files /dev/null and b/.configure-files/Secrets-Alpha.swift.enc differ diff --git a/.configure-files/Secrets-Internal.swift.enc b/.configure-files/Secrets-Internal.swift.enc new file mode 100644 index 000000000000..7a4f1165af5e Binary files /dev/null and b/.configure-files/Secrets-Internal.swift.enc differ diff --git a/.configure-files/Secrets.swift.enc b/.configure-files/Secrets.swift.enc new file mode 100644 index 000000000000..ac47e942c869 Binary files /dev/null and b/.configure-files/Secrets.swift.enc differ diff --git a/.configure-files/app_store_connect_fastlane_api_key.json.enc b/.configure-files/app_store_connect_fastlane_api_key.json.enc new file mode 100644 index 000000000000..0a5200294b95 --- /dev/null +++ b/.configure-files/app_store_connect_fastlane_api_key.json.enc @@ -0,0 +1,2 @@ +���l*���c�I�:��/�����V��%�>aH����� +\K�`�-�H�J �p� ��~����;uN6VG�F�Y�C�Y�:������/�-!��)�]�w�M���b�|m_�#��5��� ��_Y7���)I���ž�n�C`/~��Kz�|-1|�5�A� �dݟ@{ ��8��g��c�e��ʳ������IwGBm���p��u��Ğ#~#x戎�g�ج�&b�6z/�����\c ��.=h�e(���S�__�ڝ�s&P)��:�R�u�[e �lꢃ2� �#x`�������W��+>ң��G�q� 3f�3 ���ͤ�D����v�j ,qy Pvd��E�j��V���`��w��@�4��K`�@u�� \ No newline at end of file diff --git a/.configure-files/google_cloud_keys.json.enc b/.configure-files/google_cloud_keys.json.enc index 64ddfcf11e66..0b20055aaacf 100644 Binary files a/.configure-files/google_cloud_keys.json.enc and b/.configure-files/google_cloud_keys.json.enc differ diff --git a/.configure-files/project.env.enc b/.configure-files/project.env.enc index c40dc4e59287..703e97a7ecf7 100644 Binary files a/.configure-files/project.env.enc and b/.configure-files/project.env.enc differ diff --git a/.configure-files/wpcom_alpha_app_credentials.enc b/.configure-files/wpcom_alpha_app_credentials.enc deleted file mode 100644 index 72e221ab5bd0..000000000000 Binary files a/.configure-files/wpcom_alpha_app_credentials.enc and /dev/null differ diff --git a/.configure-files/wpcom_app_credentials.enc b/.configure-files/wpcom_app_credentials.enc deleted file mode 100644 index 6d0cecfc43d8..000000000000 Binary files a/.configure-files/wpcom_app_credentials.enc and /dev/null differ diff --git a/.configure-files/wpcom_internal_app_credentials.enc b/.configure-files/wpcom_internal_app_credentials.enc deleted file mode 100644 index 3891fd388660..000000000000 Binary files a/.configure-files/wpcom_internal_app_credentials.enc and /dev/null differ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000000..db1bf43b9db9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# Apply to all files +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Ruby specific rules +[{*.rb,Fastfile,Gemfile}] +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes index 6493f2e18209..648ab329c02f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ RELEASE-NOTES.txt merge=union *.strings diff=localizablestrings +.configure-files/*.enc binary diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 7e1c60b22096..715f9c2e9731 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -7,4 +7,4 @@ ### Steps to reproduce the behavior -##### Tested on [device], iOS [version], WPiOS [version] +##### Tested on [device], iOS [version], Jetpack iOS / WordPress iOS [version] diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a6e0d8365f3a..745fa692c67c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,8 +2,29 @@ Fixes # To test: +## Regression Notes +1. Potential unintended areas of impact + + +2. What I did to test those areas of impact (or what existing automated tests I relied on) + + +3. What automated tests I added (or what prevented me from doing so) + PR submission checklist: -- [ ] I have considered adding unit tests where possible. +- [ ] I have completed the Regression Notes. +- [ ] I have considered adding unit tests for my changes. - [ ] I have considered adding accessibility improvements for my changes. - [ ] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary. + +UI Changes testing checklist: +- [ ] Portrait and landscape orientations. +- [ ] Light and dark modes. +- [ ] Fonts: Larger, smaller and bold text. +- [ ] High contrast. +- [ ] VoiceOver. +- [ ] Languages with large words or with letters/accents not frequently used in English. +- [ ] Right-to-left languages. (Even if translation isn’t complete, formatting should still respect the right-to-left layout) +- [ ] iPhone and iPad. +- [ ] Multi-tasking: Split view and Slide over. (iPad) diff --git a/.github/stale.yml b/.github/stale.yml index ed2fbcd7a758..12c48644e3dc 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -31,7 +31,7 @@ markComment: > * It hasn’t been labeled `[Pri] Blocker`, `[Pri] High`, or `good first issue`. Please comment with an update if you believe this issue is still valid or if it can be closed. - This issue will also be reviewed for validity and priority (cc @designsimply). + This issue will also be reviewed for validity and priority during regularly scheduled triage sessions. # Limit the number of actions per hour, from 1-30. Default is 30 limitPerRun: 2 diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml deleted file mode 100644 index ac8f8166cdd0..000000000000 --- a/.github/workflows/danger.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Run Checks with Danger -on: - pull_request: - # Because we have a rule that validates the PR labels, we want it to run - # when the labels change, not only when a PR is opened/reopened or changes - # are pushed to it. - types: [opened, reopened, synchronize, labeled, unlabeled] - -jobs: - danger: - runs-on: ubuntu-latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v2 - - - uses: actions/setup-node@v1 - with: - node_version: 10.x - - - name: Install Yarn - run: npm install -g yarn - - - name: Cache Node Modules - id: cache-node-modules - uses: actions/cache@v1 - with: - path: node_modules - key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }} - restore-keys: ${{ runner.os }}-node_modules - - - name: Yarn Install - if: steps.cache-node-modules.outputs.cache-hit != 'true' - # frozen-lockfile will make the build fail if the lockfile is not there - run: yarn install --frozen-lockfile - - - name: Validate Labels - run: | - yarn run danger ci \ - --dangerfile Automattic/peril-settings/org/pr/label.ts \ - --id pr_labels - - - name: Consistency Checks - run: | - yarn run danger ci \ - --dangerfile Automattic/peril-settings/org/pr/ios-macos.ts \ - --id consistency_checks diff --git a/.gitignore b/.gitignore index 14f178d8ff62..5f8f39b0371a 100644 --- a/.gitignore +++ b/.gitignore @@ -54,48 +54,62 @@ WordPress/WordPress.xcodeproj/project.xcworkspace WordPress/WordPress.xcodeproj/xcuserdata /build .hockey_app_credentials -WordPress/InfoPlist.h -WordPress/InfoPlist-alpha.h -WordPress/InfoPlist-internal.h WordPress/Derived Sources/ # coverage.py */CoverageData/* WordPress/Images.xcassets/AppIcon-Internal.appiconset -# Bundler -/vendor/bundle/ -.bundle/ - # Dependencies -/vendor/ +vendor # Fastlane -Scripts/fastlane/Preview.html -Scripts/fastlane/report.xml -Scripts/fastlane/README.md -Scripts/Preview.html -Scripts/fastlane/test_output/ -Scripts/fastlane/google_cloud_keys.json -Scripts/fastlane/promo_screenshots -/Scripts/default.profraw +fastlane/Preview.html +fastlane/report.xml +fastlane/README.md +Preview.html +fastlane/test_output/ +fastlane/google_cloud_keys.json +fastlane/screenshots +fastlane/jetpack_screenshots +fastlane/promo_screenshots +fastlane/jetpack_promo_screenshots +fastlane/metadata/review_information +default.profraw derived-data/ +# Fastlane former location +Scripts/fastlane/ + +# CI Artifacts Location +Artifacts + # Generated Internal Icons -/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset -/WordPress/Resources/Icons-Internal +WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset +WordPress/Resources/Icons-Internal -# All secrets should be stored under .configure-files +# All encrypted secrets should be stored under .configure-files # Everything without a .enc extension is ignored .configure-files/* !.configure-files/*.enc +# A file external contributors can have locally to provide their own credentials. +# This file is created during the `rake init:oss` task, based on the Secrets-example.swift file. +WordPress/Credentials/Secrets.swift + # Ignoring old locations to be sure they aren't commited accidentally WordPress/Credentials/wpcom_app_credentials WordPress/Credentials/wpcom_alpha_app_credentials WordPress/Credentials/wpcom_internal_app_credentials +# These headers, too, are not longer used or generated by the build process, +# but we wnt to keep ignoring them to avoid them being accidentally committed. +# +# We shall remove this once enough time has passed since they became unused. +# Maybe a couple of years would be enough for most developers to change machine +# and no longer have these files in their local checkout. +WordPress/InfoPlist.h +WordPress/InfoPlist-alpha.h +WordPress/InfoPlist-internal.h + -# We use yarn to manage Danger -# https://yarnpkg.com/ -# https://danger.systems/js/ -yarn-error.log -node_modules +# SwiftLint Remote Config Cache +.swiftlint/RemoteConfigCache diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 000000000000..d163b541fe1b --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,32 @@ +AllCops: + Exclude: + - DerivedData/**/* + - Pods/**/* + - vendor/**/* + NewCops: enable + +Metrics/BlockLength: + # "xfiles" is a standin for `Fast-`, `Pod-`, and `Rake-file` + Exclude: &xfiles + - fastlane/Fastfile + - fastlane/lanes/*.rb + - Podfile + - Rakefile + +Metrics/MethodLength: + Max: 30 + Exclude: *xfiles + +Layout/LineLength: + Max: 180 + Exclude: *xfiles + +Layout/EmptyLines: + Exclude: *xfiles + +Style/AsciiComments: + Exclude: *xfiles + +Naming/FileName: + Exclude: + - fastlane/Matchfile diff --git a/.ruby-version b/.ruby-version index e46a05b1967c..a4dd9dba4fbf 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.6.4 \ No newline at end of file +2.7.4 diff --git a/.swiftlint.yml b/.swiftlint.yml index 587585ad8752..f9cd6c775d45 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,78 +1,2 @@ -# Project configuration -excluded: - - Pods - - Scripts - - vendor - -# Rules -whitelist_rules: - # Colons should be next to the identifier when specifying a type. - - colon - - # There should be no space before and one after any comma. - - comma - - # if,for,while,do statements shouldn't wrap their conditionals in parentheses. - - control_statement - - # Arguments can be omitted when matching enums with associated types if they - # are not used. - - empty_enum_arguments - - # Prefer `() -> ` over `Void -> `. - - empty_parameters - - # MARK comment should be in valid format. - - mark - - # Opening braces should be preceded by a single space and on the same line as - # the declaration. - - opening_brace - - # Files should have a single trailing newline. - - trailing_newline - - # Lines should not have trailing semicolons. - - trailing_semicolon - - # Lines should not have trailing whitespace. - - trailing_whitespace - - - custom_rules - -# Rules configuration - -control_statement: - severity: error - -custom_rules: - - natural_content_alignment: - name: "Natural Content Alignment" - regex: '\.contentHorizontalAlignment(\s*)=(\s*)(\.left|\.right)' - message: "Forcing content alignment left or right can affect the Right-to-Left layout. Use naturalContentHorizontalAlignment instead." - severity: warning - - natural_text_alignment: - name: "Natural Text Alignment" - regex: '\.textAlignment(\s*)=(\s*).left' - message: "Forcing text alignment to left can affect the Right-to-Left layout. Consider setting it to `natural`" - severity: warning - - inverse_text_alignment: - name: "Inverse Text Alignment" - regex: '\.textAlignment(\s*)=(\s*).right' - message: "When forcing text alignment to the right, be sure to handle the Right-to-Left layout case properly, and then silence this warning with this line `// swiftlint:disable:next inverse_text_alignment`" - severity: warning - - localization_comment: - name: "Localization Comment" - regex: 'NSLocalizedString([^,]+,\s+comment:\s*"")' - message: "Localized strings should include a description giving context for how the string is used." - severity: warning - - string_interpolation_in_localized_string: - name: "String Interpolation in Localized String" - regex: 'NSLocalizedString\("[^"]*\\\(\S*\)' - message: "Localized strings must not use interpolated variables. Instead, use `String(format:`" - severity: error +parent_config: https://raw.githubusercontent.com/Automattic/swiftlint-config/0f8ab6388bd8d15a04391825ab125f80cfb90704/.swiftlint.yml +remote_timeout: 10.0 diff --git a/.xcode-version b/.xcode-version new file mode 100644 index 000000000000..6b5bab0678ab --- /dev/null +++ b/.xcode-version @@ -0,0 +1 @@ +14.2 diff --git a/API-Mocks/README.md b/API-Mocks/README.md new file mode 100644 index 000000000000..37281ab91653 --- /dev/null +++ b/API-Mocks/README.md @@ -0,0 +1,41 @@ +Network mocking for testing the WordPress mobile apps based on [WireMock](https://wiremock.org/). + +## Usage + +To start the WireMock server as a standalone process, you can run it with this command: + +``` +./scripts/start.sh 8282 +``` + +Here `8282` is the port to run the server on. It can now be accessed from `http://localhost:8282`. + +## Creating a mock file + +The JSON files used by WireMock to handle requests and are located in `src/main/assets`. To generate one of these files +you're first going to want to set up [Charles Proxy](https://www.charlesproxy.com/) (or similar) to work with your iOS Simulator. + +Here's an example of what a mock might look like: + +```json +{ + "request": { + "urlPattern": "/rest/v1.1/me/", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + // Your response here... + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} +``` + +These files are used to match network requests while the tests are being run. For more on request matching with +WireMock check out [their documentation](http://wiremock.org/docs/request-matching/). diff --git a/API-Mocks/Rakefile b/API-Mocks/Rakefile new file mode 100644 index 000000000000..a7678769bf0a --- /dev/null +++ b/API-Mocks/Rakefile @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'json' +require 'jsonlint' +require 'git' + +desc 'Re-format all JSON files to be pretty-printed' +task :format, [:silent] do |_, args| + args.with_defaults(silent: false) + + for_each_mock_file do |file| + puts "Formatting #{file}..." unless args[:silent] + json = JSON.parse(File.read(file)) + File.write(file, JSON.pretty_generate(json)) + rescue StandardError => e + linter = JsonLint::Linter.new + linter.check(file) + linter.display_errors + + abort("Invalid JSON. See errors above. (#{e})") + end +end + +desc 'Check that all files are properly formatted in CI' +task :checkformat do + repo = Git.open('../.') + + abort('Repo is dirty – unable to verify JSON files are correctly formatted') unless repo.diff.lines.zero? + Rake::Task['format'].invoke(true) + + if repo.diff.lines.positive? + repo.reset_hard + abort('Repo contains unformatted JSON files – run `rake format` then commit your changes.') + end +end + +desc "Ensure all JSON files are valid and don't contain common mistakes" +task :lint do + file_errors = {} + + for_each_mock_file do |file| + # Ensure the file is valid JSON + linter = JsonLint::Linter.new + linter.check(file) + if linter.errors_count.positive? + linter.errors.map { |_key, value| value }.each do |error| + append_error(file, file_errors, "Invalid JSON: #{error}}") + end + end + + ## Ensure there are no references to the actual API location – we should use the mocks + # base URL – this ensures that any requests made based on the contents of other + # requests won't fail. + if File.open(file).each_line.any? { |line| line.include?('public-api.wordpress.com') } + append_error(file, file_errors, 'Contains references to `https://public-api.wordpress.com`. Replace them with `{{request.requestLine.baseUrl}}`.') + end + end + + # Output file errors in a pretty way + puts "There are errors in #{file_errors.count} files:\n" unless file_errors.empty? + file_errors.each do |file, errors| + puts "=== #{file}" + errors.each do |e| + puts " #{e}" + end + end + + abort unless file_errors.empty? + puts 'Lint Complete. Everything looks good.' +end + +def for_each_mock_file + Dir.glob('WordPressMocks/**/*.json').each do |file| + yield(File.expand_path(file)) + end +end + +def append_error(file, errors, message) + errors[file] = [] if errors[file].nil? + errors[file].append(message) +end diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/rest_v11_connect_site_info_self-hosted.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/rest_v11_connect_site_info_self-hosted.json new file mode 100644 index 000000000000..932ca9d777c1 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/rest_v11_connect_site_info_self-hosted.json @@ -0,0 +1,29 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/connect/site-info", + "queryParameters": { + "url": { + "matches": "^http(s)?://((?!wordpress.com).)*$" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "urlAfterRedirects": "{{request.query.url}}", + "exists": true, + "isWordPress": true, + "hasJetpack": true, + "jetpackVersion": "7.3.1", + "isJetpackActive": true, + "isJetpackConnected": false, + "isWordPressDotCom": false + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-system.listMethods.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-system.listMethods.json new file mode 100644 index 000000000000..f80802db4e7d --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-system.listMethods.json @@ -0,0 +1,19 @@ +{ + "request": { + "url": "/xmlrpc.php", + "method": "POST", + "bodyPatterns": [ + { + "matches": ".*system.listMethods.*" + } + ] + }, + "response": { + "status": 200, + "body": "\n\n \n \n \n \n system.multicall\n system.listMethods\n system.getCapabilities\n demo.addTwoNumbers\n demo.sayHello\n pingback.extensions.getPingbacks\n pingback.ping\n mt.publishPost\n mt.getTrackbackPings\n mt.supportedTextFilters\n mt.supportedMethods\n mt.setPostCategories\n mt.getPostCategories\n mt.getRecentPostTitles\n mt.getCategoryList\n metaWeblog.getUsersBlogs\n metaWeblog.deletePost\n metaWeblog.newMediaObject\n metaWeblog.getCategories\n metaWeblog.getRecentPosts\n metaWeblog.getPost\n metaWeblog.editPost\n metaWeblog.newPost\n blogger.deletePost\n blogger.editPost\n blogger.newPost\n blogger.getRecentPosts\n blogger.getPost\n blogger.getUserInfo\n blogger.getUsersBlogs\n wp.restoreRevision\n wp.getRevisions\n wp.getPostTypes\n wp.getPostType\n wp.getPostFormats\n wp.getMediaLibrary\n wp.getMediaItem\n wp.getCommentStatusList\n wp.newComment\n wp.editComment\n wp.deleteComment\n wp.getComments\n wp.getComment\n wp.setOptions\n wp.getOptions\n wp.getPageTemplates\n wp.getPageStatusList\n wp.getPostStatusList\n wp.getCommentCount\n wp.deleteFile\n wp.uploadFile\n wp.suggestCategories\n wp.deleteCategory\n wp.newCategory\n wp.getTags\n wp.getCategories\n wp.getAuthors\n wp.getPageList\n wp.editPage\n wp.deletePage\n wp.newPage\n wp.getPages\n wp.getPage\n wp.editProfile\n wp.getProfile\n wp.getUsers\n wp.getUser\n wp.getTaxonomies\n wp.getTaxonomy\n wp.getTerms\n wp.getTerm\n wp.deleteTerm\n wp.editTerm\n wp.newTerm\n wp.getPosts\n wp.getPost\n wp.deletePost\n wp.editPost\n wp.newPost\n wp.getUsersBlogs\n\n \n \n \n\n", + "headers": { + "Content-Type": "text/xml; charset=UTF-8", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getComments.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getComments.json new file mode 100644 index 000000000000..f5da5a49ec76 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getComments.json @@ -0,0 +1,22 @@ +{ + "request": { + "url": "/xmlrpc.php", + "method": "POST", + "bodyPatterns": [ + { + "matches": ".*wp.getComments.*" + }, + { + "matches": ".*e2eflowtestingmobile.*" + } + ] + }, + "response": { + "status": 200, + "body": "\n\n \n \n \n \n \n date_created_gmt20190215T11:23:34\n user_id0\n comment_id1\n parent0\n statusapprove\n contentHi, this is a comment.\nTo get started with moderating, editing, and deleting comments, please visit the Comments screen in the dashboard.\nCommenter avatars come from <a href="https://gravatar.com">Gravatar</a>.\n link{{request.requestLine.baseUrl}}/2019/02/hello-world/#comment-1\n post_id1\n post_titleHello world!\n authorA WordPress Commenter\n author_urlhttps://wordpress.org/\n author_emailwapuu@wordpress.example\n author_ip\n type\n\n\n \n \n \n\n", + "headers": { + "Content-Type": "text/xml; charset=UTF-8", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getOptions.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getOptions.json new file mode 100644 index 000000000000..ce21c076148c --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getOptions.json @@ -0,0 +1,22 @@ +{ + "request": { + "url": "/xmlrpc.php", + "method": "POST", + "bodyPatterns": [ + { + "matches": ".*wp.getOptions.*" + }, + { + "matches": ".*e2eflowtestingmobile.*" + } + ] + }, + "response": { + "status": 200, + "body": "\n\n \n \n \n \n software_version\n descSoftware Version\n readonly1\n value5.1.1\n\n post_thumbnail\n descPost Thumbnail\n readonly1\n value1\n\n default_comment_status\n descAllow people to post comments on new articles\n readonly0\n valueopen\n\n jetpack_client_id\n descThe Client ID/WP.com Blog ID of this site\n readonly1\n value0\n\n home_url\n descSite Address (URL)\n readonly1\n value{{request.requestLine.baseUrl}}\n\n admin_url\n descThe URL to the admin area\n readonly1\n value{{request.requestLine.baseUrl}}/wp-admin/\n\n login_url\n descLogin Address (URL)\n readonly1\n value{{request.requestLine.baseUrl}}/wp-login.php\n\n blog_title\n descSite Title\n readonly0\n valuee2eflowtestingmobile.mystagingwebsite.com at Pressable\n\n time_zone\n descTime Zone\n readonly0\n value0\n\n\n \n \n \n\n", + "headers": { + "Content-Type": "text/xml; charset=UTF-8", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getPostFormats.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getPostFormats.json new file mode 100644 index 000000000000..1e966d1475ba --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getPostFormats.json @@ -0,0 +1,22 @@ +{ + "request": { + "url": "/xmlrpc.php", + "method": "POST", + "bodyPatterns": [ + { + "matches": ".*wp.getPostFormats.*" + }, + { + "matches": ".*e2eflowtestingmobile.*" + } + ] + }, + "response": { + "status": 200, + "body": "\n\n \n \n \n \n standardStandard\n asideAside\n chatChat\n galleryGallery\n linkLink\n imageImage\n quoteQuote\n statusStatus\n videoVideo\n audioAudio\n\n \n \n \n\n", + "headers": { + "Content-Type": "text/xml; charset=UTF-8", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getProfile.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getProfile.json new file mode 100644 index 000000000000..334adf0df6ed --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getProfile.json @@ -0,0 +1,22 @@ +{ + "request": { + "url": "/xmlrpc.php", + "method": "POST", + "bodyPatterns": [ + { + "matches": ".*wp.getProfile.*" + }, + { + "matches": ".*e2eflowtestingmobile.*" + } + ] + }, + "response": { + "status": 200, + "body": "\n\n \n \n \n \n user_id1\n usernamee2eflowtestingmobile\n first_name\n last_name\n registered20190215T11:23:34\n bio\n emaile2eflowtestingmobile@example.com\n nicknamee2eflowtestingmobile\n nicenamee2eflowtestingmobile\n url\n display_namee2eflowtestingmobile\n roles\n administrator\n\n\n \n \n \n\n", + "headers": { + "Content-Type": "text/xml; charset=UTF-8", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getTerms.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getTerms.json new file mode 100644 index 000000000000..26eb658780da --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getTerms.json @@ -0,0 +1,22 @@ +{ + "request": { + "url": "/xmlrpc.php", + "method": "POST", + "bodyPatterns": [ + { + "matches": ".*wp.getTerms.*" + }, + { + "matches": ".*e2eflowtestingmobile.*" + } + ] + }, + "response": { + "status": 200, + "body": "\n\n \n \n \n \n \n term_id1\n nameUncategorized\n sluguncategorized\n term_group0\n term_taxonomy_id1\n taxonomycategory\n description\n parent0\n count1\n filterraw\n custom_fields\n\n\n\n \n \n \n\n", + "headers": { + "Content-Type": "text/xml; charset=UTF-8", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getUsers.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getUsers.json new file mode 100644 index 000000000000..dfba9f1c9f05 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getUsers.json @@ -0,0 +1,22 @@ +{ + "request": { + "url": "/xmlrpc.php", + "method": "POST", + "bodyPatterns": [ + { + "matches": ".*wp.getUsers.*" + }, + { + "matches": ".*e2eflowtestingmobile.*" + } + ] + }, + "response": { + "status": 200, + "body": "\n\n \n \n \n \n\n \n \n \n\n", + "headers": { + "Content-Type": "text/xml; charset=UTF-8", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getUsersBlogs.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getUsersBlogs.json new file mode 100644 index 000000000000..32f57c684ad4 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/self-hosted/xmlrpcphp-wp.getUsersBlogs.json @@ -0,0 +1,22 @@ +{ + "request": { + "url": "/xmlrpc.php", + "method": "POST", + "bodyPatterns": [ + { + "matches": ".*wp.getUsersBlogs.*" + }, + { + "matches": ".*e2eflowtestingmobile.*" + } + ] + }, + "response": { + "status": 200, + "body": "\n\n \n \n \n \n \n isAdmin1\n url{{request.requestLine.baseUrl}}/\n blogid1\n blogNamee2eflowtestingmobile.mystagingwebsite.com at Pressable\n xmlrpc{{request.requestLine.baseUrl}}/xmlrpc.php\n\n\n \n \n \n\n", + "headers": { + "Content-Type": "text/xml; charset=UTF-8", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/activity/wpcom_v2_site_activity.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/activity/wpcom_v2_site_activity.json new file mode 100644 index 000000000000..809cc048b6c2 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/activity/wpcom_v2_site_activity.json @@ -0,0 +1,737 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/wpcom/v2/sites/.*/activity(\\?|/|$).*" + }, + "response": { + "status": 200, + "jsonBody": { + "@context": "https://www.w3.org/ns/activitystreams", + "summary": "Activity log", + "type": "OrderedCollection", + "totalItems": 300, + "page": 1, + "totalPages": 15, + "itemsPerPage": 20, + "id": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/185124945/activity?number=20&page=1", + "nextAfter": [ + 1620221875793 + ], + "oldestItemTs": 1604484582203, + "first": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/185124945/activity?number=20&page=1", + "last": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/185124945/activity?number=20&page=15", + "current": { + "type": "OrderedCollectionPage", + "id": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/185124945/activity?number=20&page=1", + "prev": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/185124945/activity?number=20&page=0", + "next": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/185124945/activity?number=20&page=2", + "totalItems": 20, + "orderedItems": [ + { + "summary": "Backup and scan complete", + "content": { + "text": "4 plugins, 2 themes, 3 uploads, 2 posts" + }, + "name": "rewind__backup_complete_full", + "actor": { + "type": "Application", + "name": "Jetpack" + }, + "type": "Announce", + "published": "{{now format='yyyy-MM-dd'}}T{{now format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": true, + "rewind_id": "1620282190.798", + "gridicon": "cloud", + "status": "success", + "activity_id": "AXlAWDctEdwdUqNpZHlC", + "object": { + "type": "Backup", + "backup_type": "full", + "rewind_id": "1620282190.798", + "backup_stats": "{\"themes\":{\"count\":2,\"list\":[\"twentynineteen\",\"twentytwenty\"]},\"plugins\":{\"count\":4,\"list\":[\"akismet\",\"calendar\",\"jetpack\",\"jetpack-threat-tester-master\"]},\"uploads\":{\"count\":3,\"images\":2,\"movies\":0,\"audio\":0,\"archives\":0},\"tables\":{\"wp_calendar\":{\"rows\":0},\"wp_calendar_categories\":{\"rows\":1},\"wp_calendar_config\":{\"rows\":10},\"wp_commentmeta\":{\"rows\":0},\"wp_comments\":{\"rows\":1},\"wp_links\":{\"rows\":0},\"wp_options\":{\"rows\":221},\"wp_postmeta\":{\"rows\":6},\"wp_posts\":{\"rows\":6,\"published\":2},\"wp_term_relationships\":{\"rows\":2},\"wp_term_taxonomy\":{\"rows\":1},\"wp_termmeta\":{\"rows\":0},\"wp_terms\":{\"rows\":1},\"wp_usermeta\":{\"rows\":48},\"wp_users\":{\"rows\":3}},\"prefix\":\"wp_\",\"wp_version\":\"5.7.1\"}", + "backup_period": 1620282173 + }, + "is_discarded": false + }, + { + "summary": "Threat resolved", + "content": { + "text": "The threat known as URL_BlockList_1 is no longer present in wp_posts: File is now clean", + "ranges": [ + { + "type": "em", + "indices": [ + 20, + 35 + ], + "id": "3", + "parent": null + } + ] + }, + "name": "rewind__scan_result_fixed", + "actor": { + "type": "Application", + "name": "Jetpack" + }, + "type": "Announce", + "published": "{{now format='yyyy-MM-dd'}}T{{now format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": false, + "rewind_id": "1620282185.3564", + "gridicon": "checkmark", + "status": "success", + "activity_id": "AXlAWCuCHPpm8Jl5QV_X", + "object": { + "type": "Security", + "file": "wp_posts", + "signature": "URL_BlockList_1", + "fixed_ts": 1620282185208, + "reason": "File is now clean" + }, + "is_discarded": false + }, + { + "summary": "Threat resolved", + "content": { + "text": "The threat known as EICAR_AV_Test is no longer present in /htdocs/wp-content/uploads/jptt_eicar.php: File is now clean", + "ranges": [ + { + "type": "em", + "indices": [ + 20, + 33 + ], + "id": "7", + "parent": null + } + ] + }, + "name": "rewind__scan_result_fixed", + "actor": { + "type": "Application", + "name": "Jetpack" + }, + "type": "Announce", + "published": "{{now format='yyyy-MM-dd'}}T{{now format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": false, + "rewind_id": "1620282178.7763", + "gridicon": "checkmark", + "status": "success", + "activity_id": "AXlAWAOxC0Gvh1USAs-0", + "object": { + "type": "Security", + "file": "/htdocs/wp-content/uploads/jptt_eicar.php", + "signature": "EICAR_AV_Test", + "fixed_ts": 1620282178649, + "reason": "File is now clean" + }, + "is_discarded": false + }, + { + "summary": "Setting changed", + "content": { + "text": "Default post format changed to \"Standard\"", + "ranges": [ + { + "type": "em", + "indices": [ + 32, + 40 + ], + "id": "11", + "parent": null + } + ] + }, + "name": "setting__changed_default_post_format", + "actor": { + "type": "Person", + "name": "emilylaguna", + "external_user_id": 2, + "wpcom_user_id": 175698209, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/6d1fe505117c34cd81af4b6572b55f56?s=96&d=mm&r=g", + "width": 96, + "height": 96 + }, + "role": "administrator" + }, + "type": "Announce", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": true, + "rewind_id": "1620230363.1081", + "gridicon": "cog", + "status": null, + "activity_id": "AXk9QWGUekSREqZdrE0M", + "is_discarded": false + }, + { + "summary": "Setting changed", + "content": { + "text": "Timezone changed to \"UTC+0\"", + "ranges": [ + { + "type": "em", + "indices": [ + 21, + 26 + ], + "id": "15", + "parent": null + } + ] + }, + "name": "setting__changed_timezone", + "actor": { + "type": "Person", + "name": "pressable-jetpack-complete", + "external_user_id": 1, + "wpcom_user_id": 196051067, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/9eac9665a4c900eeb9ba5ceb211b0f62?s=96&d=mm&r=g", + "width": 96, + "height": 96 + }, + "role": "administrator" + }, + "type": "Announce", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": false, + "rewind_id": "1620226323.2004", + "gridicon": "cog", + "status": null, + "activity_id": "AXk9A7jxC0Gvh1USarVK", + "is_discarded": false + }, + { + "summary": "Plugin updated", + "content": { + "text": "Jetpack by WordPress.com 9.7", + "ranges": [ + { + "type": "plugin", + "indices": [ + 0, + 28 + ], + "id": "18", + "parent": null, + "slug": "jetpack", + "version": "9.7", + "site_slug": "pressable-jetpack-complete.mystagingwebsite.com" + } + ] + }, + "name": "plugin__updated", + "actor": { + "type": "Person", + "name": "pressable-jetpack-complete", + "external_user_id": 1, + "wpcom_user_id": 196051067, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/9eac9665a4c900eeb9ba5ceb211b0f62?s=96&d=mm&r=g", + "width": 96, + "height": 96 + }, + "role": "administrator" + }, + "type": "Update", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": false, + "rewind_id": "1620226323.198", + "gridicon": "plugins", + "status": "success", + "activity_id": "AXk9A7jxC0Gvh1USarVA", + "items": [ + { + "type": "Plugin", + "name": "Jetpack by WordPress.com", + "object_version": "9.7", + "object_slug": "jetpack/jetpack.php", + "object_previous_version": "9.6.1" + } + ], + "totalItems": 1, + "is_discarded": false + }, + { + "summary": "Setting changed", + "content": { + "text": "Site icon changed (icon.png)", + "ranges": [ + { + "url": "https://wordpress.com/media/185124945/8", + "indices": [ + 0, + 28 + ], + "id": 8, + "parent": null, + "type": "a", + "site_id": 185124945, + "section": "media", + "intent": "edit", + "context": "single" + } + ] + }, + "name": "setting__changed_site_icon", + "actor": { + "type": "Person", + "name": "pressable-jetpack-complete", + "external_user_id": 1, + "wpcom_user_id": 196051067, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/9eac9665a4c900eeb9ba5ceb211b0f62?s=96&d=mm&r=g", + "width": 96, + "height": 96 + }, + "role": "administrator" + }, + "type": "Announce", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": false, + "rewind_id": "1620226281.4911", + "gridicon": "cog", + "status": null, + "activity_id": "AXk9AzWAHPpm8Jl5qY7t", + "image": { + "available": true, + "type": "Image", + "name": "Site icon changed (icon.png)", + "url": "https://i2.wp.com/pressable-jetpack-complete.mystagingwebsite.com/wp-content/uploads/2021/05/icon.png?ssl=1", + "thumbnail_url": "https://i2.wp.com/pressable-jetpack-complete.mystagingwebsite.com/wp-content/uploads/2021/05/icon.png?fit=96%2C96&ssl=1" + }, + "is_discarded": false + }, + { + "summary": "Setting changed", + "content": { + "text": "Site description was changed from \"Just another WordPress site\" to \"Site with everything enabled\"", + "ranges": [ + { + "type": "em", + "indices": [ + 35, + 62 + ], + "id": "23", + "parent": null + }, + { + "type": "em", + "indices": [ + 68, + 96 + ], + "id": "26", + "parent": null + } + ] + }, + "name": "setting__changed_blogdescription", + "actor": { + "type": "Person", + "name": "pressable-jetpack-complete", + "external_user_id": 1, + "wpcom_user_id": 196051067, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/9eac9665a4c900eeb9ba5ceb211b0f62?s=96&d=mm&r=g", + "width": 96, + "height": 96 + }, + "role": "administrator" + }, + "type": "Announce", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": false, + "rewind_id": "1620226281.4705", + "gridicon": "cog", + "status": null, + "activity_id": "AXk9Ay97ekSREqZdnq2n", + "is_discarded": false + }, + { + "summary": "Setting changed", + "content": { + "text": "Site title was changed from \"My WordPress Site\" to \"Jetpack - Complete\"", + "ranges": [ + { + "type": "em", + "indices": [ + 29, + 46 + ], + "id": "30", + "parent": null + }, + { + "type": "em", + "indices": [ + 52, + 70 + ], + "id": "33", + "parent": null + } + ] + }, + "name": "setting__changed_blogname", + "actor": { + "type": "Person", + "name": "pressable-jetpack-complete", + "external_user_id": 1, + "wpcom_user_id": 196051067, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/9eac9665a4c900eeb9ba5ceb211b0f62?s=96&d=mm&r=g", + "width": 96, + "height": 96 + }, + "role": "administrator" + }, + "type": "Announce", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": true, + "rewind_id": "1620226281.462", + "gridicon": "cog", + "status": null, + "activity_id": "AXk9Ay97ekSREqZdnq2e", + "is_discarded": false + }, + { + "summary": "Image uploaded", + "content": { + "text": "icon.png", + "ranges": [ + { + "url": "https://wordpress.com/media/185124945/8", + "indices": [ + 0, + 8 + ], + "id": 8, + "parent": null, + "type": "a", + "site_id": 185124945, + "section": "media", + "intent": "edit", + "context": "single" + } + ] + }, + "name": "attachment__uploaded", + "actor": { + "type": "Person", + "name": "pressable-jetpack-complete", + "external_user_id": 1, + "wpcom_user_id": 196051067, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/9eac9665a4c900eeb9ba5ceb211b0f62?s=96&d=mm&r=g", + "width": 96, + "height": 96 + }, + "role": "administrator" + }, + "type": "Announce", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": true, + "rewind_id": "1620226276.8479", + "gridicon": "image", + "status": "success", + "activity_id": "AXk9AwVyC0Gvh1USao1z", + "image": { + "available": true, + "type": "Image", + "name": "icon.png", + "url": "https://i2.wp.com/pressable-jetpack-complete.mystagingwebsite.com/wp-content/uploads/2021/05/icon.png?ssl=1", + "thumbnail_url": "https://i2.wp.com/pressable-jetpack-complete.mystagingwebsite.com/wp-content/uploads/2021/05/icon.png?fit=96%2C96&ssl=1", + "medium_url": "https://i2.wp.com/pressable-jetpack-complete.mystagingwebsite.com/wp-content/uploads/2021/05/icon.png?w=846&ssl=1" + }, + "is_discarded": false + }, + { + "summary": "Login succeeded", + "content": { + "text": "pressable-jetpack-complete successfully logged in from IP Address 73.159.235.239", + "ranges": [ + { + "url": "https://wordpress.com/people/edit/185124945/pressable-jetpack-complete", + "indices": [ + 0, + 26 + ], + "id": 1, + "parent": null, + "type": "a", + "site_id": 185124945, + "section": "user", + "intent": "edit" + } + ] + }, + "name": "user__login", + "actor": { + "type": "Person", + "name": "pressable-jetpack-complete", + "external_user_id": 1, + "wpcom_user_id": 196051067, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/9eac9665a4c900eeb9ba5ceb211b0f62?s=96&d=mm&r=g", + "width": 96, + "height": 96 + }, + "role": "administrator" + }, + "type": "Join", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": false, + "rewind_id": "1620226253.9047", + "gridicon": "lock", + "status": null, + "activity_id": "AXk9ArAGC0Gvh1USan6F", + "object": { + "type": "Person", + "name": "pressable-jetpack-complete", + "external_user_id": 1, + "wpcom_user_id": 196051067 + }, + "is_discarded": false + }, + { + "summary": "Backup and scan complete", + "content": { + "text": "4 plugins, 2 themes, 1 upload, 2 posts" + }, + "name": "rewind__backup_complete_full", + "actor": { + "type": "Application", + "name": "Jetpack" + }, + "type": "Announce", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": true, + "rewind_id": "1620223139.141", + "gridicon": "cloud", + "status": "success", + "activity_id": "AXk80zD9HPpm8Jl5nqOO", + "object": { + "type": "Backup", + "backup_type": "full", + "rewind_id": "1620223139.141", + "backup_stats": "{\"themes\":{\"count\":2,\"list\":[\"twentynineteen\",\"twentytwenty\"]},\"plugins\":{\"count\":4,\"list\":[\"akismet\",\"calendar\",\"jetpack\",\"jetpack-threat-tester-master\"]},\"uploads\":{\"count\":1,\"images\":0,\"movies\":0,\"audio\":0,\"archives\":0},\"tables\":{\"wp_calendar\":{\"rows\":0},\"wp_calendar_categories\":{\"rows\":1},\"wp_calendar_config\":{\"rows\":10},\"wp_commentmeta\":{\"rows\":0},\"wp_comments\":{\"rows\":1},\"wp_links\":{\"rows\":0},\"wp_options\":{\"rows\":350},\"wp_postmeta\":{\"rows\":2},\"wp_posts\":{\"rows\":4,\"published\":2},\"wp_term_relationships\":{\"rows\":2},\"wp_term_taxonomy\":{\"rows\":1},\"wp_termmeta\":{\"rows\":0},\"wp_terms\":{\"rows\":1},\"wp_usermeta\":{\"rows\":48},\"wp_users\":{\"rows\":3}},\"prefix\":\"wp_\",\"wp_version\":\"5.7.1\"}", + "backup_period": 1620223121 + }, + "is_discarded": false + }, + { + "summary": "Backup and scan complete", + "content": { + "text": "4 plugins, 2 themes, 1 upload, 2 posts" + }, + "name": "rewind__backup_complete_full", + "actor": { + "type": "Application", + "name": "Jetpack" + }, + "type": "Announce", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": true, + "rewind_id": "1620223112.797", + "gridicon": "cloud", + "status": "success", + "activity_id": "AXk80rw-C0Gvh1USX6QQ", + "object": { + "type": "Backup", + "backup_type": "full", + "rewind_id": "1620223112.797", + "backup_stats": "{\"themes\":{\"count\":2,\"list\":[\"twentynineteen\",\"twentytwenty\"]},\"plugins\":{\"count\":4,\"list\":[\"akismet\",\"calendar\",\"jetpack\",\"jetpack-threat-tester-master\"]},\"uploads\":{\"count\":1,\"images\":0,\"movies\":0,\"audio\":0,\"archives\":0},\"tables\":{\"wp_calendar\":{\"rows\":0},\"wp_calendar_categories\":{\"rows\":1},\"wp_calendar_config\":{\"rows\":10},\"wp_commentmeta\":{\"rows\":0},\"wp_comments\":{\"rows\":1},\"wp_links\":{\"rows\":0},\"wp_options\":{\"rows\":350},\"wp_postmeta\":{\"rows\":2},\"wp_posts\":{\"rows\":4,\"published\":2},\"wp_term_relationships\":{\"rows\":2},\"wp_term_taxonomy\":{\"rows\":1},\"wp_termmeta\":{\"rows\":0},\"wp_terms\":{\"rows\":1},\"wp_usermeta\":{\"rows\":48},\"wp_users\":{\"rows\":3}},\"prefix\":\"wp_\",\"wp_version\":\"5.7.1\"}", + "backup_period": 1620223090 + }, + "is_discarded": false + }, + { + "summary": "Threat resolved", + "content": { + "text": "The extension in /htdocs/wp-content/plugins/calendar/calendar.php is no longer vulnerable: Applied threat fixer" + }, + "name": "rewind__scan_result_fixed", + "actor": { + "type": "Application", + "name": "Jetpack" + }, + "type": "Announce", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": false, + "rewind_id": "1620223082.8809", + "gridicon": "checkmark", + "status": "success", + "activity_id": "AXk80lkJekSREqZdlA-f", + "object": { + "type": "Security", + "file": "/htdocs/wp-content/plugins/calendar/calendar.php", + "signature": "Vulnerable.WP.Extension", + "fixed_ts": 1620223082559, + "reason": "Applied threat fixer" + }, + "is_discarded": false + }, + { + "summary": "Plugin updated", + "content": { + "text": "Calendar 1.3.14", + "ranges": [ + { + "type": "plugin", + "indices": [ + 0, + 15 + ], + "id": "44", + "parent": null, + "slug": "calendar", + "version": "1.3.14", + "site_slug": "pressable-jetpack-complete.mystagingwebsite.com" + } + ] + }, + "name": "plugin__updated", + "actor": { + "type": "Person", + "name": "", + "external_user_id": 0, + "wpcom_user_id": 0, + "icon": { + "type": "Image", + "url": "https://secure.gravatar.com/avatar/?s=96&d=mm&r=g", + "width": 96, + "height": 96 + }, + "role": "" + }, + "type": "Update", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": false, + "rewind_id": "1620223082.5373", + "gridicon": "plugins", + "status": "success", + "activity_id": "AXk87CWqEdwdUqNpxIhq", + "items": [ + { + "type": "Plugin", + "name": "Calendar", + "object_version": "1.3.14", + "object_slug": "calendar/calendar.php", + "object_previous_version": "1.3.1" + } + ], + "totalItems": 1, + "is_discarded": false + }, + { + "summary": "Backup and scan complete", + "content": { + "text": "4 plugins, 2 themes, 1 upload, 2 posts" + }, + "name": "rewind__backup_complete_full", + "actor": { + "type": "Application", + "name": "Jetpack" + }, + "type": "Announce", + "published": "{{now offset='-1 days' format='yyyy-MM-dd'}}T{{now offset='-1 days' format='HH:mm:ss.SSSZ'}}", + "generator": { + "jetpack_version": 9.7, + "blog_id": 185124945 + }, + "is_rewindable": true, + "rewind_id": "1620222944.83", + "gridicon": "cloud", + "status": "success", + "activity_id": "AXk80DZxEdwdUqNpvgLh", + "object": { + "type": "Backup", + "backup_type": "full", + "rewind_id": "1620222944.83", + "backup_stats": "{\"themes\":{\"count\":2,\"list\":[\"twentynineteen\",\"twentytwenty\"]},\"plugins\":{\"count\":4,\"list\":[\"akismet\",\"calendar\",\"jetpack\",\"jetpack-threat-tester-master\"]},\"uploads\":{\"count\":1,\"images\":0,\"movies\":0,\"audio\":0,\"archives\":0},\"tables\":{\"wp_calendar\":{\"rows\":0},\"wp_calendar_categories\":{\"rows\":1},\"wp_commentmeta\":{\"rows\":0},\"wp_calendar_config\":{\"rows\":10},\"wp_comments\":{\"rows\":1},\"wp_links\":{\"rows\":0},\"wp_options\":{\"rows\":334},\"wp_postmeta\":{\"rows\":2},\"wp_posts\":{\"rows\":4,\"published\":2},\"wp_term_relationships\":{\"rows\":2},\"wp_term_taxonomy\":{\"rows\":1},\"wp_termmeta\":{\"rows\":0},\"wp_terms\":{\"rows\":1},\"wp_usermeta\":{\"rows\":48},\"wp_users\":{\"rows\":3}},\"prefix\":\"wp_\",\"wp_version\":\"5.7.1\"}", + "backup_period": 1620222920 + }, + "is_discarded": false + } + ] + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/auth-options.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/auth-options.json new file mode 100644 index 000000000000..2a6fea975cb3 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/auth-options.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/users/.*/auth-options.*" + }, + "response": { + "status": 200, + "jsonBody": { + "passwordless": false, + "email_verified": true + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/is-available_email_available_json.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/is-available_email_available_json.json new file mode 100644 index 000000000000..fc4787daec07 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/is-available_email_available_json.json @@ -0,0 +1,24 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/is-available/email(/)?($|\\?.*)", + "queryParameters": { + "format": { + "equalTo": "json" + }, + "q": { + "matches": "^e2eflowsignuptestingmobile@example.com$" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "available": true + }, + "headers": { + "Content-Type": "text/javascript", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/is-available_email_available_text.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/is-available_email_available_text.json new file mode 100644 index 000000000000..313ea1417080 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/is-available_email_available_text.json @@ -0,0 +1,22 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/is-available/email(/)?($|\\?.*)", + "queryParameters": { + "format": { + "absent": true + }, + "q": { + "matches": "^e2eflowsignuptestingmobile@example.com$" + } + } + }, + "response": { + "status": 200, + "body": true, + "headers": { + "Content-Type": "text/javascript", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/is-available_email_not_available.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/is-available_email_not_available.json new file mode 100644 index 000000000000..af6a5e156453 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/is-available_email_not_available.json @@ -0,0 +1,23 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/is-available/email(/)?($|\\?.*)", + "queryParameters": { + "q": { + "matches": "^(?!e2eflowsignuptestingmobile@example.com).*$" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "error": "taken", + "message": "Choose a different email address. This one is not available.", + "status": "error" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/magic_link.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/magic_link.json new file mode 100644 index 000000000000..07a364843a58 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/magic_link.json @@ -0,0 +1,15 @@ +{ + "scenarioName": "auth", + "requiredScenarioState": "logged-in", + "request": { + "method": "GET", + "urlPath": "/magic-link" + }, + "response": { + "status": 302, + "headers": { + "Content-Type": "text/html;charset=utf-8", + "Location": "{{request.query.scheme}}://magic-login?token=valid_token" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/oauth2_token-error.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/oauth2_token-error.json new file mode 100644 index 000000000000..7a5cec70bce9 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/oauth2_token-error.json @@ -0,0 +1,28 @@ +{ + "request": { + "method": "POST", + "urlPath": "/oauth2/token", + "bodyPatterns": [ + { + "matches": ".*username=[^&]+.*" + }, + { + "matches": ".*password=invalidPswd(&.*|$)" + }, + { + "matches": ".*client_secret=.*" + } + ] + }, + "response": { + "status": 400, + "jsonBody": { + "error": "invalid_request", + "error_description": "Incorrect username or password." + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/oauth2_token.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/oauth2_token.json new file mode 100644 index 000000000000..d97a12bd57e1 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/oauth2_token.json @@ -0,0 +1,31 @@ +{ + "request": { + "method": "POST", + "urlPath": "/oauth2/token", + "bodyPatterns": [ + { + "matches": ".*username=[^&]+.*" + }, + { + "matches": ".*password=((?!invalidPswd)[^&])+(&.*|$)" + }, + { + "matches": ".*client_secret=.*" + } + ] + }, + "response": { + "status": 200, + "jsonBody": { + "access_token": "valid_token", + "token_type": "bearer", + "blog_id": "0", + "blog_url": null, + "scope": "global" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/rest_v11_auth_send-signup-email.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/rest_v11_auth_send-signup-email.json new file mode 100644 index 000000000000..1b0ac2b926b6 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/rest_v11_auth_send-signup-email.json @@ -0,0 +1,19 @@ +{ + "scenarioName": "auth", + "newScenarioState": "signed-up", + "request": { + "urlPattern": "/rest/v1.1/auth/send-signup-email.*", + "method": "POST" + }, + "response": { + "status": 200, + "jsonBody": { + "success": true + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/rest_v13_auth_send-login-email.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/rest_v13_auth_send-login-email.json new file mode 100644 index 000000000000..89c6a6a299d0 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/auth/rest_v13_auth_send-login-email.json @@ -0,0 +1,20 @@ +{ + "scenarioName": "auth", + "newScenarioState": "logged-in", + "request": { + "urlPattern": "/rest/v1.[1-3]/auth/send-login-email.*", + "method": "POST" + }, + "response": { + "status": 200, + "jsonBody": { + "success": true, + "new_user": false + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/blocks/blocks.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/blocks/blocks.json new file mode 100644 index 000000000000..2f6090f174ae --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/blocks/blocks.json @@ -0,0 +1,17 @@ +{ + "request": { + "urlPattern": "/wp/v2/sites/.*/blocks.*", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": [ + + ], + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/dashboard/dashboard.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/dashboard/dashboard.json new file mode 100644 index 000000000000..7c277adb285a --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/dashboard/dashboard.json @@ -0,0 +1,30 @@ +{ + "request": { + "method": "GET", + "urlPath": "/wpcom/v2/sites/106707880/dashboard/cards-data/", + "queryParameters": { + "_locale": { + "matches": "(.*)" + }, + "cards": { + "equalTo": "todays_stats,posts,pages" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "todays_stats": { + "views": 56, + "visitors": 44, + "likes": 19, + "comments": 0 + }, + "posts": { + "has_published": true, + "draft": [], + "scheduled": [] + } + } + } + } \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/dashboard/domains.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/dashboard/domains.json new file mode 100644 index 000000000000..d7eb8b9a22dc --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/dashboard/domains.json @@ -0,0 +1,85 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/domains", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "domains": [ + { + "primary_domain": true, + "blog_id": 106707880, + "subscription_id": null, + "can_manage_dns_records": false, + "can_manage_name_servers": false, + "can_update_contact_info": false, + "cannot_manage_dns_records_reason": null, + "cannot_manage_name_servers_reason": null, + "cannot_update_contact_info_reason": null, + "connection_mode": null, + "current_user_can_add_email": false, + "current_user_can_create_site_from_domain_only": true, + "current_user_cannot_add_email_reason": null, + "current_user_can_manage": true, + "current_user_is_owner": null, + "can_set_as_primary": true, + "domain": "testwebsite.wordpress.com", + "domain_notice_states": null, + "supports_domain_connect": null, + "email_forwards_count": 0, + "expiry": false, + "expiry_soon": false, + "expired": false, + "auto_renewing": false, + "pending_registration": false, + "pending_registration_time": "", + "has_registration": false, + "has_email_forward_dns_records": null, + "points_to_wpcom": true, + "privacy_available": false, + "private_domain": false, + "partner_domain": false, + "wpcom_domain": true, + "has_zone": false, + "is_renewable": false, + "is_redeemable": false, + "is_subdomain": true, + "is_eligible_for_inbound_transfer": false, + "is_locked": false, + "is_wpcom_staging_domain": false, + "transfer_away_eligible_at": null, + "type": "wpcom", + "registration_date": "", + "auto_renewal_date": "", + "google_apps_subscription": null, + "titan_mail_subscription": null, + "pending_whois_update": false, + "tld_maintenance_end_time": null, + "ssl_status": null, + "supports_gdpr_consent_management": false, + "supports_transfer_approval": false, + "domain_registration_agreement_url": "", + "contact_info_disclosure_available": false, + "contact_info_disclosed": false, + "renewable_until": null, + "redeemable_until": null, + "bundled_plan_subscription_id": null, + "product_slug": null, + "owner": "", + "pending_renewal": false, + "aftermarket_auction": false, + "aftermarket_auction_start": null, + "aftermarket_auction_end": null, + "nominet_pending_contact_verification_request": false, + "nominet_domain_suspended": false + } + ] + } + } + } diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/devices/rest_v1_devices_id_delete.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/devices/rest_v1_devices_id_delete.json new file mode 100644 index 000000000000..33207fc89216 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/devices/rest_v1_devices_id_delete.json @@ -0,0 +1,17 @@ +{ + "request": { + "urlPattern": "/rest/v1/devices/[0-9]+/delete.*", + "method": "POST" + }, + "response": { + "status": 200, + "jsonBody": { + "success": true + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/devices/rest_v1_devices_new.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/devices/rest_v1_devices_new.json new file mode 100644 index 000000000000..c0927043e8da --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/devices/rest_v1_devices_new.json @@ -0,0 +1,65 @@ +{ + "request": { + "urlPath": "/rest/v1/devices/new", + "method": "POST" + }, + "response": { + "status": 200, + "jsonBody": { + "ID": "123", + "settings": { + "comments": { + "desc": "Comments", + "long_desc": "Someone comments one of my posts", + "value": "1" + }, + "reblogs": { + "desc": "Reblogs", + "value": "1", + "long_desc": "Someone reblogs one of my posts" + }, + "follows": { + "desc": "Follows", + "long_desc": "Someone follows my blog", + "value": "1" + }, + "post_likes": { + "desc": "Likes", + "long_desc": "Someone likes one of my posts", + "value": "1" + }, + "achievements": { + "desc": "Achievements", + "long_desc": "I've reached an achievement on my blog", + "value": "1" + }, + "muted_blogs": { + "desc": "Muted Blogs", + "long_desc": "Blogs I don't want receive push notifications", + "value": [ + { + "value": 0, + "blog_id": "1", + "url": "https://infocusphotographers.com/", + "blog_name": "Mobile E2E Testing" + } + ] + }, + "mute_until": { + "desc": "Mute Push Notifications", + "value": "0" + }, + "other": { + "desc": "Other", + "long_desc": "Other Notifications!", + "value": "1" + } + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/feature-announcements.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/feature-announcements.json new file mode 100644 index 000000000000..a2e77b3fca7a --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/feature-announcements.json @@ -0,0 +1,30 @@ +{ + "request": { + "method": "GET", + "urlPath": "/wpcom/v2/mobile/feature-announcements/", + "queryParameters": { + "_locale": { + "matches": "(.*)" + }, + "app_id": { + "matches": "(.*)" + }, + "app_version": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "announcements": [ + + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/feature-flags.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/feature-flags.json new file mode 100644 index 000000000000..7b294b57f2b0 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/feature-flags.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "GET", + "urlPath": "/wpcom/v2/mobile/feature-flags", + "queryParameters": { + "_locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "dashboard_card_domain": true + } + } +} diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/jetpack/wpcom_v2_sites_1185119569_rewind_capabilities.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/jetpack/wpcom_v2_sites_1185119569_rewind_capabilities.json new file mode 100644 index 000000000000..5890f5c06779 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/jetpack/wpcom_v2_sites_1185119569_rewind_capabilities.json @@ -0,0 +1,19 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/wpcom/v2/sites/185119569/rewind/capabilities(/)?($|\\?.*)" + }, + "response": { + "status": 200, + "jsonBody": { + "capabilities": [ + "scan" + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/jetpack/wpcom_v2_sites_185119032_rewind_capabilities.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/jetpack/wpcom_v2_sites_185119032_rewind_capabilities.json new file mode 100644 index 000000000000..49586420d620 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/jetpack/wpcom_v2_sites_185119032_rewind_capabilities.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/wpcom/v2/sites/185119032/rewind/capabilities(/)?($|\\?.*)" + }, + "response": { + "status": 200, + "jsonBody": { + "capabilities": [ + "backup", + "restore", + "backup-daily" + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/jetpack/wpcom_v2_sites_185124945_rewind_capabilities.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/jetpack/wpcom_v2_sites_185124945_rewind_capabilities.json new file mode 100644 index 000000000000..036ec18bdff3 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/jetpack/wpcom_v2_sites_185124945_rewind_capabilities.json @@ -0,0 +1,23 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/wpcom/v2/sites/185124945/rewind/capabilities(/)?($|\\?.*)" + }, + "response": { + "status": 200, + "jsonBody": { + "capabilities": [ + "backup", + "restore", + "backup-realtime", + "scan", + "antispam" + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me.json new file mode 100644 index 000000000000..6468fef0b2aa --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me.json @@ -0,0 +1,55 @@ +{ + "scenarioName": "auth", + "request": { + "urlPattern": "/rest/v1.1/me(/)?($|\\?.*)", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 152748359, + "display_name": "e2eflowtestingmobile", + "username": "e2eflowtestingmobile", + "email": "main.ee0zglcj@mailosaur.io", + "primary_blog": 106707880, + "primary_blog_url": "https://infocusphotographers.com", + "primary_blog_is_jetpack": false, + "language": "en", + "locale_variant": "", + "token_site_id": false, + "token_scope": [ + "global" + ], + "avatar_URL": "https://en.gravatar.com/avatar/35029b2103460109f574c38dfeea5f3f?s=96&d=identicon", + "profile_URL": "http://en.gravatar.com/e2eflowtestingmobile", + "verified": true, + "email_verified": true, + "date": "2019-02-14T09:49:17+00:00", + "site_count": 1, + "visible_site_count": 1, + "has_unseen_notes": false, + "newest_note_type": "", + "phone_account": false, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/me", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/me/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/5836086", + "flags": "{{request.requestLine.baseUrl}}/rest/v1.1/me/flags" + } + }, + "is_valid_google_apps_country": true, + "user_ip_country_code": "IE", + "logout_URL": "https://wordpress.com/wp-login.php?action=logout&_wpnonce=2887f970c8&redirect_to=https%3A%2F%2Fwordpress.com%2F", + "social_login_connections": null, + "social_signup_service": null, + "abtests": { + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_settings_get.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_settings_get.json new file mode 100644 index 000000000000..9c009178c869 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_settings_get.json @@ -0,0 +1,59 @@ +{ + "request": { + "urlPattern": "/rest/v1.1/me/settings.*", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "enable_translator": false, + "surprise_me": false, + "post_post_flag": false, + "holidaysnow": false, + "user_login": "e2eflowtestingmobile", + "password": "", + "display_name": "e2eflowtestingmobile", + "first_name": "", + "last_name": "", + "description": "", + "user_email": "e2eflowtestingmobile@example.com", + "user_email_change_pending": false, + "new_user_email": "", + "user_URL": "https://infocusphotographers.com", + "language": "en", + "avatar_URL": "https://en.gravatar.com/avatar/35029b2103460109f574c38dfeea5f3f?s=200&d=mm", + "primary_site_ID": 106707880, + "comment_like_notification": true, + "mentions_notification": true, + "subscription_delivery_email_default": "instantly", + "subscription_delivery_jabber_default": false, + "subscription_delivery_mail_option": "html", + "subscription_delivery_day": 4, + "subscription_delivery_hour": 8, + "subscription_delivery_email_blocked": false, + "two_step_enabled": false, + "two_step_sms_enabled": false, + "two_step_backup_codes_printed": false, + "two_step_sms_country": "", + "two_step_sms_phone_number": "", + "user_login_can_be_changed": true, + "calypso_preferences": { + "is_new_reader": true, + "recentSites": [ + 1 + ] + }, + "jetpack_connect": [ + + ], + "is_desktop_app_user": false, + "locale_variant": false, + "tracks_opt_out": false + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_settings_post.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_settings_post.json new file mode 100644 index 000000000000..a96572f52584 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_settings_post.json @@ -0,0 +1,19 @@ +{ + "request": { + "urlPattern": "/rest/v1.1/me/settings.*", + "method": "POST" + }, + "response": { + "status": 200, + "jsonBody": { + "password": "", + "user_login": "e2eflowsignuptestingmobile", + "display_name": "Eeflowsignuptestingmobile" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_signup.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_signup.json new file mode 100644 index 000000000000..a28242273931 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_signup.json @@ -0,0 +1,56 @@ +{ + "scenarioName": "auth", + "requiredScenarioState": "signed-up", + "request": { + "urlPattern": "/rest/v1.1/me(/)?($|\\?.*)", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 152748359, + "display_name": "e2eflowsignuptestingmobile", + "username": "e2eflowsignuptestingmobile", + "email": "e2eflowsignuptestingmobile@example.com", + "primary_blog": null, + "primary_blog_url": null, + "primary_blog_is_jetpack": false, + "language": "en", + "locale_variant": "", + "token_site_id": false, + "token_scope": [ + "global" + ], + "avatar_URL": "https://1.gravatar.com/avatar/7a4015c11be6a342f65e0e169092d402?s=96&d=identicon", + "profile_URL": "http://en.gravatar.com/e2eflowsignuptestingmobile", + "verified": true, + "email_verified": true, + "date": "2019-02-14T09:49:17+00:00", + "site_count": 1, + "visible_site_count": 1, + "has_unseen_notes": false, + "newest_note_type": "", + "phone_account": false, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/me", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/me/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/5836086", + "flags": "{{request.requestLine.baseUrl}}/rest/v1.1/me/flags" + } + }, + "is_valid_google_apps_country": true, + "user_ip_country_code": "IE", + "logout_URL": "https://wordpress.com/wp-login.php?action=logout&_wpnonce=2887f970c8&redirect_to=https%3A%2F%2Fwordpress.com%2F", + "social_login_connections": null, + "social_signup_service": null, + "abtests": { + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_sites.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_sites.json new file mode 100644 index 000000000000..68c2dc010113 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v11_me_sites.json @@ -0,0 +1,584 @@ +{ + "priority": 2, + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.(1|2)/me/sites", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "sites": [ + { + "ID": 106707880, + "name": "Tri-County Real Estate", + "description": "", + "URL": "https://tricountyrealestate.wordpress.com", + "user_can_manage": true, + "capabilities": { + "edit_pages": true, + "edit_posts": true, + "edit_others_posts": true, + "edit_others_pages": true, + "delete_posts": true, + "delete_others_posts": true, + "edit_theme_options": true, + "edit_users": false, + "list_users": true, + "manage_categories": true, + "manage_options": true, + "moderate_comments": true, + "activate_wordads": true, + "promote_users": true, + "publish_posts": true, + "upload_files": true, + "delete_users": false, + "remove_users": true, + "own_site": true, + "view_hosting": true, + "view_stats": true + }, + "jetpack": false, + "jetpack_connection": false, + "is_multisite": true, + "post_count": 3, + "subscribers_count": 0, + "lang": "en", + "icon": { + "img": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=96", + "ico": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=96", + "media_id": 133 + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": true, + "is_coming_soon": true, + "single_user_site": false, + "is_vip": false, + "is_following": false, + "organization_id": 0, + "options": { + "timezone": "America/Los_Angeles", + "gmt_offset": -7, + "blog_public": -1, + "videopress_enabled": false, + "upgraded_filetypes_enabled": false, + "login_url": "https://tricountyrealestate.wordpress.com/wp-login.php", + "admin_url": "https://tricountyrealestate.wordpress.com/wp-admin/", + "is_mapped_domain": false, + "is_redirect": false, + "unmapped_url": "https://tricountyrealestate.wordpress.com", + "featured_images_enabled": false, + "theme_slug": "pub/hever", + "header_image": false, + "background_color": false, + "image_default_link_type": "none", + "image_thumbnail_width": 150, + "image_thumbnail_height": 150, + "image_thumbnail_crop": 0, + "image_medium_width": 300, + "image_medium_height": 300, + "image_large_width": 1024, + "image_large_height": 1024, + "permalink_structure": "/%year%/%monthnum%/%day%/%postname%/", + "post_formats": [ + + ], + "default_post_format": "standard", + "default_category": 1, + "allowed_file_types": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "doc", + "ppt", + "odt", + "pptx", + "docx", + "pps", + "ppsx", + "xls", + "xlsx", + "key", + "asc" + ], + "show_on_front": "page", + "default_likes_enabled": true, + "default_sharing_status": false, + "default_comment_status": true, + "default_ping_status": true, + "software_version": "5.5-wpcom-48929", + "created_at": "2020-08-21T23:23:47+00:00", + "wordads": false, + "publicize_permanently_disabled": false, + "frame_nonce": "354ead212c", + "jetpack_frame_nonce": "354ead212c", + "page_on_front": 5, + "page_for_posts": 0, + "wpcom_public_coming_soon_page_id": 0, + "headstart": false, + "headstart_is_fresh": false, + "ak_vp_bundle_enabled": null, + "advanced_seo_front_page_description": "", + "advanced_seo_title_formats": [ + + ], + "verification_services_codes": null, + "podcasting_archive": null, + "is_domain_only": false, + "is_automated_transfer": false, + "is_wpcom_atomic": false, + "is_wpcom_store": false, + "woocommerce_is_active": false, + "design_type": null, + "site_goals": null, + "site_segment": null, + "import_engine": null, + "is_pending_plan": false, + "is_wpforteams_site": false, + "is_cloud_eligible": false + }, + "plan": { + "product_id": 1, + "product_slug": "free_plan", + "product_name_short": "Free", + "expired": false, + "user_is_owner": false, + "is_free": true + }, + "products": [ + + ], + "jetpack_modules": null, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851541", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851541/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851541/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/comments/", + "xmlrpc": "https://tricountyrealestate.wordpress.com/xmlrpc.php", + "site_icon": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851541/media/133" + } + }, + "quota": { + "space_allowed": 3221225472, + "space_used": 75318143, + "percent_used": 2.338182895133893, + "space_available": 3145907329 + }, + "launch_status": "unlaunched", + "site_migration": null, + "is_fse_active": false, + "is_fse_eligible": false, + "is_core_site_editor_enabled": false + }, + { + "ID": 181851495, + "name": "Four Paws Dog Grooming", + "description": "", + "URL": "https://fourpawsdoggrooming.wordpress.com", + "user_can_manage": true, + "capabilities": { + "edit_pages": true, + "edit_posts": true, + "edit_others_posts": true, + "edit_others_pages": true, + "delete_posts": true, + "delete_others_posts": true, + "edit_theme_options": true, + "edit_users": false, + "list_users": true, + "manage_categories": true, + "manage_options": true, + "moderate_comments": true, + "activate_wordads": false, + "promote_users": true, + "publish_posts": true, + "upload_files": true, + "delete_users": false, + "remove_users": true, + "own_site": false, + "view_hosting": true, + "view_stats": true + }, + "jetpack": false, + "jetpack_connection": false, + "is_multisite": true, + "post_count": 3, + "subscribers_count": 0, + "lang": "en", + "icon": { + "img": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png?w=96", + "ico": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png?w=96", + "media_id": 39 + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": true, + "is_coming_soon": true, + "single_user_site": false, + "is_vip": false, + "is_following": false, + "organization_id": 0, + "options": { + "timezone": "America/Los_Angeles", + "gmt_offset": -7, + "blog_public": -1, + "videopress_enabled": false, + "upgraded_filetypes_enabled": false, + "login_url": "https://fourpawsdoggrooming.wordpress.com/wp-login.php", + "admin_url": "https://fourpawsdoggrooming.wordpress.com/wp-admin/", + "is_mapped_domain": false, + "is_redirect": false, + "unmapped_url": "https://fourpawsdoggrooming.wordpress.com", + "featured_images_enabled": false, + "theme_slug": "pub/hever", + "header_image": false, + "background_color": false, + "image_default_link_type": "none", + "image_thumbnail_width": 150, + "image_thumbnail_height": 150, + "image_thumbnail_crop": 0, + "image_medium_width": 300, + "image_medium_height": 300, + "image_large_width": 1024, + "image_large_height": 1024, + "permalink_structure": "/%year%/%monthnum%/%day%/%postname%/", + "post_formats": [ + + ], + "default_post_format": "0", + "default_category": 1, + "allowed_file_types": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "doc", + "ppt", + "odt", + "pptx", + "docx", + "pps", + "ppsx", + "xls", + "xlsx", + "key", + "asc" + ], + "show_on_front": "page", + "default_likes_enabled": true, + "default_sharing_status": false, + "default_comment_status": true, + "default_ping_status": true, + "software_version": "5.5-wpcom-48929", + "created_at": "2020-08-21T23:21:16+00:00", + "wordads": false, + "publicize_permanently_disabled": false, + "frame_nonce": "6c9ff09ee5", + "jetpack_frame_nonce": "6c9ff09ee5", + "page_on_front": 5, + "page_for_posts": 0, + "wpcom_public_coming_soon_page_id": 0, + "headstart": false, + "headstart_is_fresh": false, + "ak_vp_bundle_enabled": null, + "advanced_seo_front_page_description": "", + "advanced_seo_title_formats": [ + + ], + "verification_services_codes": null, + "podcasting_archive": null, + "is_domain_only": false, + "is_automated_transfer": false, + "is_wpcom_atomic": false, + "is_wpcom_store": false, + "woocommerce_is_active": false, + "design_type": null, + "site_goals": null, + "site_segment": null, + "import_engine": null, + "is_pending_plan": false, + "is_wpforteams_site": false, + "is_cloud_eligible": false + }, + "plan": { + "product_id": 1, + "product_slug": "free_plan", + "product_name_short": "Free", + "expired": false, + "user_is_owner": false, + "is_free": true + }, + "products": [ + + ], + "jetpack_modules": null, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/comments/", + "xmlrpc": "https://fourpawsdoggrooming.wordpress.com/xmlrpc.php", + "site_icon": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/media/39" + } + }, + "quota": { + "space_allowed": 3221225472, + "space_used": 4495332, + "percent_used": 0.1395534723997116, + "space_available": 3216730140 + }, + "launch_status": "unlaunched", + "site_migration": null, + "is_fse_active": false, + "is_fse_eligible": false, + "is_core_site_editor_enabled": false + }, + { + "ID": 181977606, + "description": "Site with everything enabled", + "name": "Weekend Bakes", + "URL": "yourjetpack.blog", + "user_can_manage": false, + "capabilities": { + "edit_pages": true, + "edit_posts": true, + "edit_others_posts": true, + "edit_others_pages": true, + "delete_posts": true, + "delete_others_posts": true, + "edit_theme_options": true, + "edit_users": false, + "list_users": true, + "manage_categories": true, + "manage_options": true, + "moderate_comments": true, + "activate_wordads": true, + "promote_users": true, + "publish_posts": true, + "upload_files": true, + "delete_users": false, + "remove_users": true, + "own_site": true, + "view_hosting": false, + "view_stats": true + }, + "jetpack": true, + "jetpack_connection": true, + "is_multisite": false, + "post_count": 2, + "subscribers_count": 1, + "lang": "en-US", + "icon": { + "img": "https://weekendbakesblog.files.wordpress.com/2020/08/image.jpg?w=96", + "ico": "https://weekendbakesblog.files.wordpress.com/2020/08/image.jpg?w=96", + "media_id": 12 + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_coming_soon": false, + "single_user_site": false, + "is_vip": false, + "is_following": true, + "organization_id": 0, + "options": { + "timezone": "", + "gmt_offset": 0, + "blog_public": 1, + "videopress_enabled": false, + "upgraded_filetypes_enabled": true, + "login_url": "https://pressable-jetpack-complete.mystagingwebsite.com/wp-login.php", + "admin_url": "https://pressable-jetpack-complete.mystagingwebsite.com/wp-admin/", + "is_mapped_domain": true, + "is_redirect": false, + "unmapped_url": "https://pressable-jetpack-complete.mystagingwebsite.com", + "featured_images_enabled": false, + "theme_slug": "twentytwenty", + "header_image": false, + "background_color": false, + "image_default_link_type": "none", + "image_thumbnail_width": 150, + "image_thumbnail_height": 150, + "image_thumbnail_crop": 0, + "image_medium_width": 300, + "image_medium_height": 300, + "image_large_width": 1024, + "image_large_height": 1024, + "permalink_structure": "/%year%/%monthnum%/%day%/%postname%/", + "post_formats": [ + + ], + "default_post_format": "standard", + "default_category": 1, + "allowed_file_types": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "doc", + "ppt", + "odt", + "pptx", + "docx", + "pps", + "ppsx", + "xls", + "xlsx", + "key", + "asc", + "mp3", + "m4a", + "wav", + "ogg", + "zip" + ], + "show_on_front": "posts", + "default_likes_enabled": true, + "default_sharing_status": true, + "default_comment_status": false, + "default_ping_status": false, + "software_version": "5.7.1", + "created_at": "2020-11-04T10:05:55+00:00", + "wordads": false, + "publicize_permanently_disabled": false, + "frame_nonce": "5b3dcfa6ca", + "jetpack_frame_nonce": "1620454064:1:53483dc97819622179cfe0fe9d28ce80", + "headstart": false, + "headstart_is_fresh": false, + "ak_vp_bundle_enabled": false, + "advanced_seo_front_page_description": "", + "advanced_seo_title_formats": [ + + ], + "verification_services_codes": null, + "podcasting_archive": null, + "is_domain_only": false, + "is_automated_transfer": false, + "is_wpcom_atomic": false, + "is_wpcom_store": false, + "woocommerce_is_active": false, + "design_type": null, + "site_goals": null, + "site_segment": false, + "import_engine": null, + "is_pending_plan": false, + "is_wpforteams_site": false, + "is_cloud_eligible": false, + "anchor_podcast": false, + "jetpack_version": "9.7", + "main_network_site": "https://pressable-jetpack-complete.mystagingwebsite.com", + "active_modules": [ + "contact-form", + "enhanced-distribution", + "json-api", + "notes", + "protect", + "stats", + "verification-tools", + "woocommerce-analytics", + "search" + ], + "max_upload_size": false, + "wp_memory_limit": "268435456", + "wp_max_memory_limit": "268435456", + "is_multi_network": false, + "is_multi_site": false, + "file_mod_disabled": [ + "wp_auto_update_core_disabled" + ] + }, + "plan": { + "product_id": 2014, + "product_slug": "jetpack_complete", + "product_name_short": "Complete", + "expired": false, + "user_is_owner": true, + "is_free": false + }, + "products": [ + + ], + "zendesk_site_meta": { + "plan": "jp_complete", + "addon": [ + + ] + }, + "jetpack_modules": [ + "contact-form", + "enhanced-distribution", + "json-api", + "notes", + "protect", + "stats", + "verification-tools", + "woocommerce-analytics", + "search" + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/185124945", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/185124945/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/185124945/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/185124945/comments/", + "xmlrpc": "https://pressable-jetpack-complete.mystagingwebsite.com/xmlrpc.php", + "site_icon": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/185124945/media/8" + } + }, + "quota": { + "space_allowed": 2100373225472, + "space_used": 0, + "percent_used": 0, + "space_available": 2100373225472 + }, + "launch_status": false, + "site_migration": null, + "is_fse_active": false, + "is_fse_eligible": false, + "is_core_site_editor_enabled": false, + "updates": { + "wordpress": 0, + "plugins": 0, + "themes": 0, + "translations": 0, + "total": 0 + } + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v2_me_gutenbeg_set.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v2_me_gutenbeg_set.json new file mode 100644 index 000000000000..c421c20fdb04 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/me/rest_v2_me_gutenbeg_set.json @@ -0,0 +1,17 @@ +{ + "request": { + "urlPath": "/wpcom/v2/me/gutenberg", + "method": "POST" + }, + "response": { + "status": 200, + "jsonBody": [ + + ], + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media.json new file mode 100644 index 000000000000..b1c975d74c35 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media.json @@ -0,0 +1,2365 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/media/", + "queryParameters": { + "number": { + "matches": "(48)?" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 202, + "media": [ + { + "ID": 384, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg", + "date": "2019-05-28T16:51:24+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "destination-landmark-las-vegas-165799.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "destination-landmark-las-vegas-165799", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=288" + }, + "height": 3072, + "width": 4608, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/384", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/384/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 383, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg", + "date": "2019-05-28T16:51:22+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "dawn-desktop-wallpaper-evening-1612351.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "dawn-desktop-wallpaper-evening-1612351", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=288" + }, + "height": 3649, + "width": 5472, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/383", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/383/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 382, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg", + "date": "2019-05-28T16:51:20+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "concert-effect-entertainment-1150837.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "concert-effect-entertainment-1150837", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=288" + }, + "height": 4000, + "width": 6000, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/382", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/382/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 381, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg", + "date": "2019-05-28T16:51:18+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "clouds-flight-flying-6881.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "clouds-flight-flying-6881", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=1230&h=1224&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=256" + }, + "height": 1224, + "width": 1632, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/381", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/381/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 379, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg", + "date": "2019-05-28T16:51:15+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "clouds-country-countryside-459063.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "clouds-country-countryside-459063", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=1230&h=926&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=415" + }, + "height": 926, + "width": 2001, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/379", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/379/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 377, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg", + "date": "2019-05-28T16:51:12+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "celebration-colorful-colourful-587741.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "celebration-colorful-colourful-587741", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=288" + }, + "height": 3617, + "width": 5425, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/377", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/377/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 376, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg", + "date": "2019-05-28T16:51:10+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "celebration-christmas-dark-625789.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "celebration-christmas-dark-625789", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=256" + }, + "height": 3024, + "width": 4032, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/376", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/376/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 375, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg", + "date": "2019-05-28T16:51:08+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "catering-decoration-dinner-57980.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "catering-decoration-dinner-57980", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=288" + }, + "height": 4000, + "width": 6000, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/375", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/375/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 374, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg", + "date": "2019-05-28T16:51:07+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "carnival-carousel-circus-992763.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "carnival-carousel-circus-992763", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=100", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=200", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=683", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=128" + }, + "height": 5184, + "width": 3456, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/374", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/374/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 373, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg", + "date": "2019-05-28T16:51:04+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "camera-macro-optics-122400.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "camera-macro-optics-122400", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=288" + }, + "height": 3840, + "width": 5760, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/373", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/373/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 372, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/camera-cup-device-821750.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/camera-cup-device-821750.jpg", + "date": "2019-05-28T16:51:02+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "camera-cup-device-821750.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "camera-cup-device-821750", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/camera-cup-device-821750.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/camera-cup-device-821750.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/camera-cup-device-821750.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/camera-cup-device-821750.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/camera-cup-device-821750.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/camera-cup-device-821750.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/camera-cup-device-821750.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/camera-cup-device-821750.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/camera-cup-device-821750.jpg?w=281" + }, + "height": 1367, + "width": 2000, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/372", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/372/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 371, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/bright-burning-celebrate-288478.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/bright-burning-celebrate-288478.jpg", + "date": "2019-05-28T16:51:00+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "bright-burning-celebrate-288478.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "bright-burning-celebrate-288478", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/bright-burning-celebrate-288478.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/bright-burning-celebrate-288478.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/bright-burning-celebrate-288478.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/bright-burning-celebrate-288478.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/bright-burning-celebrate-288478.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/bright-burning-celebrate-288478.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/bright-burning-celebrate-288478.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/bright-burning-celebrate-288478.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/bright-burning-celebrate-288478.jpg?w=256" + }, + "height": 3456, + "width": 4608, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/371", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/371/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 370, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/boat-branch-color-772429.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/boat-branch-color-772429.jpg", + "date": "2019-05-28T16:50:57+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "boat-branch-color-772429.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "boat-branch-color-772429", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/boat-branch-color-772429.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/boat-branch-color-772429.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/boat-branch-color-772429.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/boat-branch-color-772429.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/boat-branch-color-772429.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/boat-branch-color-772429.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/boat-branch-color-772429.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/boat-branch-color-772429.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/boat-branch-color-772429.jpg?w=288" + }, + "height": 3739, + "width": 5600, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/370", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/370/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 369, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-69776.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-69776.jpg", + "date": "2019-05-28T16:50:55+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "bloom-blossom-colorful-69776.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "bloom-blossom-colorful-69776", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-69776.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-69776.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-69776.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-69776.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-69776.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-69776.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-69776.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-69776.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-69776.jpg?w=256" + }, + "height": 2448, + "width": 3264, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/369", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/369/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 368, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-66902.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-66902.jpg", + "date": "2019-05-28T16:50:53+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "bloom-blossom-colorful-66902.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "bloom-blossom-colorful-66902", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-66902.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-66902.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-66902.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-66902.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-66902.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-66902.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-66902.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-66902.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/bloom-blossom-colorful-66902.jpg?w=290" + }, + "height": 2024, + "width": 3056, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/368", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/368/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 367, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/black-forest-conifer-countryside-158316.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/black-forest-conifer-countryside-158316.jpg", + "date": "2019-05-28T16:50:51+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "black-forest-conifer-countryside-158316.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "black-forest-conifer-countryside-158316", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/black-forest-conifer-countryside-158316.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/black-forest-conifer-countryside-158316.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/black-forest-conifer-countryside-158316.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/black-forest-conifer-countryside-158316.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/black-forest-conifer-countryside-158316.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/black-forest-conifer-countryside-158316.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/black-forest-conifer-countryside-158316.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/black-forest-conifer-countryside-158316.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/black-forest-conifer-countryside-158316.jpg?w=288" + }, + "height": 2633, + "width": 3956, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/367", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/367/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 365, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/beach-children-family-39691.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/beach-children-family-39691.jpg", + "date": "2019-05-28T16:50:47+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "beach-children-family-39691.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "beach-children-family-39691", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/beach-children-family-39691.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/beach-children-family-39691.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/beach-children-family-39691.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/beach-children-family-39691.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/beach-children-family-39691.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/beach-children-family-39691.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/beach-children-family-39691.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/beach-children-family-39691.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/beach-children-family-39691.jpg?w=288" + }, + "height": 2446, + "width": 3669, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/365", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/365/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 364, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/bass-guitar-chord-close-up-96380.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/bass-guitar-chord-close-up-96380.jpg", + "date": "2019-05-28T16:50:45+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "bass-guitar-chord-close-up-96380.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "bass-guitar-chord-close-up-96380", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/bass-guitar-chord-close-up-96380.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/bass-guitar-chord-close-up-96380.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/bass-guitar-chord-close-up-96380.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/bass-guitar-chord-close-up-96380.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/bass-guitar-chord-close-up-96380.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/bass-guitar-chord-close-up-96380.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/bass-guitar-chord-close-up-96380.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/bass-guitar-chord-close-up-96380.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/bass-guitar-chord-close-up-96380.jpg?w=288" + }, + "height": 3648, + "width": 5472, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/364", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/364/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 363, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/barefoot-beach-cheerful-1574653.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/barefoot-beach-cheerful-1574653.jpg", + "date": "2019-05-28T16:50:43+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "barefoot-beach-cheerful-1574653.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "barefoot-beach-cheerful-1574653", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/barefoot-beach-cheerful-1574653.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/barefoot-beach-cheerful-1574653.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/barefoot-beach-cheerful-1574653.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/barefoot-beach-cheerful-1574653.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/barefoot-beach-cheerful-1574653.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/barefoot-beach-cheerful-1574653.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/barefoot-beach-cheerful-1574653.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/barefoot-beach-cheerful-1574653.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/barefoot-beach-cheerful-1574653.jpg?w=288" + }, + "height": 4480, + "width": 6720, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/363", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/363/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 362, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-festival-167473.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/band-concert-festival-167473.jpg", + "date": "2019-05-28T16:50:41+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "band-concert-festival-167473.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "band-concert-festival-167473", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-festival-167473.jpg?w=99", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-festival-167473.jpg?w=199", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-festival-167473.jpg?w=678", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-festival-167473.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-festival-167473.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-festival-167473.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-festival-167473.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-festival-167473.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-festival-167473.jpg?w=127" + }, + "height": 4928, + "width": 3264, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/362", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/362/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 361, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-entertainment-1456642.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/band-concert-entertainment-1456642.jpg", + "date": "2019-05-28T16:50:40+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "band-concert-entertainment-1456642.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "band-concert-entertainment-1456642", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-entertainment-1456642.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-entertainment-1456642.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-entertainment-1456642.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-entertainment-1456642.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-entertainment-1456642.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-entertainment-1456642.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-entertainment-1456642.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-entertainment-1456642.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/band-concert-entertainment-1456642.jpg?w=288" + }, + "height": 3304, + "width": 4956, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/361", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/361/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 360, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/band-beat-concert-210799.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/band-beat-concert-210799.jpg", + "date": "2019-05-28T16:50:38+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "band-beat-concert-210799.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "band-beat-concert-210799", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/band-beat-concert-210799.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/band-beat-concert-210799.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/band-beat-concert-210799.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/band-beat-concert-210799.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/band-beat-concert-210799.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/band-beat-concert-210799.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/band-beat-concert-210799.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/band-beat-concert-210799.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/band-beat-concert-210799.jpg?w=256" + }, + "height": 2352, + "width": 3136, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/360", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/360/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 359, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/band-bass-guitar-concert-1588075.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/band-bass-guitar-concert-1588075.jpg", + "date": "2019-05-28T16:50:36+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "band-bass-guitar-concert-1588075.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "band-bass-guitar-concert-1588075", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/band-bass-guitar-concert-1588075.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/band-bass-guitar-concert-1588075.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/band-bass-guitar-concert-1588075.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/band-bass-guitar-concert-1588075.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/band-bass-guitar-concert-1588075.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/band-bass-guitar-concert-1588075.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/band-bass-guitar-concert-1588075.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/band-bass-guitar-concert-1588075.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/band-bass-guitar-concert-1588075.jpg?w=290" + }, + "height": 3252, + "width": 4917, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/359", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/359/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 358, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/ball-cheers-crowd-59884.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/ball-cheers-crowd-59884.jpg", + "date": "2019-05-28T16:50:34+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "ball-cheers-crowd-59884.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "ball-cheers-crowd-59884", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/ball-cheers-crowd-59884.jpg?w=100", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/ball-cheers-crowd-59884.jpg?w=199", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/ball-cheers-crowd-59884.jpg?w=681", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/ball-cheers-crowd-59884.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/ball-cheers-crowd-59884.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/ball-cheers-crowd-59884.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/ball-cheers-crowd-59884.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/ball-cheers-crowd-59884.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/ball-cheers-crowd-59884.jpg?w=128" + }, + "height": 6016, + "width": 4000, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/358", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/358/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 357, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-colorful-colourful-33109.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/autumn-colorful-colourful-33109.jpg", + "date": "2019-05-28T16:50:33+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "autumn-colorful-colourful-33109.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "autumn-colorful-colourful-33109", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-colorful-colourful-33109.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-colorful-colourful-33109.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-colorful-colourful-33109.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-colorful-colourful-33109.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-colorful-colourful-33109.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-colorful-colourful-33109.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-colorful-colourful-33109.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-colorful-colourful-33109.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-colorful-colourful-33109.jpg?w=288" + }, + "height": 2304, + "width": 3456, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/357", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/357/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 356, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-autumn-colours-color-1448899.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/autumn-autumn-colours-color-1448899.jpg", + "date": "2019-05-28T16:50:31+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "autumn-autumn-colours-color-1448899.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "autumn-autumn-colours-color-1448899", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-autumn-colours-color-1448899.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-autumn-colours-color-1448899.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-autumn-colours-color-1448899.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-autumn-colours-color-1448899.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-autumn-colours-color-1448899.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-autumn-colours-color-1448899.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-autumn-colours-color-1448899.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-autumn-colours-color-1448899.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/autumn-autumn-colours-color-1448899.jpg?w=263" + }, + "height": 2557, + "width": 3496, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/356", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/356/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 355, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/audience-club-colors-1481276.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/audience-club-colors-1481276.jpg", + "date": "2019-05-28T16:50:29+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "audience-club-colors-1481276.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "audience-club-colors-1481276", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/audience-club-colors-1481276.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/audience-club-colors-1481276.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/audience-club-colors-1481276.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/audience-club-colors-1481276.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/audience-club-colors-1481276.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/audience-club-colors-1481276.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/audience-club-colors-1481276.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/audience-club-colors-1481276.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/audience-club-colors-1481276.jpg?w=288" + }, + "height": 3408, + "width": 5112, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/355", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/355/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 354, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/audience-celebration-concert-1190297.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/audience-celebration-concert-1190297.jpg", + "date": "2019-05-28T16:50:27+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "audience-celebration-concert-1190297.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "audience-celebration-concert-1190297", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/audience-celebration-concert-1190297.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/audience-celebration-concert-1190297.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/audience-celebration-concert-1190297.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/audience-celebration-concert-1190297.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/audience-celebration-concert-1190297.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/audience-celebration-concert-1190297.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/audience-celebration-concert-1190297.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/audience-celebration-concert-1190297.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/audience-celebration-concert-1190297.jpg?w=288" + }, + "height": 2200, + "width": 3300, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/354", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/354/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 353, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/audience-band-concert-1105666.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/audience-band-concert-1105666.jpg", + "date": "2019-05-28T16:50:25+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "audience-band-concert-1105666.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "audience-band-concert-1105666", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/audience-band-concert-1105666.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/audience-band-concert-1105666.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/audience-band-concert-1105666.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/audience-band-concert-1105666.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/audience-band-concert-1105666.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/audience-band-concert-1105666.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/audience-band-concert-1105666.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/audience-band-concert-1105666.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/audience-band-concert-1105666.jpg?w=288" + }, + "height": 4480, + "width": 6720, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/353", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/353/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 352, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/audience-back-view-celebration-1267350.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/audience-back-view-celebration-1267350.jpg", + "date": "2019-05-28T16:50:23+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "audience-back-view-celebration-1267350.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "audience-back-view-celebration-1267350", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/audience-back-view-celebration-1267350.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/audience-back-view-celebration-1267350.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/audience-back-view-celebration-1267350.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/audience-back-view-celebration-1267350.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/audience-back-view-celebration-1267350.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/audience-back-view-celebration-1267350.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/audience-back-view-celebration-1267350.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/audience-back-view-celebration-1267350.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/audience-back-view-celebration-1267350.jpg?w=288" + }, + "height": 2410, + "width": 3610, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/352", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/352/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 351, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/astronomy-cosmos-dark-733475.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/astronomy-cosmos-dark-733475.jpg", + "date": "2019-05-28T16:50:21+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "astronomy-cosmos-dark-733475.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "astronomy-cosmos-dark-733475", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/astronomy-cosmos-dark-733475.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/astronomy-cosmos-dark-733475.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/astronomy-cosmos-dark-733475.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/astronomy-cosmos-dark-733475.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/astronomy-cosmos-dark-733475.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/astronomy-cosmos-dark-733475.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/astronomy-cosmos-dark-733475.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/astronomy-cosmos-dark-733475.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/astronomy-cosmos-dark-733475.jpg?w=316" + }, + "height": 3646, + "width": 6000, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/351", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/351/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 350, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-environment-grass-239520.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/asphalt-environment-grass-239520.jpg", + "date": "2019-05-28T16:50:18+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "asphalt-environment-grass-239520.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "asphalt-environment-grass-239520", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-environment-grass-239520.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-environment-grass-239520.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-environment-grass-239520.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-environment-grass-239520.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-environment-grass-239520.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-environment-grass-239520.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-environment-grass-239520.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-environment-grass-239520.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-environment-grass-239520.jpg?w=289" + }, + "height": 3799, + "width": 5714, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/350", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/350/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 349, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-buildings-business-1319839.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/asphalt-buildings-business-1319839.jpg", + "date": "2019-05-28T16:50:16+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "asphalt-buildings-business-1319839.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "asphalt-buildings-business-1319839", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-buildings-business-1319839.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-buildings-business-1319839.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-buildings-business-1319839.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-buildings-business-1319839.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-buildings-business-1319839.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-buildings-business-1319839.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-buildings-business-1319839.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-buildings-business-1319839.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/asphalt-buildings-business-1319839.jpg?w=288" + }, + "height": 4000, + "width": 6000, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/349", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/349/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 348, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/artist-cap-close-up-1460037.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/artist-cap-close-up-1460037.jpg", + "date": "2019-05-28T16:50:13+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "artist-cap-close-up-1460037.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "artist-cap-close-up-1460037", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/artist-cap-close-up-1460037.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/artist-cap-close-up-1460037.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/artist-cap-close-up-1460037.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/artist-cap-close-up-1460037.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/artist-cap-close-up-1460037.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/artist-cap-close-up-1460037.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/artist-cap-close-up-1460037.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/artist-cap-close-up-1460037.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/artist-cap-close-up-1460037.jpg?w=287" + }, + "height": 2592, + "width": 3872, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/348", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/348/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 347, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/aromatherapy-beautiful-bouquet-1061744.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/aromatherapy-beautiful-bouquet-1061744.jpg", + "date": "2019-05-28T16:50:11+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "aromatherapy-beautiful-bouquet-1061744.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "aromatherapy-beautiful-bouquet-1061744", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/aromatherapy-beautiful-bouquet-1061744.jpg?w=100", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/aromatherapy-beautiful-bouquet-1061744.jpg?w=200", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/aromatherapy-beautiful-bouquet-1061744.jpg?w=683", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/aromatherapy-beautiful-bouquet-1061744.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/aromatherapy-beautiful-bouquet-1061744.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/aromatherapy-beautiful-bouquet-1061744.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/aromatherapy-beautiful-bouquet-1061744.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/aromatherapy-beautiful-bouquet-1061744.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/aromatherapy-beautiful-bouquet-1061744.jpg?w=128" + }, + "height": 5472, + "width": 3648, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/347", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/347/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 346, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-1775307.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-1775307.jpg", + "date": "2019-05-28T16:50:08+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "architecture-buildings-business-1775307.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "architecture-buildings-business-1775307", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-1775307.jpg?w=100", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-1775307.jpg?w=200", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-1775307.jpg?w=683", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-1775307.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-1775307.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-1775307.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-1775307.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-1775307.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-1775307.jpg?w=128" + }, + "height": 5184, + "width": 3456, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/346", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/346/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 345, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-230875.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-230875.jpg", + "date": "2019-05-28T16:50:05+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "architecture-buildings-business-230875.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "architecture-buildings-business-230875", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-230875.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-230875.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-230875.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-230875.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-230875.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-230875.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-230875.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-230875.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-buildings-business-230875.jpg?w=289" + }, + "height": 3897, + "width": 5861, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/345", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/345/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 344, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bright-building-574043.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/architecture-bright-building-574043.jpg", + "date": "2019-05-28T16:50:02+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "architecture-bright-building-574043.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "architecture-bright-building-574043", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bright-building-574043.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bright-building-574043.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bright-building-574043.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bright-building-574043.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bright-building-574043.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bright-building-574043.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bright-building-574043.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bright-building-574043.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bright-building-574043.jpg?w=288" + }, + "height": 4000, + "width": 6000, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/344", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/344/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 343, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bridge-connection-417054.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/architecture-bridge-connection-417054.jpg", + "date": "2019-05-28T16:50:00+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "architecture-bridge-connection-417054.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "architecture-bridge-connection-417054", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bridge-connection-417054.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bridge-connection-417054.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bridge-connection-417054.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bridge-connection-417054.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bridge-connection-417054.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bridge-connection-417054.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bridge-connection-417054.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bridge-connection-417054.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-bridge-connection-417054.jpg?w=384" + }, + "height": 1772, + "width": 3543, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/343", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/343/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 342, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-balloons-balls-698907.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/architecture-balloons-balls-698907.jpg", + "date": "2019-05-28T16:49:58+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "architecture-balloons-balls-698907.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "architecture-balloons-balls-698907", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-balloons-balls-698907.jpg?w=113", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-balloons-balls-698907.jpg?w=225", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-balloons-balls-698907.jpg?w=768", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-balloons-balls-698907.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-balloons-balls-698907.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-balloons-balls-698907.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-balloons-balls-698907.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-balloons-balls-698907.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-balloons-balls-698907.jpg?w=144" + }, + "height": 3264, + "width": 2448, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/342", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/342/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 341, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-autumn-bench-206673.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/architecture-autumn-bench-206673.jpg", + "date": "2019-05-28T16:49:56+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "architecture-autumn-bench-206673.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "architecture-autumn-bench-206673", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-autumn-bench-206673.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-autumn-bench-206673.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-autumn-bench-206673.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-autumn-bench-206673.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-autumn-bench-206673.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-autumn-bench-206673.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-autumn-bench-206673.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-autumn-bench-206673.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/architecture-autumn-bench-206673.jpg?w=290" + }, + "height": 4910, + "width": 7419, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/341", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/341/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 340, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/arched-window-architecture-beach-572780.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/arched-window-architecture-beach-572780.jpg", + "date": "2019-05-28T16:49:52+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "arched-window-architecture-beach-572780.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "arched-window-architecture-beach-572780", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/arched-window-architecture-beach-572780.jpg?w=128", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/arched-window-architecture-beach-572780.jpg?w=256", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/arched-window-architecture-beach-572780.jpg?w=874", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/arched-window-architecture-beach-572780.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/arched-window-architecture-beach-572780.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/arched-window-architecture-beach-572780.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/arched-window-architecture-beach-572780.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/arched-window-architecture-beach-572780.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/arched-window-architecture-beach-572780.jpg?w=164" + }, + "height": 4274, + "width": 3648, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/340", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/340/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 339, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/applause-audience-band-196652.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/applause-audience-band-196652.jpg", + "date": "2019-05-28T16:49:50+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "applause-audience-band-196652.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "applause-audience-band-196652", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/applause-audience-band-196652.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/applause-audience-band-196652.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/applause-audience-band-196652.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/applause-audience-band-196652.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/applause-audience-band-196652.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/applause-audience-band-196652.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/applause-audience-band-196652.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/applause-audience-band-196652.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/applause-audience-band-196652.jpg?w=288" + }, + "height": 2667, + "width": 4000, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/339", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/339/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 338, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/antelope-canyon-canyon-cave-87419.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/antelope-canyon-canyon-cave-87419.jpg", + "date": "2019-05-28T16:49:48+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "antelope-canyon-canyon-cave-87419.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "antelope-canyon-canyon-cave-87419", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/antelope-canyon-canyon-cave-87419.jpg?w=113", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/antelope-canyon-canyon-cave-87419.jpg?w=225", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/antelope-canyon-canyon-cave-87419.jpg?w=768", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/antelope-canyon-canyon-cave-87419.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/antelope-canyon-canyon-cave-87419.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/antelope-canyon-canyon-cave-87419.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/antelope-canyon-canyon-cave-87419.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/antelope-canyon-canyon-cave-87419.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/antelope-canyon-canyon-cave-87419.jpg?w=144" + }, + "height": 3264, + "width": 2448, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/338", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/338/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 337, + "URL": "https://infocusphotographers.files.wordpress.com/2019/05/animal-aquatic-corals-847393.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/05/animal-aquatic-corals-847393.jpg", + "date": "2019-05-28T16:49:46+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "animal-aquatic-corals-847393.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "animal-aquatic-corals-847393", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/05/animal-aquatic-corals-847393.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/05/animal-aquatic-corals-847393.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/05/animal-aquatic-corals-847393.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/05/animal-aquatic-corals-847393.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/05/animal-aquatic-corals-847393.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/05/animal-aquatic-corals-847393.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/05/animal-aquatic-corals-847393.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/05/animal-aquatic-corals-847393.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/05/animal-aquatic-corals-847393.jpg?w=256" + }, + "height": 3000, + "width": 4000, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/337", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/337/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + ], + "meta": { + "next_page": "value=2019-05-28T16%3A49%3A46%2B00%3A00&id=337" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_151.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_151.json new file mode 100644 index 000000000000..b6b08a89cc10 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_151.json @@ -0,0 +1,66 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/media/151/", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 151, + "URL": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg", + "date": "2019-02-06T21:59:35+00:00", + "post_ID": 0, + "author_ID": 742098, + "file": "adult-band-concert-995301.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "adult-band-concert-995301", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg?w=288" + }, + "height": 3456, + "width": 5184, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/151", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/151/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_238.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_238.json new file mode 100644 index 000000000000..4c8241529235 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_238.json @@ -0,0 +1,67 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/media/238/", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 238, + "URL": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "date": "2019-03-20T23:44:49+00:00", + "post_ID": 237, + "author_ID": 14151046, + "file": "art-bright-celebration-1313817.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "art-bright-celebration-1313817", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=311" + }, + "height": 2628, + "width": 4256, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/238", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/238/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/237" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_294.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_294.json new file mode 100644 index 000000000000..628687ae7244 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_294.json @@ -0,0 +1,64 @@ +{ + "request": { + "method": "GET", + "urlPath": "/wp/v2/sites/106707880/media/294", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 294, + "URL": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg", + "guid": "http://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg", + "date": "2019-05-08T09:49:00+00:00", + "post_ID": 295, + "author_ID": 152748359, + "file": "img_0111-14.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "img_0111", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=150", + "medium": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=300", + "large": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=1024", + "post-thumbnail": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=740&h=430&crop=1", + "independent-publisher-2-banner": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=1440&h=600&crop=1", + "independent-publisher-2-full-width": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=1100" + }, + "height": 3024, + "width": 4032, + "exif": { + "aperture": "2.4", + "credit": "", + "camera": "iPhone X", + "caption": "", + "created_timestamp": "1522412059", + "copyright": "", + "focal_length": "6", + "iso": "16", + "shutter_speed": "0.0047846889952153", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/294", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/294/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/295" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_62.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_62.json new file mode 100644 index 000000000000..a729170ab632 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_62.json @@ -0,0 +1,67 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/media/62/", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 62, + "URL": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg", + "date": "2016-02-11T00:18:19+00:00", + "post_ID": 24, + "author_ID": 64766782, + "file": "photo-1449761485030-c9bf16154670.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "photo-1449761485030-c9bf16154670", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg?w=1080&h=720&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg?w=288" + }, + "height": 720, + "width": 1080, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0, + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/62", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/62/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/24" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_82.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_82.json new file mode 100644 index 000000000000..6586d469e882 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/media_82.json @@ -0,0 +1,67 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/media/82/", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 82, + "URL": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg", + "guid": "http://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg", + "date": "2016-02-11T00:20:47+00:00", + "post_ID": 24, + "author_ID": 64766782, + "file": "photo-1453857271477-4f9a4081966e1.jpeg", + "mime_type": "image/jpeg", + "extension": "jpeg", + "title": "photo-1453857271477-4f9a4081966e", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg?w=310" + }, + "height": 2384, + "width": 3853, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0, + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/82", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/82/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/24" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/rest_v11_sites_158396482_media.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/rest_v11_sites_158396482_media.json new file mode 100644 index 000000000000..5cf51d22f8e2 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/rest_v11_sites_158396482_media.json @@ -0,0 +1,4404 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/106707880/media(/)?($|\\?.*)" + }, + "response": { + "status": 200, + "jsonBody": { + "found": 73, + "media": [ + { + "ID": 144, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg", + "date": "2020-10-07T09:32:40-07:00", + "post_ID": 0, + "author_ID": 14151046, + "file": "pexels-jean-van-der-meulen-2416933.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-jean-van-der-meulen-2416933", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-jean-van-der-meulen-2416933.jpg?w=1568" + }, + "height": 3563, + "width": 5184, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/144", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/144/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 142, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg", + "date": "2020-10-07T09:27:59-07:00", + "post_ID": 134, + "author_ID": 14151046, + "file": "pexels-photo-265004.jpeg", + "mime_type": "image/jpeg", + "extension": "jpeg", + "title": "apartment architecture book bookcase", + "caption": "Photo by Pixabay on Pexels.com", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-265004.jpeg?w=1526" + }, + "height": 1300, + "width": 1526, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/142", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/142/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/posts/134" + } + } + }, + { + "ID": 141, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg", + "date": "2020-10-07T09:27:16-07:00", + "post_ID": 134, + "author_ID": 14151046, + "file": "pexels-photo-534172.jpeg", + "mime_type": "image/jpeg", + "extension": "jpeg", + "title": "apartment chairs clean contemporary", + "caption": "Photo by Pixabay on Pexels.com", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=103", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=207", + "large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=705", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=895&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=895&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=895&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=895", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-534172.jpeg?w=895" + }, + "height": 1300, + "width": 895, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/141", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/141/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/posts/134" + } + } + }, + { + "ID": 140, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg", + "date": "2020-10-07T09:26:16-07:00", + "post_ID": 134, + "author_ID": 14151046, + "file": "pexels-photo-1457847.jpeg", + "mime_type": "image/jpeg", + "extension": "jpeg", + "title": "bathroom interior", + "caption": "Photo by Jean van der Meulen on Pexels.com", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=111", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=222", + "large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=963&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=963&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=963", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1457847.jpeg?w=963" + }, + "height": 1300, + "width": 963, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/140", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/140/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/posts/134" + } + } + }, + { + "ID": 137, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg", + "date": "2020-10-07T09:25:22-07:00", + "post_ID": 134, + "author_ID": 14151046, + "file": "pexels-photo-1080696.jpeg", + "mime_type": "image/jpeg", + "extension": "jpeg", + "title": "black kettle beside condiment shakers and green fruits and plants on tray on brown wooden table", + "caption": "Photo by Mark McCammon on Pexels.com", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=100", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=200", + "large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=682", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=866&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=866&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=866&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=866", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-1080696.jpeg?w=866" + }, + "height": 1300, + "width": 866, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/137", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/137/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/posts/134" + } + } + }, + { + "ID": 136, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg", + "date": "2020-10-07T09:24:59-07:00", + "post_ID": 134, + "author_ID": 14151046, + "file": "pexels-photo-101808.jpeg", + "mime_type": "image/jpeg", + "extension": "jpeg", + "title": "building metal house architecture", + "caption": "Photo by PhotoMIX Company on Pexels.com", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/10/pexels-photo-101808.jpeg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/136", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/136/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/posts/134" + } + } + }, + { + "ID": 133, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg", + "date": "2020-08-24T21:27:01-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "image-1.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "image-1", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=640", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=640&h=640&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=640&h=640&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=640&h=640&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=640&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=600&h=640&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=640&h=640&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=640", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=640" + }, + "height": 640, + "width": 640, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/133", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/133/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 132, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg", + "date": "2020-08-24T21:01:01-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-280222-1.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-280222-1", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280222-1.jpg?w=1568" + }, + "height": 1249, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/132", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/132/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 131, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg", + "date": "2020-08-24T21:01:01-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1396132.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1396132", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396132.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/131", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/131/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 130, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg", + "date": "2020-08-24T21:01:01-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-271795.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-271795", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=900&h=1058&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=1200&h=1058&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271795.jpg?w=1568" + }, + "height": 1058, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/130", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/130/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 129, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg", + "date": "2020-08-24T21:01:01-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-271624.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-271624", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271624.jpg?w=1568" + }, + "height": 1255, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/129", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/129/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 128, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg", + "date": "2020-08-24T21:01:01-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3705529.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3705529", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3705529.jpg?w=1568" + }, + "height": 1245, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/128", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/128/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 127, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg", + "date": "2020-08-24T21:01:01-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1454805.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1454805", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=101", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=201", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=687", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=872&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=872&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=872&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=872", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1454805.jpg?w=872" + }, + "height": 1300, + "width": 872, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/127", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/127/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 126, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg", + "date": "2020-08-24T21:01:00-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-259962.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-259962", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259962.jpg?w=1568" + }, + "height": 1249, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/126", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/126/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 125, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg", + "date": "2020-08-24T20:59:59-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "kitchen-stove-sink-kitchen-counter-349749-1.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "kitchen-stove-sink-kitchen-counter-349749-1", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749-1.jpg?w=1568" + }, + "height": 1245, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/125", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/125/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 124, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg", + "date": "2020-08-24T20:59:59-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1643383-1.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1643383-1", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383-1.jpg?w=1568" + }, + "height": 1238, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/124", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/124/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 123, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg", + "date": "2020-08-24T20:59:59-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-298842.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-298842", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-298842.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/123", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/123/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 122, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg", + "date": "2020-08-24T20:59:58-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-276724.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-276724", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=900&h=1058&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=1200&h=1058&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-276724.jpg?w=1568" + }, + "height": 1058, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/122", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/122/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 121, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg", + "date": "2020-08-24T20:59:58-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3933278.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3933278", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=100", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=200", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=683", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=867&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=867&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=867&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=867", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3933278.jpg?w=867" + }, + "height": 1300, + "width": 867, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/121", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/121/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 120, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg", + "date": "2020-08-24T20:59:58-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3932930.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3932930", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=100", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=200", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=683", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=867&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=867&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=867&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=867", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3932930.jpg?w=867" + }, + "height": 1300, + "width": 867, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/120", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/120/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 119, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg", + "date": "2020-08-24T20:59:58-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1457842.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1457842", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=900&h=1141&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=1200&h=1141&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457842.jpg?w=1568" + }, + "height": 1141, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/119", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/119/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 118, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg", + "date": "2020-08-24T20:59:58-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-699459.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-699459", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=113", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=225", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=975&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=975&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=975", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-699459.jpg?w=975" + }, + "height": 1300, + "width": 975, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/118", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/118/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 117, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg", + "date": "2020-08-24T20:58:37-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-210617-1.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-210617-1", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617-1.jpg?w=1568" + }, + "height": 1259, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/117", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/117/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 116, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg", + "date": "2020-08-24T20:58:37-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-206172.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-206172", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-206172.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/116", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/116/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 115, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg", + "date": "2020-08-24T20:57:57-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-2101086.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-2101086", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2101086.jpg?w=1568" + }, + "height": 1255, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/115", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/115/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 114, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg", + "date": "2020-08-24T20:57:57-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-221024.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-221024", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-221024.jpg?w=1568" + }, + "height": 1259, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/114", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/114/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 113, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg", + "date": "2020-08-24T20:57:56-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3209049.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3209049", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3209049.jpg?w=1568" + }, + "height": 1255, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/113", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/113/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 112, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg", + "date": "2020-08-24T20:57:26-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-534151-1.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-534151-1", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151-1.jpg?w=1568" + }, + "height": 1255, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/112", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/112/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 111, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg", + "date": "2020-08-24T20:57:26-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-534172.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-534172", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=103", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=207", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=705", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=895&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=895&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=895&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=895", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534172.jpg?w=895" + }, + "height": 1300, + "width": 895, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/111", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/111/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 110, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg", + "date": "2020-08-24T20:56:44-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-2062426.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-2062426", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2062426.jpg?w=1568" + }, + "height": 1255, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/110", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/110/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 109, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg", + "date": "2020-08-24T20:56:44-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1080721.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1080721", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1080721.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/109", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/109/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 108, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg", + "date": "2020-08-24T20:56:44-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1125136.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1125136", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=100", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=200", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=684", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=868&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=868&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=868&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=868", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1125136.jpg?w=868" + }, + "height": 1300, + "width": 868, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/108", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/108/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 107, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg", + "date": "2020-08-24T20:56:20-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1571468.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1571468", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571468.jpg?w=1568" + }, + "height": 1255, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/107", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/107/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 106, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg", + "date": "2020-08-24T20:56:19-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1571459.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1571459", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571459.jpg?w=1568" + }, + "height": 1227, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/106", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/106/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 105, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg", + "date": "2020-08-24T20:55:47-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1648768.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1648768", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1648768.jpg?w=1537" + }, + "height": 1300, + "width": 1537, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/105", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/105/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 104, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg", + "date": "2020-08-24T20:55:47-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-2747901.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-2747901", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2747901.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/104", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/104/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 103, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg", + "date": "2020-08-24T20:53:50-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1643389.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1643389", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643389.jpg?w=1568" + }, + "height": 1255, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/103", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/103/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 102, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-210617.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-210617", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-210617.jpg?w=1568" + }, + "height": 1259, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/102", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/102/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 100, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-534151.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-534151", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-534151.jpg?w=1568" + }, + "height": 1255, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/100", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/100/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 99, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1396122.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1396122", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1396122.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/99", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/99/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 98, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-259593.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-259593", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-259593.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/98", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/98/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 97, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-238385.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-238385", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=900&h=1092&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=1200&h=1092&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-238385.jpg?w=1568" + }, + "height": 1092, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/97", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/97/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 96, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1571463.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1571463", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1571463.jpg?w=1568" + }, + "height": 1255, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/96", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/96/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 95, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-271622.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-271622", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271622.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/95", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/95/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 94, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1543447.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1543447", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1543447.jpg?w=1568" + }, + "height": 1209, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/94", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/94/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 93, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3701434.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3701434", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3701434.jpg?w=1568" + }, + "height": 1300, + "width": 1733, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/93", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/93/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 92, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-261045.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-261045", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-261045.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/92", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/92/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 91, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-2416932.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-2416932", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2416932.jpg?w=1568" + }, + "height": 1300, + "width": 1781, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/91", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/91/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 90, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3724312.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3724312", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3724312.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/90", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/90/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 89, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-2251247.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-2251247", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2251247.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/89", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/89/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 88, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1643383.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1643383", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1643383.jpg?w=1568" + }, + "height": 1238, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/88", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/88/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 87, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3935338.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3935338", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935338.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/87", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/87/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 86, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1918291.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1918291", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=900&h=1058&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=1200&h=1058&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1918291.jpg?w=1568" + }, + "height": 1058, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/86", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/86/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 85, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg", + "date": "2020-08-24T20:53:49-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3935314.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3935314", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3935314.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/85", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/85/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 84, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg", + "date": "2020-08-24T20:53:48-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "living-room-couch-interior-room-584399.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "living-room-couch-interior-room-584399", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/living-room-couch-interior-room-584399.jpg?w=1568" + }, + "height": 1244, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/84", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/84/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 83, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg", + "date": "2020-08-24T20:53:48-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-277667.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-277667", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-277667.jpg?w=1568" + }, + "height": 1258, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/83", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/83/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 82, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg", + "date": "2020-08-24T20:53:48-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3915710.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3915710", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3915710.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/82", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/82/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 81, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg", + "date": "2020-08-24T20:53:48-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "kitchen-stove-sink-kitchen-counter-349749.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "kitchen-stove-sink-kitchen-counter-349749", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/kitchen-stove-sink-kitchen-counter-349749.jpg?w=1568" + }, + "height": 1245, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/81", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/81/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 80, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg", + "date": "2020-08-24T20:53:48-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-2724749.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-2724749", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2724749.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/80", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/80/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 79, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg", + "date": "2020-08-24T20:53:48-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-323780.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-323780", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-323780.jpg?w=1568" + }, + "height": 1212, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/79", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/79/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 78, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg", + "date": "2020-08-24T20:53:48-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-279648.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-279648", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-279648.jpg?w=1318" + }, + "height": 1300, + "width": 1318, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/78", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/78/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 77, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg", + "date": "2020-08-24T20:53:48-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-280232.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-280232", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-280232.jpg?w=1568" + }, + "height": 1249, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/77", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/77/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 76, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg", + "date": "2020-08-24T20:53:48-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3016430.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3016430", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3016430.jpg?w=1568" + }, + "height": 1300, + "width": 1733, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/76", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/76/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 75, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg", + "date": "2020-08-24T20:53:48-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3214064.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3214064", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3214064.jpg?w=1568" + }, + "height": 1253, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/75", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/75/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 74, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg", + "date": "2020-08-24T20:53:47-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3723719.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3723719", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=113", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=225", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=975&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=975&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=975", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3723719.jpg?w=975" + }, + "height": 1300, + "width": 975, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/74", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/74/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 73, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg", + "date": "2020-08-24T20:53:47-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-262048.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-262048", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-262048.jpg?w=1568" + }, + "height": 1300, + "width": 1823, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/73", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/73/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 72, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg", + "date": "2020-08-24T20:53:46-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-2635038.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-2635038", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=1200", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-2635038.jpg?w=1568" + }, + "height": 1255, + "width": 1880, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/72", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/72/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 71, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg", + "date": "2020-08-24T20:53:45-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3315291.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3315291", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=125", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=250", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=1083&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=1083&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=1083", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3315291.jpg?w=1083" + }, + "height": 1300, + "width": 1083, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/71", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/71/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 70, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg", + "date": "2020-08-24T20:53:45-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-4099300.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-4099300", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=120", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=240", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=1040&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=1040&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=1040", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-4099300.jpg?w=1040" + }, + "height": 1300, + "width": 1040, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/70", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/70/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 69, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg", + "date": "2020-08-24T20:53:42-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-3554424.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-3554424", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=100", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=200", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=683", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=867&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=867&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=867&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=867", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-3554424.jpg?w=867" + }, + "height": 1300, + "width": 867, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/69", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/69/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 68, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg", + "date": "2020-08-24T20:53:42-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-271631.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-271631", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=100", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=200", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=684", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=868&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=868&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=868&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=868", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-271631.jpg?w=868" + }, + "height": 1300, + "width": 868, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/68", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/68/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 67, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg", + "date": "2020-08-24T20:53:42-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "pexels-photo-1457847.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "pexels-photo-1457847", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=111", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=222", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=750", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=963&h=900&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=963&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=963", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/pexels-photo-1457847.jpg?w=963" + }, + "height": 1300, + "width": 963, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/67", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/67/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + }, + { + "ID": 30, + "URL": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png", + "guid": "http://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png", + "date": "2020-08-24T13:58:54-07:00", + "post_ID": 0, + "author_ID": 191794483, + "file": "real-estate.png", + "mime_type": "image/png", + "extension": "png", + "title": "Real Estate", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s0.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=150", + "medium": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=300", + "large": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=512", + "newspack-article-block-landscape-large": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=512&h=512&crop=1", + "newspack-article-block-portrait-large": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=512&h=512&crop=1", + "newspack-article-block-square-large": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=512&h=512&crop=1", + "newspack-article-block-landscape-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=512&h=512&crop=1", + "newspack-article-block-portrait-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=512&h=512&crop=1", + "newspack-article-block-square-medium": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=512&h=512&crop=1", + "newspack-article-block-landscape-small": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=512", + "post-thumbnail": "https://tricountyrealestate.files.wordpress.com/2020/08/real-estate.png?w=512" + }, + "height": 512, + "width": 512, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/30", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541/media/30/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851541" + } + } + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/rest_v11_sites_158396482_media_1_image.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/rest_v11_sites_158396482_media_1_image.json new file mode 100644 index 000000000000..a8fedd1a3c30 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/rest_v11_sites_158396482_media_1_image.json @@ -0,0 +1,80 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/106707880/media(/)?($|\\?.*)", + "queryParameters": { + "media_type": { + "equalTo": "image" + }, + "number": { + "equalTo": "1" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 20, + "media": [ + { + "ID": 243, + "URL": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg", + "date": "2019-03-20T23:56:52+00:00", + "post_ID": 225, + "author_ID": 14151046, + "file": "beach-children-family-39691.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "beach-children-family-39691", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=1024", + "edin-thumbnail-landscape": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=330&h=240&crop=1", + "edin-thumbnail-square": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=330&h=330&crop=1", + "edin-thumbnail-avatar": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=96&h=96&crop=1", + "edin-featured-image": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=648", + "edin-hero": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=1230&h=1230&crop=1", + "edin-logo": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=288" + }, + "height": 2446, + "width": 3669, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/243", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/243/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/225" + } + } + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/rest_v11_sites_158396482_media_new.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/rest_v11_sites_158396482_media_new.json new file mode 100644 index 000000000000..2e035f424f19 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/rest_v11_sites_158396482_media_new.json @@ -0,0 +1,68 @@ +{ + "request": { + "method": "POST", + "urlPattern": "/rest/v1.1/sites/106707880/media/new(/)?($|\\?.*)" + }, + "response": { + "status": 200, + "jsonBody": { + "media": [ + { + "ID": 294, + "URL": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg", + "guid": "http://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg", + "date": "2019-05-08T09:49:00+00:00", + "post_ID": 295, + "author_ID": 152748359, + "file": "img_0111-14.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "img_0111", + "caption": "", + "description": "", + "alt": "", + "icon": "https://s1.wp.com/wp-includes/images/media/default.png", + "thumbnails": { + "thumbnail": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=150", + "medium": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=300", + "large": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=1024", + "post-thumbnail": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=740&h=430&crop=1", + "independent-publisher-2-banner": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=1440&h=600&crop=1", + "independent-publisher-2-full-width": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/img_0111-14.jpg?w=1100" + }, + "height": 3024, + "width": 4032, + "exif": { + "aperture": "2.4", + "credit": "", + "camera": "iPhone X", + "caption": "", + "created_timestamp": "1522412059", + "copyright": "", + "focal_length": "6", + "iso": "16", + "shutter_speed": "0.0047846889952153", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/294", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/294/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/295" + } + } + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/v2_sites_106707880_media.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/v2_sites_106707880_media.json new file mode 100644 index 000000000000..ab90a8390359 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/v2_sites_106707880_media.json @@ -0,0 +1,1266 @@ +{ + "request": { + "method": "GET", + "urlPath": "/wp/v2/sites/106707880/media" + }, + "response": { + "status": 200, + "jsonBody": [ + { + "id": 384, + "date": "2019-05-28T16:51:24", + "date_gmt": "2019-05-28T16:51:24", + "guid": { + "rendered": "http://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg" + }, + "modified": "2019-05-28T16:51:24", + "modified_gmt": "2019-05-28T16:51:24", + "slug": "destination-landmark-las-vegas-165799-2", + "status": "inherit", + "type": "attachment", + "link": "https://infocusphotographers.com/destination-landmark-las-vegas-165799-2/", + "title": { + "rendered": "destination-landmark-las-vegas-165799" + }, + "author": 742098, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/a7dJAQ-6c", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 4608, + "height": 3072, + "file": "2019/05/destination-landmark-las-vegas-165799.jpg", + "sizes": { + "thumbnail": { + "file": "destination-landmark-las-vegas-165799.jpg?w=150", + "width": 150, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=150" + }, + "medium": { + "file": "destination-landmark-las-vegas-165799.jpg?w=300", + "width": 300, + "height": 200, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=300" + }, + "large": { + "file": "destination-landmark-las-vegas-165799.jpg?w=1024", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg?w=1024" + }, + "full": { + "file": "destination-landmark-las-vegas-165799.jpg", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 2172736 + }, + "post": null, + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/destination-landmark-las-vegas-165799.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media/384" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/users/742098" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/comments?post=384" + } + ] + } + }, + { + "id": 383, + "date": "2019-05-28T16:51:22", + "date_gmt": "2019-05-28T16:51:22", + "guid": { + "rendered": "http://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg" + }, + "modified": "2019-05-28T16:51:22", + "modified_gmt": "2019-05-28T16:51:22", + "slug": "dawn-desktop-wallpaper-evening-1612351-2", + "status": "inherit", + "type": "attachment", + "link": "https://infocusphotographers.com/dawn-desktop-wallpaper-evening-1612351-2/", + "title": { + "rendered": "dawn-desktop-wallpaper-evening-1612351" + }, + "author": 742098, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/a7dJAQ-6b", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 5472, + "height": 3649, + "file": "2019/05/dawn-desktop-wallpaper-evening-1612351.jpg", + "sizes": { + "thumbnail": { + "file": "dawn-desktop-wallpaper-evening-1612351.jpg?w=150", + "width": 150, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=150" + }, + "medium": { + "file": "dawn-desktop-wallpaper-evening-1612351.jpg?w=300", + "width": 300, + "height": 200, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=300" + }, + "large": { + "file": "dawn-desktop-wallpaper-evening-1612351.jpg?w=1024", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg?w=1024" + }, + "full": { + "file": "dawn-desktop-wallpaper-evening-1612351.jpg", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 2982922 + }, + "post": null, + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/dawn-desktop-wallpaper-evening-1612351.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media/383" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/users/742098" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/comments?post=383" + } + ] + } + }, + { + "id": 382, + "date": "2019-05-28T16:51:20", + "date_gmt": "2019-05-28T16:51:20", + "guid": { + "rendered": "http://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg" + }, + "modified": "2019-05-28T16:51:20", + "modified_gmt": "2019-05-28T16:51:20", + "slug": "concert-effect-entertainment-1150837-2", + "status": "inherit", + "type": "attachment", + "link": "https://infocusphotographers.com/concert-effect-entertainment-1150837-2/", + "title": { + "rendered": "concert-effect-entertainment-1150837" + }, + "author": 742098, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/a7dJAQ-6a", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 6000, + "height": 4000, + "file": "2019/05/concert-effect-entertainment-1150837.jpg", + "sizes": { + "thumbnail": { + "file": "concert-effect-entertainment-1150837.jpg?w=150", + "width": 150, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=150" + }, + "medium": { + "file": "concert-effect-entertainment-1150837.jpg?w=300", + "width": 300, + "height": 200, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=300" + }, + "large": { + "file": "concert-effect-entertainment-1150837.jpg?w=1024", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg?w=1024" + }, + "full": { + "file": "concert-effect-entertainment-1150837.jpg", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 2881247 + }, + "post": null, + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/concert-effect-entertainment-1150837.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media/382" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/users/742098" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/comments?post=382" + } + ] + } + }, + { + "id": 381, + "date": "2019-05-28T16:51:18", + "date_gmt": "2019-05-28T16:51:18", + "guid": { + "rendered": "http://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg" + }, + "modified": "2019-05-28T16:51:18", + "modified_gmt": "2019-05-28T16:51:18", + "slug": "clouds-flight-flying-6881-2", + "status": "inherit", + "type": "attachment", + "link": "https://infocusphotographers.com/clouds-flight-flying-6881-2/", + "title": { + "rendered": "clouds-flight-flying-6881" + }, + "author": 742098, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/a7dJAQ-69", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1632, + "height": 1224, + "file": "2019/05/clouds-flight-flying-6881.jpg", + "sizes": { + "thumbnail": { + "file": "clouds-flight-flying-6881.jpg?w=150", + "width": 150, + "height": 113, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=150" + }, + "medium": { + "file": "clouds-flight-flying-6881.jpg?w=300", + "width": 300, + "height": 225, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=300" + }, + "large": { + "file": "clouds-flight-flying-6881.jpg?w=1024", + "width": 1024, + "height": 768, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg?w=1024" + }, + "full": { + "file": "clouds-flight-flying-6881.jpg", + "width": 1024, + "height": 768, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 286243 + }, + "post": null, + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-flight-flying-6881.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media/381" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/users/742098" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/comments?post=381" + } + ] + } + }, + { + "id": 379, + "date": "2019-05-28T16:51:15", + "date_gmt": "2019-05-28T16:51:15", + "guid": { + "rendered": "http://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg" + }, + "modified": "2019-05-28T16:51:15", + "modified_gmt": "2019-05-28T16:51:15", + "slug": "clouds-country-countryside-459063-2", + "status": "inherit", + "type": "attachment", + "link": "https://infocusphotographers.com/clouds-country-countryside-459063-2/", + "title": { + "rendered": "clouds-country-countryside-459063" + }, + "author": 742098, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/a7dJAQ-67", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 2001, + "height": 926, + "file": "2019/05/clouds-country-countryside-459063.jpg", + "sizes": { + "thumbnail": { + "file": "clouds-country-countryside-459063.jpg?w=150", + "width": 150, + "height": 69, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=150" + }, + "medium": { + "file": "clouds-country-countryside-459063.jpg?w=300", + "width": 300, + "height": 139, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=300" + }, + "large": { + "file": "clouds-country-countryside-459063.jpg?w=1024", + "width": 1024, + "height": 474, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg?w=1024" + }, + "full": { + "file": "clouds-country-countryside-459063.jpg", + "width": 1024, + "height": 474, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 329615 + }, + "post": null, + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/clouds-country-countryside-459063.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media/379" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/users/742098" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/comments?post=379" + } + ] + } + }, + { + "id": 377, + "date": "2019-05-28T16:51:12", + "date_gmt": "2019-05-28T16:51:12", + "guid": { + "rendered": "http://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg" + }, + "modified": "2019-05-28T16:51:12", + "modified_gmt": "2019-05-28T16:51:12", + "slug": "celebration-colorful-colourful-587741-2", + "status": "inherit", + "type": "attachment", + "link": "https://infocusphotographers.com/celebration-colorful-colourful-587741-2/", + "title": { + "rendered": "celebration-colorful-colourful-587741" + }, + "author": 742098, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/a7dJAQ-65", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 5425, + "height": 3617, + "file": "2019/05/celebration-colorful-colourful-587741.jpg", + "sizes": { + "thumbnail": { + "file": "celebration-colorful-colourful-587741.jpg?w=150", + "width": 150, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=150" + }, + "medium": { + "file": "celebration-colorful-colourful-587741.jpg?w=300", + "width": 300, + "height": 200, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=300" + }, + "large": { + "file": "celebration-colorful-colourful-587741.jpg?w=1024", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg?w=1024" + }, + "full": { + "file": "celebration-colorful-colourful-587741.jpg", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 1704908 + }, + "post": null, + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-colorful-colourful-587741.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media/377" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/users/742098" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/comments?post=377" + } + ] + } + }, + { + "id": 376, + "date": "2019-05-28T16:51:10", + "date_gmt": "2019-05-28T16:51:10", + "guid": { + "rendered": "http://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg" + }, + "modified": "2019-05-28T16:51:10", + "modified_gmt": "2019-05-28T16:51:10", + "slug": "celebration-christmas-dark-625789-2", + "status": "inherit", + "type": "attachment", + "link": "https://infocusphotographers.com/celebration-christmas-dark-625789-2/", + "title": { + "rendered": "celebration-christmas-dark-625789" + }, + "author": 742098, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/a7dJAQ-64", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 4032, + "height": 3024, + "file": "2019/05/celebration-christmas-dark-625789.jpg", + "sizes": { + "thumbnail": { + "file": "celebration-christmas-dark-625789.jpg?w=150", + "width": 150, + "height": 113, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=150" + }, + "medium": { + "file": "celebration-christmas-dark-625789.jpg?w=300", + "width": 300, + "height": 225, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=300" + }, + "large": { + "file": "celebration-christmas-dark-625789.jpg?w=1024", + "width": 1024, + "height": 768, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg?w=1024" + }, + "full": { + "file": "celebration-christmas-dark-625789.jpg", + "width": 1024, + "height": 768, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 1779369 + }, + "post": null, + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/celebration-christmas-dark-625789.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media/376" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/users/742098" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/comments?post=376" + } + ] + } + }, + { + "id": 375, + "date": "2019-05-28T16:51:08", + "date_gmt": "2019-05-28T16:51:08", + "guid": { + "rendered": "http://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg" + }, + "modified": "2019-05-28T16:51:08", + "modified_gmt": "2019-05-28T16:51:08", + "slug": "catering-decoration-dinner-57980-2", + "status": "inherit", + "type": "attachment", + "link": "https://infocusphotographers.com/catering-decoration-dinner-57980-2/", + "title": { + "rendered": "catering-decoration-dinner-57980" + }, + "author": 742098, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/a7dJAQ-63", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 6000, + "height": 4000, + "file": "2019/05/catering-decoration-dinner-57980.jpg", + "sizes": { + "thumbnail": { + "file": "catering-decoration-dinner-57980.jpg?w=150", + "width": 150, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=150" + }, + "medium": { + "file": "catering-decoration-dinner-57980.jpg?w=300", + "width": 300, + "height": 200, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=300" + }, + "large": { + "file": "catering-decoration-dinner-57980.jpg?w=1024", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg?w=1024" + }, + "full": { + "file": "catering-decoration-dinner-57980.jpg", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 2241987 + }, + "post": null, + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/catering-decoration-dinner-57980.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media/375" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/users/742098" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/comments?post=375" + } + ] + } + }, + { + "id": 374, + "date": "2019-05-28T16:51:07", + "date_gmt": "2019-05-28T16:51:07", + "guid": { + "rendered": "http://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg" + }, + "modified": "2019-05-28T16:51:07", + "modified_gmt": "2019-05-28T16:51:07", + "slug": "carnival-carousel-circus-992763-2", + "status": "inherit", + "type": "attachment", + "link": "https://infocusphotographers.com/carnival-carousel-circus-992763-2/", + "title": { + "rendered": "carnival-carousel-circus-992763" + }, + "author": 742098, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/a7dJAQ-62", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 3456, + "height": 5184, + "file": "2019/05/carnival-carousel-circus-992763.jpg", + "sizes": { + "thumbnail": { + "file": "carnival-carousel-circus-992763.jpg?w=100", + "width": 100, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=100" + }, + "medium": { + "file": "carnival-carousel-circus-992763.jpg?w=200", + "width": 200, + "height": 300, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=200" + }, + "large": { + "file": "carnival-carousel-circus-992763.jpg?w=683", + "width": 683, + "height": 1024, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg?w=683" + }, + "full": { + "file": "carnival-carousel-circus-992763.jpg", + "width": 1024, + "height": 1536, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 3780115 + }, + "post": null, + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/carnival-carousel-circus-992763.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media/374" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/users/742098" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/comments?post=374" + } + ] + } + }, + { + "id": 373, + "date": "2019-05-28T16:51:04", + "date_gmt": "2019-05-28T16:51:04", + "guid": { + "rendered": "http://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg" + }, + "modified": "2019-05-28T16:51:04", + "modified_gmt": "2019-05-28T16:51:04", + "slug": "camera-macro-optics-122400-2", + "status": "inherit", + "type": "attachment", + "link": "https://infocusphotographers.com/camera-macro-optics-122400-2/", + "title": { + "rendered": "camera-macro-optics-122400" + }, + "author": 742098, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/a7dJAQ-61", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 5760, + "height": 3840, + "file": "2019/05/camera-macro-optics-122400.jpg", + "sizes": { + "thumbnail": { + "file": "camera-macro-optics-122400.jpg?w=150", + "width": 150, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=150" + }, + "medium": { + "file": "camera-macro-optics-122400.jpg?w=300", + "width": 300, + "height": 200, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=300" + }, + "large": { + "file": "camera-macro-optics-122400.jpg?w=1024", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg?w=1024" + }, + "full": { + "file": "camera-macro-optics-122400.jpg", + "width": 1024, + "height": 683, + "mime_type": "image/jpeg", + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 3238265 + }, + "post": null, + "source_url": "https://infocusphotographers.files.wordpress.com/2019/05/camera-macro-optics-122400.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media/373" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/users/742098" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/106707880/comments?post=373" + } + ] + } + } + ], + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/v2_sites_181851495_media.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/v2_sites_181851495_media.json new file mode 100644 index 000000000000..0af090b73e7e --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/v2_sites_181851495_media.json @@ -0,0 +1,1294 @@ +{ + "request": { + "method": "GET", + "urlPath": "/wp/v2/sites/181851495/media" + }, + "response": { + "status": 200, + "jsonBody": [ + { + "id": 65, + "date": "2020-08-27T16:30:36", + "date_gmt": "2020-08-27T23:30:36", + "guid": { + "rendered": "http://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg" + }, + "modified": "2020-08-27T16:31:03", + "modified_gmt": "2020-08-27T23:31:03", + "slug": "image-2", + "status": "inherit", + "type": "attachment", + "link": "https://fourpawsdoggrooming.wordpress.com/our-services/image-2/", + "title": { + "rendered": "image" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acj1SD-13", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1320, + "height": 1848, + "file": "2020/08/image-1.jpg", + "sizes": { + "thumbnail": { + "file": "image-1.jpg?w=107", + "width": 107, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=107" + }, + "medium": { + "file": "image-1.jpg?w=214", + "width": 214, + "height": 300, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=214" + }, + "large": { + "file": "image-1.jpg?w=731", + "width": 731, + "height": 1024, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=731" + }, + "full": { + "file": "image-1.jpg", + "width": 1024, + "height": 1434, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "filesize": 702736 + }, + "post": 45, + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media/65" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/comments?post=65" + } + ] + } + }, + { + "id": 63, + "date": "2020-08-26T12:20:02", + "date_gmt": "2020-08-26T19:20:02", + "guid": { + "rendered": "http://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg" + }, + "modified": "2020-08-26T12:20:02", + "modified_gmt": "2020-08-26T19:20:02", + "slug": "wp-1598469598971", + "status": "inherit", + "type": "attachment", + "link": "https://fourpawsdoggrooming.wordpress.com/our-services/wp-1598469598971/", + "title": { + "rendered": "wp-1598469598971.jpg" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acj1SD-11", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1851, + "height": 2780, + "file": "2020/08/wp-1598469598971.jpg", + "sizes": { + "thumbnail": { + "file": "wp-1598469598971.jpg?w=100", + "width": 100, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=100" + }, + "medium": { + "file": "wp-1598469598971.jpg?w=200", + "width": 200, + "height": 300, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=200" + }, + "large": { + "file": "wp-1598469598971.jpg?w=682", + "width": 682, + "height": 1024, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=682" + }, + "full": { + "file": "wp-1598469598971.jpg", + "width": 1024, + "height": 1538, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 437547 + }, + "post": 45, + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media/63" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/comments?post=63" + } + ] + } + }, + { + "id": 61, + "date": "2020-08-25T12:05:54", + "date_gmt": "2020-08-25T19:05:54", + "guid": { + "rendered": "http://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg" + }, + "modified": "2020-08-25T12:10:32", + "modified_gmt": "2020-08-25T19:10:32", + "slug": "image", + "status": "inherit", + "type": "attachment", + "link": "https://fourpawsdoggrooming.wordpress.com/our-services/image/", + "title": { + "rendered": "image" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acj1SD-Z", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1846, + "height": 1848, + "file": "2020/08/image.jpg", + "sizes": { + "thumbnail": { + "file": "image.jpg?w=150", + "width": 150, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=150" + }, + "medium": { + "file": "image.jpg?w=300", + "width": 300, + "height": 300, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=300" + }, + "large": { + "file": "image.jpg?w=1024", + "width": "1024", + "height": 1024, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=1024" + }, + "full": { + "file": "image.jpg", + "width": 1024, + "height": 1025, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "filesize": 936680 + }, + "post": 45, + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media/61" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/comments?post=61" + } + ] + } + }, + { + "id": 49, + "date": "2020-08-24T12:07:27", + "date_gmt": "2020-08-24T19:07:27", + "guid": { + "rendered": "http://fourpawsdoggrooming.files.wordpress.com/2020/08/alvan-nee-1vgfqdcux-4-unsplash.jpg" + }, + "modified": "2020-08-24T12:07:27", + "modified_gmt": "2020-08-24T19:07:27", + "slug": "alvan-nee-1vgfqdcux-4-unsplash", + "status": "inherit", + "type": "attachment", + "link": "https://fourpawsdoggrooming.wordpress.com/alvan-nee-1vgfqdcux-4-unsplash/", + "title": { + "rendered": "alvan-nee-1vgfqdcux-4-unsplash" + }, + "author": 14151046, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acj1SD-N", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1851, + "height": 2780, + "file": "2020/08/alvan-nee-1vgfqdcux-4-unsplash.jpg", + "sizes": { + "thumbnail": { + "file": "alvan-nee-1vgfqdcux-4-unsplash.jpg?w=100", + "width": 100, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/alvan-nee-1vgfqdcux-4-unsplash.jpg?w=100" + }, + "medium": { + "file": "alvan-nee-1vgfqdcux-4-unsplash.jpg?w=200", + "width": 200, + "height": 300, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/alvan-nee-1vgfqdcux-4-unsplash.jpg?w=200" + }, + "large": { + "file": "alvan-nee-1vgfqdcux-4-unsplash.jpg?w=682", + "width": 682, + "height": 1024, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/alvan-nee-1vgfqdcux-4-unsplash.jpg?w=682" + }, + "full": { + "file": "alvan-nee-1vgfqdcux-4-unsplash.jpg", + "width": 1024, + "height": 1538, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/alvan-nee-1vgfqdcux-4-unsplash.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 437547 + }, + "post": null, + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/alvan-nee-1vgfqdcux-4-unsplash.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media/49" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/users/14151046" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/comments?post=49" + } + ] + } + }, + { + "id": 47, + "date": "2020-08-24T12:06:47", + "date_gmt": "2020-08-24T19:06:47", + "guid": { + "rendered": "http://fourpawsdoggrooming.files.wordpress.com/2020/08/boney-dhirbh9en6i-unsplash.jpg" + }, + "modified": "2020-08-24T12:06:47", + "modified_gmt": "2020-08-24T19:06:47", + "slug": "boney-dhirbh9en6i-unsplash", + "status": "inherit", + "type": "attachment", + "link": "https://fourpawsdoggrooming.wordpress.com/boney-dhirbh9en6i-unsplash/", + "title": { + "rendered": "boney-dhirbh9en6i-unsplash" + }, + "author": 14151046, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acj1SD-L", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 4179, + "height": 2781, + "file": "2020/08/boney-dhirbh9en6i-unsplash.jpg", + "sizes": { + "thumbnail": { + "file": "boney-dhirbh9en6i-unsplash.jpg?w=150", + "width": 150, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/boney-dhirbh9en6i-unsplash.jpg?w=150" + }, + "medium": { + "file": "boney-dhirbh9en6i-unsplash.jpg?w=300", + "width": 300, + "height": 200, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/boney-dhirbh9en6i-unsplash.jpg?w=300" + }, + "large": { + "file": "boney-dhirbh9en6i-unsplash.jpg?w=1024", + "width": 1024, + "height": 681, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/boney-dhirbh9en6i-unsplash.jpg?w=1024" + }, + "full": { + "file": "boney-dhirbh9en6i-unsplash.jpg", + "width": 1024, + "height": 681, + "mime_type": "image/jpeg", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/boney-dhirbh9en6i-unsplash.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 1707842 + }, + "post": null, + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/boney-dhirbh9en6i-unsplash.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media/47" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/users/14151046" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/comments?post=47" + } + ] + } + }, + { + "id": 39, + "date": "2020-08-21T16:31:38", + "date_gmt": "2020-08-21T23:31:38", + "guid": { + "rendered": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png" + }, + "modified": "2020-08-21T16:31:38", + "modified_gmt": "2020-08-21T23:31:38", + "slug": "cropped-fourpaws-logo-2-1-png", + "status": "inherit", + "type": "attachment", + "link": "https://fourpawsdoggrooming.wordpress.com/cropped-fourpaws-logo-2-1-png/", + "title": { + "rendered": "cropped-fourpaws-logo-2-1.png" + }, + "author": 14151046, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acj1SD-D", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/png", + "media_details": { + "width": 512, + "height": 512, + "file": "2020/08/cropped-fourpaws-logo-2-1.png", + "sizes": { + "site_icon-270": { + "file": "cropped-fourpaws-logo-2-1-270x270.png", + "width": 270, + "height": 270, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png?w=512" + }, + "site_icon-192": { + "file": "cropped-fourpaws-logo-2-1-192x192.png", + "width": 192, + "height": 192, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png?w=512" + }, + "site_icon-180": { + "file": "cropped-fourpaws-logo-2-1-180x180.png", + "width": 180, + "height": 180, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png?w=512" + }, + "site_icon-32": { + "file": "cropped-fourpaws-logo-2-1-32x32.png", + "width": 32, + "height": 32, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png?w=512" + }, + "thumbnail": { + "file": "cropped-fourpaws-logo-2-1.png?w=150", + "width": 150, + "height": 150, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png?w=150" + }, + "medium": { + "file": "cropped-fourpaws-logo-2-1.png?w=300", + "width": 300, + "height": 300, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png?w=300" + }, + "large": { + "file": "cropped-fourpaws-logo-2-1.png?w=512", + "width": 512, + "height": 512, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png?w=512" + }, + "full": { + "file": "cropped-fourpaws-logo-2-1.png", + "width": 512, + "height": 512, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 11020 + }, + "post": null, + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media/39" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/users/14151046" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/comments?post=39" + } + ] + } + }, + { + "id": 37, + "date": "2020-08-21T16:30:59", + "date_gmt": "2020-08-21T23:30:59", + "guid": { + "rendered": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2.png" + }, + "modified": "2020-08-21T16:30:59", + "modified_gmt": "2020-08-21T23:30:59", + "slug": "cropped-fourpaws-logo-2-png", + "status": "inherit", + "type": "attachment", + "link": "https://fourpawsdoggrooming.wordpress.com/cropped-fourpaws-logo-2-png/", + "title": { + "rendered": "cropped-fourpaws-logo-2.png" + }, + "author": 14151046, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acj1SD-B", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/png", + "media_details": { + "width": 512, + "height": 512, + "file": "2020/08/cropped-fourpaws-logo-2.png", + "sizes": { + "thumbnail": { + "file": "cropped-fourpaws-logo-2.png?w=150", + "width": 150, + "height": 150, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2.png?w=150" + }, + "medium": { + "file": "cropped-fourpaws-logo-2.png?w=300", + "width": 300, + "height": 300, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2.png?w=300" + }, + "large": { + "file": "cropped-fourpaws-logo-2.png?w=512", + "width": 512, + "height": 512, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2.png?w=512" + }, + "full": { + "file": "cropped-fourpaws-logo-2.png", + "width": 512, + "height": 512, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2.png" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 11020 + }, + "post": null, + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2.png", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media/37" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/users/14151046" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/comments?post=37" + } + ] + } + }, + { + "id": 36, + "date": "2020-08-21T16:30:52", + "date_gmt": "2020-08-21T23:30:52", + "guid": { + "rendered": "http://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-2.png" + }, + "modified": "2020-08-21T16:30:52", + "modified_gmt": "2020-08-21T23:30:52", + "slug": "fourpaws-logo-3", + "status": "inherit", + "type": "attachment", + "link": "https://fourpawsdoggrooming.wordpress.com/fourpaws-logo-3/", + "title": { + "rendered": "FourPaws Logo" + }, + "author": 14151046, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acj1SD-A", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/png", + "media_details": { + "width": 512, + "height": 512, + "file": "2020/08/fourpaws-logo-2.png", + "sizes": { + "thumbnail": { + "file": "fourpaws-logo-2.png?w=150", + "width": 150, + "height": 150, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-2.png?w=150" + }, + "medium": { + "file": "fourpaws-logo-2.png?w=300", + "width": 300, + "height": 300, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-2.png?w=300" + }, + "large": { + "file": "fourpaws-logo-2.png?w=512", + "width": 512, + "height": 512, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-2.png?w=512" + }, + "full": { + "file": "fourpaws-logo-2.png", + "width": 512, + "height": 512, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-2.png" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 6945 + }, + "post": null, + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-2.png", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media/36" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/users/14151046" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/comments?post=36" + } + ] + } + }, + { + "id": 34, + "date": "2020-08-21T16:29:57", + "date_gmt": "2020-08-21T23:29:57", + "guid": { + "rendered": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-1.png" + }, + "modified": "2020-08-21T16:29:57", + "modified_gmt": "2020-08-21T23:29:57", + "slug": "cropped-fourpaws-logo-1-png", + "status": "inherit", + "type": "attachment", + "link": "https://fourpawsdoggrooming.wordpress.com/cropped-fourpaws-logo-1-png/", + "title": { + "rendered": "cropped-fourpaws-logo-1.png" + }, + "author": 14151046, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acj1SD-y", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/png", + "media_details": { + "width": 400, + "height": 400, + "file": "2020/08/cropped-fourpaws-logo-1.png", + "sizes": { + "thumbnail": { + "file": "cropped-fourpaws-logo-1.png?w=150", + "width": 150, + "height": 150, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-1.png?w=150" + }, + "medium": { + "file": "cropped-fourpaws-logo-1.png?w=300", + "width": 300, + "height": 300, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-1.png?w=300" + }, + "large": { + "file": "cropped-fourpaws-logo-1.png?w=400", + "width": 400, + "height": 400, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-1.png?w=400" + }, + "full": { + "file": "cropped-fourpaws-logo-1.png", + "width": 400, + "height": 400, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-1.png" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 8523 + }, + "post": null, + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-1.png", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media/34" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/users/14151046" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/comments?post=34" + } + ] + } + }, + { + "id": 33, + "date": "2020-08-21T16:29:49", + "date_gmt": "2020-08-21T23:29:49", + "guid": { + "rendered": "http://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-1.png" + }, + "modified": "2020-08-21T16:29:49", + "modified_gmt": "2020-08-21T23:29:49", + "slug": "fourpaws-logo-2", + "status": "inherit", + "type": "attachment", + "link": "https://fourpawsdoggrooming.wordpress.com/fourpaws-logo-2/", + "title": { + "rendered": "FourPaws Logo" + }, + "author": 14151046, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acj1SD-x", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/png", + "media_details": { + "width": 400, + "height": 400, + "file": "2020/08/fourpaws-logo-1.png", + "sizes": { + "thumbnail": { + "file": "fourpaws-logo-1.png?w=150", + "width": 150, + "height": 150, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-1.png?w=150" + }, + "medium": { + "file": "fourpaws-logo-1.png?w=300", + "width": 300, + "height": 300, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-1.png?w=300" + }, + "large": { + "file": "fourpaws-logo-1.png?w=400", + "width": 400, + "height": 400, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-1.png?w=400" + }, + "full": { + "file": "fourpaws-logo-1.png", + "width": 400, + "height": 400, + "mime_type": "image/png", + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-1.png" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 5332 + }, + "post": null, + "source_url": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/fourpaws-logo-1.png", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media/33" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/users/14151046" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181851495/comments?post=33" + } + ] + } + } + ], + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/v2_sites_181977606_media.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/v2_sites_181977606_media.json new file mode 100644 index 000000000000..e73dc3065c11 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/media/v2_sites_181977606_media.json @@ -0,0 +1,1266 @@ +{ + "request": { + "method": "GET", + "urlPath": "/wp/v2/sites/181977606/media" + }, + "response": { + "status": 200, + "jsonBody": [ + { + "id": 54, + "date": "2020-09-04T22:49:13", + "date_gmt": "2020-09-04T22:49:13", + "guid": { + "rendered": "http://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750300.jpg" + }, + "modified": "2020-09-04T22:49:13", + "modified_gmt": "2020-09-04T22:49:13", + "slug": "wp-1599259750300", + "status": "inherit", + "type": "attachment", + "link": "https://weekendbakesblog.wordpress.com/wp-1599259750300/", + "title": { + "rendered": "wp-1599259750300.jpg" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acjyGG-S", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1080, + "height": 1920, + "file": "2020/09/wp-1599259750300.jpg", + "sizes": { + "thumbnail": { + "file": "wp-1599259750300.jpg?w=84", + "width": 84, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750300.jpg?w=84" + }, + "medium": { + "file": "wp-1599259750300.jpg?w=169", + "width": 169, + "height": 300, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750300.jpg?w=169" + }, + "large": { + "file": "wp-1599259750300.jpg?w=576", + "width": 576, + "height": 1024, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750300.jpg?w=576" + }, + "full": { + "file": "wp-1599259750300.jpg", + "width": 1024, + "height": 1820, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750300.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 1185510 + }, + "post": null, + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750300.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media/54" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/comments?post=54" + } + ] + } + }, + { + "id": 53, + "date": "2020-09-04T22:49:13", + "date_gmt": "2020-09-04T22:49:13", + "guid": { + "rendered": "http://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750281.jpg" + }, + "modified": "2020-09-04T22:49:13", + "modified_gmt": "2020-09-04T22:49:13", + "slug": "wp-1599259750281", + "status": "inherit", + "type": "attachment", + "link": "https://weekendbakesblog.wordpress.com/wp-1599259750281/", + "title": { + "rendered": "wp-1599259750281.jpg" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acjyGG-R", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1080, + "height": 1920, + "file": "2020/09/wp-1599259750281.jpg", + "sizes": { + "thumbnail": { + "file": "wp-1599259750281.jpg?w=84", + "width": 84, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750281.jpg?w=84" + }, + "medium": { + "file": "wp-1599259750281.jpg?w=169", + "width": 169, + "height": 300, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750281.jpg?w=169" + }, + "large": { + "file": "wp-1599259750281.jpg?w=576", + "width": 576, + "height": 1024, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750281.jpg?w=576" + }, + "full": { + "file": "wp-1599259750281.jpg", + "width": 1024, + "height": 1820, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750281.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 1237165 + }, + "post": null, + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750281.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media/53" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/comments?post=53" + } + ] + } + }, + { + "id": 52, + "date": "2020-09-04T22:49:13", + "date_gmt": "2020-09-04T22:49:13", + "guid": { + "rendered": "http://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750292.jpg" + }, + "modified": "2020-09-04T22:49:13", + "modified_gmt": "2020-09-04T22:49:13", + "slug": "wp-1599259750292", + "status": "inherit", + "type": "attachment", + "link": "https://weekendbakesblog.wordpress.com/wp-1599259750292/", + "title": { + "rendered": "wp-1599259750292.jpg" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acjyGG-Q", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1080, + "height": 1920, + "file": "2020/09/wp-1599259750292.jpg", + "sizes": { + "thumbnail": { + "file": "wp-1599259750292.jpg?w=84", + "width": 84, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750292.jpg?w=84" + }, + "medium": { + "file": "wp-1599259750292.jpg?w=169", + "width": 169, + "height": 300, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750292.jpg?w=169" + }, + "large": { + "file": "wp-1599259750292.jpg?w=576", + "width": 576, + "height": 1024, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750292.jpg?w=576" + }, + "full": { + "file": "wp-1599259750292.jpg", + "width": 1024, + "height": 1820, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750292.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 1266683 + }, + "post": null, + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750292.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media/52" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/comments?post=52" + } + ] + } + }, + { + "id": 51, + "date": "2020-09-04T22:49:13", + "date_gmt": "2020-09-04T22:49:13", + "guid": { + "rendered": "http://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750308.jpg" + }, + "modified": "2020-09-04T22:49:13", + "modified_gmt": "2020-09-04T22:49:13", + "slug": "wp-1599259750308", + "status": "inherit", + "type": "attachment", + "link": "https://weekendbakesblog.wordpress.com/wp-1599259750308/", + "title": { + "rendered": "wp-1599259750308.jpg" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acjyGG-P", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1080, + "height": 1920, + "file": "2020/09/wp-1599259750308.jpg", + "sizes": { + "thumbnail": { + "file": "wp-1599259750308.jpg?w=84", + "width": 84, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750308.jpg?w=84" + }, + "medium": { + "file": "wp-1599259750308.jpg?w=169", + "width": 169, + "height": 300, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750308.jpg?w=169" + }, + "large": { + "file": "wp-1599259750308.jpg?w=576", + "width": 576, + "height": 1024, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750308.jpg?w=576" + }, + "full": { + "file": "wp-1599259750308.jpg", + "width": 1024, + "height": 1820, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750308.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 1292188 + }, + "post": null, + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750308.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media/51" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/comments?post=51" + } + ] + } + }, + { + "id": 50, + "date": "2020-09-04T22:49:13", + "date_gmt": "2020-09-04T22:49:13", + "guid": { + "rendered": "http://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750265.jpg" + }, + "modified": "2020-09-04T22:49:13", + "modified_gmt": "2020-09-04T22:49:13", + "slug": "wp-1599259750265", + "status": "inherit", + "type": "attachment", + "link": "https://weekendbakesblog.wordpress.com/wp-1599259750265/", + "title": { + "rendered": "wp-1599259750265.jpg" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acjyGG-O", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1080, + "height": 1920, + "file": "2020/09/wp-1599259750265.jpg", + "sizes": { + "thumbnail": { + "file": "wp-1599259750265.jpg?w=84", + "width": 84, + "height": 150, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750265.jpg?w=84" + }, + "medium": { + "file": "wp-1599259750265.jpg?w=169", + "width": 169, + "height": 300, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750265.jpg?w=169" + }, + "large": { + "file": "wp-1599259750265.jpg?w=576", + "width": 576, + "height": 1024, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750265.jpg?w=576" + }, + "full": { + "file": "wp-1599259750265.jpg", + "width": 1024, + "height": 1820, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750265.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "filesize": 1645026 + }, + "post": null, + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/09/wp-1599259750265.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media/50" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/comments?post=50" + } + ] + } + }, + { + "id": 49, + "date": "2020-08-26T22:12:44", + "date_gmt": "2020-08-26T22:12:44", + "guid": { + "rendered": "http://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-732548.jpg" + }, + "modified": "2020-08-26T22:12:44", + "modified_gmt": "2020-08-26T22:12:44", + "slug": "pexels-photo-732548", + "status": "inherit", + "type": "attachment", + "link": "https://weekendbakesblog.wordpress.com/pexels-photo-732548/", + "title": { + "rendered": "pexels-photo-732548" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acjyGG-N", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1880, + "height": 1253, + "file": "2020/08/pexels-photo-732548.jpg", + "sizes": { + "thumbnail": { + "file": "pexels-photo-732548.jpg?w=150", + "width": 150, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-732548.jpg?w=150" + }, + "medium": { + "file": "pexels-photo-732548.jpg?w=300", + "width": 300, + "height": 200, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-732548.jpg?w=300" + }, + "large": { + "file": "pexels-photo-732548.jpg?w=1024", + "width": 1024, + "height": 682, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-732548.jpg?w=1024" + }, + "full": { + "file": "pexels-photo-732548.jpg", + "width": 1024, + "height": 682, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-732548.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "filesize": 2850496 + }, + "post": null, + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-732548.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media/49" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/comments?post=49" + } + ] + } + }, + { + "id": 48, + "date": "2020-08-26T22:12:44", + "date_gmt": "2020-08-26T22:12:44", + "guid": { + "rendered": "http://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-131723.jpg" + }, + "modified": "2020-08-26T22:12:44", + "modified_gmt": "2020-08-26T22:12:44", + "slug": "pexels-photo-131723", + "status": "inherit", + "type": "attachment", + "link": "https://weekendbakesblog.wordpress.com/pexels-photo-131723/", + "title": { + "rendered": "pexels-photo-131723" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acjyGG-M", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1880, + "height": 1249, + "file": "2020/08/pexels-photo-131723.jpg", + "sizes": { + "thumbnail": { + "file": "pexels-photo-131723.jpg?w=150", + "width": 150, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-131723.jpg?w=150" + }, + "medium": { + "file": "pexels-photo-131723.jpg?w=300", + "width": 300, + "height": 199, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-131723.jpg?w=300" + }, + "large": { + "file": "pexels-photo-131723.jpg?w=1024", + "width": 1024, + "height": 680, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-131723.jpg?w=1024" + }, + "full": { + "file": "pexels-photo-131723.jpg", + "width": 1024, + "height": 680, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-131723.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "filesize": 2579198 + }, + "post": null, + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-131723.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media/48" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/comments?post=48" + } + ] + } + }, + { + "id": 47, + "date": "2020-08-26T22:12:44", + "date_gmt": "2020-08-26T22:12:44", + "guid": { + "rendered": "http://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-1237119.jpg" + }, + "modified": "2020-08-26T22:12:44", + "modified_gmt": "2020-08-26T22:12:44", + "slug": "pexels-photo-1237119", + "status": "inherit", + "type": "attachment", + "link": "https://weekendbakesblog.wordpress.com/pexels-photo-1237119/", + "title": { + "rendered": "pexels-photo-1237119" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acjyGG-L", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1880, + "height": 1250, + "file": "2020/08/pexels-photo-1237119.jpg", + "sizes": { + "thumbnail": { + "file": "pexels-photo-1237119.jpg?w=150", + "width": 150, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-1237119.jpg?w=150" + }, + "medium": { + "file": "pexels-photo-1237119.jpg?w=300", + "width": 300, + "height": 199, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-1237119.jpg?w=300" + }, + "large": { + "file": "pexels-photo-1237119.jpg?w=1024", + "width": 1024, + "height": 681, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-1237119.jpg?w=1024" + }, + "full": { + "file": "pexels-photo-1237119.jpg", + "width": 1024, + "height": 681, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-1237119.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "filesize": 2424849 + }, + "post": null, + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-1237119.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media/47" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/comments?post=47" + } + ] + } + }, + { + "id": 46, + "date": "2020-08-26T22:12:44", + "date_gmt": "2020-08-26T22:12:44", + "guid": { + "rendered": "http://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-371589.jpg" + }, + "modified": "2020-08-26T22:12:44", + "modified_gmt": "2020-08-26T22:12:44", + "slug": "pexels-photo-371589", + "status": "inherit", + "type": "attachment", + "link": "https://weekendbakesblog.wordpress.com/pexels-photo-371589/", + "title": { + "rendered": "pexels-photo-371589" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acjyGG-K", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1880, + "height": 1293, + "file": "2020/08/pexels-photo-371589.jpg", + "sizes": { + "thumbnail": { + "file": "pexels-photo-371589.jpg?w=150", + "width": 150, + "height": 103, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-371589.jpg?w=150" + }, + "medium": { + "file": "pexels-photo-371589.jpg?w=300", + "width": 300, + "height": 206, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-371589.jpg?w=300" + }, + "large": { + "file": "pexels-photo-371589.jpg?w=1024", + "width": 1024, + "height": 704, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-371589.jpg?w=1024" + }, + "full": { + "file": "pexels-photo-371589.jpg", + "width": 1024, + "height": 704, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-371589.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "filesize": 2563558 + }, + "post": null, + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-371589.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media/46" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/comments?post=46" + } + ] + } + }, + { + "id": 45, + "date": "2020-08-26T22:12:44", + "date_gmt": "2020-08-26T22:12:44", + "guid": { + "rendered": "http://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-235734.jpg" + }, + "modified": "2020-08-26T22:12:44", + "modified_gmt": "2020-08-26T22:12:44", + "slug": "pexels-photo-235734", + "status": "inherit", + "type": "attachment", + "link": "https://weekendbakesblog.wordpress.com/pexels-photo-235734/", + "title": { + "rendered": "pexels-photo-235734" + }, + "author": 191794483, + "comment_status": "open", + "ping_status": "closed", + "template": "", + "meta": { + "_coblocks_attr": "", + "_coblocks_dimensions": "", + "_coblocks_responsive_height": "", + "_coblocks_accordion_ie_support": "", + "advanced_seo_description": "", + "amp_status": "", + "spay_email": "" + }, + "jetpack_shortlink": "https://wp.me/acjyGG-J", + "jetpack_sharing_enabled": true, + "jetpack_likes_enabled": true, + "description": { + "rendered": "

\"\"

\n" + }, + "caption": { + "rendered": "" + }, + "alt_text": "", + "media_type": "image", + "mime_type": "image/jpeg", + "media_details": { + "width": 1880, + "height": 1253, + "file": "2020/08/pexels-photo-235734.jpg", + "sizes": { + "thumbnail": { + "file": "pexels-photo-235734.jpg?w=150", + "width": 150, + "height": 100, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-235734.jpg?w=150" + }, + "medium": { + "file": "pexels-photo-235734.jpg?w=300", + "width": 300, + "height": 200, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-235734.jpg?w=300" + }, + "large": { + "file": "pexels-photo-235734.jpg?w=1024", + "width": 1024, + "height": 682, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-235734.jpg?w=1024" + }, + "full": { + "file": "pexels-photo-235734.jpg", + "width": 1024, + "height": 682, + "mime_type": "image/jpeg", + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-235734.jpg" + } + }, + "image_meta": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "filesize": 2317303 + }, + "post": null, + "source_url": "https://weekendbakesblog.files.wordpress.com/2020/08/pexels-photo-235734.jpg", + "_links": { + "self": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media/45" + } + ], + "collection": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/media" + } + ], + "about": [ + { + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/types/attachment" + } + ], + "author": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/users/191794483" + } + ], + "replies": [ + { + "embeddable": true, + "href": "{{request.requestLine.baseUrl}}/wp/v2/sites/181977606/comments?post=45" + } + ] + } + } + ], + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/default-notifications.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/default-notifications.json new file mode 100644 index 000000000000..fc59d0f47d15 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/default-notifications.json @@ -0,0 +1,7947 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/notifications", + "queryParameters": { + "number": { + "equalTo": "200" + }, + "locale": { + "matches": "(.*)" + }, + "fields": { + "equalTo": "id,type,unread,body,subject,timestamp,meta" + }, + "num_note_items": { + "equalTo": "20" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "last_seen_time": "1553125740", + "number": 52, + "notes": [ + { + "id": 3864303999, + "type": "post_milestone_achievement", + "read": 1, + "noticon": "", + "timestamp": "2019-03-20T23:49:00+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/post-milestone-1-2x.png", + "url": "http://wordpress.com/trophy-case/", + "subject": [ + { + "text": "You've made your first post on In Focus Photography.", + "ranges": [ + { + "type": "site", + "indices": [ + 31, + 51 + ], + "url": "http://infocusphotographers.wordpress.com", + "id": 106707880 + } + ] + } + ], + "body": [ + { + "text": "First Post", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 10 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/post-milestone-1-2x.png" + } + ] + }, + { + "text": "Congratulations on writing your first post on In Focus Photography!", + "ranges": [ + { + "type": "site", + "indices": [ + 46, + 66 + ], + "url": "http://infocusphotographers.wordpress.com", + "id": 106707880 + } + ] + } + ], + "meta": { + "ids": { + "site": 106707880 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/106707880" + } + }, + "title": "First Post" + }, + { + "id": 1304254364, + "type": "follow", + "read": 1, + "noticon": "", + "timestamp": "2019-01-29T07:32:12+00:00", + "icon": "https://1.gravatar.com/avatar/dc6812f0d56d01c8d8a1f126458ffa7e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "http://thenomadicwordsmith.wordpress.com", + "subject": [ + { + "text": "Riley Watts and 6 others followed your blog Words, Whimsy, and the World", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 14 + ], + "url": "http://example.wordpress.com", + "site_id": 157098772, + "email": "example@wordpress.com", + "id": 136100716 + }, + { + "type": "site", + "indices": [ + 47, + 75 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "Riley Watts", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://example.wordpress.com", + "id": 136100716, + "site_id": 157098772, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/dc6812f0d56d01c8d8a1f126458ffa7e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://example.wordpress.com" + }, + "ids": { + "user": 136100716, + "site": 157098772 + }, + "titles": { + "home": "Site Title" + } + }, + "type": "user" + }, + { + "text": "Chloe Mosley", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://ybsm.wordpress.com", + "id": 911487, + "site_id": 878232, + "type": "user", + "indices": [ + 0, + 10 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/bd3072753daa7e03db6ec81716a662c1?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://ybsm.wordpress.com" + }, + "ids": { + "user": 911487, + "site": 878232 + }, + "titles": { + "home": "ybsm", + "tagline": "Just a diary. Read ybsm as yabusame. That's it." + } + }, + "type": "user" + }, + { + "text": "Rowan Shy", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://Rowanslittlenotes.wordpress.com", + "id": 126856765, + "site_id": 134651623, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/a16c450344e13c52abe9b7bb5b3ef103?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://Rowanslittlenotes.wordpress.com" + }, + "ids": { + "user": 126856765, + "site": 134651623 + }, + "titles": { + "home": "Rowan's little notes", + "tagline": "Learn about my life, recent events and hamsters" + } + }, + "type": "user" + }, + { + "text": "Bobby Whitaker", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://asterisk15.wordpress.com", + "id": 51178141, + "site_id": 53437676, + "type": "user", + "indices": [ + 0, + 13 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/7b1a301d11cd8c5639e01dfb4dd56b85?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://asterisk15.wordpress.com" + }, + "ids": { + "user": 51178141, + "site": 53437676 + }, + "titles": { + "home": "Mother Nature" + } + }, + "type": "user" + }, + { + "text": "Lisa S.", + "ranges": [ + { + "email": "example@wordpress.com", + "id": 74005263, + "type": "user", + "indices": [ + 0, + 7 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/88fa4a65e2d06dcf34c98c038e827e10?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "example@wordpress.com" + }, + "ids": { + "user": 74005263 + } + }, + "type": "user" + }, + { + "text": "Mark", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://iamtestingthisyay.wordpress.com", + "id": 74004255, + "site_id": 78387788, + "type": "user", + "indices": [ + 0, + 4 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://iamtestingthisyay.wordpress.com" + }, + "ids": { + "user": 74004255, + "site": 78387788 + }, + "titles": { + "home": "Nature Walks", + "tagline": "Exploring the countryside" + } + }, + "type": "user" + }, + { + "text": "PlayWithLIfE", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://www.playwithlife.org", + "id": 52196013, + "type": "user", + "indices": [ + 0, + 12 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/0e2284b177e549f3aab7138978122408?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://www.playwithlife.org" + }, + "ids": { + "user": 52196013 + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "7 Followers", + "header": [ + { + "text": "Words, Whimsy, and the World", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 28 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "id": 71769073 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://www.gravatar.com/avatar/ad516503a11cd5ca435acc9bb6523536?s=128" + } + ] + }, + { + "text": "Stories and images from The Nomadic Wordsmith" + } + ] + }, + { + "id": 3658516170, + "type": "like", + "read": 1, + "noticon": "", + "timestamp": "2018-11-24T02:09:15+00:00", + "icon": "https://1.gravatar.com/avatar/d20d9beb90a8e4c049135bb43c0c7c6e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2015/07/20/along-the-coast-2/", + "subject": [ + { + "text": "Brenton Hebert and documentedwanderlust liked your post Along the Coast", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 13 + ], + "url": "http://chaoticshapes.wordpress.com", + "site_id": 123568371, + "email": "example@wordpress.com", + "id": 116518196 + }, + { + "type": "user", + "indices": [ + 18, + 38 + ], + "url": "http://documentedwanderlust.wordpress.com", + "site_id": 108361066, + "email": "example@wordpress.com", + "id": 102352165 + }, + { + "type": "post", + "indices": [ + 55, + 70 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2015/07/20/along-the-coast-2/", + "site_id": 71769073, + "id": 132 + } + ] + } + ], + "body": [ + { + "text": "Brenton Hebert", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://chaoticshapes.wordpress.com", + "id": 116518196, + "site_id": 123568371, + "type": "user", + "indices": [ + 0, + 13 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/d20d9beb90a8e4c049135bb43c0c7c6e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://chaoticshapes.wordpress.com" + }, + "ids": { + "user": 116518196, + "site": 123568371 + }, + "titles": { + "home": "Chaotic Shapes", + "tagline": "Art and Lifestyle by Brenton Hebert" + } + }, + "type": "user" + }, + { + "text": "documentedwanderlust", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://documentedwanderlust.wordpress.com", + "id": 102352165, + "site_id": 108361066, + "type": "user", + "indices": [ + 0, + 20 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/bd5dc704bef73822c2247434f91e025f?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://documentedwanderlust.wordpress.com" + }, + "ids": { + "user": 102352165, + "site": 108361066 + }, + "titles": { + "home": "Documented Wanderlust" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073, + "post": 132 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/132" + } + }, + "title": "2 Likes", + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Along the Coast" + } + ] + }, + { + "id": 3658516156, + "type": "like", + "read": 1, + "noticon": "", + "timestamp": "2018-11-24T02:09:14+00:00", + "icon": "https://1.gravatar.com/avatar/d20d9beb90a8e4c049135bb43c0c7c6e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2016/01/28/along-the-coast-4/", + "subject": [ + { + "text": "Brenton Hebert liked your post Along the Coast", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 13 + ], + "url": "http://chaoticshapes.wordpress.com", + "site_id": 123568371, + "email": "example@wordpress.com", + "id": 116518196 + }, + { + "type": "post", + "indices": [ + 30, + 45 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2016/01/28/along-the-coast-4/", + "site_id": 71769073, + "id": 147 + } + ] + } + ], + "body": [ + { + "text": "Brenton Hebert", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://chaoticshapes.wordpress.com", + "id": 116518196, + "site_id": 123568371, + "type": "user", + "indices": [ + 0, + 13 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/d20d9beb90a8e4c049135bb43c0c7c6e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://chaoticshapes.wordpress.com" + }, + "ids": { + "user": 116518196, + "site": 123568371 + }, + "titles": { + "home": "Chaotic Shapes", + "tagline": "Art and Lifestyle by Brenton Hebert" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073, + "post": 147 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/147" + } + }, + "title": "1 Like", + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Along the Coast" + } + ] + }, + { + "id": 3467659357, + "type": "like", + "read": 1, + "noticon": "", + "timestamp": "2018-11-24T02:09:13+00:00", + "icon": "https://1.gravatar.com/avatar/d20d9beb90a8e4c049135bb43c0c7c6e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2016/08/25/along-the-coast-6/", + "subject": [ + { + "text": "Brenton Hebert and 2 others liked your post Along the Coast", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 13 + ], + "url": "http://chaoticshapes.wordpress.com", + "site_id": 123568371, + "email": "example@wordpress.com", + "id": 116518196 + }, + { + "type": "post", + "indices": [ + 43, + 58 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2016/08/25/along-the-coast-6/", + "site_id": 71769073, + "id": 161 + } + ] + } + ], + "body": [ + { + "text": "Brenton Hebert", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://chaoticshapes.wordpress.com", + "id": 116518196, + "site_id": 123568371, + "type": "user", + "indices": [ + 0, + 13 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/d20d9beb90a8e4c049135bb43c0c7c6e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://chaoticshapes.wordpress.com" + }, + "ids": { + "user": 116518196, + "site": 123568371 + }, + "titles": { + "home": "Chaotic Shapes", + "tagline": "Art and Lifestyle by Brenton Hebert" + } + }, + "type": "user" + }, + { + "text": "Riley Watts", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://example.wordpress.com", + "id": 136100716, + "site_id": 157098772, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/dc6812f0d56d01c8d8a1f126458ffa7e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://example.wordpress.com" + }, + "ids": { + "user": 136100716, + "site": 157098772 + }, + "titles": { + "home": "Site Title" + } + }, + "type": "user" + }, + { + "text": "Chloe Mosley", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://ybsm.wordpress.com", + "id": 911487, + "site_id": 878232, + "type": "user", + "indices": [ + 0, + 10 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/bd3072753daa7e03db6ec81716a662c1?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://ybsm.wordpress.com" + }, + "ids": { + "user": 911487, + "site": 878232 + }, + "titles": { + "home": "ybsm", + "tagline": "Just a diary. Read ybsm as yabusame. That's it." + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073, + "post": 161 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/161" + } + }, + "title": "3 Likes", + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Along the Coast" + } + ] + }, + { + "id": 3528647758, + "type": "like", + "read": 1, + "noticon": "", + "timestamp": "2018-09-03T06:30:38+00:00", + "icon": "https://1.gravatar.com/avatar/dc6812f0d56d01c8d8a1f126458ffa7e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/10/25/along-the-coast/", + "subject": [ + { + "text": "Riley Watts liked your post Along the Coast", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 14 + ], + "url": "http://example.wordpress.com", + "site_id": 157098772, + "email": "example@wordpress.com", + "id": 136100716 + }, + { + "type": "post", + "indices": [ + 31, + 46 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/10/25/along-the-coast/", + "site_id": 71769073, + "id": 79 + } + ] + } + ], + "body": [ + { + "text": "Riley Watts", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://example.wordpress.com", + "id": 136100716, + "site_id": 157098772, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/dc6812f0d56d01c8d8a1f126458ffa7e?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://example.wordpress.com" + }, + "ids": { + "user": 136100716, + "site": 157098772 + }, + "titles": { + "home": "Site Title" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073, + "post": 79 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/79" + } + }, + "title": "1 Like", + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Along the Coast" + } + ] + }, + { + "id": 3473753915, + "type": "achieve_user_anniversary", + "read": 1, + "noticon": "", + "timestamp": "2018-07-24T18:13:52+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/anniversary-2x.png", + "url": "http://wordpress.com/trophy-case/", + "subject": [ + { + "text": "Happy Anniversary with WordPress.com!" + } + ], + "body": [ + { + "text": "4 Year Anniversary Achievement", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 30 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/anniversary-2x.png" + } + ] + }, + { + "text": "Happy Anniversary with WordPress.com!" + }, + { + "text": "You registered on WordPress.com 4 years ago." + }, + { + "text": "Thanks for flying with us. Keep up the good blogging." + } + ], + "title": "Achievement" + }, + { + "id": 3467659628, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2018-07-19T23:46:17+00:00", + "icon": "https://2.gravatar.com/avatar/bd3072753daa7e03db6ec81716a662c1?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2016/08/25/along-the-coast-6/comment-page-1/#comment-35", + "subject": [ + { + "text": "Chloe Mosley commented on Along the Coast", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 10 + ], + "url": "http://ybsm.wordpress.com", + "site_id": 878232, + "email": "example@wordpress.com", + "id": 911487 + }, + { + "type": "post", + "indices": [ + 24, + 39 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2016/08/25/along-the-coast-6/", + "site_id": 71769073, + "id": 161 + } + ] + }, + { + "text": "If we could somehow harness this lightning, we could go back to the future!\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 76 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2016/08/25/along-the-coast-6/comment-page-1/#comment-35", + "site_id": 71769073, + "post_id": 161, + "id": 35 + } + ] + } + ], + "body": [ + { + "text": "Chloe Mosley", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://ybsm.wordpress.com", + "id": 911487, + "site_id": 878232, + "type": "user", + "indices": [ + 0, + 10 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/bd3072753daa7e03db6ec81716a662c1?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://ybsm.wordpress.com" + }, + "ids": { + "user": 911487, + "site": 878232 + }, + "titles": { + "home": "ybsm", + "tagline": "Just a diary. Read ybsm as yabusame. That's it." + } + }, + "type": "user" + }, + { + "text": "If we could somehow harness this lightning, we could go back to the future!", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 35, + "user": 911487, + "post": 161, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/35", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/911487", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/161", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/35?action=edit" + } + ], + "meta": { + "ids": { + "user": 911487, + "comment": 35, + "post": 161, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/911487", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/35", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/161", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Along the Coast" + } + ], + "title": "Comment" + }, + { + "id": 2985661262, + "type": "followed_milestone_achievement", + "read": 1, + "noticon": "", + "timestamp": "2017-07-07T20:45:02+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/followed-blog-5-2x.png", + "url": "http://wordpress.com/trophy-case/", + "subject": [ + { + "text": "You've received 5 follows on Words, Whimsy, and the World.", + "ranges": [ + { + "type": "site", + "indices": [ + 29, + 57 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "5 Follows!", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 10 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/followed-blog-5-2x.png" + } + ] + }, + { + "text": "Congratulations on getting 5 total follows on Words, Whimsy, and the World!", + "ranges": [ + { + "type": "site", + "indices": [ + 46, + 74 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + }, + { + "text": "Your current tally is 7." + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "5 Followers!" + }, + { + "id": 2584083750, + "type": "post_milestone_achievement", + "read": 1, + "noticon": "", + "timestamp": "2016-08-17T18:28:59+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/post-milestone-20-2x.png", + "url": "http://wordpress.com/trophy-case/", + "subject": [ + { + "text": "You've made 20 posts on Words, Whimsy, and the World.", + "ranges": [ + { + "type": "site", + "indices": [ + 24, + 52 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "20 Posts", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 8 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/post-milestone-20-2x.png" + } + ] + }, + { + "text": "Congratulations on writing 20 posts on Words, Whimsy, and the World!", + "ranges": [ + { + "type": "site", + "indices": [ + 39, + 67 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "20 Posts" + }, + { + "id": 1993981732, + "type": "achieve_user_anniversary", + "read": 1, + "noticon": "", + "timestamp": "2015-08-06T20:43:05+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/anniversary-2x.png", + "url": "http://wordpress.com/trophy-case/", + "subject": [ + { + "text": "Happy Anniversary with WordPress.com!" + } + ], + "body": [ + { + "text": "1 Year Anniversary Achievement", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 30 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/anniversary-2x.png" + } + ] + }, + { + "text": "Happy Anniversary with WordPress.com!" + }, + { + "text": "You registered on WordPress.com one year ago." + }, + { + "text": "Thanks for flying with us. Keep up the good blogging." + } + ], + "title": "Achievement" + }, + { + "id": 1305979273, + "type": "like", + "read": 1, + "noticon": "", + "timestamp": "2015-05-30T18:20:54+00:00", + "icon": "https://1.gravatar.com/avatar/7b1a301d11cd8c5639e01dfb4dd56b85?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/about/", + "subject": [ + { + "text": "Bobby Whitaker and 2 others liked your post About This Nomad", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 13 + ], + "url": "http://asterisk15.wordpress.com", + "site_id": 53437676, + "email": "example@wordpress.com", + "id": 51178141 + }, + { + "type": "post", + "indices": [ + 43, + 59 + ], + "url": "https://thenomadicwordsmith.wordpress.com/about/", + "site_id": 71769073, + "id": 1 + } + ] + } + ], + "body": [ + { + "text": "Bobby Whitaker", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://asterisk15.wordpress.com", + "id": 51178141, + "site_id": 53437676, + "type": "user", + "indices": [ + 0, + 13 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/7b1a301d11cd8c5639e01dfb4dd56b85?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://asterisk15.wordpress.com" + }, + "ids": { + "user": 51178141, + "site": 53437676 + }, + "titles": { + "home": "Mother Nature" + } + }, + "type": "user" + }, + { + "text": "Nancy T.", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://thisismyportfolioyay.wordpress.com", + "id": 74237684, + "site_id": 45151698, + "type": "user", + "indices": [ + 0, + 8 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/4e3309b5cf977f89206c1dbb2b00e8e3?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": true + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://thisismyportfolioyay.wordpress.com" + }, + "ids": { + "user": 74237684, + "site": 45151698 + }, + "titles": { + "home": "Writing Bytes" + } + }, + "type": "user" + }, + { + "text": "Cheri Lucas Rowlands", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://cherilucasrowlands.com", + "id": 10183950, + "site_id": 9838404, + "type": "user", + "indices": [ + 0, + 20 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://cherilucasrowlands.com" + }, + "ids": { + "user": 10183950, + "site": 9838404 + }, + "titles": { + "home": "Cheri Lucas Rowlands" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073, + "post": 1 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/1" + } + }, + "title": "3 Likes", + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "About This Nomad" + } + ] + }, + { + "id": 1478993708, + "type": "traffic_surge", + "read": 1, + "noticon": "", + "timestamp": "2014-11-01T16:17:37+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/notes/images/traffic-surge-note-icon-256.png", + "url": "https://wordpress.com/stats/day/thenomadicwordsmith.wordpress.com?startDate=2014-11-01", + "subject": [ + { + "text": "Your stats are booming! Words, Whimsy, and the World is getting lots of traffic.", + "ranges": [ + { + "type": "site", + "indices": [ + 24, + 52 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 0 + ], + "url": "https://s.wp.com/wp-content/mu-plugins/notes/images/traffic-surge-note-icon-256.png" + } + ] + }, + { + "text": "Your blog, Words, Whimsy, and the World, appears to be getting more traffic than usual! 26 hourly views - 1 hourly views on average", + "ranges": [ + { + "type": "site", + "indices": [ + 11, + 39 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + }, + { + "text": "A spike in your stats", + "ranges": [ + { + "url": "https://wordpress.com/stats/day/thenomadicwordsmith.wordpress.com?startDate=2014-11-01", + "indices": [ + 11, + 21 + ], + "type": "stat", + "site_id": 71769073 + } + ] + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "Boom!" + }, + { + "id": 1477873727, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-11-01T01:45:35+00:00", + "icon": "https://1.gravatar.com/avatar/4e3309b5cf977f89206c1dbb2b00e8e3?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/two-more-hours/comment-page-1/#comment-30", + "subject": [ + { + "text": "Nancy T. commented on Two More Hours: For Writing, For Magic", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 8 + ], + "url": "http://thisismyportfolioyay.wordpress.com", + "site_id": 45151698, + "email": "example@wordpress.com", + "id": 74237684 + }, + { + "type": "post", + "indices": [ + 22, + 60 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/two-more-hours/", + "site_id": 71769073, + "id": 18 + } + ] + }, + { + "text": "Lovely, thoughtful writing. Thank you.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 39 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/two-more-hours/comment-page-1/#comment-30", + "site_id": 71769073, + "post_id": 18, + "id": 30 + } + ] + } + ], + "body": [ + { + "text": "Nancy T.", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://thisismyportfolioyay.wordpress.com", + "id": 74237684, + "site_id": 45151698, + "type": "user", + "indices": [ + 0, + 8 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/4e3309b5cf977f89206c1dbb2b00e8e3?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": true + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://thisismyportfolioyay.wordpress.com" + }, + "ids": { + "user": 74237684, + "site": 45151698 + }, + "titles": { + "home": "Writing Bytes" + } + }, + "type": "user" + }, + { + "text": "Lovely, thoughtful writing. Thank you.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 30, + "user": 74237684, + "post": 18, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/30", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74237684", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/18", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/30?action=edit" + } + ], + "meta": { + "ids": { + "user": 74237684, + "comment": 30, + "post": 18, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74237684", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/30", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/18", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Two More Hours: For Writing, For Magic" + } + ], + "title": "Comment" + }, + { + "id": 1477860339, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-11-01T01:34:30+00:00", + "icon": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/comment-page-1/#comment-27", + "subject": [ + { + "text": "Mark commented on Roaming in the Desert", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 4 + ], + "url": "http://iamtestingthisyay.wordpress.com", + "site_id": 78387788, + "email": "example@wordpress.com", + "id": 74004255 + }, + { + "type": "post", + "indices": [ + 18, + 39 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/", + "site_id": 71769073, + "id": 45 + } + ] + }, + { + "text": "Very cool poem and photographs.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 32 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/comment-page-1/#comment-27", + "site_id": 71769073, + "post_id": 45, + "id": 27 + } + ] + } + ], + "body": [ + { + "text": "Mark", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://iamtestingthisyay.wordpress.com", + "id": 74004255, + "site_id": 78387788, + "type": "user", + "indices": [ + 0, + 4 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://iamtestingthisyay.wordpress.com" + }, + "ids": { + "user": 74004255, + "site": 78387788 + }, + "titles": { + "home": "Nature Walks", + "tagline": "Exploring the countryside" + } + }, + "type": "user" + }, + { + "text": "Very cool poem and photographs.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 27, + "user": 74004255, + "post": 45, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/27", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/45", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/27?action=edit" + } + ], + "meta": { + "ids": { + "user": 74004255, + "comment": 27, + "post": 45, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/27", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/45", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Roaming in the Desert" + } + ], + "title": "Comment" + }, + { + "id": 1304317703, + "type": "like", + "read": 1, + "noticon": "", + "timestamp": "2014-10-29T00:14:29+00:00", + "icon": "https://1.gravatar.com/avatar/a494890f130ef2b12dba43c21e1ea028?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/", + "subject": [ + { + "text": "Meredith and Cheri Lucas Rowlands liked your post Roaming in the Desert", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 8 + ], + "url": "http://stupidmatters.com", + "site_id": 58167694, + "email": "example@wordpress.com", + "id": 25855508 + }, + { + "type": "user", + "indices": [ + 13, + 33 + ], + "url": "http://cherilucasrowlands.com", + "site_id": 9838404, + "email": "example@wordpress.com", + "id": 10183950 + }, + { + "type": "post", + "indices": [ + 50, + 71 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/", + "site_id": 71769073, + "id": 45 + } + ] + } + ], + "body": [ + { + "text": "Meredith", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://stupidmatters.com", + "id": 25855508, + "site_id": 58167694, + "type": "user", + "indices": [ + 0, + 8 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/a494890f130ef2b12dba43c21e1ea028?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://stupidmatters.com" + }, + "ids": { + "user": 25855508, + "site": 58167694 + }, + "titles": { + "home": "Stupid Matters", + "tagline": "Finding brilliance in the obvious and humor in the dumb." + } + }, + "type": "user" + }, + { + "text": "Cheri Lucas Rowlands", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://cherilucasrowlands.com", + "id": 10183950, + "site_id": 9838404, + "type": "user", + "indices": [ + 0, + 20 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://cherilucasrowlands.com" + }, + "ids": { + "user": 10183950, + "site": 9838404 + }, + "titles": { + "home": "Cheri Lucas Rowlands" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073, + "post": 45 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/45" + } + }, + "title": "2 Likes", + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Roaming in the Desert" + } + ] + }, + { + "id": 1469692035, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-28T00:56:32+00:00", + "icon": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/comment-page-1/#comment-24", + "subject": [ + { + "text": "Mark commented on Great Perspective on Blogging", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 4 + ], + "url": "http://iamtestingthisyay.wordpress.com", + "site_id": 78387788, + "email": "example@wordpress.com", + "id": 74004255 + }, + { + "type": "post", + "indices": [ + 18, + 47 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/", + "site_id": 71769073, + "id": 40 + } + ] + }, + { + "text": "Nice perspective on blogging — thanks.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 39 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/comment-page-1/#comment-24", + "site_id": 71769073, + "post_id": 40, + "id": 24 + } + ] + } + ], + "body": [ + { + "text": "Mark", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://iamtestingthisyay.wordpress.com", + "id": 74004255, + "site_id": 78387788, + "type": "user", + "indices": [ + 0, + 4 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://iamtestingthisyay.wordpress.com" + }, + "ids": { + "user": 74004255, + "site": 78387788 + }, + "titles": { + "home": "Nature Walks", + "tagline": "Exploring the countryside" + } + }, + "type": "user" + }, + { + "text": "Nice perspective on blogging — thanks.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 24, + "user": 74004255, + "post": 40, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/24", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/40", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/24?action=edit" + } + ], + "meta": { + "ids": { + "user": 74004255, + "comment": 24, + "post": 40, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/24", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/40", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Great Perspective on Blogging" + } + ], + "title": "Comment" + }, + { + "id": 1469662312, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-28T00:31:29+00:00", + "icon": "https://1.gravatar.com/avatar/4e3309b5cf977f89206c1dbb2b00e8e3?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/08/22/out-to-sea/comment-page-1/#comment-23", + "subject": [ + { + "text": "Nancy T. commented on Out to Sea", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 8 + ], + "url": "http://thisismyportfolioyay.wordpress.com", + "site_id": 45151698, + "email": "example@wordpress.com", + "id": 74237684 + }, + { + "type": "post", + "indices": [ + 22, + 32 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/08/22/out-to-sea/", + "site_id": 71769073, + "id": 56 + } + ] + }, + { + "text": "Great shot of a historic lighthouse.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 37 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/08/22/out-to-sea/comment-page-1/#comment-23", + "site_id": 71769073, + "post_id": 56, + "id": 23 + } + ] + } + ], + "body": [ + { + "text": "Nancy T.", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://thisismyportfolioyay.wordpress.com", + "id": 74237684, + "site_id": 45151698, + "type": "user", + "indices": [ + 0, + 8 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/4e3309b5cf977f89206c1dbb2b00e8e3?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": true + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://thisismyportfolioyay.wordpress.com" + }, + "ids": { + "user": 74237684, + "site": 45151698 + }, + "titles": { + "home": "Writing Bytes" + } + }, + "type": "user" + }, + { + "text": "Great shot of a historic lighthouse.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 23, + "user": 74237684, + "post": 56, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/23", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74237684", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/56", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/23?action=edit" + } + ], + "meta": { + "ids": { + "user": 74237684, + "comment": 23, + "post": 56, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74237684", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/23", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/56", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Out to Sea" + } + ], + "title": "Comment" + }, + { + "id": 1469657359, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-28T00:27:40+00:00", + "icon": "https://2.gravatar.com/avatar/88fa4a65e2d06dcf34c98c038e827e10?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/comment-page-1/#comment-21", + "subject": [ + { + "text": "Lisa S. commented on Roaming in the Desert", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 7 + ], + "email": "example@wordpress.com", + "id": 74005263 + }, + { + "type": "post", + "indices": [ + 21, + 42 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/", + "site_id": 71769073, + "id": 45 + } + ] + }, + { + "text": "Love this poem. It’s short and sweet.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 38 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/comment-page-1/#comment-21", + "site_id": 71769073, + "post_id": 45, + "id": 21 + } + ] + } + ], + "body": [ + { + "text": "Lisa S.", + "ranges": [ + { + "email": "example@wordpress.com", + "id": 74005263, + "type": "user", + "indices": [ + 0, + 7 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/88fa4a65e2d06dcf34c98c038e827e10?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "example@wordpress.com" + }, + "ids": { + "user": 74005263 + } + }, + "type": "user" + }, + { + "text": "Love this poem. It’s short and sweet.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 21, + "user": 74005263, + "post": 45, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/21", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74005263", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/45", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/21?action=edit" + } + ], + "meta": { + "ids": { + "user": 74005263, + "comment": 21, + "post": 45, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74005263", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/21", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/45", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Roaming in the Desert" + } + ], + "title": "Comment" + }, + { + "id": 1469655326, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-28T00:26:02+00:00", + "icon": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-20", + "subject": [ + { + "text": "Mark commented on Dreaming of Elsewhere", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 4 + ], + "url": "http://iamtestingthisyay.wordpress.com", + "site_id": 78387788, + "email": "example@wordpress.com", + "id": 74004255 + }, + { + "type": "post", + "indices": [ + 18, + 39 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/", + "site_id": 71769073, + "id": 38 + } + ] + }, + { + "text": "Gorgeous gallery! I like the picture overlooking the Pacific.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 62 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-20", + "site_id": 71769073, + "post_id": 38, + "id": 20 + } + ] + } + ], + "body": [ + { + "text": "Mark", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://iamtestingthisyay.wordpress.com", + "id": 74004255, + "site_id": 78387788, + "type": "user", + "indices": [ + 0, + 4 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://iamtestingthisyay.wordpress.com" + }, + "ids": { + "user": 74004255, + "site": 78387788 + }, + "titles": { + "home": "Nature Walks", + "tagline": "Exploring the countryside" + } + }, + "type": "user" + }, + { + "text": "Gorgeous gallery! I like the picture overlooking the Pacific.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 20, + "user": 74004255, + "post": 38, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/20", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/20?action=edit" + } + ], + "meta": { + "ids": { + "user": 74004255, + "comment": 20, + "post": 38, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/20", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Dreaming of Elsewhere" + } + ], + "title": "Comment" + }, + { + "id": 1469652677, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-28T00:24:00+00:00", + "icon": "https://1.gravatar.com/avatar/4e3309b5cf977f89206c1dbb2b00e8e3?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/two-more-hours/comment-page-1/#comment-19", + "subject": [ + { + "text": "Nancy T. commented on Two More Hours: For Writing, For Magic", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 8 + ], + "url": "http://thisismyportfolioyay.wordpress.com", + "site_id": 45151698, + "email": "example@wordpress.com", + "id": 74237684 + }, + { + "type": "post", + "indices": [ + 22, + 60 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/two-more-hours/", + "site_id": 71769073, + "id": 18 + } + ] + }, + { + "text": "A thoughtful piece on time and the writing process.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 52 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/two-more-hours/comment-page-1/#comment-19", + "site_id": 71769073, + "post_id": 18, + "id": 19 + } + ] + } + ], + "body": [ + { + "text": "Nancy T.", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://thisismyportfolioyay.wordpress.com", + "id": 74237684, + "site_id": 45151698, + "type": "user", + "indices": [ + 0, + 8 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/4e3309b5cf977f89206c1dbb2b00e8e3?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": true + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://thisismyportfolioyay.wordpress.com" + }, + "ids": { + "user": 74237684, + "site": 45151698 + }, + "titles": { + "home": "Writing Bytes" + } + }, + "type": "user" + }, + { + "text": "A thoughtful piece on time and the writing process.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 19, + "user": 74237684, + "post": 18, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/19", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74237684", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/18", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/19?action=edit" + } + ], + "meta": { + "ids": { + "user": 74237684, + "comment": 19, + "post": 18, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74237684", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/19", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/18", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Two More Hours: For Writing, For Magic" + } + ], + "title": "Comment" + }, + { + "id": 1469400992, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-27T21:24:54+00:00", + "icon": "https://2.gravatar.com/avatar/88fa4a65e2d06dcf34c98c038e827e10?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-18", + "subject": [ + { + "text": "Lisa S. commented on Dreaming of Elsewhere", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 7 + ], + "email": "example@wordpress.com", + "id": 74005263 + }, + { + "type": "post", + "indices": [ + 21, + 42 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/", + "site_id": 71769073, + "id": 38 + } + ] + }, + { + "text": "Fantastic gallery!\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 19 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-18", + "site_id": 71769073, + "post_id": 38, + "id": 18 + } + ] + } + ], + "body": [ + { + "text": "Lisa S.", + "ranges": [ + { + "email": "example@wordpress.com", + "id": 74005263, + "type": "user", + "indices": [ + 0, + 7 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/88fa4a65e2d06dcf34c98c038e827e10?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "example@wordpress.com" + }, + "ids": { + "user": 74005263 + } + }, + "type": "user" + }, + { + "text": "Fantastic gallery!", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 18, + "user": 74005263, + "post": 38, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/18", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74005263", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/18?action=edit" + } + ], + "meta": { + "ids": { + "user": 74005263, + "comment": 18, + "post": 38, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74005263", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/18", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Dreaming of Elsewhere" + } + ], + "title": "Comment" + }, + { + "id": 1469398671, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-27T21:23:17+00:00", + "icon": "https://2.gravatar.com/avatar/e6f1fa8fa90f7e1b618be976bc8f82d4?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/comment-page-1/#comment-17", + "subject": [ + { + "text": "Alexander commented on Great Perspective on Blogging", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 9 + ], + "email": "example@wordpress.com", + "id": 74006837 + }, + { + "type": "post", + "indices": [ + 23, + 52 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/", + "site_id": 71769073, + "id": 40 + } + ] + }, + { + "text": "I agree: blogging is hard sometimes.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 37 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/comment-page-1/#comment-17", + "site_id": 71769073, + "post_id": 40, + "id": 17 + } + ] + } + ], + "body": [ + { + "text": "Alexander", + "ranges": [ + { + "email": "example@wordpress.com", + "id": 74006837, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/e6f1fa8fa90f7e1b618be976bc8f82d4?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "example@wordpress.com" + }, + "ids": { + "user": 74006837 + } + }, + "type": "user" + }, + { + "text": "I agree: blogging is hard sometimes.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 17, + "user": 74006837, + "post": 40, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/17", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74006837", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/40", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/17?action=edit" + } + ], + "meta": { + "ids": { + "user": 74006837, + "comment": 17, + "post": 40, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74006837", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/17", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/40", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Great Perspective on Blogging" + } + ], + "title": "Comment" + }, + { + "id": 1469396527, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-27T21:21:49+00:00", + "icon": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/comment-page-1/#comment-16", + "subject": [ + { + "text": "Mark commented on Roaming in the Desert", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 4 + ], + "url": "http://iamtestingthisyay.wordpress.com", + "site_id": 78387788, + "email": "example@wordpress.com", + "id": 74004255 + }, + { + "type": "post", + "indices": [ + 18, + 39 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/", + "site_id": 71769073, + "id": 45 + } + ] + }, + { + "text": "Great night shot! The poem is short and sweet.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 47 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/comment-page-1/#comment-16", + "site_id": 71769073, + "post_id": 45, + "id": 16 + } + ] + } + ], + "body": [ + { + "text": "Mark", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://iamtestingthisyay.wordpress.com", + "id": 74004255, + "site_id": 78387788, + "type": "user", + "indices": [ + 0, + 4 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://iamtestingthisyay.wordpress.com" + }, + "ids": { + "user": 74004255, + "site": 78387788 + }, + "titles": { + "home": "Nature Walks", + "tagline": "Exploring the countryside" + } + }, + "type": "user" + }, + { + "text": "Great night shot! The poem is short and sweet.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 16, + "user": 74004255, + "post": 45, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/16", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/45", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/16?action=edit" + } + ], + "meta": { + "ids": { + "user": 74004255, + "comment": 16, + "post": 45, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/16", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/45", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Roaming in the Desert" + } + ], + "title": "Comment" + }, + { + "id": 1469393320, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-27T21:19:48+00:00", + "icon": "https://2.gravatar.com/avatar/88fa4a65e2d06dcf34c98c038e827e10?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/10/25/along-the-coast/comment-page-1/#comment-15", + "subject": [ + { + "text": "Lisa S. commented on Along the Coast", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 7 + ], + "email": "example@wordpress.com", + "id": 74005263 + }, + { + "type": "post", + "indices": [ + 21, + 36 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/10/25/along-the-coast/", + "site_id": 71769073, + "id": 79 + } + ] + }, + { + "text": "I miss the ocean. What a lovely view.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 38 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/10/25/along-the-coast/comment-page-1/#comment-15", + "site_id": 71769073, + "post_id": 79, + "id": 15 + } + ] + } + ], + "body": [ + { + "text": "Lisa S.", + "ranges": [ + { + "email": "example@wordpress.com", + "id": 74005263, + "type": "user", + "indices": [ + 0, + 7 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/88fa4a65e2d06dcf34c98c038e827e10?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "example@wordpress.com" + }, + "ids": { + "user": 74005263 + } + }, + "type": "user" + }, + { + "text": "I miss the ocean. What a lovely view.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 15, + "user": 74005263, + "post": 79, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/15", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74005263", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/79", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/15?action=edit" + } + ], + "meta": { + "ids": { + "user": 74005263, + "comment": 15, + "post": 79, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74005263", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/15", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/79", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Along the Coast" + } + ], + "title": "Comment" + }, + { + "id": 1465445299, + "type": "traffic_surge", + "read": 1, + "noticon": "", + "timestamp": "2014-10-25T15:19:28+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/notes/images/traffic-surge-note-icon-256.png", + "url": "https://wordpress.com/stats/day/thenomadicwordsmith.wordpress.com?startDate=2014-10-25", + "subject": [ + { + "text": "Your stats are booming! Words, Whimsy, and the World is getting lots of traffic.", + "ranges": [ + { + "type": "site", + "indices": [ + 24, + 52 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 0 + ], + "url": "https://s.wp.com/wp-content/mu-plugins/notes/images/traffic-surge-note-icon-256.png" + } + ] + }, + { + "text": "Your blog, Words, Whimsy, and the World, appears to be getting more traffic than usual! 34 hourly views - 0 hourly views on average", + "ranges": [ + { + "type": "site", + "indices": [ + 11, + 39 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + }, + { + "text": "A spike in your stats", + "ranges": [ + { + "url": "https://wordpress.com/stats/day/thenomadicwordsmith.wordpress.com?startDate=2014-10-25", + "indices": [ + 11, + 21 + ], + "type": "stat", + "site_id": 71769073 + } + ] + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "Boom!" + }, + { + "id": 1462051138, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-23T18:37:58+00:00", + "icon": "https://2.gravatar.com/avatar/e6f1fa8fa90f7e1b618be976bc8f82d4?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-14", + "subject": [ + { + "text": "Alexander commented on Dreaming of Elsewhere", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 9 + ], + "email": "example@wordpress.com", + "id": 74006837 + }, + { + "type": "post", + "indices": [ + 23, + 44 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/", + "site_id": 71769073, + "id": 38 + } + ] + }, + { + "text": "Superb shot from the monument in Berlin.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 41 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-14", + "site_id": 71769073, + "post_id": 38, + "id": 14 + } + ] + } + ], + "body": [ + { + "text": "Alexander", + "ranges": [ + { + "email": "example@wordpress.com", + "id": 74006837, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/e6f1fa8fa90f7e1b618be976bc8f82d4?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "example@wordpress.com" + }, + "ids": { + "user": 74006837 + } + }, + "type": "user" + }, + { + "text": "Superb shot from the monument in Berlin.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 14, + "user": 74006837, + "post": 38, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/14", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74006837", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/14?action=edit" + } + ], + "meta": { + "ids": { + "user": 74006837, + "comment": 14, + "post": 38, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74006837", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/14", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Dreaming of Elsewhere" + } + ], + "title": "Comment" + }, + { + "id": 1462042059, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-23T18:31:42+00:00", + "icon": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/comment-page-1/#comment-13", + "subject": [ + { + "text": "Mark commented on Roaming in the Desert", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 4 + ], + "url": "http://iamtestingthisyay.wordpress.com", + "site_id": 78387788, + "email": "example@wordpress.com", + "id": 74004255 + }, + { + "type": "post", + "indices": [ + 18, + 39 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/", + "site_id": 71769073, + "id": 45 + } + ] + }, + { + "text": "This poem is perfect!\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 22 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/burning-man/comment-page-1/#comment-13", + "site_id": 71769073, + "post_id": 45, + "id": 13 + } + ] + } + ], + "body": [ + { + "text": "Mark", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://iamtestingthisyay.wordpress.com", + "id": 74004255, + "site_id": 78387788, + "type": "user", + "indices": [ + 0, + 4 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://iamtestingthisyay.wordpress.com" + }, + "ids": { + "user": 74004255, + "site": 78387788 + }, + "titles": { + "home": "Nature Walks", + "tagline": "Exploring the countryside" + } + }, + "type": "user" + }, + { + "text": "This poem is perfect!", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 13, + "user": 74004255, + "post": 45, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/13", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/45", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/13?action=edit" + } + ], + "meta": { + "ids": { + "user": 74004255, + "comment": 13, + "post": 45, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/13", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/45", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Roaming in the Desert" + } + ], + "title": "Comment" + }, + { + "id": 1462036088, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-23T18:27:57+00:00", + "icon": "https://2.gravatar.com/avatar/88fa4a65e2d06dcf34c98c038e827e10?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/08/22/out-to-sea/comment-page-1/#comment-12", + "subject": [ + { + "text": "Lisa S. commented on Out to Sea", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 7 + ], + "email": "example@wordpress.com", + "id": 74005263 + }, + { + "type": "post", + "indices": [ + 21, + 31 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/08/22/out-to-sea/", + "site_id": 71769073, + "id": 56 + } + ] + }, + { + "text": "I’d love to visit this historical landmark along the sea.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 58 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/08/22/out-to-sea/comment-page-1/#comment-12", + "site_id": 71769073, + "post_id": 56, + "id": 12 + } + ] + } + ], + "body": [ + { + "text": "Lisa S.", + "ranges": [ + { + "email": "example@wordpress.com", + "id": 74005263, + "type": "user", + "indices": [ + 0, + 7 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/88fa4a65e2d06dcf34c98c038e827e10?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "example@wordpress.com" + }, + "ids": { + "user": 74005263 + } + }, + "type": "user" + }, + { + "text": "I’d love to visit this historical landmark along the sea.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 12, + "user": 74005263, + "post": 56, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/12", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74005263", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/56", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/12?action=edit" + } + ], + "meta": { + "ids": { + "user": 74005263, + "comment": 12, + "post": 56, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74005263", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/12", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/56", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Out to Sea" + } + ], + "title": "Comment" + }, + { + "id": 1462026240, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-23T18:21:25+00:00", + "icon": "https://2.gravatar.com/avatar/e6f1fa8fa90f7e1b618be976bc8f82d4?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/comment-page-1/#comment-11", + "subject": [ + { + "text": "Alexander commented on Great Perspective on Blogging", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 9 + ], + "email": "example@wordpress.com", + "id": 74006837 + }, + { + "type": "post", + "indices": [ + 23, + 52 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/", + "site_id": 71769073, + "id": 40 + } + ] + }, + { + "text": "I feel the same! Making lasting connections with readers is important.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 71 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/comment-page-1/#comment-11", + "site_id": 71769073, + "post_id": 40, + "id": 11 + } + ] + } + ], + "body": [ + { + "text": "Alexander", + "ranges": [ + { + "email": "example@wordpress.com", + "id": 74006837, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/e6f1fa8fa90f7e1b618be976bc8f82d4?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "example@wordpress.com" + }, + "ids": { + "user": 74006837 + } + }, + "type": "user" + }, + { + "text": "I feel the same! Making lasting connections with readers is important.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 11, + "user": 74006837, + "post": 40, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/11", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74006837", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/40", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/11?action=edit" + } + ], + "meta": { + "ids": { + "user": 74006837, + "comment": 11, + "post": 40, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74006837", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/11", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/40", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Great Perspective on Blogging" + } + ], + "title": "Comment" + }, + { + "id": 1461981982, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-10-23T17:53:38+00:00", + "icon": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-10", + "subject": [ + { + "text": "Mark commented on Dreaming of Elsewhere", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 4 + ], + "url": "http://iamtestingthisyay.wordpress.com", + "site_id": 78387788, + "email": "example@wordpress.com", + "id": 74004255 + }, + { + "type": "post", + "indices": [ + 18, + 39 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/", + "site_id": 71769073, + "id": 38 + } + ] + }, + { + "text": "Great photographs. The shot overlooking the ocean is fantastic!\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 64 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-10", + "site_id": 71769073, + "post_id": 38, + "id": 10 + } + ] + } + ], + "body": [ + { + "text": "Mark", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://iamtestingthisyay.wordpress.com", + "id": 74004255, + "site_id": 78387788, + "type": "user", + "indices": [ + 0, + 4 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/54d388fd4592cfed0b14bd1d52f80185?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://iamtestingthisyay.wordpress.com" + }, + "ids": { + "user": 74004255, + "site": 78387788 + }, + "titles": { + "home": "Nature Walks", + "tagline": "Exploring the countryside" + } + }, + "type": "user" + }, + { + "text": "Great photographs. The shot overlooking the ocean is fantastic!", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 10, + "user": 74004255, + "post": 38, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/10", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/10?action=edit" + } + ], + "meta": { + "ids": { + "user": 74004255, + "comment": 10, + "post": 38, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/74004255", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/10", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Dreaming of Elsewhere" + } + ], + "title": "Comment" + }, + { + "id": 1355738022, + "type": "like", + "read": 1, + "noticon": "", + "timestamp": "2014-08-22T19:41:04+00:00", + "icon": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/08/22/out-to-sea/", + "subject": [ + { + "text": "Cheri Lucas Rowlands liked your post Out to Sea", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 20 + ], + "url": "http://cherilucasrowlands.com", + "site_id": 9838404, + "email": "example@wordpress.com", + "id": 10183950 + }, + { + "type": "post", + "indices": [ + 37, + 47 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/08/22/out-to-sea/", + "site_id": 71769073, + "id": 56 + } + ] + } + ], + "body": [ + { + "text": "Cheri Lucas Rowlands", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://cherilucasrowlands.com", + "id": 10183950, + "site_id": 9838404, + "type": "user", + "indices": [ + 0, + 20 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://cherilucasrowlands.com" + }, + "ids": { + "user": 10183950, + "site": 9838404 + }, + "titles": { + "home": "Cheri Lucas Rowlands" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073, + "post": 56 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/56" + } + }, + "title": "1 Like", + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Out to Sea" + } + ] + }, + { + "id": 1355737668, + "type": "like_milestone_achievement", + "read": 1, + "noticon": "", + "timestamp": "2014-08-22T19:40:44+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/likeable-blog-20-2x.png", + "url": "http://thenomadicwordsmith.wordpress.com", + "subject": [ + { + "text": "You've received 20 likes on Words, Whimsy, and the World", + "ranges": [ + { + "type": "site", + "indices": [ + 28, + 56 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "20 Likes", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 8 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/likeable-blog-20-2x.png" + } + ] + }, + { + "text": "Congratulations on getting 20 total likes on Words, Whimsy, and the World.", + "ranges": [ + { + "type": "site", + "indices": [ + 45, + 73 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + }, + { + "text": "Your current tally is 30." + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "20 Likes" + }, + { + "id": 1310361903, + "type": "traffic_surge", + "read": 1, + "noticon": "", + "timestamp": "2014-07-28T04:14:13+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/notes/images/traffic-surge-note-icon-256.png", + "url": "https://wordpress.com/stats/day/thenomadicwordsmith.wordpress.com?startDate=2014-07-28", + "subject": [ + { + "text": "Your stats are booming! Words, Whimsy, and the World is getting lots of traffic.", + "ranges": [ + { + "type": "site", + "indices": [ + 24, + 52 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 0 + ], + "url": "https://s.wp.com/wp-content/mu-plugins/notes/images/traffic-surge-note-icon-256.png" + } + ] + }, + { + "text": "Your blog, Words, Whimsy, and the World, appears to be getting more traffic than usual! 31 hourly views - 0 hourly views on average", + "ranges": [ + { + "type": "site", + "indices": [ + 11, + 39 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + }, + { + "text": "A spike in your stats", + "ranges": [ + { + "url": "https://wordpress.com/stats/day/thenomadicwordsmith.wordpress.com?startDate=2014-07-28", + "indices": [ + 11, + 21 + ], + "type": "stat", + "site_id": 71769073 + } + ] + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "Boom!" + }, + { + "id": 1305980576, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-07-25T16:32:24+00:00", + "icon": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/about/comment-page-1/#comment-9", + "subject": [ + { + "text": "Cheri Lucas Rowlands commented on About This Nomad", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 20 + ], + "url": "http://cherilucasrowlands.com", + "site_id": 9838404, + "email": "example@wordpress.com", + "id": 10183950 + }, + { + "type": "post", + "indices": [ + 34, + 50 + ], + "url": "https://thenomadicwordsmith.wordpress.com/about/", + "site_id": 71769073, + "id": 1 + } + ] + }, + { + "text": "Your words and images are lovely and inspiring! I want to go to all of these places, too.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 90 + ], + "url": "https://thenomadicwordsmith.wordpress.com/about/comment-page-1/#comment-9", + "site_id": 71769073, + "post_id": 1, + "id": 9 + } + ] + } + ], + "body": [ + { + "text": "Cheri Lucas Rowlands", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://cherilucasrowlands.com", + "id": 10183950, + "site_id": 9838404, + "type": "user", + "indices": [ + 0, + 20 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://cherilucasrowlands.com" + }, + "ids": { + "user": 10183950, + "site": 9838404 + }, + "titles": { + "home": "Cheri Lucas Rowlands" + } + }, + "type": "user" + }, + { + "text": "Your words and images are lovely and inspiring! I want to go to all of these places, too.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 9, + "user": 10183950, + "post": 1, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/9", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/10183950", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/1", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/9?action=edit" + } + ], + "meta": { + "ids": { + "user": 10183950, + "comment": 9, + "post": 1, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/10183950", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/9", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/1", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "About This Nomad" + } + ], + "title": "Comment" + }, + { + "id": 1304343956, + "type": "traffic_surge", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T19:57:36+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/notes/images/traffic-surge-note-icon-256.png", + "url": "https://wordpress.com/stats/day/thenomadicwordsmith.wordpress.com?startDate=2014-07-24", + "subject": [ + { + "text": "Your stats are booming! Words, Whimsy, and the World is getting lots of traffic.", + "ranges": [ + { + "type": "site", + "indices": [ + 24, + 52 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 0 + ], + "url": "https://s.wp.com/wp-content/mu-plugins/notes/images/traffic-surge-note-icon-256.png" + } + ] + }, + { + "text": "Your blog, Words, Whimsy, and the World, appears to be getting more traffic than usual! 73 hourly views - 0 hourly views on average", + "ranges": [ + { + "type": "site", + "indices": [ + 11, + 39 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + }, + { + "text": "A spike in your stats", + "ranges": [ + { + "url": "https://wordpress.com/stats/day/thenomadicwordsmith.wordpress.com?startDate=2014-07-24", + "indices": [ + 11, + 21 + ], + "type": "stat", + "site_id": 71769073 + } + ] + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "Boom!" + }, + { + "id": 1304254353, + "type": "best_followed_day_feat", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:52:31+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/bestday-follows-2x.png", + "url": "https://wordpress.com/people/followers/71769073", + "subject": [ + { + "text": "July 24: Your best day for follows on Words, Whimsy, and the World", + "ranges": [ + { + "type": "site", + "indices": [ + 38, + 66 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 0 + ], + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/bestday-follows-2x.png" + } + ] + }, + { + "text": "On Thursday, July 24, 2014 you surpassed your previous record of most follows in one day for your blog Words, Whimsy, and the World. Nice!", + "ranges": [ + { + "url": "", + "indices": [ + 65, + 77 + ] + }, + { + "type": "site", + "indices": [ + 103, + 131 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + }, + { + "text": "Most Follows in One Day" + }, + { + "text": "Current Record: 2" + }, + { + "text": "Old Record: 1" + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "2 Followers" + }, + { + "id": 1304185197, + "type": "like", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:43:35+00:00", + "icon": "https://2.gravatar.com/avatar/2367004060918e221dcb9799584e9279?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/wanderlust/", + "subject": [ + { + "text": "Michelle Weber and 2 others liked your post When Wanderlust Hits", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 14 + ], + "url": "http://kingofstates.com", + "site_id": 40536446, + "email": "example@wordpress.com", + "id": 2193192 + }, + { + "type": "post", + "indices": [ + 44, + 64 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/wanderlust/", + "site_id": 71769073, + "id": 22 + } + ] + } + ], + "body": [ + { + "text": "Michelle Weber", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://kingofstates.com", + "id": 2193192, + "site_id": 40536446, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/2367004060918e221dcb9799584e9279?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://kingofstates.com" + }, + "ids": { + "user": 2193192, + "site": 40536446 + }, + "titles": { + "home": "King of States!", + "tagline": "I'm Michelle. This is my blog. I write about women and fatness, expound upon semi-coherent thoughts I have in the middle of the night, and offer tough love to those in whom I am disappointed; they are legion." + } + }, + "type": "user" + }, + { + "text": "Kjell Reigstad", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://kjellr.com", + "id": 39377736, + "site_id": 87462575, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/115aabd707fe985c79744d3e7df8fade?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://kjellr.com" + }, + "ids": { + "user": 39377736, + "site": 87462575 + }, + "titles": { + "home": "Kjell Reigstad", + "tagline": "Graphic Design" + } + }, + "type": "user" + }, + { + "text": "Cheri Lucas Rowlands", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://cherilucasrowlands.com", + "id": 10183950, + "site_id": 9838404, + "type": "user", + "indices": [ + 0, + 20 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://cherilucasrowlands.com" + }, + "ids": { + "user": 10183950, + "site": 9838404 + }, + "titles": { + "home": "Cheri Lucas Rowlands" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073, + "post": 22 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/22" + } + }, + "title": "3 Likes", + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "When Wanderlust Hits" + } + ] + }, + { + "id": 1304241630, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:42:50+00:00", + "icon": "https://2.gravatar.com/avatar/2367004060918e221dcb9799584e9279?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-6", + "subject": [ + { + "text": "Michelle Weber commented on Dreaming of Elsewhere", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 14 + ], + "url": "http://kingofstates.com", + "site_id": 40536446, + "email": "example@wordpress.com", + "id": 2193192 + }, + { + "type": "post", + "indices": [ + 28, + 49 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/", + "site_id": 71769073, + "id": 38 + } + ] + }, + { + "text": "Hey, I can see my house from there!\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 36 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-6", + "site_id": 71769073, + "post_id": 38, + "id": 6 + } + ] + } + ], + "body": [ + { + "text": "Michelle Weber", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://kingofstates.com", + "id": 2193192, + "site_id": 40536446, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/2367004060918e221dcb9799584e9279?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://kingofstates.com" + }, + "ids": { + "user": 2193192, + "site": 40536446 + }, + "titles": { + "home": "King of States!", + "tagline": "I'm Michelle. This is my blog. I write about women and fatness, expound upon semi-coherent thoughts I have in the middle of the night, and offer tough love to those in whom I am disappointed; they are legion." + } + }, + "type": "user" + }, + { + "text": "Hey, I can see my house from there!", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 6, + "user": 2193192, + "post": 38, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/6", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/2193192", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/6?action=edit" + } + ], + "meta": { + "ids": { + "user": 2193192, + "comment": 6, + "post": 38, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/2193192", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/6", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Dreaming of Elsewhere" + } + ], + "title": "Comment" + }, + { + "id": 1304179269, + "type": "like", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:42:33+00:00", + "icon": "https://2.gravatar.com/avatar/2367004060918e221dcb9799584e9279?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/", + "subject": [ + { + "text": "Michelle Weber and 5 others liked your post Dreaming of Elsewhere", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 14 + ], + "url": "http://kingofstates.com", + "site_id": 40536446, + "email": "example@wordpress.com", + "id": 2193192 + }, + { + "type": "post", + "indices": [ + 44, + 65 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/", + "site_id": 71769073, + "id": 38 + } + ] + } + ], + "body": [ + { + "text": "Michelle Weber", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://kingofstates.com", + "id": 2193192, + "site_id": 40536446, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/2367004060918e221dcb9799584e9279?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://kingofstates.com" + }, + "ids": { + "user": 2193192, + "site": 40536446 + }, + "titles": { + "home": "King of States!", + "tagline": "I'm Michelle. This is my blog. I write about women and fatness, expound upon semi-coherent thoughts I have in the middle of the night, and offer tough love to those in whom I am disappointed; they are legion." + } + }, + "type": "user" + }, + { + "text": "Kjell Reigstad", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://kjellr.com", + "id": 39377736, + "site_id": 87462575, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/115aabd707fe985c79744d3e7df8fade?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://kjellr.com" + }, + "ids": { + "user": 39377736, + "site": 87462575 + }, + "titles": { + "home": "Kjell Reigstad", + "tagline": "Graphic Design" + } + }, + "type": "user" + }, + { + "text": "Elizabeth", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://elizabeth.blog/", + "id": 876809, + "site_id": 847631, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/7cde026ba626f4a827f12b454177947b?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://elizabeth.blog/" + }, + "ids": { + "user": 876809, + "site": 847631 + }, + "titles": { + "home": "Accismus", + "tagline": "The thoughts, opinions, and occasional rantings of Elizabeth Urello." + } + }, + "type": "user" + }, + { + "text": "Cheri Lucas Rowlands", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://cherilucasrowlands.com", + "id": 10183950, + "site_id": 9838404, + "type": "user", + "indices": [ + 0, + 20 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://cherilucasrowlands.com" + }, + "ids": { + "user": 10183950, + "site": 9838404 + }, + "titles": { + "home": "Cheri Lucas Rowlands" + } + }, + "type": "user" + }, + { + "text": "Mark Armstrong", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "https://markarms.blog/", + "id": 22917495, + "site_id": 66671758, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/19f62880113934b262c0bbb597644adb?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "https://markarms.blog/" + }, + "ids": { + "user": 22917495, + "site": 66671758 + }, + "titles": { + "home": "Mark Armstrong", + "tagline": "Founder of Longreads; Editor at Automattic" + } + }, + "type": "user" + }, + { + "text": "Mike Dang", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://mikedang.wordpress.com", + "id": 4275359, + "site_id": 10563972, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/d861b27aa3a4ba53cf0fc5f59b7e0527?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://mikedang.wordpress.com" + }, + "ids": { + "user": 4275359, + "site": 10563972 + }, + "titles": { + "home": "Reporter Mike" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073, + "post": 38 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38" + } + }, + "title": "6 Likes", + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Dreaming of Elsewhere" + } + ] + }, + { + "id": 1304240088, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:41:38+00:00", + "icon": "https://2.gravatar.com/avatar/2367004060918e221dcb9799584e9279?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/comment-page-1/#comment-5", + "subject": [ + { + "text": "Michelle Weber replied to a comment I read a story in the New York Times today about how no one uses pens anymore. WHAT. Give me …\n", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 14 + ], + "url": "http://kingofstates.com", + "site_id": 40536446, + "email": "example@wordpress.com", + "id": 2193192 + }, + { + "type": "comment", + "indices": [ + 36, + 131 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/comment-page-1/#comment-1", + "site_id": 71769073, + "post_id": 40, + "id": 1 + } + ] + }, + { + "text": "You’ll pry my red pens out of my cold, dead hands, Dang.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 57 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/comment-page-1/#comment-5", + "site_id": 71769073, + "post_id": 40, + "id": 5 + } + ] + } + ], + "body": [ + { + "text": "Michelle Weber", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://kingofstates.com", + "id": 2193192, + "site_id": 40536446, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/2367004060918e221dcb9799584e9279?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://kingofstates.com" + }, + "ids": { + "user": 2193192, + "site": 40536446 + }, + "titles": { + "home": "King of States!", + "tagline": "I'm Michelle. This is my blog. I write about women and fatness, expound upon semi-coherent thoughts I have in the middle of the night, and offer tough love to those in whom I am disappointed; they are legion." + } + }, + "type": "user" + }, + { + "text": "You’ll pry my red pens out of my cold, dead hands, Dang.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 5, + "user": 2193192, + "post": 40, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/5", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/2193192", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/40", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 1, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/5?action=edit" + } + ], + "meta": { + "ids": { + "parent_comment": 1, + "user": 2193192, + "comment": 5, + "post": 40, + "site": 71769073 + }, + "links": { + "parent_comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/1", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/2193192", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/5", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/40", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "Mike Dang on Great Perspective on Blogging", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 9 + ], + "url": "http://mikedang.wordpress.com", + "site_id": 10563972, + "email": "example@wordpress.com", + "id": 4275359 + }, + { + "type": "post", + "indices": [ + 13, + 42 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/", + "site_id": 71769073, + "id": 40 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/d861b27aa3a4ba53cf0fc5f59b7e0527?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "I read a story in the New York Times today about how no one uses pens anymore. WHAT. Give me …" + } + ], + "title": "Reply" + }, + { + "id": 1304176391, + "type": "like", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:41:15+00:00", + "icon": "https://2.gravatar.com/avatar/e870a6cbd22eedb95c72bba37b144b7d?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/", + "subject": [ + { + "text": "Amanda Lyle and 4 others liked your post Great Perspective on Blogging", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 11 + ], + "url": "http://www.insidethelifeofmoi.com", + "site_id": 100718137, + "email": "example@wordpress.com", + "id": 61155091 + }, + { + "type": "post", + "indices": [ + 41, + 70 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/", + "site_id": 71769073, + "id": 40 + } + ] + } + ], + "body": [ + { + "text": "Amanda Lyle", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://www.insidethelifeofmoi.com", + "id": 61155091, + "site_id": 100718137, + "type": "user", + "indices": [ + 0, + 11 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/e870a6cbd22eedb95c72bba37b144b7d?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://www.insidethelifeofmoi.com" + }, + "ids": { + "user": 61155091, + "site": 100718137 + }, + "titles": { + "home": "Insidethelifeofmoi", + "tagline": "An eccentric blogger with a pen and a thousand ideas" + } + }, + "type": "user" + }, + { + "text": "Kjell Reigstad", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://kjellr.com", + "id": 39377736, + "site_id": 87462575, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/115aabd707fe985c79744d3e7df8fade?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://kjellr.com" + }, + "ids": { + "user": 39377736, + "site": 87462575 + }, + "titles": { + "home": "Kjell Reigstad", + "tagline": "Graphic Design" + } + }, + "type": "user" + }, + { + "text": "Elizabeth", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://elizabeth.blog/", + "id": 876809, + "site_id": 847631, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/7cde026ba626f4a827f12b454177947b?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://elizabeth.blog/" + }, + "ids": { + "user": 876809, + "site": 847631 + }, + "titles": { + "home": "Accismus", + "tagline": "The thoughts, opinions, and occasional rantings of Elizabeth Urello." + } + }, + "type": "user" + }, + { + "text": "Michelle Weber", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://kingofstates.com", + "id": 2193192, + "site_id": 40536446, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/2367004060918e221dcb9799584e9279?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://kingofstates.com" + }, + "ids": { + "user": 2193192, + "site": 40536446 + }, + "titles": { + "home": "King of States!", + "tagline": "I'm Michelle. This is my blog. I write about women and fatness, expound upon semi-coherent thoughts I have in the middle of the night, and offer tough love to those in whom I am disappointed; they are legion." + } + }, + "type": "user" + }, + { + "text": "Mike Dang", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://mikedang.wordpress.com", + "id": 4275359, + "site_id": 10563972, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/d861b27aa3a4ba53cf0fc5f59b7e0527?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://mikedang.wordpress.com" + }, + "ids": { + "user": 4275359, + "site": 10563972 + }, + "titles": { + "home": "Reporter Mike" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073, + "post": 40 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/40" + } + }, + "title": "5 Likes", + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Great Perspective on Blogging" + } + ] + }, + { + "id": 1304234553, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:37:23+00:00", + "icon": "https://1.gravatar.com/avatar/d861b27aa3a4ba53cf0fc5f59b7e0527?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/two-more-hours/comment-page-1/#comment-4", + "subject": [ + { + "text": "Mike Dang commented on Two More Hours: For Writing, For Magic", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 9 + ], + "url": "http://mikedang.wordpress.com", + "site_id": 10563972, + "email": "example@wordpress.com", + "id": 4275359 + }, + { + "type": "post", + "indices": [ + 23, + 61 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/two-more-hours/", + "site_id": 71769073, + "id": 18 + } + ] + }, + { + "text": "I wish I had more time in the day to write!\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 44 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/two-more-hours/comment-page-1/#comment-4", + "site_id": 71769073, + "post_id": 18, + "id": 4 + } + ] + } + ], + "body": [ + { + "text": "Mike Dang", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://mikedang.wordpress.com", + "id": 4275359, + "site_id": 10563972, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/d861b27aa3a4ba53cf0fc5f59b7e0527?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://mikedang.wordpress.com" + }, + "ids": { + "user": 4275359, + "site": 10563972 + }, + "titles": { + "home": "Reporter Mike" + } + }, + "type": "user" + }, + { + "text": "I wish I had more time in the day to write!", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 4, + "user": 4275359, + "post": 18, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/4", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/4275359", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/18", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/4?action=edit" + } + ], + "meta": { + "ids": { + "user": 4275359, + "comment": 4, + "post": 18, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/4275359", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/4", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/18", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Two More Hours: For Writing, For Magic" + } + ], + "title": "Comment" + }, + { + "id": 1304185631, + "type": "like", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:36:31+00:00", + "icon": "https://1.gravatar.com/avatar/d861b27aa3a4ba53cf0fc5f59b7e0527?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/two-more-hours/", + "subject": [ + { + "text": "Mike Dang and 2 others liked your post Two More Hours: For Writing, For Magic", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 9 + ], + "url": "http://mikedang.wordpress.com", + "site_id": 10563972, + "email": "example@wordpress.com", + "id": 4275359 + }, + { + "type": "post", + "indices": [ + 39, + 77 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/two-more-hours/", + "site_id": 71769073, + "id": 18 + } + ] + } + ], + "body": [ + { + "text": "Mike Dang", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://mikedang.wordpress.com", + "id": 4275359, + "site_id": 10563972, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/d861b27aa3a4ba53cf0fc5f59b7e0527?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://mikedang.wordpress.com" + }, + "ids": { + "user": 4275359, + "site": 10563972 + }, + "titles": { + "home": "Reporter Mike" + } + }, + "type": "user" + }, + { + "text": "Kjell Reigstad", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://kjellr.com", + "id": 39377736, + "site_id": 87462575, + "type": "user", + "indices": [ + 0, + 14 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/115aabd707fe985c79744d3e7df8fade?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://kjellr.com" + }, + "ids": { + "user": 39377736, + "site": 87462575 + }, + "titles": { + "home": "Kjell Reigstad", + "tagline": "Graphic Design" + } + }, + "type": "user" + }, + { + "text": "Cheri Lucas Rowlands", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://cherilucasrowlands.com", + "id": 10183950, + "site_id": 9838404, + "type": "user", + "indices": [ + 0, + 20 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://cherilucasrowlands.com" + }, + "ids": { + "user": 10183950, + "site": 9838404 + }, + "titles": { + "home": "Cheri Lucas Rowlands" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 71769073, + "post": 18 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/18" + } + }, + "title": "3 Likes", + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Two More Hours: For Writing, For Magic" + } + ] + }, + { + "id": 1304211566, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:20:06+00:00", + "icon": "https://1.gravatar.com/avatar/7cde026ba626f4a827f12b454177947b?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-3", + "subject": [ + { + "text": "Elizabeth commented on Dreaming of Elsewhere", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 9 + ], + "url": "http://elizabeth.blog/", + "site_id": 847631, + "email": "example@wordpress.com", + "id": 876809 + }, + { + "type": "post", + "indices": [ + 23, + 44 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/", + "site_id": 71769073, + "id": 38 + } + ] + }, + { + "text": "Lovely photos! I want to go to there.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 38 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-3", + "site_id": 71769073, + "post_id": 38, + "id": 3 + } + ] + } + ], + "body": [ + { + "text": "Elizabeth", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://elizabeth.blog/", + "id": 876809, + "site_id": 847631, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/7cde026ba626f4a827f12b454177947b?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://elizabeth.blog/" + }, + "ids": { + "user": 876809, + "site": 847631 + }, + "titles": { + "home": "Accismus", + "tagline": "The thoughts, opinions, and occasional rantings of Elizabeth Urello." + } + }, + "type": "user" + }, + { + "text": "Lovely photos! I want to go to there.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 3, + "user": 876809, + "post": 38, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/3", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/876809", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/3?action=edit" + } + ], + "meta": { + "ids": { + "user": 876809, + "comment": 3, + "post": 38, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/876809", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/3", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Dreaming of Elsewhere" + } + ], + "title": "Comment" + }, + { + "id": 1304210924, + "type": "like_milestone_achievement", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:19:39+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/likeable-blog-10-2x.png", + "url": "http://thenomadicwordsmith.wordpress.com", + "subject": [ + { + "text": "You've received 10 likes on Words, Whimsy, and the World", + "ranges": [ + { + "type": "site", + "indices": [ + 28, + 56 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "10 Likes", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 8 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/likeable-blog-10-2x.png" + } + ] + }, + { + "text": "Congratulations on getting 10 total likes on Words, Whimsy, and the World.", + "ranges": [ + { + "type": "site", + "indices": [ + 45, + 73 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + }, + { + "text": "Your current tally is 30." + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "10 Likes" + }, + { + "id": 1304184444, + "type": "like_milestone_achievement", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:01:58+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/likeable-blog-5-2x.png", + "url": "http://thenomadicwordsmith.wordpress.com", + "subject": [ + { + "text": "You've received 5 likes on Words, Whimsy, and the World", + "ranges": [ + { + "type": "site", + "indices": [ + 27, + 55 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "5 Likes", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 7 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/likeable-blog-5-2x.png" + } + ] + }, + { + "text": "Congratulations on getting 5 total likes on Words, Whimsy, and the World.", + "ranges": [ + { + "type": "site", + "indices": [ + 44, + 72 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + }, + { + "text": "Your current tally is 30." + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "5 Likes" + }, + { + "id": 1304184439, + "type": "best_liked_day_feat", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T18:01:58+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/bestday-likes-2x.png", + "url": "http://thenomadicwordsmith.wordpress.com", + "subject": [ + { + "text": "July 24: Your best day for likes on Words, Whimsy, and the World", + "ranges": [ + { + "type": "site", + "indices": [ + 36, + 64 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "5 Likes", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 7 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/bestday-likes-2x.png" + } + ] + }, + { + "text": "On Thursday July 24, 2014, you surpassed your previous record of most likes in one day for your posts on Words, Whimsy, and the World. That's pretty awesome, well done!", + "ranges": [ + { + "type": "site", + "indices": [ + 105, + 133 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + }, + { + "text": "Most Likes in One Day" + }, + { + "text": "Current Record: 5" + }, + { + "text": "Old Record: 4" + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "5 Likes" + }, + { + "id": 1304179843, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T17:58:59+00:00", + "icon": "https://1.gravatar.com/avatar/d861b27aa3a4ba53cf0fc5f59b7e0527?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-2", + "subject": [ + { + "text": "Mike Dang commented on Dreaming of Elsewhere", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 9 + ], + "url": "http://mikedang.wordpress.com", + "site_id": 10563972, + "email": "example@wordpress.com", + "id": 4275359 + }, + { + "type": "post", + "indices": [ + 23, + 44 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/", + "site_id": 71769073, + "id": 38 + } + ] + }, + { + "text": "Wait, I had this dream too. HOW DID YOU READ MY DREAMS!?\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 57 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/dreaming-of-elsewhere/comment-page-1/#comment-2", + "site_id": 71769073, + "post_id": 38, + "id": 2 + } + ] + } + ], + "body": [ + { + "text": "Mike Dang", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://mikedang.wordpress.com", + "id": 4275359, + "site_id": 10563972, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/d861b27aa3a4ba53cf0fc5f59b7e0527?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://mikedang.wordpress.com" + }, + "ids": { + "user": 4275359, + "site": 10563972 + }, + "titles": { + "home": "Reporter Mike" + } + }, + "type": "user" + }, + { + "text": "Wait, I had this dream too. HOW DID YOU READ MY DREAMS!?", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 2, + "user": 4275359, + "post": 38, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/2", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/4275359", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/2?action=edit" + } + ], + "meta": { + "ids": { + "user": 4275359, + "comment": 2, + "post": 38, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/4275359", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/2", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/38", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Dreaming of Elsewhere" + } + ], + "title": "Comment" + }, + { + "id": 1304178014, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T17:57:49+00:00", + "icon": "https://1.gravatar.com/avatar/d861b27aa3a4ba53cf0fc5f59b7e0527?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/comment-page-1/#comment-1", + "subject": [ + { + "text": "Mike Dang commented on Great Perspective on Blogging", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 9 + ], + "url": "http://mikedang.wordpress.com", + "site_id": 10563972, + "email": "example@wordpress.com", + "id": 4275359 + }, + { + "type": "post", + "indices": [ + 23, + 52 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/", + "site_id": 71769073, + "id": 40 + } + ] + }, + { + "text": "I read a story in the New York Times today about how no one uses pens anymore. WHAT. Give me your pens. I love pens.\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 117 + ], + "url": "https://thenomadicwordsmith.wordpress.com/2014/07/24/on-blogging-2/comment-page-1/#comment-1", + "site_id": 71769073, + "post_id": 40, + "id": 1 + } + ] + } + ], + "body": [ + { + "text": "Mike Dang", + "ranges": [ + { + "email": "example@wordpress.com", + "url": "http://mikedang.wordpress.com", + "id": 4275359, + "site_id": 10563972, + "type": "user", + "indices": [ + 0, + 9 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/d861b27aa3a4ba53cf0fc5f59b7e0527?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "example@wordpress.com", + "home": "http://mikedang.wordpress.com" + }, + "ids": { + "user": 4275359, + "site": 10563972 + }, + "titles": { + "home": "Reporter Mike" + } + }, + "type": "user" + }, + { + "text": "I read a story in the New York Times today about how no one uses pens anymore. WHAT. Give me your pens. I love pens.", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 1, + "user": 4275359, + "post": 40, + "site": 71769073 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/1", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/4275359", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/40", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/thenomadicwordsmith.wordpress.com/1?action=edit" + } + ], + "meta": { + "ids": { + "user": 4275359, + "comment": 1, + "post": 40, + "site": 71769073 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/4275359", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/1", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/40", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "header": [ + { + "text": "thenomadicwordsmith", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 19 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "site_id": 71769073, + "email": "example@wordpress.com", + "id": 68646169 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Great Perspective on Blogging" + } + ], + "title": "Comment" + }, + { + "id": 1304158185, + "type": "post_milestone_achievement", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T17:43:49+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/post-milestone-5-2x.png", + "url": "http://wordpress.com/trophy-case/", + "subject": [ + { + "text": "You've made 5 posts on Words, Whimsy, and the World.", + "ranges": [ + { + "type": "site", + "indices": [ + 23, + 51 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "5 Posts", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 7 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/post-milestone-5-2x.png" + } + ] + }, + { + "text": "Congratulations on writing 5 posts on Words, Whimsy, and the World!", + "ranges": [ + { + "type": "site", + "indices": [ + 38, + 66 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "5 Posts" + }, + { + "id": 1304116488, + "type": "post_milestone_achievement", + "read": 1, + "noticon": "", + "timestamp": "2014-07-24T17:13:51+00:00", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/post-milestone-1-2x.png", + "url": "http://wordpress.com/trophy-case/", + "subject": [ + { + "text": "You've made your first post on Words, Whimsy, and the World.", + "ranges": [ + { + "type": "site", + "indices": [ + 31, + 59 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "body": [ + { + "text": "First Post", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 10 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/post-milestone-1-2x.png" + } + ] + }, + { + "text": "Congratulations on writing your first post on Words, Whimsy, and the World!", + "ranges": [ + { + "type": "site", + "indices": [ + 46, + 74 + ], + "url": "http://thenomadicwordsmith.wordpress.com", + "id": 71769073 + } + ] + } + ], + "meta": { + "ids": { + "site": 71769073 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/71769073" + } + }, + "title": "First Post" + } + ] + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/rest_v11_notifications.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/rest_v11_notifications.json new file mode 100644 index 000000000000..7918209e0d43 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/rest_v11_notifications.json @@ -0,0 +1,1063 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/notifications/", + "queryParameters": { + "fields": { + "matches": "id,(note_hash,)?type,unread,body,subject,timestamp,meta" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "last_seen_time": "1598052184", + "number": 9, + "notes": [ + { + "id": 4937363649, + "type": "like", + "read": 0, + "noticon": "", + "timestamp": "{{now}}", + "icon": "https://1.gravatar.com/avatar/11bcf6345930a9fbde5e6f0985c3f24a?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://weekendbakesblog.wordpress.com/2020/10/05/my-top-10-pastry-recipes/", + "subject": [ + { + "text": "Amechie Ajimobi and 5 others liked your post My Top 10 Pastry Recipes", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 15 + ], + "url": "http://amechieajimobi.wordpress.com", + "site_id": 103806280, + "email": "test@example.com", + "id": 98380134 + }, + { + "type": "b", + "indices": [ + 20, + 21 + ] + }, + { + "type": "post", + "indices": [ + 45, + 69 + ], + "url": "https://weekendbakesblog.wordpress.com/2020/10/05/my-top-10-pastry-recipes/", + "site_id": 181977606, + "id": 56 + } + ] + } + ], + "body": [ + { + "text": "Amechie Ajimobi", + "ranges": [ + { + "email": "test@example.com", + "url": "http://amechieajimobi.wordpress.com", + "id": 98380134, + "site_id": 103806280, + "type": "user", + "indices": [ + 0, + 15 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/11bcf6345930a9fbde5e6f0985c3f24a?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "test@example.com", + "home": "http://amechieajimobi.wordpress.com" + }, + "ids": { + "user": 98380134, + "site": 103806280 + }, + "titles": { + "home": "The Cooking Point" + } + }, + "type": "user" + }, + { + "text": "Catrina Ciobanu", + "ranges": [ + { + "email": "test@example.com", + "url": "http://catrinaciobanu.wordpress.com", + "id": 98387381, + "type": "user", + "indices": [ + 0, + 15 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9e99ce19f071c664fb4a882befb72391?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "test@example.com", + "home": "http://catrinaciobanu.wordpress.com" + }, + "ids": { + "user": 98387381 + } + }, + "type": "user" + }, + { + "text": "Pamela Nguyen", + "ranges": [ + { + "email": "test@example.com", + "url": "http://pamelanguyen.wordpress.com", + "id": 98379476, + "type": "user", + "indices": [ + 0, + 13 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/b9dadbf067e5583eaf82abefc49727bb?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "test@example.com", + "home": "http://pamelanguyen.wordpress.com" + }, + "ids": { + "user": 98379476 + } + }, + "type": "user" + }, + { + "text": "Madison Ruiz", + "ranges": [ + { + "email": "test@example.com", + "url": "http://madisonruiz.wordpress.com", + "id": 98379888, + "type": "user", + "indices": [ + 0, + 12 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/c536d5d44894a7575296e657d86ed770?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "test@example.com", + "home": "http://madisonruiz.wordpress.com" + }, + "ids": { + "user": 98379888 + } + }, + "type": "user" + }, + { + "text": "Theresa Ray", + "ranges": [ + { + "email": "test@example.com", + "url": "http://theresaray.wordpress.com", + "id": 98379754, + "type": "user", + "indices": [ + 0, + 11 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/e31f50d91bb2f5b278038898680ea194?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "test@example.com", + "home": "http://theresaray.wordpress.com" + }, + "ids": { + "user": 98379754 + } + }, + "type": "user" + }, + { + "text": "Reyansh Pawar", + "ranges": [ + { + "email": "test@example.com", + "url": "http://reyanshpawar.wordpress.com", + "id": 98380071, + "type": "user", + "indices": [ + 0, + 13 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/4b7ac5a2b497adbd473313b0701023a4?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "test@example.com", + "home": "http://reyanshpawar.wordpress.com" + }, + "ids": { + "user": 98380071 + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 181977606, + "post": 56 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/181977606", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/56" + } + }, + "title": "6 Likes", + "header": [ + { + "text": "appstorescreens", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 15 + ], + "url": "http://tricountyrealestate.wordpress.com", + "site_id": 181851541, + "email": "test@example.com", + "id": 191794483 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/35029b2103460109f574c38dfeea5f3f?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "My Top 10 Pastry Recipes" + } + ], + "note_hash": 1391922081 + }, + { + "id": 4937363362, + "type": "comment", + "read": 1, + "noticon": "", + "timestamp": "{{now offset='-2 hours'}}", + "icon": "https://1.gravatar.com/avatar/4b7ac5a2b497adbd473313b0701023a4?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://weekendbakesblog.wordpress.com/2020/10/05/my-top-10-pastry-recipes/comment-page-1/#comment-2", + "subject": [ + { + "text": "Reyansh Pawar commented on My Top 10 Pastry Recipes", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 13 + ], + "url": "http://reyanshpawar.wordpress.com", + "email": "test@example.com", + "id": 98380071 + }, + { + "type": "post", + "indices": [ + 27, + 51 + ], + "url": "https://weekendbakesblog.wordpress.com/2020/10/05/my-top-10-pastry-recipes/", + "site_id": 181977606, + "id": 56 + } + ] + }, + { + "text": "Can you use almond or rice flour instead of the whole wheat to make the peach scone recipe gluten free?\n", + "ranges": [ + { + "type": "comment", + "indices": [ + 0, + 104 + ], + "url": "https://weekendbakesblog.wordpress.com/2020/10/05/my-top-10-pastry-recipes/comment-page-1/#comment-2", + "site_id": 181977606, + "post_id": 56, + "id": 2 + } + ] + } + ], + "body": [ + { + "text": "Reyansh Pawar", + "ranges": [ + { + "email": "test@example.com", + "url": "http://reyanshpawar.wordpress.com", + "id": 98380071, + "type": "user", + "indices": [ + 0, + 13 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://1.gravatar.com/avatar/4b7ac5a2b497adbd473313b0701023a4?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "test@example.com", + "home": "http://reyanshpawar.wordpress.com" + }, + "ids": { + "user": 98380071 + } + }, + "type": "user" + }, + { + "text": "Can you use almond or rice flour instead of the whole wheat to make the peach scone recipe gluten free?", + "actions": { + "spam-comment": false, + "trash-comment": false, + "approve-comment": true, + "edit-comment": true, + "replyto-comment": true, + "like-comment": false + }, + "meta": { + "ids": { + "comment": 2, + "user": 98380071, + "post": 56, + "site": 181977606 + }, + "links": { + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/2", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/98380071", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/56", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/181977606" + } + }, + "type": "comment", + "nest_level": 0, + "edit_comment_link": "https://wordpress.com/comment/weekendbakesblog.wordpress.com/2?action=edit" + } + ], + "meta": { + "ids": { + "user": 98380071, + "comment": 2, + "post": 56, + "site": 181977606 + }, + "links": { + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/98380071", + "comment": "{{request.requestLine.baseUrl}}/rest/v1/comments/2", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/56", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/181977606" + } + }, + "header": [ + { + "text": "Pamela Nguyen", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 15 + ], + "url": "http://tricountyrealestate.wordpress.com", + "site_id": 181851541, + "email": "test@example.com", + "id": 191794483 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/35029b2103460109f574c38dfeea5f3f?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "My Top 10 Pastry Recipes" + } + ], + "title": "Comment", + "note_hash": 1287359074 + }, + { + "id": 4937354640, + "type": "new_post", + "read": 0, + "noticon": "", + "timestamp": "{{now offset='-4 hours'}}", + "icon": "https://2.gravatar.com/avatar/e31f50d91bb2f5b278038898680ea194?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "https://weekendbakesblog.wordpress.com/2020/10/05/easy-blueberry-muffins/", + "subject": [ + { + "text": "Kate Williams posted on Kate's Kitchen: French Toast Four Different Ways", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 13 + ], + "url": "http://tricountyrealestate.wordpress.com", + "site_id": 181851541, + "email": "test@example.com", + "id": 191794483 + }, + { + "type": "site", + "indices": [ + 24, + 38 + ], + "url": "http://weekendbakesblog.wordpress.com", + "id": 181977606 + }, + { + "type": "post", + "indices": [ + 40, + 72 + ], + "url": "https://weekendbakesblog.wordpress.com/2020/10/05/easy-blueberry-muffins/", + "site_id": 181977606, + "id": 14 + } + ] + }, + { + "text": "Depending what mood I'm in, I like to…" + } + ], + "body": [ + { + "text": "\n\n\t• \n\t\n\t\n\t• \n\t\n\t\n\t• \n\t\n\t\nIngredients\n\n\t• 1 cup fresh or frozen blueberries\n\t\n\t• 1 3/4 cup flour\n\t\n\t• 2 tsp baking powder\n\t\n\t• 3/4 cups sugar\n\t\n\t• 1/4 cup canola oil\n\t\n\t• 1 egg\n\t\n", + "ranges": [ + { + "type": "h4", + "indices": [ + 26, + 37 + ] + }, + { + "type": "list", + "indices": [ + 1, + 25 + ] + }, + { + "type": "list", + "indices": [ + 38, + 178 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 14, + 16 + ], + "url": "https://appscreens.files.wordpress.com/2020/08/sarah-gualtieri-o3mdjpy3qdo-unsplash.jpg?w=683" + }, + { + "type": "image", + "indices": [ + 22, + 24 + ], + "url": "https://appscreens.files.wordpress.com/2020/08/yulia-khlebnikova-oh5mxkl9oho-unsplash.jpg?w=1024" + }, + { + "type": "image", + "indices": [ + 6, + 8 + ], + "url": "https://appscreens.files.wordpress.com/2020/08/sarah-gualtieri-tohjvyg65n0-unsplash.jpg?w=683" + } + ], + "actions": { + "replyto-comment": true, + "like-post": false + }, + "meta": { + "ids": { + "post": 14, + "site": 181977606 + }, + "links": { + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/14", + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/181977606" + } + }, + "type": "post" + } + ], + "meta": { + "ids": { + "site": 181977606, + "post": 14, + "user": 191794483 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/181977606", + "post": "{{request.requestLine.baseUrl}}/rest/v1/posts/14", + "user": "{{request.requestLine.baseUrl}}/rest/v1/users/191794483" + } + }, + "title": "New Post", + "header": [ + { + "text": "appstorescreens", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 15 + ], + "url": "http://tricountyrealestate.wordpress.com", + "site_id": 181851541, + "email": "test@example.com", + "id": 191794483 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/35029b2103460109f574c38dfeea5f3f?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ] + }, + { + "text": "Easy Blueberry Muffins", + "ranges": [ + { + "type": "post", + "indices": [ + 0, + 22 + ], + "url": "https://weekendbakesblog.wordpress.com/2020/10/05/easy-blueberry-muffins/", + "site_id": 181977606, + "id": 14 + } + ] + } + ], + "note_hash": 492599271 + }, + { + "id": 4937350334, + "type": "follow", + "read": 0, + "noticon": "", + "timestamp": "{{now offset='-5 hours'}}", + "icon": "https://2.gravatar.com/avatar/b9dadbf067e5583eaf82abefc49727bb?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "url": "http://weekendbakesblog.wordpress.com", + "subject": [ + { + "text": "Pamela Nguyen and 4 others followed your blog Weekend Bakes", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 13 + ], + "url": "http://pamelanguyen.wordpress.com", + "email": "test@example.com", + "id": 98379476 + }, + { + "type": "b", + "indices": [ + 18, + 19 + ] + }, + { + "type": "site", + "indices": [ + 46, + 59 + ], + "url": "http://weekendbakesblog.wordpress.com", + "id": 181977606 + } + ] + } + ], + "body": [ + { + "text": "Pamela Nguyen", + "ranges": [ + { + "email": "test@example.com", + "url": "http://pamelanguyen.wordpress.com", + "id": 98379476, + "type": "user", + "indices": [ + 0, + 13 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/b9dadbf067e5583eaf82abefc49727bb?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "test@example.com", + "home": "http://pamelanguyen.wordpress.com" + }, + "ids": { + "user": 98379476 + } + }, + "type": "user" + }, + { + "text": "Catrina Ciobanu", + "ranges": [ + { + "email": "test@example.com", + "url": "http://catrinaciobanu.wordpress.com", + "id": 98387381, + "type": "user", + "indices": [ + 0, + 15 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/9e99ce19f071c664fb4a882befb72391?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "test@example.com", + "home": "http://catrinaciobanu.wordpress.com" + }, + "ids": { + "user": 98387381 + } + }, + "type": "user" + }, + { + "text": "Theresa Ray", + "ranges": [ + { + "email": "test@example.com", + "url": "http://theresaray.wordpress.com", + "id": 98379754, + "type": "user", + "indices": [ + 0, + 11 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://2.gravatar.com/avatar/e31f50d91bb2f5b278038898680ea194?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "test@example.com", + "home": "http://theresaray.wordpress.com" + }, + "ids": { + "user": 98379754 + } + }, + "type": "user" + }, + { + "text": "Madison Ruiz", + "ranges": [ + { + "email": "test@example.com", + "url": "http://madisonruiz.wordpress.com", + "id": 98379888, + "type": "user", + "indices": [ + 0, + 12 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/c536d5d44894a7575296e657d86ed770?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "meta": { + "links": { + "email": "test@example.com", + "home": "http://madisonruiz.wordpress.com" + }, + "ids": { + "user": 98379888 + } + }, + "type": "user" + }, + { + "text": "appstorescreens", + "ranges": [ + { + "email": "test@example.com", + "url": "http://tricountyrealestate.wordpress.com", + "id": 191794483, + "site_id": 181851541, + "type": "user", + "indices": [ + 0, + 15 + ] + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://0.gravatar.com/avatar/35029b2103460109f574c38dfeea5f3f?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G" + } + ], + "actions": { + "follow": false + }, + "meta": { + "links": { + "email": "test@example.com", + "home": "http://tricountyrealestate.wordpress.com" + }, + "ids": { + "user": 191794483, + "site": 181851541 + }, + "titles": { + "home": "Tri-County Real Estate" + } + }, + "type": "user" + } + ], + "meta": { + "ids": { + "site": 181977606 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/181977606" + } + }, + "title": "5 Followers", + "header": [ + { + "text": "Weekend Bakes", + "ranges": [ + { + "type": "user", + "indices": [ + 0, + 13 + ], + "url": "http://weekendbakesblog.wordpress.com", + "site_id": 181977606, + "id": 181977606 + } + ], + "media": [ + { + "type": "image", + "indices": [ + 0, + 0 + ], + "height": "256", + "width": "256", + "url": "https://weekendbakesblog.files.wordpress.com/2020/08/image.jpg?w=96" + } + ] + }, + { + "text": "http://weekendbakesblog.wordpress.com" + } + ], + "note_hash": 3590885648 + }, + { + "id": 4937367020, + "type": "best_liked_day_feat", + "read": 1, + "noticon": "", + "timestamp": "{{now offset='-1 days'}}", + "icon": "https://s.wp.com/wp-content/mu-plugins/achievements/bestday-likes-2x.png", + "url": "http://weekendbakesblog.wordpress.com", + "subject": [ + { + "text": "October 5: Your best day for likes on Weekend Bakes", + "ranges": [ + { + "type": "site", + "indices": [ + 38, + 51 + ], + "url": "http://weekendbakesblog.wordpress.com", + "id": 181977606 + } + ] + } + ], + "body": [ + { + "text": "5 Likes", + "media": [ + { + "type": "badge", + "indices": [ + 0, + 7 + ], + "height": "256", + "width": "256", + "url": "https://s.wp.com/wp-content/mu-plugins/achievements/bestday-likes-2x.png" + } + ] + }, + { + "text": "On Monday October 5, 2020, you surpassed your previous record of most likes in one day for your posts on Weekend Bakes. That's pretty awesome, well done!", + "ranges": [ + { + "type": "site", + "indices": [ + 105, + 118 + ], + "url": "http://weekendbakesblog.wordpress.com", + "id": 181977606 + } + ] + }, + { + "text": "Most Likes in One Day" + }, + { + "text": "Current Record: 5", + "ranges": [ + { + "type": "b", + "indices": [ + 16, + 17 + ] + } + ] + }, + { + "text": "Old Record: 4", + "ranges": [ + { + "type": "b", + "indices": [ + 12, + 13 + ] + } + ] + } + ], + "meta": { + "ids": { + "site": 181977606 + }, + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1/sites/181977606" + } + }, + "title": "5 Likes", + "note_hash": 3233821948 + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/rest_v11_notifications_note_hashes.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/rest_v11_notifications_note_hashes.json new file mode 100644 index 000000000000..871115a35be6 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/rest_v11_notifications_note_hashes.json @@ -0,0 +1,57 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/notifications(/)?($|\\?.*)", + "queryParameters": { + "fields": { + "equalTo": "id,note_hash" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "last_seen_time": "1598052184", + "number": 9, + "notes": [ + { + "id": 4937370391, + "note_hash": 1528602550 + }, + { + "id": 4937363649, + "note_hash": 1391922081 + }, + { + "id": 4937363362, + "note_hash": 1287359074 + }, + { + "id": 4937360549, + "note_hash": 638476488 + }, + { + "id": 4937354640, + "note_hash": 492599271 + }, + { + "id": 4937350334, + "note_hash": 3590885648 + }, + { + "id": 4937367020, + "note_hash": 3233821948 + }, + { + "id": 4880577651, + "note_hash": 2994072417 + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/rest_v11_notifications_seen.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/rest_v11_notifications_seen.json new file mode 100644 index 000000000000..b4abe36fc952 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/notifications/rest_v11_notifications_seen.json @@ -0,0 +1,18 @@ +{ + "request": { + "method": "POST", + "urlPath": "/rest/v1.1/notifications/seen" + }, + "response": { + "status": 200, + "jsonBody": { + "last_seen_time": "1550137757", + "success": true + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/plans/rest_v13_sites_158396482_plans.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/plans/rest_v13_sites_158396482_plans.json new file mode 100644 index 000000000000..4abb05b06367 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/plans/rest_v13_sites_158396482_plans.json @@ -0,0 +1,173 @@ +{ + "request": { + "urlPattern": "/rest/v1.3/sites/.*/plans.*", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "1": { + "formatted_original_price": "£0", + "raw_price": 0, + "formatted_price": "£0", + "raw_discount": 0, + "formatted_discount": "£0", + "product_slug": "free_plan", + "product_name": "WordPress.com Free", + "discount_reason": null, + "is_domain_upgrade": null, + "currency_code": "GBP", + "user_is_owner": null, + "current_plan": true, + "id": null, + "has_domain_credit": false, + "interval": -1 + }, + "1003": { + "formatted_original_price": "£0", + "raw_price": 84, + "formatted_price": "£84", + "raw_discount": 0, + "formatted_discount": "£0", + "product_slug": "value_bundle", + "product_name": "WordPress.com Premium", + "discount_reason": null, + "is_domain_upgrade": null, + "currency_code": "GBP", + "can_start_trial": false, + "interval": 365 + }, + "1008": { + "formatted_original_price": "£0", + "raw_price": 240, + "formatted_price": "£240", + "raw_discount": 0, + "formatted_discount": "£0", + "product_slug": "business-bundle", + "product_name": "WordPress.com Business", + "discount_reason": null, + "is_domain_upgrade": null, + "currency_code": "GBP", + "can_start_trial": false, + "interval": 365 + }, + "1009": { + "formatted_original_price": "£0", + "raw_price": 48, + "formatted_price": "£48", + "raw_discount": 0, + "formatted_discount": "£0", + "product_slug": "personal-bundle", + "product_name": "WordPress.com Personal", + "discount_reason": null, + "is_domain_upgrade": null, + "currency_code": "GBP", + "can_start_trial": false, + "interval": 365 + }, + "1010": { + "formatted_original_price": "£0", + "raw_price": 27, + "formatted_price": "£27", + "raw_discount": 0, + "formatted_discount": "£0", + "product_slug": "blogger-bundle", + "product_name": "WordPress.com Blogger", + "discount_reason": null, + "is_domain_upgrade": null, + "currency_code": "GBP", + "can_start_trial": false, + "interval": 365 + }, + "1011": { + "formatted_original_price": "£0", + "raw_price": 432, + "formatted_price": "£432", + "raw_discount": 0, + "formatted_discount": "£0", + "product_slug": "ecommerce-bundle", + "product_name": "WordPress.com eCommerce", + "discount_reason": null, + "is_domain_upgrade": null, + "currency_code": "GBP", + "can_start_trial": false, + "interval": 365 + }, + "1023": { + "formatted_original_price": "£0", + "raw_price": 132, + "formatted_price": "£132", + "raw_discount": 0, + "formatted_discount": "£0", + "product_slug": "value_bundle-2y", + "product_name": "WordPress.com Premium", + "discount_reason": null, + "is_domain_upgrade": null, + "currency_code": "GBP", + "can_start_trial": false, + "interval": 730 + }, + "1028": { + "formatted_original_price": "£0", + "raw_price": 399, + "formatted_price": "£399", + "raw_discount": 0, + "formatted_discount": "£0", + "product_slug": "business-bundle-2y", + "product_name": "WordPress.com Business", + "discount_reason": null, + "is_domain_upgrade": null, + "currency_code": "GBP", + "can_start_trial": false, + "interval": 730 + }, + "1029": { + "formatted_original_price": "£0", + "raw_price": 84, + "formatted_price": "£84", + "raw_discount": 0, + "formatted_discount": "£0", + "product_slug": "personal-bundle-2y", + "product_name": "WordPress.com Personal", + "discount_reason": null, + "is_domain_upgrade": null, + "currency_code": "GBP", + "can_start_trial": false, + "interval": 730 + }, + "1030": { + "formatted_original_price": "£0", + "raw_price": 50, + "formatted_price": "£50", + "raw_discount": 0, + "formatted_discount": "£0", + "product_slug": "blogger-bundle-2y", + "product_name": "WordPress.com Blogger", + "discount_reason": null, + "is_domain_upgrade": null, + "currency_code": "GBP", + "can_start_trial": false, + "interval": 730 + }, + "1031": { + "formatted_original_price": "£0", + "raw_price": 696, + "formatted_price": "£696", + "raw_discount": 0, + "formatted_discount": "£0", + "product_slug": "ecommerce-bundle-2y", + "product_name": "WordPress.com eCommerce", + "discount_reason": null, + "is_domain_upgrade": null, + "currency_code": "GBP", + "can_start_trial": false, + "interval": 730 + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/plans/wpcom_v2_plans_mobile.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/plans/wpcom_v2_plans_mobile.json new file mode 100644 index 000000000000..903c076a70d7 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/plans/wpcom_v2_plans_mobile.json @@ -0,0 +1,377 @@ +{ + "request": { + "urlPath": "/wpcom/v2/plans/mobile", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "groups": [ + { + "slug": "personal", + "name": "Personal" + }, + { + "slug": "business", + "name": "Business" + } + ], + "plans": [ + { + "groups": [ + "personal" + ], + "products": [ + { + "plan_id": 1 + } + ], + "name": "WordPress.com Free", + "short_name": "Free", + "tagline": "Best for getting started", + "description": "If you just want to start creating, get a free site and be on your way to publishing in less than five minutes.", + "features": [ + "subdomain", + "jetpack-essentials", + "support-forums", + "themes-free", + "design-basic", + "space-3G", + "banner-ads" + ], + "icon": "https://s0.wordpress.com/i/store/mobile/plans-free.png" + }, + { + "groups": [ + "personal" + ], + "products": [ + { + "plan_id": 1010 + }, + { + "plan_id": 1030 + } + ], + "name": "WordPress.com Blogger", + "short_name": "Blogger", + "tagline": "Best for bloggers", + "description": "Brand your blog with a custom .blog domain name, and remove all WordPress.com advertising. Receive additional storage space and email support.", + "features": [ + "blog-domain", + "jetpack-essentials", + "support-email", + "themes-free", + "design-basic", + "space-6G", + "no-ads" + ], + "icon": "https://s0.wordpress.com/i/store/mobile/plans-blogger.png" + }, + { + "groups": [ + "personal" + ], + "products": [ + { + "plan_id": 1009 + }, + { + "plan_id": 1029 + } + ], + "name": "WordPress.com Personal", + "short_name": "Personal", + "tagline": "Best for personal use", + "description": "Boost your website with a custom domain name, and remove all WordPress.com advertising. Get access to high quality email and live chat support.", + "features": [ + "custom-domain", + "jetpack-essentials", + "support-live", + "themes-free", + "design-basic", + "space-6G", + "no-ads" + ], + "icon": "https://s0.wordpress.com/i/store/mobile/plans-personal.png" + }, + { + "groups": [ + "business" + ], + "products": [ + { + "plan_id": 1003 + }, + { + "plan_id": 1023 + } + ], + "name": "WordPress.com Premium", + "short_name": "Premium", + "tagline": "Best for freelancers", + "description": "Build a unique website with advanced design tools, CSS editing, lots of space for audio and video, and the ability to monetize your site with ads.", + "features": [ + "custom-domain", + "jetpack-essentials", + "support-live", + "themes-premium", + "design-custom", + "space-13G", + "no-ads", + "social-media", + "simple-payments", + "monitization", + "videopress" + ], + "icon": "https://s0.wordpress.com/i/store/mobile/plans-premium.png" + }, + { + "groups": [ + "business" + ], + "products": [ + { + "plan_id": 1008 + }, + { + "plan_id": 1028 + } + ], + "name": "WordPress.com Business", + "short_name": "Business", + "tagline": "Best for small business.", + "description": "Power your business website with unlimited premium and business theme templates, Google Analytics support, unlimited storage, and the ability to remove WordPress.com branding.", + "features": [ + "custom-domain", + "jetpack-essentials", + "support-live", + "themes-premium", + "design-custom", + "space-unlimited", + "no-ads", + "social-media", + "simple-payments", + "monitization", + "videopress", + "personalized-help", + "seo", + "plugins", + "upload-themes", + "google-analytics", + "no-branding" + ], + "icon": "https://s0.wordpress.com/i/store/mobile/plans-business.png" + }, + { + "groups": [ + "business" + ], + "products": [ + { + "plan_id": 1011 + }, + { + "plan_id": 1031 + } + ], + "name": "WordPress.com E-commerce", + "short_name": "E-commerce", + "tagline": "Best for online stores", + "description": "Sell products or services with this powerful, all-in-one online store experience. This plan includes premium integrations and is extendable, so it’ll grow with you as your business grows.", + "features": [ + "custom-domain", + "jetpack-essentials", + "support-live", + "themes-premium", + "design-custom", + "space-unlimited", + "no-ads", + "simple-payments", + "monitization", + "videopress", + "personalized-help", + "seo", + "plugins", + "upload-themes", + "google-analytics", + "no-branding", + "payments-processing", + "shipping-rates", + "unlimited-products", + "ecommerce-tools", + "store-theme" + ], + "icon": "https://s0.wordpress.com/i/store/mobile/plans-ecommerce.png" + } + ], + "features": [ + { + "id": "subdomain", + "name": "WordPress.com Subdomain", + "description": "Your site address will use a WordPress.com subdomain (sitename.wordpress.com)." + }, + { + "id": "jetpack-essentials", + "name": "Jetpack Essential Features", + "description": "Speed up your site’s performance and protect it from spammers. Access detailed records of all activity on your site. While you’re at it, improve your SEO and automate social media sharing." + }, + { + "id": "support-forums", + "name": "Community Support", + "description": "Get support through our user community forums." + }, + { + "id": "support-email", + "name": "Email Support", + "description": "High quality email support to help you get your website up and running and working how you want it." + }, + { + "id": "themes-free", + "name": "Dozens of Free Themes", + "description": "Access to a wide range of professional theme templates for your website so you can find the exact design you're looking for." + }, + { + "id": "design-basic", + "name": "Basic Design Customization", + "description": "Customize your selected theme template with pre-set color schemes, background designs, and font styles." + }, + { + "id": "space-3G", + "name": "3GB Storage Space", + "description": "Ample storage space to upload images and documents to your website." + }, + { + "id": "banner-ads", + "name": "WordPress.com Advertising and Banners", + "description": "On our free plan, we sometimes display advertisements on your blog to help pay the bills. This keeps free features free!" + }, + { + "id": "custom-domain", + "name": "Free Domain for One Year", + "description": "Get a free domain for one year. Premium domains not included. Your domain will renew at its regular price." + }, + { + "id": "support-live", + "name": "Email & Live Chat Support", + "description": "High quality support to help you get your website up and running and working how you want it." + }, + { + "id": "space-6G", + "name": "6GB Storage Space", + "description": "With increased storage space you'll be able to upload more images, audio, and documents to your website." + }, + { + "id": "no-ads", + "name": "Remove WordPress.com Ads", + "description": "Allow your visitors to visit and read your website without seeing any WordPress.com advertising." + }, + { + "id": "themes-premium", + "name": "Unlimited Premium Themes", + "description": "Unlimited access to all of our advanced premium theme templates, including templates specifically tailored for businesses." + }, + { + "id": "design-custom", + "name": "Advanced Design Customization", + "description": "Customize your selected theme template with extended color schemes, background designs, and complete control over website CSS." + }, + { + "id": "space-13G", + "name": "13GB Storage Space", + "description": "With increased storage space you'll be able to upload more images, videos, audio, and documents to your website." + }, + { + "id": "social-media", + "name": "Advanced Social Media", + "description": "Schedule your social media updates in advance and promote your posts when it's best for you." + }, + { + "id": "simple-payments", + "name": "Simple Payments", + "description": "Sell anything with a simple PayPal button." + }, + { + "id": "monitization", + "name": "Site Monetization", + "description": "Put your site to work and earn through ad revenue, easy-to-add PayPal buttons, and more." + }, + { + "id": "videopress", + "name": "VideoPress Support", + "description": "The easiest way to upload videos to your website and display them using a fast, unbranded, customizable player with rich stats." + }, + { + "id": "space-unlimited", + "name": "Unlimited Storage Space", + "description": "With increased storage space you'll be able to upload more images, videos, audio, and documents to your website." + }, + { + "id": "personalized-help", + "name": "Get Personalized Help", + "description": "Schedule a one-on-one orientation with a Happiness Engineer to set up your site and learn more about WordPress.com." + }, + { + "id": "seo", + "name": "SEO Tools", + "description": "Adds tools to enhance your site's content for better results on search engines and social media." + }, + { + "id": "plugins", + "name": "Install Plugins", + "description": "Install custom plugins on your site." + }, + { + "id": "upload-themes", + "name": "Upload Themes", + "description": "Upload custom themes on your site." + }, + { + "id": "google-analytics", + "name": "Google Analytics Integration", + "description": "Track website statistics with Google Analytics for a deeper understanding of your website visitors and customers." + }, + { + "id": "no-branding", + "name": "Remove WordPress.com Branding", + "description": "Keep the focus on your site's brand by removing the WordPress.com footer branding." + }, + { + "id": "blog-domain", + "name": "Free .blog Domain for One Year", + "description": "Get a free custom .blog domain for one year. Premium domains not included. Your domain will renew at its regular price." + }, + { + "id": "unlimited-products", + "name": "Unlimited Products or Services", + "description": "Grow your store as big as you want with the ability to add and sell unlimited products and services." + }, + { + "id": "payments-processing", + "name": "Accept Payments in 60+ Countries", + "description": "Built-in payment processing from leading providers like Stripe, PayPal, and more. Accept payments from customers all over the world." + }, + { + "id": "shipping-rates", + "name": "Integrations with Top Shipping Carriers", + "description": "Ship physical products in a snap - show live rates from shipping carriers like UPS and other shipping options" + }, + { + "id": "ecommerce-tools", + "name": "eCommerce Marketing Tools", + "description": "Optimize your store for sales by adding in email and social integrations with Facebook and Mailchimp, and more." + }, + { + "id": "store-theme", + "name": "Premium Customizable Starter Themes", + "description": "Quickly get up and running with a beautiful store theme and additional design options that you can easily make your own." + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/categories.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/categories.json new file mode 100644 index 000000000000..f375b04e7a6c --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/categories.json @@ -0,0 +1,51 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/categories", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 2, + "categories": [ + { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "feed_url": "http://infocusphotographers.com/category/uncategorized/feed/", + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 1674, + "name": "Wedding", + "slug": "wedding", + "description": "", + "post_count": 1, + "feed_url": "http://infocusphotographers.com/category/wedding/feed/", + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post-formats.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post-formats.json new file mode 100644 index 000000000000..bed37adadcf9 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post-formats.json @@ -0,0 +1,20 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/[0-9]+/post-formats(.*)" + }, + "response": { + "status": 200, + "jsonBody": { + "formats": { + "aside": "Aside", + "image": "Image", + "video": "Video", + "quote": "Quote", + "link": "Link", + "status": "Status", + "gallery": "Gallery" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_0_diffs.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_0_diffs.json new file mode 100644 index 000000000000..8aca6e9500b5 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_0_diffs.json @@ -0,0 +1,18 @@ +{ + "request": { + "urlPattern": "/rest/v1.1/sites/.*/post/[0-9]+/diffs.*", + "method": "GET" + }, + "response": { + "status": 400, + "jsonBody": { + "error": "User cannot edit this post", + "message": 403 + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_213_diffs.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_213_diffs.json new file mode 100644 index 000000000000..d3ed9d7e4255 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_213_diffs.json @@ -0,0 +1,1916 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/post/213/diffs/", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "diffs": [ + { + "from": 400, + "to": 401, + "diff": { + "post_title": [ + { + "op": "del", + "value": "(no" + }, + { + "op": "add", + "value": "Summer" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "title)" + }, + { + "op": "add", + "value": "Band Jam" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n\n\n
\"\"
\n\n\n\n
\"\"
\n" + } + ], + "totals": { + "del": 2, + "add": 3 + } + } + }, + { + "from": 309, + "to": 400, + "diff": { + "post_title": [ + { + "op": "del", + "value": "Summer" + }, + { + "op": "add", + "value": "(no" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "Band Jam" + }, + { + "op": "add", + "value": "title)" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n\n\n
\"\"
\n\n\n\n
\"\"
\n" + } + ], + "totals": { + "del": 3, + "add": 2 + } + } + }, + { + "from": 303, + "to": 309, + "diff": { + "post_title": [ + { + "op": "copy", + "value": "Summer Band Jam" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n" + }, + { + "op": "add", + "value": "\n\n
\"\"
\n\n\n\n
\"\"
\n" + }, + { + "op": "copy", + "value": "\n" + } + ], + "totals": { + "add": 50 + } + } + }, + { + "from": 300, + "to": 303, + "diff": { + "post_title": [ + { + "op": "copy", + "value": "Summer Band Jam" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n" + }, + { + "op": "del", + "value": "\n\n
\"\"
\n" + }, + { + "op": "copy", + "value": "\n" + } + ], + "totals": { + "del": 26 + } + } + }, + { + "from": 299, + "to": 300, + "diff": { + "post_title": [ + { + "op": "copy", + "value": "Summer Band Jam" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!" + }, + { + "op": "del", + "value": " ..." + }, + { + "op": "copy", + "value": "

\n\n\n\n
\"\"
\n\n" + } + ], + "totals": { + "del": 0 + } + } + }, + { + "from": 298, + "to": 299, + "diff": { + "post_title": [ + { + "op": "del", + "value": "(no" + }, + { + "op": "add", + "value": "Summer" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "title)" + }, + { + "op": "add", + "value": "Band Jam" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots! ...

\n\n\n\n
\"\"
\n" + } + ], + "totals": { + "del": 2, + "add": 3 + } + } + }, + { + "from": 297, + "to": 298, + "diff": { + "post_title": [ + { + "op": "del", + "value": "Summer" + }, + { + "op": "add", + "value": "(no" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "Band Jam" + }, + { + "op": "add", + "value": "title)" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots! ...

\n\n\n\n
\"\"
\n" + } + ], + "totals": { + "del": 3, + "add": 2 + } + } + }, + { + "from": 296, + "to": 297, + "diff": { + "post_title": [ + { + "op": "copy", + "value": "Summer Band Jam" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!" + }, + { + "op": "add", + "value": " ..." + }, + { + "op": "copy", + "value": "

\n\n\n\n
\"\"
\n\n" + } + ], + "totals": { + "add": 0 + } + } + }, + { + "from": 292, + "to": 296, + "diff": { + "post_title": [ + { + "op": "copy", + "value": "Summer Band Jam" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n\n" + }, + { + "op": "del", + "value": "\t \t " + }, + { + "op": "copy", + "value": "\n
""
" + }, + { + "op": "del", + "value": "\t \t " + }, + { + "op": "copy", + "value": "\n\n" + } + ], + "totals": { + "del": 0 + } + } + }, + { + "from": 291, + "to": 292, + "diff": { + "post_title": [ + { + "op": "del", + "value": "(no" + }, + { + "op": "add", + "value": "Summer" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "title)" + }, + { + "op": "add", + "value": "Band Jam" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n" + } + ], + "totals": { + "del": 2, + "add": 3 + } + } + }, + { + "from": 289, + "to": 291, + "diff": { + "post_title": [ + { + "op": "del", + "value": "Summer" + }, + { + "op": "add", + "value": "(no" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "Band Jam" + }, + { + "op": "add", + "value": "title)" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n" + } + ], + "totals": { + "del": 3, + "add": 2 + } + } + }, + { + "from": 288, + "to": 289, + "diff": { + "post_title": [ + { + "op": "del", + "value": "(no" + }, + { + "op": "add", + "value": "Summer" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "title)" + }, + { + "op": "add", + "value": "Band Jam" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n" + } + ], + "totals": { + "del": 2, + "add": 3 + } + } + }, + { + "from": 287, + "to": 288, + "diff": { + "post_title": [ + { + "op": "del", + "value": "Summer" + }, + { + "op": "add", + "value": "(no" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "Band Jam" + }, + { + "op": "add", + "value": "title)" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n" + } + ], + "totals": { + "del": 3, + "add": 2 + } + } + }, + { + "from": 286, + "to": 287, + "diff": { + "post_title": [ + { + "op": "del", + "value": "(no" + }, + { + "op": "add", + "value": "Summer" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "title)" + }, + { + "op": "add", + "value": "Band Jam" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n" + } + ], + "totals": { + "del": 2, + "add": 3 + } + } + }, + { + "from": 285, + "to": 286, + "diff": { + "post_title": [ + { + "op": "del", + "value": "Summer" + }, + { + "op": "add", + "value": "(no" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "Band Jam" + }, + { + "op": "add", + "value": "title)" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n" + } + ], + "totals": { + "del": 3, + "add": 2 + } + } + }, + { + "from": 284, + "to": 285, + "diff": { + "post_title": [ + { + "op": "del", + "value": "(no" + }, + { + "op": "add", + "value": "Summer" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "title)" + }, + { + "op": "add", + "value": "Band Jam" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

" + }, + { + "op": "del", + "value": "Blue" + }, + { + "op": "add", + "value": "This" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "skies" + }, + { + "op": "add", + "value": "event" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "and" + }, + { + "op": "add", + "value": "was" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "warm" + }, + { + "op": "add", + "value": "so" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "weather" + }, + { + "op": "add", + "value": "much fun" + }, + { + "op": "copy", + "value": ", " + }, + { + "op": "del", + "value": "what's" + }, + { + "op": "add", + "value": "I" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "not" + }, + { + "op": "add", + "value": "couldn’t wait" + }, + { + "op": "copy", + "value": " to " + }, + { + "op": "del", + "value": "love" + }, + { + "op": "add", + "value": "share a few of my" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "about" + }, + { + "op": "add", + "value": "favorite" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "summer?" + }, + { + "op": "add", + "value": "shots!" + }, + { + "op": "copy", + "value": "

\n\n\n\n

It's" + }, + { + "op": "add", + "value": "image" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "a great season for outdoor family portrait sessions and now is the time to book them!" + }, + { + "op": "del", + "value": "

\n\n\n\n

We offer a number of family portrait packages" + }, + { + "op": "add", + "value": ":209}" + }, + { + "op": "del", + "value": " and for a limited time are offering 15% off packages booked before May 1.

\n" + }, + { + "op": "del", + "value": "\n\n" + }, + { + "op": "copy", + "value": "\n
""
" + }, + { + "op": "add", + "value": "\t \t " + }, + { + "op": "copy", + "value": "\n\n" + }, + { + "op": "del", + "value": "\n\n

How to book

\n\n\n\n

Email us to set up a time to visit our studio.

\n" + }, + { + "op": "copy", + "value": "\n" + } + ], + "totals": { + "del": 127, + "add": 26 + } + } + }, + { + "from": 283, + "to": 284, + "diff": { + "post_title": [ + { + "op": "del", + "value": "Summer" + }, + { + "op": "add", + "value": "(no" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "Band Jam" + }, + { + "op": "add", + "value": "title)" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

" + }, + { + "op": "del", + "value": "This" + }, + { + "op": "add", + "value": "Blue" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "event" + }, + { + "op": "add", + "value": "skies" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "was" + }, + { + "op": "add", + "value": "and" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "so" + }, + { + "op": "add", + "value": "warm" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "much fun" + }, + { + "op": "add", + "value": "weather" + }, + { + "op": "copy", + "value": ", " + }, + { + "op": "del", + "value": "I" + }, + { + "op": "add", + "value": "what's" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "couldn’t wait" + }, + { + "op": "add", + "value": "not" + }, + { + "op": "copy", + "value": " to " + }, + { + "op": "del", + "value": "share a few of my" + }, + { + "op": "add", + "value": "love" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "favorite" + }, + { + "op": "add", + "value": "about" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "shots!" + }, + { + "op": "add", + "value": "summer?" + }, + { + "op": "copy", + "value": "

\n\n\n\n

It's" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "add", + "value": "a great season for outdoor family portrait sessions and now is the time to book them!" + }, + { + "op": "del", + "value": "{" + }, + { + "op": "add", + "value": "

\n\n\n\n

We offer a number of family portrait packages" + }, + { + "op": "add", + "value": " and for a limited time are offering 15% off packages booked before May 1.

\n" + }, + { + "op": "del", + "value": "\t" + }, + { + "op": "add", + "value": "\n\n" + }, + { + "op": "copy", + "value": "\n
""
" + }, + { + "op": "del", + "value": "\t \t " + }, + { + "op": "copy", + "value": "\n\n" + }, + { + "op": "add", + "value": "\n\n

How to book

\n\n\n\n

Email us to set up a time to visit our studio.

\n" + }, + { + "op": "copy", + "value": "\n" + } + ], + "totals": { + "del": 26, + "add": 127 + } + } + }, + { + "from": 280, + "to": 283, + "diff": { + "post_title": [ + { + "op": "copy", + "value": "Summer Band Jam" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n" + }, + { + "op": "add", + "value": "\n\t \t \n
\"\"
\t \t \n" + }, + { + "op": "copy", + "value": "\n" + } + ], + "totals": { + "add": 26 + } + } + }, + { + "from": 277, + "to": 280, + "diff": { + "post_title": [ + { + "op": "del", + "value": "(no" + }, + { + "op": "add", + "value": "Summer" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "title)" + }, + { + "op": "add", + "value": "Band Jam" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n" + } + ], + "totals": { + "del": 2, + "add": 3 + } + } + }, + { + "from": 273, + "to": 277, + "diff": { + "post_title": [ + { + "op": "del", + "value": "Summer" + }, + { + "op": "add", + "value": "(no" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "Band Jam" + }, + { + "op": "add", + "value": "title)" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n" + } + ], + "totals": { + "del": 3, + "add": 2 + } + } + }, + { + "from": 267, + "to": 273, + "diff": { + "post_title": [ + { + "op": "copy", + "value": "Summer Band Jam" + } + ], + "post_content": [ + { + "op": "copy", + "value": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n" + }, + { + "op": "del", + "value": "\n\n
\"\"
\n" + }, + { + "op": "copy", + "value": "\n" + } + ], + "totals": { + "del": 26 + } + } + }, + { + "from": 240, + "to": 267, + "diff": { + "post_title": [ + { + "op": "copy", + "value": "Summer Band Jam" + } + ], + "post_content": [ + { + "op": "add", + "value": "\n

" + }, + { + "op": "copy", + "value": "This event was so much fun, I couldn’t wait to share a few of my favorite shots!" + }, + { + "op": "add", + "value": "

\n" + }, + { + "op": "copy", + "value": "\n\n<" + }, + { + "op": "del", + "value": "img" + }, + { + "op": "add", + "value": "!-- wp:image" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "class=" + }, + { + "op": "add", + "value": "{" + }, + { + "op": "copy", + "value": """ + }, + { + "op": "del", + "value": "alignnone" + }, + { + "op": "add", + "value": "id":209}" + }, + { + "op": "copy", + "value": " " + }, + { + "op": "del", + "value": "size" + }, + { + "op": "add", + "value": "" + }, + { + "op": "copy", + "value": "-" + }, + { + "op": "del", + "value": "full" + }, + { + "op": "add", + "value": "->\n\n\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n\n\n
\"\"
\n\n\n\n
\"\"
\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "400": { + "post_date_gmt": "2019-06-28 21:04:40Z", + "post_modified_gmt": "2019-06-28 21:04:40Z", + "post_author": "68646169", + "id": 400, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n\n\n
\"\"
\n\n\n\n
\"\"
\n", + "post_excerpt": "", + "post_title": "" + }, + "309": { + "post_date_gmt": "2019-05-27 21:36:25Z", + "post_modified_gmt": "2019-05-27 21:36:25Z", + "post_author": "742098", + "id": 309, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n\n\n
\"\"
\n\n\n\n
\"\"
\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "303": { + "post_date_gmt": "2019-05-27 19:26:17Z", + "post_modified_gmt": "2019-05-27 19:26:17Z", + "post_author": "742098", + "id": 303, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "300": { + "post_date_gmt": "2019-04-17 10:45:45Z", + "post_modified_gmt": "2019-04-17 10:45:45Z", + "post_author": "67626417", + "id": 300, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n\n\n
\"\"
\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "299": { + "post_date_gmt": "2019-04-17 10:45:25Z", + "post_modified_gmt": "2019-04-17 10:45:25Z", + "post_author": "67626417", + "id": 299, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots! ...

\n\n\n\n
\"\"
\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "298": { + "post_date_gmt": "2019-04-17 10:44:49Z", + "post_modified_gmt": "2019-04-17 10:44:49Z", + "post_author": "68646169", + "id": 298, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots! ...

\n\n\n\n
\"\"
\n", + "post_excerpt": "", + "post_title": "" + }, + "297": { + "post_date_gmt": "2019-04-17 10:44:12Z", + "post_modified_gmt": "2019-04-17 10:44:12Z", + "post_author": "67626417", + "id": 297, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots! ...

\n\n\n\n
\"\"
\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "296": { + "post_date_gmt": "2019-04-17 10:42:14Z", + "post_modified_gmt": "2019-04-17 10:42:14Z", + "post_author": "67626417", + "id": 296, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n\n\n
\"\"
\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "292": { + "post_date_gmt": "2019-04-17 10:39:09Z", + "post_modified_gmt": "2019-04-17 10:39:09Z", + "post_author": "67626417", + "id": 292, + "post_content": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "291": { + "post_date_gmt": "2019-04-17 10:36:25Z", + "post_modified_gmt": "2019-04-17 10:36:25Z", + "post_author": "68646169", + "id": 291, + "post_content": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n", + "post_excerpt": "", + "post_title": "" + }, + "289": { + "post_date_gmt": "2019-04-17 10:29:16Z", + "post_modified_gmt": "2019-04-17 10:29:16Z", + "post_author": "67626417", + "id": 289, + "post_content": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "288": { + "post_date_gmt": "2019-04-17 10:28:37Z", + "post_modified_gmt": "2019-04-17 10:28:37Z", + "post_author": "68646169", + "id": 288, + "post_content": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n", + "post_excerpt": "", + "post_title": "" + }, + "287": { + "post_date_gmt": "2019-04-17 10:27:45Z", + "post_modified_gmt": "2019-04-17 10:27:45Z", + "post_author": "67626417", + "id": 287, + "post_content": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "286": { + "post_date_gmt": "2019-04-17 10:27:13Z", + "post_modified_gmt": "2019-04-17 10:27:13Z", + "post_author": "68646169", + "id": 286, + "post_content": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n", + "post_excerpt": "", + "post_title": "" + }, + "285": { + "post_date_gmt": "2019-04-17 09:35:57Z", + "post_modified_gmt": "2019-04-17 09:35:57Z", + "post_author": "67626417", + "id": 285, + "post_content": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "284": { + "post_date_gmt": "2019-04-17 09:32:58Z", + "post_modified_gmt": "2019-04-17 09:32:58Z", + "post_author": "68646169", + "id": 284, + "post_content": "\n

Blue skies and warm weather, what's not to love about summer?

\n\n\n\n

It's a great season for outdoor family portrait sessions and now is the time to book them!

\n\n\n\n

We offer a number of family portrait packages and for a limited time are offering 15% off packages booked before May 1.

\n\n\n\n
\"beach-clouds-daytime-994605\"
\n\n\n\n

How to book

\n\n\n\n

Email us to set up a time to visit our studio.

\n", + "post_excerpt": "", + "post_title": "" + }, + "283": { + "post_date_gmt": "2019-04-17 09:32:34Z", + "post_modified_gmt": "2019-04-17 09:32:34Z", + "post_author": "67626417", + "id": 283, + "post_content": "\r\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\r\n\r\n\r\n\t \t \r\n
\"\"
\t \t \r\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "280": { + "post_date_gmt": "2019-04-17 09:23:35Z", + "post_modified_gmt": "2019-04-17 09:23:35Z", + "post_author": "67626417", + "id": 280, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "277": { + "post_date_gmt": "2019-04-16 14:21:22Z", + "post_modified_gmt": "2019-04-16 14:21:22Z", + "post_author": "68646169", + "id": 277, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n", + "post_excerpt": "", + "post_title": "" + }, + "273": { + "post_date_gmt": "2019-03-21 17:23:26Z", + "post_modified_gmt": "2019-03-21 17:23:26Z", + "post_author": "68646169", + "id": 273, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "267": { + "post_date_gmt": "2019-03-21 17:17:59Z", + "post_modified_gmt": "2019-03-21 17:17:59Z", + "post_author": "68646169", + "id": 267, + "post_content": "\n

This event was so much fun, I couldn’t wait to share a few of my favorite shots!

\n\n\n\n
\"\"
\n", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "240": { + "post_date_gmt": "2019-03-20 23:45:49Z", + "post_modified_gmt": "2019-03-20 23:45:49Z", + "post_author": "14151046", + "id": 240, + "post_content": "This event was so much fun, I couldn’t wait to share a few of my favorite shots!\n\n\"concert-effect-entertainment-1150837\"", + "post_excerpt": "", + "post_title": "Summer Band Jam" + }, + "214": { + "post_date_gmt": "2019-02-15 23:26:48Z", + "post_modified_gmt": "2019-02-15 23:26:48Z", + "post_author": "68646169", + "id": 214, + "post_content": "This event was so much fun, I couldn’t wait to share a few of my favorite shots!", + "post_excerpt": "", + "post_title": "Summer Band Jam" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_215_diffs.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_215_diffs.json new file mode 100644 index 000000000000..d6f530c3fee7 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_215_diffs.json @@ -0,0 +1,71 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/post/215/diffs/", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "diffs": [ + { + "from": 0, + "to": 216, + "diff": { + "post_title": [ + { + "op": "del", + "value": "" + }, + { + "op": "add", + "value": "Ideas" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "add", + "value": "Returning client special - Offer a discount to clients who have left a review." + }, + { + "op": "copy", + "value": "\n\n" + }, + { + "op": "add", + "value": "Photography classes at the local" + }, + { + "op": "copy", + "value": "\n" + } + ], + "totals": { + "del": 0, + "add": 20 + } + } + } + ], + "revisions": { + "216": { + "post_date_gmt": "2019-02-15 23:27:13Z", + "post_modified_gmt": "2019-02-15 23:27:13Z", + "post_author": "68646169", + "id": 216, + "post_content": "Returning client special - Offer a discount to clients who have left a review.\n\nPhotography classes at the local", + "post_excerpt": "", + "post_title": "Ideas" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_387_diffs.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_387_diffs.json new file mode 100644 index 000000000000..4e1dbb8b8a10 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_387_diffs.json @@ -0,0 +1,71 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/post/387/diffs/", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "diffs": [ + { + "from": 0, + "to": 390, + "diff": { + "post_title": [ + { + "op": "del", + "value": "" + }, + { + "op": "add", + "value": "Time to Book Summer Sessions" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "add", + "value": "Blue skies and warm weather, what's not to love about summer?" + }, + { + "op": "copy", + "value": "\n\n" + }, + { + "op": "add", + "value": "It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before May 1.\n\n\"beach-clouds-daytime-994605\"\n\nHow to book\nEmail us to set up a time to visit our studio." + }, + { + "op": "copy", + "value": "\n" + } + ], + "totals": { + "del": 0, + "add": 86 + } + } + } + ], + "revisions": { + "390": { + "post_date_gmt": "2019-05-28 21:03:03Z", + "post_modified_gmt": "2019-05-28 21:03:03Z", + "post_author": "742098", + "id": 390, + "post_content": "Blue skies and warm weather, what's not to love about summer?\n\nIt's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before May 1.\n\n\"beach-clouds-daytime-994605\"\n\nHow to book\nEmail us to set up a time to visit our studio.", + "post_excerpt": "", + "post_title": "Time to Book Summer Sessions" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_396_diffs.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_396_diffs.json new file mode 100644 index 000000000000..5634833bff6f --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/post_396_diffs.json @@ -0,0 +1,130 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/post/396/diffs/", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "diffs": [ + { + "from": 398, + "to": 399, + "diff": { + "post_title": [ + { + "op": "copy", + "value": "Now Booking Summer Sessions" + } + ], + "post_content": [ + { + "op": "copy", + "value": "
“One must maintain a little bit of summer, even in the middle of winter.”\n\n– Henry David Thoreau
\n" + }, + { + "op": "add", + "value": "\n" + }, + { + "op": "copy", + "value": "\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n\nHow to book" + }, + { + "op": "del", + "value": "\n<" + }, + { + "op": "add", + "value": "<" + }, + { + "op": "copy", + "value": "/strong>" + }, + { + "op": "add", + "value": "\n\n\n" + }, + { + "op": "copy", + "value": "Email us to set up a time to visit our studio.\n" + } + ], + "totals": { + "add": 0, + "del": 0 + } + } + }, + { + "from": 0, + "to": 398, + "diff": { + "post_title": [ + { + "op": "del", + "value": "" + }, + { + "op": "add", + "value": "Now Booking Summer Sessions" + }, + { + "op": "copy", + "value": "\n" + } + ], + "post_content": [ + { + "op": "add", + "value": "
“One must maintain a little bit of summer, even in the middle of winter.”" + }, + { + "op": "copy", + "value": "\n\n" + }, + { + "op": "add", + "value": "– Henry David Thoreau
\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n\nHow to book\nEmail us to set up a time to visit our studio." + }, + { + "op": "copy", + "value": "\n" + } + ], + "totals": { + "del": 0, + "add": 101 + } + } + } + ], + "revisions": { + "399": { + "post_date_gmt": "2019-05-28 21:06:50Z", + "post_modified_gmt": "2019-05-28 21:06:50Z", + "post_author": "742098", + "id": 399, + "post_content": "
“One must maintain a little bit of summer, even in the middle of winter.”\n\n– Henry David Thoreau
\n\n\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n\nHow to book\n\n\nEmail us to set up a time to visit our studio.", + "post_excerpt": "", + "post_title": "Now Booking Summer Sessions" + }, + "398": { + "post_date_gmt": "2019-05-28 21:05:17Z", + "post_modified_gmt": "2019-05-28 21:05:17Z", + "post_author": "742098", + "id": 398, + "post_content": "
“One must maintain a little bit of summer, even in the middle of winter.”\n\n– Henry David Thoreau
\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n\nHow to book\nEmail us to set up a time to visit our studio.", + "post_excerpt": "", + "post_title": "Now Booking Summer Sessions" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-diff.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-diff.json new file mode 100644 index 000000000000..e5687df6fdba --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-diff.json @@ -0,0 +1,38 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/", + "queryParameters": { + "after": { + "matches": "(.*)" + }, + "before": { + "matches": "(.*)" + }, + "fields": { + "equalTo": "ID, title, URL" + }, + "number": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 0, + "posts": [ + + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/post-counts/post" + }, + "wpcom": true + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-draft,pending.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-draft,pending.json new file mode 100644 index 000000000000..6dd5353c09bb --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-draft,pending.json @@ -0,0 +1,78 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/", + "queryParameters": { + "number": { + "equalTo": "60" + }, + "context": { + "equalTo": "edit" + }, + "order_by": { + "equalTo": "date" + }, + "fields": { + "equalTo": "ID,modified,status,meta,date" + }, + "order": { + "equalTo": "DESC" + }, + "status": { + "equalTo": "draft,pending" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 7, + "posts": [ + { + "ID": 213, + "modified": "2019-07-16T02:39:45+00:00", + "status": "draft" + }, + { + "ID": 396, + "modified": "2019-05-28T21:08:03+00:00", + "status": "draft" + }, + { + "ID": 387, + "modified": "2019-05-28T21:03:22+00:00", + "status": "draft" + }, + { + "ID": 265, + "modified": "2019-04-17T10:41:03+00:00", + "status": "draft" + }, + { + "ID": 134, + "modified": "2019-05-27T21:39:08+00:00", + "status": "draft" + }, + { + "ID": 215, + "modified": "2019-02-15T23:27:13+00:00", + "status": "draft" + }, + { + "ID": 225, + "modified": "2019-03-21T17:20:44+00:00", + "status": "draft" + } + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/post-counts/post" + }, + "wpcom": true + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-draft,pending_v2.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-draft,pending_v2.json new file mode 100644 index 000000000000..da83a98cc5b0 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-draft,pending_v2.json @@ -0,0 +1,1308 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.2/sites/106707880/posts", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "context": { + "equalTo": "edit" + }, + "meta": { + "equalTo": "autosave" + }, + "number": { + "equalTo": "40" + }, + "status": { + "equalTo": "draft,pending" + }, + "type": { + "equalTo": "post" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 7, + "posts": [ + { + "ID": 213, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@gmail.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2020-08-24T12:17:14-07:00", + "modified": "2020-08-27T16:31:00-07:00", + "title": "Our Services", + "URL": "https://fourpawsdoggrooming.wordpress.com/our-services/", + "short_URL": "https://wp.me/Pcj1SD-J", + "content": "\n
\n

Mobile grooming salon for cats and dogs.

\n\n\n\n\n
\n\n\n\n

Dog grooming

\n\n\n\n
\n
\n

Our deluxe grooming service includes:

\n\n\n\n
  • Nail clip
  • Ear cleaning
  • 1st shampoo
  • 2nd shampoo
  • Conditioning rinse
  • Towel dry
  • Blow dry
  • Brush out
  • And a treat!
\n
\n\n\n\n
\n
\"\"
\n
\n
\n\n\n\n

Deluxe Cut and Groom

\n\n\n\n

Our deluxe cut and groom package includes everything in the deluxe groom plus a haircut.

\n\n\n\n

Add on services

\n\n\n\n

Pamper your pup even more with one of our signature add on services:

\n\n\n\n
  • Aloe conditioning treatment
  • Soothing medicated shampoos
  • Tooth brushing
  • Creative styling with temporary color
  • Nail polish application
  • Coat braiding
  • Bows and other accessories
\n\n\n\n
\n

\n
\n\n\n\n

\n", + "excerpt": "", + "slug": "our-services", + "guid": "https://fourpawsdoggrooming.wordpress.com/?p=45", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": false, + "comment_status": "closed", + "pings_open": false, + "ping_status": "closed", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "e41d1c97a7b93a594b07e4df51dfafb3", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + "65": { + "ID": 65, + "URL": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg", + "guid": "http://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg", + "date": "2020-08-27T16:30:36-07:00", + "post_ID": 45, + "author_ID": 191794483, + "file": "image-1.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "image", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=107", + "medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=214", + "large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=731", + "newspack-article-block-landscape-large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image-1.jpg?w=1200" + }, + "height": 1848, + "width": 1320, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/media/65", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/media/65/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/posts/45" + } + } + }, + "63": { + "ID": 63, + "URL": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg", + "guid": "http://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg", + "date": "2020-08-26T12:20:02-07:00", + "post_ID": 45, + "author_ID": 191794483, + "file": "wp-1598469598971.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "wp-1598469598971.jpg", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=100", + "medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=200", + "large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=682", + "newspack-article-block-landscape-large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/wp-1598469598971.jpg?w=1200" + }, + "height": 2780, + "width": 1851, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/media/63", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/media/63/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/posts/45" + } + } + }, + "61": { + "ID": 61, + "URL": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg", + "guid": "http://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg", + "date": "2020-08-25T12:05:54-07:00", + "post_ID": 45, + "author_ID": 191794483, + "file": "image.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "image", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=150", + "medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=300", + "large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=1024", + "newspack-article-block-landscape-large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=1200&h=900&crop=1", + "newspack-article-block-portrait-large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=900&h=1200&crop=1", + "newspack-article-block-square-large": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=1200&h=1200&crop=1", + "newspack-article-block-landscape-medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=800&h=600&crop=1", + "newspack-article-block-portrait-medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=600&h=800&crop=1", + "newspack-article-block-square-medium": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=800&h=800&crop=1", + "newspack-article-block-landscape-small": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=400&h=300&crop=1", + "newspack-article-block-portrait-small": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=300&h=400&crop=1", + "newspack-article-block-square-small": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=400&h=400&crop=1", + "newspack-article-block-landscape-tiny": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=200&h=150&crop=1", + "newspack-article-block-portrait-tiny": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=150&h=200&crop=1", + "newspack-article-block-square-tiny": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=200&h=200&crop=1", + "newspack-article-block-uncropped": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/image.jpg?w=1200" + }, + "height": 1848, + "width": 1846, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/media/61", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/media/61/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/posts/45" + } + } + } + }, + "attachment_count": 3, + "metadata": [ + { + "id": "502", + "key": "email_notification", + "value": "1553125754" + }, + { + "id": "564", + "key": "geo_public", + "value": "0" + }, + { + "id": "496", + "key": "jabber_published", + "value": "1553125752" + }, + { + "id": "503", + "key": "timeline_notification", + "value": "1553125801" + }, + { + "id": "563", + "key": "_edit_last", + "value": "67626417" + }, + { + "id": "556", + "key": "_edit_lock", + "value": "1565368108:742098" + }, + { + "id": "501", + "key": "_publicize_job_id", + "value": "28865441316" + }, + { + "id": "500", + "key": "_rest_api_client_id", + "value": "-1" + }, + { + "id": "499", + "key": "_rest_api_published", + "value": "1" + }, + { + "id": "565", + "key": "_thumbnail_id", + "value": "151" + }, + { + "id": "514", + "key": "_wp_old_date", + "value": "2019-03-20" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/213", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/213/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/213/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/213/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 402, + 401, + 400, + 309, + 303, + 300, + 299, + 298, + 297, + 296, + 292, + 291, + 289, + 288, + 287, + 286, + 285, + 284, + 283, + 280, + 277, + 273, + 267, + 240, + 214 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/07/16/%postname%/", + "suggested_slug": "summer-band-jam" + } + }, + { + "ID": 396, + "site_ID": 106707880, + "author": { + "ID": 742098, + "login": "jkmassel", + "email": "jeremy.massel@gmail.com", + "name": "Jeremy Massel", + "first_name": "Jeremy", + "last_name": "Massel", + "nice_name": "jkmassel", + "URL": "https://jkmassel.wordpress.com", + "avatar_URL": "https://2.gravatar.com/avatar/58fc51586c9a1f9895ac70e3ca60886e?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/jkmassel", + "site_ID": 1562023 + }, + "date": "2019-05-28T21:06:45+00:00", + "modified": "2019-05-28T21:08:03+00:00", + "title": "Now Booking Summer Sessions", + "URL": "http://infocusphotographers.com/?p=396", + "short_URL": "https://wp.me/p7dJAQ-6o", + "content": "
“One must maintain a little bit of summer, even in the middle of winter.”\n\n– Henry David Thoreau
\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n\nHow to book\n\nEmail us to set up a time to visit our studio.", + "excerpt": "", + "slug": "", + "guid": "http://infocusphotographers.com/?p=396", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": false, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "276e4cea0342ec68e69a0ca537acfce1", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "742", + "key": "sharing_disabled", + "value": [ + + ] + }, + { + "id": "741", + "key": "switch_like_status", + "value": "" + }, + { + "id": "743", + "key": "_edit_lock", + "value": "1565368199:742098" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/396", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/396/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/396/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/396/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 399, + 398 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/05/28/%postname%/", + "suggested_slug": "now-booking-summer-sessions-2" + } + }, + { + "ID": 387, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@gmail.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-05-28T21:03:22+00:00", + "modified": "2019-05-28T21:03:22+00:00", + "title": "Time to Book Summer Sessions", + "URL": "http://infocusphotographers.com/?p=387", + "short_URL": "https://wp.me/p7dJAQ-6f", + "content": "Blue skies and warm weather, what's not to love about summer?\n\nIt's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before May 1.\n\n\"beach-clouds-daytime-994605\"\n\nHow to book\nEmail us to set up a time to visit our studio.", + "excerpt": "", + "slug": "", + "guid": "http://infocusphotographers.com/?p=387", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "a9037bde04073835b078a1b1bb48b7de", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "731", + "key": "_edit_lock", + "value": "1559077396:742098" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/387", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/387/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/387/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/387/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 390 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/05/28/%postname%/", + "suggested_slug": "time-to-book-summer-sessions" + } + }, + { + "ID": 265, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@gmail.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-04-17T10:40:39+00:00", + "modified": "2019-04-17T10:41:03+00:00", + "title": "What we've been doing lately", + "URL": "http://infocusphotographers.com/?p=265", + "short_URL": "https://wp.me/p7dJAQ-4h", + "content": "\r\n

The last few weeks have been a blur! Here are a few shots we really like. What do you think?

\r\n\r\n\r\n\r\n\r\n", + "excerpt": "", + "slug": "", + "guid": "http://infocusphotographers.com/?p=265", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "2f7a8f8da665b6469d91f96108fb24c6", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "567", + "key": "geo_public", + "value": "0" + }, + { + "id": "566", + "key": "_edit_last", + "value": "67626417" + }, + { + "id": "555", + "key": "_edit_lock", + "value": "1555497522:67626417" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/265", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/265/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/265/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/265/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 294, + 293, + 266 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/04/17/%postname%/", + "suggested_slug": "what-weve-been-doing-lately" + } + }, + { + "ID": 134, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@gmail.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-03-19T03:08:16+00:00", + "modified": "2019-05-27T21:39:08+00:00", + "title": "Now Booking Summer Sessions", + "URL": "http://infocusphotographers.com/?p=134", + "short_URL": "https://wp.me/p7dJAQ-2a", + "content": "
“One must maintain a little bit of summer, even in the middle of winter.” \n\n– Henry David Thoreau
\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n

How to book

\nEmail us to set up a time to visit our studio.", + "excerpt": "", + "slug": "now-booking-summer-sessions", + "guid": "http://infocusphotographers.com/?p=134", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "e9bb2d5021873cf97e6ced1f9308bdd5", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "560", + "key": "_edit_lock", + "value": "1559078929:742098" + }, + { + "id": "478", + "key": "_wp_desired_post_slug", + "value": "" + }, + { + "id": "481", + "key": "_wp_desired_post_slug", + "value": "__trashed-5" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/134", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/134/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/134/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/134/likes/", + "autosave": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/134/autosave" + }, + "data": { + "autosave": { + "ID": 397, + "author_ID": "742098", + "post_ID": 134, + "title": "Now Booking Summer Sessions", + "content": "
“One must maintain a little bit of summer, even in the middle of winter.” \n\n– Henry David Thoreau
\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n

How to book

\nEmail us to set up a time to visit our studio.", + "excerpt": "", + "preview_URL": "http://infocusphotographers.com/?p=134&preview=true&preview_nonce=fcfb9daa0a", + "modified": "2019-05-28T21:05:12+00:00" + } + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 397, + 310, + 274, + 236, + 231, + 230, + 220, + 219, + 218, + 136, + 135 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/03/19/%postname%/", + "suggested_slug": "now-booking-summer-sessions" + } + }, + { + "ID": 215, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@gmail.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-02-15T23:27:13+00:00", + "modified": "2019-02-15T23:27:13+00:00", + "title": "Ideas", + "URL": "http://infocusphotographers.com/?p=215", + "short_URL": "https://wp.me/p7dJAQ-3t", + "content": "Returning client special - Offer a discount to clients who have left a review.\n\nPhotography classes at the local", + "excerpt": "", + "slug": "", + "guid": "http://infocusphotographers.com/?p=215", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "50d8150dfae26a5bef18779211142b65", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "561", + "key": "_edit_lock", + "value": "1553718391:742098" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/215", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/215/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/215/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/215/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 216 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/02/15/%postname%/", + "suggested_slug": "ideas" + } + }, + { + "ID": 225, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@gmail.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-01-01T21:54:15+00:00", + "modified": "2019-03-21T17:20:44+00:00", + "title": "Book Your Summer Sessions", + "URL": "http://infocusphotographers.com/?p=225", + "short_URL": "https://wp.me/p7dJAQ-3D", + "content": "\n

“One must maintain a little bit of summer, even in the middle of winter.

– Henry David Thoreau
\n\n\n\n

Blue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!

\n\n\n\n

We offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.

\n\n\n\n
\"beach-children-family-39691\"
\n\n\n\n

How to book

\n\n\n\n

Email us to set up a time to visit our studio.

\n", + "excerpt": "", + "slug": "__trashed-2", + "guid": "http://infocusphotographers.com/?p=225", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "2d07f957bcc110149b8aa37bd1231ad1", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + "243": { + "ID": 243, + "URL": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg", + "date": "2019-03-20T23:56:52+00:00", + "post_ID": 225, + "author_ID": 14151046, + "file": "beach-children-family-39691.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "beach-children-family-39691", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=1024" + }, + "height": 2446, + "width": 3669, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/media/243", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/media/243/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/225" + } + } + } + }, + "attachment_count": 1, + "metadata": [ + { + "id": "558", + "key": "_edit_lock", + "value": "1559077412:742098" + }, + { + "id": "468", + "key": "_wp_desired_post_slug", + "value": "" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/225", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/225/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/225/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/225/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 271, + 235, + 229, + 228, + 227, + 226 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/01/01/%postname%/", + "suggested_slug": "__trashed-2" + } + } + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/post-counts/post" + }, + "wpcom": true + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-first.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-first.json new file mode 100644 index 000000000000..99bef23f4ce7 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-first.json @@ -0,0 +1,60 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/posts/.*", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "fields": { + "equalTo": "ID, title, URL, discussion, like_count, date" + }, + "number": { + "matches": "1" + }, + "order_by": { + "equalTo": "date" + }, + "type": { + "equalTo": "post" + } + } + }, + "response": { + "status": 200, + "fixedDelayMilliseconds": 1000, + "jsonBody": { + "found": 2, + "posts": [ + { + "ID": 106, + "date": "2016-04-19T21:28:27+00:00", + "title": "Some News to Share", + "URL": "http://infocusphotographers.com/2016/04/19/some-news-to-share/", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "like_count": 0 + } + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/post-counts/post" + }, + "next_page": "value=2016-04-19T21%3A28%3A27%2B00%3A00&id=106", + "wpcom": true + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + }, + "scenarioName": "new-post", + "newScenarioState": "Post Published" +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-future.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-future.json new file mode 100644 index 000000000000..36a6269b1b62 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-future.json @@ -0,0 +1,44 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/", + "queryParameters": { + "number": { + "equalTo": "60" + }, + "context": { + "equalTo": "edit" + }, + "order_by": { + "equalTo": "date" + }, + "fields": { + "matches": "ID,modified,status(,meta)?(,date)?" + }, + "order": { + "equalTo": "DESC" + }, + "status": { + "equalTo": "future" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 0, + "posts": [ + + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/post-counts/post" + }, + "wpcom": true + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-new-after.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-new-after.json new file mode 100644 index 000000000000..5dd572a60bdc --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-new-after.json @@ -0,0 +1,56 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/106707880/posts(/)\\?($|\\?.*)", + "queryParameters": { + "context": { + "equalTo": "edit" + }, + "status": { + "equalTo": "publish,private" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 4, + "posts": [ + { + "ID": 5, + "modified": "2019-03-12T15:00:53+00:00", + "status": "publish" + }, + { + "ID": 237, + "modified": "2019-03-21T17:19:25+00:00", + "status": "private" + }, + { + "ID": 106, + "modified": "2018-03-23T00:20:36+00:00", + "status": "publish" + }, + { + "ID": 90, + "modified": "2016-03-16T02:01:50+00:00", + "status": "publish" + } + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/post-counts/post" + }, + "next_page": "value=&id=29", + "wpcom": true + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + }, + "scenarioName": "new-post", + "requiredScenarioState": "Post Published" +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-new.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-new.json new file mode 100644 index 000000000000..e9bbdb4c3058 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-new.json @@ -0,0 +1,146 @@ +{ + "request": { + "method": "POST", + "urlPattern": "/rest/v1.2/sites/.*/posts/new(/)?($|\\?.*)", + "queryParameters": { + "context": { + "equalTo": "edit" + } + } + }, + "response": { + "status": 200, + "fixedDelayMilliseconds": 1000, + "jsonBody": { + "ID": 5, + "site_ID": 106707880, + "author": { + "ID": 152748359, + "login": "e2eflowtestingmobile", + "email": "e2eflowtestingmobile@example.com", + "name": "e2eflowtestingmobile", + "first_name": "", + "last_name": "", + "nice_name": "e2eflowtestingmobile", + "URL": "https://infocusphotographers.com", + "avatar_URL": "https://1.gravatar.com/avatar/7a4015c11be6a342f65e0e169092d402?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/e2eflowtestingmobile", + "site_ID": 1 + }, + "date": "2019-03-12T15:00:53+00:00", + "modified": "2019-03-12T15:00:53+00:00", + "title": "{{jsonPath request.body '$.title'}}", + "URL": "https://lowtestingmobile.wordpress.com/2019/03/12/title-33/", + "short_URL": "https://e/paIC9Y-1r", + "content": "\n

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam congue efficitur leo eget porta.

\n", + "excerpt": "", + "slug": "title-33", + "guid": "https://infocusphotographers.com/2019/03/12/title-33/", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "127876e9017a09034bcf41ee80428027", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 82, + "parent": 0, + "meta": { + "links": { + "self": "https://ic-api.wordpress.com/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "https://ic-api.wordpress.com/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "https://ic-api.wordpress.com/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 82, + "parent": 0, + "meta": { + "links": { + "self": "https://ic-api.wordpress.com/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "https://ic-api.wordpress.com/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "https://ic-api.wordpress.com/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "909", + "key": "jabber_published", + "value": "1552402855" + } + ], + "meta": { + "links": { + "self": "https://ic-api.wordpress.com/rest/v1.1/sites/106707880/posts/5", + "help": "https://ic-api.wordpress.com/rest/v1.2/sites/106707880/posts/5/help", + "site": "https://ic-api.wordpress.com/rest/v1.2/sites/106707880", + "replies": "https://ic-api.wordpress.com/rest/v1.1/sites/106707880/posts/5/replies/", + "likes": "https://ic-api.wordpress.com/rest/v1.1/sites/106707880/posts/5/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "other_URLs": { + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + }, + "scenarioName": "new-post", + "newScenarioState": "Post Published" +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-private,publish.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-private,publish.json new file mode 100644 index 000000000000..dcb60cf5f74e --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-private,publish.json @@ -0,0 +1,58 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/", + "queryParameters": { + "number": { + "equalTo": "60" + }, + "context": { + "equalTo": "edit" + }, + "order_by": { + "equalTo": "date" + }, + "fields": { + "equalTo": "ID,modified,status,meta,date" + }, + "order": { + "equalTo": "DESC" + }, + "status": { + "equalTo": "publish,private" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 3, + "posts": [ + { + "ID": 237, + "modified": "2019-03-21T17:19:25+00:00", + "status": "private" + }, + { + "ID": 106, + "modified": "2018-03-23T00:20:36+00:00", + "status": "publish" + }, + { + "ID": 90, + "modified": "2016-03-16T02:01:50+00:00", + "status": "publish" + } + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/post-counts/post" + }, + "wpcom": true + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-private,publish_v2.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-private,publish_v2.json new file mode 100644 index 000000000000..f4209469721d --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts-private,publish_v2.json @@ -0,0 +1,677 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.2/sites/.*/posts.*", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "context": { + "equalTo": "edit" + }, + "meta": { + "equalTo": "autosave" + }, + "number": { + "matches": "(.*)" + }, + "status": { + "equalTo": "publish,private" + }, + "type": { + "equalTo": "post" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 3, + "posts": [ + { + "ID": 237, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@gmail.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-03-13T23:48:58+00:00", + "modified": "2019-03-21T17:19:25+00:00", + "title": "Photo Contest", + "URL": "http://infocusphotographers.com/2019/03/13/some-news-to-share-2/", + "short_URL": "https://wp.me/p7dJAQ-3P", + "content": "\n

In Focus Photography was nominated for an international photo award!

\n\n\n\n

Please vote for us!

\n", + "excerpt": "", + "slug": "some-news-to-share-2", + "guid": "http://infocusphotographers.com/?p=237", + "status": "private", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "9dc6f7941b6a68fc2323b652c4e9516b", + "featured_image": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "post_thumbnail": { + "ID": 238, + "URL": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "mime_type": "image/jpeg", + "width": 4256, + "height": 2628 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + "238": { + "ID": 238, + "URL": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "date": "2019-03-20T23:44:49+00:00", + "post_ID": 237, + "author_ID": 14151046, + "file": "art-bright-celebration-1313817.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "art-bright-celebration-1313817", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=1024" + }, + "height": 2628, + "width": 4256, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/media/238", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/media/238/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/237" + } + } + } + }, + "attachment_count": 1, + "metadata": [ + { + "id": "493", + "key": "email_notification", + "value": "1553125740" + }, + { + "id": "486", + "key": "jabber_published", + "value": "1553125739" + }, + { + "id": "491", + "key": "timeline_notification", + "value": "1553125810" + }, + { + "id": "557", + "key": "_edit_lock", + "value": "1553188814:68646169" + }, + { + "id": "492", + "key": "_publicize_job_id", + "value": "28865445244" + }, + { + "id": "490", + "key": "_rest_api_client_id", + "value": "-1" + }, + { + "id": "489", + "key": "_rest_api_published", + "value": "1" + }, + { + "id": "484", + "key": "_thumbnail_id", + "value": "238" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/237", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/237/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/237/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/237/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 269, + 263, + 239 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/03/13/%postname%/", + "suggested_slug": "some-news-to-share-2" + } + }, + { + "ID": 106, + "site_ID": 106707880, + "author": { + "ID": 100907762, + "login": "leahelainerand", + "email": "andreazoellner+leahrand@gmail.com", + "name": "Leah Elaine Rand", + "first_name": "Leah", + "last_name": "Rand", + "nice_name": "leahelainerand", + "URL": "", + "avatar_URL": "https://0.gravatar.com/avatar/62937c26a2a79bae5921ca9e85be8040?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/leahelainerand", + "site_ID": 106523982 + }, + "date": "2016-04-19T21:28:27+00:00", + "modified": "2018-03-23T00:20:36+00:00", + "title": "Some News to Share", + "URL": "http://infocusphotographers.com/2016/04/19/some-news-to-share/", + "short_URL": "https://wp.me/p7dJAQ-1I", + "content": "John was shortlisted for the World Press Photo competition, an international celebration of the best photojournalism of the year.\n\nhttps://twitter.com/wordpressdotcom/status/702181837079187456", + "excerpt": "", + "slug": "some-news-to-share", + "guid": "https://infocusphotographers.wordpress.com/?p=106", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "3a10a002b4f389596205c24992732a3e", + "featured_image": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg", + "post_thumbnail": { + "ID": 62, + "URL": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg", + "mime_type": "image/jpeg", + "width": 1080, + "height": 720 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "254", + "key": "jabber_published", + "value": "1461101308" + }, + { + "id": "267", + "key": "_oembed_00ce3f36d70c72c174774d1222895a08", + "value": "

Congrats to the photojournalists on WordPress who won #WPPh16 @WorldPressPhoto prizes: https://t.co/9ya2boX1Yy pic.twitter.com/ZFAHNngh1f

— WordPress.com (@wordpressdotcom) February 23, 2016
" + }, + { + "id": "294", + "key": "_oembed_33aac0b04f7a7bc31eec252936d4ae4a", + "value": "

Congrats to the photojournalists on WordPress who won #WPPh16 @WorldPressPhoto prizes: https://t.co/9ya2boX1Yy pic.twitter.com/ZFAHNngh1f

— WordPress.com (@wordpressdotcom) February 23, 2016
" + }, + { + "id": "257", + "key": "_oembed_4e3318c4371cf9880f6705450efd7fe6", + "value": "

Congrats to the photojournalists on WordPress who won #WPPh16 @WorldPressPhoto prizes: https://t.co/9ya2boX1Yy pic.twitter.com/ZFAHNngh1f

— WordPress.com (@wordpressdotcom) February 23, 2016
" + }, + { + "id": "251", + "key": "_oembed_8655c52bd5f940727d92329c248de249", + "value": "

Congrats to the photojournalists on WordPress who won #WPPh16 @WorldPressPhoto prizes: https://t.co/9ya2boX1Yy pic.twitter.com/ZFAHNngh1f

— WordPress.com (@wordpressdotcom) February 23, 2016
" + }, + { + "id": "262", + "key": "_oembed_eb2de30a8aa65d93ca387e4a12fd9f06", + "value": "

Congrats to the photojournalists on WordPress who won #WPPh16 @WorldPressPhoto prizes: https://t.co/9ya2boX1Yy pic.twitter.com/ZFAHNngh1f

— WordPress.com (@wordpressdotcom) February 23, 2016
" + }, + { + "id": "268", + "key": "_oembed_time_00ce3f36d70c72c174774d1222895a08", + "value": "1461200444" + }, + { + "id": "295", + "key": "_oembed_time_33aac0b04f7a7bc31eec252936d4ae4a", + "value": "1521764439" + }, + { + "id": "258", + "key": "_oembed_time_4e3318c4371cf9880f6705450efd7fe6", + "value": "1461101310" + }, + { + "id": "252", + "key": "_oembed_time_8655c52bd5f940727d92329c248de249", + "value": "1461101270" + }, + { + "id": "263", + "key": "_oembed_time_eb2de30a8aa65d93ca387e4a12fd9f06", + "value": "1461101311" + }, + { + "id": "261", + "key": "_publicize_job_id", + "value": "21955598041" + }, + { + "id": "260", + "key": "_rest_api_client_id", + "value": "-1" + }, + { + "id": "259", + "key": "_rest_api_published", + "value": "1" + }, + { + "id": "266", + "key": "_thumbnail_id", + "value": "62" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/106", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/106/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/106/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/106/likes/", + "autosave": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/106/autosave" + }, + "data": { + "autosave": { + "ID": 262, + "author_ID": "14151046", + "post_ID": 106, + "title": "Photo Contest", + "content": "John was shortlisted for the World Press Photo competition, an international celebration of the best photojournalism of the year.\n\nhttps://twitter.com/wordpressdotcom/status/702181837079187456", + "excerpt": "", + "preview_URL": "http://infocusphotographers.com/2016/04/19/some-news-to-share/?preview=true&preview_nonce=ef0913f6c4", + "modified": "2019-03-21T00:23:25+00:00" + } + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 262, + 109, + 108, + 107 + ], + "other_URLs": { + } + }, + { + "ID": 90, + "site_ID": 106707880, + "author": { + "ID": 100907762, + "login": "leahelainerand", + "email": "andreazoellner+leahrand@gmail.com", + "name": "Leah Elaine Rand", + "first_name": "Leah", + "last_name": "Rand", + "nice_name": "leahelainerand", + "URL": "", + "avatar_URL": "https://0.gravatar.com/avatar/62937c26a2a79bae5921ca9e85be8040?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/leahelainerand", + "site_ID": 106523982 + }, + "date": "2016-03-07T18:32:11+00:00", + "modified": "2016-03-16T02:01:50+00:00", + "title": "Martin and Amy's Wedding", + "URL": "http://infocusphotographers.com/2016/03/07/martin-and-amys-wedding/", + "short_URL": "https://wp.me/p7dJAQ-1s", + "content": "Martin and Amy are a wonderful couple that John and I were lucky to photograph. The theme for their wedding was sophisticated but warm and everything had a clearly personal touch.\n\nThey decided they wanted the bulk of their wedding photos to be black and white. While some couples like personal, goofy photos, Amy and Martin found a perfect balance between classic elegance and shots with personality.\n\n \n\nhttps://www.instagram.com/p/BCF4Z6UjRjE/?taken-by=photomatt\n\n \n\nhttps://www.instagram.com/p/8qwIAWDRum/?taken-by=photomatt\n\n ", + "excerpt": "", + "slug": "martin-and-amys-wedding", + "guid": "https://infocusphotographers.wordpress.com/?p=90", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "8ffbda2c616a90c6b92efe4fb016fe95", + "featured_image": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg", + "post_thumbnail": { + "ID": 82, + "URL": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg", + "guid": "http://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg", + "mime_type": "image/jpeg", + "width": 3853, + "height": 2384 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Wedding": { + "ID": 1674, + "name": "Wedding", + "slug": "wedding", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:wedding/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Wedding": { + "ID": 1674, + "name": "Wedding", + "slug": "wedding", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:wedding/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "201", + "key": "jabber_published", + "value": "1457375532" + }, + { + "id": "562", + "key": "_edit_lock", + "value": "1555492632:67626417" + }, + { + "id": "248", + "key": "_oembed_4e3318c4371cf9880f6705450efd7fe6", + "value": "

Congrats to the photojournalists on WordPress who won #WPPh16 @WorldPressPhoto prizes: https://t.co/9ya2boX1Yy pic.twitter.com/ZFAHNngh1f

— WordPress.com (@wordpressdotcom) February 23, 2016
" + }, + { + "id": "249", + "key": "_oembed_time_4e3318c4371cf9880f6705450efd7fe6", + "value": "1461101264" + }, + { + "id": "233", + "key": "_publicize_done_13969716", + "value": "1" + }, + { + "id": "232", + "key": "_publicize_done_external", + "value": { + "facebook": { + "13895248": "https://facebook.com/10208752052869141" + } + } + }, + { + "id": "206", + "key": "_publicize_job_id", + "value": "20665553278" + }, + { + "id": "205", + "key": "_rest_api_client_id", + "value": "-1" + }, + { + "id": "204", + "key": "_rest_api_published", + "value": "1" + }, + { + "id": "198", + "key": "_thumbnail_id", + "value": "82" + }, + { + "id": "234", + "key": "_wpas_done_13895248", + "value": "1" + }, + { + "id": "231", + "key": "_wpas_mess", + "value": "An amazing weekend, a wonderful couple, and beautiful photos to match! Check it out!" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/90", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/90/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/90/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/90/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 102, + 101, + 100, + 93, + 92, + 91 + ], + "other_URLs": { + } + } + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/post-counts/post" + }, + "wpcom": true + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_106.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_106.json new file mode 100644 index 000000000000..a13aaf8e9b34 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_106.json @@ -0,0 +1,160 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/106/", + "queryParameters": { + "context": { + "equalTo": "edit" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 106, + "site_ID": 106707880, + "author": { + "ID": 100907762, + "login": "leahelainerand", + "email": "andreazoellner+leahrand@gmail.com", + "name": "Leah Elaine Rand", + "first_name": "Leah", + "last_name": "Rand", + "nice_name": "leahelainerand", + "URL": "", + "avatar_URL": "https://0.gravatar.com/avatar/62937c26a2a79bae5921ca9e85be8040?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/leahelainerand", + "site_ID": 106523982 + }, + "date": "2016-04-19T21:28:27+00:00", + "modified": "2018-03-23T00:20:36+00:00", + "title": "Some News to Share", + "URL": "http://infocusphotographers.com/2016/04/19/some-news-to-share/", + "short_URL": "https://wp.me/p7dJAQ-1I", + "content": "John was shortlisted for the World Press Photo competition, an international celebration of the best photojournalism of the year.\n\nhttps://twitter.com/wordpressdotcom/status/702181837079187456", + "excerpt": "", + "slug": "some-news-to-share", + "guid": "https://infocusphotographers.wordpress.com/?p=106", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "3a10a002b4f389596205c24992732a3e", + "featured_image": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg", + "post_thumbnail": { + "ID": 62, + "URL": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2016/02/photo-1449761485030-c9bf16154670.jpg", + "mime_type": "image/jpeg", + "width": 1080, + "height": 720 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "254", + "key": "jabber_published", + "value": "1461101308" + }, + { + "id": "266", + "key": "_thumbnail_id", + "value": "62" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/106", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/106/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/106/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/106/likes/", + "autosave": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/106/autosave" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 262, + 109, + 108, + 107 + ], + "other_URLs": { + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_134.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_134.json new file mode 100644 index 000000000000..7037dd231942 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_134.json @@ -0,0 +1,151 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/134/", + "queryParameters": { + "context": { + "equalTo": "edit" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 134, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@wordpress.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-03-19T03:08:16+00:00", + "modified": "2019-05-27T21:39:08+00:00", + "title": "Now Booking Summer Sessions", + "URL": "http://infocusphotographers.com/?p=134", + "short_URL": "https://wp.me/p7dJAQ-2a", + "content": "
“One must maintain a little bit of summer, even in the middle of winter.” \n\n– Henry David Thoreau
\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n

How to book

\nEmail us to set up a time to visit our studio.", + "excerpt": "", + "slug": "now-booking-summer-sessions", + "guid": "http://infocusphotographers.com/?p=134", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "e9bb2d5021873cf97e6ced1f9308bdd5", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": false, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/134", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/134/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/134/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/134/likes/", + "autosave": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/134/autosave" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 397, + 310, + 274, + 236, + 231, + 230, + 220, + 219, + 218, + 136, + 135 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/03/19/%postname%/", + "suggested_slug": "now-booking-summer-sessions" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_213.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_213.json new file mode 100644 index 000000000000..23911bcd2955 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_213.json @@ -0,0 +1,196 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/213/", + "queryParameters": { + "context": { + "equalTo": "edit" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 213, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@wordpress.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-07-16T02:39:44+00:00", + "modified": "2019-07-16T02:39:45+00:00", + "title": "Our Services", + "URL": "http://infocusphotographers.com/?p=213", + "short_URL": "https://wp.me/p7dJAQ-3r", + "content": "\n
\n

Mobile grooming salon for cats and dogs.

\n\n\n\n\n
\n\n\n\n

Dog grooming

\n\n\n\n
\n
\n

Our deluxe grooming service includes:

\n\n\n\n
  • Nail clip
  • Ear cleaning
  • 1st shampoo
  • 2nd shampoo
  • Conditioning rinse
  • Towel dry
  • Blow dry
  • Brush out
  • And a treat!
\n
\n\n\n\n
\n
\"\"
\n
\n
\n\n\n\n

Deluxe Cut and Groom

\n\n\n\n

Our deluxe cut and groom package includes everything in the deluxe groom plus a haircut.

\n\n\n\n

Add on services

\n\n\n\n

Pamper your pup even more with one of our signature add on services:

\n\n\n\n
  • Aloe conditioning treatment
  • Soothing medicated shampoos
  • Tooth brushing
  • Creative styling with temporary color
  • Nail polish application
  • Coat braiding
  • Bows and other accessories
\n\n\n\n
\n

\n
\n\n\n\n

\n", + "excerpt": "", + "slug": "summer-band-jam", + "guid": "http://infocusphotographers.com/?p=213", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "e41d1c97a7b93a594b07e4df51dfafb3", + "featured_image": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg", + "post_thumbnail": { + "ID": 151, + "URL": "https://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/02/adult-band-concert-995301.jpg", + "mime_type": "image/jpeg", + "width": 5184, + "height": 3456 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "502", + "key": "email_notification", + "value": "1553125754" + }, + { + "id": "564", + "key": "geo_public", + "value": "0" + }, + { + "id": "496", + "key": "jabber_published", + "value": "1553125752" + }, + { + "id": "503", + "key": "timeline_notification", + "value": "1553125801" + }, + { + "id": "565", + "key": "_thumbnail_id", + "value": "151" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/213", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/213/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/213/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/213/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 401, + 400, + 309, + 303, + 300, + 299, + 298, + 297, + 296, + 292, + 291, + 289, + 288, + 287, + 286, + 285, + 284, + 283, + 280, + 277, + 273, + 267, + 240, + 214 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/07/16/%postname%/", + "suggested_slug": "summer-band-jam" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_215.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_215.json new file mode 100644 index 000000000000..0f9e249b8b3e --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_215.json @@ -0,0 +1,140 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/215/", + "queryParameters": { + "context": { + "equalTo": "edit" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 215, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@wordpress.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-02-15T23:27:13+00:00", + "modified": "2019-02-15T23:27:13+00:00", + "title": "Ideas", + "URL": "http://infocusphotographers.com/?p=215", + "short_URL": "https://wp.me/p7dJAQ-3t", + "content": "Returning client special - Offer a discount to clients who have left a review.\n\nPhotography classes at the local", + "excerpt": "", + "slug": "", + "guid": "http://infocusphotographers.com/?p=215", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "50d8150dfae26a5bef18779211142b65", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": false, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/215", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/215/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/215/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/215/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 216 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/02/15/%postname%/", + "suggested_slug": "ideas" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_225.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_225.json new file mode 100644 index 000000000000..96a16c9a971d --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_225.json @@ -0,0 +1,191 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/225/", + "queryParameters": { + "context": { + "equalTo": "edit" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 225, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@wordpress.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-01-01T21:54:15+00:00", + "modified": "2019-03-21T17:20:44+00:00", + "title": "Book Your Summer Sessions", + "URL": "http://infocusphotographers.com/?p=225", + "short_URL": "https://wp.me/p7dJAQ-3D", + "content": "\n

“One must maintain a little bit of summer, even in the middle of winter.”

– Henry David Thoreau
\n\n\n\n

Blue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!

\n\n\n\n

We offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.

\n\n\n\n
\"beach-children-family-39691\"
\n\n\n\n

How to book

\n\n\n\n

Email us to set up a time to visit our studio.

\n", + "excerpt": "", + "slug": "__trashed-2", + "guid": "http://infocusphotographers.com/?p=225", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "2d07f957bcc110149b8aa37bd1231ad1", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "attachments": { + "243": { + "ID": 243, + "URL": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg", + "date": "2019-03-20T23:56:52+00:00", + "post_ID": 225, + "author_ID": 14151046, + "file": "beach-children-family-39691.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "beach-children-family-39691", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/01/beach-children-family-39691.jpg?w=1024" + }, + "height": 2446, + "width": 3669, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/243", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/243/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/225" + } + } + } + }, + "attachment_count": 1, + "metadata": false, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/225", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/225/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/225/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/225/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 271, + 235, + 229, + 228, + 227, + 226 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/01/01/%postname%/", + "suggested_slug": "__trashed-2" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_237.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_237.json new file mode 100644 index 000000000000..886c2c03c1e9 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_237.json @@ -0,0 +1,216 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/237/", + "queryParameters": { + "context": { + "equalTo": "edit" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 237, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@wordpress.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-03-13T23:48:58+00:00", + "modified": "2019-03-21T17:19:25+00:00", + "title": "Photo Contest", + "URL": "http://infocusphotographers.com/2019/03/13/some-news-to-share-2/", + "short_URL": "https://wp.me/p7dJAQ-3P", + "content": "\n

In Focus Photography was nominated for an international photo award!

\n\n\n\n

Please vote for us!

\n", + "excerpt": "", + "slug": "some-news-to-share-2", + "guid": "http://infocusphotographers.com/?p=237", + "status": "private", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "9dc6f7941b6a68fc2323b652c4e9516b", + "featured_image": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "post_thumbnail": { + "ID": 238, + "URL": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "mime_type": "image/jpeg", + "width": 4256, + "height": 2628 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "attachments": { + "238": { + "ID": 238, + "URL": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "guid": "http://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg", + "date": "2019-03-20T23:44:49+00:00", + "post_ID": 237, + "author_ID": 14151046, + "file": "art-bright-celebration-1313817.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "art-bright-celebration-1313817", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=150", + "medium": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=300", + "large": "https://infocusphotographers.files.wordpress.com/2019/03/art-bright-celebration-1313817.jpg?w=1024" + }, + "height": 2628, + "width": 4256, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/238", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/media/238/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/237" + } + } + } + }, + "attachment_count": 1, + "metadata": [ + { + "id": "493", + "key": "email_notification", + "value": "1553125740" + }, + { + "id": "486", + "key": "jabber_published", + "value": "1553125739" + }, + { + "id": "491", + "key": "timeline_notification", + "value": "1553125810" + }, + { + "id": "484", + "key": "_thumbnail_id", + "value": "238" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/237", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/237/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/237/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/237/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 269, + 263, + 239 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/03/13/%postname%/", + "suggested_slug": "some-news-to-share-2" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_265.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_265.json new file mode 100644 index 000000000000..d9353f7b5fed --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_265.json @@ -0,0 +1,148 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/265/", + "queryParameters": { + "context": { + "equalTo": "edit" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 265, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@wordpress.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-04-17T10:40:39+00:00", + "modified": "2019-04-17T10:41:03+00:00", + "title": "What we've been doing lately", + "URL": "http://infocusphotographers.com/?p=265", + "short_URL": "https://wp.me/p7dJAQ-4h", + "content": "\r\n

The last few weeks have been a blur! Here are a few shots we really like. What do you think?

\r\n\r\n\r\n\r\n\r\n", + "excerpt": "", + "slug": "", + "guid": "http://infocusphotographers.com/?p=265", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "2f7a8f8da665b6469d91f96108fb24c6", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "567", + "key": "geo_public", + "value": "0" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/265", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/265/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/265/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/265/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 294, + 293, + 266 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/04/17/%postname%/", + "suggested_slug": "what-weve-been-doing-lately" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_387.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_387.json new file mode 100644 index 000000000000..d0d103a3d30b --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_387.json @@ -0,0 +1,140 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/387/", + "queryParameters": { + "context": { + "equalTo": "edit" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 387, + "site_ID": 106707880, + "author": { + "ID": 68646169, + "login": "thenomadicwordsmith", + "email": "thenomadicwordsmith@wordpress.com", + "name": "thenomadicwordsmith", + "first_name": "Nomadic", + "last_name": "Wordsmith", + "nice_name": "thenomadicwordsmith", + "URL": "http://thenomadicwordsmith.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/9ba48385fc40dfd9a55a3348d8e7f4d9?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/thenomadicwordsmith", + "site_ID": 71769073 + }, + "date": "2019-05-28T21:03:22+00:00", + "modified": "2019-05-28T21:03:22+00:00", + "title": "Time to Book Summer Sessions", + "URL": "http://infocusphotographers.com/?p=387", + "short_URL": "https://wp.me/p7dJAQ-6f", + "content": "Blue skies and warm weather, what's not to love about summer?\n\nIt's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before May 1.\n\n\"beach-clouds-daytime-994605\"\n\nHow to book\nEmail us to set up a time to visit our studio.", + "excerpt": "", + "slug": "", + "guid": "http://infocusphotographers.com/?p=387", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "a9037bde04073835b078a1b1bb48b7de", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": false, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/387", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/387/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/387/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/387/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 390 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/05/28/%postname%/", + "suggested_slug": "time-to-book-summer-sessions" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_396.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_396.json new file mode 100644 index 000000000000..c864647c6a79 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_396.json @@ -0,0 +1,154 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/396/", + "queryParameters": { + "context": { + "equalTo": "edit" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 396, + "site_ID": 106707880, + "author": { + "ID": 742098, + "login": "jkmassel", + "email": "jeremy.massel@gmail.com", + "name": "Jeremy Massel", + "first_name": "Jeremy", + "last_name": "Massel", + "nice_name": "jkmassel", + "URL": "https://jkmassel.wordpress.com", + "avatar_URL": "https://2.gravatar.com/avatar/58fc51586c9a1f9895ac70e3ca60886e?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/jkmassel", + "site_ID": 1562023 + }, + "date": "2019-05-28T21:06:45+00:00", + "modified": "2019-05-28T21:08:03+00:00", + "title": "Now Booking Summer Sessions", + "URL": "http://infocusphotographers.com/?p=396", + "short_URL": "https://wp.me/p7dJAQ-6o", + "content": "
“One must maintain a little bit of summer, even in the middle of winter.”\n\n– Henry David Thoreau
\nBlue skies and warm weather, what's not to love about summer? It's a great season for outdoor family portrait sessions and now is the time to book them!\n\nWe offer a number of family portrait packages and for a limited time are offering 15% off packages booked before April 1.\n\nHow to book\n\nEmail us to set up a time to visit our studio.", + "excerpt": "", + "slug": "", + "guid": "http://infocusphotographers.com/?p=396", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": false, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "276e4cea0342ec68e69a0ca537acfce1", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "742", + "key": "sharing_disabled", + "value": [ + + ] + }, + { + "id": "741", + "key": "switch_like_status", + "value": "" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/396", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/396/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/396/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/396/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 399, + 398 + ], + "other_URLs": { + "permalink_URL": "http://infocusphotographers.com/2019/05/28/%postname%/", + "suggested_slug": "now-booking-summer-sessions-2" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_90.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_90.json new file mode 100644 index 000000000000..c9e49d952aee --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/posts_90.json @@ -0,0 +1,171 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/90/", + "queryParameters": { + "context": { + "equalTo": "edit" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 90, + "site_ID": 106707880, + "author": { + "ID": 100907762, + "login": "leahelainerand", + "email": "andreazoellner+leahrand@gmail.com", + "name": "Leah Elaine Rand", + "first_name": "Leah", + "last_name": "Rand", + "nice_name": "leahelainerand", + "URL": "", + "avatar_URL": "https://0.gravatar.com/avatar/62937c26a2a79bae5921ca9e85be8040?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/leahelainerand", + "site_ID": 106523982 + }, + "date": "2016-03-07T18:32:11+00:00", + "modified": "2016-03-16T02:01:50+00:00", + "title": "Martin and Amy's Wedding", + "URL": "http://infocusphotographers.com/2016/03/07/martin-and-amys-wedding/", + "short_URL": "https://wp.me/p7dJAQ-1s", + "content": "Martin and Amy are a wonderful couple that John and I were lucky to photograph. The theme for their wedding was sophisticated but warm and everything had a clearly personal touch.\n\nThey decided they wanted the bulk of their wedding photos to be black and white. While some couples like personal, goofy photos, Amy and Martin found a perfect balance between classic elegance and shots with personality.\n\n \n\nhttps://www.instagram.com/p/BCF4Z6UjRjE/?taken-by=photomatt\n\n \n\nhttps://www.instagram.com/p/8qwIAWDRum/?taken-by=photomatt\n\n ", + "excerpt": "", + "slug": "martin-and-amys-wedding", + "guid": "https://infocusphotographers.wordpress.com/?p=90", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "8ffbda2c616a90c6b92efe4fb016fe95", + "featured_image": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg", + "post_thumbnail": { + "ID": 82, + "URL": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg", + "guid": "http://infocusphotographers.files.wordpress.com/2016/02/photo-1453857271477-4f9a4081966e1.jpeg", + "mime_type": "image/jpeg", + "width": 3853, + "height": 2384 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Wedding": { + "ID": 1674, + "name": "Wedding", + "slug": "wedding", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Wedding": { + "ID": 1674, + "name": "Wedding", + "slug": "wedding", + "description": "", + "post_count": 1, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "201", + "key": "jabber_published", + "value": "1457375532" + }, + { + "id": "198", + "key": "_thumbnail_id", + "value": "82" + }, + { + "id": "234", + "key": "_wpas_done_13895248", + "value": "1" + }, + { + "id": "231", + "key": "_wpas_mess", + "value": "An amazing weekend, a wonderful couple, and beautiful photos to match! Check it out!" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/90", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/90/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/90/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/90/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 102, + 101, + 100, + 93, + 92, + 91 + ], + "other_URLs": { + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v11_sites_106707880_posts_439_replies.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v11_sites_106707880_posts_439_replies.json new file mode 100644 index 000000000000..e9f061b9e93e --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v11_sites_106707880_posts_439_replies.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/439/replies" + }, + "response": { + "status": 200, + "jsonBody": { + "found": 0, + "site_ID": 106707880 + } + } +} diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v11_sites_106707880_posts_439_replies_new.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v11_sites_106707880_posts_439_replies_new.json new file mode 100644 index 000000000000..bc044558ba95 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v11_sites_106707880_posts_439_replies_new.json @@ -0,0 +1,54 @@ +{ + "request": { + "method": "POST", + "urlPath": "/rest/v1.1/sites/106707880/posts/439/replies/new" + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 1, + "post": { + "ID": 439, + "title": "Social", + "type": "post", + "link": "https://public-api.wordpress.com/rest/v1.1/sites/106707880/posts/439" + }, + "author": { + "ID": 152748359, + "login": "e2eflowtestingmobile", + "email": false, + "name": "e2eflowtestingmobile", + "first_name": "", + "last_name": "", + "nice_name": "e2eflowtestingmobile", + "URL": "http://e2eflowtestingmobile.wordpress.com", + "avatar_URL": "https://1.gravatar.com/avatar/7a4015c11be6a342f65e0e169092d402?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/e2eflowtestingmobile", + "site_ID": 106707880, + "has_avatar": false + }, + "date": "2019-05-23T12:46:55+00:00", + "URL": "https://e2eflowtestingmobile.wordpress.com/2019/05/23/sit-elit-adipiscing-elit-dolor-lorem/", + "short_URL": "https://wp.me/paIC9Y-75", + "content": "\n

{{jsonPath request.body '$.content'}}

\n", + "raw_content": "{{jsonPath request.body '$.content'}}", + "status": "approved", + "parent": false, + "type": "comment", + "like_count": 0, + "i_like": false, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/comments/1", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/comments/1/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "post": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/439", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/comments/1/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/comments/1/likes/" + } + }, + "can_moderate": true, + "i_replied": false + } + } +} diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v11_sites_posts_subscribers_mine.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v11_sites_posts_subscribers_mine.json new file mode 100644 index 000000000000..a5d3d70cf98b --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v11_sites_posts_subscribers_mine.json @@ -0,0 +1,20 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/posts/439/subscribers/mine" + }, + "response": { + "status": 200, + "jsonBody": { + "i_subscribe": false, + "receives_notifications": false, + "meta": { + "links": { + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/439", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/439/subscribers/help" + } + } + } + } +} diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v12_sites_181851495_posts-draft,pending.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v12_sites_181851495_posts-draft,pending.json new file mode 100644 index 000000000000..6d342ae93832 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v12_sites_181851495_posts-draft,pending.json @@ -0,0 +1,163 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.2/sites/181851495/posts", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "context": { + "equalTo": "edit" + }, + "meta": { + "equalTo": "autosave" + }, + "number": { + "equalTo": "40" + }, + "status": { + "equalTo": "draft,pending" + }, + "type": { + "equalTo": "post" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 1, + "posts": [ + { + "ID": 67, + "site_ID": 181851495, + "author": { + "ID": 152748359, + "login": "appstorescreens", + "email": "main.ee0zglcj@mailosaur.io", + "name": "appstorescreens", + "first_name": "", + "last_name": "", + "nice_name": "appstorescreens", + "URL": "http://tricountyrealestate.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/35029b2103460109f574c38dfeea5f3f?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/appstorescreens", + "site_ID": 181851541 + }, + "date": "2020-10-05T05:10:17-07:00", + "modified": "2020-10-05T05:12:29-07:00", + "title": "Our Services", + "URL": "https://fourpawsdoggrooming.wordpress.com/our-services/", + "short_URL": "https://wp.me/pcj1SD-15", + "content": "\n
\n

Mobile grooming salon for cats and dogs.

\n\n\n\n\n
\n\n\n\n

Dog grooming

\n\n\n\n
\n
\n

Our deluxe grooming service includes:

\n\n\n\n
  • Nail clip
  • Ear cleaning
  • 1st shampoo
  • 2nd shampoo
  • Conditioning rinse
  • Towel dry
  • Blow dry
  • Brush out
  • And a treat!
\n
\n\n\n\n
\n
\"\"
\n
\n
\n\n\n\n

Deluxe Cut and Groom

\n\n\n\n

Our deluxe cut and groom package includes everything in the deluxe groom plus a haircut.

\n\n\n\n

Add on services

\n\n\n\n

Pamper your pup even more with one of our signature add on services:

\n\n\n\n
  • Aloe conditioning treatment
  • Soothing medicated shampoos
  • Tooth brushing
  • Creative styling with temporary color
  • Nail polish application
  • Coat braiding
  • Bows and other accessories
\n\n\n\n
\n

\n
\n\n\n\n

\n", + "excerpt": "", + "slug": "our-services", + "guid": "https://fourpawsdoggrooming.wordpress.com/?p=67", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "8baa5f9a6478573f6869e88704679280", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 3, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 3, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": false, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/posts/67", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/posts/67/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/posts/67/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/posts/67/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 68 + ], + "other_URLs": { + "permalink_URL": "https://fourpawsdoggrooming.wordpress.com/2020/10/05/%postname%/", + "suggested_slug": "our-services" + } + } + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181851495/post-counts/post" + }, + "wpcom": true + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v12_sites_181977606_posts-draft,pending.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v12_sites_181977606_posts-draft,pending.json new file mode 100644 index 000000000000..0c24f982cbfc --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v12_sites_181977606_posts-draft,pending.json @@ -0,0 +1,315 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.2/sites/181977606/posts", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "context": { + "equalTo": "edit" + }, + "meta": { + "equalTo": "autosave" + }, + "number": { + "matches": "3|40" + }, + "status": { + "equalTo": "draft,pending" + }, + "type": { + "equalTo": "post" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 2, + "posts": [ + { + "ID": 14, + "site_ID": 181977606, + "author": { + "ID": 152748359, + "login": "appstorescreens", + "email": "test@example.com", + "name": "appstorescreens", + "first_name": "", + "last_name": "", + "nice_name": "appstorescreens", + "URL": "http://tricountyrealestate.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/35029b2103460109f574c38dfeea5f3f?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/appstorescreens", + "site_ID": 181977606 + }, + "date": "2020-10-05T15:03:30+00:00", + "modified": "2020-10-05T15:04:51+00:00", + "title": "Easy Blueberry Muffins", + "URL": "https://weekendbakesblog.wordpress.com/easy-blueberry-muffins/", + "short_URL": "https://wp.me/pcjyGG-e", + "content": "\n\n\n\n\n

Ingredients

\n\n\n\n
  • 1 cup fresh or frozen blueberries
  • 1 3/4 cup flour
  • 2 tsp baking powder
  • 3/4 cups sugar
  • 1/4 cup canola oil
  • 1 egg
\n", + "excerpt": "", + "slug": "easy-blueberry-muffins", + "guid": "https://weekendbakesblog.wordpress.com/?p=14", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "33b4a6f0603d5c065b2e54737b34899c", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 3, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 3, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "142", + "key": "jabber_published", + "value": "1601910212" + }, + { + "id": "148", + "key": "timeline_notification", + "value": "1601910213" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/posts/14", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606/posts/14/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/posts/14/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/posts/14/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 15 + ], + "other_URLs": { + "permalink_URL": "https://weekendbakesblog.wordpress.com/2020/10/05/%postname%/", + "suggested_slug": "easy-blueberry-muffins" + } + }, + { + "ID": 24, + "site_ID": 181977606, + "author": { + "ID": 152748359, + "login": "appstorescreens", + "email": "test@example.com", + "name": "appstorescreens", + "first_name": "", + "last_name": "", + "nice_name": "appstorescreens", + "URL": "http://tricountyrealestate.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/35029b2103460109f574c38dfeea5f3f?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/appstorescreens", + "site_ID": 181977606 + }, + "date": "2021-10-05T15:03:30+00:00", + "modified": "2021-10-05T15:04:51+00:00", + "title": "Easy Strawberry Cake", + "URL": "https://weekendbakesblog.wordpress.com/easy-strawberry-cake/", + "short_URL": "https://wp.me/pcjyGG-e", + "content": "\n

Ingredients List

\n\n\n\n
  • 1 cup fresh or frozen blueberries
  • 1 3/4 cup flour
  • 2 tsp baking powder
  • 3/4 cups sugar
  • 1/4 cup canola oil
  • 1 egg
\n", + "excerpt": "", + "slug": "easy-blueberry-muffins", + "guid": "https://weekendbakesblog.wordpress.com/?p=14", + "status": "draft", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "33b4a6f0603d5c065b2e54737b34899c", + "featured_image": "", + "post_thumbnail": { + "ID": 238, + "URL": "https://mobiledotblog.files.wordpress.com/2022/08/4551182892_9b33f3bdfb_b.jpg", + "guid": "https://mobiledotblog.files.wordpress.com/2022/08/4551182892_9b33f3bdfb_b.jpg", + "mime_type": "image/jpeg", + "width": 4256, + "height": 2628 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 3, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 3, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "142", + "key": "jabber_published", + "value": "1601910212" + }, + { + "id": "148", + "key": "timeline_notification", + "value": "1601910213" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/posts/14", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606/posts/14/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/posts/14/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/posts/14/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 15 + ], + "other_URLs": { + "permalink_URL": "https://weekendbakesblog.wordpress.com/2020/10/05/%postname%/", + "suggested_slug": "easy-blueberry-muffins" + } + } + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606/post-counts/post" + }, + "wpcom": true + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v12_sites_181977606_posts-scheduled.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v12_sites_181977606_posts-scheduled.json new file mode 100644 index 000000000000..814834a89655 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/posts/rest_v12_sites_181977606_posts-scheduled.json @@ -0,0 +1,181 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.2/sites/181977606/posts", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "context": { + "equalTo": "edit" + }, + "meta": { + "equalTo": "autosave" + }, + "number": { + "matches": "3|40" + }, + "status": { + "equalTo": "future" + }, + "type": { + "equalTo": "post" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 1, + "posts": [ + { + "ID": 15, + "site_ID": 181977606, + "author": { + "ID": 152748359, + "login": "appstorescreens", + "email": "test@example.com", + "name": "appstorescreens", + "first_name": "", + "last_name": "", + "nice_name": "appstorescreens", + "URL": "http://tricountyrealestate.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/35029b2103460109f574c38dfeea5f3f?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/appstorescreens", + "site_ID": 181977606 + }, + "date": "{{now offset='+2 days'}}", + "modified": "2020-10-05T15:04:51+00:00", + "title": "Lemon Cheesecake Tartlets", + "URL": "https://weekendbakesblog.wordpress.com/easy-blueberry-muffins/", + "short_URL": "https://wp.me/pcjyGG-e", + "content": "\n

Ingredients

\n\n\n\n
  • 1 cup fresh or frozen blueberries
  • 1 3/4 cup flour
  • 2 tsp baking powder
  • 3/4 cups sugar
  • 1/4 cup canola oil
  • 1 egg
\n", + "excerpt": "", + "slug": "easy-blueberry-muffins", + "guid": "https://weekendbakesblog.wordpress.com/?p=14", + "status": "future", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "33b4a6f0603d5c065b2e54737b34899d", + "featured_image": "", + "post_thumbnail": { + "ID": 238, + "URL": "https://mobiledotblog.files.wordpress.com/2022/08/4957581385_d7b5c6a8ac_b.jpg", + "guid": "https://mobiledotblog.files.wordpress.com/2022/08/4957581385_d7b5c6a8ac_b.jpg", + "mime_type": "image/jpeg", + "width": 4256, + "height": 2628 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 3, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + }, + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 3, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "142", + "key": "jabber_published", + "value": "1601910212" + }, + { + "id": "148", + "key": "timeline_notification", + "value": "1601910213" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/posts/14", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606/posts/14/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/posts/14/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/posts/14/likes/" + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "revisions": [ + 15 + ], + "other_URLs": { + "permalink_URL": "https://weekendbakesblog.wordpress.com/2020/10/05/%postname%/", + "suggested_slug": "easy-blueberry-muffins" + } + } + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/181977606/post-counts/post" + }, + "wpcom": true + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v11_read_following_mine.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v11_read_following_mine.json new file mode 100644 index 000000000000..011eb882eec0 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v11_read_following_mine.json @@ -0,0 +1,22 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.(1|2)/read/following/mine.*" + }, + "response": { + "status": 200, + "jsonBody": { + "subscriptions": [ + + ], + "page": 1, + "number": 0, + "total_subscriptions": 0 + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_following.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_following.json new file mode 100644 index 000000000000..dda844c09c96 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_following.json @@ -0,0 +1,1481 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.2/read/following" + }, + "response": { + "status": 200, + "jsonBody": { + "date_range": { + "before": "2019-05-23T13:00:09+00:00", + "after": "2021-05-17T16:34:44+00:00" + }, + "number": 3, + "posts": [ + { + "ID": 125073, + "site_ID": 70135762, + "author": { + "ID": 7867135, + "login": "aarongilbreath", + "email": false, + "name": "Aaron Gilbreath", + "first_name": "Aaron", + "last_name": "Gilbreath", + "nice_name": "aarongilbreath", + "URL": "", + "avatar_URL": "https://2.gravatar.com/avatar/e1203cef7aae3855fc3935404fc9f739?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/aarongilbreath", + "site_ID": 7575556, + "has_avatar": true + }, + "date": "2019-05-23T13:00:09+00:00", + "modified": "2019-05-22T16:44:11-04:00", + "title": "Optimizing Meat 2.0", + "URL": "http://longreads.com/2019/05/23/optimizing-meat-2-0/", + "short_URL": "https://wp.me/p4KhvY-wxj", + "content": "

The most impossible sounding thing about the Impossible Burger isn’t the idea of a delicious meatless protein — some of us have been eating delicious meat substitutes for years — it’s hearing people talk about this food product in terms of scalability, optics, engineering, and “manufacturable prototypes.” The future has arrived, and it tastes better than it sounds. Chris Ip writes for Engadget about the brief history, challenges, and ambitions of Impossible Foods’ meat-free technology, and how its success relates to a planet that’s warming partly because of industrial beef production.

\n

“Ethical consumerism is a failure and doesn’t really accomplish what we want it to accomplish,” said Michael Selden, CEO and founder of Finless Foods, a cell-based seafood startup. “What you need to do is create things that are ethical and moral as a baseline but make them compete on metrics of taste, price and convenience, which is what people actually buy food on, and Impossible has really embodied that.”

\n

There’s a comparison to sustainable energy here: We all need it and we’re barely willing to curtail our electricity demands, but if there’s a price-competitive, clean alternative, then sure. With food, it’s an acknowledgement that — solely for the guaranteed sensory enjoyment that those who are food secure might enjoy each day — taste is the key driver to change our habits.

\n

This leaves Impossible in a nice position. The global economic demand for meat combined with the swelling cultural-political urgency to curtail it could be great for business if you have a legitimate alternative. And the high-risk-high-reward venture capital system demands startups that can pitch themselves as limitlessly scalable. A worldwide problem of this degree means that Impossible can plausibly — and not disingenuously — bridge both a business goal of sky’s-the-limit growth and a messianic narrative. Brown’s social mission aligns with his profit-seeking obligation in ways that can make as much sense to private equity as to Katy Perry.

\n

Read the story

\n", + "excerpt": "

Can Impossible Foods’ meat facsimiles save us from our carnivorous appetites?

\n", + "slug": "optimizing-meat-2-0", + "guid": "http://longreads.com/?p=125073", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": false, + "comment_status": "closed", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 34, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "06ab8cb0a8b05a409928d97bcbf022e0", + "featured_image": "https://longreadsblog.files.wordpress.com/2019/05/ap_19093580335314.jpg", + "post_thumbnail": { + "ID": 125103, + "URL": "https://longreadsblog.files.wordpress.com/2019/05/ap_19093580335314.jpg", + "guid": "http://longreadsblog.files.wordpress.com/2019/05/ap_19093580335314.jpg", + "mime_type": "image/jpeg", + "width": 6000, + "height": 4000 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "culture": { + "ID": 1098, + "name": "culture", + "slug": "culture", + "description": "", + "post_count": 88, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/categories/slug:culture", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/categories/slug:culture/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "Editor's Pick": { + "ID": 259543, + "name": "Editor's Pick", + "slug": "editors-pick", + "description": "", + "post_count": 1395, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/categories/slug:editors-pick", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/categories/slug:editors-pick/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "Food": { + "ID": 586, + "name": "Food", + "slug": "food", + "description": "", + "post_count": 95, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/categories/slug:food", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/categories/slug:food/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "Nonfiction": { + "ID": 35009777, + "name": "Nonfiction", + "slug": "nonfiction", + "description": "", + "post_count": 3803, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/categories/slug:nonfiction", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/categories/slug:nonfiction/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "Quotes": { + "ID": 28016040, + "name": "Quotes", + "slug": "quotes", + "description": "", + "post_count": 2544, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/categories/slug:quotes", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/categories/slug:quotes/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + } + }, + "post_tag": { + "Chris Ip": { + "ID": 89063115, + "name": "Chris Ip", + "slug": "chris-ip", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:chris-ip", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:chris-ip/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "Engadget": { + "ID": 70021, + "name": "Engadget", + "slug": "engadget", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:engadget", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:engadget/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "fake meat": { + "ID": 506195, + "name": "fake meat", + "slug": "fake-meat", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:fake-meat", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:fake-meat/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "food technology": { + "ID": 1272213, + "name": "food technology", + "slug": "food-technology", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:food-technology", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:food-technology/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "food writing": { + "ID": 93032, + "name": "food writing", + "slug": "food-writing", + "description": "", + "post_count": 30, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:food-writing", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:food-writing/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "meat industry": { + "ID": 231639, + "name": "meat industry", + "slug": "meat-industry", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:meat-industry", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:meat-industry/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + } + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + "Chris Ip": { + "ID": 89063115, + "name": "Chris Ip", + "slug": "chris-ip", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:chris-ip", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:chris-ip/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + }, + "display_name": "chris-ip" + }, + "Engadget": { + "ID": 70021, + "name": "Engadget", + "slug": "engadget", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:engadget", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:engadget/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + }, + "display_name": "engadget" + }, + "fake meat": { + "ID": 506195, + "name": "fake meat", + "slug": "fake-meat", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:fake-meat", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:fake-meat/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + }, + "display_name": "fake-meat" + }, + "food technology": { + "ID": 1272213, + "name": "food technology", + "slug": "food-technology", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:food-technology", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:food-technology/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + }, + "display_name": "food-technology" + }, + "food writing": { + "ID": 93032, + "name": "food writing", + "slug": "food-writing", + "description": "", + "post_count": 30, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:food-writing", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:food-writing/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + }, + "display_name": "food-writing" + }, + "meat industry": { + "ID": 231639, + "name": "meat industry", + "slug": "meat-industry", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/tags/slug:meat-industry", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/tags/slug:meat-industry/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + }, + "display_name": "meat-industry" + } + }, + "categories": { + "culture": { + "ID": 1098, + "name": "culture", + "slug": "culture", + "description": "", + "post_count": 88, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/categories/slug:culture", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/categories/slug:culture/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "Editor's Pick": { + "ID": 259543, + "name": "Editor's Pick", + "slug": "editors-pick", + "description": "", + "post_count": 1395, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/categories/slug:editors-pick", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/categories/slug:editors-pick/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "Food": { + "ID": 586, + "name": "Food", + "slug": "food", + "description": "", + "post_count": 95, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/categories/slug:food", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/categories/slug:food/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "Nonfiction": { + "ID": 35009777, + "name": "Nonfiction", + "slug": "nonfiction", + "description": "", + "post_count": 3803, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/categories/slug:nonfiction", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/categories/slug:nonfiction/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + }, + "Quotes": { + "ID": 28016040, + "name": "Quotes", + "slug": "quotes", + "description": "", + "post_count": 2544, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/categories/slug:quotes", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/categories/slug:quotes/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762" + } + } + } + }, + "attachments": { + "125103": { + "ID": 125103, + "URL": "https://longreadsblog.files.wordpress.com/2019/05/ap_19093580335314.jpg", + "guid": "http://longreadsblog.files.wordpress.com/2019/05/ap_19093580335314.jpg", + "date": "2019-05-22T15:59:07-04:00", + "post_ID": 125073, + "author_ID": 7867135, + "file": "ap_19093580335314.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "The New Meat", + "caption": "AP Photo/Nati Harnik", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://longreadsblog.files.wordpress.com/2019/05/ap_19093580335314.jpg?w=150", + "medium": "https://longreadsblog.files.wordpress.com/2019/05/ap_19093580335314.jpg?w=300", + "large": "https://longreadsblog.files.wordpress.com/2019/05/ap_19093580335314.jpg?w=1024" + }, + "height": 4000, + "width": 6000, + "exif": { + "aperture": "10", + "credit": "AP", + "camera": "ILCE-7M3", + "caption": "FILE- This Jan. 11, 2019, file photo shows the Impossible Burger, a plant-based burger containing wheat protein, coconut oil and potato protein among it's ingredients in Bellevue, Neb. From soy-based sliders to ground lentil sausages, plant-based meat substitutes are surging in popularity. Growing demand for healthier, more sustainable food is one reason people are seeking plant-based meats. (AP Photo/Nati Harnik, File)", + "created_timestamp": "1547234932", + "copyright": "Copyright 2019 The Associated Press. All rights reserved", + "focal_length": "50", + "iso": "800", + "shutter_speed": "0.01", + "title": "The New Meat", + "orientation": "1", + "keywords": [ + "faux meat;fake meat;vegan" + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/media/125103", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/media/125103/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/posts/125073" + } + } + } + }, + "attachment_count": 1, + "metadata": [ + { + "id": "463770", + "key": "geo_public", + "value": "0" + }, + { + "id": "463900", + "key": "_thumbnail_id", + "value": "125103" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/posts/125073", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/posts/125073/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/posts/125073/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/posts/125073/likes/" + }, + "data": { + "site": { + "ID": 70135762, + "name": "Longreads", + "description": "The best longform stories on the web", + "URL": "http://longreads.com", + "jetpack": false, + "post_count": 6794, + "subscribers_count": 41078065, + "locale": "en", + "icon": { + "img": "https://secure.gravatar.com/blavatar/e68667ec6ee7cdd0c6b5416a84c52a9c", + "ico": "https://secure.gravatar.com/blavatar/e68667ec6ee7cdd0c6b5416a84c52a9c" + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_following": true, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/70135762/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/70135762/comments/", + "xmlrpc": "https://longreadsblog.wordpress.com/xmlrpc.php" + } + } + } + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "other_URLs": { + }, + "pseudo_ID": "06ab8cb0a8b05a409928d97bcbf022e0", + "is_external": false, + "site_name": "Longreads", + "site_URL": "http://longreads.com", + "site_is_private": false, + "featured_media": { + "uri": "https://longreadsblog.files.wordpress.com/2019/05/ap_19093580335314.jpg", + "width": 6000, + "height": 4000, + "type": "image" + }, + "feed_ID": 22973954, + "feed_URL": "http://longreads.com", + "is_jetpack": false, + "use_excerpt": false, + "feed_item_ID": 2286928700, + "word_count": 327, + "is_following_conversation": false + }, + { + "ID": 441, + "site_ID": 106707880, + "author": { + "ID": 152748359, + "login": "e2eflowtestingmobile", + "email": false, + "name": "e2eflowtestingmobile", + "first_name": "", + "last_name": "", + "nice_name": "e2eflowtestingmobile", + "URL": "http://e2eflowtestingmobile.wordpress.com", + "avatar_URL": "https://1.gravatar.com/avatar/7a4015c11be6a342f65e0e169092d402?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/e2eflowtestingmobile", + "site_ID": 106707880, + "has_avatar": false + }, + "date": "2019-05-23T12:48:28+00:00", + "modified": "2019-05-23T12:48:28+00:00", + "title": "Dolor Sit Elit", + "URL": "https://e2eflowtestingmobile.wordpress.com/2019/05/23/dolor-sit-elit/", + "short_URL": "https://wp.me/paIC9Y-77", + "content": "\n

Proin dictum non ligula aliquam varius. Nam congue efficitur leo eget porta. Nam congue efficitur leo eget porta. Nam congue efficitur leo eget porta.

\n\n\n\n
\"\"
\n", + "excerpt": "

Proin dictum non ligula aliquam varius. Nam congue efficitur leo eget porta. Nam congue efficitur leo eget porta. Nam congue efficitur leo eget porta.

\n", + "slug": "dolor-sit-elit", + "guid": "https://e2eflowtestingmobile.wordpress.com/2019/05/23/dolor-sit-elit/", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "3a68b6b54d7e3f2fb5f69904c4039c49", + "featured_image": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/test-image-device-photo-gps-7.jpg", + "post_thumbnail": { + "ID": 440, + "URL": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/test-image-device-photo-gps-7.jpg", + "guid": "http://e2eflowtestingmobile.files.wordpress.com/2019/05/test-image-device-photo-gps-7.jpg", + "mime_type": "image/jpeg", + "width": 1024, + "height": 768 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "iOS Test": { + "ID": 43134051, + "name": "iOS Test", + "slug": "ios-test", + "description": "", + "post_count": 58, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:ios-test", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:ios-test/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + "tag 2019-05-23 01:47:54271": { + "ID": 680249593, + "name": "tag 2019-05-23 01:47:54271", + "slug": "tag-2019-05-23-014754271", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/tags/slug:tag-2019-05-23-014754271", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/tags/slug:tag-2019-05-23-014754271/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + "tag 2019-05-23 01:47:54271": { + "ID": 680249593, + "name": "tag 2019-05-23 01:47:54271", + "slug": "tag-2019-05-23-014754271", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/tags/slug:tag-2019-05-23-014754271", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/tags/slug:tag-2019-05-23-014754271/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + }, + "display_name": "tag-2019-05-23-014754271" + } + }, + "categories": { + "iOS Test": { + "ID": 43134051, + "name": "iOS Test", + "slug": "ios-test", + "description": "", + "post_count": 58, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:ios-test", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:ios-test/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + "440": { + "ID": 440, + "URL": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/test-image-device-photo-gps-7.jpg", + "guid": "http://e2eflowtestingmobile.files.wordpress.com/2019/05/test-image-device-photo-gps-7.jpg", + "date": "2019-05-23T12:48:14+00:00", + "post_ID": 441, + "author_ID": 152748359, + "file": "test-image-device-photo-gps-7.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "test-image-device-photo-gps", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/test-image-device-photo-gps-7.jpg?w=150", + "medium": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/test-image-device-photo-gps-7.jpg?w=300", + "large": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/test-image-device-photo-gps-7.jpg?w=1024" + }, + "height": 768, + "width": 1024, + "exif": { + "aperture": "2.2", + "credit": "", + "camera": "iPhone 6 Plus", + "caption": "", + "created_timestamp": "1438604307", + "copyright": "", + "focal_length": "4.15", + "iso": "32", + "shutter_speed": "0.00055586436909394", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/media/440", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/media/440/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/441" + } + } + } + }, + "attachment_count": 1, + "metadata": [ + { + "id": "3496", + "key": "jabber_published", + "value": "1558615709" + }, + { + "id": "3507", + "key": "timeline_notification", + "value": "1558615711" + }, + { + "id": "3501", + "key": "_thumbnail_id", + "value": "440" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/441", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/441/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/441/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/441/likes/" + }, + "data": { + "site": { + "ID": 106707880, + "name": "Mobile E2E Testing", + "description": "", + "URL": "https://e2eflowtestingmobile.wordpress.com", + "capabilities": { + "edit_pages": true, + "edit_posts": true, + "edit_others_posts": true, + "edit_others_pages": true, + "delete_posts": true, + "delete_others_posts": true, + "edit_theme_options": true, + "edit_users": false, + "list_users": true, + "manage_categories": true, + "manage_options": true, + "moderate_comments": true, + "activate_wordads": true, + "promote_users": true, + "publish_posts": true, + "upload_files": true, + "delete_users": false, + "remove_users": true, + "view_stats": true + }, + "jetpack": false, + "is_multisite": true, + "post_count": 261, + "subscribers_count": 1, + "locale": "en", + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "single_user_site": true, + "is_vip": false, + "is_following": true, + "options": { + "timezone": "", + "gmt_offset": 0, + "blog_public": 1, + "videopress_enabled": false, + "upgraded_filetypes_enabled": false, + "login_url": "https://e2eflowtestingmobile.wordpress.com/wp-login.php", + "admin_url": "https://e2eflowtestingmobile.wordpress.com/wp-admin/", + "is_mapped_domain": false, + "is_redirect": false, + "unmapped_url": "https://e2eflowtestingmobile.wordpress.com", + "featured_images_enabled": false, + "theme_slug": "pub/independent-publisher-2", + "header_image": false, + "background_color": false, + "image_default_link_type": "none", + "image_thumbnail_width": 150, + "image_thumbnail_height": 150, + "image_thumbnail_crop": 0, + "image_medium_width": 300, + "image_medium_height": 300, + "image_large_width": 1024, + "image_large_height": 1024, + "permalink_structure": "/%year%/%monthnum%/%day%/%postname%/", + "post_formats": [ + + ], + "default_post_format": "standard", + "default_category": 1, + "allowed_file_types": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "doc", + "ppt", + "odt", + "pptx", + "docx", + "pps", + "ppsx", + "xls", + "xlsx", + "key" + ], + "show_on_front": "posts", + "default_likes_enabled": true, + "default_sharing_status": true, + "default_comment_status": true, + "default_ping_status": true, + "software_version": "5.2.2-alpha-45377", + "created_at": "2019-02-14T09:49:46+00:00", + "wordads": false, + "publicize_permanently_disabled": false, + "frame_nonce": "4f06a891f5", + "jetpack_frame_nonce": "4f06a891f5", + "headstart": false, + "headstart_is_fresh": false, + "ak_vp_bundle_enabled": null, + "advanced_seo_front_page_description": "", + "advanced_seo_title_formats": [ + + ], + "verification_services_codes": null, + "podcasting_archive": null, + "is_domain_only": false, + "is_automated_transfer": false, + "is_wpcom_atomic": false, + "is_wpcom_store": false, + "woocommerce_is_active": false, + "design_type": "blog", + "site_goals": null, + "site_segment": null + }, + "plan": { + "product_id": 1, + "product_slug": "free_plan", + "product_name_short": "Free", + "expired": false, + "user_is_owner": false, + "is_free": true, + "features": { + "active": [ + "free-blog", + "space", + "support" + ], + "available": { + "free-blog": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "custom-domain": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "space": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "support": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "no-adverts/no-adverts.php": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "custom-design": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "videopress": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "unlimited_themes": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "live_support": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "simple-payments": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "premium-themes": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "google-analytics": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ] + } + } + }, + "jetpack_modules": null, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/comments/", + "xmlrpc": "https://e2eflowtestingmobile.wordpress.com/xmlrpc.php" + } + }, + "quota": { + "space_allowed": 3221225472, + "space_used": 586919661, + "percent_used": 18.22038432583213, + "space_available": 2634305811 + } + } + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "other_URLs": { + }, + "pseudo_ID": "3a68b6b54d7e3f2fb5f69904c4039c49", + "is_external": false, + "site_name": "Mobile E2E Testing", + "site_URL": "https://e2eflowtestingmobile.wordpress.com", + "site_is_private": false, + "featured_media": { + "uri": "https://e2eflowtestingmobile.files.wordpress.com/2019/05/test-image-device-photo-gps-7.jpg", + "width": 1024, + "height": 768, + "type": "image" + }, + "feed_ID": 93780362, + "feed_URL": "http://e2eflowtestingmobile.wordpress.com", + "is_jetpack": false, + "use_excerpt": false, + "feed_item_ID": 2286909370, + "word_count": 23, + "is_following_conversation": true + }, + { + "ID": 439, + "site_ID": 106707880, + "author": { + "ID": 152748359, + "login": "e2eflowtestingmobile", + "email": false, + "name": "e2eflowtestingmobile", + "first_name": "", + "last_name": "", + "nice_name": "e2eflowtestingmobile", + "URL": "http://e2eflowtestingmobile.wordpress.com", + "avatar_URL": "https://1.gravatar.com/avatar/7a4015c11be6a342f65e0e169092d402?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/e2eflowtestingmobile", + "site_ID": 106707880, + "has_avatar": false + }, + "date": "2019-05-23T12:46:55+00:00", + "modified": "2019-05-23T12:46:55+00:00", + "title": "Sit Elit Adipiscing Elit Dolor Lorem", + "URL": "https://e2eflowtestingmobile.wordpress.com/2019/05/23/sit-elit-adipiscing-elit-dolor-lorem/", + "short_URL": "https://wp.me/paIC9Y-75", + "content": "\n

Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis. Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis. Proin dictum non ligula aliquam varius. Nam ornare accumsan ante, sollicitudin bibendum erat bibendum nec. Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis.

\n", + "excerpt": "

Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis. Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis. Proin dictum non ligula aliquam varius. Nam ornare accumsan ante, sollicitudin bibendum erat bibendum nec. Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis.

\n", + "slug": "sit-elit-adipiscing-elit-dolor-lorem", + "guid": "https://e2eflowtestingmobile.wordpress.com/2019/05/23/sit-elit-adipiscing-elit-dolor-lorem/", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 0, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "d0a5af2d4026b8326ef145c4986e9ecc", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 196, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "post_tag": { + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": [ + + ], + "categories": { + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 196, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "3482", + "key": "jabber_published", + "value": "1558615616" + }, + { + "id": "3490", + "key": "timeline_notification", + "value": "1558615617" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/439", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/439/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/439/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/439/likes/" + }, + "data": { + "site": { + "ID": 106707880, + "name": "Mobile E2E Testing", + "description": "", + "URL": "https://e2eflowtestingmobile.wordpress.com", + "capabilities": { + "edit_pages": true, + "edit_posts": true, + "edit_others_posts": true, + "edit_others_pages": true, + "delete_posts": true, + "delete_others_posts": true, + "edit_theme_options": true, + "edit_users": false, + "list_users": true, + "manage_categories": true, + "manage_options": true, + "moderate_comments": true, + "activate_wordads": true, + "promote_users": true, + "publish_posts": true, + "upload_files": true, + "delete_users": false, + "remove_users": true, + "view_stats": true + }, + "jetpack": false, + "is_multisite": true, + "post_count": 261, + "subscribers_count": 1, + "locale": "en", + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "single_user_site": true, + "is_vip": false, + "is_following": true, + "options": { + "timezone": "", + "gmt_offset": 0, + "blog_public": 1, + "videopress_enabled": false, + "upgraded_filetypes_enabled": false, + "login_url": "https://e2eflowtestingmobile.wordpress.com/wp-login.php", + "admin_url": "https://e2eflowtestingmobile.wordpress.com/wp-admin/", + "is_mapped_domain": false, + "is_redirect": false, + "unmapped_url": "https://e2eflowtestingmobile.wordpress.com", + "featured_images_enabled": false, + "theme_slug": "pub/independent-publisher-2", + "header_image": false, + "background_color": false, + "image_default_link_type": "none", + "image_thumbnail_width": 150, + "image_thumbnail_height": 150, + "image_thumbnail_crop": 0, + "image_medium_width": 300, + "image_medium_height": 300, + "image_large_width": 1024, + "image_large_height": 1024, + "permalink_structure": "/%year%/%monthnum%/%day%/%postname%/", + "post_formats": [ + + ], + "default_post_format": "standard", + "default_category": 1, + "allowed_file_types": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "doc", + "ppt", + "odt", + "pptx", + "docx", + "pps", + "ppsx", + "xls", + "xlsx", + "key" + ], + "show_on_front": "posts", + "default_likes_enabled": true, + "default_sharing_status": true, + "default_comment_status": true, + "default_ping_status": true, + "software_version": "5.2.2-alpha-45377", + "created_at": "2019-02-14T09:49:46+00:00", + "wordads": false, + "publicize_permanently_disabled": false, + "frame_nonce": "4f06a891f5", + "jetpack_frame_nonce": "4f06a891f5", + "headstart": false, + "headstart_is_fresh": false, + "ak_vp_bundle_enabled": null, + "advanced_seo_front_page_description": "", + "advanced_seo_title_formats": [ + + ], + "verification_services_codes": null, + "podcasting_archive": null, + "is_domain_only": false, + "is_automated_transfer": false, + "is_wpcom_atomic": false, + "is_wpcom_store": false, + "woocommerce_is_active": false, + "design_type": "blog", + "site_goals": null, + "site_segment": null + }, + "plan": { + "product_id": 1, + "product_slug": "free_plan", + "product_name_short": "Free", + "expired": false, + "user_is_owner": false, + "is_free": true, + "features": { + "active": [ + "free-blog", + "space", + "support" + ], + "available": { + "free-blog": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "custom-domain": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "space": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "support": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "no-adverts/no-adverts.php": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "custom-design": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "videopress": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "unlimited_themes": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "live_support": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "simple-payments": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "premium-themes": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ], + "google-analytics": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y" + ] + } + } + }, + "jetpack_modules": null, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/106707880/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/comments/", + "xmlrpc": "https://e2eflowtestingmobile.wordpress.com/xmlrpc.php" + } + }, + "quota": { + "space_allowed": 3221225472, + "space_used": 586919661, + "percent_used": 18.22038432583213, + "space_available": 2634305811 + } + } + } + }, + "capabilities": { + "publish_post": true, + "delete_post": true, + "edit_post": true + }, + "other_URLs": { + }, + "pseudo_ID": "d0a5af2d4026b8326ef145c4986e9ecc", + "is_external": false, + "site_name": "Mobile E2E Testing", + "site_URL": "https://e2eflowtestingmobile.wordpress.com", + "site_is_private": false, + "featured_media": { + }, + "feed_ID": 93780362, + "feed_URL": "http://e2eflowtestingmobile.wordpress.com", + "is_jetpack": false, + "use_excerpt": false, + "feed_item_ID": 2286906998, + "word_count": 44, + "is_following_conversation": true + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_menu.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_menu.json new file mode 100644 index 000000000000..ba05ef634cbf --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_menu.json @@ -0,0 +1,226 @@ +{ + "request": { + "urlPath": "/rest/v1.2/read/menu", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "default": { + "following": { + "title": "Followed Sites", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/following" + }, + "discover": { + "title": "Discover", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/sites/53424024/posts" + }, + "liked": { + "title": "My Likes", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/liked" + } + }, + "subscribed": { + "436": { + "ID": "436", + "title": "Photography", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/photography/posts", + "slug": "photography", + "display_name": "photography", + "type": "tag" + } + }, + "recommended": { + "116": { + "ID": 116, + "title": "Religion", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/religion/posts", + "slug": "religion", + "display_name": "religion", + "type": "tag" + }, + "178": { + "ID": 178, + "title": "Books", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/books/posts", + "slug": "books", + "display_name": "books", + "type": "tag" + }, + "200": { + "ID": 200, + "title": "Travel", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/travel/posts", + "slug": "travel", + "display_name": "travel", + "type": "tag" + }, + "376": { + "ID": 376, + "title": "Humor", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/humor/posts", + "slug": "humor", + "display_name": "humor", + "type": "tag" + }, + "406": { + "ID": 406, + "title": "Family", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/family/posts", + "slug": "family", + "display_name": "family", + "type": "tag" + }, + "436": { + "ID": 436, + "title": "Photography", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/photography/posts", + "slug": "photography", + "display_name": "photography", + "type": "tag" + }, + "586": { + "ID": 586, + "title": "Food", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/food/posts", + "slug": "food", + "display_name": "food", + "type": "tag" + }, + "676": { + "ID": 676, + "title": "Websites", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/websites/posts", + "slug": "websites", + "display_name": "websites", + "type": "tag" + }, + "1098": { + "ID": 1098, + "title": "Culture", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/culture/posts", + "slug": "culture", + "display_name": "culture", + "type": "tag" + }, + "2806": { + "ID": 2806, + "title": "Art & Design", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/art-design/posts", + "slug": "art-design", + "display_name": "art-design", + "type": "tag" + }, + "3750": { + "ID": 3750, + "title": "Magazines", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/magazines/posts", + "slug": "magazines", + "display_name": "magazines", + "type": "tag" + }, + "13985": { + "ID": 13985, + "title": "Health & Wellness", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/health-wellness/posts", + "slug": "health-wellness", + "display_name": "health-wellness", + "type": "tag" + }, + "26879": { + "ID": 26879, + "title": "Portfolios", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/portfolios/posts", + "slug": "portfolios", + "display_name": "portfolios", + "type": "tag" + }, + "38881": { + "ID": 38881, + "title": "Science & Nature", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/science-nature/posts", + "slug": "science-nature", + "display_name": "science-nature", + "type": "tag" + }, + "84776": { + "ID": 84776, + "title": "News & Current Events", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/news-current-events/posts", + "slug": "news-current-events", + "display_name": "news-current-events", + "type": "tag" + }, + "258845": { + "ID": 258845, + "title": "Business & Technology", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/business-technology/posts", + "slug": "business-technology", + "display_name": "business-technology", + "type": "tag" + }, + "960817": { + "ID": 960817, + "title": "Fiction & Poetry", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/fiction-poetry/posts", + "slug": "fiction-poetry", + "display_name": "fiction-poetry", + "type": "tag" + }, + "998458": { + "ID": 998458, + "title": "Writing & Blogging", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/writing-blogging/posts", + "slug": "writing-blogging", + "display_name": "writing-blogging", + "type": "tag" + }, + "1446729": { + "ID": 1446729, + "title": "Sports & Gaming", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/sports-gaming/posts", + "slug": "sports-gaming", + "display_name": "sports-gaming", + "type": "tag" + }, + "10016773": { + "ID": 10016773, + "title": "Musings & Personal", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/musings-personal/posts", + "slug": "musings-personal", + "display_name": "musings-personal", + "type": "tag" + }, + "35328892": { + "ID": 35328892, + "title": "Longreads", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/longreads/posts", + "slug": "longreads", + "display_name": "longreads", + "type": "tag" + }, + "63611190": { + "ID": 63611190, + "title": "Crafts & Fashion", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/crafts-fashion/posts", + "slug": "crafts-fashion", + "display_name": "crafts-fashion", + "type": "tag" + }, + "87210105": { + "ID": 87210105, + "title": "Popular Culture & Entertainment", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/popular-culture-entertainment/posts", + "slug": "popular-culture-entertainment", + "display_name": "popular-culture-entertainment", + "type": "tag" + } + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_sites_discover.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_sites_discover.json new file mode 100644 index 000000000000..fc2ab7799478 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_sites_discover.json @@ -0,0 +1,88 @@ +{ + "request": { + "urlPattern": "/rest/v1.2/read/sites/53424024(/)?($|\\?.*)", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 53424024, + "name": "Discover", + "description": "A daily selection of the best content published on WordPress, collected for you by humans who love to read.", + "URL": "https://discover.wordpress.com", + "jetpack": false, + "post_count": 3620, + "subscribers_count": 35560543, + "lang": "en", + "icon": { + "img": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace", + "ico": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace" + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_following": true, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/comments/", + "xmlrpc": "https://discover.wordpress.com/xmlrpc.php", + "featured": "{{request.requestLine.baseUrl}}/rest/v1.2/read/sites/53424024/featured" + } + }, + "launch_status": false, + "capabilities": { + "edit_pages": false, + "edit_posts": false, + "edit_others_posts": false, + "edit_theme_options": false, + "list_users": false, + "manage_categories": false, + "manage_options": false, + "publish_posts": false, + "upload_files": false, + "view_stats": false + }, + "is_multi_author": true, + "feed_ID": 41325786, + "feed_URL": "http://discover.wordpress.com", + "header_image": false, + "owner": { + "ID": 26957695, + "login": "a8cuser", + "name": "Automattic", + "first_name": "Automattic", + "last_name": "", + "nice_name": "a8cuser", + "URL": "", + "avatar_URL": "https://1.gravatar.com/avatar/a64c4a50f3f38f02cd27a9bfb3f11b62?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/a8cuser", + "ip_address": false, + "site_visible": true, + "has_avatar": true + }, + "subscription": { + "delivery_methods": { + "email": null, + "notification": { + "send_posts": false + } + } + }, + "is_blocked": false + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_sites_discover_posts.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_sites_discover_posts.json new file mode 100644 index 000000000000..baa805b51bda --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v12_read_sites_discover_posts.json @@ -0,0 +1,2431 @@ +{ + "request": { + "urlPattern": "/rest/v1.2/read/sites/53424024/posts(/)?($|\\?.*)", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "found": 10, + "posts": [ + { + "ID": 37222, + "site_ID": 53424024, + "author": { + "ID": 47411601, + "login": "benhuberman", + "email": false, + "name": "Ben Huberman", + "first_name": "Ben", + "last_name": "Huberman", + "nice_name": "benhuberman", + "URL": "https://benz.blog/", + "avatar_URL": "https://0.gravatar.com/avatar/663dcd498e8c5f255bfb230a7ba07678?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/benhuberman", + "site_ID": 122910962, + "has_avatar": true + }, + "date": "2019-05-27T09:00:17-04:00", + "modified": "2019-05-21T23:55:05-04:00", + "title": "Kelsey Montague Art", + "URL": "https://discover.wordpress.com/2019/05/27/kelsey-montague-art/", + "short_URL": "https://wp.me/p3Ca1O-9Gm", + "content": "

Explore mural artist Kelsey Montague’s work, stay up-to-date on her latest projects, and shop for prints on her Anything Is Possible-featured website. 

\r\n\r\n\r\n

\r\n", + "excerpt": "

Explore mural artist Kelsey Montague’s work, stay up-to-date on her latest projects, and shop for prints on her Anything Is Possible-featured website. 

\n", + "slug": "kelsey-montague-art", + "guid": "https://discover.wordpress.com/?p=37222", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": false, + "comment_status": "closed", + "pings_open": false, + "ping_status": "closed", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 33, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "b06fd6b4fd1b95644d71981f34f747f8", + "featured_image": "https://discover.files.wordpress.com/2019/05/img_3760.jpg", + "post_thumbnail": { + "ID": 37226, + "URL": "https://discover.files.wordpress.com/2019/05/img_3760.jpg", + "guid": "http://discover.files.wordpress.com/2019/05/img_3760.jpg", + "mime_type": "image/jpeg", + "width": 1280, + "height": 1706 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Art": { + "ID": 177, + "name": "Art", + "slug": "art", + "description": "Artists' sites, powerful artwork in multiple genres in a variety of mediums, and news from the art world.", + "post_count": 370, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Culture": { + "ID": 1098, + "name": "Culture", + "slug": "culture", + "description": "A curated collection of WordPress sites on society, culture in all its forms, and diverse art forms from around the world.", + "post_count": 414, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:culture", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:culture/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Diversity": { + "ID": 47458, + "name": "Diversity", + "slug": "diversity", + "description": "Blogs, stories, and web projects that highlight activists working towards greater diversity in culture, politics, and the business world.", + "post_count": 131, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:diversity", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:diversity/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Feminism": { + "ID": 553, + "name": "Feminism", + "slug": "feminism", + "description": "Magazines, collaborative websites, and feminist blogs that cover important issues in politics, culture, diversity, and the fight for gender equality.", + "post_count": 99, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:feminism", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:feminism/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Inspiration": { + "ID": 107, + "name": "Inspiration", + "slug": "inspiration", + "description": "Ideas and advice to empower and motivate people – especially bloggers, writers, and creative types – in their personal or professional journeys.", + "post_count": 327, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:inspiration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:inspiration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Popular Culture": { + "ID": 2437, + "name": "Popular Culture", + "slug": "popular-culture", + "description": "The web's leading pop culture magazines and blogs, covering music, film, television, gaming, and more.", + "post_count": 198, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:popular-culture", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:popular-culture/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_tag": { + "AnythingIsPossible": { + "ID": 109479402, + "name": "AnythingIsPossible", + "slug": "anythingispossible", + "description": "", + "post_count": 15, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:anythingispossible", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:anythingispossible/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "mural": { + "ID": 159763, + "name": "mural", + "slug": "mural", + "description": "", + "post_count": 4, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:mural", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:mural/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "mural art": { + "ID": 5762997, + "name": "mural art", + "slug": "mural-art", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:mural-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:mural-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "murals": { + "ID": 135373, + "name": "murals", + "slug": "murals", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:murals", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:murals/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "street art": { + "ID": 57447, + "name": "street art", + "slug": "street-art", + "description": "", + "post_count": 18, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:street-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:street-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "urban art": { + "ID": 28923, + "name": "urban art", + "slug": "urban-art", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:urban-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:urban-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + "AnythingIsPossible": { + "ID": 109479402, + "name": "AnythingIsPossible", + "slug": "anythingispossible", + "description": "", + "post_count": 15, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:anythingispossible", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:anythingispossible/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "anythingispossible" + }, + "mural": { + "ID": 159763, + "name": "mural", + "slug": "mural", + "description": "", + "post_count": 4, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:mural", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:mural/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "mural" + }, + "mural art": { + "ID": 5762997, + "name": "mural art", + "slug": "mural-art", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:mural-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:mural-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "mural-art" + }, + "murals": { + "ID": 135373, + "name": "murals", + "slug": "murals", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:murals", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:murals/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "murals" + }, + "street art": { + "ID": 57447, + "name": "street art", + "slug": "street-art", + "description": "", + "post_count": 18, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:street-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:street-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "street-art" + }, + "urban art": { + "ID": 28923, + "name": "urban art", + "slug": "urban-art", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:urban-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:urban-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "urban-art" + } + }, + "categories": { + "Art": { + "ID": 177, + "name": "Art", + "slug": "art", + "description": "Artists' sites, powerful artwork in multiple genres in a variety of mediums, and news from the art world.", + "post_count": 370, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Culture": { + "ID": 1098, + "name": "Culture", + "slug": "culture", + "description": "A curated collection of WordPress sites on society, culture in all its forms, and diverse art forms from around the world.", + "post_count": 414, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:culture", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:culture/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Diversity": { + "ID": 47458, + "name": "Diversity", + "slug": "diversity", + "description": "Blogs, stories, and web projects that highlight activists working towards greater diversity in culture, politics, and the business world.", + "post_count": 131, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:diversity", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:diversity/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Feminism": { + "ID": 553, + "name": "Feminism", + "slug": "feminism", + "description": "Magazines, collaborative websites, and feminist blogs that cover important issues in politics, culture, diversity, and the fight for gender equality.", + "post_count": 99, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:feminism", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:feminism/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Inspiration": { + "ID": 107, + "name": "Inspiration", + "slug": "inspiration", + "description": "Ideas and advice to empower and motivate people – especially bloggers, writers, and creative types – in their personal or professional journeys.", + "post_count": 327, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:inspiration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:inspiration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Popular Culture": { + "ID": 2437, + "name": "Popular Culture", + "slug": "popular-culture", + "description": "The web's leading pop culture magazines and blogs, covering music, film, television, gaming, and more.", + "post_count": 198, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:popular-culture", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:popular-culture/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "attachments": { + "37226": { + "ID": 37226, + "URL": "https://discover.files.wordpress.com/2019/05/img_3760.jpg", + "guid": "http://discover.files.wordpress.com/2019/05/img_3760.jpg", + "date": "2019-05-21T23:48:08-04:00", + "post_ID": 37222, + "author_ID": 47411601, + "file": "img_3760.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "IMG_3760", + "caption": "", + "description": "", + "alt": "Kelsey Montague mural art", + "thumbnails": { + "thumbnail": "https://discover.files.wordpress.com/2019/05/img_3760.jpg?w=113", + "medium": "https://discover.files.wordpress.com/2019/05/img_3760.jpg?w=311", + "large": "https://discover.files.wordpress.com/2019/05/img_3760.jpg?w=768" + }, + "height": 1706, + "width": 1280, + "exif": { + "aperture": "1.8", + "credit": "", + "camera": "iPhone 8 Plus", + "caption": "", + "created_timestamp": "1536421883", + "copyright": "", + "focal_length": "3.99", + "iso": "20", + "shutter_speed": "0.0014836795252226", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37226", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37226/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37222" + } + } + } + }, + "attachment_count": 1, + "metadata": [ + { + "id": "130242", + "key": "geo_public", + "value": "0" + }, + { + "id": "130230", + "key": "_thumbnail_id", + "value": "37226" + }, + { + "id": "130401", + "key": "_wpas_done_17927786", + "value": "1" + }, + { + "id": "130238", + "key": "_wpas_mess", + "value": "Visit @kelsmontagueart's website - featured on @wordpressdotcom's #AnythingIsPossible list - for inspiration, prints, and updates on Kelsey's latest mural projects." + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/53424024/posts/37222", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/37222/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37222/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37222/likes/" + }, + "data": { + "site": { + "ID": 53424024, + "name": "Discover", + "description": "A daily selection of the best content published on WordPress, collected for you by humans who love to read.", + "URL": "https://discover.wordpress.com", + "jetpack": false, + "post_count": 3603, + "subscribers_count": 35192480, + "locale": "en", + "icon": { + "img": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace", + "ico": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace" + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_following": true, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/comments/", + "xmlrpc": "https://discover.wordpress.com/xmlrpc.php" + } + } + } + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "other_URLs": { + }, + "discover_metadata": { + "permalink": "https://kelseymontagueart.com/", + "attribution": { + "author_name": "Krista Stevens", + "author_url": "https://kelseymontagueart.com/", + "blog_name": "Kelsey Montague Art", + "blog_url": "https://kelseymontagueart.com", + "avatar_url": "https://discover.files.wordpress.com/2019/05/kelsey-thumbnail.png?w=100&h=100&crop=true" + }, + "discover_fp_post_formats": [ + { + "name": "Pick", + "slug": "pick", + "id": 346750 + }, + { + "name": "Site Pick", + "slug": "site-pick", + "id": 308219249 + } + ], + "featured_post_wpcom_data": { + "blog_id": 161169196 + } + }, + "feed_ID": 41325786, + "feed_URL": "http://discover.wordpress.com", + "pseudo_ID": "b06fd6b4fd1b95644d71981f34f747f8", + "is_external": false, + "site_name": "Discover", + "site_URL": "https://discover.wordpress.com", + "site_is_private": false, + "featured_media": { + }, + "use_excerpt": false, + "is_following_conversation": false + }, + { + "ID": 37189, + "site_ID": 53424024, + "author": { + "ID": 10183950, + "login": "cherilucas", + "email": false, + "name": "Cheri Lucas Rowlands", + "first_name": "Cheri", + "last_name": "Rowlands", + "nice_name": "cherilucas", + "URL": "http://cherilucasrowlands.com", + "avatar_URL": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/cherilucas", + "site_ID": 9838404, + "has_avatar": true + }, + "date": "2019-05-26T09:00:58-04:00", + "modified": "2019-05-22T17:07:55-04:00", + "title": "The Radical Notion of Not Letting Work Define You", + "URL": "https://discover.wordpress.com/2019/05/26/the-radical-notion-of-not-letting-work-define-you/", + "short_URL": "https://wp.me/p3Ca1O-9FP", + "content": "

“Just because something can’t be a career doesn’t have to mean that it can’t be part of your life and identity.” At Man Repeller, Molly Conway muses on imposter syndrome, work and identity, and being a playwright.

\n", + "excerpt": "

“Just because something can’t be a career doesn’t have to mean that it can’t be part of your life and identity.” At Man Repeller, Molly Conway muses on imposter syndrome, work and identity, and being a playwright.

\n", + "slug": "the-radical-notion-of-not-letting-work-define-you", + "guid": "https://discover.wordpress.com/?p=37189", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": false, + "comment_status": "closed", + "pings_open": false, + "ping_status": "closed", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 102, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "eedec98542f8cbec7df4d4c608c847ef", + "featured_image": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "post_thumbnail": { + "ID": 37191, + "URL": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "guid": "http://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "mime_type": "image/png", + "width": 1482, + "height": 988 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Identity": { + "ID": 10679, + "name": "Identity", + "slug": "identity", + "description": "Engaging conversations and writing around the topics of identity, diversity, and the search for authenticity.", + "post_count": 166, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:identity", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:identity/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Personal Essay": { + "ID": 253221, + "name": "Personal Essay", + "slug": "personal-essay", + "description": "Personal and introspective essays and longform from new and established writers and authors.", + "post_count": 156, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:personal-essay", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:personal-essay/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Personal Musings": { + "ID": 5316, + "name": "Personal Musings", + "slug": "personal-musings", + "description": "Introspective and self-reflective writing in various formats.", + "post_count": 437, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:personal-musings", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:personal-musings/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Work": { + "ID": 131, + "name": "Work", + "slug": "work", + "description": "From tech to farming, posts and websites dedicated to the many facets of work, work-life balance, and social inequality.", + "post_count": 80, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:work", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:work/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Writing": { + "ID": 349, + "name": "Writing", + "slug": "writing", + "description": "Writing, advice, and commentary on the act and process of writing, blogging, and publishing.", + "post_count": 437, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:writing", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:writing/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_tag": { + "imposter syndrome": { + "ID": 392126, + "name": "imposter syndrome", + "slug": "imposter-syndrome", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:imposter-syndrome", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:imposter-syndrome/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "playwright": { + "ID": 160393, + "name": "playwright", + "slug": "playwright", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:playwright", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:playwright/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "PoweredByWordPress": { + "ID": 76162589, + "name": "PoweredByWordPress", + "slug": "poweredbywordpress", + "description": "", + "post_count": 42, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:poweredbywordpress", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:poweredbywordpress/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "writers": { + "ID": 16761, + "name": "writers", + "slug": "writers", + "description": "", + "post_count": 70, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:writers", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:writers/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + "imposter syndrome": { + "ID": 392126, + "name": "imposter syndrome", + "slug": "imposter-syndrome", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:imposter-syndrome", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:imposter-syndrome/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "imposter-syndrome" + }, + "playwright": { + "ID": 160393, + "name": "playwright", + "slug": "playwright", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:playwright", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:playwright/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "playwright" + }, + "PoweredByWordPress": { + "ID": 76162589, + "name": "PoweredByWordPress", + "slug": "poweredbywordpress", + "description": "", + "post_count": 42, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:poweredbywordpress", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:poweredbywordpress/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "poweredbywordpress" + }, + "writers": { + "ID": 16761, + "name": "writers", + "slug": "writers", + "description": "", + "post_count": 70, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:writers", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:writers/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "writers" + } + }, + "categories": { + "Identity": { + "ID": 10679, + "name": "Identity", + "slug": "identity", + "description": "Engaging conversations and writing around the topics of identity, diversity, and the search for authenticity.", + "post_count": 166, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:identity", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:identity/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Personal Essay": { + "ID": 253221, + "name": "Personal Essay", + "slug": "personal-essay", + "description": "Personal and introspective essays and longform from new and established writers and authors.", + "post_count": 156, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:personal-essay", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:personal-essay/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Personal Musings": { + "ID": 5316, + "name": "Personal Musings", + "slug": "personal-musings", + "description": "Introspective and self-reflective writing in various formats.", + "post_count": 437, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:personal-musings", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:personal-musings/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Work": { + "ID": 131, + "name": "Work", + "slug": "work", + "description": "From tech to farming, posts and websites dedicated to the many facets of work, work-life balance, and social inequality.", + "post_count": 80, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:work", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:work/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Writing": { + "ID": 349, + "name": "Writing", + "slug": "writing", + "description": "Writing, advice, and commentary on the act and process of writing, blogging, and publishing.", + "post_count": 437, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:writing", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:writing/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "attachments": { + "37191": { + "ID": 37191, + "URL": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "guid": "http://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "date": "2019-05-20T14:50:23-04:00", + "post_ID": 37189, + "author_ID": 10183950, + "file": "screen-shot-2019-05-20-at-11.50.07-am.png", + "mime_type": "image/png", + "extension": "png", + "title": "man repeller header image", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png?w=150", + "medium": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png?w=315", + "large": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png?w=1220" + }, + "height": 988, + "width": 1482, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37191", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37191/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37189" + } + } + }, + "37192": { + "ID": 37192, + "URL": "https://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg", + "guid": "http://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg", + "date": "2019-05-20T14:50:47-04:00", + "post_ID": 37189, + "author_ID": 10183950, + "file": "man-repeller-logo.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "man repeller logo", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg?w=150", + "medium": "https://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg?w=315", + "large": "https://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg?w=400" + }, + "height": 400, + "width": 400, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37192", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37192/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37189" + } + } + } + }, + "attachment_count": 2, + "metadata": [ + { + "id": "130145", + "key": "geo_public", + "value": "0" + }, + { + "id": "130142", + "key": "_thumbnail_id", + "value": "37191" + }, + { + "id": "130392", + "key": "_wpas_done_17926349", + "value": "1" + }, + { + "id": "130146", + "key": "_wpas_mess", + "value": "\"Just because something can’t be a career doesn’t have to mean that it can’t be part of your life and identity.\" Molly Conway muses on imposter syndrome, work and identity, and being a playwright. (@ManRepeller)" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/53424024/posts/37189", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/37189/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37189/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37189/likes/" + }, + "data": { + "site": { + "ID": 53424024, + "name": "Discover", + "description": "A daily selection of the best content published on WordPress, collected for you by humans who love to read.", + "URL": "https://discover.wordpress.com", + "jetpack": false, + "post_count": 3603, + "subscribers_count": 35192480, + "locale": "en", + "icon": { + "img": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace", + "ico": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace" + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_following": true, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/comments/", + "xmlrpc": "https://discover.wordpress.com/xmlrpc.php" + } + } + } + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "other_URLs": { + }, + "discover_metadata": { + "permalink": "https://www.manrepeller.com/2019/05/work-identity.html", + "attribution": { + "author_name": "Molly Conway", + "author_url": "https://www.manrepeller.com/author/molly-conway", + "blog_name": "Man Repeller", + "blog_url": "https://www.manrepeller.com", + "avatar_url": "https://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg?w=100&h=100&crop=true" + }, + "discover_fp_post_formats": [ + { + "name": "Pick", + "slug": "pick", + "id": 346750 + }, + { + "name": "Standard Pick", + "slug": "standard-pick", + "id": 337879995 + } + ], + "featured_post_wpcom_data": { + "blog_id": 61780023 + } + }, + "feed_ID": 41325786, + "feed_URL": "http://discover.wordpress.com", + "pseudo_ID": "eedec98542f8cbec7df4d4c608c847ef", + "is_external": false, + "site_name": "Discover", + "site_URL": "https://discover.wordpress.com", + "site_is_private": false, + "featured_media": { + "uri": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "width": 1482, + "height": 988, + "type": "image" + }, + "use_excerpt": false, + "is_following_conversation": false + }, + { + "ID": 37205, + "site_ID": 53424024, + "author": { + "ID": 47411601, + "login": "benhuberman", + "email": false, + "name": "Ben Huberman", + "first_name": "Ben", + "last_name": "Huberman", + "nice_name": "benhuberman", + "URL": "https://benz.blog/", + "avatar_URL": "https://0.gravatar.com/avatar/663dcd498e8c5f255bfb230a7ba07678?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/benhuberman", + "site_ID": 122910962, + "has_avatar": true + }, + "date": "2019-05-25T09:00:36-04:00", + "modified": "2019-05-21T23:45:15-04:00", + "title": "Barista Hustle", + "URL": "https://discover.wordpress.com/2019/05/25/barista-hustle/", + "short_URL": "https://wp.me/p3Ca1O-9G5", + "content": "\n

The team at Barista Hustle, a leading coffee-education hub, creates resources and shares their extensive knowledge on topics ranging from cutting-edge gear to latte art.

\n", + "excerpt": "

The team at Barista Hustle, a leading coffee-education hub, creates resources and shares their extensive knowledge on topics ranging from cutting-edge gear to latte art.

\n", + "slug": "barista-hustle", + "guid": "https://discover.wordpress.com/?p=37205", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": false, + "comment_status": "closed", + "pings_open": false, + "ping_status": "closed", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 58, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "5344123ea1ee2da1788f11183966d068", + "featured_image": "https://discover.files.wordpress.com/2019/05/bh-home-header-element-wide-final.jpg", + "post_thumbnail": { + "ID": 37206, + "URL": "https://discover.files.wordpress.com/2019/05/bh-home-header-element-wide-final.jpg", + "guid": "http://discover.files.wordpress.com/2019/05/bh-home-header-element-wide-final.jpg", + "mime_type": "image/jpeg", + "width": 2800, + "height": 1500 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Business": { + "ID": 179, + "name": "Business", + "slug": "business", + "description": "Small business and ecommerce resources, writing for professionals, and commentary on business, economics, and related topics.", + "post_count": 88, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:business", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:business/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Education": { + "ID": 1342, + "name": "Education", + "slug": "education", + "description": "Resources across disciplines and perspectives on teaching, learning, and the educational system from educators, teachers, and parents.", + "post_count": 121, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:education", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:education/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Food": { + "ID": 586, + "name": "Food", + "slug": "food", + "description": "Recipes, writing on food and culinary culture, and food photography or visual art.", + "post_count": 215, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:food", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:food/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Lifestyle": { + "ID": 278, + "name": "Lifestyle", + "slug": "lifestyle", + "description": "Sites devoted to fashion and beauty, interior design, travel, alternative ways of living, and more.", + "post_count": 127, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:lifestyle", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:lifestyle/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Work": { + "ID": 131, + "name": "Work", + "slug": "work", + "description": "From tech to farming, posts and websites dedicated to the many facets of work, work-life balance, and social inequality.", + "post_count": 80, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:work", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:work/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_tag": { + "Baristas": { + "ID": 831444, + "name": "Baristas", + "slug": "baristas", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:baristas", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:baristas/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "business blog": { + "ID": 287435, + "name": "business blog", + "slug": "business-blog", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:business-blog", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:business-blog/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "coffee": { + "ID": 16166, + "name": "coffee", + "slug": "coffee", + "description": "", + "post_count": 10, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:coffee", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:coffee/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "ecommerce": { + "ID": 11160, + "name": "ecommerce", + "slug": "ecommerce", + "description": "", + "post_count": 10, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:ecommerce", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:ecommerce/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Small Business": { + "ID": 10585, + "name": "Small Business", + "slug": "small-business", + "description": "", + "post_count": 37, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:small-business", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:small-business/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + "Baristas": { + "ID": 831444, + "name": "Baristas", + "slug": "baristas", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:baristas", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:baristas/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "baristas" + }, + "business blog": { + "ID": 287435, + "name": "business blog", + "slug": "business-blog", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:business-blog", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:business-blog/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "business-blog" + }, + "coffee": { + "ID": 16166, + "name": "coffee", + "slug": "coffee", + "description": "", + "post_count": 10, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:coffee", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:coffee/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "coffee" + }, + "ecommerce": { + "ID": 11160, + "name": "ecommerce", + "slug": "ecommerce", + "description": "", + "post_count": 10, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:ecommerce", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:ecommerce/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "ecommerce" + }, + "Small Business": { + "ID": 10585, + "name": "Small Business", + "slug": "small-business", + "description": "", + "post_count": 37, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:small-business", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:small-business/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "small-business" + } + }, + "categories": { + "Business": { + "ID": 179, + "name": "Business", + "slug": "business", + "description": "Small business and ecommerce resources, writing for professionals, and commentary on business, economics, and related topics.", + "post_count": 88, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:business", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:business/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Education": { + "ID": 1342, + "name": "Education", + "slug": "education", + "description": "Resources across disciplines and perspectives on teaching, learning, and the educational system from educators, teachers, and parents.", + "post_count": 121, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:education", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:education/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Food": { + "ID": 586, + "name": "Food", + "slug": "food", + "description": "Recipes, writing on food and culinary culture, and food photography or visual art.", + "post_count": 215, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:food", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:food/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Lifestyle": { + "ID": 278, + "name": "Lifestyle", + "slug": "lifestyle", + "description": "Sites devoted to fashion and beauty, interior design, travel, alternative ways of living, and more.", + "post_count": 127, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:lifestyle", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:lifestyle/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Work": { + "ID": 131, + "name": "Work", + "slug": "work", + "description": "From tech to farming, posts and websites dedicated to the many facets of work, work-life balance, and social inequality.", + "post_count": 80, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:work", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:work/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "130216", + "key": "geo_public", + "value": "0" + }, + { + "id": "130206", + "key": "_thumbnail_id", + "value": "37206" + }, + { + "id": "130379", + "key": "_wpas_done_17927786", + "value": "1" + }, + { + "id": "130212", + "key": "_wpas_mess", + "value": "Whether you're a coffee professional, an aspiring latte artist, or just looking to improve your next cup, check out the resources and courses @BaristaHustle, a #PoweredByWordPress website:" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/53424024/posts/37205", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/37205/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37205/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37205/likes/" + }, + "data": { + "site": { + "ID": 53424024, + "name": "Discover", + "description": "A daily selection of the best content published on WordPress, collected for you by humans who love to read.", + "URL": "https://discover.wordpress.com", + "jetpack": false, + "post_count": 3603, + "subscribers_count": 35192480, + "locale": "en", + "icon": { + "img": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace", + "ico": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace" + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_following": true, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/comments/", + "xmlrpc": "https://discover.wordpress.com/xmlrpc.php" + } + } + } + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "other_URLs": { + }, + "discover_metadata": { + "permalink": "https://baristahustle.com/", + "attribution": { + "author_name": "Krista Stevens", + "author_url": "https://baristahustle.com/", + "blog_name": "Barista Hustle", + "blog_url": "https://baristahustle.com", + "avatar_url": "https://discover.files.wordpress.com/2019/05/bh-logo-new-512-1-400x400.png?w=100&h=100&crop=true" + }, + "discover_fp_post_formats": [ + { + "name": "Pick", + "slug": "pick", + "id": 346750 + }, + { + "name": "Site Pick", + "slug": "site-pick", + "id": 308219249 + } + ], + "featured_post_wpcom_data": { + "blog_id": 82609915 + } + }, + "feed_ID": 41325786, + "feed_URL": "http://discover.wordpress.com", + "pseudo_ID": "5344123ea1ee2da1788f11183966d068", + "is_external": false, + "site_name": "Discover", + "site_URL": "https://discover.wordpress.com", + "site_is_private": false, + "featured_media": { + }, + "use_excerpt": false, + "is_following_conversation": false + }, + { + "ID": 37123, + "site_ID": 53424024, + "author": { + "ID": 10183950, + "login": "cherilucas", + "email": false, + "name": "Cheri Lucas Rowlands", + "first_name": "Cheri", + "last_name": "Rowlands", + "nice_name": "cherilucas", + "URL": "http://cherilucasrowlands.com", + "avatar_URL": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/cherilucas", + "site_ID": 9838404, + "has_avatar": true + }, + "date": "2019-05-24T09:00:50-04:00", + "modified": "2019-05-22T17:07:30-04:00", + "title": "Lonely Planet Kids", + "URL": "https://discover.wordpress.com/2019/05/24/lonely-planet-kids/", + "short_URL": "https://wp.me/p3Ca1O-9EL", + "content": "

Lonely Planet Kids — an offshoot of the popular travel guide company –inspires children to be curious about the world. The site features books, activities, family travel posts, and more.

\n", + "excerpt": "

Lonely Planet Kids — an offshoot of the popular travel guide company –inspires children to be curious about the world. The site features books, activities, family travel posts, and more.

\n", + "slug": "lonely-planet-kids", + "guid": "https://discover.wordpress.com/?p=37123", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": false, + "comment_status": "closed", + "pings_open": false, + "ping_status": "closed", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 49, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "12ac818a3de41e3b0cf84fea7efa2592", + "featured_image": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "post_thumbnail": { + "ID": 37125, + "URL": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "guid": "http://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "mime_type": "image/png", + "width": 1434, + "height": 808 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Books": { + "ID": 178, + "name": "Books", + "slug": "books", + "description": "Writing, reviews, resources, and news on books, authors, reading, and publishing.", + "post_count": 233, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:books", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:books/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Education": { + "ID": 1342, + "name": "Education", + "slug": "education", + "description": "Resources across disciplines and perspectives on teaching, learning, and the educational system from educators, teachers, and parents.", + "post_count": 121, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:education", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:education/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Exploration": { + "ID": 7543, + "name": "Exploration", + "slug": "exploration", + "description": "Writing and photography on travel, self-discovery, research, observation, and more.", + "post_count": 147, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:exploration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:exploration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Family": { + "ID": 406, + "name": "Family", + "slug": "family", + "description": "Writing that encompasses aspects of family, including marriage, parenting, childhood, relationships, and ancestry.", + "post_count": 226, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:family", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:family/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Inspiration": { + "ID": 107, + "name": "Inspiration", + "slug": "inspiration", + "description": "Ideas and advice to empower and motivate people – especially bloggers, writers, and creative types – in their personal or professional journeys.", + "post_count": 327, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:inspiration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:inspiration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Parenting": { + "ID": 5309, + "name": "Parenting", + "slug": "parenting", + "description": "Writing and resources on parenting, motherhood, marriage, and family.", + "post_count": 141, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:parenting", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:parenting/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Publishing": { + "ID": 3330, + "name": "Publishing", + "slug": "publishing", + "description": "Writers and editors discussing publishing-industry news, the ins and outs of the literary world, and their own journey to a published book.", + "post_count": 124, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:publishing", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:publishing/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Reading": { + "ID": 1473, + "name": "Reading", + "slug": "reading", + "description": "Posts and book blogs that focus on authors, book reviews, and the pleasures of reading in the digital age.", + "post_count": 143, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:reading", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:reading/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Travel": { + "ID": 200, + "name": "Travel", + "slug": "travel", + "description": "Blogs, online guides, and trip planning resources devoted to travel, exploration, the outdoors, expat life, and global culture.", + "post_count": 247, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:travel", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:travel/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Wanderlust": { + "ID": 13181, + "name": "Wanderlust", + "slug": "wanderlust", + "description": "Stunning travel photography, travel and lifestyle sites, and blog posts on the joys and rewards of exploring new destinations.", + "post_count": 97, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:wanderlust", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:wanderlust/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_tag": { + "activities": { + "ID": 6751, + "name": "activities", + "slug": "activities", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:activities", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:activities/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "children": { + "ID": 1343, + "name": "children", + "slug": "children", + "description": "", + "post_count": 28, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:children", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:children/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "family travel": { + "ID": 421426, + "name": "family travel", + "slug": "family-travel", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:family-travel", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:family-travel/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "imagination": { + "ID": 10906, + "name": "imagination", + "slug": "imagination", + "description": "", + "post_count": 7, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:imagination", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:imagination/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "kids": { + "ID": 3374, + "name": "kids", + "slug": "kids", + "description": "", + "post_count": 14, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:kids", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:kids/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Lonely Planet": { + "ID": 232853, + "name": "Lonely Planet", + "slug": "lonely-planet", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:lonely-planet", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:lonely-planet/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Teaching": { + "ID": 1591, + "name": "Teaching", + "slug": "teaching", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:teaching", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:teaching/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + "activities": { + "ID": 6751, + "name": "activities", + "slug": "activities", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:activities", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:activities/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "activities" + }, + "children": { + "ID": 1343, + "name": "children", + "slug": "children", + "description": "", + "post_count": 28, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:children", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:children/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "children" + }, + "family travel": { + "ID": 421426, + "name": "family travel", + "slug": "family-travel", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:family-travel", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:family-travel/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "family-travel" + }, + "imagination": { + "ID": 10906, + "name": "imagination", + "slug": "imagination", + "description": "", + "post_count": 7, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:imagination", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:imagination/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "imagination" + }, + "kids": { + "ID": 3374, + "name": "kids", + "slug": "kids", + "description": "", + "post_count": 14, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:kids", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:kids/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "kids" + }, + "Lonely Planet": { + "ID": 232853, + "name": "Lonely Planet", + "slug": "lonely-planet", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:lonely-planet", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:lonely-planet/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "lonely-planet" + }, + "Teaching": { + "ID": 1591, + "name": "Teaching", + "slug": "teaching", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:teaching", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:teaching/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "teaching" + } + }, + "categories": { + "Books": { + "ID": 178, + "name": "Books", + "slug": "books", + "description": "Writing, reviews, resources, and news on books, authors, reading, and publishing.", + "post_count": 233, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:books", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:books/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Education": { + "ID": 1342, + "name": "Education", + "slug": "education", + "description": "Resources across disciplines and perspectives on teaching, learning, and the educational system from educators, teachers, and parents.", + "post_count": 121, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:education", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:education/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Exploration": { + "ID": 7543, + "name": "Exploration", + "slug": "exploration", + "description": "Writing and photography on travel, self-discovery, research, observation, and more.", + "post_count": 147, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:exploration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:exploration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Family": { + "ID": 406, + "name": "Family", + "slug": "family", + "description": "Writing that encompasses aspects of family, including marriage, parenting, childhood, relationships, and ancestry.", + "post_count": 226, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:family", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:family/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Inspiration": { + "ID": 107, + "name": "Inspiration", + "slug": "inspiration", + "description": "Ideas and advice to empower and motivate people – especially bloggers, writers, and creative types – in their personal or professional journeys.", + "post_count": 327, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:inspiration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:inspiration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Parenting": { + "ID": 5309, + "name": "Parenting", + "slug": "parenting", + "description": "Writing and resources on parenting, motherhood, marriage, and family.", + "post_count": 141, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:parenting", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:parenting/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Publishing": { + "ID": 3330, + "name": "Publishing", + "slug": "publishing", + "description": "Writers and editors discussing publishing-industry news, the ins and outs of the literary world, and their own journey to a published book.", + "post_count": 124, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:publishing", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:publishing/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Reading": { + "ID": 1473, + "name": "Reading", + "slug": "reading", + "description": "Posts and book blogs that focus on authors, book reviews, and the pleasures of reading in the digital age.", + "post_count": 143, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:reading", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:reading/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Travel": { + "ID": 200, + "name": "Travel", + "slug": "travel", + "description": "Blogs, online guides, and trip planning resources devoted to travel, exploration, the outdoors, expat life, and global culture.", + "post_count": 247, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:travel", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:travel/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Wanderlust": { + "ID": 13181, + "name": "Wanderlust", + "slug": "wanderlust", + "description": "Stunning travel photography, travel and lifestyle sites, and blog posts on the joys and rewards of exploring new destinations.", + "post_count": 97, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:wanderlust", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:wanderlust/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "attachments": { + "37125": { + "ID": 37125, + "URL": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "guid": "http://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "date": "2019-05-10T18:53:26-04:00", + "post_ID": 37123, + "author_ID": 10183950, + "file": "screen-shot-2019-05-10-at-3.53.09-pm.png", + "mime_type": "image/png", + "extension": "png", + "title": "lonely planet kids header", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png?w=150", + "medium": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png?w=315", + "large": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png?w=1220" + }, + "height": 808, + "width": 1434, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37125", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37125/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37123" + } + } + }, + "37126": { + "ID": 37126, + "URL": "https://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png", + "guid": "http://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png", + "date": "2019-05-10T18:53:28-04:00", + "post_ID": 37123, + "author_ID": 10183950, + "file": "lonely-planet-kids-logo.png", + "mime_type": "image/png", + "extension": "png", + "title": "lonely planet kids logo", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png?w=150", + "medium": "https://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png?w=315", + "large": "https://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png?w=400" + }, + "height": 400, + "width": 400, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37126", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37126/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37123" + } + } + } + }, + "attachment_count": 2, + "metadata": [ + { + "id": "129821", + "key": "geo_public", + "value": "0" + }, + { + "id": "129818", + "key": "_thumbnail_id", + "value": "37125" + }, + { + "id": "130369", + "key": "_wpas_done_17926349", + "value": "1" + }, + { + "id": "129822", + "key": "_wpas_mess", + "value": "Lonely Planet Kids (@lpkids) inspires children to be curious about the world. The #PoweredByWordPress site features children's books, activities, family travel resources, and more." + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/53424024/posts/37123", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/37123/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37123/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37123/likes/" + }, + "data": { + "site": { + "ID": 53424024, + "name": "Discover", + "description": "A daily selection of the best content published on WordPress, collected for you by humans who love to read.", + "URL": "https://discover.wordpress.com", + "jetpack": false, + "post_count": 3603, + "subscribers_count": 35192480, + "locale": "en", + "icon": { + "img": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace", + "ico": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace" + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_following": true, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/comments/", + "xmlrpc": "https://discover.wordpress.com/xmlrpc.php" + } + } + } + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "other_URLs": { + }, + "discover_metadata": { + "permalink": "https://www.lonelyplanet.com/kids/", + "attribution": { + "author_name": "Contributors", + "author_url": "https://www.lonelyplanet.com/kids/about/", + "blog_name": "Lonely Planet Kids", + "blog_url": "https://www.lonelyplanet.com", + "avatar_url": "https://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png?w=100&h=100&crop=true" + }, + "discover_fp_post_formats": [ + { + "name": "Pick", + "slug": "pick", + "id": 346750 + }, + { + "name": "Site Pick", + "slug": "site-pick", + "id": 308219249 + } + ] + }, + "feed_ID": 41325786, + "feed_URL": "http://discover.wordpress.com", + "pseudo_ID": "12ac818a3de41e3b0cf84fea7efa2592", + "is_external": false, + "site_name": "Discover", + "site_URL": "https://discover.wordpress.com", + "site_is_private": false, + "featured_media": { + "uri": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "width": 1434, + "height": 808, + "type": "image" + }, + "use_excerpt": false, + "is_following_conversation": false + } + ], + "meta": { + "links": { + "counts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/post-counts/post" + }, + "next_page": "value=2019-04-21T09%3A00%3A08-04%3A00&id=36933", + "wpcom": true + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v13_read_menu.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v13_read_menu.json new file mode 100644 index 000000000000..db84b584799d --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v13_read_menu.json @@ -0,0 +1,250 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.3/read/menu" + }, + "response": { + "status": 200, + "jsonBody": { + "default": { + "following": { + "title": "Followed Sites", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/following" + }, + "discover": { + "title": "Discover", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/sites/53424024/posts" + }, + "liked": { + "title": "My Likes", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/liked" + } + }, + "subscribed": { + "412": { + "ID": "412", + "title": "Video", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/video/posts", + "slug": "video", + "display_name": "video", + "type": "tag" + }, + "678": { + "ID": "678", + "title": "History", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/history/posts", + "slug": "history", + "display_name": "history", + "type": "tag" + }, + "3263": { + "ID": "3263", + "title": "gallery", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/gallery/posts", + "slug": "gallery", + "display_name": "gallery", + "type": "tag" + }, + "239173": { + "ID": "239173", + "title": "gif", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/gif/posts", + "slug": "gif", + "display_name": "gif", + "type": "tag" + } + }, + "recommended": { + "116": { + "ID": 116, + "title": "Religion", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/religion/posts", + "slug": "religion", + "display_name": "religion", + "type": "tag" + }, + "178": { + "ID": 178, + "title": "Books", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/books/posts", + "slug": "books", + "display_name": "books", + "type": "tag" + }, + "200": { + "ID": 200, + "title": "Travel", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/travel/posts", + "slug": "travel", + "display_name": "travel", + "type": "tag" + }, + "376": { + "ID": 376, + "title": "Humor", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/humor/posts", + "slug": "humor", + "display_name": "humor", + "type": "tag" + }, + "406": { + "ID": 406, + "title": "Family", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/family/posts", + "slug": "family", + "display_name": "family", + "type": "tag" + }, + "436": { + "ID": 436, + "title": "Photography", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/photography/posts", + "slug": "photography", + "display_name": "photography", + "type": "tag" + }, + "586": { + "ID": 586, + "title": "Food", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/food/posts", + "slug": "food", + "display_name": "food", + "type": "tag" + }, + "676": { + "ID": 676, + "title": "Websites", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/websites/posts", + "slug": "websites", + "display_name": "websites", + "type": "tag" + }, + "1098": { + "ID": 1098, + "title": "Culture", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/culture/posts", + "slug": "culture", + "display_name": "culture", + "type": "tag" + }, + "2806": { + "ID": 2806, + "title": "Art & Design", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/art-design/posts", + "slug": "art-design", + "display_name": "art-design", + "type": "tag" + }, + "3750": { + "ID": 3750, + "title": "Magazines", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/magazines/posts", + "slug": "magazines", + "display_name": "magazines", + "type": "tag" + }, + "13985": { + "ID": 13985, + "title": "Health & Wellness", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/health-wellness/posts", + "slug": "health-wellness", + "display_name": "health-wellness", + "type": "tag" + }, + "26879": { + "ID": 26879, + "title": "Portfolios", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/portfolios/posts", + "slug": "portfolios", + "display_name": "portfolios", + "type": "tag" + }, + "38881": { + "ID": 38881, + "title": "Science & Nature", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/science-nature/posts", + "slug": "science-nature", + "display_name": "science-nature", + "type": "tag" + }, + "84776": { + "ID": 84776, + "title": "News & Current Events", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/news-current-events/posts", + "slug": "news-current-events", + "display_name": "news-current-events", + "type": "tag" + }, + "258845": { + "ID": 258845, + "title": "Business & Technology", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/business-technology/posts", + "slug": "business-technology", + "display_name": "business-technology", + "type": "tag" + }, + "960817": { + "ID": 960817, + "title": "Fiction & Poetry", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/fiction-poetry/posts", + "slug": "fiction-poetry", + "display_name": "fiction-poetry", + "type": "tag" + }, + "998458": { + "ID": 998458, + "title": "Writing & Blogging", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/writing-blogging/posts", + "slug": "writing-blogging", + "display_name": "writing-blogging", + "type": "tag" + }, + "1446729": { + "ID": 1446729, + "title": "Sports & Gaming", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/sports-gaming/posts", + "slug": "sports-gaming", + "display_name": "sports-gaming", + "type": "tag" + }, + "10016773": { + "ID": 10016773, + "title": "Musings & Personal", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/musings-personal/posts", + "slug": "musings-personal", + "display_name": "musings-personal", + "type": "tag" + }, + "35328892": { + "ID": 35328892, + "title": "Longreads", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/longreads/posts", + "slug": "longreads", + "display_name": "longreads", + "type": "tag" + }, + "63611190": { + "ID": 63611190, + "title": "Crafts & Fashion", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/crafts-fashion/posts", + "slug": "crafts-fashion", + "display_name": "crafts-fashion", + "type": "tag" + }, + "87210105": { + "ID": 87210105, + "title": "Popular Culture & Entertainment", + "URL": "{{request.requestLine.baseUrl}}/rest/v1.2/read/tags/popular-culture-entertainment/posts", + "slug": "popular-culture-entertainment", + "display_name": "popular-culture-entertainment", + "type": "tag" + } + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v2_read_interests.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v2_read_interests.json new file mode 100644 index 000000000000..23247d854d2e --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v2_read_interests.json @@ -0,0 +1,395 @@ +{ + "request": { + "method": "GET", + "urlPath": "/wpcom/v2/read/interests" + }, + "response": { + "status": 200, + "jsonBody": { + "success": true, + "interests": [ + { + "title": "Activism", + "slug": "activism" + }, + { + "title": "Advice", + "slug": "advice" + }, + { + "title": "Adventure", + "slug": "adventure" + }, + { + "title": "Animals", + "slug": "animals" + }, + { + "title": "Architecture", + "slug": "architecture" + }, + { + "title": "Art", + "slug": "art" + }, + { + "title": "Authors", + "slug": "authors" + }, + { + "title": "Baking", + "slug": "baking" + }, + { + "title": "Beauty", + "slug": "beauty" + }, + { + "title": "Beer", + "slug": "beer" + }, + { + "title": "Blogging", + "slug": "blogging" + }, + { + "title": "Books", + "slug": "books" + }, + { + "title": "Business", + "slug": "business" + }, + { + "title": "Camping", + "slug": "camping" + }, + { + "title": "Cars", + "slug": "cars" + }, + { + "title": "Cocktails", + "slug": "cocktails" + }, + { + "title": "Coding", + "slug": "coding" + }, + { + "title": "Comics", + "slug": "comics" + }, + { + "title": "Cooking", + "slug": "cooking" + }, + { + "title": "Community", + "slug": "community" + }, + { + "title": "Comics", + "slug": "comics" + }, + { + "title": "Crafts", + "slug": "crafts" + }, + { + "title": "Creativity", + "slug": "creativity" + }, + { + "title": "Culture", + "slug": "culture" + }, + { + "title": "Current Events", + "slug": "current-events" + }, + { + "title": "Dance", + "slug": "dance" + }, + { + "title": "Decorating", + "slug": "decorating" + }, + { + "title": "Design", + "slug": "design" + }, + { + "title": "Diversity", + "slug": "diversity" + }, + { + "title": "DIY", + "slug": "diy" + }, + { + "title": "Drawing", + "slug": "drawing" + }, + { + "title": "Ecommerce", + "slug": "ecommerce" + }, + { + "title": "Education", + "slug": "education" + }, + { + "title": "Entertainment", + "slug": "entertainment" + }, + { + "title": "Environment", + "slug": "environment" + }, + { + "title": "Family", + "slug": "family" + }, + { + "title": "Farming", + "slug": "farming" + }, + { + "title": "Fashion", + "slug": "fashion" + }, + { + "title": "Fiction", + "slug": "fiction" + }, + { + "title": "Finance", + "slug": "finance" + }, + { + "title": "Fitness", + "slug": "fitness" + }, + { + "title": "Food", + "slug": "food" + }, + { + "title": "Gaming", + "slug": "gaming" + }, + { + "title": "Gardening", + "slug": "gardening" + }, + { + "title": "Health", + "slug": "health" + }, + { + "title": "History", + "slug": "history" + }, + { + "title": "Homeschooling", + "slug": "homeschooling" + }, + { + "title": "Humor", + "slug": "humor" + }, + { + "title": "Identity", + "slug": "identity" + }, + { + "title": "Illustration", + "slug": "illustration" + }, + { + "title": "Inspiration", + "slug": "inspiration" + }, + { + "title": "Internet", + "slug": "internet" + }, + { + "title": "Journalism", + "slug": "journalism" + }, + { + "title": "Kids", + "slug": "kids" + }, + { + "title": "Language", + "slug": "language" + }, + { + "title": "LGBTQ", + "slug": "lgbtq" + }, + { + "title": "Lifestyle", + "slug": "lifestyle" + }, + { + "title": "Literature", + "slug": "literature" + }, + { + "title": "Mathematics", + "slug": "mathematics" + }, + { + "title": "Media", + "slug": "media" + }, + { + "title": "Mental Health", + "slug": "mental-health" + }, + { + "title": "Military", + "slug": "military" + }, + { + "title": "Movies", + "slug": "movies" + }, + { + "title": "Music", + "slug": "music" + }, + { + "title": "Nature", + "slug": "nature" + }, + { + "title": "Nonfiction", + "slug": "nonfiction" + }, + { + "title": "Nostalgia", + "slug": "nostalgia" + }, + { + "title": "Outdoors", + "slug": "outdoors" + }, + { + "title": "Parenting", + "slug": "parenting" + }, + { + "title": "Pets", + "slug": "pets" + }, + { + "title": "Photography", + "slug": "photography" + }, + { + "title": "Poetry", + "slug": "poetry" + }, + { + "title": "Politics", + "slug": "politics" + }, + { + "title": "Publishing", + "slug": "publishing" + }, + { + "title": "Reading", + "slug": "reading" + }, + { + "title": "Recipes", + "slug": "recipes" + }, + { + "title": "Relationships", + "slug": "relationships" + }, + { + "title": "Religion", + "slug": "religion" + }, + { + "title": "Science", + "slug": "science" + }, + { + "title": "Self-Improvement", + "slug": "self-improvement" + }, + { + "title": "Self-Publishing", + "slug": "self-publishing" + }, + { + "title": "Sewing", + "slug": "sewing" + }, + { + "title": "Social Media", + "slug": "social-media" + }, + { + "title": "Sports", + "slug": "sports" + }, + { + "title": "Teaching", + "slug": "teaching" + }, + { + "title": "Technology", + "slug": "technology" + }, + { + "title": "Television", + "slug": "television" + }, + { + "title": "Travel", + "slug": "travel" + }, + { + "title": "Weddings", + "slug": "weddings" + }, + { + "title": "Wellness", + "slug": "wellness" + }, + { + "title": "Wine", + "slug": "wine" + }, + { + "title": "WordPress", + "slug": "wordpress" + }, + { + "title": "Work", + "slug": "work" + }, + { + "title": "Writing", + "slug": "writing" + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v2_read_tags_cards.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v2_read_tags_cards.json new file mode 100644 index 000000000000..9ec802157b54 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/reader/rest_v2_read_tags_cards.json @@ -0,0 +1,3946 @@ +{ + "request": { + "method": "GET", + "urlPath": "/wpcom/v2/read/tags/cards" + }, + "response": { + "status": 200, + "jsonBody": { + "success": true, + "tags": [ + "photography" + ], + "sort": "popularity", + "lang": "en", + "page": 1, + "refresh": 1, + "cards": [ + { + "type": "interests_you_may_like", + "data": [ + { + "slug": "blogging", + "title": "Blogging", + "score": 278 + }, + { + "slug": "travel", + "title": "Travel", + "score": 251 + }, + { + "slug": "photos", + "title": "Photos", + "score": 173 + }, + { + "slug": "technology", + "title": "Technology", + "score": 139 + } + ] + }, + { + "type": "post", + "data": { + "ID": 37222, + "site_ID": 53424024, + "author": { + "ID": 47411601, + "login": "benhuberman", + "email": false, + "name": "Ben Huberman", + "first_name": "Ben", + "last_name": "Huberman", + "nice_name": "benhuberman", + "URL": "https://benz.blog/", + "avatar_URL": "https://0.gravatar.com/avatar/663dcd498e8c5f255bfb230a7ba07678?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/benhuberman", + "site_ID": 122910962, + "has_avatar": true + }, + "date": "{{now offset='-2 hours'}}", + "modified": "{{now offset='-2 hours'}}", + "title": "Kelsey Montague Art", + "URL": "https://discover.wordpress.com/2019/05/27/kelsey-montague-art/", + "short_URL": "https://wp.me/p3Ca1O-9Gm", + "content": "

Explore mural artist Kelsey Montague’s work, stay up-to-date on her latest projects, and shop for prints on her Anything Is Possible-featured website. 

\r\n\r\n\r\n

\r\n", + "excerpt": "

Explore mural artist Kelsey Montague’s work, stay up-to-date on her latest projects, and shop for prints on her Anything Is Possible-featured website. 

\n", + "slug": "kelsey-montague-art", + "guid": "https://discover.wordpress.com/?p=37222", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": false, + "comment_status": "closed", + "pings_open": false, + "ping_status": "closed", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 33, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "b06fd6b4fd1b95644d71981f34f747f8", + "featured_image": "https://discover.files.wordpress.com/2019/05/img_3760.jpg", + "post_thumbnail": { + "ID": 37226, + "URL": "https://discover.files.wordpress.com/2019/05/img_3760.jpg", + "guid": "http://discover.files.wordpress.com/2019/05/img_3760.jpg", + "mime_type": "image/jpeg", + "width": 1280, + "height": 1706 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Art": { + "ID": 177, + "name": "Art", + "slug": "art", + "description": "Artists' sites, powerful artwork in multiple genres in a variety of mediums, and news from the art world.", + "post_count": 370, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Culture": { + "ID": 1098, + "name": "Culture", + "slug": "culture", + "description": "A curated collection of WordPress sites on society, culture in all its forms, and diverse art forms from around the world.", + "post_count": 414, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:culture", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:culture/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Diversity": { + "ID": 47458, + "name": "Diversity", + "slug": "diversity", + "description": "Blogs, stories, and web projects that highlight activists working towards greater diversity in culture, politics, and the business world.", + "post_count": 131, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:diversity", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:diversity/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Feminism": { + "ID": 553, + "name": "Feminism", + "slug": "feminism", + "description": "Magazines, collaborative websites, and feminist blogs that cover important issues in politics, culture, diversity, and the fight for gender equality.", + "post_count": 99, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:feminism", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:feminism/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Inspiration": { + "ID": 107, + "name": "Inspiration", + "slug": "inspiration", + "description": "Ideas and advice to empower and motivate people – especially bloggers, writers, and creative types – in their personal or professional journeys.", + "post_count": 327, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:inspiration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:inspiration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Popular Culture": { + "ID": 2437, + "name": "Popular Culture", + "slug": "popular-culture", + "description": "The web's leading pop culture magazines and blogs, covering music, film, television, gaming, and more.", + "post_count": 198, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:popular-culture", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:popular-culture/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_tag": { + "AnythingIsPossible": { + "ID": 109479402, + "name": "AnythingIsPossible", + "slug": "anythingispossible", + "description": "", + "post_count": 15, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:anythingispossible", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:anythingispossible/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "mural": { + "ID": 159763, + "name": "mural", + "slug": "mural", + "description": "", + "post_count": 4, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:mural", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:mural/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "mural art": { + "ID": 5762997, + "name": "mural art", + "slug": "mural-art", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:mural-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:mural-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "murals": { + "ID": 135373, + "name": "murals", + "slug": "murals", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:murals", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:murals/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "street art": { + "ID": 57447, + "name": "street art", + "slug": "street-art", + "description": "", + "post_count": 18, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:street-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:street-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "urban art": { + "ID": 28923, + "name": "urban art", + "slug": "urban-art", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:urban-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:urban-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + "AnythingIsPossible": { + "ID": 109479402, + "name": "AnythingIsPossible", + "slug": "anythingispossible", + "description": "", + "post_count": 15, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:anythingispossible", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:anythingispossible/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "anythingispossible" + }, + "mural": { + "ID": 159763, + "name": "mural", + "slug": "mural", + "description": "", + "post_count": 4, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:mural", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:mural/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "mural" + }, + "mural art": { + "ID": 5762997, + "name": "mural art", + "slug": "mural-art", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:mural-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:mural-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "mural-art" + }, + "murals": { + "ID": 135373, + "name": "murals", + "slug": "murals", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:murals", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:murals/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "murals" + }, + "street art": { + "ID": 57447, + "name": "street art", + "slug": "street-art", + "description": "", + "post_count": 18, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:street-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:street-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "street-art" + }, + "urban art": { + "ID": 28923, + "name": "urban art", + "slug": "urban-art", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:urban-art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:urban-art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "urban-art" + } + }, + "categories": { + "Art": { + "ID": 177, + "name": "Art", + "slug": "art", + "description": "Artists' sites, powerful artwork in multiple genres in a variety of mediums, and news from the art world.", + "post_count": 370, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:art", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:art/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Culture": { + "ID": 1098, + "name": "Culture", + "slug": "culture", + "description": "A curated collection of WordPress sites on society, culture in all its forms, and diverse art forms from around the world.", + "post_count": 414, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:culture", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:culture/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Diversity": { + "ID": 47458, + "name": "Diversity", + "slug": "diversity", + "description": "Blogs, stories, and web projects that highlight activists working towards greater diversity in culture, politics, and the business world.", + "post_count": 131, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:diversity", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:diversity/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Feminism": { + "ID": 553, + "name": "Feminism", + "slug": "feminism", + "description": "Magazines, collaborative websites, and feminist blogs that cover important issues in politics, culture, diversity, and the fight for gender equality.", + "post_count": 99, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:feminism", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:feminism/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Inspiration": { + "ID": 107, + "name": "Inspiration", + "slug": "inspiration", + "description": "Ideas and advice to empower and motivate people – especially bloggers, writers, and creative types – in their personal or professional journeys.", + "post_count": 327, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:inspiration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:inspiration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Popular Culture": { + "ID": 2437, + "name": "Popular Culture", + "slug": "popular-culture", + "description": "The web's leading pop culture magazines and blogs, covering music, film, television, gaming, and more.", + "post_count": 198, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:popular-culture", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:popular-culture/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "attachments": { + "37226": { + "ID": 37226, + "URL": "https://discover.files.wordpress.com/2019/05/img_3760.jpg", + "guid": "http://discover.files.wordpress.com/2019/05/img_3760.jpg", + "date": "2019-05-21T23:48:08-04:00", + "post_ID": 37222, + "author_ID": 47411601, + "file": "img_3760.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "IMG_3760", + "caption": "", + "description": "", + "alt": "Kelsey Montague mural art", + "thumbnails": { + "thumbnail": "https://discover.files.wordpress.com/2019/05/img_3760.jpg?w=113", + "medium": "https://discover.files.wordpress.com/2019/05/img_3760.jpg?w=311", + "large": "https://discover.files.wordpress.com/2019/05/img_3760.jpg?w=768" + }, + "height": 1706, + "width": 1280, + "exif": { + "aperture": "1.8", + "credit": "", + "camera": "iPhone 8 Plus", + "caption": "", + "created_timestamp": "1536421883", + "copyright": "", + "focal_length": "3.99", + "iso": "20", + "shutter_speed": "0.0014836795252226", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37226", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37226/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37222" + } + } + } + }, + "attachment_count": 1, + "metadata": [ + { + "id": "130242", + "key": "geo_public", + "value": "0" + }, + { + "id": "130230", + "key": "_thumbnail_id", + "value": "37226" + }, + { + "id": "130401", + "key": "_wpas_done_17927786", + "value": "1" + }, + { + "id": "130238", + "key": "_wpas_mess", + "value": "Visit @kelsmontagueart's website - featured on @wordpressdotcom's #AnythingIsPossible list - for inspiration, prints, and updates on Kelsey's latest mural projects." + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/53424024/posts/37222", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/37222/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37222/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37222/likes/" + }, + "data": { + "site": { + "ID": 53424024, + "name": "Discover", + "description": "A daily selection of the best content published on WordPress, collected for you by humans who love to read.", + "URL": "https://discover.wordpress.com", + "jetpack": false, + "post_count": 3603, + "subscribers_count": 35192480, + "locale": "en", + "icon": { + "img": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace", + "ico": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace" + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_following": true, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/comments/", + "xmlrpc": "https://discover.wordpress.com/xmlrpc.php" + } + } + } + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "other_URLs": { + }, + "discover_metadata": { + "permalink": "https://kelseymontagueart.com/", + "attribution": { + "author_name": "Krista Stevens", + "author_url": "https://kelseymontagueart.com/", + "blog_name": "Kelsey Montague Art", + "blog_url": "https://kelseymontagueart.com", + "avatar_url": "https://discover.files.wordpress.com/2019/05/kelsey-thumbnail.png?w=100&h=100&crop=true" + }, + "discover_fp_post_formats": [ + { + "name": "Pick", + "slug": "pick", + "id": 346750 + }, + { + "name": "Site Pick", + "slug": "site-pick", + "id": 308219249 + } + ], + "featured_post_wpcom_data": { + "blog_id": 161169196 + } + }, + "feed_ID": 41325786, + "feed_URL": "http://discover.wordpress.com", + "pseudo_ID": "b06fd6b4fd1b95644d71981f34f747f8", + "is_external": false, + "site_name": "Discover", + "site_URL": "https://discover.wordpress.com", + "site_is_private": false, + "featured_media": { + }, + "use_excerpt": false, + "is_following_conversation": false + } + }, + { + "type": "post", + "data": { + "ID": 37189, + "site_ID": 53424024, + "author": { + "ID": 10183950, + "login": "cherilucas", + "email": false, + "name": "Cheri Lucas Rowlands", + "first_name": "Cheri", + "last_name": "Rowlands", + "nice_name": "cherilucas", + "URL": "http://cherilucasrowlands.com", + "avatar_URL": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/cherilucas", + "site_ID": 9838404, + "has_avatar": true + }, + "date": "{{now offset='-1 days'}}", + "modified": "{{now offset='-1 days'}}", + "title": "The Radical Notion of Not Letting Work Define You", + "URL": "https://discover.wordpress.com/2019/05/26/the-radical-notion-of-not-letting-work-define-you/", + "short_URL": "https://wp.me/p3Ca1O-9FP", + "content": "

“Just because something can’t be a career doesn’t have to mean that it can’t be part of your life and identity.” At Man Repeller, Molly Conway muses on imposter syndrome, work and identity, and being a playwright.

\n", + "excerpt": "

“Just because something can’t be a career doesn’t have to mean that it can’t be part of your life and identity.” At Man Repeller, Molly Conway muses on imposter syndrome, work and identity, and being a playwright.

\n", + "slug": "the-radical-notion-of-not-letting-work-define-you", + "guid": "https://discover.wordpress.com/?p=37189", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": false, + "comment_status": "closed", + "pings_open": false, + "ping_status": "closed", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 102, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "eedec98542f8cbec7df4d4c608c847ef", + "featured_image": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "post_thumbnail": { + "ID": 37191, + "URL": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "guid": "http://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "mime_type": "image/png", + "width": 1482, + "height": 988 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Identity": { + "ID": 10679, + "name": "Identity", + "slug": "identity", + "description": "Engaging conversations and writing around the topics of identity, diversity, and the search for authenticity.", + "post_count": 166, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:identity", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:identity/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Personal Essay": { + "ID": 253221, + "name": "Personal Essay", + "slug": "personal-essay", + "description": "Personal and introspective essays and longform from new and established writers and authors.", + "post_count": 156, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:personal-essay", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:personal-essay/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Personal Musings": { + "ID": 5316, + "name": "Personal Musings", + "slug": "personal-musings", + "description": "Introspective and self-reflective writing in various formats.", + "post_count": 437, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:personal-musings", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:personal-musings/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Work": { + "ID": 131, + "name": "Work", + "slug": "work", + "description": "From tech to farming, posts and websites dedicated to the many facets of work, work-life balance, and social inequality.", + "post_count": 80, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:work", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:work/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Writing": { + "ID": 349, + "name": "Writing", + "slug": "writing", + "description": "Writing, advice, and commentary on the act and process of writing, blogging, and publishing.", + "post_count": 437, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:writing", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:writing/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_tag": { + "imposter syndrome": { + "ID": 392126, + "name": "imposter syndrome", + "slug": "imposter-syndrome", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:imposter-syndrome", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:imposter-syndrome/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "playwright": { + "ID": 160393, + "name": "playwright", + "slug": "playwright", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:playwright", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:playwright/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "PoweredByWordPress": { + "ID": 76162589, + "name": "PoweredByWordPress", + "slug": "poweredbywordpress", + "description": "", + "post_count": 42, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:poweredbywordpress", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:poweredbywordpress/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "writers": { + "ID": 16761, + "name": "writers", + "slug": "writers", + "description": "", + "post_count": 70, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:writers", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:writers/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + "imposter syndrome": { + "ID": 392126, + "name": "imposter syndrome", + "slug": "imposter-syndrome", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:imposter-syndrome", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:imposter-syndrome/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "imposter-syndrome" + }, + "playwright": { + "ID": 160393, + "name": "playwright", + "slug": "playwright", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:playwright", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:playwright/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "playwright" + }, + "PoweredByWordPress": { + "ID": 76162589, + "name": "PoweredByWordPress", + "slug": "poweredbywordpress", + "description": "", + "post_count": 42, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:poweredbywordpress", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:poweredbywordpress/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "poweredbywordpress" + }, + "writers": { + "ID": 16761, + "name": "writers", + "slug": "writers", + "description": "", + "post_count": 70, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:writers", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:writers/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "writers" + } + }, + "categories": { + "Identity": { + "ID": 10679, + "name": "Identity", + "slug": "identity", + "description": "Engaging conversations and writing around the topics of identity, diversity, and the search for authenticity.", + "post_count": 166, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:identity", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:identity/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Personal Essay": { + "ID": 253221, + "name": "Personal Essay", + "slug": "personal-essay", + "description": "Personal and introspective essays and longform from new and established writers and authors.", + "post_count": 156, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:personal-essay", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:personal-essay/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Personal Musings": { + "ID": 5316, + "name": "Personal Musings", + "slug": "personal-musings", + "description": "Introspective and self-reflective writing in various formats.", + "post_count": 437, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:personal-musings", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:personal-musings/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Work": { + "ID": 131, + "name": "Work", + "slug": "work", + "description": "From tech to farming, posts and websites dedicated to the many facets of work, work-life balance, and social inequality.", + "post_count": 80, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:work", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:work/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Writing": { + "ID": 349, + "name": "Writing", + "slug": "writing", + "description": "Writing, advice, and commentary on the act and process of writing, blogging, and publishing.", + "post_count": 437, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:writing", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:writing/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "attachments": { + "37191": { + "ID": 37191, + "URL": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "guid": "http://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "date": "2019-05-20T14:50:23-04:00", + "post_ID": 37189, + "author_ID": 10183950, + "file": "screen-shot-2019-05-20-at-11.50.07-am.png", + "mime_type": "image/png", + "extension": "png", + "title": "man repeller header image", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png?w=150", + "medium": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png?w=315", + "large": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png?w=1220" + }, + "height": 988, + "width": 1482, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37191", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37191/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37189" + } + } + }, + "37192": { + "ID": 37192, + "URL": "https://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg", + "guid": "http://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg", + "date": "2019-05-20T14:50:47-04:00", + "post_ID": 37189, + "author_ID": 10183950, + "file": "man-repeller-logo.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "man repeller logo", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg?w=150", + "medium": "https://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg?w=315", + "large": "https://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg?w=400" + }, + "height": 400, + "width": 400, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37192", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37192/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37189" + } + } + } + }, + "attachment_count": 2, + "metadata": [ + { + "id": "130145", + "key": "geo_public", + "value": "0" + }, + { + "id": "130142", + "key": "_thumbnail_id", + "value": "37191" + }, + { + "id": "130392", + "key": "_wpas_done_17926349", + "value": "1" + }, + { + "id": "130146", + "key": "_wpas_mess", + "value": "\"Just because something can’t be a career doesn’t have to mean that it can’t be part of your life and identity.\" Molly Conway muses on imposter syndrome, work and identity, and being a playwright. (@ManRepeller)" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/53424024/posts/37189", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/37189/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37189/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37189/likes/" + }, + "data": { + "site": { + "ID": 53424024, + "name": "Discover", + "description": "A daily selection of the best content published on WordPress, collected for you by humans who love to read.", + "URL": "https://discover.wordpress.com", + "jetpack": false, + "post_count": 3603, + "subscribers_count": 35192480, + "locale": "en", + "icon": { + "img": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace", + "ico": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace" + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_following": true, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/comments/", + "xmlrpc": "https://discover.wordpress.com/xmlrpc.php" + } + } + } + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "other_URLs": { + }, + "discover_metadata": { + "permalink": "https://www.manrepeller.com/2019/05/work-identity.html", + "attribution": { + "author_name": "Molly Conway", + "author_url": "https://www.manrepeller.com/author/molly-conway", + "blog_name": "Man Repeller", + "blog_url": "https://www.manrepeller.com", + "avatar_url": "https://discover.files.wordpress.com/2019/05/man-repeller-logo.jpg?w=100&h=100&crop=true" + }, + "discover_fp_post_formats": [ + { + "name": "Pick", + "slug": "pick", + "id": 346750 + }, + { + "name": "Standard Pick", + "slug": "standard-pick", + "id": 337879995 + } + ], + "featured_post_wpcom_data": { + "blog_id": 61780023 + } + }, + "feed_ID": 41325786, + "feed_URL": "http://discover.wordpress.com", + "pseudo_ID": "eedec98542f8cbec7df4d4c608c847ef", + "is_external": false, + "site_name": "Discover", + "site_URL": "https://discover.wordpress.com", + "site_is_private": false, + "featured_media": { + "uri": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-20-at-11.50.07-am.png", + "width": 1482, + "height": 988, + "type": "image" + }, + "use_excerpt": false, + "is_following_conversation": false + } + }, + { + "type": "post", + "data": { + "ID": 37205, + "site_ID": 53424024, + "author": { + "ID": 47411601, + "login": "benhuberman", + "email": false, + "name": "Ben Huberman", + "first_name": "Ben", + "last_name": "Huberman", + "nice_name": "benhuberman", + "URL": "https://benz.blog/", + "avatar_URL": "https://0.gravatar.com/avatar/663dcd498e8c5f255bfb230a7ba07678?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/benhuberman", + "site_ID": 122910962, + "has_avatar": true + }, + "date": "2019-05-25T09:00:36-04:00", + "modified": "2019-05-21T23:45:15-04:00", + "title": "Barista Hustle", + "URL": "https://discover.wordpress.com/2019/05/25/barista-hustle/", + "short_URL": "https://wp.me/p3Ca1O-9G5", + "content": "\n

The team at Barista Hustle, a leading coffee-education hub, creates resources and shares their extensive knowledge on topics ranging from cutting-edge gear to latte art.

\n", + "excerpt": "

The team at Barista Hustle, a leading coffee-education hub, creates resources and shares their extensive knowledge on topics ranging from cutting-edge gear to latte art.

\n", + "slug": "barista-hustle", + "guid": "https://discover.wordpress.com/?p=37205", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": false, + "comment_status": "closed", + "pings_open": false, + "ping_status": "closed", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 58, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "5344123ea1ee2da1788f11183966d068", + "featured_image": "https://discover.files.wordpress.com/2019/05/bh-home-header-element-wide-final.jpg", + "post_thumbnail": { + "ID": 37206, + "URL": "https://discover.files.wordpress.com/2019/05/bh-home-header-element-wide-final.jpg", + "guid": "http://discover.files.wordpress.com/2019/05/bh-home-header-element-wide-final.jpg", + "mime_type": "image/jpeg", + "width": 2800, + "height": 1500 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Business": { + "ID": 179, + "name": "Business", + "slug": "business", + "description": "Small business and ecommerce resources, writing for professionals, and commentary on business, economics, and related topics.", + "post_count": 88, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:business", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:business/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Education": { + "ID": 1342, + "name": "Education", + "slug": "education", + "description": "Resources across disciplines and perspectives on teaching, learning, and the educational system from educators, teachers, and parents.", + "post_count": 121, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:education", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:education/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Food": { + "ID": 586, + "name": "Food", + "slug": "food", + "description": "Recipes, writing on food and culinary culture, and food photography or visual art.", + "post_count": 215, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:food", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:food/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Lifestyle": { + "ID": 278, + "name": "Lifestyle", + "slug": "lifestyle", + "description": "Sites devoted to fashion and beauty, interior design, travel, alternative ways of living, and more.", + "post_count": 127, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:lifestyle", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:lifestyle/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Work": { + "ID": 131, + "name": "Work", + "slug": "work", + "description": "From tech to farming, posts and websites dedicated to the many facets of work, work-life balance, and social inequality.", + "post_count": 80, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:work", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:work/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_tag": { + "Baristas": { + "ID": 831444, + "name": "Baristas", + "slug": "baristas", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:baristas", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:baristas/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "business blog": { + "ID": 287435, + "name": "business blog", + "slug": "business-blog", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:business-blog", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:business-blog/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "coffee": { + "ID": 16166, + "name": "coffee", + "slug": "coffee", + "description": "", + "post_count": 10, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:coffee", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:coffee/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "ecommerce": { + "ID": 11160, + "name": "ecommerce", + "slug": "ecommerce", + "description": "", + "post_count": 10, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:ecommerce", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:ecommerce/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Small Business": { + "ID": 10585, + "name": "Small Business", + "slug": "small-business", + "description": "", + "post_count": 37, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:small-business", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:small-business/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + "Baristas": { + "ID": 831444, + "name": "Baristas", + "slug": "baristas", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:baristas", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:baristas/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "baristas" + }, + "business blog": { + "ID": 287435, + "name": "business blog", + "slug": "business-blog", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:business-blog", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:business-blog/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "business-blog" + }, + "coffee": { + "ID": 16166, + "name": "coffee", + "slug": "coffee", + "description": "", + "post_count": 10, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:coffee", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:coffee/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "coffee" + }, + "ecommerce": { + "ID": 11160, + "name": "ecommerce", + "slug": "ecommerce", + "description": "", + "post_count": 10, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:ecommerce", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:ecommerce/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "ecommerce" + }, + "Small Business": { + "ID": 10585, + "name": "Small Business", + "slug": "small-business", + "description": "", + "post_count": 37, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:small-business", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:small-business/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "small-business" + } + }, + "categories": { + "Business": { + "ID": 179, + "name": "Business", + "slug": "business", + "description": "Small business and ecommerce resources, writing for professionals, and commentary on business, economics, and related topics.", + "post_count": 88, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:business", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:business/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Education": { + "ID": 1342, + "name": "Education", + "slug": "education", + "description": "Resources across disciplines and perspectives on teaching, learning, and the educational system from educators, teachers, and parents.", + "post_count": 121, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:education", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:education/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Food": { + "ID": 586, + "name": "Food", + "slug": "food", + "description": "Recipes, writing on food and culinary culture, and food photography or visual art.", + "post_count": 215, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:food", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:food/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Lifestyle": { + "ID": 278, + "name": "Lifestyle", + "slug": "lifestyle", + "description": "Sites devoted to fashion and beauty, interior design, travel, alternative ways of living, and more.", + "post_count": 127, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:lifestyle", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:lifestyle/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Work": { + "ID": 131, + "name": "Work", + "slug": "work", + "description": "From tech to farming, posts and websites dedicated to the many facets of work, work-life balance, and social inequality.", + "post_count": 80, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:work", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:work/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + { + "id": "130216", + "key": "geo_public", + "value": "0" + }, + { + "id": "130206", + "key": "_thumbnail_id", + "value": "37206" + }, + { + "id": "130379", + "key": "_wpas_done_17927786", + "value": "1" + }, + { + "id": "130212", + "key": "_wpas_mess", + "value": "Whether you're a coffee professional, an aspiring latte artist, or just looking to improve your next cup, check out the resources and courses @BaristaHustle, a #PoweredByWordPress website:" + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/53424024/posts/37205", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/37205/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37205/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37205/likes/" + }, + "data": { + "site": { + "ID": 53424024, + "name": "Discover", + "description": "A daily selection of the best content published on WordPress, collected for you by humans who love to read.", + "URL": "https://discover.wordpress.com", + "jetpack": false, + "post_count": 3603, + "subscribers_count": 35192480, + "locale": "en", + "icon": { + "img": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace", + "ico": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace" + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_following": true, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/comments/", + "xmlrpc": "https://discover.wordpress.com/xmlrpc.php" + } + } + } + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "other_URLs": { + }, + "discover_metadata": { + "permalink": "https://baristahustle.com/", + "attribution": { + "author_name": "Krista Stevens", + "author_url": "https://baristahustle.com/", + "blog_name": "Barista Hustle", + "blog_url": "https://baristahustle.com", + "avatar_url": "https://discover.files.wordpress.com/2019/05/bh-logo-new-512-1-400x400.png?w=100&h=100&crop=true" + }, + "discover_fp_post_formats": [ + { + "name": "Pick", + "slug": "pick", + "id": 346750 + }, + { + "name": "Site Pick", + "slug": "site-pick", + "id": 308219249 + } + ], + "featured_post_wpcom_data": { + "blog_id": 82609915 + } + }, + "feed_ID": 41325786, + "feed_URL": "http://discover.wordpress.com", + "pseudo_ID": "5344123ea1ee2da1788f11183966d068", + "is_external": false, + "site_name": "Discover", + "site_URL": "https://discover.wordpress.com", + "site_is_private": false, + "featured_media": { + }, + "use_excerpt": false, + "is_following_conversation": false + } + }, + { + "type": "post", + "data": { + "ID": 37123, + "site_ID": 53424024, + "author": { + "ID": 10183950, + "login": "cherilucas", + "email": false, + "name": "Cheri Lucas Rowlands", + "first_name": "Cheri", + "last_name": "Rowlands", + "nice_name": "cherilucas", + "URL": "http://cherilucasrowlands.com", + "avatar_URL": "https://0.gravatar.com/avatar/36207d4c7c014b0999b995ca3971d383?s=96&d=identicon&r=G", + "profile_URL": "http://en.gravatar.com/cherilucas", + "site_ID": 9838404, + "has_avatar": true + }, + "date": "2019-05-24T09:00:50-04:00", + "modified": "2019-05-22T17:07:30-04:00", + "title": "Lonely Planet Kids", + "URL": "https://discover.wordpress.com/2019/05/24/lonely-planet-kids/", + "short_URL": "https://wp.me/p3Ca1O-9EL", + "content": "

Lonely Planet Kids — an offshoot of the popular travel guide company –inspires children to be curious about the world. The site features books, activities, family travel posts, and more.

\n", + "excerpt": "

Lonely Planet Kids — an offshoot of the popular travel guide company –inspires children to be curious about the world. The site features books, activities, family travel posts, and more.

\n", + "slug": "lonely-planet-kids", + "guid": "https://discover.wordpress.com/?p=37123", + "status": "publish", + "sticky": false, + "password": "", + "parent": false, + "type": "post", + "discussion": { + "comments_open": false, + "comment_status": "closed", + "pings_open": false, + "ping_status": "closed", + "comment_count": 0 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 49, + "i_like": false, + "is_reblogged": false, + "is_following": true, + "global_ID": "12ac818a3de41e3b0cf84fea7efa2592", + "featured_image": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "post_thumbnail": { + "ID": 37125, + "URL": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "guid": "http://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "mime_type": "image/png", + "width": 1434, + "height": 808 + }, + "format": "standard", + "geo": false, + "menu_order": 0, + "page_template": "", + "publicize_URLs": [ + + ], + "terms": { + "category": { + "Books": { + "ID": 178, + "name": "Books", + "slug": "books", + "description": "Writing, reviews, resources, and news on books, authors, reading, and publishing.", + "post_count": 233, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:books", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:books/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Education": { + "ID": 1342, + "name": "Education", + "slug": "education", + "description": "Resources across disciplines and perspectives on teaching, learning, and the educational system from educators, teachers, and parents.", + "post_count": 121, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:education", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:education/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Exploration": { + "ID": 7543, + "name": "Exploration", + "slug": "exploration", + "description": "Writing and photography on travel, self-discovery, research, observation, and more.", + "post_count": 147, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:exploration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:exploration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Family": { + "ID": 406, + "name": "Family", + "slug": "family", + "description": "Writing that encompasses aspects of family, including marriage, parenting, childhood, relationships, and ancestry.", + "post_count": 226, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:family", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:family/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Inspiration": { + "ID": 107, + "name": "Inspiration", + "slug": "inspiration", + "description": "Ideas and advice to empower and motivate people – especially bloggers, writers, and creative types – in their personal or professional journeys.", + "post_count": 327, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:inspiration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:inspiration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Parenting": { + "ID": 5309, + "name": "Parenting", + "slug": "parenting", + "description": "Writing and resources on parenting, motherhood, marriage, and family.", + "post_count": 141, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:parenting", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:parenting/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Publishing": { + "ID": 3330, + "name": "Publishing", + "slug": "publishing", + "description": "Writers and editors discussing publishing-industry news, the ins and outs of the literary world, and their own journey to a published book.", + "post_count": 124, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:publishing", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:publishing/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Reading": { + "ID": 1473, + "name": "Reading", + "slug": "reading", + "description": "Posts and book blogs that focus on authors, book reviews, and the pleasures of reading in the digital age.", + "post_count": 143, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:reading", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:reading/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Travel": { + "ID": 200, + "name": "Travel", + "slug": "travel", + "description": "Blogs, online guides, and trip planning resources devoted to travel, exploration, the outdoors, expat life, and global culture.", + "post_count": 247, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:travel", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:travel/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Wanderlust": { + "ID": 13181, + "name": "Wanderlust", + "slug": "wanderlust", + "description": "Stunning travel photography, travel and lifestyle sites, and blog posts on the joys and rewards of exploring new destinations.", + "post_count": 97, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:wanderlust", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:wanderlust/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_tag": { + "activities": { + "ID": 6751, + "name": "activities", + "slug": "activities", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:activities", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:activities/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "children": { + "ID": 1343, + "name": "children", + "slug": "children", + "description": "", + "post_count": 28, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:children", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:children/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "family travel": { + "ID": 421426, + "name": "family travel", + "slug": "family-travel", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:family-travel", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:family-travel/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "imagination": { + "ID": 10906, + "name": "imagination", + "slug": "imagination", + "description": "", + "post_count": 7, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:imagination", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:imagination/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "kids": { + "ID": 3374, + "name": "kids", + "slug": "kids", + "description": "", + "post_count": 14, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:kids", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:kids/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Lonely Planet": { + "ID": 232853, + "name": "Lonely Planet", + "slug": "lonely-planet", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:lonely-planet", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:lonely-planet/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Teaching": { + "ID": 1591, + "name": "Teaching", + "slug": "teaching", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:teaching", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:teaching/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "post_format": { + }, + "mentions": { + } + }, + "tags": { + "activities": { + "ID": 6751, + "name": "activities", + "slug": "activities", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:activities", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:activities/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "activities" + }, + "children": { + "ID": 1343, + "name": "children", + "slug": "children", + "description": "", + "post_count": 28, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:children", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:children/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "children" + }, + "family travel": { + "ID": 421426, + "name": "family travel", + "slug": "family-travel", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:family-travel", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:family-travel/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "family-travel" + }, + "imagination": { + "ID": 10906, + "name": "imagination", + "slug": "imagination", + "description": "", + "post_count": 7, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:imagination", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:imagination/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "imagination" + }, + "kids": { + "ID": 3374, + "name": "kids", + "slug": "kids", + "description": "", + "post_count": 14, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:kids", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:kids/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "kids" + }, + "Lonely Planet": { + "ID": 232853, + "name": "Lonely Planet", + "slug": "lonely-planet", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:lonely-planet", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:lonely-planet/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "lonely-planet" + }, + "Teaching": { + "ID": 1591, + "name": "Teaching", + "slug": "teaching", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/tags/slug:teaching", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/tags/slug:teaching/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + }, + "display_name": "teaching" + } + }, + "categories": { + "Books": { + "ID": 178, + "name": "Books", + "slug": "books", + "description": "Writing, reviews, resources, and news on books, authors, reading, and publishing.", + "post_count": 233, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:books", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:books/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Education": { + "ID": 1342, + "name": "Education", + "slug": "education", + "description": "Resources across disciplines and perspectives on teaching, learning, and the educational system from educators, teachers, and parents.", + "post_count": 121, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:education", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:education/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Exploration": { + "ID": 7543, + "name": "Exploration", + "slug": "exploration", + "description": "Writing and photography on travel, self-discovery, research, observation, and more.", + "post_count": 147, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:exploration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:exploration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Family": { + "ID": 406, + "name": "Family", + "slug": "family", + "description": "Writing that encompasses aspects of family, including marriage, parenting, childhood, relationships, and ancestry.", + "post_count": 226, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:family", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:family/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Inspiration": { + "ID": 107, + "name": "Inspiration", + "slug": "inspiration", + "description": "Ideas and advice to empower and motivate people – especially bloggers, writers, and creative types – in their personal or professional journeys.", + "post_count": 327, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:inspiration", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:inspiration/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Parenting": { + "ID": 5309, + "name": "Parenting", + "slug": "parenting", + "description": "Writing and resources on parenting, motherhood, marriage, and family.", + "post_count": 141, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:parenting", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:parenting/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Publishing": { + "ID": 3330, + "name": "Publishing", + "slug": "publishing", + "description": "Writers and editors discussing publishing-industry news, the ins and outs of the literary world, and their own journey to a published book.", + "post_count": 124, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:publishing", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:publishing/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Reading": { + "ID": 1473, + "name": "Reading", + "slug": "reading", + "description": "Posts and book blogs that focus on authors, book reviews, and the pleasures of reading in the digital age.", + "post_count": 143, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:reading", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:reading/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Travel": { + "ID": 200, + "name": "Travel", + "slug": "travel", + "description": "Blogs, online guides, and trip planning resources devoted to travel, exploration, the outdoors, expat life, and global culture.", + "post_count": 247, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:travel", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:travel/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + }, + "Wanderlust": { + "ID": 13181, + "name": "Wanderlust", + "slug": "wanderlust", + "description": "Stunning travel photography, travel and lifestyle sites, and blog posts on the joys and rewards of exploring new destinations.", + "post_count": 97, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/categories/slug:wanderlust", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/categories/slug:wanderlust/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024" + } + } + } + }, + "attachments": { + "37125": { + "ID": 37125, + "URL": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "guid": "http://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "date": "2019-05-10T18:53:26-04:00", + "post_ID": 37123, + "author_ID": 10183950, + "file": "screen-shot-2019-05-10-at-3.53.09-pm.png", + "mime_type": "image/png", + "extension": "png", + "title": "lonely planet kids header", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png?w=150", + "medium": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png?w=315", + "large": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png?w=1220" + }, + "height": 808, + "width": 1434, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37125", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37125/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37123" + } + } + }, + "37126": { + "ID": 37126, + "URL": "https://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png", + "guid": "http://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png", + "date": "2019-05-10T18:53:28-04:00", + "post_ID": 37123, + "author_ID": 10183950, + "file": "lonely-planet-kids-logo.png", + "mime_type": "image/png", + "extension": "png", + "title": "lonely planet kids logo", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png?w=150", + "medium": "https://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png?w=315", + "large": "https://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png?w=400" + }, + "height": 400, + "width": 400, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "0", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37126", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/media/37126/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37123" + } + } + } + }, + "attachment_count": 2, + "metadata": [ + { + "id": "129821", + "key": "geo_public", + "value": "0" + }, + { + "id": "129818", + "key": "_thumbnail_id", + "value": "37125" + }, + { + "id": "130369", + "key": "_wpas_done_17926349", + "value": "1" + }, + { + "id": "129822", + "key": "_wpas_mess", + "value": "Lonely Planet Kids (@lpkids) inspires children to be curious about the world. The #PoweredByWordPress site features children's books, activities, family travel resources, and more." + } + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/53424024/posts/37123", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/37123/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37123/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/posts/37123/likes/" + }, + "data": { + "site": { + "ID": 53424024, + "name": "Discover", + "description": "A daily selection of the best content published on WordPress, collected for you by humans who love to read.", + "URL": "https://discover.wordpress.com", + "jetpack": false, + "post_count": 3603, + "subscribers_count": 35192480, + "locale": "en", + "icon": { + "img": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace", + "ico": "https://secure.gravatar.com/blavatar/c9e4e04719c81ca4936a63ea2dce6ace" + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_following": true, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024", + "help": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/53424024/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/53424024/comments/", + "xmlrpc": "https://discover.wordpress.com/xmlrpc.php" + } + } + } + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "other_URLs": { + }, + "discover_metadata": { + "permalink": "https://www.lonelyplanet.com/kids/", + "attribution": { + "author_name": "Contributors", + "author_url": "https://www.lonelyplanet.com/kids/about/", + "blog_name": "Lonely Planet Kids", + "blog_url": "https://www.lonelyplanet.com", + "avatar_url": "https://discover.files.wordpress.com/2019/05/lonely-planet-kids-logo.png?w=100&h=100&crop=true" + }, + "discover_fp_post_formats": [ + { + "name": "Pick", + "slug": "pick", + "id": 346750 + }, + { + "name": "Site Pick", + "slug": "site-pick", + "id": 308219249 + } + ] + }, + "feed_ID": 41325786, + "feed_URL": "http://discover.wordpress.com", + "pseudo_ID": "12ac818a3de41e3b0cf84fea7efa2592", + "is_external": false, + "site_name": "Discover", + "site_URL": "https://discover.wordpress.com", + "site_is_private": false, + "featured_media": { + "uri": "https://discover.files.wordpress.com/2019/05/screen-shot-2019-05-10-at-3.53.09-pm.png", + "width": 1434, + "height": 808, + "type": "image" + }, + "use_excerpt": false, + "is_following_conversation": false + } + }, + { + "type": "recommended_blogs", + "data": [ + { + "description": "A South Staffordshire Wildlife Journal", + "feed_ID": 49407045, + "feed_URL": "http://petehillmansnaturephotography.wordpress.com", + "icon": { + "img": "https://petehillmansnaturephotography.files.wordpress.com/2020/09/cropped-peter-hillman-a-nature-journey.jpg?w=96", + "ico": "https://petehillmansnaturephotography.files.wordpress.com/2020/09/cropped-peter-hillman-a-nature-journey.jpg?w=96" + }, + "ID": 112482965, + "is_private": false, + "jetpack": false, + "name": "A Nature Journey", + "prefer_feed": false, + "subscribers_count": 1968, + "subscription": { + "delivery_methods": { + "email": null, + "notification": { + "send_posts": false + } + } + }, + "URL": "http://petehillmansnaturephotography.wordpress.com" + }, + { + "description": "Jane's Lens", + "feed_ID": 1366484, + "feed_URL": "http://janeluriephotography.wordpress.com", + "icon": { + "img": "https://secure.gravatar.com/blavatar/3d272d8f6c1070fe12a4b778f2058c72", + "ico": "https://secure.gravatar.com/blavatar/3d272d8f6c1070fe12a4b778f2058c72" + }, + "ID": 26839598, + "is_private": false, + "jetpack": false, + "name": "Jane Lurie Photography", + "prefer_feed": false, + "subscribers_count": 6920, + "subscription": { + "delivery_methods": { + "email": null, + "notification": { + "send_posts": false + } + } + }, + "URL": "http://janeluriephotography.wordpress.com" + } + ] + }, + { + "type": "post", + "data": { + "ID": 10046, + "site_ID": 28958452, + "author": { + "ID": 28500267, + "login": "terriwebsterschrandt", + "email": false, + "name": "Terri Webster Schrandt", + "first_name": "Terri", + "last_name": "Webster Schrandt", + "nice_name": "terriwebsterschrandt", + "URL": "http://terriwebsterschrandt.wordpress.com", + "avatar_URL": "https://2.gravatar.com/avatar/8870e170782893e9891d83bc57e9c8df?s=96&d=retro&r=G", + "profile_URL": "https://en.gravatar.com/terriwebsterschrandt", + "site_ID": 28958452, + "has_avatar": true + }, + "date": "2020-09-27T07:00:00-07:00", + "modified": "2020-09-26T16:05:26-07:00", + "title": "Sunday Stills: Wishing for Water, But #Droplets Will Do", + "URL": "https://secondwindleisure.com/2020/09/27/sunday-stills-wishing-for-water-but-droplets-will-do/", + "short_URL": "https://wp.me/p1XvpO-2C2", + "content": "\n

My return to blogging was fun and satisfying after a tumultuous break! Thank you for welcoming me back last week. It was great to catch up with you!

\n\n\n\n

If you are confused by this week’s Sunday Stills post, we are examining the world of water droplets. In my dry, still excessively warm part of the world, if I want water, I must turn on the garden hose. Artificially produced water droplets will work!

\n\n\n\n
\"End
\n\n\n\n

After the few weeks of turmoil I recently experienced, I find calm and peace in my backyard garden any time of year.

\n\n\n\n

During our recent visit to Spokane, a few raindrops made their presence known by the end of the week.

\n\n\n\n
\"Evergreen
\n\n\n\n

After a long, dry spell, my current library of water droplets is depleted, so please enjoy a few of my favorites from the past:

\n\n\n\n
\"House
\n\n\n\n

My plumeria blossomed last year but no blooms this year. I’m pretty sure I blew up social media and my blog with images of plumeria last summer. Here are a couple donning their droplets from daily backyard watering.

\n\n\n\n\n\n\n\n

More flowers from my backyard include the Teddy Bear sunflower and the geranium in my deck garden.

\n\n\n\n\n\n\n\n

Hint: I’m sure it’s no secret, but if you need an image with water droplets pronto, use a mister or spray bottle and create your own droplets.

\n\n\n\n

If close-ups of water droplets aren’t your style, I included a couple of shots of suspended water drops:

\n\n\n\n

Catching a wall of water drops in Baja Mexico.

\n\n\n\n
\"Weightless
\n\n\n\n


I have to share this one again of Brodie romping through the river, stirring up loads of droplets.

\n\n\n\n
\"Brodie's
\n\n\n\n

No doubt, Spring has sprung in the Southern Hemisphere along with lots of opportunities to see water droplets in action. The calendar says that Autumn is here in the Northern Hemisphere, but we won’t see scenes like this in California until November!

\n\n\n\n
\"Layers
\n\n\n\n

As you know I also love taking part in other photo challenges and I love it when the planets align. I have been wanting to join Lisa’s Bird Weekly Challenge since I recently discovered it. Back in August, my lens captured this cute duck family (common merganser) out for a swim on Grant Lake in the June Lake loop in the Eastern Sierra Nevadas.

\n\n\n\n
\"\"
\n\n\n\n

Yes, it’s a bit of a stretch but my Canon Sureshot managed to capture water droplets on the feathers of the adult duck, seen best in the pic below. (Best I could do, Lisa, I think their legs are short enough)!

\n\n\n\n
\"\"
\n\n\n\n

Today’s images are partly inspired by two other fab photographers: Cee’s Flower of the Day and Jez’ Water, Water Everywhere.

\n\n\n\n

I’m hoping to live vicariously through your wonderful images of water droplets on flowers, plants, animals, whatever! Walls of water droplets cascading down from waves and fountains and other watery images also fit this week’s theme.

\n\n\n

Sunday Stills Photo Challenge Reminders

\n\n
  • Please create a new post for the theme.
  • Title your post a little differently than mine.
  • Don’t forget to create a pingback to this post so that other participants can read your post. I also recommend adding your post’s URL into the comments.
  • Entries for this theme can be shared all week. Use hashtag #SundayStills for sharing on social media.
\n\n\n

October themes are ready to view on my Sunday Stills page.

\n\n\n\n
\"\"
Original image Color Planet
\n\n\n\n

I look forward to you sharing your images of refreshing water droplets in whatever form they might take. Have a great week!

\n\n\n\n
\"SUPSIG\"
\n\n\n\n

© 2020 Copyright-All rights reserved-secondwindleisure.com

\n", + "excerpt": "

My return to blogging was fun and satisfying after a tumultuous break! Thank you for welcoming me back last week. It was great to catch up with you! If you are confused by this week’s Sunday Stills post, we are examining the world of water droplets. In my dry, still excessively warm part of the […]

\n", + "slug": "sunday-stills-wishing-for-water-but-droplets-will-do", + "guid": "http://secondwindleisure.com/?p=10046", + "status": "publish", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 80 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 77, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "6295b4c918b214147d966e6e8c6fe18e", + "featured_image": "https://terriwebsterschrandt.files.wordpress.com/2020/09/cp-drops.png", + "post_thumbnail": { + "ID": 10067, + "URL": "https://terriwebsterschrandt.files.wordpress.com/2020/09/cp-drops.png", + "guid": "http://terriwebsterschrandt.files.wordpress.com/2020/09/cp-drops.png", + "mime_type": "image/png", + "width": 1080, + "height": 1080 + }, + "format": "standard", + "tags": { + "birdweekly": { + "ID": 701015324, + "name": "birdweekly", + "slug": "birdweekly", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/tags/slug:birdweekly", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/tags/slug:birdweekly/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + }, + "Cees FOTD": { + "ID": 652598373, + "name": "Cees FOTD", + "slug": "cees-fotd", + "description": "", + "post_count": 19, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/tags/slug:cees-fotd", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/tags/slug:cees-fotd/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + }, + "gardening": { + "ID": 1833, + "name": "gardening", + "slug": "gardening", + "description": "", + "post_count": 5, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/tags/slug:gardening", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/tags/slug:gardening/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + }, + "Mammoth Lakes": { + "ID": 964261, + "name": "Mammoth Lakes", + "slug": "mammoth-lakes", + "description": "", + "post_count": 4, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/tags/slug:mammoth-lakes", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/tags/slug:mammoth-lakes/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + }, + "photography": { + "ID": 436, + "name": "photography", + "slug": "photography", + "description": "", + "post_count": 345, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/tags/slug:photography", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/tags/slug:photography/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + }, + "photography challenges": { + "ID": 1009473, + "name": "photography challenges", + "slug": "photography-challenges", + "description": "", + "post_count": 33, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/tags/slug:photography-challenges", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/tags/slug:photography-challenges/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + }, + "plumeria": { + "ID": 616561, + "name": "plumeria", + "slug": "plumeria", + "description": "", + "post_count": 8, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/tags/slug:plumeria", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/tags/slug:plumeria/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + }, + "Sunday Stills": { + "ID": 11911599, + "name": "Sunday Stills", + "slug": "sunday-stills", + "description": "", + "post_count": 131, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/tags/slug:sunday-stills", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/tags/slug:sunday-stills/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + }, + "water": { + "ID": 14157, + "name": "water", + "slug": "water", + "description": "", + "post_count": 14, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/tags/slug:water", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/tags/slug:water/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + }, + "water droplets": { + "ID": 450032, + "name": "water droplets", + "slug": "water-droplets", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/tags/slug:water-droplets", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/tags/slug:water-droplets/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + } + }, + "categories": { + "leisure": { + "ID": 3172, + "name": "leisure", + "slug": "leisure", + "description": "", + "post_count": 346, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/categories/slug:leisure", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/categories/slug:leisure/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + }, + "photography": { + "ID": 436, + "name": "photography", + "slug": "photography", + "description": "", + "post_count": 454, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/categories/slug:photography", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/categories/slug:photography/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + }, + "weekly photo challenge": { + "ID": 5114028, + "name": "weekly photo challenge", + "slug": "weekly-photo-challenge", + "description": "", + "post_count": 79, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/categories/slug:weekly-photo-challenge", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/categories/slug:weekly-photo-challenge/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/28958452/posts/10046", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28958452/posts/10046/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28958452", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/posts/10046/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28958452/posts/10046/likes/" + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "is_seen": false, + "feed_ID": 55643820, + "feed_URL": "http://secondwindleisure.com", + "pseudo_ID": "6295b4c918b214147d966e6e8c6fe18e", + "is_external": false, + "site_name": "Second Wind Leisure Perspectives", + "site_URL": "https://secondwindleisure.com", + "site_is_private": false, + "featured_media": { + "uri": "https://terriwebsterschrandt.files.wordpress.com/2020/09/2020_spokane_waterdrops.jpg", + "width": 0, + "height": 0, + "type": "image" + }, + "use_excerpt": false + } + }, + { + "type": "post", + "data": { + "ID": 1007, + "site_ID": 60412660, + "author": { + "ID": 57807571, + "login": "singhpiyush6089", + "email": false, + "name": "singhpiyush6089", + "first_name": "Piyush", + "last_name": "Singh", + "nice_name": "singhpiyush6089", + "URL": "http://theperceptionssquare.com", + "avatar_URL": "https://0.gravatar.com/avatar/fbfa5fcddc8bbef89657e331658e648c?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/singhpiyush6089", + "site_ID": 60412660, + "has_avatar": true + }, + "date": "2020-09-26T13:31:59+00:00", + "modified": "2020-09-26T13:47:39+00:00", + "title": "In Between the Shadows", + "URL": "https://theperceptionssquare.com/2020/09/26/in-between-the-shadows/", + "short_URL": "https://wp.me/p45u5K-gf", + "content": "\n
\"\"
\n\n\n\n

In between the shadows,
In the midst of darkness
And grief , I desire to survive,
I persevere hollowness of life.
My strength is that
Ray of sunshine who
Itself is persevering,
Piercing through darkness
And my grief, breaking barriers
And finally reaches me !

\n\n\n\n

©2020 Piyush Singh

\n", + "excerpt": "

In between the shadows,In the midst of darknessAnd grief , I desire to survive,I persevere hollowness of life.My strength is thatRay of sunshine whoItself is persevering,Piercing through darknessAnd my grief, breaking barriersAnd finally reaches me ! ©2020 Piyush Singh

\n", + "slug": "in-between-the-shadows", + "guid": "http://theperceptionssquare.com/?p=1007", + "status": "publish", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 42 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 167, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "2669cbf58f34e7d71da5e939657d1d4f", + "featured_image": "", + "post_thumbnail": null, + "format": "standard", + "tags": { + "#quotes": { + "ID": 755, + "name": "#quotes", + "slug": "quotes", + "description": "", + "post_count": 68, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/60412660/tags/slug:quotes", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/60412660/tags/slug:quotes/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/60412660" + } + } + }, + "blogging": { + "ID": 91, + "name": "blogging", + "slug": "blogging", + "description": "", + "post_count": 60, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/60412660/tags/slug:blogging", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/60412660/tags/slug:blogging/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/60412660" + } + } + }, + "nature": { + "ID": 1099, + "name": "nature", + "slug": "nature", + "description": "", + "post_count": 20, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/60412660/tags/slug:nature", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/60412660/tags/slug:nature/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/60412660" + } + } + }, + "photography": { + "ID": 436, + "name": "photography", + "slug": "photography", + "description": "", + "post_count": 89, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/60412660/tags/slug:photography", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/60412660/tags/slug:photography/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/60412660" + } + } + }, + "poetry": { + "ID": 422, + "name": "poetry", + "slug": "poetry", + "description": "", + "post_count": 122, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/60412660/tags/slug:poetry", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/60412660/tags/slug:poetry/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/60412660" + } + } + } + }, + "categories": { + "photography": { + "ID": 436, + "name": "photography", + "slug": "photography", + "description": "", + "post_count": 66, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/60412660/categories/slug:photography", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/60412660/categories/slug:photography/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/60412660" + } + } + }, + "poetry": { + "ID": 422, + "name": "poetry", + "slug": "poetry", + "description": "", + "post_count": 128, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/60412660/categories/slug:poetry", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/60412660/categories/slug:poetry/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/60412660" + } + } + }, + "Uncategorized": { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 32, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/60412660/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/60412660/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/60412660" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": false, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/60412660/posts/1007", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/60412660/posts/1007/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/60412660", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/60412660/posts/1007/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/60412660/posts/1007/likes/" + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "is_seen": false, + "feed_ID": 103168091, + "feed_URL": "http://theperceptionssquare.com", + "pseudo_ID": "2669cbf58f34e7d71da5e939657d1d4f", + "is_external": false, + "site_name": "The Perceptions Square", + "site_URL": "https://theperceptionssquare.com", + "site_is_private": false, + "featured_media": { + "uri": "https://singhpiyush6089.files.wordpress.com/2020/08/img_20200829_140232_624.jpg", + "width": 0, + "height": 0, + "type": "image" + }, + "use_excerpt": false + } + }, + { + "type": "post", + "data": { + "ID": 20653, + "site_ID": 28002157, + "author": { + "ID": 27562902, + "login": "marinakanavaki", + "email": false, + "name": "marina kanavaki", + "first_name": "Marina Artemis", + "last_name": "Kanavaki", + "nice_name": "marinakanavaki", + "URL": "http://marinakanavaki.wordpress.com", + "avatar_URL": "https://1.gravatar.com/avatar/4a0d3606785602212228493e899bc8a2?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/marinakanavaki", + "site_ID": 28002157, + "has_avatar": true + }, + "date": "2020-09-22T16:50:42+03:00", + "modified": "2020-09-22T16:50:42+03:00", + "title": "Autumnal [Fall] Equinox 2020", + "URL": "https://marinakanavaki.com/2020/09/22/autumnal-fall-equinox-2020/", + "short_URL": "https://wp.me/p1TuDH-5n7", + "content": "\n
\"\"
Sunrise & Sunset merged
\n\n\n\n

The time of the year when the Sun shines directly on the Equator and the length of day and night is almost equal, marking the first day of Autumn in the Northern Hemisphere and the first day of Spring in the Southern Hemisphere.

\n\n\n\n

Autumn over here and my favorite season too. There’s a special kind of quiet this season, accompanied by a burst of fiery colors, very dear to me.

\n\n\n\n

\n\n\n\n
Moon Phase: Waxing Crescent Visible: 33%↑ Age: 5,72 days • Moon Distance: 368,382.09 km
\n\n\n\n

\n\n\n\n

This Equinox,

\n\n\n\n

I decided to do

\n\n\n\n

the Sun and the Moon,

\n\n\n\n

side by side

\n\n\n\n

in similar …moods.

\n\n\n\n

\n\n\n\n

Sun and clouds

\n\n\n\n
\"\"
\n\n\n\n

Moon and clouds

\n\n\n\n
\"\"
\n\n\n\n

Sun bokeh

\n\n\n\n
\"\"
\n\n\n\n

Moon bokeh

\n\n\n\n
\"\"
\n\n\n\n

\n\n\n\n

\n\n\n\n

\n\n\n\n

Finally, a day beginning and a day ending,

\n\n\n\n
before they merge into one image [top]
\n\n\n\n

Sunrise

\n\n\n\n
\"\"
\n\n\n\n

Sunset

\n\n\n\n
\"\"
\n\n\n\n
\n\n\n\n

\n\n\n\n

Jean-Michel Jarre

\n\n\n\n

Équinoxe , Pt. 2

\n\n\n\n
Équinoxe, 1978
\n\n\n\n
\n\n
\n\n\n\n

\n\n\n\n

Happy

\n\n\n\n
and safe
\n\n\n\n

Fall Equinox

\n\n\n\n

everyone!

\n\n\n\n

\n\n\n\n

\n", + "excerpt": "

The time of the year when the Sun shines directly on the Equator and the length of day and night is almost equal, marking the first day of Autumn in the Northern Hemisphere and the first day of Spring in the Southern Hemisphere. Autumn over here and my favorite season too. There’s a special kind […]

\n", + "slug": "autumnal-fall-equinox-2020", + "guid": "http://marinakanavaki.com/?p=20653", + "status": "publish", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 63 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 110, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "34de6e3639f699de7bfa414385811f2e", + "featured_image": "https://marinakanavaki.files.wordpress.com/2020/09/fall-equinox-2020.jpg", + "post_thumbnail": { + "ID": 20676, + "URL": "https://marinakanavaki.files.wordpress.com/2020/09/fall-equinox-2020.jpg", + "guid": "http://marinakanavaki.files.wordpress.com/2020/09/fall-equinox-2020.jpg", + "mime_type": "image/jpeg", + "width": 1956, + "height": 1956 + }, + "format": "standard", + "tags": { + "2020": { + "ID": 65608, + "name": "2020", + "slug": "2020", + "description": "", + "post_count": 24, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:2020", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:2020/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "autumn": { + "ID": 2865, + "name": "autumn", + "slug": "autumn", + "description": "", + "post_count": 10, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:autumn", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:autumn/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "Autumnal Equinox": { + "ID": 1868835, + "name": "Autumnal Equinox", + "slug": "autumnal-equinox", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:autumnal-equinox", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:autumnal-equinox/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "Équinoxe": { + "ID": 5562732, + "name": "Équinoxe", + "slug": "equinoxe", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:equinoxe", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:equinoxe/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "Fall Equinox": { + "ID": 1811742, + "name": "Fall Equinox", + "slug": "fall-equinox", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:fall-equinox", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:fall-equinox/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "Jean-Michel Jarre": { + "ID": 357230, + "name": "Jean-Michel Jarre", + "slug": "jean-michel-jarre", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:jean-michel-jarre", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:jean-michel-jarre/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "Moon phase": { + "ID": 337555, + "name": "Moon phase", + "slug": "moon-phase", + "description": "", + "post_count": 4, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:moon-phase", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:moon-phase/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "moonrise": { + "ID": 360921, + "name": "moonrise", + "slug": "moonrise", + "description": "", + "post_count": 8, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:moonrise", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:moonrise/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "photo": { + "ID": 994, + "name": "photo", + "slug": "photo", + "description": "", + "post_count": 5, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:photo", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:photo/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "Photography": { + "ID": 436, + "name": "Photography", + "slug": "photography", + "description": "", + "post_count": 110, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:photography", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:photography/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "sunrise": { + "ID": 35945, + "name": "sunrise", + "slug": "sunrise", + "description": "", + "post_count": 27, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:sunrise", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:sunrise/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "sunset": { + "ID": 766, + "name": "sunset", + "slug": "sunset", + "description": "", + "post_count": 34, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/tags/slug:sunset", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/tags/slug:sunset/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + } + }, + "categories": { + "Nature": { + "ID": 1099, + "name": "Nature", + "slug": "nature", + "description": "", + "post_count": 139, + "parent": 78522247, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/categories/slug:nature", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/categories/slug:nature/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "Photography": { + "ID": 436, + "name": "Photography", + "slug": "photography", + "description": "", + "post_count": 156, + "parent": 78522247, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/categories/slug:photography", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/categories/slug:photography/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "Seasons": { + "ID": 20333, + "name": "Seasons", + "slug": "seasons", + "description": "", + "post_count": 142, + "parent": 680306717, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/categories/slug:seasons", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/categories/slug:seasons/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + }, + "Time": { + "ID": 5087, + "name": "Time", + "slug": "time", + "description": "", + "post_count": 143, + "parent": 680306717, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/categories/slug:time", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/categories/slug:time/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157" + } + } + } + }, + "attachments": { + }, + "attachment_count": 0, + "metadata": [ + + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/28002157/posts/20653", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/28002157/posts/20653/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/28002157", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/posts/20653/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/28002157/posts/20653/likes/" + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "is_seen": false, + "feed_ID": 2091483, + "feed_URL": "http://marinakanavaki.com", + "pseudo_ID": "34de6e3639f699de7bfa414385811f2e", + "is_external": false, + "site_name": "Marina Kanavaki", + "site_URL": "https://marinakanavaki.com", + "site_is_private": false, + "featured_media": { + "uri": "https://www.youtube.com/embed/_okeeslfmh8?version=3&rel=1&fs=1&autohide=2&showsearch=0&showinfo=1&iv_load_policy=1&wmode=transparent", + "type": "video" + }, + "use_excerpt": false + } + }, + { + "type": "post", + "data": { + "ID": 15697, + "site_ID": 21271414, + "author": { + "ID": 21825924, + "login": "abubot6", + "email": false, + "name": "Island Traveler", + "first_name": "Barnard", + "last_name": "Deocampo", + "nice_name": "abubot6", + "URL": "http://thismansjourney.net/", + "avatar_URL": "https://2.gravatar.com/avatar/201ebd347a0197bf0e7ffde03f9f2441?s=96&d=&r=G", + "profile_URL": "https://en.gravatar.com/abubot6", + "site_ID": 21271414, + "has_avatar": true + }, + "date": "2020-09-19T16:16:00-07:00", + "modified": "2020-09-19T16:16:32-07:00", + "title": "FALL in LOVE with your LIFE", + "URL": "https://this-mans-journey.com/2020/09/19/fall-in-love-with-your-life/", + "short_URL": "https://wp.me/p1rfFk-45b", + "content": "\n

It took pain, anxiety and sadness for me to realize I needed to be kind to myself.

\n\n\n\n
\"\"
Our first Fall Harvest Blessing Tree. We’re skipping the pumpkin patch this year. Instead, we went to Ross, Marshall’s and Dollar Tree for our pumpkin hunting. If we choose to see and celebrate what’s good of 2020, then it has been a good year.
\n\n\n\n

In this world, our happiness will not be given easily. In fact, people will find a way to take it. So, today care less of what others will say. Be nice to yourself instead of trying to please everyone. People will take advantage of your kindness and will tear your joy and positivity in pieces.

\n\n\n\n
\"\"
These pumpkins light up our dining room. We don’t wait for light to happen. We find light where we can.
\n\n\n\n

Fall in love with your life because no one can love it more than you do.

\n\n\n\n

Fall in love with your life because it deserves to be happy too.

\n\n\n\n
\"\"
I can only change how I react.
\n\n\n\n

It’s never too late to feel like we matter again. You’d been a light and warmth to others. It’s okay to experience the same.

\n\n\n\n
\"\"
Family Day in San Francisco. Stopping to watch the ocean before heading down Union Square. I turned down 3 overtime work including today and tomorrow. Money won’t make you happy, but following your heart will. 9/19/20
\n\n\n\n

Today, being better begins.

\n\n\n\n

\n", + "excerpt": "

It took pain, anxiety and sadness for me to realize I needed to be kind to myself. In this world, our happiness will not be given easily. In fact, people will find a way to take it. So, today care less of what others will say. Be nice to yourself instead of trying to please […]

\n", + "slug": "fall-in-love-with-your-life", + "guid": "http://this-mans-journey.com/?p=15697", + "status": "publish", + "discussion": { + "comments_open": true, + "comment_status": "open", + "pings_open": true, + "ping_status": "open", + "comment_count": 44 + }, + "likes_enabled": true, + "sharing_enabled": true, + "like_count": 219, + "i_like": false, + "is_reblogged": false, + "is_following": false, + "global_ID": "e61dd75e67bdca48b91ad87a2400429f", + "featured_image": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190-1.jpg", + "post_thumbnail": { + "ID": 15706, + "URL": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190-1.jpg", + "guid": "http://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190-1.jpg", + "mime_type": "image/jpeg", + "width": 1170, + "height": 1477 + }, + "format": "standard", + "tags": { + "anxiety": { + "ID": 3252, + "name": "anxiety", + "slug": "anxiety", + "description": "", + "post_count": 1, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/tags/slug:anxiety", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/tags/slug:anxiety/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414" + } + } + }, + "decor": { + "ID": 48669, + "name": "decor", + "slug": "decor", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/tags/slug:decor", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/tags/slug:decor/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414" + } + } + }, + "Fall": { + "ID": 46710, + "name": "Fall", + "slug": "fall", + "description": "", + "post_count": 4, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/tags/slug:fall", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/tags/slug:fall/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414" + } + } + }, + "Happiness": { + "ID": 22297, + "name": "Happiness", + "slug": "happiness", + "description": "", + "post_count": 26, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/tags/slug:happiness", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/tags/slug:happiness/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414" + } + } + }, + "Health": { + "ID": 337, + "name": "Health", + "slug": "health", + "description": "", + "post_count": 16, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/tags/slug:health", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/tags/slug:health/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414" + } + } + }, + "Life": { + "ID": 124, + "name": "Life", + "slug": "life", + "description": "", + "post_count": 56, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/tags/slug:life", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/tags/slug:life/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414" + } + } + }, + "love": { + "ID": 3785, + "name": "love", + "slug": "love", + "description": "", + "post_count": 6, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/tags/slug:love", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/tags/slug:love/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414" + } + } + }, + "Photography": { + "ID": 436, + "name": "Photography", + "slug": "photography", + "description": "", + "post_count": 57, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/tags/slug:photography", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/tags/slug:photography/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414" + } + } + }, + "Pumpkin": { + "ID": 186595, + "name": "Pumpkin", + "slug": "pumpkin", + "description": "", + "post_count": 3, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/tags/slug:pumpkin", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/tags/slug:pumpkin/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414" + } + } + }, + "Shopping": { + "ID": 1508, + "name": "Shopping", + "slug": "shopping", + "description": "", + "post_count": 2, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/tags/slug:shopping", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/tags/slug:shopping/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414" + } + } + } + }, + "categories": { + "Life": { + "ID": 124, + "name": "Life", + "slug": "life", + "description": "", + "post_count": 38, + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/categories/slug:life", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/categories/slug:life/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414" + } + } + } + }, + "attachments": { + "15708": { + "ID": 15708, + "URL": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216.jpg", + "guid": "http://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216.jpg", + "date": "2020-09-19T16:15:49-07:00", + "post_ID": 15697, + "author_ID": 21825924, + "file": "img_0216.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "img_0216", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216.jpg?w=150", + "medium": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216.jpg?w=300", + "large": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216.jpg?w=1024", + "full": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216.jpg" + }, + "height": 3024, + "width": 4032, + "exif": { + "aperture": "1.8", + "credit": "", + "camera": "iPhone 8 Plus", + "caption": "", + "created_timestamp": "1600531126", + "copyright": "", + "focal_length": "3.99", + "iso": "20", + "shutter_speed": "0.00055586436909394", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414/media/15708", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/media/15708/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/posts/15697" + } + } + }, + "15709": { + "ID": 15709, + "URL": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-1.jpg", + "guid": "http://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-1.jpg", + "date": "2020-09-19T16:15:49-07:00", + "post_ID": 15697, + "author_ID": 21825924, + "file": "img_0216-1.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "img_0216-1", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-1.jpg?w=150", + "medium": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-1.jpg?w=300", + "large": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-1.jpg?w=1024", + "full": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-1.jpg" + }, + "height": 3024, + "width": 4032, + "exif": { + "aperture": "1.8", + "credit": "", + "camera": "iPhone 8 Plus", + "caption": "", + "created_timestamp": "1600531126", + "copyright": "", + "focal_length": "3.99", + "iso": "20", + "shutter_speed": "0.00055586436909394", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414/media/15709", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/media/15709/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/posts/15697" + } + } + }, + "15707": { + "ID": 15707, + "URL": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-2.jpg", + "guid": "http://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-2.jpg", + "date": "2020-09-19T16:11:40-07:00", + "post_ID": 15697, + "author_ID": 21825924, + "file": "img_0216-2.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "img_0216-2", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-2.jpg?w=150", + "medium": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-2.jpg?w=300", + "large": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-2.jpg?w=1024", + "full": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216-2.jpg" + }, + "height": 3024, + "width": 4032, + "exif": { + "aperture": "1.8", + "credit": "", + "camera": "iPhone 8 Plus", + "caption": "", + "created_timestamp": "1600531126", + "copyright": "", + "focal_length": "3.99", + "iso": "20", + "shutter_speed": "0.00055586436909394", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414/media/15707", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/media/15707/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/posts/15697" + } + } + }, + "15706": { + "ID": 15706, + "URL": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190-1.jpg", + "guid": "http://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190-1.jpg", + "date": "2020-09-19T13:52:11-07:00", + "post_ID": 15697, + "author_ID": 21825924, + "file": "img_0190-1.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "img_0190-1", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190-1.jpg?w=119", + "medium": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190-1.jpg?w=238", + "large": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190-1.jpg?w=811", + "full": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190-1.jpg" + }, + "height": 1477, + "width": 1170, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414/media/15706", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/media/15706/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/posts/15697" + } + } + }, + "15704": { + "ID": 15704, + "URL": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0176.jpg", + "guid": "http://consumerjournaldotnet.files.wordpress.com/2020/09/img_0176.jpg", + "date": "2020-09-19T13:36:18-07:00", + "post_ID": 15697, + "author_ID": 21825924, + "file": "img_0176.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "img_0176", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0176.jpg?w=150", + "medium": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0176.jpg?w=300", + "large": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0176.jpg?w=1024", + "full": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0176.jpg" + }, + "height": 3024, + "width": 4032, + "exif": { + "aperture": "1.8", + "credit": "", + "camera": "iPhone 8 Plus", + "caption": "", + "created_timestamp": "1600460637", + "copyright": "", + "focal_length": "3.99", + "iso": "50", + "shutter_speed": "0.066666666666667", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414/media/15704", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/media/15704/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/posts/15697" + } + } + }, + "15703": { + "ID": 15703, + "URL": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0038.jpg", + "guid": "http://consumerjournaldotnet.files.wordpress.com/2020/09/img_0038.jpg", + "date": "2020-09-19T13:36:04-07:00", + "post_ID": 15697, + "author_ID": 21825924, + "file": "img_0038.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "img_0038", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0038.jpg?w=150", + "medium": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0038.jpg?w=300", + "large": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0038.jpg?w=1024", + "full": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0038.jpg" + }, + "height": 3024, + "width": 4032, + "exif": { + "aperture": "1.8", + "credit": "", + "camera": "iPhone 8 Plus", + "caption": "", + "created_timestamp": "1600038963", + "copyright": "", + "focal_length": "3.99", + "iso": "64", + "shutter_speed": "0.066666666666667", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414/media/15703", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/media/15703/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/posts/15697" + } + } + }, + "15701": { + "ID": 15701, + "URL": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190.jpg", + "guid": "http://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190.jpg", + "date": "2020-09-19T12:59:41-07:00", + "post_ID": 15697, + "author_ID": 21825924, + "file": "img_0190.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "img_0190", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190.jpg?w=119", + "medium": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190.jpg?w=238", + "large": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190.jpg?w=811", + "full": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0190.jpg" + }, + "height": 1477, + "width": 1170, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414/media/15701", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/media/15701/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/posts/15697" + } + } + }, + "15696": { + "ID": 15696, + "URL": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0188.jpg", + "guid": "http://consumerjournaldotnet.files.wordpress.com/2020/09/img_0188.jpg", + "date": "2020-09-19T10:47:21-07:00", + "post_ID": 15697, + "author_ID": 21825924, + "file": "img_0188.jpg", + "mime_type": "image/jpeg", + "extension": "jpg", + "title": "img_0188", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0188.jpg?w=112", + "medium": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0188.jpg?w=225", + "large": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0188.jpg?w=767", + "full": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0188.jpg" + }, + "height": 1800, + "width": 1348, + "exif": { + "aperture": "0", + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": "0", + "copyright": "", + "focal_length": "0", + "iso": "0", + "shutter_speed": "0", + "title": "", + "orientation": "1", + "keywords": [ + + ] + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414/media/15696", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/media/15696/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414", + "parent": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/posts/15697" + } + } + } + }, + "attachment_count": 8, + "metadata": [ + + ], + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/read/sites/21271414/posts/15697", + "help": "{{request.requestLine.baseUrl}}/rest/v/sites/21271414/posts/15697/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.2/sites/21271414", + "replies": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/posts/15697/replies/", + "likes": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/21271414/posts/15697/likes/" + } + }, + "capabilities": { + "publish_post": false, + "delete_post": false, + "edit_post": false + }, + "is_seen": false, + "feed_ID": 107168226, + "feed_URL": "http://this-mans-journey.com", + "pseudo_ID": "e61dd75e67bdca48b91ad87a2400429f", + "is_external": false, + "site_name": "This Man's Journey", + "site_URL": "https://this-mans-journey.com", + "site_is_private": false, + "featured_media": { + "uri": "https://consumerjournaldotnet.files.wordpress.com/2020/09/img_0216.jpg", + "width": 4032, + "height": 3024, + "type": "image" + }, + "use_excerpt": false + } + } + ], + "next_page_handle": "ZnJvbT04JnJlZnJlc2g9MQ==" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/rest_v1_batch.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/rest_v1_batch.json new file mode 100644 index 000000000000..6e8be7484b1e --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/rest_v1_batch.json @@ -0,0 +1,11 @@ +{ + "request": { + "urlPattern": "/rest/v1.1/batch(.*)", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/rewind-capabilities.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/rewind-capabilities.json new file mode 100644 index 000000000000..afd586825c56 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/rewind-capabilities.json @@ -0,0 +1,24 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/wpcom/v2/sites/106707880/rewind/capabilities.*", + "queryParameters": { + "_locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "capabilities": [ + + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/scan/wpcom_v2_sites_185124945_scan.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/scan/wpcom_v2_sites_185124945_scan.json new file mode 100644 index 000000000000..3afd5719f7c9 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/scan/wpcom_v2_sites_185124945_scan.json @@ -0,0 +1,35 @@ +{ + "request": { + "urlPattern": "/wpcom/v2/sites/185124945/scan(\\?|/|$).*", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "state": "idle", + "threats": [ + + ], + "has_cloud": false, + "credentials": [ + { + "still_valid": true, + "type": "managed", + "role": "main" + } + ], + "most_recent": { + "is_initial": false, + "timestamp": "{{now offset='-2 days' format='yyyy-MM-dd'}}T{{now offset='-2 days' format='HH:mm:ssZ'}}", + "duration": 16, + "progress": 100, + "error": false + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/e2eflowtestingmobile-wordpress-com.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/e2eflowtestingmobile-wordpress-com.json new file mode 100644 index 000000000000..b3ae03303331 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/e2eflowtestingmobile-wordpress-com.json @@ -0,0 +1,48 @@ +{ + "id": "5b687fe3-831a-489c-b1c6-e91c6af36c42", + "name": "rest_v11_sites_e2eflowtestingmobilewordpresscom", + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/e2eflowtestingmobile.wordpress.com(/)?($|\\?.*)" + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 106707880, + "name": "Mobile E2E Testing", + "description": "", + "URL": "https://e2eflowtestingmobile.wordpress.com", + "jetpack": false, + "subscribers_count": 1, + "lang": false, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": null, + "is_private": false, + "is_following": false, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/comments/", + "xmlrpc": "https://e2eflowtestingmobile.wordpress.com/xmlrpc.php" + } + }, + "launch_status": false + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + }, + "uuid": "5b687fe3-831a-489c-b1c6-e91c6af36c42", + "persistent": true, + "insertionIndex": 24 +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_106707880.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_106707880.json new file mode 100644 index 000000000000..ab7310e68115 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_106707880.json @@ -0,0 +1,297 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 106707880, + "name": "Tri-County Real Estate", + "description": "", + "URL": "http://tricountyrealestate.wordpress.com", + "capabilities": { + "edit_pages": true, + "edit_posts": true, + "edit_others_posts": true, + "edit_others_pages": true, + "delete_posts": true, + "delete_others_posts": true, + "edit_theme_options": true, + "edit_users": false, + "list_users": true, + "manage_categories": true, + "manage_options": true, + "moderate_comments": true, + "activate_wordads": false, + "promote_users": true, + "publish_posts": true, + "upload_files": true, + "delete_users": false, + "remove_users": true, + "view_stats": true + }, + "jetpack": false, + "icon": { + "img": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=96", + "ico": "https://tricountyrealestate.files.wordpress.com/2020/08/image-1.jpg?w=96" + }, + "visible": true, + "is_private": false, + "options": { + "timezone": "", + "gmt_offset": 0, + "blog_public": 1, + "videopress_enabled": false, + "upgraded_filetypes_enabled": true, + "login_url": "https://infocusphotographers.wordpress.com/wp-login.php", + "admin_url": "https://infocusphotographers.wordpress.com/wp-admin/", + "is_mapped_domain": true, + "is_redirect": false, + "unmapped_url": "https://infocusphotographers.wordpress.com", + "featured_images_enabled": false, + "theme_slug": "pub/edin", + "header_image": false, + "background_color": "ffffff", + "image_default_link_type": "none", + "image_thumbnail_width": 150, + "image_thumbnail_height": 150, + "image_thumbnail_crop": 0, + "image_medium_width": 300, + "image_medium_height": 300, + "image_large_width": 1024, + "image_large_height": 1024, + "permalink_structure": "/%year%/%monthnum%/%day%/%postname%/", + "post_formats": [ + + ], + "default_post_format": "0", + "default_category": 1, + "allowed_file_types": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "doc", + "ppt", + "odt", + "pptx", + "docx", + "pps", + "ppsx", + "xls", + "xlsx", + "key", + "asc", + "mp3", + "m4a", + "wav", + "ogg", + "zip" + ], + "show_on_front": "page", + "default_likes_enabled": true, + "default_sharing_status": true, + "default_comment_status": true, + "default_ping_status": true, + "software_version": "5.2.2", + "created_at": "2016-02-09T16:07:34+00:00", + "wordads": false, + "publicize_permanently_disabled": false, + "frame_nonce": "847b221e56", + "jetpack_frame_nonce": "847b221e56", + "page_on_front": 1, + "page_for_posts": 0, + "headstart": false, + "headstart_is_fresh": false, + "ak_vp_bundle_enabled": null, + "advanced_seo_front_page_description": "", + "advanced_seo_title_formats": [ + + ], + "verification_services_codes": null, + "podcasting_archive": null, + "is_domain_only": false, + "is_automated_transfer": false, + "is_wpcom_atomic": false, + "is_wpcom_store": false, + "woocommerce_is_active": false, + "design_type": null, + "site_goals": null, + "site_segment": null + }, + "plan": { + "product_id": 1029, + "product_slug": "personal-bundle-2y", + "product_name": "WordPress.com Personal", + "product_name_short": "Personal", + "expired": false, + "user_is_owner": false, + "is_free": false, + "features": { + "active": [ + "free-blog", + "custom-domain", + "space", + "support", + "wordads-jetpack" + ], + "available": { + "free-blog": [ + "free_plan", + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ], + "space": [ + "free_plan", + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ], + "support": [ + "free_plan", + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ], + "custom-domain": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ], + "no-adverts/no-adverts.php": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ], + "custom-design": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ], + "videopress": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ], + "unlimited_themes": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ], + "live_support": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ], + "simple-payments": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ], + "premium-themes": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ], + "google-analytics": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle", + "ecommerce-bundle-2y", + "business-bundle-monthly" + ] + } + } + }, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/comments/", + "xmlrpc": "https://infocusphotographers.wordpress.com/xmlrpc.php" + } + }, + "quota": { + "space_allowed": 6442450944, + "space_used": 442302007, + "percent_used": 6.865430731947224, + "space_available": 6000148937 + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_106707880_settings.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_106707880_settings.json new file mode 100644 index 000000000000..59cf708d0e56 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_106707880_settings.json @@ -0,0 +1,121 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/settings", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 106707880, + "name": "In Focus Photography", + "description": "", + "URL": "http://infocusphotographers.com", + "lang": "en", + "settings": { + "admin_url": "https://infocusphotographers.wordpress.com/wp-admin/", + "default_ping_status": true, + "default_comment_status": true, + "blog_public": 1, + "jetpack_sync_non_public_post_stati": false, + "jetpack_relatedposts_allowed": true, + "jetpack_relatedposts_enabled": false, + "jetpack_relatedposts_show_headline": "", + "jetpack_relatedposts_show_thumbnails": "", + "jetpack_search_enabled": false, + "jetpack_search_supported": false, + "default_category": 1, + "post_categories": [ + { + "value": 1, + "name": "Uncategorized" + }, + { + "value": 1674, + "name": "Wedding" + } + ], + "default_post_format": "0", + "default_pingback_flag": true, + "require_name_email": true, + "comment_registration": false, + "close_comments_for_old_posts": false, + "close_comments_days_old": 14, + "thread_comments": true, + "thread_comments_depth": 3, + "page_comments": true, + "comments_per_page": 50, + "default_comments_page": "newest", + "comment_order": "asc", + "comments_notify": true, + "moderation_notify": true, + "social_notifications_like": false, + "social_notifications_reblog": false, + "social_notifications_subscribe": false, + "comment_moderation": false, + "comment_whitelist": true, + "comment_max_links": 2, + "moderation_keys": "", + "blacklist_keys": "", + "lang_id": 1, + "wga": false, + "disabled_likes": false, + "disabled_reblogs": false, + "jetpack_comment_likes_enabled": true, + "twitter_via": "", + "jetpack-twitter-cards-site-tag": "", + "eventbrite_api_token": null, + "gmt_offset": "0", + "timezone_string": "", + "date_format": "F j, Y", + "time_format": "g:i a", + "start_of_week": "1", + "jetpack_testimonial": true, + "jetpack_testimonial_posts_per_page": 10, + "jetpack_portfolio": false, + "jetpack_portfolio_posts_per_page": 10, + "markdown_supported": true, + "site_icon": 0, + "advanced_seo_front_page_description": "", + "advanced_seo_title_formats": [ + + ], + "amp_is_supported": true, + "amp_is_enabled": true, + "api_cache": true, + "posts_per_page": 10, + "posts_per_rss": 10, + "rss_use_excerpt": false, + "wpcom_publish_posts_with_markdown": false, + "wpcom_publish_comments_with_markdown": false, + "infinite_scroll": false, + "infinite_scroll_blocked": "footer", + "podcasting_category_id": 0, + "podcasting_title": "", + "podcasting_subtitle": "", + "podcasting_talent_name": "", + "podcasting_summary": "", + "podcasting_copyright": "", + "podcasting_explicit": "no", + "podcasting_image": "", + "podcasting_keywords": "", + "podcasting_category_1": "", + "podcasting_category_2": "", + "podcasting_category_3": "", + "podcasting_email": "", + "podcasting_image_id": 0, + "sharing_button_style": "icon-text", + "sharing_label": "Share this:", + "sharing_show": [ + "post", + "page" + ], + "sharing_open_links": "same" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_158396482_taxonomies_category_terms.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_158396482_taxonomies_category_terms.json new file mode 100644 index 000000000000..0992f7fee0ca --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_158396482_taxonomies_category_terms.json @@ -0,0 +1,51 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/taxonomies/category/terms/" + }, + "response": { + "status": 200, + "jsonBody": { + "found": 3, + "terms": [ + { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "feed_url": "http://infocusphotographers.com/category/uncategorized/feed/", + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 1674, + "name": "Wedding", + "slug": "wedding", + "description": "", + "post_count": 1, + "feed_url": "http://infocusphotographers.com/category/wedding/feed/", + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181851495.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181851495.json new file mode 100644 index 000000000000..6891c9cd9203 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181851495.json @@ -0,0 +1,386 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/181851495", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 181851495, + "name": "Four Paws Dog Grooming", + "description": "", + "URL": "https://fourpawsdoggrooming.wordpress.com", + "user_can_manage": true, + "capabilities": { + "edit_pages": true, + "edit_posts": true, + "edit_others_posts": true, + "edit_others_pages": true, + "delete_posts": true, + "delete_others_posts": true, + "edit_theme_options": true, + "edit_users": false, + "list_users": true, + "manage_categories": true, + "manage_options": true, + "moderate_comments": true, + "activate_wordads": false, + "promote_users": true, + "publish_posts": true, + "upload_files": true, + "delete_users": false, + "remove_users": true, + "own_site": false, + "view_hosting": true, + "view_stats": true + }, + "jetpack": false, + "jetpack_connection": false, + "is_multisite": true, + "post_count": 3, + "subscribers_count": 0, + "lang": "en", + "icon": { + "img": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png?w=96", + "ico": "https://fourpawsdoggrooming.files.wordpress.com/2020/08/cropped-fourpaws-logo-2-1.png?w=96", + "media_id": 39 + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": true, + "is_coming_soon": true, + "single_user_site": false, + "is_vip": false, + "is_following": false, + "options": { + "timezone": "America/Los_Angeles", + "gmt_offset": -7, + "blog_public": -1, + "videopress_enabled": false, + "upgraded_filetypes_enabled": false, + "login_url": "https://fourpawsdoggrooming.wordpress.com/wp-login.php", + "admin_url": "https://fourpawsdoggrooming.wordpress.com/wp-admin/", + "is_mapped_domain": false, + "is_redirect": false, + "unmapped_url": "https://fourpawsdoggrooming.wordpress.com", + "featured_images_enabled": false, + "theme_slug": "pub/hever", + "header_image": false, + "background_color": false, + "image_default_link_type": "none", + "image_thumbnail_width": 150, + "image_thumbnail_height": 150, + "image_thumbnail_crop": 0, + "image_medium_width": 300, + "image_medium_height": 300, + "image_large_width": 1024, + "image_large_height": 1024, + "permalink_structure": "/%year%/%monthnum%/%day%/%postname%/", + "post_formats": [ + + ], + "default_post_format": "0", + "default_category": 1, + "allowed_file_types": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "doc", + "ppt", + "odt", + "pptx", + "docx", + "pps", + "ppsx", + "xls", + "xlsx", + "key", + "asc" + ], + "show_on_front": "page", + "default_likes_enabled": true, + "default_sharing_status": false, + "default_comment_status": true, + "default_ping_status": true, + "software_version": "5.5-wpcom-48929", + "created_at": "2020-08-21T23:21:16+00:00", + "wordads": false, + "publicize_permanently_disabled": false, + "frame_nonce": "6c9ff09ee5", + "jetpack_frame_nonce": "6c9ff09ee5", + "page_on_front": 5, + "page_for_posts": 0, + "wpcom_public_coming_soon_page_id": 0, + "headstart": false, + "headstart_is_fresh": false, + "ak_vp_bundle_enabled": null, + "advanced_seo_front_page_description": "", + "advanced_seo_title_formats": [ + + ], + "verification_services_codes": null, + "podcasting_archive": null, + "is_domain_only": false, + "is_automated_transfer": false, + "is_wpcom_atomic": false, + "is_wpcom_store": false, + "woocommerce_is_active": false, + "design_type": null, + "site_goals": null, + "site_segment": null, + "import_engine": null, + "is_pending_plan": false, + "is_wpforteams_site": false, + "is_cloud_eligible": false + }, + "plan": { + "product_id": 1, + "product_slug": "free_plan", + "product_name": "WordPress.com Free", + "product_name_short": "Free", + "expired": false, + "user_is_owner": false, + "is_free": true, + "features": { + "active": [ + "free-blog", + "space", + "support" + ], + "available": { + "free-blog": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle-2y" + ], + "custom-domain": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle-2y" + ], + "space": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle-2y" + ], + "donations": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "core/audio": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "premium-content/container": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "support": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle-2y" + ], + "no-adverts/no-adverts.php": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "custom-design": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "videopress": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "unlimited_themes": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "live_support": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "private_whois": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "simple-payments": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "calendly": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "opentable": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "send-a-message": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "core/video": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "core/cover": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "premium-themes": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "google-analytics": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "social-previews": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle-2y" + ] + } + } + }, + "products": [ + + ], + "jetpack_modules": null, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/comments/", + "xmlrpc": "https://fourpawsdoggrooming.wordpress.com/xmlrpc.php", + "site_icon": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/media/39" + } + }, + "quota": { + "space_allowed": 3221225472, + "space_used": 4495332, + "percent_used": 0.1395534723997116, + "space_available": 3216730140 + }, + "launch_status": "unlaunched", + "site_migration": null, + "is_fse_active": false, + "is_fse_eligible": false, + "is_core_site_editor_enabled": false + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181851495_settings.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181851495_settings.json new file mode 100644 index 000000000000..246902e6f08b --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181851495_settings.json @@ -0,0 +1,121 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/181851495/settings", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 181851495, + "name": "Four Paws Dog Grooming", + "description": "", + "URL": "https://fourpawsdoggrooming.wordpress.com", + "lang": "en", + "settings": { + "admin_url": "https://fourpawsdoggrooming.wordpress.com/wp-admin/", + "default_ping_status": true, + "default_comment_status": true, + "instant_search_enabled": false, + "blog_public": -1, + "jetpack_sync_non_public_post_stati": false, + "jetpack_relatedposts_allowed": true, + "jetpack_relatedposts_enabled": true, + "jetpack_relatedposts_show_headline": false, + "jetpack_relatedposts_show_thumbnails": false, + "jetpack_search_enabled": false, + "jetpack_search_supported": false, + "default_category": 1, + "post_categories": [ + { + "value": 1, + "name": "Uncategorized" + } + ], + "default_post_format": "0", + "default_pingback_flag": true, + "require_name_email": true, + "comment_registration": false, + "close_comments_for_old_posts": false, + "close_comments_days_old": 14, + "thread_comments": true, + "thread_comments_depth": 3, + "page_comments": true, + "comments_per_page": 50, + "default_comments_page": "newest", + "comment_order": "asc", + "comments_notify": true, + "moderation_notify": true, + "social_notifications_like": false, + "social_notifications_reblog": false, + "social_notifications_subscribe": false, + "comment_moderation": false, + "comment_whitelist": true, + "comment_previously_approved": false, + "comment_max_links": 2, + "moderation_keys": "", + "blacklist_keys": "", + "disallowed_keys": false, + "lang_id": 1, + "wga": false, + "disabled_likes": false, + "disabled_reblogs": false, + "jetpack_comment_likes_enabled": true, + "twitter_via": "", + "jetpack-twitter-cards-site-tag": "", + "eventbrite_api_token": null, + "gmt_offset": -7, + "timezone_string": "America/Los_Angeles", + "date_format": "F j, Y", + "time_format": "g:i a", + "start_of_week": "1", + "jetpack_testimonial": false, + "jetpack_testimonial_posts_per_page": 10, + "jetpack_portfolio": false, + "jetpack_portfolio_posts_per_page": 10, + "markdown_supported": true, + "site_icon": 39, + "advanced_seo_front_page_description": "", + "advanced_seo_title_formats": [ + + ], + "amp_is_supported": false, + "amp_is_enabled": true, + "api_cache": true, + "posts_per_page": 10, + "posts_per_rss": 10, + "rss_use_excerpt": false, + "wpcom_publish_posts_with_markdown": false, + "wpcom_publish_comments_with_markdown": false, + "infinite_scroll": true, + "infinite_scroll_blocked": false, + "wpcom_coming_soon": 1, + "podcasting_category_id": 0, + "podcasting_title": "", + "podcasting_subtitle": "", + "podcasting_talent_name": "", + "podcasting_summary": "", + "podcasting_copyright": "", + "podcasting_explicit": "no", + "podcasting_image": "", + "podcasting_keywords": "", + "podcasting_category_1": "", + "podcasting_category_2": "", + "podcasting_category_3": "", + "podcasting_email": "", + "podcasting_image_id": 0, + "sharing_button_style": "icon-text", + "sharing_label": "Share this:", + "sharing_show": [ + "post", + "page" + ], + "sharing_open_links": "same" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181977606.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181977606.json new file mode 100644 index 000000000000..f968813f9f91 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181977606.json @@ -0,0 +1,399 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/181977606", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 181977606, + "name": "Weekend Bakes", + "description": "", + "URL": "https://weekendbakesblog.wordpress.com", + "user_can_manage": false, + "capabilities": { + "edit_pages": true, + "edit_posts": true, + "edit_others_posts": true, + "edit_others_pages": true, + "delete_posts": true, + "delete_others_posts": true, + "edit_theme_options": true, + "edit_users": false, + "list_users": true, + "manage_categories": true, + "manage_options": true, + "moderate_comments": true, + "activate_wordads": true, + "promote_users": true, + "publish_posts": true, + "upload_files": true, + "delete_users": false, + "remove_users": true, + "own_site": true, + "view_hosting": true, + "view_stats": true + }, + "jetpack": false, + "jetpack_connection": false, + "is_multisite": true, + "post_count": 3, + "subscribers_count": 5, + "lang": "en", + "icon": { + "img": "https://weekendbakesblog.files.wordpress.com/2020/08/image.jpg?w=96", + "ico": "https://weekendbakesblog.files.wordpress.com/2020/08/image.jpg?w=96", + "media_id": 12 + }, + "logo": { + "id": 0, + "sizes": [ + + ], + "url": "" + }, + "visible": true, + "is_private": false, + "is_coming_soon": false, + "single_user_site": true, + "is_vip": false, + "is_following": true, + "options": { + "timezone": "", + "gmt_offset": 0, + "blog_public": 1, + "videopress_enabled": false, + "upgraded_filetypes_enabled": false, + "login_url": "https://weekendbakesblog.wordpress.com/wp-login.php", + "admin_url": "https://weekendbakesblog.wordpress.com/wp-admin/", + "is_mapped_domain": false, + "is_redirect": false, + "unmapped_url": "https://weekendbakesblog.wordpress.com", + "featured_images_enabled": false, + "theme_slug": "pub/independent-publisher-2", + "header_image": false, + "background_color": false, + "image_default_link_type": "none", + "image_thumbnail_width": 150, + "image_thumbnail_height": 150, + "image_thumbnail_crop": 0, + "image_medium_width": 300, + "image_medium_height": 300, + "image_large_width": 1024, + "image_large_height": 1024, + "permalink_structure": "/%year%/%monthnum%/%day%/%postname%/", + "post_formats": [ + + ], + "default_post_format": "standard", + "default_category": 1, + "allowed_file_types": [ + "jpg", + "jpeg", + "png", + "gif", + "pdf", + "doc", + "ppt", + "odt", + "pptx", + "docx", + "pps", + "ppsx", + "xls", + "xlsx", + "key", + "asc" + ], + "show_on_front": "posts", + "default_likes_enabled": true, + "default_sharing_status": true, + "default_comment_status": true, + "default_ping_status": true, + "software_version": "5.5-wpcom-48929", + "created_at": "2020-08-25T04:02:19+00:00", + "wordads": false, + "publicize_permanently_disabled": false, + "frame_nonce": "cd557ccebe", + "jetpack_frame_nonce": "cd557ccebe", + "wpcom_public_coming_soon_page_id": 0, + "headstart": false, + "headstart_is_fresh": false, + "ak_vp_bundle_enabled": null, + "advanced_seo_front_page_description": "", + "advanced_seo_title_formats": [ + + ], + "verification_services_codes": null, + "podcasting_archive": null, + "is_domain_only": false, + "is_automated_transfer": false, + "is_wpcom_atomic": false, + "is_wpcom_store": false, + "woocommerce_is_active": false, + "design_type": null, + "site_goals": null, + "site_segment": 2, + "import_engine": null, + "is_pending_plan": false, + "is_wpforteams_site": false, + "is_cloud_eligible": false, + "blogging_prompts_settings": { + "prompts_reminders_opted_in": false, + "reminders_days": { + "monday": false, + "tuesday": false, + "wednesday": false, + "thursday": false, + "friday": false, + "saturday": false, + "sunday": false + }, + "reminders_time": "10.00", + "is_potential_blogging_site": true, + "prompts_card_opted_in": true + } + }, + "plan": { + "product_id": 1, + "product_slug": "free_plan", + "product_name": "WordPress.com Free", + "product_name_short": "Free", + "expired": false, + "user_is_owner": false, + "is_free": true, + "features": { + "active": [ + "free-blog", + "space", + "support" + ], + "available": { + "free-blog": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle-2y" + ], + "custom-domain": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle-2y" + ], + "space": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle-2y" + ], + "donations": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "core/audio": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "premium-content/container": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "support": [ + "personal-bundle", + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "personal-bundle-2y", + "value_bundle-2y", + "business-bundle-2y", + "blogger-bundle", + "blogger-bundle-2y", + "ecommerce-bundle-2y" + ], + "no-adverts/no-adverts.php": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "custom-design": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "videopress": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "unlimited_themes": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "live_support": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "private_whois": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "simple-payments": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "calendly": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "opentable": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "send-a-message": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "core/video": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "core/cover": [ + "value_bundle", + "business-bundle", + "ecommerce-bundle", + "value_bundle-2y", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "premium-themes": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "google-analytics": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle-2y" + ], + "social-previews": [ + "business-bundle", + "ecommerce-bundle", + "business-bundle-2y", + "ecommerce-bundle-2y" + ] + } + } + }, + "products": [ + + ], + "jetpack_modules": null, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/help", + "posts": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/posts/", + "comments": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/comments/", + "xmlrpc": "https://weekendbakesblog.wordpress.com/xmlrpc.php", + "site_icon": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181977606/media/12" + } + }, + "quota": { + "space_allowed": 3221225472, + "space_used": 52880326, + "percent_used": 1.6416213785608609, + "space_available": 3168345146 + }, + "launch_status": false, + "site_migration": null, + "is_fse_active": false, + "is_fse_eligible": false, + "is_core_site_editor_enabled": false + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181977606_settings.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181977606_settings.json new file mode 100644 index 000000000000..5243583748a4 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v11_sites_181977606_settings.json @@ -0,0 +1,121 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/181977606/settings", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 181977606, + "name": "Weekend Bakes", + "description": "", + "URL": "https://weekendbakesblog.wordpress.com", + "lang": "en", + "settings": { + "admin_url": "https://weekendbakesblog.wordpress.com/wp-admin/", + "default_ping_status": true, + "default_comment_status": true, + "instant_search_enabled": false, + "blog_public": 1, + "jetpack_sync_non_public_post_stati": false, + "jetpack_relatedposts_allowed": true, + "jetpack_relatedposts_enabled": true, + "jetpack_relatedposts_show_headline": "", + "jetpack_relatedposts_show_thumbnails": "", + "jetpack_search_enabled": false, + "jetpack_search_supported": false, + "default_category": 1, + "post_categories": [ + { + "value": 1, + "name": "Uncategorized" + } + ], + "default_post_format": "standard", + "default_pingback_flag": true, + "require_name_email": true, + "comment_registration": false, + "close_comments_for_old_posts": false, + "close_comments_days_old": 14, + "thread_comments": true, + "thread_comments_depth": 3, + "page_comments": true, + "comments_per_page": 50, + "default_comments_page": "newest", + "comment_order": "asc", + "comments_notify": true, + "moderation_notify": true, + "social_notifications_like": false, + "social_notifications_reblog": false, + "social_notifications_subscribe": false, + "comment_moderation": false, + "comment_whitelist": true, + "comment_previously_approved": false, + "comment_max_links": 2, + "moderation_keys": "", + "blacklist_keys": "", + "disallowed_keys": false, + "lang_id": 1, + "wga": false, + "disabled_likes": false, + "disabled_reblogs": false, + "jetpack_comment_likes_enabled": true, + "twitter_via": "", + "jetpack-twitter-cards-site-tag": "", + "eventbrite_api_token": null, + "gmt_offset": "0", + "timezone_string": "", + "date_format": "F j, Y", + "time_format": "g:i a", + "start_of_week": "1", + "jetpack_testimonial": false, + "jetpack_testimonial_posts_per_page": 10, + "jetpack_portfolio": false, + "jetpack_portfolio_posts_per_page": 10, + "markdown_supported": true, + "site_icon": 12, + "advanced_seo_front_page_description": "", + "advanced_seo_title_formats": [ + + ], + "amp_is_supported": true, + "amp_is_enabled": true, + "api_cache": true, + "posts_per_page": 10, + "posts_per_rss": 10, + "rss_use_excerpt": false, + "wpcom_publish_posts_with_markdown": false, + "wpcom_publish_comments_with_markdown": false, + "infinite_scroll": true, + "infinite_scroll_blocked": false, + "wpcom_coming_soon": 0, + "podcasting_category_id": 0, + "podcasting_title": "", + "podcasting_subtitle": "", + "podcasting_talent_name": "", + "podcasting_summary": "", + "podcasting_copyright": "", + "podcasting_explicit": "no", + "podcasting_image": "", + "podcasting_keywords": "", + "podcasting_category_1": "", + "podcasting_category_2": "", + "podcasting_category_3": "", + "podcasting_email": "", + "podcasting_image_id": 0, + "sharing_button_style": "icon-text", + "sharing_label": "Share this:", + "sharing_show": [ + "post", + "page" + ], + "sharing_open_links": "same" + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_disabled.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_disabled.json new file mode 100644 index 000000000000..47cb930f1285 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_disabled.json @@ -0,0 +1,21 @@ +{ + "scenarioName": "gutenberg", + "requiredScenarioState": "Started", + "request": { + "urlPattern": "/wpcom/v2/sites/106707880/gutenberg(/)?($|\\?.*)", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "editor_mobile": "aztec", + "editor_web": "gutenberg", + "opt_in": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/106707880/gutenberg?editor=gutenberg&platform=mobile" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_enabled.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_enabled.json new file mode 100644 index 000000000000..07bdd78ed705 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_enabled.json @@ -0,0 +1,21 @@ +{ + "scenarioName": "gutenberg", + "requiredScenarioState": "enabled", + "request": { + "urlPattern": "/wpcom/v2/sites/106707880/gutenberg(/)?($|\\?.*)", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "editor_mobile": "gutenberg", + "editor_web": "gutenberg", + "opt_in": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/106707880/gutenberg?editor=gutenberg&platform=mobile" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_null.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_null.json new file mode 100644 index 000000000000..788a6eac90f8 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_null.json @@ -0,0 +1,19 @@ +{ + "request": { + "urlPath": "/wpcom/v2/sites/106707880/gutenberg/", + "method": "POST" + }, + "response": { + "status": 200, + "jsonBody": { + "editor_mobile": "gutenberg", + "editor_web": "gutenberg", + "opt_in": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/106707880/gutenberg?editor=gutenberg&platform=mobile" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_opt_in.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_opt_in.json new file mode 100644 index 000000000000..fff634422d85 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_opt_in.json @@ -0,0 +1,29 @@ +{ + "scenarioName": "gutenberg", + "newScenarioState": "enabled", + "request": { + "urlPattern": "/wpcom/v2/sites/106707880/gutenberg(/+)?\\?(.*)", + "method": "POST", + "queryParameters": { + "editor": { + "equalTo": "gutenberg" + }, + "platform": { + "equalTo": "mobile" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "editor_mobile": "gutenberg", + "editor_web": "gutenberg", + "opt_out": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/106707880/gutenberg?editor=classic&platform=mobile" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_opt_out.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_opt_out.json new file mode 100644 index 000000000000..75d5c964f56f --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_106707880_gutenberg_opt_out.json @@ -0,0 +1,29 @@ +{ + "scenarioName": "gutenberg", + "newScenarioState": "Started", + "request": { + "urlPattern": "/wpcom/v2/sites/106707880/gutenberg(/+)?\\?(.*)", + "method": "POST", + "queryParameters": { + "editor": { + "equalTo": "aztec" + }, + "platform": { + "equalTo": "mobile" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "editor_mobile": "aztec", + "editor_web": "gutenberg", + "opt_in": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/106707880/gutenberg?editor=gutenberg&platform=mobile" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_181851495_gutenberg.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_181851495_gutenberg.json new file mode 100644 index 000000000000..bfdd26c0b8e5 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_181851495_gutenberg.json @@ -0,0 +1,19 @@ +{ + "request": { + "urlPath": "/wpcom/v2/sites/181851495/gutenberg", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "editor_mobile": "gutenberg", + "editor_web": "gutenberg", + "opt_in": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/181851495/gutenberg?editor=gutenberg&platform=mobile" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_181977606_gutenberg.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_181977606_gutenberg.json new file mode 100644 index 000000000000..e2fb48cf4ac5 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_181977606_gutenberg.json @@ -0,0 +1,19 @@ +{ + "request": { + "urlPath": "/wpcom/v2/sites/181977606/gutenberg", + "method": "GET" + }, + "response": { + "status": 200, + "jsonBody": { + "editor_mobile": "gutenberg", + "editor_web": "gutenberg", + "opt_in": "{{request.requestLine.baseUrl}}/wpcom/v2/sites/181977606/gutenberg?editor=gutenberg&platform=mobile" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_block_layouts.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_block_layouts.json new file mode 100644 index 000000000000..e9711f9dce45 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_block_layouts.json @@ -0,0 +1,770 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/wpcom/v2/sites/([0-9]+)/block-layouts", + "queryParameters": { + "supported_blocks": { + "matches": "(.*)" + }, + "preview_width": { + "matches": "(.*)" + }, + "scale": { + "matches": "(.*)" + }, + "preview_height": { + "matches": "(.*)" + }, + "type": { + "matches": "(.*)" + }, + "is_beta": { + "matches": "(.*)" + }, + "_locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "layouts": [ + { + "slug": "menu-2", + "title": "Menu", + "content": "\n

The menu is a mix of French regional cuisines, and the menu changes with the seasons. Be sure to ask about the daily specials!

\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n

Starters

\n\n\n\n
\n\n\n\n

Salmon Canapés
Smoked salmon topped with crème fraîche, dill and capers
$15.00

\n\n\n\n

Pork Rillettes
With quick pickle of dried apricots and pistachio crumble
$14.00

\n\n\n\n

Provençal Vegetable Tart
With seasonal vegetables
$12.50

\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n

Mains

\n\n\n\n
\n\n\n\n

Coq au Vin d’Alsace
Chicken cooked in Riesling with onions, mushrooms and herbs
$25.50

\n\n\n\n

Bouillabaisse
Traditional Provençal fish stew, with shrimp, mussels, clams and monkfish
$26.00

\n\n\n\n

Sole Meunière
Pan-fried sole in butter and served with brown butter sauce, parsley and lemon
$28.00

\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n

Desserts

\n\n\n\n
\n\n\n\n

Chocolate Macarons
A classic French delicacy, filled with rich chocolate ganache
$12.00

\n\n\n\n

Crêpe Suzette
Delicate crêpe in buttery, orange sauce; served flambé
$13.00

\n\n\n\n

Praline Cake
Multi-layer praline and chocolate cake with caramel cream cheese
$12.00

\n\n\n\n
\n\n\n\n
\n\n\n\n

Our fish is responsibly caught from sustainable sources.

\n", + "categories": [ + { + "slug": "services", + "title": "Services", + "description": "Service pages", + "emoji": "🔧", + "order": 8 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=4133&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D4133%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D4133%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D4133%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "contact-with-info", + "title": "Contact", + "content": "\n
\n
\n

Get in touch

\n\n\n\n
\n
\n\n\n\n
\n

Tell your visitors how to contact you. You could also get a bit more specific, by letting them know what to contact you about.

\n\n\n\n

Be sure to mention all the channels where visitors can reach you, including social media.

\n\n\n\n\n\n\n\n
\n
\n
\n\n\n\n
\n
\n

Connect

\n
\n\n\n\n
\n
    \n\n\n\n\n\n\n\n\n\n
\n
\n
\n\n\n\n
\n", + "categories": [ + { + "slug": "contact", + "title": "Contact", + "description": "Contact pages", + "emoji": "📫", + "order": 6 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=4073&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D4073%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D4073%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D4073%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "blog-9", + "title": "Blog", + "content": "\n
\"\"
\n\n\n\n

Introduce yourself and your blog

\n\n\n\n
\n\n\n\n

My Latest Posts

\n\n\n\n

• • •

\n\n\n\n\n\n

• • •

\n", + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog pages", + "emoji": "📰", + "order": 0 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3685&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3685%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3685%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3685%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "blog-8", + "title": "Blog", + "content": "\n
\n\n\n\n
\"\"
\n\n\n\n
\n\n\n\n

Introduce yourself and your blog

\n\n\n\n
\n\n\n\n

My Latest Posts

\n\n\n\n
\n\n\n\n\n\n
\n", + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog pages", + "emoji": "📰", + "order": 0 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3681&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3681%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3681%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3681%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "favorites-2", + "title": "Favorites", + "content": "\n

My Favorites

\n\n\n\n

List a few of your favorite things, or use this page to describe what you do.

\n\n\n\n
\n\n\n\n
\"\"
\n\n\n\n

Topic 1

\n\n\n\n

Add a sentence or two about an idea, topic, or service you want to share with your readers.

\n\n\n\n
\n\n\n\n
\"\"
\n\n\n\n

Topic 2

\n\n\n\n

Add a sentence or two about an idea, topic, or service you want to share with your readers.

\n", + "categories": [ + { + "slug": "highlights", + "title": "Highlights", + "description": "Highlights pages", + "emoji": "💛", + "order": 4 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3676&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3676%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3676%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3676%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "favorites", + "title": "Favorites", + "content": "\n
\"\"
\n\n\n\n

My Favorites

\n\n\n\n

List a few of your favorite things, or use this page to describe what you do.

\n\n\n\n
\"\"
\n

Topic 1

\n\n\n\n

Add a sentence or two about an idea, topic, or service you want to share with your readers.

\n
\n\n\n\n
\n\n\n\n
\"\"
\n

Topic 2

\n\n\n\n

Add a sentence or two about an idea, topic, or service you want to share with your readers.

\n
\n", + "categories": [ + { + "slug": "highlights", + "title": "Highlights", + "description": "Highlights pages", + "emoji": "💛", + "order": 4 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3670&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3670%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3670%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3670%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "coming-soon-3", + "title": "Coming Soon", + "content": "\n
\"\"
\n
\"\"
\n\n\n\n
\n\n\n\n

Coming soon!

\n\n\n\n
\n\n\n\n

Let’s make something new together

\n\n\n\n

_Instagram
_Twitter
_YouTube

\n\n\n\n

Want to learn more? Get in touch!

\n
\n", + "categories": [ + { + "slug": "splash", + "title": "Splash", + "description": "Splash pages", + "emoji": "🏖", + "order": 3 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3665&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3665%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3665%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3665%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "coming-soon-2", + "title": "Coming Soon", + "content": "\n
\n

Coming soon

\n\n\n\n

Let’s make something new together

\n\n\n\n
\n\n\n\n\n
\n", + "categories": [ + { + "slug": "splash", + "title": "Splash", + "description": "Splash pages", + "emoji": "🏖", + "order": 3 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3662&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3662%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3662%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3662%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "about-11", + "title": "About", + "content": "\n
\"\"
\n\n\n\n
\n

About me

\n\n\n\n

Introduce yourself! Use this space to write 2 or 3 sentences about who you are, what you do, and where you are.

\n\n\n\n
    \n\n\n\n
\n
\n", + "categories": [ + { + "slug": "about", + "title": "About", + "description": "About pages", + "emoji": "👋", + "order": 1 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3658&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3658%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3658%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3658%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "about-10", + "title": "About", + "content": "\n
\n\n\n\n
\"\"
\n\n\n\n

About me

\n\n\n\n

Introduce yourself! Use this space to write 2 or 3 sentences about who you are, what you do, and where you are.

\n\n\n\n\n", + "categories": [ + { + "slug": "about", + "title": "About", + "description": "About pages", + "emoji": "👋", + "order": 1 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3654&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3654%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3654%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3654%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "links", + "title": "Links", + "content": "\n
\"\"
\n
\n\n\n\n

My Links

\n\n\n\n

My latest and greatest tips, resources, and reads. So much goodness all in one place!

\n\n\n\n\n\n\n\n
    \n\n\n\n
\n\n\n\n
\n
\n", + "categories": [ + { + "slug": "links", + "title": "Links", + "description": "Links pages", + "emoji": "🔗", + "order": 2 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3649&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3649%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3649%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3649%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "about-9", + "title": "Links", + "content": "\n
\"\"
\n\n\n\n

My Links

\n\n\n\n\n\n\n\n

My latest and greatest tips, resources, and reads. So much goodness all in one place!

\n\n\n\n\n", + "categories": [ + { + "slug": "links", + "title": "Links", + "description": "Links pages", + "emoji": "🔗", + "order": 2 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3644&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3644%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3644%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3644%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "reynolds-2", + "title": "Reynolds", + "content": "\n
\"\"
\n

Introduce yourself -- who you are, what you do, and where you are.

\n\n\n\n\n
\n\n\n\n
\"\"
\n

Project #1

\n\n\n\n

A unique approach to the creative process. Every project begins with an idea, but it's what happens to that idea along the way that counts.

\n\n\n\n\n
\n\n\n\n
\"\"
\n

Project #2

\n\n\n\n

Your project showcase demonstrates your skills, your experience, and your ability to produce stunning work across genres.

\n\n\n\n\n
\n\n\n\n
\"\"
\n

Project #3

\n\n\n\n

Your customers will love your attention to detail. Each project you showcase tells them something difference about you and your innovative work.

\n\n\n\n\n
\n\n\n\n

\n", + "categories": [ + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3504&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3504%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3504%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3504%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "vesta-2", + "title": "Vesta", + "content": "\n
\n
\n
\n\n\n\n
\n
\n

Vesta is a photographer and art director based in London, UK. Currently working for magazines and award-winning publications.

\n
\n\n\n\n
\n
\n\n\n\n
\n\n\n\n
\n
\n
\n
\"\"
\n\n\n\n

Never Ending Story

\n\n\n\n
\n
\n\n\n\n
\n
\"\"
\n\n\n\n

The Final Corner

\n\n\n\n
\n
\n
\n
\n\n\n\n
\n
\n
\n
\"\"
\n\n\n\n

Top of the Hill

\n\n\n\n
\n
\n\n\n\n
\n
\"\"
\n\n\n\n

Crossing Points

\n\n\n\n
\n
\n
\n
\n\n\n\n
\n
\n
\n
\"\"
\n\n\n\n

Ebb and Flow

\n\n\n\n
\n
\n\n\n\n
\n
\"\"
\n\n\n\n

In the Morning

\n\n\n\n
\n
\n
\n
\n\n\n\n
\n\n\n\n
\n
\n

Contact

\n
\n\n\n\n\n
\n", + "categories": [ + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3498&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3498%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3498%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3498%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "overton-2", + "title": "Overton", + "content": "\n
\n\n\n\n

From breakfast and lunch to dinner and dessert, our menu will delight.

\n\n\n\n

Our delicious menu includes snacks, finger-foods, and full meals.

\n\n\n\n\n\n\n\n
\n\n\n\n
\"\"
\n

\n
\n\n\n\n
\n
\n\n\n\n
\n
\n

Ready Made Meals

\n\n\n\n

Can't be bothered to cook but still craving delicious food? We've got you covered with our ready made meals. Our menu offers selections for breakfast, lunch, or dinner with a range that is sure to please the whole family.

\n
\n\n\n\n
\n

Birthdays

\n\n\n\n

Birthday parties are a special occasion for family and friends to gather together and celebrate. We can help you make the party even better with our delicious birthday party catering service.

\n
\n\n\n\n
\n

Weddings

\n\n\n\n

Let us make your special day even more memorable with an exquisite menu uniquely tailored to your vision and budget. We would be delighted to work with you and be a part of your celebration.

\n
\n
\n\n\n\n
\n
\n\n\n\n
\n\n\n\n
\n\n\n\n

Thank you so much for helping make our day so wonderful! Everyone enjoyed the beautifully presented food and the professional service. Thank you again!

\n\n\n\n

Marisa Daniels

\n\n\n\n
\n\n\n\n
\n\n\n\n
\n
\n

Latest from the blog

\n
\n\n\n\n
\n
\n\n\n\n
\n", + "categories": [ + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3490&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3490%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3490%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3490%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "easley-2", + "title": "Easley", + "content": "\n
\n
\n
\n
\n\n\n\n

Jane Doe

\n\n\n\n

Illustrator, Cartoonist

\n\n\n\n

Hi I'm Jane, and if you haven't noticed already, I really really like to draw! I've been working in the visual arts for more than eight years. My portfolio includes print and digital media, clothing and kids' books. Thanks for visiting!

\n
\n\n\n\n
\n
\"\"
\n
\n\n\n\n
\n
\"\"
\n
\n
\n\n\n\n
\n
\n
\"\"
\n
\n\n\n\n
\n
\"\"
\n
\n\n\n\n
\n
\"\"
\n
\n
\n
\n", + "categories": [ + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3486&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3486%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3486%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3486%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "leven-2", + "title": "Leven", + "content": "\n
\n

A Curated Collection of Vintage Cameras.

\n\n\n\n

We stock the largest and most unique range of classic and vintage cameras and accessories anywhere. Looking for film processing and darkroom equipment? We've got you covered!

\n\n\n\n\n\n\n\n
\n
\n\n\n\n
\n

As Good as New

\n
\n\n\n\n
\n
\n

GX-303

\n\n\n\n
\n\n\n\n

Galaxy Deep Field Lens

\n\n\n\n

COMING SOON

\n\n\n\n
\n\n\n\n

Professional and amateur astrophotographers alike will love our new collection of deep field lenses. With quality housing, shake-eliminating focus barrels and world-class optics, you'll be able to capture the Milky Way and our Solar System in all their magnificence. The extra wide angle and aperture provide outstanding quality and low noise. These pieces come with cleaning cloths and felt cases and a 12-month warranty.

\n\n\n\n\n\n\n\n
\n
\n\n\n\n
\n
\"\"
\n\n\n\n
\"\"
\n

If you're new to photography, you may have heard the term 'depth of field', but might be still unsure of how it affects your shot set up. Even older, vintage cameras have features that allow you to increase or decrease depth of field.

\n
\n\n\n\n
\n
\n
\n\n\n\n
\n

Professional high quality cameras, tools, and accessories for today’s avid photographers.

\n
\n\n\n\n
\n\n\n\n

What Our Customers are Saying

\n\n\n\n
\n\n\n\n

I don't leave the house without my stylish camera. Thank you for your advice and assistance.

Beverly Mattingdale
\n\n\n\n
\n\n\n\n

\n", + "categories": [ + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3477&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3477%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3477%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3477%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "brice-2", + "title": "Brice", + "content": "\n
\n
\n\n\n\n

Support your local community

\n\n\n\n

We need your help to continue to provide essential community services to people in crisis.

\n\n\n\n\n\n\n\n
\n
\n\n\n\n
\n\n\n\n

We are a local organisation that provides support and expertise within the local community, to potential volunteers, existing volunteers, and organisations that involve volunteers.

\n\n\n\n
\n\n\n\n
\n
\n\n\n\n
\n
\n

1,652

\n\n\n\n

Volunteers available

\n
\n\n\n\n
\n

1,132

\n\n\n\n

Volunteer opportunities

\n
\n\n\n\n
\n

1,972

\n\n\n\n

Matches last year

\n
\n
\n\n\n\n
\n
\n\n\n\n
\n
\n\n\n\n

Are you a business?

\n\n\n\n

We are uniting our resources around this challenge, and we are combining our resources and asks to make it easy for people to support their communities.

\n\n\n\n\n\n\n\n
\n\n\n\n

Want to volunteer?

\n\n\n\n

We’ve had an incredible response so far, and are doing everything we can to respond to everyone who wants to volunteer in one of our community programmes.

\n\n\n\n\n\n\n\n
\n
\n\n\n\n
\n
\n\n\n\n

Hear more from our community.

\n\n\n\n
\n\n\n\n\n\n
\n
\n\n\n\n
\n
\n\n\n\n

\"So many things are possible just as long as you don't know they're impossible.\"

\n\n\n\n

Norton Juster

\n\n\n\n
\n
\n", + "categories": [ + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3470&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3470%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3470%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3470%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "bowen-2", + "title": "Bowen", + "content": "\n
\"\"
\n
\n\n\n\n

Hey there!

\n\n\n\n

Hi, I’m Lillie, a mom of two, passionate about photography, home decor, and travel. Thanks for visiting!

\n\n\n\n
First time to the site? Start here
\n\n\n\n
\n
\n\n\n\n
\n\n\n\n

From the blog

\n\n\n\n\n\n
\n\n\n\n
\n

Posting new tips every week

\n\n\n\n\n
\n\n\n\n
\"\"
\n
\n\n\n\n

About Me

\n\n\n\n

Hi, I’m Lillie. Previously a magazine editor, I became a full-time mother and freelance writer in 2017. When I’m not spending time with my wonderful kids and husband, I love writing about my fascination with food, adventure, and living a healthy and organized life!

\n\n\n\n
\n
\n", + "categories": [ + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3467&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3467%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3467%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3467%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "cassel-2", + "title": "Cassel", + "content": "\n
\n\n\n\n
\n
\"\"
\n\n\n\n

About us

\n\n\n\n

We're a food blog focused on simple and seasonal recipes.

\n\n\n\n\n
\n\n\n\n
\n\n\n\n\n\n
\n\n\n\n
\"\"
\n
\n\n\n\n

New recipes posted weekly

\n\n\n\n\n\n\n\n
\n
\n", + "categories": [ + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3440&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3440%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3440%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3440%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "barnsbury-2", + "title": "Barnsbury", + "content": "\n
\"\"
\n

Locally Farmed Organic Vegetable Delivery

\n\n\n\n

20 years of growing organic vegetables and delivering vegetable boxes from our 12-acre farm in Sussex.

\n\n\n\n\n
\n\n\n\n
\n
\n\n\n\n

Services

\n\n\n\n

What We Do

\n\n\n\n
\n\n\n\n
\n
\n
\"\"
\n\n\n\n

We produce organic vegetable boxes that are affordable, seasonal, and as fresh and local as possible.

\n
\n\n\n\n
\n
\"\"
\n\n\n\n

We have different sizes of boxes available for various appetites, from individuals to families.

\n
\n\n\n\n
\n
\"\"
\n\n\n\n

We are flexible with the items in your boxes. You can change items in your order for something you would like.

\n
\n
\n\n\n\n
\n
\n\n\n\n
\"\"
\n
\n\n\n\n

Meet us better

\n\n\n\n

Our Organic Farm in Numbers

\n\n\n\n
\n\n\n\n

470 households per month

\n\n\n\n

23 experts working

\n\n\n\n

5 awards won

\n\n\n\n

100% satisfied customers

\n\n\n\n
\n
\n\n\n\n
\n\n\n\n

Try with our vegetables

\n\n\n\n

Our favourite recipes

\n\n\n\n
\n\n\n\n\n\n\n\n\n\n
\n\n\n\n
\n
\n\n\n\n

We are very happy with the quality of the box and we value the service.

Joseph
\n\n\n\n
\n
\n", + "categories": [ + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3432&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3432%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3432%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3432%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "alves-2", + "title": "Alves", + "content": "\n
\"\"
\n
\n\n\n\n

Raise Your Helping Hand

\n\n\n\n

We are a non-profit organization, we are looking forward to a peaceful world by helping each other to join hands together to bring a better future for all children.

\n\n\n\n\n\n\n\n
\n
\n\n\n\n
\"\"
\n
\n\n\n\n
\n
\n

About Us

\n\n\n\n

Our Beliefs

\n
\n\n\n\n
\n

We believe in a world where every child can read. Our mission is to invest in early childhood education in order to empower the next generation. We do that, by creating educational programs and providing necessary resources in underprivileged areas. We believe in smart fund allocation, and therefore we employ minimal staff.

\n
\n
\n\n\n\n
\n
\n\n\n\n
\"\"
\n
\n\n\n\n

How You Can Help

\n\n\n\n
\n\n\n\n
\n
\n

Send Donation

\n\n\n\n

Giving online has never been more secure, convenient or hassle-free with our one-click donation. We also do accept standard cash and check donations at all of our locations.

\n
\n\n\n\n
\n

Become a Volunteer

\n\n\n\n

You can get involved today by becoming a Volunteer. Sign up and you will be joining a group of change-makers, a network strong enough to impact positive change in the lives of children.

\n
\n\n\n\n
\n

Give Scholarship

\n\n\n\n

Your gift will help equip children in need with necessary resources, training and education while offering the promise of a brighter future. You can make a difference today by signing up.

\n
\n
\n\n\n\n
\n
\n\n\n\n
\"\"
\n
\n\n\n\n

Testimonials

\n\n\n\n

What People Say

\n\n\n\n
\n\n\n\n
\n
\n

The generosity of this organization makes it possible for me to continue having education opportunities in this difficult area.

Jane Doe
\n
\n\n\n\n
\n

Wonderful job with those kids that need us and people from various places all over the world. I will definitely join your group as a volunteer.

John Doe
\n
\n
\n\n\n\n
\n
\n\n\n\n
\"\"
\n
\n\n\n\n

News

\n\n\n\n

Recent Causes

\n\n\n\n
\n\n\n\n\n\n
\n
\n\n\n\n
\"\"
\n
\n\n\n\n

Become a Volunteer

\n\n\n\n

With the aim of helping as many people as possible, we always lack enthusiastic volunteers. Please contact us for more info.

\n\n\n\n\n\n\n\n
\n
\n", + "categories": [ + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3424&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3424%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3424%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3424%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "team-3", + "title": "Team", + "content": "\n

We are a small team of talented professionals with a wide range of skills and experience. We love what we do, and we do it with passion. We look forward to working with you.

\n\n\n\n
\n\n\n\n
\n
\n
\"\"
\n\n\n\n

Juan Pérez

\n\n\n\n

Position or Job Title

\n\n\n\n
\n
\n\n\n\n
\n
\"\"
\n\n\n\n

Sally Smith

\n\n\n\n

Position or Job Title

\n\n\n\n
\n
\n\n\n\n
\n
\"\"
\n\n\n\n

Lara Thayer

\n\n\n\n

Position or Job Title

\n\n\n\n
\n\n\n\n

\n
\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n\n\n\n\n
\n\n\n\n
\n", + "categories": [ + { + "slug": "team", + "title": "Team", + "description": "Team pages", + "emoji": "👥", + "order": 7 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3413&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3413%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3413%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3413%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "blog-7", + "title": "Blog", + "content": "\n
\n
\n\n\n\n
\n
\"\"
\n
\n\n\n\n
\n
\n\n\n\n

Eat Dessert First is for my love of food and sharing my favorites with you.

\n\n\n\n
\n\n\n\n

Hi, I’m Lillie. Previously a magazine editor, I became a full-time mother and freelance writer in 2017. I spend most of my time with my kids and husband over at The Brown Bear Family.

\n\n\n\n
\n\n\n", + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog pages", + "emoji": "📰", + "order": 0 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3395&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3395%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3395%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3395%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "services-6", + "title": "Services", + "content": "\n
\n
\n

Services

\n\n\n\n
\n
\n\n\n\n
\n

Service A

\n\n\n\n

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque.

\n\n\n\n
\n\n\n\n

Service B

\n\n\n\n

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque.

\n
\n
\n\n\n\n
\n
\n

Testimonials

\n\n\n\n
\n
\n\n\n\n
\n

Add a testimonial from someone who loves your service. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque.

Jane Doe
\n\n\n\n
\n\n\n\n

Add a testimonial from someone who loves your service. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque.

John Doe
\n
\n
\n\n\n\n
\n", + "categories": [ + { + "slug": "services", + "title": "Services", + "description": "Service pages", + "emoji": "🔧", + "order": 8 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3392&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3392%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3392%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3392%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "portfolio-10", + "title": "Portfolio", + "content": "\n

Use this space to introduce yourself.
Who you are, what you do, and where you are.

\n\n\n\n

Add more information about yourself. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh.

\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\"\"
\n
Project Name
\n\n\n\n

A short Description of your project

\n
\n\n\n\n
\n\n\n\n

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed vitae massa eu nisi vulputate congue ut a tellus. Proin accumsan purus et dui venenatis, malesuada fermentum sem efficitur. Sed dignissim tristique congue. Vestibulum neque augue, varius id finibus vel, cursus eget arcu. Aliquam at nulla diam. Integer faucibus, libero at lacinia sollicitudin, elit nisi iaculis velit, non malesuada ante est vel ex. Phasellus id feugiat leo. Donec quis tortor dolor.

\n\n\n\n
\n\n\n\n\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\"\"
\n
Project Name
\n\n\n\n

A short Description of your project

\n
\n\n\n\n
\n\n\n\n

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed vitae massa eu nisi vulputate congue ut a tellus. Proin accumsan purus et dui venenatis, malesuada fermentum sem efficitur. Sed dignissim tristique congue. Vestibulum neque augue, varius id finibus vel, cursus eget arcu. Aliquam at nulla diam. Integer faucibus, libero at lacinia sollicitudin, elit nisi iaculis velit, non malesuada ante est vel ex. Phasellus id feugiat leo. Donec quis tortor dolor.

\n\n\n\n
\n\n\n\n\n\n\n\n
\n\n\n\n
\n", + "categories": [ + { + "slug": "portfolio", + "title": "Portfolio", + "description": "Portfolio pages", + "emoji": "🎨", + "order": 5 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3383&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3383%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3383%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3383%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "portfolio-9", + "title": "Portfolio", + "content": "\n

Use this space to introduce yourself.
Who you are, what you do, and where you are.

\n\n\n\n
\n\n\n\n

Add more information about yourself. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh.

\n\n\n\n
\n\n\n\n\n", + "categories": [ + { + "slug": "portfolio", + "title": "Portfolio", + "description": "Portfolio pages", + "emoji": "🎨", + "order": 5 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3380&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3380%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3380%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3380%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "contact-12", + "title": "Contact", + "content": "\n
\n
\n

Get in touch

\n\n\n\n
\n
\n\n\n\n
\n

Tell your visitors how to contact you. You could also get a bit more specific, by letting them know what to contact you about.

\n\n\n\n

Be sure to mention all the channels where visitors can reach you, including social media. If you’d rather not provide your email, you can add a contact form instead.

\n\n\n\n

Email me at contact@example.com

\n
\n
\n\n\n\n
\n
\n

Connect

\n\n\n\n
\n
\n\n\n\n
\n
    \n\n\n\n\n\n\n\n\n\n
\n
\n
\n\n\n\n
\n", + "categories": [ + { + "slug": "contact", + "title": "Contact", + "description": "Contact pages", + "emoji": "📫", + "order": 6 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3372&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3372%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3372%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3372%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "about-7", + "title": "About", + "content": "\n
\"\"
\n\n\n\n
\n\n\n\n
\n
\n

About me

\n\n\n\n
\n
\n\n\n\n
\n

Add more information about yourself. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh. Donec hendrerit dui ut nisi tempor scelerisque.

\n
\n
\n\n\n\n
\n
\n

Expertise

\n\n\n\n
\n
\n\n\n\n
\n

What You Do

\n\n\n\n

Add more information about what you do. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh.

\n\n\n\n
\n\n\n\n

What You Do

\n\n\n\n

Add more information about what you do. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id arcu aliquet, elementum nisi quis, condimentum nibh.

\n
\n
\n", + "categories": [ + { + "slug": "about", + "title": "About", + "description": "About pages", + "emoji": "👋", + "order": 1 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3342&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3342%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3342%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3342%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "team-2", + "title": "Team", + "content": "\n

We are a small team of talented professionals with a wide range of skills and experience. We love what we do, and we do it with passion. We look forward to working with you.

\n\n\n\n
\n\n\n\n
\"\"
\n

Sally Smith

\n\n\n\n

Position or Job Title

\n\n\n\n

A short bio with personal history, key achievements, or an interesting fact.

\n\n\n\n

Email me: mail@example.com

\n
\n\n\n\n
\"\"
\n

Juan Pérez

\n\n\n\n

Position or Job Title

\n\n\n\n

A short bio with personal history, key achievements, or an interesting fact.

\n\n\n\n

Email me: mail@example.com

\n
\n\n\n\n
\"\"
\n

Samuel the Dog

\n\n\n\n

Position or Job Title

\n\n\n\n

A short bio with personal history, key achievements, or an interesting fact.

\n\n\n\n

Email me: mail@example.com

\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n

Want to work with us?

\n\n\n\n\n\n\n\n
\n\n\n\n
\n", + "categories": [ + { + "slug": "team", + "title": "Team", + "description": "Team pages", + "emoji": "👥", + "order": 7 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3360&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3360%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3360%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3360%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "about-6", + "title": "About", + "content": "\n

Visitors will want to know who is on the other side of the page. Use this space to write about yourself, your site, your business, or anything you want. Use the testimonials below to quote others, talking about the same thing – in their own words.

\n\n\n\n

This is sample content, included with the template to illustrate its features. Remove or replace it with your own words and media.

\n\n\n\n

What People Say

\n\n\n\n
\n
\n

The way to get started is to quit talking and begin doing.

Walt Disney
\n
\n\n\n\n
\n

It is our choices, Harry, that show what we truly are, far more than our abilities.

J.K. Rowling
\n
\n\n\n\n
\n

Don’t cry because it’s over, smile because it happened.

Dr. Seuss
\n
\n
\n\n\n\n
\n\n\n\n
\n\n\n\n
\n\n\n\n

Let’s build something together

\n\n\n\n\n\n\n\n
\n\n\n\n
\n", + "categories": [ + { + "slug": "about", + "title": "About", + "description": "About pages", + "emoji": "👋", + "order": 1 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3353&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3353%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3353%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3353%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "mayland", + "title": "Mayland", + "content": "\n
\n
\n
\n
\"\"
\n\n\n\n
\"\"
\n\n\n\n
\"\"
\n
\n\n\n\n
\n
\"\"
\n\n\n\n
\"\"
\n\n\n\n
\"\"
\n
\n\n\n\n
\n
\"\"
\n\n\n\n
\"\"
\n\n\n\n
\"\"
\n
\n
\n
\n", + "categories": [ + { + "slug": "gallery", + "title": "Gallery", + "description": "Gallery pages", + "emoji": "🖼", + "order": 13 + }, + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=3099&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3099%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3099%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D3099%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "contact-10", + "title": "Contact", + "content": "\n
\n
\n
\n
\"\"
\n
\n\n\n\n
\n

Let's get in touch

\n\n\n\n

Tell your visitors how to contact you. You could also get a bit more specific, by letting them know what to contact you about.

\n\n\n\n

Be sure to mention all the channels where visitors can reach you, including social media. If you’d rather not provide your email, you can add a contact form instead.

\n\n\n\n

Email me at contact@example.com or follow me on:

\n\n\n\n\n
\n
\n
\n", + "categories": [ + { + "slug": "contact", + "title": "Contact", + "description": "Contact pages", + "emoji": "📫", + "order": 6 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=1406&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1406%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1406%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1406%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "contact-9", + "title": "Contact", + "content": "\n
\n
\"\"
\n\n\n\n
\n
\n

Tell your visitors how to get in touch with you. You could also get a bit more specific, by letting them know what to contact you about.

\n\n\n\n

Be sure to mention all the channels where visitors can reach you, including social media. If you'd rather not provide your email, you can add a contact form instead.

\n
\n\n\n\n
\n

Drop me a line
contact@example.com

\n\n\n\n

Let's hang out

\n\n\n\n\n
\n
\n
\n", + "categories": [ + { + "slug": "contact", + "title": "Contact", + "description": "Contact pages", + "emoji": "📫", + "order": 6 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=1403&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1403%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1403%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1403%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "portfolio-5", + "title": "Portfolio", + "content": "\n\n", + "categories": [ + { + "slug": "gallery", + "title": "Gallery", + "description": "Gallery pages", + "emoji": "🖼", + "order": 13 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=1361&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1361%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1361%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1361%26language%3Den?vpw=400&vph=600&w=400&h=600" + }, + { + "slug": "about-3", + "title": "About", + "content": "\n
\n\n\n\n
\"\"
\n
\n\n\n\n

Add Your Name

\n\n\n\n

Add your job title

\n\n\n\n\n\n\n\n

Add more information about yourself. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In sit amet eros eget justo elementum interdum. Cras vestibulum nulla id aliquam rutrum. Vestibulum aliquet mauris ut augue ultrices facilisis. Vestibulum pretium ligula sed ipsum dapibus, tempus iaculis felis ornare. Morbi pretium sed est tincidunt hendrerit. Curabitur id elit scelerisque, pharetra tellus sit amet, dictum mi. Aliquam consectetur tristique metus non pulvinar. Donec luctus magna quis justo tincidunt, eu euismod lacus faucibus.

\n\n\n\n
Add What You Do
\n\n\n\n
  • What you do
  • What you do
\n\n\n\n
    \n\n\n\n\n\n\n\n\n\n
\n\n\n\n
\n
\n", + "categories": [ + { + "slug": "about", + "title": "About", + "description": "About pages", + "emoji": "👋", + "order": 1 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com?use_patterns=true&post_id=1340&language=en", + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1340%26language%3Den?vpw=1200&vph=1800&w=400&h=600", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1340%26language%3Den?vpw=800&vph=1200&w=400&h=600", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/Hever/dotcompatterns.wordpress.com%3Fuse_patterns%3Dtrue%26post_id%3D1340%26language%3Den?vpw=400&vph=600&w=400&h=600" + } + ], + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog pages", + "emoji": "📰", + "order": 0 + }, + { + "slug": "about", + "title": "About", + "description": "About pages", + "emoji": "👋", + "order": 1 + }, + { + "slug": "links", + "title": "Links", + "description": "Links pages", + "emoji": "🔗", + "order": 2 + }, + { + "slug": "splash", + "title": "Splash", + "description": "Splash pages", + "emoji": "🏖", + "order": 3 + }, + { + "slug": "highlights", + "title": "Highlights", + "description": "Highlights pages", + "emoji": "💛", + "order": 4 + }, + { + "slug": "portfolio", + "title": "Portfolio", + "description": "Portfolio pages", + "emoji": "🎨", + "order": 5 + }, + { + "slug": "contact", + "title": "Contact", + "description": "Contact pages", + "emoji": "📫", + "order": 6 + }, + { + "slug": "team", + "title": "Team", + "description": "Team pages", + "emoji": "👥", + "order": 7 + }, + { + "slug": "services", + "title": "Services", + "description": "Service pages", + "emoji": "🔧", + "order": 8 + }, + { + "slug": "home", + "title": "Home", + "description": "Home pages", + "emoji": "🏠", + "order": 9 + }, + { + "slug": "gallery", + "title": "Gallery", + "description": "Gallery pages", + "emoji": "🖼", + "order": 13 + } + ] + } + } +} diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_blogging_prompts.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_blogging_prompts.json new file mode 100644 index 000000000000..df70e16ba39c --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_blogging_prompts.json @@ -0,0 +1,254 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/wpcom/v2/sites/([0-9]+)/blogging-prompts", + "queryParameters": { + "number": { + "matches": "(.*)" + }, + "from": { + "matches": "(.*)" + }, + "_locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "prompts": [ + { + "id": 2003, + "text": "What foods would you like to make?", + "title": "Prompt number 200", + "content": "\n

What foods would you like to make?

\n", + "attribution": "dayone", + "date": "2022-07-20", + "answered": false, + "answered_users_count": 3, + "answered_users_sample": [ + { + "avatar": "https://2.gravatar.com/avatar/5dffa9e5d426449127114fe04f4820ba?s=96&d=identicon&r=G" + }, + { + "avatar": "https://0.gravatar.com/avatar/9cd92ae622fc905b4908503cf9fbd489?s=96&d=identicon&r=G" + }, + { + "avatar": "https://2.gravatar.com/avatar/e2526cb456f69198425ebeaff0a9225f?s=96&d=identicon&r=G" + } + ] + }, + { + "id": 2004, + "text": "What's your favorite game (card, board, video, etc.)? Why?", + "title": "Prompt number 201", + "content": "\n

What's your favorite game (card, board, video, etc.)? Why?

\n", + "attribution": "", + "date": "2022-07-21", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2005, + "text": "What’s your go-to comfort food?", + "title": "Prompt number 202", + "content": "\n

What’s your go-to comfort food?

\n", + "attribution": "dayone", + "date": "2022-07-22", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2006, + "text": "What do you listen to while you work?", + "title": "Prompt number 203", + "content": "\n

What do you listen to while you work?

\n", + "attribution": "dayone", + "date": "2022-07-23", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2007, + "text": "What would you change about modern society?", + "title": "Prompt number 204", + "content": "\n

What would you change about modern society?

\n", + "attribution": "dayone", + "date": "2022-07-24", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2008, + "text": "What are your future travel plans?", + "title": "Prompt number 205", + "content": "\n

What are your future travel plans?

\n", + "attribution": "dayone", + "date": "2022-07-25", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2009, + "text": "What's the sickest you've ever been?", + "title": "Prompt number 206", + "content": "\n

What's the sickest you've ever been?

\n", + "attribution": "dayone", + "date": "2022-07-26", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2010, + "text": "What's the story behind your nickname?", + "title": "Prompt number 207", + "content": "\n

What's the story behind your nickname?

\n", + "attribution": "dayone", + "date": "2022-07-27", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2011, + "text": "If you won two free plane tickets, where would you go?", + "title": "Prompt number 208", + "content": "\n

If you won two free plane tickets, where would you go?

\n", + "attribution": "", + "date": "2022-07-28", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2012, + "text": "If you could bring back one dinosaur, which one would it be?", + "title": "Prompt number 209", + "content": "\n

If you could bring back one dinosaur, which one would it be?

\n", + "attribution": "dayone", + "date": "2022-07-29", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2013, + "text": "How would you describe yourself to someone?", + "title": "Prompt number 210", + "content": "\n

How would you describe yourself to someone?

\n", + "attribution": "dayone", + "date": "2022-07-30", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2014, + "text": "Was today typical?", + "title": "Prompt number 211", + "content": "\n

Was today typical?

\n", + "attribution": "dayone", + "date": "2022-07-31", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2015, + "text": "What traditions have you not kept that your parents had?", + "title": "Prompt number 212", + "content": "\n

What traditions have you not kept that your parents had?

\n", + "attribution": "dayone", + "date": "2022-08-01", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2016, + "text": "How would you describe yourself to someone who can't see you?", + "title": "Prompt number 213", + "content": "\n

How would you describe yourself to someone who can't see you?

\n", + "attribution": "dayone", + "date": "2022-08-02", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2017, + "text": "Write about a random act of kindness you've done for someone.", + "title": "Prompt number 214", + "content": "\n

Write about a random act of kindness you've done for someone.

\n", + "attribution": "dayone", + "date": "2022-08-03", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2018, + "text": "What are you curious about?", + "title": "Prompt number 215", + "content": "\n

What are you curious about?

\n", + "attribution": "dayone", + "date": "2022-08-04", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2019, + "text": "Do you have any habits you're currently trying to break?", + "title": "Prompt number 216", + "content": "\n

Do you have any habits you're currently trying to break?

\n", + "attribution": "dayone", + "date": "2022-08-05", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2020, + "text": "List 30 things that make you happy.", + "title": "Prompt number 217", + "content": "\n

List 30 things that make you happy.

\n", + "attribution": "dayone", + "date": "2022-08-06", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2021, + "text": "Scour the news for an entirely uninteresting story. Consider how it connects to your life. Write about that.", + "title": "Prompt number 218", + "content": "\n

Scour the news for an entirely uninteresting story. Consider how it connects to your life. Write about that.

\n", + "attribution": "", + "date": "2022-08-07", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + }, + { + "id": 2022, + "text": "What's the most money you've ever spent on a meal? Was it worth it?", + "title": "Prompt number 219", + "content": "\n

What's the most money you've ever spent on a meal? Was it worth it?

\n", + "attribution": "", + "date": "{{now format='yyyy-MM-dd'}}", + "answered": false, + "answered_users_count": 0, + "answered_users_sample": [] + } + ] + } + } +} diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_common_starter_site_designs.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_common_starter_site_designs.json new file mode 100644 index 000000000000..5594730f41be --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/rest_v2_sites_common_starter_site_designs.json @@ -0,0 +1,647 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/wpcom/v2/common-starter-site-designs", + "queryParameters": { + "preview_width": { + "matches": "(.*)" + }, + "scale": { + "matches": "(.*)" + }, + "preview_height": { + "matches": "(.*)" + }, + "type": { + "matches": "(.*)" + }, + "_locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "designs": [ + { + "slug": "about", + "title": "Sparhead About", + "segment_id": 4, + "categories": [ + { + "slug": "about", + "title": "About", + "description": "About", + "emoji": "👋", + "order": 0 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/spearhead/mobileaboutlayout.wordpress.com/?language=en", + "theme": "spearhead", + "group": [ + "single-page", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobileaboutlayout.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobileaboutlayout.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobileaboutlayout.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "about2", + "title": "Seedlet About", + "segment_id": 4, + "categories": [ + { + "slug": "about", + "title": "About", + "description": "About", + "emoji": "👋", + "order": 0 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/seedlet/mobileaboutlayout2.wordpress.com/?language=en", + "theme": "seedlet", + "group": [ + "single-page", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobileaboutlayout2.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobileaboutlayout2.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobileaboutlayout2.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "blog-new", + "title": "Sparhead Blog", + "segment_id": 2, + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog", + "emoji": "📰", + "order": 2 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilebloglayout.wordpress.com/?language=en", + "theme": "spearhead", + "group": [ + "single-page", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilebloglayout.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilebloglayout.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilebloglayout.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "blog2", + "title": "Seedlet Blog", + "segment_id": 2, + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog", + "emoji": "📰", + "order": 2 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilebloglayout2.wordpress.com/?language=en", + "theme": "seedlet", + "group": [ + "single-page", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilebloglayout2.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilebloglayout2.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilebloglayout2.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "links", + "title": "Seedlet Links", + "segment_id": 1, + "categories": [ + { + "slug": "links", + "title": "Links", + "description": "Links", + "emoji": "🔗", + "order": 1 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilelinkslayout.wordpress.com/?language=en", + "theme": "seedlet", + "group": [ + "single-page", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilelinkslayout.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilelinkslayout.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilelinkslayout.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "links2", + "title": "Spearhead Links", + "segment_id": 1, + "categories": [ + { + "slug": "links", + "title": "Links", + "description": "Links", + "emoji": "🔗", + "order": 1 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilelinkslayout2.wordpress.com/?language=en", + "theme": "spearhead", + "group": [ + "single-page", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilelinkslayout2.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilelinkslayout2.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilelinkslayout2.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "favorites", + "title": "Seedlet Highlights", + "segment_id": 4, + "categories": [ + { + "slug": "highlights", + "title": "Highlights", + "description": "Highlights", + "emoji": "💛", + "order": 4 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilefavoriteslayout.wordpress.com/?language=en", + "theme": "seedlet", + "group": [ + "single-page", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilefavoriteslayout.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilefavoriteslayout.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilefavoriteslayout.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "favorites2", + "title": "Spearhead Highlights", + "segment_id": 4, + "categories": [ + { + "slug": "highlights", + "title": "Highlights", + "description": "Highlights", + "emoji": "💛", + "order": 4 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilefavoriteslayout2.wordpress.com/?language=en", + "theme": "spearhead", + "group": [ + "single-page", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilefavoriteslayout2.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilefavoriteslayout2.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilefavoriteslayout2.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "launch", + "title": "Seedlet Splash", + "segment_id": 1, + "categories": [ + { + "slug": "splash", + "title": "Splash", + "description": "Splash", + "emoji": "🏖️", + "order": 3 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilelaunchlayout.wordpress.com/?language=en", + "theme": "seedlet", + "group": [ + "single-page", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilelaunchlayout.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilelaunchlayout.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/seedlet/mobilelaunchlayout.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "launch2", + "title": "Spearhead Splash", + "segment_id": 1, + "categories": [ + { + "slug": "splash", + "title": "Splash", + "description": "Splash", + "emoji": "🏖️", + "order": 3 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilelaunchlayout2.wordpress.com/?language=en", + "theme": "spearhead", + "group": [ + "single-page", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilelaunchlayout2.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilelaunchlayout2.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/spearhead/mobilelaunchlayout2.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "bowen", + "title": "Bowen", + "segment_id": 2, + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog", + "emoji": "📰", + "order": 2 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/coutoire/bowenstartermobile.wordpress.com/?language=en", + "theme": "coutoire", + "group": [ + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/coutoire/bowenstartermobile.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/coutoire/bowenstartermobile.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/coutoire/bowenstartermobile.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "alves", + "title": "Alves", + "segment_id": 1, + "categories": [ + { + "slug": "business", + "title": "Business", + "description": "Business", + "emoji": "🤵", + "order": 6 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/alves/alvesstartermobile.wordpress.com/?language=en", + "theme": "alves", + "group": [ + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/alves/alvesstartermobile.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/alves/alvesstartermobile.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/alves/alvesstartermobile.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "barnsbury", + "title": "Barnsbury", + "segment_id": 1, + "categories": [ + { + "slug": "business", + "title": "Business", + "description": "Business", + "emoji": "🤵", + "order": 6 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/barnsbury/barnsburystartermobile.wordpress.com/?language=en", + "theme": "barnsbury", + "group": [ + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/barnsbury/barnsburystartermobile.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/barnsbury/barnsburystartermobile.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/barnsbury/barnsburystartermobile.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "brice", + "title": "Brice", + "segment_id": 1, + "categories": [ + { + "slug": "business", + "title": "Business", + "description": "Business", + "emoji": "🤵", + "order": 6 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/mayland/bricestartermobile.wordpress.com/?language=en", + "theme": "mayland", + "group": [ + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/mayland/bricestartermobile.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/mayland/bricestartermobile.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/mayland/bricestartermobile.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "business", + "title": "Leven", + "segment_id": 1, + "categories": [ + { + "slug": "business", + "title": "Business", + "description": "Business", + "emoji": "🤵", + "order": 6 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/leven/levenstartermobile.wordpress.com/?language=en", + "theme": "leven", + "group": [ + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/leven/levenstartermobile.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/leven/levenstartermobile.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/leven/levenstartermobile.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "overton", + "title": "Overton", + "segment_id": 1, + "categories": [ + { + "slug": "business", + "title": "Business", + "description": "Business", + "emoji": "🤵", + "order": 6 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/alves/overtonstartermobile.wordpress.com/?language=en", + "theme": "alves", + "group": [ + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/alves/overtonstartermobile.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/alves/overtonstartermobile.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/alves/overtonstartermobile.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "easley", + "title": "Easley", + "segment_id": 4, + "categories": [ + { + "slug": "professional", + "title": "Professional", + "description": "Professional", + "emoji": "💼", + "order": 5 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/maywood/easleystartermobile.wordpress.com/?language=en", + "theme": "maywood", + "group": [ + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/maywood/easleystartermobile.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/maywood/easleystartermobile.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/maywood/easleystartermobile.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "professional", + "title": "Vesta", + "segment_id": 4, + "categories": [ + { + "slug": "professional", + "title": "Professional", + "description": "Professional", + "emoji": "💼", + "order": 5 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/mayland/vestastartermobile.wordpress.com/?language=en", + "theme": "mayland", + "group": [ + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/mayland/vestastartermobile.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/mayland/vestastartermobile.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/mayland/vestastartermobile.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "reynolds", + "title": "Reynolds", + "segment_id": 4, + "categories": [ + { + "slug": "professional", + "title": "Professional", + "description": "Professional", + "emoji": "💼", + "order": 5 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/rockfield/reynoldsstartermobile.wordpress.com/?language=en", + "theme": "rockfield", + "group": [ + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/rockfield/reynoldsstartermobile.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/rockfield/reynoldsstartermobile.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/rockfield/reynoldsstartermobile.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "quadrat-white", + "title": "Quadrat", + "segment_id": 2, + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog", + "emoji": "📰", + "order": 2 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/quadrat-white/mobilefoodvertical.wordpress.com/?language=en", + "theme": "quadrat-white", + "group": [ + "food", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/quadrat-white/mobilefoodvertical.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/quadrat-white/mobilefoodvertical.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/quadrat-white/mobilefoodvertical.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "remote", + "title": "Remote", + "segment_id": 2, + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog", + "emoji": "📰", + "order": 2 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/remote/mobilenewsvertical.wordpress.com/?language=en", + "theme": "remote", + "group": [ + "news", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/remote/mobilenewsvertical.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/remote/mobilenewsvertical.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/remote/mobilenewsvertical.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "russell", + "title": "Russell", + "segment_id": 2, + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog", + "emoji": "📰", + "order": 2 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/russell/mobilestarterrussell.wordpress.com/?language=en", + "theme": "russell", + "group": [ + "lifestyle", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/russell/mobilestarterrussell.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/russell/mobilestarterrussell.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/russell/mobilestarterrussell.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "geologist-cream", + "title": "Geologist", + "segment_id": 2, + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog", + "emoji": "📰", + "order": 2 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/geologist-cream/mobilestartergeologist.wordpress.com/?language=en", + "theme": "geologist-cream", + "group": [ + "personal", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/geologist-cream/mobilestartergeologist.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/geologist-cream/mobilestartergeologist.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/geologist-cream/mobilestartergeologist.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "stewart", + "title": "Stewart", + "segment_id": 2, + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog", + "emoji": "📰", + "order": 2 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/stewart/mobilestarterstewart.wordpress.com/?language=en", + "theme": "stewart", + "group": [ + "photography", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/stewart/mobilestarterstewart.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/stewart/mobilestarterstewart.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/stewart/mobilestarterstewart.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + }, + { + "slug": "arbutus", + "title": "Arbutus", + "segment_id": 2, + "categories": [ + { + "slug": "blog", + "title": "Blog", + "description": "Blog", + "emoji": "📰", + "order": 2 + } + ], + "demo_url": "https://public-api.wordpress.com/rest/v1/template/demo/arbutus/mobilestarterarbutus.wordpress.com/?language=en", + "theme": "arbutus", + "group": [ + "travel", + "stable" + ], + "preview": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/arbutus/mobilestarterarbutus.wordpress.com/%3Flanguage%3Den?vpw=1200&vph=1560&w=500&h=650", + "preview_tablet": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/arbutus/mobilestarterarbutus.wordpress.com/%3Flanguage%3Den?vpw=800&vph=1040&w=500&h=650", + "preview_mobile": "https://s0.wp.com/mshots/v1/public-api.wordpress.com/rest/v1/template/demo/arbutus/mobilestarterarbutus.wordpress.com/%3Flanguage%3Den?vpw=400&vph=520&w=500&h=650" + } + ], + "categories": [ + { + "slug": "about", + "title": "About", + "description": "About", + "emoji": "👋", + "order": 0 + }, + { + "slug": "links", + "title": "Links", + "description": "Links", + "emoji": "🔗", + "order": 1 + }, + { + "slug": "blog", + "title": "Blog", + "description": "Blog", + "emoji": "📰", + "order": 2 + }, + { + "slug": "splash", + "title": "Splash", + "description": "Splash", + "emoji": "🏖️", + "order": 3 + }, + { + "slug": "highlights", + "title": "Highlights", + "description": "Highlights", + "emoji": "💛", + "order": 4 + }, + { + "slug": "professional", + "title": "Professional", + "description": "Professional", + "emoji": "💼", + "order": 5 + }, + { + "slug": "business", + "title": "Business", + "description": "Business", + "emoji": "🤵", + "order": 6 + } + ] + } + } +} diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/site-info-wordpress-com.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/site-info-wordpress-com.json new file mode 100644 index 000000000000..35dbffdc4942 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/site-info-wordpress-com.json @@ -0,0 +1,29 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/connect/site-info", + "queryParameters": { + "url": { + "matches": "https://(.+\\.)?wordpress\\.com" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "urlAfterRedirects": "{{request.requestLine.query.url}}", + "exists": true, + "isWordPress": true, + "hasJetpack": false, + "isJetpackActive": false, + "skipRemoteInstall": false, + "isJetpackConnected": false, + "isWordPressDotCom": true + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/site_106707880_tags.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/site_106707880_tags.json new file mode 100644 index 000000000000..f3affac862c1 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/site_106707880_tags.json @@ -0,0 +1,20 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/tags" + }, + "response": { + "status": 200, + "jsonBody": { + "found": 0, + "tags": [ + + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_181977606_users.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_181977606_users.json new file mode 100644 index 000000000000..79b7e7ef67ae --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_181977606_users.json @@ -0,0 +1,44 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/181977606/users.*", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "authors_only": { + "equalTo": "1" + }, + "number": { + "equalTo": "100" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 1, + "users": [ + { + "ID": 152748359, + "login": "appstorescreens", + "email": "test@example.com", + "name": "appstorescreens", + "first_name": "", + "last_name": "", + "nice_name": "e2eflowtestingmobile", + "URL": "http://tricountyrealestate.wordpress.com", + "avatar_URL": "https://0.gravatar.com/avatar/35029b2103460109f574c38dfeea5f3f?s=96&d=identicon&r=G", + "profile_URL": "https://en.gravatar.com/appstorescreens", + "ip_address": "", + "site_ID": 181977606, + "site_visible": true, + "roles": [ + "administrator" + ], + "is_super_admin": false + } + ] + } + } + } \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_categories.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_categories.json new file mode 100644 index 000000000000..f5be37fd541e --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_categories.json @@ -0,0 +1,51 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/categories.*", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 2, + "categories": [ + { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "feed_url": "http://infocusphotographers.com/category/uncategorized/feed/", + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495" + } + } + }, + { + "ID": 1674, + "name": "Wedding", + "slug": "wedding", + "description": "", + "post_count": 1, + "feed_url": "http://infocusphotographers.com/category/wedding/feed/", + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/categories/slug:wedding", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495/categories/slug:wedding/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/181851495" + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_comments.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_comments.json new file mode 100644 index 000000000000..e7bd55a9a4b1 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_comments.json @@ -0,0 +1,57 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/comments.*", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "ID": 2, + "post": { + "ID": 56, + "title": "My Top 10 Pastry Recipes", + "type": "post", + "link": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/181977606\/posts\/56" + }, + "author": { + "ID": 98380071, + "login": "", + "email": "test@example.com", + "name": "Reyansh Pawar", + "first_name": "", + "last_name": "", + "nice_name": "", + "URL": "http://reyanshpawar.wordpress.com", + "avatar_URL": "https://1.gravatar.com/avatar/4b7ac5a2b497adbd473313b0701023a4?s=256&d=https%3A%2F%2Fsecure.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D256&r=G", + "profile_URL": "https:\/\/en.gravatar.com\/990973e34a3efd5373e44fd270755295", + "ip_address": "000.0.000.000" + }, + "date": "{{now}}", + "URL": "https://weekendbakesblog.wordpress.com/2020/10/05/my-top-10-pastry-recipes/comment-page-1/#comment-2", + "short_URL": "https:\/\/wp.me\/pc82cg-sp%23comment-406", + "content": "Can you use almond or rice flour instead of the whole wheat to make the peach scone recipe gluten free?\n", + "status": "approved", + "parent": false, + "type": "comment", + "like_count": 4, + "i_like": true, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/181977606\/comments\/2", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/181977606\/comments\/2\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/181977606", + "post": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/181977606\/posts\/56", + "replies": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/181977606\/comments\/2\/replies\/", + "likes": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/181977606\/comments\/2\/likes\/" + } + }, + "can_moderate": true, + "i_replied": false + } + } +} diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_publicize_connections.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_publicize_connections.json new file mode 100644 index 000000000000..df9e8f236e4a --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_publicize_connections.json @@ -0,0 +1,19 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/publicize-connections.*", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "connections": [ + + ] + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_users.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_users.json new file mode 100644 index 000000000000..16590d9e991e --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/sites_users.json @@ -0,0 +1,27 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/users.*", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "authors_only": { + "equalTo": "1" + }, + "number": { + "equalTo": "100" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 0, + "site_ID": 106707880, + "comments": [ + + ] + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/wordpressdotcom.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/wordpressdotcom.json new file mode 100644 index 000000000000..360b9eb2ddc9 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/sites/wordpressdotcom.json @@ -0,0 +1,18 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/wordpress.com/", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 403, + "jsonBody": { + "error": "unauthorized", + "message": "User cannot access this restricted blog" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/rest_v11_sites_158396482_stats_post_4.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/rest_v11_sites_158396482_stats_post_4.json new file mode 100644 index 000000000000..123f8cbee583 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/rest_v11_sites_158396482_stats_post_4.json @@ -0,0 +1,632 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/106707880/stats/post/4(/)?($|\\?.*)" + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "views": 32, + "years": { + "2016": { + "months": { + "5": 1, + "6": 2, + "7": 0, + "8": 0, + "9": 0, + "10": 0, + "11": 0, + "12": 0 + }, + "total": 3 + }, + "2017": { + "months": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 1, + "7": 3, + "8": 3, + "9": 1, + "10": 0, + "11": 1, + "12": 1 + }, + "total": 10 + }, + "2018": { + "months": { + "1": 2, + "2": 3, + "3": 3, + "4": 2, + "5": 4, + "6": 1, + "7": 0, + "8": 0, + "9": 0, + "10": 1, + "11": 1, + "12": 0 + }, + "total": 17 + }, + "2019": { + "months": { + "1": 0, + "2": 2, + "3": 0, + "4": 0, + "5": 0 + }, + "total": 2 + } + }, + "averages": { + "2016": { + "months": { + "5": 0, + "6": 0, + "7": 0, + "8": 0, + "9": 0, + "10": 0, + "11": 0, + "12": 0 + }, + "overall": 0 + }, + "2017": { + "months": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": 0, + "9": 0, + "10": 0, + "11": 0, + "12": 0 + }, + "overall": 0 + }, + "2018": { + "months": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0, + "8": 0, + "9": 0, + "10": 0, + "11": 0, + "12": 0 + }, + "overall": 0 + }, + "2019": { + "months": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0 + }, + "overall": 0 + } + }, + "weeks": [ + { + "days": [ + { + "day": "{{now offset='-39 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-38 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-37 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-36 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-35 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-34 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-33 days' format='yyyy-MM-dd'}}", + "count": 0 + } + ], + "total": 0, + "average": 0, + "change": null + }, + { + "days": [ + { + "day": "{{now offset='-32 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-31 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-30 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-29 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-28 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-27 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-26 days' format='yyyy-MM-dd'}}", + "count": 0 + } + ], + "total": 0, + "average": 0, + "change": 0 + }, + { + "days": [ + { + "day": "{{now offset='-25 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-24 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-23 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-22 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-21 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-20 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-19 days' format='yyyy-MM-dd'}}", + "count": 0 + } + ], + "total": 0, + "average": 0, + "change": 0 + }, + { + "days": [ + { + "day": "{{now offset='-18 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-17 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-16 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-15 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-14 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-13 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-12 days' format='yyyy-MM-dd'}}", + "count": 0 + } + ], + "total": 0, + "average": 0, + "change": 0 + }, + { + "days": [ + { + "day": "{{now offset='-11 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-10 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-9 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-8 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-7 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-6 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-5 days' format='yyyy-MM-dd'}}", + "count": 0 + } + ], + "total": 0, + "average": 0, + "change": 0 + }, + { + "days": [ + { + "day": "{{now offset='-4 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-3 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-2 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now offset='-1 days' format='yyyy-MM-dd'}}", + "count": 0 + }, + { + "day": "{{now format='yyyy-MM-dd'}}", + "count": 0 + } + ], + "total": 0, + "average": 0, + "change": 0 + } + ], + "fields": [ + "period", + "views" + ], + "data": [ + [ + "{{now offset='-63 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-62 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-61 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-60 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-59 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-58 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-57 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-56 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-55 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-54 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-53 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-52 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-51 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-50 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-49 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-48 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-47 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-46 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-45 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-44 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-43 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-42 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-41 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-40 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-39 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-38 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-37 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-36 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-35 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-34 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-33 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-32 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-31 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-30 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-29 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-28 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-27 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-26 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-25 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-24 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-23 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-22 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-21 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-20 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-19 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-18 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-17 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-16 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-15 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-14 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-13 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-12 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-11 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-10 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-9 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-8 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-7 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-6 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-5 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-4 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-3 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-2 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now offset='-1 days' format='yyyy-MM-dd'}}", + 0 + ], + [ + "{{now format='yyyy-MM-dd'}}", + 0 + ] + ], + "highest_month": 4, + "highest_day_average": 0, + "highest_week_average": 0, + "post": { + "ID": 4, + "post_author": "152748359", + "post_date": "2016-04-19 21:28:27", + "post_date_gmt": "2016-04-19 21:28:27", + "post_content": "John was shortlisted for the World Press Photo competition, an international celebration of the best photojournalism of the year.\n\nhttps://twitter.com/wordpressdotcom/status/702181837079187456", + "post_title": "Some News to Share", + "post_excerpt": "", + "post_status": "publish", + "comment_status": "open", + "ping_status": "open", + "post_password": "", + "post_name": "some-news-to-share", + "to_ping": "", + "pinged": "", + "post_modified": "2018-03-23 00:20:36", + "post_modified_gmt": "2018-03-23 00:20:36", + "post_content_filtered": "", + "post_parent": 0, + "guid": "https://infocusphotographers.wordpress.com/?p=4", + "menu_order": 0, + "post_type": "post", + "post_mime_type": "", + "comment_count": "0", + "filter": "raw", + "permalink": "http://infocusphotographers.com/2016/04/19/some-news-to-share/" + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats.json new file mode 100644 index 000000000000..3880523bca4c --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats.json @@ -0,0 +1,201 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/stats(/+)\\?(.*)", + "queryParameters": { + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "stats": { + "visitors_today": 0, + "visitors_yesterday": 1, + "visitors": 1201, + "views_today": 0, + "views_yesterday": 1, + "views_best_day": "2018-01-15", + "views_best_day_total": 48, + "views": 2243, + "comments": 0, + "posts": 2, + "followers_blog": 1, + "followers_comments": 0, + "comments_per_month": 0, + "comments_most_active_recent_day": "", + "comments_most_active_time": "N/A", + "comments_spam": 3, + "categories": 2, + "tags": 0, + "shares": 57, + "shares_facebook": 23, + "shares_twitter": 14, + "shares_press-this": 1 + }, + "visits": { + "unit": "day", + "fields": [ + "period", + "views", + "visitors" + ], + "data": [ + [ + "{{now offset='-23 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-22 days' format='yyyy-MM-dd'}}", + 1, + 1 + ], + [ + "{{now offset='-21 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-20 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-19 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-18 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-17 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-4 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-4 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-4 days' format='yyyy-MM-dd'}}", + 1, + 1 + ], + [ + "{{now offset='-4 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-4 days' format='yyyy-MM-dd'}}", + 24, + 3 + ], + [ + "{{now offset='-4 days' format='yyyy-MM-dd'}}", + 6, + 4 + ], + [ + "{{now offset='-16 days' format='yyyy-MM-dd'}}", + 3, + 3 + ], + [ + "{{now offset='-15 days' format='yyyy-MM-dd'}}", + 9, + 2 + ], + [ + "{{now offset='-14 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-13 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-12 days' format='yyyy-MM-dd'}}", + 2, + 2 + ], + [ + "{{now offset='-11 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-10 days' format='yyyy-MM-dd'}}", + 3, + 2 + ], + [ + "{{now offset='-9 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-8 days' format='yyyy-MM-dd'}}", + 6, + 3 + ], + [ + "{{now offset='-7 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-6 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-5 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-4 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-3 days' format='yyyy-MM-dd'}}", + 0, + 0 + ], + [ + "{{now offset='-2 days' format='yyyy-MM-dd'}}", + 1, + 1 + ], + [ + "{{now offset='-1 days' format='yyyy-MM-dd'}}", + 1, + 1 + ], + [ + "{{now format='yyyy-MM-dd'}}", + 0, + 0 + ] + ] + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks-day.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks-day.json new file mode 100644 index 000000000000..d5b040c57aa9 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks-day.json @@ -0,0 +1,22 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/clicks/" + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "days": { + "{{now format='yyyy-MM-dd'}}": { + "clicks": [ + + ], + "other_clicks": 0, + "total_clicks": 0 + } + }, + "period": "day" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks-month.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks-month.json new file mode 100644 index 000000000000..bafbe70d5288 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks-month.json @@ -0,0 +1,36 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.1/sites/([0-9]+)/stats/clicks/", + "queryParameters": { + "period": { + "equalTo": "month" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-07-31", + "days": { + "2019-07-01": { + "clicks": [ + + ], + "other_clicks": 0, + "total_clicks": 0 + } + }, + "period": "month" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks-year.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks-year.json new file mode 100644 index 000000000000..659dfe2260da --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks-year.json @@ -0,0 +1,72 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/clicks/", + "queryParameters": { + "period": { + "equalTo": "year" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-12-31", + "days": { + "2019-01-01": { + "clicks": [ + { + "icon": null, + "url": "https://yourgroovysite.wordpress.com/about/", + "name": "yourgroovysite.wordpress.com/about/", + "views": 5, + "children": null + }, + { + "icon": null, + "url": null, + "name": "WordPress.com Media ", + "views": 3, + "children": [ + { + "url": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1443745029291-d5c27bc0b562.jpeg", + "name": "infocusphotographers.files.wordpress.com/2016/02/photo-1443745029291-d5c27bc0b562.jpeg", + "views": 1 + }, + { + "url": "https://infocusphotographers.files.wordpress.com/2016/02/unsplash_5252bb51404f8_1.jpeg", + "name": "infocusphotographers.files.wordpress.com/2016/02/unsplash_5252bb51404f8_1.jpeg", + "views": 1 + }, + { + "url": "https://infocusphotographers.files.wordpress.com/2016/02/photo-1447940334172-44e027c1f71c.jpeg", + "name": "infocusphotographers.files.wordpress.com/2016/02/photo-1447940334172-44e027c1f71c.jpeg", + "views": 1 + } + ] + }, + { + "icon": "https://secure.gravatar.com/blavatar/653166773dc88127bd3afe0b6dfe5ea7?s=48", + "url": "https://wordpress.com/?ref=footer_blog", + "name": "wordpress.com/?ref=footer_blog", + "views": 2, + "children": null + } + ], + "other_clicks": 0, + "total_clicks": 10 + } + }, + "period": "year" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks.json new file mode 100644 index 000000000000..6a9c9a7bddcf --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_clicks.json @@ -0,0 +1,36 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/clicks/", + "queryParameters": { + "period": { + "equalTo": "day" + }, + "max": { + "equalTo": "[0-9]+" + }, + "date": { + "equalTo": "2019-07-17" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "days": { + "{{now format='yyyy-MM-dd'}}": { + "clicks": [ + + ], + "other_clicks": 0, + "total_clicks": 0 + } + }, + "period": "day" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_comments.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_comments.json new file mode 100644 index 000000000000..75ddfbe028c6 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_comments.json @@ -0,0 +1,23 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/stats/comments/.*" + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "authors": [ + + ], + "posts": [ + + ], + "monthly_comments": 0, + "total_comments": 0, + "most_active_day": null, + "most_active_time": "N/A", + "most_commented_post": false + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_country-views-day.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_country-views-day.json new file mode 100644 index 000000000000..0f470cc70343 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_country-views-day.json @@ -0,0 +1,64 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/country-views/", + "queryParameters": { + "period": { + "equalTo": "day" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "days": { + "{{now format='yyyy-MM-dd'}}": { + "views": [ + { + "country_code": "IN", + "views": 3 + } + ], + "other_views": 0, + "total_views": 3 + } + }, + "country-info": { + "CA": { + "flag_icon": "https://secure.gravatar.com/blavatar/7f3085b2665ac78346be5923724ba4c6?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/685ac009247bf3378158ee41c3f8f250?s=48", + "country_full": "Canada", + "map_region": "021" + }, + "IN": { + "flag_icon": "https://secure.gravatar.com/blavatar/217b6ac82c316e3a176351cef1d2d0b6?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/d449a857f065ec5ddf1e7a086001a541?s=48", + "country_full": "India", + "map_region": "034" + }, + "FR": { + "flag_icon": "https://secure.gravatar.com/blavatar/bff4fa191e38bc0a316410b8fd2958fd?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/8139b3de98c828078f8a0f7deec0c79b?s=48", + "country_full": "France", + "map_region": "155" + }, + "CO": { + "flag_icon": "https://secure.gravatar.com/blavatar/f9951a3a717913a4cb99ce128cd42ef6?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/30142281e988bbfd084a1c4a9eaef1f9?s=48", + "country_full": "Colombia", + "map_region": "005" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_country-views-month.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_country-views-month.json new file mode 100644 index 000000000000..3ec0fc34eb17 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_country-views-month.json @@ -0,0 +1,76 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.1/sites/([0-9]+)/stats/country-views/", + "queryParameters": { + "period": { + "equalTo": "month" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "days": { + "{{now format='yyyy-MM-dd'}}": { + "views": [ + { + "country_code": "CA", + "views": 8 + }, + { + "country_code": "IN", + "views": 6 + }, + { + "country_code": "FR", + "views": 1 + }, + { + "country_code": "CO", + "views": 1 + } + ], + "other_views": 0, + "total_views": 16 + } + }, + "country-info": { + "CA": { + "flag_icon": "https://secure.gravatar.com/blavatar/7f3085b2665ac78346be5923724ba4c6?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/685ac009247bf3378158ee41c3f8f250?s=48", + "country_full": "Canada", + "map_region": "021" + }, + "IN": { + "flag_icon": "https://secure.gravatar.com/blavatar/217b6ac82c316e3a176351cef1d2d0b6?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/d449a857f065ec5ddf1e7a086001a541?s=48", + "country_full": "India", + "map_region": "034" + }, + "FR": { + "flag_icon": "https://secure.gravatar.com/blavatar/bff4fa191e38bc0a316410b8fd2958fd?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/8139b3de98c828078f8a0f7deec0c79b?s=48", + "country_full": "France", + "map_region": "155" + }, + "CO": { + "flag_icon": "https://secure.gravatar.com/blavatar/f9951a3a717913a4cb99ce128cd42ef6?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/30142281e988bbfd084a1c4a9eaef1f9?s=48", + "country_full": "Colombia", + "map_region": "005" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_country-views-year.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_country-views-year.json new file mode 100644 index 000000000000..64d05cfe5272 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_country-views-year.json @@ -0,0 +1,106 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/country-views/", + "queryParameters": { + "period": { + "equalTo": "year" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-12-31", + "days": { + "2019-01-01": { + "views": [ + { + "country_code": "IN", + "views": 121 + }, + { + "country_code": "US", + "views": 60 + }, + { + "country_code": "CA", + "views": 44 + }, + { + "country_code": "DE", + "views": 15 + }, + { + "country_code": "FR", + "views": 14 + }, + { + "country_code": "GB", + "views": 12 + }, + { + "country_code": "CN", + "views": 12 + } + ], + "other_views": 35, + "total_views": 313 + } + }, + "country-info": { + "IN": { + "flag_icon": "https://secure.gravatar.com/blavatar/217b6ac82c316e3a176351cef1d2d0b6?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/d449a857f065ec5ddf1e7a086001a541?s=48", + "country_full": "India", + "map_region": "034" + }, + "US": { + "flag_icon": "https://secure.gravatar.com/blavatar/5a83891a81b057fed56930a6aaaf7b3c?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/9f4faa5ad0c723474f7a6d810172447c?s=48", + "country_full": "United States", + "map_region": "021" + }, + "CA": { + "flag_icon": "https://secure.gravatar.com/blavatar/7f3085b2665ac78346be5923724ba4c6?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/685ac009247bf3378158ee41c3f8f250?s=48", + "country_full": "Canada", + "map_region": "021" + }, + "DE": { + "flag_icon": "https://secure.gravatar.com/blavatar/e13c43aa12cd8aada2ffb1663970374f?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/82f933cabd7491369097f681958bdaed?s=48", + "country_full": "Germany", + "map_region": "155" + }, + "FR": { + "flag_icon": "https://secure.gravatar.com/blavatar/bff4fa191e38bc0a316410b8fd2958fd?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/8139b3de98c828078f8a0f7deec0c79b?s=48", + "country_full": "France", + "map_region": "155" + }, + "GB": { + "flag_icon": "https://secure.gravatar.com/blavatar/45d1fd3f398678452fd02153f569ce01?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/85ac446c6eefc7e959e15a6877046da3?s=48", + "country_full": "United Kingdom", + "map_region": "154" + }, + "CN": { + "flag_icon": "https://secure.gravatar.com/blavatar/7b8f2d7453c642ac4c6920bd021bf881?s=48", + "flat_flag_icon": "https://secure.gravatar.com/blavatar/381326b114d5f7264d3207021c3d6437?s=48", + "country_full": "China", + "map_region": "030" + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_file-downloads-day.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_file-downloads-day.json new file mode 100644 index 000000000000..dec4560545ea --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_file-downloads-day.json @@ -0,0 +1,78 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/file-downloads/", + "queryParameters": { + "period": { + "equalTo": "day" + }, + "num": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-07-31", + "period": "month", + "days": { + "{{now offset='-1 days' format='yyyy-MM-dd'}}": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-06-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-05-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-04-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-03-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-02-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-01-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_file-downloads-month.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_file-downloads-month.json new file mode 100644 index 000000000000..9eee332d6abf --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_file-downloads-month.json @@ -0,0 +1,78 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.1/sites/([0-9]+)/stats/file-downloads/", + "queryParameters": { + "period": { + "equalTo": "month" + }, + "num": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-07-31", + "period": "month", + "days": { + "{{now offset='-1 days' format='yyyy-MM-dd'}}": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-06-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-05-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-04-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-03-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-02-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2019-01-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_file-downloads-year.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_file-downloads-year.json new file mode 100644 index 000000000000..90ae3f45fa65 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_file-downloads-year.json @@ -0,0 +1,78 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/file-downloads/", + "queryParameters": { + "period": { + "equalTo": "year" + }, + "num": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-12-31", + "period": "year", + "days": { + "2019-01-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2018-01-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2017-01-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2016-01-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2015-01-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2014-01-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + }, + "2013-01-01": { + "files": [ + + ], + "other_downloads": 0, + "total_downloads": 0 + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_followers_email.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_followers_email.json new file mode 100644 index 000000000000..f418613088ef --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_followers_email.json @@ -0,0 +1,42 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/stats/followers/.*", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "max": { + "matches": "(.*)" + }, + "type": { + "equalTo": "email" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "page": 1, + "pages": 1, + "total": 1, + "total_email": 1, + "total_wpcom": 0, + "subscribers": [ + { + "avatar": "https://0.gravatar.com/avatar/69a6814a8e85942fee3e846b17554972?s=64&d=https%3A%2F%2F0.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D64&r=G", + "label": "follower@example.com", + "ID": "12345", + "url": null, + "follow_data": null, + "date_subscribed": "2018-02-21T03:10:54+00:00" + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_followers_wpcom.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_followers_wpcom.json new file mode 100644 index 000000000000..7377417b8eb0 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_followers_wpcom.json @@ -0,0 +1,35 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/stats/followers/.*", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "max": { + "matches": "(.*)" + }, + "type": { + "equalTo": "wpcom" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "page": 1, + "pages": 0, + "total": 0, + "total_email": 1, + "total_wpcom": 0, + "subscribers": [ + + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_insights.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_insights.json new file mode 100644 index 000000000000..5778943de7bb --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_insights.json @@ -0,0 +1,114 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/stats/insights/.*" + }, + "response": { + "status": 200, + "jsonBody": { + "highest_hour": 4, + "highest_hour_percent": 24.742268041237114, + "highest_day_of_week": 3, + "highest_day_percent": 33.793103448275865, + "days": { + "3": 49, + "0": 45, + "2": 21, + "1": 15, + "5": 6, + "4": 5, + "6": 4 + }, + "hours": { + "04": 24, + "17": 9, + "06": 8, + "21": 7, + "09": 6, + "12": 6, + "15": 5, + "18": 5, + "08": 4, + "16": 4, + "02": 4, + "05": 3, + "01": 2, + "10": 2, + "11": 2, + "00": 1, + "23": 1, + "03": 1, + "20": 1, + "14": 1, + "22": 1, + "19": 0, + "07": 0, + "13": 0 + }, + "hourly_views": { + "{{now offset='-2 days' format='yyyy-MM-dd'}} 10:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 11:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 12:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 13:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 14:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 15:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 16:00:00": 1, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 17:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 18:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 19:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 20:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 21:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 22:00:00": 0, + "{{now offset='-2 days' format='yyyy-MM-dd'}} 23:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 00:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 01:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 02:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 03:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 04:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 05:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 06:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 07:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 08:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 09:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 10:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 11:00:00": 1, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 12:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 13:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 14:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 15:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 16:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 17:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 18:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 19:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 20:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 21:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 22:00:00": 0, + "{{now offset='-1 days' format='yyyy-MM-dd'}} 23:00:00": 0, + "{{now format='yyyy-MM-dd'}} 00:00:00": 0, + "{{now format='yyyy-MM-dd'}} 01:00:00": 0, + "{{now format='yyyy-MM-dd'}} 02:00:00": 0, + "{{now format='yyyy-MM-dd'}} 03:00:00": 0, + "{{now format='yyyy-MM-dd'}} 04:00:00": 0, + "{{now format='yyyy-MM-dd'}} 05:00:00": 0, + "{{now format='yyyy-MM-dd'}} 06:00:00": 0, + "{{now format='yyyy-MM-dd'}} 07:00:00": 0, + "{{now format='yyyy-MM-dd'}} 08:00:00": 0, + "{{now format='yyyy-MM-dd'}} 09:00:00": 0 + }, + "years": [ + { + "year": "2016", + "total_posts": 2, + "total_words": 105, + "avg_words": 52.5, + "total_likes": 0, + "avg_likes": 0, + "total_comments": 0, + "avg_comments": 0, + "total_images": 2, + "avg_images": 1 + } + ] + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_post_106.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_post_106.json new file mode 100644 index 000000000000..038007aba9c0 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_post_106.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.1/sites/.*/stats/post/(.*)(/?)" + }, + "response": { + "status": 200, + "jsonBody": { + "views": 35 + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_publicize.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_publicize.json new file mode 100644 index 000000000000..15b406ad98a2 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_publicize.json @@ -0,0 +1,27 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/stats/publicize/.*", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "max": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "services": [ + + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers-day.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers-day.json new file mode 100644 index 000000000000..590b133fbd50 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers-day.json @@ -0,0 +1,36 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/referrers/", + "queryParameters": { + "period": { + "equalTo": "day" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2020-01-01", + "days": { + "2020-01-01": { + "groups": [ + + ], + "other_views": 0, + "total_views": 0 + } + }, + "period": "day" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers-month.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers-month.json new file mode 100644 index 000000000000..edc495a7e338 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers-month.json @@ -0,0 +1,50 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.1/sites/([0-9]+)/stats/referrers/", + "queryParameters": { + "period": { + "equalTo": "month" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-07-31", + "days": { + "2019-07-01": { + "groups": [ + { + "group": "Search Engines", + "name": "Search Engines", + "icon": "https://wordpress.com/i/stats/search-engine.png", + "total": 1, + "follow_data": null, + "results": [ + { + "name": "Google Search", + "url": "http://www.google.com/", + "icon": "https://secure.gravatar.com/blavatar/6741a05f4bc6e5b65f504c4f3df388a1?s=48", + "views": 1 + } + ] + } + ], + "other_views": 0, + "total_views": 1 + } + }, + "period": "month" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers-year.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers-year.json new file mode 100644 index 000000000000..37f7619e443a --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers-year.json @@ -0,0 +1,97 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/referrers/", + "queryParameters": { + "period": { + "equalTo": "year" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-12-31", + "days": { + "2019-01-01": { + "groups": [ + { + "group": "Search Engines", + "name": "Search Engines", + "icon": "https://wordpress.com/i/stats/search-engine.png", + "total": 28, + "follow_data": null, + "results": [ + { + "name": "Google Search", + "icon": "https://secure.gravatar.com/blavatar/6741a05f4bc6e5b65f504c4f3df388a1?s=48", + "views": 26, + "children": [ + { + "name": "google.com", + "url": "http://www.google.com/", + "icon": null, + "views": 23 + }, + { + "name": "google.co.in", + "url": "http://www.google.co.in", + "icon": "https://secure.gravatar.com/blavatar/b8b1615bdc37f756888332cc17e0a5bf?s=48", + "views": 3 + } + ] + }, + { + "name": "Yahoo Search", + "url": "https://search.yahoo.com/", + "icon": "https://secure.gravatar.com/blavatar/5029a4a8e7da221ae517ddaa0dd5422b?s=48", + "views": 1 + }, + { + "name": "Bing", + "url": "http://www.bing.com", + "icon": "https://secure.gravatar.com/blavatar/112a7e096595d1c32c4ecdfd9e56b66c?s=48", + "views": 1 + } + ] + }, + { + "group": "facebook.com", + "name": "Facebook", + "url": "http://facebook.com/", + "icon": "https://secure.gravatar.com/blavatar/2343ec78a04c6ea9d80806345d31fd78?s=48", + "total": 9, + "follow_data": null, + "results": { + "views": 9 + } + }, + { + "group": "instagram.com", + "name": "Instagram", + "url": "http://l.instagram.com/?u=http%3A%2F%2Finfocusphotographers.com%2F&e=AT3BNF8FeF67OPMIY3HP5Jeb7nbdGNsihf8RKdPUQh1TX6CKkSeszZiAm8E3-newXBc2VLtbKKQML1BwLFOKvCJ4maub2TNd_oVhDT2_JVebheBgL4v6J3SSa_s", + "icon": "https://secure.gravatar.com/blavatar/8dc6460bbbb088757ed67ed8fb316b1b?s=48", + "total": 1, + "follow_data": null, + "results": { + "views": 1 + } + } + ], + "other_views": 0, + "total_views": 38 + } + }, + "period": "year" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers.json new file mode 100644 index 000000000000..f3ddf65020c4 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_referrers.json @@ -0,0 +1,27 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/referrers/" + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "days": { + "{{now format='yyyy-MM-dd'}}": { + "groups": [ + + ], + "other_views": 0, + "total_views": 0 + } + }, + "period": "day" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms-month.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms-month.json new file mode 100644 index 000000000000..ced1ebdde24b --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms-month.json @@ -0,0 +1,37 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.1/sites/([0-9]+)/stats/search-terms/", + "queryParameters": { + "period": { + "equalTo": "month" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-07-31", + "period": "month", + "days": { + "2019-07-01": { + "search_terms": [ + + ], + "encrypted_search_terms": 0, + "other_search_terms": 0, + "total_search_terms": 0 + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms-year.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms-year.json new file mode 100644 index 000000000000..4a469528b931 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms-year.json @@ -0,0 +1,40 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/search-terms/", + "queryParameters": { + "period": { + "equalTo": "year" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-12-31", + "period": "year", + "days": { + "2019-01-01": { + "search_terms": [ + { + "term": "+http:/infocusphotographers.com|", + "views": 1 + } + ], + "encrypted_search_terms": 3, + "other_search_terms": 0, + "total_search_terms": 4 + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms.json new file mode 100644 index 000000000000..b38b63689100 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms.json @@ -0,0 +1,28 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/search-terms/" + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "period": "day", + "days": { + "{{now format='yyyy-MM-dd'}}": { + "search_terms": [ + + ], + "encrypted_search_terms": 0, + "other_search_terms": 0, + "total_search_terms": 0 + } + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms_day.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms_day.json new file mode 100644 index 000000000000..5c95e6d0e094 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_search-terms_day.json @@ -0,0 +1,37 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/search-terms/", + "queryParameters": { + "period": { + "equalTo": "day" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "period": "day", + "days": { + "{{now format='yyyy-MM-dd'}}": { + "search_terms": [ + + ], + "encrypted_search_terms": 0, + "other_search_terms": 0, + "total_search_terms": 0 + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_streak.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_streak.json new file mode 100644 index 000000000000..a6bd19143035 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_streak.json @@ -0,0 +1,26 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/stats/streak/.*" + }, + "response": { + "status": 200, + "jsonBody": { + "streak": { + "long": { + "start": "", + "end": "", + "length": 1 + }, + "current": { + "start": "", + "end": "", + "length": 0 + } + }, + "data": [ + + ] + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_summary.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_summary.json new file mode 100644 index 000000000000..24effe6d1065 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_summary.json @@ -0,0 +1,24 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/stats/summary/.*" + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "period": "day", + "views": 0, + "visitors": 0, + "likes": 0, + "reblogs": 0, + "comments": 0, + "followers": 1 + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_tags.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_tags.json new file mode 100644 index 000000000000..e6092f9e1176 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_tags.json @@ -0,0 +1,28 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1.1/sites/.*/stats/tags/.*", + "queryParameters": { + "locale": { + "matches": "(.*)" + }, + "max": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "tags": [ + + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors-day.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors-day.json new file mode 100644 index 000000000000..5352d383ee8e --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors-day.json @@ -0,0 +1,99 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/top-authors/", + "queryParameters": { + "period": { + "equalTo": "day" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-07-31", + "days": { + "2019-07-01": { + "authors": [ + { + "name": "Leah Elaine Rand", + "avatar": "https://0.gravatar.com/avatar/62937c26a2a79bae5921ca9e85be8040?s=64&d=https%3A%2F%2F0.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D64&r=G", + "views": 12, + "posts": [ + { + "id": 1, + "title": "Home", + "url": "http://infocusphotographers.com/about/", + "views": 12, + "video": false + } + ], + "follow_data": { + "params": { + "stat-source": "stats_author", + "follow-text": "Follow", + "following-text": "Following", + "following-hover-text": "Unfollow", + "blog_domain": "leahinfocus.wordpress.com", + "blog_url": "http://leahinfocus.wordpress.com", + "blog_id": 106523982, + "site_id": 106523982, + "blog_title": "Leah in Focus", + "is_following": false + }, + "type": "follow" + } + }, + { + "name": "Andrea Zoellner", + "avatar": "https://0.gravatar.com/avatar/c294f0a8bad2d33f62dd1c2d81b38f99?s=64&d=https%3A%2F%2F0.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D64&r=G", + "views": 2, + "posts": [ + { + "id": 22, + "title": "Our Story", + "url": "http://infocusphotographers.com/our-story/", + "views": 1, + "video": false + }, + { + "id": 24, + "title": "Portfolio", + "url": "http://infocusphotographers.com/portfolio/", + "views": 1, + "video": false + } + ], + "follow_data": { + "params": { + "stat-source": "stats_author", + "follow-text": "Follow", + "following-text": "Following", + "following-hover-text": "Unfollow", + "blog_domain": "andreazoellner.wordpress.com", + "blog_url": "http://andreazoellner.wordpress.com", + "blog_id": 67702148, + "site_id": 67702148, + "blog_title": "Andrea Zoellner", + "is_following": false + }, + "type": "follow" + } + } + ], + "other_views": 0 + } + }, + "period": "day" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors-month.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors-month.json new file mode 100644 index 000000000000..403024c14198 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors-month.json @@ -0,0 +1,99 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.1/sites/([0-9]+)/stats/top-authors//", + "queryParameters": { + "period": { + "equalTo": "month" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-07-31", + "days": { + "2019-07-01": { + "authors": [ + { + "name": "Leah Elaine Rand", + "avatar": "https://0.gravatar.com/avatar/62937c26a2a79bae5921ca9e85be8040?s=64&d=https%3A%2F%2F0.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D64&r=G", + "views": 12, + "posts": [ + { + "id": 1, + "title": "Home", + "url": "http://infocusphotographers.com/about/", + "views": 12, + "video": false + } + ], + "follow_data": { + "params": { + "stat-source": "stats_author", + "follow-text": "Follow", + "following-text": "Following", + "following-hover-text": "Unfollow", + "blog_domain": "leahinfocus.wordpress.com", + "blog_url": "http://leahinfocus.wordpress.com", + "blog_id": 106523982, + "site_id": 106523982, + "blog_title": "Leah in Focus", + "is_following": false + }, + "type": "follow" + } + }, + { + "name": "Andrea Zoellner", + "avatar": "https://0.gravatar.com/avatar/c294f0a8bad2d33f62dd1c2d81b38f99?s=64&d=https%3A%2F%2F0.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D64&r=G", + "views": 2, + "posts": [ + { + "id": 22, + "title": "Our Story", + "url": "http://infocusphotographers.com/our-story/", + "views": 1, + "video": false + }, + { + "id": 24, + "title": "Portfolio", + "url": "http://infocusphotographers.com/portfolio/", + "views": 1, + "video": false + } + ], + "follow_data": { + "params": { + "stat-source": "stats_author", + "follow-text": "Follow", + "following-text": "Following", + "following-hover-text": "Unfollow", + "blog_domain": "andreazoellner.wordpress.com", + "blog_url": "http://andreazoellner.wordpress.com", + "blog_id": 67702148, + "site_id": 67702148, + "blog_title": "Andrea Zoellner", + "is_following": false + }, + "type": "follow" + } + } + ], + "other_views": 0 + } + }, + "period": "month" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors-year.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors-year.json new file mode 100644 index 000000000000..8b6902b64579 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors-year.json @@ -0,0 +1,155 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.1/sites/106707880/stats/top-authors/*", + "queryParameters": { + "period": { + "equalTo": "year" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-12-31", + "days": { + "2019-01-01": { + "authors": [ + { + "name": "Leah Elaine Rand", + "avatar": "https://0.gravatar.com/avatar/62937c26a2a79bae5921ca9e85be8040?s=64&d=https%3A%2F%2F0.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D64&r=G", + "views": 218, + "posts": [ + { + "id": 1, + "title": "Home", + "url": "http://infocusphotographers.com/about/", + "views": 190, + "video": false + }, + { + "id": 90, + "title": "Martin and Amy's Wedding", + "url": "http://infocusphotographers.com/2016/03/07/martin-and-amys-wedding/", + "views": 13, + "video": false + }, + { + "id": 13, + "title": "Services", + "url": "http://infocusphotographers.com/services/", + "views": 12, + "video": false + }, + { + "id": 106, + "title": "Some News to Share", + "url": "http://infocusphotographers.com/2016/04/19/some-news-to-share/", + "views": 3, + "video": false + } + ], + "follow_data": { + "params": { + "stat-source": "stats_author", + "follow-text": "Follow", + "following-text": "Following", + "following-hover-text": "Unfollow", + "blog_domain": "leahinfocus.wordpress.com", + "blog_url": "http://leahinfocus.wordpress.com", + "blog_id": 106523982, + "site_id": 106523982, + "blog_title": "Leah in Focus", + "is_following": false + }, + "type": "follow" + } + }, + { + "name": "Andrea Zoellner", + "avatar": "https://0.gravatar.com/avatar/c294f0a8bad2d33f62dd1c2d81b38f99?s=64&d=https%3A%2F%2F0.gravatar.com%2Favatar%2Fad516503a11cd5ca435acc9bb6523536%3Fs%3D64&r=G", + "views": 78, + "posts": [ + { + "id": 22, + "title": "Our Story", + "url": "http://infocusphotographers.com/our-story/", + "views": 26, + "video": false + }, + { + "id": 24, + "title": "Portfolio", + "url": "http://infocusphotographers.com/portfolio/", + "views": 19, + "video": false + }, + { + "id": 85, + "title": "photo-1432250767374-ee19cba37b52", + "url": "http://infocusphotographers.com/portfolio/photo-1432250767374-ee19cba37b52/", + "views": 4, + "video": false + }, + { + "id": 84, + "title": "unsplash_5252bb51404f8_1", + "url": "http://infocusphotographers.com/portfolio/unsplash_5252bb51404f8_1/", + "views": 4, + "video": false + }, + { + "id": 28, + "title": "photo-1423110041833-0d5dfcc552e1", + "url": "http://infocusphotographers.com/portfolio/photo-1423110041833-0d5dfcc552e1/", + "views": 4, + "video": false + }, + { + "id": 5, + "title": "dpzDUkJrTHb71Yla1EzF_IMG_4098", + "url": "http://infocusphotographers.com/about/dpzdukjrthb71yla1ezf_img_4098/", + "views": 4, + "video": false + }, + { + "id": 83, + "title": "photo-1443745029291-d5c27bc0b562", + "url": "http://infocusphotographers.com/portfolio/photo-1443745029291-d5c27bc0b562/", + "views": 4, + "video": false + } + ], + "follow_data": { + "params": { + "stat-source": "stats_author", + "follow-text": "Follow", + "following-text": "Following", + "following-hover-text": "Unfollow", + "blog_domain": "andreazoellner.wordpress.com", + "blog_url": "http://andreazoellner.wordpress.com", + "blog_id": 67702148, + "site_id": 67702148, + "blog_title": "Andrea Zoellner", + "is_following": false + }, + "type": "follow" + } + } + ], + "other_views": 0 + } + }, + "period": "year" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors.json new file mode 100644 index 000000000000..8b8e9ffa12d2 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-authors.json @@ -0,0 +1,25 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/top-authors/" + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "days": { + "{{now format='yyyy-MM-dd'}}": { + "authors": [ + + ] + } + }, + "period": "day" + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-posts-day.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-posts-day.json new file mode 100644 index 000000000000..582c08400813 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-posts-day.json @@ -0,0 +1,52 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/top-posts/", + "queryParameters": { + "period": { + "equalTo": "day" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "days": { + "{{now format='yyyy-MM-dd'}}": { + "postviews": [ + { + "id": 1, + "href": "http://infocusphotographers.com/about/", + "date": "2016-02-09 16:07:34", + "title": "Home", + "type": "page", + "views": 2, + "video_play": false + }, + { + "id": 22, + "href": "http://infocusphotographers.com/our-story/", + "date": "2016-02-09 17:32:50", + "title": "Our Story", + "type": "page", + "views": 1, + "video_play": false + } + ], + "total_views": "3" + } + }, + "period": "day" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-posts-month.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-posts-month.json new file mode 100644 index 000000000000..f1a70b493303 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-posts-month.json @@ -0,0 +1,88 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.1/sites/([0-9]+)/stats/top-posts/", + "queryParameters": { + "period": { + "equalTo": "month" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-07-31", + "days": { + "2019-07-01": { + "postviews": [ + { + "id": 1, + "href": "http://infocusphotographers.com/about/", + "date": "2016-02-09 16:07:34", + "title": "Home", + "type": "page", + "views": 403, + "video_play": false + }, + { + "id": 22, + "href": "http://infocusphotographers.com/our-story/", + "date": "2016-02-09 17:32:50", + "title": "Our Services", + "type": "page", + "views": 306, + "video_play": false + }, + { + "id": 24, + "href": "http://infocusphotographers.com/portfolio/", + "date": "2016-02-09 17:33:03", + "title": "About Us", + "type": "page", + "views": 288, + "video_play": false + }, + { + "id": 24, + "href": "http://infocusphotographers.com/portfolio/", + "date": "2016-02-09 17:33:03", + "title": "Contact Us", + "type": "page", + "views": 145, + "video_play": false + }, + { + "id": 24, + "href": "http://infocusphotographers.com/portfolio/", + "date": "2016-02-09 17:33:03", + "title": "What to know buying your first home", + "type": "page", + "views": 52, + "video_play": false + }, + { + "id": 24, + "href": "http://infocusphotographers.com/portfolio/", + "date": "2016-02-09 17:33:03", + "title": "Staging tips and tricks", + "type": "page", + "views": 39, + "video_play": false + } + ], + "total_views": 16 + } + }, + "period": "month" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-posts-year.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-posts-year.json new file mode 100644 index 000000000000..0dc7c1c4a76f --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_top-posts-year.json @@ -0,0 +1,162 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/top-posts/", + "queryParameters": { + "period": { + "equalTo": "year" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-12-31", + "days": { + "2019-01-01": { + "postviews": [ + { + "id": 1, + "href": "http://infocusphotographers.com/about/", + "date": "2016-02-09 16:07:34", + "title": "Home", + "type": "page", + "views": 190, + "children": [ + { + "title": "dpzdukjrthb71yla1ezf_img_4098", + "link": "http://infocusphotographers.com/about/dpzdukjrthb71yla1ezf_img_4098/", + "views": 4, + "type": "attachment" + } + ], + "video_play": false + }, + { + "id": 22, + "href": "http://infocusphotographers.com/our-story/", + "date": "2016-02-09 17:32:50", + "title": "Our Story", + "type": "page", + "views": 26, + "video_play": false + }, + { + "id": 24, + "href": "http://infocusphotographers.com/portfolio/", + "date": "2016-02-09 17:33:03", + "title": "Portfolio", + "type": "page", + "views": 19, + "children": [ + { + "title": "photo-1432250767374-ee19cba37b52", + "link": "http://infocusphotographers.com/portfolio/photo-1432250767374-ee19cba37b52/", + "views": 4, + "type": "attachment" + }, + { + "title": "unsplash_5252bb51404f8_1", + "link": "http://infocusphotographers.com/portfolio/unsplash_5252bb51404f8_1/", + "views": 4, + "type": "attachment" + }, + { + "title": "photo-1423110041833-0d5dfcc552e1", + "link": "http://infocusphotographers.com/portfolio/photo-1423110041833-0d5dfcc552e1/", + "views": 4, + "type": "attachment" + }, + { + "title": "photo-1443745029291-d5c27bc0b562", + "link": "http://infocusphotographers.com/portfolio/photo-1443745029291-d5c27bc0b562/", + "views": 4, + "type": "attachment" + }, + { + "title": "photo-1447940334172-44e027c1f71c", + "link": "http://infocusphotographers.com/portfolio/photo-1447940334172-44e027c1f71c/", + "views": 3, + "type": "attachment" + }, + { + "title": "photo-1439539698758-ba2680ecadb9", + "link": "http://infocusphotographers.com/portfolio/photo-1439539698758-ba2680ecadb9/", + "views": 2, + "type": "attachment" + }, + { + "title": "photo-1453857271477-4f9a4081966e-2", + "link": "http://infocusphotographers.com/portfolio/photo-1453857271477-4f9a4081966e-2/", + "views": 2, + "type": "attachment" + }, + { + "title": "photo-1453857122308-5e78881d7acc", + "link": "http://infocusphotographers.com/portfolio/photo-1453857122308-5e78881d7acc/", + "views": 2, + "type": "attachment" + }, + { + "title": "photo-1452629135160-cf2a685e1f09", + "link": "http://infocusphotographers.com/portfolio/photo-1452629135160-cf2a685e1f09/", + "views": 2, + "type": "attachment" + } + ], + "video_play": false + }, + { + "id": 0, + "href": "http://infocusphotographers.com/", + "date": null, + "title": "Home page / Archives", + "type": "homepage", + "views": 17, + "video_play": false + }, + { + "id": 90, + "href": "http://infocusphotographers.com/2016/03/07/martin-and-amys-wedding/", + "date": "2016-03-07 18:32:11", + "title": "Martin and Amy's Wedding", + "type": "post", + "views": 13, + "video_play": false + }, + { + "id": 13, + "href": "http://infocusphotographers.com/services/", + "date": "2016-02-09 17:16:04", + "title": "Services", + "type": "page", + "views": 12, + "video_play": false + }, + { + "id": 106, + "href": "http://infocusphotographers.com/2016/04/19/some-news-to-share/", + "date": "2016-04-19 21:28:27", + "title": "Some News to Share", + "type": "post", + "views": 3, + "video_play": false + } + ], + "total_views": 313, + "other_views": 33 + } + }, + "period": "year" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays-day.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays-day.json new file mode 100644 index 000000000000..5cdf5722a742 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays-day.json @@ -0,0 +1,36 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/video-plays/", + "queryParameters": { + "period": { + "equalTo": "day" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "period": "day", + "days": { + "{{now format='yyyy-MM-dd'}}": { + "plays": [ + + ], + "other_plays": 0, + "total_plays": 0 + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays-month.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays-month.json new file mode 100644 index 000000000000..76143cce05d6 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays-month.json @@ -0,0 +1,36 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.1/sites/([0-9]+)/stats/video-plays/", + "queryParameters": { + "period": { + "equalTo": "month" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-07-31", + "period": "month", + "days": { + "2019-07-01": { + "plays": [ + + ], + "other_plays": 0, + "total_plays": 0 + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays-year.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays-year.json new file mode 100644 index 000000000000..b5f29b2da78d --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays-year.json @@ -0,0 +1,36 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/video-plays/", + "queryParameters": { + "period": { + "equalTo": "year" + }, + "max": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-12-31", + "period": "year", + "days": { + "2019-01-01": { + "plays": [ + + ], + "other_plays": 0, + "total_plays": 0 + } + } + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays.json new file mode 100644 index 000000000000..bc2f48a8837d --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_video-plays.json @@ -0,0 +1,27 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/video-plays/" + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "period": "day", + "days": { + "{{now format='yyyy-MM-dd'}}": { + "plays": [ + + ], + "other_plays": 0, + "total_plays": 0 + } + } + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits-month.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits-month.json new file mode 100644 index 000000000000..e93e128410a8 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits-month.json @@ -0,0 +1,134 @@ +{ + "request": { + "method": "GET", + "urlPathPattern": "/rest/v1.1/sites/([0-9]+)/stats/visits/", + "queryParameters": { + "unit": { + "equalTo": "month" + }, + "quantity": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "unit": "month", + "fields": [ + "period", + "views", + "visitors", + "comments", + "likes" + ], + "data": [ + [ + "{{now offset='-13 months' format='yyyy-MM-dd'}}", + 1443, + 678, + 0, + 0 + ], + [ + "{{now offset='-12 months' format='yyyy-MM-dd'}}", + 1371, + 647, + 0, + 0 + ], + [ + "{{now offset='-11 months' format='yyyy-MM-dd'}}", + 1444, + 678, + 0, + 0 + ], + [ + "{{now offset='-10 months' format='yyyy-MM-dd'}}", + 1361, + 634, + 1, + 0 + ], + [ + "{{now offset='-9 months' format='yyyy-MM-dd'}}", + 1195, + 560, + 0, + 0 + ], + [ + "{{now offset='-8 months' format='yyyy-MM-dd'}}", + 1391, + 644, + 0, + 0 + ], + [ + "{{now offset='-7 months' format='yyyy-MM-dd'}}", + 1257, + 588, + 0, + 0 + ], + [ + "{{now offset='-6 months' format='yyyy-MM-dd'}}", + 1458, + 686, + 0, + 0 + ], + [ + "{{now offset='-5 months' format='yyyy-MM-dd'}}", + 1716, + 808, + 0, + 0 + ], + [ + "{{now offset='-4 months' format='yyyy-MM-dd'}}", + 1586, + 746, + 0, + 0 + ], + [ + "{{now offset='-3 months' format='yyyy-MM-dd'}}", + 1342, + 659, + 0, + 0 + ], + [ + "{{now offset='-2 months' format='yyyy-MM-dd'}}", + 1280, + 643, + 0, + 0 + ], + [ + "{{now offset='-1 months' format='yyyy-MM-dd'}}", + 1218, + 623, + 0, + 0 + ], + [ + "{{now format='yyyy-MM-dd'}}", + 1233, + 655, + 0, + 0 + ] + ] + } + } +} diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits-year.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits-year.json new file mode 100644 index 000000000000..44426144b0c2 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits-year.json @@ -0,0 +1,173 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/visits/", + "queryParameters": { + "unit": { + "equalTo": "year" + }, + "quantity": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "2019-07-16", + "unit": "year", + "fields": [ + "period", + "views", + "visitors", + "likes", + "reblogs", + "comments", + "posts" + ], + "data": [ + [ + "2005-01-01", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "2006-01-01", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "2007-01-01", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "2008-01-01", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "2009-01-01", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "2010-01-01", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "2011-01-01", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "2012-01-01", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "2013-01-01", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "2014-01-01", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "2015-01-01", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "2016-01-01", + 48, + 12, + 0, + 0, + 0, + 13 + ], + [ + "2017-01-01", + 788, + 465, + 0, + 0, + 0, + 3 + ], + [ + "2018-01-01", + 1215, + 632, + 0, + 0, + 0, + 3 + ], + [ + "2019-01-01", + 9148, + 4216, + 1351, + 864, + 0, + 0 + ] + ] + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits_unit_day_1.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits_unit_day_1.json new file mode 100644 index 000000000000..dedfcd7a186a --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits_unit_day_1.json @@ -0,0 +1,47 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/visits/", + "queryParameters": { + "unit": { + "equalTo": "day" + }, + "quantity": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "unit": "day", + "fields": [ + "period", + "views", + "visitors", + "likes", + "reblogs", + "comments", + "posts" + ], + "data": [ + [ + "{{now format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits_unit_day_12.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits_unit_day_12.json new file mode 100644 index 000000000000..59531d4a9fe8 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits_unit_day_12.json @@ -0,0 +1,145 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/visits/", + "queryParameters": { + "unit": { + "equalTo": "day" + }, + "quantity": { + "matches": "[0-9]+" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "unit": "day", + "fields": [ + "period", + "views", + "visitors", + "likes", + "reblogs", + "comments", + "posts" + ], + "data": [ + [ + "{{now offset='-11 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-10 days' format='yyyy-MM-dd'}}", + 3, + 2, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-9 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-8 days' format='yyyy-MM-dd'}}", + 6, + 3, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-7 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-6 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-5 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-4 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-3 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-2 days' format='yyyy-MM-dd'}}", + 1, + 1, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-1 days' format='yyyy-MM-dd'}}", + 1, + 1, + 0, + 0, + 0, + 0 + ], + [ + "{{now format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits_unit_day_15.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits_unit_day_15.json new file mode 100644 index 000000000000..68232726194c --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/stats/stats_visits_unit_day_15.json @@ -0,0 +1,173 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/stats/visits/", + "queryParameters": { + "unit": { + "equalTo": "day" + }, + "quantity": { + "matches": "[0-9]+" + }, + "date": { + "matches": "(.*)" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "date": "{{now format='yyyy-MM-dd'}}", + "unit": "day", + "fields": [ + "period", + "views", + "visitors", + "likes", + "reblogs", + "comments", + "posts" + ], + "data": [ + [ + "{{now offset='-14 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-13 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-12 days' format='yyyy-MM-dd'}}", + 2, + 2, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-11 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-10 days' format='yyyy-MM-dd'}}", + 3, + 2, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-9 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-8 days' format='yyyy-MM-dd'}}", + 6, + 3, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-7 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-6 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-5 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-4 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-3 days' format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-2 days' format='yyyy-MM-dd'}}", + 1, + 1, + 0, + 0, + 0, + 0 + ], + [ + "{{now offset='-1 days' format='yyyy-MM-dd'}}", + 1, + 1, + 0, + 0, + 0, + 0 + ], + [ + "{{now format='yyyy-MM-dd'}}", + 0, + 0, + 0, + 0, + 0, + 0 + ] + ] + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/terms/category.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/terms/category.json new file mode 100644 index 000000000000..9c42d616b360 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/terms/category.json @@ -0,0 +1,54 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/taxonomies/category/terms/", + "queryParameters": { + "number": { + "equalTo": "1000" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 2, + "terms": [ + { + "ID": 1, + "name": "Uncategorized", + "slug": "uncategorized", + "description": "", + "post_count": 1, + "feed_url": "http://infocusphotographers.com/category/uncategorized/feed/", + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:uncategorized/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + }, + { + "ID": 1674, + "name": "Wedding", + "slug": "wedding", + "description": "", + "post_count": 1, + "feed_url": "http://infocusphotographers.com/category/wedding/feed/", + "parent": 0, + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/categories/slug:wedding/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/terms/post_tag.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/terms/post_tag.json new file mode 100644 index 000000000000..e66841f2b9c4 --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/terms/post_tag.json @@ -0,0 +1,42 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/sites/106707880/taxonomies/post_tag/terms/", + "queryParameters": { + "number": { + "equalTo": "1000" + }, + "locale": { + "matches": "(.*)" + } + } + }, + "response": { + "status": 200, + "jsonBody": { + "found": 1, + "terms": [ + { + "ID": 2500, + "name": "tag", + "slug": "tag", + "description": "", + "post_count": 1, + "feed_url": "https://infocusphotographers.com/tag/tag/feed/", + "meta": { + "links": { + "self": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/tags/slug:tag", + "help": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880/tags/slug:tag/help", + "site": "{{request.requestLine.baseUrl}}/rest/v1.1/sites/106707880" + } + } + } + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/themes/v2_sites_106707880_themes.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/themes/v2_sites_106707880_themes.json new file mode 100644 index 000000000000..1ef3068ff24e --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/themes/v2_sites_106707880_themes.json @@ -0,0 +1,144 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/wp/v2/sites/.*/themes.*" + }, + "response": { + "status": 200, + "jsonBody": [ + { + "stylesheet": "pub/edin", + "template": "pub/edin", + "requires_php": "", + "requires_wp": "", + "textdomain": "edin", + "version": "1.3.2-wpcom", + "screenshot": "https://s0.wp.com/wp-content/themes/pub/edin/screenshot.png", + "author": { + "raw": "Automattic", + "rendered": "Automattic" + }, + "author_uri": { + "raw": "https://wordpress.com/themes/", + "rendered": "https://wordpress.com/themes/" + }, + "description": { + "raw": "Edin is a modern responsive business and corporate theme that helps you to create a strong--yet beautiful--online presence for your business.", + "rendered": "Edin is a modern responsive business and corporate theme that helps you to create a strong–yet beautiful–online presence for your business." + }, + "name": { + "raw": "Edin", + "rendered": "Edin" + }, + "tags": { + "raw": [ + "Blog", + "Blue", + "breadcrumb-navigation", + "bright", + "business", + "classic-menu", + "clean", + "collaboration", + "conservative", + "Custom Background", + "Custom Colors", + "Custom Header", + "custom-menu", + "design", + "Editor Style", + "elegant", + "Featured Images", + "flexible-header", + "food", + "formal", + "Full Width Template", + "Gray", + "hotel", + "Infinite Scroll", + "Left Sidebar", + "Light", + "Light", + "minimal", + "modern", + "multiple-menus", + "Post Formats", + "professional", + "real-estate", + "Responsive Layout", + "Right Sidebar", + "RTL Language Support", + "school", + "simple", + "site-logo", + "sophisticated", + "Sticky Post", + "testimonials", + "Theme Options", + "traditional", + "Translation Ready", + "Two Columns", + "White" + ], + "rendered": "Blog, Blue, breadcrumb-navigation, bright, business, classic-menu, clean, collaboration, conservative, Custom Background, Custom Colors, Custom Header, custom-menu, design, Editor Style, elegant, Featured Images, flexible-header, food, formal, Full Width Template, Gray, hotel, Infinite Scroll, Left Sidebar, Light, Light, minimal, modern, multiple-menus, Post Formats, professional, real-estate, Responsive Layout, Right Sidebar, RTL Language Support, school, simple, site-logo, sophisticated, Sticky Post, testimonials, Theme Options, traditional, Translation Ready, Two Columns, White" + }, + "theme_uri": { + "raw": "https://wordpress.com/themes/edin/", + "rendered": "https://wordpress.com/themes/edin/" + }, + "theme_supports": { + "align-wide": false, + "automatic-feed-links": true, + "custom-background": { + "default-color": "ffffff", + "default-image": "" + }, + "custom-header": { + "default-image": "", + "default-text-color": "303030", + "width": 1230, + "height": 100, + "flex-width": true, + "flex-height": true + }, + "custom-logo": false, + "customize-selective-refresh-widgets": false, + "dark-editor-style": false, + "disable-custom-colors": false, + "disable-custom-font-sizes": false, + "disable-custom-gradients": false, + "editor-color-palette": false, + "editor-font-sizes": false, + "editor-gradient-presets": false, + "editor-styles": false, + "html5": [ + "search-form", + "comment-form", + "comment-list", + "gallery", + "caption" + ], + "formats": [ + "standard", + "aside", + "image", + "video", + "quote", + "link", + "status", + "gallery" + ], + "post-thumbnails": true, + "responsive-embeds": false, + "title-tag": false, + "wp-block-styles": false + } + } + ], + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/tracks/rest_v11_tracks_record.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/tracks/rest_v11_tracks_record.json new file mode 100644 index 000000000000..fd6d1f5ecacd --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/tracks/rest_v11_tracks_record.json @@ -0,0 +1,19 @@ +{ + "id": "2ad4ffca-6254-4b1e-b005-982ac3b4f8f6", + "name": "rest_v11_tracks_record", + "request": { + "urlPath": "/rest/v1.1/tracks/record", + "method": "POST" + }, + "response": { + "status": 202, + "body": "\"Accepted\"", + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + }, + "uuid": "2ad4ffca-6254-4b1e-b005-982ac3b4f8f6", + "persistent": true +} \ No newline at end of file diff --git a/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/users/v11_users_suggest.json b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/users/v11_users_suggest.json new file mode 100644 index 000000000000..fd558b640b7d --- /dev/null +++ b/API-Mocks/WordPressMocks/src/main/assets/mocks/mappings/wpcom/users/v11_users_suggest.json @@ -0,0 +1,19 @@ +{ + "request": { + "method": "GET", + "urlPath": "/rest/v1.1/users/suggest" + }, + "response": { + "status": 200, + "jsonBody": { + "suggestions": [ + + ] + }, + "headers": { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Cache-Control": "no-cache, must-revalidate, max-age=0" + } + } +} \ No newline at end of file diff --git a/API-Mocks/scripts/start.sh b/API-Mocks/scripts/start.sh new file mode 100755 index 000000000000..9c27c4c9a1b1 --- /dev/null +++ b/API-Mocks/scripts/start.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -eu + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +VENDOR_DIR="$(pwd)/vendor/wiremock" + +WIREMOCK_VERSION="2.25.1" +WIREMOCK_JAR="${VENDOR_DIR}/wiremock-standalone-${WIREMOCK_VERSION}.jar" + +if [ ! -f "$WIREMOCK_JAR" ]; then + mkdir -p "${VENDOR_DIR}" && cd "${VENDOR_DIR}" + curl -O -J "https://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-standalone/${WIREMOCK_VERSION}/wiremock-standalone-${WIREMOCK_VERSION}.jar" + cd .. +fi + +# Use provided port, or default to 8282 +PORT="${1:-8282}" + +# Start WireMock server. See http://wiremock.org/docs/running-standalone/ +java -jar "${WIREMOCK_JAR}" --root-dir "${SCRIPT_DIR}/../WordPressMocks/src/main/assets/mocks" \ + --port "$PORT" \ + --global-response-templating diff --git a/API-Mocks/scripts/stop.sh b/API-Mocks/scripts/stop.sh new file mode 100755 index 000000000000..577f39af6c6b --- /dev/null +++ b/API-Mocks/scripts/stop.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -eu + +# Use provided port, or default to 8282 +PORT="${1:-8282}" + +echo "Shutting down WireMock server ..." + +# Shutdown the WireMock server. See http://wiremock.org/docs/running-standalone/#shutting-down +curl -X POST "http://localhost:8282/__admin/shutdown" diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000000..12a6d0709ce6 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,4 @@ +/WordPress/UITestsFoundation/ @wordpress-mobile/mobile-ui-testing-squad +/WordPress/WordPressUITests/ @wordpress-mobile/mobile-ui-testing-squad +/WordPress/WordPressScreenshotGeneration/ @wordpress-mobile/mobile-ui-testing-squad +/WordPress/JetpackScreenshotGeneration/ @wordpress-mobile/mobile-ui-testing-squad diff --git a/CODESTYLE.md b/CODESTYLE.md index 3b5b19349d7e..57bd02d9079e 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,4 +1,4 @@ # Code Style Guidelines for WordPress-iOS -Our code style guidelines (for both Swift and Objective-C) is located [here](docs/coding-style-guide.md). +Our code style guidelines (for both Swift and Objective-C) are located [here](docs/coding-style-guide.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7157ea1bb4aa..dc622099bbd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,16 @@ The core team monitors and reviews all pull requests. Depending on the changes, We do our best to respond quickly to all pull requests. If you don't get a response from us after a week, feel free to reach out to us via Slack. +Note: If you are part of the org and have the permissions on the repo, don't forget to assign yourself to the PR, and add the appropriate GitHub label and Milestone for the PR + +### PR merge policy + +* PRs require one reviewer to approve the PR before it can be merged to the base branch +* We keep the PR git history when merging (merge via "merge commit") +* Who merges the PR once it's approved and green? + * For PRs authored by people external to the organisation and not having push permissions, the reviewer who approved the PR will merge it. + * For PRs authored by contributors with push permissions, the author of the PR will merge their own PR. + ## Getting in Touch If you have questions or just want to say hi, join the [WordPress Slack](https://make.wordpress.org/chat/) and drop a message on the `#mobile` channel. diff --git a/Dangerfile b/Dangerfile new file mode 100644 index 000000000000..8ae75506c404 --- /dev/null +++ b/Dangerfile @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +github.dismiss_out_of_range_messages +rubocop.lint inline_comment: true, fail_on_inline_comment: true diff --git a/Gemfile b/Gemfile index 25c65ee18b7d..cdaef9177289 100644 --- a/Gemfile +++ b/Gemfile @@ -1,13 +1,24 @@ -source 'https://rubygems.org' do - gem 'rake' - gem 'cocoapods', '~> 1.8.0' - gem 'xcpretty-travis-formatter' - gem 'octokit', "~> 4.0" - gem 'fastlane', "2.141.0" - gem 'dotenv' - gem 'rubyzip', "~> 1.3" - gem 'commonmarker' -end +# frozen_string_literal: true + +source 'https://rubygems.org' -plugins_path = File.join(File.dirname(__FILE__), 'Scripts/fastlane', 'Pluginfile') -eval_gemfile(plugins_path) if File.exist?(plugins_path) +gem 'cocoapods', '~> 1.11' +gem 'commonmarker' +gem 'danger', '~> 8.6' +gem 'danger-rubocop', '~> 0.10' +gem 'dotenv' +gem 'fastlane', '~> 2.174' +gem 'fastlane-plugin-appcenter', '~> 1.8' +gem 'fastlane-plugin-sentry' +# This comment avoids typing to switch to a development version for testing. +# gem 'fastlane-plugin-wpmreleasetoolkit', git: 'git@github.com:wordpress-mobile/release-toolkit', branch: 'trunk' +gem 'fastlane-plugin-wpmreleasetoolkit', '~> 7.0' +gem 'octokit', '~> 4.0' +gem 'rake' +gem 'rubocop', '~> 1.30' +gem 'rubocop-rake', '~> 0.6' +gem 'xcpretty-travis-formatter' + +group :screenshots, optional: true do + gem 'rmagick', '~> 3.2.0' +end diff --git a/Gemfile.lock b/Gemfile.lock index a643162ca4ae..903cd2d608a7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,128 +1,183 @@ -GIT - remote: https://github.com/wordpress-mobile/release-toolkit - revision: c0daa1febc6018a050a04fe4a6cfcbdd48daec37 - tag: 0.9.0 - specs: - fastlane-plugin-wpmreleasetoolkit (0.9.0) - activesupport (~> 4) - chroma (= 0.2.0) - diffy (~> 3.3) - git (~> 1.3) - jsonlint - nokogiri (>= 1.10.4) - octokit (~> 4.13) - parallel (~> 1.14) - progress_bar (~> 1.3) - rake (~> 12.3) - rake-compiler (~> 1.0) - GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.2) - activesupport (4.2.11.1) - i18n (~> 0.7) - minitest (~> 5.1) - thread_safe (~> 0.3, >= 0.3.4) - tzinfo (~> 1.1) - addressable (2.7.0) - public_suffix (>= 2.0.2, < 5.0) - algoliasearch (1.27.1) + CFPropertyList (3.0.6) + rexml + activesupport (6.1.7.2) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 1.6, < 2) + minitest (>= 5.1) + tzinfo (~> 2.0) + zeitwerk (~> 2.3) + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) + algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) + artifactory (3.0.15) + ast (2.4.2) atomos (0.1.3) - babosa (1.0.3) + aws-eventstream (1.2.0) + aws-partitions (1.716.0) + aws-sdk-core (3.170.0) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.5) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.62.0) + aws-sdk-core (~> 3, >= 3.165.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.119.1) + aws-sdk-core (~> 3, >= 3.165.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.4) + aws-sigv4 (1.5.2) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + bigdecimal (1.4.4) + buildkit (1.5.0) + sawyer (>= 0.6) chroma (0.2.0) - claide (1.0.3) - cocoapods (1.8.4) - activesupport (>= 4.0.2, < 5) + claide (1.1.0) + claide-plugins (0.9.2) + cork + nap + open4 (~> 1.3) + cocoapods (1.11.3) + addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.8.4) + cocoapods-core (= 1.11.3) cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.2.2, < 2.0) + cocoapods-downloader (>= 1.4.0, < 2.0) cocoapods-plugins (>= 1.0.0, < 2.0) cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-stats (>= 1.0.0, < 2.0) cocoapods-trunk (>= 1.4.0, < 2.0) cocoapods-try (>= 1.1.0, < 2.0) colored2 (~> 3.1) escape (~> 0.0.4) fourflusher (>= 2.3.0, < 3.0) gh_inspector (~> 1.0) - molinillo (~> 0.6.6) + molinillo (~> 0.8.0) nap (~> 1.0) - ruby-macho (~> 1.4) - xcodeproj (>= 1.11.1, < 2.0) - cocoapods-core (1.8.4) - activesupport (>= 4.0.2, < 6) + ruby-macho (>= 1.0, < 3.0) + xcodeproj (>= 1.21.0, < 2.0) + cocoapods-core (1.11.3) + activesupport (>= 5.0, < 7) + addressable (~> 2.8) algoliasearch (~> 1.0) concurrent-ruby (~> 1.1) fuzzy_match (~> 2.0.4) nap (~> 1.0) - cocoapods-deintegrate (1.0.4) - cocoapods-downloader (1.2.2) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (1.6.3) cocoapods-plugins (1.0.0) nap - cocoapods-search (1.0.0) - cocoapods-stats (1.1.0) - cocoapods-trunk (1.4.1) + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) nap (>= 0.8, < 2.0) netrc (~> 0.11) - cocoapods-try (1.1.0) + cocoapods-try (1.2.0) colored (1.2) colored2 (3.1.2) - commander-fastlane (4.4.6) - highline (~> 1.7.2) - commonmarker (0.20.1) - ruby-enum (~> 0.5) - concurrent-ruby (1.1.5) - declarative (0.0.10) - declarative-option (0.1.0) - diffy (3.3.0) - digest-crc (0.4.1) + commander (4.6.0) + highline (~> 2.0.0) + commonmarker (0.23.7) + concurrent-ruby (1.2.0) + cork (0.3.0) + colored2 (~> 3.1) + danger (8.6.1) + claide (~> 1.0) + claide-plugins (>= 0.9.2) + colored2 (~> 3.1) + cork (~> 0.1) + faraday (>= 0.9.0, < 2.0) + faraday-http-cache (~> 2.0) + git (~> 1.7) + kramdown (~> 2.3) + kramdown-parser-gfm (~> 1.0) + no_proxy_fix + octokit (~> 4.7) + terminal-table (>= 1, < 4) + danger-rubocop (0.10.0) + danger + rubocop (~> 1.0) + declarative (0.0.20) + diffy (3.4.2) + digest-crc (0.6.4) + rake (>= 12.0.0, < 14.0.0) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) - dotenv (2.7.5) - emoji_regex (1.0.1) + dotenv (2.8.1) + emoji_regex (3.2.3) escape (0.0.4) - excon (0.72.0) - faraday (0.17.3) - multipart-post (>= 1.2, < 3) - faraday-cookie_jar (0.0.6) - faraday (>= 0.7.4) + ethon (0.15.0) + ffi (>= 1.15.0) + excon (0.99.0) + faraday (1.10.3) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) http-cookie (~> 1.0.0) - faraday_middleware (0.13.1) - faraday (>= 0.7.4, < 1.0) - fastimage (2.1.7) - fastlane (2.141.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-http-cache (2.4.0) + faraday (>= 0.8) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.4) + multipart-post (~> 2) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.0) + faraday (~> 1.0) + fastimage (2.2.6) + fastlane (2.212.1) CFPropertyList (>= 2.3, < 4.0.0) - addressable (>= 2.3, < 3.0.0) - babosa (>= 1.0.2, < 2.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) bundler (>= 1.12.0, < 3.0.0) colored - commander-fastlane (>= 4.4.6, < 5.0.0) + commander (~> 4.6) dotenv (>= 2.1.1, < 3.0.0) - emoji_regex (>= 0.1, < 2.0) + emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) - faraday (~> 0.17) + faraday (~> 1.0) faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 0.13.1) + faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-api-client (>= 0.29.2, < 0.37.0) - google-cloud-storage (>= 1.15.0, < 2.0.0) - highline (>= 1.7.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-storage (~> 1.31) + highline (~> 2.0) json (< 3.0.0) - jwt (~> 2.1.0) + jwt (>= 2.1.0, < 3) mini_magick (>= 4.9.4, < 5.0.0) - multi_xml (~> 0.5) multipart-post (~> 2.0.0) + naturally (~> 2.2) + optparse (~> 0.1.1) plist (>= 3.1.0, < 4.0.0) - public_suffix (~> 2.0.0) - rubyzip (>= 1.3.0, < 2.0.0) + rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.3) simctl (~> 1.6.3) - slack-notifier (>= 2.0.0, < 3.0.0) terminal-notifier (>= 2.0.0, < 3.0.0) terminal-table (>= 1.4.5, < 2.0.0) tty-screen (>= 0.6.3, < 1.0.0) @@ -131,150 +186,206 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) - fastlane-plugin-appcenter (1.7.1) - fastlane-plugin-sentry (1.6.0) + fastlane-plugin-appcenter (1.11.1) + fastlane-plugin-sentry (1.11.0) + fastlane-plugin-wpmreleasetoolkit (7.0.0) + activesupport (>= 6.1.7.1) + bigdecimal (~> 1.4) + buildkit (~> 1.5) + chroma (= 0.2.0) + diffy (~> 3.3) + git (~> 1.3) + google-cloud-storage (~> 1.31) + nokogiri (~> 1.11) + octokit (~> 4.18) + parallel (~> 1.14) + plist (~> 3.1) + progress_bar (~> 1.3) + rake (>= 12.3, < 14.0) + rake-compiler (~> 1.0) + ffi (1.15.4) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - git (1.6.0) + git (1.13.2) + addressable (~> 2.8) rchardet (~> 1.8) - google-api-client (0.36.4) + google-apis-androidpublisher_v3 (0.34.0) + google-apis-core (>= 0.9.1, < 2.a) + google-apis-core (0.11.0) addressable (~> 2.5, >= 2.5.1) - googleauth (~> 0.9) - httpclient (>= 2.8.1, < 3.0) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) mini_mime (~> 1.0) representable (~> 3.0) - retriable (>= 2.0, < 4.0) - signet (~> 0.12) - google-cloud-core (1.5.0) + retriable (>= 2.0, < 4.a) + rexml + webrick + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.19.0) + google-apis-core (>= 0.9.0, < 2.a) + google-cloud-core (1.6.0) google-cloud-env (~> 1.0) google-cloud-errors (~> 1.0) - google-cloud-env (1.3.0) - faraday (~> 0.11) - google-cloud-errors (1.0.0) - google-cloud-storage (1.25.1) - addressable (~> 2.5) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.3.0) + google-cloud-storage (1.44.0) + addressable (~> 2.8) digest-crc (~> 0.4) - google-api-client (~> 0.33) - google-cloud-core (~> 1.2) - googleauth (~> 0.9) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.19.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - googleauth (0.10.0) - faraday (~> 0.12) + googleauth (1.3.0) + faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) - signet (~> 0.12) - highline (1.7.10) - http-cookie (1.0.3) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-cookie (1.0.5) domain_name (~> 0.5) httpclient (2.8.3) - i18n (0.9.5) + i18n (1.12.0) concurrent-ruby (~> 1.0) - json (2.3.0) - jsonlint (0.3.0) - oj (~> 3) - optimist (~> 3) - jwt (2.1.0) + jmespath (1.6.2) + json (2.6.3) + jwt (2.7.0) + kramdown (2.4.0) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) memoist (0.16.2) - mini_magick (4.10.1) - mini_mime (1.0.2) - mini_portile2 (2.4.0) - minitest (5.12.2) - molinillo (0.6.6) - multi_json (1.14.1) - multi_xml (0.6.0) + mini_magick (4.12.0) + mini_mime (1.1.2) + mini_portile2 (2.8.1) + minitest (5.17.0) + molinillo (0.8.0) + multi_json (1.15.0) multipart-post (2.0.0) - nanaimo (0.2.6) + nanaimo (0.3.0) nap (1.1.0) - naturally (2.2.0) + naturally (2.2.1) netrc (0.11.0) - nokogiri (1.10.8) - mini_portile2 (~> 2.4.0) - octokit (4.14.0) - sawyer (~> 0.8.0, >= 0.5.3) - oj (3.10.2) - optimist (3.0.0) + no_proxy_fix (0.1.2) + nokogiri (1.14.2) + mini_portile2 (~> 2.8.0) + racc (~> 1.4) + octokit (4.25.1) + faraday (>= 1, < 3) + sawyer (~> 0.9) + open4 (1.3.4) options (2.3.2) - os (1.0.1) - parallel (1.19.1) - plist (3.5.0) - progress_bar (1.3.1) + optparse (0.1.1) + os (1.1.4) + parallel (1.22.1) + parser (3.1.2.0) + ast (~> 2.4.1) + plist (3.7.0) + progress_bar (1.3.3) highline (>= 1.6, < 3) options (~> 2.3.0) - public_suffix (2.0.5) - rake (12.3.3) - rake-compiler (1.1.0) + public_suffix (4.0.7) + racc (1.6.2) + rainbow (3.1.1) + rake (13.0.6) + rake-compiler (1.2.1) rake rchardet (1.8.0) - representable (3.0.4) + regexp_parser (2.5.0) + representable (3.2.0) declarative (< 0.1.0) - declarative-option (< 0.2.0) + trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) + rexml (3.2.5) rmagick (3.2.0) rouge (2.0.7) - ruby-enum (0.7.2) - i18n - ruby-macho (1.4.0) - rubyzip (1.3.0) - sawyer (0.8.2) + rubocop (1.30.1) + parallel (~> 1.10) + parser (>= 3.1.0.0) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.18.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 3.0) + rubocop-ast (1.18.0) + parser (>= 3.1.1.0) + rubocop-rake (0.6.0) + rubocop (~> 1.0) + ruby-macho (2.5.1) + ruby-progressbar (1.11.0) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + sawyer (0.9.2) addressable (>= 2.3.5) - faraday (> 0.8, < 2.0) + faraday (>= 0.17.3, < 3) security (0.1.3) - signet (0.12.0) - addressable (~> 2.3) - faraday (~> 0.9) + signet (0.17.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) - simctl (1.6.7) + simctl (1.6.10) CFPropertyList naturally - slack-notifier (2.3.2) terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) - thread_safe (0.3.6) + terminal-table (1.6.0) + trailblazer-option (0.1.2) tty-cursor (0.7.1) - tty-screen (0.7.0) + tty-screen (0.8.1) tty-spinner (0.9.3) tty-cursor (~> 0.7) - tzinfo (1.2.5) - thread_safe (~> 0.1) + typhoeus (1.4.0) + ethon (>= 0.9.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) uber (0.1.0) unf (0.1.4) unf_ext - unf_ext (0.0.7.6) - unicode-display_width (1.6.1) + unf_ext (0.0.8.2) + unicode-display_width (2.2.0) + webrick (1.8.1) word_wrap (1.0.0) - xcodeproj (1.14.0) + xcodeproj (1.22.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.2.6) + nanaimo (~> 0.3.0) + rexml (~> 3.2.4) xcpretty (0.3.0) rouge (~> 2.0.7) - xcpretty-travis-formatter (1.0.0) + xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) + zeitwerk (2.6.7) PLATFORMS ruby DEPENDENCIES - cocoapods (~> 1.8.0)! - commonmarker! - dotenv! - fastlane (= 2.141.0)! - fastlane-plugin-appcenter (= 1.7.1) + cocoapods (~> 1.11) + commonmarker + danger (~> 8.6) + danger-rubocop (~> 0.10) + dotenv + fastlane (~> 2.174) + fastlane-plugin-appcenter (~> 1.8) fastlane-plugin-sentry - fastlane-plugin-wpmreleasetoolkit! - octokit (~> 4.0)! - rake! + fastlane-plugin-wpmreleasetoolkit (~> 7.0) + octokit (~> 4.0) + rake rmagick (~> 3.2.0) - rubyzip (~> 1.3)! - xcpretty-travis-formatter! + rubocop (~> 1.30) + rubocop-rake (~> 0.6) + xcpretty-travis-formatter BUNDLED WITH - 2.0.2 + 2.3.23 diff --git a/MIGRATIONS.md b/MIGRATIONS.md index 28c95f897947..4592dd18143f 100644 --- a/MIGRATIONS.md +++ b/MIGRATIONS.md @@ -3,6 +3,449 @@ This file documents changes in the data model. Please explain any changes to the data model as well as any custom migrations. +## WordPress 148 + +@momozw 2023-02-20 + +- `Blog`: added `isBlazeApproved` attribute. (required, default `NO`, `Boolean`) + +@salimbraksa 2023-02-22 + +- Created a new entity `BlockedAuthor` with: + - `accountID` (required, no default, `Int 64`) + - `authorID` (required, no default, `Int 64`) + +## WordPress 147 + +@salimbraksa 2023-02-04 + +- Created a new entity `BlockedSite` with: + - `accountID` (required, no default, `Int 64`) + - `blogID` (required, no default, `Int 64`) + +## WordPress 146 + +@salimbraksa 2023-01-31 + +- `ManagedAccountSettings`: added `twoStepEnabled` attribute. ( required, default `NO`, `Boolean`) + +## WordPress 145 + +@geriux 2022-07-08 + +- `Media`: added `remoteLargeURL` attribute. (optional, no default, `String`) +- `Media`: added `remoteMediumURL` attribute. (optional, no default, `String`) + +## WordPress 144 + + @salimbraksa 2022-07-07 + + - `UserSuggestion`: added `userID` attribute. (optional, no default, `Int 64`) + +## WordPress 143 + +@wargcm 2022-06-01 (@scoutharris 2022-05-24) + +- `Post`: added `bloggingPromptID` attribute. (optional, no default, `String`) + +## WordPress 142 + +@dvdchr 2022-05-31 + +- Updated `BloggingPrompt`: + - `displayAvatarURLs` now uses `NSSecureUnarchiveFromData` as `Transformer`. + +## WordPress 141 + +@wargcm 2022-05-23 + +- Created a new entity `BloggingPromptSettings` with: + - `isPotentialBloggingSite` (required, default `NO`, `Boolean`) + - `promptCardEnabled` (required, default `YES`, `Boolean`) + - `promptRemindersEnabled` (required, default `NO`, `Boolean`) + - `reminderTime` (required, default empty string, `String`) + - `siteID` (required, default `0`, `Int 32`) + - `reminderDays` one-to-one mapping to `BloggingPromptSettingsReminderDays` +- Created a new entity `BloggingPromptSettingsReminderDays` with: + - `monday` (required, default `NO`, `Boolean`) + - `tuesday` (required, default `NO`, `Boolean`) + - `wednesday` (required, default `NO`, `Boolean`) + - `thursday` (required, default `NO`, `Boolean`) + - `friday` (required, default `NO`, `Boolean`) + - `saturday` (required, default `NO`, `Boolean`) + - `sunday` (required, default `NO`, `Boolean`) + - `settings` one-to-one mapping to `BloggingPromptSettings` + +## WordPress 140 + +@dvdchr 2022-05-13 + +- Created a new entity `BloggingPrompt` with: + - `promptID` (required, default `0`, `Int 32`) + - `siteID` (required, default `0`, `Int 32`) + - `text` (required, default empty string, `String`) + - `title` (required, default empty string, `String`) + - `content` (required, default empty string, `String`) + - `attribution` (required, default empty string, `String`) + - `date` (optional, no default, `Date`) + - `answered` (required, default `NO`, `Boolean`) + - `answerCount` (required, default `0`, `Int 32`) + - `displayAvatarURLs` (optional, no default, `Transformable` with type `[URL]`) + +## WordPress 138 + +@dvdchr 2022-03-07 + +- `Comment`: added `visibleOnReader` attribute. (required, default `true`, `Boolean`) + +## WordPress 137 + +@dvdchr 2021-11-26 + +- `Comment`: added `authorID` attribute. (optional, default `0`, `Int 32`) + +## WordPress 134 + +@dvdchr 2021-10-14 + +- `ReaderPost`: added `receivesCommentNotifications` attribute. (required, default `false`, `Boolean`) + +## WordPress 132 + +@momo-ozawa 2021-08-19 + +- `Post`: deleted `geolocation` attribute +- `Post`: deleted `latitudeID` attribute +- `Post`: deleted `longitudeID` attribute + +## WordPress 131 + +@scoutharris 2021-08-04 + +- `Comment`: set `author_ip` default value to empty string + +## WordPress 130 + +@scoutharris 2021-08-03 + +- `Comment`: set attribute default values + - `author`: empty string + - `author_email`: empty string + - `author_url`: empty string + - `authorAvatarURL`: empty string + - `commentID`: 0 + - `content`: empty string + - `hierarchy`: empty string + - `isLiked`: `NO` + - `link`: empty string + - `parentID`: 0 + - `postID`: 0 + - `postTitle`: empty string + - `status`: empty string + - `type`: `comment` + +## WordPress 129 + +@scoutharris 2021-07-29 + +- `Comment`: set `rawContent` attribute as optional. Self-hosted does not have this property. + +## WordPress 128 + +@scoutharris 2021-07-27 + +- `Comment`: added `rawContent` attribute. (required, default empty string, `String`) + +## WordPress 127 + +@chipsnyder 2021-07-1 + +- `BlockEditorSettings`: added the attribute + - `rawStyles` (optional, no default, `String`) + - `rawFeatures` (optional, no default, `String`) + +- `BlockEditorSettingElement`: added the attribute + - `order` (required, 0, `Int`) + +## WordPress 126 + +@scoutharris 2021-06-28 + +- `Comment`: added `canModerate` attribute. (required, default `false`, `Boolean`) + +## WordPress 125 + +@aerych 2021-06-04 + +- `ReaderPost`: added `canSubscribeComments` attribute. (required, default `false`, `Boolean`) +- `ReaderPost`: added `isSubscribedComments` attribute. (required, default `false`, `Boolean`) + +## WordPress 124 + +@scoutharris 2021-05-07 + +- `LikeUser`: added `dateFetched` attribute. + +## WordPress 123 + +@scoutharris 2021-04-28 + +- Added new attributes to `LikeUser`: + - `likedSiteID` + - `likedPostID` + - `likedCommentID` +- Corrected spelling of `dateLikedString` + +## WordPress 122 + +@scoutharris 2021-04-23 + +- Added new entities: +- `LikeUser` +- `LikeUserPreferredBlog` +- Created one-to-one relationship between `LikeUser` and `LikeUserPreferredBlog` + +## WordPress 121 + +@twstokes 2021-04-21 + +- `BlogAuthor`: added the attribute + - `deletedFromBlog` (required, default `NO`, `Boolean`) + +## WordPress 120 + +@chipsnyder 2021-04-12 + +- Created a new entity `BlockEditorSettings` with: + - `isFSETheme` (required, default `false`, `Boolean`) FSE = "Full Site Editing" + - `lastUpdated` (required, no default, `Date`) + +- Created a new entity `BlockEditorSettingElement` with: + - `type` (required, no default, `String`) + - `value` (required, no default, `String`) + - `slug` (required, no default, `String`) + - `name` ( required, no default, `String`) + +- Created one-to-many relationship between `BlockEditorSettings` and `BlockEditorSettingElement` + - `BlockEditorSettings` + - `elements` (optional, to-many, cascade on delete) + - `BlockEditorSettingElement` + - `settings` (required, to-one, nullify on delete) + +- Created one-to-one relationship between `Blog` and `BlockEditorSettings` + - `BlockEditorSettings` + - `blockEditorSettings` (optional, to-one, cascade on delete) + - `BlockEditorSettings` + - `blog` (required, to-one, nullify on delete) + +## WordPress 119 + +@mkevins 2021-03-31 + +- `PageTemplateCategory`: added the attribute + - `ordinal` as Int64 (non-optional) + +## WordPress 118 + +@chipsnyder 2021-03-26 + +- `PageTemplateLayout`: set default values on: + - `demoUrl` to Empty String + - `previewTablet` to Empty String + - `previewMobile` to Empty String + +## WordPress 117 + +@mkevins 2021-03-17 + +- `PageTemplateLayout`: added the attributes + - `demoUrl` as string + - `previewTablet` as string + - `previewMobile` as string + +## WordPress 116 + +@ceyhun 2021-03-15 + +- `BlogSettings`: renamed `commentsFromKnownUsersWhitelisted` to `commentsFromKnownUsersAllowlisted` +- `BlogSettings`: renamed `jetpackLoginWhiteListedIPAddresses` to `jetpackLoginAllowListedIPAddresses` +- `BlogSettings`: renamed `commentsBlacklistKeys` to `commentsBlocklistKeys` + +## WordPress 115 + +@mindgraffiti 2021-03-10 + +- Added `blockEmailNotifications` is attribute to `AccountSettings` entity. + +## WordPress 114 + +@aerych 2021-02-25 + +- Changes Blog inviteLinks relation deletion rule to cascade. + +## WordPress 113 + +@aerych 2021-02-19 + +- Added `InviteLinks` entity. + +## WordPress 112 + +@scoutharris 2021-01-29 + +- `ReaderPost`: added `isSeenSupported` attribute. +- `ReaderPost`: changed default value of `isSeen` to `true`. + +## WordPress 111 + +@scoutharris 2021-01-14 + +- Added `isSeen` attribute to `ReaderPost` entity. + +## WordPress 110 + +@emilylaguna 2021-01-05 + +- Removed an invalid relationship to `ReaderSiteTopic.sites` from the `Comment` entity + +## WordPress 109 + +@mindgraffiti 2020-12-15 + +- Added `unseenCount` attribute to `ReaderSiteTopic` entity + +## WordPress 108 + +@scoutharris 2020-12-14 + +- `ReaderTeamTopic`: added `organizationID`. +- `ReaderSiteTopic`: made `organizationID` non-optional. +- `ReaderPost`: made `organizationID` non-optional. + +## WordPress 107 + +@scoutharris 2020-12-09 + +- `ReaderSiteTopic`: removed `isWPForTeams`, added `organizationID`. +- `ReaderPost`: removed `isWPForTeams`, added `organizationID`. + +## WordPress 106 + +@mindgraffiti 2020-12-07 + +- Added `isWPForTeams` property to `ReaderSiteTopic`. + +## WordPress 105 + +@scoutharris 2020-12-04 + +- Added `isWPForTeams` property to `ReaderPost`. + +## WordPress 104 + +@frosty 2020-12-03 + +- Set the following `Transformable` properties to use the `NSSecureUnarchiveFromData`: + - AbstractPost.revisions + - Blog.capabilities + - Blog.options + - Blog.postFormats + - MenuItem.classes + - Notification.body + - Notification.header + - Notification.meta + - Notification.subject + - Post.disabledPublicizeConnections + - Theme.tags +- Set custom transformers on the following properties: + - BlogSettings.commentsBlacklistKeys -> SetValueTransformer + - BlogSettings.commentsModerationKeys -> SetValueTransformer + - BlogSettings.jetpackLoginWhiteListedIPAddresses -> SetValueTransformer + - Media.error -> NSErrorValueTransformer + - Post.geolocation -> LocationValueTransformer + +## WordPress 103 + +@guarani 2020-11-25 + +- Add a new `SiteSuggestion` entity to support Gutenberg's xpost implementation +- Add a one-to-many relationship between `Blog` and `SiteSuggestion` + +## WordPress 102 + +@chipsnyder 2020-10-20 + +- Added one-to-many relationship between `Blog` and `PageTemplateCategory` + - `Blog` + - `pageTemplateCategories` (optional, to-many, cascade on delete) + - `PageTemplateCategory` + - `blog` (required, to-one, nullify on delete) + +- Updated the many-to-many relationship between `PageTemplateLayout` and `PageTemplateCategory` + - `PageTemplateLayout` + - `categories` (optional, to-many, nullify on delete) + - `PageTemplateCategory` + - `layouts` (optional, to-many, ***cascade*** on delete) + +## WordPress 101 + +@emilylaguna 2020-10-09 +- Add a relationship between `ReaderCard` and `ReaderSiteTopic` + +## WordPress 100 + +@guarani 2020-10-09 + +- Add a new `UserSuggestion` entity +- Add a one-to-many relationship between `Blog` and `UserSuggestion` + +## WordPress 99 + +@chipsnyder 2020-10-05 + +- Created a new entity `PageTemplateCategory` with: + - `desc` (optional, `String`) short for "description" + - `emoji` (optional, `String`) + - `slug` (required, no default, `String`) + - `title` ( required, no default, `String`) +- Created a new entity `PageTemplateLayout` with: + - `content` (required, no default, `String`) + - `preview` (required, no default, `String`) + - `slug` (required, no default, `String`) + - `title` ( required, no default, `String`) + +- Created many-to-many relationship between `PageTemplateLayout` and `PageTemplateCategory` + - `PageTemplateLayout` + - `categories` (optional, to-many, nullify on delete) + - `PageTemplateCategory` + - `layouts` (optional, to-many, nullify on delete) + +## WordPress 98 + +@leandrowalonso 2020-07-27 + +- Add a new `ReaderCard` entity +- Add a relationship between `ReaderCard` and `ReaderPost` +- Add a relationship between `ReaderCard` and `ReaderTagTopic` + +## WordPress 97 + +@aerych 2020-06-17 + +- All stats entities were reviewed for consistency of Optional settings for strings and dates and default values for scalar numerical fields. +- Categories entity updated to make numeric fields scalar and non-optional. + +## WordPress 96 + +@Gio2018 2020-06-12 + +- Add fields `supportPriority`, `supportName` and `nonLocalizedShortname` to the `Plan` entity for Zendesk integration. + +## WordPress 95 + +@aerych 2020-03-21 + +- `ReaderPost` added the property `isBlogAtomic` (optional, `Boolean`). ## WordPress 94 @@ -20,7 +463,7 @@ data model as well as any custom migrations. @jklausa 2019-08-19 -- `AbstractPost`: Addded a `confirmedChangesHash` (`nullable` `String`) and `confirmedChangesTimestamp` (`nullable` `Date`) properties. +- `AbstractPost`: Addded a `confirmedChangesHash` (`nullable` `String`) and `confirmedChangesTimestamp` (`nullable` `Date`) properties. @leandroalonso 2019-09-27 @@ -34,7 +477,7 @@ data model as well as any custom migrations. ## WordPress 91 @aerych 2019-10-15 -- `WPAccount` added `primaryBlogID` property. +- `WPAccount` added `primaryBlogID` property. ## WordPress 90 @@ -63,8 +506,8 @@ data model as well as any custom migrations. * `StatsRecordValue` * `StatsRecord` -* `AllTimeStatsRecordValue` -* `AnnualAndMostPopularTimeStatsRecordValue` +* `AllTimeStatsRecordValue` +* `AnnualAndMostPopularTimeStatsRecordValue` * `ClicksStatsRecordValue` * `CountryStatsRecordValue` * `FollowersStatsRecordValue` diff --git a/Podfile b/Podfile index 0e780fd03322..03bb9998ab90 100644 --- a/Podfile +++ b/Podfile @@ -1,377 +1,394 @@ +# frozen_string_literal: true + +# For security reasons, please always keep the wordpress-mobile source first and the CDN second. +# For more info, see https://github.com/wordpress-mobile/cocoapods-specs#source-order-and-security-considerations +install! 'cocoapods', warn_for_multiple_pod_sources: false +source 'https://github.com/wordpress-mobile/cocoapods-specs.git' source 'https://cdn.cocoapods.org/' +raise 'Please run CocoaPods via `bundle exec`' unless %w[BUNDLE_BIN_PATH BUNDLE_GEMFILE].any? { |k| ENV.key?(k) } + inhibit_all_warnings! use_frameworks! -platform :ios, '11.0' +app_ios_deployment_target = Gem::Version.new('13.0') + +platform :ios, app_ios_deployment_target.version workspace 'WordPress.xcworkspace' ## Pods shared between all the targets ## =================================== ## def wordpress_shared - ## for production: - pod 'WordPressShared', '1.8.15-beta.2' - - ## for development: - # pod 'WordPressShared', :path => '../WordPress-iOS-Shared' - - ## while PR is in review: - # 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 => 'efe5a065f3ace331353595ef85eef502baa23497' + # pod 'WordPressShared', '~> 2.0-beta' + # 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: 'trunk' + # pod 'WordPressShared', git: 'https://github.com/wordpress-mobile/WordPress-iOS-Shared.git', commit: '' + # pod 'WordPressShared', path: '../WordPress-iOS-Shared' end def aztec - ## When using a tagged version, feel free to comment out the WordPress-Aztec-iOS line below. - ## When using a commit number (during development) you should provide the same commit number for both pods. - ## - ## pod 'WordPress-Aztec-iOS', :git => 'https://github.com/wordpress-mobile/AztecEditor-iOS.git', :commit => 'ba8524aba1332550efb05cad583a85ed3511beb5' - ## pod 'WordPress-Editor-iOS', :git => 'https://github.com/wordpress-mobile/AztecEditor-iOS.git', :commit => 'ba8524aba1332550efb05cad583a85ed3511beb5' - ## pod 'WordPress-Editor-iOS', :git => 'https://github.com/wordpress-mobile/AztecEditor-iOS.git', :tag => '1.5.0.beta.1' - ## pod 'WordPress-Editor-iOS', :path => '../AztecEditor-iOS' - pod 'WordPress-Editor-iOS', '~> 1.16.0' + ## When using a tagged version, feel free to comment out the WordPress-Aztec-iOS line below. + ## When using a commit number (during development) you should provide the same commit number for both pods. + ## + # pod 'WordPress-Aztec-iOS', git: 'https://github.com/wordpress-mobile/AztecEditor-iOS.git', commit: '' + # pod 'WordPress-Editor-iOS', git: 'https://github.com/wordpress-mobile/AztecEditor-iOS.git', commit: '' + # pod 'WordPress-Editor-iOS', git: 'https://github.com/wordpress-mobile/AztecEditor-iOS.git', tag: '' + # pod 'WordPress-Editor-iOS', path: '../AztecEditor-iOS' + pod 'WordPress-Editor-iOS', '~> 1.19.8' end def wordpress_ui - ## for production: - pod 'WordPressUI', '~> 1.5.1' - - ## for development: - #pod 'WordPressUI', :path => '../WordPressUI-iOS' - ## while PR is in review: - #pod 'WordPressUI', :git => 'https://github.com/wordpress-mobile/WordPressUI-iOS', :branch => '' + pod 'WordPressUI', '~> 1.12.5' + # pod 'WordPressUI', git: 'https://github.com/wordpress-mobile/WordPressUI-iOS', tag: '' + # pod 'WordPressUI', git: 'https://github.com/wordpress-mobile/WordPressUI-iOS', branch: '' + # pod 'WordPressUI', git: 'https://github.com/wordpress-mobile/WordPressUI-iOS', commit: '' + # pod 'WordPressUI', path: '../WordPressUI-iOS' end def wordpress_kit - pod 'WordPressKit', '~> 4.5.9-beta' - #pod 'WordPressKit', :git => 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', :tag => '4.5.9-beta.1' - #pod 'WordPressKit', :git => 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', :branch => 'fix/datarequest-weak-reference' - #pod 'WordPressKit', :git => 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', :commit => '0ab4bb57ae5ba77e8f705ab987d8affa7e188d18' - #pod 'WordPressKit', :path => '../WordPressKit-iOS' + pod 'WordPressKit', '~> 8.0-beta' + # pod 'WordPressKit', git: 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', branch: '' + # pod 'WordPressKit', git: 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', tag: '' + # pod 'WordPressKit', git: 'https://github.com/wordpress-mobile/WordPressKit-iOS.git', commit: '' + # pod 'WordPressKit', path: '../WordPressKit-iOS' +end + +def kanvas + pod 'Kanvas', '~> 1.4.4' + # pod 'Kanvas', git: 'https://github.com/tumblr/Kanvas-iOS.git', tag: '' + # pod 'Kanvas', git: 'https://github.com/tumblr/Kanvas-iOS.git', commit: '' + # pod 'Kanvas', path: '../Kanvas-iOS' end def shared_with_all_pods - wordpress_shared - pod 'CocoaLumberjack', '3.5.2' - pod 'FormatterKit/TimeIntervalFormatter', '1.8.2' - pod 'NSObject-SafeExpectations', '0.0.3' + wordpress_shared + pod 'CocoaLumberjack/Swift', '~> 3.0' + pod 'NSObject-SafeExpectations', '~> 0.0.4' end def shared_with_networking_pods - pod 'Alamofire', '4.8.0' - pod 'Reachability', '3.2' + pod 'Alamofire', '4.8.0' + pod 'Reachability', '3.2' - wordpress_kit + wordpress_kit end def shared_test_pods - pod 'OHHTTPStubs', '6.1.0' - pod 'OHHTTPStubs/Swift', '6.1.0' - pod 'OCMock', '3.4.3' + pod 'OHHTTPStubs/Swift', '~> 9.1.0' + pod 'OCMock', '~> 3.4.3' + gutenberg_pods end def shared_with_extension_pods - pod 'Gridicons', '~> 0.16' - pod 'ZIPFoundation', '~> 0.9.8' - pod 'Down', '~> 0.6.6' + shared_style_pods + pod 'ZIPFoundation', '~> 0.9.8' + pod 'Down', '~> 0.6.6' end -def gutenberg(options) - options[:git] = 'http://github.com/wordpress-mobile/gutenberg-mobile/' - local_gutenberg = ENV['LOCAL_GUTENBERG'] - if local_gutenberg - options = { :path => local_gutenberg.include?('/') ? local_gutenberg : '../gutenberg-mobile' } - end - pod 'Gutenberg', options - pod 'RNTAztecView', options - - gutenberg_dependencies options +def shared_style_pods + pod 'Gridicons', '~> 1.1.0' end -def gutenberg_dependencies(options) - dependencies = [ - 'FBReactNativeSpec', - 'FBLazyVector', - 'React', - 'ReactCommon', - 'RCTRequired', - 'RCTTypeSafety', - 'React-Core', - 'React-CoreModules', - 'React-RCTActionSheet', - 'React-RCTAnimation', - 'React-RCTBlob', - 'React-RCTImage', - 'React-RCTLinking', - 'React-RCTNetwork', - 'React-RCTSettings', - 'React-RCTText', - 'React-RCTVibration', - 'React-cxxreact', - 'React-jsinspector', - 'React-jsi', - 'React-jsiexecutor', - 'Yoga', - 'Folly', - 'glog', - 'react-native-keyboard-aware-scroll-view', - 'react-native-safe-area', - 'react-native-video', - 'RNSVG', - 'ReactNativeDarkMode', - 'react-native-slider', - 'react-native-linear-gradient' - ] - if options[:path] - podspec_prefix = options[:path] - else - tag_or_commit = options[:tag] || options[:commit] - podspec_prefix = "https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/#{tag_or_commit}" - end +def gutenberg_pods + gutenberg tag: 'v1.94.0' +end - for pod_name in dependencies do - pod pod_name, :podspec => "#{podspec_prefix}/react-native-gutenberg-bridge/third-party-podspecs/#{pod_name}.podspec.json" - end +def gutenberg(options) + options[:git] = 'https://github.com/wordpress-mobile/gutenberg-mobile.git' + options[:submodules] = true + local_gutenberg = ENV.fetch('LOCAL_GUTENBERG', nil) + if local_gutenberg + options = { path: local_gutenberg.include?('/') ? local_gutenberg : '../gutenberg-mobile' } + end + pod 'Gutenberg', options + pod 'RNTAztecView', options + + gutenberg_dependencies options end -## WordPress iOS -## ============= -## -target 'WordPress' do - project 'WordPress/WordPress.xcodeproj' - - shared_with_all_pods - shared_with_networking_pods - shared_with_extension_pods - - ## Gutenberg (React Native) - ## ===================== - ## - gutenberg :commit => 'd377b883c761c2a71d29bd631f3d3227b3e313a2' - - ## Third party libraries - ## ===================== - ## - pod '1PasswordExtension', '1.8.5' - pod 'Charts', '~> 3.2.2' - pod 'Gifu', '3.2.0' - pod 'AppCenter', '2.5.1', :configurations => ['Release-Internal', 'Release-Alpha'] - pod 'AppCenter/Distribute', '2.5.1', :configurations => ['Release-Internal', 'Release-Alpha'] - pod 'MRProgress', '0.8.3' - pod 'Starscream', '3.0.6' - pod 'SVProgressHUD', '2.2.5' - pod 'ZendeskSupportSDK', '5.0.0' - pod 'AlamofireNetworkActivityIndicator', '~> 2.4' - pod 'FSInteractiveMap', :git => 'https://github.com/wordpress-mobile/FSInteractiveMap.git', :tag => '0.2.0' - pod 'JTAppleCalendar', '~> 8.0.2' - - ## Automattic libraries - ## ==================== - ## - - # Production - pod 'Automattic-Tracks-iOS', '~> 0.4.3' - # While in PR - # pod 'Automattic-Tracks-iOS', :git => 'https://github.com/Automattic/Automattic-Tracks-iOS.git', :commit => '0cc8960098791cfe1b02914b15a662af20b60389' - - pod 'NSURL+IDN', '0.3' - - pod 'WPMediaPicker', '~> 1.6.0' - ## while PR is in review: - ## pod 'WPMediaPicker', :git => 'https://github.com/wordpress-mobile/MediaPicker-iOS.git', :commit => '7c3cb8f00400b9316a803640b42bb88a66bbc648' - - pod 'Gridicons', '~> 0.16' - - pod 'WordPressAuthenticator', '~> 1.10.9-beta' - # pod 'WordPressAuthenticator', :git => 'https://github.com/wordpress-mobile/WordPressAuthenticator-iOS.git', :branch => '' - # pod 'WordPressAuthenticator', :path => '../WordPressAuthenticator-iOS' - - pod 'MediaEditor', '~> 1.0.1' - # pod 'MediaEditor', :git => 'https://github.com/wordpress-mobile/MediaEditor-iOS.git', :commit => 'a4178ed9b0f3622faafb41dd12503e26c5523a32' - # pod 'MediaEditor', :path => '../MediaEditor-iOS' - - aztec - wordpress_ui +def gutenberg_dependencies(options) + # Note that the pods in this array might seem unused if you look for + # `import` statements in this codebase. However, make sure to also check + # whether they are used in the gutenberg-mobile and Gutenberg projects. + # + # See https://github.com/wordpress-mobile/gutenberg-mobile/issues/5025 + dependencies = %w[ + FBLazyVector + React + ReactCommon + RCTRequired + RCTTypeSafety + React-Core + React-CoreModules + React-RCTActionSheet + React-RCTAnimation + React-RCTBlob + React-RCTImage + React-RCTLinking + React-RCTNetwork + React-RCTSettings + React-RCTText + React-RCTVibration + React-callinvoker + React-cxxreact + React-jsinspector + React-jsi + React-jsiexecutor + React-logger + React-perflogger + React-runtimeexecutor + boost + Yoga + RCT-Folly + glog + react-native-safe-area + react-native-safe-area-context + react-native-video + react-native-webview + RNSVG + react-native-slider + BVLinearGradient + react-native-get-random-values + react-native-blur + RNScreens + RNReanimated + RNGestureHandler + RNCMaskedView + RNCClipboard + RNFastImage + React-Codegen + React-bridging + ] + if options[:path] + podspec_prefix = options[:path] + else + tag_or_commit = options[:tag] || options[:commit] + podspec_prefix = "https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/#{tag_or_commit}" + end + + # FBReactNativeSpec needs special treatment because of react-native-codegen code generation + pod 'FBReactNativeSpec', podspec: "#{podspec_prefix}/third-party-podspecs/FBReactNativeSpec/FBReactNativeSpec.podspec.json" + + dependencies.each do |pod_name| + pod pod_name, podspec: "#{podspec_prefix}/third-party-podspecs/#{pod_name}.podspec.json" + end +end +abstract_target 'Apps' do + project 'WordPress/WordPress.xcodeproj' + + shared_with_all_pods + shared_with_networking_pods + shared_with_extension_pods + + ## Gutenberg (React Native) + ## ===================== + ## + gutenberg_pods + + ## Third party libraries + ## ===================== + ## + pod 'Gifu', '3.2.0' + + app_center_version = '~> 4.1' + app_center_configurations = %w[Release-Internal Release-Alpha] + pod 'AppCenter', app_center_version, configurations: app_center_configurations + pod 'AppCenter/Distribute', app_center_version, configurations: app_center_configurations + + pod 'MRProgress', '0.8.3' + pod 'Starscream', '3.0.6' + pod 'SVProgressHUD', '2.2.5' + pod 'ZendeskSupportSDK', '5.3.0' + pod 'AlamofireImage', '3.5.2' + pod 'AlamofireNetworkActivityIndicator', '~> 2.4' + pod 'FSInteractiveMap', git: 'https://github.com/wordpress-mobile/FSInteractiveMap.git', tag: '0.2.0' + pod 'JTAppleCalendar', '~> 8.0.2' + pod 'CropViewController', '2.5.3' + + ## Automattic libraries + ## ==================== + ## + wordpress_kit + wordpress_shared + kanvas + + # Production + + pod 'Automattic-Tracks-iOS', '~> 2.2' + # While in PR + # pod 'Automattic-Tracks-iOS', git: 'https://github.com/Automattic/Automattic-Tracks-iOS.git', branch: '' + # Local Development + # pod 'Automattic-Tracks-iOS', path: '~/Projects/Automattic-Tracks-iOS' + + pod 'NSURL+IDN', '~> 0.4' + + pod 'WPMediaPicker', '~> 1.8.7' + ## while PR is in review: + # pod 'WPMediaPicker', git: 'https://github.com/wordpress-mobile/MediaPicker-iOS.git', branch: '' + # pod 'WPMediaPicker', path: '../MediaPicker-iOS' + + pod 'Gridicons', '~> 1.1.0' + + pod 'WordPressAuthenticator', '~> 6.1-beta' + # pod 'WordPressAuthenticator', git: 'https://github.com/wordpress-mobile/WordPressAuthenticator-iOS.git', branch: '' + # pod 'WordPressAuthenticator', git: 'https://github.com/wordpress-mobile/WordPressAuthenticator-iOS.git', commit: '' + # pod 'WordPressAuthenticator', path: '../WordPressAuthenticator-iOS' + + pod 'MediaEditor', '~> 1.2.1' + # pod 'MediaEditor', git: 'https://github.com/wordpress-mobile/MediaEditor-iOS.git', commit: '' + # pod 'MediaEditor', path: '../MediaEditor-iOS' + + aztec + wordpress_ui + + ## WordPress App iOS + ## ================= + ## + target 'WordPress' do target 'WordPressTest' do - inherit! :search_paths + inherit! :search_paths - shared_test_pods - pod 'Nimble', '~> 7.3.1' + shared_test_pods end + end - - post_install do - puts 'Patching RCTShadowView to fix nested group block - it could be removed after upgrade to 0.62' - %x(patch Pods/React-Core/React/Views/RCTShadowView.m < patches/react-native+0.61.5.patch) - - - ## Convert the 3rd-party license acknowledgements markdown into html for use in the app - require 'commonmarker' - - project_root = File.dirname(__FILE__) - acknowledgements = 'Acknowledgments' - markdown = File.read("#{project_root}/Pods/Target Support Files/Pods-WordPress/Pods-WordPress-acknowledgements.markdown") - rendered_html = CommonMarker.render_html(markdown, :DEFAULT) - styled_html = " - - - - #{acknowledgements} - - - - #{rendered_html} - " - - ## Remove the

, since we've promoted it to - styled_html = styled_html.sub("<h1>Acknowledgements</h1>", '') - - ## The glog library's license contains a URL that does not wrap in the web view, - ## leading to a large right-hand whitespace gutter. Work around this by explicitly - ## inserting a <br> in the HTML. Use gsub juuust in case another one sneaks in later. - styled_html = styled_html.gsub('p?hl=en#dR3YEbitojA/COPYING', 'p?hl=en#dR3YEbitojA/COPYING<br>') - - File.write("#{project_root}/Pods/Target Support Files/Pods-WordPress/acknowledgements.html", styled_html) - end + ## Jetpack App iOS + ## =============== + ## + target 'Jetpack' end - ## Share Extension ## =============== ## target 'WordPressShareExtension' do - project 'WordPress/WordPress.xcodeproj' + project 'WordPress/WordPress.xcodeproj' - shared_with_extension_pods + shared_with_extension_pods - aztec - shared_with_all_pods - shared_with_networking_pods - wordpress_ui + aztec + shared_with_all_pods + shared_with_networking_pods + wordpress_ui end +target 'JetpackShareExtension' do + project 'WordPress/WordPress.xcodeproj' + + shared_with_extension_pods + + aztec + shared_with_all_pods + shared_with_networking_pods + wordpress_ui +end ## DraftAction Extension ## ===================== ## target 'WordPressDraftActionExtension' do - project 'WordPress/WordPress.xcodeproj' + project 'WordPress/WordPress.xcodeproj' - shared_with_extension_pods + shared_with_extension_pods - aztec - shared_with_all_pods - shared_with_networking_pods - wordpress_ui + aztec + shared_with_all_pods + shared_with_networking_pods + wordpress_ui end +target 'JetpackDraftActionExtension' do + project 'WordPress/WordPress.xcodeproj' -## Today Widget -## ============ -## -target 'WordPressTodayWidget' do - project 'WordPress/WordPress.xcodeproj' - - shared_with_all_pods - shared_with_networking_pods + shared_with_extension_pods - wordpress_ui + aztec + shared_with_all_pods + shared_with_networking_pods + wordpress_ui end -## All Time Widget +## Home Screen Widgets ## ============ ## -target 'WordPressAllTimeWidget' do - project 'WordPress/WordPress.xcodeproj' +target 'WordPressStatsWidgets' do + project 'WordPress/WordPress.xcodeproj' - shared_with_all_pods - shared_with_networking_pods + shared_with_all_pods + shared_with_networking_pods + shared_style_pods - wordpress_ui + wordpress_ui end -## This Week Widget -## ============ -## -target 'WordPressThisWeekWidget' do - project 'WordPress/WordPress.xcodeproj' +target 'JetpackStatsWidgets' do + project 'WordPress/WordPress.xcodeproj' - shared_with_all_pods - shared_with_networking_pods + shared_with_all_pods + shared_with_networking_pods + shared_style_pods - wordpress_ui + wordpress_ui end -## Notification Content Extension -## ============================== +## Intents +## ============ ## -target 'WordPressNotificationContentExtension' do - project 'WordPress/WordPress.xcodeproj' +target 'WordPressIntents' do + project 'WordPress/WordPress.xcodeproj' - wordpress_kit - wordpress_shared - wordpress_ui + shared_with_all_pods + shared_with_networking_pods + + wordpress_ui end +target 'JetpackIntents' do + project 'WordPress/WordPress.xcodeproj' + + shared_with_all_pods + shared_with_networking_pods + wordpress_ui +end ## Notification Service Extension ## ============================== ## target 'WordPressNotificationServiceExtension' do - project 'WordPress/WordPress.xcodeproj' + project 'WordPress/WordPress.xcodeproj' - wordpress_kit - wordpress_shared - wordpress_ui + wordpress_kit + wordpress_shared + wordpress_ui end +target 'JetpackNotificationServiceExtension' do + project 'WordPress/WordPress.xcodeproj' -## Mocks -## =================== -## -def wordpress_mocks - pod 'WordPressMocks', '~> 0.0.8' - # pod 'WordPressMocks', :git => 'https://github.com/wordpress-mobile/WordPressMocks.git', :commit => '' - # pod 'WordPressMocks', :git => 'https://github.com/wordpress-mobile/WordPressMocks.git', :branch => 'add/screenshot-mocks' - # pod 'WordPressMocks', :path => '../WordPressMocks' + wordpress_kit + wordpress_shared + wordpress_ui end - ## Screenshot Generation ## =================== ## target 'WordPressScreenshotGeneration' do - project 'WordPress/WordPress.xcodeproj' - - wordpress_mocks - pod 'SimulatorStatusMagic' + project 'WordPress/WordPress.xcodeproj' end ## UI Tests ## =================== ## target 'WordPressUITests' do - project 'WordPress/WordPress.xcodeproj' + project 'WordPress/WordPress.xcodeproj' +end - wordpress_mocks +abstract_target 'Tools' do + pod 'SwiftLint', '~> 0.50' end # Static Frameworks: @@ -382,25 +399,112 @@ end # A future version of CocoaPods may make this easier to do. See https://github.com/CocoaPods/CocoaPods/issues/7428 shared_targets = ['WordPressFlux'] pre_install do |installer| - static = [] - dynamic = [] - installer.pod_targets.each do |pod| - - # Statically linking Sentry results in a conflict with `NSDictionary.objectAtKeyPath`, but dynamically - # linking it resolves this. - if pod.name == "Sentry" - dynamic << pod - next - end - - # If this pod is a dependency of one of our shared targets, it must be linked dynamically - if pod.target_definitions.any? { |t| shared_targets.include? t.name } - dynamic << pod - next - end - static << pod - pod.instance_variable_set(:@build_type, Pod::Target::BuildType.static_framework) + static = [] + dynamic = [] + installer.pod_targets.each do |pod| + # Statically linking Sentry results in a conflict with `NSDictionary.objectAtKeyPath`, but dynamically + # linking it resolves this. + if %w[Sentry SentryPrivate].include? pod.name + dynamic << pod + next + end + + # If this pod is a dependency of one of our shared targets, it must be linked dynamically + if pod.target_definitions.any? { |t| shared_targets.include? t.name } + dynamic << pod + next + end + static << pod + pod.instance_variable_set(:@build_type, Pod::BuildType.static_framework) + end + puts "Installing #{static.count} pods as static frameworks" + puts "Installing #{dynamic.count} pods as dynamic frameworks" +end + +post_install do |installer| + project_root = File.dirname(__FILE__) + + ## Convert the 3rd-party license acknowledgements markdown into html for use in the app + require 'commonmarker' + + acknowledgements = 'Acknowledgments' + markdown = File.read("#{project_root}/Pods/Target Support Files/Pods-Apps-WordPress/Pods-Apps-WordPress-acknowledgements.markdown") + rendered_html = CommonMarker.render_html(markdown, :DEFAULT) + styled_html = "<head> + <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> + <style> + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + font-size: 16px; + color: #1a1a1a; + margin: 20px; + } + @media (prefers-color-scheme: dark) { + body { + background: #1a1a1a; + color: white; + } + } + pre { + white-space: pre-wrap; + } + </style> + <title> + #{acknowledgements} + + + + #{rendered_html} + " + + ## Remove the

, since we've promoted it to + styled_html = styled_html.sub('<h1>Acknowledgements</h1>', '') + + ## The glog library's license contains a URL that does not wrap in the web view, + ## leading to a large right-hand whitespace gutter. Work around this by explicitly + ## inserting a <br> in the HTML. Use gsub juuust in case another one sneaks in later. + styled_html = styled_html.gsub('p?hl=en#dR3YEbitojA/COPYING', 'p?hl=en#dR3YEbitojA/COPYING<br>') + + File.write("#{project_root}/Pods/Target Support Files/Pods-Apps-WordPress/acknowledgements.html", styled_html) + + # Let Pods targets inherit deployment target from the app + # This solution is suggested here: https://github.com/CocoaPods/CocoaPods/issues/4859 + # ===================================== + # + installer.pods_project.targets.each do |target| + # Exclude RCT-Folly as it requires explicit deployment target https://git.io/JPb73 + next unless target.name != 'RCT-Folly' + + target.build_configurations.each do |configuration| + pod_ios_deployment_target = Gem::Version.new(configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET']) + configuration.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' if pod_ios_deployment_target <= app_ios_deployment_target + end + end + + # Fix a code signing issue in Xcode 14 beta. + # This solution is suggested here: https://github.com/CocoaPods/CocoaPods/issues/11402#issuecomment-1189861270 + # ==================================== + # + # TODO: fix the linting issue if this workaround is still needed in Xcode 14 GM. + # rubocop:disable Style/CombinableLoops + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['CODE_SIGN_IDENTITY'] = '' end - puts "Installing #{static.count} pods as static frameworks" - puts "Installing #{dynamic.count} pods as dynamic frameworks" + end + # rubocop:enable Style/CombinableLoops + + # Flag Alpha builds for Tracks + # ============================ + # + tracks_target = installer.pods_project.targets.find { |target| target.name == 'Automattic-Tracks-iOS' } + # This will crash if/when we'll remove Tracks. + # That's okay because it is a crash we'll only have to address once. + tracks_target.build_configurations.each do |config| + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)', 'ALPHA=1'] if (config.name == 'Release-Alpha') || (config.name == 'Release-Internal') + end + + yellow_marker = "\033[33m" + reset_marker = "\033[0m" + puts "#{yellow_marker}The abstract target warning below is expected. Feel free to ignore it.#{reset_marker}" end diff --git a/Podfile.lock b/Podfile.lock index ed07ade03b5e..24d215e41afb 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,78 +1,77 @@ PODS: - - 1PasswordExtension (1.8.5) - Alamofire (4.8.0) + - AlamofireImage (3.5.2): + - Alamofire (~> 4.8) - AlamofireNetworkActivityIndicator (2.4.0): - Alamofire (~> 4.8) - - AppCenter (2.5.1): - - AppCenter/Analytics (= 2.5.1) - - AppCenter/Crashes (= 2.5.1) - - AppCenter/Analytics (2.5.1): + - AppAuth (1.6.1): + - AppAuth/Core (= 1.6.1) + - AppAuth/ExternalUserAgent (= 1.6.1) + - AppAuth/Core (1.6.1) + - AppAuth/ExternalUserAgent (1.6.1): + - AppAuth/Core + - AppCenter (4.4.1): + - AppCenter/Analytics (= 4.4.1) + - AppCenter/Crashes (= 4.4.1) + - AppCenter/Analytics (4.4.1): - AppCenter/Core - - AppCenter/Core (2.5.1) - - AppCenter/Crashes (2.5.1): + - AppCenter/Core (4.4.1) + - AppCenter/Crashes (4.4.1): - AppCenter/Core - - AppCenter/Distribute (2.5.1): + - AppCenter/Distribute (4.4.1): - AppCenter/Core - - Automattic-Tracks-iOS (0.4.3): - - CocoaLumberjack (~> 3.5.2) - - Reachability (~> 3.1) - - Sentry (~> 4) - - UIDeviceIdentifier (~> 1) - - boost-for-react-native (1.63.0) - - Charts (3.2.2): - - Charts/Core (= 3.2.2) - - Charts/Core (3.2.2) - - CocoaLumberjack (3.5.2): - - CocoaLumberjack/Core (= 3.5.2) - - CocoaLumberjack/Core (3.5.2) + - Automattic-Tracks-iOS (2.2.0): + - Sentry (~> 8.0) + - Sodium (>= 0.9.1) + - UIDeviceIdentifier (~> 2.0) + - boost (1.76.0) + - BVLinearGradient (2.5.6-wp-3): + - React-Core + - CocoaLumberjack/Core (3.8.0) + - CocoaLumberjack/Swift (3.8.0): + - CocoaLumberjack/Core + - CropViewController (2.5.3) - DoubleConversion (1.1.5) - Down (0.6.6) - - FBLazyVector (0.61.5) - - FBReactNativeSpec (0.61.5): - - Folly (= 2018.10.22.00) - - RCTRequired (= 0.61.5) - - RCTTypeSafety (= 0.61.5) - - React-Core (= 0.61.5) - - React-jsi (= 0.61.5) - - ReactCommon/turbomodule/core (= 0.61.5) - - Folly (2018.10.22.00): - - boost-for-react-native - - DoubleConversion - - Folly/Default (= 2018.10.22.00) - - glog - - Folly/Default (2018.10.22.00): - - boost-for-react-native - - DoubleConversion - - glog - - FormatterKit/Resources (1.8.2) - - FormatterKit/TimeIntervalFormatter (1.8.2): - - FormatterKit/Resources + - FBLazyVector (0.69.4) + - FBReactNativeSpec (0.69.4): + - RCT-Folly (= 2021.06.28.00-v2) + - RCTRequired (= 0.69.4) + - RCTTypeSafety (= 0.69.4) + - React-Core (= 0.69.4) + - React-jsi (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - fmt (6.2.1) - FSInteractiveMap (0.1.0) - Gifu (3.2.0) - glog (0.3.5) - - GoogleSignIn (4.4.0): - - "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)" - - "GoogleToolboxForMac/NSString+URLArguments (~> 2.1)" + - GoogleSignIn (6.0.2): + - AppAuth (~> 1.4) + - GTMAppAuth (~> 1.0) - GTMSessionFetcher/Core (~> 1.1) - - GoogleToolboxForMac/DebugUtils (2.2.2): - - GoogleToolboxForMac/Defines (= 2.2.2) - - GoogleToolboxForMac/Defines (2.2.2) - - "GoogleToolboxForMac/NSDictionary+URLArguments (2.2.2)": - - GoogleToolboxForMac/DebugUtils (= 2.2.2) - - GoogleToolboxForMac/Defines (= 2.2.2) - - "GoogleToolboxForMac/NSString+URLArguments (= 2.2.2)" - - "GoogleToolboxForMac/NSString+URLArguments (2.2.2)" - - Gridicons (0.19) - - GTMSessionFetcher/Core (1.3.1) - - Gutenberg (1.23.0): - - React (= 0.61.5) - - React-CoreModules (= 0.61.5) - - React-RCTImage (= 0.61.5) + - Gridicons (1.1.0) + - GTMAppAuth (1.3.1): + - AppAuth/Core (~> 1.6) + - GTMSessionFetcher/Core (< 3.0, >= 1.5) + - GTMSessionFetcher/Core (1.7.2) + - Gutenberg (1.94.0): + - React (= 0.69.4) + - React-CoreModules (= 0.69.4) + - React-RCTImage (= 0.69.4) - RNTAztecView - - JTAppleCalendar (8.0.2) - - lottie-ios (2.5.2) - - MediaEditor (1.0.1): - - TOCropViewController (~> 2.5.2) + - JTAppleCalendar (8.0.3) + - Kanvas (1.4.4) + - libwebp (1.2.4): + - libwebp/demux (= 1.2.4) + - libwebp/mux (= 1.2.4) + - libwebp/webp (= 1.2.4) + - libwebp/demux (1.2.4): + - libwebp/webp + - libwebp/mux (1.2.4): + - libwebp/demux + - libwebp/webp (1.2.4) + - MediaEditor (1.2.1): + - CropViewController (~> 2.5.3) - MRProgress (0.8.3): - MRProgress/ActivityIndicator (= 0.8.3) - MRProgress/Blur (= 0.8.3) @@ -101,430 +100,568 @@ PODS: - MRProgress/ProgressBaseClass (0.8.3) - MRProgress/Stopable (0.8.3): - MRProgress/Helper - - Nimble (7.3.4) - - NSObject-SafeExpectations (0.0.3) - - "NSURL+IDN (0.3)" + - NSObject-SafeExpectations (0.0.4) + - "NSURL+IDN (0.4)" - OCMock (3.4.3) - - OHHTTPStubs (6.1.0): - - OHHTTPStubs/Default (= 6.1.0) - - OHHTTPStubs/Core (6.1.0) - - OHHTTPStubs/Default (6.1.0): + - OHHTTPStubs/Core (9.1.0) + - OHHTTPStubs/Default (9.1.0): - OHHTTPStubs/Core - OHHTTPStubs/JSON - OHHTTPStubs/NSURLSession - OHHTTPStubs/OHPathHelpers - - OHHTTPStubs/JSON (6.1.0): + - OHHTTPStubs/JSON (9.1.0): - OHHTTPStubs/Core - - OHHTTPStubs/NSURLSession (6.1.0): + - OHHTTPStubs/NSURLSession (9.1.0): - OHHTTPStubs/Core - - OHHTTPStubs/OHPathHelpers (6.1.0) - - OHHTTPStubs/Swift (6.1.0): + - OHHTTPStubs/OHPathHelpers (9.1.0) + - OHHTTPStubs/Swift (9.1.0): - OHHTTPStubs/Default - - RCTRequired (0.61.5) - - RCTTypeSafety (0.61.5): - - FBLazyVector (= 0.61.5) - - Folly (= 2018.10.22.00) - - RCTRequired (= 0.61.5) - - React-Core (= 0.61.5) + - RCT-Folly (2021.06.28.00-v2): + - boost + - DoubleConversion + - fmt (~> 6.2.1) + - glog + - RCT-Folly/Default (= 2021.06.28.00-v2) + - RCT-Folly/Default (2021.06.28.00-v2): + - boost + - DoubleConversion + - fmt (~> 6.2.1) + - glog + - RCTRequired (0.69.4) + - RCTTypeSafety (0.69.4): + - FBLazyVector (= 0.69.4) + - RCTRequired (= 0.69.4) + - React-Core (= 0.69.4) - Reachability (3.2) - - React (0.61.5): - - React-Core (= 0.61.5) - - React-Core/DevSupport (= 0.61.5) - - React-Core/RCTWebSocket (= 0.61.5) - - React-RCTActionSheet (= 0.61.5) - - React-RCTAnimation (= 0.61.5) - - React-RCTBlob (= 0.61.5) - - React-RCTImage (= 0.61.5) - - React-RCTLinking (= 0.61.5) - - React-RCTNetwork (= 0.61.5) - - React-RCTSettings (= 0.61.5) - - React-RCTText (= 0.61.5) - - React-RCTVibration (= 0.61.5) - - React-Core (0.61.5): - - Folly (= 2018.10.22.00) + - React (0.69.4): + - React-Core (= 0.69.4) + - React-Core/DevSupport (= 0.69.4) + - React-Core/RCTWebSocket (= 0.69.4) + - React-RCTActionSheet (= 0.69.4) + - React-RCTAnimation (= 0.69.4) + - React-RCTBlob (= 0.69.4) + - React-RCTImage (= 0.69.4) + - React-RCTLinking (= 0.69.4) + - React-RCTNetwork (= 0.69.4) + - React-RCTSettings (= 0.69.4) + - React-RCTText (= 0.69.4) + - React-RCTVibration (= 0.69.4) + - React-bridging (0.69.4): + - RCT-Folly (= 2021.06.28.00-v2) + - React-jsi (= 0.69.4) + - React-callinvoker (0.69.4) + - React-Codegen (0.69.4): + - FBReactNativeSpec (= 0.69.4) + - RCT-Folly (= 2021.06.28.00-v2) + - RCTRequired (= 0.69.4) + - RCTTypeSafety (= 0.69.4) + - React-Core (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - React-Core (0.69.4): - glog - - React-Core/Default (= 0.61.5) - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - RCT-Folly (= 2021.06.28.00-v2) + - React-Core/Default (= 0.69.4) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/CoreModulesHeaders (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/CoreModulesHeaders (0.69.4): - glog + - RCT-Folly (= 2021.06.28.00-v2) - React-Core/Default - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/Default (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/Default (0.69.4): - glog - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - RCT-Folly (= 2021.06.28.00-v2) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/DevSupport (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/DevSupport (0.69.4): - glog - - React-Core/Default (= 0.61.5) - - React-Core/RCTWebSocket (= 0.61.5) - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) - - React-jsinspector (= 0.61.5) + - RCT-Folly (= 2021.06.28.00-v2) + - React-Core/Default (= 0.69.4) + - React-Core/RCTWebSocket (= 0.69.4) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-jsinspector (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/RCTActionSheetHeaders (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/RCTActionSheetHeaders (0.69.4): - glog + - RCT-Folly (= 2021.06.28.00-v2) - React-Core/Default - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/RCTAnimationHeaders (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/RCTAnimationHeaders (0.69.4): - glog + - RCT-Folly (= 2021.06.28.00-v2) - React-Core/Default - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/RCTBlobHeaders (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/RCTBlobHeaders (0.69.4): - glog + - RCT-Folly (= 2021.06.28.00-v2) - React-Core/Default - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/RCTImageHeaders (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/RCTImageHeaders (0.69.4): - glog + - RCT-Folly (= 2021.06.28.00-v2) - React-Core/Default - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/RCTLinkingHeaders (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/RCTLinkingHeaders (0.69.4): - glog + - RCT-Folly (= 2021.06.28.00-v2) - React-Core/Default - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/RCTNetworkHeaders (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/RCTNetworkHeaders (0.69.4): - glog + - RCT-Folly (= 2021.06.28.00-v2) - React-Core/Default - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/RCTSettingsHeaders (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/RCTSettingsHeaders (0.69.4): - glog + - RCT-Folly (= 2021.06.28.00-v2) - React-Core/Default - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/RCTTextHeaders (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/RCTTextHeaders (0.69.4): - glog + - RCT-Folly (= 2021.06.28.00-v2) - React-Core/Default - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/RCTVibrationHeaders (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/RCTVibrationHeaders (0.69.4): - glog + - RCT-Folly (= 2021.06.28.00-v2) - React-Core/Default - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-Core/RCTWebSocket (0.61.5): - - Folly (= 2018.10.22.00) + - React-Core/RCTWebSocket (0.69.4): - glog - - React-Core/Default (= 0.61.5) - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsiexecutor (= 0.61.5) + - RCT-Folly (= 2021.06.28.00-v2) + - React-Core/Default (= 0.69.4) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsiexecutor (= 0.69.4) + - React-perflogger (= 0.69.4) - Yoga - - React-CoreModules (0.61.5): - - FBReactNativeSpec (= 0.61.5) - - Folly (= 2018.10.22.00) - - RCTTypeSafety (= 0.61.5) - - React-Core/CoreModulesHeaders (= 0.61.5) - - React-RCTImage (= 0.61.5) - - ReactCommon/turbomodule/core (= 0.61.5) - - React-cxxreact (0.61.5): - - boost-for-react-native (= 1.63.0) + - React-CoreModules (0.69.4): + - RCT-Folly (= 2021.06.28.00-v2) + - RCTTypeSafety (= 0.69.4) + - React-Codegen (= 0.69.4) + - React-Core/CoreModulesHeaders (= 0.69.4) + - React-jsi (= 0.69.4) + - React-RCTImage (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - React-cxxreact (0.69.4): + - boost (= 1.76.0) - DoubleConversion - - Folly (= 2018.10.22.00) - glog - - React-jsinspector (= 0.61.5) - - React-jsi (0.61.5): - - boost-for-react-native (= 1.63.0) + - RCT-Folly (= 2021.06.28.00-v2) + - React-callinvoker (= 0.69.4) + - React-jsi (= 0.69.4) + - React-jsinspector (= 0.69.4) + - React-logger (= 0.69.4) + - React-perflogger (= 0.69.4) + - React-runtimeexecutor (= 0.69.4) + - React-jsi (0.69.4): + - boost (= 1.76.0) - DoubleConversion - - Folly (= 2018.10.22.00) - glog - - React-jsi/Default (= 0.61.5) - - React-jsi/Default (0.61.5): - - boost-for-react-native (= 1.63.0) + - RCT-Folly (= 2021.06.28.00-v2) + - React-jsi/Default (= 0.69.4) + - React-jsi/Default (0.69.4): + - boost (= 1.76.0) - DoubleConversion - - Folly (= 2018.10.22.00) - glog - - React-jsiexecutor (0.61.5): + - RCT-Folly (= 2021.06.28.00-v2) + - React-jsiexecutor (0.69.4): - DoubleConversion - - Folly (= 2018.10.22.00) - glog - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - React-jsinspector (0.61.5) - - react-native-keyboard-aware-scroll-view (0.8.7): - - React - - react-native-linear-gradient (2.5.6): - - React + - RCT-Folly (= 2021.06.28.00-v2) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-perflogger (= 0.69.4) + - React-jsinspector (0.69.4) + - React-logger (0.69.4): + - glog + - react-native-blur (3.6.1): + - React-Core + - react-native-get-random-values (1.4.0): + - React-Core - react-native-safe-area (0.5.1): - - React - - react-native-slider (2.0.7): - - React - - react-native-video (4.4.1): - React-Core - - react-native-video/Video (= 4.4.1) - - react-native-video/Video (4.4.1): + - react-native-safe-area-context (3.2.0): + - React-Core + - react-native-slider (3.0.2-wp-3): + - React-Core + - react-native-video (5.2.0-wp-5): - React-Core - - React-RCTActionSheet (0.61.5): - - React-Core/RCTActionSheetHeaders (= 0.61.5) - - React-RCTAnimation (0.61.5): - - React-Core/RCTAnimationHeaders (= 0.61.5) - - React-RCTBlob (0.61.5): - - React-Core/RCTBlobHeaders (= 0.61.5) - - React-Core/RCTWebSocket (= 0.61.5) - - React-jsi (= 0.61.5) - - React-RCTNetwork (= 0.61.5) - - React-RCTImage (0.61.5): - - React-Core/RCTImageHeaders (= 0.61.5) - - React-RCTNetwork (= 0.61.5) - - React-RCTLinking (0.61.5): - - React-Core/RCTLinkingHeaders (= 0.61.5) - - React-RCTNetwork (0.61.5): - - React-Core/RCTNetworkHeaders (= 0.61.5) - - React-RCTSettings (0.61.5): - - React-Core/RCTSettingsHeaders (= 0.61.5) - - React-RCTText (0.61.5): - - React-Core/RCTTextHeaders (= 0.61.5) - - React-RCTVibration (0.61.5): - - React-Core/RCTVibrationHeaders (= 0.61.5) - - ReactCommon (0.61.5): - - ReactCommon/jscallinvoker (= 0.61.5) - - ReactCommon/turbomodule (= 0.61.5) - - ReactCommon/jscallinvoker (0.61.5): + - react-native-video/Video (= 5.2.0-wp-5) + - react-native-video/Video (5.2.0-wp-5): + - React-Core + - react-native-webview (11.6.2): + - React-Core + - React-perflogger (0.69.4) + - React-RCTActionSheet (0.69.4): + - React-Core/RCTActionSheetHeaders (= 0.69.4) + - React-RCTAnimation (0.69.4): + - RCT-Folly (= 2021.06.28.00-v2) + - RCTTypeSafety (= 0.69.4) + - React-Codegen (= 0.69.4) + - React-Core/RCTAnimationHeaders (= 0.69.4) + - React-jsi (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - React-RCTBlob (0.69.4): + - RCT-Folly (= 2021.06.28.00-v2) + - React-Codegen (= 0.69.4) + - React-Core/RCTBlobHeaders (= 0.69.4) + - React-Core/RCTWebSocket (= 0.69.4) + - React-jsi (= 0.69.4) + - React-RCTNetwork (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - React-RCTImage (0.69.4): + - RCT-Folly (= 2021.06.28.00-v2) + - RCTTypeSafety (= 0.69.4) + - React-Codegen (= 0.69.4) + - React-Core/RCTImageHeaders (= 0.69.4) + - React-jsi (= 0.69.4) + - React-RCTNetwork (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - React-RCTLinking (0.69.4): + - React-Codegen (= 0.69.4) + - React-Core/RCTLinkingHeaders (= 0.69.4) + - React-jsi (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - React-RCTNetwork (0.69.4): + - RCT-Folly (= 2021.06.28.00-v2) + - RCTTypeSafety (= 0.69.4) + - React-Codegen (= 0.69.4) + - React-Core/RCTNetworkHeaders (= 0.69.4) + - React-jsi (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - React-RCTSettings (0.69.4): + - RCT-Folly (= 2021.06.28.00-v2) + - RCTTypeSafety (= 0.69.4) + - React-Codegen (= 0.69.4) + - React-Core/RCTSettingsHeaders (= 0.69.4) + - React-jsi (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - React-RCTText (0.69.4): + - React-Core/RCTTextHeaders (= 0.69.4) + - React-RCTVibration (0.69.4): + - RCT-Folly (= 2021.06.28.00-v2) + - React-Codegen (= 0.69.4) + - React-Core/RCTVibrationHeaders (= 0.69.4) + - React-jsi (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - React-runtimeexecutor (0.69.4): + - React-jsi (= 0.69.4) + - ReactCommon (0.69.4): + - React-logger (= 0.69.4) + - ReactCommon/react_debug_core (= 0.69.4) + - ReactCommon/turbomodule (= 0.69.4) + - ReactCommon/react_debug_core (0.69.4): + - React-logger (= 0.69.4) + - ReactCommon/turbomodule (0.69.4): - DoubleConversion - - Folly (= 2018.10.22.00) - glog - - React-cxxreact (= 0.61.5) - - ReactCommon/turbomodule (0.61.5): + - RCT-Folly (= 2021.06.28.00-v2) + - React-bridging (= 0.69.4) + - React-callinvoker (= 0.69.4) + - React-Core (= 0.69.4) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-logger (= 0.69.4) + - React-perflogger (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - ReactCommon/turbomodule/samples (= 0.69.4) + - ReactCommon/turbomodule/core (0.69.4): - DoubleConversion - - Folly (= 2018.10.22.00) - glog - - React-Core (= 0.61.5) - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - ReactCommon/jscallinvoker (= 0.61.5) - - ReactCommon/turbomodule/core (= 0.61.5) - - ReactCommon/turbomodule/samples (= 0.61.5) - - ReactCommon/turbomodule/core (0.61.5): + - RCT-Folly (= 2021.06.28.00-v2) + - React-bridging (= 0.69.4) + - React-callinvoker (= 0.69.4) + - React-Core (= 0.69.4) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-logger (= 0.69.4) + - React-perflogger (= 0.69.4) + - ReactCommon/turbomodule/samples (0.69.4): - DoubleConversion - - Folly (= 2018.10.22.00) - glog - - React-Core (= 0.61.5) - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - ReactCommon/jscallinvoker (= 0.61.5) - - ReactCommon/turbomodule/samples (0.61.5): + - RCT-Folly (= 2021.06.28.00-v2) + - React-bridging (= 0.69.4) + - React-callinvoker (= 0.69.4) + - React-Core (= 0.69.4) + - React-cxxreact (= 0.69.4) + - React-jsi (= 0.69.4) + - React-logger (= 0.69.4) + - React-perflogger (= 0.69.4) + - ReactCommon/turbomodule/core (= 0.69.4) + - RNCClipboard (1.9.0): + - React-Core + - RNCMaskedView (0.2.6): + - React-Core + - RNFastImage (8.5.11): + - React-Core + - SDWebImage (~> 5.11.1) + - SDWebImageWebPCoder (~> 0.8.4) + - RNGestureHandler (2.3.2-wp-2): + - React-Core + - RNReanimated (2.9.1-wp-3): - DoubleConversion - - Folly (= 2018.10.22.00) + - FBLazyVector + - FBReactNativeSpec - glog - - React-Core (= 0.61.5) - - React-cxxreact (= 0.61.5) - - React-jsi (= 0.61.5) - - ReactCommon/jscallinvoker (= 0.61.5) - - ReactCommon/turbomodule/core (= 0.61.5) - - ReactNativeDarkMode (0.0.10): - - React - - RNSVG (9.13.6-gb): - - React - - RNTAztecView (1.23.0): + - RCT-Folly + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core/DevSupport + - React-Core/RCTWebSocket + - React-CoreModules + - React-cxxreact + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-RCTActionSheet + - React-RCTAnimation + - React-RCTBlob + - React-RCTImage + - React-RCTLinking + - React-RCTNetwork + - React-RCTSettings + - React-RCTText + - ReactCommon/turbomodule/core + - Yoga + - RNScreens (2.9.0): + - React-Core + - RNSVG (9.13.6): - React-Core - - WordPress-Aztec-iOS (= 1.16.0) - - Sentry (4.4.3): - - Sentry/Core (= 4.4.3) - - Sentry/Core (4.4.3) - - SimulatorStatusMagic (2.4.1) + - RNTAztecView (1.94.0): + - React-Core + - WordPress-Aztec-iOS (~> 1.19.8) + - SDWebImage (5.11.1): + - SDWebImage/Core (= 5.11.1) + - SDWebImage/Core (5.11.1) + - SDWebImageWebPCoder (0.8.5): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.10) + - Sentry (8.6.0): + - Sentry/Core (= 8.6.0) + - SentryPrivate (= 8.6.0) + - Sentry/Core (8.6.0): + - SentryPrivate (= 8.6.0) + - SentryPrivate (8.6.0) + - Sodium (0.9.1) - Starscream (3.0.6) - SVProgressHUD (2.2.5) - - TOCropViewController (2.5.2) - - UIDeviceIdentifier (1.4.0) - - WordPress-Aztec-iOS (1.16.0) - - WordPress-Editor-iOS (1.16.0): - - WordPress-Aztec-iOS (= 1.16.0) - - WordPressAuthenticator (1.10.9-beta.1): - - 1PasswordExtension (= 1.8.5) - - Alamofire (= 4.8) - - CocoaLumberjack (~> 3.5) - - GoogleSignIn (~> 4.4) - - Gridicons (~> 0.15) - - lottie-ios (= 2.5.2) - - "NSURL+IDN (= 0.3)" - - SVProgressHUD (= 2.2.5) - - WordPressKit (~> 4.5.9-beta) - - WordPressShared (~> 1.8.13-beta) - - WordPressUI (~> 1.4-beta.1) - - WordPressKit (4.5.9-beta.2): + - SwiftLint (0.50.3) + - UIDeviceIdentifier (2.3.0) + - WordPress-Aztec-iOS (1.19.8) + - WordPress-Editor-iOS (1.19.8): + - WordPress-Aztec-iOS (= 1.19.8) + - WordPressAuthenticator (6.2.0): + - GoogleSignIn (~> 6.0.1) + - Gridicons (~> 1.0) + - "NSURL+IDN (= 0.4)" + - SVProgressHUD (~> 2.2.5) + - WordPressKit (~> 8.0-beta) + - WordPressShared (~> 2.1-beta) + - WordPressUI (~> 1.7-beta) + - WordPressKit (8.0.0): - Alamofire (~> 4.8.0) - - CocoaLumberjack (~> 3.4) - - NSObject-SafeExpectations (= 0.0.3) - - UIDeviceIdentifier (~> 1) - - WordPressShared (~> 1.8.13-beta) - - wpxmlrpc (= 0.8.4) - - WordPressMocks (0.0.8) - - WordPressShared (1.8.15-beta.2): - - CocoaLumberjack (~> 3.4) - - FormatterKit/TimeIntervalFormatter (= 1.8.2) - - WordPressUI (1.5.1) - - WPMediaPicker (1.6.0) - - wpxmlrpc (0.8.4) + - NSObject-SafeExpectations (~> 0.0.4) + - UIDeviceIdentifier (~> 2.0) + - WordPressShared (~> 2.0-beta) + - wpxmlrpc (~> 0.10) + - WordPressShared (2.2.0) + - WordPressUI (1.12.5) + - WPMediaPicker (1.8.7) + - wpxmlrpc (0.10.0) - Yoga (1.14.0) - - ZendeskCommonUISDK (4.0.0): - - ZendeskSDKConfigurationsSDK (~> 1.1.2) - - ZendeskCoreSDK (2.2.1) - - ZendeskMessagingAPISDK (3.0.0): - - ZendeskSDKConfigurationsSDK (~> 1.1.2) - - ZendeskMessagingSDK (3.0.0): - - ZendeskCommonUISDK (~> 4.0.0) - - ZendeskMessagingAPISDK (~> 3.0.0) - - ZendeskSDKConfigurationsSDK (1.1.2) - - ZendeskSupportProvidersSDK (5.0.0): - - ZendeskCoreSDK (~> 2.2.1) - - ZendeskSupportSDK (5.0.0): - - ZendeskMessagingSDK (~> 3.0.0) - - ZendeskSupportProvidersSDK (~> 5.0.0) - - ZIPFoundation (0.9.9) + - ZendeskCommonUISDK (6.1.2) + - ZendeskCoreSDK (2.5.1) + - ZendeskMessagingAPISDK (3.8.3): + - ZendeskSDKConfigurationsSDK (= 1.1.9) + - ZendeskMessagingSDK (3.8.3): + - ZendeskCommonUISDK (= 6.1.2) + - ZendeskMessagingAPISDK (= 3.8.3) + - ZendeskSDKConfigurationsSDK (1.1.9) + - ZendeskSupportProvidersSDK (5.3.0): + - ZendeskCoreSDK (~> 2.5.1) + - ZendeskSupportSDK (5.3.0): + - ZendeskMessagingSDK (~> 3.8.2) + - ZendeskSupportProvidersSDK (~> 5.3.0) + - ZIPFoundation (0.9.13) DEPENDENCIES: - - 1PasswordExtension (= 1.8.5) - Alamofire (= 4.8.0) + - AlamofireImage (= 3.5.2) - AlamofireNetworkActivityIndicator (~> 2.4) - - AppCenter (= 2.5.1) - - AppCenter/Distribute (= 2.5.1) - - Automattic-Tracks-iOS (~> 0.4.3) - - Charts (~> 3.2.2) - - CocoaLumberjack (= 3.5.2) + - AppCenter (~> 4.1) + - AppCenter/Distribute (~> 4.1) + - Automattic-Tracks-iOS (~> 2.2) + - boost (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/boost.podspec.json`) + - BVLinearGradient (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/BVLinearGradient.podspec.json`) + - CocoaLumberjack/Swift (~> 3.0) + - CropViewController (= 2.5.3) - Down (~> 0.6.6) - - FBLazyVector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/FBLazyVector.podspec.json`) - - FBReactNativeSpec (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/FBReactNativeSpec.podspec.json`) - - Folly (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/Folly.podspec.json`) - - FormatterKit/TimeIntervalFormatter (= 1.8.2) + - FBLazyVector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/FBLazyVector.podspec.json`) + - FBReactNativeSpec (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.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/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/glog.podspec.json`) - - Gridicons (~> 0.16) - - Gutenberg (from `http://github.com/wordpress-mobile/gutenberg-mobile/`, commit `d377b883c761c2a71d29bd631f3d3227b3e313a2`) + - glog (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/glog.podspec.json`) + - Gridicons (~> 1.1.0) + - Gutenberg (from `https://github.com/wordpress-mobile/gutenberg-mobile.git`, tag `v1.94.0`) - JTAppleCalendar (~> 8.0.2) - - MediaEditor (~> 1.0.1) + - Kanvas (~> 1.4.4) + - MediaEditor (~> 1.2.1) - MRProgress (= 0.8.3) - - Nimble (~> 7.3.1) - - NSObject-SafeExpectations (= 0.0.3) - - "NSURL+IDN (= 0.3)" - - OCMock (= 3.4.3) - - OHHTTPStubs (= 6.1.0) - - OHHTTPStubs/Swift (= 6.1.0) - - RCTRequired (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RCTRequired.podspec.json`) - - RCTTypeSafety (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RCTTypeSafety.podspec.json`) + - NSObject-SafeExpectations (~> 0.0.4) + - "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.94.0/third-party-podspecs/RCT-Folly.podspec.json`) + - RCTRequired (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RCTRequired.podspec.json`) + - RCTTypeSafety (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RCTTypeSafety.podspec.json`) - Reachability (= 3.2) - - React (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React.podspec.json`) - - React-Core (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-Core.podspec.json`) - - React-CoreModules (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-CoreModules.podspec.json`) - - React-cxxreact (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-cxxreact.podspec.json`) - - React-jsi (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsi.podspec.json`) - - React-jsiexecutor (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsiexecutor.podspec.json`) - - React-jsinspector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsinspector.podspec.json`) - - react-native-keyboard-aware-scroll-view (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-keyboard-aware-scroll-view.podspec.json`) - - react-native-linear-gradient (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-linear-gradient.podspec.json`) - - react-native-safe-area (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-safe-area.podspec.json`) - - react-native-slider (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-slider.podspec.json`) - - react-native-video (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-video.podspec.json`) - - React-RCTActionSheet (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTActionSheet.podspec.json`) - - React-RCTAnimation (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTAnimation.podspec.json`) - - React-RCTBlob (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTBlob.podspec.json`) - - React-RCTImage (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTImage.podspec.json`) - - React-RCTLinking (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTLinking.podspec.json`) - - React-RCTNetwork (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTNetwork.podspec.json`) - - React-RCTSettings (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTSettings.podspec.json`) - - React-RCTText (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTText.podspec.json`) - - React-RCTVibration (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTVibration.podspec.json`) - - ReactCommon (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/ReactCommon.podspec.json`) - - ReactNativeDarkMode (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/ReactNativeDarkMode.podspec.json`) - - RNSVG (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RNSVG.podspec.json`) - - RNTAztecView (from `http://github.com/wordpress-mobile/gutenberg-mobile/`, commit `d377b883c761c2a71d29bd631f3d3227b3e313a2`) - - SimulatorStatusMagic + - React (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React.podspec.json`) + - React-bridging (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-bridging.podspec.json`) + - React-callinvoker (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-callinvoker.podspec.json`) + - React-Codegen (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-Codegen.podspec.json`) + - React-Core (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-Core.podspec.json`) + - React-CoreModules (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-CoreModules.podspec.json`) + - React-cxxreact (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-cxxreact.podspec.json`) + - React-jsi (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsi.podspec.json`) + - React-jsiexecutor (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsiexecutor.podspec.json`) + - React-jsinspector (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsinspector.podspec.json`) + - React-logger (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-logger.podspec.json`) + - react-native-blur (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-blur.podspec.json`) + - react-native-get-random-values (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-get-random-values.podspec.json`) + - react-native-safe-area (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.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.94.0/third-party-podspecs/react-native-safe-area-context.podspec.json`) + - react-native-slider (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-slider.podspec.json`) + - react-native-video (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-video.podspec.json`) + - react-native-webview (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-webview.podspec.json`) + - React-perflogger (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-perflogger.podspec.json`) + - React-RCTActionSheet (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTActionSheet.podspec.json`) + - React-RCTAnimation (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTAnimation.podspec.json`) + - React-RCTBlob (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTBlob.podspec.json`) + - React-RCTImage (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTImage.podspec.json`) + - React-RCTLinking (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTLinking.podspec.json`) + - React-RCTNetwork (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTNetwork.podspec.json`) + - React-RCTSettings (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTSettings.podspec.json`) + - React-RCTText (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTText.podspec.json`) + - React-RCTVibration (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTVibration.podspec.json`) + - React-runtimeexecutor (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-runtimeexecutor.podspec.json`) + - ReactCommon (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/ReactCommon.podspec.json`) + - RNCClipboard (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNCClipboard.podspec.json`) + - RNCMaskedView (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNCMaskedView.podspec.json`) + - RNFastImage (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNFastImage.podspec.json`) + - RNGestureHandler (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNGestureHandler.podspec.json`) + - RNReanimated (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNReanimated.podspec.json`) + - RNScreens (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNScreens.podspec.json`) + - RNSVG (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNSVG.podspec.json`) + - RNTAztecView (from `https://github.com/wordpress-mobile/gutenberg-mobile.git`, tag `v1.94.0`) - Starscream (= 3.0.6) - SVProgressHUD (= 2.2.5) - - WordPress-Editor-iOS (~> 1.16.0) - - WordPressAuthenticator (~> 1.10.9-beta) - - WordPressKit (~> 4.5.9-beta) - - WordPressMocks (~> 0.0.8) - - WordPressShared (= 1.8.15-beta.2) - - WordPressUI (~> 1.5.1) - - WPMediaPicker (~> 1.6.0) - - Yoga (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/Yoga.podspec.json`) - - ZendeskSupportSDK (= 5.0.0) + - SwiftLint (~> 0.50) + - WordPress-Editor-iOS (~> 1.19.8) + - WordPressAuthenticator (~> 6.1-beta) + - WordPressKit (~> 8.0-beta) + - WordPressShared (from `https://github.com/wordpress-mobile/WordPress-iOS-Shared.git`, branch `trunk`) + - WordPressUI (~> 1.12.5) + - WPMediaPicker (~> 1.8.7) + - Yoga (from `https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.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 trunk: - - 1PasswordExtension - Alamofire + - AlamofireImage - AlamofireNetworkActivityIndicator + - AppAuth - AppCenter - Automattic-Tracks-iOS - - boost-for-react-native - - Charts - CocoaLumberjack + - CropViewController - DoubleConversion - Down - - FormatterKit + - fmt - Gifu - GoogleSignIn - - GoogleToolboxForMac - Gridicons + - GTMAppAuth - GTMSessionFetcher - JTAppleCalendar - - lottie-ios + - Kanvas + - libwebp - MediaEditor - MRProgress - - Nimble - NSObject-SafeExpectations - "NSURL+IDN" - OCMock - OHHTTPStubs - Reachability + - SDWebImage + - SDWebImageWebPCoder - Sentry - - SimulatorStatusMagic + - SentryPrivate + - Sodium - Starscream - SVProgressHUD - - TOCropViewController + - SwiftLint - UIDeviceIdentifier - WordPress-Aztec-iOS - WordPress-Editor-iOS - - WordPressAuthenticator - - WordPressKit - - WordPressMocks - - WordPressShared - WordPressUI - WPMediaPicker - wpxmlrpc @@ -538,174 +675,231 @@ SPEC REPOS: - ZIPFoundation EXTERNAL SOURCES: + boost: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/boost.podspec.json + BVLinearGradient: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/BVLinearGradient.podspec.json FBLazyVector: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/FBLazyVector.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/FBLazyVector.podspec.json FBReactNativeSpec: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/FBReactNativeSpec.podspec.json - Folly: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/Folly.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.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/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/glog.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/glog.podspec.json Gutenberg: - :commit: d377b883c761c2a71d29bd631f3d3227b3e313a2 - :git: http://github.com/wordpress-mobile/gutenberg-mobile/ + :git: https://github.com/wordpress-mobile/gutenberg-mobile.git + :submodules: true + :tag: v1.94.0 + RCT-Folly: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RCT-Folly.podspec.json RCTRequired: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RCTRequired.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RCTRequired.podspec.json RCTTypeSafety: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RCTTypeSafety.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RCTTypeSafety.podspec.json React: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React.podspec.json + React-bridging: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-bridging.podspec.json + React-callinvoker: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-callinvoker.podspec.json + React-Codegen: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-Codegen.podspec.json React-Core: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-Core.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-Core.podspec.json React-CoreModules: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-CoreModules.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-CoreModules.podspec.json React-cxxreact: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-cxxreact.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-cxxreact.podspec.json React-jsi: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsi.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsi.podspec.json React-jsiexecutor: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsiexecutor.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsiexecutor.podspec.json React-jsinspector: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-jsinspector.podspec.json - react-native-keyboard-aware-scroll-view: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-keyboard-aware-scroll-view.podspec.json - react-native-linear-gradient: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-linear-gradient.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-jsinspector.podspec.json + React-logger: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-logger.podspec.json + react-native-blur: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-blur.podspec.json + react-native-get-random-values: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-get-random-values.podspec.json react-native-safe-area: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-safe-area.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.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.94.0/third-party-podspecs/react-native-safe-area-context.podspec.json react-native-slider: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-slider.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-slider.podspec.json react-native-video: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/react-native-video.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-video.podspec.json + react-native-webview: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/react-native-webview.podspec.json + React-perflogger: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-perflogger.podspec.json React-RCTActionSheet: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTActionSheet.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTActionSheet.podspec.json React-RCTAnimation: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTAnimation.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTAnimation.podspec.json React-RCTBlob: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTBlob.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTBlob.podspec.json React-RCTImage: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTImage.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTImage.podspec.json React-RCTLinking: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTLinking.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTLinking.podspec.json React-RCTNetwork: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTNetwork.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTNetwork.podspec.json React-RCTSettings: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTSettings.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTSettings.podspec.json React-RCTText: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTText.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTText.podspec.json React-RCTVibration: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/React-RCTVibration.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-RCTVibration.podspec.json + React-runtimeexecutor: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/React-runtimeexecutor.podspec.json ReactCommon: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/ReactCommon.podspec.json - ReactNativeDarkMode: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/ReactNativeDarkMode.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/ReactCommon.podspec.json + RNCClipboard: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNCClipboard.podspec.json + RNCMaskedView: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNCMaskedView.podspec.json + RNFastImage: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNFastImage.podspec.json + RNGestureHandler: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNGestureHandler.podspec.json + RNReanimated: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNReanimated.podspec.json + RNScreens: + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNScreens.podspec.json RNSVG: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/RNSVG.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/RNSVG.podspec.json RNTAztecView: - :commit: d377b883c761c2a71d29bd631f3d3227b3e313a2 - :git: http://github.com/wordpress-mobile/gutenberg-mobile/ + :git: https://github.com/wordpress-mobile/gutenberg-mobile.git + :submodules: true + :tag: v1.94.0 + WordPressShared: + :branch: trunk + :git: https://github.com/wordpress-mobile/WordPress-iOS-Shared.git Yoga: - :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/d377b883c761c2a71d29bd631f3d3227b3e313a2/react-native-gutenberg-bridge/third-party-podspecs/Yoga.podspec.json + :podspec: https://raw.githubusercontent.com/wordpress-mobile/gutenberg-mobile/v1.94.0/third-party-podspecs/Yoga.podspec.json CHECKOUT OPTIONS: FSInteractiveMap: :git: https://github.com/wordpress-mobile/FSInteractiveMap.git :tag: 0.2.0 Gutenberg: - :commit: d377b883c761c2a71d29bd631f3d3227b3e313a2 - :git: http://github.com/wordpress-mobile/gutenberg-mobile/ + :git: https://github.com/wordpress-mobile/gutenberg-mobile.git + :submodules: true + :tag: v1.94.0 RNTAztecView: - :commit: d377b883c761c2a71d29bd631f3d3227b3e313a2 - :git: http://github.com/wordpress-mobile/gutenberg-mobile/ + :git: https://github.com/wordpress-mobile/gutenberg-mobile.git + :submodules: true + :tag: v1.94.0 + WordPressShared: + :commit: 9a010fdab8d31f9e1fa0511f231e7068ef0170b1 + :git: https://github.com/wordpress-mobile/WordPress-iOS-Shared.git SPEC CHECKSUMS: - 1PasswordExtension: 0e95bdea64ec8ff2f4f693be5467a09fac42a83d Alamofire: 3ec537f71edc9804815215393ae2b1a8ea33a844 + AlamofireImage: 63cfe3baf1370be6c498149687cf6db3e3b00999 AlamofireNetworkActivityIndicator: 9acc3de3ca6645bf0efed462396b0df13dd3e7b8 - AppCenter: fddcbac6e4baae3d93a196ceb0bfe0e4ce407dec - Automattic-Tracks-iOS: 5515b3e6a5e55183a244ca6cb013df26810fa994 - boost-for-react-native: 39c7adb57c4e60d6c5479dd8623128eb5b3f0f2c - Charts: f69cf0518b6d1d62608ca504248f1bbe0b6ae77e - CocoaLumberjack: 118bf4a820efc641f79fa487b75ed928dccfae23 + AppAuth: e48b432bb4ba88b10cb2bcc50d7f3af21e78b9c2 + AppCenter: b0b6f1190215b5f983c42934db718f3b46fff3c0 + Automattic-Tracks-iOS: a1b020ab02f0e5a39c5d4e6870a498273f286158 + boost: 32a63928ef0a5bf8b60f6b930c8864113fa28779 + BVLinearGradient: 708898fab8f7113d927b0ef611a321e759f6ad3e + CocoaLumberjack: 78abfb691154e2a9df8ded4350d504ee19d90732 + CropViewController: a5c143548a0fabcd6cc25f2d26e40460cfb8c78c DoubleConversion: e22e0762848812a87afd67ffda3998d9ef29170c Down: 71bf4af3c04fa093e65dffa25c4b64fa61287373 - FBLazyVector: 47798d43f20e85af0d3cef09928b6e2d16dbbe4c - FBReactNativeSpec: 8d0bf8eca089153f4196975ca190cda8c2d5dbd2 - Folly: 30e7936e1c45c08d884aa59369ed951a8e68cf51 - FormatterKit: 4b8f29acc9b872d5d12a63efb560661e8f2e1b98 + FBLazyVector: 16fdf30fcbc7177c6a4bdf35ef47225577eb9636 + FBReactNativeSpec: 2ffeca5f498ddc94234d823f38abf51ce0313171 + fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 FSInteractiveMap: a396f610f48b76cb540baa87139d056429abda86 Gifu: 7bcb6427457d85e0b4dff5a84ec5947ac19a93ea - glog: 1f3da668190260b06b429bb211bfbee5cd790c28 - GoogleSignIn: 7ff245e1a7b26d379099d3243a562f5747e23d39 - GoogleToolboxForMac: 800648f8b3127618c1b59c7f97684427630c5ea3 - Gridicons: dc92efbe5fd60111d2e8ea051d84a60cca552abc - GTMSessionFetcher: cea130bbfe5a7edc8d06d3f0d17288c32ffe9925 - Gutenberg: fd94d54ccf8605564288cc6ef0f762da70f18b01 - JTAppleCalendar: bb3dd3752e2bcc85cb798ab763fbdd6e142715fc - lottie-ios: 3fef45d3fabe63e3c7c2eb603dd64ddfffc73062 - MediaEditor: 7296cd01d7a0548fb2bc909aa72153b376a56a61 + glog: 741689bdd65551bc8fb59d633e55c34293030d3e + GoogleSignIn: fd381840dbe7c1137aa6dc30849a5c3e070c034a + Gridicons: 17d660b97ce4231d582101b02f8280628b141c9a + GTMAppAuth: 0ff230db599948a9ad7470ca667337803b3fc4dd + GTMSessionFetcher: 5595ec75acf5be50814f81e9189490412bad82ba + Gutenberg: f0bc3334e1a5e81077ef2496536b097eaeafe93e + JTAppleCalendar: 932cadea40b1051beab10f67843451d48ba16c99 + Kanvas: f932eaed3d3f47aae8aafb6c2d27c968bdd49030 + libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef + MediaEditor: 20cdeb46bdecd040b8bc94467ac85a52b53b193a MRProgress: 16de7cc9f347e8846797a770db102a323fe7ef09 - Nimble: 051e3d8912d40138fa5591c78594f95fb172af37 - NSObject-SafeExpectations: b989b68a8a9b7b9f2b264a8b52ba9d7aab8f3129 - "NSURL+IDN": 82355a0afd532fe1de08f6417c134b49b1a1c4b3 + NSObject-SafeExpectations: ab8fe623d36b25aa1f150affa324e40a2f3c0374 + "NSURL+IDN": afc873e639c18138a1589697c3add197fe8679ca OCMock: 43565190abc78977ad44a61c0d20d7f0784d35ab - OHHTTPStubs: 1e21c7d2c084b8153fc53d48400d8919d2d432d0 - RCTRequired: 3ca691422140f76f04fd2af6dc90914cf0f81ef1 - RCTTypeSafety: aab4e9679dbb3682bf0404fded7b9557d7306795 + OHHTTPStubs: 90eac6d8f2c18317baeca36698523dc67c513831 + RCT-Folly: b60af04f04d86a9f9c3317ba253365c4bd30ac5f + RCTRequired: f29d295ee209e2ac38b0aede22af2079ba814983 + RCTTypeSafety: 385273055103e9b60ac9ec070900621d3a31ff28 Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 - React: 5a954890216a4493df5ab2149f70f18592b513ac - React-Core: 865fa241faa644ff20cb5ec87787b32a5acc43b3 - React-CoreModules: 026fafece67a3802aa8bb1995d27227b0d95e0f5 - React-cxxreact: 9c76312456310d1b486e23edb9ce576a5397ebc2 - React-jsi: 6d6afac4873e8a3433334378589a0a8190d58070 - React-jsiexecutor: 9dfdcd0db23042623894dcbc02d61a772da8e3c1 - React-jsinspector: 89927b9ec6d75759882949d2043ba704565edaec - react-native-keyboard-aware-scroll-view: 01c4b2303c4ef1c49c4d239c9c5856f0393104df - react-native-linear-gradient: 258ba8c61848324b1f2019bed5f460e6396137b7 - react-native-safe-area: e8230b0017d76c00de6b01e2412dcf86b127c6a3 - react-native-slider: b36527edad24d49d9f3b53f3078334f45558f97b - react-native-video: 9de661e89386bb7ab78cc68e61a146cbdf5ad4ad - React-RCTActionSheet: e8f642cfaa396b6b09fd38f53378506c2d63af35 - React-RCTAnimation: cec1abbcfb006978a288c5072e3d611d6ff76d4c - React-RCTBlob: 7596eb2048150e429127a92a701e6cd40a8c0a74 - React-RCTImage: 03c7e36877a579ee51dcc33079cc8bc98658a722 - React-RCTLinking: cdc3f1aaff5f321bc954a98b7ffae3f864a6eaa3 - React-RCTNetwork: 33b3da6944786edea496a5fc6afea466633fd711 - React-RCTSettings: a3b7b3124315f8c91fad5d8aff08ee97d4b471cd - React-RCTText: ee9c8b70180fb58d062483d9664cd921d14b5961 - React-RCTVibration: 20deb1f6f001000d1f2603722ec110c66c74796b - ReactCommon: 48926fc48fcd7c8a629860049ffba9c23b4005dc - ReactNativeDarkMode: f61376360c5d983907e5c316e8e1c853a8c2f348 - RNSVG: 68a534a5db06dcbdaebfd5079349191598caef7b - RNTAztecView: 48948d6a92e3202dca86fbb3c579b0b3065c89fd - Sentry: 14bdd673870e8cf64932b149fad5bbbf39a9b390 - SimulatorStatusMagic: 28d4a9d1a500ac7cea0b2b5a43c1c6ddb40ba56c + React: ee95447578c5b9789ba7aad0593d162b72a45e6f + React-bridging: 011e313a56cbb8e98f97749b83f4b43fafdcf3db + React-callinvoker: 132da8333bd1a22a4d637a800bcd5e9bb051404f + React-Codegen: 1bb3fbcd85a52638967113eab1cc0acb3e719c6f + React-Core: bd57dad64f256ac856c5a5341c3433593bc9e98b + React-CoreModules: 98d0fd895946722aeda6214ff155f0ddeef02fa3 + React-cxxreact: 53614bcfdacdf57c6bf5ebbeb942dd020f6c9f37 + React-jsi: 828954dea2cd2fba7433d1c2e824d26f4a1c09fd + React-jsiexecutor: 8dfd84cc30ef554c37084f040db8171f998bec6c + React-jsinspector: f86975c8251bd7882f9a9d68df150db287a822bb + React-logger: 16a67709f5aa1d752fd09f9e6ccbf802ba0c24e9 + react-native-blur: 14c75aa19da8734c1656d5b6ca5adb859b2c26aa + react-native-get-random-values: 2869478c635a6e33080b917ce33f2803cb69262c + react-native-safe-area: e3de9e959c7baaae8db9bcb015d99ed1da25c9d5 + react-native-safe-area-context: 1e501ec30c260422def56e95e11edb103caa5bf2 + react-native-slider: f1ea4381d6d43ef5b945b5b101e9c66d249630a6 + react-native-video: 7b1832a8dcea07303f5e696b639354ea599931ff + react-native-webview: fca2337b045c6554b4209ab5073e922fabac8e17 + React-perflogger: 685c7bd5da242acbe09ae37488dd81c7d41afbb4 + React-RCTActionSheet: 6c194ed0520d57075d03f3daf58ad025b1fb98a2 + React-RCTAnimation: 2c9468ff7d0116801a994f445108f4be4f41f9df + React-RCTBlob: 18a19196ddf511eaab0be1ff30feb0c38b9ad5c9 + React-RCTImage: 72af5e51c5ce2e725ebddea590901fb9c4fd46aa + React-RCTLinking: 6224cf6652cb9a6304c7d5c3e5ab92a72a0c9bf7 + React-RCTNetwork: e82a24ca24d461dd8f9c087eb4332bd77004c906 + React-RCTSettings: 81df0a79a648cb1678220e926d92e6ebc5ea6cc5 + React-RCTText: b55360e76043f19128eee6ac04e0cbd53e6baf79 + React-RCTVibration: 87d2dbefada4a1c345dcdc4c522494ac95c8bc9a + React-runtimeexecutor: f4e1071b6cebeef4a30896343960606dc09ca079 + ReactCommon: bb76a4ca9fb5c2b8b1428dcfe0bc32eba5e4c02d + RNCClipboard: e2298216e12d730c3c2eb9484095e1f2e1679cce + RNCMaskedView: b467479e450f13e5dcee04423fefd2534f08c3eb + RNFastImage: 9407b5abc43452149a2f628107c64a7d11aa2948 + RNGestureHandler: f1645f845fc899a01cd7f87edf634b670de91b07 + RNReanimated: 8abe8173f54110a9ae98a629d0d8bf343a84f739 + RNScreens: bd1f43d7dfcd435bc11d4ee5c60086717c45a113 + RNSVG: 259ef12cbec2591a45fc7c5f09d7aa09e6692533 + RNTAztecView: 80480c43423929f7e3b7012670787e7375fbac9c + SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d + SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d + Sentry: d80553ff85ea72def75b792aaa5a71c158e51595 + SentryPrivate: ef1c5b3dfe44ec0c70e2eb343a5be2689164c021 + Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da Starscream: ef3ece99d765eeccb67de105bfa143f929026cf5 SVProgressHUD: 1428aafac632c1f86f62aa4243ec12008d7a51d6 - TOCropViewController: e9da34f484aedd4e5d5a8ab230ba217cfe16c729 - UIDeviceIdentifier: 44f805037d21b94394821828f4fcaba34b38c2d0 - WordPress-Aztec-iOS: 64a2989d25befb5ce086fac440315f696026ffd5 - WordPress-Editor-iOS: 63ef6a532af2c92e3301421f5c4af41ad3be8721 - WordPressAuthenticator: 64239e90c2bb2b1885789da6510575744674a65d - WordPressKit: 54b1c041c59b871e91a331f24a2fb5d347e070b0 - WordPressMocks: b4064b99a073117bbc304abe82df78f2fbe60992 - WordPressShared: 28f28c072d5d97fbd892fa23d58f4205c2e09e90 - WordPressUI: ce0ac522146dabcd0a68ace24c0104dfdf6f4b0d - WPMediaPicker: e5d28197da6b467d4e5975d64a49255977e39455 - wpxmlrpc: 6ba55c773cfa27083ae4a2173e69b19f46da98e2 - Yoga: c920bf12bf8146aa5cd118063378c2cf5682d16c - ZendeskCommonUISDK: 3c432801e31abff97d6e30441ea102eaef6b99e2 - ZendeskCoreSDK: f264e849b941a4b9b22215520765b8d9980478c3 - ZendeskMessagingAPISDK: 7c0cbd1d2c941f05b36f73e7db5faee5863fe8b0 - ZendeskMessagingSDK: 6f168161d834dd66668344f645f7a6b6b121b58a - ZendeskSDKConfigurationsSDK: 13eaf9b688504aaf7d5803c33772ced314b2e837 - ZendeskSupportProvidersSDK: 96b704d58bf0d44978de135607059f379c766e58 - ZendeskSupportSDK: a87ab1e4badace92c75eb11dc77ede1e995b2adc - ZIPFoundation: 89df685c971926b0323087952320bdfee9f0b6ef + SwiftLint: 77f7cb2b9bb81ab4a12fcc86448ba3f11afa50c6 + UIDeviceIdentifier: 442b65b4ff1832d4ca9c2a157815cb29ad981b17 + WordPress-Aztec-iOS: 7d11d598f14c82c727c08b56bd35fbeb7dafb504 + WordPress-Editor-iOS: 9eb9f12f21a5209cb837908d81ffe1e31cb27345 + WordPressAuthenticator: b0b900696de5129a215adcd1e9ae6eb89da36ac8 + WordPressKit: b65a51863982d8166897bea8b753f1fc51732aad + WordPressShared: 87f3ee89b0a3e83106106f13a8b71605fb8eb6d2 + WordPressUI: c5be816f6c7b3392224ac21de9e521e89fa108ac + WPMediaPicker: 0d45dfd7b3c5651c5236ffd48c1b0b2f60a2d5d2 + wpxmlrpc: 68db063041e85d186db21f674adf08d9c70627fd + Yoga: 5e12f4deb20582f86f6323e1cdff25f07afc87f6 + ZendeskCommonUISDK: 5f0a83f412e07ae23701f18c412fe783b3249ef5 + ZendeskCoreSDK: 19a18e5ef2edcb18f4dbc0ea0d12bd31f515712a + ZendeskMessagingAPISDK: db91be0c5cb88229d22f0e560ed99ba6e1dce02e + ZendeskMessagingSDK: ce2750c0a3dbd40918ea2e2d44dd0dbe34d21bc8 + ZendeskSDKConfigurationsSDK: f91f54f3b41aa36ffbc43a37af9956752a062055 + ZendeskSupportProvidersSDK: 2bdf8544f7cd0fd4c002546f5704b813845beb2a + ZendeskSupportSDK: 3a8e508ab1d9dd22dc038df6c694466414e037ba + ZIPFoundation: ae5b4b813d216d3bf0a148773267fff14bd51d37 -PODFILE CHECKSUM: 9405bf223f24480ab74f14e9cb3eec6313182ab4 +PODFILE CHECKSUM: ab88bd849ac377484fd7f0c4b079701ce16de5a3 -COCOAPODS: 1.8.4 +COCOAPODS: 1.11.3 diff --git a/README.md b/README.md index 4cc2658b9ac9..94abc77b03bb 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,48 @@ # WordPress for iOS # -[![CircleCI](https://circleci.com/gh/wordpress-mobile/WordPress-iOS.svg?style=svg)](https://circleci.com/gh/wordpress-mobile/WordPress-iOS) +[![Build status](https://badge.buildkite.com/2f3fbb17bfbb5bba508efd80f1ea8d640db5ca2465a516a457.svg)](https://buildkite.com/automattic/wordpress-ios) [![Reviewed by Hound](https://img.shields.io/badge/Reviewed_by-Hound-8E64B0.svg)](https://houndci.com) ## Build Instructions Please refer to the sections below for more detailed information. The instructions assume the work is performed from a command line. +> Please note – these setup instructions only apply to Intel-based machines. M1-based Mac support is coming, but isn't yet supported by our tooling. + +### Getting Started + 1. [Download](https://developer.apple.com/downloads/index.action) and install Xcode. *WordPress for iOS* requires Xcode 11.2.1 or newer. -1. From a command line, `git clone git@github.com:wordpress-mobile/WordPress-iOS.git` in the folder of your preference. -1. `cd WordPress-iOS` to enter the working directory. -1. `rake dependencies` to install all dependencies required to run the project (this may take some time to complete). -1. `rake xcode` to open the project in Xcode. -1. Compile and run the app on a device or an simulator. +1. From a command line, run `git clone git@github.com:wordpress-mobile/WordPress-iOS.git` in the folder of your preference. +1. Now, run `cd WordPress-iOS` to enter the working directory. -In order to login to WordPress.com using the app: +#### Create WordPress.com API Credentials 1. Create a WordPress.com account at https://wordpress.com/start/user (if you don't already have one). 1. Create an application at https://developer.wordpress.com/apps/. 1. Set "Redirect URLs"= `https://localhost` and "Type" = `Native` and click "Create" then "Update". -1. Copy the `Client ID` and `Client Secret` from the OAuth Information. -1. From a command line, ensure you are in the project's working directory and run `cp WordPress/Credentials/wpcom_app_credentials-example .configure-files/wpcom_app_credentials` to copy the sample credentials file. -1. Open the newly copied `.configure-files/wpcom_app_credentials` with the text editor of your choice, and replace `WPCOM_APP_ID` and `WPCOM_APP_SECRET` with the `Client ID` and `Client Secret` of the application you created. Note that `.configure-files` will be hidden by default in Finder. If you need to view it in Finder, hold down `Control`+`Shift`+`.` and it should appear. -1. Recompile and run the app on a device or an simulator. +1. Copy the `Client ID` and `Client Secret` from the OAuth Information. + +#### Configure Your WordPress App Development Environment + +1. Check that your local version of Ruby matches the one in [.ruby-version](./.ruby-version). We recommend installing a tool like [rbenv](https://github.com/rbenv/rbenv) so your system will always use the version defined in that file. Once installed, simply run `rbenv install` in the repo to match the version. +1. Return to the command line and run `rake init:oss` to configure your computer and WordPress app to be able to run and login to WordPress.com +1. Once completed, run `rake xcode` to open the project in Xcode. + +If all went well you can now compile to your iOS device or simulator, and log into the WordPress app. -You can only log in with the WordPress.com account that you used to create the WordPress application. +Note: You can only log in with the WordPress.com account that you used to create the WordPress application. + +## Configuration Details + +The steps above will help you configure the WordPress app to run and compile. But you may sometimes need to update or re-run specific parts of the initial setup (like updating the dependencies.) To see how to do that, please check out the steps below. ### Third party tools -We use a few tools to help with development. Running `rake dependencies` will configure them for you. +We use a few tools to help with development. Running `rake dependencies` will configure or update them for you. #### CocoaPods -WordPress for iOS uses [CocoaPods](http://cocoapods.org/) to manage third party libraries. +WordPress for iOS uses [CocoaPods](http://cocoapods.org/) to manage third party libraries. Third party libraries and resources managed by CocoaPods will be installed by the `rake dependencies` command above. #### SwiftLint @@ -63,7 +73,7 @@ Launch the workspace by running the following from the command line: `rake xcode` -This will ensure any dependencies are ready before launching Xcode. +This will ensure any dependencies are ready before launching Xcode. You can also open the project by double clicking on `WordPress.xcworkspace` file, or launching Xcode and choose `File` > `Open` and browse to `WordPress.xcworkspace`. @@ -73,19 +83,14 @@ In order to login to WordPress.com with the app you need to create an account ov After you create an account you can create an application on the [WordPress.com applications manager](https://developer.wordpress.com/apps/). -When creating your application, select "Native client" for the application type. The applications manager currently requires a "redirect URL", but this isn't used for mobile apps. Just use "https://localhost". - -Your new application will have an associated client ID and a client secret key. These are used to authenticate the API calls made by your application. - -Next, create a credential file. Start by copying the sample credentials file in your local repo by doing this: - -`cp WordPress/Credentials/wpcom_app_credentials-example .configure-files/wpcom_app_credentials` +When creating your application, you should select "Native client" for the application type. +The "**Website URL**", "**Redirect URLs**", and "**Javascript Origins**" fields are required but not used for the mobile apps. Just use `https://localhost`. -Then edit the `WordPress/Credentials/wpcom_app_credentials-example` file and change the `WPCOM_APP_ID` and `WPCOM_APP_SECRET` fields to the values of your application's client ID and client secret. +Your new application will have an associated client ID and a client secret key. These are used to authenticate the API calls made by your application. -Now you can compile and run the app on a simulator and log in with a WordPress.com account. Note that authenticating to WordPress.com via Google is not supported in development builds of the app, only in the official release. +Next, run the command `rake credentials:setup` you will be prompted for your Client ID and your Client Secret. Once added you will be able to log into the WordPress app -**Remember the only WordPress.com account you will be able to login in with is the one used to create your client ID and client secret.** +**Remember the only WordPress.com account you will be able to login in with is the one used to create your client ID and client secret.** Read more about [OAuth2](https://developer.wordpress.com/docs/oauth2/) and the [WordPress.com REST endpoint](https://developer.wordpress.com/docs/api/). diff --git a/RELEASE-CYCLE.md b/RELEASE-CYCLE.md deleted file mode 100644 index 2ac342a1da66..000000000000 --- a/RELEASE-CYCLE.md +++ /dev/null @@ -1,36 +0,0 @@ -WordPress iOS releases are handled following the [Git Flow](http://nvie.com/posts/a-successful-git-branching-model/) model for Git with most release cycles lasting 2 weeks. - -## Standard Release - -A description of what happens during a standard release taking as an example version `9.1` of the app. - -### Day 1 (Monday): CODE FREEZE - -- Create a new branch from develop called `release/9.1`: only features completed before Day 1 will make it to the release. -- Generate the English strings file on this branch, this will pick up all the new strings that were added since the last release. -- Mark the milestone as frozen. -- Protect the branch to avoid unwanted merges. -- Release the beta version and post the call for testing on [Make WordPress Mobile](https://make.wordpress.org/mobile/). -- Merge back to develop. -- A script will automatically pick up new strings and upload them to GlotPress for translation. - -### Day 2-13: STABILIZATION - -- If we discover any bugs on `release/9.1` that were introduced on the last sprint, important crashes, or bugs in new features to be released, we submit a PR targeting `release/9.1`, and we make a new beta release. We then merge back to develop. - -### Day 14: SUBMISSION & RELEASE - -- Fetch the localized strings from GlotPress and integrate them into the project. -- Generate a production build and upload it to the store and phase release it. -- Finalize the release on GitHub and close the milestone. -- Merge `release/9.1` into `develop` and into `master`. - -## Hot Fix - -Sometimes there is a bug or crash that can’t wait two weeks to be fixed. This is how we handle this, for example when a critical issue is uncovered on version `9.1` of the app, currently released. - -- Create a new branch from master called `release/9.1.1`. -- Create a PR against that branch. -- Get approvals, test very very very well, merge. -- Submit to the store. -- Merge back into `develop` and into `master`. diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index d6f4156fe8e2..316f2216ff84 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,7 +1,948 @@ +22.4 +----- +* [**] [Jetpack-only] Adds a dashboard card for viewing activity log. [#20569] +* [**] [Jetpack-only] Adds a dashboard card for viewing pages. [#20524] + +22.3 +----- +* [*] [internal] Allow updating specific fields when updating media details. [#20606] +* [**] Block Editor: Enable VideoPress block (only on Simple WPCOM sites) [#20580] + +22.2 +----- +* [**] [Jetpack-only] Added a dashboard card for purchasing domains. [#20424] +* [*] [internal] [Jetpack-only] Redesigned the migration success card. [#20515] +* [**] [internal] Refactored Google SignIn implementation to not use the Google SDK [#20128] +* [***] Block Editor: Resolved scroll-jump issues and enhanced caret focus management [https://github.com/WordPress/gutenberg/pull/48791] +* [**] [Jetpack-only] Blogging Prompts: adds the ability to view other users' responses to a prompt. [#20540] + +22.1 +----- +* [**] [internal] Refactor updating account related Core Data operations, which ususally happens during log in and out of the app. [#20394] +* [***] [internal] Refactor uploading photos (from the device photo, the Free Photo library, and other sources) to the WordPress Media Library. Affected areas are where you can choose a photo and upload, including the "Media" screen, adding images to a post, updating site icon, etc. [#20322] +* [**] [WordPress-only] Warns user about sites with only individual plugins not supporting core app features and offers the option to switch to the Jetpack app. [#20408] +* [*] [Reader] Fix an issue that was causing the app to crash when tapping the More or Share buttons in Reader Detail screen. [#20490] +* [*] Block editor: Avoid empty Gallery block error [https://github.com/WordPress/gutenberg/pull/49557] + +22.0 +----- +* [*] Remove large title in Reader and Notifications tabs. [#20271] +* [*] Reader: Change the following button cog icon. [#20274] +* [*] [Jetpack-only] Change the dark background color of toolbars and top tabs across the whole app. [#20278] +* [*] Change the Reader's navigation bar background color to match other screens. [#20278] +* [*] Tweak My Site Dashboard Cards UI. [#20303] +* [*] [Jetpack-only] Change My Sites tab bar icon. [#20310] +* [*] [internal] Refactored the Core Data operations (saving the site data) after a new site is created. [#20270] +* [*] [internal] Refactored updating user role in the "People" screen on the "My Sites" tab. [#20244] +* [*] [internal] Refactor managing social connections and social buttons in the "Sharing" screen. [#20265] +* [*] [internal] Refactor uploading media assets. [#20294] +* [*] Block editor: Allow new block transforms for most blocks. [https://github.com/WordPress/gutenberg/pull/48792] +* [*] Visual improvements were made to the in-app survey along with updated text to differentiate between the WordPress and Jetpack apps. [#20276] +* [*] Reader: Resolve an issue that could cause the app to crash when blocking a post author. [#20421] + +21.9 +----- +* [*] [internal] Refactored fetching posts in the Reader tab, including post related operations (i.e. like/unlike, save for later, etc.) [#20197] +* [**] Reader: Add a button in the post menu to block an author and stop seeing their posts. [#20193] +* [**] [Jetpack-only] Jetpack individual plugin support: Warns user about sites with only individual plugins not supporting all features of the app yet and gives the ability to install the full Jetpack plugin. [#20223] +* [**] [Jetpack-only] Help: Display the Jetpack app FAQ card on Help screen when switching from the WordPress app to the Jetpack app is complete. [#20232] +* [***] [Jetpack-only] Blaze: We added support for Blaze in the app. The user can now promote a post or page from the app to reach new audiences. [#20253] + +21.8.1 +----- +* [**] [internal] Fixes a crash that happens in the background when the weekly roundup notification is being processed. [#20275] + +21.8 +----- +* [*] [WordPress-only] We have redesigned and simplified the landing screen. [#20061] +* [*] [internal] Refactored account related operations (i.e. log in and out of the app). [#19893] +* [*] [internal] Refactored comment related operations (i.e. like a comment, reply to a post or comment). +* [*] [internal] Refactored how reader topics are fetched from the database. [#20129] +* [*] [internal] Refactored blog related operations (i.e. loading blogs of the logged in account, updating blog settings). [#20047] +* [*] Reader: Add ability to block a followed site. [#20053] +* [*] Reader: Add ability to report a post's author. [#20064] +* [*] [internal] Refactored the topic related features in the Reader tab (i.e. following, unfollowing, and search). [#20150] +* [*] Fix inaccessible block settings within the unsupported block editor [https://github.com/WordPress/gutenberg/pull/48435] + +21.7 +----- +* [*] [Jetpack-only] Fixed an issue where stats were not displaying latest data when the system date rolls over to the next day while the app is in background. [#19989] +* [*] [Jetpack-only] Hide Scan Login Code when logged into an account with 2FA. [#19567] +* [**] [Jetpack-only] Blogging Prompts: add the ability to answer previous prompts, disable prompts, and other minor enhancements. [#20055] + +21.6 +----- +* [*] Fix a layout issue impacting the "No media matching your search" empty state message of the Media Picker screen. [#19820] +* [**] [internal] Refactor saving changes in the "Account Settings" page. [#19910] +* [*] The Migration flow doesn't complete automatically if the user interrupts the migration mid flow. [#19888] +* [**] [internal] Refactored fetching blog editor settings. [#19915] +* [*] [Jetpack-only] The Migration flow doesn't complete automatically if the user interrupts the migration mid flow. [#19888] +* [***] [Jetpack-only] Stats Insights Update. Helps you understand how your content is performing and what’s resonating with your audience. [#19909] +* [***] [internal] Delete all the activity logs after logging out. [#19930] +* [*] [Jetpack-only] Fixed an issue where Stats Followers details did not update on Pull-to-refresh in the Stats Followers Details screen [#19935] +* [**] Refactored loading WP.com plans. [#19949] +* [*] Resolve an edge case that was causing the user to be stuck in the "Onboading Questions" screen. [#19791] +* [*] [Jetpack-only] Tweak Migration Screens UI when fonts are enlarged. [#19944] + +21.5.1 +----- +* [*] [Jetpack-only] Fixed a bug where the Login flow was restarting every time the app enters the foreground. [#19961] + +21.5 +----- +* [***] [internal] A significant refactor to the app’s architecture was made to allow for the new simplified UI. Regression testing on the app’s main flows is needed. [#19817] +* [**] [internal] Disable Story posts when Jetpack features are removed [#19823] +* [*] [internal] Editor: Only register core blocks when `onlyCoreBlocks` capability is enabled [https://github.com/wordpress-mobile/gutenberg-mobile/pull/5293] +* [**] [internal] Disable StockPhoto and Tenor media sources when Jetpack features are removed [#19826] +* [*] [Jetpack-only] Fixed a bug where analytics calls weren't synced to the user account. [#19926] + +21.4 +----- +* [*] Fixed an issue where publishing Posts and Pages could fail under certain conditions. [#19717] +* [*] Share extension navigation bar is no longer transparent [#19700] +* [***] [Jetpack-only] Adds a smooth, opt-in transition to the Jetpack app for users migrating from the WordPress app. [#19759] +* [***] You can now migrate your site content to the Jetpack app without a hitch. [#19759] +* [**] [internal] Upgrade React Native from 0.66.2 to 0.69.4 [https://github.com/wordpress-mobile/gutenberg-mobile/pull/5193] +* [*] [internal] When a user migrates to the Jetpack app and allows notifications, WordPress app notifications are disabled. [#19616, #19611, #19590] +* [*] Reader now scrolls to the top if the tab bar button is tapped. [#19769] +* [*] [Internal] Update WordPressShared, WordPressKit, and WordPressAuthenticator to their latest versions. [#19643] + +21.3 +----- +* [*] Fixed a minor UI issue where the segmented control under My SIte was being clipped when "Home" is selected. [#19595] +* [*] Fixed an issue where the site wasn't removed and the app wasn't refreshed after disconnecting the site from WordPress.com. [#19634] +* [*] [internal] Fixed an issue where Jetpack extensions were conflicting with WordPress extensions. [#19665] + +21.2 +----- +* [*] [internal] Refactored fetching posts in the Reader tab. [#19539] +* [*] Fixed an issue where the message "No media matching your search" for the media picker is not visible [#19555] + +21.1 +----- +* [**] [Jetpack-only] We added a new landing screen with a cool animation that responds to device motion! [#19251, #19264, #19277, #19381, #19404, #19410, #19432, #19434, #19442, #19443, #19468, #19469] +* [*] [internal] Database access change: the 'new Core Data context structure' feature flag is turned on by default. [#19433] +* [***] [Jetpack-only] Widgets are now on Jetpack. Find Today, This Week, and All Time Widgets to display your Stats on your home screen. [#19479] +* [*] Block Editor: Fixed iOS Voice Control support within Image block captions. [https://github.com/WordPress/gutenberg/pull/44850] +* [***] Dropped support for iOS 13. Now supporting iOS 14.0 and above. [#19509] + +21.0 +----- +* [*] Fixed an issue where the cached notifications are retained after logging out of WordPress.com account [#19360] +* [**] [Jetpack-only] Added a share extension. Now users can share content to Jetpack through iOS's share sheet. This was previously only available on the WordPress app. [#19383] +* [*] Update launch screen. [#19341] +* [*] [Jetpack-only] Add ability to set custom app icon for Jetpack app. [#19378] +* [**] [Jetpack-only] Added a "Save as Draft" extension. Now users can save content to Jetpack through iOS's share sheet. This was previously only available on the WordPress app. [#19414] +* [**] [Jetpack-only] Enables Rich Notifications for the Jetpack app. Now we display more details on most of the push notifications. This was previously only available on the WordPress app. [#19415] +* [*] Reader: Comment Details have been redesigned. [#19387] +* [*] [internal] A refactor in weekly roundup notification scheduler. [#19422] +* [*] [internal] A low level database refactor around fetching cards in the Reader tab. [#19427] +* [*] Stories: Fixed an issue where the keyboard would overlap with the publish dialog in landscape. [#19350] +* [*] [internal] A refactor in fetch Reader posts and their comments. [#19458] +* [*] Fixed an issue where the navigation bar becomes invisible when swiping back to Login Prologue screen. [#19461] + +20.9 +----- +* [*] Login Flow: Provide ability for user to cancel login WP.com flow when already logged in to a self-hosted site [#19349] +* [*] [WordPress-only] Powered by Jetpack banner: Fixed an edge case where some scroll views could momentarily become unresponsive to touch. [#19369] +* [*] [Jetpack-only] Weekly roundup: Adds support for weekly roundup notifications to the Jetpack app. [#19364] +* [*] Fixed an issue where the push notifications prompt button would overlap on iPad. [#19304] +* [*] Story Post: Fixed an issue where deleting one image in a story draft would cause the following image not to load. [#16966] +* [*] Fixed an issue where the no result label on the side menu is oversize on iPad. [#19305] +* [*] [internal] Various low level database refactors around posts, pages, and comments. [#19353, #19363, #19386] + +20.8 +----- +* [*] User Mention: When replying to a post or a comment, sort user-mentions suggestions by prefix first then alphabetically. [#19218] +* [*] User Mention: Fixed an issue where the user-mentions suggestions were disappearing after expanding/collapsing the reply field. [#19248] +* [***] [internal] Update Sentry, our crash monitoring tool, to its latest major version [#19315] + +20.7 +----- +* [*] [Jetpack-only] Block Editor: Update link colors in action sheets from green to blue [https://github.com/WordPress/gutenberg/pull/42996] +* [*] Jetpack Social: Rebrand Publicize to Jetpack Social [https://github.com/wordpress-mobile/WordPress-iOS/pull/19262] + +20.6 +----- +* [*] [Jetpack-only] Recommend App: you can now share the Jetpack app with your friends. [#19174] +* [*] [Jetpack-only] Feature Announcements: new features are highlighted via the What's New modals. [#19176] +* [**] [Jetpack-only] Self-hosted sites: enables logging in via a self-hosted site / adding a self-hosted site [#19194] +* [*] Pages List: Fixed an issue where the app would freeze when opening the pages list if one of the featured images is a GIF. [#19184] +* [*] Stats: Fixed an issue where File Downloads section was being displayed for Jetpack sites even though it's not supported. [#19200] + +20.5 +----- +* [*] [Jetpack-only] Block Editor: Makes some small changes to the editor's accent colours for consistency. [#19113] +* [*] User Mention: Split the suggestions list into a prominent section and a regular section. [#19064] +* [*] Use larger thumbnail previews for recommended themes during site creation [https://github.com/wordpress-mobile/WordPress-iOS/pull/18972] +* [***] [internal] Block Editor: List block: Adds support for V2 behind a feature flag [https://github.com/WordPress/gutenberg/pull/42702] +* [**] Fix for Referrers Card Not Showing Search Engine Details [https://github.com/wordpress-mobile/WordPress-iOS/pull/19158] +* [*] WeeklyRoundupBackgroundTask - format notification body [https://github.com/wordpress-mobile/WordPress-iOS/pull/19144] + +20.4 +----- +* [*] Site Creation: Fixed a bug in the design picker where the horizontal position of designs could be reset. [#19020] +* [*] [internal] Block Editor: Add React Native FastImage [https://github.com/WordPress/gutenberg/pull/42009] +* [*] Block Editor: Inserter displays block collections [https://github.com/WordPress/gutenberg/pull/42405] +* [*] Block Editor: Fix incorrect spacing within Image alt text footnote [https://github.com/WordPress/gutenberg/pull/42504] +* [***] Block Editor: Gallery and Image block - Performance improvements [https://github.com/WordPress/gutenberg/pull/42178] +* [**] [WP.com and Jetpack sites with VideoPress] Prevent validation error when viewing VideoPress markup within app [https://github.com/Automattic/jetpack/pull/24548] +* [*] [internal] Add Jetpack branding elements (badges and banners) [#19007, #19040, #19049, #19059, #19062, #19065, #19071, #19073, #19103, #19074, #19085, #19094, #19102, #19104] + +20.3 +----- +* [*] Stories: Fixed a crash that could occur when adding multiple items to a Story post. [#18967] +* [*] User Mention: When replying to a post or a comment, the post author or comment author shows up at the top of the suggestions list. [#18979] +* [*] Block Editor: Fixed an issue where the media picker search query was being retained after dismissing the picker and opening it again. [#18980] +* [*] Block Editor: Add 'Insert from URL' option to Video block [https://github.com/WordPress/gutenberg/pull/41493] +* [*] Block Editor: Image block copies the alt text from the media library when selecting an item [https://github.com/WordPress/gutenberg/pull/41839] +* [*] Block Editor: Introduce "block recovery" option for invalid blocks [https://github.com/WordPress/gutenberg/pull/41988] + +20.2 +----- +* [*] Preview: Post preview now resizes to account for device orientation change. [#18921] +* [***] [Jetpack-only] Enables QR Code Login scanning from the Me menu. [#18904] +* [*] Reverted the app icon back to Cool Blue. Users can reselect last month's icon in Me > App Settings > App Icon if they'd like. [#18934] + +20.1 +----- +* [*] Notifications: Fixed an issue where the first notification opened in landscape mode was not scrollable. [#18823] +* [*] Site Creation: Enhances the design selection screen with recommended designs. [#18740] +* [***] [Jetpack-only] Introducing blogging prompts. Build a writing habit and support creativity with a periodic prompt for inspiration. [#18860] +* [**] Follow Conversation: A tooltip has been added to highlight the follow conversation feature. [#18848] +* [*] [internal] Block Editor: Bump react-native-gesture-handler to version 2.3.2. [#18742] +* [*] People Management: Fixed a crash that can occur when loading the People view. [#18907] + +20.0 +----- +* [*] Quick Start: The "Get to know the WordPress app" card has a fresh new look [#18688, #18747] +* [*] Block Editor: A11y: Improve text read by screen readers for BottomSheetSelectControl [https://github.com/WordPress/gutenberg/pull/41036] +* [*] Block Editor: Add 'Insert from URL' option to Image block [https://github.com/WordPress/gutenberg/pull/40334] +* [*] App Settings: refreshed the UI with updated colors for Media Cache Size controls, Clear Spot Index row button, and Clear Siri Shortcut Suggestions row button. From destructive (red color) to standard and brand colors. [#18636] +* [*] [internal] Quick Start: Fixed an issue where the Quick Start modal was not displayed after login if the user's default tab is Home. [#18721] +* [*] Quick Start: The Next Steps modal has a fresh new look [#18711] +* [*] [internal] Quick Start: Fixed a couple of layout issues with the Quick Start notices when rotating the device. [#18758] + +19.9 +----- +* [*] Site Settings: we fixed an issue that prevented the site title to be updated when it changed in Site Settings [#18543] +* [*] Media Picker: Fixed an issue where the empty state view was being displayed incorrectly. [#18471] +* [*] Quick Start: We are now showing a different set of Quick Start tasks for existing sites and new sites. The existing sites checklist includes new tours such as: "Check your notifications" and "Upload photos or videos". [#18395, #18412, #18443, #18471] +* [*] Site Creation: we fixed an issue where the navigation buttons were not scaling when large fonts were selected on the device [#18559] +* [**] Block Editor: Cover Block: Improve color contrast between background and text [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4808] +* [***] Block Editor: Add drag & drop blocks feature [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4832] +* [*] Block Editor: Gallery block: Fix broken "Link To" settings and add "Image Size" settings [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4841] +* [*] Block Editor: Unsupported Block Editor: Prevent WordPress.com tour banner from displaying. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4820] +* [*] Widgets: we fixed an issue where text appeared flipped in rtl languages [#18567] +* [*] Stats: we fixed a crash that occurred sometimes in Stats [#18613] +* [*] Posts list: we fixed an issue where the create button was not shown on iPad in split screen [#18609] + +19.8 +----- +* [**] Self hosted sites are not restricted by video length during media uploads [https://github.com/wordpress-mobile/WordPress-iOS/pull/18414] +* [*] [internal] My Site Dashboard: Made some changes to the code architecture of the dashboard. The majority of the changes are related to the posts cards. It should have no visible changes but could cause regressions. Please test it by creating/trashing drafts and scheduled posts and testing that they appear correctly on the dashboard. [#18405] +* [*] Quick Start: Updated the Stats tour. The tour can now be accessed from either the dashboard or the menu tab. [#18413] +* [*] Quick Start: Updated the Reader tour. The tour now highlights the Discover tab and guides users to follow topics via the Settings screen. [#18450] +* [*] [internal] Quick Start: Deleted the Edit your homepage tour. [#18469] +* [*] [internal] Quick Start: Refactored some code related to the tasks displayed in the Quick Start Card and the Quick Start modal. It should have no visible changes but could cause regressions. [#18395] +* [**] Follow Conversation flow now enables in-app notifications by default. They were updated to be opt-out rather than opt-in. [#18449] +* [*] Block Editor: Latest Posts block: Add featured image settings [https://github.com/WordPress/gutenberg/pull/39257] +* [*] Block Editor: Prevent incorrect notices displaying when switching between HTML-Visual mode quickly [https://github.com/WordPress/gutenberg/pull/40415] +* [*] Block Editor: Embed block: Fix inline preview cut-off when editing URL [https://github.com/WordPress/gutenberg/pull/35326] +* [*] Block Editor: Prevent gaps shown around floating toolbar when using external keyboard [https://github.com/WordPress/gutenberg/pull/40266] +* [**] We'll now ask users logging in which area of the app they'd like to focus on to build towards a more personalized experience. [#18385] + +19.7 +----- +* [*] a11y: VoiceOver has been improved on the Menus view and now announces changes to ordering. [#18155] +* [*] Notifications list: remove comment Trash swipe action. [#18349] +* [*] Web previews now abide by safe areas when a toolbar is shown [#18127] +* [*] Site creation: Adds a new screen asking the user the intent of the site [#18367] +* [**] Block Editor: Quote block: Adds support for V2 behind a feature flag [https://github.com/WordPress/gutenberg/pull/40133] +* [**] Block Editor: Update "add block" button's style in default editor view [https://github.com/WordPress/gutenberg/pull/39726] +* [*] Block Editor: Remove banner error notification on upload failure [https://github.com/WordPress/gutenberg/pull/39694] +* [*] My Site: display site name in My Site screen nav title [#18373] +* [*] [internal] Site creation: Adds a new screen asking the user the name of the site [#18280] + +19.6 +----- +* [*] Enhances the exit animation of notices. [#18182] +* [*] Media Permissions: display error message when using camera to capture photos and media permission not given [https://github.com/wordpress-mobile/WordPress-iOS/pull/18139] +* [***] My Site: your My Site screen now has two tabs, "Menu" and "Home". Under "Home", you'll find contextual cards with some highlights of whats going on with your site. Check your drafts or scheduled posts, your today's stats or go directly to another section of the app. [#18240] +* [*] [internal] Site creation: Adds a new screen asking the user the intent of the site [#18270] + +19.5 +----- +* [*] Improves the error message shown when trying to create a new site with non-English characters in the domain name [https://github.com/wordpress-mobile/WordPress-iOS/pull/17985] +* [*] Quick Start: updated the design for the Quick Start cell on My Site [#18095] +* [*] Reader: Fixed a bug where comment replies are misplaced after its parent comment is moderated [#18094] +* [*] Bug fix: Allow keyboard to be dismissed when the password field is focused during WP.com account creation. +* [*] iPad: Fixed a bug where the current displayed section wasn't selected on the menu [#18118] +* [**] Comment Notifications: updated UI and functionality to match My Site Comments. [#18141] +* [*] Block Editor: Add GIF badge for animated GIFs uploaded to Image blocks [https://github.com/WordPress/gutenberg/pull/38996] +* [*] Block Editor: Small refinement to media upload errors, including centering and tweaking copy. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4597] +* [*] Block Editor: Fix issue with list's starting index and the order [https://github.com/WordPress/gutenberg/pull/39354] +* [*] Quick Start: Fixed a bug where a user creating a new site is displayed a quick start tour containing data from their presviously active site. + +19.4 +----- +* [*] Site Creation: Fixed layout of domain input field for RTL languages. [#18006] +* [*] [internal] The FAB (blue button to create posts/stories/pages) creation/life cycle was changed [#18026] +* [*] Stats: we fixed a variety of performance issues in the Insight screen. [#17926, #17936, #18017] +* [*] Stats: we re-organized the default view in Insights, presenting more interesting data at a glance [#18072] +* [*] Push notifications will now display rich media when long pressed. [#18048] +* [*] Weekly Roundup: We made some further changes to try and ensure that Weekly Roundup notifications are showing up for everybody who's enabled them [#18029] +* [*] Block editor: Autocorrected Headings no longer apply bold formatting if they weren't already bold. [#17844] +* [***] Block editor: Support for multiple color palettes [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4588] +* [**] User profiles: Fixed issue where the app wasn't displaying any of the device photos which the user had granted the app access to. + +19.3 +----- +* [*] Site previews: Reduced visual flickering when previewing sites and templates. [#17861] +* [*] Stats: Scroll to new Insights card when added. [#17894] +* [*] Add "Copy Link" functionality to Posts List and Pages List [#17911] +* [*] [Jetpack-only] Enables the ability to use and create WordPress.com sites, and enables the Reader tab. [#17914, #17948] +* [*] Block editor: Additional error messages for media upload failures. [#17971] +* [**] Adds animated Gif support in notifications and comments [#17981] + +19.2 +----- +* [*] Site creation: Fixed bug where sites created within the app were not given the correct time zone, leading to post scheduling issues. [#17821] +* [*] Block editor: Replacing the media for an image set as featured prompts to update the featured image [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3930] +* [***] Block editor: Font size and line-height support for text-based blocks used in block-based themes [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4519] +* [**] Some of the screens of the app has a new, fresh and more modern visual, including the initial one: My Site. [#17812] +* [**] Notifications: added a button to mark all notifications in the selected filter as read. [#17840] +* [**] People: you can now manage Email Followers on the People section! [#17854] +* [*] Stats: fix navigation between Stats tab. [#17856] +* [*] Quick Start: Fixed a bug where a user logging in via a self-hosted site not connected to Jetpack would see Quick Start when selecting "No thanks" on the Quick Start prompt. [#17855] +* [**] Threaded comments: comments can now be moderated via a drop-down menu on each comment. [#17888] +* [*] Stats: Users can now add a new Insights card from the navigation bar. [#17867] +* [*] Site creation: The checkbox that appears when choosing a design no longer flickers when toggled. [#17868] + +19.1 +----- +* [*] Signup: Fixed bug where username selection screen could be pushed twice. [#17624] +* [**] Reader post details Comments snippet: added ability to manage conversation subscription and notifications. [#17749] +* [**] Accessibility: VoiceOver and Dynamic Type improvements on Activity Log and Schedule Post calendars [#17756, #17761, #17780] +* [*] Weekly Roundup: Fix a crash which was preventing weekly roundup notifications from appearing [#17765] +* [*] Self-hosted login: Improved error messages. [#17724] +* [*] Share Sheet from Photos: Fix an issue where certain filenames would not upload or render in Post [#16773] +* [*] Block editor: Fixed an issue where video thumbnails could show when selecting images, and vice versa. [#17670] +* [**] Media: If a user has only enabled limited device media access, we now show a prompt to allow the user to change their selection. [#17795] +* [**] Block editor: Fix content justification attribute in Buttons block [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4451] +* [*] Block editor: Hide help button from Unsupported Block Editor. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4352] +* [*] Block editor: Add contrast checker to text-based blocks [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4357] +* [*] Block editor: Fix missing translations of color settings [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4479] +* [*] Block editor: Highlight text: fix applying formatting for non-selected text [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4471] +* [***] Self-hosted sites: Fixed a crash when saving media and no Internet connection was available. [#17759] +* [*] Publicize: Fixed an issue where a successful login was not automatically detected when connecting a Facebook account to Publicize. [#17803] + +19.0 +----- +* [**] Video uploads: video upload is now limited to 5 minutes per video on free plans. [#17689] +* [*] Block editor: Give multi-line block names central alignment in inserter [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4343] +* [**] Block editor: Fix missing translations by refactoring the editor initialization code [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4332] +* [**] Block editor: Add Jetpack and Layout Grid translations [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4359] +* [**] Block editor: Fix text formatting mode lost after backspace is used [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4423] +* [*] Block editor: Add missing translations of unsupported block editor modal [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4410] +* [**] Time zone suggester: we have a new time zone selection screen that suggests the time zone based on the device, and improves search. [#17699] +* [*] Added the "Share WordPress with a friend" row back to the Me screen. [#17748] +* [***] Updated default app icon. [#17793] + +18.9 +----- +* [***] Reader Comments: Updated comment threads with a new design and some new capabilities. [#17659] +* [**] Block editor: Fix issue where editor doesn't auto-scroll so you can see what is being typed. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4299] +* [*] Block editor: Preformatted block: Fix an issue where the background color is not showing up for standard themes. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4292] +* [**] Block editor: Update Gallery Block to default to the new format and auto-convert old galleries to the new format. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4315] +* [***] Block editor: Highlight text: Enables color customization for specific text within a Paragraph block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4175] +* [**] Reader post details: a Comments snippet is now displayed after the post content. [#17650] + +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 +----- +* [*] Comment Reply: updated UI. [#17443, #17445] +* [***] Two-step Authentication notifications now require an unlocked device to approve or deny them. +* [***] Site Comments: Updated comment details with a fresh new look and capability to display rich contents. [#17466] +* [**] Block editor: Image block: Add ability to quickly link images to Media Files and Attachment Pages [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3971] +* [**] Block editor: Fixed a crash that could occur when copying lists from Microsoft Word. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4174] +* [***] Fixed an issue where trying to upload an image while offline crashes the app. [#17488] + +18.6 +----- +* [**] Comments: Users can now follow conversation via notifications, in addition to emails. [#17363] +* [**] Block editor: Block inserter indicates newly available block types [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4047] +* [*] Reader post comments: fixed an issue that prevented all comments from displaying. [#17373] +* [**] Stats: added Reader Discover nudge for sites with low traffic in order to increase it. [#17349, #17352, #17354, #17377] +* [**] Block editor: Search block - Text and background color support [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4127] +* [*] Block editor: Fix Embed Block loading glitch with resolver resolution approach [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4146] +* [*] Block editor: Fixed an issue where the Help screens may not respect an iOS device's notch. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4110] +* [**] Block editor: Block inserter indicates newly available block types [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4047] +* [*] Block editor: Add support for the Mark HTML tag [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4162] +* [*] Stats Insights: HTML tags no longer display in post titles. [#17380] + +18.5 +----- +* [**] Block editor: Embed block: Include Jetpack embed variants. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4008] +* [*] Fixed a minor visual glitch on the pre-publishing nudge bottom sheet. [https://github.com/wordpress-mobile/WordPress-iOS/pull/17300] +* [*] Improved support for larger text sizes when choosing a homepage layout or page layout. [#17325] +* [*] Site Comments: fixed an issue that caused the lists to not refresh. [#17303] +* [*] Block editor: Embed block: Fix inline preview cut-off when editing URL [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4072] +* [*] Block editor: Embed block: Fix URL not editable after dismissing the edit URL bottom sheet with empty value [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4094] +* [**] Block editor: Embed block: Detect when an embeddable URL is pasted into an empty paragraph. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4048] +* [**] Block editor: Pullquote block - Added support for text and background color customization [https://github.com/WordPress/gutenberg/pull/34451] +* [**] Block editor: Preformatted block - Added support for text and background color customization [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4071] +* [**] Stats: added Publicize and Blogging Reminders nudges for sites with low traffic in order to increase it. [#17142, #17261, #17294, #17312, #17323] +* [**] Fixed an issue that made it impossible to log in when emails had an apostrophe. [#17334] + +18.4 +----- +* [*] Improves our user images download logic to avoid synchronization issues. [#17197] +* [*] Fixed an issue where images point to local URLs in the editor when saving a post with ongoing uploads. [#17157] +* [**] Embed block: Add the top 5 specific embed blocks to the Block inserter list. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3995] +* [*] Embed block: Fix URL update when edited after setting a bad URL of a provider. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/4002] +* [**] Users can now contact support from inside the block editor screen. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3975] +* [**] Block editor: Help menu with guides about how to work with blocks [#17265] + +18.3 +----- +* [*] Fixed a bug on Reader that prevented Saved posts to be removed +* [*] Share Extension: Allow creation of Pages in addition to Posts. [#16084] +* [*] Updated the wording for the "Posts" and "Pages" entries in My Site screen [https://github.com/wordpress-mobile/WordPress-iOS/pull/17156] +* [**] Fixed a bug that prevented sharing images and videos out of your site's media library. [#17164] +* [*] Fixed an issue that caused `Follow conversation by email` to not appear on some post's comments. [#17159] +* [**] Block editor: Embed block: Enable WordPress embed preview [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3853] +* [**] Block editor: Embed block: Add error bottom sheet with retry and convert to link actions. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3921] +* [**] Block editor: Embed block: Implemented the No Preview UI when an embed is successful, but we're unable to show an inline preview [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3927] +* [*] Block editor: Embed block: Add device's locale to preview content [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3788] +* [*] Block editor: Column block: Translate column width's control labels [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3952] +* [**] Block editor: Embed block: Enable embed preview for Instagram and Vimeo providers. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3918] + +18.2 +----- +* [internal] Fixed an issue where source and platform tags were not added to a Zendesk ticket if the account has no blogs. [#17084] +* [*] Set the post formats to have 'Standard' first and then alphabetized the remaining items. [#17074] +* [*] Fixed wording of theme customization screen's menu bar by using "Activate" on inactive themes. [#17060] +* [*] Added pull-to-refresh to My Site. [#17089] +* [***] Weekly Roundup: users will receive a weekly notification that presents a summary of the activity on their most used sites [#17066, #17116] +* [**] Site Comments: when editing a Comment, the author's name, email address, and web address can now be changed. [#17111] +* [**] Block editor: Enable embed preview for a list of providers (for now only YouTube and Twitter) [https://github.com/WordPress/gutenberg/pull/34446] +* [***] Block editor: Add Inserter Block Search [https://github.com/WordPress/gutenberg/pull/33237] + +18.1 +----- +* [*] Reader: Fixes an issue where the top of an article could be cropped after rotating a device. [#17041] +* [*] Posts Settings: Removed deprecated Location feature. [#17052] +* [**] Added a time selection feature to Blogging Reminders: users can now choose at what time they will receive the reminders [#17024, #17033] +* [**] Block editor: Embed block: Add "Resize for smaller devices" setting. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3753] +* [**] Account Settings: added the ability to close user account. +* [*] Users can now share WordPress app with friends. Accessible from Me and About screen. [#16995] + +18.0 +----- +* [*] Fixed a bug that would make it impossible to scroll the plugins the first time the plugin section was opened. +* [*] Resolved an issue where authentication tokens weren't be regenerated when disabled on the server. [#16920] +* [*] Updated the header text sizes to better support large texts on Choose a Domain and Choose a Design flows. [#16923] +* [internal] Made a change to how Comment content is displayed. Should be no visible changes, but could cause regressions. [#16933] +* [internal] Converted Comment model properties to Swift. Should be no functional changes, but could cause regressions. [#16969, #16980] +* [internal] Updated GoogleSignIn to 6.0.1 through WordPressAuthenticator. Should be no visible changes, but could cause regression in Google sign in flow. [#16974] +* [internal] Converted Comment model properties to Swift. Should be no functional changes, but could cause regressions. [#16969] +* [*] Posts: Ampersands are correctly decoded in publishing notices instead of showing as HTML entites. [#16972] +* [***] Adjusted the image size of Theme Images for more optimal download speeds. [#16914] +* [*] Comments and Notifications list are now displayed with a unified design. [#16985] +* [*] Block editor: Add a "featured" banner and ability to set or remove an image as featured. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3449] + +17.9 +----- +* [internal] Redirect Terms and service to open the page in an external web view [#16907] +* [internal] Converted Comment model methods to Swift. Should be no functional changes, but could cause regressions. [#16898, #16905, #16908, #16913] +* [*] Enables Support for Global Style Colors with Full Site Editing Themes [#16823] +* [***] Block editor: New Block: Embed block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3727] + +17.8 +----- +* [*] Authors and Contributors can now view a site's Comments via My Site > Comments. [#16783] +* [*] [Jetpack-only] Fix bugs when tapping to notifications +* [*] Fixed some refresh issues with the site follow buttons in the reader. [#16819] +* [*] Block editor: Update loading and failed screens for web version of the editor [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3573] +* [*] Block editor: Handle floating keyboard case - Fix issue with the block selector on iPad. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3687] +* [**] Block editor: Added color/background customization for text blocks. [https://github.com/WordPress/gutenberg/pull/33250] + +17.7 +----- +* [***] Added blogging reminders. Choose which days you'd like to be reminded, and we'll send you a notification prompting you to post on your site +* [** Does not apply to Jetpack app] Self hosted sites that do not use Jetpack can now manage (install, uninstall, activate, and deactivate) their plugins [#16675] +* [*] Upgraded the Zendesk SDK to version 5.3.0 +* [*] You can now subscribe to conversations by email from Reader lists and articles. [#16599] +* [*] Block editor: Tablet view fixes for inserter button. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3602] +* [*] Block editor: Tweaks to the badge component's styling, including change of background color and reduced padding. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3642] +* [***] Block editor: New block Layout grid. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3513] +* [*] Fixed an issue where the SignUp flow could not be dismissed sometimes. [#16824] + +17.6 +----- +* [**] Reader Post details: now shows a summary of Likes for the post. Tapping it displays the full list of Likes. [#16628] +* [*] Fix notice overlapping the ActionSheet that displays the Site Icon controls. [#16579] +* [*] Fix login error for WordPress.org sites to show inline. [#16614] +* [*] Disables the ability to open the editor for Post Pages [#16369] +* [*] Fixed an issue that could cause a crash when moderating Comments. [#16645] +* [*] Fix notice overlapping the ActionSheet that displays the QuickStart Removal. [#16609] +* [*] Site Pages: when setting a parent, placeholder text is now displayed for pages with blank titles. [#16661] +* [***] Block Editor: Audio block now available on WP.com sites on the free plan. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3523] +* [**] You can now create a Site Icon for your site using an emoji. [#16670] +* [*] Fix notice overlapping the ActionSheet that displays the More Actions in the Editor. [#16658] +* [*] The quick action buttons will be hidden when iOS is using a accessibility font sizes. [#16701] +* [*] Block Editor: Improve unsupported block message for reusable block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3621] +* [**] Block Editor: Fix incorrect block insertion point after blurring the post title field. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3640] +* [*] Fixed a crash when sharing photos to WordPress [#16737] + +17.5 +----- +* [*] Fixed a crash when rendering the Noticons font in rich notification. [#16525] +* [**] Block Editor: Audio block: Add Insert from URL functionality. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3031] +* [***] Block Editor: Slash command to insert new blocks. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3250] +* [**] Like Notifications: now displays all users who liked a post or comment. [#15662] +* [*] Fixed a bug that was causing some fonts to become enormous when large text was enabled. +* [*] Fixed scrolling and item selection in the Plugins directory. [#16087] +* [*] Improved large text support in the blog details header in My Sites. [#16521] +* [***] Block Editor: New Block: Reusable block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3490] +* [***] Block Editor: Add reusable blocks to the block inserter menu. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3054] +* [*] Fixed a bug where the web version of the editor did not load when using an account created before December 2018. [#16586] + +17.4 +----- +* [**] A new author can be chosen for Posts and Pages on multi-author sites. [#16281] +* [*] Fixed the Follow Sites Quick Start Tour so that Reader Search is highlighted. [#16391] +* [*] Enabled approving login authentication requests via push notification while the app is in the foreground. [#16075] +* [**] Added pull-to-refresh to the My Site screen when a user has no sites. [#16241] +* [***] Fixed a bug that was causing uploaded videos to not be viewable in other platforms. [#16548] + +17.3 +----- +* [**] Fix issue where deleting a post and selecting undo would sometimes convert the content to the classic editor. [#16342] +* [**] Fix issue where restoring a post left the restored post in the published list even though it has been converted to a draft. [#16358] +* [**] Fix issue where trashing a post converted it to Classic content. [#16367] +* [**] Fix issue where users could not leave the username selection screen due to styling issues. [#16380] +* [*] Comments can be filtered to show the most recent unreplied comments from other users. [#16215] +* [*] Fixed the background color of search fields. [#16365] +* [*] Fixed the navigation bar color in dark mode. [#16348] +* [*] Fix translation issues for templates fetched on the site creation design selection screen. [#16404] +* [*] Fix translation issues for templates fetched on the page creation design selection screen. [#16404] +* [*] Fix translation issue for the Choose button on the template preview in the site creation flow. [#16404] +* [***] Block Editor: New Block: Search Block [#https://github.com/wordpress-mobile/gutenberg-mobile/pull/3210] +* [**] Block Editor: The media upload options of the Image, Video and Gallery block automatically opens when the respective block is inserted. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2700] +* [**] Block Editor: The media upload options of the File and Audio block automatically opens when the respective block is inserted. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3399] +* [*] Block Editor: Remove visual feedback from non-interactive bottom-sheet cell sections [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3404] +* [*] Block Editor: Fixed an issue that was causing the featured image badge to be shown on images in an incorrect manner. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3494] + + +17.2 +----- + +* [**] Added transform block capability [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3321] +* [*] Fixed an issue where some author display names weren't visible for self-hosted sites. [#16297] +* [***] Updated custom app icons. [#16261] +* [**] Removed Site Switcher in the Editor +* [*] a11y: Bug fix: Allow stepper cell to be selected by screenreader [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3362] +* [*] Image block: Improve text entry for long alt text. [https://github.com/WordPress/gutenberg/pull/29670] +* [***] New Block: Jetpack contact info. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3340] + +17.1 +----- + +* [*] Reordered categories in page layout picker [#16156] +* [*] Added preview device mode selector in the page layout previews [#16141] +* [***] Block Editor: Improved the accessibility of range and step-type block settings. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3255] +* [**] Block Editor: Added Contact Info block to sites on WPcom or with Jetpack version >= 8.5. +* [**] We updated the app's color scheme with a brighter new blue used throughout. [#16213, #16207] +* [**] We updated the login prologue with brand new content and graphics. [#16159, #16177, #16185, #16187, #16200, #16217, #16219, #16221, #16222] +* [**] We updated the app's color scheme with a brighter new blue used throughout. [#16213, #16207] +* [**] Updated the app icon to match the new color scheme within the app. [#16220] +* [*] Fixed an issue where some webview navigation bar controls weren't visible. [#16257] + +17.0 +----- +* [internal] Updated Zendesk to latest version. Should be no functional changes. [#16051] +* [*] Reader: fixed an issue that caused unfollowing external sites to fail. [#16060] +* [*] Stats: fixed an issue where an error was displayed for Latest Post Summary if the site had no posts. [#16074] +* [*] Fixed an issue where password text on Post Settings was showing as black in dark mode. [#15768] +* [*] Added a thumbnail device mode selector in the page layout, and use a default setting based on the current device. [#16019] +* [**] Comments can now be filtered by status (All, Pending, Approved, Trashed, or Spam). [#15955, #16110] +* [*] Notifications: Enabled the new view milestone notifications [#16144] +* [***] We updated the app's design, with fresh new headers throughout and a new site switcher in My Site. [#15750] + +16.9 +----- +* [*] Adds helper UI to Choose a Domain screen to provide a hint of what a domain is. [#15962] +* [**] Site Creation: Adds filterable categories to the site design picker when creating a WordPress.com site, and includes single-page site designs [#15933] +* [**] The classic editor will no longer be available for new posts soon, but this won’t affect editing any existing posts or pages. Users should consider switching over to the Block Editor now. [#16008] +* [**] Reader: Added related posts to the bottom of reader posts +* [*] Reader: We redesigned the recommended topics section of Discover +* [*] Reader: Added a way to discover new topics from the Manage Topics view +* [*] P2 users can create and share group invite links via the Invite Person screen under the People Management feature. [#16005] +* [*] Fixed an issue that prevented searching for plugins and the Popular Plugins section from appearing: [#16070] +* [**] Stories: Fixed a video playback issue when recording on iPhone 7, 8, and SE devices. [#16109] +* [*] Stories: Fixed a video playback issue when selecting an exported Story video from a site's library. [#16109] + +16.8.1 +----- + +* [**] Stories: Fixed an issue which could remove content from a post when a new Story block was edited. [#16059] + +16.8 +----- +* [**] Prevent deleting published homepages which would have the effect of breaking a site. [#15797] +* [**] Prevent converting published homepage to a draft in the page list and settings which would have the effect of breaking a site. [#15797] +* [*] Fix app crash when device is offline and user visits Notification or Reader screens [#15916] +* [*] Under-the-hood improvements to the Reader Stream, People Management, and Sharing Buttons [#15849, #15861, #15862] +* [*] Block Editor: Fixed block mover title wording for better clarity from 'Move block position' to 'Change block position'. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3049] +* [**] Block Editor: Add support for setting Cover block focal point. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3028] +* [**] Prevent converting published homepage to a draft in the page list and editor's status settings which would have the effect of breaking a site. [#15797] +* [*] Prevent selection of unpublished homepages the homepage settings which would have the effect of breaking a site. [#15885] +* [*] Quick Start: Completing a step outside of a tour now automatically marks it as complete. [#15712] +* [internal] Site Comments: updated UI. Should be no functional changes. [#15944] +* [***] iOS 14 Widgets: new This Week Widgets to display This Week Stats in your home screen. [#15844] +* [***] Stories: There is now a new Story post type available to quickly and conveniently post images and videos to your blog. + +16.7 +----- +* [**] Site Creation: Adds the option to choose between mobile, tablet or desktop thumbnails and previews in the home page design picker when creating a WordPress.com site [https://github.com/wordpress-mobile/WordPress-iOS/pull/15688] +* [*] Block Editor: Fix issue with uploading media after exiting the editor multiple times [https://github.com/wordpress-mobile/WordPress-iOS/pull/15656]. +* [**] Site Creation: Enables dot blog subdomains for each site design. [#15736] +* [**] Reader post card and post details: added ability to mark a followed post as seen/unseen. [#15638, #15645, #15676] +* [**] Reader site filter: show unseen post count. [#15581] +* [***] Block Editor: New Block: Audio [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2854, https://github.com/wordpress-mobile/gutenberg-mobile/pull/3070] +* [**] Block Editor: Add support for setting heading anchors [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2947] +* [**] Block Editor: Disable Unsupported Block Editor for Reusable blocks [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3067] +* [**] Block Editor: Add proper handling for single use blocks such as the more block [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3042] +* [*] Reader post options: fixed an issue where the options in post details did not match those on post cards. [#15778] +* [***] iOS 14 Widgets: new All Time Widgets to display All Time Stats in your home screen. [#15771, #15794] +* [***] Jetpack: Backup and Restore is now available, depending on your sites plan you can now restore your site to a point in time, or download a backup file. [https://github.com/wordpress-mobile/WordPress-iOS/issues/15191] +* [***] Jetpack: For sites that have Jetpack Scan enabled you will now see a new section that allows you to scan your site for threats, as well as fix or ignore them. [https://github.com/wordpress-mobile/WordPress-iOS/issues/15190] +* [**] Block Editor: Make inserter long-press options "add to beginning" and "add to end" always available. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/3074] +* [*] Block Editor: Fix crash when Column block width attribute was empty. [https://github.com/WordPress/gutenberg/pull/29015] + +16.6 +----- +* [**] Activity Log: adds support for Date Range and Activity Type filters. [https://github.com/wordpress-mobile/WordPress-iOS/issues/15192] +* [*] Quick Start: Removed the Browse theme step and added guidance for reviewing pages and editing your Homepage. [#15680] +* [**] iOS 14 Widgets: new Today Widgets to display your Today Stats in your home screen. +* [*] Fixes an issue where the submit button was invisible during the domain registration flow. + +16.5 +----- + +* [*] In the Pages screen, the options to delete posts are styled to reflect that they are destructive actions, and show confirmation alerts. [#15622] +* [*] In the Comments view, overly-large twemoji are sized the same as Apple's emoji. [#15503] +* [*] Reader 'P2s': added ability to filter by site. [#15484] +* [**] Choose a Domain will now return more options in the search results, sort the results to have exact matches first, and let you know if no exact matches were found. [#15482] +* [**] Page List: Adds duplicate page functionality [#15515] +* [*] Invite People: add link to user roles definition web page. [#15530] +* [***] Block Editor: Cross-post suggestions are now available by typing the + character (or long-pressing the toolbar button labelled with an @-symbol) in a post on a P2 site [#15139] +* [***] Block Editor: Full-width and wide alignment support for Columns (https://github.com/wordpress-mobile/gutenberg-mobile/pull/2919) +* [**] Block Editor: Image block - Add link picker to the block settings and enhance link settings with auto-hide options (https://github.com/wordpress-mobile/gutenberg-mobile/pull/2841) +* [*] Block Editor: Fix button link setting, rel link will not be overwritten if modified by the user (https://github.com/wordpress-mobile/gutenberg-mobile/pull/2894) +* [**] Block Editor: Added move to top/bottom when long pressing on respective block movers (https://github.com/wordpress-mobile/gutenberg-mobile/pull/2872) +* [**] Reader: Following now only shows non-P2 sites. [#15585] +* [**] Reader site filter: selected filters now persist while in app.[#15594] +* [**] Block Editor: Fix crash in text-based blocks with custom font size [https://github.com/WordPress/gutenberg/pull/28121] + +16.4 +----- + +* [internal] Removed unused Reader files. Should be no functional changes. [#15414] +* [*] Adjusted the search box background color in dark mode on Choose a domain screen to be full width. [https://github.com/wordpress-mobile/WordPress-iOS/pull/15419] +* [**] Added shadow to thumbnail cells on Site Creation and Page Creation design pickers to add better contrast [https://github.com/wordpress-mobile/WordPress-iOS/pull/15418] +* [*] For DotCom and Jetpack sites, you can now subscribe to comments by tapping the "Follow conversation" button in the Comments view. [#15424] +* [**] Reader: Added 'P2s' stream. [#15442] +* [*] Add a new P2 default site icon to replace the generic default site icon. [#15430] +* [*] Block Editor: Fix Gallery block uploads when the editor is closed. [#15457] +* [*] Reader: Removes gray tint from site icons that contain transparency (located in Reader > Settings > Followed sites). [#15474] +* [*] Prologue: updates site address button to say "Enter your existing site address" to reduce confusion with site creation actions. [#15481] +* [**] Posts List: Adds duplicate post functionality [#15460] +* [***] Block Editor: New Block: File [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2835] +* [*] Reader: Removes gray tint from site icons that contain transparency (located in Reader > Settings > Followed sites). +* [*] Block Editor: Remove popup informing user that they will be using the block editor by default [#15492] +* [**] Fixed an issue where the Prepublishing Nudges Publish button could be cut off smaller devices [#15525] + +16.3 +----- +* [***] Login: Updated to new iOS 14 pasteboard APIs for 2FA auto-fill. Pasteboard prompts should be less intrusive now! [#15454] +* [***] Site Creation: Adds an option to pick a home page design when creating a WordPress.com site. [multiple PRs](https://github.com/search?q=repo%3Awordpress-mobile%2FWordPress-iOS+++repo%3Awordpress-mobile%2FWordPress-iOS-Shared+repo%3Awordpress-mobile%2FWordPressUI-iOS+repo%3Awordpress-mobile%2FWordPressKit-iOS+repo%3Awordpress-mobile%2FAztecEditor-iOS+is%3Apr+closed%3A%3C2020-11-17+%22Home+Page+Picker%22&type=Issues) + +* [**] Fixed a bug where @-mentions didn't work on WordPress.com sites with plugins enabled [#14844] +* [***] Site Creation: Adds an option to pick a home page design when creating a WordPress.com site. [multiple PRs](https://github.com/search?q=repo%3Awordpress-mobile%2FWordPress-iOS+++repo%3Awordpress-mobile%2FWordPress-iOS-Shared+repo%3Awordpress-mobile%2FWordPressUI-iOS+repo%3Awordpress-mobile%2FWordPressKit-iOS+repo%3Awordpress-mobile%2FAztecEditor-iOS+is%3Apr+closed%3A%3C2020-11-30+%22Home+Page+Picker%22&type=Issues) +* [*] Fixed an issue where `tel:` and `mailto:` links weren't launching actions in the webview found in Reader > post > more > Visit. [#15310] +* [*] Reader bug fix: tapping a telephone, sms or email link in a detail post in Reader will now respond with the correct action. [#15307] +* [**] Block Editor: Button block - Add link picker to the block settings [https://github.com/WordPress/gutenberg/pull/26206] +* [***] Block Editor: Adding support for selecting different unit of value in Cover and Columns blocks [https://github.com/WordPress/gutenberg/pull/26161] +* [*] Block Editor: Fix theme colors syncing with the editor [https://github.com/WordPress/gutenberg/pull/26821] +* [*] My Site > Settings > Start Over. Correcting a translation error in the detailed instructions on the Start Over view. [#15358] + +16.2 +----- +* [**] Support contact email: fixed issue that prevented non-alpha characters from being entered. [#15210] +* [*] Support contact information prompt: fixed issue that could cause the app to crash when entering email address. [#15210] +* [*] Fixed an issue where comments viewed in the Reader would always be italicized. +* [**] Jetpack Section - Added quick and easy access for all the Jetpack features (Stats, Activity Log, Jetpack and Settings) [#15287]. +* [*] Fixed a display issue with the time picker when scheduling posts on iOS 14. [#15392] + +16.1 +----- +* [***] Block Editor: Adds new option to select from a variety of predefined page templates when creating a new page for a Gutenberg site. +* [*] Fixed an issue that was causing the refresh control to show up on top of the list of sites. [https://github.com/wordpress-mobile/WordPress-iOS/pull/15136] +* [***] The "Floating Action Button" now appears on the list of posts and pages for quick and convenient creation. [https://github.com/wordpress-mobile/WordPress-iOS/pull/15149l] + +16.0 +----- +* [***] Block Editor: Full-width and wide alignment support for Video, Latest-posts, Gallery, Media & text, and Pullquote block. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2605] +* [***] Block Editor: Fix unsupported block bottom sheet is triggered when device is rotated. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2710] +* [***] Block Editor: Unsupported Block Editor: Fixed issue when cannot view or interact with the classic block on Jetpack site. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2709] +* [**] Reader: Select interests is now displayed under the Discover tab. [#15097] +* [**] Reader: The reader now displays site recommendations in the Discover feed [#15116] +* [***] Reader: The new redesigned Reader detail shows your post as beautiful as ever. And if you add a featured image it would be twice as beautiful! [#15107] + +15.9 +----- +* [*] Fixed issue that caused duplicate views to be displayed when requesting a login link. [#14975] +* [internal] Modified feature flags that show unified Site Address, Google, Apple, WordPress views and iCloud keychain login. Could cause regressions. [#14954, #14969, #14970, #14971, #14972] +* [*] Fixed an issue that caused page editor to become an invisible overlay. [#15012] +* [**] Block Editor: Increase tap-target of primary action on unsupported blocks. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2608] +* [***] Block Editor: On Jetpack connected sites, Unsupported Block Editor can be enabled via enabling Jetpack SSO setting directly from within the missing block alert. [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2610] +* [***] Block Editor: Add support for selecting user's post when configuring the link [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2484] +* [*] Reader: Fixed an issue that resulted in no action when tapping a link with an anchor. [#15027] +* [***] Block Editor: Unsupported Block Editor: Fixed issue when cannot view or interact with the classic block on Jetpack sites [https://github.com/wordpress-mobile/gutenberg-mobile/issues/2695] + +15.8 +----- +* [*] Image Preview: Fixes an issue where an image would be incorrectly positioned after changing device orientation. +* [***] Block Editor: Full-width and wide alignment support for Group, Cover and Image block [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2559] +* [**] Block Editor: Add support for rounded style in Image block [https://github.com/wordpress-mobile/gutenberg-mobile/pull/2591] +* [*] Fixed an issue where the username didn't display on the Signup Epilogue after signing up with Apple and hiding the email address. [#14882] +* [*] Login: display correct error message when the max number of failed login attempts is reached. [#14914] +* [**] Block Editor: Fixed a case where adding a block made the toolbar jump [https://github.com/WordPress/gutenberg/pull/24573] + +15.7 +----- +* [**] Updated UI when connecting a self-hosted site from Login Epilogue, My Sites, and Post Signup Interstitial. (#14742) +* [**] You can now follow conversations for P2 sites +* [**] Block Editor: Block settings now immediately reflect changes from menu sliders. +* [**] Simplified authentication and updated UI.(#14845, #14831, #14825, #14817). + Now when an email address is entered, the app automatically determines the next step and directs the user accordingly. (i.e. signup or login with the appropriate login view). +* [**] Added iCloud Keychain login functionality. (#14770) +* [***] Reader: We’re introducing a new Reader experience that allows users to tailor their Discover feed to their chosen interests. +* [*] Media editing: Reduced memory usage when marking up an image, which could cause a crash. +* [**] Block Editor: Fixed Dark Mode transition for editor menus. + +15.6 +----- +* [***] Block Editor: Fixed empty text fields on RTL layout. Now they are selectable and placeholders are visible. +* [**] Block Editor: Add settings to allow changing column widths +* [**] Block Editor: Media editing support in Gallery block. +* [**] Updated UI when logging in with a Site Address. +* [**] Updated UI when logging in/signing up with Apple. +* [**] Updated UI when logging in/signing up with Google. +* [**] Simplified Google authentication. If signup is attempted with an existing WordPress account, automatically redirects to login. If login is attempted without a matching WordPress account, automatically redirects to signup. +* [**] Fixes issue where the stats were not updating when switching between sites in My Sites. +* [*] Block Editor: Improved logic for creating undo levels. +* [*] Social account login: Fixed an issue that could have inadvertently linked two social accounts. + +15.5 +----- +* [*] Reader: revamped UI for your site header. +* [***] Block Editor: New feature for WordPress.com and Jetpack sites: auto-complete username mentions. An auto-complete popup will show up when the user types the @ character in the block editor. +* [*] Block Editor: Media editing support in Cover block. +* [*] Block Editor: Fixed a bug on the Heading block, where a heading with a link and string formatting showed a white shadow in dark mode. + +15.4 +----- + * [**] Fixes issue where the new page editor wouldn't always show when selected from the "My Site" page on iOS versions 12.4 and below. + * [***] Block Editor: Media editing support in Media & Text block. + * [***] Block Editor: New block: Social Icons + * [*] Block Editor: Cover block placeholder is updated to allow users to start the block with a background color + * [**] Improved support for the Classic block to give folks a smooth transition from the classic editor to the block editor + +15.3 +----- +* [***] Block Editor: Adds Copy, Cut, Paste, and Duplicate functionality to blocks +* [***] Block Editor: Users can now individually edit unsupported blocks found in posts or pages. Not available on selfhosted sites or sites defaulting to classic editor. +* [*] Block Editor: Improved editor loading experience with Ghost Effect. + +15.2 +---- +* [*] Block editor: Display content metrics information (blocks, words, characters count). +* [*] Fixed a crash that results in navigating to the block editor quickly after logging out and immediately back in. +* [***] Reader content improved: a lot of fixes in how the content appears when you're reading a post. +* [**] A site's title can now be changed by tapping on the title in the site detail screen. +* [**] Added a new Quick Start task to set a title for a new site. +* [**] Block editor: Add support for customizing gradient type and angle in Buttons and Cover blocks. + +----- + +15.1 +----- +* [**] Block Editor: Add support to upload videos to Cover Blocks after the editor has closed. +* [*] Block Editor: Display the animation of animated GIFs while editing image blocks. +* [**] Block editor: Adds support for theme colors and gradients. +* [*] App Settings: Added an app-level toggle for light or dark appearance. +* [*] Fix a bug where the Latest Post date on Insights Stats was being calculated incorrectly. +* Block editor: [*] Support for breaking out of captions/citation authors by pressing enter on the following blocks: image, video, gallery, quote, and pullquote. +* Block editor: [**] Adds editor support for theme defined colors and theme defined gradients on cover and button blocks. +* [*] Fixed a bug where "Follow another site" was using the wrong steps in the "Grow Your Audience" Quick Start tour. +* [*] Fix a bug where Quick Start completed tasks were not communicated to VoiceOver users. +* [**] Quick Start: added VoiceOver support to the Next Steps section. +* [*] Fixed a bug where the "Publish a post" Quick Start tour didn't reflect the app's new information architecture +* [***] Free GIFs can now be added to the media library, posts, and pages. +* [**] You can now set pages as your site's homepage or posts page directly from the Pages list. +* [**] Fixed a bug that prevented some logins via 'Continue with Apple'. +* [**] Reader: Fixed a bug where tapping on the more menu may not present the menu +* [*] Block editor: Fix 'Take a Photo' option failing after adding an image to gallery block + +15.0 +----- +* [**] Block editor: Fix media upload progress when there's no connection. +* [*] Fix a bug where taking a photo for your user gravatar got you blocked in the crop screen. +* Reader: Updated card design +* [internal] Logging in via 'Continue with Google' has changes that can cause regressions. See https://git.io/Jf2LF for full testing details. +* [***] Block Editor: New block: Verse +* [***] Block Editor: Trash icon that is used to remove blocks is moved to the new menu reachable via ellipsis button in the block toolbar +* [**] Block Editor: Add support for changing overlay color settings in Cover block +* [**] Block Editor: Add enter/exit animation in FloatingToolbar +* [**] Block Editor: Block toolbar can now collapse when the block width is smaller than the toolbar content +* [**] Block Editor: Tooltip for page template selection buttons +* [*] Block Editor: Fix merging of text blocks when text had active formatting (bold, italic, strike, link) +* [*] Block Editor: Fix button alignment in page templates and make strings consistent +* [*] Block Editor: Add support for displaying radial gradients in Buttons and Cover blocks +* [*] Block Editor: Fix a bug where it was not possible to add a second image after previewing a post +* [internal] Signing up via 'Continue with Google' has changes that can cause regressions. See https://git.io/JfwjX for full testing details. +* My Site: Add support for setting the Homepage and Posts Page for a site. + +14.9 +----- +* Streamlined navigation: now there are fewer and better organized tabs, posting shortcuts and more, so you can find what you need fast. +* My Site: the "Add Posts and Pages" features has been moved. There is a new "Floating Action Button" in "My Site" that lets you create a new post or page without having to navigate to another screen. +* My Site: the "Me" section has been moved. There is a new button on the top right of "My Site" that lets you access the "Me" section from there. +* Reader: revamped UI with a tab bar that lets you quickly switch between sections, and filtering and settings panes to easily access and manage your favorite content. +* [internal] the "Change Username" on the Signup Epilogue screen has navigation changes that can cause regressions. See https://git.io/JfGnv for testing details. +* [internal] the "3 button view" (WP.com email, Google, SIWA, Site Address) presented after pressing the "Log In" button has navigation changes that can cause regressions. See https://git.io/JfZUV for testing details. +* [**] Support the superscript and subscript HTML formatting on the Block Editor and Classic Editor. +* [**] Block editor: Support for the pullquote block. +* [**] Block editor: Fix the icons and buttons in Gallery, Paragraph, List and MediaText block on RTL mode. +* [**] Block editor: Update page templates to use new blocks. +* [**] Block editor: Fix a crash when uploading new videos on a video block. +* [**] Block Editor: Add support for changing background and text color in Buttons block +* [internal] the "enter your password" screen has navigation changes that can cause regressions. See https://git.io/Jfl1C for full testing details. +* Support the superscript and subscript HTML formatting on the Block Editor and Classic Editor. +* [***] You can now draw on images to annotate them using the Edit image feature in the post editor. +* [*] Fixed a bug on the editors where changing a featured image didn't trigger that the post/page changed. + +14.8.1 +----- +* Fix adding and removing of featured images to posts. + +14.8 +----- +* Block editor: Prefill caption for image blocks when available on the Media library +* Block editor: New block: Buttons. From now you’ll be able to add the individual Button block only inside the Buttons block +* Block editor: Fix bug where whitespaces at start of text blocks were being removed +* Block editor: Add support for upload options in Cover block +* Block editor: Floating toolbar, previously located above nested blocks, is now placed at the bottom of the screen +* Block editor: Fix the icons in FloatingToolbar on RTL mode +* Block editor: Fix Quote block so it visually reflects selected alignment +* Block editor: Fix bug where buttons in page templates were not rendering correctly on web +* Block editor: Remove Subscription Button from the Blog template since it didn't have an initial functionality and it is hard to configure for users. +* [internal] the "send magic link" screen has navigation changes that can cause regressions. See https://git.io/Jfqiz for testing details. +* Updated UI for Login and Signup epilogues. +* Fixes delayed split view resizing while rotating your device. + +14.7 +----- +* Classic Editor: Fixed action sheet position for additional Media sources picker on iPad +* [internal] the signup flow using email has code changes that can cause regressions. See https://git.io/JvALZ for testing details. +* [internal] Notifications tab should pop to the root of the navigation stack when tapping on the tab from within a notification detail screen. See https://git.io/Jvxka for testing details. +* Classic and Block editor: Prefill caption for image blocks when available on the Media library. +* [internal] the "login by email" flow and the self-hosted login flow have code changes that can cause regressions. See https://git.io/JfeFN for testing details. +* Block editor: Disable ripple effect in all BottomSheet's controls. +* Block editor: New block: Columns +* Block editor: New starter page template: Blog +* Block editor: Make Starter Page Template picker buttons visible only when the screen height is enough +* Block editor: Fix a bug which caused to show URL settings modal randomly when changing the device orientation multiple times during the time Starter Page Template Preview is open +* [internal] the login by email flow and the self-hosted login flow have code changes that can cause regressions. See https://git.io/JfeFN for testing details. +* Updated the appearance of the login and signup buttons to make signup more prominent. +* [internal] the navigation to the "login by site address" flow has code changes that can cause regressions. See https://git.io/JfvP9 for testing details. +* Updated site details screen title to My Site, to avoid duplicating the title of the current site which is displayed in the screen's header area. +* You can now schedule your post, add tags or change the visibility before hitting "Publish Now" — and you don't have to go to the Post Settings for this! + +* Login Epilogue: fixed issue where account information never stopped loading for some self-hosted sites. +* Updated site details screen title to My Site, to avoid duplicating the title of the current site which is displayed in the screen's header area. + +14.6 +----- +* [internal] the login flow with 2-factor authentication enabled has code changes that can cause regressions. See https://git.io/Jvdil for testing details. +* [internal] the login and signup Magic Link flows have code changes that could cause regressions. See https://git.io/JvSD6 and https://git.io/Jvy4P for testing details. +* [internal] the login and signup Magic Link flows have code changes that can cause regressions. See https://git.io/Jvy4P for testing details. +* [internal] the login and signup Continue with Google flows have code changes that can cause regressions. See https://git.io/JvypB for testing details. +* Notifications: Fix layout on screens with a notch. +* Post Commenting: fixed issue that prevented selecting an @ mention suggestion. +* Fixed an issue that could have caused the app to crash when accessing Site Pages. +* Site Creation: faster site creation, removed intermediate steps. Just select what kind of site you'd like, enter the domain name and the site will be created. +* Post Preview: Increase Post and Page Preview size on iPads running iOS 13. +* Block editor: Added the Cover block +* Block editor: Removed the dimming effect on unselected blocks +* Block editor: Add alignment options for Heading block +* Block editor: Implemented dropdown toolbar for alignment toolbar in Heading, Paragraph, Image, MediaText blocks +* Block Editor: When editing link settings, tapping the keyboard return button now closes the settings panel as well as closing the keyboard. +* Fixed a crash when a blog's URL became `nil` from a Core Data operation. +* Added Share action to the more menu in the Posts list +* Period Stats: fix colors when switching between light and dark modes. +* Media uploads from "Other Apps": Fixed an issue where the Cancel button on the document picker/browser was not showing up in Light Mode. +* Fix a crash when accessing Blog Posts from the Quick Actions button on iPads running iOS 12 and below. +* Reader post detail: fix colors when switching between light and dark modes. +* Fixed an issue where Continue with Apple button wouldn't respond after Jetpack Setup > Sign up flow completed. + + +14.5 +----- +* Block editor: New block: Latest Posts +* Block editor: Fix Quote block's left border not being visible in Dark Mode +* Block editor: Added Starter Page Templates: when you create a new page, we now show you a few templates to get started more quickly. +* Block editor: Fix crash when pasting HTML content with embeded images on paragraphs +* Post Settings: Fix issue where the status of a post showed "Scheduled" instead of "Published" after scheduling before the current date. +* Stats: Fix background color in Dark Mode on wider screen sizes. +* Post Settings: Fix issue where the calendar selection may not match the selected date when site timezone differs from device timezone. +* Dark Mode fixes: + - Border color on Search bars. + - Stats background color on wider screen sizes. + - Media Picker action bar background color. + - Login and Signup button colors. + - Reader comments colors. + - Jetpack install flow colors. +* Reader: Fix toolbar and search bar width on wider screen sizes. +* Updated the Signup and Login Magic Link confirmation screen advising the user to check their spam/junk folder. +* Updated appearance of Google login/signup button. +* Updated appearance of Apple login/signup button. + +14.4.1 +----- +* Block Editor: Fix crash when inserting a Button Block. + 14.4 ----- * Post Settings: Fixes the displayed publish date of posts which are to be immediately published. - + 14.3 ----- * Aztec and Block Editor: Fix the presentation of ordered lists with large numbers. @@ -18,8 +959,8 @@ * Block editor: Add support for upload options in Gallery block * Aztec and Block Editor: Fix the presentation of ordered lists with large numbers. * Added Quick Action buttons on the Site Details page to access the most frequently used parts of a site. -* Post Settings: Adjusts the weekday symbols in the calendar depending on Regional settings. - +* Post Settings: Adjusts the weekday symbols in the calendar depending on Regional settings. + 14.2 ----- diff --git a/Rakefile b/Rakefile index 967b4a881ffc..9986fb976ae6 100644 --- a/Rakefile +++ b/Rakefile @@ -1,222 +1,249 @@ -SWIFTLINT_VERSION="0.27.0" -XCODE_WORKSPACE="WordPress.xcworkspace" -XCODE_SCHEME="WordPress" -XCODE_CONFIGURATION="Debug" +# frozen_string_literal: true +require 'English' require 'fileutils' require 'tmpdir' require 'rake/clean' require 'yaml' require 'digest' -PROJECT_DIR = File.expand_path(File.dirname(__FILE__)) + +RUBY_REPO_VERSION = File.read('./.ruby-version').rstrip +XCODE_WORKSPACE = 'WordPress.xcworkspace' +XCODE_SCHEME = 'WordPress' +XCODE_CONFIGURATION = 'Debug' +EXPECTED_XCODE_VERSION = File.read('.xcode-version').rstrip + +PROJECT_DIR = __dir__ +abort('Project directory contains one or more spaces – unable to continue.') if PROJECT_DIR.include?(' ') + +SWIFTLINT_BIN = File.join(PROJECT_DIR, 'Pods', 'SwiftLint', 'swiftlint') task default: %w[test] -desc "Install required dependencies" -task :dependencies => %w[dependencies:check assets:check] +desc 'Install required dependencies' +task dependencies: %w[dependencies:check assets:check] namespace :dependencies do - task :check => %w[bundler:check bundle:check credentials:apply pod:check lint:check] + task check: %w[ruby:check bundler:check bundle:check credentials:apply pod:check lint:check] - namespace :bundler do + namespace :ruby do task :check do - unless command?("bundler") - Rake::Task["dependencies:bundler:install"].invoke + unless ruby_version_is_match? + # show a warning that Ruby doesn't match .ruby-version + puts '=====================================================================================' + puts 'Warning: Your local Ruby version doesn\'t match .ruby-version' + puts '' + puts ".ruby-version:\t#{RUBY_REPO_VERSION}" + puts "Your Ruby:\t#{RUBY_VERSION}" + puts '' + puts 'Refer to the WPiOS docs on setting the exact version with rbenv.' + puts '' + puts 'Press enter to continue anyway' + puts '=====================================================================================' + $stdin.gets.strip end end + # compare repo Ruby version to local + def ruby_version_is_match? + RUBY_REPO_VERSION == RUBY_VERSION + end + end + + namespace :bundler do + task :check do + Rake::Task['dependencies:bundler:install'].invoke unless command?('bundler') + end + task :install do - puts "Bundler not found in PATH, installing to vendor" + puts 'Bundler not found in PATH, installing to vendor' ENV['GEM_HOME'] = File.join(PROJECT_DIR, 'vendor', 'gems') - ENV['PATH'] = File.join(PROJECT_DIR, 'vendor', 'gems', 'bin') + ":#{ENV['PATH']}" - sh "gem install bundler" unless command?("bundler") + ENV['PATH'] = File.join(PROJECT_DIR, 'vendor', 'gems', 'bin') + ":#{ENV.fetch('PATH', nil)}" + sh 'gem install bundler' unless command?('bundler') end - CLOBBER << "vendor/gems" + CLOBBER << 'vendor/gems' end namespace :bundle do task :check do - sh "bundle check --path=${BUNDLE_PATH:-vendor/bundle} > /dev/null", verbose: false do |ok, res| + sh 'bundle check > /dev/null', verbose: false do |ok, _res| next if ok + # bundle check exits with a non zero code if install is needed - dependency_failed("Bundler") - Rake::Task["dependencies:bundle:install"].invoke + dependency_failed('Bundler') + Rake::Task['dependencies:bundle:install'].invoke end end task :install do - fold("install.bundler") do - sh "bundle install --jobs=3 --retry=3 --path=${BUNDLE_PATH:-vendor/bundle}" + fold('install.bundler') do + sh 'bundle install --jobs=3 --retry=3 --path=${BUNDLE_PATH:-vendor/bundle}' end end - CLOBBER << "vendor/bundle" - CLOBBER << ".bundle" + CLOBBER << 'vendor/bundle' + CLOBBER << '.bundle' end namespace :credentials do task :apply do next unless Dir.exist?(File.join(Dir.home, '.mobile-secrets/.git')) || ENV.key?('CONFIGURE_ENCRYPTION_KEY') - sh('FASTLANE_SKIP_UPDATE_CHECK=1 FASTLANE_ENV_PRINTER=1 bundle exec fastlane run configure_apply force:true') + + # The string is indented all the way to the left to avoid padding when printed in the terminal + command = %( +FASTLANE_SKIP_UPDATE_CHECK=1 \ +FASTLANE_HIDE_CHANGELOG=1 \ +FASTLANE_HIDE_PLUGINS_TABLE=1 \ +FASTLANE_ENV_PRINTER=1 \ +FASTLANE_SKIP_ACTION_SUMMARY=1 \ +FASTLANE_HIDE_TIMESTAMP=1 \ +bundle exec fastlane run configure_apply force:true + ) + + sh(command) end end namespace :pod do task :check do unless podfile_locked? && lockfiles_match? - dependency_failed("CocoaPods") - Rake::Task["dependencies:pod:install"].invoke + dependency_failed('CocoaPods') + Rake::Task['dependencies:pod:install'].invoke end end task :install do - fold("install.cocoapds") do + fold('install.cocoapds') do + pod %w[install] + rescue StandardError + puts "Attempting to fix Gutenberg-Mobile local podspecs failing to install — since that is one of the most common reason for `pod install` to fail — then retrying…\n\n" + Rake::Task['dependencies:pod:fix_gbm_pods'].invoke pod %w[install] end end + task :fix_gbm_pods do + require 'yaml' + + deps = YAML.load_file('Podfile.lock')['DEPENDENCIES'] + gbm_pod_regex = %r{(.*) \(from `https://raw\.githubusercontent\.com/wordpress-mobile/gutenberg-mobile/.*/third-party-podspecs/.*\.podspec\.json`\)}.freeze + gbm_pods = deps.map do |pod| + gbm_pod_regex.match(pod)&.captures&.first + end.compact + + pod ['update', *gbm_pods] + end + task :clean do - fold("clean.cocoapds") do + fold('clean.cocoapds') do FileUtils.rm_rf('Pods') end end - CLOBBER << "Pods" + CLOBBER << 'Pods' end namespace :lint do - task :check do if swiftlint_needs_install - dependency_failed("SwiftLint") - Rake::Task["dependencies:lint:install"].invoke + dependency_failed('SwiftLint') + Rake::Task['dependencies:pod:install'].invoke end end - - task :install do - fold("install.swiftlint") do - puts "Installing SwiftLint #{SWIFTLINT_VERSION} into #{swiftlint_path}" - Dir.mktmpdir do |tmpdir| - # Try first using a binary release - zipfile = "#{tmpdir}/swiftlint-#{SWIFTLINT_VERSION}.zip" - sh "curl --fail --location -o #{zipfile} https://github.com/realm/SwiftLint/releases/download/#{SWIFTLINT_VERSION}/portable_swiftlint.zip || true" - if File.exists?(zipfile) - extracted_dir = "#{tmpdir}/swiftlint-#{SWIFTLINT_VERSION}" - sh "unzip #{zipfile} -d #{extracted_dir}" - FileUtils.mkdir_p("#{swiftlint_path}/bin") - FileUtils.cp("#{extracted_dir}/swiftlint", "#{swiftlint_path}/bin/swiftlint") - else - sh "git clone --quiet https://github.com/realm/SwiftLint.git #{tmpdir}" - Dir.chdir(tmpdir) do - sh "git checkout --quiet #{SWIFTLINT_VERSION}" - sh "git submodule --quiet update --init --recursive" - FileUtils.remove_entry_secure(swiftlint_path) if Dir.exist?(swiftlint_path) - FileUtils.mkdir_p(swiftlint_path) - sh "make prefix_install PREFIX='#{swiftlint_path}'" - end - end - end - end - end - CLOBBER << "vendor/swiftlint" end - end namespace :assets do task :check do next unless Dir['WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/*.png'].empty? + Dir.mktmpdir do |tmpdir| - puts "Generate internal icon set" - if system("export PROJECT_DIR=#{Dir.pwd}/WordPress && export TEMP_DIR=#{tmpdir} && ./Scripts/BuildPhases/AddVersionToIcons.sh >/dev/null 2>&1") != 0 + puts 'Generate internal icon set' + if system("export PROJECT_DIR=#{Dir.pwd}/WordPress && export TEMP_DIR=#{tmpdir} && ./Scripts/BuildPhases/AddVersionToIcons.sh >/dev/null 2>&1") != 0 system("cp #{Dir.pwd}/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/*.png #{Dir.pwd}/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/") end - end + end end -end +end -CLOBBER << "vendor" +CLOBBER << 'vendor' -desc "Mocks" +desc 'Mocks' task :mocks do - wordpress_mocks_path = "./Pods/WordPressMocks" - # If WordPressMocks is referenced by a local path, use that. - unless lockfile_hash.dig("EXTERNAL SOURCES", "WordPressMocks", :path).nil? - wordpress_mocks_path = lockfile_hash.dig("EXTERNAL SOURCES", "WordPressMocks", :path) - end - - sh "#{wordpress_mocks_path}/scripts/start.sh 8282" + sh "#{File.join(PROJECT_DIR, 'API-Mocks', 'scripts', 'start.sh')} 8282" end desc "Build #{XCODE_SCHEME}" -task :build => [:dependencies] do +task build: [:dependencies] do xcodebuild(:build) end desc "Profile build #{XCODE_SCHEME}" -task :buildprofile => [:dependencies] do - ENV["verbose"] = "1" +task buildprofile: [:dependencies] do + ENV['verbose'] = '1' xcodebuild(:build, "OTHER_SWIFT_FLAGS='-Xfrontend -debug-time-compilation -Xfrontend -debug-time-expression-type-checking'") end -task :timed_build => [:clean] do +task timed_build: [:clean] do require 'benchmark' time = Benchmark.measure do - Rake::Task["build"].invoke + Rake::Task['build'].invoke end puts "CPU Time: #{time.total}" puts "Wall Time: #{time.real}" end -desc "Run test suite" -task :test => [:dependencies] do +desc 'Run test suite' +task test: [:dependencies] do xcodebuild(:build, :test) end -desc "Remove any temporary products" +desc 'Remove any temporary products' task :clean do xcodebuild(:clean) end -desc "Checks the source for style errors" -task :lint => %w[dependencies:lint:check] do +desc 'Checks the source for style errors' +task lint: %w[dependencies:lint:check] do swiftlint %w[lint --quiet] end namespace :lint do - desc "Automatically corrects style errors where possible" - task :autocorrect => %w[dependencies:lint:check] do - swiftlint %w[autocorrect] + desc 'Automatically corrects style errors where possible' + task autocorrect: %w[dependencies:lint:check] do + swiftlint %w[lint --autocorrect --quiet] end end namespace :git do hooks = %w[pre-commit post-checkout post-merge] - desc "Install git hooks" + desc 'Install git hooks' task :install_hooks do hooks.each do |hook| target = hook_target(hook) source = hook_source(hook) backup = hook_backup(hook) - next if File.symlink?(target) and File.readlink(target) == source - next if File.file?(target) and File.identical?(target, source) + next if File.symlink?(target) && (File.readlink(target) == source) + next if File.file?(target) && File.identical?(target, source) + if File.exist?(target) puts "Existing hook for #{hook}. Creating backup at #{target} -> #{backup}" - FileUtils.mv(target, backup, :force => true) + FileUtils.mv(target, backup, force: true) end FileUtils.ln_s(source, target) puts "Installed #{hook} hook" end end - desc "Uninstall git hooks" + desc 'Uninstall git hooks' task :uninstall_hooks do hooks.each do |hook| target = hook_target(hook) source = hook_source(hook) backup = hook_backup(hook) - next unless File.symlink?(target) and File.readlink(target) == source + next unless File.symlink?(target) && (File.readlink(target) == source) + puts "Removing hook for #{hook}" File.unlink(target) if File.exist?(backup) @@ -227,11 +254,12 @@ namespace :git do end def hook_target(hook) - ".git/hooks/#{hook}" + hooks_dir = `git rev-parse --git-path hooks`.chomp + File.join(hooks_dir, hook) end def hook_source(hook) - "../../Scripts/hooks/#{hook}" + File.absolute_path(File.join(PROJECT_DIR, 'Scripts', 'hooks', hook)) end def hook_backup(hook) @@ -240,12 +268,10 @@ namespace :git do end namespace :git do - task :pre_commit => %[dependencies:lint:check] do - begin - swiftlint %w[lint --quiet --strict] - rescue - exit $?.exitstatus - end + task pre_commit: %(dependencies:lint:check) do + swiftlint %w[lint --quiet --strict] + rescue StandardError + exit $CHILD_STATUS.exitstatus end task :post_merge do @@ -257,19 +283,377 @@ namespace :git do end end -desc "Open the project in Xcode" -task :xcode => [:dependencies] do +desc 'Open the project in Xcode' +task xcode: [:dependencies] do sh "open #{XCODE_WORKSPACE}" end -def fold(label, &block) - puts "travis_fold:start:#{label}" if is_travis? - yield - puts "travis_fold:end:#{label}" if is_travis? +desc 'Install and configure WordPress iOS and its dependencies - External Contributors' +namespace :init do + task oss: %w[ + install:xcode:check + dependencies + install:tools:check_oss + install:lint:check + credentials:setup + ] + + desc 'Install and configure WordPress iOS and its dependencies - Automattic Developers' + task developer: %w[ + install:xcode:check + dependencies + install:tools:check_developer + install:lint:check + credentials:setup + gpg_key:setup + ] +end + +namespace :install do + namespace :xcode do + task check: %w[xcode_app:check xcode_select:check] + + # xcode_app namespace checks for the existance of xcode on developer's machine, + # checks to make sure that developer is using the correct version per the CI specs + # and confirms developer has xcode-select command line tools, if not installs them + namespace :xcode_app do + # check the existance of xcode, and compare version to CI specs + task :check do + puts 'Checking for system for Xcode' + if xcode_installed? + puts 'Xcode installed' + else + # if xcode is not installed, prompt user to install and terminate rake + puts 'Xcode not Found!' + puts '' + puts '=====================================================================================' + puts 'Developing for WordPressiOS requires Xcode.' + puts 'Please install Xcode before setting up WordPressiOS' + puts 'https://apps.apple.com/app/xcode/id497799835?mt=12' + abort('') + end + + puts 'Checking CI recommended installed Xcode version' + + unless xcode_version_is_correct? + # if xcode is the wrong version, prompt user to install the correct version and terminate rake + puts 'Not recommended version of Xcode installed' + puts "It is recommended to use Xcode version #{EXPECTED_XCODE_VERSION}" + puts 'Please press enter to continue' + $stdin.gets.strip + next + end + end + + # Check if Xcode is installed + def xcode_installed? + system 'xcodebuild -version', %i[out err] => File::NULL + end + + # compare xcode version to expected CI spec version + def xcode_version_is_correct? + if xcode_version == EXPECTED_XCODE_VERSION + puts 'Correct version of Xcode installed' + true + else + false + end + end + + def xcode_version + puts 'Checking installed version of Xcode' + version = `xcodebuild -version` + + version.split[1] + end + end + + # Xcode-select command line tools must be installed to update dependencies + # Xcode_select checks the existence of xcode-select on developer's machine, installs if not found + namespace :xcode_select do + task :check do + puts 'Checking system for Xcode-select' + if command?('xcode-select') + puts 'Xcode-select installed' + else + Rake::Task['install:xcode:xcode_select:install'].invoke + end + end + + task :install do + puts 'Installing xcode select' + sh 'xcode-select --install' + end + end + end + + # Tools namespace deals with installing developer and OSS tools required to work on WPiOS + namespace :tools do + task check_oss: %w[homebrew:check addons:check_oss] + task check_developer: %w[homebrew:check addons:check_developer] + + # Check for Homebrew and install if missing + namespace :homebrew do + task :check do + puts 'Checking system for Homebrew' + if command?('brew') + puts 'Homebrew installed' + else + Rake::Task['install:tools:homebrew:prompt'].invoke + end + end + + # prompt developer that Homebrew is required to install required tools and confirm they want to install + # allow to bail out of install script if they developer declines to install homebrew + task :prompt do + puts '=====================================================================================' + puts 'Setting WordPress iOS requires installing Homebrew to manage installing some tools' + puts 'For more information on Homebrew check out https://brew.sh/' + puts 'Do you want to continue with the WordPress iOS setup and install Homebrew?' + puts "Press 'Y' to install Homebrew. Press 'N' for exit" + puts '=====================================================================================' + + if display_prompt_response == true + Rake::Task['install:tools:homebrew:install'].invoke + else + abort('') + end + end + + task :install do + command = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"' + sh command + end + end + + # Install required tools to work with WPiOS + namespace :addons do + # NOTE: hash key = default installed directory on device + # hash value = brew install location + oss_tools = { 'convert' => 'imagemagick', + 'gs' => 'ghostscript' } + developer_tools = { 'convert' => 'imagemagick', + 'gs' => 'ghostscript', + 'sentry-cli' => 'getsentry/tools/sentry-cli', + 'gpg' => 'gpg', + 'git-crypt' => 'git-crypt' } + + # Check for tool, install if not installed + task :check_oss do + tool_check(oss_tools) + end + + task :check_developer do + tool_check(developer_tools) + end + + # check if the developer tool is present in the machine, if not install + def tool_check(hash) + hash.each do |key, value| + puts "Checking system for #{key}" + if command?(key) + puts "#{key} found" + else + tool_install(value) + end + end + end + + # install selected developer tool + def tool_install(tool) + puts "#{tool} not found. Installing #{tool}" + sh "brew install #{tool}" + end + end + end + + namespace :lint do + task :check do + unless git_initialized? + puts 'Initializing git repository' + sh 'git init', verbose: false + end + + Rake::Task['git:install_hooks'].invoke + end + + def git_initialized? + sh 'git rev-parse --is-inside-work-tree > /dev/null 2>&1', verbose: false + end + end +end + +# Credentials deals with the setting up the developer's WPCOM API app ID and app Secret +namespace :credentials do + task setup: %w[credentials:prompt credentials:set_app_secrets] + + task :prompt do + puts '' + puts '=====================================================================================' + puts 'To be able to log into the WordPress app while developing you will need to setup API credentials' + puts 'To do this follow these steps' + puts '' + puts '' + puts '' + puts '=====================================================================================' + + puts "1. Go to https://wordpress.com/start/user and create a WordPress.com account (if you don't already have one)." + prompt_for_continue('Once you have created your account,') + + puts '=====================================================================================' + puts '2. Now register an API application at https://developer.wordpress.com/apps/.' + prompt_for_continue('Once you have registered your API App,') + + puts '=====================================================================================' + puts '3. Make sure to set "Redirect URLs"= https://localhost and "Type" = Native and click "Create" then "Update".' + prompt_for_continue('Once you have set the redirect url and type,') + + puts '=====================================================================================' + prompt_for_continue('Lastly, keep your Client ID and App Secret on hand for the next steps,') + end + + def prompt_for_continue(prompt) + puts "#{prompt} Please press enter to continue" + $stdin.gets.strip + end + + # user given app id and secret and create a new wpcom_app_credentials file + task :set_app_secrets do + set_app_secrets(client_id, client_secret) + end + + def client_id + $stdout.puts 'Please enter your Client ID' + $stdin.gets.strip + end + + def client_secret + $stdout.puts 'Please enter your Client Secret' + $stdin.gets.strip + end + + # Duplicate the example file and add the new app secret and app id + def set_app_secrets(id, secret) + puts 'Writing App ID and App Secret to secrets file' + + replaced_text = File.read('WordPress/Credentials/Secrets-example.swift') + .gsub('let client = "0"', "let client=\"#{id}\"") + .gsub('let secret = "your-secret-here"', "let secret=\"#{secret}\"") + + File.open('WordPress/Credentials/Secrets.swift', 'w') do |file| + file.puts replaced_text + end + end +end + +namespace :gpg_key do + # automate the process of creatong a GPG key + task setup: %w[gpg_key:check gpg_key:prompt gpg_key:finish] + + # confirm that GPG tools is installed + task :check do + puts 'Checking system for GPG Tools' + if command?('gpg') + puts 'GPG Tools found' + else + Rake::Task['gpg_key:install'].invoke + end + end + + # install GPG Tools + task :install do + puts 'GPG Tools not found. Installing GPG Tools' + sh 'brew install gpg' + end + + # Ask developer if they need to create a new key. + # If yes, begin process of creating key, if no move on + task :prompt do + next unless create_gpg_key? + + if create_default_key? + display_default_config_helpers + Rake::Task['gpg_key:generate_default'].invoke + else + Rake::Task['gpg_key:generate_custom'].invoke + end + end + + # Generate new GPG key + task :generate_custom do + puts '' + puts 'Begin Generating Custom GPG Keys' + puts '=====================================================================================' + + sh 'gpg --full-generate-key', verbose: false + end + + # Generate new default GPG key + task :generate_default do + puts '' + puts 'Begin Generating Default GPG Keys' + puts '=====================================================================================' + + sh 'gpg --generate-key', verbose: false + end + + # prompt developer to send GPG key to Platform + task :finish do + puts '=====================================================================================' + puts 'Key Generation Complete!' + puts 'Please send your GPG public key to Platform 9-3/4' + puts 'You can contact them in the Slack channel #platform9' + puts '=====================================================================================' + end + + # ask user if they want to create a key, loop till given a valid answer + def create_gpg_key? + puts '=====================================================================================' + puts 'To access production credentials for the WordPress app you will need to a GPG Key' + puts 'Do you need to generate a new GPG Key?' + puts "Press 'Y' to create a new key. Press 'N' to skip" + + display_prompt_response + end + + # ask user if they want to create a key, loop till given a valid answer + def create_default_key? + puts '=====================================================================================' + puts 'You can choose to setup with a default or custom key pair setup' + puts 'Default setup - Type: RSA to RSA, RSA length: 2048, Valid for: does not expire' + puts 'Would you like to continue with the default setup?' + puts '=====================================================================================' + puts "Press 'Y' for Yes. Press 'N' for custom configuration" + + display_prompt_response + end + + # display prompt for developer to aid in setting up default key + def display_default_config_helpers + puts '' + puts '' + puts '=====================================================================================' + puts 'You will need to enter the following info to create your key' + puts 'Please enter your real name, email address, and a password for your key when prompted' + puts '=====================================================================================' + end +end + +# prompt for a Y or N response, continue asking if other character +# return true for Y and false for N +def display_prompt_response + response = $stdin.gets.strip.upcase + until %w[Y N].include?(response) + puts 'Invalid entry, please enter Y or N' + response = $stdin.gets.strip.upcase + end + + response == 'Y' end -def is_travis? - return ENV["TRAVIS"] != nil +# FIXME: This used to add Travis folding formatting, but we no longer use Travis. I'm leaving it here for the moment, but I think we should remove it. +def fold(_) + yield end def pod(args) @@ -278,7 +662,7 @@ def pod(args) end def lockfile_hash - YAML.load(File.read("Podfile.lock")) + YAML.load_file('Podfile.lock') end def lockfiles_match? @@ -286,67 +670,58 @@ def lockfiles_match? end def podfile_locked? - podfile_checksum = Digest::SHA1.file("Podfile") - lockfile_checksum = lockfile_hash["PODFILE CHECKSUM"] + podfile_checksum = Digest::SHA1.file('Podfile') + lockfile_checksum = lockfile_hash['PODFILE CHECKSUM'] podfile_checksum == lockfile_checksum end -def swiftlint_path - "#{PROJECT_DIR}/vendor/swiftlint" -end - def swiftlint(args) - args = [swiftlint_bin] + args + args = [SWIFTLINT_BIN] + args sh(*args) end -def swiftlint_bin - "#{swiftlint_path}/bin/swiftlint" -end - def swiftlint_needs_install - return true unless File.exist?(swiftlint_bin) - installed_version = `"#{swiftlint_bin}" version`.chomp - return (installed_version != SWIFTLINT_VERSION) + File.exist?(SWIFTLINT_BIN) == false end def xcodebuild(*build_cmds) - cmd = "xcodebuild" + cmd = 'xcodebuild' cmd += " -destination 'platform=iOS Simulator,name=iPhone 6s'" - cmd += " -sdk iphonesimulator" + cmd += ' -sdk iphonesimulator' cmd += " -workspace #{XCODE_WORKSPACE}" cmd += " -scheme #{XCODE_SCHEME}" cmd += " -configuration #{xcode_configuration}" - cmd += " " - cmd += build_cmds.map(&:to_s).join(" ") - cmd += " | bundle exec xcpretty -f `bundle exec xcpretty-travis-formatter` && exit ${PIPESTATUS[0]}" unless ENV['verbose'] + cmd += ' ' + cmd += build_cmds.map(&:to_s).join(' ') + cmd += ' | bundle exec xcpretty -f `bundle exec xcpretty-travis-formatter` && exit ${PIPESTATUS[0]}' unless ENV['verbose'] sh(cmd) end def xcode_configuration - ENV['XCODE_CONFIGURATION'] || XCODE_CONFIGURATION + ENV.fetch('XCODE_CONFIGURATION') { XCODE_CONFIGURATION } end def command?(command) system("which #{command} > /dev/null 2>&1") end + def dependency_failed(component) msg = "#{component} dependencies missing or outdated. " if ENV['DRY_RUN'] - msg += "Run rake dependencies to install them." - fail msg + msg += 'Run rake dependencies to install them.' + raise msg else - msg += "Installing..." + msg += 'Installing...' puts msg end end def check_dependencies_hook - ENV['DRY_RUN'] = "1" + ENV['DRY_RUN'] = '1' begin Rake::Task['dependencies'].invoke - rescue Exception => e + rescue StandardError => e puts e.message exit 1 end diff --git a/Scripts/BuildPhases/GenerateCredentials.sh b/Scripts/BuildPhases/GenerateCredentials.sh new file mode 100755 index 000000000000..9b94b0deae4c --- /dev/null +++ b/Scripts/BuildPhases/GenerateCredentials.sh @@ -0,0 +1,130 @@ +#!/bin/bash -euo pipefail + +# The Secrets File Sources +SECRETS_ROOT="${HOME}/.configure/wordpress-ios/secrets" + +# To help the Xcode build system optimize the build, we want to ensure each of +# the secrets we want to copy is defined as an input file for the run script +# build phase. +# +# > The Xcode Build System will use [these files] to determine if your run +# > scripts should actually run or not. So this should include any file that +# > your run script phase, the script content, is actually going to read or +# > look at during its process. +# +# > If you have no input files declared, the Xcode build system will need to +# > run your run script phase on every single build. +# +# https://developer.apple.com/videos/play/wwdc2018/408/ +function ensure_is_in_input_files_list() { + # Loop through the file input lists looking for $1. If not found, fail the + # build. + if [ -z "$1" ]; then + echo "error: Input file list verification needs a path to verify!" + exit 1 + fi + file_to_find=$1 + + i=0 + found=false + while [[ $i -lt $SCRIPT_INPUT_FILE_LIST_COUNT && "$found" = false ]] + do + # Need this two step process to access the input at index + file_list_resolved_var_name=SCRIPT_INPUT_FILE_LIST_${i} + # The following reads the processed xcfilelist line by line looking for + # the given file + while read input_file; do + if [ "$file_to_find" == "$input_file" ]; then + found=true + break + fi + done <"${!file_list_resolved_var_name}" + let i=i+1 + done + if [ "$found" = false ]; then + echo "error: Could not find $file_to_find as an input to the build phase. Add $file_to_find to the input files list using the .xcfilelist." + exit 1 + fi +} + +PRODUCTION_SECRETS_FILE="${SECRETS_ROOT}/WordPress-Secrets.swift" +ensure_is_in_input_files_list $PRODUCTION_SECRETS_FILE +INTERNAL_SECRETS_FILE="${SECRETS_ROOT}/WordPress-Secrets-Internal.swift" +ensure_is_in_input_files_list $INTERNAL_SECRETS_FILE +ALPHA_SECRETS_FILE="${SECRETS_ROOT}/WordPress-Secrets-Alpha.swift" +ensure_is_in_input_files_list $ALPHA_SECRETS_FILE +JETPACK_SECRETS_FILE="${SECRETS_ROOT}/Jetpack-Secrets.swift" +ensure_is_in_input_files_list $JETPACK_SECRETS_FILE + +LOCAL_SECRETS_FILE="${SRCROOT}/Credentials/Secrets.swift" +EXAMPLE_SECRETS_FILE="${SRCROOT}/Credentials/Secrets-example.swift" +ensure_is_in_input_files_list $EXAMPLE_SECRETS_FILE + +# The Secrets file destination +SECRETS_DESTINATION_FILE="${BUILD_DIR}/Secrets/Secrets.swift" +mkdir -p $(dirname "$SECRETS_DESTINATION_FILE") + +# If the WordPress Production Secrets are available for WordPress, use them +if [ -f "$PRODUCTION_SECRETS_FILE" ] && [ "$BUILD_SCHEME" == "WordPress" ]; then + echo "Applying Production Secrets" + cp -v "$PRODUCTION_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}" + exit 0 +fi + +# If the WordPress Internal Secrets are available, use them +if [ -f "$INTERNAL_SECRETS_FILE" ] && [ "${BUILD_SCHEME}" == "WordPress Internal" ]; then + echo "Applying Internal Secrets" + cp -v "$INTERNAL_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}" + exit 0 +fi + +# If the WordPress Alpha Secrets are available, use them +if [ -f "$ALPHA_SECRETS_FILE" ] && [ "${BUILD_SCHEME}" == "WordPress Alpha" ]; then + echo "Applying Alpha Secrets" + cp -v "$ALPHA_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}" + exit 0 +fi + +# If the Jetpack Secrets are available (and if we're building Jetpack) use them +if [ -f "$JETPACK_SECRETS_FILE" ] && [ "${BUILD_SCHEME}" == "Jetpack" ]; then + echo "Applying Jetpack Secrets" + cp -v "$JETPACK_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}" + exit 0 +fi + +EXTERNAL_CONTRIBUTOR_RELEASE_MSG="External contributors should not need to perform a Release build" + +# If the developer has a local secrets file, use it +if [ -f "$LOCAL_SECRETS_FILE" ]; then + if [[ $CONFIGURATION == Release* ]]; then + echo "error: You can't do a Release build when using local Secrets (from $LOCAL_SECRETS_FILE). $EXTERNAL_CONTRIBUTOR_RELEASE_MSG." + exit 1 + fi + + echo "warning: Using local Secrets from $LOCAL_SECRETS_FILE. If you are an external contributor, this is expected and you can ignore this warning. If you are an internal contributor, make sure to use our shared credentials instead." + echo "Applying Local Secrets" + cp -v "$LOCAL_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}" + exit 0 +fi + +# None of the above secrets was found. Use the example secrets file as a last +# resort, unless building for Release. + +COULD_NOT_FIND_SECRET_MSG="Could not find secrets file at ${SECRETS_DESTINATION_FILE}. This is likely due to the source secrets being missing from ${SECRETS_ROOT}" +INTERNAL_CONTRIBUTOR_MSG="If you are an internal contributor, run \`bundle exec fastlane run configure_apply\` to update your secrets and try again" +EXTERNAL_CONTRIBUTOR_MSG="If you are an external contributor, run \`bundle exec rake init:oss\` to set up and use your own credentials" + +case $CONFIGURATION in + Release*) + # There are three release configurations: Release, Release-Alpha, and + # Release-Internal. Since they all start with "Release" we can use a + # pattern to check for them. + echo "error: $COULD_NOT_FIND_SECRET_MSG. Cannot continue Release build. $INTERNAL_CONTRIBUTOR_MSG. $EXTERNAL_CONTRIBUTOR_RELEASE_MSG." + exit 1 + ;; + *) + echo "warning: $COULD_NOT_FIND_SECRET_MSG. Falling back to $EXAMPLE_SECRETS_FILE. In a Release build, this would be an error. $INTERNAL_CONTRIBUTOR_MSG. $EXTERNAL_CONTRIBUTOR_MSG." + echo "Applying Example Secrets" + cp -v "$EXAMPLE_SECRETS_FILE" "${SECRETS_DESTINATION_FILE}" + ;; +esac diff --git a/Scripts/BuildPhases/GenerateCredentials.xcfilelist b/Scripts/BuildPhases/GenerateCredentials.xcfilelist new file mode 100644 index 000000000000..7c04a0f6df97 --- /dev/null +++ b/Scripts/BuildPhases/GenerateCredentials.xcfilelist @@ -0,0 +1,20 @@ +# Lists of input files for the script that populates the app's secrets with the +# correct values for the current scheme and build configuration. +${HOME}/.configure/wordpress-ios/secrets/WordPress-Secrets.swift +${HOME}/.configure/wordpress-ios/secrets/WordPress-Secrets-Internal.swift +${HOME}/.configure/wordpress-ios/secrets/WordPress-Secrets-Alpha.swift +${HOME}/.configure/wordpress-ios/secrets/Jetpack-Secrets.swift + +# Local Secrets file that external contributors can use to specify their own +# ClientID and Secrets. This file is created by the Rakefile when external +# contributors run the `init:oss` task and provide their own credentials. +${SRCROOT}/Credentials/Secrets.swift + +# Example secrets file, we fallback to this if none of the above is avaiable. +# That usually happens on new machines, to external contributors, or in CI +# builds that don't need access to secrets, such as the unit tests. +${SRCROOT}/Credentials/Secrets-example.swift + +# Add the script that uses this file as a source, so that, if the script +# changes, Xcode will run it again on the next build. +${SRCROOT}/../Scripts/BuildPhases/ApplyConfiguration.sh diff --git a/Scripts/BuildPhases/LintAppLocalizedStringsUsage.sh b/Scripts/BuildPhases/LintAppLocalizedStringsUsage.sh new file mode 100755 index 000000000000..9d72f0876452 --- /dev/null +++ b/Scripts/BuildPhases/LintAppLocalizedStringsUsage.sh @@ -0,0 +1,21 @@ +#!/bin/bash -eu + +SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" +SCRIPT_SRC="${SCRIPT_DIR}/LintAppLocalizedStringsUsage.swift" + +LINTER_BUILD_DIR="${BUILD_DIR:-${TMPDIR}}" +LINTER_EXEC="${LINTER_BUILD_DIR}/$(basename "${SCRIPT_SRC}" .swift)" + +if [ ! -x "${LINTER_EXEC}" ] || ! (shasum -c "${LINTER_EXEC}.shasum" >/dev/null 2>/dev/null); then + echo "Pre-compiling linter script to ${LINTER_EXEC}..." + swiftc -O -sdk "$(xcrun --sdk macosx --show-sdk-path)" "${SCRIPT_SRC}" -o "${LINTER_EXEC}" + shasum "${SCRIPT_SRC}" >"${LINTER_EXEC}.shasum" + chmod +x "${LINTER_EXEC}" + echo "Pre-compiled linter script ready" +fi + +if [ -z "${PROJECT_FILE_PATH:=${1:-}}" ]; then + echo "error: Please provide the path to the xcodeproj to scan" + exit 1 +fi +"$LINTER_EXEC" "${PROJECT_FILE_PATH}" "${@:2}" diff --git a/Scripts/BuildPhases/LintAppLocalizedStringsUsage.swift b/Scripts/BuildPhases/LintAppLocalizedStringsUsage.swift new file mode 100755 index 000000000000..9a29840bd00a --- /dev/null +++ b/Scripts/BuildPhases/LintAppLocalizedStringsUsage.swift @@ -0,0 +1,326 @@ +import Foundation + +// MARK: Xcodeproj entry point type + +/// The main entry point type to parse `.xcodeproj` files +class Xcodeproj { + let projectURL: URL // points to the "<projectDirectory>/<projectName>.xcodeproj/project.pbxproj" file + private let pbxproj: PBXProjFile + + /// Semantic type for strings that correspond to an object' UUID in the `pbxproj` file + typealias ObjectUUID = String + + /// Builds an `Xcodeproj` instance by parsing the `.xcodeproj` or `.pbxproj` file at the provided URL. + init(url: URL) throws { + projectURL = url.pathExtension == "xcodeproj" ? URL(fileURLWithPath: "project.pbxproj", relativeTo: url) : url + let data = try Data(contentsOf: projectURL) + let decoder = PropertyListDecoder() + pbxproj = try decoder.decode(PBXProjFile.self, from: data) + } + + /// An internal mapping listing the parent ObjectUUID for each ObjectUUID. + /// - Built by recursing top-to-bottom in the various `PBXGroup` objects of the project to visit all the children objects, + /// and storing which parent object they belong to. + /// - Used by the `resolveURL` method to find the real path of a `PBXReference`, as we need to navigate from the `PBXReference` object + /// up into the chain of parent `PBXGroup` containers to construct the successive relative paths of groups using `sourceTree = "<group>"` + private lazy var referrers: [ObjectUUID: ObjectUUID] = { + var referrers: [ObjectUUID: ObjectUUID] = [:] + func recurseIfGroup(objectID: ObjectUUID) { + guard let group = try? (self.pbxproj.object(id: objectID) as PBXGroup) else { return } + for childID in group.children { + referrers[childID] = objectID + recurseIfGroup(objectID: childID) + } + } + recurseIfGroup(objectID: self.pbxproj.rootProject.mainGroup) + return referrers + }() +} + +// Convenience methods and properties +extension Xcodeproj { + /// Builds an `Xcodeproj` instance by parsing the `.xcodeproj` or `pbxproj` file at the provided path + convenience init(path: String) throws { + try self.init(url: URL(fileURLWithPath: path)) + } + + /// The directory where the `.xcodeproj` resides. + var projectDirectory: URL { projectURL.deletingLastPathComponent().deletingLastPathComponent() } + /// The list of `PBXNativeTarget` targets in the project. Convenience getter for `PBXProjFile.nativeTargets` + var nativeTargets: [PBXNativeTarget] { pbxproj.nativeTargets } + /// The list of `PBXBuildFile` files a given `PBXNativeTarget` will build. Convenience getter for `PBXProjFile.buildFiles(for:)` + func buildFiles(for target: PBXNativeTarget) -> [PBXBuildFile] { pbxproj.buildFiles(for: target) } + + /// Finds the full path / URL of a `PBXBuildFile` based on the groups it belongs to and their `sourceTree` attribute + func resolveURL(to buildFile: PBXBuildFile) throws -> URL? { + if let fileRefID = buildFile.fileRef, let fileRefObject = try? self.pbxproj.object(id: fileRefID) as PBXFileReference { + return try resolveURL(objectUUID: fileRefID, object: fileRefObject) + } else { + // If the `PBXBuildFile` is pointing to `XCVersionGroup` (like `*.xcdatamodel`) and `PBXVariantGroup` (like `*.strings`) + // (instead of a `PBXFileReference`), then in practice each file in the group's `children` will be built by the Build Phase. + // In practice we can skip parsing those in our case and save some CPU, as we don't have a need to lint those non-source-code files. + return nil // just skip those (but don't throw — those are valid use cases in any pbxproj, just ones we don't care about) + } + } + + /// Finds the full path / URL of a PBXReference (`PBXFileReference` of `PBXGroup`) based on the groups it belongs to and their `sourceTree` attribute + private func resolveURL<T: PBXReference>(objectUUID: ObjectUUID, object: T) throws -> URL? { + if objectUUID == self.pbxproj.rootProject.mainGroup { return URL(fileURLWithPath: ".", relativeTo: projectDirectory) } + + switch object.sourceTree { + case .absolute: + guard let path = object.path else { throw ProjectInconsistencyError.incorrectAbsolutePath(id: objectUUID) } + return URL(fileURLWithPath: path) + case .group: + guard let parentUUID = referrers[objectUUID] else { throw ProjectInconsistencyError.orphanObject(id: objectUUID, object: object) } + let parentGroup = try self.pbxproj.object(id: parentUUID) as PBXGroup + guard let groupURL = try resolveURL(objectUUID: parentUUID, object: parentGroup) else { return nil } + return object.path.map { groupURL.appendingPathComponent($0) } ?? groupURL + case .projectRoot: + return object.path.map { URL(fileURLWithPath: $0, relativeTo: projectDirectory) } ?? projectDirectory + case .buildProductsDir, .devDir, .sdkDir: + print("\(self.projectURL.path): warning: Reference \(objectUUID) is relative to \(object.sourceTree.rawValue), which is not supported by the linter") + return nil + } + } +} + +// MARK: - Implementation Details + +/// "Parent" type for all the PBX... types of objects encountered in a pbxproj +protocol PBXObject: Decodable { + static var isa: String { get } +} +extension PBXObject { + static var isa: String { String(describing: self) } +} + +/// "Parent" type for PBXObjects referencing relative path information (`PBXFileReference`, `PBXGroup`) +protocol PBXReference: PBXObject { + var name: String? { get } + var path: String? { get } + var sourceTree: Xcodeproj.SourceTree { get } +} + +/// Types used to parse and decode the internals of a `*.xcodeproj/project.pbxproj` file +extension Xcodeproj { + /// An error `thrown` when an inconsistency is found while parsing the `.pbxproj` file. + enum ProjectInconsistencyError: Swift.Error, CustomStringConvertible { + case objectNotFound(id: ObjectUUID) + case unexpectedObjectType(id: ObjectUUID, expectedType: Any.Type, found: PBXObject) + case incorrectAbsolutePath(id: ObjectUUID) + case orphanObject(id: ObjectUUID, object: PBXObject) + + var description: String { + switch self { + case .objectNotFound(id: let id): + return "Unable to find object with UUID `\(id)`" + case .unexpectedObjectType(id: let id, expectedType: let expectedType, found: let found): + return "Object with UUID `\(id)` was expected to be of type \(expectedType) but found \(found) instead" + case .incorrectAbsolutePath(id: let id): + return "Object `\(id)` has `sourceTree = \(Xcodeproj.SourceTree.absolute)` but no `path`" + case .orphanObject(id: let id, object: let object): + return "Unable to find parent group of \(object) (`\(id)`) during file path resolution" + } + } + } + + /// Type used to represent and decode the root object of a `.pbxproj` file. + struct PBXProjFile: Decodable { + let rootObject: ObjectUUID + let objects: [String: PBXObjectWrapper] + + // Convenience methods + + /// Returns the `PBXObject` instance with the given `ObjectUUID`, by looking it up in the list of `objects` registered in the project. + func object<T: PBXObject>(id: ObjectUUID) throws -> T { + guard let wrapped = objects[id] else { throw ProjectInconsistencyError.objectNotFound(id: id) } + guard let obj = wrapped.wrappedValue as? T else { + throw ProjectInconsistencyError.unexpectedObjectType(id: id, expectedType: T.self, found: wrapped.wrappedValue) + } + return obj + } + + /// Returns the `PBXObject` instance with the given `ObjectUUID`, by looking it up in the list of `objects` registered in the project. + func object<T: PBXObject>(id: ObjectUUID) -> T? { + try? object(id: id) as T + } + + /// The `PBXProject` corresponding to the `rootObject` of the project file. + var rootProject: PBXProject { try! object(id: rootObject) } + + /// The `PBXGroup` corresponding to the main groop serving as root for the whole hierarchy of files and groups in the project. + var mainGroup: PBXGroup { try! object(id: rootProject.mainGroup) } + + /// The list of `PBXNativeTarget` targets found in the project. + var nativeTargets: [PBXNativeTarget] { rootProject.targets.compactMap(object(id:)) } + + /// The list of `PBXBuildFile` build file references included in a given target. + func buildFiles(for target: PBXNativeTarget) -> [PBXBuildFile] { + guard let sourceBuildPhase: PBXSourcesBuildPhase = target.buildPhases.lazy.compactMap(object(id:)).first else { return [] } + return sourceBuildPhase.files.compactMap(object(id:)) as [PBXBuildFile] + } + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents the root project object. + struct PBXProject: PBXObject { + let mainGroup: ObjectUUID + let targets: [ObjectUUID] + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents a native target (i.e. a target building an app, app extension, bundle...). + /// - note: Does not represent other types of targets like `PBXAggregateTarget`, only native ones. + struct PBXNativeTarget: PBXObject { + let name: String + let buildPhases: [ObjectUUID] + let productType: String + var knownProductType: ProductType? { ProductType(rawValue: productType) } + + enum ProductType: String, Decodable { + case app = "com.apple.product-type.application" + case appExtension = "com.apple.product-type.app-extension" + case unitTest = "com.apple.product-type.bundle.unit-test" + case uiTest = "com.apple.product-type.bundle.ui-testing" + case framework = "com.apple.product-type.framework" + } + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents a "Compile Sources" build phase containing a list of files to compile. + /// - note: Does not represent other types of Build Phases that could exist in the project, only "Compile Sources" one + struct PBXSourcesBuildPhase: PBXObject { + let files: [ObjectUUID] + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents a single build file in a `PBXSourcesBuildPhase` build phase. + struct PBXBuildFile: PBXObject { + let fileRef: ObjectUUID? + } + + /// This type is used to indicate what a file reference in the project is actually relative to + enum SourceTree: String, Decodable, CustomStringConvertible { + case absolute = "<absolute>" + case group = "<group>" + case projectRoot = "SOURCE_ROOT" + case buildProductsDir = "BUILT_PRODUCTS_DIR" + case devDir = "DEVELOPER_DIR" + case sdkDir = "SDKROOT" + var description: String { rawValue } + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents a reference to a file contained in the project tree. + struct PBXFileReference: PBXReference { + let name: String? + let path: String? + let sourceTree: SourceTree + } + + /// One of the many `PBXObject` types encountered in the `.pbxproj` file format. + /// Represents a group (aka "folder") contained in the project tree. + struct PBXGroup: PBXReference { + let name: String? + let path: String? + let sourceTree: SourceTree + let children: [ObjectUUID] + } + + /// Fallback type for any unknown `PBXObject` type. + struct UnknownPBXObject: PBXObject { + let isa: String + } + + /// Wrapper helper to decode any `PBXObject` based on the value of their `isa` field + @propertyWrapper + struct PBXObjectWrapper: Decodable, CustomDebugStringConvertible { + let wrappedValue: PBXObject + + static let knownTypes: [PBXObject.Type] = [ + PBXProject.self, + PBXGroup.self, + PBXFileReference.self, + PBXNativeTarget.self, + PBXSourcesBuildPhase.self, + PBXBuildFile.self + ] + + init(from decoder: Decoder) throws { + let untypedObject = try UnknownPBXObject(from: decoder) + if let objectType = Self.knownTypes.first(where: { $0.isa == untypedObject.isa }) { + self.wrappedValue = try objectType.init(from: decoder) + } else { + self.wrappedValue = untypedObject + } + } + var debugDescription: String { String(describing: wrappedValue) } + } +} + + + +// MARK: - Lint method + +/// The outcome of running our lint logic on a file +enum LintResult { case ok, skipped, violationsFound([(line: Int, col: Int)]) } + +/// Lint a given file for usages of `NSLocalizedString` instead of `AppLocalizedString` +func lint(fileAt url: URL, targetName: String) throws -> LintResult { + guard ["m", "swift"].contains(url.pathExtension) else { return .skipped } + let content = try String(contentsOf: url) + var lineNo = 0 + var violations: [(line: Int, col: Int)] = [] + content.enumerateLines { line, _ in + lineNo += 1 + guard line.range(of: "\\s*//", options: .regularExpression) == nil else { return } // Skip commented lines + guard let range = line.range(of: "NSLocalizedString") else { return } + + // Violation found, report it + let colNo = line.distance(from: line.startIndex, to: range.lowerBound) + let message = "Use `AppLocalizedString` instead of `NSLocalizedString` in source files that are used in the `\(targetName)` extension target. See paNNhX-nP-p2 for more info." + print("\(url.path):\(lineNo):\(colNo): error: \(message)") + violations.append((lineNo, colNo)) + } + return violations.isEmpty ? .ok : .violationsFound(violations) +} + + + +// MARK: - Main (Script Code entry point) + +// 1st arg = project path +let args = CommandLine.arguments.dropFirst() +guard let projectPath = args.first, !projectPath.isEmpty else { print("You must provide the path to the xcodeproj as first argument."); exit(1) } +do { + let project = try Xcodeproj(path: projectPath) + + // 2nd arg (optional) = name of target to lint + let targetsToLint: [Xcodeproj.PBXNativeTarget] + if let targetName = args.dropFirst().first, !targetName.isEmpty { + print("Selected target: \(targetName)") + targetsToLint = project.nativeTargets.filter { $0.name == targetName } + } else { + print("Linting all app extension targets") + targetsToLint = project.nativeTargets.filter { $0.knownProductType == .appExtension } + } + + // Lint each requested target + var violationsFound = 0 + for target in targetsToLint { + let buildFiles: [Xcodeproj.PBXBuildFile] = project.buildFiles(for: target) + print("Linting the Build Files for \(target.name):") + for buildFile in buildFiles { + guard let fileURL = try project.resolveURL(to: buildFile) else { continue } + let result = try lint(fileAt: fileURL.absoluteURL, targetName: target.name) + print(" - \(fileURL.relativePath) [\(result)]") + if case .violationsFound(let list) = result { violationsFound += list.count } + } + } + print("Done! \(violationsFound) violation(s) found.") + exit(violationsFound > 0 ? 1 : 0) +} catch let error { + print("\(projectPath): error: Error while parsing the project file \(projectPath): \(error.localizedDescription)") + exit(2) +} diff --git a/Scripts/allowSimulatorPhotosAccess.sh b/Scripts/allowSimulatorPhotosAccess.sh deleted file mode 100755 index a836786f9b51..000000000000 --- a/Scripts/allowSimulatorPhotosAccess.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/perl -$currentUserID = `id -un`; -chomp($currentUserID); -$folderLocations = `find "/Users/$currentUserID/Library/Developer/CoreSimulator/Devices" -name TCC`; -print "currentUserID: $currentUserID\n\n"; - -while($folderLocations =~ /(..*)/g) { - print "folder: $1\n"; - `sqlite3 "$1/TCC.db" "insert into access values('kTCCServicePhotos','org.wordpress', 0, 1, 1, null, null)"`; - print "\n"; -} diff --git a/Scripts/coverage.py b/Scripts/coverage.py deleted file mode 100644 index db6b8cc22698..000000000000 --- a/Scripts/coverage.py +++ /dev/null @@ -1,326 +0,0 @@ -import glob -import os -import shutil -import subprocess -import StringIO -import sys - -# Directories -dataDirectory = "./CoverageData" -cacheDirectory = dataDirectory + "/Cache" -derivedDataDirectory = dataDirectory + "/DerivedData" -buildObjectsDirectory = derivedDataDirectory + "/Build/Intermediates/WordPress.build/Debug-iphonesimulator/WordPress.build/Objects-normal/x86_64" -gcovOutputDirectory = dataDirectory + "/GCOVOutput" -finalReport = dataDirectory + "/FinalReport" - -# Files -gcovOutputFileName = gcovOutputDirectory + "/gcov.output" - -# File Patterns -allGcdaFiles = "/*.gcda" -allGcnoFiles = "/*.gcno" - -# Data conversion methods - -def IsInt(i): - try: - int(i) - return True - except ValueError: - return False - -# Directory methods - -def copyFiles(sourcePattern, destination): - assert sourcePattern - - for file in glob.glob(sourcePattern): - shutil.copy(file, destination) - - return - -def createDirectoryIfNecessary(directory): - if not os.path.exists(directory): - os.makedirs(directory) - return - -def removeDirectory(directory): - assert directory - assert directory.startswith(dataDirectory) - subprocess.call(["rm", - "-rf", - directory]) - return - -def removeFileIfNecessary(file): - if os.path.isfile(gcovOutputFileName): - os.remove(gcovOutputFileName) - return - -# Xcode interaction methods - -def xcodeBuildOperation(operation, simulator): - assert operation - - return subprocess.call(["xcodebuild", - operation, - "-workspace", - "../WordPress.xcworkspace", - "-scheme", - "WordPress", - "-configuration", - "Debug", - "-destination", - "platform=" + simulator, - "-derivedDataPath", - derivedDataDirectory, - "GCC_GENERATE_TEST_COVERAGE_FILES=YES", - "GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=YES"]) - -def xcodeClean(simulator): - return xcodeBuildOperation("clean", simulator) - -def xcodeBuild(simulator): - return xcodeBuildOperation("build", simulator) - -def xcodeTest(simulator): - return xcodeBuildOperation("test", simulator) - -# Simulator interaction methods - -def simulatorEraseContentAndSettings(simulator): - - deviceID = simulator[2] - - command = ["xcrun", - "simctl", - "erase", - deviceID] - result = subprocess.call(command) - - if (result != 0): - exit("Error: subprocess xcrun failed to erase content and settings for device ID: " + deviceID + ".") - - return - -# Caching methods - -def cacheAllGcdaFiles(): - allGcdaFilesPath = buildObjectsDirectory + allGcdaFiles - copyFiles(allGcdaFilesPath, cacheDirectory) - return - -def cacheAllGcnoFiles(): - allGcnoFilesPath = buildObjectsDirectory + allGcnoFiles - copyFiles(allGcnoFilesPath, cacheDirectory) - return - -# Core procedures - -def createInitialDirectories(): - createDirectoryIfNecessary(dataDirectory) - createDirectoryIfNecessary(cacheDirectory) - createDirectoryIfNecessary(derivedDataDirectory) - createDirectoryIfNecessary(gcovOutputDirectory) - createDirectoryIfNecessary(finalReport) - return - -def generateGcdaAndGcnoFiles(simulator): - if xcodeClean(simulator) != 0: - sys.exit("Exit: the clean procedure failed.") - - if xcodeBuild(simulator) != 0: - sys.exit("Exit: the build procedure failed.") - - if xcodeTest(simulator) != 0: - sys.exit("Exit: the test procedure failed.") - - cacheAllGcdaFiles() - cacheAllGcnoFiles() - return - -def processGcdaAndGcnoFiles(): - - removeFileIfNecessary(gcovOutputFileName) - gcovOutputFile = open(gcovOutputFileName, "wb") - - sourceFilesPattern = cacheDirectory + allGcnoFiles - - for file in glob.glob(sourceFilesPattern): - fileWithPath = "../../" + file - - command = ["gcov", fileWithPath] - - subprocess.call(command, - cwd = gcovOutputDirectory, - stdout = gcovOutputFile) - return - -# Selecting a Simulator - -def availableSimulators(): - command = ["xcrun", - "simctl", - "list", - "devices"] - - process = subprocess.Popen(command, - stdout = subprocess.PIPE) - out, err = process.communicate() - - simulators = availableSimulatorsFromXcrunOutput(out) - - return simulators - -def availableSimulatorsFromXcrunOutput(output): - outStringIO = StringIO.StringIO(output) - - iOSVersion = "" - simulators = [] - - line = outStringIO.readline() - line = line.strip("\r").strip("\n") - - assert line == "== Devices ==" - - while True: - line = outStringIO.readline() - line = line.strip("\r").strip("\n") - - if line.startswith("-- "): - iOSVersion = line.strip("-- iOS ").strip(" --") - elif line: - name = line[4:line.rfind(" (", 0, line.rfind(" ("))] - id = line[line.rfind("(", 0, line.rfind("(")) + 1:line.rfind(")", 0, line.rfind(")"))] - simulators.append([iOSVersion, name, id]) - else: - break - - return simulators - -def askUserToSelectSimulator(simulators): - option = "" - - while True: - print "\r\nPlease select a simulator:\r\n" - - for idx, simulator in enumerate(simulators): - print str(idx) + " - iOS Version: " + simulator[0] + " - Name: " + simulator[1] + " - ID: " + simulator[2] - print "x - Exit\r\n" - - option = raw_input(": ") - - if option == "x": - exit(0) - elif IsInt(option): - intOption = int(option) - if intOption >= 0 and intOption < len(simulators): - break - - print "Invalid option!" - return int(option) - -def selectSimulator(): - result = None - simulators = availableSimulators() - - if (len(simulators) > 0): - option = askUserToSelectSimulator(simulators) - - assert option >= 0 and option < len(simulators) - - simulatorEraseContentAndSettings(simulators[option]) - - result = "iOS Simulator,name=" + simulators[option][1] + ",OS=" + simulators[option][0] - print "Selected simulator: " + result - - return result - -# Parsing the data - -def parseCoverageData(line): - header = "Lines executed:" - - assert line.startswith(header) - - line = line[len(header):] - lineComponents = line.split(" of ") - - percentage = float(lineComponents[0].strip("%")) / 100 - totalLines = int(lineComponents[1]) - linesExecuted = int(round(percentage * totalLines)) - - return str(percentage), str(totalLines), str(linesExecuted) - -def parseFilePath(line): - assert line.startswith("File '") - - splitStrings = line.split("'") - path = splitStrings[1] - - parentDir = os.path.dirname(os.getcwd()) - - if path.startswith(parentDir): - path = path[len(parentDir):] - else: - path = None - - return path - -def parseGcovFiles(): - gcovFile = open(gcovOutputFileName, "r") - csvFile = open(finalReport + "/report.csv", "w") - - lineNumber = 0 - skipNext = False - - csvFile.write("File, Covered Lines, Total Lines, Coverage Percentage\r\n") - - for line in gcovFile: - lineOffset = lineNumber % 4 - - if lineOffset == 0: - filePath = parseFilePath(line) - - if filePath: - csvFile.write(filePath + ",") - else: - skipNext = True - - elif lineOffset == 1: - if not skipNext: - percentage, totalLines, linesExecuted = parseCoverageData(line) - - csvFile.write(linesExecuted + "," + totalLines + "," + percentage + "\r\n") - else: - skipNext = False - - lineNumber += 1 - - return - -# Main - -def main(arguments): - createInitialDirectories() - - simulator = selectSimulator() - - generateGcdaAndGcnoFiles(simulator) - processGcdaAndGcnoFiles() - parseGcovFiles() - - removeDirectory(derivedDataDirectory) - return - -main(sys.argv) -print("Done.") - - - - - - - - - diff --git a/Scripts/exportipa/exportOptions.plist b/Scripts/exportipa/exportOptions.plist deleted file mode 100644 index 4284b196eaa3..000000000000 --- a/Scripts/exportipa/exportOptions.plist +++ /dev/null @@ -1,31 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> -<plist version="1.0"> -<dict> - <key>iCloudContainerEnvironment</key> - <string>Production</string> - <key>method</key> - <string>app-store</string> - <key>provisioningProfiles</key> - <dict> - <key>org.wordpress</key> - <string>WordPress App Store</string> - <key>org.wordpress.WordPressShare</key> - <string>WordPress Share App Store Distribution</string> - <key>org.wordpress.WordPressTodayWidget</key> - <string>WordPress Today Widget App Store Distribution</string> - </dict> - <key>signingCertificate</key> - <string>iPhone Distribution</string> - <key>signingStyle</key> - <string>manual</string> - <key>stripSwiftSymbols</key> - <true/> - <key>teamID</key> - <string>PZYM8XX95Q</string> - <key>uploadBitcode</key> - <false/> - <key>uploadSymbols</key> - <true/> -</dict> -</plist> diff --git a/Scripts/exportipa/exportipa.sh b/Scripts/exportipa/exportipa.sh deleted file mode 100755 index 5dab65d561f3..000000000000 --- a/Scripts/exportipa/exportipa.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash - -executable=$(basename "$0" ".sh") - -if [ $# -ne 3 ] || [ -z "$1" ] || [ -z "$2" ] || [ -z "$3" ]; then - echo "*** Error!" - echo "usage: $executable workspacePath scheme exportPath" - echo "example: $executable WordPress.xcworkspace WordPress ~/Desktop/WordPress/ Debug Release" - echo "" - exit -1 -fi - -workspacePath="$1" -scheme="$2" -exportPath="$3" - -if [ ! -e "$workspacePath" ]; then - echo "The specified workspace file does not exist." - exit -1 -fi - -if [ -e exportPath ]; then - echo "The specified exportPath matches an existing file." - exit -1 -fi - -tmpDir="$(mktemp -d)" -archiveFile="$tmpDir/archive.xcarchive" -dsymDir="$tmpDir/archive.xcarchive/dSYMs" -logFile="$tmpDir/exportipa.log" - -function finish { - echo "Log location: $logFile" -} - -trap finish EXIT - -echo "*** Configuration:" -echo "archiveFile = $archiveFile" -echo "dsymDir = $dsymDir" -echo "exportPath = $exportPath" -echo "scheme = $scheme" -echo "tmpDir = $tmpDir" -echo "workspacePath = $workspacePath" -echo "" -echo "*** Cleaning for testing." -xcodebuild -workspace "$workspacePath" -scheme "$scheme" clean -destination "platform=iOS Simulator,name=iPhone 8" >> "$logFile" 2>&1 || exit 1 -echo "*** Testing." -xcodebuild -workspace "$workspacePath" -scheme "$scheme" test -destination "platform=iOS Simulator,name=iPhone 8" >> "$logFile" 2>&1 || exit 1 -echo "*** Cleaning for building." -# The clean command for release builds seems to require the configuration to be set. -xcodebuild -workspace "$workspacePath" -scheme "$scheme" clean -configuration 'Release' >> "$logFile" 2>&1 || exit 1 -echo "*** Building." -xcodebuild -workspace "$workspacePath" -scheme "$scheme" archive -archivePath "$archiveFile" >> "$logFile" 2>&1 || exit 1 -echo "*** Exporting IPA." -xcodebuild -exportArchive -archivePath "$archiveFile" -exportOptionsPlist exportOptions.plist -exportPath "$exportPath" >> "$logFile" 2>&1 || exit 1 -echo "*** Archiving and exporting DSYM." -ditto -c -k --sequesterRsrc --keepParent "$dsymDir" "$exportPath/dSYMs.zip" -echo "" -echo "*** Completed!!" -echo "IPA location: $exportPath" diff --git a/Scripts/extract-framework-translations.swift b/Scripts/extract-framework-translations.swift deleted file mode 100755 index 79781ea7d6cb..000000000000 --- a/Scripts/extract-framework-translations.swift +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env swift - -import Foundation - -let fileManager = FileManager.default -let cwd = fileManager.currentDirectoryPath -let script = CommandLine.arguments[0] - -let base = cwd -let projectDir = base.appending("/WordPress") -let resources = projectDir.appending("/Resources") -let frameworkRoots = [ - "WordPressTodayWidget", - "WordPressShareExtension" - ].map({ projectDir.appending("/\($0)") }) - -guard fileManager.fileExists(atPath: projectDir) else { - print("Must run script from project root folder") - exit(1) -} - - -func projectLanguages() -> [String] { - return (try? fileManager.contentsOfDirectory(atPath: resources) - .filter({ $0.hasSuffix(".lproj") }) - .map({ $0.replacingOccurrences(of: ".lproj", with: "") }) - .filter({ $0 != "en" }) - ) ?? [] -} - -func readStrings(path: String) -> [String: String] { - do { - let sourceData = try Data(contentsOf: URL(fileURLWithPath: path)) - let source = try PropertyListSerialization.propertyList(from: sourceData, options: [], format: nil) as! [String: String] - return source - } catch { - print("Error reading \(path): \(error)") - return [:] - } -} - -func sourceStrings(framework: String) -> [String: String] { - let sourcePath = framework.appending("/Base.lproj/Localizable.strings") - return readStrings(path: sourcePath) -} - -func readProjectTranslations(for language: String) -> [String: String] { - let path = resources.appending("/\(language).lproj/Localizable.strings") - return readStrings(path: path) -} - -func writeTranslations(_ translations: [String: String], language: String, framework: String) { - let frameworkName = (framework as NSString).lastPathComponent - let languageDir = framework.appending("/\(language).lproj") - let stringsPath = languageDir.appending("/Localizable.strings") - do { - try fileManager.createDirectory(atPath: languageDir, withIntermediateDirectories: true, attributes: nil) - let data = try PropertyListSerialization.data(fromPropertyList: translations, format: .binary, options: 0) - if !fileManager.fileExists(atPath: stringsPath) { - print("New \(language) translation for \(frameworkName). Please add it to the Xcode project") - } - try data.write(to: URL(fileURLWithPath: stringsPath)) - } catch { - print("Error writing translation to \(stringsPath): \(error)") - } -} - -for framework in frameworkRoots { - let name = (framework as NSString).lastPathComponent - let sources = sourceStrings(framework: framework) - var languagesAdded = [String]() - for language in projectLanguages() { - let projectTranslations = readProjectTranslations(for: language) - var translations = sources - for (key, _) in sources { - translations[key] = projectTranslations[key] - } - - guard !translations.isEmpty else { - continue - } - languagesAdded.append(language) - - writeTranslations(translations, language: language, framework: framework) - } - if languagesAdded.isEmpty { - print("No translations extracted to \(name)") - } else { - print("Extracted translations to \(name) for: " + languagesAdded.joined(separator: " ")) - } -} diff --git a/Scripts/fastlane/.gitignore b/Scripts/fastlane/.gitignore deleted file mode 100644 index 1e69c202112e..000000000000 --- a/Scripts/fastlane/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -screenshots -screenshots_orig -*.itmsp diff --git a/Scripts/fastlane/Deliverfile b/Scripts/fastlane/Deliverfile deleted file mode 100644 index faedc3a5c977..000000000000 --- a/Scripts/fastlane/Deliverfile +++ /dev/null @@ -1,18 +0,0 @@ -require 'dotenv' -Dotenv.load('~/.wpios-env.default') - -screenshots_path "./screenshots/" -app_identifier "org.wordpress" - -# Make sure to update these keys for a new version -app_version "14.3" - -privacy_url({ - 'default' => 'https://automattic.com/privacy/', -}) - -copyright('2020 Automattic Inc.') - -skip_binary_upload true -overwrite_screenshots true -phased_release true diff --git a/Scripts/fastlane/Fastfile b/Scripts/fastlane/Fastfile deleted file mode 100644 index 053632aa66be..000000000000 --- a/Scripts/fastlane/Fastfile +++ /dev/null @@ -1,478 +0,0 @@ -default_platform(:ios) -fastlane_require 'xcodeproj' -fastlane_require 'dotenv' -fastlane_require 'open-uri' - -USER_ENV_FILE_PATH = File.join(Dir.home, '.wpios-env.default') -PROJECT_ENV_FILE_PATH = File.expand_path(File.join(Dir.pwd, '../../.configure-files/project.env')) - -# Use this instead of getting values from ENV directly -# It will throw an error if the requested value is missing -def get_required_env(key) - unless ENV.key?(key) - UI.user_error!("Environment variable '#{key}' is not set. Have you setup #{USER_ENV_FILE_PATH} correctly?") - end - ENV[key] -end - -before_all do - # Check that the env files exist - unless is_ci || File.file?(USER_ENV_FILE_PATH) - UI.user_error!("~/.wpios-env.default not found: Please copy env/user.env-example to #{USER_ENV_FILE_PATH} and fill in the values") - end - unless File.file?(PROJECT_ENV_FILE_PATH) - UI.user_error!("project.env not found: Make sure your configuration is up to date with `rake dependencies`") - end - - # This allows code signing to work on CircleCI - # It is skipped if this isn't running on CI - # See https://circleci.com/docs/2.0/ios-codesigning/ - setup_circle_ci -end - -platform :ios do -######################################################################## -# Environment -######################################################################## -Dotenv.load(USER_ENV_FILE_PATH) -Dotenv.load(PROJECT_ENV_FILE_PATH) -ENV[GHHELPER_REPO="wordpress-mobile/wordpress-iOS"] -ENV["PROJECT_NAME"]="WordPress" -ENV["PUBLIC_CONFIG_FILE"]="../config/Version.Public.xcconfig" -ENV["INTERNAL_CONFIG_FILE"]="../config/Version.internal.xcconfig" -ENV["DOWNLOAD_METADATA"]="./fastlane/download_metadata.swift" -ENV["PROJECT_ROOT_FOLDER"]="../" - -######################################################################## -# Screenshots -######################################################################## -import "./ScreenshotFastfile" - -######################################################################## -# Release Lanes -######################################################################## - ##################################################################################### - # code_freeze - # ----------------------------------------------------------------------------------- - # This lane executes the steps planned on code freeze - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane code_freeze [skip_confirm:<skip confirm>] - # - # Example: - # bundle exec fastlane code_freeze - # bundle exec fastlane code_freeze skip_confirm:true - ##################################################################################### - desc "Creates a new release branch from the current develop" - lane :code_freeze do | options | - old_version = ios_codefreeze_prechecks(options) - - ios_bump_version_release() - new_version = ios_get_app_version() - ios_update_release_notes(new_version: new_version) - setbranchprotection(repository:GHHELPER_REPO, branch: "release/#{new_version}") - setfrozentag(repository:GHHELPER_REPO, milestone: new_version) - - ios_localize_project() - ios_tag_build() - get_prs_list(repository:GHHELPER_REPO, start_tag:"#{old_version}", report_path:"#{File.expand_path('~')}/wpios_prs_list_#{old_version}_#{new_version}.txt") - end - - ##################################################################################### - # update_appstore_strings - # ----------------------------------------------------------------------------------- - # This lane updates the AppStoreStrings.po files with the latest content from - # the release_notes.txt file and the other text sources - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane update_appstore_strings version:<release note version> - # - # Example: - # bundle exec fastlane update_appstore_strings version:10.7 - ##################################################################################### - desc "Updates the AppStoreStrings.po file with the latest data" - lane :update_appstore_strings do | options | - prj_folder = Pathname.new(File.join(Dir.pwd, "../..")).expand_path.to_s - source_metadata_folder = File.join(prj_folder, "Scripts/fastlane/appstoreres/metadata/source") - - files = { - whats_new: File.join(prj_folder, "/WordPress/Resources/release_notes.txt"), - app_store_subtitle: File.join(source_metadata_folder, "subtitle.txt"), - app_store_desc: File.join(source_metadata_folder, "description.txt"), - app_store_keywords: File.join(source_metadata_folder, "keywords.txt"), - "standard-whats-new-1" => File.join(source_metadata_folder, "standard_whats_new_1.txt"), - "standard-whats-new-2" => File.join(source_metadata_folder, "standard_whats_new_2.txt"), - "standard-whats-new-3" => File.join(source_metadata_folder, "standard_whats_new_3.txt"), - "standard-whats-new-4" => File.join(source_metadata_folder, "standard_whats_new_4.txt"), - "app_store_screenshot-1" => File.join(source_metadata_folder, "promo_screenshot_1.txt"), - "app_store_screenshot-2" => File.join(source_metadata_folder, "promo_screenshot_2.txt"), - "app_store_screenshot-3" => File.join(source_metadata_folder, "promo_screenshot_3.txt"), - "app_store_screenshot-4" => File.join(source_metadata_folder, "promo_screenshot_4.txt"), - "app_store_screenshot-5" => File.join(source_metadata_folder, "promo_screenshot_5.txt"), - } - - ios_update_metadata_source(po_file_path: prj_folder + "/WordPress/Resources/AppStoreStrings.po", - source_files: files, - release_version: options[:version]) - end - - ##################################################################################### - # new_beta_release - # ----------------------------------------------------------------------------------- - # This lane updates the release branch for a new beta release. It will update the - # current release branch by default. If you want to update a different branch - # (i.e. hotfix branch) pass the related version with the 'base_version' param - # (example: base_version:10.6.1 will work on the 10.6.1 branch) - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane new_beta_release [skip_confirm:<skip confirm>] [base_version:<version>] - # - # Example: - # bundle exec fastlane new_beta_release - # bundle exec fastlane new_beta_release skip_confirm:true - # bundle exec fastlane new_beta_release base_version:10.6.1 - ##################################################################################### - desc "Updates a release branch for a new beta release" - lane :new_beta_release do | options | - ios_betabuild_prechecks(options) - ios_bump_version_beta() - ios_tag_build() - end - - ##################################################################################### - # new_hotfix_release - # ----------------------------------------------------------------------------------- - # This lane updates the release branch for a new hotix release. - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane new_hotfix_release [skip_confirm:<skip confirm>] [version:<version>] - # - # Example: - # bundle exec fastlane new_hotfix_release version:10.6.1 - # bundle exec fastlane new_hotfix_release skip_confirm:true version:10.6.1 - ##################################################################################### - desc "Creates a new hotfix branch from the given tag" - lane :new_hotfix_release do | options | - prev_ver = ios_hotfix_prechecks(options) - ios_bump_version_hotfix(previous_version: prev_ver, version: options[:version]) - ios_tag_build() - end - - ##################################################################################### - # finalize_release - # ----------------------------------------------------------------------------------- - # This lane finalize a release: updates store metadata, pushes the final tag and - # cleans all the temp ones - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane finalize_release [skip_confirm:<skip confirm>] [version:<version>] - # - # Example: - # bundle exec fastlane finalize_release - # bundle exec fastlane finalize_release skip_confirm:true - ##################################################################################### - desc "Removes all the temp tags and puts the final one" - lane :finalize_release do | options | - ios_finalize_prechecks(options) - ios_update_metadata(options) unless ios_current_branch_is_hotfix - ios_bump_version_beta() unless ios_current_branch_is_hotfix - ios_final_tag(options) - - # Wrap up - version = ios_get_app_version() - removebranchprotection(repository:GHHELPER_REPO, branch: "release/#{version}") - setfrozentag(repository:GHHELPER_REPO, milestone: version, freeze: false) - create_new_milestone(repository:GHHELPER_REPO) - close_milestone(repository:GHHELPER_REPO, milestone: version) - end - - ##################################################################################### - # build_and_upload_release - # ----------------------------------------------------------------------------------- - # This lane builds the app and upload it for both internal and external distribution - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane build_and_upload_release [skip_confirm:<skip confirm>] - # - # Example: - # bundle exec fastlane build_and_upload_release - # bundle exec fastlane build_and_upload_release skip_confirm:true - ##################################################################################### - desc "Builds and updates for distribution" - lane :build_and_upload_release do | options | - ios_build_prechecks(skip_confirm: options[:skip_confirm], - internal: true, - external: true) - ios_build_preflight() - build_and_upload_internal(skip_prechecks: true, skip_confirm: options[:skip_confirm]) - build_and_upload_itc(skip_prechecks: true, skip_confirm: options[:skip_confirm]) - end - - ##################################################################################### - # build_and_upload_installable_build - # ----------------------------------------------------------------------------------- - # This lane builds the app and upload it for adhoc testing - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane build_and_upload_installable_build [version_long:<version_long>] - # - # Example: - # bundle exec fastlane build_and_upload_installable_build - # bundle exec fastlane build_and_upload_installable_build build_number:123 - ##################################################################################### - desc "Builds and uploads an installable build" - lane :build_and_upload_installable_build do | options | - alpha_code_signing - - # Get the current build version, and update it if needed - version_config_path = "../../config/Version.internal.xcconfig" - versions = Xcodeproj::Config.new(File.new(version_config_path)).to_hash - build_number = versions["VERSION_LONG"] - - if options.key?(:build_number) - build_number = options[:build_number] - - UI.message("Updating build version to #{build_number}") - - versions["VERSION_LONG"] = build_number - new_config = Xcodeproj::Config.new(versions) - new_config.save_as(Pathname.new(version_config_path)) - end - - gym( - scheme: "WordPress Alpha", - workspace: "../WordPress.xcworkspace", - export_method: "enterprise", - clean: true, - output_directory: "../build/", - derived_data_path: "../derived-data/alpha/", - export_team_id: ENV["INT_EXPORT_TEAM_ID"], - export_options: { method: "enterprise" }) - - sh("mv ../../build/WordPress.ipa \"../../build/WordPress Alpha.ipa\"") - - appcenter_upload( - api_token: get_required_env("APPCENTER_API_TOKEN"), - owner_name: "automattic", - owner_type: "organization", - app_name: "WPiOS-One-Offs", - file: "../build/WordPress Alpha.ipa", - destinations: "All-users-of-WPiOS-One-Offs", - notify_testers: false - ) - - download_url = Actions.lane_context[SharedValues::APPCENTER_DOWNLOAD_LINK] - UI.message("Successfully built and uploaded installable build here: #{download_url}") - install_url = "https://install.appcenter.ms/orgs/automattic/apps/WPiOS-One-Offs/" - - # Create a comment.json file so that Peril to comment with the build details, if this is running on CI - comment_body = "You can test the changes on this Pull Request by downloading it from AppCenter [here](#{install_url}) with build number: #{build_number}. IPA is available [here](#{download_url}). If you need access to this, you can ask a maintainer to add you." - File.write("comment.json", { body: comment_body }.to_json) - end - - ##################################################################################### - # build_and_upload_internal - # ----------------------------------------------------------------------------------- - # This lane builds the app and upload it for internal testing - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane build_and_upload_internal [skip_confirm:<skip confirm>] - # - # Example: - # bundle exec fastlane build_and_upload_internal - # bundle exec fastlane build_and_upload_internal skip_confirm:true - ##################################################################################### - desc "Builds and uploads for distribution" - lane :build_and_upload_internal do | options | - ios_build_prechecks(skip_confirm: options[:skip_confirm], internal: true) unless (options[:skip_prechecks]) - ios_build_preflight() unless (options[:skip_prechecks]) - - internal_code_signing - - gym( - scheme: "WordPress Internal", - workspace: "../WordPress.xcworkspace", - export_method: "enterprise", - clean: true, - output_directory: "../build/", - derived_data_path: "../derived-data/internal/", - export_team_id: get_required_env("INT_EXPORT_TEAM_ID"), - export_options: { method: "enterprise" }) - - sh("mv ../../build/WordPress.ipa \"../../build/WordPress Internal.ipa\"") - - appcenter_upload( - api_token: ENV["APPCENTER_API_TOKEN"], - owner_name: "automattic", - owner_type: "organization", - app_name: "WP-Internal", - file: "../build/WordPress Internal.ipa", - notify_testers: false - ) - - - dSYM_PATH = File.dirname(File.dirname(Dir.pwd)) + "/build/WordPress.app.dSYM.zip" - - sentry_upload_dsym( - auth_token: get_required_env("SENTRY_AUTH_TOKEN"), - org_slug: 'a8c', - project_slug: 'wordpress-ios', - dsym_path: dSYM_PATH, - ) - - end - - ##################################################################################### - # build_and_upload_itc - # ----------------------------------------------------------------------------------- - # This lane builds the app and upload it for external distribution - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane build_and_upload_itc [skip_confirm:<skip confirm>] [create_release:<Create release on GH> ] - # - # Example: - # bundle exec fastlane build_and_upload_itc - # bundle exec fastlane build_and_upload_itc skip_confirm:true - # bundle exec fastlane build_and_upload_itc create_release:true - ##################################################################################### - desc "Builds and uploads for distribution" - lane :build_and_upload_itc do | options | - ios_build_prechecks(skip_confirm: options[:skip_confirm], external: true) unless (options[:skip_prechecks]) - ios_build_preflight() unless (options[:skip_prechecks]) - - appstore_code_signing - - gym(scheme: "WordPress", workspace: "../WordPress.xcworkspace", - clean: true, - export_team_id: get_required_env("EXT_EXPORT_TEAM_ID"), - derived_data_path: "../derived-data/itc/", - export_options: { method: "app-store" } - ) - - testflight( - skip_waiting_for_build_processing: true, - team_id: "299112", - ) - - sh("cd .. && rm WordPress.ipa") - dSYM_PATH = File.dirname(Dir.pwd) + "/WordPress.app.dSYM.zip" - - sentry_upload_dsym( - dsym_path: dSYM_PATH, - auth_token: get_required_env("SENTRY_AUTH_TOKEN"), - org_slug: 'a8c', - project_slug: 'wordpress-ios', - ) - - sh("cd .. && rm WordPress.app.dSYM.zip") - - if (options[:create_release]) - archive_zip_path = File.dirname(Dir.pwd) + "/WordPress.xarchive.zip" - zip(path: lane_context[SharedValues::XCODEBUILD_ARCHIVE], output_path: archive_zip_path) - - version = ios_get_app_version() - create_release(repository:GHHELPER_REPO, - version: version, - release_notes_file_path:'../WordPress/Resources/release_notes.txt', - release_assets:"#{archive_zip_path}" - ) - - sh("rm #{archive_zip_path}") - end - end - - ##################################################################################### - # build_release - # ----------------------------------------------------------------------------------- - # This lane builds the app, create a GH release and upload it for external distribution - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane build_release [skip_confirm:<skip confirm>] - # - # Example: - # bundle exec fastlane build_release - # bundle exec fastlane build_release skip_confirm:true - ##################################################################################### - desc "Builds, create release and uploads for distribution" - lane :build_release do | options | - build_and_upload_itc(skip_confirm: options[:skip_confirm], create_release: true) - end - -######################################################################## -# Cnfigure Lanes -######################################################################## - ##################################################################################### - # update_certs_and_profiles - # ----------------------------------------------------------------------------------- - # This lane downloads all the required certs and profiles and, - # if not run on CI it creates the missing ones. - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane update_certs_and_profiles - # - # Example: - # bundle exec fastlane update_certs_and_profiles - ##################################################################################### - lane :update_certs_and_profiles do | options | - alpha_code_signing - internal_code_signing - appstore_code_signing - end - - ######################################################################## - # Fastlane match code signing - ######################################################################## - private_lane :alpha_code_signing do |options| - match( - type: "enterprise", - team_id: get_required_env("INT_EXPORT_TEAM_ID"), - readonly: options[:readonly] || is_ci, - app_identifier: ["org.wordpress.alpha", - "org.wordpress.alpha.WordPressShare", - "org.wordpress.alpha.WordPressDraftAction", - "org.wordpress.alpha.WordPressTodayWidget", - "org.wordpress.alpha.WordPressNotificationServiceExtension", - "org.wordpress.alpha.WordPressNotificationContentExtension", - "org.wordpress.alpha.WordPressAllTimeWidget", - "org.wordpress.alpha.WordPressThisWeekWidget"]) - end - - private_lane :internal_code_signing do |options| - match( - type: "enterprise", - team_id: get_required_env("INT_EXPORT_TEAM_ID"), - readonly: options[:readonly] || is_ci, - app_identifier: ["org.wordpress.internal", - "org.wordpress.internal.WordPressShare", - "org.wordpress.internal.WordPressDraftAction", - "org.wordpress.internal.WordPressTodayWidget", - "org.wordpress.internal.WordPressNotificationServiceExtension", - "org.wordpress.internal.WordPressNotificationContentExtension", - "org.wordpress.internal.WordPressAllTimeWidget", - "org.wordpress.internal.WordPressThisWeekWidget"]) - end - - private_lane :appstore_code_signing do |options| - match( - type: "appstore", - team_id: get_required_env("EXT_EXPORT_TEAM_ID"), - readonly: options[:readonly] || is_ci, - app_identifier: ["org.wordpress", - "org.wordpress.WordPressShare", - "org.wordpress.WordPressDraftAction", - "org.wordpress.WordPressTodayWidget", - "org.wordpress.WordPressNotificationServiceExtension", - "org.wordpress.WordPressNotificationContentExtension", - "org.wordpress.WordPressAllTimeWidget", - "org.wordpress.WordPressThisWeekWidget"]) - end - -######################################################################## -# Helper Lanes -######################################################################## - desc "Get a list of pull request from `start_tag` to the current state" - lane :get_pullrequests_list do | options | - get_prs_list(repository:GHHELPER_REPO, start_tag:"#{options[:start_tag]}", report_path:"#{File.expand_path('~')}/wpios_prs_list.txt") - end - -end diff --git a/Scripts/fastlane/Matchfile b/Scripts/fastlane/Matchfile deleted file mode 100644 index 038edec0f4e6..000000000000 --- a/Scripts/fastlane/Matchfile +++ /dev/null @@ -1,6 +0,0 @@ -# This Matchfile has the shared properties used for all signing types - -# Store certs/profiles encrypted in Google Cloud -storage_mode("google_cloud") -google_cloud_bucket_name("a8c-fastlane-match") -google_cloud_keys_file("../.configure-files/google_cloud_keys.json") diff --git a/Scripts/fastlane/Pluginfile b/Scripts/fastlane/Pluginfile deleted file mode 100644 index a42e93be1c00..000000000000 --- a/Scripts/fastlane/Pluginfile +++ /dev/null @@ -1,11 +0,0 @@ -# Autogenerated by fastlane -# -# Ensure this file is checked in to source control! - -group :screenshots, optional: true do - gem 'rmagick', '~> 3.2.0' -end - -gem 'fastlane-plugin-wpmreleasetoolkit', git: 'https://github.com/wordpress-mobile/release-toolkit', tag: '0.9.0' -gem 'fastlane-plugin-sentry' -gem 'fastlane-plugin-appcenter', '1.7.1' diff --git a/Scripts/fastlane/ScreenshotFastfile b/Scripts/fastlane/ScreenshotFastfile deleted file mode 100644 index f065aa335cda..000000000000 --- a/Scripts/fastlane/ScreenshotFastfile +++ /dev/null @@ -1,154 +0,0 @@ -require 'fileutils' - -default_platform(:ios) - -platform :ios do -######################################################################## -# Screenshot Lanes -######################################################################## - ##################################################################################### - # screenshots - # ----------------------------------------------------------------------------------- - # This lane generates the localised screenshots. - # It is the same as running bundle exec fastlane snapshot, but ensures that the app - # is only built once. - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane screenshots - # - # Example: - # bundle exec fastlane screenshots - ##################################################################################### - desc "Generate localised screenshots" - lane :screenshots do |options| - fastlane_directory = File.expand_path File.dirname(__FILE__) - derived_data_path = File.join(fastlane_directory, "DerivedData") - - Dir.chdir("../../") do - FileUtils.rm_rf('Pods') - end - - sh('bundle exec pod install') - FileUtils.rm_rf(derived_data_path) - - scan( - workspace: File.join(fastlane_directory, "../../WordPress.xcworkspace"), - scheme: "WordPressScreenshotGeneration", - build_for_testing: true, - derived_data_path: derived_data_path, - ) - - # By default, clear previous screenshots - should_clear_previous_screenshots = true - languages = "da de-DE en-AU en-CA en-GB en-US es-ES fr-FR id it ja ko no nl-NL pt-BR pt-PT ru sv th tr zh-Hans zh-Hant".split(" ") - - # Allow creating screenshots for just one languages - if options[:language] != nil - languages.keep_if { |language| - language.casecmp(options[:language]) == 0 - } - - # Don't clear, because we might just be fixing one locale - should_clear_previous_screenshots = false - end - - puts languages - - capture_ios_screenshots( - test_without_building: true, - derived_data_path: derived_data_path, - languages: languages, - clear_previous_screenshots: should_clear_previous_screenshots, - ) - end - - ##################################################################################### - # create_promo_screenshots - # ----------------------------------------------------------------------------------- - # This lane generates the promo screenshots. - # Source plain screenshots are supposed to be in the screenshots_orig folder - # If this folder doesn't exist, the system will ask to use the standard screenshot - # folder. If the user confirms, the pictures in the screenshots folder will be - # copied to a new screenshots_orig folder. - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane create_promo_screenshots - # - # Example: - # bundle exec fastlane create_promo_screenshots - ##################################################################################### - desc "Creates promo screenshots" - lane :create_promo_screenshots do |options| - - # Run screenshots generator tool - # All file paths are relative to the `fast file`. - promo_screenshots( - orig_folder: "screenshots", - metadata_folder: "appstoreres/metadata", - output_folder: File.join(Dir.pwd, "/promo_screenshots"), - force: options[:force], - ) - end - - ##################################################################################### - # download_promo_strings - # ----------------------------------------------------------------------------------- - # This lane downloads the promo strings to use for the creation of the enhanced - # screenshots. - # ----------------------------------------------------------------------------------- - # Usage: - # bundle exec fastlane download_promo_strings - # - # Example: - # bundle exec fastlane download_promo_strings - ##################################################################################### - desc "Downloads translated promo strings from GlotPress" - lane :download_promo_strings do |options| - files = { - "app_store_screenshot-1" => {desc: "app_store_screenshot_2.txt"}, - "app_store_screenshot-2" => {desc: "app_store_screenshot_5.txt"}, - "app_store_screenshot-3" => {desc: "app_store_screenshot_3.txt"}, - "app_store_screenshot-4" => {desc: "app_store_screenshot_1.txt"}, - "app_store_screenshot-5" => {desc: "app_store_screenshot_4.txt"}, - - "enhanced_app_store_screenshot-1" => {desc: "app_store_screenshot_1.html"}, - "enhanced_app_store_screenshot-2" => {desc: "app_store_screenshot_2.html"}, - "enhanced_app_store_screenshot-3" => {desc: "app_store_screenshot_3.html"}, - "enhanced_app_store_screenshot-4" => {desc: "app_store_screenshot_4.html"}, - "enhanced_app_store_screenshot-5" => {desc: "app_store_screenshot_5.html"}, - "enhanced_app_store_screenshot-6" => {desc: "app_store_screenshot_6.html"} - } - - metadata_locales = [ - ["en-gb", "en-US"], - ["en-gb", "en-GB"], - ["en-ca", "en-CA"], - ["en-au", "en-AU"], - ["da", "da"], - ["de", "de-DE"], - ["es", "es-ES"], - ["fr", "fr-FR"], - ["id", "id"], - ["it", "it"], - ["ja", "ja"], - ["ko", "ko"], - ["nl", "nl-NL"], - ["nb", "no"], - ["pt-br", "pt-BR"], - ["pt", "pt-PT"], - ["ru", "ru"], - ["sv", "sv"], - ["th", "th"], - ["tr", "tr"], - ["zh-cn", "zh-Hans"], - ["zh-tw", "zh-Hant"], - ] - - gp_downloadmetadata(project_url: "https://translate.wordpress.org/projects/apps/ios/release-notes/", - target_files: files, - locales: metadata_locales, - source_locale: "en-US", - download_path: "./fastlane/appstoreres/metadata") - end - -end diff --git a/Scripts/fastlane/Snapfile b/Scripts/fastlane/Snapfile deleted file mode 100644 index 84d79a4ebcd3..000000000000 --- a/Scripts/fastlane/Snapfile +++ /dev/null @@ -1,60 +0,0 @@ -# Uncomment the lines below you want to change by removing the # in the beginning -# Verify script has credentials - -fastlane_directory = File.expand_path File.dirname(__FILE__) -# __FILE__ can return different results when this file is required or used directly -# This allows it to work in all cases -if File.basename(fastlane_directory) != "fastlane" - fastlane_directory = File.join fastlane_directory, "fastlane" -end - -# A list of devices you want to take the screenshots from -devices([ - "iPhone Xs Max", - "iPhone 8 Plus", - "iPad Pro (12.9-inch) (2nd generation)", - "iPad Pro (12.9-inch) (3rd generation)", -]) - -# Where should the resulting screenshots be stored? -output_directory File.join fastlane_directory, "screenshots" - -scheme "WordPressScreenshotGeneration" - -# clear_previous_screenshots true # remove the '#'' to clear all previously generated screenshots before creating new ones - -# Where is your project (or workspace)? Provide the full path here -workspace File.join fastlane_directory, "../../WordPress.xcworkspace" - -# Since Fastlane searches recursively from the current directory for the helper (Scripts/ or fastlane/), -# this check will always fail for us -skip_helper_version_check true - -reinstall_app true -erase_simulator true -localize_simulator true -concurrent_simulators false -clear_previous_screenshots true - -# By default, the latest version should be used automatically. If you want to change it, do it here -# ios_version '8.1' - -# Custom Callbacks - -# setup_for_device_change do |device| -# puts "Preparing device: #{device}" -# end - -# setup_for_language_change do |lang, device| -# puts "Running #{lang} on #{device}" -# system("./popuplateDatabase.sh") -# end - -# teardown_language do |lang, device| -# puts "Finished with #{lang} on #{device}" -# end - -# teardown_device do |device| -# puts "Cleaning device #{device}" -# system("./cleanup.sh") -# end diff --git a/Scripts/fastlane/actions/ios_update_metadata_source.rb b/Scripts/fastlane/actions/ios_update_metadata_source.rb deleted file mode 100644 index 1dfb0d4b847a..000000000000 --- a/Scripts/fastlane/actions/ios_update_metadata_source.rb +++ /dev/null @@ -1,83 +0,0 @@ -module Fastlane - module Actions - class IosUpdateMetadataSourceAction < Action - def self.run(params) - # Check local repo status - other_action.ensure_git_status_clean() - - other_action.gp_update_metadata_source(po_file_path: params[:po_file_path], - source_files: params[:source_files], - release_version: params[:release_version]) - - Action.sh("git add #{params[:po_file_path]}") - params[:source_files].each do | key, file | - Action.sh("git add #{file}") - end - - repo_status = Actions.sh("git status --porcelain") - repo_clean = repo_status.empty? - if (!repo_clean) then - Action.sh("git commit -m \"Update metadata strings\"") - Action.sh("git push") - end - end - - ##################################################### - # @!group Documentation - ##################################################### - - def self.description - "Updates the AppStoreStrings.po file with the data from text source files" - end - - def self.details - "Updates the AppStoreStrings.po file with the data from text source files" - end - - def self.available_options - # Define all options your action supports. - - # Below a few examples - [ - FastlaneCore::ConfigItem.new(key: :po_file_path, - env_name: "FL_IOS_UPDATE_METADATA_SOURCE_PO_FILE_PATH", - description: "The path of the .po file to update", - is_string: true, - verify_block: proc do |value| - UI.user_error!("No .po file path for UpdateMetadataSourceAction given, pass using `po_file_path: 'file path'`") unless (value and not value.empty?) - UI.user_error!("Couldn't find file at path '#{value}'") unless File.exist?(value) - end), - FastlaneCore::ConfigItem.new(key: :release_version, - env_name: "FL_IOS_UPDATE_METADATA_SOURCE_RELEASE_VERSION", - description: "The release version of the app (to use to mark the release notes)", - verify_block: proc do |value| - UI.user_error!("No relase version for UpdateMetadataSourceAction given, pass using `release_version: 'version'`") unless (value and not value.empty?) - end), - FastlaneCore::ConfigItem.new(key: :source_files, - env_name: "FL_IOS_UPDATE_METADATA_SOURCE_SOURCE_FILES", - description: "The hash with the path to the source files and the key to use to include their content", - is_string: false, - verify_block: proc do |value| - UI.user_error!("No source file hash for UpdateMetadataSourceAction given, pass using `source_files: 'source file hash'`") unless (value and not value.empty?) - end) - ] - end - - def self.output - - end - - def self.return_value - - end - - def self.authors - ["loremattei"] - end - - def self.is_supported?(platform) - platform == :ios - end - end - end -end diff --git a/Scripts/fastlane/appstoreres/assets/ipad-pro.png b/Scripts/fastlane/appstoreres/assets/ipad-pro.png deleted file mode 100644 index 53a8b3f910a8..000000000000 Binary files a/Scripts/fastlane/appstoreres/assets/ipad-pro.png and /dev/null differ diff --git a/Scripts/fastlane/appstoreres/assets/iphone-8.png b/Scripts/fastlane/appstoreres/assets/iphone-8.png deleted file mode 100644 index c780629f0227..000000000000 Binary files a/Scripts/fastlane/appstoreres/assets/iphone-8.png and /dev/null differ diff --git a/Scripts/fastlane/appstoreres/assets/iphone-x.png b/Scripts/fastlane/appstoreres/assets/iphone-x.png deleted file mode 100644 index 29f8428eb320..000000000000 Binary files a/Scripts/fastlane/appstoreres/assets/iphone-x.png and /dev/null differ diff --git a/Scripts/fastlane/appstoreres/assets/style.css b/Scripts/fastlane/appstoreres/assets/style.css deleted file mode 100644 index fca02b4cfbbf..000000000000 --- a/Scripts/fastlane/appstoreres/assets/style.css +++ /dev/null @@ -1,7 +0,0 @@ -*{ - font-family: 'NotoSans'; -} - -strong{ - font-family: 'NotoSans-Bold'; -} diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_1.txt deleted file mode 100644 index 219b92d6cf47..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Teil dein Ideen -mit der Welt diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_2.txt deleted file mode 100644 index 178891bf8443..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Genieße deine -Lieblings-Websites diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.html b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.html deleted file mode 100644 index 809fd09e8442..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.html +++ /dev/null @@ -1,2 +0,0 @@ -<strong>Prüfe</strong> in Echtzeit, was -passiert diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.txt deleted file mode 100644 index f9ece98755c9..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Verwalte deine Website -immer und überall diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_4.txt deleted file mode 100644 index d539b6396107..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -Alle Statistiken -in deiner Hand diff --git a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_5.txt deleted file mode 100644 index 89c731fa3722..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/de-DE/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Erhalte -Benachrichtigungen in Echtzeit diff --git a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_1.txt deleted file mode 100644 index f05f62365f9c..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Share your ideas -with the world diff --git a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_2.txt deleted file mode 100644 index def49ec14b24..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Enjoy your -favourite sites diff --git a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_3.txt deleted file mode 100644 index d59c84b907e9..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Manage your site -everywhere you go diff --git a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_4.txt deleted file mode 100644 index 63b0655fe4b2..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -All the stats -in your hand diff --git a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_5.txt deleted file mode 100644 index f9384a7f75d8..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-AU/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Get notified -in real-time diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_1.txt deleted file mode 100644 index f05f62365f9c..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Share your ideas -with the world diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_2.txt deleted file mode 100644 index def49ec14b24..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Enjoy your -favourite sites diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_3.txt deleted file mode 100644 index d59c84b907e9..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Manage your site -everywhere you go diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_4.txt deleted file mode 100644 index 63b0655fe4b2..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -All the stats -in your hand diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_5.txt deleted file mode 100644 index f9384a7f75d8..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Get notified -in real-time diff --git a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_6.html b/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_6.html deleted file mode 100644 index f41587280498..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-CA/app_store_screenshot_6.html +++ /dev/null @@ -1 +0,0 @@ -<strong>Write</strong> without compromises diff --git a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_1.txt deleted file mode 100644 index f05f62365f9c..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Share your ideas -with the world diff --git a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_2.txt deleted file mode 100644 index def49ec14b24..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Enjoy your -favourite sites diff --git a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_3.txt deleted file mode 100644 index d59c84b907e9..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Manage your site -everywhere you go diff --git a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_4.txt deleted file mode 100644 index 63b0655fe4b2..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -All the stats -in your hand diff --git a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_5.txt deleted file mode 100644 index 41f0eeade0e1..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-GB/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Get notified -in real time diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_1.txt deleted file mode 100644 index f05f62365f9c..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Share your ideas -with the world diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_2.txt deleted file mode 100644 index 6be99561adab..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Enjoy your -favorite sites diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_3.txt deleted file mode 100644 index d59c84b907e9..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Manage your site -everywhere you go diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_4.txt deleted file mode 100644 index 63b0655fe4b2..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -All the stats -in your hand diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_5.txt deleted file mode 100644 index f9384a7f75d8..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Get notified -in real-time diff --git a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_6.html b/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_6.html deleted file mode 100644 index f41587280498..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/en-US/app_store_screenshot_6.html +++ /dev/null @@ -1 +0,0 @@ -<strong>Write</strong> without compromises diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_1.txt deleted file mode 100644 index 31d5d067bd62..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Comparte tus ideas -con el mundo diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.html b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.html deleted file mode 100644 index 1c136a4f3a74..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.html +++ /dev/null @@ -1,2 +0,0 @@ -<strong>Analiza</strong> que les encanta -a tus visitantes diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.txt deleted file mode 100644 index 150b0e7abd3a..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Disfruta de tus -sitios favoritos diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_3.txt deleted file mode 100644 index 43cafbb68c03..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Gestiona tu sitio -desde donde estés diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_4.txt deleted file mode 100644 index f93bd8587bff..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -Todas las estadísticas -en tu mano diff --git a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_5.txt deleted file mode 100644 index 199d03e49352..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/es-ES/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Recibe notificaciones -en tiempo real diff --git a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_1.txt deleted file mode 100644 index 6f3d361510b2..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Partagez vos idées -avec le monde entier diff --git a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_2.txt deleted file mode 100644 index a8f70d933e89..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Profitez de -vos sites préférés diff --git a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_3.txt deleted file mode 100644 index a8c7e16d083e..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Gérez votre site -où que vous soyez diff --git a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_4.txt deleted file mode 100644 index f2e996beefe7..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -Toutes les statistiques -à portée de main diff --git a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_5.txt deleted file mode 100644 index 8119ac950a4c..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/fr-FR/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Recevez les notifications -en temps réel diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_1.txt deleted file mode 100644 index 12fe0921e1dd..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Bagikan ide Anda -kepada dunia diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_2.txt deleted file mode 100644 index 40e19ba04a0e..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Nikmati -situs favorit Anda diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.html b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.html deleted file mode 100644 index 27ef85ed423d..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.html +++ /dev/null @@ -1,2 +0,0 @@ -<strong>Ketahui</strong> kabar terkini -secara real-time diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.txt deleted file mode 100644 index 6cc6996d4053..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Kelola situs Anda -dari mana saja diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_4.txt deleted file mode 100644 index 9584564e95b2..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -Semua statistik -dalam kendali Anda diff --git a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_5.txt deleted file mode 100644 index 3915cf816508..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/id/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Dapatkan pemberitahuan -secara real-time diff --git a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_1.txt deleted file mode 100644 index 1dc10e52c2d0..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Condividi idee -con tutto il mondo diff --git a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_2.txt deleted file mode 100644 index 8309cdf033c3..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Goditi i -siti preferiti diff --git a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_3.txt deleted file mode 100644 index 430c02559daf..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Gestisci il sito -ovunque tu vada diff --git a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_4.txt deleted file mode 100644 index d7d9cbbfbd2d..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -Tutte le statistiche -a portata di mano diff --git a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_5.txt deleted file mode 100644 index c460e6f6c455..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/it/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Notifiche in -tempo reale diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_1.txt deleted file mode 100644 index b6a7a9e1cb11..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -アイディアを -世界と共有 diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_2.txt deleted file mode 100644 index 3051711fb9c2..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -お気に入りの -サイトを楽しむ diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_3.txt deleted file mode 100644 index 95a69882c50e..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -どこからでも -サイトを管理 diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.html b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.html deleted file mode 100644 index 3cd302344249..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.html +++ /dev/null @@ -1,2 +0,0 @@ -<strong>共有</strong>元 -制限なし diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.txt deleted file mode 100644 index fe97078b5d80..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -統計情報を -いつでもチェック diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.html b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.html deleted file mode 100644 index e012799ba726..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.html +++ /dev/null @@ -1,2 +0,0 @@ -<strong>キャプチャ</strong>案 -外出先 diff --git a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.txt deleted file mode 100644 index 0fa5e98dbf2e..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ja/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -リアルタイムに -通知を受信 diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_1.txt deleted file mode 100644 index 33fbca80dd17..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_1.txt +++ /dev/null @@ -1 +0,0 @@ -전 세계 사람들과 아이디어 공유 diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_2.txt deleted file mode 100644 index 3006f35cee2f..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_2.txt +++ /dev/null @@ -1 +0,0 @@ -즐겨 찾는 사이트 즐기기 diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_3.txt deleted file mode 100644 index 9c12bf253391..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_3.txt +++ /dev/null @@ -1 +0,0 @@ -어디에서든 사이트 관리 diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_4.txt deleted file mode 100644 index 1028309d6c22..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_4.txt +++ /dev/null @@ -1 +0,0 @@ -모든 통계를 간편하게 확인 diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_5.txt deleted file mode 100644 index 03ffb9d129f5..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_5.txt +++ /dev/null @@ -1 +0,0 @@ -실시간 알림 받기 diff --git a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_6.html b/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_6.html deleted file mode 100644 index 423330b3c25f..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ko/app_store_screenshot_6.html +++ /dev/null @@ -1 +0,0 @@ -타협하지 않고 <strong>작성</strong> diff --git a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_1.txt deleted file mode 100644 index 1bda0b27f086..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Deel je ideeën -met de wereld diff --git a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_2.txt deleted file mode 100644 index cdc021d4ba22..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Geniet van je -favoriete sites diff --git a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_3.txt deleted file mode 100644 index 8e48d12d0447..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Beheer je site -waar je ook bent diff --git a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_4.txt deleted file mode 100644 index 451d6cb05f0e..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -Alle statistieken -in je handpalm diff --git a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_5.txt deleted file mode 100644 index c3ea919918bd..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/nl-NL/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Ontvang -realtime meldingen diff --git a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_1.txt deleted file mode 100644 index d7092d266d33..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Del dine ideer -med verden diff --git a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_2.txt deleted file mode 100644 index 095a1dfa5f64..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Ha glede av dine -favorittnettsteder diff --git a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_3.txt deleted file mode 100644 index 7d380691e1d2..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Administrer ditt nettsted -hvor enn du går diff --git a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_4.txt deleted file mode 100644 index 20672437f718..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -All statistikken -i din hånd diff --git a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_5.txt deleted file mode 100644 index 9070936267dc..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/no/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Bli varslet -i sanntid diff --git a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_1.txt deleted file mode 100644 index b60fe496fba4..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Compartilhe suas ideias -com o mundo diff --git a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_2.txt deleted file mode 100644 index 04adaf2ffb8d..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Divirta-se com -seus sites favoritos diff --git a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_3.txt deleted file mode 100644 index 74809ba7474a..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Gerencie seu site -de qualquer lugar diff --git a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_4.txt deleted file mode 100644 index 2ad588d5428f..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -Todos as estatísticas -em suas mãos diff --git a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_5.txt deleted file mode 100644 index 4e0b2af21dec..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/pt-BR/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Notificações -em tempo real diff --git a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_1.txt deleted file mode 100644 index a1fdb665884c..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Partilhe as suas ideias -com o mundo diff --git a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_4.txt deleted file mode 100644 index 001d56880250..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -Todas as estatísticas -na sua mão diff --git a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_5.txt deleted file mode 100644 index 47de92a1dfac..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/pt-PT/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Receba notificações -em tempo real diff --git a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_1.txt deleted file mode 100644 index 59900c172464..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Делитесь идеями -с миром diff --git a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_2.txt deleted file mode 100644 index 41e4abe05b1c..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Наслаждайтесь -любимыми сайтами diff --git a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_3.txt deleted file mode 100644 index e67131e7a5cd..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Управляйте сайтом -отовсюду diff --git a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_4.txt deleted file mode 100644 index eeed21c0f3e2..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -Вся статистика -в ваших руках diff --git a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_5.txt deleted file mode 100644 index 9adce6ad0dbe..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/ru/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Получайте уведомления -в реальном времени diff --git a/Scripts/fastlane/appstoreres/metadata/source/description.txt b/Scripts/fastlane/appstoreres/metadata/source/description.txt deleted file mode 100644 index d37d529106ec..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/source/description.txt +++ /dev/null @@ -1,9 +0,0 @@ -Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favorite photos and videos, view stats and reply to comments. - -With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from. - -WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/. - -WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher. - -Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS.\n \ No newline at end of file diff --git a/Scripts/fastlane/appstoreres/metadata/source/keywords.txt b/Scripts/fastlane/appstoreres/metadata/source/keywords.txt deleted file mode 100644 index 9b6f8ad531d5..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/source/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design\n \ No newline at end of file diff --git a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_1.txt deleted file mode 100644 index 5866dd31ce88..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Enjoy your -favorite sites \ No newline at end of file diff --git a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_2.txt deleted file mode 100644 index 46c365499355..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Get notified -in real-time \ No newline at end of file diff --git a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_3.txt deleted file mode 100644 index d6ef87810d30..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Manage your site -everywhere you go \ No newline at end of file diff --git a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_4.txt deleted file mode 100644 index 7bd2b359b0a9..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -Share your ideas -with the world \ No newline at end of file diff --git a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_5.txt deleted file mode 100644 index 3e846b759f5a..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/source/promo_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -All the stats -in your hand \ No newline at end of file diff --git a/Scripts/fastlane/appstoreres/metadata/source/subtitle.txt b/Scripts/fastlane/appstoreres/metadata/source/subtitle.txt deleted file mode 100644 index b34d71b64f35..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/source/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Manage your website anywhere \ No newline at end of file diff --git a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_1.txt deleted file mode 100644 index 634a9cd85d5d..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Dela dina idéer -med världen diff --git a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_2.txt deleted file mode 100644 index f99f138fe0b4..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Läs dina -favoriter. diff --git a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_3.txt deleted file mode 100644 index 866e5098cbe0..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Hantera din webbplats -varsomhelst diff --git a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_4.txt deleted file mode 100644 index 8e006bf59b15..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -All statistik -till hands diff --git a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_5.txt deleted file mode 100644 index d345b679698b..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/sv/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Få notiser -i realtid diff --git a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_1.txt deleted file mode 100644 index 6213e5d92728..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -Fikirlerinizi dünya -ile paylaşın diff --git a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_2.txt deleted file mode 100644 index 65f26734f78f..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -Beğendiğiniz sitelerin -tadını çıkarın diff --git a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_3.txt deleted file mode 100644 index 72f1ba4d4c5c..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -Gittiğiniz her yerde -sitenizi yönetin diff --git a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_4.txt deleted file mode 100644 index 3b773e4e2345..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -Tüm istatistikler -avucunuzda diff --git a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_5.txt deleted file mode 100644 index af489559eaec..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/tr/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -Gerçek zamanlı -olarak bildirim alın diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.html b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.html deleted file mode 100644 index adf9f281b0de..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.html +++ /dev/null @@ -1,2 +0,0 @@ -<strong>创建</strong>精美的 -文章和页面 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.txt deleted file mode 100644 index 089802b482b4..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -与全世界 -分享您的观点 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.html b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.html deleted file mode 100644 index 50abbbe3d2b4..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.html +++ /dev/null @@ -1,2 +0,0 @@ -<strong>跟踪</strong> -访客喜好 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.txt deleted file mode 100644 index fbde724420ba..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -尽情访问 -您喜爱的站点 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.html b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.html deleted file mode 100644 index e2da0c01b9c3..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.html +++ /dev/null @@ -1,2 +0,0 @@ -实时<strong>查看</strong> -动态 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.txt deleted file mode 100644 index 621eaf28020b..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -随时随地 -管理您的站点 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_4.txt deleted file mode 100644 index 66947743ca56..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -掌握所有 -统计信息 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_5.txt deleted file mode 100644 index 4c59907f5b63..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -实时 -获取通知 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_6.html b/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_6.html deleted file mode 100644 index 1166dd9acf0d..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hans/app_store_screenshot_6.html +++ /dev/null @@ -1 +0,0 @@ -<strong>轻松撰写</strong>,自由无阻 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_1.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_1.txt deleted file mode 100644 index fa8b7484f5e4..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_1.txt +++ /dev/null @@ -1,2 +0,0 @@ -和全世界 -分享你的想法 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_2.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_2.txt deleted file mode 100644 index bfd0d3eac94d..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_2.txt +++ /dev/null @@ -1,2 +0,0 @@ -盡情瀏覽 -你喜愛的網站 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_3.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_3.txt deleted file mode 100644 index b3bb3cffabe2..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_3.txt +++ /dev/null @@ -1,2 +0,0 @@ -隨時隨地 -管理你的網站 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_4.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_4.txt deleted file mode 100644 index bdef77677f76..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_4.txt +++ /dev/null @@ -1,2 +0,0 @@ -所有統計資料 -盡在你掌握 diff --git a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_5.txt b/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_5.txt deleted file mode 100644 index 3bcf65e4d957..000000000000 --- a/Scripts/fastlane/appstoreres/metadata/zh-Hant/app_store_screenshot_5.txt +++ /dev/null @@ -1,2 +0,0 @@ -即時收到 -通知 diff --git a/Scripts/fastlane/download_metadata.swift b/Scripts/fastlane/download_metadata.swift deleted file mode 100755 index 735856467129..000000000000 --- a/Scripts/fastlane/download_metadata.swift +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env swift - -import Foundation - -let glotPressSubtitleKey = "app_store_subtitle" -let glotPressWhatsNewKey = "v14.3-whats-new" -let glotPressDescriptionKey = "app_store_desc" -let glotPressKeywordsKey = "app_store_keywords" -let baseFolder = "./metadata" - -// iTunes Connect language code: GlotPress code -let languages = [ - "da": "da", - "de-DE": "de", - "en-AU": "en-au", - "en-CA": "en-ca", - "en-GB": "en-gb", - "default": "en-us", // Technically not a real GlotPress language - "en-US": "en-us", // Technically not a real GlotPress language - "es-ES": "es", - "fr-FR": "fr", - "id": "id", - "it": "it", - "ja": "ja", - "ko": "ko", - "nl-NL": "nl", - "no": "nb", - "pt-BR": "pt-br", - "pt-PT": "pt", - "ru": "ru", - "sv": "sv", - "th": "th", - "tr": "tr", - "zh-Hans": "zh-cn", - "zh-Hant": "zh-tw", -] - -func downloadTranslation(languageCode: String, folderName: String) { - let languageCodeOverride = languageCode == "en-us" ? "en-gb" : languageCode - let glotPressURL = "https://translate.wordpress.org/projects/apps/ios/release-notes/\(languageCodeOverride)/default/export-translations?format=json" - let requestURL: URL = URL(string: glotPressURL)! - let urlRequest: URLRequest = URLRequest(url: requestURL) - let session = URLSession.shared - - let sema = DispatchSemaphore( value: 0) - - print("Downloading Language: \(languageCode)") - - let task = session.dataTask(with: urlRequest) { - (data, response, error) -> Void in - - defer { - sema.signal() - } - - guard let data = data else { - print(" Invalid data downloaded.") - return - } - - guard let json = try? JSONSerialization.jsonObject(with: data, options: []), - let jsonDict = json as? [String: Any] else { - print(" JSON was not returned") - return - } - - var subtitle: String? - var whatsNew: String? - var keywords: String? - var storeDescription: String? - - jsonDict.forEach({ (key: String, value: Any) in - - guard let index = key.index(of: Character(UnicodeScalar(0004))) else { - return - } - - let keyFirstPart = String(key[..<index]) - - guard let value = value as? [String], - let firstValue = value.first else { - print(" No translation for \(keyFirstPart)") - return - } - - var originalLanguage = String(key[index...]) - originalLanguage.remove(at: originalLanguage.startIndex) - let translation = languageCode == "en-us" ? originalLanguage : firstValue - - switch keyFirstPart { - case glotPressSubtitleKey: - subtitle = translation - case glotPressKeywordsKey: - keywords = translation - case glotPressWhatsNewKey: - whatsNew = translation - case glotPressDescriptionKey: - storeDescription = translation - default: - print(" Unknown key: \(keyFirstPart)") - } - }) - - let languageFolder = "\(baseFolder)/\(folderName)" - - let fileManager = FileManager.default - try? fileManager.createDirectory(atPath: languageFolder, withIntermediateDirectories: true, attributes: nil) - - /// This sort of hack-ey – we're not actually downloading anything here, but the idea is - /// to keep all of the metadata generation code in the same place. By keeping this in the - /// same file, it should be easier to find, and update if necessary. - let marketingURL = "https://apps.wordpress.com/mobile/" - - do { - try subtitle?.write(toFile: "\(languageFolder)/subtitle.txt", atomically: true, encoding: .utf8) - try whatsNew?.write(toFile: "\(languageFolder)/release_notes.txt", atomically: true, encoding: .utf8) - try keywords?.write(toFile: "\(languageFolder)/keywords.txt", atomically: true, encoding: .utf8) - try storeDescription?.write(toFile: "\(languageFolder)/description.txt", atomically: true, encoding: .utf8) - - // Don't add the marketing URL unless there's other metadata that's been downloaded - if try fileManager.contentsOfDirectory(atPath: languageFolder).count > 0 { - try marketingURL.write(toFile: "\(languageFolder)/marketing_url.txt", atomically: true, encoding: .utf8) - } - } catch { - print(" Error writing: \(error)") - } - } - - task.resume() - sema.wait() -} - -func deleteExistingMetadata() { - let fileManager = FileManager.default - let url = URL(fileURLWithPath: baseFolder, isDirectory: true) - try? fileManager.removeItem(at: url) - try? fileManager.createDirectory(at: url, withIntermediateDirectories: false) -} - - -deleteExistingMetadata() - -languages.forEach( { (key: String, value: String) in - downloadTranslation(languageCode: value, folderName: key) -}) - diff --git a/Scripts/fastlane/metadata/de-DE/description.txt b/Scripts/fastlane/metadata/de-DE/description.txt deleted file mode 100644 index 807442f80457..000000000000 --- a/Scripts/fastlane/metadata/de-DE/description.txt +++ /dev/null @@ -1,10 +0,0 @@ -Verwalte oder kreiere deinen WordPress-Blog oder deine Website direkt auf deinem iOS-Gerät. Erstelle und editiere Beiträge und Seiten, lade deine Liebelingsfotos und Videos hoch, sieh dir Statistiken an und antworte auf Kommentare. - -Mit WordPress für iOS hältst du das Werkzeug, um Texte zu veröffentlichen direkt in deiner Hand. Entwirf einen spontanen Haiku auf deiner Couch. Schieß ein Foto in der Mittagspause und zeig es allen. Antworte auf einen neuen Kommentar oder schau dir an, aus welchen Ländern deine neuesten Besucher kommen. - -WordPress für iOS ist ein OpenSource-Projekt, an dessen Entwicklung du dich beteiligen kannst. Um mehr zu erfahren, schau unter https://apps.wordpress.com/contribute/ nach. - -WordPress für iOS unterstützt WordPress.com und selbst gehostete WordPress.org-Websites ab WordPress-Version 4.0. - -Wenn du Hilfe zur App brauchst, findest du Informationen im den Foren unter https://ios.forums.wordpress.org/. Du kannst aber auch einen Tweet an us @WordPressiOS schicken. - diff --git a/Scripts/fastlane/metadata/de-DE/keywords.txt b/Scripts/fastlane/metadata/de-DE/keywords.txt deleted file mode 100644 index 8961611da89b..000000000000 --- a/Scripts/fastlane/metadata/de-DE/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -sozial,netzwerk,notizen,jetpack,fotos,schreiben,geotagging,medien,blog,wordpress,website,design diff --git a/Scripts/fastlane/metadata/de-DE/marketing_url.txt b/Scripts/fastlane/metadata/de-DE/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/de-DE/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/de-DE/subtitle.txt b/Scripts/fastlane/metadata/de-DE/subtitle.txt deleted file mode 100644 index b22aef49d3a7..000000000000 --- a/Scripts/fastlane/metadata/de-DE/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Verwalte deine Website überall \ No newline at end of file diff --git a/Scripts/fastlane/metadata/default/description.txt b/Scripts/fastlane/metadata/default/description.txt deleted file mode 100644 index 5b077b5f6cc1..000000000000 --- a/Scripts/fastlane/metadata/default/description.txt +++ /dev/null @@ -1,10 +0,0 @@ -Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favorite photos and videos, view stats and reply to comments. - -With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from. - -WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/. - -WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher. - -Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS. - diff --git a/Scripts/fastlane/metadata/default/keywords.txt b/Scripts/fastlane/metadata/default/keywords.txt deleted file mode 100644 index ab6cbfc0f9a1..000000000000 --- a/Scripts/fastlane/metadata/default/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design diff --git a/Scripts/fastlane/metadata/default/marketing_url.txt b/Scripts/fastlane/metadata/default/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/default/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/default/release_notes.txt b/Scripts/fastlane/metadata/default/release_notes.txt deleted file mode 100644 index 97442e5c2f83..000000000000 --- a/Scripts/fastlane/metadata/default/release_notes.txt +++ /dev/null @@ -1,16 +0,0 @@ -Commenting: -- Fixed a bug causing text selection to happen on the wrong line when editing comments. -- Fixed a bug causing HTML markup to display in comment content. -- Fixed an issue causing comments not to appear in the Reader. - -Publishing: -- You can now crop, zoom in/out, and rotate images in a post. -- The app now includes a desktop preview mode on iPhone, and Mobile preview on iPad. Post preview also has new navigation, “Open in Safari,” and Share options. -- Lots of block editor improvements: Added a long-press icon for inserting blocks before/after and an “Edit” button overlay on selected image blocks. The editor will retry to load images after connectivity issues. And the Gallery block now has support for image size options. -- We fixed a bug that could disable comments on a draft post when previewing it. - -Signup and Login -- Signup or login via magic link now supports multiple email clients. Tapping on the “Open Email” button will present a list of installed email clients to choose from. - -Reader -- The apps now support post reblogging! Tap the new “reblog” button in the post action bar to choose which of your sites to post to and open the editor of your choice with pre-populated content from the original post. diff --git a/Scripts/fastlane/metadata/default/subtitle.txt b/Scripts/fastlane/metadata/default/subtitle.txt deleted file mode 100644 index b34d71b64f35..000000000000 --- a/Scripts/fastlane/metadata/default/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Manage your website anywhere \ No newline at end of file diff --git a/Scripts/fastlane/metadata/en-AU/description.txt b/Scripts/fastlane/metadata/en-AU/description.txt deleted file mode 100644 index 4e4165e37344..000000000000 --- a/Scripts/fastlane/metadata/en-AU/description.txt +++ /dev/null @@ -1,10 +0,0 @@ -Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favourite photos and videos, view stats and reply to comments. - -With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from. - -WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/. - -WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher. - -Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS. - diff --git a/Scripts/fastlane/metadata/en-AU/keywords.txt b/Scripts/fastlane/metadata/en-AU/keywords.txt deleted file mode 100644 index ab6cbfc0f9a1..000000000000 --- a/Scripts/fastlane/metadata/en-AU/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design diff --git a/Scripts/fastlane/metadata/en-AU/marketing_url.txt b/Scripts/fastlane/metadata/en-AU/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/en-AU/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/en-AU/subtitle.txt b/Scripts/fastlane/metadata/en-AU/subtitle.txt deleted file mode 100644 index b34d71b64f35..000000000000 --- a/Scripts/fastlane/metadata/en-AU/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Manage your website anywhere \ No newline at end of file diff --git a/Scripts/fastlane/metadata/en-CA/description.txt b/Scripts/fastlane/metadata/en-CA/description.txt deleted file mode 100644 index 4e4165e37344..000000000000 --- a/Scripts/fastlane/metadata/en-CA/description.txt +++ /dev/null @@ -1,10 +0,0 @@ -Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favourite photos and videos, view stats and reply to comments. - -With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from. - -WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/. - -WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher. - -Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS. - diff --git a/Scripts/fastlane/metadata/en-CA/keywords.txt b/Scripts/fastlane/metadata/en-CA/keywords.txt deleted file mode 100644 index ab6cbfc0f9a1..000000000000 --- a/Scripts/fastlane/metadata/en-CA/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design diff --git a/Scripts/fastlane/metadata/en-CA/marketing_url.txt b/Scripts/fastlane/metadata/en-CA/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/en-CA/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/en-CA/subtitle.txt b/Scripts/fastlane/metadata/en-CA/subtitle.txt deleted file mode 100644 index b34d71b64f35..000000000000 --- a/Scripts/fastlane/metadata/en-CA/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Manage your website anywhere \ No newline at end of file diff --git a/Scripts/fastlane/metadata/en-GB/description.txt b/Scripts/fastlane/metadata/en-GB/description.txt deleted file mode 100644 index 4e4165e37344..000000000000 --- a/Scripts/fastlane/metadata/en-GB/description.txt +++ /dev/null @@ -1,10 +0,0 @@ -Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favourite photos and videos, view stats and reply to comments. - -With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from. - -WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/. - -WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher. - -Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS. - diff --git a/Scripts/fastlane/metadata/en-GB/keywords.txt b/Scripts/fastlane/metadata/en-GB/keywords.txt deleted file mode 100644 index ab6cbfc0f9a1..000000000000 --- a/Scripts/fastlane/metadata/en-GB/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design diff --git a/Scripts/fastlane/metadata/en-GB/marketing_url.txt b/Scripts/fastlane/metadata/en-GB/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/en-GB/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/en-GB/release_notes.txt b/Scripts/fastlane/metadata/en-GB/release_notes.txt deleted file mode 100644 index ba17bc253b3f..000000000000 --- a/Scripts/fastlane/metadata/en-GB/release_notes.txt +++ /dev/null @@ -1,16 +0,0 @@ -Commenting: -- Fixed a bug causing text selection to happen on the wrong line when editing comments. -- Fixed a bug causing HTML markup to display in comment content. -- Fixed an issue causing comments not to appear in the Reader. - -Publishing: -- You can now crop, zoom in/out and rotate images in a post. -- The app now includes a desktop preview mode on iPhone and Mobile preview on iPad. Post preview also has new navigation, “Open in Safari,” and Share options. -- Lots of block editor improvements: added a long-press icon for inserting blocks before/after and an “Edit” button overlay on selected image blocks. The editor will retry to load images after connectivity issues. And the Gallery block now has support for image size options. -- We fixed a bug that could disable comments on a draft post when previewing it. - -Signup and Login -- Signup or login via magic link now supports multiple email clients. Tapping on the “Open Email” button will present a list of installed email clients to choose from. - -Reader -- The apps now support post reblogging! Tap the new “reblog” button in the post action bar to choose which of your sites to post to and open the editor of your choice with pre-populated content from the original post. diff --git a/Scripts/fastlane/metadata/en-GB/subtitle.txt b/Scripts/fastlane/metadata/en-GB/subtitle.txt deleted file mode 100644 index b34d71b64f35..000000000000 --- a/Scripts/fastlane/metadata/en-GB/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Manage your website anywhere \ No newline at end of file diff --git a/Scripts/fastlane/metadata/en-US/description.txt b/Scripts/fastlane/metadata/en-US/description.txt deleted file mode 100644 index 5b077b5f6cc1..000000000000 --- a/Scripts/fastlane/metadata/en-US/description.txt +++ /dev/null @@ -1,10 +0,0 @@ -Manage or create your WordPress blog or website right from your iOS device: create and edit posts and pages, upload your favorite photos and videos, view stats and reply to comments. - -With WordPress for iOS, you have the power to publish in the palm of your hand. Draft a spontaneous haiku from the couch. Snap and post a photo on your lunch break. Respond to your latest comments, or check your stats to see what new countries today’s visitors are coming from. - -WordPress for iOS is an Open Source project, which means you too can contribute to its development. Learn more at https://apps.wordpress.com/contribute/. - -WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher. - -Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS. - diff --git a/Scripts/fastlane/metadata/en-US/keywords.txt b/Scripts/fastlane/metadata/en-US/keywords.txt deleted file mode 100644 index ab6cbfc0f9a1..000000000000 --- a/Scripts/fastlane/metadata/en-US/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design diff --git a/Scripts/fastlane/metadata/en-US/marketing_url.txt b/Scripts/fastlane/metadata/en-US/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/en-US/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/en-US/release_notes.txt b/Scripts/fastlane/metadata/en-US/release_notes.txt deleted file mode 100644 index 97442e5c2f83..000000000000 --- a/Scripts/fastlane/metadata/en-US/release_notes.txt +++ /dev/null @@ -1,16 +0,0 @@ -Commenting: -- Fixed a bug causing text selection to happen on the wrong line when editing comments. -- Fixed a bug causing HTML markup to display in comment content. -- Fixed an issue causing comments not to appear in the Reader. - -Publishing: -- You can now crop, zoom in/out, and rotate images in a post. -- The app now includes a desktop preview mode on iPhone, and Mobile preview on iPad. Post preview also has new navigation, “Open in Safari,” and Share options. -- Lots of block editor improvements: Added a long-press icon for inserting blocks before/after and an “Edit” button overlay on selected image blocks. The editor will retry to load images after connectivity issues. And the Gallery block now has support for image size options. -- We fixed a bug that could disable comments on a draft post when previewing it. - -Signup and Login -- Signup or login via magic link now supports multiple email clients. Tapping on the “Open Email” button will present a list of installed email clients to choose from. - -Reader -- The apps now support post reblogging! Tap the new “reblog” button in the post action bar to choose which of your sites to post to and open the editor of your choice with pre-populated content from the original post. diff --git a/Scripts/fastlane/metadata/en-US/subtitle.txt b/Scripts/fastlane/metadata/en-US/subtitle.txt deleted file mode 100644 index b34d71b64f35..000000000000 --- a/Scripts/fastlane/metadata/en-US/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Manage your website anywhere \ No newline at end of file diff --git a/Scripts/fastlane/metadata/es-ES/description.txt b/Scripts/fastlane/metadata/es-ES/description.txt deleted file mode 100644 index 0ae2ce09c78d..000000000000 --- a/Scripts/fastlane/metadata/es-ES/description.txt +++ /dev/null @@ -1,9 +0,0 @@ -Crea y administra tu sitio web o blog de WordPress directamente desde tu dispositivo iOS: crea y edita cualquier entrada o página, sube tus fotos y videos favoritos, accede a las estadísticas o responde a los comentarios fácilmente. - -Con WordPress para iOS, podrás publicar todo tu contenido desde la palma de tu mano. Escribe desde tu sofá el borrador de ese haiku que no te quitas de la cabeza. Sube y comparte en un momento esa increíble foto que hiciste durante el descanso en el trabajo. Responde a los últimos comentarios que te hayan dejado o descubre a qué nuevos países ha llegado hoy tu contenido echándole un vistazo a la sección de estadísticas. - -WordPress para iOS es un proyecto de código abierto, lo que significa que tú también puedes participar en su desarrollo. Descubre más información en https://apps.wordpress.com/contribute/.. - -WordPress para iOS es compatible con sitios de WordPress.com y sitios WordPress.org autoalojadas que tengan WordPress 4.0 o superior. - -¿Necesitas ayuda con la aplicación? Entra en el foro de ayuda https://ios.forums.wordpress.org/ o déjanos un tweet en @WordPressiOS. diff --git a/Scripts/fastlane/metadata/es-ES/keywords.txt b/Scripts/fastlane/metadata/es-ES/keywords.txt deleted file mode 100644 index 149308fa6dd7..000000000000 --- a/Scripts/fastlane/metadata/es-ES/keywords.txt +++ /dev/null @@ -1,2 +0,0 @@ -red,social,notas,jetpack,fotos,escribir,geotagging,media,blog,wordpress,web,blogging,diseño - diff --git a/Scripts/fastlane/metadata/es-ES/marketing_url.txt b/Scripts/fastlane/metadata/es-ES/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/es-ES/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/es-ES/release_notes.txt b/Scripts/fastlane/metadata/es-ES/release_notes.txt deleted file mode 100644 index e697649b6f5e..000000000000 --- a/Scripts/fastlane/metadata/es-ES/release_notes.txt +++ /dev/null @@ -1,16 +0,0 @@ -Comentarios: -- Se ha corregido un fallo que hacía que la selección de texto se hiciera en la línea errónea al editar los comentarios. -- Se ha corregido un fallo que hacía que el marcado HTML se mostrara en el contenido de los comentarios. -- Se ha corregido un problema que hacía que los comentarios no aparecieran en el lector. - -Publicación: -- Ahora, puedes recortar, acercar/alejar y rotar las imágenes en una entrada. -- Ahora, la aplicación incluye un modo de vista previa del escritorio en iPhone y una vista previa móvil en iPad. La vista previa de las entradas también tiene las nuevas opciones de navegación «Abrir en Safari» y «Compartir». -- Muchas mejoras en el editor de bloques: Se ha añadido un icono de pulsación larga para insertar bloques antes/después y la superposición de un botón «Editar» en los bloques de imágenes seleccionados. El editor volverá a intentar cargar las imágenes después de los problemas de conectividad. Y el bloque de galería tiene ahora compatibilidad para opciones de tamaño de imágenes. -- Hemos corregido un fallo que podía desactivar los comentarios en un borrador de una entrada al previsualizarla. - -Registro y acceso -- Ahora, el registro y acceso a través de un enlace mágico es compatible con múltiples clientes de correo electrónico. Al tocar el botón «Abrir el correo electrónico», se presentará una lista de clientes de correo electrónico instalados para elegir. - -Lector -- ¡La aplicación es compatible ahora con el reblogueo de las entradas! Toca el nuevo botón «Rebloguear» en la barra de acción de la entrada para elegir en cuál de tus sitios publicar y abrir el editor de tu elección con el contenido prerellenado de la entrada original. diff --git a/Scripts/fastlane/metadata/es-ES/subtitle.txt b/Scripts/fastlane/metadata/es-ES/subtitle.txt deleted file mode 100644 index 06f43a4782f3..000000000000 --- a/Scripts/fastlane/metadata/es-ES/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Publica en cualquier parte \ No newline at end of file diff --git a/Scripts/fastlane/metadata/fr-FR/description.txt b/Scripts/fastlane/metadata/fr-FR/description.txt deleted file mode 100644 index ebcf5e4f5b50..000000000000 --- a/Scripts/fastlane/metadata/fr-FR/description.txt +++ /dev/null @@ -1,9 +0,0 @@ -Gérez ou créez votre blog ou site WordPress directement depuis votre appareil iOS : créez et modifiez vos articles et pages, téléversez vos photos et vidéos préférées, regardez vos statistiques et répondez aux commentaires. - -Avec WordPress pour iOS, vous avez le pouvoir de publier dans le creux de la main. Griffonnez un petit poème sur le pouce. Prenez une photo et publiez-la à la pause déjeuner. Répondez aux derniers commentaires ou vérifiez vos statistiques pour voir de quel pays proviennent vos visiteurs du jour. - -WordPress pour iOS est un projet Open Source, ce qui veut dire que vous aussi vous pouvez contribuer à son développement. Apprenez-en plus sur https://apps.wordpress.com/contribute/. - -WordPress pour iOS fonctionne avec les sites WordPress.com et auto-hébergés tournant sous WordPress 4.0 ou ultérieurs. - -Besoin d’aide avec l’app ? Visitez le forum sur https://ios.forums.wordpress.org/ ou envoyez-nous un tweet sur @WordPressiOS. diff --git a/Scripts/fastlane/metadata/fr-FR/keywords.txt b/Scripts/fastlane/metadata/fr-FR/keywords.txt deleted file mode 100644 index b31f09448e74..000000000000 --- a/Scripts/fastlane/metadata/fr-FR/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -social,réseau,notes,jetpack,photos,écriture,geotagging,média,blog,wordpress,website,blogging,design diff --git a/Scripts/fastlane/metadata/fr-FR/marketing_url.txt b/Scripts/fastlane/metadata/fr-FR/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/fr-FR/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/fr-FR/release_notes.txt b/Scripts/fastlane/metadata/fr-FR/release_notes.txt deleted file mode 100644 index b6a9b7bc0c38..000000000000 --- a/Scripts/fastlane/metadata/fr-FR/release_notes.txt +++ /dev/null @@ -1,16 +0,0 @@ -Commenter : -- Correction d’un bug qui sélectionnait la mauvaise ligne lors de l’édition de commentaires. -- Correction d’un bug qui affichait le balisage HTML dans le contenu d’un commentaire. -- Correction d’un problème qui n’affichait plus les commentaires dans le Lecteur. - -Publication : -- vous pouvez à présent recadrer, zoomer en avant/arrière et tourner les images dans un article. -- L’app inclus maintenant un mode d’aperçu bureau sur iPhone et d’aperçu mobile sur iPad. L’aperçu d’article obtient aussi nouvelle navigation, « Ouvrir dans Safari » et des options de partage. -- Beaucoup d’amélioration de l’éditeur de bloc : Ajout via une longue pression d’une icône pour insérer des blocs avant/ après et un bouton « Modification » en superposition sur un bloc image sélectionné. L’éditeur réessaiera de charger les images après un problème de connexion. Et la galerie de blocs prend en charge maintenant les options de taille d’image. -- Nous avons corrigé un bug qui pouvait désactiver les commentaires sur un brouillon d’article lors de son aperçu. - -Inscription et connexion : -- L’inscription et la connexion via lien magique prend en charge plusieurs apps de messagerie. Toucher le bouton « Ouvrir l’e-mail » vous présentera un liste d’apps d’e-mails installé à choisir. - -Lecteur : -- Les apps prennent maintenant en charge le reblogue d’articles ! Toucher le nouveau bouton « Rebloguer » dans la barre d’actions de l’article pour choisir un de vos sites sur lequel publier et ouvrir l’éditeur de votre choix avec le contenu collé depuis l’article original. diff --git a/Scripts/fastlane/metadata/fr-FR/subtitle.txt b/Scripts/fastlane/metadata/fr-FR/subtitle.txt deleted file mode 100644 index 4a257e4502ed..000000000000 --- a/Scripts/fastlane/metadata/fr-FR/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Gérez votre site de partout \ No newline at end of file diff --git a/Scripts/fastlane/metadata/id/description.txt b/Scripts/fastlane/metadata/id/description.txt deleted file mode 100644 index 7091ba14657a..000000000000 --- a/Scripts/fastlane/metadata/id/description.txt +++ /dev/null @@ -1,10 +0,0 @@ -Kelola atau buat blog atau situs web WordPress Anda dari perangkat iOS: buat dan sunting pos serta halaman, unggah foto dan video favorit, lihat statistik, dan balas komentar. - -Dengan WordPress untuk iOS, Anda memiliki kekuatan untuk memublikasikan dari telapak tangan Anda. Rangkai haiku secara spontan dari sofa Anda. Ambil dan pos foto saat istirahat makan siang. Tanggapi komentar terbaru, atau periksa statistik untuk melihat dari negara mana saja pengunjung situs Anda hari ini. - -WordPress untuk iOS adalah proyek Sumber Terbuka, yang berarti Anda juga bisa memberikan kontribusi dalam pengembangannya. Pelajari selengkapnya di https://apps.wordpress.com/contribute/. - -WordPress untuk iOS mendukung WordPress.com dan situs WordPress.org yang dihosting sendiri yang menjalankan WordPress versi 4.0 atau versi lebih tinggi. - -Butuh bantuan terkait aplikasi? Kunjungi forum di https://ios.forums.wordpress.org/ atau tweet kami di @WordPressiOS. - diff --git a/Scripts/fastlane/metadata/id/keywords.txt b/Scripts/fastlane/metadata/id/keywords.txt deleted file mode 100644 index b19068fd8fcf..000000000000 --- a/Scripts/fastlane/metadata/id/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -sosial,jaringan,catatan,jetpack,foto,tulisan,geotagging,media,blog,wordpress,situs web,blog,desain diff --git a/Scripts/fastlane/metadata/id/marketing_url.txt b/Scripts/fastlane/metadata/id/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/id/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/id/subtitle.txt b/Scripts/fastlane/metadata/id/subtitle.txt deleted file mode 100644 index fb05e64587e6..000000000000 --- a/Scripts/fastlane/metadata/id/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Kelola situs Anda di mana saja \ No newline at end of file diff --git a/Scripts/fastlane/metadata/it/description.txt b/Scripts/fastlane/metadata/it/description.txt deleted file mode 100644 index 330ee3c96310..000000000000 --- a/Scripts/fastlane/metadata/it/description.txt +++ /dev/null @@ -1,9 +0,0 @@ -Gestisci o crea il tuo sito web o blog WordPress direttamente dal tuo dispositivo iOS: Crea e modifica articoli e pagine, carica foto e video preferiti, visualizza le statistiche e rispondi ai commenti. - -Con WordPress per iOS, hai il potere di pubblicare sul palmo della tua mano. Crea una bozza di un haiku spontaneo dal divano. Scatta e supplica una foto della tua pausa pranzo. Rispondi agli ultimi commenti o controlla le statistiche per scoprire da quale paese provengono i nuovi visitatori di oggi. - -WordPress per iOS è un progetto Open Source, il che significa che anche tu puoi contribuire al suo sviluppo. Scopri di più all’indirizzo https://apps.wordpress.com/contribute/. - -WordPress per iOS supporta WordPress.com e i siti WordPress.org in self-hosting che eseguono WordPress 4.0 o versione successiva. - -Hai bisogno di aiuto con l’app? Visita i forum all’indirizzo https://ios.forums.wordpress.org/ o mandaci un tweet a @WordPressiOS. diff --git a/Scripts/fastlane/metadata/it/keywords.txt b/Scripts/fastlane/metadata/it/keywords.txt deleted file mode 100644 index 66c1f8b6e0f4..000000000000 --- a/Scripts/fastlane/metadata/it/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -Social,network,note,jetpack,foto,scrittura,geotag,media,blog,wordpress,sito web,blog,design diff --git a/Scripts/fastlane/metadata/it/marketing_url.txt b/Scripts/fastlane/metadata/it/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/it/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/it/subtitle.txt b/Scripts/fastlane/metadata/it/subtitle.txt deleted file mode 100644 index 641897ee4f19..000000000000 --- a/Scripts/fastlane/metadata/it/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Gestisci il tuo sito ovunque \ No newline at end of file diff --git a/Scripts/fastlane/metadata/ja/description.txt b/Scripts/fastlane/metadata/ja/description.txt deleted file mode 100644 index 8cdcc58422bf..000000000000 --- a/Scripts/fastlane/metadata/ja/description.txt +++ /dev/null @@ -1,9 +0,0 @@ -iOS 端末から WordPress ブログとサイトを管理または作成しましょう。投稿やページの作成と編集、お気に入りの写真と動画のアップロード、統計の表示、コメントへの返信が可能です。 - -WordPress for iOS を使えばスマートフォンから投稿を公開できます。くつろぎながら、頭に浮かんだ言葉をさっと下書きできます。お昼休みに写真を撮って投稿できます。最新のコメントに返信し、統計情報画面から初アクセスがあった国を確認できます。 - -WordPress for iOS はオープンソースのプロジェクトですので、誰でも開発に貢献できます。詳しくは https://apps.wordpress.com/contribute/ をご覧ください。 - -WordPress for iOS は、WordPress.com と、WordPress 4.0以降が稼働するインストール型 WordPress.org のサイトに対応しています。 - -アプリに関するサポートが必要なときは、https://ios.forums.wordpress.org/ にアクセスするか、@WordPressiOS にツイートしてください。 diff --git a/Scripts/fastlane/metadata/ja/keywords.txt b/Scripts/fastlane/metadata/ja/keywords.txt deleted file mode 100644 index aa64d7de3f89..000000000000 --- a/Scripts/fastlane/metadata/ja/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -ソーシャル,ネットワーク,メモ,jetpack,写真,投稿,位置情報,メディア,ブログ,wordpress,サイト,ブログ作成,デザイン diff --git a/Scripts/fastlane/metadata/ja/marketing_url.txt b/Scripts/fastlane/metadata/ja/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/ja/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/ja/subtitle.txt b/Scripts/fastlane/metadata/ja/subtitle.txt deleted file mode 100644 index 2e64a5e683ca..000000000000 --- a/Scripts/fastlane/metadata/ja/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -どこからでもサイトを管理 \ No newline at end of file diff --git a/Scripts/fastlane/metadata/ko/description.txt b/Scripts/fastlane/metadata/ko/description.txt deleted file mode 100644 index 13d8f6323a2c..000000000000 --- a/Scripts/fastlane/metadata/ko/description.txt +++ /dev/null @@ -1,9 +0,0 @@ -iOS 기기에서 바로 워드프레스 블로그 또는 웹사이트를 관리하거나 만드세요. 글과 페이지를 만들고 편집하며, 좋아하는 사진과 비디오를 업로드하며, 통계를 보고 댓글에 답하세요. - -iOS용 워드프레스를 사용하여 사용자가 직접 발행할 수 있습니다. 소파에서 즉흥적으로 짧은 시를 써 보세요. 점심시간에 사진을 찍어 게시해 보세요. 최근 댓글에 답하거나, 통계를 보고 오늘 방문자가 어느 나라에서 새로 방문했는지 확인해 보세요. - -iOS용 워드프레스는 오픈 소스 프로젝트이므로 사용자도 개발에 참여할 수 있습니다. https://apps.wordpress.com/contribute/에서 자세히 알아보세요. - -iOS용 워드프레스는 워드프레스닷컴과 워드프레스 4.0 이상을 실행하는 자체 호스팅된 WordPress.org 사이트를 지원합니다. - -앱 사용에 도움이 필요하세요? https://ios.forums.wordpress.org/에서 포럼을 방문하거나 @WordPressiOS로 트윗하세요. diff --git a/Scripts/fastlane/metadata/ko/keywords.txt b/Scripts/fastlane/metadata/ko/keywords.txt deleted file mode 100644 index 77cafaca5395..000000000000 --- a/Scripts/fastlane/metadata/ko/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -소셜,네트워크,메모,젯팩,사진,쓰기,지오태그,미디어,블로그,워드프레스,웹사이트,블로깅,디자인 diff --git a/Scripts/fastlane/metadata/ko/marketing_url.txt b/Scripts/fastlane/metadata/ko/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/ko/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/ko/subtitle.txt b/Scripts/fastlane/metadata/ko/subtitle.txt deleted file mode 100644 index 44ebd61aca3b..000000000000 --- a/Scripts/fastlane/metadata/ko/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -어디서든 웹사이트를 관리 \ No newline at end of file diff --git a/Scripts/fastlane/metadata/nl-NL/description.txt b/Scripts/fastlane/metadata/nl-NL/description.txt deleted file mode 100644 index dbbb8a8e643a..000000000000 --- a/Scripts/fastlane/metadata/nl-NL/description.txt +++ /dev/null @@ -1,9 +0,0 @@ -Beheer of maak je WordPress-blog of -website direct vanaf je iOS-device: maak en bewerk berichten en pagina's, upload je favoriete foto's en video's, bekijk statistieken en plaats reacties op opmerkingen. - -Met WordPress voor iOS heb je publicatiemogelijkheden in de palm van je hand. Schrijf een spontane haiku vanaf je bank. Maak een foto tijdens je lunchpauze en upload deze. Reageer op de laatste opmerkingen of houd je statistieken in de gaten om te zien uit welke landen de bezoekers van vandaag komen. - -WordPress voor iOS is een opensourceproject; dat betekent dat ook jij kunt helpen bij de ontwikkeling ervan. Ga naar https://apps.wordpress.com/contribute/ voor meer informatie. - -WordPress voor iOS ondersteunt WordPress.com en zelf-gehoste WordPress.org-sites die op WordPress 4.0 of nieuwer worden uitgevoerd. - -Hulp nodig bij de app? Bezoek de forums op https://ios.forums.wordpress.org/ of tweet ons @WordPressiOS. diff --git a/Scripts/fastlane/metadata/nl-NL/keywords.txt b/Scripts/fastlane/metadata/nl-NL/keywords.txt deleted file mode 100644 index 9d6745e6c784..000000000000 --- a/Scripts/fastlane/metadata/nl-NL/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -sociaal,netwerk,notities,jetpack,foto's,schrijven,media,blog,wordpress,website,blogging,ontwerp diff --git a/Scripts/fastlane/metadata/nl-NL/marketing_url.txt b/Scripts/fastlane/metadata/nl-NL/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/nl-NL/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/nl-NL/subtitle.txt b/Scripts/fastlane/metadata/nl-NL/subtitle.txt deleted file mode 100644 index eb6c4de86ac3..000000000000 --- a/Scripts/fastlane/metadata/nl-NL/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Beheer je website overal \ No newline at end of file diff --git a/Scripts/fastlane/metadata/no/description.txt b/Scripts/fastlane/metadata/no/description.txt deleted file mode 100644 index a542475c5c4b..000000000000 --- a/Scripts/fastlane/metadata/no/description.txt +++ /dev/null @@ -1,9 +0,0 @@ -Administrer eller opprett din WordPress-blogg eller -nettsted rett fra din iOS-enhet: Opprett og rediger innlegg og sider, last opp dine beste bilder og videoer, vis statistikk og svar på kommentarer. - -Med WordPress for iOS har du muligheten til å publisere i din hule hånd. Skisser et spontant haiku-dikt fra sofaen. Ta og publiser et bilde i lunsjpausen din. Svar på de nyeste kommentarene, eller sjekk statistikken for å se hvilke nye land dagens besøkende kommer fra. - -WordPress for iOS er et åpen kildekode-prosjekt, som betyr at du også kan bidra til utviklingen. Ler mer på https://apps.wordpress.com/contribute/. - -WordPress for iOS støtter WordPress.com og selvstendige WordPress.org-installasjoner som kjører versjon 4.0 eller høyere. - -Trenger du hjelp med appen? Besøk forumene på https://ios.forums.wordpress.org/ eller send en tweet til @WordPressiOS. diff --git a/Scripts/fastlane/metadata/no/keywords.txt b/Scripts/fastlane/metadata/no/keywords.txt deleted file mode 100644 index 216b4985fa33..000000000000 --- a/Scripts/fastlane/metadata/no/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -sosial,notater,foto,skriving,geotagging,medier,blogg,wordpress,nettsted,hjemmeside,blogging,design diff --git a/Scripts/fastlane/metadata/no/marketing_url.txt b/Scripts/fastlane/metadata/no/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/no/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/no/subtitle.txt b/Scripts/fastlane/metadata/no/subtitle.txt deleted file mode 100644 index 9114a459ffc9..000000000000 --- a/Scripts/fastlane/metadata/no/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Administrer nettsted overalt \ No newline at end of file diff --git a/Scripts/fastlane/metadata/pt-BR/description.txt b/Scripts/fastlane/metadata/pt-BR/description.txt deleted file mode 100644 index 728f01507ca2..000000000000 --- a/Scripts/fastlane/metadata/pt-BR/description.txt +++ /dev/null @@ -1,10 +0,0 @@ -Gerencie ou crie seu blog ou site WordPress diretamente de seu dispositivo iOS: crie e edite posts e páginas, envie suas fotos e vídeos favoritos, veja estatísticas e responda comentários. - -Com WordPress para iOS, você tem o poder de publicação em suas mãos! Crie rascunhos em seu sofá. Tire e publique uma foto na hora do almoço. Responda seus últimos comentários e verifique suas estatísticas para saber de qual país seus leitores acessam seu site. - -Como o WordPress para iOS é um projeto de código aberto, você também pode contribuir para seu desenvolvimento. Saiba mais em https://apps.wordpress.com/contribute/. - -WordPress para iOS suporta sites feitos no WordPress.com e sites WordPress.org rodando a versão 4.0 ou mais recente. - -Precisa de ajuda com o aplicativo? Acesse o fórum em https://ios.forums.wordpress.org/ ou tweet para @WordPressiOS. - diff --git a/Scripts/fastlane/metadata/pt-BR/keywords.txt b/Scripts/fastlane/metadata/pt-BR/keywords.txt deleted file mode 100644 index 88a0fe8c53e0..000000000000 --- a/Scripts/fastlane/metadata/pt-BR/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -social,network,notas,jetpack,fotos,escrita,geotagging,mídia,blog,wordpress,site,blog,design diff --git a/Scripts/fastlane/metadata/pt-BR/marketing_url.txt b/Scripts/fastlane/metadata/pt-BR/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/pt-BR/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/pt-BR/release_notes.txt b/Scripts/fastlane/metadata/pt-BR/release_notes.txt deleted file mode 100644 index bf079efb1762..000000000000 --- a/Scripts/fastlane/metadata/pt-BR/release_notes.txt +++ /dev/null @@ -1,16 +0,0 @@ -Comentários: -– Corrigido um erro que fazia com que a seleção de texto ocorresse na linha errada ao se editar comentários. -– Corrigido um erro que fazia com que o código HTML aparecesse no conteúdo dos comentários. -– Corrigido um erro que fazia com que os comentários não aparecessem no Leitor. - -Publicação: -– Agora você pode recortar, aumentar/reduzir o zoom e rotacionar as imagens em um post. -– O aplicativo agora inclui um modo para pré-visualizar no iPhone como ficaria no computador e uma prévia em dispositivos móveis para o iPad. A prévia de posts também inclui uma nova navegação e as opções "Abrir no Safari" e "Compartilhar". -– Um monte de melhorias no Editor de Blocos: adicionado um ícone de toque-longo para inserir blocos antes/depois e um botão "Editar" sobreposto a blocos de imagens que estiverem selecionados. O editor irá tentar recarregar as imagens se houver problemas de conexão. E o bloco Galeria agora oferece opções de tamanho de imagens. -– Corrigimos um erro que poderia desativar os comentários enquanto se estivesse visualizando um rascunho. - -Registro e acesso: -- O registro e acesso via link mágico agora suporta múltiplos clientes de email. Tocando-se no botão "Abrir Email" irá se apresentar uma lista de aplicativos instalados para o usuário escolher. - -Leitor: -- Os aplicativos agora suportam republicação de posts! Toque no novo botão "Reblog" na barra de funções do post para escolher em qual dos seus sites você quer publicar e abrir o editor de sua preferência com um conteúdo copiado do post original. diff --git a/Scripts/fastlane/metadata/pt-BR/subtitle.txt b/Scripts/fastlane/metadata/pt-BR/subtitle.txt deleted file mode 100644 index 7b9570322d39..000000000000 --- a/Scripts/fastlane/metadata/pt-BR/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Publique em qualquer lugar \ No newline at end of file diff --git a/Scripts/fastlane/metadata/ru/description.txt b/Scripts/fastlane/metadata/ru/description.txt deleted file mode 100644 index ea495a446e31..000000000000 --- a/Scripts/fastlane/metadata/ru/description.txt +++ /dev/null @@ -1,9 +0,0 @@ -Управляйте или создавайте ваш блог или сайт прямо с вашего iOS устройства: просматривайте статистику, управляйте комментариями, создавайте и редактируйте страницы и записи, загружайте медиафайлы. - -С WordPress для iOS у вас есть сила публикации на вашей ладони. Набросайте неожиданно сложившееся в голове хайку, лежа на диване, сделайте фото на обеденном перерыве для еженедельного фотоконкурса в газете. Ответьте на последние комментарии или посмотрите статистику, чтобы увидеть, откуда сегодня приходили читатели. - -WordPress для iOS - проект с открытым исходным кодом, что означает, что вы также можете принять участие в его разработке, узнайте больше на https://apps.wordpress.com/contribute/. - -WordPress для iOS поддерживает как WordPress.com так и свои сайты с WordPress 4.0 или новее. - -Нужна помощь с приложением? Посетите форум - https://ios.forums.wordpress.org/ или напишите в Twitter на @WordPressiOS. diff --git a/Scripts/fastlane/metadata/ru/keywords.txt b/Scripts/fastlane/metadata/ru/keywords.txt deleted file mode 100644 index ab6cbfc0f9a1..000000000000 --- a/Scripts/fastlane/metadata/ru/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design diff --git a/Scripts/fastlane/metadata/ru/marketing_url.txt b/Scripts/fastlane/metadata/ru/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/ru/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/ru/release_notes.txt b/Scripts/fastlane/metadata/ru/release_notes.txt deleted file mode 100644 index 60c76a50865a..000000000000 --- a/Scripts/fastlane/metadata/ru/release_notes.txt +++ /dev/null @@ -1,16 +0,0 @@ -Комментирование: -- Исправлена ошибка с выделением неверной строки при редактировании комментариев. -- Исправлена ошибка с появлением разметки HTML в содержимом комментариев. -- Исправлена проблема с непоявлением комментариев в Чтиве. - -Публикация: -- Вы можете обрезать, уменьшать или увеличивать, а также вращать изображения в записи. -- Приложение теперь включает предпросмотр как на ПК с IPhone, и мобильный предпросмотр на iPad. В режиме предпросмотра также можно использовать возможности поделиться и "открыть в Safari", а также новую навигацию. -- Уйма улучшений в редакторе блоков: при длительном нажатии - значок добавления блоков перед и после, плавающая кнопка редактирования на выбранных блоках изображений. Редактор блоков будет повторно пытаться загружать изображения после сбоев подключений к сети. Блок галереи поддерживает выбор вариантов размера. -- Исправлена ошибка с отключением комментариев в черновиках записей при их предпросмотре. - -Регистрация и вход: -- Магические ссылки теперь поддерживаются множеством почтовых программ. Нажатие на "открыть email" позволит вам выбрать из установленных почтовых приложений. - -Чтение: -- Поддержка реблоггинга! Нажмите на кнопку "реблог" на панели действий с записью, выберите на каком из сайтов вы хотите перепубликовать ее и запись откроется в редакторе на выбранном сайте, с подготовленным содержимым оригинальной записи. diff --git a/Scripts/fastlane/metadata/ru/subtitle.txt b/Scripts/fastlane/metadata/ru/subtitle.txt deleted file mode 100644 index 6d96c7ba8279..000000000000 --- a/Scripts/fastlane/metadata/ru/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Управляйте сайтом отовсюду \ No newline at end of file diff --git a/Scripts/fastlane/metadata/sv/description.txt b/Scripts/fastlane/metadata/sv/description.txt deleted file mode 100644 index d634445631f7..000000000000 --- a/Scripts/fastlane/metadata/sv/description.txt +++ /dev/null @@ -1,9 +0,0 @@ -Hantera eller skapa din WordPress-blogg eller -webbplats direkt från din iOS-enhet: skapa och redigera inlägg och sidor, ladda upp dina bästa bilder och videoklipp, kolla statistiken och svara på kommentarer. - -Med WordPress för iOS håller du möjligheten att publicera i din hand. Skriv ett utkast till en haiku när du sitter i soffan. Ta ett foto och publicera på lunchrasten. Svara på dina senaste kommentarer eller kolla statistiken för att se från vilka nya länder du fått besökare idag. - -WordPress för iOS är ett öppen källkodsprojekt, vilket innebär att även du är välkommen att bidra till dess utveckling. Läs mer på https://apps.wordpress.com/contribute/. - -WordPress för iOS stöder WordPress.com och webbplatser på egen server med programvaran WordPress.org version 4.0 eller senare. - -Behöver du hjälp med appen? Välkommen till forumen på https://ios.forums.wordpress.org/ eller twittra till oss på @WordPressiOS. diff --git a/Scripts/fastlane/metadata/sv/keywords.txt b/Scripts/fastlane/metadata/sv/keywords.txt deleted file mode 100644 index 45ca6d0a1408..000000000000 --- a/Scripts/fastlane/metadata/sv/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -socialt,nätverk,anteckningar,foton,skriva,media,blogg,wordpress,webbplats,bloggning,blogga,hemsida diff --git a/Scripts/fastlane/metadata/sv/marketing_url.txt b/Scripts/fastlane/metadata/sv/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/sv/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/sv/release_notes.txt b/Scripts/fastlane/metadata/sv/release_notes.txt deleted file mode 100644 index 137a45df038a..000000000000 --- a/Scripts/fastlane/metadata/sv/release_notes.txt +++ /dev/null @@ -1,16 +0,0 @@ -Kommentarer: -- Rättat ett fel där markeringen av text hamnade på fel rad när man redigerade kommentarer. -- Rättat ett fel som kunde göra att HTML-kod visades i kommentarers innehåll. -- Rättat ett fel som gjorde att kommentarer inte visades i läsaren. - -Publicering: -- Nu kan du beskära, förstora/förminska och rotera bilder i ett inlägg. -- Nu innehåller appen ett förhandsgranskningsläge för datorskärm på iPhone och förhandsgranskning för mobil på iPad. Förhandsvisning av inlägg har nu ny en ny menypost: ”Öppna länk i Safari” och nya delningsalternativ. -- En rad förbättringar av blockredigeraren: Lagt till en ikon för långt tryck för infogning av block före/efter och ett överlägg ”Redigera” på vissa bildblock. Redigeraren kommer att försöka ladda bilder på nytt efter eventuella problem med internetförbindelseen. Och blocket ”Galleri” har nu stöd för olika bildstorlekar. -- Vi har rättat ett fel som kunde inaktivera kommentarer på ett utkast vid förhansgranskning. - -Registrering och inloggning -- Nu stöder registrering och inloggning med hjälp av ”magiska länkar” flera olika e-postklienter. När man trycker på knappen ”öppna e-post” visas en lista där man kan välja mellan de e-postprogra som finns installerade. - -Läsaren -- Nu har appen stöd för reblogging av inlägg! Tryck på den nya knappen ”reblogga” i verktygsfältet för åtgärder på inlägg och välj till vilka av dina webbplatser du vill publicera och öppna din favoritredigerare med innehållet från det ursprungliga inlägget redan inlagt. diff --git a/Scripts/fastlane/metadata/sv/subtitle.txt b/Scripts/fastlane/metadata/sv/subtitle.txt deleted file mode 100644 index 56091961f560..000000000000 --- a/Scripts/fastlane/metadata/sv/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Publicera när det passar dig \ No newline at end of file diff --git a/Scripts/fastlane/metadata/tr/description.txt b/Scripts/fastlane/metadata/tr/description.txt deleted file mode 100644 index 6fed4571c5bf..000000000000 --- a/Scripts/fastlane/metadata/tr/description.txt +++ /dev/null @@ -1,9 +0,0 @@ -WordPress blogunuzu veya web sitenizi doğrudan iOS cihazınızdan yönetin veya oluşturun: yazılar ve sayfalar oluşturun ve düzenleyin, beğendiğiniz fotoğraflarınızı ve videolarınızı yükleyin, istatistikleri görüntüleyin ve yorumlara cevap verin. - -iOS için WordPress ile yayımcılığın gücünü avucunuzda tutarsınız. Kanepede otururken hemen o anda bir haiku yazın. Öğle tatilinde fotoğraf çekin ve yayımlayın. En son yorumlarınıza yanıt verin veya bugünün okuyucularının hangi yeni ülkelerden geldiğini görmek için istatistiklerinizi kontrol edin. - -iOS için WordPress bir açık kaynak projesidir, bu da sizin gelişimine katkıda bulunabileceğiniz anlamına gelir. https://apps.wordpress.com/contribute/ adresinden daha fazla bilgi edinin. - -iOS için WordPress, WordPress.com ve kendi kendine barındırılan WordPress 4.0 veya daha yüksek bir sürümünü çalıştıran WordPress.org sitelerini destekler. - -Uygulamayla ilgili yardım mı gerekli? https://ios.forums.wordpress.org/ adresinde forumları ziyaret edin veya @WordPressiOS adresinden bize tweet atın. diff --git a/Scripts/fastlane/metadata/tr/keywords.txt b/Scripts/fastlane/metadata/tr/keywords.txt deleted file mode 100644 index 56b8cfc0c476..000000000000 --- a/Scripts/fastlane/metadata/tr/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -blog,wordpress,jetpack,websitesi,sosyal,ağ,not,fotoraf,yazma,ortam,bloglama,tasarım,dizayn diff --git a/Scripts/fastlane/metadata/tr/marketing_url.txt b/Scripts/fastlane/metadata/tr/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/tr/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/tr/release_notes.txt b/Scripts/fastlane/metadata/tr/release_notes.txt deleted file mode 100644 index a732bd98c0af..000000000000 --- a/Scripts/fastlane/metadata/tr/release_notes.txt +++ /dev/null @@ -1,16 +0,0 @@ -Yorum yapma: -- Yorumları düzenlerken metin seçiminin yanlış satırda olmasına neden olan bir hata düzeltildi. -- HTML işaretlemesinin yorum içeriğinde görüntülenmesine neden olan bir hata düzeltildi. -- Yorumların okuyucuda görünmemesine neden olan bir sorun düzeltildi. - -Yayıncılık: -- Artık bir yazıdaki görüntüleri kırpabilir, yakınlaştırabilir / uzaklaştırabilir ve döndürebilirsiniz. -- Uygulama artık iPhone'da bir masaüstü ön izleme modu ve iPad'de mobil ön izleme içeriyor. Yayın ön izlemesinde ayrıca "Safari'de aç" ve "Paylaş seçenekleri" gibi yeni gezinme seçenekleri de vardır. -- Çok sayıda blok düzenleyici geliştirmesi: Blokları önce / sonra eklemek için uzun basma simgesi ve seçilen görüntü bloklarına “Düzenle” düğmesi kaplaması eklendi. Düzenleyici, bağlantı sorunlarından sonra görüntüleri yüklemeyi yeniden deneyecek. Galeri bloğu artık görüntü boyutu seçeneklerini destekliyor. -- Bir taslak gönderideki yorumları önizlerken devre dışı bırakabilecek bir hatayı düzelttik. - -Kayıt ve Giriş -- Sihirli bağlantı yoluyla kaydolma veya giriş artık birden çok e-posta istemcisini destekliyor. “E-postayı aç” düğmesine dokunduğunuzda, seçebileceğiniz yüklü e-posta istemcilerinin bir listesi gösterilir. - -Okuyucu -- Uygulamalar artık yeniden blog yazmayı destekliyor! Sitelerinizden hangilerine yayın yapılacağını seçmek ve seçtiğiniz editörü orijinal yayından önceden doldurulmuş içerikle açmak için yayınlama işlem çubuğundaki yeni "yeniden blog" düğmesine dokunun. diff --git a/Scripts/fastlane/metadata/tr/subtitle.txt b/Scripts/fastlane/metadata/tr/subtitle.txt deleted file mode 100644 index 196967d7ad13..000000000000 --- a/Scripts/fastlane/metadata/tr/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -Dilediğiniz yerde yayımlayın \ No newline at end of file diff --git a/Scripts/fastlane/metadata/zh-Hans/description.txt b/Scripts/fastlane/metadata/zh-Hans/description.txt deleted file mode 100644 index 6238b94f19ef..000000000000 --- a/Scripts/fastlane/metadata/zh-Hans/description.txt +++ /dev/null @@ -1,10 +0,0 @@ -您可以直接在 iOS 设备上管理或创建自己的 WordPress 博客或网站:创建和编辑文章和页面,上传您最喜欢的照片和视频,查看统计数据和回复评论。 - -借助 iOS 版 WordPress,您可以随时随地发布信息,尽在您的掌握。在沙发上为即兴创作的俳句撰写草稿。在午休时间拍照并发表。回复最新评论,或者查看统计数据,看看今天的访客来自哪些新国家/地区。 - -iOS 版 WordPress 是开源项目,这意味着您也可以为它的开发贡献一份力量。详细信息请访问 https://apps.wordpress.com/contribute/。 - -iOS 版 WordPress 支持 WordPress.com 和运行 WordPress 4.0 或更高版本的自托管 WordPress.org 站点。 - -需要应用方面的帮助?请访问我们的论坛 (https://ios.forums.wordpress.org/),或在 Twitter 上 @ 我们 (@WordPressiOS)。 - diff --git a/Scripts/fastlane/metadata/zh-Hans/keywords.txt b/Scripts/fastlane/metadata/zh-Hans/keywords.txt deleted file mode 100644 index 4bf3ed1a28a4..000000000000 --- a/Scripts/fastlane/metadata/zh-Hans/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -社交, 网络, 备注, Jetpack, 照片, 写作, 地理标记, 媒体, 博客, WordPress, 网站, 撰写博客, 设计 diff --git a/Scripts/fastlane/metadata/zh-Hans/marketing_url.txt b/Scripts/fastlane/metadata/zh-Hans/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/zh-Hans/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/zh-Hans/subtitle.txt b/Scripts/fastlane/metadata/zh-Hans/subtitle.txt deleted file mode 100644 index 23518f1bad87..000000000000 --- a/Scripts/fastlane/metadata/zh-Hans/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -随时随地管理您的网站 \ No newline at end of file diff --git a/Scripts/fastlane/metadata/zh-Hant/description.txt b/Scripts/fastlane/metadata/zh-Hant/description.txt deleted file mode 100644 index 1092b1a8e0be..000000000000 --- a/Scripts/fastlane/metadata/zh-Hant/description.txt +++ /dev/null @@ -1,10 +0,0 @@ -使用 iOS 裝置管理或建立 WordPress 網誌或網站:建立及編輯文章和網頁、上傳最愛的相片和影片、查看統計資料及回覆留言。 - -使用 iOS 版 WordPress,你就能夠在掌上輕鬆發佈文章。窩在沙發上信手創作幾行俳句;趁著午休時間拍照並上傳;回覆最新的回應;或者查看統計資料,並瞭解今天的讀者來自哪些國家。 - -Android 版 WordPress 是開放原始碼專案,這表示你也可以貢獻己力協助開發此程式。深入瞭解:https://apps.wordpress.com/contribute/。 - -iOS 版 WordPress 支援 WordPress.com 和執行 WordPress 4.0 或以上版本的自助託管 WordPress.org 網站。 - -需要應用程式方面的協助嗎?請造訪論壇:https://ios.forums.wordpress.org/ 或使用 Twitter 推文給我們:@WordPressiOS。 - diff --git a/Scripts/fastlane/metadata/zh-Hant/keywords.txt b/Scripts/fastlane/metadata/zh-Hant/keywords.txt deleted file mode 100644 index 36944dd212c0..000000000000 --- a/Scripts/fastlane/metadata/zh-Hant/keywords.txt +++ /dev/null @@ -1 +0,0 @@ -社交, 網路, 備註, jetpack, 相片, 寫作, 地理標籤, 媒體, 網誌, wordpress, 網站, 撰寫網誌, 設計 diff --git a/Scripts/fastlane/metadata/zh-Hant/marketing_url.txt b/Scripts/fastlane/metadata/zh-Hant/marketing_url.txt deleted file mode 100644 index 0ad53b917f84..000000000000 --- a/Scripts/fastlane/metadata/zh-Hant/marketing_url.txt +++ /dev/null @@ -1 +0,0 @@ -https://apps.wordpress.com/mobile/ \ No newline at end of file diff --git a/Scripts/fastlane/metadata/zh-Hant/subtitle.txt b/Scripts/fastlane/metadata/zh-Hant/subtitle.txt deleted file mode 100644 index ffbac7d88c64..000000000000 --- a/Scripts/fastlane/metadata/zh-Hant/subtitle.txt +++ /dev/null @@ -1 +0,0 @@ -隨時隨地管理網站 \ No newline at end of file diff --git a/Scripts/fastlane/screenshots.json b/Scripts/fastlane/screenshots.json deleted file mode 100644 index b39d6537324b..000000000000 --- a/Scripts/fastlane/screenshots.json +++ /dev/null @@ -1,336 +0,0 @@ -{ - "version": 0.1, - "shadow_offset": 40, - "background_color": "#016087", - "stylesheet": "appstoreres/assets/style.css", - "devices": [ - { - "name": "iPhone Xs max", - "canvas_size": [1242,2688], - "text_size": [1242, 510], - "font_size": "90px", - "screenshot_size": [921, 1994], - "screenshot_offset": [161, 559], - "screenshot_mask": "appstoreres/assets/iphone-x-mask.png", - "device_frame": "appstoreres/assets/iphone-x.png", - "device_frame_size": [1200, 2268], - "device_frame_offset": [21, 420] - }, - { - "name": "iPhone 8", - "canvas_size": [1242,2208], - "text_size": [1242, 320], - "font_size": "70px", - "screenshot_size": [921, 1638], - "screenshot_offset": [161, 580], - "device_frame": "appstoreres/assets/iphone-8-white.png", - "device_frame_size": [1129, 2211], - "device_frame_offset": [56, 292] - }, - { - "name": "iPad Pro", - "canvas_size": [2732,2048], - "text_size": [2732, 317], - "font_size": "90px", - "screenshot_size": [2220, 1670], - "screenshot_offset": [257, 410], - "device_frame": "appstoreres/assets/ipad-pro.png", - "device_frame_size": [2788, 1987], - "device_frame_offset": [-25, 248] - }, - { - "name": "iPad X", - "canvas_size": [2732,2048], - "text_size": [2732, 317], - "font_size": "90px", - "screenshot_size": [2220, 1670], - "screenshot_offset": [257, 410], - "screenshot_mask": "appstoreres/assets/ipad-x-mask.png", - "device_frame": "appstoreres/assets/ipad-x.png", - "device_frame_size": [2612, 2022], - "device_frame_offset": [61, 231] - } - ], - "entries": [ - { - "device": "iPhone Xs max", - "text": "appstoreres/metadata/%s/app_store_screenshot_1.html", - "screenshot": "iPhone XS Max-1-PostEditor.png", - "background": "#0F78A2", - "attachments": [ - { - "file": "appstoreres/assets/white-box.svg", - "size": [865,1165], - "position": [186, 1101] - }, - { - "file": "appstoreres/assets/attachments/1-photos-iPhoneX.png", - "size": [1108, 1364], - "position": [71, 1085] - } - ] - }, - { - "device": "iPhone Xs max", - "text": "appstoreres/metadata/%s/app_store_screenshot_2.html", - "screenshot": "iPhone XS Max-2-Stats.png", - "background": "#9658A3", - "attachments": [ - { - "file": "appstoreres/assets/attachments/2-chart.png", - "size": [1060,672], - "position": [91, 941] - } - ] - }, - { - "device": "iPhone Xs max", - "text": "appstoreres/metadata/%s/app_store_screenshot_3.html", - "screenshot": "iPhone XS Max-3-Notifications.png", - "background": "#0F78A2", - "attachments": [ - { - "file": "appstoreres/assets/attachments/3-notifications-iPhoneX.png", - "size": [1156, 1340], - "position": [89, 906] - } - ] - }, - { - "device": "iPhone Xs max", - "text": "appstoreres/metadata/%s/app_store_screenshot_4.html", - "screenshot": "iPhone XS Max-4-Media.png", - "background": "#9658A3", - "attachments": [ - { - "file": "appstoreres/assets/attachments/4-photos-iPhoneX.png", - "size": [1072,1541], - "position": [71, 872] - } - ] - }, - { - "device": "iPhone Xs max", - "text": "appstoreres/metadata/%s/app_store_screenshot_5.html", - "screenshot": "iPhone XS Max-5-DraftEditor.png", - "background": "#0F78A2", - "attachments": [ - { - "file": "appstoreres/assets/ios-dictation-keyboard-x.png", - "size": [921,933], - "position": [161, 1622] - }, - { - "file": "appstoreres/assets/attachments/5-1.png", - "size": [1058,288], - "position": [92, 1988] - } - ] - }, - { - "device": "iPhone 8", - "text": "appstoreres/metadata/%s/app_store_screenshot_1.html", - "screenshot": "iPhone 8 Plus-1-PostEditor.png", - "background": "#0F78A2", - "attachments": [ - { - "file": "appstoreres/assets/white-box.svg", - "size": [878,1062], - "position": [181, 1058] - }, - { - "file": "appstoreres/assets/attachments/1-photos-iPhoneX.png", - "size": [1108,1364], - "position": [71, 1090] - } - ] - }, - { - "device": "iPhone 8", - "text": "appstoreres/metadata/%s/app_store_screenshot_2.html", - "screenshot": "iPhone 8 Plus-2-Stats.png", - "background": "#9658A3", - "attachments": [ - { - "file": "appstoreres/assets/attachments/2-chart.png", - "size": [1060,672], - "position": [91, 933] - } - ] - }, - { - "device": "iPhone 8", - "text": "appstoreres/metadata/%s/app_store_screenshot_3.html", - "screenshot": "iPhone 8 Plus-3-Notifications.png", - "background": "#0F78A2", - "attachments": [ - { - "file": "appstoreres/assets/attachments/3-notifications-iPhone8.png", - "size": [1156, 1340], - "position": [55, 705] - } - ] - }, - { - "device": "iPhone 8", - "text": "appstoreres/metadata/%s/app_store_screenshot_4.html", - "screenshot": "iPhone 8 Plus-4-Media.png", - "background": "#9658A3", - "attachments": [ - { - "file": "appstoreres/assets/attachments/4-photos-iPhone8.png", - "size": [1115,1459], - "position": [55, 705] - } - ] - }, - { - "device": "iPhone 8", - "text": "appstoreres/metadata/%s/app_store_screenshot_5.html", - "screenshot": "iPhone 8 Plus-5-DraftEditor.png", - "background": "#0F78A2", - "attachments": [ - { - "file": "appstoreres/assets/ios-dictation-keyboard.png", - "size": [921,893], - "position": [161, 1407] - }, - { - "file": "appstoreres/assets/attachments/5-1.png", - "size": [1058,288], - "position": [92, 1695] - } - ] - }, - { - "device": "iPad X", - "text": "appstoreres/metadata/%s/app_store_screenshot_1.html", - "screenshot": "iPad Pro (12.9-inch) (3rd generation)-1-PostEditor.png", - "background": "#0F78A2", - "attachments": [ - { - "file": "appstoreres/assets/white-box.svg", - "size": [995,1081], - "position": [893, 772] - }, - { - "file": "appstoreres/assets/attachments/1-photos-iPad.png", - "size": [1213,1428], - "position": [751, 819] - } - ] - }, - { - "device": "iPad X", - "text": "appstoreres/metadata/%s/app_store_screenshot_4.html", - "screenshot": "iPad Pro (12.9-inch) (3rd generation)-4-Media.png", - "background": "#9658A3", - "attachments": [ - { - "file": "appstoreres/assets/attachments/4-photos-iPad.png", - "size": [1795,1368], - "position": [736, 507] - } - ] - }, - { - "device": "iPad X", - "text": "appstoreres/metadata/%s/app_store_screenshot_2.html", - "screenshot": "iPad Pro (12.9-inch) (3rd generation)-2-Stats.png", - "background": "#9658A3", - "attachments": [ - { - "file": "appstoreres/assets/attachments/2-chart-iPad.png", - "size": [1745,720], - "position": [746,565] - } - ] - }, - { - "device": "iPad X", - "text": "appstoreres/metadata/%s/app_store_screenshot_3.html", - "screenshot": "iPad Pro (12.9-inch) (3rd generation)-3-Notifications.png", - "background": "#0F78A2", - "attachments": [ - { - "file": "appstoreres/assets/attachments/3-notifications-iPad.png", - "size": [880, 1158], - "position": [117, 688] - } - ] - }, - { - "device": "iPad X", - "text": "appstoreres/metadata/%s/app_store_screenshot_6.html", - "screenshot": "iPad Pro (12.9-inch) (3rd generation)-6-No-Keyboard-Editor.png", - "background": "#0F78A2", - "attachments": [ - ] - }, - { - "device": "iPad Pro", - "text": "appstoreres/metadata/%s/app_store_screenshot_1.html", - "screenshot": "iPad Pro (12.9-inch) (2nd generation)-1-PostEditor.png", - "background": "#0F78A2", - "attachments": [ - { - "file": "appstoreres/assets/white-box.svg", - "size": [995,1081], - "position": [893, 772] - }, - { - "file": "appstoreres/assets/attachments/1-photos-iPad.png", - "size": [1213,1428], - "position": [751, 819] - } - ] - }, - { - "device": "iPad Pro", - "text": "appstoreres/metadata/%s/app_store_screenshot_2.html", - "screenshot": "iPad Pro (12.9-inch) (2nd generation)-2-Stats.png", - "background": "#9658A3", - "attachments": [ - { - "file": "appstoreres/assets/attachments/2-chart-iPad.png", - "size": [1745,720], - "position": [746,565] - } - ] - }, - { - "device": "iPad Pro", - "text": "appstoreres/metadata/%s/app_store_screenshot_3.html", - "screenshot": "iPad Pro (12.9-inch) (2nd generation)-3-Notifications.png", - "background": "#0F78A2", - "attachments": [ - { - "file": "appstoreres/assets/attachments/3-notifications-iPad.png", - "size": [924, 1222], - "position": [117, 688] - } - ] - }, - { - "device": "iPad Pro", - "text": "appstoreres/metadata/%s/app_store_screenshot_4.html", - "screenshot": "iPad Pro (12.9-inch) (2nd generation)-4-Media.png", - "background": "#9658A3", - "attachments": [ - { - "file": "appstoreres/assets/attachments/4-photos-iPad.png", - "size": [1795,1368], - "position": [736, 507] - } - ] - }, - { - "device": "iPad Pro", - "text": "appstoreres/metadata/%s/app_store_screenshot_6.html", - "screenshot": "iPad Pro (12.9-inch) (2nd generation)-6-No-Keyboard-Editor.png", - "background": "#0F78A2", - "attachments": [ - ] - } - ] -} \ No newline at end of file diff --git a/Scripts/fix-screenshots.rb b/Scripts/fix-screenshots.rb index 88562299484a..8ae17eeb0b5f 100755 --- a/Scripts/fix-screenshots.rb +++ b/Scripts/fix-screenshots.rb @@ -1,15 +1,16 @@ #!/usr/bin/env ruby +# frozen_string_literal: true require 'find' -Find.find('./fastlane/screenshots') { |e| - next if File.directory?(e) - next if File.extname(e) != ".png" +Find.find('./fastlane/screenshots') do |e| + next if File.directory?(e) + next if File.extname(e) != '.png' - info = `identify "#{e}"`.sub(e, '').split - dimensions = info[1].split('x') + info = `identify "#{e}"`.sub(e, '').split + dimensions = info[1].split('x') - if e.include?('iPad') && dimensions[0] < dimensions[1] - `convert "#{e}" -rotate -90 "#{e}"` - puts "✅ #{e}" - end -} + if e.include?('iPad') && dimensions[0] < dimensions[1] + `convert "#{e}" -rotate -90 "#{e}"` + puts "✅ #{e}" + end +end diff --git a/Scripts/fix-translation b/Scripts/fix-translation deleted file mode 100755 index 4c0f3afa7fae..000000000000 --- a/Scripts/fix-translation +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env swift - -import Foundation - -guard CommandLine.arguments.count > 1 else { - print("Usage: fix-translation path/to/Localizable.strings") - exit(1) -} - -func fix(file: String) throws { - var encoding = String.Encoding.utf16LittleEndian - let contents = try String(contentsOfFile: file, usedEncoding: &encoding) - let regexp = try NSRegularExpression(pattern: "^\"(.*)\" = \"\";$", options: []) - var output = "" - contents.enumerateLines { line, _ in - let replaced = regexp.stringByReplacingMatches(in: line, options: [], range: NSRange(location: 0, length: line.count), withTemplate: "\"$1\" = \"$1\";") - output.append(replaced as String) - output.append("\n") - } - try output.write(toFile: file, atomically: true, encoding: encoding) -} - -do { - try CommandLine.arguments.dropFirst().forEach { file in - try fix(file: file) - } -} catch { - print(error) -} diff --git a/Scripts/install-oclint.sh b/Scripts/install-oclint.sh deleted file mode 100755 index 34255590e0f7..000000000000 --- a/Scripts/install-oclint.sh +++ /dev/null @@ -1,8 +0,0 @@ -echo "[*] installing oclint 0.8.1" -pushd . -cd $TMPDIR -curl http://archives.oclint.org/releases/0.8/oclint-0.8.1-x86_64-darwin-14.0.0.tar.gz > oclint.tar.gz -tar -zxvf oclint.tar.gz -cp oclint-0.8.1/bin/oclint* /usr/local/bin/ -cp -rp oclint-0.8.1/lib/* /usr/local/lib/ -popd diff --git a/Scripts/localize.py b/Scripts/localize.py deleted file mode 100755 index 6b2a56bbe731..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' - else: - find_cmd = 'find . -name "*.m" -o -name "*.swift" | grep -v Vendor | grep -v ./WordPressTest/I18n.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/Scripts/manage-version.sh b/Scripts/manage-version.sh deleted file mode 100755 index 351ca0b69a52..000000000000 --- a/Scripts/manage-version.sh +++ /dev/null @@ -1,459 +0,0 @@ -#!/bin/sh - -### Misc definitions -CMD_CREATE="create-branch" -CMD_CREATE_SHORT="create" -CMD_UPDATE="update-branch" -CMD_UPDATE_SHORT="update" -CMD_FORCE="force-branch" -CMD_FORCE_SHORT="force" -CMD_BUMP_RELEASE="bump-release" -CMD_BUMP_HOTFIX="bump-hotfix" -CMD_BUMP_INTERNAL="bump-internal" -CMD_GET_VERSION="get-version" - -# Regex for "is a number" -IS_A_NUM_RE="^[0-9]+$" - -# Color/formatting support -OUTPUT_NORM="\033[0m" -OUTPUT_RED="\033[31m" -OUTPUT_GREEN="\033[32m" -OUTPUT_BOLD="\033[1m" - -# Config files -publicConfig=("Version.public.xcconfig") -internalConfig=("Version.internal.xcconfig") - - -### Function definitions -# Show script usage, commands and options -function showUsage() { - # Help message - echo "Usage: $exeName command new-version [new-internal-version]" - echo "" - echo " Available commands:" - echo " $CMD_GET_VERSION: reads the current version" - echo " $CMD_BUMP_RELEASE: reads the current version, bumps the release digits and creates the new branch (works only on develop branch)" - echo " $CMD_BUMP_HOTFIX: reads the current version, bumps the hotfix digit and updates the IDs (works only on release branch)" - echo " $CMD_BUMP_INTERNAL: reads the current version, bumps the internal build digit and updates the IDs (works only on release branch)" - echo " $CMD_CREATE (or $CMD_CREATE_SHORT): creates the new branch and updates the version IDs" - echo " $CMD_UPDATE (or $CMD_UPDATE_SHORT): updates the version IDs" - echo " $CMD_FORCE (or $CMD_FORCE_SHORT): force the update to the provided version, skipping the checks." - echo "" - echo "Example: $exeName $CMD_GET_VERSION" - echo "Example: $exeName $CMD_BUMP_RELEASE" - echo "Example: $exeName $CMD_BUMP_HOTFIX" - echo "Example: $exeName $CMD_BUMP_INTERNAL" - echo "Example: $exeName $CMD_CREATE_SHORT 9.3.0" - echo "Example: $exeName $CMD_UPDATE_SHORT 9.3.0.1" - echo "Example: $exeName $CMD_UPDATE_SHORT 9.3.0.1 9.3.0.20180129" - echo "" - exit 1 -} - -function showErrorMessage() { - message=$1 - echo "$OUTPUT_RED$message$OUTPUT_NORM" - echo $message >> $logFile -} - -function showOkMessage() { - message=$1 - echo "$OUTPUT_GREEN$message$OUTPUT_NORM" - echo $message >> $logFile -} - -function showTitleMessage() { - message=$1 - echo "$OUTPUT_BOLD$message$OUTPUT_NORM" - echo $message >> $logFile -} - -function showMessage() { - echo "$1" | tee -a $logFile -} - -# Verifies the command against the known ones and normalize to the extended version -# Shows script usage in case of unknown command -function verifyCommand() { - if [ $cmd == $CMD_CREATE ] || [ $cmd == $CMD_CREATE_SHORT ]; then - cmd=$CMD_CREATE - return - fi - - if [ $cmd == $CMD_UPDATE ] || [ $cmd == $CMD_UPDATE_SHORT ]; then - cmd=$CMD_UPDATE - return - fi - - if [ $cmd == $CMD_FORCE ] || [ $cmd == $CMD_FORCE_SHORT ]; then - cmd=$CMD_FORCE - return - fi - - if [ $cmd == $CMD_BUMP_RELEASE ] || [ $cmd == $CMD_BUMP_INTERNAL ] || [ $cmd == $CMD_BUMP_HOTFIX ] || [ $cmd == $CMD_GET_VERSION ]; then - return - fi - - showUsage -} - -# Check version length, format and coherency. -# Also creates the internal version if it doesn't exists -function verifyVersion() { - nvp=( ${newVer//./ } ) - - # Check version array has at least 2 elements - if [ "${#nvp[@]}" -lt 2 ]; then - showErrorMessage "Version string must contain Major and Minor numbers at least" - exit 1 - fi - - # Check version array has no more than 4 elements - if [ "${#nvp[@]}" -gt 4 ]; then - showErrorMessage "Version string can contain no more than Major, Minor, Release and Build numbers" - exit 1 - fi - - # Assign 3rd and 4th el to zero if they doesn't exist - if [ x${nvp[2]} == x ]; then - nvp[2]=0 - fi - - if [ x${nvp[3]} == x ]; then - nvp[3]=0 - fi - - # Check every part is a number - for i in "${nvp[@]}" - do - if ! [[ $i =~ $IS_A_NUM_RE ]] ; then - showErrorMessage "Version value can only contains numbers" - exit 1 - fi - done - - # Create version numbers - newVer=${nvp[0]}.${nvp[1]}.${nvp[2]}.${nvp[3]} - newMainVer=${nvp[0]}.${nvp[1]} - if [ ${nvp[2]} == 0 ]; then - newShortVer=${newMainVer} - else - newShortVer=${newMainVer}.${nvp[2]} - fi - releaseBranch="$releaseBranch$newMainVer" - - # If internal version exists, check if has the same major, minor, release - # otherwise, create one - if [ x$newIntVer == x ]; then - todayDate=`date +%Y%m%d` - newIntVer=${newVer%.*}.$todayDate - elif [ ${newVer%.*} != ${newIntVer%.*} ]; then - showErrorMessage "Internal and external versions don't match." - exit 1 - fi -} - -# Verifies the command and the version -function verifyParams() { - verifyCommand - - # Skip verify for bump commands - if [ $cmd == $CMD_BUMP_RELEASE ] || [ $cmd == $CMD_BUMP_INTERNAL ] || [ $cmd == $CMD_BUMP_HOTFIX ] || [ $cmd == $CMD_GET_VERSION ]; then - return - fi - - verifyVersion -} - -# Shows the configuration the script received -function showConfig() { - showMessage "Current build version: $currentVer" - showMessage "Current internal version: $currentIntVer" - showMessage "New build version: $newVer" - showMessage "New internal version: $newIntVer" - showMessage "New short version: $newShortVer" - showMessage "Release branch: $releaseBranch" -} - -# Appends an init line to the log -function startLog() { - dateTime=`date "+%d-%m-%Y - %H:%M:%S"` - echo "$exeName started at $dateTime" >> $logFile -} - -# Appends a closing line to the log -function stopLog() { - dateTime=`date "+%d-%m-%Y - %H:%M:%S"` - echo "$exeName terminated at $dateTime" >> $logFile - echo "" >> $logFile - echo "Log location: $logFile" -} - -# Writes an error message and exits -function stopOnError() { - showErrorMessage "Operation failed. Aborting." - showErrorMessage "See log for further details." - stopLog - exit 1 -} - -# Checks out develop, updates it to origin and creates the release branch -function doBranching() { - git checkout develop >> $logFile 2>&1 || stopOnError - git pull origin develop >> $logFile 2>&1 || stopOnError - git show-ref --verify --quiet "refs/heads/$releaseBranch" >> $logFile 2>&1 - if [ $? -eq 0 ]; then - showMessage "Branch $releaseBranch already exists. Skipping creation." - git checkout $releaseBranch >> $logFile 2>&1 || stopOnError - git pull origin $releaseBranch >> $logFile 2>&1 - else - git checkout -b $releaseBranch >> $logFile 2>&1 || stopOnError - - # Push to origin - git push -u origin $releaseBranch >> $logFile 2>&1 || stopOnError - fi -} - -# Updates the keys in download_metadata.swift and AppStoreStrings.po -function updateGlotPressKey() { - dmFile="./fastlane/download_metadata.swift" - if [ -f $dmFile ]; then - sed -i '' "s/let glotPressWhatsNewKey.*/let glotPressWhatsNewKey = \"v$newMainVer-whats-new\"/" $dmFile - else - showErrorMessage "Can't find $dmFile." - stopOnError - fi -} - -# Updates the app version in Fastlane Deliver file -function updateFastlaneDeliver() { - fdFile="./fastlane/Deliverfile" - if [ -f $fdFile ]; then - sed -i '' "s/app_version.*/app_version \"$newShortVer\"/" $fdFile - else - showErrorMessage "Can't find $fdFile." - stopOnError - fi -} - -# Updates a list of config files with the provided version -function updateConfigFiles() { - declare -a fileList=("${!1}") - updateVer=$2 - - for i in "${fileList[@]}" - do - cFile="../config/$i" - if [ -f "$cFile" ]; then - echo "Updating $cFile to version $2" >> $logFile 2>&1 - sed -i '' "$(awk '/^VERSION_SHORT/{ print NR; exit }' "$cFile")s/=.*/=$newShortVer/" "$cFile" >> $logFile 2>&1 || stopOnError - sed -i '' "$(awk '/^VERSION_LONG/{ print NR; exit }' "$cFile")s/=.*/=$updateVer/" "$cFile" >> $logFile 2>&1 || stopOnError - else - stopOnError "$cFile not found" - fi - done -} - -# Updates the config files -function updateXcConfigs() { - updateConfigFiles publicConfig[@] "$newVer" - updateConfigFiles internalConfig[@] "$newIntVer" -} - -# Updates config files and fastlane deliver on the current branch -function updateBranch() { - if [ $cmd == $CMD_UPDATE ]; then - startLog - checkVersions - showTitleMessage "Updating the current branch to version $newMainVer..." - showConfig - fi - - showMessage "Updating Fastlane deliver file..." - updateFastlaneDeliver - showMessage "Done!" - showMessage "Updating XcConfig..." - updateXcConfigs - showMessage "Done!" - - if [ $cmd == $CMD_UPDATE ]; then - showOkMessage "Success!" - stopLog - fi -} - -# Creates a new branch for the release and updates the relevant files -function createBranch() { - startLog - if [ $cmd != $CMD_FORCE ]; then - checkVersions - showTitleMessage "Creating new Release branch for version $newMainVer..." - else - showTitleMessage "Forcing branch for version $newMainVer..." - fi - showConfig - doBranching - showMessage "Done!" - showMessage "Updating glotPressKeys..." - updateGlotPressKey - showMessage "Done!" - updateBranch - showOkMessage "Success!" - stopLog -} - -# Reads a version from a config file -function readVersion() { - cFile="../config/$1" - if [ -f "$cFile" ]; then - tmp=$(sed -n "$(awk '/^VERSION_LONG/{ print NR; exit }' "$cFile")p" "$cFile" | cut -d'=' -f 2) - else - showErrorMessage "$cFile not found. Can't read version. Are you in the correct branch/folder?" - exit 1 - fi -} - -# Reads the current internal and external versions -function getCurrentVersions() { - printf "Reading current version in this branch..." - readVersion ${publicConfig[0]} - currentVer=$tmp - - readVersion ${internalConfig[0]} - currentIntVer=$tmp - echo "Done." -} - -# Check coherency between current and updating version -function checkVersion() { - firstVer=$1 - secondVer=$2 - if [ $firstVer == $secondVer ]; then - showErrorMessage "Current branch is already on version $firstVer" - stopOnError - fi - - nvp=( ${firstVer//./ } ) - cvp=( ${secondVer//./ } ) - - idx=0 - for i in "${nvp[@]}" - do - if [ $i -gt ${cvp[idx]} ]; then - return - elif [ $i -lt ${cvp[idx]} ]; then - showErrorMessage "New version $firstVer is lower than current version $secondVer" - stopOnError - fi - ((idx++)) - done -} - -# Check coherency between current and updating versions -function checkVersions() { - checkVersion $newVer $currentVer - checkVersion $newIntVer $currentIntVer -} - -# Check that the current branch name contains the provided string -function checkBranch() { - btover=$1 - branch_name=$(git symbolic-ref -q HEAD) - if [[ $branch_name = *"$btover"* ]]; then - return - fi - - showErrorMessage "This command works only on $1 branch" - stopOnError -} - -# Bump current release number (only on develop branch) -function bumpRelease() { - checkBranch "develop" - - # Bump release - showMessage "Current version: $currentVer" - cvp=( ${currentVer//./ } ) - - # Bump minor - cvp[1]=$((${cvp[1]}+1)) - if [ ${cvp[1]} == 10 ]; then - cvp[1]=0 - cvp[0]=$((${cvp[0]}+1)) - fi - - newVer=${cvp[0]}.${cvp[1]} - verifyVersion - createBranch -} - -# Bump hotfix digit (only on release branch) -function bumpHotFix { - checkBranch "release" - - # Bump release - showMessage "Current version: $currentVer" - cvp=( ${currentVer//./ } ) - - cvp[2]=$((${cvp[2]}+1)) - newVer=${cvp[0]}.${cvp[1]}.${cvp[2]} - verifyVersion - showConfig - updateBranch -} - -#Bump internal digit (only on release branch) -function bumpInternal { - checkBranch "release" - - # Bump release - showMessage "Current version: $currentVer" - cvp=( ${currentVer//./ } ) - - cvp[3]=$((${cvp[3]}+1)) - newVer=${cvp[0]}.${cvp[1]}.${cvp[2]}.${cvp[3]} - verifyVersion - showConfig - updateBranch -} - -### Script main -exeName=$(basename "$0" ".sh") - -# Params check -if [ "$#" -lt 1 ] || [ "$#" -gt 3 ] || [ -z $1 ]; then - showUsage -fi - -# Load params -cmd=$1 -newVer=$2 -newIntVer=$3 -newMainVer=0 -newShortVer=0 -currentVer=0 -currentIntVer=0 -releaseBranch="release/" -logFile="/tmp/manage-version.log" - -verifyParams -getCurrentVersions - -if [ $cmd == $CMD_CREATE ] || [ $cmd == $CMD_FORCE ]; then - createBranch -elif [ $cmd == $CMD_UPDATE ]; then - updateBranch -elif [ $cmd == $CMD_BUMP_RELEASE ]; then - bumpRelease -elif [ $cmd == $CMD_BUMP_HOTFIX ]; then - bumpHotFix -elif [ $cmd == $CMD_BUMP_INTERNAL ]; then - bumpInternal -elif [ $cmd == $CMD_GET_VERSION ]; then - echo $currentVer - echo $currentIntVer -else - showUsage -fi diff --git a/Scripts/run-oclint.sh b/Scripts/run-oclint.sh deleted file mode 100755 index 8a72dca8c1b1..000000000000 --- a/Scripts/run-oclint.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/bin/sh -source ~/.bash_profile -check_file="$1" -oclint_args="-disable-rule=ShortVariableName -disable-rule=LongLine -disable-rule=LongClass -disable-rule=LongMethod -disable-rule=UnusedMethodParameter -disable-rule=LongVariableName" -temp_dir="/tmp" -build_dir="${temp_dir}/WPiOS_linting" -compile_commands_path=${temp_dir}/compile_commands.json -xcodebuild_log_path=${temp_dir}/xcodebuild.log - -hash oclint &> /dev/null -if [ $? -eq 1 ]; then - echo >&2 "[OCLint] oclint not found, analyzing stopped" - exit 1 -fi - -oclint --version - -echo "[OCLint] cleaning up generated files" -[[ -f $compile_commands_path ]] && rm ${compile_commands_path} -[[ -f $xcodebuild_log_path ]] && rm ${xcodebuild_log_path} - -echo "[OCLint] starting xcodebuild to build the project.." -if [ -d WordPress.xcworkspace ]; then - echo "[OCLint] we're running the script from the CLI" - xcode_workspace="WordPress.xcworkspace" - if [ ! $TRAVIS ]; then - oclint_args+=" -report-type=html -o=oclint_result.html" - fi - pipe_command="" -elif [ -d ../WordPress.xcworkspace ]; then - echo "[OCLint] we're running the script from Xcode" - xcode_workspace="../WordPress.xcworkspace" - pipe_command="| sed 's/\\(.*\\.\\m\\{1,2\\}:[0-9]*:[0-9]*:\\)/\\1 warning:/'" -else - # error! - echo >&2 "[OCLint] workspace not found, analyzing stopped" - exit 1 -fi - -echo "[OCLint] cleaning project" -xctool clean \ - -sdk "iphonesimulator8.4" \ - -workspace $xcode_workspace -configuration Debug -scheme WordPress \ - CONFIGURATION_BUILD_DIR=$build_dir \ - DSTROOT=$build_dir OBJROOT=$build_dir SYMROOT=$build_dir \ - reporter pretty \ - > ${temp_dir}/clean.log - -echo "[OCLint] building project" -xctool build \ - -sdk "iphonesimulator8.4" \ - CONFIGURATION_BUILD_DIR=$build_dir \ - -workspace $xcode_workspace -configuration Debug -scheme WordPress \ - DSTROOT=$build_dir OBJROOT=$build_dir SYMROOT=$build_dir \ - -reporter json-compilation-database:$compile_commands_path - - -if [ $TRAVIS ]; then - echo "[OCLint] only files changed on push"; - include_files=`git diff $TRAVIS_COMMIT_RANGE --name-only | grep '\.m' | tr '\n' '|' | sed 's/|*$/"/g'` - exclude_files="-e Pods/ -e Vendor/ -e WordPressTodayWidget/ -e SFHFKeychainUtils.m -e Constants.m" - base_commit=`echo $TRAVIS_COMMIT_RANGE | cut -d '.' -f 1` - base_commit+="^" - sha=`echo $TRAVIS_COMMIT_RANGE | cut -d '.' -f 4` - full_sha=`git rev-parse $sha` - echo $full_sha - if [ ! -z "$include_files" ]; then - include_files=' -i "'$include_files - else - exclude_files="-e *" - fi - echo "[OCLint] analyzing these files: $include_files" -elif [[ $1 == "DIFF" ]]; then - include_files=`git diff HEAD^ --name-only | grep '\.m' | tr '\n' '|' | sed 's/|*$/"/g'` - include_files=' -i "'$include_files - echo "[OCLint] only looking at this files: $include_files" - exclude_files="-e Pods/ -e Vendor/ -e WordPressTodayWidget/ -e SFHFKeychainUtils.m -e Constants.m" -elif [ $1 ]; then - include_files="-i ${check_file}" - exclude_files="-e Pods/ -e Vendor/ -e WordPressTodayWidget/ -e SFHFKeychainUtils.m -e Constants.m" -else - echo "[OCLint] Looking at all files" - include_files="" - exclude_files="-e Pods/ -e Vendor/ -e WordPressTodayWidget/ -e SFHFKeychainUtils.m -e Constants.m" -fi - -#echo "[*] transforming xcodebuild.log into compile_commands.json..." -cd ${temp_dir} -#oclint-xcodebuild -e Pods/ -o ${compile_commands_path} - -echo "[OCLint] starting analyzing" - -if [ $TRAVIS ]; then - eval "oclint-json-compilation-database $exclude_files $include_files oclint_args \"$oclint_args\" " > currentLint.log - cat currentLint.log - cd ${TRAVIS_BUILD_DIR} - git checkout $base_commit - cd ${temp_dir} - eval "oclint-json-compilation-database $exclude_files $include_files oclint_args \"$oclint_args\" " > baseLint.log - currentSummary=`cat currentLint.log | grep "Summary: "` - baseSummary=`cat baseLint.log | grep "Summary: "` - regex='P1=([[:digit:]]*) P2=([[:digit:]]*) P3=([[:digit:]]*)' - if [[ $currentSummary =~ $regex ]]; then - currentTotalSummary=( ${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}) - fi - if [[ $baseSummary =~ $regex ]]; then - baseTotalSummary=( ${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}) - fi - errors=0; - i=0 - n=3 - message="" - while [[ $i -lt $n ]] - do - if [[ currentTotalSummary[$i] -ge baseTotalSummary[$i] ]]; then - amount=$((${currentTotalSummary[$i]} - ${baseTotalSummary[$i]})) - errors+=$amount - message+=" P"$(($i+1))"=+"$amount - echo "[OCLint] Your changes introduced "$amount "P"$(($i+1))" issue(s)" - else - amount=$((${baseTotalSummary[$i]} - ${currentTotalSummary[$i]})) - message+=" P"$(($i+1))"=-"$amount - echo "[OCLint] Your changes removed "$amount "P"$(($i+1))" issue(s)" - fi - let i++ - done - - # going back to original push commit. - cd ${TRAVIS_BUILD_DIR} - git checkout $TRAVIS_COMMIT - - # sending message to github - travis_url="https://travis-ci.org/${TRAVIS_REPO_SLUG}/builds/${TRAVIS_BUILD_ID}/" - echo $travis_url - if [[ $errors -eq 0 ]]; then - state="success" - message="OK "$message - else - state="failure" - message="Failed "$message - fi - curl -i -H "Content-Type: application/json" \ - -H "Authorization: token ${TRAVIS_OCLINT_GITHUB_TOKEN}" \ - -d "{\"state\": \"${state}\",\"target_url\": \"${travis_url}\",\"description\": \"${message}\",\"context\": \"oclint\"}" \ - https://api.github.com/repos/${TRAVIS_REPO_SLUG}/statuses/$full_sha - - exit 0 -else - eval "oclint-json-compilation-database $exclude_files $include_files oclint_args \"$oclint_args\" $pipe_command" - echo "[OCLint] showing results" - if [ -d oclint_result.html ]; then - open oclint_result.html - fi - exit $? -fi diff --git a/Scripts/runUITests.sh b/Scripts/runUITests.sh deleted file mode 100755 index 81cc1af77afb..000000000000 --- a/Scripts/runUITests.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh - -#checking if xcpretty is available to use -pretty="xcpretty" -command -v xcpretty >/dev/null -if [ $? -eq 1 ]; then -echo >&2 "xcpretty not found don't use it." -pretty="&>"; -fi -#run tests using iPhone 6 simulator on iOS 8 -xcodebuild test -workspace WordPress.xcworkspace -scheme WordPressUITests -sdk iphonesimulator10.2 -destination 'platform=iOS Simulator,name=iPhone 7' | ${pretty} diff --git a/Scripts/update-translations.rb b/Scripts/update-translations.rb deleted file mode 100755 index 6701a96484b5..000000000000 --- a/Scripts/update-translations.rb +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env ruby -# encoding: utf-8 - -# Supported languages: -# ar,ca,cs,cy,da,de,el,en,en-CA,en-GB,es,fi,fr,he,hr,hu,id,it,ja,ko,ms,nb,nl,pl,pt,pt-PT,ro,ru,sk,sv,th,tr,uk,vi,zh-Hans,zh-Hant -# * Arabic -# * Catalan -# * Czech -# * Danish -# * German -# * Greek -# * English -# * English (Canada eh) -# * English (UK) -# * Spanish -# * Finnish -# * French -# * Hebrew -# * Croatian -# * Hungarian -# * Indonesian -# * Italian -# * Japanese -# * Korean -# * Malay -# * Norwegian (Bokmål) -# * Dutch -# * Polish -# * Portuguese -# * Portuguese (Portugal) -# * Romanian -# * Russian -# * Slovak -# * Swedish -# * Thai -# * Turkish -# * Ukranian -# * Vietnamese -# * Chinese (China) [zh-Hans] -# * Chinese (Taiwan) [zh-Hant] -# * Welsh - -if Dir.pwd =~ /Scripts/ - puts "Must run script from root folder" - exit -end - -ALL_LANGS={ - 'ar' => 'ar', # Arabic - 'bg' => 'bg', # Bulgarian - 'cs' => 'cs', # Czech - 'cy' => 'cy', # Welsh - 'da' => 'da', # Danish - 'de' => 'de', # German - 'en-au' => 'en-AU', # English (Australia) - 'en-ca' => 'en-CA', # English (Canada) - 'en-gb' => 'en-GB', # English (UK) - 'es' => 'es', # Spanish - 'fr' => 'fr', # French - 'he' => 'he', # Hebrew - 'hr' => 'hr', # Croatian - 'hu' => 'hu', # Hungarian - 'id' => 'id', # Indonesian - 'is' => 'is', # Icelandic - 'it' => 'it', # Italian - 'ja' => 'ja', # Japanese - 'ko' => 'ko', # Korean - 'nb' => 'nb', # Norwegian (Bokmål) - 'nl' => 'nl', # Dutch - 'pl' => 'pl', # Polish - 'pt' => 'pt', # Portuguese - 'pt-br' => 'pt-BR', # Portuguese (Brazil) - 'ro' => 'ro', # Romainian - 'ru' => 'ru', # Russian - 'sk' => 'sk', # Slovak - 'sq' => 'sq', # Albanian - 'sv' => 'sv', # Swedish - 'th' => 'th', # Thai - 'tr' => 'tr', # Turkish - 'zh-cn' => 'zh-Hans', # Chinese (China) - 'zh-tw' => 'zh-Hant', # Chinese (Taiwan) -} - -langs = {} -if ARGV.count > 0 - for key in ARGV - unless local = ALL_LANGS[key] - puts "Unknown language #{key}" - exit 1 - end - langs[key] = local - end -else - langs = ALL_LANGS -end - -langs.each do |code,local| - lang_dir = File.join('WordPress', 'Resources', "#{local}.lproj") - puts "Updating #{code}" - system "mkdir -p #{lang_dir}" - system "if [ -e #{lang_dir}/Localizable.strings ]; then cp #{lang_dir}/Localizable.strings #{lang_dir}/Localizable.strings.bak; fi" - - url = "https://translate.wordpress.org/projects/apps/ios/dev/#{code}/default/export-translations?format=strings" - destination = "#{lang_dir}/Localizable.strings" - - system "curl -fLso #{destination} #{url}" or begin - puts "Error downloading #{code}" - end - - if File.size(destination).to_f == 0 - abort("\e[31mFatal Error: #{destination} appears to be empty. Exiting.\e[0m") - end - - system "./Scripts/fix-translation #{lang_dir}/Localizable.strings" - system "plutil -lint #{lang_dir}/Localizable.strings" and system "rm #{lang_dir}/Localizable.strings.bak" - system "grep -a '\\x00\\x20\\x00\\x22\\x00\\x22\\x00\\x3b$' #{lang_dir}/Localizable.strings" -end -system "Scripts/extract-framework-translations.swift" diff --git a/WordPress.xcworkspace/contents.xcworkspacedata b/WordPress.xcworkspace/contents.xcworkspacedata index be0a88cc2010..eca405337189 100644 --- a/WordPress.xcworkspace/contents.xcworkspacedata +++ b/WordPress.xcworkspace/contents.xcworkspacedata @@ -5,7 +5,7 @@ location = "group:WordPress/WordPress.xcodeproj"> </FileRef> <FileRef - location = "group:WordPressFlux/WordPressFlux.xcodeproj"> + location = "group:WordPressFlux"> </FileRef> <FileRef location = "group:Pods/Pods.xcodeproj"> diff --git a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000000..cfdb51af1b5f --- /dev/null +++ b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,106 @@ +{ + "object": { + "pins": [ + { + "package": "AutomatticAbout", + "repositoryURL": "https://github.com/automattic/AutomatticAbout-swift", + "state": { + "branch": null, + "revision": "0f784591b324e5d3ddc5771808ef8eca923e3de2", + "version": "1.1.2" + } + }, + { + "package": "Charts", + "repositoryURL": "https://github.com/danielgindi/Charts", + "state": { + "branch": null, + "revision": "07b23476ad52b926be772f317d8f1d4511ee8d02", + "version": "4.1.0" + } + }, + { + "package": "CwlCatchException", + "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", + "state": { + "branch": null, + "revision": "35f9e770f54ce62dd8526470f14c6e137cef3eea", + "version": "2.1.1" + } + }, + { + "package": "CwlPreconditionTesting", + "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git", + "state": { + "branch": null, + "revision": "c21f7bab5ca8eee0a9998bbd17ca1d0eb45d4688", + "version": "2.1.0" + } + }, + { + "package": "Lottie", + "repositoryURL": "https://github.com/airbnb/lottie-ios.git", + "state": { + "branch": null, + "revision": "4ca8023b820b7d5d5ae1e2637c046e3dab0f45d0", + "version": "3.4.2" + } + }, + { + "package": "Nimble", + "repositoryURL": "https://github.com/Quick/Nimble", + "state": { + "branch": null, + "revision": "1f3bde57bde12f5e7b07909848c071e9b73d6edc", + "version": "10.0.0" + } + }, + { + "package": "ScreenObject", + "repositoryURL": "https://github.com/Automattic/ScreenObject", + "state": { + "branch": null, + "revision": "cb38a32bbcc733ba03e307ca7bcae63f8c5de729", + "version": "0.2.2" + } + }, + { + "package": "swift-algorithms", + "repositoryURL": "https://github.com/apple/swift-algorithms", + "state": { + "branch": null, + "revision": "b14b7f4c528c942f121c8b860b9410b2bf57825e", + "version": "1.0.0" + } + }, + { + "package": "swift-numerics", + "repositoryURL": "https://github.com/apple/swift-numerics", + "state": { + "branch": null, + "revision": "0a5bc04095a675662cf24757cc0640aa2204253b", + "version": "1.0.2" + } + }, + { + "package": "BuildkiteTestCollector", + "repositoryURL": "https://github.com/buildkite/test-collector-swift", + "state": { + "branch": null, + "revision": "77c7f492f5c1c9ca159f73d18f56bbd1186390b0", + "version": "0.3.0" + } + }, + { + "package": "XCUITestHelpers", + "repositoryURL": "https://github.com/Automattic/XCUITestHelpers", + "state": { + "branch": null, + "revision": "5179cb69d58b90761cc713bdee7740c4889d3295", + "version": "0.4.0" + } + } + ] + }, + "version": 1 +} diff --git a/WordPress/Classes/Categories/Media+WPMediaAsset.m b/WordPress/Classes/Categories/Media+WPMediaAsset.m index 43dc6c8803ad..18e89d8c33a2 100644 --- a/WordPress/Classes/Categories/Media+WPMediaAsset.m +++ b/WordPress/Classes/Categories/Media+WPMediaAsset.m @@ -1,7 +1,7 @@ #import "Media+WPMediaAsset.h" #import "MediaService.h" #import "Blog.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "WordPress-Swift.h" @implementation Media(WPMediaAsset) @@ -47,9 +47,16 @@ - (WPMediaRequestID)videoAssetWithCompletionHandler:(WPMediaAssetBlock)completio if (!url && self.videopressGUID.length > 0 ){ NSManagedObjectContext *mainContext = [[ContextManager sharedInstance] mainContext]; MediaService *mediaService = [[MediaService alloc] initWithManagedObjectContext:mainContext]; - [mediaService getMediaURLFromVideoPressID:self.videopressGUID inBlog:self.blog success:^(NSString *videoURL, NSString *posterURL) { + [mediaService getMetadataFromVideoPressID: self.videopressGUID inBlog:self.blog success:^(RemoteVideoPressVideo *metadata) { // Let see if can create an asset with this url - AVURLAsset *asset = [AVURLAsset assetWithURL:[NSURL URLWithString:videoURL]]; + NSURL *originalURL = metadata.originalURL; + if (!originalURL) { + NSString *errorMessage = NSLocalizedString(@"Selected media is unavailable.", @"Error message when user tries a no longer existent video media object."); + completionHandler(nil, [self errorWithMessage:errorMessage]); + return; + } + NSURL *videoURL = [metadata getURLWithToken:originalURL] ?: originalURL; + AVURLAsset *asset = [AVURLAsset assetWithURL:videoURL]; if (!asset) { NSString *errorMessage = NSLocalizedString(@"Selected media is unavailable.", @"Error message when user tries a no longer existent video media object."); completionHandler(nil, [self errorWithMessage:errorMessage]); diff --git a/WordPress/Classes/Categories/NSAttributedString+Util.h b/WordPress/Classes/Categories/NSAttributedString+Util.h deleted file mode 100644 index 4a320be5affc..000000000000 --- a/WordPress/Classes/Categories/NSAttributedString+Util.h +++ /dev/null @@ -1,9 +0,0 @@ -#import <Foundation/Foundation.h> - - - -@interface NSMutableAttributedString (Util) - -- (void)applyAttributesToQuotes:(NSDictionary *)attributes; - -@end diff --git a/WordPress/Classes/Categories/NSAttributedString+Util.m b/WordPress/Classes/Categories/NSAttributedString+Util.m deleted file mode 100644 index 0e083044b069..000000000000 --- a/WordPress/Classes/Categories/NSAttributedString+Util.m +++ /dev/null @@ -1,22 +0,0 @@ -#import "NSAttributedString+Util.h" -#import "NSScanner+Helpers.h" - - - -@implementation NSMutableAttributedString (Util) - -- (void)applyAttributesToQuotes:(NSDictionary *)attributes -{ - NSString *rawText = self.string; - NSScanner *scanner = [NSScanner scannerWithString:rawText]; - NSArray *quotes = [scanner scanQuotedText]; - - for (NSString *quote in quotes) { - NSRange itemRange = [rawText rangeOfString:quote]; - if (itemRange.location != NSNotFound) { - [self addAttributes:attributes range:itemRange]; - } - } -} - -@end diff --git a/WordPress/Classes/Categories/NSObject+Helpers.h b/WordPress/Classes/Categories/NSObject+Helpers.h index 10df519e4a78..65607f071262 100644 --- a/WordPress/Classes/Categories/NSObject+Helpers.h +++ b/WordPress/Classes/Categories/NSObject+Helpers.h @@ -1,9 +1,12 @@ #import <Foundation/Foundation.h> - +NS_ASSUME_NONNULL_BEGIN @interface NSObject (Helpers) -+ (nonnull NSString *)classNameWithoutNamespaces; ++ (NSString *)classNameWithoutNamespaces; +- (void)debounce:(SEL)selector afterDelay:(NSTimeInterval)timeInterval; @end + +NS_ASSUME_NONNULL_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/Categories/NSScanner+Helpers.h b/WordPress/Classes/Categories/NSScanner+Helpers.h deleted file mode 100644 index 461c702571c0..000000000000 --- a/WordPress/Classes/Categories/NSScanner+Helpers.h +++ /dev/null @@ -1,9 +0,0 @@ -#import <Foundation/Foundation.h> - - - -@interface NSScanner (Helpers) - -- (NSArray *)scanQuotedText; - -@end diff --git a/WordPress/Classes/Categories/NSScanner+Helpers.m b/WordPress/Classes/Categories/NSScanner+Helpers.m deleted file mode 100644 index 08c7847929dd..000000000000 --- a/WordPress/Classes/Categories/NSScanner+Helpers.m +++ /dev/null @@ -1,26 +0,0 @@ -#import "NSScanner+Helpers.h" - - - -@implementation NSScanner (Helpers) - -- (NSArray *)scanQuotedText -{ - NSMutableArray *scanned = [NSMutableArray array]; - NSString *quote = nil; - - while ([self isAtEnd] == NO) { - [self scanUpToString:@"\"" intoString:nil]; - [self scanString:@"\"" intoString:nil]; - [self scanUpToString:@"\"" intoString:"e]; - [self scanString:@"\"" intoString:nil]; - - if (quote.length) { - [scanned addObject:quote]; - } - } - - return scanned; -} - -@end diff --git a/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.h b/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.h new file mode 100644 index 000000000000..d387ec7b8109 --- /dev/null +++ b/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.h @@ -0,0 +1,19 @@ +#import <UIKit/UIKit.h> + +@class Blog; + +NS_ASSUME_NONNULL_BEGIN + +@interface UIViewController (RemoveQuickStart) + + +/// Displays an action sheet with an option to remove current quickstart tours from the provided blog. +/// Displayed as an action sheet on iPhone and as a popover on iPad +/// @param blog Blog to remove quickstart from +/// @param sourceView View used as sourceView for the sheet's popoverPresentationController +/// @param sourceRect rect used as sourceRect for the sheet's popoverPresentationController +- (void)removeQuickStartFromBlog:(Blog *)blog sourceView:(UIView *)sourceView sourceRect:(CGRect)sourceRect; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.m b/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.m new file mode 100644 index 000000000000..f4edb6f2d68b --- /dev/null +++ b/WordPress/Classes/Categories/UIViewController+RemoveQuickStart.m @@ -0,0 +1,40 @@ +#import "UIViewController+RemoveQuickStart.h" + +#import "Blog.h" +#import "WordPress-Swift.h" + +@implementation UIViewController (RemoveQuickStart) + +- (void)removeQuickStartFromBlog:(Blog *)blog sourceView:(UIView *)sourceView sourceRect:(CGRect)sourceRect +{ + [NoticesDispatch lock]; + NSString *removeTitle = NSLocalizedString(@"Remove Next Steps", @"Title for action that will remove the next steps/quick start menus."); + NSString *removeMessage = NSLocalizedString(@"Removing Next Steps will hide all tours on this site. This action cannot be undone.", @"Explanation of what will happen if the user confirms this alert."); + NSString *confirmationTitle = NSLocalizedString(@"Remove", @"Title for button that will confirm removing the next steps/quick start menus."); + NSString *cancelTitle = NSLocalizedString(@"Cancel", @"Cancel button"); + + UIAlertController *removeConfirmation = [UIAlertController alertControllerWithTitle:removeTitle message:removeMessage preferredStyle:UIAlertControllerStyleAlert]; + [removeConfirmation addCancelActionWithTitle:cancelTitle handler:^(UIAlertAction * _Nonnull __unused action) { + [WPAnalytics trackQuickStartStat:WPAnalyticsStatQuickStartRemoveDialogButtonCancelTapped blog: blog]; + [NoticesDispatch unlock]; + }]; + [removeConfirmation addDefaultActionWithTitle:confirmationTitle handler:^(UIAlertAction * _Nonnull __unused action) { + [WPAnalytics trackQuickStartStat:WPAnalyticsStatQuickStartRemoveDialogButtonRemoveTapped blog: blog]; + [[QuickStartTourGuide shared] removeFrom:blog]; + [NoticesDispatch unlock]; + }]; + + UIAlertController *removeSheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + removeSheet.popoverPresentationController.sourceView = sourceView; + removeSheet.popoverPresentationController.sourceRect = sourceRect; + [removeSheet addDestructiveActionWithTitle:removeTitle handler:^(UIAlertAction * _Nonnull __unused action) { + [self presentViewController:removeConfirmation animated:YES completion:nil]; + }]; + [removeSheet addCancelActionWithTitle:cancelTitle handler:^(UIAlertAction * _Nonnull __unused action) { + [NoticesDispatch unlock]; + }]; + + [self presentViewController:removeSheet animated:YES completion:nil]; +} + +@end diff --git a/WordPress/Classes/Categories/WPStyleGuide+Suggestions.m b/WordPress/Classes/Categories/WPStyleGuide+Suggestions.m index 656c5374fc0b..145ff18ec4c6 100644 --- a/WordPress/Classes/Categories/WPStyleGuide+Suggestions.m +++ b/WordPress/Classes/Categories/WPStyleGuide+Suggestions.m @@ -4,7 +4,13 @@ @implementation WPStyleGuide (Suggestions) + (UIColor *)suggestionsHeaderSmoke { - return [UIColor colorWithRed:0. green:0. blue:0. alpha:0.3]; + return [UIColor colorWithDynamicProvider:^(UITraitCollection *traitCollection) { + if (traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark) { + return [UIColor colorWithRed:0. green:0. blue:0. alpha:0.7]; + } else { + return [UIColor colorWithRed:0. green:0. blue:0. alpha:0.3]; + } + }]; } + (UIColor *)suggestionsSeparatorSmoke diff --git a/WordPress/Classes/Extensions/AbstractPost+Dates.swift b/WordPress/Classes/Extensions/AbstractPost+Dates.swift index 63cf00d0e0b3..d5b661758a22 100644 --- a/WordPress/Classes/Extensions/AbstractPost+Dates.swift +++ b/WordPress/Classes/Extensions/AbstractPost+Dates.swift @@ -8,13 +8,13 @@ extension AbstractPost { /// - **Immediately**: Displays "Publish Immediately" string /// - **Published or Draft**: Shows relative date when < 7 days public func displayDate() -> String? { - let context = managedObjectContext ?? ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - let timeZone = blogService.timeZone(for: blog) + assert(self.managedObjectContext != nil) + + let timeZone = blog.timeZone // Unpublished post shows relative or date string if originalIsDraft() || status == .pending { - return dateModified?.mediumString(timeZone: timeZone) + return dateModified?.toMediumString(inTimeZone: timeZone) } // Scheduled Post shows date with time to be clear about when it goes live @@ -27,6 +27,6 @@ extension AbstractPost { return NSLocalizedString("Publish Immediately", comment: "A short phrase indicating a post is due to be immedately published.") } - return dateCreated?.mediumString(timeZone: timeZone) + return dateCreated?.toMediumString(inTimeZone: timeZone) } } diff --git a/WordPress/Classes/Extensions/AbstractPost+PostInformation.swift b/WordPress/Classes/Extensions/AbstractPost+PostInformation.swift deleted file mode 100644 index 39bc42b56466..000000000000 --- a/WordPress/Classes/Extensions/AbstractPost+PostInformation.swift +++ /dev/null @@ -1,27 +0,0 @@ - -extension AbstractPost: ImageSourceInformation { - var isPrivateOnWPCom: Bool { - return isPrivate() && blog.isHostedAtWPcom - } - - var isSelfHostedWithCredentials: Bool { - return blog.isSelfHostedWithCredentials - } - - var isLocalRevision: Bool { - return self.originalIsDraft() && self.isRevision() && self.remoteStatus == .local - } - - /// Returns true if the post is a draft and has never been uploaded to the server. - var isLocalDraft: Bool { - return self.isDraft() && !self.hasRemote() - } - - /// An autosave revision may include post title, content and/or excerpt. - var hasAutosaveRevision: Bool { - guard let autosaveRevisionIdentifier = autosaveIdentifier?.intValue else { - return false - } - return autosaveRevisionIdentifier > 0 - } -} diff --git a/WordPress/Classes/Extensions/Array+Page.swift b/WordPress/Classes/Extensions/Array+Page.swift index cb5f3743aaaf..a1d2a4689123 100644 --- a/WordPress/Classes/Extensions/Array+Page.swift +++ b/WordPress/Classes/Extensions/Array+Page.swift @@ -88,6 +88,19 @@ extension Array where Element == Page { .hierachyIndexes() } + /// Moves the homepage first if it is on the top level + /// + /// - Returns: An Array of Elements + func setHomePageFirst() -> [Element] { + if let homepageIndex = self.firstIndex(where: { $0.isSiteHomepage }) { + var pages: [Page] = Array(self) + let homepage = pages.remove(at: homepageIndex) + pages.insert(homepage, at: 0) + return pages + } + return self + } + /// Remove Elements from a specific index /// /// - Parameter index: The starting index diff --git a/WordPress/Classes/Extensions/Binding+OnChange.swift b/WordPress/Classes/Extensions/Binding+OnChange.swift new file mode 100644 index 000000000000..d5b3c0382db7 --- /dev/null +++ b/WordPress/Classes/Extensions/Binding+OnChange.swift @@ -0,0 +1,13 @@ +import SwiftUI + +extension Binding { + func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> { + Binding( + get: { self.wrappedValue }, + set: { newValue in + self.wrappedValue = newValue + handler(newValue) + } + ) + } +} diff --git a/WordPress/Classes/Extensions/Blog+ImageSourceInformation.swift b/WordPress/Classes/Extensions/Blog+ImageSourceInformation.swift deleted file mode 100644 index 5e1c8f4125ed..000000000000 --- a/WordPress/Classes/Extensions/Blog+ImageSourceInformation.swift +++ /dev/null @@ -1,10 +0,0 @@ - -extension Blog: ImageSourceInformation { - var isPrivateOnWPCom: Bool { - return isHostedAtWPcom && isPrivate() - } - - var isSelfHostedWithCredentials: Bool { - return !isHostedAtWPcom && isBasicAuthCredentialStored() - } -} diff --git a/WordPress/Classes/Extensions/Bool+StringRepresentation.swift b/WordPress/Classes/Extensions/Bool+StringRepresentation.swift new file mode 100644 index 000000000000..f7ba822bd9bf --- /dev/null +++ b/WordPress/Classes/Extensions/Bool+StringRepresentation.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Bool { + var stringLiteral: String { + self ? "true" : "false" + } +} diff --git a/WordPress/Classes/Extensions/CollectionType+Helpers.swift b/WordPress/Classes/Extensions/CollectionType+Helpers.swift deleted file mode 100644 index 997d7749b626..000000000000 --- a/WordPress/Classes/Extensions/CollectionType+Helpers.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -extension BidirectionalCollection { - public func lastIndex(where predicate: (Self.Iterator.Element) throws -> Bool) rethrows -> Self.Index? { - if let idx = try reversed().firstIndex(where: predicate) { - return self.index(before: idx.base) - } - return nil - } -} - -extension Collection { - - /// Returns the element at the specified index if it is within bounds, otherwise nil. - subscript (safe index: Index) -> Element? { - return indices.contains(index) ? self[index] : nil - } -} diff --git a/WordPress/Classes/Extensions/Colors and Styles/MurielColor.swift b/WordPress/Classes/Extensions/Colors and Styles/MurielColor.swift index b1aa91ea8bba..9ec538d8ea10 100644 --- a/WordPress/Classes/Extensions/Colors and Styles/MurielColor.swift +++ b/WordPress/Classes/Extensions/Colors and Styles/MurielColor.swift @@ -11,6 +11,7 @@ enum MurielColorName: String, CustomStringConvertible { case purple case red case yellow + case jetpackGreen var description: String { // can't use .capitalized because it lowercases the P and B in "wordPressBlue" @@ -57,16 +58,18 @@ struct MurielColor { } // MARK: - Muriel's semantic colors - static let accent = MurielColor(name: .pink) - static let brand = MurielColor(name: .wordPressBlue) - static let divider = MurielColor(name: .gray, shade: .shade10) - static let error = MurielColor(name: .red) - static let gray = MurielColor(name: .gray) - static let primary = MurielColor(name: .blue) - static let success = MurielColor(name: .green) - static let text = MurielColor(name: .gray, shade: .shade80) - static let textSubtle = MurielColor(name: .gray, shade: .shade50) - static let warning = MurielColor(name: .yellow) + static let accent = AppStyleGuide.accent + static let brand = AppStyleGuide.brand + static let divider = AppStyleGuide.divider + static let error = AppStyleGuide.error + static let gray = AppStyleGuide.gray + static let primary = AppStyleGuide.primary + static let success = AppStyleGuide.success + static let text = AppStyleGuide.text + static let textSubtle = AppStyleGuide.textSubtle + static let warning = AppStyleGuide.warning + static let jetpackGreen = AppStyleGuide.jetpackGreen + static let editorPrimary = AppStyleGuide.editorPrimary /// The full name of the color, with required shade value func assetName() -> String { diff --git a/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColors.swift b/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColors.swift index 85cdbc20f605..a6e962a4ebfd 100644 --- a/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColors.swift +++ b/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColors.swift @@ -21,6 +21,16 @@ extension UIColor { let newColor = MurielColor(from: color, shade: shade) return muriel(color: newColor) } + + /// Get a UIColor from the Muriel color palette by name, adjusted to a given shade + /// - Parameters: + /// - name: a MurielColorName + /// - shade: a MurielColorShade + /// - Returns: the desired color/shade + class func muriel(name: MurielColorName, _ shade: MurielColorShade) -> UIColor { + let newColor = MurielColor(name: name, shade: shade) + return muriel(color: newColor) + } } // MARK: - Basic Colors extension UIColor { @@ -52,6 +62,12 @@ extension UIColor { return muriel(color: .primary, shade) } + /// Muriel editor primary color + static var editorPrimary = muriel(color: .editorPrimary) + class func editorPrimary(_ shade: MurielColorShade) -> UIColor { + return muriel(color: .editorPrimary, shade) + } + /// Muriel success color static var success = muriel(color: .success) class func success(_ shade: MurielColorShade) -> UIColor { @@ -63,6 +79,9 @@ extension UIColor { class func warning(_ shade: MurielColorShade) -> UIColor { return muriel(color: .warning, shade) } + + /// Muriel jetpack green color + static var jetpackGreen = muriel(color: .jetpackGreen) } // MARK: - Grays @@ -112,212 +131,184 @@ extension UIColor { extension UIColor { /// The most basic background: white in light mode, black in dark mode static var basicBackground: UIColor { - if #available(iOS 13, *) { - return .systemBackground - } - return .white + return .systemBackground } /// Tertiary background static var tertiaryBackground: UIColor { - if #available(iOS 13, *) { - return .tertiarySystemBackground - } + return .tertiarySystemBackground + } - return .neutral(.shade10) + /// Quaternary background + static var quaternaryBackground: UIColor { + return .quaternarySystemFill } + /// Tertiary system fill + static var tertiaryFill: UIColor { + return .tertiarySystemFill + } + /// Default text color: high contrast static var text: UIColor { - if #available(iOS 13, *) { - return .label - } - - return muriel(color: .text) + return .label } /// Secondary text color: less contrast static var textSubtle: UIColor { - if #available(iOS 13, *) { - return .secondaryLabel - } - - return muriel(color: .gray) + return .secondaryLabel } /// Very low contrast text static var textTertiary: UIColor { - if #available(iOS 13, *) { - return .tertiaryLabel - } - - return UIColor.neutral(.shade20) + return .tertiaryLabel } /// Very, very low contrast text static var textQuaternary: UIColor { - if #available(iOS 13, *) { - return .quaternaryLabel - } - - return UIColor.neutral(.shade10) + return .quaternaryLabel } static var textInverted = UIColor(light: .white, dark: .gray(.shade100)) static var textPlaceholder: UIColor { - if #available(iOS 13, *) { - return .tertiaryLabel - } - - return neutral(.shade30) + return .tertiaryLabel } static var placeholderElement: UIColor { - if #available(iOS 13, *) { - return UIColor(light: .systemGray5, dark: .systemGray4) - } - - return .gray(.shade10) + return UIColor(light: .systemGray5, dark: .systemGray4) } + static var placeholderElementFaded: UIColor { - if #available(iOS 13, *) { - return UIColor(light: .systemGray6, dark: .systemGray5) - } + return UIColor(light: .systemGray6, dark: .systemGray5) + } + + // MARK: - Search Fields - return .gray(.shade5) + static var searchFieldPlaceholderText: UIColor { + return .secondaryLabel } - /// Muriel/iOS navigation color - static var appBar = UIColor(light: .brand, dark: .gray(.shade100)) + static var searchFieldIcons: UIColor { + return .secondaryLabel + } // MARK: - Table Views static var divider: UIColor { - if #available(iOS 13, *) { - return .separator - } + return .separator + } - return muriel(color: .divider) + static var primaryButtonBorder: UIColor { + return .opaqueSeparator } /// WP color for table foregrounds (cells, etc) static var listForeground: UIColor { - if #available(iOS 13, *) { - return .secondarySystemGroupedBackground - } - - return .white + return .secondarySystemGroupedBackground } static var listForegroundUnread: UIColor { - if #available(iOS 13, *) { - return .tertiarySystemGroupedBackground - } - - return .primary(.shade0) + return .tertiarySystemGroupedBackground } static var listBackground: UIColor { - if #available(iOS 13, *) { - return .systemGroupedBackground - } + return .systemGroupedBackground + } + + static var ungroupedListBackground: UIColor { + return .systemBackground + } - return muriel(color: .gray, .shade0) + static var ungroupedListUnread: UIColor { + return UIColor(light: .primary(.shade0), dark: muriel(color: .gray, .shade80)) } /// For icons that are present in a table view, or similar list static var listIcon: UIColor { - if #available(iOS 13, *) { - return .secondaryLabel - } - - return .neutral(.shade20) + return .secondaryLabel } /// For small icons, such as the badges on notification gravatars static var listSmallIcon: UIColor { - if #available(iOS 13, *) { - return .systemGray - } + return .systemGray + } - return UIColor.neutral(.shade20) + static var buttonIcon: UIColor { + return .systemGray2 } - static var filterBarBackground: UIColor { - if #available(iOS 13, *) { - return UIColor(light: white, dark: .gray(.shade100)) - } + /// For icons that are present in a toolbar or similar view + static var toolbarInactive: UIColor { + return .secondaryLabel + } - return white + static var barButtonItemTitle: UIColor { + return UIColor(light: UIColor.primary(.shade50), dark: UIColor.primary(.shade30)) } - static var filterBarSelected: UIColor { - if #available(iOS 13, *) { - return UIColor(light: .primary, dark: .label) - } +// MARK: - WP Fancy Buttons + static var primaryButtonBackground = primary + static var primaryButtonDownBackground = muriel(color: .primary, .shade80) - return .primary + static var secondaryButtonBackground: UIColor { + return UIColor(light: .white, dark: .systemGray5) } - /// For icons that are present in a toolbar or similar view - static var toolbarInactive: UIColor { - if #available(iOS 13, *) { - return .secondaryLabel - } - - return .neutral(.shade30) + static var secondaryButtonBorder: UIColor { + return .systemGray3 } - /// Note: these values are intended to match the iOS defaults - static var tabUnselected: UIColor = UIColor(light: UIColor(hexString: "999999"), dark: UIColor(hexString: "757575")) + static var secondaryButtonDownBackground: UIColor { + return .systemGray3 + } -// MARK: - WP Fancy Buttons - static var primaryButtonBackground = accent - static var primaryButtonDownBackground = muriel(color: .accent, .shade80) + static var secondaryButtonDownBorder: UIColor { + return secondaryButtonBorder + } - static var secondaryButtonBackground: UIColor { - if #available(iOS 13, *) { - return UIColor(light: .white, dark: .systemGray5) - } + static var authSecondaryButtonBackground: UIColor { + return UIColor(light: .white, dark: .black) + } - return .white + static var authButtonViewBackground: UIColor { + return UIColor(light: .white, dark: .black) } - static var secondaryButtonBorder: UIColor { - if #available(iOS 13, *) { - return .systemGray3 - } + // MARK: - Quick Action Buttons - return .neutral(.shade20) + static var quickActionButtonBackground: UIColor { + .clear } - static var secondaryButtonDownBackground: UIColor { + static var quickActionButtonBorder: UIColor { + .systemGray3 + } - if #available(iOS 13, *) { - return .systemGray3 - } + static var quickActionSelectedBackground: UIColor { + UIColor(light: .black, dark: .white).withAlphaComponent(0.17) + } + + // MARK: - Others - return .neutral(.shade20) + static var preformattedBackground: UIColor { + return .systemGray6 } - static var secondaryButtonDownBorder: UIColor { - return secondaryButtonBorder + static var prologueBackground: UIColor { + return UIColor(light: muriel(color: MurielColor(name: .blue, shade: .shade0)), dark: .systemBackground) } } +@objc extension UIColor { // A way to create dynamic colors that's compatible with iOS 11 & 12 + @objc convenience init(light: UIColor, dark: UIColor) { - if #available(iOS 13, *) { - self.init { traitCollection in - if traitCollection.userInterfaceStyle == .dark { - return dark - } else { - return light - } + self.init { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + return dark + } else { + return light } - } else { - // in older versions of iOS, we assume light mode - self.init(color: light) } } @@ -334,9 +325,17 @@ extension UIColor { extension UIColor { func color(for trait: UITraitCollection?) -> UIColor { - if #available(iOS 13, *), let trait = trait { + if let trait = trait { return resolvedColor(with: trait) } return self } + + func lightVariant() -> UIColor { + return color(for: UITraitCollection(userInterfaceStyle: .light)) + } + + func darkVariant() -> UIColor { + return color(for: UITraitCollection(userInterfaceStyle: .dark)) + } } diff --git a/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColorsObjC.swift b/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColorsObjC.swift index 4f91aedf2315..770acee2a39b 100644 --- a/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColorsObjC.swift +++ b/WordPress/Classes/Extensions/Colors and Styles/UIColor+MurielColorsObjC.swift @@ -16,6 +16,11 @@ return .primaryDark } + @available(swift, obsoleted: 1.0) + static func murielEditorPrimary() -> UIColor { + return .editorPrimary + } + @available(swift, obsoleted: 1.0) static func murielNeutral() -> UIColor { return .neutral @@ -130,4 +135,14 @@ static func murielListIcon() -> UIColor { return .listIcon } + + @available(swift, obsoleted: 1.0) + static func murielAppBarText() -> UIColor { + return .appBarText + } + + @available(swift, obsoleted: 1.0) + static func murielAppBarBackground() -> UIColor { + return .appBarBackground + } } diff --git a/WordPress/Classes/Extensions/Colors and Styles/UIColor+Notice.swift b/WordPress/Classes/Extensions/Colors and Styles/UIColor+Notice.swift new file mode 100644 index 000000000000..7cf1eaed3dab --- /dev/null +++ b/WordPress/Classes/Extensions/Colors and Styles/UIColor+Notice.swift @@ -0,0 +1,26 @@ +extension UIColor { + + static var invertedSystem5: UIColor { + return UIColor(light: UIColor.systemGray5.color(for: UITraitCollection(userInterfaceStyle: .dark)), + dark: UIColor.systemGray5.color(for: UITraitCollection(userInterfaceStyle: .light))) + } + + static var invertedLabel: UIColor { + return UIColor(light: UIColor.label.color(for: UITraitCollection(userInterfaceStyle: .dark)), + dark: UIColor.label.color(for: UITraitCollection(userInterfaceStyle: .light))) + } + + static var invertedSecondaryLabel: UIColor { + return UIColor(light: UIColor.secondaryLabel.color(for: UITraitCollection(userInterfaceStyle: .dark)), + dark: UIColor.secondaryLabel.color(for: UITraitCollection(userInterfaceStyle: .light))) + } + + static var invertedLink: UIColor { + UIColor(light: .primary(.shade30), dark: .primary(.shade50)) + } + + static var invertedSeparator: UIColor { + return UIColor(light: UIColor.separator.color(for: UITraitCollection(userInterfaceStyle: .dark)), + dark: UIColor.separator.color(for: UITraitCollection(userInterfaceStyle: .light))) + } +} diff --git a/WordPress/Classes/Extensions/Colors and Styles/UIColor+WordPressColors.swift b/WordPress/Classes/Extensions/Colors and Styles/UIColor+WordPressColors.swift new file mode 100644 index 000000000000..d00d0c249430 --- /dev/null +++ b/WordPress/Classes/Extensions/Colors and Styles/UIColor+WordPressColors.swift @@ -0,0 +1,49 @@ +import UIKit + +// MARK: - UI elements +extension UIColor { + + /// Muriel/iOS navigation color + static var appBarBackground: UIColor { + UIColor(light: .white, dark: .gray(.shade100)) + } + + static var appBarTint: UIColor { + .primary + } + + static var lightAppBarTint: UIColor { + return UIColor(light: .primary, dark: .white) + } + + static var appBarText: UIColor { + .text + } + + static var filterBarBackground: UIColor { + return UIColor(light: .white, dark: .gray(.shade100)) + } + + static var filterBarSelected: UIColor { + return UIColor(light: .primary, dark: .label) + } + + static var filterBarSelectedText: UIColor { + return UIColor(light: .primary, dark: .label) + } + + static var tabSelected: UIColor { + return .primary + } + + /// Note: these values are intended to match the iOS defaults + static var tabUnselected: UIColor = UIColor(light: UIColor(hexString: "999999"), dark: UIColor(hexString: "757575")) + + static var statsPrimaryHighlight: UIColor { + return UIColor(light: .accent(.shade30), dark: .accent(.shade60)) + } + + static var statsSecondaryHighlight: UIColor { + return UIColor(light: .accent(.shade60), dark: .accent(.shade30)) + } +} diff --git a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift index 5df6f4a8c754..b580087879c3 100644 --- a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift +++ b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift @@ -2,17 +2,19 @@ import Foundation import WordPressShared extension WPStyleGuide { - // MARK: - styles used before Muriel colors are enabled - public class func navigationBarBackgroundImage() -> UIImage { - return UIImage(color: WPStyleGuide.wordPressBlue()) + @objc + public class var preferredStatusBarStyle: UIStatusBarStyle { + .default } - public class func navigationBarBarStyle() -> UIBarStyle { - return .black + @objc + public class var navigationBarStandardFont: UIFont { + return AppStyleGuide.navigationBarStandardFont } - public class func navigationBarShadowImage() -> UIImage { - return UIImage(color: UIColor(fromHex: 0x007eb1)) + @objc + public class var navigationBarLargeFont: UIFont { + return AppStyleGuide.navigationBarLargeFont } class func configureDefaultTint() { @@ -23,45 +25,62 @@ extension WPStyleGuide { class func configureNavigationAppearance() { let navigationAppearance = UINavigationBar.appearance() navigationAppearance.isTranslucent = false - navigationAppearance.tintColor = .white - navigationAppearance.barTintColor = .appBar - navigationAppearance.barStyle = .black + navigationAppearance.tintColor = .appBarTint + navigationAppearance.barTintColor = .appBarBackground - if #available(iOS 13.0, *) { - // Required to fix detail navigation controller appearance due to https://stackoverflow.com/q/56615513 - let appearance = UINavigationBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundColor = .appBar - appearance.titleTextAttributes = [.foregroundColor: UIColor.white] - navigationAppearance.standardAppearance = appearance - navigationAppearance.scrollEdgeAppearance = navigationAppearance.standardAppearance - } + var textAttributes: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.appBarText] + let largeTitleTextAttributes: [NSAttributedString.Key: Any] = [.font: WPStyleGuide.navigationBarLargeFont] + + textAttributes[.font] = WPStyleGuide.navigationBarStandardFont + + navigationAppearance.titleTextAttributes = textAttributes + navigationAppearance.largeTitleTextAttributes = largeTitleTextAttributes + + // Required to fix detail navigation controller appearance due to https://stackoverflow.com/q/56615513 + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = .appBarBackground + appearance.titleTextAttributes = textAttributes + appearance.largeTitleTextAttributes = largeTitleTextAttributes + appearance.shadowColor = .separator + + let scrollEdgeAppearance = appearance.copy() + scrollEdgeAppearance.shadowColor = .clear + navigationAppearance.scrollEdgeAppearance = scrollEdgeAppearance + + navigationAppearance.standardAppearance = appearance + navigationAppearance.compactAppearance = appearance let buttonBarAppearance = UIBarButtonItem.appearance() - buttonBarAppearance.tintColor = .white - buttonBarAppearance.setTitleTextAttributes([NSAttributedString.Key.font: WPFontManager.systemRegularFont(ofSize: 17.0), - NSAttributedString.Key.foregroundColor: UIColor.white], - for: .normal) - buttonBarAppearance.setTitleTextAttributes([NSAttributedString.Key.font: WPFontManager.systemRegularFont(ofSize: 17.0), - NSAttributedString.Key.foregroundColor: UIColor.white.withAlphaComponent(0.25)], - for: .disabled) + buttonBarAppearance.tintColor = .appBarTint + } + + /// Style `UITableView` in the app + class func configureTableViewAppearance() { + if #available(iOS 15.0, *) { + UITableView.appearance().sectionHeaderTopPadding = 0 + } } /// Style the tab bar using Muriel colors class func configureTabBarAppearance() { - UITabBar.appearance().tintColor = .primary + UITabBar.appearance().tintColor = .tabSelected UITabBar.appearance().unselectedItemTintColor = .tabUnselected + + if #available(iOS 15.0, *) { + let appearance = UITabBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = .systemBackground + + UITabBar.appearance().standardAppearance = appearance + UITabBar.appearance().scrollEdgeAppearance = appearance + } } /// Style the `LightNavigationController` UINavigationBar and BarButtonItems class func configureLightNavigationBarAppearance() { let separatorColor: UIColor - - if #available(iOS 13.0, *) { - separatorColor = .systemGray4 - } else { - separatorColor = .lightGray - } + separatorColor = .systemGray4 let navigationBarAppearanceProxy = UINavigationBar.appearance(whenContainedInInstancesOf: [LightNavigationController.self]) navigationBarAppearanceProxy.backgroundColor = .white // Only used on iOS 12 so doesn't need dark mode support @@ -72,14 +91,12 @@ extension WPStyleGuide { NSAttributedString.Key.foregroundColor: UIColor.text ] - if #available(iOS 13.0, *) { - let appearance = UINavigationBarAppearance() - appearance.backgroundColor = .systemBackground - appearance.shadowColor = separatorColor - navigationBarAppearanceProxy.standardAppearance = appearance - } + let appearance = UINavigationBarAppearance() + appearance.backgroundColor = .systemBackground + appearance.shadowColor = separatorColor + navigationBarAppearanceProxy.standardAppearance = appearance - let tintColor = UIColor(light: .brand, dark: .white) + let tintColor = UIColor.lightAppBarTint let buttonBarAppearance = UIBarButtonItem.appearance(whenContainedInInstancesOf: [LightNavigationController.self]) buttonBarAppearance.tintColor = tintColor @@ -91,6 +108,17 @@ extension WPStyleGuide { for: .disabled) } + + class func configureToolbarAppearance() { + let appearance = UIToolbarAppearance() + appearance.configureWithDefaultBackground() + + UIToolbar.appearance().standardAppearance = appearance + + if #available(iOS 15.0, *) { + UIToolbar.appearance().scrollEdgeAppearance = appearance + } + } } @@ -100,12 +128,14 @@ extension WPStyleGuide { configureTableViewColors(view: view) configureTableViewColors(tableView: tableView) } + class func configureTableViewColors(view: UIView?) { guard let view = view else { return } view.backgroundColor = .basicBackground } + class func configureTableViewColors(tableView: UITableView?) { guard let tableView = tableView else { return diff --git a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Aztec.swift b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Aztec.swift index 483bb674a65d..f8c77aae9f21 100644 --- a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Aztec.swift +++ b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Aztec.swift @@ -26,8 +26,7 @@ extension WPStyleGuide { } static func configureBetaButton(_ button: UIButton) { - let helpImage = Gridicon.iconOfType(.helpOutline) - button.setImage(helpImage, for: .normal) + button.setImage(.gridicon(.helpOutline), for: .normal) button.tintColor = .neutral(.shade20) let edgeInsets = UIEdgeInsets(top: 2.0, left: 2.0, bottom: 2.0, right: 2.0) diff --git a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+FilterTabBar.swift b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+FilterTabBar.swift index aa538415eb19..07130d415312 100644 --- a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+FilterTabBar.swift +++ b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+FilterTabBar.swift @@ -4,6 +4,7 @@ extension WPStyleGuide { @objc class func configureFilterTabBar(_ filterTabBar: FilterTabBar) { filterTabBar.backgroundColor = .filterBarBackground filterTabBar.tintColor = .filterBarSelected + filterTabBar.selectedTitleColor = .filterBarSelectedText filterTabBar.deselectedTabColor = .textSubtle filterTabBar.dividerColor = .neutral(.shade10) } diff --git a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Jetpack.swift b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Jetpack.swift new file mode 100644 index 000000000000..c8c11fce087f --- /dev/null +++ b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Jetpack.swift @@ -0,0 +1,31 @@ +import Foundation +import WordPressShared + +extension WPStyleGuide { + + enum Jetpack { + + // MARK: - Style Methods + + static func highlightString(_ substring: String, inString: String) -> NSAttributedString { + let attributedString = NSMutableAttributedString(string: inString) + + guard let subStringRange = inString.nsRange(of: substring) else { + return attributedString + } + + attributedString.addAttributes([ + .foregroundColor: substringHighlightTextColor, + .font: substringHighlightFont + ], range: subStringRange) + + return attributedString + } + + // MARK: - Style Values + + static let substringHighlightTextColor = UIColor.primary + static let substringHighlightFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + } + +} diff --git a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Search.swift b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Search.swift index 2aec378d4a45..ebad5bb1c5a6 100644 --- a/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Search.swift +++ b/WordPress/Classes/Extensions/Colors and Styles/WPStyleGuide+Search.swift @@ -1,20 +1,25 @@ import Foundation import WordPressShared +import UIKit extension WPStyleGuide { - @objc public class func configureSearchBar(_ searchBar: UISearchBar) { + fileprivate static let barTintColor: UIColor = .neutral(.shade10) + + public class func configureSearchBar(_ searchBar: UISearchBar, backgroundColor: UIColor, returnKeyType: UIReturnKeyType) { searchBar.accessibilityIdentifier = "Search" searchBar.autocapitalizationType = .none searchBar.autocorrectionType = .no - searchBar.isTranslucent = false - searchBar.barTintColor = .neutral(.shade10) - searchBar.layer.borderColor = UIColor.neutral(.shade10).cgColor + searchBar.isTranslucent = true + searchBar.backgroundImage = UIImage() + searchBar.backgroundColor = backgroundColor searchBar.layer.borderWidth = 1.0 - searchBar.returnKeyType = .done - if #available(iOS 13.0, *) { - searchBar.searchTextField.backgroundColor = .basicBackground - } + searchBar.returnKeyType = returnKeyType + } + + /// configures a search bar with a default `.appBackground` color and a `.done` return key + @objc public class func configureSearchBar(_ searchBar: UISearchBar) { + configureSearchBar(searchBar, backgroundColor: .appBarBackground, returnKeyType: .done) } @objc public class func configureSearchBarAppearance() { @@ -23,28 +28,38 @@ extension WPStyleGuide { let barButtonItemAppearance = UIBarButtonItem.appearance(whenContainedInInstancesOf: [UISearchBar.self]) barButtonItemAppearance.tintColor = .neutral(.shade70) + let iconSizes = CGSize(width: 20, height: 20) + // We have to manually tint these images, as we want them // a different color from the search bar's cursor (which uses `tintColor`) - let cancelImage = UIImage(named: "icon-clear-searchfield")?.imageWithTintColor(.neutral(.shade30)) - let searchImage = UIImage(named: "icon-post-list-search")?.imageWithTintColor(.neutral(.shade30)) - UISearchBar.appearance().setImage(cancelImage, for: .clear, state: UIControl.State()) + let clearImage = UIImage.gridicon(.crossCircle, size: iconSizes).withTintColor(.searchFieldIcons).withRenderingMode(.alwaysOriginal) + let searchImage = UIImage.gridicon(.search, size: iconSizes).withTintColor(.searchFieldIcons).withRenderingMode(.alwaysOriginal) + UISearchBar.appearance().setImage(clearImage, for: .clear, state: UIControl.State()) UISearchBar.appearance().setImage(searchImage, for: .search, state: UIControl.State()) } @objc public class func configureSearchBarTextAppearance() { // Cancel button let barButtonTitleAttributes: [NSAttributedString.Key: Any] = [.font: WPStyleGuide.fixedFont(for: .headline), - .foregroundColor: UIColor.neutral(.shade70)] + .foregroundColor: UIColor.neutral(.shade70)] let barButtonItemAppearance = UIBarButtonItem.appearance(whenContainedInInstancesOf: [UISearchBar.self]) barButtonItemAppearance.setTitleTextAttributes(barButtonTitleAttributes, for: UIControl.State()) // Text field - UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self]).defaultTextAttributes = - (WPStyleGuide.defaultSearchBarTextAttributesSwifted(.neutral(.shade70))) let placeholderText = NSLocalizedString("Search", comment: "Placeholder text for the search bar") let attributedPlaceholderText = NSAttributedString(string: placeholderText, - attributes: WPStyleGuide.defaultSearchBarTextAttributesSwifted(.neutral(.shade30))) + attributes: WPStyleGuide.defaultSearchBarTextAttributesSwifted(.searchFieldPlaceholderText)) UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self]).attributedPlaceholder = attributedPlaceholderText } } + +extension UISearchBar { + // Per Apple's documentation (https://developer.apple.com/documentation/xcode/supporting_dark_mode_in_your_interface), + // `cgColor` objects do not adapt to appearance changes (i.e. toggling light/dark mode). + // `tintColorDidChange` is called when the appearance changes, so re-set the border color when this occurs. + open override func tintColorDidChange() { + super.tintColorDidChange() + layer.borderColor = UIColor.appBarBackground.cgColor + } +} diff --git a/WordPress/Classes/Extensions/Comment+Interface.swift b/WordPress/Classes/Extensions/Comment+Interface.swift new file mode 100644 index 000000000000..f06dac6dabb6 --- /dev/null +++ b/WordPress/Classes/Extensions/Comment+Interface.swift @@ -0,0 +1,79 @@ +/// Allows comment objects to be sectioned by relative date. +/// +/// This implementation is copied from Notification+Interface.swift. It pains me having to copy paste code, +/// but we should be able clean this up once `Comment` and the view controller displaying it is rewritten in Swift. +/// i.e.: Introduce a protocol with default implementation. Protocol extension doesn't work with @objc! +/// +extension Comment { + /// Returns a Section Identifier that can be sorted. Note that this string is not human + /// readable, and you should use the *descriptionForSectionIdentifier* method + /// as well! + /// + @objc func relativeDateSectionIdentifier() -> String? { + guard let dateCreated = dateCreated else { + return nil + } + + // Normalize Dates: Time must not be considered. Just the raw dates + let fromDate = dateCreated.normalizedDate() + let toDate = Date().normalizedDate() + + // Analyze the Delta-Components + let calendar = Calendar.current + let components = [.day, .weekOfYear, .month] as Set<Calendar.Component> + let dateComponents = calendar.dateComponents(components, from: fromDate, to: toDate) + let identifier: Sections + + // Months + if let month = dateComponents.month, month >= 1 { + identifier = .Months + // Weeks + } else if let week = dateComponents.weekOfYear, week >= 1 { + identifier = .Weeks + // Days + } else if let day = dateComponents.day, day > 1 { + identifier = .Days + } else if let day = dateComponents.day, day == 1 { + identifier = .Yesterday + } else { + identifier = .Today + } + + return identifier.rawValue + } + + /// Translates a relative date section identifier into a human-readable string. + /// + @objc static func descriptionForSectionIdentifier(_ identifier: String) -> String { + guard let section = Sections(rawValue: identifier) else { + return String() + } + + return section.description + } + + // MARK: - Private Helpers + + private enum Sections: String { + case Months = "0" + case Weeks = "2" + case Days = "4" + case Yesterday = "5" + case Today = "6" + + var description: String { + switch self { + case .Months: + return NSLocalizedString("Older than a Month", comment: "Comments Months Section Header") + case .Weeks: + return NSLocalizedString("Older than a Week", comment: "Comments Weeks Section Header") + case .Days: + return NSLocalizedString("Older than 2 days", comment: "Comments +2 Days Section Header") + case .Yesterday: + return NSLocalizedString("Yesterday", comment: "Comments Yesterday Section Header") + case .Today: + return NSLocalizedString("Today", comment: "Comments Today Section Header") + } + } + } +} diff --git a/WordPress/Classes/Extensions/Font/UIFont+Fitting.swift b/WordPress/Classes/Extensions/Font/UIFont+Fitting.swift new file mode 100644 index 000000000000..866638839931 --- /dev/null +++ b/WordPress/Classes/Extensions/Font/UIFont+Fitting.swift @@ -0,0 +1,48 @@ +// MIT License +// +// Copyright (c) 2019 Jonathan Cardasis +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// Based on FontFit: https://github.com/joncardasis/FontFit + +public extension UIFont { + /** + Provides the largest font which fits the text in the given bounds. + */ + static func fontFittingText(_ text: String, in bounds: CGSize, fontDescriptor: UIFontDescriptor) -> UIFont? { + let properBounds = CGRect(origin: .zero, size: bounds) + let largestFontSize = Int(bounds.height) + let constrainingBounds = CGSize(width: properBounds.width, height: CGFloat.infinity) + + let bestFittingFontSize: Int? = (1...largestFontSize).reversed().first(where: { fontSize in + let font = UIFont(descriptor: fontDescriptor, size: CGFloat(fontSize)) + let currentFrame = text.boundingRect(with: constrainingBounds, options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font], context: nil) + + if properBounds.contains(currentFrame) { + return true + } + + return false + }) + + guard let fontSize = bestFittingFontSize else { return nil } + return UIFont(descriptor: fontDescriptor, size: CGFloat(fontSize)) + } +} diff --git a/WordPress/Classes/Extensions/Font/UIFont+Weight.swift b/WordPress/Classes/Extensions/Font/UIFont+Weight.swift new file mode 100644 index 000000000000..a8df631c9e16 --- /dev/null +++ b/WordPress/Classes/Extensions/Font/UIFont+Weight.swift @@ -0,0 +1,30 @@ +extension UIFont { + /// Returns a UIFont instance with the italic trait applied. + func italic() -> UIFont { + return withSymbolicTraits(.traitItalic) + } + + /// Returns a UIFont instance with the bold trait applied. + func bold() -> UIFont { + return withWeight(.bold) + } + + /// Returns a UIFont instance with the semibold trait applied. + func semibold() -> UIFont { + return withWeight(.semibold) + } + + private func withSymbolicTraits(_ traits: UIFontDescriptor.SymbolicTraits) -> UIFont { + guard let descriptor = fontDescriptor.withSymbolicTraits(traits) else { + return self + } + + return UIFont(descriptor: descriptor, size: 0) + } + + private func withWeight(_ weight: UIFont.Weight) -> UIFont { + let descriptor = fontDescriptor.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: weight]]) + + return UIFont(descriptor: descriptor, size: 0) + } +} diff --git a/WordPress/Classes/Extensions/Interpolation.swift b/WordPress/Classes/Extensions/Interpolation.swift new file mode 100644 index 000000000000..ae019dca6a7c --- /dev/null +++ b/WordPress/Classes/Extensions/Interpolation.swift @@ -0,0 +1,64 @@ +import Foundation + +extension CGFloat { + static func interpolated(from fromValue: CGFloat, to toValue: CGFloat, progress: CGFloat) -> CGFloat { + return fromValue.interpolated(to: toValue, with: progress) + } + + /// Interpolates a CGFloat + /// - Parameters: + /// - toValue: The to value + /// - progress: a number between 0.0 and 1.0 + /// - Returns: a new CGFloat value between 2 numbers using the progress + func interpolated(to toValue: CGFloat, with progress: CGFloat) -> CGFloat { + return (1 - progress) * self + progress * toValue + } +} + +extension UIColor { + struct RGBAComponents { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + func interpolated(to toColor: RGBAComponents, with progress: CGFloat) -> RGBAComponents { + return RGBAComponents(red: red.interpolated(to: toColor.red, with: progress), + green: green.interpolated(to: toColor.green, with: progress), + blue: blue.interpolated(to: toColor.blue, with: progress), + alpha: alpha.interpolated(to: toColor.alpha, with: progress)) + } + } + + /// Returns the RGBA components for a color + var rgbaComponents: RGBAComponents { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + return RGBAComponents(red: red, green: green, blue: blue, alpha: alpha) + } + + /// Interpolates between the fromColor and toColor using the given progress + /// - Parameters: + /// - fromColor: The start color + /// - toColor: The start color + /// - progress: A value between 0.0 and 1.0 + /// - Returns: An + static func interpolate(from fromColor: UIColor, to toColor: UIColor, with progress: CGFloat) -> UIColor { + + if fromColor == toColor { + return fromColor + } + + let components = fromColor.rgbaComponents.interpolated(to: toColor.rgbaComponents, with: progress) + + return UIColor(red: components.red, + green: components.green, + blue: components.blue, + alpha: components.alpha) + } +} diff --git a/WordPress/Classes/Extensions/JSONDecoderExtension.swift b/WordPress/Classes/Extensions/JSONDecoderExtension.swift new file mode 100644 index 000000000000..0b3802ecf131 --- /dev/null +++ b/WordPress/Classes/Extensions/JSONDecoderExtension.swift @@ -0,0 +1,42 @@ +import Foundation + +extension JSONDecoder.DateDecodingStrategy { + + enum DateFormat: String, CaseIterable { + case noTime = "yyyy-mm-dd" + case dateWithTime = "yyyy-MM-dd HH:mm:ss" + case iso8601 = "yyyy-MM-dd'T'HH:mm:ssZ" + case iso8601WithMilliseconds = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" + + var formatter: DateFormatter { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = rawValue + return dateFormatter + } + } + + static var supportMultipleDateFormats: JSONDecoder.DateDecodingStrategy { + return JSONDecoder.DateDecodingStrategy.custom({ (decoder) -> Date in + let container = try decoder.singleValueContainer() + let dateStr = try container.decode(String.self) + + var date: Date? + + for format in DateFormat.allCases { + date = format.formatter.date(from: dateStr) + if date != nil { + break + } + } + + guard let calculatedDate = date else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot decode date string \(dateStr)" + ) + } + + return calculatedDate + }) + } +} diff --git a/WordPress/Classes/Extensions/Media+Blog.swift b/WordPress/Classes/Extensions/Media+Blog.swift index 88d9197470c8..84fad141cfbe 100644 --- a/WordPress/Classes/Extensions/Media+Blog.swift +++ b/WordPress/Classes/Extensions/Media+Blog.swift @@ -59,4 +59,19 @@ extension Media { return media } + + /// Returns a list of Media objects from a post, that should be autoUploaded on the next attempt. + /// + /// - Parameters: + /// - post: the post to look auto-uploadable media for. + /// - automatedRetry: whether the media to upload is the result of an automated retry. + /// + /// - Returns: the Media objects that should be autoUploaded. + /// + class func failedForUpload(in post: AbstractPost, automatedRetry: Bool) -> [Media] { + post.media.filter { media in + media.remoteStatus == .failed + && (!automatedRetry || media.autoUploadFailureCount.intValue < Media.maxAutoUploadFailureCount) + } + } } diff --git a/WordPress/Classes/Extensions/Media+Sync.swift b/WordPress/Classes/Extensions/Media+Sync.swift new file mode 100644 index 000000000000..6fac636fcfa7 --- /dev/null +++ b/WordPress/Classes/Extensions/Media+Sync.swift @@ -0,0 +1,68 @@ +import Foundation + +extension Media { + + /// Returns a list of Media objects that should be uploaded for the given input parameters. + /// + /// - Parameters: + /// - automatedRetry: whether the media to upload is the result of an automated retry. + /// + /// - Returns: the Media objects that should be uploaded for the given input parameters. + /// + static func failedMediaForUpload(automatedRetry: Bool, in context: NSManagedObjectContext) -> [Media] { + let request = NSFetchRequest<Media>(entityName: Media.entityName()) + let failedMediaPredicate = NSPredicate(format: "\(#keyPath(Media.remoteStatusNumber)) == %d", MediaRemoteStatus.failed.rawValue) + + if automatedRetry { + let autoUploadFailureCountPredicate = NSPredicate(format: "\(#keyPath(Media.autoUploadFailureCount)) < %d", Media.maxAutoUploadFailureCount) + + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [failedMediaPredicate, autoUploadFailureCountPredicate]) + } else { + request.predicate = failedMediaPredicate + } + + let media = (try? context.fetch(request)) ?? [] + + return media + } + + /// This method checks the status of all media objects and updates them to the correct status if needed. + /// The main cause of wrong status is the app being killed while uploads of media are happening. + /// + /// - Parameters: + /// - onCompletion: block to invoke when status update is finished. + /// - onError: block to invoke if any error occurs while the update is being made. + /// + static func refreshMediaStatus(using coreDataStack: CoreDataStackSwift, onCompletion: (() -> Void)? = nil, onError: ((Error) -> Void)? = nil) { + coreDataStack.performAndSave({ context in + let fetch = NSFetchRequest<Media>(entityName: Media.classNameWithoutNamespaces()) + let pushingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: MediaRemoteStatus.pushing.rawValue)) + let processingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: MediaRemoteStatus.processing.rawValue)) + let errorPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: MediaRemoteStatus.failed.rawValue)) + fetch.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [pushingPredicate, processingPredicate, errorPredicate]) + let mediaPushing = try context.fetch(fetch) + for media in mediaPushing { + // If file were in the middle of being pushed or being processed they now are failed. + if media.remoteStatus == .pushing || media.remoteStatus == .processing { + media.remoteStatus = .failed + } + // If they failed to upload themselfs because no local copy exists then we need to delete this media object + // This scenario can happen when media objects were created based on an asset that failed to import to the WordPress App. + // For example a PHAsset that is stored on the iCloud storage and because of the network connection failed the import process. + if media.remoteStatus == .failed, + let error = media.error as NSError?, error.domain == MediaServiceErrorDomain && error.code == MediaServiceError.fileDoesNotExist.rawValue { + context.delete(media) + } + } + }, completion: { result in + switch result { + case .success: + onCompletion?() + case let .failure(error): + DDLogError("Error while attempting to clean local media: \(error.localizedDescription)") + onError?(error) + } + }, on: .main) + } + +} diff --git a/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift b/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift index f11c2739b856..9e6b2c056752 100644 --- a/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift +++ b/WordPress/Classes/Extensions/NSAttributedString+Helpers.swift @@ -1,4 +1,5 @@ -import Foundation +import UIKit +import MobileCoreServices @objc public extension NSAttributedString { @@ -22,9 +23,28 @@ public extension NSAttributedString { var rangeDelta = 0 for (value, image) in unwrappedEmbeds { - let imageAttachment = NSTextAttachment() - imageAttachment.bounds = CGRect(origin: CGPoint.zero, size: image.size) - imageAttachment.image = image + let imageAttachment = NSTextAttachment() + let gifType = kUTTypeGIF as String + var displayAnimatedGifs = false + + // Check to see if the animated gif view provider is registered + if #available(iOS 15.0, *) { + displayAnimatedGifs = NSTextAttachment.textAttachmentViewProviderClass(forFileType: gifType) == AnimatedGifAttachmentViewProvider.self + } + + // When displaying an animated gif pass the gif data instead of the image + if + displayAnimatedGifs, + let animatedImage = image as? AnimatedImageWrapper, + animatedImage.gifData != nil + { + imageAttachment.contents = animatedImage.gifData + imageAttachment.fileType = gifType + imageAttachment.bounds = CGRect(origin: CGPoint.zero, size: animatedImage.targetSize ?? image.size) + } else { + imageAttachment.image = image + imageAttachment.bounds = CGRect(origin: CGPoint.zero, size: image.size) + } // Each embed is expected to add 1 char to the string. Compensate for that let attachmentString = NSAttributedString(attachment: imageAttachment) diff --git a/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift b/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift index fcff310b7952..5987b85fc10a 100644 --- a/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift +++ b/WordPress/Classes/Extensions/NSAttributedString+StyledHTML.swift @@ -115,7 +115,7 @@ public enum HTMLAttributeType: String { } } -private extension UIFont { +public extension UIFont { var isBold: Bool { return fontDescriptor.symbolicTraits.contains(.traitBold) } diff --git a/WordPress/Classes/Extensions/NSCalendar+Helpers.swift b/WordPress/Classes/Extensions/NSCalendar+Helpers.swift index 33198832235f..4d3f2620a80e 100644 --- a/WordPress/Classes/Extensions/NSCalendar+Helpers.swift +++ b/WordPress/Classes/Extensions/NSCalendar+Helpers.swift @@ -9,4 +9,35 @@ extension Calendar { let components = dateComponents([.day], from: fromDate, to: toDate) return components.day! } + + /// Converts a localized weekday index (where 0 can be either Sunday or Monday depending on the locale settings) + /// into an unlocalized weekday index (where 0 is always Sunday). + /// + /// - Parameters: + /// - localizedWeekdayIndex: a localized weekday index representing the desired day of + /// the week. 0 could either be Sunday or Monday depending on the `Calendar`'s locale settings. + /// + /// - Returns: an index where 0 is always Sunday. This index can be used with methods such as `Calendar.weekdaySymbol` + /// to obtain the name of the day. + /// + public func unlocalizedWeekdayIndex(localizedWeekdayIndex: Int) -> Int { + return (localizedWeekdayIndex + firstWeekday - 1) % weekdaySymbols.count + } + + /// Converts an unlocalized weekday index (where 0 is always Sunday) + /// into a localized weekday index (where 0 can be either Sunday or Monday depending on the locale settings). + /// + /// - Parameters: + /// - unlocalizedWeekdayIndex: an unlocalized weekday index representing the desired day of + /// the week. 0 is always Sunday. + /// + /// - Returns: an index where 0 can be either Sunday or Monday depending on locale settings. + /// + public func localizedWeekdayIndex(unlocalizedWeekdayIndex: Int) -> Int { + let firstZeroBasedWeekday = firstWeekday - 1 + + return unlocalizedWeekdayIndex >= firstZeroBasedWeekday + ? unlocalizedWeekdayIndex - firstZeroBasedWeekday + : unlocalizedWeekdayIndex + weekdaySymbols.count - firstZeroBasedWeekday + } } diff --git a/WordPress/Classes/Extensions/NSMutableAttributedString+ApplyAttributesToQuotes.swift b/WordPress/Classes/Extensions/NSMutableAttributedString+ApplyAttributesToQuotes.swift new file mode 100644 index 000000000000..a005e1eb3dc9 --- /dev/null +++ b/WordPress/Classes/Extensions/NSMutableAttributedString+ApplyAttributesToQuotes.swift @@ -0,0 +1,24 @@ +import Foundation + +extension NSMutableAttributedString { + + /// Applies a collection of attributes to all of quoted substrings + /// + /// - Parameters: + /// - attributes: Collection of attributes to be applied on the matched strings + /// + public func applyAttributes(toQuotes attributes: [NSAttributedString.Key: Any]?) { + guard let attributes = attributes else { + return + } + let rawString = self.string + let scanner = Scanner(string: rawString) + let quotes = scanner.scanQuotedText() + quotes.forEach { + if let itemRange = rawString.range(of: $0) { + let range = NSRange(itemRange, in: rawString) + self.addAttributes(attributes, range: range) + } + } + } +} diff --git a/WordPress/Classes/Extensions/NoResultsViewController+FollowedSites.swift b/WordPress/Classes/Extensions/NoResultsViewController+FollowedSites.swift new file mode 100644 index 000000000000..2800982dac7a --- /dev/null +++ b/WordPress/Classes/Extensions/NoResultsViewController+FollowedSites.swift @@ -0,0 +1,33 @@ +import Foundation + +extension NoResultsViewController { + private struct Constants { + static let noFollowedSitesTitle = NSLocalizedString("No followed sites", comment: "Title for the no followed sites result screen") + static let noFollowedSitesSubtitle = NSLocalizedString("When you follow sites, you’ll see their content here.", comment: "Subtitle for the no followed sites result screen") + static let noFollowedSitesButtonTitle = NSLocalizedString("Discover Sites", comment: "Title for button on the no followed sites result screen") + + static let noFollowedSitesImage = "wp-illustration-following-empty-results" + } + + class func noFollowedSitesController(showActionButton showButton: Bool) -> NoResultsViewController { + let titleText = NSMutableAttributedString(string: Constants.noFollowedSitesTitle, + attributes: WPStyleGuide.noFollowedSitesErrorTitleAttributes()) + + let subtitleText = NSMutableAttributedString(string: Constants.noFollowedSitesSubtitle, + attributes: WPStyleGuide.noFollowedSitesErrorSubtitleAttributes()) + + let controller = NoResultsViewController.controller() + + controller.configure(title: "", + attributedTitle: titleText, + buttonTitle: showButton ? Constants.noFollowedSitesButtonTitle : nil, + attributedSubtitle: subtitleText, + attributedSubtitleConfiguration: { (attributedText: NSAttributedString) -> NSAttributedString? in + return subtitleText }, + image: Constants.noFollowedSitesImage) + controller.labelStackViewSpacing = 12 + controller.labelButtonStackViewSpacing = 18 + + return controller + } +} diff --git a/WordPress/Classes/Extensions/Notifications/NotificationName+Names.swift b/WordPress/Classes/Extensions/Notifications/NotificationName+Names.swift index 1a5812edbb63..01c13f207c1b 100644 --- a/WordPress/Classes/Extensions/Notifications/NotificationName+Names.swift +++ b/WordPress/Classes/Extensions/Notifications/NotificationName+Names.swift @@ -6,14 +6,9 @@ extension Foundation.Notification.Name { static var reachabilityChanged: Foundation.NSNotification.Name { return Foundation.Notification.Name("org.wordpress.reachability.changed") } - - static var showAllSavedForLaterPosts: Foundation.NSNotification.Name { - return Foundation.Notification.Name("org.wordpress.reader.savedforlaterposts.showall") - } } @objc extension NSNotification { - public static let ShowAllSavedForLaterPostsNotification = Foundation.Notification.Name.showAllSavedForLaterPosts public static let ReachabilityChangedNotification = Foundation.Notification.Name.reachabilityChanged } diff --git a/WordPress/Classes/Extensions/Post+BloggingPrompts.swift b/WordPress/Classes/Extensions/Post+BloggingPrompts.swift new file mode 100644 index 000000000000..4a522db6da48 --- /dev/null +++ b/WordPress/Classes/Extensions/Post+BloggingPrompts.swift @@ -0,0 +1,46 @@ +extension Post { + + func prepareForPrompt(_ prompt: BloggingPrompt?) { + guard let prompt = prompt else { + return + } + + content = promptContent(withPromptText: prompt.text) + bloggingPromptID = String(prompt.promptID) + + if let currentTags = tags { + tags = "\(currentTags), \(Strings.promptTag)" + } else { + tags = Strings.promptTag + } + + if FeatureFlag.bloggingPromptsEnhancements.enabled { + tags?.append(", \(Strings.promptTag)-\(prompt.promptID)") + } + } + + private func promptContent(withPromptText promptText: String) -> String { + if FeatureFlag.bloggingPromptsEnhancements.enabled { + return pullquoteBlock(promptText: promptText) + Strings.emptyParagraphBlock + } else { + return pullquoteBlock(promptText: promptText) + } + } + + private func pullquoteBlock(promptText: String) -> String { + return """ + <!-- wp:pullquote --> + <figure class="wp-block-pullquote"><blockquote><p>\(promptText)</p></blockquote></figure> + <!-- /wp:pullquote --> + """ + } + + private enum Strings { + static let promptTag = "dailyprompt" + static let emptyParagraphBlock = """ + <!-- wp:paragraph --> + <p></p> + <!-- /wp:paragraph --> + """ + } +} diff --git a/WordPress/Classes/Extensions/Scanner+QuotedText.swift b/WordPress/Classes/Extensions/Scanner+QuotedText.swift new file mode 100644 index 000000000000..d7230c45b9a9 --- /dev/null +++ b/WordPress/Classes/Extensions/Scanner+QuotedText.swift @@ -0,0 +1,21 @@ +import Foundation + +extension Scanner { + public func scanQuotedText() -> [String] { + var allQuotedTextFound = [String]() + var textRead: String? + let quoteString = "\"" + while self.isAtEnd == false { + _ = scanUpToString(quoteString) // scan up to quotation mark + _ = scanString(quoteString) // skip opening quotation mark + textRead = scanUpToString(quoteString) // read text up to next quotation mark + let closingMarkFound = scanString(quoteString) != nil // skip closing quotation mark + + if let quotedTextFound = textRead, quotedTextFound.isEmpty == false, closingMarkFound { + allQuotedTextFound.append(quotedTextFound as String) + } + } + + return allQuotedTextFound + } +} diff --git a/WordPress/Classes/Extensions/String+CondenseWhitespace.swift b/WordPress/Classes/Extensions/String+CondenseWhitespace.swift new file mode 100644 index 000000000000..5cb2508d14d4 --- /dev/null +++ b/WordPress/Classes/Extensions/String+CondenseWhitespace.swift @@ -0,0 +1,31 @@ +import Foundation + +extension String { + /// + /// Attempts to remove excessive whitespace in text by replacing multiple new lines with just 2. + /// This first trims whitespace and newlines from the ends + /// Then normalizes the newlines by replacing {Space}{Newline} with a single newline char + /// Then finally it looks for any newlines that are 3 or more and replaces them with 2 newlines. + /// + /// Example: + /// ``` + /// This is the first line + /// + /// + /// + /// + /// This is the last line + /// ``` + /// Turns into: + /// ``` + /// This is the first line + /// + /// This is the last line + /// ``` + /// + func condenseWhitespace() -> String { + return self.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + .replacingOccurrences(of: "\\s\n", with: "\n", options: .regularExpression, range: nil) + .replacingOccurrences(of: "[\n]{3,}", with: "\n\n", options: .regularExpression, range: nil) + } +} diff --git a/WordPress/Classes/Extensions/UIAlertController+Helpers.swift b/WordPress/Classes/Extensions/UIAlertController+Helpers.swift index 313d16e1c035..4c2a295cbc69 100644 --- a/WordPress/Classes/Extensions/UIAlertController+Helpers.swift +++ b/WordPress/Classes/Extensions/UIAlertController+Helpers.swift @@ -7,15 +7,9 @@ import WordPressFlux // This method is required because the presenter ViewController must be visible, and we've got several // flows in which the VC that triggers the alert, might not be visible anymore. // - guard let rootViewController = UIApplication.shared.keyWindow?.rootViewController else { - print("Error loading the rootViewController") + guard let leafViewController = UIApplication.shared.leafViewController else { return } - - var leafViewController = rootViewController - while leafViewController.presentedViewController != nil && !leafViewController.presentedViewController!.isBeingDismissed { - leafViewController = leafViewController.presentedViewController! - } popoverPresentationController?.sourceView = view popoverPresentationController?.permittedArrowDirections = [] leafViewController.present(self, animated: true) diff --git a/WordPress/Classes/Extensions/UIApplication+AppAvailability.swift b/WordPress/Classes/Extensions/UIApplication+AppAvailability.swift new file mode 100644 index 000000000000..8efc0205f728 --- /dev/null +++ b/WordPress/Classes/Extensions/UIApplication+AppAvailability.swift @@ -0,0 +1,16 @@ +import Foundation + +enum AppScheme: String { + case wordpress = "wordpress://" + case wordpressMigrationV1 = "wordpressmigration+v1://" + case jetpack = "jetpack://" +} + +extension UIApplication { + func canOpen(app: AppScheme) -> Bool { + guard let url = URL(string: app.rawValue) else { + return false + } + return canOpenURL(url) + } +} diff --git a/WordPress/Classes/Extensions/UIApplication+Helpers.swift b/WordPress/Classes/Extensions/UIApplication+Helpers.swift new file mode 100644 index 000000000000..3cc886fc67e4 --- /dev/null +++ b/WordPress/Classes/Extensions/UIApplication+Helpers.swift @@ -0,0 +1,12 @@ +import Foundation +import UIKit + +extension UIApplication { + func openSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { + return + } + + self.open(url) + } +} diff --git a/WordPress/Classes/Extensions/UIApplication+mainWindow.swift b/WordPress/Classes/Extensions/UIApplication+mainWindow.swift new file mode 100644 index 000000000000..3097a37545e8 --- /dev/null +++ b/WordPress/Classes/Extensions/UIApplication+mainWindow.swift @@ -0,0 +1,26 @@ +import UIKit + +extension UIApplication { + @objc var mainWindow: UIWindow? { + return windows.filter {$0.isKeyWindow}.first + } + + @objc var currentStatusBarFrame: CGRect { + return mainWindow?.windowScene?.statusBarManager?.statusBarFrame ?? CGRect.zero + } + + @objc var currentStatusBarOrientation: UIInterfaceOrientation { + return mainWindow?.windowScene?.interfaceOrientation ?? .unknown + } + + var leafViewController: UIViewController? { + guard let rootViewController = mainWindow?.rootViewController else { + return nil + } + var leafViewController = rootViewController + while leafViewController.presentedViewController != nil && !leafViewController.presentedViewController!.isBeingDismissed { + leafViewController = leafViewController.presentedViewController! + } + return leafViewController + } +} diff --git a/WordPress/Classes/Extensions/UIEdgeInsets.swift b/WordPress/Classes/Extensions/UIEdgeInsets.swift index 672c9cabf71f..33df6842bb61 100644 --- a/WordPress/Classes/Extensions/UIEdgeInsets.swift +++ b/WordPress/Classes/Extensions/UIEdgeInsets.swift @@ -1,12 +1,25 @@ import UIKit extension UIEdgeInsets { + var flippedForRightToLeft: UIEdgeInsets { + guard UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft else { + return self + } + + return flippedForRightToLeftLayoutDirection() + } + func flippedForRightToLeftLayoutDirection() -> UIEdgeInsets { return UIEdgeInsets(top: top, left: right, bottom: bottom, right: left) } + + init(allEdges: CGFloat) { + self.init(top: allEdges, left: allEdges, bottom: allEdges, right: allEdges) + } } extension UIButton { + @objc func flipInsetsForRightToLeftLayoutDirection() { guard userInterfaceLayoutDirection() == .rightToLeft else { return @@ -15,6 +28,32 @@ extension UIButton { imageEdgeInsets = imageEdgeInsets.flippedForRightToLeftLayoutDirection() titleEdgeInsets = titleEdgeInsets.flippedForRightToLeftLayoutDirection() } + + func verticallyAlignImageAndText(padding: CGFloat = 5) { + guard let imageView = imageView, + let titleLabel = titleLabel else { + return + } + + let imageSize = imageView.frame.size + let titleSize = titleLabel.frame.size + let totalHeight = imageSize.height + titleSize.height + padding + + imageEdgeInsets = UIEdgeInsets( + top: -(totalHeight - imageSize.height), + left: 0, + bottom: 0, + right: -titleSize.width + ) + + titleEdgeInsets = UIEdgeInsets( + top: 0, + left: -imageSize.width, + bottom: -(totalHeight - titleSize.height), + right: 0 + ) + } + } // Hack: Since UIEdgeInsets is a struct in ObjC, you can't have methods on it. diff --git a/WordPress/Classes/Extensions/UIImageView+SiteIcon.swift b/WordPress/Classes/Extensions/UIImageView+SiteIcon.swift index 9241fd9d5f03..e6b1f583f4f7 100644 --- a/WordPress/Classes/Extensions/UIImageView+SiteIcon.swift +++ b/WordPress/Classes/Extensions/UIImageView+SiteIcon.swift @@ -1,5 +1,8 @@ +import AlamofireImage +import Alamofire +import AutomatticTracks import Foundation - +import Gridicons /// UIImageView Helper Methods that allow us to download a SiteIcon, given a website's "Icon Path" /// @@ -8,21 +11,14 @@ extension UIImageView { /// Default Settings /// struct SiteIconDefaults { - /// Default SiteIcon's Image Size, in points. /// - static let imageSize = 40 - - /// Default SiteIcon's Image Size, in pixels. - /// - static var imageSizeInPixels: Int { - return imageSize * Int(UIScreen.main.scale) - } + static let imageSize = CGSize(width: 40, height: 40) } /// Downloads the SiteIcon Image, hosted at the specified path. This method will attempt to optimize the URL, so that - /// the download Image Size matches `SiteIconDefaults.imageSize`. + /// the download Image Size matches `imageSize`. /// /// TODO: This is a convenience method. Nuke me once we're all swifted. /// @@ -35,80 +31,145 @@ extension UIImageView { /// Downloads the SiteIcon Image, hosted at the specified path. This method will attempt to optimize the URL, so that - /// the download Image Size matches `SiteIconDefaults.imageSize`. + /// the download Image Size matches `imageSize`. /// /// - Parameters: /// - path: SiteIcon's url (string encoded) to be downloaded. + /// - imageSize: Request site icon in the specified image size. /// - placeholderImage: Yes. It's the "place holder image", Sherlock. /// @objc - func downloadSiteIcon(at path: String, placeholderImage: UIImage?) { - guard let siteIconURL = optimizedURL(for: path) else { + func downloadSiteIcon( + at path: String, + imageSize: CGSize = SiteIconDefaults.imageSize, + placeholderImage: UIImage? + ) { + guard let siteIconURL = optimizedURL(for: path, imageSize: imageSize) else { image = placeholderImage return } - downloadImage(from: siteIconURL, placeholderImage: placeholderImage) + logURLOptimization(from: path, to: siteIconURL) + + let request = URLRequest(url: siteIconURL) + downloadSiteIcon(with: request, imageSize: imageSize, placeholderImage: placeholderImage) + } + + /// Downloads a SiteIcon image, using a specified request. + /// + /// - Parameters: + /// - request: The request for the SiteIcon. + /// - imageSize: Request site icon in the specified image size. + /// - placeholderImage: Yes. It's the "place holder image". + /// + private func downloadSiteIcon( + with request: URLRequest, + imageSize expectedSize: CGSize = SiteIconDefaults.imageSize, + placeholderImage: UIImage? + ) { + af_setImage(withURLRequest: request, placeholderImage: placeholderImage, completion: { [weak self] dataResponse in + switch dataResponse.result { + case .success(let image): + guard let self = self else { + return + } + + // In `MediaRequesAuthenticator.authenticatedRequestForPrivateAtomicSiteThroughPhoton` we're + // having to replace photon URLs for Atomic Private Sites, with a call to the Atomic Media Proxy + // endpoint. The downside of calling that endpoint is that it doesn't always return images of + // the requested size. + // + // The following lines of code ensure that we resize the image to the default Site Icon size, to + // ensure there is no UI breakage due to having larger images set here. + // + if image.size != expectedSize { + self.image = image.resizedImage(with: .scaleAspectFill, bounds: expectedSize, interpolationQuality: .default) + } else { + self.image = image + } + + self.removePlaceholderBorder() + case .failure(let error): + if case .requestCancelled = (error as? AFIError) { + // Do not log intentionally cancelled requests as errors. + } else { + DDLogError(error.localizedDescription) + } + } + }) } /// Downloads the SiteIcon Image, associated to a given Blog. This method will attempt to optimize the URL, so that - /// the download Image Size matches `SiteIconDefaults.imageSize`. + /// the download Image Size matches `imageSize`. /// /// - Parameters: /// - blog: reference to the source blog /// - placeholderImage: Yes. It's the "place holder image". /// - @objc - func downloadSiteIcon(for blog: Blog, placeholderImage: UIImage? = .siteIconPlaceholder) { - guard let siteIconPath = blog.icon, let siteIconURL = optimizedURL(for: siteIconPath) else { + @objc func downloadSiteIcon( + for blog: Blog, + imageSize: CGSize = SiteIconDefaults.imageSize, + placeholderImage: UIImage? = .siteIconPlaceholder + ) { + guard let siteIconPath = blog.icon, let siteIconURL = optimizedURL(for: siteIconPath, imageSize: imageSize) else { + + if blog.isWPForTeams() && placeholderImage == .siteIconPlaceholder { + image = UIImage.gridicon(.p2, size: imageSize) + return + } + image = placeholderImage return } - let request: URLRequest - if blog.isPrivate(), PrivateSiteURLProtocol.urlGoes(toWPComSite: siteIconURL) { - request = PrivateSiteURLProtocol.requestForPrivateSite(from: siteIconURL) - } else { - request = URLRequest(url: siteIconURL) + logURLOptimization(from: siteIconPath, to: siteIconURL, for: blog) + + let host = MediaHost(with: blog) { error in + // We'll log the error, so we know it's there, but we won't halt execution. + DDLogError(error.localizedDescription) } - downloadImage(usingRequest: request, placeholderImage: placeholderImage, success: { [weak self] (image) in - self?.image = image - self?.removePlaceholderBorder() - }, failure: nil) + let mediaRequestAuthenticator = MediaRequestAuthenticator() + mediaRequestAuthenticator.authenticatedRequest( + for: siteIconURL, + from: host, + onComplete: { [weak self] request in + self?.downloadSiteIcon(with: request, imageSize: imageSize, placeholderImage: placeholderImage) + }) { error in + DDLogError(error.localizedDescription) + } } } // MARK: - Private Methods // -private extension UIImageView { - +extension UIImageView { /// Returns the Size Optimized URL for a given Path. /// - func optimizedURL(for path: String) -> URL? { + func optimizedURL(for path: String, imageSize: CGSize = SiteIconDefaults.imageSize) -> URL? { if isPhotonURL(path) || isDotcomURL(path) { - return optimizedDotcomURL(from: path) + return optimizedDotcomURL(from: path, imageSize: imageSize) } if isBlavatarURL(path) { - return optimizedBlavatarURL(from: path) + return optimizedBlavatarURL(from: path, imageSize: imageSize) } - return optimizedPhotonURL(from: path) + return optimizedPhotonURL(from: path, imageSize: imageSize) } // MARK: - Private Helpers - /// Returns the download URL for a square icon with a size of `SiteIconDefaults.imageSizeInPixels` + /// Returns the download URL for a square icon with a size of `imageSize` in pixels. /// /// - Parameter path: SiteIcon URL (string encoded). /// - private func optimizedDotcomURL(from path: String) -> URL? { - let size = SiteIconDefaults.imageSizeInPixels - let query = String(format: "w=%d&h=%d", size, size) + private func optimizedDotcomURL(from path: String, imageSize: CGSize = SiteIconDefaults.imageSize) -> URL? { + let size = imageSize.toPixels() + let query = String(format: "w=%d&h=%d", Int(size.width), Int(size.height)) return parseURL(path: path, query: query) } @@ -118,9 +179,9 @@ private extension UIImageView { /// /// - Parameter path: Blavatar URL (string encoded). /// - private func optimizedBlavatarURL(from path: String) -> URL? { - let size = SiteIconDefaults.imageSizeInPixels - let query = String(format: "d=404&s=%d", size) + private func optimizedBlavatarURL(from path: String, imageSize: CGSize = SiteIconDefaults.imageSize) -> URL? { + let size = imageSize.toPixels() + let query = String(format: "d=404&s=%d", Int(max(size.width, size.height))) return parseURL(path: path, query: query) } @@ -130,13 +191,12 @@ private extension UIImageView { /// /// - Parameter siteIconPath: SiteIcon URL (string encoded). /// - private func optimizedPhotonURL(from path: String) -> URL? { + private func optimizedPhotonURL(from path: String, imageSize: CGSize = SiteIconDefaults.imageSize) -> URL? { guard let url = URL(string: path) else { return nil } - let size = CGSize(width: SiteIconDefaults.imageSize, height: SiteIconDefaults.imageSize) - return PhotonImageURLHelper.photonURL(with: size, forImageURL: url) + return PhotonImageURLHelper.photonURL(with: imageSize, forImageURL: url) } @@ -184,3 +244,41 @@ extension UIImageView { layer.borderColor = UIColor.clear.cgColor } } + +// MARK: - Logging Support + +/// This is just a temporary extension to try and narrow down the caused behind this issue: https://sentry.io/share/issue/3da4662c65224346bb3a731c131df13d/ +/// +private extension UIImageView { + + private func logURLOptimization(from original: String, to optimized: URL) { + DDLogInfo("URL optimized from \(original) to \(optimized.absoluteString)") + } + + private func logURLOptimization(from original: String, to optimized: URL, for blog: Blog) { + let blogInfo: String + if blog.isAccessibleThroughWPCom() { + blogInfo = "dot-com-accessible: \(blog.url ?? "unknown"), id: \(blog.dotComID ?? 0)" + } else { + blogInfo = "self-hosted with url: \(blog.url ?? "unknown")" + } + + DDLogInfo("URL optimized from \(original) to \(optimized.absoluteString) for blog \(blogInfo)") + } +} + +// MARK: - CGFloat Extension + +private extension CGSize { + + func toPixels() -> CGSize { + return CGSize(width: width.toPixels(), height: height.toPixels()) + } +} + +private extension CGFloat { + + func toPixels() -> CGFloat { + return self * UIScreen.main.scale + } +} diff --git a/WordPress/Classes/Extensions/UINavigationBar+Appearance.swift b/WordPress/Classes/Extensions/UINavigationBar+Appearance.swift new file mode 100644 index 000000000000..45cc4b0d84a9 --- /dev/null +++ b/WordPress/Classes/Extensions/UINavigationBar+Appearance.swift @@ -0,0 +1,7 @@ +import UIKit + +extension UINavigationBar { + class func standardTitleTextAttributes() -> [NSAttributedString.Key: Any] { + return appearance().standardAppearance.titleTextAttributes + } +} diff --git a/WordPress/Classes/Extensions/UINavigationController+Helpers.swift b/WordPress/Classes/Extensions/UINavigationController+Helpers.swift index d3dab8b0c759..c7462b5c40e8 100644 --- a/WordPress/Classes/Extensions/UINavigationController+Helpers.swift +++ b/WordPress/Classes/Extensions/UINavigationController+Helpers.swift @@ -2,7 +2,7 @@ import UIKit extension UINavigationController { override open var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent + return WPStyleGuide.preferredStatusBarStyle } override open var childForStatusBarStyle: UIViewController? { @@ -15,21 +15,16 @@ extension UINavigationController { @objc func scrollContentToTopAnimated(_ animated: Bool) { guard viewControllers.count == 1 else { return } - let scrollToTop = { (scrollView: UIScrollView) in - let offset = CGPoint(x: 0, y: -scrollView.contentInset.top) - scrollView.setContentOffset(offset, animated: animated) - } - if let topViewController = topViewController as? WPScrollableViewController { topViewController.scrollViewToTop() } else if let scrollView = topViewController?.view as? UIScrollView { // If the view controller's view is a scrollview - scrollToTop(scrollView) + scrollView.scrollToTop(animated: animated) } else if let scrollViews = topViewController?.view.subviews.filter({ $0 is UIScrollView }) as? [UIScrollView] { // If one of the top level views of the view controller's view // is a scrollview if let scrollView = scrollViews.first { - scrollToTop(scrollView) + scrollView.scrollToTop(animated: animated) } } } @@ -39,8 +34,8 @@ extension UINavigationController { /// If this issue is addressed by Apple in following release we can remove this override. /// @objc override open func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { - if #available(iOS 13, *), UIDevice.current.userInterfaceIdiom == .phone, - let webKitVC = topViewController as? WebKitViewController { + if UIDevice.current.userInterfaceIdiom == .phone, + let webKitVC = topViewController as? WebKitViewController { viewControllerToPresent.popoverPresentationController?.delegate = webKitVC } super.present(viewControllerToPresent, animated: flag, completion: completion) diff --git a/WordPress/Classes/Extensions/UINavigationController+SplitViewFullscreen.swift b/WordPress/Classes/Extensions/UINavigationController+SplitViewFullscreen.swift index a87db035af33..f0d1cdc834f5 100644 --- a/WordPress/Classes/Extensions/UINavigationController+SplitViewFullscreen.swift +++ b/WordPress/Classes/Extensions/UINavigationController+SplitViewFullscreen.swift @@ -13,7 +13,7 @@ fileprivate let fadeAnimationDuration: TimeInterval = 0.1 // UIKit glitch. extension UINavigationController { @objc func pushFullscreenViewController(_ viewController: UIViewController, animated: Bool) { - guard let splitViewController = splitViewController, splitViewController.preferredDisplayMode != .primaryHidden else { + guard let splitViewController = splitViewController, splitViewController.preferredDisplayMode != .secondaryOnly else { pushViewController(viewController, animated: animated) return } @@ -54,9 +54,9 @@ extension UIView { /// Hides this view by inserting a snapshot into the view hierarchy. /// - /// - Parameter afterScreenUpdates: A Boolean value that specifies whether - /// the snapshot should be taken after recent changes have been - /// incorporated. Pass the value false to capture the screen in + /// - Parameter afterScreenUpdates: A Boolean value that specifies whether + /// the snapshot should be taken after recent changes have been + /// incorporated. Pass the value false to capture the screen in /// its current state, which might not include recent changes. @objc func hideWithBlankingSnapshot(afterScreenUpdates: Bool = false) { if subviews.first is BlankingView { @@ -90,18 +90,14 @@ extension UIView { extension UINavigationBar { @objc func fadeOutNavigationItems(animated: Bool = true) { - if let barTintColor = barTintColor { - fadeNavigationItems(toColor: barTintColor, animated: animated) - } + fadeNavigationItems(withTintColor: .appBarBackground, textColor: .appBarBackground, animated: animated) } @objc func fadeInNavigationItemsIfNecessary(animated: Bool = true) { - if tintColor != UIColor.white { - fadeNavigationItems(toColor: UIColor.white, animated: animated) - } + fadeNavigationItems(withTintColor: .appBarTint, textColor: .appBarText, animated: animated) } - private func fadeNavigationItems(toColor color: UIColor, animated: Bool) { + private func fadeNavigationItems(withTintColor tintColor: UIColor, textColor: UIColor, animated: Bool) { if animated { // We're using CAAnimation because the various navigation item properties // didn't seem to animate using a standard UIView animation block. @@ -112,8 +108,11 @@ extension UINavigationBar { layer.add(fadeAnimation, forKey: "fadeNavigationBar") } - titleTextAttributes = [.foregroundColor: color] - tintColor = color + self.tintColor = tintColor + + var attributes = titleTextAttributes + attributes?[.foregroundColor] = textColor + titleTextAttributes = attributes } } diff --git a/WordPress/Classes/Extensions/UIPopoverPresentationController+PopoverAnchor.swift b/WordPress/Classes/Extensions/UIPopoverPresentationController+PopoverAnchor.swift new file mode 100644 index 000000000000..6fcef97a4ed7 --- /dev/null +++ b/WordPress/Classes/Extensions/UIPopoverPresentationController+PopoverAnchor.swift @@ -0,0 +1,9 @@ +import UIKit + +extension UIPopoverPresentationController { + + enum PopoverAnchor { + case view(UIView) + case barButtonItem(UIBarButtonItem) + } +} diff --git a/WordPress/Classes/Extensions/UIScrollView+Helpers.swift b/WordPress/Classes/Extensions/UIScrollView+Helpers.swift new file mode 100644 index 000000000000..3fa4dc93a4af --- /dev/null +++ b/WordPress/Classes/Extensions/UIScrollView+Helpers.swift @@ -0,0 +1,143 @@ +import UIKit + +extension UIScrollView { + + // MARK: - Vertical scrollview + + // Scroll to a specific view in a vertical scrollview so that it's top is at the top our scrollview + @objc func scrollVerticallyToView(_ view: UIView, animated: Bool) { + if let origin = view.superview { + + // Get the Y position of your child view + let childStartPoint = origin.convert(view.frame.origin, to: self) + + // Scroll to a rectangle starting at the Y of your subview, with a height of the scrollview safe area + // if the bottom of the rectangle is within the content size height. + // + // Otherwise, scroll all the way to the bottom. + // + if childStartPoint.y + safeAreaLayoutGuide.layoutFrame.height < contentSize.height { + let targetRect = CGRect(x: 0, + y: childStartPoint.y - Constants.targetRectPadding, + width: Constants.targetRectDimension, + height: safeAreaLayoutGuide.layoutFrame.height) + scrollRectToVisible(targetRect, animated: animated) + + // This ensures scrolling to the correct position, especially when there are layout changes + // + // See: https://stackoverflow.com/a/35437399 + // + layoutIfNeeded() + } else { + scrollToBottom(animated: true) + } + } + } + + @objc func scrollToTop(animated: Bool) { + let topOffset = CGPoint(x: 0, y: -adjustedContentInset.top) + setContentOffset(topOffset, animated: animated) + layoutIfNeeded() + } + + @objc func scrollToBottom(animated: Bool) { + let bottomOffset = CGPoint(x: 0, y: contentSize.height - bounds.size.height + adjustedContentInset.bottom) + if bottomOffset.y > 0 { + setContentOffset(bottomOffset, animated: animated) + layoutIfNeeded() + } + } + + // MARK: - Horizontal scrollview + + // Scroll to a specific view in a horizontal scrollview so that it's leading edge is at the leading edge of our scrollview + @objc func scrollHorizontallyToView(_ view: UIView, animated: Bool) { + if let origin = view.superview { + let childStartPoint = origin.convert(view.frame.origin, to: self).x + let childEndPoint = childStartPoint + view.frame.width + if userInterfaceLayoutDirection() == .leftToRight { + normalScrollHorizontallyToPoint(childStartPoint, animated: true) + } else { + flippedScrollHorizontallyToPoint(childEndPoint, animated: true) + } + } + } + + /// Scroll horizontally to a view with the provided starting point + /// Used when the layout direction is Left to Right + /// - Parameters: + /// - point: The point to scroll to. Normally the starting point of the view. + /// - animated: `true` if the scrolling should be animated, `false` if it should be immediate. + private func normalScrollHorizontallyToPoint(_ point: CGFloat, animated: Bool) { + // Scroll to a rectangle starting at the X of your subview, with a width of the scrollview safe area + // if the end of the rectangle is within the content size width. + // + // Otherwise, scroll all the way to the end. + // + if point + safeAreaLayoutGuide.layoutFrame.width < contentSize.width { + let targetRect = CGRect(x: point - Constants.targetRectPadding, + y: 0, + width: safeAreaLayoutGuide.layoutFrame.width, + height: Constants.targetRectDimension) + scrollRectToVisible(targetRect, animated: animated) + + // This ensures scrolling to the correct position, especially when there are layout changes + // + // See: https://stackoverflow.com/a/35437399 + // + layoutIfNeeded() + } else { + scrollToEnd(animated: true) + } + } + + /// Scroll horizontally to a view with the provided end point + /// Used when the layout direction is Right to Left + /// - Parameters: + /// - point: The point to scroll to. Normally the ending point of the view. + /// - animated: `true` if the scrolling should be animated, `false` if it should be immediate. + private func flippedScrollHorizontallyToPoint(_ point: CGFloat, animated: Bool) { + // Scroll to a rectangle ending at the X of your subview, with a width of the scrollview safe area + // if the start of the rectangle is within the content size width. + // + // Otherwise, scroll all the way to the start. + // + if point - safeAreaLayoutGuide.layoutFrame.width > 0 { + let targetRect = CGRect(x: point - safeAreaLayoutGuide.layoutFrame.width, + y: 0, + width: safeAreaLayoutGuide.layoutFrame.width, + height: Constants.targetRectDimension) + scrollRectToVisible(targetRect, animated: animated) + + // This ensures scrolling to the correct position, especially when there are layout changes + // + // See: https://stackoverflow.com/a/35437399 + // + layoutIfNeeded() + } else { + scrollToStart(animated: true) + } + } + + func scrollToEnd(animated: Bool) { + let endOffset = CGPoint(x: contentSize.width - bounds.size.width, y: 0) + if endOffset.x > 0 { + setContentOffset(endOffset, animated: animated) + layoutIfNeeded() + } + } + + func scrollToStart(animated: Bool) { + let startOffset = CGPoint(x: 0, y: 0) + setContentOffset(startOffset, animated: animated) + layoutIfNeeded() + } + + private enum Constants { + /// An arbitrary placeholder value for the target rect -- must be some value larger than 0 + static let targetRectDimension: CGFloat = 1 + + /// Padding for the target rect + static let targetRectPadding: CGFloat = 20 + } +} diff --git a/WordPress/Classes/Extensions/UITableViewCell+enableDisable.swift b/WordPress/Classes/Extensions/UITableViewCell+enableDisable.swift new file mode 100644 index 000000000000..4642d64387f2 --- /dev/null +++ b/WordPress/Classes/Extensions/UITableViewCell+enableDisable.swift @@ -0,0 +1,20 @@ +import WordPressShared + +extension UITableViewCell { + /// Enable cell interaction + @objc func enable() { + isUserInteractionEnabled = true + textLabel?.isEnabled = true + textLabel?.textColor = .text + detailTextLabel?.textColor = .listSmallIcon + } + + /// Disable cell interaction + @objc func disable() { + accessoryType = .none + isUserInteractionEnabled = false + textLabel?.isEnabled = false + textLabel?.textColor = .neutral(.shade20) + detailTextLabel?.textColor = .neutral(.shade20) + } +} diff --git a/WordPress/Classes/Extensions/UITextField+WorkaroundContinueIssue.swift b/WordPress/Classes/Extensions/UITextField+WorkaroundContinueIssue.swift new file mode 100644 index 000000000000..c26ef1d79428 --- /dev/null +++ b/WordPress/Classes/Extensions/UITextField+WorkaroundContinueIssue.swift @@ -0,0 +1,50 @@ +import Foundation + +@objc +extension UITextField { + + /// This method takes care of resolving whether the iOS version is vulnerable to the Bulgarian / Icelandic keyboard crash issue + /// by Apple. Once the issue is resolved by Apple we should consider setting an upper iOS version to limit this workaround. + /// + /// Once we drop support for iOS 14, we could remove this extension entirely. + /// + public class func shouldActivateWorkaroundForBulgarianKeyboardCrash() -> Bool { + return true + } + + /// We're swizzling `UITextField.becomeFirstResponder()` so that we can fix an issue with + /// Bulgarian and Icelandic keyboards when appropriate. + /// + /// Ref: https://github.com/wordpress-mobile/WordPress-iOS/issues/15187 + /// + @objc + class func activateWorkaroundForBulgarianKeyboardCrash() { + guard let original = class_getInstanceMethod( + UITextField.self, + #selector(UITextField.becomeFirstResponder)), + let new = class_getInstanceMethod( + UITextField.self, + #selector(UITextField.swizzledBecomeFirstResponder)) else { + + DDLogError("Could not activate workaround for Bulgarian keyboard crash.") + + return + } + + method_exchangeImplementations(original, new) + } + + /// This method simply replaces the `returnKeyType == .continue` with + /// `returnKeyType == .next`when the Bulgarian Keyboard crash workaround is needed. + /// + public func swizzledBecomeFirstResponder() { + if UITextField.shouldActivateWorkaroundForBulgarianKeyboardCrash(), + returnKeyType == .continue { + returnKeyType = .next + } + + // This can look confusing - it's basically calling the original method to + // make sure we don't disrupt anything. + swizzledBecomeFirstResponder() + } +} diff --git a/WordPress/Classes/Extensions/UIView+Borders.swift b/WordPress/Classes/Extensions/UIView+Borders.swift index e71038718dc9..832b07e182a1 100644 --- a/WordPress/Classes/Extensions/UIView+Borders.swift +++ b/WordPress/Classes/Extensions/UIView+Borders.swift @@ -25,6 +25,19 @@ extension UIView { return borderView } + @discardableResult + func addBottomBorder(withColor bgColor: UIColor, leadingMargin: CGFloat) -> UIView { + let borderView = makeBorderView(withColor: bgColor) + + NSLayoutConstraint.activate([ + borderView.heightAnchor.constraint(equalToConstant: .hairlineBorderWidth), + borderView.bottomAnchor.constraint(equalTo: bottomAnchor), + borderView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: leadingMargin), + borderView.trailingAnchor.constraint(equalTo: trailingAnchor) + ]) + return borderView + } + private func makeBorderView(withColor: UIColor) -> UIView { let borderView = UIView() borderView.backgroundColor = withColor diff --git a/WordPress/Classes/Extensions/UIView+PinSubviewPriority.swift b/WordPress/Classes/Extensions/UIView+PinSubviewPriority.swift new file mode 100644 index 000000000000..b11dde317e52 --- /dev/null +++ b/WordPress/Classes/Extensions/UIView+PinSubviewPriority.swift @@ -0,0 +1,23 @@ +import Foundation +import UIKit + +extension UIView { + /// Adds constraints that pin a subview to self with padding insets and an applied priority. + /// + /// - Parameters: + /// - subview: a subview to be pinned to self. + /// - insets: spacing between each subview edge to self. A positive value for an edge indicates that the subview is inside self on that edge. + /// - priority: the `UILayoutPriority` to be used for the constraints + @objc public func pinSubviewToAllEdges(_ subview: UIView, insets: UIEdgeInsets = .zero, priority: UILayoutPriority = .defaultHigh) { + let constraints = [ + leadingAnchor.constraint(equalTo: subview.leadingAnchor, constant: -insets.left), + trailingAnchor.constraint(equalTo: subview.trailingAnchor, constant: insets.right), + topAnchor.constraint(equalTo: subview.topAnchor, constant: -insets.top), + bottomAnchor.constraint(equalTo: subview.bottomAnchor, constant: insets.bottom), + ] + + constraints.forEach { $0.priority = priority } + + NSLayoutConstraint.activate(constraints) + } +} diff --git a/WordPress/Classes/Extensions/UIViewController+Dismissal.swift b/WordPress/Classes/Extensions/UIViewController+Dismissal.swift new file mode 100644 index 000000000000..a13de365c8b3 --- /dev/null +++ b/WordPress/Classes/Extensions/UIViewController+Dismissal.swift @@ -0,0 +1,25 @@ +import Foundation + +extension UIViewController { + /// iOS's `isBeingDismissed` can return `false` if the VC is being dismissed indirectly, by one of its ancestors + /// being dismissed. This method returns `true` if the VC is being dismissed directly, or if one of its ancestors is being + /// dismissed. + /// + func isBeingDismissedDirectlyOrByAncestor() -> Bool { + guard !isBeingDismissed else { + return true + } + + var current: UIViewController = self + + while let ancestor = current.parent { + guard !ancestor.isBeingDismissed else { + return true + } + + current = ancestor + } + + return false + } +} diff --git a/WordPress/Classes/Extensions/UIViewController+NoResults.swift b/WordPress/Classes/Extensions/UIViewController+NoResults.swift index 878c3624642f..048cec2d211f 100644 --- a/WordPress/Classes/Extensions/UIViewController+NoResults.swift +++ b/WordPress/Classes/Extensions/UIViewController+NoResults.swift @@ -1,4 +1,4 @@ -protocol NoResultsViewHost: class { } +protocol NoResultsViewHost: AnyObject { } extension NoResultsViewHost where Self: UIViewController { typealias NoResultsCustomizationBlock = (NoResultsViewController) -> Void @@ -22,6 +22,7 @@ extension NoResultsViewHost where Self: UIViewController { /// - view: The no results view parentView. Required. /// - title: Main descriptive text. Required. /// - subtitle: Secondary descriptive text. Optional. + /// - noConnectionSubtitle: Secondary descriptive text to use specifically when there is no network connection. Optional. /// - buttonTitle: Title of action button. Optional. /// - attributedSubtitle: Secondary descriptive attributed text. Optional. /// - attributedSubtitleConfiguration: Called after default styling, for subtitle attributed text customization. @@ -33,6 +34,7 @@ extension NoResultsViewHost where Self: UIViewController { func configureAndDisplayNoResults(on view: UIView, title: String, subtitle: String? = nil, + noConnectionSubtitle: String? = nil, buttonTitle: String? = nil, attributedSubtitle: NSAttributedString? = nil, attributedSubtitleConfiguration: NoResultsAttributedSubtitleConfiguration? = nil, @@ -44,6 +46,7 @@ extension NoResultsViewHost where Self: UIViewController { noResultsViewController.configure(title: title, buttonTitle: buttonTitle, subtitle: subtitle, + noConnectionSubtitle: noConnectionSubtitle, attributedSubtitle: attributedSubtitle, attributedSubtitleConfiguration: attributedSubtitleConfiguration, image: image, diff --git a/WordPress/Classes/Extensions/UIViewController+Notice.swift b/WordPress/Classes/Extensions/UIViewController+Notice.swift index 25fe65afd2b3..db9ac41246fe 100644 --- a/WordPress/Classes/Extensions/UIViewController+Notice.swift +++ b/WordPress/Classes/Extensions/UIViewController+Notice.swift @@ -1,46 +1,33 @@ import Foundation import WordPressFlux -import WordPressShared - extension UIViewController { - /// Dispatch a Notice for subscribing notification action - /// - /// - Parameters: - /// - siteTitle: Title to display - /// - siteID: Site id to be used - func dispatchSubscribingNotificationNotice(with siteTitle: String?, siteID: NSNumber?) { - guard let siteTitle = siteTitle, let siteID = siteID else { - return - } - - let localizedTitle = NSLocalizedString("Following %@", comment: "Title for a notice informing the user that they've successfully followed a site. %@ is a placeholder for the name of the site.") - let title = String(format: localizedTitle, siteTitle) - let message = NSLocalizedString("Enable site notifications?", comment: "Message informing the user about the enable notifications action") - let buttonTitle = NSLocalizedString("Enable", comment: "Button title about the enable notifications action") + @objc func displayNotice(title: String, message: String? = nil) { + displayActionableNotice(title: title, message: message) + } - let notice = Notice(title: title, - message: message, - feedbackType: .success, - notificationInfo: nil, - actionTitle: buttonTitle) { _ in - let context = ContextManager.sharedInstance().mainContext - let service = ReaderTopicService(managedObjectContext: context) - service.toggleSubscribingNotifications(for: siteID.intValue, subscribe: true, { - WPAnalytics.track(.readerListNotificationEnabled) - }) - } - ActionDispatcher.dispatch(NoticeAction.post(notice)) + @objc func displayActionableNotice(title: String, + message: String? = nil, + actionTitle: String? = nil, + actionHandler: ((Bool) -> Void)? = nil) { + displayActionableNotice(title: title, message: message, style: NormalNoticeStyle(), actionTitle: actionTitle, actionHandler: actionHandler) } -} -@objc extension UIViewController { - @objc func displayNotice(title: String, message: String? = nil) { - let notice = Notice(title: title, message: message) + // NoticeStyle is Swift only, so this method is needed to set it. + func displayActionableNotice(title: String, + message: String? = nil, + style: NoticeStyle = NormalNoticeStyle(), + actionTitle: String? = nil, + actionHandler: ((Bool) -> Void)? = nil) { + let notice = Notice(title: title, message: message, style: style, actionTitle: actionTitle, actionHandler: actionHandler) ActionDispatcher.dispatch(NoticeAction.post(notice)) } @objc func dismissNotice() { ActionDispatcher.dispatch(NoticeAction.dismiss) } + + @objc func dismissQuickStartTaskCompleteNotice() { + QuickStartTourGuide.shared.dismissTaskCompleteNotice() + } } diff --git a/WordPress/Classes/Extensions/URL+Helpers.swift b/WordPress/Classes/Extensions/URL+Helpers.swift index 0941f52eab24..6969c5a837ec 100644 --- a/WordPress/Classes/Extensions/URL+Helpers.swift +++ b/WordPress/Classes/Extensions/URL+Helpers.swift @@ -116,23 +116,7 @@ extension URL { } } - func appendingHideMasterbarParameters() -> URL? { - guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { - return nil - } - // FIXME: This code is commented out because of a menu navigation issue that can occur while - // viewing a site within the webview. See https://github.com/wordpress-mobile/WordPress-iOS/issues/9796 - // for more details. - // - // var queryItems = components.queryItems ?? [] - // queryItems.append(URLQueryItem(name: "preview", value: "true")) - // queryItems.append(URLQueryItem(name: "iframe", value: "true")) - // components.queryItems = queryItems - ///// - return components.url - } - - var hasWordPressDotComHostname: Bool { + var isHostedAtWPCom: Bool { guard let host = host else { return false } @@ -143,7 +127,44 @@ extension URL { var isWordPressDotComPost: Bool { // year, month, day, slug let components = pathComponents.filter({ $0 != "/" }) - return components.count == 4 && hasWordPressDotComHostname + return components.count == 4 && isHostedAtWPCom + } + + + /// Handle the common link protocols. + /// - tel: open a prompt to call the phone number + /// - sms: compose new message in iMessage app + /// - mailto: compose new email in Mail app + /// + var isLinkProtocol: Bool { + guard let urlScheme = scheme else { + return false + } + + let linkProtocols = ["tel", "sms", "mailto"] + if linkProtocols.contains(urlScheme) && UIApplication.shared.canOpenURL(self) { + return true + } + + return false + } + + + /// Does a quick test to see if 2 urls are equal to each other by + /// using just the hosts and paths. This ignores any query items, or hashes + /// on the urls + func isHostAndPathEqual(to url: URL) -> Bool { + guard + let components1 = URLComponents(url: self, resolvingAgainstBaseURL: true), + let components2 = URLComponents(url: url, resolvingAgainstBaseURL: true) + else { + return false + } + + let check1 = (components1.host ?? "") + components1.path + let check2 = (components2.host ?? "") + components2.path + + return check1 == check2 } } @@ -159,10 +180,6 @@ extension NSURL { return NSNumber(value: fileSize) } - @objc func appendingHideMasterbarParameters() -> NSURL? { - let url = self as URL - return url.appendingHideMasterbarParameters() as NSURL? - } } extension URL { @@ -188,3 +205,16 @@ extension URL { return newComponents.url ?? self } } + +extension URL { + /// Appends query items to the URL. + /// - Parameter newQueryItems: The new query items to add to the URL. These will **not** overwrite any existing items but are appended to the existing list. + /// - Returns: The URL with added query items. + func appendingQueryItems(_ newQueryItems: [URLQueryItem]) -> URL { + var components = URLComponents(url: self, resolvingAgainstBaseURL: false) + var queryItems = components?.queryItems ?? [] + queryItems.append(contentsOf: newQueryItems) + components?.queryItems = queryItems + return components?.url ?? self + } +} diff --git a/WordPress/Classes/Extensions/URLQueryItem+Parameters.swift b/WordPress/Classes/Extensions/URLQueryItem+Parameters.swift new file mode 100644 index 000000000000..4fe9e5edba48 --- /dev/null +++ b/WordPress/Classes/Extensions/URLQueryItem+Parameters.swift @@ -0,0 +1,11 @@ + +extension URLQueryItem { + /// Query Parameters to be used for the WP Stories feature. + /// Can be appended to the URL of any WordPress blog post. + enum WPStory { + /// Opens the story in fullscreen. + static let fullscreen = URLQueryItem(name: "wp-story-load-in-fullscreen", value: "true") + /// Begins playing the story immediately. + static let playOnLoad = URLQueryItem(name: "wp-story-play-on-load", value: "true") + } +} diff --git a/WordPress/Classes/Extensions/WKWebView+Preview.swift b/WordPress/Classes/Extensions/WKWebView+Preview.swift deleted file mode 100644 index 4f5b1ee1d209..000000000000 --- a/WordPress/Classes/Extensions/WKWebView+Preview.swift +++ /dev/null @@ -1,32 +0,0 @@ -import WebKit - -/// This extension contains a couple of small hacks used in site previews -/// to hide various wpcom UI elements from webpages or prevent interaction. -/// -extension WKWebView { - - func prepareWPComPreview() { - hideWPComPreviewBanners() - preventInteraction() - } - - /// Hides the 'Create your website at WordPress.com' getting started bar, - /// displayed on logged out sites, as well as the cookie widget banner. - func hideWPComPreviewBanners() { - let javascript = """ - document.querySelector('html').style.cssText += '; margin-top: 0 !important;';\n document.getElementById('wpadminbar').style.display = 'none';\n - document.getElementsByClassName("widget_eu_cookie_law_widget")[0].style += '; display: none !important;';\n - """ - - evaluateJavaScript(javascript, completionHandler: nil) - } - - /// Prevents interaction on the current page using CSS. - func preventInteraction() { - let javascript = """ - document.querySelector('*').style.cssText += '; pointer-events: none; -webkit-tap-highlight-color: rgba(0,0,0,0);';\n - """ - - evaluateJavaScript(javascript, completionHandler: nil) - } -} diff --git a/WordPress/Classes/Extensions/WKWebView+UserAgent.swift b/WordPress/Classes/Extensions/WKWebView+UserAgent.swift index b2aa6e35c35f..730c6a36615b 100644 --- a/WordPress/Classes/Extensions/WKWebView+UserAgent.swift +++ b/WordPress/Classes/Extensions/WKWebView+UserAgent.swift @@ -13,7 +13,7 @@ extension WKWebView { func userAgent() -> String { guard let userAgent = value(forKey: WKWebView.userAgentKey) as? String, userAgent.count > 0 else { - CrashLogging.logMessage( + WordPressAppDelegate.crashLogging?.logMessage( "This method for retrieveing the user agent seems to be no longer working. We need to figure out an alternative.", properties: [:], level: .error) diff --git a/WordPress/Classes/Extensions/WordPressSupportSourceTag+Editor.swift b/WordPress/Classes/Extensions/WordPressSupportSourceTag+Editor.swift new file mode 100644 index 000000000000..ccbcf4d9126c --- /dev/null +++ b/WordPress/Classes/Extensions/WordPressSupportSourceTag+Editor.swift @@ -0,0 +1,8 @@ +import Foundation +import WordPressAuthenticator + +extension WordPressSupportSourceTag { + public static var editorHelp: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "editorHelp", origin: "origin:editor-help") + } +} diff --git a/WordPress/Classes/Models/AbstractPost+Autosave.swift b/WordPress/Classes/Models/AbstractPost+Autosave.swift new file mode 100644 index 000000000000..8f2ca445eaa3 --- /dev/null +++ b/WordPress/Classes/Models/AbstractPost+Autosave.swift @@ -0,0 +1,11 @@ +import Foundation + +extension AbstractPost { + /// An autosave revision may include post title, content and/or excerpt. + var hasAutosaveRevision: Bool { + guard let autosaveRevisionIdentifier = autosaveIdentifier?.intValue else { + return false + } + return autosaveRevisionIdentifier > 0 + } +} diff --git a/WordPress/Classes/Models/AbstractPost+Blaze.swift b/WordPress/Classes/Models/AbstractPost+Blaze.swift new file mode 100644 index 000000000000..7288e99fec53 --- /dev/null +++ b/WordPress/Classes/Models/AbstractPost+Blaze.swift @@ -0,0 +1,8 @@ +import Foundation + +extension AbstractPost { + + var canBlaze: Bool { + return blog.isBlazeApproved && status == .publish && password == nil + } +} diff --git a/WordPress/Classes/Models/AbstractPost+Local.swift b/WordPress/Classes/Models/AbstractPost+Local.swift new file mode 100644 index 000000000000..5736b7d512cf --- /dev/null +++ b/WordPress/Classes/Models/AbstractPost+Local.swift @@ -0,0 +1,22 @@ +import Foundation + +extension AbstractPost { + /// Returns true if the post is a draft and has never been uploaded to the server. + var isLocalDraft: Bool { + return self.isDraft() && !self.hasRemote() + } + + var isLocalRevision: Bool { + return self.originalIsDraft() && self.isRevision() && self.remoteStatus == .local + } + + /// Count posts that have never been uploaded to the server. + /// + /// - Parameter context: A `NSManagedObjectContext` in which to count the posts + /// - Returns: number of local posts in the given context. + static func countLocalPosts(in context: NSManagedObjectContext) -> Int { + let request = NSFetchRequest<AbstractPost>(entityName: NSStringFromClass(AbstractPost.self)) + request.predicate = NSPredicate(format: "postID = NULL OR postID <= 0") + return (try? context.count(for: request)) ?? 0 + } +} diff --git a/WordPress/Classes/Models/AbstractPost+MarkAsFailedAndDraftIfNeeded.swift b/WordPress/Classes/Models/AbstractPost+MarkAsFailedAndDraftIfNeeded.swift new file mode 100644 index 000000000000..fced068bf6e9 --- /dev/null +++ b/WordPress/Classes/Models/AbstractPost+MarkAsFailedAndDraftIfNeeded.swift @@ -0,0 +1,36 @@ +@objc extension AbstractPost { + + // MARK: - Updating the Remote Status + + /// Updates the post after an upload failure. + /// + /// Local-only pages will be reverted back to `.draft` to avoid scenarios like this: + /// + /// 1. A locally published page upload failed + /// 2. The user presses the Page List's Retry button. + /// 3. The page upload is retried and the page is **published**. + /// + /// This is an unexpected behavior and can be surprising for the user. We'd want the user to + /// explicitly press on a “Publish” button instead. + /// + /// Posts' statuses are kept as is because we support automatic uploading of posts. + /// + /// - Important: This logic could have been placed in the setter for `remoteStatus`, but it's my belief + /// that our code will be much more resilient if we decouple the act of setting the `remoteStatus` value + /// and the logic behind processing an upload failure. In fact I think the `remoteStatus` setter should + /// eventually be made private. + /// - SeeAlso: PostCoordinator.resume + /// + func markAsFailedAndDraftIfNeeded() { + guard self.remoteStatus != .failed else { + return + } + + self.remoteStatus = .failed + + if !self.hasRemote() && self is Page { + self.status = .draft + self.dateModified = Date() + } + } +} diff --git a/WordPress/Classes/Models/AbstractPost+TitleForVisibility.swift b/WordPress/Classes/Models/AbstractPost+TitleForVisibility.swift new file mode 100644 index 000000000000..b19b15960aaf --- /dev/null +++ b/WordPress/Classes/Models/AbstractPost+TitleForVisibility.swift @@ -0,0 +1,18 @@ +import Foundation + +extension AbstractPost { + static let passwordProtectedLabel = NSLocalizedString("Password protected", comment: "Privacy setting for posts set to 'Password protected'. Should be the same as in core WP.") + static let privateLabel = NSLocalizedString("Private", comment: "Privacy setting for posts set to 'Private'. Should be the same as in core WP.") + static let publicLabel = NSLocalizedString("Public", comment: "Privacy setting for posts set to 'Public' (default). Should be the same as in core WP.") + + /// A title describing the status. Ie.: "Public" or "Private" or "Password protected" + @objc var titleForVisibility: String { + if password != nil { + return AbstractPost.passwordProtectedLabel + } else if status == .publishPrivate { + return AbstractPost.privateLabel + } + + return AbstractPost.publicLabel + } +} diff --git a/WordPress/Classes/Models/AbstractPost.h b/WordPress/Classes/Models/AbstractPost.h index 3175e05fbbc0..eb780151c39e 100644 --- a/WordPress/Classes/Models/AbstractPost.h +++ b/WordPress/Classes/Models/AbstractPost.h @@ -96,7 +96,7 @@ typedef NS_ENUM(NSUInteger, AbstractPostRemoteStatus) { - (NSString *)blavatarForDisplay; - (NSString *)dateStringForDisplay; - (BOOL)isMultiAuthorBlog; -- (BOOL)isPrivate; +- (BOOL)isPrivateAtWPCom; - (BOOL)supportsStats; @@ -152,6 +152,11 @@ typedef NS_ENUM(NSUInteger, AbstractPostRemoteStatus) { */ - (BOOL)isDraft; +/** + Returns YES if the post is a published. + */ +- (BOOL)isPublished; + /** Returns YES if the original post is a draft */ diff --git a/WordPress/Classes/Models/AbstractPost.m b/WordPress/Classes/Models/AbstractPost.m index 16477d2f41c9..1e62605b84c2 100644 --- a/WordPress/Classes/Models/AbstractPost.m +++ b/WordPress/Classes/Models/AbstractPost.m @@ -1,6 +1,6 @@ #import "AbstractPost.h" #import "Media.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "WordPress-Swift.h" #import "BasePost.h" @import WordPressKit; @@ -402,6 +402,11 @@ - (BOOL)isDraft return [self.status isEqualToString:PostStatusDraft]; } +- (BOOL)isPublished +{ + return [self.status isEqualToString:PostStatusPublish]; +} + - (BOOL)originalIsDraft { if ([self.status isEqualToString:PostStatusDraft]) { @@ -420,7 +425,7 @@ - (void)publishImmediately - (BOOL)shouldPublishImmediately { - return [self originalIsDraft] && ![self hasFuturePublishDate]; + return [self originalIsDraft] && [self dateCreatedIsNilOrEqualToDateModified]; } - (NSString *)authorNameForDisplay @@ -475,9 +480,9 @@ - (BOOL)supportsStats return [self.blog supports:BlogFeatureStats] && [self hasRemote]; } -- (BOOL)isPrivate +- (BOOL)isPrivateAtWPCom { - return self.blog.isPrivate; + return self.blog.isPrivateAtWPCom; } - (BOOL)isMultiAuthorBlog @@ -572,6 +577,17 @@ - (BOOL)hasLocalChanges return YES; } + if ( ((self.featuredImage != nil) && ![self.featuredImage.objectID isEqual: original.featuredImage.objectID]) || + (self.featuredImage == nil && self.original.featuredImage != nil) ) { + return YES; + } + + if ((self.authorID != original.authorID) + && (![self.authorID isEqual:original.authorID])) + { + return YES; + } + return NO; } diff --git a/WordPress/Classes/Models/AbstractPost.swift b/WordPress/Classes/Models/AbstractPost.swift index 44d3803093a2..72595c119466 100644 --- a/WordPress/Classes/Models/AbstractPost.swift +++ b/WordPress/Classes/Models/AbstractPost.swift @@ -122,6 +122,10 @@ extension AbstractPost { return content?.contains("<!-- wp:") ?? false } + @objc func containsStoriesBlocks() -> Bool { + return content?.contains("<!-- wp:jetpack/story") ?? false + } + var analyticsPostType: String? { switch self { case is Post: diff --git a/WordPress/Classes/Models/BasePost.h b/WordPress/Classes/Models/BasePost.h index da9f7f9f1283..28dd67b30271 100644 --- a/WordPress/Classes/Models/BasePost.h +++ b/WordPress/Classes/Models/BasePost.h @@ -33,6 +33,11 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, assign) BOOL isFeaturedImageChanged; +/** + BOOL flag set to true if the post is first time published. + */ +@property (nonatomic, assign) BOOL isFirstTimePublish; + //date conversion @property (nonatomic, strong, nullable) NSDate * dateCreated; diff --git a/WordPress/Classes/Models/BasePost.m b/WordPress/Classes/Models/BasePost.m index c3b10ee13060..30ec2716066a 100644 --- a/WordPress/Classes/Models/BasePost.m +++ b/WordPress/Classes/Models/BasePost.m @@ -1,7 +1,7 @@ #import "BasePost.h" #import "Media.h" #import "NSMutableDictionary+Helpers.h" -#import "ContextManager.h" +#import "CoreDataStack.h" @import WordPressShared; @implementation BasePost @@ -22,6 +22,7 @@ @implementation BasePost @dynamic pathForDisplayImage; @synthesize isFeaturedImageChanged; +@synthesize isFirstTimePublish; - (NSDate *)dateCreated { @@ -107,7 +108,8 @@ - (BOOL)hasContent - (BOOL)isContentEmpty { - return self.content ? self.content.isEmpty : YES; + BOOL isContentAnEmptyGBParagraph = [self.content isEqualToString:@"<!-- wp:paragraph -->\n<p></p>\n<!-- /wp:paragraph -->"]; + return self.content ? (self.content.isEmpty || isContentAnEmptyGBParagraph) : YES; } @end diff --git a/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataClass.swift b/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataClass.swift new file mode 100644 index 000000000000..0283d26c4ba4 --- /dev/null +++ b/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(BlockEditorSettingElement) +public class BlockEditorSettingElement: NSManagedObject { + +} diff --git a/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataProperties.swift b/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataProperties.swift new file mode 100644 index 000000000000..c2cd60f429cc --- /dev/null +++ b/WordPress/Classes/Models/BlockEditorSettingElement+CoreDataProperties.swift @@ -0,0 +1,80 @@ +import Foundation +import CoreData + +enum BlockEditorSettingElementTypes: String { + case color + case gradient + case experimentalFeatures + + var valueKey: String { + self.rawValue + } +} + +enum BlockEditorExperimentalFeatureKeys: String { + case galleryWithImageBlocks + case quoteBlockV2 + case listBlockV2 +} + +extension BlockEditorSettingElement { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<BlockEditorSettingElement> { + return NSFetchRequest<BlockEditorSettingElement>(entityName: "BlockEditorSettingElement") + } + + /// Stores the associated type that this object represents. + /// Available types are defined in `BlockEditorSettingElementTypes` + /// + @NSManaged public var type: String + + /// Stores the value for the associated type. The associated field in the API response might differ based on the type. + /// + @NSManaged public var value: String + + /// Stores a unique key associated to the `value`. + /// + @NSManaged public var slug: String + + /// Stores a user friendly display name for the `slug`. + /// + @NSManaged public var name: String + + /// Stores maintains the order as passed from the API + /// + @NSManaged public var order: Int + + /// Stores a reference back to the parent `BlockEditorSettings`. + /// + @NSManaged public var settings: BlockEditorSettings +} + +extension BlockEditorSettingElement: Identifiable { + var rawRepresentation: [String: String]? { + guard let type = BlockEditorSettingElementTypes(rawValue: self.type) else { return nil } + return [ + #keyPath(BlockEditorSettingElement.slug): self.slug, + #keyPath(BlockEditorSettingElement.name): self.name, + type.valueKey: self.value + ] + } + + convenience init(fromRawRepresentation rawObject: [String: String], type: BlockEditorSettingElementTypes, order: Int, context: NSManagedObjectContext) { + self.init(name: rawObject[ #keyPath(BlockEditorSettingElement.name)], + value: rawObject[type.valueKey], + slug: rawObject[#keyPath(BlockEditorSettingElement.slug)], + type: type, + order: order, + context: context) + } + + convenience init(name: String?, value: String?, slug: String?, type: BlockEditorSettingElementTypes, order: Int, context: NSManagedObjectContext) { + self.init(context: context) + + self.type = type.rawValue + self.value = value ?? "" + self.slug = slug ?? "" + self.name = name ?? "" + self.order = order + } +} diff --git a/WordPress/Classes/Models/BlockEditorSettings+CoreDataClass.swift b/WordPress/Classes/Models/BlockEditorSettings+CoreDataClass.swift new file mode 100644 index 000000000000..43e2d73ab70e --- /dev/null +++ b/WordPress/Classes/Models/BlockEditorSettings+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(BlockEditorSettings) +public class BlockEditorSettings: NSManagedObject { + +} diff --git a/WordPress/Classes/Models/BlockEditorSettings+CoreDataProperties.swift b/WordPress/Classes/Models/BlockEditorSettings+CoreDataProperties.swift new file mode 100644 index 000000000000..05ed69d83ffc --- /dev/null +++ b/WordPress/Classes/Models/BlockEditorSettings+CoreDataProperties.swift @@ -0,0 +1,59 @@ +import Foundation +import CoreData + +extension BlockEditorSettings { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<BlockEditorSettings> { + return NSFetchRequest<BlockEditorSettings>(entityName: "BlockEditorSettings") + } + + /// Stores a n MD5 checksum representing the stored data. Used for a comparison to decide if the data has changed. + /// + @NSManaged public var checksum: String + + /// Stores a Bool indicating if the theme supports Full Site Editing (FSE) or not. `true` means the theme is an FSE theme. + /// Default is `false` + /// + @NSManaged public var isFSETheme: Bool + + /// Stores a date indicating the last time stamp that the settings were modified. + /// + @NSManaged public var lastUpdated: Date + + /// Stores the raw JSON string that comes from the Global Styles Setting Request. + /// + @NSManaged public var rawStyles: String? + + /// Stores the raw JSON string that comes from the Global Styles Setting Request. + /// + @NSManaged public var rawFeatures: String? + + /// Stores a set of attributes describing values that are represented with arrays in the API request. + /// Available types are defined in `BlockEditorSettingElementTypes` + /// + @NSManaged public var elements: Set<BlockEditorSettingElement>? + + /// Stores a reference back to the parent blog. + /// + @NSManaged public var blog: Blog +} + +// MARK: Generated accessors for elements +extension BlockEditorSettings { + + @objc(addElementsObject:) + @NSManaged public func addToElements(_ value: BlockEditorSettingElement) + + @objc(removeElementsObject:) + @NSManaged public func removeFromElements(_ value: BlockEditorSettingElement) + + @objc(addElements:) + @NSManaged public func addToElements(_ values: Set<BlockEditorSettingElement>) + + @objc(removeElements:) + @NSManaged public func removeFromElements(_ values: Set<BlockEditorSettingElement>) +} + +extension BlockEditorSettings: Identifiable { + +} diff --git a/WordPress/Classes/Models/BlockEditorSettings+GutenbergEditorSettings.swift b/WordPress/Classes/Models/BlockEditorSettings+GutenbergEditorSettings.swift new file mode 100644 index 000000000000..90f4a3a25c07 --- /dev/null +++ b/WordPress/Classes/Models/BlockEditorSettings+GutenbergEditorSettings.swift @@ -0,0 +1,112 @@ +import Foundation +import WordPressKit +import Gutenberg + +extension BlockEditorSettings: GutenbergEditorSettings { + public var colors: [[String: String]]? { + elementsByType(.color) + } + + public var gradients: [[String: String]]? { + elementsByType(.gradient) + } + + public var galleryWithImageBlocks: Bool { + return experimentalFeature(.galleryWithImageBlocks) + } + + public var quoteBlockV2: Bool { + return experimentalFeature(.quoteBlockV2) + } + + public var listBlockV2: Bool { + return experimentalFeature(.listBlockV2) + } + + private func elementsByType(_ type: BlockEditorSettingElementTypes) -> [[String: String]]? { + return elements?.sorted(by: { (lhs, rhs) -> Bool in + return lhs.order >= rhs.order + }).compactMap({ (element) -> [String: String]? in + guard element.type == type.rawValue else { return nil } + return element.rawRepresentation + }) + } + + private func experimentalFeature(_ feature: BlockEditorExperimentalFeatureKeys) -> Bool { + guard let experimentalFeature = elements?.first(where: { (element) -> Bool in + guard element.type == BlockEditorSettingElementTypes.experimentalFeatures.rawValue else { return false } + return element.slug == feature.rawValue + }) else { return false } + + return Bool(experimentalFeature.value) ?? false + } +} + +extension BlockEditorSettings { + convenience init?(editorTheme: RemoteEditorTheme, context: NSManagedObjectContext) { + self.init(context: context) + self.isFSETheme = false + self.lastUpdated = Date() + self.checksum = editorTheme.checksum + + var parsedElements = Set<BlockEditorSettingElement>() + if let themeSupport = editorTheme.themeSupport { + themeSupport.colors?.enumerated().forEach({ (index, color) in + parsedElements.insert(BlockEditorSettingElement(fromRawRepresentation: color, type: .color, order: index, context: context)) + }) + + themeSupport.gradients?.enumerated().forEach({ (index, gradient) in + parsedElements.insert(BlockEditorSettingElement(fromRawRepresentation: gradient, type: .gradient, order: index, context: context)) + }) + } + + self.elements = parsedElements + } + + convenience init?(remoteSettings: RemoteBlockEditorSettings, context: NSManagedObjectContext) { + self.init(context: context) + self.isFSETheme = remoteSettings.isFSETheme + self.lastUpdated = Date() + self.checksum = remoteSettings.checksum + self.rawStyles = remoteSettings.rawStyles + self.rawFeatures = remoteSettings.rawFeatures + + var parsedElements = Set<BlockEditorSettingElement>() + + remoteSettings.colors?.enumerated().forEach({ (index, color) in + parsedElements.insert(BlockEditorSettingElement(fromRawRepresentation: color, type: .color, order: index, context: context)) + }) + + remoteSettings.gradients?.enumerated().forEach({ (index, gradient) in + parsedElements.insert(BlockEditorSettingElement(fromRawRepresentation: gradient, type: .gradient, order: index, context: context)) + }) + + // Experimental Features + let galleryKey = BlockEditorExperimentalFeatureKeys.galleryWithImageBlocks.rawValue + let galleryRefactor = BlockEditorSettingElement(name: galleryKey, + value: "\(remoteSettings.galleryWithImageBlocks)", + slug: galleryKey, + type: .experimentalFeatures, + order: 0, + context: context) + let quoteKey = BlockEditorExperimentalFeatureKeys.quoteBlockV2.rawValue + let quoteRefactor = BlockEditorSettingElement(name: quoteKey, + value: "\(remoteSettings.quoteBlockV2)", + slug: quoteKey, + type: .experimentalFeatures, + order: 1, + context: context) + let listKey = BlockEditorExperimentalFeatureKeys.listBlockV2.rawValue + let listRefactor = BlockEditorSettingElement(name: listKey, + value: "\(remoteSettings.listBlockV2)", + slug: listKey, + type: .experimentalFeatures, + order: 2, + context: context) + parsedElements.insert(galleryRefactor) + parsedElements.insert(quoteRefactor) + parsedElements.insert(listRefactor) + + self.elements = parsedElements + } +} diff --git a/WordPress/Classes/Models/Blocking/BlockedAuthor.swift b/WordPress/Classes/Models/Blocking/BlockedAuthor.swift new file mode 100644 index 000000000000..21db27749ae7 --- /dev/null +++ b/WordPress/Classes/Models/Blocking/BlockedAuthor.swift @@ -0,0 +1,62 @@ +import Foundation + +@objc(BlockedAuthor) +final class BlockedAuthor: NSManagedObject { + + @NSManaged var accountID: NSNumber + @NSManaged var authorID: NSNumber +} + +extension BlockedAuthor { + + // MARK: Fetch Elements + + static func findOne(_ query: Query, context: NSManagedObjectContext) -> BlockedAuthor? { + return Self.find(query, context: context).first + } + + static func find(_ query: Query, context: NSManagedObjectContext) -> [BlockedAuthor] { + do { + let request = NSFetchRequest<BlockedAuthor>(entityName: Self.entityName()) + request.predicate = query.predicate + let result = try context.fetch(request) + return result + } catch let error { + DDLogError("Couldn't fetch blocked author with error: \(error.localizedDescription)") + return [] + } + } + + // MARK: Inserting Elements + + static func insert(into context: NSManagedObjectContext) -> BlockedAuthor { + return NSEntityDescription.insertNewObject(forEntityName: Self.entityName(), into: context) as! BlockedAuthor + } + + // MARK: - Deleting Elements + + @discardableResult + static func delete(_ query: Query, context: NSManagedObjectContext) -> Bool { + let objects = Self.find(query, context: context) + for object in objects { + context.deleteObject(object) + } + return true + } + + // MARK: - Types + + enum Query { + case accountID(NSNumber) + case predicate(NSPredicate) + + var predicate: NSPredicate { + switch self { + case .accountID(let id): + return NSPredicate(format: "\(#keyPath(BlockedAuthor.accountID)) = %@", id) + case .predicate(let predicate): + return predicate + } + } + } +} diff --git a/WordPress/Classes/Models/Blocking/BlockedSite.swift b/WordPress/Classes/Models/Blocking/BlockedSite.swift new file mode 100644 index 000000000000..b26979cee24e --- /dev/null +++ b/WordPress/Classes/Models/Blocking/BlockedSite.swift @@ -0,0 +1,47 @@ +import Foundation + +@objc(BlockedSite) +final class BlockedSite: NSManagedObject { + + @NSManaged var accountID: NSNumber + @NSManaged var blogID: NSNumber +} + +extension BlockedSite { + + // MARK: Fetch Elements + + static func findOne(accountID: NSNumber, blogID: NSNumber, context: NSManagedObjectContext) -> BlockedSite? { + return Self.find(accountID: accountID, blogID: blogID, context: context).first + } + + static func find(accountID: NSNumber, blogID: NSNumber, context: NSManagedObjectContext) -> [BlockedSite] { + do { + let request = NSFetchRequest<BlockedSite>(entityName: Self.entityName()) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "\(#keyPath(BlockedSite.accountID)) = %@ AND \(#keyPath(BlockedSite.blogID)) = %@", accountID, blogID) + let result = try context.fetch(request) + return result + } catch let error { + DDLogError("Couldn't fetch blocked site with error: \(error.localizedDescription)") + return [] + } + } + + // MARK: Inserting Elements + + static func insert(into context: NSManagedObjectContext) -> BlockedSite { + return NSEntityDescription.insertNewObject(forEntityName: Self.entityName(), into: context) as! BlockedSite + } + + // MARK: - Deleting Elements + + @discardableResult + static func delete(accountID: NSNumber, blogID: NSNumber, context: NSManagedObjectContext) -> Bool { + let objects = Self.find(accountID: accountID, blogID: blogID, context: context) + for object in objects { + context.deleteObject(object) + } + return true + } +} diff --git a/WordPress/Classes/Models/Blog+BlockEditorSettings.swift b/WordPress/Classes/Models/Blog+BlockEditorSettings.swift new file mode 100644 index 000000000000..11ccf22a2281 --- /dev/null +++ b/WordPress/Classes/Models/Blog+BlockEditorSettings.swift @@ -0,0 +1,15 @@ +import Foundation +import CoreData + +extension Blog { + + /// Stores the relationship to the `BlockEditorSettings` which is an optional entity that holds settings realated to the BlockEditor. These are features + /// such as Global Styles and Full Site Editing settings and capabilities. + /// + @NSManaged public var blockEditorSettings: BlockEditorSettings? + + @objc + func supportsBlockEditorSettings() -> Bool { + return hasRequiredWordPressVersion("5.8") + } +} diff --git a/WordPress/Classes/Models/Blog+BlogAuthors.swift b/WordPress/Classes/Models/Blog+BlogAuthors.swift index 2340433cfd3d..8545eacf0207 100644 --- a/WordPress/Classes/Models/Blog+BlogAuthors.swift +++ b/WordPress/Classes/Models/Blog+BlogAuthors.swift @@ -3,7 +3,7 @@ import CoreData extension Blog { - @NSManaged public var authors: NSSet? + @NSManaged public var authors: Set<BlogAuthor>? @objc(addAuthorsObject:) @@ -17,4 +17,14 @@ extension Blog { @objc(removeAuthors:) @NSManaged public func removeFromAuthors(_ values: NSSet) + + @objc + func getAuthorWith(id: NSNumber) -> BlogAuthor? { + return authors?.first(where: { $0.userID == id }) + } + + @objc + func getAuthorWith(linkedID: NSNumber) -> BlogAuthor? { + return authors?.first(where: { $0.linkedUserID == linkedID }) + } } diff --git a/WordPress/Classes/Models/Blog+Capabilities.swift b/WordPress/Classes/Models/Blog+Capabilities.swift index 6bcc4486d836..a493d813a354 100644 --- a/WordPress/Classes/Models/Blog+Capabilities.swift +++ b/WordPress/Classes/Models/Blog+Capabilities.swift @@ -28,7 +28,7 @@ extension Blog { /// Returns true if a given capability is enabled. False otherwise /// public func isUserCapableOf(_ capability: Capability) -> Bool { - return capabilities?[capability.rawValue] as? Bool ?? false + return isUserCapableOf(capability.rawValue) } /// Returns true if the current user is allowed to list a Blog's Users @@ -48,4 +48,36 @@ extension Blog { @objc public func isUploadingFilesAllowed() -> Bool { return isUserCapableOf(.UploadFiles) } + + /// Returns true if the current user is allowed to see Jetpack's Backups + /// + @objc public func isBackupsAllowed() -> Bool { + return isUserCapableOf("backup") || isUserCapableOf("backup-daily") || isUserCapableOf("backup-realtime") + } + + /// Returns true if the current user is allowed to see Jetpack's Scan + /// + @objc public func isScanAllowed() -> Bool { + return !hasBusinessPlan && isUserCapableOf("scan") + } + + /// Returns true if the current user is allowed to list and edit the blog's Pages + /// + @objc public func isListingPagesAllowed() -> Bool { + return isAdmin || isUserCapableOf(.EditPages) + } + + /// Returns true if the current user is allowed to view Stats + /// + @objc public func isViewingStatsAllowed() -> Bool { + return isAdmin || isUserCapableOf(.ViewStats) + } + + private func isUserCapableOf(_ capability: String) -> Bool { + return capabilities?[capability] as? Bool ?? false + } + + public func areBloggingRemindersAllowed() -> Bool { + return Feature.enabled(.bloggingReminders) && isUserCapableOf(.EditPosts) && JetpackNotificationMigrationService.shared.shouldPresentNotifications() + } } diff --git a/WordPress/Classes/Models/Blog+Creation.swift b/WordPress/Classes/Models/Blog+Creation.swift new file mode 100644 index 000000000000..b5346d6e64e6 --- /dev/null +++ b/WordPress/Classes/Models/Blog+Creation.swift @@ -0,0 +1,29 @@ +extension Blog { + + /// Creates a blank `Blog` object for this account + @objc(createBlankBlogWithAccount:) + static func createBlankBlog(with account: WPAccount) -> Blog { + let blog = createBlankBlog(in: account.managedObjectContext!) + blog.account = account + return blog + } + + /// Creates a blank `Blog` object with no account + @objc(createBlankBlogInContext:) + static func createBlankBlog(in context: NSManagedObjectContext) -> Blog { + let blog = Blog(context: context) + blog.addSettingsIfNecessary() + return blog + } + + @objc + func addSettingsIfNecessary() { + guard settings == nil else { + return + } + + settings = BlogSettings(context: managedObjectContext!) + settings?.blog = self + } + +} diff --git a/WordPress/Classes/Models/Blog+DashboardState.swift b/WordPress/Classes/Models/Blog+DashboardState.swift new file mode 100644 index 000000000000..be8f4807b21f --- /dev/null +++ b/WordPress/Classes/Models/Blog+DashboardState.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Blog { + /// The state of the dashboard for the current blog + var dashboardState: BlogDashboardState { + BlogDashboardState.shared(for: self) + } +} diff --git a/WordPress/Classes/Models/Blog+History.swift b/WordPress/Classes/Models/Blog+History.swift new file mode 100644 index 000000000000..f0b043b642a6 --- /dev/null +++ b/WordPress/Classes/Models/Blog+History.swift @@ -0,0 +1,32 @@ +import Foundation + +extension Blog { + + /// Returns the blog currently flagged as the one last used, or the primary blog, + /// or the first blog in an alphanumerically sorted list, whichever is found first. + @objc(lastUsedOrFirstInContext:) + static func lastUsedOrFirst(in context: NSManagedObjectContext) -> Blog? { + lastUsed(in: context) + ?? (try? WPAccount.lookupDefaultWordPressComAccount(in: context))?.defaultBlog + ?? firstBlog(in: context) + } + + /// Returns the blog currently flaged as the one last used. + static func lastUsed(in context: NSManagedObjectContext) -> Blog? { + guard let url = RecentSitesService().recentSites.first else { + return nil + } + + return try? BlogQuery() + .visible(true) + .hostname(matching: url) + .blog(in: context) + } + + private static func firstBlog(in context: NSManagedObjectContext) -> Blog? { + try? BlogQuery() + .visible(true) + .blog(in: context) + } + +} diff --git a/WordPress/Classes/Models/Blog+HomepageSettings.swift b/WordPress/Classes/Models/Blog+HomepageSettings.swift new file mode 100644 index 000000000000..59895499f4c1 --- /dev/null +++ b/WordPress/Classes/Models/Blog+HomepageSettings.swift @@ -0,0 +1,112 @@ +import Foundation + +enum HomepageType: String { + case page + case posts + + var title: String { + switch self { + case .page: + return NSLocalizedString("Static Homepage", comment: "Name of setting configured when a site uses a static page as its homepage") + case .posts: + return NSLocalizedString("Classic Blog", comment: "Name of setting configured when a site uses a list of blog posts as its homepage") + } + } + + var remoteType: RemoteHomepageType { + switch self { + case .page: + return .page + case .posts: + return .posts + } + } +} + +extension Blog { + private enum OptionsKeys { + static let homepageType = "show_on_front" + static let homepageID = "page_on_front" + static let postsPageID = "page_for_posts" + } + + /// The type of homepage used for the site: blog posts, or static pages + /// + var homepageType: HomepageType? { + get { + guard let options = options, + !options.isEmpty, + let type = getOptionString(name: OptionsKeys.homepageType) + else { + return nil + } + + return HomepageType(rawValue: type) + } + set { + if let value = newValue?.rawValue { + setValue(value, forOption: OptionsKeys.homepageType) + } + } + } + + /// The ID of the page to use for the site's 'posts' page, + /// if `homepageType` is set to `.posts` + /// + var homepagePostsPageID: Int? { + get { + guard let options = options, + !options.isEmpty, + let pageID = getOptionNumeric(name: OptionsKeys.postsPageID) + else { + return nil + } + + return pageID.intValue + } + set { + let number: NSNumber? + if let newValue = newValue { + number = NSNumber(integerLiteral: newValue) + } else { + number = nil + } + setValue(number as Any, forOption: OptionsKeys.postsPageID) + } + } + + /// The ID of the page to use for the site's homepage, + /// if `homepageType` is set to `.page` + /// + var homepagePageID: Int? { + get { + guard let options = options, + !options.isEmpty, + let pageID = getOptionNumeric(name: OptionsKeys.homepageID) + else { + return nil + } + + return pageID.intValue + } + set { + let number: NSNumber? + if let newValue = newValue { + number = NSNumber(integerLiteral: newValue) + } else { + number = nil + } + setValue(number as Any, forOption: OptionsKeys.homepageID) + } + } + + /// Getter which returns the current homepage (or nil) + /// Note: It seems to be necessary to first sync pages (otherwise the `findPost` result fails to cast to `Page`) + var homepage: Page? { + guard let pageID = homepageType == .page ? homepagePageID + : homepageType == .posts ? homepagePostsPageID + : nil else { return nil } + let context = ContextManager.sharedInstance().mainContext + return lookupPost(withID: Int64(pageID), in: context) as? Page + } +} diff --git a/WordPress/Classes/Models/Blog+Jetpack.swift b/WordPress/Classes/Models/Blog+Jetpack.swift index 3e7a83ad34b5..d96a7a07bb37 100644 --- a/WordPress/Classes/Models/Blog+Jetpack.swift +++ b/WordPress/Classes/Models/Blog+Jetpack.swift @@ -3,11 +3,11 @@ extension Blog { return getOptionValue(name) as? T } - private func getOptionString(name: String) -> String? { + func getOptionString(name: String) -> String? { return (getOption(name: name) as NSString?).map(String.init) } - private func getOptionNumeric(name: String) -> NSNumber? { + func getOptionNumeric(name: String) -> NSNumber? { switch getOptionValue(name) { case let numericValue as NSNumber: return numericValue @@ -31,4 +31,53 @@ extension Blog { state.automatedTransfer = getOption(name: "is_automated_transfer") ?? false return state } + + /// Returns true if the blog has the proper version of Jetpack installed, + /// otherwise false + /// + var hasJetpack: Bool { + guard let jetpack else { + return false + } + return (jetpack.isConnected && jetpack.isUpdatedToRequiredVersion) + } + + /// Returns true if the blog has a version of the Jetpack plugin installed, + /// otherwise false + /// + var jetpackIsConnected: Bool { + guard let jetpack else { + return false + } + return jetpack.isConnected + } + + // MARK: Jetpack Individual Plugins Support + + var jetpackConnectionActivePlugins: [String]? { + switch getOptionValue("jetpack_connection_active_plugins") { + case .some(let values as [NSString]): + return values.map { String($0) } + case .some(let values as [String]): + return values + default: + return nil + } + } + + /// Returns true if the blog is Jetpack-connected only through individual plugins. Otherwise false. + /// + /// If the site is hosted at WP.com, the key `jetpack_connection_active_plugins` will not exist in `options`. + /// Atomic sites will have the full Jetpack plugin automatically installed. + /// Example values for Jetpack individual plugins: `jetpack-search`, `jetpack-backup`, etc. + /// + /// Note: We can't use `jetpackIsConnected` because it checks the installed Jetpack version. + /// + var jetpackIsConnectedWithoutFullPlugin: Bool { + guard let activeJetpackPlugins = jetpackConnectionActivePlugins else { + return false + } + + return !(activeJetpackPlugins.isEmpty || activeJetpackPlugins.contains("jetpack")) + } } diff --git a/WordPress/Classes/Models/Blog+Lookup.swift b/WordPress/Classes/Models/Blog+Lookup.swift new file mode 100644 index 000000000000..dbc57cc3ad6f --- /dev/null +++ b/WordPress/Classes/Models/Blog+Lookup.swift @@ -0,0 +1,154 @@ +import Foundation + +/// An extension dedicated to looking up and returning blog objects +public extension Blog { + + /// Lookup a Blog by ID + /// + /// - Parameters: + /// - id: The ID associated with the blog. + /// + /// On a WPMU site, this is the `blog_id` field on the [the wp_blogs table](https://codex.wordpress.org/Database_Description#Table:_wp_blogs). + /// - context: An NSManagedObjectContext containing the `Blog` object with the given `blogID`. + /// - Returns: The `Blog` object associated with the given `blogID`, if it exists. + static func lookup(withID id: Int, in context: NSManagedObjectContext) throws -> Blog? { + return try lookup(withID: Int64(id), in: context) + } + + /// Lookup a Blog by ID + /// + /// - Parameters: + /// - id: The ID associated with the blog. + /// + /// On a WPMU site, this is the `blog_id` field on the [the wp_blogs table](https://codex.wordpress.org/Database_Description#Table:_wp_blogs). + /// - context: An NSManagedObjectContext containing the `Blog` object with the given `blogID`. + /// - Returns: The `Blog` object associated with the given `blogID`, if it exists. + static func lookup(withID id: Int64, in context: NSManagedObjectContext) throws -> Blog? { + let fetchRequest = NSFetchRequest<Self>(entityName: Blog.entityName()) + fetchRequest.predicate = NSPredicate(format: "blogID == %ld", id) + return try context.fetch(fetchRequest).first + } + + /// Lookup a Blog by ID + /// + /// - Parameters: + /// - id: The NSNumber-wrapped ID associated with the blog. + /// + /// On a WPMU site, this is the `blog_id` field on the [the wp_blogs table](https://codex.wordpress.org/Database_Description#Table:_wp_blogs). + /// - context: An NSManagedObjectContext containing the `Blog` object with the given `blogID`. + /// - Returns: The `Blog` object associated with the given `blogID`, if it exists. + @objc + static func lookup(withID id: NSNumber, in context: NSManagedObjectContext) -> Blog? { + // Because a `nil` NSNumber can be passed from Objective-C, we can't trust the object + // to have a valid value. For that reason, we'll unwrap it to an `int64` and look that up instead. + // That way, if the `id` is `nil`, it'll return nil instead of crashing while trying to + // assemble the predicate as in `NSPredicate("blogID == %@")` + try? lookup(withID: id.int64Value, in: context) + } + + /// Lookup a Blog by its hostname + /// + /// - Parameters: + /// - hostname: The hostname of the blog. + /// - context: An `NSManagedObjectContext` containing the `Blog` object with the given `hostname`. + /// - Returns: The `Blog` object associated with the given `hostname`, if it exists. + @objc(lookupWithHostname:inContext:) + static func lookup(hostname: String, in context: NSManagedObjectContext) -> Blog? { + try? BlogQuery().hostname(containing: hostname).blog(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. + @objc(lookupWithUsername:xmlrpc:inContext:) + static func lookup(username: String, xmlrpc: String, in context: NSManagedObjectContext) -> Blog? { + try? BlogQuery().xmlrpc(matching: xmlrpc).selfHostedBlogUsername(username).blog(in: context) + } + + /// Searches for a `Blog` object for this account with the given XML-RPC endpoint + /// + /// - Warning: If more than one blog is found, they'll be considered duplicates and be + /// deleted leaving only one of them. + /// + /// - Parameters: + /// - xmlrpc: the XML-RPC endpoint URL as a string + /// - account: the account the blog belongs to + /// - context: the NSManagedObjectContext containing the account and the Blog object. + /// - Returns: the blog if one was found, otherwise it returns nil + static func lookup(xmlrpc: String, andRemoveDuplicateBlogsOf account: WPAccount, in context: NSManagedObjectContext) -> Blog? { + let predicate = NSPredicate(format: "xmlrpc like %@", xmlrpc) + let foundBlogs = account.blogs.filter { predicate.evaluate(with: $0) } + + guard foundBlogs.count > 1 else { + return foundBlogs.first + } + + // If more than one blog matches, return the first and delete the rest + + // Choose blogs with URL not starting with https to account for a glitch in the API in early 2014 + let blogToReturn = foundBlogs.first { $0.url?.starts(with: "https://") == false } + ?? foundBlogs.randomElement()! + + // Remove the duplicates + var duplicates = foundBlogs + duplicates.remove(blogToReturn) + duplicates.forEach(context.delete(_:)) + + return blogToReturn + } + + @objc(countInContext:) + static func count(in context: NSManagedObjectContext) -> Int { + BlogQuery().count(in: context) + } + + @objc(wpComBlogCountInContext:) + static func wpComBlogCount(in context: NSManagedObjectContext) -> Int { + BlogQuery().hostedByWPCom(true).count(in: context) + } + + static func hasAnyJetpackBlogs(in context: NSManagedObjectContext) throws -> Bool { + let fetchRequest = NSFetchRequest<Self>(entityName: Blog.entityName()) + fetchRequest.predicate = NSPredicate(format: "account != NULL AND isHostedAtWPcom = NO") + if try context.count(for: fetchRequest) > 0 { + return true + } + + return Blog.selfHosted(in: context) + .filter { $0.jetpack?.isConnected == true } + .count > 0 + } + + @available(swift, obsoleted: 1.0) + @objc(hasAnyJetpackBlogsInContext:) + static func objc_hasAnyJetpackBlogs(in context: NSManagedObjectContext) -> Bool { + (try? hasAnyJetpackBlogs(in: context)) == true + } + + @objc(selfHostedInContext:) + static func selfHosted(in context: NSManagedObjectContext) -> [Blog] { + (try? BlogQuery().hostedByWPCom(false).blogs(in: context)) ?? [] + } + + /// Find a cached comment with given ID. + /// + /// - Parameter id: The comment id + /// - Returns: The `Comment` object associated with the given id, or `nil` if none is found. + @objc + func comment(withID id: NSNumber) -> Comment? { + comment(withID: id.int32Value) + } + + /// Find a cached comment with given ID. + /// + /// - Parameter id: The comment id + /// - Returns: The `Comment` object associated with the given id, or `nil` if none is found. + func comment(withID id: Int32) -> Comment? { + (comments as? Set<Comment>)?.first { $0.commentID == id } + } + +} diff --git a/WordPress/Classes/Models/Blog+Media.swift b/WordPress/Classes/Models/Blog+Media.swift new file mode 100644 index 000000000000..f0aae5188094 --- /dev/null +++ b/WordPress/Classes/Models/Blog+Media.swift @@ -0,0 +1,37 @@ +import Foundation + +extension Blog { + + /// Get the number of items in a blog media library that are of a certain type. + /// + /// - Parameter mediaTypes: set of media type values to be considered in the counting. + /// - Returns: Number of media assets matching the criteria. + @objc(mediaLibraryCountForTypes:) + func mediaLibraryCount(types mediaTypes: NSSet) -> Int { + guard let context = managedObjectContext else { + return 0 + } + + var count = 0 + context.performAndWait { + var predicate = NSPredicate(format: "blog == %@", self) + + if mediaTypes.count > 0 { + let types = mediaTypes + .map { obj in + guard let rawValue = (obj as? NSNumber)?.uintValue, + let type = MediaType(rawValue: rawValue) else { + fatalError("Can't convert \(obj) to MediaType") + } + return Media.string(from: type) + } + let filterPredicate = NSPredicate(format: "mediaTypeString IN %@", types) + predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, filterPredicate]) + } + + count = context.countObjects(ofType: Media.self, matching: predicate) + } + return count + } + +} diff --git a/WordPress/Classes/Models/Blog+MySite.swift b/WordPress/Classes/Models/Blog+MySite.swift new file mode 100644 index 000000000000..f1795cb95592 --- /dev/null +++ b/WordPress/Classes/Models/Blog+MySite.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Blog { + /// If the blog should show the "Jetpack" or the "General" section + @objc var shouldShowJetpackSection: Bool { + (supports(.activity) && !isWPForTeams()) || supports(.jetpackSettings) + } +} diff --git a/WordPress/Classes/Models/Blog+Organization.swift b/WordPress/Classes/Models/Blog+Organization.swift new file mode 100644 index 000000000000..3bf4eeedee47 --- /dev/null +++ b/WordPress/Classes/Models/Blog+Organization.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Blog { + var isAutomatticP2: Bool { + SiteOrganizationType(rawValue: organizationID.intValue) == .automattic + } +} diff --git a/WordPress/Classes/Models/Blog+Post.swift b/WordPress/Classes/Models/Blog+Post.swift new file mode 100644 index 000000000000..e29bdd8d4f31 --- /dev/null +++ b/WordPress/Classes/Models/Blog+Post.swift @@ -0,0 +1,111 @@ +import Foundation + +// MARK: - Lookup posts + +extension Blog { + /// Lookup a post in the blog. + /// + /// - Parameter postID: The ID associated with the post. + /// - Returns: The `AbstractPost` associated with the given post ID. + @objc(lookupPostWithID:inContext:) + func lookupPost(withID postID: NSNumber, in context: NSManagedObjectContext) -> AbstractPost? { + lookupPost(withID: postID.int64Value, in: context) + } + + /// Lookup a post in the blog. + /// + /// - Parameter postID: The ID associated with the post. + /// - Returns: The `AbstractPost` associated with the given post ID. + func lookupPost(withID postID: Int, in context: NSManagedObjectContext) -> AbstractPost? { + lookupPost(withID: Int64(postID), in: context) + } + + /// Lookup a post in the blog. + /// + /// - Parameter postID: The ID associated with the post. + /// - Returns: The `AbstractPost` associated with the given post ID. + func lookupPost(withID postID: Int64, in context: NSManagedObjectContext) -> AbstractPost? { + let request = NSFetchRequest<AbstractPost>(entityName: NSStringFromClass(AbstractPost.self)) + request.predicate = NSPredicate(format: "blog = %@ AND original = NULL AND postID = %ld", self, postID) + return (try? context.fetch(request))?.first + } +} + +// MARK: - Create posts + +extension Blog { + + /// Create a post in the blog. + @objc + func createPost() -> Post { + guard let context = managedObjectContext else { + fatalError("The `Blog` instance is not associated with an `NSManagedObjectContext`") + } + + let post = NSEntityDescription.insertNewObject(forEntityName: NSStringFromClass(Post.self), into: context) as! Post + post.blog = self + post.remoteStatus = .sync + + if let categoryID = settings?.defaultCategoryID, + categoryID.intValue != PostCategoryUncategorized, + let category = try? PostCategory.lookup(withBlogID: objectID, categoryID: categoryID, in: context) { + post.addCategoriesObject(category) + } + + post.postFormat = settings?.defaultPostFormat + post.postType = Post.typeDefaultIdentifier + + if let userID = userID, let author = getAuthorWith(id: userID) { + post.authorID = author.userID + post.author = author.displayName + } + + try? context.obtainPermanentIDs(for: [post]) + precondition(!post.objectID.isTemporaryID, "The new post for this blog must have a permanent ObjectID") + + return post + } + + /// Create a draft post in the blog. + func createDraftPost() -> Post { + let post = createPost() + markAsDraft(post) + return post + } + + /// Create a page in the blog. + @objc + func createPage() -> Page { + guard let context = managedObjectContext else { + fatalError("The `Blog` instance is not associated with a `NSManagedObjectContext`") + } + + let page = NSEntityDescription.insertNewObject(forEntityName: NSStringFromClass(Page.self), into: context) as! Page + page.blog = self + page.date_created_gmt = Date() + page.remoteStatus = .sync + + if let userID = userID, let author = getAuthorWith(id: userID) { + page.authorID = author.userID + page.author = author.displayName + } + + try? context.obtainPermanentIDs(for: [page]) + precondition(!page.objectID.isTemporaryID, "The new page for this blog must have a permanent ObjectID") + + return page + } + + /// Create a draft page in the blog. + func createDraftPage() -> Page { + let page = createPage() + markAsDraft(page) + return page + } + + private func markAsDraft(_ post: AbstractPost) { + post.remoteStatus = .local + post.dateModified = Date() + post.status = .draft + } +} diff --git a/WordPress/Classes/Models/Blog+QuickStart.swift b/WordPress/Classes/Models/Blog+QuickStart.swift index 44e2cfefa14b..df7e7b506642 100644 --- a/WordPress/Classes/Models/Blog+QuickStart.swift +++ b/WordPress/Classes/Models/Blog+QuickStart.swift @@ -9,6 +9,22 @@ extension Blog { return quickStartTours?.filter { $0.skipped } } + var quickStartType: QuickStartType { + get { + guard let value = quickStartTypeValue?.intValue, + let type = QuickStartType(rawValue: value) else { + return .undefined + } + return type + } + + set { + quickStartTypeValue = NSNumber(value: newValue.rawValue) + let context = managedObjectContext ?? ContextManager.sharedInstance().mainContext + ContextManager.sharedInstance().saveContextAndWait(context) + } + } + public func skipTour(_ tourID: String) { let tourState = findOrCreate(tour: tourID) tourState.skipped = true diff --git a/WordPress/Classes/Models/Blog.h b/WordPress/Classes/Models/Blog.h index 1fb5bbc383e2..1128318f87a8 100644 --- a/WordPress/Classes/Models/Blog.h +++ b/WordPress/Classes/Models/Blog.h @@ -8,9 +8,14 @@ NS_ASSUME_NONNULL_BEGIN @class BlogSettings; @class WPAccount; @class WordPressComRestApi; +@class WordPressOrgRestApi; @class WordPressOrgXMLRPCApi; @class Role; @class QuickStartTourState; +@class UserSuggestion; +@class SiteSuggestion; +@class PageTemplateCategory; +@class JetpackFeaturesRemovalCoordinator; extern NSString * const BlogEntityName; extern NSString * const PostFormatStandard; @@ -34,6 +39,8 @@ typedef NS_ENUM(NSUInteger, BlogFeature) { BlogFeatureActivity, /// Does the blog support mentions? BlogFeatureMentions, + /// Does the blog support xposts? + BlogFeatureXposts, /// Does the blog support push notifications? BlogFeaturePushNotifications, /// Does the blog support theme browsing? @@ -69,7 +76,37 @@ typedef NS_ENUM(NSUInteger, BlogFeature) { /// Does the blog support deleting media? BlogFeatureMediaDeletion, /// Does the blog support Stock Photos feature (free photos library) - BlogFeatureStockPhotos + BlogFeatureStockPhotos, + /// Does the blog support Tenor feature (free GIF library) + BlogFeatureTenor, + /// Does the blog support setting the homepage type and pages? + BlogFeatureHomepageSettings, + /// Does the blog support stories? + BlogFeatureStories, + /// Does the blog support Jetpack contact info block? + BlogFeatureContactInfo, + BlogFeatureBlockEditorSettings, + /// Does the blog support the Layout grid block? + BlogFeatureLayoutGrid, + /// Does the blog support the tiled gallery block? + BlogFeatureTiledGallery, + /// Does the blog support the VideoPress block? + BlogFeatureVideoPress, + /// Does the blog support Facebook embed block? + BlogFeatureFacebookEmbed, + /// Does the blog support Instagram embed block? + BlogFeatureInstagramEmbed, + /// Does the blog support Loom embed block? + BlogFeatureLoomEmbed, + /// Does the blog support Smartframe embed block? + BlogFeatureSmartframeEmbed, + /// Does the blog support File Downloads section in stats? + BlogFeatureFileDownloadsStats, + /// Does the blog support Blaze? + BlogFeatureBlaze, + /// Does the blog support listing and editing Pages? + BlogFeaturePages, + }; typedef NS_ENUM(NSInteger, SiteVisibility) { @@ -85,6 +122,7 @@ typedef NS_ENUM(NSInteger, SiteVisibility) { @property (nonatomic, strong, readwrite, nullable) NSNumber *dotComID; @property (nonatomic, strong, readwrite, nullable) NSString *xmlrpc; @property (nonatomic, strong, readwrite, nullable) NSString *apiKey; +@property (nonatomic, strong, readwrite, nonnull) NSNumber *organizationID; @property (nonatomic, strong, readwrite, nullable) NSNumber *hasOlderPosts; @property (nonatomic, strong, readwrite, nullable) NSNumber *hasOlderPages; @property (nonatomic, strong, readwrite, nullable) NSSet<AbstractPost *> *posts; @@ -92,9 +130,12 @@ typedef NS_ENUM(NSInteger, SiteVisibility) { @property (nonatomic, strong, readwrite, nullable) NSSet *tags; @property (nonatomic, strong, readwrite, nullable) NSSet *comments; @property (nonatomic, strong, readwrite, nullable) NSSet *connections; +@property (nonatomic, strong, readwrite, nullable) NSSet *inviteLinks; @property (nonatomic, strong, readwrite, nullable) NSSet *domains; @property (nonatomic, strong, readwrite, nullable) NSSet *themes; @property (nonatomic, strong, readwrite, nullable) NSSet *media; +@property (nonatomic, strong, readwrite, nullable) NSSet<UserSuggestion *> *userSuggestions; +@property (nonatomic, strong, readwrite, nullable) NSSet<SiteSuggestion *> *siteSuggestions; @property (nonatomic, strong, readwrite, nullable) NSOrderedSet *menus; @property (nonatomic, strong, readwrite, nullable) NSOrderedSet *menuLocations; @property (nonatomic, strong, readwrite, nullable) NSSet<Role *> *roles; @@ -126,12 +167,14 @@ typedef NS_ENUM(NSInteger, SiteVisibility) { @property (nonatomic, strong, readwrite, nullable) NSSet *sharingButtons; @property (nonatomic, strong, readwrite, nullable) NSDictionary *capabilities; @property (nonatomic, strong, readwrite, nullable) NSSet<QuickStartTourState *> *quickStartTours; +@property (nonatomic, strong, readwrite, nullable) NSNumber *quickStartTypeValue; +@property (nonatomic, assign, readwrite) BOOL isBlazeApproved; /// The blog's user ID for the current user @property (nonatomic, strong, readwrite, nullable) NSNumber *userID; /// Disk quota for site, this is only available for WP.com sites @property (nonatomic, strong, readwrite, nullable) NSNumber *quotaSpaceAllowed; @property (nonatomic, strong, readwrite, nullable) NSNumber *quotaSpaceUsed; - +@property (nullable, nonatomic, retain) NSSet<PageTemplateCategory *> *pageTemplateCategories; /** * @details Maps to a BlogSettings instance, which contains a collection of the available preferences, @@ -159,6 +202,7 @@ typedef NS_ENUM(NSInteger, SiteVisibility) { @property (nonatomic, weak, readonly, nullable) NSArray *sortedConnections; @property (nonatomic, readonly, nullable) NSArray<Role *> *sortedRoles; @property (nonatomic, strong, readonly, nullable) WordPressOrgXMLRPCApi *xmlrpcApi; +@property (nonatomic, strong, readonly, nullable) WordPressOrgRestApi *wordPressOrgRestApi; @property (nonatomic, weak, readonly, nullable) NSString *version; @property (nonatomic, strong, readonly, nullable) NSString *authToken; @property (nonatomic, strong, readonly, nullable) NSSet *allowedFileTypes; @@ -185,22 +229,29 @@ typedef NS_ENUM(NSInteger, SiteVisibility) { // Used to check if the blog has an icon set up @property (readonly) BOOL hasIcon; +/** Determine timezone for blog from blog options. If no timezone information is stored on the device, then assume GMT+0 is the default. */ +@property (readonly) NSTimeZone *timeZone; + #pragma mark - Blog information - (BOOL)isAtomic; +- (BOOL)isWPForTeams; - (BOOL)isAutomatedTransfer; - (BOOL)isPrivate; +- (BOOL)isPrivateAtWPCom; - (nullable NSArray *)sortedCategories; - (nullable id)getOptionValue:(NSString *) name; +- (void)setValue:(id)value forOption:(NSString *)name; - (NSString *)loginUrl; - (NSString *)urlWithPath:(NSString *)path; - (NSString *)adminUrlWithPath:(NSString *)path; -- (NSUInteger)numberOfPendingComments; - (NSDictionary *) getImageResizeDimensions; - (BOOL)supportsFeaturedImages; - (BOOL)supports:(BlogFeature)feature; - (BOOL)supportsPublicize; - (BOOL)supportsShareButtons; +- (BOOL)isStatsActive; +- (BOOL)hasMappedDomain; /** * Returnst the text description for a post format code @@ -255,6 +306,10 @@ typedef NS_ENUM(NSInteger, SiteVisibility) { */ - (BOOL)isBasicAuthCredentialStored; +/// Checks the blogs installed WordPress version is more than or equal to the requiredVersion +/// @param requiredVersion The minimum version to check for +- (BOOL)hasRequiredWordPressVersion:(NSString *)requiredVersion; + @end NS_ASSUME_NONNULL_END diff --git a/WordPress/Classes/Models/Blog.m b/WordPress/Classes/Models/Blog.m index f492c0686f05..d22b633d47db 100644 --- a/WordPress/Classes/Models/Blog.m +++ b/WordPress/Classes/Models/Blog.m @@ -1,15 +1,15 @@ #import "Blog.h" -#import "Comment.h" #import "WPAccount.h" #import "AccountService.h" #import "NSURL+IDN.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "Constants.h" #import "WordPress-Swift.h" -#import "SFHFKeychainUtils.h" #import "WPUserAgent.h" #import "WordPress-Swift.h" +@class Comment; + static NSInteger const ImageSizeSmallWidth = 240; static NSInteger const ImageSizeSmallHeight = 180; static NSInteger const ImageSizeMediumWidth = 480; @@ -21,16 +21,19 @@ NSString * const BlogEntityName = @"Blog"; NSString * const PostFormatStandard = @"standard"; +NSString * const ActiveModulesKeyStats = @"stats"; NSString * const ActiveModulesKeyPublicize = @"publicize"; NSString * const ActiveModulesKeySharingButtons = @"sharedaddy"; NSString * const OptionsKeyActiveModules = @"active_modules"; NSString * const OptionsKeyPublicizeDisabled = @"publicize_permanently_disabled"; NSString * const OptionsKeyIsAutomatedTransfer = @"is_automated_transfer"; NSString * const OptionsKeyIsAtomic = @"is_wpcom_atomic"; +NSString * const OptionsKeyIsWPForTeams = @"is_wpforteams_site"; @interface Blog () @property (nonatomic, strong, readwrite) WordPressOrgXMLRPCApi *xmlrpcApi; +@property (nonatomic, strong, readwrite) WordPressOrgRestApi *wordPressOrgRestApi; @end @@ -41,6 +44,7 @@ @implementation Blog @dynamic url; @dynamic xmlrpc; @dynamic apiKey; +@dynamic organizationID; @dynamic hasOlderPosts; @dynamic hasOlderPages; @dynamic hasDomainCredit; @@ -50,8 +54,11 @@ @implementation Blog @dynamic comments; @dynamic connections; @dynamic domains; +@dynamic inviteLinks; @dynamic themes; @dynamic media; +@dynamic userSuggestions; +@dynamic siteSuggestions; @dynamic menus; @dynamic menuLocations; @dynamic roles; @@ -79,15 +86,19 @@ @implementation Blog @dynamic sharingButtons; @dynamic capabilities; @dynamic quickStartTours; +@dynamic quickStartTypeValue; +@dynamic isBlazeApproved; @dynamic userID; @dynamic quotaSpaceAllowed; @dynamic quotaSpaceUsed; +@dynamic pageTemplateCategories; @synthesize isSyncingPosts; @synthesize isSyncingPages; @synthesize videoPressEnabled; @synthesize isSyncingMedia; @synthesize xmlrpcApi = _xmlrpcApi; +@synthesize wordPressOrgRestApi = _wordPressOrgRestApi; #pragma mark - NSManagedObject subclass methods @@ -95,7 +106,13 @@ - (void)prepareForDeletion { [super prepareForDeletion]; + // delete stored password in the keychain for self-hosted sites. + if ([self.username length] > 0 && [self.xmlrpc length] > 0) { + self.password = nil; + } + [_xmlrpcApi invalidateAndCancelTasks]; + [_wordPressOrgRestApi invalidateAndCancelTasks]; } - (void)didTurnIntoFault @@ -104,45 +121,49 @@ - (void)didTurnIntoFault // Clean up instance variables self.xmlrpcApi = nil; + self.wordPressOrgRestApi = nil; [[NSNotificationCenter defaultCenter] removeObserver:self]; } - #pragma mark - #pragma mark Custom methods +- (NSNumber *)organizationID { + NSNumber *organizationID = [self primitiveValueForKey:@"organizationID"]; + + if (organizationID == nil) { + return @0; + } else { + return organizationID; + } +} + - (BOOL)isAtomic { - return [self.options[OptionsKeyIsAtomic] boolValue]; + NSNumber *value = (NSNumber *)[self getOptionValue:OptionsKeyIsAtomic]; + return [value boolValue]; } -- (BOOL)isAutomatedTransfer +- (BOOL)isWPForTeams { - NSNumber *value = (NSNumber *)[self getOptionValue:OptionsKeyIsAutomatedTransfer]; + NSNumber *value = (NSNumber *)[self getOptionValue:OptionsKeyIsWPForTeams]; return [value boolValue]; } -- (NSString *)icon +- (BOOL)isAutomatedTransfer { - [self willAccessValueForKey:@"icon"]; - NSString *icon = [self primitiveValueForKey:@"icon"]; - [self didAccessValueForKey:@"icon"]; - - if (icon) { - return icon; - } - - // if the icon is not set we can use the host url to construct it - NSString *hostUrl = [[NSURL URLWithString:self.xmlrpc] host]; - if (hostUrl == nil) { - hostUrl = self.xmlrpc; - } - return hostUrl; + NSNumber *value = (NSNumber *)[self getOptionValue:OptionsKeyIsAutomatedTransfer]; + return [value boolValue]; } // Used as a key to store passwords, if you change the algorithm, logins will break - (NSString *)displayURL { + if (self.url == nil) { + DDLogInfo(@"Blog display URL is nil"); + return nil; + } + NSError *error = nil; NSRegularExpression *protocol = [NSRegularExpression regularExpressionWithPattern:@"http(s?)://" options:NSRegularExpressionCaseInsensitive error:&error]; NSString *result = [NSString stringWithFormat:@"%@", [protocol stringByReplacingMatchesInString:self.url options:0 range:NSMakeRange(0, [self.url length]) withTemplate:@""]]; @@ -206,6 +227,11 @@ - (NSString *)loginUrl - (NSString *)urlWithPath:(NSString *)path { + if (!path || !self.xmlrpc) { + DDLogError(@"Blog: Error creating urlWithPath."); + return nil; + } + NSError *error = nil; NSRegularExpression *xmlrpc = [NSRegularExpression regularExpressionWithPattern:@"xmlrpc.php$" options:NSRegularExpressionCaseInsensitive error:&error]; return [xmlrpc stringByReplacingMatchesInString:self.xmlrpc options:0 range:NSMakeRange(0, [self.xmlrpc length]) withTemplate:path]; @@ -223,26 +249,6 @@ - (NSString *)adminUrlWithPath:(NSString *)path return [NSString stringWithFormat:@"%@%@", adminBaseUrl, path]; } -- (NSUInteger)numberOfPendingComments -{ - NSUInteger pendingComments = 0; - if ([self hasFaultForRelationshipNamed:@"comments"]) { - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Comment"]; - [request setPredicate:[NSPredicate predicateWithFormat:@"blog = %@ AND status like 'hold'", self]]; - [request setIncludesSubentities:NO]; - NSError *error; - pendingComments = [self.managedObjectContext countForFetchRequest:request error:&error]; - } else { - for (Comment *element in self.comments) { - if ( [CommentStatusPending isEqualToString:element.status] ) { - pendingComments++; - } - } - } - - return pendingComments; -} - - (NSArray *)sortedCategories { NSSortDescriptor *sortNameDescriptor = [[NSSortDescriptor alloc] initWithKey:@"categoryName" @@ -258,17 +264,19 @@ - (NSArray *)sortedPostFormats if ([self.postFormats count] == 0) { return @[]; } + NSMutableArray *sortedFormats = [NSMutableArray arrayWithCapacity:[self.postFormats count]]; - + if (self.postFormats[PostFormatStandard]) { [sortedFormats addObject:PostFormatStandard]; } - [self.postFormats enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - if (![key isEqual:PostFormatStandard]) { - [sortedFormats addObject:key]; - } + + NSArray *sortedNonStandardFormats = [[self.postFormats keysSortedByValueUsingSelector:@selector(localizedCaseInsensitiveCompare:)] wp_filter:^BOOL(id obj) { + return ![obj isEqual:PostFormatStandard]; }]; + [sortedFormats addObjectsFromArray:sortedNonStandardFormats]; + return [NSArray arrayWithArray:sortedFormats]; } @@ -301,6 +309,17 @@ - (NSString *)defaultPostFormatText return [self postFormatTextFromSlug:self.settings.defaultPostFormat]; } +- (BOOL)hasMappedDomain { + if (![self isHostedAtWPcom]) { + return NO; + } + + NSURL *unmappedURL = [NSURL URLWithString:[self getOptionValue:@"unmapped_url"]]; + NSURL *homeURL = [NSURL URLWithString:[self homeURL]]; + + return ![[unmappedURL host] isEqualToString:[homeURL host]]; +} + - (BOOL)hasIcon { // A blog without an icon has the blog url in icon, so we can't directly check its @@ -308,6 +327,36 @@ - (BOOL)hasIcon return self.icon.length > 0 ? [NSURL URLWithString:self.icon].pathComponents.count > 1 : NO; } +- (NSTimeZone *)timeZone +{ + CGFloat const OneHourInSeconds = 60.0 * 60.0; + + NSString *timeZoneName = [self getOptionValue:@"timezone"]; + NSNumber *gmtOffSet = [self getOptionValue:@"gmt_offset"]; + id optionValue = [self getOptionValue:@"time_zone"]; + + NSTimeZone *timeZone = nil; + if (timeZoneName.length > 0) { + timeZone = [NSTimeZone timeZoneWithName:timeZoneName]; + } + + if (!timeZone && gmtOffSet != nil) { + timeZone = [NSTimeZone timeZoneForSecondsFromGMT:(gmtOffSet.floatValue * OneHourInSeconds)]; + } + + if (!timeZone && optionValue != nil) { + NSInteger timeZoneOffsetSeconds = [optionValue floatValue] * OneHourInSeconds; + timeZone = [NSTimeZone timeZoneForSecondsFromGMT:timeZoneOffsetSeconds]; + } + + if (!timeZone) { + timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0]; + } + + return timeZone; + +} + - (NSString *)postFormatTextFromSlug:(NSString *)postFormatSlug { NSDictionary *allFormats = self.postFormats; @@ -322,10 +371,18 @@ - (NSString *)postFormatTextFromSlug:(NSString *)postFormatSlug return formatText; } -// WP.COM private blog. +/// Call this method to know whether the blog is private. +/// - (BOOL)isPrivate { - return (self.isHostedAtWPcom && [self.settings.privacy isEqualToNumber:@(SiteVisibilityPrivate)]); + return [self.settings.privacy isEqualToNumber:@(SiteVisibilityPrivate)]; +} + +/// Call this method to know whether the blog is private AND hosted at WP.com. +/// +- (BOOL)isPrivateAtWPCom +{ + return (self.isHostedAtWPcom && [self isPrivate]); } - (SiteVisibility)siteVisibility @@ -395,12 +452,26 @@ - (void)setXmlrpc:(NSString *)xmlrpc - (NSString *)version { - return [self getOptionValue:@"software_version"]; + // Ensure the value being returned is a string to prevent a crash when using this value in Swift + id value = [self getOptionValue:@"software_version"]; + + // If its a string, then return its value 🎉 + if([value isKindOfClass:NSString.class]) { + return value; + } + + // If its not a string, but can become a string, then convert it + if([value respondsToSelector:@selector(stringValue)]) { + return [value stringValue]; + } + + // If the value is an unknown type, and can not become a string, then default to a blank string. + return @""; } - (NSString *)password { - return [SFHFKeychainUtils getPasswordForUsername:self.username andServiceName:self.xmlrpc error:nil]; + return [SFHFKeychainUtils getPasswordForUsername:self.username andServiceName:self.xmlrpc accessGroup:nil error:nil]; } - (void)setPassword:(NSString *)password @@ -411,11 +482,13 @@ - (void)setPassword:(NSString *)password [SFHFKeychainUtils storeUsername:self.username andPassword:password forServiceName:self.xmlrpc + accessGroup:nil updateExisting:YES error:nil]; } else { [SFHFKeychainUtils deleteItemForUsername:self.username andServiceName:self.xmlrpc + accessGroup:nil error:nil]; } } @@ -429,7 +502,7 @@ - (NSString *)usernameForSite { if (self.username) { return self.username; - } else if (self.account && self.isHostedAtWPcom) { + } else if (self.account && self.isAccessibleThroughWPCom) { return self.account.username; } else { // FIXME: Figure out how to get the self hosted username when using Jetpack REST (@koke 2015-06-15) @@ -462,15 +535,21 @@ - (BOOL)supports:(BlogFeature)feature return [self supportsRestApi] && self.isListingUsersAllowed; case BlogFeatureWPComRESTAPI: case BlogFeatureCommentLikes: + return [self supportsRestApi]; case BlogFeatureStats: + return [self supportsRestApi] && [self isViewingStatsAllowed]; case BlogFeatureStockPhotos: - return [self supportsRestApi]; + return [self supportsRestApi] && [JetpackFeaturesRemovalCoordinator jetpackFeaturesEnabled]; + case BlogFeatureTenor: + return [JetpackFeaturesRemovalCoordinator jetpackFeaturesEnabled]; case BlogFeatureSharing: return [self supportsSharing]; case BlogFeatureOAuth2Login: return [self isHostedAtWPcom]; case BlogFeatureMentions: - return [self isHostedAtWPcom]; + return [self isAccessibleThroughWPCom]; + case BlogFeatureXposts: + return [self isAccessibleThroughWPCom]; case BlogFeatureReblog: case BlogFeaturePlans: return [self isHostedAtWPcom] && [self isAdmin]; @@ -479,7 +558,7 @@ - (BOOL)supports:(BlogFeature)feature case BlogFeatureJetpackImageSettings: return [self supportsJetpackImageSettings]; case BlogFeatureJetpackSettings: - return [self supportsRestApi] && ![self isHostedAtWPcom] && [self isAdmin]; + return [self supportsJetpackSettings]; case BlogFeaturePushNotifications: return [self supportsPushNotifications]; case BlogFeatureThemeBrowsing: @@ -501,13 +580,41 @@ - (BOOL)supports:(BlogFeature)feature case BlogFeatureSiteManagement: return [self supportsSiteManagementServices]; case BlogFeatureDomains: - return [self isHostedAtWPcom] && [self supportsSiteManagementServices]; + return ([self isHostedAtWPcom] || [self isAtomic]) && [self isAdmin] && ![self isWPForTeams]; case BlogFeatureNoncePreviews: return [self supportsRestApi] && ![self isHostedAtWPcom]; case BlogFeatureMediaMetadataEditing: return [self supportsRestApi] && [self isAdmin]; case BlogFeatureMediaDeletion: return [self isAdmin]; + case BlogFeatureHomepageSettings: + return [self supportsRestApi] && [self isAdmin]; + case BlogFeatureStories: + return [self supportsStories]; + case BlogFeatureContactInfo: + return [self supportsContactInfo]; + case BlogFeatureBlockEditorSettings: + return [self supportsBlockEditorSettings]; + case BlogFeatureLayoutGrid: + return [self supportsLayoutGrid]; + case BlogFeatureTiledGallery: + return [self supportsTiledGallery]; + case BlogFeatureVideoPress: + return [self supportsVideoPress]; + case BlogFeatureFacebookEmbed: + return [self supportsEmbedVariation: @"9.0"]; + case BlogFeatureInstagramEmbed: + return [self supportsEmbedVariation: @"9.0"]; + case BlogFeatureLoomEmbed: + return [self supportsEmbedVariation: @"9.0"]; + case BlogFeatureSmartframeEmbed: + return [self supportsEmbedVariation: @"10.2"]; + case BlogFeatureFileDownloadsStats: + return [self isHostedAtWPcom]; + case BlogFeatureBlaze: + return [self isBlazeApproved]; + case BlogFeaturePages: + return [self isListingPagesAllowed]; } } @@ -554,6 +661,11 @@ - (BOOL)supportsShareButtons } } +- (BOOL)isStatsActive +{ + return [self jetpackStatsModuleEnabled] || [self isHostedAtWPcom]; +} + - (BOOL)supportsPushNotifications { return [self accountIsDefaultAccount]; @@ -569,17 +681,67 @@ - (BOOL)supportsPluginManagement BOOL hasRequiredJetpack = [self hasRequiredJetpackVersion:@"5.6"]; BOOL isTransferrable = self.isHostedAtWPcom - && self.hasBusinessPlan - && self.siteVisibility != SiteVisibilityPrivate + && self.hasBusinessPlan + && self.siteVisibility != SiteVisibilityPrivate + && self.isAdmin; + + BOOL supports = isTransferrable || hasRequiredJetpack; + + // If the site is not hosted on WP.com we can still manage plugins directly using the WP.org rest API + // Reference: https://make.wordpress.org/core/2020/07/16/new-and-modified-rest-api-endpoints-in-wordpress-5-5/ + if(!supports && !self.account){ + supports = !self.isHostedAtWPcom + && self.wordPressOrgRestApi + && [self hasRequiredWordPressVersion:@"5.5"] && self.isAdmin; + } + + return supports; +} + +- (BOOL)supportsStories +{ + BOOL hasRequiredJetpack = [self hasRequiredJetpackVersion:@"9.1"]; + // Stories are disabled in iPad until this Kanvas issue is solved: https://github.com/tumblr/kanvas-ios/issues/104 + return (hasRequiredJetpack || self.isHostedAtWPcom) && ![UIDevice isPad] && [JetpackFeaturesRemovalCoordinator jetpackFeaturesEnabled]; +} + +- (BOOL)supportsContactInfo +{ + return [self hasRequiredJetpackVersion:@"8.5"] || self.isHostedAtWPcom; +} + +- (BOOL)supportsLayoutGrid +{ + return self.isHostedAtWPcom || self.isAtomic; +} + +- (BOOL)supportsTiledGallery +{ + return self.isHostedAtWPcom; +} + +- (BOOL)supportsVideoPress +{ + return self.isHostedAtWPcom; +} - return isTransferrable || hasRequiredJetpack; +- (BOOL)supportsEmbedVariation:(NSString *)requiredJetpackVersion +{ + return [self hasRequiredJetpackVersion:requiredJetpackVersion] || self.isHostedAtWPcom; +} + +- (BOOL)supportsJetpackSettings +{ + return [JetpackFeaturesRemovalCoordinator jetpackFeaturesEnabled] + && [self supportsRestApi] + && ![self isHostedAtWPcom] + && [self isAdmin]; } - (BOOL)accountIsDefaultAccount { - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext]; - return [accountService isDefaultWordPressComAccount:self.account]; + return [[self account] isDefaultWordPressComAccount]; } - (NSNumber *)dotComID @@ -717,6 +879,14 @@ - (WordPressOrgXMLRPCApi *)xmlrpcApi return _xmlrpcApi; } +- (WordPressOrgRestApi *)wordPressOrgRestApi +{ + if (_wordPressOrgRestApi == nil) { + _wordPressOrgRestApi = [[WordPressOrgRestApi alloc] initWithBlog:self]; + } + return _wordPressOrgRestApi; +} + - (WordPressComRestApi *)wordPressComRestApi { if (self.account) { @@ -743,6 +913,11 @@ - (BOOL)jetpackActiveModule:(NSString *)moduleName return [activeModules containsObject:moduleName] ?: NO; } +- (BOOL)jetpackStatsModuleEnabled +{ + return [self jetpackActiveModule:ActiveModulesKeyStats]; +} + - (BOOL)jetpackPublicizeModuleEnabled { return [self jetpackActiveModule:ActiveModulesKeyPublicize]; @@ -775,6 +950,13 @@ - (BOOL)hasRequiredJetpackVersion:(NSString *)requiredJetpackVersion && [self.jetpack.version compare:requiredJetpackVersion options:NSNumericSearch] != NSOrderedAscending; } +/// Checks the blogs installed WordPress version is more than or equal to the requiredVersion +/// @param requiredVersion The minimum version to check for +- (BOOL)hasRequiredWordPressVersion:(NSString *)requiredVersion +{ + return [self.version compare:requiredVersion options:NSNumericSearch] != NSOrderedAscending; +} + #pragma mark - Private Methods - (id)getOptionValue:(NSString *)name @@ -790,4 +972,20 @@ - (id)getOptionValue:(NSString *)name return optionValue; } +- (void)setValue:(id)value forOption:(NSString *)name +{ + [self.managedObjectContext performBlockAndWait:^{ + if ( self.options == nil || (self.options.count == 0) ) { + return; + } + + NSMutableDictionary *mutableOptions = [self.options mutableCopy]; + + NSDictionary *valueDict = @{ @"value": value }; + mutableOptions[name] = valueDict; + + self.options = [NSDictionary dictionaryWithDictionary:mutableOptions]; + }]; +} + @end diff --git a/WordPress/Classes/Models/BlogAuthor.swift b/WordPress/Classes/Models/BlogAuthor.swift index 22cd22340607..66d00b8661cb 100644 --- a/WordPress/Classes/Models/BlogAuthor.swift +++ b/WordPress/Classes/Models/BlogAuthor.swift @@ -11,4 +11,5 @@ public class BlogAuthor: NSManagedObject { @NSManaged public var avatarURL: String? @NSManaged public var linkedUserID: NSNumber? @NSManaged public var blog: Blog? + @NSManaged public var deletedFromBlog: Bool } diff --git a/WordPress/Classes/Models/BlogSettings+Discussion.swift b/WordPress/Classes/Models/BlogSettings+Discussion.swift index b5f4962ffcce..d3f4ea3151ef 100644 --- a/WordPress/Classes/Models/BlogSettings+Discussion.swift +++ b/WordPress/Classes/Models/BlogSettings+Discussion.swift @@ -194,7 +194,7 @@ extension BlogSettings { get { if commentsRequireManualModeration { return .disabled - } else if commentsFromKnownUsersWhitelisted { + } else if commentsFromKnownUsersAllowlisted { return .fromKnownUsers } @@ -202,7 +202,7 @@ extension BlogSettings { } set { commentsRequireManualModeration = newValue == .disabled - commentsFromKnownUsersWhitelisted = newValue == .fromKnownUsers + commentsFromKnownUsersAllowlisted = newValue == .fromKnownUsers } } diff --git a/WordPress/Classes/Models/BlogSettings.swift b/WordPress/Classes/Models/BlogSettings.swift index f320d1243c8b..c6c548b8e222 100644 --- a/WordPress/Classes/Models/BlogSettings.swift +++ b/WordPress/Classes/Models/BlogSettings.swift @@ -81,9 +81,9 @@ open class BlogSettings: NSManagedObject { /// @NSManaged var commentsAllowed: Bool - /// Contains a list of words, space separated, that would cause a comment to be automatically blacklisted. + /// Contains a list of words, space separated, that would cause a comment to be automatically blocklisted. /// - @NSManaged var commentsBlacklistKeys: Set<String>? + @NSManaged var commentsBlocklistKeys: Set<String>? /// If true, comments will be automatically closed after the number of days, specified by `commentsCloseAutomaticallyAfterDays`. /// @@ -94,9 +94,9 @@ open class BlogSettings: NSManagedObject { /// @NSManaged var commentsCloseAutomaticallyAfterDays: NSNumber? - /// When enabled, comments from known users will be whitelisted. + /// When enabled, comments from known users will be allowlisted. /// - @NSManaged var commentsFromKnownUsersWhitelisted: Bool + @NSManaged var commentsFromKnownUsersAllowlisted: Bool /// Indicates the maximum number of links allowed per comment. When a new comment exceeds this number, /// it'll be held in queue for moderation. @@ -231,7 +231,7 @@ open class BlogSettings: NSManagedObject { /// List of IP addresses that will never be blocked for logins by Jetpack /// - @NSManaged var jetpackLoginWhiteListedIPAddresses: Set<String>? + @NSManaged var jetpackLoginAllowListedIPAddresses: Set<String>? /// Indicates whether WordPress.com SSO is enabled for the Jetpack site /// diff --git a/WordPress/Classes/Models/BloggingPrompt+CoreDataClass.swift b/WordPress/Classes/Models/BloggingPrompt+CoreDataClass.swift new file mode 100644 index 000000000000..7b9e0ccd615c --- /dev/null +++ b/WordPress/Classes/Models/BloggingPrompt+CoreDataClass.swift @@ -0,0 +1,91 @@ +import Foundation +import CoreData +import WordPressKit + +public class BloggingPrompt: NSManagedObject { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<BloggingPrompt> { + return NSFetchRequest<BloggingPrompt>(entityName: Self.classNameWithoutNamespaces()) + } + + @nonobjc public class func newObject(in context: NSManagedObjectContext) -> BloggingPrompt? { + return NSEntityDescription.insertNewObject(forEntityName: Self.classNameWithoutNamespaces(), into: context) as? BloggingPrompt + } + + public override func awakeFromInsert() { + self.date = .init(timeIntervalSince1970: 0) + self.displayAvatarURLs = [] + } + + var promptAttribution: BloggingPromptsAttribution? { + BloggingPromptsAttribution(rawValue: attribution.lowercased()) + } + + /// Convenience method to map properties from `RemoteBloggingPrompt`. + /// + /// - Parameters: + /// - remotePrompt: The remote prompt model to convert + /// - siteID: The ID of the site that the prompt is intended for + func configure(with remotePrompt: RemoteBloggingPrompt, for siteID: Int32) { + self.promptID = Int32(remotePrompt.promptID) + self.siteID = siteID + self.text = remotePrompt.text + self.title = remotePrompt.title + self.content = remotePrompt.content + self.attribution = remotePrompt.attribution + self.date = remotePrompt.date + self.answered = remotePrompt.answered + self.answerCount = Int32(remotePrompt.answeredUsersCount) + self.displayAvatarURLs = remotePrompt.answeredUserAvatarURLs + } + + func textForDisplay() -> String { + return text.stringByDecodingXMLCharacters().trim() + } + + /// Convenience method that checks if the given date is within the same day of the prompt's date without considering the timezone information. + /// + /// Example: `2022-05-19 23:00:00 UTC-5` and `2022-05-20 00:00:00 UTC` are both dates within the same day (when the UTC date is converted to UTC-5), + /// but this method will return `false`. + /// + /// - Parameters: + /// - localDate: The date to compare against in local timezone. + /// - Returns: True if the year, month, and day components of the `localDate` matches the prompt's localized date. + func inSameDay(as dateToCompare: Date) -> Bool { + return DateFormatters.utc.string(from: date) == DateFormatters.local.string(from: dateToCompare) + } +} + +// MARK: - Notification Payload + +extension BloggingPrompt { + + struct NotificationKeys { + static let promptID = "prompt_id" + static let siteID = "site_id" + } + +} + +// MARK: - Private Helpers + +private extension BloggingPrompt { + + struct DateFormatters { + static let local: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .init(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + static let utc: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .init(identifier: "en_US_POSIX") + formatter.timeZone = .init(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + } + +} diff --git a/WordPress/Classes/Models/BloggingPrompt+CoreDataProperties.swift b/WordPress/Classes/Models/BloggingPrompt+CoreDataProperties.swift new file mode 100644 index 000000000000..a40e60d39968 --- /dev/null +++ b/WordPress/Classes/Models/BloggingPrompt+CoreDataProperties.swift @@ -0,0 +1,34 @@ +import Foundation +import CoreData + +extension BloggingPrompt { + /// The unique ID for the prompt, received from the server. + @NSManaged public var promptID: Int32 + + /// The site ID for the prompt. + @NSManaged public var siteID: Int32 + + /// The prompt content to be displayed at entry points. + @NSManaged public var text: String + + /// Template title for the draft post. + @NSManaged public var title: String + + /// Template content for the draft post. + @NSManaged public var content: String + + /// The attribution source for the prompt. + @NSManaged public var attribution: String + + /// The prompt date. Time information should be ignored. + @NSManaged public var date: Date + + /// Whether the current user has answered the prompt in `siteID`. + @NSManaged public var answered: Bool + + /// The number of users that has answered the prompt. + @NSManaged public var answerCount: Int32 + + /// Contains avatar URLs of some users that have answered the prompt. + @NSManaged public var displayAvatarURLs: [URL] +} diff --git a/WordPress/Classes/Models/BloggingPromptSettings+CoreDataClass.swift b/WordPress/Classes/Models/BloggingPromptSettings+CoreDataClass.swift new file mode 100644 index 000000000000..a92a16fde33e --- /dev/null +++ b/WordPress/Classes/Models/BloggingPromptSettings+CoreDataClass.swift @@ -0,0 +1,52 @@ +import Foundation +import CoreData +import WordPressKit + +public class BloggingPromptSettings: NSManagedObject { + + func configure(with remoteSettings: RemoteBloggingPromptsSettings, siteID: Int32, context: NSManagedObjectContext) { + self.siteID = siteID + self.promptCardEnabled = remoteSettings.promptCardEnabled + self.reminderTime = remoteSettings.reminderTime + self.promptRemindersEnabled = remoteSettings.promptRemindersEnabled + self.isPotentialBloggingSite = remoteSettings.isPotentialBloggingSite + updatePromptSettingsIfNecessary(siteID: Int(siteID), enabled: isPotentialBloggingSite) + self.reminderDays = reminderDays ?? BloggingPromptSettingsReminderDays(context: context) + reminderDays?.configure(with: remoteSettings.reminderDays) + } + + func reminderTimeDate() -> Date? { + guard let reminderTime = reminderTime else { + return nil + } + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH.mm" + return dateFormatter.date(from: reminderTime) + } + + private func updatePromptSettingsIfNecessary(siteID: Int, enabled: Bool) { + let service = BlogDashboardPersonalizationService(siteID: siteID) + if !service.hasPreference(for: .prompts) { + service.setEnabled(enabled, for: .prompts) + } + } + +} + +extension RemoteBloggingPromptsSettings { + + init(with model: BloggingPromptSettings) { + self.init(promptCardEnabled: model.promptCardEnabled, + promptRemindersEnabled: model.promptRemindersEnabled, + reminderDays: ReminderDays(monday: model.reminderDays?.monday ?? false, + tuesday: model.reminderDays?.tuesday ?? false, + wednesday: model.reminderDays?.wednesday ?? false, + thursday: model.reminderDays?.thursday ?? false, + friday: model.reminderDays?.friday ?? false, + saturday: model.reminderDays?.saturday ?? false, + sunday: model.reminderDays?.sunday ?? false), + reminderTime: model.reminderTime ?? String(), + isPotentialBloggingSite: model.isPotentialBloggingSite) + } + +} diff --git a/WordPress/Classes/Models/BloggingPromptSettings+CoreDataProperties.swift b/WordPress/Classes/Models/BloggingPromptSettings+CoreDataProperties.swift new file mode 100644 index 000000000000..12ff178acd6d --- /dev/null +++ b/WordPress/Classes/Models/BloggingPromptSettings+CoreDataProperties.swift @@ -0,0 +1,17 @@ +import Foundation +import CoreData + +extension BloggingPromptSettings { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<BloggingPromptSettings> { + return NSFetchRequest<BloggingPromptSettings>(entityName: "BloggingPromptSettings") + } + + @NSManaged public var isPotentialBloggingSite: Bool + @NSManaged public var promptCardEnabled: Bool + @NSManaged public var promptRemindersEnabled: Bool + @NSManaged public var reminderTime: String? + @NSManaged public var siteID: Int32 + @NSManaged public var reminderDays: BloggingPromptSettingsReminderDays? + +} diff --git a/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataClass.swift b/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataClass.swift new file mode 100644 index 000000000000..bb883f1798f0 --- /dev/null +++ b/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataClass.swift @@ -0,0 +1,34 @@ +import Foundation +import CoreData +import WordPressKit + +public class BloggingPromptSettingsReminderDays: NSManagedObject { + + func configure(with remoteReminderDays: RemoteBloggingPromptsSettings.ReminderDays) { + self.monday = remoteReminderDays.monday + self.tuesday = remoteReminderDays.tuesday + self.wednesday = remoteReminderDays.wednesday + self.thursday = remoteReminderDays.thursday + self.friday = remoteReminderDays.friday + self.saturday = remoteReminderDays.saturday + self.sunday = remoteReminderDays.sunday + } + + func getActiveWeekdays() -> [BloggingRemindersScheduler.Weekday] { + return [ + sunday, + monday, + tuesday, + wednesday, + thursday, + friday, + saturday + ].enumerated().compactMap { (index: Int, isReminderActive: Bool) in + guard isReminderActive else { + return nil + } + return BloggingRemindersScheduler.Weekday(rawValue: index) + } + } + +} diff --git a/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataProperties.swift b/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataProperties.swift new file mode 100644 index 000000000000..f2ef0e74dd87 --- /dev/null +++ b/WordPress/Classes/Models/BloggingPromptSettingsReminderDays+CoreDataProperties.swift @@ -0,0 +1,19 @@ +import Foundation +import CoreData + +extension BloggingPromptSettingsReminderDays { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<BloggingPromptSettingsReminderDays> { + return NSFetchRequest<BloggingPromptSettingsReminderDays>(entityName: "BloggingPromptSettingsReminderDays") + } + + @NSManaged public var monday: Bool + @NSManaged public var tuesday: Bool + @NSManaged public var wednesday: Bool + @NSManaged public var thursday: Bool + @NSManaged public var friday: Bool + @NSManaged public var saturday: Bool + @NSManaged public var sunday: Bool + @NSManaged public var settings: BloggingPromptSettings? + +} diff --git a/WordPress/Classes/Models/Comment+CoreDataClass.swift b/WordPress/Classes/Models/Comment+CoreDataClass.swift new file mode 100644 index 000000000000..6af39dba371a --- /dev/null +++ b/WordPress/Classes/Models/Comment+CoreDataClass.swift @@ -0,0 +1,237 @@ +import Foundation +import CoreData + +@objc(Comment) +public class Comment: NSManagedObject { + + @objc static func descriptionFor(_ commentStatus: CommentStatusType) -> String { + return commentStatus.description + } + + @objc func authorUrlForDisplay() -> String { + return authorURL()?.host ?? String() + } + + @objc func contentForEdit() -> String { + return availableContent() + } + + @objc func isApproved() -> Bool { + return status.isEqual(to: CommentStatusType.approved.description) + } + + @objc func isReadOnly() -> Bool { + guard let blog = blog else { + return true + } + + // If the current user cannot moderate the comment, they can only Like and Reply if the comment is Approved. + return (blog.isHostedAtWPcom || blog.isAtomic()) && !canModerate && !isApproved() + } + + // This can be removed when `unifiedCommentsAndNotificationsList` is permanently enabled + // as it's replaced by Comment+Interface:relativeDateSectionIdentifier. + @objc func sectionIdentifier() -> String? { + guard let dateCreated = dateCreated else { + return nil + } + + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .none + return formatter.string(from: dateCreated) + } + + @objc func commentURL() -> URL? { + guard !link.isEmpty else { + return nil + } + + return URL(string: link) + } + + @objc func deleteWillBePermanent() -> Bool { + return status.isEqual(to: Comment.descriptionFor(.spam)) || status.isEqual(to: Comment.descriptionFor(.unapproved)) + } + + func numberOfLikes() -> Int { + return Int(likeCount) + } + + func hasAuthorUrl() -> Bool { + return !author_url.isEmpty + } + + func canEditAuthorData() -> Bool { + // If the authorID is zero, the user is unregistered. Therefore, the data can be edited. + return authorID == 0 + } + + func hasParentComment() -> Bool { + return parentID > 0 + } + + + /// 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. + @objc 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 + } + + func isFromPostAuthor() -> Bool { + guard let postAuthorID = post?.authorID?.int32Value, + postAuthorID > 0, + authorID > 0 else { + return false + } + + return authorID == postAuthorID + } +} + +private extension Comment { + + func decodedContent() -> String { + // rawContent/content contains markup for Gutenberg comments. Remove it so it's not displayed. + return availableContent().stringByDecodingXMLCharacters().trim().strippingHTML().normalizingWhitespace() ?? String() + } + + func authorName() -> String { + return !author.isEmpty ? author : NSLocalizedString("Anonymous", comment: "the comment has an anonymous author.") + } + + // The REST endpoint response contains both content and rawContent. + // The XMLRPC endpoint response contains only content. + // So for Comment display and Comment editing, use which content the Comment has. + // The result is WP sites will use rawContent, self-hosted will use content. + func availableContent() -> String { + if !rawContent.isEmpty { + return rawContent + } + + if !content.isEmpty { + return content + } + + return String() + } + +} + +extension Comment: PostContentProvider { + + public func titleForDisplay() -> String { + let title = post?.postTitle ?? postTitle + return !title.isEmpty ? title.stringByDecodingXMLCharacters() : NSLocalizedString("(No Title)", comment: "Empty Post Title") + } + + @objc public func authorForDisplay() -> String { + let displayAuthor = authorName().stringByDecodingXMLCharacters().trim() + return !displayAuthor.isEmpty ? displayAuthor : gravatarEmailForDisplay() + } + + // Used in Comment details (non-threaded) + public func contentForDisplay() -> String { + return decodedContent() + } + + // Used in Comments list (non-threaded) + public func contentPreviewForDisplay() -> String { + return decodedContent() + } + + public func avatarURLForDisplay() -> URL? { + return !authorAvatarURL.isEmpty ? URL(string: authorAvatarURL) : nil + } + + public func gravatarEmailForDisplay() -> String { + let displayEmail = author_email.trim() + return !displayEmail.isEmpty ? displayEmail : String() + } + + public func dateForDisplay() -> Date? { + return dateCreated + } + + @objc public func authorURL() -> URL? { + return !author_url.isEmpty ? URL(string: author_url) : nil + } + +} + +// When CommentViewController and CommentService are converted to Swift, this can be simplified to a String enum. +@objc enum CommentStatusType: Int { + case pending + case approved + case unapproved + case spam + // Draft status is for comments that have not yet been successfully published/uploaded. + // We can use this status to restore comment replies that the user has written. + case draft + + var description: String { + switch self { + case .pending: + return "hold" + case .approved: + return "approve" + case .unapproved: + return "trash" + case .spam: + return "spam" + case .draft: + return "draft" + } + } + + static func typeForStatus(_ status: String?) -> CommentStatusType? { + switch status { + case "hold": + return .pending + case "approve": + return .approved + case "trash": + return .unapproved + case "spam": + return .spam + case "draft": + return .draft + default: + return nil + } + } +} diff --git a/WordPress/Classes/Models/Comment+CoreDataProperties.swift b/WordPress/Classes/Models/Comment+CoreDataProperties.swift new file mode 100644 index 000000000000..b05378ebc971 --- /dev/null +++ b/WordPress/Classes/Models/Comment+CoreDataProperties.swift @@ -0,0 +1,45 @@ +extension Comment { + @NSManaged public var commentID: Int32 + @NSManaged public var postID: Int32 + @NSManaged public var likeCount: Int16 + @NSManaged public var dateCreated: Date? + @NSManaged public var isLiked: Bool + @NSManaged public var canModerate: Bool + @NSManaged public var content: String + @NSManaged public var rawContent: String + @NSManaged public var postTitle: String + @NSManaged public var link: String + @NSManaged public var status: String + @NSManaged public var type: String + @NSManaged public var authorID: Int32 + @NSManaged public var author: String + @NSManaged public var author_email: String + @NSManaged public var author_url: String + @NSManaged public var authorAvatarURL: String + @NSManaged public var author_ip: String + + // Relationships + @NSManaged public var blog: Blog? + @NSManaged public var post: BasePost? + + // Hierarchical properties + @NSManaged public var parentID: Int32 + @NSManaged public var depth: Int16 + @NSManaged public var hierarchy: String + @NSManaged public var replyID: Int32 + + /// Determines if the comment should be displayed in the Reader comment thread. + /// + /// Note that this property is only updated and guaranteed to be correct within the comment thread. + /// The value may be outdated when accessed from other places (e.g. My Sites, Notifications). + @NSManaged public var visibleOnReader: Bool + + /* + // Hierarchy is a string representation of a comments ancestors. Each ancestor's + // is denoted by a ten character zero padded representation of its ID + // (e.g. "0000000001"). Ancestors are separated by a period. + // This allows hierarchical comments to be retrieved from core data by sorting + // on hierarchy, and allows for new comments to be inserted without needing to + // reorder the list. + */ +} diff --git a/WordPress/Classes/Models/Comment.h b/WordPress/Classes/Models/Comment.h deleted file mode 100644 index d43c336d6186..000000000000 --- a/WordPress/Classes/Models/Comment.h +++ /dev/null @@ -1,57 +0,0 @@ -#import <Foundation/Foundation.h> -#import <CoreData/CoreData.h> -#import "WPCommentContentViewProvider.h" - -@class Blog; -@class BasePost; - -// This is the notification name used with NSNotificationCenter -extern NSString * const CommentUploadFailedNotification; - -extern NSString * const CommentStatusPending; -extern NSString * const CommentStatusApproved; -extern NSString * const CommentStatusUnapproved; -extern NSString * const CommentStatusSpam; -// Draft status is for comments that have not yet been successfully published -// we can use this status to restore comment replies that the user has written -extern NSString * const CommentStatusDraft; - -@interface Comment : NSManagedObject<WPCommentContentViewProvider> - -@property (nonatomic, strong) Blog *blog; -@property (nonatomic, strong) BasePost *post; -@property (nonatomic, strong) NSString *author; -@property (nonatomic, strong) NSString *author_email; -@property (nonatomic, strong) NSString *author_ip; -@property (nonatomic, strong) NSString *author_url; -@property (nonatomic, strong) NSString *authorAvatarURL; -@property (nonatomic, strong) NSNumber *commentID; -@property (nonatomic, strong) NSString *content; -@property (nonatomic, strong) NSDate *dateCreated; -@property (nonatomic, strong) NSNumber *depth; -// Hierarchy is a string representation of a comments ancestors. Each ancestor's -// is denoted by a ten character zero padded representation of its ID -// (e.g. "0000000001"). Ancestors are separated by a period. -// This allows hierarchical comments to be retrieved from core data by sorting -// on hierarchy, and allows for new comments to be inserted without needing to -// reorder the list. -@property (nonatomic, strong) NSString *hierarchy; -@property (nonatomic, strong) NSString *link; -@property (nonatomic, strong) NSNumber *parentID; -@property (nonatomic, strong) NSNumber *postID; -@property (nonatomic, strong) NSString *postTitle; -@property (nonatomic, strong) NSString *status; -@property (nonatomic, strong) NSString *type; -@property (nonatomic, strong) NSNumber *likeCount; -@property (nonatomic, strong) NSAttributedString *attributedContent; -@property (nonatomic) BOOL isLiked; -@property (nonatomic, assign) BOOL isNew; - -/// Helper methods -/// -+ (NSString *)titleForStatus:(NSString *)status; -- (NSString *)authorUrlForDisplay; -- (BOOL)hasAuthorUrl; -- (BOOL)isApproved; - -@end diff --git a/WordPress/Classes/Models/Comment.m b/WordPress/Classes/Models/Comment.m deleted file mode 100644 index 446a9d3a0856..000000000000 --- a/WordPress/Classes/Models/Comment.m +++ /dev/null @@ -1,200 +0,0 @@ -#import "Comment.h" -#import "ContextManager.h" -#import "Blog.h" -#import "BasePost.h" -#import <WordPressShared/NSString+XMLExtensions.h> -#import "WordPress-Swift.h" - -NSString * const CommentUploadFailedNotification = @"CommentUploadFailed"; - -NSString * const CommentStatusPending = @"hold"; -NSString * const CommentStatusApproved = @"approve"; -NSString * const CommentStatusUnapproved = @"trash"; -NSString * const CommentStatusSpam = @"spam"; - -// draft is used for comments that have been composed but not succesfully uploaded yet -NSString * const CommentStatusDraft = @"draft"; - -@implementation Comment - -@dynamic blog; -@dynamic post; -@dynamic author; -@dynamic author_email; -@dynamic author_ip; -@dynamic author_url; -@dynamic authorAvatarURL; -@dynamic commentID; -@dynamic content; -@dynamic dateCreated; -@dynamic depth; -@dynamic hierarchy; -@dynamic link; -@dynamic parentID; -@dynamic postID; -@dynamic postTitle; -@dynamic status; -@dynamic type; -@dynamic isLiked; -@dynamic likeCount; -@synthesize isNew; -@synthesize attributedContent; - -#pragma mark - Helper methods - -+ (NSString *)titleForStatus:(NSString *)status -{ - if ([status isEqualToString:CommentStatusPending]) { - return NSLocalizedString(@"Pending moderation", @""); - } else if ([status isEqualToString:CommentStatusApproved]) { - return NSLocalizedString(@"Comments", @""); - } - - return status; -} - -- (NSString *)postTitle -{ - NSString *title = nil; - if (self.post) { - title = self.post.postTitle; - } else { - [self willAccessValueForKey:@"postTitle"]; - title = [self primitiveValueForKey:@"postTitle"]; - [self didAccessValueForKey:@"postTitle"]; - } - - if (title == nil || [@"" isEqualToString:title]) { - title = NSLocalizedString(@"(no title)", @"the post has no title."); - } - return title; - -} - -- (NSString *)author -{ - NSString *authorName = nil; - - [self willAccessValueForKey:@"author"]; - authorName = [self primitiveValueForKey:@"author"]; - [self didAccessValueForKey:@"author"]; - - if (authorName == nil || [@"" isEqualToString:authorName]) { - authorName = NSLocalizedString(@"Anonymous", @"the comment has an anonymous author."); - } - return authorName; - -} - -- (NSDate *)dateCreated -{ - NSDate *date = nil; - - [self willAccessValueForKey:@"dateCreated"]; - date = [self primitiveValueForKey:@"dateCreated"]; - [self didAccessValueForKey:@"dateCreated"]; - - return date; -} - - -#pragma mark - PostContentProvider protocol - -- (BOOL)isPrivateContent -{ - if ([self.post respondsToSelector:@selector(isPrivate)]) { - return (BOOL)[self.post performSelector:@selector(isPrivate)]; - } - return NO; -} - -- (NSString *)titleForDisplay -{ - return [self.postTitle stringByDecodingXMLCharacters]; -} - -- (NSString *)authorForDisplay -{ - return [[self.author trim] length] > 0 ? [[self.author stringByDecodingXMLCharacters] trim] : [self.author_email trim]; -} - -- (NSString *)blogNameForDisplay -{ - return self.author_url; -} - -- (NSString *)statusForDisplay -{ - NSString *status = [[self class] titleForStatus:self.status]; - if ([status isEqualToString:NSLocalizedString(@"Comments", @"")]) { - status = nil; - } - return status; -} - -- (NSString *)authorUrlForDisplay -{ - return self.author_url.hostname; -} - -- (BOOL)hasAuthorUrl -{ - return self.author_url && ![self.author_url isEqualToString:@""]; -} - -- (BOOL)isApproved -{ - return [self.status isEqualToString:CommentStatusApproved]; -} - -- (NSString *)contentForDisplay -{ - //Strip HTML from the comment content - NSString *commentContent = [self.content stringByDecodingXMLCharacters]; - commentContent = [commentContent trim]; - commentContent = [commentContent stringByStrippingHTML]; - commentContent = [commentContent stringByNormalizingWhitespace]; - - return commentContent; -} - -- (NSString *)contentPreviewForDisplay -{ - return [[[self.content stringByDecodingXMLCharacters] stringByStrippingHTML] stringByNormalizingWhitespace]; -} - -- (NSURL *)avatarURLForDisplay -{ - return [NSURL URLWithString:self.authorAvatarURL]; -} - -- (NSString *)gravatarEmailForDisplay -{ - return [self.author_email trim]; -} - -- (NSDate *)dateForDisplay -{ - return self.dateCreated; -} - -- (NSURL *)authorURL -{ - if (self.author_url) { - return [NSURL URLWithString:self.author_url]; - } - - return nil; -} - -- (BOOL)authorIsPostAuthor -{ - return [[self authorURL] isEqual:[self.post authorURL]]; -} - -- (NSNumber *)numberOfLikes -{ - return self.likeCount ?: @(0); -} - -@end diff --git a/WordPress/Classes/Models/Coordinate.h b/WordPress/Classes/Models/Coordinate.h index c92c30adb95d..ddf2a2a829f5 100644 --- a/WordPress/Classes/Models/Coordinate.h +++ b/WordPress/Classes/Models/Coordinate.h @@ -1,7 +1,7 @@ #import <Foundation/Foundation.h> #import <CoreLocation/CoreLocation.h> -@interface Coordinate : NSObject <NSCoding> { +@interface Coordinate : NSObject <NSSecureCoding> { CLLocationCoordinate2D _coordinate; } diff --git a/WordPress/Classes/Models/Coordinate.m b/WordPress/Classes/Models/Coordinate.m index 9fe6ba583fef..fa42e4086993 100644 --- a/WordPress/Classes/Models/Coordinate.m +++ b/WordPress/Classes/Models/Coordinate.m @@ -22,19 +22,24 @@ - (CLLocationDegrees)longitude } #pragma mark - -#pragma mark NSCoding +#pragma mark NSSecureCoding + ++ (BOOL)supportsSecureCoding +{ + return YES; +} - (void)encodeWithCoder:(NSCoder *)encoder { - [encoder encodeDouble:_coordinate.latitude forKey:@"latitude"]; - [encoder encodeDouble:_coordinate.longitude forKey:@"longitude"]; + [encoder encodeObject:@(_coordinate.latitude) forKey:@"latitude"]; + [encoder encodeObject:@(_coordinate.longitude) forKey:@"longitude"]; } - (id)initWithCoder:(NSCoder *)decoder { if (self = [super init]) { - _coordinate.latitude = [decoder decodeDoubleForKey:@"latitude"]; - _coordinate.longitude = [decoder decodeDoubleForKey:@"longitude"]; + _coordinate.latitude = [[decoder decodeObjectOfClass:[NSNumber class] forKey:@"latitude"] doubleValue]; + _coordinate.longitude = [[decoder decodeObjectOfClass:[NSNumber class] forKey:@"longitude"] doubleValue]; } return self; diff --git a/WordPress/Classes/Models/Domain.swift b/WordPress/Classes/Models/Domain.swift index cad8302a20fb..815ee3eede53 100644 --- a/WordPress/Classes/Models/Domain.swift +++ b/WordPress/Classes/Models/Domain.swift @@ -8,7 +8,12 @@ extension Domain { init(managedDomain: ManagedDomain) { self.init(domainName: managedDomain.domainName, isPrimaryDomain: managedDomain.isPrimary, - domainType: managedDomain.domainType) + domainType: managedDomain.domainType, + autoRenewing: managedDomain.autoRenewing, + autoRenewalDate: managedDomain.autoRenewalDate, + expirySoon: managedDomain.expirySoon, + expired: managedDomain.expired, + expiryDate: managedDomain.expiryDate) } } @@ -24,6 +29,11 @@ class ManagedDomain: NSManagedObject { static let domainName = "domainName" static let isPrimary = "isPrimary" static let domainType = "domainType" + static let autoRenewing = "autoRenewing" + static let autoRenewalDate = "autoRenewalDate" + static let expirySoon = "expirySoon" + static let expired = "expired" + static let expiryDate = "expiryDate" } struct Relationships { @@ -34,12 +44,23 @@ class ManagedDomain: NSManagedObject { @NSManaged var isPrimary: Bool @NSManaged var domainType: DomainType @NSManaged var blog: Blog + @NSManaged var autoRenewing: Bool + @NSManaged var autoRenewalDate: String + @NSManaged var expirySoon: Bool + @NSManaged var expired: Bool + @NSManaged var expiryDate: String func updateWith(_ domain: Domain, blog: Blog) { self.domainName = domain.domainName self.isPrimary = domain.isPrimaryDomain self.domainType = domain.domainType self.blog = blog + + self.autoRenewing = domain.autoRenewing + self.autoRenewalDate = domain.autoRenewalDate + self.expirySoon = domain.expirySoon + self.expired = domain.expired + self.expiryDate = domain.expiryDate } } @@ -48,5 +69,10 @@ extension Domain: Equatable {} public func ==(lhs: Domain, rhs: Domain) -> Bool { return lhs.domainName == rhs.domainName && lhs.domainType == rhs.domainType && - lhs.isPrimaryDomain == rhs.isPrimaryDomain + lhs.isPrimaryDomain == rhs.isPrimaryDomain && + lhs.autoRenewing == rhs.autoRenewing && + lhs.autoRenewalDate == rhs.autoRenewalDate && + lhs.expirySoon == rhs.expirySoon && + lhs.expired == rhs.expired && + lhs.expiryDate == rhs.expiryDate } diff --git a/WordPress/Classes/Models/InviteLinks+CoreDataClass.swift b/WordPress/Classes/Models/InviteLinks+CoreDataClass.swift new file mode 100644 index 000000000000..8b5e52199615 --- /dev/null +++ b/WordPress/Classes/Models/InviteLinks+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(InviteLinks) +public class InviteLinks: NSManagedObject { + +} diff --git a/WordPress/Classes/Models/InviteLinks+CoreDataProperties.swift b/WordPress/Classes/Models/InviteLinks+CoreDataProperties.swift new file mode 100644 index 000000000000..5868f4feab82 --- /dev/null +++ b/WordPress/Classes/Models/InviteLinks+CoreDataProperties.swift @@ -0,0 +1,20 @@ +import Foundation +import CoreData + + +extension InviteLinks { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<InviteLinks> { + return NSFetchRequest<InviteLinks>(entityName: "InviteLinks") + } + + @NSManaged public var inviteKey: String! + @NSManaged public var role: String! + @NSManaged public var isPending: Bool + @NSManaged public var inviteDate: Date! + @NSManaged public var groupInvite: Bool + @NSManaged public var expiry: Int64 + @NSManaged public var link: String! + @NSManaged public var blog: Blog! + +} diff --git a/WordPress/Classes/Models/JetpackSiteRef.swift b/WordPress/Classes/Models/JetpackSiteRef.swift index 3570ac2187a4..a33c101f22ce 100644 --- a/WordPress/Classes/Models/JetpackSiteRef.swift +++ b/WordPress/Classes/Models/JetpackSiteRef.swift @@ -13,14 +13,57 @@ struct JetpackSiteRef: Hashable, Codable { let siteID: Int /// The WordPress.com username. let username: String + /// The homeURL string for a site. + let homeURL: String + + private var hasBackup = false + private var hasPaidPlan = false + + // Self Hosted Non Jetpack Support + // Ideally this would be a different "ref" object but the JetpackSiteRef + // is so coupled into the plugin management that the amount of changes and work needed to change + // would be very large. This is a workaround for that. + let isSelfHostedWithoutJetpack: Bool + + /// The XMLRPC path for the site, only applies to self hosted sites with no Jetpack connected + var xmlRPC: String? = nil init?(blog: Blog) { - guard let username = blog.account?.username, - let siteID = blog.dotComID as? Int else { + + // Init for self hosted and no Jetpack + if blog.account == nil, !blog.isHostedAtWPcom { + guard + let username = blog.username, + let homeURL = blog.homeURL as String?, + let xmlRPC = blog.xmlrpc + else { return nil + } + + self.isSelfHostedWithoutJetpack = true + self.username = username + self.siteID = Constants.selfHostedSiteID + self.homeURL = homeURL + self.xmlRPC = xmlRPC + } + + // Init for normal Jetpack connected sites + else { + guard + let username = blog.account?.username, + let siteID = blog.dotComID as? Int, + let homeURL = blog.homeURL as String? + else { + return nil + } + + self.isSelfHostedWithoutJetpack = false + self.siteID = siteID + self.username = username + self.homeURL = homeURL + self.hasBackup = blog.isBackupsAllowed() + self.hasPaidPlan = blog.hasPaidPlan } - self.siteID = siteID - self.username = username } public func hash(into hasher: inout Hasher) { @@ -30,5 +73,16 @@ struct JetpackSiteRef: Hashable, Codable { static func ==(lhs: JetpackSiteRef, rhs: JetpackSiteRef) -> Bool { return lhs.siteID == rhs.siteID && lhs.username == rhs.username + && lhs.homeURL == rhs.homeURL + && lhs.hasBackup == rhs.hasBackup + && lhs.hasPaidPlan == rhs.hasPaidPlan + } + + func shouldShowActivityLogFilter() -> Bool { + hasBackup || hasPaidPlan + } + + struct Constants { + static let selfHostedSiteID = -1 } } diff --git a/WordPress/Classes/Models/JetpackState.swift b/WordPress/Classes/Models/JetpackState.swift index bd4d377a0074..96b0c1fe0ea6 100644 --- a/WordPress/Classes/Models/JetpackState.swift +++ b/WordPress/Classes/Models/JetpackState.swift @@ -25,6 +25,13 @@ return true } + /// Return true is Jetpack has site-connection (Jetpack plugin connected to the site but not connected to WP.com account) + var isSiteConnection: Bool { + let isUserConnected = connectedUsername != nil || connectedEmail != nil + + return isConnected && !isUserConnected + } + /// Returns YES if the detected version meets the app requirements. /// - SeeAlso: JetpackVersionMinimumRequired diff --git a/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataClass.swift b/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataClass.swift index 6fda524e757a..841df25b723a 100644 --- a/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataClass.swift +++ b/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataClass.swift @@ -10,6 +10,13 @@ public class LastPostStatsRecordValue: StatsRecordValue { return URL(string: url) } + public var featuredImageURL: URL? { + guard let url = featuredImageUrlString as String? else { + return nil + } + return URL(string: url) + } + public override func validateForInsert() throws { try super.validateForInsert() try recordValueSingleValueValidation() @@ -27,6 +34,7 @@ extension StatsLastPostInsight: StatsRecordValueConvertible { value.urlString = self.url.absoluteString value.viewsCount = Int64(self.viewsCount) value.postID = Int64(self.postID) + value.featuredImageUrlString = self.featuredImageURL?.absoluteString return [value] } @@ -47,7 +55,8 @@ extension StatsLastPostInsight: StatsRecordValueConvertible { likesCount: Int(insight.likesCount), commentsCount: Int(insight.commentsCount), viewsCount: Int(insight.viewsCount), - postID: Int(insight.postID)) + postID: Int(insight.postID), + featuredImageURL: insight.featuredImageURL) } static var recordType: StatsRecordType { diff --git a/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataProperties.swift b/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataProperties.swift index 4ab5dfddd611..7248acef20f3 100644 --- a/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataProperties.swift +++ b/WordPress/Classes/Models/LastPostStatsRecordValue+CoreDataProperties.swift @@ -15,5 +15,6 @@ extension LastPostStatsRecordValue { @NSManaged public var urlString: String? @NSManaged public var viewsCount: Int64 @NSManaged public var postID: Int64 + @NSManaged public var featuredImageUrlString: String? } diff --git a/WordPress/Classes/Models/ManagedAccountSettings+CoreDataProperties.swift b/WordPress/Classes/Models/ManagedAccountSettings+CoreDataProperties.swift index e39208568106..228db45f3a5a 100644 --- a/WordPress/Classes/Models/ManagedAccountSettings+CoreDataProperties.swift +++ b/WordPress/Classes/Models/ManagedAccountSettings+CoreDataProperties.swift @@ -18,6 +18,8 @@ extension ManagedAccountSettings { @NSManaged var webAddress: String @NSManaged var language: String @NSManaged var tracksOptOut: Bool + @NSManaged var blockEmailNotifications: Bool + @NSManaged var twoStepEnabled: Bool @NSManaged var account: WPAccount } diff --git a/WordPress/Classes/Models/ManagedAccountSettings.swift b/WordPress/Classes/Models/ManagedAccountSettings.swift index 4378d125a864..dbfb29fcd84a 100644 --- a/WordPress/Classes/Models/ManagedAccountSettings.swift +++ b/WordPress/Classes/Models/ManagedAccountSettings.swift @@ -27,6 +27,8 @@ class ManagedAccountSettings: NSManagedObject { webAddress = accountSettings.webAddress language = accountSettings.language tracksOptOut = accountSettings.tracksOptOut + blockEmailNotifications = accountSettings.blockEmailNotifications + twoStepEnabled = accountSettings.twoStepEnabled } /// Applies a change to the account settings @@ -107,7 +109,9 @@ extension AccountSettings { primarySiteID: managed.primarySiteID.intValue, webAddress: managed.webAddress, language: managed.language, - tracksOptOut: managed.tracksOptOut) + tracksOptOut: managed.tracksOptOut, + blockEmailNotifications: managed.blockEmailNotifications, + twoStepEnabled: managed.twoStepEnabled) } var emailForDisplay: String { diff --git a/WordPress/Classes/Models/ManagedPerson.swift b/WordPress/Classes/Models/ManagedPerson.swift index b176e0756259..498719586365 100644 --- a/WordPress/Classes/Models/ManagedPerson.swift +++ b/WordPress/Classes/Models/ManagedPerson.swift @@ -30,6 +30,8 @@ class ManagedPerson: NSManagedObject { return User(managedPerson: self) case PersonKind.viewer.rawValue: return Viewer(managedPerson: self) + case PersonKind.emailFollower.rawValue: + return EmailFollower(managedPerson: self) default: return Follower(managedPerson: self) } @@ -97,3 +99,18 @@ extension Viewer { isSuperAdmin: managedPerson.isSuperAdmin) } } + +extension EmailFollower { + init(managedPerson: ManagedPerson) { + self.init(ID: Int(managedPerson.userID), + username: managedPerson.username, + firstName: managedPerson.firstName, + lastName: managedPerson.lastName, + displayName: managedPerson.displayName, + role: RemoteRole.follower.slug, + siteID: Int(managedPerson.siteID), + linkedUserID: Int(managedPerson.linkedUserID), + avatarURL: managedPerson.avatarURL.flatMap { URL(string: $0) }, + isSuperAdmin: managedPerson.isSuperAdmin) + } +} diff --git a/WordPress/Classes/Models/Media.h b/WordPress/Classes/Models/Media.h index 371f3d8df56f..48bac74a6a61 100644 --- a/WordPress/Classes/Models/Media.h +++ b/WordPress/Classes/Models/Media.h @@ -42,6 +42,8 @@ typedef NS_ENUM(NSUInteger, MediaType) { @property (nonatomic, strong, nullable) NSNumber *remoteStatusNumber; @property (nonatomic, strong, nullable) NSString *remoteThumbnailURL; @property (nonatomic, strong, nullable) NSString *remoteURL; +@property (nonatomic, strong, nullable) NSString *remoteLargeURL; +@property (nonatomic, strong, nullable) NSString *remoteMediumURL; @property (nonatomic, strong, nullable) NSString *shortcode; @property (nonatomic, strong, nullable) NSString *title; @property (nonatomic, strong, nullable) NSString *videopressGUID; diff --git a/WordPress/Classes/Models/Media.m b/WordPress/Classes/Models/Media.m index 5729473fb5c2..c6bd5de908bd 100644 --- a/WordPress/Classes/Models/Media.m +++ b/WordPress/Classes/Models/Media.m @@ -1,5 +1,5 @@ #import "Media.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "WordPress-Swift.h" @implementation Media @@ -7,6 +7,8 @@ @implementation Media @dynamic alt; @dynamic mediaID; @dynamic remoteURL; +@dynamic remoteLargeURL; +@dynamic remoteMediumURL; @dynamic localURL; @dynamic shortcode; @dynamic width; @@ -261,4 +263,18 @@ - (BOOL)hasRemote { return self.mediaID.intValue != 0; } +- (void)setError:(NSError *)error +{ + if (error != nil) { + // Cherry pick keys that support secure coding. NSErrors thrown from the OS can + // contain types that don't adopt NSSecureCoding, leading to a Core Data exception and crash. + NSDictionary *userInfo = @{NSLocalizedDescriptionKey: error.localizedDescription}; + error = [NSError errorWithDomain:error.domain code:error.code userInfo:userInfo]; + } + + [self willChangeValueForKey:@"error"]; + [self setPrimitiveValue:error forKey:@"error"]; + [self didChangeValueForKey:@"error"]; +} + @end diff --git a/WordPress/Classes/Models/MenuItem.h b/WordPress/Classes/Models/MenuItem.h index 81edfbb5b098..0c0ab9943859 100644 --- a/WordPress/Classes/Models/MenuItem.h +++ b/WordPress/Classes/Models/MenuItem.h @@ -105,13 +105,21 @@ extern NSString * const MenuItemLinkTargetBlank; @param orderedItems an ordered set of items nested top-down as a parent followed by children. (as returned from the Menus API) @returns MenuItem the last child of self to occur in the ordered set. */ -- (MenuItem *)lastDescendantInOrderedItems:(NSOrderedSet *)orderedItems; +- (nullable MenuItem *)lastDescendantInOrderedItems:(NSOrderedSet *)orderedItems; /** The item's name is nil, empty, or the default string. */ - (BOOL)nameIsEmptyOrDefault; +/** + Search for a sibling that precedes self. + A sibling is a MenuItem that shares the same parent. + @param orderedItems an ordered set of items nested top-down as a parent followed by children. (as returned from the Menus API) + @returns MenuItem sibling that precedes self, or nil if there is not one. + */ +- (nullable MenuItem *)precedingSiblingInOrderedItems:(NSOrderedSet *)orderedItems; + @end @interface MenuItem (CoreDataGeneratedAccessors) diff --git a/WordPress/Classes/Models/MenuItem.m b/WordPress/Classes/Models/MenuItem.m index 9213aa5d141f..d90055a2e6ff 100644 --- a/WordPress/Classes/Models/MenuItem.m +++ b/WordPress/Classes/Models/MenuItem.m @@ -94,7 +94,7 @@ - (BOOL)isDescendantOfItem:(MenuItem *)item /** Traverse the orderedItems for parent items equal to self or that are a descendant of self (a child of a child). */ -- (MenuItem *)lastDescendantInOrderedItems:(NSOrderedSet *)orderedItems +- (nullable MenuItem *)lastDescendantInOrderedItems:(NSOrderedSet *)orderedItems { MenuItem *lastChildItem = nil; NSUInteger parentIndex = [orderedItems indexOfObject:self]; @@ -115,4 +115,18 @@ - (BOOL)nameIsEmptyOrDefault return self.name.length == 0 || [self.name isEqualToString:[MenuItem defaultItemNameLocalized]]; } +/** + Return a sibling that precedes self, or nil if one wasn't found. + */ +- (nullable MenuItem *)precedingSiblingInOrderedItems:(NSOrderedSet *)orderedItems +{ + for (NSUInteger idx = [orderedItems indexOfObject:self]; idx > 0; idx--) { + MenuItem *previousItem = [orderedItems objectAtIndex:idx - 1]; + if (previousItem.parent == self.parent) { + return previousItem; + } + } + return nil; +} + @end diff --git a/WordPress/Classes/Models/NewsItem.swift b/WordPress/Classes/Models/NewsItem.swift deleted file mode 100644 index 7ab97dcb562f..000000000000 --- a/WordPress/Classes/Models/NewsItem.swift +++ /dev/null @@ -1,41 +0,0 @@ -/// Encapsulates the content of the message to be presented in the "New" Card -struct NewsItem { - let title: String - let content: String - let extendedInfoURL: URL - let version: Decimal -} - -extension NewsItem { - private struct FileKeys { - static let title = "Title" - static let content = "Content" - static let URL = "URL" - static let version = "version" - } - - init?(fileContent: [String: String]) { - guard let title = fileContent[FileKeys.title], - let content = fileContent[FileKeys.content], - let urlString = fileContent[FileKeys.URL], - let url = URL(string: urlString), - let versionString = fileContent[FileKeys.version], - let version = Decimal(string: versionString) else { - return nil - } - - self.init(title: title, content: content, extendedInfoURL: url, version: version) - } -} - -extension NewsItem: CustomStringConvertible { - var description: String { - return "\(title): \(content)" - } -} - -extension NewsItem: CustomDebugStringConvertible { - var debugDescription: String { - return description - } -} diff --git a/WordPress/Classes/Models/Notifications/Actions/ApproveComment.swift b/WordPress/Classes/Models/Notifications/Actions/ApproveComment.swift index 7db152b5a2d7..ff5f41b9092c 100644 --- a/WordPress/Classes/Models/Notifications/Actions/ApproveComment.swift +++ b/WordPress/Classes/Models/Notifications/Actions/ApproveComment.swift @@ -1,8 +1,8 @@ -/// Encapsulates logic to approve a cooment +/// Encapsulates logic to approve a comment class ApproveComment: DefaultNotificationActionCommand { enum TitleStrings { - static let approve = NSLocalizedString("Approve", comment: "Approves a Comment") - static let unapprove = NSLocalizedString("Unapprove", comment: "Unapproves a Comment") + static let approve = NSLocalizedString("Approve Comment", comment: "Approves a Comment") + static let unapprove = NSLocalizedString("Unapprove Comment", comment: "Unapproves a Comment") } enum TitleHints { @@ -18,8 +18,11 @@ class ApproveComment: DefaultNotificationActionCommand { return on ? .neutral(.shade30) : .primary } - override func execute<ObjectType: FormattableCommentContent>(context: ActionContext<ObjectType>) { - let block = context.block + override func execute<ObjectType: FormattableContent>(context: ActionContext<ObjectType>) { + guard let block = context.block as? FormattableCommentContent else { + super.execute(context: context) + return + } if on { unApprove(block: block) } else { diff --git a/WordPress/Classes/Models/Notifications/Actions/EditComment.swift b/WordPress/Classes/Models/Notifications/Actions/EditComment.swift index cf332cc2911c..99069efa64d7 100644 --- a/WordPress/Classes/Models/Notifications/Actions/EditComment.swift +++ b/WordPress/Classes/Models/Notifications/Actions/EditComment.swift @@ -7,8 +7,11 @@ class EditComment: DefaultNotificationActionCommand { return EditComment.title } - override func execute<ContentType: FormattableCommentContent>(context: ActionContext<ContentType>) { - let block = context.block + override func execute<ContentType: FormattableContent>(context: ActionContext<ContentType>) { + guard let block = context.block as? FormattableCommentContent else { + super.execute(context: context) + return + } let content = context.content actionsService?.updateCommentWithBlock(block, content: content, completion: { success in guard success else { diff --git a/WordPress/Classes/Models/Notifications/Actions/Follow.swift b/WordPress/Classes/Models/Notifications/Actions/Follow.swift index 235d3471ebbe..7b2aea8c89d6 100644 --- a/WordPress/Classes/Models/Notifications/Actions/Follow.swift +++ b/WordPress/Classes/Models/Notifications/Actions/Follow.swift @@ -13,7 +13,10 @@ final class Follow: DefaultNotificationActionCommand { return on ? .neutral(.shade30) : .primary } - override func execute<ContentType: FormattableUserContent>(context: ActionContext<ContentType>) { - + override func execute<ContentType: FormattableContent>(context: ActionContext<ContentType>) { + guard let _ = context.block as? FormattableUserContent else { + super.execute(context: context) + return + } } } diff --git a/WordPress/Classes/Models/Notifications/Actions/LikeComment.swift b/WordPress/Classes/Models/Notifications/Actions/LikeComment.swift index e0e2d61fa218..9dafbd75e380 100644 --- a/WordPress/Classes/Models/Notifications/Actions/LikeComment.swift +++ b/WordPress/Classes/Models/Notifications/Actions/LikeComment.swift @@ -14,8 +14,11 @@ class LikeComment: DefaultNotificationActionCommand { return on ? TitleStrings.like : TitleStrings.unlike } - override func execute<ContentType: FormattableCommentContent>(context: ActionContext<ContentType>) { - let block = context.block + override func execute<ContentType: FormattableContent>(context: ActionContext<ContentType>) { + guard let block = context.block as? FormattableCommentContent else { + super.execute(context: context) + return + } if on { removeLike(block: block) } else { diff --git a/WordPress/Classes/Models/Notifications/Actions/LikePost.swift b/WordPress/Classes/Models/Notifications/Actions/LikePost.swift index a505d510c6dc..4500ec12c814 100644 --- a/WordPress/Classes/Models/Notifications/Actions/LikePost.swift +++ b/WordPress/Classes/Models/Notifications/Actions/LikePost.swift @@ -4,7 +4,10 @@ final class LikePost: DefaultNotificationActionCommand { return NSLocalizedString("Like", comment: "Like a post.") } - override func execute<ContentType: FormattableCommentContent>(context: ActionContext<ContentType>) { - + override func execute<ContentType: FormattableContent>(context: ActionContext<ContentType>) { + guard let _ = context.block as? FormattableCommentContent else { + super.execute(context: context) + return + } } } diff --git a/WordPress/Classes/Models/Notifications/Actions/MarkAsSpam.swift b/WordPress/Classes/Models/Notifications/Actions/MarkAsSpam.swift index c4bfe31d9637..0923df33f7e6 100644 --- a/WordPress/Classes/Models/Notifications/Actions/MarkAsSpam.swift +++ b/WordPress/Classes/Models/Notifications/Actions/MarkAsSpam.swift @@ -7,9 +7,13 @@ class MarkAsSpam: DefaultNotificationActionCommand { return MarkAsSpam.title } - override func execute<ContentType: FormattableCommentContent>(context: ActionContext<ContentType>) { + override func execute<ContentType: FormattableContent>(context: ActionContext<ContentType>) { + guard let block = context.block as? FormattableCommentContent else { + super.execute(context: context) + return + } let request = NotificationDeletionRequest(kind: .spamming, action: { [weak self] requestCompletion in - self?.actionsService?.spamCommentWithBlock(context.block) { (success) in + self?.actionsService?.spamCommentWithBlock(block) { (success) in requestCompletion(success) } }) diff --git a/WordPress/Classes/Models/Notifications/Actions/NotificationAction.swift b/WordPress/Classes/Models/Notifications/Actions/NotificationAction.swift index f0ea318a3234..223f16f69d08 100644 --- a/WordPress/Classes/Models/Notifications/Actions/NotificationAction.swift +++ b/WordPress/Classes/Models/Notifications/Actions/NotificationAction.swift @@ -19,7 +19,7 @@ class DefaultNotificationActionCommand: FormattableContentActionCommand { }() private(set) lazy var actionsService: NotificationActionsService? = { - return NotificationActionsService(managedObjectContext: mainContext!) + return NotificationActionsService(coreDataStack: ContextManager.shared) }() init(on: Bool) { diff --git a/WordPress/Classes/Models/Notifications/Actions/ReplyToComment.swift b/WordPress/Classes/Models/Notifications/Actions/ReplyToComment.swift index 331e6056c7e6..4a267596df05 100644 --- a/WordPress/Classes/Models/Notifications/Actions/ReplyToComment.swift +++ b/WordPress/Classes/Models/Notifications/Actions/ReplyToComment.swift @@ -2,13 +2,17 @@ class ReplyToComment: DefaultNotificationActionCommand { static let title = NSLocalizedString("Reply", comment: "Reply to a comment.") static let hint = NSLocalizedString("Replies to a comment.", comment: "VoiceOver accessibility hint, informing the user the button can be used to reply to a comment.") + static let identifier = "reply-button" override var actionTitle: String { return ReplyToComment.title } - override func execute<ContentType: FormattableCommentContent>(context: ActionContext<ContentType>) { - let block = context.block + override func execute<ContentType: FormattableContent>(context: ActionContext<ContentType>) { + guard let block = context.block as? FormattableCommentContent else { + super.execute(context: context) + return + } let content = context.content actionsService?.replyCommentWithBlock(block, content: content, completion: { success in guard success else { diff --git a/WordPress/Classes/Models/Notifications/Actions/TrashComment.swift b/WordPress/Classes/Models/Notifications/Actions/TrashComment.swift index a200d7481738..1c6963b66a8c 100644 --- a/WordPress/Classes/Models/Notifications/Actions/TrashComment.swift +++ b/WordPress/Classes/Models/Notifications/Actions/TrashComment.swift @@ -11,10 +11,14 @@ class TrashComment: DefaultNotificationActionCommand { return .error } - override func execute<ContentType: FormattableCommentContent>(context: ActionContext<ContentType>) { + override func execute<ContentType: FormattableContent>(context: ActionContext<ContentType>) { + guard let block = context.block as? FormattableCommentContent else { + super.execute(context: context) + return + } ReachabilityUtils.onAvailableInternetConnectionDo { let request = NotificationDeletionRequest(kind: .deletion, action: { [weak self] requestCompletion in - self?.actionsService?.deleteCommentWithBlock(context.block, completion: { success in + self?.actionsService?.deleteCommentWithBlock(block, completion: { success in requestCompletion(success) }) }) diff --git a/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataClass.swift b/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataClass.swift new file mode 100644 index 000000000000..702cb7bd202c --- /dev/null +++ b/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataClass.swift @@ -0,0 +1,6 @@ +import CoreData + +@objc(LikeUser) +public class LikeUser: NSManagedObject { + +} diff --git a/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataProperties.swift b/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataProperties.swift new file mode 100644 index 000000000000..2825a1434902 --- /dev/null +++ b/WordPress/Classes/Models/Notifications/Likes/LikeUser+CoreDataProperties.swift @@ -0,0 +1,22 @@ +import CoreData + +extension LikeUser { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<LikeUser> { + return NSFetchRequest<LikeUser>(entityName: "LikeUser") + } + + @NSManaged public var userID: Int64 + @NSManaged public var username: String + @NSManaged public var displayName: String + @NSManaged public var primaryBlogID: Int64 + @NSManaged public var avatarUrl: String + @NSManaged public var bio: String + @NSManaged public var dateLiked: Date + @NSManaged public var dateLikedString: String + @NSManaged public var likedSiteID: Int64 + @NSManaged public var likedPostID: Int64 + @NSManaged public var likedCommentID: Int64 + @NSManaged public var preferredBlog: LikeUserPreferredBlog? + @NSManaged public var dateFetched: Date +} diff --git a/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataClass.swift b/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataClass.swift new file mode 100644 index 000000000000..25f4aeb19d61 --- /dev/null +++ b/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataClass.swift @@ -0,0 +1,6 @@ +import CoreData + +@objc(LikeUserPreferredBlog) +public class LikeUserPreferredBlog: NSManagedObject { + +} diff --git a/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataProperties.swift b/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataProperties.swift new file mode 100644 index 000000000000..bf2f60879214 --- /dev/null +++ b/WordPress/Classes/Models/Notifications/Likes/LikeUserPreferredBlog+CoreDataProperties.swift @@ -0,0 +1,15 @@ +import CoreData + +extension LikeUserPreferredBlog { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<LikeUserPreferredBlog> { + return NSFetchRequest<LikeUserPreferredBlog>(entityName: "LikeUserPreferredBlog") + } + + @NSManaged public var blogUrl: String + @NSManaged public var blogName: String + @NSManaged public var iconUrl: String + @NSManaged public var blogID: Int64 + @NSManaged public var user: LikeUser + +} diff --git a/WordPress/Classes/Models/Notifications/Notification.swift b/WordPress/Classes/Models/Notifications/Notification.swift index 5be28f4c5d44..3b763d953f44 100644 --- a/WordPress/Classes/Models/Notifications/Notification.swift +++ b/WordPress/Classes/Models/Notifications/Notification.swift @@ -81,10 +81,32 @@ class Notification: NSManagedObject { /// fileprivate var cachedHeaderAndBodyContentGroups: [FormattableContentGroup]? + private var cachedAttributesObserver: NotificationCachedAttributesObserver? + /// Array that contains the Cached Property Names /// fileprivate static let cachedAttributes = Set(arrayLiteral: "body", "header", "subject", "timestamp") + override func awakeFromFetch() { + super.awakeFromFetch() + + if cachedAttributesObserver == nil { + let observer = NotificationCachedAttributesObserver() + for attr in Notification.cachedAttributes { + addObserver(observer, forKeyPath: attr, options: [.prior], context: nil) + } + cachedAttributesObserver = observer + } + } + + deinit { + if let observer = cachedAttributesObserver { + for attr in Notification.cachedAttributes { + removeObserver(observer, forKeyPath: attr) + } + } + } + func renderSubject() -> NSAttributedString? { guard let subjectContent = subjectContentGroup?.blocks.first else { return nil @@ -99,26 +121,6 @@ class Notification: NSManagedObject { return formatter.render(content: snippetContent, with: SnippetsContentStyles()) } - /// When needed, nukes cached attributes - /// - override func willChangeValue(forKey key: String) { - super.willChangeValue(forKey: key) - - // Note: - // Cached Attributes are only consumed on the main thread, when initializing UI elements. - // As an optimization, we'll only reset those attributes when we're running on the main thread. - // - guard managedObjectContext?.concurrencyType == .mainQueueConcurrencyType else { - return - } - - guard Swift.type(of: self).cachedAttributes.contains(key) else { - return - } - - resetCachedAttributes() - } - /// Nukes any cached values. /// func resetCachedAttributes() { @@ -216,6 +218,10 @@ extension Notification { return block.isActionEnabled(id: commandId) && !block.isActionOn(id: commandId) } + var isViewMilestone: Bool { + return FeatureFlag.milestoneNotifications.enabled && type == "view_milestone" + } + /// Returns the Meta ID's collection, if any. /// fileprivate var metaIds: [String: AnyObject]? { @@ -228,6 +234,18 @@ extension Notification { return metaIds?[MetaKeys.Comment] as? NSNumber } + /// Comment Author ID, if any. + /// + @objc var metaCommentAuthorID: NSNumber? { + return metaIds?[MetaKeys.User] as? NSNumber + } + + /// Comment Parent ID, if any. + /// + @objc var metaParentID: NSNumber? { + return metaIds?[MetaKeys.Parent] as? NSNumber + } + /// Post ID, if any. /// @objc var metaPostID: NSNumber? { @@ -382,6 +400,8 @@ extension Notification { static let Site = "site" static let Post = "post" static let Comment = "comment" + static let User = "user" + static let Parent = "parent_comment" static let Reply = "reply_comment" static let Home = "home" } @@ -394,3 +414,25 @@ extension Notification: Notifiable { return notificationId } } + +private class NotificationCachedAttributesObserver: NSObject { + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + guard let keyPath, let notification = object as? Notification, Notification.cachedAttributes.contains(keyPath) else { + return + } + + guard (change?[.notificationIsPriorKey] as? NSNumber)?.boolValue == true else { + return + } + + // Note: + // Cached Attributes are only consumed on the main thread, when initializing UI elements. + // As an optimization, we'll only reset those attributes when we're running on the main thread. + // + guard notification.managedObjectContext?.concurrencyType == .mainQueueConcurrencyType else { + return + } + + notification.resetCachedAttributes() + } +} diff --git a/WordPress/Classes/Models/Notifications/NotificationBlockGroup.swift b/WordPress/Classes/Models/Notifications/NotificationBlockGroup.swift deleted file mode 100644 index 45b519ba9b3d..000000000000 --- a/WordPress/Classes/Models/Notifications/NotificationBlockGroup.swift +++ /dev/null @@ -1,198 +0,0 @@ -import Foundation - -// MARK: - NotificationBlockGroup: Adapter to match 1 View <> 1 BlockGroup -// -class NotificationBlockGroup { - /// Grouped Blocks - /// - let blocks: [NotificationBlock] - - /// Kind of the current Group - /// - let kind: Kind - - /// Designated Initializer - /// - init(blocks: [NotificationBlock], kind: Kind) { - self.blocks = blocks - self.kind = kind - } -} - - - -// MARK: - Helpers Methods -// -extension NotificationBlockGroup { - /// Returns the First Block of a specified kind - /// - func blockOfKind(_ kind: NotificationBlock.Kind) -> NotificationBlock? { - return type(of: self).blockOfKind(kind, from: blocks) - } - - /// Extracts all of the imageUrl's for the blocks of the specified kinds - /// - func imageUrlsFromBlocksInKindSet(_ kindSet: Set<NotificationBlock.Kind>) -> Set<URL> { - let filtered = blocks.filter { kindSet.contains($0.kind) } - let imageUrls = filtered.flatMap { $0.imageUrls } - return Set(imageUrls) as Set<URL> - } -} - - - -// MARK: - Parsers -// -extension NotificationBlockGroup { - /// Subject: Contains a User + Text Block - /// - class func groupFromSubject(_ subject: [[String: AnyObject]], parent: Notification) -> NotificationBlockGroup { - let blocks = NotificationBlock.blocksFromArray(subject, parent: parent) - return NotificationBlockGroup(blocks: blocks, kind: .subject) - } - - /// Header: Contains a User + Text Block - /// - class func groupFromHeader(_ header: [[String: AnyObject]], parent: Notification) -> NotificationBlockGroup { - let blocks = NotificationBlock.blocksFromArray(header, parent: parent) - return NotificationBlockGroup(blocks: blocks, kind: .header) - } - - /// Body: May contain different kinds of Groups! - /// - class func groupsFromBody(_ body: [[String: AnyObject]], parent: Notification) -> [NotificationBlockGroup] { - let blocks = NotificationBlock.blocksFromArray(body, parent: parent) - - switch parent.kind { - case .comment: - return groupsForCommentBodyBlocks(blocks, parent: parent) - default: - return groupsForNonCommentBodyBlocks(blocks, parent: parent) - } - } -} - - -// MARK: - Private Parsing Helpers -// -private extension NotificationBlockGroup { - /// Non-Comment Body Groups: 1-1 Mapping between Blocks <> BlockGroups - /// - /// - Notifications of the kind [Follow, Like, CommentLike] may contain a Footer block. - /// - We can assume that whenever the last block is of the type .Text, we're dealing with a footer. - /// - Whenever we detect such a block, we'll map the NotificationBlock into a .Footer group. - /// - Footers are visually represented as `View All Followers` / `View All Likers` - /// - class func groupsForNonCommentBodyBlocks(_ blocks: [NotificationBlock], parent: Notification) -> [NotificationBlockGroup] { - let parentKindsWithFooters: [NotificationKind] = [.follow, .like, .commentLike] - let parentMayContainFooter = parentKindsWithFooters.contains(parent.kind) - - return blocks.map { block in - let isFooter = parentMayContainFooter && block.kind == .text && blocks.last == block - let kind = isFooter ? .footer : Kind.fromBlockKind(block.kind) - return NotificationBlockGroup(blocks: [block], kind: kind) - } - } - - /// Comment Body Blocks: - /// - Required to always render the Actions at the very bottom. - /// - Adapter: a single NotificationBlockGroup can be easily mapped against a single UI entity. - /// - class func groupsForCommentBodyBlocks(_ blocks: [NotificationBlock], parent: Notification) -> [NotificationBlockGroup] { - guard let comment = blockOfKind(.comment, from: blocks), let user = blockOfKind(.user, from: blocks) else { - return [] - } - - var groups = [NotificationBlockGroup]() - let commentGroupBlocks = [comment, user] - let middleGroupBlocks = blocks.filter { return commentGroupBlocks.contains($0) == false } - let actionGroupBlocks = [comment] - - // Comment Group: Comment + User Blocks - groups.append(NotificationBlockGroup(blocks: commentGroupBlocks, kind: .comment)) - - // Middle Group(s): Anything - for block in middleGroupBlocks { - // Duck Typing Again: - // If the block contains a range that matches with the metaReplyID field, we'll need to render this - // with a custom style. Translates into the `You replied to this comment` footer. - // - var kind = Kind.fromBlockKind(block.kind) - if let parentReplyID = parent.metaReplyID, block.notificationRangeWithCommentId(parentReplyID) != nil { - kind = .footer - } - - groups.append(NotificationBlockGroup(blocks: [block], kind: kind)) - } - - // Whenever Possible *REMOVE* this workaround. Pingback Notifications require a locally generated block. - // - if parent.isPingback, let homeURL = user.metaLinksHome { - let blockGroup = pingbackReadMoreGroup(for: homeURL) - groups.append(blockGroup) - } - - // Actions Group: A copy of the Comment Block (Actions) - groups.append(NotificationBlockGroup(blocks: actionGroupBlocks, kind: .actions)) - - return groups - } - - /// Returns the First Block of a specified kind. - /// - class func blockOfKind(_ kind: NotificationBlock.Kind, from blocks: [NotificationBlock]) -> NotificationBlock? { - for block in blocks where block.kind == kind { - return block - } - - return nil - } -} - - -// MARK: - Private Parsing Helpers -// -private extension NotificationBlockGroup { - - /// Returns a BlockGroup containing a single Text Block, which links to the specified URL. - /// - class func pingbackReadMoreGroup(for url: URL) -> NotificationBlockGroup { - let text = NSLocalizedString("Read the source post", comment: "Displayed at the footer of a Pingback Notification.") - let textRange = NSRange(location: 0, length: text.count) - let zeroRange = NSRange(location: 0, length: 0) - - let ranges = [ - NotificationRange(kind: .Noticon, range: zeroRange, value: "\u{f442}"), - NotificationRange(kind: .Link, range: textRange, url: url) - ] - - let block = NotificationBlock(text: text, ranges: ranges) - return NotificationBlockGroup(blocks: [block], kind: .footer) - } -} - -// MARK: - NotificationBlockGroup Types -// -extension NotificationBlockGroup { - /// Known Kinds of Block Groups - /// - enum Kind { - case text - case image - case user - case comment - case actions - case subject - case header - case footer - - static func fromBlockKind(_ blockKind: NotificationBlock.Kind) -> Kind { - switch blockKind { - case .text: return .text - case .image: return .image - case .user: return .user - case .comment: return .comment - } - } - } -} diff --git a/WordPress/Classes/Models/Notifications/NotificationSettings.swift b/WordPress/Classes/Models/Notifications/NotificationSettings.swift index 02d03be9320e..9cbeb33fabc0 100644 --- a/WordPress/Classes/Models/Notifications/NotificationSettings.swift +++ b/WordPress/Classes/Models/Notifications/NotificationSettings.swift @@ -19,6 +19,11 @@ open class NotificationSettings { /// public let blog: Blog? + /// The settings that are stored locally + /// + static let locallyStoredKeys: [String] = [ + Keys.weeklyRoundup, + ] /// Designated Initializer @@ -47,6 +52,10 @@ open class NotificationSettings { return Keys.localizedDetailsMap[preferenceKey] } + static func isLocallyStored(_ preferenceKey: String) -> Bool { + return Self.locallyStoredKeys.contains(preferenceKey) + } + /// Returns an array of the sorted Preference Keys /// @@ -130,7 +139,15 @@ open class NotificationSettings { } // MARK: - Private Properties - fileprivate let blogPreferenceKeys = [Keys.commentAdded, Keys.commentLiked, Keys.postLiked, Keys.follower, Keys.achievement, Keys.mention] + fileprivate var blogPreferenceKeys: [String] { + var keys = [Keys.commentAdded, Keys.commentLiked, Keys.postLiked, Keys.follower, Keys.achievement, Keys.mention] + + if Feature.enabled(.weeklyRoundup) && JetpackNotificationMigrationService.shared.shouldPresentNotifications() { + keys.append(Keys.weeklyRoundup) + } + + return keys + } fileprivate let blogEmailPreferenceKeys = [Keys.commentAdded, Keys.commentLiked, Keys.postLiked, Keys.follower, Keys.mention] fileprivate let otherPreferenceKeys = [Keys.commentLiked, Keys.commentReplied] fileprivate let wpcomPreferenceKeys = [Keys.marketing, Keys.research, Keys.community] @@ -147,6 +164,7 @@ open class NotificationSettings { static let marketing = "marketing" static let research = "research" static let community = "community" + static let weeklyRoundup = "weekly_roundup" static let localizedDescriptionMap = [ commentAdded: NSLocalizedString("Comments on my site", @@ -168,7 +186,9 @@ open class NotificationSettings { research: NSLocalizedString("Research", comment: "Setting: WordPress.com Surveys"), community: NSLocalizedString("Community", - comment: "Setting: WordPress.com Community") + comment: "Setting: WordPress.com Community"), + weeklyRoundup: NSLocalizedString("Weekly Roundup", + comment: "Setting: indicates if the site reports its Weekly Roundup"), ] static let localizedDetailsMap = [ diff --git a/WordPress/Classes/Models/Page.swift b/WordPress/Classes/Models/Page.swift index 226bc419533b..ac8f9440f89a 100644 --- a/WordPress/Classes/Models/Page.swift +++ b/WordPress/Classes/Models/Page.swift @@ -61,4 +61,35 @@ class Page: AbstractPost { hash(for: parentID?.intValue ?? 0) ] } + + override func availableStatusesForEditing() -> [Any] { + if isSiteHomepage && isPublished() { + return [PostStatusPublish] + } + return super.availableStatusesForEditing() + } + + // MARK: - Homepage Settings + + @objc var isSiteHomepage: Bool { + guard let postID = postID, + let homepageID = blog.homepagePageID, + let homepageType = blog.homepageType, + homepageType == .page else { + return false + } + + return homepageID == postID.intValue + } + + @objc var isSitePostsPage: Bool { + guard let postID = postID, + let postsPageID = blog.homepagePostsPageID, + let homepageType = blog.homepageType, + homepageType == .page else { + return false + } + + return postsPageID == postID.intValue + } } diff --git a/WordPress/Classes/Models/PageTemplateCategory+CoreDataClass.swift b/WordPress/Classes/Models/PageTemplateCategory+CoreDataClass.swift new file mode 100644 index 000000000000..4f793089069f --- /dev/null +++ b/WordPress/Classes/Models/PageTemplateCategory+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(PageTemplateCategory) +public class PageTemplateCategory: NSManagedObject { + +} diff --git a/WordPress/Classes/Models/PageTemplateCategory+CoreDataProperties.swift b/WordPress/Classes/Models/PageTemplateCategory+CoreDataProperties.swift new file mode 100644 index 000000000000..959f97a6a1f7 --- /dev/null +++ b/WordPress/Classes/Models/PageTemplateCategory+CoreDataProperties.swift @@ -0,0 +1,56 @@ +import Foundation +import CoreData + +extension PageTemplateCategory { + + @nonobjc public class func fetchRequest(forBlog blog: Blog, categorySlugs: [String]) -> NSFetchRequest<PageTemplateCategory> { + let request = NSFetchRequest<PageTemplateCategory>(entityName: "PageTemplateCategory") + let blogPredicate = NSPredicate(format: "\(#keyPath(PageTemplateCategory.blog)) == %@", blog) + let categoryPredicate = NSPredicate(format: "\(#keyPath(PageTemplateCategory.slug)) IN %@", categorySlugs) + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [blogPredicate, categoryPredicate]) + return request + } + + @nonobjc public class func fetchRequest(forBlog blog: Blog) -> NSFetchRequest<PageTemplateCategory> { + let request = NSFetchRequest<PageTemplateCategory>(entityName: "PageTemplateCategory") + request.predicate = NSPredicate(format: "\(#keyPath(PageTemplateCategory.blog)) == %@", blog) + return request + } + + @NSManaged public var desc: String? + @NSManaged public var emoji: String? + @NSManaged public var slug: String + @NSManaged public var title: String + @NSManaged public var layouts: Set<PageTemplateLayout>? + @NSManaged public var blog: Blog? + @NSManaged public var ordinal: Int +} + +// MARK: Generated accessors for layouts +extension PageTemplateCategory { + + @objc(addLayoutsObject:) + @NSManaged public func addToLayouts(_ value: PageTemplateLayout) + + @objc(removeLayoutsObject:) + @NSManaged public func removeFromLayouts(_ value: PageTemplateLayout) + + @objc(addLayouts:) + @NSManaged public func addToLayouts(_ values: Set<PageTemplateLayout>) + + @objc(removeLayouts:) + @NSManaged public func removeFromLayouts(_ values: Set<PageTemplateLayout>) + +} + +extension PageTemplateCategory { + + convenience init(context: NSManagedObjectContext, category: RemoteLayoutCategory, ordinal: Int) { + self.init(context: context) + slug = category.slug + title = category.title + desc = category.description + emoji = category.emoji + self.ordinal = ordinal + } +} diff --git a/WordPress/Classes/Models/PageTemplateLayout+CoreDataClass.swift b/WordPress/Classes/Models/PageTemplateLayout+CoreDataClass.swift new file mode 100644 index 000000000000..abc555effb7c --- /dev/null +++ b/WordPress/Classes/Models/PageTemplateLayout+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(PageTemplateLayout) +public class PageTemplateLayout: NSManagedObject { + +} diff --git a/WordPress/Classes/Models/PageTemplateLayout+CoreDataProperties.swift b/WordPress/Classes/Models/PageTemplateLayout+CoreDataProperties.swift new file mode 100644 index 000000000000..201af71fd63b --- /dev/null +++ b/WordPress/Classes/Models/PageTemplateLayout+CoreDataProperties.swift @@ -0,0 +1,59 @@ +import Foundation +import CoreData + +extension PageTemplateLayout { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<PageTemplateLayout> { + return NSFetchRequest<PageTemplateLayout>(entityName: "PageTemplateLayout") + } + + @NSManaged public var content: String + @NSManaged public var preview: String + @NSManaged public var previewTablet: String + @NSManaged public var previewMobile: String + @NSManaged public var demoUrl: String + @NSManaged public var slug: String + @NSManaged public var title: String? + @NSManaged public var categories: Set<PageTemplateCategory>? + +} + +// MARK: Generated accessors for categories +extension PageTemplateLayout { + + @objc(addCategoriesObject:) + @NSManaged public func addToCategories(_ value: PageTemplateCategory) + + @objc(removeCategoriesObject:) + @NSManaged public func removeFromCategories(_ value: PageTemplateCategory) + + @objc(addCategories:) + @NSManaged public func addToCategories(_ values: Set<PageTemplateCategory>) + + @objc(removeCategories:) + @NSManaged public func removeFromCategories(_ values: Set<PageTemplateCategory>) +} + +extension PageTemplateLayout { + + convenience init(context: NSManagedObjectContext, layout: RemoteLayout) { + self.init(context: context) + preview = layout.preview ?? "" + previewTablet = layout.previewTablet ?? "" + previewMobile = layout.previewMobile ?? "" + demoUrl = layout.demoUrl ?? "" + content = layout.content ?? "" + title = layout.title + slug = layout.slug + } +} + +extension PageTemplateLayout: Comparable { + public static func < (lhs: PageTemplateLayout, rhs: PageTemplateLayout) -> Bool { + return lhs.slug.compare(rhs.slug) == .orderedDescending + } + + public static func > (lhs: PageTemplateLayout, rhs: PageTemplateLayout) -> Bool { + return lhs.slug.compare(rhs.slug) == .orderedAscending + } +} diff --git a/WordPress/Classes/Models/Plan.swift b/WordPress/Classes/Models/Plan.swift index eb501089b21e..dac0c31b1fa7 100644 --- a/WordPress/Classes/Models/Plan.swift +++ b/WordPress/Classes/Models/Plan.swift @@ -11,4 +11,7 @@ public class Plan: NSManagedObject { @NSManaged public var summary: String @NSManaged public var features: String @NSManaged public var icon: String + @NSManaged public var supportPriority: Int16 + @NSManaged public var supportName: String + @NSManaged public var nonLocalizedShortname: String } diff --git a/WordPress/Classes/Models/Plugin.swift b/WordPress/Classes/Models/Plugin.swift index bb261db583aa..052c6f85d6c4 100644 --- a/WordPress/Classes/Models/Plugin.swift +++ b/WordPress/Classes/Models/Plugin.swift @@ -2,7 +2,7 @@ import Foundation struct Plugin: Equatable { let state: PluginState - let directoryEntry: PluginDirectoryEntry? + var directoryEntry: PluginDirectoryEntry? var id: String { return state.id @@ -12,6 +12,13 @@ struct Plugin: Equatable { return state.name } + var deactivateAllowed: Bool { + guard JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() else { + return true + } + return state.deactivateAllowed + } + static func ==(lhs: Plugin, rhs: Plugin) -> Bool { return lhs.state == rhs.state && lhs.directoryEntry == rhs.directoryEntry diff --git a/WordPress/Classes/Models/Post+CoreDataProperties.swift b/WordPress/Classes/Models/Post+CoreDataProperties.swift index e986c6ce6586..1fcad17690ce 100644 --- a/WordPress/Classes/Models/Post+CoreDataProperties.swift +++ b/WordPress/Classes/Models/Post+CoreDataProperties.swift @@ -5,10 +5,7 @@ extension Post { @NSManaged var commentCount: NSNumber? @NSManaged var disabledPublicizeConnections: [NSNumber: [String: String]]? - @NSManaged var geolocation: Coordinate? - @NSManaged var latitudeID: String? @NSManaged var likeCount: NSNumber? - @NSManaged var longitudeID: String? @NSManaged var postFormat: String? @NSManaged var postType: String? @NSManaged var publicID: String? @@ -18,6 +15,10 @@ extension Post { @NSManaged var categories: Set<PostCategory>? @NSManaged var isStickyPost: Bool + + // If the post is created as an answer to a Blogging Prompt, the promptID is stored here. + @NSManaged var bloggingPromptID: String? + // These were added manually, since the code generator for Swift is not generating them. // @NSManaged func addCategoriesObject(_ value: PostCategory) diff --git a/WordPress/Classes/Models/Post+RefreshStatus.swift b/WordPress/Classes/Models/Post+RefreshStatus.swift new file mode 100644 index 000000000000..b8d1b4dc5ec0 --- /dev/null +++ b/WordPress/Classes/Models/Post+RefreshStatus.swift @@ -0,0 +1,21 @@ +extension Post { + + /// This method checks the status of all post objects and updates them to the correct status if needed. + /// The main cause of wrong status is the app being killed while uploads of posts are happening. + /// + /// - Parameters: + /// - onCompletion: block to invoke when status update is finished. + /// - onError: block to invoke if any error occurs while the update is being made. + static func refreshStatus(with coreDataStack: CoreDataStack) { + coreDataStack.performAndSave { context in + let fetch = NSFetchRequest<Post>(entityName: Post.classNameWithoutNamespaces()) + let pushingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: AbstractPostRemoteStatus.pushing.rawValue)) + let processingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: AbstractPostRemoteStatus.pushingMedia.rawValue)) + fetch.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [pushingPredicate, processingPredicate]) + guard let postsPushing = try? context.fetch(fetch) else { return } + for post in postsPushing { + post.markAsFailedAndDraftIfNeeded() + } + } + } +} diff --git a/WordPress/Classes/Models/Post.swift b/WordPress/Classes/Models/Post.swift index 8e956b6c2b95..3e92adb3fed1 100644 --- a/WordPress/Classes/Models/Post.swift +++ b/WordPress/Classes/Models/Post.swift @@ -60,7 +60,7 @@ class Post: AbstractPost { return } - buildContentPreview() + storedContentPreviewForDisplay = "" } // MARK: - Content Preview @@ -224,6 +224,10 @@ class Post: AbstractPost { return (tags?.trim().count > 0) } + override func authorForDisplay() -> String? { + return author ?? blog.account?.displayName + } + // MARK: - BasePost override func contentPreviewForDisplay() -> String { @@ -247,16 +251,6 @@ class Post: AbstractPost { return true } - if let coord1 = geolocation?.coordinate, - let coord2 = originalPost.geolocation?.coordinate, coord1.latitude != coord2.latitude || coord1.longitude != coord2.longitude { - - return true - } - - if (geolocation == nil && originalPost.geolocation != nil) || (geolocation != nil && originalPost.geolocation == nil) { - return true - } - if publicizeMessage ?? "" != originalPost.publicizeMessage ?? "" { return true } @@ -318,8 +312,6 @@ class Post: AbstractPost { hash(for: tags ?? ""), hash(for: postFormat ?? ""), hash(for: stringifiedCategories), - hash(for: geolocation?.latitude ?? 0), - hash(for: geolocation?.longitude ?? 0), hash(for: isStickyPost ? 1 : 0)] } } diff --git a/WordPress/Classes/Models/PostCategory+Creation.swift b/WordPress/Classes/Models/PostCategory+Creation.swift new file mode 100644 index 000000000000..fe073ec26caa --- /dev/null +++ b/WordPress/Classes/Models/PostCategory+Creation.swift @@ -0,0 +1,22 @@ +import Foundation + +extension PostCategory { + + static func create(withBlogID id: NSManagedObjectID, in context: NSManagedObjectContext) throws -> PostCategory { + let object = try context.existingObject(with: id) + + guard let blog = object as? Blog else { + fatalError("The object id does not belong to a Blog: \(id)") + } + + let category = PostCategory(context: context) + category.blog = blog + return category + } + + @objc(createWithBlogObjectID:inContext:) + static func objc_create(withBlogID id: NSManagedObjectID, in context: NSManagedObjectContext) -> PostCategory? { + try? create(withBlogID: id, in: context) + } + +} diff --git a/WordPress/Classes/Models/PostCategory+Lookup.swift b/WordPress/Classes/Models/PostCategory+Lookup.swift new file mode 100644 index 000000000000..98b1db9ce9c4 --- /dev/null +++ b/WordPress/Classes/Models/PostCategory+Lookup.swift @@ -0,0 +1,43 @@ +import Foundation + +extension PostCategory { + + static func lookup(withBlogID id: NSManagedObjectID, categoryID: NSNumber, in context: NSManagedObjectContext) throws -> PostCategory? { + try lookup(withBlogID: id, predicate: NSPredicate(format: "categoryID == %@", categoryID), in: context) + } + + static func lookup(withBlogID id: NSManagedObjectID, parentCategoryID: NSNumber?, categoryName: String, in context: NSManagedObjectContext) throws -> PostCategory? { + try lookup( + withBlogID: id, + predicate: NSPredicate(format: "(categoryName like %@) AND (parentID = %@)", categoryName, parentCategoryID ?? 0), + in: context + ) + } + + private static func lookup(withBlogID id: NSManagedObjectID, predicate: NSPredicate, in context: NSManagedObjectContext) throws -> PostCategory? { + let object = try context.existingObject(with: id) + + guard let blog = object as? Blog else { + fatalError("The object id does not belong to a Blog: \(id)") + } + + return blog.categories?.first { predicate.evaluate(with: $0) } as? PostCategory + } + +} + +// MARK: - Objective-C API + +extension PostCategory { + + @objc(lookupWithBlogObjectID:categoryID:inContext:) + static func objc_lookup(withBlogID id: NSManagedObjectID, categoryID: NSNumber, in context: NSManagedObjectContext) -> PostCategory? { + try? lookup(withBlogID: id, categoryID: categoryID, in: context) + } + + @objc(lookupWithBlogObjectID:parentCategoryID:categoryName:inContext:) + static func objc_lookup(withBlogID id: NSManagedObjectID, parentCategoryID: NSNumber?, categoryName: String, in context: NSManagedObjectContext) -> PostCategory? { + try? lookup(withBlogID: id, parentCategoryID: parentCategoryID, categoryName: categoryName, in: context) + } + +} diff --git a/WordPress/Classes/Models/PostContentProvider.h b/WordPress/Classes/Models/PostContentProvider.h index 417ae33be7bb..66ec522bd313 100644 --- a/WordPress/Classes/Models/PostContentProvider.h +++ b/WordPress/Classes/Models/PostContentProvider.h @@ -3,16 +3,17 @@ @protocol PostContentProvider <NSObject> - (NSString *)titleForDisplay; - (NSString *)authorForDisplay; -- (NSString *)blogNameForDisplay; -- (NSString *)statusForDisplay; - (NSString *)contentForDisplay; - (NSString *)contentPreviewForDisplay; - (NSURL *)avatarURLForDisplay; // Some providers use a hardcoded URL or blavatar URL - (NSString *)gravatarEmailForDisplay; - (NSDate *)dateForDisplay; @optional +- (NSString *)blogNameForDisplay; +- (NSString *)statusForDisplay; - (BOOL)unreadStatusForDisplay; - (NSURL *)featuredImageURLForDisplay; - (NSURL *)authorURL; - (NSString *)slugForDisplay; +- (NSArray <NSString *> *)tagsForDisplay; @end diff --git a/WordPress/Classes/Models/PostTag+Comparable.swift b/WordPress/Classes/Models/PostTag+Comparable.swift deleted file mode 100644 index 8bb2107aea2d..000000000000 --- a/WordPress/Classes/Models/PostTag+Comparable.swift +++ /dev/null @@ -1,10 +0,0 @@ - -extension PostTag: Comparable { - public static func <(lhs: PostTag, rhs: PostTag) -> Bool { - guard let lhsName = lhs.name, let rhsName = rhs.name else { - return false - } - - return lhsName < rhsName - } -} diff --git a/WordPress/Classes/Models/PublicizeConnection+Creation.swift b/WordPress/Classes/Models/PublicizeConnection+Creation.swift new file mode 100644 index 000000000000..0d993f265670 --- /dev/null +++ b/WordPress/Classes/Models/PublicizeConnection+Creation.swift @@ -0,0 +1,51 @@ +import Foundation + +extension PublicizeConnection { + + /// Composes a new `PublicizeConnection`, or updates an existing one, with + /// data represented by the passed `RemotePublicizeConnection`. + /// + /// - Parameter remoteConnection: The remote connection representing the publicize connection. + /// + /// - Returns: A `PublicizeConnection`. + /// + static func createOrReplace(from remoteConnection: RemotePublicizeConnection, in context: NSManagedObjectContext) -> PublicizeConnection { + let pubConnection = findPublicizeConnection(byID: remoteConnection.connectionID, in: context) + ?? NSEntityDescription.insertNewObject(forEntityName: PublicizeConnection.classNameWithoutNamespaces(), + into: context) as! PublicizeConnection + + pubConnection.connectionID = remoteConnection.connectionID + pubConnection.dateExpires = remoteConnection.dateExpires + pubConnection.dateIssued = remoteConnection.dateIssued + pubConnection.externalDisplay = remoteConnection.externalDisplay + pubConnection.externalFollowerCount = remoteConnection.externalFollowerCount + pubConnection.externalID = remoteConnection.externalID + pubConnection.externalName = remoteConnection.externalName + pubConnection.externalProfilePicture = remoteConnection.externalProfilePicture + pubConnection.externalProfileURL = remoteConnection.externalProfileURL + pubConnection.keyringConnectionID = remoteConnection.keyringConnectionID + pubConnection.keyringConnectionUserID = remoteConnection.keyringConnectionUserID + pubConnection.label = remoteConnection.label + pubConnection.refreshURL = remoteConnection.refreshURL + pubConnection.service = remoteConnection.service + pubConnection.shared = remoteConnection.shared + pubConnection.status = remoteConnection.status + pubConnection.siteID = remoteConnection.siteID + pubConnection.userID = remoteConnection.userID + + return pubConnection + } + + /// Finds a cached `PublicizeConnection` by its `connectionID` + /// + /// - Parameter connectionID: The ID of the `PublicizeConnection`. + /// + /// - Returns: The requested `PublicizeConnection` or nil. + /// + private static func findPublicizeConnection(byID connectionID: NSNumber, in context: NSManagedObjectContext) -> PublicizeConnection? { + let request = NSFetchRequest<PublicizeConnection>(entityName: PublicizeConnection.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "connectionID = %@", connectionID) + return try? context.fetch(request).first + } + +} diff --git a/WordPress/Classes/Models/PublicizeService+Lookup.swift b/WordPress/Classes/Models/PublicizeService+Lookup.swift new file mode 100644 index 000000000000..09884c044ecc --- /dev/null +++ b/WordPress/Classes/Models/PublicizeService+Lookup.swift @@ -0,0 +1,30 @@ +extension PublicizeService { + /// Finds a cached `PublicizeService` matching the specified service name. + /// + /// - Parameter name: The name of the service. This is the `serviceID` attribute for a `PublicizeService` object. + /// + /// - Returns: The requested `PublicizeService` or nil. + /// + static func lookupPublicizeServiceNamed(_ name: String, in context: NSManagedObjectContext) throws -> PublicizeService? { + let request = NSFetchRequest<PublicizeService>(entityName: PublicizeService.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "serviceID = %@", name) + return try context.fetch(request).first + } + + @objc(lookupPublicizeServiceNamed:inContext:) + static func objc_lookupPublicizeServiceNamed(_ name: String, in context: NSManagedObjectContext) -> PublicizeService? { + try? lookupPublicizeServiceNamed(name, in: context) + } + + /// Returns an array of all cached `PublicizeService` objects. + /// + /// - Returns: An array of `PublicizeService`. The array is empty if no objects are cached. + /// + @objc(allPublicizeServicesInContext:error:) + static func allPublicizeServices(in context: NSManagedObjectContext) throws -> [PublicizeService] { + let request = NSFetchRequest<PublicizeService>(entityName: PublicizeService.classNameWithoutNamespaces()) + let sortDescriptor = NSSortDescriptor(key: "order", ascending: true) + request.sortDescriptors = [sortDescriptor] + return try context.fetch(request) + } +} diff --git a/WordPress/Classes/Models/ReaderAbstractTopic+Lookup.swift b/WordPress/Classes/Models/ReaderAbstractTopic+Lookup.swift new file mode 100644 index 000000000000..e973d1cb8d65 --- /dev/null +++ b/WordPress/Classes/Models/ReaderAbstractTopic+Lookup.swift @@ -0,0 +1,90 @@ +import Foundation + +extension ReaderAbstractTopic { + + /// Fetch all `ReaderAbstractTopics` currently in Core Data. + /// + /// - Returns: An array of all `ReaderAbstractTopics` currently persisted in Core Data. + @objc(lookupAllInContext:error:) + static func lookupAll(in context: NSManagedObjectContext) throws -> [ReaderAbstractTopic] { + let request = NSFetchRequest<ReaderAbstractTopic>(entityName: ReaderAbstractTopic.classNameWithoutNamespaces()) + return try context.fetch(request) + } + + /// Fetch all `ReaderAbstractTopics` for the menu currently in Core Data. + /// + /// - Returns: An array of all `ReaderAbstractTopics` for the menu currently persisted in Core Data. + @objc(lookupAllMenusInContext:error:) + static func lookupAllMenus(in context: NSManagedObjectContext) throws -> [ReaderAbstractTopic] { + let request = NSFetchRequest<ReaderAbstractTopic>(entityName: ReaderAbstractTopic.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "showInMenu = YES") + return try context.fetch(request) + } + + /// Fetch all `Fetch all saved Site topics` currently in Core Data. + /// + @objc(lookupAllSitesInContext:error:) + static func lookupAllSites(in context: NSManagedObjectContext) throws -> [ReaderSiteTopic] { + let request = NSFetchRequest<ReaderSiteTopic>(entityName: ReaderSiteTopic.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "following = YES") + request.sortDescriptors = [ + NSSortDescriptor(key: "title", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare(_:))) + ] + return try context.fetch(request) + } + + /// Find a specific ReaderAbstractTopic by its `path` property. + /// + /// - Parameter path: The unique, cannonical path of the topic. + /// - Returns: A matching `ReaderAbstractTopic` or nil if there is no match. + static func lookup(withPath path: String, in context: NSManagedObjectContext) throws -> ReaderAbstractTopic? { + let lowcasedPath = path.lowercased() + return try lookupAll(in: context).first { $0.path == lowcasedPath } + } + + /// Find a specific ReaderAbstractTopic by its `path` property. + /// + /// - Parameter path: The unique, cannonical path of the topic. + /// - Returns: A matching `ReaderAbstractTopic` or nil if there is no match. + @objc(lookupWithPath:inContext:) + static func objc_lookup(withPath path: String, in context: NSManagedObjectContext) -> ReaderAbstractTopic? { + try? lookup(withPath: path, in: context) + } + + /// Find a topic where its path contains a specified path. + /// + /// - Parameter path: The path of the topic + /// - Returns: A matching abstract topic or nil. + static func lookup(pathContaining path: String, in context: NSManagedObjectContext) throws -> ReaderAbstractTopic? { + let lowcasedPath = path.lowercased() + return try lookupAll(in: context).first { $0.path.contains(lowcasedPath) } + } + + /// Find a topic where its path contains a specified path. + /// + /// - Parameter path: The path of the topic + /// - Returns: A matching abstract topic or nil. + @objc(lookupContainingPath:inContext:) + static func objc_lookup(pathContaining path: String, in context: NSManagedObjectContext) -> ReaderAbstractTopic? { + try? lookup(pathContaining: path, in: context) + } + + /// Fetch the topic for 'sites I follow' if it exists. + /// + /// - Returns: A `ReaderAbstractTopic` instance or nil. + static func lookupFollowedSitesTopic(in context: NSManagedObjectContext) throws -> ReaderAbstractTopic? { + let request = NSFetchRequest<ReaderAbstractTopic>(entityName: ReaderAbstractTopic.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "path LIKE %@", "*/read/following") + request.fetchLimit = 1 + return try context.fetch(request).first + } + + /// Fetch the topic for 'sites I follow' if it exists. + /// + /// - Returns: A `ReaderAbstractTopic` instance or nil. + @objc(lookupFollowedSitesTopicInContext:) + static func objc_lookupFollowedSitesTopic(in context: NSManagedObjectContext) -> ReaderAbstractTopic? { + try? lookupFollowedSitesTopic(in: context) + } + +} diff --git a/WordPress/Classes/Models/ReaderCard+CoreDataClass.swift b/WordPress/Classes/Models/ReaderCard+CoreDataClass.swift new file mode 100644 index 000000000000..2787ce87cb49 --- /dev/null +++ b/WordPress/Classes/Models/ReaderCard+CoreDataClass.swift @@ -0,0 +1,59 @@ +import Foundation +import CoreData + +public class ReaderCard: NSManagedObject { + enum CardType { + case post + case topics + case sites + case unknown + } + + var type: CardType { + if post != nil { + return .post + } + + if topicsArray.count > 0 { + return .topics + } + + if sitesArray.count > 0 { + return .sites + } + + return .unknown + } + + var topicsArray: [ReaderTagTopic] { + topics?.array as? [ReaderTagTopic] ?? [] + } + + var sitesArray: [ReaderSiteTopic] { + sites?.array as? [ReaderSiteTopic] ?? [] + } + + convenience init?(context: NSManagedObjectContext, from remoteCard: RemoteReaderCard) { + guard remoteCard.type != .unknown else { + return nil + } + + self.init(context: context) + + switch remoteCard.type { + case .post: + post = ReaderPost.createOrReplace(fromRemotePost: remoteCard.post, for: nil, context: context) + case .interests: + topics = NSOrderedSet(array: remoteCard.interests?.map { + ReaderTagTopic.createOrUpdateIfNeeded(from: $0, context: context) + } ?? []) + case .sites: + sites = NSOrderedSet(array: remoteCard.sites?.map { + ReaderSiteTopic.createIfNeeded(from: $0, context: context) + } ?? []) + + default: + break + } + } +} diff --git a/WordPress/Classes/Models/ReaderCard+CoreDataProperties.swift b/WordPress/Classes/Models/ReaderCard+CoreDataProperties.swift new file mode 100644 index 000000000000..5dc390451d3c --- /dev/null +++ b/WordPress/Classes/Models/ReaderCard+CoreDataProperties.swift @@ -0,0 +1,15 @@ +import Foundation +import CoreData + +extension ReaderCard { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<ReaderCard> { + return NSFetchRequest<ReaderCard>(entityName: "ReaderCard") + } + + @NSManaged public var sortRank: Double + @NSManaged public var post: ReaderPost? + @NSManaged public var topics: NSOrderedSet? + @NSManaged public var sites: NSOrderedSet? + +} diff --git a/WordPress/Classes/Models/ReaderCardContent+PostInformation.swift b/WordPress/Classes/Models/ReaderCardContent+PostInformation.swift deleted file mode 100644 index cbc965310715..000000000000 --- a/WordPress/Classes/Models/ReaderCardContent+PostInformation.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -class ReaderCardContent: ImageSourceInformation { - private let originalProvider: ReaderPostContentProvider - - init(provider: ReaderPostContentProvider) { - originalProvider = provider - } - - var isPrivateOnWPCom: Bool { - return originalProvider.isPrivate() && originalProvider.isWPCom() - } - - var isSelfHostedWithCredentials: Bool { - return !originalProvider.isWPCom() && !originalProvider.isJetpack() - } -} diff --git a/WordPress/Classes/Models/ReaderListTopic+Creation.swift b/WordPress/Classes/Models/ReaderListTopic+Creation.swift new file mode 100644 index 000000000000..2f0abdfd6e2a --- /dev/null +++ b/WordPress/Classes/Models/ReaderListTopic+Creation.swift @@ -0,0 +1,27 @@ +import Foundation + +extension ReaderListTopic { + + /// Returns an existing topic for the specified list, or creates one if one + /// doesn't already exist. + /// + static func named(_ listName: String, forUser user: String, in context: NSManagedObjectContext) -> ReaderListTopic? { + let remote = ReaderTopicServiceRemote(wordPressComRestApi: WordPressComRestApi.anonymousApi(userAgent: WPUserAgent.wordPress())) + let sanitizedListName = remote.slug(forTopicName: listName) ?? listName.lowercased() + let sanitizedUser = user.lowercased() + let path = remote.path(forEndpoint: "read/list/\(sanitizedUser)/\(sanitizedListName)/posts", withVersion: ._1_2) + + if let existingTopic = try? ReaderAbstractTopic.lookup(pathContaining: path, in: context) as? ReaderListTopic { + return existingTopic + } + + let topic = ReaderListTopic(context: context) + topic.title = listName + topic.slug = sanitizedListName + topic.owner = user + topic.path = path + + return topic + } + +} diff --git a/WordPress/Classes/Models/ReaderPost+Helper.swift b/WordPress/Classes/Models/ReaderPost+Helper.swift new file mode 100644 index 000000000000..b4fe2e3ae71b --- /dev/null +++ b/WordPress/Classes/Models/ReaderPost+Helper.swift @@ -0,0 +1,44 @@ +import Foundation + +extension ReaderPost { + + /// Find cached comment with given ID. + /// + /// - Parameter id: The comment id + /// - Returns: The `Comment` object associated with the given id, or `nil` if none is found. + @objc + func comment(withID id: NSNumber) -> Comment? { + comment(withID: id.int32Value) + } + + /// Find cached comment with given ID. + /// + /// - Parameter id: The comment id + /// - Returns: The `Comment` object associated with the given id, or `nil` if none is found. + func comment(withID id: Int32) -> Comment? { + return (comments as? Set<Comment>)?.first { $0.commentID == id } + } + + /// Get a cached site's ReaderPost with the specified ID. + /// + /// - Parameter postID: ID of the post. + /// - Parameter siteID: ID of the site the post belongs to. + /// - Returns: the matching `ReaderPost`, or `nil` if none is found. + static func lookup(withID postID: NSNumber, forSiteWithID siteID: NSNumber, in context: NSManagedObjectContext) throws -> ReaderPost? { + let request = NSFetchRequest<ReaderPost>(entityName: ReaderPost.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "postID = %@ AND siteID = %@", postID, siteID) + request.fetchLimit = 1 + return try context.fetch(request).first + } + + /// Get a cached site's ReaderPost with the specified ID. + /// + /// - Parameter postID: ID of the post. + /// - Parameter siteID: ID of the site the post belongs to. + /// - Returns: the matching `ReaderPost`, or `nil` if none is found. + @objc(lookupWithID:forSiteWithID:inContext:) + static func objc_lookup(withID postID: NSNumber, forSiteWithID siteID: NSNumber, in context: NSManagedObjectContext) -> ReaderPost? { + try? lookup(withID: postID, forSiteWithID: siteID, in: context) + } + +} diff --git a/WordPress/Classes/Models/ReaderPost.h b/WordPress/Classes/Models/ReaderPost.h index 9ed8f8b7d1b3..31931f3f7a12 100644 --- a/WordPress/Classes/Models/ReaderPost.h +++ b/WordPress/Classes/Models/ReaderPost.h @@ -7,6 +7,8 @@ @class ReaderCrossPostMeta; @class SourcePostAttribution; @class Comment; +@class RemoteReaderPost; +@class ReaderCard; extern NSString * const ReaderPostStoredCommentIDKey; extern NSString * const ReaderPostStoredCommentTextKey; @@ -26,12 +28,16 @@ extern NSString * const ReaderPostStoredCommentTextKey; @property (nonatomic, strong) NSNumber *feedID; @property (nonatomic, strong) NSNumber *feedItemID; @property (nonatomic, strong) NSString *globalID; +@property (nonatomic) BOOL isBlogAtomic; @property (nonatomic) BOOL isBlogPrivate; @property (nonatomic) BOOL isFollowing; @property (nonatomic) BOOL isLiked; @property (nonatomic) BOOL isReblogged; @property (nonatomic) BOOL isWPCom; @property (nonatomic) BOOL isSavedForLater; +@property (nonatomic) BOOL isSeen; +@property (nonatomic) BOOL isSeenSupported; +@property (nonatomic, strong) NSNumber *organizationID; @property (nonatomic, strong) NSNumber *likeCount; @property (nonatomic, strong) NSNumber *score; @property (nonatomic, strong) NSNumber *siteID; @@ -45,10 +51,14 @@ extern NSString * const ReaderPostStoredCommentTextKey; @property (nonatomic, readonly, strong) NSURL *featuredImageURL; @property (nonatomic, strong) NSString *tags; @property (nonatomic, strong) ReaderAbstractTopic *topic; +@property (nonatomic, strong) NSSet<ReaderCard *> *card; @property (nonatomic) BOOL isLikesEnabled; @property (nonatomic) BOOL isSharingEnabled; @property (nonatomic) BOOL isSiteBlocked; @property (nonatomic, strong) SourcePostAttribution *sourceAttribution; +@property (nonatomic) BOOL isSubscribedComments; +@property (nonatomic) BOOL canSubscribeComments; +@property (nonatomic) BOOL receivesCommentNotifications; @property (nonatomic, strong) NSString *primaryTag; @property (nonatomic, strong) NSString *primaryTagSlug; @@ -65,8 +75,11 @@ extern NSString * const ReaderPostStoredCommentTextKey; // When true indicates a post should not be deleted/cleaned-up as its currently being used. @property (nonatomic) BOOL inUse; ++ (instancetype)createOrReplaceFromRemotePost:(RemoteReaderPost *)remotePost forTopic:(ReaderAbstractTopic *)topic context:(NSManagedObjectContext *) managedObjectContext; + - (BOOL)isCrossPost; - (BOOL)isPrivate; +- (BOOL)isP2Type; - (NSString *)authorString; - (NSString *)avatar; - (UIImage *)cachedAvatarWithSize:(CGSize)size; diff --git a/WordPress/Classes/Models/ReaderPost.m b/WordPress/Classes/Models/ReaderPost.m index 61f1156dbec3..ffd9bea01406 100644 --- a/WordPress/Classes/Models/ReaderPost.m +++ b/WordPress/Classes/Models/ReaderPost.m @@ -1,6 +1,6 @@ #import "ReaderPost.h" #import "AccountService.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "SourcePostAttribution.h" #import "WPAccount.h" #import "WPAvatarSource.h" @@ -12,6 +12,11 @@ NSString * const ReaderPostStoredCommentIDKey = @"commentID"; NSString * const ReaderPostStoredCommentTextKey = @"comment"; +static NSString * const SourceAttributionSiteTaxonomy = @"site-pick"; +static NSString * const SourceAttributionImageTaxonomy = @"image-pick"; +static NSString * const SourceAttributionQuoteTaxonomy = @"quote-pick"; +static NSString * const SourceAttributionStandardTaxonomy = @"standard-pick"; + @implementation ReaderPost @dynamic authorDisplayName; @@ -26,11 +31,13 @@ @implementation ReaderPost @dynamic featuredImage; @dynamic feedID; @dynamic feedItemID; +@dynamic isBlogAtomic; @dynamic isBlogPrivate; @dynamic isFollowing; @dynamic isLiked; @dynamic isReblogged; @dynamic isWPCom; +@dynamic organizationID; @dynamic likeCount; @dynamic score; @dynamic siteID; @@ -40,12 +47,18 @@ @implementation ReaderPost @dynamic comments; @dynamic tags; @dynamic topic; +@dynamic card; @dynamic globalID; @dynamic isLikesEnabled; @dynamic isSharingEnabled; @dynamic isSiteBlocked; @dynamic sourceAttribution; @dynamic isSavedForLater; +@dynamic isSeen; +@dynamic isSeenSupported; +@dynamic isSubscribedComments; +@dynamic canSubscribeComments; +@dynamic receivesCommentNotifications; @dynamic primaryTag; @dynamic primaryTagSlug; @@ -59,17 +72,188 @@ @implementation ReaderPost @synthesize rendered; ++ (instancetype)createOrReplaceFromRemotePost:(RemoteReaderPost *)remotePost + forTopic:(ReaderAbstractTopic *)topic + context:(NSManagedObjectContext *) managedObjectContext +{ + NSError *error; + ReaderPost *post; + NSString *globalID = remotePost.globalID; + NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + fetchRequest.predicate = [NSPredicate predicateWithFormat:@"globalID = %@ AND (topic = %@ OR topic = NULL)", globalID, topic]; + NSArray *arr = [managedObjectContext executeFetchRequest:fetchRequest error:&error]; + + BOOL existing = false; + if (error) { + DDLogError(@"Error fetching an existing reader post. - %@", error); + } else if ([arr count] > 0) { + post = (ReaderPost *)[arr objectAtIndex:0]; + existing = YES; + } else { + post = [NSEntityDescription insertNewObjectForEntityForName:@"ReaderPost" + inManagedObjectContext:managedObjectContext]; + } + + post.authorID = remotePost.authorID; + post.author = remotePost.author; + post.authorAvatarURL = remotePost.authorAvatarURL; + post.authorDisplayName = remotePost.authorDisplayName; + post.authorEmail = remotePost.authorEmail; + post.authorURL = remotePost.authorURL; + post.organizationID = remotePost.organizationID; + post.siteIconURL = remotePost.siteIconURL; + post.blogName = remotePost.blogName; + post.blogDescription = remotePost.blogDescription; + post.blogURL = remotePost.blogURL; + post.commentCount = remotePost.commentCount; + post.commentsOpen = remotePost.commentsOpen; + post.date_created_gmt = [DateUtils dateFromISOString:remotePost.date_created_gmt]; + post.featuredImage = remotePost.featuredImage; + post.feedID = remotePost.feedID; + post.feedItemID = remotePost.feedItemID; + post.globalID = remotePost.globalID; + post.isBlogAtomic = remotePost.isBlogAtomic; + post.isBlogPrivate = remotePost.isBlogPrivate; + post.isFollowing = remotePost.isFollowing; + post.isLiked = remotePost.isLiked; + post.isReblogged = remotePost.isReblogged; + post.isWPCom = remotePost.isWPCom; + post.organizationID = remotePost.organizationID; + post.likeCount = remotePost.likeCount; + post.permaLink = remotePost.permalink; + post.postID = remotePost.postID; + post.postTitle = remotePost.postTitle; + post.railcar = remotePost.railcar; + post.score = remotePost.score; + post.siteID = remotePost.siteID; + post.sortDate = remotePost.sortDate; + post.isSeen = remotePost.isSeen; + post.isSeenSupported = remotePost.isSeenSupported; + post.isSubscribedComments = remotePost.isSubscribedComments; + post.canSubscribeComments = remotePost.canSubscribeComments; + post.receivesCommentNotifications = remotePost.receivesCommentNotifications; + + if (existing && [topic isKindOfClass:[ReaderSearchTopic class]]) { + // Failsafe. The `read/search` endpoint might return the same post on + // more than one page. If this happens preserve the *original* sortRank + // to avoid content jumping around in the UI. + } else { + post.sortRank = remotePost.sortRank; + } + + post.status = remotePost.status; + post.summary = remotePost.summary; + post.tags = remotePost.tags; + post.isSharingEnabled = remotePost.isSharingEnabled; + post.isLikesEnabled = remotePost.isLikesEnabled; + post.isSiteBlocked = NO; + + if (remotePost.crossPostMeta) { + if (!post.crossPostMeta) { + ReaderCrossPostMeta *meta = (ReaderCrossPostMeta *)[NSEntityDescription insertNewObjectForEntityForName:[ReaderCrossPostMeta classNameWithoutNamespaces] + inManagedObjectContext:managedObjectContext]; + post.crossPostMeta = meta; + } + post.crossPostMeta.siteURL = remotePost.crossPostMeta.siteURL; + post.crossPostMeta.postURL = remotePost.crossPostMeta.postURL; + post.crossPostMeta.commentURL = remotePost.crossPostMeta.commentURL; + post.crossPostMeta.siteID = remotePost.crossPostMeta.siteID; + post.crossPostMeta.postID = remotePost.crossPostMeta.postID; + } else { + post.crossPostMeta = nil; + } + + NSString *tag = remotePost.primaryTag; + NSString *slug = remotePost.primaryTagSlug; + if ([topic isKindOfClass:[ReaderTagTopic class]]) { + ReaderTagTopic *tagTopic = (ReaderTagTopic *)topic; + if ([tagTopic.slug isEqualToString:remotePost.primaryTagSlug]) { + tag = remotePost.secondaryTag; + slug = remotePost.secondaryTagSlug; + } + } + post.primaryTag = tag; + post.primaryTagSlug = slug; + + post.isExternal = remotePost.isExternal; + post.isJetpack = remotePost.isJetpack; + post.wordCount = remotePost.wordCount; + post.readingTime = remotePost.readingTime; + + if (remotePost.sourceAttribution) { + post.sourceAttribution = [self createOrReplaceFromRemoteDiscoverAttribution:remotePost.sourceAttribution forPost:post context:managedObjectContext]; + } else { + post.sourceAttribution = nil; + } + + post.content = [RichContentFormatter removeInlineStyles:[RichContentFormatter removeForbiddenTags:remotePost.content]]; + + // assign the topic last. + post.topic = topic; + + return post; +} + ++ (SourcePostAttribution *)createOrReplaceFromRemoteDiscoverAttribution:(RemoteSourcePostAttribution *)remoteAttribution + forPost:(ReaderPost *)post + context:(NSManagedObjectContext *) managedObjectContext +{ + SourcePostAttribution *attribution = post.sourceAttribution; + + if (!attribution) { + attribution = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([SourcePostAttribution class]) + inManagedObjectContext:managedObjectContext]; + } + attribution.authorName = remoteAttribution.authorName; + attribution.authorURL = remoteAttribution.authorURL; + attribution.avatarURL = remoteAttribution.avatarURL; + attribution.blogName = remoteAttribution.blogName; + attribution.blogURL = remoteAttribution.blogURL; + attribution.permalink = remoteAttribution.permalink; + attribution.blogID = remoteAttribution.blogID; + attribution.postID = remoteAttribution.postID; + attribution.commentCount = remoteAttribution.commentCount; + attribution.likeCount = remoteAttribution.likeCount; + attribution.attributionType = [self attributionTypeFromTaxonomies:remoteAttribution.taxonomies]; + return attribution; +} + ++ (NSString *)attributionTypeFromTaxonomies:(NSArray *)taxonomies +{ + if ([taxonomies containsObject:SourceAttributionSiteTaxonomy]) { + return SourcePostAttributionTypeSite; + } + + if ([taxonomies containsObject:SourceAttributionImageTaxonomy] || + [taxonomies containsObject:SourceAttributionQuoteTaxonomy] || + [taxonomies containsObject:SourceAttributionStandardTaxonomy] ) { + return SourcePostAttributionTypePost; + } + + return nil; +} - (BOOL)isCrossPost { return self.crossPostMeta != nil; } +- (BOOL)isAtomic +{ + return self.isBlogAtomic; +} + - (BOOL)isPrivate { return self.isBlogPrivate; } +- (BOOL)isP2Type +{ + NSInteger orgID = [self.organizationID intValue]; + return orgID == SiteOrganizationTypeP2 || orgID == SiteOrganizationTypeAutomattic; +} + - (NSString *)authorString { if ([self.authorDisplayName length] > 0) { @@ -187,6 +371,17 @@ - (NSString *)titleForDisplay return title; } +- (NSArray <NSString *> *)tagsForDisplay +{ + if (self.tags.length <= 0) { + return @[]; + } + + NSArray *tags = [self.tags componentsSeparatedByString:@", "]; + + return [tags sortedArrayUsingSelector:@selector(localizedCompare:)]; +} + - (NSString *)authorForDisplay { return [self authorString]; @@ -285,6 +480,11 @@ - (NSString *)siteURLForDisplay return self.blogURL; } +- (NSString *)siteHostNameForDisplay +{ + return self.blogURL.hostname; +} + - (NSString *)crossPostOriginSiteURLForDisplay { return self.crossPostMeta.siteURL; @@ -310,4 +510,15 @@ - (NSDictionary *)railcarDictionary return nil; } +- (void) didSave { + [super didSave]; + + // A ReaderCard can have either a post, or a list of topics, but not both. + // Since this card has a post, we can confidently set `topics` to NULL. + if ([self respondsToSelector:@selector(card)] && self.card.count > 0) { + self.card.allObjects[0].topics = NULL; + [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; + } +} + @end diff --git a/WordPress/Classes/Models/ReaderPostContentProvider.h b/WordPress/Classes/Models/ReaderPostContentProvider.h index 5f6680181164..cfd8891374db 100644 --- a/WordPress/Classes/Models/ReaderPostContentProvider.h +++ b/WordPress/Classes/Models/ReaderPostContentProvider.h @@ -8,6 +8,7 @@ typedef NS_ENUM(NSUInteger, SourceAttributionStyle) { }; @protocol ReaderPostContentProvider <PostContentProvider> +- (NSNumber *)siteID; - (NSURL *)siteIconForDisplayOfSize:(NSInteger)size; - (SourceAttributionStyle)sourceAttributionStyle; - (NSString *)sourceAuthorNameForDisplay; @@ -22,6 +23,7 @@ typedef NS_ENUM(NSUInteger, SourceAttributionStyle) { - (BOOL)commentsOpen; - (BOOL)isFollowing; - (BOOL)isLikesEnabled; +- (BOOL)isAtomic; - (BOOL)isPrivate; - (BOOL)isLiked; - (BOOL)isExternal; @@ -33,6 +35,7 @@ typedef NS_ENUM(NSUInteger, SourceAttributionStyle) { - (NSNumber *)wordCount; - (NSString *)siteURLForDisplay; +- (NSString *)siteHostNameForDisplay; - (NSString *)crossPostOriginSiteURLForDisplay; - (BOOL)isCommentCrossPost; diff --git a/WordPress/Classes/Models/ReaderSaveForLaterTopic.swift b/WordPress/Classes/Models/ReaderSaveForLaterTopic.swift deleted file mode 100644 index 67b8f8b4b3e0..000000000000 --- a/WordPress/Classes/Models/ReaderSaveForLaterTopic.swift +++ /dev/null @@ -1,30 +0,0 @@ -/// Plese do not review this class. This is basically a mock at the moment. It models a mock topic, so that I can test that the topic gets rendered in the UI -final class ReaderSaveForLaterTopic: ReaderAbstractTopic { - init() { - let managedObjectContext = ReaderSaveForLaterTopic.setUpInMemoryManagedObjectContext() - let entity = NSEntityDescription.entity(forEntityName: "ReaderDefaultTopic", in: managedObjectContext) - super.init(entity: entity!, insertInto: managedObjectContext) - } - - override open class var TopicType: String { - return "saveForLater" - } - - /// TODO. This function will have to go away - static func setUpInMemoryManagedObjectContext() -> NSManagedObjectContext { - let managedObjectModel = NSManagedObjectModel.mergedModel(from: [Bundle.main])! - - let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: managedObjectModel) - - do { - try persistentStoreCoordinator.addPersistentStore(ofType: NSInMemoryStoreType, configurationName: nil, at: nil, options: nil) - } catch { - print("Adding in-memory persistent store failed") - } - - let managedObjectContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - managedObjectContext.persistentStoreCoordinator = persistentStoreCoordinator - - return managedObjectContext - } -} diff --git a/WordPress/Classes/Models/ReaderSiteInfoSubscriptions.swift b/WordPress/Classes/Models/ReaderSiteInfoSubscriptions.swift index 13f2c30faf30..db9647e13975 100644 --- a/WordPress/Classes/Models/ReaderSiteInfoSubscriptions.swift +++ b/WordPress/Classes/Models/ReaderSiteInfoSubscriptions.swift @@ -5,6 +5,22 @@ import CoreData @objc public class ReaderSiteInfoSubscriptionPost: NSManagedObject { @NSManaged open var siteTopic: ReaderSiteTopic @NSManaged open var sendPosts: Bool + + class func createOrUpdate(from remoteSiteInfo: RemoteReaderSiteInfo, topic: ReaderSiteTopic, context: NSManagedObjectContext) -> ReaderSiteInfoSubscriptionPost? { + guard let postSubscription = remoteSiteInfo.postSubscription, postSubscription.wp_isValidObject() else { + return nil + } + + var subscription = topic.postSubscription + if subscription?.wp_isValidObject() == false { + subscription = ReaderSiteInfoSubscriptionPost(context: context) + } + + subscription?.siteTopic = topic + subscription?.sendPosts = postSubscription.sendPosts + + return subscription + } } @@ -13,4 +29,22 @@ import CoreData @NSManaged open var sendPosts: Bool @NSManaged open var sendComments: Bool @NSManaged open var postDeliveryFrequency: String + + class func createOrUpdate(from remoteSiteInfo: RemoteReaderSiteInfo, topic: ReaderSiteTopic, context: NSManagedObjectContext) -> ReaderSiteInfoSubscriptionEmail? { + guard let emailSubscription = remoteSiteInfo.emailSubscription, emailSubscription.wp_isValidObject() else { + return nil + } + + var subscription = topic.emailSubscription + if subscription?.wp_isValidObject() == false { + subscription = ReaderSiteInfoSubscriptionEmail(context: context) + } + + subscription?.siteTopic = topic + subscription?.sendPosts = emailSubscription.sendPosts + subscription?.sendComments = emailSubscription.sendComments + subscription?.postDeliveryFrequency = emailSubscription.postDeliveryFrequency + + return subscription + } } diff --git a/WordPress/Classes/Models/ReaderSiteTopic+Lookup.swift b/WordPress/Classes/Models/ReaderSiteTopic+Lookup.swift new file mode 100644 index 000000000000..85cddf750c88 --- /dev/null +++ b/WordPress/Classes/Models/ReaderSiteTopic+Lookup.swift @@ -0,0 +1,58 @@ +import Foundation + +extension ReaderSiteTopic { + + /// Find a site topic by its site id + /// + /// - Parameter siteID: The site id of the topic + static func lookup(withSiteID siteID: NSNumber, in context: NSManagedObjectContext) throws -> ReaderSiteTopic? { + let request = NSFetchRequest<ReaderSiteTopic>(entityName: ReaderSiteTopic.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "siteID = %@", siteID) + request.fetchLimit = 1 + return try context.fetch(request).first + } + + /// Find a site topic by its site id + /// + /// - Parameter siteID: The site id of the topic + @objc(lookupWithSiteID:inContext:) + static func objc_lookup(withSiteID siteID: NSNumber, in context: NSManagedObjectContext) -> ReaderSiteTopic? { + try? lookup(withSiteID: siteID, in: context) + } + + /// Find a site topic by its feed id + /// + /// - Parameter feedID: The feed id of the topic + static func lookup(withFeedID feedID: NSNumber, in context: NSManagedObjectContext) throws -> ReaderSiteTopic? { + let request = NSFetchRequest<ReaderSiteTopic>(entityName: ReaderSiteTopic.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "feedID = %@", feedID) + request.fetchLimit = 1 + return try context.fetch(request).first + } + + /// Find a site topic by its feed id + /// + /// - Parameter feedID: The feed id of the topic + @objc(lookupWithFeedID:inContext:) + static func objc_lookup(withFeedID feedID: NSNumber, in context: NSManagedObjectContext) -> ReaderSiteTopic? { + try? lookup(withFeedID: feedID, in: context) + } + + /// Find a site topic by its feed URL + /// + /// - Parameter feedURL: The feed URL of the topic + static func lookup(withFeedURL feedURL: String, in context: NSManagedObjectContext) throws -> ReaderSiteTopic? { + let request = NSFetchRequest<ReaderSiteTopic>(entityName: ReaderSiteTopic.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "feedURL = %@", feedURL) + request.fetchLimit = 1 + return try context.fetch(request).first + } + + /// Find a site topic by its feed URL + /// + /// - Parameter feedURL: The feed URL of the topic + @objc(lookupWithFeedURL:inContext:) + static func objc_lookup(withFeedURL feedURL: String, in context: NSManagedObjectContext) -> ReaderSiteTopic? { + try? lookup(withFeedURL: feedURL, in: context) + } +} diff --git a/WordPress/Classes/Models/ReaderSiteTopic.swift b/WordPress/Classes/Models/ReaderSiteTopic.swift index 9952f8d3a223..48f0cc32206f 100644 --- a/WordPress/Classes/Models/ReaderSiteTopic.swift +++ b/WordPress/Classes/Models/ReaderSiteTopic.swift @@ -11,12 +11,15 @@ import Foundation @NSManaged open var isJetpack: Bool @NSManaged open var isPrivate: Bool @NSManaged open var isVisible: Bool + @NSManaged open var organizationID: Int @NSManaged open var postCount: NSNumber @NSManaged open var siteBlavatar: String @NSManaged open var siteDescription: String @NSManaged open var siteID: NSNumber @NSManaged open var siteURL: String @NSManaged open var subscriberCount: NSNumber + @NSManaged open var unseenCount: Int + @NSManaged open var cards: NSOrderedSet? override open class var TopicType: String { return "site" @@ -28,6 +31,14 @@ import Foundation } } + var organizationType: SiteOrganizationType { + SiteOrganizationType(rawValue: organizationID) ?? .none + } + + var isP2Type: Bool { + return organizationType == .p2 || organizationType == .automattic + } + @objc open var blogNameToDisplay: String { return posts.first?.blogNameForDisplay() ?? title } @@ -35,4 +46,48 @@ import Foundation @objc open var isSubscribedForPostNotifications: Bool { return postSubscription?.sendPosts ?? false } + + + /// Creates a new ReaderTagTopic object from a RemoteReaderInterest + convenience init(remoteInfo: RemoteReaderSiteInfo, context: NSManagedObjectContext) { + self.init(context: context) + + feedID = remoteInfo.feedID ?? 0 + feedURL = remoteInfo.feedURL ?? "" + following = remoteInfo.isFollowing + isJetpack = remoteInfo.isJetpack + isPrivate = remoteInfo.isPrivate + isVisible = remoteInfo.isVisible + organizationID = remoteInfo.organizationID?.intValue ?? 0 + path = remoteInfo.postsEndpoint ?? remoteInfo.endpointPath ?? "" + postCount = remoteInfo.postCount ?? 0 + showInMenu = false + siteBlavatar = remoteInfo.siteBlavatar ?? "" + siteDescription = remoteInfo.siteDescription ?? "" + siteID = remoteInfo.siteID ?? 0 + siteURL = remoteInfo.siteURL ?? "" + subscriberCount = remoteInfo.subscriberCount ?? 0 + title = remoteInfo.siteName ?? "" + type = Self.TopicType + + postSubscription = ReaderSiteInfoSubscriptionPost.createOrUpdate(from: remoteInfo, topic: self, context: context) + emailSubscription = ReaderSiteInfoSubscriptionEmail.createOrUpdate(from: remoteInfo, topic: self, context: context) + } + + class func createIfNeeded(from remoteInfo: RemoteReaderSiteInfo, context: NSManagedObjectContext) -> ReaderSiteTopic { + guard let path = remoteInfo.postsEndpoint ?? remoteInfo.endpointPath else { + return ReaderSiteTopic(remoteInfo: remoteInfo, context: context) + } + + let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: ReaderAbstractTopic.classNameWithoutNamespaces()) + fetchRequest.predicate = NSPredicate(format: "path = %@ OR path ENDSWITH %@", path, path) + + let topics = try? context.fetch(fetchRequest) as? [ReaderSiteTopic] + + guard let topic = topics?.first else { + return ReaderSiteTopic(remoteInfo: remoteInfo, context: context) + } + + return topic + } } diff --git a/WordPress/Classes/Models/ReaderTagTopic+Lookup.swift b/WordPress/Classes/Models/ReaderTagTopic+Lookup.swift new file mode 100644 index 000000000000..07229302b0d0 --- /dev/null +++ b/WordPress/Classes/Models/ReaderTagTopic+Lookup.swift @@ -0,0 +1,51 @@ +import Foundation + +extension ReaderTagTopic { + + /// Find an existing topic with the specified slug. + /// + /// - Parameter slug: The slug of the topic to find in core data. + /// - Returns: A matching `ReaderTagTopic` instance or nil. + static func lookup(withSlug slug: String, in context: NSManagedObjectContext) throws -> ReaderTagTopic? { + let request = NSFetchRequest<ReaderTagTopic>(entityName: ReaderTagTopic.classNameWithoutNamespaces()) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "slug = %@", slug) + request.sortDescriptors = [ + NSSortDescriptor(key: "title", ascending: true) + ] + return try context.fetch(request).first + } + + /// Find an existing topic with the specified slug. + /// + /// - Parameter slug: The slug of the topic to find in core data. + /// - Returns: A matching `ReaderTagTopic` instance or nil. + @objc(lookupWithSlug:inContext:) + static func objc_lookup(withSlug slug: String, in context: NSManagedObjectContext) -> ReaderTagTopic? { + try? lookup(withSlug: slug, in: context) + } + + /// Find an existing topic with the specified topicID. + /// + /// - Parameter tagID: The tag id of the topic to find in core data. + /// - Returns: A matching `ReaderTagTopic` instance or nil. + static func lookup(withTagID tagID: NSNumber, in context: NSManagedObjectContext) throws -> ReaderTagTopic? { + let request = NSFetchRequest<ReaderTagTopic>(entityName: ReaderTagTopic.classNameWithoutNamespaces()) + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "tagID = %@", tagID) + request.sortDescriptors = [ + NSSortDescriptor(key: "title", ascending: true) + ] + return try context.fetch(request).first + } + + /// Find an existing topic with the specified topicID. + /// + /// - Parameter tagID: The tag id of the topic to find in core data. + /// - Returns: A matching `ReaderTagTopic` instance or nil. + @objc(lookupWithTagID:inContext:) + static func objc_lookup(withTagID tagID: NSNumber, in context: NSManagedObjectContext) -> ReaderTagTopic? { + try? lookup(withTagID: tagID, in: context) + } + +} diff --git a/WordPress/Classes/Models/ReaderTagTopic.swift b/WordPress/Classes/Models/ReaderTagTopic.swift index ec6fa6ef45de..110b5a96f5c2 100644 --- a/WordPress/Classes/Models/ReaderTagTopic.swift +++ b/WordPress/Classes/Models/ReaderTagTopic.swift @@ -4,8 +4,44 @@ import Foundation @NSManaged open var isRecommended: Bool @NSManaged open var slug: String @NSManaged open var tagID: NSNumber + @NSManaged open var cards: NSOrderedSet? override open class var TopicType: String { return "tag" } + + // MARK: - Logged Out Helpers + + /// The tagID used if an interest was added locally and not sync'd with the server + class var loggedOutTagID: NSNumber { + return NSNotFound as NSNumber + } + + /// Creates a new ReaderTagTopic object from a RemoteReaderInterest + convenience init(remoteInterest: RemoteReaderInterest, context: NSManagedObjectContext, isFollowing: Bool = false) { + self.init(context: context) + + title = remoteInterest.title + slug = remoteInterest.slug + tagID = Self.loggedOutTagID + type = Self.TopicType + following = isFollowing + showInMenu = true + } + + /// Returns an existing ReaderTagTopic or creates a new one based on remote interest + /// If an existing topic is returned, the title will be updated with the remote interest + class func createOrUpdateIfNeeded(from remoteInterest: RemoteReaderInterest, context: NSManagedObjectContext) -> ReaderTagTopic { + let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: self.classNameWithoutNamespaces()) + fetchRequest.predicate = NSPredicate(format: "slug = %@", remoteInterest.slug) + let topics = try? context.fetch(fetchRequest) as? [ReaderTagTopic] + + guard let topic = topics?.first else { + return ReaderTagTopic(remoteInterest: remoteInterest, context: context) + } + + topic.title = remoteInterest.title + + return topic + } } diff --git a/WordPress/Classes/Models/ReaderTeamTopic.swift b/WordPress/Classes/Models/ReaderTeamTopic.swift index a28b0e039ada..b821f85c129f 100644 --- a/WordPress/Classes/Models/ReaderTeamTopic.swift +++ b/WordPress/Classes/Models/ReaderTeamTopic.swift @@ -2,22 +2,20 @@ import Foundation @objc open class ReaderTeamTopic: ReaderAbstractTopic { @NSManaged open var slug: String + @NSManaged open var organizationID: Int override open class var TopicType: String { - return "team" + return "organization" } - @objc open var icon: UIImage? { - guard bundledTeamIcons.contains(slug) else { - return nil - } - - return UIImage(named: slug) + var shownTrackEvent: WPAnalyticsEvent { + return slug == ReaderTeamTopic.a8cSlug ? .readerA8CShown : .readerP2Shown } - fileprivate let bundledTeamIcons: [String] = [ - ReaderTeamTopic.a8cTeamSlug - ] + var organizationType: SiteOrganizationType { + return SiteOrganizationType(rawValue: organizationID) ?? .none + } - static let a8cTeamSlug = "a8c" + static let a8cSlug = "a8c" + static let p2Slug = "p2" } diff --git a/WordPress/Classes/Models/Role.swift b/WordPress/Classes/Models/Role.swift index cdb328882dad..9bd0615bea19 100644 --- a/WordPress/Classes/Models/Role.swift +++ b/WordPress/Classes/Models/Role.swift @@ -12,6 +12,14 @@ extension Role { func toUnmanaged() -> RemoteRole { return RemoteRole(slug: slug, name: name) } + + static func lookup(withBlogID blogID: NSManagedObjectID, slug: String, in context: NSManagedObjectContext) throws -> Role? { + guard let blog = try context.existingObject(with: blogID) as? Blog else { + return nil + } + let predicate = NSPredicate(format: "slug = %@ AND blog = %@", slug, blog) + return context.firstObject(ofType: Role.self, matching: predicate) + } } extension Role { diff --git a/WordPress/Classes/Models/SharingButton+Lookup.swift b/WordPress/Classes/Models/SharingButton+Lookup.swift new file mode 100644 index 000000000000..df75928bd2b0 --- /dev/null +++ b/WordPress/Classes/Models/SharingButton+Lookup.swift @@ -0,0 +1,29 @@ +extension SharingButton { + + /// Returns an array of all cached `SharingButtons` objects. + /// + /// - Returns: An array of `SharingButton`s. The array is empty if no objects are cached. + /// + @objc(allSharingButtonsForBlog:inContext:error:) + static func allSharingButtons(for blog: Blog, in context: NSManagedObjectContext) throws -> [SharingButton] { + let request = NSFetchRequest<SharingButton>(entityName: SharingButton.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "blog = %@", blog) + request.sortDescriptors = [NSSortDescriptor(key: "order", ascending: true)] + return try context.fetch(request) + } + + /// Finds a cached `SharingButton` by its `buttonID` for the specified `Blog` + /// + /// - Parameters: + /// - buttonID: The button ID of the `SharingButton`. + /// - blog: The blog that owns the sharing button. + /// + /// - Returns: The requested `SharingButton` or nil. + /// + static func lookupSharingButton(byID buttonID: String, for blog: Blog, in context: NSManagedObjectContext) throws -> SharingButton? { + let request = NSFetchRequest<SharingButton>(entityName: SharingButton.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "buttonID = %@ AND blog = %@", buttonID, blog) + return try context.fetch(request).first + } + +} diff --git a/WordPress/Classes/Models/SiteInformation.swift b/WordPress/Classes/Models/SiteInformation.swift index 0ddd2000dee1..d4f00ab9050f 100644 --- a/WordPress/Classes/Models/SiteInformation.swift +++ b/WordPress/Classes/Models/SiteInformation.swift @@ -1,6 +1,15 @@ struct SiteInformation { let title: String let tagLine: String? + + /// if title is nil, then the corresponding SiteInformation value is nil + init?(title: String?, tagLine: String?) { + guard let title = title else { + return nil + } + self.title = title + self.tagLine = tagLine + } } extension SiteInformation: Equatable { diff --git a/WordPress/Classes/Models/SiteSuggestion+CoreDataClass.swift b/WordPress/Classes/Models/SiteSuggestion+CoreDataClass.swift new file mode 100644 index 000000000000..fd7487bb82e1 --- /dev/null +++ b/WordPress/Classes/Models/SiteSuggestion+CoreDataClass.swift @@ -0,0 +1,34 @@ +import Foundation +import CoreData + +extension CodingUserInfoKey { + static let managedObjectContext = CodingUserInfoKey(rawValue: "managedObjectContext")! +} + +enum DecoderError: Error { + case missingManagedObjectContext +} + +@objc(SiteSuggestion) +public class SiteSuggestion: NSManagedObject, Decodable { + enum CodingKeys: String, CodingKey { + case title = "title" + case siteURL = "siteurl" + case subdomain = "subdomain" + case blavatarURL = "blavatar" + } + + required convenience public init(from decoder: Decoder) throws { + guard let managedObjectContext = decoder.userInfo[CodingUserInfoKey.managedObjectContext] as? NSManagedObjectContext else { + throw DecoderError.missingManagedObjectContext + } + + self.init(context: managedObjectContext) + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.title = try container.decode(String.self, forKey: .title) + self.siteURL = try? container.decode(URL.self, forKey: .siteURL) + self.subdomain = try container.decode(String.self, forKey: .subdomain) + self.blavatarURL = try? container.decode(URL.self, forKey: .blavatarURL) + } +} diff --git a/WordPress/Classes/Models/SiteSuggestion+CoreDataProperties.swift b/WordPress/Classes/Models/SiteSuggestion+CoreDataProperties.swift new file mode 100644 index 000000000000..5c9245936aba --- /dev/null +++ b/WordPress/Classes/Models/SiteSuggestion+CoreDataProperties.swift @@ -0,0 +1,17 @@ +import Foundation +import CoreData + + +extension SiteSuggestion { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<SiteSuggestion> { + return NSFetchRequest<SiteSuggestion>(entityName: "SiteSuggestion") + } + + @NSManaged public var title: String? + @NSManaged public var siteURL: URL? + @NSManaged public var subdomain: String? + @NSManaged public var blavatarURL: URL? + @NSManaged public var blog: Blog? + +} diff --git a/WordPress/Classes/Models/Suggestion.h b/WordPress/Classes/Models/Suggestion.h deleted file mode 100644 index 925cbabc99bd..000000000000 --- a/WordPress/Classes/Models/Suggestion.h +++ /dev/null @@ -1,16 +0,0 @@ -#import <Foundation/Foundation.h> - -typedef void(^SuggestionAvatarFetchSuccessBlock)(UIImage* image); - -@interface Suggestion : NSObject - -@property (nonatomic, strong) NSString *userLogin; -@property (nonatomic, strong) NSString *displayName; -@property (nonatomic, strong) NSURL *imageURL; - -+ (instancetype)suggestionFromDictionary:(NSDictionary *)dictionary; - -- (UIImage *)cachedAvatarWithSize:(CGSize)size; -- (void)fetchAvatarWithSize:(CGSize)size success:(SuggestionAvatarFetchSuccessBlock)success; - -@end diff --git a/WordPress/Classes/Models/Suggestion.m b/WordPress/Classes/Models/Suggestion.m deleted file mode 100644 index be9be5e3352c..000000000000 --- a/WordPress/Classes/Models/Suggestion.m +++ /dev/null @@ -1,46 +0,0 @@ -#import "Suggestion.h" -#import "WPAvatarSource.h" - -@implementation Suggestion - -+ (instancetype)suggestionFromDictionary:(NSDictionary *)dictionary { - Suggestion *suggestion = [Suggestion new]; - - suggestion.userLogin = [dictionary stringForKey:@"user_login"]; - suggestion.displayName = [dictionary stringForKey:@"display_name"]; - suggestion.imageURL = [NSURL URLWithString:[dictionary stringForKey:@"image_URL"]]; - - return suggestion; -} - -- (UIImage *)cachedAvatarWithSize:(CGSize)size -{ - NSString *hash; - WPAvatarSourceType type = [self avatarSourceTypeWithHash:&hash]; - if (!hash) { - return nil; - } - return [[WPAvatarSource sharedSource] cachedImageForAvatarHash:hash ofType:type withSize:size]; -} - -- (void)fetchAvatarWithSize:(CGSize)size success:(void (^)(UIImage *image))success -{ - NSString *hash; - WPAvatarSourceType type = [self avatarSourceTypeWithHash:&hash]; - - if (hash) { - [[WPAvatarSource sharedSource] fetchImageForAvatarHash:hash ofType:type withSize:size success:success]; - } else if (success) { - success(nil); - } -} - -- (WPAvatarSourceType)avatarSourceTypeWithHash:(NSString **)hash -{ - if (self.imageURL) { - return [[WPAvatarSource sharedSource] parseURL:self.imageURL forAvatarHash:hash]; - } - return WPAvatarSourceTypeUnknown; -} - -@end diff --git a/WordPress/Classes/Models/Suggestion.swift b/WordPress/Classes/Models/Suggestion.swift new file mode 100644 index 000000000000..a598adb40dcd --- /dev/null +++ b/WordPress/Classes/Models/Suggestion.swift @@ -0,0 +1,27 @@ +import Foundation + +@objcMembers public class Suggestion: NSObject { + let userLogin: String? + let displayName: String? + let imageURL: URL? + + init?(dictionary: [String: Any]) { + + let userLogin = dictionary["user_login"] as? String + let displayName = dictionary["display_name"] as? String + + // A user suggestion is only valid when at least one of these is present. + guard userLogin != nil || displayName != nil else { + return nil + } + + self.userLogin = userLogin + self.displayName = displayName + + if let imageURLString = dictionary["image_URL"] as? String { + imageURL = URL(string: imageURLString) + } else { + imageURL = nil + } + } +} diff --git a/WordPress/Classes/Models/Theme.m b/WordPress/Classes/Models/Theme.m index 61c45381a421..d8e3586fed76 100644 --- a/WordPress/Classes/Models/Theme.m +++ b/WordPress/Classes/Models/Theme.m @@ -1,6 +1,6 @@ #import "Theme.h" #import "Blog.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "WPAccount.h" #import "AccountService.h" #import "WordPress-Swift.h" diff --git a/WordPress/Classes/Models/UserSettings.swift b/WordPress/Classes/Models/UserSettings.swift new file mode 100644 index 000000000000..88638663d12d --- /dev/null +++ b/WordPress/Classes/Models/UserSettings.swift @@ -0,0 +1,90 @@ +import Foundation + +struct UserSettings { + /// Stores all `UserSettings` keys. + /// + /// The additional level of indirection allows these keys to be retrieved from tests. + /// + /// **IMPORTANT NOTE:** + /// + /// Any change to these keys is a breaking change without some kind of migration. + /// It's probably best never to change them. + enum Keys: String, CaseIterable { + case crashLoggingOptOutKey = "crashlytics_opt_out" + case forceCrashLoggingKey = "force-crash-logging" + case defaultDotComUUIDKey = "AccountDefaultDotcomUUID" + } + + @UserDefault(Keys.crashLoggingOptOutKey.rawValue, defaultValue: false) + static var userHasOptedOutOfCrashLogging: Bool + + @UserDefault(Keys.forceCrashLoggingKey.rawValue, defaultValue: false) + static var userHasForcedCrashLoggingEnabled: Bool + + @NullableUserDefault(Keys.defaultDotComUUIDKey.rawValue) + static var defaultDotComUUID: String? + + /// Reset all UserSettings back to their defaults + static func reset() { + UserSettings.Keys.allCases.forEach { UserPersistentStoreFactory.instance().removeObject(forKey: $0.rawValue) } + } +} + +/// Objective-C Wrapper for UserSettings +@objc(UserSettings) +class ObjcCUserSettings: NSObject { + @objc + static var defaultDotComUUID: String? { + get { UserSettings.defaultDotComUUID } + set { UserSettings.defaultDotComUUID = newValue } + } + + @objc + static func reset() { + UserSettings.reset() + } +} + +/// A property wrapper for UserDefaults access +@propertyWrapper +struct UserDefault<T> { + let key: String + let defaultValue: T + + init(_ key: String, defaultValue: T) { + self.key = key + self.defaultValue = defaultValue + } + + var wrappedValue: T { + get { + return UserPersistentStoreFactory.instance().object(forKey: key) as? T ?? defaultValue + } + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: key) + } + } +} + +/// A property wrapper for optional UserDefaults that return `nil` by default +@propertyWrapper +struct NullableUserDefault<T> { + let key: String + + init(_ key: String) { + self.key = key + } + + var wrappedValue: T? { + get { + return UserPersistentStoreFactory.instance().object(forKey: key) as? T + } + set { + if let newValue = newValue { + UserPersistentStoreFactory.instance().set(newValue, forKey: key) + } else { + UserPersistentStoreFactory.instance().removeObject(forKey: key) + } + } + } +} diff --git a/WordPress/Classes/Models/UserSuggestion+Comparable.swift b/WordPress/Classes/Models/UserSuggestion+Comparable.swift new file mode 100644 index 000000000000..7d5188a68801 --- /dev/null +++ b/WordPress/Classes/Models/UserSuggestion+Comparable.swift @@ -0,0 +1,11 @@ +extension UserSuggestion: Comparable { + public static func < (lhs: UserSuggestion, rhs: UserSuggestion) -> Bool { + if let leftDisplayName = lhs.displayName, let rightDisplayName = rhs.displayName { + return leftDisplayName.localizedCaseInsensitiveCompare(rightDisplayName) == .orderedAscending + } else if let leftUsername = lhs.username, let rightUsername = rhs.username { + return leftUsername < rightUsername + } + + return false + } +} diff --git a/WordPress/Classes/Models/UserSuggestion+CoreDataClass.swift b/WordPress/Classes/Models/UserSuggestion+CoreDataClass.swift new file mode 100644 index 000000000000..3baceacd9cea --- /dev/null +++ b/WordPress/Classes/Models/UserSuggestion+CoreDataClass.swift @@ -0,0 +1,32 @@ +import Foundation +import CoreData + +@objc(UserSuggestion) +public class UserSuggestion: NSManagedObject { + + convenience init?(dictionary: [String: Any], context: NSManagedObjectContext) { + let userLoginValue = dictionary["user_login"] as? String + let displayNameValue = dictionary["display_name"] as? String + + // A user suggestion is only valid when it has an ID and at least user_login or display_name is present. + guard let id = dictionary["ID"] as? UInt, userLoginValue != nil || displayNameValue != nil else { + return nil + } + + guard let entityDescription = NSEntityDescription.entity(forEntityName: "UserSuggestion", in: context) else { + return nil + } + self.init(entity: entityDescription, insertInto: context) + + self.userID = NSNumber(value: id) + self.username = userLoginValue + self.displayName = displayNameValue + + if let imageURLString = dictionary["image_URL"] as? String { + imageURL = URL(string: imageURLString) + } else { + imageURL = nil + } + } + +} diff --git a/WordPress/Classes/Models/UserSuggestion+CoreDataProperties.swift b/WordPress/Classes/Models/UserSuggestion+CoreDataProperties.swift new file mode 100644 index 000000000000..005c62bd8482 --- /dev/null +++ b/WordPress/Classes/Models/UserSuggestion+CoreDataProperties.swift @@ -0,0 +1,16 @@ +import Foundation +import CoreData + + +extension UserSuggestion { + + @nonobjc public class func fetchRequest() -> NSFetchRequest<UserSuggestion> { + return NSFetchRequest<UserSuggestion>(entityName: "UserSuggestion") + } + + @NSManaged public var userID: NSNumber? + @NSManaged public var displayName: String? + @NSManaged public var imageURL: URL? + @NSManaged public var username: String? + @NSManaged public var blog: Blog? +} diff --git a/WordPress/Classes/Models/ValueTransformers.swift b/WordPress/Classes/Models/ValueTransformers.swift new file mode 100644 index 000000000000..af2e60fcc8eb --- /dev/null +++ b/WordPress/Classes/Models/ValueTransformers.swift @@ -0,0 +1,58 @@ +import Foundation + +extension ValueTransformer { + @objc + static func registerCustomTransformers() { + CoordinateValueTransformer.register() + NSErrorValueTransformer.register() + SetValueTransformer.register() + } +} + +@objc +final class CoordinateValueTransformer: NSSecureUnarchiveFromDataTransformer { + + static let name = NSValueTransformerName(rawValue: String(describing: CoordinateValueTransformer.self)) + + override static var allowedTopLevelClasses: [AnyClass] { + return [Coordinate.self] + } + + @objc + public static func register() { + let transformer = CoordinateValueTransformer() + ValueTransformer.setValueTransformer(transformer, forName: name) + } +} + +@objc +final class NSErrorValueTransformer: NSSecureUnarchiveFromDataTransformer { + + static let name = NSValueTransformerName(rawValue: String(describing: NSErrorValueTransformer.self)) + + override static var allowedTopLevelClasses: [AnyClass] { + return [NSError.self] + } + + @objc + public static func register() { + let transformer = NSErrorValueTransformer() + ValueTransformer.setValueTransformer(transformer, forName: name) + } +} + +@objc +final class SetValueTransformer: NSSecureUnarchiveFromDataTransformer { + + static let name = NSValueTransformerName(rawValue: String(describing: SetValueTransformer.self)) + + override static var allowedTopLevelClasses: [AnyClass] { + return [NSSet.self] + } + + @objc + public static func register() { + let transformer = SetValueTransformer() + ValueTransformer.setValueTransformer(transformer, forName: name) + } +} diff --git a/WordPress/Classes/Models/WPAccount+AccountSettings.swift b/WordPress/Classes/Models/WPAccount+AccountSettings.swift index 94334953dd75..2ee84d010466 100644 --- a/WordPress/Classes/Models/WPAccount+AccountSettings.swift +++ b/WordPress/Classes/Models/WPAccount+AccountSettings.swift @@ -12,8 +12,7 @@ extension WPAccount { case .displayName(let value): self.displayName = value case .primarySite(let value): - let service = BlogService(managedObjectContext: managedObjectContext!) - defaultBlog = service.blog(byBlogId: NSNumber(value: value)) + defaultBlog = try? Blog.lookup(withID: value, in: managedObjectContext!) default: break } diff --git a/WordPress/Classes/Models/WPAccount+DeduplicateBlogs.swift b/WordPress/Classes/Models/WPAccount+DeduplicateBlogs.swift new file mode 100644 index 000000000000..646418f635a0 --- /dev/null +++ b/WordPress/Classes/Models/WPAccount+DeduplicateBlogs.swift @@ -0,0 +1,62 @@ +import Foundation + +extension WPAccount { + /// Removes any duplicate blogs in the given account + /// + /// We consider a blog to be a duplicate of another if they have the same dotComID. + /// For each group of duplicate blogs, this will delete all but one, giving preference to + /// blogs that have local drafts. + /// + /// If there's more than one blog in each group with local drafts, those will be reassigned + /// to the remaining blog. + /// + @objc(deduplicateBlogs) + func deduplicateBlogs() { + let context = managedObjectContext! + // Group all the account blogs by ID so it's easier to find duplicates + let blogsById = Dictionary(grouping: blogs, by: { $0.dotComID?.intValue ?? 0 }) + // For any group with more than one blog, remove duplicates + for (blogID, group) in blogsById where group.count > 1 { + assert(blogID > 0, "There should not be a Blog without ID if it has an account") + guard blogID > 0 else { + DDLogError("Found one or more WordPress.com blogs without ID, skipping de-duplication") + continue + } + DDLogWarn("Found \(group.count - 1) duplicates for blog with ID \(blogID)") + deduplicate(group: group, in: context) + } + } + + private func deduplicate(group: [Blog], in context: NSManagedObjectContext) { + // If there's a blog with local drafts, we'll preserve that one, otherwise we pick up the first + // since we don't really care which blog to pick + let candidateIndex = group.firstIndex(where: { !localDrafts(for: $0).isEmpty }) ?? 0 + let candidate = group[candidateIndex] + + // We look through every other blog + for (index, blog) in group.enumerated() where index != candidateIndex { + // If there are other blogs with local drafts, we reassing them to the blog that + // is not going to be deleted + for draft in localDrafts(for: blog) { + DDLogInfo("Migrating local draft \(draft.postTitle ?? "<Untitled>") to de-duplicated blog") + draft.blog = candidate + } + // Once the drafts are moved (if any), we can safely delete the duplicate + DDLogInfo("Deleting duplicate blog \(blog.logDescription())") + context.delete(blog) + } + } + + private func localDrafts(for blog: Blog) -> [AbstractPost] { + // The original predicate from PostService.countPostsWithoutRemote() was: + // "postID = NULL OR postID <= 0" + // Swift optionals make things a bit more verbose, but this should be equivalent + return blog.posts?.filter({ (post) -> Bool in + if let postID = post.postID?.intValue, + postID > 0 { + return false + } + return true + }) ?? [] + } +} diff --git a/WordPress/Classes/Models/WPAccount+Lookup.swift b/WordPress/Classes/Models/WPAccount+Lookup.swift new file mode 100644 index 000000000000..1ba1629f8e73 --- /dev/null +++ b/WordPress/Classes/Models/WPAccount+Lookup.swift @@ -0,0 +1,164 @@ +import CoreData + +public extension WPAccount { + + // MARK: - Relationship Lookups + + /// Is this `WPAccount` object the default WordPress.com account? + /// + @objc + var isDefaultWordPressComAccount: Bool { + guard let uuid = UserSettings.defaultDotComUUID else { + return false + } + + return self.uuid == uuid + } + + /// Does this `WPAccount` object have any associated blogs? + /// + @objc + var hasBlogs: Bool { + return !blogs.isEmpty + } + + // MARK: - Object Lookups + + /// Returns the default WordPress.com account, if one exists + /// + /// The default WordPress.com account is the one used for Reader and Notifications. + /// + static func lookupDefaultWordPressComAccount(in context: NSManagedObjectContext) throws -> WPAccount? { + guard let uuid = UserSettings.defaultDotComUUID, !uuid.isEmpty else { + // No account, or no default account set. Clear the defaults key. + UserSettings.defaultDotComUUID = nil + return nil + } + + return try lookup(withUUIDString: uuid, in: context) + } + + /// Lookup a WPAccount by its local uuid + /// + /// - Parameters: + /// - uuidString: The UUID (in string form) associated with the account + /// - context: An NSManagedObjectContext containing the `WPAccount` object with the given `uuidString`. + /// - Returns: The `WPAccount` object associated with the given `uuidString`, if it exists. + /// + static func lookup(withUUIDString uuidString: String, in context: NSManagedObjectContext) throws -> WPAccount? { + let fetchRequest = NSFetchRequest<Self>(entityName: WPAccount.entityName()) + fetchRequest.predicate = NSPredicate(format: "uuid = %@", uuidString) + + guard let defaultAccount = try context.fetch(fetchRequest).first else { + return nil + } + + /// This was brought over from the `AccountService`, but can (and probably should) be moved to an accessor for the property + if let displayName = defaultAccount.displayName { + defaultAccount.displayName = displayName.stringByDecodingXMLCharacters() + } + + return defaultAccount + } + + /// Lookup a WPAccount with the specified username, if it exists + /// + /// - Parameters: + /// - username: The username associated with the account + /// - context: An NSManagedObjectContext containing the `WPAccount` object with the given `username`. + /// - Returns: The `WPAccount` object associated with the given `username`, if it exists. + /// + static func lookup(withUsername username: String, in context: NSManagedObjectContext) throws -> WPAccount? { + let fetchRequest = NSFetchRequest<Self>(entityName: WPAccount.entityName()) + fetchRequest.predicate = NSPredicate(format: "username = [c] %@ || email = [c] %@", username, username) + + guard let account = try context.fetch(fetchRequest).first else { + return nil + } + + return account + } + + /// Lookup a WPAccount with the specified userID, if it exists + /// + /// - Parameters: + /// - userID: The userID associated with the account + /// - context: An NSManagedObjectContext containing the `WPAccount` object with the given `userID`. + /// - Returns: The `WPAccount` object associated with the given `userID`, if it exists. + /// + static func lookup(withUserID userID: Int64, in context: NSManagedObjectContext) throws -> WPAccount? { + let fetchRequest = NSFetchRequest<Self>(entityName: WPAccount.entityName()) + fetchRequest.predicate = NSPredicate(format: "userID = %ld", userID) + + guard let account = try context.fetch(fetchRequest).first else { + return nil + } + + return account + } + + /// Lookup the total number of `WPAccount` objects in the given `context`. + /// + /// If none exist, this method returns `0`. + /// + /// - Parameters: + /// - context: An NSManagedObjectContext that may or may not contain `WPAccount` objects. + /// - Returns: The number of `WPAccount` objects in the given `context`. + /// + static func lookupNumberOfAccounts(in context: NSManagedObjectContext) throws -> Int { + let fetchRequest = NSFetchRequest<Self>(entityName: WPAccount.entityName()) + fetchRequest.includesSubentities = false + return try context.count(for: fetchRequest) + } + + /// Lookup all the `WPAccount` objects in the given `context`. + /// + /// If none exist, this method returns empty array. + /// + /// - Parameters: + /// - context: An NSManagedObjectContext that may or may not contain `WPAccount` objects. + /// - Returns: All `WPAccount` objects in the given `context`. + /// + static func lookupAllAccounts(in context: NSManagedObjectContext) throws -> [WPAccount] { + let fetchRequest = NSFetchRequest<Self>(entityName: WPAccount.entityName()) + return try context.fetch(fetchRequest) + } + + // MARK: - Objective-C Compatibility Wrappers + + /// An Objective-C wrapper around the `lookupDefaultWordPressComAccount` method. + /// + /// Prefer using `lookupDefaultWordPressComAccount` directly + @available(swift, obsoleted: 1.0) + @objc(lookupDefaultWordPressComAccountInContext:) + static func objc_lookupDefaultWordPressComAccount(in context: NSManagedObjectContext) -> WPAccount? { + return try? lookupDefaultWordPressComAccount(in: context) + } + + /// An Objective-C wrapper around the `lookupDefaultWordPressComAccount` method. + /// + /// Prefer using `lookupDefaultWordPressComAccount` directly + @available(swift, obsoleted: 1.0) + @objc(lookupNumberOfAccountsInContext:) + static func objc_lookupNumberOfAccounts(in context: NSManagedObjectContext) -> Int { + return (try? lookupNumberOfAccounts(in: context)) ?? 0 + } + + /// An Objective-C wrapper around the `lookupAllAccounts(in:)` method. + /// + /// Prefer using `lookupAllAccounts(in:)` directly + @available(swift, obsoleted: 1.0) + @objc(lookupAllAccountsInContext:) + static func objc_lookupAllAccounts(in context: NSManagedObjectContext) -> [WPAccount] { + return (try? lookupAllAccounts(in: context)) ?? [] + } + + /// An Objective-C wrapper around the `lookup(withUsername:context:)` method. + /// + /// Prefer using `lookup(withUsername:context:)` directly + @available(swift, obsoleted: 1.0) + @objc(lookupWithUsername:context:) + static func objc_lookupWithUsername(username: String, context: NSManagedObjectContext) -> WPAccount? { + return try? lookup(withUsername: username, in: context) + } +} diff --git a/WordPress/Classes/Models/WPAccount.h b/WordPress/Classes/Models/WPAccount.h index 110d41c98b12..a1ba030db6e4 100644 --- a/WordPress/Classes/Models/WPAccount.h +++ b/WordPress/Classes/Models/WPAccount.h @@ -48,5 +48,7 @@ - (void)removeBlogsObject:(Blog *)value; - (void)addBlogs:(NSSet *)values; - (void)removeBlogs:(NSSet *)values; ++ (NSString *)tokenForUsername:(NSString *)username; +- (BOOL)hasAtomicSite; @end diff --git a/WordPress/Classes/Models/WPAccount.m b/WordPress/Classes/Models/WPAccount.m index a677248592d5..b8a6fe709c3d 100644 --- a/WordPress/Classes/Models/WPAccount.m +++ b/WordPress/Classes/Models/WPAccount.m @@ -1,9 +1,6 @@ #import "WPAccount.h" -#import "SFHFKeychainUtils.h" #import "WordPress-Swift.h" -static NSString * const WordPressComOAuthKeychainServiceName = @"public-api.wordpress.com"; - @interface WPAccount () @property (nonatomic, strong, readwrite) WordPressComRestApi *wordPressComRestApi; @@ -57,19 +54,19 @@ + (NSString *)entityName - (void)setUsername:(NSString *)username { NSString *previousUsername = self.username; - + BOOL usernameChanged = ![previousUsername isEqualToString:username]; NSString *authToken = nil; - + if (usernameChanged) { authToken = self.authToken; self.authToken = nil; } - + [self willChangeValueForKey:@"username"]; [self setPrimitiveValue:username forKey:@"username"]; [self didChangeValueForKey:@"username"]; - + if (usernameChanged) { self.authToken = authToken; } @@ -77,14 +74,7 @@ - (void)setUsername:(NSString *)username - (NSString *)authToken { - NSError *error = nil; - NSString *authToken = [SFHFKeychainUtils getPasswordForUsername:self.username andServiceName:WordPressComOAuthKeychainServiceName error:&error]; - - if (error) { - DDLogError(@"Error while retrieving WordPressComOAuthKeychainServiceName token: %@", error); - } - - return authToken; + return [WPAccount tokenForUsername:self.username]; } - (void)setAuthToken:(NSString *)authToken @@ -93,10 +83,11 @@ - (void)setAuthToken:(NSString *)authToken NSError *error = nil; [SFHFKeychainUtils storeUsername:self.username andPassword:authToken - forServiceName:WordPressComOAuthKeychainServiceName + forServiceName:[WPAccount authKeychainServiceName] + accessGroup:nil updateExisting:YES error:&error]; - + if (error) { DDLogError(@"Error while updating WordPressComOAuthKeychainServiceName token: %@", error); } @@ -104,13 +95,14 @@ - (void)setAuthToken:(NSString *)authToken } else { NSError *error = nil; [SFHFKeychainUtils deleteItemForUsername:self.username - andServiceName:WordPressComOAuthKeychainServiceName + andServiceName:[WPAccount authKeychainServiceName] + accessGroup:nil error:&error]; if (error) { DDLogError(@"Error while deleting WordPressComOAuthKeychainServiceName token: %@", error); } } - + // Make sure to release any RestAPI alloc'ed, since it might have an invalid token _wordPressComRestApi = nil; } @@ -125,10 +117,46 @@ - (NSArray *)visibleBlogs return [visibleBlogs sortedArrayUsingDescriptors:@[descriptor]]; } -- (BOOL)isDefault { - AccountService *service = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext]; - WPAccount *defaultAccount = [service defaultWordPressComAccount]; - return [defaultAccount isEqual:self]; +- (BOOL)hasAtomicSite { + for (Blog *blog in self.blogs) { + if ([blog isAtomic]) { + return YES; + } + } + return NO; +} + +#pragma mark - Static methods + ++ (NSString *)tokenForUsername:(NSString *)username +{ + NSError *error = nil; + [WPAccount migrateAuthKeyForUsername:username]; + NSString *authToken = [SFHFKeychainUtils getPasswordForUsername:username + andServiceName:[WPAccount authKeychainServiceName] + accessGroup:nil + error:&error]; + if (error) { + DDLogError(@"Error while retrieving WordPressComOAuthKeychainServiceName token: %@", error); + } + + return authToken; +} + ++ (void)migrateAuthKeyForUsername:(NSString *)username +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + if ([AppConfiguration isJetpack]) { + SharedDataIssueSolver *sharedDataIssueSolver = [SharedDataIssueSolver instance]; + [sharedDataIssueSolver migrateAuthKeyFor:username]; + } + }); +} + ++ (NSString *)authKeychainServiceName +{ + return [AppConstants authKeychainServiceName]; } #pragma mark - API Helpers @@ -144,7 +172,7 @@ - (WordPressComRestApi *)wordPressComRestApi [_wordPressComRestApi setInvalidTokenHandler:^{ [weakSelf setAuthToken:nil]; [WordPressAuthenticationManager showSigninForWPComFixingAuthToken]; - if (weakSelf.isDefault) { + if (weakSelf.isDefaultWordPressComAccount) { [[NSNotificationCenter defaultCenter] postNotificationName:WPAccountDefaultWordPressComAccountChangedNotification object:weakSelf]; } }]; diff --git a/WordPress/Classes/Models/WPCommentContentViewProvider.h b/WordPress/Classes/Models/WPCommentContentViewProvider.h deleted file mode 100644 index cefc561b57ba..000000000000 --- a/WordPress/Classes/Models/WPCommentContentViewProvider.h +++ /dev/null @@ -1,11 +0,0 @@ -#import <Foundation/Foundation.h> -#import "PostContentProvider.h" - -@protocol WPCommentContentViewProvider <PostContentProvider> - -- (BOOL)isLiked; -- (BOOL)authorIsPostAuthor; -- (NSNumber *)numberOfLikes; -- (BOOL)isPrivateContent; - -@end diff --git a/WordPress/Classes/Networking/MediaHost+AbstractPost.swift b/WordPress/Classes/Networking/MediaHost+AbstractPost.swift new file mode 100644 index 000000000000..57a656ab373c --- /dev/null +++ b/WordPress/Classes/Networking/MediaHost+AbstractPost.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Extends `MediaRequestAuthenticator.MediaHost` so that we can easily +/// initialize it from a given `AbstractPost`. +/// +extension MediaHost { + enum AbstractPostError: Swift.Error { + case baseInitializerError(error: BlogError, post: AbstractPost) + } + + init(with post: AbstractPost, failure: (AbstractPostError) -> ()) { + self.init( + with: post.blog, + failure: { error in + // We just associate a post with the underlying error for simpler debugging. + failure(AbstractPostError.baseInitializerError( + error: error, + post: post)) + }) + } +} diff --git a/WordPress/Classes/Networking/MediaHost+Blog.swift b/WordPress/Classes/Networking/MediaHost+Blog.swift new file mode 100644 index 000000000000..e6366f3d6769 --- /dev/null +++ b/WordPress/Classes/Networking/MediaHost+Blog.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Extends `MediaRequestAuthenticator.MediaHost` so that we can easily +/// initialize it from a given `Blog`. +/// +extension MediaHost { + enum BlogError: Swift.Error { + case baseInitializerError(error: Error, blog: Blog) + } + + init(with blog: Blog, failure: (BlogError) -> ()) { + let isAtomic = blog.isAtomic() + self.init(with: blog, isAtomic: isAtomic, failure: failure) + } + + init(with blog: Blog, isAtomic: Bool, failure: (BlogError) -> ()) { + self.init(isAccessibleThroughWPCom: blog.isAccessibleThroughWPCom(), + isPrivate: blog.isPrivate(), + isAtomic: isAtomic, + siteID: blog.dotComID?.intValue, + username: blog.usernameForSite, + authToken: blog.authToken, + failure: { error in + // We just associate a blog with the underlying error for simpler debugging. + failure(BlogError.baseInitializerError( + error: error, + blog: blog)) + }) + } +} diff --git a/WordPress/Classes/Networking/MediaHost+ReaderPostContentProvider.swift b/WordPress/Classes/Networking/MediaHost+ReaderPostContentProvider.swift new file mode 100644 index 000000000000..7af84a15507b --- /dev/null +++ b/WordPress/Classes/Networking/MediaHost+ReaderPostContentProvider.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Extends `MediaRequestAuthenticator.MediaHost` so that we can easily +/// initialize it from a given `Blog`. +/// +extension MediaHost { + enum ReaderPostContentProviderError: Swift.Error { + case noDefaultWordPressComAccount + case baseInitializerError(error: Error, readerPostContentProvider: ReaderPostContentProvider) + } + + init(with readerPostContentProvider: ReaderPostContentProvider, failure: (ReaderPostContentProviderError) -> ()) { + let isAccessibleThroughWPCom = readerPostContentProvider.isWPCom() || readerPostContentProvider.isJetpack() + + // This is the only way in which we can obtain the username and authToken here. + // It'd be nice if all data was associated with an account instead, for transparency + // and cleanliness of the code - but this'll have to do for now. + + // We allow a nil account in case the user connected only self-hosted sites. + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) + let username = account?.username + let authToken = account?.authToken + + self.init(isAccessibleThroughWPCom: isAccessibleThroughWPCom, + isPrivate: readerPostContentProvider.isPrivate(), + isAtomic: readerPostContentProvider.isAtomic(), + siteID: readerPostContentProvider.siteID()?.intValue, + username: username, + authToken: authToken, + failure: { error in + // We just associate a ReaderPostContentProvider with the underlying error for simpler debugging. + failure(ReaderPostContentProviderError.baseInitializerError( + error: error, + readerPostContentProvider: readerPostContentProvider)) + }) + } +} diff --git a/WordPress/Classes/Networking/MediaHost.swift b/WordPress/Classes/Networking/MediaHost.swift new file mode 100644 index 000000000000..12741a6396c7 --- /dev/null +++ b/WordPress/Classes/Networking/MediaHost.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Defines a media host for request authentication purposes. +/// +enum MediaHost: Equatable { + case publicSite + case publicWPComSite + case privateSelfHostedSite + case privateWPComSite(authToken: String) + case privateAtomicWPComSite(siteID: Int, username: String, authToken: String) + + enum Error: Swift.Error { + case wpComWithoutSiteID + case wpComPrivateSiteWithoutAuthToken + case wpComPrivateSiteWithoutUsername + } + + init( + isAccessibleThroughWPCom: Bool, + isPrivate: Bool, + isAtomic: Bool, + siteID: Int?, + username: String?, + authToken: String?, + failure: (Error) -> Void) { + + guard isPrivate else { + if isAccessibleThroughWPCom { + self = .publicWPComSite + } else { + self = .publicSite + } + return + } + + guard isAccessibleThroughWPCom else { + self = .privateSelfHostedSite + return + } + + guard let authToken = authToken else { + // This should actually not be possible. We have no good way to + // handle this. + failure(Error.wpComPrivateSiteWithoutAuthToken) + + // If the caller wants to kill execution, they can do it in the failure block + // call above. + // + // Otherwise they'll be able to continue trying to request the image as if it + // was hosted in a public WPCom site. This is the best we can offer with the + // provided input parameters. + self = .publicSite + return + } + + guard isAtomic else { + self = .privateWPComSite(authToken: authToken) + return + } + + guard let username = username else { + // This should actually not be possible. We have no good way to + // handle this. + failure(Error.wpComPrivateSiteWithoutUsername) + + // If the caller wants to kill execution, they can do it in the failure block + // call above. + // + // Otherwise they'll be able to continue trying to request the image as if it + // was hosted in a private WPCom site. This is the best we can offer with the + // provided input parameters. + self = .privateWPComSite(authToken: authToken) + return + } + + guard let siteID = siteID else { + // This should actually not be possible. We have no good way to + // handle this. + failure(Error.wpComWithoutSiteID) + + // If the caller wants to kill execution, they can do it in the failure block + // call above. + // + // Otherwise they'll be able to continue trying to request the image as if it + // was hosted in a private WPCom site. This is the best we can offer with the + // provided input parameters. + self = .privateWPComSite(authToken: authToken) + return + } + + self = .privateAtomicWPComSite(siteID: siteID, username: username, authToken: authToken) + } +} diff --git a/WordPress/Classes/Networking/MediaRequestAuthenticator.swift b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift new file mode 100644 index 000000000000..6ed51533bec8 --- /dev/null +++ b/WordPress/Classes/Networking/MediaRequestAuthenticator.swift @@ -0,0 +1,246 @@ +import Foundation + +fileprivate let photonHost = "i0.wp.com" +fileprivate let secureHttpScheme = "https" +fileprivate let wpComApiHost = "public-api.wordpress.com" + +extension URL { + /// Whether the URL is a Photon URL. + /// + fileprivate func isPhoton() -> Bool { + return host == photonHost + } +} + +/// This class takes care of resolving any authentication necessary before +/// requesting media from WP sites (both self-hosted and WP.com). +/// +/// This also includes regular and photon URLs. +/// +class MediaRequestAuthenticator { + + /// Errors conditions that this class can find. + /// + enum Error: Swift.Error { + case cannotFindSiteIDForSiteAvailableThroughWPCom(blog: Blog) + case cannotBreakDownURLIntoComponents(url: URL) + case cannotCreateAtomicURL(components: URLComponents) + case cannotCreateAtomicProxyURL(components: URLComponents) + case cannotCreatePrivateURL(components: URLComponents) + case cannotFindWPContentInPhotonPath(components: URLComponents) + case failedToLoadAtomicAuthenticationCookies(underlyingError: Swift.Error) + } + + // MARK: - Request Authentication + + /// Pass this method a media URL and host information, and it will handle all the necessary + /// logic to provide the caller with an authenticated request through the completion closure. + /// + /// - Parameters: + /// - url: the url for the media. + /// - host: the `MediaHost` for the requested Media. This is used for authenticating the requests. + /// - provide: the closure that will be called once authentication is sorted out by this class. + /// The request can be executed directly without having to do anything else in terms of + /// authentication. + /// - fail: the closure that will be called upon finding an error condition. + /// + func authenticatedRequest( + for url: URL, + from host: MediaHost, + onComplete provide: @escaping (URLRequest) -> (), + onFailure fail: @escaping (Error) -> ()) { + + // We want to make sure we're never sending credentials + // to a URL that's not safe. + guard !url.isFileURL || url.isHostedAtWPCom || url.isPhoton() else { + let request = URLRequest(url: url) + provide(request) + return + } + + switch host { + case .publicSite: + fallthrough + case .publicWPComSite: + fallthrough + case .privateSelfHostedSite: + // The authentication for these is handled elsewhere + let request = URLRequest(url: url) + provide(request) + case .privateWPComSite(let authToken): + authenticatedRequestForPrivateSite( + for: url, + authToken: authToken, + onComplete: provide, + onFailure: fail) + case .privateAtomicWPComSite(let siteID, let username, let authToken): + if url.isPhoton() { + authenticatedRequestForPrivateAtomicSiteThroughPhoton( + for: url, + siteID: siteID, + authToken: authToken, + onComplete: provide, + onFailure: fail) + } else { + authenticatedRequestForPrivateAtomicSite( + for: url, + siteID: siteID, + username: username, + authToken: authToken, + onComplete: provide, + onFailure: fail) + } + } + } + + // MARK: - Request Authentication: Specific Scenarios + + /// Authentication for a WPCom private request. + /// + /// - Parameters: + /// - url: the url for the media. + /// - provide: the closure that will be called once authentication is sorted out by this class. + /// The request can be executed directly without having to do anything else in terms of + /// authentication. + /// - fail: the closure that will be called upon finding an error condition. + /// + private func authenticatedRequestForPrivateSite( + for url: URL, + authToken: String, + onComplete provide: (URLRequest) -> (), + onFailure fail: (Error) -> ()) { + + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + fail(Error.cannotBreakDownURLIntoComponents(url: url)) + return + } + + // Just in case, enforce HTTPs + components.scheme = secureHttpScheme + + guard let finalURL = components.url else { + fail(Error.cannotCreatePrivateURL(components: components)) + return + } + + let request = tokenAuthenticatedWPComRequest(for: finalURL, authToken: authToken) + provide(request) + } + + /// Authentication for a WPCom private atomic request. + /// + /// - Parameters: + /// - url: the url for the media. + /// - siteID: the ID of the site that owns this media. + /// - provide: the closure that will be called once authentication is sorted out by this class. + /// The request can be executed directly without having to do anything else in terms of + /// authentication. + /// - fail: the closure that will be called upon finding an error condition. + /// + private func authenticatedRequestForPrivateAtomicSite( + for url: URL, + siteID: Int, + username: String, + authToken: String, + onComplete provide: @escaping (URLRequest) -> (), + onFailure fail: @escaping (Error) -> ()) { + + guard url.isHostedAtWPCom, + var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + provide(URLRequest(url: url)) + return + } + + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) else { + provide(URLRequest(url: url)) + return + } + + let authenticationService = AtomicAuthenticationService(account: account) + let cookieJar = HTTPCookieStorage.shared + + // Just in case, enforce HTTPs + components.scheme = secureHttpScheme + + guard let finalURL = components.url else { + fail(Error.cannotCreateAtomicURL(components: components)) + return + } + + let request = tokenAuthenticatedWPComRequest(for: finalURL, authToken: authToken) + + authenticationService.loadAuthCookies(into: cookieJar, username: account.username, siteID: siteID, success: { + provide(request) + }) { error in + fail(Error.failedToLoadAtomicAuthenticationCookies(underlyingError: error)) + } + } + + /// Authentication for a Photon request in a private atomic site. + /// + /// - Important: Photon URLs are currently not working for private atomic sites, so this is a workaround + /// to replace those URLs with working URLs. + /// + /// By recommendation of @zieladam we'll be using the Atomic Proxy endpoint for these until + /// Photon starts working with Atomic Private Sites: + /// + /// https://public-api.wordpress.com/wpcom/v2/sites/$siteID/atomic-auth-proxy/file/$wpContentPath + /// + /// To know whether you can remove this method, try requesting the photon URL from an + /// atomic private site. If it works then you can remove this workaround logic. + /// + /// - Parameters: + /// - url: the url for the media. + /// - siteID: the ID of the site that owns this media. + /// - provide: the closure that will be called once authentication is sorted out by this class. + /// The request can be executed directly without having to do anything else in terms of + /// authentication. + /// - fail: the closure that will be called upon finding an error condition. + /// + private func authenticatedRequestForPrivateAtomicSiteThroughPhoton( + for url: URL, + siteID: Int, + authToken: String, + onComplete provide: @escaping (URLRequest) -> (), + onFailure fail: @escaping (Error) -> ()) { + + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + fail(Error.cannotBreakDownURLIntoComponents(url: url)) + return + } + + guard let wpContentRange = components.path.range(of: "/wp-content") else { + fail(Error.cannotFindWPContentInPhotonPath(components: components)) + return + } + + let contentPath = String(components.path[wpContentRange.lowerBound ..< components.path.endIndex]) + + components.scheme = secureHttpScheme + components.host = wpComApiHost + components.path = "/wpcom/v2/sites/\(siteID)/atomic-auth-proxy/file" + components.queryItems = [URLQueryItem(name: "path", value: contentPath)] + + guard let finalURL = components.url else { + fail(Error.cannotCreateAtomicProxyURL(components: components)) + return + } + + let request = tokenAuthenticatedWPComRequest(for: finalURL, authToken: authToken) + provide(request) + } + + // MARK: - Adding the Auth Token + + /// Returns a request with the Bearer token for WPCom authentication. + /// + /// - Parameters: + /// - url: the url of the media. + /// - authToken: the Bearer token to add to the resulting request. + /// + private func tokenAuthenticatedWPComRequest(for url: URL, authToken: String) -> URLRequest { + var request = URLRequest(url: url) + request.addValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + return request + } +} diff --git a/WordPress/Classes/Networking/Pinghub.swift b/WordPress/Classes/Networking/Pinghub.swift index 6fbc68a655f8..9161cbbc03b3 100644 --- a/WordPress/Classes/Networking/Pinghub.swift +++ b/WordPress/Classes/Networking/Pinghub.swift @@ -8,7 +8,7 @@ import Starscream /// The delegate of a PinghubClient must adopt the PinghubClientDelegate /// protocol. The client will inform the delegate of any relevant events. /// -public protocol PinghubClientDelegate: class { +public protocol PinghubClientDelegate: AnyObject { /// The client connected successfully. /// func pingubDidConnect(_ client: PinghubClient) @@ -222,7 +222,7 @@ extension PinghubClient { // MARK: - Socket -internal protocol Socket: class { +internal protocol Socket: AnyObject { func connect() func disconnect() var onConnect: (() -> Void)? { get set } diff --git a/WordPress/Classes/Networking/WordPressOrgRestApi+WordPress.swift b/WordPress/Classes/Networking/WordPressOrgRestApi+WordPress.swift new file mode 100644 index 000000000000..97b51266e797 --- /dev/null +++ b/WordPress/Classes/Networking/WordPressOrgRestApi+WordPress.swift @@ -0,0 +1,48 @@ +import Foundation +import WordPressKit + +private func makeAuthenticator(blog: Blog) -> Authenticator? { + return blog.account != nil + ? makeTokenAuthenticator(blog: blog) + : makeCookieNonceAuthenticator(blog: blog) +} + +private func makeTokenAuthenticator(blog: Blog) -> Authenticator? { + guard let token = blog.authToken else { + DDLogError("Failed to initialize a .com API client with blog: \(blog)") + return nil + } + return TokenAuthenticator(token: token) +} + +private func makeCookieNonceAuthenticator(blog: Blog) -> Authenticator? { + guard let loginURL = try? blog.loginUrl().asURL(), + let adminURL = try? blog.adminUrl(withPath: "").asURL(), + let username = blog.username, + let password = blog.password, + let version = blog.version as String? else { + DDLogError("Failed to initialize a .org API client with blog: \(blog)") + return nil + } + + return CookieNonceAuthenticator(username: username, password: password, loginURL: loginURL, adminURL: adminURL, version: version) +} + +private func apiBase(blog: Blog) -> URL? { + precondition(blog.account == nil, ".com support has not been implemented yet") + return try? blog.url(withPath: "wp-json/").asURL() +} + +extension WordPressOrgRestApi { + @objc public convenience init?(blog: Blog) { + guard let apiBase = apiBase(blog: blog), + let authenticator = makeAuthenticator(blog: blog) else { + return nil + } + self.init( + apiBase: apiBase, + authenticator: authenticator, + userAgent: WPUserAgent.wordPress() + ) + } +} diff --git a/WordPress/Classes/PropertyWrappers/Atomic.swift b/WordPress/Classes/PropertyWrappers/Atomic.swift new file mode 100644 index 000000000000..20c907fc2ede --- /dev/null +++ b/WordPress/Classes/PropertyWrappers/Atomic.swift @@ -0,0 +1,29 @@ +import Foundation + +@propertyWrapper +struct Atomic<Value> { + + private var value: Value + private let lock = NSLock() + + init(wrappedValue value: Value) { + self.value = value + } + + var wrappedValue: Value { + get { return load() } + set { store(newValue: newValue) } + } + + func load() -> Value { + lock.lock() + defer { lock.unlock() } + return value + } + + mutating func store(newValue: Value) { + lock.lock() + defer { lock.unlock() } + value = newValue + } +} diff --git a/WordPress/Classes/Services/AccountService+Cookies.swift b/WordPress/Classes/Services/AccountService+Cookies.swift new file mode 100644 index 000000000000..0abaaa99fa4c --- /dev/null +++ b/WordPress/Classes/Services/AccountService+Cookies.swift @@ -0,0 +1,20 @@ +import Foundation + +extension AccountService { + + /// Loads the default WordPress account's cookies into shared cookie storage. + /// + static func loadDefaultAccountCookies() { + guard + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext), + let auth = RequestAuthenticator(account: account), + let url = URL(string: WPComDomain) + else { + return + } + auth.request(url: url, cookieJar: HTTPCookieStorage.shared) { _ in + // no op + } + } + +} diff --git a/WordPress/Classes/Services/AccountService+MergeDuplicates.swift b/WordPress/Classes/Services/AccountService+MergeDuplicates.swift index 36b6e538c3b2..928cd6c2be6e 100644 --- a/WordPress/Classes/Services/AccountService+MergeDuplicates.swift +++ b/WordPress/Classes/Services/AccountService+MergeDuplicates.swift @@ -3,22 +3,20 @@ import Foundation extension AccountService { func mergeDuplicatesIfNecessary() { - guard numberOfAccounts() > 1 else { - return - } - - let accounts = allAccounts() - let accountGroups = Dictionary(grouping: accounts) { $0.userID } - for group in accountGroups.values where group.count > 1 { - mergeDuplicateAccounts(accounts: group) - } - - if managedObjectContext.hasChanges { - ContextManager.sharedInstance().save(managedObjectContext) + coreDataStack.performAndSave { context in + guard let count = try? WPAccount.lookupNumberOfAccounts(in: context), count > 1 else { + return + } + + let accounts = (try? WPAccount.lookupAllAccounts(in: context)) ?? [] + let accountGroups = Dictionary(grouping: accounts) { $0.userID } + for group in accountGroups.values where group.count > 1 { + self.mergeDuplicateAccounts(accounts: group, in: context) + } } } - private func mergeDuplicateAccounts(accounts: [WPAccount]) { + private func mergeDuplicateAccounts(accounts: [WPAccount], in context: NSManagedObjectContext) { // For paranoia guard accounts.count > 1 else { return @@ -27,22 +25,21 @@ extension AccountService { // If one of the accounts is the default account, merge the rest into it. // Otherwise just use the first account. var destination = accounts.first! - if let defaultAccount = defaultWordPressComAccount(), accounts.contains(defaultAccount) { + if let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: context), accounts.contains(defaultAccount) { destination = defaultAccount } for account in accounts where account != destination { - mergeAccount(account: account, into: destination) + mergeAccount(account: account, into: destination, in: context) } - let service = BlogService(managedObjectContext: managedObjectContext) - service.deduplicateBlogs(for: destination) + destination.deduplicateBlogs() } - private func mergeAccount(account: WPAccount, into destination: WPAccount) { + private func mergeAccount(account: WPAccount, into destination: WPAccount, in context: NSManagedObjectContext) { // Move all blogs to the destination account destination.addBlogs(account.blogs) - managedObjectContext.delete(account) + context.deleteObject(account) } } diff --git a/WordPress/Classes/Services/AccountService.h b/WordPress/Classes/Services/AccountService.h index 512080237ffd..ed391eb47118 100644 --- a/WordPress/Classes/Services/AccountService.h +++ b/WordPress/Classes/Services/AccountService.h @@ -1,5 +1,5 @@ #import <Foundation/Foundation.h> -#import "LocalCoreDataService.h" +#import "CoreDataService.h" NS_ASSUME_NONNULL_BEGIN @@ -9,23 +9,12 @@ NS_ASSUME_NONNULL_BEGIN extern NSString *const WPAccountDefaultWordPressComAccountChangedNotification; extern NSNotificationName const WPAccountEmailAndDefaultBlogUpdatedNotification; -@interface AccountService : LocalCoreDataService +@interface AccountService : CoreDataService ///------------------------------------ /// @name Default WordPress.com account ///------------------------------------ -/** - Returns the default WordPress.com account - - The default WordPress.com account is the one used for Reader and Notifications - - @return the default WordPress.com account - @see setDefaultWordPressComAccount: - @see removeDefaultWordPressComAccount - */ -- (nullable WPAccount *)defaultWordPressComAccount; - /** Sets the default WordPress.com account @@ -43,11 +32,6 @@ extern NSNotificationName const WPAccountEmailAndDefaultBlogUpdatedNotification; */ - (void)removeDefaultWordPressComAccount; -/** - Returns if the given account is the default WordPress.com account. - */ -- (BOOL)isDefaultWordPressComAccount:(WPAccount *)account; - /** Query to check if an email address is paired to a wpcom account. Used in the magic links signup flow. @@ -93,35 +77,9 @@ extern NSNotificationName const WPAccountEmailAndDefaultBlogUpdatedNotification; @param username the WordPress.com account's username @param authToken the OAuth2 token returned by signIntoWordPressDotComWithUsername:authToken: - @return a WordPress.com `WPAccount` object for the given `username` - */ -- (WPAccount *)createOrUpdateAccountWithUsername:(NSString *)username - authToken:(NSString *)authToken; - -- (NSUInteger)numberOfAccounts; - -/** - Returns all accounts currently existing in core data. - - @return An array of WPAccounts. - */ -- (NSArray<WPAccount *> *)allAccounts; - -/** - Returns a WordPress.com account with the specified username, if it exists - - @param username the account's username - @return a `WPAccount` object if there's one for the specified username. Otherwise it returns nil - */ -- (nullable WPAccount *)findAccountWithUsername:(NSString *)username; - -/** - Returns a WordPress.com account with the specified user ID, if it exists - - @param userID the account's user ID - @return a `WPAccount` object if there's one for the specified username. Otherwise it returns nil + @return The ID of the WordPress.com `WPAccount` object for the given `username` */ -- (nullable WPAccount *)findAccountWithUserID:(NSNumber *)userID; +- (NSManagedObjectID *)createOrUpdateAccountWithUsername:(NSString *)username authToken:(NSString *)authToken; /** Updates user details including username, email, userID, avatarURL, and default blog. @@ -136,7 +94,7 @@ extern NSNotificationName const WPAccountEmailAndDefaultBlogUpdatedNotification; Updates the default blog for the specified account. The default blog will be the one whose siteID matches the accounts primaryBlogID. */ -- (void)updateDefaultBlogIfNeeded:(WPAccount *)account; +- (void)updateDefaultBlogIfNeeded:(WPAccount *)account inContext:(NSManagedObjectContext *)context; /** Syncs the details for the account associated with the provided auth token, then diff --git a/WordPress/Classes/Services/AccountService.m b/WordPress/Classes/Services/AccountService.m index cf20e988b734..2e5660c0b7c3 100644 --- a/WordPress/Classes/Services/AccountService.m +++ b/WordPress/Classes/Services/AccountService.m @@ -1,6 +1,6 @@ #import "AccountService.h" #import "WPAccount.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "Blog.h" #import "BlogService.h" #import "TodayExtensionService.h" @@ -22,30 +22,6 @@ @implementation AccountService /// @name Default WordPress.com account ///------------------------------------ -/** - Returns the default WordPress.com account - - The default WordPress.com account is the one used for Reader and Notifications - - @return the default WordPress.com account - @see setDefaultWordPressComAccount: - @see removeDefaultWordPressComAccount - */ -- (WPAccount *)defaultWordPressComAccount -{ - NSString *uuid = [[NSUserDefaults standardUserDefaults] stringForKey:DefaultDotcomAccountUUIDDefaultsKey]; - if (uuid.length > 0) { - WPAccount *account = [self accountWithUUID:uuid]; - if (account) { - return account; - } - } - - // No account, or no default account set. Clear the defaults key. - [[NSUserDefaults standardUserDefaults] removeObjectForKey:DefaultDotcomAccountUUIDDefaultsKey]; - return nil; -} - /** Sets the default WordPress.com account @@ -58,19 +34,19 @@ - (void)setDefaultWordPressComAccount:(WPAccount *)account NSParameterAssert(account != nil); NSAssert(account.authToken.length > 0, @"Account should have an authToken for WP.com"); - if ([[self defaultWordPressComAccount] isEqual:account]) { + if ([account isDefaultWordPressComAccount]) { return; } - [[NSUserDefaults standardUserDefaults] setObject:account.uuid forKey:DefaultDotcomAccountUUIDDefaultsKey]; + [[UserPersistentStoreFactory userDefaultsInstance] setObject:account.uuid forKey:DefaultDotcomAccountUUIDDefaultsKey]; NSManagedObjectID *accountID = account.objectID; void (^notifyAccountChange)(void) = ^{ - NSManagedObjectContext *mainContext = [[ContextManager sharedInstance] mainContext]; + NSManagedObjectContext *mainContext = self.coreDataStack.mainContext; NSManagedObject *accountInContext = [mainContext existingObjectWithID:accountID error:nil]; [[NSNotificationCenter defaultCenter] postNotificationName:WPAccountDefaultWordPressComAccountChangedNotification object:accountInContext]; - [[PushNotificationsManager shared] registerForRemoteNotifications]; + [[PushNotificationsManager shared] setupRemoteNotifications]; }; if ([NSThread isMainThread]) { // This is meant to help with testing account observers. @@ -94,13 +70,15 @@ - (void)removeDefaultWordPressComAccount [[PushNotificationsManager shared] unregisterDeviceToken]; - WPAccount *account = [self defaultWordPressComAccount]; + WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext]; if (account == nil) { return; } - [self.managedObjectContext deleteObject:account]; - [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + WPAccount *accountInContext = [context existingObjectWithID:account.objectID error:nil]; + [context deleteObject:accountInContext]; + }]; // Clear WordPress.com cookies NSArray<id<CookieJar>> *cookieJars = @[ @@ -115,20 +93,12 @@ - (void)removeDefaultWordPressComAccount [[NSURLCache sharedURLCache] removeAllCachedResponses]; // Remove defaults - [[NSUserDefaults standardUserDefaults] removeObjectForKey:DefaultDotcomAccountUUIDDefaultsKey]; + [[UserPersistentStoreFactory userDefaultsInstance] removeObjectForKey:DefaultDotcomAccountUUIDDefaultsKey]; [WPAnalytics refreshMetadata]; [[NSNotificationCenter defaultCenter] postNotificationName:WPAccountDefaultWordPressComAccountChangedNotification object:nil]; } -- (BOOL)isDefaultWordPressComAccount:(WPAccount *)account { - NSString *uuid = [[NSUserDefaults standardUserDefaults] stringForKey:DefaultDotcomAccountUUIDDefaultsKey]; - if (uuid.length == 0) { - return false; - } - return [account.uuid isEqualToString:uuid]; -} - - (void)isEmailAvailable:(NSString *)email success:(void (^)(BOOL available))success failure:(void (^)(NSError *error))failure { id<AccountServiceRemote> remote = [self remoteForAnonymous]; @@ -161,7 +131,10 @@ - (void)isUsernameAvailable:(NSString *)username - (void)requestVerificationEmail:(void (^)(void))success failure:(void (^)(NSError * _Nonnull))failure { - id<AccountServiceRemote> remote = [self remoteForAccount:[self defaultWordPressComAccount]]; + NSAssert([NSThread isMainThread], @"This method should only be called from the main thread"); + + WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext]; + id<AccountServiceRemote> remote = [self remoteForAccount:account]; [remote requestVerificationEmailWithSucccess:^{ if (success) { success(); @@ -178,20 +151,28 @@ - (void)requestVerificationEmail:(void (^)(void))success failure:(void (^)(NSErr /// @name Account creation ///----------------------- -- (WPAccount *)createOrUpdateAccountWithUserDetails:(RemoteUser *)remoteUser authToken:(NSString *)authToken +- (NSManagedObjectID *)createOrUpdateAccountWithUserDetails:(RemoteUser *)remoteUser authToken:(NSString *)authToken { - WPAccount *account = [self findAccountWithUserID:remoteUser.userID]; - if (account) { - // Even if we find an account via its userID we should still update - // its authtoken, otherwise the Authenticator's authtoken fixer won't - // work. - account.authToken = authToken; + NSManagedObjectID * __block accountObjectID = nil; + [self.coreDataStack.mainContext performBlockAndWait:^{ + accountObjectID = [[WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext] objectID]; + }]; + + if (accountObjectID) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + WPAccount *account = [context existingObjectWithID:accountObjectID error:nil]; + // Even if we find an account via its userID we should still update + // its authtoken, otherwise the Authenticator's authtoken fixer won't + // work. + account.authToken = authToken; + }]; } else { - NSString *username = remoteUser.username; - account = [self createOrUpdateAccountWithUsername:username authToken:authToken]; + accountObjectID = [self createOrUpdateAccountWithUsername:remoteUser.username authToken:authToken]; } - [self updateAccount:account withUserDetails:remoteUser]; - return account; + + [self updateAccountWithID:accountObjectID withUserDetails:remoteUser]; + + return accountObjectID; } /** @@ -203,55 +184,36 @@ - (WPAccount *)createOrUpdateAccountWithUserDetails:(RemoteUser *)remoteUser aut @param username the WordPress.com account's username @param authToken the OAuth2 token returned by signIntoWordPressDotComWithUsername:authToken: - @return a WordPress.com `WPAccount` object for the given `username` + @return The ID of the WordPress.com `WPAccount` object for the given `username` @see createOrUpdateWordPressComAccountWithUsername:password:authToken: */ -- (WPAccount *)createOrUpdateAccountWithUsername:(NSString *)username - authToken:(NSString *)authToken +- (NSManagedObjectID *)createOrUpdateAccountWithUsername:(NSString *)username authToken:(NSString *)authToken { - WPAccount *account = [self findAccountWithUsername:username]; - - if (!account) { - account = [NSEntityDescription insertNewObjectForEntityForName:@"Account" inManagedObjectContext:self.managedObjectContext]; - account.uuid = [[NSUUID new] UUIDString]; - account.username = username; - } - account.authToken = authToken; - [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext]; - - if (![self defaultWordPressComAccount]) { - [self setDefaultWordPressComAccount:account]; - dispatch_async(dispatch_get_main_queue(), ^{ - [WPAnalytics refreshMetadata]; - }); - } - - return account; -} + NSManagedObjectID * __block objectID = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + WPAccount *account = [WPAccount lookupWithUsername:username context:context]; + if (!account) { + account = [NSEntityDescription insertNewObjectForEntityForName:@"Account" inManagedObjectContext:context]; + account.uuid = [[NSUUID new] UUIDString]; + account.username = username; + } + account.authToken = authToken; + [context obtainPermanentIDsForObjects:@[account] error:nil]; + objectID = account.objectID; + }]; -- (NSUInteger)numberOfAccounts -{ - NSFetchRequest *request = [[NSFetchRequest alloc] init]; - [request setEntity:[NSEntityDescription entityForName:@"Account" inManagedObjectContext:self.managedObjectContext]]; - [request setIncludesSubentities:NO]; - - NSError *error; - NSUInteger count = [self.managedObjectContext countForFetchRequest:request error:&error]; - if (count == NSNotFound) { - count = 0; - } - return count; -} + [self.coreDataStack.mainContext performBlockAndWait:^{ + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext]; + if (!defaultAccount) { + WPAccount *account = [self.coreDataStack.mainContext existingObjectWithID:objectID error:nil]; + [self setDefaultWordPressComAccount:account]; + dispatch_async(dispatch_get_main_queue(), ^{ + [WPAnalytics refreshMetadata]; + }); + } + }]; -- (NSArray<WPAccount *> *)allAccounts -{ - NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Account"]; - NSError *error = nil; - NSArray *fetchedObjects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - if (error) { - return @[]; - } - return fetchedObjects; + return objectID; } /** @@ -276,42 +238,28 @@ - (BOOL)accountHasOnlyJetpackBlogs:(WPAccount *)account return YES; } -- (WPAccount *)accountWithUUID:(NSString *)uuid -{ - NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:@"Account"]; - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"uuid == %@", uuid]; - fetchRequest.predicate = predicate; - - NSError *error = nil; - NSArray *fetchedObjects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - if (fetchedObjects.count > 0) { - WPAccount *defaultAccount = fetchedObjects.firstObject; - defaultAccount.displayName = [defaultAccount.displayName stringByDecodingXMLCharacters]; - return defaultAccount; - } - return nil; -} - - (void)restoreDisassociatedAccountIfNecessary { - if ([self defaultWordPressComAccount]) { + NSAssert([NSThread isMainThread], @"This method should only be called from the main thread"); + + if([WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext] != nil) { return; } // Attempt to restore a default account that has somehow been disassociated. - WPAccount *account = [self findDefaultAccountCandidate]; + WPAccount *account = [self findDefaultAccountCandidateFromAccounts:[WPAccount lookupAllAccountsInContext:self.coreDataStack.mainContext]]; if (account) { // Assume we have a good candidate account and make it the default account in the app. // Note that this should be the account with the most blogs. // Updates user defaults here vs the setter method to avoid potential side-effects from dispatched notifications. - [[NSUserDefaults standardUserDefaults] setObject:account.uuid forKey:DefaultDotcomAccountUUIDDefaultsKey]; + [[UserPersistentStoreFactory userDefaultsInstance] setObject:account.uuid forKey:DefaultDotcomAccountUUIDDefaultsKey]; } } -- (WPAccount *)findDefaultAccountCandidate +- (WPAccount *)findDefaultAccountCandidateFromAccounts:(NSArray *)allAccounts { NSSortDescriptor *sort = [NSSortDescriptor sortDescriptorWithKey:@"blogs.@count" ascending:NO]; - NSArray *accounts = [[self allAccounts] sortedArrayUsingDescriptors:@[sort]]; + NSArray *accounts = [allAccounts sortedArrayUsingDescriptors:@[sort]]; for (WPAccount *account in accounts) { // Skip accounts that were likely added to Jetpack-connected self-hosted @@ -324,26 +272,6 @@ - (WPAccount *)findDefaultAccountCandidate return nil; } -- (WPAccount *)findAccountWithUsername:(NSString *)username -{ - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Account"]; - [request setPredicate:[NSPredicate predicateWithFormat:@"username =[c] %@ || email =[c] %@", username, username]]; - [request setIncludesPendingChanges:YES]; - - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:nil]; - return [results firstObject]; -} - -- (WPAccount *)findAccountWithUserID:(NSNumber *)userID -{ - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:@"Account"]; - [request setPredicate:[NSPredicate predicateWithFormat:@"userID = %@", userID]]; - [request setIncludesPendingChanges:YES]; - - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:nil]; - return [results firstObject]; -} - - (void)createOrUpdateAccountWithAuthToken:(NSString *)authToken success:(void (^)(WPAccount * _Nonnull))success failure:(void (^)(NSError * _Nonnull))failure @@ -351,7 +279,11 @@ - (void)createOrUpdateAccountWithAuthToken:(NSString *)authToken WordPressComRestApi *api = [WordPressComRestApi defaultApiWithOAuthToken:authToken userAgent:[WPUserAgent defaultUserAgent] localeKey:[WordPressComRestApi LocaleKeyDefault]]; AccountServiceRemoteREST *remote = [[AccountServiceRemoteREST alloc] initWithWordPressComRestApi:api]; [remote getAccountDetailsWithSuccess:^(RemoteUser *remoteUser) { - WPAccount *account = [self createOrUpdateAccountWithUserDetails:remoteUser authToken:authToken]; + NSManagedObjectID *objectID = [self createOrUpdateAccountWithUserDetails:remoteUser authToken:authToken]; + WPAccount * __block account = nil; + [self.coreDataStack.mainContext performBlockAndWait:^{ + account = [self.coreDataStack.mainContext existingObjectWithID:objectID error:nil]; + }]; success(account); } failure:^(NSError *error) { failure(error); @@ -365,12 +297,10 @@ - (void)updateUserDetailsForAccount:(WPAccount *)account NSAssert(account, @"Account can not be nil"); NSAssert(account.username, @"account.username can not be nil"); - NSString *username = account.username; id<AccountServiceRemote> remote = [self remoteForAccount:account]; [remote getAccountDetailsWithSuccess:^(RemoteUser *remoteUser) { // account.objectID can be temporary, so fetch via username/xmlrpc instead. - WPAccount *fetchedAccount = [self findAccountWithUsername:username]; - [self updateAccount:fetchedAccount withUserDetails:remoteUser]; + [self updateAccountWithID:account.objectID withUserDetails:remoteUser]; dispatch_async(dispatch_get_main_queue(), ^{ [WPAnalytics refreshMetadata]; if (success) { @@ -402,24 +332,33 @@ - (void)updateUserDetailsForAccount:(WPAccount *)account return [[AccountServiceRemoteREST alloc] initWithWordPressComRestApi:account.wordPressComRestApi]; } -- (void)updateAccount:(WPAccount *)account withUserDetails:(RemoteUser *)userDetails +- (void)updateAccountWithID:(NSManagedObjectID *)objectID withUserDetails:(RemoteUser *)userDetails { - account.userID = userDetails.userID; - account.username = userDetails.username; - account.email = userDetails.email; - account.avatarURL = userDetails.avatarURL; - account.displayName = userDetails.displayName; - account.dateCreated = userDetails.dateCreated; - account.emailVerified = @(userDetails.emailVerified); - account.primaryBlogID = userDetails.primaryBlogID; - - [self updateDefaultBlogIfNeeded: account]; - - [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext]; + NSParameterAssert(![objectID isTemporaryID]); + + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + WPAccount *account = [context existingObjectWithID:objectID error:nil]; + account.userID = userDetails.userID; + account.username = userDetails.username; + account.email = userDetails.email; + account.avatarURL = userDetails.avatarURL; + account.displayName = userDetails.displayName; + account.dateCreated = userDetails.dateCreated; + account.emailVerified = @(userDetails.emailVerified); + account.primaryBlogID = userDetails.primaryBlogID; + }]; + + // Make sure the account is saved before updating its default blog. + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + WPAccount *account = [context existingObjectWithID:objectID error:nil]; + [self updateDefaultBlogIfNeeded:account inContext:context]; + }]; } -- (void)updateDefaultBlogIfNeeded:(WPAccount *)account +- (void)updateDefaultBlogIfNeeded:(WPAccount *)account inContext:(NSManagedObjectContext *)context { + NSParameterAssert(account.managedObjectContext == context); + if (!account.primaryBlogID || [account.primaryBlogID intValue] == 0) { return; } @@ -437,15 +376,29 @@ - (void)updateDefaultBlogIfNeeded:(WPAccount *)account account.defaultBlog = defaultBlog; // Update app extensions if needed. - if (account == [self defaultWordPressComAccount]) { - [self setupAppExtensionsWithDefaultAccount]; + if ([account isDefaultWordPressComAccount]) { + [self setupAppExtensionsWithDefaultAccount:account inContext:context]; } } - (void)setupAppExtensionsWithDefaultAccount { - WPAccount *defaultAccount = [self defaultWordPressComAccount]; - Blog *defaultBlog = [defaultAccount defaultBlog]; + NSManagedObjectContext *context = self.coreDataStack.mainContext; + [context performBlockAndWait:^{ + WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:context]; + if (account == nil) { + return; + } + [self setupAppExtensionsWithDefaultAccount:account inContext:context]; + }]; +} + +- (void)setupAppExtensionsWithDefaultAccount:(WPAccount *)defaultAccount inContext:(NSManagedObjectContext *)context +{ + NSParameterAssert(defaultAccount.managedObjectContext == context); + + NSManagedObjectID *defaultAccountObjectID = defaultAccount.objectID; + Blog *defaultBlog = [defaultAccount defaultBlog]; NSNumber *siteId = defaultBlog.dotComID; NSString *blogName = defaultBlog.settings.name; NSString *blogUrl = defaultBlog.displayURL; @@ -463,26 +416,27 @@ - (void)setupAppExtensionsWithDefaultAccount } else { // Required Attributes - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:self.managedObjectContext]; NSString *oauth2Token = defaultAccount.authToken; // For the Today Extensions, if the user has set a non-primary site, use that. NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:WPAppGroupName]; - NSNumber *todayExtensionSiteID = [sharedDefaults objectForKey:WPStatsTodayWidgetUserDefaultsSiteIdKey]; - NSString *todayExtensionBlogName = [sharedDefaults objectForKey:WPStatsTodayWidgetUserDefaultsSiteNameKey]; - NSString *todayExtensionBlogUrl = [sharedDefaults objectForKey:WPStatsTodayWidgetUserDefaultsSiteUrlKey]; + NSNumber *todayExtensionSiteID = [sharedDefaults objectForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteIdKey]; + NSString *todayExtensionBlogName = [sharedDefaults objectForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteNameKey]; + NSString *todayExtensionBlogUrl = [sharedDefaults objectForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteUrlKey]; - Blog *todayExtensionBlog = [blogService blogByBlogId:todayExtensionSiteID]; - NSTimeZone *timeZone = [blogService timeZoneForBlog:todayExtensionBlog]; + Blog *todayExtensionBlog = [Blog lookupWithID:todayExtensionSiteID in:context]; + NSTimeZone *timeZone = [todayExtensionBlog timeZone]; - if (todayExtensionSiteID == NULL) { + if (todayExtensionSiteID == NULL || todayExtensionBlog == nil) { todayExtensionSiteID = siteId; todayExtensionBlogName = blogName; todayExtensionBlogUrl = blogUrl; - timeZone = [blogService timeZoneForBlog:defaultBlog]; + timeZone = [defaultBlog timeZone]; } dispatch_async(dispatch_get_main_queue(), ^{ + WPAccount *defaultAccount = [self.coreDataStack.mainContext existingObjectWithID:defaultAccountObjectID error:nil]; + TodayExtensionService *service = [TodayExtensionService new]; [service configureTodayWidgetWithSiteID:todayExtensionSiteID blogName:todayExtensionBlogName @@ -499,6 +453,7 @@ - (void)setupAppExtensionsWithDefaultAccount [NotificationSupportService insertServiceExtensionToken:defaultAccount.authToken]; [NotificationSupportService insertServiceExtensionUsername:defaultAccount.username]; + [NotificationSupportService insertServiceExtensionUserID:defaultAccount.userID.stringValue]; }); } @@ -508,17 +463,24 @@ - (void)purgeAccountIfUnused:(WPAccount *)account { NSParameterAssert(account); - BOOL purge = NO; - WPAccount *defaultAccount = [self defaultWordPressComAccount]; - if ([account.blogs count] == 0 - && ![defaultAccount isEqual:account]) { - purge = YES; - } + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + WPAccount *accountInContext = [context existingObjectWithID:account.objectID error:nil]; + if (accountInContext == nil) { + return; + } - if (purge) { - DDLogWarn(@"Removing account since it has no blogs associated and it's not the default account: %@", account); - [self.managedObjectContext deleteObject:account]; - } + BOOL purge = NO; + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:context]; + if ([accountInContext.blogs count] == 0 + && ![defaultAccount isEqual:accountInContext]) { + purge = YES; + } + + if (purge) { + DDLogWarn(@"Removing account since it has no blogs associated and it's not the default account: %@", accountInContext); + [context deleteObject:accountInContext]; + } + }]; } ///-------------------- @@ -527,21 +489,35 @@ - (void)purgeAccountIfUnused:(WPAccount *)account - (void)setVisibility:(BOOL)visible forBlogs:(NSArray *)blogs { - NSMutableDictionary *blogVisibility = [NSMutableDictionary dictionaryWithCapacity:blogs.count]; - for (Blog *blog in blogs) { - NSAssert(blog.dotComID.unsignedIntegerValue > 0, @"blog should have a wp.com ID"); - NSAssert([blog.account isEqual:[self defaultWordPressComAccount]], @"blog should belong to the default account"); - // This shouldn't happen, but just in case, let's not crash if - // something tries to change visibility for a self hosted - if (blog.dotComID) { - blogVisibility[blog.dotComID] = @(visible); - } - blog.visible = visible; - } - AccountServiceRemoteREST *remote = [self remoteForAccount:[self defaultWordPressComAccount]]; - [remote updateBlogsVisibility:blogVisibility success:nil failure:^(NSError *error) { - DDLogError(@"Error setting blog visibility: %@", error); + NSArray<NSManagedObjectID *> *blogIds = [blogs wp_map:^id(Blog *obj) { + return obj.objectID; }]; + NSMutableDictionary *blogVisibility = [NSMutableDictionary dictionaryWithCapacity:blogIds.count]; + + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + // `defaultAccount` is only used in the `NSAssert` check below, but in our release builds + // `NSAssert` are ignored resulting in `defaultAccount` being unused and the compiler + // throwing an error. The `__unused` annotation lets us work aruond that. + __unused WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:context]; + + for (NSManagedObjectID *blogId in blogIds) { + Blog *blog = [context existingObjectWithID:blogId error:nil]; + NSAssert(blog.dotComID.unsignedIntegerValue > 0, @"blog should have a wp.com ID"); + NSAssert([blog.account isEqual:defaultAccount], @"blog should belong to the default account"); + // This shouldn't happen, but just in case, let's not crash if + // something tries to change visibility for a self hosted + if (blog.dotComID) { + blogVisibility[blog.dotComID] = @(visible); + } + blog.visible = visible; + } + } completion:^{ + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext]; + AccountServiceRemoteREST *remote = [self remoteForAccount:defaultAccount]; + [remote updateBlogsVisibility:blogVisibility success:nil failure:^(NSError *error) { + DDLogError(@"Error setting blog visibility: %@", error); + }]; + } onQueue:dispatch_get_main_queue()]; } @end diff --git a/WordPress/Classes/Services/AccountSettingsService.swift b/WordPress/Classes/Services/AccountSettingsService.swift index f0df01fc98fb..927854ca06cb 100644 --- a/WordPress/Classes/Services/AccountSettingsService.swift +++ b/WordPress/Classes/Services/AccountSettingsService.swift @@ -20,6 +20,15 @@ protocol AccountSettingsRemoteInterface { func changeUsername(to username: String, success: @escaping () -> Void, failure: @escaping () -> Void) func suggestUsernames(base: String, finished: @escaping ([String]) -> Void) func updatePassword(_ password: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) + func closeAccount(success: @escaping () -> Void, failure: @escaping (Error) -> Void) +} + +extension AccountSettingsRemoteInterface { + func updateSetting(_ change: AccountSettingsChange) async throws { + try await withCheckedThrowingContinuation { continuation in + self.updateSetting(change, success: continuation.resume, failure: continuation.resume(throwing:)) + } + } } extension AccountSettingsRemote: AccountSettingsRemoteInterface {} @@ -50,24 +59,34 @@ class AccountSettingsService { var stallTimer: Timer? - fileprivate let context = ContextManager.sharedInstance().mainContext + private let coreDataStack: CoreDataStackSwift convenience init(userID: Int, api: WordPressComRestApi) { let remote = AccountSettingsRemote.remoteWithApi(api) self.init(userID: userID, remote: remote) } - init(userID: Int, remote: AccountSettingsRemoteInterface) { + init(userID: Int, remote: AccountSettingsRemoteInterface, coreDataStack: CoreDataStackSwift = ContextManager.sharedInstance()) { self.userID = userID self.remote = remote + self.coreDataStack = coreDataStack loadSettings() } - func getSettingsAttempt(count: Int = 0) { - self.remote.getSettings( + func getSettingsAttempt(count: Int = 0, completion: ((Result<AccountSettings, Error>) -> Void)? = nil) { + remote.getSettings( success: { settings in - self.updateSettings(settings) - self.status = .idle + self.coreDataStack.performAndSave({ context in + if let managedSettings = self.managedAccountSettingsWithID(self.userID, in: context) { + managedSettings.updateWith(settings) + } else { + self.createAccountSettings(self.userID, settings: settings, in: context) + } + }, completion: { + self.loadSettings() + self.status = .idle + completion?(.success(settings)) + }, on: .main) }, failure: { error in let error = error as NSError @@ -77,21 +96,22 @@ class AccountSettingsService { DDLogError("Error refreshing settings (unrecoverable): \(error)") } - if error.domain == NSURLErrorDomain && count < Defaults.maxRetries { - self.getSettingsAttempt(count: count + 1) + if error.domain == NSURLErrorDomain && error.code != URLError.cancelled.rawValue && count < Defaults.maxRetries { + self.getSettingsAttempt(count: count + 1, completion: completion) } else { self.status = .failed + completion?(.failure(error)) } } ) } - func refreshSettings() { + func refreshSettings(completion: ((Result<AccountSettings, Error>) -> Void)? = nil) { guard status == .idle || status == .failed else { return } status = .refreshing - getSettingsAttempt() + getSettingsAttempt(completion: completion) stallTimer = Timer.scheduledTimer(timeInterval: Defaults.stallTimeout, target: self, selector: #selector(AccountSettingsService.stallTimerFired), @@ -107,23 +127,33 @@ class AccountSettingsService { } func saveChange(_ change: AccountSettingsChange, finished: ((Bool) -> ())? = nil) { - guard let reverse = try? applyChange(change) else { + Task { @MainActor in + do { + try await saveChange(change) + finished?(true) + } catch { + NotificationCenter.default.post(name: NSNotification.Name.AccountSettingsServiceChangeSaveFailed, object: self, userInfo: [NSUnderlyingErrorKey: error]) + finished?(false) + } + } + } + + func saveChange(_ change: AccountSettingsChange) async throws { + guard let reverse = try? await applyChange(change) else { return } - remote.updateSetting(change, success: { - finished?(true) - }) { (error) -> Void in + do { + try await remote.updateSetting(change) + } catch { do { // revert change - try self.applyChange(reverse) + try await self.applyChange(reverse) } catch { DDLogError("Error reverting change \(error)") } DDLogError("Error saving account settings change \(error)") - NotificationCenter.default.post(name: NSNotification.Name.AccountSettingsServiceChangeSaveFailed, object: self, userInfo: [NSUnderlyingErrorKey: error]) - - finished?(false) + throw error } } @@ -149,11 +179,18 @@ class AccountSettingsService { } } - func primarySiteNameForSettings(_ settings: AccountSettings) -> String? { - let service = BlogService(managedObjectContext: context) - let blog = service.blog(byBlogId: NSNumber(value: settings.primarySiteID)) + func closeAccount(result: @escaping (Result<Void, Error>) -> Void) { + remote.closeAccount { + result(.success(())) + } failure: { error in + result(.failure(error)) + } + } - return blog?.settings?.name + func primarySiteNameForSettings(_ settings: AccountSettings) -> String? { + coreDataStack.performQuery { context in + try? Blog.lookup(withID: settings.primarySiteID, in: context)?.settings?.name + } } /// Change the current user's username @@ -174,42 +211,36 @@ class AccountSettingsService { settings = accountSettingsWithID(self.userID) } - @discardableResult fileprivate func applyChange(_ change: AccountSettingsChange) throws -> AccountSettingsChange { - guard let settings = managedAccountSettingsWithID(userID) else { - DDLogError("Tried to apply a change to nonexistent settings (ID: \(userID)") - throw Errors.notFound - } - - let reverse = settings.applyChange(change) - settings.account.applyChange(change) - - ContextManager.sharedInstance().save(context) - loadSettings() + @discardableResult fileprivate func applyChange(_ change: AccountSettingsChange) async throws -> AccountSettingsChange { + let reverse = try await coreDataStack.performAndSave({ context in + guard let settings = self.managedAccountSettingsWithID(self.userID, in: context) else { + DDLogError("Tried to apply a change to nonexistent settings (ID: \(self.userID)") + throw Errors.notFound + } - return reverse - } + let reverse = settings.applyChange(change) + settings.account.applyChange(change) + return reverse + }) - fileprivate func updateSettings(_ settings: AccountSettings) { - if let managedSettings = managedAccountSettingsWithID(userID) { - managedSettings.updateWith(settings) - } else { - createAccountSettings(userID, settings: settings) + await MainActor.run { + self.loadSettings() } - ContextManager.sharedInstance().save(context) - loadSettings() + return reverse } fileprivate func accountSettingsWithID(_ userID: Int) -> AccountSettings? { + coreDataStack.performQuery { context in + guard let managedAccount = self.managedAccountSettingsWithID(userID, in: context) else { + return nil + } - guard let managedAccount = managedAccountSettingsWithID(userID) else { - return nil + return AccountSettings.init(managed: managedAccount) } - - return AccountSettings.init(managed: managedAccount) } - fileprivate func managedAccountSettingsWithID(_ userID: Int) -> ManagedAccountSettings? { + fileprivate func managedAccountSettingsWithID(_ userID: Int, in context: NSManagedObjectContext) -> ManagedAccountSettings? { let request = NSFetchRequest<NSFetchRequestResult>(entityName: ManagedAccountSettings.entityName()) request.predicate = NSPredicate(format: "account.userID = %d", userID) request.fetchLimit = 1 @@ -219,9 +250,9 @@ class AccountSettingsService { return results.first } - fileprivate func createAccountSettings(_ userID: Int, settings: AccountSettings) { - let accountService = AccountService(managedObjectContext: context) - guard let account = accountService.findAccount(withUserID: NSNumber(value: userID)) else { + fileprivate func createAccountSettings(_ userID: Int, settings: AccountSettings, in context: NSManagedObjectContext) { + + guard let account = try? WPAccount.lookup(withUserID: Int64(userID), in: context) else { DDLogError("Tried to create settings for a missing account (ID: \(userID)): \(settings)") return } @@ -254,20 +285,3 @@ class AccountSettingsService { } } } - -struct AccountSettingsHelper { - let accountService: AccountService - - init(accountService: AccountService) { - self.accountService = accountService - } - - func updateTracksOptOutSetting(_ optOut: Bool) { - guard let account = accountService.defaultWordPressComAccount() else { - return - } - - let change = AccountSettingsChange.tracksOptOut(optOut) - AccountSettingsService(userID: account.userID.intValue, api: account.wordPressComRestApi).saveChange(change) - } -} diff --git a/WordPress/Classes/Services/AtomicAuthenticationService.swift b/WordPress/Classes/Services/AtomicAuthenticationService.swift new file mode 100644 index 000000000000..9153c11e4113 --- /dev/null +++ b/WordPress/Classes/Services/AtomicAuthenticationService.swift @@ -0,0 +1,60 @@ +import AutomatticTracks +import Foundation +import WordPressKit + +class AtomicAuthenticationService { + + let remote: AtomicAuthenticationServiceRemote + + init(remote: AtomicAuthenticationServiceRemote) { + self.remote = remote + } + + convenience init(account: WPAccount) { + let wpComRestApi = account.wordPressComRestV2Api + let remote = AtomicAuthenticationServiceRemote(wordPressComRestApi: wpComRestApi) + + self.init(remote: remote) + } + + func getAuthCookie( + siteID: Int, + success: @escaping (_ cookie: HTTPCookie) -> Void, + failure: @escaping (Error) -> Void) { + + remote.getAuthCookie(siteID: siteID, success: success, failure: failure) + } + + func loadAuthCookies( + into cookieJar: CookieJar, + username: String, + siteID: Int, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + + cookieJar.hasWordPressComAuthCookie( + username: username, + atomicSite: true) { hasCookie in + + guard !hasCookie else { + success() + return + } + + self.getAuthCookie(siteID: siteID, success: { cookies in + cookieJar.setCookies([cookies]) { + success() + } + }) { error in + // Make sure this error scenario isn't silently ignored. + WordPressAppDelegate.crashLogging?.logError(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + failure(error) + } + } + } +} diff --git a/WordPress/Classes/Services/AuthenticationService.swift b/WordPress/Classes/Services/AuthenticationService.swift new file mode 100644 index 000000000000..ca9b401148d3 --- /dev/null +++ b/WordPress/Classes/Services/AuthenticationService.swift @@ -0,0 +1,217 @@ +import AutomatticTracks +import Foundation + +class AuthenticationService { + + static let wpComLoginEndpoint = "https://wordpress.com/wp-login.php" + + enum RequestAuthCookieError: Error, LocalizedError { + case wpcomCookieNotReturned + + public var errorDescription: String? { + switch self { + case .wpcomCookieNotReturned: + return "Response to request for auth cookie for WP.com site failed to return cookie." + } + } + } + + // MARK: - Self Hosted + + func loadAuthCookiesForSelfHosted( + into cookieJar: CookieJar, + loginURL: URL, + username: String, + password: String, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + + cookieJar.hasWordPressSelfHostedAuthCookie(for: loginURL, username: username) { hasCookie in + guard !hasCookie else { + success() + return + } + + self.getAuthCookiesForSelfHosted(loginURL: loginURL, username: username, password: password, success: { cookies in + cookieJar.setCookies(cookies) { + success() + } + + cookieJar.hasWordPressSelfHostedAuthCookie(for: loginURL, username: username) { hasCookie in + print("Has cookie: \(hasCookie)") + } + }) { error in + // Make sure this error scenario isn't silently ignored. + WordPressAppDelegate.crashLogging?.logError(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + failure(error) + } + } + } + + func getAuthCookiesForSelfHosted( + loginURL: URL, + username: String, + password: String, + success: @escaping (_ cookies: [HTTPCookie]) -> Void, + failure: @escaping (Error) -> Void) { + + let headers = [String: String]() + let parameters = [ + "log": username, + "pwd": password, + "rememberme": "true" + ] + + requestAuthCookies( + from: loginURL, + headers: headers, + parameters: parameters, + success: success, + failure: failure) + } + + // MARK: - WP.com + + func loadAuthCookiesForWPCom( + into cookieJar: CookieJar, + username: String, + authToken: String, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + + cookieJar.hasWordPressComAuthCookie( + username: username, + atomicSite: false) { hasCookie in + + guard !hasCookie else { + // The stored cookie can be stale but we'll try to use it and refresh it if the request fails. + success() + return + } + + self.getAuthCookiesForWPCom(username: username, authToken: authToken, success: { cookies in + cookieJar.setCookies(cookies) { + + cookieJar.hasWordPressComAuthCookie(username: username, atomicSite: false) { hasCookie in + guard hasCookie else { + failure(RequestAuthCookieError.wpcomCookieNotReturned) + return + } + success() + } + + } + }) { error in + // Make sure this error scenario isn't silently ignored. + WordPressAppDelegate.crashLogging?.logError(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + failure(error) + } + } + } + + func getAuthCookiesForWPCom( + username: String, + authToken: String, + success: @escaping (_ cookies: [HTTPCookie]) -> Void, + failure: @escaping (Error) -> Void) { + + let loginURL = URL(string: AuthenticationService.wpComLoginEndpoint)! + let headers = [ + "Authorization": "Bearer \(authToken)" + ] + let parameters = [ + "log": username, + "rememberme": "true" + ] + + requestAuthCookies( + from: loginURL, + headers: headers, + parameters: parameters, + success: success, + failure: failure) + } + + // MARK: - Request Construction + + private func requestAuthCookies( + from url: URL, + headers: [String: String], + parameters: [String: String], + success: @escaping (_ cookies: [HTTPCookie]) -> Void, + failure: @escaping (Error) -> Void) { + + // We don't want these cookies persisted in other sessions + let session = URLSession(configuration: .ephemeral) + var request = URLRequest(url: url) + + request.httpMethod = "POST" + request.httpBody = body(withParameters: parameters) + + headers.forEach { (key, value) in + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue(WPUserAgent.wordPress(), forHTTPHeaderField: "User-Agent") + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + DispatchQueue.main.async { + failure(error) + } + return + } + + // The following code is a bit complicated to read, apologies. + // We're retrieving all cookies from the "Set-Cookie" header manually, and combining + // those cookies with the ones from the current session. The reason behind this is that + // iOS's URLSession processes the cookies from such header before this callback is executed, + // whereas OHTTPStubs.framework doesn't (the cookies are left in the header fields of + // the response). The only way to combine both is to just add them together here manually. + // + // To know if you can remove this, you'll have to test this code live and in our unit tests + // and compare the session cookies. + let responseCookies = self.cookies(from: response, loginURL: url) + let cookies = (session.configuration.httpCookieStorage?.cookies ?? [HTTPCookie]()) + responseCookies + DispatchQueue.main.async { + success(cookies) + } + } + + task.resume() + } + + private func body(withParameters parameters: [String: String]) -> Data? { + var queryItems = [URLQueryItem]() + + for parameter in parameters { + let queryItem = URLQueryItem(name: parameter.key, value: parameter.value) + queryItems.append(queryItem) + } + + var components = URLComponents() + components.queryItems = queryItems + + return components.percentEncodedQuery?.data(using: .utf8) + } + + // MARK: - Response Parsing + + private func cookies(from response: URLResponse?, loginURL: URL) -> [HTTPCookie] { + guard let httpResponse = response as? HTTPURLResponse, + let headers = httpResponse.allHeaderFields as? [String: String] else { + return [] + } + + return HTTPCookie.cookies(withResponseHeaderFields: headers, for: loginURL) + } +} diff --git a/WordPress/Classes/Services/BlazeService.swift b/WordPress/Classes/Services/BlazeService.swift new file mode 100644 index 000000000000..1e90c5477ec8 --- /dev/null +++ b/WordPress/Classes/Services/BlazeService.swift @@ -0,0 +1,71 @@ +import Foundation +import WordPressKit + +@objc final class BlazeService: NSObject { + + private let contextManager: CoreDataStackSwift + private let remote: BlazeServiceRemote + + // MARK: - Init + + required init?(contextManager: CoreDataStackSwift = ContextManager.shared, + remote: BlazeServiceRemote? = nil) { + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext) else { + return nil + } + + self.contextManager = contextManager + self.remote = remote ?? .init(wordPressComRestApi: account.wordPressComRestV2Api) + } + + @objc class func createService() -> BlazeService? { + self.init() + } + + // MARK: - Methods + + /// Fetches a site's blaze status from the server, and updates the blog's isBlazeApproved property. + /// + /// - Parameters: + /// - blog: A blog + /// - completion: Closure to be called on completion + @objc func getStatus(for blog: Blog, + completion: (() -> Void)? = nil) { + + guard BlazeHelper.isBlazeFlagEnabled() else { + updateBlogWithID(blog.objectID, isBlazeApproved: false, completion: completion) + return + } + + guard let siteId = blog.dotComID?.intValue else { + DDLogError("Invalid site ID for Blaze") + updateBlogWithID(blog.objectID, isBlazeApproved: false, completion: completion) + return + } + + remote.getStatus(forSiteId: siteId) { result in + switch result { + case .success(let approved): + self.updateBlogWithID(blog.objectID, isBlazeApproved: approved, completion: completion) + case .failure(let error): + DDLogError("Unable to fetch isBlazeApproved value from remote: \(error.localizedDescription)") + self.updateBlogWithID(blog.objectID, isBlazeApproved: false, completion: completion) + } + } + } + + private func updateBlogWithID(_ objectID: NSManagedObjectID, + isBlazeApproved: Bool, + completion: (() -> Void)? = nil) { + contextManager.performAndSave({ context in + guard let blog = try? context.existingObject(with: objectID) as? Blog else { + DDLogError("Unable to fetch blog and update isBlazedApproved value") + return + } + blog.isBlazeApproved = isBlazeApproved + DDLogInfo("Successfully updated isBlazeApproved value for blog: \(isBlazeApproved)") + }, completion: { + completion?() + }, on: .main) + } +} diff --git a/WordPress/Classes/Services/BlockEditorSettingsService.swift b/WordPress/Classes/Services/BlockEditorSettingsService.swift new file mode 100644 index 000000000000..ff0392b07eb4 --- /dev/null +++ b/WordPress/Classes/Services/BlockEditorSettingsService.swift @@ -0,0 +1,205 @@ +import Foundation +import WordPressKit + +class BlockEditorSettingsService { + struct SettingsServiceResult { + let hasChanges: Bool + let blockEditorSettings: BlockEditorSettings? + } + + enum BlockEditorSettingsServiceError: Int, Error { + case blogNotFound + } + + typealias BlockEditorSettingsServiceCompletion = (Swift.Result<SettingsServiceResult, Error>) -> Void + + let blog: Blog + let remote: BlockEditorSettingsServiceRemote + let coreDataStack: CoreDataStackSwift + + var cachedSettings: BlockEditorSettings? { + return blog.blockEditorSettings + } + + convenience init?(blog: Blog, coreDataStack: CoreDataStackSwift) { + let remoteAPI: WordPressRestApi + if blog.isAccessibleThroughWPCom(), + blog.dotComID?.intValue != nil, + let restAPI = blog.wordPressComRestApi() { + remoteAPI = restAPI + } else if let orgAPI = blog.wordPressOrgRestApi { + remoteAPI = orgAPI + } else { + // This is should only happen if there is a problem with the blog itsself. + return nil + } + + self.init(blog: blog, remoteAPI: remoteAPI, coreDataStack: coreDataStack) + } + + init(blog: Blog, remoteAPI: WordPressRestApi, coreDataStack: CoreDataStackSwift) { + assert(blog.objectID.persistentStore != nil, "The blog instance should be saved first") + self.blog = blog + self.coreDataStack = coreDataStack + self.remote = BlockEditorSettingsServiceRemote(remoteAPI: remoteAPI) + } + + func fetchSettings(_ completion: @escaping BlockEditorSettingsServiceCompletion) { + if blog.supports(.blockEditorSettings) { + fetchBlockEditorSettings(completion) + } else { + fetchTheme(completion) + } + } +} + +// MARK: Editor `theme_supports` support +private extension BlockEditorSettingsService { + func fetchTheme(_ completion: @escaping BlockEditorSettingsServiceCompletion) { + remote.fetchTheme(forSiteID: blog.dotComID?.intValue) { [weak self] (response) in + guard let `self` = self else { return } + switch response { + case .success(let editorTheme): + self.blog.managedObjectContext?.perform { + let originalChecksum = self.blog.blockEditorSettings?.checksum ?? "" + self.track(isBlockEditorSettings: false, isFSE: false) + self.updateEditorThemeCache(originalChecksum: originalChecksum, editorTheme: editorTheme, completion: completion) + } + case .failure(let err): + DDLogError("Error loading active theme: \(err)") + completion(.failure(err)) + } + } + } + + func updateEditorThemeCache(originalChecksum: String, editorTheme: RemoteEditorTheme?, completion: @escaping BlockEditorSettingsServiceCompletion) { + let newChecksum = editorTheme?.checksum ?? "" + guard originalChecksum != newChecksum else { + /// The fetched Editor Theme is the same as the cached one so respond with no new changes. + let result = SettingsServiceResult(hasChanges: false, blockEditorSettings: self.blog.blockEditorSettings) + completion(.success(result)) + return + } + + guard let editorTheme = editorTheme else { + /// The original checksum is different than an empty one so we need to clear the old settings. + clearCoreData(completion: completion) + return + } + + /// The fetched Editor Theme is different than the cached one so persist the new one and delete the old one. + self.persistEditorThemeToCoreData(blogID: self.blog.objectID, editorTheme: editorTheme) { callback in + switch callback { + case .success: + let result = SettingsServiceResult(hasChanges: true, blockEditorSettings: self.blog.blockEditorSettings) + completion(.success(result)) + case .failure(let err): + completion(.failure(err)) + } + } + } + + func persistEditorThemeToCoreData(blogID: NSManagedObjectID, editorTheme: RemoteEditorTheme, completion: @escaping (Swift.Result<Void, Error>) -> Void) { + coreDataStack.performAndSave({ context in + guard let blog = context.object(with: blogID) as? Blog else { + throw BlockEditorSettingsServiceError.blogNotFound + } + + if let blockEditorSettings = blog.blockEditorSettings { + // Block Editor Settings nullify on delete + context.delete(blockEditorSettings) + } + + blog.blockEditorSettings = BlockEditorSettings(editorTheme: editorTheme, context: context) + }, completion: completion, on: .main) + } +} + +// MARK: Editor Global Styles support +private extension BlockEditorSettingsService { + func fetchBlockEditorSettings(_ completion: @escaping BlockEditorSettingsServiceCompletion) { + remote.fetchBlockEditorSettings(forSiteID: blog.dotComID?.intValue) { [weak self] (response) in + guard let `self` = self else { return } + switch response { + case .success(let remoteSettings): + self.blog.managedObjectContext?.perform { + let originalChecksum = self.blog.blockEditorSettings?.checksum ?? "" + self.track(isBlockEditorSettings: true, isFSE: remoteSettings?.isFSETheme ?? false) + self.updateBlockEditorSettingsCache(originalChecksum: originalChecksum, remoteSettings: remoteSettings, completion: completion) + } + case .failure(let err): + DDLogError("Error fetching editor settings: \(err)") + // The user may not have the gutenberg plugin installed so try /wp/v2/themes to maintain feature support. + // In WP 5.9 we may be able to skip this attempt. + self.fetchTheme(completion) + } + } + } + + func updateBlockEditorSettingsCache(originalChecksum: String, remoteSettings: RemoteBlockEditorSettings?, completion: @escaping BlockEditorSettingsServiceCompletion) { + let newChecksum = remoteSettings?.checksum ?? "" + guard originalChecksum != newChecksum else { + /// The fetched Block Editor Settings is the same as the cached one so respond with no new changes. + let result = SettingsServiceResult(hasChanges: false, blockEditorSettings: self.blog.blockEditorSettings) + completion(.success(result)) + return + } + + guard let remoteSettings = remoteSettings else { + /// The original checksum is different than an empty one so we need to clear the old settings. + clearCoreData(completion: completion) + return + } + + /// The fetched Block Editor Settings is different than the cached one so persist the new one and delete the old one. + self.persistBlockEditorSettingsToCoreData(blogID: self.blog.objectID, remoteSettings: remoteSettings) { callback in + switch callback { + case .success: + let result = SettingsServiceResult(hasChanges: true, blockEditorSettings: self.blog.blockEditorSettings) + completion(.success(result)) + case .failure(let err): + completion(.failure(err)) + } + } + } + + func persistBlockEditorSettingsToCoreData(blogID: NSManagedObjectID, remoteSettings: RemoteBlockEditorSettings, completion: @escaping (Swift.Result<Void, Error>) -> Void) { + coreDataStack.performAndSave({ context in + guard let blog = context.object(with: blogID) as? Blog else { + throw BlockEditorSettingsServiceError.blogNotFound + } + + if let blockEditorSettings = blog.blockEditorSettings { + // Block Editor Settings nullify on delete + context.delete(blockEditorSettings) + } + + blog.blockEditorSettings = BlockEditorSettings(remoteSettings: remoteSettings, context: context) + }, completion: completion, on: .main) + } +} + +// MARK: Shared Events +private extension BlockEditorSettingsService { + func clearCoreData(completion: @escaping BlockEditorSettingsServiceCompletion) { + coreDataStack.performAndSave({ context in + guard let blogInContext = try? context.existingObject(with: self.blog.objectID) as? Blog else { + return + } + if let blockEditorSettings = blogInContext.blockEditorSettings { + // Block Editor Settings nullify on delete + context.delete(blockEditorSettings) + } + }, completion: { + let result = SettingsServiceResult(hasChanges: true, blockEditorSettings: nil) + completion(.success(result)) + }, on: .main) + } + + func track(isBlockEditorSettings: Bool, isFSE: Bool) { + let endpoint = isBlockEditorSettings ? "wp-block-editor" : "theme_supports" + let properties: [AnyHashable: Any] = ["endpoint": endpoint, + "full_site_editing": "\(isFSE)"] + WPAnalytics.track(.gutenbergEditorSettingsFetched, properties: properties) + } +} diff --git a/WordPress/Classes/Services/BlogJetpackSettingsService.swift b/WordPress/Classes/Services/BlogJetpackSettingsService.swift index b0a2601d3df2..4771b2994b92 100644 --- a/WordPress/Classes/Services/BlogJetpackSettingsService.swift +++ b/WordPress/Classes/Services/BlogJetpackSettingsService.swift @@ -4,10 +4,10 @@ import WordPressKit struct BlogJetpackSettingsService { - fileprivate let context: NSManagedObjectContext + private let coreDataStack: CoreDataStack - init(managedObjectContext context: NSManagedObjectContext) { - self.context = context + init(coreDataStack: CoreDataStack) { + self.coreDataStack = coreDataStack } /// Sync ALL the Jetpack settings for a blog @@ -18,10 +18,10 @@ struct BlogJetpackSettingsService { return } guard let remoteAPI = blog.wordPressComRestApi(), - let blogDotComId = blog.dotComID as? Int, - let blogSettings = blog.settings else { - success() - return + let blogDotComId = blog.dotComID as? Int + else { + failure(nil) + return } var fetchError: Error? = nil @@ -60,14 +60,15 @@ struct BlogJetpackSettingsService { failure(fetchError) return } - self.updateJetpackSettings(blogSettings, remoteSettings: remoteJetpackSettings) - self.updateJetpackMonitorSettings(blogSettings, remoteSettings: remoteJetpackMonitorSettings) - do { - try self.context.save() - success() - } catch let error as NSError { - failure(error) - } + + self.coreDataStack.performAndSave({ context in + guard let blogSettings = Blog.lookup(withObjectID: blog.objectID, in: context)?.settings else { + return + } + + self.updateJetpackSettings(blogSettings, remoteSettings: remoteJetpackSettings) + self.updateJetpackMonitorSettings(blogSettings, remoteSettings: remoteJetpackMonitorSettings) + }, completion: success, on: .main) }) } @@ -79,75 +80,67 @@ struct BlogJetpackSettingsService { return } guard let remoteAPI = blog.wordPressComRestApi(), - let blogDotComId = blog.dotComID as? Int, - let blogSettings = blog.settings else { - failure(nil) - return + let blogDotComId = blog.dotComID as? Int + else { + failure(nil) + return } let remote = BlogJetpackSettingsServiceRemote(wordPressComRestApi: remoteAPI) - remote.getJetpackModulesSettingsForSite(blogDotComId, - success: { (remoteModulesSettings) in - self.updateJetpackModulesSettings(blogSettings, remoteSettings: remoteModulesSettings) - do { - try self.context.save() - success() - } catch let error as NSError { - failure(error) - } - }, - failure: { (error) in - failure(error) - }) + remote.getJetpackModulesSettingsForSite( + blogDotComId, + success: { (remoteModulesSettings) in + self.coreDataStack.performAndSave({ context in + guard let blogSettings = Blog.lookup(withObjectID: blog.objectID, in: context)?.settings else { + return + } + self.updateJetpackModulesSettings(blogSettings, remoteSettings: remoteModulesSettings) + }, completion: success, on: .main) + }, + failure: failure + ) } func updateJetpackSettingsForBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { guard let remoteAPI = blog.wordPressComRestApi(), let blogDotComId = blog.dotComID as? Int, - let blogSettings = blog.settings else { - failure(nil) - return + let blogSettings = blog.settings + else { + failure(nil) + return } + let changes = blogSettings.changedValues() let remote = BlogJetpackSettingsServiceRemote(wordPressComRestApi: remoteAPI) - remote.updateJetpackSettingsForSite(blogDotComId, - settings: jetpackSettingsRemote(blogSettings), - success: { - do { - try self.context.save() - success() - } catch let error as NSError { - failure(error) - } - }, - failure: { (error) in - failure(error) - }) - + remote.updateJetpackSettingsForSite( + blogDotComId, + settings: jetpackSettingsRemote(blogSettings), + success: { + self.updateSettings(of: blog, withKeyValueChanges: changes, success: success) + }, + failure: failure + ) } func updateJetpackMonitorSettingsForBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { guard let remoteAPI = blog.wordPressComRestApi(), let blogDotComId = blog.dotComID as? Int, - let blogSettings = blog.settings else { - failure(nil) - return + let blogSettings = blog.settings + else { + failure(nil) + return } + let changes = blogSettings.changedValues() let remote = BlogJetpackSettingsServiceRemote(wordPressComRestApi: remoteAPI) - remote.updateJetpackMonitorSettingsForSite(blogDotComId, - settings: jetpackMonitorsSettingsRemote(blogSettings), - success: { - do { - try self.context.save() - success() - } catch let error as NSError { - failure(error) - } - }, - failure: { (error) in - failure(error) - }) + remote.updateJetpackMonitorSettingsForSite( + blogDotComId, + settings: jetpackMonitorsSettingsRemote(blogSettings), + success: { + self.updateSettings(of: blog, withKeyValueChanges: changes, success: success) + }, + failure: failure + ) } func updateJetpackLazyImagesModuleSettingForBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { @@ -156,15 +149,21 @@ struct BlogJetpackSettingsService { return } - updateJetpackModuleActiveSettingForBlog(blog, - module: BlogJetpackSettingsServiceRemote.Keys.lazyLoadImages, - active: blogSettings.jetpackLazyLoadImages, - success: { - success() - }, - failure: { (error) in - failure(error) - }) + let isActive = blogSettings.jetpackLazyLoadImages + updateJetpackModuleActiveSettingForBlog( + blog, + module: BlogJetpackSettingsServiceRemote.Keys.lazyLoadImages, + active: isActive, + success: { + self.coreDataStack.performAndSave({ context in + guard let blogSettingsInContext = Blog.lookup(withObjectID: blog.objectID, in: context)?.settings else { + return + } + blogSettingsInContext.jetpackLazyLoadImages = isActive + }, completion: success, on: .main) + }, + failure: failure + ) } func updateJetpackServeImagesFromOurServersModuleSettingForBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { @@ -173,15 +172,21 @@ struct BlogJetpackSettingsService { return } - updateJetpackModuleActiveSettingForBlog(blog, - module: BlogJetpackSettingsServiceRemote.Keys.serveImagesFromOurServers, - active: blogSettings.jetpackServeImagesFromOurServers, - success: { - success() - }, - failure: { (error) in - failure(error) - }) + let isActive = blogSettings.jetpackServeImagesFromOurServers + updateJetpackModuleActiveSettingForBlog( + blog, + module: BlogJetpackSettingsServiceRemote.Keys.serveImagesFromOurServers, + active: isActive, + success: { + self.coreDataStack.performAndSave({ context in + guard let blogSettingsInContext = Blog.lookup(withObjectID: blog.objectID, in: context)?.settings else { + return + } + blogSettingsInContext.jetpackServeImagesFromOurServers = isActive + }, completion: success, on: .main) + }, + failure: failure + ) } func updateJetpackModuleActiveSettingForBlog(_ blog: Blog, module: String, active: Bool, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { @@ -192,20 +197,13 @@ struct BlogJetpackSettingsService { } let remote = BlogJetpackSettingsServiceRemote(wordPressComRestApi: remoteAPI) - remote.updateJetpackModuleActiveSettingForSite(blogDotComId, - module: module, - active: active, - success: { - do { - try self.context.save() - success() - } catch let error as NSError { - failure(error) - } - }, - failure: { (error) in - failure(error) - }) + remote.updateJetpackModuleActiveSettingForSite( + blogDotComId, + module: module, + active: active, + success: success, + failure: failure + ) } func disconnectJetpackFromBlog(_ blog: Blog, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { @@ -216,13 +214,7 @@ struct BlogJetpackSettingsService { } let remote = BlogJetpackSettingsServiceRemote(wordPressComRestApi: remoteAPI) - remote.disconnectJetpackFromSite(blogDotComId, - success: { - success() - }, - failure: { (error) in - failure(error) - }) + remote.disconnectJetpackFromSite(blogDotComId, success: success, failure: failure) } } @@ -232,7 +224,7 @@ private extension BlogJetpackSettingsService { func updateJetpackSettings(_ settings: BlogSettings, remoteSettings: RemoteBlogJetpackSettings) { settings.jetpackMonitorEnabled = remoteSettings.monitorEnabled settings.jetpackBlockMaliciousLoginAttempts = remoteSettings.blockMaliciousLoginAttempts - settings.jetpackLoginWhiteListedIPAddresses = remoteSettings.loginWhiteListedIPAddresses + settings.jetpackLoginAllowListedIPAddresses = remoteSettings.loginAllowListedIPAddresses settings.jetpackSSOEnabled = remoteSettings.ssoEnabled settings.jetpackSSOMatchAccountsByEmail = remoteSettings.ssoMatchAccountsByEmail settings.jetpackSSORequireTwoStepAuthentication = remoteSettings.ssoRequireTwoStepAuthentication @@ -251,7 +243,7 @@ private extension BlogJetpackSettingsService { func jetpackSettingsRemote(_ settings: BlogSettings) -> RemoteBlogJetpackSettings { return RemoteBlogJetpackSettings(monitorEnabled: settings.jetpackMonitorEnabled, blockMaliciousLoginAttempts: settings.jetpackBlockMaliciousLoginAttempts, - loginWhiteListedIPAddresses: settings.jetpackLoginWhiteListedIPAddresses ?? Set<String>(), + loginAllowListedIPAddresses: settings.jetpackLoginAllowListedIPAddresses ?? Set<String>(), ssoEnabled: settings.jetpackSSOEnabled, ssoMatchAccountsByEmail: settings.jetpackSSOMatchAccountsByEmail, ssoRequireTwoStepAuthentication: settings.jetpackSSORequireTwoStepAuthentication) @@ -262,4 +254,15 @@ private extension BlogJetpackSettingsService { monitorPushNotifications: settings.jetpackMonitorPushNotifications) } + func updateSettings(of blog: Blog, withKeyValueChanges changes: [String: Any], success: @escaping () -> Void) { + coreDataStack.performAndSave({ context in + guard let blogSettingsInContext = Blog.lookup(withObjectID: blog.objectID, in: context)?.settings else { + return + } + + for (key, value) in changes { + blogSettingsInContext.setValue(value, forKey: key) + } + }, completion: success, on: .main) + } } diff --git a/WordPress/Classes/Services/BlogService+BlogAuthors.swift b/WordPress/Classes/Services/BlogService+BlogAuthors.swift index 8eb2736b5ef6..941255f4d409 100644 --- a/WordPress/Classes/Services/BlogService+BlogAuthors.swift +++ b/WordPress/Classes/Services/BlogService+BlogAuthors.swift @@ -2,14 +2,19 @@ import Foundation extension BlogService { - @objc func blogAuthors(for blog: Blog, with remoteUsers: [RemoteUser]) { + /// Synchronizes authors for a `Blog` from an array of `RemoteUser`s. + /// - Parameters: + /// - blog: Blog object. + /// - remoteUsers: Array of `RemoteUser`s. + @objc(updateBlogAuthorsForBlog:withRemoteUsers:inContext:) + func updateBlogAuthors(for blog: Blog, with remoteUsers: [RemoteUser], in context: NSManagedObjectContext) { do { - guard let blog = try managedObjectContext.existingObject(with: blog.objectID) as? Blog else { + guard let blog = try context.existingObject(with: blog.objectID) as? Blog else { return } remoteUsers.forEach { - let blogAuthor = findBlogAuthor(with: $0.userID, and: blog) + let blogAuthor = findBlogAuthor(with: $0.userID, and: blog, in: context) blogAuthor.userID = $0.userID blogAuthor.username = $0.username blogAuthor.email = $0.email @@ -17,9 +22,16 @@ extension BlogService { blogAuthor.primaryBlogID = $0.primaryBlogID blogAuthor.avatarURL = $0.avatarURL blogAuthor.linkedUserID = $0.linkedUserID + blogAuthor.deletedFromBlog = false blog.addToAuthors(blogAuthor) } + + // Local authors who weren't included in the remote users array should be set as deleted. + let remoteUserIDs = Set(remoteUsers.map { $0.userID }) + blog.authors? + .filter { !remoteUserIDs.contains($0.userID) } + .forEach { $0.deletedFromBlog = true } } catch { return } @@ -28,8 +40,7 @@ extension BlogService { private extension BlogService { - private func findBlogAuthor(with userId: NSNumber, and blog: Blog) -> BlogAuthor { - return managedObjectContext.entity(of: BlogAuthor.self, - with: NSPredicate(format: "\(#keyPath(BlogAuthor.userID)) = %@ AND \(#keyPath(BlogAuthor.blog)) = %@", userId, blog)) + private func findBlogAuthor(with userId: NSNumber, and blog: Blog, in context: NSManagedObjectContext) -> BlogAuthor { + return context.entity(of: BlogAuthor.self, with: NSPredicate(format: "\(#keyPath(BlogAuthor.userID)) = %@ AND \(#keyPath(BlogAuthor.blog)) = %@", userId, blog)) } } diff --git a/WordPress/Classes/Services/BlogService+BloggingPrompts.swift b/WordPress/Classes/Services/BlogService+BloggingPrompts.swift new file mode 100644 index 000000000000..1ae65dc678dd --- /dev/null +++ b/WordPress/Classes/Services/BlogService+BloggingPrompts.swift @@ -0,0 +1,22 @@ +import CoreData + +extension BlogService { + + @objc func updatePromptSettings(for blog: RemoteBlog?, context: NSManagedObjectContext) { + guard let blog = blog, + let jsonSettings = blog.options["blogging_prompts_settings"] as? [String: Any], + let settingsValue = jsonSettings["value"], + let data = try? JSONSerialization.data(withJSONObject: settingsValue), + let remoteSettings = try? JSONDecoder().decode(RemoteBloggingPromptsSettings.self, from: data) else { + return + } + + let fetchRequest = BloggingPromptSettings.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "\(#keyPath(BloggingPromptSettings.siteID)) = %@", blog.blogID) + fetchRequest.fetchLimit = 1 + let existingSettings = (try? context.fetch(fetchRequest))?.first + let settings = existingSettings ?? BloggingPromptSettings(context: context) + settings.configure(with: remoteSettings, siteID: blog.blogID.int32Value, context: context) + } + +} diff --git a/WordPress/Classes/Services/BlogService+Deduplicate.swift b/WordPress/Classes/Services/BlogService+Deduplicate.swift deleted file mode 100644 index 9f3bc775f2d5..000000000000 --- a/WordPress/Classes/Services/BlogService+Deduplicate.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation - -extension BlogService { - /// Removes any duplicate blogs in the given account - /// - /// We consider a blog to be a duplicate of another if they have the same dotComID. - /// For each group of duplicate blogs, this will delete all but one, giving preference to - /// blogs that have local drafts. - /// - /// If there's more than one blog in each group with local drafts, those will be reassigned - /// to the remaining blog. - /// - @objc(deduplicateBlogsForAccount:) - func deduplicateBlogs(for account: WPAccount) { - // Group all the account blogs by ID so it's easier to find duplicates - let blogsById = Dictionary(grouping: account.blogs, by: { $0.dotComID?.intValue ?? 0 }) - // For any group with more than one blog, remove duplicates - for (blogID, group) in blogsById where group.count > 1 { - assert(blogID > 0, "There should not be a Blog without ID if it has an account") - guard blogID > 0 else { - DDLogError("Found one or more WordPress.com blogs without ID, skipping de-duplication") - continue - } - DDLogWarn("Found \(group.count - 1) duplicates for blog with ID \(blogID)") - deduplicate(group: group) - } - } - - private func deduplicate(group: [Blog]) { - // If there's a blog with local drafts, we'll preserve that one, otherwise we pick up the first - // since we don't really care which blog to pick - let candidateIndex = group.firstIndex(where: { !localDrafts(for: $0).isEmpty }) ?? 0 - let candidate = group[candidateIndex] - - // We look through every other blog - for (index, blog) in group.enumerated() where index != candidateIndex { - // If there are other blogs with local drafts, we reassing them to the blog that - // is not going to be deleted - for draft in localDrafts(for: blog) { - DDLogInfo("Migrating local draft \(draft.postTitle ?? "<Untitled>") to de-duplicated blog") - draft.blog = candidate - } - // Once the drafts are moved (if any), we can safely delete the duplicate - DDLogInfo("Deleting duplicate blog \(blog.logDescription())") - managedObjectContext.delete(blog) - } - } - - private func localDrafts(for blog: Blog) -> [AbstractPost] { - // The original predicate from PostService.countPostsWithoutRemote() was: - // "postID = NULL OR postID <= 0" - // Swift optionals make things a bit more verbose, but this should be equivalent - return blog.posts?.filter({ (post) -> Bool in - if let postID = post.postID?.intValue, - postID > 0 { - return false - } - return true - }) ?? [] - } -} diff --git a/WordPress/Classes/Services/BlogService+Domains.swift b/WordPress/Classes/Services/BlogService+Domains.swift new file mode 100644 index 000000000000..cc1039a90799 --- /dev/null +++ b/WordPress/Classes/Services/BlogService+Domains.swift @@ -0,0 +1,42 @@ +import Foundation + +/// This extension is necessary because DomainsService is unavailable in ObjC. +/// +extension BlogService { + enum BlogServiceDomainError: Error { + case noAccountForSpecifiedBlog(blog: Blog) + case noSiteIDForSpecifiedBlog(blog: Blog) + case noWordPressComRestApi(blog: Blog) + } + + /// Convenience method to be able to refresh the blogs from ObjC. + /// + @objc + func refreshDomains(for blog: Blog, success: (() -> Void)?, failure: ((Error) -> Void)?) { + guard let account = blog.account else { + failure?(BlogServiceDomainError.noAccountForSpecifiedBlog(blog: blog)) + return + } + + guard account.wordPressComRestApi != nil else { + failure?(BlogServiceDomainError.noWordPressComRestApi(blog: blog)) + return + } + + guard let siteID = blog.dotComID?.intValue else { + failure?(BlogServiceDomainError.noSiteIDForSpecifiedBlog(blog: blog)) + return + } + + let service = DomainsService(coreDataStack: coreDataStack, account: account) + + service.refreshDomains(siteID: siteID) { result in + switch result { + case .success: + success?() + case .failure(let error): + failure?(error) + } + } + } +} diff --git a/WordPress/Classes/Services/BlogService+JetpackConvenience.swift b/WordPress/Classes/Services/BlogService+JetpackConvenience.swift index b71ec40397d5..e1956f5e33c5 100644 --- a/WordPress/Classes/Services/BlogService+JetpackConvenience.swift +++ b/WordPress/Classes/Services/BlogService+JetpackConvenience.swift @@ -1,6 +1,13 @@ extension BlogService { static func blog(with site: JetpackSiteRef, context: NSManagedObjectContext = ContextManager.shared.mainContext) -> Blog? { - let service = BlogService(managedObjectContext: context) - return service.blog(byBlogId: site.siteID as NSNumber, andUsername: site.username) + let blog: Blog? + + if site.isSelfHostedWithoutJetpack, let xmlRPC = site.xmlRPC { + blog = Blog.lookup(username: site.username, xmlrpc: xmlRPC, in: context) + } else { + blog = try? BlogQuery().blogID(site.siteID).dotComAccountUsername(site.username).blog(in: context) + } + + return blog } } diff --git a/WordPress/Classes/Services/BlogService+Reminders.swift b/WordPress/Classes/Services/BlogService+Reminders.swift new file mode 100644 index 000000000000..c3bd08396c4b --- /dev/null +++ b/WordPress/Classes/Services/BlogService+Reminders.swift @@ -0,0 +1,15 @@ +import Foundation + +extension BlogService { + @objc func unscheduleBloggingReminders(for blog: Blog) { + do { + let scheduler = try ReminderScheduleCoordinator() + scheduler.schedule(.none, for: blog, completion: { _ in }) + // We're currently not propagating success / failure here, as it's + // it's only used when removing blogs or accounts, and there's + // no extra action we can take if it fails anyway. + } catch { + DDLogError("Could not instantiate the reminders scheduler: \(error.localizedDescription)") + } + } +} diff --git a/WordPress/Classes/Services/BlogService.h b/WordPress/Classes/Services/BlogService.h index 287f91837159..87856ed2c947 100644 --- a/WordPress/Classes/Services/BlogService.h +++ b/WordPress/Classes/Services/BlogService.h @@ -1,64 +1,17 @@ #import <Foundation/Foundation.h> -#import "LocalCoreDataService.h" +#import "CoreDataService.h" #import "Blog.h" NS_ASSUME_NONNULL_BEGIN extern NSString *const WordPressMinimumVersion; extern NSString *const WPBlogUpdatedNotification; +extern NSString *const WPBlogSettingsUpdatedNotification; @class WPAccount; @class SiteInfo; -@interface BlogService : LocalCoreDataService - -+ (instancetype)serviceWithMainContext; - -- (instancetype) init __attribute__((unavailable("must use initWithManagedObjectContext"))); - -/** - Returns the blog that matches with a given blogID - */ -- (nullable Blog *)blogByBlogId:(NSNumber *)blogID; - -/** - Returns the blog that matches with a given blogID and account.username - */ -- (nullable Blog *)blogByBlogId:(NSNumber *)blogID andUsername:(NSString *)username; - -/** - Returns the blog that matches with a given hostname - */ -- (nullable Blog *)blogByHostname:(NSString *)hostname; - -/** - Returns the blog currently flagged as the one last used, or the primary blog, - or the first blog in an alphanumerically sorted list, whichever is found first. - */ -- (nullable Blog *)lastUsedOrFirstBlog; - -/** - Returns the blog currently flagged as the one last used, or the primary blog, - or the first blog in an alphanumerically sorted list that supports the given - feature, whichever is found first. - */ -- (nullable Blog *)lastUsedOrFirstBlogThatSupports:(BlogFeature)feature; - -/** - Returns the blog currently flaged as the one last used. - */ -- (nullable Blog *)lastUsedBlog; - -/** - Returns the first blog in an alphanumerically sorted list. - */ -- (nullable Blog *)firstBlog; - -/** - Returns the default WPCom blog. - */ -- (nullable Blog *)primaryBlog; - +@interface BlogService : CoreDataService /** * Sync all available blogs for an acccount @@ -127,6 +80,17 @@ extern NSString *const WPBlogUpdatedNotification; success:(void (^)(void))success failure:(void (^)(NSError *error))failure; +/** + * Sync authors from the server + * + * @param blog the blog from where to read the information from + * @param success a block that is invoked when the sync is successful + * @param failure a block that in invoked when the sync fails. + */ +- (void)syncAuthorsForBlog:(Blog *)blog + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + /** * Update blog settings to the server * @@ -149,94 +113,8 @@ extern NSString *const WPBlogUpdatedNotification; success:(void (^)(void))success failure:(void (^)(NSError *error))failure; -/** - * Update the password for the blog. - * - * @discussion This is only valid for self-hosted sites that don't use jetpack. - * - * @param password the new password to use for the blog - * @param blog to change the password. - */ -- (void)updatePassword:(NSString *)password forBlog:(Blog *)blog; - -- (BOOL)hasVisibleWPComAccounts; - -- (BOOL)hasAnyJetpackBlogs; - -- (NSInteger)blogCountForAllAccounts; - -- (NSInteger)blogCountSelfHosted; - -- (NSInteger)blogCountForWPComAccounts; - -- (NSInteger)blogCountVisibleForWPComAccounts; - -- (NSInteger)blogCountVisibleForAllAccounts; - -- (NSArray<Blog *> *)blogsForAllAccounts; - -- (NSArray<Blog *> *)visibleBlogsForWPComAccounts; - -- (NSArray *)blogsWithNoAccount; - -- (NSArray *)blogsWithPredicate:(NSPredicate *)predicate; - -/** - Returns every stored blog, arranged in a Dictionary by blogId. - */ -- (NSDictionary *)blogsForAllAccountsById; - -/*! Determine timezone for blog from blog options. If no timezone information is stored on - * the device, then assume GMT+0 is the default. - * - * \param blog The blog/site to determine the timezone for. - */ -- (NSTimeZone *)timeZoneForBlog:(Blog *)blog; - - (void)removeBlog:(Blog *)blog; -///-------------------- -/// @name Blog creation -///-------------------- - -/** - Searches for a `Blog` object for this account with the given XML-RPC endpoint - - @warn If more than one blog is found, they'll be considered duplicates and be - deleted leaving only one of them. - - @param xmlrpc the XML-RPC endpoint URL as a string - @param account the account the blog belongs to - @return the blog if one was found, otherwise it returns nil - */ -- (nullable Blog *)findBlogWithXmlrpc:(NSString *)xmlrpc - inAccount:(WPAccount *)account; - -/** - Searches for a `Blog` object for this account with the given username - - @param xmlrpc the XML-RPC endpoint URL as a string - @param username the blog's username - @return the blog if one was found, otherwise it returns nil - */ -- (nullable Blog *)findBlogWithXmlrpc:(NSString *)xmlrpc - andUsername:(NSString *)username; - -/** - Creates a blank `Blog` object for this account - - @param account the account the blog belongs to - @return the newly created blog - */ -- (Blog *)createBlogWithAccount:(WPAccount *)account; - -/** - Creates a blank `Blog` object with no account - - @return the newly created blog - */ -- (Blog *)createBlog; - @end NS_ASSUME_NONNULL_END diff --git a/WordPress/Classes/Services/BlogService.m b/WordPress/Classes/Services/BlogService.m index 775fc8e093f3..b5a2e82ea186 100644 --- a/WordPress/Classes/Services/BlogService.m +++ b/WordPress/Classes/Services/BlogService.m @@ -2,130 +2,29 @@ #import "Blog.h" #import "WPAccount.h" #import "AccountService.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "WPError.h" -#import "Comment.h" #import "Media.h" #import "PostCategoryService.h" #import "CommentService.h" #import "PostService.h" #import "TodayExtensionService.h" -#import "ContextManager.h" #import "WordPress-Swift.h" #import "PostType.h" @import WordPressKit; @import WordPressShared; +@class Comment; + NSString *const WPComGetFeatures = @"wpcom.getFeatures"; NSString *const VideopressEnabled = @"videopress_enabled"; NSString *const WordPressMinimumVersion = @"4.0"; NSString *const HttpsPrefix = @"https://"; NSString *const WPBlogUpdatedNotification = @"WPBlogUpdatedNotification"; - -CGFloat const OneHourInSeconds = 60.0 * 60.0; - +NSString *const WPBlogSettingsUpdatedNotification = @"WPBlogSettingsUpdatedNotification"; @implementation BlogService -+ (instancetype)serviceWithMainContext -{ - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - return [[BlogService alloc] initWithManagedObjectContext:context]; -} - -- (Blog *)blogByBlogId:(NSNumber *)blogID -{ - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"blogID == %@", blogID]; - return [self blogWithPredicate:predicate]; -} - -- (Blog *)blogByBlogId:(NSNumber *)blogID andUsername:(NSString *)username -{ - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"blogID = %@ AND account.username = %@", blogID, username]; - return [self blogWithPredicate:predicate]; -} - -- (Blog *)blogByHostname:(NSString *)hostname -{ - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"url CONTAINS %@", hostname]; - NSArray <Blog *>* blogs = [self blogsWithPredicate:predicate]; - return [blogs firstObject]; -} - -- (Blog *)lastUsedOrFirstBlog -{ - Blog *blog = [self lastUsedOrPrimaryBlog]; - - if (!blog) { - blog = [self firstBlog]; - } - - return blog; -} - -- (Blog *)lastUsedOrFirstBlogThatSupports:(BlogFeature)feature -{ - Blog *blog = [self lastUsedOrPrimaryBlog]; - - if (![blog supports:feature]) { - blog = [self firstBlogThatSupports:feature]; - } - - return blog; -} - -- (Blog *)lastUsedOrPrimaryBlog -{ - Blog *blog = [self lastUsedBlog]; - - if (!blog) { - blog = [self primaryBlog]; - } - - return blog; -} - -- (Blog *)lastUsedBlog -{ - // Try to get the last used blog, if there is one. - RecentSitesService *recentSitesService = [RecentSitesService new]; - NSString *url = [[recentSitesService recentSites] firstObject]; - if (!url) { - return nil; - } - - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"visible = YES AND url = %@", url]; - Blog *blog = [self blogWithPredicate:predicate]; - - return blog; -} - -- (Blog *)primaryBlog -{ - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; - return defaultAccount.defaultBlog; -} - -- (Blog *)firstBlogThatSupports:(BlogFeature)feature -{ - NSPredicate *predicate = [self predicateForVisibleBlogs]; - NSArray *results = [self blogsWithPredicate:predicate]; - - for (Blog *blog in results) { - if ([blog supports:feature]) { - return blog; - } - } - return nil; -} - -- (Blog *)firstBlog -{ - NSPredicate *predicate = [self predicateForVisibleBlogs]; - return [self blogWithPredicate:predicate]; -} - - (void)syncBlogsForAccount:(WPAccount *)account success:(void (^)(void))success failure:(void (^)(NSError *error))failure @@ -133,27 +32,14 @@ - (void)syncBlogsForAccount:(WPAccount *)account DDLogMethod(); id<AccountServiceRemote> remote = [self remoteForAccount:account]; - [remote getBlogsWithSuccess:^(NSArray *blogs) { - [self.managedObjectContext performBlock:^{ - - // Let's check if the account object is not nil. Otherwise we'll get an exception below. - NSManagedObjectID *accountObjectID = account.objectID; - if (!accountObjectID) { - DDLogError(@"Error: The Account objectID could not be loaded"); - return; - } - - // Reload the Account in the current Context - NSError *error = nil; - WPAccount *accountInContext = (WPAccount *)[self.managedObjectContext existingObjectWithID:accountObjectID - error:&error]; - if (!accountInContext) { - DDLogError(@"Error loading WordPress Account: %@", error); - return; - } - - [self mergeBlogs:blogs withAccount:accountInContext completion:success]; + + BOOL filterJetpackSites = [AppConfiguration showJetpackSitesOnly]; + [remote getBlogs:filterJetpackSites success:^(NSArray *blogs) { + [[[JetpackCapabilitiesService alloc] init] syncWithBlogs:blogs success:^(NSArray<RemoteBlog *> *blogs) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + [self mergeBlogs:blogs withAccountID:account.objectID inContext:context]; + } completion:success onQueue:dispatch_get_main_queue()]; }]; } failure:^(NSError *error) { DDLogError(@"Error syncing blogs: %@", error); @@ -218,16 +104,14 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co dispatch_group_enter(syncGroup); [restRemote syncBlogSettingsWithSuccess:^(RemoteBlogSettings *settings) { - [self.managedObjectContext performBlock:^{ - NSError *error = nil; - Blog *blogInContext = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID - error:&error]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blogInContext = (Blog *)[context existingObjectWithID:blogObjectID error:nil]; if (blogInContext) { [self updateSettings:blogInContext.settings withRemoteSettings:settings]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; } + } completion:^{ dispatch_group_leave(syncGroup); - }]; + } onQueue:dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)]; } failure:^(NSError *error) { DDLogError(@"Failed syncing settings for blog %@: %@", blog.url, error); dispatch_group_leave(syncGroup); @@ -244,7 +128,7 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co dispatch_group_leave(syncGroup); }]; - PostCategoryService *categoryService = [[PostCategoryService alloc] initWithManagedObjectContext:self.managedObjectContext]; + PostCategoryService *categoryService = [[PostCategoryService alloc] initWithCoreDataStack:self.coreDataStack]; dispatch_group_enter(syncGroup); [categoryService syncCategoriesForBlog:blog success:^{ @@ -255,7 +139,7 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co dispatch_group_leave(syncGroup); }]; - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:self.managedObjectContext]; + SharingSyncService *sharingService = [[SharingSyncService alloc] initWithCoreDataStack:self.coreDataStack]; dispatch_group_enter(syncGroup); [sharingService syncPublicizeConnectionsForBlog:blog success:^{ @@ -271,11 +155,11 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co [self updateMultiAuthor:users forBlog:blogObjectID]; dispatch_group_leave(syncGroup); } failure:^(NSError *error) { - DDLogError(@"Failed checking muti-author status for blog %@: %@", blog.url, error); + DDLogError(@"Failed checking multi-author status for blog %@: %@", blog.url, error); dispatch_group_leave(syncGroup); }]; - PlanService *planService = [[PlanService alloc] initWithManagedObjectContext:self.managedObjectContext]; + PlanService *planService = [[PlanService alloc] initWithCoreDataStack:self.coreDataStack]; dispatch_group_enter(syncGroup); [planService getWpcomPlans:blog.account success:^{ @@ -293,16 +177,31 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co dispatch_group_leave(syncGroup); }]; - EditorSettingsService *editorService = [[EditorSettingsService alloc] initWithManagedObjectContext:self.managedObjectContext]; + EditorSettingsService *editorService = [[EditorSettingsService alloc] initWithCoreDataStack:self.coreDataStack]; dispatch_group_enter(syncGroup); [editorService syncEditorSettingsForBlog:blog success:^{ dispatch_group_leave(syncGroup); - } failure:^(NSError * _Nonnull error) { + } failure:^(NSError * _Nonnull __unused error) { DDLogError(@"Failed to sync Editor settings"); dispatch_group_leave(syncGroup); }]; - - + + BlazeService *blazeService = [BlazeService createService]; + dispatch_group_enter(syncGroup); + [blazeService getStatusFor:blog completion:^{ + dispatch_group_leave(syncGroup); + }]; + + if ([DomainsDashboardCardHelper isFeatureEnabled]) { + dispatch_group_enter(syncGroup); + [self refreshDomainsFor:blog success:^{ + dispatch_group_leave(syncGroup); + } failure:^(NSError * _Nonnull error) { + DDLogError(@"Failed refreshing domains: %@", error); + dispatch_group_leave(syncGroup); + }]; + } + // When everything has left the syncGroup (all calls have ended with success // or failure) perform the completionHandler dispatch_group_notify(syncGroup, dispatch_get_main_queue(),^{ @@ -317,23 +216,20 @@ - (void)syncSettingsForBlog:(Blog *)blog failure:(void (^)(NSError *error))failure { NSManagedObjectID *blogID = [blog objectID]; - [self.managedObjectContext performBlock:^{ - Blog *blogInContext = (Blog *)[self.managedObjectContext objectWithID:blogID]; + [self.coreDataStack.mainContext performBlock:^{ + Blog *blogInContext = (Blog *)[self.coreDataStack.mainContext objectWithID:blogID]; if (!blogInContext) { if (success) { success(); } return; } + void(^updateOnSuccess)(RemoteBlogSettings *) = ^(RemoteBlogSettings *remoteSettings) { - [self.managedObjectContext performBlock:^{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blogInContext = (Blog *)[context objectWithID:blogID]; [self updateSettings:blogInContext.settings withRemoteSettings:remoteSettings]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (success) { - success(); - } - }]; - }]; + } completion:nil onQueue:dispatch_get_main_queue()]; }; id<BlogServiceRemote> remote = [self remoteForBlog:blogInContext]; if ([remote isKindOfClass:[BlogServiceRemoteXMLRPC class]]) { @@ -354,49 +250,63 @@ - (void)syncSettingsForBlog:(Blog *)blog }]; } +- (void)syncAuthorsForBlog:(Blog *)blog + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSManagedObjectID *blogObjectID = blog.objectID; + id<BlogServiceRemote> remote = [self remoteForBlog:blog]; + + [remote getAllAuthorsWithSuccess:^(NSArray<RemoteUser *> *users) { + [self updateMultiAuthor:users forBlog:blogObjectID]; + success(); + } failure:^(NSError *error) { + DDLogError(@"Failed checking multi-author status for blog %@: %@", blog.url, error); + failure(error); + }]; +} + - (void)updateSettingsForBlog:(Blog *)blog success:(void (^)(void))success failure:(void (^)(NSError *error))failure { NSManagedObjectID *blogID = [blog objectID]; - [self.managedObjectContext performBlock:^{ - Blog *blogInContext = (Blog *)[self.managedObjectContext objectWithID:blogID]; + NSManagedObjectContext *context = self.coreDataStack.mainContext; + [context performBlock:^{ + Blog *blogInContext = (Blog *)[context objectWithID:blogID]; id<BlogServiceRemote> remote = [self remoteForBlog:blogInContext]; + RemoteBlogSettings *remoteSettings = [self remoteSettingFromSettings:blogInContext.settings]; - void(^saveOnSuccess)(void) = ^() { - [self.managedObjectContext performBlock:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (success) { - success(); - } - }]; - }]; + void(^onSuccess)(void) = ^() { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blogInContext = (Blog *)[context existingObjectWithID:blogID error:nil]; + if (blogInContext) { + [self updateSettings:blogInContext.settings withRemoteSettings:remoteSettings]; + } + } completion:^{ + if (success) { + success(); + } + } onQueue:dispatch_get_main_queue()]; }; if ([remote isKindOfClass:[BlogServiceRemoteXMLRPC class]]) { BlogServiceRemoteXMLRPC *xmlrpcRemote = remote; - RemoteBlogSettings *remoteSettings = [self remoteSettingFromSettings:blogInContext.settings]; [xmlrpcRemote updateBlogOptionsWith:[RemoteBlogOptionsHelper remoteOptionsForUpdatingBlogTitleAndTagline:remoteSettings] - success:saveOnSuccess + success:onSuccess failure:failure]; } else if([remote isKindOfClass:[BlogServiceRemoteREST class]]) { BlogServiceRemoteREST *restRemote = remote; - [restRemote updateBlogSettings:[self remoteSettingFromSettings:blogInContext.settings] - success:saveOnSuccess + [restRemote updateBlogSettings:remoteSettings + success:onSuccess failure:failure]; } }]; } -- (void)updatePassword:(NSString *)password forBlog:(Blog *)blog -{ - blog.password = password; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; -} - - (void)syncPostTypesForBlog:(Blog *)blog success:(void (^)(void))success failure:(void (^)(NSError *error))failure @@ -404,23 +314,24 @@ - (void)syncPostTypesForBlog:(Blog *)blog NSManagedObjectID *blogObjectID = blog.objectID; id<BlogServiceRemote> remote = [self remoteForBlog:blog]; [remote syncPostTypesWithSuccess:^(NSArray<RemotePostType *> *remotePostTypes) { - [self.managedObjectContext performBlock:^{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { NSError *blogError; - Blog *blogInContext = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID - error:&blogError]; + Blog *blogInContext = (Blog *)[context existingObjectWithID:blogObjectID error:&blogError]; if (!blogInContext || blogError) { DDLogError(@"Error occurred fetching blog in context with: %@", blogError); - if (failure) { - failure(blogError); - return; - } + dispatch_async(dispatch_get_main_queue(), ^{ + if (failure) { + failure(blogError); + return; + } + }); } // Create new PostType entities with the RemotePostType objects. NSMutableSet *postTypes = [NSMutableSet setWithCapacity:remotePostTypes.count]; NSString *entityName = NSStringFromClass([PostType class]); for (RemotePostType *remoteType in remotePostTypes) { PostType *postType = [NSEntityDescription insertNewObjectForEntityForName:entityName - inManagedObjectContext:self.managedObjectContext]; + inManagedObjectContext:context]; postType.name = remoteType.name; postType.label = remoteType.label; postType.apiQueryable = remoteType.apiQueryable; @@ -428,11 +339,7 @@ - (void)syncPostTypesForBlog:(Blog *)blog } // Replace the current set of postTypes with new entities. blogInContext.postTypes = [NSSet setWithSet:postTypes]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - if (success) { - success(); - } - }]; + } completion:success onQueue:dispatch_get_main_queue()]; } failure:failure]; } @@ -446,87 +353,6 @@ - (void)syncPostFormatsForBlog:(Blog *)blog failure:failure]; } -- (BOOL)hasVisibleWPComAccounts -{ - return [self blogCountVisibleForWPComAccounts] > 0; -} - -- (BOOL)hasAnyJetpackBlogs -{ - NSPredicate *jetpackManagedPredicate = [NSPredicate predicateWithFormat:@"account != NULL AND isHostedAtWPcom = NO"]; - NSInteger jetpackManagedCount = [self blogCountWithPredicate:jetpackManagedPredicate]; - if (jetpackManagedCount > 0) { - return YES; - } - - NSArray *selfHostedBlogs = [self blogsWithNoAccount]; - NSArray *jetpackUnmanagedBlogs = [selfHostedBlogs wp_filter:^BOOL(Blog *blog) { - return blog.jetpack.isConnected; - }]; - - return [jetpackUnmanagedBlogs count] > 0; -} - -- (NSInteger)blogCountForAllAccounts -{ - return [self blogCountWithPredicate:nil]; -} - -- (NSInteger)blogCountSelfHosted -{ - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"account = NULL"]; - return [self blogCountWithPredicate:predicate]; -} - -- (NSInteger)blogCountForWPComAccounts -{ - return [self blogCountWithPredicate:[NSPredicate predicateWithFormat:@"account != NULL"]]; -} - -- (NSInteger)blogCountVisibleForWPComAccounts -{ - NSPredicate *predicate = [self predicateForVisibleBlogsWPComAccounts]; - return [self blogCountWithPredicate:predicate]; -} - -- (NSInteger)blogCountVisibleForAllAccounts -{ - NSPredicate *predicate = [self predicateForVisibleBlogs]; - return [self blogCountWithPredicate:predicate]; -} - -- (NSArray *)blogsWithNoAccount -{ - NSPredicate *predicate = [self predicateForNoAccount]; - return [self blogsWithPredicate:predicate]; -} - -- (NSArray<Blog *> *)blogsForAllAccounts -{ - return [self blogsWithPredicate:nil]; -} - -- (NSArray<Blog *> *)visibleBlogsForWPComAccounts -{ - NSPredicate *predicate = [self predicateForVisibleBlogsWPComAccounts]; - return [self blogsWithPredicate:predicate]; -} - -- (NSDictionary *)blogsForAllAccountsById -{ - NSMutableDictionary *blogMap = [NSMutableDictionary dictionary]; - NSArray *allBlogs = [self blogsWithPredicate:nil]; - - for (Blog *blog in allBlogs) { - if (blog.dotComID != nil) { - blogMap[blog.dotComID] = blog; - } - } - - return blogMap; -} - - ///-------------------- /// @name Blog creation ///-------------------- @@ -538,83 +364,24 @@ - (Blog *)findBlogWithDotComID:(NSNumber *)dotComID return [[account.blogs filteredSetUsingPredicate:predicate] anyObject]; } -- (Blog *)findBlogWithXmlrpc:(NSString *)xmlrpc - inAccount:(WPAccount *)account -{ - NSSet *foundBlogs = [account.blogs filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"xmlrpc like %@", xmlrpc]]; - if ([foundBlogs count] == 1) { - return [foundBlogs anyObject]; - } - - // If more than one blog matches, return the first and delete the rest - if ([foundBlogs count] > 1) { - Blog *blogToReturn = [foundBlogs anyObject]; - for (Blog *b in foundBlogs) { - // Choose blogs with URL not starting with https to account for a glitch in the API in early 2014 - if (!([b.url hasPrefix:HttpsPrefix])) { - blogToReturn = b; - break; - } - } - - for (Blog *b in foundBlogs) { - if (!([b isEqual:blogToReturn])) { - [self.managedObjectContext deleteObject:b]; - } - } - - return blogToReturn; - } - return nil; -} - -- (Blog *)findBlogWithXmlrpc:(NSString *)xmlrpc - andUsername:(NSString *)username -{ - NSArray *foundBlogs = [self blogsWithPredicate:[NSPredicate predicateWithFormat:@"xmlrpc = %@ AND username = %@", xmlrpc, username]]; - return [foundBlogs firstObject]; -} - -- (Blog *)createBlogWithAccount:(WPAccount *)account -{ - Blog *blog = [self createBlog]; - blog.account = account; - return blog; -} - -- (Blog *)createBlog -{ - NSString *entityName = NSStringFromClass([Blog class]); - Blog *blog = [NSEntityDescription insertNewObjectForEntityForName:entityName - inManagedObjectContext:self.managedObjectContext]; - blog.settings = [self createSettingsWithBlog:blog]; - return blog; -} - -- (BlogSettings *)createSettingsWithBlog:(Blog *)blog -{ - NSString *entityName = [BlogSettings classNameWithoutNamespaces]; - BlogSettings *settings = [NSEntityDescription insertNewObjectForEntityForName:entityName - inManagedObjectContext:self.managedObjectContext]; - settings.blog = blog; - return settings; -} - - (void)removeBlog:(Blog *)blog { DDLogInfo(@"<Blog:%@> remove", blog.hostURL); [blog.xmlrpcApi invalidateAndCancelTasks]; + [self unscheduleBloggingRemindersFor:blog]; + WPAccount *account = blog.account; - [self.managedObjectContext deleteObject:blog]; - [self.managedObjectContext processPendingChanges]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blogInContext = [context existingObjectWithID:blog.objectID error:nil]; + [context deleteObject:blogInContext]; + }]; - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext]; if (account) { + AccountService *accountService = [[AccountService alloc] initWithCoreDataStack:self.coreDataStack]; [accountService purgeAccountIfUnused:account]; } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; [WPAnalytics refreshMetadata]; } @@ -623,37 +390,37 @@ - (void)associateSyncedBlogsToJetpackAccount:(WPAccount *)account failure:(void (^)(NSError *error))failure { AccountServiceRemoteREST *remote = [[AccountServiceRemoteREST alloc] initWithWordPressComRestApi:account.wordPressComRestApi]; - [remote getBlogsWithSuccess:^(NSArray *remoteBlogs) { - - NSMutableSet *accountBlogIDs = [NSMutableSet new]; - for (RemoteBlog *remoteBlog in remoteBlogs) { - [accountBlogIDs addObject:remoteBlog.blogID]; - } - - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([Blog class])]; - request.predicate = [NSPredicate predicateWithFormat:@"account = NULL"]; - NSArray *blogs = [self.managedObjectContext executeFetchRequest:request error:nil]; - blogs = [blogs filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id evaluatedObject, NSDictionary *bindings) { - Blog *blog = (Blog *)evaluatedObject; - NSNumber *jetpackBlogID = blog.jetpack.siteID; - return jetpackBlogID && [accountBlogIDs containsObject:jetpackBlogID]; - }]]; - [account addBlogs:[NSSet setWithArray:blogs]]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - - success(); - - } failure:^(NSError *error) { - failure(error); + + BOOL filterJetpackSites = [AppConfiguration showJetpackSitesOnly]; + + [remote getBlogs:filterJetpackSites success:^(NSArray *remoteBlogs) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSMutableSet *accountBlogIDs = [NSMutableSet new]; + for (RemoteBlog *remoteBlog in remoteBlogs) { + [accountBlogIDs addObject:remoteBlog.blogID]; + } - }]; + NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([Blog class])]; + request.predicate = [NSPredicate predicateWithFormat:@"account = NULL"]; + NSArray *blogs = [context executeFetchRequest:request error:nil]; + blogs = [blogs filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id evaluatedObject, NSDictionary * __unused bindings) { + Blog *blog = (Blog *)evaluatedObject; + NSNumber *jetpackBlogID = blog.jetpack.siteID; + return jetpackBlogID && [accountBlogIDs containsObject:jetpackBlogID]; + }]]; + + WPAccount *accountInContext = [context existingObjectWithID:account.objectID error:nil]; + [accountInContext addBlogs:[NSSet setWithArray:blogs]]; + } completion:success onQueue:dispatch_get_main_queue()]; + } failure:failure]; } #pragma mark - Private methods -- (void)mergeBlogs:(NSArray<RemoteBlog *> *)blogs withAccount:(WPAccount *)account completion:(void (^)(void))completion +- (void)mergeBlogs:(NSArray<RemoteBlog *> *)blogs withAccountID:(NSManagedObjectID *)accountID inContext:(NSManagedObjectContext *)context { // Nuke dead blogs + WPAccount *account = [context existingObjectWithID:accountID error:nil]; NSSet *remoteSet = [NSSet setWithArray:[blogs valueForKey:@"blogID"]]; NSSet *localSet = [account.blogs valueForKey:@"dotComID"]; NSMutableSet *toDelete = [localSet mutableCopy]; @@ -662,7 +429,10 @@ - (void)mergeBlogs:(NSArray<RemoteBlog *> *)blogs withAccount:(WPAccount *)accou if ([toDelete count] > 0) { for (Blog *blog in account.blogs) { if ([toDelete containsObject:blog.dotComID]) { - [self.managedObjectContext deleteObject:blog]; + [self unscheduleBloggingRemindersFor:blog]; + // Consider switching this to a call to removeBlog in the future + // to consolidate behaviour @frosty + [context deleteObject:blog]; } } } @@ -670,7 +440,8 @@ - (void)mergeBlogs:(NSArray<RemoteBlog *> *)blogs withAccount:(WPAccount *)accou // Go through each remote incoming blog and make sure we're up to date with titles, etc. // Also adds any blogs we don't have for (RemoteBlog *remoteBlog in blogs) { - [self updateBlogWithRemoteBlog:remoteBlog account:account]; + [self updateBlogWithRemoteBlog:remoteBlog account:account inContext:context]; + [self updatePromptSettingsFor:remoteBlog context:context]; } /* @@ -682,30 +453,28 @@ - (void)mergeBlogs:(NSArray<RemoteBlog *> *)blogs withAccount:(WPAccount *)accou More context here: https://github.com/wordpress-mobile/WordPress-iOS/issues/7886#issuecomment-524221031 */ - [self deduplicateBlogsForAccount:account]; - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; + [account deduplicateBlogs]; // Ensure that the account has a default blog defined (if there is one). - AccountService *service = [[AccountService alloc]initWithManagedObjectContext:self.managedObjectContext]; - [service updateDefaultBlogIfNeeded:account]; - - if (completion != nil) { - dispatch_async(dispatch_get_main_queue(), completion); - } + AccountService *service = [[AccountService alloc] initWithCoreDataStack:self.coreDataStack]; + [service updateDefaultBlogIfNeeded:account inContext:context]; } -- (void)updateBlogWithRemoteBlog:(RemoteBlog *)remoteBlog account:(WPAccount *)account +- (void)updateBlogWithRemoteBlog:(RemoteBlog *)remoteBlog account:(WPAccount *)account inContext:(NSManagedObjectContext *)context { Blog *blog = [self findBlogWithDotComID:remoteBlog.blogID inAccount:account]; if (!blog && remoteBlog.jetpack) { - blog = [self migrateRemoteJetpackBlog:remoteBlog forAccount:account]; + blog = [self migrateRemoteJetpackBlog:remoteBlog forAccount:account inContext:context]; } if (!blog) { DDLogInfo(@"New blog from account %@: %@", account.username, remoteBlog); - blog = [self createBlogWithAccount:account]; + if (account != nil) { + blog = [Blog createBlankBlogWithAccount:account]; + } else { + blog = [Blog createBlankBlogInContext:context]; + } blog.xmlrpc = remoteBlog.xmlrpc; } @@ -714,12 +483,11 @@ - (void)updateBlogWithRemoteBlog:(RemoteBlog *)remoteBlog account:(WPAccount *)a - (void)updateBlog:(Blog *)blog withRemoteBlog:(RemoteBlog *)remoteBlog { - if (!blog.settings) { - blog.settings = [self createSettingsWithBlog:blog]; - } + [blog addSettingsIfNecessary]; blog.url = remoteBlog.url; blog.dotComID = remoteBlog.blogID; + blog.organizationID = remoteBlog.organizationID; blog.isHostedAtWPcom = !remoteBlog.jetpack; blog.icon = remoteBlog.icon; blog.capabilities = remoteBlog.capabilities; @@ -757,6 +525,7 @@ - (void)updateBlog:(Blog *)blog withRemoteBlog:(RemoteBlog *)remoteBlog */ - (Blog *)migrateRemoteJetpackBlog:(RemoteBlog *)remoteBlog forAccount:(WPAccount *)account + inContext:(NSManagedObjectContext *)context { assert(remoteBlog.xmlrpc != nil); NSURL *xmlrpcURL = [NSURL URLWithString:remoteBlog.xmlrpc]; @@ -767,7 +536,7 @@ - (Blog *)migrateRemoteJetpackBlog:(RemoteBlog *)remoteBlog components.scheme = @"https"; } NSURL *alternateXmlrpcURL = components.URL; - NSArray *blogsWithNoAccount = [self blogsWithNoAccount]; + NSArray *blogsWithNoAccount = [Blog selfHostedInContext:context]; Blog *jetpackBlog = [[blogsWithNoAccount wp_filter:^BOOL(Blog *blogToTest) { return [blogToTest.xmlrpc caseInsensitiveCompare:xmlrpcURL.absoluteString] == NSOrderedSame || [blogToTest.xmlrpc caseInsensitiveCompare:alternateXmlrpcURL.absoluteString] == NSOrderedSame; @@ -804,102 +573,13 @@ - (Blog *)migrateRemoteJetpackBlog:(RemoteBlog *)remoteBlog return [[AccountServiceRemoteREST alloc] initWithWordPressComRestApi:account.wordPressComRestApi]; } -- (Blog *)blogWithPredicate:(NSPredicate *)predicate -{ - return [[self blogsWithPredicate:predicate] firstObject]; -} - -- (NSArray *)blogsWithPredicate:(NSPredicate *)predicate -{ - NSFetchRequest *request = [self fetchRequestWithPredicate:predicate]; - NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"settings.name" - ascending:YES]; - request.sortDescriptors = @[ sortDescriptor ]; - - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:request - error:&error]; - if (error) { - DDLogError(@"Couldn't fetch blogs with predicate %@: %@", predicate, error); - return nil; - } - - return results; -} - -- (NSInteger)blogCountWithPredicate:(NSPredicate *)predicate -{ - NSFetchRequest *request = [self fetchRequestWithPredicate:predicate]; - - NSError *err; - NSUInteger count = [self.managedObjectContext countForFetchRequest:request - error:&err]; - if (count == NSNotFound) { - count = 0; - } - return count; -} - -- (NSFetchRequest *)fetchRequestWithPredicate:(NSPredicate *)predicate -{ - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([Blog class])]; - request.includesSubentities = NO; - request.predicate = predicate; - return request; -} - -- (NSPredicate *)predicateForVisibleBlogs -{ - return [NSPredicate predicateWithFormat:@"visible = YES"]; -} - - -- (NSPredicate *)predicateForVisibleBlogsWPComAccounts -{ - NSArray *subpredicates = @[ - [self predicateForVisibleBlogs], - [NSPredicate predicateWithFormat:@"account != NULL"], - ]; - NSPredicate *predicate = [NSCompoundPredicate andPredicateWithSubpredicates:subpredicates]; - - return predicate; -} - -- (NSPredicate *)predicateForNoAccount -{ - return [NSPredicate predicateWithFormat:@"account = NULL"]; -} - -- (NSUInteger)countForSyncedPostsWithEntityName:(NSString *)entityName - forBlog:(Blog *)blog -{ - __block NSUInteger count = 0; - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:entityName]; - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"(remoteStatusNumber == %@) AND (postID != NULL) AND (original == NULL) AND (blog == %@)", - [NSNumber numberWithInt:AbstractPostRemoteStatusSync], - blog]; - [request setPredicate:predicate]; - NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"date_created_gmt" - ascending:YES]; - [request setSortDescriptors:@[sortDescriptor]]; - request.includesSubentities = NO; - request.resultType = NSCountResultType; - - [self.managedObjectContext performBlockAndWait:^{ - NSError *error = nil; - count = [self.managedObjectContext countForFetchRequest:request - error:&error]; - }]; - return count; -} - #pragma mark - Completion handlers - (void)updateMultiAuthor:(NSArray<RemoteUser *> *)users forBlog:(NSManagedObjectID *)blogObjectID { - [self.managedObjectContext performBlock:^{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { NSError *error; - Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID error:&error]; + Blog *blog = (Blog *)[context existingObjectWithID:blogObjectID error:&error]; if (error) { DDLogError(@"%@", error); } @@ -907,7 +587,7 @@ - (void)updateMultiAuthor:(NSArray<RemoteUser *> *)users forBlog:(NSManagedObjec return; } - [self blogAuthorsFor:blog with:users]; + [self updateBlogAuthorsForBlog:blog withRemoteUsers:users inContext:context]; blog.isMultiAuthor = users.count > 1; /// Search for a matching user ID @@ -933,7 +613,6 @@ - (void)updateMultiAuthor:(NSArray<RemoteUser *> *)users forBlog:(NSManagedObjec } } } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; }]; } @@ -941,19 +620,14 @@ - (BlogDetailsHandler)blogDetailsHandlerWithBlogObjectID:(NSManagedObjectID *)bl completionHandler:(void (^)(void))completion { return ^void(RemoteBlog *remoteBlog) { - [self.managedObjectContext performBlock:^{ - NSError *error = nil; - Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID - error:&error]; - if (blog) { - [self updateBlog:blog withRemoteBlog:remoteBlog]; - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - } - - if (completion) { - completion(); - } + [[[JetpackCapabilitiesService alloc] init] syncWithBlogs:@[remoteBlog] success:^(NSArray<RemoteBlog *> *blogs) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blog = (Blog *)[context existingObjectWithID:blogObjectID error:nil]; + if (blog) { + [self updateBlog:blog withRemoteBlog:blogs.firstObject]; + [self updatePromptSettingsFor:blogs.firstObject context:context]; + } + } completion:completion onQueue:dispatch_get_main_queue()]; }]; }; } @@ -962,35 +636,33 @@ - (OptionsHandler)optionsHandlerWithBlogObjectID:(NSManagedObjectID *)blogObject completionHandler:(void (^)(void))completion { return ^void(NSDictionary *options) { - [self.managedObjectContext performBlock:^{ - Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID - error:nil]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blog = (Blog *)[context existingObjectWithID:blogObjectID error:nil]; if (!blog) { - if (completion) { - completion(); - } return; } + blog.options = [NSDictionary dictionaryWithDictionary:options]; RemoteBlogSettings *remoteSettings = [RemoteBlogOptionsHelper remoteBlogSettingsFromXMLRPCDictionaryOptions:options]; [self updateSettings:blog.settings withRemoteSettings:remoteSettings]; + // NOTE: `[blog version]` can return nil. If this happens `version` will be `0` CGFloat version = [[blog version] floatValue]; if (version > 0 && version < [WordPressMinimumVersion floatValue]) { if (blog.lastUpdateWarning == nil || [blog.lastUpdateWarning floatValue] < [WordPressMinimumVersion floatValue]) { - // TODO :: Remove UI call from service layer - [WPError showAlertWithTitle:NSLocalizedString(@"WordPress version too old", @"") - message:[NSString stringWithFormat:NSLocalizedString(@"The site at %@ uses WordPress %@. We recommend to update to the latest version, or at least %@", @""), [blog hostname], [blog version], WordPressMinimumVersion]]; + dispatch_async(dispatch_get_main_queue(), ^{ + // TODO :: Remove UI call from service layer + [WPError showAlertWithTitle:NSLocalizedString(@"WordPress version too old", @"") + message:[NSString stringWithFormat:NSLocalizedString(@"The site at %@ uses WordPress %@. We recommend to update to the latest version, or at least %@", @""), [blog hostname], [blog version], WordPressMinimumVersion]]; + }); blog.lastUpdateWarning = WordPressMinimumVersion; } } - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:completion]; - }]; + } completion:completion onQueue:dispatch_get_main_queue()]; }; } @@ -998,9 +670,8 @@ - (PostFormatsHandler)postFormatsHandlerWithBlogObjectID:(NSManagedObjectID *)bl completionHandler:(void (^)(void))completion { return ^void(NSDictionary *postFormats) { - [self.managedObjectContext performBlock:^{ - Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID - error:nil]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blog = (Blog *)[context existingObjectWithID:blogObjectID error:nil]; if (blog) { NSDictionary *formats = postFormats; if (![formats objectForKey:PostFormatStandard]) { @@ -1009,51 +680,18 @@ - (PostFormatsHandler)postFormatsHandlerWithBlogObjectID:(NSManagedObjectID *)bl formats = [NSDictionary dictionaryWithDictionary:mutablePostFormats]; } blog.postFormats = formats; - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; } - - if (completion) { - completion(); - } - }]; + } completion:completion onQueue:dispatch_get_main_queue()]; }; } -- (NSTimeZone *)timeZoneForBlog:(Blog *)blog -{ - NSString *timeZoneName = [blog getOptionValue:@"timezone"]; - NSNumber *gmtOffSet = [blog getOptionValue:@"gmt_offset"]; - id optionValue = [blog getOptionValue:@"time_zone"]; - - NSTimeZone *timeZone = nil; - if (timeZoneName.length > 0) { - timeZone = [NSTimeZone timeZoneWithName:timeZoneName]; - } - - if (!timeZone && gmtOffSet != nil) { - timeZone = [NSTimeZone timeZoneForSecondsFromGMT:(gmtOffSet.floatValue * OneHourInSeconds)]; - } - - if (!timeZone && optionValue != nil) { - NSInteger timeZoneOffsetSeconds = [optionValue floatValue] * OneHourInSeconds; - timeZone = [NSTimeZone timeZoneForSecondsFromGMT:timeZoneOffsetSeconds]; - } - - if (!timeZone) { - timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0]; - } - - return timeZone; -} - - (void)updateSettings:(BlogSettings *)settings withRemoteSettings:(RemoteBlogSettings *)remoteSettings { NSParameterAssert(settings); NSParameterAssert(remoteSettings); // Transformables - NSSet *separatedBlacklistKeys = [remoteSettings.commentsBlacklistKeys uniqueStringComponentsSeparatedByNewline]; + NSSet *separatedBlocklistKeys = [remoteSettings.commentsBlocklistKeys uniqueStringComponentsSeparatedByNewline]; NSSet *separatedModerationKeys = [remoteSettings.commentsModerationKeys uniqueStringComponentsSeparatedByNewline]; // General @@ -1075,10 +713,10 @@ - (void)updateSettings:(BlogSettings *)settings withRemoteSettings:(RemoteBlogSe // Discussion settings.commentsAllowed = [remoteSettings.commentsAllowed boolValue]; - settings.commentsBlacklistKeys = separatedBlacklistKeys; + settings.commentsBlocklistKeys = separatedBlocklistKeys; settings.commentsCloseAutomatically = [remoteSettings.commentsCloseAutomatically boolValue]; settings.commentsCloseAutomaticallyAfterDays = remoteSettings.commentsCloseAutomaticallyAfterDays; - settings.commentsFromKnownUsersWhitelisted = [remoteSettings.commentsFromKnownUsersWhitelisted boolValue]; + settings.commentsFromKnownUsersAllowlisted = [remoteSettings.commentsFromKnownUsersAllowlisted boolValue]; settings.commentsMaximumLinks = remoteSettings.commentsMaximumLinks; settings.commentsModerationKeys = separatedModerationKeys; @@ -1123,7 +761,7 @@ - (RemoteBlogSettings *)remoteSettingFromSettings:(BlogSettings *)settings RemoteBlogSettings *remoteSettings = [RemoteBlogSettings new]; // Transformables - NSString *joinedBlacklistKeys = [[settings.commentsBlacklistKeys allObjects] componentsJoinedByString:@"\n"]; + NSString *joinedBlocklistKeys = [[settings.commentsBlocklistKeys allObjects] componentsJoinedByString:@"\n"]; NSString *joinedModerationKeys = [[settings.commentsModerationKeys allObjects] componentsJoinedByString:@"\n"]; // General @@ -1145,10 +783,10 @@ - (RemoteBlogSettings *)remoteSettingFromSettings:(BlogSettings *)settings // Discussion remoteSettings.commentsAllowed = @(settings.commentsAllowed); - remoteSettings.commentsBlacklistKeys = joinedBlacklistKeys; + remoteSettings.commentsBlocklistKeys = joinedBlocklistKeys; remoteSettings.commentsCloseAutomatically = @(settings.commentsCloseAutomatically); remoteSettings.commentsCloseAutomaticallyAfterDays = settings.commentsCloseAutomaticallyAfterDays; - remoteSettings.commentsFromKnownUsersWhitelisted = @(settings.commentsFromKnownUsersWhitelisted); + remoteSettings.commentsFromKnownUsersAllowlisted = @(settings.commentsFromKnownUsersAllowlisted); remoteSettings.commentsMaximumLinks = settings.commentsMaximumLinks; remoteSettings.commentsModerationKeys = joinedModerationKeys; diff --git a/WordPress/Classes/Services/BlogSyncFacade.m b/WordPress/Classes/Services/BlogSyncFacade.m index bd809dd35da2..5f1d443ea506 100644 --- a/WordPress/Classes/Services/BlogSyncFacade.m +++ b/WordPress/Classes/Services/BlogSyncFacade.m @@ -1,5 +1,5 @@ #import "BlogSyncFacade.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "BlogService.h" #import "AccountService.h" #import "Blog.h" @@ -14,8 +14,7 @@ - (void)syncBlogsForAccount:(WPAccount *)account success:(void (^)(void))success failure:(void (^)(NSError *error))failure { - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; + BlogService *blogService = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [blogService syncBlogsForAccount:account success:^{ WP3DTouchShortcutCreator *shortcutCreator = [WP3DTouchShortcutCreator new]; [shortcutCreator createShortcutsIf3DTouchAvailable:YES]; @@ -32,16 +31,15 @@ - (void)syncBlogWithUsername:(NSString *)username finishedSync:(void(^)(Blog *))finishedSync { NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; NSString *blogName = [options stringForKeyPath:@"blog_title.value"]; NSString *url = [options stringForKeyPath:@"home_url.value"]; if (!url) { url = [options stringForKeyPath:@"blog_url.value"]; } - Blog *blog = [blogService findBlogWithXmlrpc:xmlrpc andUsername:username]; + Blog *blog = [Blog lookupWithUsername:username xmlrpc:xmlrpc inContext:context]; if (!blog) { - blog = [blogService createBlogWithAccount:nil]; + blog = [Blog createBlankBlogInContext:context]; if (url) { blog.url = url; } @@ -70,8 +68,7 @@ - (void)syncBlogWithUsername:(NSString *)username NSString *dotcomUsername = [blog getOptionValue:@"jetpack_user_login"]; if (dotcomUsername) { // Search for a matching .com account - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context]; - WPAccount *account = [accountService findAccountWithUsername:dotcomUsername]; + WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:context]; if (account) { blog.account = account; [WPAppAnalytics track:WPAnalyticsStatSignedInToJetpack withBlog:blog]; diff --git a/WordPress/Classes/Services/BloggingPromptsService.swift b/WordPress/Classes/Services/BloggingPromptsService.swift new file mode 100644 index 000000000000..bd68c13a6df3 --- /dev/null +++ b/WordPress/Classes/Services/BloggingPromptsService.swift @@ -0,0 +1,332 @@ +import CoreData +import WordPressKit + +class BloggingPromptsService { + private let contextManager: CoreDataStackSwift + private let siteID: NSNumber + private let remote: BloggingPromptsServiceRemote + private let calendar: Calendar = .autoupdatingCurrent + private let maxListPrompts = 11 + + /// A UTC date formatter that ignores time information. + private static var utcDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .init(identifier: "en_US_POSIX") + formatter.timeZone = .init(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + + return formatter + }() + + /// A date formatter using the local timezone that ignores time information. + private static var localDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .init(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + + return formatter + }() + + /// Convenience computed variable that returns today's prompt from local store. + /// + var localTodaysPrompt: BloggingPrompt? { + loadPrompts(from: Date(), number: 1).first + } + + /// Convenience computed variable that returns prompt settings from the local store. + /// + var localSettings: BloggingPromptSettings? { + loadSettings(context: contextManager.mainContext) + } + + /// Fetches a number of blogging prompts starting from the specified date. + /// When no parameters are specified, this method will attempt to return prompts from ten days ago and two weeks ahead. + /// + /// - Parameters: + /// - startDate: When specified, only prompts after the specified date will be returned. Defaults to 10 days ago. + /// - endDate: When specified, only prompts before the specified date will be returned. + /// - number: The amount of prompts to return. Defaults to 25 when unspecified (10 days back, today, 14 days ahead). + /// - success: Closure to be called when the fetch process succeeded. + /// - failure: Closure to be called when the fetch process failed. + func fetchPrompts(from startDate: Date? = nil, + to endDate: Date? = nil, + number: Int = 25, + success: (([BloggingPrompt]) -> Void)? = nil, + failure: ((Error?) -> Void)? = nil) { + let fromDate = startDate ?? defaultStartDate + remote.fetchPrompts(for: siteID, number: number, fromDate: fromDate) { result in + switch result { + case .success(let remotePrompts): + self.upsert(with: remotePrompts) { innerResult in + if case .failure(let error) = innerResult { + failure?(error) + return + } + + success?(self.loadPrompts(from: fromDate, to: endDate, number: number)) + } + case .failure(let error): + failure?(error) + } + } + } + + /// Convenience method to fetch the blogging prompt for the current day. + /// + /// - Parameters: + /// - success: Closure to be called when the fetch process succeeded. + /// - failure: Closure to be called when the fetch process failed. + func fetchTodaysPrompt(success: ((BloggingPrompt?) -> Void)? = nil, + failure: ((Error?) -> Void)? = nil) { + fetchPrompts(from: Date(), number: 1, success: { (prompts) in + success?(prompts.first) + }, failure: failure) + } + + /// Convenience method to obtain the blogging prompt for the current day, + /// either from local cache or remote. + /// + /// - Parameters: + /// - success: Closure to be called when the fetch process succeeded. + /// - failure: Closure to be called when the fetch process failed. + func todaysPrompt(success: @escaping (BloggingPrompt?) -> Void, + failure: @escaping (Error?) -> Void) { + guard localTodaysPrompt == nil else { + success(localTodaysPrompt) + return + } + + fetchTodaysPrompt(success: success, failure: failure) + } + + /// Convenience method to fetch the blogging prompts for the Prompts List. + /// Fetches 11 prompts - the current day and 10 previous. + /// + /// - Parameters: + /// - success: Closure to be called when the fetch process succeeded. + /// - failure: Closure to be called when the fetch process failed. + func fetchListPrompts(success: @escaping ([BloggingPrompt]) -> Void, + failure: @escaping (Error?) -> Void) { + fetchPrompts(from: listStartDate, to: Date(), number: maxListPrompts, success: success, failure: failure) + } + + /// Loads a single prompt with the given `promptID`. + /// + /// - Parameters: + /// - promptID: The unique ID for the blogging prompt. + /// - blog: The blog associated with the prompt. + /// - Returns: The blogging prompt object if it exists, or nil otherwise. + func loadPrompt(with promptID: Int, in blog: Blog) -> BloggingPrompt? { + guard let siteID = blog.dotComID else { + return nil + } + + let fetchRequest = BloggingPrompt.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.promptID)) = %@", siteID, NSNumber(value: promptID)) + fetchRequest.fetchLimit = 1 + + return (try? self.contextManager.mainContext.fetch(fetchRequest))?.first + } + + // MARK: - Settings + + /// Fetches the blogging prompt settings for the configured `siteID`. + /// + /// - Parameters: + /// - success: Closure to be called on success with an optional `BloggingPromptSettings` object. + /// - failure: Closure to be called on failure with an optional `Error` object. + func fetchSettings(success: @escaping (BloggingPromptSettings?) -> Void, + failure: @escaping (Error?) -> Void) { + remote.fetchSettings(for: siteID) { result in + switch result { + case .success(let remoteSettings): + self.saveSettings(remoteSettings) { + let settings = self.loadSettings(context: self.contextManager.mainContext) + success(settings) + } + case .failure(let error): + failure(error) + } + } + } + + /// Updates the blogging prompt settings for the configured `siteID`. + /// + /// - Parameters: + /// - settings: The new settings to update the remote with + /// - success: Closure to be called on success with an optional `BloggingPromptSettings` object. `nil` is passed + /// when the call is successful but there were no updated settings on the remote. + /// - failure: Closure to be called on failure with an optional `Error` object. + func updateSettings(settings: RemoteBloggingPromptsSettings, + success: @escaping (BloggingPromptSettings?) -> Void, + failure: @escaping (Error?) -> Void) { + remote.updateSettings(for: siteID, with: settings) { result in + switch result { + case .success(let remoteSettings): + guard let updatedSettings = remoteSettings else { + success(nil) + return + } + self.saveSettings(updatedSettings) { + let settings = self.loadSettings(context: self.contextManager.mainContext) + success(settings) + } + case .failure(let error): + failure(error) + } + } + } + + // MARK: - Init + + required init?(contextManager: CoreDataStackSwift = ContextManager.shared, + remote: BloggingPromptsServiceRemote? = nil, + blog: Blog? = nil) { + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext), + let siteID = blog?.dotComID ?? account.primaryBlogID else { + return nil + } + + self.contextManager = contextManager + self.siteID = siteID + self.remote = remote ?? .init(wordPressComRestApi: account.wordPressComRestV2Api) + } +} + +// MARK: - Service Factory + +/// Convenience factory to generate `BloggingPromptsService` for different blogs. +/// +class BloggingPromptsServiceFactory { + let contextManager: CoreDataStackSwift + let remote: BloggingPromptsServiceRemote? + + init(contextManager: CoreDataStackSwift = ContextManager.shared, remote: BloggingPromptsServiceRemote? = nil) { + self.contextManager = contextManager + self.remote = remote + } + + func makeService(for blog: Blog) -> BloggingPromptsService? { + return .init(contextManager: contextManager, remote: remote, blog: blog) + } +} + +// MARK: - Private Helpers + +private extension BloggingPromptsService { + + var defaultStartDate: Date { + calendar.date(byAdding: .day, value: -10, to: Date()) ?? Date() + } + + var listStartDate: Date { + calendar.date(byAdding: .day, value: -(maxListPrompts - 1), to: Date()) ?? Date() + } + + /// Converts the given date to UTC preserving the date and ignores the time information. + /// Examples: + /// Given `2022-05-01 23:00:00 UTC-4` (`2022-05-02 03:00:00 UTC`), this should return `2022-05-01 00:00:00 UTC`. + /// + /// Given `2022-05-02 05:00:00 UTC+9` (`2022-05-01 20:00:00 UTC`), this should return `2022-05-02 00:00:00 UTC`. + /// + /// - Parameter date: The date to convert. + /// - Returns: The UTC date without the time information. + func utcDateIgnoringTime(from date: Date?) -> Date? { + guard let date = date else { + return nil + } + let dateString = Self.localDateFormatter.string(from: date) + return Self.utcDateFormatter.date(from: dateString) + } + + /// Loads local prompts based on the given parameters. + /// + /// - Parameters: + /// - startDate: Only prompts after the specified date will be returned. + /// - endDate: When specified, only prompts before the specified date will be returned. + /// - number: The amount of prompts to return. + /// - Returns: An array of `BloggingPrompt` objects sorted ascending by date. + func loadPrompts(from startDate: Date, to endDate: Date? = nil, number: Int) -> [BloggingPrompt] { + guard let utcStartDate = utcDateIgnoringTime(from: startDate) else { + DDLogError("Error converting date to UTC: \(startDate)") + return [] + } + + let fetchRequest = BloggingPrompt.fetchRequest() + if let utcEndDate = utcDateIgnoringTime(from: endDate) { + let format = "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.date)) >= %@ AND \(#keyPath(BloggingPrompt.date)) <= %@" + fetchRequest.predicate = NSPredicate(format: format, siteID, utcStartDate as NSDate, utcEndDate as NSDate) + } else { + let format = "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.date)) >= %@" + fetchRequest.predicate = NSPredicate(format: format, siteID, utcStartDate as NSDate) + } + fetchRequest.fetchLimit = number + fetchRequest.sortDescriptors = [.init(key: #keyPath(BloggingPrompt.date), ascending: true)] + + return (try? self.contextManager.mainContext.fetch(fetchRequest)) ?? [] + } + + /// Find and update existing prompts, or insert new ones if they don't exist. + /// + /// - Parameters: + /// - remotePrompts: An array containing prompts obtained from remote. + /// - completion: Closure to be called after the process completes. Returns an array of prompts when successful. + func upsert(with remotePrompts: [RemoteBloggingPrompt], completion: @escaping (Result<Void, Error>) -> Void) { + if remotePrompts.isEmpty { + completion(.success(())) + return + } + + let remoteIDs = Set(remotePrompts.map { Int32($0.promptID) }) + let remotePromptsDictionary = remotePrompts.reduce(into: [Int32: RemoteBloggingPrompt]()) { partialResult, remotePrompt in + partialResult[Int32(remotePrompt.promptID)] = remotePrompt + } + + let predicate = NSPredicate(format: "\(#keyPath(BloggingPrompt.siteID)) = %@ AND \(#keyPath(BloggingPrompt.promptID)) IN %@", siteID, remoteIDs) + let fetchRequest = BloggingPrompt.fetchRequest() + fetchRequest.predicate = predicate + + contextManager.performAndSave({ derivedContext in + var foundExistingIDs = [Int32]() + let results = try derivedContext.fetch(fetchRequest) + results.forEach { prompt in + guard let remotePrompt = remotePromptsDictionary[prompt.promptID] else { + return + } + + foundExistingIDs.append(prompt.promptID) + prompt.configure(with: remotePrompt, for: self.siteID.int32Value) + } + + // Insert new prompts + let newPromptIDs = remoteIDs.subtracting(foundExistingIDs) + newPromptIDs.forEach { newPromptID in + guard let remotePrompt = remotePromptsDictionary[newPromptID], + let newPrompt = BloggingPrompt.newObject(in: derivedContext) else { + return + } + newPrompt.configure(with: remotePrompt, for: self.siteID.int32Value) + } + }, completion: completion, on: .main) + } + + /// Updates existing settings or creates new settings from the remote prompt settings. + /// + /// - Parameters: + /// - remoteSettings: The blogging prompt settings from the remote. + /// - completion: Closure to be called on completion. + func saveSettings(_ remoteSettings: RemoteBloggingPromptsSettings, completion: @escaping () -> Void) { + contextManager.performAndSave({ derivedContext in + let settings = self.loadSettings(context: derivedContext) ?? BloggingPromptSettings(context: derivedContext) + settings.configure(with: remoteSettings, siteID: self.siteID.int32Value, context: derivedContext) + }, completion: completion, on: .main) + } + + func loadSettings(context: NSManagedObjectContext) -> BloggingPromptSettings? { + let fetchRequest = BloggingPromptSettings.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "\(#keyPath(BloggingPromptSettings.siteID)) = %@", siteID) + fetchRequest.fetchLimit = 1 + return (try? context.fetch(fetchRequest))?.first + } + +} diff --git a/WordPress/Classes/Services/CommentService+Likes.swift b/WordPress/Classes/Services/CommentService+Likes.swift new file mode 100644 index 000000000000..fa2d761440c2 --- /dev/null +++ b/WordPress/Classes/Services/CommentService+Likes.swift @@ -0,0 +1,124 @@ +extension CommentService { + + /** + Fetches a list of users from remote that liked the comment with the given IDs. + + @param commentID The ID of the comment to fetch likes for + @param siteID The ID of the site that contains the post + @param count Number of records to retrieve. Optional. Defaults to the endpoint max of 90. + @param before Filter results to likes before this date/time. Optional. + @param excludingIDs An array of user IDs to exclude from the returned results. Optional. + @param purgeExisting Indicates if existing Likes for the given post and site should be purged before + new ones are created. Defaults to true. + @param success A success block returning: + - Array of LikeUser + - Total number of likes for the given comment + - Number of likes per fetch + @param failure A failure block + */ + func getLikesFor(commentID: NSNumber, + siteID: NSNumber, + count: Int = 90, + before: String? = nil, + excludingIDs: [NSNumber]? = nil, + purgeExisting: Bool = true, + success: @escaping (([LikeUser], Int, Int) -> Void), + failure: @escaping ((Error?) -> Void)) { + + guard let remote = restRemote(forSite: siteID) else { + DDLogError("Unable to create a REST remote for comments.") + failure(nil) + return + } + + remote.getLikesForCommentID(commentID, + count: NSNumber(value: count), + before: before, + excludeUserIDs: excludingIDs, + success: { remoteLikeUsers, totalLikes in + self.createNewUsers(from: remoteLikeUsers, + commentID: commentID, + siteID: siteID, + purgeExisting: purgeExisting) { + let users = self.likeUsersFor(commentID: commentID, siteID: siteID) + success(users, totalLikes.intValue, count) + LikeUserHelper.purgeStaleLikes() + } + }, failure: { error in + DDLogError(String(describing: error)) + failure(error) + }) + } + + /** + Fetches a list of users from Core Data that liked the comment with the given IDs. + + @param commentID The ID of the comment to fetch likes for. + @param siteID The ID of the site that contains the post. + @param after Filter results to likes after this Date. Optional. + */ + func likeUsersFor(commentID: NSNumber, siteID: NSNumber, after: Date? = nil) -> [LikeUser] { + self.coreDataStack.performQuery { context in + let request = LikeUser.fetchRequest() as NSFetchRequest<LikeUser> + + request.predicate = { + if let after = after { + // The date comparison is 'less than' because Likes are in descending order. + return NSPredicate(format: "likedSiteID = %@ AND likedCommentID = %@ AND dateLiked < %@", siteID, commentID, after as CVarArg) + } + + return NSPredicate(format: "likedSiteID = %@ AND likedCommentID = %@", siteID, commentID) + }() + + request.sortDescriptors = [NSSortDescriptor(key: "dateLiked", ascending: false)] + + if let users = try? context.fetch(request) { + return users + } + + return [LikeUser]() + } + } + +} + +private extension CommentService { + + func createNewUsers(from remoteLikeUsers: [RemoteLikeUser]?, + commentID: NSNumber, + siteID: NSNumber, + purgeExisting: Bool, + onComplete: @escaping (() -> Void)) { + + guard let remoteLikeUsers = remoteLikeUsers, + !remoteLikeUsers.isEmpty else { + DispatchQueue.main.async { + onComplete() + } + return + } + + coreDataStack.performAndSave({ derivedContext in + let likers = remoteLikeUsers.map { remoteUser in + LikeUserHelper.createOrUpdateFrom(remoteUser: remoteUser, context: derivedContext) + } + + if purgeExisting { + self.deleteExistingUsersFor(commentID: commentID, siteID: siteID, from: derivedContext, likesToKeep: likers) + } + }, completion: onComplete, on: .main) + } + + func deleteExistingUsersFor(commentID: NSNumber, siteID: NSNumber, from context: NSManagedObjectContext, likesToKeep: [LikeUser]) { + let request = LikeUser.fetchRequest() as NSFetchRequest<LikeUser> + request.predicate = NSPredicate(format: "likedSiteID = %@ AND likedCommentID = %@ AND NOT (self IN %@)", siteID, commentID, likesToKeep) + + do { + let users = try context.fetch(request) + users.forEach { context.delete($0) } + } catch { + DDLogError("Error fetching comment Like Users: \(error)") + } + } + +} diff --git a/WordPress/Classes/Services/CommentService+Replies.swift b/WordPress/Classes/Services/CommentService+Replies.swift new file mode 100644 index 000000000000..6bb4f98b6e71 --- /dev/null +++ b/WordPress/Classes/Services/CommentService+Replies.swift @@ -0,0 +1,89 @@ +/// Encapsulates actions related to fetching reply comments. +/// +extension CommentService { + /// Fetches the current user's latest reply ID for the specified `parentID`. + /// In case if there are no replies found, the success block will still be called with value 0. + /// + /// - Parameters: + /// - parentID: The ID of the parent comment. + /// - siteID: The ID of the site containing the parent comment. + /// - accountService: Service dependency to fetch the current user's dotcom ID. + /// - success: Closure called when the fetch succeeds. + /// - failure: Closure called when the fetch fails. + func getLatestReplyID(for parentID: Int, + siteID: Int, + accountService: AccountService? = nil, + success: @escaping (Int) -> Void, + failure: @escaping (Error?) -> Void) { + guard let remote = restRemote(forSite: NSNumber(value: siteID)) else { + DDLogError("Unable to create a REST remote to fetch comment replies.") + failure(nil) + return + } + + guard let userID = getCurrentUserID(accountService: accountService)?.intValue else { + DDLogError("Unable to find the current user's dotcom ID to fetch comment replies.") + failure(nil) + return + } + + // If the current user does not have permission to the site, the `author` endpoint parameter is not permitted. + // Therefore, fetch all replies and filter for the current user here. + remote.getCommentsV2(for: siteID, parameters: [.parent: parentID]) { remoteComments in + // Filter for comments authored by the current user, and return the most recent commentID (if any). + success(remoteComments + .filter { $0.authorID == userID } + .sorted { $0.date > $1.date }.first?.commentID ?? 0) + } failure: { error in + failure(error) + } + } + + /// Update the visibility of the comment's replies on the comment thread. + /// Note that this only applies to comments fetched from the Reader Comments section (i.e. has a reference to the `ReaderPost`). + /// + /// - Parameters: + /// - ancestorComment: The ancestor comment that will have its reply comments iterated. + /// - completion: The block executed after the replies are updated. + func updateRepliesVisibility(for ancestorComment: Comment, completion: (() -> Void)? = nil) { + guard let context = ancestorComment.managedObjectContext, + let post = ancestorComment.post as? ReaderPost, + let comments = post.comments as? Set<Comment> else { + completion?() + return + } + + let isVisible = (ancestorComment.status == CommentStatusType.approved.description) + + // iterate over the ancestor comment's descendants and update their visibility for the comment thread. + // + // the hierarchy property stores ancestral info by storing a string version of its comment ID hierarchy, + // e.g.: "0000000012.0000000025.00000000035". The idea is to check if the ancestor comment's ID exists in the hierarchy. + // as an optimization, skip checking the hierarchy when the comment is the direct child of the ancestor comment. + context.perform { + comments.filter { comment in + comment.parentID == ancestorComment.commentID + || comment.hierarchy + .split(separator: ".") + .compactMap({ Int32($0) }) + .contains(ancestorComment.commentID) + }.forEach { childComment in + childComment.visibleOnReader = isVisible + } + + self.coreDataStack.save(context, completion: completion, on: .main) + } + } +} + +private extension CommentService { + /// Returns the current user's dotcom ID. + /// + /// - Parameter accountService: The service used to fetch the default `WPAccount`. + /// - Returns: The current user's dotcom ID if exists, or nil otherwise. + func getCurrentUserID(accountService: AccountService? = nil) -> NSNumber? { + self.coreDataStack.performQuery { context in + (try? WPAccount.lookupDefaultWordPressComAccount(in: context))?.userID + } + } +} diff --git a/WordPress/Classes/Services/CommentService.h b/WordPress/Classes/Services/CommentService.h index 692c19f2c62e..2798219e8789 100644 --- a/WordPress/Classes/Services/CommentService.h +++ b/WordPress/Classes/Services/CommentService.h @@ -1,5 +1,9 @@ #import <Foundation/Foundation.h> -#import "LocalCoreDataService.h" +#import "CoreDataService.h" + +NS_ASSUME_NONNULL_BEGIN + +@import WordPressKit; extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage; @@ -7,65 +11,101 @@ extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage; @class Comment; @class ReaderPost; @class BasePost; +@class RemoteUser; +@class CommentServiceRemoteFactory; -@interface CommentService : LocalCoreDataService - -+ (BOOL)isSyncingCommentsForBlog:(Blog *)blog; +@interface CommentService : CoreDataService -// Create comment -- (Comment *)createCommentForBlog:(Blog *)blog; +/// Initializes the instance with a custom service remote provider. +/// +/// @param coreDataStack The `CoreDataStack` this instance will use for interacting with CoreData. +/// @param commentServiceRemoteFactory The factory this instance will use to get service remote instances from. +- (instancetype)initWithCoreDataStack:(id<CoreDataStack>)coreDataStack + commentServiceRemoteFactory:(CommentServiceRemoteFactory *)remoteFactory NS_DESIGNATED_INITIALIZER; // Create reply -- (Comment *)createReplyForComment:(Comment *)comment; - -// Restore draft reply -- (Comment *)restoreReplyForComment:(Comment *)comment; - -- (NSSet *)findCommentsWithPostID:(NSNumber *)postID inBlog:(Blog *)blog; +- (void)createReplyForComment:(Comment *)comment content:(NSString *)content completion:(void (^)(Comment *reply))completion; // Sync comments - (void)syncCommentsForBlog:(Blog *)blog - success:(void (^)(BOOL hasMore))success - failure:(void (^)(NSError *error))failure; + withStatus:(CommentStatusFilter)status + success:(void (^ _Nullable)(BOOL hasMore))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +- (void)syncCommentsForBlog:(Blog *)blog + withStatus:(CommentStatusFilter)status + filterUnreplied:(BOOL)filterUnreplied + success:(void (^ _Nullable)(BOOL hasMore))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Determine if a recent cache is available + (BOOL)shouldRefreshCacheFor:(Blog *)blog; // Load extra comments - (void)loadMoreCommentsForBlog:(Blog *)blog - success:(void (^)(BOOL hasMore))success - failure:(void (^)(NSError *))failure; + withStatus:(CommentStatusFilter)status + success:(void (^ _Nullable)(BOOL hasMore))success + failure:(void (^ _Nullable)(NSError * _Nullable))failure; + +// Load a single comment +- (void)loadCommentWithID:(NSNumber *_Nonnull)commentID + forBlog:(Blog *_Nonnull)blog + success:(void (^_Nullable)(Comment *_Nullable))success + failure:(void (^_Nullable)(NSError *_Nullable))failure; + +- (void)loadCommentWithID:(NSNumber *_Nonnull)commentID + forPost:(ReaderPost *_Nonnull)post + success:(void (^_Nullable)(Comment *_Nullable))success + failure:(void (^_Nullable)(NSError *_Nullable))failure; // Upload comment - (void)uploadComment:(Comment *)comment - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Approve comment - (void)approveComment:(Comment *)comment - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; -// Unapprove comment +// Unapprove (Pending) comment - (void)unapproveComment:(Comment *)comment - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Spam comment - (void)spamComment:(Comment *)comment - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Trash comment +- (void)trashComment:(Comment *)comment + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +// Delete comment - (void)deleteComment:(Comment *)comment - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable 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 - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(BOOL hasMore, NSNumber * _Nullable totalComments))success + failure:(void (^ _Nullable)(NSError * _Nullable 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 (^ _Nullable)(BOOL hasMore, NSNumber * _Nullable totalComments))success + failure:(void (^ _Nullable)(NSError * _Nullable 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. @@ -84,62 +124,62 @@ extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage; - (void)updateCommentWithID:(NSNumber *)commentID siteID:(NSNumber *)siteID content:(NSString *)content - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Replies - (void)replyToPost:(ReaderPost *)post content:(NSString *)content - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; - (void)replyToHierarchicalCommentWithID:(NSNumber *)commentID post:(ReaderPost *)post content:(NSString *)content - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; - (void)replyToCommentWithID:(NSNumber *)commentID siteID:(NSNumber *)siteID content:(NSString *)content - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Like comment - (void)likeCommentWithID:(NSNumber *)commentID siteID:(NSNumber *)siteID - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Unlike comment - (void)unlikeCommentWithID:(NSNumber *)commentID siteID:(NSNumber *)siteID - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Approve comment - (void)approveCommentWithID:(NSNumber *)commentID siteID:(NSNumber *)siteID - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Unapprove comment - (void)unapproveCommentWithID:(NSNumber *)commentID siteID:(NSNumber *)siteID - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Spam comment - (void)spamCommentWithID:(NSNumber *)commentID siteID:(NSNumber *)siteID - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Delete comment - (void)deleteCommentWithID:(NSNumber *)commentID siteID:(NSNumber *)siteID - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; /** This method will toggle the like status for a comment and optimistically save it. It will also @@ -150,7 +190,18 @@ extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage; */ - (void)toggleLikeStatusForComment:(Comment *)comment siteID:(NSNumber *)siteID - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Get a CommentServiceRemoteREST for the given site. + This is public so it can be accessed from Swift extensions. + + @param siteID The ID of the site the remote will be used for. + */ +- (CommentServiceRemoteREST *_Nullable)restRemoteForSite:(NSNumber *_Nonnull)siteID; + @end + +NS_ASSUME_NONNULL_END diff --git a/WordPress/Classes/Services/CommentService.m b/WordPress/Classes/Services/CommentService.m index 318e509cc132..07055a1fe203 100644 --- a/WordPress/Classes/Services/CommentService.m +++ b/WordPress/Classes/Services/CommentService.m @@ -1,22 +1,41 @@ #import "CommentService.h" #import "AccountService.h" #import "Blog.h" -#import "Comment.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "ReaderPost.h" #import "WPAccount.h" #import "PostService.h" #import "AbstractPost.h" #import "WordPress-Swift.h" -@import WordPressKit; NSUInteger const WPTopLevelHierarchicalCommentsPerPage = 20; NSInteger const WPNumberOfCommentsToSync = 100; static NSTimeInterval const CommentsRefreshTimeoutInSeconds = 60 * 5; // 5 minutes +@interface CommentService () + +@property (nonnull, strong, nonatomic) CommentServiceRemoteFactory *remoteFactory; + +@end + @implementation CommentService +- (instancetype)initWithCoreDataStack:(id<CoreDataStack>)coreDataStack +{ + return [self initWithCoreDataStack:coreDataStack commentServiceRemoteFactory:[CommentServiceRemoteFactory new]]; +} + +- (instancetype)initWithCoreDataStack:(id<CoreDataStack>)coreDataStack + commentServiceRemoteFactory:(CommentServiceRemoteFactory *)remoteFactory +{ + self = [super initWithCoreDataStack:coreDataStack]; + if (self) { + self.remoteFactory = remoteFactory; + } + return self; +} + + (NSMutableSet *)syncingCommentsLocks { static NSMutableSet *syncingCommentsLocks; @@ -64,12 +83,6 @@ + (BOOL)shouldRefreshCacheFor:(Blog *)blog return !isSyncing && (lastSynced == nil || ABS(lastSynced.timeIntervalSinceNow) > CommentsRefreshTimeoutInSeconds); } -- (NSSet *)findCommentsWithPostID:(NSNumber *)postID inBlog:(Blog *)blog -{ - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"postID = %@", postID]; - return [blog.comments filteredSetUsingPredicate:predicate]; -} - #pragma mark Public methods #pragma mark Blog-centric methods @@ -77,47 +90,49 @@ - (NSSet *)findCommentsWithPostID:(NSNumber *)postID inBlog:(Blog *)blog // Create comment - (Comment *)createCommentForBlog:(Blog *)blog { - Comment *comment = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Comment class]) inManagedObjectContext:blog.managedObjectContext]; + NSParameterAssert(blog.managedObjectContext != nil); + + Comment *comment = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Comment class]) + inManagedObjectContext:blog.managedObjectContext]; + comment.dateCreated = [NSDate new]; comment.blog = blog; return comment; } // Create reply -- (Comment *)createReplyForComment:(Comment *)comment +- (void)createReplyForComment:(Comment *)comment content:(NSString *)content completion:(void (^)(Comment *reply))completion { - Comment *reply = [self createCommentForBlog:comment.blog]; - reply.postID = comment.postID; - reply.post = comment.post; - reply.parentID = comment.commentID; - reply.status = CommentStatusApproved; - return reply; + NSManagedObjectID *parentCommentID = comment.objectID; + NSManagedObjectID * __block replyID = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *comment = [context existingObjectWithID:parentCommentID error:nil]; + Comment *reply = [self createCommentForBlog:comment.blog]; + reply.postID = comment.postID; + reply.post = comment.post; + reply.parentID = comment.commentID; + reply.status = [Comment descriptionFor:CommentStatusTypeApproved]; + reply.content = content; + [context obtainPermanentIDsForObjects:@[reply] error:nil]; + replyID = reply.objectID; + } completion:^{ + if (completion) { + completion([self.coreDataStack.mainContext existingObjectWithID:replyID error:nil]); + } + } onQueue:dispatch_get_main_queue()]; } -// Restore draft reply -- (Comment *)restoreReplyForComment:(Comment *)comment +// Sync comments +- (void)syncCommentsForBlog:(Blog *)blog + withStatus:(CommentStatusFilter)status + success:(void (^)(BOOL hasMore))success + failure:(void (^)(NSError *error))failure { - NSFetchRequest *existingReply = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([Comment class])]; - existingReply.predicate = [NSPredicate predicateWithFormat:@"status == %@ AND parentID == %@", CommentStatusDraft, comment.commentID]; - existingReply.fetchLimit = 1; - - NSError *error; - NSArray *replies = [self.managedObjectContext executeFetchRequest:existingReply error:&error]; - if (error) { - DDLogError(@"Failed to fetch reply: %@", error); - } - - Comment *reply = [replies firstObject]; - if (!reply) { - reply = [self createReplyForComment:comment]; - } - - reply.status = CommentStatusDraft; - - return reply; + [self syncCommentsForBlog:blog withStatus:status filterUnreplied:NO success:success failure:failure]; } -// Sync comments - (void)syncCommentsForBlog:(Blog *)blog + withStatus:(CommentStatusFilter)status + filterUnreplied:(BOOL)filterUnreplied success:(void (^)(BOOL hasMore))success failure:(void (^)(NSError *error))failure { @@ -129,54 +144,127 @@ - (void)syncCommentsForBlog:(Blog *)blog } return; } - + + // If the comment status is not specified, default to all. + CommentStatusFilter commentStatus = status ?: CommentStatusFilterAll; + NSDictionary *options = @{ @"status": [NSNumber numberWithInt:commentStatus] }; + id<CommentServiceRemote> remote = [self remoteForBlog:blog]; - [remote getCommentsWithMaximumCount:WPNumberOfCommentsToSync success:^(NSArray *comments) { - [self.managedObjectContext performBlock:^{ - Blog *blogInContext = (Blog *)[self.managedObjectContext existingObjectWithID:blogID error:nil]; - if (blogInContext) { - [self mergeComments:comments - forBlog:blog - purgeExisting:YES - completionHandler:^{ - [[self class] stopSyncingCommentsForBlog:blogID]; - - [self.managedObjectContext performBlock:^{ - blogInContext.lastCommentsSync = [NSDate date]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (success) { - // Note: - // We'll assume that if the requested page size couldn't be filled, there are no - // more comments left to retrieve. - BOOL hasMore = comments.count >= WPNumberOfCommentsToSync; - success(hasMore); - } - }]; - }]; - }]; - } - }]; - } failure:^(NSError *error) { - [[self class] stopSyncingCommentsForBlog:blogID]; - if (failure) { - [self.managedObjectContext performBlock:^{ - failure(error); - }]; - } - }]; + + [remote getCommentsWithMaximumCount:WPNumberOfCommentsToSync + options:options + success:^(NSArray *comments) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blog = [context existingObjectWithID:blogID error:nil]; + if (!blog) { + return; + } + + NSArray *fetchedComments = comments; + if (filterUnreplied) { + NSString *author = @""; + if (blog.account) { + // See if there is a linked Jetpack user that we should use. + BlogAuthor *blogAuthor = [blog getAuthorWithLinkedID:blog.account.userID]; + author = (blogAuthor) ? blogAuthor.email : blog.account.email; + } else { + BlogAuthor *blogAuthor = [blog getAuthorWithId:blog.userID]; + author = (blogAuthor) ? blogAuthor.email : author; + } + fetchedComments = [self filterUnrepliedComments:comments forAuthor:author]; + } + [self mergeComments:fetchedComments forBlog:blog purgeExisting:YES]; + blog.lastCommentsSync = [NSDate date]; + } completion:^{ + [[self class] stopSyncingCommentsForBlog:blogID]; + + if (success) { + // Note: + // We'll assume that if the requested page size couldn't be filled, there are no + // more comments left to retrieve. However, for unreplied comments, we only fetch the first page (for now). + BOOL hasMore = comments.count >= WPNumberOfCommentsToSync && !filterUnreplied; + success(hasMore); + } + } onQueue:dispatch_get_main_queue()]; + } failure:^(NSError *error) { + [[self class] stopSyncingCommentsForBlog:blogID]; + + if (failure) { + dispatch_async(dispatch_get_main_queue(), ^{ + failure(error); + }); + } + }]; +} + +- (NSArray *)filterUnrepliedComments:(NSArray *)comments forAuthor:(NSString *)author { + NSMutableArray *marr = [comments mutableCopy]; + + NSMutableArray *foundIDs = [NSMutableArray array]; + NSMutableArray *discardables = [NSMutableArray array]; + + // get ids of comments that user has replied to. + for (RemoteComment *comment in marr) { + if (![comment.authorEmail isEqualToString:author] || !comment.parentID) { + continue; + } + [foundIDs addObject:comment.parentID]; + [discardables addObject:comment]; + } + // Discard the replies, they aren't needed. + [marr removeObjectsInArray:discardables]; + [discardables removeAllObjects]; + + // Get the parents, grandparents etc. and discard those too. + while ([foundIDs count] > 0) { + NSArray *needles = [foundIDs copy]; + [foundIDs removeAllObjects]; + for (RemoteComment *comment in marr) { + if ([needles containsObject:comment.commentID]) { + if (comment.parentID) { + [foundIDs addObject:comment.parentID]; + } + [discardables addObject:comment]; + } + } + // Discard the matches, and keep looking if items were found. + [marr removeObjectsInArray:discardables]; + [discardables removeAllObjects]; + } + + // remove any remaining child comments. + // remove any remaining root comments made by the user. + for (RemoteComment *comment in marr) { + if (comment.parentID.intValue != 0) { + [discardables addObject:comment]; + } else if ([comment.authorEmail isEqualToString:author]) { + [discardables addObject:comment]; + } + } + [marr removeObjectsInArray:discardables]; + + // these are the most recent unreplied comments from other users. + return [NSArray arrayWithArray:marr]; } - (Comment *)oldestCommentForBlog:(Blog *)blog { + NSParameterAssert(blog.managedObjectContext != nil); + NSString *entityName = NSStringFromClass([Comment class]); NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:entityName]; request.predicate = [NSPredicate predicateWithFormat:@"dateCreated != NULL && blog=%@", blog]; NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"dateCreated" ascending:YES]; request.sortDescriptors = @[sortDescriptor]; - Comment *oldestComment = [[self.managedObjectContext executeFetchRequest:request error:nil] firstObject]; + + Comment * __block oldestComment = nil; + [blog.managedObjectContext performBlockAndWait:^{ + oldestComment = [[blog.managedObjectContext executeFetchRequest:request error:nil] firstObject]; + }]; return oldestComment; } - (void)loadMoreCommentsForBlog:(Blog *)blog + withStatus:(CommentStatusFilter)status success:(void (^)(BOOL hasMore))success failure:(void (^)(NSError *))failure { @@ -188,8 +276,14 @@ - (void)loadMoreCommentsForBlog:(Blog *)blog } } - id<CommentServiceRemote> remote = [self remoteForBlog:blog]; NSMutableDictionary *options = [NSMutableDictionary dictionary]; + + // If the comment status is not specified, default to all. + CommentStatusFilter commentStatus = status ?: CommentStatusFilterAll; + options[@"status"] = [NSNumber numberWithInt:commentStatus]; + + id<CommentServiceRemote> remote = [self remoteForBlog:blog]; + if ([remote isKindOfClass:[CommentServiceRemoteREST class]]) { Comment *oldestComment = [self oldestCommentForBlog:blog]; if (oldestComment.dateCreated) { @@ -200,26 +294,111 @@ - (void)loadMoreCommentsForBlog:(Blog *)blog NSUInteger commentCount = [blog.comments count]; options[@"offset"] = @(commentCount); } - [remote getCommentsWithMaximumCount:WPNumberOfCommentsToSync options:options success:^(NSArray *comments) { - [self.managedObjectContext performBlock:^{ - Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogID error:nil]; + + [remote getCommentsWithMaximumCount:WPNumberOfCommentsToSync + options:options + success:^(NSArray *comments) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blog = [context existingObjectWithID:blogID error:nil]; if (!blog) { return; } - [self mergeComments:comments forBlog:blog purgeExisting:NO completionHandler:^{ - [[self class] stopSyncingCommentsForBlog:blogID]; - if (success) { - success(comments.count > 1); - } - }]; - }]; - + [self mergeComments:comments forBlog:blog purgeExisting:NO]; + } completion:^{ + [[self class] stopSyncingCommentsForBlog:blogID]; + if (success) { + success(comments.count > 1); + } + } onQueue:dispatch_get_main_queue()]; } failure:^(NSError *error) { [[self class] stopSyncingCommentsForBlog:blogID]; if (failure) { - [self.managedObjectContext performBlock:^{ + dispatch_async(dispatch_get_main_queue(), ^{ + failure(error); + }); + } + }]; +} + +- (void)loadCommentWithID:(NSNumber *)commentID + forBlog:(Blog *)blog + success:(void (^)(Comment *comment))success + failure:(void (^)(NSError *))failure { + + NSManagedObjectID *blogID = blog.objectID; + id<CommentServiceRemote> remote = [self remoteForBlog:blog]; + + [remote getCommentWithID:commentID + success:^(RemoteComment *remoteComment) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blog = [context existingObjectWithID:blogID error:nil]; + if (!blog) { + return; + } + + Comment *comment = [blog commentWithID:remoteComment.commentID]; + if (!comment) { + comment = [self createCommentForBlog:blog]; + } + + [self updateComment:comment withRemoteComment:remoteComment]; + } completion:^{ + if (success) { + [self.coreDataStack.mainContext performBlock:^{ + Blog *blog = [self.coreDataStack.mainContext existingObjectWithID:blogID error:nil]; + success([blog commentWithID:remoteComment.commentID]); + }]; + } + } onQueue:dispatch_get_main_queue()]; + } failure:^(NSError *error) { + DDLogError(@"Error loading comment for blog: %@", error); + if (failure) { + dispatch_async(dispatch_get_main_queue(), ^{ failure(error); - }]; + }); + } + }]; +} + +- (void)loadCommentWithID:(NSNumber *)commentID + forPost:(ReaderPost *)post + success:(void (^)(Comment *comment))success + failure:(void (^)(NSError *))failure { + + NSManagedObjectID *postID = post.objectID; + CommentServiceRemoteREST *service = [self restRemoteForSite:post.siteID]; + + [service getCommentWithID:commentID + success:^(RemoteComment *remoteComment) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderPost *post = [context existingObjectWithID:postID error:nil]; + if (!post) { + return; + } + + Comment *comment = [post commentWithID:remoteComment.commentID]; + + if (!comment) { + comment = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Comment class]) inManagedObjectContext:context]; + comment.dateCreated = [NSDate new]; + } + + comment.post = post; + [self updateComment:comment withRemoteComment:remoteComment]; + } completion:^{ + if (success) { + [self.coreDataStack.mainContext performBlock:^{ + ReaderPost *post = [self.coreDataStack.mainContext existingObjectWithID:postID error:nil]; + success([post commentWithID:remoteComment.commentID]); + }]; + } + } onQueue:dispatch_get_main_queue()]; + } failure:^(NSError *error) { + DDLogError(@"Error loading comment for post: %@", error); + if (failure) { + dispatch_async(dispatch_get_main_queue(), ^{ + failure(error); + }); } }]; } @@ -229,24 +408,20 @@ - (void)uploadComment:(Comment *)comment success:(void (^)(void))success failure:(void (^)(NSError *error))failure { - id<CommentServiceRemote> remote = [self remoteForBlog:comment.blog]; + id<CommentServiceRemote> remote = [self remoteForComment:comment]; RemoteComment *remoteComment = [self remoteCommentWithComment:comment]; NSManagedObjectID *commentObjectID = comment.objectID; void (^successBlock)(RemoteComment *comment) = ^(RemoteComment *comment) { - [self.managedObjectContext performBlock:^{ - Comment *commentInContext = (Comment *)[self.managedObjectContext existingObjectWithID:commentObjectID error:nil]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *commentInContext = [context existingObjectWithID:commentObjectID error:nil]; if (commentInContext) { [self updateComment:commentInContext withRemoteComment:comment]; } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - if (success) { - success(); - } - }]; + } completion:success onQueue:dispatch_get_main_queue()]; }; - if (comment.commentID) { + if (comment.commentID != 0) { [remote updateComment:remoteComment success:successBlock failure:failure]; @@ -263,7 +438,7 @@ - (void)approveComment:(Comment *)comment failure:(void (^)(NSError *error))failure { [self moderateComment:comment - withStatus:CommentStatusApproved + withStatus:CommentStatusTypeApproved success:success failure:failure]; } @@ -274,7 +449,7 @@ - (void)unapproveComment:(Comment *)comment failure:(void (^)(NSError *error))failure { [self moderateComment:comment - withStatus:CommentStatusPending + withStatus:CommentStatusTypePending success:success failure:failure]; } @@ -284,17 +459,40 @@ - (void)spamComment:(Comment *)comment success:(void (^)(void))success failure:(void (^)(NSError *error))failure { + + // If the Comment is not permanently deleted, don't remove it from the local cache as it can still be displayed. + if (!comment.deleteWillBePermanent) { + [self moderateComment:comment + withStatus:CommentStatusTypeSpam + success:success + failure:failure]; + + return; + } + NSManagedObjectID *commentID = comment.objectID; + [self moderateComment:comment - withStatus:CommentStatusSpam + withStatus:CommentStatusTypeSpam success:^{ - Comment *commentInContext = (Comment *)[self.managedObjectContext existingObjectWithID:commentID error:nil]; - [self.managedObjectContext deleteObject:commentInContext]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - if (success) { - success(); - } - } failure:failure]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *commentInContext = [context existingObjectWithID:commentID error:nil]; + if (commentInContext != nil){ + [context deleteObject:commentInContext]; + } + } completion:success onQueue:dispatch_get_main_queue()]; + } failure: failure]; +} + +// Trash comment +- (void)trashComment:(Comment *)comment + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + [self moderateComment:comment + withStatus:CommentStatusTypeUnapproved + success:success + failure:failure]; } // Delete comment @@ -302,83 +500,153 @@ - (void)deleteComment:(Comment *)comment success:(void (^)(void))success failure:(void (^)(NSError *error))failure { - NSNumber *commentID = comment.commentID; - if (commentID) { - RemoteComment *remoteComment = [self remoteCommentWithComment:comment]; - id<CommentServiceRemote> remote = [self remoteForBlog:comment.blog]; + // If this comment is local only, just delete. No need to query the endpoint or do any other work. + if (comment.commentID == 0) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *commentInContext = [context existingObjectWithID:comment.objectID error:nil]; + if (commentInContext != nil) { + [context deleteObject:commentInContext]; + } + } completion:success onQueue:dispatch_get_main_queue()]; + return; + } + + RemoteComment *remoteComment = [self remoteCommentWithComment:comment]; + id<CommentServiceRemote> remote = [self remoteForBlog:comment.blog]; + + // If the Comment is not permanently deleted, don't remove it from the local cache as it can still be displayed. + if (!comment.deleteWillBePermanent) { [remote trashComment:remoteComment success:success failure:failure]; + return; } - [self.managedObjectContext deleteObject:comment]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; -} + // For the best user experience we want to optimistically delete the comment. + // However, if there is an error we need to restore it. + NSManagedObjectID *blogObjID = comment.blog.objectID; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *commentInContext = [context existingObjectWithID:comment.objectID error:nil]; + if (commentInContext != nil) { + [context deleteObject:commentInContext]; + } + } completion:^{ + [remote trashComment:remoteComment success:success failure:^(NSError *error) { + // Failure. Restore the comment. + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blog = [context objectWithID:blogObjID]; + if (!blog) { + return; + } + + Comment *comment = [self createCommentForBlog:blog]; + [self updateComment:comment withRemoteComment:remoteComment]; + } completion:^{ + if (failure) { + failure(error); + } + } onQueue:dispatch_get_main_queue()]; + }]; + } onQueue:dispatch_get_main_queue()]; +} #pragma mark - Post-centric methods - (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; - [self.managedObjectContext performBlock:^{ - CommentServiceRemoteREST *service = [self restRemoteForSite:siteID]; - [service syncHierarchicalCommentsForPost:postID - page:page - number:WPTopLevelHierarchicalCommentsPerPage - success:^(NSArray *comments) { - [self.managedObjectContext performBlock:^{ + + NSUInteger commentsPerPage = number ?: WPTopLevelHierarchicalCommentsPerPage; + NSUInteger pageNumber = page ?: 1; + + CommentServiceRemoteREST *service = [self restRemoteForSite:siteID]; + [service syncHierarchicalCommentsForPost:postID + page:pageNumber + number:commentsPerPage + success:^(NSArray *comments, NSNumber *totalComments) { + BOOL __block includesNewComments = NO; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSError *error; + ReaderPost *aPost = [context existingObjectWithID:postObjectID error:&error]; + if (!aPost) { + if (failure) { + dispatch_async(dispatch_get_main_queue(), ^{ + failure(error); + }); + } + return; + } + + includesNewComments = [self mergeHierarchicalComments:comments forPage:page forPost:aPost]; + } completion:^{ + if (!success) { + return; + } + + [self.coreDataStack.mainContext performBlock:^{ NSError *error; - ReaderPost *aPost = (ReaderPost *)[self.managedObjectContext existingObjectWithID:postObjectID error:&error]; + ReaderPost *aPost = [self.coreDataStack.mainContext existingObjectWithID:postObjectID error:&error]; if (!aPost) { if (failure) { - dispatch_async(dispatch_get_main_queue(), ^{ - failure(error); - }); + failure(error); } return; } - [self mergeHierarchicalComments:comments forPage:page forPost:aPost onComplete:^(BOOL includesNewComments) { - if (!success) { - return; - } - - [self.managedObjectContext performBlock:^{ - // There are no more comments when: - // - There are fewer top level comments in the results than requested - // - Page > 1, the number of top level comments matches those requested, but there are no new comments - // We check this way because the API can return the last page of results instead - // of returning zero results when the requested page is the last + 1. - NSArray *parents = [self topLevelCommentsForPage:page forPost:aPost]; - BOOL hasMore = YES; - if (([parents count] < WPTopLevelHierarchicalCommentsPerPage) || (page > 1 && !includesNewComments)) { - hasMore = NO; - } - - dispatch_async(dispatch_get_main_queue(), ^{ - success([comments count], hasMore); - }); - }]; - }]; - }]; - } failure:^(NSError *error) { - [self.managedObjectContext performBlock:^{ - if (failure) { - dispatch_async(dispatch_get_main_queue(), ^{ - failure(error); - }); + // There are no more comments when: + // - There are fewer top level comments in the results than requested + // - Page > 1, the number of top level comments matches those requested, but there are no new comments + // We check this way because the API can return the last page of results instead + // of returning zero results when the requested page is the last + 1. + NSArray *parents = [self topLevelCommentsForPage:page forPost:aPost]; + BOOL hasMore = YES; + if (([parents count] < WPTopLevelHierarchicalCommentsPerPage) || (page > 1 && !includesNewComments)) { + hasMore = NO; } + + success(hasMore, totalComments); }]; - }]; - }]; + } onQueue:dispatch_get_main_queue()]; + } failure:^(NSError *error) { + if (failure) { + dispatch_async(dispatch_get_main_queue(), ^{ + failure(error); + }); + } + }]; } - (NSInteger)numberOfHierarchicalPagesSyncedforPost:(ReaderPost *)post { - NSSet *topComments = [post.comments filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"parentID = NULL"]]; + NSSet *topComments = [post.comments filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"parentID = 0"]]; CGFloat page = [topComments count] / WPTopLevelHierarchicalCommentsPerPage; return (NSInteger)page; } @@ -415,50 +683,41 @@ - (void)replyToPost:(ReaderPost *)post { // Create and optimistically save a comment, based on the current wpcom acct // post and content provided. - Comment *comment = [self createHierarchicalCommentWithContent:content withParent:nil postID:post.postID siteID:post.siteID]; BOOL isPrivateSite = post.isPrivate; - - // This fixes an issue where the comment may not appear for some posts after a successful posting - // More information: https://github.com/wordpress-mobile/WordPress-iOS/issues/13259 - comment.post = post; + [self createHierarchicalCommentWithContent:content withParent:nil postObjectID:post.objectID siteID:post.siteID completion:^(NSManagedObjectID *commentID) { + void (^successBlock)(RemoteComment *remoteComment) = ^void(RemoteComment *remoteComment) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *comment = [context existingObjectWithID:commentID error:nil]; + if (!comment) { + return; + } - NSManagedObjectID *commentID = comment.objectID; - void (^successBlock)(RemoteComment *remoteComment) = ^void(RemoteComment *remoteComment) { - [self.managedObjectContext performBlock:^{ - Comment *comment = (Comment *)[self.managedObjectContext existingObjectWithID:commentID error:nil]; - if (!comment) { - return; - } + remoteComment.content = [self sanitizeCommentContent:remoteComment.content isPrivateSite:isPrivateSite]; - remoteComment.content = [self sanitizeCommentContent:remoteComment.content isPrivateSite:isPrivateSite]; + [self updateHierarchicalComment:comment withRemoteComment:remoteComment]; + } completion:success onQueue:dispatch_get_main_queue()]; + }; - // Update and save the comment - [self updateCommentAndSave:comment withRemoteComment:remoteComment]; - if (success) { - success(); - } - }]; - }; - - void (^failureBlock)(NSError *error) = ^void(NSError *error) { - [self.managedObjectContext performBlock:^{ - Comment *comment = (Comment *)[self.managedObjectContext existingObjectWithID:commentID error:nil]; - if (!comment) { - return; - } + void (^failureBlock)(NSError *error) = ^void(NSError *error) { // Remove the optimistically saved comment. - [self deleteComment:comment success:nil failure:nil]; - if (failure) { - failure(error); - } - }]; - }; - - CommentServiceRemoteREST *remote = [self restRemoteForSite:post.siteID]; - [remote replyToPostWithID:post.postID - content:content - success:successBlock - failure:failureBlock]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *commentInContext = [context existingObjectWithID:commentID error:nil]; + if (commentInContext != nil) { + [context deleteObject:commentInContext]; + } + } completion:^{ + if (failure) { + failure(error); + } + } onQueue:dispatch_get_main_queue()]; + }; + + CommentServiceRemoteREST *remote = [self restRemoteForSite:post.siteID]; + [remote replyToPostWithID:post.postID + content:content + success:successBlock + failure:failureBlock]; + }]; } - (void)replyToHierarchicalCommentWithID:(NSNumber *)commentID @@ -469,52 +728,45 @@ - (void)replyToHierarchicalCommentWithID:(NSNumber *)commentID { // Create and optimistically save a comment, based on the current wpcom acct // post and content provided. - Comment *comment = [self createHierarchicalCommentWithContent:content withParent:commentID postID:post.postID siteID:post.siteID]; BOOL isPrivateSite = post.isPrivate; - - // This fixes an issue where the comment may not appear for some posts after a successful posting - // More information: https://github.com/wordpress-mobile/WordPress-iOS/issues/13259 - comment.post = post; - - NSManagedObjectID *commentObjectID = comment.objectID; - void (^successBlock)(RemoteComment *remoteComment) = ^void(RemoteComment *remoteComment) { - // Update and save the comment - [self.managedObjectContext performBlock:^{ - Comment *comment = (Comment *)[self.managedObjectContext existingObjectWithID:commentObjectID error:nil]; - if (!comment) { - return; - } - - remoteComment.content = [self sanitizeCommentContent:remoteComment.content isPrivateSite:isPrivateSite]; + [self createHierarchicalCommentWithContent:content withParent:nil postObjectID:post.objectID siteID:post.siteID completion:^(NSManagedObjectID *commentObjectID) { + void (^successBlock)(RemoteComment *remoteComment) = ^void(RemoteComment *remoteComment) { + // Update and save the comment + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *comment = [context existingObjectWithID:commentObjectID error:nil]; + if (!comment) { + return; + } - [self updateCommentAndSave:comment withRemoteComment:remoteComment]; - if (success) { - success(); - } - }]; - }; + remoteComment.content = [self sanitizeCommentContent:remoteComment.content isPrivateSite:isPrivateSite]; - void (^failureBlock)(NSError *error) = ^void(NSError *error) { - [self.managedObjectContext performBlock:^{ - Comment *comment = (Comment *)[self.managedObjectContext existingObjectWithID:commentObjectID error:nil]; - if (!comment) { - return; - } - // Remove the optimistically saved comment. - ReaderPost *post = (ReaderPost *)comment.post; - post.commentCount = @([post.commentCount integerValue] - 1); - [self deleteComment:comment success:nil failure:nil]; - if (failure) { - failure(error); - } - }]; - }; + [self updateHierarchicalComment:comment withRemoteComment:remoteComment]; + } completion:success onQueue:dispatch_get_main_queue()]; + }; - CommentServiceRemoteREST *remote = [self restRemoteForSite:post.siteID]; - [remote replyToCommentWithID:commentID - content:content - success:successBlock - failure:failureBlock]; + void (^failureBlock)(NSError *error) = ^void(NSError *error) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *commentInContext = [context existingObjectWithID:commentObjectID error:nil]; + if (!commentInContext) { + return; + } + // Remove the optimistically saved comment. + [context deleteObject:commentInContext]; + ReaderPost *post = (ReaderPost *)commentInContext.post; + post.commentCount = @([post.commentCount integerValue] - 1); + } completion:^{ + if (failure) { + failure(error); + } + } onQueue:dispatch_get_main_queue()]; + }; + + CommentServiceRemoteREST *remote = [self restRemoteForSite:post.siteID]; + [remote replyToCommentWithID:commentID + content:content + success:successBlock + failure:failureBlock]; + }]; } - (void)replyToCommentWithID:(NSNumber *)commentID @@ -526,7 +778,7 @@ - (void)replyToCommentWithID:(NSNumber *)commentID CommentServiceRemoteREST *remote = [self restRemoteForSite:siteID]; [remote replyToCommentWithID:commentID content:content - success:^(RemoteComment *comment){ + success:^(RemoteComment * __unused comment){ if (success){ success(); } @@ -590,12 +842,11 @@ - (void)spamCommentWithID:(NSNumber *)commentID { CommentServiceRemoteREST *remote = [self restRemoteForSite:siteID]; [remote moderateCommentWithID:commentID - status:CommentStatusSpam + status:[Comment descriptionFor:CommentStatusTypeSpam] success:success failure:failure]; } -// Trash - (void)deleteCommentWithID:(NSNumber *)commentID siteID:(NSNumber *)siteID success:(void (^)(void))success @@ -612,112 +863,115 @@ - (void)toggleLikeStatusForComment:(Comment *)comment success:(void (^)(void))success failure:(void (^)(NSError *error))failure { - // toggle the like status and change the like count and save it - comment.isLiked = !comment.isLiked; - comment.likeCount = @([comment.likeCount intValue] + (comment.isLiked ? 1 : -1)); - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; + NSManagedObjectID *commentObjectID = comment.objectID; + BOOL isLikedOriginally = comment.isLiked; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + // toggle the like status and change the like count and save it + Comment *comment = [context existingObjectWithID:commentObjectID error:nil]; + comment.isLiked = !isLikedOriginally; + comment.likeCount = comment.likeCount + (comment.isLiked ? 1 : -1); + } completion:^{ + // This block will reverse the like/unlike action + void (^failureBlock)(NSError *) = ^(NSError *error) { + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *comment = [context existingObjectWithID:commentObjectID error:nil]; + DDLogError(@"Error while %@ comment: %@", comment.isLiked ? @"liking" : @"unliking", error); + + comment.isLiked = isLikedOriginally; + comment.likeCount = comment.likeCount + (comment.isLiked ? 1 : -1); + } completion:^{ + if (failure) { + failure(error); + } + } onQueue:dispatch_get_main_queue()]; + }; - __weak __typeof(self) weakSelf = self; - NSManagedObjectID *commentID = comment.objectID; + NSNumber *commentID = [NSNumber numberWithInt:comment.commentID]; - // This block will reverse the like/unlike action - void (^failureBlock)(NSError *) = ^(NSError *error) { - Comment *comment = (Comment *)[self.managedObjectContext existingObjectWithID:commentID error:nil]; - if (!comment) { - return; + if (!isLikedOriginally) { + [self likeCommentWithID:commentID siteID:siteID success:success failure:failureBlock]; } - DDLogError(@"Error while %@ comment: %@", comment.isLiked ? @"liking" : @"unliking", error); - - comment.isLiked = !comment.isLiked; - comment.likeCount = @([comment.likeCount intValue] + (comment.isLiked ? 1 : -1)); - - [[ContextManager sharedInstance] saveContext:weakSelf.managedObjectContext]; - - if (failure) { - failure(error); + else { + [self unlikeCommentWithID:commentID siteID:siteID success:success failure:failureBlock]; } - }; - - if (comment.isLiked) { - [self likeCommentWithID:comment.commentID siteID:siteID success:success failure:failureBlock]; - } - else { - [self unlikeCommentWithID:comment.commentID siteID:siteID success:success failure:failureBlock]; - } + } onQueue:dispatch_get_main_queue()]; } #pragma mark - Private methods // Deletes orphaned comments. Does not save context. -- (void)deleteUnownedComments +- (void)deleteUnownedCommentsInContext:(NSManagedObjectContext *)context { NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:NSStringFromClass([Comment class])]; fetchRequest.predicate = [NSPredicate predicateWithFormat:@"post = NULL && blog = NULL"]; NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; + NSArray *results = [context executeFetchRequest:fetchRequest error:&error]; if (error) { DDLogError(@"Error fetching orphaned comments: %@", error); } for (Comment *comment in results) { - [self.managedObjectContext deleteObject:comment]; + [context deleteObject:comment]; } } + + #pragma mark - Blog centric methods // Generic moderation - (void)moderateComment:(Comment *)comment - withStatus:(NSString *)status + withStatus:(CommentStatusType)status success:(void (^)(void))success failure:(void (^)(NSError *error))failure { + NSString *currentStatus = [Comment descriptionFor:status]; NSString *prevStatus = comment.status; - if ([prevStatus isEqualToString:status]) { + + if ([prevStatus isEqualToString:currentStatus]) { if (success) { success(); } return; } - comment.status = status; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - id <CommentServiceRemote> remote = [self remoteForBlog:comment.blog]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *commentInContext = [context existingObjectWithID:comment.objectID error:nil]; + commentInContext.status = currentStatus; + }]; + + comment.status = currentStatus; + + id <CommentServiceRemote> remote = [self remoteForComment:comment]; RemoteComment *remoteComment = [self remoteCommentWithComment:comment]; - NSManagedObjectID *commentID = comment.objectID; [remote moderateComment:remoteComment - success:^(RemoteComment *comment) { + success:^(RemoteComment * __unused comment) { if (success) { success(); } - } failure:^(NSError *error) { - [self.managedObjectContext performBlock:^{ - // Note: The comment might have been deleted at this point - Comment *commentInContext = (Comment *)[self.managedObjectContext existingObjectWithID:commentID error:nil]; - if (commentInContext) { - commentInContext.status = prevStatus; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - } - + } failure:^(NSError *error) { + DDLogError(@"Error moderating comment: %@", error); + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Comment *commentInContext = [context existingObjectWithID:comment.objectID error:nil]; + commentInContext.status = prevStatus; + } completion:^{ if (failure) { - dispatch_async(dispatch_get_main_queue(), ^{ - failure(error); - }); + failure(error); } - }]; + } onQueue:dispatch_get_main_queue()]; }]; } - (void)mergeComments:(NSArray *)comments forBlog:(Blog *)blog purgeExisting:(BOOL)purgeExisting - completionHandler:(void (^)(void))completion { + NSParameterAssert(blog.managedObjectContext != nil); + NSMutableArray *commentsToKeep = [NSMutableArray array]; for (RemoteComment *remoteComment in comments) { - Comment *comment = [self findCommentWithID:remoteComment.commentID inBlog:blog]; + Comment *comment = [blog commentWithID:remoteComment.commentID]; if (!comment) { comment = [self createCommentForBlog:blog]; } @@ -730,26 +984,15 @@ - (void)mergeComments:(NSArray *)comments if (existingComments.count > 0) { for (Comment *comment in existingComments) { // Don't delete unpublished comments - if (![commentsToKeep containsObject:comment] && comment.commentID != nil) { + if (![commentsToKeep containsObject:comment] && comment.commentID != 0) { DDLogInfo(@"Deleting Comment: %@", comment); - [self.managedObjectContext deleteObject:comment]; + [blog.managedObjectContext deleteObject:comment]; } } } } - [self deleteUnownedComments]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (completion) { - dispatch_async(dispatch_get_main_queue(), completion); - } - }]; -} - -- (Comment *)findCommentWithID:(NSNumber *)commentID inBlog:(Blog *)blog -{ - NSSet *comments = [blog.comments filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"commentID = %@", commentID]]; - return [comments anyObject]; + [self deleteUnownedCommentsInContext:blog.managedObjectContext]; } #pragma mark - Post centric methods @@ -759,7 +1002,7 @@ - (NSMutableArray *)ancestorsForCommentWithParentID:(NSNumber *)parentID andCurr NSMutableArray *ancestors = [currentAncestors mutableCopy]; // Calculate hierarchy and depth. - if (parentID) { + if (parentID.intValue != 0) { if ([ancestors containsObject:parentID]) { NSUInteger index = [ancestors indexOfObject:parentID] + 1; NSArray *subarray = [ancestors subarrayWithRange:NSMakeRange(0, index)]; @@ -795,13 +1038,32 @@ - (NSString *)formattedHierarchyElement:(NSNumber *)commentID return [NSString stringWithFormat:@"%010u", [commentID integerValue]]; } -- (Comment *)createHierarchicalCommentWithContent:(NSString *)content withParent:(NSNumber *)parentID postID:(NSNumber *)postID siteID:(NSNumber *)siteID +- (void)createHierarchicalCommentWithContent:(NSString *)content + withParent:(NSNumber *)parentID + postObjectID:(NSManagedObjectID *)postObjectID + siteID:(NSNumber *)siteID + completion:(void (^)(NSManagedObjectID *commentID))completion +{ + NSManagedObjectID * __block objectID = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderPost *post = [context existingObjectWithID:postObjectID error:nil]; + Comment *comment = [self createHierarchicalCommentWithContent:content withParent:parentID postID:post.postID siteID:siteID inContext:context]; + objectID = comment.objectID; + // This fixes an issue where the comment may not appear for some posts after a successful posting + // More information: https://github.com/wordpress-mobile/WordPress-iOS/issues/13259 + comment.post = post; + } completion:^{ + completion(objectID); + } onQueue:dispatch_get_main_queue()]; +} + +- (Comment *)createHierarchicalCommentWithContent:(NSString *)content withParent:(NSNumber *)parentID postID:(NSNumber *)postID siteID:(NSNumber *)siteID inContext:(NSManagedObjectContext *)context { // Fetch the relevant ReaderPost NSError *error; NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:NSStringFromClass([ReaderPost class])]; fetchRequest.predicate = [NSPredicate predicateWithFormat:@"postID = %@ AND siteID = %@", postID, siteID]; - NSArray *results = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; + NSArray *results = [context executeFetchRequest:fetchRequest error:&error]; if (error) { DDLogError(@"Error fetching post with id %@ and site %@. %@", postID, siteID, error); return nil; @@ -814,45 +1076,48 @@ - (Comment *)createHierarchicalCommentWithContent:(NSString *)content withParent // (Insert a new comment into core data. Check for its existance first for paranoia sake. // In theory a sync could include a newly created comment before the request that created it returned. - Comment *comment = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Comment class]) inManagedObjectContext:self.managedObjectContext]; + Comment *comment = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Comment class]) inManagedObjectContext:context]; - AccountService *service = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext]; - comment.author = [[service defaultWordPressComAccount] username]; + WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:context]; + comment.author = [account username]; + comment.authorID = [account.userID intValue]; comment.content = content; comment.dateCreated = [NSDate date]; - comment.parentID = parentID; - comment.postID = postID; + comment.parentID = [parentID intValue]; + comment.postID = [postID intValue]; comment.postTitle = post.postTitle; - comment.status = CommentStatusDraft; + comment.status = [Comment descriptionFor:CommentStatusTypeDraft]; comment.post = post; - // Increment the post's comment count. + // Increment the post's comment count. post.commentCount = @([post.commentCount integerValue] + 1); // Find its parent comment (if it exists) Comment *parentComment; - if (parentID) { - parentComment = [self findCommentWithID:parentID fromPost:post]; + if (parentID.intValue != 0) { + parentComment = [post commentWithID:parentID]; } // Update depth and hierarchy - [self setHierarchAndDepthOnComment:comment withParentComment:parentComment]; + [self setHierarchyAndDepthOnComment:comment withParentComment:parentComment]; - [self.managedObjectContext obtainPermanentIDsForObjects:@[comment] error:&error]; + [context obtainPermanentIDsForObjects:@[comment] error:&error]; if (error) { DDLogError(@"%@ error obtaining permanent ID for a hierarchical comment %@: %@", NSStringFromSelector(_cmd), comment, error); } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; return comment; } -- (void)setHierarchAndDepthOnComment:(Comment *)comment withParentComment:(Comment *)parentComment +- (void)setHierarchyAndDepthOnComment:(Comment *)comment withParentComment:(Comment *)parentComment { + NSParameterAssert(comment.managedObjectContext != nil); + // Update depth and hierarchy - NSNumber *commentID = comment.commentID; - if (!commentID) { - // A new comment will have a nil commentID. If nil is used when formatting the hierarchy, + NSNumber *commentID = [NSNumber numberWithInt:comment.commentID]; + + if (commentID != 0) { + // A new comment will have a 0 commentID. If 0 is used when formatting the hierarchy, // the comment will preceed any other comment in its level of the hierarchy. // Instead we'll pass a number so large as to ensure the comment will appear last in a list. commentID = @9999999; @@ -860,56 +1125,67 @@ - (void)setHierarchAndDepthOnComment:(Comment *)comment withParentComment:(Comme if (parentComment) { comment.hierarchy = [NSString stringWithFormat:@"%@.%@", parentComment.hierarchy, [self formattedHierarchyElement:commentID]]; - comment.depth = @([parentComment.depth integerValue] + 1); + comment.depth = parentComment.depth + 1; } else { comment.hierarchy = [self formattedHierarchyElement:commentID]; - comment.depth = @(0); + comment.depth = 0; } - - [self.managedObjectContext performBlock:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - }]; } -- (void)updateCommentAndSave:(Comment *)comment withRemoteComment:(RemoteComment *)remoteComment +- (void)updateHierarchicalComment:(Comment *)comment withRemoteComment:(RemoteComment *)remoteComment { + NSParameterAssert(comment.managedObjectContext != nil); + [self updateComment:comment withRemoteComment:remoteComment]; // Find its parent comment (if it exists) Comment *parentComment; - if (comment.parentID) { - parentComment = [self findCommentWithID:comment.parentID fromPost:(ReaderPost *)comment.post]; + if (comment.parentID != 0) { + NSNumber *parentID = [NSNumber numberWithInt:comment.parentID]; + parentComment = [(ReaderPost *)comment.post commentWithID:parentID]; } // Update depth and hierarchy - [self setHierarchAndDepthOnComment:comment withParentComment:parentComment]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; + [self setHierarchyAndDepthOnComment:comment withParentComment:parentComment]; } -- (void)mergeHierarchicalComments:(NSArray *)comments forPage:(NSUInteger)page forPost:(ReaderPost *)post onComplete:(void (^)(BOOL includesNewComments))onComplete +- (BOOL)mergeHierarchicalComments:(NSArray *)comments forPage:(NSUInteger)page forPost:(ReaderPost *)post { + NSParameterAssert(post.managedObjectContext != nil); + if (![comments count]) { - onComplete(NO); - return; + return NO; } + NSMutableSet<NSNumber *> *visibleCommentIds = [NSMutableSet new]; NSMutableArray *ancestors = [NSMutableArray array]; NSMutableArray *commentsToKeep = [NSMutableArray array]; NSString *entityName = NSStringFromClass([Comment class]); NSUInteger newCommentCount = 0; for (RemoteComment *remoteComment in comments) { - Comment *comment = [self findCommentWithID:remoteComment.commentID fromPost:post]; + Comment *comment = [post commentWithID:remoteComment.commentID]; if (!comment) { newCommentCount++; - comment = [NSEntityDescription insertNewObjectForEntityForName:entityName inManagedObjectContext:self.managedObjectContext]; + comment = [NSEntityDescription insertNewObjectForEntityForName:entityName inManagedObjectContext:post.managedObjectContext]; } [self updateComment:comment withRemoteComment:remoteComment]; // Calculate hierarchy and depth. - ancestors = [self ancestorsForCommentWithParentID:comment.parentID andCurrentAncestors:ancestors]; - comment.hierarchy = [self hierarchyFromAncestors:ancestors andCommentID:comment.commentID]; - comment.depth = @([ancestors count]); + ancestors = [self ancestorsForCommentWithParentID:[NSNumber numberWithInt:comment.parentID] andCurrentAncestors:ancestors]; + comment.hierarchy = [self hierarchyFromAncestors:ancestors andCommentID:[NSNumber numberWithInt:comment.commentID]]; + + // Comments are shown on the thread when (1) it is approved, and (2) its ancestors are approved. + // Having the comments sorted hierarchically ascending ensures that each comment's predecessors will be visited first. + // Therefore, we only need to check if the comment and its direct parent are approved. + // Ref: https://github.com/wordpress-mobile/WordPress-iOS/issues/18081 + BOOL hasValidParent = comment.parentID > 0 && [visibleCommentIds containsObject:@(comment.parentID)]; + if ([comment isApproved] && ([comment isTopLevelComment] || hasValidParent)) { + [visibleCommentIds addObject:@(comment.commentID)]; + } + comment.visibleOnReader = [visibleCommentIds containsObject:@(comment.commentID)]; + + comment.depth = ancestors.count; comment.post = post; comment.content = [self sanitizeCommentContent:comment.content isPrivateSite:post.isPrivate]; [commentsToKeep addObject:comment]; @@ -921,7 +1197,7 @@ - (void)mergeHierarchicalComments:(NSArray *)comments forPage:(NSUInteger)page f // helps avoid certain cases where some pages might not be resynced, creating gaps in the content. if (page == 1) { [self deleteCommentsMissingFromHierarchicalComments:commentsToKeep forPost:post]; - [self deleteUnownedComments]; + [self deleteUnownedCommentsInContext:post.managedObjectContext]; } // Make sure the post's comment count is at least the number of comments merged. @@ -929,9 +1205,7 @@ - (void)mergeHierarchicalComments:(NSArray *)comments forPage:(NSUInteger)page f post.commentCount = @([commentsToKeep count]); } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - onComplete(newCommentCount > 0); - }]; + return newCommentCount > 0; } // Does not save context @@ -939,7 +1213,7 @@ - (void)deleteCommentsMissingFromHierarchicalComments:(NSArray *)commentsToKeep { for (Comment *comment in post.comments) { if (![commentsToKeep containsObject:comment]) { - [self.managedObjectContext deleteObject:comment]; + [post.managedObjectContext deleteObject:comment]; } } } @@ -950,7 +1224,7 @@ - (NSArray *)topLevelCommentsForPage:(NSUInteger)page forPost:(ReaderPost *)post // Retrieve the starting and ending comments for the specified page. NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:entityName]; - fetchRequest.predicate = [NSPredicate predicateWithFormat:@"post = %@ AND parentID = NULL", post]; + fetchRequest.predicate = [NSPredicate predicateWithFormat:@"post = %@ AND parentID = 0", post]; NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"hierarchy" ascending:YES]; fetchRequest.sortDescriptors = @[sortDescriptor]; [fetchRequest setFetchLimit:WPTopLevelHierarchicalCommentsPerPage]; @@ -958,114 +1232,106 @@ - (NSArray *)topLevelCommentsForPage:(NSUInteger)page forPost:(ReaderPost *)post [fetchRequest setFetchOffset:offset]; NSError *error = nil; - NSArray *fetchedObjects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; + NSArray *fetchedObjects = [post.managedObjectContext executeFetchRequest:fetchRequest error:&error]; if (error) { DDLogError(@"Error fetching top level comments for page %i : %@", page, error); } return fetchedObjects; } -- (Comment *)firstCommentForPage:(NSUInteger)page forPost:(ReaderPost *)post +- (NSArray *)topLevelComments:(NSUInteger)number forPost:(ReaderPost *)post { - NSArray *comments = [self topLevelCommentsForPage:page forPost:post]; - return [comments firstObject]; -} - -- (Comment *)lastCommentForPage:(NSUInteger)page forPost:(ReaderPost *)post -{ - NSArray *comments = [self topLevelCommentsForPage:page forPost:post]; - Comment *lastParentComment = [comments lastObject]; - - NSString *entityName = NSStringFromClass([Comment class]); - NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:entityName]; - NSString *wildCard = [NSString stringWithFormat:@"%@*", lastParentComment.hierarchy]; - fetchRequest.predicate = [NSPredicate predicateWithFormat:@"post = %@ AND hierarchy LIKE %@", post, wildCard]; - NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"hierarchy" ascending:YES]; - fetchRequest.sortDescriptors = @[sortDescriptor]; - - NSError *error = nil; - NSArray *fetchedObjects = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - if (error) { - DDLogError(@"Error fetching last comment for page %i : %@", page, error); - } - return [fetchedObjects lastObject]; -} - -- (Comment *)findCommentWithID:(NSNumber *)commentID fromPost:(ReaderPost *)post -{ - NSSet *comments = [post.comments filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"commentID = %@", commentID]]; - return [comments anyObject]; + NSParameterAssert(post.managedObjectContext != nil); + NSArray * __block commentsToReturn = nil; + [post.managedObjectContext performBlockAndWait:^{ + NSArray *comments = [self topLevelCommentsForPage:1 forPost:post]; + NSInteger count = MIN(comments.count, number); + commentsToReturn = [comments subarrayWithRange:NSMakeRange(0, count)]; + }]; + return commentsToReturn; } - #pragma mark - Transformations - (void)updateComment:(Comment *)comment withRemoteComment:(RemoteComment *)remoteComment { - comment.commentID = remoteComment.commentID; + NSParameterAssert(comment.managedObjectContext != nil); + + comment.commentID = [remoteComment.commentID intValue]; + comment.authorID = [remoteComment.authorID intValue]; comment.author = remoteComment.author; comment.author_email = remoteComment.authorEmail; comment.author_url = remoteComment.authorUrl; comment.authorAvatarURL = remoteComment.authorAvatarURL; + comment.author_ip = remoteComment.authorIP; comment.content = remoteComment.content; + comment.rawContent = remoteComment.rawContent; comment.dateCreated = remoteComment.date; comment.link = remoteComment.link; - comment.parentID = remoteComment.parentID; - comment.postID = remoteComment.postID; + comment.parentID = [remoteComment.parentID intValue]; + comment.postID = [remoteComment.postID intValue]; comment.postTitle = remoteComment.postTitle; comment.status = remoteComment.status; comment.type = remoteComment.type; comment.isLiked = remoteComment.isLiked; - comment.likeCount = remoteComment.likeCount; + comment.likeCount = [remoteComment.likeCount intValue]; + comment.canModerate = remoteComment.canModerate; // if the post for the comment is not set, check if that post is already stored and associate them if (!comment.post) { - PostService *postService = [[PostService alloc] initWithManagedObjectContext:self.managedObjectContext]; - comment.post = [postService findPostWithID:comment.postID inBlog:comment.blog]; + comment.post = [comment.blog lookupPostWithID:[NSNumber numberWithInt:comment.postID] inContext:comment.managedObjectContext]; } } - (RemoteComment *)remoteCommentWithComment:(Comment *)comment { RemoteComment *remoteComment = [RemoteComment new]; - remoteComment.commentID = comment.commentID; + remoteComment.commentID = [NSNumber numberWithInt:comment.commentID]; + remoteComment.authorID = [NSNumber numberWithInt:comment.authorID]; remoteComment.author = comment.author; remoteComment.authorEmail = comment.author_email; remoteComment.authorUrl = comment.author_url; remoteComment.authorAvatarURL = comment.authorAvatarURL; + remoteComment.authorIP = comment.author_ip; remoteComment.content = comment.content; remoteComment.date = comment.dateCreated; remoteComment.link = comment.link; - remoteComment.parentID = comment.parentID; - remoteComment.postID = comment.postID; + remoteComment.parentID = [NSNumber numberWithInt:comment.parentID]; + remoteComment.postID = [NSNumber numberWithInt:comment.postID]; remoteComment.postTitle = comment.postTitle; remoteComment.status = comment.status; remoteComment.type = comment.type; remoteComment.isLiked = comment.isLiked; - remoteComment.likeCount = comment.likeCount; + remoteComment.likeCount = [NSNumber numberWithInt:comment.likeCount]; + remoteComment.canModerate = comment.canModerate; return remoteComment; } #pragma mark - Remotes -- (id<CommentServiceRemote>)remoteForBlog:(Blog *)blog +- (id<CommentServiceRemote>)remoteForComment:(Comment *)comment { - id<CommentServiceRemote>remote; - // TODO: refactor API creation so it's not part of the model - if ([blog supports:BlogFeatureWPComRESTAPI]) { - if (blog.wordPressComRestApi) { - remote = [[CommentServiceRemoteREST alloc] initWithWordPressComRestApi:blog.wordPressComRestApi siteID:blog.dotComID]; - } - } else if (blog.xmlrpcApi) { - remote = [[CommentServiceRemoteXMLRPC alloc] initWithApi:blog.xmlrpcApi username:blog.username password:blog.password]; + NSParameterAssert(comment.managedObjectContext != nil); + + // If the comment is fetched through the Reader API, the blog will always be nil. + // Try to find the Blog locally first, as it should exist if the user has a role on the site. + if (comment.post && [comment.post isKindOfClass:[ReaderPost class]]) { + ReaderPost *readerPost = (ReaderPost *)comment.post; + return [self remoteForBlog:[Blog lookupWithHostname:readerPost.blogURL inContext:comment.managedObjectContext]]; } - return remote; + + return [self remoteForBlog:comment.blog]; +} + +- (id<CommentServiceRemote>)remoteForBlog:(Blog *)blog +{ + return [self.remoteFactory remoteWithBlog:blog]; } - (CommentServiceRemoteREST *)restRemoteForSite:(NSNumber *)siteID { - return [[CommentServiceRemoteREST alloc] initWithWordPressComRestApi:[self apiForRESTRequest] siteID:siteID]; + return [self.remoteFactory restRemoteWithSiteID:siteID api:[self apiForRESTRequest]]; } /** @@ -1073,8 +1339,11 @@ - (CommentServiceRemoteREST *)restRemoteForSite:(NSNumber *)siteID */ - (WordPressComRestApi *)apiForRESTRequest { - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; + WPAccount * __block defaultAccount = nil; + [self.coreDataStack.mainContext performBlockAndWait:^{ + defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext]; + }]; + WordPressComRestApi *api = [defaultAccount wordPressComRestApi]; //Sergio Estevao: Do we really want to do this? If the call going to be valid if no credential is available? if (![api hasCredentials]) { diff --git a/WordPress/Classes/Services/CommentServiceRemoteFactory.swift b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift new file mode 100644 index 000000000000..5175d6d6a92b --- /dev/null +++ b/WordPress/Classes/Services/CommentServiceRemoteFactory.swift @@ -0,0 +1,38 @@ +import Foundation +import WordPressKit + + +/// Provides service remote instances for CommentService +@objc class CommentServiceRemoteFactory: NSObject { + + /// Returns a CommentServiceRemote for a given Blog object + /// + /// - Parameter blog: A valid Blog object + /// - Returns: A CommentServiceRemote instance + @objc func remote(blog: Blog) -> CommentServiceRemote? { + if blog.supports(.wpComRESTAPI), + let api = blog.wordPressComRestApi(), + let dotComID = blog.dotComID { + return CommentServiceRemoteREST(wordPressComRestApi: api, siteID: dotComID) + } + + if let api = blog.xmlrpcApi, + let username = blog.username, + let password = blog.password { + return CommentServiceRemoteXMLRPC(api: api, username: username, password: password) + } + + return nil + } + + /// Returns a REST remote for a given site ID. + /// + /// - Parameters: + /// - siteID: A valid siteID + /// - api: An instance of WordPressComRestAPI + /// - Returns: An instance of CommentServiceRemoteREST + @objc func restRemote(siteID: NSNumber, api: WordPressComRestApi) -> CommentServiceRemoteREST { + return CommentServiceRemoteREST(wordPressComRestApi: api, siteID: siteID) + } + +} diff --git a/WordPress/Classes/Services/CoreDataService.h b/WordPress/Classes/Services/CoreDataService.h new file mode 100644 index 000000000000..fb4e9ae9aff8 --- /dev/null +++ b/WordPress/Classes/Services/CoreDataService.h @@ -0,0 +1,28 @@ +#import <Foundation/Foundation.h> + +#import "CoreDataStack.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface CoreDataService : NSObject + +/** + * @brief This `CoreDataStack` object this instance will use for interacting with CoreData. + */ +@property (nonatomic, strong, readonly) id<CoreDataStack> coreDataStack; + +/** + * @brief Initializes the instance. + * + * @param coreDataStack The `CoreDataStack` this instance will use for interacting with CoreData. + * Cannot be nil. + * + * @returns The initialized object. + */ +- (instancetype)initWithCoreDataStack:(id<CoreDataStack>)coreDataStack NS_DESIGNATED_INITIALIZER; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WordPress/Classes/Services/CoreDataService.m b/WordPress/Classes/Services/CoreDataService.m new file mode 100644 index 000000000000..9bad4e03d531 --- /dev/null +++ b/WordPress/Classes/Services/CoreDataService.m @@ -0,0 +1,14 @@ +#import "CoreDataService.h" + +@implementation CoreDataService + +- (instancetype)initWithCoreDataStack:(id<CoreDataStack>)coreDataStack +{ + self = [super init]; + if (self) { + _coreDataStack = coreDataStack; + } + return self; +} + +@end diff --git a/WordPress/Classes/Services/CredentialsService.swift b/WordPress/Classes/Services/CredentialsService.swift index 3f63b1e05d7c..3118b89ca3e9 100644 --- a/WordPress/Classes/Services/CredentialsService.swift +++ b/WordPress/Classes/Services/CredentialsService.swift @@ -10,13 +10,12 @@ struct KeychainCredentialsProvider: CredentialsProvider { class CredentialsService { private let provider: CredentialsProvider - private let dotComOAuthKeychainService = "public-api.wordpress.com" init(provider: CredentialsProvider = KeychainCredentialsProvider()) { self.provider = provider } func getOAuthToken(site: JetpackSiteRef) -> String? { - return provider.getPassword(username: site.username, service: dotComOAuthKeychainService) + return provider.getPassword(username: site.username, service: AppConstants.authKeychainServiceName) } } diff --git a/WordPress/Classes/Services/DomainsService.swift b/WordPress/Classes/Services/DomainsService.swift index b90d6dbbdbd1..96abfa1d4cf4 100644 --- a/WordPress/Classes/Services/DomainsService.swift +++ b/WordPress/Classes/Services/DomainsService.swift @@ -1,46 +1,114 @@ import Foundation import CocoaLumberjack import WordPressKit +import CoreData + +struct FullyQuotedDomainSuggestion { + public let domainName: String + public let productID: Int? + public let supportsPrivacy: Bool? + public let costString: String + public let saleCostString: String? + + /// Maps the suggestion to a DomainSuggestion we can use with out APIs. + func remoteSuggestion() -> DomainSuggestion { + DomainSuggestion(domainName: domainName, + productID: productID, + supportsPrivacy: supportsPrivacy, + costString: costString) + } +} struct DomainsService { + typealias RemoteDomainSuggestion = DomainSuggestion + let remote: DomainsServiceRemote + let productsRemote: ProductServiceRemote - fileprivate let context: NSManagedObjectContext + private let coreDataStack: CoreDataStack - init(managedObjectContext context: NSManagedObjectContext, remote: DomainsServiceRemote) { - self.context = context + init(coreDataStack: CoreDataStack, remote: DomainsServiceRemote) { + self.coreDataStack = coreDataStack self.remote = remote + self.productsRemote = ProductServiceRemote(restAPI: remote.wordPressComRestApi) } - func refreshDomainsForSite(_ siteID: Int, completion: @escaping (Bool) -> Void) { + /// Refreshes the domains for the specified site. Since this method takes care of merging the new data into our local + /// persistance layer making it useful to call even without knowing the result, the completion closure is optional. + /// + /// - Parameters: + /// - siteID: the ID of the site to refresh the domains for. + /// - completion: the result of the refresh request. + /// + func refreshDomains(siteID: Int, completion: ((Result<Void, Error>) -> Void)? = nil) { remote.getDomainsForSite(siteID, success: { domains in - self.mergeDomains(domains, forSite: siteID) - completion(true) - }, failure: { error in - completion(false) + self.coreDataStack.performAndSave({ context in + self.mergeDomains(domains, forSite: siteID, in: context) + }, completion: { + completion?(.success(())) + }, on: .main) + }, failure: { error in + completion?(.failure(error)) }) } - func getDomainSuggestions(base: String, - segmentID: Int64, + func getDomainSuggestions(query: String, + segmentID: Int64? = nil, + quantity: Int? = nil, + domainSuggestionType: DomainsServiceRemote.DomainSuggestionType? = nil, success: @escaping ([DomainSuggestion]) -> Void, failure: @escaping (Error) -> Void) { - let request = DomainSuggestionRequest(query: base, segmentID: segmentID) + let request = DomainSuggestionRequest(query: query, segmentID: segmentID, quantity: quantity, suggestionType: domainSuggestionType) remote.getDomainSuggestions(request: request, success: { suggestions in - let sorted = self.sortedSuggestions(suggestions, forBase: base) - success(sorted) + let sorted = self.sortedSuggestions(suggestions, query: query) + success(sorted) }) { error in failure(error) } } - func getDomainSuggestions(base: String, - domainSuggestionType: DomainsServiceRemote.DomainSuggestionType = .onlyWordPressDotCom, + func getFullyQuotedDomainSuggestions(query: String, + segmentID: Int64? = nil, + quantity: Int? = nil, + domainSuggestionType: DomainsServiceRemote.DomainSuggestionType? = nil, + success: @escaping ([FullyQuotedDomainSuggestion]) -> Void, + failure: @escaping (Error) -> Void) { + + productsRemote.getProducts { result in + switch result { + case .failure(let error): + failure(error) + case .success(let products): + getDomainSuggestions(query: query, segmentID: segmentID, quantity: quantity, domainSuggestionType: domainSuggestionType, success: { domainSuggestions in + + success(domainSuggestions.map { remoteSuggestion in + let saleCostString = products.first() { + $0.id == remoteSuggestion.productID + }?.saleCostForDisplay() + + return FullyQuotedDomainSuggestion( + domainName: remoteSuggestion.domainName, + productID: remoteSuggestion.productID, + supportsPrivacy: remoteSuggestion.supportsPrivacy, + costString: remoteSuggestion.costString, + saleCostString: saleCostString) + }) + }, failure: failure) + } + } + } +/* + func getDomainSuggestions(query: String, + quantity: Int? = nil, + domainSuggestionType: DomainSuggestionType = .onlyWordPressDotCom, success: @escaping ([DomainSuggestion]) -> Void, failure: @escaping (Error) -> Void) { + let request = DomainSuggestionRequest(query: query, quantity: quantity) + remote.getDomainSuggestions(base: base, + quantity: quantity, domainSuggestionType: domainSuggestionType, success: { suggestions in let sorted = self.sortedSuggestions(suggestions, forBase: base) @@ -48,15 +116,15 @@ struct DomainsService { }) { error in failure(error) } - } + }*/ // If any of the suggestions matches the base exactly, // then sort that suggestion up to the top of the list. - fileprivate func sortedSuggestions(_ suggestions: [DomainSuggestion], forBase base: String) -> [DomainSuggestion] { - let normalizedBase = base.lowercased().replacingMatches(of: " ", with: "") + private func sortedSuggestions(_ suggestions: [RemoteDomainSuggestion], query: String) -> [RemoteDomainSuggestion] { + let normalizedQuery = query.lowercased().replacingMatches(of: " ", with: "") var filteredSuggestions = suggestions - if let matchedSuggestionIndex = suggestions.firstIndex(where: { $0.subdomain == base || $0.subdomain == normalizedBase }) { + if let matchedSuggestionIndex = suggestions.firstIndex(where: { $0.subdomain == query || $0.subdomain == normalizedQuery }) { let matchedSuggestion = filteredSuggestions.remove(at: matchedSuggestionIndex) filteredSuggestions.insert(matchedSuggestion, at: 0) } @@ -64,15 +132,15 @@ struct DomainsService { return filteredSuggestions } - fileprivate func mergeDomains(_ domains: [Domain], forSite siteID: Int) { + private func mergeDomains(_ domains: [Domain], forSite siteID: Int, in context: NSManagedObjectContext) { let remoteDomains = domains - let localDomains = domainsForSite(siteID) + let localDomains = domainsForSite(siteID, in: context) let remoteDomainNames = Set(remoteDomains.map({ $0.domainName })) let localDomainNames = Set(localDomains.map({ $0.domainName })) let removedDomainNames = localDomainNames.subtracting(remoteDomainNames) - removeDomains(removedDomainNames, fromSite: siteID) + removeDomains(removedDomainNames, fromSite: siteID, in: context) // Let's try to only update objects that have changed let remoteChanges = remoteDomains.filter { @@ -80,22 +148,18 @@ struct DomainsService { } for remoteDomain in remoteChanges { - if let existingDomain = managedDomainWithName(remoteDomain.domainName, forSite: siteID), - let blog = blogForSiteID(siteID) { + if let existingDomain = managedDomainWithName(remoteDomain.domainName, forSite: siteID, in: context), + let blog = blogForSiteID(siteID, in: context) { existingDomain.updateWith(remoteDomain, blog: blog) DDLogDebug("Updated domain \(existingDomain)") } else { - createManagedDomain(remoteDomain, forSite: siteID) + create(remoteDomain, forSite: siteID, in: context) } } - - ContextManager.sharedInstance().saveContextAndWait(context) } - fileprivate func blogForSiteID(_ siteID: Int) -> Blog? { - let service = BlogService(managedObjectContext: context) - - guard let blog = service.blog(byBlogId: NSNumber(value: siteID)) else { + private func blogForSiteID(_ siteID: Int, in context: NSManagedObjectContext) -> Blog? { + guard let blog = try? Blog.lookup(withID: siteID, in: context) else { let error = "Tried to obtain a Blog for a non-existing site (ID: \(siteID))" assertionFailure(error) DDLogError(error) @@ -105,8 +169,8 @@ struct DomainsService { return blog } - fileprivate func managedDomainWithName(_ domainName: String, forSite siteID: Int) -> ManagedDomain? { - guard let blog = blogForSiteID(siteID) else { return nil } + private func managedDomainWithName(_ domainName: String, forSite siteID: Int, in context: NSManagedObjectContext) -> ManagedDomain? { + guard let blog = blogForSiteID(siteID, in: context) else { return nil } let request = NSFetchRequest<NSFetchRequestResult>(entityName: ManagedDomain.entityName()) request.predicate = NSPredicate(format: "%K = %@ AND %K = %@", ManagedDomain.Relationships.blog, blog, ManagedDomain.Attributes.domainName, domainName) @@ -115,16 +179,22 @@ struct DomainsService { return results.first } - fileprivate func createManagedDomain(_ domain: Domain, forSite siteID: Int) { - guard let blog = blogForSiteID(siteID) else { return } + func create(_ domain: Domain, forSite siteID: Int) { + coreDataStack.performAndSave { context in + self.create(domain, forSite: siteID, in: context) + } + } + + private func create(_ domain: Domain, forSite siteID: Int, in context: NSManagedObjectContext) { + guard let blog = blogForSiteID(siteID, in: context) else { return } let managedDomain = NSEntityDescription.insertNewObject(forEntityName: ManagedDomain.entityName(), into: context) as! ManagedDomain managedDomain.updateWith(domain, blog: blog) DDLogDebug("Created domain \(managedDomain)") } - fileprivate func domainsForSite(_ siteID: Int) -> [Domain] { - guard let blog = blogForSiteID(siteID) else { return [] } + private func domainsForSite(_ siteID: Int, in context: NSManagedObjectContext) -> [Domain] { + guard let blog = blogForSiteID(siteID, in: context) else { return [] } let request = NSFetchRequest<NSFetchRequestResult>(entityName: ManagedDomain.entityName()) request.predicate = NSPredicate(format: "%K == %@", ManagedDomain.Relationships.blog, blog) @@ -140,8 +210,8 @@ struct DomainsService { return domains.map { Domain(managedDomain: $0) } } - fileprivate func removeDomains(_ domainNames: Set<String>, fromSite siteID: Int) { - guard let blog = blogForSiteID(siteID) else { return } + private func removeDomains(_ domainNames: Set<String>, fromSite siteID: Int, in context: NSManagedObjectContext) { + guard let blog = blogForSiteID(siteID, in: context) else { return } let request = NSFetchRequest<NSFetchRequestResult>(entityName: ManagedDomain.entityName()) request.predicate = NSPredicate(format: "%K = %@ AND %K IN %@", ManagedDomain.Relationships.blog, blog, ManagedDomain.Attributes.domainName, domainNames) @@ -154,7 +224,7 @@ struct DomainsService { } extension DomainsService { - init(managedObjectContext context: NSManagedObjectContext, account: WPAccount) { - self.init(managedObjectContext: context, remote: DomainsServiceRemote(wordPressComRestApi: account.wordPressComRestApi)) + init(coreDataStack: CoreDataStack, account: WPAccount) { + self.init(coreDataStack: coreDataStack, remote: DomainsServiceRemote(wordPressComRestApi: account.wordPressComRestApi)) } } diff --git a/WordPress/Classes/Services/EditorSettingsService.swift b/WordPress/Classes/Services/EditorSettingsService.swift index e707c074c896..61b8276f91e8 100644 --- a/WordPress/Classes/Services/EditorSettingsService.swift +++ b/WordPress/Classes/Services/EditorSettingsService.swift @@ -1,10 +1,17 @@ import Foundation +import WordPressKit @objc enum EditorSettingsServiceError: Int, Swift.Error { case mobileEditorNotSet } -@objc class EditorSettingsService: LocalCoreDataService { +@objc class EditorSettingsService: CoreDataService { + + private lazy var coreDataStackSwift: CoreDataStackSwift = { + // The concrete type of coreDataStack is actually ContextManager, which also conforms to CoreDataStackSwift. + (coreDataStack as? CoreDataStackSwift) ?? ContextManager.shared + }() + @objc(syncEditorSettingsForBlog:success:failure:) func syncEditorSettings(for blog: Blog, success: @escaping () -> Void, failure: @escaping (Swift.Error) -> Void) { guard let api = api(for: blog) else { @@ -19,15 +26,19 @@ import Foundation let service = EditorServiceRemote(wordPressComRestApi: api) service.getEditorSettings(siteID, success: { (settings) in - do { - try self.update(blog, remoteEditorSettings: settings) - ContextManager.sharedInstance().save(self.managedObjectContext) - success() - } catch EditorSettingsServiceError.mobileEditorNotSet { - self.migrateLocalSettingToRemote(for: blog, success: success, failure: failure) - } catch { - failure(error) - } + self.coreDataStackSwift.performAndSave({ context in + let blogInContext = try context.existingObject(with: blog.objectID) as! Blog + try self.update(blogInContext, remoteEditorSettings: settings) + }, completion: { result in + switch result { + case .success: + success() + case .failure(EditorSettingsServiceError.mobileEditorNotSet): + self.migrateLocalSettingToRemote(for: blog, success: success, failure: failure) + case let .failure(error): + failure(error) + } + }, on: .main) }, failure: failure) } @@ -78,7 +89,9 @@ import Foundation private extension EditorSettingsService { var defaultWPComAccount: WPAccount? { - return AccountService(managedObjectContext: managedObjectContext).defaultWordPressComAccount() + coreDataStack.performQuery { context in + try? WPAccount.lookupDefaultWordPressComAccount(in: context) + } } func updateAllSites(with response: [Int: EditorSettings.Mobile]) { diff --git a/WordPress/Classes/Services/FollowCommentsService.swift b/WordPress/Classes/Services/FollowCommentsService.swift new file mode 100644 index 000000000000..114769ab502c --- /dev/null +++ b/WordPress/Classes/Services/FollowCommentsService.swift @@ -0,0 +1,114 @@ +import Foundation +import WordPressKit + +class FollowCommentsService: NSObject { + + let post: ReaderPost + let remote: ReaderPostServiceRemote + private let coreDataStack: CoreDataStack + + fileprivate let postID: Int + fileprivate let siteID: Int + + required init?( + post: ReaderPost, + coreDataStack: CoreDataStack = ContextManager.shared, + remote: ReaderPostServiceRemote = ReaderPostServiceRemote.withDefaultApi() + ) { + guard let postID = post.postID as? Int, + let siteID = post.siteID as? Int + else { + return nil + } + + self.post = post + self.coreDataStack = coreDataStack + self.postID = postID + self.siteID = siteID + self.remote = remote + } + + @objc class func createService(with post: ReaderPost) -> FollowCommentsService? { + self.init(post: post) + } + + /// Returns a Bool indicating whether or not the comments on the post can be followed. + /// + @objc var canFollowConversation: Bool { + return post.canSubscribeComments + } + + /// Fetches the subscription status of the specified post for the current user. + /// + /// - Parameters: + /// - success: Success block called on a successful fetch. + /// - failure: Failure block called if there is any error. + @objc func fetchSubscriptionStatus(success: @escaping (Bool) -> Void, + failure: @escaping (Error?) -> Void) { + remote.fetchSubscriptionStatus(for: postID, + from: siteID, + success: success, + failure: failure) + } + + /// Toggles the subscription status of the specified post. + /// + /// - Parameters: + /// - isSubscribed: The current subscription status for the reader post. + /// - success: Success block called on a successful fetch. + /// - failure: Failure block called if there is any error. + @objc func toggleSubscribed(_ isSubscribed: Bool, + success: @escaping (Bool) -> Void, + failure: @escaping (Error?) -> Void) { + let objID = post.objectID + let successBlock = { (taskSuccessful: Bool) -> Void in + self.coreDataStack.performAndSave({ context in + if let post = try? context.existingObject(with: objID) as? ReaderPost { + post.isSubscribedComments = !isSubscribed + } + }, completion: { + success(taskSuccessful) + }, on: .main) + } + + if isSubscribed { + remote.unsubscribeFromPost(with: postID, + for: siteID, + success: successBlock, + failure: failure) + } else { + remote.subscribeToPost(with: postID, + for: siteID, + success: successBlock, + failure: failure) + } + } + + /// Toggles the notification setting for a specified post. + /// + /// - Parameters: + /// - isNotificationsEnabled: Determines whether the user should receive notifications for new comments on the specified post. + /// - success: Block called after the operation completes successfully. + /// - failure: Block called when the operation fails. + @objc func toggleNotificationSettings(_ isNotificationsEnabled: Bool, + success: @escaping () -> Void, + failure: @escaping (Error?) -> Void) { + + remote.updateNotificationSettingsForPost(with: postID, siteID: siteID, receiveNotifications: isNotificationsEnabled) { [weak self] in + guard let self = self else { + failure(nil) + return + } + + self.coreDataStack.performAndSave({ context in + if let post = try? context.existingObject(with: self.post.objectID) as? ReaderPost { + post.receivesCommentNotifications = isNotificationsEnabled + } + }, completion: success, on: .main) + } failure: { error in + DDLogError("Error updating notification settings for followed conversation: \(String(describing: error))") + failure(error) + } + } + +} diff --git a/WordPress/Classes/Services/HomepageSettingsService.swift b/WordPress/Classes/Services/HomepageSettingsService.swift new file mode 100644 index 000000000000..67c7729ba6b2 --- /dev/null +++ b/WordPress/Classes/Services/HomepageSettingsService.swift @@ -0,0 +1,90 @@ +import Foundation +import WordPressKit + +/// Service allowing updating of homepage settings +/// +struct HomepageSettingsService { + public enum ResponseError: Error { + case decodingFailed + } + + let blog: Blog + + fileprivate let coreDataStack: CoreDataStack + fileprivate let remote: HomepageSettingsServiceRemote + fileprivate let siteID: Int + + init?(blog: Blog, coreDataStack: CoreDataStack) { + guard let api = blog.wordPressComRestApi(), let dotComID = blog.dotComID as? Int else { + return nil + } + + self.remote = HomepageSettingsServiceRemote(wordPressComRestApi: api) + self.siteID = dotComID + self.blog = blog + self.coreDataStack = coreDataStack + } + + public func setHomepageType(_ type: HomepageType, + withPostsPageID postsPageID: Int? = nil, + homePageID: Int? = nil, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + var originalHomepageType: HomepageType? + var originalHomePageID: Int? + var originalPostsPageID: Int? + coreDataStack.performAndSave({ context in + guard let blog = Blog.lookup(withObjectID: self.blog.objectID, in: context) else { + return + } + + // Keep track of the original settings in case we need to revert + originalHomepageType = blog.homepageType + originalHomePageID = blog.homepagePageID + originalPostsPageID = blog.homepagePostsPageID + + switch type { + case .page: + blog.homepageType = .page + if let postsPageID = postsPageID { + blog.homepagePostsPageID = postsPageID + if postsPageID == originalHomePageID { + // Don't allow the same page to be set for both values + blog.homepagePageID = 0 + } + } + if let homePageID = homePageID { + blog.homepagePageID = homePageID + if homePageID == originalPostsPageID { + // Don't allow the same page to be set for both values + blog.homepagePostsPageID = 0 + } + } + case .posts: + blog.homepageType = .posts + } + }, completion: { + remote.setHomepageType( + type: type.remoteType, + for: siteID, + withPostsPageID: blog.homepagePostsPageID, + homePageID: blog.homepagePageID, + success: success, + failure: { error in + self.coreDataStack.performAndSave({ context in + guard let blog = Blog.lookup(withObjectID: self.blog.objectID, in: context) else { + return + } + blog.homepageType = originalHomepageType + blog.homepagePostsPageID = originalPostsPageID + blog.homepagePageID = originalHomePageID + }, completion: { + failure(error) + }, on: .main) + } + ) + }, on: .main) + + + } +} diff --git a/WordPress/Classes/Services/JetpackBackupService.swift b/WordPress/Classes/Services/JetpackBackupService.swift new file mode 100644 index 000000000000..06daa3cc5525 --- /dev/null +++ b/WordPress/Classes/Services/JetpackBackupService.swift @@ -0,0 +1,40 @@ +import Foundation + +class JetpackBackupService { + + private let coreDataStack: CoreDataStack + + private lazy var service: JetpackBackupServiceRemote = { + var api: WordPressComRestApi! + coreDataStack.mainContext.performAndWait { + api = WordPressComRestApi.defaultApi(in: self.coreDataStack.mainContext, localeKey: WordPressComRestApi.LocaleKeyV2) + } + + return JetpackBackupServiceRemote(wordPressComRestApi: api) + }() + + init(coreDataStack: CoreDataStack) { + self.coreDataStack = coreDataStack + } + + func prepareBackup(for site: JetpackSiteRef, + rewindID: String? = nil, + restoreTypes: JetpackRestoreTypes? = nil, + success: @escaping (JetpackBackup) -> Void, + failure: @escaping (Error) -> Void) { + service.prepareBackup(site.siteID, rewindID: rewindID, types: restoreTypes, success: success, failure: failure) + } + + func getBackupStatus(for site: JetpackSiteRef, downloadID: Int, success: @escaping (JetpackBackup) -> Void, failure: @escaping (Error) -> Void) { + service.getBackupStatus(site.siteID, downloadID: downloadID, success: success, failure: failure) + } + + func getAllBackupStatus(for site: JetpackSiteRef, success: @escaping ([JetpackBackup]) -> Void, failure: @escaping (Error) -> Void) { + service.getAllBackupStatus(site.siteID, success: success, failure: failure) + } + + func dismissBackupNotice(site: JetpackSiteRef, downloadID: Int) { + service.markAsDismissed(site.siteID, downloadID: downloadID, success: {}, failure: { _ in }) + } + +} diff --git a/WordPress/Classes/Services/JetpackCapabilitiesService.swift b/WordPress/Classes/Services/JetpackCapabilitiesService.swift new file mode 100644 index 000000000000..c6d8768e9f28 --- /dev/null +++ b/WordPress/Classes/Services/JetpackCapabilitiesService.swift @@ -0,0 +1,39 @@ +import WordPressKit + +@objc class JetpackCapabilitiesService: NSObject { + + let capabilitiesServiceRemote: JetpackCapabilitiesServiceRemote + + init(coreDataStack: CoreDataStack, capabilitiesServiceRemote: JetpackCapabilitiesServiceRemote?) { + if let capabilitiesServiceRemote { + self.capabilitiesServiceRemote = capabilitiesServiceRemote + } else { + let api = coreDataStack.performQuery { + WordPressComRestApi.defaultApi(in: $0, localeKey: WordPressComRestApi.LocaleKeyV2) + } + + self.capabilitiesServiceRemote = JetpackCapabilitiesServiceRemote(wordPressComRestApi: api) + } + } + + override convenience init() { + self.init(coreDataStack: ContextManager.shared, capabilitiesServiceRemote: nil) + } + + /// Returns an array of [RemoteBlog] with the Jetpack capabilities added in `capabilities` + /// - Parameters: + /// - blogs: An array of RemoteBlog + /// - success: A block that accepts an array of RemoteBlog + @objc func sync(blogs: [RemoteBlog], success: @escaping ([RemoteBlog]) -> Void) { + capabilitiesServiceRemote.for(siteIds: blogs.compactMap { $0.blogID as? Int }, + success: { capabilities in + blogs.forEach { blog in + if let cap = capabilities["\(blog.blogID)"] as? [String] { + cap.forEach { blog.capabilities[$0] = true } + } + } + success(blogs) + }) + } + +} diff --git a/WordPress/Classes/Services/JetpackNotificationMigrationService.swift b/WordPress/Classes/Services/JetpackNotificationMigrationService.swift new file mode 100644 index 000000000000..bf50cfaf08b4 --- /dev/null +++ b/WordPress/Classes/Services/JetpackNotificationMigrationService.swift @@ -0,0 +1,206 @@ +import UIKit + +protocol JetpackNotificationMigrationServiceProtocol { + func shouldPresentNotifications() -> Bool +} + +/// The service is created to support disabling WordPress notifications when Jetpack app enables notifications +/// The service uses URLScheme to determine from Jetpack app if WordPress app is installed, open it, disable notifications and come back to Jetpack app +/// This is a temporary solution to avoid duplicate notifications during the migration process from WordPress to Jetpack app +/// This service and its usage can be deleted once the migration is done +final class JetpackNotificationMigrationService: JetpackNotificationMigrationServiceProtocol { + private let remoteNotificationRegister: RemoteNotificationRegister + private let featureFlagStore: RemoteFeatureFlagStore + private let userDefaults: UserDefaults + private let isWordPress: Bool + + static let shared = JetpackNotificationMigrationService() + + static let wordPressScheme = "wordpressnotificationmigration" + static let jetpackScheme = "jetpacknotificationmigration" + private let wordPressNotificationsToggledDefaultsKey = "wordPressNotificationsToggledDefaultsKey" + private let jetpackNotificationMigrationDefaultsKey = "jetpackNotificationMigrationDefaultsKey" + + private var jetpackMigrationPreventDuplicateNotifications: Bool { + return RemoteFeatureFlag.jetpackMigrationPreventDuplicateNotifications.enabled(using: featureFlagStore) + } + + private lazy var notificationSettingsService: NotificationSettingsService? = { + NotificationSettingsService(coreDataStack: ContextManager.sharedInstance()) + }() + + private lazy var bloggingRemindersScheduler: BloggingRemindersScheduler? = { + try? BloggingRemindersScheduler( + notificationCenter: UNUserNotificationCenter.current(), + pushNotificationAuthorizer: InteractiveNotificationsManager.shared + ) + }() + + var wordPressNotificationsEnabled: Bool { + get { + /// UIApplication.shared.isRegisteredForRemoteNotifications should be always accessed from main thread + if Thread.isMainThread { + return remoteNotificationRegister.isRegisteredForRemoteNotifications + } else { + var isRegisteredForRemoteNotifications = false + DispatchQueue.main.sync { + isRegisteredForRemoteNotifications = remoteNotificationRegister.isRegisteredForRemoteNotifications + } + return isRegisteredForRemoteNotifications + } + } + + set { + userDefaults.set(true, forKey: wordPressNotificationsToggledDefaultsKey) + + if newValue, isWordPress { + remoteNotificationRegister.registerForRemoteNotifications() + rescheduleLocalNotifications() + } else if isWordPress { + remoteNotificationRegister.unregisterForRemoteNotifications() + } + + if isWordPress && !newValue { + cancelAllPendingWordPressLocalNotifications() + } + } + } + + /// Migration is supported if WordPress is compatible with the notification migration URLScheme + var isMigrationSupported: Bool { + guard let url = URL(string: "\(JetpackNotificationMigrationService.wordPressScheme)://") else { + return false + } + + return UIApplication.shared.canOpenURL(url) && jetpackMigrationPreventDuplicateNotifications + } + + /// disableWordPressNotificationsFromJetpack may get triggered multiple times from Jetpack app but it only needs to be executed the first time + private var isMigrationDone: Bool { + get { + return userDefaults.bool(forKey: jetpackNotificationMigrationDefaultsKey) + } + set { + userDefaults.setValue(newValue, forKey: jetpackNotificationMigrationDefaultsKey) + } + } + + init(remoteNotificationRegister: RemoteNotificationRegister = UIApplication.shared, + featureFlagStore: RemoteFeatureFlagStore = RemoteFeatureFlagStore(), + userDefaults: UserDefaults = .standard, + isWordPress: Bool = AppConfiguration.isWordPress) { + self.remoteNotificationRegister = remoteNotificationRegister + self.featureFlagStore = featureFlagStore + self.userDefaults = userDefaults + self.isWordPress = isWordPress + } + + func shouldShowNotificationControl() -> Bool { + return jetpackMigrationPreventDuplicateNotifications && isWordPress + } + + func shouldPresentNotifications() -> Bool { + let notificationsDisabled = !JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() + let appMigrated = jetpackMigrationPreventDuplicateNotifications + && isWordPress + && userDefaults.bool(forKey: wordPressNotificationsToggledDefaultsKey) + && !wordPressNotificationsEnabled + let disableNotifications = notificationsDisabled || appMigrated + + if disableNotifications { + cancelAllPendingWordPressLocalNotifications() + } + + return !disableNotifications + } + + // MARK: - Only executed on Jetpack app + + func disableWordPressNotificationsFromJetpack() { + guard !isMigrationDone, jetpackMigrationPreventDuplicateNotifications, !isWordPress else { + return + } + + let wordPressUrl: URL? = { + var components = URLComponents() + components.scheme = JetpackNotificationMigrationService.wordPressScheme + return components.url + }() + + /// Open WordPress app to disable notifications + if let url = wordPressUrl, UIApplication.shared.canOpenURL(url) { + isMigrationDone = true + UIApplication.shared.open(url) + } + } + + // MARK: - Only executed on WordPress app + + func handleNotificationMigrationOnWordPress() -> Bool { + guard isWordPress else { + return false + } + + wordPressNotificationsEnabled = false + + let jetpackUrl: URL? = { + var components = URLComponents() + components.scheme = JetpackNotificationMigrationService.jetpackScheme + return components.url + }() + + /// Return to Jetpack app + if let url = jetpackUrl, UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + + return true + } + + // MARK: - Local notifications + + private func cancelAllPendingWordPressLocalNotifications(notificationCenter: UNUserNotificationCenter = UNUserNotificationCenter.current()) { + if isWordPress { + notificationCenter.removeAllPendingNotificationRequests() + } + } + + func rescheduleLocalNotifications() { + DispatchQueue.main.async { [weak self] in + self?.rescheduleWeeklyRoundupNotifications() + self?.rescheduleBloggingReminderNotifications() + } + } + + private func rescheduleWeeklyRoundupNotifications() { + WordPressAppDelegate.shared?.backgroundTasksCoordinator.scheduleTasks { _ in } + } + + private func rescheduleBloggingReminderNotifications() { + notificationSettingsService?.getAllSettings { [weak self] settings in + for setting in settings { + if let blog = setting.blog, + let schedule = self?.bloggingRemindersScheduler?.schedule(for: blog), + let time = self?.bloggingRemindersScheduler?.scheduledTime(for: blog) { + if schedule != .none { + self?.bloggingRemindersScheduler?.schedule(schedule, for: blog, time: time) { result in + if case .success = result { + BloggingRemindersFlow.setHasShownWeeklyRemindersFlow(for: blog) + } + } + } + } + } + } failure: { _ in } + } +} + +// MARK: - Helpers + +protocol RemoteNotificationRegister { + func registerForRemoteNotifications() + func unregisterForRemoteNotifications() + var isRegisteredForRemoteNotifications: Bool { get } +} + +extension UIApplication: RemoteNotificationRegister {} diff --git a/WordPress/Classes/Services/JetpackRestoreService.swift b/WordPress/Classes/Services/JetpackRestoreService.swift new file mode 100644 index 000000000000..442e1cd0195c --- /dev/null +++ b/WordPress/Classes/Services/JetpackRestoreService.swift @@ -0,0 +1,36 @@ +import Foundation + +@objc class JetpackRestoreService: CoreDataService { + + private lazy var serviceV1: ActivityServiceRemote_ApiVersion1_0 = { + let api = coreDataStack.performQuery { + WordPressComRestApi.defaultApi(in: $0) + } + + return ActivityServiceRemote_ApiVersion1_0(wordPressComRestApi: api) + }() + + private lazy var service: ActivityServiceRemote = { + let api = coreDataStack.performQuery { + WordPressComRestApi.defaultApi(in: $0, localeKey: WordPressComRestApi.LocaleKeyV2) + } + + return ActivityServiceRemote(wordPressComRestApi: api) + }() + + func restoreSite(_ site: JetpackSiteRef, + rewindID: String?, + restoreTypes: JetpackRestoreTypes? = nil, + success: @escaping (String, Int) -> Void, + failure: @escaping (Error) -> Void) { + guard let rewindID = rewindID else { + return + } + serviceV1.restoreSite(site.siteID, rewindID: rewindID, types: restoreTypes, success: success, failure: failure) + } + + func getRewindStatus(for site: JetpackSiteRef, success: @escaping (RewindStatus) -> Void, failure: @escaping (Error) -> Void) { + service.getRewindStatus(site.siteID, success: success, failure: failure) + } + +} diff --git a/WordPress/Classes/Services/JetpackScanService.swift b/WordPress/Classes/Services/JetpackScanService.swift new file mode 100644 index 000000000000..2671f550da0b --- /dev/null +++ b/WordPress/Classes/Services/JetpackScanService.swift @@ -0,0 +1,143 @@ +import Foundation + +@objc class JetpackScanService: CoreDataService { + private lazy var service: JetpackScanServiceRemote = { + let api = coreDataStack.performQuery { + WordPressComRestApi.defaultApi(in: $0, localeKey: WordPressComRestApi.LocaleKeyV2) + } + + return JetpackScanServiceRemote(wordPressComRestApi: api) + }() + + @objc func getScanAvailable(for blog: Blog, success: @escaping(Bool) -> Void, failure: @escaping(Error?) -> Void) { + guard let siteID = blog.dotComID?.intValue else { + failure(JetpackScanServiceError.invalidSiteID) + return + } + + service.getScanAvailableForSite(siteID, success: success, failure: failure) + } + + func getScan(for blog: Blog, success: @escaping(JetpackScan) -> Void, failure: @escaping(Error?) -> Void) { + guard let siteID = blog.dotComID?.intValue else { + failure(JetpackScanServiceError.invalidSiteID) + return + } + + service.getScanForSite(siteID, success: success, failure: failure) + } + + func getScanWithFixableThreatsStatus(for blog: Blog, success: @escaping(JetpackScan) -> Void, failure: @escaping(Error?) -> Void) { + guard let siteID = blog.dotComID?.intValue else { + failure(JetpackScanServiceError.invalidSiteID) + return + } + + service.getScanForSite(siteID, success: { [weak self] scanObj in + // Only check if we're in the idle state, ie: not scanning or preparing to scan + // The result does not have any fixable threats, we don't need to get the statuses for them + guard scanObj.state == .idle, scanObj.hasFixableThreats, let fixableThreats = scanObj.fixableThreats else { + success(scanObj) + return + } + + self?.getFixStatusForThreats(fixableThreats, blog: blog, success: { fixResponse in + // We're not fixing any threats, just return the original scan object + guard fixResponse.isFixingThreats else { + success(scanObj) + return + } + + // Make a copy of the object so we can modify the state / fixing status + var returnObj = scanObj + returnObj.state = .fixingThreats + + // Map the threat Ids to Threats + let threats = returnObj.fixableThreats ?? [] + var inProgressThreats: [JetpackThreatFixStatus] = [] + + for item in fixResponse.threats { + // Filter any fixable threats that may not be actively being fixed + if item.status == .notStarted { + continue + } + + var threat = threats.filter({ $0.id == item.threatId }).first + if item.status == .inProgress { + threat?.status = .fixing + } + + var fixStatus = item + fixStatus.threat = threat + inProgressThreats.append(fixStatus) + } + + returnObj.threatFixStatus = inProgressThreats + + // + success(returnObj) + }, failure: failure) + }, failure: failure) + } + + func startScan(for blog: Blog, success: @escaping(Bool) -> Void, failure: @escaping(Error?) -> Void) { + guard let siteID = blog.dotComID?.intValue else { + failure(JetpackScanServiceError.invalidSiteID) + return + } + + service.startScanForSite(siteID, success: success, failure: failure) + } + + // MARK: - Threats + func fixThreats(_ threats: [JetpackScanThreat], blog: Blog, success: @escaping(JetpackThreatFixResponse) -> Void, failure: @escaping(Error) -> Void) { + guard let siteID = blog.dotComID?.intValue else { + failure(JetpackScanServiceError.invalidSiteID) + return + } + + service.fixThreats(threats, siteID: siteID, success: success, failure: failure) + } + + func fixThreat(_ threat: JetpackScanThreat, blog: Blog, success: @escaping(JetpackThreatFixStatus) -> Void, failure: @escaping(Error) -> Void) { + guard let siteID = blog.dotComID?.intValue else { + failure(JetpackScanServiceError.invalidSiteID) + return + } + + service.fixThreat(threat, siteID: siteID, success: success, failure: failure) + } + + public func getFixStatusForThreats(_ threats: [JetpackScanThreat], blog: Blog, success: @escaping(JetpackThreatFixResponse) -> Void, failure: @escaping(Error) -> Void) { + guard let siteID = blog.dotComID?.intValue else { + failure(JetpackScanServiceError.invalidSiteID) + return + } + + service.getFixStatusForThreats(threats, siteID: siteID, success: success, failure: failure) + } + + func ignoreThreat(_ threat: JetpackScanThreat, blog: Blog, success: @escaping() -> Void, failure: @escaping(Error) -> Void) { + guard let siteID = blog.dotComID?.intValue else { + failure(JetpackScanServiceError.invalidSiteID) + return + } + + service.ignoreThreat(threat, siteID: siteID, success: success, failure: failure) + } + + // MARK: - History + func getHistory(for blog: Blog, success: @escaping(JetpackScanHistory) -> Void, failure: @escaping(Error) -> Void) { + guard let siteID = blog.dotComID?.intValue else { + failure(JetpackScanServiceError.invalidSiteID) + return + } + + service.getHistoryForSite(siteID, success: success, failure: failure) + } + + // MARK: - Helpers + enum JetpackScanServiceError: Error { + case invalidSiteID + } +} diff --git a/WordPress/Classes/Services/JetpackWebViewControllerFactory.swift b/WordPress/Classes/Services/JetpackWebViewControllerFactory.swift new file mode 100644 index 000000000000..e3d53f3d23b8 --- /dev/null +++ b/WordPress/Classes/Services/JetpackWebViewControllerFactory.swift @@ -0,0 +1,12 @@ +import UIKit + +class JetpackWebViewControllerFactory { + + static func settingsController(siteID: Int) -> UIViewController? { + guard let url = URL(string: "https://wordpress.com/settings/jetpack/\(siteID)") else { + return nil + } + return WebViewControllerFactory.controller(url: url, source: "jetpack_web_settings") + } + +} diff --git a/WordPress/Classes/Services/LikeUserHelpers.swift b/WordPress/Classes/Services/LikeUserHelpers.swift new file mode 100644 index 000000000000..b96f0df1c2fc --- /dev/null +++ b/WordPress/Classes/Services/LikeUserHelpers.swift @@ -0,0 +1,85 @@ +import Foundation +import CoreData + +/// Helper class for creating LikeUser objects. +/// Used by PostService and CommentService when fetching likes for posts/comments. +/// +@objc class LikeUserHelper: NSObject { + + @objc class func createOrUpdateFrom(remoteUser: RemoteLikeUser, context: NSManagedObjectContext) -> LikeUser { + let liker = likeUser(for: remoteUser, context: context) ?? LikeUser(context: context) + + liker.userID = remoteUser.userID.int64Value + liker.username = remoteUser.username + liker.displayName = remoteUser.displayName + liker.primaryBlogID = remoteUser.primaryBlogID?.int64Value ?? 0 + liker.avatarUrl = remoteUser.avatarURL + liker.bio = remoteUser.bio ?? "" + liker.dateLikedString = remoteUser.dateLiked ?? "" + liker.dateLiked = DateUtils.date(fromISOString: liker.dateLikedString) + liker.likedSiteID = remoteUser.likedSiteID?.int64Value ?? 0 + liker.likedPostID = remoteUser.likedPostID?.int64Value ?? 0 + liker.likedCommentID = remoteUser.likedCommentID?.int64Value ?? 0 + liker.dateFetched = Date() + + updatePreferredBlog(for: liker, with: remoteUser, context: context) + + return liker + } + + class func likeUser(for remoteUser: RemoteLikeUser, context: NSManagedObjectContext) -> LikeUser? { + let userID = remoteUser.userID ?? 0 + let siteID = remoteUser.likedSiteID ?? 0 + let postID = remoteUser.likedPostID ?? 0 + let commentID = remoteUser.likedCommentID ?? 0 + + let request = LikeUser.fetchRequest() as NSFetchRequest<LikeUser> + request.predicate = NSPredicate(format: "userID = %@ AND likedSiteID = %@ AND likedPostID = %@ AND likedCommentID = %@", + userID, siteID, postID, commentID) + return try? context.fetch(request).first + } + + 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 + } + + 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 + } + + class func purgeStaleLikes() { + ContextManager.shared.performAndSave { + purgeStaleLikes(fromContext: $0) + } + } + + // Delete all LikeUsers that were last fetched at least 7 days ago. + private class func purgeStaleLikes(fromContext context: NSManagedObjectContext) { + guard let staleDate = Calendar.current.date(byAdding: .day, value: -7, to: Date()) else { + DDLogError("Error creating date to purge stale Likes.") + return + } + + let request = LikeUser.fetchRequest() as NSFetchRequest<LikeUser> + request.predicate = NSPredicate(format: "dateFetched <= %@", staleDate as CVarArg) + + do { + let users = try context.fetch(request) + users.forEach { context.delete($0) } + } catch { + DDLogError("Error fetching Like Users: \(error)") + } + } + +} diff --git a/WordPress/Classes/Services/LocalNewsService.swift b/WordPress/Classes/Services/LocalNewsService.swift deleted file mode 100644 index 0380545aadbf..000000000000 --- a/WordPress/Classes/Services/LocalNewsService.swift +++ /dev/null @@ -1,34 +0,0 @@ -final class LocalNewsService: NewsService { - private var content: [String: String]? - - /// This initialiser is here temporarily. Instead of the content, we should only pass the url to the file that we want to load - init(filePath: String?) { - loadFile(path: filePath) - } - - private func loadFile(path: String?) { - guard let path = path else { - return - } - - content = NSDictionary.init(contentsOfFile: path) as? [String: String] - } - - func load(then completion: @escaping (Result<NewsItem, Error>) -> Void) { - guard let content = content else { - let result: Result<NewsItem, Error> = .failure(NewsError.fileNotFound) - completion(result) - - return - } - - guard let newsItem = NewsItem(fileContent: content) else { - let result: Result<NewsItem, Error> = .failure(NewsError.invalidContent) - completion(result) - - return - } - - completion(.success(newsItem)) - } -} diff --git a/WordPress/Classes/Services/MediaCoordinator.swift b/WordPress/Classes/Services/MediaCoordinator.swift index fe62cb955e86..5fdd415fa80a 100644 --- a/WordPress/Classes/Services/MediaCoordinator.swift +++ b/WordPress/Classes/Services/MediaCoordinator.swift @@ -11,12 +11,18 @@ import enum Alamofire.AFError class MediaCoordinator: NSObject { @objc static let shared = MediaCoordinator() - private(set) var backgroundContext: NSManagedObjectContext = { - let context = ContextManager.sharedInstance().newDerivedContext() - context.automaticallyMergesChangesFromParent = true - return context + private let coreDataStack: CoreDataStackSwift + + private var mainContext: NSManagedObjectContext { + coreDataStack.mainContext + } + + private let syncOperationQueue: OperationQueue = { + let queue = OperationQueue() + queue.name = "org.wordpress.mediauploadcoordinator.sync" + queue.maxConcurrentOperationCount = 1 + return queue }() - private let mainContext = ContextManager.sharedInstance().mainContext private let queue = DispatchQueue(label: "org.wordpress.mediauploadcoordinator") @@ -36,8 +42,9 @@ class MediaCoordinator: NSObject { private let mediaServiceFactory: MediaService.Factory - init(_ mediaServiceFactory: MediaService.Factory = MediaService.Factory()) { + init(_ mediaServiceFactory: MediaService.Factory = MediaService.Factory(), coreDataStack: CoreDataStackSwift = ContextManager.shared) { self.mediaServiceFactory = mediaServiceFactory + self.coreDataStack = coreDataStack super.init() @@ -54,12 +61,11 @@ class MediaCoordinator: NSObject { /// - Returns: `true` if all media in the post is uploading or was uploaded, `false` otherwise. /// func uploadMedia(for post: AbstractPost, automatedRetry: Bool = false) -> Bool { - let mediaService = mediaServiceFactory.create(backgroundContext) let failedMedia: [Media] = post.media.filter({ $0.remoteStatus == .failed }) let mediasToUpload: [Media] if automatedRetry { - mediasToUpload = mediaService.failedMediaForUpload(in: post, automatedRetry: automatedRetry) + mediasToUpload = Media.failedForUpload(in: post, automatedRetry: automatedRetry) } else { mediasToUpload = failedMedia } @@ -132,10 +138,8 @@ class MediaCoordinator: NSObject { /// - parameter blog: The blog that the asset should be added to. /// - parameter origin: The location in the app where the upload was initiated (optional). /// - @discardableResult - func addMedia(from asset: ExportableAsset, to blog: Blog, analyticsInfo: MediaAnalyticsInfo? = nil) -> Media? { - let coordinator = mediaLibraryProgressCoordinator - return addMedia(from: asset, blog: blog, post: nil, coordinator: coordinator, analyticsInfo: analyticsInfo) + func addMedia(from asset: ExportableAsset, to blog: Blog, analyticsInfo: MediaAnalyticsInfo? = nil) { + addMedia(from: asset, blog: blog, post: nil, coordinator: mediaLibraryProgressCoordinator, analyticsInfo: analyticsInfo) } /// Adds the specified media asset to the specified post. The upload process @@ -147,57 +151,88 @@ class MediaCoordinator: NSObject { /// @discardableResult func addMedia(from asset: ExportableAsset, to post: AbstractPost, analyticsInfo: MediaAnalyticsInfo? = nil) -> Media? { - let coordinator = self.coordinator(for: post) - return addMedia(from: asset, blog: post.blog, post: post, coordinator: coordinator, analyticsInfo: analyticsInfo) + addMedia(from: asset, post: post, coordinator: coordinator(for: post), analyticsInfo: analyticsInfo) } - @discardableResult - private func addMedia(from asset: ExportableAsset, blog: Blog, post: AbstractPost?, coordinator: MediaProgressCoordinator, analyticsInfo: MediaAnalyticsInfo? = nil) -> Media? { + /// Create a `Media` instance from the main context and upload the asset to the Meida Library. + /// + /// - Warning: This function must be called from the main thread. + /// + /// - SeeAlso: `MediaImportService.createMedia(with:blog:post:thumbnailCallback:completion:)` + private func addMedia(from asset: ExportableAsset, post: AbstractPost, coordinator: MediaProgressCoordinator, analyticsInfo: MediaAnalyticsInfo? = nil) -> Media? { coordinator.track(numberOfItems: 1) - let service = mediaServiceFactory.create(mainContext) + let service = MediaImportService(coreDataStack: coreDataStack) let totalProgress = Progress.discreteProgress(totalUnitCount: MediaExportProgressUnits.done) - var creationProgress: Progress? = nil - let mediaOptional = service.createMedia(with: asset, - blog: blog, - post: post, - progress: &creationProgress, - thumbnailCallback: { [weak self] media, url in - self?.thumbnailReady(url: url, for: media) - }, - completion: { [weak self] media, error in - guard let strongSelf = self else { - return - } - if let error = error as NSError? { - if let media = media { - coordinator.attach(error: error as NSError, toMediaID: media.uploadID) - strongSelf.fail(error as NSError, media: media) - } else { - // If there was an error and we don't have a media object we just say to the coordinator that one item was finished - coordinator.finishOneItem() - } - return - } - guard let media = media, !media.isDeleted else { - return - } - - strongSelf.trackUploadOf(media, analyticsInfo: analyticsInfo) - - let uploadProgress = strongSelf.uploadMedia(media) - totalProgress.addChild(uploadProgress, withPendingUnitCount: MediaExportProgressUnits.threeQuartersDone) - }) - guard let media = mediaOptional else { + let result = service.createMedia( + with: asset, + blog: post.blog, + post: post, + thumbnailCallback: { [weak self] media, url in + self?.thumbnailReady(url: url, for: media) + }, + completion: { [weak self] media, error in + self?.handleMediaImportResult(coordinator: coordinator, totalProgress: totalProgress, analyticsInfo: analyticsInfo, media: media, error: error) + } + ) + guard let (media, creationProgress) = result else { return nil } + processing(media) - if let creationProgress = creationProgress { - totalProgress.addChild(creationProgress, withPendingUnitCount: MediaExportProgressUnits.quarterDone) - coordinator.track(progress: totalProgress, of: media, withIdentifier: media.uploadID) - } + + totalProgress.addChild(creationProgress, withPendingUnitCount: MediaExportProgressUnits.quarterDone) + coordinator.track(progress: totalProgress, of: media, withIdentifier: media.uploadID) + return media } + /// Create a `Media` instance and upload the asset to the Meida Library. + /// + /// - SeeAlso: `MediaImportService.createMedia(with:blog:post:receiveUpdate:thumbnailCallback:completion:)` + private func addMedia(from asset: ExportableAsset, blog: Blog, post: AbstractPost?, coordinator: MediaProgressCoordinator, analyticsInfo: MediaAnalyticsInfo? = nil) { + coordinator.track(numberOfItems: 1) + let service = MediaImportService(coreDataStack: coreDataStack) + let totalProgress = Progress.discreteProgress(totalUnitCount: MediaExportProgressUnits.done) + let creationProgress = service.createMedia( + with: asset, + blog: blog, + post: post, + receiveUpdate: { [weak self] media in + self?.processing(media) + coordinator.track(progress: totalProgress, of: media, withIdentifier: media.uploadID) + }, + thumbnailCallback: { [weak self] media, url in + self?.thumbnailReady(url: url, for: media) + }, + completion: { [weak self] media, error in + self?.handleMediaImportResult(coordinator: coordinator, totalProgress: totalProgress, analyticsInfo: analyticsInfo, media: media, error: error) + } + ) + + totalProgress.addChild(creationProgress, withPendingUnitCount: MediaExportProgressUnits.quarterDone) + } + + private func handleMediaImportResult(coordinator: MediaProgressCoordinator, totalProgress: Progress, analyticsInfo: MediaAnalyticsInfo?, media: Media?, error: Error?) -> Void { + if let error = error as NSError? { + if let media = media { + coordinator.attach(error: error as NSError, toMediaID: media.uploadID) + fail(error as NSError, media: media) + } else { + // If there was an error and we don't have a media object we just say to the coordinator that one item was finished + coordinator.finishOneItem() + } + return + } + guard let media = media, !media.isDeleted else { + return + } + + trackUploadOf(media, analyticsInfo: analyticsInfo) + + let uploadProgress = uploadMedia(media) + totalProgress.addChild(uploadProgress, withPendingUnitCount: MediaExportProgressUnits.threeQuartersDone) + } + /// Retry the upload of a media object that previously has failed. /// /// - Parameters: @@ -286,42 +321,77 @@ class MediaCoordinator: NSObject { func delete(media: [Media], onProgress: ((Progress?) -> Void)? = nil, success: (() -> Void)? = nil, failure: (() -> Void)? = nil) { media.forEach({ self.cancelUpload(of: $0) }) - let service = mediaServiceFactory.create(backgroundContext) - service.deleteMedia(media, - progress: { onProgress?($0) }, - success: success, - failure: failure) + coreDataStack.performAndSave { context in + let service = self.mediaServiceFactory.create(context) + service.deleteMedia(media, + progress: { onProgress?($0) }, + success: success, + failure: failure) + } } @discardableResult private func uploadMedia(_ media: Media, automatedRetry: Bool = false) -> Progress { - let service = mediaServiceFactory.create(backgroundContext) + let resultProgress = Progress.discreteProgress(totalUnitCount: 100) - var progress: Progress? = nil + let success: () -> Void = { + self.end(media) + } + let failure: (Error?) -> Void = { error in + // Ideally the upload service should always return an error. This may be easier to enforce + // if we update the service to Swift, but in the meanwhile I'm instantiating an unknown upload + // error whenever the service doesn't provide one. + // + let nserror = error as NSError? + ?? NSError( + domain: MediaServiceErrorDomain, + code: MediaServiceError.unknownUploadError.rawValue, + userInfo: [ + "filename": media.filename ?? "", + "filesize": media.filesize ?? "", + "height": media.height ?? "", + "width": media.width ?? "", + "localURL": media.localURL ?? "", + "remoteURL": media.remoteURL ?? "", + ]) - service.uploadMedia(media, - automatedRetry: automatedRetry, - progress: &progress, - success: { - self.end(media) - }, failure: { error in - guard let nserror = error as NSError? else { - return - } self.coordinator(for: media).attach(error: nserror, toMediaID: media.uploadID) self.fail(nserror, media: media) - }) - var resultProgress = Progress.discreteCompletedProgress() - if let taskProgress = progress { - resultProgress = taskProgress } + + // For some reason, this `MediaService` instance has to be created with the main context, otherwise + // the successfully uploaded media is shown as a "local" assets incorrectly (see the issue comment linked below). + // https://github.com/wordpress-mobile/WordPress-iOS/issues/20298#issuecomment-1465319707 + let service = self.mediaServiceFactory.create(coreDataStack.mainContext) + var progress: Progress? = nil + service.uploadMedia(media, automatedRetry: automatedRetry, progress: &progress, success: success, failure: failure) + if let progress { + resultProgress.addChild(progress, withPendingUnitCount: resultProgress.totalUnitCount) + } + uploading(media, progress: resultProgress) + return resultProgress } private func trackUploadOf(_ media: Media, analyticsInfo: MediaAnalyticsInfo?) { + guard let info = analyticsInfo else { + return + } + + guard let event = info.eventForMediaType(media.mediaType) else { + // Fall back to the WPShared event tracking + trackUploadViaWPSharedOf(media, analyticsInfo: analyticsInfo) + return + } + + let properties = info.properties(for: media) + WPAnalytics.track(event, properties: properties, blog: media.blog) + } + + private func trackUploadViaWPSharedOf(_ media: Media, analyticsInfo: MediaAnalyticsInfo?) { guard let info = analyticsInfo, - let event = info.eventForMediaType(media.mediaType) else { + let event = info.wpsharedEventForMediaType(media.mediaType) else { return } @@ -430,7 +500,10 @@ class MediaCoordinator: NSObject { func addObserver(_ onUpdate: @escaping ObserverBlock, for media: Media? = nil) -> UUID { let uuid = UUID() - let observer = MediaObserver(media: media, onUpdate: onUpdate) + let observer = MediaObserver( + subject: media.flatMap({ .media(id: $0.objectID) }) ?? .all, + onUpdate: onUpdate + ) queue.async { self.mediaObservers[uuid] = observer @@ -454,7 +527,7 @@ class MediaCoordinator: NSObject { let uuid = UUID() let original = post.original ?? post - let observer = MediaObserver(post: original, onUpdate: onUpdate) + let observer = MediaObserver(subject: .post(id: original.objectID), onUpdate: onUpdate) queue.async { self.mediaObservers[uuid] = observer @@ -503,42 +576,28 @@ class MediaCoordinator: NSObject { } /// Encapsulates an observer block and an optional observed media item or post. - struct MediaObserver { - let media: Media? - let post: AbstractPost? - let onUpdate: ObserverBlock - - init(onUpdate: @escaping ObserverBlock) { - self.media = nil - self.post = nil - self.onUpdate = onUpdate + private struct MediaObserver { + enum Subject: Equatable { + case media(id: NSManagedObjectID) + case post(id: NSManagedObjectID) + case all } - init(media: Media?, onUpdate: @escaping ObserverBlock) { - self.media = media - self.post = nil - self.onUpdate = onUpdate - } - - init(post: AbstractPost, onUpdate: @escaping ObserverBlock) { - self.media = nil - self.post = post - self.onUpdate = onUpdate - } + let subject: Subject + let onUpdate: ObserverBlock } - /// Utility method to return all observers for a specific media item, - /// including any 'wildcard' observers that are observing _all_ media items. + /// Utility method to return all observers for a `Media` item with the given `NSManagedObjectID` + /// and part of the posts with given `NSManagedObjectID`s, including any 'wildcard' observers + /// that are observing _all_ media items. /// - private func observersForMedia(_ media: Media) -> [MediaObserver] { - let mediaObservers = self.mediaObservers.values.filter({ $0.media?.uploadID == media.uploadID }) + private func observersForMedia(withObjectID mediaObjectID: NSManagedObjectID, originalPostIDs: [NSManagedObjectID]) -> [MediaObserver] { + let mediaObservers = self.mediaObservers.values.filter({ $0.subject == .media(id: mediaObjectID) }) let postObservers = self.mediaObservers.values.filter({ - guard let posts = media.posts as? Set<AbstractPost>, - let post = $0.post else { return false } + guard case let .post(postObjectID) = $0.subject else { return false } - let originals = posts.map({ $0.original ?? $0 }) - return originals.contains(post) + return originalPostIDs.contains(postObjectID) }) return mediaObservers + postObservers + wildcardObservers @@ -548,7 +607,7 @@ class MediaCoordinator: NSObject { /// observing _all_ media items. /// private var wildcardObservers: [MediaObserver] { - return mediaObservers.values.filter({ $0.media == nil && $0.post == nil }) + return mediaObservers.values.filter({ $0.subject == .all }) } // MARK: - Notifying observers @@ -589,8 +648,21 @@ class MediaCoordinator: NSObject { } func notifyObserversForMedia(_ media: Media, ofStateChange state: MediaState) { + let originalPostIDs: [NSManagedObjectID] = coreDataStack.performQuery { context in + guard let mediaInContext = try? context.existingObject(with: media.objectID) as? Media else { + return [] + } + + return mediaInContext.posts?.compactMap { (object: AnyHashable) in + guard let post = object as? AbstractPost else { + return nil + } + return (post.original ?? post).objectID + } ?? [] + } + queue.async { - self.observersForMedia(media).forEach({ observer in + self.observersForMedia(withObjectID: media.objectID, originalPostIDs: originalPostIDs).forEach({ observer in DispatchQueue.main.async { if let media = self.mainContext.object(with: media.objectID) as? Media { observer.onUpdate(media, state) @@ -605,16 +677,30 @@ class MediaCoordinator: NSObject { /// - parameter blog: The blog from where to sync the media library from. /// @objc func syncMedia(for blog: Blog, success: (() -> Void)? = nil, failure: ((Error) ->Void)? = nil) { - let service = mediaServiceFactory.create(backgroundContext) - service.syncMediaLibrary(for: blog, success: success, failure: failure) + syncOperationQueue.addOperation(AsyncBlockOperation { done in + self.coreDataStack.performAndSave { context in + let service = self.mediaServiceFactory.create(context) + service.syncMediaLibrary( + for: blog, + success: { + done() + success?() + }, + failure: { error in + done() + failure?(error) + } + ) + } + }) + } /// This method checks the status of all media objects and updates them to the correct status if needed. /// The main cause of wrong status is the app being killed while uploads of media are happening. /// @objc func refreshMediaStatus() { - let service = mediaServiceFactory.create(backgroundContext) - service.refreshMediaStatus() + Media.refreshMediaStatus(using: coreDataStack) } } @@ -662,16 +748,6 @@ extension MediaCoordinator: MediaProgressCoordinatorDelegate { } } -extension MediaCoordinator: Uploader { - func resume() { - let service = mediaServiceFactory.create(backgroundContext) - - service.failedMediaForUpload(automatedRetry: true).forEach() { - retryMedia($0, automatedRetry: true) - } - } -} - extension MediaCoordinator { // Based on user logs we've collected for users, we've noticed the app sometimes // trying to upload a Media object and failing because the underlying file has disappeared from @@ -686,8 +762,7 @@ extension MediaCoordinator { } self.cancelUploadAndDeleteMedia(media) - CrashLogging.logError(mediaError, - userInfo: ["description": "Deleting a media object that's failed to upload because of a missing local file."]) + WordPressAppDelegate.crashLogging?.logMessage("Deleting a media object that's failed to upload because of a missing local file. \(mediaError)") }, for: nil) } diff --git a/WordPress/Classes/Services/MediaImportService.swift b/WordPress/Classes/Services/MediaImportService.swift index 8419ea98386b..b5f5b9cfc731 100644 --- a/WordPress/Classes/Services/MediaImportService.swift +++ b/WordPress/Classes/Services/MediaImportService.swift @@ -6,11 +6,11 @@ import CocoaLumberjack /// - Note: Methods with escaping closures will call back via the configured managedObjectContext /// method and its corresponding thread. /// -open class MediaImportService: LocalCoreDataService { +class MediaImportService: NSObject { private static let defaultImportQueue: DispatchQueue = DispatchQueue(label: "org.wordpress.mediaImportService", autoreleaseFrequency: .workItem) - @objc public lazy var importQueue: DispatchQueue = { + @objc lazy var importQueue: DispatchQueue = { return MediaImportService.defaultImportQueue }() @@ -20,55 +20,255 @@ open class MediaImportService: LocalCoreDataService { /// @objc static let preferredImageCompressionQuality = 0.9 - /// Allows the caller to designate supported import file types - @objc var allowableFileExtensions = Set<String>() - static let defaultAllowableFileExtensions = Set<String>(["docx", "ppt", "mp4", "ppsx", "3g2", "mpg", "ogv", "pptx", "xlsx", "jpeg", "xls", "mov", "key", "3gp", "png", "avi", "doc", "pdf", "gif", "odt", "pps", "m4v", "wmv", "jpg"]) /// Completion handler for a created Media object. /// - public typealias MediaCompletion = (Media) -> Void + typealias MediaCompletion = (Media) -> Void /// Error handler. /// - public typealias OnError = (Error) -> Void + typealias OnError = (Error) -> Void + + private let coreDataStack: CoreDataStackSwift + + /// The initialiser for Objective-C code. + /// + /// Using `ContextManager` as the argument becuase `CoreDataStackSwift` is not accessible from Objective-C code. + @objc + convenience init(contextManager: ContextManager) { + self.init(coreDataStack: contextManager) + } + + init(coreDataStack: CoreDataStackSwift) { + self.coreDataStack = coreDataStack + } // MARK: - Instance methods + /// Create a media object using the `ExportableAsset` provided as the source of media. + /// + /// - Note: All blocks arguments are called from the main thread. The `Media` argument in the blocks is bound to + /// the main context. + /// + /// - Warning: This function must be called from the main thread. + /// + /// This functions returns a `Media` instance. To ensure the returned `Media` instance continues to be a valid + /// instance, it can't be bound to a background context which are all temporary context. The only long living + /// context is the main context. And the safe way to create and return an object bound to the main context is + /// doing it from the main thread, which is why this function must be called from the main thread. + /// + /// - Parameters: + /// - exportable: an object that conforms to `ExportableAsset` + /// - blog: the blog object to associate to the media + /// - post: the optional post object to associate to the media + /// - thumbnailCallback: a closure that will be invoked when the thumbnail for the media object is ready + /// - completion: a closure that will be invoked when the media is created, on success it will return a valid `Media` + /// object, on failure it will return a `nil` `Media` and an error object with the details. + /// + /// - Returns: The new `Media` instance and a `Process` instance that tracks the progress of the export process + /// + /// - SeeAlso: `createMedia(with:blog:post:thumbnailCallback:completion:)` + func createMedia( + with exportable: ExportableAsset, + blog: Blog, + post: AbstractPost?, + thumbnailCallback: ((Media, URL) -> Void)?, + completion: @escaping (Media?, Error?) -> Void + ) -> (Media, Progress)? { + assert(Thread.isMainThread, "\(#function) can only be called from the main thread") + + guard let media = try? createMedia(with: exportable, blogObjectID: blog.objectID, postObjectID: post?.objectID, in: coreDataStack.mainContext) else { + return nil + } + + coreDataStack.saveContextAndWait(coreDataStack.mainContext) + + let blogInContext: Blog + do { + blogInContext = try coreDataStack.mainContext.existingObject(with: blog.objectID) as! Blog + } catch { + completion(nil, error) + return nil + } + + let createProgress = self.import(exportable, to: media, blog: blogInContext, thumbnailCallback: thumbnailCallback) { + switch $0 { + case let .success(media): + completion(media, nil) + case let .failure(error): + completion(media, error) + } + } + + return (media, createProgress) + } + + /// Create a media object using the `ExportableAsset` provided as the source of media. + /// + /// Unlike `createMedia(with:blog:post:thumbnailCallback:completion:)`, this function can be called from any thread. + /// + /// - Note: All blocks arguments are called from the main thread. The `Media` argument in the blocks is bound to + /// the main context. + /// + /// - Parameters: + /// - exportable: an object that conforms to `ExportableAsset` + /// - blog: the blog object to associate to the media + /// - post: the optional post object to associate to the media + /// - progress: a NSProgress that tracks the progress of the export process. + /// - receiveUpdate: a closure that will be invoked with the created `Media` instance. + /// - thumbnailCallback: a closure that will be invoked when the thumbnail for the media object is ready + /// - completion: a closure that will be invoked when the media is created, on success it will return a valid Media + /// object, on failure it will return a nil Media and an error object with the details. + @objc + @discardableResult + func createMedia( + with exportable: ExportableAsset, + blog: Blog, + post: AbstractPost?, + receiveUpdate: ((Media) -> Void)?, + thumbnailCallback: ((Media, URL) -> Void)?, + completion: @escaping (Media?, Error?) -> Void + ) -> Progress { + let createProgress = Progress.discreteProgress(totalUnitCount: 1) + coreDataStack.performAndSave({ context in + let media = try self.createMedia(with: exportable, blogObjectID: blog.objectID, postObjectID: post?.objectID, in: context) + try context.obtainPermanentIDs(for: [media]) + return media.objectID + }, completion: { (result: Result<NSManagedObjectID, Error>) in + let transformed = result.flatMap { mediaObjectID in + Result { + ( + try self.coreDataStack.mainContext.existingObject(with: mediaObjectID) as! Media, + try self.coreDataStack.mainContext.existingObject(with: blog.objectID) as! Blog + ) + } + } + switch transformed { + case let .success((media, blog)): + let progress = self.import(exportable, to: media, blog: blog, thumbnailCallback: thumbnailCallback) { + switch $0 { + case let .success(media): + completion(media, nil) + case let .failure(error): + completion(media, error) + } + } + createProgress.addChild(progress, withPendingUnitCount: 1) + receiveUpdate?(media) + case let .failure(error): + completion(nil, error) + } + }, on: .main) + return createProgress + } + + private func createMedia(with exportable: ExportableAsset, blogObjectID: NSManagedObjectID, postObjectID: NSManagedObjectID?, in context: NSManagedObjectContext) throws -> Media { + let blogInContext = try context.existingObject(with: blogObjectID) as! Blog + let postInContext = try postObjectID.flatMap(context.existingObject(with:)) as? AbstractPost + + let media = postInContext.flatMap(Media.makeMedia(post:)) ?? Media.makeMedia(blog: blogInContext) + media.mediaType = exportable.assetMediaType + media.remoteStatus = .processing + return media + } + + private func `import`( + _ exportable: ExportableAsset, + to media: Media, + blog: Blog, + thumbnailCallback: ((Media, URL) -> Void)?, + completion: @escaping (Result<Media, Error>) -> Void + ) -> Progress { + assert(Thread.isMainThread) + assert(media.managedObjectContext == coreDataStack.mainContext) + assert(blog.managedObjectContext == coreDataStack.mainContext) + + var allowedFileTypes = blog.allowedFileTypes as? Set<String> ?? [] + // HEIC isn't supported when uploading an image, so we filter it out (http://git.io/JJAae) + allowedFileTypes.remove("heic") + + let completion: (Error?) -> Void = { error in + self.coreDataStack.performAndSave({ context in + let mediaInContext = try context.existingObject(with: media.objectID) as! Media + if let error { + mediaInContext.remoteStatus = .failed + mediaInContext.error = error + } else { + mediaInContext.remoteStatus = .local + mediaInContext.error = nil + } + }, completion: { result in + let transformed = result.flatMap { + Result { + try self.coreDataStack.mainContext.existingObject(with: media.objectID) as! Media + } + } + + if case let .success(media) = transformed { + // Pre-generate a thumbnail image, see the method notes. + self.exportPlaceholderThumbnail(for: media) { url in + assert(Thread.isMainThread) + guard let url, let media = try? self.coreDataStack.mainContext.existingObject(with: media.objectID) as? Media else { + return + } + thumbnailCallback?(media, url) + } + } + + completion(transformed) + }, on: .main) + } + + return self.import(exportable, to: media, allowableFileExtensions: allowedFileTypes, completion: completion) + } + /// Imports media from a PHAsset to the Media object, asynchronously. /// /// - Parameters: /// - exportable: the exportable resource where data will be read from. /// - media: the media object to where media will be imported to. - /// - onCompletion: Called if the Media was successfully created and the asset's data imported to the absoluteLocalURL. - /// - onError: Called if an error was encountered during creation, error convertible to NSError with a localized description. + /// - onCompletion: Called if the Media was successfully created and the asset's data imported to the + /// `absoluteLocalURL`. This closure is called on the main thread. The closure's `media` argument is also + /// bound to the main context (`CoreDataStack.mainContext`). + /// - onError: Called if an error was encountered during creation, error convertible to `NSError` with a + /// localized description. This closure is called on the main thread. /// /// - Returns: a progress object that report the current state of the import process. /// - @objc(importResource:toMedia:onCompletion:onError:) - func `import`(_ exportable: ExportableAsset, to media: Media, onCompletion: @escaping MediaCompletion, onError: @escaping OnError) -> Progress? { + private func `import`(_ exportable: ExportableAsset, to media: Media, allowableFileExtensions: Set<String>, completion: @escaping (Error?) -> Void) -> Progress { let progress: Progress = Progress.discreteProgress(totalUnitCount: 1) importQueue.async { - guard let exporter = self.makeExporter(for: exportable) else { + guard let exporter = self.makeExporter(for: exportable, allowableFileExtensions: allowableFileExtensions) else { preconditionFailure("An exporter needs to be availale") } - let exportProgress = exporter.export(onCompletion: { export in - self.managedObjectContext.perform { - self.configureMedia(media, withExport: export) - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { - onCompletion(media) - }) + let exportProgress = exporter.export( + onCompletion: { export in + self.coreDataStack.performAndSave({ context in + let mediaInContext = try context.existingObject(with: media.objectID) as! Media + self.configureMedia(mediaInContext, withExport: export) + }, completion: { result in + if case let .failure(error) = result { + completion(error) + } else { + completion(nil) + } + }, on: .main) + }, + onError: { error in + MediaImportService.logExportError(error) + // Return the error via the context's queue, and as an NSError to ensure it carries over the right code/message. + DispatchQueue.main.async { + completion(error) + } } - }, onError: { mediaExportError in - self.handleExportError(mediaExportError, errorHandler: onError) - } ) progress.addChild(exportProgress, withPendingUnitCount: 1) } return progress } - func makeExporter(for exportable: ExportableAsset) -> MediaExporter? { + private func makeExporter(for exportable: ExportableAsset, allowableFileExtensions: Set<String>) -> MediaExporter? { switch exportable { case let asset as PHAsset: let exporter = MediaAssetExporter(asset: asset) @@ -84,19 +284,43 @@ open class MediaImportService: LocalCoreDataService { let exporter = MediaURLExporter(url: url) exporter.imageOptions = self.exporterImageOptions exporter.videoOptions = self.exporterVideoOptions - exporter.urlOptions = self.exporterURLOptions - return exporter - case let giphyMedia as GiphyMedia: - let exporter = MediaExternalExporter(externalAsset: giphyMedia) + exporter.urlOptions = self.exporterURLOptions(allowableFileExtensions: allowableFileExtensions) return exporter case let stockPhotosMedia as StockPhotosMedia: let exporter = MediaExternalExporter(externalAsset: stockPhotosMedia) return exporter + case let tenorMedia as TenorMedia: + let exporter = MediaExternalExporter(externalAsset: tenorMedia) + return exporter default: return nil } } + /// Generate a thumbnail image for the `Media` so that consumers of the `absoluteThumbnailLocalURL` property + /// will have an image ready to load, without using the async methods provided via `MediaThumbnailService`. + /// + /// This is primarily used as a placeholder image throughout the code-base, particulary within the editors. + /// + /// Note: Ideally we wouldn't need this at all, but the synchronous usage of `absoluteThumbnailLocalURL` across the code-base + /// to load a thumbnail image is relied on quite heavily. In the future, transitioning to asynchronous thumbnail loading + /// via the new thumbnail service methods is much preferred, but would indeed take a good bit of refactoring away from + /// using `absoluteThumbnailLocalURL`. + func exportPlaceholderThumbnail(for media: Media, completion: ((URL?) -> Void)?) { + let thumbnailService = MediaThumbnailService(coreDataStack: coreDataStack) + thumbnailService.thumbnailURL(forMedia: media, preferredSize: .zero) { url in + self.coreDataStack.performAndSave({ context in + let mediaInContext = try context.existingObject(with: media.objectID) as! Media + // Set the absoluteThumbnailLocalURL with the generated thumbnail's URL. + mediaInContext.absoluteThumbnailLocalURL = url + }, completion: { _ in + completion?(url) + }, on: .main) + } onError: { error in + DDLogError("Error occurred exporting placeholder thumbnail: \(error)") + } + } + // MARK: - Helpers class func logExportError(_ error: MediaExportError) { @@ -120,21 +344,9 @@ open class MediaImportService: LocalCoreDataService { DDLogError("\(errorLogMessage), code: \(nerror.code), error: \(nerror)") } - /// Handle the OnError callback and logging any errors encountered. - /// - fileprivate func handleExportError(_ error: MediaExportError, errorHandler: OnError?) { - MediaImportService.logExportError(error) - // Return the error via the context's queue, and as an NSError to ensure it carries over the right code/message. - if let errorHandler = errorHandler { - self.managedObjectContext.perform { - errorHandler(error) - } - } - } - // MARK: - Media export configurations - fileprivate var exporterImageOptions: MediaImageExporter.Options { + private var exporterImageOptions: MediaImageExporter.Options { var options = MediaImageExporter.Options() options.maximumImageSize = self.exporterMaximumImageSize() options.stripsGeoLocationIfNeeded = MediaSettings().removeLocationSetting @@ -142,14 +354,14 @@ open class MediaImportService: LocalCoreDataService { return options } - fileprivate var exporterVideoOptions: MediaVideoExporter.Options { + private var exporterVideoOptions: MediaVideoExporter.Options { var options = MediaVideoExporter.Options() options.stripsGeoLocationIfNeeded = MediaSettings().removeLocationSetting options.exportPreset = MediaSettings().maxVideoSizeSetting.videoPreset return options } - fileprivate var exporterURLOptions: MediaURLExporter.Options { + private func exporterURLOptions(allowableFileExtensions: Set<String>) -> MediaURLExporter.Options { var options = MediaURLExporter.Options() options.allowableFileExtensions = allowableFileExtensions options.stripsGeoLocationIfNeeded = MediaSettings().removeLocationSetting @@ -161,7 +373,7 @@ open class MediaImportService: LocalCoreDataService { /// - Note: Eventually we'll rewrite MediaSettings.imageSizeForUpload to do this for us, but want to leave /// that class alone while implementing MediaExportService. /// - fileprivate func exporterMaximumImageSize() -> CGFloat? { + private func exporterMaximumImageSize() -> CGFloat? { let maxUploadSize = MediaSettings().imageSizeForUpload if maxUploadSize < Int.max { return CGFloat(maxUploadSize) @@ -171,7 +383,7 @@ open class MediaImportService: LocalCoreDataService { /// Configure Media with a MediaExport. /// - fileprivate func configureMedia(_ media: Media, withExport export: MediaExport) { + private func configureMedia(_ media: Media, withExport export: MediaExport) { media.absoluteLocalURL = export.url media.filename = export.url.lastPathComponent media.mediaType = (export.url as NSURL).assetMediaType diff --git a/WordPress/Classes/Services/MediaService+Swift.swift b/WordPress/Classes/Services/MediaService+Swift.swift index 8f601b97d1f4..d3fd0808cfde 100644 --- a/WordPress/Classes/Services/MediaService+Swift.swift +++ b/WordPress/Classes/Services/MediaService+Swift.swift @@ -9,6 +9,12 @@ extension MediaService { if media.remoteURL != remoteMedia.url?.absoluteString { media.remoteURL = remoteMedia.url?.absoluteString } + if media.remoteLargeURL != remoteMedia.largeURL?.absoluteString { + media.remoteLargeURL = remoteMedia.largeURL?.absoluteString + } + if media.remoteMediumURL != remoteMedia.mediumURL?.absoluteString { + media.remoteMediumURL = remoteMedia.mediumURL?.absoluteString + } if remoteMedia.date != nil && remoteMedia.date != media.creationDate { media.creationDate = remoteMedia.date } diff --git a/WordPress/Classes/Services/MediaService.h b/WordPress/Classes/Services/MediaService.h index 42839c067787..268523d2d408 100644 --- a/WordPress/Classes/Services/MediaService.h +++ b/WordPress/Classes/Services/MediaService.h @@ -5,6 +5,7 @@ @class Media; +@class RemoteVideoPressVideo; @class Blog; @class AbstractPost; @protocol ExportableAsset; @@ -14,33 +15,12 @@ typedef NS_ERROR_ENUM(MediaServiceErrorDomain, MediaServiceError) { MediaServiceErrorFileDoesNotExist = 0, MediaServiceErrorFileLargerThanDiskQuotaAvailable = 1, MediaServiceErrorFileLargerThanMaxFileSize = 2, - MediaServiceErrorUnableToCreateMedia = 3 + MediaServiceErrorUnableToCreateMedia = 3, + MediaServiceErrorUnknownUploadError = 4 }; @interface MediaService : LocalCoreDataService -/** - This property determines if multiple thumbnail generation will be done in parallel. - By default this value is NO. - */ -@property (nonatomic, assign) BOOL concurrentThumbnailGeneration; -/** - Create a media object using the url provided as the source of media. - - @param exportable an object that implements the exportable interface - @param blog the blog object to associate to the media - @param post the post object to associate to the media - @param progress a NSProgress that tracks the progress of the export process. - @param thumbnailCallback a block that will be invoked when the thumbail for the media object is ready - @param completion a block that will be invoked when the media is created, on success it will return a valid Media object, on failure it will return a nil Media and an error object with the details. - */ -- (nullable Media *)createMediaWith:(nonnull id<ExportableAsset>)exportable - blog:(nonnull Blog *)blog - post:(nullable AbstractPost *)post - progress:(NSProgress * __nullable __autoreleasing * __nullable)progress - thumbnailCallback:(nullable void (^)(Media * __nonnull media, NSURL * __nonnull thumbnailURL))thumbnailCallback - completion:(nullable void (^)(Media * __nullable media, NSError * __nullable error))completion; - /** Get the Media object from the server using the blog and the mediaID as the identifier of the resource @@ -70,11 +50,12 @@ typedef NS_ERROR_ENUM(MediaServiceErrorDomain, MediaServiceError) { failure:(nullable void (^)(NSError * _Nullable error))failure; /** - Updates the media object details to the server. This method doesn't allow you to update media file, - because that always stays static after the initial upload, it only allows to change details like, - caption, alternative text, etc... + Updates the media object details. All fields defined in the media object will be updated. + + NOTE: This method doesn't allow you to update media file, because that always stays static after the initial + upload, it only allows to change details like, caption, alternative text, etc... - @param media object to upload to the server. + @param media object to update. @success a block that will be invoked when the media upload finished with success @failure a block that will be invoked when there is upload error. */ @@ -83,8 +64,23 @@ typedef NS_ERROR_ENUM(MediaServiceErrorDomain, MediaServiceError) { failure:(nullable void (^)(NSError * _Nullable error))failure; /** - Updates multiple media objects similar to -updateMedia:success:failure: but batches them - together. The success handler is only called when all updates succeed. Failure is called + Updates the specified defails of media object. + + @param media object to update. + @param fieldsToUpdate Fields to be updated of media object. + @success a block that will be invoked when the media upload finished with success + @failure a block that will be invoked when there is upload error. + */ +- (void)updateMedia:(nonnull Media *)media + fieldsToUpdate:(NSArray<NSString *> *)fieldsToUpdate + success:(nullable void (^)(void))success + failure:(nullable void (^)(NSError * _Nullable error))failure; + +/** + Updates multiple media objects similar to `-updateMedia:success:failure:` but batches them + together. All fields defined in the media objects will be updated. + + The success handler is only called when all updates succeed. Failure is called if the entire process fails in some catostrophic way. @param mediaObjects An array of media objects to update @@ -94,6 +90,21 @@ typedef NS_ERROR_ENUM(MediaServiceErrorDomain, MediaServiceError) { overallSuccess:(nullable void (^)(void))overallSuccess failure:(nullable void (^)(NSError * _Nullable error))failure; +/** + Updates specified details of multiple media objects. + + The success handler is only called when all updates succeed. Failure is called + if the entire process fails in some catostrophic way. + + @param mediaObjects An array of media objects to update + @param fieldsToUpdate Fields to be updated of media objects. + @param success + */ +- (void)updateMedia:(nonnull NSArray<Media *> *)mediaObjects + fieldsToUpdate:(NSArray<NSString *> *)fieldsToUpdate + overallSuccess:(nullable void (^)(void))overallSuccess + failure:(nullable void (^)(NSError * _Nullable error))failure; + /** Deletes the Media object from the server. Note the Media is deleted, not trashed. @@ -119,17 +130,20 @@ typedef NS_ERROR_ENUM(MediaServiceErrorDomain, MediaServiceError) { failure:(nullable void (^)(void))failure; /** - * Obtains the video url and poster image url for the video with the videoPressID + * Retrieves the metadata of a VideoPress video. + * + * The metadata parameters can be found in the API reference: + * https://developer.wordpress.com/docs/api/1.1/get/videos/%24guid/ * - * @param videoPressID ID of video in VideoPress - * @param blog blog to use to access video references - * @param success return block if videopress info is found - * @param failure return block if not information found. + * @param videoPressID ID of the video in VideoPress. + * @param success a block to be executed when the metadata is fetched successfully. + * @param failure a block to be executed when the metadata can't be fetched. */ -- (void)getMediaURLFromVideoPressID:(nonnull NSString *)videoPressID +- (void)getMetadataFromVideoPressID:(nonnull NSString *)videoPressID inBlog:(nonnull Blog *)blog - success:(nullable void (^)(NSString * _Nonnull videoURL, NSString * _Nullable posterURL))success + success:(nullable void (^)(RemoteVideoPressVideo * _Nonnull metadata))success failure:(nullable void (^)(NSError * _Nonnull error))failure; + /** * Sync all Media objects from the server to local database @@ -141,40 +155,6 @@ typedef NS_ERROR_ENUM(MediaServiceErrorDomain, MediaServiceError) { success:(nullable void (^)(void))success failure:(nullable void (^)(NSError * _Nonnull error))failure; -/** - Gets a local thumbnail image file URL for the Media item, or generates one, if available. - - @discussion If the media asset is a video a frame of the video is returned. - - @param mediaInRandomContext the Media object from where to get the thumbnail. - @param preferredSize the preferred size for the image in points. If set to CGSizeZero the resulting image will not - exceed the maximum dimension of the UIScreen size. - @param completion block that will be invoked when the thumbnail is ready, if available, or an error if something went wrong. - */ -- (void)thumbnailFileURLForMedia:(nonnull Media *)mediaInRandomContext - preferredSize:(CGSize)preferredSize - completion:(nonnull void (^)(NSURL * _Nullable url, NSError * _Nullable error))completion; -/** - Gets a thumbnail image for the Media item, or generates one, if available. - - @discussion If the media asset is a video a frame of the video is returned. - - @param mediaInRandomContext the Media object from where to get the thumbnail. - @param preferredSize the preferred size for the image in points. If set to CGSizeZero the resulting image will not - exceed the maximum dimension of the UIScreen size. - @param completion block that will be invoked when the thumbnail is ready, if available, or an error if something went wrong. - */ -- (void)thumbnailImageForMedia:(nonnull Media *)mediaInRandomContext - preferredSize:(CGSize)preferredSize - completion:(nonnull void (^)(UIImage * _Nullable image, NSError * _Nullable error))completion; -/** - * Get the number of items in a blog media library that are of a certain type. - * - * @param blog from what blog to count the media items. - * @param mediaTypes set of media type values to be considered in the counting. - */ -- (NSInteger)getMediaLibraryCountForBlog:(nonnull Blog *)blog - forMediaTypes:(nonnull NSSet *)mediaTypes; - (void)getMediaLibraryServerCountForBlog:(nonnull Blog *)blog forMediaTypes:(nonnull NSSet *)mediaTypes diff --git a/WordPress/Classes/Services/MediaService.m b/WordPress/Classes/Services/MediaService.m index 24de26ca1f65..a0f17223c128 100644 --- a/WordPress/Classes/Services/MediaService.m +++ b/WordPress/Classes/Services/MediaService.m @@ -2,7 +2,7 @@ #import "AccountService.h" #import "Media.h" #import "WPAccount.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "Blog.h" #import <MobileCoreServices/MobileCoreServices.h> #import "WordPress-Swift.h" @@ -16,137 +16,8 @@ NSErrorDomain const MediaServiceErrorDomain = @"MediaServiceErrorDomain"; -@interface MediaService () - -@property (nonatomic, strong) MediaThumbnailService *thumbnailService; - -@end - @implementation MediaService -- (instancetype)initWithManagedObjectContext:(NSManagedObjectContext *)context -{ - self = [super initWithManagedObjectContext:context]; - if (self) { - _concurrentThumbnailGeneration = NO; - } - return self; -} - -#pragma mark - Creating media - -- (Media *)createMediaWith:(id<ExportableAsset>)exportable - blog:(Blog *)blog - post:(AbstractPost *)post - progress:(NSProgress **)progress - thumbnailCallback:(void (^)(Media *media, NSURL *thumbnailURL))thumbnailCallback - completion:(void (^)(Media *media, NSError *error))completion -{ - NSParameterAssert(post == nil || blog == post.blog); - NSParameterAssert(blog.managedObjectContext == self.managedObjectContext); - NSProgress *createProgress = [NSProgress discreteProgressWithTotalUnitCount:1]; - __block Media *media; - __block NSSet<NSString *> *allowedFileTypes = [NSSet set]; - [self.managedObjectContext performBlockAndWait:^{ - if ( blog == nil ) { - if (completion) { - NSError *error = [NSError errorWithDomain: MediaServiceErrorDomain code: MediaServiceErrorUnableToCreateMedia userInfo: nil]; - completion(nil, error); - } - return; - } - - if (blog.allowedFileTypes != nil) { - allowedFileTypes = blog.allowedFileTypes; - } - - if (post != nil) { - media = [Media makeMediaWithPost:post]; - } else { - media = [Media makeMediaWithBlog:blog]; - } - media.mediaType = exportable.assetMediaType; - media.remoteStatus = MediaRemoteStatusProcessing; - - [self.managedObjectContext obtainPermanentIDsForObjects:@[media] error:nil]; - [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext]; - }]; - if (media == nil) { - return nil; - } - NSManagedObjectID *mediaObjectID = media.objectID; - [self.managedObjectContext performBlock:^{ - // Setup completion handlers - void(^completionWithMedia)(Media *) = ^(Media *media) { - media.remoteStatus = MediaRemoteStatusLocal; - media.error = nil; - // Pre-generate a thumbnail image, see the method notes. - [self exportPlaceholderThumbnailForMedia:media - completion:^(NSURL *url){ - if (thumbnailCallback) { - thumbnailCallback(media, url); - } - }]; - if (completion) { - completion(media, nil); - } - }; - void(^completionWithError)( NSError *) = ^(NSError *error) { - Media *mediaInContext = (Media *)[self.managedObjectContext existingObjectWithID:mediaObjectID error:nil]; - if (mediaInContext) { - mediaInContext.error = error; - mediaInContext.remoteStatus = MediaRemoteStatusFailed; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - } - if (completion) { - completion(media, error); - } - }; - - // Export based on the type of the exportable. - MediaImportService *importService = [[MediaImportService alloc] initWithManagedObjectContext:self.managedObjectContext]; - importService.allowableFileExtensions = allowedFileTypes; - NSProgress *importProgress = [importService importResource:exportable toMedia:media onCompletion:completionWithMedia onError:completionWithError]; - [createProgress addChild:importProgress withPendingUnitCount:1]; - }]; - if (progress != nil) { - *progress = createProgress; - } - return media; -} - -/** - Generate a thumbnail image for the Media asset so that consumers of the absoluteThumbnailLocalURL property - will have an image ready to load, without using the async methods provided via MediaThumbnailService. - - This is primarily used as a placeholder image throughout the code-base, particulary within the editors. - - Note: Ideally we wouldn't need this at all, but the synchronous usage of absoluteThumbnailLocalURL across the code-base - to load a thumbnail image is relied on quite heavily. In the future, transitioning to asynchronous thumbnail loading - via the new thumbnail service methods is much preferred, but would indeed take a good bit of refactoring away from - using absoluteThumbnailLocalURL. -*/ -- (void)exportPlaceholderThumbnailForMedia:(Media *)media completion:(void (^)(NSURL *thumbnailURL))thumbnailCallback -{ - [self.thumbnailService thumbnailURLForMedia:media - preferredSize:CGSizeZero - onCompletion:^(NSURL *url) { - [self.managedObjectContext performBlock:^{ - if (url) { - // Set the absoluteThumbnailLocalURL with the generated thumbnail's URL. - media.absoluteThumbnailLocalURL = url; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - } - if (thumbnailCallback) { - thumbnailCallback(url); - } - }]; - } - onError:^(NSError *error) { - DDLogError(@"Error occurred exporting placeholder thumbnail: %@", error); - }]; -} - #pragma mark - Uploading media - (BOOL)isValidFileInMedia:(Media *)media error:(NSError **)error { @@ -228,7 +99,7 @@ - (void)uploadMedia:(Media *)media if (failure) { failure(customError); } - }]; + } onQueue:dispatch_get_main_queue()]; return; } if (failure) { @@ -277,7 +148,7 @@ - (void)uploadMedia:(Media *)media if (success) { success(); } - }]; + } onQueue:dispatch_get_main_queue()]; }]; }; @@ -305,11 +176,18 @@ - (void)trackUploadError:(NSError *)error #pragma mark - Updating media - (void)updateMedia:(Media *)media + fieldsToUpdate:(NSArray<NSString *> *)fieldsToUpdate success:(void (^)(void))success failure:(void (^)(NSError *error))failure { + RemoteMedia *remoteMedia; + if (fieldsToUpdate != nil && [fieldsToUpdate count] > 0) { + remoteMedia = [self remoteMediaFromMedia:media fieldsToUpdate:fieldsToUpdate]; + } else { + remoteMedia = [self remoteMediaFromMedia:media]; + } + id<MediaServiceRemote> remote = [self remoteForBlog:media.blog]; - RemoteMedia *remoteMedia = [self remoteMediaFromMedia:media]; NSManagedObjectID *mediaObjectID = media.objectID; void (^successBlock)(RemoteMedia *media) = ^(RemoteMedia *media) { [self.managedObjectContext performBlock:^{ @@ -328,7 +206,7 @@ - (void)updateMedia:(Media *)media if (success) { success(); } - }]; + } onQueue:dispatch_get_main_queue()]; }]; }; void (^failureBlock)(NSError *error) = ^(NSError *error) { @@ -348,8 +226,16 @@ - (void)updateMedia:(Media *)media failure:failureBlock]; } +- (void)updateMedia:(Media *)media + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + [self updateMedia:media fieldsToUpdate:nil success:success failure:failure]; +} + - (void)updateMedia:(NSArray<Media *> *)mediaObjects - overallSuccess:(void (^)(void))overallSuccess + fieldsToUpdate:(NSArray<NSString *> *)fieldsToUpdate + overallSuccess:(void (^)(void))overallSuccess failure:(void (^)(NSError *error))failure { if (mediaObjects.count == 0) { @@ -383,14 +269,21 @@ - (void)updateMedia:(NSArray<Media *> *)mediaObjects for (Media *media in mediaObjects) { // This effectively ignores any errors presented - [self updateMedia:media success:^{ + [self updateMedia:media fieldsToUpdate:fieldsToUpdate success:^{ individualOperationCompletion(true); - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { individualOperationCompletion(false); }]; } } +- (void)updateMedia:(NSArray<Media *> *)mediaObjects + overallSuccess:(void (^)(void))overallSuccess + failure:(void (^)(NSError *error))failure +{ + [self updateMedia:mediaObjects fieldsToUpdate:nil overallSuccess:overallSuccess failure:failure]; +} + #pragma mark - Private helpers - (NSError *)customMediaUploadError:(NSError *)error remote:(id <MediaServiceRemote>)remote { @@ -422,7 +315,7 @@ - (NSError *)customMediaUploadError:(NSError *)error remote:(id <MediaServiceRem case NSURLErrorNetworkConnectionLost: case NSURLErrorNotConnectedToInternet: // Clear lack of device internet connection, notify the user - customErrorMessage = NSLocalizedString(@"The internet connection appears to be offline.", @"Error message shown when a media upload fails because the user isn't connected to the internet."); + customErrorMessage = NSLocalizedString(@"The Internet connection appears to be offline.", @"Error message shown when a media upload fails because the user isn't connected to the Internet."); break; default: // Default NSURL error messaging, probably server-side, encourage user to try again @@ -431,11 +324,13 @@ - (NSError *)customMediaUploadError:(NSError *)error remote:(id <MediaServiceRem } } } + if (customErrorMessage) { NSMutableDictionary *userInfo = [error.userInfo mutableCopy]; userInfo[NSLocalizedDescriptionKey] = customErrorMessage; - error = [[NSError alloc] initWithDomain:error.domain code:error.code userInfo:userInfo]; + error = [NSError errorWithDomain:error.domain code:error.code userInfo:userInfo]; } + return error; } @@ -450,13 +345,18 @@ - (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:^{ - if (success) { - success(); - } - }]; + withCompletionBlock:success + onQueue:dispatch_get_main_queue()]; }]; }; @@ -489,7 +389,7 @@ - (void)deleteMedia:(nonnull NSArray<Media *> *)mediaObjects dispatch_group_t group = dispatch_group_create(); - [mediaObjects enumerateObjectsUsingBlock:^(Media *media, NSUInteger idx, BOOL *stop) { + [mediaObjects enumerateObjectsUsingBlock:^(Media *media, NSUInteger __unused idx, BOOL * __unused stop) { dispatch_group_enter(group); [self deleteMedia:media success:^{ currentProgress.completedUnitCount++; @@ -497,7 +397,7 @@ - (void)deleteMedia:(nonnull NSArray<Media *> *)mediaObjects progress(currentProgress); } dispatch_group_leave(group); - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { dispatch_group_leave(group); }]; }]; @@ -536,10 +436,17 @@ - (void) getMediaWithID:(NSNumber *) mediaID inBlog:(Blog *) blog media = [Media makeMediaWithBlog:blog]; } [self updateMedia:media withRemoteMedia:remoteMedia]; + + [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext]; + if (success){ success(media); + + if ([media hasChanges]) { + NSCAssert(NO, @"The success callback should not modify the Media instance"); + [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; + } } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; }]; } failure:^(NSError *error) { if (failure) { @@ -551,15 +458,15 @@ - (void) getMediaWithID:(NSNumber *) mediaID inBlog:(Blog *) blog }]; } -- (void)getMediaURLFromVideoPressID:(NSString *)videoPressID +- (void)getMetadataFromVideoPressID:(NSString *)videoPressID inBlog:(Blog *)blog - success:(void (^)(NSString *videoURL, NSString *posterURL))success + success:(void (^)(RemoteVideoPressVideo *metadata))success failure:(void (^)(NSError *error))failure { id<MediaServiceRemote> remote = [self remoteForBlog:blog]; - [remote getVideoURLFromVideoPressID:videoPressID success:^(NSURL *videoURL, NSURL *posterURL) { + [remote getMetadataFromVideoPressID:videoPressID isSitePrivate:blog.isPrivate success:^(RemoteVideoPressVideo *metadata) { if (success) { - success(videoURL.absoluteString, posterURL.absoluteString); + success(metadata); } } failure:^(NSError * error) { if (failure) { @@ -571,15 +478,43 @@ - (void)getMediaURLFromVideoPressID:(NSString *)videoPressID - (void)syncMediaLibraryForBlog:(Blog *)blog success:(void (^)(void))success failure:(void (^)(NSError *error))failure -{ +{ + __block BOOL onePageLoad = NO; NSManagedObjectID *blogObjectID = [blog objectID]; + + /// Temporary logging to try and narrow down an issue: + /// + /// REF: https://github.com/wordpress-mobile/WordPress-iOS/issues/15335 + /// + if (blog == nil || blog.objectID == nil) { + DDLogError(@"🔴 Error: missing object ID (please contact @diegoreymendez with this log)"); + DDLogError(@"%@", [NSThread callStackSymbols]); + } + [self.managedObjectContext performBlock:^{ - Blog *blogInContext = (Blog *)[self.managedObjectContext objectWithID:blogObjectID]; + NSError *error = nil; + Blog *blogInContext = (Blog *)[self.managedObjectContext existingObjectWithID:blogObjectID error:&error]; + + if (!blogInContext) { + failure(error); + return; + } + NSSet *originalLocalMedia = blogInContext.media; id<MediaServiceRemote> remote = [self remoteForBlog:blogInContext]; - [remote getMediaLibraryWithSuccess:^(NSArray *media) { + [remote getMediaLibraryWithPageLoad:^(NSArray *media) { + [self.managedObjectContext performBlock:^{ + void (^completion)(void) = nil; + if (!onePageLoad) { + onePageLoad = YES; + completion = success; + } + [self mergeMedia:media forBlog:blogInContext baseMedia:originalLocalMedia deleteUnreferencedMedia:NO completionHandler:completion]; + }]; + } + success:^(NSArray *media) { [self.managedObjectContext performBlock:^{ - [self mergeMedia:media forBlog:blogInContext baseMedia:originalLocalMedia completionHandler:success]; + [self mergeMedia:media forBlog:blogInContext baseMedia:originalLocalMedia deleteUnreferencedMedia:YES completionHandler:success]; }]; } failure:^(NSError *error) { @@ -592,21 +527,6 @@ - (void)syncMediaLibraryForBlog:(Blog *)blog }]; } -- (NSInteger)getMediaLibraryCountForBlog:(Blog *)blog - forMediaTypes:(NSSet *)mediaTypes -{ - __block NSInteger assetsCount; - [self.managedObjectContext performBlockAndWait:^{ - NSString *entityName = NSStringFromClass([Media class]); - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:entityName]; - request.predicate = [self predicateForMediaTypes:mediaTypes blog:blog]; - NSError *error; - NSArray *mediaAssets = [self.managedObjectContext executeFetchRequest:request error:&error]; - assetsCount = mediaAssets.count; - }]; - return assetsCount; -} - - (void)getMediaLibraryServerCountForBlog:(Blog *)blog forMediaTypes:(NSSet *)mediaTypes success:(void (^)(NSInteger count))success @@ -644,66 +564,6 @@ - (void)getMediaLibraryServerCountForBlog:(Blog *)blog }]; } -#pragma mark - Thumbnails - -- (void)thumbnailFileURLForMedia:(Media *)mediaInRandomContext - preferredSize:(CGSize)preferredSize - completion:(void (^)(NSURL * _Nullable, NSError * _Nullable))completion -{ - NSManagedObjectID *mediaID = [mediaInRandomContext objectID]; - [self.managedObjectContext performBlock:^{ - NSError *error; - Media *media = (Media *)[self.managedObjectContext existingObjectWithID:mediaID error:&error]; - if (media == nil) { - completion(nil, error); - return; - } - [self.thumbnailService thumbnailURLForMedia:media - preferredSize:preferredSize - onCompletion:^(NSURL *url) { - completion(url, nil); - } - onError:^(NSError *error) { - completion(nil, error); - }]; - }]; -} - -- (void)thumbnailImageForMedia:(nonnull Media *)mediaInRandomContext - preferredSize:(CGSize)preferredSize - completion:(void (^)(UIImage * _Nullable image, NSError * _Nullable error))completion -{ - NSManagedObjectID *mediaID = [mediaInRandomContext objectID]; - [self.managedObjectContext performBlock:^{ - NSError *error; - Media *media = (Media *)[self.managedObjectContext existingObjectWithID:mediaID error:&error]; - if (media == nil) { - completion(nil, error); - return; - } - [self.thumbnailService thumbnailURLForMedia:media - preferredSize:preferredSize - onCompletion:^(NSURL *url) { - UIImage *image = [UIImage imageWithContentsOfFile:url.path]; - completion(image, nil); - } - onError:^(NSError *error) { - completion(nil, error); - }]; - }]; -} - -- (MediaThumbnailService *)thumbnailService -{ - if (!_thumbnailService) { - _thumbnailService = [[MediaThumbnailService alloc] initWithManagedObjectContext:self.managedObjectContext]; - if (self.concurrentThumbnailGeneration) { - _thumbnailService.exportQueue = dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0); - } - } - return _thumbnailService; -} - #pragma mark - Private helpers - (NSString *)mimeTypeForMediaType:(NSNumber *)mediaType @@ -713,26 +573,6 @@ - (NSString *)mimeTypeForMediaType:(NSNumber *)mediaType return mimeType; } -- (NSPredicate *)predicateForMediaTypes:(NSSet *)mediaTypes blog:(Blog *)blog -{ - NSMutableArray * filters = [NSMutableArray array]; - [mediaTypes enumerateObjectsUsingBlock:^(NSNumber *obj, BOOL *stop){ - MediaType filter = (MediaType)[obj intValue]; - NSString *filterString = [Media stringFromMediaType:filter]; - [filters addObject:[NSString stringWithFormat:@"mediaTypeString == \"%@\"", filterString]]; - }]; - - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"blog == %@", blog]; - if (filters.count > 0) { - NSString *mediaFilters = [filters componentsJoinedByString:@" || "]; - NSPredicate *mediaPredicate = [NSPredicate predicateWithFormat:mediaFilters]; - predicate = [NSCompoundPredicate andPredicateWithSubpredicates: - @[predicate, mediaPredicate]]; - } - - return predicate; -} - #pragma mark - Media helpers - (id<MediaServiceRemote>)remoteForBlog:(Blog *)blog @@ -752,6 +592,7 @@ - (NSPredicate *)predicateForMediaTypes:(NSSet *)mediaTypes blog:(Blog *)blog - (void)mergeMedia:(NSArray *)media forBlog:(Blog *)blog baseMedia:(NSSet *)originalBlogMedia +deleteUnreferencedMedia:(BOOL)deleteUnreferencedMedia completionHandler:(void (^)(void))completion { NSParameterAssert(blog); @@ -767,12 +608,14 @@ - (void)mergeMedia:(NSArray *)media [mediaToKeep addObject:local]; } } - NSMutableSet *mediaToDelete = [NSMutableSet setWithSet:originalBlogMedia]; - [mediaToDelete minusSet:mediaToKeep]; - for (Media *deleteMedia in mediaToDelete) { - // only delete media that is server based - if ([deleteMedia.mediaID intValue] > 0) { - [self.managedObjectContext deleteObject:deleteMedia]; + if (deleteUnreferencedMedia) { + NSMutableSet *mediaToDelete = [NSMutableSet setWithSet:originalBlogMedia]; + [mediaToDelete minusSet:mediaToKeep]; + for (Media *deleteMedia in mediaToDelete) { + // only delete media that is server based + if ([deleteMedia.mediaID intValue] > 0) { + [self.managedObjectContext deleteObject:deleteMedia]; + } } } [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; @@ -786,6 +629,8 @@ - (RemoteMedia *)remoteMediaFromMedia:(Media *)media RemoteMedia *remoteMedia = [[RemoteMedia alloc] init]; remoteMedia.mediaID = media.mediaID; remoteMedia.url = [NSURL URLWithString:media.remoteURL]; + remoteMedia.largeURL = [NSURL URLWithString:media.remoteLargeURL]; + remoteMedia.mediumURL = [NSURL URLWithString:media.remoteMediumURL]; remoteMedia.date = media.creationDate; remoteMedia.file = media.filename; remoteMedia.extension = [media fileExtension] ?: @"unknown"; @@ -803,4 +648,43 @@ - (RemoteMedia *)remoteMediaFromMedia:(Media *)media return remoteMedia; } +- (RemoteMedia *)remoteMediaFromMedia:(Media *)media fieldsToUpdate:(NSArray<NSString *> *)fieldsToUpdate +{ + RemoteMedia *remoteMedia = [[RemoteMedia alloc] init]; + remoteMedia.mediaID = media.mediaID; + + NSMutableDictionary *updateDict = [NSMutableDictionary dictionary]; + for (NSString *field in fieldsToUpdate) { + id value = [media valueForKey:field]; + if (value) { + if ([field isEqualToString: @"fileExtension"]) { + updateDict[field] = [media fileExtension] ?: @"unknown"; + } else if ([field isEqualToString: @"mimeType"]) { + updateDict[field] = [media mimeType]; + } else { + updateDict[field] = value; + } + } + } + + remoteMedia.url = [NSURL URLWithString:updateDict[@"remoteURL"]]; + remoteMedia.largeURL = [NSURL URLWithString:updateDict[@"remoteLargeURL"]]; + remoteMedia.mediumURL = [NSURL URLWithString:updateDict[@"remoteMediumURL"]]; + remoteMedia.date = updateDict[@"creationDate"]; + remoteMedia.file = updateDict[@"filename"]; + remoteMedia.extension = updateDict[@"fileExtension"]; + remoteMedia.title = updateDict[@"title"]; + remoteMedia.caption = updateDict[@"caption"]; + remoteMedia.descriptionText = updateDict[@"desc"]; + remoteMedia.alt = updateDict[@"alt"]; + remoteMedia.height = updateDict[@"height"]; + remoteMedia.width = updateDict[@"width"]; + remoteMedia.localURL = updateDict[@"absoluteLocalURL"]; + remoteMedia.mimeType = updateDict[@"mimeType"]; + remoteMedia.videopressGUID = updateDict[@"videopressGUID"]; + remoteMedia.remoteThumbnailURL = updateDict[@"remoteThumbnailURL"]; + remoteMedia.postID = updateDict[@"postID"]; + return remoteMedia; +} + @end diff --git a/WordPress/Classes/Services/MediaService.swift b/WordPress/Classes/Services/MediaService.swift index 583ec25b46b5..e20d9ec1cf8b 100644 --- a/WordPress/Classes/Services/MediaService.swift +++ b/WordPress/Classes/Services/MediaService.swift @@ -1,96 +1,5 @@ import Foundation -extension MediaService { - - // MARK: - Failed Media for Uploading - - /// Returns a list of Media objects that should be uploaded for the given input parameters. - /// - /// - Parameters: - /// - automatedRetry: whether the media to upload is the result of an automated retry. - /// - /// - Returns: the Media objects that should be uploaded for the given input parameters. - /// - func failedMediaForUpload(automatedRetry: Bool) -> [Media] { - let request = NSFetchRequest<Media>(entityName: Media.entityName()) - let failedMediaPredicate = NSPredicate(format: "\(#keyPath(Media.remoteStatusNumber)) == %d", MediaRemoteStatus.failed.rawValue) - - if automatedRetry { - let autoUploadFailureCountPredicate = NSPredicate(format: "\(#keyPath(Media.autoUploadFailureCount)) < %d", Media.maxAutoUploadFailureCount) - - request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [failedMediaPredicate, autoUploadFailureCountPredicate]) - } else { - request.predicate = failedMediaPredicate - } - - let media = (try? managedObjectContext.fetch(request)) ?? [] - - return media - } - - /// Returns a list of Media objects from a post, that should be autoUploaded on the next attempt. - /// - /// - Parameters: - /// - post: the post to look auto-uploadable media for. - /// - automatedRetry: whether the media to upload is the result of an automated retry. - /// - /// - Returns: the Media objects that should be autoUploaded. - /// - func failedMediaForUpload(in post: AbstractPost, automatedRetry: Bool) -> [Media] { - return post.media.filter({ media in - return media.remoteStatus == .failed - && (!automatedRetry || media.autoUploadFailureCount.intValue < Media.maxAutoUploadFailureCount) - }) - } - - // MARK: - Misc - - /// This method checks the status of all media objects and updates them to the correct status if needed. - /// The main cause of wrong status is the app being killed while uploads of media are happening. - /// - /// - Parameters: - /// - onCompletion: block to invoke when status update is finished. - /// - onError: block to invoke if any error occurs while the update is being made. - /// - func refreshMediaStatus(onCompletion: (() -> Void)? = nil, onError: ((Error) -> Void)? = nil) { - self.managedObjectContext.perform { - let fetch = NSFetchRequest<Media>(entityName: Media.classNameWithoutNamespaces()) - let pushingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: MediaRemoteStatus.pushing.rawValue)) - let processingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: MediaRemoteStatus.processing.rawValue)) - let errorPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: MediaRemoteStatus.failed.rawValue)) - fetch.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [pushingPredicate, processingPredicate, errorPredicate]) - do { - let mediaPushing = try self.managedObjectContext.fetch(fetch) - for media in mediaPushing { - // If file were in the middle of being pushed or being processed they now are failed. - if media.remoteStatus == .pushing || media.remoteStatus == .processing { - media.remoteStatus = .failed - } - // If they failed to upload themselfs because no local copy exists then we need to delete this media object - // This scenario can happen when media objects were created based on an asset that failed to import to the WordPress App. - // For example a PHAsset that is stored on the iCloud storage and because of the network connection failed the import process. - if media.remoteStatus == .failed, - let error = media.error as NSError?, error.domain == MediaServiceErrorDomain && error.code == MediaServiceError.fileDoesNotExist.rawValue { - self.managedObjectContext.delete(media) - } - } - - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { - DispatchQueue.main.async { - onCompletion?() - } - }) - - } catch { - DDLogError("Error while attempting to clean local media: \(error.localizedDescription)") - DispatchQueue.main.async { - onError?(error) - } - } - } - } -} - // MARK: - Factory extension MediaService { diff --git a/WordPress/Classes/Services/MediaSettings.swift b/WordPress/Classes/Services/MediaSettings.swift index 250d447e2bf8..d20aec639a94 100644 --- a/WordPress/Classes/Services/MediaSettings.swift +++ b/WordPress/Classes/Services/MediaSettings.swift @@ -29,7 +29,7 @@ class MediaSettings: NSObject { case .size3840x2160: return AVAssetExportPreset3840x2160 case .sizeOriginal: - return AVAssetExportPresetPassthrough + return AVAssetExportPresetHighestQuality } } @@ -91,7 +91,7 @@ class MediaSettings: NSObject { } convenience override init() { - self.init(database: UserDefaults() as KeyValueDatabase) + self.init(database: UserPersistentStoreFactory.instance() as KeyValueDatabase) } // MARK: Public accessors diff --git a/WordPress/Classes/Services/MediaThumbnailCoordinator.swift b/WordPress/Classes/Services/MediaThumbnailCoordinator.swift index c9f3716b4e40..4eade2adf11b 100644 --- a/WordPress/Classes/Services/MediaThumbnailCoordinator.swift +++ b/WordPress/Classes/Services/MediaThumbnailCoordinator.swift @@ -9,28 +9,15 @@ class MediaThumbnailCoordinator: NSObject { @objc static let shared = MediaThumbnailCoordinator() - private(set) var backgroundContext: NSManagedObjectContext = { - let context = ContextManager.sharedInstance().newDerivedContext() - context.automaticallyMergesChangesFromParent = true - return context - }() + private var coreDataStack: CoreDataStackSwift { + ContextManager.shared + } private let queue = DispatchQueue(label: "org.wordpress.media_thumbnail_coordinator", qos: .default) typealias ThumbnailBlock = (UIImage?, Error?) -> Void typealias LoadStubMediaCompletionBlock = (Media?, Error?) -> Void - private lazy var mediaThumbnailService: MediaThumbnailService = { - let mediaThumbnailService = MediaThumbnailService(managedObjectContext: backgroundContext) - mediaThumbnailService.exportQueue = queue - return mediaThumbnailService - }() - - private lazy var mediaService: MediaService = { - let mediaService = MediaService(managedObjectContext: backgroundContext) - return mediaService - }() - /// Tries to generate a thumbnail for the specified media object with the size requested /// /// - Parameters: @@ -42,7 +29,8 @@ class MediaThumbnailCoordinator: NSObject { fetchThumbnailForMediaStub(for: media, with: size, onCompletion: onCompletion) return } - mediaThumbnailService.thumbnailURL(forMedia: media, preferredSize: size, onCompletion: { (url) in + + let success: (URL?) -> Void = { (url) in guard let imageURL = url else { DispatchQueue.main.async { onCompletion(nil, MediaThumbnailExporter.ThumbnailExportError.failedToGenerateThumbnailFileURL) @@ -53,11 +41,16 @@ class MediaThumbnailCoordinator: NSObject { DispatchQueue.main.async { onCompletion(image, nil) } - }, onError: { (error) in + } + let failure: (Error?) -> Void = { (error) in DispatchQueue.main.async { onCompletion(nil, error) } - }) + } + + let mediaThumbnailService = MediaThumbnailService(coreDataStack: coreDataStack) + mediaThumbnailService.exportQueue = self.queue + mediaThumbnailService.thumbnailURL(forMedia: media, preferredSize: size, onCompletion: success, onError: failure) } /// Tries to generate a thumbnail for the specified media object that is stub with the size requested @@ -85,7 +78,10 @@ class MediaThumbnailCoordinator: NSObject { return } - mediaService.getMediaWithID(mediaID, in: media.blog, success: { (loadedMedia) in + // It's only safe to use the main context as this MediaService instance's context because a Media object is + // leaked out of MediaService's lifecycle (MediaService.managedObjectContext's to be exact). + let mediaService = MediaService(managedObjectContext: coreDataStack.mainContext) + mediaService.getMediaWithID(mediaID, in: media.blog, success: { loadedMedia in onCompletion(loadedMedia, nil) }, failure: { (error) in onCompletion(nil, error) diff --git a/WordPress/Classes/Services/MediaThumbnailService.swift b/WordPress/Classes/Services/MediaThumbnailService.swift index 1c3624278e2f..5e185a20bf60 100644 --- a/WordPress/Classes/Services/MediaThumbnailService.swift +++ b/WordPress/Classes/Services/MediaThumbnailService.swift @@ -3,7 +3,7 @@ import Foundation /// A service for handling the process of retrieving and generating thumbnail images /// for existing Media objects, whether remote or locally available. /// -class MediaThumbnailService: LocalCoreDataService { +class MediaThumbnailService: NSObject { /// Completion handler for a thumbnail URL. /// @@ -19,24 +19,43 @@ class MediaThumbnailService: LocalCoreDataService { return MediaThumbnailService.defaultExportQueue }() + private let coreDataStack: CoreDataStackSwift + + /// The initialiser for Objective-C code. + /// + /// Using `ContextManager` as the argument becuase `CoreDataStackSwift` is not accessible from Objective-C code. + @objc + convenience init(contextManager: ContextManager) { + self.init(coreDataStack: contextManager) + } + + init(coreDataStack: CoreDataStackSwift) { + self.coreDataStack = coreDataStack + } + /// Generate a URL to a thumbnail of the Media, if available. /// /// - Parameters: /// - media: The Media object the URL should be a thumbnail of. /// - preferredSize: An ideal size of the thumbnail in points. If `zero`, the maximum dimension of the UIScreen is used. - /// - onCompletion: Completion handler passing the URL once available, or nil if unavailable. - /// - onError: Error handler. + /// - onCompletion: Completion handler passing the URL once available, or nil if unavailable. This closure is called on the `exportQueue`. + /// - onError: Error handler. This closure is called on the `exportQueue`. /// /// - Note: Images may be downloaded and resized if required, avoid requesting multiple explicit preferredSizes /// as several images could be downloaded, resized, and cached, if there are several variations in size. /// @objc func thumbnailURL(forMedia media: Media, preferredSize: CGSize, onCompletion: @escaping OnThumbnailURL, onError: OnError?) { - managedObjectContext.perform { + // We can use the main context here because we only read the `Media` instance, without changing it, and all + // the time consuming work is done in background queues. + let context = coreDataStack.mainContext + context.perform { var objectInContext: NSManagedObject? do { - objectInContext = try self.managedObjectContext.existingObject(with: media.objectID) + objectInContext = try context.existingObject(with: media.objectID) } catch { - onError?(error) + self.exportQueue.async { + onError?(error) + } return } guard let mediaInContext = objectInContext as? Media else { @@ -56,7 +75,9 @@ class MediaThumbnailService: LocalCoreDataService { // Check if there is already an exported thumbnail available. if let identifier = mediaInContext.localThumbnailIdentifier, let availableThumbnail = exporter.availableThumbnail(with: identifier) { - onCompletion(availableThumbnail) + self.exportQueue.async { + onCompletion(availableThumbnail) + } return } @@ -69,12 +90,10 @@ class MediaThumbnailService: LocalCoreDataService { // Configure a handler for any thumbnail exports let onThumbnailExport: MediaThumbnailExporter.OnThumbnailExport = { (identifier, export) in - self.managedObjectContext.perform { - self.handleThumbnailExport(media: mediaInContext, - identifier: identifier, - export: export, - onCompletion: onCompletion) - } + self.handleThumbnailExport(media: mediaInContext, + identifier: identifier, + export: export, + onCompletion: onCompletion) } // Configure an error handler let onThumbnailExportError: OnExportError = { (error) in @@ -83,30 +102,25 @@ class MediaThumbnailService: LocalCoreDataService { // Configure an attempt to download a remote thumbnail and export it as a thumbnail. let attemptDownloadingThumbnail: () -> Void = { - self.downloadThumbnail(forMedia: mediaInContext, preferredSize: preferredSize, onCompletion: { (image) in + self.downloadThumbnail(forMedia: mediaInContext, preferredSize: preferredSize, callbackQueue: self.exportQueue, onCompletion: { (image) in guard let image = image else { onError?(MediaThumbnailExporter.ThumbnailExportError.failedToGenerateThumbnailFileURL) return } - self.exportQueue.async { - exporter.exportThumbnail(forImage: image, - onCompletion: onThumbnailExport, - onError: onThumbnailExportError) - } + exporter.exportThumbnail(forImage: image, onCompletion: onThumbnailExport, onError: onThumbnailExportError) }, onError: { (error) in onError?(error) }) } // If the Media asset is available locally, export thumbnails from the local asset. - if let localAssetURL = mediaInContext.absoluteLocalURL { - if exporter.supportsThumbnailExport(forFile: localAssetURL) { + if let localAssetURL = mediaInContext.absoluteLocalURL, + exporter.supportsThumbnailExport(forFile: localAssetURL) { self.exportQueue.async { exporter.exportThumbnail(forFile: localAssetURL, onCompletion: onThumbnailExport, onError: onThumbnailExportError) } - } return } @@ -118,7 +132,7 @@ class MediaThumbnailService: LocalCoreDataService { onError: { (error) in // If an error occurred with the remote video URL, try and download the Media's // remote thumbnail instead. - self.managedObjectContext.perform { + context.perform { attemptDownloadingThumbnail() } }) @@ -136,103 +150,74 @@ class MediaThumbnailService: LocalCoreDataService { /// - Parameters: /// - media: The Media object. /// - preferredSize: The preferred size of the image, in points, to configure remote URLs for. + /// - callbackQueue: The queue to execute the `onCompletion` or the `onError` callback. /// - onCompletion: Completes if everything was successful, but nil if no image is available. /// - onError: An error was encountered either from the server or locally, depending on the Media object or blog. /// /// - Note: based on previous implementation in MediaService.m. /// - fileprivate func downloadThumbnail(forMedia media: Media, - preferredSize: CGSize, - onCompletion: @escaping (UIImage?) -> Void, - onError: @escaping (Error) -> Void) { + private func downloadThumbnail( + forMedia media: Media, + preferredSize: CGSize, + callbackQueue: DispatchQueue, + onCompletion: @escaping (UIImage?) -> Void, + onError: @escaping (Error) -> Void + ) { var remoteURL: URL? // Check if the Media item is a video or image. if media.mediaType == .video { // If a video, ensure there is a remoteThumbnailURL - guard let remoteThumbnailURL = media.remoteThumbnailURL else { - // No video thumbnail available. - onCompletion(nil) - return + if let remoteThumbnailURL = media.remoteThumbnailURL { + remoteURL = URL(string: remoteThumbnailURL) } - remoteURL = URL(string: remoteThumbnailURL) } else { // Check if a remote URL for the media itself is available. - guard let remoteAssetURLStr = media.remoteURL, let remoteAssetURL = URL(string: remoteAssetURLStr) else { - // No remote asset URL available. - onCompletion(nil) - return - } - // Get an expected WP URL, for sizing. - if media.blog.isPrivate() || (!media.blog.isHostedAtWPcom && media.blog.isBasicAuthCredentialStored()) { - remoteURL = WPImageURLHelper.imageURLWithSize(preferredSize, forImageURL: remoteAssetURL) - } else { - remoteURL = PhotonImageURLHelper.photonURL(with: preferredSize, forImageURL: remoteAssetURL) + if let remoteAssetURLStr = media.remoteURL, let remoteAssetURL = URL(string: remoteAssetURLStr) { + // Get an expected WP URL, for sizing. + if media.blog.isPrivateAtWPCom() || (!media.blog.isHostedAtWPcom && media.blog.isBasicAuthCredentialStored()) { + remoteURL = WPImageURLHelper.imageURLWithSize(preferredSize, forImageURL: remoteAssetURL) + } else { + remoteURL = PhotonImageURLHelper.photonURL(with: preferredSize, forImageURL: remoteAssetURL) + } } } guard let imageURL = remoteURL else { // No URL's available, no images available. - onCompletion(nil) - return - } - let inContextImageHandler: (UIImage?) -> Void = { (image) in - self.managedObjectContext.perform { - onCompletion(image) - } - } - let inContextErrorHandler: (Error?) -> Void = { (error) in - self.managedObjectContext.perform { - guard let error = error else { - onCompletion(nil) - return - } - onError(error) - } - } - if media.blog.isPrivate() { - let accountService = AccountService(managedObjectContext: self.managedObjectContext) - guard let authToken = accountService.defaultWordPressComAccount()?.authToken else { - // Don't have an auth token for some reason, return nothing. + callbackQueue.async { onCompletion(nil) - return - } - DispatchQueue.main.async { - WPImageSource.shared().downloadImage(for: imageURL, - authToken: authToken, - withSuccess: inContextImageHandler, - failure: inContextErrorHandler) - } - } else { - DispatchQueue.main.async { - WPImageSource.shared().downloadImage(for: imageURL, - withSuccess: inContextImageHandler, - failure: inContextErrorHandler) } + return } + + let download = AuthenticatedImageDownload(url: imageURL, blogObjectID: media.blog.objectID, callbackQueue: callbackQueue, onSuccess: onCompletion, onFailure: onError) + + download.start() } // MARK: - Helpers - fileprivate func handleThumbnailExport(media: Media, identifier: MediaThumbnailExporter.ThumbnailIdentifier, export: MediaExport, onCompletion: @escaping OnThumbnailURL) { - // Make sure the Media object hasn't been deleted. - guard media.isDeleted == false else { - onCompletion(nil) - return - } - if media.localThumbnailIdentifier != identifier { - media.localThumbnailIdentifier = identifier - ContextManager.sharedInstance().save(managedObjectContext) - } - onCompletion(export.url) + private func handleThumbnailExport(media: Media, identifier: MediaThumbnailExporter.ThumbnailIdentifier, export: MediaExport, onCompletion: @escaping OnThumbnailURL) { + coreDataStack.performAndSave({ context in + let object = try context.existingObject(with: media.objectID) + // It's safe to force-unwrap here, since the `object`, if exists, must be a `Media` type. + let mediaInContext = object as! Media + mediaInContext.localThumbnailIdentifier = identifier + }, completion: { (result: Result<Void, Error>) in + switch result { + case .success: + onCompletion(export.url) + case .failure: + onCompletion(nil) + } + }, on: exportQueue) } /// Handle the OnError callback and logging any errors encountered. /// - fileprivate func handleExportError(_ error: MediaExportError, errorHandler: OnError?) { + private func handleExportError(_ error: MediaExportError, errorHandler: OnError?) { MediaImportService.logExportError(error) - if let errorHandler = errorHandler { - self.managedObjectContext.perform { - errorHandler(error.toNSError()) - } + exportQueue.async { + errorHandler?(error.toNSError()) } } } diff --git a/WordPress/Classes/Services/MenusService.m b/WordPress/Classes/Services/MenusService.m index 95f9e7dd8b93..ce37c91fd105 100644 --- a/WordPress/Classes/Services/MenusService.m +++ b/WordPress/Classes/Services/MenusService.m @@ -4,7 +4,7 @@ #import "Menu.h" #import "MenuItem.h" #import "MenuLocation.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "PostService.h" #import "WordPress-Swift.h" @import WordPressKit; @@ -74,11 +74,8 @@ - (void)syncMenusForBlog:(Blog *)blog blog.menus = [NSOrderedSet orderedSetWithArray:menus]; [[ContextManager sharedInstance] saveContext:self.managedObjectContext - withCompletionBlock:^{ - if (success) { - success(); - } - }]; + withCompletionBlock:success + onQueue:dispatch_get_main_queue()]; }]; } failure:failure]; @@ -197,12 +194,11 @@ - (void)generateDefaultMenuItemsForBlog:(Blog *)blog [items addObject:pageItem]; } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext - withCompletionBlock:^{ - if (success) { - success(items); - } - }]; + [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ + if (success) { + success(items); + } + } onQueue:dispatch_get_main_queue()]; }]; } failure:failure]; @@ -266,11 +262,8 @@ - (void)updateMenu:(Menu *)menu [self addMenuItemFromRemoteMenuItem:remoteItem forMenu:menu]; } [[ContextManager sharedInstance] saveContext:self.managedObjectContext - withCompletionBlock:^{ - if (success) { - success(); - } - }]; + withCompletionBlock:success + onQueue:dispatch_get_main_queue()]; }]; } failure:failure]; diff --git a/WordPress/Classes/Services/NewsService.swift b/WordPress/Classes/Services/NewsService.swift deleted file mode 100644 index 0acaf647cf87..000000000000 --- a/WordPress/Classes/Services/NewsService.swift +++ /dev/null @@ -1,9 +0,0 @@ -/// Abstracts a source of News (i.e. the source of content for the New Card) -protocol NewsService { - func load(then completion: @escaping (Result<NewsItem, Error>) -> Void) -} - -enum NewsError: Error { - case fileNotFound - case invalidContent -} diff --git a/WordPress/Classes/Services/NotificationActionsService.swift b/WordPress/Classes/Services/NotificationActionsService.swift index 8f265f2187f4..1b5fa2448ebb 100644 --- a/WordPress/Classes/Services/NotificationActionsService.swift +++ b/WordPress/Classes/Services/NotificationActionsService.swift @@ -3,7 +3,7 @@ import CocoaLumberjack /// This service encapsulates all of the Actions that can be performed with a NotificationBlock /// -class NotificationActionsService: LocalCoreDataService { +class NotificationActionsService: CoreDataService { /// Follows a Site referenced by a given NotificationBlock. /// @@ -268,7 +268,7 @@ private extension NotificationActionsService { let notificationID = block.parent.notificationIdentifier DDLogInfo("Invalidating Cache and Force Sync'ing Notification with ID: \(notificationID)") - mediator.invalidateCacheForNotification(with: notificationID) + mediator.invalidateCacheForNotification(notificationID) mediator.syncNote(with: notificationID) } } @@ -280,10 +280,10 @@ private extension NotificationActionsService { private extension NotificationActionsService { var commentService: CommentService { - return CommentService(managedObjectContext: managedObjectContext) + return CommentService(coreDataStack: coreDataStack) } var siteService: ReaderSiteService { - return ReaderSiteService(managedObjectContext: managedObjectContext) + return ReaderSiteService(coreDataStack: coreDataStack) } } diff --git a/WordPress/Classes/Services/NotificationFilteringService.swift b/WordPress/Classes/Services/NotificationFilteringService.swift new file mode 100644 index 000000000000..aa2a7496cb52 --- /dev/null +++ b/WordPress/Classes/Services/NotificationFilteringService.swift @@ -0,0 +1,87 @@ +import Foundation + +/// The service is created to support disabling WordPress notifications when Jetpack app is installed +/// The service uses App Groups which allows Jetpack app to change the state of notifications flag and be later accessed by WordPress app +/// This is a temporary solution to avoid duplicate notifications during the migration process from WordPress to Jetpack app +/// This service and its usage can be deleted once the migration is done +final class NotificationFilteringService { + private var notificationSettingsLoader: NotificationSettingsLoader + private var notificationsEnabled: Bool = false + private let allowDisablingWPNotifications: Bool + private let isWordPress: Bool + private let userDefaults = UserDefaults(suiteName: WPAppGroupName) + + var wordPressNotificationsEnabled: Bool { + get { + guard let userDefaults = userDefaults, + userDefaults.value(forKey: AppConfiguration.Extension.NotificationsService.enabledKey) != nil else { + /// Treat this flag as enabled if it wasn't explicitly disabled + return true + } + + return userDefaults.bool(forKey: AppConfiguration.Extension.NotificationsService.enabledKey) + } + + set { + userDefaults?.set(newValue, forKey: AppConfiguration.Extension.NotificationsService.enabledKey) + + if isWordPress && !newValue { + cancelAllPendingWordPressLocalNotifications() + } + } + } + + init(notificationSettingsLoader: NotificationSettingsLoader = UNUserNotificationCenter.current(), + allowDisablingWPNotifications: Bool = FeatureFlag.allowDisablingWPNotifications.enabled, + isWordPress: Bool = AppConfiguration.isWordPress) { + self.notificationSettingsLoader = notificationSettingsLoader + self.allowDisablingWPNotifications = allowDisablingWPNotifications + self.isWordPress = isWordPress + + notificationSettingsLoader.getNotificationAuthorizationStatus { [weak self] status in + self?.notificationsEnabled = status == .authorized + } + } + + func shouldShowNotificationControl() -> Bool { + return allowDisablingWPNotifications && isWordPress && notificationsEnabled + } + + func disableWordPressNotificationsIfNeeded() { + if allowDisablingWPNotifications, !isWordPress { + wordPressNotificationsEnabled = false + } + } + + func shouldFilterWordPressNotifications() -> Bool { + let shouldFilter = allowDisablingWPNotifications + && isWordPress + && !wordPressNotificationsEnabled + + if shouldFilter { + cancelAllPendingWordPressLocalNotifications() + } + + return shouldFilter + } + + private func cancelAllPendingWordPressLocalNotifications(notificationCenter: UNUserNotificationCenter = UNUserNotificationCenter.current()) { + if isWordPress { + notificationCenter.removeAllPendingNotificationRequests() + } + } +} + +// MARK: - Helpers + +protocol NotificationSettingsLoader: AnyObject { + func getNotificationAuthorizationStatus(completionHandler: @escaping (UNAuthorizationStatus) -> Void) +} + +extension UNUserNotificationCenter: NotificationSettingsLoader { + func getNotificationAuthorizationStatus(completionHandler: @escaping (UNAuthorizationStatus) -> Void) { + getNotificationSettings { settings in + completionHandler(settings.authorizationStatus) + } + } +} diff --git a/WordPress/Classes/Services/NotificationSettingsService.swift b/WordPress/Classes/Services/NotificationSettingsService.swift index fc23ca46b317..d0db1ceec0d7 100644 --- a/WordPress/Classes/Services/NotificationSettingsService.swift +++ b/WordPress/Classes/Services/NotificationSettingsService.swift @@ -4,7 +4,7 @@ import WordPressKit /// This service encapsulates the Restful API related to WordPress Notifications. /// -open class NotificationSettingsService: LocalCoreDataService { +class NotificationSettingsService { // MARK: - Aliases public typealias Channel = NotificationSettings.Channel public typealias Stream = NotificationSettings.Stream @@ -14,14 +14,17 @@ open class NotificationSettingsService: LocalCoreDataService { /// /// - Parameter managedObjectContext: A Reference to the MOC that should be used to interact with the Core Data Stack. /// - public override init(managedObjectContext context: NSManagedObjectContext) { - super.init(managedObjectContext: context) + public convenience init(coreDataStack: CoreDataStack) { + var remoteApi: WordPressComRestApi? = nil - if let defaultAccount = AccountService(managedObjectContext: context).defaultWordPressComAccount(), + if let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: coreDataStack.mainContext), defaultAccount.authToken != nil, - let restApi = defaultAccount.wordPressComRestApi { - remoteApi = restApi.hasCredentials() ? restApi : nil + let restApi = defaultAccount.wordPressComRestApi, + restApi.hasCredentials() { + remoteApi = restApi } + + self.init(coreDataStack: coreDataStack, wordPressComRestApi: remoteApi) } /// Convenience Initializer. Useful for Unit Testing @@ -30,8 +33,8 @@ open class NotificationSettingsService: LocalCoreDataService { /// - managedObjectContext: A Reference to the MOC that should be used to interact with the Core Data Stack. /// - wordPressComRestApi: The WordPressComRestApi that should be used. /// - @objc public convenience init(managedObjectContext context: NSManagedObjectContext, wordPressComRestApi: WordPressComRestApi) { - self.init(managedObjectContext: context) + public init(coreDataStack: CoreDataStack, wordPressComRestApi: WordPressComRestApi?) { + self.coreDataStack = coreDataStack self.remoteApi = wordPressComRestApi } @@ -44,14 +47,55 @@ open class NotificationSettingsService: LocalCoreDataService { /// open func getAllSettings(_ success: (([NotificationSettings]) -> Void)?, failure: ((NSError?) -> Void)?) { notificationsServiceRemote?.getAllSettings(deviceId, - success: { - (remote: [RemoteNotificationSettings]) in + success: { remote in let parsed = self.settingsFromRemote(remote) + + for settings in parsed { + guard let blog = settings.blog, + let pushNotificationStream = settings.streams.first(where: { $0.kind == .Device }), + let preferences = pushNotificationStream.preferences else { + + continue + } + + let localSettings = self.loadLocalSettings(for: blog) + + let updatedPreferences = preferences.merging(localSettings) { first, second in + second + } + + pushNotificationStream.preferences = updatedPreferences + } + success?(parsed) }, failure: failure) } + private func userDefaultsKey(withNotificationSettingKey key: String, for blog: Blog) -> String { + "\(key)-\(blog.objectID.uriRepresentation().absoluteString)" + } + + private func loadLocalSettings(for blog: Blog) -> [String: Bool] { + var localSettings = [String: Bool]() + + for key in NotificationSettings.locallyStoredKeys { + let userDefaultsKey = userDefaultsKey(withNotificationSettingKey: key, for: blog) + let value = (UserPersistentStoreFactory.instance().object(forKey: userDefaultsKey) as? Bool) ?? true + + localSettings[key] = value + } + + return localSettings + } + + private func saveLocalSettings(_ settings: [String: Bool], blog: Blog) { + for (key, value) in settings { + if NotificationSettings.isLocallyStored(key) { + UserPersistentStoreFactory.instance().set(value, forKey: userDefaultsKey(withNotificationSettingKey: key, for: blog)) + } + } + } /// Updates the specified NotificationSettings's Stream, with a collection of new values. /// @@ -63,6 +107,7 @@ open class NotificationSettingsService: LocalCoreDataService { /// - failure: Closure to be called on failure, with the associated error. /// open func updateSettings(_ settings: NotificationSettings, stream: Stream, newValues: [String: Bool], success: (() -> ())?, failure: ((NSError?) -> Void)?) { + let remote = remoteFromSettings(newValues, channel: settings.channel, stream: stream) let pristine = stream.preferences @@ -71,6 +116,11 @@ open class NotificationSettingsService: LocalCoreDataService { stream.preferences?[key] = value } + if let preferences = stream.preferences, + let blog = settings.blog { + saveLocalSettings(preferences, blog: blog) + } + notificationsServiceRemote?.updateSettings(remote as [String: AnyObject], success: { success?() @@ -97,7 +147,7 @@ open class NotificationSettingsService: LocalCoreDataService { } notificationsServiceRemote?.registerDeviceForPushNotifications(token, - pushNotificationAppId: WPPushNotificationAppId, + pushNotificationAppId: AppConstants.pushNotificationAppId, success: success, failure: failure) } @@ -130,7 +180,8 @@ open class NotificationSettingsService: LocalCoreDataService { /// fileprivate func settingsFromRemote(_ remoteSettings: [RemoteNotificationSettings]) -> [NotificationSettings] { var parsed = [NotificationSettings]() - let blogMap = blogService.blogsForAllAccountsById() as? [Int: Blog] + let blogs = ((try? BlogQuery().blogs(in: coreDataStack.mainContext)) ?? []).filter { $0.dotComID != nil } + let blogMap = Dictionary(blogs.map { ($0.dotComID!.intValue, $0) }, uniquingKeysWith: { _, new in new }) for remoteSetting in remoteSettings { let channel = channelFromRemote(remoteSetting.channel) @@ -257,7 +308,7 @@ open class NotificationSettingsService: LocalCoreDataService { // MARK: - Private Properties fileprivate var remoteApi: WordPressComRestApi? - + private let coreDataStack: CoreDataStack // MARK: - Private Computed Properties fileprivate var notificationsServiceRemote: NotificationSettingsServiceRemote? { @@ -268,10 +319,6 @@ open class NotificationSettingsService: LocalCoreDataService { return NotificationSettingsServiceRemote(wordPressComRestApi: remoteApi) } - fileprivate var blogService: BlogService { - return BlogService(managedObjectContext: managedObjectContext) - } - fileprivate var deviceId: String { return PushNotificationsManager.shared.deviceId ?? String() } diff --git a/WordPress/Classes/Services/NotificationSupportService.swift b/WordPress/Classes/Services/NotificationSupportService.swift index eedfb723c3fc..ef5e8e5b3630 100644 --- a/WordPress/Classes/Services/NotificationSupportService.swift +++ b/WordPress/Classes/Services/NotificationSupportService.swift @@ -9,11 +9,13 @@ open class NotificationSupportService: NSObject { @objc class func insertContentExtensionToken(_ oauthToken: String) { do { - try SFHFKeychainUtils.storeUsername(WPNotificationContentExtensionKeychainTokenKey, - andPassword: oauthToken, - forServiceName: WPNotificationContentExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup, - updateExisting: true) + try SFHFKeychainUtils.storeUsername( + WPNotificationContentExtensionKeychainTokenKey, + andPassword: oauthToken, + forServiceName: WPNotificationContentExtensionKeychainServiceName, + accessGroup: WPAppKeychainAccessGroup, + updateExisting: true + ) } catch { DDLogDebug("Error while saving Notification Content Extension OAuth token: \(error)") } @@ -26,11 +28,13 @@ open class NotificationSupportService: NSObject { @objc class func insertContentExtensionUsername(_ username: String) { do { - try SFHFKeychainUtils.storeUsername(WPNotificationContentExtensionKeychainUsernameKey, - andPassword: username, - forServiceName: WPNotificationContentExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup, - updateExisting: true) + try SFHFKeychainUtils.storeUsername( + WPNotificationContentExtensionKeychainUsernameKey, + andPassword: username, + forServiceName: WPNotificationContentExtensionKeychainServiceName, + accessGroup: WPAppKeychainAccessGroup, + updateExisting: true + ) } catch { DDLogDebug("Error while saving Notification Content Extension username: \(error)") } @@ -43,11 +47,13 @@ open class NotificationSupportService: NSObject { @objc class func insertServiceExtensionToken(_ oauthToken: String) { do { - try SFHFKeychainUtils.storeUsername(WPNotificationServiceExtensionKeychainTokenKey, - andPassword: oauthToken, - forServiceName: WPNotificationServiceExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup, - updateExisting: true) + try SFHFKeychainUtils.storeUsername( + AppConfiguration.Extension.NotificationsService.keychainTokenKey, + andPassword: oauthToken, + forServiceName: AppConfiguration.Extension.NotificationsService.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup, + updateExisting: true + ) } catch { DDLogDebug("Error while saving Notification Service Extension OAuth token: \(error)") } @@ -60,24 +66,47 @@ open class NotificationSupportService: NSObject { @objc class func insertServiceExtensionUsername(_ username: String) { do { - try SFHFKeychainUtils.storeUsername(WPNotificationServiceExtensionKeychainUsernameKey, - andPassword: username, - forServiceName: WPNotificationServiceExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup, - updateExisting: true) + try SFHFKeychainUtils.storeUsername( + AppConfiguration.Extension.NotificationsService.keychainUsernameKey, + andPassword: username, + forServiceName: AppConfiguration.Extension.NotificationsService.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup, + updateExisting: true + ) } catch { DDLogDebug("Error while saving Notification Service Extension username: \(error)") } } + /// Sets the UserID that should be used by the Notification Service Extension to access WPCOM. + /// + /// - Parameter userID: WordPress.com userID + /// + @objc + class func insertServiceExtensionUserID(_ userID: String) { + do { + try SFHFKeychainUtils.storeUsername( + AppConfiguration.Extension.NotificationsService.keychainUserIDKey, + andPassword: userID, + forServiceName: AppConfiguration.Extension.NotificationsService.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup, + updateExisting: true + ) + } catch { + DDLogDebug("Error while saving Notification Service Extension userID: \(error)") + } + } + /// Attempts to delete the current WPCOM OAuth Token used by the Notification Content Extension. /// @objc class func deleteContentExtensionToken() { do { - try SFHFKeychainUtils.deleteItem(forUsername: WPNotificationContentExtensionKeychainTokenKey, - andServiceName: WPNotificationContentExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup) + try SFHFKeychainUtils.deleteItem( + forUsername: WPNotificationContentExtensionKeychainTokenKey, + andServiceName: WPNotificationContentExtensionKeychainServiceName, + accessGroup: WPAppKeychainAccessGroup + ) } catch { DDLogDebug("Error while removing Notification Content Extension OAuth token: \(error)") } @@ -88,9 +117,11 @@ open class NotificationSupportService: NSObject { @objc class func deleteContentExtensionUsername() { do { - try SFHFKeychainUtils.deleteItem(forUsername: WPNotificationContentExtensionKeychainUsernameKey, - andServiceName: WPNotificationContentExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup) + try SFHFKeychainUtils.deleteItem( + forUsername: WPNotificationContentExtensionKeychainUsernameKey, + andServiceName: WPNotificationContentExtensionKeychainServiceName, + accessGroup: WPAppKeychainAccessGroup + ) } catch { DDLogDebug("Error while removing Notification Content Extension username: \(error)") } @@ -101,9 +132,11 @@ open class NotificationSupportService: NSObject { @objc class func deleteServiceExtensionToken() { do { - try SFHFKeychainUtils.deleteItem(forUsername: WPNotificationServiceExtensionKeychainTokenKey, - andServiceName: WPNotificationServiceExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup) + try SFHFKeychainUtils.deleteItem( + forUsername: AppConfiguration.Extension.NotificationsService.keychainTokenKey, + andServiceName: AppConfiguration.Extension.NotificationsService.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup + ) } catch { DDLogDebug("Error while removing Notification Service Extension OAuth token: \(error)") } @@ -114,11 +147,28 @@ open class NotificationSupportService: NSObject { @objc class func deleteServiceExtensionUsername() { do { - try SFHFKeychainUtils.deleteItem(forUsername: WPNotificationServiceExtensionKeychainUsernameKey, - andServiceName: WPNotificationServiceExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup) + try SFHFKeychainUtils.deleteItem( + forUsername: AppConfiguration.Extension.NotificationsService.keychainUsernameKey, + andServiceName: AppConfiguration.Extension.NotificationsService.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup + ) } catch { DDLogDebug("Error while removing Notification Service Extension username: \(error)") } } + + /// Attempts to delete the current WPCOM Username used by the Notification Service Extension. + /// + @objc + class func deleteServiceExtensionUserID() { + do { + try SFHFKeychainUtils.deleteItem( + forUsername: AppConfiguration.Extension.NotificationsService.keychainUserIDKey, + andServiceName: AppConfiguration.Extension.NotificationsService.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup + ) + } catch { + DDLogDebug("Error while removing Notification Service Extension userID: \(error)") + } + } } diff --git a/WordPress/Classes/Services/NotificationSyncMediator.swift b/WordPress/Classes/Services/NotificationSyncMediator.swift index 2515106a55b1..76039605f329 100644 --- a/WordPress/Classes/Services/NotificationSyncMediator.swift +++ b/WordPress/Classes/Services/NotificationSyncMediator.swift @@ -22,10 +22,10 @@ let NotificationSyncMediatorDidUpdateNotifications = "NotificationSyncMediatorDi // MARK: - NotificationSyncMediator // -class NotificationSyncMediator { +final class NotificationSyncMediator { /// Returns the Main Managed Context /// - fileprivate let contextManager: ContextManager + private let contextManager: CoreDataStackSwift /// Sync Service Remote /// @@ -41,23 +41,24 @@ class NotificationSyncMediator { return contextManager.mainContext } - /// Thread Safety Helper! + /// Shared serial operation queue among all instances. /// - fileprivate static let lock = NSLock() - - /// Shared PrivateContext among all of the Sync Service Instances - /// - fileprivate static var privateContext: NSManagedObjectContext! - + /// This queue is used to ensure notification operations (like syncing operations) invoked from various places of + /// the app are performed sequentially, to prevent potential data corruption. + private static let operationQueue = { + let queue = OperationQueue() + queue.name = "org.wordpress.NotificationSyncMediator" + queue.maxConcurrentOperationCount = 1 + return queue + }() /// Designed Initializer /// convenience init?() { let manager = ContextManager.sharedInstance() - let service = AccountService(managedObjectContext: manager.mainContext) - guard let dotcomAPI = service.defaultWordPressComAccount()?.wordPressComRestApi else { + guard let dotcomAPI = try? WPAccount.lookupDefaultWordPressComAccount(in: manager.mainContext)?.wordPressComRestApi else { return nil } @@ -70,7 +71,7 @@ class NotificationSyncMediator { /// - manager: ContextManager Instance /// - wordPressComRestApi: The WordPressComRestApi that should be used. /// - init?(manager: ContextManager, dotcomAPI: WordPressComRestApi) { + init?(manager: CoreDataStackSwift, dotcomAPI: WordPressComRestApi) { guard dotcomAPI.hasCredentials() else { return nil } @@ -159,7 +160,19 @@ class NotificationSyncMediator { /// - completion: Callback to be executed on completion. /// func markAsRead(_ notification: Notification, completion: ((Error?)-> Void)? = nil) { - mark(notification, asRead: true, completion: completion) + mark([notification], asRead: true, completion: completion) + } + + /// Marks an array of notifications as Read. + /// + /// - Note: This method should only be used on the main thread. + /// + /// - Parameters: + /// - notifications: Notifications that were marked as read. + /// - completion: Callback to be executed on completion. + /// + func markAsRead(_ notifications: [Notification], completion: ((Error?)-> Void)? = nil) { + mark(notifications, asRead: true, completion: completion) } /// Marks a Notification as Unead. @@ -171,17 +184,19 @@ class NotificationSyncMediator { /// - completion: Callback to be executed on completion. /// func markAsUnread(_ notification: Notification, completion: ((Error?)-> Void)? = nil) { - mark(notification, asRead: false, completion: completion) + mark([notification], asRead: false, completion: completion) } - private func mark(_ notification: Notification, asRead read: Bool = true, completion: ((Error?)-> Void)? = nil) { + private func mark(_ notifications: [Notification], asRead read: Bool = true, completion: ((Error?)-> Void)? = nil) { assert(Thread.isMainThread) - let noteID = notification.notificationId - remote.updateReadStatus(noteID, read: read) { error in + let noteIDs = notifications.map { + $0.notificationId + } + + remote.updateReadStatusForNotifications(noteIDs, read: read) { error in if let error = error { - let readState = read ? "read" : "unread" - DDLogError("Error marking note as \(readState): \(error)") + DDLogError("Error marking notifications as \(Self.readState(for: read)): \(error)") // Ideally, we'd want to revert to the previous status if this // fails, but if the note is visible, the UI layer will keep // trying to mark this note and fail. @@ -191,13 +206,24 @@ class NotificationSyncMediator { // next successful sync. // // https://github.com/wordpress-mobile/WordPress-iOS/issues/7216 - NotificationSyncMediator()?.invalidateCacheForNotification(with: noteID) + NotificationSyncMediator()?.invalidateCacheForNotifications(noteIDs) } completion?(error) } - updateReadStatus(read, forNoteWithObjectID: notification.objectID) + let objectIDs = notifications.map { + $0.objectID + } + + updateReadStatus( + read, + forNotesWithObjectIDs: objectIDs + ) + } + + private static func readState(for read: Bool) -> String { + read ? "read" : "unread" } /// Invalidates the cache for a notification, marks it as read and syncs it. @@ -207,7 +233,7 @@ class NotificationSyncMediator { /// - completion: Callback to be executed on completion. /// func markAsReadAndSync(_ noteID: String, completion: ((Error?) -> Void)? = nil) { - invalidateCacheForNotification(with: noteID) + invalidateCacheForNotification(noteID) remote.updateReadStatus(noteID, read: true) { error in if let error = error { DDLogError("Error marking note as read: \(error)") @@ -241,34 +267,34 @@ class NotificationSyncMediator { /// Deletes the note with the given ID from Core Data. /// func deleteNote(noteID: String) { - let derivedContext = type(of: self).sharedDerivedContext(with: contextManager) + Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] done in + contextManager.performAndSave({ context in + let predicate = NSPredicate(format: "(notificationId == %@)", noteID) - derivedContext.perform { - let predicate = NSPredicate(format: "(notificationId == %@)", noteID) - - for orphan in derivedContext.allObjects(ofType: Notification.self, matching: predicate) { - derivedContext.deleteObject(orphan) - } - - self.contextManager.save(derivedContext) - } + for orphan in context.allObjects(ofType: Notification.self, matching: predicate) { + context.deleteObject(orphan) + } + }, completion: done, on: .main) + }) } /// Invalidates the local cache for the notification with the specified ID. /// - func invalidateCacheForNotification(with noteID: String) { - let derivedContext = type(of: self).sharedDerivedContext(with: contextManager) - let predicate = NSPredicate(format: "(notificationId == %@)", noteID) - - derivedContext.perform { - guard let notification = derivedContext.firstObject(ofType: Notification.self, matching: predicate) else { - return - } + func invalidateCacheForNotification(_ noteID: String) { + invalidateCacheForNotifications([noteID]) + } - notification.notificationHash = nil + /// Invalidates the local cache for all the notifications with specified ID's in the array. + /// + func invalidateCacheForNotifications(_ noteIDs: [String]) { + Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] done in + contextManager.performAndSave({ context in + let predicate = NSPredicate(format: "(notificationId IN %@)", noteIDs) + let notifications = context.allObjects(ofType: Notification.self, matching: predicate) - self.contextManager.save(derivedContext) - } + notifications.forEach { $0.notificationHash = nil } + }, completion: done, on: .main) + }) } } @@ -284,30 +310,27 @@ private extension NotificationSyncMediator { /// - completion: Callback to be executed on completion /// func determineUpdatedNotes(with remoteHashes: [RemoteNotification], completion: @escaping (([String]) -> Void)) { - let derivedContext = type(of: self).sharedDerivedContext(with: contextManager) - - derivedContext.perform { - let remoteIds = remoteHashes.map { $0.notificationId } - let predicate = NSPredicate(format: "(notificationId IN %@)", remoteIds) - var localHashes = [String: String]() - - for note in derivedContext.allObjects(ofType: Notification.self, matching: predicate) { - localHashes[note.notificationId] = note.notificationHash ?? "" - } - - let filtered = remoteHashes.filter { remote in - let localHash = localHashes[remote.notificationId] - return localHash == nil || localHash != remote.notificationHash - } - - derivedContext.reset() - - let outdatedIds = filtered.map { $0.notificationId } + Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] done in + contextManager.performAndSave({ context in + let remoteIds = remoteHashes.map { $0.notificationId } + let predicate = NSPredicate(format: "(notificationId IN %@)", remoteIds) + var localHashes = [String: String]() + + for note in context.allObjects(ofType: Notification.self, matching: predicate) { + localHashes[note.notificationId] = note.notificationHash ?? "" + } - DispatchQueue.main.async { + return remoteHashes + .filter { remote in + let localHash = localHashes[remote.notificationId] + return localHash == nil || localHash != remote.notificationHash + } + .map { $0.notificationId } + }, completion: { outdatedIds in completion(outdatedIds) - } - } + done() + }, on: .main) + }) } @@ -319,22 +342,21 @@ private extension NotificationSyncMediator { /// - completion: Callback to be executed on completion /// func updateLocalNotes(with remoteNotes: [RemoteNotification], completion: (() -> Void)? = nil) { - let derivedContext = type(of: self).sharedDerivedContext(with: contextManager) - - derivedContext.perform { - for remoteNote in remoteNotes { - let predicate = NSPredicate(format: "(notificationId == %@)", remoteNote.notificationId) - let localNote = derivedContext.firstObject(ofType: Notification.self, matching: predicate) ?? derivedContext.insertNewObject(ofType: Notification.self) - - localNote.update(with: remoteNote) - } + Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] done in + contextManager.performAndSave({ context in + for remoteNote in remoteNotes { + let predicate = NSPredicate(format: "(notificationId == %@)", remoteNote.notificationId) + let localNote = context.firstObject(ofType: Notification.self, matching: predicate) ?? context.insertNewObject(ofType: Notification.self) - self.contextManager.save(derivedContext) { + localNote.update(with: remoteNote) + } + }, completion: { + done() DispatchQueue.main.async { completion?() } - } - } + }, on: .global()) + }) } @@ -344,22 +366,21 @@ private extension NotificationSyncMediator { /// - Parameter remoteHashes: Collection of remoteNotifications. /// func deleteLocalMissingNotes(from remoteHashes: [RemoteNotification], completion: @escaping (() -> Void)) { - let derivedContext = type(of: self).sharedDerivedContext(with: contextManager) - - derivedContext.perform { - let remoteIds = remoteHashes.map { $0.notificationId } - let predicate = NSPredicate(format: "NOT (notificationId IN %@)", remoteIds) + Self.operationQueue.addOperation(AsyncBlockOperation { [contextManager] done in + contextManager.performAndSave({ context in + let remoteIds = remoteHashes.map { $0.notificationId } + let predicate = NSPredicate(format: "NOT (notificationId IN %@)", remoteIds) - for orphan in derivedContext.allObjects(ofType: Notification.self, matching: predicate) { - derivedContext.deleteObject(orphan) - } - - self.contextManager.save(derivedContext) { + for orphan in context.allObjects(ofType: Notification.self, matching: predicate) { + context.deleteObject(orphan) + } + }, completion: { + done() DispatchQueue.main.async { completion() } - } - } + }, on: .global()) + }) } @@ -373,8 +394,24 @@ private extension NotificationSyncMediator { /// - noteObjectID: CoreData ObjectID /// func updateReadStatus(_ status: Bool, forNoteWithObjectID noteObjectID: NSManagedObjectID) { - let note = mainContext.loadObject(ofType: Notification.self, with: noteObjectID) - note?.read = status + updateReadStatus(status, forNotesWithObjectIDs: [noteObjectID]) + } + + /// Updates the Read status, of an array of Notifications, as specified. + /// + /// Note: This method uses *saveContextAndWait* in order to prevent animation glitches when pushing + /// Notification Details. + /// + /// - Parameters: + /// - status: New *read* value + /// - notesObjectIDs: CoreData ObjectIDs + /// + func updateReadStatus(_ status: Bool, forNotesWithObjectIDs notesObjectIDs: [NSManagedObjectID]) { + let predicate = NSPredicate(format: "SELF IN %@", notesObjectIDs) + + let notes = mainContext.allObjects(ofType: Notification.self, matching: predicate) + + notes.forEach { $0.read = status } contextManager.saveContextAndWait(mainContext) } @@ -387,28 +424,3 @@ private extension NotificationSyncMediator { notificationCenter.post(name: Foundation.Notification.Name(rawValue: NotificationSyncMediatorDidUpdateNotifications), object: nil) } } - - - -// MARK: - Thread Safety Helpers -// -extension NotificationSyncMediator { - /// Returns the current Shared Derived Context, if any. Otherwise, proceeds to create a new - /// derived context, given a specified ContextManager. - /// - static func sharedDerivedContext(with manager: ContextManager) -> NSManagedObjectContext { - lock.lock() - if privateContext == nil { - privateContext = manager.newDerivedContext() - } - lock.unlock() - - return privateContext - } - - /// Nukes the private Shared Derived Context instance. For unit testing purposes. - /// - static func resetSharedDerivedContext() { - privateContext = nil - } -} diff --git a/WordPress/Classes/Services/PageCoordinator.swift b/WordPress/Classes/Services/PageCoordinator.swift new file mode 100644 index 000000000000..4539f7124df0 --- /dev/null +++ b/WordPress/Classes/Services/PageCoordinator.swift @@ -0,0 +1,21 @@ +import Foundation + +class PageCoordinator { + typealias TemplateSelectionCompletion = (_ layout: PageTemplateLayout?) -> Void + + static func showLayoutPickerIfNeeded(from controller: UIViewController, forBlog blog: Blog, completion: @escaping TemplateSelectionCompletion) { + if blog.isGutenbergEnabled && JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() { + showLayoutPicker(from: controller, forBlog: blog, completion) + } else { + completion(nil) + } + } + + private static func showLayoutPicker(from controller: UIViewController, forBlog blog: Blog, _ completion: @escaping TemplateSelectionCompletion) { + let rootViewController = GutenbergLayoutPickerViewController(blog: blog, completion: completion) + let navigationController = GutenbergLightNavigationController(rootViewController: rootViewController) + navigationController.modalPresentationStyle = .pageSheet + + controller.present(navigationController, animated: true, completion: nil) + } +} diff --git a/WordPress/Classes/Services/PageLayoutService.swift b/WordPress/Classes/Services/PageLayoutService.swift new file mode 100644 index 000000000000..ac219e4eb53c --- /dev/null +++ b/WordPress/Classes/Services/PageLayoutService.swift @@ -0,0 +1,137 @@ +import UIKit +import CoreData +import Gutenberg +import WordPressKit + +class PageLayoutService { + private struct Parameters { + static let supportedBlocks = "supported_blocks" + static let previewWidth = "preview_width" + static let previewHeight = "preview_height" + static let scale = "scale" + static let type = "type" + static let isBeta = "is_beta" + } + + typealias CompletionHandler = (Swift.Result<Void, Error>) -> Void + static func fetchLayouts(forBlog blog: Blog, withThumbnailSize thumbnailSize: CGSize, completion: CompletionHandler? = nil) { + let blogPersistentID = blog.objectID + let api: WordPressComRestApi + let dotComID: Int? + if blog.isAccessibleThroughWPCom(), + let blogID = blog.dotComID?.intValue, + let restAPI = blog.account?.wordPressComRestV2Api { + api = restAPI + dotComID = blogID + } else { + api = WordPressComRestApi.anonymousApi(userAgent: WPUserAgent.wordPress(), localeKey: WordPressComRestApi.LocaleKeyV2) + dotComID = nil + } + + fetchLayouts(api, dotComID, blogPersistentID, thumbnailSize, completion) + } + + private static func fetchLayouts(_ api: WordPressComRestApi, _ dotComID: Int?, _ blogPersistentID: NSManagedObjectID, _ thumbnailSize: CGSize, _ completion: CompletionHandler?) { + let params = parameters(thumbnailSize) + + PageLayoutServiceRemote.fetchLayouts(api, forBlogID: dotComID, withParameters: params) { (result) in + switch result { + case .success(let remoteLayouts): + persistToCoreData(blogPersistentID, remoteLayouts) { (persistanceResult) in + switch persistanceResult { + case .success: + completion?(.success(())) + case .failure(let error): + completion?(.failure(error)) + } + } + case .failure(let error): + completion?(.failure(error)) + } + } + } + + // Parameter Generation + private static func parameters(_ thumbnailSize: CGSize) -> [String: AnyObject] { + return [ + Parameters.supportedBlocks: supportedBlocks as AnyObject, + Parameters.previewWidth: "\(thumbnailSize.width)" as AnyObject, + Parameters.previewHeight: "\(thumbnailSize.height)" as AnyObject, + Parameters.scale: scale as AnyObject, + Parameters.type: type as AnyObject, + Parameters.isBeta: isBeta as AnyObject + ] + } + + private static let supportedBlocks: String = { + let isDevMode = BuildConfiguration.current ~= [.localDeveloper, .a8cBranchTest] + return Gutenberg.supportedBlocks(isDev: isDevMode).joined(separator: ",") + }() + + private static let scale = UIScreen.main.nativeScale + + private static let type = "mobile" + + // Return "true" or "false" for isBeta that gets passed into the endpoint. + private static let isBeta = String(BuildConfiguration.current ~= [.localDeveloper, .a8cBranchTest]) + +} + +extension PageLayoutService { + + static func resultsController(forBlog blog: Blog, delegate: NSFetchedResultsControllerDelegate? = nil) -> NSFetchedResultsController<PageTemplateCategory> { + let context = ContextManager.shared.mainContext + let request: NSFetchRequest<PageTemplateCategory> = PageTemplateCategory.fetchRequest(forBlog: blog) + let sort = NSSortDescriptor(key: #keyPath(PageTemplateCategory.ordinal), ascending: true) + request.sortDescriptors = [sort] + + let resultsController = NSFetchedResultsController<PageTemplateCategory>(fetchRequest: request, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil) + resultsController.delegate = delegate + do { + try resultsController.performFetch() + } catch { + DDLogError("Failed to fetch entities: \(error)") + } + + return resultsController + } + + /// This will use a wipe all and rebuild strategy for managing the stored layouts. They are stored and associated per blog to prevent weird edge cases of downloading one set of layouts then having that bleed to another site (like a self hosted one) which may have a different set of suggested layouts. + private static func persistToCoreData(_ blogPersistentID: NSManagedObjectID, _ layouts: RemotePageLayouts, _ completion: @escaping (Swift.Result<Void, Error>) -> Void) { + ContextManager.shared.performAndSave({ context in + guard let blog = context.object(with: blogPersistentID) as? Blog else { + let userInfo = [NSLocalizedFailureReasonErrorKey: "Couldn't find blog to save the fetched results to."] + throw NSError(domain: "PageLayoutService.persistToCoreData", code: 0, userInfo: userInfo) + } + cleanUpStoredLayouts(forBlog: blog, context: context) + try persistCategoriesToCoreData(blog, layouts.categories, context: context) + try persistLayoutsToCoreData(blog, layouts.layouts, context: context) + }, completion: completion, on: .main) + } + + private static func cleanUpStoredLayouts(forBlog blog: Blog, context: NSManagedObjectContext) { + // PageTemplateCategories have a cascade deletion rule to PageTemplateLayout. Deleting each category for the blog will cascade to also clean up the layouts. + blog.pageTemplateCategories?.forEach({ context.delete($0) }) + } + + private static func persistCategoriesToCoreData(_ blog: Blog, _ categories: [RemoteLayoutCategory], context: NSManagedObjectContext) throws { + for (index, category) in categories.enumerated() { + let category = PageTemplateCategory(context: context, category: category, ordinal: index) + blog.pageTemplateCategories?.insert(category) + } + } + + private static func persistLayoutsToCoreData(_ blog: Blog, _ layouts: [RemoteLayout], context: NSManagedObjectContext) throws { + for layout in layouts { + let localLayout = PageTemplateLayout(context: context, layout: layout) + try associate(blog, layout: localLayout, toCategories: layout.categories, context: context) + } + } + + private static func associate(_ blog: Blog, layout: PageTemplateLayout, toCategories categories: [RemoteLayoutCategory], context: NSManagedObjectContext) throws { + let categoryList = categories.map({ $0.slug }) + let request: NSFetchRequest<PageTemplateCategory> = PageTemplateCategory.fetchRequest(forBlog: blog, categorySlugs: categoryList) + let fetchedCategories = try context.fetch(request) + layout.categories = Set(fetchedCategories) + } +} diff --git a/WordPress/Classes/Services/PeopleService.swift b/WordPress/Classes/Services/PeopleService.swift index 92a0a9035c3e..6543966e4ccf 100644 --- a/WordPress/Classes/Services/PeopleService.swift +++ b/WordPress/Classes/Services/PeopleService.swift @@ -2,6 +2,10 @@ import Foundation import CocoaLumberjack import WordPressKit +enum PeopleServiceError: Error { + case userNotFoundLocally(User) +} + /// Service providing access to the People Management WordPress.com API. /// struct PeopleService { @@ -11,7 +15,7 @@ struct PeopleService { // MARK: - Private Properties /// - fileprivate let context: NSManagedObjectContext + private let coreDataStack: CoreDataStackSwift fileprivate let remote: PeopleServiceRemote @@ -21,14 +25,14 @@ struct PeopleService { /// - blog: Target Blog Instance /// - context: CoreData context to be used. /// - init?(blog: Blog, context: NSManagedObjectContext) { + init?(blog: Blog, coreDataStack: CoreDataStackSwift) { guard let api = blog.wordPressComRestApi(), let dotComID = blog.dotComID as? Int else { return nil } self.remote = PeopleServiceRemote(wordPressComRestApi: api) self.siteID = dotComID - self.context = context + self.coreDataStack = coreDataStack } /// Loads a page of Users associated to the current blog, starting at the specified offset. @@ -41,9 +45,11 @@ struct PeopleService { /// func loadUsersPage(_ offset: Int = 0, count: Int = 20, success: @escaping ((_ retrieved: Int, _ shouldLoadMore: Bool) -> Void), failure: ((Error) -> Void)? = nil) { remote.getUsers(siteID, offset: offset, count: count, success: { users, hasMore in - self.mergePeople(users) - success(users.count, hasMore) - + coreDataStack.performAndSave({ context in + self.mergePeople(users, in: context) + }, completion: { + success(users.count, hasMore) + }, on: .main) }, failure: { error in DDLogError(String(describing: error)) failure?(error) @@ -60,8 +66,36 @@ struct PeopleService { /// func loadFollowersPage(_ offset: Int = 0, count: Int = 20, success: @escaping ((_ retrieved: Int, _ shouldLoadMore: Bool) -> Void), failure: ((Error) -> Void)? = nil) { remote.getFollowers(siteID, offset: offset, count: count, success: { followers, hasMore in - self.mergePeople(followers) - success(followers.count, hasMore) + coreDataStack.performAndSave({ context in + self.mergePeople(followers, in: context) + }, completion: { + success(followers.count, hasMore) + }, on: .main) + }, failure: { error in + DDLogError(String(describing: error)) + failure?(error) + }) + } + + /// Loads a page of Email Followers associated to the current blog, starting at the specified offset. + /// + /// - Parameters: + /// - offset: Number of records to skip. + /// - count: Number of records to retrieve. By default set to 20. + /// - success: Closure to be executed on success with the number of followers retrieved and a bool indicating if more are available. + /// - failure: Closure to be executed on failure. + /// + func loadEmailFollowersPage(_ offset: Int = 0, + count: Int = 20, + success: @escaping ((_ retrieved: Int, _ shouldLoadMore: Bool) -> Void), + failure: ((Error) -> Void)? = nil) { + let page = (offset / count) + 1 + remote.getEmailFollowers(siteID, page: page, max: count, success: { followers, hasMore in + self.coreDataStack.performAndSave({ context in + self.mergePeople(followers, in: context) + }, completion: { + success(followers.count, hasMore) + }, on: .main) }, failure: { error in DDLogError(String(describing: error)) failure?(error) @@ -78,9 +112,11 @@ struct PeopleService { /// func loadViewersPage(_ offset: Int = 0, count: Int = 20, success: @escaping ((_ retrieved: Int, _ shouldLoadMore: Bool) -> Void), failure: ((Error) -> Void)? = nil) { remote.getViewers(siteID, offset: offset, count: count, success: { viewers, hasMore in - self.mergePeople(viewers) - success(viewers.count, hasMore) - + self.coreDataStack.performAndSave({ context in + self.mergePeople(viewers, in: context) + }, completion: { + success(viewers.count, hasMore) + }, on: .main) }, failure: { error in DDLogError(String(describing: error)) failure?(error) @@ -96,34 +132,52 @@ struct PeopleService { /// /// - Returns: A new Person instance, with the new Role already assigned. /// - func updateUser(_ user: User, role: String, failure: ((Error, User) -> Void)?) -> User { - guard let managedPerson = managedPersonFromPerson(user) else { - return user - } + func updateUser(_ user: User, role: String, receiveUpdate: @escaping (User) -> Void, failure: ((Error, User) -> Void)?) { + coreDataStack.performAndSave({ context throws -> (String, User) in + guard let managedPerson = managedPersonFromPerson(user, in: context) else { + throw PeopleServiceError.userNotFoundLocally(user) + } - // OP Reversal - let pristineRole = managedPerson.role + // OP Reversal + let pristineRole = managedPerson.role - // Hit the Backend - remote.updateUserRole(siteID, userID: user.ID, newRole: role, success: nil, failure: { error in + // Pre-emptively update the role + managedPerson.role = role - DDLogError("### Error while updating person \(user.ID) in blog \(self.siteID): \(error)") - - guard let managedPerson = self.managedPersonFromPerson(user) else { - DDLogError("### Person with ID \(user.ID) deleted before update") - return + return (pristineRole, User(managedPerson: managedPerson)) + }, completion: { result in + switch result { + case let .failure(error): + failure?(error, user) + case let .success((pristineRole, updated)): + receiveUpdate(updated) + self.updateRemoteUser(user, role: role, roleToRevertUponFailure: pristineRole, failure: failure) } + }, on: .main) + } - managedPerson.role = pristineRole + private func updateRemoteUser(_ user: User, role: String, roleToRevertUponFailure pristineRole: String, failure: ((Error, User) -> Void)?) { + remote.updateUserRole(siteID, userID: user.ID, newRole: role, success: nil, failure: { error in + DDLogError("### Error while updating person \(user.ID) in blog \(self.siteID): \(error)") - let reloadedPerson = User(managedPerson: managedPerson) - failure?(error, reloadedPerson) + self.coreDataStack.performAndSave({ context in + guard let managedPerson = self.managedPersonFromPerson(user, in: context) else { + DDLogError("### Person with ID \(user.ID) deleted before update") + throw PeopleServiceError.userNotFoundLocally(user) + } + + managedPerson.role = pristineRole + + return User(managedPerson: managedPerson) + }, completion: { result in + switch result { + case let .failure(error): + failure?(error, user) + case let .success(reloadedPerson): + failure?(error, reloadedPerson) + } + }, on: .main) }) - - // Pre-emptively update the role - managedPerson.role = role - - return User(managedPerson: managedPerson) } /// Deletes a given User. @@ -133,25 +187,20 @@ struct PeopleService { /// - success: Closure to be executed in case of success. /// - failure: Closure to be executed on error /// - func deleteUser(_ user: User, success: (() -> Void)? = nil, failure: ((Error) -> Void)? = nil) { - guard let managedPerson = managedPersonFromPerson(user) else { - return - } - - // Hit the Backend - remote.deleteUser(siteID, userID: user.ID, success: { - success?() - }, failure: { error in - DDLogError("### Error while deleting person \(user.ID) from blog \(self.siteID): \(error)") - - // Revert the deletion - self.createManagedPerson(user) - - failure?(error) - }) - - // Pre-emptively nuke the entity - context.delete(managedPerson) + func deleteUser(_ user: User, success: @escaping () -> Void, failure: ((Error) -> Void)? = nil) { + delete( + user, + using: { completion in + remote.deleteUser( + siteID, + userID: user.ID, + success: { completion(.success(())) }, + failure: { completion(.failure($0)) } + ) + }, + success: success, + failure: failure + ) } /// Deletes a given Follower. @@ -162,24 +211,42 @@ struct PeopleService { /// - failure: Closure to be executed on error /// func deleteFollower(_ person: Follower, success: (() -> Void)? = nil, failure: ((Error) -> Void)? = nil) { - guard let managedPerson = managedPersonFromPerson(person) else { - return - } - - // Hit the Backend - remote.deleteFollower(siteID, userID: person.ID, success: { - success?() - }, failure: { error in - DDLogError("### Error while deleting follower \(person.ID) from blog \(self.siteID): \(error)") - - // Revert the deletion - self.createManagedPerson(person) - - failure?(error) - }) + delete( + person, + using: { completion in + remote.deleteFollower( + siteID, + userID: person.ID, + success: { completion(.success(())) }, + failure: { completion(.failure($0)) } + ) + }, + success: success, + failure: failure + ) + } - // Pre-emptively nuke the entity - context.delete(managedPerson) + /// Deletes a given EmailFollower. + /// + /// - Parameters: + /// - person: The email follower that should be deleted + /// - success: Closure to be executed in case of success. + /// - failure: Closure to be executed on error + /// + func deleteEmailFollower(_ person: EmailFollower, success: (() -> Void)? = nil, failure: ((Error) -> Void)? = nil) { + delete( + person, + using: { completion in + remote.deleteEmailFollower( + siteID, + userID: person.ID, + success: { completion(.success(())) }, + failure: { completion(.failure($0)) } + ) + }, + success: success, + failure: failure + ) } /// Deletes a given Viewer. @@ -189,35 +256,32 @@ struct PeopleService { /// - success: Closure to be executed in case of success. /// - failure: Closure to be executed on error /// - func deleteViewer(_ person: Viewer, success: (() -> Void)? = nil, failure: ((Error) -> Void)? = nil) { - guard let managedPerson = managedPersonFromPerson(person) else { - return - } - - // Hit the Backend - remote.deleteViewer(siteID, userID: person.ID, success: { - success?() - }, failure: { error in - DDLogError("### Error while deleting viewer \(person.ID) from blog \(self.siteID): \(error)") - - // Revert the deletion - self.createManagedPerson(person) - - failure?(error) - }) - - // Pre-emptively nuke the entity - context.delete(managedPerson) + func deleteViewer(_ person: Viewer, success: @escaping () -> Void, failure: ((Error) -> Void)? = nil) { + delete( + person, + using: { completion in + remote.deleteViewer( + siteID, + userID: person.ID, + success: { completion(.success(())) }, + failure: { completion(.failure($0)) } + ) + }, + success: success, + failure: failure + ) } /// Nukes all users from Core Data. /// func removeManagedPeople() { - let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Person") - request.predicate = NSPredicate(format: "siteID = %@", NSNumber(value: siteID)) - if let objects = (try? context.fetch(request)) as? [NSManagedObject] { - objects.forEach { context.delete($0) } - } + coreDataStack.performAndSave({ context in + let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Person") + request.predicate = NSPredicate(format: "siteID = %@", NSNumber(value: siteID)) + if let objects = (try? context.fetch(request)) as? [NSManagedObject] { + objects.forEach { context.delete($0) } + } + }, completion: nil, on: .main) } /// Validates Invitation Recipients. @@ -263,26 +327,235 @@ struct PeopleService { } } +// MARK: - Invite Links Related + +extension PeopleService { + + /// Convenience method for retrieving invite links from core data. + /// + /// - Parameters: + /// - siteID: The ID of the site. + /// - success: A success block. + /// - failure: A failure block + /// + private func inviteLinks(_ siteID: Int, in context: NSManagedObjectContext) -> [InviteLinks] { + let request = InviteLinks.fetchRequest() as NSFetchRequest<InviteLinks> + request.predicate = NSPredicate(format: "blog.blogID = %@", NSNumber(value: siteID)) + if let invites = try? context.fetch(request) { + return invites + } + return [InviteLinks]() + } + + /// Fetch any existing Invite Links + /// + /// - Parameters: + /// - siteID: The ID of the site. + /// - success: A success block. + /// - failure: A failure block + /// + func fetchInviteLinks(_ siteID: Int, + success: @escaping (([InviteLinks]) -> Void), + failure: @escaping ((Error) -> Void)) { + remote.fetchInvites(siteID) { remoteInvites in + merge(remoteInvites: remoteInvites, for: siteID) { + self.coreDataStack.mainContext.perform { + let links = inviteLinks(siteID, in: self.coreDataStack.mainContext) + success(links) + } + } + } failure: { error in + failure(error) + } + } + + /// Generate new Invite Links + /// + /// - Parameters: + /// - siteID: The ID of the site. + /// - success: A success block. + /// - failure: A failure block + /// + func generateInviteLinks(_ siteID: Int, + success: @escaping (([InviteLinks]) -> Void), + failure: @escaping ((Error) -> Void)) { + remote.generateInviteLinks(siteID) { _ in + // Fetch after generation. + fetchInviteLinks(siteID, success: success, failure: failure) + } failure: { error in + failure(error) + } + } + + /// Disable existing Invite Links + /// + /// - Parameters: + /// - siteID: The ID of the site. + /// - success: A success block. + /// - failure: A failure block + /// + func disableInviteLinks(_ siteID: Int, + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + remote.disableInviteLinks(siteID) { deletedKeys in + deleteInviteLinks(keys: deletedKeys, for: siteID) { + success() + } + } failure: { (error) in + failure(error) + } + } + + /// Merges an array of RemoteInviteLinks with any existing InviteLinks. InviteLinks + /// missing from the array of RemoteInviteLinks are deleted. + /// + /// - Parameters: + /// - remoteInvites: An array of RemoteInviteLinks + /// - siteID: The ID of the site to which the InviteLinks belong. + /// - onComplete: A completion block that is called after changes are saved to core data. + /// + func merge(remoteInvites: [RemoteInviteLink], for siteID: Int, onComplete: @escaping (() -> Void)) { + coreDataStack.performAndSave({ context in + guard let blog = try Blog.lookup(withID: siteID, in: context) else { + return + } + + // Delete Stale Items + let inviteKeys = remoteInvites.map { invite -> String in + return invite.inviteKey + } + deleteMissingInviteLinks(keys: inviteKeys, for: siteID, from: context) + + // Create or Update items + for remoteInvite in remoteInvites { + createOrUpdateInviteLink(remoteInvite: remoteInvite, blog: blog, context: context) + } + }, completion: { _ in onComplete() }, on: .main) + } + + /// Deletes InviteLinks whose inviteKeys belong to the supplied array of keys. + /// + /// - Parameters: + /// - keys: An array of inviteKeys representing InviteLinks to delete. + /// - siteID: The ID of the site to which the InviteLinks belong. + /// - onComplete: A completion block that is called after changes are saved to core data. + /// + func deleteInviteLinks(keys: [String], for siteID: Int, onComplete: @escaping (() -> Void)) { + coreDataStack.performAndSave({ context in + let request = InviteLinks.fetchRequest() as NSFetchRequest<InviteLinks> + request.predicate = NSPredicate(format: "inviteKey IN %@ AND blog.blogID = %@", keys, NSNumber(value: siteID)) + + do { + let staleInvites = try context.fetch(request) + for staleInvite in staleInvites { + context.delete(staleInvite) + } + } catch { + DDLogError("Error fetching stale invite links: \(error)") + } + }, completion: onComplete, on: .main) + } + + /// Markes for deletion InviteLinks whose inviteKeys are not included in the supplied array of keys. + /// This method does not save changes to the persistent store. + /// + /// - Parameters: + /// - keys: An array of inviteKeys representing InviteLinks to keep. + /// - siteID: The ID of the site to which the InviteLinks belong. + /// - context: The NSManagedObjectContext to operate on. It is assumed this is a background write context. + /// + func deleteMissingInviteLinks(keys: [String], for siteID: Int, from context: NSManagedObjectContext) { + let request = InviteLinks.fetchRequest() as NSFetchRequest<InviteLinks> + request.predicate = NSPredicate(format: "NOT (inviteKey IN %@) AND blog.blogID = %@", keys, NSNumber(value: siteID)) + + do { + let staleInvites = try context.fetch(request) + for staleInvite in staleInvites { + context.delete(staleInvite) + } + } catch { + DDLogError("Error fetching stale invite links: \(error)") + } + } + + /// Updates an existing InviteLinks record, or inserts a new record into the specified NSManagedObjectContext. + /// This method does not save changes to the persistent store. + /// + /// - Parameters: + /// - remoteInvite: The RemoteInviteLink that needs to be stored. + /// - blog: The blog instance to which the InviteLinks belong. + /// - context: The NSManagedObjectContext to operate on. It is assumed this is a background write context. + /// + func createOrUpdateInviteLink(remoteInvite: RemoteInviteLink, blog: Blog, context: NSManagedObjectContext) { + let request = InviteLinks.fetchRequest() as NSFetchRequest<InviteLinks> + request.predicate = NSPredicate(format: "inviteKey = %@ AND blog = %@", remoteInvite.inviteKey, blog) + + if let invite = try? context.fetch(request).first ?? InviteLinks(context: context) { + invite.blog = blog + invite.expiry = remoteInvite.expiry + invite.groupInvite = remoteInvite.groupInvite + invite.inviteDate = remoteInvite.inviteDate + invite.inviteKey = remoteInvite.inviteKey + invite.isPending = remoteInvite.isPending + invite.link = remoteInvite.link + invite.role = remoteInvite.role + } + } + +} + + +// MARK: - Private Methods -/// Encapsulates all of the PeopleService Private Methods. -/// private extension PeopleService { + + func delete( + _ person: RemotePerson, + using api: @escaping (@escaping (Result<Void, Error>) -> Void) -> Void, + success: (() -> Void)?, + failure: ((Error) -> Void)? + ) { + coreDataStack.performAndSave({ context in + guard let managedPerson = managedPersonFromPerson(person, in: context) else { + return + } + // Pre-emptively nuke the entity + context.delete(managedPerson) + }, completion: { + // Hit the Backend + api { result in + switch result { + case .success: + success?() + case let .failure(error): + // Revert the deletion + self.coreDataStack.performAndSave({ context in + self.createManagedPerson(person, in: context) + }, completion: { + failure?(error) + }, on: .main) + } + } + }, on: .main) + + } + /// Updates the Core Data collection of users, to match with the array of People received. /// - func mergePeople<T: Person>(_ remotePeople: [T]) { + func mergePeople<T: Person>(_ remotePeople: [T], in context: NSManagedObjectContext) { for remotePerson in remotePeople { - if let existingPerson = managedPersonFromPerson(remotePerson) { + if let existingPerson = managedPersonFromPerson(remotePerson, in: context) { existingPerson.updateWith(remotePerson) DDLogDebug("Updated person \(existingPerson)") } else { - createManagedPerson(remotePerson) + createManagedPerson(remotePerson, in: context) } } } /// Retrieves the collection of users, persisted in Core Data, associated with the current blog. /// - func loadPeople<T: Person>(_ siteID: Int, type: T.Type) -> [T] { + func loadPeople<T: Person>(_ siteID: Int, type: T.Type, in context: NSManagedObjectContext) -> [T] { let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Person") request.predicate = NSPredicate(format: "siteID = %@ AND kind = %@", NSNumber(value: siteID as Int), @@ -300,7 +573,7 @@ private extension PeopleService { /// Retrieves a Person from Core Data, with the specifiedID. /// - func managedPersonFromPerson(_ person: Person) -> ManagedPerson? { + func managedPersonFromPerson(_ person: Person, in context: NSManagedObjectContext) -> ManagedPerson? { let request = NSFetchRequest<NSFetchRequestResult>(entityName: "Person") request.predicate = NSPredicate(format: "siteID = %@ AND userID = %@ AND kind = %@", NSNumber(value: siteID as Int), @@ -314,7 +587,7 @@ private extension PeopleService { /// Nukes the set of users, from Core Data, with the specified ID's. /// - func removeManagedPeopleWithIDs<T: Person>(_ ids: Set<Int>, type: T.Type) { + func removeManagedPeopleWithIDs<T: Person>(_ ids: Set<Int>, type: T.Type, in context: NSManagedObjectContext) { if ids.isEmpty { return } @@ -335,7 +608,7 @@ private extension PeopleService { /// Inserts a new Person instance into Core Data, with the specified payload. /// - func createManagedPerson<T: Person>(_ person: T) { + func createManagedPerson<T: Person>(_ person: T, in context: NSManagedObjectContext) { let managedPerson = NSEntityDescription.insertNewObject(forEntityName: "Person", into: context) as! ManagedPerson managedPerson.updateWith(person) managedPerson.creationDate = Date() diff --git a/WordPress/Classes/Services/PlanService.swift b/WordPress/Classes/Services/PlanService.swift index f82aee1d47cb..3be9f7744b8c 100644 --- a/WordPress/Classes/Services/PlanService.swift +++ b/WordPress/Classes/Services/PlanService.swift @@ -3,7 +3,13 @@ import CocoaLumberjack import WordPressKit -open class PlanService: LocalCoreDataService { +open class PlanService: NSObject { + + private let coreDataStack: CoreDataStack + + @objc init(coreDataStack: CoreDataStack) { + self.coreDataStack = coreDataStack + } public func getAllSitesNonLocalizedPlanDescriptionsForAccount(_ account: WPAccount, success: @escaping ([Int: RemotePlanSimpleDescription]) -> Void, @@ -31,33 +37,28 @@ open class PlanService: LocalCoreDataService { let remote = PlanServiceRemote(wordPressComRestApi: api) remote.getWpcomPlans({ plans in - self.mergeRemoteWpcomPlans(plans.plans, remoteGroups: plans.groups, remoteFeatures: plans.features, onComplete: { - success() - }) + self.mergeRemoteWpcomPlans(plans.plans, remoteGroups: plans.groups, remoteFeatures: plans.features, onComplete: success) }, failure: failure) } - func mergeRemoteWpcomPlans(_ remotePlans: [RemoteWpcomPlan], + private func mergeRemoteWpcomPlans(_ remotePlans: [RemoteWpcomPlan], remoteGroups: [RemotePlanGroup], remoteFeatures: [RemotePlanFeature], onComplete: @escaping () -> Void ) { - - mergeRemoteWpcomPlans(remotePlans) - mergeRemotePlanGroups(remoteGroups) - mergeRemotePlanFeatures(remoteFeatures) - - ContextManager.sharedInstance().save(managedObjectContext) { - onComplete() - } + coreDataStack.performAndSave({ context in + self.mergeRemoteWpcomPlans(remotePlans, in: context) + self.mergeRemotePlanGroups(remoteGroups, in: context) + self.mergeRemotePlanFeatures(remoteFeatures, in: context) + }, completion: onComplete, on: .main) } - func allPlans() -> [Plan] { + func allPlans(in context: NSManagedObjectContext) -> [Plan] { let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Plan") fetchRequest.sortDescriptors = [NSSortDescriptor(key: "order", ascending: true)] do { - return try managedObjectContext.fetch(fetchRequest) as! [Plan] + return try context.fetch(fetchRequest) as! [Plan] } catch let error as NSError { DDLogError("Error fetching Plans: \(error.localizedDescription)") return [Plan]() @@ -65,18 +66,18 @@ open class PlanService: LocalCoreDataService { } - func findPlanByShortname(_ shortname: String) -> Plan? { - let plans = allPlans() as NSArray - let results = plans.filtered(using: NSPredicate(format: "shortname = %@", shortname)) - return results.first as? Plan + private func findPlanByShortname(_ shortname: String, in context: NSManagedObjectContext) -> Plan? { + allPlans(in: context).first { + $0.shortname == shortname + } } - func allPlanGroups() -> [PlanGroup] { + private func allPlanGroups(in context: NSManagedObjectContext) -> [PlanGroup] { let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "PlanGroup") fetchRequest.sortDescriptors = [NSSortDescriptor(key: "order", ascending: true)] do { - return try managedObjectContext.fetch(fetchRequest) as! [PlanGroup] + return try context.fetch(fetchRequest) as! [PlanGroup] } catch let error as NSError { DDLogError("Error fetching PlanGroups: \(error.localizedDescription)") return [PlanGroup]() @@ -84,17 +85,17 @@ open class PlanService: LocalCoreDataService { } - func findPlanGroupBySlug(_ slug: String) -> PlanGroup? { - let groups = allPlanGroups() as NSArray - let results = groups.filtered(using: NSPredicate(format: "slug = %@", slug)) - return results.first as? PlanGroup + private func findPlanGroupBySlug(_ slug: String, in context: NSManagedObjectContext) -> PlanGroup? { + allPlanGroups(in: context).first { + $0.slug == slug + } } - func allPlanFeatures() -> [PlanFeature] { + func allPlanFeatures(in context: NSManagedObjectContext) -> [PlanFeature] { let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "PlanFeature") do { - return try managedObjectContext.fetch(fetchRequest) as! [PlanFeature] + return try context.fetch(fetchRequest) as! [PlanFeature] } catch let error as NSError { DDLogError("Error fetching PlanFeatures: \(error.localizedDescription)") return [PlanFeature]() @@ -102,21 +103,21 @@ open class PlanService: LocalCoreDataService { } - func findPlanFeatureBySlug(_ slug: String) -> PlanFeature? { - let features = allPlanFeatures() as NSArray + private func findPlanFeatureBySlug(_ slug: String, in context: NSManagedObjectContext) -> PlanFeature? { + let features = allPlanFeatures(in: context) as NSArray let results = features.filtered(using: NSPredicate(format: "slug = %@", slug)) return results.first as? PlanFeature } - func mergeRemoteWpcomPlans(_ remotePlans: [RemoteWpcomPlan]) { + private func mergeRemoteWpcomPlans(_ remotePlans: [RemoteWpcomPlan], in context: NSManagedObjectContext) { // create or update plans var plansToKeep = [Plan]() for remotePlan in remotePlans { - var plan = findPlanByShortname(remotePlan.shortname) + var plan = findPlanByShortname(remotePlan.shortname, in: context) if plan == nil { - plan = NSEntityDescription.insertNewObject(forEntityName: "Plan", into: managedObjectContext) as? Plan + plan = NSEntityDescription.insertNewObject(forEntityName: "Plan", into: context) as? Plan } plan?.order = Int16(plansToKeep.count) plan?.groups = remotePlan.groups @@ -127,30 +128,33 @@ open class PlanService: LocalCoreDataService { plan?.summary = remotePlan.description plan?.features = remotePlan.features plan?.icon = remotePlan.icon + plan?.nonLocalizedShortname = remotePlan.nonLocalizedShortname + plan?.supportName = remotePlan.supportName + plan?.supportPriority = Int16(remotePlan.supportPriority) plansToKeep.append(plan!) } // Delete missing plans - let plans = allPlans() + let plans = allPlans(in: context) for plan in plans { if plansToKeep.contains(plan) { continue } - managedObjectContext.delete(plan) + context.delete(plan) } } - func mergeRemotePlanGroups(_ remoteGroups: [RemotePlanGroup]) { + private func mergeRemotePlanGroups(_ remoteGroups: [RemotePlanGroup], in context: NSManagedObjectContext) { // create or update plans var groupsToKeep = [PlanGroup]() for remoteGroup in remoteGroups { - var group = findPlanGroupBySlug(remoteGroup.slug) + var group = findPlanGroupBySlug(remoteGroup.slug, in: context) if group == nil { - group = NSEntityDescription.insertNewObject(forEntityName: "PlanGroup", into: managedObjectContext) as? PlanGroup + group = NSEntityDescription.insertNewObject(forEntityName: "PlanGroup", into: context) as? PlanGroup } group?.order = Int16(groupsToKeep.count) @@ -161,24 +165,24 @@ open class PlanService: LocalCoreDataService { } // Delete missing plans - let groups = allPlanGroups() + let groups = allPlanGroups(in: context) for group in groups { if groupsToKeep.contains(group) { continue } - managedObjectContext.delete(group) + context.delete(group) } } - func mergeRemotePlanFeatures(_ remoteFeatures: [RemotePlanFeature]) { + private func mergeRemotePlanFeatures(_ remoteFeatures: [RemotePlanFeature], in context: NSManagedObjectContext) { // create or update plans var featuresToKeep = [PlanFeature]() for remoteFeature in remoteFeatures { - var feature = findPlanFeatureBySlug(remoteFeature.slug) + var feature = findPlanFeatureBySlug(remoteFeature.slug, in: context) if feature == nil { - feature = NSEntityDescription.insertNewObject(forEntityName: "PlanFeature", into: managedObjectContext) as? PlanFeature + feature = NSEntityDescription.insertNewObject(forEntityName: "PlanFeature", into: context) as? PlanFeature } feature?.slug = remoteFeature.slug @@ -189,12 +193,12 @@ open class PlanService: LocalCoreDataService { } // Delete missing plans - let features = allPlanFeatures() + let features = allPlanFeatures(in: context) for feature in features { if featuresToKeep.contains(feature) { continue } - managedObjectContext.delete(feature) + context.delete(feature) } } } @@ -219,52 +223,33 @@ extension PlanService { success() return } - PlanStorage.updateHasDomainCredit(planIdInt, - forSite: siteID, - hasDomainCredit: plans.activePlan.hasDomainCredit ?? false) - success() - }, + self.coreDataStack.performAndSave({ context in + PlanStorage.updateHasDomainCredit( + planIdInt, + forBlog: blog, + hasDomainCredit: plans.activePlan.hasDomainCredit ?? false, + in: context + ) + }, completion: success, on: .main) + }, failure: { error in DDLogError("Failed checking prices for blog for site \(siteID): \(error.localizedDescription)") failure(error) - }) + } + ) } } -struct PlanStorage { - static func activatePlan(_ planID: Int, forSite siteID: Int) { - let manager = ContextManager.sharedInstance() - let context = manager.newDerivedContext() - let service = BlogService(managedObjectContext: context) - context.performAndWait { - guard let blog = service.blog(byBlogId: NSNumber(value: siteID)) else { - let error = "Tried to activate a plan for a non-existing site (ID: \(siteID))" - assertionFailure(error) - DDLogError(error) - return - } - if blog.planID?.intValue != planID { - blog.planID = NSNumber(value: planID) - manager.saveContextAndWait(context) - } +private struct PlanStorage { + static func updateHasDomainCredit(_ planID: Int, forBlog blog: Blog, hasDomainCredit: Bool, in context: NSManagedObjectContext) { + guard let blogInContext = try? context.existingObject(with: blog.objectID) as? Blog else { + let error = "Tried to update a plan for a non-existing site" + assertionFailure(error) + DDLogError(error) + return } - } - - static func updateHasDomainCredit(_ planID: Int, forSite siteID: Int, hasDomainCredit: Bool) { - let manager = ContextManager.sharedInstance() - let context = manager.newDerivedContext() - let service = BlogService(managedObjectContext: context) - context.performAndWait { - guard let blog = service.blog(byBlogId: NSNumber(value: siteID)) else { - let error = "Tried to update a plan for a non-existing site (ID: \(siteID))" - assertionFailure(error) - DDLogError(error) - return - } - if blog.hasDomainCredit != hasDomainCredit { - blog.hasDomainCredit = hasDomainCredit - manager.saveContextAndWait(context) - } + if blogInContext.hasDomainCredit != hasDomainCredit { + blogInContext.hasDomainCredit = hasDomainCredit } } } diff --git a/WordPress/Classes/Services/PluginJetpackProxyService.swift b/WordPress/Classes/Services/PluginJetpackProxyService.swift new file mode 100644 index 000000000000..8920db055675 --- /dev/null +++ b/WordPress/Classes/Services/PluginJetpackProxyService.swift @@ -0,0 +1,60 @@ +/// Plugin management service through the Jetpack Proxy. +/// +class PluginJetpackProxyService { + + // MARK: Properties + + private let remote: JetpackProxyServiceRemote + + init(remote: JetpackProxyServiceRemote? = nil) { + self.remote = remote ?? .init(wordPressComRestApi: .defaultApi(in: ContextManager.shared.mainContext)) + } + + // MARK: Methods + + /// Installs a plugin for a site with the given `siteID` via the Jetpack Proxy API. + /// + /// - Note: The `pluginSlug` value is currently only obtainable from the WordPress.org REST v1.1 or v1.2 API. + /// The documentation for this API is rather sparse, so we'll have to test things ourselves. + /// See [this page](https://codex.wordpress.org/WordPress.org_API#Plugins) for more details. + /// + /// - Parameters: + /// - siteID: The dotcom ID of the Jetpack-connected site. + /// - pluginSlug: A string used as an identifier for the plugin. See the note above. + /// - active: Whether the plugin should be activated immediately after installation. + /// - completion: Closure called after the request completes. + /// - Returns: A Progress object that can be used to cancel the request. Discardable. + @discardableResult + func installPlugin(for siteID: Int, + pluginSlug: String, + active: Bool = false, + completion: @escaping (Result<Void, Error>) -> Void) -> Progress? { + let parameters = [ + "slug": pluginSlug, + "status": active ? "active" : "inactive" + ] + + return remote.proxyRequest(for: siteID, + path: Constants.pluginsPath, + method: .post, + parameters: parameters) { result in + switch result { + case .success: + // we're ignoring the response object for now. + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } +} + +// MARK: - Private helpers + +private extension PluginJetpackProxyService { + + enum Constants { + static let pluginsPath = "/wp/v2/plugins" + } + +} diff --git a/WordPress/Classes/Services/PostAutoUploadInteractor.swift b/WordPress/Classes/Services/PostAutoUploadInteractor.swift index 2b073c89147d..c90f36ebf2b9 100644 --- a/WordPress/Classes/Services/PostAutoUploadInteractor.swift +++ b/WordPress/Classes/Services/PostAutoUploadInteractor.swift @@ -78,7 +78,7 @@ final class PostAutoUploadInteractor { /// Temporary method to support old _Retry_ upload functionality. /// - /// This is going to be removed later. + /// This is going to be removed later. func canRetryUpload(of post: AbstractPost) -> Bool { guard post.isFailed, let status = post.status else { diff --git a/WordPress/Classes/Services/PostCategoryService.h b/WordPress/Classes/Services/PostCategoryService.h index 438a9b437294..80584a86ac7b 100644 --- a/WordPress/Classes/Services/PostCategoryService.h +++ b/WordPress/Classes/Services/PostCategoryService.h @@ -1,5 +1,5 @@ #import <Foundation/Foundation.h> -#import "LocalCoreDataService.h" +#import "CoreDataStack.h" NS_ASSUME_NONNULL_BEGIN @@ -10,12 +10,11 @@ typedef NS_ENUM(NSInteger, PostCategoryServiceErrors) { PostCategoryServiceErrorsBlogNotFound }; -@interface PostCategoryService : LocalCoreDataService +@interface PostCategoryService : NSObject -- (PostCategory *)newCategoryForBlogObjectID:(NSManagedObjectID *)blogObjectID; +@property (nonatomic, strong, readonly) id<CoreDataStack> coreDataStack; -- (nullable PostCategory *)findWithBlogObjectID:(NSManagedObjectID *)blogObjectID andCategoryID:(NSNumber *)categoryID; -- (nullable PostCategory *)findWithBlogObjectID:(NSManagedObjectID *)blogObjectID parentID:(nullable NSNumber *)parentID andName:(NSString *)name; +- (instancetype)initWithCoreDataStack:(id<CoreDataStack>)coreDataStack; /** Sync an initial batch of categories for blog via default remote parameters and responses. diff --git a/WordPress/Classes/Services/PostCategoryService.m b/WordPress/Classes/Services/PostCategoryService.m index 10c8116c8a9b..6c49a57d6f98 100644 --- a/WordPress/Classes/Services/PostCategoryService.m +++ b/WordPress/Classes/Services/PostCategoryService.m @@ -1,70 +1,27 @@ #import "PostCategoryService.h" #import "PostCategory.h" #import "Blog.h" -#import "ContextManager.h" +#import "CoreDataStack.h" +#import "WordPress-Swift.h" @import WordPressKit; NS_ASSUME_NONNULL_BEGIN @implementation PostCategoryService -- (NSError *)serviceErrorNoBlog -{ - return [NSError errorWithDomain:NSStringFromClass([self class]) - code:PostCategoryServiceErrorsBlogNotFound - userInfo:nil]; -} - -- (PostCategory *)newCategoryForBlog:(Blog *)blog -{ - PostCategory *category = [NSEntityDescription insertNewObjectForEntityForName:[PostCategory entityName] - inManagedObjectContext:self.managedObjectContext]; - category.blog = blog; - return category; -} - -- (PostCategory *)newCategoryForBlogObjectID:(NSManagedObjectID *)blogObjectID { - Blog *blog = [self blogWithObjectID:blogObjectID]; - return [self newCategoryForBlog:blog]; -} - -- (BOOL)existsName:(NSString *)name forBlogObjectID:(NSManagedObjectID *)blogObjectID withParentId:(nullable NSNumber *)parentId +- (instancetype)initWithCoreDataStack:(id<CoreDataStack>)coreDataStack { - Blog *blog = [self blogWithObjectID:blogObjectID]; - - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"(categoryName like %@) AND (parentID = %@)", name, - (parentId ? parentId : @0)]; - - NSSet *items = [blog.categories filteredSetUsingPredicate:predicate]; - - if ((items != nil) && (items.count > 0)) { - // Already exists - return YES; + if ((self = [super init])) { + _coreDataStack = coreDataStack; } - - return NO; + return self; } -- (nullable PostCategory *)findWithBlogObjectID:(NSManagedObjectID *)blogObjectID andCategoryID:(NSNumber *)categoryID -{ - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"categoryID == %@", categoryID]; - return [self findWithBlogObjectID:blogObjectID predicate:predicate]; -} - -- (nullable PostCategory *)findWithBlogObjectID:(NSManagedObjectID *)blogObjectID parentID:(nullable NSNumber *)parentID andName:(NSString *)name -{ - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"(categoryName like %@) AND (parentID = %@)", name, - (parentID ? parentID : @0)]; - return [self findWithBlogObjectID:blogObjectID predicate:predicate]; -} - -- (nullable PostCategory *)findWithBlogObjectID:(NSManagedObjectID *)blogObjectID predicate:(NSPredicate *)predicate +- (NSError *)serviceErrorNoBlog { - Blog *blog = [self blogWithObjectID:blogObjectID]; - - NSSet *results = [blog.categories filteredSetUsingPredicate:predicate]; - return [results anyObject]; - + return [NSError errorWithDomain:NSStringFromClass([self class]) + code:PostCategoryServiceErrorsBlogNotFound + userInfo:nil]; } - (void)syncCategoriesForBlog:(Blog *)blog @@ -74,22 +31,25 @@ - (void)syncCategoriesForBlog:(Blog *)blog id<TaxonomyServiceRemote> remote = [self remoteForBlog:blog]; NSManagedObjectID *blogID = blog.objectID; [remote getCategoriesWithSuccess:^(NSArray *categories) { - [self.managedObjectContext performBlock:^{ - Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogID error:nil]; + NSError * __block error = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blog = (Blog *)[context existingObjectWithID:blogID error:nil]; if (!blog) { + error = [self serviceErrorNoBlog]; + return; + } + [self mergeCategories:categories forBlog:blog inContext:context]; + } completion: ^{ + if (error) { if (failure) { - failure([self serviceErrorNoBlog]); + failure(error); + } + } else { + if (success) { + success(); } - return; } - [self mergeCategories:categories - forBlog:blog - completionHandler:^(NSArray<PostCategory *> *postCategories) { - if (success) { - success(); - } - }]; - }]; + } onQueue: dispatch_get_main_queue()]; } failure:failure]; } @@ -106,22 +66,24 @@ - (void)syncCategoriesForBlog:(Blog *)blog NSManagedObjectID *blogID = blog.objectID; [remote getCategoriesWithPaging:paging success:^(NSArray<RemotePostCategory *> *categories) { - [self.managedObjectContext performBlock:^{ - Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:blogID error:nil]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blog = (Blog *)[context existingObjectWithID:blogID error:nil]; if (!blog) { if (failure) { failure([self serviceErrorNoBlog]); } return; } - [self mergeCategories:categories - forBlog:blog - completionHandler:^(NSArray<PostCategory *> *postCategories) { - if (success) { - success(postCategories); - } + [self mergeCategories:categories forBlog:blog inContext:context]; + } completion: ^{ + if (success) { + NSManagedObjectContext *context = [self.coreDataStack mainContext]; + NSArray *postCategories = [categories wp_map:^id(RemotePostCategory *obj) { + return [PostCategory lookupWithBlogObjectID:blogID categoryID:obj.categoryID inContext:context]; }]; - }]; + success(postCategories); + } + } onQueue: dispatch_get_main_queue()]; } failure:failure]; } @@ -132,32 +94,33 @@ - (void)createCategoryWithName:(NSString *)name failure:(nullable void (^)(NSError *error))failure { NSParameterAssert(name != nil); - Blog *blog = [self blogWithObjectID:blogObjectID]; + Blog * __block blog = nil; RemotePostCategory *remoteCategory = [RemotePostCategory new]; remoteCategory.name = name; - if (parentCategoryObjectID) { - PostCategory *parent = [self categoryWithObjectID:parentCategoryObjectID]; - remoteCategory.parentID = parent.categoryID; - } + + [self.coreDataStack.mainContext performBlockAndWait:^{ + blog = [self.coreDataStack.mainContext existingObjectWithID:blogObjectID error:nil]; + if (parentCategoryObjectID) { + PostCategory *parent = [self.coreDataStack.mainContext existingObjectWithID:parentCategoryObjectID error:nil]; + remoteCategory.parentID = parent.categoryID; + } + }]; id<TaxonomyServiceRemote> remote = [self remoteForBlog:blog]; [remote createCategory:remoteCategory success:^(RemotePostCategory *receivedCategory) { - [self.managedObjectContext performBlock:^{ - Blog *blog = [self blogWithObjectID:blogObjectID]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blog = [context existingObjectWithID:blogObjectID error:nil]; if (!blog) { if (failure) { failure([self serviceErrorNoBlog]); } return; } - PostCategory *newCategory = [self newCategoryForBlog:blog]; + PostCategory *newCategory = [PostCategory createWithBlogObjectID:blogObjectID inContext:context]; newCategory.categoryID = receivedCategory.categoryID; if ([remote isKindOfClass:[TaxonomyServiceRemoteXMLRPC class]]) { - // XML-RPC only returns ID, let's fetch the new category as - // filters might change the content - [self syncCategoriesForBlog:blog success:nil failure:nil]; newCategory.categoryName = remoteCategory.name; newCategory.parentID = remoteCategory.parentID; } else { @@ -167,15 +130,23 @@ - (void)createCategoryWithName:(NSString *)name if (newCategory.parentID == nil) { newCategory.parentID = @0; } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; + } completion:^{ if (success) { + PostCategory *newCategory = [PostCategory lookupWithBlogObjectID:blogObjectID + categoryID:receivedCategory.categoryID + inContext:[self.coreDataStack mainContext]]; success(newCategory); } - }]; + if ([remote isKindOfClass:[TaxonomyServiceRemoteXMLRPC class]]) { + // XML-RPC only returns ID, let's fetch the new category as + // filters might change the content + [self syncCategoriesForBlog:blog success:nil failure:nil]; + } + } onQueue: dispatch_get_main_queue()]; } failure:failure]; } -- (void)mergeCategories:(NSArray <RemotePostCategory *> *)remoteCategories forBlog:(Blog *)blog completionHandler:(nullable void (^)(NSArray <PostCategory *> *categories))completion +- (void)mergeCategories:(NSArray <RemotePostCategory *> *)remoteCategories forBlog:(Blog *)blog inContext:(NSManagedObjectContext *)context { NSSet *remoteSet = [NSSet setWithArray:[remoteCategories valueForKey:@"categoryID"]]; NSSet *localSet = [blog.categories valueForKey:@"categoryID"]; @@ -186,63 +157,24 @@ - (void)mergeCategories:(NSArray <RemotePostCategory *> *)remoteCategories forBl NSSet *blogCategories = [blog.categories copy]; for (PostCategory *category in blogCategories) { if ([toDelete containsObject:category.categoryID]) { - category.blog = nil; - [self.managedObjectContext deleteObject:category]; + [context deleteObject:category]; } } } - + NSMutableArray *categories = [NSMutableArray arrayWithCapacity:remoteCategories.count]; - + for (RemotePostCategory *remoteCategory in remoteCategories) { - PostCategory *category = [self findWithBlogObjectID:blog.objectID andCategoryID:remoteCategory.categoryID]; + PostCategory *category = [PostCategory lookupWithBlogObjectID:blog.objectID categoryID:remoteCategory.categoryID inContext:context]; if (!category) { - category = [self newCategoryForBlog:blog]; + category = [PostCategory createWithBlogObjectID:blog.objectID inContext:context]; category.categoryID = remoteCategory.categoryID; } category.categoryName = remoteCategory.name; category.parentID = remoteCategory.parentID; - - [categories addObject:category]; - } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - - if (completion) { - completion(categories); - } -} - -- (nullable Blog *)blogWithObjectID:(nullable NSManagedObjectID *)objectID -{ - if (objectID == nil) { - return nil; - } - - NSError *error; - Blog *blog = (Blog *)[self.managedObjectContext existingObjectWithID:objectID error:&error]; - if (error) { - DDLogError(@"Error when retrieving Blog by ID: %@", error); - return nil; - } - - return blog; -} - -- (nullable PostCategory *)categoryWithObjectID:(nullable NSManagedObjectID *)objectID -{ - if (objectID == nil) { - return nil; - } - - NSError *error; - PostCategory *category = (PostCategory *)[self.managedObjectContext existingObjectWithID:objectID error:&error]; - if (error) { - DDLogError(@"Error when retrieving Category by ID: %@", error); - return nil; + [categories addObject:category]; } - - return category; } - (id<TaxonomyServiceRemote>)remoteForBlog:(Blog *)blog { diff --git a/WordPress/Classes/Services/PostCoordinator+Dashboard.swift b/WordPress/Classes/Services/PostCoordinator+Dashboard.swift new file mode 100644 index 000000000000..d4231c9c3fbe --- /dev/null +++ b/WordPress/Classes/Services/PostCoordinator+Dashboard.swift @@ -0,0 +1,31 @@ +import Foundation + +/// This extension emits notification so the posts card on +/// the Dashboard can listen to them +/// +/// Basically this is needed to show a card when a post is +/// scheduled or drafted and this card is not appearing +extension PostCoordinator { + func notifyNewPostScheduled() { + NotificationCenter.default.post(name: .newPostScheduled, object: nil) + } + + func notifyNewPostCreated() { + NotificationCenter.default.post(name: .newPostCreated, object: nil) + } + + func notifyNewPostPublished() { + NotificationCenter.default.post(name: .newPostPublished, object: nil) + } +} + +extension NSNotification.Name { + /// Fired when a post is scheduled + static let newPostScheduled = NSNotification.Name("NewPostScheduled") + + /// Fired when a draft is saved + static let newPostCreated = NSNotification.Name("NewPostCreated") + + /// Fired when a post is published + static let newPostPublished = NSNotification.Name("NewPostPublished") +} diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index 18240e35fd13..a26a3ba81441 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -11,9 +11,11 @@ class PostCoordinator: NSObject { @objc static let shared = PostCoordinator() - private let backgroundContext: NSManagedObjectContext + private let coreDataStack: CoreDataStack - private let mainContext: NSManagedObjectContext + private var mainContext: NSManagedObjectContext { + coreDataStack.mainContext + } private let queue = DispatchQueue(label: "org.wordpress.postcoordinator") @@ -21,7 +23,6 @@ class PostCoordinator: NSObject { private let mediaCoordinator: MediaCoordinator - private let backgroundService: PostService private let mainService: PostService private let failedPostsFetcher: FailedPostsFetcher @@ -30,21 +31,15 @@ class PostCoordinator: NSObject { // MARK: - Initializers init(mainService: PostService? = nil, - backgroundService: PostService? = nil, mediaCoordinator: MediaCoordinator? = nil, failedPostsFetcher: FailedPostsFetcher? = nil, - actionDispatcherFacade: ActionDispatcherFacade = ActionDispatcherFacade()) { - let contextManager = ContextManager.sharedInstance() - - let mainContext = contextManager.mainContext - let backgroundContext = contextManager.newDerivedContext() - backgroundContext.automaticallyMergesChangesFromParent = true + actionDispatcherFacade: ActionDispatcherFacade = ActionDispatcherFacade(), + coreDataStack: CoreDataStack = ContextManager.sharedInstance()) { + self.coreDataStack = coreDataStack - self.mainContext = mainContext - self.backgroundContext = backgroundContext + let mainContext = self.coreDataStack.mainContext self.mainService = mainService ?? PostService(managedObjectContext: mainContext) - self.backgroundService = backgroundService ?? PostService(managedObjectContext: backgroundContext) self.mediaCoordinator = mediaCoordinator ?? MediaCoordinator.shared self.failedPostsFetcher = failedPostsFetcher ?? FailedPostsFetcher(mainContext) @@ -60,6 +55,8 @@ class PostCoordinator: NSObject { defaultFailureNotice: Notice? = nil, completion: ((Result<AbstractPost, Error>) -> ())? = nil) { + notifyNewPostCreated() + prepareToSave(postToSave, automatedRetry: automatedRetry) { result in switch result { case .success(let post): @@ -93,6 +90,7 @@ class PostCoordinator: NSObject { func publish(_ post: AbstractPost) { if post.status == .draft { post.status = .publish + post.isFirstTimePublish = true } if post.status != .scheduled { @@ -117,7 +115,7 @@ class PostCoordinator: NSObject { /// - Parameter then: a block to perform after post is ready to be saved /// private func prepareToSave(_ post: AbstractPost, automatedRetry: Bool = false, - then completion: @escaping (Result<AbstractPost, Error>) -> ()) { + then completion: @escaping (Result<AbstractPost, SavingError>) -> ()) { post.autoUploadAttemptsCount = NSNumber(value: automatedRetry ? post.autoUploadAttemptsCount.intValue + 1 : 0) guard mediaCoordinator.uploadMedia(for: post, automatedRetry: automatedRetry) else { @@ -136,61 +134,22 @@ class PostCoordinator: NSObject { return } - let handleSingleMediaFailure = { [weak self] in - guard let `self` = self, - self.isObserving(post: post) else { - return - } - - // One of the media attached to the post has already failed. We're changing the - // status of the post to .failed so we don't need to observe for other failed media - // anymore. If we do, we'll receive more notifications and we'll be calling - // completion() multiple times. - self.removeObserver(for: post) - - self.change(post: post, status: .failed) { savedPost in - completion(.failure(SavingError.mediaFailure(savedPost))) + // Ensure that all synced media references are up to date + post.media.forEach { media in + if media.remoteStatus == .sync { + self.updateReferences(to: media, in: post) } } - let uuid = mediaCoordinator.addObserver({ [weak self](media, state) in - guard let `self` = self else { - return - } - switch state { - case .ended: - let successHandler = { - self.updateReferences(to: media, in: post) - // Let's check if media uploading is still going, if all finished with success then we can upload the post - if !self.mediaCoordinator.isUploadingMedia(for: post) && !post.hasFailedMedia { - self.removeObserver(for: post) - completion(.success(post)) - } - } - switch media.mediaType { - case .video: - EditorMediaUtility.fetchRemoteVideoURL(for: media, in: post) { (result) in - switch result { - case .failure: - handleSingleMediaFailure() - case .success(let value): - media.remoteURL = value.videoURL.absoluteString - successHandler() - } - } - default: - successHandler() - } - case .failed: - handleSingleMediaFailure() - default: - DDLogInfo("Post Coordinator -> Media state: \(state)") - } - }, forMediaFor: post) - + let uuid = observeMedia(for: post, completion: completion) trackObserver(receipt: uuid, for: post) return + } else { + // Ensure that all media references are up to date + post.media.forEach { media in + self.updateReferences(to: media, in: post) + } } completion(.success(post)) @@ -204,9 +163,9 @@ class PostCoordinator: NSObject { return post.remoteStatus == .pushing } - func posts(for blog: Blog, wichTitleContains value: String) -> NSFetchedResultsController<AbstractPost> { + func posts(for blog: Blog, containsTitle title: String, excludingPostIDs excludedPostIDs: [Int] = [], entityName: String? = nil, publishedOnly: Bool = false) -> NSFetchedResultsController<AbstractPost> { let context = self.mainContext - let fetchRequest = NSFetchRequest<AbstractPost>(entityName: "AbstractPost") + let fetchRequest = NSFetchRequest<AbstractPost>(entityName: entityName ?? AbstractPost.entityName()) fetchRequest.sortDescriptors = [NSSortDescriptor(key: "date_created_gmt", ascending: false)] @@ -214,8 +173,14 @@ class PostCoordinator: NSObject { let urlPredicate = NSPredicate(format: "permaLink != NULL") let noVersionPredicate = NSPredicate(format: "original == NULL") var compoundPredicates = [blogPredicate, urlPredicate, noVersionPredicate] - if !value.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty { - compoundPredicates.append(NSPredicate(format: "postTitle contains[c] %@", value)) + if !title.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty { + compoundPredicates.append(NSPredicate(format: "postTitle contains[c] %@", title)) + } + if !excludedPostIDs.isEmpty { + compoundPredicates.append(NSPredicate(format: "NOT (postID IN %@)", excludedPostIDs)) + } + if publishedOnly { + compoundPredicates.append(NSPredicate(format: "\(BasePost.statusKeyPath) == '\(PostStatusPublish)'")) } let resultPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: compoundPredicates) @@ -258,7 +223,7 @@ class PostCoordinator: NSObject { /// The main cause of wrong status is the app being killed while uploads of posts are happening. /// @objc func refreshPostStatus() { - backgroundService.refreshPostStatus() + Post.refreshStatus(with: coreDataStack) } private func upload(post: AbstractPost, forceDraftIfCreating: Bool, completion: ((Result<AbstractPost, Error>) -> ())? = nil) { @@ -270,6 +235,12 @@ class PostCoordinator: NSObject { print("Post Coordinator -> upload succesfull: \(String(describing: uploadedPost.content))") + if uploadedPost.isScheduled() { + self?.notifyNewPostScheduled() + } else if uploadedPost.isPublished() { + self?.notifyNewPostPublished() + } + SearchManager.shared.indexItem(uploadedPost) let model = PostNoticeViewModel(post: uploadedPost) @@ -285,12 +256,81 @@ class PostCoordinator: NSObject { }) } + func add(assets: [ExportableAsset], to post: AbstractPost) -> [Media?] { + let media = assets.map { asset in + return mediaCoordinator.addMedia(from: asset, to: post) + } + return media + } + + private func observeMedia(for post: AbstractPost, completion: @escaping (Result<AbstractPost, SavingError>) -> ()) -> UUID { + // Only observe if we're not already + let handleSingleMediaFailure = { [weak self] in + guard let `self` = self, + self.isObserving(post: post) else { + return + } + + // One of the media attached to the post has already failed. We're changing the + // status of the post to .failed so we don't need to observe for other failed media + // anymore. If we do, we'll receive more notifications and we'll be calling + // completion() multiple times. + self.removeObserver(for: post) + + self.change(post: post, status: .failed) { savedPost in + completion(.failure(SavingError.mediaFailure(savedPost))) + } + } + + return mediaCoordinator.addObserver({ [weak self](media, state) in + guard let `self` = self else { + return + } + switch state { + case .ended: + let successHandler = { + self.updateReferences(to: media, in: post) + // Let's check if media uploading is still going, if all finished with success then we can upload the post + if !self.mediaCoordinator.isUploadingMedia(for: post) && !post.hasFailedMedia { + self.removeObserver(for: post) + completion(.success(post)) + } + } + switch media.mediaType { + case .video: + EditorMediaUtility.fetchRemoteVideoURL(for: media, in: post) { (result) in + switch result { + case .failure: + handleSingleMediaFailure() + case .success(let videoURL): + media.remoteURL = videoURL.absoluteString + successHandler() + } + } + default: + successHandler() + } + case .failed: + handleSingleMediaFailure() + default: + DDLogInfo("Post Coordinator -> Media state: \(state)") + } + }, forMediaFor: post) + } + private func updateReferences(to media: Media, in post: AbstractPost) { guard var postContent = post.content, let mediaID = media.mediaID?.intValue, let remoteURLStr = media.remoteURL else { return } + var imageURL = remoteURLStr + + if let remoteLargeURL = media.remoteLargeURL { + imageURL = remoteLargeURL + } else if let remoteMediumURL = media.remoteMediumURL { + imageURL = remoteMediumURL + } let mediaLink = media.link let mediaUploadID = media.uploadID @@ -301,27 +341,55 @@ class PostCoordinator: NSObject { var gutenbergProcessors = [Processor]() var aztecProcessors = [Processor]() + // File block can upload any kind of media. + let gutenbergFileProcessor = GutenbergFileUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + gutenbergProcessors.append(gutenbergFileProcessor) + if media.mediaType == .image { - let gutenbergImgPostUploadProcessor = GutenbergImgUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + let gutenbergImgPostUploadProcessor = GutenbergImgUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: imageURL) gutenbergProcessors.append(gutenbergImgPostUploadProcessor) - let gutenbergGalleryPostUploadProcessor = GutenbergGalleryUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr, mediaLink: mediaLink) + let gutenbergGalleryPostUploadProcessor = GutenbergGalleryUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: imageURL, mediaLink: mediaLink) gutenbergProcessors.append(gutenbergGalleryPostUploadProcessor) let imgPostUploadProcessor = ImgUploadProcessor(mediaUploadID: mediaUploadID, remoteURLString: remoteURLStr, width: media.width?.intValue, height: media.height?.intValue) aztecProcessors.append(imgPostUploadProcessor) + + let gutenbergCoverPostUploadProcessor = GutenbergCoverUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + gutenbergProcessors.append(gutenbergCoverPostUploadProcessor) + + let gutenbergMediaFilesUploadProcessor = GutenbergMediaFilesUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + gutenbergProcessors.append(gutenbergMediaFilesUploadProcessor) + } else if media.mediaType == .video { let gutenbergVideoPostUploadProcessor = GutenbergVideoUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) gutenbergProcessors.append(gutenbergVideoPostUploadProcessor) + let gutenbergCoverPostUploadProcessor = GutenbergCoverUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + gutenbergProcessors.append(gutenbergCoverPostUploadProcessor) + let videoPostUploadProcessor = VideoUploadProcessor(mediaUploadID: mediaUploadID, remoteURLString: remoteURLStr, videoPressID: media.videopressGUID) aztecProcessors.append(videoPostUploadProcessor) + + let gutenbergMediaFilesUploadProcessor = GutenbergMediaFilesUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + gutenbergProcessors.append(gutenbergMediaFilesUploadProcessor) + + if let videoPressGUID = media.videopressGUID { + let gutenbergVideoPressUploadProcessor = GutenbergVideoPressUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, videoPressGUID: videoPressGUID) + gutenbergProcessors.append(gutenbergVideoPressUploadProcessor) + } + + } else if media.mediaType == .audio { + let gutenbergAudioProcessor = GutenbergAudioUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + gutenbergProcessors.append(gutenbergAudioProcessor) } else if let remoteURL = URL(string: remoteURLStr) { let documentTitle = remoteURL.lastPathComponent let documentUploadProcessor = DocumentUploadProcessor(mediaUploadID: mediaUploadID, remoteURLString: remoteURLStr, title: documentTitle) aztecProcessors.append(documentUploadProcessor) } + + // Gutenberg processors need to run first because they are more specific/and target only content inside specific blocks postContent = gutenbergProcessors.reduce(postContent) { (content, processor) -> String in return processor.process(content) @@ -368,7 +436,7 @@ class PostCoordinator: NSObject { context.perform { if status == .failed { - self.mainService.markAsFailedAndDraftIfNeeded(post: post) + post.markAsFailedAndDraftIfNeeded() } else { post.remoteStatus = status } @@ -442,24 +510,22 @@ extension PostCoordinator: Uploader { extension PostCoordinator { /// Fetches failed posts that should be retried when there is an internet connection. class FailedPostsFetcher { - private let postService: PostService - - init(_ postService: PostService) { - self.postService = postService - } + private let managedObjectContext: NSManagedObjectContext init(_ managedObjectContext: NSManagedObjectContext) { - postService = PostService(managedObjectContext: managedObjectContext) + self.managedObjectContext = managedObjectContext } func postsAndRetryActions(result: @escaping ([AbstractPost: PostAutoUploadInteractor.AutoUploadAction]) -> Void) { let interactor = PostAutoUploadInteractor() + managedObjectContext.perform { + let request = NSFetchRequest<AbstractPost>(entityName: NSStringFromClass(AbstractPost.self)) + request.predicate = NSPredicate(format: "remoteStatusNumber == %d", AbstractPostRemoteStatus.failed.rawValue) + let posts = (try? self.managedObjectContext.fetch(request)) ?? [] - postService.getFailedPosts { posts in let postsAndActions = posts.reduce(into: [AbstractPost: PostAutoUploadInteractor.AutoUploadAction]()) { result, post in result[post] = interactor.autoUploadAction(for: post) } - result(postsAndActions) } } diff --git a/WordPress/Classes/Services/PostService+Likes.swift b/WordPress/Classes/Services/PostService+Likes.swift new file mode 100644 index 000000000000..fccfda6d5cbd --- /dev/null +++ b/WordPress/Classes/Services/PostService+Likes.swift @@ -0,0 +1,122 @@ +extension PostService { + + /** + Fetches a list of users from remote that liked the post with the given IDs. + + @param postID The ID of the post to fetch likes for + @param siteID The ID of the site that contains the post + @param count Number of records to retrieve. Optional. Defaults to the endpoint max of 90. + @param before Filter results to likes before this date/time. Optional. + @param excludingIDs An array of user IDs to exclude from the returned results. Optional. + @param purgeExisting Indicates if existing Likes for the given post and site should be purged before + new ones are created. Defaults to true. + @param success A success block returning: + - Array of LikeUser + - Total number of likes for the given post + - Number of likes per fetch + @param failure A failure block + */ + func getLikesFor(postID: NSNumber, + siteID: NSNumber, + count: Int = 90, + before: String? = nil, + excludingIDs: [NSNumber]? = nil, + purgeExisting: Bool = true, + success: @escaping (([LikeUser], Int, Int) -> Void), + failure: @escaping ((Error?) -> Void)) { + + guard let remote = postServiceRemoteFactory.restRemoteFor(siteID: siteID, context: managedObjectContext) else { + DDLogError("Unable to create a REST remote for posts.") + failure(nil) + return + } + + remote.getLikesForPostID(postID, + count: NSNumber(value: count), + before: before, + excludeUserIDs: excludingIDs, + success: { remoteLikeUsers, totalLikes in + self.createNewUsers(from: remoteLikeUsers, + postID: postID, + siteID: siteID, + purgeExisting: purgeExisting) { + let users = self.likeUsersFor(postID: postID, siteID: siteID) + success(users, totalLikes.intValue, count) + LikeUserHelper.purgeStaleLikes() + } + }, failure: { error in + DDLogError(String(describing: error)) + failure(error) + }) + } + + /** + Fetches a list of users from Core Data that liked the post with the given IDs. + + @param postID The ID of the post to fetch likes for. + @param siteID The ID of the site that contains the post. + @param after Filter results to likes after this Date. + */ + func likeUsersFor(postID: NSNumber, siteID: NSNumber, after: Date? = nil) -> [LikeUser] { + let request = LikeUser.fetchRequest() as NSFetchRequest<LikeUser> + + request.predicate = { + if let after = after { + // The date comparison is 'less than' because Likes are in descending order. + return NSPredicate(format: "likedSiteID = %@ AND likedPostID = %@ AND dateLiked < %@", siteID, postID, after as CVarArg) + } + + return NSPredicate(format: "likedSiteID = %@ AND likedPostID = %@", siteID, postID) + }() + + request.sortDescriptors = [NSSortDescriptor(key: "dateLiked", ascending: false)] + + if let users = try? managedObjectContext.fetch(request) { + return users + } + + return [LikeUser]() + } + +} + +private extension PostService { + + func createNewUsers(from remoteLikeUsers: [RemoteLikeUser]?, + postID: NSNumber, + siteID: NSNumber, + purgeExisting: Bool, + onComplete: @escaping (() -> Void)) { + + guard let remoteLikeUsers = remoteLikeUsers, + !remoteLikeUsers.isEmpty else { + DispatchQueue.main.async { + onComplete() + } + return + } + + ContextManager.shared.performAndSave({ derivedContext in + let likers = remoteLikeUsers.map { remoteUser in + LikeUserHelper.createOrUpdateFrom(remoteUser: remoteUser, context: derivedContext) + } + + if purgeExisting { + self.deleteExistingUsersFor(postID: postID, siteID: siteID, from: derivedContext, likesToKeep: likers) + } + }, completion: onComplete, on: .main) + } + + func deleteExistingUsersFor(postID: NSNumber, siteID: NSNumber, from context: NSManagedObjectContext, likesToKeep: [LikeUser]) { + let request = LikeUser.fetchRequest() as NSFetchRequest<LikeUser> + request.predicate = NSPredicate(format: "likedSiteID = %@ AND likedPostID = %@ AND NOT (self IN %@)", siteID, postID, likesToKeep) + + do { + let users = try context.fetch(request) + users.forEach { context.delete($0) } + } catch { + DDLogError("Error fetching post Like Users: \(error)") + } + } + +} diff --git a/WordPress/Classes/Services/PostService+MarkAsFailedAndDraftIfNeeded.swift b/WordPress/Classes/Services/PostService+MarkAsFailedAndDraftIfNeeded.swift deleted file mode 100644 index a7f05845ffd6..000000000000 --- a/WordPress/Classes/Services/PostService+MarkAsFailedAndDraftIfNeeded.swift +++ /dev/null @@ -1,36 +0,0 @@ -@objc extension PostService { - - // MARK: - Updating the Remote Status - - /// Updates the post after an upload failure. - /// - /// Local-only pages will be reverted back to `.draft` to avoid scenarios like this: - /// - /// 1. A locally published page upload failed - /// 2. The user presses the Page List's Retry button. - /// 3. The page upload is retried and the page is **published**. - /// - /// This is an unexpected behavior and can be surprising for the user. We'd want the user to - /// explicitly press on a “Publish” button instead. - /// - /// Posts' statuses are kept as is because we support automatic uploading of posts. - /// - /// - Important: This logic could have been placed in the setter for `remoteStatus`, but it's my belief - /// that our code will be much more resilient if we decouple the act of setting the `remoteStatus` value - /// and the logic behind processing an upload failure. In fact I think the `remoteStatus` setter should - /// eventually be made private. - /// - SeeAlso: PostCoordinator.resume - /// - func markAsFailedAndDraftIfNeeded(post: AbstractPost) { - guard post.remoteStatus != .failed else { - return - } - - post.remoteStatus = .failed - - if !post.hasRemote() && post is Page { - post.status = .draft - post.dateModified = Date() - } - } -} diff --git a/WordPress/Classes/Services/PostService+RefreshStatus.swift b/WordPress/Classes/Services/PostService+RefreshStatus.swift deleted file mode 100644 index ad77b0d9e4f1..000000000000 --- a/WordPress/Classes/Services/PostService+RefreshStatus.swift +++ /dev/null @@ -1,36 +0,0 @@ -extension PostService { - - /// This method checks the status of all post objects and updates them to the correct status if needed. - /// The main cause of wrong status is the app being killed while uploads of posts are happening. - /// - /// - Parameters: - /// - onCompletion: block to invoke when status update is finished. - /// - onError: block to invoke if any error occurs while the update is being made. - /// - func refreshPostStatus(onCompletion: (() -> Void)? = nil, onError: ((Error) -> Void)? = nil) { - self.managedObjectContext.perform { - let fetch = NSFetchRequest<Post>(entityName: Post.classNameWithoutNamespaces()) - let pushingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: AbstractPostRemoteStatus.pushing.rawValue)) - let processingPredicate = NSPredicate(format: "remoteStatusNumber = %@", NSNumber(value: AbstractPostRemoteStatus.pushingMedia.rawValue)) - fetch.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: [pushingPredicate, processingPredicate]) - do { - let postsPushing = try self.managedObjectContext.fetch(fetch) - for post in postsPushing { - self.markAsFailedAndDraftIfNeeded(post: post) - } - - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { - DispatchQueue.main.async { - onCompletion?() - } - }) - - } catch { - DDLogError("Error while attempting to update posts status: \(error.localizedDescription)") - DispatchQueue.main.async { - onError?(error) - } - } - } - } -} diff --git a/WordPress/Classes/Services/PostService+Revisions.swift b/WordPress/Classes/Services/PostService+Revisions.swift index 6c6d4fe035af..0b58259fa6c3 100644 --- a/WordPress/Classes/Services/PostService+Revisions.swift +++ b/WordPress/Classes/Services/PostService+Revisions.swift @@ -3,14 +3,9 @@ import Foundation extension PostService { - /// PostService API to get the revisions list - /// - /// - Parameters: - /// - post: A valid abstract post - /// - success: The success block accepts an optional list of Revisions - /// - failure: The failure block accepts an optional error + /// PostService API to get the revisions list and store them into the Core Data data store. func getPostRevisions(for post: AbstractPost, - success: @escaping ([Revision]?) -> Void, + success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { guard let blogId = post.blog.dotComID, let postId = post.postID, @@ -24,12 +19,12 @@ extension PostService { postId: postId.intValue, success: { (remoteRevisions) in self.managedObjectContext.perform { - let revisions = self.syncPostRevisions(from: remoteRevisions ?? [], - for: postId.intValue, - with: blogId.intValue) - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { - success(revisions) - }) + let _ = self.syncPostRevisions( + from: remoteRevisions ?? [], + for: postId.intValue, + with: blogId.intValue + ) + ContextManager.sharedInstance().save(self.managedObjectContext, completion: success, on: .main) } }, failure: failure) } diff --git a/WordPress/Classes/Services/PostService+UnattachedMedia.swift b/WordPress/Classes/Services/PostService+UnattachedMedia.swift index ec0146559466..f8f415ca2874 100644 --- a/WordPress/Classes/Services/PostService+UnattachedMedia.swift +++ b/WordPress/Classes/Services/PostService+UnattachedMedia.swift @@ -14,7 +14,7 @@ extension PostService { } let mediaService = MediaService(managedObjectContext: self.managedObjectContext) - mediaService.updateMedia(mediaToUpdate, overallSuccess: { + mediaService.updateMedia(mediaToUpdate, fieldsToUpdate: ["postID"], overallSuccess: { ContextManager.sharedInstance().save(self.managedObjectContext) success() }) { error in diff --git a/WordPress/Classes/Services/PostService.h b/WordPress/Classes/Services/PostService.h index 8c0989308a23..a774abaf52f4 100644 --- a/WordPress/Classes/Services/PostService.h +++ b/WordPress/Classes/Services/PostService.h @@ -7,6 +7,7 @@ @class Post; @class Page; @class RemotePost; +@class RemoteUser; @class PostServiceRemoteFactory; @class PostServiceUploadingList; @@ -24,16 +25,12 @@ extern const NSUInteger PostServiceDefaultNumberToSync; @interface PostService : LocalCoreDataService +// This is public so it can be accessed from Swift extensions. +@property (nonnull, strong, nonatomic) PostServiceRemoteFactory *postServiceRemoteFactory; + - (instancetype)initWithManagedObjectContext:(NSManagedObjectContext *)context postServiceRemoteFactory:(PostServiceRemoteFactory *)postServiceRemoteFactory NS_DESIGNATED_INITIALIZER; -- (Post *)createDraftPostForBlog:(Blog *)blog; -- (Page *)createDraftPageForBlog:(Blog *)blog; - -- (AbstractPost *)findPostWithID:(NSNumber *)postID inBlog:(Blog *)blog; - -- (NSUInteger)countPostsWithoutRemote; - /** Sync a specific post from the API @@ -47,13 +44,6 @@ extern const NSUInteger PostServiceDefaultNumberToSync; success:(void (^)(AbstractPost *post))success failure:(void (^)(NSError *))failure; -/** - Get all posts that failed to upload. - - @param result a block that will be invoked to return the requested posts. - */ -- (void)getFailedPosts:(nonnull void (^)( NSArray<AbstractPost *>* _Nonnull posts))result; - /** Sync an initial batch of posts from the specified blog. Please note that success and/or failure are called in the context of the @@ -170,6 +160,13 @@ forceDraftIfCreating:(BOOL)forceDraftIfCreating success:(nullable void (^)(void))success failure:(void (^)(NSError * _Nullable error))failure; +/** + Creates a RemotePost from an AbstractPost to be used for API calls. + + @param post The AbstractPost used to create the RemotePost + */ +- (RemotePost *)remotePostWithPost:(AbstractPost *)post; + @end NS_ASSUME_NONNULL_END diff --git a/WordPress/Classes/Services/PostService.m b/WordPress/Classes/Services/PostService.m index 72247aaf3690..398a1076507a 100644 --- a/WordPress/Classes/Services/PostService.m +++ b/WordPress/Classes/Services/PostService.m @@ -2,7 +2,7 @@ #import "Coordinate.h" #import "PostCategory.h" #import "PostCategoryService.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "CommentService.h" #import "MediaService.h" #import "Media.h" @@ -19,8 +19,6 @@ @interface PostService () -@property (nonnull, strong, nonatomic) PostServiceRemoteFactory *postServiceRemoteFactory; - @end @implementation PostService @@ -38,72 +36,6 @@ - (instancetype)initWithManagedObjectContext:(NSManagedObjectContext *)context return self; } -- (Post *)createPostForBlog:(Blog *)blog { - NSAssert(self.managedObjectContext == blog.managedObjectContext, @"Blog's context should be the the same as the service's"); - Post *post = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Post class]) inManagedObjectContext:self.managedObjectContext]; - post.blog = blog; - post.remoteStatus = AbstractPostRemoteStatusSync; - PostCategoryService *postCategoryService = [[PostCategoryService alloc] initWithManagedObjectContext:self.managedObjectContext]; - - if (blog.settings.defaultCategoryID && blog.settings.defaultCategoryID.integerValue != PostCategoryUncategorized) { - PostCategory *category = [postCategoryService findWithBlogObjectID:blog.objectID andCategoryID:blog.settings.defaultCategoryID]; - if (category) { - [post addCategoriesObject:category]; - } - } - - post.postFormat = blog.settings.defaultPostFormat; - post.postType = Post.typeDefaultIdentifier; - - [[ContextManager sharedInstance] obtainPermanentIDForObject:post]; - - return post; -} - -- (Post *)createDraftPostForBlog:(Blog *)blog { - Post *post = [self createPostForBlog:blog]; - [self initializeDraft:post]; - return post; -} - -- (Page *)createPageForBlog:(Blog *)blog { - NSAssert(self.managedObjectContext == blog.managedObjectContext, @"Blog's context should be the the same as the service's"); - Page *page = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([Page class]) inManagedObjectContext:self.managedObjectContext]; - page.blog = blog; - page.date_created_gmt = [NSDate date]; - page.remoteStatus = AbstractPostRemoteStatusSync; - - [[ContextManager sharedInstance] obtainPermanentIDForObject:page]; - - return page; -} - -- (Page *)createDraftPageForBlog:(Blog *)blog { - Page *page = [self createPageForBlog:blog]; - [self initializeDraft:page]; - return page; -} - - -- (void)getFailedPosts:(void (^)( NSArray<AbstractPost *>* posts))result { - [self.managedObjectContext performBlock:^{ - NSString *entityName = NSStringFromClass([AbstractPost class]); - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:entityName]; - - request.predicate = [NSPredicate predicateWithFormat:@"remoteStatusNumber == %d", AbstractPostRemoteStatusFailed]; - - NSError *error = nil; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - - if (!results) { - result(@[]); - } else { - result(results); - } - }]; -} - - - (void)getPostWithID:(NSNumber *)postID forBlog:(Blog *)blog success:(void (^)(AbstractPost *post))success @@ -119,10 +51,16 @@ - (void)getPostWithID:(NSNumber *)postID return; } if (remotePost) { - AbstractPost *post = [self findPostWithID:postID inBlog:blog]; + AbstractPost *post = [blog lookupPostWithID:postID inContext:self.managedObjectContext]; + if (!post) { - post = [self createPostForBlog:blog]; + if ([remotePost.type isEqualToString:PostServiceTypePage]) { + post = [blog createPage]; + } else { + post = [blog createPost]; + } } + [self updatePost:post withRemotePost:remotePost]; [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; @@ -260,6 +198,8 @@ - (void)uploadPost:(AbstractPost *)post // Add the post to the uploading queue list [self.uploadingList uploading:postObjectID]; + + BOOL isFirstTimePublish = post.isFirstTimePublish; void (^successBlock)(RemotePost *post) = ^(RemotePost *post) { [self.managedObjectContext performBlock:^{ @@ -277,6 +217,7 @@ - (void)uploadPost:(AbstractPost *)post } } + postInContext.isFirstTimePublish = isFirstTimePublish; [self updatePost:postInContext withRemotePost:post]; postInContext.remoteStatus = AbstractPostRemoteStatusSync; @@ -307,7 +248,7 @@ - (void)uploadPost:(AbstractPost *)post [self.managedObjectContext performBlock:^{ Post *postInContext = (Post *)[self.managedObjectContext existingObjectWithID:postObjectID error:nil]; if (postInContext) { - [self markAsFailedAndDraftIfNeededWithPost:postInContext]; + [postInContext markAsFailedAndDraftIfNeeded]; [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; } if (failure) { @@ -327,13 +268,32 @@ - (void)uploadPost:(AbstractPost *)post if (forceDraftIfCreating) { remotePost.status = PostStatusDraft; } - - [remote createPost:remotePost - success:successBlock - failure:failureBlock]; + [self createPost:remotePost + remote:remote + success:successBlock + failure:failureBlock]; } } + +/// Creates new post on the server. +/// If the post type is scheduled, another call to update the post is made after creation to fix the modified date. +- (void)createPost:(RemotePost *)post + remote:(id<PostServiceRemote>)remote + success:(void (^)(RemotePost *post))success + failure:(void (^)(NSError *error))failure; +{ + [remote createPost:post success:^(RemotePost *post) { + if ([post.status isEqualToString:PostStatusScheduled]) { + [remote updatePost:post success:success failure:failure]; + } + else { + success(post); + } + + } failure:failure]; +} + #pragma mark - Autosave Related typedef void (^AutosaveFailureBlock)(NSError *error); @@ -364,7 +324,7 @@ - (AutosaveFailureBlock)wrappedAutosaveFailureBlock:(AbstractPost *)post failure - (AutosaveSuccessBlock)wrappedAutosaveSuccessBlock:(NSManagedObjectID *)postObjectID success:(void (^)(AbstractPost *post, NSString *previewURL))success { - return ^(RemotePost *post, NSString *previewURL) { + return ^(RemotePost *__unused post, NSString *previewURL) { [self.managedObjectContext performBlock:^{ AbstractPost *postInContext = (AbstractPost *)[self.managedObjectContext existingObjectWithID:postObjectID error:nil]; if (postInContext) { @@ -673,12 +633,6 @@ - (void)restoreRemotePostWithPost:(AbstractPost*)post #pragma mark - Helpers -- (void)initializeDraft:(AbstractPost *)post { - post.remoteStatus = AbstractPostRemoteStatusLocal; - post.dateModified = [NSDate date]; - post.status = PostStatusDraft; -} - - (void)mergePosts:(NSArray <RemotePost *> *)remotePosts ofType:(NSString *)syncPostType withStatuses:(NSArray *)statuses @@ -689,14 +643,14 @@ - (void)mergePosts:(NSArray <RemotePost *> *)remotePosts { NSMutableArray *posts = [NSMutableArray arrayWithCapacity:remotePosts.count]; for (RemotePost *remotePost in remotePosts) { - AbstractPost *post = [self findPostWithID:remotePost.postID inBlog:blog]; + AbstractPost *post = [blog lookupPostWithID:remotePost.postID inContext:self.managedObjectContext]; if (!post) { if ([remotePost.type isEqualToString:PostServiceTypePage]) { // Create a Page entity for posts with a remote type of "page" - post = [self createPageForBlog:blog]; + post = [blog createPage]; } else { // Create a Post entity for any other posts that have a remote post type of "post" or a custom post type. - post = [self createPostForBlog:blog]; + post = [blog createPost]; } } [self updatePost:post withRemotePost:remotePost]; @@ -753,21 +707,6 @@ - (void)mergePosts:(NSArray <RemotePost *> *)remotePosts } } -- (AbstractPost *)findPostWithID:(NSNumber *)postID inBlog:(Blog *)blog { - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([AbstractPost class])]; - request.predicate = [NSPredicate predicateWithFormat:@"blog = %@ AND original = NULL AND postID = %@", blog, postID]; - NSArray *posts = [self.managedObjectContext executeFetchRequest:request error:nil]; - return [posts firstObject]; -} - -- (NSUInteger)countPostsWithoutRemote -{ - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([AbstractPost class])]; - request.predicate = [NSPredicate predicateWithFormat:@"postID = NULL OR postID <= 0"]; - - return [self.managedObjectContext countForFetchRequest:request error:nil]; -} - - (NSDictionary *)remoteSyncParametersDictionaryForRemote:(nonnull id <PostServiceRemote>)remote withOptions:(nonnull PostServiceSyncOptions *)options { @@ -777,7 +716,10 @@ - (NSDictionary *)remoteSyncParametersDictionaryForRemote:(nonnull id <PostServi - (void)updatePost:(AbstractPost *)post withRemotePost:(RemotePost *)remotePost { NSNumber *previousPostID = post.postID; post.postID = remotePost.postID; - post.author = remotePost.authorDisplayName; + // Used to populate author information for self-hosted sites. + BlogAuthor *author = [post.blog getAuthorWithId:remotePost.authorID]; + + post.author = remotePost.authorDisplayName ?: author.displayName; post.authorID = remotePost.authorID; post.date_created_gmt = remotePost.date; post.dateModified = remotePost.dateModified; @@ -797,7 +739,7 @@ - (void)updatePost:(AbstractPost *)post withRemotePost:(RemotePost *)remotePost if (post.pathForDisplayImage.length == 0) { [post updatePathForDisplayImageBasedOnContent]; } - post.authorAvatarURL = remotePost.authorAvatarURL; + post.authorAvatarURL = remotePost.authorAvatarURL ?: author.avatarURL; post.mt_excerpt = remotePost.excerpt; post.wp_slug = remotePost.slug; post.suggested_slug = remotePost.suggestedSlug; @@ -867,9 +809,6 @@ - (void)updatePost:(AbstractPost *)post withRemotePost:(RemotePost *)remotePost disabledPublicizeConnections[keyringConnectionID] = keyringConnectionData; } } - postPost.geolocation = geolocation; - postPost.latitudeID = latitudeID; - postPost.longitudeID = longitudeID; postPost.publicID = publicID; postPost.publicizeMessage = publicizeMessage; postPost.publicizeMessageID = publicizeMessageID; @@ -894,6 +833,11 @@ - (RemotePost *)remotePostWithPost:(AbstractPost *)post remotePost.password = post.password; remotePost.type = @"post"; remotePost.authorAvatarURL = post.authorAvatarURL; + // If a Post's authorID is 0 (the default Core Data value) + // or nil, don't send it to the API. + if (post.authorID.integerValue != 0) { + remotePost.authorID = post.authorID; + } remotePost.excerpt = post.mt_excerpt; remotePost.slug = post.wp_slug; @@ -911,7 +855,7 @@ - (RemotePost *)remotePostWithPost:(AbstractPost *)post remotePost.categories = [self remoteCategoriesForPost:postPost]; remotePost.metadata = [self remoteMetadataForPost:postPost]; - // Because we can't get what's the self-hosted non JetPack site capabilities + // Because we can't get what's the self-hosted non Jetpack site capabilities // only Admin users are allowed to set a post as sticky. // This doesn't affect WPcom sites. // @@ -941,51 +885,14 @@ - (RemotePostCategory *)remoteCategoryWithCategory:(PostCategory *)category } - (NSArray *)remoteMetadataForPost:(Post *)post { - NSMutableArray *metadata = [NSMutableArray arrayWithCapacity:3]; - Coordinate *c = post.geolocation; - - /* - This might look more complicated than it should be, but it needs to be that way. - - Depending of the existence of geolocation and ID values, we need to add/update/delete the custom fields: - - geolocation && ID: update - - geolocation && !ID: add - - !geolocation && ID: delete - - !geolocation && !ID: noop - */ - if (post.latitudeID || c) { - NSMutableDictionary *latitudeDictionary = [NSMutableDictionary dictionaryWithCapacity:3]; - if (post.latitudeID) { - latitudeDictionary[@"id"] = [post.latitudeID numericValue]; - } - if (c) { - latitudeDictionary[@"key"] = @"geo_latitude"; - latitudeDictionary[@"value"] = @(c.latitude); - } - [metadata addObject:latitudeDictionary]; - } - if (post.longitudeID || c) { - NSMutableDictionary *longitudeDictionary = [NSMutableDictionary dictionaryWithCapacity:3]; - if (post.latitudeID) { - longitudeDictionary[@"id"] = [post.longitudeID numericValue]; - } - if (c) { - longitudeDictionary[@"key"] = @"geo_longitude"; - longitudeDictionary[@"value"] = @(c.longitude); - } - [metadata addObject:longitudeDictionary]; - } - if (post.publicID || c) { - NSMutableDictionary *publicDictionary = [NSMutableDictionary dictionaryWithCapacity:3]; - if (post.publicID) { - publicDictionary[@"id"] = [post.publicID numericValue]; - } - if (c) { - publicDictionary[@"key"] = @"geo_public"; - publicDictionary[@"value"] = @1; - } + NSMutableArray *metadata = [NSMutableArray arrayWithCapacity:4]; + + if (post.publicID) { + NSMutableDictionary *publicDictionary = [NSMutableDictionary dictionaryWithCapacity:1]; + publicDictionary[@"id"] = [post.publicID numericValue]; [metadata addObject:publicDictionary]; } + if (post.publicizeMessageID || post.publicizeMessage.length) { NSMutableDictionary *publicizeMessageDictionary = [NSMutableDictionary dictionaryWithCapacity:3]; if (post.publicizeMessageID) { @@ -995,6 +902,7 @@ - (NSArray *)remoteMetadataForPost:(Post *)post { publicizeMessageDictionary[@"value"] = post.publicizeMessage.length ? post.publicizeMessage : @""; [metadata addObject:publicizeMessageDictionary]; } + for (NSNumber *keyringConnectionId in post.disabledPublicizeConnections.allKeys) { NSMutableDictionary *disabledConnectionsDictionary = [NSMutableDictionary dictionaryWithCapacity: 3]; // We need to compose back the key @@ -1003,18 +911,25 @@ - (NSArray *)remoteMetadataForPost:(Post *)post { [disabledConnectionsDictionary addEntriesFromDictionary:post.disabledPublicizeConnections[keyringConnectionId]]; [metadata addObject:disabledConnectionsDictionary]; } + + if (post.bloggingPromptID) { + NSMutableDictionary *promptDictionary = [NSMutableDictionary dictionaryWithCapacity:3]; + promptDictionary[@"key"] = @"_jetpack_blogging_prompt_key"; + promptDictionary[@"value"] = post.bloggingPromptID; + [metadata addObject:promptDictionary]; + } + return metadata; } - (void)updatePost:(Post *)post withRemoteCategories:(NSArray *)remoteCategories { NSManagedObjectID *blogObjectID = post.blog.objectID; - PostCategoryService *categoryService = [[PostCategoryService alloc] initWithManagedObjectContext:self.managedObjectContext]; NSMutableSet *categories = [post mutableSetValueForKey:@"categories"]; [categories removeAllObjects]; for (RemotePostCategory *remoteCategory in remoteCategories) { - PostCategory *category = [categoryService findWithBlogObjectID:blogObjectID andCategoryID:remoteCategory.categoryID]; + PostCategory *category = [PostCategory lookupWithBlogObjectID:blogObjectID categoryID:remoteCategory.categoryID inContext:self.managedObjectContext]; if (!category) { - category = [categoryService newCategoryForBlogObjectID:blogObjectID]; + category = [PostCategory createWithBlogObjectID:blogObjectID inContext:self.managedObjectContext]; category.categoryID = remoteCategory.categoryID; category.categoryName = remoteCategory.name; category.parentID = remoteCategory.parentID; @@ -1025,10 +940,9 @@ - (void)updatePost:(Post *)post withRemoteCategories:(NSArray *)remoteCategories - (void)updateCommentsForPost:(AbstractPost *)post { - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:self.managedObjectContext]; NSMutableSet *currentComments = [post mutableSetValueForKey:@"comments"]; - NSSet *allComments = [commentService findCommentsWithPostID:post.postID inBlog:post.blog]; - [currentComments addObjectsFromArray:[allComments allObjects]]; + NSSet *allComments = [post.blog.comments filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"postID = %@", post.postID]]; + [currentComments unionSet:allComments]; } - (NSDictionary *)dictionaryWithKey:(NSString *)key inMetadata:(NSArray *)metadata { diff --git a/WordPress/Classes/Services/PostServiceRemoteFactory.swift b/WordPress/Classes/Services/PostServiceRemoteFactory.swift index 69ee8aaeae61..4d43327c3565 100644 --- a/WordPress/Classes/Services/PostServiceRemoteFactory.swift +++ b/WordPress/Classes/Services/PostServiceRemoteFactory.swift @@ -5,15 +5,48 @@ import WordPressKit @objc class PostServiceRemoteFactory: NSObject { @objc func forBlog(_ blog: Blog) -> PostServiceRemote? { if blog.supports(.wpComRESTAPI), - let api = blog.wordPressComRestApi(), - let dotComID = blog.dotComID { + let api = blog.wordPressComRestApi(), + let dotComID = blog.dotComID { return PostServiceRemoteREST(wordPressComRestApi: api, siteID: dotComID) - } else if let api = blog.xmlrpcApi, - let username = blog.username, - let password = blog.password { + } + + if let api = blog.xmlrpcApi, + let username = blog.username, + let password = blog.password { return PostServiceRemoteXMLRPC(api: api, username: username, password: password) - } else { + } + + return nil + } + + @objc func restRemoteFor(siteID: NSNumber, context: NSManagedObjectContext) -> PostServiceRemoteREST? { + guard let api = apiForRESTRequest(using: context) else { + return nil + } + + return PostServiceRemoteREST(wordPressComRestApi: api, siteID: siteID) + } + + // MARK: Private methods + + /// Get the api to use for making REST requests. + /// + /// - Returns: an instance of WordPressComRestApi + private func apiForRESTRequest(using context: NSManagedObjectContext) -> WordPressComRestApi? { + + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context), + let api = account.wordPressComRestApi else { return nil } + + // return anonymous api when no credentials are available. + // reference: https://github.com/wordpress-mobile/WordPress-iOS/commit/4507481 + guard api.hasCredentials() else { + return WordPressComRestApi(oAuthToken: nil, + userAgent: WPUserAgent.wordPress(), + localeKey: WordPressComRestApi.LocaleKeyDefault) + } + + return api } } diff --git a/WordPress/Classes/Services/PostTagService.m b/WordPress/Classes/Services/PostTagService.m index 3078c474d002..552df40fd07b 100644 --- a/WordPress/Classes/Services/PostTagService.m +++ b/WordPress/Classes/Services/PostTagService.m @@ -1,7 +1,8 @@ #import "PostTagService.h" #import "Blog.h" #import "PostTag.h" -#import "ContextManager.h" +#import "CoreDataStack.h" +#import "WordPress-Swift.h" @import WordPressKit; NS_ASSUME_NONNULL_BEGIN diff --git a/WordPress/Classes/Services/PushAuthenticationService.swift b/WordPress/Classes/Services/PushAuthenticationService.swift index c12a5b581ec6..33f51eecf089 100644 --- a/WordPress/Classes/Services/PushAuthenticationService.swift +++ b/WordPress/Classes/Services/PushAuthenticationService.swift @@ -4,18 +4,18 @@ import Foundation /// The purpose of this service is to encapsulate the Restful API that performs Mobile 2FA /// Code Verification. /// -@objc open class PushAuthenticationService: LocalCoreDataService { +class PushAuthenticationService { - @objc open var authenticationServiceRemote: PushAuthenticationServiceRemote? + var authenticationServiceRemote: PushAuthenticationServiceRemote? /// Designated Initializer /// /// - Parameter managedObjectContext: A Reference to the MOC that should be used to interact with /// the Core Data Persistent Store. /// - public required override init(managedObjectContext: NSManagedObjectContext) { - super.init(managedObjectContext: managedObjectContext) - self.authenticationServiceRemote = PushAuthenticationServiceRemote(wordPressComRestApi: apiForRequest()) + init(coreDataStack: CoreDataStack) { + let api = coreDataStack.performQuery(self.apiForRequest(in:)) + self.authenticationServiceRemote = PushAuthenticationServiceRemote(wordPressComRestApi: api) } /// Authorizes a WordPress.com Login Attempt (2FA Protected Accounts) @@ -24,7 +24,7 @@ import Foundation /// - token: The Token sent over by the backend, via Push Notifications. /// - completion: The completion block to be executed when the remote call finishes. /// - @objc open func authorizeLogin(_ token: String, completion: @escaping ((Bool) -> ())) { + func authorizeLogin(_ token: String, completion: @escaping ((Bool) -> ())) { if self.authenticationServiceRemote == nil { return } @@ -43,12 +43,11 @@ import Foundation /// /// - Returns: WordPressComRestApi instance. It can be an anonymous API instance if there are no credentials. /// - fileprivate func apiForRequest() -> WordPressComRestApi { + private func apiForRequest(in context: NSManagedObjectContext) -> WordPressComRestApi { var api: WordPressComRestApi? = nil - let accountService = AccountService(managedObjectContext: managedObjectContext) - if let unwrappedRestApi = accountService.defaultWordPressComAccount()?.wordPressComRestApi { + if let unwrappedRestApi = (try? WPAccount.lookupDefaultWordPressComAccount(in: context))?.wordPressComRestApi { if unwrappedRestApi.hasCredentials() { api = unwrappedRestApi } diff --git a/WordPress/Classes/Services/QRLoginService.swift b/WordPress/Classes/Services/QRLoginService.swift new file mode 100644 index 000000000000..4671069b3484 --- /dev/null +++ b/WordPress/Classes/Services/QRLoginService.swift @@ -0,0 +1,19 @@ +import Foundation +import WordPressKit + +class QRLoginService { + private let service: QRLoginServiceRemote + + init(coreDataStack: CoreDataStack, remoteService: QRLoginServiceRemote? = nil) { + self.service = remoteService ?? + coreDataStack.performQuery({ QRLoginServiceRemote(wordPressComRestApi: WordPressComRestApi.defaultApi(in: $0, localeKey: WordPressComRestApi.LocaleKeyV2)) }) + } + + func validate(token: QRLoginToken, success: @escaping(QRLoginValidationResponse) -> Void, failure: @escaping(Error?, QRLoginError?) -> Void) { + service.validate(token: token.token, data: token.data, success: success, failure: failure) + } + + func authenticate(token: QRLoginToken, success: @escaping(Bool) -> Void, failure: @escaping(Error) -> Void) { + service.authenticate(token: token.token, data: token.data, success: success, failure: failure) + } +} diff --git a/WordPress/Classes/Services/Reader Post/ReaderPostService.h b/WordPress/Classes/Services/Reader Post/ReaderPostService.h new file mode 100644 index 000000000000..297d56cd9667 --- /dev/null +++ b/WordPress/Classes/Services/Reader Post/ReaderPostService.h @@ -0,0 +1,206 @@ +#import <Foundation/Foundation.h> +#import "CoreDataService.h" + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wnullability-completeness" + +@import WordPressKit; +@import WordPressShared; + +@class ReaderPost; +@class ReaderAbstractTopic; + +extern NSString * const ReaderPostServiceErrorDomain; +extern NSString * const ReaderPostServiceToggleSiteFollowingState; + +@interface ReaderPostService : CoreDataService + +/** + Fetches and saves the posts for the specified topic + + @param topic The Topic for which to request posts. + @param date The date to get posts earlier than. + @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)fetchPostsForTopic:(ReaderAbstractTopic *)topic + earlierThan:(NSDate *)date + success:(void (^)(NSInteger count, BOOL hasMore))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches and saves the posts for the specified topic + + @param topic The Topic for which to request posts. + @param date The date to get posts earlier than. + @param deletingEarlier Deletes any cached posts earlier than the earliers post returned. + @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)fetchPostsForTopic:(ReaderAbstractTopic *)topic + earlierThan:(NSDate *)date + deletingEarlier:(BOOL)deleteEarlier + success:(void (^)(NSInteger count, BOOL hasMore))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches and saves the posts for the specified topic + + @param topic The Topic for which to request posts. + @param offset The offset of the posts to fetch. + @param deletingEarlier Deletes any cached posts earlier than the earliers post returned. + @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)fetchPostsForTopic:(ReaderAbstractTopic *)topic + atOffset:(NSUInteger)offset + deletingEarlier:(BOOL)deleteEarlier + success:(void (^)(NSInteger count, BOOL hasMore))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches a specific post from the specified remote site + + @param postID The ID of the post to fetch. + @param siteID The ID of the post's site. + @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)fetchPost:(NSUInteger)postID + forSite:(NSUInteger)siteID + isFeed:(BOOL)isFeed + success:(void (^)(ReaderPost *post))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches a specific post from the specified URL + + @param postURL The URL of the post to fetch + @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)fetchPostAtURL:(NSURL *)postURL + success:(void (^)(ReaderPost *post))success + failure:(void (^)(NSError *error))failure; + +/** + Silently refresh posts for the followed sites topic. + Note that calling this method creates a new service instance that performs + all its work on a derived managed object context, and background queue. + */ +- (void)refreshPostsForFollowedTopic; + +/** + Toggle the liked status of the specified post. + + @param post The reader post to like/unlike. + @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)toggleLikedForPost:(ReaderPost *)post + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + Toggle the following status of the specified post's blog. + + @param post The ReaderPost whose blog should be followed. + @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)toggleFollowingForPost:(ReaderPost *)post + success:(void (^)(BOOL follow))success + failure:(void (^)(BOOL follow, NSError *error))failure; + +/** + Toggle the saved for later status of the specified post. + + @param post The reader post to like/unlike. + @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)toggleSavedForLaterForPost:(ReaderPost *)post + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + Toggle the seen status of the specified post. + + @param post The reader post to mark seen/unseen. + @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)toggleSeenForPost:(ReaderPost *)post + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + Deletes all posts that do not belong to a `ReaderAbstractTopic` + Saves the NSManagedObjectContext. + */ +- (void)deletePostsWithNoTopic; + +/** + Sets the `isSavedForLater` flag to false for all posts. + */ +- (void)clearSavedPostFlags; + +/** + Globally sets the `inUse` flag to false for all posts. + */ +- (void)clearInUseFlags; + +/** + Updates in core data the following status of posts belonging to the specified site & url + + @param following Whether the user is following the site. + @param siteID The ID of the site + @siteURL the URL of the site. + */ +- (void)setFollowing:(BOOL)following forPostsFromSiteWithID:(NSNumber *)siteID andURL:(NSString *)siteURL; + +/** + Delete all `ReaderPosts` beyond the max number to be retained. + + The managed object context is not saved. + + @param topic the `ReaderAbstractTopic` to delete posts from. + */ +- (void)deletePostsInExcessOfMaxAllowedForTopic:(ReaderAbstractTopic *)topic; + +/** + Delete posts that are flagged as belonging to a blocked site. + + The managed object context is not saved. + */ +- (void)deletePostsFromBlockedSites; + +#pragma mark - Merging and Deletion + +/** + Merge a freshly fetched batch of posts into the existing set of posts for the specified topic. + Saves the managed object context. + + @param remotePosts An array of RemoteReaderPost objects + @param date The `before` date posts were requested. + @param topicObjectID The ObjectID of the ReaderAbstractTopic to assign to the newly created posts. + @param success block called on a successful fetch which should be performed after merging + */ +- (void)mergePosts:(NSArray *)remotePosts + rankedLessThan:(NSNumber *)rank + forTopic:(NSManagedObjectID *)topicObjectID + deletingEarlier:(BOOL)deleteEarlier + callingSuccess:(void (^)(NSInteger count, BOOL hasMore))success; + +#pragma mark Internal + +@property (readwrite, assign) BOOL isSilentlyFetchingPosts; + +- (WordPressComRestApi *)apiForRequest; +- (NSUInteger)numberToSyncForTopic:(ReaderAbstractTopic *)topic; +- (void)updateTopic:(NSManagedObjectID *)topicObjectID withAlgorithm:(NSString *)algorithm; +- (BOOL)canLoadMorePostsForTopic:(ReaderAbstractTopic * _Nonnull)readerTopic remotePosts:(NSArray * _Nonnull)remotePosts inContext: (NSManagedObjectContext * _Nonnull)context; + +@end + +#pragma clang diagnostic pop // -Wnullability-completeness diff --git a/WordPress/Classes/Services/Reader Post/ReaderPostService.m b/WordPress/Classes/Services/Reader Post/ReaderPostService.m new file mode 100644 index 000000000000..64c9cac23077 --- /dev/null +++ b/WordPress/Classes/Services/Reader Post/ReaderPostService.m @@ -0,0 +1,1164 @@ +#import "ReaderPostService.h" + +#import "AccountService.h" +#import "CoreDataStack.h" +#import "ReaderGapMarker.h" +#import "ReaderPost.h" +#import "ReaderSiteService.h" +#import "SourcePostAttribution.h" +#import "WPAccount.h" +#import "WPAppAnalytics.h" +#import <WordPressShared/NSString+XMLExtensions.h> +#import "WordPress-Swift.h" +@import WordPressKit; +@import WordPressShared; + +NSUInteger const ReaderPostServiceNumberToSync = 40; +// NOTE: The search endpoint is currently capped to max results of 20 and returns +// a 500 error if more are requested. +// For performance reasons, request fewer results. EJ 2016-05-13 +NSUInteger const ReaderPostServiceNumberToSyncForSearch = 10; +NSUInteger const ReaderPostServiceMaxSearchPosts = 200; +NSUInteger const ReaderPostServiceMaxPosts = 300; +NSString * const ReaderPostServiceErrorDomain = @"ReaderPostServiceErrorDomain"; +NSString * const ReaderPostServiceToggleSiteFollowingState = @"ReaderPostServiceToggleSiteFollowingState"; + +static NSString * const ReaderPostGlobalIDKey = @"globalID"; + +@implementation ReaderPostService + +#pragma mark - Fetch Methods + +- (void)fetchPostsForTopic:(ReaderAbstractTopic *)topic + earlierThan:(NSDate *)date + success:(void (^)(NSInteger count, BOOL hasMore))success + failure:(void (^)(NSError *error))failure +{ + [self fetchPostsForTopic:topic earlierThan:date deletingEarlier:NO success:success failure:failure]; +} + +- (void)fetchPostsForTopic:(ReaderAbstractTopic *)topic + atOffset:(NSUInteger)offset + deletingEarlier:(BOOL)deleteEarlier + success:(void (^)(NSInteger count, BOOL hasMore))success + failure:(void (^)(NSError *error))failure +{ + NSNumber * __block rank = @([[NSDate date] timeIntervalSinceReferenceDate]); + if (offset > 0) { + [self.coreDataStack.mainContext performBlockAndWait:^{ + rank = [self rankForPostAtOffset:offset - 1 forTopic:topic inContext:self.coreDataStack.mainContext]; + }]; + } + + if (offset >= ReaderPostServiceMaxSearchPosts && [topic isKindOfClass:[ReaderSearchTopic class]]) { + // A search supports a max offset of 199. If more are requested we want to bail early. + success(0, NO); + return; + } + + // Don't pass the algorithm if at the start of the results + NSString *reqAlgorithm = offset == 0 ? nil : topic.algorithm; + + NSManagedObjectID *topicObjectID = topic.objectID; + ReaderPostServiceRemote *remoteService = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; + [remoteService fetchPostsFromEndpoint:[NSURL URLWithString:topic.path] + algorithm:reqAlgorithm + count:[self numberToSyncForTopic:topic] + offset:offset + success:^(NSArray<RemoteReaderPost *> *posts, NSString *algorithm) { + [self updateTopic:topicObjectID withAlgorithm:algorithm]; + + [self mergePosts:posts + rankedLessThan:rank + forTopic:topicObjectID + deletingEarlier:deleteEarlier + callingSuccess:success]; + + } + failure:^(NSError *error) { + if (failure) { + failure(error); + } + }]; +} + + +- (void)updateTopic:(NSManagedObjectID *)topicObjectID withAlgorithm:(NSString *)algorithm +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderAbstractTopic *topic = (ReaderAbstractTopic *)[context existingObjectWithID:topicObjectID error:nil]; + topic.algorithm = algorithm; + }]; +} + + +- (void)fetchPostsForTopic:(ReaderAbstractTopic *)topic + earlierThan:(NSDate *)date + deletingEarlier:(BOOL)deleteEarlier + success:(void (^)(NSInteger count, BOOL hasMore))success + failure:(void (^)(NSError *error))failure +{ + // Don't pass the algorithm if fetching a brand new list. + // When fetching the beginning of a date ordered list the date passed is "now". + // If the passed date is equal to the current date we know we're starting from scratch. + NSString *reqAlgorithm = [date isEqualToDate:[NSDate date]] ? nil : topic.algorithm; + + NSManagedObjectID *topicObjectID = topic.objectID; + ReaderPostServiceRemote *remoteService = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; + [remoteService fetchPostsFromEndpoint:[NSURL URLWithString:topic.path] + algorithm:reqAlgorithm + count:[self numberToSyncForTopic:topic] + before:date + success:^(NSArray *posts, NSString *algorithm) { + [self updateTopic:topicObjectID withAlgorithm:algorithm]; + + // Construct a rank from the date provided + NSNumber *rank = @([date timeIntervalSinceReferenceDate]); + [self mergePosts:posts + rankedLessThan:rank + forTopic:topicObjectID + deletingEarlier:deleteEarlier + callingSuccess:success]; + + } + failure:^(NSError *error) { + if (failure) { + failure(error); + } + }]; +} + +- (void)fetchPost:(NSUInteger)postID forSite:(NSUInteger)siteID isFeed:(BOOL)isFeed success:(void (^)(ReaderPost *post))success failure:(void (^)(NSError *error))failure +{ + ReaderPostServiceRemote *remoteService = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; + [remoteService fetchPost:postID fromSite:siteID isFeed:isFeed success:^(RemoteReaderPost *remotePost) { + if (!success) { + return; + } + + NSManagedObjectID * __block postObjectID = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderPost *post = [self createOrReplaceFromRemotePost:remotePost forTopic:nil inContext:context]; + + NSError *error; + BOOL obtainedID = [context obtainPermanentIDsForObjects:@[post] error:&error]; + if (!obtainedID) { + DDLogError(@"Error obtaining a permanent ID for post. %@, %@", post, error); + } + postObjectID = post.objectID; + } completion:^{ + success([self.coreDataStack.mainContext existingObjectWithID:postObjectID error:nil]); + } onQueue:dispatch_get_main_queue()]; + } failure:failure]; +} + +- (void)fetchPostAtURL:(NSURL *)postURL + success:(void (^)(ReaderPost *post))success + failure:(void (^)(NSError *error))failure +{ + ReaderPostServiceRemote *remoteService = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; + [remoteService fetchPostAtURL:postURL + success:^(RemoteReaderPost *remotePost) { + if (!success) { + return; + } + + NSManagedObjectID * __block postObjectID = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderPost *post = [self createOrReplaceFromRemotePost:remotePost forTopic:nil inContext:context]; + + NSError *error; + BOOL obtainedID = [context obtainPermanentIDsForObjects:@[post] error:&error]; + if (!obtainedID) { + DDLogError(@"Error obtaining a permanent ID for post. %@, %@", post, error); + } + postObjectID = post.objectID; + } completion:^{ + success([self.coreDataStack.mainContext existingObjectWithID:postObjectID error:nil]); + } onQueue:dispatch_get_main_queue()]; + } failure:failure]; +} + +- (void)refreshPostsForFollowedTopic +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderAbstractTopic *topic = [ReaderAbstractTopic lookupFollowedSitesTopicInContext:context]; + if (topic) { + ReaderPostService *service = [[ReaderPostService alloc] initWithCoreDataStack:self.coreDataStack]; + [service fetchPostsForTopic:topic earlierThan:[NSDate date] deletingEarlier:YES success:nil failure:nil]; + } + }]; +} + +#pragma mark - Update Methods + +- (void)toggleLikedForPost:(ReaderPost *)post success:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + // Get a the post in our own context + NSError *error; + ReaderPost *readerPost = (ReaderPost *)[context existingObjectWithID:post.objectID error:&error]; + if (error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (failure) { + failure(error); + } + }); + return; + } + + [self toggleLikedForPost:readerPost inContext:context success:success failure:failure]; + }]; +} + +- (void)toggleLikedForPost:(ReaderPost *)readerPost inContext:(NSManagedObjectContext *)context success:(void (^)(void))success failure:(void (^)(NSError *error))failure +{ + NSParameterAssert(readerPost.managedObjectContext == context); + + // Keep previous values in case of failure + BOOL oldValue = readerPost.isLiked; + BOOL like = !oldValue; + NSNumber *oldCount = [readerPost.likeCount copy]; + + // Optimistically update + readerPost.isLiked = like; + if (like) { + readerPost.likeCount = @([readerPost.likeCount integerValue] + 1); + } else { + readerPost.likeCount = @([readerPost.likeCount integerValue] - 1); + } + + NSDictionary *railcar = [readerPost railcarDictionary]; + // Define success block. + NSNumber *postID = readerPost.postID; + NSNumber *siteID = readerPost.siteID; + void (^successBlock)(void) = ^void() { + if (postID && siteID) { + NSDictionary *properties = @{ + WPAppAnalyticsKeyPostID: postID, + WPAppAnalyticsKeyBlogID: siteID + }; + if (like) { + [WPAnalytics trackReaderStat:WPAnalyticsStatReaderArticleLiked properties:properties]; + if (railcar) { + [WPAnalytics trackReaderStat:WPAnalyticsStatReaderArticleLiked properties:railcar]; + } + } else { + [WPAnalytics trackReaderStat:WPAnalyticsStatReaderArticleUnliked properties:properties]; + } + } + if (success) { + success(); + } + }; + + // Define failure block. Make sure rollback happens in the moc's queue, + void (^failureBlock)(NSError *error) = ^void(NSError *error) { + [context performBlockAndWait:^{ + // Revert changes on failure + readerPost.isLiked = oldValue; + readerPost.likeCount = oldCount; + + [[ContextManager sharedInstance] saveContext:context withCompletionBlock:^{ + if (failure) { + failure(error); + } + } onQueue:dispatch_get_main_queue()]; + }]; + }; + + // Call the remote service. + ReaderPostServiceRemote *remoteService = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequestInContext:context]]; + if (like) { + [remoteService likePost:[readerPost.postID integerValue] forSite:[readerPost.siteID integerValue] success:successBlock failure:failureBlock]; + } else { + [remoteService unlikePost:[readerPost.postID integerValue] forSite:[readerPost.siteID integerValue] success:successBlock failure:failureBlock]; + } +} + +- (void)toggleFollowingForPost:(ReaderPost *)post + success:(void (^)(BOOL follow))success + failure:(void (^)(BOOL follow, NSError *error))failure +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSError *error; + ReaderPost *readerPost = (ReaderPost *)[context existingObjectWithID:post.objectID error:&error]; + if (error) { + if (failure) { + failure(true, error); + } + return; + } + + [self toggleFollowingForPost:readerPost inContext:context success:success failure:failure]; + }]; +} + +- (void)toggleFollowingForPost:(ReaderPost *)readerPost + inContext:(NSManagedObjectContext *)context + success:(void (^)(BOOL follow))success + failure:(void (^)(BOOL follow, NSError *error))failure +{ + NSParameterAssert(readerPost.managedObjectContext == context); + + // If this post belongs to a site topic, let the topic service do the work. + ReaderTopicService *topicService = [[ReaderTopicService alloc] initWithCoreDataStack:self.coreDataStack]; + + if ([readerPost.topic isKindOfClass:[ReaderSiteTopic class]]) { + ReaderSiteTopic *siteTopic = (ReaderSiteTopic *)readerPost.topic; + [topicService toggleFollowingForSite:siteTopic success:success failure:failure]; + return; + } + + ReaderSiteTopic *feedSiteTopic = [ReaderSiteTopic lookupWithFeedID:readerPost.feedID inContext:context]; + if (feedSiteTopic) { + [topicService toggleFollowingForSite:feedSiteTopic success:success failure:failure]; + return; + } + + + // Keep previous values in case of failure + BOOL oldValue = readerPost.isFollowing; + BOOL follow = !oldValue; + + // Optimistically update + readerPost.isFollowing = follow; + [self setFollowing:follow forPostsFromSiteWithID:readerPost.siteID andURL:readerPost.blogURL]; + + + // If the post in question belongs to the default followed sites topic, skip refreshing. + // We don't want to jar the user. + BOOL shouldRefreshFollowedPosts = readerPost.topic != [ReaderAbstractTopic lookupFollowedSitesTopicInContext:context]; + + // Define success block + void (^successBlock)(void) = ^void() { + + // Update subscription count + NSInteger oldSubscriptionCount = [WPAnalytics subscriptionCount]; + NSInteger newSubscriptionCount = follow ? oldSubscriptionCount + 1 : oldSubscriptionCount - 1; + [WPAnalytics setSubscriptionCount:newSubscriptionCount]; + + if (shouldRefreshFollowedPosts) { + [self refreshPostsForFollowedTopic]; + } + if (success) { + success(follow); + } + + dispatch_async(dispatch_get_main_queue(), ^{ + //Notifiy Settings view controller a site's following state has changed + [[NSNotificationCenter defaultCenter] postNotificationName:ReaderPostServiceToggleSiteFollowingState object:nil]; + }); + }; + + // Define failure block + void (^failureBlock)(NSError *error) = ^void(NSError *error) { + // Revert changes on failure + [self setFollowing:oldValue forPostsFromSiteWithID:readerPost.siteID andURL:readerPost.blogURL completion:^{ + if (failure) { + failure(follow, error); + } + }]; + }; + + ReaderSiteService *siteService = [[ReaderSiteService alloc] initWithCoreDataStack:self.coreDataStack]; + if (!readerPost.isExternal) { + if (follow) { + [siteService followSiteWithID:[readerPost.siteID integerValue] success:successBlock failure:failureBlock]; + } else { + [siteService unfollowSiteWithID:[readerPost.siteID integerValue] success:successBlock failure:failureBlock]; + } + } else if (readerPost.blogURL) { + if (follow) { + [siteService followSiteAtURL:readerPost.blogURL success:successBlock failure:failureBlock]; + } else { + [siteService unfollowSiteAtURL:readerPost.blogURL success:successBlock failure:failureBlock]; + } + } else { + NSString *description = NSLocalizedString(@"Could not toggle Follow: missing blogURL attribute", @"An error description explaining that Follow could not be toggled due to a missing blogURL attribute."); + NSDictionary *userInfo = @{ NSLocalizedDescriptionKey : description }; + NSError *error = [NSError errorWithDomain:ReaderPostServiceErrorDomain code:0 userInfo:userInfo]; + failureBlock(error); + } +} + +- (void)toggleSavedForLaterForPost:(ReaderPost *)post + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + // Get a the post in our own context + NSError *error; + ReaderPost *readerPost = (ReaderPost *)[context existingObjectWithID:post.objectID error:&error]; + if (error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (failure) { + failure(error); + } + }); + return; + } + + readerPost.isSavedForLater = !readerPost.isSavedForLater; + } completion:success onQueue:dispatch_get_main_queue()]; +} + +- (void)toggleSeenForPost:(ReaderPost *)post + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSError *error = [self validatePostForSeenToggle: post]; + if (error != nil) { + if (failure) { + failure(error); + } + + return; + } + + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + // Get a the post in our own context + NSError *error; + ReaderPost *readerPost = (ReaderPost *)[context existingObjectWithID:post.objectID error:&error]; + if (error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (failure) { + failure(error); + } + }); + return; + } + + [self toggleSeenForPost:readerPost inContext:context success:success failure:failure]; + }]; +} + +- (void)toggleSeenForPost:(ReaderPost *)readerPost + inContext:(NSManagedObjectContext *)context + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSParameterAssert(readerPost.managedObjectContext == context); + + // Keep previous values in case of failure + BOOL oldValue = readerPost.isSeen; + BOOL seen = !oldValue; + + // Optimistically update + readerPost.isSeen = seen; + + // Define success block. + void (^successBlock)(void) = ^void() { + if (success) { + success(); + } + }; + + // Define failure block. Make sure rollback happens in the managedObjectContext's queue. + void (^failureBlock)(NSError *error) = ^void(NSError *error) { + [context performBlockAndWait:^{ + + DDLogError(@"Error while toggling post Seen status: %@", error); + readerPost.isSeen = oldValue; + + [[ContextManager sharedInstance] saveContext:context withCompletionBlock:^{ + if (failure) { + failure(error); + } + } onQueue:dispatch_get_main_queue()]; + }]; + }; + + // Call the remote service. + ReaderPostServiceRemote *remoteService = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequestInContext:context]]; + + if (readerPost.isWPCom) { + [remoteService markBlogPostSeenWithSeen:seen + blogID:readerPost.siteID + postID:readerPost.postID + success:successBlock failure:failureBlock]; + } else { + [remoteService markFeedPostSeenWithSeen:seen + feedID:readerPost.feedID + feedItemID:readerPost.feedItemID + success:successBlock failure:failureBlock]; + } +} + +- (void)deletePostsWithNoTopic +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSError *error; + NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + + NSPredicate *pred = [NSPredicate predicateWithFormat:@"topic = NULL AND inUse = false"]; + pred = [self predicateIgnoringSavedForLaterPosts:pred]; + [fetchRequest setPredicate:pred]; + + NSArray *arr = [context executeFetchRequest:fetchRequest error:&error]; + if (error) { + DDLogError(@"%@, error fetching posts belonging to no topic: %@", NSStringFromSelector(_cmd), error); + return; + } + + for (ReaderPost *post in arr) { + DDLogInfo(@"%@, deleting topicless post: %@", NSStringFromSelector(_cmd), post); + [context deleteObject:post]; + } + }]; +} + +- (void)clearSavedPostFlags +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSError *error; + NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + request.predicate = [NSPredicate predicateWithFormat:@"isSavedForLater = true"]; + NSArray *results = [context executeFetchRequest:request error:&error]; + if (error) { + DDLogError(@"%@, unsaving saved posts: %@", NSStringFromSelector(_cmd), error); + return; + } + + for (ReaderPost *post in results) { + post.isSavedForLater = NO; + } + }]; +} + +- (void)clearInUseFlags +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSError *error; + NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + request.predicate = [NSPredicate predicateWithFormat:@"inUse = true"]; + NSArray *results = [context executeFetchRequest:request error:&error]; + if (error) { + DDLogError(@"%@, marking posts not in use.: %@", NSStringFromSelector(_cmd), error); + return; + } + + for (ReaderPost *post in results) { + post.inUse = NO; + } + }]; +} + +- (void)setFollowing:(BOOL)following forPostsFromSiteWithID:(NSNumber *)siteID andURL:(NSString *)siteURL +{ + [self setFollowing:following forPostsFromSiteWithID:siteID andURL:siteURL completion:nil]; +} + +- (void)setFollowing:(BOOL)following forPostsFromSiteWithID:(NSNumber *)siteID andURL:(NSString *)siteURL completion:(void (^)(void))completion +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + // Fetch all the posts for the specified site ID and update its following status + NSError *error; + NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + request.predicate = [NSPredicate predicateWithFormat:@"siteID = %@ AND blogURL = %@", siteID, siteURL]; + NSArray *results = [context executeFetchRequest:request error:&error]; + if (error) { + DDLogError(@"%@, error (un)following posts with siteID %@ and URL @%: %@", NSStringFromSelector(_cmd), siteID, siteURL, error); + return; + } + if ([results count] == 0) { + return; + } + + for (ReaderPost *post in results) { + post.isFollowing = following; + } + } completion:completion onQueue:dispatch_get_main_queue()]; +} + + +#pragma mark - Private Methods + +/** + Get the api to use for the request. + */ +- (WordPressComRestApi *)apiForRequest +{ + WordPressComRestApi * __block api = nil; + [self.coreDataStack.mainContext performBlockAndWait:^{ + api = [self apiForRequestInContext:self.coreDataStack.mainContext]; + }]; + return api; +} + +- (WordPressComRestApi *)apiForRequestInContext:(NSManagedObjectContext *)context +{ + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:context]; + WordPressComRestApi *api = [defaultAccount wordPressComRestApi]; + + if (![api hasCredentials]) { + api = [WordPressComRestApi defaultApiWithOAuthToken:nil + userAgent:[WPUserAgent wordPressUserAgent] + localeKey:[WordPressComRestApi LocaleKeyDefault]]; + } + return api; +} + +- (NSUInteger)numberToSyncForTopic:(ReaderAbstractTopic *)topic +{ + return [topic isKindOfClass:[ReaderSearchTopic class]] ? ReaderPostServiceNumberToSyncForSearch : ReaderPostServiceNumberToSync; +} + +- (NSUInteger)maxPostsToSaveForTopic:(ReaderAbstractTopic *)topic +{ + return [topic isKindOfClass:[ReaderSearchTopic class]] ? ReaderPostServiceMaxSearchPosts : ReaderPostServiceMaxPosts; +} + +- (NSUInteger)numberOfPostsForTopic:(ReaderAbstractTopic *)topic inContext:(NSManagedObjectContext *)context +{ + NSError *error; + NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + fetchRequest.includesSubentities = NO; // Exclude gap markers when counting. + NSPredicate *pred = [NSPredicate predicateWithFormat:@"topic = %@", topic]; + [fetchRequest setPredicate:pred]; + + NSUInteger count = [context countForFetchRequest:fetchRequest error:&error]; + return count; +} + +- (NSNumber *)rankForPostAtOffset:(NSUInteger)offset forTopic:(ReaderAbstractTopic *)topic inContext:(NSManagedObjectContext *)context +{ + NSError *error; + NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"topic = %@", topic]; + [fetchRequest setPredicate:predicate]; + + NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"sortRank" ascending:NO]; + [fetchRequest setSortDescriptors:@[sortDescriptor]]; + + fetchRequest.fetchOffset = offset; + fetchRequest.fetchLimit = 1; + + ReaderPost *post = [[context executeFetchRequest:fetchRequest error:&error] firstObject]; + if (error || !post) { + DDLogError(@"Error fetching post at a specific offset.", error); + return nil; + } + + return post.sortRank; +} + +- (NSPredicate *)predicateIgnoringSavedForLaterPosts:(NSPredicate*)fromPredicate +{ + return [NSCompoundPredicate andPredicateWithSubpredicates:@[fromPredicate, [self notSavedForLaterPredicate]]]; +} + +- (NSPredicate *)notSavedForLaterPredicate +{ + return [NSPredicate predicateWithFormat:@"isSavedForLater == NO"]; +} + +- (NSError *)validatePostForSeenToggle:(ReaderPost *)post +{ + NSString *description = nil; + + if (post.isWPCom && post.postID == nil) { + DDLogError(@"Could not toggle Seen: missing postID."); + description = NSLocalizedString(@"Could not toggle Seen: missing postID.", @"An error description explaining that Seen could not be toggled due to a missing postID."); + + } else if (!post.isWPCom && post.feedItemID == nil) { + DDLogError(@"Could not toggle Seen: missing feedItemID."); + description = NSLocalizedString(@"Could not toggle Seen: missing feedItemID.", @"An error description explaining that Seen could not be toggled due to a missing feedItemID."); + } + + if (description == nil) { + return nil; + } + + NSDictionary *userInfo = @{ NSLocalizedDescriptionKey : description }; + NSError *error = [NSError errorWithDomain:ReaderPostServiceErrorDomain code:0 userInfo:userInfo]; + return error; +} + +#pragma mark - Merging and Deletion + +/** + Merge a freshly fetched batch of posts into the existing set of posts for the specified topic. + Saves the managed object context. + + @param remotePosts An array of RemoteReaderPost objects + @param date The `before` date posts were requested. + @param topicObjectID The ObjectID of the ReaderAbstractTopic to assign to the newly created posts. + @param success block called on a successful fetch which should be performed after merging + */ +- (void)mergePosts:(NSArray *)remotePosts + rankedLessThan:(NSNumber *)rank + forTopic:(NSManagedObjectID *)topicObjectID + deletingEarlier:(BOOL)deleteEarlier + callingSuccess:(void (^)(NSInteger count, BOOL hasMore))success +{ + NSUInteger __block postsCount = 0; + BOOL __block hasMore = NO; + + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSError *error; + ReaderAbstractTopic *readerTopic = (ReaderAbstractTopic *)[context existingObjectWithID:topicObjectID error:&error]; + if (error || !readerTopic) { + // if there was an error or the topic was deleted just bail. + if (success) { + success(0, NO); + } + return; + } + + postsCount = [remotePosts count]; + if (postsCount == 0) { + [self deletePostsRankedLessThan:rank forTopic:readerTopic inContext:context]; + } else { + NSArray *posts = remotePosts; + BOOL overlap = NO; + + if (!deleteEarlier) { + // Before processing the new posts, check if there is an overlap between + // what is currently cached, and what is being synced. + overlap = [self checkIfRemotePosts:posts overlapExistingPostsinTopic:readerTopic inContext:context]; + + // A strategy to avoid false positives in gap detection is to sync + // one extra post. Only remove the extra post if we received a + // full set of results. A partial set means we've reached + // the end of syncable content. + if ([posts count] == [self numberToSyncForTopic:readerTopic] && ![ReaderHelpers isTopicSearchTopic:readerTopic]) { + posts = [posts subarrayWithRange:NSMakeRange(0, [posts count] - 1)]; + postsCount = [posts count]; + } + + } + + // Create or update the synced posts. + NSMutableArray *newPosts = [self makeNewPostsFromRemotePosts:posts forTopic:readerTopic inContext:context]; + + // When refreshing, some content previously synced may have been deleted remotely. + // Remove anything we've synced that is missing. + // NOTE that this approach leaves the possibility for older posts to not be cleaned up. + [self deletePostsForTopic:readerTopic missingFromBatch:newPosts withStartingRank:rank inContext:context]; + + // If deleting earlier, delete every post older than the last post in this batch. + if (deleteEarlier) { + ReaderPost *lastPost = [newPosts lastObject]; + [self deletePostsRankedLessThan:lastPost.sortRank forTopic:readerTopic inContext:context]; + [self removeGapMarkerForTopic:readerTopic inContext:context]; // Paranoia + + } else { + + // Handle an overlap in posts that were synced + if (overlap) { + [self removeGapMarkerForTopic:readerTopic ifNewPostsOverlapMarker:newPosts inContext:context]; + + } else { + // If there are existing posts older than the oldest of the + // new posts then append a gap placeholder to the end of the + // new posts + ReaderPost *lastPost = [newPosts lastObject]; + if ([self topic:readerTopic hasPostsRankedLessThan:lastPost.sortRank inContext:context]) { + [self insertGapMarkerBeforePost:lastPost forTopic:readerTopic inContext:context]; + } + } + } + } + + // Clean up + [self deletePostsInExcessOfMaxAllowedForTopic:readerTopic inContext:context]; + [self deletePostsFromBlockedSitesInContext:context]; + + BOOL spaceAvailable = ([self numberOfPostsForTopic:readerTopic inContext:context] < [self maxPostsToSaveForTopic:readerTopic]); + if ([ReaderHelpers isTopicTag:readerTopic]) { + // For tags, assume there is more content as long as more than zero results are returned. + hasMore = (postsCount > 0 ) && spaceAvailable; + } else { + // For other topics, assume there is more content as long as the number of results requested is returned. + hasMore = ([remotePosts count] == [self numberToSyncForTopic:readerTopic]) && spaceAvailable; + } + } completion:^{ + if (success) { + success(postsCount, hasMore); + } + } onQueue:dispatch_get_main_queue()]; +} + +- (BOOL)checkIfRemotePosts:(NSArray *)remotePosts overlapExistingPostsinTopic:(ReaderAbstractTopic *)readerTopic inContext:(NSManagedObjectContext *)context +{ + // Get global IDs of new posts to use as part of the predicate. + NSSet *remoteGlobalIDs = [self globalIDsOfRemotePosts:remotePosts]; + + // Fetch matching existing posts. + NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([ReaderPost class])]; + fetchRequest.predicate = [NSPredicate predicateWithFormat:@"topic = %@ AND globalID in %@", readerTopic, remoteGlobalIDs]; + + NSError *error; + NSArray *results = [context executeFetchRequest:fetchRequest error:&error]; + if (error) { + DDLogError(error.localizedDescription); + return NO; + } + + // For each match, check that the dates are the same. If at least one date is the same then there is an overlap so return true. + // If the dates are different then the existing cached post will be updated. Don't treat this as overlap. + for (ReaderPost *post in results) { + for (RemoteReaderPost *remotePost in remotePosts) { + if (![remotePost.globalID isEqualToString:post.globalID]) { + continue; + } + if ([post.sortDate isEqualToDate:remotePost.sortDate]) { + return YES; + } + } + } + + return NO; +} + +#pragma mark Gap Detection Methods + +- (void)removeGapMarkerForTopic:(ReaderAbstractTopic *)topic ifNewPostsOverlapMarker:(NSArray *)newPosts inContext:(NSManagedObjectContext *)context +{ + ReaderGapMarker *gapMarker = [self gapMarkerForTopic:topic inContext:context]; + if (gapMarker) { + double highestRank = [((ReaderPost *)newPosts.firstObject).sortRank doubleValue]; + double lowestRank = [((ReaderPost *)newPosts.lastObject).sortRank doubleValue]; + double gapRank = [gapMarker.sortRank doubleValue]; + // Confirm the overlap includes the gap marker. + if (lowestRank < gapRank && gapRank < highestRank) { + // No need for a gap placeholder. Remove any that existed + [self removeGapMarkerForTopic:topic inContext:context]; + } + } +} + +- (ReaderGapMarker *)gapMarkerForTopic:(ReaderAbstractTopic *)topic inContext:(NSManagedObjectContext *)context +{ + NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([ReaderGapMarker class])]; + fetchRequest.predicate = [NSPredicate predicateWithFormat:@"topic = %@", topic]; + + NSError *error; + NSArray *results = [context executeFetchRequest:fetchRequest error:&error]; + if (error) { + DDLogError(error.localizedDescription); + return nil; + } + + // Assume there will ever only be one and return the first result. + return results.firstObject; +} + +- (void)insertGapMarkerBeforePost:(ReaderPost *)post forTopic:(ReaderAbstractTopic *)topic inContext:(NSManagedObjectContext *)context +{ + [self removeGapMarkerForTopic:topic inContext:context]; + + ReaderGapMarker *marker = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([ReaderGapMarker class]) + inManagedObjectContext:context]; + + // Synced posts do not use millisecond precision for their dates. We can take + // advantage of this and make our marker post a fraction of a second earlier + // than the last post. + // We'll store the unmodifed sort date as date_create_gmt so we have a convenient + // and accurate date reference should we need it. + marker.sortDate = [post.sortDate dateByAddingTimeInterval:-0.1]; + marker.date_created_gmt = post.sortDate; + + // For compatability with posts that are sorted by score + marker.sortRank = @([post.sortRank doubleValue] - CGFLOAT_MIN); + marker.score = post.score; + + marker.topic = topic; +} + + +- (void)removeGapMarkerForTopic:(ReaderAbstractTopic *)topic inContext:(NSManagedObjectContext *)context +{ + NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([ReaderGapMarker class])]; + fetchRequest.predicate = [NSPredicate predicateWithFormat:@"topic = %@", topic]; + + NSError *error; + NSArray *results = [context executeFetchRequest:fetchRequest error:&error]; + if (error) { + DDLogError(error.localizedDescription); + return; + } + + // There should only ever be one, but loop over all results just in case. + for (ReaderGapMarker *marker in results) { + DDLogInfo(@"Deleting Gap Marker: %@", marker); + [context deleteObject:marker]; + } +} + +- (BOOL)topic:(ReaderAbstractTopic *)topic hasPostsRankedLessThan:(NSNumber *)rank inContext:(NSManagedObjectContext *)context +{ + NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([ReaderPost class])]; + fetchRequest.predicate = [NSPredicate predicateWithFormat:@"topic = %@ AND sortRank < %@", topic, rank]; + + NSError *error; + NSInteger count = [context countForFetchRequest:fetchRequest error:&error]; + if (error) { + DDLogError(error.localizedDescription); + return NO; + } + return (count > 0); +} + +- (NSSet *)globalIDsOfRemotePosts:(NSArray *)remotePosts +{ + NSMutableArray *arr = [NSMutableArray array]; + for (RemoteReaderPost *post in remotePosts) { + [arr addObject:post.globalID]; + } + // return non-mutable array + return [NSSet setWithArray:arr]; +} + + +#pragma mark Deletion and Clean up + +/** + Deletes any existing post whose sortRank is less than the passed rank. This + is to handle situations where posts have been synced but were subsequently removed + from the result set (deleted, unliked, etc.) rendering the result set empty. + + @param rank The sortRank to delete posts less than. + @param topic The `ReaderAbstractTopic` to delete posts from. + */ +- (void)deletePostsRankedLessThan:(NSNumber *)rank forTopic:(ReaderAbstractTopic *)topic inContext:(NSManagedObjectContext *)context +{ + // Don't trust the relationships on the topic to be current or correct. + NSError *error; + NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + + NSPredicate *pred = [NSPredicate predicateWithFormat:@"topic = %@ AND sortRank < %@", topic, rank]; + [fetchRequest setPredicate:pred]; + + NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"sortRank" ascending:NO]; + [fetchRequest setSortDescriptors:@[sortDescriptor]]; + + NSArray *currentPosts = [context executeFetchRequest:fetchRequest error:&error]; + if (error) { + DDLogError(@"%@ error fetching posts: %@", NSStringFromSelector(_cmd), error); + return; + } + + for (ReaderPost *post in currentPosts) { + if (post.isSavedForLater) { + // If the missing post is currently being used or has been saved, just remove its topic. + post.topic = nil; + } else { + DDLogInfo(@"Deleting ReaderPost: %@", post); + [context deleteObject:post]; + } + } +} + +/** + Using an array of post as a filter, deletes any existing post whose sortRank falls + within the range of the filter posts, but is not included in the filter posts. + + This let's us remove unliked posts from /read/liked, posts from blogs that are + unfollowed from /read/following, or posts that were otherwise removed. + + The managed object context is not saved. + + @param topic The ReaderAbstractTopic to delete posts from. + @param posts The batch of posts to use as a filter. + @param startingRank The starting rank of the batch of posts. May be less than the highest ranked post in the batch. + */ +- (void)deletePostsForTopic:(ReaderAbstractTopic *)topic + missingFromBatch:(NSArray *)posts + withStartingRank:(NSNumber *)startingRank + inContext:(NSManagedObjectContext *)context +{ + // Don't trust the relationships on the topic to be current or correct. + NSError *error; + NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + + NSNumber *highestRank = startingRank; + + NSNumber *lowestRank = ((ReaderPost *)[posts lastObject]).sortRank; + NSPredicate *pred = [NSPredicate predicateWithFormat:@"topic == %@ AND sortRank > %@ AND sortRank < %@", topic, lowestRank, highestRank]; + + [fetchRequest setPredicate:pred]; + + NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"sortRank" ascending:NO]; + [fetchRequest setSortDescriptors:@[sortDescriptor]]; + + NSArray *currentPosts = [context executeFetchRequest:fetchRequest error:&error]; + if (error) { + DDLogError(@"%@ error fetching posts: %@", NSStringFromSelector(_cmd), error); + return; + } + + for (ReaderPost *post in currentPosts) { + if ([posts containsObject:post]) { + continue; + } + // The post was missing from the batch and needs to be cleaned up. + if ([self topicShouldBeClearedFor:post]) { + // If the missing post is currently being used or has been saved, just remove its topic. + post.topic = nil; + } else { + DDLogInfo(@"Deleting ReaderPost: %@", post); + [context deleteObject:post]; + } + } +} + +/** + Delete all `ReaderPosts` beyond the max number to be retained. + + The managed object context is not saved. + + @param topic the `ReaderAbstractTopic` to delete posts from. + */ +- (void)deletePostsInExcessOfMaxAllowedForTopic:(ReaderAbstractTopic *)topic +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + [self deletePostsInExcessOfMaxAllowedForTopic:topic inContext:context]; + }]; +} + +- (void)deletePostsInExcessOfMaxAllowedForTopic:(ReaderAbstractTopic *)topic inContext:(NSManagedObjectContext *)context +{ + // Don't trust the relationships on the topic to be current or correct. + NSError *error; + NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + + NSPredicate *pred = [NSPredicate predicateWithFormat:@"topic = %@", topic]; + [fetchRequest setPredicate:pred]; + + NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"sortRank" ascending:NO]; + [fetchRequest setSortDescriptors:@[sortDescriptor]]; + + NSUInteger maxPosts = [self maxPostsToSaveForTopic:topic]; + + // Specifying a fetchOffset to just get the posts in range doesn't seem to work very well. + // Just perform the fetch and remove the excess. + NSUInteger count = [context countForFetchRequest:fetchRequest error:&error]; + if (count <= maxPosts) { + return; + } + + NSArray *posts = [context executeFetchRequest:fetchRequest error:&error]; + if (error) { + DDLogError(@"%@ error fetching posts: %@", NSStringFromSelector(_cmd), error); + return; + } + + NSRange range = NSMakeRange(maxPosts, [posts count] - maxPosts); + NSArray *postsToDelete = [posts subarrayWithRange:range]; + for (ReaderPost *post in postsToDelete) { + if ([self topicShouldBeClearedFor:post]) { + post.topic = nil; + } else { + DDLogInfo(@"Deleting ReaderPost: %@", post.postTitle); + [context deleteObject:post]; + } + } + + // If the last remaining post is a gap marker, remove it. + ReaderPost *lastPost = [posts objectAtIndex:maxPosts - 1]; + if ([lastPost isKindOfClass:[ReaderGapMarker class]]) { + DDLogInfo(@"Deleting Last GapMarker: %@", lastPost); + [context deleteObject:lastPost]; + } +} + +/** + Delete posts that are flagged as belonging to a blocked site. + + The managed object context is not saved. + */ +- (void)deletePostsFromBlockedSites +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + [self deletePostsFromBlockedSitesInContext:context]; + }]; +} + +- (void)deletePostsFromBlockedSitesInContext:(NSManagedObjectContext *)context +{ + NSError *error; + NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + request.predicate = [NSPredicate predicateWithFormat:@"isSiteBlocked = YES"]; + NSArray *results = [context executeFetchRequest:request error:&error]; + if (error) { + DDLogError(@"%@, error deleting deleting posts from blocked sites: %@", NSStringFromSelector(_cmd), error); + return; + } + + if ([results count] == 0) { + return; + } + + for (ReaderPost *post in results) { + if ([self topicShouldBeClearedFor:post]) { + // If the missing post is currenty being used just remove its topic. + post.topic = nil; + } else { + DDLogInfo(@"Deleting ReaderPost: %@", post.postTitle); + [context deleteObject:post]; + } + } +} + +- (BOOL)topicShouldBeClearedFor:(ReaderPost *)post +{ + return (post.inUse || post.isSavedForLater); +} + + +#pragma mark Entity Creation + +/** + Accepts an array of `RemoteReaderPost` objects and creates model objects + for each one. + + @param posts An array of `RemoteReaderPost` objects. + @param topic The `ReaderAbsractTopic` to assign to the created posts. + @return An array of `ReaderPost` objects + */ +- (NSMutableArray *)makeNewPostsFromRemotePosts:(NSArray *)posts forTopic:(ReaderAbstractTopic *)topic inContext:(NSManagedObjectContext *)context +{ + NSMutableArray *newPosts = [NSMutableArray array]; + for (RemoteReaderPost *post in posts) { + ReaderPost *newPost = [self createOrReplaceFromRemotePost:post forTopic:topic inContext:context]; + if (newPost != nil) { + [newPosts addObject:newPost]; + } else { + DDLogInfo(@"%@ returned a nil post: %@", NSStringFromSelector(_cmd), post); + } + } + return newPosts; +} + +/** + Create a `ReaderPost` model object from the specified dictionary. + + @param dict A `RemoteReaderPost` object. + @param topic The `ReaderAbstractTopic` to assign to the created post. + @return A `ReaderPost` model object whose properties are populated with the values from the passed dictionary. + */ +- (ReaderPost *)createOrReplaceFromRemotePost:(RemoteReaderPost *)remotePost forTopic:(ReaderAbstractTopic *)topic inContext:(NSManagedObjectContext *)context +{ + NSParameterAssert(context != nil); + NSParameterAssert(topic == nil || topic.managedObjectContext == context); + return [ReaderPost createOrReplaceFromRemotePost:remotePost forTopic:topic context:context]; +} + +#pragma mark Internal + +- (BOOL)canLoadMorePostsForTopic:(ReaderAbstractTopic * _Nonnull)readerTopic remotePosts:(NSArray * _Nonnull)remotePosts inContext: (NSManagedObjectContext * _Nonnull)context { + BOOL hasMore = NO; + BOOL spaceAvailable = ([self numberOfPostsForTopic:readerTopic inContext:context] < [self maxPostsToSaveForTopic:readerTopic]); + if ([ReaderHelpers isTopicTag:readerTopic]) { + // For tags, assume there is more content as long as more than zero results are returned. + hasMore = ([remotePosts count] > 0 ) && spaceAvailable; + } else { + // For other topics, assume there is more content as long as the number of results requested is returned. + hasMore = ([remotePosts count] == [self numberToSyncForTopic:readerTopic]) && spaceAvailable; + } + return hasMore; +} + +@end diff --git a/WordPress/Classes/Services/Reader Post/ReaderPostService.swift b/WordPress/Classes/Services/Reader Post/ReaderPostService.swift new file mode 100644 index 000000000000..c3a58a6803f3 --- /dev/null +++ b/WordPress/Classes/Services/Reader Post/ReaderPostService.swift @@ -0,0 +1,191 @@ +import Foundation +import WordPressKit + +extension ReaderPostService { + + // MARK: - Fetch Unblocked Posts + + /// Fetches a list of posts from the API and filters out the posts that belong to a blocked author. + func fetchUnblockedPosts( + topic: ReaderAbstractTopic, + earlierThan date: Date, + forceRetry: Bool = false, + success: SuccessCallback? = nil, + failure: ErrorCallback? = nil + ) { + let maxRetries = RetryOption.maxRetries + let retryOption = forceRetry || !isSilentlyFetchingPosts ? RetryOption.enabled(retry: 0, maxRetries: maxRetries) : .disabled + fetchUnblockedPostsWithRetries(topicObjectID: topic.objectID, earlierThan: date, retryOption: retryOption, success: success, failure: failure) + } + + private func fetchUnblockedPostsWithRetries( + topicObjectID: NSManagedObjectID, + earlierThan date: Date, + retryOption: RetryOption, + success: SuccessCallback? = nil, + failure: ErrorCallback? = nil + ) { + // Don't pass the algorithm if fetching a brand new list. + // When fetching the beginning of a date ordered list the date passed is "now". + // If the passed date is equal to the current date we know we're starting from scratch. + guard let (reqAlgorithm, endpoint, count) = self.coreDataStack.performQuery({ context -> (String?, URL?, UInt)? in + guard let topic = try? context.existingObject(with: topicObjectID) as? ReaderAbstractTopic else { + return nil + } + return ( + date == Date() ? nil : topic.algorithm, + URL(string: topic.path), + self.numberToSync(for: topic) + ) + }) else { + success?(0, false) + return + } + + let remoteService = ReaderPostServiceRemote(wordPressComRestApi: apiForRequest()) + remoteService.fetchPosts( + fromEndpoint: endpoint, + algorithm: reqAlgorithm, + count: count, + before: date + ) { posts, algorithm in + self.processFetchedPostsForTopic( + topicObjectID: topicObjectID, + remotePosts: posts ?? [], + earlierThan: date, + deletingEarlier: false, + algorithm: algorithm, + retryOption: retryOption, + success: success + ) + } failure: { error in + failure?(error) + } + } + + private func processFetchedPostsForTopic( + topicObjectID: NSManagedObjectID, + remotePosts posts: [Any], + earlierThan date: Date, + deletingEarlier: Bool, + algorithm: String?, + retryOption: RetryOption, + success: SuccessCallback? = nil + ) { + // The passed-in topic might have missing data, the following code ensures fully realized object. + guard let posts = posts as? [RemoteReaderPost] + else { + success?(0, true) + return + } + + // Update topic locally + self.updateTopic(topicObjectID, withAlgorithm: algorithm) + + // Filter out blocked posts + let filteredPosts = self.coreDataStack.performQuery { context in + self.remotePostsByFilteringOutBlockedPosts(posts, in: context) + } + let hasMore = self.coreDataStack.performQuery { context in + guard let topic = try? context.existingObject(with: topicObjectID) as? ReaderAbstractTopic else { return false } + return self.canLoadMorePosts(for: topic, remotePosts: posts, in: context) + } + + // Persist filtered posts locally. + self.persistRemotePosts( + filteredPosts: filteredPosts, + allPosts: posts, + topicObjectID: topicObjectID, + beforeDate: date, + deletingEarlier: deletingEarlier, + hasMoreContent: hasMore, + success: success + ) + + // Fetch more posts when certain conditions are fulfilled. See method documentation for more details. + self.fetchMorePostsIfNeeded(filteredPosts: filteredPosts, allPosts: posts, topicObjectID: topicObjectID, retryOption: retryOption) + } + + /// Persists the remote posts in Core Data. + private func persistRemotePosts( + filteredPosts: [RemoteReaderPost], + allPosts posts: [RemoteReaderPost], + topicObjectID: NSManagedObjectID, + beforeDate date: Date, + deletingEarlier: Bool, + hasMoreContent hasMore: Bool, + success: SuccessCallback? = nil + ) { + // We don't want to call `mergePosts` if all posts are blocked, henced filtered out. + // Because this somehow causes existing posts in Core Data to be removed. + let allPostsAreFilteredOut = filteredPosts.isEmpty && !posts.isEmpty + if !allPostsAreFilteredOut { + let rank = date.timeIntervalSinceReferenceDate as NSNumber + self.mergePosts(filteredPosts, rankedLessThan: rank, forTopic: topicObjectID, deletingEarlier: deletingEarlier) { count, _ in + success?(count, hasMore) + } + } else { + success?(filteredPosts.count, hasMore) + } + } + + /// Silently fetch new content when the certain conditions are fulfiled. + /// + /// Those conditions are: + /// + /// 1. The retries count hasn't exceeded the limit. + /// 2. The fetched posts all belong to a blocked author(s). Which means, they all posts are filtered out. + private func fetchMorePostsIfNeeded( + filteredPosts: [RemoteReaderPost], + allPosts posts: [RemoteReaderPost], + topicObjectID: NSManagedObjectID, + retryOption: RetryOption + ) { + guard case let .enabled(retry, maxRetries) = retryOption, let lastPost = posts.last else { + return + } + let shouldContinueRetrying = filteredPosts.isEmpty && retry < maxRetries + if shouldContinueRetrying { + self.fetchUnblockedPostsWithRetries( + topicObjectID: topicObjectID, + earlierThan: lastPost.sortDate, + retryOption: .enabled(retry: retry + 1, maxRetries: maxRetries) + ) + } + self.isSilentlyFetchingPosts = shouldContinueRetrying + } + + /// Takes a list of remote posts and returns a new list without the blocked posts. + private func remotePostsByFilteringOutBlockedPosts(_ posts: [RemoteReaderPost], in context: NSManagedObjectContext) -> [RemoteReaderPost] { + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context), + let accountID = account.userID + else { + return posts + } + + let blockedAuthors = Set(BlockedAuthor.find(.accountID(accountID), context: context).map { $0.authorID }) + + guard !blockedAuthors.isEmpty else { + return posts + } + + return posts.filter { post -> Bool in + guard let authorID = post.authorID else { + return true + } + return !blockedAuthors.contains(authorID) + } + } + + // MARK: - Types + + typealias SuccessCallback = (_ count: Int, _ hasMore: Bool) -> Void + typealias ErrorCallback = (_ error: Error?) -> Void + + enum RetryOption { + case disabled + case enabled(retry: Int, maxRetries: Int) + + static let maxRetries = 15 + } +} diff --git a/WordPress/Classes/Services/ReaderCardService.swift b/WordPress/Classes/Services/ReaderCardService.swift new file mode 100644 index 000000000000..72412e9133ad --- /dev/null +++ b/WordPress/Classes/Services/ReaderCardService.swift @@ -0,0 +1,141 @@ +import Foundation + +class ReaderCardService { + private let service: ReaderPostServiceRemote + + private let coreDataStack: CoreDataStack + + private let followedInterestsService: ReaderFollowedInterestsService + private let siteInfoService: ReaderSiteInfoService + + /// An string used to retrieve the next page + private var pageHandle: String? + + /// Used only internally to order the cards + private var pageNumber = 1 + + init(service: ReaderPostServiceRemote = ReaderPostServiceRemote.withDefaultApi(), + coreDataStack: CoreDataStack = ContextManager.shared, + followedInterestsService: ReaderFollowedInterestsService? = nil, + siteInfoService: ReaderSiteInfoService? = nil) { + self.service = service + self.coreDataStack = coreDataStack + self.followedInterestsService = followedInterestsService ?? ReaderTopicService(coreDataStack: coreDataStack) + self.siteInfoService = siteInfoService ?? ReaderTopicService(coreDataStack: coreDataStack) + } + + func fetch(isFirstPage: Bool, refreshCount: Int = 0, success: @escaping (Int, Bool) -> Void, failure: @escaping (Error?) -> Void) { + followedInterestsService.fetchFollowedInterestsLocally { [unowned self] topics in + guard let interests = topics, !interests.isEmpty else { + failure(Errors.noInterests) + return + } + + let slugs = interests.map { $0.slug } + self.service.fetchCards(for: slugs, + page: self.pageHandle(isFirstPage: isFirstPage), + refreshCount: refreshCount, + success: { [weak self] cards, pageHandle in + + guard let self = self else { + return + } + + self.pageHandle = pageHandle + + self.coreDataStack.performAndSave({ context in + if isFirstPage { + self.pageNumber = 1 + self.removeAllCards(in: context) + } else { + self.pageNumber += 1 + } + + cards.enumerated().forEach { index, remoteCard in + let card = ReaderCard(context: context, from: remoteCard) + + // Assign each interest an endpoint + card? + .topics? + .array + .compactMap { $0 as? ReaderTagTopic } + .forEach { $0.path = self.followedInterestsService.path(slug: $0.slug) } + + // Assign each site an endpoint URL if needed + card? + .sites? + .array + .compactMap { $0 as? ReaderSiteTopic } + .forEach { + let path = $0.path + // Sites coming from the cards API only have a path and not a full url + // Once we save the model locally it will be a full URL, so we don't + // want to reapply this logic + if !path.hasPrefix("http") { + $0.path = self.siteInfoService.endpointURLString(path: path) + } + } + + // To keep the API order + card?.sortRank = Double((self.pageNumber * Constants.paginationMultiplier) + index) + } + }, completion: { + let hasMore = pageHandle != nil + success(cards.count, hasMore) + }, on: .main) + }, failure: { error in + failure(error) + }) + + } + } + + /// Remove all cards and saves the context + func clean() { + coreDataStack.performAndSave { context in + self.removeAllCards(in: context) + } + } + + private func removeAllCards(in context: NSManagedObjectContext) { + let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: ReaderCard.classNameWithoutNamespaces()) + fetchRequest.returnsObjectsAsFaults = false + + do { + let results = try context.fetch(fetchRequest) + for object in results { + guard let objectData = object as? NSManagedObject else { continue } + context.delete(objectData) + } + } catch let error { + print("Clean card error:", error) + } + } + + private func pageHandle(isFirstPage: Bool) -> String? { + isFirstPage ? nil : self.pageHandle + } + + enum Errors: Error { + case noInterests + } + + private enum Constants { + static let paginationMultiplier = 100 + static let firstPage = 1 + } +} + +/// Used to inject the ReaderPostServiceRemote as an dependency +extension ReaderPostServiceRemote { + class func withDefaultApi() -> ReaderPostServiceRemote { + + let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) + let token: String? = defaultAccount?.authToken + + let api = WordPressComRestApi.defaultApi(oAuthToken: token, + userAgent: WPUserAgent.wordPress(), + localeKey: WordPressComRestApi.LocaleKeyV2) + return ReaderPostServiceRemote(wordPressComRestApi: api) + } +} diff --git a/WordPress/Classes/Services/ReaderPostService+RelatedPosts.swift b/WordPress/Classes/Services/ReaderPostService+RelatedPosts.swift new file mode 100644 index 000000000000..3d62be214f33 --- /dev/null +++ b/WordPress/Classes/Services/ReaderPostService+RelatedPosts.swift @@ -0,0 +1,37 @@ +import Foundation + +extension ReaderPostService { + + /** + Fetches related posts for a specific post. + + @param post The reader post to fetch related posts for. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ + func fetchRelatedPosts(for post: ReaderPost, + success: @escaping ([RemoteReaderSimplePost]) -> Void, + failure: @escaping (Error?) -> Void) { + + let remoteService = ReaderPostServiceRemote.withDefaultApi() + + guard let postID = post.postID?.intValue else { + failure(ReaderPostServiceError.invalidPostID) + return + } + + guard let siteID = post.siteID?.intValue else { + failure(ReaderPostServiceError.invalidSiteID) + return + } + + remoteService.fetchRelatedPosts(for: postID, from: siteID, success: success, failure: failure) + } + + // MARK: - Helpers + + enum ReaderPostServiceError: Error { + case invalidPostID + case invalidSiteID + } +} diff --git a/WordPress/Classes/Services/ReaderPostService.h b/WordPress/Classes/Services/ReaderPostService.h deleted file mode 100644 index 677ab0afc90c..000000000000 --- a/WordPress/Classes/Services/ReaderPostService.h +++ /dev/null @@ -1,193 +0,0 @@ -#import <Foundation/Foundation.h> -#import "LocalCoreDataService.h" - -@class ReaderPost; -@class ReaderAbstractTopic; - -extern NSString * const ReaderPostServiceErrorDomain; -extern NSString * const ReaderPostServiceToggleSiteFollowingState; - -@interface ReaderPostService : LocalCoreDataService - -/** - Fetches the posts for the specified topic - - @param topic The Topic for which to request posts. - @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)fetchPostsForTopic:(ReaderAbstractTopic *)topic - success:(void (^)(NSInteger count, BOOL hasMore))success - failure:(void (^)(NSError *error))failure; - -/** - Fetches and saves the posts for the specified topic - - @param topic The Topic for which to request posts. - @param date The date to get posts earlier than. - @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)fetchPostsForTopic:(ReaderAbstractTopic *)topic - earlierThan:(NSDate *)date - success:(void (^)(NSInteger count, BOOL hasMore))success - failure:(void (^)(NSError *error))failure; - -/** - Fetches and saves the posts for the specified topic - - @param topic The Topic for which to request posts. - @param date The date to get posts earlier than. - @param deletingEarlier Deletes any cached posts earlier than the earliers post returned. - @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)fetchPostsForTopic:(ReaderAbstractTopic *)topic - earlierThan:(NSDate *)date - deletingEarlier:(BOOL)deleteEarlier - success:(void (^)(NSInteger count, BOOL hasMore))success - failure:(void (^)(NSError *error))failure; - -/** - Fetches and saves the posts for the specified topic - - @param topic The Topic for which to request posts. - @param offset The offset of the posts to fetch. - @param deletingEarlier Deletes any cached posts earlier than the earliers post returned. - @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)fetchPostsForTopic:(ReaderAbstractTopic *)topic - atOffset:(NSUInteger)offset - deletingEarlier:(BOOL)deleteEarlier - success:(void (^)(NSInteger count, BOOL hasMore))success - failure:(void (^)(NSError *error))failure; - -/** - Fetches a specific post from the specified remote site - - @param postID The ID of the post to fetch. - @param siteID The ID of the post's site. - @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)fetchPost:(NSUInteger)postID - forSite:(NSUInteger)siteID - isFeed:(BOOL)isFeed - success:(void (^)(ReaderPost *post))success - failure:(void (^)(NSError *error))failure; - -/** - Fetches a specific post from the specified URL - - @param postURL The URL of the post to fetch - @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)fetchPostAtURL:(NSURL *)postURL - success:(void (^)(ReaderPost *post))success - failure:(void (^)(NSError *error))failure; - -/** - Silently refresh posts for the followed sites topic. - Note that calling this method creates a new service instance that performs - all its work on a derived managed object context, and background queue. - */ -- (void)refreshPostsForFollowedTopic; - -/** - Toggle the liked status of the specified post. - - @param post The reader post to like/unlike. - @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)toggleLikedForPost:(ReaderPost *)post - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; - -/** - Toggle the following status of the specified post's blog. - - @param post The ReaderPost whose blog should be followed. - @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)toggleFollowingForPost:(ReaderPost *)post - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; - -/** - Toggle the saved for later status of the specified post. - - @param post The reader post to like/unlike. - @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)toggleSavedForLaterForPost:(ReaderPost *)post - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; - -/** - Deletes all posts that do not belong to a `ReaderAbstractTopic` - Saves the NSManagedObjectContext. - */ -- (void)deletePostsWithNoTopic; - -/** - Sets the `isSavedForLater` flag to false for all posts. - */ -- (void)clearSavedPostFlags; - -/** - Globally sets the `inUse` flag to false for all posts. - */ -- (void)clearInUseFlags; - -/** - Delete posts from the specified site/feed from the specified topic - - @param siteID The id of the site or feed. - @param siteURL The URL of the site or feed. - @param topic The `ReaderAbstractTopic` owning the posts. - */ -- (void)deletePostsWithSiteID:(NSNumber *)siteID - andSiteURL:(NSString *)siteURL - fromTopic:(ReaderAbstractTopic *)topic; - -/** - Delete posts from the specified site (not feed) - - @param siteID The id of the site or feed. - */ - -- (void)deletePostsFromSiteWithID:(NSNumber *)siteID; - -- (void)flagPostsFromSite:(NSNumber *)siteID asBlocked:(BOOL)blocked; - -/** - Follows or unfollows the specified site. Posts belonging to that site and URL - have their following status updated in core data. - - @param following Whether the user is following the site. - @param siteID The ID of the site - @siteURL the URL of the site. - @param success block called on a successful call. - @param failure block called if there is any error. `error` can be any underlying network error. - */ -- (void)setFollowing:(BOOL)following - forWPComSiteWithID:(NSNumber *)siteID - andURL:(NSString *)siteURL - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure; - -/** - Updates in core data the following status of posts belonging to the specified site & url - - @param following Whether the user is following the site. - @param siteID The ID of the site - @siteURL the URL of the site. - */ -- (void)setFollowing:(BOOL)following forPostsFromSiteWithID:(NSNumber *)siteID andURL:(NSString *)siteURL; - -@end diff --git a/WordPress/Classes/Services/ReaderPostService.m b/WordPress/Classes/Services/ReaderPostService.m deleted file mode 100644 index a40f3c826f26..000000000000 --- a/WordPress/Classes/Services/ReaderPostService.m +++ /dev/null @@ -1,1273 +0,0 @@ -#import "ReaderPostService.h" - -#import "AccountService.h" -#import "ContextManager.h" -#import "ReaderGapMarker.h" -#import "ReaderPost.h" -#import "ReaderSiteService.h" -#import "SourcePostAttribution.h" -#import "WPAccount.h" -#import "WPAppAnalytics.h" -#import <WordPressShared/NSString+XMLExtensions.h> -#import "WordPress-Swift.h" -@import WordPressKit; -@import WordPressShared; - -NSUInteger const ReaderPostServiceNumberToSync = 40; -// NOTE: The search endpoint is currently capped to max results of 20 and returns -// a 500 error if more are requested. -// For performance reasons, request fewer results. EJ 2016-05-13 -NSUInteger const ReaderPostServiceNumberToSyncForSearch = 10; -NSUInteger const ReaderPostServiceMaxSearchPosts = 200; -NSUInteger const ReaderPostServiceMaxPosts = 300; -NSString * const ReaderPostServiceErrorDomain = @"ReaderPostServiceErrorDomain"; -NSString * const ReaderPostServiceToggleSiteFollowingState = @"ReaderPostServiceToggleSiteFollowingState"; - -static NSString * const ReaderPostGlobalIDKey = @"globalID"; -static NSString * const SourceAttributionSiteTaxonomy = @"site-pick"; -static NSString * const SourceAttributionImageTaxonomy = @"image-pick"; -static NSString * const SourceAttributionQuoteTaxonomy = @"quote-pick"; -static NSString * const SourceAttributionStandardTaxonomy = @"standard-pick"; - -@implementation ReaderPostService - -#pragma mark - Fetch Methods - -- (void)fetchPostsForTopic:(ReaderAbstractTopic *)topic - success:(void (^)(NSInteger count, BOOL hasMore))success - failure:(void (^)(NSError *error))failure -{ - [self fetchPostsForTopic:topic earlierThan:[NSDate date] success:success failure:failure]; -} - -- (void)fetchPostsForTopic:(ReaderAbstractTopic *)topic - earlierThan:(NSDate *)date - success:(void (^)(NSInteger count, BOOL hasMore))success - failure:(void (^)(NSError *error))failure -{ - [self fetchPostsForTopic:topic earlierThan:date deletingEarlier:NO success:success failure:failure]; -} - -- (void)fetchPostsForTopic:(ReaderAbstractTopic *)topic - atOffset:(NSUInteger)offset - deletingEarlier:(BOOL)deleteEarlier - success:(void (^)(NSInteger count, BOOL hasMore))success - failure:(void (^)(NSError *error))failure -{ - NSNumber *rank = @([[NSDate date] timeIntervalSinceReferenceDate]); - if (offset > 0) { - rank = [self rankForPostAtOffset:offset - 1 forTopic:topic]; - } - - if (offset >= ReaderPostServiceMaxSearchPosts && [topic isKindOfClass:[ReaderSearchTopic class]]) { - // A search supports a max offset of 199. If more are requested we want to bail early. - success(0, NO); - return; - } - - // Don't pass the algorithm if at the start of the results - NSString *reqAlgorithm = offset == 0 ? nil : topic.algorithm; - - NSManagedObjectID *topicObjectID = topic.objectID; - ReaderPostServiceRemote *remoteService = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; - [remoteService fetchPostsFromEndpoint:[NSURL URLWithString:topic.path] - algorithm:reqAlgorithm - count:[self numberToSyncForTopic:topic] - offset:offset - success:^(NSArray<RemoteReaderPost *> *posts, NSString *algorithm) { - [self updateTopic:topicObjectID withAlgorithm:algorithm]; - - [self mergePosts:posts - rankedLessThan:rank - forTopic:topicObjectID - deletingEarlier:deleteEarlier - callingSuccess:success]; - - } - failure:^(NSError *error) { - if (failure) { - failure(error); - } - }]; -} - - -- (void)updateTopic:(NSManagedObjectID *)topicObjectID withAlgorithm:(NSString *)algorithm -{ - [self.managedObjectContext performBlock:^{ - NSError *error; - ReaderAbstractTopic *topic = (ReaderAbstractTopic *)[self.managedObjectContext existingObjectWithID:topicObjectID error:&error]; - topic.algorithm = algorithm; - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - }]; -} - - -- (void)fetchPostsForTopic:(ReaderAbstractTopic *)topic - earlierThan:(NSDate *)date - deletingEarlier:(BOOL)deleteEarlier - success:(void (^)(NSInteger count, BOOL hasMore))success - failure:(void (^)(NSError *error))failure -{ - // Don't pass the algorithm if fetching a brand new list. - // When fetching the beginning of a date ordered list the date passed is "now". - // If the passed date is equal to the current date we know we're starting from scratch. - NSString *reqAlgorithm = [date isEqualToDate:[NSDate date]] ? nil : topic.algorithm; - - NSManagedObjectID *topicObjectID = topic.objectID; - ReaderPostServiceRemote *remoteService = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; - [remoteService fetchPostsFromEndpoint:[NSURL URLWithString:topic.path] - algorithm:reqAlgorithm - count:[self numberToSyncForTopic:topic] - before:date - success:^(NSArray *posts, NSString *algorithm) { - [self updateTopic:topicObjectID withAlgorithm:algorithm]; - - // Construct a rank from the date provided - NSNumber *rank = @([date timeIntervalSinceReferenceDate]); - [self mergePosts:posts - rankedLessThan:rank - forTopic:topicObjectID - deletingEarlier:deleteEarlier - callingSuccess:success]; - - } - failure:^(NSError *error) { - if (failure) { - failure(error); - } - }]; -} - -- (void)fetchPost:(NSUInteger)postID forSite:(NSUInteger)siteID isFeed:(BOOL)isFeed success:(void (^)(ReaderPost *post))success failure:(void (^)(NSError *error))failure -{ - ReaderPostServiceRemote *remoteService = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; - [remoteService fetchPost:postID fromSite:siteID isFeed:isFeed success:^(RemoteReaderPost *remotePost) { - if (!success) { - return; - } - - ReaderPost *post = [self createOrReplaceFromRemotePost:remotePost forTopic:nil]; - - NSError *error; - BOOL obtainedID = [self.managedObjectContext obtainPermanentIDsForObjects:@[post] error:&error]; - if (!obtainedID) { - DDLogError(@"Error obtaining a permanent ID for post. %@, %@", post, error); - } - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - success(post); - - } failure:^(NSError *error) { - if (failure) { - failure(error); - } - }]; -} - -- (void)fetchPostAtURL:(NSURL *)postURL - success:(void (^)(ReaderPost *post))success - failure:(void (^)(NSError *error))failure -{ - ReaderPostServiceRemote *remoteService = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; - [remoteService fetchPostAtURL:postURL - success:^(RemoteReaderPost *remotePost) { - if (!success) { - return; - } - - ReaderPost *post = [self createOrReplaceFromRemotePost:remotePost forTopic:nil]; - - NSError *error; - BOOL obtainedID = [self.managedObjectContext obtainPermanentIDsForObjects:@[post] error:&error]; - if (!obtainedID) { - DDLogError(@"Error obtaining a permanent ID for post. %@, %@", post, error); - } - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - success(post); - - } failure:^(NSError *error) { - if (failure) { - failure(error); - } - }]; -} - -- (void)refreshPostsForFollowedTopic -{ - // Do all of this work on a background thread. - NSManagedObjectContext *context = [[ContextManager sharedInstance] newDerivedContext]; - ReaderTopicService *topicService = [[ReaderTopicService alloc] initWithManagedObjectContext:context]; - [context performBlock:^{ - ReaderAbstractTopic *topic = [topicService topicForFollowedSites]; - if (topic) { - ReaderPostService *service = [[ReaderPostService alloc] initWithManagedObjectContext:context]; - [service fetchPostsForTopic:topic earlierThan:[NSDate date] deletingEarlier:YES success:nil failure:nil]; - } - }]; -} - - -#pragma mark - Update Methods - -- (void)toggleLikedForPost:(ReaderPost *)post success:(void (^)(void))success failure:(void (^)(NSError *error))failure -{ - [self.managedObjectContext performBlock:^{ - - // Get a the post in our own context - NSError *error; - ReaderPost *readerPost = (ReaderPost *)[self.managedObjectContext existingObjectWithID:post.objectID error:&error]; - if (error) { - dispatch_async(dispatch_get_main_queue(), ^{ - if (failure) { - failure(error); - } - }); - return; - } - - // Keep previous values in case of failure - BOOL oldValue = readerPost.isLiked; - BOOL like = !oldValue; - NSNumber *oldCount = [readerPost.likeCount copy]; - - // Optimistically update - readerPost.isLiked = like; - if (like) { - readerPost.likeCount = @([readerPost.likeCount integerValue] + 1); - } else { - readerPost.likeCount = @([readerPost.likeCount integerValue] - 1); - } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - - NSDictionary *railcar = [readerPost railcarDictionary]; - // Define success block. - NSNumber *postID = readerPost.postID; - NSNumber *siteID = readerPost.siteID; - void (^successBlock)(void) = ^void() { - if (postID && siteID) { - NSDictionary *properties = @{ - WPAppAnalyticsKeyPostID: postID, - WPAppAnalyticsKeyBlogID: siteID - }; - if (like) { - [WPAppAnalytics track:WPAnalyticsStatReaderArticleLiked withProperties:properties]; - if (railcar) { - [WPAppAnalytics trackTrainTracksInteraction:WPAnalyticsStatReaderArticleLiked withProperties:railcar]; - } - } else { - [WPAppAnalytics track:WPAnalyticsStatReaderArticleUnliked withProperties:properties]; - } - } - if (success) { - success(); - } - }; - - // Define failure block. Make sure rollback happens in the moc's queue, - void (^failureBlock)(NSError *error) = ^void(NSError *error) { - [self.managedObjectContext performBlockAndWait:^{ - // Revert changes on failure - readerPost.isLiked = oldValue; - readerPost.likeCount = oldCount; - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (failure) { - failure(error); - } - }]; - }]; - }; - - // Call the remote service. - ReaderPostServiceRemote *remoteService = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; - if (like) { - [remoteService likePost:[readerPost.postID integerValue] forSite:[readerPost.siteID integerValue] success:successBlock failure:failureBlock]; - } else { - [remoteService unlikePost:[readerPost.postID integerValue] forSite:[readerPost.siteID integerValue] success:successBlock failure:failureBlock]; - } - - }]; -} - -- (void)setFollowing:(BOOL)following - forWPComSiteWithID:(NSNumber *)siteID - andURL:(NSString *)siteURL - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure -{ - // Optimistically Update - [self setFollowing:following forPostsFromSiteWithID:siteID andURL:siteURL]; - - // Define success block - void (^successBlock)(void) = ^void() { - if (success) { - success(); - } - }; - - // Define failure block - void (^failureBlock)(NSError *error) = ^void(NSError *error) { - // Revert changes on failure - [self setFollowing:!following forPostsFromSiteWithID:siteID andURL:siteURL]; - - if (failure) { - failure(error); - } - }; - - ReaderSiteService *siteService = [[ReaderSiteService alloc] initWithManagedObjectContext:self.managedObjectContext]; - if (following) { - [siteService followSiteWithID:[siteID integerValue] success:successBlock failure:failureBlock]; - } else { - [siteService unfollowSiteWithID:[siteID integerValue] success:successBlock failure:failureBlock]; - } -} - -- (void)toggleFollowingForPost:(ReaderPost *)post success:(void (^)(void))success failure:(void (^)(NSError *error))failure -{ - // Get a the post in our own context - NSError *error; - ReaderPost *readerPost = (ReaderPost *)[self.managedObjectContext existingObjectWithID:post.objectID error:&error]; - if (error) { - if (failure) { - failure(error); - } - return; - } - - ReaderTopicService *topicService = [[ReaderTopicService alloc] initWithManagedObjectContext:self.managedObjectContext]; - // If this post belongs to a site topic, let the topic service do the work. - if ([readerPost.topic isKindOfClass:[ReaderSiteTopic class]]) { - ReaderSiteTopic *siteTopic = (ReaderSiteTopic *)readerPost.topic; - [topicService toggleFollowingForSite:siteTopic success:success failure:failure]; - return; - } - - // Keep previous values in case of failure - BOOL oldValue = readerPost.isFollowing; - BOOL follow = !oldValue; - - // Optimistically update - readerPost.isFollowing = follow; - [self setFollowing:follow forPostsFromSiteWithID:post.siteID andURL:post.blogURL]; - - - // If the post in question belongs to the default followed sites topic, skip refreshing. - // We don't want to jar the user. - BOOL shouldRefreshFollowedPosts = post.topic != [topicService topicForFollowedSites]; - - // Define success block - void (^successBlock)(void) = ^void() { - if (shouldRefreshFollowedPosts) { - [self refreshPostsForFollowedTopic]; - } - if (success) { - success(); - } - - dispatch_async(dispatch_get_main_queue(), ^{ - //Notifiy Settings view controller a site's following state has changed - [[NSNotificationCenter defaultCenter] postNotificationName:ReaderPostServiceToggleSiteFollowingState object:nil]; - }); - }; - - // Define failure block - void (^failureBlock)(NSError *error) = ^void(NSError *error) { - // Revert changes on failure - readerPost.isFollowing = oldValue; - [self setFollowing:oldValue forPostsFromSiteWithID:post.siteID andURL:post.blogURL]; - - if (failure) { - failure(error); - } - }; - - ReaderSiteService *siteService = [[ReaderSiteService alloc] initWithManagedObjectContext:self.managedObjectContext]; - if (!post.isExternal) { - if (follow) { - [siteService followSiteWithID:[post.siteID integerValue] success:successBlock failure:failureBlock]; - } else { - [siteService unfollowSiteWithID:[post.siteID integerValue] success:successBlock failure:failureBlock]; - } - } else if (post.blogURL) { - if (follow) { - [siteService followSiteAtURL:post.blogURL success:successBlock failure:failureBlock]; - } else { - [siteService unfollowSiteAtURL:post.blogURL success:successBlock failure:failureBlock]; - } - } else { - NSString *description = NSLocalizedString(@"Could not toggle Follow: missing blogURL attribute", @"An error description explaining that Follow could not be toggled due to a missing blogURL attribute."); - NSDictionary *userInfo = @{ NSLocalizedDescriptionKey : description }; - NSError *error = [NSError errorWithDomain:ReaderPostServiceErrorDomain code:0 userInfo:userInfo]; - failureBlock(error); - } -} - -- (void)toggleSavedForLaterForPost:(ReaderPost *)post - success:(void (^)(void))success - failure:(void (^)(NSError *error))failure -{ - [self.managedObjectContext performBlock:^{ - - // Get a the post in our own context - NSError *error; - ReaderPost *readerPost = (ReaderPost *)[self.managedObjectContext existingObjectWithID:post.objectID error:&error]; - if (error) { - dispatch_async(dispatch_get_main_queue(), ^{ - if (failure) { - failure(error); - } - }); - return; - } - - readerPost.isSavedForLater = !readerPost.isSavedForLater; - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - - success(); - }]; -} - -- (void)deletePostsWithNoTopic -{ - NSError *error; - NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - - NSPredicate *pred = [NSPredicate predicateWithFormat:@"topic = NULL AND inUse = false"]; - pred = [self predicateIgnoringSavedForLaterPosts:pred]; - [fetchRequest setPredicate:pred]; - - NSArray *arr = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - if (error) { - DDLogError(@"%@, error fetching posts belonging to no topic: %@", NSStringFromSelector(_cmd), error); - return; - } - - for (ReaderPost *post in arr) { - DDLogInfo(@"%@, deleting topicless post: %@", NSStringFromSelector(_cmd), post); - [self.managedObjectContext deleteObject:post]; - } - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; -} - -- (void)clearSavedPostFlags -{ - NSError *error; - NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - request.predicate = [NSPredicate predicateWithFormat:@"isSavedForLater = true"]; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@, unsaving saved posts: %@", NSStringFromSelector(_cmd), error); - return; - } - - for (ReaderPost *post in results) { - post.isSavedForLater = NO; - } - - [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext]; -} - -- (void)clearInUseFlags -{ - NSError *error; - NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - request.predicate = [NSPredicate predicateWithFormat:@"inUse = true"]; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@, marking posts not in use.: %@", NSStringFromSelector(_cmd), error); - return; - } - - for (ReaderPost *post in results) { - post.inUse = NO; - } - - [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext]; -} - -- (void)setFollowing:(BOOL)following forPostsFromSiteWithID:(NSNumber *)siteID andURL:(NSString *)siteURL -{ - // Fetch all the posts for the specified site ID and update its following status - NSError *error; - NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - request.predicate = [NSPredicate predicateWithFormat:@"siteID = %@ AND blogURL = %@", siteID, siteURL]; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@, error (un)following posts with siteID %@ and URL @%: %@", NSStringFromSelector(_cmd), siteID, siteURL, error); - return; - } - if ([results count] == 0) { - return; - } - - for (ReaderPost *post in results) { - post.isFollowing = following; - } - [self.managedObjectContext performBlock:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - }]; -} - -- (void)deletePostsWithSiteID:(NSNumber *)siteID andSiteURL:(NSString *)siteURL fromTopic:(ReaderAbstractTopic *)topic -{ - NSError *error; - NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - NSString *likeSiteURL = [NSString stringWithFormat:@"%@*", siteURL]; - NSPredicate *postsMatching = [NSPredicate predicateWithFormat:@"siteID = %@ AND permaLink LIKE %@ AND topic = %@", siteID, likeSiteURL, topic]; - request.predicate = [self predicateIgnoringSavedForLaterPosts:postsMatching]; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@, error (un)following posts with siteID %@ and URL @%: %@", NSStringFromSelector(_cmd), siteID, siteURL, error); - return; - } - - if ([results count] == 0) { - return; - } - - for (ReaderPost *post in results) { - [self.managedObjectContext deleteObject:post]; - } - - [self.managedObjectContext performBlockAndWait:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - }]; -} - -- (void)deletePostsFromSiteWithID:(NSNumber *)siteID -{ - NSError *error; - NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - NSPredicate *postsMatching = [NSPredicate predicateWithFormat:@"siteID = %@ AND isWPCom = YES", siteID]; - request.predicate = [self predicateIgnoringSavedForLaterPosts:postsMatching]; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@, error deleting posts belonging to siteID %@: %@", NSStringFromSelector(_cmd), siteID, error); - return; - } - - if ([results count] == 0) { - return; - } - - for (ReaderPost *post in results) { - DDLogInfo(@"Deleting post: %@", post); - [self.managedObjectContext deleteObject:post]; - } - - [self.managedObjectContext performBlockAndWait:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - }]; -} - -- (void)flagPostsFromSite:(NSNumber *)siteID asBlocked:(BOOL)blocked -{ - NSError *error; - NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - request.predicate = [NSPredicate predicateWithFormat:@"siteID = %@ AND isWPCom = YES", siteID]; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@, error deleting posts belonging to siteID %@: %@", NSStringFromSelector(_cmd), siteID, error); - return; - } - - if ([results count] == 0) { - return; - } - - for (ReaderPost *post in results) { - post.isSiteBlocked = blocked; - } - - [self.managedObjectContext performBlockAndWait:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - }]; -} - - -#pragma mark - Private Methods - -/** - Get the api to use for the request. - */ -- (WordPressComRestApi *)apiForRequest -{ - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; - WordPressComRestApi *api = [defaultAccount wordPressComRestApi]; - if (![api hasCredentials]) { - api = [WordPressComRestApi defaultApiWithOAuthToken:nil - userAgent:[WPUserAgent wordPressUserAgent] - localeKey:[WordPressComRestApi LocaleKeyDefault]]; - } - return api; -} - -- (NSUInteger)numberToSyncForTopic:(ReaderAbstractTopic *)topic -{ - return [topic isKindOfClass:[ReaderSearchTopic class]] ? ReaderPostServiceNumberToSyncForSearch : ReaderPostServiceNumberToSync; -} - -- (NSUInteger)maxPostsToSaveForTopic:(ReaderAbstractTopic *)topic -{ - return [topic isKindOfClass:[ReaderSearchTopic class]] ? ReaderPostServiceMaxSearchPosts : ReaderPostServiceMaxPosts; -} - -- (NSUInteger)numberOfPostsForTopic:(ReaderAbstractTopic *)topic -{ - NSError *error; - NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - fetchRequest.includesSubentities = NO; // Exclude gap markers when counting. - NSPredicate *pred = [NSPredicate predicateWithFormat:@"topic = %@", topic]; - [fetchRequest setPredicate:pred]; - - NSUInteger count = [self.managedObjectContext countForFetchRequest:fetchRequest error:&error]; - return count; -} - -- (NSNumber *)rankForPostAtOffset:(NSUInteger)offset forTopic:(ReaderAbstractTopic *)topic -{ - NSError *error; - NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"topic = %@", topic]; - [fetchRequest setPredicate:predicate]; - - NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"sortRank" ascending:NO]; - [fetchRequest setSortDescriptors:@[sortDescriptor]]; - - fetchRequest.fetchOffset = offset; - fetchRequest.fetchLimit = 1; - - ReaderPost *post = [[self.managedObjectContext executeFetchRequest:fetchRequest error:&error] firstObject]; - if (error || !post) { - DDLogError(@"Error fetching post at a specific offset.", error); - return nil; - } - - return post.sortRank; -} - -- (NSPredicate *)predicateIgnoringSavedForLaterPosts:(NSPredicate*)fromPredicate -{ - return [NSCompoundPredicate andPredicateWithSubpredicates:@[fromPredicate, [self notSavedForLaterPredicate]]]; -} - -- (NSPredicate *)notSavedForLaterPredicate -{ - return [NSPredicate predicateWithFormat:@"isSavedForLater == NO"]; -} - - -#pragma mark - Merging and Deletion - -/** - Merge a freshly fetched batch of posts into the existing set of posts for the specified topic. - Saves the managed object context. - - @param remotePosts An array of RemoteReaderPost objects - @param date The `before` date posts were requested. - @param topicObjectID The ObjectID of the ReaderAbstractTopic to assign to the newly created posts. - @param success block called on a successful fetch which should be performed after merging - */ -- (void)mergePosts:(NSArray *)remotePosts - rankedLessThan:(NSNumber *)rank - forTopic:(NSManagedObjectID *)topicObjectID - deletingEarlier:(BOOL)deleteEarlier - callingSuccess:(void (^)(NSInteger count, BOOL hasMore))success -{ - // Use a performBlock here so the work to merge does not block the main thread. - [self.managedObjectContext performBlock:^{ - - if (self.managedObjectContext.parentContext == [[ContextManager sharedInstance] mainContext]) { - // Its possible the ReaderAbstractTopic was deleted the parent main context. - // If so, and we merge and save, it will cause a crash. - // Reset the context so it will be current with its parent context. - [self.managedObjectContext reset]; - } - - NSError *error; - ReaderAbstractTopic *readerTopic = (ReaderAbstractTopic *)[self.managedObjectContext existingObjectWithID:topicObjectID error:&error]; - if (error || !readerTopic) { - // if there was an error or the topic was deleted just bail. - if (success) { - success(0, NO); - } - return; - } - - NSUInteger postsCount = [remotePosts count]; - if (postsCount == 0) { - [self deletePostsRankedLessThan:rank forTopic:readerTopic]; - - } else { - NSArray *posts = remotePosts; - BOOL overlap = NO; - - if (!deleteEarlier) { - // Before processing the new posts, check if there is an overlap between - // what is currently cached, and what is being synced. - overlap = [self checkIfRemotePosts:posts overlapExistingPostsinTopic:readerTopic]; - - // A strategy to avoid false positives in gap detection is to sync - // one extra post. Only remove the extra post if we received a - // full set of results. A partial set means we've reached - // the end of syncable content. - if ([posts count] == [self numberToSyncForTopic:readerTopic] && ![ReaderHelpers isTopicSearchTopic:readerTopic]) { - posts = [posts subarrayWithRange:NSMakeRange(0, [posts count] - 1)]; - postsCount = [posts count]; - } - - } - - // Create or update the synced posts. - NSMutableArray *newPosts = [self makeNewPostsFromRemotePosts:posts forTopic:readerTopic]; - - // When refreshing, some content previously synced may have been deleted remotely. - // Remove anything we've synced that is missing. - // NOTE that this approach leaves the possibility for older posts to not be cleaned up. - [self deletePostsForTopic:readerTopic missingFromBatch:newPosts withStartingRank:rank]; - - // If deleting earlier, delete every post older than the last post in this batch. - if (deleteEarlier) { - ReaderPost *lastPost = [newPosts lastObject]; - [self deletePostsRankedLessThan:lastPost.sortRank forTopic:readerTopic]; - [self removeGapMarkerForTopic:readerTopic]; // Paranoia - - } else { - - // Handle an overlap in posts that were synced - if (overlap) { - [self removeGapMarkerForTopic:readerTopic ifNewPostsOverlapMarker:newPosts]; - - } else { - // If there are existing posts older than the oldest of the - // new posts then append a gap placeholder to the end of the - // new posts - ReaderPost *lastPost = [newPosts lastObject]; - if ([self topic:readerTopic hasPostsRankedLessThan:lastPost.sortRank]) { - [self insertGapMarkerBeforePost:lastPost forTopic:readerTopic]; - } - } - } - } - - // Clean up - [self deletePostsInExcessOfMaxAllowedForTopic:readerTopic]; - [self deletePostsFromBlockedSites]; - - BOOL hasMore = NO; - BOOL spaceAvailable = ([self numberOfPostsForTopic:readerTopic] < [self maxPostsToSaveForTopic:readerTopic]); - if ([ReaderHelpers isTopicTag:readerTopic]) { - // For tags, assume there is more content as long as more than zero results are returned. - hasMore = (postsCount > 0 ) && spaceAvailable; - } else { - // For other topics, assume there is more content as long as the number of results requested is returned. - hasMore = ([remotePosts count] == [self numberToSyncForTopic:readerTopic]) && spaceAvailable; - } - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - // Is called on main queue - if (success) { - success(postsCount, hasMore); - } - }]; - }]; -} - -- (BOOL)checkIfRemotePosts:(NSArray *)remotePosts overlapExistingPostsinTopic:(ReaderAbstractTopic *)readerTopic -{ - // Get global IDs of new posts to use as part of the predicate. - NSSet *remoteGlobalIDs = [self globalIDsOfRemotePosts:remotePosts]; - - // Fetch matching existing posts. - NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([ReaderPost class])]; - fetchRequest.predicate = [NSPredicate predicateWithFormat:@"topic = %@ AND globalID in %@", readerTopic, remoteGlobalIDs]; - - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - if (error) { - DDLogError(error.localizedDescription); - return NO; - } - - // For each match, check that the dates are the same. If at least one date is the same then there is an overlap so return true. - // If the dates are different then the existing cached post will be updated. Don't treat this as overlap. - for (ReaderPost *post in results) { - for (RemoteReaderPost *remotePost in remotePosts) { - if (![remotePost.globalID isEqualToString:post.globalID]) { - continue; - } - if ([post.sortDate isEqualToDate:remotePost.sortDate]) { - return YES; - } - } - } - - return NO; -} - -#pragma mark Gap Detection Methods - -- (void)removeGapMarkerForTopic:(ReaderAbstractTopic *)topic ifNewPostsOverlapMarker:(NSArray *)newPosts -{ - ReaderGapMarker *gapMarker = [self gapMarkerForTopic:topic]; - if (gapMarker) { - double highestRank = [((ReaderPost *)newPosts.firstObject).sortRank doubleValue]; - double lowestRank = [((ReaderPost *)newPosts.lastObject).sortRank doubleValue]; - double gapRank = [gapMarker.sortRank doubleValue]; - // Confirm the overlap includes the gap marker. - if (lowestRank < gapRank && gapRank < highestRank) { - // No need for a gap placeholder. Remove any that existed - [self removeGapMarkerForTopic:topic]; - } - } -} - -- (ReaderGapMarker *)gapMarkerForTopic:(ReaderAbstractTopic *)topic -{ - NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([ReaderGapMarker class])]; - fetchRequest.predicate = [NSPredicate predicateWithFormat:@"topic = %@", topic]; - - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - if (error) { - DDLogError(error.localizedDescription); - return nil; - } - - // Assume there will ever only be one and return the first result. - return results.firstObject; -} - -- (void)insertGapMarkerBeforePost:(ReaderPost *)post forTopic:(ReaderAbstractTopic *)topic -{ - [self removeGapMarkerForTopic:topic]; - - ReaderGapMarker *marker = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([ReaderGapMarker class]) - inManagedObjectContext:self.managedObjectContext]; - - // Synced posts do not use millisecond precision for their dates. We can take - // advantage of this and make our marker post a fraction of a second earlier - // than the last post. - // We'll store the unmodifed sort date as date_create_gmt so we have a convenient - // and accurate date reference should we need it. - marker.sortDate = [post.sortDate dateByAddingTimeInterval:-0.1]; - marker.date_created_gmt = post.sortDate; - - // For compatability with posts that are sorted by score - marker.sortRank = @([post.sortRank doubleValue] - CGFLOAT_MIN); - marker.score = post.score; - - marker.topic = topic; -} - - -- (void)removeGapMarkerForTopic:(ReaderAbstractTopic *)topic -{ - NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([ReaderGapMarker class])]; - fetchRequest.predicate = [NSPredicate predicateWithFormat:@"topic = %@", topic]; - - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - if (error) { - DDLogError(error.localizedDescription); - return; - } - - // There should only ever be one, but loop over all results just in case. - for (ReaderGapMarker *marker in results) { - DDLogInfo(@"Deleting Gap Marker: %@", marker); - [self.managedObjectContext deleteObject:marker]; - } -} - -- (BOOL)topic:(ReaderAbstractTopic *)topic hasPostsRankedLessThan:(NSNumber *)rank -{ - NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:NSStringFromClass([ReaderPost class])]; - fetchRequest.predicate = [NSPredicate predicateWithFormat:@"topic = %@ AND sortRank < %@", topic, rank]; - - NSError *error; - NSInteger count = [self.managedObjectContext countForFetchRequest:fetchRequest error:&error]; - if (error) { - DDLogError(error.localizedDescription); - return NO; - } - return (count > 0); -} - -- (NSSet *)globalIDsOfRemotePosts:(NSArray *)remotePosts -{ - NSMutableArray *arr = [NSMutableArray array]; - for (RemoteReaderPost *post in remotePosts) { - [arr addObject:post.globalID]; - } - // return non-mutable array - return [NSSet setWithArray:arr]; -} - - -#pragma mark Deletion and Clean up - -/** - Deletes any existing post whose sortRank is less than the passed rank. This - is to handle situations where posts have been synced but were subsequently removed - from the result set (deleted, unliked, etc.) rendering the result set empty. - - @param rank The sortRank to delete posts less than. - @param topic The `ReaderAbstractTopic` to delete posts from. - */ -- (void)deletePostsRankedLessThan:(NSNumber *)rank forTopic:(ReaderAbstractTopic *)topic -{ - // Don't trust the relationships on the topic to be current or correct. - NSError *error; - NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - - NSPredicate *pred = [NSPredicate predicateWithFormat:@"topic = %@ AND sortRank < %@", topic, rank]; - [fetchRequest setPredicate:pred]; - - NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"sortRank" ascending:NO]; - [fetchRequest setSortDescriptors:@[sortDescriptor]]; - - NSArray *currentPosts = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - if (error) { - DDLogError(@"%@ error fetching posts: %@", NSStringFromSelector(_cmd), error); - return; - } - - for (ReaderPost *post in currentPosts) { - if (post.isSavedForLater) { - // If the missing post is currently being used or has been saved, just remove its topic. - post.topic = nil; - } else { - DDLogInfo(@"Deleting ReaderPost: %@", post); - [self.managedObjectContext deleteObject:post]; - } - } -} - -/** - Using an array of post as a filter, deletes any existing post whose sortRank falls - within the range of the filter posts, but is not included in the filter posts. - - This let's us remove unliked posts from /read/liked, posts from blogs that are - unfollowed from /read/following, or posts that were otherwise removed. - - The managed object context is not saved. - - @param topic The ReaderAbstractTopic to delete posts from. - @param posts The batch of posts to use as a filter. - @param startingRank The starting rank of the batch of posts. May be less than the highest ranked post in the batch. - */ -- (void)deletePostsForTopic:(ReaderAbstractTopic *)topic missingFromBatch:(NSArray *)posts withStartingRank:(NSNumber *)startingRank -{ - // Don't trust the relationships on the topic to be current or correct. - NSError *error; - NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - - NSNumber *highestRank = startingRank; - - NSNumber *lowestRank = ((ReaderPost *)[posts lastObject]).sortRank; - NSPredicate *pred = [NSPredicate predicateWithFormat:@"topic == %@ AND sortRank > %@ AND sortRank < %@", topic, lowestRank, highestRank]; - - [fetchRequest setPredicate:pred]; - - NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"sortRank" ascending:NO]; - [fetchRequest setSortDescriptors:@[sortDescriptor]]; - - NSArray *currentPosts = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - if (error) { - DDLogError(@"%@ error fetching posts: %@", NSStringFromSelector(_cmd), error); - return; - } - - for (ReaderPost *post in currentPosts) { - if ([posts containsObject:post]) { - continue; - } - // The post was missing from the batch and needs to be cleaned up. - if ([self topicShouldBeClearedFor:post]) { - // If the missing post is currently being used or has been saved, just remove its topic. - post.topic = nil; - } else { - DDLogInfo(@"Deleting ReaderPost: %@", post); - [self.managedObjectContext deleteObject:post]; - } - } -} - -/** - Delete all `ReaderPosts` beyond the max number to be retained. - - The managed object context is not saved. - - @param topic the `ReaderAbstractTopic` to delete posts from. - */ -- (void)deletePostsInExcessOfMaxAllowedForTopic:(ReaderAbstractTopic *)topic -{ - // Don't trust the relationships on the topic to be current or correct. - NSError *error; - NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - - NSPredicate *pred = [NSPredicate predicateWithFormat:@"topic = %@", topic]; - [fetchRequest setPredicate:pred]; - - NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"sortRank" ascending:NO]; - [fetchRequest setSortDescriptors:@[sortDescriptor]]; - - NSUInteger maxPosts = [self maxPostsToSaveForTopic:topic]; - - // Specifying a fetchOffset to just get the posts in range doesn't seem to work very well. - // Just perform the fetch and remove the excess. - NSUInteger count = [self.managedObjectContext countForFetchRequest:fetchRequest error:&error]; - if (count <= maxPosts) { - return; - } - - NSArray *posts = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - if (error) { - DDLogError(@"%@ error fetching posts: %@", NSStringFromSelector(_cmd), error); - return; - } - - NSRange range = NSMakeRange(maxPosts, [posts count] - maxPosts); - NSArray *postsToDelete = [posts subarrayWithRange:range]; - for (ReaderPost *post in postsToDelete) { - if ([self topicShouldBeClearedFor:post]) { - post.topic = nil; - } else { - DDLogInfo(@"Deleting ReaderPost: %@", post.postTitle); - [self.managedObjectContext deleteObject:post]; - } - } - - // If the last remaining post is a gap marker, remove it. - ReaderPost *lastPost = [posts objectAtIndex:maxPosts - 1]; - if ([lastPost isKindOfClass:[ReaderGapMarker class]]) { - DDLogInfo(@"Deleting Last GapMarker: %@", lastPost); - [self.managedObjectContext deleteObject:lastPost]; - } -} - -/** - Delete posts that are flagged as belonging to a blocked site. - - The managed object context is not saved. - */ -- (void)deletePostsFromBlockedSites -{ - NSError *error; - NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - request.predicate = [NSPredicate predicateWithFormat:@"isSiteBlocked = YES"]; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@, error deleting deleting posts from blocked sites: %@", NSStringFromSelector(_cmd), error); - return; - } - - if ([results count] == 0) { - return; - } - - for (ReaderPost *post in results) { - if ([self topicShouldBeClearedFor:post]) { - // If the missing post is currenty being used just remove its topic. - post.topic = nil; - } else { - DDLogInfo(@"Deleting ReaderPost: %@", post.postTitle); - [self.managedObjectContext deleteObject:post]; - } - } -} - -- (BOOL)topicShouldBeClearedFor:(ReaderPost *)post -{ - return (post.inUse || post.isSavedForLater); -} - - -#pragma mark Entity Creation - -/** - Accepts an array of `RemoteReaderPost` objects and creates model objects - for each one. - - @param posts An array of `RemoteReaderPost` objects. - @param topic The `ReaderAbsractTopic` to assign to the created posts. - @return An array of `ReaderPost` objects - */ -- (NSMutableArray *)makeNewPostsFromRemotePosts:(NSArray *)posts forTopic:(ReaderAbstractTopic *)topic -{ - NSMutableArray *newPosts = [NSMutableArray array]; - for (RemoteReaderPost *post in posts) { - ReaderPost *newPost = [self createOrReplaceFromRemotePost:post forTopic:topic]; - if (newPost != nil) { - [newPosts addObject:newPost]; - } else { - DDLogInfo(@"%@ returned a nil post: %@", NSStringFromSelector(_cmd), post); - } - } - return newPosts; -} - -/** - Create a `ReaderPost` model object from the specified dictionary. - - @param dict A `RemoteReaderPost` object. - @param topic The `ReaderAbstractTopic` to assign to the created post. - @return A `ReaderPost` model object whose properties are populated with the values from the passed dictionary. - */ -- (ReaderPost *)createOrReplaceFromRemotePost:(RemoteReaderPost *)remotePost forTopic:(ReaderAbstractTopic *)topic -{ - NSError *error; - ReaderPost *post; - NSString *globalID = remotePost.globalID; - NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; - fetchRequest.predicate = [NSPredicate predicateWithFormat:@"globalID = %@ AND (topic = %@ OR topic = NULL)", globalID, topic]; - NSArray *arr = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - - BOOL existing = false; - if (error) { - DDLogError(@"Error fetching an existing reader post. - %@", error); - } else if ([arr count] > 0) { - post = (ReaderPost *)[arr objectAtIndex:0]; - existing = YES; - } else { - post = [NSEntityDescription insertNewObjectForEntityForName:@"ReaderPost" - inManagedObjectContext:self.managedObjectContext]; - } - - post.author = remotePost.author; - post.authorAvatarURL = remotePost.authorAvatarURL; - post.authorDisplayName = remotePost.authorDisplayName; - post.authorEmail = remotePost.authorEmail; - post.authorURL = remotePost.authorURL; - post.siteIconURL = remotePost.siteIconURL; - post.blogName = remotePost.blogName; - post.blogDescription = remotePost.blogDescription; - post.blogURL = remotePost.blogURL; - post.commentCount = remotePost.commentCount; - post.commentsOpen = remotePost.commentsOpen; - post.content = remotePost.content; - post.date_created_gmt = [DateUtils dateFromISOString:remotePost.date_created_gmt]; - post.featuredImage = remotePost.featuredImage; - post.feedID = remotePost.feedID; - post.feedItemID = remotePost.feedItemID; - post.globalID = remotePost.globalID; - post.isBlogPrivate = remotePost.isBlogPrivate; - post.isFollowing = remotePost.isFollowing; - post.isLiked = remotePost.isLiked; - post.isReblogged = remotePost.isReblogged; - post.isWPCom = remotePost.isWPCom; - post.likeCount = remotePost.likeCount; - post.permaLink = remotePost.permalink; - post.postID = remotePost.postID; - post.postTitle = remotePost.postTitle; - post.railcar = remotePost.railcar; - post.score = remotePost.score; - post.siteID = remotePost.siteID; - post.sortDate = remotePost.sortDate; - - if (existing && [topic isKindOfClass:[ReaderSearchTopic class]]) { - // Failsafe. The `read/search` endpoint might return the same post on - // more than one page. If this happens preserve the *original* sortRank - // to avoid content jumping around in the UI. - } else { - post.sortRank = remotePost.sortRank; - } - - post.status = remotePost.status; - post.summary = remotePost.summary; - post.tags = remotePost.tags; - post.isSharingEnabled = remotePost.isSharingEnabled; - post.isLikesEnabled = remotePost.isLikesEnabled; - post.isSiteBlocked = NO; - - if (remotePost.crossPostMeta) { - if (!post.crossPostMeta) { - ReaderCrossPostMeta *meta = (ReaderCrossPostMeta *)[NSEntityDescription insertNewObjectForEntityForName:[ReaderCrossPostMeta classNameWithoutNamespaces] - inManagedObjectContext:self.managedObjectContext]; - post.crossPostMeta = meta; - } - post.crossPostMeta.siteURL = remotePost.crossPostMeta.siteURL; - post.crossPostMeta.postURL = remotePost.crossPostMeta.postURL; - post.crossPostMeta.commentURL = remotePost.crossPostMeta.commentURL; - post.crossPostMeta.siteID = remotePost.crossPostMeta.siteID; - post.crossPostMeta.postID = remotePost.crossPostMeta.postID; - } else { - post.crossPostMeta = nil; - } - - NSString *tag = remotePost.primaryTag; - NSString *slug = remotePost.primaryTagSlug; - if ([topic isKindOfClass:[ReaderTagTopic class]]) { - ReaderTagTopic *tagTopic = (ReaderTagTopic *)topic; - if ([tagTopic.slug isEqualToString:remotePost.primaryTagSlug]) { - tag = remotePost.secondaryTag; - slug = remotePost.secondaryTagSlug; - } - } - post.primaryTag = tag; - post.primaryTagSlug = slug; - - post.isExternal = remotePost.isExternal; - post.isJetpack = remotePost.isJetpack; - post.wordCount = remotePost.wordCount; - post.readingTime = remotePost.readingTime; - - if (remotePost.sourceAttribution) { - post.sourceAttribution = [self createOrReplaceFromRemoteDiscoverAttribution:remotePost.sourceAttribution forPost:post]; - } else { - post.sourceAttribution = nil; - } - - // assign the topic last. - post.topic = topic; - - return post; -} - -- (SourcePostAttribution *)createOrReplaceFromRemoteDiscoverAttribution:(RemoteSourcePostAttribution *)remoteAttribution - forPost:(ReaderPost *)post -{ - SourcePostAttribution *attribution = post.sourceAttribution; - - if (!attribution) { - attribution = [NSEntityDescription insertNewObjectForEntityForName:NSStringFromClass([SourcePostAttribution class]) - inManagedObjectContext:self.managedObjectContext]; - } - attribution.authorName = remoteAttribution.authorName; - attribution.authorURL = remoteAttribution.authorURL; - attribution.avatarURL = remoteAttribution.avatarURL; - attribution.blogName = remoteAttribution.blogName; - attribution.blogURL = remoteAttribution.blogURL; - attribution.permalink = remoteAttribution.permalink; - attribution.blogID = remoteAttribution.blogID; - attribution.postID = remoteAttribution.postID; - attribution.commentCount = remoteAttribution.commentCount; - attribution.likeCount = remoteAttribution.likeCount; - attribution.attributionType = [self attributionTypeFromTaxonomies:remoteAttribution.taxonomies]; - return attribution; -} - -- (NSString *)attributionTypeFromTaxonomies:(NSArray *)taxonomies -{ - if ([taxonomies containsObject:SourceAttributionSiteTaxonomy]) { - return SourcePostAttributionTypeSite; - } - - if ([taxonomies containsObject:SourceAttributionImageTaxonomy] || - [taxonomies containsObject:SourceAttributionQuoteTaxonomy] || - [taxonomies containsObject:SourceAttributionStandardTaxonomy] ) { - return SourcePostAttributionTypePost; - } - - return nil; -} - -@end diff --git a/WordPress/Classes/Services/ReaderPostStreamService.swift b/WordPress/Classes/Services/ReaderPostStreamService.swift new file mode 100644 index 000000000000..e80706cfd3e7 --- /dev/null +++ b/WordPress/Classes/Services/ReaderPostStreamService.swift @@ -0,0 +1,78 @@ +import Foundation + +class ReaderPostStreamService { + + private let coreDataStack: CoreDataStack + + private var nextPageHandle: String? + private var pageNumber: Int = 0 + + init(coreDataStack: CoreDataStack) { + self.coreDataStack = coreDataStack + } + + func fetchPosts(for topic: ReaderTagTopic, isFirstPage: Bool = true, success: @escaping (Int, Bool) -> Void, failure: @escaping (Error?) -> Void) { + if isFirstPage { + nextPageHandle = nil + } + + let remoteService = ReaderPostServiceRemote.withDefaultApi() + remoteService.fetchPosts(for: [topic.slug], page: nextPageHandle, success: { posts, pageHandle in + self.coreDataStack.performAndSave({ context in + guard let readerTopic = try? context.existingObject(with: topic.objectID) as? ReaderAbstractTopic else { + // if there was an error or the topic was deleted just bail. + success(0, false) + return + } + + self.nextPageHandle = pageHandle + + if isFirstPage { + self.pageNumber = 1 + self.removePosts(forTopic: readerTopic, in: context) + } else { + self.pageNumber += 1 + } + + posts.enumerated().forEach { index, remotePost in + let post = ReaderPost.createOrReplace(fromRemotePost: remotePost, for: readerTopic, context: context) + // To keep the API order + post?.sortRank = NSNumber(value: Date().timeIntervalSinceReferenceDate - Double(((self.pageNumber * Constants.paginationMultiplier) + index))) + } + + // Clean up + let serivce = ReaderPostService(coreDataStack: self.coreDataStack) + serivce.deletePostsInExcessOfMaxAllowed(for: readerTopic) + serivce.deletePostsFromBlockedSites() + }, completion: { + let hasMore = pageHandle != nil + success(posts.count, hasMore) + }, on: .main) + }, failure: { error in + failure(error) + }) + } + + private func removePosts(forTopic topic: ReaderAbstractTopic, in context: NSManagedObjectContext) { + let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: ReaderPost.classNameWithoutNamespaces()) + fetchRequest.predicate = NSPredicate(format: "topic == %@", argumentArray: [topic]) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "sortRank", ascending: false)] + fetchRequest.returnsObjectsAsFaults = false + + do { + let results = try context.fetch(fetchRequest) + for object in results { + guard let objectData = object as? NSManagedObject else { continue } + context.delete(objectData) + + // Checar se ta em uso ou foi salvo + } + } catch let error { + print("Clean post error:", error) + } + } + + private enum Constants { + static let paginationMultiplier = 100 + } +} diff --git a/WordPress/Classes/Services/ReaderSearchSuggestionService.swift b/WordPress/Classes/Services/ReaderSearchSuggestionService.swift index aaa21f5fab2f..9c476d6de7ac 100644 --- a/WordPress/Classes/Services/ReaderSearchSuggestionService.swift +++ b/WordPress/Classes/Services/ReaderSearchSuggestionService.swift @@ -4,22 +4,33 @@ import CocoaLumberjack /// Provides functionality for fetching, saving, and deleting search phrases /// used to search for content in the reader. /// -@objc class ReaderSearchSuggestionService: LocalCoreDataService { +@objc class ReaderSearchSuggestionService: NSObject { + + private let coreDataStack: CoreDataStack + + @objc init(coreDataStack: CoreDataStack) { + self.coreDataStack = coreDataStack + super.init() + } /// Creates or updates an existing record for the specified search phrase. /// /// - Parameters: /// - phrase: The search phrase in question. /// - @objc func createOrUpdateSuggestionForPhrase(_ phrase: String) { - var suggestion = findSuggestionForPhrase(phrase) - if suggestion == nil { - suggestion = NSEntityDescription.insertNewObject(forEntityName: ReaderSearchSuggestion.classNameWithoutNamespaces(), - into: managedObjectContext) as? ReaderSearchSuggestion - suggestion?.searchPhrase = phrase + @objc(createOrUpdateSuggestionForPhrase:) + func createOrUpdateSuggestion(forPhrase phrase: String) { + self.coreDataStack.performAndSave { context in + var suggestion = self.findSuggestion(forPhrase: phrase, in: context) + if suggestion == nil { + suggestion = NSEntityDescription.insertNewObject( + forEntityName: ReaderSearchSuggestion.classNameWithoutNamespaces(), + into: context + ) as? ReaderSearchSuggestion + suggestion?.searchPhrase = phrase + } + suggestion?.date = Date() } - suggestion?.date = Date() - ContextManager.sharedInstance().save(managedObjectContext) } @@ -30,13 +41,13 @@ import CocoaLumberjack /// /// - Returns: A matching search phrase or nil. /// - @objc func findSuggestionForPhrase(_ phrase: String) -> ReaderSearchSuggestion? { + private func findSuggestion(forPhrase phrase: String, in context: NSManagedObjectContext) -> ReaderSearchSuggestion? { let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "ReaderSearchSuggestion") fetchRequest.predicate = NSPredicate(format: "searchPhrase MATCHES[cd] %@", phrase) var suggestions = [ReaderSearchSuggestion]() do { - suggestions = try managedObjectContext.fetch(fetchRequest) as! [ReaderSearchSuggestion] + suggestions = try context.fetch(fetchRequest) as! [ReaderSearchSuggestion] } catch let error as NSError { DDLogError("Error fetching search suggestion for phrase \(phrase) : \(error.localizedDescription)") } @@ -44,57 +55,18 @@ import CocoaLumberjack return suggestions.first } - - /// Finds and returns all ReaderSearchSuggestion starting with the specified search phrase. - /// - /// - Parameters: - /// - phrase: The search phrase in question. - /// - /// - Returns: An array of matching `ReaderSearchSuggestion`s. - /// - @objc func fetchSuggestionsLikePhrase(_ phrase: String) -> [ReaderSearchSuggestion] { - let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "ReaderSearchSuggestion") - fetchRequest.predicate = NSPredicate(format: "searchPhrase BEGINSWITH[cd] %@", phrase) - - let sort = NSSortDescriptor(key: "date", ascending: false) - fetchRequest.sortDescriptors = [sort] - - var suggestions = [ReaderSearchSuggestion]() - do { - suggestions = try managedObjectContext.fetch(fetchRequest) as! [ReaderSearchSuggestion] - } catch let error as NSError { - DDLogError("Error fetching search suggestions for phrase \(phrase) : \(error.localizedDescription)") - } - - return suggestions - } - - - /// Deletes the specified search suggestion. - /// - /// - Parameters: - /// - suggestion: The `ReaderSearchSuggestion` to delete. - /// - @objc func deleteSuggestion(_ suggestion: ReaderSearchSuggestion) { - managedObjectContext.delete(suggestion) - ContextManager.sharedInstance().saveContextAndWait(managedObjectContext) - } - - /// Deletes all saved search suggestions. /// @objc func deleteAllSuggestions() { - let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "ReaderSearchSuggestion") - var suggestions = [ReaderSearchSuggestion]() - do { - suggestions = try managedObjectContext.fetch(fetchRequest) as! [ReaderSearchSuggestion] - } catch let error as NSError { - DDLogError("Error fetching search suggestion : \(error.localizedDescription)") - } - for suggestion in suggestions { - managedObjectContext.delete(suggestion) + self.coreDataStack.performAndSave { context in + let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "ReaderSearchSuggestion") + do { + let suggestions = try context.fetch(fetchRequest) as! [ReaderSearchSuggestion] + suggestions.forEach(context.delete(_:)) + } catch let error as NSError { + DDLogError("Error fetching search suggestion : \(error.localizedDescription)") + } } - ContextManager.sharedInstance().save(managedObjectContext) } } diff --git a/WordPress/Classes/Services/ReaderSiteSearchService.swift b/WordPress/Classes/Services/ReaderSiteSearchService.swift index b016d2bbe690..7ab999283843 100644 --- a/WordPress/Classes/Services/ReaderSiteSearchService.swift +++ b/WordPress/Classes/Services/ReaderSiteSearchService.swift @@ -6,15 +6,18 @@ typealias ReaderSiteSearchFailureBlock = (_ error: Error?) -> Void /// Allows searching for sites / feeds in the Reader. /// -@objc class ReaderSiteSearchService: LocalCoreDataService { +@objc class ReaderSiteSearchService: CoreDataService { // The size of a single page of results when performing a search. static let pageSize = 20 private func apiRequest() -> WordPressComRestApi { - let accountService = AccountService(managedObjectContext: managedObjectContext) - let defaultAccount = accountService.defaultWordPressComAccount() - if let api = defaultAccount?.wordPressComRestApi, api.hasCredentials() { + let api = coreDataStack.performQuery { + let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: $0) + return defaultAccount?.wordPressComRestApi + } + + if let api, api.hasCredentials() { return api } diff --git a/WordPress/Classes/Services/ReaderSiteService.h b/WordPress/Classes/Services/ReaderSiteService.h index 9de2bf536fc6..ba91c509d167 100644 --- a/WordPress/Classes/Services/ReaderSiteService.h +++ b/WordPress/Classes/Services/ReaderSiteService.h @@ -1,5 +1,6 @@ #import <Foundation/Foundation.h> -#import "LocalCoreDataService.h" +#import "CoreDataService.h" +#import "ReaderTopicService.h" typedef NS_ENUM(NSUInteger, ReaderSiteServiceError) { ReaderSiteServiceErrorNotLoggedIn, @@ -8,7 +9,7 @@ typedef NS_ENUM(NSUInteger, ReaderSiteServiceError) { extern NSString * const ReaderSiteServiceErrorDomain; -@interface ReaderSiteService : LocalCoreDataService +@interface ReaderSiteService : CoreDataService /** Follow a site by its URL. @@ -74,16 +75,15 @@ extern NSString * const ReaderSiteServiceErrorDomain; - (void)syncPostsForFollowedSites; /** - Block/unblock the specified site from appearing in the user's reader + Returns a ReaderSiteTopic for the given site URL. - @param siteID The ID of the site. - @param blocked Boolean value. YES to block a site. NO to unblock a site. - @param success block called on a successful block. + @param siteURL The URL of the site. + @param success block called on a successful fetch containing the ReaderSiteTopic. @param failure block called if there is any error. `error` can be any underlying network error. */ -- (void)flagSiteWithID:(NSNumber *)siteID - asBlocked:(BOOL)blocked - success:(void(^)(void))success - failure:(void(^)(NSError *error))failure; +- (void)topicWithSiteURL:(NSURL *)siteURL + success:(void (^)(ReaderSiteTopic *topic))success + failure:(void(^)(NSError *error))failure; + @end diff --git a/WordPress/Classes/Services/ReaderSiteService.m b/WordPress/Classes/Services/ReaderSiteService.m index a3b4fcf82839..c34ff8a009a5 100644 --- a/WordPress/Classes/Services/ReaderSiteService.m +++ b/WordPress/Classes/Services/ReaderSiteService.m @@ -1,10 +1,9 @@ #import "ReaderSiteService.h" #import "AccountService.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "ReaderPostService.h" #import "ReaderPost.h" -#import "ReaderTopicService.h" #import "WPAccount.h" #import "WordPress-Swift.h" #import "WPAppAnalytics.h" @@ -56,8 +55,8 @@ - (void)followSiteWithID:(NSUInteger)siteID success:(void(^)(void))success failu } [service followSiteWithID:siteID success:^(){ [self fetchTopicServiceWithID:siteID success:success failure:failure]; - - [WPAppAnalytics track:WPAnalyticsStatReaderSiteFollowed withBlogID:[NSNumber numberWithUnsignedInteger:siteID]]; + NSNumber *blogID = [NSNumber numberWithUnsignedInteger:siteID]; + [WPAnalytics trackReaderStat:WPAnalyticsStatReaderSiteFollowed properties:@{ @"blog_id": blogID }]; } failure:failure]; } failure:^(NSError *error) { @@ -79,11 +78,12 @@ - (void)unfollowSiteWithID:(NSUInteger)siteID success:(void(^)(void))success fai ReaderSiteServiceRemote *service = [[ReaderSiteServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; [service unfollowSiteWithID:siteID success:^(){ - [self unfollowSiteTopicWithSiteID:@(siteID)]; + [self markUnfollowedSiteTopicWithSiteID:@(siteID)]; if (success) { success(); } - [WPAppAnalytics track:WPAnalyticsStatReaderSiteUnfollowed withBlogID:[NSNumber numberWithUnsignedInteger:siteID]]; + NSNumber *blogID = [NSNumber numberWithUnsignedInteger:siteID]; + [WPAnalytics trackReaderStat:WPAnalyticsStatReaderSiteUnfollowed properties:@{ @"blog_id": blogID }]; } failure:failure]; } @@ -117,7 +117,7 @@ - (void)followSiteAtURL:(NSString *)siteURL success:(void(^)(void))success failu if (success) { success(); } - [WPAppAnalytics track:WPAnalyticsStatReaderSiteFollowed withProperties:@{ @"url":sanitizedURL }]; + [WPAnalytics trackReaderStat:WPAnalyticsStatReaderSiteFollowed properties:@{ @"url":sanitizedURL }]; } failure:failure]; } failure:^(NSError *error) { @@ -139,67 +139,38 @@ - (void)unfollowSiteAtURL:(NSString *)siteURL success:(void(^)(void))success fai ReaderSiteServiceRemote *service = [[ReaderSiteServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; [service unfollowSiteAtURL:siteURL success:^(){ - [self unfollowSiteTopicWithURL:siteURL]; + [self markUnfollowedSiteTopicWithFeedURL:siteURL]; if (success) { success(); } - [WPAppAnalytics track:WPAnalyticsStatReaderSiteUnfollowed withProperties:@{@"url":siteURL}]; + [WPAnalytics trackReaderStat:WPAnalyticsStatReaderSiteUnfollowed properties:@{@"url":siteURL}]; } failure:failure]; } -- (void)unfollowSiteTopicWithSiteID:(NSNumber *)siteID -{ - ReaderTopicService *topicService = [[ReaderTopicService alloc] initWithManagedObjectContext:self.managedObjectContext]; - [topicService markUnfollowedSiteTopicWithSiteID:siteID]; -} - -- (void)unfollowSiteTopicWithURL:(NSString *)siteURL -{ - ReaderTopicService *topicService = [[ReaderTopicService alloc] initWithManagedObjectContext:self.managedObjectContext]; - [topicService markUnfollowedSiteTopicWithFeedURL:siteURL]; -} - - (void)syncPostsForFollowedSites { - ReaderTopicService *topicService = [[ReaderTopicService alloc] initWithManagedObjectContext:self.managedObjectContext]; - ReaderAbstractTopic *followedSites = [topicService topicForFollowedSites]; - if (!followedSites) { - return; - } + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderAbstractTopic *followedSites = [ReaderAbstractTopic lookupFollowedSitesTopicInContext:context]; + if (!followedSites) { + return; + } - NSManagedObjectContext *context = [[ContextManager sharedInstance] newDerivedContext]; - ReaderPostService *postService = [[ReaderPostService alloc] initWithManagedObjectContext:context]; - [postService fetchPostsForTopic:followedSites earlierThan:[NSDate date] success:nil failure:nil]; + ReaderPostService *postService = [[ReaderPostService alloc] initWithCoreDataStack:self.coreDataStack]; + [postService fetchPostsForTopic:followedSites earlierThan:[NSDate date] success:nil failure:nil]; + }]; } -- (void)flagSiteWithID:(NSNumber *)siteID asBlocked:(BOOL)blocked success:(void(^)(void))success failure:(void(^)(NSError *error))failure +- (void)topicWithSiteURL:(NSURL *)siteURL success:(void (^)(ReaderSiteTopic *topic))success failure:(void(^)(NSError *error))failure { WordPressComRestApi *api = [self apiForRequest]; - if (!api) { - if (failure) { - failure([self errorForNotLoggedIn]); - } - return; - } - - // Optimistically flag the posts from the site being blocked. - [self flagPostsFromSite:siteID asBlocked:blocked]; - ReaderSiteServiceRemote *service = [[ReaderSiteServiceRemote alloc] initWithWordPressComRestApi:api]; - [service flagSiteWithID:[siteID integerValue] asBlocked:blocked success:^{ - NSDictionary *properties = @{WPAppAnalyticsKeyBlogID:siteID}; - [WPAppAnalytics track:WPAnalyticsStatReaderSiteBlocked withProperties:properties]; - - if (success) { - success(); - } + + [service findSiteIDForURL:siteURL success:^(NSUInteger siteID) { + NSNumber *site = [NSNumber numberWithUnsignedLong:siteID]; + ReaderSiteTopic *topic = [ReaderSiteTopic lookupWithSiteID:site inContext:self.coreDataStack.mainContext]; + success(topic); } failure:^(NSError *error) { - // Undo the changes - [self flagPostsFromSite:siteID asBlocked:!blocked]; - - if (failure) { - failure(error); - } + failure(error); }]; } @@ -212,10 +183,10 @@ - (void)flagSiteWithID:(NSNumber *)siteID asBlocked:(BOOL)blocked success:(void( - (void)fetchTopicServiceWithID:(NSUInteger)siteID success:(void(^)(void))success failure:(void(^)(NSError *error))failure { DDLogInfo(@"Fetch and store followed topic"); - ReaderTopicService *service = [[ReaderTopicService alloc] initWithManagedObjectContext:self.managedObjectContext]; + ReaderTopicService *service = [[ReaderTopicService alloc] initWithCoreDataStack:self.coreDataStack]; [service siteTopicForSiteWithID:@(siteID) isFeed:false - success:^(NSManagedObjectID *objectID, BOOL isFollowing) { + success:^(NSManagedObjectID * __unused objectID, BOOL __unused isFollowing) { if (success) { success(); } @@ -227,12 +198,16 @@ - (void)fetchTopicServiceWithID:(NSUInteger)siteID success:(void(^)(void))succes */ - (WordPressComRestApi *)apiForRequest { - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; - WordPressComRestApi *api = [defaultAccount wordPressComRestApi]; + WordPressComRestApi * __block api = nil; + [self.coreDataStack.mainContext performBlockAndWait:^{ + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext]; + api = [defaultAccount wordPressComRestApi]; + }]; + if (![api hasCredentials]) { return nil; } + return api; } @@ -249,7 +224,7 @@ - (void)followExistingSiteByURL:(NSURL *)siteURL success:(void (^)(void))success } else { [self followSiteAtURL:[siteURL absoluteString] success:success failure:failure]; } - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { DDLogInfo(@"Could not find site at URL: %@", siteURL); [self followSiteAtURL:[siteURL absoluteString] success:success failure:failure]; }]; @@ -257,10 +232,48 @@ - (void)followExistingSiteByURL:(NSURL *)siteURL success:(void (^)(void))success - (void)flagPostsFromSite:(NSNumber *)siteID asBlocked:(BOOL)blocked { - ReaderPostService *service = [[ReaderPostService alloc] initWithManagedObjectContext:self.managedObjectContext]; - [service flagPostsFromSite:siteID asBlocked:blocked]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + [self flagPostsFromSite:siteID asBlocked:blocked inContext:context]; + }]; } +- (void)flagPostsFromSite:(NSNumber *)siteID asBlocked:(BOOL)blocked inContext:(NSManagedObjectContext *)context +{ + NSError *error; + NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:@"ReaderPost"]; + request.predicate = [NSPredicate predicateWithFormat:@"siteID = %@", siteID]; + NSArray *results = [context executeFetchRequest:request error:&error]; + if (error) { + DDLogError(@"%@, error deleting posts belonging to siteID %@: %@", NSStringFromSelector(_cmd), siteID, error); + return; + } + + if ([results count] == 0) { + return; + } + + for (ReaderPost *post in results) { + post.isSiteBlocked = blocked; + } +} + +// Updates the site topic's following status in core data only. +- (void)markUnfollowedSiteTopicWithFeedURL:(NSString *)feedURL +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderSiteTopic *topic = [ReaderSiteTopic lookupWithFeedURL:feedURL inContext:context]; + topic.following = NO; + }]; +} + +// Updates the site topic's following status in core data only. +- (void)markUnfollowedSiteTopicWithSiteID:(NSNumber *)siteID +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderSiteTopic *topic = [ReaderSiteTopic lookupWithSiteID:siteID inContext:context]; + topic.following = NO; + }]; +} #pragma mark - Error messages diff --git a/WordPress/Classes/Services/ReaderSiteService.swift b/WordPress/Classes/Services/ReaderSiteService.swift new file mode 100644 index 000000000000..463ea02cc733 --- /dev/null +++ b/WordPress/Classes/Services/ReaderSiteService.swift @@ -0,0 +1,51 @@ +import Foundation + +@objc extension ReaderSiteService { + + private var defaultAccount: WPAccount? { + self.coreDataStack.performQuery { context in + try? WPAccount.lookupDefaultWordPressComAccount(in: context) + } + } + + /// Block/unblock the specified site from appearing in the user's reader + /// - Parameters: + /// - id: The ID of the site. + /// - blocked: Boolean value. `true` to block a site. `false` to unblock a site. + /// - success: Closure called when the request succeeds. + /// - failure: Closure called when the request fails. + func flagSite(withID id: NSNumber, asBlocked blocked: Bool, success: (() -> Void)? = nil, failure: ((Error?) -> Void)? = nil) { + guard let defaultAccount = defaultAccount, let api = defaultAccount.wordPressComRestApi, api.hasCredentials() else { + failure?(self.errorForNotLoggedIn()) + return + } + + // Optimistically flag the posts from the site being blocked. + self.flagPosts(fromSite: id, asBlocked: blocked) + + // Flag site as blocked remotely + let service = ReaderSiteServiceRemote(wordPressComRestApi: api) + service.flagSite(withID: id.uintValue, asBlocked: blocked) { + let properties: [String: Any] = [WPAppAnalyticsKeyBlogID: id] + WPAnalytics.track(.readerSiteBlocked, withProperties: properties) + self.coreDataStack.performAndSave({ context in + self.flagSiteLocally(accountID: defaultAccount.userID, siteID: id, asBlocked: blocked, in: context) + }, completion: { + success?() + }, on: .main) + } failure: { error in + self.flagPosts(fromSite: id, asBlocked: !blocked) + failure?(error) + } + } + + private func flagSiteLocally(accountID: NSNumber, siteID: NSNumber, asBlocked blocked: Bool, in context: NSManagedObjectContext) { + if blocked { + let blocked = BlockedSite.insert(into: context) + blocked.accountID = accountID + blocked.blogID = siteID + } else { + BlockedSite.delete(accountID: accountID, blogID: siteID, context: context) + } + } +} diff --git a/WordPress/Classes/Services/ReaderSiteService_Internal.h b/WordPress/Classes/Services/ReaderSiteService_Internal.h new file mode 100644 index 000000000000..047aee612282 --- /dev/null +++ b/WordPress/Classes/Services/ReaderSiteService_Internal.h @@ -0,0 +1,8 @@ +#import "ReaderSiteService.h" + +@interface ReaderSiteService () + +- (nonnull NSError *)errorForNotLoggedIn; +- (void)flagPostsFromSite:(NSNumber * _Nonnull)siteID asBlocked:(BOOL)blocked; + +@end diff --git a/WordPress/Classes/Services/ReaderTopicService+FollowedInterests.swift b/WordPress/Classes/Services/ReaderTopicService+FollowedInterests.swift new file mode 100644 index 000000000000..d280d50d111e --- /dev/null +++ b/WordPress/Classes/Services/ReaderTopicService+FollowedInterests.swift @@ -0,0 +1,116 @@ +import Foundation + +// MARK: - ReaderFollowedInterestsService + +/// Protocol representing a service that retrieves the users followed interests/tags +protocol ReaderFollowedInterestsService: AnyObject { + /// Fetches the users locally followed interests + /// - Parameter completion: Called after a fetch, will return nil if the user has no interests or an error occurred + func fetchFollowedInterestsLocally(completion: @escaping ([ReaderTagTopic]?) -> Void) + + /// Fetches the users followed interests from the network, then returns the sync'd interests + /// - Parameter completion: Called after a fetch, will return nil if the user has no interests or an error occurred + func fetchFollowedInterestsRemotely(completion: @escaping ([ReaderTagTopic]?) -> Void) + + + /// Follow the provided interests + /// If the user is not logged into a WP.com account, the interests will only be saved locally. + func followInterests(_ interests: [RemoteReaderInterest], + success: @escaping ([ReaderTagTopic]?) -> Void, + failure: @escaping (Error) -> Void, + isLoggedIn: Bool) + + /// Returns the API path of a given slug + func path(slug: String) -> String +} + +// MARK: - CoreData Fetching +extension ReaderTopicService: ReaderFollowedInterestsService { + public func fetchFollowedInterestsLocally(completion: @escaping ([ReaderTagTopic]?) -> Void) { + DispatchQueue.main.async { + completion(self.followedInterests()) + } + } + + public func fetchFollowedInterestsRemotely(completion: @escaping ([ReaderTagTopic]?) -> Void) { + fetchReaderMenu(success: { [weak self] in + self?.fetchFollowedInterestsLocally(completion: completion) + }) { [weak self] error in + DDLogError("Could not fetch remotely followed interests: \(String(describing: error))") + self?.fetchFollowedInterestsLocally(completion: completion) + } + } + + func followInterests(_ interests: [RemoteReaderInterest], + success: @escaping ([ReaderTagTopic]?) -> Void, + failure: @escaping (Error) -> Void, + isLoggedIn: Bool) { + // If the user is logged in, attempt to save the interests on the server + // If the user is not logged in, save the interests locally + if isLoggedIn { + let slugs = interests.map { $0.slug } + + let topicService = ReaderTopicServiceRemote(wordPressComRestApi: apiRequest()) + topicService.followInterests(withSlugs: slugs, success: { [weak self] in + self?.fetchFollowedInterestsRemotely(completion: success) + }) { error in + failure(error) + } + } else { + followInterestsLocally(interests, success: success, failure: failure) + } + } + + func path(slug: String) -> String { + // We create a "remote" service to get an accurate path for the tag + // https://public-api.../tags/_tag_/posts + let service = ReaderTopicServiceRemote(wordPressComRestApi: apiRequest()) + return service.pathForTopic(slug: slug) + } + + private func followInterestsLocally(_ interests: [RemoteReaderInterest], + success: @escaping ([ReaderTagTopic]?) -> Void, + failure: @escaping (Error) -> Void) { + + self.coreDataStack.performAndSave({ context in + interests.forEach { interest in + let topic = ReaderTagTopic(remoteInterest: interest, context: context, isFollowing: true) + topic.path = self.path(slug: interest.slug) + } + }, completion: { [weak self] in + self?.fetchFollowedInterestsLocally(completion: success) + }, on: .main) + } + + private func apiRequest() -> WordPressComRestApi { + let token = self.coreDataStack.performQuery { context in + try? WPAccount.lookupDefaultWordPressComAccount(in: context)?.authToken + } + + return WordPressComRestApi.defaultApi(oAuthToken: token, + userAgent: WPUserAgent.wordPress()) + } + + // MARK: - Private: Fetching Helpers + private func followedInterestsFetchRequest() -> NSFetchRequest<ReaderTagTopic> { + let entityName = "ReaderTagTopic" + let predicate = NSPredicate(format: "following = YES AND showInMenu = YES") + let fetchRequest = NSFetchRequest<ReaderTagTopic>(entityName: entityName) + fetchRequest.predicate = predicate + + return fetchRequest + } + + private func followedInterests() -> [ReaderTagTopic]? { + assert(Thread.isMainThread, "\(#function) must be called from the main thread") + + let fetchRequest = followedInterestsFetchRequest() + do { + return try coreDataStack.mainContext.fetch(fetchRequest) + } catch { + DDLogError("Could not fetch followed interests: \(String(describing: error))") + + return nil + } + } +} diff --git a/WordPress/Classes/Services/ReaderTopicService+Interests.swift b/WordPress/Classes/Services/ReaderTopicService+Interests.swift new file mode 100644 index 000000000000..48c8ae82bd9a --- /dev/null +++ b/WordPress/Classes/Services/ReaderTopicService+Interests.swift @@ -0,0 +1,36 @@ +import Foundation +import WordPressKit + +// MARK: - ReaderInterestsService + +/// Protocol representing a service that retrieves a list of interests the user can follow +protocol ReaderInterestsService: AnyObject { + func fetchInterests(success: @escaping ([RemoteReaderInterest]) -> Void, + failure: @escaping (Error) -> Void) +} + +// MARK: - Select Interests +extension ReaderTopicService: ReaderInterestsService { + public func fetchInterests(success: @escaping ([RemoteReaderInterest]) -> Void, + failure: @escaping (Error) -> Void) { + let service = ReaderTopicServiceRemote(wordPressComRestApi: apiRequest()) + + service.fetchInterests({ (interests) in + success(interests) + }) { (error) in + failure(error) + } + } + + + /// Creates a new WP.com API instances that allows us to specify the LocaleKeyV2 + private func apiRequest() -> WordPressComRestApi { + let token = coreDataStack.performQuery { context in + try? WPAccount.lookupDefaultWordPressComAccount(in: context)?.authToken + } + + return WordPressComRestApi.defaultApi(oAuthToken: token, + userAgent: WPUserAgent.wordPress(), + localeKey: WordPressComRestApi.LocaleKeyV2) + } +} diff --git a/WordPress/Classes/Services/ReaderTopicService+SiteInfo.swift b/WordPress/Classes/Services/ReaderTopicService+SiteInfo.swift new file mode 100644 index 000000000000..a134ac9a4881 --- /dev/null +++ b/WordPress/Classes/Services/ReaderTopicService+SiteInfo.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Protocol representing a service that retrieves the users followed interests/tags +protocol ReaderSiteInfoService: AnyObject { + /// Returns an API endpoint URL for the given path + /// Example: https://public-api.wordpress.com/PATH + /// - Parameter path: The API endpoint path to convert + func endpointURLString(path: String) -> String +} + +extension ReaderTopicService: ReaderSiteInfoService { + func endpointURLString(path: String) -> String { + // We have to create a "remote" service to get an accurate path for the endpoint + let service = ReaderTopicServiceRemote(wordPressComRestApi: apiRequest()) + + return service.endpointUrl(forPath: path) + } + + private func apiRequest() -> WordPressComRestApi { + let token = self.coreDataStack.performQuery { context in + try? WPAccount.lookupDefaultWordPressComAccount(in: context)?.authToken + } + + return WordPressComRestApi.defaultApi(oAuthToken: token, + userAgent: WPUserAgent.wordPress()) + } +} diff --git a/WordPress/Classes/Services/ReaderTopicService+Subscriptions.swift b/WordPress/Classes/Services/ReaderTopicService+Subscriptions.swift index 608acf775aff..0ed7ed891acb 100644 --- a/WordPress/Classes/Services/ReaderTopicService+Subscriptions.swift +++ b/WordPress/Classes/Services/ReaderTopicService+Subscriptions.swift @@ -13,66 +13,67 @@ extension ReaderTopicService { // MARK: Private methods private func apiRequest() -> WordPressComRestApi { - let accountService = AccountService(managedObjectContext: managedObjectContext) - let defaultAccount = accountService.defaultWordPressComAccount() - if let api = defaultAccount?.wordPressComRestApi, api.hasCredentials() { + let api = coreDataStack.performQuery { context in + try? WPAccount.lookupDefaultWordPressComAccount(in: context)?.wordPressComRestApi + } + if let api, api.hasCredentials() { return api } return WordPressComRestApi.defaultApi(oAuthToken: nil, userAgent: WPUserAgent.wordPress()) } - private func fetchSiteTopic(with siteId: Int, _ failure: @escaping (ReaderTopicServiceError?) -> Void) -> ReaderSiteTopic? { - guard let siteTopic = findSiteTopic(withSiteID: NSNumber(value: siteId)) else { + private func fetchSiteTopic(with siteId: Int, in context: NSManagedObjectContext, _ failure: @escaping (ReaderTopicServiceError?) -> Void) -> ReaderSiteTopic? { + guard let siteTopic = try? ReaderSiteTopic.lookup(withSiteID: NSNumber(value: siteId), in: context) else { failure(.topicNotfound(id: siteId)) return nil } if siteTopic.postSubscription == nil { siteTopic.postSubscription = NSEntityDescription.insertNewObject(forEntityName: ReaderSiteInfoSubscriptionPost.classNameWithoutNamespaces(), - into: managedObjectContext) as? ReaderSiteInfoSubscriptionPost + into: context) as? ReaderSiteInfoSubscriptionPost } if siteTopic.emailSubscription == nil { siteTopic.emailSubscription = NSEntityDescription.insertNewObject(forEntityName: ReaderSiteInfoSubscriptionEmail.classNameWithoutNamespaces(), - into: managedObjectContext) as? ReaderSiteInfoSubscriptionEmail + into: context) as? ReaderSiteInfoSubscriptionEmail } - ContextManager.sharedInstance().saveContextAndWait(managedObjectContext) - return siteTopic } private func remoteAction(for action: SubscriptionAction, _ subscribe: Bool, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { let service = ReaderTopicServiceRemote(wordPressComRestApi: apiRequest()) - let successBlock = { - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: success) - } - switch action { case .notifications(let siteId): if subscribe { - service.subscribeSiteNotifications(with: siteId, successBlock, failure) + service.subscribeSiteNotifications(with: siteId, { + WPAnalytics.trackReader(.followedBlogNotificationsReaderMenuOn, properties: ["blogId": siteId]) + success() + }, failure) } else { - service.unsubscribeSiteNotifications(with: siteId, successBlock, failure) + service.unsubscribeSiteNotifications(with: siteId, { + WPAnalytics.trackReader(.followedBlogNotificationsReaderMenuOff, properties: ["blog_id": siteId]) + success() + }, failure) } case .postsEmail(let siteId): if subscribe { - service.subscribePostsEmail(with: siteId, successBlock, failure) + service.subscribePostsEmail(with: siteId, success, failure) } else { - service.unsubscribePostsEmail(with: siteId, successBlock, failure) + service.unsubscribePostsEmail(with: siteId, success, failure) } case .updatePostsEmail(let siteId, let frequency): - service.updateFrequencyPostsEmail(with: siteId, frequency: frequency, successBlock, failure) + service.updateFrequencyPostsEmail(with: siteId, frequency: frequency, success, failure) case .comments(let siteId): if subscribe { - service.subscribeSiteComments(with: siteId, successBlock, failure) + service.subscribeSiteComments(with: siteId, success, failure) } else { - service.unsubscribeSiteComments(with: siteId, successBlock, failure) + service.unsubscribeSiteComments(with: siteId, success, failure) } } } @@ -103,14 +104,16 @@ extension ReaderTopicService { DDLogError("Error turn on notifications: \(error?.description ?? "unknown error")") } - toggleSiteNotifications(with: siteId, subscribe: subscribe, successBlock, failureBlock) + coreDataStack.performAndSave { context in + self.toggleSiteNotifications(with: siteId, subscribe: subscribe, in: context, successBlock, failureBlock) + } } // MARK: Private methods - private func toggleSiteNotifications(with siteId: Int, subscribe: Bool = false, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { - guard let siteTopic = fetchSiteTopic(with: siteId, failure), + private func toggleSiteNotifications(with siteId: Int, subscribe: Bool = false, in context: NSManagedObjectContext, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + guard let siteTopic = fetchSiteTopic(with: siteId, in: context, failure), let postSubscription = siteTopic.postSubscription else { return } @@ -119,14 +122,15 @@ extension ReaderTopicService { postSubscription.sendPosts = subscribe let failureBlock = { (error: ReaderTopicServiceError?) in - guard let siteTopic = self.findSiteTopic(withSiteID: NSNumber(value: siteId)) else { - failure(.topicNotfound(id: siteId)) - return - } - siteTopic.postSubscription?.sendPosts = oldValue - ContextManager.sharedInstance().save(self.managedObjectContext) { + self.coreDataStack.performAndSave({ context in + guard let siteTopic = try? ReaderSiteTopic.lookup(withSiteID: NSNumber(value: siteId), in: context) else { + failure(.topicNotfound(id: siteId)) + return + } + siteTopic.postSubscription?.sendPosts = oldValue + }, completion: { failure(error) - } + }, on: .main) } remoteAction(for: .notifications(siteId: siteId), subscribe, success, failureBlock) @@ -161,14 +165,16 @@ extension ReaderTopicService { DDLogError("Error turn on notifications: \(error?.description ?? "unknown error")") } - togglePostComments(with: siteId, subscribe: subscribe, successBlock, failureBlock) + coreDataStack.performAndSave { context in + self.togglePostComments(with: siteId, subscribe: subscribe, in: context, successBlock, failureBlock) + } } // MARK: Private methods - private func togglePostComments(with siteId: Int, subscribe: Bool = false, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { - guard let siteTopic = fetchSiteTopic(with: siteId, failure), + private func togglePostComments(with siteId: Int, subscribe: Bool = false, in context: NSManagedObjectContext, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + guard let siteTopic = fetchSiteTopic(with: siteId, in: context, failure), let emailSubscription = siteTopic.emailSubscription else { return } @@ -177,14 +183,15 @@ extension ReaderTopicService { emailSubscription.sendComments = subscribe let failureBlock = { (error: ReaderTopicServiceError?) in - guard let siteTopic = self.findSiteTopic(withSiteID: NSNumber(value: siteId)) else { - failure(.topicNotfound(id: siteId)) - return - } - siteTopic.emailSubscription?.sendComments = oldValue - ContextManager.sharedInstance().save(self.managedObjectContext) { + self.coreDataStack.performAndSave({ context in + guard let siteTopic = try? ReaderSiteTopic.lookup(withSiteID: NSNumber(value: siteId), in: context) else { + failure(.topicNotfound(id: siteId)) + return + } + siteTopic.emailSubscription?.sendComments = oldValue + }, completion: { failure(error) - } + }, on: .main) } remoteAction(for: .comments(siteId: siteId), subscribe, success, failureBlock) @@ -219,7 +226,9 @@ extension ReaderTopicService { DDLogError("Error turn on notifications: \(error?.description ?? "unknown error")") } - togglePostsEmail(with: siteId, subscribe: subscribe, successBlock, failureBlock) + coreDataStack.performAndSave { context in + self.togglePostsEmail(with: siteId, subscribe: subscribe, in: context, successBlock, failureBlock) + } } @@ -244,7 +253,10 @@ extension ReaderTopicService { failure?(error) DDLogError("Error turn on notifications: \(error?.description ?? "unknown error")") } - updatePostsEmail(with: siteId, frequency: frequency, successBlock, failureBlock) + + coreDataStack.performAndSave { context in + self.updatePostsEmail(with: siteId, frequency: frequency, in: context, successBlock, failureBlock) + } } @@ -263,8 +275,8 @@ extension ReaderTopicService { } } - private func togglePostsEmail(with siteId: Int, subscribe: Bool = false, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { - guard let siteTopic = fetchSiteTopic(with: siteId, failure), + private func togglePostsEmail(with siteId: Int, subscribe: Bool = false, in context: NSManagedObjectContext, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + guard let siteTopic = fetchSiteTopic(with: siteId, in: context, failure), let emailSubscription = siteTopic.emailSubscription else { return } @@ -273,21 +285,22 @@ extension ReaderTopicService { emailSubscription.sendPosts = subscribe let failureBlock = { (error: ReaderTopicServiceError?) in - guard let siteTopic = self.findSiteTopic(withSiteID: NSNumber(value: siteId)) else { - failure(nil) - return - } - siteTopic.emailSubscription?.sendPosts = oldValue - ContextManager.sharedInstance().save(self.managedObjectContext) { + self.coreDataStack.performAndSave({ context in + guard let siteTopic = try? ReaderSiteTopic.lookup(withSiteID: NSNumber(value: siteId), in: context) else { + failure(nil) + return + } + siteTopic.emailSubscription?.sendPosts = oldValue + }, completion: { failure(error) - } + }, on: .main) } remoteAction(for: .postsEmail(siteId: siteId), subscribe, success, failureBlock) } - private func updatePostsEmail(with siteId: Int, frequency: ReaderServiceDeliveryFrequency, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { - guard let siteTopic = fetchSiteTopic(with: siteId, failure), + private func updatePostsEmail(with siteId: Int, frequency: ReaderServiceDeliveryFrequency, in context: NSManagedObjectContext, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + guard let siteTopic = fetchSiteTopic(with: siteId, in: context, failure), let emailSubscription = siteTopic.emailSubscription else { return } @@ -296,14 +309,15 @@ extension ReaderTopicService { emailSubscription.postDeliveryFrequency = frequency.rawValue let failureBlock = { (error: ReaderTopicServiceError?) in - guard let siteTopic = self.findSiteTopic(withSiteID: NSNumber(value: siteId)) else { - failure(.topicNotfound(id: siteId)) - return - } - siteTopic.emailSubscription?.postDeliveryFrequency = oldValue - ContextManager.sharedInstance().save(self.managedObjectContext) { + self.coreDataStack.performAndSave({ context in + guard let siteTopic = try? ReaderSiteTopic.lookup(withSiteID: NSNumber(value: siteId), in: context) else { + failure(.topicNotfound(id: siteId)) + return + } + siteTopic.emailSubscription?.postDeliveryFrequency = oldValue + }, completion: { failure(error) - } + }, on: .main) } remoteAction(for: .updatePostsEmail(siteId: siteId, frequency: frequency), false, success, failureBlock) diff --git a/WordPress/Classes/Services/ReaderTopicService.h b/WordPress/Classes/Services/ReaderTopicService.h index 4c6bcf69df14..ddd119878837 100644 --- a/WordPress/Classes/Services/ReaderTopicService.h +++ b/WordPress/Classes/Services/ReaderTopicService.h @@ -1,8 +1,6 @@ #import <Foundation/Foundation.h> -#import "LocalCoreDataService.h" +#import "CoreDataService.h" -extern NSString * const ReaderTopicDidChangeViaUserInteractionNotification; -extern NSString * const ReaderTopicDidChangeNotification; extern NSString * const ReaderTopicFreshlyPressedPathCommponent; @class ReaderAbstractTopic; @@ -10,13 +8,11 @@ extern NSString * const ReaderTopicFreshlyPressedPathCommponent; @class ReaderSiteTopic; @class ReaderSearchTopic; -@interface ReaderTopicService : LocalCoreDataService +@interface ReaderTopicService : CoreDataService -/** - Sets the currentTopic and dispatches the `ReaderTopicDidChangeNotification` notification. - Passing `nil` for the topic will not dispatch the notification. - */ -@property (nonatomic) ReaderAbstractTopic *currentTopic; +- (ReaderAbstractTopic *)currentTopicInContext:(NSManagedObjectContext *)context; + +- (void)setCurrentTopic:(ReaderAbstractTopic *)topic; /** Fetches the topics for the reader's menu. @@ -35,13 +31,6 @@ extern NSString * const ReaderTopicFreshlyPressedPathCommponent; - (void)fetchFollowedSitesWithSuccess:(void(^)(void))success failure:(void(^)(NSError *error))failure; -/** - Counts the number of `ReaderTagTopics` the user has subscribed to. - - @return The number of ReaderTagTopics whose `followed` property is set to `YES` - */ -- (NSUInteger)numberOfSubscribedTopics; - /** Deletes all search topics from core data and saves the context. Use to clean-up searches when they are finished. @@ -69,33 +58,13 @@ extern NSString * const ReaderTopicFreshlyPressedPathCommponent; */ - (void)deleteTopic:(ReaderAbstractTopic *)topic; -/** - Marks the specified topic as being subscribed, and marks it current. - - @param topic The ReaderAbstractTopic to follow and make current. - */ -- (void)subscribeToAndMakeTopicCurrent:(ReaderAbstractTopic *)topic; - /** Creates a ReaderSearchTopic from the specified search phrase. @param phrase: The search phrase. - - @return A ReaderSearchTopic instance. - */ -- (ReaderSearchTopic *)searchTopicForSearchPhrase:(NSString *)phrase; - - -/** - Unfollows the specified topic. If the specified topic was the current topic the - current topic is updated to a default. - - @param topic The ReaderAbstractTopic to unfollow. - @param success block called on a successful fetch. - @param failure block called if there is any error. `error` can be any underlying network error. + @param completion: A completion callback to receive the created ReaderSearchTopic instance. */ - -- (void)unfollowAndRefreshCurrentTopicForTag:(ReaderTagTopic *)topic withSuccess:(void (^)(void))success failure:(void (^)(NSError *error))failure; +- (void)createSearchTopicForSearchPhrase:(NSString *)phrase completion:(void (^)(NSManagedObjectID *))completion; /** Unfollows the specified topic @@ -113,16 +82,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; - -/** - Follow the tag with the specified slug - - @param tagName The name of a tag to follow. - @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)followTagWithSlug:(NSString *)slug 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; /** Toggle the following status of the tag for the specified tag topic @@ -140,37 +103,9 @@ extern NSString * const ReaderTopicFreshlyPressedPathCommponent; @param success block called on a successful change. @param failure block called if there is any error. `error` can be any underlying network error. */ -- (void)toggleFollowingForSite:(ReaderSiteTopic *)topic success:(void (^)(void))success failure:(void (^)(NSError *error))failure; - -/** - Mark a site topic as unfollowed in core data only. Should be called after unfollowing - a post to ensure that any existing site topics reflect the correct following status. - - @param feedURL The feedURL of the site topic. - */ -- (void)markUnfollowedSiteTopicWithFeedURL:(NSString *)feedURL; - -/** - Mark a site topic as unfollowed in core data only. Should be called after unfollowing - a post to ensure that any existing site topics reflect the correct following status. - - @param siteID the siteID of the site topic. - */ -- (void)markUnfollowedSiteTopicWithSiteID:(NSNumber *)siteID; - -/** - Fetch the topic for 'sites I follow' if it exists. - - @return A `ReaderAbstractTopic` instance or nil. - */ -- (ReaderAbstractTopic *)topicForFollowedSites; - -/** - Fetch the topic for 'Discover' if it exists. - - @return A `ReaderAbstractTopic` instance or nil. - */ -- (ReaderAbstractTopic *)topicForDiscover; +- (void)toggleFollowingForSite:(ReaderSiteTopic *)topic + success:(void (^)(BOOL follow))success + failure:(void (^)(BOOL follow, NSError *error))failure; /** Fetch a tag topic for a tag with the specified slug. @@ -196,51 +131,11 @@ extern NSString * const ReaderTopicFreshlyPressedPathCommponent; success:(void (^)(NSManagedObjectID *objectID, BOOL isFollowing))success failure:(void (^)(NSError *error))failure; - -/** - Fetch all saved Site topics - - @return A list of site topic - */ -- (NSArray <ReaderSiteTopic *>*)allSiteTopics; - -/** - Find a topic by its exact path. - - @param path The path of the topic - - @returns A matching abstract topic or nil. - */ -- (ReaderAbstractTopic *)findWithPath:(NSString *)path; - -/** - Find a topic where its path contains a specified path. - - @param path The path of the topic - - @returns A matching abstract topic or nil. - */ -- (ReaderAbstractTopic *)findContainingPath:(NSString *)path; - -/** - Find a site topic by its site id - - @param siteID The site id of the topic - @return A matched site topic - */ -- (ReaderSiteTopic *)findSiteTopicWithSiteID:(NSNumber *)siteID; - -/** - Find a site topic by its feed id - - @param feedID The feed id of the topic - @return A matched site topic - */ -- (ReaderSiteTopic *)findSiteTopicWithFeedID:(NSNumber *)feedID; - @end @interface ReaderTopicService (Tests) +- (void)mergeFollowedSites:(NSArray *)sites withSuccess:(void (^)(void))success; - (void)mergeMenuTopics:(NSArray *)topics withSuccess:(void (^)(void))success; +- (void)mergeMenuTopics:(NSArray *)topics isLoggedIn:(BOOL)isLoggedIn withSuccess:(void (^)(void))success; - (NSString *)formatTitle:(NSString *)str; @end diff --git a/WordPress/Classes/Services/ReaderTopicService.m b/WordPress/Classes/Services/ReaderTopicService.m index ece2dc652b60..574b8fc07ded 100644 --- a/WordPress/Classes/Services/ReaderTopicService.m +++ b/WordPress/Classes/Services/ReaderTopicService.m @@ -1,7 +1,7 @@ #import "ReaderTopicService.h" #import "AccountService.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "ReaderPost.h" #import "ReaderPostService.h" #import "WPAccount.h" @@ -10,8 +10,6 @@ @import WordPressKit; -NSString * const ReaderTopicDidChangeViaUserInteractionNotification = @"ReaderTopicDidChangeViaUserInteractionNotification"; -NSString * const ReaderTopicDidChangeNotification = @"ReaderTopicDidChangeNotification"; NSString * const ReaderTopicFreshlyPressedPathCommponent = @"freshly-pressed"; static NSString * const ReaderTopicCurrentTopicPathKey = @"ReaderTopicCurrentTopicPathKey"; @@ -19,46 +17,38 @@ @implementation ReaderTopicService - (void)fetchReaderMenuWithSuccess:(void (^)(void))success failure:(void (^)(NSError *error))failure { - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext: context]; - // Keep a reference to the NSManagedObjectID (if it exists). - // We'll use it to verify that the account did not change while fetching topics. - ReaderTopicServiceRemote *remoteService = [[ReaderTopicServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; - [remoteService fetchReaderMenuWithSuccess:^(NSArray *topics) { + // Keep a reference to the NSManagedObjectID (if it exists). + // We'll use it to verify that the account did not change while fetching topics. + ReaderTopicServiceRemote *remoteService = [[ReaderTopicServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequestInContext:context]]; + [remoteService fetchReaderMenuWithSuccess:^(NSArray *topics) { - WPAccount *reloadedAccount = [accountService defaultWordPressComAccount]; + NSAssert(NSThread.isMainThread, @"This callback must be dispatched on the main thread"); + WPAccount *reloadedAccount = [WPAccount lookupDefaultWordPressComAccountInContext:self.coreDataStack.mainContext]; - // Make sure that we have the same account now that we did when we started. - if ((!defaultAccount && !reloadedAccount) || [defaultAccount.objectID isEqual:reloadedAccount.objectID]) { - // If both accounts are nil, or if both accounts exist and are identical we're good to go. - } else { - // The account changed so our results are invalid. Fetch them anew! - [self fetchReaderMenuWithSuccess:success failure:failure]; - return; - } + // Make sure that we have the same account now that we did when we started. + if ((!defaultAccount && !reloadedAccount) || [defaultAccount.objectID isEqual:reloadedAccount.objectID]) { + // If both accounts are nil, or if both accounts exist and are identical we're good to go. + } else { + // The account changed so our results are invalid. Fetch them anew! + [self fetchReaderMenuWithSuccess:success failure:failure]; + return; + } - [self mergeMenuTopics:topics withSuccess:success]; + [self mergeMenuTopics:topics withSuccess:success]; - } failure:^(NSError *error) { - if (failure) { - failure(error); - } - }]; + } failure:failure]; + } completion:nil onQueue:dispatch_get_main_queue()]; } - (void)fetchFollowedSitesWithSuccess:(void(^)(void))success failure:(void(^)(NSError *error))failure { ReaderTopicServiceRemote *service = [[ReaderTopicServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; [service fetchFollowedSitesWithSuccess:^(NSArray *sites) { - for (RemoteReaderSiteInfo *siteInfo in sites) { - [self siteTopicForRemoteSiteInfo:siteInfo]; - } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (success) { - success(); - } - }]; + [WPAnalytics setSubscriptionCount: sites.count]; + [self mergeFollowedSites:sites withSuccess:success]; } failure:^(NSError *error) { if (failure) { failure(error); @@ -66,29 +56,29 @@ - (void)fetchFollowedSitesWithSuccess:(void(^)(void))success failure:(void(^)(NS }]; } -- (ReaderAbstractTopic *)currentTopic +- (ReaderAbstractTopic *)currentTopicInContext:(NSManagedObjectContext *)context { ReaderAbstractTopic *topic; - topic = [self currentTopicFromSavedPath]; + topic = [self currentTopicFromSavedPathInContext:context]; if (!topic) { - topic = [self currentTopicFromDefaultTopic]; + topic = [self currentTopicFromDefaultTopicInContext:context]; [self setCurrentTopic:topic]; } return topic; } -- (ReaderAbstractTopic *)currentTopicFromSavedPath +- (ReaderAbstractTopic *)currentTopicFromSavedPathInContext:(NSManagedObjectContext *)context { ReaderAbstractTopic *topic; - NSString *topicPathString = [[NSUserDefaults standardUserDefaults] stringForKey:ReaderTopicCurrentTopicPathKey]; + NSString *topicPathString = [[UserPersistentStoreFactory userDefaultsInstance] stringForKey:ReaderTopicCurrentTopicPathKey]; if (topicPathString) { NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderAbstractTopic classNameWithoutNamespaces]]; request.predicate = [NSPredicate predicateWithFormat:@"path = %@", topicPathString]; NSError *error; - topic = [[self.managedObjectContext executeFetchRequest:request error:&error] firstObject]; + topic = [[context executeFetchRequest:request error:&error] firstObject]; if (error) { DDLogError(@"%@ error fetching topic: %@", NSStringFromSelector(_cmd), error); } @@ -96,7 +86,7 @@ - (ReaderAbstractTopic *)currentTopicFromSavedPath return topic; } -- (ReaderAbstractTopic *)currentTopicFromDefaultTopic +- (ReaderAbstractTopic *)currentTopicFromDefaultTopicInContext:(NSManagedObjectContext *)context { // Return the default topic ReaderAbstractTopic *topic; @@ -104,7 +94,7 @@ - (ReaderAbstractTopic *)currentTopicFromDefaultTopic NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderDefaultTopic classNameWithoutNamespaces]]; NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"title" ascending:YES]; request.sortDescriptors = @[sortDescriptor]; - NSArray *topics = [self.managedObjectContext executeFetchRequest:request error:&error]; + NSArray *topics = [context executeFetchRequest:request error:&error]; if (error) { DDLogError(@"%@ error fetching topic: %@", NSStringFromSelector(_cmd), error); return nil; @@ -127,107 +117,85 @@ - (ReaderAbstractTopic *)currentTopicFromDefaultTopic - (void)setCurrentTopic:(ReaderAbstractTopic *)topic { if (!topic) { - [[NSUserDefaults standardUserDefaults] removeObjectForKey:ReaderTopicCurrentTopicPathKey]; - [NSUserDefaults resetStandardUserDefaults]; + [[UserPersistentStoreFactory userDefaultsInstance] removeObjectForKey:ReaderTopicCurrentTopicPathKey]; } else { - [[NSUserDefaults standardUserDefaults] setObject:topic.path forKey:ReaderTopicCurrentTopicPathKey]; - [NSUserDefaults resetStandardUserDefaults]; - - dispatch_async(dispatch_get_main_queue(), ^{ - [[NSNotificationCenter defaultCenter] postNotificationName:ReaderTopicDidChangeNotification object:nil]; - }); - } -} - -- (NSUInteger)numberOfSubscribedTopics -{ - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderTagTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"following = YES"]; - NSError *error; - NSUInteger count = [self.managedObjectContext countForFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error counting topics: %@", NSStringFromSelector(_cmd), error); - return 0; + [[UserPersistentStoreFactory userDefaultsInstance] setObject:topic.path forKey:ReaderTopicCurrentTopicPathKey]; } - return count; } - (void)deleteAllSearchTopics { - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderSearchTopic classNameWithoutNamespaces]]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderSearchTopic classNameWithoutNamespaces]]; - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error executing fetch request: %@", NSStringFromSelector(_cmd), error); - return; - } + NSError *error; + NSArray *results = [context executeFetchRequest:request error:&error]; + if (error) { + DDLogError(@"%@ error executing fetch request: %@", NSStringFromSelector(_cmd), error); + return; + } - for (ReaderAbstractTopic *topic in results) { - DDLogInfo(@"Deleting topic: %@", topic.title); - [self preserveSavedPostsFromTopic:topic]; - [self.managedObjectContext deleteObject:topic]; - } - [self.managedObjectContext performBlockAndWait:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; + for (ReaderAbstractTopic *topic in results) { + DDLogInfo(@"Deleting topic: %@", topic.title); + [self preserveSavedPostsFromTopic:topic]; + [context deleteObject:topic]; + } }]; } - (void)deleteNonMenuTopics { - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderAbstractTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"showInMenu = false AND inUse = false"]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderAbstractTopic classNameWithoutNamespaces]]; + request.predicate = [NSPredicate predicateWithFormat:@"showInMenu = false AND inUse = false"]; - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error executing fetch request: %@", NSStringFromSelector(_cmd), error); - return; - } + NSError *error; + NSArray *results = [context executeFetchRequest:request error:&error]; + if (error) { + DDLogError(@"%@ error executing fetch request: %@", NSStringFromSelector(_cmd), error); + return; + } - for (ReaderAbstractTopic *topic in results) { - // Do not purge site topics that are followed. We want these to stay so they appear immediately when managing followed sites. - if ([topic isKindOfClass:[ReaderSiteTopic class]] && topic.following) { - continue; + for (ReaderAbstractTopic *topic in results) { + // Do not purge site topics that are followed. We want these to stay so they appear immediately when managing followed sites. + if ([topic isKindOfClass:[ReaderSiteTopic class]] && topic.following) { + continue; + } + DDLogInfo(@"Deleting topic: %@", topic.title); + [self preserveSavedPostsFromTopic:topic]; + [context deleteObject:topic]; } - DDLogInfo(@"Deleting topic: %@", topic.title); - [self preserveSavedPostsFromTopic:topic]; - [self.managedObjectContext deleteObject:topic]; - } - [self.managedObjectContext performBlockAndWait:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; }]; } - (void)clearInUseFlags { - NSError *error; - NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:[ReaderAbstractTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"inUse = true"]; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@, marking topic not in use.: %@", NSStringFromSelector(_cmd), error); - return; - } - - for (ReaderAbstractTopic *topic in results) { - topic.inUse = NO; - } + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSError *error; + NSFetchRequest *request = [[NSFetchRequest alloc] initWithEntityName:[ReaderAbstractTopic classNameWithoutNamespaces]]; + request.predicate = [NSPredicate predicateWithFormat:@"inUse = true"]; + NSArray *results = [context executeFetchRequest:request error:&error]; + if (error) { + DDLogError(@"%@, marking topic not in use.: %@", NSStringFromSelector(_cmd), error); + return; + } - [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext]; + for (ReaderAbstractTopic *topic in results) { + topic.inUse = NO; + } + }]; } - (void)deleteAllTopics { - [self setCurrentTopic:nil]; - NSArray *currentTopics = [self allTopics]; - for (ReaderAbstractTopic *topic in currentTopics) { - DDLogInfo(@"Deleting topic: %@", topic.title); - [self preserveSavedPostsFromTopic:topic]; - [self.managedObjectContext deleteObject:topic]; - } - [self.managedObjectContext performBlockAndWait:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + [self setCurrentTopic:nil]; + NSArray *currentTopics = [ReaderAbstractTopic lookupAllInContext:context error:nil]; + for (ReaderAbstractTopic *topic in currentTopics) { + DDLogInfo(@"Deleting topic: %@", topic.title); + [self preserveSavedPostsFromTopic:topic]; + [context deleteObject:topic]; + } }]; } @@ -236,16 +204,19 @@ - (void)deleteTopic:(ReaderAbstractTopic *)topic if (!topic) { return; } - [self preserveSavedPostsFromTopic:topic]; - [self.managedObjectContext deleteObject:topic]; - [self.managedObjectContext performBlockAndWait:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderAbstractTopic *topicInContext = [context existingObjectWithID:topic.objectID error:nil]; + if (topicInContext == nil) { + return; + } + [self preserveSavedPostsFromTopic:topicInContext]; + [context deleteObject:topicInContext]; }]; } - (void)preserveSavedPostsFromTopic:(ReaderAbstractTopic *)topic { - [topic.posts enumerateObjectsUsingBlock:^(ReaderPost * _Nonnull post, NSUInteger idx, BOOL * _Nonnull stop) { + [topic.posts enumerateObjectsUsingBlock:^(ReaderPost * _Nonnull post, NSUInteger __unused idx, BOOL * _Nonnull __unused stop) { if (post.isSavedForLater) { DDLogInfo(@"Preserving saved post: %@", post.titleForDisplay); post.topic = nil; @@ -253,76 +224,50 @@ - (void)preserveSavedPostsFromTopic:(ReaderAbstractTopic *)topic }]; } -- (ReaderSearchTopic *)searchTopicForSearchPhrase:(NSString *)phrase +- (void)createSearchTopicForSearchPhrase:(NSString *)phrase completion:(void (^)(NSManagedObjectID *))completion { NSAssert([phrase length] > 0, @"A search phrase is required."); - WordPressComRestApi *api = [WordPressComRestApi defaultApiWithOAuthToken:nil userAgent:[WPUserAgent wordPressUserAgent] localeKey:[WordPressComRestApi LocaleKeyDefault]]; - ReaderPostServiceRemote *remote = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:api]; - - NSString *path = [remote endpointUrlForSearchPhrase:[phrase lowercaseString]]; - ReaderSearchTopic *topic = (ReaderSearchTopic *)[self findWithPath:path]; - if (!topic || ![topic isKindOfClass:[ReaderSearchTopic class]]) { - topic = [NSEntityDescription insertNewObjectForEntityForName:[ReaderSearchTopic classNameWithoutNamespaces] - inManagedObjectContext:self.managedObjectContext]; - } - topic.type = [ReaderSearchTopic TopicType]; - topic.title = phrase; - topic.path = path; - topic.showInMenu = NO; - topic.following = NO; - - // Save / update the search phrase to use it as a suggestion later. - ReaderSearchSuggestionService *suggestionService = [[ReaderSearchSuggestionService alloc] initWithManagedObjectContext:self.managedObjectContext]; - [suggestionService createOrUpdateSuggestionForPhrase:phrase]; + NSManagedObjectID * __block topicObjectID = nil; - [[ContextManager sharedInstance] saveContextAndWait:self.managedObjectContext]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + WordPressComRestApi *api = [WordPressComRestApi defaultApiWithOAuthToken:nil userAgent:[WPUserAgent wordPressUserAgent] localeKey:[WordPressComRestApi LocaleKeyDefault]]; + ReaderPostServiceRemote *remote = [[ReaderPostServiceRemote alloc] initWithWordPressComRestApi:api]; - return topic; -} - -- (void)subscribeToAndMakeTopicCurrent:(ReaderAbstractTopic *)topic -{ - // Optimistically mark the topic subscribed. - topic.following = YES; - [self.managedObjectContext performBlockAndWait:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - }]; - [self setCurrentTopic:topic]; + NSString *path = [remote endpointUrlForSearchPhrase:[phrase lowercaseString]]; + ReaderSearchTopic *topic = (ReaderSearchTopic *)[ReaderAbstractTopic lookupWithPath:path inContext:context]; + if (!topic || ![topic isKindOfClass:[ReaderSearchTopic class]]) { + topic = [NSEntityDescription insertNewObjectForEntityForName:[ReaderSearchTopic classNameWithoutNamespaces] + inManagedObjectContext:context]; + } + topic.type = [ReaderSearchTopic TopicType]; + topic.title = phrase; + topic.path = path; + topic.showInMenu = NO; + topic.following = NO; - NSString *topicName = [topic.title lowercaseString]; - ReaderTopicServiceRemote *remoteService = [[ReaderTopicServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; - [remoteService followTopicNamed:topicName withSuccess:^(NSNumber *topicID){ - // noop - } failure:^(NSError *error) { - DDLogError(@"%@ error following topic: %@", NSStringFromSelector(_cmd), error); - }]; -} + [context obtainPermanentIDsForObjects:@[topic] error:nil]; + topicObjectID = topic.objectID; + } completion:^{ + // Save / update the search phrase to use it as a suggestion later. + ReaderSearchSuggestionService *suggestionService = [[ReaderSearchSuggestionService alloc] initWithCoreDataStack:self.coreDataStack]; + [suggestionService createOrUpdateSuggestionForPhrase:phrase]; -- (void)unfollowAndRefreshCurrentTopicForTag:(ReaderTagTopic *)topic withSuccess:(void (^)(void))success failure:(void (^)(NSError *error))failure -{ - BOOL deletingCurrentTopic = [topic isEqual:self.currentTopic]; - [self unfollowTag:topic withSuccess:^{ - if (deletingCurrentTopic) { - [self setCurrentTopic:nil]; - [self currentTopic]; - } - if (success) { - success(); + if (completion) { + completion(topicObjectID); } - } failure:failure]; - + } onQueue:dispatch_get_main_queue()]; } - (void)unfollowTag:(ReaderTagTopic *)topic withSuccess:(void (^)(void))success failure:(void (^)(NSError *error))failure { // Optimistically unfollow the topic - topic.following = NO; - if (!topic.isRecommended) { - topic.showInMenu = NO; - } - [self.managedObjectContext performBlockAndWait:^{ - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderTagTopic *topicInContext = [context existingObjectWithID:topic.objectID error:nil]; + topicInContext.following = NO; + if (!topicInContext.isRecommended) { + topicInContext.showInMenu = NO; + } }]; ReaderTopicServiceRemote *remoteService = [[ReaderTopicServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; @@ -338,11 +283,20 @@ - (void)unfollowTag:(ReaderTagTopic *)topic withSuccess:(void (^)(void))success // Now do it for realz. NSDictionary *properties = @{@"tag":slug}; - [remoteService unfollowTopicWithSlug:slug withSuccess:^(NSNumber *topicID) { - [WPAnalytics track:WPAnalyticsStatReaderTagUnfollowed withProperties:properties]; + void (^successBlock)(void) = ^{ + [WPAnalytics trackReaderStat:WPAnalyticsStatReaderTagUnfollowed properties:properties]; if (success) { success(); } + }; + + if (!ReaderHelpers.isLoggedIn) { + successBlock(); + return; + } + + [remoteService unfollowTopicWithSlug:slug withSuccess:^(NSNumber * __unused topicID) { + successBlock(); } failure:^(NSError *error) { if (failure) { DDLogError(@"%@ error unfollowing topic: %@", NSStringFromSelector(_cmd), error); @@ -351,18 +305,21 @@ - (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:^{ - [WPAnalytics track:WPAnalyticsStatReaderTagFollowed]; - [self selectTopicWithID:topicID]; - if (success) { - success(); - } + NSDictionary *properties = @{@"tag":topicName, @"source":source}; + [WPAnalytics trackReaderStat:WPAnalyticsStatReaderTagFollowed properties:properties]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + [self selectTopicWithID:topicID inContext:context]; + } completion:success onQueue:dispatch_get_main_queue()]; } failure:failure]; } failure:^(NSError *error) { if (failure) { @@ -374,12 +331,22 @@ - (void)followTagNamed:(NSString *)topicName withSuccess:(void (^)(void))success - (void)followTagWithSlug:(NSString *)slug withSuccess:(void (^)(void))success failure:(void (^)(NSError *error))failure { - ReaderTopicServiceRemote *remoteService = [[ReaderTopicServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; - [remoteService followTopicWithSlug:slug withSuccess:^(NSNumber *topicID) { - [WPAnalytics track:WPAnalyticsStatReaderTagFollowed]; + void (^successBlock)(void) = ^{ + NSDictionary *properties = @{@"tag":slug}; + [WPAnalytics trackReaderStat:WPAnalyticsStatReaderTagFollowed properties:properties]; if (success) { success(); } + }; + + if (!ReaderHelpers.isLoggedIn) { + successBlock(); + return; + } + + ReaderTopicServiceRemote *remoteService = [[ReaderTopicServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; + [remoteService followTopicWithSlug:slug withSuccess:^(NSNumber * __unused topicID) { + successBlock(); } failure:^(NSError *error) { if (failure) { DDLogError(@"%@ error following topic by name: %@", NSStringFromSelector(_cmd), error); @@ -391,8 +358,10 @@ - (void)followTagWithSlug:(NSString *)slug withSuccess:(void (^)(void))success f - (void)toggleFollowingForTag:(ReaderTagTopic *)tagTopic success:(void (^)(void))success failure:(void (^)(NSError *error))failure { + NSAssert(NSThread.isMainThread, @"%s must be called from the main thread", __FUNCTION__); + NSError *error; - ReaderTagTopic *topic = (ReaderTagTopic *)[self.managedObjectContext existingObjectWithID:tagTopic.objectID error:&error]; + ReaderTagTopic *topic = (ReaderTagTopic *)[self.coreDataStack.mainContext existingObjectWithID:tagTopic.objectID error:&error]; if (error) { DDLogError(error.localizedDescription); if (failure) { @@ -406,24 +375,29 @@ - (void)toggleFollowingForTag:(ReaderTagTopic *)tagTopic success:(void (^)(void) BOOL oldShowInMenuValue = topic.showInMenu; // Optimistically update and save - topic.following = !topic.following; - if (topic.following) { - topic.showInMenu = YES; - } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext * __unused context) { + ReaderTagTopic *topicInContext = (ReaderTagTopic *)[self.coreDataStack.mainContext existingObjectWithID:tagTopic.objectID error:nil]; + topicInContext.following = !oldFollowingValue; + if (topicInContext.following) { + topicInContext.showInMenu = YES; + } + }]; // Define failure block void (^failureBlock)(NSError *error) = ^void(NSError *error) { // Revert changes on failure - topic.following = oldFollowingValue; - topic.showInMenu = oldShowInMenuValue; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - if (failure) { - failure(error); - } + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext * __unused context) { + ReaderTagTopic *topicInContext = (ReaderTagTopic *)[self.coreDataStack.mainContext existingObjectWithID:tagTopic.objectID error:nil]; + topicInContext.following = oldFollowingValue; + topicInContext.showInMenu = oldShowInMenuValue; + } completion:^{ + if (failure) { + failure(error); + } + } onQueue:dispatch_get_main_queue()]; }; - if (topic.following) { + if (!oldFollowingValue) { [self followTagWithSlug:topic.slug withSuccess:success failure:failureBlock]; } else { [self unfollowTag:topic withSuccess:success failure:failureBlock]; @@ -437,20 +411,27 @@ - (void)tagTopicForTagWithSlug:(NSString *)slug success:(void(^)(NSManagedObject } // Find existing tag by slug - ReaderTagTopic *existingTopic = [self findTagWithSlug:slug]; + NSManagedObjectID * __block existingTopic = nil; + [self.coreDataStack.mainContext performBlockAndWait:^{ + existingTopic = [[ReaderTagTopic lookupWithSlug:slug inContext:self.coreDataStack.mainContext] objectID]; + }]; if (existingTopic) { dispatch_async(dispatch_get_main_queue(), ^{ - success(existingTopic.objectID); + success(existingTopic); }); return; } ReaderTopicServiceRemote *remoteService = [[ReaderTopicServiceRemote alloc] initWithWordPressComRestApi:[self apiForRequest]]; [remoteService fetchTagInfoForTagWithSlug:slug success:^(RemoteReaderTopic *remoteTopic) { - ReaderTagTopic *topic = [self tagTopicForRemoteTopic:remoteTopic]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - success(topic.objectID); - }]; + NSManagedObjectID * __block topicObjectID = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderTagTopic *topic = [self tagTopicForRemoteTopic:remoteTopic inContext:context]; + [context obtainPermanentIDsForObjects:@[topic] error:nil]; + topicObjectID = topic.objectID; + } completion:^{ + success(topicObjectID); + } onQueue:dispatch_get_main_queue()]; } failure:^(NSError *error) { DDLogError(@"%@ error fetching site info for site with ID %@: %@", NSStringFromSelector(_cmd), slug, error); if (failure) { @@ -459,14 +440,18 @@ - (void)tagTopicForTagWithSlug:(NSString *)slug success:(void(^)(NSManagedObject }]; } -- (void)toggleFollowingForSite:(ReaderSiteTopic *)siteTopic success:(void (^)(void))success failure:(void (^)(NSError *error))failure +- (void)toggleFollowingForSite:(ReaderSiteTopic *)siteTopic + success:(void (^)(BOOL follow))success + failure:(void (^)(BOOL follow, NSError *error))failure { + NSAssert(NSThread.isMainThread, @"%s must be called from the main thread", __FUNCTION__); + NSError *error; - ReaderSiteTopic *topic = (ReaderSiteTopic *)[self.managedObjectContext existingObjectWithID:siteTopic.objectID error:&error]; + ReaderSiteTopic *topic = (ReaderSiteTopic *)[self.coreDataStack.mainContext existingObjectWithID:siteTopic.objectID error:&error]; if (error) { DDLogError(error.localizedDescription); if (failure) { - failure(error); + failure(true, error); } return; } @@ -479,32 +464,54 @@ - (void)toggleFollowingForSite:(ReaderSiteTopic *)siteTopic success:(void (^)(vo NSString *siteURLForPostService = topic.siteURL; // Optimistically update - topic.following = newFollowValue; - ReaderPostService *postService = [[ReaderPostService alloc] initWithManagedObjectContext:self.managedObjectContext]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderSiteTopic *topicInContext = (ReaderSiteTopic *)[context existingObjectWithID:siteTopic.objectID error:nil]; + topicInContext.following = newFollowValue; + }]; + + ReaderPostService *postService = [[ReaderPostService alloc] initWithCoreDataStack:self.coreDataStack]; [postService setFollowing:newFollowValue forPostsFromSiteWithID:siteIDForPostService andURL:siteURLForPostService]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; // Define success block void (^successBlock)(void) = ^void() { + + // Update subscription count + NSInteger oldSubscriptionCount = [WPAnalytics subscriptionCount]; + NSInteger newSubscriptionCount = newFollowValue ? oldSubscriptionCount + 1 : oldSubscriptionCount - 1; + [WPAnalytics setSubscriptionCount:newSubscriptionCount]; + [self refreshPostsForFollowedTopic]; + if (success) { - success(); + success(newFollowValue); } }; // Define failure block void (^failureBlock)(NSError *error) = ^void(NSError *error) { - // Revert changes on failure - topic.following = oldFollowValue; - [postService setFollowing:oldFollowValue forPostsFromSiteWithID:siteIDForPostService andURL:siteURLForPostService]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; - - if (failure) { - failure(error); + BOOL alreadyFollowing = newFollowValue && error.domain == ReaderSiteServiceErrorDomain && error.code == ReaderSiteServiceErrorAlreadyFollowingSite; + BOOL alreadyUnsubscribed = !newFollowValue && [error.userInfo[WordPressComRestApi.ErrorKeyErrorCode] isEqual:@"are_not_subscribed"]; + BOOL successWithoutChanges = alreadyFollowing || alreadyUnsubscribed; + + if (successWithoutChanges) { + successBlock(); + return; } + + // Revert changes on failure, unless the error is that we're already following + // a site. + [postService setFollowing:oldFollowValue forPostsFromSiteWithID:siteIDForPostService andURL:siteURLForPostService]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderSiteTopic *topicInContext = (ReaderSiteTopic *)[context existingObjectWithID:siteTopic.objectID error:nil]; + topicInContext.following = oldFollowValue; + } completion:^{ + if (failure) { + failure(newFollowValue, error); + } + } onQueue:dispatch_get_main_queue()]; }; - ReaderSiteService *siteService = [[ReaderSiteService alloc] initWithManagedObjectContext:self.managedObjectContext]; + ReaderSiteService *siteService = [[ReaderSiteService alloc] initWithCoreDataStack:self.coreDataStack]; if (topic.isExternal) { if (newFollowValue) { [siteService followSiteAtURL:topic.feedURL success:successBlock failure:failureBlock]; @@ -532,71 +539,24 @@ - (void)toggleFollowingForSite:(ReaderSiteTopic *)siteTopic success:(void (^)(vo - (void)refreshPostsForFollowedTopic { - ReaderPostService *postService = [[ReaderPostService alloc] initWithManagedObjectContext:self.managedObjectContext]; + ReaderPostService *postService = [[ReaderPostService alloc] initWithCoreDataStack:self.coreDataStack]; [postService refreshPostsForFollowedTopic]; } -// Updates the site topic's following status in core data only. -- (void)markUnfollowedSiteTopicWithFeedURL:(NSString *)feedURL -{ - ReaderSiteTopic *topic = [self findSiteTopicWithFeedURL:feedURL]; - if (!topic) { - return; - } - topic.following = NO; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; -} - -// Updates the site topic's following status in core data only. -- (void)markUnfollowedSiteTopicWithSiteID:(NSNumber *)siteID -{ - ReaderSiteTopic *topic = [self findSiteTopicWithSiteID:siteID]; - if (!topic) { - return; - } - topic.following = NO; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext]; -} - - -- (ReaderAbstractTopic *)topicForFollowedSites -{ - NSError *error; - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderAbstractTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"path LIKE %@", @"*/read/following"]; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"Failed to fetch topic for sites I follow: %@", error); - return nil; - } - return (ReaderAbstractTopic *)[results firstObject]; -} - -- (ReaderAbstractTopic *)topicForDiscover -{ - NSError *error; - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderAbstractTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"path LIKE %@", @"*/read/sites/53424024/posts"]; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"Failed to fetch topic for Discover: %@", error); - return nil; - } - return (ReaderAbstractTopic *)[results firstObject]; -} - - (void)siteTopicForSiteWithID:(NSNumber *)siteID isFeed:(BOOL)isFeed success:(void (^)(NSManagedObjectID *objectID, BOOL isFollowing))success failure:(void (^)(NSError *error))failure { - ReaderSiteTopic *siteTopic; + ReaderSiteTopic * __block siteTopic = nil; - if (isFeed) { - siteTopic = [self findSiteTopicWithFeedID:siteID]; - } else { - siteTopic = [self findSiteTopicWithSiteID:siteID]; - } + [self.coreDataStack.mainContext performBlockAndWait:^{ + if (isFeed) { + siteTopic = [ReaderSiteTopic lookupWithFeedID:siteID inContext:self.coreDataStack.mainContext]; + } else { + siteTopic = [ReaderSiteTopic lookupWithSiteID:siteID inContext:self.coreDataStack.mainContext]; + } + }]; if (siteTopic) { if (success) { @@ -611,11 +571,14 @@ - (void)siteTopicForSiteWithID:(NSNumber *)siteID return; } - ReaderSiteTopic *topic = [self siteTopicForRemoteSiteInfo: siteInfo]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - success(topic.objectID, siteInfo.isFollowing); - }]; - + NSManagedObjectID * __block topicObjectID = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + ReaderSiteTopic *topic = [self siteTopicForRemoteSiteInfo:siteInfo inContext:context]; + [context obtainPermanentIDsForObjects:@[topic] error:nil]; + topicObjectID = topic.objectID; + } completion:^{ + success(topicObjectID, siteInfo.isFollowing); + } onQueue:dispatch_get_main_queue()]; } failure:^(NSError *error) { DDLogError(@"%@ error fetching site info for site with ID %@: %@", NSStringFromSelector(_cmd), siteID, error); if (failure) { @@ -630,10 +593,9 @@ - (void)siteTopicForSiteWithID:(NSNumber *)siteID /** Get the api to use for the request. */ -- (WordPressComRestApi *)apiForRequest +- (WordPressComRestApi *)apiForRequestInContext:(NSManagedObjectContext *)context { - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:self.managedObjectContext]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:context]; WordPressComRestApi *api = [defaultAccount wordPressComRestApi]; if (![api hasCredentials]) { api = [WordPressComRestApi defaultApiWithOAuthToken:nil userAgent:[WPUserAgent wordPressUserAgent] localeKey:[WordPressComRestApi LocaleKeyDefault]]; @@ -641,102 +603,32 @@ - (WordPressComRestApi *)apiForRequest return api; } -/** - Finds an existing topic matching the specified name and, if found, makes it the - selected topic. - */ -- (void)selectTopicNamed:(NSString *)topicName +- (WordPressComRestApi *)apiForRequest { - ReaderAbstractTopic *topic = [self findTopicNamed:topicName]; - [self setCurrentTopic:topic]; + WordPressComRestApi * __block api = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + api = [self apiForRequestInContext:context]; + }]; + return api; } /** Finds an existing topic matching the specified topicID and, if found, makes it the selected topic. */ -- (void)selectTopicWithID:(NSNumber *)topicID +- (void)selectTopicWithID:(NSNumber *)topicID inContext:(NSManagedObjectContext *)context { - ReaderAbstractTopic *topic = [self findTopicWithID:topicID]; + ReaderAbstractTopic *topic = [ReaderTagTopic lookupWithTagID:topicID inContext:context]; [self setCurrentTopic:topic]; } -/** - Find an existing topic with the specified title. - - @param topicName The title of the topic to find in core data. - @return A matching `ReaderTagTopic` instance or nil. - */ -- (ReaderTagTopic *)findTopicNamed:(NSString *)topicName -{ - NSError *error; - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderTagTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"title LIKE[c] %@", topicName]; - - NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"title" ascending:YES]; - request.sortDescriptors = @[sortDescriptor]; - NSArray *topics = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error fetching topic: %@", NSStringFromSelector(_cmd), error); - return nil; - } - - return [topics firstObject]; -} - -/** - Find an existing topic with the specified slug. - - @param slug The slug of the topic to find in core data. - @return A matching `ReaderTagTopic` instance or nil. - */ -- (ReaderTagTopic *)findTagWithSlug:(NSString *)slug -{ - NSError *error; - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderTagTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"slug = %@", slug]; - - NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"title" ascending:YES]; - request.sortDescriptors = @[sortDescriptor]; - NSArray *topics = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error fetching topic: %@", NSStringFromSelector(_cmd), error); - return nil; - } - - return [topics firstObject]; -} - -/** - Find an existing topic with the specified topicID. - - @param topicID The topicID of the topic to find in core data. - @return A matching `ReaderTagTopic` instance or nil. - */ -- (ReaderTagTopic *)findTopicWithID:(NSNumber *)topicID -{ - NSError *error; - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderTagTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"tagID = %@", topicID]; - - NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"title" ascending:YES]; - request.sortDescriptors = @[sortDescriptor]; - NSArray *topics = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error fetching topic: %@", NSStringFromSelector(_cmd), error); - return nil; - } - - return [topics firstObject]; -} - /** Create a new `ReaderAbstractTopic` or update an existing `ReaderAbstractTopic`. @param dict A `RemoteReaderTopic` object. @return A new or updated, but unsaved, `ReaderAbstractTopic`. */ -- (ReaderAbstractTopic *)createOrReplaceFromRemoteTopic:(RemoteReaderTopic *)remoteTopic +- (ReaderAbstractTopic *)createOrReplaceFromRemoteTopic:(RemoteReaderTopic *)remoteTopic inContext:(NSManagedObjectContext *)context { NSString *path = remoteTopic.path; @@ -749,30 +641,33 @@ - (ReaderAbstractTopic *)createOrReplaceFromRemoteTopic:(RemoteReaderTopic *)rem return nil; } - ReaderAbstractTopic *topic = [self topicForRemoteTopic:remoteTopic]; + ReaderAbstractTopic *topic = [self topicForRemoteTopic:remoteTopic inContext:context]; return topic; } -- (ReaderAbstractTopic *)topicForRemoteTopic:(RemoteReaderTopic *)remoteTopic +- (ReaderAbstractTopic *)topicForRemoteTopic:(RemoteReaderTopic *)remoteTopic inContext:(NSManagedObjectContext *)context { if ([remoteTopic.path rangeOfString:@"/tags/"].location != NSNotFound) { - return [self tagTopicForRemoteTopic:remoteTopic]; + return [self tagTopicForRemoteTopic:remoteTopic inContext:context]; + } - } else if ([remoteTopic.path rangeOfString:@"/list/"].location != NSNotFound) { - return [self listTopicForRemoteTopic:remoteTopic]; - } else if ([remoteTopic.type isEqualToString:@"team"]) { - return [self teamTopicForRemoteTopic:remoteTopic]; + if ([remoteTopic.path rangeOfString:@"/list/"].location != NSNotFound) { + return [self listTopicForRemoteTopic:remoteTopic inContext:context]; } - return [self defaultTopicForRemoteTopic:remoteTopic]; + if ([remoteTopic.type isEqualToString:@"organization"]) { + return [self teamTopicForRemoteTopic:remoteTopic inContext:context]; + } + + return [self defaultTopicForRemoteTopic:remoteTopic inContext:context]; } -- (ReaderTagTopic *)tagTopicForRemoteTopic:(RemoteReaderTopic *)remoteTopic +- (ReaderTagTopic *)tagTopicForRemoteTopic:(RemoteReaderTopic *)remoteTopic inContext:(NSManagedObjectContext *)context { - ReaderTagTopic *topic = (ReaderTagTopic *)[self findWithPath:remoteTopic.path]; + ReaderTagTopic *topic = (ReaderTagTopic *)[ReaderAbstractTopic lookupWithPath:remoteTopic.path inContext:context]; if (!topic || ![topic isKindOfClass:[ReaderTagTopic class]]) { topic = [NSEntityDescription insertNewObjectForEntityForName:[ReaderTagTopic classNameWithoutNamespaces] - inManagedObjectContext:self.managedObjectContext]; + inManagedObjectContext:context]; } topic.type = [ReaderTagTopic TopicType]; topic.tagID = remoteTopic.topicID; @@ -785,12 +680,12 @@ - (ReaderTagTopic *)tagTopicForRemoteTopic:(RemoteReaderTopic *)remoteTopic return topic; } -- (ReaderListTopic *)listTopicForRemoteTopic:(RemoteReaderTopic *)remoteTopic +- (ReaderListTopic *)listTopicForRemoteTopic:(RemoteReaderTopic *)remoteTopic inContext:(NSManagedObjectContext *)context { - ReaderListTopic *topic = (ReaderListTopic *)[self findWithPath:remoteTopic.path]; + ReaderListTopic *topic = (ReaderListTopic *)[ReaderAbstractTopic lookupWithPath:remoteTopic.path inContext:context]; if (!topic || ![topic isKindOfClass:[ReaderListTopic class]]) { topic = [NSEntityDescription insertNewObjectForEntityForName:[ReaderListTopic classNameWithoutNamespaces] - inManagedObjectContext:self.managedObjectContext]; + inManagedObjectContext:context]; } topic.type = [ReaderListTopic TopicType]; topic.listID = remoteTopic.topicID; @@ -804,12 +699,12 @@ - (ReaderListTopic *)listTopicForRemoteTopic:(RemoteReaderTopic *)remoteTopic return topic; } -- (ReaderDefaultTopic *)defaultTopicForRemoteTopic:(RemoteReaderTopic *)remoteTopic +- (ReaderDefaultTopic *)defaultTopicForRemoteTopic:(RemoteReaderTopic *)remoteTopic inContext:(NSManagedObjectContext *)context { - ReaderDefaultTopic *topic = (ReaderDefaultTopic *)[self findWithPath:remoteTopic.path]; + ReaderDefaultTopic *topic = (ReaderDefaultTopic *)[ReaderAbstractTopic lookupWithPath:remoteTopic.path inContext:context]; if (!topic || ![topic isKindOfClass:[ReaderDefaultTopic class]]) { topic = [NSEntityDescription insertNewObjectForEntityForName:[ReaderDefaultTopic classNameWithoutNamespaces] - inManagedObjectContext:self.managedObjectContext]; + inManagedObjectContext:context]; } topic.type = [ReaderDefaultTopic TopicType]; topic.title = [self formatTitle:remoteTopic.title]; @@ -820,12 +715,12 @@ - (ReaderDefaultTopic *)defaultTopicForRemoteTopic:(RemoteReaderTopic *)remoteTo return topic; } -- (ReaderTeamTopic *)teamTopicForRemoteTopic:(RemoteReaderTopic *)remoteTopic +- (ReaderTeamTopic *)teamTopicForRemoteTopic:(RemoteReaderTopic *)remoteTopic inContext:(NSManagedObjectContext *)context { - ReaderTeamTopic *topic = (ReaderTeamTopic *)[self findWithPath:remoteTopic.path]; + ReaderTeamTopic *topic = (ReaderTeamTopic *)[ReaderAbstractTopic lookupWithPath:remoteTopic.path inContext:context]; if (!topic || ![topic isKindOfClass:[ReaderTeamTopic class]]) { topic = [NSEntityDescription insertNewObjectForEntityForName:[ReaderTeamTopic classNameWithoutNamespaces] - inManagedObjectContext:self.managedObjectContext]; + inManagedObjectContext:context]; } topic.type = [ReaderTeamTopic TopicType]; topic.title = [self formatTitle:remoteTopic.title]; @@ -833,16 +728,17 @@ - (ReaderTeamTopic *)teamTopicForRemoteTopic:(RemoteReaderTopic *)remoteTopic topic.path = remoteTopic.path; topic.showInMenu = YES; topic.following = YES; + topic.organizationID = [remoteTopic.organizationID integerValue]; return topic; } -- (ReaderSiteTopic *)siteTopicForRemoteSiteInfo:(RemoteReaderSiteInfo *)siteInfo +- (ReaderSiteTopic *)siteTopicForRemoteSiteInfo:(RemoteReaderSiteInfo *)siteInfo inContext:(NSManagedObjectContext *)context { - ReaderSiteTopic *topic = (ReaderSiteTopic *)[self findWithPath:siteInfo.postsEndpoint]; + ReaderSiteTopic *topic = (ReaderSiteTopic *)[ReaderAbstractTopic lookupWithPath:siteInfo.postsEndpoint inContext:context]; if (!topic || ![topic isKindOfClass:[ReaderSiteTopic class]]) { topic = [NSEntityDescription insertNewObjectForEntityForName:[ReaderSiteTopic classNameWithoutNamespaces] - inManagedObjectContext:self.managedObjectContext]; + inManagedObjectContext:context]; } topic.feedID = siteInfo.feedID; @@ -851,6 +747,8 @@ - (ReaderSiteTopic *)siteTopicForRemoteSiteInfo:(RemoteReaderSiteInfo *)siteInfo topic.isJetpack = siteInfo.isJetpack; topic.isPrivate = siteInfo.isPrivate; topic.isVisible = siteInfo.isVisible; + topic.organizationID = [siteInfo.organizationID integerValue]; + topic.path = siteInfo.postsEndpoint; topic.postCount = siteInfo.postCount; topic.showInMenu = NO; topic.siteBlavatar = siteInfo.siteBlavatar; @@ -860,15 +758,15 @@ - (ReaderSiteTopic *)siteTopicForRemoteSiteInfo:(RemoteReaderSiteInfo *)siteInfo topic.subscriberCount = siteInfo.subscriberCount; topic.title = siteInfo.siteName; topic.type = ReaderSiteTopic.TopicType; - topic.path = siteInfo.postsEndpoint; + topic.unseenCount = [siteInfo.unseenCount integerValue]; - topic.postSubscription = [self postSubscriptionFor:siteInfo topic:topic]; - topic.emailSubscription = [self emailSubscriptionFor:siteInfo topic:topic]; + topic.postSubscription = [self postSubscriptionFor:siteInfo topic:topic inContext:context]; + topic.emailSubscription = [self emailSubscriptionFor:siteInfo topic:topic inContext:context]; return topic; } -- (ReaderSiteInfoSubscriptionPost *)postSubscriptionFor:(RemoteReaderSiteInfo *)siteInfo topic:(ReaderSiteTopic *)topic +- (ReaderSiteInfoSubscriptionPost *)postSubscriptionFor:(RemoteReaderSiteInfo *)siteInfo topic:(ReaderSiteTopic *)topic inContext:(NSManagedObjectContext *)context { if (![siteInfo.postSubscription wp_isValidObject]) { return nil; @@ -878,13 +776,13 @@ - (ReaderSiteInfoSubscriptionPost *)postSubscriptionFor:(RemoteReaderSiteInfo *) if (![postSubscription wp_isValidObject]) { postSubscription = [NSEntityDescription insertNewObjectForEntityForName:[ReaderSiteInfoSubscriptionPost classNameWithoutNamespaces] - inManagedObjectContext:self.managedObjectContext]; + inManagedObjectContext:context]; } postSubscription.siteTopic = topic; return postSubscription; } -- (ReaderSiteInfoSubscriptionEmail *)emailSubscriptionFor:(RemoteReaderSiteInfo *)siteInfo topic:(ReaderSiteTopic *)topic +- (ReaderSiteInfoSubscriptionEmail *)emailSubscriptionFor:(RemoteReaderSiteInfo *)siteInfo topic:(ReaderSiteTopic *)topic inContext:(NSManagedObjectContext *)context { if (![siteInfo.emailSubscription wp_isValidObject]) { return nil; @@ -893,7 +791,7 @@ - (ReaderSiteInfoSubscriptionEmail *)emailSubscriptionFor:(RemoteReaderSiteInfo ReaderSiteInfoSubscriptionEmail *emailSubscription = topic.emailSubscription; if (![emailSubscription wp_isValidObject]) { emailSubscription = [NSEntityDescription insertNewObjectForEntityForName:[ReaderSiteInfoSubscriptionEmail classNameWithoutNamespaces] - inManagedObjectContext:self.managedObjectContext]; + inManagedObjectContext:context]; } emailSubscription.sendPosts = siteInfo.emailSubscription.sendPosts; emailSubscription.sendComments = siteInfo.emailSubscription.sendComments; @@ -925,20 +823,50 @@ - (NSString *)formatTitle:(NSString *)str return [title capitalizedStringWithLocale:[NSLocale currentLocale]]; } +/** +Saves the specified `ReaderSiteTopics`. Any `ReaderSiteTopics` not included in the passed +array are marked as being unfollowed in Core Data. + +@param topics An array of `ReaderSiteTopics` to save. +*/ +- (void)mergeFollowedSites:(NSArray *)sites withSuccess:(void (^)(void))success +{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSArray *currentSiteTopics = [ReaderAbstractTopic lookupAllSitesInContext:context error:nil]; + NSMutableArray *remoteFeedIds = [NSMutableArray array]; + + for (RemoteReaderSiteInfo *siteInfo in sites) { + if (siteInfo.feedID) { + [remoteFeedIds addObject:siteInfo.feedID]; + } + + [self siteTopicForRemoteSiteInfo:siteInfo inContext:context]; + } + + for (ReaderSiteTopic *siteTopic in currentSiteTopics) { + // If a site fetched from Core Data isn't included in the list of sites + // fetched from remote, that means it's no longer being followed. + if (![remoteFeedIds containsObject:siteTopic.feedID]) { + siteTopic.following = NO; + } + } + } completion:success onQueue:dispatch_get_main_queue()]; +} + /** Saves the specified `ReaderAbstractTopics`. Any `ReaderAbstractTopics` not included in the passed array are removed from Core Data. @param topics An array of `ReaderAbstractTopics` to save. */ -- (void)mergeMenuTopics:(NSArray *)topics withSuccess:(void (^)(void))success +- (void)mergeMenuTopics:(NSArray *)topics isLoggedIn:(BOOL)isLoggedIn withSuccess:(void (^)(void))success { - [self.managedObjectContext performBlock:^{ - NSArray *currentTopics = [self allMenuTopics]; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + NSArray *currentTopics = [ReaderAbstractTopic lookupAllMenusInContext:context error:nil]; NSMutableArray *topicsToKeep = [NSMutableArray array]; for (RemoteReaderTopic *remoteTopic in topics) { - ReaderAbstractTopic *newTopic = [self createOrReplaceFromRemoteTopic:remoteTopic]; + ReaderAbstractTopic *newTopic = [self createOrReplaceFromRemoteTopic:remoteTopic inContext:context]; if (newTopic != nil) { [topicsToKeep addObject:newTopic]; } else { @@ -949,10 +877,16 @@ - (void)mergeMenuTopics:(NSArray *)topics withSuccess:(void (^)(void))success if ([currentTopics count] > 0) { for (ReaderAbstractTopic *topic in currentTopics) { if (![topic isKindOfClass:[ReaderSiteTopic class]] && ![topicsToKeep containsObject:topic]) { - if ([topic isEqual:self.currentTopic]) { + + if ([topic isEqual:[self currentTopicInContext:context]]) { self.currentTopic = nil; } if (topic.inUse) { + if (!ReaderHelpers.isLoggedIn && [topic isKindOfClass:ReaderTagTopic.class]) { + DDLogInfo(@"Not unfollowing a locally saved topic: %@", topic.title); + continue; + } + // If the topic is in use just set showInMenu to false // and let it be cleaned up like any other non-menu topic. DDLogInfo(@"Removing topic from menu: %@", topic.title); @@ -961,144 +895,35 @@ - (void)mergeMenuTopics:(NSArray *)topics withSuccess:(void (^)(void))success // removing the topic, if it was once followed its not now. topic.following = NO; } else { + // If the user adds a locally saved tag/interest prevent it from being deleted + // while the user is logged out. + ReaderTagTopic *tagTopic = (ReaderTagTopic *)topic; + + if (!isLoggedIn && [topic isKindOfClass:ReaderTagTopic.class]) { + DDLogInfo(@"Not deleting a locally saved topic: %@", topic.title); + continue; + } + + if ([topic isKindOfClass:ReaderTagTopic.class] && tagTopic.cards.count > 0) { + DDLogInfo(@"Not deleting a topic related to a card: %@", topic.title); + continue; + } + DDLogInfo(@"Deleting topic: %@", topic.title); [self preserveSavedPostsFromTopic:topic]; - [self.managedObjectContext deleteObject:topic]; + [context deleteObject:topic]; } } } } - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (success) { - success(); - } - }]; - - }]; -} - -/** - Fetch all `ReaderAbstractTopics` for the menu currently in Core Data. - - @return An array of all `ReaderAbstractTopics` for the menu currently persisted in Core Data. - */ -- (NSArray *)allMenuTopics -{ - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderAbstractTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"showInMenu = YES"]; - - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error executing fetch request: %@", NSStringFromSelector(_cmd), error); - return nil; - } - - return results; + } completion:success onQueue:dispatch_get_main_queue()]; } -/** - Fetch all `ReaderAbstractTopics` currently in Core Data. - - @return An array of all `ReaderAbstractTopics` currently persisted in Core Data. - */ -- (NSArray *)allTopics -{ - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderAbstractTopic classNameWithoutNamespaces]]; - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error executing fetch request: %@", NSStringFromSelector(_cmd), error); - return nil; - } - - return results; -} - -/** - Fetch all `ReaderAbstractTopics` currently in Core Data. - - @return An array of all `ReaderAbstractTopics` currently persisted in Core Data. - */ -- (NSArray *)allSiteTopics -{ - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderSiteTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"following = YES"]; - - NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"title" - ascending:YES - selector:@selector(localizedCaseInsensitiveCompare:)]; - request.sortDescriptors = @[sortDescriptor]; - - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error executing fetch request: %@", NSStringFromSelector(_cmd), error); - return @[]; - } - - return results; -} - -/** - Find a specific ReaderAbstractTopic by its `path` property. - - @param path The unique, cannonical path of the topic. - @return A matching `ReaderAbstractTopic` or nil if there is no match. - */ -- (ReaderAbstractTopic *)findWithPath:(NSString *)path -{ - NSArray *results = [[self allTopics] filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"path = %@", [path lowercaseString]]]; - return [results firstObject]; -} - -- (ReaderAbstractTopic *)findContainingPath:(NSString *)path -{ - NSArray *results = [[self allTopics] filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"path CONTAINS %@", [path lowercaseString]]]; - return [results firstObject]; -} - -- (ReaderSiteTopic *)findSiteTopicWithSiteID:(NSNumber *)siteID -{ - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderSiteTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"siteID = %@", siteID]; - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error executing fetch request: %@", NSStringFromSelector(_cmd), error); - return nil; - } - - return (ReaderSiteTopic *)[results firstObject]; -} - -- (ReaderSiteTopic *)findSiteTopicWithFeedID:(NSNumber *)feedID -{ - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderSiteTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"feedID = %@", feedID]; - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error executing fetch request: %@", NSStringFromSelector(_cmd), error); - return nil; - } - - return (ReaderSiteTopic *)[results firstObject]; -} - -- (ReaderSiteTopic *)findSiteTopicWithFeedURL:(NSString *)feedURL +- (void)mergeMenuTopics:(NSArray *)topics withSuccess:(void (^)(void))success { - NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:[ReaderSiteTopic classNameWithoutNamespaces]]; - request.predicate = [NSPredicate predicateWithFormat:@"feedURL = %@", feedURL]; - NSError *error; - NSArray *results = [self.managedObjectContext executeFetchRequest:request error:&error]; - if (error) { - DDLogError(@"%@ error executing fetch request: %@", NSStringFromSelector(_cmd), error); - return nil; - } - - return (ReaderSiteTopic *)[results firstObject]; + [self mergeMenuTopics:topics + isLoggedIn:ReaderHelpers.isLoggedIn + withSuccess:success]; } @end diff --git a/WordPress/Classes/Services/RoleService.swift b/WordPress/Classes/Services/RoleService.swift index 8c0ad2e26a7f..78d610bb6d4e 100644 --- a/WordPress/Classes/Services/RoleService.swift +++ b/WordPress/Classes/Services/RoleService.swift @@ -4,42 +4,40 @@ import WordPressKit /// Service providing access to user roles /// struct RoleService { - let blog: Blog + let blogID: NSManagedObjectID - fileprivate let context: NSManagedObjectContext + fileprivate let coreDataStack: CoreDataStack fileprivate let remote: PeopleServiceRemote fileprivate let siteID: Int - init?(blog: Blog, context: NSManagedObjectContext) { + init?(blog: Blog, coreDataStack: CoreDataStack) { guard let api = blog.wordPressComRestApi(), let dotComID = blog.dotComID as? Int else { return nil } self.remote = PeopleServiceRemote(wordPressComRestApi: api) self.siteID = dotComID - self.blog = blog - self.context = context - } - - /// Returns a role from Core Data with the given slug. - /// - func getRole(slug: String) -> Role? { - let predicate = NSPredicate(format: "slug = %@ AND blog = %@", slug, blog) - return context.firstObject(ofType: Role.self, matching: predicate) + self.blogID = blog.objectID + self.coreDataStack = coreDataStack } /// Forces a refresh of roles from the api and stores them in Core Data. /// - func fetchRoles(success: @escaping ([Role]) -> Void, failure: @escaping (Error) -> Void) { + func fetchRoles(success: @escaping () -> Void, failure: @escaping (Error) -> Void) { remote.getUserRoles(siteID, success: { (remoteRoles) in - let roles = self.mergeRoles(remoteRoles) - success(roles) + self.coreDataStack.performAndSave({ context in + self.mergeRoles(remoteRoles, in: context) + }, completion: success, on: .main) }, failure: failure) } } private extension RoleService { - func mergeRoles(_ remoteRoles: [RemoteRole]) -> [Role] { + func mergeRoles(_ remoteRoles: [RemoteRole], in context: NSManagedObjectContext) { + guard let blog = try? context.existingObject(with: blogID) as? Blog else { + DDLogError("The blog used to create RoleService was deleted") + return + } let existingRoles = blog.roles ?? [] var rolesToKeep = [Role]() for (order, remoteRole) in remoteRoles.enumerated() { @@ -57,7 +55,5 @@ private extension RoleService { } let rolesToDelete = existingRoles.subtracting(rolesToKeep) rolesToDelete.forEach(context.delete(_:)) - ContextManager.sharedInstance().save(context) - return rolesToKeep } } diff --git a/WordPress/Classes/Services/ShareExtensionService.swift b/WordPress/Classes/Services/ShareExtensionService.swift index d1dc7b138810..670f57e6a0ec 100644 --- a/WordPress/Classes/Services/ShareExtensionService.swift +++ b/WordPress/Classes/Services/ShareExtensionService.swift @@ -8,11 +8,13 @@ open class ShareExtensionService: NSObject { /// @objc class func configureShareExtensionToken(_ oauth2Token: String) { do { - try SFHFKeychainUtils.storeUsername(WPShareExtensionKeychainTokenKey, + try SFHFKeychainUtils.storeUsername( + AppConfiguration.Extension.Share.keychainTokenKey, andPassword: oauth2Token, - forServiceName: WPShareExtensionKeychainServiceName, + forServiceName: AppConfiguration.Extension.Share.keychainServiceName, accessGroup: WPAppKeychainAccessGroup, - updateExisting: true) + updateExisting: true + ) } catch { print("Error while saving Share Extension OAuth bearer token: \(error)") } @@ -24,11 +26,13 @@ open class ShareExtensionService: NSObject { /// @objc class func configureShareExtensionUsername(_ username: String) { do { - try SFHFKeychainUtils.storeUsername(WPShareExtensionKeychainUsernameKey, + try SFHFKeychainUtils.storeUsername( + AppConfiguration.Extension.Share.keychainUsernameKey, andPassword: username, - forServiceName: WPShareExtensionKeychainServiceName, + forServiceName: AppConfiguration.Extension.Share.keychainServiceName, accessGroup: WPAppKeychainAccessGroup, - updateExisting: true) + updateExisting: true + ) } catch { print("Error while saving Share Extension OAuth bearer token: \(error)") } @@ -46,8 +50,8 @@ open class ShareExtensionService: NSObject { return } - userDefaults.set(defaultSiteID, forKey: WPShareExtensionUserDefaultsPrimarySiteID) - userDefaults.set(defaultSiteName, forKey: WPShareExtensionUserDefaultsPrimarySiteName) + userDefaults.set(defaultSiteID, forKey: AppConfiguration.Extension.Share.userDefaultsPrimarySiteID) + userDefaults.set(defaultSiteName, forKey: AppConfiguration.Extension.Share.userDefaultsPrimarySiteName) } /// Sets the Last Used Site that should be pre-selected in the Share Extension. @@ -61,8 +65,8 @@ open class ShareExtensionService: NSObject { return } - userDefaults.set(lastUsedSiteID, forKey: WPShareExtensionUserDefaultsLastUsedSiteID) - userDefaults.set(lastUsedSiteName, forKey: WPShareExtensionUserDefaultsLastUsedSiteName) + userDefaults.set(lastUsedSiteID, forKey: AppConfiguration.Extension.Share.userDefaultsLastUsedSiteID) + userDefaults.set(lastUsedSiteName, forKey: AppConfiguration.Extension.Share.userDefaultsLastUsedSiteName) } /// Sets the Maximum Media Size. @@ -74,7 +78,7 @@ open class ShareExtensionService: NSObject { return } - userDefaults.set(maximumMediaDimension, forKey: WPShareExtensionMaximumMediaDimensionKey) + userDefaults.set(maximumMediaDimension, forKey: AppConfiguration.Extension.Share.maximumMediaDimensionKey) } @@ -87,43 +91,48 @@ open class ShareExtensionService: NSObject { return } - userDefaults.set(recentSites, forKey: WPShareExtensionRecentSitesKey) + userDefaults.set(recentSites, forKey: AppConfiguration.Extension.Share.recentSitesKey) } /// Nukes all of the Share Extension Configuration /// @objc class func removeShareExtensionConfiguration() { do { - try SFHFKeychainUtils.deleteItem(forUsername: WPShareExtensionKeychainTokenKey, - andServiceName: WPShareExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup) + try SFHFKeychainUtils.deleteItem( + forUsername: AppConfiguration.Extension.Share.keychainTokenKey, + andServiceName: AppConfiguration.Extension.Share.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup + ) } catch { print("Error while removing Share Extension OAuth2 bearer token: \(error)") } do { - try SFHFKeychainUtils.deleteItem(forUsername: WPShareExtensionKeychainUsernameKey, - andServiceName: WPShareExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup) + try SFHFKeychainUtils.deleteItem( + forUsername: AppConfiguration.Extension.Share.keychainUsernameKey, + andServiceName: AppConfiguration.Extension.Share.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup + ) } catch { print("Error while removing Share Extension Username: \(error)") } if let userDefaults = UserDefaults(suiteName: WPAppGroupName) { - userDefaults.removeObject(forKey: WPShareExtensionUserDefaultsPrimarySiteID) - userDefaults.removeObject(forKey: WPShareExtensionUserDefaultsPrimarySiteName) - userDefaults.removeObject(forKey: WPShareExtensionUserDefaultsLastUsedSiteID) - userDefaults.removeObject(forKey: WPShareExtensionUserDefaultsLastUsedSiteName) - userDefaults.removeObject(forKey: WPShareExtensionMaximumMediaDimensionKey) - userDefaults.removeObject(forKey: WPShareExtensionRecentSitesKey) + userDefaults.removeObject(forKey: AppConfiguration.Extension.Share.userDefaultsPrimarySiteID) + userDefaults.removeObject(forKey: AppConfiguration.Extension.Share.userDefaultsPrimarySiteName) + userDefaults.removeObject(forKey: AppConfiguration.Extension.Share.userDefaultsLastUsedSiteID) + userDefaults.removeObject(forKey: AppConfiguration.Extension.Share.userDefaultsLastUsedSiteName) + userDefaults.removeObject(forKey: AppConfiguration.Extension.Share.maximumMediaDimensionKey) + userDefaults.removeObject(forKey: AppConfiguration.Extension.Share.recentSitesKey) } } /// Retrieves the WordPress.com OAuth Token, meant for Extension usage. /// @objc class func retrieveShareExtensionToken() -> String? { - guard let oauth2Token = try? SFHFKeychainUtils.getPasswordForUsername(WPShareExtensionKeychainTokenKey, - andServiceName: WPShareExtensionKeychainServiceName, accessGroup: WPAppKeychainAccessGroup) else { + guard let oauth2Token = try? SFHFKeychainUtils.getPasswordForUsername(AppConfiguration.Extension.Share.keychainTokenKey, + andServiceName: AppConfiguration.Extension.Share.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup) else { return nil } @@ -133,8 +142,9 @@ open class ShareExtensionService: NSObject { /// Retrieves the WordPress.com Username, meant for Extension usage. /// @objc class func retrieveShareExtensionUsername() -> String? { - guard let oauth2Token = try? SFHFKeychainUtils.getPasswordForUsername(WPShareExtensionKeychainUsernameKey, - andServiceName: WPShareExtensionKeychainServiceName, accessGroup: WPAppKeychainAccessGroup) else { + guard let oauth2Token = try? SFHFKeychainUtils.getPasswordForUsername(AppConfiguration.Extension.Share.keychainUsernameKey, + andServiceName: AppConfiguration.Extension.Share.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup) else { return nil } @@ -148,8 +158,8 @@ open class ShareExtensionService: NSObject { return nil } - if let siteID = userDefaults.object(forKey: WPShareExtensionUserDefaultsPrimarySiteID) as? Int, - let siteName = userDefaults.object(forKey: WPShareExtensionUserDefaultsPrimarySiteName) as? String { + if let siteID = userDefaults.object(forKey: AppConfiguration.Extension.Share.userDefaultsPrimarySiteID) as? Int, + let siteName = userDefaults.object(forKey: AppConfiguration.Extension.Share.userDefaultsPrimarySiteName) as? String { return (siteID, siteName) } @@ -164,13 +174,13 @@ open class ShareExtensionService: NSObject { return nil } - if let siteID = userDefaults.object(forKey: WPShareExtensionUserDefaultsLastUsedSiteID) as? Int, - let siteName = userDefaults.object(forKey: WPShareExtensionUserDefaultsLastUsedSiteName) as? String { + if let siteID = userDefaults.object(forKey: AppConfiguration.Extension.Share.userDefaultsLastUsedSiteID) as? Int, + let siteName = userDefaults.object(forKey: AppConfiguration.Extension.Share.userDefaultsLastUsedSiteName) as? String { return (siteID, siteName) } - if let siteID = userDefaults.object(forKey: WPShareExtensionUserDefaultsPrimarySiteID) as? Int, - let siteName = userDefaults.object(forKey: WPShareExtensionUserDefaultsPrimarySiteName) as? String { + if let siteID = userDefaults.object(forKey: AppConfiguration.Extension.Share.userDefaultsPrimarySiteID) as? Int, + let siteName = userDefaults.object(forKey: AppConfiguration.Extension.Share.userDefaultsPrimarySiteName) as? String { return (siteID, siteName) } @@ -184,7 +194,7 @@ open class ShareExtensionService: NSObject { return nil } - return userDefaults.object(forKey: WPShareExtensionMaximumMediaDimensionKey) as? Int + return userDefaults.object(forKey: AppConfiguration.Extension.Share.maximumMediaDimensionKey) as? Int } /// Retrieves the recently used sites, if any. @@ -194,6 +204,6 @@ open class ShareExtensionService: NSObject { return nil } - return userDefaults.object(forKey: WPShareExtensionRecentSitesKey) as? [String] + return userDefaults.object(forKey: AppConfiguration.Extension.Share.recentSitesKey) as? [String] } } diff --git a/WordPress/Classes/Services/SharingService.swift b/WordPress/Classes/Services/SharingService.swift index 77a42704a5b7..d7867616b434 100644 --- a/WordPress/Classes/Services/SharingService.swift +++ b/WordPress/Classes/Services/SharingService.swift @@ -6,8 +6,22 @@ import WordPressKit /// SharingService is responsible for wrangling publicize services, publicize /// connections, and keyring connections. /// -open class SharingService: LocalCoreDataService { - @objc let SharingAPIErrorNotFound = "not_found" +@objc class SharingService: NSObject { + let SharingAPIErrorNotFound = "not_found" + + private let coreDataStack: CoreDataStackSwift + + /// The initialiser for Objective-C code. + /// + /// Using `ContextManager` as the argument becuase `CoreDataStackSwift` is not accessible from Objective-C code. + @objc + init(contextManager: ContextManager) { + self.coreDataStack = contextManager + } + + init(coreDataStack: CoreDataStackSwift) { + self.coreDataStack = coreDataStack + } // MARK: - Publicize Related Methods @@ -19,7 +33,7 @@ open class SharingService: LocalCoreDataService { /// - success: An optional success block accepting no parameters /// - failure: An optional failure block accepting an `NSError` parameter /// - @objc open func syncPublicizeServicesForBlog(_ blog: Blog, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { + @objc func syncPublicizeServicesForBlog(_ blog: Blog, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { guard let remote = remoteForBlog(blog) else { return } @@ -39,7 +53,7 @@ open class SharingService: LocalCoreDataService { /// - success: An optional success block accepting an array of `KeyringConnection` objects /// - failure: An optional failure block accepting an `NSError` parameter /// - @objc open func fetchKeyringConnectionsForBlog(_ blog: Blog, success: (([KeyringConnection]) -> Void)?, failure: ((NSError?) -> Void)?) { + @objc func fetchKeyringConnectionsForBlog(_ blog: Blog, success: (([KeyringConnection]) -> Void)?, failure: ((NSError?) -> Void)?) { guard let remote = remoteForBlog(blog) else { return } @@ -51,27 +65,6 @@ open class SharingService: LocalCoreDataService { } - /// Syncs Publicize connections for the specified wpcom blog. - /// - /// - Parameters: - /// - blog: The `Blog` for which to sync publicize connections - /// - success: An optional success block accepting no parameters. - /// - failure: An optional failure block accepting an `NSError` parameter. - /// - @objc open func syncPublicizeConnectionsForBlog(_ blog: Blog, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { - let blogObjectID = blog.objectID - guard let remote = remoteForBlog(blog) else { - return - } - remote.getPublicizeConnections(blog.dotComID!, success: { remoteConnections in - - // Process the results - self.mergePublicizeConnectionsForBlog(blogObjectID, remoteConnections: remoteConnections, onComplete: success) - }, - failure: failure) - } - - /// Creates a new publicize connection for the specified `Blog`, using the specified /// keyring. Optionally the connection can target a particular external user account. /// @@ -82,7 +75,7 @@ open class SharingService: LocalCoreDataService { /// - success: An optional success block accepting a `PublicizeConnection` parameter. /// - failure: An optional failure block accepting an NSError parameter. /// - @objc open func createPublicizeConnectionForBlog(_ blog: Blog, + @objc func createPublicizeConnectionForBlog(_ blog: Blog, keyring: KeyringConnection, externalUserID: String?, success: ((PublicizeConnection) -> Void)?, @@ -92,7 +85,8 @@ open class SharingService: LocalCoreDataService { return } let dotComID = blog.dotComID! - remote.createPublicizeConnection(dotComID, + remote.createPublicizeConnection( + dotComID, keyringConnectionID: keyring.keyringID, externalUserID: externalUserID, success: { remoteConnection in @@ -100,17 +94,26 @@ open class SharingService: LocalCoreDataService { "service": keyring.service ] WPAppAnalytics.track(.sharingPublicizeConnected, withProperties: properties, withBlogID: dotComID) - do { - let pubConn = try self.createOrReplacePublicizeConnectionForBlogWithObjectID(blogObjectID, remoteConnection: remoteConnection) - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { - success?(pubConn) - }) - - } catch let error as NSError { - DDLogError("Error creating publicize connection from remote: \(error)") - failure?(error) - } + self.coreDataStack.performAndSave({ context -> NSManagedObjectID in + let pubConn = try self.createOrReplacePublicizeConnectionForBlogWithObjectID(blogObjectID, remoteConnection: remoteConnection, in: context) + try context.obtainPermanentIDs(for: [pubConn]) + return pubConn.objectID + }, completion: { result in + let transformed = result.flatMap { objectID in + Result { + let object = try self.coreDataStack.mainContext.existingObject(with: objectID) + return object as! PublicizeConnection + } + } + switch transformed { + case let .success(object): + success?(object) + case let .failure(error): + DDLogError("Error creating publicize connection from remote: \(error)") + failure?(error as NSError) + } + }, on: .main) }, failure: { (error: NSError?) in failure?(error) @@ -127,53 +130,72 @@ open class SharingService: LocalCoreDataService { /// - success: An optional success block accepting no parameters. /// - failure: An optional failure block accepting an NSError parameter. /// - @objc open func updateSharedForBlog(_ blog: Blog, + @objc func updateSharedForBlog( + _ blog: Blog, shared: Bool, forPublicizeConnection pubConn: PublicizeConnection, success: (() -> Void)?, - failure: ((NSError?) -> Void)?) { - - if pubConn.shared == shared { - success?() - return - } - - let oldValue = pubConn.shared - pubConn.shared = shared - ContextManager.sharedInstance().save(managedObjectContext) + failure: ((NSError?) -> Void)? + ) { + typealias PubConnUpdateResult = (oldValue: Bool, siteID: NSNumber, connectionID: NSNumber, service: String, remote: SharingServiceRemote?) - let blogObjectID = blog.objectID - let siteID = pubConn.siteID - guard let remote = remoteForBlog(blog) else { - return - } - remote.updatePublicizeConnectionWithID(pubConn.connectionID, - shared: shared, - forSite: siteID, - success: { remoteConnection in - let properties = [ - "service": pubConn.service, - "is_site_wide": NSNumber(value: shared).stringValue - ] - WPAppAnalytics.track(.sharingPublicizeConnectionAvailableToAllChanged, withProperties: properties, withBlogID: siteID) - do { - _ = try self.createOrReplacePublicizeConnectionForBlogWithObjectID(blogObjectID, remoteConnection: remoteConnection) - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { - success?() - }) + let blogObjectID = blog.objectID + coreDataStack.performAndSave({ context -> PubConnUpdateResult in + let blogInContext = try context.existingObject(with: blogObjectID) as! Blog + let pubConnInContext = try context.existingObject(with: pubConn.objectID) as! PublicizeConnection + let oldValue = pubConnInContext.shared + pubConnInContext.shared = shared + return ( + oldValue: oldValue, + siteID: pubConnInContext.siteID, + connectionID: pubConnInContext.connectionID, + service: pubConnInContext.service, + remote: self.remoteForBlog(blogInContext) + ) + }, completion: { result in + switch result { + case let .success(value): + if value.oldValue == shared { + success?() + return + } - } catch let error as NSError { - DDLogError("Error creating publicize connection from remote: \(error)") - failure?(error) + value.remote?.updatePublicizeConnectionWithID( + value.connectionID, + shared: shared, + forSite: value.siteID, + success: { remoteConnection in + let properties = [ + "service": value.service, + "is_site_wide": NSNumber(value: shared).stringValue + ] + WPAppAnalytics.track(.sharingPublicizeConnectionAvailableToAllChanged, withProperties: properties, withBlogID: value.siteID) + + self.coreDataStack.performAndSave({ context in + try self.createOrReplacePublicizeConnectionForBlogWithObjectID(blogObjectID, remoteConnection: remoteConnection, in: context) + }, completion: { result in + switch result { + case .success: + success?() + case let .failure(error): + DDLogError("Error creating publicize connection from remote: \(error)") + failure?(error as NSError) + } + }, on: .main) + }, + failure: { (error: NSError?) in + self.coreDataStack.performAndSave({ context in + let pubConnInContext = try context.existingObject(with: pubConn.objectID) as! PublicizeConnection + pubConnInContext.shared = value.oldValue + }, completion: { _ in + failure?(error) + }, on: .main) } - - }, - failure: { (error: NSError?) in - pubConn.shared = oldValue - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { - failure?(error) - }) - }) + ) + case let .failure(error): + failure?(error as NSError) + } + }, on: .main) } @@ -186,7 +208,7 @@ open class SharingService: LocalCoreDataService { /// - success: An optional success block accepting no parameters. /// - failure: An optional failure block accepting an NSError parameter. /// - @objc open func updateExternalID(_ externalID: String, + @objc func updateExternalID(_ externalID: String, forBlog blog: Blog, forPublicizeConnection pubConn: PublicizeConnection, success: (() -> Void)?, @@ -205,17 +227,17 @@ open class SharingService: LocalCoreDataService { externalID: externalID, forSite: siteID, success: { remoteConnection in - do { - _ = try self.createOrReplacePublicizeConnectionForBlogWithObjectID(blogObjectID, remoteConnection: remoteConnection) - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { + self.coreDataStack.performAndSave({ context in + try self.createOrReplacePublicizeConnectionForBlogWithObjectID(blogObjectID, remoteConnection: remoteConnection, in: context) + }, completion: { result in + switch result { + case .success: success?() - }) - - } catch let error as NSError { - DDLogError("Error creating publicize connection from remote: \(error)") - failure?(error) - } - + case let .failure(error): + DDLogError("Error creating publicize connection from remote: \(error)") + failure?(error as NSError) + } + }, on: .main) }, failure: failure) } @@ -230,82 +252,45 @@ open class SharingService: LocalCoreDataService { /// - success: An optional success block accepting no parameters. /// - failure: An optional failure block accepting an NSError parameter. /// - @objc open func deletePublicizeConnectionForBlog(_ blog: Blog, pubConn: PublicizeConnection, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { + @objc func deletePublicizeConnectionForBlog(_ blog: Blog, pubConn: PublicizeConnection, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { // optimistically delete the connection locally. - let siteID = pubConn.siteID - managedObjectContext.delete(pubConn) - ContextManager.sharedInstance().save(managedObjectContext) - - guard let remote = remoteForBlog(blog) else { - return - } - remote.deletePublicizeConnection(siteID, - connectionID: pubConn.connectionID, - success: { - let properties = [ - "service": pubConn.service - ] - WPAppAnalytics.track(.sharingPublicizeDisconnected, withProperties: properties, withBlogID: siteID) - success?() - }, - failure: { (error: NSError?) in - if let errorCode = error?.userInfo[WordPressComRestApi.ErrorKeyErrorCode] as? String { - if errorCode == self.SharingAPIErrorNotFound { - // This is a special situation. If the call to disconnect the service returns not_found then the service - // has probably already been disconnected and the call was made with stale data. - // Assume this is the case and treat this error as a successful disconnect. + coreDataStack.performAndSave({ context in + let blogInContext = try context.existingObject(with: blog.objectID) as! Blog + let pubConnInContext = try context.existingObject(with: pubConn.objectID) as! PublicizeConnection + + let siteID = pubConnInContext.siteID + context.delete(pubConnInContext) + return (siteID, pubConnInContext.connectionID, pubConnInContext.service, self.remoteForBlog(blogInContext)) + }, completion: { result in + switch result { + case let .success((siteID, connectionID, service, remote)): + remote?.deletePublicizeConnection( + siteID, + connectionID: connectionID, + success: { + let properties = [ + "service": service + ] + WPAppAnalytics.track(.sharingPublicizeDisconnected, withProperties: properties, withBlogID: siteID) success?() - return + }, + failure: { (error: NSError?) in + if let errorCode = error?.userInfo[WordPressComRestApi.ErrorKeyErrorCode] as? String { + if errorCode == self.SharingAPIErrorNotFound { + // This is a special situation. If the call to disconnect the service returns not_found then the service + // has probably already been disconnected and the call was made with stale data. + // Assume this is the case and treat this error as a successful disconnect. + success?() + return + } + } + failure?(error) } - } - failure?(error) - }) - } - - - // MARK: - Public PublicizeService Methods - - - /// Finds a cached `PublicizeService` matching the specified service name. - /// - /// - Parameter name: The name of the service. This is the `serviceID` attribute for a `PublicizeService` object. - /// - /// - Returns: The requested `PublicizeService` or nil. - /// - @objc open func findPublicizeServiceNamed(_ name: String) -> PublicizeService? { - let request = NSFetchRequest<NSFetchRequestResult>(entityName: PublicizeService.classNameWithoutNamespaces()) - request.predicate = NSPredicate(format: "serviceID = %@", name) - - var services: [PublicizeService] - do { - services = try managedObjectContext.fetch(request) as! [PublicizeService] - } catch let error as NSError { - DDLogError("Error fetching Publicize Service named \(name) : \(error.localizedDescription)") - services = [] - } - - return services.first - } - - - /// Returns an array of all cached `PublicizeService` objects. - /// - /// - Returns: An array of `PublicizeService`. The array is empty if no objects are cached. - /// - @objc open func allPublicizeServices() -> [PublicizeService] { - let request = NSFetchRequest<NSFetchRequestResult>(entityName: PublicizeService.classNameWithoutNamespaces()) - let sortDescriptor = NSSortDescriptor(key: "order", ascending: true) - request.sortDescriptors = [sortDescriptor] - - var services: [PublicizeService] - do { - services = try managedObjectContext.fetch(request) as! [PublicizeService] - } catch let error as NSError { - DDLogError("Error fetching Publicize Services: \(error.localizedDescription)") - services = [] - } - - return services + ) + case let .failure(error): + failure?(error as NSError) + } + }, on: .main) } @@ -319,28 +304,22 @@ open class SharingService: LocalCoreDataService { /// - remoteServices: An array of `RemotePublicizeService` objects to merge. /// - success: An optional callback block to be performed when core data has saved the changes. /// - fileprivate func mergePublicizeServices(_ remoteServices: [RemotePublicizeService], success: (() -> Void)? ) { - managedObjectContext.perform { - let currentPublicizeServices = self.allPublicizeServices() + private func mergePublicizeServices(_ remoteServices: [RemotePublicizeService], success: (() -> Void)? ) { + coreDataStack.performAndSave({ context in + let currentPublicizeServices = (try? PublicizeService.allPublicizeServices(in: context)) ?? [] // Create or update based on the contents synced. let servicesToKeep = remoteServices.map { (remoteService) -> PublicizeService in - let pubService = self.createOrReplaceFromRemotePublicizeService(remoteService) - return pubService + self.createOrReplaceFromRemotePublicizeService(remoteService, in: context) } // Delete any cached PublicizeServices that were not synced. for pubService in currentPublicizeServices { if !servicesToKeep.contains(pubService) { - self.managedObjectContext.delete(pubService) + context.delete(pubService) } } - - // Save all the things. - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { - success?() - }) - } + }, completion: success, on: .main) } @@ -350,11 +329,11 @@ open class SharingService: LocalCoreDataService { /// /// - Returns: A `PublicizeService`. /// - fileprivate func createOrReplaceFromRemotePublicizeService(_ remoteService: RemotePublicizeService) -> PublicizeService { - var pubService = findPublicizeServiceNamed(remoteService.serviceID) + private func createOrReplaceFromRemotePublicizeService(_ remoteService: RemotePublicizeService, in context: NSManagedObjectContext) -> PublicizeService { + var pubService = try? PublicizeService.lookupPublicizeServiceNamed(remoteService.serviceID, in: context) if pubService == nil { pubService = NSEntityDescription.insertNewObject(forEntityName: PublicizeService.classNameWithoutNamespaces(), - into: managedObjectContext) as? PublicizeService + into: context) as? PublicizeService } pubService?.connectURL = remoteService.connectURL pubService?.detail = remoteService.detail @@ -372,139 +351,9 @@ open class SharingService: LocalCoreDataService { } - // MARK: - Public PublicizeConnection Methods - - - /// Finds a cached `PublicizeConnection` by its `connectionID` - /// - /// - Parameter connectionID: The ID of the `PublicizeConnection`. - /// - /// - Returns: The requested `PublicizeConnection` or nil. - /// - @objc open func findPublicizeConnectionByID(_ connectionID: NSNumber) -> PublicizeConnection? { - let request = NSFetchRequest<NSFetchRequestResult>(entityName: PublicizeConnection.classNameWithoutNamespaces()) - request.predicate = NSPredicate(format: "connectionID = %@", connectionID) - - var services: [PublicizeConnection] - do { - services = try managedObjectContext.fetch(request) as! [PublicizeConnection] - } catch let error as NSError { - DDLogError("Error fetching Publicize Service with ID \(connectionID) : \(error.localizedDescription)") - services = [] - } - - return services.first - } - - - /// Returns an array of all cached `PublicizeConnection` objects. - /// - /// - Parameters - /// - blog: A `Blog` object - /// - /// - Returns: An array of `PublicizeConnection`. The array is empty if no objects are cached. - /// - @objc open func allPublicizeConnectionsForBlog(_ blog: Blog) -> [PublicizeConnection] { - let request = NSFetchRequest<NSFetchRequestResult>(entityName: PublicizeConnection.classNameWithoutNamespaces()) - request.predicate = NSPredicate(format: "blog = %@", blog) - - var connections: [PublicizeConnection] - do { - connections = try managedObjectContext.fetch(request) as! [PublicizeConnection] - } catch let error as NSError { - DDLogError("Error fetching Publicize Connections: \(error.localizedDescription)") - connections = [] - } - - return connections - } - - // MARK: - Private PublicizeConnection Methods - /// Called when syncing Publicize connections. Merges synced and cached data, removing - /// anything that does not exist on the server. Saves the context. - /// - /// - Parameters: - /// - blogObjectID: the NSManagedObjectID of a `Blog` - /// - remoteConnections: An array of `RemotePublicizeConnection` objects to merge. - /// - onComplete: An optional callback block to be performed when core data has saved the changes. - /// - fileprivate func mergePublicizeConnectionsForBlog(_ blogObjectID: NSManagedObjectID, remoteConnections: [RemotePublicizeConnection], onComplete: (() -> Void)?) { - managedObjectContext.perform { - var blog: Blog - do { - blog = try self.managedObjectContext.existingObject(with: blogObjectID) as! Blog - } catch let error as NSError { - DDLogError("Error fetching Blog: \(error)") - // Because of the error we'll bail early, but we still need to call - // the success callback if one was passed. - onComplete?() - return - } - - let currentPublicizeConnections = self.allPublicizeConnectionsForBlog(blog) - - // Create or update based on the contents synced. - let connectionsToKeep = remoteConnections.map { (remoteConnection) -> PublicizeConnection in - let pubConnection = self.createOrReplaceFromRemotePublicizeConnection(remoteConnection) - pubConnection.blog = blog - return pubConnection - } - - // Delete any cached PublicizeServices that were not synced. - for pubConnection in currentPublicizeConnections { - if !connectionsToKeep.contains(pubConnection) { - self.managedObjectContext.delete(pubConnection) - } - } - - // Save all the things. - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { - onComplete?() - }) - } - } - - - /// Composes a new `PublicizeConnection`, or updates an existing one, with - /// data represented by the passed `RemotePublicizeConnection`. - /// - /// - Parameter remoteConnection: The remote connection representing the publicize connection. - /// - /// - Returns: A `PublicizeConnection`. - /// - fileprivate func createOrReplaceFromRemotePublicizeConnection(_ remoteConnection: RemotePublicizeConnection) -> PublicizeConnection { - var pubConnection = findPublicizeConnectionByID(remoteConnection.connectionID) - if pubConnection == nil { - pubConnection = NSEntityDescription.insertNewObject(forEntityName: PublicizeConnection.classNameWithoutNamespaces(), - into: managedObjectContext) as? PublicizeConnection - } - - pubConnection?.connectionID = remoteConnection.connectionID - pubConnection?.dateExpires = remoteConnection.dateExpires - pubConnection?.dateIssued = remoteConnection.dateIssued - pubConnection?.externalDisplay = remoteConnection.externalDisplay - pubConnection?.externalFollowerCount = remoteConnection.externalFollowerCount - pubConnection?.externalID = remoteConnection.externalID - pubConnection?.externalName = remoteConnection.externalName - pubConnection?.externalProfilePicture = remoteConnection.externalProfilePicture - pubConnection?.externalProfileURL = remoteConnection.externalProfileURL - pubConnection?.keyringConnectionID = remoteConnection.keyringConnectionID - pubConnection?.keyringConnectionUserID = remoteConnection.keyringConnectionUserID - pubConnection?.label = remoteConnection.label - pubConnection?.refreshURL = remoteConnection.refreshURL - pubConnection?.service = remoteConnection.service - pubConnection?.shared = remoteConnection.shared - pubConnection?.status = remoteConnection.status - pubConnection?.siteID = remoteConnection.siteID - pubConnection?.userID = remoteConnection.userID - - return pubConnection! - } - - /// Composes a new `PublicizeConnection`, with data represented by the passed `RemotePublicizeConnection`. /// Throws an error if unable to find a `Blog` for the `blogObjectID` /// @@ -512,14 +361,16 @@ open class SharingService: LocalCoreDataService { /// /// - Returns: A `PublicizeConnection`. /// - fileprivate func createOrReplacePublicizeConnectionForBlogWithObjectID(_ blogObjectID: NSManagedObjectID, - remoteConnection: RemotePublicizeConnection) throws -> PublicizeConnection { - - let blog = try managedObjectContext.existingObject(with: blogObjectID) as! Blog - let pubConn = createOrReplaceFromRemotePublicizeConnection(remoteConnection) - pubConn.blog = blog + private func createOrReplacePublicizeConnectionForBlogWithObjectID( + _ blogObjectID: NSManagedObjectID, + remoteConnection: RemotePublicizeConnection, + in context: NSManagedObjectContext + ) throws -> PublicizeConnection { + let blog = try context.existingObject(with: blogObjectID) as! Blog + let pubConn = PublicizeConnection.createOrReplace(from: remoteConnection, in: context) + pubConn.blog = blog - return pubConn + return pubConn } @@ -532,7 +383,7 @@ open class SharingService: LocalCoreDataService { /// - success: An optional success block accepting no parameters. /// - failure: An optional failure block accepting an `NSError` parameter. /// - @objc open func syncSharingButtonsForBlog(_ blog: Blog, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { + @objc func syncSharingButtonsForBlog(_ blog: Blog, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { let blogObjectID = blog.objectID guard let remote = remoteForBlog(blog) else { return @@ -554,7 +405,7 @@ open class SharingService: LocalCoreDataService { /// - success: An optional success block accepting no parameters. /// - failure: An optional failure block accepting an `NSError` parameter. /// - @objc open func updateSharingButtonsForBlog(_ blog: Blog, sharingButtons: [SharingButton], success: (() -> Void)?, failure: ((NSError?) -> Void)?) { + @objc func updateSharingButtonsForBlog(_ blog: Blog, sharingButtons: [SharingButton], success: (() -> Void)?, failure: ((NSError?) -> Void)?) { let blogObjectID = blog.objectID guard let remote = remoteForBlog(blog) else { @@ -577,65 +428,28 @@ open class SharingService: LocalCoreDataService { /// - remoteSharingButtons: An array of `RemoteSharingButton` objects to merge. /// - onComplete: An optional callback block to be performed when core data has saved the changes. /// - fileprivate func mergeSharingButtonsForBlog(_ blogObjectID: NSManagedObjectID, remoteSharingButtons: [RemoteSharingButton], onComplete: (() -> Void)?) { - managedObjectContext.perform { - var blog: Blog - do { - blog = try self.managedObjectContext.existingObject(with: blogObjectID) as! Blog - } catch let error as NSError { - DDLogError("Error fetching Blog: \(error)") - // Because of the error we'll bail early, but we still need to call - // the success callback if one was passed. - onComplete?() - return - } + private func mergeSharingButtonsForBlog(_ blogObjectID: NSManagedObjectID, remoteSharingButtons: [RemoteSharingButton], onComplete: (() -> Void)?) { + coreDataStack.performAndSave({ context in + let blog = try context.existingObject(with: blogObjectID) as! Blog - let currentSharingbuttons = self.allSharingButtonsForBlog(blog) + let currentSharingbuttons = try SharingButton.allSharingButtons(for: blog, in: context) // Create or update based on the contents synced. let buttonsToKeep = remoteSharingButtons.map { (remoteButton) -> SharingButton in - return self.createOrReplaceFromRemoteSharingButton(remoteButton, blog: blog) + return self.createOrReplaceFromRemoteSharingButton(remoteButton, blog: blog, in: context) } // Delete any cached PublicizeServices that were not synced. for button in currentSharingbuttons { if !buttonsToKeep.contains(button) { - self.managedObjectContext.delete(button) + context.delete(button) } } - - // Save all the things. - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { - onComplete?() - }) - } + }, completion: { _ in + onComplete?() + }, on: .main) } - - /// Returns an array of all cached `SharingButtons` objects. - /// - /// - Parameters - /// - blog: A `Blog` object - /// - /// - Returns: An array of `SharingButton`s. The array is empty if no objects are cached. - /// - @objc open func allSharingButtonsForBlog(_ blog: Blog) -> [SharingButton] { - let request = NSFetchRequest<NSFetchRequestResult>(entityName: SharingButton.classNameWithoutNamespaces()) - request.predicate = NSPredicate(format: "blog = %@", blog) - request.sortDescriptors = [NSSortDescriptor(key: "order", ascending: true)] - - var buttons: [SharingButton] - do { - buttons = try managedObjectContext.fetch(request) as! [SharingButton] - } catch let error as NSError { - DDLogError("Error fetching Publicize Connections: \(error.localizedDescription)") - buttons = [] - } - - return buttons - } - - /// Composes a new `SharingButton`, or updates an existing one, with /// data represented by the passed `RemoteSharingButton`. /// @@ -645,11 +459,11 @@ open class SharingService: LocalCoreDataService { /// /// - Returns: A `SharingButton`. /// - fileprivate func createOrReplaceFromRemoteSharingButton(_ remoteButton: RemoteSharingButton, blog: Blog) -> SharingButton { - var shareButton = findSharingButtonByID(remoteButton.buttonID, blog: blog) + private func createOrReplaceFromRemoteSharingButton(_ remoteButton: RemoteSharingButton, blog: Blog, in context: NSManagedObjectContext) -> SharingButton { + var shareButton = try? SharingButton.lookupSharingButton(byID: remoteButton.buttonID, for: blog, in: context) if shareButton == nil { shareButton = NSEntityDescription.insertNewObject(forEntityName: SharingButton.classNameWithoutNamespaces(), - into: managedObjectContext) as? SharingButton + into: context) as? SharingButton } shareButton?.buttonID = remoteButton.buttonID @@ -672,7 +486,7 @@ open class SharingService: LocalCoreDataService { /// /// - Returns: An array of `RemoteSharingButton` objects. /// - fileprivate func remoteShareButtonsFromShareButtons(_ shareButtons: [SharingButton]) -> [RemoteSharingButton] { + private func remoteShareButtonsFromShareButtons(_ shareButtons: [SharingButton]) -> [RemoteSharingButton] { return shareButtons.map { (shareButton) -> RemoteSharingButton in let btn = RemoteSharingButton() btn.buttonID = shareButton.buttonID @@ -687,30 +501,6 @@ open class SharingService: LocalCoreDataService { } - /// Finds a cached `SharingButton` by its `buttonID` for the specified `Blog` - /// - /// - Parameters: - /// - buttonID: The button ID of the `sharingButton`. - /// - blog: The blog that owns the sharing button. - /// - /// - Returns: The requested `SharingButton` or nil. - /// - @objc open func findSharingButtonByID(_ buttonID: String, blog: Blog) -> SharingButton? { - let request = NSFetchRequest<NSFetchRequestResult>(entityName: SharingButton.classNameWithoutNamespaces()) - request.predicate = NSPredicate(format: "buttonID = %@ AND blog = %@", buttonID, blog) - - var buttons: [SharingButton] - do { - buttons = try managedObjectContext.fetch(request) as! [SharingButton] - } catch let error as NSError { - DDLogError("Error fetching shareing button \(buttonID) : \(error.localizedDescription)") - buttons = [] - } - - return buttons.first - } - - // MARK: Private Instance Methods @@ -718,7 +508,7 @@ open class SharingService: LocalCoreDataService { /// /// - Parameter blog: The blog to use for the rest api. /// - fileprivate func remoteForBlog(_ blog: Blog) -> SharingServiceRemote? { + private func remoteForBlog(_ blog: Blog) -> SharingServiceRemote? { guard let api = blog.wordPressComRestApi() else { return nil } diff --git a/WordPress/Classes/Services/SharingSyncService.swift b/WordPress/Classes/Services/SharingSyncService.swift new file mode 100644 index 000000000000..5d598665f040 --- /dev/null +++ b/WordPress/Classes/Services/SharingSyncService.swift @@ -0,0 +1,111 @@ +import Foundation +import CocoaLumberjack +import WordPressShared +import WordPressKit + +/// SharingService is responsible for wrangling publicize services, publicize +/// connections, and keyring connections. +/// +@objc class SharingSyncService: CoreDataService { + + /// Returns the remote to use with the service. + /// + /// - Parameter blog: The blog to use for the rest api. + /// + private func remoteForBlog(_ blog: Blog) -> SharingServiceRemote? { + guard let api = blog.wordPressComRestApi() else { + return nil + } + + return SharingServiceRemote(wordPressComRestApi: api) + } + + /// Syncs Publicize connections for the specified wpcom blog. + /// + /// - Parameters: + /// - blog: The `Blog` for which to sync publicize connections + /// - success: An optional success block accepting no parameters. + /// - failure: An optional failure block accepting an `NSError` parameter. + /// + @objc open func syncPublicizeConnectionsForBlog(_ blog: Blog, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { + let blogObjectID = blog.objectID + guard let remote = remoteForBlog(blog) else { + failure?(Error.siteWithNoRemote as NSError) + return + } + + remote.getPublicizeConnections(blog.dotComID!, success: { remoteConnections in + + // Process the results + self.mergePublicizeConnectionsForBlog(blogObjectID, remoteConnections: remoteConnections, onComplete: success) + }, + failure: failure) + } + + /// Called when syncing Publicize connections. Merges synced and cached data, removing + /// anything that does not exist on the server. Saves the context. + /// + /// - Parameters: + /// - blogObjectID: the NSManagedObjectID of a `Blog` + /// - remoteConnections: An array of `RemotePublicizeConnection` objects to merge. + /// - onComplete: An optional callback block to be performed when core data has saved the changes. + /// + private func mergePublicizeConnectionsForBlog(_ blogObjectID: NSManagedObjectID, remoteConnections: [RemotePublicizeConnection], onComplete: (() -> Void)?) { + coreDataStack.performAndSave({ context in + var blog: Blog + do { + blog = try context.existingObject(with: blogObjectID) as! Blog + } catch let error as NSError { + DDLogError("Error fetching Blog: \(error)") + // Because of the error we'll bail early, but we still need to call + // the success callback if one was passed. + return + } + + let currentPublicizeConnections = self.allPublicizeConnections(for: blog, in: context) + + // Create or update based on the contents synced. + let connectionsToKeep = remoteConnections.map { (remoteConnection) -> PublicizeConnection in + let pubConnection = PublicizeConnection.createOrReplace(from: remoteConnection, in: context) + pubConnection.blog = blog + return pubConnection + } + + // Delete any cached PublicizeServices that were not synced. + for pubConnection in currentPublicizeConnections { + if !connectionsToKeep.contains(pubConnection) { + context.delete(pubConnection) + } + } + }, completion: { onComplete?() }, on: .main) + } + + + /// Returns an array of all cached `PublicizeConnection` objects. + /// + /// - Parameters + /// - blog: A `Blog` object + /// + /// - Returns: An array of `PublicizeConnection`. The array is empty if no objects are cached. + /// + private func allPublicizeConnections(for blog: Blog, in context: NSManagedObjectContext) -> [PublicizeConnection] { + let request = NSFetchRequest<NSFetchRequestResult>(entityName: PublicizeConnection.classNameWithoutNamespaces()) + request.predicate = NSPredicate(format: "blog = %@", blog) + + var connections: [PublicizeConnection] + do { + connections = try context.fetch(request) as! [PublicizeConnection] + } catch let error as NSError { + DDLogError("Error fetching Publicize Connections: \(error.localizedDescription)") + connections = [] + } + + return connections + } + + // Error for failure states + enum Error: Swift.Error { + case siteWithNoRemote + } + +} diff --git a/WordPress/Classes/Services/SiteAddressService.swift b/WordPress/Classes/Services/SiteAddressService.swift index 59bf94beec07..af5c41ada729 100644 --- a/WordPress/Classes/Services/SiteAddressService.swift +++ b/WordPress/Classes/Services/SiteAddressService.swift @@ -2,24 +2,41 @@ import WordPressKit // MARK: - SiteAddressService -typealias SiteAddressServiceCompletion = (Result<[DomainSuggestion], Error>) -> Void +struct SiteAddressServiceResult { + let hasExactMatch: Bool + let domainSuggestions: [DomainSuggestion] + let invalidQuery: Bool + + init(hasExactMatch: Bool = false, domainSuggestions: [DomainSuggestion] = [], invalidQuery: Bool = false) { + self.hasExactMatch = hasExactMatch + self.domainSuggestions = domainSuggestions + self.invalidQuery = invalidQuery + } +} + +typealias SiteAddressServiceCompletion = (Result<SiteAddressServiceResult, Error>) -> Void protocol SiteAddressService { func addresses(for query: String, segmentID: Int64, completion: @escaping SiteAddressServiceCompletion) + func addresses(for query: String, completion: @escaping SiteAddressServiceCompletion) } // MARK: - MockSiteAddressService final class MockSiteAddressService: SiteAddressService { func addresses(for query: String, segmentID: Int64, completion: @escaping SiteAddressServiceCompletion) { - completion(.success(mockAddresses())) + completion(.success(SiteAddressServiceResult(hasExactMatch: true, domainSuggestions: mockAddresses))) } - private func mockAddresses() -> [DomainSuggestion] { + func addresses(for query: String, completion: @escaping SiteAddressServiceCompletion) { + completion(.success(SiteAddressServiceResult(hasExactMatch: true, domainSuggestions: mockAddresses))) + } + + private let mockAddresses: [DomainSuggestion] = { return [ DomainSuggestion(name: "ravenclaw.wordpress.com"), DomainSuggestion(name: "ravenclaw.com"), DomainSuggestion(name: "team.ravenclaw.com")] - } + }() } private extension DomainSuggestion { @@ -30,10 +47,15 @@ private extension DomainSuggestion { // MARK: - DomainsServiceAdapter -final class DomainsServiceAdapter: LocalCoreDataService, SiteAddressService { +final class DomainsServiceAdapter: SiteAddressService { // MARK: Properties + /// Checks if the Domain Purchasing Feature Flag and AB Experiment are enabled + private var domainPurchasingEnabled: Bool { + FeatureFlag.siteCreationDomainPurchasing.enabled && ABTest.siteCreationDomainPurchasing.isTreatmentVariation + } + /** Corresponds to: @@ -41,43 +63,112 @@ final class DomainsServiceAdapter: LocalCoreDataService, SiteAddressService { */ private static let emptyResultsErrorCode = 7 + /// Overrides the default quantity in the server request, + private let domainRequestQuantity = 20 + /// The existing service for retrieving DomainSuggestions private let domainsService: DomainsService // MARK: LocalCoreDataService - override init(managedObjectContext context: NSManagedObjectContext) { - let accountService = AccountService(managedObjectContext: context) + @objc convenience init(coreDataStack: CoreDataStack) { + let api: WordPressComRestApi = coreDataStack.performQuery({ + (try? WPAccount.lookupDefaultWordPressComAccount(in: $0))?.wordPressComRestApi + }) ?? WordPressComRestApi.defaultApi(userAgent: WPUserAgent.wordPress()) - let api: WordPressComRestApi - if let wpcomApi = accountService.defaultWordPressComAccount()?.wordPressComRestApi { - api = wpcomApi - } else { - api = WordPressComRestApi.defaultApi(userAgent: WPUserAgent.wordPress()) - } - let remoteService = DomainsServiceRemote(wordPressComRestApi: api) + self.init(coreDataStack: coreDataStack, api: api) + } - self.domainsService = DomainsService(managedObjectContext: context, remote: remoteService) + // Used to help with testing + init(coreDataStack: CoreDataStack, api: WordPressComRestApi) { + let remoteService = DomainsServiceRemote(wordPressComRestApi: api) + self.domainsService = DomainsService(coreDataStack: coreDataStack, remote: remoteService) + } - super.init(managedObjectContext: context) + @objc func refreshDomains(siteID: Int, completion: @escaping (Bool) -> Void) { + domainsService.refreshDomains(siteID: siteID) { result in + switch result { + case .success: + completion(true) + case .failure: + completion(false) + } + } } // MARK: SiteAddressService func addresses(for query: String, segmentID: Int64, completion: @escaping SiteAddressServiceCompletion) { - domainsService.getDomainSuggestions(base: query, + domainsService.getDomainSuggestions(query: query, segmentID: segmentID, + quantity: domainRequestQuantity, success: { domainSuggestions in - completion(Result.success(domainSuggestions)) - }, + completion(Result.success(self.sortSuggestions(for: query, suggestions: domainSuggestions))) + }, failure: { error in if (error as NSError).code == DomainsServiceAdapter.emptyResultsErrorCode { - completion(Result.success([])) + completion(Result.success(SiteAddressServiceResult())) return } completion(Result.failure(error)) + }) + } + + func addresses(for query: String, completion: @escaping SiteAddressServiceCompletion) { + let domainSuggestionType: DomainsServiceRemote.DomainSuggestionType = domainPurchasingEnabled + ? .freeAndPaid + : .wordPressDotComAndDotBlogSubdomains + domainsService.getDomainSuggestions(query: query, + quantity: domainRequestQuantity, + domainSuggestionType: domainSuggestionType, + success: { domainSuggestions in + if self.domainPurchasingEnabled { + let hasExactMatch = domainSuggestions.contains { domain -> Bool in + return domain.domainNameStrippingSubdomain.caseInsensitiveCompare(query) == .orderedSame + } + completion(Result.success(.init(hasExactMatch: hasExactMatch, domainSuggestions: domainSuggestions))) + } else { + completion(Result.success(self.sortSuggestions(for: query, suggestions: domainSuggestions))) + } + }, + failure: { error in + if (error as NSError).code == DomainsServiceAdapter.emptyResultsErrorCode { + completion(Result.success(SiteAddressServiceResult())) + return + } + if (error as NSError).code == WordPressComRestApiError.invalidQuery.rawValue { + completion(Result.success(SiteAddressServiceResult(invalidQuery: true))) + return + } + + completion(Result.failure(error)) }) } + + private func sortSuggestions(for query: String, suggestions: [DomainSuggestion]) -> SiteAddressServiceResult { + var hasExactMatch = false + let sortedSuggestions = suggestions.sorted { (lhs, rhs) -> Bool in + if lhs.domainNameStrippingSubdomain.caseInsensitiveCompare(query) == .orderedSame + && rhs.domainNameStrippingSubdomain.caseInsensitiveCompare(query) == .orderedSame { + // If each are an exact match sort alphabetically on the full domain and mark that we found a match + hasExactMatch = true + return lhs.domainName.caseInsensitiveCompare(rhs.domainName) == .orderedAscending + } else if lhs.domainNameStrippingSubdomain.caseInsensitiveCompare(query) == .orderedSame { + // If lhs side is a match (and rhs isn't given the previous cases) then we are sorted. + hasExactMatch = true + return true + } else if rhs.domainNameStrippingSubdomain.caseInsensitiveCompare(query) == .orderedSame { + // If rhs side is a match (and lhs isn't given the previous cases) then we are not sorted. + hasExactMatch = true + return false + } else { + // If neither rhs nor lhs ara a match then sort alphabetically + return lhs.domainName.caseInsensitiveCompare(rhs.domainName) == .orderedAscending + } + } + + return SiteAddressServiceResult(hasExactMatch: hasExactMatch, domainSuggestions: sortedSuggestions) + } } diff --git a/WordPress/Classes/Services/SiteAssemblyService.swift b/WordPress/Classes/Services/SiteAssemblyService.swift index 3a2e679f502b..d095846a5724 100644 --- a/WordPress/Classes/Services/SiteAssemblyService.swift +++ b/WordPress/Classes/Services/SiteAssemblyService.swift @@ -1,11 +1,17 @@ import Foundation +/// Site Creation Notification +/// +extension NSNotification.Name { + static let WPSiteCreated = NSNotification.Name(rawValue: "SiteCreated") +} + // MARK: - EnhancedSiteCreationService /// Working implementation of a `SiteAssemblyService`. /// -final class EnhancedSiteCreationService: LocalCoreDataService, SiteAssemblyService { +final class EnhancedSiteCreationService: SiteAssemblyService { // MARK: Properties @@ -27,21 +33,17 @@ final class EnhancedSiteCreationService: LocalCoreDataService, SiteAssemblyServi /// The most recently created blog corresponding to the site creation request; `nil` otherwise. private(set) var createdBlog: Blog? - // MARK: LocalCoreDataService + private var coreDataStack: CoreDataStackSwift - override init(managedObjectContext context: NSManagedObjectContext) { - self.accountService = AccountService(managedObjectContext: context) - self.blogService = BlogService(managedObjectContext: context) + init(coreDataStack: CoreDataStackSwift) { + self.coreDataStack = coreDataStack + self.accountService = AccountService(coreDataStack: coreDataStack) + self.blogService = BlogService(coreDataStack: coreDataStack) - let api: WordPressComRestApi - if let wpcomApi = accountService.defaultWordPressComAccount()?.wordPressComRestApi { - api = wpcomApi - } else { - api = WordPressComRestApi.defaultApi(userAgent: WPUserAgent.wordPress()) - } + let api: WordPressComRestApi = coreDataStack.performQuery { context in + (try? WPAccount.lookupDefaultWordPressComAccount(in: context))?.wordPressComRestApi + } ?? WordPressComRestApi.defaultApi(userAgent: WPUserAgent.wordPress()) self.remoteService = WordPressComServiceRemote(wordPressComRestApi: api) - - super.init(managedObjectContext: context) } // MARK: SiteAssemblyService @@ -90,6 +92,7 @@ final class EnhancedSiteCreationService: LocalCoreDataService, SiteAssemblyServi // Here we designate the new site as the last used, so that it will be presented post-creation if let siteUrl = createdBlog?.url { RecentSitesService().touch(site: siteUrl) + NotificationCenter.default.post(name: .WPSiteCreated, object: nil) } currentStatus = .succeeded @@ -115,8 +118,15 @@ final class EnhancedSiteCreationService: LocalCoreDataService, SiteAssemblyServi return } - - self.synchronize(createdSite: response.createdSite) + self.coreDataStack.performAndSave({ context in + self.createSite(for: response.createdSite, in: context) + }, completion: { [weak self] blogID in + guard let blogID else { + self?.endFailedAssembly() + return + } + self?.syncBlogAndAccount(createdBlogID: blogID) + }, on: .main) case .failure(let creationError): DDLogError("\(creationError)") self.endFailedAssembly() @@ -124,19 +134,18 @@ final class EnhancedSiteCreationService: LocalCoreDataService, SiteAssemblyServi } } - private func synchronize(createdSite: CreatedSite) { - guard let defaultAccount = accountService.defaultWordPressComAccount() else { - endFailedAssembly() - return + private func createSite(for createdSite: CreatedSite, in context: NSManagedObjectContext) -> NSManagedObjectID? { + guard let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: context) else { + return nil } let xmlRpcUrlString = createdSite.xmlrpcString let blog: Blog - if let existingBlog = blogService.findBlog(withXmlrpc: xmlRpcUrlString, in: defaultAccount) { + if let existingBlog = Blog.lookup(xmlrpc: xmlRpcUrlString, andRemoveDuplicateBlogsOf: defaultAccount, in: context) { blog = existingBlog } else { - blog = blogService.createBlog(with: defaultAccount) + blog = Blog.createBlankBlog(with: defaultAccount) blog.xmlrpc = xmlRpcUrlString } @@ -153,19 +162,35 @@ final class EnhancedSiteCreationService: LocalCoreDataService, SiteAssemblyServi defaultAccount.defaultBlog = blog - ContextManager.sharedInstance().save(managedObjectContext) { [weak self] in - guard let self = self else { + try? context.obtainPermanentIDs(for: [blog]) + + return blog.objectID + } + + private func syncBlogAndAccount(createdBlogID blogID: NSManagedObjectID) { + assert(Thread.isMainThread, "\(#function) must be called from the main thread") + + guard let blog = try? self.coreDataStack.mainContext.existingObject(with: blogID) as? Blog else { + endFailedAssembly() + return + } + + blogService.syncBlogAndAllMetadata(blog, completionHandler: { + assert(Thread.isMainThread, "must be called from the main thread") + guard let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: self.coreDataStack.mainContext) else { + self.endFailedAssembly() return } - self.blogService.syncBlogAndAllMetadata(blog, completionHandler: { - self.accountService.updateUserDetails(for: defaultAccount, - success: { - self.createdBlog = blog - self.endSuccessfulAssembly() + + self.accountService.updateUserDetails( + for: defaultAccount, + success: { + self.createdBlog = blog + self.endSuccessfulAssembly() }, - failure: { error in self.endFailedAssembly() }) - }) - } + failure: { error in self.endFailedAssembly() } + ) + }) } private func validatePendingRequest() { diff --git a/WordPress/Classes/Services/SiteManagementService.swift b/WordPress/Classes/Services/SiteManagementService.swift index a70fd04edb41..fd2d29406f8e 100644 --- a/WordPress/Classes/Services/SiteManagementService.swift +++ b/WordPress/Classes/Services/SiteManagementService.swift @@ -11,9 +11,22 @@ public extension Blog { } } +/// Site Deletion Notification +/// +extension NSNotification.Name { + static let WPSiteDeleted = NSNotification.Name(rawValue: "SiteDeleted") +} + /// SiteManagementService handles operations for managing a WordPress.com site. /// -open class SiteManagementService: LocalCoreDataService { +open class SiteManagementService: NSObject { + + private let coreDataStack: CoreDataStack + + init(coreDataStack: CoreDataStack) { + self.coreDataStack = coreDataStack + } + /// Deletes the specified WordPress.com site. /// /// - Parameters: @@ -27,13 +40,11 @@ open class SiteManagementService: LocalCoreDataService { } remote.deleteSite(blog.dotComID!, success: { - self.managedObjectContext.perform { - let blogService = BlogService(managedObjectContext: self.managedObjectContext) - blogService.remove(blog) - - ContextManager.sharedInstance().save(self.managedObjectContext, withCompletionBlock: { - success?() - }) + let blogService = BlogService(coreDataStack: self.coreDataStack) + blogService.remove(blog) + DispatchQueue.main.async { + NotificationCenter.default.post(name: .WPSiteDeleted, object: nil) + success?() } }, failure: { error in diff --git a/WordPress/Classes/Services/SiteSegmentsService.swift b/WordPress/Classes/Services/SiteSegmentsService.swift index 0376dd3775fd..8d9acd126a1a 100644 --- a/WordPress/Classes/Services/SiteSegmentsService.swift +++ b/WordPress/Classes/Services/SiteSegmentsService.swift @@ -11,31 +11,19 @@ protocol SiteSegmentsService { } // MARK: - SiteSegmentsService -final class SiteCreationSegmentsService: LocalCoreDataService, SiteSegmentsService { +final class SiteCreationSegmentsService: SiteSegmentsService { // MARK: Properties - /// A service for interacting with WordPress accounts. - private let accountService: AccountService - /// A facade for WPCOM services. private let remoteService: WordPressComServiceRemote - // MARK: LocalCoreDataService - - override init(managedObjectContext context: NSManagedObjectContext) { - self.accountService = AccountService(managedObjectContext: context) - - let api: WordPressComRestApi - if let account = accountService.defaultWordPressComAccount() { - api = account.wordPressComRestV2Api - } else { - api = WordPressComRestApi.anonymousApi(userAgent: WPUserAgent.wordPress(), localeKey: WordPressComRestApi.LocaleKeyV2) - } + init(coreDataStack: CoreDataStack) { + let api = coreDataStack.performQuery({ context in + try? WPAccount.lookupDefaultWordPressComAccount(in: context)?.wordPressComRestV2Api + }) ?? WordPressComRestApi.anonymousApi(userAgent: WPUserAgent.wordPress(), localeKey: WordPressComRestApi.LocaleKeyV2) self.remoteService = WordPressComServiceRemote(wordPressComRestApi: api) - - super.init(managedObjectContext: context) } // MARK: SiteSegmentsService diff --git a/WordPress/Classes/Services/SiteSuggestionService.swift b/WordPress/Classes/Services/SiteSuggestionService.swift new file mode 100644 index 000000000000..a148d8ee3703 --- /dev/null +++ b/WordPress/Classes/Services/SiteSuggestionService.swift @@ -0,0 +1,146 @@ +import Foundation + +/// A service to fetch and persist a list of sites that can be xpost to from a post. +class SiteSuggestionService { + + private var blogsCurrentlyBeingRequested = [NSNumber]() + private var requests = [NSNumber: Date]() + private var disabledBlogs = [NSNumber]() + + static let shared = SiteSuggestionService() + + /** + Fetch cached suggestions if available, otherwise from the network if the device is online. + + @param the blog/site to retrieve suggestions for + @param completion callback containing list of suggestions, or nil if unavailable + */ + func suggestions(for blog: Blog, completion: @escaping ([SiteSuggestion]?) -> Void) { + + let throttleDuration: TimeInterval = 60 // seconds + let isBelowThrottleThreshold: Bool + if let id = blog.dotComID, let requestDate = requests[id] { + isBelowThrottleThreshold = Date().timeIntervalSince(requestDate) < throttleDuration + } else { + isBelowThrottleThreshold = false + } + + + if isBelowThrottleThreshold, let suggestions = retrievePersistedSuggestions(for: blog), suggestions.isEmpty == false { + completion(suggestions) + } else if ReachabilityUtils.isInternetReachable() { + fetchAndPersistSuggestions(for: blog, completion: completion) + } else { + completion(nil) + } + } + + /** + If no suggestions already in Core Data, fetch them from the network and store them in Core Data. + Aborts and calls callback if the site does not support suggestions. + @param blog The blog/site to prefetch suggestions for + */ + func prefetchSuggestionsIfNeeded(for blog: Blog, completion: @escaping () -> Void) { + guard shouldShowSuggestions(for: blog) else { + completion() + return + } + let persistedSuggestions = retrievePersistedSuggestions(for: blog) + if persistedSuggestions == nil || persistedSuggestions?.isEmpty == true { + fetchAndPersistSuggestions(for: blog, completion: { _ in + completion() + }) + } + } + + /** + Performs a REST API request for the given blog. + Persists response objects to Core Data. + + @param blog/site to retrieve suggestions for + */ + private func fetchAndPersistSuggestions(for blog: Blog, completion: @escaping ([SiteSuggestion]?) -> Void) { + + guard let blogId = blog.dotComID, let hostname = blog.hostname else { return } + + // if there is already a request in place for this blog, just wait + guard !blogsCurrentlyBeingRequested.contains(blogId) else { return } + + let suggestPath = "/wpcom/v2/sites/\(hostname)/xposts" + let params = ["decode_html": true] as [String: AnyObject] + + // add this blog to currently being requested list + blogsCurrentlyBeingRequested.append(blogId) + + defaultAccount()?.wordPressComRestApi.GET(suggestPath, parameters: params, success: { [weak self] responseObject, httpResponse in + guard let `self` = self else { return } + + let context = ContextManager.shared.mainContext + guard let data = try? JSONSerialization.data(withJSONObject: responseObject) else { return } + let decoder = JSONDecoder() + decoder.userInfo[CodingUserInfoKey.managedObjectContext] = context + guard let suggestions = try? decoder.decode([SiteSuggestion].self, from: data) else { return } + + // Delete any existing `SiteSuggestion` objects + self.retrievePersistedSuggestions(for: blog)?.forEach { suggestion in + context.delete(suggestion) + } + + // Associate `SiteSuggestion` objects with blog + blog.siteSuggestions = Set(suggestions) + + // Save the changes + try? ContextManager.shared.mainContext.save() + + self.requests[blogId] = Date() + + self.disabledBlogs.removeAll { $0 == blogId } + completion(suggestions) + + // remove blog from the currently being requested list + self.blogsCurrentlyBeingRequested.removeAll { $0 == blogId } + }, failure: { [weak self] error, response in + guard let `self` = self else { return } + + if response?.statusCode == 400 { // blog/site does not have Xposts available + self.disabledBlogs.append(blogId) + } + completion([]) + + // remove blog from the currently being requested list + self.blogsCurrentlyBeingRequested.removeAll { $0 == blogId} + + DDLogVerbose("[Rest API] ! \(error.localizedDescription)") + }) + } + + /** + Tells the caller if it is a good idea to show suggestions right now for a given blog/site. + + @param blog blog/site to check for + @return BOOL Whether the caller should show suggestions + */ + func shouldShowSuggestions(for blog: Blog) -> Bool { + + // Any blog/site that has previously returned an error indicating Xposts are not available is marked as disabled. + guard let blogId = blog.dotComID, disabledBlogs.contains(blogId) == false else { + return false + } + + // The device must be online or there must be already persisted suggestions + guard ReachabilityUtils.isInternetReachable() || retrievePersistedSuggestions(for: blog)?.isEmpty == false else { + return false + } + + return blog.supports(.xposts) + } + + private func defaultAccount() -> WPAccount? { + try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) + } + + func retrievePersistedSuggestions(for blog: Blog) -> [SiteSuggestion]? { + guard let suggestions = blog.siteSuggestions else { return nil } + return Array(suggestions) + } +} diff --git a/WordPress/Classes/Services/SiteVerticalsPromptService.swift b/WordPress/Classes/Services/SiteVerticalsPromptService.swift deleted file mode 100644 index b2ec27e41ab3..000000000000 --- a/WordPress/Classes/Services/SiteVerticalsPromptService.swift +++ /dev/null @@ -1,61 +0,0 @@ - -import Foundation - -// MARK: - SiteVerticalsPromptService - -/// Abstracts retrieval of prompt values for Site Creation : Verticals search & selection. -/// -protocol SiteVerticalsPromptService { - func retrieveVerticalsPrompt(request: SiteVerticalsPromptRequest, completion: @escaping SiteVerticalsPromptServiceCompletion) -} - -// MARK: - MockSiteVerticalsPromptService - -/// Mock implementation of the prompt service -/// -final class MockSiteVerticalsPromptService: SiteVerticalsPromptService { - func retrieveVerticalsPrompt(request: SiteVerticalsPromptRequest, completion: @escaping - SiteVerticalsPromptServiceCompletion) { - - let mockPrompt = SiteVerticalsPrompt(title: "Faux title", subtitle: "Faux subtitle", hint: "Faux placeholder") - completion(mockPrompt) - } -} - -// MARK: - SiteCreationVerticalsPromptService - -/// Retrieves localized user-facing prompts for searching Verticals during Site Creation. -/// -final class SiteCreationVerticalsPromptService: LocalCoreDataService, SiteVerticalsPromptService { - - // MARK: Properties - - /// A service for interacting with WordPress accounts. - private let accountService: AccountService - - /// A facade for WPCOM services. - private let remoteService: WordPressComServiceRemote - - // MARK: LocalCoreDataService - - override init(managedObjectContext context: NSManagedObjectContext) { - self.accountService = AccountService(managedObjectContext: context) - - let api: WordPressComRestApi - if let account = accountService.defaultWordPressComAccount() { - api = account.wordPressComRestV2Api - } else { - api = WordPressComRestApi.anonymousApi(userAgent: WPUserAgent.wordPress(), localeKey: WordPressComRestApi.LocaleKeyV2) - } - - self.remoteService = WordPressComServiceRemote(wordPressComRestApi: api) - - super.init(managedObjectContext: context) - } - - // MARK: SiteVerticalsPromptService - - func retrieveVerticalsPrompt(request: SiteVerticalsPromptRequest, completion: @escaping SiteVerticalsPromptServiceCompletion) { - remoteService.retrieveVerticalsPrompt(request: request, completion: completion) - } -} diff --git a/WordPress/Classes/Services/SiteVerticalsService.swift b/WordPress/Classes/Services/SiteVerticalsService.swift index 9ebe5bf796b9..23cbbc9f7ab2 100644 --- a/WordPress/Classes/Services/SiteVerticalsService.swift +++ b/WordPress/Classes/Services/SiteVerticalsService.swift @@ -47,30 +47,18 @@ final class MockSiteVerticalsService: SiteVerticalsService { /// Retrieves candidate Site Verticals used to create a new site. /// -final class SiteCreationVerticalsService: LocalCoreDataService, SiteVerticalsService { +final class SiteCreationVerticalsService: SiteVerticalsService { // MARK: Properties - /// A service for interacting with WordPress accounts. - private let accountService: AccountService - /// A facade for WPCOM services. private let remoteService: WordPressComServiceRemote - // MARK: LocalCoreDataService - - override init(managedObjectContext context: NSManagedObjectContext) { - self.accountService = AccountService(managedObjectContext: context) - - let api: WordPressComRestApi - if let account = accountService.defaultWordPressComAccount() { - api = account.wordPressComRestV2Api - } else { - api = WordPressComRestApi.anonymousApi(userAgent: WPUserAgent.wordPress(), localeKey: WordPressComRestApi.LocaleKeyV2) - } + init(coreDataStack: CoreDataStack) { + let api = coreDataStack.performQuery({ context in + try? WPAccount.lookupDefaultWordPressComAccount(in: context)?.wordPressComRestV2Api + }) ?? WordPressComRestApi.anonymousApi(userAgent: WPUserAgent.wordPress(), localeKey: WordPressComRestApi.LocaleKeyV2) self.remoteService = WordPressComServiceRemote(wordPressComRestApi: api) - - super.init(managedObjectContext: context) } // MARK: SiteVerticalsService @@ -82,7 +70,7 @@ final class SiteCreationVerticalsService: LocalCoreDataService, SiteVerticalsSer switch result { case .success(let verticals): guard let vertical = verticals.first else { - CrashLogging.logMessage("The verticals service should always return at least 1 match for the precise term queried.", level: .error) + WordPressAppDelegate.crashLogging?.logMessage("The verticals service should always return at least 1 match for the precise term queried.", level: .error) completion(.failure(.serviceFailure)) return } diff --git a/WordPress/Classes/Services/Stories/CameraHandler.swift b/WordPress/Classes/Services/Stories/CameraHandler.swift new file mode 100644 index 000000000000..7e6567440b33 --- /dev/null +++ b/WordPress/Classes/Services/Stories/CameraHandler.swift @@ -0,0 +1,121 @@ +import Kanvas + +/// Handles basic `CameraControllerDelegate` methods and calls `createdMedia` on export. +class CameraHandler: CameraControllerDelegate { + + let createdMedia: (CameraController.MediaOutput) -> Void + + init(created: @escaping (CameraController.MediaOutput) -> Void) { + createdMedia = created + } + + func getQuickPostButton() -> UIView { + return UIView() + } + + func getBlogSwitcher() -> UIView { + return UIView() + } + + func didCreateMedia(_ cameraController: CameraController, media: CameraController.MediaOutput, exportAction: KanvasExportAction) { + createdMedia(media) + } + + private func showDiscardAlert(on: UIViewController, discard: @escaping () -> Void) { + let title = NSLocalizedString("You have unsaved changes.", comment: "Title of message with options that shown when there are unsaved changes and the author is trying to move away from the post.") + let cancelTitle = NSLocalizedString("Keep Editing", comment: "Button shown if there are unsaved changes and the author is trying to move away from the post.") + let discardTitle = NSLocalizedString("Discard", comment: "Button shown if there are unsaved changes and the author is trying to move away from the post.") + + let alertController = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) + alertController.view.accessibilityIdentifier = "post-has-changes-alert" + + // Button: Keep editing + alertController.addCancelActionWithTitle(cancelTitle) + + alertController.addDestructiveActionWithTitle(discardTitle) { _ in + discard() + } + + on.present(alertController, animated: true, completion: nil) + } + + private func endEditing(editor: StoryEditor, onDismiss: @escaping () -> Void) { + showDiscardAlert(on: editor.topmostPresentedViewController) { + if editor.presentingViewController is AztecNavigationController == false { + editor.cancelEditing() + editor.post.managedObjectContext?.delete(editor.post) + } + onDismiss() + } + } + + func dismissButtonPressed(_ cameraController: CameraController) { + if let editor = cameraController as? StoryEditor { + endEditing(editor: editor) { + cameraController.dismiss(animated: true, completion: nil) + } + } else { + cameraController.dismiss(animated: true, completion: nil) + } + } + + func tagButtonPressed() { + + } + + func editorDismissed(_ cameraController: CameraController) { + if let editor = cameraController as? StoryEditor { + endEditing(editor: editor) { + cameraController.dismiss(animated: true, completion: { + cameraController.dismiss(animated: false) + }) + } + } + } + + func didDismissWelcomeTooltip() { + + } + + func cameraShouldShowWelcomeTooltip() -> Bool { + return false + } + + func didDismissColorSelectorTooltip() { + + } + + func editorShouldShowColorSelectorTooltip() -> Bool { + return true + } + + func didEndStrokeSelectorAnimation() { + + } + + func editorShouldShowStrokeSelectorAnimation() -> Bool { + return true + } + + func provideMediaPickerThumbnail(targetSize: CGSize, completion: @escaping (UIImage?) -> Void) { + PHPhotoLibrary.requestAuthorization { status in + completion(nil) + } + } + + func didBeginDragInteraction() { + + } + + func didEndDragInteraction() { + + } + + func openAppSettings(completion: ((Bool) -> ())?) { + if let targetURL = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(targetURL) + } else { + assertionFailure("Couldn't unwrap Settings URL") + } + } +} diff --git a/WordPress/Classes/Services/Stories/StoryEditor.swift b/WordPress/Classes/Services/Stories/StoryEditor.swift new file mode 100644 index 000000000000..fbc73a01cc0e --- /dev/null +++ b/WordPress/Classes/Services/Stories/StoryEditor.swift @@ -0,0 +1,274 @@ +import Foundation +import Kanvas + +/// An story editor which displays the Kanvas camera + editing screens. +class StoryEditor: CameraController { + + var post: AbstractPost = AbstractPost() + + private static let directoryName = "Stories" + + /// A directory to temporarily hold imported media. + /// - Throws: Any errors resulting from URL or directory creation. + /// - Returns: A URL with the media cache directory. + static func mediaCacheDirectory() throws -> URL { + let storiesURL = try MediaFileManager.cache.directoryURL().appendingPathComponent(directoryName, isDirectory: true) + try FileManager.default.createDirectory(at: storiesURL, withIntermediateDirectories: true, attributes: nil) + return storiesURL + } + + /// A directory to temporarily hold saved archives. + /// - Throws: Any errors resulting from URL or directory creation. + /// - Returns: A URL with the save directory. + static func saveDirectory() throws -> URL { + let saveDirectory = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent(directoryName, isDirectory: true) + try FileManager.default.createDirectory(at: saveDirectory, withIntermediateDirectories: true, attributes: nil) + return saveDirectory + } + + var onClose: ((Bool, Bool) -> Void)? = nil + + var editorSession: PostEditorAnalyticsSession + + let navigationBarManager: PostEditorNavigationBarManager? = nil + + fileprivate(set) lazy var debouncer: Debouncer = { + return Debouncer(delay: PostEditorDebouncerConstants.autoSavingDelay, callback: debouncerCallback) + }() + + private(set) lazy var postEditorStateContext: PostEditorStateContext = { + return PostEditorStateContext(post: post, delegate: self) + }() + + var verificationPromptHelper: VerificationPromptHelper? = nil + + var analyticsEditorSource: String { + return "stories" + } + + private var cameraHandler: CameraHandler? + private var poster: StoryPoster? + private lazy var storyLoader: StoryMediaLoader = { + return StoryMediaLoader() + }() + + private static let useMetal = false + + static var cameraSettings: CameraSettings { + let settings = CameraSettings() + settings.features.ghostFrame = true + settings.features.metalPreview = useMetal + settings.features.metalFilters = useMetal + settings.features.openGLPreview = !useMetal + settings.features.openGLCapture = !useMetal + settings.features.cameraFilters = false + settings.features.experimentalCameraFilters = true + settings.features.editor = true + settings.features.editorGIFMaker = false + settings.features.editorFilters = false + settings.features.editorText = true + settings.features.editorMedia = true + settings.features.editorDrawing = false + settings.features.editorMedia = false + settings.features.mediaPicking = true + settings.features.editorPostOptions = false + settings.features.newCameraModes = true + settings.features.gifs = false + settings.features.multipleExports = true + settings.features.editorConfirmAtTop = true + settings.features.muteButton = true + settings.crossIconInEditor = true + settings.enabledModes = [.normal] + settings.defaultMode = .normal + settings.features.scaleMediaToFill = true + settings.features.resizesFonts = false + settings.animateEditorControls = false + settings.exportStopMotionPhotoAsVideo = false + settings.fontSelectorUsesFont = true + settings.aspectRatio = 9/16 + + return settings + } + + enum EditorCreationError: Error { + case unsupportedDevice + } + + typealias UpdateResult = Result<String, PostCoordinator.SavingError> + typealias UploadResult = Result<Void, PostCoordinator.SavingError> + + static func editor(blog: Blog, + context: NSManagedObjectContext, + updated: @escaping (UpdateResult) -> Void) throws -> StoryEditor { + let post = blog.createDraftPost() + return try editor(post: post, mediaFiles: nil, publishOnCompletion: true, updated: updated) + } + + static func editor(post: AbstractPost, + mediaFiles: [MediaFile]?, + publishOnCompletion: Bool = false, + updated: @escaping (UpdateResult) -> Void) throws -> StoryEditor { + + guard !UIDevice.isPad() else { + throw EditorCreationError.unsupportedDevice + } + + let controller = StoryEditor(post: post, + onClose: nil, + settings: cameraSettings, + stickerProvider: nil, + analyticsProvider: nil, + quickBlogSelectorCoordinator: nil, + tagCollection: nil, + mediaFiles: mediaFiles, + publishOnCompletion: publishOnCompletion, + updated: updated) + controller.modalPresentationStyle = .fullScreen + controller.modalTransitionStyle = .crossDissolve + return controller + } + + init(post: AbstractPost, + onClose: ((Bool, Bool) -> Void)?, + settings: CameraSettings, + stickerProvider: StickerProvider?, + analyticsProvider: KanvasAnalyticsProvider?, + quickBlogSelectorCoordinator: KanvasQuickBlogSelectorCoordinating?, + tagCollection: UIView?, + mediaFiles: [MediaFile]?, + publishOnCompletion: Bool, + updated: @escaping (UpdateResult) -> Void) { + self.post = post + self.onClose = onClose + self.editorSession = PostEditorAnalyticsSession(editor: .stories, post: post) + + Kanvas.KanvasColors.shared = KanvasCustomUI.shared.cameraColors() + Kanvas.KanvasFonts.shared = KanvasCustomUI.shared.cameraFonts() + Kanvas.KanvasImages.shared = KanvasCustomUI.shared.cameraImages() + Kanvas.KanvasStrings.shared = KanvasStrings( + cameraPermissionsTitleLabel: NSLocalizedString("Post to WordPress", comment: "Title of camera permissions screen"), + cameraPermissionsDescriptionLabel: NSLocalizedString("Allow access so you can start taking photos and videos.", comment: "Message on camera permissions screen to explain why the app needs camera and microphone permissions") + ) + + let saveDirectory: URL? + do { + saveDirectory = try Self.saveDirectory() + } catch let error { + assertionFailure("Should be able to create a save directory in Documents \(error)") + saveDirectory = nil + } + + super.init(settings: settings, + mediaPicker: WPMediaPickerForKanvas.self, + stickerProvider: nil, + analyticsProvider: KanvasAnalyticsHandler(), + quickBlogSelectorCoordinator: nil, + tagCollection: nil, + saveDirectory: saveDirectory) + + cameraHandler = CameraHandler(created: { [weak self] media in + self?.poster = StoryPoster(context: post.blog.managedObjectContext ?? ContextManager.shared.mainContext, mediaFiles: mediaFiles) + let postMedia: [StoryPoster.MediaItem] = media.compactMap { result in + switch result { + case .success(let item): + guard let item = item else { return nil } + return StoryPoster.MediaItem(url: item.output, size: item.size, archive: item.archive, original: item.unmodified) + case .failure: + return nil + } + } + + guard let self = self else { return } + + let uploads: (String, [Media])? = try? self.poster?.add(mediaItems: postMedia, post: post) + + let content = uploads?.0 ?? "" + + updated(.success(content)) + + if publishOnCompletion { + // Replace the contents if we are publishing a new post + post.content = content + + do { + try post.managedObjectContext?.save() + } catch let error { + assertionFailure("Failed to save post during story update: \(error)") + } + + self.publishPost(action: .publish, dismissWhenDone: true, analyticsStat: + .editorPublishedPost) + } else { + self.dismiss(animated: true, completion: nil) + } + }) + self.delegate = cameraHandler + } + + func present(on: UIViewController, with files: [MediaFile]) { + storyLoader.download(files: files, for: post) { [weak self] output in + guard let self = self else { return } + DispatchQueue.main.async { + self.show(media: output) + on.present(self, animated: true, completion: {}) + } + } + } + + func trackOpen() { + editorSession.start() + } +} + +extension StoryEditor: PublishingEditor { + var prepublishingIdentifiers: [PrepublishingIdentifier] { + return [.title, .visibility, .schedule, .tags, .categories] + } + + var prepublishingSourceView: UIView? { + return nil + } + + var alertBarButtonItem: UIBarButtonItem? { + return nil + } + + var isUploadingMedia: Bool { + return false + } + + var postTitle: String { + get { + return post.postTitle ?? "" + } + set { + post.postTitle = newValue + } + } + + func getHTML() -> String { + return post.content ?? "" + } + + func cancelUploadOfAllMedia(for post: AbstractPost) { + + } + + func publishingDismissed() { + hideLoading() + } + + var wordCount: UInt { + return post.content?.wordCount() ?? 0 + } +} + +extension StoryEditor: PostEditorStateContextDelegate { + func context(_ context: PostEditorStateContext, didChangeAction: PostEditorAction) { + + } + + func context(_ context: PostEditorStateContext, didChangeActionAllowed: Bool) { + + } +} diff --git a/WordPress/Classes/Services/Stories/StoryMediaLoader.swift b/WordPress/Classes/Services/Stories/StoryMediaLoader.swift new file mode 100644 index 000000000000..fed8ab4fb7f8 --- /dev/null +++ b/WordPress/Classes/Services/Stories/StoryMediaLoader.swift @@ -0,0 +1,103 @@ +import Kanvas + +class StoryMediaLoader { + + typealias Output = (CameraSegment, Data?) + + var completion: (([Output]) -> Void)? + + var downloadTasks: [ImageDownloaderTask] = [] + var results: [Output?] = [] { + didSet { + if results.contains(where: { $0 == nil }) == false { + completion?(results.compactMap { $0 }) + results = [] + } + } + } + + private let mediaUtility = EditorMediaUtility() + private let queue = DispatchQueue.global(qos: .userInitiated) + + func download(files: [MediaFile], for post: AbstractPost, completion: @escaping ([Output]) -> Void) { + + self.completion = completion + results = [Output?](repeating: nil, count: files.count) + downloadTasks = [] + + let service = MediaService(managedObjectContext: ContextManager.shared.mainContext) + files.enumerated().forEach { (idx, file) in + + do { + let archive = try unarchive(file: file) + + if let archive = archive { + + let validFile: Bool + let segment = archive.0 + + switch segment { + case .image: + validFile = true + case .video(let url, _): + validFile = FileManager.default.fileExists(atPath: url.path) + } + + if validFile { + results[idx] = archive + return + } + } + } catch let error { + DDLogError("Error unarchiving \(file.url) - \(error)") + } + + service.getMediaWithID(NSNumber(value: Double(file.id) ?? 0), in: post.blog, success: { [weak self] media in + guard let self = self else { return } + let mediaType = media.mediaType + switch mediaType { + case .image: + let size = media.pixelSize() + if let url = URL(string: file.url) { + let task = self.mediaUtility.downloadImage(from: url, size: size, scale: 1, post: post, success: { [weak self] image in + self?.queue.async { + self?.results[idx] = (CameraSegment.image(image, nil, nil, Kanvas.MediaInfo(source: .kanvas_camera)), nil) + } + }, onFailure: { error in + DDLogWarn("Failed Stories image download: \(error)") + }) + self.downloadTasks.append(task) + } + case .video: + EditorMediaUtility.fetchRemoteVideoURL(for: media, in: post, withToken: true) { [weak self] result in + switch result { + case .success((let videoURL)): + self?.queue.async { + self?.results[idx] = (CameraSegment.video(videoURL, nil), nil) + } + case .failure(let error): + DDLogWarn("Failed stories video download: \(error)") + } + } + default: + DDLogWarn("Unexpected Stories media type: \(mediaType)") + } + + }) { (error) in + DDLogWarn("Stories media fetch error \(error)") + } + } + } + + func unarchive(file: MediaFile) throws -> (CameraSegment, Data?)? { + if let archiveURL = StoryPoster.filePath?.appendingPathComponent(file.id) { + return try CameraController.unarchive(archiveURL) + } else { + return nil + } + } + + func cancel() { + downloadTasks.forEach({ $0.cancel() }) + } +} diff --git a/WordPress/Classes/Services/Stories/StoryPoster.swift b/WordPress/Classes/Services/Stories/StoryPoster.swift new file mode 100644 index 000000000000..c47f798224db --- /dev/null +++ b/WordPress/Classes/Services/Stories/StoryPoster.swift @@ -0,0 +1,180 @@ +import Foundation +import UIKit +import WordPressKit +import AutomatticTracks + +/// A type representing the Story block +struct Story: Codable { + let mediaFiles: [MediaFile] +} + +/// The contents of a Story block +struct MediaFile: Codable { + let alt: String + let caption: String + let id: String + let link: String + let mime: String + let type: String + let url: String + + init(alt: String, + caption: String, + id: String, + link: String, + mime: String, + type: String, + url: String) { + self.alt = alt + self.caption = caption + self.id = id + self.link = link + self.mime = mime + self.type = type + self.url = url + } + + init(dictionary: [String: Any]) throws { + // We must handle both possible types because the Gutenberg `replaceBlock` method seems to be changing the type of this field. + let id: String + do { + id = try dictionary.value(key: CodingKeys.id.stringValue, type: NSNumber.self).stringValue + } catch { + id = try dictionary.value(key: CodingKeys.id.stringValue, type: String.self) + } + self.init(alt: try dictionary.value(key: CodingKeys.alt.stringValue, type: String.self), + caption: try dictionary.value(key: CodingKeys.caption.stringValue, type: String.self), + id: id, + link: try dictionary.value(key: CodingKeys.link.stringValue, type: String.self), + mime: try dictionary.value(key: CodingKeys.mime.stringValue, type: String.self), + type: try dictionary.value(key: CodingKeys.type.stringValue, type: String.self), + url: try dictionary.value(key: CodingKeys.url.stringValue, type: String.self)) + } + + static func file(from dictionary: [String: Any]) -> MediaFile? { + do { + return try self.init(dictionary: dictionary) + } catch let error { + DDLogWarn("MediaFile error: \(error)") + return nil + } + } +} + +extension Dictionary where Key == String, Value == Any { + enum ValueError: Error, CustomDebugStringConvertible { + case missingKey(String) + case wrongType(String, Any) + + var debugDescription: String { + switch self { + case Dictionary.ValueError.missingKey(let key): + return "Dictionary is missing key: \(key)" + case Dictionary.ValueError.wrongType(let key, let value): + return "Dictionary has wrong type for \(key): \(type(of: value))" + } + } + } + + func value<T: Any>(key: String, type: T.Type) throws -> T { + let value = self[key] + if let castValue = value as? T { + return castValue + } else { + if let value = value { + throw ValueError.wrongType(key, value) + } else { + throw ValueError.missingKey(key) + } + } + } +} + +class StoryPoster { + + struct MediaItem { + let url: URL + let size: CGSize + let archive: URL? + let original: URL? + + var mimeType: String { + return url.mimeType + } + } + + let context: NSManagedObjectContext + private let oldMediaFiles: [MediaFile]? + + init(context: NSManagedObjectContext, mediaFiles: [MediaFile]?) { + self.context = context + self.oldMediaFiles = mediaFiles + } + + /// Uploads media to a post and updates the post contents upon completion. + /// - Parameters: + /// - mediaItems: The media items to upload. + /// - post: The post to add media items to. + /// - completion: Called on completion with the new post or an error. + /// - Returns: `(String, [Media])` A tuple containing the Block which was added to contain the media and the new uploading Media objects will be returned. + func add(mediaItems: [MediaItem], post: AbstractPost) throws -> (String, [Media]) { + let assets = mediaItems.map { item in + return item.url as ExportableAsset + } + + // Uploades the media and notifies upong completion with the updated post. + let media = PostCoordinator.shared.add(assets: assets, to: post).compactMap { return $0 } + + // Update set of `MediaItem`s with values from the new added uploading `Media`. + let mediaFiles: [MediaFile] = media.enumerated().map { (idx, media) -> MediaFile in + let item = mediaItems[idx] + return MediaFile(alt: media.alt ?? "", + caption: media.caption ?? "", + id: String(media.gutenbergUploadID), + link: media.remoteURL ?? "", + mime: item.mimeType, + type: String(item.mimeType.split(separator: "/").first ?? ""), + url: item.archive?.absoluteString ?? "") + } + + let story = Story(mediaFiles: mediaFiles) + let encoder = JSONEncoder() + let json = String(data: try encoder.encode(story), encoding: .utf8) + let block = StoryBlock.wrap(json ?? "", includeFooter: true) + return (block, media) + } + + static var filePath: URL? = { + do { + let media = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("KanvasMedia") + try FileManager.default.createDirectory(at: media, withIntermediateDirectories: true, attributes: nil) + return media + } catch let error { + assertionFailure("Failed to create media file path: \(error)") + return nil + } + }() +} + +struct StoryBlock { + + private static let openTag = "<!-- wp:jetpack/story" + private static let closeTag = "-->" + private static let footer = """ + <div class="wp-story wp-block-jetpack-story"></div> + <!-- /wp:jetpack/story --> + """ + + /// Wraps the JSON of a Story into a story block. + /// - Parameter json: The JSON string to wrap in a story block. + /// - Returns: The string containing the full Story block. + static func wrap(_ json: String, includeFooter: Bool) -> String { + let content = """ + \(openTag) + \(json) + \(closeTag) + \(includeFooter ? footer : "") + """ + return content + } +} diff --git a/WordPress/Classes/Services/Stories/WPMediaPicker+MediaPicker.swift b/WordPress/Classes/Services/Stories/WPMediaPicker+MediaPicker.swift new file mode 100644 index 000000000000..8caf37010046 --- /dev/null +++ b/WordPress/Classes/Services/Stories/WPMediaPicker+MediaPicker.swift @@ -0,0 +1,445 @@ +import WPMediaPicker +import Kanvas +import Gridicons +import Combine + +class PortraitTabBarController: UITabBarController { + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait + } + + override var shouldAutorotate: Bool { + return false + } +} + +class WPMediaPickerForKanvas: WPNavigationMediaPickerViewController, MediaPicker { + + private struct Constants { + static let photosTabBarTitle: String = NSLocalizedString("Photos", comment: "Tab bar title for the Photos tab in Media Picker") + static let photosTabBarIcon: UIImage? = .gridicon(.imageMultiple) + static let mediaPickerTabBarTitle: String = NSLocalizedString("Media", comment: "Tab bar title for the Media tab in Media Picker") + static let mediaPickerTabBarIcon: UIImage? = UIImage(named: "icon-wp")?.af_imageAspectScaled(toFit: CGSize(width: 30, height: 30)) + } + + static var pickerDataSource: MediaLibraryPickerDataSource? + + private let delegateHandler: MediaPickerDelegate + + init(options: WPMediaPickerOptions, delegate: MediaPickerDelegate) { + self.delegateHandler = delegate + super.init(options: options) + self.delegate = delegate + self.mediaPicker.mediaPickerDelegate = delegate + self.mediaPicker.registerClass(forReusableCellOverlayViews: DisabledVideoOverlay.self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public static func present(on: UIViewController, + with settings: CameraSettings, + delegate: KanvasMediaPickerViewControllerDelegate, + completion: @escaping () -> Void) { + + guard let blog = (on as? StoryEditor)?.post.blog else { + DDLogWarn("No blog for Kanvas Media Picker") + return + } + + let tabBar = PortraitTabBarController() + + let mediaPickerDelegate = MediaPickerDelegate(kanvasDelegate: delegate, + presenter: tabBar, + blog: blog) + let options = WPMediaPickerOptions() + options.allowCaptureOfMedia = false + + let photoPicker = WPMediaPickerForKanvas(options: options, delegate: mediaPickerDelegate) + photoPicker.dataSource = WPPHAssetDataSource.sharedInstance() + photoPicker.tabBarItem = UITabBarItem(title: Constants.photosTabBarTitle, image: Constants.photosTabBarIcon, tag: 0) + + if FeatureFlag.mediaPickerPermissionsNotice.enabled { + photoPicker.mediaPicker.registerClass(forCustomHeaderView: DeviceMediaPermissionsHeader.self) + } + + let mediaPicker = WPMediaPickerForKanvas(options: options, delegate: mediaPickerDelegate) + mediaPicker.startOnGroupSelector = false + mediaPicker.showGroupSelector = false + + pickerDataSource = MediaLibraryPickerDataSource(blog: blog) + mediaPicker.dataSource = pickerDataSource + mediaPicker.tabBarItem = UITabBarItem(title: Constants.mediaPickerTabBarTitle, image: Constants.mediaPickerTabBarIcon, tag: 0) + + tabBar.viewControllers = [ + photoPicker, + mediaPicker + ] + on.present(tabBar, animated: true, completion: completion) + } +} + +class MediaPickerDelegate: NSObject, WPMediaPickerViewControllerDelegate { + + private weak var kanvasDelegate: KanvasMediaPickerViewControllerDelegate? + private weak var presenter: UIViewController? + private let blog: Blog + private var cancellables = Set<AnyCancellable>() + + init(kanvasDelegate: KanvasMediaPickerViewControllerDelegate, + presenter: UIViewController, + blog: Blog) { + self.kanvasDelegate = kanvasDelegate + self.presenter = presenter + self.blog = blog + } + + func mediaPickerControllerDidCancel(_ picker: WPMediaPickerViewController) { + presenter?.dismiss(animated: true, completion: nil) + } + + enum ExportErrors: Error { + case missingImage + case missingVideoURL + case failedVideoDownload + case unexpectedAssetType // `WPMediaAsset.assetType()` was not an image or video + } + + private struct ExportOutput { + let index: Int + let media: PickedMedia + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, didFinishPicking assets: [WPMediaAsset]) { + + let selected = picker.selectedAssets + picker.clearSelectedAssets(false) + picker.reloadInputViews() // Reloads the bottom bar so it is hidden while loading + + SVProgressHUD.setDefaultMaskType(.black) + SVProgressHUD.setContainerView(presenter?.view) + SVProgressHUD.showProgress(-1) + + let mediaExports: [AnyPublisher<(Int, PickedMedia), Error>] = assets.enumerated().map { (index, asset) -> AnyPublisher<(Int, PickedMedia), Error> in + switch asset.assetType() { + case .image: + return asset.imagePublisher().map { (image, url) in + (index, PickedMedia.image(image, url)) + }.eraseToAnyPublisher() + case .video: + return asset.videoURLPublisher().map { url in + (index, PickedMedia.video(url)) + }.eraseToAnyPublisher() + default: + return Fail(outputType: (Int, PickedMedia).self, failure: ExportErrors.unexpectedAssetType).eraseToAnyPublisher() + } + } + + Publishers.MergeMany(mediaExports) + .collect(assets.count) // Wait for all assets to complete before receiving. + .map { media in + // Sort our media back into the original order since they may be mixed up after export. + return media.sorted { left, right in + return left.0 < right.0 + }.map { + return $0.1 + } + } + .receive(on: DispatchQueue.main).sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + picker.selectedAssets = selected + + let title = NSLocalizedString("Failed Media Export", comment: "Error title when picked media cannot be imported into stories.") + let message = NSLocalizedString("Your media could not be exported. If the problem persists you can contact us via the Me > Help & Support screen.", comment: "Error message when picked media cannot be imported into stories.") + let dismissTitle = NSLocalizedString( + "mediaPicker.failedMediaExportAlert.dismissButton", + value: "Dismiss", + comment: "The title of the button to dismiss the alert shown when the picked media cannot be imported into stories." + ) + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + let dismiss = UIAlertAction(title: dismissTitle, style: .default) { _ in + alert.dismiss(animated: true, completion: nil) + } + alert.addAction(dismiss) + picker.present(alert, animated: true, completion: nil) + + DDLogError("Failed to export picked Stories media: \(error)") + case .finished: + break + } + SVProgressHUD.dismiss() + }, receiveValue: { [weak self] media in + self?.presenter?.dismiss(animated: true, completion: nil) + self?.kanvasDelegate?.didPick(media: media) + }).store(in: &cancellables) + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, shouldShowOverlayViewForCellFor asset: WPMediaAsset) -> Bool { + picker != self && !blog.canUploadAsset(asset) + } + + func mediaPickerControllerShouldShowCustomHeaderView(_ picker: WPMediaPickerViewController) -> Bool { + guard FeatureFlag.mediaPickerPermissionsNotice.enabled, + picker.dataSource is WPPHAssetDataSource else { + return false + } + + return PHPhotoLibrary.authorizationStatus(for: .readWrite) == .limited + } + + func mediaPickerControllerReferenceSize(forCustomHeaderView picker: WPMediaPickerViewController) -> CGSize { + let header = DeviceMediaPermissionsHeader() + header.translatesAutoresizingMaskIntoConstraints = false + + return header.referenceSizeInView(picker.view) + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, configureCustomHeaderView headerView: UICollectionReusableView) { + guard let headerView = headerView as? DeviceMediaPermissionsHeader else { + return + } + + headerView.presenter = picker + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, shouldSelect asset: WPMediaAsset) -> Bool { + if picker != self, !blog.canUploadAsset(asset) { + presentVideoLimitExceededFromPicker(on: picker) + return false + } + return true + } +} + +// MARK: - User messages for video limits allowances + +extension MediaPickerDelegate: VideoLimitsAlertPresenter {} + +// MARK: Media Export extensions + +enum VideoURLErrors: Error { + case videoAssetExportFailed + case failedVideoDownload +} + +private extension PHAsset { + // TODO: Update MPMediaPicker with degraded image implementation. + func sizedImage(with size: CGSize, completionHandler: @escaping (UIImage?, Error?) -> Void) { + let options = PHImageRequestOptions() + options.isSynchronous = false + options.deliveryMode = .opportunistic + options.resizeMode = .fast + options.isNetworkAccessAllowed = true + PHImageManager.default().requestImage(for: self, targetSize: size, contentMode: .aspectFit, options: options, resultHandler: { (result, info) in + let error = info?[PHImageErrorKey] as? Error + let cancelled = info?[PHImageCancelledKey] as? Bool + if let error = error, cancelled != true { + completionHandler(nil, error) + } + // Wait for resized image instead of thumbnail + if let degraded = info?[PHImageResultIsDegradedKey] as? Bool, degraded == false { + completionHandler(result, nil) + } + }) + } +} + +extension WPMediaAsset { + + private func fit(size: CGSize) -> CGSize { + let assetSize = pixelSize() + let aspect = assetSize.width / assetSize.height + if size.width / aspect <= size.height { + return CGSize(width: size.width, height: round(size.width / aspect)) + } else { + return CGSize(width: round(size.height * aspect), height: round(size.height)) + } + } + + func sizedImage(with size: CGSize, completionHandler: @escaping (UIImage?, Error?) -> Void) { + if let asset = self as? PHAsset { + asset.sizedImage(with: size, completionHandler: completionHandler) + } else { + image(with: size, completionHandler: completionHandler) + } + } + + /// Produces a Publisher which contains a resulting image and image URL where available. + /// - Returns: A Publisher containing resuling image, URL and any errors during export. + func imagePublisher() -> AnyPublisher<(UIImage, URL?), Error> { + return Future<(UIImage, URL?), Error> { promise in + let size = self.fit(size: UIScreen.main.nativeBounds.size) + self.sizedImage(with: size) { (image, error) in + guard let image = image else { + if let error = error { + return promise(.failure(error)) + } + return promise(.failure(WPMediaAssetError.imageAssetExportFailed)) + } + return promise(.success((image, nil))) + } + }.eraseToAnyPublisher() + } + + /// Produces a Publisher containing a URL of saved video and any errors which occurred. + /// + /// - Parameters: + /// - skipTransformCheck: Skips the transform check. + /// + /// - Returns: Publisher containing the URL to a saved video and any errors which occurred. + /// + func videoURLPublisher(skipTransformCheck: Bool = false) -> AnyPublisher<URL, Error> { + videoAssetPublisher().tryMap { asset -> AnyPublisher<URL, Error> in + let filename = UUID().uuidString + let url = try StoryEditor.mediaCacheDirectory().appendingPathComponent(filename) + let urlAsset = asset as? AVURLAsset + + // Portrait video is exported so that it is rotated for use in Kanvas. + // Once the Metal renderer is fixed to properly rotate this media, this can be removed. + let trackTransform = asset.tracks(withMediaType: .video).first?.preferredTransform + + // DRM: I moved this logic into a variable because it seems to be completely out of place in this method + // and it was causing some issues when sharing videos that needed to be downloaded. I added a parameter + // with a default value that will make sure this check is executed for any old code. + let transformCheck = skipTransformCheck || trackTransform == CGAffineTransform.identity + + if let assetURL = urlAsset?.url, transformCheck { + let exportURL = url.appendingPathExtension(assetURL.pathExtension) + if urlAsset?.url.scheme != "file" { + // Download any file which isn't local and move it to the proper location. + return URLSession.shared.downloadTaskPublisher(url: assetURL).tryMap { (location, _) -> URL in + if let location = location { + try FileManager.default.moveItem(at: location, to: exportURL) + return exportURL + } else { + return url + } + }.eraseToAnyPublisher() + } else { + // Return the local asset URL which we will use directly. + return Just(assetURL).setFailureType(to: Error.self).eraseToAnyPublisher() + } + } else { + // Export any other file which isn't an AVURLAsset since we don't have a URL to use. + return try asset.exportPublisher(url: url) + } + }.flatMap { publisher -> AnyPublisher<URL, Error> in + return publisher + }.eraseToAnyPublisher() + } +} + +private extension AVAsset { + func exportPublisher(url: URL) throws -> AnyPublisher<URL, Error> { + let exportURL = url.appendingPathExtension("mov") + + let (composition, videoComposition) = try rotate() + + if let exportSession = AVAssetExportSession(asset: composition, presetName: AVAssetExportPreset1920x1080) { + exportSession.videoComposition = videoComposition + exportSession.outputURL = exportURL + exportSession.outputFileType = .mov + return exportSession.exportPublisher(url: exportURL) + } else { + throw WPMediaAssetError.videoAssetExportFailed + } + } + + /// Applies the `preferredTransform` of the video track. + /// - Returns: Returns both an AVMutableComposition containing video + audio and an AVVideoComposition of the rotate video. + private func rotate() throws -> (AVMutableComposition, AVVideoComposition) { + guard let videoTrack = tracks(withMediaType: .video).first else { + throw WPMediaAssetError.assetMissingVideoTrack + } + + let videoComposition = AVMutableVideoComposition(propertiesOf: self) + let size = videoTrack.naturalSize.applying(videoTrack.preferredTransform) + videoComposition.renderSize = CGSize(width: abs(size.width), height: abs(size.height)) + let instruction = AVMutableVideoCompositionInstruction() + instruction.timeRange = CMTimeRange(start: .zero, duration: videoTrack.timeRange.duration) + + let transformer = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack) + transformer.setTransform(videoTrack.preferredTransform, at: .zero) + instruction.layerInstructions = [transformer] + videoComposition.instructions = [instruction] + + let composition = AVMutableComposition() + + let mutableVideoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) + try? mutableVideoTrack?.insertTimeRange(CMTimeRange(start: .zero, end: videoTrack.timeRange.duration), of: videoTrack, at: .zero) + + if let audioTrack = tracks(withMediaType: .audio).first { + let mutableAudioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) + try? mutableAudioTrack?.insertTimeRange(CMTimeRange(start: .zero, duration: audioTrack.timeRange.duration), of: audioTrack, at: .zero) + } + + return (composition, videoComposition) + } +} + + +enum WPMediaAssetError: Error { + case imageAssetExportFailed + case videoAssetExportFailed + case assetMissingVideoTrack +} + +extension WPMediaAsset { + /// Produces a Publisher `AVAsset` from a `WPMediaAsset` object. + /// - Returns: Publisher with an AVAsset and any errors which occur during export. + func videoAssetPublisher() -> AnyPublisher<AVAsset, Error> { + Future<AVAsset, Error> { [weak self] promise in + self?.videoAsset(completionHandler: { asset, error in + guard let asset = asset else { + if let error = error { + return promise(.failure(error)) + } + return promise(.failure(WPMediaAssetError.videoAssetExportFailed)) + } + promise(.success(asset)) + }) + }.eraseToAnyPublisher() + } +} + +extension URLSession { + typealias DownloadTaskResult = (location: URL?, response: URLResponse?) + /// Produces a Publisher which contains the result of a Download Task + /// - Parameter url: The URL to download from. + /// - Returns: A publisher containing the result of a Download Task and any errors which occur during the download. + func downloadTaskPublisher(url: URL) -> AnyPublisher<DownloadTaskResult, Error> { + return Deferred { + Future<DownloadTaskResult, Error> { promise in + URLSession.shared.downloadTask(with: url) { (location, response, error) in + if let error = error { + promise(.failure(error)) + } else { + promise(.success((location, response))) + } + }.resume() + } + }.eraseToAnyPublisher() + } +} + +extension AVAssetExportSession { + /// Produces a publisher which wraps the export of a video. + /// - Parameter url: The location to save the video to. + /// - Returns: A publisher containing the location the asset was saved to and an error. + func exportPublisher(url: URL) -> AnyPublisher<URL, Error> { + return Deferred { + Future<URL, Error> { [weak self] promise in + self?.exportAsynchronously { [weak self] in + if let error = self?.error { + promise(.failure(error)) + } + promise(.success(url)) + } + } + }.handleEvents(receiveCancel: { + self.cancelExport() + }).eraseToAnyPublisher() + } +} diff --git a/WordPress/Classes/Services/SuggestionService.h b/WordPress/Classes/Services/SuggestionService.h deleted file mode 100644 index 35a77e5700df..000000000000 --- a/WordPress/Classes/Services/SuggestionService.h +++ /dev/null @@ -1,33 +0,0 @@ -#import <Foundation/Foundation.h> - -extern NSString * const SuggestionListUpdatedNotification; - -@interface SuggestionService : NSObject - -+ (instancetype)sharedInstance; - -/** - Returns the cached @mention suggestions (if any) for a given siteID. Calls - updateSuggestionsForSiteID if no suggestions for the site have been cached. - - @param siteID ID of the blog/site to retrieve suggestions for - @return An array of suggestions - */ -- (NSArray *)suggestionsForSiteID:(NSNumber *)siteID; - -/** - Performs a REST API request for the siteID given. - - @param siteID ID of the blog/site to retrieve suggestions for - */ -- (void)updateSuggestionsForSiteID:(NSNumber *)siteID; - -/** - Tells the caller if it is a good idea to show suggestions right now for a given siteID. - - @param siteID ID of the blog/site to check for - @return BOOL Whether the caller should show suggestions - */ -- (BOOL)shouldShowSuggestionsForSiteID:(NSNumber *)siteID; - -@end diff --git a/WordPress/Classes/Services/SuggestionService.m b/WordPress/Classes/Services/SuggestionService.m deleted file mode 100644 index 0c37fcbb40e4..000000000000 --- a/WordPress/Classes/Services/SuggestionService.m +++ /dev/null @@ -1,121 +0,0 @@ -#import "SuggestionService.h" -#import "Suggestion.h" -#import "AccountService.h" -#import "ContextManager.h" -#import "WPAccount.h" -#import "BlogService.h" -#import "Blog.h" -#import "WordPress-Swift.h" - -NSString * const SuggestionListUpdatedNotification = @"SuggestionListUpdatedNotification"; - -@interface SuggestionService () - -@property (nonatomic, strong) NSCache *suggestionsCache; -@property (nonatomic, strong) NSMutableArray *siteIDsCurrentlyBeingRequested; - -@end - -@implementation SuggestionService - -+ (instancetype)sharedInstance -{ - static SuggestionService *shared = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - shared = [[self alloc] init]; - }); - return shared; -} - -- (instancetype)init -{ - self = [super init]; - if (self) { - _suggestionsCache = [NSCache new]; - _siteIDsCurrentlyBeingRequested = [NSMutableArray new]; - } - return self; -} - -#pragma mark - - -- (NSArray *)suggestionsForSiteID:(NSNumber *)siteID -{ - NSArray *suggestions = [self.suggestionsCache objectForKey:siteID]; - if (!suggestions) { - [self updateSuggestionsForSiteID:siteID]; - } - return suggestions; -} - -- (void)updateSuggestionsForSiteID:(NSNumber *)siteID -{ - // if there is already a request in place for this siteID, just wait - if ([self.siteIDsCurrentlyBeingRequested containsObject:siteID]) { - return; - } - - // add this siteID to currently being requested list - [self.siteIDsCurrentlyBeingRequested addObject:siteID]; - - NSString *suggestPath = @"rest/v1.1/users/suggest"; - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; - NSDictionary *params = @{@"site_id": siteID}; - - __weak __typeof(self) weakSelf = self; - - [[defaultAccount wordPressComRestApi] GET:suggestPath - parameters:params - success:^(id responseObject, NSHTTPURLResponse *httpResponse) { - NSArray *restSuggestions = responseObject[@"suggestions"]; - NSMutableArray *suggestions = [[NSMutableArray alloc] initWithCapacity:restSuggestions.count]; - - for (id restSuggestion in restSuggestions) { - [suggestions addObject:[Suggestion suggestionFromDictionary:restSuggestion]]; - } - [weakSelf.suggestionsCache setObject:suggestions forKey:siteID cost:suggestions.count]; - - // send the siteID with the notification so it could be filtered out - [[NSNotificationCenter defaultCenter] postNotificationName:SuggestionListUpdatedNotification object:siteID]; - - // remove siteID from the currently being requested list - [weakSelf.siteIDsCurrentlyBeingRequested removeObject:siteID]; - } failure:^(NSError *error, NSHTTPURLResponse *httpResponse){ - // remove siteID from the currently being requested list - [weakSelf.siteIDsCurrentlyBeingRequested removeObject:siteID]; - - DDLogVerbose(@"[Rest API] ! %@", [error localizedDescription]); - }]; -} - -- (BOOL)shouldShowSuggestionsForSiteID:(NSNumber *)siteID -{ - if (!siteID) { - return NO; - } - - WordPressAppDelegate *appDelegate = [WordPressAppDelegate shared]; - - NSArray *suggestions = [self.suggestionsCache objectForKey:siteID]; - - // if the device is offline and suggestion list is not yet retrieved - if (!appDelegate.connectionAvailable && !suggestions) { - return NO; - } - - // if the suggestion list is already retrieved and there is nothing to show - if (suggestions && suggestions.count == 0) { - return NO; - } - - // if the site is not hosted on WordPress.com - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *service = [[BlogService alloc] initWithManagedObjectContext:context]; - Blog *blog = [service blogByBlogId:siteID]; - return [blog supports:BlogFeatureMentions]; -} - -@end diff --git a/WordPress/Classes/Services/SuggestionService.swift b/WordPress/Classes/Services/SuggestionService.swift new file mode 100644 index 000000000000..dd17f6c87a6e --- /dev/null +++ b/WordPress/Classes/Services/SuggestionService.swift @@ -0,0 +1,120 @@ +import Foundation + +/// A service to fetch and persist a list of users that can be @-mentioned in a post or comment. +class SuggestionService { + + private var blogsCurrentlyBeingRequested = [NSNumber]() + private var requests = [NSNumber: Date]() + + static let shared = SuggestionService() + + /** + Fetch cached suggestions if available, otherwise from the network if the device is online. + + @param the blog/site to retrieve suggestions for + @param completion callback containing list of suggestions, or nil if unavailable + */ + func suggestions(for blog: Blog, completion: @escaping ([UserSuggestion]?) -> Void) { + + let throttleDuration: TimeInterval = 60 // seconds + let isBelowThrottleThreshold: Bool + if let id = blog.dotComID, let requestDate = requests[id] { + isBelowThrottleThreshold = Date().timeIntervalSince(requestDate) < throttleDuration + } else { + isBelowThrottleThreshold = false + } + + if isBelowThrottleThreshold, let suggestions = retrievePersistedSuggestions(for: blog), suggestions.isEmpty == false { + completion(suggestions) + } else if ReachabilityUtils.isInternetReachable() { + fetchAndPersistSuggestions(for: blog, completion: completion) + } else { + completion(nil) + } + } + + /** + Performs a REST API request for the given blog. + Persists response objects to Core Data. + + @param blog/site to retrieve suggestions for + */ + private func fetchAndPersistSuggestions(for blog: Blog, completion: @escaping ([UserSuggestion]?) -> Void) { + + guard let blogId = blog.dotComID else { return } + + // if there is already a request in place for this blog, just wait + guard !blogsCurrentlyBeingRequested.contains(blogId) else { return } + + guard let siteID = blog.dotComID else { return } + + let suggestPath = "rest/v1.1/users/suggest" + let params = ["site_id": siteID] + + // add this blog to currently being requested list + blogsCurrentlyBeingRequested.append(blogId) + + defaultAccount()?.wordPressComRestApi.GET(suggestPath, parameters: params, success: { [weak self] responseObject, httpResponse in + guard let `self` = self else { return } + guard let payload = responseObject as? [String: Any] else { return } + guard let restSuggestions = payload["suggestions"] as? [[String: Any]] else { return } + + let context = ContextManager.shared.mainContext + + // Delete any existing `UserSuggestion` objects + self.retrievePersistedSuggestions(for: blog)?.forEach { suggestion in + context.delete(suggestion) + } + + // Create new `UserSuggestion` objects + let suggestions = restSuggestions.compactMap { UserSuggestion(dictionary: $0, context: context) } + + // Associate `UserSuggestion` objects with blog + blog.userSuggestions = Set(suggestions) + + // Save the changes + try? blog.managedObjectContext?.save() + + self.requests[blogId] = Date() + + completion(suggestions) + + // remove blog from the currently being requested list + self.blogsCurrentlyBeingRequested.removeAll { $0 == blogId } + }, failure: { [weak self] error, _ in + guard let `self` = self else { return } + + completion(nil) + + // remove blog from the currently being requested list + self.blogsCurrentlyBeingRequested.removeAll { $0 == blogId} + + DDLogVerbose("[Rest API] ! \(error.localizedDescription)") + }) + } + + /** + Tells the caller if it is a good idea to show suggestions right now for a given blog/site. + + @param blog blog/site to check for + @return BOOL Whether the caller should show suggestions + */ + func shouldShowSuggestions(for blog: Blog) -> Bool { + + // The device must be online or there must be already persisted suggestions + guard ReachabilityUtils.isInternetReachable() || retrievePersistedSuggestions(for: blog)?.isEmpty == false else { + return false + } + + return blog.supports(.mentions) + } + + private func defaultAccount() -> WPAccount? { + try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) + } + + private func retrievePersistedSuggestions(for blog: Blog) -> [UserSuggestion]? { + guard let suggestions = blog.userSuggestions else { return nil } + return Array(suggestions) + } +} diff --git a/WordPress/Classes/Services/ThemeService.h b/WordPress/Classes/Services/ThemeService.h index 5fe7a8d4a955..85d59699d6fc 100644 --- a/WordPress/Classes/Services/ThemeService.h +++ b/WordPress/Classes/Services/ThemeService.h @@ -1,4 +1,4 @@ -#import "LocalCoreDataService.h" +#import "CoreDataService.h" @class Blog; @class Theme; @@ -9,7 +9,7 @@ typedef void(^ThemeServiceThemeRequestSuccessBlock)(Theme *theme); typedef void(^ThemeServiceThemesRequestSuccessBlock)(NSArray<Theme *> *themes, BOOL hasMore, NSInteger totalThemeCount); typedef void(^ThemeServiceFailureBlock)(NSError *error); -@interface ThemeService : LocalCoreDataService +@interface ThemeService : CoreDataService #pragma mark - Themes availability @@ -23,19 +23,6 @@ typedef void(^ThemeServiceFailureBlock)(NSError *error); */ - (BOOL)blogSupportsThemeServices:(Blog *)blog; -#pragma mark - Local queries: finding themes - -/** - * @brief Obtains the theme with the specified ID if it exists. - * - * @param themeId The ID of the theme to retrieve. Cannot be nil. - * @param blog Blog to find theme for. May be nil for account. - * - * @returns The stored theme matching the specified ID if found, or nil if it's not found. - */ -- (Theme *)findThemeWithId:(NSString *)themeId - forBlog:(Blog *)blog; - #pragma mark - Remote queries: Getting theme info /** @@ -51,52 +38,6 @@ typedef void(^ThemeServiceFailureBlock)(NSError *error); success:(ThemeServiceThemeRequestSuccessBlock)success failure:(ThemeServiceFailureBlock)failure; -/** - * @brief Gets the list of purchased themes for a blog. - * - * @param blogId The blog to get the purchased themes for. Cannot be nil. - * @param success The success handler. Can be nil. - * @param failure The failure handler. Can be nil. - * - * @returns The progress object. - */ -- (NSProgress *)getPurchasedThemesForBlog:(Blog *)blog - success:(ThemeServiceThemesRequestSuccessBlock)success - failure:(ThemeServiceFailureBlock)failure; - - -/** - * @brief Gets information for a specific theme. - * - * @param themeId The identifier of the theme to request info for. Cannot be nil. - * @param account The account to get the theme from. Cannot be nil. - * @param success The success handler. Can be nil. - * @param failure The failure handler. Can be nil. - * - * @returns The progress object. - */ -- (NSProgress *)getThemeId:(NSString*)themeId - forAccount:(WPAccount *)account - success:(ThemeServiceThemeRequestSuccessBlock)success - failure:(ThemeServiceFailureBlock)failure; - -/** - * @brief Gets the list of WP.com available themes. - * @details Includes premium themes even if not purchased. Don't call this method if the list - * you want to retrieve is for a specific blog. Use getThemesForBlogId instead. - * - * @param account The account to get the theme from. Cannot be nil. - * @param page Results page to return. - * @param success The success handler. Can be nil. - * @param failure The failure handler. Can be nil. - * - * @returns The progress object. - */ -- (NSProgress *)getThemesForAccount:(WPAccount *)account - page:(NSInteger)page - success:(ThemeServiceThemesRequestSuccessBlock)success - failure:(ThemeServiceFailureBlock)failure; - /** * @brief Gets the list of available themes for a blog. * @details Includes premium themes even if not purchased. The only difference with the @@ -124,22 +65,6 @@ typedef void(^ThemeServiceFailureBlock)(NSError *error); success:(ThemeServiceThemesRequestSuccessBlock)success failure:(ThemeServiceFailureBlock)failure; -/** - * @brief Gets a list of suggested starter themes for the given site category - * (blog, website, portfolio). - * @details During the site creation process, a list of suggested mobile-friendly starter - * themes is displayed for the selected category. - * - * @param category The category for the site being created. Cannot be nil. - * @param page Results page to return. Cannot be nil. - * @param success The success handler. Can be nil. - * @param failure The failure handler. Can be nil. - */ -- (void)getStartingThemesForCategory:(NSString *)category - page:(NSInteger)page - success:(ThemeServiceThemesRequestSuccessBlock)success - failure:(ThemeServiceFailureBlock)failure; - #pragma mark - Remote queries: Activating themes /** diff --git a/WordPress/Classes/Services/ThemeService.m b/WordPress/Classes/Services/ThemeService.m index 17c966e42e84..de828dc7464d 100644 --- a/WordPress/Classes/Services/ThemeService.m +++ b/WordPress/Classes/Services/ThemeService.m @@ -3,7 +3,7 @@ #import "Blog.h" #import "Theme.h" #import "WPAccount.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "WordPress-Swift.h" @import WordPressKit; @@ -39,22 +39,19 @@ - (BOOL)blogSupportsThemeServices:(Blog *)blog */ - (Theme *)newThemeWithId:(NSString *)themeId forBlog:(nullable Blog *)blog + inContext:(NSManagedObjectContext *)context { NSParameterAssert([themeId isKindOfClass:[NSString class]]); NSEntityDescription *entityDescription = [NSEntityDescription entityForName:[Theme entityName] - inManagedObjectContext:self.managedObjectContext]; - - __block Theme *theme = nil; - - [self.managedObjectContext performBlockAndWait:^{ - theme = [[Theme alloc] initWithEntity:entityDescription - insertIntoManagedObjectContext:self.managedObjectContext]; - if (blog) { - theme.blog = blog; - } - }]; + inManagedObjectContext:context]; + Theme *theme = [[Theme alloc] initWithEntity:entityDescription + insertIntoManagedObjectContext:context]; + if (blog) { + theme.blog = blog; + } + return theme; } @@ -69,15 +66,14 @@ - (Theme *)newThemeWithId:(NSString *)themeId */ - (Theme *)findOrCreateThemeWithId:(NSString *)themeId forBlog:(nullable Blog *)blog + inContext:(NSManagedObjectContext *)context { NSParameterAssert([themeId isKindOfClass:[NSString class]]); - Theme *theme = [self findThemeWithId:themeId - forBlog:blog]; + Theme *theme = [self findThemeWithId:themeId forBlog:blog inContext:context]; if (!theme) { - theme = [self newThemeWithId:themeId - forBlog:blog]; + theme = [self newThemeWithId:themeId forBlog:blog inContext:context]; } return theme; @@ -87,6 +83,7 @@ - (Theme *)findOrCreateThemeWithId:(NSString *)themeId - (Theme *)findThemeWithId:(NSString *)themeId forBlog:(nullable Blog *)blog + inContext:(NSManagedObjectContext *)context { NSParameterAssert([themeId isKindOfClass:[NSString class]]); @@ -102,7 +99,7 @@ - (Theme *)findThemeWithId:(NSString *)themeId fetchRequest.predicate = predicate; NSError *error = nil; - NSArray *results = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; + NSArray *results = [context executeFetchRequest:fetchRequest error:&error]; if (results.count > 0) { theme = (Theme *)[results firstObject]; @@ -116,18 +113,6 @@ - (Theme *)findThemeWithId:(NSString *)themeId return theme; } -- (NSArray *)findAccountThemes -{ - NSPredicate *predicate = [NSPredicate predicateWithFormat:@"blog.@count == 0"]; - NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:[Theme entityName]]; - fetchRequest.predicate = predicate; - - NSError *error = nil; - NSArray *results = [self.managedObjectContext executeFetchRequest:fetchRequest error:&error]; - - return results; -} - #pragma mark - Remote queries: Getting theme info - (NSProgress *)getActiveThemeForBlog:(Blog *)blog @@ -144,106 +129,20 @@ - (NSProgress *)getActiveThemeForBlog:(Blog *)blog ThemeServiceRemote *remote = [[ThemeServiceRemote alloc] initWithWordPressComRestApi:blog.wordPressComRestApi]; - NSProgress *progress = [remote getActiveThemeForBlogId:[blog dotComID] - success:^(RemoteTheme *remoteTheme) { - remoteTheme = [self removeWPComSuffixIfNeeded:remoteTheme - forBlog:blog]; - Theme *theme = [self themeFromRemoteTheme:remoteTheme - forBlog:blog]; - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (success) { - success(theme); - } - }]; - } failure:failure]; - - return progress; -} - -- (NSProgress *)getPurchasedThemesForBlog:(Blog *)blog - success:(ThemeServiceThemesRequestSuccessBlock)success - failure:(ThemeServiceFailureBlock)failure -{ - NSParameterAssert([blog isKindOfClass:[Blog class]]); - NSAssert([self blogSupportsThemeServices:blog], - @"Do not call this method on unsupported blogs, check with blogSupportsThemeServices first."); - - if (blog.wordPressComRestApi == nil) { - return nil; - } - - ThemeServiceRemote *remote = [[ThemeServiceRemote alloc] initWithWordPressComRestApi:blog.wordPressComRestApi]; - - NSProgress *progress = [remote getPurchasedThemesForBlogId:[blog dotComID] - success:^(NSArray *remoteThemes) { - NSArray *themes = [self themesFromRemoteThemes:remoteThemes - forBlog:blog]; - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (success) { - success(themes, NO, themes.count); - } - }]; - } failure:failure]; - - return progress; -} - -- (NSProgress *)getThemeId:(NSString*)themeId - forAccount:(WPAccount *)account - success:(ThemeServiceThemeRequestSuccessBlock)success - failure:(ThemeServiceFailureBlock)failure -{ - NSParameterAssert([themeId isKindOfClass:[NSString class]]); - NSParameterAssert(account.wordPressComRestApi != nil); - - if (account.wordPressComRestApi == nil) { - return nil; - } - - ThemeServiceRemote *remote = [[ThemeServiceRemote alloc] initWithWordPressComRestApi:account.wordPressComRestApi]; - - NSProgress *progress = [remote getThemeId:themeId - success:^(RemoteTheme *remoteTheme) { - Theme *theme = [self themeFromRemoteTheme:remoteTheme - forBlog:nil]; - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (success) { - success(theme); - } - }]; - } failure:failure]; - - return progress; -} - -- (NSProgress *)getThemesForAccount:(WPAccount *)account - page:(NSInteger)page - success:(ThemeServiceThemesRequestSuccessBlock)success - failure:(ThemeServiceFailureBlock)failure -{ - NSParameterAssert([account isKindOfClass:[WPAccount class]]); - - if (account.wordPressComRestApi == nil) { - return nil; - } - - ThemeServiceRemote *remote = [[ThemeServiceRemote alloc] initWithWordPressComRestApi:account.wordPressComRestApi]; - - NSProgress *progress = [remote getWPThemesPage:page - freeOnly:false - success:^(NSArray<RemoteTheme *> *remoteThemes, BOOL hasMore, NSInteger totalThemeCount) { - NSArray *themes = [self themesFromRemoteThemes:remoteThemes - forBlog:nil]; - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (success) { - success(themes, hasMore, totalThemeCount); - } - }]; - } failure:failure]; + NSProgress *progress = [remote getActiveThemeForBlogId:[blog dotComID] success:^(RemoteTheme *remoteTheme) { + NSManagedObjectID * __block themeID = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blogInContext = [context existingObjectWithID:blog.objectID error:nil]; + RemoteTheme *updatedRemoteTheme = [self removeWPComSuffixIfNeeded:remoteTheme forBlog:blogInContext]; + Theme *theme = [self themeFromRemoteTheme:updatedRemoteTheme forBlog:blogInContext inContext:context]; + [context obtainPermanentIDsForObjects:@[theme] error:nil]; + themeID = theme.objectID; + } completion:^{ + if (success) { + success([self.coreDataStack.mainContext existingObjectWithID:themeID error:nil]); + } + } onQueue:dispatch_get_main_queue()]; + } failure:failure]; return progress; } @@ -263,57 +162,79 @@ - (NSProgress *)getThemesForBlog:(Blog *)blog } ThemeServiceRemote *remote = [[ThemeServiceRemote alloc] initWithWordPressComRestApi:blog.wordPressComRestApi]; - NSMutableSet *unsyncedThemes = sync ? [NSMutableSet setWithSet:blog.themes] : nil; if ([blog supports:BlogFeatureCustomThemes]) { return [remote getWPThemesPage:page freeOnly:![blog supports:BlogFeaturePremiumThemes] success:^(NSArray<RemoteTheme *> *remoteThemes, BOOL hasMore, NSInteger totalThemeCount) { - NSArray *themes = [self themesFromRemoteThemes:remoteThemes - forBlog:blog]; - if (sync) { - // We don't want to touch custom themes here, only WP.com themes - NSMutableSet *unsyncedWPThemes = [unsyncedThemes mutableCopy]; - for (Theme *theme in unsyncedThemes) { - if (theme.custom) { - [unsyncedWPThemes removeObject:theme]; + NSArray * __block themeObjectIDs = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blogInContext = [context existingObjectWithID:blog.objectID error:nil]; + NSArray *themes = [self themesFromRemoteThemes:remoteThemes + forBlog:blogInContext + inContext:context]; + if (sync) { + NSMutableSet *unsyncedThemes = [NSMutableSet setWithSet:blogInContext.themes]; + // We don't want to touch custom themes here, only WP.com themes + NSMutableSet *unsyncedWPThemes = [unsyncedThemes mutableCopy]; + for (Theme *theme in unsyncedThemes) { + if (theme.custom) { + [unsyncedWPThemes removeObject:theme]; + } } - } - [unsyncedWPThemes minusSet:[NSSet setWithArray:themes]]; - for (Theme *deleteTheme in unsyncedWPThemes) { - if (![blog.currentThemeId isEqualToString:deleteTheme.themeId]) { - [self.managedObjectContext deleteObject:deleteTheme]; + [unsyncedWPThemes minusSet:[NSSet setWithArray:themes]]; + for (Theme *deleteTheme in unsyncedWPThemes) { + if (![blogInContext.currentThemeId isEqualToString:deleteTheme.themeId]) { + [context deleteObject:deleteTheme]; + } } } - } - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ + [context obtainPermanentIDsForObjects:themes error:nil]; + themeObjectIDs = [themes wp_map:^id(Theme *obj) { + return obj.objectID; + }]; + } completion:^{ if (success) { + NSArray *themes = [themeObjectIDs wp_map:^id(NSManagedObjectID *objectID) { + return [self.coreDataStack.mainContext existingObjectWithID:objectID error:nil]; + }]; success(themes, hasMore, totalThemeCount); } - }]; + } onQueue:dispatch_get_main_queue()]; } failure:failure]; } else { return [remote getThemesForBlogId:[blog dotComID] page:page success:^(NSArray<RemoteTheme *> *remoteThemes, BOOL hasMore, NSInteger totalThemeCount) { - NSArray *themes = [self themesFromRemoteThemes:remoteThemes - forBlog:blog]; - if (sync) { - [unsyncedThemes minusSet:[NSSet setWithArray:themes]]; - for (Theme *deleteTheme in unsyncedThemes) { - if (![blog.currentThemeId isEqualToString:deleteTheme.themeId]) { - [self.managedObjectContext deleteObject:deleteTheme]; + NSArray * __block themeObjectIDs = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blogInContext = [context existingObjectWithID:blog.objectID error:nil]; + NSArray *themes = [self themesFromRemoteThemes:remoteThemes + forBlog:blogInContext + inContext:context]; + if (sync) { + NSMutableSet *unsyncedThemes = [NSMutableSet setWithSet:blogInContext.themes]; + [unsyncedThemes minusSet:[NSSet setWithArray:themes]]; + for (Theme *deleteTheme in unsyncedThemes) { + if (![blog.currentThemeId isEqualToString:deleteTheme.themeId]) { + [context deleteObject:deleteTheme]; + } } } - } - - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ + [context obtainPermanentIDsForObjects:themes error:nil]; + themeObjectIDs = [themes wp_map:^id(Theme *obj) { + return obj.objectID; + }]; + } completion:^{ if (success) { + NSArray *themes = [themeObjectIDs wp_map:^id(NSManagedObjectID *objectID) { + return [self.coreDataStack.mainContext existingObjectWithID:objectID error:nil]; + }]; success(themes, hasMore, totalThemeCount); } - }]; - } failure:failure]; + } onQueue:dispatch_get_main_queue()]; + } + failure:failure]; } } @@ -333,65 +254,53 @@ - (NSProgress *)getCustomThemesForBlog:(Blog *)blog return [remote getCustomThemesForBlogId:[blog dotComID] success:^(NSArray<RemoteTheme *> *remoteThemes, BOOL hasMore, NSInteger totalThemeCount) { - NSMutableArray *validRemoteThemes = [NSMutableArray array]; - // We need to filter out themes with an id ending in -wpcom to match Calypso - for (RemoteTheme *remoteTheme in remoteThemes) { - if (![ThemeIdHelper themeIdHasWPComSuffix:remoteTheme.themeId]) { - [validRemoteThemes addObject:remoteTheme]; - } - } - - NSArray *themes = [self customThemesFromRemoteThemes:validRemoteThemes - forBlog:blog]; - if (sync) { - // We don't want to touch WP.com themes here, only custom themes - NSMutableSet *unsyncedThemes = [NSMutableSet setWithSet:blog.themes]; - NSMutableSet *unsyncedCustomThemes = [unsyncedThemes mutableCopy]; - for (Theme *theme in unsyncedThemes) { - if (!theme.custom) { - [unsyncedCustomThemes removeObject:theme]; + NSArray * __block themeObjectIDs = nil; + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Blog *blogInContext = [context existingObjectWithID:blog.objectID error:nil]; + + NSMutableArray *validRemoteThemes = [NSMutableArray array]; + // We need to filter out themes with an id ending in -wpcom to match Calypso + for (RemoteTheme *remoteTheme in remoteThemes) { + if (![ThemeIdHelper themeIdHasWPComSuffix:remoteTheme.themeId]) { + [validRemoteThemes addObject:remoteTheme]; } } - [unsyncedCustomThemes minusSet:[NSSet setWithArray:themes]]; - for (Theme *deleteTheme in unsyncedCustomThemes) { - if (![blog.currentThemeId isEqualToString:deleteTheme.themeId]) { - [self.managedObjectContext deleteObject:deleteTheme]; + + NSArray *themes = [self customThemesFromRemoteThemes:validRemoteThemes + forBlog:blogInContext + inContext:context]; + if (sync) { + // We don't want to touch WP.com themes here, only custom themes + NSMutableSet *unsyncedThemes = [NSMutableSet setWithSet:blogInContext.themes]; + NSMutableSet *unsyncedCustomThemes = [unsyncedThemes mutableCopy]; + for (Theme *theme in unsyncedThemes) { + if (!theme.custom) { + [unsyncedCustomThemes removeObject:theme]; + } + } + [unsyncedCustomThemes minusSet:[NSSet setWithArray:themes]]; + for (Theme *deleteTheme in unsyncedCustomThemes) { + if (![blogInContext.currentThemeId isEqualToString:deleteTheme.themeId]) { + [context deleteObject:deleteTheme]; + } } } - } - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ + [context obtainPermanentIDsForObjects:themes error:nil]; + themeObjectIDs = [themes wp_map:^id(Theme *obj) { + return obj.objectID; + }]; + } completion:^{ if (success) { - success(themes, hasMore, themes.count); + NSArray *themes = [themeObjectIDs wp_map:^id(NSManagedObjectID *objectID) { + return [self.coreDataStack.mainContext existingObjectWithID:objectID error:nil]; + }]; + success(themes, hasMore, totalThemeCount); } - }]; + } onQueue:dispatch_get_main_queue()]; } failure:failure]; } -- (void)getStartingThemesForCategory:(NSString *)category - page:(NSInteger)page - success:(ThemeServiceThemesRequestSuccessBlock)success - failure:(ThemeServiceFailureBlock)failure -{ - NSParameterAssert(page > 0); - NSParameterAssert([category isKindOfClass:[NSString class]]); - - WordPressComRestApi *api = [WordPressComRestApi defaultApiWithOAuthToken:nil userAgent:nil localeKey:[WordPressComRestApi LocaleKeyDefault]]; - ThemeServiceRemote *remote = [[ThemeServiceRemote alloc] initWithWordPressComRestApi:api]; - - [remote getStartingThemesForCategory:category - page:page - success:^(NSArray<RemoteTheme *> *remoteThemes, BOOL hasMore, NSInteger totalThemeCount) { - NSArray *themes = [self themesFromRemoteThemes:remoteThemes - forBlog:nil]; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ - if (success) { - success(themes, hasMore, themes.count); - } - }]; - } failure:failure]; -} - #pragma mark - Remote queries: Activating themes - (NSProgress *)activateTheme:(Theme *)theme @@ -416,7 +325,7 @@ - (NSProgress *)activateTheme:(Theme *)theme NSString *themeIdWithWPComSuffix = [ThemeIdHelper themeIdWithWPComSuffix:theme.themeId]; return [remote installThemeId:themeIdWithWPComSuffix forBlogId:[blog dotComID] - success:^(RemoteTheme *remoteTheme) { + success:^(RemoteTheme * __unused remoteTheme) { [self activateThemeId:themeIdWithWPComSuffix forBlog:blog success:^(){ @@ -425,7 +334,7 @@ - (NSProgress *)activateTheme:(Theme *)theme success:success]; } failure:failure]; - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { // There's no way to know from the WP.com theme list if the theme was already // installed, BUT trying to install an already installed theme returns an error, // so regardless we are trying to activate. Calypso does this same thing. @@ -457,7 +366,7 @@ - (NSProgress *)activateThemeId:(NSString *)themeId return [remote activateThemeId:themeId forBlogId:[blog dotComID] - success:^(RemoteTheme *remoteTheme) { + success:^(RemoteTheme * __unused remoteTheme) { if (success) { success(); } @@ -468,12 +377,19 @@ - (void)themeActivatedSuccessfully:(Theme *)theme forBlog:(Blog *)blog success:(ThemeServiceThemeRequestSuccessBlock)success { - blog.currentThemeId = theme.themeId; - [[ContextManager sharedInstance] saveContext:self.managedObjectContext withCompletionBlock:^{ + [self.coreDataStack performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + Theme *themeInContext = [context existingObjectWithID:theme.objectID error:nil]; + if (themeInContext == nil) { + return; + } + + Blog *blogInContext = [context existingObjectWithID:blog.objectID error:nil]; + blogInContext.currentThemeId = themeInContext.themeId; + } completion:^{ if (success) { - success(theme); + success([self.coreDataStack.mainContext existingObjectWithID:theme.objectID error:nil]); } - }]; + } onQueue:dispatch_get_main_queue()]; } - (RemoteTheme *)removeWPComSuffixIfNeeded:(RemoteTheme *)remoteTheme @@ -516,11 +432,11 @@ - (NSProgress *)installTheme:(Theme *)theme NSString *themeIdWithWPComSuffix = [ThemeIdHelper themeIdWithWPComSuffix:theme.themeId]; return [remote installThemeId:themeIdWithWPComSuffix forBlogId:[blog dotComID] - success:^(RemoteTheme *remoteTheme) { + success:^(RemoteTheme * __unused remoteTheme) { if (success) { success(); } - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { // Since installing a previously installed theme will fail, but there is no // way of knowing if it failed because of that or if the theme was previously installed, // I'm going to go ahead and call success. Calypso does this same thing. I'm sorry. @@ -544,11 +460,11 @@ - (NSProgress *)installTheme:(Theme *)theme */ - (Theme *)themeFromRemoteTheme:(RemoteTheme *)remoteTheme forBlog:(nullable Blog *)blog + inContext:(NSManagedObjectContext *)context { NSParameterAssert([remoteTheme isKindOfClass:[RemoteTheme class]]); - Theme *theme = [self findOrCreateThemeWithId:remoteTheme.themeId - forBlog:blog]; + Theme *theme = [self findOrCreateThemeWithId:remoteTheme.themeId forBlog:blog inContext:context]; if (remoteTheme.author) { theme.author = remoteTheme.author; @@ -596,8 +512,9 @@ - (Theme *)themeFromRemoteTheme:(RemoteTheme *)remoteTheme */ - (NSArray<Theme *> *)themesFromRemoteThemes:(NSArray<RemoteTheme *> *)remoteThemes forBlog:(nullable Blog *)blog + inContext:(NSManagedObjectContext *)context { - return [self themesFromRemoteThemes:remoteThemes custom:NO forBlog:blog]; + return [self themesFromRemoteThemes:remoteThemes custom:NO forBlog:blog inContext:context]; } /** @@ -613,24 +530,25 @@ - (Theme *)themeFromRemoteTheme:(RemoteTheme *)remoteTheme */ - (NSArray<Theme *> *)customThemesFromRemoteThemes:(NSArray<RemoteTheme *> *)remoteThemes forBlog:(nullable Blog *)blog + inContext:(NSManagedObjectContext *)context { - return [self themesFromRemoteThemes:remoteThemes custom:YES forBlog:blog]; + return [self themesFromRemoteThemes:remoteThemes custom:YES forBlog:blog inContext:context]; } - (NSArray<Theme *> *)themesFromRemoteThemes:(NSArray<RemoteTheme *> *)remoteThemes custom:(BOOL)custom forBlog:(nullable Blog *)blog + inContext:(NSManagedObjectContext *)context { NSParameterAssert([remoteThemes isKindOfClass:[NSArray class]]); NSMutableArray *themes = [[NSMutableArray alloc] initWithCapacity:remoteThemes.count]; - [remoteThemes enumerateObjectsUsingBlock:^(RemoteTheme *remoteTheme, NSUInteger idx, BOOL *stop) { + [remoteThemes enumerateObjectsUsingBlock:^(RemoteTheme *remoteTheme, NSUInteger __unused idx, BOOL * __unused stop) { NSAssert([remoteTheme isKindOfClass:[RemoteTheme class]], @"Expected a remote theme."); - Theme *theme = [self themeFromRemoteTheme:remoteTheme - forBlog:blog]; + Theme *theme = [self themeFromRemoteTheme:remoteTheme forBlog:blog inContext:context]; theme.custom = custom; [themes addObject:theme]; }]; diff --git a/WordPress/Classes/Services/TodayExtensionService.m b/WordPress/Classes/Services/TodayExtensionService.m index 784d0b3565b7..edc0442526a4 100644 --- a/WordPress/Classes/Services/TodayExtensionService.m +++ b/WordPress/Classes/Services/TodayExtensionService.m @@ -1,7 +1,6 @@ #import "TodayExtensionService.h" #import <NotificationCenter/NotificationCenter.h> #import "Constants.h" -#import "SFHFKeychainUtils.h" #import "WordPress-Swift.h" @implementation TodayExtensionService @@ -20,22 +19,23 @@ - (void)configureTodayWidgetWithSiteID:(NSNumber *)siteID NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:WPAppGroupName]; // If the widget site has changed, clear the widgets saved data. - NSNumber *previousSiteID = [sharedDefaults objectForKey:WPStatsTodayWidgetUserDefaultsSiteIdKey]; + NSNumber *previousSiteID = [sharedDefaults objectForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteIdKey]; if (siteID != previousSiteID) { [StatsDataHelper clearWidgetsData]; [WPAnalytics track:WPAnalyticsStatWidgetActiveSiteChanged]; } // Save the site information to shared user defaults for use in the today widgets. - [sharedDefaults setObject:timeZone.name forKey:WPStatsTodayWidgetUserDefaultsSiteTimeZoneKey]; - [sharedDefaults setObject:siteID forKey:WPStatsTodayWidgetUserDefaultsSiteIdKey]; - [sharedDefaults setObject:blogName forKey:WPStatsTodayWidgetUserDefaultsSiteNameKey]; - [sharedDefaults setObject:blogUrl forKey:WPStatsTodayWidgetUserDefaultsSiteUrlKey]; + [sharedDefaults setObject:timeZone.name forKey:AppConfigurationWidgetStatsToday.userDefaultsSiteTimeZoneKey]; + [sharedDefaults setObject:siteID forKey:AppConfigurationWidgetStatsToday.userDefaultsSiteIdKey]; + [sharedDefaults setObject:blogName forKey:AppConfigurationWidgetStatsToday.userDefaultsSiteNameKey]; + [sharedDefaults setObject:blogUrl forKey:AppConfigurationWidgetStatsToday.userDefaultsSiteUrlKey]; NSError *error; - [SFHFKeychainUtils storeUsername:WPStatsTodayWidgetKeychainTokenKey + + [SFHFKeychainUtils storeUsername:AppConfigurationWidgetStats.keychainTokenKey andPassword:oauth2Token - forServiceName:WPStatsTodayWidgetKeychainServiceName + forServiceName:AppConfigurationWidgetStats.keychainServiceName accessGroup:WPAppKeychainAccessGroup updateExisting:YES error:&error]; @@ -48,13 +48,13 @@ - (void)removeTodayWidgetConfiguration { NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:WPAppGroupName]; - [sharedDefaults removeObjectForKey:WPStatsTodayWidgetUserDefaultsSiteTimeZoneKey]; - [sharedDefaults removeObjectForKey:WPStatsTodayWidgetUserDefaultsSiteIdKey]; - [sharedDefaults removeObjectForKey:WPStatsTodayWidgetUserDefaultsSiteNameKey]; - [sharedDefaults removeObjectForKey:WPStatsTodayWidgetUserDefaultsSiteUrlKey]; + [sharedDefaults removeObjectForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteTimeZoneKey]; + [sharedDefaults removeObjectForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteIdKey]; + [sharedDefaults removeObjectForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteNameKey]; + [sharedDefaults removeObjectForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteUrlKey]; - [SFHFKeychainUtils deleteItemForUsername:WPStatsTodayWidgetKeychainTokenKey - andServiceName:WPStatsTodayWidgetKeychainServiceName + [SFHFKeychainUtils deleteItemForUsername:AppConfigurationWidgetStats.keychainTokenKey + andServiceName:AppConfigurationWidgetStats.keychainServiceName accessGroup:WPAppKeychainAccessGroup error:nil]; } @@ -62,9 +62,9 @@ - (void)removeTodayWidgetConfiguration - (BOOL)widgetIsConfigured { NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:WPAppGroupName]; - NSString *siteId = [sharedDefaults stringForKey:WPStatsTodayWidgetUserDefaultsSiteIdKey]; - NSString *oauth2Token = [SFHFKeychainUtils getPasswordForUsername:WPStatsTodayWidgetKeychainTokenKey - andServiceName:WPStatsTodayWidgetKeychainServiceName + NSString *siteId = [sharedDefaults stringForKey:AppConfigurationWidgetStatsToday.userDefaultsSiteIdKey]; + NSString *oauth2Token = [SFHFKeychainUtils getPasswordForUsername:AppConfigurationWidgetStats.keychainTokenKey + andServiceName:AppConfigurationWidgetStats.keychainServiceName accessGroup:WPAppKeychainAccessGroup error:nil]; diff --git a/WordPress/Classes/Services/WordPressComSyncService.swift b/WordPress/Classes/Services/WordPressComSyncService.swift index 4d621c9e9f64..d5b45eaf86a4 100644 --- a/WordPress/Classes/Services/WordPressComSyncService.swift +++ b/WordPress/Classes/Services/WordPressComSyncService.swift @@ -15,8 +15,7 @@ class WordPressComSyncService { /// - onFailure: Closure to be executed upon failure. /// func syncWPCom(authToken: String, isJetpackLogin: Bool, onSuccess: @escaping (WPAccount) -> Void, onFailure: @escaping (Error) -> Void) { - let context = ContextManager.sharedInstance().mainContext - let accountService = AccountService(managedObjectContext: context) + let accountService = AccountService(coreDataStack: ContextManager.sharedInstance()) accountService.createOrUpdateAccount(withAuthToken: authToken, success: { account in self.syncOrAssociateBlogs(account: account, isJetpackLogin: isJetpackLogin, onSuccess: onSuccess, onFailure: onFailure) }, failure: { error in @@ -33,8 +32,7 @@ class WordPressComSyncService { /// - onFailure: Failure block /// func syncOrAssociateBlogs(account: WPAccount, isJetpackLogin: Bool, onSuccess: @escaping (WPAccount) -> Void, onFailure: @escaping (Error) -> Void) { - let context = ContextManager.sharedInstance().mainContext - let accountService = AccountService(managedObjectContext: context) + let accountService = AccountService(coreDataStack: ContextManager.sharedInstance()) let onFailureInternal = { (error: Error) in /// At this point the user is authed and there is a valid account in core data. Make a note of the error and just dismiss @@ -48,12 +46,12 @@ class WordPressComSyncService { onSuccess(account) } - if isJetpackLogin && !accountService.isDefaultWordPressComAccount(account) { - let blogService = BlogService(managedObjectContext: context) + if isJetpackLogin && !account.isDefaultWordPressComAccount { + let blogService = BlogService(coreDataStack: ContextManager.shared) blogService.associateSyncedBlogs(toJetpackAccount: account, success: onSuccessInternal, failure: onFailureInternal) } else { - if accountService.defaultWordPressComAccount()?.isEqual(account) == false { + if !account.isDefaultWordPressComAccount { accountService.removeDefaultWordPressComAccount() } diff --git a/WordPress/Classes/Stores/ActivityStore.swift b/WordPress/Classes/Stores/ActivityStore.swift index 16796581d752..a55f6192c50f 100644 --- a/WordPress/Classes/Stores/ActivityStore.swift +++ b/WordPress/Classes/Stores/ActivityStore.swift @@ -5,9 +5,11 @@ import WordPressFlux // MARK: - Store helper types enum ActivityAction: Action { - case refreshActivities(site: JetpackSiteRef) - case receiveActivities(site: JetpackSiteRef, activities: [Activity]) + case refreshActivities(site: JetpackSiteRef, quantity: Int, afterDate: Date?, beforeDate: Date?, group: [String]) + case loadMoreActivities(site: JetpackSiteRef, quantity: Int, offset: Int, afterDate: Date?, beforeDate: Date?, group: [String]) + case receiveActivities(site: JetpackSiteRef, activities: [Activity], hasMore: Bool, loadingMore: Bool) case receiveActivitiesFailed(site: JetpackSiteRef, error: Error) + case resetActivities(site: JetpackSiteRef) case rewind(site: JetpackSiteRef, rewindID: String) case rewindStarted(site: JetpackSiteRef, rewindID: String, restoreID: String) @@ -18,11 +20,21 @@ enum ActivityAction: Action { case rewindStatusUpdated(site: JetpackSiteRef, status: RewindStatus) case rewindStatusUpdateFailed(site: JetpackSiteRef, error: Error) case rewindStatusUpdateTimedOut(site: JetpackSiteRef) + + case refreshBackupStatus(site: JetpackSiteRef) + case backupStatusUpdated(site: JetpackSiteRef, status: JetpackBackup) + case backupStatusUpdateFailed(site: JetpackSiteRef, error: Error) + case backupStatusUpdateTimedOut(site: JetpackSiteRef) + case dismissBackupNotice(site: JetpackSiteRef, downloadID: Int) + + case refreshGroups(site: JetpackSiteRef, afterDate: Date?, beforeDate: Date?) + case resetGroups(site: JetpackSiteRef) } enum ActivityQuery { case activities(site: JetpackSiteRef) case restoreStatus(site: JetpackSiteRef) + case backupStatus(site: JetpackSiteRef) var site: JetpackSiteRef { switch self { @@ -30,6 +42,8 @@ enum ActivityQuery { return site case .restoreStatus(let site): return site + case .backupStatus(let site): + return site } } } @@ -38,12 +52,20 @@ struct ActivityStoreState { var activities = [JetpackSiteRef: [Activity]]() var lastFetch = [JetpackSiteRef: Date]() var fetchingActivities = [JetpackSiteRef: Bool]() + var hasMore = false + + var groups = [JetpackSiteRef: [ActivityGroup]]() + var fetchingGroups = [JetpackSiteRef: Bool]() var rewindStatus = [JetpackSiteRef: RewindStatus]() var fetchingRewindStatus = [JetpackSiteRef: Bool]() + var backupStatus = [JetpackSiteRef: JetpackBackup]() + var fetchingBackupStatus = [JetpackSiteRef: Bool]() + // This needs to be `fileprivate` because `DelayStateWrapper` is private. fileprivate var rewindStatusRetries = [JetpackSiteRef: DelayStateWrapper]() + fileprivate var backupStatusRetries = [JetpackSiteRef: DelayStateWrapper]() } @@ -53,21 +75,34 @@ private enum Constants { static let maxRetries = 12 } -private enum ActivityStoreError: Error { +enum ActivityStoreError: Error { case rewindAlreadyRunning } class ActivityStore: QueryStore<ActivityStoreState, ActivityQuery> { - fileprivate let refreshInterval: TimeInterval = 60 // seconds + private let refreshInterval: TimeInterval = 60 + + private let activityServiceRemote: ActivityServiceRemote? + + private let backupService: JetpackBackupService + + /// When set to true, this store will only return items that are restorable + var onlyRestorableItems = false + + var numberOfItemsPerPage = 20 override func queriesChanged() { super.queriesChanged() processQueries() } - init() { - super.init(initialState: ActivityStoreState()) + init(dispatcher: ActionDispatcher = .global, + activityServiceRemote: ActivityServiceRemote? = nil, + backupService: JetpackBackupService? = nil) { + self.activityServiceRemote = activityServiceRemote + self.backupService = backupService ?? JetpackBackupService(coreDataStack: ContextManager.sharedInstance()) + super.init(initialState: ActivityStoreState(), dispatcher: dispatcher) } override func logError(_ error: String) { @@ -79,17 +114,19 @@ class ActivityStore: QueryStore<ActivityStoreState, ActivityQuery> { transaction { state in state.activities = [:] state.rewindStatus = [:] + state.backupStatus = [:] state.rewindStatusRetries = [:] state.lastFetch = [:] state.fetchingActivities = [:] state.fetchingRewindStatus = [:] + state.fetchingBackupStatus = [:] } return } // Fetching Activities. sitesToFetch - .forEach { fetchActivities(site: $0) } + .forEach { fetchActivities(site: $0, count: numberOfItemsPerPage) } // Fetching Status @@ -99,6 +136,12 @@ class ActivityStore: QueryStore<ActivityStoreState, ActivityQuery> { fetchRewindStatus(site: $0) } + // Fetching Backup Status + sitesStatusesToFetch + .filter { state.fetchingBackupStatus[$0] != true } + .forEach { + fetchBackupStatus(site: $0) + } } private var sitesToFetch: [JetpackSiteRef] { return activeQueries @@ -119,6 +162,8 @@ class ActivityStore: QueryStore<ActivityStoreState, ActivityQuery> { .filter { if case .restoreStatus = $0 { return true + } else if case .backupStatus = $0 { + return true } else { return false } @@ -130,26 +175,34 @@ class ActivityStore: QueryStore<ActivityStoreState, ActivityQuery> { func shouldFetch(site: JetpackSiteRef) -> Bool { let lastFetch = state.lastFetch[site, default: .distantPast] let needsRefresh = lastFetch + refreshInterval < Date() - let currentlyFetching = isFetching(site: site) + let currentlyFetching = isFetchingActivities(site: site) return needsRefresh && !currentlyFetching } - func isFetching(site: JetpackSiteRef) -> Bool { + func isFetchingActivities(site: JetpackSiteRef) -> Bool { return state.fetchingActivities[site, default: false] } + func isFetchingGroups(site: JetpackSiteRef) -> Bool { + return state.fetchingGroups[site, default: false] + } + override func onDispatch(_ action: Action) { guard let activityAction = action as? ActivityAction else { return } switch activityAction { - case .receiveActivities(let site, let activities): - receiveActivities(site: site, activities: activities) + case .receiveActivities(let site, let activities, let hasMore, let loadingMore): + receiveActivities(site: site, activities: activities, hasMore: hasMore, loadingMore: loadingMore) + case .loadMoreActivities(let site, let quantity, let offset, let afterDate, let beforeDate, let group): + loadMoreActivities(site: site, quantity: quantity, offset: offset, afterDate: afterDate, beforeDate: beforeDate, group: group) case .receiveActivitiesFailed(let site, let error): receiveActivitiesFailed(site: site, error: error) - case .refreshActivities(let site): - refreshActivities(site: site) + case .refreshActivities(let site, let quantity, let afterDate, let beforeDate, let group): + refreshActivities(site: site, quantity: quantity, afterDate: afterDate, beforeDate: beforeDate, group: group) + case .resetActivities(let site): + resetActivities(site: site) case .rewind(let site, let rewindID): rewind(site: site, rewindID: rewindID) case .rewindStarted(let site, let rewindID, let restoreID): @@ -174,6 +227,29 @@ class ActivityStore: QueryStore<ActivityStoreState, ActivityQuery> { comment: "Text displayed when a site restore takes too long.")) actionDispatcher.dispatch(NoticeAction.post(notice)) } + case .refreshGroups(let site, let afterDate, let beforeDate): + refreshGroups(site: site, afterDate: afterDate, beforeDate: beforeDate) + case .resetGroups(let site): + resetGroups(site: site) + case .refreshBackupStatus(let site): + fetchBackupStatus(site: site) + case .backupStatusUpdated(let site, let status): + backupStatusUpdated(site: site, status: status) + case .backupStatusUpdateFailed(let site, _): + delayedRetryFetchBackupStatus(site: site) + case .backupStatusUpdateTimedOut(let site): + transaction { state in + state.fetchingBackupStatus[site] = false + state.backupStatusRetries[site] = nil + } + + if shouldPostStateUpdates(for: site) { + let notice = Notice(title: NSLocalizedString("Your backup is taking longer than usual, please check again in a few minutes.", + comment: "Text displayed when a site backup takes too long.")) + actionDispatcher.dispatch(NoticeAction.post(notice)) + } + case .dismissBackupNotice(let site, let downloadID): + dismissBackupNotice(site: site, downloadID: downloadID) } } } @@ -183,35 +259,104 @@ extension ActivityStore { return state.activities[site] ?? nil } + func getGroups(site: JetpackSiteRef) -> [ActivityGroup]? { + return state.groups[site] ?? nil + } + func getActivity(site: JetpackSiteRef, rewindID: String) -> Activity? { return getActivities(site: site)?.filter { $0.rewindID == rewindID }.first } - func getRewindStatus(site: JetpackSiteRef) -> RewindStatus? { + func getCurrentRewindStatus(site: JetpackSiteRef) -> RewindStatus? { return state.rewindStatus[site] ?? nil } + + func getBackupStatus(site: JetpackSiteRef) -> JetpackBackup? { + return state.backupStatus[site] ?? nil + } + + func isRestoreAlreadyRunning(site: JetpackSiteRef) -> Bool { + let currentStatus = getCurrentRewindStatus(site: site) + let restoreStatus = currentStatus?.restore?.status + return currentStatus != nil && (restoreStatus == .running || restoreStatus == .queued) + } + + func isAwaitingCredentials(site: JetpackSiteRef) -> Bool { + let currentStatus = getCurrentRewindStatus(site: site) + return currentStatus?.state == .awaitingCredentials + } + + func fetchRewindStatus(site: JetpackSiteRef) { + state.fetchingRewindStatus[site] = true + + remote(site: site)?.getRewindStatus( + site.siteID, + success: { [actionDispatcher] rewindStatus in + actionDispatcher.dispatch(ActivityAction.rewindStatusUpdated(site: site, status: rewindStatus)) + }, + failure: { [actionDispatcher] error in + actionDispatcher.dispatch(ActivityAction.rewindStatusUpdateFailed(site: site, error: error)) + }) + } + + func fetchBackupStatus(site: JetpackSiteRef) { + state.fetchingBackupStatus[site] = true + + backupService.getAllBackupStatus(for: site, success: { [actionDispatcher] backupsStatus in + guard let status = backupsStatus.first else { + return + } + + actionDispatcher.dispatch(ActivityAction.backupStatusUpdated(site: site, status: status)) + }, failure: { [actionDispatcher] error in + actionDispatcher.dispatch(ActivityAction.backupStatusUpdateFailed(site: site, error: error)) + }) + } + } private extension ActivityStore { - func fetchActivities(site: JetpackSiteRef, count: Int = 1000) { + func fetchActivities(site: JetpackSiteRef, + count: Int, + offset: Int = 0, + afterDate: Date? = nil, + beforeDate: Date? = nil, + group: [String] = []) { state.fetchingActivities[site] = true remote(site: site)?.getActivityForSite( site.siteID, + offset: offset, count: count, - success: { [actionDispatcher] (activities, _ /* hasMore */) in - actionDispatcher.dispatch(ActivityAction.receiveActivities(site: site, activities: activities)) + after: afterDate, + before: beforeDate, + group: group, + success: { [weak self, actionDispatcher] (activities, hasMore) in + guard let self = self else { + return + } + + let loadingMore = offset > 0 + actionDispatcher.dispatch( + ActivityAction.receiveActivities( + site: site, + activities: self.onlyRestorableItems ? activities.filter { $0.isRewindable } : activities, + hasMore: hasMore, + loadingMore: loadingMore) + ) }, failure: { [actionDispatcher] error in actionDispatcher.dispatch(ActivityAction.receiveActivitiesFailed(site: site, error: error)) }) } - func receiveActivities(site: JetpackSiteRef, activities: [Activity]) { + func receiveActivities(site: JetpackSiteRef, activities: [Activity], hasMore: Bool = false, loadingMore: Bool = false) { transaction { state in - state.activities[site] = activities + let allActivities = loadingMore ? (state.activities[site] ?? []) + activities : activities + state.activities[site] = allActivities state.fetchingActivities[site] = false state.lastFetch[site] = Date() + state.hasMore = hasMore } } @@ -222,17 +367,33 @@ private extension ActivityStore { } } - func refreshActivities(site: JetpackSiteRef) { - guard !isFetching(site: site) else { + func refreshActivities(site: JetpackSiteRef, quantity: Int, afterDate: Date?, beforeDate: Date?, group: [String]) { + guard !isFetchingActivities(site: site) else { DDLogInfo("Activity Log refresh triggered while one was in progress") return } - fetchActivities(site: site) + fetchActivities(site: site, count: quantity, afterDate: afterDate, beforeDate: beforeDate, group: group) + } + + func loadMoreActivities(site: JetpackSiteRef, quantity: Int, offset: Int, afterDate: Date?, beforeDate: Date?, group: [String]) { + guard !isFetchingActivities(site: site) else { + DDLogInfo("Activity Log refresh triggered while one was in progress") + return + } + fetchActivities(site: site, count: quantity, offset: offset, afterDate: afterDate, beforeDate: beforeDate, group: group) + } + + func resetActivities(site: JetpackSiteRef) { + transaction { state in + state.activities[site] = [] + state.fetchingActivities[site] = false + state.lastFetch[site] = Date() + state.hasMore = false + } } func rewind(site: JetpackSiteRef, rewindID: String) { - let currentStatus = getRewindStatus(site: site) - guard currentStatus == nil || (currentStatus?.restore?.status != .running && currentStatus?.restore?.status != .queued) else { + if isRestoreAlreadyRunning(site: site) { actionDispatcher.dispatch(ActivityAction.rewindRequestFailed(site: site, error: ActivityStoreError.rewindAlreadyRunning)) return } @@ -240,7 +401,7 @@ private extension ActivityStore { remoteV1(site: site)?.restoreSite( site.siteID, rewindID: rewindID, - success: { [actionDispatcher] restoreID in + success: { [actionDispatcher] restoreID, _ in actionDispatcher.dispatch(ActivityAction.rewindStarted(site: site, rewindID: rewindID, restoreID: restoreID)) }, failure: { [actionDispatcher] error in @@ -253,12 +414,12 @@ private extension ActivityStore { let notice: Notice let title = NSLocalizedString("Your site is being restored", - comment: "Title of a message displayed when user starts a rewind operation") + comment: "Title of a message displayed when user starts a restore operation") if let activity = getActivity(site: site, rewindID: rewindID) { let formattedString = mediumString(from: activity.published, adjustingTimezoneTo: site) - let message = String(format: NSLocalizedString("Rewinding to %@", comment: "Notice showing the date the site is being rewinded to. '%@' is a placeholder that will expand to a date."), formattedString) + let message = String(format: NSLocalizedString("Restoring to %@", comment: "Notice showing the date the site is being restored to. '%@' is a placeholder that will expand to a date."), formattedString) notice = Notice(title: title, message: message) } else { notice = Notice(title: title) @@ -280,7 +441,7 @@ private extension ActivityStore { if let activity = getActivity(site: site, rewindID: restoreID) { let formattedString = mediumString(from: activity.published, adjustingTimezoneTo: site) - let message = String(format: NSLocalizedString("Rewound to %@", comment: "Notice showing the date the site is being rewinded to. '%@' is a placeholder that will expand to a date."), formattedString) + let message = String(format: NSLocalizedString("Restored to %@", comment: "Notice showing the date the site is being rewinded to. '%@' is a placeholder that will expand to a date."), formattedString) notice = Notice(title: title, message: message) } else { notice = Notice(title: title) @@ -305,19 +466,6 @@ private extension ActivityStore { actionDispatcher.dispatch(noticeAction) } - func fetchRewindStatus(site: JetpackSiteRef) { - state.fetchingRewindStatus[site] = true - - remote(site: site)?.getRewindStatus( - site.siteID, - success: { [actionDispatcher] rewindStatus in - actionDispatcher.dispatch(ActivityAction.rewindStatusUpdated(site: site, status: rewindStatus)) - }, - failure: { [actionDispatcher] error in - actionDispatcher.dispatch(ActivityAction.rewindStatusUpdateFailed(site: site, error: error)) - }) - } - func delayedRetryFetchRewindStatus(site: JetpackSiteRef) { guard sitesStatusesToFetch.contains(site) == false else { // if we still have an active query asking about status of this site (e.g. it's still visible on screen) @@ -352,6 +500,38 @@ private extension ActivityStore { state.rewindStatusRetries[site] = existingWrapper } + func delayedRetryFetchBackupStatus(site: JetpackSiteRef) { + guard sitesStatusesToFetch.contains(site) == false else { + _ = DispatchDelayedAction(delay: .seconds(Constants.delaySequence.last!)) { [weak self] in + self?.fetchBackupStatus(site: site) + } + return + } + + guard var existingWrapper = state.backupStatusRetries[site] else { + let newDelayWrapper = DelayStateWrapper(delaySequence: Constants.delaySequence) { [weak self] in + self?.fetchBackupStatus(site: site) + } + + state.backupStatusRetries[site] = newDelayWrapper + return + } + + guard existingWrapper.retryAttempt < Constants.maxRetries else { + existingWrapper.delayedRetryAction.cancel() + actionDispatcher.dispatch(ActivityAction.backupStatusUpdateTimedOut(site: site)) + return + } + + existingWrapper.increment() + state.backupStatusRetries[site] = existingWrapper + } + + func dismissBackupNotice(site: JetpackSiteRef, downloadID: Int) { + backupService.dismissBackupNotice(site: site, downloadID: downloadID) + state.backupStatus[site] = nil + } + func rewindStatusUpdated(site: JetpackSiteRef, status: RewindStatus) { state.rewindStatus[site] = status @@ -373,9 +553,65 @@ private extension ActivityStore { } } + func backupStatusUpdated(site: JetpackSiteRef, status: JetpackBackup) { + state.backupStatus[site] = status + + if let progress = status.progress, progress > 0 { + delayedRetryFetchBackupStatus(site: site) + } + } + + func refreshGroups(site: JetpackSiteRef, afterDate: Date?, beforeDate: Date?) { + guard !isFetchingGroups(site: site) else { + DDLogInfo("Activity Log fetch groups triggered while one was in progress") + return + } + + state.fetchingGroups[site] = true + + if state.groups[site]?.isEmpty ?? true { + remote(site: site)?.getActivityGroupsForSite( + site.siteID, + after: afterDate, + before: beforeDate, + success: { [weak self] groups in + self?.receiveGroups(site: site, groups: groups) + }, failure: { [weak self] error in + self?.failedGroups(site: site) + }) + } else { + receiveGroups(site: site, groups: state.groups[site] ?? []) + } + } + + func receiveGroups(site: JetpackSiteRef, groups: [ActivityGroup]) { + transaction { state in + state.fetchingGroups[site] = false + state.groups[site] = groups.sorted { $0.count > $1.count } + } + } + + func failedGroups(site: JetpackSiteRef) { + transaction { state in + state.fetchingGroups[site] = false + state.groups[site] = nil + } + } + + func resetGroups(site: JetpackSiteRef) { + transaction { state in + state.groups[site] = [] + state.fetchingGroups[site] = false + } + } + // MARK: - Helpers func remote(site: JetpackSiteRef) -> ActivityServiceRemote? { + guard activityServiceRemote == nil else { + return activityServiceRemote + } + guard let token = CredentialsService().getOAuthToken(site: site) else { return nil } @@ -408,7 +644,7 @@ private extension ActivityStore { // we're gonna show users "hey, your rewind finished!". But if the only thing we know the restore is // that it has finished in a recent past, we don't do anything special. - return getRewindStatus(site: site)?.restore?.status == .running || - getRewindStatus(site: site)?.restore?.status == .queued + return getCurrentRewindStatus(site: site)?.restore?.status == .running || + getCurrentRewindStatus(site: site)?.restore?.status == .queued } } diff --git a/WordPress/Classes/Stores/NoticeStore.swift b/WordPress/Classes/Stores/NoticeStore.swift index d3e3f48e7b11..8cf50f1bdaaa 100644 --- a/WordPress/Classes/Stores/NoticeStore.swift +++ b/WordPress/Classes/Stores/NoticeStore.swift @@ -103,6 +103,18 @@ struct NoticeNotificationInfo { } } +/// Objective-C bridge for ActionDispatcher specific for notices. +/// +class NoticesDispatch: NSObject { + @objc static func lock() -> Void { + ActionDispatcher.dispatch(NoticeAction.lock) + } + + @objc static func unlock() -> Void { + ActionDispatcher.dispatch(NoticeAction.unlock) + } +} + /// NoticeActions can be posted to control or report the display of notices. /// enum NoticeAction: Action { @@ -120,7 +132,7 @@ enum NoticeAction: Action { /// 4. MediaBrowser dispatches `dismiss` which dismisses **NoticeB**! /// /// If MediaBrowser used `clear` or `clearWithTag`, the NoticeB should not have been dismissed - /// prematurely. + /// prematurely. case dismiss /// Removes the given `Notice` from the Store. case clear(Notice) @@ -130,6 +142,10 @@ enum NoticeAction: Action { case clearWithTag(Notice.Tag) /// Removes all Notices except the current one. case empty + // Prevents the notices from showing up untill an unlock action. + case lock + // Show the missed notices. + case unlock } @@ -150,6 +166,7 @@ struct NoticeStoreState { /// - SeeAlso: `NoticeAction` class NoticeStore: StatefulStore<NoticeStoreState> { private var pending = Queue<Notice>() + private var storeLocked = false init(dispatcher: ActionDispatcher = .global) { super.init(initialState: NoticeStoreState(), dispatcher: dispatcher) @@ -170,6 +187,10 @@ class NoticeStore: StatefulStore<NoticeStoreState> { dequeueNotice() case .empty: emptyQueue() + case .lock: + lock() + case .unlock: + unlock() } } @@ -183,7 +204,7 @@ class NoticeStore: StatefulStore<NoticeStoreState> { // MARK: - Action handlers private func enqueueNotice(_ notice: Notice) { - if state.notice == nil { + if state.notice == nil && !storeLocked { state.notice = notice } else { pending.push(notice) @@ -191,7 +212,25 @@ class NoticeStore: StatefulStore<NoticeStoreState> { } private func dequeueNotice() { - state.notice = pending.pop() + if !storeLocked { + state.notice = pending.pop() + } + } + + private func lock() { + if storeLocked { + return + } + state.notice = nil + storeLocked = true + } + + private func unlock() { + if !storeLocked { + return + } + storeLocked = false + dequeueNotice() } private func clear(notice: Notice) { diff --git a/WordPress/Classes/Stores/PluginStore.swift b/WordPress/Classes/Stores/PluginStore.swift index b54fc377835a..4dc3ec483e52 100644 --- a/WordPress/Classes/Stores/PluginStore.swift +++ b/WordPress/Classes/Stores/PluginStore.swift @@ -1,5 +1,6 @@ import Foundation import WordPressFlux +import WordPressKit enum PluginAction: Action { case activate(id: String, site: JetpackSiteRef) @@ -340,7 +341,7 @@ extension PluginStore { } func getPlugin(slug: String, site: JetpackSiteRef) -> Plugin? { - return getPlugins(site: site)?.plugins.first(where: { $0.state.slug == slug }) + return getPlugins(site: site)?.plugins.first(where: { $0.state.slug.hasPrefix(slug) }) } func getFeaturedPlugins() -> [PluginDirectoryEntry]? { @@ -414,11 +415,10 @@ private extension PluginStore { plugin.active = true } - WPAppAnalytics.track(.pluginActivated, withBlogID: site.siteID as NSNumber) + track(.pluginActivated, with: site) remote(site: site)?.activatePlugin( - pluginID: pluginID, - siteID: site.siteID, + pluginID: plugin.state.id, success: {}, failure: { [weak self] (error) in let message = String(format: NSLocalizedString("Error activating %@.", comment: "There was an error activating a plugin, placeholder is the plugin name"), plugin.name) @@ -437,11 +437,10 @@ private extension PluginStore { plugin.active = false } - WPAppAnalytics.track(.pluginDeactivated, withBlogID: site.siteID as NSNumber) + track(.pluginDeactivated, with: site) remote(site: site)?.deactivatePlugin( - pluginID: pluginID, - siteID: site.siteID, + pluginID: plugin.state.id, success: {}, failure: { [weak self] (error) in let message = String(format: NSLocalizedString("Error deactivating %@.", comment: "There was an error deactivating a plugin, placeholder is the plugin name"), plugin.name) @@ -460,11 +459,10 @@ private extension PluginStore { plugin.autoupdate = true } - WPAppAnalytics.track(.pluginAutoupdateEnabled, withBlogID: site.siteID as NSNumber) + track(.pluginAutoupdateEnabled, with: site) remote(site: site)?.enableAutoupdates( - pluginID: pluginID, - siteID: site.siteID, + pluginID: plugin.state.id, success: {}, failure: { [weak self] (error) in let message = String(format: NSLocalizedString("Error enabling autoupdates for %@.", comment: "There was an error enabling autoupdates for a plugin, placeholder is the plugin name"), plugin.name) @@ -483,11 +481,10 @@ private extension PluginStore { plugin.autoupdate = false } - WPAppAnalytics.track(.pluginAutoupdateDisabled, withBlogID: site.siteID as NSNumber) + track(.pluginAutoupdateDisabled, with: site) remote(site: site)?.disableAutoupdates( - pluginID: pluginID, - siteID: site.siteID, + pluginID: plugin.state.id, success: {}, failure: { [weak self] (error) in let message = String(format: NSLocalizedString("Error disabling autoupdates for %@.", comment: "There was an error disabling autoupdates for a plugin, placeholder is the plugin name"), plugin.name) @@ -499,15 +496,14 @@ private extension PluginStore { } func activateAndEnableAutoupdatesPlugin(pluginID: String, site: JetpackSiteRef) { - guard getPlugin(id: pluginID, site: site) != nil else { + guard let plugin = getPlugin(id: pluginID, site: site) else { return } state.modifyPlugin(id: pluginID, site: site) { plugin in plugin.autoupdate = true plugin.active = true } - remote(site: site)?.activateAndEnableAutoupdated(pluginID: pluginID, - siteID: site.siteID, + remote(site: site)?.activateAndEnableAutoupdates(pluginID: plugin.state.id, success: {}, failure: { [weak self] error in self?.state.modifyPlugin(id: pluginID, site: site) { plugin in @@ -524,15 +520,13 @@ private extension PluginStore { } state.updatesInProgress[site, default: Set()].insert(plugin.slug) - WPAppAnalytics.track(.pluginInstalled, withBlogID: site.siteID as NSNumber) - + track(.pluginInstalled, with: site) remote.install( pluginSlug: plugin.slug, - siteID: site.siteID, success: { [weak self] installedPlugin in self?.transaction { state in state.upsertPlugin(id: installedPlugin.id, site: site, newPlugin: installedPlugin) - state.updatesInProgress[site]?.remove(installedPlugin.slug) + state.updatesInProgress[site]?.remove(installedPlugin.id) } let message = String(format: NSLocalizedString("Successfully installed %@.", comment: "Notice displayed after installing a plug-in."), installedPlugin.name) @@ -559,10 +553,10 @@ private extension PluginStore { plugin.updateState = .updating(version) }) } - WPAppAnalytics.track(.pluginUpdated, withBlogID: site.siteID as NSNumber) + track(.pluginUpdated, with: site) + remote(site: site)?.updatePlugin( - pluginID: pluginID, - siteID: site.siteID, + pluginID: plugin.state.id, success: { [weak self] (plugin) in self?.transaction({ (state) in state.modifyPlugin(id: pluginID, site: site, change: { (updatedPlugin) in @@ -590,7 +584,7 @@ private extension PluginStore { return } state.plugins[site]?.plugins.remove(at: index) - WPAppAnalytics.track(.pluginRemoved, withBlogID: site.siteID as NSNumber) + track(.pluginRemoved, with: site) guard let remote = self.remote(site: site) else { return @@ -604,15 +598,13 @@ private extension PluginStore { let remove = { remote.remove( - pluginID: pluginID, - siteID: site.siteID, + pluginID: plugin.state.id, success: {}, failure: failure) } if plugin.state.active { - remote.deactivatePlugin(pluginID: pluginID, - siteID: site.siteID, + remote.deactivatePlugin(pluginID: plugin.state.id, success: remove, failure: failure) } else { @@ -650,7 +642,6 @@ private extension PluginStore { } state.fetching[site] = true remote.getPlugins( - siteID: site.siteID, success: { [actionDispatcher] (plugins) in actionDispatcher.dispatch(PluginAction.receivePlugins(site: site, plugins: plugins)) }, @@ -796,12 +787,36 @@ private extension PluginStore { ActionDispatcher.dispatch(NoticeAction.post(Notice(title: message))) } - func remote(site: JetpackSiteRef) -> PluginServiceRemote? { + func remote(site: JetpackSiteRef) -> PluginManagementClient? { + guard site.isSelfHostedWithoutJetpack else { + return jetpackRemoteClient(site: site) + } + + return selfHostedRemoteClient(site: site) + } + + private func jetpackRemoteClient(site: JetpackSiteRef) -> PluginManagementClient? { guard let token = CredentialsService().getOAuthToken(site: site) else { return nil } + let api = WordPressComRestApi.defaultApi(oAuthToken: token, userAgent: WPUserAgent.wordPress()) + let pluginRemote = PluginServiceRemote(wordPressComRestApi: api) + + return JetpackPluginManagementClient(with: site.siteID, remote: pluginRemote) + } + + private func selfHostedRemoteClient(site: JetpackSiteRef) -> PluginManagementClient? { + guard let remote = BlogService.blog(with: site)?.wordPressOrgRestApi else { + return nil + } + + return SelfHostedPluginManagementClient(with: remote) + } + + func track(_ statName: WPAnalyticsStat, with site: JetpackSiteRef) { + let siteID: NSNumber? = (site.isSelfHostedWithoutJetpack ? nil : site.siteID) as NSNumber? - return PluginServiceRemote(wordPressComRestApi: api) + WPAppAnalytics.track(statName, withBlogID: siteID) } } diff --git a/WordPress/Classes/Stores/RemoteConfigStore.swift b/WordPress/Classes/Stores/RemoteConfigStore.swift new file mode 100644 index 000000000000..22542735bffe --- /dev/null +++ b/WordPress/Classes/Stores/RemoteConfigStore.swift @@ -0,0 +1,73 @@ +import Foundation + +fileprivate extension DispatchQueue { + static let remoteConfigStoreQueue = DispatchQueue(label: "remote-config-store-queue") +} + +class RemoteConfigStore { + + // MARK: Private Variables + + /// Thread Safety Coordinator + private let queue: DispatchQueue + private let remote: RemoteConfigRemote + private let persistenceStore: UserPersistentRepository + + // MARK: Initializer + + init(queue: DispatchQueue = .remoteConfigStoreQueue, + remote: RemoteConfigRemote = RemoteConfigRemote(wordPressComRestApi: .defaultApi()), + persistenceStore: UserPersistentRepository = UserDefaults.standard) { + self.queue = queue + self.remote = remote + self.persistenceStore = persistenceStore + } + + // MARK: Public Functions + + /// Looks up the value for a remote config parameter. + /// - Parameters: + /// - key: The key associated with a remote config parameter + public func value(for key: String) -> Any? { + return cache[key] + } + + /// Fetches remote config values from the server. + /// - Parameter callback: An optional callback that can be used to update UI following the fetch. It is not called on the UI thread. + public func update(then callback: FetchCallback? = nil) { + remote.getRemoteConfig { [weak self] result in + switch result { + case .success(let response): + self?.cache = response + DDLogInfo("🚩 Successfully updated remote config values: \(response)") + callback?() + case .failure(let error): + DDLogError("🚩 Unable to update remote config values: \(error.localizedDescription)") + } + } + } +} + +extension RemoteConfigStore { + struct Constants { + static let CachedResponseKey = "RemoteConfigStoreCache" + } + + typealias FetchCallback = () -> Void + + /// The local cache stores remote config values between runs so that the most recently fetched set are ready to go as soon as this object is instantiated. + private var cache: [String: Any] { + get { + // Read from the cache in a thread-safe way + queue.sync { + persistenceStore.dictionary(forKey: Constants.CachedResponseKey) ?? [:] + } + } + set { + // Write to the cache in a thread-safe way. + self.queue.sync { + persistenceStore.set(newValue, forKey: Constants.CachedResponseKey) + } + } + } +} diff --git a/WordPress/Classes/Stores/RemoteFeatureFlagStore.swift b/WordPress/Classes/Stores/RemoteFeatureFlagStore.swift new file mode 100644 index 000000000000..b283adf20b4e --- /dev/null +++ b/WordPress/Classes/Stores/RemoteFeatureFlagStore.swift @@ -0,0 +1,87 @@ +import Foundation +import WordPressKit + +fileprivate extension DispatchQueue { + static let remoteFeatureFlagStoreQueue = DispatchQueue(label: "remote-feature-flag-store-queue") +} + +class RemoteFeatureFlagStore { + + /// Thread Safety Coordinator + private var queue: DispatchQueue + private var persistenceStore: UserPersistentRepository + + init(queue: DispatchQueue = .remoteFeatureFlagStoreQueue, + persistenceStore: UserPersistentRepository = UserDefaults.standard) { + self.queue = queue + self.persistenceStore = persistenceStore + } + + /// Fetches remote feature flags from the server. + /// - Parameter remote: An optional FeatureFlagRemote with a default WordPressComRestApi instance. Inject a FeatureFlagRemote with a different WordPressComRestApi instance + /// to authenticate with the Remote Feature Flags endpoint – this allows customizing flags server-side on a per-user basis. + /// - Parameter callback: An optional callback that can be used to update UI following the fetch. It is not called on the UI thread. + public func update(using remote: FeatureFlagRemote = FeatureFlagRemote(wordPressComRestApi: WordPressComRestApi.defaultApi()), + then callback: FetchCallback? = nil) { + DDLogInfo("🚩 Updating Remote Feature Flags with Device ID: \(deviceID)") + remote.getRemoteFeatureFlags(forDeviceId: deviceID) { [weak self] result in + switch result { + case .success(let flags): + self?.cache = flags.dictionaryValue + DDLogInfo("🚩 Successfully updated local feature flags: \(flags)") + callback?() + case .failure(let error): + DDLogError("🚩 Unable to update Feature Flag Store: \(error.localizedDescription)") + callback?() + } + } + } + + /// Checks if the local cache has a value for a given `FeatureFlag` + public func hasValue(for flagKey: String) -> Bool { + return value(for: flagKey) != nil + } + + /// Looks up the value for a remote feature flag. + public func value(for flagKey: String) -> Bool? { + return cache[flagKey] + } +} + +extension RemoteFeatureFlagStore { + struct Constants { + static let DeviceIdKey = "FeatureFlagDeviceId" + static let CachedFlagsKey = "FeatureFlagStoreCache" + } + + typealias FetchCallback = () -> Void + + /// The `deviceID` ensures we retain a stable set of Feature Flags between updates. If there are staged rollouts or other dynamic changes + /// happening server-side we don't want out flags to change on each fetch, so we provide an anonymous ID to manage this. + private var deviceID: String { + guard let deviceID = persistenceStore.string(forKey: Constants.DeviceIdKey) else { + DDLogInfo("🚩 Unable to find existing device ID – generating a new one") + let newID = UUID().uuidString + persistenceStore.set(newID, forKey: Constants.DeviceIdKey) + return newID + } + + return deviceID + } + + /// The local cache stores feature flags between runs so that the most recently fetched set are ready to go as soon as this object is instantiated. + private var cache: [String: Bool] { + get { + // Read from the cache in a thread-safe way + queue.sync { + persistenceStore.dictionary(forKey: Constants.CachedFlagsKey) as? [String: Bool] ?? [:] + } + } + set { + // Write to the cache in a thread-safe way. + self.queue.sync { + persistenceStore.set(newValue, forKey: Constants.CachedFlagsKey) + } + } + } +} diff --git a/WordPress/Classes/Stores/StatsInsightsStore.swift b/WordPress/Classes/Stores/StatsInsightsStore.swift index fe1288dadc44..120378bab634 100644 --- a/WordPress/Classes/Stores/StatsInsightsStore.swift +++ b/WordPress/Classes/Stores/StatsInsightsStore.swift @@ -1,6 +1,7 @@ import Foundation import WordPressKit import WordPressFlux +import WidgetKit enum InsightAction: Action { @@ -15,7 +16,7 @@ enum InsightAction: Action { case receivedTodaysStats(_ todaysStats: StatsTodayInsight?, _ error: Error?) case receivedPostingActivity(_ postingActivity: StatsPostingStreakInsight?, _ error: Error?) case receivedTagsAndCategories(_ tagsAndCategories: StatsTagsAndCategoriesInsight?, _ error: Error?) - case refreshInsights + case refreshInsights(forceRefresh: Bool) // Insights details case receivedAllDotComFollowers(_ allDotComFollowers: StatsDotComFollowersInsight?, _ error: Error?) @@ -54,7 +55,12 @@ struct InsightStoreState { var allTimeStats: StatsAllTimesInsight? { didSet { - storeAllTimeWidgetData() + let allTimeWidgetStats = AllTimeWidgetStats(views: allTimeStats?.viewsCount, + visitors: allTimeStats?.visitorsCount, + posts: allTimeStats?.postsCount, + bestViews: allTimeStats?.bestViewsPerDayCount) + storeAllTimeWidgetData(data: allTimeWidgetStats) + StoreContainer.shared.statsWidgets.storeHomeWidgetData(widgetType: HomeWidgetAllTimeData.self, stats: allTimeWidgetStats) } } var allTimeStatus: StoreFetchingStatus = .idle @@ -76,7 +82,13 @@ struct InsightStoreState { var todaysStats: StatsTodayInsight? { didSet { - storeTodayWidgetData() + let todayWidgetStats = TodayWidgetStats(views: todaysStats?.viewsCount, + visitors: todaysStats?.visitorsCount, + likes: todaysStats?.likesCount, + comments: todaysStats?.commentsCount) + + storeTodayWidgetData(data: todayWidgetStats) + StoreContainer.shared.statsWidgets.storeHomeWidgetData(widgetType: HomeWidgetTodayData.self, stats: todayWidgetStats) } } var todaysStatsStatus: StoreFetchingStatus = .idle @@ -107,10 +119,22 @@ struct InsightStoreState { class StatsInsightsStore: QueryStore<InsightStoreState, InsightQuery> { + fileprivate static let cacheTTL: TimeInterval = 300 // 5 minutes + init() { super.init(initialState: InsightStoreState()) } + /// A set containing all the data types associated with the currently visible Insights cards + /// which defines the number and type of api calls we need to perform. + var currentDataTypes: Set<InsightDataType> { + Set(SiteStatsInformation.sharedInstance + .getCurrentSiteInsights() // The current visible cards + .reduce(into: [InsightDataType]()) { + $0.append(contentsOf: $1.insightsDataForSection) + }) // And the respective associated data + } + override func onDispatch(_ action: Action) { guard let insightAction = action as? InsightAction else { @@ -138,8 +162,8 @@ class StatsInsightsStore: QueryStore<InsightStoreState, InsightQuery> { receivedPostingActivity(postingActivity, error) case .receivedTagsAndCategories(let tagsAndCategories, let error): receivedTagsAndCategories(tagsAndCategories, error) - case .refreshInsights: - refreshInsights() + case .refreshInsights(let forceRefresh): + refreshInsights(forceRefresh: forceRefresh) case .receivedAllDotComFollowers(let allDotComFollowers, let error): receivedAllDotComFollowers(allDotComFollowers, error) case .receivedAllEmailFollowers(let allEmailFollowers, let error): @@ -161,7 +185,7 @@ class StatsInsightsStore: QueryStore<InsightStoreState, InsightQuery> { } if !isFetchingOverview { - DDLogInfo("Stats: Insights Overview fetching operations finished.") + DDLogInfo("Stats: Insights Overview refreshing operations finished.") } } @@ -173,7 +197,7 @@ class StatsInsightsStore: QueryStore<InsightStoreState, InsightQuery> { func persistToCoreData() { guard let siteID = SiteStatsInformation.sharedInstance.siteID, - let blog = BlogService.withMainContext().blog(byBlogId: siteID) else { + let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { return } @@ -189,6 +213,7 @@ class StatsInsightsStore: QueryStore<InsightStoreState, InsightQuery> { _ = state.topTagsAndCategories.flatMap { StatsRecord.record(from: $0, for: blog) } try? ContextManager.shared.mainContext.save() + setLastRefreshDate(Date(), for: siteID) } } @@ -234,104 +259,142 @@ private extension StatsInsightsStore { // MARK: - Insights Overview func fetchInsights() { - setAllFetchingStatus(.loading) - fetchLastPostSummary() + updateFetchingStatusForVisibleCards(.loading) + fetchInsightsCards() } - func fetchOverview() { + func fetchInsightsCards() { guard let api = statsRemote() else { setAllFetchingStatus(.idle) return } - api.getInsight { (allTimesStats: StatsAllTimesInsight?, error) in - if error != nil { - DDLogInfo("Error fetching all time insights: \(String(describing: error?.localizedDescription))") - } - self.actionDispatcher.dispatch(InsightAction.receivedAllTimeStats(allTimesStats, error)) + currentDataTypes.forEach { + fetchInsightsForCard(type: $0, api: api) } + } - api.getInsight { (wpComFollowers: StatsDotComFollowersInsight?, error) in - if error != nil { - DDLogInfo("Error fetching WP.com followers: \(String(describing: error?.localizedDescription))") + func fetchInsightsForCard(type: InsightDataType, api: StatsServiceRemoteV2) { + switch type { + case .latestPost: + api.getInsight { (lastPost: StatsLastPostInsight?, error) in + if let error = error { + DDLogError("Error fetching last posts insights: \(error.localizedDescription)") + } + self.actionDispatcher.dispatch(InsightAction.receivedLastPostInsight(lastPost, error)) + DDLogInfo("Stats: Insights - successfully fetched latest post summary") } - self.actionDispatcher.dispatch(InsightAction.receivedDotComFollowers(wpComFollowers, error)) - } - - api.getInsight { (emailFollowers: StatsEmailFollowersInsight?, error) in - if error != nil { - DDLogInfo("Error fetching email followers: \(String(describing: error?.localizedDescription))") + case .allTime: + api.getInsight { (allTimesStats: StatsAllTimesInsight?, error) in + if let error = error { + DDLogError("Error fetching all time insights: \(error.localizedDescription)") + } + self.actionDispatcher.dispatch(InsightAction.receivedAllTimeStats(allTimesStats, error)) + DDLogInfo("Stats: Insights - successfully fetched all time") } - self.actionDispatcher.dispatch(InsightAction.receivedEmailFollowers(emailFollowers, error)) - } - api.getInsight { (publicizeInsight: StatsPublicizeInsight?, error) in - if error != nil { - DDLogInfo("Error fetching publicize insights: \(String(describing: error?.localizedDescription))") + case .annualAndMostPopular: + api.getInsight { (annualAndTime: StatsAnnualAndMostPopularTimeInsight?, error) in + if let error = error { + DDLogError("Error fetching annual/most popular time: \(error.localizedDescription)") + } + self.actionDispatcher.dispatch(InsightAction.receivedAnnualAndMostPopularTimeStats(annualAndTime, error)) + DDLogInfo("Stats: Insights - successfully fetched annual and most popular") } - self.actionDispatcher.dispatch(InsightAction.receivedPublicize(publicizeInsight, error)) - } + case .tagsAndCategories: + api.getInsight { (tagsAndCategoriesInsight: StatsTagsAndCategoriesInsight?, error) in + if let error = error { + DDLogError("Error fetching tags and categories insight: \(error.localizedDescription)") + } - api.getInsight { (annualAndTime: StatsAnnualAndMostPopularTimeInsight?, error) in - if error != nil { - DDLogInfo("Error fetching annual/most popular time: \(String(describing: error?.localizedDescription))") + self.actionDispatcher.dispatch(InsightAction.receivedTagsAndCategories(tagsAndCategoriesInsight, error)) + DDLogInfo("Stats: Insights - successfully fetched tags and categories") } - self.actionDispatcher.dispatch(InsightAction.receivedAnnualAndMostPopularTimeStats(annualAndTime, error)) - } - api.getInsight { (todayInsight: StatsTodayInsight?, error) in - if error != nil { - DDLogInfo("Error fetching today's insight: \(String(describing: error?.localizedDescription))") + case .comments: + api.getInsight { (commentsInsights: StatsCommentsInsight?, error) in + if let error = error { + DDLogError("Error fetching comment insights: \(error.localizedDescription)") + } + self.actionDispatcher.dispatch(InsightAction.receivedCommentsInsight(commentsInsights, error)) + DDLogInfo("Stats: Insights - successfully fetched comments") } - - self.actionDispatcher.dispatch(InsightAction.receivedTodaysStats(todayInsight, error)) - } - - api.getInsight { (commentsInsights: StatsCommentsInsight?, error) in - if error != nil { - DDLogInfo("Error fetching comment insights: \(String(describing: error?.localizedDescription))") + case .followers: + api.getInsight { (wpComFollowers: StatsDotComFollowersInsight?, error) in + if let error = error { + DDLogError("Error fetching WP.com followers: \(error.localizedDescription)") + } + self.actionDispatcher.dispatch(InsightAction.receivedDotComFollowers(wpComFollowers, error)) + DDLogInfo("Stats: Insights - successfully fetched wp.com followers") } - self.actionDispatcher.dispatch(InsightAction.receivedCommentsInsight(commentsInsights, error)) - } - api.getInsight { (tagsAndCategoriesInsight: StatsTagsAndCategoriesInsight?, error) in - if error != nil { - DDLogInfo("Error fetching tags and categories insight: \(String(describing: error?.localizedDescription))") + api.getInsight { (emailFollowers: StatsEmailFollowersInsight?, error) in + if let error = error { + DDLogError("Error fetching email followers: \(error.localizedDescription)") + } + self.actionDispatcher.dispatch(InsightAction.receivedEmailFollowers(emailFollowers, error)) + DDLogInfo("Stats: Insights - successfully fetched email followers") } + case .today: + api.getInsight { (todayInsight: StatsTodayInsight?, error) in + if let error = error { + DDLogError("Error fetching today's insight: \(error.localizedDescription)") + } - self.actionDispatcher.dispatch(InsightAction.receivedTagsAndCategories(tagsAndCategoriesInsight, error)) - } - - api.getInsight(limit: 5000) { (streak: StatsPostingStreakInsight?, error) in - if error != nil { - DDLogInfo("Error fetching posting activity insight: \(String(describing: error?.localizedDescription))") + self.actionDispatcher.dispatch(InsightAction.receivedTodaysStats(todayInsight, error)) + DDLogInfo("Stats: Insights - successfully fetched today") } + case .postingActivity: + api.getInsight(limit: 5000) { (streak: StatsPostingStreakInsight?, error) in + if let error = error { + DDLogError("Error fetching posting activity insight: \(error.localizedDescription)") + } - self.actionDispatcher.dispatch(InsightAction.receivedPostingActivity(streak, error)) + self.actionDispatcher.dispatch(InsightAction.receivedPostingActivity(streak, error)) + DDLogInfo("Stats: Insights - successfully fetched posting activity") + } + case .publicize: + api.getInsight { (publicizeInsight: StatsPublicizeInsight?, error) in + if let error = error { + DDLogError("Error fetching publicize insights: \(error.localizedDescription)") + } + self.actionDispatcher.dispatch(InsightAction.receivedPublicize(publicizeInsight, error)) + DDLogInfo("Stats: Insights - successfully fetched publicize") + } } } func loadFromCache() { guard let siteID = SiteStatsInformation.sharedInstance.siteID, - let blog = BlogService.withMainContext().blog(byBlogId: siteID) else { + let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { return } transaction { state in state.lastPostInsight = StatsRecord.insight(for: blog, type: .lastPostInsight).flatMap { StatsLastPostInsight(statsRecordValues: $0.recordValues) } + state.lastPostSummaryStatus = state.lastPostInsight == nil ? .error : .success state.allTimeStats = StatsRecord.insight(for: blog, type: .allTimeStatsInsight).flatMap { StatsAllTimesInsight(statsRecordValues: $0.recordValues) } + state.allTimeStatus = state.allTimeStats == nil ? .error : .success state.annualAndMostPopularTime = StatsRecord.insight(for: blog, type: .annualAndMostPopularTimes).flatMap { StatsAnnualAndMostPopularTimeInsight(statsRecordValues: $0.recordValues) } + state.annualAndMostPopularTimeStatus = state.annualAndMostPopularTime != nil ? .error : .success state.publicizeFollowers = StatsRecord.insight(for: blog, type: .publicizeConnection).flatMap { StatsPublicizeInsight(statsRecordValues: $0.recordValues) } + state.publicizeFollowersStatus = state.publicizeFollowers == nil ? .error : .success state.todaysStats = StatsRecord.insight(for: blog, type: .today).flatMap { StatsTodayInsight(statsRecordValues: $0.recordValues) } + state.todaysStatsStatus = state.todaysStats == nil ? .error : .success state.postingActivity = StatsRecord.insight(for: blog, type: .streakInsight).flatMap { StatsPostingStreakInsight(statsRecordValues: $0.recordValues) } + state.postingActivityStatus = state.postingActivity == nil ? .error : .success state.topTagsAndCategories = StatsRecord.insight(for: blog, type: .tagsAndCategories).flatMap { StatsTagsAndCategoriesInsight(statsRecordValues: $0.recordValues) } + state.tagsAndCategoriesStatus = state.topTagsAndCategories == nil ? .error : .success state.topCommentsInsight = StatsRecord.insight(for: blog, type: .commentInsight).flatMap { StatsCommentsInsight(statsRecordValues: $0.recordValues) } + state.commentsInsightStatus = state.topCommentsInsight == nil ? .error : .success let followersInsight = StatsRecord.insight(for: blog, type: .followers) state.dotComFollowers = followersInsight.flatMap { StatsDotComFollowersInsight(statsRecordValues: $0.recordValues) } + state.dotComFollowersStatus = state.dotComFollowers == nil ? .error : .success state.emailFollowers = followersInsight.flatMap { StatsEmailFollowersInsight(statsRecordValues: $0.recordValues) } + state.emailFollowersStatus = state.emailFollowers == nil ? .error : .success } DDLogInfo("Insights load from cache") @@ -349,12 +412,21 @@ private extension StatsInsightsStore { return StatsServiceRemoteV2(wordPressComRestApi: wpApi, siteID: siteID, siteTimezone: timeZone) } - func refreshInsights() { + func refreshInsights(forceRefresh: Bool = false) { guard shouldFetchOverview() else { - DDLogInfo("Stats Insights Overview refresh triggered while one was in progress.") + DDLogInfo("Stats: Insights Overview refresh triggered while one was in progress.") return } + guard forceRefresh || hasCacheExpired else { + DDLogInfo("Stats: Insights Overview refresh requested but we still have valid cache data.") + return + } + + if forceRefresh { + DDLogInfo("Stats: Forcing an Insights refresh.") + } + persistToCoreData() fetchInsights() } @@ -371,6 +443,7 @@ private extension StatsInsightsStore { DDLogInfo("Error fetching last posts insights: \(String(describing: error?.localizedDescription))") } self.actionDispatcher.dispatch(InsightAction.receivedLastPostInsight(lastPost, error)) + DDLogInfo("Stats: Insights - successfully fetched latest post summary.") } } @@ -382,14 +455,13 @@ private extension StatsInsightsStore { } api.getDetails(forPostID: postID) { (postStats: StatsPostDetails?, error: Error?) in - if error != nil { - DDLogInfo("Insights: Error fetching Post Stats: \(String(describing: error?.localizedDescription))") + if let error = error { + DDLogError("Insights: Error fetching Post Stats: \(error.localizedDescription)") } - DDLogInfo("Insights: Finished fetching post stats.") + DDLogInfo("Stats: Insights - successfully fetched latest post details.") DispatchQueue.main.async { self.receivedPostStats(postStats, error) - self.fetchOverview() } } } @@ -412,7 +484,6 @@ private extension StatsInsightsStore { transaction { state in state.lastPostSummaryStatus = error != nil ? .error : .success } - fetchOverview() } func receivedAllTimeStats(_ allTimeStats: StatsAllTimesInsight?, _ error: Error?) { @@ -496,6 +567,21 @@ private extension StatsInsightsStore { } } + /// Updates the current fetching status on data types associated with visible cards. Other data types status will remain unchanged. + /// - Parameter status: the new status to set + func updateFetchingStatusForVisibleCards( _ status: StoreFetchingStatus) { + state.lastPostSummaryStatus = currentDataTypes.contains(.latestPost) ? status : state.lastPostSummaryStatus + state.allTimeStatus = currentDataTypes.contains(.allTime) ? status : state.allTimeStatus + state.annualAndMostPopularTimeStatus = currentDataTypes.contains(.annualAndMostPopular) ? status : state.annualAndMostPopularTimeStatus + state.dotComFollowersStatus = currentDataTypes.contains(.followers) ? status : state.dotComFollowersStatus + state.emailFollowersStatus = currentDataTypes.contains(.followers) ? status : state.emailFollowersStatus + state.todaysStatsStatus = currentDataTypes.contains(.today) ? status : state.todaysStatsStatus + state.tagsAndCategoriesStatus = currentDataTypes.contains(.tagsAndCategories) ? status : state.tagsAndCategoriesStatus + state.publicizeFollowersStatus = currentDataTypes.contains(.publicize) ? status : state.publicizeFollowersStatus + state.commentsInsightStatus = currentDataTypes.contains(.comments) ? status : state.commentsInsightStatus + state.postingActivityStatus = currentDataTypes.contains(.postingActivity) ? status : state.postingActivityStatus + } + func setAllFetchingStatus(_ status: StoreFetchingStatus) { state.lastPostSummaryStatus = status state.allTimeStatus = status @@ -688,6 +774,38 @@ private extension StatsInsightsStore { return !isFetchingAnnual } + // MARK: - Cache expiry + + var hasCacheExpired: Bool { + guard let siteID = SiteStatsInformation.sharedInstance.siteID, + let date = lastRefreshDate(for: siteID) else { + return true + } + + let interval = Date().timeIntervalSince(date) + let expired = interval > StatsInsightsStore.cacheTTL + + let intervalLogMessage = "(\(String(format: "%.2f", interval))s since last refresh)" + DDLogInfo("Stats: Insights cache for site \(siteID) has \(expired ? "" : "not ")expired \(intervalLogMessage).") + + return expired + } + + func lastRefreshDate(for siteID: NSNumber) -> Date? { + if let date = UserPersistentStoreFactory.instance().object(forKey: "\(CacheUserDefaultsKeys.lastRefreshDatePrefix)\(siteID)") as? Date { + return date + } + + return nil + } + + func setLastRefreshDate(_ date: Date, for siteID: NSNumber) { + UserPersistentStoreFactory.instance().set(date, forKey: "\(CacheUserDefaultsKeys.lastRefreshDatePrefix)\(siteID)") + } + + private enum CacheUserDefaultsKeys { + static let lastRefreshDatePrefix: String = "StatsStoreLastRefreshDate-" + } } // MARK: - Public Accessors @@ -718,10 +836,28 @@ extension StatsInsightsStore { return state.emailFollowers } + func getTotalFollowerCount() -> Int { + let totalDotComFollowers = getDotComFollowers()?.dotComFollowersCount ?? 0 + let totalEmailFollowers = getEmailFollowers()?.emailFollowersCount ?? 0 + let totalPublicize = getPublicizeCount() + + return totalDotComFollowers + totalEmailFollowers + totalPublicize + } + func getPublicize() -> StatsPublicizeInsight? { return state.publicizeFollowers } + func getPublicizeCount() -> Int { + var totalPublicize = 0 + if let publicize = getPublicize(), + !publicize.publicizeServices.isEmpty { + totalPublicize = publicize.publicizeServices.compactMap({$0.followers}).reduce(0, +) + } + + return totalPublicize + } + func getTopCommentsInsight() -> StatsCommentsInsight? { return state.topCommentsInsight } @@ -946,38 +1082,29 @@ extension StatsInsightsStore { private extension InsightStoreState { - func storeTodayWidgetData() { + func storeTodayWidgetData(data: TodayWidgetStats) { guard widgetUsingCurrentSite() else { return } - let data = TodayWidgetStats(views: todaysStats?.viewsCount, - visitors: todaysStats?.visitorsCount, - likes: todaysStats?.likesCount, - comments: todaysStats?.commentsCount) data.saveData() } - func storeAllTimeWidgetData() { + func storeAllTimeWidgetData(data: AllTimeWidgetStats) { guard widgetUsingCurrentSite() else { return } - let data = AllTimeWidgetStats(views: allTimeStats?.viewsCount, - visitors: allTimeStats?.visitorsCount, - posts: allTimeStats?.postsCount, - bestViews: allTimeStats?.bestViewsPerDayCount) data.saveData() } func widgetUsingCurrentSite() -> Bool { - // Only store data if the widget is using the current site + // Only store data if the widget is using the current site. guard let sharedDefaults = UserDefaults(suiteName: WPAppGroupName), - let widgetSiteID = sharedDefaults.object(forKey: WPStatsTodayWidgetUserDefaultsSiteIdKey) as? NSNumber, + let widgetSiteID = sharedDefaults.object(forKey: AppConfiguration.Widget.StatsToday.userDefaultsSiteIdKey) as? NSNumber, widgetSiteID == SiteStatsInformation.sharedInstance.siteID else { return false } return true } - } diff --git a/WordPress/Classes/Stores/StatsPeriodStore.swift b/WordPress/Classes/Stores/StatsPeriodStore.swift index 1c4ea6291c70..dbeb058904cf 100644 --- a/WordPress/Classes/Stores/StatsPeriodStore.swift +++ b/WordPress/Classes/Stores/StatsPeriodStore.swift @@ -1,5 +1,6 @@ import Foundation import WordPressFlux +import WidgetKit enum PeriodType: CaseIterable { case summary @@ -18,7 +19,6 @@ enum PeriodAction: Action { // Period overview case receivedSummary(_ summary: StatsSummaryTimeIntervalData?, _ error: Error?) - case receivedLikesSummary(_ likes: StatsLikesSummaryTimeIntervalData?, _ error: Error?) case receivedPostsAndPages(_ postsAndPages: StatsTopPostsTimeIntervalData?, _ error: Error?) case receivedPublished(_ published: StatsPublishedPostsTimeIntervalData?, _ error: Error?) case receivedReferrers(_ referrers: StatsTopReferrersTimeIntervalData?, _ error: Error?) @@ -130,11 +130,12 @@ struct PeriodStoreState { var summary: StatsSummaryTimeIntervalData? { didSet { storeThisWeekWidgetData() + StoreContainer.shared.statsWidgets.updateThisWeekHomeWidget(summary: summary) + storeTodayHomeWidgetData() } } var summaryStatus: StoreFetchingStatus = .idle - var summaryLikesStatus: StoreFetchingStatus = .idle var topPostsAndPages: StatsTopPostsTimeIntervalData? var topPostsAndPagesStatus: StoreFetchingStatus = .idle @@ -169,15 +170,22 @@ struct PeriodStoreState { var postStatsFetchingStatuses = [Int: StoreFetchingStatus]() } +protocol StatsPeriodStoreDelegate: AnyObject { + func didChangeSpamState(for referrerDomain: String, isSpam: Bool) + func changingSpamStateForReferrerDomainFailed(oldValue: Bool) +} + class StatsPeriodStore: QueryStore<PeriodStoreState, PeriodQuery> { private typealias PeriodOperation = StatsPeriodAsyncOperation private typealias PublishedPostOperation = StatsPublishedPostsAsyncOperation private typealias PostDetailOperation = StatsPostDetailAsyncOperation - private var statsServiceRemote: StatsServiceRemoteV2? + var statsServiceRemote: StatsServiceRemoteV2? private var operationQueue = OperationQueue() private let scheduler = Scheduler(seconds: 0.3) + weak var delegate: StatsPeriodStoreDelegate? + init() { super.init(initialState: PeriodStoreState()) } @@ -191,8 +199,6 @@ class StatsPeriodStore: QueryStore<PeriodStoreState, PeriodQuery> { switch periodAction { case .receivedSummary(let summary, let error): receivedSummary(summary, error) - case .receivedLikesSummary(let likes, let error): - receivedLikesSummary(likes, error) case .receivedPostsAndPages(let postsAndPages, let error): receivedPostsAndPages(postsAndPages, error) case .refreshPostsAndPages(let date, let period): @@ -246,7 +252,7 @@ class StatsPeriodStore: QueryStore<PeriodStoreState, PeriodQuery> { func persistToCoreData() { guard let siteID = SiteStatsInformation.sharedInstance.siteID, - let blog = BlogService.withMainContext().blog(byBlogId: siteID) else { + let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { return } @@ -374,17 +380,12 @@ private extension StatsPeriodStore { return } - let likesOperation = PeriodOperation(service: service, for: period, date: date, limit: 14) { [weak self] (likes: StatsLikesSummaryTimeIntervalData?, error: Error?) in - if error != nil { - DDLogError("Stats Period: Error fetching likes summary: \(String(describing: error?.localizedDescription))") - } + let group = DispatchGroup() - DDLogInfo("Stats Period: Finished fetching likes summary.") - DispatchQueue.main.async { - self?.receivedLikesSummary(likes, error) - } + if FeatureFlag.statsNewAppearance.enabled { + group.enter() + DDLogInfo("Stats Period: Enter group fetching posts.") } - let topPostsOperation = PeriodOperation(service: service, for: period, date: date) { [weak self] (posts: StatsTopPostsTimeIntervalData?, error: Error?) in if error != nil { DDLogError("Stats Period: Error fetching posts: \(String(describing: error?.localizedDescription))") @@ -394,9 +395,17 @@ private extension StatsPeriodStore { DispatchQueue.main.async { self?.receivedPostsAndPages(posts, error) + if FeatureFlag.statsNewAppearance.enabled { + DDLogInfo("Stats Period: Leave group fetching posts.") + group.leave() + } } } + if FeatureFlag.statsNewAppearance.enabled { + group.enter() + DDLogInfo("Stats Period: Enter group fetching referrers.") + } let topReferrers = PeriodOperation(service: service, for: period, date: date) { [weak self] (referrers: StatsTopReferrersTimeIntervalData?, error: Error?) in if error != nil { DDLogError("Stats Period: Error fetching referrers: \(String(describing: error?.localizedDescription))") @@ -406,9 +415,17 @@ private extension StatsPeriodStore { DispatchQueue.main.async { self?.receivedReferrers(referrers, error) + if FeatureFlag.statsNewAppearance.enabled { + DDLogInfo("Stats Period: Leave group fetching referrers.") + group.leave() + } } } + if FeatureFlag.statsNewAppearance.enabled { + group.enter() + DDLogInfo("Stats Period: Enter group fetching published.") + } let topPublished = PublishedPostOperation(service: service, for: period, date: date) { [weak self] (published: StatsPublishedPostsTimeIntervalData?, error: Error?) in if error != nil { DDLogError("Stats Period: Error fetching published: \(String(describing: error?.localizedDescription))") @@ -418,9 +435,17 @@ private extension StatsPeriodStore { DispatchQueue.main.async { self?.receivedPublished(published, error) + if FeatureFlag.statsNewAppearance.enabled { + DDLogInfo("Stats Period: Leave group fetching published.") + group.leave() + } } } + if FeatureFlag.statsNewAppearance.enabled { + group.enter() + DDLogInfo("Stats Period: Enter group fetching clicks.") + } let topClicks = PeriodOperation(service: service, for: period, date: date) { [weak self] (clicks: StatsTopClicksTimeIntervalData?, error: Error?) in if error != nil { DDLogError("Stats Period: Error fetching clicks: \(String(describing: error?.localizedDescription))") @@ -430,9 +455,17 @@ private extension StatsPeriodStore { DispatchQueue.main.async { self?.receivedClicks(clicks, error) + if FeatureFlag.statsNewAppearance.enabled { + DDLogInfo("Stats Period: Leave group fetching clicks.") + group.leave() + } } } + if FeatureFlag.statsNewAppearance.enabled { + group.enter() + DDLogInfo("Stats Period: Enter group fetching authors.") + } let topAuthors = PeriodOperation(service: service, for: period, date: date) { [weak self] (authors: StatsTopAuthorsTimeIntervalData?, error: Error?) in if error != nil { DDLogError("Stats Period: Error fetching authors: \(String(describing: error?.localizedDescription))") @@ -442,9 +475,17 @@ private extension StatsPeriodStore { DispatchQueue.main.async { self?.receivedAuthors(authors, error) + if FeatureFlag.statsNewAppearance.enabled { + DDLogInfo("Stats Period: Leave group fetching authors.") + group.leave() + } } } + if FeatureFlag.statsNewAppearance.enabled { + group.enter() + DDLogInfo("Stats Period: Enter group fetching search terms.") + } let topSearchTerms = PeriodOperation(service: service, for: period, date: date) { [weak self] (searchTerms: StatsSearchTermTimeIntervalData?, error: Error?) in if error != nil { DDLogError("Stats Period: Error fetching search terms: \(String(describing: error?.localizedDescription))") @@ -454,9 +495,17 @@ private extension StatsPeriodStore { DispatchQueue.main.async { self?.receivedSearchTerms(searchTerms, error) + if FeatureFlag.statsNewAppearance.enabled { + DDLogInfo("Stats Period: Leave group fetching search terms.") + group.leave() + } } } + if FeatureFlag.statsNewAppearance.enabled { + group.enter() + DDLogInfo("Stats Period: Enter group fetching countries.") + } let topCountries = PeriodOperation(service: service, for: period, date: date, limit: 0) { [weak self] (countries: StatsTopCountryTimeIntervalData?, error: Error?) in if error != nil { DDLogError("Stats Period: Error fetching countries: \(String(describing: error?.localizedDescription))") @@ -466,9 +515,17 @@ private extension StatsPeriodStore { DispatchQueue.main.async { self?.receivedCountries(countries, error) + if FeatureFlag.statsNewAppearance.enabled { + DDLogInfo("Stats Period: Leave group fetching countries.") + group.leave() + } } } + if FeatureFlag.statsNewAppearance.enabled { + group.enter() + DDLogInfo("Stats Period: Enter group fetching videos.") + } let topVideos = PeriodOperation(service: service, for: period, date: date) { [weak self] (videos: StatsTopVideosTimeIntervalData?, error: Error?) in if error != nil { DDLogError("Stats Period: Error fetching videos: \(String(describing: error?.localizedDescription))") @@ -478,11 +535,19 @@ private extension StatsPeriodStore { DispatchQueue.main.async { self?.receivedVideos(videos, error) + if FeatureFlag.statsNewAppearance.enabled { + DDLogInfo("Stats Period: Leave group fetching videos.") + group.leave() + } } } // 'limit' in this context is used for the 'num' parameter for the 'file-downloads' endpoint. // 'num' relates to the "number of periods to include in the query". + if FeatureFlag.statsNewAppearance.enabled { + group.enter() + DDLogInfo("Stats Period: Enter group fetching file downloads.") + } let topFileDownloads = PeriodOperation(service: service, for: period, date: date, limit: 1) { [weak self] (downloads: StatsFileDownloadsTimeIntervalData?, error: Error?) in if error != nil { DDLogError("Stats Period: Error file downloads: \(String(describing: error?.localizedDescription))") @@ -492,11 +557,14 @@ private extension StatsPeriodStore { DispatchQueue.main.async { self?.receivedFileDownloads(downloads, error) + if FeatureFlag.statsNewAppearance.enabled { + DDLogInfo("Stats Period: Leave group fetching file downloads.") + group.leave() + } } } - operationQueue.addOperations([likesOperation, - topPostsOperation, + operationQueue.addOperations([topPostsOperation, topReferrers, topPublished, topClicks, @@ -506,21 +574,11 @@ private extension StatsPeriodStore { topVideos, topFileDownloads], waitUntilFinished: false) - } - - func fetchSummaryLikesData(date: Date, period: StatsPeriodUnit) { - guard let statsRemote = statsRemote() else { - return - } - statsRemote.getData(for: period, endingOn: date, limit: 14) { (likes: StatsLikesSummaryTimeIntervalData?, error: Error?) in - if error != nil { - DDLogInfo("Stats Period: Error fetching likes summary: \(String(describing: error?.localizedDescription))") - } - - DDLogInfo("Stats Period: Finished fetching likes summary.") - DispatchQueue.main.async { - self.actionDispatcher.dispatch(PeriodAction.receivedLikesSummary(likes, error)) + if FeatureFlag.statsNewAppearance.enabled { + group.notify(queue: .main) { [weak self] in + DDLogInfo("Stats Period: Finished fetchAsyncData.") + self?.persistToCoreData() } } } @@ -528,7 +586,7 @@ private extension StatsPeriodStore { func loadFromCache(date: Date, period: StatsPeriodUnit) { guard let siteID = SiteStatsInformation.sharedInstance.siteID, - let blog = BlogService.withMainContext().blog(byBlogId: siteID) else { + let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { return } @@ -905,39 +963,6 @@ private extension StatsPeriodStore { } } - func receivedLikesSummary(_ likesSummary: StatsLikesSummaryTimeIntervalData?, _ error: Error?) { - // This is a workaround for how our API works — the requests for summary for long periods of times - // can take extreme amounts of time to finish (and semi-frequenty fail). In order to not block the UI - // here, we split out the views/visitors/comments and likes requests. - // This method splices the results of the two back together so we can persist it to Core Data. - guard let summary = likesSummary, - let currentSummary = state.summary, - summary.summaryData.count == currentSummary.summaryData.count else { - transaction { state in - state.summaryLikesStatus = error != nil ? .error : .success - } - return - } - - let newSummaryData = currentSummary.summaryData.enumerated().map { index, obj in - return StatsSummaryData(period: obj.period, - periodStartDate: obj.periodStartDate, - viewsCount: obj.viewsCount, - visitorsCount: obj.visitorsCount, - likesCount: summary.summaryData[index].likesCount, - commentsCount: obj.commentsCount) - } - - let newSummary = StatsSummaryTimeIntervalData(period: currentSummary.period, - periodEndDate: currentSummary.periodEndDate, - summaryData: newSummaryData) - - transaction { state in - state.summary = newSummary - state.summaryLikesStatus = error != nil ? .error : .success - } - } - func receivedPostsAndPages(_ postsAndPages: StatsTopPostsTimeIntervalData?, _ error: Error?) { transaction { state in state.topPostsAndPagesStatus = error != nil ? .error : .success @@ -1038,11 +1063,15 @@ private extension StatsPeriodStore { // MARK: - Helpers func statsRemote() -> StatsServiceRemoteV2? { - - if statsServiceRemote == nil { + // initialize the service if it's nil + guard let statsService = statsServiceRemote else { + initializeStatsRemote() + return statsServiceRemote + } + // also re-initialize the service if the site has changed + if let siteID = SiteStatsInformation.sharedInstance.siteID?.intValue, siteID != statsService.siteID { initializeStatsRemote() } - return statsServiceRemote } @@ -1062,12 +1091,8 @@ private extension StatsPeriodStore { func cancelQueries() { operationQueue.cancelAllOperations() - statsServiceRemote?.wordPressComRestApi.invalidateAndCancelTasks() + statsServiceRemote?.wordPressComRestApi.cancelTasks() setAllFetchingStatus(.idle) - - // `invalidateAndCancelTasks` invalidates the SessionManager, - // so we need to recreate it to run queries. - initializeStatsRemote() } func shouldFetchOverview() -> Bool { @@ -1084,17 +1109,18 @@ private extension StatsPeriodStore { } func setAllFetchingStatus(_ status: StoreFetchingStatus) { - state.summaryStatus = status - state.summaryLikesStatus = status - state.topPostsAndPagesStatus = status - state.topReferrersStatus = status - state.topPublishedStatus = status - state.topClicksStatus = status - state.topAuthorsStatus = status - state.topSearchTermsStatus = status - state.topCountriesStatus = status - state.topVideosStatus = status - state.topFileDownloadsStatus = status + transaction { state in + state.summaryStatus = status + state.topPostsAndPagesStatus = status + state.topReferrersStatus = status + state.topPublishedStatus = status + state.topClicksStatus = status + state.topAuthorsStatus = status + state.topSearchTermsStatus = status + state.topCountriesStatus = status + state.topVideosStatus = status + state.topFileDownloadsStatus = status + } } func shouldFetchPostsAndPages() -> Bool { @@ -1242,10 +1268,6 @@ extension StatsPeriodStore { return state.topFileDownloadsStatus } - var isFetchingSummaryLikes: Bool { - return state.summaryLikesStatus == .loading - } - var isFetchingPostsAndPages: Bool { return topPostsAndPagesStatus == .loading } @@ -1340,12 +1362,50 @@ extension StatsPeriodStore { } return status } + + func toggleSpamState(for referrerDomain: String, currentValue: Bool) { + for (index, referrer) in (state.topReferrers?.referrers ?? []).enumerated() { + guard (referrer.children.isEmpty && referrer.url?.host == referrerDomain) || + referrer.children.first?.url?.host == referrerDomain else { + continue + } + + toggleSpamState(for: referrerDomain, + currentValue: currentValue, + referrerIndex: index, + hasChildren: !referrer.children.isEmpty) { [weak self] in + switch $0 { + case .success: + self?.delegate?.didChangeSpamState(for: referrerDomain, isSpam: !currentValue) + case .failure: + self?.delegate?.changingSpamStateForReferrerDomainFailed(oldValue: currentValue) + } + } + break + } + } } // MARK: - Widget Data private extension PeriodStoreState { + // Store data for the iOS 14 Today widget. We don't need to check if the site + // matches here, as `storeHomeWidgetData` does that for us. + func storeTodayHomeWidgetData() { + guard summary?.period == .day, + summary?.periodEndDate == StatsDataHelper.currentDateForSite().normalizedDate(), + let todayData = summary?.summaryData.last else { + return + } + + let todayWidgetStats = TodayWidgetStats(views: todayData.viewsCount, + visitors: todayData.visitorsCount, + likes: todayData.likesCount, + comments: todayData.commentsCount) + StoreContainer.shared.statsWidgets.storeHomeWidgetData(widgetType: HomeWidgetTodayData.self, stats: todayWidgetStats) + } + func storeThisWeekWidgetData() { // Only store data if: // - The widget is using the current site @@ -1367,11 +1427,41 @@ private extension PeriodStoreState { func widgetUsingCurrentSite() -> Bool { guard let sharedDefaults = UserDefaults(suiteName: WPAppGroupName), - let widgetSiteID = sharedDefaults.object(forKey: WPStatsTodayWidgetUserDefaultsSiteIdKey) as? NSNumber, + let widgetSiteID = sharedDefaults.object(forKey: AppConfiguration.Widget.StatsToday.userDefaultsSiteIdKey) as? NSNumber, widgetSiteID == SiteStatsInformation.sharedInstance.siteID else { return false } return true } +} +// MARK: - Toggle referrer spam state helper + +private extension StatsPeriodStore { + func toggleSpamState(for referrerDomain: String, + currentValue: Bool, + referrerIndex: Int, + hasChildren: Bool, + completion: @escaping (Result<Void, Error>) -> Void) { + statsServiceRemote?.toggleSpamState(for: referrerDomain, currentValue: currentValue, success: { [weak self] in + guard let self = self else { + return + } + self.state.topReferrers?.referrers[referrerIndex].isSpam.toggle() + DDLogInfo("Stats Period: Referrer \(referrerDomain) isSpam set to \(self.state.topReferrers?.referrers[referrerIndex].isSpam ?? false)") + + guard hasChildren else { + completion(.success(())) + return + } + for (childIndex, _) in (self.state.topReferrers?.referrers[referrerIndex].children ?? []).enumerated() { + self.state.topReferrers?.referrers[referrerIndex].children[childIndex].isSpam.toggle() + } + + completion(.success(())) + }, failure: { error in + DDLogInfo("Stats Period: Couldn't toggle spam state for referrer \(referrerDomain), reason: \(error.localizedDescription)") + completion(.failure(error)) + }) + } } diff --git a/WordPress/Classes/Stores/StatsRevampStore.swift b/WordPress/Classes/Stores/StatsRevampStore.swift new file mode 100644 index 000000000000..2d73e44deb0a --- /dev/null +++ b/WordPress/Classes/Stores/StatsRevampStore.swift @@ -0,0 +1,401 @@ +import Foundation +import WordPressFlux + +/// StatsRevampStore is created to support use cases in Stats that can combine +/// different periods and endpoints. +/// +/// The class hides the complexity and exposes actions and data for specific use cases. + +struct StatsRevampStoreState { + var summary: StatsSummaryTimeIntervalData? + var summaryStatus: StoreFetchingStatus = .idle + + var topReferrers: StatsTopReferrersTimeIntervalData? + var topReferrersStatus: StoreFetchingStatus = .idle + + var topCountries: StatsTopCountryTimeIntervalData? + var topCountriesStatus: StoreFetchingStatus = .idle + + var topPostsAndPages: StatsTopPostsTimeIntervalData? + var topPostsAndPagesStatus: StoreFetchingStatus = .idle +} + +enum StatsRevampStoreAction: Action { + case refreshViewsAndVisitors(date: Date) + case refreshLikesTotals(date: Date) +} + +enum StatsRevampStoreQuery {} + +class StatsRevampStore: QueryStore<StatsRevampStoreState, StatsRevampStoreQuery> { + private typealias PeriodOperation = StatsPeriodAsyncOperation + private var statsServiceRemote: StatsServiceRemoteV2? + + private var operationQueue = OperationQueue() + private let scheduler = Scheduler(seconds: 0.3) + + // MARK: - Query Store + + override init(initialState: StatsRevampStoreState = StatsRevampStoreState(), dispatcher: ActionDispatcher = .global) { + super.init(initialState: initialState, dispatcher: dispatcher) + } + + override func onDispatch(_ action: Action) { + guard let action = action as? StatsRevampStoreAction else { + return + } + + switch action { + case .refreshLikesTotals(let date): + fetchLikesTotalsData(date: date) + case .refreshViewsAndVisitors(let date): + fetchViewsAndVisitorsData(date: date) + } + } +} + +// MARK: - Status + +extension StatsRevampStore { + var viewsAndVisitorsStatus: StoreFetchingStatus { + let statuses = [state.summaryStatus, state.topReferrersStatus, state.topCountriesStatus] + return aggregateStatus(for: statuses, data: state.summary) + } + + var likesTotalsStatus: StoreFetchingStatus { + let statuses = [state.summaryStatus, state.topPostsAndPagesStatus] + return aggregateStatus(for: statuses, data: state.summary) + } +} + +// MARK: - Getters + +extension StatsRevampStore { + struct ViewsAndVisitorsData { + let summary: StatsSummaryTimeIntervalData? + let topReferrers: StatsTopReferrersTimeIntervalData? + let topCountries: StatsTopCountryTimeIntervalData? + } + + struct TotalLikesData { + let summary: StatsSummaryTimeIntervalData? + let topPostsAndPages: StatsTopPostsTimeIntervalData? + } + + func getViewsAndVisitorsData() -> StatsRevampStore.ViewsAndVisitorsData { + return ViewsAndVisitorsData( + summary: state.summary, + topReferrers: state.topReferrers, + topCountries: state.topCountries + ) + } + + func getLikesTotalsData() -> StatsRevampStore.TotalLikesData { + return TotalLikesData( + summary: state.summary, + topPostsAndPages: state.topPostsAndPages + ) + } +} + +// MARK: - Helpers + +private extension StatsRevampStore { + func aggregateStatus(for statuses: [StoreFetchingStatus], data: Any?) -> StoreFetchingStatus { + if statuses.first(where: { $0 == .loading }) != nil { + return .loading + } else if statuses.first(where: { $0 == .success }) != nil || data != nil { + return .success + } else if statuses.first(where: { $0 == .error }) != nil { + return .error + } else { + return .idle + } + } +} + +// MARK: - Data for Views & Visitors weekly details + +private extension StatsRevampStore { + func shouldFetchViewsAndVisitors() -> Bool { + return viewsAndVisitorsStatus != .loading + } + + func setViewsAndVisitorsFetchingStatus(_ status: StoreFetchingStatus) { + transaction { state in + state.summaryStatus = status + state.topReferrersStatus = status + state.topCountriesStatus = status + } + } + + func fetchViewsAndVisitorsData(date: Date) { + loadViewsAndVisitorsCache(date: date) + + guard shouldFetchViewsAndVisitors() else { + DDLogInfo("Stats Views and Visitors details refresh triggered while one was in progress.") + return + } + + setViewsAndVisitorsFetchingStatus(.loading) + + fetchSummary(date: date) { [weak self] in + self?.fetchViewsAndVisitorsDetailsData(date: date) + } + } + + func fetchViewsAndVisitorsDetailsData(date: Date) { + guard let service = statsRemote() else { + return + } + + let topReferrers = PeriodOperation(service: service, for: .week, date: date) { [weak self] (referrers: StatsTopReferrersTimeIntervalData?, error: Error?) in + if error != nil { + DDLogError("Stats Revamp Store: Error fetching referrers: \(String(describing: error?.localizedDescription))") + } + + DDLogInfo("Stats Revamp Store: Finished fetching referrers.") + + DispatchQueue.main.async { + self?.receivedReferrers(referrers, error) + } + } + + let topCountries = PeriodOperation(service: service, for: .week, date: date, limit: 0) { [weak self] (countries: StatsTopCountryTimeIntervalData?, error: Error?) in + if error != nil { + DDLogError("Stats Revamp Store: Error fetching countries: \(String(describing: error?.localizedDescription))") + } + + DDLogInfo("Stats Revamp Store: Finished fetching countries.") + + DispatchQueue.main.async { + self?.receivedCountries(countries, error) + } + } + + operationQueue.addOperations([topReferrers, + topCountries], + waitUntilFinished: false) + } + + private func loadViewsAndVisitorsCache(date: Date) { + guard + let siteID = SiteStatsInformation.sharedInstance.siteID, + let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { + return + } + + let summary = StatsRecord.timeIntervalData(for: blog, type: .blogVisitsSummary, period: StatsRecordPeriodType(remoteStatus: .day), date: date) + let referrers = StatsRecord.timeIntervalData(for: blog, type: .referrers, period: StatsRecordPeriodType(remoteStatus: .week), date: date) + let countries = StatsRecord.timeIntervalData(for: blog, type: .countryViews, period: StatsRecordPeriodType(remoteStatus: .week), date: date) + + DDLogInfo("Stats Revamp Store: Finished loading Period data from Core Data.") + + transaction { state in + state.summary = summary.flatMap { StatsSummaryTimeIntervalData(statsRecordValues: $0.recordValues) } + state.topReferrers = referrers.flatMap { StatsTopReferrersTimeIntervalData(statsRecordValues: $0.recordValues) } + state.topCountries = countries.flatMap { StatsTopCountryTimeIntervalData(statsRecordValues: $0.recordValues) } + DDLogInfo("Stats Revamp Store: Finished setting data to Period store from Core Data.") + } + } +} + +// MARK: - Data Total Likes weekly details + +private extension StatsRevampStore { + func shouldFetchLikesTotals() -> Bool { + return likesTotalsStatus != .loading + } + + func setLikesTotalsDetailsFetchingStatus(_ status: StoreFetchingStatus) { + transaction { state in + state.summaryStatus = status + state.topPostsAndPagesStatus = status + } + } + + func fetchLikesTotalsData(date: Date) { + loadLikesTotalsCache(date: date) + + guard shouldFetchLikesTotals() else { + DDLogInfo("Stats Views and Visitors details refresh triggered while one was in progress.") + return + } + + setLikesTotalsDetailsFetchingStatus(.loading) + + fetchSummary(date: date) { [weak self] in + self?.fetchLikesTotalsDetailsData(date: date) + } + } + + func fetchLikesTotalsDetailsData(date: Date) { + guard let service = statsRemote() else { + return + } + + let topPostsOperation = PeriodOperation(service: service, for: .week, date: date) { [weak self] (posts: StatsTopPostsTimeIntervalData?, error: Error?) in + if error != nil { + DDLogError("Stats Revamp Store: Error fetching posts: \(String(describing: error?.localizedDescription))") + } + + DDLogInfo("Stats Revamp Store: Finished fetching posts.") + + DispatchQueue.main.async { + self?.receivedPostsAndPages(posts, error) + } + } + + operationQueue.addOperations([topPostsOperation], + waitUntilFinished: false) + } + + private func loadLikesTotalsCache(date: Date) { + guard + let siteID = SiteStatsInformation.sharedInstance.siteID, + let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { + return + } + + let summary = StatsRecord.timeIntervalData(for: blog, type: .blogVisitsSummary, period: StatsRecordPeriodType(remoteStatus: .day), date: date) + let posts = StatsRecord.timeIntervalData(for: blog, type: .topViewedPost, period: StatsRecordPeriodType(remoteStatus: .week), date: date) + + DDLogInfo("Stats Revamp Store: Finished loading Period data from Core Data.") + + transaction { state in + state.summary = summary.flatMap { StatsSummaryTimeIntervalData(statsRecordValues: $0.recordValues) } + state.topPostsAndPages = posts.flatMap { StatsTopPostsTimeIntervalData(statsRecordValues: $0.recordValues) } + DDLogInfo("Stats Revamp Store: Finished setting data to Period store from Core Data.") + } + } +} + +private extension StatsRevampStore { + func fetchSummary(date: Date, _ completion: @escaping () -> ()) { + guard let service = statsRemote() else { + return + } + + scheduler.debounce { [weak self] in + DDLogInfo("Stats Revamp Store: Cancel all operations") + + self?.operationQueue.cancelAllOperations() + + let chartOperation = PeriodOperation(service: service, for: .day, date: date, limit: 14) { [weak self] (summary: StatsSummaryTimeIntervalData?, error: Error?) in + if error != nil { + DDLogError("Stats Revamp Store: Error fetching summary: \(String(describing: error?.localizedDescription))") + } + + DDLogInfo("Stats Revamp Store: Finished fetching summary.") + + DispatchQueue.main.async { + self?.receivedSummary(summary, error) + completion() + } + } + + self?.operationQueue.addOperation(chartOperation) + } + } +} + +// MARK: - Helpers +private extension StatsRevampStore { + func statsRemote() -> StatsServiceRemoteV2? { + // initialize the service if it's nil + guard let statsService = statsServiceRemote else { + initializeStatsRemote() + return statsServiceRemote + } + // also re-initialize the service if the site has changed + if let siteID = SiteStatsInformation.sharedInstance.siteID?.intValue, siteID != statsService.siteID { + initializeStatsRemote() + } + return statsServiceRemote + } + + func initializeStatsRemote() { + guard + let siteID = SiteStatsInformation.sharedInstance.siteID?.intValue, + let timeZone = SiteStatsInformation.sharedInstance.siteTimeZone + else { + statsServiceRemote = nil + return + } + + let wpApi = WordPressComRestApi.defaultApi(oAuthToken: SiteStatsInformation.sharedInstance.oauth2Token, userAgent: WPUserAgent.wordPress()) + statsServiceRemote = StatsServiceRemoteV2(wordPressComRestApi: wpApi, siteID: siteID, siteTimezone: timeZone) + } +} + +// MARK: - Receive Data + +private extension StatsRevampStore { + func receivedReferrers(_ referrers: StatsTopReferrersTimeIntervalData?, _ error: Error?) { + transaction { state in + state.topReferrersStatus = error != nil ? .error : .success + + if referrers != nil { + state.topReferrers = referrers + } + } + + persistData(state.topReferrers) + } + + func receivedCountries(_ countries: StatsTopCountryTimeIntervalData?, _ error: Error?) { + transaction { state in + state.topCountriesStatus = error != nil ? .error : .success + + if countries != nil { + state.topCountries = countries + } + } + + persistData(state.topCountries) + } + + func receivedPostsAndPages(_ postsAndPages: StatsTopPostsTimeIntervalData?, _ error: Error?) { + transaction { state in + state.topPostsAndPagesStatus = error != nil ? .error : .success + + if postsAndPages != nil { + state.topPostsAndPages = postsAndPages + } + } + + persistData(state.topPostsAndPages) + } + + func receivedSummary(_ summaryData: StatsSummaryTimeIntervalData?, _ error: Error?) { + transaction { state in + state.summaryStatus = error != nil ? .error : .success + + if summaryData != nil { + state.summary = summaryData + } + } + + persistData(state.summary) + } +} + +private extension StatsRevampStore { + func persistData<TimeIntervalType: StatsTimeIntervalData & TimeIntervalStatsRecordValueConvertible>(_ data: TimeIntervalType?) { + guard + let data, + let siteID = SiteStatsInformation.sharedInstance.siteID, + let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { + return + } + + _ = StatsRecord.record(from: data, for: blog) + + do { + try ContextManager.shared.mainContext.save() + DDLogInfo("Stats Revamp Store: finished persisting stats to disk.") + } catch { + DDLogError("Stats Revamp Store: failed persisting stats to disk.") + } + } +} diff --git a/WordPress/Classes/Stores/StatsStore+Cache.swift b/WordPress/Classes/Stores/StatsStore+Cache.swift index 1dfc69aa21bd..534089fdce51 100644 --- a/WordPress/Classes/Stores/StatsStore+Cache.swift +++ b/WordPress/Classes/Stores/StatsStore+Cache.swift @@ -14,9 +14,11 @@ extension StatsStoreCacheable { extension StatsInsightsStore: StatsStoreCacheable { func containsCachedData(for type: InsightType) -> Bool { switch type { + case .viewsVisitors: + return true case .latestPostSummary: return state.lastPostInsight != nil - case .allTimeStats: + case .allTimeStats, .growAudience: return state.allTimeStats != nil case .followersTotals, .followers: return state.dotComFollowers != nil && diff --git a/WordPress/Classes/Stores/StatsWidgetsStore.swift b/WordPress/Classes/Stores/StatsWidgetsStore.swift new file mode 100644 index 000000000000..2647d0de02a8 --- /dev/null +++ b/WordPress/Classes/Stores/StatsWidgetsStore.swift @@ -0,0 +1,353 @@ +import WidgetKit +import WordPressAuthenticator + +class StatsWidgetsStore { + private let coreDataStack: CoreDataStack + + init(coreDataStack: CoreDataStack = ContextManager.shared) { + self.coreDataStack = coreDataStack + + updateJetpackFeaturesDisabled() + observeAccountChangesForWidgets() + observeAccountSignInForWidgets() + observeApplicationLaunched() + observeSiteUpdatesForWidgets() + observeJetpackFeaturesState() + } + + /// Refreshes the site list used to configure the widgets when sites are added or deleted + @objc func refreshStatsWidgetsSiteList() { + initializeStatsWidgetsIfNeeded() + + if let newTodayData = refreshStats(type: HomeWidgetTodayData.self) { + HomeWidgetTodayData.write(items: newTodayData) + WidgetCenter.shared.reloadTimelines(ofKind: AppConfiguration.Widget.Stats.todayKind) + } + + if let newAllTimeData = refreshStats(type: HomeWidgetAllTimeData.self) { + HomeWidgetAllTimeData.write(items: newAllTimeData) + WidgetCenter.shared.reloadTimelines(ofKind: AppConfiguration.Widget.Stats.allTimeKind) + } + + if let newThisWeekData = refreshStats(type: HomeWidgetThisWeekData.self) { + HomeWidgetThisWeekData.write(items: newThisWeekData) + WidgetCenter.shared.reloadTimelines(ofKind: AppConfiguration.Widget.Stats.thisWeekKind) + } + } + + /// Initialize the local cache for widgets, if it does not exist + @objc func initializeStatsWidgetsIfNeeded() { + UserDefaults(suiteName: WPAppGroupName)?.setValue(AccountHelper.defaultSiteId, forKey: AppConfiguration.Widget.Stats.userDefaultsSiteIdKey) + + if !HomeWidgetTodayData.cacheDataExists() { + DDLogInfo("StatsWidgets: Writing initialization data into HomeWidgetTodayData.plist") + HomeWidgetTodayData.write(items: initializeHomeWidgetData(type: HomeWidgetTodayData.self)) + WidgetCenter.shared.reloadTimelines(ofKind: AppConfiguration.Widget.Stats.todayKind) + } + + if !HomeWidgetThisWeekData.cacheDataExists() { + DDLogInfo("StatsWidgets: Writing initialization data into HomeWidgetThisWeekData.plist") + HomeWidgetThisWeekData.write(items: initializeHomeWidgetData(type: HomeWidgetThisWeekData.self)) + WidgetCenter.shared.reloadTimelines(ofKind: AppConfiguration.Widget.Stats.thisWeekKind) + } + + if !HomeWidgetAllTimeData.cacheDataExists() { + DDLogInfo("StatsWidgets: Writing initialization data into HomeWidgetAllTimeData.plist") + HomeWidgetAllTimeData.write(items: initializeHomeWidgetData(type: HomeWidgetAllTimeData.self)) + WidgetCenter.shared.reloadTimelines(ofKind: AppConfiguration.Widget.Stats.allTimeKind) + } + } + + /// Store stats in the widget cache + /// - Parameters: + /// - widgetType: concrete type of the widget + /// - stats: stats to be stored + func storeHomeWidgetData<T: HomeWidgetData>(widgetType: T.Type, stats: Codable) { + guard let siteID = SiteStatsInformation.sharedInstance.siteID else { + return + } + + var homeWidgetCache = T.read() ?? initializeHomeWidgetData(type: widgetType) + guard let oldData = homeWidgetCache[siteID.intValue] else { + DDLogError("StatsWidgets: Failed to find a matching site") + return + } + + guard let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { + DDLogError("StatsWidgets: the site does not exist anymore") + // if for any reason that site does not exist anymore, remove it from the cache. + homeWidgetCache.removeValue(forKey: siteID.intValue) + T.write(items: homeWidgetCache) + return + } + var widgetKind = "" + if widgetType == HomeWidgetTodayData.self, let stats = stats as? TodayWidgetStats { + + widgetKind = AppConfiguration.Widget.Stats.todayKind + + homeWidgetCache[siteID.intValue] = HomeWidgetTodayData(siteID: siteID.intValue, + siteName: blog.title ?? oldData.siteName, + url: blog.url ?? oldData.url, + timeZone: blog.timeZone, + date: Date(), + stats: stats) as? T + + + } else if widgetType == HomeWidgetAllTimeData.self, let stats = stats as? AllTimeWidgetStats { + widgetKind = AppConfiguration.Widget.Stats.allTimeKind + + homeWidgetCache[siteID.intValue] = HomeWidgetAllTimeData(siteID: siteID.intValue, + siteName: blog.title ?? oldData.siteName, + url: blog.url ?? oldData.url, + timeZone: blog.timeZone, + date: Date(), + stats: stats) as? T + + } else if widgetType == HomeWidgetThisWeekData.self, let stats = stats as? ThisWeekWidgetStats { + + homeWidgetCache[siteID.intValue] = HomeWidgetThisWeekData(siteID: siteID.intValue, + siteName: blog.title ?? oldData.siteName, + url: blog.url ?? oldData.url, + timeZone: blog.timeZone, + date: Date(), + stats: stats) as? T + } + + T.write(items: homeWidgetCache) + WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) + } +} + + +// MARK: - Helper methods +private extension StatsWidgetsStore { + + // creates a list of days from the current date with empty stats to avoid showing an empty widget preview + var initializedWeekdays: [ThisWeekWidgetDay] { + var days = [ThisWeekWidgetDay]() + for index in 0...7 { + days.insert(ThisWeekWidgetDay(date: NSCalendar.current.date(byAdding: .day, + value: -index, + to: Date()) ?? Date(), + viewsCount: 0, + dailyChangePercent: 0), + at: index) + } + return days + } + + func refreshStats<T: HomeWidgetData>(type: T.Type) -> [Int: T]? { + guard let currentData = T.read() else { + return nil + } + let updatedSiteList = (try? BlogQuery().visible(true).hostedByWPCom(true).blogs(in: coreDataStack.mainContext)) ?? [] + + let newData = updatedSiteList.reduce(into: [Int: T]()) { sitesList, site in + guard let blogID = site.dotComID else { + return + } + let existingSite = currentData[blogID.intValue] + + let siteURL = site.url ?? existingSite?.url ?? "" + let siteName = (site.title ?? siteURL).isEmpty ? siteURL : site.title ?? siteURL + + var timeZone = existingSite?.timeZone ?? TimeZone.current + + if let blog = Blog.lookup(withID: blogID, in: ContextManager.shared.mainContext) { + timeZone = blog.timeZone + } + + let date = existingSite?.date ?? Date() + + if type == HomeWidgetTodayData.self { + + let stats = (existingSite as? HomeWidgetTodayData)?.stats ?? TodayWidgetStats() + + sitesList[blogID.intValue] = HomeWidgetTodayData(siteID: blogID.intValue, + siteName: siteName, + url: siteURL, + timeZone: timeZone, + date: date, + stats: stats) as? T + } else if type == HomeWidgetAllTimeData.self { + + let stats = (existingSite as? HomeWidgetAllTimeData)?.stats ?? AllTimeWidgetStats() + + sitesList[blogID.intValue] = HomeWidgetAllTimeData(siteID: blogID.intValue, + siteName: siteName, + url: siteURL, + timeZone: timeZone, + date: date, + stats: stats) as? T + + } else if type == HomeWidgetThisWeekData.self { + + let stats = (existingSite as? HomeWidgetThisWeekData)?.stats ?? ThisWeekWidgetStats(days: initializedWeekdays) + + sitesList[blogID.intValue] = HomeWidgetThisWeekData(siteID: blogID.intValue, + siteName: siteName, + url: siteURL, + timeZone: timeZone, + date: date, + stats: stats) as? T + } + } + return newData + } + + func initializeHomeWidgetData<T: HomeWidgetData>(type: T.Type) -> [Int: T] { + let blogs = (try? BlogQuery().visible(true).hostedByWPCom(true).blogs(in: coreDataStack.mainContext)) ?? [] + return blogs.reduce(into: [Int: T]()) { result, element in + if let blogID = element.dotComID, + let url = element.url, + let blog = Blog.lookup(withID: blogID, in: ContextManager.shared.mainContext) { + // set the title to the site title, if it's not nil and not empty; otherwise use the site url + let title = (element.title ?? url).isEmpty ? url : element.title ?? url + let timeZone = blog.timeZone + if type == HomeWidgetTodayData.self { + result[blogID.intValue] = HomeWidgetTodayData(siteID: blogID.intValue, + siteName: title, + url: url, + timeZone: timeZone, + date: Date(timeIntervalSinceReferenceDate: 0), + stats: TodayWidgetStats()) as? T + } else if type == HomeWidgetAllTimeData.self { + result[blogID.intValue] = HomeWidgetAllTimeData(siteID: blogID.intValue, + siteName: title, + url: url, + timeZone: timeZone, + date: Date(timeIntervalSinceReferenceDate: 0), + stats: AllTimeWidgetStats()) as? T + } else if type == HomeWidgetThisWeekData.self { + result[blogID.intValue] = HomeWidgetThisWeekData(siteID: blogID.intValue, + siteName: title, + url: url, + timeZone: timeZone, + date: Date(timeIntervalSinceReferenceDate: 0), + stats: ThisWeekWidgetStats(days: initializedWeekdays)) as? T + } + } + } + } +} + + +// MARK: - Extract this week data +extension StatsWidgetsStore { + func updateThisWeekHomeWidget(summary: StatsSummaryTimeIntervalData?) { + switch summary?.period { + case .day: + guard summary?.periodEndDate == StatsDataHelper.currentDateForSite().normalizedDate() else { + return + } + let summaryData = Array(summary?.summaryData.reversed().prefix(ThisWeekWidgetStats.maxDaysToDisplay + 1) ?? []) + + let stats = ThisWeekWidgetStats(days: ThisWeekWidgetStats.daysFrom(summaryData: summaryData)) + StoreContainer.shared.statsWidgets.storeHomeWidgetData(widgetType: HomeWidgetThisWeekData.self, stats: stats) + case .week: + WidgetCenter.shared.reloadTimelines(ofKind: AppConfiguration.Widget.Stats.thisWeekKind) + default: + break + } + } +} + + +// MARK: - Login/Logout notifications +private extension StatsWidgetsStore { + /// Observes WPAccountDefaultWordPressComAccountChanged notification and reloads widget data based on the state of account. + /// The site data is not yet loaded after this notification and widget data cannot be cached for newly signed in account. + func observeAccountChangesForWidgets() { + NotificationCenter.default.addObserver(forName: .WPAccountDefaultWordPressComAccountChanged, + object: nil, + queue: nil) { notification in + + UserDefaults(suiteName: WPAppGroupName)?.setValue(AccountHelper.isLoggedIn, forKey: AppConfiguration.Widget.Stats.userDefaultsLoggedInKey) + + if !AccountHelper.isLoggedIn { + HomeWidgetTodayData.delete() + HomeWidgetThisWeekData.delete() + HomeWidgetAllTimeData.delete() + + UserDefaults(suiteName: WPAppGroupName)?.setValue(nil, forKey: AppConfiguration.Widget.Stats.userDefaultsSiteIdKey) + WidgetCenter.shared.reloadTimelines(ofKind: AppConfiguration.Widget.Stats.todayKind) + WidgetCenter.shared.reloadTimelines(ofKind: AppConfiguration.Widget.Stats.thisWeekKind) + WidgetCenter.shared.reloadTimelines(ofKind: AppConfiguration.Widget.Stats.allTimeKind) + } + } + } + + /// Observes WPSigninDidFinishNotification and wordpressLoginFinishedJetpackLogin notifications and initializes the widget. + /// The site data is loaded after this notification and widget data can be cached. + func observeAccountSignInForWidgets() { + NotificationCenter.default.addObserver(self, selector: #selector(initializeStatsWidgetsIfNeeded), name: NSNotification.Name(rawValue: WordPressAuthenticator.WPSigninDidFinishNotification), object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(initializeStatsWidgetsIfNeeded), name: .wordpressLoginFinishedJetpackLogin, object: nil) + } + + /// Observes applicationLaunchCompleted notification and runs migration. + func observeApplicationLaunched() { + NotificationCenter.default.addObserver(forName: NSNotification.Name.applicationLaunchCompleted, + object: nil, + queue: nil) { [weak self] _ in + self?.handleJetpackWidgetsMigration() + } + } + + func observeJetpackFeaturesState() { + NotificationCenter.default.addObserver(self, + selector: #selector(updateJetpackFeaturesDisabled), + name: .WPAppUITypeChanged, + object: nil) + } + + @objc func updateJetpackFeaturesDisabled() { + guard let defaults = UserDefaults(suiteName: WPAppGroupName) else { + return + } + let key = AppConfiguration.Widget.Stats.userDefaultsJetpackFeaturesDisabledKey + let oldValue = defaults.bool(forKey: key) + let newValue = !JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() + defaults.setValue(newValue, forKey: key) + if oldValue != newValue { + refreshStatsWidgetsSiteList() + } + } +} + +private extension StatsWidgetsStore { + + /// Handles migration to a Jetpack app version that started supporting Stats widgets. + /// The required flags in shared UserDefaults are set and widgets are initialized. + func handleJetpackWidgetsMigration() { + // If user is logged in but defaultSiteIdKey is not set + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: coreDataStack.mainContext), + let siteId = account.defaultBlog?.dotComID, + let userDefaults = UserDefaults(suiteName: WPAppGroupName), + userDefaults.value(forKey: AppConfiguration.Widget.Stats.userDefaultsSiteIdKey) == nil else { + return + } + + userDefaults.setValue(AccountHelper.isLoggedIn, forKey: AppConfiguration.Widget.Stats.userDefaultsLoggedInKey) + userDefaults.setValue(siteId, forKey: AppConfiguration.Widget.Stats.userDefaultsSiteIdKey) + initializeStatsWidgetsIfNeeded() + } + + func observeSiteUpdatesForWidgets() { + NotificationCenter.default.addObserver(self, selector: #selector(refreshStatsWidgetsSiteList), name: .WPSiteCreated, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(refreshStatsWidgetsSiteList), name: .WPSiteDeleted, object: nil) + } +} + + +extension StatsViewController { + @objc func initializeStatsWidgetsIfNeeded() { + StoreContainer.shared.statsWidgets.initializeStatsWidgetsIfNeeded() + } +} + + +extension BlogListViewController { + @objc func refreshStatsWidgetsSiteList() { + StoreContainer.shared.statsWidgets.refreshStatsWidgetsSiteList() + } +} diff --git a/WordPress/Classes/Stores/StoreContainer.swift b/WordPress/Classes/Stores/StoreContainer.swift index b757de361bd4..db521e68888a 100644 --- a/WordPress/Classes/Stores/StoreContainer.swift +++ b/WordPress/Classes/Stores/StoreContainer.swift @@ -20,4 +20,6 @@ class StoreContainer { let statsInsights = StatsInsightsStore() let statsPeriod = StatsPeriodStore() let jetpackInstall = JetpackInstallStore() + let statsWidgets = StatsWidgetsStore() + let statsRevamp = StatsRevampStore() } diff --git a/WordPress/Classes/Stores/UserPersistentRepository.swift b/WordPress/Classes/Stores/UserPersistentRepository.swift new file mode 100644 index 000000000000..40437d5a4b6b --- /dev/null +++ b/WordPress/Classes/Stores/UserPersistentRepository.swift @@ -0,0 +1,25 @@ +protocol UserPersistentRepositoryReader { + func string(forKey key: String) -> String? + func bool(forKey key: String) -> Bool + func integer(forKey key: String) -> Int + func float(forKey key: String) -> Float + func double(forKey key: String) -> Double + func array(forKey key: String) -> [Any]? + func dictionary(forKey key: String) -> [String: Any]? + func url(forKey key: String) -> URL? + func dictionaryRepresentation() -> [String: Any] +} + +protocol UserPersistentRepositoryWriter: KeyValueDatabase { + func set(_ value: Any?, forKey key: String) + func set(_ value: Int, forKey key: String) + func set(_ value: Float, forKey key: String) + func set(_ value: Double, forKey key: String) + func set(_ value: Bool, forKey key: String) + func set(_ url: URL?, forKey key: String) + func removeObject(forKey key: String) +} + +typealias UserPersistentRepository = UserPersistentRepositoryReader & UserPersistentRepositoryWriter & UserPersistentRepositoryUtility + +extension UserDefaults: UserPersistentRepository {} diff --git a/WordPress/Classes/Stores/UserPersistentRepositoryUtility.swift b/WordPress/Classes/Stores/UserPersistentRepositoryUtility.swift new file mode 100644 index 000000000000..2e0e2a15b669 --- /dev/null +++ b/WordPress/Classes/Stores/UserPersistentRepositoryUtility.swift @@ -0,0 +1,188 @@ +import Foundation + +private enum UPRUConstants { + static let promptKey = "onboarding_notifications_prompt_displayed" + static let questionKey = "onboarding_question_selection" + static let notificationPrimerAlertWasDisplayed = "NotificationPrimerAlertWasDisplayed" + static let notificationsTabAccessCount = "NotificationsTabAccessCount" + static let notificationPrimerInlineWasAcknowledged = "notificationPrimerInlineWasAcknowledged" + static let secondNotificationsAlertCount = "secondNotificationsAlertCount" + static let hasShownCustomAppIconUpgradeAlert = "custom-app-icon-upgrade-alert-shown" + static let createButtonTooltipWasDisplayed = "CreateButtonTooltipWasDisplayed" + static let createButtonTooltipDisplayCount = "CreateButtonTooltipDisplayCount" + static let savedPostsPromoWasDisplayed = "SavedPostsV1PromoWasDisplayed" + static let storiesIntroWasAcknowledged = "storiesIntroWasAcknowledged" + static let currentAnnouncementsKey = "currentAnnouncements" + static let currentAnnouncementsDateKey = "currentAnnouncementsDate" + static let announcementsVersionDisplayedKey = "announcementsVersionDisplayed" + static let isJPContentImportCompleteKey = "jetpackContentImportComplete" + static let jetpackContentMigrationStateKey = "jetpackContentMigrationState" +} + +protocol UserPersistentRepositoryUtility: AnyObject { + var onboardingNotificationsPromptDisplayed: Bool { get set } + var onboardingQuestionSelected: OnboardingOption? { get set } + var notificationPrimerAlertWasDisplayed: Bool { get set } +} + +extension UserPersistentRepositoryUtility { + var onboardingNotificationsPromptDisplayed: Bool { + get { + UserPersistentStoreFactory.instance().bool(forKey: UPRUConstants.promptKey) + } + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: UPRUConstants.promptKey) + } + } + + var onboardingQuestionSelected: OnboardingOption? { + get { + if let str = UserPersistentStoreFactory.instance().string(forKey: UPRUConstants.questionKey) { + return OnboardingOption(rawValue: str) + } + + return nil + } + set { + UserPersistentStoreFactory.instance().set(newValue?.rawValue, forKey: UPRUConstants.questionKey) + } + } + + var notificationPrimerAlertWasDisplayed: Bool { + get { + UserPersistentStoreFactory.instance().bool(forKey: UPRUConstants.notificationPrimerAlertWasDisplayed) + } + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: UPRUConstants.notificationPrimerAlertWasDisplayed) + } + } + + var notificationsTabAccessCount: Int { + get { + UserPersistentStoreFactory.instance().integer(forKey: UPRUConstants.notificationsTabAccessCount) + } + + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: UPRUConstants.notificationsTabAccessCount) + } + } + + var welcomeNotificationSeenKey: String { + return "welcomeNotificationSeen" + } + + var notificationPrimerInlineWasAcknowledged: Bool { + get { + UserPersistentStoreFactory.instance().bool(forKey: UPRUConstants.notificationPrimerInlineWasAcknowledged) + } + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: UPRUConstants.notificationPrimerInlineWasAcknowledged) + } + } + + var secondNotificationsAlertCount: Int { + get { + UserPersistentStoreFactory.instance().integer(forKey: UPRUConstants.secondNotificationsAlertCount) + } + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: UPRUConstants.secondNotificationsAlertCount) + } + } + + var hasShownCustomAppIconUpgradeAlert: Bool { + get { + UserPersistentStoreFactory.instance().bool(forKey: UPRUConstants.hasShownCustomAppIconUpgradeAlert) + } + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: UPRUConstants.hasShownCustomAppIconUpgradeAlert) + } + } + + var createButtonTooltipDisplayCount: Int { + get { + UserPersistentStoreFactory.instance().integer(forKey: UPRUConstants.createButtonTooltipDisplayCount) + } + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: UPRUConstants.createButtonTooltipDisplayCount) + } + } + + var createButtonTooltipWasDisplayed: Bool { + get { + UserPersistentStoreFactory.instance().bool(forKey: UPRUConstants.createButtonTooltipWasDisplayed) + } + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: UPRUConstants.createButtonTooltipWasDisplayed) + } + } + + var savedPostsPromoWasDisplayed: Bool { + get { + return UserPersistentStoreFactory.instance().bool(forKey: UPRUConstants.savedPostsPromoWasDisplayed) + } + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: UPRUConstants.savedPostsPromoWasDisplayed) + } + } + + var storiesIntroWasAcknowledged: Bool { + get { + return UserPersistentStoreFactory.instance().bool(forKey: UPRUConstants.storiesIntroWasAcknowledged) + } + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: UPRUConstants.storiesIntroWasAcknowledged) + } + } + + var announcements: [Announcement]? { + get { + guard let encodedAnnouncements = UserPersistentStoreFactory.instance().object(forKey: UPRUConstants.currentAnnouncementsKey) as? Data, + let announcements = try? PropertyListDecoder().decode([Announcement].self, from: encodedAnnouncements) else { + return nil + } + return announcements + } + + set { + guard let announcements = newValue, let encodedAnnouncements = try? PropertyListEncoder().encode(announcements) else { + UserPersistentStoreFactory.instance().removeObject(forKey: UPRUConstants.currentAnnouncementsKey) + UserPersistentStoreFactory.instance().removeObject(forKey: UPRUConstants.currentAnnouncementsDateKey) + return + } + UserPersistentStoreFactory.instance().set(encodedAnnouncements, forKey: UPRUConstants.currentAnnouncementsKey) + UserPersistentStoreFactory.instance().set(Date(), forKey: UPRUConstants.currentAnnouncementsDateKey) + } + } + + var announcementsDate: Date? { + UserPersistentStoreFactory.instance().object(forKey: UPRUConstants.currentAnnouncementsDateKey) as? Date + } + + var announcementsVersionDisplayed: String? { + get { + UserPersistentStoreFactory.instance().string(forKey: UPRUConstants.announcementsVersionDisplayedKey) + } + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: UPRUConstants.announcementsVersionDisplayedKey) + } + } + + var jetpackContentMigrationState: MigrationState { + get { + let repository = UserPersistentStoreFactory.instance() + if let value = repository.string(forKey: UPRUConstants.jetpackContentMigrationStateKey) { + return MigrationState(rawValue: value) ?? .notStarted + } else if repository.bool(forKey: UPRUConstants.isJPContentImportCompleteKey) { + // Migrate the value of the old `isJPContentImportCompleteKey` to `jetpackContentMigrationStateKey` + let state = MigrationState.completed + repository.set(state.rawValue, forKey: UPRUConstants.jetpackContentMigrationStateKey) + repository.set(nil, forKey: UPRUConstants.isJPContentImportCompleteKey) + return state + } else { + return .notStarted + } + } set { + UserPersistentStoreFactory.instance().set(newValue.rawValue, forKey: UPRUConstants.jetpackContentMigrationStateKey) + } + } +} diff --git a/WordPress/Classes/Stores/UserPersistentStore.swift b/WordPress/Classes/Stores/UserPersistentStore.swift new file mode 100644 index 000000000000..a4d8341ac36f --- /dev/null +++ b/WordPress/Classes/Stores/UserPersistentStore.swift @@ -0,0 +1,126 @@ +class UserPersistentStore: UserPersistentRepository { + static let standard = UserPersistentStore(defaultsSuiteName: defaultsSuiteName)! + private static let defaultsSuiteName = WPAppGroupName // TBD + + private let userDefaults: UserDefaults + + init?(defaultsSuiteName: String) { + guard let suiteDefaults = UserDefaults(suiteName: defaultsSuiteName) else { + return nil + } + userDefaults = suiteDefaults + } + + // MARK: - UserPeresistentRepositoryReader + func object(forKey key: String) -> Any? { + if let object = userDefaults.object(forKey: key) { + return object + } + + return UserDefaults.standard.object(forKey: key) + } + + func string(forKey key: String) -> String? { + if let string = userDefaults.string(forKey: key) { + return string + } + + return UserDefaults.standard.string(forKey: key) + } + + func bool(forKey key: String) -> Bool { + userDefaults.bool(forKey: key) || UserDefaults.standard.bool(forKey: key) + } + + func integer(forKey key: String) -> Int { + let suiteValue = userDefaults.integer(forKey: key) + if suiteValue != 0 { + return suiteValue + } + + return UserDefaults.standard.integer(forKey: key) + } + + func float(forKey key: String) -> Float { + let suiteValue = userDefaults.float(forKey: key) + if suiteValue != 0 { + return suiteValue + } + + return UserDefaults.standard.float(forKey: key) + } + + func double(forKey key: String) -> Double { + let suiteValue = userDefaults.double(forKey: key) + if suiteValue != 0 { + return suiteValue + } + + return UserDefaults.standard.double(forKey: key) + } + + func array(forKey key: String) -> [Any]? { + let suiteValue = userDefaults.array(forKey: key) + if suiteValue != nil { + return suiteValue + } + + return UserDefaults.standard.array(forKey: key) + } + + func dictionary(forKey key: String) -> [String: Any]? { + let suiteValue = userDefaults.dictionary(forKey: key) + if suiteValue != nil { + return suiteValue + } + + return UserDefaults.standard.dictionary(forKey: key) + } + + func url(forKey key: String) -> URL? { + if let url = userDefaults.url(forKey: key) { + return url + } + + return UserDefaults.standard.url(forKey: key) + } + + func dictionaryRepresentation() -> [String: Any] { + return userDefaults.dictionaryRepresentation() + } + + // MARK: - UserPersistentRepositoryWriter + func set(_ value: Any?, forKey key: String) { + userDefaults.set(value, forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } + + func set(_ value: Int, forKey key: String) { + userDefaults.set(value, forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } + + func set(_ value: Float, forKey key: String) { + userDefaults.set(value, forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } + + func set(_ value: Double, forKey key: String) { + userDefaults.set(value, forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } + + func set(_ value: Bool, forKey key: String) { + userDefaults.set(value, forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } + + func set(_ url: URL?, forKey key: String) { + userDefaults.set(url, forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } + + func removeObject(forKey key: String) { + userDefaults.removeObject(forKey: key) + } +} diff --git a/WordPress/Classes/Stores/UserPersistentStoreFactory.swift b/WordPress/Classes/Stores/UserPersistentStoreFactory.swift new file mode 100644 index 000000000000..3bff47f760fd --- /dev/null +++ b/WordPress/Classes/Stores/UserPersistentStoreFactory.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Factory was used to sync WP & JP UserDefaults. It was decided to do it as one-off instead. +/// Instead of refactoring the call-sites, we simply return `standard` here. Although it looks +/// redundant, it gives us more flexibility to go back to syncing or apply similar changes and effect is isolated. +/// If it is evident that this kind of thing is no longer needed after migration is complete, we can remove this +/// and update call-sites to call `standard` directly. +@objc +final class UserPersistentStoreFactory: NSObject { + static func instance() -> UserPersistentRepository { + UserDefaults.standard + } + + @objc + static func userDefaultsInstance() -> UserDefaults { + return UserDefaults.standard + } +} diff --git a/WordPress/Classes/System/3DTouch/WP3DTouchShortcutCreator.swift b/WordPress/Classes/System/3DTouch/WP3DTouchShortcutCreator.swift index 040a579ed37e..a101ef5ea2bb 100644 --- a/WordPress/Classes/System/3DTouch/WP3DTouchShortcutCreator.swift +++ b/WordPress/Classes/System/3DTouch/WP3DTouchShortcutCreator.swift @@ -9,7 +9,7 @@ public protocol ApplicationShortcutsProvider { extension UIApplication: ApplicationShortcutsProvider { @objc public var is3DTouchAvailable: Bool { - return keyWindow?.traitCollection.forceTouchCapability == .available + return mainWindow?.traitCollection.forceTouchCapability == .available } } @@ -23,7 +23,6 @@ open class WP3DTouchShortcutCreator: NSObject { var shortcutsProvider: ApplicationShortcutsProvider @objc let mainContext = ContextManager.sharedInstance().mainContext - @objc let blogService: BlogService fileprivate let logInShortcutIconImageName = "icon-shortcut-signin" fileprivate let notificationsShortcutIconImageName = "icon-shortcut-notifications" @@ -33,7 +32,6 @@ open class WP3DTouchShortcutCreator: NSObject { public init(shortcutsProvider: ApplicationShortcutsProvider) { self.shortcutsProvider = shortcutsProvider - blogService = BlogService(managedObjectContext: mainContext) super.init() registerForNotifications() } @@ -78,8 +76,8 @@ open class WP3DTouchShortcutCreator: NSObject { fileprivate func loggedInShortcutArray() -> [UIApplicationShortcutItem] { var defaultBlogName: String? - if blogService.blogCountForAllAccounts() > 1 { - defaultBlogName = blogService.lastUsedOrFirstBlog()?.settings?.name + if Blog.count(in: mainContext) > 1 { + defaultBlogName = Blog.lastUsedOrFirst(in: mainContext)?.settings?.name } let notificationsShortcut = UIMutableApplicationShortcutItem(type: WP3DTouchShortcutHandler.ShortcutIdentifier.Notifications.type, @@ -110,23 +108,27 @@ open class WP3DTouchShortcutCreator: NSObject { } @objc fileprivate func createLoggedInShortcuts() { + DispatchQueue.main.async {[weak self]() in guard let strongSelf = self else { return } let entireShortcutArray = strongSelf.loggedInShortcutArray() var visibleShortcutArray = [UIApplicationShortcutItem]() + let jetpackFeaturesEnabled = JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() - if strongSelf.hasWordPressComAccount() { + if jetpackFeaturesEnabled && strongSelf.hasWordPressComAccount() { visibleShortcutArray.append(entireShortcutArray[LoggedIn3DTouchShortcutIndex.notifications.rawValue]) } - if strongSelf.doesCurrentBlogSupportStats() { + if jetpackFeaturesEnabled && strongSelf.doesCurrentBlogSupportStats() { visibleShortcutArray.append(entireShortcutArray[LoggedIn3DTouchShortcutIndex.stats.rawValue]) } - visibleShortcutArray.append(entireShortcutArray[LoggedIn3DTouchShortcutIndex.newPhotoPost.rawValue]) - visibleShortcutArray.append(entireShortcutArray[LoggedIn3DTouchShortcutIndex.newPost.rawValue]) + if AppConfiguration.allowsNewPostShortcut { + visibleShortcutArray.append(entireShortcutArray[LoggedIn3DTouchShortcutIndex.newPhotoPost.rawValue]) + visibleShortcutArray.append(entireShortcutArray[LoggedIn3DTouchShortcutIndex.newPost.rawValue]) + } strongSelf.shortcutsProvider.shortcutItems = visibleShortcutArray } @@ -141,7 +143,7 @@ open class WP3DTouchShortcutCreator: NSObject { } fileprivate func is3DTouchAvailable() -> Bool { - let window = UIApplication.shared.keyWindow + let window = UIApplication.shared.mainWindow return window?.traitCollection.forceTouchCapability == .available } @@ -151,7 +153,7 @@ open class WP3DTouchShortcutCreator: NSObject { } fileprivate func doesCurrentBlogSupportStats() -> Bool { - guard let currentBlog = blogService.lastUsedOrFirstBlog() else { + guard let currentBlog = Blog.lastUsedOrFirst(in: mainContext) else { return false } @@ -159,6 +161,6 @@ open class WP3DTouchShortcutCreator: NSObject { } fileprivate func hasBlog() -> Bool { - return blogService.blogCountForAllAccounts() > 0 + return Blog.count(in: mainContext) > 0 } } diff --git a/WordPress/Classes/System/3DTouch/WP3DTouchShortcutHandler.swift b/WordPress/Classes/System/3DTouch/WP3DTouchShortcutHandler.swift index 7352f8c86e5f..2127f862ee8c 100644 --- a/WordPress/Classes/System/3DTouch/WP3DTouchShortcutHandler.swift +++ b/WordPress/Classes/System/3DTouch/WP3DTouchShortcutHandler.swift @@ -25,7 +25,7 @@ open class WP3DTouchShortcutHandler: NSObject { @objc static let applicationShortcutUserInfoIconKey = "applicationShortcutUserInfoIconKey" @objc open func handleShortcutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool { - let tabBarController: WPTabBarController = WPTabBarController.sharedInstance() + let rootViewPresenter = RootViewCoordinator.sharedPresenter switch shortcutItem.type { case ShortcutIdentifier.LogIn.type: @@ -33,22 +33,23 @@ open class WP3DTouchShortcutHandler: NSObject { return true case ShortcutIdentifier.NewPost.type: WPAnalytics.track(.shortcutNewPost) - tabBarController.showPostTab(animated: false, toMedia: false) + rootViewPresenter.showPostTab(animated: false, toMedia: false) return true case ShortcutIdentifier.NewPhotoPost.type: WPAnalytics.track(.shortcutNewPhotoPost) - tabBarController.showPostTab(animated: false, toMedia: true) + rootViewPresenter.showPostTab(animated: false, toMedia: true) return true case ShortcutIdentifier.Stats.type: WPAnalytics.track(.shortcutStats) clearCurrentViewController() - let blogService: BlogService = BlogService(managedObjectContext: ContextManager.sharedInstance().mainContext) - tabBarController.switchMySitesTabToStatsView(for: blogService.lastUsedOrFirstBlog()) + if let mainBlog = Blog.lastUsedOrFirst(in: ContextManager.sharedInstance().mainContext) { + rootViewPresenter.mySitesCoordinator.showStats(for: mainBlog) + } return true case ShortcutIdentifier.Notifications.type: WPAnalytics.track(.shortcutNotifications) clearCurrentViewController() - tabBarController.showNotificationsTab() + rootViewPresenter.showNotificationsTab() return true default: return false diff --git a/WordPress/Classes/System/Constants.h b/WordPress/Classes/System/Constants.h index abdf4fec4f8f..b2119c8690cc 100644 --- a/WordPress/Classes/System/Constants.h +++ b/WordPress/Classes/System/Constants.h @@ -13,11 +13,9 @@ extern NSString *const WPMobileReaderDetailURL; extern NSString *const WPAutomatticMainURL; extern NSString *const WPAutomatticTermsOfServiceURL; extern NSString *const WPAutomatticPrivacyURL; +extern NSString *const WPAutomatticCCPAPrivacyNoticeURL; extern NSString *const WPAutomatticCookiesURL; -extern NSString *const WPAutomatticAppsBlogURL; extern NSString *const WPGithubMainURL; -extern NSString *const WPTwitterWordPressHandle; -extern NSString *const WPTwitterWordPressMobileURL; extern NSString *const WPComReferrerURL; extern NSString *const AutomatticDomain; extern NSString *const WPComDomain; @@ -42,6 +40,7 @@ extern NSString *const WPNotificationContentExtensionKeychainUsernameKey; extern NSString *const WPNotificationServiceExtensionKeychainServiceName; extern NSString *const WPNotificationServiceExtensionKeychainTokenKey; extern NSString *const WPNotificationServiceExtensionKeychainUsernameKey; +extern NSString *const WPNotificationServiceExtensionKeychainUserIDKey; /// Share Extension Constants /// @@ -55,15 +54,6 @@ extern NSString *const WPShareExtensionUserDefaultsLastUsedSiteID; extern NSString *const WPShareExtensionMaximumMediaDimensionKey; extern NSString *const WPShareExtensionRecentSitesKey; -/// Today Widget Constants -/// -extern NSString *const WPStatsTodayWidgetKeychainTokenKey; -extern NSString *const WPStatsTodayWidgetKeychainServiceName; -extern NSString *const WPStatsTodayWidgetUserDefaultsSiteIdKey; -extern NSString *const WPStatsTodayWidgetUserDefaultsSiteNameKey; -extern NSString *const WPStatsTodayWidgetUserDefaultsSiteUrlKey; -extern NSString *const WPStatsTodayWidgetUserDefaultsSiteTimeZoneKey; - /// Apple ID Constants /// extern NSString *const WPAppleIDKeychainUsernameKey; diff --git a/WordPress/Classes/System/Constants.m b/WordPress/Classes/System/Constants.m index 8fef78945fe2..41022050a0bb 100644 --- a/WordPress/Classes/System/Constants.m +++ b/WordPress/Classes/System/Constants.m @@ -13,29 +13,21 @@ NSString *const WPAutomatticMainURL = @"https://automattic.com/"; NSString *const WPAutomatticTermsOfServiceURL = @"https://wordpress.com/tos/"; NSString *const WPAutomatticPrivacyURL = @"https://automattic.com/privacy/"; +NSString *const WPAutomatticCCPAPrivacyNoticeURL = @"https://automattic.com/privacy/#california-consumer-privacy-act-ccpa"; NSString *const WPAutomatticCookiesURL = @"https://automattic.com/cookies/"; -NSString *const WPAutomatticAppsBlogURL = @"https://blog.wordpress.com"; NSString *const WPGithubMainURL = @"https://github.com/wordpress-mobile/WordPress-iOS/"; -NSString *const WPTwitterWordPressHandle = @"@WordPressiOS"; -NSString *const WPTwitterWordPressMobileURL = @"https://twitter.com/WordPressiOS"; NSString *const WPComReferrerURL = @"https://wordpress.com"; NSString *const AutomatticDomain = @"automattic.com"; NSString *const WPComDomain = @"wordpress.com"; -/// Notifications Constants -/// -#ifdef DEBUG -NSString *const WPPushNotificationAppId = @"org.wordpress.appstore.dev"; -#else -#ifdef INTERNAL_BUILD -NSString *const WPPushNotificationAppId = @"org.wordpress.internal"; -#else -NSString *const WPPushNotificationAppId = @"org.wordpress.appstore"; -#endif -#endif /// Keychain Constants /// -#ifdef INTERNAL_BUILD +/// Note: Multiple compiler flags are set for some builds, so conditional ordering matters. +/// +#if defined(ALPHA_BUILD) +NSString *const WPAppGroupName = @"group.org.wordpress.alpha"; +NSString *const WPAppKeychainAccessGroup = @"99KV9Z6BKV.org.wordpress.alpha"; +#elif defined(INTERNAL_BUILD) NSString *const WPAppGroupName = @"group.org.wordpress.internal"; NSString *const WPAppKeychainAccessGroup = @"99KV9Z6BKV.org.wordpress.internal"; #else @@ -49,33 +41,6 @@ NSString *const WPNotificationContentExtensionKeychainTokenKey = @"OAuth2Token"; NSString *const WPNotificationContentExtensionKeychainUsernameKey = @"Username"; -/// Notification Service Extension Constants -/// -NSString *const WPNotificationServiceExtensionKeychainServiceName = @"NotificationServiceExtension"; -NSString *const WPNotificationServiceExtensionKeychainTokenKey = @"OAuth2Token"; -NSString *const WPNotificationServiceExtensionKeychainUsernameKey = @"Username"; - -/// Share Extension Constants -/// -NSString *const WPShareExtensionKeychainUsernameKey = @"Username"; -NSString *const WPShareExtensionKeychainTokenKey = @"OAuth2Token"; -NSString *const WPShareExtensionKeychainServiceName = @"ShareExtension"; -NSString *const WPShareExtensionUserDefaultsPrimarySiteName = @"WPShareUserDefaultsPrimarySiteName"; -NSString *const WPShareExtensionUserDefaultsPrimarySiteID = @"WPShareUserDefaultsPrimarySiteID"; -NSString *const WPShareExtensionUserDefaultsLastUsedSiteName = @"WPShareUserDefaultsLastUsedSiteName"; -NSString *const WPShareExtensionUserDefaultsLastUsedSiteID = @"WPShareUserDefaultsLastUsedSiteID"; -NSString *const WPShareExtensionMaximumMediaDimensionKey = @"WPShareExtensionMaximumMediaDimensionKey"; -NSString *const WPShareExtensionRecentSitesKey = @"WPShareExtensionRecentSitesKey"; - -/// Today Widget Constants -/// -NSString *const WPStatsTodayWidgetKeychainTokenKey = @"OAuth2Token"; -NSString *const WPStatsTodayWidgetKeychainServiceName = @"TodayWidget"; -NSString *const WPStatsTodayWidgetUserDefaultsSiteIdKey = @"WordPressTodayWidgetSiteId"; -NSString *const WPStatsTodayWidgetUserDefaultsSiteNameKey = @"WordPressTodayWidgetSiteName"; -NSString *const WPStatsTodayWidgetUserDefaultsSiteUrlKey = @"WordPressTodayWidgetSiteUrl"; -NSString *const WPStatsTodayWidgetUserDefaultsSiteTimeZoneKey = @"WordPressTodayWidgetTimeZone"; - /// Apple ID Constants /// NSString *const WPAppleIDKeychainUsernameKey = @"Username"; diff --git a/WordPress/Classes/System/MySitesCoordinator+RootViewPresenter.swift b/WordPress/Classes/System/MySitesCoordinator+RootViewPresenter.swift new file mode 100644 index 000000000000..79799612cde6 --- /dev/null +++ b/WordPress/Classes/System/MySitesCoordinator+RootViewPresenter.swift @@ -0,0 +1,143 @@ +import Foundation + +/// `MySitesCoordinator` is used as the root presenter when Jetpack features are disabled +/// and the app's UI is simplified. +extension MySitesCoordinator: RootViewPresenter { + + // MARK: General + + var currentViewController: UIViewController? { + return rootViewController + } + + func getMeScenePresenter() -> ScenePresenter { + meScenePresenter + } + + func currentlySelectedScreen() -> String { + return "Blog List" + } + + func currentlyVisibleBlog() -> Blog? { + currentBlog + } + + // MARK: Reader + + var readerTabViewController: ReaderTabViewController? { + unsupportedFeatureFallback() + return nil + } + + var readerCoordinator: ReaderCoordinator? { + unsupportedFeatureFallback() + return nil + } + + var readerNavigationController: UINavigationController? { + unsupportedFeatureFallback() + return nil + } + + func showReaderTab() { + unsupportedFeatureFallback() + } + + func switchToDiscover() { + unsupportedFeatureFallback() + } + + func switchToSavedPosts() { + unsupportedFeatureFallback() + } + + func resetReaderDiscoverNudgeFlow() { + unsupportedFeatureFallback() + } + + func resetReaderTab() { + unsupportedFeatureFallback() + } + + func navigateToReaderSearch() { + unsupportedFeatureFallback() + } + + func navigateToReaderSearch(withSearchText: String) { + unsupportedFeatureFallback() + } + + func switchToTopic(where predicate: (ReaderAbstractTopic) -> Bool) { + unsupportedFeatureFallback() + } + + func switchToMyLikes() { + unsupportedFeatureFallback() + } + + func switchToFollowedSites() { + unsupportedFeatureFallback() + } + + func navigateToReaderSite(_ topic: ReaderSiteTopic) { + unsupportedFeatureFallback() + } + + func navigateToReaderTag(_ topic: ReaderTagTopic) { + unsupportedFeatureFallback() + } + + func navigateToReader(_ pushControlller: UIViewController?) { + unsupportedFeatureFallback() + } + + func showReaderTab(forPost: NSNumber, onBlog: NSNumber) { + unsupportedFeatureFallback() + } + + // MARK: My Site + + var mySitesCoordinator: MySitesCoordinator { + return self + } + + func showMySitesTab() { + // Do nothing + // Landing here means we're trying to show the My Sites, but it's already showing. + } + + // MARK: Notifications + + var notificationsViewController: NotificationsViewController? { + unsupportedFeatureFallback() + return nil + } + + func showNotificationsTab() { + unsupportedFeatureFallback() + } + + func switchNotificationsTabToNotificationSettings() { + unsupportedFeatureFallback() + } + + func showNotificationsTabForNote(withID notificationID: String) { + unsupportedFeatureFallback() + } + + func popNotificationsTabToRoot() { + unsupportedFeatureFallback() + } + + // MARK: Helpers + + /// Default implementation for functions that are not supported by the simplified UI. + func unsupportedFeatureFallback(callingFunction: String = #function) { + // Display overlay + displayJetpackOverlayForDisabledEntryPoint() + + // Track incorrect access + let properties = ["calling_function": callingFunction] + WPAnalytics.track(.jetpackFeatureIncorrectlyAccessed, properties: properties) + } +} diff --git a/WordPress/Classes/System/RootViewCoordinator.swift b/WordPress/Classes/System/RootViewCoordinator.swift new file mode 100644 index 000000000000..a95d52b78264 --- /dev/null +++ b/WordPress/Classes/System/RootViewCoordinator.swift @@ -0,0 +1,140 @@ +import Foundation + +extension NSNotification.Name { + static let WPAppUITypeChanged = NSNotification.Name(rawValue: "WPAppUITypeChanged") +} + +class RootViewCoordinator { + + // MARK: Class Enum + + enum AppUIType { + case normal + case simplified + case staticScreens + } + + // MARK: Static shared variables + + static let shared = RootViewCoordinator(featureFlagStore: RemoteFeatureFlagStore(), + windowManager: WordPressAppDelegate.shared?.windowManager) + static var sharedPresenter: RootViewPresenter { + shared.rootViewPresenter + } + + // MARK: Public Variables + + lazy var whatIsNewScenePresenter: ScenePresenter = { + return makeWhatIsNewPresenter() + }() + + lazy var bloggingPromptCoordinator: BloggingPromptCoordinator = { + return makeBloggingPromptCoordinator() + }() + + // MARK: Private instance variables + + private(set) var rootViewPresenter: RootViewPresenter + private var currentAppUIType: AppUIType { + didSet { + updateJetpackFeaturesRemovalCoordinatorState() + } + } + private var featureFlagStore: RemoteFeatureFlagStore + private var windowManager: WindowManager? + + // MARK: Initializer + + init(featureFlagStore: RemoteFeatureFlagStore, + windowManager: WindowManager?) { + self.featureFlagStore = featureFlagStore + self.windowManager = windowManager + self.currentAppUIType = Self.appUIType(featureFlagStore: featureFlagStore) + switch self.currentAppUIType { + case .normal: + self.rootViewPresenter = WPTabBarController(staticScreens: false) + case .simplified: + let meScenePresenter = MeScenePresenter() + self.rootViewPresenter = MySitesCoordinator(meScenePresenter: meScenePresenter, onBecomeActiveTab: {}) + case .staticScreens: + self.rootViewPresenter = StaticScreensTabBarWrapper() + } + updateJetpackFeaturesRemovalCoordinatorState() + updatePromptsIfNeeded() + } + + // MARK: JP Features State + + /// Used to determine the expected app UI type based on the removal phase. + private static func appUIType(featureFlagStore: RemoteFeatureFlagStore) -> AppUIType { + let phase = JetpackFeaturesRemovalCoordinator.generalPhase(featureFlagStore: featureFlagStore) + switch phase { + case .four, .newUsers, .selfHosted: + return .simplified + case .staticScreens: + return .staticScreens + default: + return .normal + } + } + + private func updateJetpackFeaturesRemovalCoordinatorState() { + JetpackFeaturesRemovalCoordinator.currentAppUIType = currentAppUIType + } + + // MARK: UI Reload + + /// Reload the UI if needed after the app has already been launched. + /// - Returns: Boolean value describing whether the UI was reloaded or not. + @discardableResult + func reloadUIIfNeeded(blog: Blog?) -> Bool { + let newUIType: AppUIType = Self.appUIType(featureFlagStore: featureFlagStore) + let oldUIType = currentAppUIType + guard newUIType != oldUIType, let windowManager else { + return false + } + currentAppUIType = newUIType + displayOverlay(using: windowManager, blog: blog) + reloadUI(using: windowManager) + postUIReloadedNotification() + return true + } + + private func displayOverlay(using windowManager: WindowManager, blog: Blog?) { + guard currentAppUIType == .simplified else { + return + } + + let viewController = BlurredEmptyViewController() + + windowManager.displayOverlayingWindow(with: viewController) + + JetpackFeaturesRemovalCoordinator.presentOverlayIfNeeded(in: viewController, + source: .appOpen, + forced: true, + fullScreen: true, + blog: blog, + onWillDismiss: { + viewController.removeBlurView() + }, onDidDismiss: { + windowManager.clearOverlayingWindow() + }) + } + + private func reloadUI(using windowManager: WindowManager) { + switch currentAppUIType { + case .normal: + self.rootViewPresenter = WPTabBarController(staticScreens: false) + case .simplified: + let meScenePresenter = MeScenePresenter() + self.rootViewPresenter = MySitesCoordinator(meScenePresenter: meScenePresenter, onBecomeActiveTab: {}) + case .staticScreens: + self.rootViewPresenter = StaticScreensTabBarWrapper() + } + windowManager.showUI(animated: false) + } + + private func postUIReloadedNotification() { + NotificationCenter.default.post(name: .WPAppUITypeChanged, object: nil) + } +} diff --git a/WordPress/Classes/System/RootViewPresenter+EditorNavigation.swift b/WordPress/Classes/System/RootViewPresenter+EditorNavigation.swift new file mode 100644 index 000000000000..be350db9f413 --- /dev/null +++ b/WordPress/Classes/System/RootViewPresenter+EditorNavigation.swift @@ -0,0 +1,135 @@ +import Foundation + +extension RootViewPresenter { + func currentOrLastBlog() -> Blog? { + if let blog = currentlyVisibleBlog() { + return blog + } + let context = ContextManager.shared.mainContext + return Blog.lastUsedOrFirst(in: context) + } + + func showPostTab() { + showPostTab(completion: nil) + } + + func showPostTab(completion afterDismiss: (() -> Void)?) { + let context = ContextManager.shared.mainContext + // Ignore taps on the post tab and instead show the modal. + if Blog.count(in: context) == 0 { + mySitesCoordinator.showAddNewSite() + } else { + showPostTab(animated: true, toMedia: false, completion: afterDismiss) + } + } + + func showPostTab(for blog: Blog) { + let context = ContextManager.shared.mainContext + if Blog.count(in: context) == 0 { + mySitesCoordinator.showAddNewSite() + } else { + showPostTab(animated: true, toMedia: false, blog: blog) + } + } + + func showPostTab(animated: Bool, + toMedia openToMedia: Bool, + blog: Blog? = nil, + completion afterDismiss: (() -> Void)? = nil) { + if rootViewController.presentedViewController != nil { + rootViewController.dismiss(animated: false) + } + + guard let blog = blog ?? currentOrLastBlog() else { + return + } + + let editor = EditPostViewController(blog: blog) + editor.modalPresentationStyle = .fullScreen + editor.showImmediately = !animated + editor.openWithMediaPicker = openToMedia + editor.afterDismiss = afterDismiss + + let properties = [WPAppAnalyticsKeyTapSource: "create_button", WPAppAnalyticsKeyPostType: "post"] + WPAppAnalytics.track(.editorCreatedPost, withProperties: properties, with: blog) + rootViewController.present(editor, animated: false) + } + + func showPageEditor(forBlog: Blog? = nil) { + showPageEditor(blog: forBlog) + } + + func showStoryEditor(forBlog: Blog? = nil) { + showStoryEditor(blog: forBlog) + } + + /// Show the page tab + /// - Parameter blog: Blog to a add a page to. Uses the current or last blog if not provided + func showPageEditor(blog inBlog: Blog? = nil, title: String? = nil, content: String? = nil, source: String = "create_button") { + + // If we are already showing a view controller, dismiss and show the editor afterward + guard rootViewController.presentedViewController == nil else { + rootViewController.dismiss(animated: true) { [weak self] in + self?.showPageEditor(blog: inBlog, title: title, content: content, source: source) + } + return + } + guard let blog = inBlog ?? self.currentOrLastBlog() else { return } + guard content == nil else { + showEditor(blog: blog, title: title, content: content, templateKey: nil) + return + } + + WPAnalytics.track(WPAnalyticsEvent.editorCreatedPage, + properties: [WPAppAnalyticsKeyTapSource: source], + blog: blog) + PageCoordinator.showLayoutPickerIfNeeded(from: rootViewController, forBlog: blog) { [weak self] (selectedLayout) in + self?.showEditor(blog: blog, title: selectedLayout?.title, content: selectedLayout?.content, templateKey: selectedLayout?.slug) + } + } + + private func showEditor(blog: Blog, title: String?, content: String?, templateKey: String?) { + let editorViewController = EditPageViewController(blog: blog, postTitle: title, content: content, appliedTemplate: templateKey) + rootViewController.present(editorViewController, animated: false) + } + + /// Show the story editor + /// - Parameter blog: Blog to a add a story to. Uses the current or last blog if not provided + func showStoryEditor(blog inBlog: Blog? = nil, title: String? = nil, content: String? = nil, source: String = "create_button") { + // If we are already showing a view controller, dismiss and show the editor afterward + guard rootViewController.presentedViewController == nil else { + rootViewController.dismiss(animated: true) { [weak self] in + self?.showStoryEditor(blog: inBlog, title: title, content: content, source: source) + } + return + } + + if UserPersistentStoreFactory.instance().storiesIntroWasAcknowledged == false { + // Show Intro screen + let intro = StoriesIntroViewController(continueTapped: { [weak self] in + UserPersistentStoreFactory.instance().storiesIntroWasAcknowledged = true + self?.showStoryEditor() + }, openURL: { [weak self] url in + let webViewController = WebViewControllerFactory.controller(url: url, source: "show_story_example") + let navController = UINavigationController(rootViewController: webViewController) + self?.rootViewController.presentedViewController?.present(navController, animated: true) + }) + + rootViewController.present(intro, animated: true, completion: { + StoriesIntroViewController.trackShown() + }) + } else { + guard let blog = inBlog ?? self.currentOrLastBlog() else { return } + let blogID = blog.dotComID?.intValue ?? 0 as Any + + WPAppAnalytics.track(.editorCreatedPost, withProperties: [WPAppAnalyticsKeyTapSource: source, WPAppAnalyticsKeyBlogID: blogID, WPAppAnalyticsKeyEditorSource: "stories", WPAppAnalyticsKeyPostType: "post"]) + + do { + let controller = try StoryEditor.editor(blog: blog, context: ContextManager.shared.mainContext, updated: {_ in }) + rootViewController.present(controller, animated: true, completion: nil) + } catch { + assertionFailure("Story editor should not fail since this button is hidden on iPads.") + } + } + } +} diff --git a/WordPress/Classes/System/RootViewPresenter+MeNavigation.swift b/WordPress/Classes/System/RootViewPresenter+MeNavigation.swift new file mode 100644 index 000000000000..4802dcad485f --- /dev/null +++ b/WordPress/Classes/System/RootViewPresenter+MeNavigation.swift @@ -0,0 +1,58 @@ +import UIKit + +/// Methods to access the Me Scene and sub levels +extension RootViewPresenter { + /// removes all but the primary viewControllers from the stack + func popMeTabToRoot() { + getNavigationController()?.popToRootViewController(animated: false) + } + /// presents the Me scene. If the feature flag is disabled, replaces the previously defined `showMeTab` + func showMeScene(animated: Bool = true, completion: (() -> Void)? = nil) { + let meScenePresenter = getMeScenePresenter() + meScenePresenter.present(on: rootViewController, animated: animated, completion: completion) + } + /// access to sub levels + func navigateToAccountSettings() { + showMeScene(animated: false) { + self.popMeTabToRoot() + self.getMeViewController()?.navigateToAccountSettings() + } + } + + func navigateToAppSettings() { + showMeScene() { + self.popMeTabToRoot() + self.getMeViewController()?.navigateToAppSettings() + } + } + + func navigateToSupport() { + showMeScene() { + self.popMeTabToRoot() + self.getMeViewController()?.navigateToHelpAndSupport() + } + } + + /// obtains a reference to the navigation controller of the presented MeViewController + private func getNavigationController() -> UINavigationController? { + let meScenePresenter = getMeScenePresenter() + if let navigationController = meScenePresenter.presentedViewController as? UINavigationController { + return navigationController + } + + if let splitController = meScenePresenter.presentedViewController as? WPSplitViewController, + let navigationController = splitController.viewControllers.first as? UINavigationController { + return navigationController + } + return nil + } + + /// obtains a reference to the presented MeViewController + private func getMeViewController() -> MeViewController? { + guard let navigationController = getNavigationController(), + let meController = navigationController.viewControllers.first as? MeViewController else { + return nil + } + return meController + } +} diff --git a/WordPress/Classes/System/RootViewPresenter.swift b/WordPress/Classes/System/RootViewPresenter.swift new file mode 100644 index 000000000000..90a6614bc59e --- /dev/null +++ b/WordPress/Classes/System/RootViewPresenter.swift @@ -0,0 +1,51 @@ +import Foundation + +protocol RootViewPresenter: AnyObject { + + // MARK: General + + var rootViewController: UIViewController { get } + var currentViewController: UIViewController? { get } + func showBlogDetails(for blog: Blog) + func getMeScenePresenter() -> ScenePresenter + func currentlySelectedScreen() -> String + func currentlyVisibleBlog() -> Blog? + func willDisplayPostSignupFlow() + + // MARK: Reader + + var readerTabViewController: ReaderTabViewController? { get } + var readerCoordinator: ReaderCoordinator? { get } + var readerNavigationController: UINavigationController? { get } + func showReaderTab() + func showReaderTab(forPost: NSNumber, onBlog: NSNumber) + func switchToDiscover() + func switchToSavedPosts() + func resetReaderDiscoverNudgeFlow() + func resetReaderTab() + func navigateToReaderSearch() + func navigateToReaderSearch(withSearchText: String) + func switchToTopic(where predicate: (ReaderAbstractTopic) -> Bool) + func switchToMyLikes() + func switchToFollowedSites() + func navigateToReaderSite(_ topic: ReaderSiteTopic) + func navigateToReaderTag( _ topic: ReaderTagTopic) + func navigateToReader(_ pushControlller: UIViewController?) + + // MARK: My Site + + var mySitesCoordinator: MySitesCoordinator { get } + func showMySitesTab() + func showPages(for blog: Blog) + func showPosts(for blog: Blog) + func showMedia(for blog: Blog) + + // MARK: Notifications + + var notificationsViewController: NotificationsViewController? { get } + func showNotificationsTab() + func showNotificationsTabForNote(withID notificationID: String) + func switchNotificationsTabToNotificationSettings() + func popNotificationsTabToRoot() + +} diff --git a/WordPress/Classes/System/StaticScreensTabBarWrapper.swift b/WordPress/Classes/System/StaticScreensTabBarWrapper.swift new file mode 100644 index 000000000000..7a5e39cd2dbe --- /dev/null +++ b/WordPress/Classes/System/StaticScreensTabBarWrapper.swift @@ -0,0 +1,155 @@ +import Foundation + +/// `StaticScreensTabBarWrapper` is used as the root presenter when Jetpack features are disabled +/// but not fully removed. The class wraps around `WPTabBarController` +/// but disables all Reader and Notifications functionality +class StaticScreensTabBarWrapper: RootViewPresenter { + + // MARK: Private Variables + + private let tabBarController = WPTabBarController(staticScreens: true) + + // MARK: General + + var rootViewController: UIViewController { + return tabBarController + } + + var currentViewController: UIViewController? { + return tabBarController.currentViewController + } + + func getMeScenePresenter() -> ScenePresenter { + tabBarController.getMeScenePresenter() + } + + func currentlySelectedScreen() -> String { + tabBarController.currentlySelectedScreen() + } + + func showBlogDetails(for blog: Blog) { + tabBarController.showBlogDetails(for: blog) + } + + func currentlyVisibleBlog() -> Blog? { + tabBarController.currentlyVisibleBlog() + } + + func willDisplayPostSignupFlow() { + tabBarController.willDisplayPostSignupFlow() + } + + // MARK: Reader + + var readerTabViewController: ReaderTabViewController? { + return nil + } + + var readerCoordinator: ReaderCoordinator? { + return nil + } + + var readerNavigationController: UINavigationController? { + return nil + } + + func showReaderTab() { + tabBarController.showReaderTab() + } + + func showReaderTab(forPost: NSNumber, onBlog: NSNumber) { + // Do nothing + } + + func switchToDiscover() { + // Do nothing + } + + func switchToSavedPosts() { + // Do nothing + } + + func resetReaderDiscoverNudgeFlow() { + // Do nothing + } + + func resetReaderTab() { + // Do nothing + } + + func navigateToReaderSearch() { + // Do nothing + } + + func navigateToReaderSearch(withSearchText: String) { + // Do nothing + } + + func switchToTopic(where predicate: (ReaderAbstractTopic) -> Bool) { + // Do nothing + } + + func switchToMyLikes() { + // Do nothing + } + + func switchToFollowedSites() { + // Do nothing + } + + func navigateToReaderSite(_ topic: ReaderSiteTopic) { + // Do nothing + } + + func navigateToReaderTag(_ topic: ReaderTagTopic) { + // Do nothing + } + + func navigateToReader(_ pushControlller: UIViewController?) { + // Do nothing + } + + // MARK: My Site + + var mySitesCoordinator: MySitesCoordinator { + return tabBarController.mySitesCoordinator + } + + func showMySitesTab() { + tabBarController.showMySitesTab() + } + + func showPages(for blog: Blog) { + tabBarController.showPages(for: blog) + } + + func showPosts(for blog: Blog) { + tabBarController.showPosts(for: blog) + } + + func showMedia(for blog: Blog) { + tabBarController.showMedia(for: blog) + } + + // MARK: Notifications + + var notificationsViewController: NotificationsViewController? { + return nil + } + + func showNotificationsTab() { + tabBarController.showNotificationsTab() + } + + func showNotificationsTabForNote(withID notificationID: String) { + tabBarController.showNotificationsTab() + } + + func switchNotificationsTabToNotificationSettings() { + tabBarController.showNotificationsTab() + } + + func popNotificationsTabToRoot() { + // Do nothing since static notification tab will never have a stack + } +} diff --git a/WordPress/Classes/System/WPGUIConstants.h b/WordPress/Classes/System/WPGUIConstants.h index 72a8ab9cd506..e8cc1ba4159e 100644 --- a/WordPress/Classes/System/WPGUIConstants.h +++ b/WordPress/Classes/System/WPGUIConstants.h @@ -6,6 +6,7 @@ extern const CGFloat WPAlphaZero; extern const CGFloat WPColorFull; extern const CGFloat WPColorZero; +extern const NSTimeInterval WPAnimationDurationSlow; extern const NSTimeInterval WPAnimationDurationDefault; extern const NSTimeInterval WPAnimationDurationFast; extern const NSTimeInterval WPAnimationDurationFaster; diff --git a/WordPress/Classes/System/WPGUIConstants.m b/WordPress/Classes/System/WPGUIConstants.m index 625f48366463..94b6f9dd7f63 100644 --- a/WordPress/Classes/System/WPGUIConstants.m +++ b/WordPress/Classes/System/WPGUIConstants.m @@ -6,6 +6,7 @@ const CGFloat WPColorFull = 1.0; const CGFloat WPColorZero = 0.0; +const NSTimeInterval WPAnimationDurationSlow = 0.6; const NSTimeInterval WPAnimationDurationDefault = 0.33; const NSTimeInterval WPAnimationDurationFast = 0.15; const NSTimeInterval WPAnimationDurationFaster = 0.07; diff --git a/WordPress/Classes/System/WPTabBarController+RootViewPresenter.swift b/WordPress/Classes/System/WPTabBarController+RootViewPresenter.swift new file mode 100644 index 000000000000..aa6d3cce6fe0 --- /dev/null +++ b/WordPress/Classes/System/WPTabBarController+RootViewPresenter.swift @@ -0,0 +1,49 @@ +import Foundation + +/// `WPTabBarController` is used as the root presenter when Jetpack features are enabled +/// and the app's UI is normal. +extension WPTabBarController: RootViewPresenter { + + // MARK: General + + var rootViewController: UIViewController { + return self + } + + var currentViewController: UIViewController? { + return viewControllers?[selectedIndex] + } + + func showBlogDetails(for blog: Blog) { + mySitesCoordinator.showBlogDetails(for: blog) + } + + func getMeScenePresenter() -> ScenePresenter { + meScenePresenter + } + + func currentlyVisibleBlog() -> Blog? { + guard selectedIndex == WPTab.mySites.rawValue else { + return nil + } + return mySitesCoordinator.currentBlog + } + + func willDisplayPostSignupFlow() { + mySitesCoordinator.willDisplayPostSignupFlow() + } + + // MARK: My Site + + func showPages(for blog: Blog) { + mySitesCoordinator.showPages(for: blog) + } + + func showPosts(for blog: Blog) { + mySitesCoordinator.showPosts(for: blog) + } + + func showMedia(for blog: Blog) { + mySitesCoordinator.showMedia(for: blog) + } +} diff --git a/WordPress/Classes/System/WindowManager.swift b/WordPress/Classes/System/WindowManager.swift new file mode 100644 index 000000000000..2e90579cfeb3 --- /dev/null +++ b/WordPress/Classes/System/WindowManager.swift @@ -0,0 +1,142 @@ +import Foundation +import WordPressAuthenticator + +/// This class takes care of managing the App window and its `rootViewController`. +/// This is mostly intended to handle the UI transitions between authenticated and unauthenticated user sessions. +/// +@objc +class WindowManager: NSObject { + + typealias Completion = () -> Void + + /// The App's window. + /// + private let window: UIWindow + + /// Temporary window that's displayed on top of the app's UI when needed. + /// + private var overlayingWindow: UIWindow? + + /// A boolean to track whether we're showing the sign in flow in fullscreen mode.. + /// + private(set) var isShowingFullscreenSignIn = false + + /// The root view controller for the window. + /// + var rootViewController: UIViewController? { + return window.rootViewController + } + + init(window: UIWindow) { + self.window = window + } + + // MARK: - Initial App UI + + /// 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(for blog: Blog? = nil, animated: Bool = true) { + if AccountHelper.isLoggedIn { + showAppUI(for: blog, animated: animated) + } else { + showSignInUI() + } + } + + /// Shows the SignIn UI flow if the conditions to do so are met. + /// + @objc + func showFullscreenSignIn() { + guard isShowingFullscreenSignIn == false && AccountHelper.isLoggedIn == false else { + return + } + + showSignInUI() + } + + func dismissFullscreenSignIn(blogToShow: Blog? = nil, completion: Completion? = nil) { + guard isShowingFullscreenSignIn == true && AccountHelper.isLoggedIn == true else { + return + } + + showAppUI(for: blogToShow, completion: completion) + } + + /// Shows the UI for authenticated users. + /// + @objc func showAppUI(for blog: Blog? = nil, animated: Bool = true, completion: Completion? = nil) { + isShowingFullscreenSignIn = false + show(RootViewCoordinator.sharedPresenter.rootViewController, animated: animated, completion: completion) + + guard let blog = blog else { + return + } + + RootViewCoordinator.sharedPresenter.showBlogDetails(for: blog) + } + + /// Shows the initial UI for unauthenticated users. + /// + func showSignInUI(completion: Completion? = nil) { + isShowingFullscreenSignIn = true + + guard let loginViewController = WordPressAuthenticator.loginUI() else { + fatalError("No login UI to show to the user. There's no way to gracefully handle this error.") + } + + show(loginViewController, completion: completion) + WordPressAuthenticator.track(.openedLogin) + } + + /// Shows the specified VC as the root VC for the managed window. Takes care of animating the transition whenever the existing + /// root VC isn't `nil` (this is because a `nil` VC means we're showing the initial VC on a call to this method). + /// + func show(_ viewController: UIViewController, animated: Bool = true, completion: Completion? = nil) { + // When the App is launched, the root VC will be `nil`. + // When this is the case we'll simply show the VC without any type of animation. + guard window.rootViewController != nil, animated else { + window.rootViewController = viewController + return + } + + window.rootViewController = viewController + + UIView.transition( + with: window, + duration: WPAnimationDurationDefault, + options: .transitionCrossDissolve, + animations: nil, + completion: { _ in + completion?() + }) + } + + // MARK: Temporary Overlaying Window + + /// Creates a window with the passed root view and displays it on top of the app's UI. + /// - Parameter rootViewController: View controller to be used as the root view controller for the newly created window. + /// + func displayOverlayingWindow(with rootViewController: UIViewController) { + clearOverlayingWindow() + let windowFrame = window.frame + let window = UIWindow(frame: windowFrame) + window.rootViewController = rootViewController + window.windowLevel = .alert + window.isHidden = false + window.makeKeyAndVisible() + overlayingWindow = window + } + + + /// Removes the temporary overlaying window if it exists. And makes the main window the key window again. + /// + func clearOverlayingWindow() { + guard let overlayingWindow = overlayingWindow else { + return + } + overlayingWindow.isHidden = true + self.overlayingWindow = nil + window.makeKey() + } +} diff --git a/WordPress/Classes/System/WordPress-Bridging-Header.h b/WordPress/Classes/System/WordPress-Bridging-Header.h index bb15a59638c9..419e48ab2d37 100644 --- a/WordPress/Classes/System/WordPress-Bridging-Header.h +++ b/WordPress/Classes/System/WordPress-Bridging-Header.h @@ -7,24 +7,21 @@ #import "ActivityLogViewController.h" #import "AbstractPost+HashHelpers.h" #import "AccountService.h" -#import "ApiCredentials.h" #import "Blog.h" -#import "BlogDetailHeaderView.h" #import "BlogService.h" #import "BlogSyncFacade.h" #import "BlogSelectorViewController.h" #import "BlogListViewController.h" #import "BlogDetailsViewController.h" +#import "BlogSiteVisibilityHelper.h" -#import "Comment.h" #import "CommentService.h" #import "CommentsViewController+Network.h" #import "ConfigurablePostView.h" #import "Confirmable.h" #import "Constants.h" -#import "ContextManager.h" -#import "ContextManager-Internals.h" +#import "CoreDataStack.h" #import "Coordinate.h" #import "CustomHighlightButton.h" @@ -36,15 +33,18 @@ #import "MediaLibraryPickerDataSource.h" #import "MediaService.h" #import "MeHeaderView.h" +#import "MenuItem.h" +#import "MenuItemsViewController.h" +#import "MenusViewController.h" #import "NavBarTitleDropdownButton.h" -#import "NSAttributedString+Util.h" #import "NSObject+Helpers.h" #import "PageListTableViewCell.h" #import "PageSettingsViewController.h" #import "PostContentProvider.h" #import "PostCategory.h" +#import "PostCategoryService.h" #import "PostContentProvider.h" #import "PostListFooterView.h" #import "PostMetaButton.h" @@ -55,7 +55,6 @@ #import "WPProgressTableViewCell.h" #import "PostTag.h" #import "PostTagService.h" -#import "PrivateSiteURLProtocol.h" #import "ReachabilityUtils.h" #import "ReaderCommentsViewController.h" @@ -64,6 +63,7 @@ #import "ReaderPostContentProvider.h" #import "ReaderPostService.h" #import "ReaderSiteService.h" +#import "ReaderSiteService_Internal.h" #import "ReaderTopicService.h" #import "TextBundleWrapper.h" @@ -77,8 +77,8 @@ #import "SiteSettingsViewController.h" #import "SourcePostAttribution.h" #import "StatsViewController.h" -#import "SuggestionService.h" #import "SuggestionsTableView.h" +#import "SuggestionsTableViewCell.h" #import "SVProgressHUD+Dismiss.h" #import "Theme.h" @@ -88,6 +88,7 @@ #import "UIAlertControllerProxy.h" #import "UIApplication+Helpers.h" #import "UIView+Subviews.h" +#import "UIViewController+RemoveQuickStart.h" #import "WPAccount.h" #import "WPActivityDefaults.h" @@ -95,6 +96,7 @@ #import "WPAnalyticsTrackerWPCom.h" #import "WPAppAnalytics.h" #import "WPAuthTokenIssueSolver.h" +#import "WPAvatarSource.h" #import "WPBlogTableViewCell.h" #import "WPBlogSelectorButton.h" #import "WPUploadStatusButton.h" @@ -115,10 +117,10 @@ #import "WPLogger.h" #import "WPException.h" +#import "WPAddPostCategoryViewController.h" // Pods #import <SVProgressHUD/SVProgressHUD.h> -#import <FormatterKit/FormatterKit-umbrella.h> #import <WPMediaPicker/WPMediaPicker.h> @@ -128,3 +130,5 @@ #import <WordPressShared/WPTableViewCell.h> #import <WordPressShared/WPAnalytics.h> #import <WordPressUI/UIImage+Util.h> + +FOUNDATION_EXTERN void SetCocoaLumberjackObjCLogLevel(NSUInteger ddLogLevelRawValue); diff --git a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift index 3008ea48f3d6..f535da7eeef0 100644 --- a/WordPress/Classes/System/WordPressAppDelegate+openURL.swift +++ b/WordPress/Classes/System/WordPressAppDelegate+openURL.swift @@ -11,7 +11,28 @@ import AutomatticTracks return true } - // 3. let's see if it's our wpcom scheme + if QRLoginCoordinator.didHandle(url: url) { + return true + } + + if UniversalLinkRouter.shared.canHandle(url: url) { + UniversalLinkRouter.shared.handle(url: url, shouldTrack: true) + return true + } + + /// WordPress only. Handle deeplink from JP that requests data export. + let wordPressExportRouter = MigrationDeepLinkRouter(urlForScheme: URL(string: AppScheme.wordpressMigrationV1.rawValue), + routes: [WordPressExportRoute()]) + if AppConfiguration.isWordPress, + wordPressExportRouter.canHandle(url: url) { + wordPressExportRouter.handle(url: url) + return true + } + + if url.scheme == JetpackNotificationMigrationService.wordPressScheme { + return JetpackNotificationMigrationService.shared.handleNotificationMigrationOnWordPress() + } + guard url.scheme == WPComScheme else { return false } @@ -20,6 +41,8 @@ import AutomatticTracks switch url.host { case "newpost": return handleNewPost(url: url) + case "newpage": + return handleNewPage(url: url) case "magic-login": return handleMagicLogin(url: url) case "viewpost": @@ -45,13 +68,17 @@ import AutomatticTracks private func handleMagicLogin(url: URL) -> Bool { DDLogInfo("App launched with authentication link") - let allowWordPressComAuth = !AccountHelper.isDotcomAvailable() + + guard AccountHelper.noWordPressDotComAccount || url.isJetpackConnect else { + DDLogInfo("The user clicked on a login or signup magic link when already logged into a WPCom account. Since this is not a Jetpack connection attempt we're cancelling the operation.") + return false + } + guard let rvc = window?.rootViewController else { return false } - return WordPressAuthenticator.openAuthenticationURL(url, - allowWordPressComAuth: allowWordPressComAuth, - fromRootViewController: rvc) + + return WordPressAuthenticator.openAuthenticationURL(url, fromRootViewController: rvc) } private func handleViewPost(url: URL) -> Bool { @@ -61,17 +88,26 @@ import AutomatticTracks return false } - WPTabBarController.sharedInstance()?.showReaderTab(forPost: NSNumber(value: postId), onBlog: NSNumber(value: blogId)) + RootViewCoordinator.sharedPresenter.showReaderTab(forPost: NSNumber(value: postId), onBlog: NSNumber(value: blogId)) return true } private func handleViewStats(url: URL) -> Bool { - let blogService = BlogService(managedObjectContext: ContextManager.sharedInstance().mainContext) guard let params = url.queryItems, let siteId = params.intValue(of: "siteId"), - let blog = blogService.blog(byBlogId: NSNumber(value: siteId)) else { + let blog = try? Blog.lookup(withID: siteId, in: ContextManager.shared.mainContext) else { + return false + } + + guard JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() else { + // Display overlay + RootViewCoordinator.sharedPresenter.mySitesCoordinator.displayJetpackOverlayForDisabledEntryPoint() + + // Track incorrect access + let properties = ["calling_function": "deep_link", "url": url.absoluteString] + WPAnalytics.track(.jetpackFeatureIncorrectlyAccessed, properties: properties) return false } @@ -86,14 +122,14 @@ import AutomatticTracks // so the Stats view displays the correct stats. SiteStatsInformation.sharedInstance.siteID = currentSiteID - WPTabBarController.sharedInstance()?.dismiss(animated: true, completion: nil) + RootViewCoordinator.sharedPresenter.rootViewController.dismiss(animated: true, completion: nil) } let navController = UINavigationController(rootViewController: statsViewController) navController.modalPresentationStyle = .currentContext navController.navigationBar.isTranslucent = false - WPTabBarController.sharedInstance()?.present(navController, animated: true, completion: nil) + RootViewCoordinator.sharedPresenter.rootViewController.present(navController, animated: true, completion: nil) return true } @@ -105,8 +141,8 @@ import AutomatticTracks return false } - if debugKey == ApiCredentials.debuggingKey(), debugType == "force_crash" { - CrashLogging.crash() + if debugKey == ApiCredentials.debuggingKey, debugType == "force_crash" { + WordPressAppDelegate.crashLogging?.crash() } return true @@ -130,15 +166,14 @@ import AutomatticTracks let tags = params.value(of: NewPostKey.tags) let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - guard let blog = blogService.lastUsedOrFirstBlog() else { + guard let blog = Blog.lastUsedOrFirst(in: context) else { return false } - // Should more formats be accepted be accepted in the future, this line would have to be expanded to accomodate it. + // Should more formats be accepted in the future, this line would have to be expanded to accomodate it. let contentEscaped = contentRaw.escapeHtmlNamedEntities() - let post = PostService(managedObjectContext: context).createDraftPost(for: blog) + let post = blog.createDraftPost() post.postTitle = title post.content = contentEscaped post.tags = tags @@ -146,13 +181,41 @@ import AutomatticTracks let postVC = EditPostViewController(post: post) postVC.modalPresentationStyle = .fullScreen - WPTabBarController.sharedInstance()?.present(postVC, animated: true, completion: nil) + RootViewCoordinator.sharedPresenter.rootViewController.present(postVC, animated: true, completion: nil) + + WPAppAnalytics.track(.editorCreatedPost, withProperties: [WPAppAnalyticsKeyTapSource: "url_scheme", WPAppAnalyticsKeyPostType: "post"]) - WPAppAnalytics.track(.editorCreatedPost, withProperties: ["tap_source": "url_scheme"]) + return true + } + + /// Handle a call of wordpress://newpage?… + /// + /// - Parameter url: URL of the request + /// - Returns: true if the url was handled + /// - Note: **url** must contain param for `content` at minimum. Also supports `title`. Currently `content` is assumed to be + /// text. May support other formats, such as HTML or Markdown in the future. + private func handleNewPage(url: URL) -> Bool { + guard let params = url.queryItems, + let contentRaw = params.value(of: NewPostKey.content) else { + return false + } + + let title = params.value(of: NewPostKey.title) + + let context = ContextManager.sharedInstance().mainContext + guard let blog = Blog.lastUsedOrFirst(in: context) else { + return false + } + + // Should more formats be accepted be accepted in the future, this line would have to be expanded to accomodate it. + let contentEscaped = contentRaw.escapeHtmlNamedEntities() + + RootViewCoordinator.sharedPresenter.showPageEditor(blog: blog, title: title, content: contentEscaped, source: "url_scheme") return true } + private enum NewPostKey { static let title = "title" static let content = "content" diff --git a/WordPress/Classes/System/WordPressAppDelegate.swift b/WordPress/Classes/System/WordPressAppDelegate.swift index f8cd9d7a36f2..aff92dc1deef 100644 --- a/WordPress/Classes/System/WordPressAppDelegate.swift +++ b/WordPress/Classes/System/WordPressAppDelegate.swift @@ -5,7 +5,7 @@ import AutomatticTracks import WordPressAuthenticator import WordPressShared import AlamofireNetworkActivityIndicator -import AutomatticTracks +import AutomatticAbout #if APPCENTER_ENABLED import AppCenter @@ -18,12 +18,21 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - var analytics: WPAppAnalytics? - private lazy var crashLoggingProvider: WPCrashLoggingProvider = { - return WPCrashLoggingProvider() + let backgroundTasksCoordinator = BackgroundTasksCoordinator(tasks: [ + WeeklyRoundupBackgroundTask() + ], eventHandler: WordPressBackgroundTaskEventHandler()) + + @objc + lazy var windowManager: WindowManager = { + guard let window = window else { + fatalError("The App cannot run without a window.") + } + + return AppDependency.windowManager(window: window) }() - @objc var logger: WPLogger? + var analytics: WPAppAnalytics? + @objc var internetReachability: Reachability? @objc var connectionAvailable: Bool = true @@ -34,13 +43,15 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { private var pingHubManager: PingHubManager? private var noticePresenter: NoticePresenter? private var bgTask: UIBackgroundTaskIdentifier? = nil + private let remoteFeatureFlagStore = RemoteFeatureFlagStore() + private let remoteConfigStore = RemoteConfigStore() + + private var mainContext: NSManagedObjectContext { + return ContextManager.shared.mainContext + } private var shouldRestoreApplicationState = false private lazy var uploadsManager: UploadsManager = { - // This is intentionally a `lazy var` to prevent `PostCoordinator.shared` (below) from - // triggering an initialization of `ContextManager.shared.mainContext` during the - // initialization of this class. This is so any track events in `mainContext` - // (e.g. by `NullBlogPropertySanitizer`) will be recorded properly. // It's not great that we're using singletons here. This change is a good opportunity to // revisit if we can make the coordinators children to another owning object. @@ -58,7 +69,20 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { return UploadsManager(uploaders: uploaders) }() + private let loggingStack = WPLoggingStack() + + /// Access the crash logging type + class var crashLogging: CrashLogging? { + shared?.loggingStack.crashLogging + } + + /// Access the event logging type + class var eventLogging: EventLogging? { + shared?.loggingStack.eventLogging + } + @objc class var shared: WordPressAppDelegate? { + assert(Thread.isMainThread, "WordPressAppDelegate.shared can only be accessed from the main thread") return UIApplication.shared.delegate as? WordPressAppDelegate } @@ -66,6 +90,10 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { window = UIWindow(frame: UIScreen.main.bounds) + AppAppearance.overrideAppearance() + + // Start CrashLogging as soon as possible (in case a crash happens during startup) + try? loggingStack.start() // Configure WPCom API overrides configureWordPressComApi() @@ -74,13 +102,16 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { configureReachability() configureSelfHostedChallengeHandler() + updateFeatureFlags() + updateRemoteConfig() window?.makeKeyAndVisible() // Restore a disassociated account prior to fixing tokens. - AccountService(managedObjectContext: ContextManager.shared.mainContext).restoreDisassociatedAccountIfNecessary() + AccountService(coreDataStack: ContextManager.sharedInstance()).restoreDisassociatedAccountIfNecessary() customizeAppearance() + configureAnalytics() let solver = WPAuthTokenIssueSolver() let isFixingAuthTokenIssue = solver.fixAuthTokenIssueAndDo { [weak self] in @@ -95,35 +126,36 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { DDLogInfo("didFinishLaunchingWithOptions state: \(application.applicationState)") - let queue = DispatchQueue(label: "asd", qos: .background) - let deviceInformation = TracksDeviceInformation() - - queue.async { - let height = deviceInformation.statusBarHeight - let orientation = deviceInformation.orientation! + ABTest.start() - print("Height: \(height); orientation: \(orientation)") + if UITextField.shouldActivateWorkaroundForBulgarianKeyboardCrash() { + // WORKAROUND: this is a workaround for an issue with UITextField in iOS 14. + // Please refer to the documentation of the called method to learn the details and know + // how to tell if this call can be removed. + UITextField.activateWorkaroundForBulgarianKeyboardCrash() } - InteractiveNotificationsManager.shared.registerForUserNotifications() - showWelcomeScreenIfNeeded(animated: false) setupPingHub() setupBackgroundRefresh(application) setupComponentsAppearance() disableAnimationsForUITests(application) + // This was necessary to properly load fonts for the Stories editor. I believe external libraries may require this call to access fonts. + let fonts = Bundle.main.urls(forResourcesWithExtension: "ttf", subdirectory: nil) + fonts?.forEach({ url in + CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil) + }) + PushNotificationsManager.shared.deletePendingLocalNotifications() - if #available(iOS 13, *) { - startObservingAppleIDCredentialRevoked() - } + startObservingAppleIDCredentialRevoked() NotificationCenter.default.post(name: .applicationLaunchCompleted, object: nil) + return true } - func applicationWillTerminate(_ application: UIApplication) { DDLogInfo("\(self) \(#function)") } @@ -131,9 +163,11 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { func applicationDidEnterBackground(_ application: UIApplication) { DDLogInfo("\(self) \(#function)") - // Let the app finish any uploads that are in progress let app = UIApplication.shared + + // Let the app finish any uploads that are in progress if let task = bgTask, bgTask != .invalid { + DDLogInfo("BackgroundTask: ending existing backgroundTask for bgTask = \(task.rawValue)") app.endBackgroundTask(task) bgTask = .invalid } @@ -143,17 +177,31 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { // the task actually finishes at around the same time. DispatchQueue.main.async { [weak self] in if let task = self?.bgTask, task != .invalid { + DDLogInfo("BackgroundTask: executing expirationHandler for bgTask = \(task.rawValue)") app.endBackgroundTask(task) self?.bgTask = .invalid } } }) + + if let bgTask = bgTask { + DDLogInfo("BackgroundTask: beginBackgroundTask for bgTask = \(bgTask.rawValue)") + } } func applicationWillEnterForeground(_ application: UIApplication) { DDLogInfo("\(self) \(#function)") uploadsManager.resume() + updateFeatureFlags() + updateRemoteConfig() + +#if JETPACK + // JetpackWindowManager is only available in the Jetpack target. + if let windowManager = windowManager as? JetpackWindowManager { + windowManager.startMigrationFlowIfNeeded() + } +#endif } func applicationWillResignActive(_ application: UIApplication) { @@ -164,9 +212,8 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { DDLogInfo("\(self) \(#function)") // This is done here so the check is done on app launch and app switching. - if #available(iOS 13, *) { - checkAppleIDCredentialState() - } + checkAppleIDCredentialState() + GutenbergSettings().performGutenbergPhase2MigrationIfNeeded() } @@ -176,7 +223,7 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool { let lastSavedStateVersionKey = "lastSavedStateVersionKey" - let defaults = UserDefaults.standard + let defaults = UserPersistentStoreFactory.instance() var shouldRestoreApplicationState = false @@ -186,9 +233,8 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { shouldRestoreApplicationState = self.shouldRestoreApplicationState } - defaults.setValue(currentVersion, forKey: lastSavedStateVersionKey) + defaults.set(currentVersion, forKey: lastSavedStateVersionKey) } - return shouldRestoreApplicationState } @@ -226,19 +272,31 @@ 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] = [:]) { // Local notifications addNotificationObservers() - logger = WPLogger() - - CrashLogging.start(withDataProvider: crashLoggingProvider) - configureAppCenterSDK() configureAppRatingUtility() - configureAnalytics() + + let libraryLogger = WordPressLibraryLogger() + TracksLogging.delegate = libraryLogger + WPSharedSetLoggingDelegate(libraryLogger) + WPKitSetLoggingDelegate(libraryLogger) + WPAuthenticatorSetLoggingDelegate(libraryLogger) printDebugLaunchInfoWithLaunchOptions(launchOptions) toggleExtraDebuggingIfNeeded() @@ -259,7 +317,9 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { // Push notifications // This is silent (the user isn't prompted) so we can do it on launch. // We'll ask for user notification permission after signin. - PushNotificationsManager.shared.registerForRemoteNotifications() + DispatchQueue.main.async { + PushNotificationsManager.shared.setupRemoteNotifications() + } // Deferred tasks to speed up app launch DispatchQueue.global(qos: .background).async { [weak self] in @@ -274,16 +334,14 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { shortcutCreator.createShortcutsIf3DTouchAvailable(AccountHelper.isLoggedIn) - window?.rootViewController = WPTabBarController.sharedInstance() + AccountService.loadDefaultAccountCookies() + windowManager.showUI() setupNoticePresenter() } private func mergeDuplicateAccountsIfNeeded() { - let context = ContextManager.shared.mainContext - context.perform { - AccountService(managedObjectContext: ContextManager.shared.mainContext).mergeDuplicatesIfNecessary() - } + AccountService(coreDataStack: ContextManager.sharedInstance()).mergeDuplicatesIfNecessary() } private func setupPingHub() { @@ -299,7 +357,11 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { } private func setupBackgroundRefresh(_ application: UIApplication) { - application.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum) + backgroundTasksCoordinator.scheduleTasks { result in + if case .failure(let error) = result { + DDLogError("Error scheduling background tasks: \(error)") + } + } } // MARK: - Helpers @@ -313,7 +375,7 @@ class WordPressAppDelegate: UIResponder, UIApplicationDelegate { if CommandLine.arguments.contains("-no-animations") { UIView.setAnimationsEnabled(false) application.windows.first?.layer.speed = MAXFLOAT - application.keyWindow?.layer.speed = MAXFLOAT + application.mainWindow?.layer.speed = MAXFLOAT } } @@ -347,18 +409,6 @@ extension WordPressAppDelegate { completionHandler: completionHandler) } - // MARK: Background refresh - - func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - let tabBarController = WPTabBarController.sharedInstance() - - if let readerMenu = tabBarController?.readerMenuViewController, - let stream = readerMenu.currentReaderStream { - stream.backgroundFetch(completionHandler) - } else { - completionHandler(.noData) - } - } } // MARK: - Utility Configuration @@ -366,12 +416,8 @@ extension WordPressAppDelegate { extension WordPressAppDelegate { func configureAnalytics() { - let context = ContextManager.sharedInstance().mainContext - let accountService = AccountService(managedObjectContext: context) - - analytics = WPAppAnalytics(accountService: accountService, - lastVisibleScreenBlock: { [weak self] in - return self?.currentlySelectedScreen + analytics = WPAppAnalytics(lastVisibleScreenBlock: { [weak self] in + return self?.currentlySelectedScreen }) } @@ -385,15 +431,16 @@ extension WordPressAppDelegate { utility.register(section: "notifications", significantEventCount: 5) utility.systemWideSignificantEventCountRequiredForPrompt = 10 utility.setVersion(version) - utility.checkIfAppReviewPromptsHaveBeenDisabled(success: nil, failure: { - DDLogError("Was unable to retrieve data about throttling") - }) + if AppConfiguration.isWordPress { + utility.checkIfAppReviewPromptsHaveBeenDisabled(success: nil, failure: { + DDLogError("Was unable to retrieve data about throttling") + }) + } } @objc func configureAppCenterSDK() { #if APPCENTER_ENABLED - MSAppCenter.start(ApiCredentials.appCenterAppId(), withServices: [MSDistribute.self]) - MSDistribute.setEnabled(true) + AppCenter.start(withAppSecret: ApiCredentials.appCenterAppId, services: [Distribute.self]) #endif } @@ -440,15 +487,24 @@ extension WordPressAppDelegate { } @objc func configureWordPressAuthenticator() { - authManager = WordPressAuthenticationManager() + let authManager = AppDependency.authenticationManager(windowManager: windowManager) - authManager?.initializeWordPressAuthenticator() - authManager?.startRelayingSupportNotifications() + authManager.initializeWordPressAuthenticator() + authManager.startRelayingSupportNotifications() WordPressAuthenticator.shared.delegate = authManager + self.authManager = authManager } func handleWebActivity(_ activity: NSUserActivity) { + // try to handle unauthenticated routes first. + if activity.activityType == NSUserActivityTypeBrowsingWeb, + let url = activity.webpageURL, + UniversalLinkRouter.unauthenticated.canHandle(url: url) { + UniversalLinkRouter.unauthenticated.handle(url: url) + return + } + guard AccountHelper.isLoggedIn, activity.activityType == NSUserActivityTypeBrowsingWeb, let url = activity.webpageURL else { @@ -456,7 +512,28 @@ extension WordPressAppDelegate { return } - UniversalLinkRouter.shared.handle(url: url) + if QRLoginCoordinator.didHandle(url: url) { + return + } + + /// If the counterpart WordPress/Jetpack app is installed, and the URL has a wp-admin link equivalent, + /// bounce the wp-admin link to Safari instead. + /// + /// Passing a URL that the router couldn't handle results in opening the URL in Safari, which will + /// cause the other app to "catch" the intent — and leads to a navigation loop between the two apps. + /// + /// TODO: Remove this after the Universal Link routes for the WordPress app are removed. + /// + /// Read more: https://github.com/wordpress-mobile/WordPress-iOS/issues/19755 + if MigrationAppDetection.isCounterpartAppInstalled, + WPAdminConvertibleRouter.shared.canHandle(url: url) { + WPAdminConvertibleRouter.shared.handle(url: url) + return + } + + trackDeepLink(for: url) { url in + UniversalLinkRouter.shared.handle(url: url) + } } @objc func setupNetworkActivityIndicator() { @@ -464,12 +541,37 @@ extension WordPressAppDelegate { } @objc func configureWordPressComApi() { - if let baseUrl = UserDefaults.standard.string(forKey: "wpcom-api-base-url") { + if let baseUrl = UserPersistentStoreFactory.instance().string(forKey: "wpcom-api-base-url") { Environment.replaceEnvironment(wordPressComApiBase: baseUrl) } } } +// MARK: - Deep Link Handling + +extension WordPressAppDelegate { + + private func trackDeepLink(for url: URL, completion: @escaping ((URL) -> Void)) { + guard isIterableDeepLink(url) else { + completion(url) + return + } + + let task = URLSession.shared.dataTask(with: url) {(_, response, error) in + if let url = response?.url { + completion(url) + } + } + task.resume() + } + + private func isIterableDeepLink(_ url: URL) -> Bool { + return url.absoluteString.contains(WordPressAppDelegate.iterableDomain) + } + + private static let iterableDomain = "links.wp.a8cmail.com" +} + // MARK: - UIAppearance extension WordPressAppDelegate { @@ -496,7 +598,7 @@ extension WordPressAppDelegate { appearance.actionFont = WPStyleGuide.fontForTextStyle(.headline) appearance.infoFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) - appearance.infoTintColor = WPStyleGuide.wordPressBlue() + appearance.infoTintColor = .primary appearance.topDividerColor = .neutral(.shade5) appearance.bottomDividerColor = .neutral(.shade0) @@ -532,17 +634,30 @@ extension WordPressAppDelegate { extension WordPressAppDelegate { var currentlySelectedScreen: String { - // Check if the post editor or login view is up - let rootViewController = window?.rootViewController - if let presentedViewController = rootViewController?.presentedViewController { - if presentedViewController is EditPostViewController { - return "Post Editor" - } else if presentedViewController is LoginNavigationController { - return "Login View" - } + guard let rootViewController = window?.rootViewController else { + DDLogInfo("\(#function) is called when `rootViewController` is nil.") + return String() } - return WPTabBarController.sharedInstance().currentlySelectedScreen() + // NOTE: This logic doesn't cover all the scenarios properly yet. If we want to know what screen was actually seen, + // there should be a recursive check to get to the visible view controller (or call `UINavigationController`'s `visibleViewController`). + // + // Read more here: https://github.com/wordpress-mobile/WordPress-iOS/pull/19677#pullrequestreview-1199885009 + // + switch rootViewController.presentedViewController ?? rootViewController { + case is EditPostViewController: + return "Post Editor" + case is LoginNavigationController: + return "Login View" +#if JETPACK + case is MigrationNavigationController: + return "Jetpack Migration View" + case is MigrationLoadWordPressViewController: + return "Jetpack Migration Load WordPress View" +#endif + default: + return RootViewCoordinator.sharedPresenter.currentlySelectedScreen() + } } var isWelcomeScreenVisible: Bool { @@ -559,35 +674,27 @@ extension WordPressAppDelegate { } } - - @objc(showWelcomeScreenIfNeededAnimated:) - func showWelcomeScreenIfNeeded(animated: Bool) { - guard isWelcomeScreenVisible == false && AccountHelper.isLoggedIn == false else { - return - } - - // Check if the presentedVC is UIAlertController because in iPad we show a Sign-out button in UIActionSheet - // and it's not dismissed before the check and `dismissViewControllerAnimated` does not work for it - if let presenter = window?.rootViewController?.presentedViewController, - !(presenter is UIAlertController) { - presenter.dismiss(animated: animated, completion: { [weak self] in - self?.showWelcomeScreen(animated, thenEditor: false) - }) - } else { - showWelcomeScreen(animated, thenEditor: false) + @objc func trackLogoutIfNeeded() { + if AccountHelper.isLoggedIn == false { + WPAnalytics.track(.logout) } } - func showWelcomeScreen(_ animated: Bool, thenEditor: Bool) { - if let rootViewController = window?.rootViewController { - WordPressAuthenticator.showLogin(from: rootViewController, animated: animated) + /// Updates the remote feature flags using an authenticated remote if a token is provided or an account exists + /// Otherwise an anonymous remote will be used + func updateFeatureFlags(authToken: String? = nil, completion: (() -> Void)? = nil) { + var api: WordPressComRestApi + if let authToken { + api = WordPressComRestApi.defaultV2Api(authToken: authToken) + } else { + api = WordPressComRestApi.defaultV2Api(in: mainContext) } + let remote = FeatureFlagRemote(wordPressComRestApi: api) + remoteFeatureFlagStore.update(using: remote, then: completion) } - @objc func trackLogoutIfNeeded() { - if AccountHelper.isLoggedIn == false { - WPAnalytics.track(.logout) - } + func updateRemoteConfig() { + remoteConfigStore.update() } } @@ -598,16 +705,16 @@ extension WordPressAppDelegate { let unknown = "Unknown" let device = UIDevice.current - let crashCount = UserDefaults.standard.integer(forKey: "crashCount") - - let extraDebug = UserDefaults.standard.bool(forKey: "extra_debug") + let crashCount = UserPersistentStoreFactory.instance().integer(forKey: "crashCount") - let context = ContextManager.sharedInstance().mainContext + let extraDebug = UserPersistentStoreFactory.instance().bool(forKey: "extra_debug") - let detailedVersionNumber = Bundle(for: type(of: self)).detailedVersionNumber() ?? unknown + let bundle = Bundle.main + let detailedVersionNumber = bundle.detailedVersionNumber() ?? unknown + let appName = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String ?? unknown DDLogInfo("===========================================================================") - DDLogInfo("Launching WordPress for iOS \(detailedVersionNumber)...") + DDLogInfo("Launching \(appName) for iOS \(detailedVersionNumber)...") DDLogInfo("Crash count: \(crashCount)") #if DEBUG @@ -620,7 +727,7 @@ extension WordPressAppDelegate { let devicePlatform = UIDeviceHardware.platformString() let architecture = UIDeviceHardware.platform() - let languages = UserDefaults.standard.array(forKey: "AppleLanguages") + let languages = UserPersistentStoreFactory.instance().array(forKey: "AppleLanguages") let currentLanguage = languages?.first ?? unknown let udid = device.wordPressIdentifier() ?? unknown @@ -631,7 +738,7 @@ extension WordPressAppDelegate { DDLogInfo("APN token: \(PushNotificationsManager.shared.deviceToken ?? "None")") DDLogInfo("Launch options: \(String(describing: launchOptions ?? [:]))") - AccountHelper.logBlogsAndAccounts(context: context) + AccountHelper.logBlogsAndAccounts(context: mainContext) DDLogInfo("===========================================================================") } @@ -639,25 +746,25 @@ extension WordPressAppDelegate { if !AccountHelper.isLoggedIn { // When there are no blogs in the app the settings screen is unavailable. // In this case, enable extra_debugging by default to help troubleshoot any issues. - guard UserDefaults.standard.object(forKey: "orig_extra_debug") == nil else { + guard UserPersistentStoreFactory.instance().object(forKey: "orig_extra_debug") == nil else { // Already saved. Don't save again or we could loose the original value. return } - let origExtraDebug = UserDefaults.standard.bool(forKey: "extra_debug") ? "YES" : "NO" - UserDefaults.standard.set(origExtraDebug, forKey: "orig_extra_debug") - UserDefaults.standard.set(true, forKey: "extra_debug") + let origExtraDebug = UserPersistentStoreFactory.instance().bool(forKey: "extra_debug") ? "YES" : "NO" + UserPersistentStoreFactory.instance().set(origExtraDebug, forKey: "orig_extra_debug") + UserPersistentStoreFactory.instance().set(true, forKey: "extra_debug") WordPressAppDelegate.setLogLevel(.verbose) } else { - guard let origExtraDebug = UserDefaults.standard.string(forKey: "orig_extra_debug") else { + guard let origExtraDebug = UserPersistentStoreFactory.instance().string(forKey: "orig_extra_debug") else { return } let origExtraDebugValue = (origExtraDebug as NSString).boolValue // Restore the original setting and remove orig_extra_debug - UserDefaults.standard.set(origExtraDebugValue, forKey: "extra_debug") - UserDefaults.standard.removeObject(forKey: "orig_extra_debug") + UserPersistentStoreFactory.instance().set(origExtraDebugValue, forKey: "extra_debug") + UserPersistentStoreFactory.instance().removeObject(forKey: "orig_extra_debug") if origExtraDebugValue { WordPressAppDelegate.setLogLevel(.verbose) @@ -666,9 +773,8 @@ extension WordPressAppDelegate { } @objc class func setLogLevel(_ level: DDLogLevel) { - WPSharedSetLoggingLevel(level) - TracksSetLoggingLevel(level) - WPAuthenticatorSetLoggingLevel(level) + SetCocoaLumberjackObjCLogLevel(level.rawValue) + CocoaLumberjack.dynamicLogLevel = level } } @@ -689,11 +795,6 @@ extension WordPressAppDelegate { name: UIApplication.didReceiveMemoryWarningNotification, object: nil) - nc.addObserver(self, - selector: #selector(handleUIContentSizeCategoryDidChangeNotification(_:)), - name: UIContentSizeCategory.didChangeNotification, - object: nil) - nc.addObserver(self, selector: #selector(saveRecentSitesForExtensions), name: .WPRecentSitesChanged, @@ -703,22 +804,16 @@ extension WordPressAppDelegate { @objc fileprivate func handleDefaultAccountChangedNotification(_ notification: NSNotification) { // If the notification object is not nil, then it's a login if notification.object != nil { - setupShareExtensionToken() - configureNotificationExtension() - - if #available(iOS 13, *) { - startObservingAppleIDCredentialRevoked() - } + setupWordPressExtensions() + startObservingAppleIDCredentialRevoked() + AccountService.loadDefaultAccountCookies() } else { trackLogoutIfNeeded() removeTodayWidgetConfiguration() removeShareExtensionConfiguration() removeNotificationExtensionConfiguration() - showWelcomeScreenIfNeeded(animated: false) - - if #available(iOS 13, *) { - stopObservingAppleIDCredentialRevoked() - } + windowManager.showFullscreenSignIn() + stopObservingAppleIDCredentialRevoked() } toggleExtraDebuggingIfNeeded() @@ -729,10 +824,6 @@ extension WordPressAppDelegate { @objc fileprivate func handleLowMemoryWarningNotification(_ notification: NSNotification) { WPAnalytics.track(.lowMemoryWarning) } - - @objc fileprivate func handleUIContentSizeCategoryDidChangeNotification(_ notification: NSNotification) { - customizeAppearanceForTextElements() - } } // MARK: - Extensions @@ -740,8 +831,7 @@ extension WordPressAppDelegate { extension WordPressAppDelegate { func setupWordPressExtensions() { - let context = ContextManager.sharedInstance().mainContext - let accountService = AccountService(managedObjectContext: context) + let accountService = AccountService(coreDataStack: ContextManager.sharedInstance()) accountService.setupAppExtensionsWithDefaultAccount() let maxImagesize = MediaSettings().maxImageSizeSetting @@ -759,10 +849,8 @@ extension WordPressAppDelegate { // MARK: - Share Extension func setupShareExtensionToken() { - let context = ContextManager.sharedInstance().mainContext - let accountService = AccountService(managedObjectContext: context) - if let account = accountService.defaultWordPressComAccount(), let authToken = account.authToken { + if let account = try? WPAccount.lookupDefaultWordPressComAccount(in: mainContext), let authToken = account.authToken { ShareExtensionService.configureShareExtensionToken(authToken) ShareExtensionService.configureShareExtensionUsername(account.username) } @@ -780,15 +868,14 @@ extension WordPressAppDelegate { // MARK: - Notification Service Extension func configureNotificationExtension() { - let context = ContextManager.sharedInstance().mainContext - let accountService = AccountService(managedObjectContext: context) - if let account = accountService.defaultWordPressComAccount(), let authToken = account.authToken { + if let account = try? WPAccount.lookupDefaultWordPressComAccount(in: mainContext), let authToken = account.authToken { NotificationSupportService.insertContentExtensionToken(authToken) NotificationSupportService.insertContentExtensionUsername(account.username) NotificationSupportService.insertServiceExtensionToken(authToken) NotificationSupportService.insertServiceExtensionUsername(account.username) + NotificationSupportService.insertServiceExtensionUserID(account.userID.stringValue) } } @@ -798,6 +885,7 @@ extension WordPressAppDelegate { NotificationSupportService.deleteServiceExtensionToken() NotificationSupportService.deleteServiceExtensionUsername() + NotificationSupportService.deleteServiceExtensionUserID() } } @@ -806,15 +894,21 @@ extension WordPressAppDelegate { extension WordPressAppDelegate { func customizeAppearance() { window?.backgroundColor = .black - window?.tintColor = WPStyleGuide.wordPressBlue() + window?.tintColor = .primary + + // iOS 14 started rendering backgrounds for stack views, when previous versions + // of iOS didn't show them. This is a little hacky, but ensures things keep + // looking the same on newer versions of iOS. + UIStackView.appearance().backgroundColor = .clear WPStyleGuide.configureTabBarAppearance() WPStyleGuide.configureNavigationAppearance() + WPStyleGuide.configureTableViewAppearance() WPStyleGuide.configureDefaultTint() WPStyleGuide.configureLightNavigationBarAppearance() + WPStyleGuide.configureToolbarAppearance() UISegmentedControl.appearance().setTitleTextAttributes( [NSAttributedString.Key.font: WPStyleGuide.regularTextFont()], for: .normal) - UIToolbar.appearance().barTintColor = .primary UISwitch.appearance().onTintColor = .primary let navReferenceAppearance = UINavigationBar.appearance(whenContainedInInstancesOf: [UIReferenceLibraryViewController.self]) @@ -836,7 +930,6 @@ extension WordPressAppDelegate { barItemAppearance.setTitleTextAttributes([NSAttributedString.Key.foregroundColor: UIColor.white, NSAttributedString.Key.font: WPFontManager.systemSemiBoldFont(ofSize: 16.0)], for: .disabled) UICollectionView.appearance(whenContainedInInstancesOf: [WPMediaPickerViewController.self]).backgroundColor = .neutral(.shade5) - let cellAppearance = WPMediaCollectionViewCell.appearance(whenContainedInInstancesOf: [WPMediaPickerViewController.self]) cellAppearance.loadingBackgroundColor = .listBackground cellAppearance.placeholderBackgroundColor = .neutral(.shade70) @@ -844,25 +937,24 @@ extension WordPressAppDelegate { cellAppearance.setCellTintColor(.primary) UIButton.appearance(whenContainedInInstancesOf: [WPActionBar.self]).tintColor = .primary + WPActionBar.appearance().barBackgroundColor = .basicBackground + WPActionBar.appearance().lineColor = .basicBackground - customizeAppearanceForTextElements() - } - - private func customizeAppearanceForTextElements() { - let maximumPointSize = WPStyleGuide.maxFontSize + // Post Settings styles + UITableView.appearance(whenContainedInInstancesOf: [AztecNavigationController.self]).tintColor = .editorPrimary + UISwitch.appearance(whenContainedInInstancesOf: [AztecNavigationController.self]).onTintColor = .editorPrimary - UINavigationBar.appearance().titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white, - NSAttributedString.Key.font: WPStyleGuide.fixedFont(for: UIFont.TextStyle.headline, weight: UIFont.Weight.bold)] + /// Sets the `tintColor` for parent category selection within the Post Settings screen + UIView.appearance(whenContainedInInstancesOf: [PostCategoriesViewController.self]).tintColor = .editorPrimary - WPStyleGuide.configureSearchBarTextAppearance() + /// It's necessary to target `PostCategoriesViewController` a second time to "reset" the UI element's `tintColor` for use in the app's Site Settings screen. + UIView.appearance(whenContainedInInstancesOf: [PostCategoriesViewController.self, WPSplitViewController.self]).tintColor = .primary - SVProgressHUD.setFont(WPStyleGuide.fontForTextStyle(UIFont.TextStyle.headline, maximumPointSize: maximumPointSize)) } } // MARK: - Apple Account Handling -@available(iOS 13.0, *) extension WordPressAppDelegate { func checkAppleIDCredentialState() { @@ -877,7 +969,7 @@ extension WordPressAppDelegate { let appleUserID: String do { appleUserID = try SFHFKeychainUtils.getPasswordForUsername(WPAppleIDKeychainUsernameKey, - andServiceName: WPAppleIDKeychainServiceName) + andServiceName: WPAppleIDKeychainServiceName) } catch { DDLogInfo("checkAppleIDCredentialState: No Apple ID found.") return diff --git a/WordPress/Classes/Utility/AB Testing/ABTest.swift b/WordPress/Classes/Utility/AB Testing/ABTest.swift new file mode 100644 index 000000000000..4aeb74cec7cf --- /dev/null +++ b/WordPress/Classes/Utility/AB Testing/ABTest.swift @@ -0,0 +1,38 @@ +import AutomatticTracks + +// Attention: AB test is available only for WPiOS +// Jetpack is not supported +enum ABTest: String, CaseIterable { + case unknown = "unknown" + case siteCreationDomainPurchasing = "jpios_site_creation_domain_purchasing_v1" + + /// Returns a variation for the given experiment + var variation: Variation { + return ExPlat.shared?.experiment(self.rawValue) ?? .control + } + + /// Flag indicating whether the experiment's variation is treament or not. + var isTreatmentVariation: Bool { + switch variation { + case .treatment, .customTreatment: return true + case .control: return false + } + } +} + +extension ABTest { + /// Start the AB Testing platform if any experiment exists + /// + static func start() { + guard ABTest.allCases.count > 1, + AccountHelper.isLoggedIn, + AppConfiguration.isJetpack, + let exPlat = ExPlat.shared + else { + return + } + let experimentNames = ABTest.allCases.filter { $0 != .unknown }.map { $0.rawValue } + exPlat.register(experiments: experimentNames) + exPlat.refresh() + } +} diff --git a/WordPress/Classes/Utility/AccountHelper.swift b/WordPress/Classes/Utility/AccountHelper.swift index f87dcce17e15..77258a794d58 100644 --- a/WordPress/Classes/Utility/AccountHelper.swift +++ b/WordPress/Classes/Utility/AccountHelper.swift @@ -8,11 +8,10 @@ import Foundation /// @objc static func isDotcomAvailable() -> Bool { let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) var available = false context.performAndWait { - available = service.defaultWordPressComAccount() != nil + available = (try? WPAccount.lookupDefaultWordPressComAccount(in: context)) != nil } return available @@ -26,24 +25,31 @@ import Foundation @objc static var noSelfHostedBlogs: Bool { let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) + return BlogQuery().hostedByWPCom(false).count(in: context) == 0 && (try? Blog.hasAnyJetpackBlogs(in: context)) == false + } - return blogService.blogCountSelfHosted() == 0 && blogService.hasAnyJetpackBlogs() == false + static var hasBlogs: Bool { + let context = ContextManager.sharedInstance().mainContext + return Blog.count(in: context) > 0 } @objc static var noWordPressDotComAccount: Bool { return !AccountHelper.isDotcomAvailable() } + static var defaultSiteId: NSNumber? { + let context = ContextManager.sharedInstance().mainContext + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) + return account?.defaultBlog?.dotComID + } + static func logBlogsAndAccounts(context: NSManagedObjectContext) { - let accountService = AccountService(managedObjectContext: context) - let blogService = BlogService(managedObjectContext: context) - let allBlogs = blogService.blogsForAllAccounts() + let allBlogs = (try? BlogQuery().blogs(in: context)) ?? [] let blogsByAccount = Dictionary(grouping: allBlogs, by: { $0.account }) - let defaultAccount = accountService.defaultWordPressComAccount() + let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: context) - let accountCount = accountService.numberOfAccounts() + let accountCount = (try? WPAccount.lookupNumberOfAccounts(in: context)) ?? 0 let otherAccounts = accountCount > 1 ? " + \(accountCount - 1) others" : "" let accountsDescription = "wp.com account: " + (defaultAccount?.logDescription ?? "<none>") + otherAccounts @@ -63,10 +69,27 @@ import Foundation } static func logOutDefaultWordPressComAccount() { - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) + // Unschedule any scheduled blogging reminders + let service = AccountService(coreDataStack: ContextManager.sharedInstance()) + + // Unschedule any scheduled blogging reminders for the account's blogs. + // We don't just clear all reminders, in case the user has self-hosted + // sites added to the app. + if let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext), + let blogs = account.blogs, + let scheduler = try? ReminderScheduleCoordinator() { + blogs.forEach { scheduler.unschedule(for: $0) } + } + service.removeDefaultWordPressComAccount() + deleteAccountData() + } + + @objc static func deleteAccountData() { + // Delete saved dashboard states + BlogDashboardState.resetAllStates() + // Delete local notification on logout PushNotificationsManager.shared.deletePendingLocalNotifications() @@ -77,8 +100,12 @@ import Foundation StatsDataHelper.clearWidgetsData() // Delete donated user activities (e.g., for Siri Shortcuts) - if #available(iOS 12.0, *) { - NSUserActivity.deleteAllSavedUserActivities {} - } + NSUserActivity.deleteAllSavedUserActivities {} + + // Refresh Remote Feature Flags + WordPressAppDelegate.shared?.updateFeatureFlags() + + // Delete all the logs after logging out + WPLogger.shared().deleteAllLogs() } } diff --git a/WordPress/Classes/Utility/Analytics/EventTracker.swift b/WordPress/Classes/Utility/Analytics/EventTracker.swift new file mode 100644 index 000000000000..3a746afe5c43 --- /dev/null +++ b/WordPress/Classes/Utility/Analytics/EventTracker.swift @@ -0,0 +1,17 @@ +/// Convenient tracking abstraction, which allows this to be a visible and injectable dependency. +/// +protocol EventTracker { + func track(_ event: WPAnalyticsEvent) + func track(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any]) +} + +/// The default implementation, which is a thin wrapper over the actual `WPAnalytics` static methods. +struct DefaultEventTracker: EventTracker { + func track(_ event: WPAnalyticsEvent) { + WPAnalytics.track(event) + } + + func track(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any]) { + WPAnalytics.track(event, properties: properties) + } +} diff --git a/WordPress/Classes/Utility/Analytics/WPAnalytics+Domains.swift b/WordPress/Classes/Utility/Analytics/WPAnalytics+Domains.swift new file mode 100644 index 000000000000..d3877434ca99 --- /dev/null +++ b/WordPress/Classes/Utility/Analytics/WPAnalytics+Domains.swift @@ -0,0 +1,31 @@ +import Foundation + +extension WPAnalytics { + + /// Checks if the Domain Purchasing Feature Flag and AB Experiment are enabled + private static var domainPurchasingEnabled: Bool { + FeatureFlag.siteCreationDomainPurchasing.enabled && ABTest.siteCreationDomainPurchasing.isTreatmentVariation + } + + static func domainsProperties(for blog: Blog) -> [AnyHashable: Any] { + // For now we do not have the `siteCreation` route implemented so hardcoding `menu` + domainsProperties(usingCredit: blog.canRegisterDomainWithPaidPlan, origin: .menu) + } + + static func domainsProperties( + usingCredit: Bool, + origin: DomainPurchaseWebViewViewOrigin? + ) -> [AnyHashable: Any] { + var dict: [AnyHashable: Any] = ["using_credit": usingCredit.stringLiteral] + if Self.domainPurchasingEnabled, + let origin = origin { + dict["origin"] = origin.rawValue + } + return dict + } +} + +enum DomainPurchaseWebViewViewOrigin: String { + case siteCreation = "site_creation" + case menu +} diff --git a/WordPress/Classes/Utility/Analytics/WPAnalytics+QuickStart.swift b/WordPress/Classes/Utility/Analytics/WPAnalytics+QuickStart.swift new file mode 100644 index 000000000000..2bf963142b0c --- /dev/null +++ b/WordPress/Classes/Utility/Analytics/WPAnalytics+QuickStart.swift @@ -0,0 +1,43 @@ +import Foundation + +extension WPAnalytics { + + static let WPAppAnalyticsKeyQuickStartSiteType: String = "site_type" + + /// Track a Quick Start event + /// + /// This will call each registered tracker and fire the given event + /// - Parameter event: a `WPAnalyticsEvent` that represents the Quick Start event to track + /// - Parameter properties: a `Hash` that represents the properties + /// - Parameter blog: a `Blog` to which the Quick Start event relates to. Used to determine the Quick Start Type + /// + static func trackQuickStartEvent(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any] = [:], blog: Blog) { + var props = properties + props[WPAppAnalyticsKeyQuickStartSiteType] = blog.quickStartType.key + WPAnalytics.track(event, properties: props) + } + + /// Track a Quick Start stat + /// + /// This will call each registered tracker and fire the given stat + /// - Parameter stat: a `WPAnalyticsStat` that represents the Quick Start stat to track + /// - Parameter properties: a `Hash` that represents the properties + /// - Parameter blog: a `Blog` to which the Quick Start stat relates to. Used to determine the Quick Start Type + /// + static func trackQuickStartStat(_ stat: WPAnalyticsStat, properties: [AnyHashable: Any] = [:], blog: Blog) { + var props = properties + props[WPAppAnalyticsKeyQuickStartSiteType] = blog.quickStartType.key + WPAnalytics.track(stat, withProperties: props) + } + + /// Track a Quick Start stat in Obj-C + /// + /// This will call each registered tracker and fire the given stat + /// - Parameter stat: a `WPAnalyticsStat` that represents the Quick Start stat to track + /// - Parameter blog: a `Blog` to which the Quick Start stat relates to. Used to determine the Quick Start Type + /// + @objc static func trackQuickStartStat(_ stat: WPAnalyticsStat, blog: Blog) { + let props = [WPAppAnalyticsKeyQuickStartSiteType: blog.quickStartType.key] + WPAnalytics.track(stat, withProperties: props) + } +} diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift new file mode 100644 index 000000000000..4cc5ebe79920 --- /dev/null +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -0,0 +1,1496 @@ +import Foundation + +// WPiOS-only events +@objc enum WPAnalyticsEvent: Int { + + case createSheetShown + case createSheetActionTapped + case createAnnouncementModalShown + + // Media Editor + case mediaEditorShown + case mediaEditorUsed + case editorCreatedPage + + // Tenor + case tenorAccessed + case tenorSearched + case tenorUploaded + case mediaLibraryAddedPhotoViaTenor + case editorAddedPhotoViaTenor + + // Settings and Prepublishing Nudges + case editorPostPublishTap + case editorPostPublishDismissed + case editorPostScheduledChanged + case editorPostTitleChanged + case editorPostVisibilityChanged + case editorPostTagsChanged + case editorPostAuthorChanged + case editorPostPublishNowTapped + case editorPostCategoryChanged + case editorPostStatusChanged + case editorPostFormatChanged + case editorPostFeaturedImageChanged + case editorPostStickyChanged + case editorPostLocationChanged + case editorPostSlugChanged + case editorPostExcerptChanged + case editorPostSiteChanged + + // App Settings + case appSettingsAppearanceChanged + + // Gutenberg Features + case gutenbergUnsupportedBlockWebViewShown + case gutenbergUnsupportedBlockWebViewClosed + case gutenbergSuggestionSessionFinished + case gutenbergEditorSettingsFetched + case gutenbergEditorHelpShown + case gutenbergEditorBlockInserted + case gutenbergEditorBlockMoved + + // Notifications Permissions + case pushNotificationsPrimerSeen + case pushNotificationsPrimerAllowTapped + case pushNotificationsPrimerNoTapped + case secondNotificationsAlertSeen + case secondNotificationsAlertAllowTapped + case secondNotificationsAlertNoTapped + + // Reader + case selectInterestsShown + case selectInterestsPicked + case readerDiscoverShown + case readerFollowingShown + case readerSavedListShown + case readerLikedShown + case readerA8CShown + case readerP2Shown + case readerBlogPreviewed + case readerDiscoverPaginated + case readerPostCardTapped + case readerPullToRefresh + case readerDiscoverTopicTapped + case postCardMoreTapped + case followedBlogNotificationsReaderMenuOff + case followedBlogNotificationsReaderMenuOn + case readerArticleVisited + case itemSharedReader + case readerBlogBlocked + case readerAuthorBlocked + case readerChipsMoreToggled + case readerToggleFollowConversation + case readerToggleCommentNotifications + case readerMoreToggleFollowConversation + case readerPostReported + case readerPostAuthorReported + case readerArticleDetailMoreTapped + case readerSharedItem + case readerSuggestedSiteVisited + case readerSuggestedSiteToggleFollow + case readerDiscoverContentPresented + case readerPostMarkSeen + case readerPostMarkUnseen + case readerRelatedPostFromOtherSiteClicked + case readerRelatedPostFromSameSiteClicked + case readerSearchHistoryCleared + case readerArticleLinkTapped + case readerArticleImageTapped + case readerFollowConversationTooltipTapped + case readerFollowConversationAnchorTapped + + // Stats - Empty Stats nudges + case statsPublicizeNudgeShown + case statsPublicizeNudgeTapped + case statsPublicizeNudgeDismissed + case statsPublicizeNudgeCompleted + case statsBloggingRemindersNudgeShown + case statsBloggingRemindersNudgeTapped + case statsBloggingRemindersNudgeDismissed + case statsBloggingRemindersNudgeCompleted + case statsReaderDiscoverNudgeShown + case statsReaderDiscoverNudgeTapped + case statsReaderDiscoverNudgeDismissed + case statsReaderDiscoverNudgeCompleted + case statsLineChartTapped + + // Stats - Insights + case statsCustomizeInsightsShown + case statsInsightsManagementSaved + case statsInsightsManagementDismissed + case statsInsightsViewMore + case statsInsightsViewsVisitorsToggled + case statsInsightsViewsGrowAudienceDismissed + case statsInsightsViewsGrowAudienceConfirmed + case statsInsightsAnnouncementShown + case statsInsightsAnnouncementConfirmed + case statsInsightsAnnouncementDismissed + case statsInsightsTotalLikesGuideTapped + + // What's New - Feature announcements + case featureAnnouncementShown + case featureAnnouncementButtonTapped + + // Stories + case storyIntroShown + case storyIntroDismissed + case storyIntroCreateStoryButtonTapped + case storyAddedMedia + case storyBlockAddMediaTapped + + // Jetpack + case jetpackSettingsViewed + case jetpackManageConnectionViewed + case jetpackDisconnectTapped + case jetpackDisconnectRequested + case jetpackAllowlistedIpsViewed + case jetpackAllowlistedIpsChanged + case activitylogFilterbarSelectType + case activitylogFilterbarResetType + case activitylogFilterbarTypeButtonTapped + case activitylogFilterbarRangeButtonTapped + case activitylogFilterbarSelectRange + case activitylogFilterbarResetRange + case backupListOpened + case backupFilterbarRangeButtonTapped + case backupFilterbarSelectRange + case backupFilterbarResetRange + case restoreOpened + case restoreConfirmed + case restoreError + case restoreNotifiyMeButtonTapped + case backupDownloadOpened + case backupDownloadConfirmed + case backupFileDownloadError + case backupNotifiyMeButtonTapped + case backupFileDownloadTapped + case backupDownloadShareLinkTapped + + // Jetpack Scan + case jetpackScanAccessed + case jetpackScanHistoryAccessed + case jetpackScanHistoryFilter + case jetpackScanThreatListItemTapped + case jetpackScanRunTapped + case jetpackScanIgnoreThreatDialogOpen + case jetpackScanThreatIgnoreTapped + case jetpackScanFixThreatDialogOpen + case jetpackScanThreatFixTapped + case jetpackScanAllThreatsOpen + case jetpackScanAllthreatsFixTapped + case jetpackScanError + + // Comments + case commentViewed + case commentApproved + case commentUnApproved + case commentLiked + case commentUnliked + case commentTrashed + case commentSpammed + case commentEditorOpened + case commentEdited + case commentRepliedTo + case commentFilterChanged + case commentSnackbarNext + case commentFullScreenEntered + case commentFullScreenExited + + // InviteLinks + case inviteLinksGetStatus + case inviteLinksGenerate + case inviteLinksShare + case inviteLinksDisable + + // Page Layout and Site Design Picker + case categoryFilterSelected + case categoryFilterDeselected + + // User Profile Sheet + case userProfileSheetShown + case userProfileSheetSiteShown + + // Blog preview by URL (that is, in a WebView) + case blogUrlPreviewed + + // Likes list shown from Reader Post details + case likeListOpened + + // When Likes list is scrolled + case likeListFetchedMore + + // Recommend app to others + case recommendAppEngaged + case recommendAppContentFetchFailed + + // Domains + case domainsDashboardViewed + case domainsDashboardAddDomainTapped + case domainsSearchSelectDomainTapped + case domainsRegistrationFormViewed + case domainsRegistrationFormSubmitted + case domainsPurchaseWebviewViewed + + // My Site + case mySitePullToRefresh + + // 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 + case initialScreenChanged + + // Account Close + case accountCloseTapped + case accountCloseCompleted + + // App Settings + case appSettingsClearMediaCacheTapped + case appSettingsClearSpotlightIndexTapped + case appSettingsClearSiriSuggestionsTapped + case appSettingsOpenDeviceSettingsTapped + + // Privacy Settings + case privacySettingsOpened + case privacySettingsReportCrashesToggled + + // Notifications + case notificationsPreviousTapped + case notificationsNextTapped + case notificationsMarkAllReadTapped + case notificationMarkAsReadTapped + case notificationMarkAsUnreadTapped + + // Sharing Buttons + case sharingButtonsEditSharingButtonsToggled + case sharingButtonsEditMoreButtonToggled + case sharingButtonsLabelChanged + + // Comment Sharing + case readerArticleCommentShared + case siteCommentsCommentShared + + // People + case peopleFilterChanged + case peopleUserInvited + + // Login: Epilogue + case loginEpilogueChooseSiteTapped + case loginEpilogueCreateNewSiteTapped + + // WebKitView + case webKitViewDisplayed + case webKitViewDismissed + case webKitViewOpenInSafariTapped + case webKitViewReloadTapped + case webKitViewShareTapped + case webKitViewNavigatedBack + case webKitViewNavigatedForward + + // Preview WebKitView + case previewWebKitViewDeviceChanged + + // Add Site + case addSiteAlertDisplayed + + // Change Username + case changeUsernameSearchPerformed + case changeUsernameDisplayed + case changeUsernameDismissed + + // My Site Dashboard + case dashboardCardShown + case dashboardCardItemTapped + case dashboardCardContextualMenuAccessed + case dashboardCardHideTapped + case mySiteTabTapped + case mySiteSiteMenuShown + case mySiteDashboardShown + case mySiteDefaultTabExperimentVariantAssigned + + // Site Intent Question + case enhancedSiteCreationIntentQuestionCanceled + case enhancedSiteCreationIntentQuestionSkipped + case enhancedSiteCreationIntentQuestionVerticalSelected + case enhancedSiteCreationIntentQuestionCustomVerticalSelected + case enhancedSiteCreationIntentQuestionSearchFocused + case enhancedSiteCreationIntentQuestionViewed + case enhancedSiteCreationIntentQuestionExperiment + + // Site Name + case enhancedSiteCreationSiteNameCanceled + case enhancedSiteCreationSiteNameSkipped + case enhancedSiteCreationSiteNameEntered + case enhancedSiteCreationSiteNameViewed + + // Quick Start + case quickStartStarted + case quickStartTapped + + // Onboarding Question Prompt + case onboardingQuestionsDisplayed + case onboardingQuestionsItemSelected + case onboardingQuestionsSkipped + + // Onboarding Enable Notifications Prompt + case onboardingEnableNotificationsDisplayed + case onboardingEnableNotificationsSkipped + case onboardingEnableNotificationsEnableTapped + + // QR Login + case qrLoginScannerDisplayed + case qrLoginScannerScannedCode + case qrLoginScannerDismissed + + case qrLoginCameraPermissionDisplayed + case qrLoginCameraPermissionApproved + case qrLoginCameraPermissionDenied + + case qrLoginVerifyCodeDisplayed + case qrLoginVerifyCodeDismissed + case qrLoginVerifyCodeScanAgain + case qrLoginVerifyCodeFailed + case qrLoginVerifyCodeTokenValidated + case qrLoginVerifyCodeApproved + case qrLoginVerifyCodeCancelled + case qrLoginAuthenticated + // Blogging Reminders Notification + case bloggingRemindersNotificationReceived + + // Blogging Prompts + case promptsBottomSheetAnswerPrompt + case promptsBottomSheetHelp + case promptsBottomSheetViewed + case promptsIntroductionModalViewed + case promptsIntroductionModalDismissed + case promptsIntroductionModalTryItNow + case promptsIntroductionModalRemindMe + case promptsIntroductionModalGotIt + case promptsDashboardCardAnswerPrompt + case promptsDashboardCardMenu + case promptsDashboardCardMenuViewMore + case promptsDashboardCardMenuSkip + case promptsDashboardCardMenuRemove + case promptsDashboardCardMenuLearnMore + case promptsDashboardCardViewed + case promptsListViewed + case promptsReminderSettingsIncludeSwitch + case promptsReminderSettingsHelp + case promptsNotificationAnswerActionTapped + case promptsNotificationDismissActionTapped + case promptsNotificationTapped + case promptsNotificationDismissed + case promptsOtherAnswersTapped + case promptsSettingsShowPromptsTapped + + // Jetpack branding + case jetpackPoweredBadgeTapped + case jetpackPoweredBannerTapped + case jetpackPoweredBottomSheetButtonTapped + case jetpackFullscreenOverlayDisplayed + case jetpackFullscreenOverlayLinkTapped + case jetpackFullscreenOverlayButtonTapped + case jetpackFullscreenOverlayDismissed + case jetpackSiteCreationOverlayDisplayed + case jetpackSiteCreationOverlayButtonTapped + case jetpackSiteCreationOverlayDismissed + case jetpackBrandingMenuCardDisplayed + case jetpackBrandingMenuCardTapped + case jetpackBrandingMenuCardLinkTapped + case jetpackBrandingMenuCardHidden + case jetpackBrandingMenuCardRemindLater + case jetpackBrandingMenuCardContextualMenuAccessed + case jetpackFeatureIncorrectlyAccessed + + // Jetpack plugin overlay modal + case jetpackInstallPluginModalViewed + case jetpackInstallPluginModalDismissed + case jetpackInstallPluginModalInstallTapped + case wordPressInstallPluginModalViewed + case wordPressInstallPluginModalDismissed + case wordPressInstallPluginModalSwitchTapped + + // Jetpack full plugin installation for individual sites + case jetpackInstallFullPluginViewed + case jetpackInstallFullPluginCancelTapped + case jetpackInstallFullPluginInstallTapped + case jetpackInstallFullPluginRetryTapped + case jetpackInstallFullPluginCompleted + case jetpackInstallFullPluginDoneTapped + case jetpackInstallFullPluginCardViewed + case jetpackInstallFullPluginCardTapped + case jetpackInstallFullPluginCardDismissed + + // Blaze + case blazeEntryPointDisplayed + case blazeEntryPointTapped + case blazeContextualMenuAccessed + case blazeCardHidden + case blazeOverlayDisplayed + case blazeOverlayButtonTapped + case blazeOverlayDismissed + case blazeFlowStarted + case blazeFlowCanceled + case blazeFlowCompleted + case blazeFlowError + + // Moved to Jetpack static screen + case removeStaticPosterDisplayed + case removeStaticPosterButtonTapped + case removeStaticPosterLinkTapped + + // Help & Support + case supportOpenMobileForumTapped + case supportMigrationFAQButtonTapped + case supportMigrationFAQCardViewed + + // Jetpack plugin connection to user's WP.com account + case jetpackPluginConnectUserAccountStarted + case jetpackPluginConnectUserAccountFailed + case jetpackPluginConnectUserAccountCompleted + + // Domains Dashboard Card + case directDomainsPurchaseDashboardCardShown + case directDomainsPurchaseDashboardCardTapped + case directDomainsPurchaseDashboardCardHidden + + /// A String that represents the event + var value: String { + switch self { + case .createSheetShown: + return "create_sheet_shown" + case .createSheetActionTapped: + return "create_sheet_action_tapped" + case .createAnnouncementModalShown: + return "create_announcement_modal_shown" + // Media Editor + case .mediaEditorShown: + return "media_editor_shown" + case .mediaEditorUsed: + return "media_editor_used" + case .editorCreatedPage: + return "editor_page_created" + // Tenor + case .tenorAccessed: + return "tenor_accessed" + case .tenorSearched: + return "tenor_searched" + case .tenorUploaded: + return "tenor_uploaded" + case .mediaLibraryAddedPhotoViaTenor: + return "media_library_photo_added" + case .editorAddedPhotoViaTenor: + return "editor_photo_added" + // Editor + case .editorPostPublishTap: + return "editor_post_publish_tapped" + case .editorPostPublishDismissed: + return "editor_post_publish_dismissed" + case .editorPostScheduledChanged: + return "editor_post_scheduled_changed" + case .editorPostTitleChanged: + return "editor_post_title_changed" + case .editorPostVisibilityChanged: + return "editor_post_visibility_changed" + case .editorPostTagsChanged: + return "editor_post_tags_changed" + case .editorPostPublishNowTapped: + return "editor_post_publish_now_tapped" + case .editorPostCategoryChanged: + return "editor_post_category_changed" + case .editorPostStatusChanged: + return "editor_post_status_changed" + case .editorPostFormatChanged: + return "editor_post_format_changed" + case .editorPostFeaturedImageChanged: + return "editor_post_featured_image_changed" + case .editorPostStickyChanged: + return "editor_post_sticky_changed" + case .editorPostAuthorChanged: + return "editor_post_author_changed" + case .editorPostLocationChanged: + return "editor_post_location_changed" + case .editorPostSlugChanged: + return "editor_post_slug_changed" + case .editorPostExcerptChanged: + return "editor_post_excerpt_changed" + case .editorPostSiteChanged: + return "editor_post_site_changed" + case .appSettingsAppearanceChanged: + return "app_settings_appearance_changed" + case .gutenbergUnsupportedBlockWebViewShown: + return "gutenberg_unsupported_block_webview_shown" + case .gutenbergUnsupportedBlockWebViewClosed: + return "gutenberg_unsupported_block_webview_closed" + case .gutenbergSuggestionSessionFinished: + return "suggestion_session_finished" + case .gutenbergEditorSettingsFetched: + return "editor_settings_fetched" + case .gutenbergEditorHelpShown: + return "editor_help_shown" + case .gutenbergEditorBlockInserted: + return "editor_block_inserted" + case .gutenbergEditorBlockMoved: + return "editor_block_moved" + // Notifications permissions + case .pushNotificationsPrimerSeen: + return "notifications_primer_seen" + case .pushNotificationsPrimerAllowTapped: + return "notifications_primer_allow_tapped" + case .pushNotificationsPrimerNoTapped: + return "notifications_primer_no_tapped" + case .secondNotificationsAlertSeen: + return "notifications_second_alert_seen" + case .secondNotificationsAlertAllowTapped: + return "notifications_second_alert_allow_tapped" + case .secondNotificationsAlertNoTapped: + return "notifications_second_alert_no_tapped" + // Reader + case .selectInterestsShown: + return "select_interests_shown" + case .selectInterestsPicked: + return "select_interests_picked" + case .readerDiscoverShown: + return "reader_discover_shown" + case .readerFollowingShown: + return "reader_following_shown" + case .readerLikedShown: + return "reader_liked_shown" + case .readerA8CShown: + return "reader_a8c_shown" + case .readerP2Shown: + return "reader_p2_shown" + case .readerSavedListShown: + return "reader_saved_list_shown" + case .readerBlogPreviewed: + return "reader_blog_previewed" + case .readerDiscoverPaginated: + return "reader_discover_paginated" + case .readerPostCardTapped: + return "reader_post_card_tapped" + case .readerPullToRefresh: + return "reader_pull_to_refresh" + case .readerDiscoverTopicTapped: + return "reader_discover_topic_tapped" + case .postCardMoreTapped: + return "post_card_more_tapped" + case .followedBlogNotificationsReaderMenuOff: + return "followed_blog_notifications_reader_menu_off" + case .followedBlogNotificationsReaderMenuOn: + return "followed_blog_notifications_reader_menu_on" + case .readerArticleVisited: + return "reader_article_visited" + case .itemSharedReader: + return "item_shared_reader" + case .readerBlogBlocked: + return "reader_blog_blocked" + case .readerAuthorBlocked: + return "reader_author_blocked" + case .readerChipsMoreToggled: + return "reader_chips_more_toggled" + case .readerToggleFollowConversation: + return "reader_toggle_follow_conversation" + case .readerToggleCommentNotifications: + return "reader_toggle_comment_notifications" + case .readerMoreToggleFollowConversation: + return "reader_more_toggle_follow_conversation" + case .readerPostReported: + return "reader_post_reported" + case .readerPostAuthorReported: + return "reader_post_author_reported" + case .readerArticleDetailMoreTapped: + return "reader_article_detail_more_tapped" + case .readerSharedItem: + return "reader_shared_item" + case .readerSuggestedSiteVisited: + return "reader_suggested_site_visited" + case .readerSuggestedSiteToggleFollow: + return "reader_suggested_site_toggle_follow" + case .readerDiscoverContentPresented: + return "reader_discover_content_presented" + case .readerPostMarkSeen: + return "reader_mark_as_seen" + case .readerPostMarkUnseen: + return "reader_mark_as_unseen" + case .readerRelatedPostFromOtherSiteClicked: + return "reader_related_post_from_other_site_clicked" + case .readerRelatedPostFromSameSiteClicked: + return "reader_related_post_from_same_site_clicked" + case .readerSearchHistoryCleared: + return "reader_search_history_cleared" + case .readerArticleLinkTapped: + return "reader_article_link_tapped" + case .readerArticleImageTapped: + return "reader_article_image_tapped" + case .readerFollowConversationTooltipTapped: + return "reader_follow_conversation_tooltip_tapped" + case .readerFollowConversationAnchorTapped: + return "reader_follow_conversation_anchor_tapped" + + // Stats - Empty Stats nudges + case .statsPublicizeNudgeShown: + return "stats_publicize_nudge_shown" + case .statsPublicizeNudgeTapped: + return "stats_publicize_nudge_tapped" + case .statsPublicizeNudgeDismissed: + return "stats_publicize_nudge_dismissed" + case .statsPublicizeNudgeCompleted: + return "stats_publicize_nudge_completed" + case .statsBloggingRemindersNudgeShown: + return "stats_blogging_reminders_nudge_shown" + case .statsBloggingRemindersNudgeTapped: + return "stats_blogging_reminders_nudge_tapped" + case .statsBloggingRemindersNudgeDismissed: + return "stats_blogging_reminders_nudge_dismissed" + case .statsBloggingRemindersNudgeCompleted: + return "stats_blogging_reminders_nudge_completed" + case .statsReaderDiscoverNudgeShown: + return "stats_reader_discover_nudge_shown" + case .statsReaderDiscoverNudgeTapped: + return "stats_reader_discover_nudge_tapped" + case .statsReaderDiscoverNudgeDismissed: + return "stats_reader_discover_nudge_dismissed" + case .statsReaderDiscoverNudgeCompleted: + return "stats_reader_discover_nudge_completed" + case .statsLineChartTapped: + return "stats_line_chart_tapped" + + // Stats - Insights + case .statsCustomizeInsightsShown: + return "stats_customize_insights_shown" + case .statsInsightsManagementSaved: + return "stats_insights_management_saved" + case .statsInsightsManagementDismissed: + return "stats_insights_management_dismissed" + case .statsInsightsViewMore: + return "stats_insights_view_more" + case .statsInsightsViewsVisitorsToggled: + return "stats_insights_views_visitors_toggled" + case .statsInsightsViewsGrowAudienceDismissed: + return "stats_insights_views_grow_audience_dismissed" + case .statsInsightsViewsGrowAudienceConfirmed: + return "stats_insights_views_grow_audience_confirmed" + case .statsInsightsAnnouncementShown: + return "stats_insights_announcement_shown" + case .statsInsightsAnnouncementConfirmed: + return "stats_insights_announcement_confirmed" + case .statsInsightsAnnouncementDismissed: + return "stats_insights_announcement_dismissed" + case .statsInsightsTotalLikesGuideTapped: + return "stats_insights_total_likes_guide_tapped" + + // What's New - Feature announcements + case .featureAnnouncementShown: + return "feature_announcement_shown" + case .featureAnnouncementButtonTapped: + return "feature_announcement_button_tapped" + + // Stories + case .storyIntroShown: + return "story_intro_shown" + case .storyIntroDismissed: + return "story_intro_dismissed" + case .storyIntroCreateStoryButtonTapped: + return "story_intro_create_story_button_tapped" + case .storyAddedMedia: + return "story_added_media" + case .storyBlockAddMediaTapped: + return "story_block_add_media_tapped" + + // Jetpack + case .jetpackSettingsViewed: + return "jetpack_settings_viewed" + case .jetpackManageConnectionViewed: + return "jetpack_manage_connection_viewed" + case .jetpackDisconnectTapped: + return "jetpack_disconnect_tapped" + case .jetpackDisconnectRequested: + return "jetpack_disconnect_requested" + case .jetpackAllowlistedIpsViewed: + return "jetpack_allowlisted_ips_viewed" + case .jetpackAllowlistedIpsChanged: + return "jetpack_allowlisted_ips_changed" + case .activitylogFilterbarSelectType: + return "activitylog_filterbar_select_type" + case .activitylogFilterbarResetType: + return "activitylog_filterbar_reset_type" + case .activitylogFilterbarTypeButtonTapped: + return "activitylog_filterbar_type_button_tapped" + case .activitylogFilterbarRangeButtonTapped: + return "activitylog_filterbar_range_button_tapped" + case .activitylogFilterbarSelectRange: + return "activitylog_filterbar_select_range" + case .activitylogFilterbarResetRange: + return "activitylog_filterbar_reset_range" + case .backupListOpened: + return "jetpack_backup_list_opened" + case .backupFilterbarRangeButtonTapped: + return "jetpack_backup_filterbar_range_button_tapped" + case .backupFilterbarSelectRange: + return "jetpack_backup_filterbar_select_range" + case .backupFilterbarResetRange: + return "jetpack_backup_filterbar_reset_range" + case .restoreOpened: + return "jetpack_restore_opened" + case .restoreConfirmed: + return "jetpack_restore_confirmed" + case .restoreError: + return "jetpack_restore_error" + case .restoreNotifiyMeButtonTapped: + return "jetpack_restore_notify_me_button_tapped" + case .backupDownloadOpened: + return "jetpack_backup_download_opened" + case .backupDownloadConfirmed: + return "jetpack_backup_download_confirmed" + case .backupFileDownloadError: + return "jetpack_backup_file_download_error" + case .backupNotifiyMeButtonTapped: + return "jetpack_backup_notify_me_button_tapped" + case .backupFileDownloadTapped: + return "jetpack_backup_file_download_tapped" + case .backupDownloadShareLinkTapped: + return "jetpack_backup_download_share_link_tapped" + + // Jetpack Scan + case .jetpackScanAccessed: + return "jetpack_scan_accessed" + case .jetpackScanHistoryAccessed: + return "jetpack_scan_history_accessed" + case .jetpackScanHistoryFilter: + return "jetpack_scan_history_filter" + case .jetpackScanThreatListItemTapped: + return "jetpack_scan_threat_list_item_tapped" + case .jetpackScanRunTapped: + return "jetpack_scan_run_tapped" + case .jetpackScanIgnoreThreatDialogOpen: + return "jetpack_scan_ignorethreat_dialogopen" + case .jetpackScanThreatIgnoreTapped: + return "jetpack_scan_threat_ignore_tapped" + case .jetpackScanFixThreatDialogOpen: + return "jetpack_scan_fixthreat_dialogopen" + case .jetpackScanThreatFixTapped: + return "jetpack_scan_threat_fix_tapped" + case .jetpackScanAllThreatsOpen: + return "jetpack_scan_allthreats_open" + case .jetpackScanAllthreatsFixTapped: + return "jetpack_scan_allthreats_fix_tapped" + case .jetpackScanError: + return "jetpack_scan_error" + + // Comments + case .commentViewed: + return "comment_viewed" + case .commentApproved: + return "comment_approved" + case .commentUnApproved: + return "comment_unapproved" + case .commentLiked: + return "comment_liked" + case .commentUnliked: + return "comment_unliked" + case .commentTrashed: + return "comment_trashed" + case .commentSpammed: + return "comment_spammed" + case .commentEditorOpened: + return "comment_editor_opened" + case .commentEdited: + return "comment_edited" + case .commentRepliedTo: + return "comment_replied_to" + case .commentFilterChanged: + return "comment_filter_changed" + case .commentSnackbarNext: + return "comment_snackbar_next" + case .commentFullScreenEntered: + return "comment_fullscreen_entered" + case .commentFullScreenExited: + return "comment_fullscreen_exited" + + // Invite Links + case .inviteLinksGetStatus: + return "invite_links_get_status" + case .inviteLinksGenerate: + return "invite_links_generate" + case .inviteLinksShare: + return "invite_links_share" + case .inviteLinksDisable: + return "invite_links_disable" + + // Page Layout and Site Design Picker + case .categoryFilterSelected: + return "category_filter_selected" + case .categoryFilterDeselected: + return "category_filter_deselected" + + // User Profile Sheet + case .userProfileSheetShown: + return "user_profile_sheet_shown" + case .userProfileSheetSiteShown: + return "user_profile_sheet_site_shown" + + // Blog preview by URL (that is, in a WebView) + case .blogUrlPreviewed: + return "blog_url_previewed" + + // Likes list shown from Reader Post details + case .likeListOpened: + return "like_list_opened" + + // When Likes list is scrolled + case .likeListFetchedMore: + return "like_list_fetched_more" + + // When the recommend app button is tapped + case .recommendAppEngaged: + return "recommend_app_engaged" + + // When the content fetching for the recommend app failed + case .recommendAppContentFetchFailed: + return "recommend_app_content_fetch_failed" + + // Domains + case .domainsDashboardViewed: + return "domains_dashboard_viewed" + case .domainsDashboardAddDomainTapped: + return "domains_dashboard_add_domain_tapped" + case .domainsSearchSelectDomainTapped: + return "domains_dashboard_select_domain_tapped" + case .domainsRegistrationFormViewed: + return "domains_registration_form_viewed" + case .domainsRegistrationFormSubmitted: + return "domains_registration_form_submitted" + case .domainsPurchaseWebviewViewed: + return "domains_purchase_webview_viewed" + + // My Site + case .mySitePullToRefresh: + return "my_site_pull_to_refresh" + + // 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 .initialScreenChanged: + return "app_settings_initial_screen_changed" + 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" + + // Notifications + case .notificationsPreviousTapped: + return "notifications_previous_tapped" + case .notificationsNextTapped: + return "notifications_next_tapped" + case .notificationsMarkAllReadTapped: + return "notifications_mark_all_read_tapped" + case .notificationMarkAsReadTapped: + return "notification_mark_as_read_tapped" + case .notificationMarkAsUnreadTapped: + return "notification_mark_as_unread_tapped" + + // Sharing + case .sharingButtonsEditSharingButtonsToggled: + return "sharing_buttons_edit_sharing_buttons_toggled" + case .sharingButtonsEditMoreButtonToggled: + return "sharing_buttons_edit_more_button_toggled" + case .sharingButtonsLabelChanged: + return "sharing_buttons_label_changed" + + // Comment Sharing + case .readerArticleCommentShared: + return "reader_article_comment_shared" + case .siteCommentsCommentShared: + return "site_comments_comment_shared" + + // People + case .peopleFilterChanged: + return "people_management_filter_changed" + case .peopleUserInvited: + return "people_management_user_invited" + + // Login: Epilogue + case .loginEpilogueChooseSiteTapped: + return "login_epilogue_choose_site_tapped" + case .loginEpilogueCreateNewSiteTapped: + return "login_epilogue_create_new_site_tapped" + + // WebKitView + case .webKitViewDisplayed: + return "webkitview_displayed" + case .webKitViewDismissed: + return "webkitview_dismissed" + case .webKitViewOpenInSafariTapped: + return "webkitview_open_in_safari_tapped" + case .webKitViewReloadTapped: + return "webkitview_reload_tapped" + case .webKitViewShareTapped: + return "webkitview_share_tapped" + case .webKitViewNavigatedBack: + return "webkitview_navigated_back" + case .webKitViewNavigatedForward: + return "webkitview_navigated_forward" + + case .previewWebKitViewDeviceChanged: + return "preview_webkitview_device_changed" + + case .addSiteAlertDisplayed: + return "add_site_alert_displayed" + + // Change Username + case .changeUsernameSearchPerformed: + return "change_username_search_performed" + case .changeUsernameDisplayed: + return "change_username_displayed" + case .changeUsernameDismissed: + return "change_username_dismissed" + + // My Site Dashboard + case .dashboardCardShown: + return "my_site_dashboard_card_shown" + case .dashboardCardItemTapped: + return "my_site_dashboard_card_item_tapped" + case .dashboardCardContextualMenuAccessed: + return "my_site_dashboard_contextual_menu_accessed" + case .dashboardCardHideTapped: + return "my_site_dashboard_card_hide_tapped" + case .mySiteTabTapped: + return "my_site_tab_tapped" + case .mySiteSiteMenuShown: + return "my_site_site_menu_shown" + case .mySiteDashboardShown: + return "my_site_dashboard_shown" + case .mySiteDefaultTabExperimentVariantAssigned: + return "my_site_default_tab_experiment_variant_assigned" + + // Quick Start + case .quickStartStarted: + return "quick_start_started" + case .quickStartTapped: + return "quick_start_tapped" + + // Site Intent Question + case .enhancedSiteCreationIntentQuestionCanceled: + return "enhanced_site_creation_intent_question_canceled" + case .enhancedSiteCreationIntentQuestionSkipped: + return "enhanced_site_creation_intent_question_skipped" + case .enhancedSiteCreationIntentQuestionVerticalSelected: + return "enhanced_site_creation_intent_question_vertical_selected" + case .enhancedSiteCreationIntentQuestionCustomVerticalSelected: + return "enhanced_site_creation_intent_question_custom_vertical_selected" + case .enhancedSiteCreationIntentQuestionSearchFocused: + return "enhanced_site_creation_intent_question_search_focused" + case .enhancedSiteCreationIntentQuestionViewed: + return "enhanced_site_creation_intent_question_viewed" + case .enhancedSiteCreationIntentQuestionExperiment: + return "enhanced_site_creation_intent_question_experiment" + + // Onboarding Question Prompt + case .onboardingQuestionsDisplayed: + return "onboarding_questions_displayed" + case .onboardingQuestionsItemSelected: + return "onboarding_questions_item_selected" + case .onboardingQuestionsSkipped: + return "onboarding_questions_skipped" + + // Onboarding Enable Notifications Prompt + case .onboardingEnableNotificationsDisplayed: + return "onboarding_enable_notifications_displayed" + case .onboardingEnableNotificationsSkipped: + return "onboarding_enable_notifications_skipped" + case .onboardingEnableNotificationsEnableTapped: + return "onboarding_enable_notifications_enable_tapped" + + // Site Name + case .enhancedSiteCreationSiteNameCanceled: + return "enhanced_site_creation_site_name_canceled" + case .enhancedSiteCreationSiteNameSkipped: + return "enhanced_site_creation_site_name_skipped" + case .enhancedSiteCreationSiteNameEntered: + return "enhanced_site_creation_site_name_entered" + case .enhancedSiteCreationSiteNameViewed: + return "enhanced_site_creation_site_name_viewed" + + // QR Login + case .qrLoginScannerDisplayed: + return "qrlogin_scanner_displayed" + case .qrLoginScannerScannedCode: + return "qrlogin_scanner_scanned_code" + case .qrLoginScannerDismissed: + return "qrlogin_scanned_dismissed" + case .qrLoginVerifyCodeDisplayed: + return "qrlogin_verify_displayed" + case .qrLoginVerifyCodeDismissed: + return "qrlogin_verify_dismissed" + case .qrLoginVerifyCodeFailed: + return "qrlogin_verify_failed" + case .qrLoginVerifyCodeApproved: + return "qrlogin_verify_approved" + case .qrLoginVerifyCodeScanAgain: + return "qrlogin_verify_scan_again" + case .qrLoginVerifyCodeCancelled: + return "qrlogin_verify_cancelled" + case .qrLoginVerifyCodeTokenValidated: + return "qrlogin_verify_token_validated" + case .qrLoginAuthenticated: + return "qrlogin_authenticated" + case .qrLoginCameraPermissionDisplayed: + return "qrlogin_camera_permission_displayed" + case .qrLoginCameraPermissionApproved: + return "qrlogin_camera_permission_approved" + case .qrLoginCameraPermissionDenied: + return "qrlogin_camera_permission_denied" + + // Blogging Reminders Notification + case .bloggingRemindersNotificationReceived: + return "blogging_reminders_notification_received" + + // Blogging Prompts + case .promptsBottomSheetAnswerPrompt: + return "my_site_create_sheet_answer_prompt_tapped" + case .promptsBottomSheetHelp: + return "my_site_create_sheet_prompt_help_tapped" + case .promptsBottomSheetViewed: + return "blogging_prompts_create_sheet_card_viewed" + case .promptsIntroductionModalViewed: + return "blogging_prompts_introduction_modal_viewed" + case .promptsIntroductionModalDismissed: + return "blogging_prompts_introduction_modal_dismissed" + case .promptsIntroductionModalTryItNow: + return "blogging_prompts_introduction_modal_try_it_now_tapped" + case .promptsIntroductionModalRemindMe: + return "blogging_prompts_introduction_modal_remind_me_tapped" + case .promptsIntroductionModalGotIt: + return "blogging_prompts_introduction_modal_got_it_tapped" + case .promptsDashboardCardAnswerPrompt: + return "blogging_prompts_my_site_card_answer_prompt_tapped" + case .promptsDashboardCardMenu: + return "blogging_prompts_my_site_card_menu_tapped" + case .promptsDashboardCardMenuViewMore: + return "blogging_prompts_my_site_card_menu_view_more_prompts_tapped" + case .promptsDashboardCardMenuSkip: + return "blogging_prompts_my_site_card_menu_skip_this_prompt_tapped" + case .promptsDashboardCardMenuRemove: + return "blogging_prompts_my_site_card_menu_remove_from_dashboard_tapped" + case .promptsDashboardCardMenuLearnMore: + return "blogging_prompts_my_site_card_menu_learn_more_tapped" + case .promptsDashboardCardViewed: + return "blogging_prompts_my_site_card_viewed" + case .promptsListViewed: + return "blogging_prompts_prompts_list_viewed" + case .promptsReminderSettingsIncludeSwitch: + return "blogging_reminders_include_prompt_tapped" + case .promptsReminderSettingsHelp: + return "blogging_reminders_include_prompt_help_tapped" + case .promptsNotificationAnswerActionTapped: + return "blogging_reminders_notification_prompt_answer_tapped" + case .promptsNotificationDismissActionTapped: + return "blogging_reminders_notification_prompt_dismiss_tapped" + case .promptsNotificationTapped: + return "blogging_reminders_notification_prompt_tapped" + case .promptsNotificationDismissed: + return "blogging_reminders_notification_prompt_dismissed" + case .promptsOtherAnswersTapped: + return "blogging_prompts_my_site_card_view_answers_tapped" + case .promptsSettingsShowPromptsTapped: + return "blogging_prompts_settings_show_prompts_tapped" + + // Jetpack branding + case .jetpackPoweredBadgeTapped: + return "jetpack_powered_badge_tapped" + case .jetpackPoweredBannerTapped: + return "jetpack_powered_banner_tapped" + case .jetpackPoweredBottomSheetButtonTapped: + return "jetpack_powered_bottom_sheet_button_tapped" + case .jetpackFullscreenOverlayDisplayed: + return "remove_feature_overlay_displayed" + case .jetpackFullscreenOverlayLinkTapped: + return "remove_feature_overlay_link_tapped" + case .jetpackFullscreenOverlayButtonTapped: + return "remove_feature_overlay_button_tapped" + case .jetpackFullscreenOverlayDismissed: + return "remove_feature_overlay_dismissed" + case .jetpackSiteCreationOverlayDisplayed: + return "remove_site_creation_overlay_displayed" + case .jetpackSiteCreationOverlayButtonTapped: + return "remove_site_creation_overlay_button_tapped" + case .jetpackSiteCreationOverlayDismissed: + return "remove_site_creation_overlay_dismissed" + case .jetpackBrandingMenuCardDisplayed: + return "remove_feature_card_displayed" + case .jetpackBrandingMenuCardTapped: + return "remove_feature_card_tapped" + case .jetpackBrandingMenuCardLinkTapped: + return "remove_feature_card_link_tapped" + case .jetpackBrandingMenuCardHidden: + return "remove_feature_card_hide_tapped" + case .jetpackBrandingMenuCardRemindLater: + return "remove_feature_card_remind_later_tapped" + case .jetpackBrandingMenuCardContextualMenuAccessed: + return "remove_feature_card_menu_accessed" + case .jetpackFeatureIncorrectlyAccessed: + return "jetpack_feature_incorrectly_accessed" + + // Jetpack plugin overlay modal + case .jetpackInstallPluginModalViewed: + return "jp_install_full_plugin_onboarding_modal_viewed" + case .jetpackInstallPluginModalDismissed: + return "jp_install_full_plugin_onboarding_modal_dismissed" + case .jetpackInstallPluginModalInstallTapped: + return "jp_install_full_plugin_onboarding_modal_install_tapped" + case .wordPressInstallPluginModalViewed: + return "wp_individual_site_overlay_viewed" + case .wordPressInstallPluginModalDismissed: + return "wp_individual_site_overlay_dismissed" + case .wordPressInstallPluginModalSwitchTapped: + return "wp_individual_site_overlay_primary_tapped" + + // Jetpack full plugin installation for individual sites + case .jetpackInstallFullPluginViewed: + return "jp_install_full_plugin_flow_viewed" + case .jetpackInstallFullPluginInstallTapped: + return "jp_install_full_plugin_flow_install_tapped" + case .jetpackInstallFullPluginCancelTapped: + return "jp_install_full_plugin_flow_cancel_tapped" + case .jetpackInstallFullPluginRetryTapped: + return "jp_install_full_plugin_flow_retry_tapped" + case .jetpackInstallFullPluginCompleted: + return "jp_install_full_plugin_flow_success" + case .jetpackInstallFullPluginDoneTapped: + return "jp_install_full_plugin_flow_done_tapped" + case .jetpackInstallFullPluginCardViewed: + return "jp_install_full_plugin_card_viewed" + case .jetpackInstallFullPluginCardTapped: + return "jp_install_full_plugin_card_tapped" + case .jetpackInstallFullPluginCardDismissed: + return "jp_install_full_plugin_card_dismissed" + + // Blaze + case .blazeEntryPointDisplayed: + return "blaze_entry_point_displayed" + case .blazeEntryPointTapped: + return "blaze_entry_point_tapped" + case .blazeContextualMenuAccessed: + return "blaze_entry_point_menu_accessed" + case .blazeCardHidden: + return "blaze_entry_point_hide_tapped" + case .blazeOverlayDisplayed: + return "blaze_overlay_displayed" + case .blazeOverlayButtonTapped: + return "blaze_overlay_button_tapped" + case .blazeOverlayDismissed: + return "blaze_overlay_dismissed" + case .blazeFlowStarted: + return "blaze_flow_started" + case .blazeFlowCanceled: + return "blaze_flow_canceled" + case .blazeFlowCompleted: + return "blaze_flow_completed" + case .blazeFlowError: + return "blaze_flow_error" + + // Moved to Jetpack static screen + case .removeStaticPosterDisplayed: + return "remove_static_poster_displayed" + case .removeStaticPosterButtonTapped: + return "remove_static_poster_get_jetpack_tapped" + case .removeStaticPosterLinkTapped: + return "remove_static_poster_link_tapped" + + // Help & Support + case .supportOpenMobileForumTapped: + return "support_open_mobile_forum_tapped" + case .supportMigrationFAQButtonTapped: + return "support_migration_faq_tapped" + case .supportMigrationFAQCardViewed: + return "support_migration_faq_viewed" + + // Jetpack plugin connection to user's WP.com account + case .jetpackPluginConnectUserAccountStarted: + return "jetpack_plugin_connect_user_account_started" + case .jetpackPluginConnectUserAccountFailed: + return "jetpack_plugin_connect_user_account_failed" + case .jetpackPluginConnectUserAccountCompleted: + return "jetpack_plugin_connect_user_account_completed" + + // Domains Dashboard Card + case .directDomainsPurchaseDashboardCardShown: + return "direct_domains_purchase_dashboard_card_shown" + case .directDomainsPurchaseDashboardCardHidden: + return "direct_domains_purchase_dashboard_card_hidden" + case .directDomainsPurchaseDashboardCardTapped: + return "direct_domains_purchase_dashboard_card_tapped" + + } // END OF SWITCH + } + + /** + The default properties of the event + + # Example + ``` + case .mediaEditorShown: + return ["from": "ios"] + ``` + */ + var defaultProperties: [AnyHashable: Any]? { + switch self { + case .mediaLibraryAddedPhotoViaTenor: + 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 + } + } +} + +extension WPAnalytics { + + @objc static var subscriptionCount: Int = 0 + + private static let WPAppAnalyticsKeySubscriptionCount: String = "subscription_count" + + /// Track a event + /// + /// This will call each registered tracker and fire the given event. + /// - Parameter event: a `String` that represents the event name + /// - Note: If an event has its default properties, it will be passed through + static func track(_ event: WPAnalyticsEvent) { + WPAnalytics.trackString(event.value, withProperties: event.defaultProperties ?? [:]) + } + + /// Track a event + /// + /// This will call each registered tracker and fire the given event + /// - Parameter event: a `String` that represents the event name + /// - Parameter properties: a `Hash` that represents the properties + /// + static func track(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any]) { + var mergedProperties: [AnyHashable: Any] = event.defaultProperties ?? [:] + mergedProperties.merge(properties) { (_, new) in new } + + WPAnalytics.trackString(event.value, withProperties: mergedProperties) + } + + /// This will call each registered tracker and fire the given event. + /// - Parameters: + /// - event: a `String` that represents the event name + /// - properties: a `Hash` that represents the properties + /// - blog: a `Blog` asssociated with the event + static func track(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any], blog: Blog) { + var props = properties + props[WPAppAnalyticsKeyBlogID] = blog.dotComID + props[WPAppAnalyticsKeySiteType] = blog.isWPForTeams() ? WPAppAnalyticsValueSiteTypeP2 : WPAppAnalyticsValueSiteTypeBlog + WPAnalytics.track(event, properties: props) + } + + /// Track a Reader event + /// + /// This will call each registered tracker and fire the given event + /// - Parameter event: a `String` that represents the Reader event name + /// - Parameter properties: a `Hash` that represents the properties + /// + static func trackReader(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any] = [:]) { + var props = properties + props[WPAppAnalyticsKeySubscriptionCount] = subscriptionCount + WPAnalytics.track(event, properties: props) + } + + /// Track a Reader stat + /// + /// This will call each registered tracker and fire the given event + /// - Parameter event: a `String` that represents the Reader event name + /// - Parameter properties: a `Hash` that represents the properties + /// + static func trackReader(_ stat: WPAnalyticsStat, properties: [AnyHashable: Any] = [:]) { + var props = properties + props[WPAppAnalyticsKeySubscriptionCount] = subscriptionCount + WPAnalytics.track(stat, withProperties: props) + } + + /// Track a event in Obj-C + /// + /// This will call each registered tracker and fire the given event + /// - Parameter event: a `String` that represents the event name + /// + @objc static func trackEvent(_ event: WPAnalyticsEvent) { + WPAnalytics.trackString(event.value) + } + + /// Track a event in Obj-C + /// + /// This will call each registered tracker and fire the given event + /// - Parameter event: a `WPAnalyticsEvent` that represents the event name + /// - Parameter properties: a `Hash` that represents the properties + /// + @objc static func trackEvent(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any]) { + track(event, properties: properties) + } + + /// Track an event in Obj-C + /// + /// This will call each registered tracker and fire the given event. + /// - Parameters: + /// - event: a `WPAnalyticsEvent` that represents the event name + /// - properties: a `Hash` that represents the properties + /// - blog: a `Blog` asssociated with the event + @objc static func trackEvent(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any], blog: Blog) { + track(event, properties: properties, blog: blog) + } + + /// Track a Reader event in Obj-C + /// + /// This will call each registered tracker and fire the given event + /// - Parameter event: a `String` that represents the Reader event name + /// - Parameter properties: a `Hash` that represents the properties + /// + @objc static func trackReaderEvent(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any]) { + var props = properties + props[WPAppAnalyticsKeySubscriptionCount] = subscriptionCount + WPAnalytics.track(event, properties: props) + } + + /// Track a Reader stat in Obj-C + /// + /// This will call each registered tracker and fire the given stat + /// - Parameter stat: a `String` that represents the Reader stat name + /// - Parameter properties: a `Hash` that represents the properties + /// + @objc static func trackReaderStat(_ stat: WPAnalyticsStat, properties: [AnyHashable: Any]) { + var props = properties + props[WPAppAnalyticsKeySubscriptionCount] = subscriptionCount + WPAnalytics.track(stat, withProperties: props) + } + + /// This will call each registered tracker and fire the given event. + /// - Parameters: + /// - eventName: a `String` that represents the Block Editor event name + /// - properties: a `Hash` that represents the properties + /// - blog: a `Blog` asssociated with the event + static func trackBlockEditorEvent(_ eventName: String, properties: [AnyHashable: Any], blog: Blog) { + var event: WPAnalyticsEvent? + switch eventName { + case "editor_block_inserted": event = .gutenbergEditorBlockInserted + case "editor_block_moved": event = .gutenbergEditorBlockMoved + default: event = nil + } + + if event == nil { + print("🟡 Not Tracked: \"\(eventName)\" Block Editor event ignored as it was not found in the `trackBlockEditorEvent` conversion cases.") + } else { + WPAnalytics.track(event!, properties: properties, blog: blog) + } + } + + @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 361c5e2685f0..756260b398d0 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsTrackerAutomatticTracks.m +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsTrackerAutomatticTracks.m @@ -1,5 +1,5 @@ #import "WPAnalyticsTrackerAutomatticTracks.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "AccountService.h" #import "BlogService.h" #import "WPAccount.h" @@ -47,6 +47,8 @@ - (instancetype)init if (self) { _contextManager = [TracksContextManager new]; _tracksService = [[TracksService alloc] initWithContextManager:_contextManager]; + _tracksService.eventNamePrefix = AppConstants.eventNamePrefix; + _tracksService.platform = AppConstants.explatPlatform; } return self; } @@ -69,23 +71,32 @@ - (void)track:(WPAnalyticsStat)stat withProperties:(NSDictionary *)properties [mergedProperties addEntriesFromDictionary:eventPair.properties]; [mergedProperties addEntriesFromDictionary:properties]; - if (eventPair.properties == nil && properties == nil) { - DDLogInfo(@"🔵 Tracked: %@", eventPair.eventName); + [self trackString:eventPair.eventName withProperties:mergedProperties]; +} + +- (void)trackString:(NSString *)event +{ + [self trackString:event withProperties:nil]; +} + +- (void)trackString:(NSString *)event withProperties:(NSDictionary *)properties { + if (properties == nil) { + DDLogInfo(@"🔵 Tracked: %@", event); } else { - NSArray<NSString *> *propertyKeys = [[mergedProperties allKeys] sortedArrayUsingSelector:@selector(localizedCaseInsensitiveCompare:)]; + NSArray<NSString *> *propertyKeys = [[properties allKeys] sortedArrayUsingSelector:@selector(localizedCaseInsensitiveCompare:)]; NSString *propertiesDescription = [[propertyKeys wp_map:^NSString *(NSString *key) { - return [NSString stringWithFormat:@"%@: %@", key, mergedProperties[key]]; + return [NSString stringWithFormat:@"%@: %@", key, properties[key]]; }] componentsJoinedByString:@", "]; - DDLogInfo(@"🔵 Tracked: %@ <%@>", eventPair.eventName, propertiesDescription); + DDLogInfo(@"🔵 Tracked: %@ <%@>", event, propertiesDescription); } - [self.tracksService trackEventName:eventPair.eventName withCustomProperties:mergedProperties]; + [self.tracksService trackEventName:event withCustomProperties:properties]; } - (void)beginSession { if (self.loggedInID.length > 0) { - [self.tracksService switchToAuthenticatedUserWithUsername:self.loggedInID userID:nil skipAliasEventCreation:YES]; + [self.tracksService switchToAuthenticatedUserWithUsername:self.loggedInID userID:nil wpComToken:[WPAccount tokenForUsername:self.loggedInID] skipAliasEventCreation:YES]; } else { [self.tracksService switchToAnonymousUserWithAnonymousID:self.anonymousID]; } @@ -110,13 +121,10 @@ - (void)refreshMetadata __block WPAccount *account; [context performBlockAndWait:^{ - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context]; - account = [accountService defaultWordPressComAccount]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:[[ContextManager sharedInstance] mainContext]]; + account = [WPAccount lookupDefaultWordPressComAccountInContext:context]; - - blogCount = [blogService blogCountForAllAccounts]; - jetpackBlogsPresent = [blogService hasAnyJetpackBlogs]; + blogCount = [Blog countInContext:context]; + jetpackBlogsPresent = [Blog hasAnyJetpackBlogsInContext:context]; if (account != nil) { username = account.username; userID = nil; @@ -144,6 +152,7 @@ - (void)refreshMetadata }]; NSMutableDictionary *userProperties = [NSMutableDictionary new]; + userProperties[@"app_scheme"] = WPComScheme; userProperties[@"platform"] = @"iOS"; userProperties[@"dotcom_user"] = @(dotcom_user); userProperties[@"jetpack_user"] = @(jetpackBlogsPresent); @@ -162,14 +171,14 @@ - (void)refreshMetadata self.loggedInID = username; self.anonymousID = nil; - [self.tracksService switchToAuthenticatedUserWithUsername:username userID:@"" skipAliasEventCreation:NO]; + [self.tracksService switchToAuthenticatedUserWithUsername:username userID:@"" wpComToken:[WPAccount tokenForUsername:username] skipAliasEventCreation:NO]; } else if ([self.loggedInID isEqualToString:username]){ // Username did not change from last refreshMetadata - just make sure Tracks client has it - [self.tracksService switchToAuthenticatedUserWithUsername:username userID:@"" skipAliasEventCreation:YES]; + [self.tracksService switchToAuthenticatedUserWithUsername:username userID:@"" wpComToken:[WPAccount tokenForUsername:username] skipAliasEventCreation:YES]; } else { // Username changed for some reason - switch back to anonymous first [self.tracksService switchToAnonymousUserWithAnonymousID:self.anonymousID]; - [self.tracksService switchToAuthenticatedUserWithUsername:username userID:@"" skipAliasEventCreation:NO]; + [self.tracksService switchToAuthenticatedUserWithUsername:username userID:@"" wpComToken:[WPAccount tokenForUsername:username] skipAliasEventCreation:NO]; self.loggedInID = username; self.anonymousID = nil; } @@ -185,10 +194,10 @@ - (void)refreshMetadata - (NSString *)anonymousID { if (_anonymousID == nil || _anonymousID.length == 0) { - NSString *anonymousID = [[NSUserDefaults standardUserDefaults] stringForKey:TracksUserDefaultsAnonymousUserIDKey]; + NSString *anonymousID = [[UserPersistentStoreFactory userDefaultsInstance] stringForKey:TracksUserDefaultsAnonymousUserIDKey]; if (anonymousID == nil) { anonymousID = [[NSUUID UUID] UUIDString]; - [[NSUserDefaults standardUserDefaults] setObject:anonymousID forKey:TracksUserDefaultsAnonymousUserIDKey]; + [[UserPersistentStoreFactory userDefaultsInstance] setObject:anonymousID forKey:TracksUserDefaultsAnonymousUserIDKey]; } _anonymousID = anonymousID; @@ -202,17 +211,17 @@ - (void)setAnonymousID:(NSString *)anonymousID _anonymousID = anonymousID; if (anonymousID == nil) { - [[NSUserDefaults standardUserDefaults] removeObjectForKey:TracksUserDefaultsAnonymousUserIDKey]; + [[UserPersistentStoreFactory userDefaultsInstance] removeObjectForKey:TracksUserDefaultsAnonymousUserIDKey]; return; } - [[NSUserDefaults standardUserDefaults] setObject:anonymousID forKey:TracksUserDefaultsAnonymousUserIDKey]; + [[UserPersistentStoreFactory userDefaultsInstance] setObject:anonymousID forKey:TracksUserDefaultsAnonymousUserIDKey]; } - (NSString *)loggedInID { if (_loggedInID == nil || _loggedInID.length == 0) { - NSString *loggedInID = [[NSUserDefaults standardUserDefaults] stringForKey:TracksUserDefaultsLoggedInUserIDKey]; + NSString *loggedInID = [[UserPersistentStoreFactory userDefaultsInstance] stringForKey:TracksUserDefaultsLoggedInUserIDKey]; if (loggedInID != nil) { _loggedInID = loggedInID; } @@ -226,11 +235,11 @@ - (void)setLoggedInID:(NSString *)loggedInID _loggedInID = loggedInID; if (loggedInID == nil) { - [[NSUserDefaults standardUserDefaults] removeObjectForKey:TracksUserDefaultsLoggedInUserIDKey]; + [[UserPersistentStoreFactory userDefaultsInstance] removeObjectForKey:TracksUserDefaultsLoggedInUserIDKey]; return; } - [[NSUserDefaults standardUserDefaults] setObject:loggedInID forKey:TracksUserDefaultsLoggedInUserIDKey]; + [[UserPersistentStoreFactory userDefaultsInstance] setObject:loggedInID forKey:TracksUserDefaultsLoggedInUserIDKey]; } + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat @@ -437,10 +446,6 @@ + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat case WPAnalyticsStatDomainCreditRedemptionTapped: eventName = @"domain_credit_redemption_tapped"; break; - case WPAnalyticsStatEditorAddedPhotoViaGiphy: - eventName = @"editor_photo_added"; - eventProperties = @{ @"via" : @"giphy" }; - break; case WPAnalyticsStatEditorAddedPhotoViaLocalLibrary: eventName = @"editor_photo_added"; eventProperties = @{ @"via" : @"local_library" }; @@ -528,9 +533,6 @@ + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat case WPAnalyticsStatEditorSessionTemplateApply: eventName = @"editor_session_template_apply"; break; - case WPAnalyticsStatEditorSessionTemplatePreview: - eventName = @"editor_session_template_preview"; - break; case WPAnalyticsStatEditorPublishedPost: eventName = @"editor_post_published"; break; @@ -660,6 +662,30 @@ + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat case WPAnalyticsStatEnhancedSiteCreationSegmentsSelected: eventName = @"enhanced_site_creation_segments_selected"; break; + case WPAnalyticsStatEnhancedSiteCreationSiteDesignViewed: + eventName = @"enhanced_site_creation_site_design_viewed"; + break; + case WPAnalyticsStatEnhancedSiteCreationSiteDesignSelected: + eventName = @"enhanced_site_creation_site_design_selected"; + break; + case WPAnalyticsStatEnhancedSiteCreationSiteDesignSkipped: + eventName = @"enhanced_site_creation_site_design_skipped"; + break; + case WPAnalyticsStatEnhancedSiteCreationSiteDesignPreviewViewed: + eventName = @"enhanced_site_creation_site_design_preview_viewed"; + break; + case WPAnalyticsStatEnhancedSiteCreationSiteDesignPreviewLoading: + eventName = @"enhanced_site_creation_site_design_preview_loading"; + break; + case WPAnalyticsStatEnhancedSiteCreationSiteDesignPreviewLoaded: + eventName = @"enhanced_site_creation_site_design_preview_loaded"; + break; + case WPAnalyticsStatEnhancedSiteCreationSiteDesignPreviewModeButtonTapped: + eventName = @"enhanced_site_creation_site_design_preview_mode_button_tapped"; + break; + case WPAnalyticsStatEnhancedSiteCreationSiteDesignPreviewModeChanged: + eventName = @"enhanced_site_creation_site_design_preview_mode_changed"; + break; case WPAnalyticsStatEnhancedSiteCreationVerticalsViewed: eventName = @"enhanced_site_creation_verticals_viewed"; break; @@ -699,15 +725,6 @@ + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat case WPAnalyticsStatEnhancedSiteCreationErrorShown: eventName = @"enhanced_site_creation_error_shown"; break; - case WPAnalyticsStatGiphyAccessed: - eventName = @"giphy_accessed"; - break; - case WPAnalyticsStatGiphySearched: - eventName = @"giphy_searched"; - break; - case WPAnalyticsStatGiphyUploaded: - eventName = @"giphy_uploaded"; - break; case WPAnalyticsStatGravatarCropped: eventName = @"me_gravatar_cropped"; break; @@ -768,6 +785,30 @@ + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat case WPAnalyticsStatInstallJetpackWebviewFailed: eventName = @"connect_jetpack_failed"; break; + case WPAnalyticsStatLandingEditorShown: + eventName = @"landing_editor_shown"; + break; + case WPAnalyticsStatLayoutPickerPreviewErrorShown: + eventName = @"layout_picker_preview_error_shown"; + break; + case WPAnalyticsStatLayoutPickerPreviewLoaded: + eventName = @"layout_picker_preview_loaded"; + break; + case WPAnalyticsStatLayoutPickerPreviewLoading: + eventName = @"layout_picker_preview_loading"; + break; + case WPAnalyticsStatLayoutPickerPreviewModeButtonTapped: + eventName = @"layout_picker_preview_mode_button_tapped"; + break; + case WPAnalyticsStatLayoutPickerPreviewModeChanged: + eventName = @"layout_picker_preview_mode_changed"; + break; + case WPAnalyticsStatLayoutPickerPreviewViewed: + eventName = @"layout_picker_preview_viewed"; + break; + case WPAnalyticsStatLayoutPickerThumbnailModeButtonTapped: + eventName = @"layout_picker_thumbnail_mode_button_tapped"; + break; case WPAnalyticsStatLogSpecialCondition: eventName = @"log_special_condition"; break; @@ -852,12 +893,6 @@ + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat case WPAnalyticsStatLowMemoryWarning: eventName = @"application_low_memory_warning"; break; - case WPAnalyticsStatMediaEditorShown: - eventName = @"media_editor_shown"; - break; - case WPAnalyticsStatMediaEditorUsed: - eventName = @"media_editor_used"; - break; case WPAnalyticsStatMediaLibraryDeletedItems: eventName = @"media_library_deleted_items"; break; @@ -877,10 +912,6 @@ + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat eventName = @"media_library_photo_added"; eventProperties = @{ @"via" : @"device_library" }; break; - case WPAnalyticsStatMediaLibraryAddedPhotoViaGiphy: - eventName = @"media_library_photo_added"; - eventProperties = @{ @"via" : @"giphy" }; - break; case WPAnalyticsStatMediaLibraryAddedPhotoViaOtherApps: eventName = @"media_library_photo_added"; eventProperties = @{ @"via" : @"other_library" }; @@ -959,17 +990,8 @@ + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat case WPAnalyticsStatMySitesTabAccessed: eventName = @"my_site_tab_accessed"; break; - case WPAnalyticsStatNewsCardViewed: - eventName = @"news_card_shown"; - break; - case WPAnalyticsStatNewsCardDismissed: - eventName = @"news_card_dismissed"; - break; - case WPAnalyticsStatNewsCardRequestedExtendedInfo: - eventName = @"news_card_extended_info_requested"; - break; case WPAnalyticsStatNotificationsCommentApproved: - eventName = @"notifications_approved"; + eventName = @"notifications_comment_approved"; break; case WPAnalyticsStatNotificationsCommentFlaggedAsSpam: eventName = @"notifications_flagged_as_spam"; @@ -1184,6 +1206,10 @@ + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat eventName = @"post_list_button_pressed"; eventProperties = @{ TracksEventPropertyButtonKey : @"edit" }; break; + case WPAnalyticsStatPostListDuplicateAction: + eventName = @"post_list_button_pressed"; + eventProperties = @{ TracksEventPropertyButtonKey : @"copy" }; // Property aligned with Android + break; case WPAnalyticsStatPostListExcessiveLoadMoreDetected: eventName = @"post_list_excessive_load_more_detected"; break; @@ -1472,6 +1498,7 @@ + (TracksEventPair *)eventPairForStat:(WPAnalyticsStat)stat break; case WPAnalyticsStatReaderTagFollowed: eventName = @"reader_reader_tag_followed"; + eventProperties = @{ @"source" : @"unknown" }; break; case WPAnalyticsStatReaderTagLoaded: eventName = @"reader_tag_loaded"; @@ -1719,6 +1746,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/Analytics/WPAnalyticsTrackerWPCom.m b/WordPress/Classes/Utility/Analytics/WPAnalyticsTrackerWPCom.m index 4b0f4fbc3d33..3e0736f240dc 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsTrackerWPCom.m +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsTrackerWPCom.m @@ -26,6 +26,16 @@ - (void)track:(WPAnalyticsStat)stat withProperties:(NSDictionary *)properties } } +- (void)trackString:(NSString *)event +{ + // Only WPAnalyticsStat should be used in this Tracker +} + +- (void)trackString:(NSString *)event withProperties:(NSDictionary *)properties +{ + // Only WPAnalyticsStat should be used in this Tracker +} + - (void)pingWPComStatsEndpoint:(NSString *)statName { int x = arc4random(); diff --git a/WordPress/Classes/Utility/Analytics/WPAppAnalytics+Media.swift b/WordPress/Classes/Utility/Analytics/WPAppAnalytics+Media.swift index f754ab07d14b..0d4af8374aa9 100644 --- a/WordPress/Classes/Utility/Analytics/WPAppAnalytics+Media.swift +++ b/WordPress/Classes/Utility/Analytics/WPAppAnalytics+Media.swift @@ -59,10 +59,15 @@ public struct MediaAnalyticsInfo { self.selectionMethod = selectionMethod } - func eventForMediaType(_ mediaType: MediaType) -> WPAnalyticsStat? { + func eventForMediaType(_ mediaType: MediaType) -> WPAnalyticsEvent? { return origin.eventForMediaType(mediaType) } + // Old tracking events via WPShared + func wpsharedEventForMediaType(_ mediaType: MediaType) -> WPAnalyticsStat? { + return origin.wpsharedEventForMediaType(mediaType) + } + var retryEvent: WPAnalyticsStat? { switch origin { case .mediaLibrary: @@ -107,12 +112,28 @@ enum MediaUploadOrigin { case mediaLibrary(MediaSource) case editor(MediaSource) - func eventForMediaType(_ mediaType: MediaType) -> WPAnalyticsStat? { + // All new media tracking events will be added into WPAnalyticsEvent + func eventForMediaType(_ mediaType: MediaType) -> WPAnalyticsEvent? { + switch (self, mediaType) { + // Media Library + case (.mediaLibrary(let source), .image) where source == .tenor: + return .mediaLibraryAddedPhotoViaTenor + + // Editor + case (.editor(let source), .image) where source == .tenor: + return .editorAddedPhotoViaTenor + + default: + return nil + } + } + + // This is for the previous events created within WordPressShared + func wpsharedEventForMediaType(_ mediaType: MediaType) -> WPAnalyticsStat? { switch (self, mediaType) { + // Media Library case (.mediaLibrary(let source), .image) where source == .deviceLibrary: return .mediaLibraryAddedPhotoViaDeviceLibrary - case (.mediaLibrary(let source), .image) where source == .giphy: - return .mediaLibraryAddedPhotoViaGiphy case (.mediaLibrary(let source), .image) where source == .otherApps: return .mediaLibraryAddedPhotoViaOtherApps case (.mediaLibrary(let source), .image) where source == .stockPhotos: @@ -125,8 +146,7 @@ enum MediaUploadOrigin { return .mediaLibraryAddedVideoViaOtherApps case (.mediaLibrary(let source), .video) where source == .camera: return .mediaLibraryAddedVideoViaCamera - case (.editor(let source), .image) where source == .giphy : - return .editorAddedPhotoViaGiphy + // Editor case (.editor(let source), .image) where source == .deviceLibrary: return .editorAddedPhotoViaLocalLibrary case (.editor(let source), .image) where source == .wpMediaLibrary: @@ -157,6 +177,6 @@ enum MediaSource { case wpMediaLibrary case stockPhotos case camera - case giphy case mediaEditor + case tenor } diff --git a/WordPress/Classes/Utility/Analytics/WPAppAnalytics.h b/WordPress/Classes/Utility/Analytics/WPAppAnalytics.h index 33dd0bddde20..c8aae9f55d0d 100644 --- a/WordPress/Classes/Utility/Analytics/WPAppAnalytics.h +++ b/WordPress/Classes/Utility/Analytics/WPAppAnalytics.h @@ -9,6 +9,7 @@ extern NSString * const WPAppAnalyticsDefaultsUserOptedOut; extern NSString * const WPAppAnalyticsDefaultsKeyUsageTracking_deprecated; extern NSString * const WPAppAnalyticsKeyBlogID; extern NSString * const WPAppAnalyticsKeyPostID; +extern NSString * const WPAppAnalyticsKeyPostAuthorID; extern NSString * const WPAppAnalyticsKeyFeedID; extern NSString * const WPAppAnalyticsKeyFeedItemID; extern NSString * const WPAppAnalyticsKeyIsJetpack; @@ -17,7 +18,15 @@ extern NSString * const WPAppAnalyticsKeyEditorSource; extern NSString * const WPAppAnalyticsKeyCommentID; extern NSString * const WPAppAnalyticsKeyLegacyQuickAction; extern NSString * const WPAppAnalyticsKeyQuickAction; +extern NSString * const WPAppAnalyticsKeyFollowAction; extern NSString * const WPAppAnalyticsKeySource; +extern NSString * const WPAppAnalyticsKeyPostType; +extern NSString * const WPAppAnalyticsKeyTapSource; +extern NSString * const WPAppAnalyticsKeyTabSource; +extern NSString * const WPAppAnalyticsKeyReplyingTo; +extern NSString * const WPAppAnalyticsKeySiteType; +extern NSString * const WPAppAnalyticsValueSiteTypeBlog; +extern NSString * const WPAppAnalyticsValueSiteTypeP2; /** * @class WPAppAnalytics @@ -34,25 +43,24 @@ extern NSString * const WPAppAnalyticsKeySource; /** * @brief Default initializer. * - * @param accountService An instance of AccountService, used to fetch - * the default wpcom account (if available) and - * update settings relating to analytics. * @param lastVisibleScreenCallback This block will be executed whenever this object * needs to know the last visible screen for tracking * purposes. * * @returns The initialized object. */ -- (instancetype)initWithAccountService:(AccountService *)accountService - lastVisibleScreenBlock:(WPAppAnalyticsLastVisibleScreenCallback)lastVisibleScreenCallback; - -@property (nonatomic, readonly) AccountService *accountService; +- (instancetype)initWithLastVisibleScreenBlock:(WPAppAnalyticsLastVisibleScreenCallback)lastVisibleScreenCallback; /** * @brief The current session count. */ + (NSInteger)sessionCount; +/** + * @brief Returns the site type for the blogID. Default is "blog". + */ ++ (NSString *)siteTypeForBlogWithID:(NSNumber *)blogID; + #pragma mark - User Opt Out /** diff --git a/WordPress/Classes/Utility/Analytics/WPAppAnalytics.m b/WordPress/Classes/Utility/Analytics/WPAppAnalytics.m index 482b574d360c..47db3af7b578 100644 --- a/WordPress/Classes/Utility/Analytics/WPAppAnalytics.m +++ b/WordPress/Classes/Utility/Analytics/WPAppAnalytics.m @@ -1,11 +1,11 @@ #import "WPAppAnalytics.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "WPAnalyticsTrackerWPCom.h" #import "WPAnalyticsTrackerAutomatticTracks.h" #import "WPTabBarController.h" -#import "ApiCredentials.h" #import "AccountService.h" +#import "BlogService.h" #import "Blog.h" #import "AbstractPost.h" #import "WordPress-Swift.h" @@ -14,21 +14,34 @@ NSString * const WPAppAnalyticsDefaultsKeyUsageTracking_deprecated = @"usage_tracking_enabled"; NSString * const WPAppAnalyticsKeyBlogID = @"blog_id"; NSString * const WPAppAnalyticsKeyPostID = @"post_id"; +NSString * const WPAppAnalyticsKeyPostAuthorID = @"post_author_id"; NSString * const WPAppAnalyticsKeyFeedID = @"feed_id"; NSString * const WPAppAnalyticsKeyFeedItemID = @"feed_item_id"; NSString * const WPAppAnalyticsKeyIsJetpack = @"is_jetpack"; NSString * const WPAppAnalyticsKeySessionCount = @"session_count"; +NSString * const WPAppAnalyticsKeySubscriptionCount = @"subscription_count"; NSString * const WPAppAnalyticsKeyEditorSource = @"editor_source"; NSString * const WPAppAnalyticsKeyCommentID = @"comment_id"; NSString * const WPAppAnalyticsKeyLegacyQuickAction = @"is_quick_action"; NSString * const WPAppAnalyticsKeyQuickAction = @"quick_action"; - +NSString * const WPAppAnalyticsKeyFollowAction = @"follow_action"; NSString * const WPAppAnalyticsKeySource = @"source"; +NSString * const WPAppAnalyticsKeyPostType = @"post_type"; +NSString * const WPAppAnalyticsKeyTapSource = @"tap_source"; +NSString * const WPAppAnalyticsKeyTabSource = @"tab_source"; +NSString * const WPAppAnalyticsKeyReplyingTo = @"replying_to"; +NSString * const WPAppAnalyticsKeySiteType = @"site_type"; NSString * const WPAppAnalyticsKeyHasGutenbergBlocks = @"has_gutenberg_blocks"; +NSString * const WPAppAnalyticsKeyHasStoriesBlocks = @"has_wp_stories_blocks"; + static NSString * const WPAppAnalyticsKeyLastVisibleScreen = @"last_visible_screen"; static NSString * const WPAppAnalyticsKeyTimeInApp = @"time_in_app"; +NSString * const WPAppAnalyticsValueSiteTypeBlog = @"blog"; +NSString * const WPAppAnalyticsValueSiteTypeP2 = @"p2"; + + @interface WPAppAnalytics () /** @@ -36,8 +49,6 @@ @interface WPAppAnalytics () */ @property (nonatomic, strong, readwrite) NSDate* applicationOpenedTime; -@property (nonatomic, strong, readwrite) AccountService *accountService; - /** * @brief If set, this block will be called whenever this object needs to know what the last * visible screen was, for tracking purposes. @@ -55,16 +66,13 @@ - (instancetype)init return nil; } -- (instancetype)initWithAccountService:(AccountService *)accountService - lastVisibleScreenBlock:(WPAppAnalyticsLastVisibleScreenCallback)lastVisibleScreenCallback +- (instancetype)initWithLastVisibleScreenBlock:(WPAppAnalyticsLastVisibleScreenCallback)lastVisibleScreenCallback; { - NSParameterAssert(accountService); NSParameterAssert(lastVisibleScreenCallback); self = [super init]; if (self) { - _accountService = accountService; _lastVisibleScreenCallback = lastVisibleScreenCallback; [self initializeAppTracking]; @@ -84,7 +92,8 @@ - (void)initializeAppTracking [self initializeOptOutTracking]; BOOL userHasOptedOut = [WPAppAnalytics userHasOptedOut]; - if (!userHasOptedOut) { + BOOL isUITesting = [[NSProcessInfo processInfo].arguments containsObject:@"-ui-testing"]; + if (!isUITesting && !userHasOptedOut) { [self registerTrackers]; [self beginSession]; } @@ -102,6 +111,13 @@ - (void)clearTrackers [WPAnalytics clearTrackers]; } ++ (NSString *)siteTypeForBlogWithID:(NSNumber *)blogID +{ + NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; + Blog *blog = [Blog lookupWithID:blogID in:context]; + return [blog isWPForTeams] ? WPAppAnalyticsValueSiteTypeP2 : WPAppAnalyticsValueSiteTypeBlog; +} + #pragma mark - Notifications - (void)startObservingNotifications @@ -138,7 +154,8 @@ - (void)applicationDidEnterBackground:(NSNotification*)notification - (void)accountSettingsDidChange:(NSNotification*)notification { - WPAccount *defaultAccount = [self.accountService defaultWordPressComAccount]; + NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:context]; if (!defaultAccount.settings) { return; } @@ -184,9 +201,13 @@ - (void)trackApplicationClosed analyticsProperties[WPAppAnalyticsKeyTimeInApp] = @(timeInApp); self.applicationOpenedTime = nil; } + + [[ReaderTracker shared] stopAll]; + [analyticsProperties addEntriesFromDictionary: [[ReaderTracker shared] data]]; [WPAnalytics track:WPAnalyticsStatApplicationClosed withProperties:analyticsProperties]; [WPAnalytics endSession]; + [[ReaderTracker shared] reset]; } /** @@ -251,6 +272,9 @@ + (void)track:(WPAnalyticsStat)stat withProperties:(NSDictionary *)properties wi if (blogID) { [mutableProperties setObject:blogID forKey:WPAppAnalyticsKeyBlogID]; + + NSString *siteType = [self siteTypeForBlogWithID:blogID]; + [mutableProperties setObject:siteType forKey:WPAppAnalyticsKeySiteType]; } if ([mutableProperties count] > 0) { @@ -276,6 +300,7 @@ + (void)track:(WPAnalyticsStat)stat withProperties:(NSDictionary *)properties wi mutableProperties[WPAppAnalyticsKeyPostID] = postOrPage.postID; } mutableProperties[WPAppAnalyticsKeyHasGutenbergBlocks] = @([postOrPage containsGutenbergBlocks]); + mutableProperties[WPAppAnalyticsKeyHasStoriesBlocks] = @([postOrPage containsStoriesBlocks]); [WPAppAnalytics track:stat withProperties:mutableProperties withBlog:postOrPage.blog]; } @@ -344,13 +369,13 @@ + (NSError * _Nonnull)sanitizedErrorFromError:(NSError * _Nonnull)error + (BOOL)isTrackingUsage { - return [[NSUserDefaults standardUserDefaults] boolForKey:WPAppAnalyticsDefaultsKeyUsageTracking_deprecated]; + return [[UserPersistentStoreFactory userDefaultsInstance] boolForKey:WPAppAnalyticsDefaultsKeyUsageTracking_deprecated]; } - (void)setTrackingUsage:(BOOL)trackingUsage { if (trackingUsage != [WPAppAnalytics isTrackingUsage]) { - [[NSUserDefaults standardUserDefaults] setBool:trackingUsage + [[UserPersistentStoreFactory userDefaultsInstance] setBool:trackingUsage forKey:WPAppAnalyticsDefaultsKeyUsageTracking_deprecated]; } } @@ -363,9 +388,9 @@ - (void)initializeOptOutTracking { return; } - if ([[NSUserDefaults standardUserDefaults] objectForKey:WPAppAnalyticsDefaultsKeyUsageTracking_deprecated] == nil) { + if ([[UserPersistentStoreFactory userDefaultsInstance] objectForKey:WPAppAnalyticsDefaultsKeyUsageTracking_deprecated] == nil) { [self setUserHasOptedOutValue:NO]; - } else if ([[NSUserDefaults standardUserDefaults] boolForKey:WPAppAnalyticsDefaultsKeyUsageTracking_deprecated] == NO) { + } else if ([[UserPersistentStoreFactory userDefaultsInstance] boolForKey:WPAppAnalyticsDefaultsKeyUsageTracking_deprecated] == NO) { // If the user has already explicitly disabled tracking, // then we should mirror that to the new setting [self setUserHasOptedOutValue:YES]; @@ -375,18 +400,18 @@ - (void)initializeOptOutTracking { } + (BOOL)userHasOptedOutIsSet { - return [[NSUserDefaults standardUserDefaults] objectForKey:WPAppAnalyticsDefaultsUserOptedOut] != nil; + return [[UserPersistentStoreFactory userDefaultsInstance] objectForKey:WPAppAnalyticsDefaultsUserOptedOut] != nil; } + (BOOL)userHasOptedOut { - return [[NSUserDefaults standardUserDefaults] boolForKey:WPAppAnalyticsDefaultsUserOptedOut]; + return [[UserPersistentStoreFactory userDefaultsInstance] boolForKey:WPAppAnalyticsDefaultsUserOptedOut]; } /// This method just sets the user defaults value for UserOptedOut, and doesn't /// do any additional configuration of sessions or trackers. - (void)setUserHasOptedOutValue:(BOOL)optedOut { - [[NSUserDefaults standardUserDefaults] setBool:optedOut forKey:WPAppAnalyticsDefaultsUserOptedOut]; + [[UserPersistentStoreFactory userDefaultsInstance] setBool:optedOut forKey:WPAppAnalyticsDefaultsUserOptedOut]; } - (void)setUserHasOptedOut:(BOOL)optedOut diff --git a/WordPress/Classes/Utility/Animator.swift b/WordPress/Classes/Utility/Animator.swift index 5947a38f0bc3..a6c8b8f8d0ef 100644 --- a/WordPress/Classes/Utility/Animator.swift +++ b/WordPress/Classes/Utility/Animator.swift @@ -58,7 +58,7 @@ class Animator: NSObject { preamble?() } - UIView.animate(withDuration: duration, delay: 0, options: .curveEaseOut, animations: animations) { [unowned self] _ in + UIView.animate(withDuration: duration, delay: 0, options: .curveEaseOut, animations: animations) { _ in self.animationsInProgress -= 1 if self.animationsInProgress == 0 { diff --git a/WordPress/Classes/Utility/App Configuration/AppConfiguration.swift b/WordPress/Classes/Utility/App Configuration/AppConfiguration.swift new file mode 100644 index 000000000000..450ceedb0428 --- /dev/null +++ b/WordPress/Classes/Utility/App Configuration/AppConfiguration.swift @@ -0,0 +1,26 @@ +import Foundation + +/** + * WordPress Configuration + * - Warning: + * This configuration class has a **Jetpack** counterpart in the Jetpack bundle. + * Make sure to keep them in sync to avoid build errors when building the Jetpack target. + */ +@objc class AppConfiguration: NSObject { + @objc static let isJetpack: Bool = false + @objc static let isWordPress: Bool = true + @objc static let showJetpackSitesOnly: Bool = false + @objc static let allowsNewPostShortcut: Bool = true + @objc static let allowsConnectSite: Bool = true + @objc static let allowSiteCreation: Bool = true + @objc static let allowSignUp: Bool = true + @objc static let allowsCustomAppIcons: Bool = true + @objc static let allowsDomainRegistration: Bool = false + @objc static let showsCreateButton: Bool = true + @objc static let showAddSelfHostedSiteButton: Bool = true + @objc static let showsQuickActions: Bool = true + @objc static let showsFollowedSitesSettings: Bool = true + @objc static let showsWhatIsNew: Bool = true + @objc static let showsStatsRevampV2: Bool = false + @objc static let qrLoginEnabled: Bool = false +} diff --git a/WordPress/Classes/Utility/App Configuration/AppConstants.swift b/WordPress/Classes/Utility/App Configuration/AppConstants.swift new file mode 100644 index 000000000000..b7c9bb2f5298 --- /dev/null +++ b/WordPress/Classes/Utility/App Configuration/AppConstants.swift @@ -0,0 +1,83 @@ +import Foundation +import WordPressAuthenticator + +/// - Warning: +/// This configuration class has a **Jetpack** counterpart in the Jetpack bundle. +/// Make sure to keep them in sync to avoid build errors when building the Jetpack target. +@objc class AppConstants: NSObject { + static let itunesAppID = "335703880" + static let productTwitterHandle = "@WordPressiOS" + static let productTwitterURL = "https://twitter.com/WordPressiOS" + static let productBlogURL = "https://wordpress.org/news/" + static let productBlogDisplayURL = "wordpress.org/news" + static let zendeskSourcePlatform = "mobile_-_ios" + static let shareAppName: ShareAppName = .wordpress + static let mobileAnnounceAppId = "2" + @objc static let eventNamePrefix = "wpios" + @objc static let explatPlatform = "wpios" + @objc static let authKeychainServiceName = "public-api.wordpress.com" + + /// Notifications Constants + /// + #if DEBUG + static let pushNotificationAppId = "org.wordpress.appstore.dev" + #else + #if INTERNAL_BUILD + static let pushNotificationAppId = "org.wordpress.internal" + #else + #if ALPHA_BUILD + static let pushNotificationAppId = "org.wordpress.alpha" + #else + static let pushNotificationAppId = "org.wordpress.appstore" + #endif + #endif + #endif +} + +// MARK: - Tab bar order +@objc enum WPTab: Int { + case mySites + case reader + case notifications +} + +// MARK: - Localized Strings +extension AppConstants { + + struct AboutScreen { + static let blogName = NSLocalizedString("News", comment: "Title of a button that displays the WordPress.org blog") + static let workWithUs = NSLocalizedString("Contribute", comment: "Title of button that displays the WordPress.org contributor page") + static let workWithUsURL = "https://make.wordpress.org/mobile/handbook" + } + + struct AppRatings { + static let prompt = NSLocalizedString("appRatings.wordpress.prompt", value: "What do you think about WordPress?", comment: "This is the string we display when prompting the user to review the WordPress app") + } + + struct PostSignUpInterstitial { + static let welcomeTitleText = NSLocalizedString("Welcome to WordPress", comment: "Post Signup Interstitial Title Text for WordPress iOS") + } + + struct Settings { + static let aboutTitle: String = NSLocalizedString("About WordPress", comment: "Link to About screen for WordPress for iOS") + static let shareButtonTitle = NSLocalizedString("Share WordPress with a friend", comment: "Title for a button that recommends the app to others") + static let whatIsNewTitle = NSLocalizedString("What's New in WordPress", comment: "Opens the What's New / Feature Announcement modal") + } + + struct Login { + static let continueButtonTitle = WordPressAuthenticatorDisplayStrings.defaultStrings.continueWithWPButtonTitle + } + + struct Logout { + static let alertTitle = NSLocalizedString("Log out of WordPress?", comment: "LogOut confirmation text, whenever there are no local changes") + } + + struct Zendesk { + static let ticketSubject = NSLocalizedString("WordPress for iOS Support", comment: "Subject of new Zendesk ticket.") + } + + struct QuickStart { + static let getToKnowTheAppTourTitle = NSLocalizedString("Get to know the WordPress app", + comment: "Name of the Quick Start list that guides users through a few tasks to explore the WordPress app.") + } +} diff --git a/WordPress/Classes/Utility/App Configuration/AppDependency.swift b/WordPress/Classes/Utility/App Configuration/AppDependency.swift new file mode 100644 index 000000000000..4343611b2ca4 --- /dev/null +++ b/WordPress/Classes/Utility/App Configuration/AppDependency.swift @@ -0,0 +1,17 @@ +import Foundation + +/// - Warning: +/// This configuration class has a **Jetpack** counterpart in the Jetpack bundle. +/// Make sure to keep them in sync to avoid build errors when building the Jetpack target. +@objc class AppDependency: NSObject { + static func authenticationManager(windowManager: WindowManager) -> WordPressAuthenticationManager { + return WordPressAuthenticationManager( + windowManager: windowManager, + remoteFeaturesStore: RemoteFeatureFlagStore() + ) + } + + static func windowManager(window: UIWindow) -> WindowManager { + return WindowManager(window: window) + } +} diff --git a/WordPress/Classes/Utility/App Configuration/AppStyleGuide.swift b/WordPress/Classes/Utility/App Configuration/AppStyleGuide.swift new file mode 100644 index 000000000000..8f52a4e1b806 --- /dev/null +++ b/WordPress/Classes/Utility/App Configuration/AppStyleGuide.swift @@ -0,0 +1,42 @@ +import Foundation +import WordPressShared + +/// - Warning: +/// This configuration struct has a **Jetpack** counterpart in the Jetpack bundle. +/// Make sure to keep them in sync to avoid build errors when building the Jetpack target. +struct AppStyleGuide { + static let navigationBarStandardFont: UIFont = WPStyleGuide.fixedSerifFontForTextStyle(.headline, fontWeight: .semibold) + static let navigationBarLargeFont: UIFont = WPStyleGuide.fixedSerifFontForTextStyle(.largeTitle, fontWeight: .semibold) + static let blogDetailHeaderTitleFont: UIFont = WPStyleGuide.serifFontForTextStyle(.title2, fontWeight: .semibold) + static let epilogueTitleFont: UIFont = WPStyleGuide.fixedSerifFontForTextStyle(.largeTitle, fontWeight: .semibold) +} + +// MARK: - Colors +extension AppStyleGuide { + static let accent = MurielColor(name: .pink) + static let brand = MurielColor(name: .wordPressBlue) + static let divider = MurielColor(name: .gray, shade: .shade10) + static let error = MurielColor(name: .red) + static let gray = MurielColor(name: .gray) + static let primary = MurielColor(name: .blue) + static let success = MurielColor(name: .green) + static let text = MurielColor(name: .gray, shade: .shade80) + static let textSubtle = MurielColor(name: .gray, shade: .shade50) + static let warning = MurielColor(name: .yellow) + static let jetpackGreen = MurielColor(name: .jetpackGreen) + static let editorPrimary = MurielColor(name: .blue) +} + +// MARK: - Images +extension AppStyleGuide { + static let mySiteTabIcon = UIImage(named: "icon-tab-mysites") + static let aboutAppIcon = UIImage(named: "icon-wp") + static let quickStartExistingSite = UIImage(named: "wp-illustration-quickstart-existing-site") +} + +// MARK: - Fonts +extension AppStyleGuide { + static func prominentFont(textStyle: UIFont.TextStyle, weight: UIFont.Weight) -> UIFont { + WPStyleGuide.serifFontForTextStyle(textStyle, fontWeight: weight) + } +} diff --git a/WordPress/Classes/Utility/App Configuration/AuthenticationHandler.swift b/WordPress/Classes/Utility/App Configuration/AuthenticationHandler.swift new file mode 100644 index 000000000000..f956affb991d --- /dev/null +++ b/WordPress/Classes/Utility/App Configuration/AuthenticationHandler.swift @@ -0,0 +1,14 @@ +import WordPressAuthenticator + +protocol AuthenticationHandler { + + // WPAuthenticator style overrides + var statusBarStyle: UIStatusBarStyle { get } + var prologueViewController: UIViewController? { get } + var buttonViewTopShadowImage: UIImage? { get } + var prologueButtonsBackgroundColor: UIColor? { get } + var prologueButtonsBlurEffect: UIBlurEffect? { get } + var prologueBackgroundImage: UIImage? { get } + var prologuePrimaryButtonStyle: NUXButtonStyle? { get } + var prologueSecondaryButtonStyle: NUXButtonStyle? { get } +} diff --git a/WordPress/Classes/Utility/App Configuration/ExtensionConfiguration.swift b/WordPress/Classes/Utility/App Configuration/ExtensionConfiguration.swift new file mode 100644 index 000000000000..dc1d3db5510c --- /dev/null +++ b/WordPress/Classes/Utility/App Configuration/ExtensionConfiguration.swift @@ -0,0 +1,33 @@ +// WordPress Extension configuration + +import Foundation + +/// - Warning: +/// This configuration extension has a **Jetpack** counterpart in the Jetpack bundle. +/// Make sure to keep them in sync to avoid build errors when building the Jetpack target. +@objc extension AppConfiguration { + + @objc(AppConfigurationExtension) + class Extension: NSObject { + @objc(AppConfigurationExtensionShare) + class Share: NSObject { + @objc static let keychainUsernameKey = "Username" + @objc static let keychainTokenKey = "OAuth2Token" + @objc static let keychainServiceName = "ShareExtension" + @objc static let userDefaultsPrimarySiteName = "WPShareUserDefaultsPrimarySiteName" + @objc static let userDefaultsPrimarySiteID = "WPShareUserDefaultsPrimarySiteID" + @objc static let userDefaultsLastUsedSiteName = "WPShareUserDefaultsLastUsedSiteName" + @objc static let userDefaultsLastUsedSiteID = "WPShareUserDefaultsLastUsedSiteID" + @objc static let maximumMediaDimensionKey = "WPShareExtensionMaximumMediaDimensionKey" + @objc static let recentSitesKey = "WPShareExtensionRecentSitesKey" + } + + @objc(AppConfigurationExtensionNotificationsService) + class NotificationsService: NSObject { + @objc static let keychainServiceName = "NotificationServiceExtension" + @objc static let keychainTokenKey = "OAuth2Token" + @objc static let keychainUsernameKey = "Username" + @objc static let keychainUserIDKey = "UserID" + } + } +} diff --git a/WordPress/Classes/Utility/App Configuration/WidgetConfiguration.swift b/WordPress/Classes/Utility/App Configuration/WidgetConfiguration.swift new file mode 100644 index 000000000000..486199aaa2fd --- /dev/null +++ b/WordPress/Classes/Utility/App Configuration/WidgetConfiguration.swift @@ -0,0 +1,42 @@ +// WordPress Widget configuration + +import Foundation + +/// - Warning: +/// This configuration extension has a **Jetpack** counterpart in the Jetpack bundle. +/// Make sure to keep them in sync to avoid build errors when building the Jetpack target. +@objc extension AppConfiguration { + + @objc(AppConfigurationWidget) + class Widget: NSObject { + @objc(AppConfigurationWidgetStats) + class Stats: NSObject { + @objc static let keychainTokenKey = "OAuth2Token" + @objc static let keychainServiceName = "TodayWidget" + @objc static let userDefaultsSiteIdKey = "WordPressHomeWidgetsSiteId" + @objc static let userDefaultsLoggedInKey = "WordPressHomeWidgetsLoggedIn" + @objc static let userDefaultsJetpackFeaturesDisabledKey = "WordPressJPFeaturesDisabledKey" + @objc static let todayKind = "WordPressHomeWidgetToday" + @objc static let allTimeKind = "WordPressHomeWidgetAllTime" + @objc static let thisWeekKind = "WordPressHomeWidgetThisWeek" + @objc static let todayProperties = "WordPressHomeWidgetTodayProperties" + @objc static let allTimeProperties = "WordPressHomeWidgetAllTimeProperties" + @objc static let thisWeekProperties = "WordPressHomeWidgetThisWeekProperties" + @objc static let todayFilename = "HomeWidgetTodayData.plist" + @objc static let allTimeFilename = "HomeWidgetAllTimeData.plist" + @objc static let thisWeekFilename = "HomeWidgetThisWeekData.plist" + } + + // iOS13 Stats Today Widgets + @objc(AppConfigurationWidgetStatsToday) + class StatsToday: NSObject { + @objc static let userDefaultsSiteIdKey = "WordPressTodayWidgetSiteId" + @objc static let userDefaultsSiteNameKey = "WordPressTodayWidgetSiteName" + @objc static let userDefaultsSiteUrlKey = "WordPressTodayWidgetSiteUrl" + @objc static let userDefaultsSiteTimeZoneKey = "WordPressTodayWidgetTimeZone" + @objc static let todayFilename = "TodayData.plist" + @objc static let thisWeekFilename = "ThisWeekData.plist" + @objc static let allTimeFilename = "AllTimeData.plist" + } + } +} diff --git a/WordPress/Classes/Utility/AppAppearance.swift b/WordPress/Classes/Utility/AppAppearance.swift new file mode 100644 index 000000000000..54fce4ed05d3 --- /dev/null +++ b/WordPress/Classes/Utility/AppAppearance.swift @@ -0,0 +1,84 @@ +import UIKit + +/// Encapsulates UIUserInterfaceStyle getting and setting for the app's +/// main window. Allows users to override the interface style for the app. +/// +struct AppAppearance { + /// The default interface style if not overridden + static let `default`: UIUserInterfaceStyle = .unspecified + + private static var currentWindow: UIWindow? { + return WordPressAppDelegate.shared?.window + } + + /// The current user interface style used by the app + static var current: UIUserInterfaceStyle { + return currentWindow?.overrideUserInterfaceStyle ?? .unspecified + } + + /// Overrides the app's current appeareance with the specified style. + /// If no style is provided, the app's appearance will be overridden + /// by any preference that may be currently saved in user defaults. + /// + static func overrideAppearance(with style: UIUserInterfaceStyle? = nil) { + guard let window = currentWindow else { + return + } + + if let style = style { + trackEvent(with: style) + savedStyle = style + } + + window.overrideUserInterfaceStyle = style ?? savedStyle + } + + // MARK: - Tracks + + private static func trackEvent(with style: UIUserInterfaceStyle) { + WPAnalytics.track(.appSettingsAppearanceChanged, properties: [Keys.styleTracksProperty: style.appearanceDescription]) + } + + // MARK: - Persistence + + /// Saves or gets the current interface style preference. + /// If no style has been saved, returns the default. + /// + private static var savedStyle: UIUserInterfaceStyle { + get { + guard let rawValue = UserPersistentStoreFactory.instance().object(forKey: Keys.appAppearanceDefaultsKey) as? Int, + let style = UIUserInterfaceStyle(rawValue: rawValue) else { + return AppAppearance.default + } + + return style + } + set { + UserPersistentStoreFactory.instance().set(newValue.rawValue, forKey: Keys.appAppearanceDefaultsKey) + } + } + + enum Keys { + static let styleTracksProperty = "style" + static let appAppearanceDefaultsKey = "app-appearance-override" + } +} + +extension UIUserInterfaceStyle { + var appearanceDescription: String { + switch self { + case .light: + return NSLocalizedString("Light", comment: "Title for the app appearance setting for light mode") + case .dark: + return NSLocalizedString("Dark", comment: "Title for the app appearance setting for dark mode") + case .unspecified: + return NSLocalizedString("System default", comment: "Title for the app appearance setting (light / dark mode) that uses the system default value") + @unknown default: + return "" + } + } + + static var allStyles: [UIUserInterfaceStyle] { + return [.light, .dark, .unspecified] + } +} diff --git a/WordPress/Classes/Utility/AppIcon.swift b/WordPress/Classes/Utility/AppIcon.swift new file mode 100644 index 000000000000..60ab3d48977e --- /dev/null +++ b/WordPress/Classes/Utility/AppIcon.swift @@ -0,0 +1,102 @@ +import UIKit + +/// Encapsulates a custom icon used by the app and provides some convenience +/// methods around using custom icons. +/// +struct AppIcon { + let name: String + + /// Icons with a white background require a border when displayed so their edges remain visible. + let isBordered: Bool + + /// Legacy icons are the original set of custom icons available in the app. They have been superseded + /// by a newer style of icon, but are still provided for compatibility and for users who prefer them. + let isLegacy: Bool + + // Boolean indicating whether the icon is the default icon of the app. + let isPrimary: Bool + + var displayName: String { + return name.replacingMatches(of: " Classic", with: "") + } + + var imageName: String { + let lowered = name.lowercased().replacingMatches(of: " ", with: "-") + return "\(lowered)-\(Constants.imageBaseName)" + } + + static var isUsingCustomIcon: Bool { + return UIApplication.shared.alternateIconName != nil + } + + /// The image file name of the current icon used by the app, whether custom or default. + static var currentOrDefaultIconName: String { + guard AppConfiguration.allowsCustomAppIcons else { + return iconNameFromBundle() + } + + return currentOrDefaultIcon.imageName + } + + /// An `AppIcon` instance representing the current icon used by the app, whether custom or default. + static private var currentOrDefaultIcon: AppIcon { + if let name = UIApplication.shared.alternateIconName { + return allIcons.first(where: { $0.name == name }) ?? defaultIcon + } else { + return defaultIcon + } + } + + /// An `AppIcon` instance representing the default icon for the app. + static let defaultIcon: AppIcon = { + return AppIcon(name: AppIcon.defaultIconName, + isBordered: false, + isLegacy: false, + isPrimary: true) + }() + + /// An array of `AppIcons` representing all possible custom icons that can be used by the app. + static var allIcons: [AppIcon] { + guard let bundleDict = Bundle.main.object(forInfoDictionaryKey: Constants.infoPlistBundleIconsKey) as? [String: Any], + let iconDict = bundleDict[Constants.infoPlistAlternateIconsKey] as? [String: Any] else { + return [defaultIcon] + } + + let customIcons = iconDict.compactMap { (key, value) -> AppIcon? in + guard let value = value as? [String: Any] else { + return nil + } + + let isBordered = value[Constants.infoPlistRequiresBorderKey] as? Bool == true + let isLegacy = value[Constants.infoPlistLegacyIconKey] as? Bool == true + return AppIcon(name: key, isBordered: isBordered, isLegacy: isLegacy, isPrimary: false) + } + + return [defaultIcon] + customIcons + } + + /// The app's default icon filename returned from the app's info plist. + private static func iconNameFromBundle() -> String { + guard let icons = + Bundle.main.infoDictionary?[Constants.infoPlistBundleIconsKey] as? [String: Any], + let primaryIcon = icons[Constants.infoPlistPrimaryIconKey] as? [String: Any], + let iconFiles = primaryIcon[Constants.infoPlistIconFilesKey] as? [String] else { + return "" + } + + return iconFiles.last ?? "" + } + + private enum Constants { + static let infoPlistBundleIconsKey = "CFBundleIcons" + static let infoPlistPrimaryIconKey = "CFBundlePrimaryIcon" + static let infoPlistAlternateIconsKey = "CFBundleAlternateIcons" + static let infoPlistIconFilesKey = "CFBundleIconFiles" + static let infoPlistRequiresBorderKey = "WPRequiresBorder" + static let infoPlistLegacyIconKey = "WPLegacyIcon" + static let imageBaseName = AppConfiguration.isWordPress ? "icon-app-60x60" : "icon-app-60" + } + + static let defaultIconName = AppConfiguration.isJetpack ? "Cool Green" : "Cool Blue" + static let defaultLegacyIconName = AppConfiguration.isJetpack ? nil : "WordPress" +} diff --git a/WordPress/Classes/Utility/AppLocalizedString.swift b/WordPress/Classes/Utility/AppLocalizedString.swift new file mode 100644 index 000000000000..07552675634b --- /dev/null +++ b/WordPress/Classes/Utility/AppLocalizedString.swift @@ -0,0 +1,69 @@ +import SwiftUI + + +extension Bundle { + /// Returns the `Bundle` for the host `.app`. + /// + /// - If this is called from code already located in the main app's bundle or from a Pod/Framework, + /// this will return the same as `Bundle.main`, aka the bundle of the app itself. + /// - If this is called from an App Extension (Widget, ShareExtension, etc), this will return the bundle of the + /// main app hosting said App Extension (while `Bundle.main` would return the App Extension itself) + /// + /// This is particularly useful to reference a resource or string bundled inside the app from an App Extension / Widget. + /// + /// - Note: + /// In the context of Unit Tests this will return the Test Harness (aka Test Host) app, since that is the app running said tests. + /// + static let app: Bundle = { + var url = Bundle.main.bundleURL + while url.pathExtension != "app" && url.lastPathComponent != "/" { + url.deleteLastPathComponent() + } + guard let appBundle = Bundle(url: url) else { fatalError("Unable to find the parent app bundle") } + return appBundle + }() +} + +/// Use this to express *intent* on your API that the string you are manipulating / returning is intended to already be localized +/// and its value to have been provided via a call to `NSLocalizedString` or `AppLocalizedString`. +/// +/// Semantically speaking, a method taking or returning a `LocalizedString` is signaling that you can display said UI string +/// to the end user, without the need to be treated as a key to be localized. The string is expected to already have been localized +/// at that point of the code, via a call to `NSLocalizedString`, `AppLocalizedString` or similar upstream in the code. +/// +/// - Note: Remember though that, as a `typealias`, this won't provide any compile-time guarantee. +typealias LocalizedString = String + +/// Use this function instead of `NSLocalizedString` to reference localized strings **from the app bundle** – especially +/// when using localized strings from the code of an app extension. +/// +/// You should use this `AppLocalizedString` method in place of `NSLocalizedString` especially when calling it +/// from App Extensions and Widgets, in order to reference strings whose localization live in the app bundle's `.strings` file +/// (rather than the AppExtension's own bundle). +/// +/// In order to avoid duplicating our strings accross targets, and make our localization process & tooling easier, we keep all +/// localized `.strings` in the app's bundle (and don't have a `.strings` file in the App Extension targets themselves); +/// then we make those App Extensions & Widgets reference the strings from the `Localizable.strings` files +/// hosted in the app bundle itself – which is when this helper method is helpful. +/// +/// - Note: +/// Tooling: Be sure to pass this function's name as a custom routine when parsing the code to generate the main `.strings` file, +/// using `genstrings -s AppLocalizedString`, so that this helper method is recognized. You will also have to +/// exclude this very file from being parsed by `genstrings`, so that it won't accidentally misinterpret that routine/function definition +/// below as a call site and generate an error because of it. +/// +/// - Parameters: +/// - key: An identifying value used to reference a localized string. +/// - tableName: The basename of the `.strings` file **in the app bundle** containing +/// the localized values. If `tableName` is `nil`, the `Localizable` table is used. +/// - value: The English/default copy for the string. This is the user-visible string that the +/// translators will use as original to translate, and also the string returned when the localized string for +/// `key` cannot be found in the table. If `value` is `nil` or empty, `key` would be returned instead. +/// - comment: A note to the translator describing the context where the localized string is presented to the user. +/// +/// - Returns: A localized version of the string designated by `key` in the table identified by `tableName`. +/// If the localized string for `key` cannot be found within the table, `value` is returned. +/// (However, `key` is returned instead when `value` is `nil` or the empty string). +func AppLocalizedString(_ key: String, tableName: String? = nil, value: String? = nil, comment: String) -> LocalizedString { + Bundle.app.localizedString(forKey: key, value: value, table: nil) +} diff --git a/WordPress/Classes/Utility/Automated Transfer/AutomatedTransferHelper.swift b/WordPress/Classes/Utility/Automated Transfer/AutomatedTransferHelper.swift index b398dc62da5c..d92831c12f44 100644 --- a/WordPress/Classes/Utility/Automated Transfer/AutomatedTransferHelper.swift +++ b/WordPress/Classes/Utility/Automated Transfer/AutomatedTransferHelper.swift @@ -269,9 +269,9 @@ class AutomatedTransferHelper { private func refreshSite() { DDLogInfo("[AT] Starting to refresh the site after AT process completed.") - let service = BlogService.withMainContext() + let service = BlogService(coreDataStack: ContextManager.shared) - guard let blog = service.blog(byBlogId: site.siteID as NSNumber, andUsername: site.username) else { + guard let blog = try? BlogQuery().blogID(site.siteID).dotComAccountUsername(site.username).blog(in: ContextManager.sharedInstance().mainContext) else { DDLogInfo("[AT] Couldn't find a blog with provided JetpackSiteRef. This definitely shouldn't have happened. Bailing.") SVProgressHUD.dismiss() diff --git a/WordPress/Classes/Utility/BackgroundTasks/BackgroundTasksCoordinator.swift b/WordPress/Classes/Utility/BackgroundTasks/BackgroundTasksCoordinator.swift new file mode 100644 index 000000000000..78c489a8be1f --- /dev/null +++ b/WordPress/Classes/Utility/BackgroundTasks/BackgroundTasksCoordinator.swift @@ -0,0 +1,224 @@ +import BackgroundTasks + +protocol BackgroundTask { + static var identifier: String { get } + + // MARK: - Scheduling + + /// Returns a schedule request for this task, so it can be scheduled by the coordinator. + /// + func nextRunDate() -> Date? + + /// This method allows the task to perform extra processing after scheduling the BG Task. + /// + func didSchedule(completion: @escaping (Result<Void, Error>) -> Void) + + // MARK: - Execution + + func expirationHandler() + + /// Runs the background task. + /// + /// - Parameters: + /// - osTask: the `BGTask` associated with this `BackgroundTask`. + /// - event: called for important events in the background tasks execution. + /// + func run(onError: @escaping (Error) -> Void, completion: @escaping (_ cancelled: Bool) -> Void) +} + +/// Events during the execution of background tasks. +/// +enum BackgroundTaskEvent { + case start(identifier: String) + case error(identifier: String, error: Error) + case expirationHandlerCalled(identifier: String) + case taskCompleted(identifier: String, cancelled: Bool) + case rescheduled(identifier: String) +} + +/// An event handler for background task events. +/// +protocol BackgroundTaskEventHandler { + func handle(_ event: BackgroundTaskEvent) +} + +/// The task coordinator. This is the entry point for registering and scheduling background tasks. +/// +class BackgroundTasksCoordinator { + enum SchedulingError: Error { + case schedulingFailed(tasksAndErrors: [String: Error]) + case schedulingFailed(task: String, error: Error) + } + + /// Event handler. Useful for logging or tracking purposes. + /// + private let eventHandler: BackgroundTaskEventHandler + + /// The task scheduler. It's a weak reference because the scheduler retains the coordinator through the + /// + private let scheduler: BGTaskScheduler + + /// The tasks that were registered through this coordinator on initialization. + /// + private let registeredTasks: [BackgroundTask] + + /// Default initializer. Immediately registers the task handlers with the scheduler. + /// + /// - Parameters: + /// - scheduler: The scheduler to use. + /// - tasks: The tasks that this coordinator will manage. + /// + init( + scheduler: BGTaskScheduler = BGTaskScheduler.shared, + tasks: [BackgroundTask], + eventHandler: BackgroundTaskEventHandler) { + + self.eventHandler = eventHandler + self.scheduler = scheduler + self.registeredTasks = tasks + + for task in tasks { + if FeatureFlag.weeklyRoundupBGProcessingTask.enabled { + // https://github.com/wordpress-mobile/WordPress-iOS/issues/18156 + // we still need to register to handle the old identifier = "org.wordpress.bgtask.weeklyroundup" + // in order to handle previously scheduled app refresh tasks before this enhancement. + // + // When the old identifier AppRefreshTask is triggered this will re-schedule using the new identifier going forward + // at some point in future when this FeatureFlag is removed and most users are on new version of app this can be removed + scheduler.register(forTaskWithIdentifier: WeeklyRoundupBackgroundTask.Constants.taskIdentifier, using: nil) { osTask in + self.schedule(task) { [weak self] result in + self?.taskScheduledCompleted(osTask, identifier: type(of: task).identifier, result: result, cancelled: false) + } + } + } + + scheduler.register(forTaskWithIdentifier: type(of: task).identifier, using: nil) { osTask in + guard Feature.enabled(.weeklyRoundup) && JetpackNotificationMigrationService.shared.shouldPresentNotifications() else { + osTask.setTaskCompleted(success: false) + eventHandler.handle(.taskCompleted(identifier: type(of: task).identifier, cancelled: true)) + return + } + + eventHandler.handle(.start(identifier: type(of: task).identifier)) + + osTask.expirationHandler = { + eventHandler.handle(.expirationHandlerCalled(identifier: type(of: task).identifier)) + task.expirationHandler() + } + + task.run(onError: { error in + eventHandler.handle(.error(identifier: type(of: task).identifier, error: error)) + }) { cancelled in + eventHandler.handle(.taskCompleted(identifier: type(of: task).identifier, cancelled: cancelled)) + + if FeatureFlag.weeklyRoundupBGProcessingTask.enabled { + self.schedule(task) { [weak self] result in + self?.taskScheduledCompleted(osTask, identifier: type(of: task).identifier, result: result, cancelled: cancelled) + } + } else { + //TODO - remove after removing feature flag + self.schedule(task) { result in + switch result { + case .success: + eventHandler.handle(.rescheduled(identifier: type(of: task).identifier)) + case .failure(let error): + eventHandler.handle(.error(identifier: type(of: task).identifier, error: error)) + } + + osTask.setTaskCompleted(success: !cancelled) + } + } + } + } + } + } + + func taskScheduledCompleted(_ osTask: BGTask, identifier: String, result: Result<Void, Error>, cancelled: Bool) { + switch result { + case .success: + eventHandler.handle(.rescheduled(identifier: identifier)) + case .failure(let error): + eventHandler.handle(.error(identifier: identifier, error: error)) + } + + osTask.setTaskCompleted(success: !cancelled) + } + + /// Schedules the registered tasks. The reason this step is separated from the registration of the tasks, is that we need + /// to make sure the task registration completes before the App finishes launching, while scheduling can be taken care + /// of separately. + /// + /// Ref: https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler + /// + func scheduleTasks(completion: @escaping (Result<Void, Error>) -> Void) { + var tasksAndErrors = [String: Error]() + + scheduler.getPendingTaskRequests { [weak self] scheduledRequests in + guard let self = self else { + return + } + + let tasksToSchedule = self.registeredTasks.filter { task in + !scheduledRequests.contains { request in + request.identifier == type(of: task).identifier + } + } + + for task in tasksToSchedule { + self.schedule(task) { result in + if case .failure(let error) = result { + tasksAndErrors[type(of: task).identifier] = error + } + } + } + + if tasksAndErrors.isEmpty { + completion(.success(())) + } else { + completion(.failure(SchedulingError.schedulingFailed(tasksAndErrors: tasksAndErrors))) + } + } + } + + func schedule(_ task: BackgroundTask, completion: @escaping (Result<Void, Error>) -> Void) { + guard let nextDate = task.nextRunDate() else { + return + } + + let request = createBGTaskRequest(task, beginDate: nextDate) + + do { + try self.scheduler.submit(request) + task.didSchedule(completion: completion) + } catch { + completion(.failure(SchedulingError.schedulingFailed(task: type(of: task).identifier, error: error))) + } + } + + func createBGTaskRequest(_ task: BackgroundTask, beginDate: Date) -> BGTaskRequest { + if FeatureFlag.weeklyRoundupBGProcessingTask.enabled { + let bgProcessingTaskRequest = BGProcessingTaskRequest(identifier: type(of: task).identifier) + bgProcessingTaskRequest.requiresNetworkConnectivity = true + bgProcessingTaskRequest.earliestBeginDate = beginDate + return bgProcessingTaskRequest + } + + let appRefreshTaskRequest = BGAppRefreshTaskRequest(identifier: type(of: task).identifier) + appRefreshTaskRequest.earliestBeginDate = beginDate + return appRefreshTaskRequest + } + + + // MARK: - Querying Data + + func getScheduledExecutionDate(taskIdentifier: String, completion: @escaping (Date?) -> Void) { + scheduler.getPendingTaskRequests { requests in + guard let weeklyRoundupRequest = requests.first(where: { $0.identifier == taskIdentifier }) else { + completion(nil) + return + } + + return completion(weeklyRoundupRequest.earliestBeginDate) + } + } +} diff --git a/WordPress/Classes/Utility/BackgroundTasks/WeeklyRoundupBackgroundTask.swift b/WordPress/Classes/Utility/BackgroundTasks/WeeklyRoundupBackgroundTask.swift new file mode 100644 index 000000000000..0046ed8ffcc3 --- /dev/null +++ b/WordPress/Classes/Utility/BackgroundTasks/WeeklyRoundupBackgroundTask.swift @@ -0,0 +1,700 @@ +import Foundation +import CoreData + +/// The main data provider for Weekly Roundup information. +/// +private class WeeklyRoundupDataProvider { + + // MARK: - Definitions + + typealias SiteStats = [Blog: StatsSummaryData] + + enum DataRequestError: Error { + case dotComSiteWithoutDotComID(_ site: Blog) + case siteFetchingError(_ error: Error) + case unknownErrorRetrievingStats(_ site: Blog) + case errorRetrievingStats(_ blogID: Int, error: Error) + case filterWeeklyRoundupEnabledSitesError(_ error: NSError?) + } + + // MARK: - Misc Properties + + private let coreDataStack: CoreDataStack + + /// Method to report errors that won't interrupt the execution. + /// + private let onError: (Error) -> Void + + /// Debug settings configured through the App's debug menu. + /// + private let debugSettings = WeeklyRoundupDebugScreen.Settings() + + init(coreDataStack: CoreDataStack, onError: @escaping (Error) -> Void) { + self.coreDataStack = coreDataStack + self.onError = onError + } + + func getTopSiteStats(completion: @escaping (Result<SiteStats?, Error>) -> Void) { + getSites() { [weak self] sitesResult in + guard let self = self else { + return + } + + switch sitesResult { + case .success(let sites): + guard sites.count > 0 else { + completion(.success(nil)) + return + } + + self.getTopSiteStats(from: sites, completion: completion) + case .failure(let error): + completion(.failure(error)) + return + } + } + } + + private func getTopSiteStats(from sites: [Blog], completion: @escaping (Result<SiteStats?, Error>) -> Void) { + var endDateComponents = DateComponents() + endDateComponents.weekday = 1 + + // The DateComponents timezone is ignored when calling `Calendar.current.nextDate(...)`, so we need to + // create a GMT Calendar to perform the date search using it, instead. + var gmtCalendar = Calendar(identifier: .gregorian) + gmtCalendar.timeZone = TimeZone(secondsFromGMT: 0)! + + guard let periodEndDate = gmtCalendar.nextDate(after: Date(), matching: endDateComponents, matchingPolicy: .nextTime, direction: .backward) else { + DDLogError("Something's wrong with the preiod end date selection.") + return + } + + var blogStats = [Blog: StatsSummaryData]() + var statsProcessed = 0 + + for site in sites { + guard let authToken = site.account?.authToken else { + continue + } + + let wpApi = WordPressComRestApi.defaultApi(oAuthToken: authToken, userAgent: WPUserAgent.wordPress()) + + guard let dotComID = site.dotComID?.intValue else { + onError(DataRequestError.dotComSiteWithoutDotComID(site)) + continue + } + + let statsServiceRemote = StatsServiceRemoteV2(wordPressComRestApi: wpApi, siteID: dotComID, siteTimezone: site.timeZone) + + statsServiceRemote.getData(for: .week, endingOn: periodEndDate, limit: 1) { (timeStats: StatsSummaryTimeIntervalData?, error) in + defer { + statsProcessed = statsProcessed + 1 + + if statsProcessed == sites.count { + let bestBlogStats = self.filterBest(5, from: blogStats) + + completion(.success(bestBlogStats)) + } + } + + guard let timeStats = timeStats else { + guard let error = error else { + self.onError(DataRequestError.unknownErrorRetrievingStats(site)) + return + } + + self.onError(DataRequestError.errorRetrievingStats(dotComID, error: error)) + return + } + + guard let stats = timeStats.summaryData.first else { + // No stats for this site, or not enough views to qualify. This is not an error. + return + } + + blogStats[site] = stats + } + } + } + + /// Filters the "best" count sites from the provided dictionary of sites and stats. This method implicitly implements the + /// definition of "best" through a sorting mechanism where the "best" sites are placed first. + /// + private func filterBest(_ count: Int, minimumViewsCount: Int = 5, from blogStats: SiteStats) -> SiteStats { + let filteredAndSorted = blogStats.filter { (site, stats) in + stats.viewsCount >= minimumViewsCount + }.sorted { (first: (_, value: StatsSummaryData), second: (_, value: StatsSummaryData)) in + first.value.viewsCount >= second.value.viewsCount + } + + return filteredAndSorted + .dropLast(filteredAndSorted.count > count ? filteredAndSorted.count - count : 0) + .reduce(into: [:]) { $0[$1.key] = $1.value } + } + + /// Retrieves the sites considered by Weekly Roundup for reporting. + /// + /// - Returns: the requested sites (could be an empty array if there's none) or an error if there is one. + /// + private func getSites(result: @escaping (Result<[Blog], Error>) -> Void) { + + switch getAllSites() { + case .success(let sites): + filterCandidateSites(sites, result: result) + case .failure(let error): + result(.failure(error)) + } + } + + /// Filters the candidate sites for the Weekly Roundup notification + /// + private func filterCandidateSites(_ sites: [Blog], result: @escaping (Result<[Blog], Error>) -> Void) { + let administeredSites = sites.filter { site in + site.isAdmin && ((FeatureFlag.debugMenu.enabled && debugSettings.isEnabledForA8cP2s) || !site.isAutomatticP2) + } + + guard administeredSites.count > 0 else { + result(.success([])) + return + } + + filterWeeklyRoundupEnabledSites(administeredSites, result: result) + } + + /// 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(coreDataStack: coreDataStack) + + noteService.getAllSettings { settings in + let weeklyRoundupEnabledSites = sites.filter { site in + guard let siteSettings = settings.first(where: { $0.blog == site }), + let pushNotificationsStream = siteSettings.streams.first(where: { $0.kind == .Device }), + let sitePreferences = pushNotificationsStream.preferences else { + return false + } + + return sitePreferences["weekly_roundup"] ?? true + } + + result(.success(weeklyRoundupEnabledSites)) + } failure: { (error: NSError?) in + let error = DataRequestError.filterWeeklyRoundupEnabledSitesError(error) + result(.failure(error)) + } + } + + private func getAllSites() -> Result<[Blog], Error> { + let request = NSFetchRequest<Blog>(entityName: NSStringFromClass(Blog.self)) + + request.sortDescriptors = [ + NSSortDescriptor(key: "accountForDefaultBlog.userID", ascending: false), + NSSortDescriptor(key: "settings.name", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare(_:))) + ] + + do { + let result = try coreDataStack.mainContext.fetch(request) + return .success(result) + } catch { + return .failure(DataRequestError.siteFetchingError(error)) + } + } +} + +class WeeklyRoundupBackgroundTask: BackgroundTask { + + // MARK: - Store + + class Store { + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + // Mark - User Defaults Storage + + private let lastRunDateKey = "weeklyRoundup.lastExecutionDate" + + func getLastRunDate() -> Date? { + UserPersistentStoreFactory.instance().object(forKey: lastRunDateKey) as? Date + } + + func setLastRunDate(_ date: Date) { + UserPersistentStoreFactory.instance().set(date, forKey: lastRunDateKey) + } + } + + // MARK: - Misc Properties + + static var identifier: String { + if FeatureFlag.weeklyRoundupBGProcessingTask.enabled { + return Constants.taskIdentifierProcessing + } + + return Constants.taskIdentifier + } + static private let secondsPerDay = 24 * 60 * 60 + + private let store: Store + + private let operationQueue: OperationQueue = { + let queue = OperationQueue() + queue.maxConcurrentOperationCount = 1 + return queue + }() + + enum RunError: Error { + case staticNotificationAlreadyDelivered + } + + private let eventTracker: NotificationEventTracker + let runDateComponents: DateComponents + let notificationScheduler: WeeklyRoundupNotificationScheduler + + init( + eventTracker: NotificationEventTracker = NotificationEventTracker(), + runDateComponents: DateComponents? = nil, + staticNotificationDateComponents: DateComponents? = nil, + store: Store = Store()) { + + self.eventTracker = eventTracker + notificationScheduler = WeeklyRoundupNotificationScheduler(staticNotificationDateComponents: staticNotificationDateComponents) + self.store = store + + self.runDateComponents = runDateComponents ?? { + var dateComponents = DateComponents() + + dateComponents.calendar = Calendar.current + + // `DateComponent`'s weekday uses a 1-based index. + dateComponents.weekday = 2 + dateComponents.hour = 10 + + return dateComponents + }() + } + + /// Just a convenience method to know then this task is run, what run date to use as "current". + /// + private func currentRunPeriodEndDate() -> Date { + let runDate = Calendar.current.nextDate( + after: Date(), + matching: runDateComponents, + matchingPolicy: .nextTime, + direction: .backward) ?? Date() + + // The run date is when the task is scheduled to run, but the period end date is actually + // the previous day at 24:59:59. + let periodEndDate = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: runDate)!.addingTimeInterval(TimeInterval.init(-1)) + + return periodEndDate + } + + private func secondsInDays(_ numberOfDays: Int) -> Int { + numberOfDays * Self.secondsPerDay + } + + /// This method checks if we skipped a Weekly Roundup run, and if we're within 2 days of that skipped Weekly Roundup run date. + /// If all is true, it returns the date of the last skipped Weekly Roundup. + /// + /// If Weekly Roundup has never been run this will always return `nil` as we haven't skipped any date. + /// + /// - Returns: the date of the last skipped Weekly Roundup, or `nil` if the conditions aren't met. + /// + private func skippedWeeklyRoundupDate() -> Date? { + let today = Date() + + if let lastRunDate = store.getLastRunDate(), + Int(today.timeIntervalSinceReferenceDate - lastRunDate.timeIntervalSinceReferenceDate) > secondsInDays(6), + let lastValidDate = Calendar.current.nextDate( + after: Date(), + matching: runDateComponents, + matchingPolicy: .nextTime, + direction: .backward), + lastValidDate > lastRunDate, + Int(today.timeIntervalSinceReferenceDate - lastValidDate.timeIntervalSinceReferenceDate) <= secondsInDays(2) { + + return lastValidDate + } + + return nil + } + + func nextRunDate() -> Date? { + // If we're within 2 days of a skipped Weekly Roundup date, we can show it. + if let skippedRunDate = skippedWeeklyRoundupDate() { + return skippedRunDate + } + + return Calendar.current.nextDate( + after: Date(), + matching: runDateComponents, + matchingPolicy: .nextTime) + } + + func didSchedule(completion: @escaping (Result<Void, Error>) -> Void) { + if Feature.enabled(.weeklyRoundupStaticNotification) { + // We're scheduling a static notification in case the BG task won't run. + // This will happen when the App has been explicitly killed by the user as of 2021/08/03, + // as Apple doesn't let background tasks run in this scenario. + notificationScheduler.scheduleStaticNotification(completion: completion) + } + + completion(.success(())) + } + + func expirationHandler() { + cancelExecution() + } + + private func cancelExecution() { + operationQueue.cancelAllOperations() + } + + // MARK: - Running the Background Task + + func run(onError: @escaping (Error) -> Void, completion: @escaping (Bool) -> Void) { + + // This will no longer run for WordPress as part of Jetpack migration. + // This can be removed once Jetpack migration is complete. + guard JetpackNotificationMigrationService.shared.shouldPresentNotifications() else { + notificationScheduler.cancellAll() + notificationScheduler.cancelStaticNotification() + return + } + + // We use multiple operations in series so that if the expiration handler is + // called, the operation queue will cancell any pending operations, ensuring + // that the task will exit as soon as possible. + + let cancelStaticNotification = BlockOperation { + if Feature.enabled(.weeklyRoundupStaticNotification) { + let group = DispatchGroup() + group.enter() + + self.notificationScheduler.cancelStaticNotification { cancelled in + defer { + group.leave() + } + + guard cancelled else { + onError(RunError.staticNotificationAlreadyDelivered) + self.operationQueue.cancelAllOperations() + return + } + } + + group.wait() + } + } + + let dataProvider = WeeklyRoundupDataProvider(coreDataStack: ContextManager.shared, onError: onError) + var siteStats: [Blog: StatsSummaryData]? = nil + + let requestData = BlockOperation { + let group = DispatchGroup() + group.enter() + + dataProvider.getTopSiteStats { result in + defer { + group.leave() + } + + switch result { + case .success(let topSiteStats): + guard let topSiteStats = topSiteStats else { + self.cancelExecution() + return + } + + siteStats = topSiteStats + case .failure(let error): + onError(error) + self.cancelExecution() + } + } + + group.wait() + } + + let scheduleNotification = BlockOperation { + let group = DispatchGroup() + + guard let siteStats = siteStats else { + self.cancelExecution() + return + } + + for (site, stats) in siteStats { + group.enter() + + self.notificationScheduler.scheduleDynamicNotification( + site: site, + views: stats.viewsCount, + comments: stats.commentsCount, + likes: stats.likesCount, + periodEndDate: self.currentRunPeriodEndDate() + ) { result in + + switch result { + case .success: + self.eventTracker.notificationScheduled(type: .weeklyRoundup, siteId: site.dotComID?.intValue) + case .failure(let error): + onError(error) + } + + group.leave() + } + } + + group.wait() + } + + // no-op: the reason we're adding this block is to get the completion handler below. + // This closure may not be executed if the task is cancelled (through the operation queue) + // but the completion closure below should always be called regardless. + let completionOperation = BlockOperation {} + + completionOperation.completionBlock = { + self.store.setLastRunDate(Date()) + completion(completionOperation.isCancelled) + } + + operationQueue.addOperation(cancelStaticNotification) + operationQueue.addOperation(requestData) + operationQueue.addOperation(scheduleNotification) + operationQueue.addOperation(completionOperation) + } + + enum Constants { + static let taskIdentifier = "org.wordpress.bgtask.weeklyroundup" + static let taskIdentifierProcessing = "org.wordpress.bgtask.weeklyroundup.processing" + } +} + +class WeeklyRoundupNotificationScheduler { + + // MARK: - Identifiers + + static let notificationIdentifier = "org.wordpress.notification.identifier.weeklyRoundup" + static let threadIdentifier = "org.wordpress.notification.threadIdentifier.weeklyRoundup" + + private lazy var staticNotificationIdentifier: String = { + "\(Self.notificationIdentifier).static" + }() + + func dynamicNotificationIdentifier(for blogID: Int) -> String { + "\(Self.notificationIdentifier).\(blogID)" + } + + // MARK: - Initialization + + init( + staticNotificationDateComponents: DateComponents? = nil, + userNotificationCenter: UNUserNotificationCenter = UNUserNotificationCenter.current()) { + + self.userNotificationCenter = userNotificationCenter + + self.staticNotificationDateComponents = staticNotificationDateComponents ?? { + var dateComponents = DateComponents() + + dateComponents.calendar = Calendar.current + + // `DateComponent`'s weekday uses a 1-based index. + dateComponents.weekday = 2 + dateComponents.hour = 18 + + return dateComponents + }() + } + + // MARK: - Scheduling Notifications + + let staticNotificationDateComponents: DateComponents + let userNotificationCenter: UNUserNotificationCenter + + enum NotificationSchedulingError: Error { + case staticNotificationSchedulingError(error: Error) + case dynamicNotificationSchedulingError(error: Error) + } + + func scheduleStaticNotification(completion: @escaping (Result<Void, Error>) -> Void) { + let title = TextContent.staticNotificationTitle + let body = TextContent.staticNotificationBody + + scheduleNotification( + identifier: staticNotificationIdentifier, + title: title, + body: body, + dateComponents: staticNotificationDateComponents) { result in + + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(NotificationSchedulingError.staticNotificationSchedulingError(error: error))) + } + } + } + + func scheduleDynamicNotification( + site: Blog, + views: Int, + comments: Int, + likes: Int, + periodEndDate: Date, + completion: @escaping (Result<Void, Error>) -> Void + ) { + var siteTitle: String? + var dotComID: Int? + + site.managedObjectContext?.performAndWait { + siteTitle = site.title + dotComID = site.dotComID?.intValue + } + + guard let dotComID = dotComID else { + fatalError("The argument site is not a WordPress.com site. Site: \(site)") + } + + let title = notificationTitle(siteTitle) + let body = notificationBodyWith(views: views, comments: likes, likes: comments) + + // The dynamic notification date is defined by when the background task is run. + // Since these lines of code execute when the BG Task is run, we can just schedule + // the dynamic notification after a few seconds. + let date = Date(timeIntervalSinceNow: 10) + let calendar = Calendar.current + let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: date) + + let identifier = dynamicNotificationIdentifier(for: dotComID) + let userInfo: [AnyHashable: Any] = [ + InteractiveNotificationsManager.blogIDKey: dotComID, + InteractiveNotificationsManager.dateKey: periodEndDate, + PushNotificationsManager.Notification.typeKey: NotificationEventTracker.NotificationType.weeklyRoundup.rawValue + ] + + scheduleNotification( + identifier: identifier, + title: title, + body: body, + userInfo: userInfo, + dateComponents: dateComponents) { result in + + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(NotificationSchedulingError.dynamicNotificationSchedulingError(error: error))) + } + } + } + + func notificationBodyWith(views: Int, comments: Int, likes: Int) -> String { + var body = "" + let hideLikesCount = likes <= 0 + let hideCommentsCount = comments <= 0 + + switch (hideLikesCount, hideCommentsCount) { + case (true, true): + body = String(format: TextContent.dynamicNotificationBodyViewsOnly, views.abbreviatedString()) + case (false, true): + body = String(format: TextContent.dynamicNotificationBodyViewsAndLikes, views.abbreviatedString(), likes.abbreviatedString()) + case (true, false): + body = String(format: TextContent.dynamicNotificationBodyViewsAndComments, views.abbreviatedString(), comments.abbreviatedString()) + default: + body = String(format: TextContent.dynamicNotificationBodyAll, views.abbreviatedString(), comments.abbreviatedString(), likes.abbreviatedString()) + } + + return body + } + + private func scheduleNotification( + identifier: String, + title: String, + body: String, + userInfo: [AnyHashable: Any] = [:], + dateComponents: DateComponents, + completion: @escaping (Result<Void, Error>) -> Void) { + + guard JetpackNotificationMigrationService.shared.shouldPresentNotifications() else { + return + } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.categoryIdentifier = InteractiveNotificationsManager.NoteCategoryDefinition.weeklyRoundup.rawValue + + // We want to make sure all weekly roundup notifications are grouped together. + content.threadIdentifier = Self.threadIdentifier + content.userInfo = userInfo + + let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + userNotificationCenter.add(request) { error in + if let error = error { + completion(.failure(error)) + return + } + + completion(.success(())) + } + } + + // MARK: - Cancelling Notifications + + /// Useful for cancelling all of the Weekly Roundup notifications. + /// + func cancellAll() { + userNotificationCenter.getPendingNotificationRequests { requests in + let notifications = requests.filter({ $0.content.threadIdentifier == Self.threadIdentifier }) + + guard notifications.count > 0 else { + return + } + + self.userNotificationCenter.removePendingNotificationRequests(withIdentifiers: notifications.map({ $0.identifier })) + } + } + + func cancelStaticNotification(completion: @escaping (Bool) -> Void = { _ in }) { + userNotificationCenter.getPendingNotificationRequests { requests in + if Feature.enabled(.weeklyRoundupStaticNotification) { + guard requests.contains( where: { $0.identifier == self.staticNotificationIdentifier }) else { + // The reason why we're cancelling the background task if there's no static notification scheduled is because + // it means we've already shown the static notification to the user. Since iOS doesn't ensure an execution time + // for background tasks, we assume this is the case where the static notification was shown before the dynamic + // task was run. + completion(false) + return + } + + self.userNotificationCenter.removePendingNotificationRequests(withIdentifiers: [self.staticNotificationIdentifier]) + } + + completion(true) + } + } + + func notificationTitle(_ siteTitle: String?) -> String { + if let siteTitle = siteTitle { + return String(format: TextContent.dynamicNotificationTitle, siteTitle) + } else { + return TextContent.staticNotificationTitle + } + } + + enum TextContent { + static let staticNotificationTitle = NSLocalizedString("Weekly Roundup", comment: "Title of Weekly Roundup push notification") + static let dynamicNotificationTitle = NSLocalizedString("Weekly Roundup: %@", comment: "Title of Weekly Roundup push notification. %@ is a placeholder and will be replaced with the title of one of the user's websites.") + static let staticNotificationBody = NSLocalizedString("Your weekly roundup is ready, tap here to see the details!", comment: "Prompt displayed as part of the stats Weekly Roundup push notification.") + static let dynamicNotificationBodyViewsOnly = NSLocalizedString("Last week you had %@ views.", comment: "Content of a weekly roundup push notification containing stats about the user's site. The % marker is a placeholder and will be replaced by the appropriate number of views") + static let dynamicNotificationBodyViewsAndLikes = NSLocalizedString("Last week you had %1$@ views and %2$@ likes.", comment: "Content of a weekly roundup push notification containing stats about the user's site. The % markers are placeholders and will be replaced by the appropriate number of views and likes. The numbers indicate the order, so they can be rearranged if necessary – 1 is views, 2 is likes.") + static let dynamicNotificationBodyViewsAndComments = NSLocalizedString("Last week you had %1$@ views and %2$@ comments.", comment: "Content of a weekly roundup push notification containing stats about the user's site. The % markers are placeholders and will be replaced by the appropriate number of views and comments. The numbers indicate the order, so they can be rearranged if necessary – 1 is views, 2 is comments.") + static let dynamicNotificationBodyAll = NSLocalizedString("Last week you had %1$@ views, %2$@ comments and %3$@ likes.", comment: "Content of a weekly roundup push notification containing stats about the user's site. The % markers are placeholders and will be replaced by the appropriate number of views, comments, and likes. The numbers indicate the order, so they can be rearranged if necessary – 1 is views, 2 is comments, 3 is likes.") + } +} diff --git a/WordPress/Classes/Utility/BackgroundTasks/WordPressBackgroundTaskEventHandler.swift b/WordPress/Classes/Utility/BackgroundTasks/WordPressBackgroundTaskEventHandler.swift new file mode 100644 index 000000000000..ded451f9e957 --- /dev/null +++ b/WordPress/Classes/Utility/BackgroundTasks/WordPressBackgroundTaskEventHandler.swift @@ -0,0 +1,21 @@ +import Foundation + +/// WordPress event handler for background task events. +/// This class knows specifically about how WordPress wants to log and track these events. +/// +class WordPressBackgroundTaskEventHandler: BackgroundTaskEventHandler { + func handle(_ event: BackgroundTaskEvent) { + switch event { + case .start(let identifier): + DDLogInfo("Background task started: \(identifier)") + case .error(let identifier, let error): + DDLogError("Background task error: \(identifier) - Error: \(error)") + case .expirationHandlerCalled(let identifier): + DDLogError("Background task time expired: \(identifier)") + case .taskCompleted(let identifier, let cancelled): + DDLogInfo("Background task completed: \(identifier) - Cancelled: \(cancelled)") + case .rescheduled(let identifier): + DDLogInfo("Background task rescheduled: \(identifier)") + } + } +} diff --git a/WordPress/Classes/Utility/BlogQuery.swift b/WordPress/Classes/Utility/BlogQuery.swift new file mode 100644 index 000000000000..eddb42bfe191 --- /dev/null +++ b/WordPress/Classes/Utility/BlogQuery.swift @@ -0,0 +1,78 @@ +import Foundation + +/// A helper to query `Blog` from given `NSManagedObjectContext`. +/// +/// Note: the implementation here isn't meant to be a standard way to perform query. But it might be valuable +/// to explore a standard way to perform query. https://github.com/wordpress-mobile/WordPress-iOS/pull/19394 made +/// an attempt, but still has lots of unknowns. +struct BlogQuery { + private var predicates = [NSPredicate]() + + func blogID(_ id: Int) -> Self { + blogID(Int64(id)) + } + + func blogID(_ id: NSNumber) -> Self { + blogID(id.int64Value) + } + + func blogID(_ id: Int64) -> Self { + and(NSPredicate(format: "blogID = %ld", id)) + } + + func dotComAccountUsername(_ username: String) -> Self { + and(NSPredicate(format: "account.username = %@", username)) + } + + func selfHostedBlogUsername(_ username: String) -> Self { + and(NSPredicate(format: "username = %@", username)) + } + + func hostname(containing hostname: String) -> Self { + and(NSPredicate(format: "url CONTAINS %@", hostname)) + } + + func hostname(matching hostname: String) -> Self { + and(NSPredicate(format: "url = %@", hostname)) + } + + func visible(_ flag: Bool) -> Self { + and(NSPredicate(format: "visible = %@", NSNumber(booleanLiteral: flag))) + } + + func hostedByWPCom(_ flag: Bool) -> Self { + and(NSPredicate(format: flag ? "account != NULL" : "account == NULL")) + } + + func xmlrpc(matching xmlrpc: String) -> Self { + and(NSPredicate(format: "xmlrpc = %@", xmlrpc)) + } + + func count(in context: NSManagedObjectContext) -> Int { + (try? context.count(for: buildFetchRequest())) ?? 0 + } + + func blog(in context: NSManagedObjectContext) throws -> Blog? { + let request = buildFetchRequest() + request.fetchLimit = 1 + return (try context.fetch(request).first) + } + + func blogs(in context: NSManagedObjectContext) throws -> [Blog] { + try context.fetch(buildFetchRequest()) + } + + private func buildFetchRequest() -> NSFetchRequest<Blog> { + let request = NSFetchRequest<Blog>(entityName: Blog.entityName()) + request.includesSubentities = false + request.sortDescriptors = [NSSortDescriptor(key: "settings.name", ascending: true)] + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + return request + } + + private func and(_ predicate: NSPredicate) -> Self { + var query = self + query.predicates.append(predicate) + return query + } +} diff --git a/WordPress/Classes/Utility/Blogging Prompts/PromptRemindersScheduler.swift b/WordPress/Classes/Utility/Blogging Prompts/PromptRemindersScheduler.swift new file mode 100644 index 000000000000..45b94b6d624e --- /dev/null +++ b/WordPress/Classes/Utility/Blogging Prompts/PromptRemindersScheduler.swift @@ -0,0 +1,497 @@ +import Foundation +import UserNotifications + +/// Encapsulates the local notification scheduling logic for Blogging Prompts. +/// +class PromptRemindersScheduler { + enum Errors: Error { + case invalidSite + case fileSaveError + case unknown + } + + private let promptsServiceFactory: BloggingPromptsServiceFactory + private let notificationScheduler: NotificationScheduler + private let pushAuthorizer: PushNotificationAuthorizer + private let currentDateProvider: CurrentDateProvider + private let localStore: LocalFileStore + + private static var gmtTimeZone = TimeZone(secondsFromGMT: 0) + + // MARK: Public Methods + + init(bloggingPromptsServiceFactory: BloggingPromptsServiceFactory = .init(), + notificationScheduler: NotificationScheduler = UNUserNotificationCenter.current(), + pushAuthorizer: PushNotificationAuthorizer = InteractiveNotificationsManager.shared, + localStore: LocalFileStore = FileManager.default, + currentDateProvider: CurrentDateProvider = DefaultCurrentDateProvider()) { + self.promptsServiceFactory = bloggingPromptsServiceFactory + self.notificationScheduler = notificationScheduler + self.pushAuthorizer = pushAuthorizer + self.localStore = localStore + self.currentDateProvider = currentDateProvider + } + + /// Schedule local notifications that will show prompts on selected weekdays based on the given `Schedule`. + /// Prompt notifications will be bulk-scheduled for 2 weeks ahead, followed with static notifications for 2 weeks. + /// + /// Note: Calling this method will trigger the push notification authorization flow. + /// + /// - Parameters: + /// - schedule: The preferred notification schedule. + /// - blog: The blog that will upload the user's post. + /// - time: The user's preferred time to be notified. + /// - completion: Closure called after the process completes. + func schedule(_ schedule: BloggingRemindersScheduler.Schedule, for blog: Blog, time: Date? = nil, completion: @escaping (Result<Void, Error>) -> Void) { + guard schedule != .none else { + // If there's no schedule, then we don't need to request authorization + processSchedule(schedule, blog: blog, time: time, completion: completion) + return + } + + pushAuthorizer.requestAuthorization { [weak self] allowed in + guard let self = self else { + return + } + + guard allowed else { + completion(.failure(BloggingRemindersScheduler.Error.needsPermissionForPushNotifications)) + return + } + + self.processSchedule(schedule, blog: blog, time: time, completion: completion) + } + } + + /// Removes all pending notifications for a given `siteID`. + /// + /// - Parameter blog: The blog that will have its pending reminder notifications cleared. + func unschedule(for blog: Blog) { + guard let siteID = blog.dotComID?.intValue, + let receiptsForSite = fetchReceipts(for: siteID), + !receiptsForSite.isEmpty else { + return + } + + notificationScheduler.removePendingNotificationRequests(withIdentifiers: receiptsForSite) + try? deleteReceipts(for: siteID) + } +} + +// MARK: - Private Helpers + +private extension PromptRemindersScheduler { + typealias Schedule = BloggingRemindersScheduler.Schedule + typealias Weekday = BloggingRemindersScheduler.Weekday + + /// A simple structure representing hour and minute. + struct Time { + let hour: Int + let minute: Int + + init(hour: Int, minute: Int) { + self.hour = hour + self.minute = minute + } + + init?(from date: Date?) { + guard let dateComponents = date?.dateAndTimeComponents(), + let hourComponent = dateComponents.hour, + let minuteComponent = dateComponents.minute else { + return nil + } + + self.init(hour: hourComponent, minute: minuteComponent) + } + + func compare(with date: Date) -> ComparisonResult { + let hourToCompare = Calendar.current.component(.hour, from: date) + let minuteToCompare = Calendar.current.component(.minute, from: date) + + if hour == hourToCompare { + return NSNumber(value: minute).compare(NSNumber(value: minuteToCompare)) + } + + return NSNumber(value: hour).compare(NSNumber(value: hourToCompare)) + } + } + + enum Constants { + static let defaultTime = Time(hour: 10, minute: 0) // 10:00 AM + static let promptsToFetch = 15 // fetch prompts for today + two weeks ahead + static let staticNotificationMaxDays = 14 // schedule static notifications up to two weeks ahead + static let notificationTitle = NSLocalizedString("Today's Prompt 💡", comment: "Title for a push notification showing today's blogging prompt.") + static let staticNotificationContent = NSLocalizedString("Tap to load today's prompt...", comment: "Title for a push notification with fixed content" + + " that invites the user to load today's blogging prompt.") + static let defaultFileName = "PromptReminders.plist" + } + + /// The actual implementation for the prompt notification scheduling. + /// This method should only be called after push notifications have been authorized. + /// + /// - Parameters: + /// - schedule: The preferred notification schedule. + /// - blog: The blog that will upload the user's post. + /// - time: The user's preferred time to be notified. + /// - completion: Closure called after the process completes. + func processSchedule(_ schedule: Schedule, blog: Blog, time: Date? = nil, completion: @escaping(Result<Void, Error>) -> Void) { + // always reset pending notifications. + unschedule(for: blog) + + guard case .weekdays(let weekdays) = schedule else { + completion(.success(())) + return + } + + guard let siteID = blog.dotComID?.intValue, + let promptsService = promptsServiceFactory.makeService(for: blog) else { + completion(.failure(Errors.invalidSite)) + return + } + + let reminderTime = Time(from: time) ?? Constants.defaultTime + let currentDate = currentDateProvider.date() + promptsService.fetchPrompts(from: currentDate, number: Constants.promptsToFetch) { [weak self] prompts in + guard let self = self else { + completion(.failure(Errors.unknown)) + return + } + + // Step 1: Filter prompts based on the Schedule. + let promptsToSchedule = prompts.sorted { $0.date < $1.date }.filter { prompt in + guard let gmtTimeZone = Self.gmtTimeZone, + let weekdayComponent = Calendar.current.dateComponents(in: gmtTimeZone, from: prompt.date).weekday, + let weekday = Weekday(rawValue: weekdayComponent - 1) else { // Calendar.Component.weekday starts from 1 (Sunday) + return false + } + + // only select prompts in the future that matches the weekdays listed in the schedule. + // additionally, if today's prompt is included, only include it if the reminder time has not passed. + return weekdays.contains(weekday) + && (!prompt.inSameDay(as: currentDate) || reminderTime.compare(with: currentDate) == .orderedDescending) + + } + + // Step 2: Schedule prompt reminders. + // The `lastScheduledPrompt` is stored to figure out the start date for static local notifications. + var lastScheduledPrompt: BloggingPrompt? = nil + var notificationIds = [String]() + promptsToSchedule.forEach { prompt in + guard let identifier = self.addLocalNotification(for: prompt, blog: blog, at: reminderTime) else { + return + } + notificationIds.append(identifier) + lastScheduledPrompt = prompt + } + + // Step 3: Schedule static notifications. + // first, check the last reminder date. If there are no prompts scheduled (perhaps due to unavailable prompts), + // this will schedule local notifications after the current date instead of the last scheduled date. + let lastReminderDate: Date = { + guard let lastScheduledPrompt = lastScheduledPrompt, + let lastReminderDateComponents = self.reminderDateComponents(for: lastScheduledPrompt, at: reminderTime), + let lastReminderDate = Calendar.current.date(from: lastReminderDateComponents) else { + return currentDate + } + + return lastReminderDate + }() + + if let staticNotificationIds = self.addStaticNotifications(after: lastReminderDate, with: schedule, time: reminderTime, blog: blog) { + notificationIds.append(contentsOf: staticNotificationIds) + } + + do { + // Step 4: Store pending notification identifiers to local store. + try self.saveReceipts(notificationIds, for: siteID) + } catch { + completion(.failure(error)) + } + + completion(.success(())) + + } failure: { error in + completion(.failure(error ?? Errors.unknown)) + } + } + + // MARK: Notification Scheduler + + /// Schedules the local notification for the given blogging prompt. + /// + /// - Parameters: + /// - prompt: The `BloggingPrompt` instance used to populate the content. + /// - blog: The user's blog. + /// - time: The preferred reminder time for the notification. + /// - Returns: String representing the notification identifier. + func addLocalNotification(for prompt: BloggingPrompt, blog: Blog, at time: Time) -> String? { + guard blog.dotComID != nil else { + return nil + } + + let content = UNMutableNotificationContent() + content.title = Constants.notificationTitle + content.subtitle = blog.title ?? String() + content.body = prompt.text + content.categoryIdentifier = InteractiveNotificationsManager.NoteCategoryDefinition.bloggingPrompt.rawValue + content.userInfo = notificationPayload(for: blog, prompt: prompt) + + guard let reminderDateComponents = reminderDateComponents(for: prompt, at: time) else { + return nil + } + + return addLocalNotification(with: content, dateComponents: reminderDateComponents) + } + + /// Converts the date from the `BloggingPrompt` to local date and time (matching the given `Time`), ignoring timezone conversion. + /// For example, given: + /// - Local timezone: GMT-5 + /// - BloggingPrompt date: 2022-05-01 00:00:00 +00:00 + /// - Time: 10:30 + /// This method will return `DateComponents` for `2022-05-01 10:30:00 -05:00`. + /// + /// - Parameters: + /// - prompt: The `BloggingPrompt` instance used for date reference. + /// - time: The preferred time for the reminder. + /// - Returns: Date components in local date and time. + func reminderDateComponents(for prompt: BloggingPrompt, at time: Time) -> DateComponents? { + guard let gmtTimeZone = Self.gmtTimeZone else { + return nil + } + + let gmtDateComponents = Calendar.current.dateComponents(in: gmtTimeZone, from: prompt.date) + guard let year = gmtDateComponents.year, + let month = gmtDateComponents.month, + let day = gmtDateComponents.day else { + return nil + } + + return DateComponents(year: year, month: month, day: day, hour: time.hour, minute: time.minute) + } + + /// Bulk schedule local notifications with static content for the given `Blog`. + /// The notifications are scheduled after `afterDate` according to the provided `Schedule` and `Time`. + /// + /// - Parameters: + /// - afterDate: Local notifications will be scheduled after this date. + /// - schedule: The preferred notification schedule. + /// - time: The preferred reminder time. + /// - blog: The blog to be associated with the reminder notification. + /// - maxDays: Defines how far the reminders should be scheduled in the future. + /// - Returns: An array of notification identifiers, or nil if there are logic errors. + func addStaticNotifications(after afterDate: Date, + with schedule: Schedule, + time: Time, + blog: Blog, + maxDays: Int = Constants.staticNotificationMaxDays) -> [String]? { + guard case .weekdays(let weekdays) = schedule, + maxDays > 0, + let maxDate = Calendar.current.date(byAdding: .day, value: maxDays, to: afterDate), + blog.dotComID != nil else { + return nil + } + + // create the notification content. + // note that the userInfo dictionary excludes `promptID` since there is no prompt associated with it. + let content = UNMutableNotificationContent() + content.title = Constants.notificationTitle + content.body = Constants.staticNotificationContent + content.categoryIdentifier = InteractiveNotificationsManager.NoteCategoryDefinition.bloggingPrompt.rawValue + content.userInfo = notificationPayload(for: blog) + + var date = afterDate + var identifiers = [String]() + while date < maxDate { + // find the next dates matching the given schedule. The dates are sorted at the end to properly order the dates based on current date. + // for example: given that today is Tuesday and the schedule is [.monday, .wednesday], the correct order for nextDates should be + // [Wednesday this week, Monday next week]. + let nextDates: [Date] = weekdays.compactMap { weekday in + guard let nextDate = Calendar.current.nextDate(after: date, matching: .init(weekday: weekday.rawValue + 1), matchingPolicy: .nextTime), + nextDate <= maxDate else { + return nil + } + return nextDate + }.sorted() + + guard !nextDates.isEmpty else { + break + } + + // finally, schedule the local notifications. + nextDates.forEach { nextDate in + let components = Calendar.current.dateComponents([.year, .month, .day], from: nextDate) + guard let year = components.year, + let month = components.month, + let day = components.day else { + return + } + + let reminderDateComponents = DateComponents(year: year, month: month, day: day, hour: time.hour, minute: time.minute) + let identifier = self.addLocalNotification(with: content, dateComponents: reminderDateComponents) + + identifiers.append(identifier) + date = nextDate // move the `date` forward to get it closer to `maxDate`. + } + } + + return identifiers + } + + /// Adds the local notification request to the notification scheduler. + /// + /// - Parameters: + /// - content: The local notification contents. + /// - dateComponents: When the local notification should occur. + /// - Returns: A String representing the notification identifier. + func addLocalNotification(with content: UNMutableNotificationContent, dateComponents: DateComponents) -> String { + let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: false) + let identifier = UUID().uuidString + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + + // schedule the local notification. + notificationScheduler.add(request) { error in + if let error = error { + DDLogError("[PromptRemindersScheduler] Error adding notification request: \(error.localizedDescription)") + } + } + + return identifier + } + + func notificationPayload(for blog: Blog, prompt: BloggingPrompt? = nil) -> [AnyHashable: Any] { + guard let siteID = blog.dotComID?.intValue else { + return [:] + } + + var userInfo: [AnyHashable: Any] = [BloggingPrompt.NotificationKeys.siteID: siteID] + + if let prompt = prompt { + userInfo[BloggingPrompt.NotificationKeys.promptID] = Int(prompt.promptID) + } + + return userInfo + } + + // MARK: Local Storage + + func defaultFileURL() throws -> URL { + let directoryURL = try FileManager.default.url(for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true) + + return directoryURL.appendingPathComponent(Constants.defaultFileName) + } + + /// Loads a dictionary containing all of the pending notification IDs for all sites. + /// + /// - Parameter fileURL: The file store location. + /// - Returns: A dictionary containing `siteID` and an array of `String` representing pending notification IDs. + func fetchAllReceipts(from fileURL: URL) throws -> [Int: [String]] { + if !localStore.fileExists(at: fileURL) { + let data = try PropertyListEncoder().encode([Int: [String]]()) + localStore.save(contents: data, at: fileURL) + } + + let data = try localStore.data(from: fileURL) + return try PropertyListDecoder().decode([Int: [String]].self, from: data) + } + + /// Convenience method to fetch notification receipts for a given `siteID`. + /// + /// - Parameter siteID: The ID of the blog associated with the notification receipts. + /// - Returns: An array of string representing the notification receipts. + func fetchReceipts(for siteID: Int) -> [String]? { + guard let allReceipts = try? fetchAllReceipts(from: defaultFileURL()), + let receiptsForSite = allReceipts[siteID] else { + return nil + } + + return receiptsForSite + } + + /// Updates the stored receipts under the given `siteID` key. + /// When passing nil, this method will remove the receipts for `siteID` instead. + /// + /// - Parameters: + /// - receipts: A sequence of notification receipts to store. + /// - siteID: The `siteID` of the Blog associated with the prompt reminders. + func saveReceipts(_ receipts: [String]?, for siteID: Int) throws { + let fileURL = try defaultFileURL() + var allReceipts = try fetchAllReceipts(from: fileURL) + + if let receipts = receipts, !receipts.isEmpty { + allReceipts[siteID] = receipts + } else { + allReceipts.removeValue(forKey: siteID) + } + + let data = try PropertyListEncoder().encode(allReceipts) + guard localStore.save(contents: data, at: fileURL) else { + throw Errors.fileSaveError + } + } + + /// Convenience method for deleting notification receipts. + /// + /// - Parameter siteID: The blog's ID associated with the notification receipts. + func deleteReceipts(for siteID: Int) throws { + try saveReceipts(nil, for: siteID) + } +} + +// MARK: - Current Date Provider + +/// A wrapper protocol to get the current `Date`. +/// This is created to simplify unit testing. +/// +protocol CurrentDateProvider { + func date() -> Date +} + +struct DefaultCurrentDateProvider: CurrentDateProvider { + func date() -> Date { + return Date() + } +} + +// MARK: - Local Store + +/// A wrapper protocol intended for `FileManager`. +/// Created to simplify unit testing. +/// +protocol LocalFileStore { + func data(from url: URL) throws -> Data + + func fileExists(at url: URL) -> Bool + + @discardableResult + func save(contents: Data, at url: URL) -> Bool + + func containerURL(forAppGroup appGroup: String) -> URL? + + func removeItem(at url: URL) throws + + func copyItem(at srcURL: URL, to dstURL: URL) throws +} + +extension LocalFileStore { + func data(from url: URL) throws -> Data { + return try Data(contentsOf: url) + } +} + +extension FileManager: LocalFileStore { + func containerURL(forAppGroup appGroup: String) -> URL? { + return containerURL(forSecurityApplicationGroupIdentifier: appGroup) + } + + func fileExists(at url: URL) -> Bool { + return fileExists(atPath: url.path) + } + + @discardableResult + func save(contents: Data, at url: URL) -> Bool { + return createFile(atPath: url.path, contents: contents) + } +} diff --git a/WordPress/Classes/Utility/Blogging Prompts/ReminderScheduleCoordinator.swift b/WordPress/Classes/Utility/Blogging Prompts/ReminderScheduleCoordinator.swift new file mode 100644 index 000000000000..4682d8dd4e02 --- /dev/null +++ b/WordPress/Classes/Utility/Blogging Prompts/ReminderScheduleCoordinator.swift @@ -0,0 +1,158 @@ +import UserNotifications + +/// Bridges the logic between Blogging Reminders and Blogging Prompts. +/// +/// Users can switch between receiving Blogging Reminders or Blogging Prompts based on the switch toggle in the reminder sheet. They are both delivered +/// through local notifications, but the mechanism between the two is differentiated due to technical limitations. Blogging Prompts requires the content +/// of each notification to be different, and this is not possible if we want to use a repeating `UNCalendarNotificationTrigger`. +/// +class ReminderScheduleCoordinator { + + // MARK: Dependencies + + private let bloggingRemindersScheduler: BloggingRemindersScheduler + private let promptRemindersScheduler: PromptRemindersScheduler + private let bloggingPromptsServiceFactory: BloggingPromptsServiceFactory + + // MARK: Public Methods + + init(bloggingRemindersScheduler: BloggingRemindersScheduler, + promptRemindersScheduler: PromptRemindersScheduler, + bloggingPromptsServiceFactory: BloggingPromptsServiceFactory = .init()) { + self.bloggingRemindersScheduler = bloggingRemindersScheduler + self.promptRemindersScheduler = promptRemindersScheduler + self.bloggingPromptsServiceFactory = bloggingPromptsServiceFactory + } + + convenience init(notificationScheduler: NotificationScheduler = UNUserNotificationCenter.current(), + pushNotificationAuthorizer: PushNotificationAuthorizer = InteractiveNotificationsManager.shared, + bloggingPromptsServiceFactory: BloggingPromptsServiceFactory = .init()) throws { + + let bloggingRemindersScheduler = try BloggingRemindersScheduler(notificationCenter: notificationScheduler, + pushNotificationAuthorizer: pushNotificationAuthorizer) + let promptRemindersScheduler = PromptRemindersScheduler(bloggingPromptsServiceFactory: bloggingPromptsServiceFactory, + notificationScheduler: notificationScheduler, + pushAuthorizer: pushNotificationAuthorizer) + + self.init(bloggingRemindersScheduler: bloggingRemindersScheduler, + promptRemindersScheduler: promptRemindersScheduler, + bloggingPromptsServiceFactory: bloggingPromptsServiceFactory) + } + + /// Returns the user's reminder schedule for the given `blog`, based on the current reminder type. + /// + /// - Parameter blog: The blog associated with the reminders. + /// - Returns: The user's preferred reminder schedule. + func schedule(for blog: Blog) -> BloggingRemindersScheduler.Schedule { + switch reminderType(for: blog) { + case .bloggingReminders: + return bloggingRemindersScheduler.schedule(for: blog) + + case .bloggingPrompts: + guard let settings = promptReminderSettings(for: blog), + let reminderDays = settings.reminderDays, + !reminderDays.getActiveWeekdays().isEmpty else { + return .none + } + + return .weekdays(reminderDays.getActiveWeekdays()) + } + } + + /// Returns the user's preferred time for the given `blog`, based on the current reminder type. + /// + /// - Parameter blog: The blog associated with the reminders. + /// - Returns: The user's preferred time returned in `Date`. + func scheduledTime(for blog: Blog) -> Date { + switch reminderType(for: blog) { + case .bloggingReminders: + return bloggingRemindersScheduler.scheduledTime(for: blog) + + case .bloggingPrompts: + guard let settings = promptReminderSettings(for: blog), + let dateForTime = settings.reminderTimeDate() else { + return Constants.defaultTime + } + + return dateForTime + } + } + + /// Schedules a reminder notification for the given `blog` based on the current reminder type. + /// + /// - Note: Calling this method will trigger the push notification authorization flow. + /// + /// - Parameters: + /// - schedule: The preferred notification schedule. + /// - blog: The blog that will upload the user's post. + /// - time: The user's preferred time to be notified. + /// - completion: Closure called after the process completes. + func schedule(_ schedule: BloggingRemindersScheduler.Schedule, + for blog: Blog, + time: Date? = nil, + completion: @escaping (Result<Void, Swift.Error>) -> ()) { + switch reminderType(for: blog) { + case .bloggingReminders: + bloggingRemindersScheduler.schedule(schedule, for: blog, time: time) { [weak self] result in + // always unschedule prompt reminders in case the user toggled the switch. + self?.promptRemindersScheduler.unschedule(for: blog) + completion(result) + } + + case .bloggingPrompts: + promptRemindersScheduler.schedule(schedule, for: blog, time: time) { [weak self] result in + // always unschedule blogging reminders in case the user toggled the switch. + self?.bloggingRemindersScheduler.unschedule(for: blog) + completion(result) + } + } + } + + /// Unschedules all future reminders from the given `blog`. + /// This applies to both Blogging Reminders and Blogging Prompts. + /// + /// - Parameter blog: The blog associated with the reminders. + func unschedule(for blog: Blog) { + bloggingRemindersScheduler.unschedule(for: blog) + promptRemindersScheduler.unschedule(for: blog) + } + +} + +// MARK: - Private Helpers + +private extension ReminderScheduleCoordinator { + + enum ReminderType { + case bloggingReminders + case bloggingPrompts + } + + enum Constants { + static let defaultHour = 10 + static let defaultMinute = 0 + + static var defaultTime: Date { + let calendar = Calendar.current + return calendar.date(from: DateComponents(calendar: calendar, hour: defaultHour, minute: defaultMinute)) ?? Date() + } + } + + func promptReminderSettings(for blog: Blog) -> BloggingPromptSettings? { + guard let service = bloggingPromptsServiceFactory.makeService(for: blog) else { + return nil + } + + return service.localSettings + } + + func reminderType(for blog: Blog) -> ReminderType { + if Feature.enabled(.bloggingPrompts), + let settings = promptReminderSettings(for: blog), + settings.promptRemindersEnabled { + return .bloggingPrompts + } + + return .bloggingReminders + } +} diff --git a/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduleFormatter.swift b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduleFormatter.swift new file mode 100644 index 000000000000..7a3dae78bf50 --- /dev/null +++ b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduleFormatter.swift @@ -0,0 +1,195 @@ +import Foundation + +struct BloggingRemindersScheduleFormatter { + private let calendar: Calendar + + init(calendar: Calendar? = nil) { + self.calendar = calendar ?? { + var calendar = Calendar.current + calendar.locale = Locale.autoupdatingCurrent + return calendar + }() + } + + /// Attributed short description string of the current schedule for the specified blog. + /// + func shortScheduleDescription(for schedule: BloggingRemindersScheduler.Schedule, time: String? = nil) -> NSAttributedString { + switch schedule { + case .none: + return Self.stringToAttributedString(TextContent.shortNoRemindersDescription) + case .weekdays(let days): + guard days.count > 0 else { + return shortScheduleDescription(for: .none, time: time) + } + + return Self.shortScheduleDescription(for: days.count, time: time) + } + } + + /// Attributed long description string of the current schedule for the specified blog. + /// + func longScheduleDescription(for schedule: BloggingRemindersScheduler.Schedule, time: String) -> NSAttributedString { + switch schedule { + case .none: + return NSAttributedString(string: TextContent.longNoRemindersDescription) + case .weekdays(let days): + guard days.count > 0 else { + return longScheduleDescription(for: .none, time: time) + } + + // We want the days sorted by their localized index because under some locale configurations + // Sunday is the first day of the week, whereas in some other localizations Monday comes first. + let sortedDays = days.sorted { (first, second) -> Bool in + let firstIndex = self.calendar.localizedWeekdayIndex(unlocalizedWeekdayIndex: first.rawValue) + let secondIndex = self.calendar.localizedWeekdayIndex(unlocalizedWeekdayIndex: second.rawValue) + + return firstIndex < secondIndex + } + + let markedUpDays: [String] = sortedDays.compactMap({ day in + return "<strong>\(self.calendar.weekdaySymbols[day.rawValue])</strong>" + }) + + let text: String + + if days.count == 1 { + text = String(format: TextContent.oneReminderLongDescriptionWithTime, markedUpDays.first ?? "", "<strong>\(time)</strong>") + } else { + let formatter = ListFormatter() + let formattedDays = formatter.string(from: markedUpDays) ?? "" + text = String(format: TextContent.manyRemindersLongDescriptionWithTime, "<strong>\(days.count)</strong>", formattedDays, "<strong>\(time)</strong>") + } + + return Self.stringToAttributedString(text) + } + } + + +} + +// MARK: - Private type methods and properties +private extension BloggingRemindersScheduleFormatter { + + static func shortScheduleDescription(for days: Int, time: String?) -> NSAttributedString { + guard let time = time else { + return shortScheduleDescription(for: days) + } + return shortScheduleDescriptionWithTime(for: days, time: time) + } + + static func shortScheduleDescriptionWithTime(for days: Int, time: String) -> NSAttributedString { + let text: String = { + switch days { + case 1: + return String(format: TextContent.oneReminderShortDescriptionWithTime, time) + case 2: + return String(format: TextContent.twoRemindersShortDescriptionWithTime, time) + case 7: + return "<strong>" + String(format: TextContent.everydayRemindersShortDescriptionWithTime, time) + "</strong>" + default: + return String(format: TextContent.manyRemindersShortDescriptionWithTime, days, time) + } + }() + + return Self.stringToAttributedString(text) + } + + static func shortScheduleDescription(for days: Int) -> NSAttributedString { + + let text: String = { + switch days { + case 1: + return TextContent.oneReminderShortDescription + case 2: + return TextContent.twoRemindersShortDescription + case 7: + return "<strong>" + TextContent.everydayRemindersShortDescription + "</strong>" + default: + return String(format: TextContent.manyRemindersShortDescription, days) + } + }() + + return Self.stringToAttributedString(text) + } + + static func stringToAttributedString(_ string: String) -> NSAttributedString { + // When the app is in a background state, return the non-emphasized plain String instead. + // Parsing a HTML string in the background may lead to a crash. + // Refer to https://github.com/wordpress-mobile/WordPress-iOS/pull/17678 for a similar scenario and explanation. + if UIApplication.shared.applicationState == .background { + return .init(string: string.removedHTMLEmphases()) + } + + let htmlData = NSString(string: string).data(using: String.Encoding.unicode.rawValue) ?? Data() + let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [.documentType: NSAttributedString.DocumentType.html] + + let attributedString = (try? NSMutableAttributedString(data: htmlData, + options: options, + documentAttributes: nil)) ?? NSMutableAttributedString() + + // This loop applies the default font to the whole text, while keeping any symbolic attributes the previous font may + // have had (such as bold style). + attributedString.enumerateAttribute(.font, in: NSRange(location: 0, length: attributedString.length)) { (value, range, stop) in + + guard let oldFont = value as? UIFont, + let newDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + .withSymbolicTraits(oldFont.fontDescriptor.symbolicTraits) else { + + return + } + + let newFont = UIFont(descriptor: newDescriptor, size: 0) + + attributedString.addAttributes([.font: newFont], range: range) + } + + return attributedString + } + + enum TextContent { + static let shortNoRemindersDescription = NSLocalizedString("None set", comment: "Title shown on table row where no blogging reminders have been set up yet") + + static let longNoRemindersDescription = NSLocalizedString("You have no reminders set.", comment: "Text shown to the user when setting up blogging reminders, if they complete the flow and have chosen not to add any reminders.") + + // Ideally we should use stringsdict to translate plurals, but GlotPress currently doesn't support this. + static let oneReminderLongDescriptionWithTime = NSLocalizedString("You'll get a reminder to blog <strong>once</strong> a week on %@ at %@.", + comment: "Blogging Reminders description confirming a user's choices. The placeholder will be replaced at runtime with a day of the week. The HTML markup is used to bold the word 'once'.") + + static let manyRemindersLongDescriptionWithTime = NSLocalizedString("You'll get reminders to blog %@ times a week on %@.", + comment: "Blogging Reminders description confirming a user's choices. The first placeholder will be populated with a count of the number of times a week they'll be reminded. The second will be a formatted list of days. For example: 'You'll get reminders to blog 2 times a week on Monday and Tuesday.") + + static let oneReminderShortDescriptionWithTime = NSLocalizedString("<strong>Once</strong> a week at %@", + comment: "Short title telling the user they will receive a blogging reminder once per week. The word for 'once' should be surrounded by <strong> HTML tags.") + + static let twoRemindersShortDescriptionWithTime = NSLocalizedString("<strong>Twice</strong> a week at %@", + comment: "Short title telling the user they will receive a blogging reminder two times a week. The word for 'twice' should be surrounded by <strong> HTML tags.") + + static let manyRemindersShortDescriptionWithTime = NSLocalizedString("<strong>%d</strong> times a week at %@", + comment: "A short description of how many times a week the user will receive a blogging reminder. The '%d' placeholder will be populated with a count of the number of times a week they'll be reminded, and should be surrounded by <strong> HTML tags.") + + static let everydayRemindersShortDescriptionWithTime = NSLocalizedString("Every day at %@", + comment: "Short title telling the user they will receive a blogging reminder every day of the week.") + + static let oneReminderShortDescription = NSLocalizedString("<strong>Once</strong> a week", + comment: "Short title telling the user they will receive a blogging reminder once per week. The word for 'once' should be surrounded by <strong> HTML tags.") + + static let twoRemindersShortDescription = NSLocalizedString("<strong>Twice</strong> a week", + comment: "Short title telling the user they will receive a blogging reminder two times a week. The word for 'twice' should be surrounded by <strong> HTML tags.") + + static let manyRemindersShortDescription = NSLocalizedString("<strong>%d</strong> times a week", + comment: "A short description of how many times a week the user will receive a blogging reminder. The '%d' placeholder will be populated with a count of the number of times a week they'll be reminded, and should be surrounded by <strong> HTML tags.") + + static let everydayRemindersShortDescription = NSLocalizedString("Every day", + comment: "Short title telling the user they will receive a blogging reminder every day of the week.") + } +} + +private extension String { + /// Convenient method to remove HTML emphasis tags from a given string. + func removedHTMLEmphases() -> String { + guard let expression = try? NSRegularExpression(pattern: "</?strong>", options: .caseInsensitive) else { + return self + } + return expression.stringByReplacingMatches(in: self, range: NSMakeRange(0, self.count), withTemplate: String()) + } +} diff --git a/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduler.swift b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduler.swift new file mode 100644 index 000000000000..b180aaec50da --- /dev/null +++ b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersScheduler.swift @@ -0,0 +1,426 @@ +import Foundation + +protocol NotificationScheduler { + func add(_ request: UNNotificationRequest, withCompletionHandler completionHandler: ((Error?) -> Void)?) + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) +} + +extension UNUserNotificationCenter: NotificationScheduler { +} + +protocol PushNotificationAuthorizer { + func requestAuthorization(completion: @escaping (_ allowed: Bool) -> Void) +} + +extension InteractiveNotificationsManager: PushNotificationAuthorizer { +} + +/// Main interface for scheduling blogging reminders +/// +class BloggingRemindersScheduler { + + // MARK: - Convenience Typealiases + + typealias BlogIdentifier = BloggingRemindersStore.BlogIdentifier + typealias ScheduledReminders = BloggingRemindersStore.ScheduledReminders + typealias ScheduledWeekday = BloggingRemindersStore.ScheduledWeekday + typealias ScheduledWeekdaysWithTime = BloggingRemindersStore.ScheduledWeekdaysWithTime + + // MARK: - Error Handling + + enum Error: Swift.Error { + case cantRetrieveContainerForAppGroup(appGroupName: String) + case needsPermissionForPushNotifications + case noPreviousScheduleAttempt + } + + // MARK: - Schedule Data Containers + + enum Schedule: Equatable { + /// No reminder schedule. + /// + case none + + /// Weekdays reminders + /// + case weekdays(_ days: [Weekday]) + + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.none, .none): + return true + case (.weekdays(let leftArray), .weekdays(let rightArray)): + return leftArray.count == rightArray.count && leftArray.sorted() == rightArray.sorted() + default: + return false + } + } + } + + /// The raw values have been selected for convenience, so that they perfectly match Apple's + /// index for weekday symbol methods, such as `Calendar.weekdaySymbols`. + /// + enum Weekday: Int, Codable, Comparable, CaseIterable { + case sunday = 0 + case monday + case tuesday + case wednesday + case thursday + case friday + case saturday + + /// The default reminder hour. In the future we may want to replace this constant with a more customizable approach. + /// + static let defaultHour = 10 + + static func < (lhs: BloggingRemindersScheduler.Weekday, rhs: BloggingRemindersScheduler.Weekday) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + // MARK: - Scheduler State + + /// The store for persisting our schedule. + /// + private let store: BloggingRemindersStore + + /// The notification scheduler + /// + private let notificationScheduler: NotificationScheduler + + /// Push notifications authorizer + /// + private let pushNotificationAuthorizer: PushNotificationAuthorizer + + /// The time of the day when blogging reminders will be received for the given blog + /// - Parameter blog: the given blog + /// - Returns: the time of the day + func scheduledTime(for blog: Blog) -> Date { + switch scheduledReminders(for: blog) { + case .weekDaysWithTime(let daysWithTime): + return daysWithTime.time + default: + let settings = BloggingPromptsService(blog: blog)?.localSettings + let defaultTime = Calendar.current.date(from: DateComponents(calendar: Calendar.current, hour: Weekday.defaultHour, minute: 0)) ?? Date() + + if FeatureFlag.bloggingPrompts.enabled, settings?.promptRemindersEnabled ?? false { + return settings?.reminderTimeDate() ?? defaultTime + } else { + return defaultTime + } + } + } + + /// Active schedule. + /// + func schedule(for blog: Blog) -> Schedule { + switch scheduledReminders(for: blog) { + case .none: + return .none + case .weekdays(let days): + return .weekdays(days.map({ $0.weekday })) + case .weekDaysWithTime(let daysWithTime): + return .weekdays(daysWithTime.days.map({ $0.weekday })) + } + } + + // MARK: - Default Store + + private static func defaultStore() throws -> BloggingRemindersStore { + let url = try defaultDataFileURL() + return try BloggingRemindersStore(dataFileURL: url) + } + + private static var defaultDataFileName = "BloggingReminders.plist" + + private static func defaultDataFileURL() throws -> URL { + try FileManager.default.url(for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true) + .appendingPathComponent(defaultDataFileName) + } + + private static func sharedDataFileURL() -> URL? { + let sharedDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: WPAppGroupName) + return sharedDirectory?.appendingPathComponent(defaultDataFileName) + } + + static func handleRemindersMigration() { + guard FeatureFlag.contentMigration.enabled else { + return + } + + if AppConfiguration.isWordPress { + copyStoreToSharedFile() + } else if AppConfiguration.isJetpack { + copyStoreToLocalFile() + } + } + + /// Deletes backup reminders if it exists. + /// + static func deleteBackupReminders() { + guard FeatureFlag.contentMigration.enabled, + let sharedFileURL = sharedDataFileURL() else { + return + } + + try? FileManager.default.removeItem(at: sharedFileURL) + } + + private static func copyStoreToSharedFile() { + guard let store = try? defaultStore(), + let sharedFileUrl = sharedDataFileURL() else { + return + } + + ContextManager.shared.performAndSave { context in + var configuration = [String: ScheduledReminders]() + for (blogIdentifier, schedule) in store.configuration { + guard let objectID = context.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: blogIdentifier), + let blog = context.object(with: objectID) as? Blog, + let url = blog.url else { + continue + } + configuration[url] = schedule + } + + if configuration.count > 0 { + try? PropertyListEncoder().encode(configuration).write(to: sharedFileUrl) + } + } + } + + private static func copyStoreToLocalFile() { + guard let localStore = try? defaultStore(), + let sharedFileUrl = sharedDataFileURL(), + FileManager.default.fileExists(at: sharedFileUrl), + let data = try? Data(contentsOf: sharedFileUrl), + let sharedConfig = try? PropertyListDecoder().decode([String: ScheduledReminders].self, from: data) else { + return + } + + // Only copy if the existing local store contains no schedules + if localStore.configuration.count == 0 { + ContextManager.shared.performAndSave { context in + for (blogUrl, schedule) in sharedConfig { + guard let blog = try? BlogQuery().hostname(matching: blogUrl).blog(in: context) else { + continue + } + let blogIdentifier = blog.objectID.uriRepresentation() + try? localStore.save(scheduledReminders: schedule, for: blogIdentifier) + } + try? FileManager.default.removeItem(at: sharedFileUrl) + } + } + } + + // MARK: - Initializers + + /// Default initializer. Allows overriding the blogging reminders store and the notification center for testing purposes. + /// + /// - Parameters: + /// - store: The `BloggingRemindersStore` to use for persisting the reminders schedule. + /// - notificationCenter: The `NotificationScheduler` to use for the notification requests. + /// - pushNotificationAuthorizer: The `PushNotificationAuthorizer` to use for push notification authorization. + /// + init( + store: BloggingRemindersStore, + notificationCenter: NotificationScheduler = UNUserNotificationCenter.current(), + pushNotificationAuthorizer: PushNotificationAuthorizer = InteractiveNotificationsManager.shared) { + + self.store = store + self.notificationScheduler = notificationCenter + self.pushNotificationAuthorizer = pushNotificationAuthorizer + } + + /// Default initializer. Allows overriding the blogging reminders store and the notification center for testing purposes. + /// + /// - Parameters: + /// - blogIdentifier, the blog identifier. This is necessary since we support blogging reminders for multiple blogs. + /// - notificationCenter: The `NotificationScheduler` to use for the notification requests. + /// - pushNotificationAuthorizer: The `PushNotificationAuthorizer` to use for push notification authorization. + /// + init( + notificationCenter: NotificationScheduler = UNUserNotificationCenter.current(), + pushNotificationAuthorizer: PushNotificationAuthorizer = InteractiveNotificationsManager.shared) throws { + + self.store = try Self.defaultStore() + self.notificationScheduler = notificationCenter + self.pushNotificationAuthorizer = pushNotificationAuthorizer + } + + // MARK: - Scheduling + + /// Main method for scheduling blogging reminder notifications. This method will take care of scheduling the local notifications and + /// persisting the user-defined reminder schedule. + /// + /// - Parameters: + /// - schedule: the blogging reminders schedule. + /// + func schedule(_ schedule: Schedule, for blog: Blog, time: Date? = nil, completion: @escaping (Result<Void, Swift.Error>) -> ()) { + guard schedule != .none else { + // If there's no schedule, then we don't need to request authorization + pushAuthorizationReceived(blog: blog, schedule: schedule, time: time, completion: completion) + return + } + + pushNotificationAuthorizer.requestAuthorization { [weak self] allowed in + guard let self = self else { + return + } + + guard allowed else { + completion(.failure(Error.needsPermissionForPushNotifications)) + return + } + + self.pushAuthorizationReceived(blog: blog, schedule: schedule, time: time, completion: completion) + } + } + + /// You should not be calling this method directly. Instead, make sure to use `schedule(_:completion:)`. + /// + private func pushAuthorizationReceived(blog: Blog, schedule: Schedule, time: Date?, completion: (Result<Void, Swift.Error>) -> ()) { + unschedule(scheduledReminders(for: blog)) + + let scheduledReminders: BloggingRemindersStore.ScheduledReminders + + switch schedule { + case .none: + scheduledReminders = .none + case .weekdays(let days): + guard let time = time else { + scheduledReminders = .weekdays(scheduled(days, for: blog)) + break + } + scheduledReminders = .weekDaysWithTime(scheduledWithTime(days, time: time, for: blog)) + } + + do { + try store.save(scheduledReminders: scheduledReminders, for: blog.objectID.uriRepresentation()) + } catch { + completion(.failure(error)) + return + } + + completion(.success(())) + } + + /// Schedules a notifications for the passed days, and returns another array with the days and their + /// associated notification IDs. + /// + /// - Parameters: + /// - weekdays: the weekdays to schedule notifications for. + /// + /// - Returns: the weekdays with the associated notification IDs. + /// + private func scheduled(_ weekdays: [Weekday], for blog: Blog) -> [ScheduledWeekday] { + weekdays.map { scheduled($0, for: blog) } + } + + /// Schedules a notifications for the passed days, and returns a `ScheduledWeekdaysWithTime` instance + /// containing the scheduling time and an array of `ScheduledWeekday` + /// - Parameters: + /// - weekdays: the weekdays to schedule notifications for. + /// - time: the time of the day when the notification will be received + private func scheduledWithTime(_ weekdays: [Weekday], time: Date, for blog: Blog) -> ScheduledWeekdaysWithTime { + ScheduledWeekdaysWithTime(time: time, days: weekdays.map { scheduled($0, time: time, for: blog) }) + } + + /// Schedules a notification for the passed day, and returns the day with the associated notification ID. + /// + /// - Parameters: + /// - weekday: the weekday to schedule a notification for. + /// + /// - Returns: the weekday with the associated notification ID. + /// + private func scheduled(_ weekday: Weekday, time: Date? = nil, for blog: Blog) -> ScheduledWeekday { + let notificationID = scheduleNotification(for: weekday, time: time, blog: blog) + return ScheduledWeekday(weekday: weekday, notificationID: notificationID) + } + + /// Schedules a notification for the specified weekday. + /// + private func scheduleNotification(for weekday: Weekday, time: Date?, blog: Blog) -> String { + let content = UNMutableNotificationContent() + if let title = blog.title { + content.title = String(format: TextContent.notificationTitle, title) + } else { + content.title = TextContent.noTitleNotificationTitle + } + content.body = TextContent.notificationBody + content.categoryIdentifier = InteractiveNotificationsManager.NoteCategoryDefinition.bloggingReminderWeekly.rawValue + if let blogID = blog.dotComID?.stringValue { + content.threadIdentifier = blogID + } + + var dateComponents = DateComponents() + let calendar = Calendar.current + dateComponents.calendar = calendar + + // `DateComponent`'s weekday uses a 1-based index. + dateComponents.weekday = weekday.rawValue + 1 + dateComponents.hour = time?.dateAndTimeComponents().hour ?? Weekday.defaultHour + dateComponents.minute = time?.dateAndTimeComponents().minute + + let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + + let uuidString = UUID().uuidString + let request = UNNotificationRequest(identifier: uuidString, content: content, trigger: trigger) + + notificationScheduler.add(request) { (error) in + if let error = error { + DDLogError(error.localizedDescription) + } + } + + return uuidString + } + + // MARK: - Unscheduling + func unschedule(for blogs: [Blog]) { + for blog in blogs { + unschedule(for: blog) + } + } + + func unschedule(for blog: Blog) { + schedule(.none, for: blog, completion: { _ in }) + } + + /// Unschedules all notifications for the passed schedule. + /// + private func unschedule(_ schedule: ScheduledReminders) { + switch schedule { + case .none: + return + case .weekdays(let days): + unschedule(days) + case .weekDaysWithTime(let daysWithTime): + unschedule(daysWithTime.days) + } + } + + /// Unschedules all notiication for the specified days. + /// + private func unschedule(_ days: [ScheduledWeekday]) { + let notificationIDs = days.map { $0.notificationID } + + notificationScheduler.removePendingNotificationRequests(withIdentifiers: notificationIDs) + } + + // MARK: - Scheduled Reminders + + private func scheduledReminders(for blog: Blog) -> ScheduledReminders { + store.scheduledReminders(for: blog.objectID.uriRepresentation()) + } + + private enum TextContent { + static let noTitleNotificationTitle = NSLocalizedString("It's time to blog!", comment: "Title of a notification displayed prompting the user to create a new blog post") + static let notificationTitle = NSLocalizedString("It's time to blog on %@!", + comment: "Title of a notification displayed prompting the user to create a new blog post. The %@ will be replaced with the blog's title.") + static let notificationBody = NSLocalizedString("This is your reminder to blog today ✍️", comment: "The body of a notification displayed to the user prompting them to create a new blog post. The emoji should ideally remain, as part of the text.") + } +} diff --git a/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersStore.swift b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersStore.swift new file mode 100644 index 000000000000..9ae01684e0aa --- /dev/null +++ b/WordPress/Classes/Utility/Blogging Reminders/BloggingRemindersStore.swift @@ -0,0 +1,197 @@ +import Foundation + +/// Store for the blogging reminders. This class should not be interacted with directly other than to pass +/// to the initializer of `BloggingRemindersScheduler`. +/// +class BloggingRemindersStore { + + /// I'm intentionally making the naming and definition of the blog identifier a bit generic because right now we're using + /// a `URIRepresentation`, but I'm not sure this is going to stay this way. + /// + typealias BlogIdentifier = URL + + enum Error: Swift.Error { + case configurationDecodingFailed(error: Swift.Error) + case configurationEncodingFailed(error: Swift.Error) + case configurationFileCreationFailed(url: URL, data: Data) + } + + /// Represents user-defined reminders for which notifications have been scheduled. + /// + enum ScheduledReminders { + /// No reminders scheduled. + /// + case none + + /// Scheduled weekday reminders. + /// + case weekdays(_ days: [ScheduledWeekday]) + + /// Scheduled weekday reminders with time of the day + /// + case weekDaysWithTime(_ daysWithTime: ScheduledWeekdaysWithTime) + } + + struct ScheduledWeekdaysWithTime: Codable { + let time: Date + let days: [ScheduledWeekday] + } + + /// A weekday with an associated notification that has already been scheduled. + /// + struct ScheduledWeekday: Codable { + let weekday: BloggingRemindersScheduler.Weekday + let notificationID: String + } + + private let fileManager: FileManager + private let dataFileURL: URL + + /// The blogging reminders configuration for all blogs. + /// + private(set) var configuration: [BlogIdentifier: ScheduledReminders] + + // MARK: - Initializers + + private init( + fileManager: FileManager, + configuration: [BlogIdentifier: ScheduledReminders], + dataFileURL: URL) { + + self.dataFileURL = dataFileURL + self.configuration = configuration + self.fileManager = fileManager + } + + convenience init(fileManager: FileManager = .default, dataFileURL url: URL) throws { + guard fileManager.fileExists(atPath: url.path) else { + self.init(fileManager: fileManager, configuration: [:], dataFileURL: url) + try save() + return + } + + let decoder = PropertyListDecoder() + do { + let data = try Data(contentsOf: url) + let configuration = try decoder.decode([BlogIdentifier: ScheduledReminders].self, from: data) + self.init(fileManager: fileManager, configuration: configuration, dataFileURL: url) + } catch { + throw Error.configurationDecodingFailed(error: error) + self.init(fileManager: fileManager, configuration: [:], dataFileURL: url) + } + } + + // MARK: - Configurations + + func scheduledReminders(for blogIdentifier: BlogIdentifier) -> ScheduledReminders { + configuration[blogIdentifier] ?? .none + } + + func save(scheduledReminders: ScheduledReminders, for blogIdentifier: BlogIdentifier) throws { + switch scheduledReminders { + case .none: + configuration.removeValue(forKey: blogIdentifier) + case .weekdays, .weekDaysWithTime: + configuration[blogIdentifier] = scheduledReminders + } + try save() + } + + private func save() throws { + let data: Data + + do { + data = try PropertyListEncoder().encode(configuration) + } catch { + throw Error.configurationEncodingFailed(error: error) + } + + guard fileManager.createFile(atPath: dataFileURL.path, contents: data, attributes: nil) else { + throw Error.configurationFileCreationFailed(url: dataFileURL, data: data) + } + } +} + +// MARK: - ReminderSchedule: Equatable + +extension BloggingRemindersStore.ScheduledReminders: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.none, .none): + return true + case (.weekdays(let left), .weekdays(let right)): + return left == right + default: + return false + } + } +} + +// MARK: - ReminderSchedule: Codable + +extension BloggingRemindersStore.ScheduledReminders: Codable { + typealias ScheduledWeekday = BloggingRemindersStore.ScheduledWeekday + typealias ScheduledWeekdaysWithTime = BloggingRemindersStore.ScheduledWeekdaysWithTime + + private enum CodingKeys: String, CodingKey { + case none + case weekdays + case weekDaysWithTime + } + + private enum Error: Swift.Error { + case cantFindKeyForDecoding + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + guard let key = container.allKeys.first else { + throw Error.cantFindKeyForDecoding + } + + switch key { + case CodingKeys.none: + self = .none + case .weekdays: + do { + let days = try container.decode([ScheduledWeekday].self, forKey: .weekdays) + self = .weekdays(days) + } catch { + DDLogError("Failed to decode days from Blogging Reminders store :\(error)") + self = .none + } + case .weekDaysWithTime: + do { + let daysWithTime = try container.decode(ScheduledWeekdaysWithTime.self, forKey: .weekDaysWithTime) + self = .weekDaysWithTime(daysWithTime) + } catch { + DDLogError("Failed to decode days from Blogging Reminders store :\(error)") + self = .none + } + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .none: + try container.encode(true, forKey: .none) + case .weekdays(let days): + try container.encode(days, forKey: .weekdays) + case .weekDaysWithTime(let daysWithTime): + try container.encode(daysWithTime, forKey: .weekDaysWithTime) + + } + } +} + +// MARK: - ScheduledWeekday: Equatable + +extension BloggingRemindersStore.ScheduledWeekday: Equatable { + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.weekday == rhs.weekday + && lhs.notificationID == rhs.notificationID + } +} diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 6120e0bdc7b5..12f93d278812 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -2,13 +2,43 @@ /// different builds. @objc enum FeatureFlag: Int, CaseIterable { + case bloggingPrompts + case bloggingPromptsEnhancements case jetpackDisconnect case debugMenu - case postReblogging - case unifiedAuth - case quickActions - case meMove - case floatingCreateButton + case readerCSS + case homepageSettings + case unifiedPrologueCarousel + case todayWidget + case milestoneNotifications + case bloggingReminders + case siteIconCreator + case weeklyRoundup + case weeklyRoundupStaticNotification + case weeklyRoundupBGProcessingTask + case domains + case timeZoneSuggester + case mediaPickerPermissionsNotice + case notificationCommentDetails + case siteIntentQuestion + case landInTheEditor + case statsNewAppearance + case statsNewInsights + case siteName + case quickStartForExistingUsers + case qrLogin + case betaSiteDesigns + case featureHighlightTooltip + case jetpackPowered + case jetpackPoweredBottomSheet + case contentMigration + case newJetpackLandingScreen + case newWordPressLandingScreen + case newCoreDataContext + case jetpackIndividualPluginSupport + case siteCreationDomainPurchasing + case readerUserBlocking + case personalizeHomeTab /// Returns a boolean indicating if the feature is enabled var enabled: Bool { @@ -17,23 +47,90 @@ enum FeatureFlag: Int, CaseIterable { } switch self { + case .bloggingPrompts: + return AppConfiguration.isJetpack + case .bloggingPromptsEnhancements: + return AppConfiguration.isJetpack case .jetpackDisconnect: return BuildConfiguration.current == .localDeveloper case .debugMenu: - return BuildConfiguration.current ~= [.localDeveloper, - .a8cBranchTest] - case .postReblogging: + return BuildConfiguration.current ~= [.localDeveloper, .a8cBranchTest, .a8cPrereleaseTesting] + case .readerCSS: + return false + case .homepageSettings: return true - case .unifiedAuth: - return BuildConfiguration.current == .localDeveloper - case .quickActions: + case .unifiedPrologueCarousel: return true - case .meMove: - return BuildConfiguration.current == .localDeveloper - case .floatingCreateButton: - return BuildConfiguration.current == .localDeveloper + case .todayWidget: + return true + case .milestoneNotifications: + return true + case .bloggingReminders: + return true + case .siteIconCreator: + return BuildConfiguration.current != .appStore + case .weeklyRoundup: + return true + case .weeklyRoundupStaticNotification: + // This may be removed, but we're feature flagging it for now until we know for sure we won't need it. + return false + case .weeklyRoundupBGProcessingTask: + return true + case .domains: + // Note: when used to control access to the domains feature, you should also check whether + // the current AppConfiguration and blog support domains. + // See BlogDetailsViewController.shouldShowDomainRegistration for an example. + return true + case .timeZoneSuggester: + return true + case .mediaPickerPermissionsNotice: + return true + case .notificationCommentDetails: + return true + case .siteIntentQuestion: + return true + case .landInTheEditor: + return false + case .statsNewAppearance: + return AppConfiguration.showsStatsRevampV2 + case .statsNewInsights: + return AppConfiguration.showsStatsRevampV2 + case .siteName: + return false + case .quickStartForExistingUsers: + return true + case .qrLogin: + return true + case .betaSiteDesigns: + return false + case .featureHighlightTooltip: + return true + case .jetpackPowered: + return true + case .jetpackPoweredBottomSheet: + return true + case .contentMigration: + return true + case .newJetpackLandingScreen: + return true + case .newWordPressLandingScreen: + return true + case .newCoreDataContext: + return true + case .jetpackIndividualPluginSupport: + return AppConfiguration.isJetpack + case .siteCreationDomainPurchasing: + return false + case .readerUserBlocking: + return true + case .personalizeHomeTab: + return false } } + + var disabled: Bool { + return enabled == false + } } /// Objective-C bridge for FeatureFlag. @@ -46,32 +143,103 @@ class Feature: NSObject { } } -extension FeatureFlag: OverrideableFlag { +extension FeatureFlag { /// Descriptions used to display the feature flag override menu in debug builds var description: String { switch self { + case .bloggingPrompts: + return "Blogging Prompts" + case .bloggingPromptsEnhancements: + return "Blogging Prompts Enhancements" case .jetpackDisconnect: return "Jetpack disconnect" case .debugMenu: return "Debug menu" - case .postReblogging: - return "Post Reblogging" - case .unifiedAuth: - return "Unified Auth" - case .quickActions: - return "Quick Actions" - case .meMove: - return "Move the Me Scene to My Site" - case .floatingCreateButton: - return "Floating Create Button" + case .readerCSS: + return "Ignore Reader CSS Cache" + case .homepageSettings: + return "Homepage Settings" + case .unifiedPrologueCarousel: + return "Unified Prologue Carousel" + case .todayWidget: + return "iOS 14 Today Widget" + case .milestoneNotifications: + return "Milestone notifications" + case .bloggingReminders: + return "Blogging Reminders" + case .siteIconCreator: + return "Site Icon Creator" + case .weeklyRoundup: + return "Weekly Roundup" + case .weeklyRoundupStaticNotification: + return "Weekly Roundup Static Notification" + case .weeklyRoundupBGProcessingTask: + return "Weekly Roundup BGProcessingTask" + case .domains: + return "Domain Purchases" + case .timeZoneSuggester: + return "TimeZone Suggester" + case .mediaPickerPermissionsNotice: + return "Media Picker Permissions Notice" + case .notificationCommentDetails: + return "Notification Comment Details" + case .siteIntentQuestion: + return "Site Intent Question" + case .landInTheEditor: + return "Land In The Editor" + case .statsNewAppearance: + return "New Appearance for Stats" + case .statsNewInsights: + return "New Cards for Stats Insights" + case .siteName: + return "Site Name" + case .quickStartForExistingUsers: + return "Quick Start For Existing Users" + case .qrLogin: + return "QR Code Login" + case .betaSiteDesigns: + return "Fetch Beta Site Designs" + case .featureHighlightTooltip: + return "Feature Highlight Tooltip" + case .jetpackPowered: + return "Jetpack powered banners and badges" + case .jetpackPoweredBottomSheet: + return "Jetpack powered bottom sheet" + case .contentMigration: + return "Content Migration" + case .newJetpackLandingScreen: + return "New Jetpack landing screen" + case .newWordPressLandingScreen: + return "New WordPress landing screen" + case .newCoreDataContext: + return "Use new Core Data context structure (Require app restart)" + case .jetpackIndividualPluginSupport: + return "Jetpack Individual Plugin Support" + case .siteCreationDomainPurchasing: + return "Site Creation Domain Purchasing" + case .readerUserBlocking: + return "Reader User Blocking" + case .personalizeHomeTab: + return "Personalize Home Tab" } } +} + +extension FeatureFlag: OverridableFlag { + + var originalValue: Bool { + return enabled + } var canOverride: Bool { switch self { case .debugMenu: return false - case .floatingCreateButton: + case .todayWidget: + return false + case .weeklyRoundup: + return false + case .weeklyRoundupStaticNotification: return false default: return true diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlagOverrideStore.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlagOverrideStore.swift index 9c2259ecd0b3..0b9b458757e5 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlagOverrideStore.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlagOverrideStore.swift @@ -3,8 +3,8 @@ import Foundation // Protocol allows easier unit testing, so we can implement mock // feature flags to use in test cases. // -protocol OverrideableFlag: CustomStringConvertible { - var enabled: Bool { get } +protocol OverridableFlag: CustomStringConvertible { + var originalValue: Bool { get } var canOverride: Bool { get } } @@ -17,19 +17,19 @@ struct FeatureFlagOverrideStore { self.store = store } - private func key(for flag: OverrideableFlag) -> String { + private func key(for flag: OverridableFlag) -> String { return "ff-override-\(String(describing: flag))" } /// - returns: True if the specified feature flag is overridden /// - func isOverridden(_ featureFlag: OverrideableFlag) -> Bool { + func isOverridden(_ featureFlag: OverridableFlag) -> Bool { return overriddenValue(for: featureFlag) != nil } /// Removes any existing overridden value and stores the new value /// - func override(_ featureFlag: OverrideableFlag, withValue value: Bool) throws { + func override(_ featureFlag: OverridableFlag, withValue value: Bool) throws { guard featureFlag.canOverride == true else { throw FeatureFlagError.cannotBeOverridden } @@ -40,7 +40,7 @@ struct FeatureFlagOverrideStore { store.removeObject(forKey: key) } - if value != featureFlag.enabled { + if value != featureFlag.originalValue { store.set(value, forKey: key) } } @@ -48,7 +48,7 @@ struct FeatureFlagOverrideStore { /// - returns: The overridden value for the specified feature flag, if one exists. /// If no override exists, returns `nil`. /// - func overriddenValue(for featureFlag: OverrideableFlag) -> Bool? { + func overriddenValue(for featureFlag: OverridableFlag) -> Bool? { guard let value = store.object(forKey: key(for: featureFlag)) as? Bool else { return nil } diff --git a/WordPress/Classes/Utility/BuildInformation/RemoteConfigOverrideStore.swift b/WordPress/Classes/Utility/BuildInformation/RemoteConfigOverrideStore.swift new file mode 100644 index 000000000000..84ae8f3303db --- /dev/null +++ b/WordPress/Classes/Utility/BuildInformation/RemoteConfigOverrideStore.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Used to override values for remote config parameters at runtime in debug builds +/// +struct RemoteConfigOverrideStore { + private let store: KeyValueDatabase + + init(store: KeyValueDatabase = UserDefaults.standard) { + self.store = store + } + + private func key(for param: RemoteParameter) -> String { + return "remote-config-override-\(String(describing: param))" + } + + /// Stores the new overridden value + func override(_ param: RemoteParameter, withValue value: String) { + let key = self.key(for: param) + store.set(value, forKey: key) + } + + /// Removes any existing overridden value + func reset(_ param: RemoteParameter) { + let key = self.key(for: param) + store.removeObject(forKey: key) + } + + /// - returns: The overridden value for the specified parameter, if one exists. + /// If no override exists, returns `nil`. + func overriddenValue(for param: RemoteParameter) -> String? { + guard let value = store.object(forKey: key(for: param)) as? String else { + return nil + } + + return value + } +} diff --git a/WordPress/Classes/Utility/BuildInformation/RemoteConfigParameter.swift b/WordPress/Classes/Utility/BuildInformation/RemoteConfigParameter.swift new file mode 100644 index 000000000000..6e6bac685a7f --- /dev/null +++ b/WordPress/Classes/Utility/BuildInformation/RemoteConfigParameter.swift @@ -0,0 +1,105 @@ +import Foundation + +protocol RemoteParameter { + var key: String { get } + var defaultValue: LosslessStringConvertible? { get } + var description: String { get } +} + +extension RemoteParameter { + func value<T: LosslessStringConvertible>(using remoteStore: RemoteConfigStore = .init(), + overrideStore: RemoteConfigOverrideStore = .init()) -> T? { + if let overriddenStringValue = overrideStore.overriddenValue(for: self) { + DDLogInfo("🚩 Returning overridden value for remote config param: \(description).") + return T.init(overriddenStringValue) + } + if let remoteValue = remoteStore.value(for: key) { + return remoteValue as? T + } + DDLogInfo("🚩 Unable to resolve remote config param: \(description). Returning compile-time default.") + return defaultValue as? T + } +} + +/// Each enum case represents a single remote parameter. Each parameter has a default value and a server value. +/// We fallback to the default value if the server value cannot be retrieved. +enum RemoteConfigParameter: CaseIterable, RemoteParameter { + case jetpackDeadline + case phaseTwoBlogPostUrl + case phaseThreeBlogPostUrl + case phaseFourBlogPostUrl + case phaseNewUsersBlogPostUrl + case phaseSelfHostedBlogPostUrl + case blazeNonDismissibleStep + case blazeFlowCompletedStep + case wordPressPluginOverlayMaxShown + + var key: String { + switch self { + case .jetpackDeadline: + return "jp_deadline" + case .phaseTwoBlogPostUrl: + return "phase_two_blog_post" + case .phaseThreeBlogPostUrl: + return "phase_three_blog_post" + case .phaseFourBlogPostUrl: + return "phase_four_blog_post" + case .phaseNewUsersBlogPostUrl: + return "phase_new_users_blog_post" + case .phaseSelfHostedBlogPostUrl: + return "phase_self_hosted_blog_post" + case .blazeNonDismissibleStep: + return "blaze_non_dismissable_hash" + case .blazeFlowCompletedStep: + return "blaze_completed_step_hash" + case .wordPressPluginOverlayMaxShown: + return "wp_plugin_overlay_max_shown" + } + } + + var defaultValue: LosslessStringConvertible? { + switch self { + case .jetpackDeadline: + return nil + case .phaseTwoBlogPostUrl: + return nil + case .phaseThreeBlogPostUrl: + return nil + case .phaseFourBlogPostUrl: + return nil + case .phaseNewUsersBlogPostUrl: + return nil + case .phaseSelfHostedBlogPostUrl: + return nil + case .blazeNonDismissibleStep: + return "step-4" + case .blazeFlowCompletedStep: + return "step-5" + case .wordPressPluginOverlayMaxShown: + return 3 + } + } + + var description: String { + switch self { + case .jetpackDeadline: + return "Jetpack Deadline" + case .phaseTwoBlogPostUrl: + return "Phase 2 Blog Post URL" + case .phaseThreeBlogPostUrl: + return "Phase 3 Blog Post URL" + case .phaseFourBlogPostUrl: + return "Phase 4 Blog Post URL" + case .phaseNewUsersBlogPostUrl: + return "Phase New Users Blog Post URL" + case .phaseSelfHostedBlogPostUrl: + return "Phase Self-Hosted Blog Post URL" + case .blazeNonDismissibleStep: + return "Blaze Non-Dismissible Step" + case .blazeFlowCompletedStep: + return "Blaze Completed Step" + case .wordPressPluginOverlayMaxShown: + return "WP Plugin Overlay Max Frequency" + } + } +} diff --git a/WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift new file mode 100644 index 000000000000..374ab7ed4efa --- /dev/null +++ b/WordPress/Classes/Utility/BuildInformation/RemoteFeatureFlag.swift @@ -0,0 +1,168 @@ +import Foundation + +@objc +enum RemoteFeatureFlag: Int, CaseIterable { + case jetpackFeaturesRemovalPhaseOne + case jetpackFeaturesRemovalPhaseTwo + case jetpackFeaturesRemovalPhaseThree + case jetpackFeaturesRemovalPhaseFour + case jetpackFeaturesRemovalPhaseNewUsers + case jetpackFeaturesRemovalPhaseSelfHosted + case jetpackFeaturesRemovalStaticPosters + case jetpackMigrationPreventDuplicateNotifications + case wordPressSupportForum + case blaze + case wordPressIndividualPluginSupport + case domainsDashboardCard + case pagesDashboardCard + case activityLogDashboardCard + case sdkLessGoogleSignIn + case bloggingPromptsSocial + + var defaultValue: Bool { + switch self { + case .jetpackMigrationPreventDuplicateNotifications: + return true + case .jetpackFeaturesRemovalPhaseOne: + return false + case .jetpackFeaturesRemovalPhaseTwo: + return false + case .jetpackFeaturesRemovalPhaseThree: + return false + case .jetpackFeaturesRemovalPhaseFour: + return false + case .jetpackFeaturesRemovalPhaseNewUsers: + return false + case .jetpackFeaturesRemovalPhaseSelfHosted: + return false + case .jetpackFeaturesRemovalStaticPosters: + return false + case .wordPressSupportForum: + return true + case .blaze: + return false + case .wordPressIndividualPluginSupport: + return AppConfiguration.isWordPress + case .domainsDashboardCard: + return false + case .pagesDashboardCard: + return false + case .activityLogDashboardCard: + return false + case .sdkLessGoogleSignIn: + return false + case .bloggingPromptsSocial: + return AppConfiguration.isJetpack + } + } + + /// This key must match the server-side one for remote feature flagging + var remoteKey: String { + switch self { + case .jetpackFeaturesRemovalPhaseOne: + return "jp_removal_one" + case .jetpackFeaturesRemovalPhaseTwo: + return "jp_removal_two" + case .jetpackFeaturesRemovalPhaseThree: + return "jp_removal_three" + case .jetpackFeaturesRemovalPhaseFour: + return "jp_removal_four" + case .jetpackFeaturesRemovalPhaseNewUsers: + return "jp_removal_new_users" + case .jetpackFeaturesRemovalPhaseSelfHosted: + return "jp_removal_self_hosted" + case .jetpackFeaturesRemovalStaticPosters: + return "jp_removal_static_posters" + case .jetpackMigrationPreventDuplicateNotifications: + return "prevent_duplicate_notifs_remote_field" + case .wordPressSupportForum: + return "wordpress_support_forum_remote_field" + case .blaze: + return "blaze" + case .wordPressIndividualPluginSupport: + return "wp_individual_plugin_overlay" + case .domainsDashboardCard: + return "dashboard_card_domain" + case .pagesDashboardCard: + return "dashboard_card_pages" + case .activityLogDashboardCard: + return "dashboard_card_activity_log" + case .sdkLessGoogleSignIn: + return "google_signin_without_sdk" + case .bloggingPromptsSocial: + return "blogging_prompts_social_enabled" + } + } + + var description: String { + switch self { + case .jetpackMigrationPreventDuplicateNotifications: + return "Jetpack Migration prevent duplicate WordPress app notifications when Jetpack is installed" + case .jetpackFeaturesRemovalPhaseOne: + return "Jetpack Features Removal Phase One" + case .jetpackFeaturesRemovalPhaseTwo: + return "Jetpack Features Removal Phase Two" + case .jetpackFeaturesRemovalPhaseThree: + return "Jetpack Features Removal Phase Three" + case .jetpackFeaturesRemovalPhaseFour: + return "Jetpack Features Removal Phase Four" + case .jetpackFeaturesRemovalPhaseNewUsers: + return "Jetpack Features Removal Phase For New Users" + case .jetpackFeaturesRemovalPhaseSelfHosted: + return "Jetpack Features Removal Phase For Self-Hosted Sites" + case .jetpackFeaturesRemovalStaticPosters: + return "Jetpack Features Removal Static Screens Phase" + case .wordPressSupportForum: + return "Provide support through a forum" + case .blaze: + return "Blaze" + case .wordPressIndividualPluginSupport: + return "Jetpack Individual Plugin Support for WordPress" + case .domainsDashboardCard: + return "Domains Dashboard Card" + case .pagesDashboardCard: + return "Pages Dashboard Card" + case .activityLogDashboardCard: + return "Activity Log Dashboard Card" + case .sdkLessGoogleSignIn: + return "Sign-In with Google without the Google SDK" + case .bloggingPromptsSocial: + return "Blogging Prompts Social" + } + } + + /// If the flag is overridden, the overridden value is returned. + /// If the flag exists in the local cache, the current value will be returned. + /// If the flag is not overridden and does not exist in the local cache, the compile-time default will be returned. + func enabled(using remoteStore: RemoteFeatureFlagStore = RemoteFeatureFlagStore(), + overrideStore: FeatureFlagOverrideStore = FeatureFlagOverrideStore()) -> Bool { + if let overriddenValue = overrideStore.overriddenValue(for: self) { + return overriddenValue + } + if let remoteValue = remoteStore.value(for: remoteKey) { // The value may not be in the cache if this is the first run + return remoteValue + } + DDLogInfo("🚩 Unable to resolve remote feature flag: \(description). Returning compile-time default.") + return defaultValue + } +} + +extension RemoteFeatureFlag: OverridableFlag { + var originalValue: Bool { + return enabled() + } + + var canOverride: Bool { + true + } +} + +/// Objective-C bridge for RemoteFeatureFlag. +/// +/// Since we can't expose properties on Swift enums we use a class instead +class RemoteFeature: NSObject { + /// Returns a boolean indicating if the feature is enabled + @objc static func enabled(_ feature: RemoteFeatureFlag) -> Bool { + return feature.enabled() + } +} diff --git a/WordPress/Classes/Utility/ContentCoordinator.swift b/WordPress/Classes/Utility/ContentCoordinator.swift index 314eb19b549f..99c4bd1fd2e7 100644 --- a/WordPress/Classes/Utility/ContentCoordinator.swift +++ b/WordPress/Classes/Utility/ContentCoordinator.swift @@ -1,13 +1,15 @@ protocol ContentCoordinator { func displayReaderWithPostId(_ postID: NSNumber?, siteID: NSNumber?) throws - func displayCommentsWithPostId(_ postID: NSNumber?, siteID: NSNumber?) throws - func displayStatsWithSiteID(_ siteID: NSNumber?) throws + func displayCommentsWithPostId(_ postID: NSNumber?, siteID: NSNumber?, commentID: NSNumber?, source: ReaderCommentsSource) throws + func displayStatsWithSiteID(_ siteID: NSNumber?, url: URL?) throws func displayFollowersWithSiteID(_ siteID: NSNumber?, expirationTime: TimeInterval) throws func displayStreamWithSiteID(_ siteID: NSNumber?) throws - func displayWebViewWithURL(_ url: URL) + func displayWebViewWithURL(_ url: URL, source: String) func displayFullscreenImage(_ image: UIImage) func displayPlugin(withSlug pluginSlug: String, on siteSlug: String) throws + func displayBackupWithSiteID(_ siteID: NSNumber?) throws + func displayScanWithSiteID(_ siteID: NSNumber?) throws } @@ -15,7 +17,6 @@ protocol ContentCoordinator { /// like Posts, Site streams, Comments, etc... /// struct DefaultContentCoordinator: ContentCoordinator { - enum DisplayError: Error { case missingParameter case unsupportedFeature @@ -39,33 +40,83 @@ struct DefaultContentCoordinator: ContentCoordinator { controller?.navigationController?.pushFullscreenViewController(readerViewController, animated: true) } - func displayCommentsWithPostId(_ postID: NSNumber?, siteID: NSNumber?) throws { - guard let postID = postID, let siteID = siteID else { - throw DisplayError.missingParameter - } + func displayCommentsWithPostId(_ postID: NSNumber?, siteID: NSNumber?, commentID: NSNumber?, source: ReaderCommentsSource) throws { + guard let postID = postID, + let siteID = siteID, + let commentsViewController = ReaderCommentsViewController(postID: postID, siteID: siteID, source: source) else { + throw DisplayError.missingParameter + } - let commentsViewController = ReaderCommentsViewController(postID: postID, siteID: siteID) - commentsViewController?.allowsPushingPostDetails = true - controller?.navigationController?.pushViewController(commentsViewController!, animated: true) + commentsViewController.navigateToCommentID = commentID + commentsViewController.allowsPushingPostDetails = true + controller?.navigationController?.pushViewController(commentsViewController, animated: true) } - func displayStatsWithSiteID(_ siteID: NSNumber?) throws { - guard let blog = blogWithBlogID(siteID), blog.supports(.stats) else { + func displayStatsWithSiteID(_ siteID: NSNumber?, url: URL? = nil) throws { + guard let siteID = siteID, + let blog = Blog.lookup(withID: siteID, in: mainContext), + blog.supports(.stats) + else { throw DisplayError.missingParameter } + // Stats URLs should be of the form /stats/:time_period/:domain + if let url = url { + setTimePeriodForStatsURLIfPossible(url) + } + let statsViewController = StatsViewController() statsViewController.blog = blog controller?.navigationController?.pushViewController(statsViewController, animated: true) } + private func setTimePeriodForStatsURLIfPossible(_ url: URL) { + guard let siteID = SiteStatsInformation.sharedInstance.siteID?.intValue else { + return + } + + let matcher = RouteMatcher(routes: UniversalLinkRouter.statsRoutes) + let matches = matcher.routesMatching(url) + if let match = matches.first, + let action = match.action as? StatsRoute, + let timePeriod = action.timePeriod { + // Initializing a StatsPeriodType to ensure we have a valid period + let key = SiteStatsDashboardViewController.lastSelectedStatsPeriodTypeKey(forSiteID: siteID) + UserPersistentStoreFactory.instance().set(timePeriod.rawValue, forKey: key) + } + } + + func displayBackupWithSiteID(_ siteID: NSNumber?) throws { + guard let siteID = siteID, + let blog = Blog.lookup(withID: siteID, in: mainContext), + let backupListViewController = BackupListViewController.withJPBannerForBlog(blog) + else { + throw DisplayError.missingParameter + } + + controller?.navigationController?.pushViewController(backupListViewController, animated: true) + } + + func displayScanWithSiteID(_ siteID: NSNumber?) throws { + guard let siteID = siteID, + let blog = Blog.lookup(withID: siteID, in: mainContext), + blog.isScanAllowed() + else { + throw DisplayError.missingParameter + } + + let scanViewController = JetpackScanViewController.withJPBannerForBlog(blog) + controller?.navigationController?.pushViewController(scanViewController, animated: true) + } + func displayFollowersWithSiteID(_ siteID: NSNumber?, expirationTime: TimeInterval) throws { - guard let blog = blogWithBlogID(siteID) else { + guard let siteID = siteID, + let blog = Blog.lookup(withID: siteID, in: mainContext) + else { throw DisplayError.missingParameter } - let service = BlogService(managedObjectContext: mainContext) - SiteStatsInformation.sharedInstance.siteTimeZone = service.timeZone(for: blog) + SiteStatsInformation.sharedInstance.siteTimeZone = blog.timeZone SiteStatsInformation.sharedInstance.oauth2Token = blog.authToken SiteStatsInformation.sharedInstance.siteID = blog.dotComID @@ -83,13 +134,13 @@ struct DefaultContentCoordinator: ContentCoordinator { controller?.navigationController?.pushViewController(browseViewController, animated: true) } - func displayWebViewWithURL(_ url: URL) { + func displayWebViewWithURL(_ url: URL, source: String) { if UniversalLinkRouter(routes: UniversalLinkRouter.readerRoutes).canHandle(url: url) { - UniversalLinkRouter(routes: UniversalLinkRouter.readerRoutes).handle(url: url, source: controller) + UniversalLinkRouter(routes: UniversalLinkRouter.readerRoutes).handle(url: url, source: .inApp(presenter: controller)) return } - let webViewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url) + let webViewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url, source: source) let navController = UINavigationController(rootViewController: webViewController) controller?.present(navController, animated: true) } @@ -110,20 +161,9 @@ struct DefaultContentCoordinator: ContentCoordinator { } private func jetpackSiteReff(with slug: String) -> JetpackSiteRef? { - let service = BlogService(managedObjectContext: mainContext) - guard let blog = service.blog(byHostname: slug), let jetpack = JetpackSiteRef(blog: blog) else { + guard let blog = Blog.lookup(hostname: slug, in: mainContext), let jetpack = JetpackSiteRef(blog: blog) else { return nil } return jetpack } - - private func blogWithBlogID(_ blogID: NSNumber?) -> Blog? { - guard let blogID = blogID else { - return nil - } - - let service = BlogService(managedObjectContext: mainContext) - return service.blog(byBlogId: blogID) - } - } diff --git a/WordPress/Classes/Utility/ContextManager+ErrorHandling.swift b/WordPress/Classes/Utility/ContextManager+ErrorHandling.swift index 73f9ebc0365a..9d73ee139845 100644 --- a/WordPress/Classes/Utility/ContextManager+ErrorHandling.swift +++ b/WordPress/Classes/Utility/ContextManager+ErrorHandling.swift @@ -56,7 +56,30 @@ private extension NSExceptionName { } extension ContextManager { - @objc(handleSaveError:inContext:) + + func internalSave(_ context: NSManagedObjectContext) { + guard context.hasChanges else { + return + } + + let inserted = Array(context.insertedObjects) + do { + try context.obtainPermanentIDs(for: inserted) + } catch { + DDLogError("Error obtaining permanent object IDs for \(inserted), \(error)") + } + + do { + try context.save() + } catch { + handleSaveError(error as NSError, in: context) + } + } + +} + +private extension ContextManager { + func handleSaveError(_ error: NSError, in context: NSManagedObjectContext) { let isMainContext = context == mainContext let exceptionName: NSExceptionName = isMainContext ? .coreDataSaveMainException : .coreDataSaveDerivedException @@ -68,9 +91,7 @@ extension ContextManager { let exception = NSException(name: exceptionName, reason: reason, userInfo: nil) exception.raise() } -} -private extension ContextManager { func reasonForError(_ error: NSError) -> String { if error.code == NSValidationMultipleErrorsError { guard let errors = error.userInfo[NSDetailedErrorsKey] as? [NSError] else { diff --git a/WordPress/Classes/Utility/ContextManager-Internals.h b/WordPress/Classes/Utility/ContextManager-Internals.h deleted file mode 100644 index e915dbd7abe1..000000000000 --- a/WordPress/Classes/Utility/ContextManager-Internals.h +++ /dev/null @@ -1,7 +0,0 @@ -#import "ContextManager.h" - -@interface ContextManager () - -+ (void)overrideSharedInstance:(ContextManager *)contextManager; - -@end \ No newline at end of file diff --git a/WordPress/Classes/Utility/ContextManager.h b/WordPress/Classes/Utility/ContextManager.h deleted file mode 100644 index 8b884ecda456..000000000000 --- a/WordPress/Classes/Utility/ContextManager.h +++ /dev/null @@ -1,109 +0,0 @@ -#import <Foundation/Foundation.h> -#import <CoreData/CoreData.h> - -NS_ASSUME_NONNULL_BEGIN - -@interface ContextManager : NSObject - -///---------------------------------------------- -///@name Persistent Contexts -/// -/// The mainContext has concurrency type NSMainQueueConcurrencyType and should be used -/// for UI elements and fetched results controllers. -/// Internally, we'll use a privateQueued context to perform disk write Operations. -/// -///---------------------------------------------- -@property (nonatomic, readonly, strong) NSManagedObjectContext *mainContext; - -///------------------------------------------------------------- -///@name Access to the persistent store and managed object model -///------------------------------------------------------------- -@property (nonatomic, readonly, strong) NSPersistentStoreCoordinator *persistentStoreCoordinator; -@property (nonatomic, readonly, strong) NSManagedObjectModel *managedObjectModel; - -///-------------------------------------- -///@name ContextManager -///-------------------------------------- - -/** - Returns the singleton - - @return instance of ContextManager -*/ -+ (instancetype)sharedInstance; - - -///-------------------------- -///@name Contexts -///-------------------------- - -/** - For usage as a 'scratch pad' context or for doing background work. - - Make sure to save using saveDerivedContext: - - @return a new MOC with NSPrivateQueueConcurrencyType, - with the parent context as the main context -*/ -- (NSManagedObjectContext *const)newDerivedContext; - -/** - For usage as a snapshot of the main context. This is useful when operations - should happen on the main queue (fetches) but not immedately reflect changes to - the main context. - - Make sure to save using saveContext: - - @return a new MOC with NSMainQueueConcurrencyType, - with the parent context as the main context - */ -- (NSManagedObjectContext *const)newMainContextChildContext; - -/** - Save a given context synchronously. - - @param a NSManagedObject context instance - */ -- (void)saveContextAndWait:(NSManagedObjectContext *)context; - -/** - Save a given context. Convenience for error handling. - - @param a NSManagedObject context instance - */ -- (void)saveContext:(NSManagedObjectContext *)context; - -/** - Save a given context. - - @param a NSManagedObject context instance - @param a completion block that will be executed on the main queue - */ -- (void)saveContext:(NSManagedObjectContext *)context withCompletionBlock:(void (^)(void))completionBlock; - -/** - Get a permanent NSManagedObjectID for the specified NSManagedObject - - @param managedObject A managedObject with a temporary NSManagedObjectID - @return YES if the permanentID was successfully obtained, or NO if it failed. - */ -- (BOOL)obtainPermanentIDForObject:(NSManagedObject *)managedObject; - -/** - Merge changes for a given context with a fault-protection, on the context's queue. - - @param context a NSManagedObject context instance - @return notification NSNotification from a NSManagedObjectContextDidSaveNotification. - */ -- (void)mergeChanges:(NSManagedObjectContext *)context fromContextDidSaveNotification:(NSNotification *)notification; - -/** - Verify if the Core Data model migration failed. - - @return YES if there were any errors during the migration: the PSC instance is mapping to a fresh database. - */ -- (BOOL)didMigrationFail; - -@end - -NS_ASSUME_NONNULL_END diff --git a/WordPress/Classes/Utility/ContextManager.m b/WordPress/Classes/Utility/ContextManager.m deleted file mode 100644 index 13941fd5e335..000000000000 --- a/WordPress/Classes/Utility/ContextManager.m +++ /dev/null @@ -1,386 +0,0 @@ -#import "ContextManager.h" -#import "ContextManager-Internals.h" -#import "ALIterativeMigrator.h" -#import "WordPress-Swift.h" -@import WordPressShared.WPAnalytics; - -#define SentryStartupEventAddError(event, error) [event addError:error file:__FILE__ function:__FUNCTION__ line:__LINE__] - -// MARK: - Static Variables -// -static ContextManager *_instance; -static ContextManager *_override; - - -// MARK: - Private Properties -// -@interface ContextManager () - -@property (nonatomic, strong) NSPersistentStoreCoordinator *persistentStoreCoordinator; -@property (nonatomic, strong) NSManagedObjectModel *managedObjectModel; -@property (nonatomic, strong) NSManagedObjectContext *mainContext; -@property (nonatomic, strong) NSManagedObjectContext *writerContext; -@property (nonatomic, assign) BOOL migrationFailed; - -@end - - -// MARK: - ContextManager -// -@implementation ContextManager - -- (instancetype)init -{ - self = [super init]; - if (self) { - [self startListeningToMainContextNotifications]; - } - - return self; -} - -+ (instancetype)sharedInstance -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - _instance = [[ContextManager alloc] init]; - }); - - return _override ?: _instance; -} - -+ (void)overrideSharedInstance:(ContextManager *)contextManager -{ - [ContextManager sharedInstance]; - _override = contextManager; -} - - -#pragma mark - Contexts - -- (NSManagedObjectContext *const)newDerivedContext -{ - return [self newChildContextWithConcurrencyType:NSPrivateQueueConcurrencyType]; -} - -- (NSManagedObjectContext *const)newMainContextChildContext -{ - return [self newChildContextWithConcurrencyType:NSMainQueueConcurrencyType]; -} - -- (NSManagedObjectContext *const)writerContext -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSPrivateQueueConcurrencyType]; - context.persistentStoreCoordinator = self.persistentStoreCoordinator; - self.writerContext = context; - }); - - return _writerContext; -} - -- (NSManagedObjectContext *const)mainContext -{ - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - NSManagedObjectContext *context = [[NSManagedObjectContext alloc] initWithConcurrencyType:NSMainQueueConcurrencyType]; - context.parentContext = self.writerContext; - self.mainContext = context; - [[[NullBlogPropertySanitizer alloc] initWithContext:context] sanitize]; - }); - - return _mainContext; -} - -- (NSManagedObjectContext *const)newChildContextWithConcurrencyType:(NSManagedObjectContextConcurrencyType)concurrencyType -{ - NSManagedObjectContext *childContext = [[NSManagedObjectContext alloc] - initWithConcurrencyType:concurrencyType]; - childContext.parentContext = self.mainContext; - childContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy; - - return childContext; -} - - -#pragma mark - Context Saving and Merging - -- (void)saveContextAndWait:(NSManagedObjectContext *)context -{ - [self saveContext:context andWait:YES withCompletionBlock:nil]; -} - -- (void)saveContext:(NSManagedObjectContext *)context -{ - [self saveContext:context andWait:NO withCompletionBlock:nil]; -} - -- (void)saveContext:(NSManagedObjectContext *)context withCompletionBlock:(void (^)(void))completionBlock -{ - [self saveContext:context andWait:NO withCompletionBlock:completionBlock]; -} - - -- (void)saveContext:(NSManagedObjectContext *)context andWait:(BOOL)wait withCompletionBlock:(void (^)(void))completionBlock -{ - // Save derived contexts a little differently - if (context.parentContext == self.mainContext) { - [self saveDerivedContext:context andWait:wait withCompletionBlock:completionBlock]; - return; - } - - if (wait) { - [context performBlockAndWait:^{ - [self internalSaveContext:context withCompletionBlock:completionBlock]; - }]; - } else { - [context performBlock:^{ - [self internalSaveContext:context withCompletionBlock:completionBlock]; - }]; - } -} - -- (void)saveDerivedContext:(NSManagedObjectContext *)context andWait:(BOOL)wait withCompletionBlock:(void (^)(void))completionBlock -{ - if (wait) { - [context performBlockAndWait:^{ - [self internalSaveContext:context]; - [self saveContext:self.mainContext andWait:wait withCompletionBlock:completionBlock]; - }]; - } else { - [context performBlock:^{ - [self internalSaveContext:context]; - [self saveContext:self.mainContext andWait:wait withCompletionBlock:completionBlock]; - }]; - } -} - -- (void)internalSaveContext:(NSManagedObjectContext *)context withCompletionBlock:(void (^)(void))completionBlock -{ - [self internalSaveContext:context]; - - if (completionBlock) { - dispatch_async(dispatch_get_main_queue(), completionBlock); - } -} - -- (BOOL)obtainPermanentIDForObject:(NSManagedObject *)managedObject -{ - // Failsafe - if (!managedObject) { - return NO; - } - - if (managedObject && ![managedObject.objectID isTemporaryID]) { - // Object already has a permanent ID so just return success. - return YES; - } - - NSError *error; - if (![managedObject.managedObjectContext obtainPermanentIDsForObjects:@[managedObject] error:&error]) { - DDLogError(@"Error obtaining permanent object ID for %@, %@", managedObject, error); - return NO; - } - return YES; -} - -- (void)mergeChanges:(NSManagedObjectContext *)context fromContextDidSaveNotification:(NSNotification *)notification -{ - [context performBlock:^{ - // Fault-in updated objects before a merge to avoid any internal inconsistency errors later. - // Based on old solution referenced here: http://www.mlsite.net/blog/?p=518 - NSSet* updates = [notification.userInfo objectForKey:NSUpdatedObjectsKey]; - for (NSManagedObject *object in updates) { - NSManagedObject *objectInContext = [context existingObjectWithID:object.objectID error:nil]; - if ([objectInContext isFault]) { - // Force a fault-in of the object's key-values - [objectInContext willAccessValueForKey:nil]; - } - } - // Continue with the merge - [context mergeChangesFromContextDidSaveNotification:notification]; - }]; -} - -- (BOOL)didMigrationFail -{ - return _migrationFailed; -} - - -#pragma mark - Setup - -- (NSManagedObjectModel *)managedObjectModel -{ - if (_managedObjectModel) { - return _managedObjectModel; - } - NSString *modelPath = [self modelPath]; - NSURL *modelURL = [NSURL fileURLWithPath:modelPath]; - _managedObjectModel = [[NSManagedObjectModel alloc] initWithContentsOfURL:modelURL]; - return _managedObjectModel; -} - -- (NSPersistentStoreCoordinator *)persistentStoreCoordinator -{ - if (_persistentStoreCoordinator) { - return _persistentStoreCoordinator; - } - - [self migrateDataModelsIfNecessary]; - - // Attempt to open the store - _migrationFailed = NO; - - NSURL *storeURL = self.storeURL; - - // This is important for automatic version migration. Leave it here! - NSDictionary *options = @{ - NSInferMappingModelAutomaticallyOption : @(YES), - NSMigratePersistentStoresAutomaticallyOption : @(YES) - }; - - NSError *error = nil; - SentryStartupEvent *startupEvent = [SentryStartupEvent new]; - _persistentStoreCoordinator = [[NSPersistentStoreCoordinator alloc] - initWithManagedObjectModel:[self managedObjectModel]]; - - if (![_persistentStoreCoordinator addPersistentStoreWithType:NSSQLiteStoreType - configuration:nil - URL:storeURL - options:options - error:&error]) { - DDLogError(@"Error opening the database. %@\nDeleting the file and trying again", error); - - SentryStartupEventAddError(startupEvent, error); - error = nil; - - _migrationFailed = YES; - - // make a backup of the old database - [[NSFileManager defaultManager] copyItemAtPath:storeURL.path - toPath:[storeURL.path stringByAppendingString:@"~"] - error:&error]; - - if (error != nil) { - SentryStartupEventAddError(startupEvent, error); - error = nil; - } - - // delete the sqlite file and try again - [[NSFileManager defaultManager] removeItemAtPath:storeURL.path error:&error]; - - if (error != nil) { - SentryStartupEventAddError(startupEvent, error); - error = nil; - } - - if (![_persistentStoreCoordinator addPersistentStoreWithType:NSSQLiteStoreType - configuration:nil - URL:storeURL - options:nil - error:&error]) { - - SentryStartupEventAddError(startupEvent, error); - [startupEvent sendWithTitle:@"Can't initialize Core Data stack"]; - - @throw [NSException exceptionWithName:@"Can't initialize Core Data stack" - reason:[error localizedDescription] - userInfo:[error userInfo]]; - } - } - - return _persistentStoreCoordinator; -} - - -#pragma mark - Notification Helpers - -- (void)startListeningToMainContextNotifications -{ - NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; - [nc addObserver:self selector:@selector(mainContextDidSave:) name:NSManagedObjectContextDidSaveNotification object:self.mainContext]; -} - -- (void)mainContextDidSave:(NSNotification *)notification -{ - // Defer I/O to a BG Writer Context. Simperium 4ever! - // - [self.writerContext performBlock:^{ - [self internalSaveContext:self.writerContext]; - }]; -} - - -#pragma mark - Private Helpers - -- (void)internalSaveContext:(NSManagedObjectContext *)context -{ - NSParameterAssert(context); - - NSError *error; - if (![context obtainPermanentIDsForObjects:context.insertedObjects.allObjects error:&error]) { - DDLogError(@"Error obtaining permanent object IDs for %@, %@", context.insertedObjects.allObjects, error); - } - - if ([context hasChanges] && ![context save:&error]) { - [self handleSaveError:error inContext:context]; - } -} - -- (void)migrateDataModelsIfNecessary -{ - if (![[NSFileManager defaultManager] fileExistsAtPath:[[self storeURL] path]]) { - DDLogInfo(@"No store exists at URL %@. Skipping migration.", [self storeURL]); - return; - } - - NSDictionary *metadata = [NSPersistentStoreCoordinator metadataForPersistentStoreOfType:NSSQLiteStoreType - URL:[self storeURL] - options:nil - error:nil]; - BOOL migrationNeeded = ![self.managedObjectModel isConfiguration:nil compatibleWithStoreMetadata:metadata]; - - if (migrationNeeded) { - DDLogWarn(@"Migration required for persistent store."); - NSError *error = nil; - NSArray *sortedModelNames = [self sortedModelNames]; - BOOL migrateResult = [ALIterativeMigrator iterativeMigrateURL:[self storeURL] - ofType:NSSQLiteStoreType - toModel:self.managedObjectModel - orderedModelNames:sortedModelNames - error:&error]; - if (!migrateResult || error != nil) { - DDLogError(@"Unable to migrate store: %@", error); - } - } -} - -- (NSArray *)sortedModelNames -{ - NSString *modelPath = [self modelPath]; - NSString *versionPath = [modelPath stringByAppendingPathComponent:@"VersionInfo.plist"]; - NSDictionary *versionInfo = [NSDictionary dictionaryWithContentsOfFile:versionPath]; - NSArray *modelNames = [[versionInfo[@"NSManagedObjectModel_VersionHashes"] allKeys] sortedArrayUsingComparator:^NSComparisonResult(id obj1, id obj2) { - return [obj1 compare:obj2 options:NSNumericSearch]; - }]; - - return modelNames; -} - -- (NSURL *)storeURL -{ - NSString *documentsDirectory = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, - NSUserDomainMask, - YES) lastObject]; - - return [NSURL fileURLWithPath:[documentsDirectory stringByAppendingPathComponent:@"WordPress.sqlite"]]; -} - -- (NSString *)modelPath -{ - return [[NSBundle mainBundle] pathForResource:@"WordPress" ofType:@"momd"]; -} - -@end diff --git a/WordPress/Classes/Utility/ContextManager.swift b/WordPress/Classes/Utility/ContextManager.swift new file mode 100644 index 000000000000..e8b5315c7edc --- /dev/null +++ b/WordPress/Classes/Utility/ContextManager.swift @@ -0,0 +1,372 @@ +import Foundation +import CoreData + +/// A constant representing the current version of the data model. +/// +/// - SeeAlso: ContextManager.init(modelName:store:) +let ContextManagerModelNameCurrent = "$CURRENT" + +public protocol CoreDataStackSwift: CoreDataStack { + + /// Execute the given block with a background context and save the changes. + /// + /// This function _does not block_ its running thread. The block is executed in background and its return value + /// is passed onto the `completion` block which is executed on the given `queue`. + /// + /// - Parameters: + /// - block: A closure which uses the given `NSManagedObjectContext` to make Core Data model changes. + /// - completion: A closure which is called with the return value of the `block`, after the changed made + /// by the `block` is saved. + /// - queue: A queue on which to execute the completion block. + func performAndSave<T>(_ block: @escaping (NSManagedObjectContext) -> T, completion: ((T) -> Void)?, on queue: DispatchQueue) + + /// Execute the given block with a background context and save the changes _if the block does not throw an error_. + /// + /// This function _does not block_ its running thread. The block is executed in background and the return value + /// (or an error) is passed onto the `completion` block which is executed on the given `queue`. + /// + /// - Parameters: + /// - block: A closure that uses the given `NSManagedObjectContext` to make Core Data model changes. The changes + /// are only saved if the block does not throw an error. + /// - completion: A closure which is called with the `block`'s execution result, which is either an error thrown + /// by the `block` or the return value of the `block`. + /// - queue: A queue on which to execute the completion block. + func performAndSave<T>(_ block: @escaping (NSManagedObjectContext) throws -> T, completion: ((Result<T, Error>) -> Void)?, on queue: DispatchQueue) + + /// Execute the given block with a background context and save the changes _if the block does not throw an error_. + /// + /// - Parameter block: A closure that uses the given `NSManagedObjectContext` to make Core Data model changes. + /// The changes are only saved if the block does not throw an error. + /// - Returns: The value returned by the `block` + /// - Throws: The error thrown by the `block`, in which case the Core Data changes made by the `block` is discarded. + func performAndSave<T>(_ block: @escaping (NSManagedObjectContext) throws -> T) async throws -> T + +} + +@objc +public class ContextManager: NSObject, CoreDataStack, CoreDataStackSwift { + static var inMemoryStoreURL: URL { + URL(fileURLWithPath: "/dev/null") + } + + private let modelName: String + private let storeURL: URL + private let persistentContainer: NSPersistentContainer + + /// A serial queue used to ensure there is only one writing operation at a time. + /// + /// - Note: This queue currently is not used in `performAndSave(_:)` the "save synchronously" function, since it's + /// not safe to block current thread. Considering the aforementioned `performAndSave(_:)` function is going to be + /// removed soon, I think it's okay to make this compromise. + private let writerQueue: OperationQueue + + @objc + public var mainContext: NSManagedObjectContext { + persistentContainer.viewContext + } + + convenience override init() { + self.init(modelName: ContextManagerModelNameCurrent, store: Self.localDatabasePath) + } + + /// Create a ContextManager instance with given model name and database location. + /// + /// Note: This initialiser is only used for testing purpose at the moment. + /// + /// - Parameters: + /// - modelName: Model name in Core Data data model file. + /// Use ContextManagerModelNameCurrent for current version, or + /// "WordPress <version>" for specific version. + /// - store: Database location. Use `ContextManager.inMemoryStoreURL` to create an in-memory database. + init(modelName: String, store storeURL: URL) { + assert(modelName == ContextManagerModelNameCurrent || modelName.hasPrefix("WordPress ")) + assert(storeURL.isFileURL) + + self.modelName = modelName + self.storeURL = storeURL + self.persistentContainer = Self.createPersistentContainer(storeURL: storeURL, modelName: modelName) + self.writerQueue = OperationQueue() + self.writerQueue.name = "org.wordpress.CoreDataStack.writer" + self.writerQueue.maxConcurrentOperationCount = 1 + + super.init() + + mainContext.automaticallyMergesChangesFromParent = true + mainContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + NullBlogPropertySanitizer(context: mainContext).sanitize() + } + + public func newDerivedContext() -> NSManagedObjectContext { + let context = persistentContainer.newBackgroundContext() + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + return context + } + + @objc(performAndSaveUsingBlock:) + public func performAndSave(_ block: @escaping (NSManagedObjectContext) -> Void) { + let context = newDerivedContext() + context.performAndWait { + block(context) + + self.save(context, .alreadyInContextQueue) + } + } + + @objc(performAndSaveUsingBlock:completion:onQueue:) + public func performAndSave(_ block: @escaping (NSManagedObjectContext) -> Void, completion: (() -> Void)?, on queue: DispatchQueue) { + let context = newDerivedContext() + self.writerQueue.addOperation(AsyncBlockOperation { done in + context.perform { + block(context) + + self.save(context, .alreadyInContextQueue) + queue.async { completion?() } + done() + } + }) + } + + public func performAndSave<T>(_ block: @escaping (NSManagedObjectContext) throws -> T, completion: ((Result<T, Error>) -> Void)?, on queue: DispatchQueue) { + let context = newDerivedContext() + self.writerQueue.addOperation(AsyncBlockOperation { done in + context.perform { + let result = Result(catching: { try block(context) }) + if case .success = result { + self.save(context, .alreadyInContextQueue) + } + queue.async { completion?(result) } + done() + } + }) + } + + public func performAndSave<T>(_ block: @escaping (NSManagedObjectContext) -> T, completion: ((T) -> Void)?, on queue: DispatchQueue) { + performAndSave( + block, + completion: { (result: Result<T, Error>) in + // It's safe to force-unwrap here, since the `block` does not throw an error. + completion?(try! result.get()) + }, + on: queue + ) + } + + public func performAndSave<T>(_ block: @escaping (NSManagedObjectContext) throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + performAndSave(block, completion: continuation.resume(with:), on: DispatchQueue.global()) + } + } + + @objc + public func saveContextAndWait(_ context: NSManagedObjectContext) { + save(context, .synchronously) + } + + @objc(saveContext:) + public func save(_ context: NSManagedObjectContext) { + save(context, .asynchronously) + } + + @objc(saveContext:withCompletionBlock:onQueue:) + public func save(_ context: NSManagedObjectContext, completion: (() -> Void)?, on queue: DispatchQueue) { + if let completion { + save(context, .asynchronouslyWithCallback(completion: completion, queue: queue)) + } else { + save(context, .asynchronously) + } + } + + static func migrateDataModelsIfNecessary(storeURL: URL, objectModel: NSManagedObjectModel) throws { + guard FileManager.default.fileExists(atPath: storeURL.path) else { + DDLogInfo("No store exists at \(storeURL). Skipping migration.") + return + } + + guard let metadata = try? NSPersistentStoreCoordinator.metadataForPersistentStore(ofType: NSSQLiteStoreType, at: storeURL), + !objectModel.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) + else { + return + } + + DDLogWarn("Migration required for persistent store.") + + guard let modelFileURL = Bundle.main.url(forResource: "WordPress", withExtension: "momd") else { + fatalError("Can't find WordPress.momd") + } + + guard let versionInfo = NSDictionary(contentsOf: modelFileURL.appendingPathComponent("VersionInfo.plist")) else { + fatalError("Can't get the object model's version info") + } + + guard let modelNames = (versionInfo["NSManagedObjectModel_VersionHashes"] as? [String: AnyObject])?.keys else { + fatalError("Can't parse the model versions") + } + + let sortedModelNames = modelNames.sorted { $0.compare($1, options: .numeric) == .orderedAscending } + try CoreDataIterativeMigrator.iterativeMigrate( + sourceStore: storeURL, + storeType: NSSQLiteStoreType, + to: objectModel, + using: sortedModelNames + ) + } +} + +// MARK: - Private methods + +private extension ContextManager { + static var localDatabasePath: URL { + guard let url = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { + fatalError("Failed to find the document directory") + } + + return url.appendingPathComponent("WordPress.sqlite") + } + + func save(_ context: NSManagedObjectContext, _ option: SaveContextOption) { + let block: () -> Void = { + self.internalSave(context) + + switch option { + case let .asynchronouslyWithCallback(completion, queue): + queue.async(execute: completion) + case .synchronously, .asynchronously, .alreadyInContextQueue: + // Do nothing + break + } + } + + // Ensure that the `context`'s concurrency type is not `confinementConcurrencyType`, since it will crash if `perform` or `performAndWait` is called. + guard context.concurrencyType == .mainQueueConcurrencyType || context.concurrencyType == .privateQueueConcurrencyType else { + block() + return + } + + switch option { + case .synchronously: + context.performAndWait(block) + case .asynchronously, .asynchronouslyWithCallback: + context.perform(block) + case .alreadyInContextQueue: + block() + } + } +} + +// MARK: - Initialise Core Data stack + +private extension ContextManager { + static func createPersistentContainer(storeURL: URL, modelName: String) -> NSPersistentContainer { + guard var modelFileURL = Bundle.main.url(forResource: "WordPress", withExtension: "momd") else { + fatalError("Can't find WordPress.momd") + } + + if modelName != ContextManagerModelNameCurrent { + modelFileURL = modelFileURL.appendingPathComponent(modelName).appendingPathExtension("mom") + } + + guard let objectModel = NSManagedObjectModel(contentsOf: modelFileURL) else { + fatalError("Can't create object model named \(modelName) at \(modelFileURL)") + } + + let startupEvent = SentryStartupEvent() + + do { + try migrateDataModelsIfNecessary(storeURL: storeURL, objectModel: objectModel) + } catch { + DDLogError("Unable to migrate store: \(error)") + startupEvent.add(error: error as NSError) + } + + let storeDescription = NSPersistentStoreDescription(url: storeURL) + storeDescription.shouldInferMappingModelAutomatically = true + storeDescription.shouldMigrateStoreAutomatically = true + let persistentContainer = NSPersistentContainer(name: "WordPress", managedObjectModel: objectModel) + persistentContainer.persistentStoreDescriptions = [storeDescription] + persistentContainer.loadPersistentStores { _, error in + guard let error else { + return + } + + DDLogError("Error opening the database. \(error)\nDeleting the file and trying again") + startupEvent.add(error: error) + + // make a backup of the old database + do { + try CoreDataIterativeMigrator.backupDatabase(at: storeURL) + } catch { + startupEvent.add(error: error) + } + + startupEvent.send(title: "Can't initialize Core Data stack") + objc_exception_throw( + NSException( + name: NSExceptionName(rawValue: "Can't initialize Core Data stack"), + reason: error.localizedDescription, + userInfo: (error as NSError).userInfo + ) + ) + } + + return persistentContainer + } + +} + +extension ContextManager { + private static let internalSharedInstance = ContextManager() + /// Tests purpose only + static var overrideInstance: ContextManager? + + @objc class func sharedInstance() -> ContextManager { + if let overrideInstance = overrideInstance { + return overrideInstance + } + + return ContextManager.internalSharedInstance + } + + static var shared: ContextManager { + return sharedInstance() + } +} + +private enum SaveContextOption { + case synchronously + case asynchronously + case asynchronouslyWithCallback(completion: () -> Void, queue: DispatchQueue) + case alreadyInContextQueue +} + +/// Use this temporary workaround to mitigate Core Data concurrency issues when accessing the given `object`. +/// +/// When the app is launched from Xcode, some code may crash due to the effect of the "com.apple.CoreData.ConcurrencyDebug" +/// launch argument. The crash indicates the crash site violates [the following rule](https://developer.apple.com/documentation/coredata/using_core_data_in_the_background#overview) +/// +/// > To use Core Data in a multithreaded environment, ensure that: +/// > - Managed objects retrieved from a context are bound to the same queue that the context is bound to. +/// +/// This function can be used as a temporary workaround to mitigate aforementioned crashes during development. +/// +/// - Warning: The workaround does not apply to release builds. In a release build, calling this function is exactly +/// the same as calling the given `closure` directly. +/// +/// - Warning: This function is _not_ a solution for Core Data concurrency issues, and should only be used as a +/// temporary solution, to avoid the Core Data concurrency issue becoming a blocker to feature developlement. +@available(*, deprecated, message: "This workaround is meant as a temporary solution to mitigate Core Data concurrency issues when accessing the `object`. Please see this function's API doc for details.") +@inlinable +public func workaroundCoreDataConcurrencyIssue<Value>(accessing object: NSManagedObject, _ closure: () -> Value) -> Value { +#if DEBUG + guard let context = object.managedObjectContext else { + fatalError("The object must be bound to a context: \(object)") + } + + var value: Value! + context.performAndWait { + value = closure() + } + return value +#else + return closure() +#endif /* DEBUG */ +} diff --git a/WordPress/Classes/Utility/CookieJar.swift b/WordPress/Classes/Utility/CookieJar.swift index cfe26fb8aedd..ec9ea86ce292 100644 --- a/WordPress/Classes/Utility/CookieJar.swift +++ b/WordPress/Classes/Utility/CookieJar.swift @@ -7,9 +7,11 @@ import WebKit @objc protocol CookieJar { func getCookies(url: URL, completion: @escaping ([HTTPCookie]) -> Void) func getCookies(completion: @escaping ([HTTPCookie]) -> Void) - func hasCookie(url: URL, username: String, completion: @escaping (Bool) -> Void) + func hasWordPressSelfHostedAuthCookie(for url: URL, username: String, completion: @escaping (Bool) -> Void) + func hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) func removeCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) func removeWordPressComCookies(completion: @escaping () -> Void) + func setCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) } // As long as CookieJar is @objc, we can't have shared methods in protocol @@ -27,11 +29,17 @@ protocol CookieJarSharedImplementation: CookieJar { } extension CookieJarSharedImplementation { - func _hasCookie(url: URL, username: String, completion: @escaping (Bool) -> Void) { + func _hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { + let url = URL(string: "https://wordpress.com/")! + + return _hasWordPressAuthCookie(for: url, username: username, atomicSite: atomicSite, completion: completion) + } + + func _hasWordPressAuthCookie(for url: URL, username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { getCookies(url: url) { (cookies) in let cookie = cookies .contains(where: { cookie in - return cookie.isWordPressLoggedIn(username: username) + return cookie.isWordPressLoggedIn(username: username, atomic: atomicSite) }) completion(cookie) @@ -54,8 +62,12 @@ extension HTTPCookieStorage: CookieJarSharedImplementation { completion(cookies ?? []) } - func hasCookie(url: URL, username: String, completion: @escaping (Bool) -> Void) { - _hasCookie(url: url, username: username, completion: completion) + func hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { + _hasWordPressComAuthCookie(username: username, atomicSite: atomicSite, completion: completion) + } + + func hasWordPressSelfHostedAuthCookie(for url: URL, username: String, completion: @escaping (Bool) -> Void) { + _hasWordPressAuthCookie(for: url, username: username, atomicSite: false, completion: completion) } func removeCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { @@ -66,6 +78,14 @@ extension HTTPCookieStorage: CookieJarSharedImplementation { func removeWordPressComCookies(completion: @escaping () -> Void) { _removeWordPressComCookies(completion: completion) } + + func setCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { + for cookie in cookies { + setCookie(cookie) + } + + completion() + } } extension WKHTTPCookieStore: CookieJarSharedImplementation { @@ -107,8 +127,12 @@ extension WKHTTPCookieStore: CookieJarSharedImplementation { getAllCookies(completion) } - func hasCookie(url: URL, username: String, completion: @escaping (Bool) -> Void) { - _hasCookie(url: url, username: username, completion: completion) + func hasWordPressComAuthCookie(username: String, atomicSite: Bool, completion: @escaping (Bool) -> Void) { + _hasWordPressComAuthCookie(username: username, atomicSite: atomicSite, completion: completion) + } + + func hasWordPressSelfHostedAuthCookie(for url: URL, username: String, completion: @escaping (Bool) -> Void) { + _hasWordPressAuthCookie(for: url, username: username, atomicSite: false, completion: completion) } func removeCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { @@ -130,6 +154,18 @@ extension WKHTTPCookieStore: CookieJarSharedImplementation { func removeWordPressComCookies(completion: @escaping () -> Void) { _removeWordPressComCookies(completion: completion) } + + func setCookies(_ cookies: [HTTPCookie], completion: @escaping () -> Void) { + guard let cookie = cookies.last else { + return completion() + } + + DispatchQueue.main.async { + self.setCookie(cookie) { [weak self] in + self?.setCookies(cookies.dropLast(), completion: completion) + } + } + } } #if DEBUG @@ -149,13 +185,28 @@ extension WKHTTPCookieStore: CookieJarSharedImplementation { } #endif +private let atomicLoggedInCookieNamePrefix = "wordpress_logged_in_" private let loggedInCookieName = "wordpress_logged_in" + private extension HTTPCookie { - func isWordPressLoggedIn(username: String) -> Bool { - return name == loggedInCookieName + func isWordPressLoggedIn(username: String, atomic: Bool) -> Bool { + guard !atomic else { + return isWordPressLoggedInAtomic(username: username) + } + + return isWordPressLoggedIn(username: username) + } + + private func isWordPressLoggedIn(username: String) -> Bool { + return name.hasPrefix(loggedInCookieName) && value.components(separatedBy: "%").first == username } + private func isWordPressLoggedInAtomic(username: String) -> Bool { + return name.hasPrefix(atomicLoggedInCookieNamePrefix) + && value.components(separatedBy: "|").first == username + } + func matches(url: URL) -> Bool { guard let host = url.host else { return false diff --git a/WordPress/Classes/Utility/CoreDataHelper.swift b/WordPress/Classes/Utility/CoreDataHelper.swift index 4ccf28843d58..00b6bbe948ea 100644 --- a/WordPress/Classes/Utility/CoreDataHelper.swift +++ b/WordPress/Classes/Utility/CoreDataHelper.swift @@ -1,5 +1,6 @@ import Foundation import CocoaLumberjack +import CoreData // MARK: - NSManagedObject Default entityName Helper // @@ -169,5 +170,174 @@ extension NSPersistentStoreCoordinator { } return result } +} + + +// MARK: - ContextManager Helpers +extension ContextManager { + enum ContextManagerError: Error { + case missingCoordinatorOrStore + case missingDatabase + } +} + +extension CoreDataStack { + /// Perform a query using the `mainContext` and return the result. + func performQuery<T>(_ block: @escaping (NSManagedObjectContext) -> T) -> T { + var value: T! = nil + self.mainContext.performAndWait { + value = block(self.mainContext) + } + return value + } + + // MARK: - Database Migration + + /// Creates a copy of the current open store and saves it to the specified destination + /// - Parameter backupLocation: Location to save the store copy to + func createStoreCopy(to backupLocation: URL) throws { + try? removeBackupData(from: backupLocation) + guard let storeCoordinator = mainContext.persistentStoreCoordinator, + let store = storeCoordinator.persistentStores.first else { + throw ContextManager.ContextManagerError.missingCoordinatorOrStore + } + + let model = storeCoordinator.managedObjectModel + let storeCoordinatorCopy = NSPersistentStoreCoordinator(managedObjectModel: model) + var storeOptions = store.options + storeOptions?[NSReadOnlyPersistentStoreOption] = true + let storeCopy = try storeCoordinatorCopy.addPersistentStore(ofType: store.type, + configurationName: store.configurationName, + at: store.url, + options: storeOptions) + try storeCoordinatorCopy.migratePersistentStore(storeCopy, + to: backupLocation, + withType: storeCopy.type) + } + + /// Removes any copy of the store from the backup location. + /// - Parameter backupLocation: Where the backup store is located. + func removeBackupData(from location: URL) throws { + let (backupLocation, shmLocation, walLocation) = databaseFiles(for: location) + try FileManager.default.removeItem(at: backupLocation) + try FileManager.default.removeItem(at: shmLocation) + try FileManager.default.removeItem(at: walLocation) + } + + /// Replaces the current active store with the database at the specified location. + /// + /// The following steps are performed: + /// - Remove the current store from the store coordinator. + /// - Create a backup of the current database. + /// - Copy the source database over the current database. If this fails, restore the backup files. + /// - Attempt to re-add the store with the new database or original database if the copy failed. If adding the new store fails, restore the backup and try to re-add the old store. + /// - Finally, remove all the backup files and source database if everything was successful. + /// + /// **Warning: This is destructive towards the active database. It will be overwritten on success.** + /// - Parameter databaseLocation: Database to overwrite the current one with + func restoreStoreCopy(from databaseLocation: URL) throws { + guard let storeCoordinator = mainContext.persistentStoreCoordinator, + let store = storeCoordinator.persistentStores.first else { + throw ContextManager.ContextManagerError.missingCoordinatorOrStore + } + + let (databaseLocation, shmLocation, walLocation) = databaseFiles(for: databaseLocation) + + guard let currentDatabaseLocation = store.url, + FileManager.default.fileExists(atPath: databaseLocation.path), + FileManager.default.fileExists(atPath: shmLocation.path), + FileManager.default.fileExists(atPath: walLocation.path) else { + throw ContextManager.ContextManagerError.missingDatabase + } + + try? migrateDatabaseIfNecessary(at: databaseLocation) + mainContext.reset() + try storeCoordinator.remove(store) + let databaseReplaced = replaceDatabase(from: databaseLocation, to: currentDatabaseLocation) + + do { + let options = [NSMigratePersistentStoresAutomaticallyOption: true, + NSInferMappingModelAutomaticallyOption: true] + try storeCoordinator.addPersistentStore(ofType: NSSQLiteStoreType, + configurationName: nil, + at: currentDatabaseLocation, + options: options) + + if databaseReplaced { + // The database was replaced successfully and the store added with no errors so we + // can remove the source database & backup files + let (databaseBackup, shmBackup, walBackup) = backupFiles(for: currentDatabaseLocation) + try? FileManager.default.removeItem(at: databaseLocation) + try? FileManager.default.removeItem(at: shmLocation) + try? FileManager.default.removeItem(at: walLocation) + try? FileManager.default.removeItem(at: databaseBackup) + try? FileManager.default.removeItem(at: shmBackup) + try? FileManager.default.removeItem(at: walBackup) + } + } catch { + // Re-adding the store failed for some reason, attempt to restore the backup + // and use that store instead. We re-throw the error so that the caller can + // attempt to handle the error + restoreDatabaseBackup(at: currentDatabaseLocation) + _ = try? storeCoordinator.addPersistentStore(ofType: NSSQLiteStoreType, + configurationName: nil, + at: currentDatabaseLocation) + throw error + } + } + + private func databaseFiles(for database: URL) -> (database: URL, shm: URL, wal: URL) { + let shmFile = URL(string: database.absoluteString.appending("-shm"))! + let walFile = URL(string: database.absoluteString.appending("-wal"))! + return (database, shmFile, walFile) + } + + private func backupFiles(for database: URL) -> (database: URL, shm: URL, wal: URL) { + let (database, shmFile, walFile) = databaseFiles(for: database) + let databaseBackup = database.appendingPathExtension("backup") + let shmBackup = shmFile.appendingPathExtension("backup") + let walBackup = walFile.appendingPathExtension("backup") + return (databaseBackup, shmBackup, walBackup) + } + + private func replaceDatabase(from source: URL, to destination: URL) -> Bool { + let (source, sourceShm, sourceWal) = databaseFiles(for: source) + let (destination, destinationShm, destinationWal) = databaseFiles(for: destination) + let (databaseBackup, shmBackup, walBackup) = backupFiles(for: destination) + + do { + try FileManager.default.copyItem(at: destination, to: databaseBackup) + try FileManager.default.copyItem(at: destinationShm, to: shmBackup) + try FileManager.default.copyItem(at: destinationWal, to: walBackup) + try FileManager.default.removeItem(at: destination) + try FileManager.default.removeItem(at: destinationShm) + try FileManager.default.removeItem(at: destinationWal) + try FileManager.default.copyItem(at: source, to: destination) + try FileManager.default.copyItem(at: sourceShm, to: destinationShm) + try FileManager.default.copyItem(at: sourceWal, to: destinationWal) + return true + } catch { + // Attempt to restore backup files. Some might not exist depending on where the process failed + DDLogError("Error when replacing database: \(error)") + restoreDatabaseBackup(at: destination) + return false + } + } + + private func restoreDatabaseBackup(at location: URL) { + let (location, locationShm, locationWal) = databaseFiles(for: location) + let (databaseBackup, shmBackup, walBackup) = backupFiles(for: location) + _ = try? FileManager.default.replaceItemAt(location, withItemAt: databaseBackup) + _ = try? FileManager.default.replaceItemAt(locationShm, withItemAt: shmBackup) + _ = try? FileManager.default.replaceItemAt(locationWal, withItemAt: walBackup) + } + + private func migrateDatabaseIfNecessary(at databaseLocation: URL) throws { + guard let modelFileURL = Bundle.main.url(forResource: "WordPress", withExtension: "momd"), + let objectModel = NSManagedObjectModel(contentsOf: modelFileURL) else { + return + } + try ContextManager.migrateDataModelsIfNecessary(storeURL: databaseLocation, objectModel: objectModel) + } } diff --git a/WordPress/Classes/Utility/CoreDataStack.h b/WordPress/Classes/Utility/CoreDataStack.h new file mode 100644 index 000000000000..68a0a61858a5 --- /dev/null +++ b/WordPress/Classes/Utility/CoreDataStack.h @@ -0,0 +1,34 @@ +#import <Foundation/Foundation.h> +#import <CoreData/CoreData.h> + +NS_ASSUME_NONNULL_BEGIN + +@protocol CoreDataStack +@property (nonatomic, readonly, strong) NSManagedObjectContext *mainContext; +- (NSManagedObjectContext *const)newDerivedContext DEPRECATED_MSG_ATTRIBUTE("Use `performAndSave` instead"); +- (void)saveContextAndWait:(NSManagedObjectContext *)context; +- (void)saveContext:(NSManagedObjectContext *)context; +- (void)saveContext:(NSManagedObjectContext *)context withCompletionBlock:(void (^ _Nullable)(void))completionBlock onQueue:(dispatch_queue_t)queue NS_SWIFT_NAME(save(_:completion:on:)); + +/// Execute the given block with a background context and save the changes. +/// +/// This function _blocks_ its running thread. The changed made by the `aBlock` argument are saved before this +/// function returns. +/// +/// - Parameter aBlock: A block which uses the given `NSManagedObjectContext` to make Core Data model changes. +- (void)performAndSaveUsingBlock:(void (^)(NSManagedObjectContext *context))aBlock; + +/// Execute the given block with a background context and save the changes _if the block does not throw an error_. +/// +/// This function _does not block_ its running thread. The `aBlock` argument is executed in the background. The +/// `completion` block is called after the Core Data model changes are saved. +/// +/// - Parameters: +/// - aBlock: A block which uses the given `NSManagedObjectContext` to make Core Data model changes. +/// - completion: A block which is called after the changes made by the `block` are saved. +/// - queue: A queue on which to execute the `completion` block. +- (void)performAndSaveUsingBlock:(void (^)(NSManagedObjectContext *context))aBlock completion:(void (^ _Nullable)(void))completion onQueue:(dispatch_queue_t)queue; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WordPress/Classes/Utility/Date+TimeStrings.swift b/WordPress/Classes/Utility/Date+TimeStrings.swift new file mode 100644 index 000000000000..7cb10c1abc28 --- /dev/null +++ b/WordPress/Classes/Utility/Date+TimeStrings.swift @@ -0,0 +1,16 @@ +import Foundation + +extension Date { + /// Extracts the time from the passed date + func toLocalTime() -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter.string(from: self) + } + + func toLocal24HTime() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter.string(from: self) + } +} diff --git a/WordPress/Classes/Utility/Editor/EditorFactory.swift b/WordPress/Classes/Utility/Editor/EditorFactory.swift index ce2a209eb798..fbfd332142d6 100644 --- a/WordPress/Classes/Utility/Editor/EditorFactory.swift +++ b/WordPress/Classes/Utility/Editor/EditorFactory.swift @@ -20,7 +20,7 @@ class EditorFactory { } } - private func createGutenbergVC(with post: AbstractPost, loadAutosaveRevision: Bool, replaceEditor: @escaping ReplaceEditorBlock) -> GutenbergViewController { + func createGutenbergVC(with post: AbstractPost, loadAutosaveRevision: Bool, replaceEditor: @escaping ReplaceEditorBlock) -> GutenbergViewController { let gutenbergVC = GutenbergViewController(post: post, loadAutosaveRevision: loadAutosaveRevision, replaceEditor: replaceEditor) if gutenbergSettings.shouldAutoenableGutenberg(for: post) { @@ -33,11 +33,19 @@ class EditorFactory { return gutenbergVC } - func switchToAztec(from source: EditorViewController) { - let replacement = AztecPostViewController(post: source.post, replaceEditor: source.replaceEditor, editorSession: source.editorSession) - source.replaceEditor(source, replacement) - } + // TODO: DRY this up + func createHomepageGutenbergVC(with post: AbstractPost, loadAutosaveRevision: Bool, replaceEditor: @escaping ReplaceEditorBlock) -> EditHomepageViewController { + let gutenbergVC = EditHomepageViewController(post: post, loadAutosaveRevision: loadAutosaveRevision, replaceEditor: replaceEditor) + if gutenbergSettings.shouldAutoenableGutenberg(for: post) { + gutenbergSettings.setGutenbergEnabled(true, for: post.blog, source: .onBlockPostOpening) + gutenbergSettings.postSettingsToRemote(for: post.blog) + gutenbergVC.shouldPresentInformativeDialog = true + gutenbergSettings.willShowDialog(for: post.blog) + } + + return gutenbergVC + } func switchToGutenberg(from source: EditorViewController) { let replacement = GutenbergViewController(post: source.post, replaceEditor: source.replaceEditor, editorSession: source.editorSession) source.replaceEditor(source, replacement) diff --git a/WordPress/Classes/Utility/Editor/GutenbergRollout.swift b/WordPress/Classes/Utility/Editor/GutenbergRollout.swift index db981d4a7107..42a4750010a1 100644 --- a/WordPress/Classes/Utility/Editor/GutenbergRollout.swift +++ b/WordPress/Classes/Utility/Editor/GutenbergRollout.swift @@ -29,7 +29,7 @@ struct GutenbergRollout { } private func atLeastOneSiteHasAztecEnabled() -> Bool { - let allBlogs = BlogService(managedObjectContext: context).blogsForAllAccounts() + let allBlogs = (try? BlogQuery().blogs(in: context)) ?? [] return allBlogs.contains { $0.editor == .aztec } } } diff --git a/WordPress/Classes/Utility/Editor/GutenbergSettings.swift b/WordPress/Classes/Utility/Editor/GutenbergSettings.swift index 6e3d9372a0cf..c15048e33866 100644 --- a/WordPress/Classes/Utility/Editor/GutenbergSettings.swift +++ b/WordPress/Classes/Utility/Editor/GutenbergSettings.swift @@ -5,13 +5,23 @@ class GutenbergSettings { enum Key { static let appWideEnabled = "kUserDefaultsGutenbergEditorEnabled" static func enabledOnce(for blog: Blog) -> String { - let url = (blog.url ?? "") as String + let url = urlStringFrom(blog) return "com.wordpress.gutenberg-autoenabled-" + url } static func showPhase2Dialog(for blog: Blog) -> String { - let url = (blog.url ?? "") as String + let url = urlStringFrom(blog) return "kShowGutenbergPhase2Dialog-" + url } + static let focalPointPickerTooltipShown = "kGutenbergFocalPointPickerTooltipShown" + static let blockTypeImpressions = "kBlockTypeImpressions" + + private static func urlStringFrom(_ blog: Blog) -> String { + return (blog.url ?? "") + // New sites will add a slash at the end of URL. + // This is removed when the URL is refreshed from remote. + // Removing trailing '/' in case there is one for consistency. + .removingTrailingCharacterIfExists("/") + } } enum TracksSwitchSource: String { @@ -22,9 +32,10 @@ class GutenbergSettings { } // MARK: - Internal variables - fileprivate let database: KeyValueDatabase - - let context = Environment.current.contextManager.mainContext + private let database: KeyValueDatabase + private var coreDataStack: CoreDataStack { + Environment.current.contextManager + } // MARK: - Initialization init(database: KeyValueDatabase) { @@ -57,13 +68,13 @@ class GutenbergSettings { func performGutenbergPhase2MigrationIfNeeded() { guard ReachabilityUtils.isInternetReachable(), - let account = AccountService(managedObjectContext: context).defaultWordPressComAccount() + let userID = coreDataStack.performQuery({ try? WPAccount.lookupDefaultWordPressComAccount(in: $0)?.userID }) else { return } var rollout = GutenbergRollout(database: database) - if rollout.shouldPerformPhase2Migration(userId: account.userID.intValue) { + if rollout.shouldPerformPhase2Migration(userId: userID.intValue) { setGutenbergEnabledForAllSites() rollout.isUserInRolloutGroup = true trackSettingChange(to: true, from: .onProgressiveRolloutPhase2) @@ -71,14 +82,14 @@ class GutenbergSettings { } private func setGutenbergEnabledForAllSites() { - let allBlogs = BlogService(managedObjectContext: context).blogsForAllAccounts() + let allBlogs = coreDataStack.performQuery({ (try? BlogQuery().blogs(in: $0)) ?? [] }) allBlogs.forEach { blog in if blog.editor == .aztec { setShowPhase2Dialog(true, for: blog) database.set(true, forKey: Key.enabledOnce(for: blog)) } } - let editorSettingsService = EditorSettingsService(managedObjectContext: context) + let editorSettingsService = EditorSettingsService(coreDataStack: coreDataStack) editorSettingsService.migrateGlobalSettingToRemote(isGutenbergEnabled: true, overrideRemote: true, onSuccess: { WPAnalytics.refreshMetadata() }) @@ -105,10 +116,15 @@ class GutenbergSettings { trackSettingChange(to: isEnabled, from: source) } - blog.mobileEditor = isEnabled ? .gutenberg : .aztec - ContextManager.sharedInstance().save(context) + let mobileEditor: MobileEditor = isEnabled ? .gutenberg : .aztec + blog.mobileEditor = mobileEditor - WPAnalytics.refreshMetadata() + coreDataStack.performAndSave({ context in + let blogInContext = try? context.existingObject(with: blog.objectID) as? Blog + blogInContext?.mobileEditor = mobileEditor + }, completion: { + WPAnalytics.refreshMetadata() + }, on: .main) } private func shouldUpdateSettings(enabling isEnablingGutenberg: Bool, for blog: Blog) -> Bool { @@ -129,7 +145,7 @@ class GutenbergSettings { /// /// - Parameter blog: The site to synch editor settings func postSettingsToRemote(for blog: Blog) { - let editorSettingsService = EditorSettingsService(managedObjectContext: context) + let editorSettingsService = EditorSettingsService(coreDataStack: coreDataStack) editorSettingsService.postEditorSetting(for: blog, success: {}) { (error) in DDLogError("Failed to post new post selection with Error: \(error)") } @@ -149,8 +165,31 @@ class GutenbergSettings { database.set(true, forKey: Key.enabledOnce(for: blog)) } + /// True if it should show the tooltip for the focal point picker + var focalPointPickerTooltipShown: Bool { + get { + database.bool(forKey: Key.focalPointPickerTooltipShown) + } + set { + database.set(newValue, forKey: Key.focalPointPickerTooltipShown) + } + } + + var blockTypeImpressions: [String: Int] { + get { + database.object(forKey: Key.blockTypeImpressions) as? [String: Int] ?? [:] + } + set { + database.set(newValue, forKey: Key.blockTypeImpressions) + } + } + // MARK: - Gutenberg Choice Logic + func isSimpleWPComSite(_ blog: Blog) -> Bool { + return !blog.isAtomic() && blog.isHostedAtWPcom + } + /// Call this method to know if Gutenberg must be used for the specified post. /// /// - Parameters: @@ -160,9 +199,8 @@ class GutenbergSettings { /// func mustUseGutenberg(for post: AbstractPost) -> Bool { let blog = post.blog - if post.isContentEmpty() { - return blog.isGutenbergEnabled + return isSimpleWPComSite(post.blog) || blog.isGutenbergEnabled } else { // It's an existing post return post.containsGutenbergBlocks() @@ -170,7 +208,8 @@ class GutenbergSettings { } func getDefaultEditor(for blog: Blog) -> MobileEditor { - return .aztec + database.set(true, forKey: Key.enabledOnce(for: blog)) + return .gutenberg } } @@ -185,4 +224,18 @@ class GutenbergSettingsBridge: NSObject { static func postSettingsToRemote(for blog: Blog) { GutenbergSettings().postSettingsToRemote(for: blog) } + + @objc(isSimpleWPComSite:) + static func isSimpleWPComSite(_ blog: Blog) -> Bool { + return GutenbergSettings().isSimpleWPComSite(blog) + } +} + +private extension String { + func removingTrailingCharacterIfExists(_ character: Character) -> String { + if self.last == character { + return String(dropLast()) + } + return self + } } diff --git a/WordPress/Classes/Utility/Environment/Environment.swift b/WordPress/Classes/Utility/Environment/Environment.swift index 294629be81ab..c4cfcbce0c5b 100644 --- a/WordPress/Classes/Utility/Environment/Environment.swift +++ b/WordPress/Classes/Utility/Environment/Environment.swift @@ -12,7 +12,7 @@ struct Environment { let appRatingUtility: AppRatingUtilityType /// A type to create derived context, save context, etc... - let contextManager: ContextManagerType + let contextManager: CoreDataStack /// The base url to use for WP.com api requests let wordPressComApiBase: String @@ -33,7 +33,7 @@ struct Environment { private init( appRatingUtility: AppRatingUtilityType = AppRatingUtility.shared, - contextManager: ContextManagerType = ContextManager.shared, + contextManager: CoreDataStack = ContextManager.shared, wordPressComApiBase: String = WordPressComRestApi.apiBaseURLString) { self.appRatingUtility = appRatingUtility @@ -48,7 +48,7 @@ extension Environment { @discardableResult static func replaceEnvironment( appRatingUtility: AppRatingUtilityType = Environment.current.appRatingUtility, - contextManager: ContextManagerType = Environment.current.contextManager, + contextManager: CoreDataStack = Environment.current.contextManager, wordPressComApiBase: String = Environment.current.wordPressComApiBase) -> Environment { current = Environment( diff --git a/WordPress/Classes/Utility/Environment/Protocols/ContextManagerType.swift b/WordPress/Classes/Utility/Environment/Protocols/ContextManagerType.swift deleted file mode 100644 index b0942f322046..000000000000 --- a/WordPress/Classes/Utility/Environment/Protocols/ContextManagerType.swift +++ /dev/null @@ -1,11 +0,0 @@ - -protocol ContextManagerType { - var mainContext: NSManagedObjectContext { get } - static var shared: ContextManagerType { get } -} - -extension ContextManager: ContextManagerType { - static var shared: ContextManagerType { - return ContextManager.sharedInstance() - } -} diff --git a/WordPress/Classes/Utility/FormattableContent/Actions/FormattableContentAction.swift b/WordPress/Classes/Utility/FormattableContent/Actions/FormattableContentAction.swift index 9ce13b279aa8..c7c946c4216d 100644 --- a/WordPress/Classes/Utility/FormattableContent/Actions/FormattableContentAction.swift +++ b/WordPress/Classes/Utility/FormattableContent/Actions/FormattableContentAction.swift @@ -12,9 +12,9 @@ public enum NotificationDeletionKind { public var legendText: String { switch self { case .deletion: - return NSLocalizedString("Comment has been deleted", comment: "Displayed when a Comment is deleted") + return AppLocalizedString("Comment has been deleted", comment: "Displayed when a Comment is deleted") case .spamming: - return NSLocalizedString("Comment has been marked as Spam", comment: "Displayed when a Comment is spammed") + return AppLocalizedString("Comment has been marked as Spam", comment: "Displayed when a Comment is spammed") } } } diff --git a/WordPress/Classes/Utility/FormattableContent/FormattableContentGroup.swift b/WordPress/Classes/Utility/FormattableContent/FormattableContentGroup.swift index 0e7b468c22dc..5f483e33c005 100644 --- a/WordPress/Classes/Utility/FormattableContent/FormattableContentGroup.swift +++ b/WordPress/Classes/Utility/FormattableContent/FormattableContentGroup.swift @@ -9,6 +9,7 @@ extension FormattableContentGroup.Kind { static let subject = FormattableContentGroup.Kind("subject") static let header = FormattableContentGroup.Kind("header") static let footer = FormattableContentGroup.Kind("footer") + static let button = FormattableContentGroup.Kind("button") } // MARK: - FormattableContentGroup: Adapter to match 1 View <> 1 BlockGroup diff --git a/WordPress/Classes/Utility/FormattableContent/FormattableContentRange.swift b/WordPress/Classes/Utility/FormattableContent/FormattableContentRange.swift index e75bdb160a30..798117f36160 100644 --- a/WordPress/Classes/Utility/FormattableContent/FormattableContentRange.swift +++ b/WordPress/Classes/Utility/FormattableContent/FormattableContentRange.swift @@ -84,4 +84,6 @@ extension FormattableRangeKind { public static let match = FormattableRangeKind("match") public static let link = FormattableRangeKind("link") public static let italic = FormattableRangeKind("i") + public static let scan = FormattableRangeKind("scan") + public static let strong = FormattableRangeKind("b") } diff --git a/WordPress/Classes/Utility/Generics/FailableDecodable.swift b/WordPress/Classes/Utility/Generics/FailableDecodable.swift new file mode 100644 index 000000000000..1c3b3906b6fb --- /dev/null +++ b/WordPress/Classes/Utility/Generics/FailableDecodable.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Acts as a wrapper around decodable types, and marks them as failable. +/// This allows the decoding process to succeed even if the decoder was unable to decode a failable item. +struct FailableDecodable<T: Decodable & Hashable>: Decodable { + let result: Result<T, Error> + + var value: T? { + return try? result.get() + } + + init(value: T) { + result = Result.success(value) + } + + init(from decoder: Decoder) throws { + result = Result(catching: { try T(from: decoder) }) + } +} + +extension FailableDecodable: Hashable { + func hash(into hasher: inout Hasher) { + hasher.combine(value) + } + + static func == (lhs: FailableDecodable<T>, rhs: FailableDecodable<T>) -> Bool { + return lhs.value == rhs.value + } +} diff --git a/WordPress/Classes/Utility/Gesture Recognizer/BindableTapGestureRecognizer.swift b/WordPress/Classes/Utility/Gesture Recognizer/BindableTapGestureRecognizer.swift new file mode 100644 index 000000000000..54561ee2a36c --- /dev/null +++ b/WordPress/Classes/Utility/Gesture Recognizer/BindableTapGestureRecognizer.swift @@ -0,0 +1,22 @@ +import Foundation + +/// A tap gesture recognizer that works with a closure, instead of an action and target. +/// +class BindableTapGestureRecognizer: UITapGestureRecognizer { + typealias Action = (_ sender: BindableTapGestureRecognizer) -> Void + + let action: Action + + init(action: @escaping Action) { + self.action = action + + super.init(target: nil, action: nil) + + addTarget(self, action: #selector(actionHandler)) + } + + @objc + private func actionHandler() { + action(self) + } +} diff --git a/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionFetcher.swift b/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionFetcher.swift new file mode 100644 index 000000000000..1a82711c94fb --- /dev/null +++ b/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionFetcher.swift @@ -0,0 +1,87 @@ +import UIKit + +class ImageDimensionsFetcher: NSObject, URLSessionDataDelegate { + // Helpful typealiases for the closures + public typealias CompletionHandler = (ImageDimensionFormat, CGSize?) -> Void + public typealias ErrorHandler = (Error?) -> Void + + let completionHandler: CompletionHandler + let errorHandler: ErrorHandler? + + // Internal use properties + private let request: URLRequest + private var task: URLSessionDataTask? = nil + private let parser: ImageDimensionParser + private var session: URLSession? = nil + + deinit { + cancel() + } + + init(request: URLRequest, + success: @escaping CompletionHandler, + error: ErrorHandler? = nil, + imageParser: ImageDimensionParser = ImageDimensionParser()) { + self.request = request + self.completionHandler = success + self.errorHandler = error + self.parser = imageParser + + super.init() + } + + /// Starts the calculation process + func start() { + let config = URLSessionConfiguration.default + let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + let task = session.dataTask(with: request) + task.resume() + + self.task = task + self.session = session + } + + func cancel() { + session?.invalidateAndCancel() + task?.cancel() + } + + // MARK: - URLSessionDelegate + public func urlSession(_ session: URLSession, task dataTask: URLSessionTask, didCompleteWithError error: Error?) { + // Don't trigger an error if we cancelled the task + if let error = error, (error as NSError).code == NSURLErrorCancelled { + return + } + + self.errorHandler?(error) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + // Add the downloaded data to the parser + parser.append(bytes: data) + + // Wait for the format to be detected + guard let format = parser.format else { + return + } + + // Check if the format is unsupported + guard format != .unsupported else { + completionHandler(format, nil) + + // We can't parse unsupported images, cancel the download + cancel() + return + } + + // Wait for the image size + guard let size = parser.imageSize else { + return + } + + completionHandler(format, size) + + // The image size has been calculated, stop downloading + cancel() + } +} diff --git a/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionParser.swift b/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionParser.swift new file mode 100644 index 000000000000..4819fe39a495 --- /dev/null +++ b/WordPress/Classes/Utility/Image Dimension Parser/ImageDimensionParser.swift @@ -0,0 +1,270 @@ +import UIKit + +class ImageDimensionParser { + private(set) var format: ImageDimensionFormat? + private(set) var imageSize: CGSize? = nil + + private var data: Data + + init(with data: Data = Data()) { + self.data = data + + parse() + } + + public func append(bytes: Data) { + data.append(contentsOf: bytes) + + parse() + } + + private func parse() { + guard + let format = ImageDimensionFormat(with: data) + else { + return + } + + self.format = format + imageSize = dimensions(with: data) + + guard imageSize != nil else { + return + } + } + + // MARK: - Dimension Calculating + private func dimensions(with data: Data) -> CGSize? { + switch format { + case .png: return pngSize(with: data) + case .gif: return gifSize(with: data) + case .jpeg: return jpegSize(with: data) + + default: return nil + } + } + + // MARK: - PNG Parsing + private func pngSize(with data: Data) -> CGSize? { + // Bail out if the data size is too small to read the header + let chunkSize = PNGConstants.chunkSize + let ihdrStart = PNGConstants.headerSize + chunkSize + + // The min length needed to read the width / height + let minLength = ihdrStart + chunkSize * 3 + + guard data.count >= minLength else { + return nil + } + + // Validate the header to make sure the width/height is in the correct spot + guard data.subdata(start: ihdrStart, length: chunkSize) == PNGConstants.IHDR else { + return nil + } + + // Width is immediately after the IHDR header + let widthOffset = ihdrStart + chunkSize + + // Height is after the width + let heightOffset = widthOffset + chunkSize + + // Height and width are stored as 32 bit ints + // http://www.libpng.org/pub/png/spec/1.0/PNG-Chunks.html + // ^ The maximum for each is (2^31)-1 in order to accommodate languages that have difficulty with unsigned 4-byte values. + let width = CFSwapInt32(data[widthOffset, chunkSize] as UInt32) + let height = CFSwapInt32(data[heightOffset, chunkSize] as UInt32) + + return CGSize(width: Int(width), height: Int(height)) + } + + private struct PNGConstants { + // PNG header size is 8 bytes + static let headerSize = 8 + + // PNG is broken up into 4 byte chunks, except for the header + static let chunkSize = 4 + + // IHDR header: // https://www.w3.org/TR/PNG/#11IHDR + static let IHDR = Data([0x49, 0x48, 0x44, 0x52]) + } + + // MARK: - GIF Parsing + private func gifSize(with data: Data) -> CGSize? { + // Bail out if the data size is too small to read the header + let valueSize = GIFConstants.valueSize + let headerSize = GIFConstants.headerSize + + // Min length we need to read is the header size + 4 bytes + let minLength = headerSize + valueSize * 3 + + guard data.count >= minLength else { + return nil + } + + // The width appears directly after the header, and the height after that. + let widthOffset = headerSize + let heightOffset = widthOffset + + // Reads the "logical screen descriptor" which appears after the GIF header block + let width: UInt16 = data[widthOffset, valueSize] + let height: UInt16 = data[heightOffset, valueSize] + + return CGSize(width: Int(width), height: Int(height)) + } + + private struct GIFConstants { + // http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp + + // The GIF header size is 6 bytes + static let headerSize = 6 + + // The height and width are stored as 2 byte values + static let valueSize = 2 + } + + // MARK: - JPEG Parsing + private struct JPEGConstants { + static let blockSize: UInt16 = 256 + + // 16 bytes skips the header and the first block + static let minDataCount = 16 + + static let valueSize = 2 + static let heightOffset = 5 + + // JFIF{NULL} + static let jfifHeader = Data([0x4A, 0x46, 0x49, 0x46, 0x00]) + } + + private func jpegSize(with data: Data) -> CGSize? { + // Bail out if the data size is too small to read the header + guard data.count > JPEGConstants.minDataCount else { + return nil + } + + // Adapted from: + // - https://web.archive.org/web/20131016210645/http://www.64lines.com/jpeg-width-height + + var i = JPEGConstants.jfifHeader.count - 1 + + let blockSize: UInt16 = JPEGConstants.blockSize + + // Retrieve the block length of the first block since the first block will not contain the size of file + var block_length = UInt16(data[i]) * blockSize + UInt16(data[i+1]) + + while i < data.count { + i += Int(block_length) + + // Protect again out of bounds issues + // 10 = the max size we need to read all the values from below + if i + 10 >= data.count { + return nil + } + + // Check that we are truly at the start of another block + if data[i] != 0xFF { + return nil + } + + // SOFn marker + let marker = data[i+1] + + let isValidMarker = (marker >= 0xC0 && marker <= 0xC3) || + (marker >= 0xC5 && marker <= 0xC7) || + (marker >= 0xC9 && marker <= 0xCB) || + (marker >= 0xCD && marker <= 0xCF) + + if isValidMarker { + // "Start of frame" marker which contains the file size + let valueSize = JPEGConstants.valueSize + let heightOffset = i + JPEGConstants.heightOffset + let widthOffset = heightOffset + valueSize + + let height = CFSwapInt16(data[heightOffset, valueSize] as UInt16) + let width = CFSwapInt16(data[widthOffset, valueSize] as UInt16) + + return CGSize(width: Int(width), height: Int(height)) + } + + // Go to the next block + i += 2 // Skip the block marker + block_length = UInt16(data[i]) * blockSize + UInt16(data[i+1]) + } + + return nil + } +} + +// MARK: - ImageFormat +enum ImageDimensionFormat { + // WordPress supported image formats: + // https://wordpress.com/support/images/ + // https://codex.wordpress.org/Uploading_Files + case jpeg + case png + case gif + case unsupported + + init?(with data: Data) { + if data.headerIsEqual(to: FileMarker.jpeg) { + self = .jpeg + } + else if data.headerIsEqual(to: FileMarker.gif) { + self = .gif + } + else if data.headerIsEqual(to: FileMarker.png) { + self = .png + } + else if data.count < FileMarker.png.count { + return nil + } + else { + self = .unsupported + } + } + + // File type markers denote the type of image in the first few bytes of the file + private struct FileMarker { + // https://en.wikipedia.org/wiki/JPEG_Network_Graphics + static let png = Data([0x89, 0x50, 0x4E, 0x47]) + + // https://en.wikipedia.org/wiki/JPEG_File_Interchange_Format + // FFD8 = SOI, APP0 marker + static let jpeg = Data([0xFF, 0xD8, 0xFF]) + + // https://en.wikipedia.org/wiki/GIF + static let gif = Data([0x47, 0x49, 0x46, 0x38]) //GIF8 + } +} + + + +// MARK: - Private: Extensions +private extension Data { + func headerData(with length: Int) -> Data { + return subdata(start: 0, length: length) + } + + func headerIsEqual(to value: Data) -> Bool { + // Prevent any out of bounds issues + if count < value.count { + return false + } + + let header = headerData(with: value.count) + + return header == value + } + + func subdata(start: Int, length: Int) -> Data { + return subdata(in: start ..< start + length) + } + + subscript<UInt16>(range: Range<Data.Index>) -> UInt16 { + return subdata(in: range).withUnsafeBytes { $0.load(as: UInt16.self) } + } + + subscript<T>(start: Int, length: Int) -> T { + return self[start..<start + length] + } +} diff --git a/WordPress/Classes/Utility/ImmuTable.swift b/WordPress/Classes/Utility/ImmuTable.swift index 4b0bb7333225..eba71e9826b5 100644 --- a/WordPress/Classes/Utility/ImmuTable.swift +++ b/WordPress/Classes/Utility/ImmuTable.swift @@ -1,3 +1,4 @@ +import UIKit /** ImmuTable represents the view model for a static UITableView. @@ -234,13 +235,15 @@ public enum ImmuTableCell { /// reference to the handler from your view controller. /// open class ImmuTableViewHandler: NSObject, UITableViewDataSource, UITableViewDelegate { - @objc unowned let target: UITableViewController + typealias UIViewControllerWithTableView = TableViewContainer & UITableViewDataSource & UITableViewDelegate & UIViewController + + @objc unowned let target: UIViewControllerWithTableView private weak var passthroughScrollViewDelegate: UIScrollViewDelegate? /// Initializes the handler with a target table view controller. /// - postcondition: After initialization, it becomse the data source and /// delegate for the the target's table view. - @objc public init(takeOver target: UITableViewController, with passthroughScrollViewDelegate: UIScrollViewDelegate? = nil) { + @objc init(takeOver target: UIViewControllerWithTableView, with passthroughScrollViewDelegate: UIScrollViewDelegate? = nil) { self.target = target self.passthroughScrollViewDelegate = passthroughScrollViewDelegate @@ -253,7 +256,7 @@ open class ImmuTableViewHandler: NSObject, UITableViewDataSource, UITableViewDel /// An ImmuTable object representing the table structure. open var viewModel = ImmuTable.Empty { didSet { - if target.isViewLoaded { + if target.isViewLoaded && automaticallyReloadTableView { target.tableView.reloadData() } } @@ -262,6 +265,9 @@ open class ImmuTableViewHandler: NSObject, UITableViewDataSource, UITableViewDel /// Configure the handler to automatically deselect any cell after tapping it. @objc var automaticallyDeselectCells = false + /// Automatically reload table view when view model changes + @objc var automaticallyReloadTableView = true + // MARK: UITableViewDataSource open func numberOfSections(in tableView: UITableView) -> Int { @@ -289,18 +295,38 @@ open class ImmuTableViewHandler: NSObject, UITableViewDataSource, UITableViewDel return viewModel.sections[section].footerText } + open func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + if target.responds(to: #selector(UITableViewDataSource.tableView(_:canEditRowAt:))) { + return target.tableView?(tableView, canEditRowAt: indexPath) ?? false + } + + return false + } + + open func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { + if target.responds(to: #selector(UITableViewDataSource.tableView(_:canMoveRowAt:))) { + return target.tableView?(tableView, canMoveRowAt: indexPath) ?? false + } + + return false + } + + open func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { + target.tableView?(tableView, moveRowAt: sourceIndexPath, to: destinationIndexPath) + } + // MARK: UITableViewDelegate open func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { if target.responds(to: #selector(UITableViewDelegate.tableView(_:willSelectRowAt:))) { - return target.tableView(tableView, willSelectRowAt: indexPath) + return target.tableView?(tableView, willSelectRowAt: indexPath) } else { return indexPath } } open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if target.responds(to: #selector(UITableViewDelegate.tableView(_:didSelectRowAt:))) { - target.tableView(tableView, didSelectRowAt: indexPath) + target.tableView?(tableView, didSelectRowAt: indexPath) } else { let row = viewModel.rowAtIndexPath(indexPath) row.action?(row) @@ -320,7 +346,7 @@ open class ImmuTableViewHandler: NSObject, UITableViewDataSource, UITableViewDel open func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { if target.responds(to: #selector(UITableViewDelegate.tableView(_:heightForFooterInSection:))) { - return target.tableView(tableView, heightForFooterInSection: section) + return target.tableView?(tableView, heightForFooterInSection: section) ?? UITableView.automaticDimension } return UITableView.automaticDimension @@ -328,7 +354,7 @@ open class ImmuTableViewHandler: NSObject, UITableViewDataSource, UITableViewDel open func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { if target.responds(to: #selector(UITableViewDelegate.tableView(_:heightForHeaderInSection:))) { - return target.tableView(tableView, heightForHeaderInSection: section) + return target.tableView?(tableView, heightForHeaderInSection: section) ?? UITableView.automaticDimension } return UITableView.automaticDimension @@ -336,7 +362,7 @@ open class ImmuTableViewHandler: NSObject, UITableViewDataSource, UITableViewDel open func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { if target.responds(to: #selector(UITableViewDelegate.tableView(_:viewForFooterInSection:))) { - return target.tableView(tableView, viewForFooterInSection: section) + return target.tableView?(tableView, viewForFooterInSection: section) } return nil @@ -344,23 +370,36 @@ open class ImmuTableViewHandler: NSObject, UITableViewDataSource, UITableViewDel open func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { if target.responds(to: #selector(UITableViewDelegate.tableView(_:viewForHeaderInSection:))) { - return target.tableView(tableView, viewForHeaderInSection: section) + return target.tableView?(tableView, viewForHeaderInSection: section) } return nil } - open func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - if target.responds(to: #selector(UITableViewDataSource.tableView(_:canEditRowAt:))) { - return target.tableView(tableView, canEditRowAt: indexPath) + open func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath { + if target.responds(to: #selector(UITableViewDelegate.tableView(_:targetIndexPathForMoveFromRowAt:toProposedIndexPath:))) { + return target.tableView?(tableView, targetIndexPathForMoveFromRowAt: sourceIndexPath, toProposedIndexPath: proposedDestinationIndexPath) ?? proposedDestinationIndexPath } - return false + return proposedDestinationIndexPath } - open func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - if target.responds(to: #selector(UITableViewDelegate.tableView(_:editActionsForRowAt:))) { - return target.tableView(tableView, editActionsForRowAt: indexPath) + open func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + if target.responds(to: #selector(UITableViewDelegate.tableView(_:editingStyleForRowAt:))) { + return target.tableView?(tableView, editingStyleForRowAt: indexPath) ?? .none + } + + return .none + } + + open func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { + return target.tableView?(tableView, shouldIndentWhileEditingRowAt: indexPath) ?? true + } + + + public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + if target.responds(to: #selector(UITableViewDelegate.tableView(_:trailingSwipeActionsConfigurationForRowAt:))) { + return target.tableView?(tableView, trailingSwipeActionsConfigurationForRowAt: indexPath) } return nil @@ -448,3 +487,11 @@ extension UITableView: CellRegistrar { } } } + +// MARK: - UITableViewController conformance + +@objc public protocol TableViewContainer: AnyObject { + var tableView: UITableView! { get set } +} + +extension UITableViewController: TableViewContainer {} diff --git a/WordPress/Classes/Utility/ImmuTableViewController.swift b/WordPress/Classes/Utility/ImmuTableViewController.swift index 037b8e8579de..dfdb008dcae5 100644 --- a/WordPress/Classes/Utility/ImmuTableViewController.swift +++ b/WordPress/Classes/Utility/ImmuTableViewController.swift @@ -3,7 +3,7 @@ import WordPressShared typealias ImmuTableRowControllerGenerator = (ImmuTableRow) -> UIViewController -protocol ImmuTablePresenter: class { +protocol ImmuTablePresenter: AnyObject { func push(_ controllerGenerator: @escaping ImmuTableRowControllerGenerator) -> ImmuTableAction func present(_ controllerGenerator: @escaping ImmuTableRowControllerGenerator) -> ImmuTableAction } @@ -49,16 +49,25 @@ protocol ImmuTableController { /// a "controller" class that handles all the logic, and updates the view /// controller, like you would update a view. final class ImmuTableViewController: UITableViewController, ImmuTablePresenter { - fileprivate lazy var handler: ImmuTableViewHandler = { + private(set) lazy var handler: ImmuTableViewHandler = { return ImmuTableViewHandler(takeOver: self) }() - fileprivate var noticeAnimator: NoticeAnimator! + fileprivate var messageAnimator: MessageAnimator! let controller: ImmuTableController // MARK: - Table View Controller + init(controller: ImmuTableController, style: UITableView.Style) { + self.controller = controller + super.init(style: style) + + title = controller.title + registerRows(controller.immuTableRows) + controller.refreshModel() + } + init(controller: ImmuTableController) { self.controller = controller super.init(style: .grouped) @@ -75,7 +84,7 @@ final class ImmuTableViewController: UITableViewController, ImmuTablePresenter { override func viewDidLoad() { super.viewDidLoad() - noticeAnimator = NoticeAnimator(target: view) + messageAnimator = MessageAnimator(target: view) WPStyleGuide.configureColors(view: view, tableView: tableView) WPStyleGuide.configureAutomaticHeightRows(for: tableView) @@ -83,7 +92,7 @@ final class ImmuTableViewController: UITableViewController, ImmuTablePresenter { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - noticeAnimator.layout() + messageAnimator.layout() } override func viewWillAppear(_ animated: Bool) { @@ -119,7 +128,7 @@ final class ImmuTableViewController: UITableViewController, ImmuTablePresenter { @objc var noticeMessage: String? = nil { didSet { guard noticeMessage != oldValue else { return } - noticeAnimator.animateMessage(noticeMessage) + messageAnimator.animateMessage(noticeMessage) } } diff --git a/WordPress/Classes/Utility/InfoPListTranslator.h b/WordPress/Classes/Utility/InfoPListTranslator.h deleted file mode 100644 index f38ccaba7738..000000000000 --- a/WordPress/Classes/Utility/InfoPListTranslator.h +++ /dev/null @@ -1,12 +0,0 @@ -#import <Foundation/Foundation.h> - -/** - This class purpose is to feed strings existing on the Info.plist file to the translation scripts. - When the tranlators provide the translation the tokens will show up in the Localizable.strings files. - Then the strings need to be copied over to the correct InfoPList.strings file for each language - */ -@interface InfoPListTranslator : NSObject - -+ (void)translateStrings; - -@end diff --git a/WordPress/Classes/Utility/InfoPListTranslator.m b/WordPress/Classes/Utility/InfoPListTranslator.m deleted file mode 100644 index 9ee167b6ed46..000000000000 --- a/WordPress/Classes/Utility/InfoPListTranslator.m +++ /dev/null @@ -1,16 +0,0 @@ -#import "InfoPListTranslator.h" - -@implementation InfoPListTranslator - -+ (void)translateStrings -{ - NSLocalizedString(@"WordPress would like to add your location to posts on sites where you have enabled geotagging.", @"NSLocationUsageDescription: This sentence is show when the app asks permission from the user to use is location. "); - NSLocalizedString(@"WordPress would like to add your location to posts on sites where you have enabled geotagging.", @"NSLocationWhenInUseUsageDescription: this sentence is show when the app asks permission from the user to use is location."); - NSLocalizedString(@"To take photos or videos to use in your posts.", @"NSCameraUsageDescription: Sentence to justify why the app is asking permission from the user to use is camera."); - NSLocalizedString(@"To add photos or videos to your posts.", @"NSPhotoLibraryUsageDescription: Sentence to justify why the app asks permission from the user to access is Media Library."); - NSLocalizedString(@"To add photos or videos to your posts.", @"NSPhotoLibraryAddUsageDescription: Sentence to justify why the app asks permission from the user to access is Media Library."); - NSLocalizedString(@"Enable microphone access to record sound in your videos.", @"NSMicrophoneUsageDescription: Sentence to justify why the app asks permission from the user to access the device microphone."); - NSLocalizedString(@"Save as Draft", @"WordPressDraftActionExtension.CFBundleDisplayName and WordPressDraftActionExtension.CFBundleName: The localised name of the Draft Extension. It will be displayed in the system Share Sheets."); -} - -@end diff --git a/WordPress/Classes/Utility/InteractiveNotificationsManager.swift b/WordPress/Classes/Utility/InteractiveNotificationsManager.swift index 117af292833e..f4ec77ab8307 100644 --- a/WordPress/Classes/Utility/InteractiveNotificationsManager.swift +++ b/WordPress/Classes/Utility/InteractiveNotificationsManager.swift @@ -14,6 +14,10 @@ final class InteractiveNotificationsManager: NSObject { /// @objc static let shared = InteractiveNotificationsManager() + /// The analytics event tracker. + /// + private let eventTracker = NotificationEventTracker() + /// Returns the Core Data main context. /// @objc var context: NSManagedObjectContext { @@ -23,7 +27,7 @@ final class InteractiveNotificationsManager: NSObject { /// Returns a CommentService instance. /// @objc var commentService: CommentService { - return CommentService(managedObjectContext: context) + return CommentService(coreDataStack: ContextManager.shared) } /// Returns a NotificationSyncMediator instance. @@ -48,26 +52,24 @@ final class InteractiveNotificationsManager: NSObject { /// The first time this method is called it will ask the user for permission to show notifications. /// Because of this, this should be called only when we know we will need to show notifications (for instance, after login). /// - @objc func requestAuthorization(completion: @escaping () -> ()) { + @objc func requestAuthorization(completion: @escaping (_ allowed: Bool) -> Void) { defer { WPAnalytics.track(.pushNotificationOSAlertShown) } - var options: UNAuthorizationOptions = [.badge, .sound, .alert] - if #available(iOS 12.0, *) { - options.insert(.providesAppNotificationSettings) - } + let options: UNAuthorizationOptions = [.badge, .sound, .alert, .providesAppNotificationSettings] let notificationCenter = UNUserNotificationCenter.current() - notificationCenter.requestAuthorization(options: options) { (allowed, _) in + notificationCenter.requestAuthorization(options: options) { [weak self] (allowed, _) in DispatchQueue.main.async { if allowed { WPAnalytics.track(.pushNotificationOSAlertAllowed) + self?.disableWordPressNotificationsIfNeeded() } else { WPAnalytics.track(.pushNotificationOSAlertDenied) } } - completion() + completion(allowed) } } @@ -80,10 +82,14 @@ final class InteractiveNotificationsManager: NSObject { /// - Returns: True on success /// @objc @discardableResult - func handleAction(with identifier: String, category: String, userInfo: NSDictionary, responseText: String?) -> Bool { + func handleAction(with identifier: String, category: String, threadId: String?, userInfo: NSDictionary, responseText: String?) -> Bool { if let noteCategory = NoteCategoryDefinition(rawValue: category), noteCategory.isLocalNotification { - return handleLocalNotificationAction(with: identifier, category: category, userInfo: userInfo, responseText: responseText) + return handleLocalNotificationAction(with: identifier, category: category, threadId: threadId, userInfo: userInfo, responseText: responseText) + } + + if NoteActionDefinition.approveLogin == NoteActionDefinition(rawValue: identifier) { + return approveAuthChallenge(userInfo) } guard AccountHelper.isDotcomAvailable(), @@ -137,7 +143,7 @@ final class InteractiveNotificationsManager: NSObject { return true } - func handleLocalNotificationAction(with identifier: String, category: String, userInfo: NSDictionary, responseText: String?) -> Bool { + func handleLocalNotificationAction(with identifier: String, category: String, threadId: String?, userInfo: NSDictionary, responseText: String?) -> Bool { if let noteCategory = NoteCategoryDefinition(rawValue: category) { switch noteCategory { case .mediaUploadSuccess, .mediaUploadFailure: @@ -191,6 +197,74 @@ final class InteractiveNotificationsManager: NSObject { ShareNoticeNavigationCoordinator.navigateToBlogDetails(with: userInfo) return true } + case .bloggingReminderWeekly: + // This event should actually be tracked for all notification types, but in order to implement + // the tracking this correctly we'll have to review the other notification_type values to match Android. + // https://github.com/wordpress-mobile/WordPress-Android/blob/e3b65c4b1adc0fbc102e640750990d7655d89185/WordPress/src/main/java/org/wordpress/android/push/NotificationType.kt + // + // Since this task is non-trivial and beyond the scope of my current work, I'll only track this + // specific notification type for now in a way that matches Android, but using a mechanism that + // is extensible to track other notification types in the future. + eventTracker.notificationTapped(type: .bloggingReminders) + + if identifier == UNNotificationDefaultActionIdentifier { + let targetBlog: Blog? = blog(from: threadId) + + RootViewCoordinator.sharedPresenter.mySitesCoordinator.showCreateSheet(for: targetBlog) + } + case .weeklyRoundup: + let targetBlog = blog(from: userInfo) + let siteId = targetBlog?.dotComID?.intValue + + eventTracker.notificationTapped(type: .weeklyRoundup, siteId: siteId) + + if identifier == UNNotificationDefaultActionIdentifier { + guard let targetBlog = targetBlog else { + DDLogError("Could not obtain the blog from the Weekly Notification thread ID.") + break + } + + let targetDate = date(from: userInfo) + + RootViewCoordinator.sharedPresenter.mySitesCoordinator.showStats( + for: targetBlog, + timePeriod: .weeks, + date: targetDate) + } + + case .bloggingPrompt: + WPAnalytics.track(.bloggingRemindersNotificationReceived, properties: ["prompt_included": true]) + + let answerPromptBlock = { + RootViewCoordinator.shared.showPromptAnsweringFlow(with: userInfo) + } + + // check if user interacted with custom notification actions. + if let action = NoteActionDefinition(rawValue: identifier) { + switch action { + case .answerPrompt: // user taps on the "Answer" button. + WPAnalytics.track(.promptsNotificationAnswerActionTapped) + answerPromptBlock() + + case .dismissPrompt: // user taps on the "Dismiss" button. + WPAnalytics.track(.promptsNotificationDismissActionTapped) + // no-op, let the notification be dismissed. + + default: + break + } + } + + // handle default notification actions. + if identifier == UNNotificationDefaultActionIdentifier { + WPAnalytics.track(.promptsNotificationTapped) + answerPromptBlock() + + } else if identifier == UNNotificationDismissActionIdentifier { + WPAnalytics.track(.promptsNotificationDismissed) + // no-op + } + default: break } } @@ -199,6 +273,37 @@ final class InteractiveNotificationsManager: NSObject { } } +// MARK: - Notifications: Retrieving Stored Data + +extension InteractiveNotificationsManager { + + static let blogIDKey = "blogID" + static let dateKey = "date" + + private func blog(from userInfo: NSDictionary) -> Blog? { + if let blogID = userInfo[Self.blogIDKey] as? Int { + return try? Blog.lookup(withID: blogID, in: ContextManager.shared.mainContext) + } + + return nil + } + + private func blog(from threadId: String?) -> Blog? { + if let threadId = threadId, + let blogId = Int(threadId) { + return try? Blog.lookup(withID: blogId, in: ContextManager.shared.mainContext) + } + + return nil + } + + /// Retrieves a date from the userInfo dictionary using a generic "date" key. This was made generic on purpose. + /// + private func date(from userInfo: NSDictionary) -> Date? { + userInfo[Self.dateKey] as? Date + } +} + // MARK: - Private Helpers // @@ -241,7 +346,7 @@ private extension InteractiveNotificationsManager { /// - Parameter noteID: The Notification's Identifier /// func showDetailsWithNoteID(_ noteId: NSNumber) { - WPTabBarController.sharedInstance().showNotificationsTabForNote(withID: noteId.stringValue) + RootViewCoordinator.sharedPresenter.showNotificationsTabForNote(withID: noteId.stringValue) } @@ -271,13 +376,22 @@ private extension InteractiveNotificationsManager { let categories: [UNNotificationCategory] = NoteCategoryDefinition.allDefinitions.map({ $0.notificationCategory() }) return Set(categories) } + + /// Handles approving an 2fa authentication challenge. + /// + /// - Parameter userInfo: The notification's Payload + /// - Returns: True if successfule. Otherwise false. + /// + func approveAuthChallenge(_ userInfo: NSDictionary) -> Bool { + return PushNotificationsManager.shared.handleAuthenticationApprovedAction(userInfo) + } } // MARK: - Nested Types // -private extension InteractiveNotificationsManager { +extension InteractiveNotificationsManager { /// Describes information about Custom Actions that WPiOS can perform, as a response to /// a Push Notification event. @@ -293,6 +407,10 @@ private extension InteractiveNotificationsManager { case postUploadFailure = "post-upload-failure" case shareUploadSuccess = "share-upload-success" case shareUploadFailure = "share-upload-failure" + case login = "push_auth" + case bloggingReminderWeekly = "blogging-reminder-weekly" + case weeklyRoundup = "weekly-roundup" + case bloggingPrompt = "blogging-prompt" var actions: [NoteActionDefinition] { switch self { @@ -316,6 +434,14 @@ private extension InteractiveNotificationsManager { return [.shareEditPost] case .shareUploadFailure: return [] + case .login: + return [.approveLogin, .denyLogin] + case .bloggingReminderWeekly: + return [] + case .weeklyRoundup: + return [] + case .bloggingPrompt: + return [.answerPrompt, .dismissPrompt] } } @@ -327,16 +453,25 @@ private extension InteractiveNotificationsManager { return NoteCategoryDefinition.localDefinitions.contains(self) } + var notificationCategoryOptions: [UNNotificationCategoryOptions] { + switch self { + case .bloggingPrompt: + return [.customDismissAction] + default: + return [] + } + } + func notificationCategory() -> UNNotificationCategory { return UNNotificationCategory( identifier: identifier, actions: actions.map({ $0.notificationAction() }), intentIdentifiers: [], - options: []) + options: UNNotificationCategoryOptions()) } - static var allDefinitions = [commentApprove, commentLike, commentReply, commentReplyWithLike, mediaUploadSuccess, mediaUploadFailure, postUploadSuccess, postUploadFailure, shareUploadSuccess, shareUploadFailure] - static var localDefinitions = [mediaUploadSuccess, mediaUploadFailure, postUploadSuccess, postUploadFailure, shareUploadSuccess, shareUploadFailure] + static var allDefinitions = [commentApprove, commentLike, commentReply, commentReplyWithLike, mediaUploadSuccess, mediaUploadFailure, postUploadSuccess, postUploadFailure, shareUploadSuccess, shareUploadFailure, login, bloggingReminderWeekly, bloggingPrompt] + static var localDefinitions = [mediaUploadSuccess, mediaUploadFailure, postUploadSuccess, postUploadFailure, shareUploadSuccess, shareUploadFailure, bloggingReminderWeekly, weeklyRoundup, bloggingPrompt] } @@ -352,6 +487,10 @@ private extension InteractiveNotificationsManager { case postRetry = "POST_RETRY" case postView = "POST_VIEW" case shareEditPost = "SHARE_EDIT_POST" + case approveLogin = "APPROVE_LOGIN_ATTEMPT" + case denyLogin = "DENY_LOGIN_ATTEMPT" + case answerPrompt = "ANSWER_BLOGGING_PROMPT" + case dismissPrompt = "DISMISS_BLOGGING_PROMPT" var description: String { switch self { @@ -371,6 +510,18 @@ private extension InteractiveNotificationsManager { return NSLocalizedString("View", comment: "Opens the post epilogue screen to allow sharing / viewing of a post.") case .shareEditPost: return NSLocalizedString("Edit Post", comment: "Opens the editor to edit an existing post.") + case .approveLogin: + return NSLocalizedString("Approve", comment: "Verb. Approves a 2fa authentication challenge, and logs in a user.") + case .denyLogin: + return NSLocalizedString("Deny", comment: "Verb. Denies a 2fa authentication challenge.") + case .answerPrompt: + return NSLocalizedString("Answer", comment: "Verb. Opens the editor to answer the blogging prompt.") + case .dismissPrompt: + return NSLocalizedString( + "bloggingPrompt.pushNotification.customActionDescription.dismiss", + value: "Dismiss", + comment: "Verb. Dismisses the blogging prompt notification." + ) } } @@ -383,12 +534,17 @@ private extension InteractiveNotificationsManager { } var requiresAuthentication: Bool { - return false + switch self { + case .approveLogin, .denyLogin: + return true + default: + return false + } } var requiresForeground: Bool { switch self { - case .mediaWritePost, .mediaRetry, .postView, .shareEditPost: + case .mediaWritePost, .mediaRetry, .postView, .shareEditPost, .answerPrompt: return true default: return false } @@ -438,7 +594,7 @@ private extension InteractiveNotificationsManager { } } - static var allDefinitions = [commentApprove, commentLike, commentReply, mediaWritePost, mediaRetry, postRetry, postView, shareEditPost] + static var allDefinitions = [commentApprove, commentLike, commentReply, mediaWritePost, mediaRetry, postRetry, postView, shareEditPost, approveLogin, denyLogin, answerPrompt, dismissPrompt] } } @@ -454,6 +610,20 @@ extension InteractiveNotificationsManager: UNUserNotificationCenterDelegate { // If the app is open, and a Zendesk view is being shown, Zendesk will display an alert allowing the user to view the updated ticket. handleZendeskNotification(userInfo: userInfo) + // Otherwise see if it's an auth notification + if PushNotificationsManager.shared.handleAuthenticationNotification(userInfo, userInteraction: true, completionHandler: nil) { + return + } + + // If it's a blogging reminder notification, display it in-app + if notification.request.content.categoryIdentifier == NoteCategoryDefinition.bloggingReminderWeekly.rawValue + || notification.request.content.categoryIdentifier == NoteCategoryDefinition.weeklyRoundup.rawValue { + + completionHandler([.banner, .list, .sound]) + return + } + + // Otherwise a share notification let category = notification.request.content.categoryIdentifier guard (category == ShareNoticeConstants.categorySuccessIdentifier || category == ShareNoticeConstants.categoryFailureIdentifier), @@ -485,6 +655,7 @@ extension InteractiveNotificationsManager: UNUserNotificationCenterDelegate { if handleAction(with: response.actionIdentifier, category: response.notification.request.content.categoryIdentifier, + threadId: response.notification.request.content.threadIdentifier, userInfo: userInfo, responseText: textInputResponse?.userText) { completionHandler() @@ -504,12 +675,20 @@ extension InteractiveNotificationsManager: UNUserNotificationCenterDelegate { // - Nuke `PushNotificationsManager` // // - PushNotificationsManager.shared.handleNotification(userInfo) { _ in + PushNotificationsManager.shared.handleNotification(userInfo, userInteraction: true) { _ in completionHandler() } } func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) { - MeNavigationAction.notificationSettings.perform() + MeNavigationAction.notificationSettings.perform(router: UniversalLinkRouter.shared) + } +} + +private extension InteractiveNotificationsManager { + /// A temporary setting to allow controlling WordPress notifications when they are disabled after Jetpack installation + /// Disable WordPress notifications when they are enabled on Jetpack + func disableWordPressNotificationsIfNeeded() { + JetpackNotificationMigrationService.shared.disableWordPressNotificationsFromJetpack() } } diff --git a/WordPress/Classes/Utility/Kanvas/KanvasCameraAnalyticsHandler.swift b/WordPress/Classes/Utility/Kanvas/KanvasCameraAnalyticsHandler.swift new file mode 100644 index 000000000000..30cf83169c77 --- /dev/null +++ b/WordPress/Classes/Utility/Kanvas/KanvasCameraAnalyticsHandler.swift @@ -0,0 +1,320 @@ +import AVFoundation +import Foundation +import Kanvas + +final public class KanvasAnalyticsHandler: NSObject, KanvasAnalyticsProvider { + + public func logCameraOpen(mode: CameraMode) { + logString(string: "logCameraOpen mode:\(modeStringValue(mode))") + } + + public func logCapturedMedia(type: CameraMode, cameraPosition: AVCaptureDevice.Position, length: TimeInterval, ghostFrameEnabled: Bool, filterType: FilterType) { + logString(string: "logCapturedMedia type:\(modeStringValue(type)) cameraPosition:\(positionStringValue(cameraPosition)) length:\(format(length)) ghostFrameEnabled:\(ghostFrameEnabled) filterType:\(filterType.key() ?? "null")") + } + + public func logNextTapped() { + logString(string: "logNextTapped") + } + + public func logConfirmedMedia(mode: CameraMode, clipsCount: Int, length: TimeInterval) { + logString(string: "logConfirmedMedia mode:\(modeStringValue(mode)) clipsCount:\(clipsCount) length:\(format(length))") + } + + public func logDismiss() { + logString(string: "logDismiss") + } + + public func logPhotoCaptured(cameraPosition: String) { + logString(string: "logPhotoCaptured cameraPosition:\(cameraPosition)") + } + + public func logGifCaptured(cameraPosition: String) { + logString(string: "logGifCaptured cameraPosition:\(cameraPosition)") + } + + public func logVideoCaptured(cameraPosition: String) { + logString(string: "logVideoCaptured cameraPosition:\(cameraPosition)") + } + + public func logFlipCamera() { + logString(string: "logFlipCamera") + } + + public func logDeleteSegment() { + logString(string: "logDeleteSegment") + } + + public func logFlashToggled() { + logString(string: "logFlashToggled") + } + + public func logImagePreviewToggled(enabled: Bool) { + logString(string: "logImagePreviewToggled enabled:\(enabled)") + } + + public func logUndoTapped() { + logString(string: "logUndoTapped") + } + + public func logPreviewDismissed() { + logString(string: "logPreviewDismissed") + } + + public func logMovedClip() { + logString(string: "logMovedClip") + } + + public func logPinchedZoom() { + logString(string: "logPinchedZoom") + } + + public func logSwipedZoom() { + logString(string: "logSwipedZoom") + } + + public func logOpenFiltersSelector() { + logString(string: "logOpenFiltersSelector") + } + + public func logFilterSelected(filterType: FilterType) { + logString(string: "logFilterSelected filterType:\(filterType.key() ?? "null")") + } + + public func logMediaPickerOpen() { + logString(string: "logMediaPickerOpen") + } + + public func logMediaPickerDismiss() { + logString(string: "logMediaPickerDismiss") + } + + public func logEditorOpen() { + logString(string: "logEditorOpen") + } + + public func logEditorBack() { + logString(string: "logEditorBack") + } + + public func logMediaPickerPickedMedia(ofTypes mediaTypes: [KanvasMediaType]) { + let typeStrings = mediaTypes.map { $0.string() } + WPAnalytics.track(.storyAddedMedia, properties: ["mediaTypes": typeStrings.joined(separator: ",")]) + } + + public func logEditorFiltersOpen() { + logString(string: "logEditorFiltersOpen") + } + + public func logEditorFilterSelected(filterType: FilterType) { + logString(string: "logEditorFilterSelected filterType:\(filterType.key() ?? "null")") + } + + public func logEditorDrawingOpen() { + logString(string: "logEditorDrawingOpen") + } + + public func logEditorDrawingChangeStrokeSize(strokeSize: Float) { + logString(string: "logEditorDrawingChangeStrokeSize strokeSize:\(format(strokeSize))") + } + + public func logEditorDrawingChangeBrush(brushType: KanvasBrushType) { + logString(string: "logEditorDrawingChangeBrush brushType:\(brushType.string())") + } + + public func logEditorDrawingChangeColor(selectionTool: KanvasColorSelectionTool) { + logString(string: "logEditorDrawingChangeColor selectionTool:\(selectionTool.string())") + } + + public func logEditorDrawStroke(brushType: KanvasBrushType, strokeSize: Float, drawType: KanvasDrawingAction) { + logString(string: "logEditorDrawStroke brushType:\(brushType.string()), strokeSize:\(format(strokeSize)), drawType:\(drawType.string())") + } + + public func logEditorDrawingUndo() { + logString(string: "logEditorDrawingUndo") + } + + public func logEditorDrawingEraser(brushType: KanvasBrushType, strokeSize: Float, drawType: KanvasDrawingAction) { + logString(string: "logEditorDrawingEraser brushType:\(brushType.string()), strokeSize:\(format(strokeSize)), drawType:\(drawType.string())") + } + + public func logEditorDrawingConfirm() { + logString(string: "logEditorDrawingConfirm") + } + + public func logEditorTextAdd() { + logString(string: "logEditorTextAdd") + } + + public func logEditorTextEdit() { + logString(string: "logEditorTextEdit") + } + + public func logEditorTextConfirm(isNew: Bool, font: UIFont, alignment: KanvasTextAlignment, highlighted: Bool) { + logString(string: "logEditorTextConfirm new:\(isNew) font:\(font.fontName) alignment:\(alignment.string()) highlighted:\(highlighted)") + } + + public func logEditorTextMove() { + logString(string: "logEditorTextMove") + } + + public func logEditorTextRemove() { + logString(string: "logEditorTextRemove") + } + + public func logEditorTextChange(font: UIFont) { + logString(string: "logEditorTextChangeFont font:\(font.fontName)") + } + + public func logEditorTextChange(alignment: KanvasTextAlignment) { + logString(string: "logEditorTextChangeAlignment alignment:\(alignment.string())") + } + + public func logEditorTextChange(highlighted: Bool) { + logString(string: "logEditorTextChangeBackground highlighted:\(highlighted)") + } + + public func logEditorTextChange(color: Bool) { + logString(string: "logEditorTextChangeColor") + } + + public func logEditorCreatedMedia(clipsCount: Int, length: TimeInterval) { + logString(string: "logEditorCreatedMedia clipsCount:\(clipsCount) length:\(format(length))") + } + + public func logOpenFromDashboard(openAction: KanvasDashboardOpenAction) { + logString(string: "logOpenFromDashboard openAction:\(openAction.string())") + } + + public func logDismissFromDashboard(dismissAction: KanvasDashboardDismissAction) { + logString(string: "logDismissFromDashboard dismissAction:\(dismissAction.string())") + } + + public func logPostFromDashboard() { + logString(string: "logPostFromDashboard") + } + + public func logChangeBlogForPostFromDashboard() { + logString(string: "logChangeBlogForPostFromDashboard") + } + + public func logSaveFromDashboard() { + logString(string: "logSaveFromDashboard") + } + + public func logOpenComposeFromDashboard() { + logString(string: "logOpenComposeFromDashboard") + } + + public func logEditorTagTapped() { + logString(string: "logEditorTagTapped") + } + + public func logIconPresentedOnDashboard() { + logString(string: "logIconPresentedOnDashboard") + } + + public func logEditorMediaDrawerOpen() { + logString(string: "logEditorMediaDrawerOpen") + } + + public func logEditorMediaDrawerClosed() { + logString(string: "logEditorMediaDrawerClosed") + } + + public func logEditorMediaDrawerSelectStickers() { + logString(string: "logEditorMediaDrawerSelectStickers") + } + + public func logEditorStickerPackSelect(stickerPackId: String) { + logString(string: "logEditorStickerPackSelect stickerPackId: \(stickerPackId)") + } + + public func logEditorStickerAdd(stickerId: String) { + logString(string: "logEditorStickerAdd stickerId: \(stickerId)") + } + + public func logEditorStickerRemove(stickerId: String) { + logString(string: "logEditorStickerRemove stickerId: \(stickerId)") + } + + public func logEditorStickerMove(stickerId: String) { + logString(string: "logEditorStickerMove stickerId: \(stickerId)") + } + + public func logAdvancedOptionsOpen(page: String) { + logString(string: "logAdvancedOptionsOpen Page: \(page)") + } + + public func logEditorGIFButtonToggle(_ value: Bool) { + logString(string: "logEditorGIFButtonToggle value:\(value)") + } + + public func logEditorGIFOpen() { + logString(string: "logEditorGIFOpen") + } + + public func logEditorGIFOpenTrim() { + logString(string: "logEditorGIFOpenTrim") + } + + public func logEditorGIFOpenSpeed() { + logString(string: "logEditorGIFOpenSpeed") + } + + public func logEditorGIFRevert() { + logString(string: "logEditorGIFRevert") + } + + public func logEditorGIFConfirm(duration: TimeInterval, playbackMode: KanvasGIFPlaybackMode, speed: Float) { + logString(string: "logEditorGIFConfirm duration: \(duration), playbackMode: \(playbackMode.string()), speed: \(speed)") + } + + public func logEditorGIFChange(playbackMode: KanvasGIFPlaybackMode) { + logString(string: "logEditorGIFChange playbackMode: \(playbackMode.string())") + } + + public func logEditorGIFChange(speed: Float) { + logString(string: "logEditorGIFChange speed: \(speed)") + } + + public func logEditorGIFChange(trimStart: TimeInterval, trimEnd: TimeInterval) { + logString(string: "logEditorGIFChange trimStart: \(trimStart) end: \(trimEnd)") + } + + func logString(string: String) { + NSLog("\(self): \(string)") + } + + private func format(_ double: Double) -> Double { + return round(100 * double) / 100.0 + } + + private func format(_ float: Float) -> Float { + return round(100 * float) / 100.0 + } + + private func modeStringValue(_ mode: CameraMode) -> String { + switch mode.group { + case .gif: + return "gif" + case .photo: + return "photo" + case .video: + return "video" + } + } + + private func positionStringValue(_ position: AVCaptureDevice.Position) -> String { + switch position { + case .back: + return "rear" + case .front: + return "front" + case .unspecified: + return "unspecified" + @unknown default: + return "unspecified" + } + } + +} diff --git a/WordPress/Classes/Utility/Kanvas/KanvasCameraCustomUI.swift b/WordPress/Classes/Utility/Kanvas/KanvasCameraCustomUI.swift new file mode 100644 index 000000000000..0e28c9a937b5 --- /dev/null +++ b/WordPress/Classes/Utility/Kanvas/KanvasCameraCustomUI.swift @@ -0,0 +1,216 @@ +import Foundation +import Kanvas + +/// Contains custom colors and fonts for the KanvasCamera framework +public class KanvasCustomUI { + + public static let shared = KanvasCustomUI() + + private static let brightBlue = UIColor.muriel(color: MurielColor(name: .blue)).color(for: UITraitCollection(userInterfaceStyle: .dark)) + private static let brightPurple = UIColor.muriel(color: MurielColor(name: .purple)).color(for: UITraitCollection(userInterfaceStyle: .dark)) + private static let brightPink = UIColor.muriel(color: MurielColor(name: .pink)).color(for: UITraitCollection(userInterfaceStyle: .dark)) + private static let brightYellow = UIColor.muriel(color: MurielColor(name: .yellow)).color(for: UITraitCollection(userInterfaceStyle: .dark)) + private static let brightGreen = UIColor.muriel(color: MurielColor(name: .green)).color(for: UITraitCollection(userInterfaceStyle: .dark)) + private static let brightRed = UIColor.muriel(color: MurielColor(name: .red)).color(for: UITraitCollection(userInterfaceStyle: .dark)) + private static let brightOrange = UIColor.muriel(color: MurielColor(name: .orange)).color(for: UITraitCollection(userInterfaceStyle: .dark)) + private static let white = UIColor.white + + static private var firstPrimary: UIColor { + return KanvasCustomUI.primaryColors.first ?? UIColor.blue + } + + static private var lastPrimary: UIColor { + return KanvasCustomUI.primaryColors.last ?? UIColor.green + } + + private let pickerColors: [UIColor] = [KanvasCustomUI.firstPrimary] + KanvasCustomUI.primaryColors + [KanvasCustomUI.lastPrimary] + + private let segmentColors: [UIColor] = KanvasCustomUI.primaryColors + KanvasCustomUI.primaryColors + [KanvasCustomUI.firstPrimary] + + static private let primaryColors: [UIColor] = [.blue, + .purple, + .magenta, + .red, + .yellow, + .green] + + private let backgroundColorCollection: [UIColor] = KanvasCustomUI.primaryColors + + private let mangaColor: UIColor = brightPink + private let toonColor: UIColor = brightOrange + + private let selectedColor = brightBlue // ColorPickerController:29 + private let black25 = UIColor(white: 0, alpha: 0.25) + + func cameraColors() -> KanvasColors { + let firstPrimary = KanvasCustomUI.primaryColors.first ?? .blue + return KanvasColors( + drawingDefaultColor: firstPrimary, + colorPickerColors: pickerColors, + selectedPickerColor: selectedColor, + timeSegmentColors: segmentColors, + backgroundColors: backgroundColorCollection, + strokeColor: firstPrimary, + sliderActiveColor: firstPrimary, + sliderOuterCircleColor: firstPrimary, + trimBackgroundColor: firstPrimary, + trashColor: Self.brightRed, + tooltipBackgroundColor: .systemRed, + closeButtonColor: black25, + cameraConfirmationColor: firstPrimary, + primaryButtonBackgroundColor: Self.brightRed, + permissionsButtonColor: Self.brightBlue, + permissionsButtonAcceptedBackgroundColor: UIColor.muriel(color: MurielColor(name: .green, shade: .shade20)), + overlayColor: UIColor.muriel(color: MurielColor.gray), + filterColors: [ + .manga: mangaColor, + .toon: toonColor, + ]) + } + + private static let cameraPermissions = KanvasFonts.CameraPermissions(titleFont: UIFont.systemFont(ofSize: 26, weight: .medium), descriptionFont: UIFont.systemFont(ofSize: 16), buttonFont: UIFont.systemFont(ofSize: 16, weight: .medium)) + private static let drawer = KanvasFonts.Drawer(textSelectedFont: UIFont.systemFont(ofSize: 14, weight: .medium), textUnselectedFont: UIFont.systemFont(ofSize: 14)) + + func cameraFonts() -> KanvasFonts { + let paddingAdjustment: (UIFont) -> KanvasFonts.Padding? = { font in + if font == UIFont.systemFont(ofSize: font.pointSize) { + return KanvasFonts.Padding(topMargin: 8.0, + leftMargin: 5.7, + extraVerticalPadding: 0.125 * font.pointSize, + extraHorizontalPadding: 0) + } + else { + return nil + } + } + let editorFonts: [UIFont] = [.libreBaskerville(fontSize: 20), .nunitoBold(fontSize: 24), .pacifico(fontSize: 24), .shrikhand(fontSize: 22), .spaceMonoBold(fontSize: 20), .oswaldUpper(fontSize: 22)] + return KanvasFonts(permissions: Self.cameraPermissions, + drawer: Self.drawer, + editorFonts: editorFonts, + optionSelectorCellFont: UIFont.systemFont(ofSize: 16, weight: .medium), + mediaClipsFont: UIFont.systemFont(ofSize: 9.5), + mediaClipsSmallFont: UIFont.systemFont(ofSize: 8), + modeButtonFont: UIFont.systemFont(ofSize: 18.5), + speedLabelFont: UIFont.systemFont(ofSize: 16, weight: .medium), + timeIndicatorFont: UIFont.systemFont(ofSize: 16, weight: .medium), + colorSelectorTooltipFont: + UIFont.systemFont(ofSize: 14), + modeSelectorTooltipFont: UIFont.systemFont(ofSize: 15), + postLabelFont: UIFont.systemFont(ofSize: 14, weight: .medium), + gifMakerRevertButtonFont: UIFont.systemFont(ofSize: 15, weight: .bold), + paddingAdjustment: paddingAdjustment + ) + } + + func cameraImages() -> KanvasImages { + return KanvasImages(confirmImage: UIImage(named: "stories-confirm-button"), editorConfirmImage: UIImage(named: "stories-confirm-button"), nextImage: UIImage(named: "stories-next-button")) + } +} + +enum CustomKanvasFonts: CaseIterable { + case libreBaskerville + case nunitoBold + case pacifico + case oswaldUpper + case shrikhand + case spaceMonoBold + + struct Shadow { + let radius: CGFloat + let offset: CGPoint + let color: UIColor + } + + var name: String { + switch self { + case .libreBaskerville: + return "LibreBaskerville-Regular" + case .nunitoBold: + return "Nunito-Bold" + case .pacifico: + return "Pacifico-Regular" + case .oswaldUpper: + return "Oswald-Regular" + case .shrikhand: + return "Shrikhand-Regular" + case .spaceMonoBold: + return "SpaceMono-Bold" + } + } + + var size: Int { + switch self { + case .libreBaskerville: + return 20 + case .nunitoBold: + return 24 + case .pacifico: + return 24 + case .oswaldUpper: + return 22 + case .shrikhand: + return 22 + case .spaceMonoBold: + return 20 + } + } + + var shadow: Shadow? { + switch self { + case .libreBaskerville: + return nil + case .nunitoBold: + return Shadow(radius: 1, offset: CGPoint(x: 0, y: 2), color: UIColor.black.withAlphaComponent(75)) + case .pacifico: + return Shadow(radius: 5, offset: .zero, color: UIColor.white.withAlphaComponent(50)) + case .oswaldUpper: + return nil + case .shrikhand: + return Shadow(radius: 1, offset: CGPoint(x: 1, y: 2), color: UIColor.black.withAlphaComponent(75)) + case .spaceMonoBold: + return nil + } + } +} + +extension UIFont { + + static func libreBaskerville(fontSize: CGFloat) -> UIFont { + let font = UIFont(name: "LibreBaskerville-Regular", size: fontSize) ?? UIFont.systemFont(ofSize: fontSize, weight: .medium) + return UIFontMetrics.default.scaledFont(for: font) + } + + static func nunitoBold(fontSize: CGFloat) -> UIFont { + let font = UIFont(name: "Nunito-Bold", size: fontSize) ?? UIFont.systemFont(ofSize: fontSize, weight: .medium) + return UIFontMetrics.default.scaledFont(for: font) + } + + static func pacifico(fontSize: CGFloat) -> UIFont { + let font = UIFont(name: "Pacifico-Regular", size: fontSize) ?? UIFont.systemFont(ofSize: fontSize, weight: .medium) + return UIFontMetrics.default.scaledFont(for: font) + } + + static func oswaldUpper(fontSize: CGFloat) -> UIFont { + let font = UIFont(name: "Oswald-Regular", size: fontSize) ?? UIFont.systemFont(ofSize: fontSize, weight: .medium) + return UIFontMetrics.default.scaledFont(for: font) + } + + static func shrikhand(fontSize: CGFloat) -> UIFont { + let font = UIFont(name: "Shrikhand-Regular", size: fontSize) ?? UIFont.systemFont(ofSize: fontSize, weight: .medium) + return UIFontMetrics.default.scaledFont(for: font) + } + + static func spaceMonoBold(fontSize: CGFloat) -> UIFont { + let font = UIFont(name: "SpaceMono-Bold", size: fontSize) ?? UIFont.systemFont(ofSize: fontSize, weight: .medium) + return UIFontMetrics.default.scaledFont(for: font) + } + + @objc func fontByAddingSymbolicTrait(_ trait: UIFontDescriptor.SymbolicTraits) -> UIFont { + let modifiedTraits = fontDescriptor.symbolicTraits.union(trait) + guard let modifiedDescriptor = fontDescriptor.withSymbolicTraits(modifiedTraits) else { + assertionFailure("Unable to created modified font descriptor by adding a symbolic trait.") + return self + } + return UIFont(descriptor: modifiedDescriptor, size: pointSize) + } +} diff --git a/WordPress/Classes/Utility/KeychainTools.swift b/WordPress/Classes/Utility/KeychainTools.swift index 12f6ae8cfe74..8c9fc50a92d2 100644 --- a/WordPress/Classes/Utility/KeychainTools.swift +++ b/WordPress/Classes/Utility/KeychainTools.swift @@ -27,7 +27,7 @@ final class KeychainTools: NSObject { return } - guard let item = UserDefaults.standard.value(forKey: keychainDebugWipeArgument) as? String else { + guard let item = UserPersistentStoreFactory.instance().object(forKey: keychainDebugWipeArgument) as? String else { return } @@ -42,7 +42,7 @@ final class KeychainTools: NSObject { static fileprivate func serviceForItem(_ item: String) -> String? { switch item { case "wordpress.com": - return "public-api.wordpress.com" + return AppConstants.authKeychainServiceName case "*", "all": return nil default: diff --git a/WordPress/Classes/Utility/KeychainUtils.swift b/WordPress/Classes/Utility/KeychainUtils.swift new file mode 100644 index 000000000000..401bef2cb032 --- /dev/null +++ b/WordPress/Classes/Utility/KeychainUtils.swift @@ -0,0 +1,37 @@ +@objcMembers +class KeychainUtils: NSObject { + + private let keychainUtils: SFHFKeychainUtils.Type + + init(keychainUtils: SFHFKeychainUtils.Type = SFHFKeychainUtils.self) { + self.keychainUtils = keychainUtils + } + + func copyKeychain(from sourceAccessGroup: String?, + to destinationAccessGroup: String?, + updateExisting: Bool = true) throws { + let sourceItems = try keychainUtils.getAllPasswords(forAccessGroup: sourceAccessGroup) + + for item in sourceItems { + guard let username = item["username"], + let password = item["password"], + let serviceName = item["serviceName"] else { + continue + } + + try keychainUtils.storeUsername(username, andPassword: password, forServiceName: serviceName, accessGroup: destinationAccessGroup, updateExisting: updateExisting) + } + } + + func password(for username: String, serviceName: String, accessGroup: String? = nil) throws -> String? { + return try keychainUtils.getPasswordForUsername(username, andServiceName: serviceName, accessGroup: accessGroup) + } + + func store(username: String, password: String, serviceName: String, accessGroup: String? = nil, updateExisting: Bool) throws { + return try keychainUtils.storeUsername(username, + andPassword: password, + forServiceName: serviceName, + accessGroup: accessGroup, + updateExisting: updateExisting) + } +} diff --git a/WordPress/Classes/Utility/LinkBehavior.swift b/WordPress/Classes/Utility/LinkBehavior.swift index 45d2098237b7..9cabb533b9af 100644 --- a/WordPress/Classes/Utility/LinkBehavior.swift +++ b/WordPress/Classes/Utility/LinkBehavior.swift @@ -1,20 +1,38 @@ import Foundation import WebKit +protocol ExternalURLHandler { + func open(_ url: URL) +} + +extension UIApplication: ExternalURLHandler { + func open(_ url: URL) { + open(url, options: [:], completionHandler: nil) + } +} + enum LinkBehavior { case all case hostOnly(URL) case urlOnly(URL) + case withBaseURLOnly(String) - func handle(navigationAction: WKNavigationAction, for webView: WKWebView) -> WKNavigationActionPolicy { + func handle(navigationAction: WKNavigationAction, + for webView: WKWebView, + externalURLHandler: ExternalURLHandler = UIApplication.shared) -> WKNavigationActionPolicy { + return handle(request: navigationAction.request, with: navigationAction.navigationType, externalURLHandler: externalURLHandler) + } + func handle(request: URLRequest, + with type: WKNavigationType, + externalURLHandler: ExternalURLHandler = UIApplication.shared) -> WKNavigationActionPolicy { // We only want to apply this policy for links, not for all resource loads - guard navigationAction.navigationType == .linkActivated && navigationAction.request.url == navigationAction.request.mainDocumentURL else { + guard type == .linkActivated && request.url == request.mainDocumentURL else { return .allow } // Should not happen, but future checks will not work if we can't check the URL - guard let navigationURL = navigationAction.request.url else { + guard let navigationURL = request.url else { return .allow } @@ -22,17 +40,24 @@ enum LinkBehavior { case .all: return .allow case .hostOnly(let url): - if navigationAction.request.url?.host == url.host { + if request.url?.host == url.host { return .allow } else { - UIApplication.shared.open(navigationURL) + externalURLHandler.open(navigationURL) return .cancel } case .urlOnly(let url): - if navigationAction.request.url?.absoluteString == url.absoluteString { + if request.url?.absoluteString == url.absoluteString { + return .allow + } else { + externalURLHandler.open(navigationURL) + return .cancel + } + case .withBaseURLOnly(let baseURL): + if request.url?.absoluteString.hasPrefix(baseURL) ?? false { return .allow } else { - UIApplication.shared.open(navigationURL) + externalURLHandler.open(navigationURL) return .cancel } } diff --git a/WordPress/Classes/Utility/LocationService.m b/WordPress/Classes/Utility/LocationService.m index d4726753ae86..0510b4114e88 100644 --- a/WordPress/Classes/Utility/LocationService.m +++ b/WordPress/Classes/Utility/LocationService.m @@ -58,7 +58,7 @@ - (BOOL)locationServicesDisabled - (BOOL)locationServicesDenied { - CLAuthorizationStatus status = [CLLocationManager authorizationStatus]; + CLAuthorizationStatus status = self.locationManager.authorizationStatus; if (status == kCLAuthorizationStatusRestricted || status == kCLAuthorizationStatusDenied) { return YES; } @@ -201,8 +201,9 @@ - (void)locationManager:(CLLocationManager *)manager didUpdateLocations:(NSArray [self getAddressForLocation:location]; } -- (void)locationManager:(CLLocationManager *)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status +- (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager { + CLAuthorizationStatus status = manager.authorizationStatus; if (status == kCLAuthorizationStatusRestricted || status == kCLAuthorizationStatusDenied) { [self serviceFailed:[NSError errorWithDomain:LocationServiceErrorDomain code:LocationServiceErrorPermissionDenied userInfo:nil]]; } @@ -240,7 +241,7 @@ - (void)showAlertForLocationError:(NSError *)error [alertController addAction:okAction]; if (otherButtonTitle) { - UIAlertAction *otherAction = [UIAlertAction actionWithTitle:otherButtonTitle style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + UIAlertAction *otherAction = [UIAlertAction actionWithTitle:otherButtonTitle style:UIAlertActionStyleDefault handler:^(UIAlertAction * __unused action) { NSURL *settingsURL = [NSURL URLWithString:UIApplicationOpenSettingsURLString]; [[UIApplication sharedApplication] openURL:settingsURL options:nil completionHandler:nil]; }]; diff --git a/WordPress/Classes/Utility/Logging/CocoaLumberjack.swift b/WordPress/Classes/Utility/Logging/CocoaLumberjack.swift index 4bd3a61602a1..6113d71870a9 100644 --- a/WordPress/Classes/Utility/Logging/CocoaLumberjack.swift +++ b/WordPress/Classes/Utility/Logging/CocoaLumberjack.swift @@ -1,108 +1,27 @@ import Foundation import CocoaLumberjack -// June 14 2017 - @astralbodies -// Taken from CocoaLumberjack repository - reproduced to prevent issue with -// CocoaPods and some weird bug with frameworks - -// Software License Agreement (BSD License) -// -// Copyright (c) 2014-2016, Deusty, LLC -// All rights reserved. -// -// Redistribution and use of this software in source and binary forms, -// with or without modification, are permitted provided that the following conditions are met: -// -// * Redistributions of source code must retain the above copyright notice, -// this list of conditions and the following disclaimer. -// -// * Neither the name of Deusty nor the names of its contributors may be used -// to endorse or promote products derived from this software without specific -// prior written permission of Deusty, LLC. - -extension DDLogFlag { - public static func from(_ logLevel: DDLogLevel) -> DDLogFlag { - return DDLogFlag(rawValue: logLevel.rawValue) - } - - public init(_ logLevel: DDLogLevel) { - self = DDLogFlag(rawValue: logLevel.rawValue) - } - - ///returns the log level, or the lowest equivalant. - public func toLogLevel() -> DDLogLevel { - if let ourValid = DDLogLevel(rawValue: rawValue) { - return ourValid - } else { - if contains(.verbose) { - return .verbose - } else if contains(.debug) { - return .debug - } else if contains(.info) { - return .info - } else if contains(.warning) { - return .warning - } else if contains(.error) { - return .error - } else { - return .off - } - } - } -} - -public var defaultDebugLevel = DDLogLevel.verbose - -public func resetDefaultDebugLevel() { - defaultDebugLevel = DDLogLevel.verbose -} - -public func _DDLogMessage(_ message: @autoclosure () -> String, level: DDLogLevel, flag: DDLogFlag, context: Int, file: StaticString, function: StaticString, line: UInt, tag: Any?, asynchronous: Bool, ddlog: DDLog) { - if level.rawValue & flag.rawValue != 0 { - // Tell the DDLogMessage constructor to copy the C strings that get passed to it. - let logMessage = DDLogMessage(message: message(), level: level, flag: flag, context: context, file: String(describing: file), function: String(describing: function), line: line, tag: tag, options: [.copyFile, .copyFunction], timestamp: nil) - ddlog.log(asynchronous: asynchronous, message: logMessage) - } -} - -public func DDLogDebug(_ message: @autoclosure () -> String, level: DDLogLevel = defaultDebugLevel, context: Int = 0, file: StaticString = #file, function: StaticString = #function, line: UInt = #line, tag: Any? = nil, asynchronous async: Bool = true, ddlog: DDLog = DDLog.sharedInstance) { - _DDLogMessage(message(), level: level, flag: .debug, context: context, file: file, function: function, line: line, tag: tag, asynchronous: async, ddlog: ddlog) -} - -public func DDLogInfo(_ message: @autoclosure () -> String, level: DDLogLevel = defaultDebugLevel, context: Int = 0, file: StaticString = #file, function: StaticString = #function, line: UInt = #line, tag: Any? = nil, asynchronous async: Bool = true, ddlog: DDLog = DDLog.sharedInstance) { - _DDLogMessage(message(), level: level, flag: .info, context: context, file: file, function: function, line: line, tag: tag, asynchronous: async, ddlog: ddlog) +@inlinable +public func DDLogVerbose(_ message: @autoclosure () -> Any, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + CocoaLumberjack.DDLogVerbose(message(), file: file, function: function, line: line) } -public func DDLogWarn(_ message: @autoclosure () -> String, level: DDLogLevel = defaultDebugLevel, context: Int = 0, file: StaticString = #file, function: StaticString = #function, line: UInt = #line, tag: Any? = nil, asynchronous async: Bool = true, ddlog: DDLog = DDLog.sharedInstance) { - _DDLogMessage(message(), level: level, flag: .warning, context: context, file: file, function: function, line: line, tag: tag, asynchronous: async, ddlog: ddlog) +@inlinable +public func DDLogDebug(_ message: @autoclosure () -> Any, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + CocoaLumberjack.DDLogDebug(message(), file: file, function: function, line: line) } -public func DDLogVerbose(_ message: @autoclosure () -> String, level: DDLogLevel = defaultDebugLevel, context: Int = 0, file: StaticString = #file, function: StaticString = #function, line: UInt = #line, tag: Any? = nil, asynchronous async: Bool = true, ddlog: DDLog = DDLog.sharedInstance) { - _DDLogMessage(message(), level: level, flag: .verbose, context: context, file: file, function: function, line: line, tag: tag, asynchronous: async, ddlog: ddlog) +@inlinable +public func DDLogInfo(_ message: @autoclosure () -> Any, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + CocoaLumberjack.DDLogInfo(message(), file: file, function: function, line: line) } -public func DDLogError(_ message: @autoclosure () -> String, level: DDLogLevel = defaultDebugLevel, context: Int = 0, file: StaticString = #file, function: StaticString = #function, line: UInt = #line, tag: Any? = nil, asynchronous async: Bool = false, ddlog: DDLog = DDLog.sharedInstance) { - _DDLogMessage(message(), level: level, flag: .error, context: context, file: file, function: function, line: line, tag: tag, asynchronous: async, ddlog: ddlog) +@inlinable +public func DDLogWarn(_ message: @autoclosure () -> Any, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + CocoaLumberjack.DDLogWarn(message(), file: file, function: function, line: line) } -/// Returns a String of the current filename, without full path or extension. -/// -/// Analogous to the C preprocessor macro `THIS_FILE`. -public func CurrentFileName(_ fileName: StaticString = #file) -> String { - var str = String(describing: fileName) - if let idx = str.range(of: "/", options: .backwards)?.upperBound { -#if swift(>=4.0) - str = String(str[idx...]) -#else - str = str.substring(from: idx) -#endif - } - if let idx = str.range(of: ".", options: .backwards)?.lowerBound { -#if swift(>=4.0) - str = String(str.prefix(upTo: idx)) -#else - str = str.substring(to: idx) -#endif - } - return str +@inlinable +public func DDLogError(_ message: @autoclosure () -> Any, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + CocoaLumberjack.DDLogError(message(), file: file, function: function, line: line) } diff --git a/WordPress/Classes/Utility/Logging/CrashLogging+Singleton.swift b/WordPress/Classes/Utility/Logging/CrashLogging+Singleton.swift new file mode 100644 index 000000000000..9a164405cea3 --- /dev/null +++ b/WordPress/Classes/Utility/Logging/CrashLogging+Singleton.swift @@ -0,0 +1,15 @@ +import Foundation +import AutomatticTracks + +extension CrashLogging { + + static let main: CrashLogging = { + if let crashLogging = WordPressAppDelegate.crashLogging { + return crashLogging + } + // `WordPressAppDelegate.crashLogging` is probably never going to be nil + // So the following code won't be executed at runtime. + let stack = WPLoggingStack() + return stack.crashLogging + }() +} diff --git a/WordPress/Classes/Utility/Logging/EventLoggingDataProvider.swift b/WordPress/Classes/Utility/Logging/EventLoggingDataProvider.swift new file mode 100644 index 000000000000..86aadb6f3fa3 --- /dev/null +++ b/WordPress/Classes/Utility/Logging/EventLoggingDataProvider.swift @@ -0,0 +1,38 @@ +import Foundation +import AutomatticTracks +import CocoaLumberjack + +struct EventLoggingDataProvider: EventLoggingDataSource { + + typealias LogFilesCallback = (() -> [URL]) + + /// A block that returns all existing log files + private let fetchLogFiles: LogFilesCallback? + + /// Initialize the data provider using a block. + /// + /// Because the most recent log file path can change at runtime, we must determine which is the most recent log file each time we access it. + /// For example: if a given session spans a day boundary the logging system may roll the log file transparently in the background. + init(_ block: @escaping LogFilesCallback) { + self.fetchLogFiles = block + } + + /// The key used to encrypt log files + let loggingEncryptionKey: String = ApiCredentials.encryptedLogKey + + /// The Authorization token for the upload endpoint + var loggingAuthenticationToken: String = ApiCredentials.secret + + /// The current session log will almost always be the correct one, because they're split by day + func logFilePath(forErrorLevel: EventLoggingErrorType, at date: Date) -> URL? { + return fetchLogFiles?().first + } + + static func fromDDFileLogger(_ logger: DDFileLogger) -> EventLoggingDataSource { + EventLoggingDataProvider { + logger.logFileManager.sortedLogFileInfos.map { + URL(fileURLWithPath: $0.filePath) + } + } + } +} diff --git a/WordPress/Classes/Utility/Logging/EventLoggingDelegate.swift b/WordPress/Classes/Utility/Logging/EventLoggingDelegate.swift new file mode 100644 index 000000000000..df00a4cab9cb --- /dev/null +++ b/WordPress/Classes/Utility/Logging/EventLoggingDelegate.swift @@ -0,0 +1,54 @@ +import Foundation +import AutomatticTracks + +struct EventLoggingDelegate: AutomatticTracks.EventLoggingDelegate { + + var shouldUploadLogFiles: Bool { + return + !ProcessInfo.processInfo.isLowPowerModeEnabled + && !UserSettings.userHasOptedOutOfCrashLogging + } + + func didQueueLogForUpload(_ log: LogFile) { + NotificationCenter.default.post(name: WPLoggingStack.QueuedLogsDidChangeNotification, object: log) + DDLogDebug("📜 Added log to queue: \(log.uuid)") + + DispatchQueue.main.async { + guard let eventLogging = WordPressAppDelegate.eventLogging else { + return + } + + DDLogDebug("📜\t There are \(eventLogging.queuedLogFiles.count) logs in the queue.") + } + } + + func didStartUploadingLog(_ log: LogFile) { + NotificationCenter.default.post(name: WPLoggingStack.QueuedLogsDidChangeNotification, object: log) + DDLogDebug("📜 Started uploading encrypted log: \(log.uuid)") + } + + func didFinishUploadingLog(_ log: LogFile) { + NotificationCenter.default.post(name: WPLoggingStack.QueuedLogsDidChangeNotification, object: log) + DDLogDebug("📜 Finished uploading encrypted log: \(log.uuid)") + + DispatchQueue.main.async { + guard let eventLogging = WordPressAppDelegate.eventLogging else { + return + } + + DDLogDebug("📜\t There are \(eventLogging.queuedLogFiles.count) logs remaining in the queue.") + } + } + + func uploadFailed(withError error: Error, forLog log: LogFile) { + NotificationCenter.default.post(name: WPLoggingStack.QueuedLogsDidChangeNotification, object: log) + DDLogError("📜 Error uploading encrypted log: \(log.uuid)") + DDLogError("📜\t\(error.localizedDescription)") + + let nserror = error as NSError + DDLogError("📜\t Code: \(nserror.code)") + if let details = nserror.localizedFailureReason { + DDLogError("📜\t Details: \(details)") + } + } +} diff --git a/WordPress/Classes/Utility/Logging/SentryStartupEvent.swift b/WordPress/Classes/Utility/Logging/SentryStartupEvent.swift index 143df989482f..f80d322a1071 100644 --- a/WordPress/Classes/Utility/Logging/SentryStartupEvent.swift +++ b/WordPress/Classes/Utility/Logging/SentryStartupEvent.swift @@ -1,4 +1,5 @@ import Foundation +import AutomatticTracks import Sentry private struct ErrorWithCaller { @@ -21,6 +22,10 @@ startup time. This will block the thread. Do not use unless you're sure. errors.append(ErrorWithCaller(error: error, caller: "\(function) (\(filename):\(line))")) } + func add(error: Error, file: String = #file, function: String = #function, line: UInt = #line) { + add(error: error as NSError, file: file, function: function, line: line) + } + @objc(addError:file:function:line:) func _objc_add(error: NSError, file: UnsafePointer<CChar>, function: UnsafePointer<CChar>, line: UInt) { add(error: error, file: String(cString: file), function: String(cString: function), line: line) @@ -28,15 +33,7 @@ startup time. This will block the thread. Do not use unless you're sure. // Send the event and block the thread until it was actually sent @objc func send(title: String) { - guard !WPAppAnalytics.userHasOptedOut(), - let client = try? Client(dsn: ApiCredentials.sentryDSN()) else { - return - } - let semaphore = DispatchSemaphore(value: 0) - let event = Event(level: .debug) - event.message = title - - event.extra = errors.enumerated().reduce(into: [String: Any](), { (result, arg1) in + let userInfo = errors.enumerated().reduce(into: [String: Any](), { (result, arg1) in let (index, errorWithCaller) = arg1 let error = errorWithCaller.error result["Error \(index + 1)"] = [ @@ -45,13 +42,15 @@ startup time. This will block the thread. Do not use unless you're sure. "Code": error.code, "Description": error.localizedDescription, "User Info": error.userInfo.description - ] + ] as [String: Any] }) - client.send(event: event, completion: { _ in - semaphore.signal() - }) - - semaphore.wait() + let error = NSError(domain: title, code: -1, userInfo: [NSLocalizedDescriptionKey: title]) + do { + try WordPressAppDelegate.crashLogging?.logErrorAndWait(error, userInfo: userInfo, level: SentryLevel.fatal) + } catch let err { + DDLogError("⛔️ Unable to send startup error message to Sentry:") + DDLogError(err.localizedDescription) + } } } diff --git a/WordPress/Classes/Utility/Logging/WPCrashLoggingProvider.swift b/WordPress/Classes/Utility/Logging/WPCrashLoggingProvider.swift index 97f250a4f350..bf8319dbd521 100644 --- a/WordPress/Classes/Utility/Logging/WPCrashLoggingProvider.swift +++ b/WordPress/Classes/Utility/Logging/WPCrashLoggingProvider.swift @@ -1,23 +1,56 @@ -import Foundation +import UIKit import AutomatticTracks -fileprivate let UserOptedOutKey = "crashlytics_opt_out" +/// A wrapper around the logging stack – provides shared initialization and configuration for Tracks Crash and Event Logging +struct WPLoggingStack { -class WPCrashLoggingProvider: CrashLoggingDataProvider { + static let QueuedLogsDidChangeNotification = NSNotification.Name("WPCrashLoggingQueueDidChange") - var sentryDSN: String = ApiCredentials.sentryDSN() + let crashLogging: CrashLogging + let eventLogging: EventLogging - var buildType: String = BuildConfiguration.current.rawValue + private let eventLoggingDataProvider = EventLoggingDataProvider.fromDDFileLogger(WPLogger.shared().fileLogger) + private let eventLoggingDelegate = EventLoggingDelegate() + + init() { + + let eventLogging = EventLogging(dataSource: eventLoggingDataProvider, delegate: eventLoggingDelegate) + + self.eventLogging = eventLogging + self.crashLogging = CrashLogging(dataProvider: WPCrashLoggingDataProvider(), eventLogging: eventLogging) + + /// Upload any remaining files any time the app becomes active + let willEnterForeground = UIApplication.willEnterForegroundNotification + NotificationCenter.default.addObserver(forName: willEnterForeground, object: nil, queue: nil, using: self.willEnterForeground) + } + + func start() throws { + _ = try crashLogging.start() + } + + private func willEnterForeground(note: Foundation.Notification) { + self.eventLogging.uploadNextLogFileIfNeeded() + DDLogDebug("📜 Resumed encrypted log upload queue due to app entering foreground") + } +} + +struct WPCrashLoggingDataProvider: CrashLoggingDataProvider { + let sentryDSN: String = ApiCredentials.sentryDSN var userHasOptedOut: Bool { - return UserDefaults.standard.bool(forKey: UserOptedOutKey) + return UserSettings.userHasOptedOutOfCrashLogging } - var currentUser: TracksUser? { + var buildType: String = BuildConfiguration.current.rawValue + var shouldEnableAutomaticSessionTracking: Bool { + return !UserSettings.userHasOptedOutOfCrashLogging + } + + var currentUser: TracksUser? { let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - guard let account = service.defaultWordPressComAccount() else { + + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) else { return nil } diff --git a/WordPress/Classes/Utility/Logging/WPLogger.h b/WordPress/Classes/Utility/Logging/WPLogger.h index 0f8fc4f1beca..c5edc3cf2ada 100644 --- a/WordPress/Classes/Utility/Logging/WPLogger.h +++ b/WordPress/Classes/Utility/Logging/WPLogger.h @@ -8,7 +8,7 @@ */ @interface WPLogger : NSObject -@property (nonatomic, strong, readonly) DDFileLogger *fileLogger; +@property (nonatomic, strong, readonly) DDFileLogger * _Nonnull fileLogger; #pragma mark - Reading from the log @@ -19,8 +19,23 @@ * * @returns The requested log data. */ -- (NSString *)getLogFilesContentWithMaxSize:(NSInteger)maxSize; +- (NSString * _Nonnull)getLogFilesContentWithMaxSize:(NSInteger)maxSize; + (void)configureLoggerLevelWithExtraDebug; +/** + * @brief A shared instance that can be called from anywhere + */ ++ (WPLogger * _Nonnull)shared; + +/** + * @brief Deletes all the logs from the device + */ +- (void)deleteAllLogs; + +/** + * @brief Deletes all the old archived logs from the device + */ +- (void)deleteArchivedLogs; + @end diff --git a/WordPress/Classes/Utility/Logging/WPLogger.m b/WordPress/Classes/Utility/Logging/WPLogger.m index c09095ad367c..d56012c728f2 100644 --- a/WordPress/Classes/Utility/Logging/WPLogger.m +++ b/WordPress/Classes/Utility/Logging/WPLogger.m @@ -3,13 +3,29 @@ @import CocoaLumberjack; +DDLogLevel ddLogLevel = DDLogLevelInfo; + +void SetCocoaLumberjackObjCLogLevel(NSUInteger ddLogLevelRawValue) +{ + ddLogLevel = (DDLogLevel)ddLogLevelRawValue; +} + @interface WPLogger () -@property (nonatomic, strong, readwrite) DDFileLogger *fileLogger; +@property (nonatomic, strong, readwrite) DDFileLogger * _Nonnull fileLogger; @end @implementation WPLogger -#pragma mark - Inititlization +#pragma mark - Initialization + ++ (WPLogger *)shared { + static WPLogger *shared = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + shared = [[self alloc] init]; + }); + return shared; +} - (instancetype)init { @@ -92,10 +108,34 @@ - (NSString *)getLogFilesContentWithMaxSize:(NSInteger)maxSize return description; } +#pragma mark - Deleting + +- (void)deleteAllLogs +{ + NSArray *logFiles = self.fileLogger.logFileManager.sortedLogFileInfos; + for (DDLogFileInfo *logFileInfo in logFiles) { + [[NSFileManager defaultManager] removeItemAtPath:logFileInfo.filePath error:nil]; + } + + DDLogWarn(@"All log files erased."); +} + +- (void)deleteArchivedLogs +{ + NSArray *logFiles = self.fileLogger.logFileManager.sortedLogFileInfos; + for (DDLogFileInfo *logFileInfo in logFiles) { + if (logFileInfo.isArchived) { + [[NSFileManager defaultManager] removeItemAtPath:logFileInfo.filePath error:nil]; + } + } + + DDLogWarn(@"All archived log files erased."); +} + #pragma mark - Public static methods + (void)configureLoggerLevelWithExtraDebug { - BOOL extraDebug = [[NSUserDefaults standardUserDefaults] boolForKey:@"extra_debug"]; + BOOL extraDebug = [[UserPersistentStoreFactory userDefaultsInstance] boolForKey:@"extra_debug"]; if (extraDebug) { [WordPressAppDelegate setLogLevel:DDLogLevelVerbose]; } else { diff --git a/WordPress/Classes/Utility/Logging/WordPressLibraryLogger.swift b/WordPress/Classes/Utility/Logging/WordPressLibraryLogger.swift new file mode 100644 index 000000000000..5d35d472db6a --- /dev/null +++ b/WordPress/Classes/Utility/Logging/WordPressLibraryLogger.swift @@ -0,0 +1,26 @@ +import CocoaLumberjack +import AutomatticTracks +import WordPressShared + +class WordPressLibraryLogger: NSObject, TracksLoggingDelegate, WordPressLoggingDelegate { + + func logError(_ str: String) { + DDLogError(str) + } + + func logWarning(_ str: String) { + DDLogWarn(str) + } + + func logInfo(_ str: String) { + DDLogInfo(str) + } + + func logDebug(_ str: String) { + DDLogDebug(str) + } + + func logVerbose(_ str: String) { + DDLogVerbose(str) + } +} diff --git a/WordPress/Classes/Utility/Media/Blog+VideoLimits.swift b/WordPress/Classes/Utility/Media/Blog+VideoLimits.swift new file mode 100644 index 000000000000..5440ce8e7875 --- /dev/null +++ b/WordPress/Classes/Utility/Media/Blog+VideoLimits.swift @@ -0,0 +1,11 @@ +extension Blog { + + /// returns true if the blog is allowed to upload the given asset, true otherwise + func canUploadAsset(_ asset: WPMediaAsset) -> Bool { + return canUploadAsset(asset.exceedsFreeSitesAllowance()) + } + + public func canUploadAsset(_ assetExceedsFreeSitesAllowance: Bool) -> Bool { + return hasPaidPlan || !isHostedAtWPcom || !assetExceedsFreeSitesAllowance + } +} diff --git a/WordPress/Classes/Utility/Media/GIFPlaybackStrategy.swift b/WordPress/Classes/Utility/Media/GIFPlaybackStrategy.swift index 68f93387316f..c2d6d670db75 100644 --- a/WordPress/Classes/Utility/Media/GIFPlaybackStrategy.swift +++ b/WordPress/Classes/Utility/Media/GIFPlaybackStrategy.swift @@ -2,6 +2,7 @@ import Foundation @objc public enum GIFStrategy: Int { + case tinyGIFs case smallGIFs case mediumGIFs case largeGIFs @@ -10,6 +11,8 @@ public enum GIFStrategy: Int { /// var playbackStrategy: GIFPlaybackStrategy { switch self { + case .tinyGIFs: + return TinyGIFPlaybackStrategy() case .smallGIFs: return SmallGIFPlaybackStrategy() case .mediumGIFs: @@ -51,21 +54,27 @@ extension GIFPlaybackStrategy { return true } } +// This is good for thumbnail GIFs used in a collection view +class TinyGIFPlaybackStrategy: GIFPlaybackStrategy { + var maxSize = 2_000_000 // in MB + var frameBufferCount = 5 + var gifStrategy: GIFStrategy = .tinyGIFs +} class SmallGIFPlaybackStrategy: GIFPlaybackStrategy { var maxSize = 8_000_000 // in MB - var frameBufferCount = 25 + var frameBufferCount = 50 var gifStrategy: GIFStrategy = .smallGIFs } class MediumGIFPlaybackStrategy: GIFPlaybackStrategy { var maxSize = 20_000_000 // in MB - var frameBufferCount = 50 + var frameBufferCount = 150 var gifStrategy: GIFStrategy = .mediumGIFs } class LargeGIFPlaybackStrategy: GIFPlaybackStrategy { var maxSize = 50_000_000 // in MB - var frameBufferCount = 60 + var frameBufferCount = 300 var gifStrategy: GIFStrategy = .largeGIFs } diff --git a/WordPress/Classes/Utility/Media/ImageDownloader.swift b/WordPress/Classes/Utility/Media/ImageDownloader.swift index 40c01ea0a46a..816e2b055ee4 100644 --- a/WordPress/Classes/Utility/Media/ImageDownloader.swift +++ b/WordPress/Classes/Utility/Media/ImageDownloader.swift @@ -1,18 +1,32 @@ -import Foundation +import UIKit +// MARK: - ImageDownloadTask protocol + +/// This protocol can be implemented to represent an image download task handled by the ImageDownloader. +/// +protocol ImageDownloaderTask { + /// Calling this method should cancel the task's execution. + /// + func cancel() +} + +extension Operation: ImageDownloaderTask {} +extension URLSessionTask: ImageDownloaderTask {} + +extension URLSession: ImageDownloaderTask { + func cancel() { + invalidateAndCancel() + } +} // MARK: - Image Downloading Tool -// + class ImageDownloader { /// Shared Instance! /// static let shared = ImageDownloader() - /// Public Aliases - /// - typealias Task = URLSessionDataTask - /// Internal URLSession Instance /// private let session = URLSession(configuration: .default) @@ -26,9 +40,8 @@ class ImageDownloader { /// Downloads the UIImage resource at the specified URL. On completion the received closure will be executed. /// @discardableResult - func downloadImage(at url: URL, completion: @escaping (UIImage?, Error?) -> Void) -> Task { + func downloadImage(at url: URL, completion: @escaping (UIImage?, Error?) -> Void) -> ImageDownloaderTask { var request = URLRequest(url: url) - request.httpShouldHandleCookies = false request.addValue("image/*", forHTTPHeaderField: "Accept") return downloadImage(for: request, completion: completion) @@ -37,22 +50,65 @@ class ImageDownloader { /// Downloads the UIImage resource at the specified endpoint. On completion the received closure will be executed. /// @discardableResult - func downloadImage(for request: URLRequest, completion: @escaping (UIImage?, Error?) -> Void) -> Task { + func downloadImage(for request: URLRequest, completion: @escaping (UIImage?, Error?) -> Void) -> ImageDownloaderTask { let task = session.dataTask(with: request) { (data, _, error) in - guard let data = data, let image = UIImage(data: data) else { - let error = error ?? ImageDownloaderError.failed - completion(nil, error) - return + guard let data = data else { + if let error = error { + completion(nil, error) + } else { + completion(nil, ImageDownloaderError.failed) + } + return } - completion(image, nil) + if let gif = self.makeGIF(with: data, request: request) { + completion(gif, nil) + } else if let image = UIImage.init(data: data) { + completion(image, nil) + } else { + completion(nil, ImageDownloaderError.failed) + } } task.resume() return task } + + private func makeGIF(with data: Data, request: URLRequest) -> AnimatedImageWrapper? { + guard let url = request.url, url.pathExtension.lowercased() == "gif" else { + return nil + } + + return AnimatedImageWrapper(gifData: data) + } } +// MARK: - AnimatedImageWrapper + +/// This is a wrapper around `RCTAnimatedImage` that allows including extra information +/// to better render the gifs in text views. +/// +/// This class uses the RCTAnimatedImage to verify the image is a valid gif which is why I'm still +/// using that here. +class AnimatedImageWrapper: UIImage { + var gifData: Data? = nil + var targetSize: CGSize? = nil + + private static let playbackStrategy: GIFPlaybackStrategy = LargeGIFPlaybackStrategy() + + convenience init?(gifData: Data) { + self.init(data: gifData, scale: 1) + + // Don't store the gifdata if they're too large + // We still allow the the RCTAnimatedImage to be rendered since it will still render + // the first frame, but not eat up data + guard gifData.count < Self.playbackStrategy.maxSize else { + return + } + + self.gifData = gifData + } +} // MARK: - Error Types // diff --git a/WordPress/Classes/Utility/Media/ImageLoader.swift b/WordPress/Classes/Utility/Media/ImageLoader.swift index 201fd9278af6..023c3a2c3b72 100644 --- a/WordPress/Classes/Utility/Media/ImageLoader.swift +++ b/WordPress/Classes/Utility/Media/ImageLoader.swift @@ -1,18 +1,6 @@ import MobileCoreServices - -/// Protocol used to abstract the information needed to load post related images. -/// -@objc protocol ImageSourceInformation { - - /// The post is private and hosted on WPcom. - /// Redundant name due to naming conflict. - /// - var isPrivateOnWPCom: Bool { get } - - /// The blog is self-hosted and there is already a basic auth credential stored. - /// - var isSelfHostedWithCredentials: Bool { get } -} +import AlamofireImage +import AutomatticTracks /// Class used together with `CachedAnimatedImageView` to facilitate the loading of both /// still images and animated gifs. @@ -32,6 +20,15 @@ import MobileCoreServices } } + // MARK: - Image Dimensions Support + typealias ImageLoaderDimensionsBlock = (ImageDimensionFormat, CGSize) -> Void + + /// Called if the imageLoader is able to determine the image format, and dimensions + /// for the image prior to it being downloaded. + /// Note: Set the property prior to calling any load method + public var imageDimensionsHandler: ImageLoaderDimensionsBlock? + private var imageDimensionsFetcher: ImageDimensionsFetcher? = nil + // MARK: Private Fields private unowned let imageView: CachedAnimatedImageView @@ -53,7 +50,7 @@ import MobileCoreServices @objc init(imageView: CachedAnimatedImageView, gifStrategy: GIFStrategy = .mediumGIFs) { self.imageView = imageView imageView.gifStrategy = gifStrategy - loadingIndicator = CircularProgressView(style: .wordPressBlue) + loadingIndicator = CircularProgressView(style: .primary) super.init() @@ -68,26 +65,26 @@ import MobileCoreServices imageView.prepForReuse() } - @objc(loadImageWithURL:fromPost:andPreferredSize:) /// Load an image from a specific post, using the given URL. Supports animated images (gifs) as well. /// /// - Parameters: /// - url: The URL to load the image from. - /// - post: The post where the image is loaded from. + /// - host: The `MediaHost` of the image. /// - size: The preferred size of the image to load. /// - func loadImage(with url: URL, from source: ImageSourceInformation, preferredSize size: CGSize = .zero) { - if url.isGif { - loadGif(with: url, from: source, preferredSize: size) + func loadImage(with url: URL, from host: MediaHost, preferredSize size: CGSize = .zero) { + if url.isFileURL { + downloadImage(from: url) + } else if url.isGif { + loadGif(with: url, from: host, preferredSize: size) } else { imageView.clean() - loadStaticImage(with: url, from: source, preferredSize: size) + loadStaticImage(with: url, from: host, preferredSize: size) } } - @objc(loadImageWithURL:success:error:) /// Load an image from a specific URL. As no source is provided, we can assume - /// that this is from an external source. Supports animated images (gifs) as well. + /// that this is from a public site. Supports animated images (gifs) as well. /// /// - Parameters: /// - url: The URL to load the image from. @@ -99,96 +96,148 @@ import MobileCoreServices errorHandler = error if url.isGif { - loadGif(with: url, from: nil) + loadGif(with: url, from: .publicSite) } else { imageView.clean() - loadStaticImage(with: url, from: nil) + loadStaticImage(with: url, from: .publicSite) } } @objc(loadImageWithURL:fromPost:preferredSize:placeholder:success:error:) + func loadImage(with url: URL, from post: AbstractPost, preferredSize size: CGSize = .zero, placeholder: UIImage?, success: ImageLoaderSuccessBlock?, error: ImageLoaderFailureBlock?) { + + let host = MediaHost(with: post, failure: { error in + WordPressAppDelegate.crashLogging?.logError(error) + }) + + loadImage(with: url, from: host, preferredSize: size, placeholder: placeholder, success: success, error: error) + } + + @objc(loadImageWithURL:fromReaderPost:preferredSize:placeholder:success:error:) + func loadImage(with url: URL, from readerPost: ReaderPost, preferredSize size: CGSize = .zero, placeholder: UIImage?, success: ImageLoaderSuccessBlock?, error: ImageLoaderFailureBlock?) { + + let host = MediaHost(with: readerPost, failure: { error in + WordPressAppDelegate.crashLogging?.logError(error) + }) + + loadImage(with: url, from: host, preferredSize: size, placeholder: placeholder, success: success, error: error) + } + /// Load an image from a specific post, using the given URL. Supports animated images (gifs) as well. /// /// - Parameters: /// - url: The URL to load the image from. - /// - post: The post where the image is loaded from. + /// - host: The host of the image. /// - size: The preferred size of the image to load. You can pass height 0 to set width and preserve aspect ratio. /// - placeholder: A placeholder to show while the image is loading. /// - success: A closure to be called if the image was loaded successfully. /// - error: A closure to be called if there was an error loading the image. - func loadImage(with url: URL, from source: ImageSourceInformation, preferredSize size: CGSize = .zero, placeholder: UIImage?, success: ImageLoaderSuccessBlock?, error: ImageLoaderFailureBlock?) { + func loadImage(with url: URL, from host: MediaHost, preferredSize size: CGSize = .zero, placeholder: UIImage?, success: ImageLoaderSuccessBlock?, error: ImageLoaderFailureBlock?) { self.placeholder = placeholder successHandler = success errorHandler = error - loadImage(with: url, from: source, preferredSize: size) + loadImage(with: url, from: host, preferredSize: size) } // MARK: - Private helpers /// Load an animated image from the given URL. /// - private func loadGif(with url: URL, from source: ImageSourceInformation?, preferredSize size: CGSize = .zero) { - let request: URLRequest - if url.isFileURL { - request = URLRequest(url: url) - } else if let source = source, source.isPrivateOnWPCom, PrivateSiteURLProtocol.urlGoes(toWPComSite: url) { - request = PrivateSiteURLProtocol.requestForPrivateSite(from: url) - } else { - if let photonUrl = getPhotonUrl(for: url, size: size), - source != nil { - request = URLRequest(url: photonUrl) - } else { - request = URLRequest(url: url) - } - } - downloadGif(from: request) + private func loadGif(with url: URL, from host: MediaHost, preferredSize size: CGSize = .zero) { + let mediaAuthenticator = MediaRequestAuthenticator() + mediaAuthenticator.authenticatedRequest( + for: url, + from: host, + onComplete: { request in + self.downloadGif(from: request) + }, + onFailure: { error in + WordPressAppDelegate.crashLogging?.logError(error) + self.callErrorHandler(with: error) + }) } /// Load a static image from the given URL. /// - private func loadStaticImage(with url: URL, from source: ImageSourceInformation?, preferredSize size: CGSize = .zero) { - if url.isFileURL { - downloadImage(from: url) - } else if let source = source { - if source.isPrivateOnWPCom && PrivateSiteURLProtocol.urlGoes(toWPComSite: url) { - loadPrivateImage(with: url, from: source, preferredSize: size) - } else if source.isSelfHostedWithCredentials { - downloadImage(from: url) - } else { - loadPhotonUrl(with: url, preferredSize: size) - } - } else { - downloadImage(from: url) + private func loadStaticImage(with url: URL, from host: MediaHost, preferredSize size: CGSize = .zero) { + let finalURL: URL + + switch host { + case .publicSite: fallthrough + case .privateSelfHostedSite: + finalURL = url + case .publicWPComSite: fallthrough + case .privateAtomicWPComSite: + finalURL = photonUrl(with: url, preferredSize: size) + case .privateWPComSite: + finalURL = privateImageURL(with: url, from: host, preferredSize: size) + } + + let mediaRequestAuthenticator = MediaRequestAuthenticator() + + mediaRequestAuthenticator.authenticatedRequest(for: finalURL, from: host, onComplete: { request in + self.downloadImage(from: request) + }) { error in + WordPressAppDelegate.crashLogging?.logError(error) + self.callErrorHandler(with: error) } } - /// Loads the image from a private post hosted in WPCom. + /// Constructs the URL for an image from a private post hosted in WPCom. /// - private func loadPrivateImage(with url: URL, from source: ImageSourceInformation, preferredSize size: CGSize) { + private func privateImageURL(with url: URL, from host: MediaHost, preferredSize size: CGSize) -> URL { let scale = UIScreen.main.scale let scaledSize = CGSize(width: size.width * scale, height: size.height * scale) let scaledURL = WPImageURLHelper.imageURLWithSize(scaledSize, forImageURL: url) - let request = PrivateSiteURLProtocol.requestForPrivateSite(from: scaledURL) - downloadImage(from: request) + return scaledURL } - /// Loads the image from the Photon API with the given size. + /// Gets the photon URL with the specified size, or returns the passed `URL` /// - private func loadPhotonUrl(with url: URL, preferredSize size: CGSize) { + private func photonUrl(with url: URL, preferredSize size: CGSize) -> URL { guard let photonURL = getPhotonUrl(for: url, size: size) else { - downloadImage(from: url) + return url + } + + return photonURL + } + + + /// Triggers the image dimensions fetcher if the `imageDimensionsHandler` property is set + private func calculateImageDimensionsIfNeeded(from request: URLRequest) { + guard let imageDimensionsHandler = imageDimensionsHandler else { return } - downloadImage(from: photonURL) + let fetcher = ImageDimensionsFetcher(request: request, success: { (format, size) in + guard let size = size, size != .zero else { + return + } + + DispatchQueue.main.async { + imageDimensionsHandler(format, size) + } + }) + + fetcher.start() + + imageDimensionsFetcher = fetcher + } + + /// Stop the image dimension calculation + private func cancelImageDimensionCalculation() { + imageDimensionsFetcher?.cancel() + imageDimensionsFetcher = nil } /// Download the animated image from the given URL Request. /// private func downloadGif(from request: URLRequest) { + calculateImageDimensionsIfNeeded(from: request) + imageView.startLoadingAnimation() imageView.setAnimatedImage(request, placeholderImage: placeholder, success: { [weak self] in self?.callSuccessHandler() @@ -200,28 +249,33 @@ import MobileCoreServices /// Downloads the image from the given URL Request. /// private func downloadImage(from request: URLRequest) { + calculateImageDimensionsIfNeeded(from: request) + imageView.startLoadingAnimation() - imageView.downloadImage(usingRequest: request, placeholderImage: placeholder, success: { [weak self] (image) in - // Since a success block is specified, we need to set the image manually. - self?.imageView.image = image - self?.callSuccessHandler() - }) { [weak self] (error) in - self?.callErrorHandler(with: error) - } + imageView.af_setImage(withURLRequest: request, completion: { [weak self] dataResponse in + guard let self = self else { + return + } + + switch dataResponse.result { + case .success: + self.callSuccessHandler() + case .failure(let error): + self.callErrorHandler(with: error) + } + }) } /// Downloads the image from the given URL. /// private func downloadImage(from url: URL) { - imageView.startLoadingAnimation() - imageView.downloadImage(from: url, placeholderImage: placeholder, success: { [weak self] (_) in - self?.callSuccessHandler() - }) { [weak self] (error) in - self?.callErrorHandler(with: error) - } + let request = URLRequest(url: url) + downloadImage(from: request) } private func callSuccessHandler() { + cancelImageDimensionCalculation() + imageView.stopLoadingAnimation() guard successHandler != nil else { return @@ -236,6 +290,8 @@ import MobileCoreServices return } + cancelImageDimensionCalculation() + DispatchQueue.main.async { [weak self] in guard let self = self else { return @@ -258,7 +314,7 @@ import MobileCoreServices // MARK: - Loading Media object extension ImageLoader { - @objc(loadImageFromMedia:preferredSize:placeholder:success:error:) + @objc(loadImageFromMedia:preferredSize:placeholder:isBlogAtomic:success:error:) /// Load an image from the given Media object. If it's a gif, it will animate it. /// For any other type of media, this will load the corresponding static image. /// @@ -266,10 +322,16 @@ extension ImageLoader { /// - media: The media object /// - placeholder: A placeholder to show while the image is loading. /// - size: The preferred size of the image to load. + /// - isBlogAtomic: Whether the blog associated to the media item is Atomic or not /// - success: A closure to be called if the image was loaded successfully. /// - error: A closure to be called if there was an error loading the image. /// - func loadImage(media: Media, preferredSize size: CGSize = .zero, placeholder: UIImage?, success: ImageLoaderSuccessBlock?, error: ImageLoaderFailureBlock?) { + func loadImage(media: Media, + preferredSize size: CGSize = .zero, + placeholder: UIImage?, + isBlogAtomic: Bool, + success: ImageLoaderSuccessBlock?, + error: ImageLoaderFailureBlock?) { guard let mediaId = media.mediaID?.stringValue else { let error = createError(description: "The Media id doesn't exist") callErrorHandler(with: error) @@ -286,7 +348,7 @@ extension ImageLoader { if let fetchedMedia = fetchedMedia, let fetchedMediaId = fetchedMedia.mediaID?.stringValue, fetchedMediaId == mediaId { DispatchQueue.main.async { - self?.loadImage(media: fetchedMedia, preferredSize: size, placeholder: placeholder, success: success, error: error) + self?.loadImage(media: fetchedMedia, preferredSize: size, placeholder: placeholder, isBlogAtomic: isBlogAtomic, success: success, error: error) } } else { self?.callErrorHandler(with: fetchedMediaError) @@ -301,7 +363,12 @@ extension ImageLoader { } if url.isGif { - loadGif(with: url, from: media.blog, preferredSize: size) + let host = MediaHost(with: media.blog, isAtomic: isBlogAtomic) { error in + // We'll log the error, so we know it's there, but we won't halt execution. + WordPressAppDelegate.crashLogging?.logError(error) + } + + loadGif(with: url, from: host, preferredSize: size) } else if imageView.image == nil { imageView.clean() loadImage(from: media, preferredSize: size) @@ -381,7 +448,7 @@ extension ImageLoader { imageView.image = placeholder imageView.startLoadingAnimation() - PHImageManager.default().requestImageData(for: asset, + PHImageManager.default().requestImageDataAndOrientation(for: asset, options: assetRequestOptions, resultHandler: { [weak self] (data, str, orientation, info) -> Void in guard info?[PHImageErrorKey] == nil else { diff --git a/WordPress/Classes/Utility/Media/MediaAssetExporter.swift b/WordPress/Classes/Utility/Media/MediaAssetExporter.swift index 3390b491fabe..34356b4f0286 100644 --- a/WordPress/Classes/Utility/Media/MediaAssetExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaAssetExporter.swift @@ -111,7 +111,7 @@ class MediaAssetExporter: MediaExporter { } // Request the image. - imageManager.requestImageData(for: asset, + imageManager.requestImageDataAndOrientation(for: asset, options: options, resultHandler: { (data, uti, orientation, info) in progress.completedUnitCount = MediaExportProgressUnits.halfDone @@ -167,12 +167,7 @@ class MediaAssetExporter: MediaExporter { } // Configure a video exporter to handle an export session. - var exporterVideoOptions = videoOptions ?? MediaVideoExporter.Options() - - if exporterVideoOptions.preferredExportVideoType == nil { - exporterVideoOptions.preferredExportVideoType = videoResource.uniformTypeIdentifier - } - + let exporterVideoOptions = videoOptions ?? MediaVideoExporter.Options() let originalFilename = videoResource.originalFilename // Request an export session, which may take time to download the complete video data. diff --git a/WordPress/Classes/Utility/Media/MediaFileManager.swift b/WordPress/Classes/Utility/Media/MediaFileManager.swift index 7352d7f2365b..0391ac48874d 100644 --- a/WordPress/Classes/Utility/Media/MediaFileManager.swift +++ b/WordPress/Classes/Utility/Media/MediaFileManager.swift @@ -221,8 +221,7 @@ class MediaFileManager: NSObject { /// Removes any local Media files, except any Media matching the predicate. /// fileprivate func purgeMediaFiles(exceptMedia predicate: NSPredicate, onCompletion: (() -> Void)?, onError: ((Error) -> Void)?) { - let context = ContextManager.sharedInstance().newDerivedContext() - context.perform { + ContextManager.shared.performAndSave({ context in let fetch = NSFetchRequest<NSDictionary>(entityName: Media.classNameWithoutNamespaces()) fetch.predicate = predicate fetch.resultType = .dictionaryResultType @@ -230,34 +229,29 @@ class MediaFileManager: NSObject { let localThumbnailURLProperty = #selector(getter: Media.localThumbnailURL).description fetch.propertiesToFetch = [localURLProperty, localThumbnailURLProperty] - do { - let mediaToKeep = try context.fetch(fetch) - var filesToKeep: Set<String> = [] - for dictionary in mediaToKeep { - if let localPath = dictionary[localURLProperty] as? String, - let localURL = URL(string: localPath) { - filesToKeep.insert(localURL.lastPathComponent) - } - if let localThumbnailPath = dictionary[localThumbnailURLProperty] as? String, - let localThumbnailURL = URL(string: localThumbnailPath) { - filesToKeep.insert(localThumbnailURL.lastPathComponent) - } + + let mediaToKeep = try context.fetch(fetch) + var filesToKeep: Set<String> = [] + for dictionary in mediaToKeep { + if let localPath = dictionary[localURLProperty] as? String, + let localURL = URL(string: localPath) { + filesToKeep.insert(localURL.lastPathComponent) } - try self.purgeDirectory(exceptFiles: filesToKeep) - if let onCompletion = onCompletion { - DispatchQueue.main.async { - onCompletion() - } + if let localThumbnailPath = dictionary[localThumbnailURLProperty] as? String, + let localThumbnailURL = URL(string: localThumbnailPath) { + filesToKeep.insert(localThumbnailURL.lastPathComponent) } - } catch { + } + try self.purgeDirectory(exceptFiles: filesToKeep) + }, completion: { result in + switch result { + case .success: + onCompletion?() + case let .failure(error): DDLogError("Error while attempting to clean local media: \(error.localizedDescription)") - if let onError = onError { - DispatchQueue.main.async { - onError(error) - } - } + onError?(error) } - } + }, on: .main) } /// Removes files in the Media directory, except any files found in the set. diff --git a/WordPress/Classes/Utility/Media/MediaURLExporter.swift b/WordPress/Classes/Utility/Media/MediaURLExporter.swift index 1171b2c1dfd2..6ebe56bb1530 100644 --- a/WordPress/Classes/Utility/Media/MediaURLExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaURLExporter.swift @@ -77,10 +77,10 @@ class MediaURLExporter: MediaExporter { /// func exportURL(fileURL: URL, onCompletion: @escaping OnMediaExport, onError: @escaping OnExportError) -> Progress { // Verify the export is permissible - if let urlExportOptions = urlOptions, - !urlExportOptions.allowableFileExtensions.isEmpty, - let fileExtension = fileURL.typeIdentifierFileExtension { - if !urlExportOptions.allowableFileExtensions.contains(fileExtension) { + if let urlExportOptions = urlOptions, let fileExtension = fileURL.typeIdentifierFileExtension { + // Check the default file types. We want to limit to supported types but mobile is not restricted to only allowed ones. + // `allowableFileExtensions` can be empty for self-hosted sites + if !MediaImportService.defaultAllowableFileExtensions.contains(fileExtension) && !urlExportOptions.allowableFileExtensions.isEmpty && !urlExportOptions.allowableFileExtensions.contains(fileExtension) { onError(exporterErrorWith(error: URLExportError.unsupportedFileType)) return Progress.discreteCompletedProgress() } diff --git a/WordPress/Classes/Utility/Media/MediaVideoExporter.swift b/WordPress/Classes/Utility/Media/MediaVideoExporter.swift index f65c0c2451eb..003b95dc4f78 100644 --- a/WordPress/Classes/Utility/Media/MediaVideoExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaVideoExporter.swift @@ -86,6 +86,7 @@ class MediaVideoExporter: MediaExporter { onError(exporterErrorWith(error: VideoExportError.videoAssetWasDetectedAsNotExportable)) return Progress.discreteCompletedProgress() } + guard let session = AVAssetExportSession(asset: asset, presetName: options.exportPreset) else { onError(exporterErrorWith(error: VideoExportError.failedToInitializeVideoExportSession)) return Progress.discreteCompletedProgress() @@ -139,6 +140,7 @@ class MediaVideoExporter: MediaExporter { let observer = VideoSessionProgressObserver(videoSession: session, progressHandler: { value in progress.completedUnitCount = Int64(Float(MediaExportProgressUnits.done) * value) }) + session.exportAsynchronously { observer.stop() guard session.status == .completed else { diff --git a/WordPress/Classes/Utility/Media/VideoLimitsAlertPresenter.swift b/WordPress/Classes/Utility/Media/VideoLimitsAlertPresenter.swift new file mode 100644 index 000000000000..daa0552f7e91 --- /dev/null +++ b/WordPress/Classes/Utility/Media/VideoLimitsAlertPresenter.swift @@ -0,0 +1,36 @@ + +/// Handles user alerts regarding video limits allowances +protocol VideoLimitsAlertPresenter { + func presentVideoLimitExceededFromPicker(on viewController: UIViewController) + func presentVideoLimitExceededAfterCapture(on viewController: UIViewController) +} + +extension VideoLimitsAlertPresenter { + + /// Alerts users that the video they are trying to select from a media picker exceeds the allowed duration + func presentVideoLimitExceededFromPicker(on viewController: UIViewController) { + let title = NSLocalizedString("Selection not allowed", + comment: "Title of an alert informing users that the video they are trying to select is not allowed.") + presentVideoLimitsAlert(on: viewController, title: title) + } + + /// Alerts users that the video they just recorded exceeds the allowed duration + func presentVideoLimitExceededAfterCapture(on viewController: UIViewController) { + let title = NSLocalizedString("Video not uploaded", + comment: "Title of an alert informing users that the video they are trying to select is not allowed.") + presentVideoLimitsAlert(on: viewController, title: title) + } + + /// Builds and presents an alert for users trying to upload a video that exceeds allowed limits + /// - Parameters: + /// - viewController: presenting UIViewController + /// - title: title of the alert + private func presentVideoLimitsAlert(on viewController: UIViewController, title: String) { + let message = NSLocalizedString("Uploading videos longer than 5 minutes requires a paid plan.", + comment: "Message of an alert informing users that the video they are trying to select is not allowed.") + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + + alert.addAction(UIAlertAction(title: "Ok", style: .default)) + viewController.present(alert, animated: true, completion: nil) + } +} diff --git a/WordPress/Classes/Utility/Media/WPMediaAsset+VideoLimits.swift b/WordPress/Classes/Utility/Media/WPMediaAsset+VideoLimits.swift new file mode 100644 index 000000000000..7ee455051fb5 --- /dev/null +++ b/WordPress/Classes/Utility/Media/WPMediaAsset+VideoLimits.swift @@ -0,0 +1,8 @@ +extension WPMediaAsset { + /// Returns true if the asset is a video and its duration is longer than the maximum allowed duration on free sites. + func exceedsFreeSitesAllowance() -> Bool { + // maximum allowed duration for video uploads on free sites, in seconds (5 mins) + let maximumVideoDurationForFreeSites: CGFloat = 300 + return assetType() == .video && duration() > maximumVideoDurationForFreeSites + } +} diff --git a/WordPress/Classes/Utility/MessageAnimator.swift b/WordPress/Classes/Utility/MessageAnimator.swift new file mode 100644 index 000000000000..13ae573c5ba4 --- /dev/null +++ b/WordPress/Classes/Utility/MessageAnimator.swift @@ -0,0 +1,151 @@ +import UIKit +import WordPressShared + +/// NoticeAnimator is a helper class to animate error messages. +/// +/// The notices show at the top of the target view, and are meant to appear to +/// be attached to a navigation bar. The expected usage is to display offline +/// status or requests taking longer than usual. +/// +/// To use an NoticeAnimator, you need to keep a reference to it, and call two +/// methods: +/// +/// - `layout()` from your `UIView.layoutSubviews()` or +/// `UIViewController.viewDidLayoutSubviews()`. Failure to do this won't render +/// the animation correctly. +/// +/// - `animateMessage(_)` when you want to change the message displayed. Pass +/// nil if you want to hide the error view. +/// +class MessageAnimator: Animator { + + // MARK: - Private Constants + fileprivate struct Defaults { + static let animationDuration = 0.3 + static let padding = UIOffset(horizontal: 15, vertical: 20) + static let labelFont = WPStyleGuide.regularTextFont() + } + + + // MARK: - Private properties + fileprivate var previousHeight: CGFloat = 0 + fileprivate var message: String? { + get { + return noticeLabel.label.text + } + set { + noticeLabel.label.text = newValue + } + } + + + // MARK: - Private Immutable Properties + fileprivate let targetView: UIView + fileprivate let noticeLabel: PaddedLabel = { + let label = PaddedLabel() + label.backgroundColor = .primary(.shade40) + label.clipsToBounds = true + label.padding.horizontal = Defaults.padding.horizontal + label.label.textColor = UIColor.white + label.label.font = Defaults.labelFont + label.label.numberOfLines = 0 + return label + }() + + + // MARK: - Private Computed Properties + fileprivate var shouldDisplayMessage: Bool { + return message != nil + } + fileprivate var targetTableView: UITableView? { + return targetView as? UITableView + } + + + + // MARK: - Initializers + @objc init(target: UIView) { + targetView = target + super.init() + } + + + + // MARK: - Public Methods + @objc func layout() { + var targetFrame = noticeLabel.frame + targetFrame.size.width = targetView.bounds.width + targetFrame.size.height = heightForMessage(message) + noticeLabel.frame = targetFrame + } + + @objc func animateMessage(_ message: String?) { + let shouldAnimate = self.message != message + self.message = message + + if shouldAnimate { + animateWithDuration(Defaults.animationDuration, preamble: preamble, animations: animations, cleanup: cleanup) + } + } + + + + // MARK: - Animation Methods + fileprivate func preamble() { + UIView.performWithoutAnimation { [weak self] in + self?.targetView.layoutIfNeeded() + } + + if shouldDisplayMessage == true && noticeLabel.superview == nil { + targetView.addSubview(noticeLabel) + noticeLabel.frame.size.height = CGSize.zero.height + noticeLabel.label.alpha = 0 + } + } + + fileprivate func animations() { + let height = heightForMessage(message) + + if shouldDisplayMessage { + // Position + Size + Alpha + noticeLabel.frame.origin.y = -height + noticeLabel.frame.size.height = height + noticeLabel.label.alpha = 1 + + // Table Insets + Offset + targetTableView?.contentInset.top += height - previousHeight + if targetTableView?.contentOffset.y == 0 { + targetTableView?.contentOffset.y = -height + previousHeight + } + + } else { + // Size + Alpha + noticeLabel.frame.size.height = CGSize.zero.height + noticeLabel.label.alpha = 0 + + // Table Insets + targetTableView?.contentInset.top -= previousHeight + } + + previousHeight = height + } + + fileprivate func cleanup() { + if shouldDisplayMessage == false { + noticeLabel.removeFromSuperview() + previousHeight = CGSize.zero.height + } + } + + + + // MARK: - Helpers + fileprivate func heightForMessage(_ message: String?) -> CGFloat { + guard let message = message else { + return CGSize.zero.height + } + + let size = message.suggestedSize(with: Defaults.labelFont, width: targetView.frame.width) + return round(size.height + Defaults.padding.vertical) + } +} diff --git a/WordPress/Classes/Utility/Migration/ContentMigrationCoordinator.swift b/WordPress/Classes/Utility/Migration/ContentMigrationCoordinator.swift new file mode 100644 index 000000000000..033162e5d7d5 --- /dev/null +++ b/WordPress/Classes/Utility/Migration/ContentMigrationCoordinator.swift @@ -0,0 +1,197 @@ +/// Encapsulates logic related to content migration from WordPress to Jetpack. +/// +@objc class ContentMigrationCoordinator: NSObject { + + @objc static var shared: ContentMigrationCoordinator = { + .init() + }() + + var previousMigrationError: MigrationError? { + guard let storedErrorValue = sharedPersistentRepository?.string(forKey: .exportErrorSharedKey) else { + return nil + } + + return .init(rawValue: storedErrorValue) + } + + // MARK: Dependencies + + private let coreDataStack: CoreDataStack + private let dataMigrator: ContentDataMigrating + private let notificationCenter: NotificationCenter + private let userPersistentRepository: UserPersistentRepository + private let sharedPersistentRepository: UserPersistentRepository? + private let eligibilityProvider: ContentMigrationEligibilityProvider + private let tracker: MigrationAnalyticsTracker + + init(coreDataStack: CoreDataStack = ContextManager.shared, + dataMigrator: ContentDataMigrating = DataMigrator(), + notificationCenter: NotificationCenter = .default, + userPersistentRepository: UserPersistentRepository = UserDefaults.standard, + sharedPersistentRepository: UserPersistentRepository? = UserDefaults(suiteName: WPAppGroupName), + eligibilityProvider: ContentMigrationEligibilityProvider = AppConfiguration(), + tracker: MigrationAnalyticsTracker = .init()) { + self.coreDataStack = coreDataStack + self.dataMigrator = dataMigrator + self.notificationCenter = notificationCenter + self.userPersistentRepository = userPersistentRepository + self.sharedPersistentRepository = sharedPersistentRepository + self.eligibilityProvider = eligibilityProvider + self.tracker = tracker + + super.init() + + // register for account change notification. + ensureBackupDataDeletedOnLogout() + } + + enum MigrationError: String, LocalizedError { + case ineligible + case exportFailure + case localDraftsNotSynced + + var errorDescription: String? { + switch self { + case .ineligible: return "Content export is ineligible" + case .exportFailure: return "Content export failed" + case .localDraftsNotSynced: return "Local drafts not synced" + } + } + } + + // MARK: Methods + + /// Starts the content migration process of exporting app data to the shared location + /// that will be accessible by the Jetpack app. + /// + /// The completion block is intentionally called regardless of whether the export process + /// succeeds or fails. Since the export process consists of local file operations, we should + /// just let the user continue with the original intent in case of failure. + /// + /// - Parameter completion: Closure called after the export process completes. + func startAndDo(completion: ((Result<Void, MigrationError>) -> Void)? = nil) { + guard eligibilityProvider.isEligibleForMigration else { + tracker.trackContentExportEligibility(eligible: false) + processResult(.failure(.ineligible), completion: completion) + return + } + + guard isLocalPostsSynced() else { + let error = MigrationError.localDraftsNotSynced + tracker.trackContentExportFailed(reason: error.localizedDescription) + processResult(.failure(error), completion: completion) + return + } + + dataMigrator.exportData { [weak self] result in + switch result { + case .success: + self?.tracker.trackContentExportSucceeded() + self?.processResult(.success(()), completion: completion) + + case .failure(let error): + DDLogError("[Jetpack Migration] Error exporting data: \(error)") + self?.tracker.trackContentExportFailed(reason: error.localizedDescription) + self?.processResult(.failure(.exportFailure), completion: completion) + } + } + } + + /// Attempts to clean up the exported data by re-exporting user content if they're still eligible, or deleting them otherwise. + /// Re-exporting user content ensures that the exported data will match the latest state of Account and Blogs. + /// + @objc func cleanupExportedDataIfNeeded() { + // try to re-export the user content if they're still eligible. + startAndDo { [weak self] result in + switch result { + case .failure(let error) where error == .ineligible: + // if the user is no longer eligible, ensure that any exported contents are deleted. + self?.dataMigrator.deleteExportedData() + default: + break + } + } + } +} + +// MARK: - Private Helpers + +private extension ContentMigrationCoordinator { + + func isLocalPostsSynced() -> Bool { + let fetchRequest = NSFetchRequest<Post>(entityName: String(describing: Post.self)) + fetchRequest.predicate = NSPredicate(format: "remoteStatusNumber = %@ || remoteStatusNumber = %@ || remoteStatusNumber = %@ || remoteStatusNumber = %@", + NSNumber(value: AbstractPostRemoteStatus.pushing.rawValue), + NSNumber(value: AbstractPostRemoteStatus.failed.rawValue), + NSNumber(value: AbstractPostRemoteStatus.local.rawValue), + NSNumber(value: AbstractPostRemoteStatus.pushingMedia.rawValue)) + guard let count = try? coreDataStack.mainContext.count(for: fetchRequest) else { + return false + } + + return count == 0 + } + + /// When the user logs out, ensure that any exported data is deleted if it exists at the backup location. + /// This prevents the user from entering the migration flow and immediately gets shown with a login pop-up (since we couldn't migrate the authToken anymore). + /// + func ensureBackupDataDeletedOnLogout() { + // we only need to listen to changes from the WordPress side. + guard AppConfiguration.isWordPress else { + return + } + + notificationCenter.addObserver(forName: .WPAccountDefaultWordPressComAccountChanged, object: nil, queue: nil) { [weak self] notification in + // nil notification object means it's a logout event. + guard let self, + notification.object == nil else { + return + } + + self.cleanupExportedDataIfNeeded() + } + } + + /// A "middleware" logic that attempts to record (or clear) any migration error to the App Group space + /// before calling the completion block. + /// + /// - Parameters: + /// - result: The `Result` object from the export process. + /// - completion: Closure that'll be executed after the process completes. + func processResult(_ result: Result<Void, MigrationError>, completion: ((Result<Void, MigrationError>) -> Void)?) { + // make sure that we're only intercepting from the WordPress side. + guard AppConfiguration.isWordPress else { + completion?(result) + return + } + + switch result { + case .success: + sharedPersistentRepository?.removeObject(forKey: .exportErrorSharedKey) + + case .failure(let error): + sharedPersistentRepository?.set(error.rawValue, forKey: .exportErrorSharedKey) + } + + completion?(result) + } +} + +// MARK: - Content Migration Eligibility Provider + +protocol ContentMigrationEligibilityProvider { + /// Determines if we should export user's content data in the current app state. + var isEligibleForMigration: Bool { get } +} + +extension AppConfiguration: ContentMigrationEligibilityProvider { + var isEligibleForMigration: Bool { + FeatureFlag.contentMigration.enabled && Self.isWordPress && AccountHelper.isLoggedIn && AccountHelper.hasBlogs + } +} + +// MARK: - Constants + +private extension String { + static let exportErrorSharedKey = "wordpress_shared_export_error" +} diff --git a/WordPress/Classes/Utility/Migrations/10-11/BlogToAccount.m b/WordPress/Classes/Utility/Migrations/10-11/BlogToAccount.m index 9f6f18e9744a..10b55fb1852a 100644 --- a/WordPress/Classes/Utility/Migrations/10-11/BlogToAccount.m +++ b/WordPress/Classes/Utility/Migrations/10-11/BlogToAccount.m @@ -1,8 +1,8 @@ #import "BlogToAccount.h" #import <NSURL_IDN/NSURL+IDN.h> -#import "SFHFKeychainUtils.h" #import "WPAccount.h" #import "Constants.h" +#import "WordPress-Swift.h" @implementation BlogToAccount { NSString *_defaultWpcomUsername; @@ -19,7 +19,7 @@ - (BOOL)endEntityMapping:(NSEntityMapping *)mapping manager:(NSMigrationManager DDLogInfo(@"%@ %@ (%@ -> %@)", self, NSStringFromSelector(_cmd), [mapping sourceEntityName], [mapping destinationEntityName]); NSString *const WPComDefaultAccountUsernameKey = @"wpcom_username_preference"; - NSString *username = [[NSUserDefaults standardUserDefaults] objectForKey:WPComDefaultAccountUsernameKey]; + NSString *username = [[UserPersistentStoreFactory userDefaultsInstance] objectForKey:WPComDefaultAccountUsernameKey]; if (!username) { // There is no default WordPress.com account, nothing to do here return YES; @@ -42,11 +42,10 @@ - (BOOL)endEntityMapping:(NSEntityMapping *)mapping manager:(NSMigrationManager NSString *newKey = WPComXMLRPCUrl; NSError *error; - NSString *password = [SFHFKeychainUtils getPasswordForUsername:username andServiceName:oldKey error:&error]; + NSString *password = [SFHFKeychainUtils getPasswordForUsername:username andServiceName:oldKey accessGroup:nil error:&error]; if (password) { - if ([SFHFKeychainUtils storeUsername:username andPassword:password - forServiceName:newKey updateExisting:YES error:&error]) { - [SFHFKeychainUtils deleteItemForUsername:username andServiceName:oldKey error:&error]; + if ([SFHFKeychainUtils storeUsername:username andPassword:password forServiceName:newKey accessGroup:nil updateExisting:YES error:&error]) { + [SFHFKeychainUtils deleteItemForUsername:username andServiceName:oldKey accessGroup:nil error:&error]; } } if (error) { @@ -55,7 +54,7 @@ - (BOOL)endEntityMapping:(NSEntityMapping *)mapping manager:(NSMigrationManager } NSURL *accountURL = [[account objectID] URIRepresentation]; - [[NSUserDefaults standardUserDefaults] setURL:accountURL forKey:WPComDefaultAccountUrlKey]; + [[UserPersistentStoreFactory userDefaultsInstance] setURL:accountURL forKey:WPComDefaultAccountUrlKey]; return YES; } @@ -113,10 +112,10 @@ - (BOOL)createDestinationInstancesForSourceInstance:(NSManagedObject *)source newKey = xmlrpc; } NSError *error; - NSString *password = [SFHFKeychainUtils getPasswordForUsername:username andServiceName:oldKey error:&error]; + NSString *password = [SFHFKeychainUtils getPasswordForUsername:username andServiceName:oldKey accessGroup:nil error:&error]; if (password) { - if ([SFHFKeychainUtils storeUsername:username andPassword:password forServiceName:newKey updateExisting:YES error:&error]) { - [SFHFKeychainUtils deleteItemForUsername:username andServiceName:oldKey error:&error]; + if ([SFHFKeychainUtils storeUsername:username andPassword:password forServiceName:newKey accessGroup:nil updateExisting:YES error:&error]) { + [SFHFKeychainUtils deleteItemForUsername:username andServiceName:oldKey accessGroup:nil error:&error]; } } if (error) { diff --git a/WordPress/Classes/Utility/Migrations/10-11/BlogToJetpackAccount.h b/WordPress/Classes/Utility/Migrations/10-11/BlogToJetpackAccount.h index a968df495c56..16036b624912 100644 --- a/WordPress/Classes/Utility/Migrations/10-11/BlogToJetpackAccount.h +++ b/WordPress/Classes/Utility/Migrations/10-11/BlogToJetpackAccount.h @@ -1,5 +1,7 @@ #import <CoreData/CoreData.h> +static NSString * const WPComXMLRPCUrl = @"https://wordpress.com/xmlrpc.php"; + @interface BlogToJetpackAccount : NSEntityMigrationPolicy @end diff --git a/WordPress/Classes/Utility/Migrations/10-11/BlogToJetpackAccount.m b/WordPress/Classes/Utility/Migrations/10-11/BlogToJetpackAccount.m index 9bce74debdd9..4d4923dc6c89 100644 --- a/WordPress/Classes/Utility/Migrations/10-11/BlogToJetpackAccount.m +++ b/WordPress/Classes/Utility/Migrations/10-11/BlogToJetpackAccount.m @@ -1,9 +1,8 @@ #import "BlogToJetpackAccount.h" -#import "SFHFKeychainUtils.h" #import "WPAccount.h" +#import "WordPress-Swift.h" static NSString * const BlogJetpackKeychainPrefix = @"jetpackblog-"; -static NSString * const WPComXMLRPCUrl = @"https://wordpress.com/xmlrpc.php"; @implementation BlogToJetpackAccount @@ -53,10 +52,10 @@ - (BOOL)createDestinationInstancesForSourceInstance:(NSManagedObject *)source // Migrate passwords NSError *error; - NSString *password = [SFHFKeychainUtils getPasswordForUsername:username andServiceName:@"WordPress.com" error:&error]; + NSString *password = [SFHFKeychainUtils getPasswordForUsername:username andServiceName:@"WordPress.com" accessGroup:nil error:nil]; if (password) { - if ([SFHFKeychainUtils storeUsername:username andPassword:password forServiceName:WPComXMLRPCUrl updateExisting:YES error:&error]) { - [SFHFKeychainUtils deleteItemForUsername:username andServiceName:@"WordPress.com" error:&error]; + if ([SFHFKeychainUtils storeUsername:username andPassword:password forServiceName:WPComXMLRPCUrl accessGroup:nil updateExisting:YES error:&error]) { + [SFHFKeychainUtils deleteItemForUsername:username andServiceName:@"WordPress.com" accessGroup:nil error:&error]; } } if (error) { @@ -106,13 +105,13 @@ - (NSString *)jetpackDefaultsKeyForBlog:(NSManagedObject *)blog - (NSString *)jetpackUsernameForBlog:(NSManagedObject *)blog { - return [[NSUserDefaults standardUserDefaults] stringForKey:[self jetpackDefaultsKeyForBlog:blog]]; + return [[UserPersistentStoreFactory userDefaultsInstance] stringForKey:[self jetpackDefaultsKeyForBlog:blog]]; } - (NSString *)jetpackPasswordForBlog:(NSManagedObject *)blog { NSError *error = nil; - return [SFHFKeychainUtils getPasswordForUsername:[self jetpackUsernameForBlog:blog] andServiceName:@"WordPress.com" error:&error]; + return [SFHFKeychainUtils getPasswordForUsername:[self jetpackUsernameForBlog:blog] andServiceName:@"WordPress.com" accessGroup:nil error:&error]; } @end diff --git a/WordPress/Classes/Utility/Migrations/20-21/AccountToAccount20to21.swift b/WordPress/Classes/Utility/Migrations/20-21/AccountToAccount20to21.swift index 3d1df43b7d28..153ab0d5b6b8 100644 --- a/WordPress/Classes/Utility/Migrations/20-21/AccountToAccount20to21.swift +++ b/WordPress/Classes/Utility/Migrations/20-21/AccountToAccount20to21.swift @@ -18,8 +18,8 @@ class AccountToAccount20to21: NSEntityMigrationPolicy { if let unwrappedAccount = legacyDefaultWordPressAccount(manager.sourceContext) { let username = unwrappedAccount.value(forKey: "username") as! String - let userDefaults = UserDefaults.standard - userDefaults.setValue(username, forKey: defaultDotcomUsernameKey) + let userDefaults = UserPersistentStoreFactory.instance() + userDefaults.set(username, forKey: defaultDotcomUsernameKey) DDLogWarn(">> Migration process matched [\(username)] as the default WordPress.com account") } else { @@ -29,7 +29,7 @@ class AccountToAccount20to21: NSEntityMigrationPolicy { override func end(_ mapping: NSEntityMapping, manager: NSMigrationManager) throws { // Load the default username - let userDefaults = UserDefaults.standard + let userDefaults = UserPersistentStoreFactory.instance() let defaultUsername = userDefaults.string(forKey: defaultDotcomUsernameKey) ?? String() // Find the Default Account @@ -55,7 +55,7 @@ class AccountToAccount20to21: NSEntityMigrationPolicy { // MARK: - Private Helpers fileprivate func legacyDefaultWordPressAccount(_ context: NSManagedObjectContext) -> NSManagedObject? { - let objectURL = UserDefaults.standard.url(forKey: defaultDotcomKey) + let objectURL = UserPersistentStoreFactory.instance().url(forKey: defaultDotcomKey) if objectURL == nil { return nil } @@ -88,7 +88,7 @@ class AccountToAccount20to21: NSEntityMigrationPolicy { let accountURL = account.objectID.uriRepresentation() - let defaults = UserDefaults.standard + let defaults = UserPersistentStoreFactory.instance() defaults.set(accountURL, forKey: defaultDotcomKey) } } diff --git a/WordPress/Classes/Utility/Migrations/87-88/BlogToBlogMigration87to88.swift b/WordPress/Classes/Utility/Migrations/87-88/BlogToBlogMigration87to88.swift index 06f6569dd342..6de287b75651 100644 --- a/WordPress/Classes/Utility/Migrations/87-88/BlogToBlogMigration87to88.swift +++ b/WordPress/Classes/Utility/Migrations/87-88/BlogToBlogMigration87to88.swift @@ -4,7 +4,7 @@ class BlogToBlogMigration87to88: NSEntityMigrationPolicy { override func createRelationships(forDestination dInstance: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager) throws { try super.createRelationships(forDestination: dInstance, in: mapping, manager: manager) - let gutenbergEnabledFlag = UserDefaults.standard.object(forKey: "kUserDefaultsGutenbergEditorEnabled") as? Bool + let gutenbergEnabledFlag = UserPersistentStoreFactory.instance().object(forKey: "kUserDefaultsGutenbergEditorEnabled") as? Bool let isGutenbergEnabled = gutenbergEnabledFlag ?? false let editor = isGutenbergEnabled ? "gutenberg" : "aztec" @@ -13,15 +13,14 @@ class BlogToBlogMigration87to88: NSEntityMigrationPolicy { if gutenbergEnabledFlag != nil { let url = dInstance.value(forKey: "url") as? String ?? "" let perSiteEnabledKey = "com.wordpress.gutenberg-autoenabled-"+url - UserDefaults.standard.set(true, forKey: perSiteEnabledKey) + UserPersistentStoreFactory.instance().set(true, forKey: perSiteEnabledKey) } } override func end(_ mapping: NSEntityMapping, manager: NSMigrationManager) throws { NotificationCenter.default.observeOnce(forName: .applicationLaunchCompleted, object: nil, queue: .main, using: { (_) in - let context = ContextManager.shared.mainContext - let service = EditorSettingsService(managedObjectContext: context) - let isGutenbergEnabled = UserDefaults.standard.object(forKey: "kUserDefaultsGutenbergEditorEnabled") as? Bool ?? false + let service = EditorSettingsService(coreDataStack: ContextManager.shared) + let isGutenbergEnabled = UserPersistentStoreFactory.instance().object(forKey: "kUserDefaultsGutenbergEditorEnabled") as? Bool ?? false service.migrateGlobalSettingToRemote(isGutenbergEnabled: isGutenbergEnabled) }) diff --git a/WordPress/Classes/Utility/Migrations/AccountToAccount22to23.swift b/WordPress/Classes/Utility/Migrations/AccountToAccount22to23.swift index 67137731fa8d..9e9b0c4cba6c 100644 --- a/WordPress/Classes/Utility/Migrations/AccountToAccount22to23.swift +++ b/WordPress/Classes/Utility/Migrations/AccountToAccount22to23.swift @@ -31,8 +31,8 @@ class AccountToAccount22to23: NSEntityMigrationPolicy { } if isDotCom! == true { - let userDefaults = UserDefaults.standard - userDefaults.setValue(username!, forKey: defaultDotcomUsernameKey) + let userDefaults = UserPersistentStoreFactory.instance() + userDefaults.set(username!, forKey: defaultDotcomUsernameKey) DDLogWarn(">> Migration process matched [\(username!)] as the default WordPress.com account") } else { @@ -51,7 +51,7 @@ class AccountToAccount22to23: NSEntityMigrationPolicy { } // Assign the UUID's + Find the old defaultAccount (if any) - let defaultUsername: String = UserDefaults.standard.string(forKey: defaultDotcomUsernameKey) ?? String() + let defaultUsername: String = UserPersistentStoreFactory.instance().string(forKey: defaultDotcomUsernameKey) ?? String() var defaultAccount: NSManagedObject? for account in accounts { @@ -74,7 +74,7 @@ class AccountToAccount22to23: NSEntityMigrationPolicy { } // Set the defaultAccount (if any) - let userDefaults = UserDefaults.standard + let userDefaults = UserPersistentStoreFactory.instance() if defaultAccount != nil { let uuid = defaultAccount!.value(forKey: "uuid") as! String @@ -92,7 +92,7 @@ class AccountToAccount22to23: NSEntityMigrationPolicy { // MARK: - Private Helpers fileprivate func legacyDefaultWordPressAccount(_ context: NSManagedObjectContext) -> NSManagedObject? { - let objectURL = UserDefaults.standard.url(forKey: defaultDotcomKey) + let objectURL = UserPersistentStoreFactory.instance().url(forKey: defaultDotcomKey) if objectURL == nil { return nil } @@ -115,7 +115,7 @@ class AccountToAccount22to23: NSEntityMigrationPolicy { } fileprivate func defaultWordPressAccount(_ context: NSManagedObjectContext) -> NSManagedObject? { - let objectUUID = UserDefaults.standard.string(forKey: defaultDotcomUUIDKey) + let objectUUID = UserPersistentStoreFactory.instance().string(forKey: defaultDotcomUUIDKey) if objectUUID == nil { return nil } @@ -142,7 +142,7 @@ class AccountToAccount22to23: NSEntityMigrationPolicy { return } - let defaults = UserDefaults.standard + let defaults = UserPersistentStoreFactory.instance() defaults.set(uuid, forKey: defaultDotcomUUIDKey) } diff --git a/WordPress/Classes/Utility/Migrator/CoreDataIterativeMigrator.swift b/WordPress/Classes/Utility/Migrator/CoreDataIterativeMigrator.swift new file mode 100644 index 000000000000..f3a33858fa08 --- /dev/null +++ b/WordPress/Classes/Utility/Migrator/CoreDataIterativeMigrator.swift @@ -0,0 +1,316 @@ +import Foundation +import CoreData + +/// CoreDataIterativeMigrator: Migrates through a series of models to allow for users to skip app versions without risk. +/// +class CoreDataIterativeMigrator: NSObject { + + private static func error(with code: IterativeMigratorErrorCodes, description: String) -> NSError { + return NSError(domain: "IterativeMigrator", code: code.rawValue, userInfo: [NSLocalizedDescriptionKey: description]) + } + + /// Migrates a store to a particular model using the list of models to do it iteratively, if required. + /// + /// - Parameters: + /// - sourceStore: URL of the store on disk. + /// - storeType: Type of store (usually NSSQLiteStoreType). + /// - to: The target/most current model the migrator should migrate to. + /// - using: List of models on disk, sorted in migration order, that should include the to: model. + /// + /// - Returns: True if the process succeeded and didn't run into any errors. False if there was any problem and the store was left untouched. + /// + /// - Throws: A whole bunch of crap is possible to be thrown between Core Data and FileManager. + /// + @objc static func iterativeMigrate(sourceStore: URL, storeType: String, to targetModel: NSManagedObjectModel, using modelNames: [String]) throws { + // If the persistent store does not exist at the given URL, + // assume that it hasn't yet been created and return success immediately. + guard FileManager.default.fileExists(atPath: sourceStore.path) == true else { + return + } + + // Get the persistent store's metadata. The metadata is used to + // get information about the store's managed object model. + // If metadataForPersistentStore throws an error that error is propagated, not replaced by the throw + // in the guard's else clause. If metadataForPersistentStore returns nil then an error is thrown. + guard let sourceMetadata = try metadataForPersistentStore(storeType: storeType, at: sourceStore) else { + throw error(with: .failedRetrievingMetadata, description: "The source metadata was nil.") + } + + // Check whether the final model is already compatible with the store. + // If it is, no migration is necessary. + guard targetModel.isConfiguration(withName: nil, compatibleWithStoreMetadata: sourceMetadata) == false else { + return + } + + // Find the current model used by the store. + guard let sourceModel = try model(for: sourceMetadata) else { + return + } + + // Get NSManagedObjectModels for each of the model names given. + let objectModels = try models(for: modelNames) + + // Build an inclusive list of models between the source and final models. + var modelsToMigrate = [NSManagedObjectModel]() + var firstFound = false, lastFound = false, reverse = false + + for model in objectModels { + if model.isEqual(sourceModel) || model.isEqual(targetModel) { + if firstFound { + lastFound = true + // In case a reverse migration is being performed (descending through the + // ordered array of models), check whether the source model is found + // after the final model. + reverse = model.isEqual(sourceModel) + } else { + firstFound = true + } + } + + if firstFound { + modelsToMigrate.append(model) + } + + if lastFound { + break + } + } + + // Ensure that the source model is at the start of the list. + if reverse { + modelsToMigrate = modelsToMigrate.reversed() + } + + // Nested function for retrieving a model's version name. + // Used to give more context to errors. + func versionNameForModel(model: NSManagedObjectModel) -> String { + guard let index = objectModels.firstIndex(of: model) else { + return "Unknown" + } + return modelNames[index] + } + + // Migrate between each model. Count - 2 because of zero-based index and we want + // to stop at the last pair (you can't migrate the last model to nothingness). + let upperBound = modelsToMigrate.count - 2 + for index in 0...upperBound { + let modelFrom = modelsToMigrate[index] + let modelTo = modelsToMigrate[index + 1] + + let migrateWithModel: NSMappingModel + // Check whether a custom mapping model exists. + if let customModel = NSMappingModel(from: nil, forSourceModel: modelFrom, destinationModel: modelTo) { + migrateWithModel = customModel + } else { + // No custom model, so use an inferred model. + do { + let inferredModel = try NSMappingModel.inferredMappingModel(forSourceModel: modelFrom, destinationModel: modelTo) + migrateWithModel = inferredModel + } catch { + let versionFrom = versionNameForModel(model: modelFrom) + let versionTo = versionNameForModel(model: modelTo) + var description = "Mapping model could not be inferred, and no custom mapping model found." + description = description + "Version From \(versionFrom), To \(versionTo)." + description = description + " Original Error: \(error)" + throw CoreDataIterativeMigrator.error(with: IterativeMigratorErrorCodes.failedOnCustomMappingModel, description: description) + } + + } + + // Migrate the model to the next step + DDLogWarn("⚠️ Attempting migration from \(modelNames[index]) to \(modelNames[index + 1])") + + do { + try migrateStore(at: sourceStore, storeType: storeType, fromModel: modelFrom, toModel: modelTo, with: migrateWithModel) + } catch { + let versionFrom = versionNameForModel(model: modelFrom) + let versionTo = versionNameForModel(model: modelTo) + var description = "Failed migrating store from version \(versionFrom) to version \(versionTo)." + description = description + " Original Error: \(error)" + throw CoreDataIterativeMigrator.error(with: IterativeMigratorErrorCodes.failedMigratingStore, description: description) + } + } + } + + @objc static func backupDatabase(at storeURL: URL) throws { + _ = try CoreDataIterativeMigrator.makeBackup(at: storeURL) + } +} + + +// MARK: - File helpers +// +private extension CoreDataIterativeMigrator { + + /// Build a temporary path to write the migrated store. + /// + static func createTemporaryFolder(at storeURL: URL) -> URL { + let fileManager = FileManager.default + let tempDestinationURL = storeURL.deletingLastPathComponent().appendingPathComponent("migration").appendingPathComponent(storeURL.lastPathComponent) + try? fileManager.removeItem(at: tempDestinationURL.deletingLastPathComponent()) + try? fileManager.createDirectory(at: tempDestinationURL.deletingLastPathComponent(), withIntermediateDirectories: false, attributes: nil) + + return tempDestinationURL + } + + /// Move the original source store to a backup location. + /// + static func makeBackup(at storeURL: URL) throws -> URL { + let fileManager = FileManager.default + let backupURL = storeURL.deletingLastPathComponent().appendingPathComponent("backup") + try? fileManager.removeItem(at: backupURL) + try? fileManager.createDirectory(atPath: backupURL.path, withIntermediateDirectories: false, attributes: nil) + do { + let files = try fileManager.contentsOfDirectory(atPath: storeURL.deletingLastPathComponent().path) + try files.forEach { (file) in + if file.hasPrefix(storeURL.lastPathComponent) { + let fullPath = storeURL.deletingLastPathComponent().appendingPathComponent(file).path + let toPath = URL(fileURLWithPath: backupURL.path).appendingPathComponent(file).path + try fileManager.moveItem(atPath: fullPath, toPath: toPath) + } + } + } catch { + DDLogError("⛔️ Error while moving original source store to a backup location: \(error)") + throw error + } + + return backupURL + } + + /// Copy migrated over the original files + /// + static func copyMigratedOverOriginal(from tempDestinationURL: URL, to storeURL: URL) throws { + do { + let fileManager = FileManager.default + let files = try fileManager.contentsOfDirectory(atPath: tempDestinationURL.deletingLastPathComponent().path) + try files.forEach { (file) in + if file.hasPrefix(tempDestinationURL.lastPathComponent) { + let fullPath = tempDestinationURL.deletingLastPathComponent().appendingPathComponent(file).path + let toPath = storeURL.deletingLastPathComponent().appendingPathComponent(file).path + try? fileManager.removeItem(atPath: toPath) + try fileManager.moveItem(atPath: fullPath, toPath: toPath) + } + } + } catch { + DDLogError("⛔️ Error while copying migrated over the original files: \(error)") + throw error + } + } + + /// Delete backup copies of the original file before migration + /// + static func deleteBackupCopies(at backupURL: URL) throws { + do { + let fileManager = FileManager.default + let files = try fileManager.contentsOfDirectory(atPath: backupURL.path) + try files.forEach { (file) in + let fullPath = URL(fileURLWithPath: backupURL.path).appendingPathComponent(file).path + try fileManager.removeItem(atPath: fullPath) + } + } catch { + DDLogError("⛔️ Error while deleting backup copies of the original file before migration: \(error)") + throw error + } + } +} + + +// MARK: - Private helper functions +// +private extension CoreDataIterativeMigrator { + + static func migrateStore(at url: URL, + storeType: String, + fromModel: NSManagedObjectModel, + toModel: NSManagedObjectModel, + with mappingModel: NSMappingModel) throws { + let tempDestinationURL = createTemporaryFolder(at: url) + + // Migrate from the source model to the target model using the mapping, + // and store the resulting data at the temporary path. + let migrator = NSMigrationManager(sourceModel: fromModel, destinationModel: toModel) + do { + try migrator.migrateStore(from: url, + sourceType: storeType, + options: nil, + with: mappingModel, + toDestinationURL: tempDestinationURL, + destinationType: storeType, + destinationOptions: nil) + } catch { + throw error + } + + do { + let backupURL = try makeBackup(at: url) + try copyMigratedOverOriginal(from: tempDestinationURL, to: url) + try deleteBackupCopies(at: backupURL) + } catch { + throw error + } + } + + static func metadataForPersistentStore(storeType: String, at url: URL) throws -> [String: Any]? { + do { + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(ofType: storeType, at: url, options: nil) + return metadata + } catch { + let originalDescription: String = (error as NSError).userInfo[NSLocalizedDescriptionKey] as? String ?? "" + let description = "Failed to find source metadata for store: \(url). Original Description: \(originalDescription)" + throw CoreDataIterativeMigrator.error(with: IterativeMigratorErrorCodes.noMetadataForStore, description: description) + } + } + + static func model(for metadata: [String: Any]) throws -> NSManagedObjectModel? { + let bundle = Bundle(for: ContextManager.self) + guard let sourceModel = NSManagedObjectModel.mergedModel(from: [bundle], forStoreMetadata: metadata) else { + let description = "Failed to find source model for metadata: \(metadata)" + throw error(with: .noSourceModelForMetadata, description: description) + } + + return sourceModel + } + + static func models(for names: [String]) throws -> [NSManagedObjectModel] { + let models = try names.map { (name) -> NSManagedObjectModel in + guard let url = urlForModel(name: name, in: nil), + let model = NSManagedObjectModel(contentsOf: url) else { + let description = "No model found for \(name)" + throw error(with: .noModelFound, description: description) + } + + return model + } + + return models + } + + static func urlForModel(name: String, in directory: String?) -> URL? { + let bundle = Bundle(for: ContextManager.self) + var url = bundle.url(forResource: name, withExtension: "mom", subdirectory: directory) + + if url != nil { + return url + } + + let momdPaths = bundle.paths(forResourcesOfType: "momd", inDirectory: directory) + momdPaths.forEach { (path) in + if url != nil { + return + } + url = bundle.url(forResource: name, withExtension: "mom", subdirectory: URL(fileURLWithPath: path).lastPathComponent) + } + + return url + } +} + +enum IterativeMigratorErrorCodes: Int { + case noSourceModelForMetadata = 100 + case noMetadataForStore = 102 + case noModelFound = 110 + + case failedRetrievingMetadata = 120 + case failedOnCustomMappingModel = 130 + case failedMigratingStore = 140 +} diff --git a/WordPress/Classes/Utility/NSManagedObject+Lookup.swift b/WordPress/Classes/Utility/NSManagedObject+Lookup.swift new file mode 100644 index 000000000000..d28c9610bca7 --- /dev/null +++ b/WordPress/Classes/Utility/NSManagedObject+Lookup.swift @@ -0,0 +1,14 @@ +import CoreData + +extension NSManagedObject { + + /// Lookup an object by its NSManagedObjectID + /// + /// - Parameters: + /// - objectID: The `NSManagedObject` subclass' objectID as defined by Core Data. + /// - context: An NSManagedObjectContext that contains the associated object. + /// - Returns: The `NSManagedObject` subclass associated with the given `objectID`, if it exists. + static func lookup(withObjectID objectID: NSManagedObjectID, in context: NSManagedObjectContext) -> Self? { + return try? context.existingObject(with: objectID) as? Self + } +} diff --git a/WordPress/Classes/Utility/Networking/GutenbergRequestAuthenticator.swift b/WordPress/Classes/Utility/Networking/GutenbergRequestAuthenticator.swift new file mode 100644 index 000000000000..ff5566bb2440 --- /dev/null +++ b/WordPress/Classes/Utility/Networking/GutenbergRequestAuthenticator.swift @@ -0,0 +1,15 @@ +/// Small override of RequestAuthenticator to be able to authenticate with writting rights on Atomic sites. +/// Needed to load the gutenberg web editor on a web view on Atomic public and private sites. +class GutenbergRequestAuthenticator: RequestAuthenticator { + convenience init?(account: WPAccount, blog: Blog? = nil) { + guard + let username = account.username, + let token = account.authToken + else { + return nil + } + + // To load gutenberg web editor (or wp-admin in general) we need regular authentication type. + self.init(credentials: .dotCom(username: username, authToken: token, authenticationType: .regular)) + } +} diff --git a/WordPress/Classes/Utility/Networking/RequestAuthenticator.swift b/WordPress/Classes/Utility/Networking/RequestAuthenticator.swift new file mode 100644 index 000000000000..2a77e064c6e5 --- /dev/null +++ b/WordPress/Classes/Utility/Networking/RequestAuthenticator.swift @@ -0,0 +1,370 @@ +import AutomatticTracks +import Foundation + +/// Authenticator for requests to self-hosted sites, wp.com sites, including private +/// sites and atomic sites. +/// +/// - Note: at some point I considered moving this module to the WordPressAuthenticator pod. +/// Unfortunately the effort required for this makes it unfeasible for me to focus on it +/// right now, as it involves also moving at least CookieJar, AuthenticationService and AtomicAuthenticationService over there as well. - @diegoreymendez +/// +class RequestAuthenticator: NSObject { + + enum Error: Swift.Error { + case atomicSiteWithoutDotComID(blog: Blog) + } + + enum DotComAuthenticationType { + case regular + case regularMapped(siteID: Int) + case atomic(loginURL: String) + case privateAtomic(blogID: Int) + } + + enum WPNavigationActionType { + case reload + case allow + } + + enum Credentials { + case dotCom(username: String, authToken: String, authenticationType: DotComAuthenticationType) + case siteLogin(loginURL: URL, username: String, password: String) + } + + fileprivate let credentials: Credentials + + // MARK: - Services + + private let authenticationService: AuthenticationService + + // MARK: - Initializers + + init(credentials: Credentials, authenticationService: AuthenticationService = AuthenticationService()) { + self.credentials = credentials + self.authenticationService = authenticationService + } + + @objc convenience init?(account: WPAccount, blog: Blog? = nil) { + guard let username = account.username, + let token = account.authToken else { + return nil + } + + var authenticationType: DotComAuthenticationType = .regular + + if let blog = blog, let dotComID = blog.dotComID as? Int { + + if blog.isAtomic() { + authenticationType = blog.isPrivate() ? .privateAtomic(blogID: dotComID) : .atomic(loginURL: blog.loginUrl()) + } else if blog.hasMappedDomain() { + authenticationType = .regularMapped(siteID: dotComID) + } + } + + self.init(credentials: .dotCom(username: username, authToken: token, authenticationType: authenticationType)) + } + + @objc convenience init?(blog: Blog) { + if let account = blog.account { + self.init(account: account, blog: blog) + } else if let username = blog.usernameForSite, + let password = blog.password, + let loginURL = URL(string: blog.loginUrl()) { + self.init(credentials: .siteLogin(loginURL: loginURL, username: username, password: password)) + } else { + DDLogError("Can't authenticate blog \(String(describing: blog.displayURL)) yet") + return nil + } + } + + /// Potentially rewrites a request for authentication. + /// + /// This method will call the completion block with the request to be used. + /// + /// - Warning: On WordPress.com, this uses a special redirect system. It + /// requires the web view to call `interceptRedirect(request:)` before + /// loading any request. + /// + /// - Parameters: + /// - url: the URL to be loaded. + /// - cookieJar: a CookieJar object where the authenticator will look + /// for existing cookies. + /// - completion: this will be called with either the request for + /// authentication, or a request for the original URL. + /// + @objc func request(url: URL, cookieJar: CookieJar, completion: @escaping (URLRequest) -> Void) { + switch self.credentials { + case .dotCom(let username, let authToken, let authenticationType): + requestForWPCom( + url: url, + cookieJar: cookieJar, + username: username, + authToken: authToken, + authenticationType: authenticationType, + completion: completion) + case .siteLogin(let loginURL, let username, let password): + requestForSelfHosted( + url: url, + loginURL: loginURL, + cookieJar: cookieJar, + username: username, + password: password, + completion: completion) + } + } + + private func requestForWPCom(url: URL, cookieJar: CookieJar, username: String, authToken: String, authenticationType: DotComAuthenticationType, completion: @escaping (URLRequest) -> Void) { + + switch authenticationType { + case .regular: + requestForWPCom( + url: url, + cookieJar: cookieJar, + username: username, + authToken: authToken, + completion: completion) + case .regularMapped(let siteID): + requestForMappedWPCom(url: url, + cookieJar: cookieJar, + username: username, + authToken: authToken, + siteID: siteID, + completion: completion) + + case .privateAtomic(let siteID): + requestForPrivateAtomicWPCom( + url: url, + cookieJar: cookieJar, + username: username, + siteID: siteID, + completion: completion) + case .atomic(let loginURL): + requestForAtomicWPCom( + url: url, + loginURL: loginURL, + cookieJar: cookieJar, + username: username, + authToken: authToken, + completion: completion) + } + } + + private func requestForSelfHosted(url: URL, loginURL: URL, cookieJar: CookieJar, username: String, password: String, completion: @escaping (URLRequest) -> Void) { + + func done() { + let request = URLRequest(url: url) + completion(request) + } + + authenticationService.loadAuthCookiesForSelfHosted(into: cookieJar, loginURL: loginURL, username: username, password: password, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func requestForPrivateAtomicWPCom(url: URL, cookieJar: CookieJar, username: String, siteID: Int, completion: @escaping (URLRequest) -> Void) { + + func done() { + let request = URLRequest(url: url) + completion(request) + } + + // We should really consider refactoring how we retrieve the default account since it doesn't really use + // a context at all... + let context = ContextManager.shared.mainContext + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) else { + WordPressAppDelegate.crashLogging?.logMessage("It shouldn't be possible to reach this point without an account.", properties: nil, level: .error) + return + } + let authenticationService = AtomicAuthenticationService(account: account) + + authenticationService.loadAuthCookies(into: cookieJar, username: username, siteID: siteID, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func requestForAtomicWPCom(url: URL, loginURL: String, cookieJar: CookieJar, username: String, authToken: String, completion: @escaping (URLRequest) -> Void) { + + func done() { + // For non-private Atomic sites, proxy the request through wp-login like Calypso does. + // If the site has SSO enabled auth should happen and we get redirected to our preview. + // If SSO is not enabled wp-admin prompts for credentials, then redirected. + var components = URLComponents(string: loginURL) + var queryItems = components?.queryItems ?? [] + queryItems.append(URLQueryItem(name: "redirect_to", value: url.absoluteString)) + components?.queryItems = queryItems + let requestURL = components?.url ?? url + + let request = URLRequest(url: requestURL) + completion(request) + } + + authenticationService.loadAuthCookiesForWPCom(into: cookieJar, username: username, authToken: authToken, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func requestForMappedWPCom(url: URL, cookieJar: CookieJar, username: String, authToken: String, siteID: Int, completion: @escaping (URLRequest) -> Void) { + func done() { + guard + let host = url.host, + !host.contains(WPComDomain) + else { + // The requested URL is to the unmapped version of the domain, + // so skip proxying the request through r-login. + completion(URLRequest(url: url)) + return + } + + let rlogin = "https://r-login.wordpress.com/remote-login.php?action=auth" + guard var components = URLComponents(string: rlogin) else { + // Safety net in case something unexpected changes in the future. + DDLogError("There was an unexpected problem initializing URLComponents via the rlogin string.") + completion(URLRequest(url: url)) + return + } + var queryItems = components.queryItems ?? [] + queryItems.append(contentsOf: [ + URLQueryItem(name: "host", value: host), + URLQueryItem(name: "id", value: String(siteID)), + URLQueryItem(name: "back", value: url.absoluteString) + ]) + components.queryItems = queryItems + let requestURL = components.url ?? url + + let request = URLRequest(url: requestURL) + completion(request) + } + + authenticationService.loadAuthCookiesForWPCom(into: cookieJar, username: username, authToken: authToken, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func requestForWPCom(url: URL, cookieJar: CookieJar, username: String, authToken: String, completion: @escaping (URLRequest) -> Void) { + + func done() { + let request = URLRequest(url: url) + completion(request) + } + + authenticationService.loadAuthCookiesForWPCom(into: cookieJar, username: username, authToken: authToken, success: { + done() + }) { [weak self] error in + // Make sure this error scenario isn't silently ignored. + self?.logErrorIfNeeded(error) + + // Even if getting the auth cookies fail, we'll still try to load the URL + // so that the user sees a reasonable error situation on screen. + // We could opt to create a special screen but for now I'd rather users report + // the issue when it happens. + done() + } + } + + private func logErrorIfNeeded(_ error: Swift.Error) { + + if let cookieError = error as? AuthenticationService.RequestAuthCookieError { + WordPressAppDelegate.crashLogging?.logMessage(cookieError.localizedDescription) + return + } + + let nsError = error as NSError + + switch nsError.code { + case NSURLErrorTimedOut, NSURLErrorNotConnectedToInternet: + return + default: + WordPressAppDelegate.crashLogging?.logError(error) + } + } +} + +private extension RequestAuthenticator { + static let wordPressComLoginUrl = URL(string: "https://wordpress.com/wp-login.php")! +} + +extension RequestAuthenticator { + func isLogin(url: URL) -> Bool { + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.queryItems = nil + + return components?.url == RequestAuthenticator.wordPressComLoginUrl + } +} + +// MARK: Navigation Validator +extension RequestAuthenticator { + /// Validates that the navigation worked as expected then provides a recommendation on if the screen should reload or not. + func decideActionFor(response: URLResponse, cookieJar: CookieJar, completion: @escaping (WPNavigationActionType) -> Void) { + switch self.credentials { + case .dotCom(let username, _, let authenticationType): + decideActionForWPCom(response: response, cookieJar: cookieJar, username: username, authenticationType: authenticationType, completion: completion) + case .siteLogin: + completion(.allow) + } + } + + private func decideActionForWPCom(response: URLResponse, cookieJar: CookieJar, username: String, authenticationType: DotComAuthenticationType, completion: @escaping (WPNavigationActionType) -> Void) { + + guard didEncouterRecoverableChallenge(response) else { + completion(.allow) + return + } + + cookieJar.removeWordPressComCookies { + completion(.reload) + } + } + + private func didEncouterRecoverableChallenge(_ response: URLResponse) -> Bool { + guard let url = response.url?.absoluteString else { + return false + } + + if url.contains("r-login.wordpress.com") || url.contains("wordpress.com/log-in?") { + return true + } + + guard let statusCode = (response as? HTTPURLResponse)?.statusCode else { + return false + } + + return 400 <= statusCode && statusCode < 500 + } +} diff --git a/WordPress/Classes/Utility/Networking/WordPressComRestApi+Defaults.swift b/WordPress/Classes/Utility/Networking/WordPressComRestApi+Defaults.swift index 933106bdbb0e..4279232d88e6 100644 --- a/WordPress/Classes/Utility/Networking/WordPressComRestApi+Defaults.swift +++ b/WordPress/Classes/Utility/Networking/WordPressComRestApi+Defaults.swift @@ -10,4 +10,32 @@ extension WordPressComRestApi { localeKey: localeKey, baseUrlString: Environment.current.wordPressComApiBase) } + + + /// Returns the default API the default WP.com account using the given context + @objc public static func defaultApi(in context: NSManagedObjectContext, + userAgent: String? = WPUserAgent.wordPress(), + localeKey: String = WordPressComRestApi.LocaleKeyDefault) -> WordPressComRestApi { + + let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: context) + let token: String? = defaultAccount?.authToken + + return WordPressComRestApi.defaultApi(oAuthToken: token, + userAgent: userAgent, + localeKey: localeKey) + } + + @objc public static func defaultV2Api(authToken: String? = nil) -> WordPressComRestApi { + let userAgent = WPUserAgent.wordPress() + let localeKey = WordPressComRestApi.LocaleKeyV2 + return WordPressComRestApi.defaultApi(oAuthToken: authToken, + userAgent: userAgent, + localeKey: localeKey) + } + + @objc public static func defaultV2Api(in context: NSManagedObjectContext) -> WordPressComRestApi { + return WordPressComRestApi.defaultApi(in: context, + userAgent: WPUserAgent.wordPress(), + localeKey: WordPressComRestApi.LocaleKeyV2) + } } diff --git a/WordPress/Classes/Utility/NoticeAnimator.swift b/WordPress/Classes/Utility/NoticeAnimator.swift deleted file mode 100644 index e4301b054d3c..000000000000 --- a/WordPress/Classes/Utility/NoticeAnimator.swift +++ /dev/null @@ -1,151 +0,0 @@ -import UIKit -import WordPressShared - -/// NoticeAnimator is a helper class to animate error messages. -/// -/// The notices show at the top of the target view, and are meant to appear to -/// be attached to a navigation bar. The expected usage is to display offline -/// status or requests taking longer than usual. -/// -/// To use an NoticeAnimator, you need to keep a reference to it, and call two -/// methods: -/// -/// - `layout()` from your `UIView.layoutSubviews()` or -/// `UIViewController.viewDidLayoutSubviews()`. Failure to do this won't render -/// the animation correctly. -/// -/// - `animateMessage(_)` when you want to change the message displayed. Pass -/// nil if you want to hide the error view. -/// -class NoticeAnimator: Animator { - - // MARK: - Private Constants - fileprivate struct Defaults { - static let animationDuration = 0.3 - static let padding = UIOffset(horizontal: 15, vertical: 20) - static let labelFont = WPStyleGuide.regularTextFont() - } - - - // MARK: - Private properties - fileprivate var previousHeight: CGFloat = 0 - fileprivate var message: String? { - get { - return noticeLabel.label.text - } - set { - noticeLabel.label.text = newValue - } - } - - - // MARK: - Private Immutable Properties - fileprivate let targetView: UIView - fileprivate let noticeLabel: PaddedLabel = { - let label = PaddedLabel() - label.backgroundColor = .primary(.shade40) - label.clipsToBounds = true - label.padding.horizontal = Defaults.padding.horizontal - label.label.textColor = UIColor.white - label.label.font = Defaults.labelFont - label.label.numberOfLines = 0 - return label - }() - - - // MARK: - Private Computed Properties - fileprivate var shouldDisplayMessage: Bool { - return message != nil - } - fileprivate var targetTableView: UITableView? { - return targetView as? UITableView - } - - - - // MARK: - Initializers - @objc init(target: UIView) { - targetView = target - super.init() - } - - - - // MARK: - Public Methods - @objc func layout() { - var targetFrame = noticeLabel.frame - targetFrame.size.width = targetView.bounds.width - targetFrame.size.height = heightForMessage(message) - noticeLabel.frame = targetFrame - } - - @objc func animateMessage(_ message: String?) { - let shouldAnimate = self.message != message - self.message = message - - if shouldAnimate { - animateWithDuration(Defaults.animationDuration, preamble: preamble, animations: animations, cleanup: cleanup) - } - } - - - - // MARK: - Animation Methods - fileprivate func preamble() { - UIView.performWithoutAnimation { [weak self] in - self?.targetView.layoutIfNeeded() - } - - if shouldDisplayMessage == true && noticeLabel.superview == nil { - targetView.addSubview(noticeLabel) - noticeLabel.frame.size.height = CGSize.zero.height - noticeLabel.label.alpha = 0 - } - } - - fileprivate func animations() { - let height = heightForMessage(message) - - if shouldDisplayMessage { - // Position + Size + Alpha - noticeLabel.frame.origin.y = -height - noticeLabel.frame.size.height = height - noticeLabel.label.alpha = 1 - - // Table Insets + Offset - targetTableView?.contentInset.top += height - previousHeight - if targetTableView?.contentOffset.y == 0 { - targetTableView?.contentOffset.y = -height + previousHeight - } - - } else { - // Size + Alpha - noticeLabel.frame.size.height = CGSize.zero.height - noticeLabel.label.alpha = 0 - - // Table Insets - targetTableView?.contentInset.top -= previousHeight - } - - previousHeight = height - } - - fileprivate func cleanup() { - if shouldDisplayMessage == false { - noticeLabel.removeFromSuperview() - previousHeight = CGSize.zero.height - } - } - - - - // MARK: - Helpers - fileprivate func heightForMessage(_ message: String?) -> CGFloat { - guard let message = message else { - return CGSize.zero.height - } - - let size = message.suggestedSize(with: Defaults.labelFont, width: targetView.frame.width) - return round(size.height + Defaults.padding.vertical) - } -} diff --git a/WordPress/Classes/Utility/NotificationEventTracker.swift b/WordPress/Classes/Utility/NotificationEventTracker.swift new file mode 100644 index 000000000000..a73a7cff6ccc --- /dev/null +++ b/WordPress/Classes/Utility/NotificationEventTracker.swift @@ -0,0 +1,52 @@ +import Foundation + +class NotificationEventTracker { + enum Event: String { + case notificationScheduled = "notification_scheduled" + case notificationTapped = "notification_tapped" + } + + enum Properties: String { + case notificationType = "notification_type" + case siteId = "site_id" + } + + enum NotificationType: String { + case bloggingReminders = "blogging_reminders" + case weeklyRoundup = "weekly_roundup" + } + + private let track: (AnalyticsEvent) -> Void + + init(trackMethod track: @escaping (AnalyticsEvent) -> Void = WPAnalytics.track) { + self.track = track + } + + func notificationScheduled(type: NotificationType, siteId: Int? = nil) { + let event = AnalyticsEvent( + name: Event.notificationScheduled.rawValue, + properties: properties(for: type, siteId: siteId)) + + track(event) + } + + func notificationTapped(type: NotificationType, siteId: Int? = nil) { + let event = AnalyticsEvent( + name: Event.notificationTapped.rawValue, + properties: properties(for: type, siteId: siteId)) + + track(event) + } + + private func properties(for type: NotificationType, siteId: Int?) -> [String: String] { + var properties: [String: String] = [ + Properties.notificationType.rawValue: type.rawValue, + ] + + if let siteId = siteId { + properties[Properties.siteId.rawValue] = String(siteId) + } + + return properties + } +} diff --git a/WordPress/Classes/Utility/OverlayFrequencyTracker.swift b/WordPress/Classes/Utility/OverlayFrequencyTracker.swift new file mode 100644 index 000000000000..905b50ae33e8 --- /dev/null +++ b/WordPress/Classes/Utility/OverlayFrequencyTracker.swift @@ -0,0 +1,131 @@ +import Foundation + +protocol OverlaySource { + var key: String { get } + var frequencyType: OverlayFrequencyTracker.FrequencyType { get } +} + +class OverlayFrequencyTracker { + + private let source: OverlaySource + private let type: OverlayType + private let frequencyConfig: FrequencyConfig + private let phaseString: String? + private let persistenceStore: UserPersistentRepository + + private var sourceDateKey: String { + guard let phaseString = phaseString else { + return "\(type.rawValue)\(Constants.lastDateKeyPrefix)-\(source.key)" + } + return "\(type.rawValue)\(Constants.lastDateKeyPrefix)-\(source.key)-\(phaseString)" + } + + private var lastSavedGenericDate: Date? { + get { + let key = "\(type.rawValue)\(Constants.lastDateKeyPrefix)" + return persistenceStore.object(forKey: key) as? Date + } + set { + let key = "\(type.rawValue)\(Constants.lastDateKeyPrefix)" + persistenceStore.set(newValue, forKey: key) + } + } + + private var lastSavedSourceDate: Date? { + get { + return persistenceStore.object(forKey: sourceDateKey) as? Date + } + set { + persistenceStore.set(newValue, forKey: sourceDateKey) + } + } + + init(source: OverlaySource, + type: OverlayType, + frequencyConfig: FrequencyConfig = .defaultConfig, + phaseString: String? = nil, + persistenceStore: UserPersistentRepository = UserDefaults.standard) { + self.source = source + self.type = type + self.frequencyConfig = frequencyConfig + self.phaseString = phaseString + self.persistenceStore = persistenceStore + } + + func shouldShow(forced: Bool) -> Bool { + if forced { + return true + } + switch source.frequencyType { + case .showOnce: + return lastSavedSourceDate == nil + case .alwaysShow: + return true + case .respectFrequencyConfig: + return frequenciesPassed() + } + } + + func track() { + let date = Date() + lastSavedSourceDate = date + lastSavedGenericDate = date + } + + private func frequenciesPassed() -> Bool { + guard let lastSavedGenericDate = lastSavedGenericDate else { + return true // First overlay ever + } + let secondsSinceLastSavedGenericDate = -lastSavedGenericDate.timeIntervalSinceNow + let generalFreqPassed = secondsSinceLastSavedGenericDate > frequencyConfig.generalInSeconds + if generalFreqPassed == false { + return false // An overlay was shown recently so we can't show one now + } + + guard let lastSavedSourceDate = lastSavedSourceDate else { + return true // This specific overlay was never shown, so we can show it + } + + let secondsSinceLastSavedSourceDate = -lastSavedSourceDate.timeIntervalSinceNow + let featureSpecificFreqPassed = secondsSinceLastSavedSourceDate > frequencyConfig.featureSpecificInSeconds + // Check if this specific overlay was shown recently + return featureSpecificFreqPassed + } +} + +extension OverlayFrequencyTracker { + + enum FrequencyType { + case showOnce + case alwaysShow + case respectFrequencyConfig + } + + enum OverlayType: String { + case featuresRemoval = "" // Empty string to make sure the generated keys are backwards compatible + case blaze + } + + struct FrequencyConfig { + // MARK: Instance Variables + let featureSpecificInDays: Int + let generalInDays: Int + + // MARK: Static Variables + static let defaultConfig = FrequencyConfig(featureSpecificInDays: 0, generalInDays: 0) + private static let secondsInDay: TimeInterval = 86_400 + + // MARK: Computed Variables + var featureSpecificInSeconds: TimeInterval { + return TimeInterval(featureSpecificInDays) * Self.secondsInDay + } + + var generalInSeconds: TimeInterval { + return TimeInterval(generalInDays) * Self.secondsInDay + } + } + + enum Constants { + static let lastDateKeyPrefix = "JetpackOverlayLastDate" + } +} diff --git a/WordPress/Classes/Utility/PingHubManager.swift b/WordPress/Classes/Utility/PingHubManager.swift index c11c6a351bea..94203bd707a7 100644 --- a/WordPress/Classes/Utility/PingHubManager.swift +++ b/WordPress/Classes/Utility/PingHubManager.swift @@ -4,9 +4,7 @@ import Reachability // This is added as a top level function to avoid cluttering PingHubManager.init private func defaultAccountToken() -> String? { - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - guard let account = service.defaultWordPressComAccount() else { + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) else { return nil } guard let token = account.authToken, !token.isEmpty else { diff --git a/WordPress/Classes/Utility/PushAuthenticationManager.swift b/WordPress/Classes/Utility/PushAuthenticationManager.swift index 49e31cc0f8ca..c2bf21d41019 100644 --- a/WordPress/Classes/Utility/PushAuthenticationManager.swift +++ b/WordPress/Classes/Utility/PushAuthenticationManager.swift @@ -20,8 +20,7 @@ class PushAuthenticationManager { convenience init() { - let context = ContextManager.sharedInstance().mainContext - let service = PushAuthenticationService(managedObjectContext: context) + let service = PushAuthenticationService(coreDataStack: ContextManager.shared) self.init(pushAuthenticationService: service) } @@ -72,6 +71,25 @@ class PushAuthenticationManager { } } } + + /// Called as a part of approving a 2fa channel via a notification action. + /// + /// - Parameter userInfo: Is the Notification's payload. + /// + func handleAuthenticationApprovedAction(_ userInfo: NSDictionary?) { + guard isAuthenticationNotificationExpired(userInfo) == false else { + showLoginExpiredAlert() + WPAnalytics.track(.pushAuthenticationExpired) + return + } + + guard let token = userInfo?["push_auth_token"] as? String else { + return + } + + authorizeLogin(token, retryCount: Settings.initialRetryCount) + WPAnalytics.track(.pushAuthenticationApproved) + } } diff --git a/WordPress/Classes/Utility/PushNotificationsManager.swift b/WordPress/Classes/Utility/PushNotificationsManager.swift index 8bac9e5d5610..ee24f7d720ae 100644 --- a/WordPress/Classes/Utility/PushNotificationsManager.swift +++ b/WordPress/Classes/Utility/PushNotificationsManager.swift @@ -11,6 +11,13 @@ import UserNotifications /// final public class PushNotificationsManager: NSObject { + // MARK: Initializer + + override init() { + super.init() + registerForNotifications() + } + /// Returns the shared PushNotificationsManager instance. /// @objc static let shared = PushNotificationsManager() @@ -20,10 +27,10 @@ final public class PushNotificationsManager: NSObject { /// @objc var deviceToken: String? { get { - return UserDefaults.standard.string(forKey: Device.tokenKey) ?? String() + return UserPersistentStoreFactory.instance().string(forKey: Device.tokenKey) ?? String() } set { - UserDefaults.standard.set(newValue, forKey: Device.tokenKey) + UserPersistentStoreFactory.instance().set(newValue, forKey: Device.tokenKey) } } @@ -32,10 +39,10 @@ final public class PushNotificationsManager: NSObject { /// @objc var deviceId: String? { get { - return UserDefaults.standard.string(forKey: Device.idKey) ?? String() + return UserPersistentStoreFactory.instance().string(forKey: Device.idKey) ?? String() } set { - UserDefaults.standard.set(newValue, forKey: Device.idKey) + UserPersistentStoreFactory.instance().set(newValue, forKey: Device.idKey) } } @@ -53,14 +60,31 @@ final public class PushNotificationsManager: NSObject { return sharedApplication.applicationState } + private var didRegisterForRemoteNotifications = false + + /// Enables or disables remote notifications based on current settings. /// Registers the device for Remote Notifications: Badge + Sounds + Alerts /// - @objc func registerForRemoteNotifications() { + @objc func setupRemoteNotifications() { + guard JetpackNotificationMigrationService.shared.shouldPresentNotifications() else { + disableRemoteNotifications() + return + } + sharedApplication.registerForRemoteNotifications() + didRegisterForRemoteNotifications = true + return } - + private func disableRemoteNotifications() { + if !didRegisterForRemoteNotifications { + sharedApplication.registerForRemoteNotifications() + } + sharedApplication.unregisterForRemoteNotifications() + sharedApplication.applicationIconBadgeNumber = 0 + didRegisterForRemoteNotifications = false + } /// Checks asynchronously if Notifications are enabled in the Device's Settings, or not. /// @@ -101,7 +125,7 @@ final public class PushNotificationsManager: NSObject { deviceToken = newToken // Register against WordPress.com - let noteService = NotificationSettingsService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let noteService = NotificationSettingsService(coreDataStack: ContextManager.sharedInstance()) noteService.registerDeviceForPushNotifications(newToken, success: { deviceId in DDLogVerbose("Successfully registered Device ID \(deviceId) for Push Notifications") @@ -138,7 +162,7 @@ final public class PushNotificationsManager: NSObject { ZendeskUtils.unregisterDevice() - let noteService = NotificationSettingsService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let noteService = NotificationSettingsService(coreDataStack: ContextManager.sharedInstance()) noteService.unregisterDeviceForPushNotifications(knownDeviceId, success: { DDLogInfo("Successfully unregistered Device ID \(knownDeviceId) for Push Notifications!") @@ -157,9 +181,10 @@ final public class PushNotificationsManager: NSObject { /// /// - Parameters: /// - userInfo: The Notification's Payload + /// - userInteraction: Indicates if the user interacted with the Push Notification /// - completionHandler: A callback, to be executed on completion /// - @objc func handleNotification(_ userInfo: NSDictionary, completionHandler: ((UIBackgroundFetchResult) -> Void)?) { + @objc func handleNotification(_ userInfo: NSDictionary, userInteraction: Bool = false, completionHandler: ((UIBackgroundFetchResult) -> Void)?) { DDLogVerbose("Received push notification:\nPayload: \(userInfo)\n") DDLogVerbose("Current Application state: \(applicationState.rawValue)") @@ -184,7 +209,7 @@ final public class PushNotificationsManager: NSObject { handleQuickStartLocalNotification] for handler in handlers { - if handler(userInfo, completionHandler) { + if handler(userInfo, userInteraction, completionHandler) { break } } @@ -212,6 +237,13 @@ final public class PushNotificationsManager: NSObject { let event: WPAnalyticsStat = (applicationState == .background) ? .pushNotificationReceived : .pushNotificationAlertPressed WPAnalytics.track(event, withProperties: properties) } + + // MARK: Observing Notifications + + private func registerForNotifications() { + let notificationCenter = NotificationCenter.default + notificationCenter.addObserver(self, selector: #selector(setupRemoteNotifications), name: .WPAppUITypeChanged, object: nil) + } } @@ -230,7 +262,7 @@ extension PushNotificationsManager { /// /// - Returns: True when handled. False otherwise /// - @objc func handleSupportNotification(_ userInfo: NSDictionary, completionHandler: ((UIBackgroundFetchResult) -> Void)?) -> Bool { + @objc func handleSupportNotification(_ userInfo: NSDictionary, userInteraction: Bool, completionHandler: ((UIBackgroundFetchResult) -> Void)?) -> Bool { guard let type = userInfo.string(forKey: ZendeskUtils.PushNotificationIdentifiers.key), type == ZendeskUtils.PushNotificationIdentifiers.type else { @@ -243,7 +275,7 @@ extension PushNotificationsManager { WPAnalytics.track(.supportReceivedResponseFromSupport) if applicationState == .background { - WPTabBarController.sharedInstance().showMeScene() + RootViewCoordinator.sharedPresenter.showMeScene() } completionHandler?(.newData) @@ -263,7 +295,7 @@ extension PushNotificationsManager { /// /// - Returns: True when handled. False otherwise /// - @objc func handleAuthenticationNotification(_ userInfo: NSDictionary, completionHandler: ((UIBackgroundFetchResult) -> Void)?) -> Bool { + @objc func handleAuthenticationNotification(_ userInfo: NSDictionary, userInteraction: Bool, completionHandler: ((UIBackgroundFetchResult) -> Void)?) -> Bool { // WordPress.com Push Authentication Notification // Due to the Background Notifications entitlement, any given Push Notification's userInfo might be received // while the app is in BG, and when it's about to become active. In order to prevent UI glitches, let's skip @@ -274,8 +306,20 @@ extension PushNotificationsManager { return false } - if applicationState != .background { + /// This is a (hopefully temporary) workaround. A Push Authentication must be dealt with whenever: + /// + /// 1. When the user interacts with a Push Notification + /// 2. When the App is in Foreground + /// + /// As per iOS 13 there are certain scenarios in which the `applicationState` may be `.background` when the user pressed over the Alert. + /// By means of the `userInteraction` flag, we're just working around the new SDK behavior. + /// + /// Proper fix involves a full refactor, and definitely stop checking on `applicationState`, since it's not reliable anymore. + /// + if applicationState != .background || userInteraction { authenticationManager.handleAuthenticationNotification(userInfo) + } else { + DDLogInfo("Skipping handling authentication notification due to app being in background or user not interacting with it.") } completionHandler?(.newData) @@ -283,6 +327,20 @@ extension PushNotificationsManager { return true } + /// A handler for a 2fa auth notification approval action. + /// + /// - Parameter userInfo: The Notification's Payload + /// - Returns: True if successful. False otherwise. + /// + @objc func handleAuthenticationApprovedAction(_ userInfo: NSDictionary) -> Bool { + let authenticationManager = PushAuthenticationManager() + guard authenticationManager.isAuthenticationNotification(userInfo) else { + return false + } + authenticationManager.handleAuthenticationApprovedAction(userInfo) + return true + } + /// Handles a Notification while in Inactive Mode /// @@ -295,7 +353,7 @@ extension PushNotificationsManager { /// /// - Returns: True when handled. False otherwise /// - @objc func handleInactiveNotification(_ userInfo: NSDictionary, completionHandler: ((UIBackgroundFetchResult) -> Void)?) -> Bool { + @objc func handleInactiveNotification(_ userInfo: NSDictionary, userInteraction: Bool, completionHandler: ((UIBackgroundFetchResult) -> Void)?) -> Bool { guard applicationState == .inactive else { return false } @@ -304,7 +362,7 @@ extension PushNotificationsManager { return false } - WPTabBarController.sharedInstance().showNotificationsTabForNote(withID: notificationId) + RootViewCoordinator.sharedPresenter.showNotificationsTabForNote(withID: notificationId) completionHandler?(.newData) return true @@ -322,7 +380,7 @@ extension PushNotificationsManager { /// /// - Returns: True when handled. False otherwise /// - @objc func handleBackgroundNotification(_ userInfo: NSDictionary, completionHandler: ((UIBackgroundFetchResult) -> Void)?) -> Bool { + @objc func handleBackgroundNotification(_ userInfo: NSDictionary, userInteraction: Bool, completionHandler: ((UIBackgroundFetchResult) -> Void)?) -> Bool { guard userInfo.number(forKey: Notification.identifierKey)?.stringValue != nil else { return false } @@ -366,6 +424,7 @@ extension PushNotificationsManager { static let originKey = "origin" static let badgeResetValue = "badge-reset" static let local = "qs-local-notification" + static let bloggingPrompts = "blogging-prompts-notification" } enum Tracking { @@ -389,20 +448,24 @@ extension PushNotificationsManager { /// - completionHandler: A callback, to be executed on completion /// /// - Returns: True when handled. False otherwise - @objc func handleQuickStartLocalNotification(_ userInfo: NSDictionary, completionHandler: ((UIBackgroundFetchResult) -> Void)?) -> Bool { + @objc func handleQuickStartLocalNotification(_ userInfo: NSDictionary, userInteraction: Bool, completionHandler: ((UIBackgroundFetchResult) -> Void)?) -> Bool { guard let type = userInfo.string(forKey: Notification.typeKey), type == Notification.local else { return false } - if WPTabBarController.sharedInstance()?.presentedViewController != nil { - WPTabBarController.sharedInstance()?.dismiss(animated: false) + let rootViewController = RootViewCoordinator.sharedPresenter.rootViewController + + if rootViewController.presentedViewController != nil { + rootViewController.dismiss(animated: false) } - WPTabBarController.sharedInstance()?.showMySitesTab() + RootViewCoordinator.sharedPresenter.showMySitesTab() - if let taskName = userInfo.string(forKey: QuickStartTracking.taskNameKey) { + if let taskName = userInfo.string(forKey: QuickStartTracking.taskNameKey), + let quickStartType = userInfo.string(forKey: QuickStartTracking.quickStartTypeKey) { WPAnalytics.track(.quickStartNotificationTapped, - withProperties: [QuickStartTracking.taskNameKey: taskName]) + withProperties: [QuickStartTracking.taskNameKey: taskName, + WPAnalytics.WPAppAnalyticsKeyQuickStartSiteType: quickStartType]) } completionHandler?(.newData) @@ -410,7 +473,7 @@ extension PushNotificationsManager { return true } - func postNotification(for tour: QuickStartTour) { + func postNotification(for tour: QuickStartTour, quickStartType: QuickStartType) { deletePendingLocalNotifications() let content = UNMutableNotificationContent() @@ -432,7 +495,8 @@ extension PushNotificationsManager { UNUserNotificationCenter.current().add(request) WPAnalytics.track(.quickStartNotificationStarted, - withProperties: [QuickStartTracking.taskNameKey: tour.analyticsKey]) + withProperties: [QuickStartTracking.taskNameKey: tour.analyticsKey, + WPAnalytics.WPAppAnalyticsKeyQuickStartSiteType: quickStartType.key]) } @objc func deletePendingLocalNotifications() { @@ -446,6 +510,7 @@ extension PushNotificationsManager { private enum QuickStartTracking { static let taskNameKey = "task_name" + static let quickStartTypeKey = "site_type" } } diff --git a/WordPress/Classes/Utility/Ratings/AppRatingsUtility.swift b/WordPress/Classes/Utility/Ratings/AppRatingsUtility.swift index 18fc1ee80e20..08488b0b02fb 100644 --- a/WordPress/Classes/Utility/Ratings/AppRatingsUtility.swift +++ b/WordPress/Classes/Utility/Ratings/AppRatingsUtility.swift @@ -23,6 +23,9 @@ class AppRatingUtility: NSObject { /// up to 2 times a year (183 = round(365/2)). @objc var numberOfDaysToWaitBetweenPrompts: Int = 183 + /// A value to indicate whether this launch was an upgrade from a previous version + var didUpgradeVersion: Bool = false + private let defaults: UserDefaults private var sections = [String: Section]() private var promptingDisabledRemote = false @@ -59,6 +62,7 @@ class AppRatingUtility: NSObject { if trackingVersion == version { incrementUseCount() } else { + didUpgradeVersion = true let shouldSkipRating = shouldSkipRatingForCurrentVersion() resetValuesForNewVersion() resetReviewPromptDisabledStatus() @@ -378,7 +382,7 @@ class AppRatingUtility: NSObject { } private enum Constants { - static let defaultAppReviewURL = URL(string: "https://itunes.apple.com/app/id335703880?mt=8&action=write-review")! + static let defaultAppReviewURL = URL(string: "https://itunes.apple.com/app/id\(AppConstants.itunesAppID)?mt=8&action=write-review")! static let promptDisabledURL = URL(string: "https://api.wordpress.org/iphoneapp/app-review-prompt-check/1.0/")! } } diff --git a/WordPress/Classes/Utility/ReachabilityUtils.m b/WordPress/Classes/Utility/ReachabilityUtils.m index e1b25a5d276b..150555ce003e 100644 --- a/WordPress/Classes/Utility/ReachabilityUtils.m +++ b/WordPress/Classes/Utility/ReachabilityUtils.m @@ -38,12 +38,12 @@ - (void)show message:message preferredStyle:UIAlertControllerStyleAlert]; - [alertController addCancelActionWithTitle:NSLocalizedString(@"OK", @"") handler:^(UIAlertAction *action) { + [alertController addCancelActionWithTitle:NSLocalizedString(@"OK", @"") handler:^(UIAlertAction * __unused action) { __currentReachabilityAlert = nil; }]; if (self.retryBlock) { - [alertController addDefaultActionWithTitle:NSLocalizedString(@"Retry?", @"") handler:^(UIAlertAction *action) { + [alertController addDefaultActionWithTitle:NSLocalizedString(@"Retry?", @"") handler:^(UIAlertAction * __unused action) { self.retryBlock(); __currentReachabilityAlert = nil; }]; diff --git a/WordPress/Classes/Utility/SFHFKeychainUtils.h b/WordPress/Classes/Utility/SFHFKeychainUtils.h index 1b7dd354c872..0bf448028a60 100755 --- a/WordPress/Classes/Utility/SFHFKeychainUtils.h +++ b/WordPress/Classes/Utility/SFHFKeychainUtils.h @@ -65,4 +65,7 @@ accessGroup:(NSString *)accessGroup error:(NSError **)error; ++ (NSArray<NSDictionary<NSString *, NSString *> *> *)getAllPasswordsForAccessGroup:(NSString *)accessGroup + error:(NSError **)error; + @end \ No newline at end of file diff --git a/WordPress/Classes/Utility/SFHFKeychainUtils.m b/WordPress/Classes/Utility/SFHFKeychainUtils.m index b4ebe3bc07a7..8a79a3eceb6f 100755 --- a/WordPress/Classes/Utility/SFHFKeychainUtils.m +++ b/WordPress/Classes/Utility/SFHFKeychainUtils.m @@ -528,5 +528,49 @@ + (BOOL)deleteItemForUsername:(NSString *)username error:error]; } ++ (NSArray<NSDictionary<NSString *, NSString *> *> *)getAllPasswordsForAccessGroup:(NSString *)accessGroup + error:(NSError **)error { + NSMutableDictionary *query = [[@{ + (__bridge id)kSecClass: (__bridge NSString *)kSecClassGenericPassword, + (__bridge id)kSecMatchLimit: (__bridge NSString *)kSecMatchLimitAll, + (__bridge id)kSecReturnAttributes: @YES, + (__bridge id)kSecReturnRef: @YES + } mutableCopy] autorelease]; -@end \ No newline at end of file + if (accessGroup.length > 0) { + query[(__bridge id)kSecAttrAccessGroup] = accessGroup; + } + + CFTypeRef result = NULL; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)[NSDictionary dictionaryWithDictionary:query], &result); + + if (status != noErr) { + if (error != nil) { + *error = [NSError errorWithDomain:SFHFKeychainUtilsErrorDomain code:status userInfo:nil]; + } + + return nil; + } + + if (result != NULL) { + NSMutableArray *items = [[NSMutableArray alloc] init]; + NSArray *resultsArray = (__bridge NSArray *)result; + for (NSDictionary<id, id> *item in resultsArray) { + NSString *username = item[(__bridge NSString *)kSecAttrAccount]; + NSString *serviceName = item[(__bridge NSString *)kSecAttrService]; + NSString *password = [[[NSString alloc] initWithData:item[(__bridge NSString *)kSecValueData] encoding:NSUTF8StringEncoding] autorelease]; + [items addObject:@{ + @"username": username, + @"serviceName": serviceName, + @"password": password, + }]; + } + CFRelease(result); + + return [items autorelease]; + } + + return nil; +} + +@end diff --git a/WordPress/Classes/Utility/SiteDateFormatters.swift b/WordPress/Classes/Utility/SiteDateFormatters.swift index 91bf12c919a3..b28a3c2bfdfe 100644 --- a/WordPress/Classes/Utility/SiteDateFormatters.swift +++ b/WordPress/Classes/Utility/SiteDateFormatters.swift @@ -9,11 +9,4 @@ struct SiteDateFormatters { formatter.timeZone = timeZone return formatter } - - static func dateFormatter(for blog: Blog, dateStyle: DateFormatter.Style, timeStyle: DateFormatter.Style, managedObjectContext: NSManagedObjectContext = ContextManager.sharedInstance().mainContext) -> DateFormatter { - let blogService = BlogService(managedObjectContext: managedObjectContext) - let timeZone = blogService.timeZone(for: blog) - - return dateFormatter(for: timeZone, dateStyle: dateStyle, timeStyle: timeStyle) - } } diff --git a/WordPress/Classes/Utility/Spotlight/SearchManager.swift b/WordPress/Classes/Utility/Spotlight/SearchManager.swift index 0e82b8cc57f3..f63122c0df4d 100644 --- a/WordPress/Classes/Utility/Spotlight/SearchManager.swift +++ b/WordPress/Classes/Utility/Spotlight/SearchManager.swift @@ -253,8 +253,8 @@ fileprivate extension SearchManager { onSuccess: @escaping (_ post: AbstractPost) -> Void, onFailure: @escaping () -> Void) { let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - guard let blog = blogService.blog(byBlogId: blogID) else { + + guard let blog = Blog.lookup(withID: blogID, in: context) else { onFailure() return } @@ -272,11 +272,9 @@ fileprivate extension SearchManager { onSuccess: @escaping (_ post: AbstractPost) -> Void, onFailure: @escaping () -> Void) { let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - guard let selfHostedBlogs = blogService.blogsWithNoAccount() as? [Blog], - let blog = selfHostedBlogs.filter({ $0.xmlrpc == blogXMLRpcString }).first else { - onFailure() - return + guard let blog = Blog.selfHosted(in: context).first(where: { $0.xmlrpc == blogXMLRpcString }) else { + onFailure() + return } let postService = PostService(managedObjectContext: context) @@ -291,8 +289,8 @@ fileprivate extension SearchManager { onSuccess: @escaping (_ blog: Blog) -> Void, onFailure: @escaping () -> Void) { let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - guard let blog = blogService.blog(byBlogId: blogID) else { + + guard let blog = Blog.lookup(withID: blogID, in: context) else { onFailure() return } @@ -303,11 +301,9 @@ fileprivate extension SearchManager { onSuccess: @escaping (_ blog: Blog) -> Void, onFailure: @escaping () -> Void) { let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - guard let selfHostedBlogs = blogService.blogsWithNoAccount() as? [Blog], - let blog = selfHostedBlogs.filter({ $0.xmlrpc == blogXMLRpcString }).first else { - onFailure() - return + guard let blog = Blog.selfHosted(in: context).first(where: { $0.xmlrpc == blogXMLRpcString }) else { + onFailure() + return } onSuccess(blog) } @@ -315,47 +311,47 @@ fileprivate extension SearchManager { // MARK: Site Tab Navigation func openMySitesTab() -> Bool { - WPTabBarController.sharedInstance().showMySitesTab() + RootViewCoordinator.sharedPresenter.showMySitesTab() return true } func openSiteDetailsScreen(for blog: Blog) { - WPTabBarController.sharedInstance().switchMySitesTabToBlogDetails(for: blog) + RootViewCoordinator.sharedPresenter.showBlogDetails(for: blog) } // MARK: Reader Tab Navigation func openReaderTab() -> Bool { - WPTabBarController.sharedInstance().showReaderTab() + RootViewCoordinator.sharedPresenter.showReaderTab() return true } // MARK: Me Tab Navigation func openMeTab() -> Bool { - WPTabBarController.sharedInstance().showMeScene() + RootViewCoordinator.sharedPresenter.showMeScene() return true } func openAppSettingsScreen() -> Bool { - WPTabBarController.sharedInstance().navigateToAppSettings() + RootViewCoordinator.sharedPresenter.navigateToAppSettings() return true } func openSupportScreen() -> Bool { - WPTabBarController.sharedInstance().navigateToSupport() + RootViewCoordinator.sharedPresenter.navigateToSupport() return true } // MARK: Notification Tab Navigation func openNotificationsTab() -> Bool { - WPTabBarController.sharedInstance().showNotificationsTab() + RootViewCoordinator.sharedPresenter.showNotificationsTab() return true } func openNotificationSettingsScreen() -> Bool { - WPTabBarController.sharedInstance().switchNotificationsTabToNotificationSettings() + RootViewCoordinator.sharedPresenter.switchNotificationsTabToNotificationSettings() return true } @@ -402,9 +398,9 @@ fileprivate extension SearchManager { func openListView(for apost: AbstractPost) { closePreviewIfNeeded(for: apost) if let post = apost as? Post { - WPTabBarController.sharedInstance().switchTabToPostsList(for: post) + RootViewCoordinator.sharedPresenter.showPosts(for: post.blog) } else if let page = apost as? Page { - WPTabBarController.sharedInstance().switchTabToPagesList(for: page) + RootViewCoordinator.sharedPresenter.showPages(for: page.blog) } } @@ -416,7 +412,7 @@ fileprivate extension SearchManager { onFailure() return } - WPTabBarController.sharedInstance().showReaderTab(forPost: postID, onBlog: blogID) + RootViewCoordinator.sharedPresenter.showReaderTab(forPost: postID, onBlog: blogID) } func openReader(for postID: NSNumber, siteID: NSNumber, onFailure: () -> Void) { @@ -425,7 +421,7 @@ fileprivate extension SearchManager { onFailure() return } - WPTabBarController.sharedInstance().showReaderTab(forPost: postID, onBlog: siteID) + RootViewCoordinator.sharedPresenter.showReaderTab(forPost: postID, onBlog: siteID) } // MARK: - Editor @@ -435,50 +431,31 @@ fileprivate extension SearchManager { openListView(for: post) let editor = EditPostViewController.init(post: post) editor.modalPresentationStyle = .fullScreen - WPTabBarController.sharedInstance().present(editor, animated: true) + RootViewCoordinator.sharedPresenter.rootViewController.present(editor, animated: true) } func openEditor(for page: Page) { closePreviewIfNeeded(for: page) openListView(for: page) - let editorFactory = EditorFactory() - let editor = editorFactory.instantiateEditor( - for: page, - replaceEditor: { [weak self] (editor, replacement) in - self?.replaceEditor(editor: editor, replacement: replacement) - }) - - open(editor) - } - - private func open(_ editor: EditorViewController) { - editor.onClose = { [unowned editor] changesSaved, _ in - editor.dismiss(animated: true) - } - - let navController = UINavigationController(rootViewController: editor) - navController.restorationIdentifier = Restorer.Identifier.navigationController.rawValue - navController.modalPresentationStyle = .fullScreen - WPTabBarController.sharedInstance().present(navController, animated: true) - } - - func replaceEditor(editor: EditorViewController, replacement: EditorViewController) { - editor.dismiss(animated: true) { [weak self] in - self?.open(replacement) - } + let editorViewController = EditPageViewController(page: page) + RootViewCoordinator.sharedPresenter.rootViewController.present(editorViewController, animated: false) } // MARK: - Preview func openPreview(for apost: AbstractPost) { - WPTabBarController.sharedInstance().showMySitesTab() + RootViewCoordinator.sharedPresenter.showMySitesTab() closePreviewIfNeeded(for: apost) - let controller = PreviewWebKitViewController(post: apost) + let controller = PreviewWebKitViewController(post: apost, source: "spotlight_preview_post") controller.trackOpenEvent() let navWrapper = LightNavigationController(rootViewController: controller) - WPTabBarController.sharedInstance().present(navWrapper, animated: true) + let rootViewController = RootViewCoordinator.sharedPresenter.rootViewController + if rootViewController.traitCollection.userInterfaceIdiom == .pad { + navWrapper.modalPresentationStyle = .fullScreen + } + rootViewController.present(navWrapper, animated: true) openListView(for: apost) } @@ -487,7 +464,8 @@ fileprivate extension SearchManager { /// AbstractPost, leave it open, otherwise close it. /// func closePreviewIfNeeded(for apost: AbstractPost) { - guard let navController = WPTabBarController.sharedInstance().presentedViewController as? UINavigationController else { + let rootViewController = RootViewCoordinator.sharedPresenter.rootViewController + guard let navController = rootViewController.presentedViewController as? UINavigationController else { return } @@ -503,7 +481,8 @@ fileprivate extension SearchManager { /// If there is any post preview window open, close it. /// func closeAnyOpenPreview() { - guard let navController = WPTabBarController.sharedInstance().presentedViewController as? UINavigationController, + let rootViewController = RootViewCoordinator.sharedPresenter.rootViewController + guard let navController = rootViewController.presentedViewController as? UINavigationController, navController.topViewController is PreviewWebKitViewController else { return } diff --git a/WordPress/Classes/Utility/Spotlight/SearchableActivityConvertable.swift b/WordPress/Classes/Utility/Spotlight/SearchableActivityConvertable.swift index e4545af5d547..1b5fa1c150a2 100644 --- a/WordPress/Classes/Utility/Spotlight/SearchableActivityConvertable.swift +++ b/WordPress/Classes/Utility/Spotlight/SearchableActivityConvertable.swift @@ -103,13 +103,10 @@ extension SearchableActivityConvertable where Self: UIViewController { activity.isEligibleForSearch = true activity.isEligibleForHandoff = false + activity.isEligibleForPrediction = true - if #available(iOS 12.0, *) { - activity.isEligibleForPrediction = true - - if let wpActivityType = WPActivityType(rawValue: activityType) { - activity.suggestedInvocationPhrase = wpActivityType.suggestedInvocationPhrase - } + if let wpActivityType = WPActivityType(rawValue: activityType) { + activity.suggestedInvocationPhrase = wpActivityType.suggestedInvocationPhrase } // Set the UIViewController's userActivity property, which is defined in UIResponder. Doing this allows diff --git a/WordPress/Classes/Utility/Spotlight/Spotlightable.swift b/WordPress/Classes/Utility/Spotlight/Spotlightable.swift new file mode 100644 index 000000000000..1913ad2911cb --- /dev/null +++ b/WordPress/Classes/Utility/Spotlight/Spotlightable.swift @@ -0,0 +1,121 @@ +import UIKit + +protocol Spotlightable: UIView { + var spotlight: QuickStartSpotlightView? { get } + var shouldShowSpotlight: Bool { get set } +} + +class SpotlightableButton: UIButton, Spotlightable { + + enum SpotlightHorizontalPosition { + case leading + case trailing + + var defaultOffset: UIOffset { + switch self { + case .leading: + return Constants.leadingDefaultOffset + case .trailing: + return Constants.trailingDefaultOffset + } + } + } + + var spotlight: QuickStartSpotlightView? + var originalTitle: String? + var spotlightHorizontalPosition: SpotlightHorizontalPosition = .leading + + private var spotlightHorizontalAnchor: NSLayoutXAxisAnchor { + switch spotlightHorizontalPosition { + case .leading: + return leadingAnchor + case .trailing: + return trailingAnchor + } + } + + /// If this property is set, the default offset will be overridden. + /// + var spotlightOffset: UIOffset? + private var spotlightXConstraint: NSLayoutConstraint? + private var spotlightYConstraint: NSLayoutConstraint? + + var shouldShowSpotlight: Bool { + get { + spotlight != nil + } + set { + switch newValue { + case true: + setupSpotlight() + case false: + spotlight?.removeFromSuperview() + spotlight = nil + } + } + } + + func startLoading() { + originalTitle = titleLabel?.text + setTitle("", for: .normal) + activityIndicator.startAnimating() + } + + func stopLoading() { + activityIndicator.stopAnimating() + setTitle(originalTitle, for: .normal) + } + + private lazy var activityIndicator: UIActivityIndicatorView = { + let activityIndicator = UIActivityIndicatorView(style: .large) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicator) + + NSLayoutConstraint.activate([ + activityIndicator.centerYAnchor.constraint(equalTo: self.centerYAnchor), + activityIndicator.centerXAnchor.constraint(equalTo: self.centerXAnchor) + ]) + + return activityIndicator + }() + + private func setupSpotlight() { + spotlight?.removeFromSuperview() + + let spotlightView = QuickStartSpotlightView() + addSubview(spotlightView) + spotlightView.translatesAutoresizingMaskIntoConstraints = false + + let spotlightXConstraint = spotlightView.centerXAnchor.constraint(equalTo: spotlightHorizontalAnchor) + let spotlightYConstraint = spotlightView.centerYAnchor.constraint(equalTo: centerYAnchor) + + self.spotlightXConstraint = spotlightXConstraint + self.spotlightYConstraint = spotlightYConstraint + updateConstraintConstants() + + let newSpotlightWidth = spotlightView.widthAnchor.constraint(equalToConstant: Constants.spotlightDiameter) + let newSpotlightHeight = spotlightView.heightAnchor.constraint(equalToConstant: Constants.spotlightDiameter) + + NSLayoutConstraint.activate([ + spotlightXConstraint, + spotlightYConstraint, + newSpotlightWidth, + newSpotlightHeight + ]) + + spotlight = spotlightView + } + + private func updateConstraintConstants() { + let offset = spotlightOffset ?? spotlightHorizontalPosition.defaultOffset + + spotlightXConstraint?.constant = offset.horizontal + spotlightYConstraint?.constant = offset.vertical + } + + private enum Constants { + static let spotlightDiameter: CGFloat = 40 + static let leadingDefaultOffset = UIOffset(horizontal: -10, vertical: 0) + static let trailingDefaultOffset = UIOffset(horizontal: 10, vertical: 0) + } +} diff --git a/WordPress/Classes/Utility/UIAlertControllerProxy.m b/WordPress/Classes/Utility/UIAlertControllerProxy.m index cce289b10ea6..c6aea284b3f4 100644 --- a/WordPress/Classes/Utility/UIAlertControllerProxy.m +++ b/WordPress/Classes/Utility/UIAlertControllerProxy.m @@ -15,7 +15,7 @@ - (UIAlertController *)showWithTitle:(NSString *)title message:message preferredStyle:UIAlertControllerStyleAlert]; - void (^handler)(UIAlertAction *) = ^(UIAlertAction *action) { + void (^handler)(UIAlertAction *) = ^(UIAlertAction * __unused action) { if (!tapBlock) { return; } diff --git a/WordPress/Classes/Utility/Universal Links/Migration/MigrationDeepLinkRouter.swift b/WordPress/Classes/Utility/Universal Links/Migration/MigrationDeepLinkRouter.swift new file mode 100644 index 000000000000..40c5952029a5 --- /dev/null +++ b/WordPress/Classes/Utility/Universal Links/Migration/MigrationDeepLinkRouter.swift @@ -0,0 +1,54 @@ +/// A router that specifically handles deeplinks. +/// Note that the capability of this router is very limited; it can only handle up to one path component (e.g.: `wordpress://intent`). +/// +/// This is meant to be used during the WP->JP migratory period. Once we decide to move on from this phase, this class may be removed. +/// +struct MigrationDeepLinkRouter: LinkRouter { + + let routes: [Route] + + /// when this is set, the router ensures that the URL has a scheme that matches this value. + private var scheme: String? = nil + + init(routes: [Route]) { + self.routes = routes + } + + init(scheme: String?, routes: [Route]) { + self.init(routes: routes) + self.scheme = scheme + } + + init(urlForScheme: URL?, routes: [Route]) { + self.init(scheme: urlForScheme?.scheme, routes: routes) + } + + func canHandle(url: URL) -> Bool { + // if the scheme is set, check if the URL fulfills the requirement. + if let scheme, url.scheme != scheme { + return false + } + + /// deeplinks have their paths start at `host`, unlike universal links. + /// e.g. wordpress://intent -> "intent" is the URL's host. + /// + /// Ensure that the deeplink URL has a "host" that we can run against the `routes`' path. + guard let deepLinkPath = url.host else { + return false + } + + return routes + .map { $0.path.removingPrefix("/") } + .contains { $0 == deepLinkPath } + } + + func handle(url: URL, shouldTrack track: Bool = false, source: DeepLinkSource? = nil) { + guard let deepLinkPath = url.host, + let route = routes.filter({ $0.path.removingPrefix("/") == deepLinkPath }).first else { + return + } + + // there's no need to pass any arguments or parameters since most of the migration deeplink routes are standalone. + route.action.perform([:], source: nil, router: self) + } +} diff --git a/WordPress/Classes/Utility/Universal Links/Migration/WPAdminConvertibleRouter.swift b/WordPress/Classes/Utility/Universal Links/Migration/WPAdminConvertibleRouter.swift new file mode 100644 index 000000000000..4bf7692b6d5a --- /dev/null +++ b/WordPress/Classes/Utility/Universal Links/Migration/WPAdminConvertibleRouter.swift @@ -0,0 +1,95 @@ +/// A router that handles routes that can be converted to /wp-admin links. +/// +/// Note that this is a workaround for an infinite redirect issue between WordPress and Jetpack +/// when both apps are installed. +/// +/// This can be removed once we remove the Universal Link routes for the WordPress app. +struct WPAdminConvertibleRouter: LinkRouter { + static let shared = WPAdminConvertibleRouter(routes: [ + EditPostRoute() + ]) + + let routes: [Route] + let matcher: RouteMatcher + + init(routes: [Route]) { + self.routes = routes + matcher = RouteMatcher(routes: routes) + } + + func canHandle(url: URL) -> Bool { + return matcher.routesMatching(url).count > 0 + } + + func handle(url: URL, shouldTrack track: Bool = false, source: DeepLinkSource? = nil) { + matcher.routesMatching(url).forEach { route in + route.action.perform(route.values, source: nil, router: self) + } + } +} + +// MARK: - Routes + +struct EditPostRoute: Route { + let path = "/post/:domain/:postID" + let section: DeepLinkSection? = nil + let action: NavigationAction = WPAdminConvertibleNavigationAction.editPost + let jetpackPowered: Bool = false +} + +// MARK: - Navigation Action + +enum WPAdminConvertibleNavigationAction: NavigationAction { + case editPost + + func perform(_ values: [String: String], source: UIViewController?, router: LinkRouter) { + let wpAdminURL: URL? = { + switch self { + case .editPost: + guard let url = blogURLString(from: values), + let postID = postID(from: values) else { + return nil + } + + var components = URLComponents(string: "https://\(url)/wp-admin/post.php") + components?.queryItems = [ + .init(name: "post", value: postID), + .init(name: "action", value: "edit"), + .init(name: "calypsoify", value: "1") + ] + return components?.url + } + }() + + guard let wpAdminURL else { + return + } + + UIApplication.shared.open(wpAdminURL) + } +} + +private extension WPAdminConvertibleNavigationAction { + func blogURLString(from values: [String: String]?) -> String? { + guard let domain = values?["domain"] else { + return nil + } + + // First, check if the provided domain is a siteID. + // If it is, then try to look up existing blogs and return the URL instead. + if let siteID = Int(domain) { + let blog = try? Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) + return blog?.hostURL as? String + } + + if let _ = URL(string: domain) { + return domain + } + + return nil + } + + func postID(from values: [String: String]?) -> String? { + return values?["postID"] + } +} diff --git a/WordPress/Classes/Utility/Universal Links/Migration/WordPressExportRoute.swift b/WordPress/Classes/Utility/Universal Links/Migration/WordPressExportRoute.swift new file mode 100644 index 000000000000..12707abea335 --- /dev/null +++ b/WordPress/Classes/Utility/Universal Links/Migration/WordPressExportRoute.swift @@ -0,0 +1,37 @@ +/// Triggers the data export process on WordPress. +/// +/// Note: this is only meant to be used in WordPress! +/// +struct WordPressExportRoute: Route { + let path = "/export-213" + let section: DeepLinkSection? = nil + var action: NavigationAction { + return self + } + let jetpackPowered: Bool = false +} + +extension WordPressExportRoute: NavigationAction { + func perform(_ values: [String: String], source: UIViewController?, router: LinkRouter) { + guard AppConfiguration.isWordPress else { + return + } + + ContentMigrationCoordinator.shared.startAndDo { _ in + // Regardless of the result, redirect the user back to Jetpack. + let jetpackUrl: URL? = { + var components = URLComponents() + components.scheme = JetpackNotificationMigrationService.jetpackScheme + return components.url + }() + + guard let url = jetpackUrl, + UIApplication.shared.canOpenURL(url) else { + DDLogError("WordPressExportRoute: Cannot redirect back to the Jetpack app.") + return + } + + UIApplication.shared.open(url) + } + } +} diff --git a/WordPress/Classes/Utility/Universal Links/NavigationActionHelpers.swift b/WordPress/Classes/Utility/Universal Links/NavigationActionHelpers.swift index 2ff5a443bdf5..6b8065cea770 100644 --- a/WordPress/Classes/Utility/Universal Links/NavigationActionHelpers.swift +++ b/WordPress/Classes/Utility/Universal Links/NavigationActionHelpers.swift @@ -4,9 +4,7 @@ import WordPressFlux extension NavigationAction { func defaultBlog() -> Blog? { let context = ContextManager.sharedInstance().mainContext - let service = BlogService(managedObjectContext: context) - - return service.lastUsedOrFirstBlog() + return Blog.lastUsedOrFirst(in: context) } func blog(from values: [String: String]?) -> Blog? { @@ -15,15 +13,14 @@ extension NavigationAction { } let context = ContextManager.sharedInstance().mainContext - let service = BlogService(managedObjectContext: context) - if let blog = service.blog(byHostname: domain) { + if let blog = Blog.lookup(hostname: domain, in: context) { return blog } // Some stats URLs use a site ID instead if let siteIDValue = Int(domain) { - return service.blog(byBlogId: NSNumber(value: siteIDValue)) + return try? Blog.lookup(withID: siteIDValue, in: context) } return nil diff --git a/WordPress/Classes/Utility/Universal Links/Route+Page.swift b/WordPress/Classes/Utility/Universal Links/Route+Page.swift new file mode 100644 index 000000000000..3a8c13e504b3 --- /dev/null +++ b/WordPress/Classes/Utility/Universal Links/Route+Page.swift @@ -0,0 +1,25 @@ +import Foundation + +struct NewPageRoute: Route { + let path = "/page" + let section: DeepLinkSection? = .editor + let action: NavigationAction = NewPageNavigationAction() + let jetpackPowered: Bool = false +} + +struct NewPageForSiteRoute: Route { + let path = "/page/:domain" + let section: DeepLinkSection? = .editor + let action: NavigationAction = NewPageNavigationAction() + let jetpackPowered: Bool = false +} + +struct NewPageNavigationAction: NavigationAction { + func perform(_ values: [String: String], source: UIViewController? = nil, router: LinkRouter) { + if let blog = blog(from: values) { + RootViewCoordinator.sharedPresenter.showPageEditor(forBlog: blog) + } else { + RootViewCoordinator.sharedPresenter.showPageEditor() + } + } +} diff --git a/WordPress/Classes/Utility/Universal Links/Route.swift b/WordPress/Classes/Utility/Universal Links/Route.swift index 3902771cbc1f..adde5cb10e06 100644 --- a/WordPress/Classes/Utility/Universal Links/Route.swift +++ b/WordPress/Classes/Utility/Universal Links/Route.swift @@ -9,11 +9,29 @@ import Foundation /// protocol Route { var path: String { get } + var section: DeepLinkSection? { get } + var source: DeepLinkSource { get } var action: NavigationAction { get } + var shouldTrack: Bool { get } + var jetpackPowered: Bool { get } +} + +extension Route { + // Default routes to handling links rather than other source types + var source: DeepLinkSource { + return .link + } + + // By default, we'll track all routes, but certain routes can override this. + // Routes like banner and email routes may not want to track their original + // link, but will instead just track any redirect that they contain. + var shouldTrack: Bool { + return true + } } protocol NavigationAction { - func perform(_ values: [String: String], source: UIViewController?) + func perform(_ values: [String: String], source: UIViewController?, router: LinkRouter) } extension NavigationAction { @@ -26,6 +44,14 @@ extension NavigationAction { return false } + // TODO: This is a workaround. Remove after the Universal Link routes for the WordPress app are removed. + // + // Don't fallback to Safari if the counterpart WordPress/Jetpack app is installed. + // Read more: https://github.com/wordpress-mobile/WordPress-iOS/issues/19755 + if MigrationAppDetection.isCounterpartAppInstalled { + return false + } + let noOptions: [UIApplication.OpenExternalURLOptionsKey: Any] = [:] UIApplication.shared.open(url, options: noOptions, completionHandler: nil) return true @@ -33,7 +59,7 @@ extension NavigationAction { } struct FailureNavigationAction: NavigationAction { - func perform(_ values: [String: String], source: UIViewController?) { + func perform(_ values: [String: String], source: UIViewController?, router: LinkRouter) { // This navigation action exists only to fail navigations } @@ -58,3 +84,56 @@ extension Route { return path == route.path } } + +// MARK: - Tracking + +/// Where did the deep link originate? +/// +enum DeepLinkSource: Equatable { + case link + case banner + case email(campaign: String) + case widget + case inApp(presenter: UIViewController?) + + init?(sourceName: String) { + switch sourceName { + // We only care about widgets right now, but we could + // add others in the future if necessary. + case "widget": + self = .widget + default: + return nil + } + } + + var isInternal: Bool { + switch self { + case .inApp: + return true + default: + return false + } + } + + var trackingInfo: String? { + switch self { + case .email(let campaign): + return campaign + default: + return nil + } + } +} + +/// Which broad section of the app is being linked to? +/// +enum DeepLinkSection: String { + case editor + case me + case mySite = "my_site" + case notifications + case reader + case siteCreation = "site_creation" + case stats +} diff --git a/WordPress/Classes/Utility/Universal Links/RouteMatcher.swift b/WordPress/Classes/Utility/Universal Links/RouteMatcher.swift index 7bf79dc1feaa..1192efb636fe 100644 --- a/WordPress/Classes/Utility/Universal Links/RouteMatcher.swift +++ b/WordPress/Classes/Utility/Universal Links/RouteMatcher.swift @@ -61,9 +61,22 @@ class RouteMatcher { values[MatchedRouteURLComponentKey.fragment.rawValue] = fragment } + if let source = sourceQueryItemValue(for: url) { + values[MatchedRouteURLComponentKey.source.rawValue] = source + } + return values } + private func sourceQueryItemValue(for url: URL) -> String? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let urlSource = components.queryItems?.first(where: { $0.name == "source" })?.value?.removingPercentEncoding else { + return nil + } + + return urlSource + } + private func isPlaceholder(_ component: String) -> Bool { return component.hasPrefix(":") } @@ -93,13 +106,26 @@ class RouteMatcher { /// struct MatchedRoute: Route { let path: String + let section: DeepLinkSection? + let source: DeepLinkSource let action: NavigationAction + let shouldTrack: Bool let values: [String: String] - - init(path: String, action: NavigationAction, values: [String: String] = [:]) { - self.path = path - self.action = action + let jetpackPowered: Bool + + init(from route: Route, with values: [String: String] = [:]) { + // Allows optional overriding of source based on the input URL parameters. + // Currently used for widget links. + let sourceValue = values[MatchedRouteURLComponentKey.source.rawValue] ?? "" + let source = DeepLinkSource(sourceName: sourceValue) + + self.path = route.path + self.section = route.section + self.source = source ?? route.source + self.action = route.action + self.shouldTrack = route.shouldTrack self.values = values + self.jetpackPowered = route.jetpackPowered } } @@ -107,11 +133,12 @@ extension Route { /// - returns: A MatchedRoute for the current path, with optional values /// extracted from the resolved path. fileprivate func matched(with values: [String: String] = [:]) -> MatchedRoute { - return MatchedRoute(path: path, action: action, values: values) + return MatchedRoute(from: self, with: values) } } enum MatchedRouteURLComponentKey: String { case fragment = "matched-route-fragment" + case source = "matched-route-source" case url = "matched-route-url" } diff --git a/WordPress/Classes/Utility/Universal Links/Routes+Banners.swift b/WordPress/Classes/Utility/Universal Links/Routes+Banners.swift index 9a110ff9d63f..14f4408c840c 100644 --- a/WordPress/Classes/Utility/Universal Links/Routes+Banners.swift +++ b/WordPress/Classes/Utility/Universal Links/Routes+Banners.swift @@ -11,6 +11,10 @@ import Foundation /// struct AppBannerRoute: Route { let path = "/get" + let section: DeepLinkSection? = nil + let source: DeepLinkSource = .banner + let shouldTrack: Bool = false + let jetpackPowered: Bool = false var action: NavigationAction { return self @@ -18,7 +22,7 @@ struct AppBannerRoute: Route { } extension AppBannerRoute: NavigationAction { - func perform(_ values: [String: String], source: UIViewController? = nil) { + func perform(_ values: [String: String], source: UIViewController? = nil, router: LinkRouter) { guard let fragmentValue = values[MatchedRouteURLComponentKey.fragment.rawValue], let fragment = fragmentValue.removingPercentEncoding else { return @@ -32,9 +36,7 @@ extension AppBannerRoute: NavigationAction { components.path = fragment if let url = components.url { - // We disable tracking when passing the URL back through the router, - // otherwise we'd be posting two stats events. - UniversalLinkRouter.shared.handle(url: url, shouldTrack: false) + router.handle(url: url, shouldTrack: true, source: .banner) } } } diff --git a/WordPress/Classes/Utility/Universal Links/Routes+Jetpack.swift b/WordPress/Classes/Utility/Universal Links/Routes+Jetpack.swift new file mode 100644 index 000000000000..b7427fba7086 --- /dev/null +++ b/WordPress/Classes/Utility/Universal Links/Routes+Jetpack.swift @@ -0,0 +1,15 @@ +struct JetpackRoute: Route { + let path = "/app" + let section: DeepLinkSection? = nil + var action: NavigationAction { + return self + } + let jetpackPowered: Bool = false +} + +extension JetpackRoute: NavigationAction { + func perform(_ values: [String: String], source: UIViewController?, router: LinkRouter) { + // We don't care where it opens in the app as long as it opens the app. + // If we handle deferred linking only then it would be relevant. + } +} diff --git a/WordPress/Classes/Utility/Universal Links/Routes+Mbar.swift b/WordPress/Classes/Utility/Universal Links/Routes+Mbar.swift index 9f4397ca148b..231f46ac1310 100644 --- a/WordPress/Classes/Utility/Universal Links/Routes+Mbar.swift +++ b/WordPress/Classes/Utility/Universal Links/Routes+Mbar.swift @@ -1,5 +1,5 @@ import Foundation - +import Alamofire /// Handles mbar redirects. These are marketing redirects to URLs that mobile should handle. /// @@ -12,16 +12,30 @@ import Foundation /// will be opened and then the default browser will be opened... but other than that, it is /// a safe procedure. /// +/// Many mbar links will consist of an initial redirect_to value of wp-login.php, which in turn +/// has its own redirect_to parameter containing our final destination. For these links, we'll +/// keep following the redirects until we find the end point. +/// /// * /mbar/?redirect_to=https%3A%2F%2Fwordpress.com%2Fpost%2Fsomesite.wordpress.com /// -struct MbarRoute: Route { - static let redirectURLParameter = "redirect_to" +public struct MbarRoute: Route { + private static let redirectURLParameter = "redirect_to" + private static let campaignURLParameter = "login_reason" + private static let unknownCampaignValue = "unknown" + private static let loginURLPath = "wp-login.php" + let path = "/mbar" + let section: DeepLinkSection? = nil + var action: NavigationAction { return self } + let shouldTrack: Bool = false + + let jetpackPowered: Bool = false + private func redirectURL(from url: String) -> URL? { guard let components = URLComponents(string: url) else { return nil @@ -30,17 +44,35 @@ struct MbarRoute: Route { return redirectURL(from: components) } - private func redirectURL(from components: URLComponents) -> URL? { + private func redirectURL(from components: URLComponents, followRedirects: Bool = true) -> URL? { guard let redirectURL = components.queryItems?.first(where: { $0.name == MbarRoute.redirectURLParameter })?.value?.removingPercentEncoding else { return nil } - return URL(string: redirectURL) + let url = URL(string: redirectURL) + + // If this is a wp-login link, handle _its_ redirect_to parameter + if followRedirects && url?.lastPathComponent == MbarRoute.loginURLPath { + return self.redirectURL(from: redirectURL) + } + + return url + } + + private func campaign(from url: String) -> String { + guard let components = URLComponents(string: url), + let url = redirectURL(from: components, followRedirects: false), + let redirectComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), + let campaignValue = redirectComponents.queryItems?.first(where: { $0.name == MbarRoute.campaignURLParameter })?.value?.removingPercentEncoding else { + return MbarRoute.unknownCampaignValue + } + + return campaignValue } } extension MbarRoute: NavigationAction { - func perform(_ values: [String: String], source: UIViewController? = nil) { + func perform(_ values: [String: String], source: UIViewController? = nil, router: LinkRouter) { guard let url = values[MatchedRouteURLComponentKey.url.rawValue], let redirectUrl = redirectURL(from: url) else { @@ -48,6 +80,19 @@ extension MbarRoute: NavigationAction { return } - UniversalLinkRouter.shared.handle(url: redirectUrl, shouldTrack: false, source: source) + // If we're handling the link in the app, fire off a request to the + // original URL so that any necessary tracking takes places. + Alamofire.request(url) + .validate() + .responseData { response in + switch response.result { + case .success: + DDLogInfo("Mbar deep link request successful.") + case .failure(let error): + DDLogError("Mbar deep link request failed: \(error.localizedDescription)") + } + } + + router.handle(url: redirectUrl, shouldTrack: true, source: .email(campaign: campaign(from: url))) } } diff --git a/WordPress/Classes/Utility/Universal Links/Routes+Me.swift b/WordPress/Classes/Utility/Universal Links/Routes+Me.swift index b2070d5eb0cc..caee3c3ad9c5 100644 --- a/WordPress/Classes/Utility/Universal Links/Routes+Me.swift +++ b/WordPress/Classes/Utility/Universal Links/Routes+Me.swift @@ -2,17 +2,23 @@ import Foundation struct MeRoute: Route { let path = "/me" + let section: DeepLinkSection? = .me let action: NavigationAction = MeNavigationAction.root + let jetpackPowered: Bool = false } struct MeAccountSettingsRoute: Route { let path = "/me/account" + let section: DeepLinkSection? = .me let action: NavigationAction = MeNavigationAction.accountSettings + let jetpackPowered: Bool = false } struct MeNotificationSettingsRoute: Route { let path = "/me/notifications" + let section: DeepLinkSection? = .me let action: NavigationAction = MeNavigationAction.notificationSettings + let jetpackPowered: Bool = true } enum MeNavigationAction: NavigationAction { @@ -20,17 +26,14 @@ enum MeNavigationAction: NavigationAction { case accountSettings case notificationSettings - func perform(_ values: [String: String] = [:], source: UIViewController? = nil) { + func perform(_ values: [String: String] = [:], source: UIViewController? = nil, router: LinkRouter) { switch self { case .root: - WPTabBarController.sharedInstance().showMeScene() - if !FeatureFlag.meMove.enabled { - WPTabBarController.sharedInstance().popMeTabToRoot() - } + RootViewCoordinator.sharedPresenter.showMeScene() case .accountSettings: - WPTabBarController.sharedInstance().navigateToAccountSettings() + RootViewCoordinator.sharedPresenter.navigateToAccountSettings() case .notificationSettings: - WPTabBarController.sharedInstance().switchNotificationsTabToNotificationSettings() + RootViewCoordinator.sharedPresenter.switchNotificationsTabToNotificationSettings() } } } diff --git a/WordPress/Classes/Utility/Universal Links/Routes+MySites.swift b/WordPress/Classes/Utility/Universal Links/Routes+MySites.swift index 703b45d9d26e..f8ae48a68001 100644 --- a/WordPress/Classes/Utility/Universal Links/Routes+MySites.swift +++ b/WordPress/Classes/Utility/Universal Links/Routes+MySites.swift @@ -12,6 +12,10 @@ enum MySitesRoute { } extension MySitesRoute: Route { + var section: DeepLinkSection? { + return .mySite + } + var action: NavigationAction { return self } @@ -36,19 +40,38 @@ extension MySitesRoute: Route { return "/plugins/manage/:domain" } } + + var jetpackPowered: Bool { + switch self { + case .pages: + return false + case .posts: + return false + case .media: + return false + case .comments: + return false + case .sharing: + return true + case .people: + return true + case .plugins: + return false + case .managePlugins: + return false + } + } } extension MySitesRoute: NavigationAction { - func perform(_ values: [String: String], source: UIViewController? = nil) { - guard let coordinator = WPTabBarController.sharedInstance().mySitesCoordinator else { - return - } + func perform(_ values: [String: String], source: UIViewController? = nil, router: LinkRouter) { + let coordinator = RootViewCoordinator.sharedPresenter.mySitesCoordinator guard let blog = blog(from: values) else { WPAppAnalytics.track(.deepLinkFailed, withProperties: ["route": path]) if failAndBounce(values) == false { - coordinator.showMySites() + coordinator.showRootViewController() postFailureNotice(title: NSLocalizedString("Site not found", comment: "Error notice shown if the app can't find a specific site belonging to the user")) } diff --git a/WordPress/Classes/Utility/Universal Links/Routes+Notifications.swift b/WordPress/Classes/Utility/Universal Links/Routes+Notifications.swift index 36f1ceef821e..bc0210e29516 100644 --- a/WordPress/Classes/Utility/Universal Links/Routes+Notifications.swift +++ b/WordPress/Classes/Utility/Universal Links/Routes+Notifications.swift @@ -2,12 +2,14 @@ import Foundation struct NotificationsRoute: Route { let path = "/notifications" + let section: DeepLinkSection? = .notifications let action: NavigationAction = NotificationsNavigationAction() + let jetpackPowered: Bool = true } struct NotificationsNavigationAction: NavigationAction { - func perform(_ values: [String: String], source: UIViewController? = nil) { - WPTabBarController.sharedInstance().showNotificationsTab() - WPTabBarController.sharedInstance().popNotificationsTabToRoot() + func perform(_ values: [String: String], source: UIViewController? = nil, router: LinkRouter) { + RootViewCoordinator.sharedPresenter.showNotificationsTab() + RootViewCoordinator.sharedPresenter.popNotificationsTabToRoot() } } diff --git a/WordPress/Classes/Utility/Universal Links/Routes+Post.swift b/WordPress/Classes/Utility/Universal Links/Routes+Post.swift index 4d29af35f5cc..f8a21542778d 100644 --- a/WordPress/Classes/Utility/Universal Links/Routes+Post.swift +++ b/WordPress/Classes/Utility/Universal Links/Routes+Post.swift @@ -2,20 +2,24 @@ import Foundation struct NewPostRoute: Route { let path = "/post" + let section: DeepLinkSection? = .editor let action: NavigationAction = NewPostNavigationAction() + let jetpackPowered: Bool = false } struct NewPostForSiteRoute: Route { let path = "/post/:domain" + let section: DeepLinkSection? = .editor let action: NavigationAction = NewPostNavigationAction() + let jetpackPowered: Bool = false } struct NewPostNavigationAction: NavigationAction { - func perform(_ values: [String: String], source: UIViewController? = nil) { + func perform(_ values: [String: String], source: UIViewController? = nil, router: LinkRouter) { if let blog = blog(from: values) { - WPTabBarController.sharedInstance().showPostTab(for: blog) + RootViewCoordinator.sharedPresenter.showPostTab(for: blog) } else { - WPTabBarController.sharedInstance().showPostTab() + RootViewCoordinator.sharedPresenter.showPostTab() } } } diff --git a/WordPress/Classes/Utility/Universal Links/Routes+Reader.swift b/WordPress/Classes/Utility/Universal Links/Routes+Reader.swift index 61f6d5411518..70b4d5a631e2 100644 --- a/WordPress/Classes/Utility/Universal Links/Routes+Reader.swift +++ b/WordPress/Classes/Utility/Universal Links/Routes+Reader.swift @@ -5,6 +5,7 @@ enum ReaderRoute { case discover case search case a8c + case p2 case likes case manageFollowing case list @@ -13,6 +14,7 @@ enum ReaderRoute { case blog case feedsPost case blogsPost + case wpcomPost } extension ReaderRoute: Route { @@ -26,6 +28,8 @@ extension ReaderRoute: Route { return "/read/search" case .a8c: return "/read/a8c" + case .p2: + return "/read/p2" case .likes: return "/activities/likes" case .manageFollowing: @@ -42,28 +46,32 @@ extension ReaderRoute: Route { return "/read/feeds/:feed_id/posts/:post_id" case .blogsPost: return "/read/blogs/:blog_id/posts/:post_id" + case .wpcomPost: + return "/:post_year/:post_month/:post_day/:post_name" } } + var section: DeepLinkSection? { + return .reader + } + var action: NavigationAction { return self } + + var jetpackPowered: Bool { + return true + } } extension ReaderRoute: NavigationAction { - func perform(_ values: [String: String], source: UIViewController? = nil) { - guard let coordinator = WPTabBarController.sharedInstance().readerCoordinator else { + func perform(_ values: [String: String], source: UIViewController? = nil, router: LinkRouter) { + guard JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() else { + RootViewCoordinator.sharedPresenter.showReaderTab() // Show static reader tab return } - - coordinator.source = source - - if source == nil { - // If we're not navigating internally, - // we want to bounce back to Safari on failure - coordinator.failureBlock = { - self.failAndBounce(values) - } + guard let coordinator = RootViewCoordinator.sharedPresenter.readerCoordinator else { + return } switch self { @@ -74,7 +82,9 @@ extension ReaderRoute: NavigationAction { case .search: coordinator.showSearch() case .a8c: - coordinator.showA8CTeam() + coordinator.showA8C() + case .p2: + coordinator.showP2() case .likes: coordinator.showMyLikes() case .manageFollowing: @@ -106,6 +116,13 @@ extension ReaderRoute: NavigationAction { if let (blogID, postID) = blogAndPostID(from: values) { coordinator.showPost(with: postID, for: blogID, isFeed: false) } + case .wpcomPost: + if let urlString = values[MatchedRouteURLComponentKey.url.rawValue], + let url = URL(string: urlString), + isValidWpcomUrl(values) { + + coordinator.showPost(with: url) + } } } @@ -130,4 +147,25 @@ extension ReaderRoute: NavigationAction { return (blogID, postID) } + + private func isValidWpcomUrl(_ values: [String: String]) -> Bool { + let year = Int(values["post_year"] ?? "") ?? 0 + let month = Int(values["post_month"] ?? "") ?? 0 + let day = Int(values["post_day"] ?? "") ?? 0 + + // we assume no posts were made in the 1800's or earlier + func isYear(_ year: Int) -> Bool { + year > 1900 + } + + func isMonth(_ month: Int) -> Bool { + (1...12).contains(month) + } + + func isDay(_ day: Int) -> Bool { + (1...31).contains(day) + } + + return isYear(year) && isMonth(month) && isDay(day) + } } diff --git a/WordPress/Classes/Utility/Universal Links/Routes+Start.swift b/WordPress/Classes/Utility/Universal Links/Routes+Start.swift new file mode 100644 index 000000000000..87ffc531aaa4 --- /dev/null +++ b/WordPress/Classes/Utility/Universal Links/Routes+Start.swift @@ -0,0 +1,21 @@ +import Foundation + +struct StartRoute: Route, NavigationAction { + let path = "/start" + + let section: DeepLinkSection? = .siteCreation + + var action: NavigationAction { + return self + } + + let jetpackPowered: Bool = true + + func perform(_ values: [String: String], source: UIViewController?, router: LinkRouter) { + guard AccountHelper.isDotcomAvailable() else { + return + } + + RootViewCoordinator.sharedPresenter.mySitesCoordinator.showSiteCreation() + } +} diff --git a/WordPress/Classes/Utility/Universal Links/Routes+Stats.swift b/WordPress/Classes/Utility/Universal Links/Routes+Stats.swift index 9617843644be..6ca9ae6bfca9 100644 --- a/WordPress/Classes/Utility/Universal Links/Routes+Stats.swift +++ b/WordPress/Classes/Utility/Universal Links/Routes+Stats.swift @@ -11,9 +11,30 @@ enum StatsRoute { case dayCategory case annualStats case activityLog + + var timePeriod: StatsPeriodType? { + switch self { + case .daySite: + return .days + case .weekSite: + return .weeks + case .monthSite: + return .months + case .yearSite: + return .years + case .insights: + return .insights + default: + return nil + } + } } extension StatsRoute: Route { + var section: DeepLinkSection? { + return .stats + } + var action: NavigationAction { return self } @@ -42,13 +63,15 @@ extension StatsRoute: Route { return "/stats/activity/:domain" } } + + var jetpackPowered: Bool { + return true + } } extension StatsRoute: NavigationAction { - func perform(_ values: [String: String], source: UIViewController? = nil) { - guard let coordinator = WPTabBarController.sharedInstance().mySitesCoordinator else { - return - } + func perform(_ values: [String: String], source: UIViewController? = nil, router: LinkRouter) { + let coordinator = RootViewCoordinator.sharedPresenter.mySitesCoordinator switch self { case .root: @@ -102,7 +125,7 @@ extension StatsRoute: NavigationAction { WPAppAnalytics.track(.deepLinkFailed, withProperties: ["route": path]) if failAndBounce(values) == false { - coordinator.showMySites() + coordinator.showRootViewController() postFailureNotice(title: NSLocalizedString("Site not found", comment: "Error notice shown if the app can't find a specific site belonging to the user")) } @@ -115,30 +138,11 @@ extension StatsRoute: NavigationAction { // In this case, we'll check whether the last component is actually a // time period, and if so we'll show that time period for the default site. guard let component = values["domain"], - let timePeriod = StatsPeriodType.fromString(component), - let blog = defaultBlog() else { + let timePeriod = StatsPeriodType(from: component), + let blog = defaultBlog() else { return } coordinator.showStats(for: blog, timePeriod: timePeriod) } } - -private extension StatsPeriodType { - static func fromString(_ string: String) -> StatsPeriodType? { - switch string { - case "day": - return .days - case "week": - return .weeks - case "month": - return .months - case "year": - return .years - case "insights": - return .insights - default: - return nil - } - } -} diff --git a/WordPress/Classes/Utility/Universal Links/UniversalLinkRouter.swift b/WordPress/Classes/Utility/Universal Links/UniversalLinkRouter.swift index 02f4651bb8b8..a67ea50a7c40 100644 --- a/WordPress/Classes/Utility/Universal Links/UniversalLinkRouter.swift +++ b/WordPress/Classes/Utility/Universal Links/UniversalLinkRouter.swift @@ -1,11 +1,19 @@ import Foundation +protocol LinkRouter { + init(routes: [Route]) + func canHandle(url: URL) -> Bool + func handle(url: URL, shouldTrack track: Bool, source: DeepLinkSource?) +} + /// UniversalLinkRouter keeps a list of possible URL routes that are exposed /// via universal links, and handles incoming links to trigger the appropriate route. /// -struct UniversalLinkRouter { +struct UniversalLinkRouter: LinkRouter { private let matcher: RouteMatcher + private static let extraLoggingEnabled = BuildConfiguration.current == .localDeveloper + init(routes: [Route]) { matcher = RouteMatcher(routes: routes) } @@ -17,15 +25,22 @@ struct UniversalLinkRouter { static let shared = UniversalLinkRouter( routes: defaultRoutes) + // A singleton that handles universal link routes without requiring authentication. + // + static let unauthenticated = UniversalLinkRouter(routes: jetpackRoutes) + static let defaultRoutes: [Route] = redirects + meRoutes + newPostRoutes + + newPageRoutes + + jetpackRoutes + notificationsRoutes + readerRoutes + statsRoutes + mySitesRoutes + - appBannerRoutes + appBannerRoutes + + startRoutes static let meRoutes: [Route] = [ MeRoute(), @@ -33,11 +48,20 @@ struct UniversalLinkRouter { MeNotificationSettingsRoute() ] + static let jetpackRoutes: [Route] = [ + JetpackRoute() + ] + static let newPostRoutes: [Route] = [ NewPostRoute(), NewPostForSiteRoute() ] + static let newPageRoutes: [Route] = [ + NewPageRoute(), + NewPageForSiteRoute() + ] + static let notificationsRoutes: [Route] = [ NotificationsRoute() ] @@ -47,6 +71,7 @@ struct UniversalLinkRouter { ReaderRoute.discover, ReaderRoute.search, ReaderRoute.a8c, + ReaderRoute.p2, ReaderRoute.likes, ReaderRoute.manageFollowing, ReaderRoute.list, @@ -54,7 +79,8 @@ struct UniversalLinkRouter { ReaderRoute.feed, ReaderRoute.blog, ReaderRoute.feedsPost, - ReaderRoute.blogsPost + ReaderRoute.blogsPost, + ReaderRoute.wpcomPost ] static let statsRoutes: [Route] = [ @@ -85,6 +111,10 @@ struct UniversalLinkRouter { AppBannerRoute() ] + static let startRoutes: [Route] = [ + StartRoute() + ] + static let redirects: [Route] = [ MbarRoute() ] @@ -95,12 +125,14 @@ struct UniversalLinkRouter { func canHandle(url: URL) -> Bool { let matcherCanHandle = matcher.routesMatching(url).count > 0 - guard let host = url.host else { + guard let host = url.host, let scheme = url.scheme else { return matcherCanHandle } - // If there's a hostname, check it's WordPress.com - return host == "wordpress.com" && matcherCanHandle + // If there's a hostname, check if it's WordPress.com or jetpack.com/app. + return scheme == "https" + && (host == "wordpress.com" || host == "jetpack.com") + && matcherCanHandle } /// Attempts to find a route that matches the url's path, and perform its @@ -109,28 +141,110 @@ struct UniversalLinkRouter { /// - parameter url: The URL to match against. /// - parameter track: If false, don't post an analytics event for this URL. /// - func handle(url: URL, shouldTrack track: Bool = true, source: UIViewController? = nil) { + func handle(url: URL, shouldTrack track: Bool = true, source: DeepLinkSource? = nil) { let matches = matcher.routesMatching(url) - if track { - trackDeepLink(matchCount: matches.count, url: url) + // We don't want to track internal links + if track, source?.isInternal != true { + trackDeepLinks(with: matches, for: url, source: source) } if matches.isEmpty { + // TODO: This is a workaround. Remove after the Universal Link routes for the WordPress app are removed. + // + // Don't fallback to Safari if the counterpart WordPress/Jetpack app is installed. + // Read more: https://github.com/wordpress-mobile/WordPress-iOS/issues/19755 + if MigrationAppDetection.isCounterpartAppInstalled { + return + } + UIApplication.shared.open(url, options: [:], completionHandler: nil) } + // Extract the presenter if there is one + var presentingViewController: UIViewController? = nil + if case .inApp(let viewController) = source { + presentingViewController = viewController + } + for matchedRoute in matches { - matchedRoute.action.perform(matchedRoute.values, source: source) + if matchedRoute.jetpackPowered && !JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures() { + // Display overlay + RootViewCoordinator.sharedPresenter.mySitesCoordinator.displayJetpackOverlayForDisabledEntryPoint() + + // Track incorrect access + let properties = ["calling_function": "deep_link", TracksPropertyKeys.url: url.absoluteString] + WPAnalytics.track(.jetpackFeatureIncorrectlyAccessed, properties: properties) + continue + } + matchedRoute.action.perform(matchedRoute.values, source: presentingViewController, router: self) } } - private func trackDeepLink(matchCount: Int, url: URL) { - let stat: WPAnalyticsStat = (matchCount > 0) ? .deepLinked : .deepLinkFailed - let properties = ["url": url.absoluteString] + private func trackDeepLinks(with matches: [MatchedRoute], for url: URL, source: DeepLinkSource? = nil) { + if matches.isEmpty { + WPAppAnalytics.track(.deepLinkFailed, withProperties: [TracksPropertyKeys.url: url.absoluteString]) + return + } - WPAppAnalytics.track(stat, withProperties: properties) + matches.forEach({ trackDeepLink(for: $0, source: source) }) + } + + private func trackDeepLink(for match: MatchedRoute, source: DeepLinkSource? = nil) { + // Check if the route is overridding tracking + if match.shouldTrack == false { + return + } + + // If we've been passed a source we'll use that to override the route's original source. + // For example, if we've been handed a link from a banner. + let properties: [String: String] = [ + TracksPropertyKeys.url: match.path, + TracksPropertyKeys.source: source?.tracksValue ?? match.source.tracksValue, + TracksPropertyKeys.sourceInfo: source?.trackingInfo ?? match.source.trackingInfo ?? "", + TracksPropertyKeys.section: match.section?.rawValue ?? "", + ] + + if UniversalLinkRouter.extraLoggingEnabled { + logDeepLink(with: properties) + } + + WPAppAnalytics.track(.deepLinked, withProperties: properties) + } + + private func logDeepLink(with properties: [String: String]) { + let path = properties[TracksPropertyKeys.url] ?? "" + let section = properties[TracksPropertyKeys.section] ?? "" + let source = properties[TracksPropertyKeys.source] ?? "" + let sourceInfo = properties[TracksPropertyKeys.sourceInfo] ?? "" + let info = sourceInfo.isEmpty ? "" : " – \(sourceInfo)" + + DDLogInfo("🔗 Deep link: \(path), source: \(source)\(info), section: \(section)") + } + + private enum TracksPropertyKeys { + static let url = "url" + static let source = "source" + static let sourceInfo = "source_info" + static let section = "section" + } +} + +extension DeepLinkSource { + var tracksValue: String { + switch self { + case .link: + return "link" + case .banner: + return "banner" + case .email: + return "email" + case .widget: + return "widget" + case .inApp: + return "internal" + } } } diff --git a/WordPress/Classes/Utility/WPAuthTokenIssueSolver.m b/WordPress/Classes/Utility/WPAuthTokenIssueSolver.m index 0bcb5baba0a5..2752723adb09 100644 --- a/WordPress/Classes/Utility/WPAuthTokenIssueSolver.m +++ b/WordPress/Classes/Utility/WPAuthTokenIssueSolver.m @@ -1,7 +1,7 @@ #import "WPAuthTokenIssueSolver.h" #import "AccountService.h" #import "BlogService.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "WPAccount.h" #import "WordPress-Swift.h" @@ -22,8 +22,7 @@ - (BOOL)fixAuthTokenIssueAndDo:(WPAuthTokenissueSolverCompletionBlock)onComplete // alert instead of itself. dispatch_async(dispatch_get_main_queue(), ^{ [self showCancelReAuthenticationAlertAndOnOK:^{ - NSManagedObjectContext *mainContext = [[ContextManager sharedInstance] mainContext]; - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:mainContext]; + AccountService *accountService = [[AccountService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [accountService removeDefaultWordPressComAccount]; onComplete(); @@ -34,7 +33,7 @@ - (BOOL)fixAuthTokenIssueAndDo:(WPAuthTokenissueSolverCompletionBlock)onComplete } }]; - [UIApplication sharedApplication].keyWindow.rootViewController = controller; + [UIApplication sharedApplication].mainWindow.rootViewController = controller; [self showExplanationAlertForReAuthenticationDueToMissingAuthToken]; isFixingAuthTokenIssue = YES; @@ -47,20 +46,6 @@ - (BOOL)fixAuthTokenIssueAndDo:(WPAuthTokenissueSolverCompletionBlock)onComplete #pragma mark - Misc -/** - * @brief Call this method to know if there are hosted blogs. - * - * @returns YES if there are hosted blogs, NO otherwise. - */ -- (BOOL)noSelfHostedBlogs -{ - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; - - NSInteger blogCount = [blogService blogCountSelfHosted]; - return blogCount == 0; -} - /** * @brief Call this method to know if the local installation of WPiOS has the authToken issue * this class was designed to solve. @@ -70,8 +55,7 @@ - (BOOL)noSelfHostedBlogs - (BOOL)hasAuthTokenIssues { NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context]; - WPAccount *account = [accountService defaultWordPressComAccount]; + WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:context]; BOOL hasAuthTokenIssues = account && ![account authToken]; @@ -104,16 +88,16 @@ - (void)showCancelReAuthenticationAlertAndOnOK:(WPAuthTokenissueSolverCompletion UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:cancelButtonTitle style:UIAlertActionStyleCancel - handler:^(UIAlertAction *action){}]; + handler:^(UIAlertAction *__unused action){}]; UIAlertAction *deleteAction = [UIAlertAction actionWithTitle:deleteButtonTitle style:UIAlertActionStyleDestructive - handler:^(UIAlertAction *action){ + handler:^(UIAlertAction *__unused action){ okBlock(); }]; [alertController addAction:cancelAction]; [alertController addAction:deleteAction]; - [[[UIApplication sharedApplication] keyWindow].rootViewController presentViewController:alertController + [[[UIApplication sharedApplication] mainWindow].rootViewController presentViewController:alertController animated:YES completion:nil]; } @@ -140,7 +124,7 @@ - (void)showExplanationAlertForReAuthenticationDueToMissingAuthToken UIAlertAction *okAction = [UIAlertAction actionWithTitle:okButtonTitle style:UIAlertActionStyleDefault - handler:^(UIAlertAction *action){}]; + handler:^(UIAlertAction *__unused action){}]; [alertController addAction:okAction]; alertController.modalPresentationStyle = UIModalPresentationPopover; diff --git a/WordPress/Classes/Utility/WPAvatarSource.m b/WordPress/Classes/Utility/WPAvatarSource.m index 3533905e8cd8..8feaf3b08443 100644 --- a/WordPress/Classes/Utility/WPAvatarSource.m +++ b/WordPress/Classes/Utility/WPAvatarSource.m @@ -114,7 +114,7 @@ - (void)fetchImageForAvatarHash:(NSString *)hash ofType:(WPAvatarSourceType)type withSuccess:^(UIImage *image) { [self setCachedImage:image forHash:hash type:type size:maxSize]; [self processImage:image forHash:hash type:type size:size success:success]; - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { if (success) { success(nil); } diff --git a/WordPress/Classes/Utility/WPContentSyncHelper.swift b/WordPress/Classes/Utility/WPContentSyncHelper.swift index e8cc95495667..2a2725207de0 100644 --- a/WordPress/Classes/Utility/WPContentSyncHelper.swift +++ b/WordPress/Classes/Utility/WPContentSyncHelper.swift @@ -48,7 +48,7 @@ class WPContentSyncHelper: NSObject { @objc @discardableResult func syncContentWithUserInteraction(_ userInteraction: Bool) -> Bool { - if isSyncing { + guard !isSyncing else { return false } @@ -56,15 +56,11 @@ class WPContentSyncHelper: NSObject { delegate?.syncHelper(self, syncContentWithUserInteraction: userInteraction, success: { [weak self] (hasMore: Bool) -> Void in - if let weakSelf = self { - weakSelf.hasMoreContent = hasMore - weakSelf.syncContentEnded() - } + self?.hasMoreContent = hasMore + self?.syncContentEnded() }, failure: { [weak self] (error: NSError) -> Void in - if let weakSelf = self { - weakSelf.syncContentEnded(error: true) - } + self?.syncContentEnded(error: true) }) return true @@ -72,7 +68,7 @@ class WPContentSyncHelper: NSObject { @objc @discardableResult func syncMoreContent() -> Bool { - if isSyncing { + guard !isSyncing else { return false } @@ -81,23 +77,19 @@ class WPContentSyncHelper: NSObject { delegate?.syncHelper(self, syncMoreWithSuccess: { [weak self] (hasMore: Bool) in - if let weakSelf = self { - weakSelf.hasMoreContent = hasMore - weakSelf.syncContentEnded() - } + self?.hasMoreContent = hasMore + self?.syncContentEnded() }, failure: { [weak self] (error: NSError) in DDLogInfo("Error syncing more: \(error)") - if let weakSelf = self { - weakSelf.syncContentEnded(error: true) - } + self?.syncContentEnded(error: true) }) return true } @objc func backgroundSync(success: (() -> Void)?, failure: ((_ error: NSError?) -> Void)?) { - if isSyncing { + guard !isSyncing else { success?() return } @@ -105,18 +97,14 @@ class WPContentSyncHelper: NSObject { isSyncing = true delegate?.syncHelper(self, syncContentWithUserInteraction: false, success: { - [weak self] (hasMore: Bool) -> Void in - if let weakSelf = self { - weakSelf.hasMoreContent = hasMore - weakSelf.syncContentEnded() - } - success?() - }, failure: { - [weak self] (error: NSError) -> Void in - if let weakSelf = self { - weakSelf.syncContentEnded() - } - failure?(error) + [weak self] (hasMore: Bool) -> Void in + self?.hasMoreContent = hasMore + self?.syncContentEnded() + success?() + }, failure: { + [weak self] (error: NSError) -> Void in + self?.syncContentEnded() + failure?(error) }) } diff --git a/WordPress/Classes/Utility/WPError.m b/WordPress/Classes/Utility/WPError.m index f1aa23eae1d9..df8cc525aad7 100644 --- a/WordPress/Classes/Utility/WPError.m +++ b/WordPress/Classes/Utility/WPError.m @@ -122,7 +122,7 @@ + (void)showAlertWithTitle:(NSString *)title message:(NSString *)message withSup preferredStyle:UIAlertControllerStyleAlert]; UIAlertAction *action = [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", nil) - style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull __unused action) { if (okBlock) { okBlock(alertController); } @@ -135,7 +135,7 @@ + (void)showAlertWithTitle:(NSString *)title message:(NSString *)message withSup NSString *supportText = NSLocalizedString(@"Need Help?", @"'Need help?' button label, links off to the WP for iOS FAQ."); UIAlertAction *action = [UIAlertAction actionWithTitle:supportText style:UIAlertActionStyleCancel - handler:^(UIAlertAction * _Nonnull action) { + handler:^(UIAlertAction * _Nonnull __unused action) { SupportTableViewController *supportVC = [SupportTableViewController new]; [supportVC showFromTabBar]; [WPError internalInstance].alertShowing = NO; diff --git a/WordPress/Classes/Utility/WPImmuTableRows.swift b/WordPress/Classes/Utility/WPImmuTableRows.swift index 008be82a40d8..25bf3ea1ab98 100644 --- a/WordPress/Classes/Utility/WPImmuTableRows.swift +++ b/WordPress/Classes/Utility/WPImmuTableRows.swift @@ -12,23 +12,33 @@ struct NavigationItemRow: ImmuTableRow { let action: ImmuTableAction? let accessoryType: UITableViewCell.AccessoryType let accessibilityIdentifer: String? + let loading: Bool - init(title: String, detail: String? = nil, icon: UIImage? = nil, badgeCount: Int = 0, accessoryType: UITableViewCell.AccessoryType = .disclosureIndicator, action: @escaping ImmuTableAction, accessibilityIdentifier: String? = nil) { + init(title: String, detail: String? = nil, icon: UIImage? = nil, badgeCount: Int = 0, accessoryType: UITableViewCell.AccessoryType = .disclosureIndicator, action: @escaping ImmuTableAction, accessibilityIdentifier: String? = nil, loading: Bool = false) { self.title = title self.detail = detail self.icon = icon self.accessoryType = accessoryType self.action = action self.accessibilityIdentifer = accessibilityIdentifier + self.loading = loading } func configureCell(_ cell: UITableViewCell) { cell.textLabel?.text = title cell.detailTextLabel?.text = detail - cell.accessoryType = accessoryType cell.imageView?.image = icon cell.accessibilityIdentifier = accessibilityIdentifer + if loading { + let indicator: UIActivityIndicatorView + indicator = UIActivityIndicatorView(style: .medium) + indicator.startAnimating() + cell.accessoryView = indicator + } else { + cell.accessoryType = accessoryType + } + WPStyleGuide.configureTableViewCell(cell) } } @@ -68,12 +78,30 @@ struct EditableTextRow: ImmuTableRow { let title: String let value: String + let accessoryImage: UIImage? let action: ImmuTableAction? + let fieldName: String? + + 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) { cell.textLabel?.text = title cell.detailTextLabel?.text = value + cell.accessibilityLabel = title + cell.accessibilityValue = value + if cell.isUserInteractionEnabled { + cell.accessibilityHint = NSLocalizedString("Tap to edit", comment: "Accessibility hint prompting the user to tap a table row to edit its value.") + } cell.accessoryType = .disclosureIndicator + if accessoryImage != nil { + cell.accessoryView = UIImageView(image: accessoryImage) + } WPStyleGuide.configureTableViewCell(cell) } @@ -106,6 +134,9 @@ struct TextRow: ImmuTableRow { func configureCell(_ cell: UITableViewCell) { cell.textLabel?.text = title cell.detailTextLabel?.text = value + cell.accessibilityLabel = title + cell.accessibilityValue = value + cell.selectionStyle = .none WPStyleGuide.configureTableViewCell(cell) @@ -113,20 +144,57 @@ struct TextRow: ImmuTableRow { } struct CheckmarkRow: ImmuTableRow { - static let cell = ImmuTableCell.class(WPTableViewCellDefault.self) + static let cell = ImmuTableCell.class(WPTableViewCellSubtitle.self) let title: String + let subtitle: String? let checked: Bool let action: ImmuTableAction? func configureCell(_ cell: UITableViewCell) { + cell.isAccessibilityElement = true + cell.accessibilityLabel = title + cell.accessibilityHint = subtitle + cell.textLabel?.text = title + cell.detailTextLabel?.text = subtitle cell.selectionStyle = .none cell.accessoryType = (checked) ? .checkmark : .none WPStyleGuide.configureTableViewCell(cell) } + init(title: String, subtitle: String? = nil, checked: Bool, action: ImmuTableAction?) { + self.title = title + self.subtitle = subtitle + self.checked = checked + self.action = action + } + +} + +struct ActivityIndicatorRow: ImmuTableRow { + static let cell = ImmuTableCell.class(WPTableViewCellDefault.self) + + let title: String + let animating: Bool + let action: ImmuTableAction? + + func configureCell(_ cell: UITableViewCell) { + cell.textLabel?.text = title + + let indicator: UIActivityIndicatorView + indicator = UIActivityIndicatorView(style: .medium) + + if animating { + indicator.startAnimating() + } + + cell.accessoryView = indicator + + WPStyleGuide.configureTableViewCell(cell) + } + } struct LinkRow: ImmuTableRow { @@ -137,7 +205,7 @@ struct LinkRow: ImmuTableRow { private static let imageSize = CGSize(width: 20, height: 20) private var accessoryImageView: UIImageView { - let image = Gridicon.iconOfType(.external, withSize: LinkRow.imageSize) + let image = UIImage.gridicon(.external, size: LinkRow.imageSize) let imageView = UIImageView(image: image) imageView.tintColor = WPStyleGuide.cellGridiconAccessoryColor() @@ -168,6 +236,37 @@ struct LinkWithValueRow: ImmuTableRow { } } +/// Create a row that navigates to a new ViewController. +/// Uses the WordPress branded blue and is left aligned. +/// +struct BrandedNavigationRow: ImmuTableRow { + static let cell = ImmuTableCell.class(WPTableViewCellIndicator.self) + + let title: String + let showIndicator: Bool + let action: ImmuTableAction? + let accessibilityIdentifier: String? + + init(title: String, action: @escaping ImmuTableAction, showIndicator: Bool = false, accessibilityIdentifier: String? = nil) { + self.title = title + self.showIndicator = showIndicator + self.action = action + self.accessibilityIdentifier = accessibilityIdentifier + } + + func configureCell(_ cell: UITableViewCell) { + guard let cell = cell as? WPTableViewCellIndicator else { + return + } + cell.textLabel?.text = title + WPStyleGuide.configureTableViewCell(cell) + cell.textLabel?.textColor = .primary + cell.showIndicator = showIndicator + cell.accessibilityTraits = .button + cell.accessibilityIdentifier = accessibilityIdentifier + } +} + struct ButtonRow: ImmuTableRow { static let cell = ImmuTableCell.class(WPTableViewCellDefault.self) @@ -252,14 +351,21 @@ struct SwitchRow: ImmuTableRow { let title: String let value: Bool let icon: UIImage? + let isUserInteractionEnabled: Bool let action: ImmuTableAction? = nil let onChange: (Bool) -> Void let accessibilityIdentifier: String? - init(title: String, value: Bool, icon: UIImage? = nil, onChange: @escaping (Bool) -> Void, accessibilityIdentifier: String? = nil) { + init(title: String, + value: Bool, + icon: UIImage? = nil, + isUserInteractionEnabled: Bool = true, + onChange: @escaping (Bool) -> Void, + accessibilityIdentifier: String? = nil) { self.title = title self.value = value self.icon = icon + self.isUserInteractionEnabled = isUserInteractionEnabled self.onChange = onChange self.accessibilityIdentifier = accessibilityIdentifier } @@ -269,6 +375,7 @@ struct SwitchRow: ImmuTableRow { cell.textLabel?.text = title cell.imageView?.image = icon + cell.isUserInteractionEnabled = isUserInteractionEnabled cell.selectionStyle = .none cell.on = value cell.onChange = onChange diff --git a/WordPress/Classes/Utility/WPMediaEditor.swift b/WordPress/Classes/Utility/WPMediaEditor.swift index ee351f6e0835..fd546e41c566 100644 --- a/WordPress/Classes/Utility/WPMediaEditor.swift +++ b/WordPress/Classes/Utility/WPMediaEditor.swift @@ -16,6 +16,11 @@ class WPMediaEditor: MediaEditor { } } + /// The number of images in the Media Editor + private var numberOfImages: Int { + return max(asyncImages.count, images.count) + } + override var styles: MediaEditorStyles { get { return [ @@ -24,12 +29,12 @@ class WPMediaEditor: MediaEditor { .cancelLabel: NSLocalizedString("Cancel", comment: "Cancel editing an image"), .errorLoadingImageMessage: NSLocalizedString("We couldn't retrieve this media.\nPlease tap to retry.", comment: "Description that appears when a media fails to load in the Media Editor."), .cancelColor: UIColor.white, - .resetIcon: Gridicon.iconOfType(.undo), - .doneIcon: Gridicon.iconOfType(.checkmark), - .cancelIcon: Gridicon.iconOfType(.cross), - .rotateClockwiseIcon: Gridicon.iconOfType(.rotate).withHorizontallyFlippedOrientation(), + .resetIcon: UIImage.gridicon(.undo), + .doneIcon: UIImage.gridicon(.checkmark), + .cancelIcon: UIImage.gridicon(.cross), + .rotateClockwiseIcon: UIImage.gridicon(.rotate).withHorizontallyFlippedOrientation(), .rotateCounterclockwiseButtonHidden: true, - .retryIcon: Gridicon.iconOfType(.refresh, withSize: CGSize(width: 48, height: 48)) + .retryIcon: UIImage.gridicon(.refresh, size: CGSize(width: 48, height: 48)) ] } @@ -41,7 +46,7 @@ class WPMediaEditor: MediaEditor { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - WPAnalytics.track(.mediaEditorShown) + WPAnalytics.track(.mediaEditorShown, properties: ["number_of_images": numberOfImages]) } override func viewDidDisappear(_ animated: Bool) { @@ -54,6 +59,6 @@ class WPMediaEditor: MediaEditor { return } - WPAnalytics.track(.mediaEditorUsed, withProperties: ["actions": actions.description]) + WPAnalytics.track(.mediaEditorUsed, properties: ["actions": actions.description]) } } diff --git a/WordPress/Classes/Utility/WPTableImageSource.m b/WordPress/Classes/Utility/WPTableImageSource.m index e01337ede195..43b7b2258907 100644 --- a/WordPress/Classes/Utility/WPTableImageSource.m +++ b/WordPress/Classes/Utility/WPTableImageSource.m @@ -1,7 +1,8 @@ #import "WPTableImageSource.h" #import "AccountService.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "WPAccount.h" +#import "WordPress-Swift.h" @import WordPressUI; @import WordPressShared; @@ -126,8 +127,7 @@ - (void)fetchImageForURL:(NSURL *)url withSize:(CGSize)size indexPath:(NSIndexPa if (isPrivate) { NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:context]; [[WPImageSource sharedSource] downloadImageForURL:url authToken:[defaultAccount authToken] withSuccess:successBlock diff --git a/WordPress/Classes/Utility/WPTableViewHandler.h b/WordPress/Classes/Utility/WPTableViewHandler.h index 0697235119ee..1b790a83b7a8 100644 --- a/WordPress/Classes/Utility/WPTableViewHandler.h +++ b/WordPress/Classes/Utility/WPTableViewHandler.h @@ -49,7 +49,6 @@ #pragma mark - Editing actions -- (nullable NSArray<UITableViewRowAction *> *)tableView:(nonnull UITableView *)tableView editActionsForRowAtIndexPath:(nonnull NSIndexPath *)indexPath; - (nullable UISwipeActionsConfiguration *)tableView:(nonnull UITableView *)tableView leadingSwipeActionsConfigurationForRowAtIndexPath:(nonnull NSIndexPath *)indexPath; - (nullable UISwipeActionsConfiguration *)tableView:(nonnull UITableView *)tableView trailingSwipeActionsConfigurationForRowAtIndexPath:(nonnull NSIndexPath *)indexPath; diff --git a/WordPress/Classes/Utility/WPTableViewHandler.m b/WordPress/Classes/Utility/WPTableViewHandler.m index 6264ee03c772..458d8c5dd215 100644 --- a/WordPress/Classes/Utility/WPTableViewHandler.m +++ b/WordPress/Classes/Utility/WPTableViewHandler.m @@ -151,6 +151,10 @@ - (void)refreshTableViewPreservingOffset } newIndexPath = [self.resultsController indexPathForObject:obj]; + if (i > visibleCellFrames.count) { + break; + } + CGRect originalCellFrame = [[visibleCellFrames objectAtIndex:i] CGRectValue]; if (newIndexPath) { // Since we still have one of the originally visible objects, @@ -330,15 +334,6 @@ - (UITableViewCellEditingStyle)tableView:(UITableView *)tableView editingStyleFo return UITableViewCellEditingStyleNone; } -- (NSArray *)tableView:(UITableView *)tableView editActionsForRowAtIndexPath:(NSIndexPath *)indexPath -{ - if ([self.delegate respondsToSelector:@selector(tableView:editActionsForRowAtIndexPath:)]) { - return [self.delegate tableView:tableView editActionsForRowAtIndexPath:indexPath]; - } - - return nil; -} - - (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView leadingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath { if ([self.delegate respondsToSelector:@selector(tableView:leadingSwipeActionsConfigurationForRowAtIndexPath:)]) { @@ -632,10 +627,12 @@ - (void)controllerWillChangeContent:(NSFetchedResultsController *)controller - (void)controllerDidChangeContent:(NSFetchedResultsController *)controller { [self.tableView endUpdates]; - - if (self.indexPathSelectedAfterUpdates) { + + // Prevent crashes in iOS 14 if the row is negative + // See http://git.io/JIKIB + if (self.indexPathSelectedAfterUpdates && self.indexPathSelectedAfterUpdates.row > 0) { [self.tableView selectRowAtIndexPath:self.indexPathSelectedAfterUpdates animated:NO scrollPosition:UITableViewScrollPositionNone]; - } else if (self.indexPathSelectedBeforeUpdates) { + } else if (self.indexPathSelectedBeforeUpdates && self.indexPathSelectedBeforeUpdates.row > 0) { [self.tableView selectRowAtIndexPath:self.indexPathSelectedBeforeUpdates animated:NO scrollPosition:UITableViewScrollPositionNone]; } @@ -653,6 +650,18 @@ - (void)controller:(NSFetchedResultsController *)controller forChangeType:(NSFetchedResultsChangeType)type newIndexPath:(NSIndexPath *)newIndexPath { + // Prevent crashes in iOS 14 if the row is negative + // See http://git.io/JIKIB + if (indexPath.row < 0 || newIndexPath.row < 0) { + return; + } + + // Prevents a crash where index path rows could end with an integer overflow error. + // See https://github.com/wordpress-mobile/WordPress-iOS/issues/15366 + if (indexPath.row == NSUIntegerMax || newIndexPath.row == NSUIntegerMax) { + return; + } + if (NSFetchedResultsChangeUpdate == type && newIndexPath && ![newIndexPath isEqual:indexPath]) { // Seriously, Apple? // http://developer.apple.com/library/ios/#releasenotes/iPhone/NSFetchedResultsChangeMoveReportedAsNSFetchedResultsChangeUpdate/_index.html diff --git a/WordPress/Classes/Utility/WPUserAgent.m b/WordPress/Classes/Utility/WPUserAgent.m index 097a198bbcdf..195d97099e99 100644 --- a/WordPress/Classes/Utility/WPUserAgent.m +++ b/WordPress/Classes/Utility/WPUserAgent.m @@ -12,9 +12,9 @@ + (NSString *)defaultUserAgent static NSString * _defaultUserAgent; static dispatch_once_t _onceToken; dispatch_once(&_onceToken, ^{ - NSDictionary * registrationDomain = [[NSUserDefaults standardUserDefaults] volatileDomainForName:NSRegistrationDomain]; + NSDictionary * registrationDomain = [[UserPersistentStoreFactory userDefaultsInstance] volatileDomainForName:NSRegistrationDomain]; NSString *storeCurrentUA = [registrationDomain objectForKey:WPUserAgentKeyUserAgent]; - [[NSUserDefaults standardUserDefaults] registerDefaults:@{WPUserAgentKeyUserAgent: @(0)}]; + [[UserPersistentStoreFactory userDefaultsInstance] registerDefaults:@{WPUserAgentKeyUserAgent: @(0)}]; if ([NSThread isMainThread]){ _defaultUserAgent = [WKWebView userAgent]; @@ -24,7 +24,7 @@ + (NSString *)defaultUserAgent }); } if (storeCurrentUA) { - [[NSUserDefaults standardUserDefaults] registerDefaults:@{WPUserAgentKeyUserAgent: storeCurrentUA}]; + [[UserPersistentStoreFactory userDefaultsInstance] registerDefaults:@{WPUserAgentKeyUserAgent: storeCurrentUA}]; } }); NSAssert(_defaultUserAgent != nil, @"User agent shouldn't be nil"); @@ -48,8 +48,8 @@ + (NSString *)wordPressUserAgent + (void)useWordPressUserAgentInWebViews { // Cleanup unused NSUserDefaults keys from older WPUserAgent implementation - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"DefaultUserAgent"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"AppUserAgent"]; + [[UserPersistentStoreFactory userDefaultsInstance] removeObjectForKey:@"DefaultUserAgent"]; + [[UserPersistentStoreFactory userDefaultsInstance] removeObjectForKey:@"AppUserAgent"]; NSString *userAgent = [self wordPressUserAgent]; @@ -57,7 +57,7 @@ + (void)useWordPressUserAgentInWebViews NSDictionary *dictionary = @{WPUserAgentKeyUserAgent: userAgent}; // We have to call registerDefaults else the change isn't picked up by WKWebViews. - [[NSUserDefaults standardUserDefaults] registerDefaults:dictionary]; + [[UserPersistentStoreFactory userDefaultsInstance] registerDefaults:dictionary]; DDLogVerbose(@"User-Agent set to: %@", userAgent); } diff --git a/WordPress/Classes/Utility/WPWebViewController.h b/WordPress/Classes/Utility/WPWebViewController.h index b8469588c44a..d6b922bc0a44 100644 --- a/WordPress/Classes/Utility/WPWebViewController.h +++ b/WordPress/Classes/Utility/WPWebViewController.h @@ -4,7 +4,7 @@ @class Blog; @class WPAccount; @class WebViewControllerConfiguration; -@class WebViewAuthenticator; +@class RequestAuthenticator; NS_ASSUME_NONNULL_BEGIN @@ -34,12 +34,7 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, assign) BOOL addsWPComReferrer; -/** - * @brief When true adds parameters to hide the site's masterbar. - */ -@property (nonatomic, assign) BOOL addsHideMasterbarParameters; - -@property (nonatomic, strong, nullable) WebViewAuthenticator *authenticator; +@property (nonatomic, strong, nullable) RequestAuthenticator *authenticator; /** * @brief Dismiss modal presentation diff --git a/WordPress/Classes/Utility/WPWebViewController.m b/WordPress/Classes/Utility/WPWebViewController.m index 6f5bfa30a57e..ad12775d30cd 100644 --- a/WordPress/Classes/Utility/WPWebViewController.m +++ b/WordPress/Classes/Utility/WPWebViewController.m @@ -71,7 +71,6 @@ - (instancetype)initWithConfiguration:(WebViewControllerConfiguration *)configur _optionsButton = configuration.optionsButton; _secureInteraction = configuration.secureInteraction; _addsWPComReferrer = configuration.addsWPComReferrer; - _addsHideMasterbarParameters = configuration.addsHideMasterbarParameters; _customTitle = configuration.customTitle; _authenticator = configuration.authenticator; } @@ -95,24 +94,24 @@ - (void)viewDidLoad // Buttons if (!self.optionsButton) { - self.optionsButton = [[UIBarButtonItem alloc] initWithImage:[Gridicon iconOfType:GridiconTypeShareIOS] style:UIBarButtonItemStylePlain target:self action:@selector(showLinkOptions)]; + self.optionsButton = [[UIBarButtonItem alloc] initWithImage:[UIImage gridiconOfType:GridiconTypeShareiOS] style:UIBarButtonItemStylePlain target:self action:@selector(showLinkOptions)]; self.optionsButton.accessibilityLabel = NSLocalizedString(@"Share", @"Spoken accessibility label"); } - self.dismissButton = [[UIBarButtonItem alloc] initWithImage:[Gridicon iconOfType:GridiconTypeCross] style:UIBarButtonItemStylePlain target:self action:@selector(dismiss)]; + self.dismissButton = [[UIBarButtonItem alloc] initWithImage:[UIImage gridiconOfType:GridiconTypeCross] style:UIBarButtonItemStylePlain target:self action:@selector(dismiss)]; self.dismissButton.accessibilityLabel = NSLocalizedString(@"Dismiss", @"Dismiss a view. Verb"); self.backButton.accessibilityLabel = NSLocalizedString(@"Back", @"Previous web page"); self.forwardButton.accessibilityLabel = NSLocalizedString(@"Forward", @"Next web page"); - self.backButton.image = [[Gridicon iconOfType:GridiconTypeChevronLeft] imageFlippedForRightToLeftLayoutDirection]; - self.forwardButton.image = [[Gridicon iconOfType:GridiconTypeChevronRight] imageFlippedForRightToLeftLayoutDirection]; + self.backButton.image = [[UIImage gridiconOfType:GridiconTypeChevronLeft] imageFlippedForRightToLeftLayoutDirection]; + self.forwardButton.image = [[UIImage gridiconOfType:GridiconTypeChevronRight] imageFlippedForRightToLeftLayoutDirection]; // Toolbar: Hidden by default! - self.toolbar.barTintColor = [UIColor whiteColor]; - self.backButton.tintColor = [UIColor murielNeutral20]; - self.forwardButton.tintColor = [UIColor murielNeutral20]; + self.toolbar.barTintColor = [[UIColor alloc] initWithLight:[UIColor whiteColor] dark:[UIColor murielAppBarBackground]]; + self.backButton.tintColor = [UIColor murielListIcon]; + self.forwardButton.tintColor = [UIColor murielListIcon]; self.toolbarBottomConstraint.constant = WPWebViewToolbarHiddenConstant; // Share @@ -122,14 +121,13 @@ - (void)viewDidLoad // Additional Setup [self setupWebView]; - [self setupAuthenticator]; // Fire away! [self applyModalStyleIfNeeded]; [self loadWebViewRequest]; if (UIAccessibilityIsBoldTextEnabled()) { - self.navigationController.navigationBar.tintColor = [UIColor murielNeutral20]; + self.navigationController.navigationBar.tintColor = [UIColor murielListIcon]; } } @@ -147,19 +145,8 @@ - (void)applyModalStyleIfNeeded return; } - UIImage *navBackgroundImage = [UIImage imageWithColor:[WPStyleGuide webViewModalNavigationBarBackground]]; - UIImage *navShadowImage = [UIImage imageWithColor:[WPStyleGuide webViewModalNavigationBarShadow]]; - - UINavigationBar *navigationBar = self.navigationController.navigationBar; - navigationBar.shadowImage = navShadowImage; - navigationBar.barStyle = UIBarStyleDefault; - [navigationBar setBackgroundImage:navBackgroundImage forBarMetrics:UIBarMetricsDefault]; - - self.titleView.titleLabel.textColor = [UIColor whiteColor]; - self.titleView.subtitleLabel.textColor = [UIColor whiteColor]; - - self.dismissButton.tintColor = [UIColor whiteColor]; - self.optionsButton.tintColor = [UIColor whiteColor]; + self.titleView.titleLabel.textColor = [UIColor murielText]; + self.titleView.subtitleLabel.textColor = [UIColor murielNeutral30]; self.navigationItem.leftBarButtonItem = self.dismissButton; } @@ -176,14 +163,6 @@ - (BOOL)expectsWidePanel #pragma mark - Setup -/// I'm not sure why this is necessary, but it seems like the authenticator is failing to redirect us if -/// this isn't set to true. @kokejb suggested this change and it's working for now. -/// -- (void)setupAuthenticator -{ - self.authenticator.safeRedirect = true; -} - - (void)setupTitle { self.titleView = [NavigationTitleView new]; @@ -259,11 +238,6 @@ - (void)loadRequest:(NSURLRequest *)request [mutableRequest setValue:WPComReferrerURL forHTTPHeaderField:@"Referer"]; } - if (self.addsHideMasterbarParameters && - ([mutableRequest.URL.host containsString:WPComDomain] || [mutableRequest.URL.host containsString:AutomatticDomain])) { - mutableRequest.URL = [mutableRequest.URL appendingHideMasterbarParameters]; - } - [mutableRequest setValue:[WPUserAgent wordPressUserAgent] forHTTPHeaderField:@"User-Agent"]; [self.webView loadRequest:mutableRequest]; } @@ -386,7 +360,7 @@ - (IBAction)showLinkOptions [activityItems addObject:[NSURL URLWithString:permaLink]]; UIActivityViewController *activityViewController = [[UIActivityViewController alloc] initWithActivityItems:activityItems applicationActivities:[WPActivityDefaults defaultActivities]]; - activityViewController.completionWithItemsHandler = ^(NSString *activityType, BOOL completed, NSArray *returnedItems, NSError *activityError) { + activityViewController.completionWithItemsHandler = ^(NSString *activityType, BOOL completed, NSArray * __unused returnedItems, NSError * __unused activityError) { if (!completed) { return; } @@ -409,15 +383,6 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati DDLogInfo(@"%@ Should Start Loading [%@]", NSStringFromClass([self class]), request.URL.absoluteString); - NSURLRequest *redirectRequest = [self.authenticator interceptRedirectWithRequest:request]; - if (redirectRequest != NULL) { - DDLogInfo(@"Found redirect to %@", redirectRequest); - [self.webView loadRequest:redirectRequest]; - - decisionHandler(WKNavigationActionPolicyCancel); - return; - } - // To handle WhatsApp and Telegraph shares // Even though the documentation says that canOpenURL will only return YES for // URLs configured on the plist under LSApplicationQueriesSchemes if we don't filter diff --git a/WordPress/Classes/Utility/WPWebViewController.xib b/WordPress/Classes/Utility/WPWebViewController.xib index 201c4ba12345..768d3c7b879e 100644 --- a/WordPress/Classes/Utility/WPWebViewController.xib +++ b/WordPress/Classes/Utility/WPWebViewController.xib @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15705" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" colorMatched="YES"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" colorMatched="YES"> <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15706"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> @@ -24,7 +24,7 @@ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> <wkWebView contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IBv-yN-stu"> - <rect key="frame" x="20" y="20" width="280" height="416"/> + <rect key="frame" x="0.0" y="44" width="320" height="392"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <wkWebViewConfiguration key="configuration" allowsInlineMediaPlayback="YES"> <audiovisualMediaTypes key="mediaTypesRequiringUserActionForPlayback" audio="YES" video="YES"/> @@ -57,15 +57,15 @@ </subviews> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <constraints> - <constraint firstItem="IBv-yN-stu" firstAttribute="leading" secondItem="1" secondAttribute="leading" constant="20" symbolic="YES" id="Cte-l4-V7j"/> + <constraint firstItem="IBv-yN-stu" firstAttribute="leading" secondItem="1" secondAttribute="leading" id="Cte-l4-V7j"/> <constraint firstAttribute="trailing" secondItem="KoJ-2F-0M7" secondAttribute="trailing" id="ISH-w3-feS"/> <constraint firstAttribute="bottom" secondItem="AXg-oh-s2G" secondAttribute="bottom" id="Nlf-af-GR4"/> - <constraint firstItem="IBv-yN-stu" firstAttribute="top" secondItem="1" secondAttribute="top" constant="20" symbolic="YES" id="PbC-8b-MZC"/> + <constraint firstItem="IBv-yN-stu" firstAttribute="top" secondItem="1" secondAttribute="topMargin" id="PbC-8b-MZC"/> <constraint firstItem="KoJ-2F-0M7" firstAttribute="top" secondItem="1" secondAttribute="top" id="R7H-hb-JSn"/> <constraint firstItem="KoJ-2F-0M7" firstAttribute="leading" secondItem="1" secondAttribute="leading" id="XPs-kd-Qp3"/> <constraint firstItem="AXg-oh-s2G" firstAttribute="top" secondItem="IBv-yN-stu" secondAttribute="bottom" symbolic="YES" id="Y8P-zj-I7e"/> <constraint firstAttribute="trailing" secondItem="AXg-oh-s2G" secondAttribute="trailing" id="c0p-pM-tqz"/> - <constraint firstAttribute="trailing" secondItem="IBv-yN-stu" secondAttribute="trailing" constant="20" symbolic="YES" id="f9S-JN-LYn"/> + <constraint firstAttribute="trailing" secondItem="IBv-yN-stu" secondAttribute="trailing" id="f9S-JN-LYn"/> <constraint firstItem="AXg-oh-s2G" firstAttribute="leading" secondItem="1" secondAttribute="leading" id="hjY-y9-jer"/> </constraints> <simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/> diff --git a/WordPress/Classes/Utility/WebKitViewController.swift b/WordPress/Classes/Utility/WebKitViewController.swift index 7ea0769c40ff..78d0649b598e 100644 --- a/WordPress/Classes/Utility/WebKitViewController.swift +++ b/WordPress/Classes/Utility/WebKitViewController.swift @@ -2,14 +2,40 @@ import Foundation import Gridicons import UIKit import WebKit +import WordPressShared -class WebKitViewController: UIViewController { +protocol WebKitAuthenticatable { + var authenticator: RequestAuthenticator? { get } + func authenticatedRequest(for url: URL, on webView: WKWebView, completion: @escaping (URLRequest) -> Void) +} + +extension WebKitAuthenticatable { + func authenticatedRequest(for url: URL, on webView: WKWebView, completion: @escaping (URLRequest) -> Void) { + let cookieJar = webView.configuration.websiteDataStore.httpCookieStore + authenticatedRequest(for: url, with: cookieJar, completion: completion) + } + + func authenticatedRequest(for url: URL, with cookieJar: CookieJar, completion: @escaping (URLRequest) -> Void) { + guard let authenticator = authenticator else { + return completion(URLRequest(url: url)) + } + + DispatchQueue.main.async { + authenticator.request(url: url, cookieJar: cookieJar) { (request) in + completion(request) + } + } + } +} + +class WebKitViewController: UIViewController, WebKitAuthenticatable { @objc let webView: WKWebView @objc let progressView = WebProgressView() @objc let titleView = NavigationTitleView() + let analyticsSource: String? @objc lazy var backButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: Gridicon.iconOfType(.chevronLeft).imageFlippedForRightToLeftLayoutDirection(), + let button = UIBarButtonItem(image: UIImage.gridicon(.chevronLeft).imageFlippedForRightToLeftLayoutDirection(), style: .plain, target: self, action: #selector(goBack)) @@ -17,7 +43,7 @@ class WebKitViewController: UIViewController { return button }() @objc lazy var forwardButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: Gridicon.iconOfType(.chevronRight), + let button = UIBarButtonItem(image: .gridicon(.chevronRight), style: .plain, target: self, action: #selector(goForward)) @@ -25,7 +51,7 @@ class WebKitViewController: UIViewController { return button }() @objc lazy var shareButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: Gridicon.iconOfType(.shareIOS), + let button = UIBarButtonItem(image: .gridicon(.shareiOS), style: .plain, target: self, action: #selector(share)) @@ -33,7 +59,7 @@ class WebKitViewController: UIViewController { return button }() @objc lazy var safariButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: Gridicon.iconOfType(.globe), + let button = UIBarButtonItem(image: .gridicon(.globe), style: .plain, target: self, action: #selector(openInSafari)) @@ -42,24 +68,23 @@ class WebKitViewController: UIViewController { return button }() @objc lazy var refreshButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: Gridicon.iconOfType(.refresh), style: .plain, target: self, action: #selector(WebKitViewController.refresh)) + let button = UIBarButtonItem(image: .gridicon(.refresh), style: .plain, target: self, action: #selector(WebKitViewController.refresh)) button.title = NSLocalizedString("Refresh", comment: "Button label to refres a web page") return button }() @objc lazy var closeButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: Gridicon.iconOfType(.cross), style: .plain, target: self, action: #selector(WebKitViewController.close)) - button.title = NSLocalizedString("Dismiss", comment: "Dismiss a view. Verb") + let button = UIBarButtonItem(image: .gridicon(.cross), style: .plain, target: self, action: #selector(WebKitViewController.close)) + button.title = NSLocalizedString("webKit.button.dismiss", value: "Dismiss", comment: "Verb. Dismiss the web view screen.") return button }() @objc var customOptionsButton: UIBarButtonItem? @objc let url: URL? - @objc let authenticator: WebViewAuthenticator? + @objc let authenticator: RequestAuthenticator? @objc let navigationDelegate: WebNavigationDelegate? @objc var secureInteraction = false @objc var addsWPComReferrer = false - @objc var addsHideMasterbarParameters = true @objc var customTitle: String? private let opensNewInSafari: Bool let linkBehavior: LinkBehavior @@ -67,40 +92,60 @@ class WebKitViewController: UIViewController { private var reachabilityObserver: Any? private var tapLocation = CGPoint(x: 0.0, y: 0.0) private var widthConstraint: NSLayoutConstraint? + private var stackViewBottomAnchor: NSLayoutConstraint? + private var onClose: (() -> Void)? + + private var barButtonTintColor: UIColor { + .listIcon + } + + private var navBarTitleColor: UIColor { + .text + } + private struct WebViewErrors { static let frameLoadInterrupted = 102 } + /// Precautionary variable that's in place to make sure the web view doesn't run into an endless loop of reloads if it encounters an error. + private var hasAttemptedAuthRecovery = false + @objc init(configuration: WebViewControllerConfiguration) { - webView = WKWebView() + let config = WKWebViewConfiguration() + // The default on iPad is true. We want the iPhone to be true as well. + config.allowsInlineMediaPlayback = true + + webView = WKWebView(frame: .zero, configuration: config) url = configuration.url customOptionsButton = configuration.optionsButton secureInteraction = configuration.secureInteraction addsWPComReferrer = configuration.addsWPComReferrer - addsHideMasterbarParameters = configuration.addsHideMasterbarParameters customTitle = configuration.customTitle authenticator = configuration.authenticator navigationDelegate = configuration.navigationDelegate linkBehavior = configuration.linkBehavior opensNewInSafari = configuration.opensNewInSafari + onClose = configuration.onClose + analyticsSource = configuration.analyticsSource + super.init(nibName: nil, bundle: nil) hidesBottomBarWhenPushed = true startObservingWebView() } - fileprivate init(url: URL, parent: WebKitViewController) { - webView = WKWebView(frame: .zero, configuration: parent.webView.configuration) + fileprivate init(url: URL, parent: WebKitViewController, configuration: WKWebViewConfiguration, source: String? = nil) { + webView = WKWebView(frame: .zero, configuration: configuration) self.url = url customOptionsButton = parent.customOptionsButton secureInteraction = parent.secureInteraction addsWPComReferrer = parent.addsWPComReferrer - addsHideMasterbarParameters = parent.addsHideMasterbarParameters customTitle = parent.customTitle authenticator = parent.authenticator navigationDelegate = parent.navigationDelegate linkBehavior = parent.linkBehavior opensNewInSafari = parent.opensNewInSafari + analyticsSource = source super.init(nibName: nil, bundle: nil) hidesBottomBarWhenPushed = true startObservingWebView() @@ -147,7 +192,16 @@ class WebKitViewController: UIViewController { NSLayoutConstraint.activate(edgeConstraints) - view.pinSubviewAtCenter(stackView) + // we are pinning the top and bottom of the stack view to the safe area to prevent unintentionally hidden content/overlaps (ie cookie acceptance popup) then center the horizontal constraints vertically + let safeArea = self.view.safeAreaLayoutGuide + + stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true + stackView.topAnchor.constraint(equalTo: safeArea.topAnchor).isActive = true + + // this constraint saved as a varible so it can be deactivated when the toolbar is hidden, to prevent unintended pinning to the safe area + let stackViewBottom = stackView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor) + stackViewBottomAnchor = stackViewBottom + NSLayoutConstraint.activate([stackViewBottom]) let stackWidthConstraint = stackView.widthAnchor.constraint(equalToConstant: 0) stackWidthConstraint.priority = UILayoutPriority.defaultLow @@ -162,36 +216,28 @@ class WebKitViewController: UIViewController { webView.uiDelegate = self loadWebViewRequest() + + track(.webKitViewDisplayed) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) stopWaitingForConnectionRestored() ReachabilityUtils.dismissNoInternetConnectionNotice() + + track(.webKitViewDismissed) } @objc func loadWebViewRequest() { if ReachabilityUtils.alertIsShowing() { - self.dismiss(animated: false) + dismiss(animated: false) } - - guard let authenticator = authenticator else { - if let url = url { - load(request: URLRequest(url: url)) - } + guard let url = url else { return } - DispatchQueue.main.async { [weak self] in - guard let strongSelf = self else { - return - } - - if let url = strongSelf.url { - authenticator.request(url: url, cookieJar: strongSelf.webView.configuration.websiteDataStore.httpCookieStore) { [weak self] (request) in - self?.load(request: request) - } - } + authenticatedRequest(for: url, on: webView) { [weak self] (request) in + self?.load(request: request) } } @@ -201,12 +247,6 @@ class WebKitViewController: UIViewController { request.setValue(WPComReferrerURL, forHTTPHeaderField: "Referer") } - if addsHideMasterbarParameters, - let host = request.url?.host, - (host.contains(WPComDomain) || host.contains(AutomatticDomain)) { - request.url = request.url?.appendingHideMasterbarParameters() - } - webView.load(request) } @@ -225,7 +265,6 @@ class WebKitViewController: UIViewController { setupCloseButton() styleNavBar() - styleNavBarButtons() } private func setupRefreshButton() { @@ -242,11 +281,8 @@ class WebKitViewController: UIViewController { private func setupNavBarTitleView() { titleView.titleLabel.text = NSLocalizedString("Loading...", comment: "Loading. Verb") - if #available(iOS 13.0, *), navigationController is LightNavigationController == false { - titleView.titleLabel.textColor = UIColor(light: .white, dark: .neutral(.shade70)) - } else { - titleView.titleLabel.textColor = .neutral(.shade70) - } + + titleView.titleLabel.textColor = navBarTitleColor titleView.subtitleLabel.textColor = .neutral(.shade30) if let title = customTitle { @@ -261,24 +297,24 @@ class WebKitViewController: UIViewController { return } navigationBar.barStyle = .default - navigationBar.titleTextAttributes = [.foregroundColor: UIColor.neutral(.shade70)] + + // Remove serif title bar formatting + navigationBar.standardAppearance.titleTextAttributes = [:] + navigationBar.shadowImage = UIImage(color: WPStyleGuide.webViewModalNavigationBarShadow()) navigationBar.setBackgroundImage(UIImage(color: WPStyleGuide.webViewModalNavigationBarBackground()), for: .default) fixBarButtonsColorForBoldText(on: navigationBar) } - private func styleNavBarButtons() { - navigationItem.leftBarButtonItems?.forEach(styleBarButton) - navigationItem.rightBarButtonItems?.forEach(styleBarButton) - } - // MARK: ToolBar setup @objc func configureToolbar() { navigationController?.isToolbarHidden = secureInteraction guard !secureInteraction else { + // if not a secure interaction/view, no toolbar is displayed, so deactivate constraint pinning stack view to safe area + stackViewBottomAnchor?.isActive = false return } @@ -307,7 +343,17 @@ class WebKitViewController: UIViewController { guard let toolBar = navigationController?.toolbar else { return } - toolBar.barTintColor = UIColor(light: .white, dark: .appBar) + + let appearance = UIToolbarAppearance() + appearance.configureWithDefaultBackground() + appearance.backgroundColor = UIColor(light: .white, dark: .appBarBackground) + + toolBar.standardAppearance = appearance + + if #available(iOS 15.0, *) { + toolBar.scrollEdgeAppearance = appearance + } + fixBarButtonsColorForBoldText(on: toolBar) } @@ -317,9 +363,19 @@ class WebKitViewController: UIViewController { /// Sets the width of the web preview /// - Parameter width: The width value to set the webView to - func setWidth(_ width: CGFloat?) { + /// - Parameter viewWidth: The view width the webView must fit within, used to manage view transitions, e.g. orientation change + func setWidth(_ width: CGFloat?, viewWidth: CGFloat? = nil) { if let width = width { - widthConstraint?.constant = width + let horizontalViewBound: CGFloat + if let viewWidth = viewWidth { + horizontalViewBound = viewWidth + } else if let superViewWidth = view.superview?.frame.width { + horizontalViewBound = superViewWidth + } else { + horizontalViewBound = width + } + + widthConstraint?.constant = min(width, horizontalViewBound) widthConstraint?.priority = UILayoutPriority.defaultHigh } else { widthConstraint?.priority = UILayoutPriority.defaultLow @@ -335,11 +391,7 @@ class WebKitViewController: UIViewController { } private func styleBarButton(_ button: UIBarButtonItem) { - if #available(iOS 13.0, *), navigationController is LightNavigationController == false { - button.tintColor = UIColor(light: .white, dark: .neutral(.shade70)) - } else { - button.tintColor = .listIcon - } + button.tintColor = barButtonTintColor } private func styleToolBarButton(_ button: UIBarButtonItem) { @@ -370,9 +422,8 @@ class WebKitViewController: UIViewController { } // MARK: User Actions - @objc func close() { - dismiss(animated: true) + dismiss(animated: true, completion: onClose) } @objc func share() { @@ -390,19 +441,22 @@ class WebKitViewController: UIViewController { } } present(activityViewController, animated: true) - + track(.webKitViewShareTapped) } @objc func refresh() { webView.reload() + track(.webKitViewReloadTapped) } @objc func goBack() { webView.goBack() + track(.webKitViewNavigatedBack) } @objc func goForward() { webView.goForward() + track(.webKitViewNavigatedForward) } @objc func openInSafari() { @@ -410,6 +464,7 @@ class WebKitViewController: UIViewController { return } UIApplication.shared.open(url) + track(.webKitViewOpenInSafariTapped) } ///location is used to present a document menu in tap location on iOS 13 @@ -455,15 +510,18 @@ class WebKitViewController: UIViewController { navigationItem.titleView?.accessibilityValue = titleView.titleLabel.text navigationItem.titleView?.accessibilityTraits = .updatesFrequently } + + private func track(_ event: WPAnalyticsEvent) { + let properties: [AnyHashable: Any] = [ + "source": analyticsSource ?? "unknown" + ] + + WPAnalytics.track(event, properties: properties) + } } extension WebKitViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - if let request = authenticator?.interceptRedirect(request: navigationAction.request) { - decisionHandler(.cancel) - load(request: request) - return - } if let delegate = navigationDelegate { let policy = delegate.shouldNavigate(request: navigationAction.request) @@ -480,10 +538,59 @@ extension WebKitViewController: WKNavigationDelegate { return } + // Check for link protocols such as `tel:` and set the correct behavior + if let url = navigationAction.request.url, let scheme = url.scheme { + let linkProtocols = ["tel", "sms", "mailto"] + if linkProtocols.contains(scheme) && UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + decisionHandler(.cancel) + return + } + } + + /// Force cross-site navigations to be opened in the web view when the counterpart app is installed. + /// + /// The default system behavior (through `decisionHandler`) for cross-site navigation is to open the + /// destination URL in Safari. When both WordPress & Jetpack are installed, this caused the counterpart + /// app to catch the navigation intent and process the URL in the app instead. + /// + /// We can remove this workaround when the universal link routes are removed from WordPress.com. + if MigrationAppDetection.isCounterpartAppInstalled, + let originHost = webView.url?.host?.lowercased(), + let destinationHost = navigationAction.request.url?.host?.lowercased(), + navigationAction.navigationType == .linkActivated, + destinationHost.hasSuffix("wordpress.com"), + originHost != destinationHost { + load(request: navigationAction.request) + decisionHandler(.cancel) + return + } + let policy = linkBehavior.handle(navigationAction: navigationAction, for: webView) decisionHandler(policy) } + + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + guard navigationResponse.isForMainFrame, let authenticator = authenticator, !hasAttemptedAuthRecovery else { + decisionHandler(.allow) + return + } + + let cookieStore = webView.configuration.websiteDataStore.httpCookieStore + authenticator.decideActionFor(response: navigationResponse.response, cookieJar: cookieStore) { [unowned self] action in + switch action { + case .reload: + decisionHandler(.cancel) + + /// We've cleared the stored cookies so let's try again. + self.hasAttemptedAuthRecovery = true + self.loadWebViewRequest() + case .allow: + decisionHandler(.allow) + } + } + } } extension WebKitViewController: WKUIDelegate { @@ -494,9 +601,10 @@ extension WebKitViewController: WKUIDelegate { if opensNewInSafari { UIApplication.shared.open(url, options: [:], completionHandler: nil) } else { - let controller = WebKitViewController(url: url, parent: self) + let controller = WebKitViewController(url: url, parent: self, configuration: configuration, source: analyticsSource) let navController = UINavigationController(rootViewController: controller) present(navController, animated: true) + return controller.webView } } return nil @@ -519,7 +627,7 @@ extension WebKitViewController: WKUIDelegate { ReachabilityUtils.showNoInternetConnectionNotice() reloadWhenConnectionRestored() } else { - WPError.showAlert(withTitle: NSLocalizedString("Error", comment: "Generic error alert title"), message: error.localizedDescription) + DDLogError("WebView \(webView) didFailProvisionalNavigation: \(error.localizedDescription)") } } } diff --git a/WordPress/Classes/Utility/WebViewAuthenticator.swift b/WordPress/Classes/Utility/WebViewAuthenticator.swift deleted file mode 100644 index 76b842d625f4..000000000000 --- a/WordPress/Classes/Utility/WebViewAuthenticator.swift +++ /dev/null @@ -1,230 +0,0 @@ -import Foundation - -/// Encapsulates all the authentication logic for web views. -/// -/// This objects is in charge of deciding when a web view should be authenticated, -/// and rewriting requests to do so. -/// -/// Our current authentication system is based on posting to wp-login.php and -/// taking advantage of the `redirect_to` parameter. In the specific case of -/// WordPress.com, we sometimes want to pre-authenticate the user when visiting -/// URLs that we're not sure if they are hosted there (e.g. mapped domains). -/// -/// Since WordPress.com doesn't allow redirects to external URLs, we use a -/// special WordPress.com URL that includes the target URL, and extract that on -/// `interceptRedirect(request:)`. You should call that from your web view's -/// delegate method, when deciding if a request or redirect should continue. -/// -class WebViewAuthenticator: NSObject { - enum Credentials { - case dotCom(username: String, authToken: String) - case siteLogin(loginURL: URL, username: String, password: String) - } - - fileprivate let credentials: Credentials - - /// If true, the authenticator will assume that redirect URLs are allowed and - /// won't use the special WordPress.com redirect URL - /// - @objc - var safeRedirect = false - - init(credentials: Credentials) { - self.credentials = credentials - } - - @objc convenience init?(account: WPAccount) { - guard let username = account.username, - let token = account.authToken else { - return nil - } - self.init(credentials: .dotCom(username: username, authToken: token)) - } - - @objc convenience init?(blog: Blog) { - if let account = blog.account { - self.init(account: account) - } else if let username = blog.usernameForSite, - let password = blog.password, - let loginURL = URL(string: blog.loginUrl()) { - self.init(credentials: .siteLogin(loginURL: loginURL, username: username, password: password)) - } else { - DDLogError("Can't authenticate blog \(String(describing: blog.displayURL)) yet") - return nil - } - } - - /// Potentially rewrites a request for authentication. - /// - /// This method will call the completion block with the request to be used. - /// - /// - Warning: On WordPress.com, this uses a special redirect system. It - /// requires the web view to call `interceptRedirect(request:)` before - /// loading any request. - /// - /// - Parameters: - /// - url: the URL to be loaded. - /// - cookieJar: a CookieJar object where the authenticator will look - /// for existing cookies. - /// - completion: this will be called with either the request for - /// authentication, or a request for the original URL. - /// - @objc func request(url: URL, cookieJar: CookieJar, completion: @escaping (URLRequest) -> Void) { - cookieJar.hasCookie(url: loginURL, username: username) { [weak self] (hasCookie) in - guard let authenticator = self else { - return - } - - let request = authenticator.request(url: url, authenticated: !hasCookie) - completion(request) - } - } - - /// Intercepts and rewrites any potential redirect after login. - /// - /// This should be called whenever a web view needs to decide if it should - /// load a request. If this returns a non-nil value, the resulting request - /// should be loaded instead. - /// - /// - Parameters: - /// - request: the request that was going to be loaded by the web view. - /// - /// - Returns: a request to be loaded instead. If `nil`, the original - /// request should continue loading. - /// - @objc func interceptRedirect(request: URLRequest) -> URLRequest? { - guard let url = request.url, - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - components.scheme == "https", - components.host == "wordpress.com", - let encodedRedirect = components - .queryItems? - .first(where: { $0.name == WebViewAuthenticator.redirectParameter })? - .value, - let redirect = encodedRedirect.removingPercentEncoding, - let redirectUrl = URL(string: redirect) else { - return nil - } - - return URLRequest(url: redirectUrl) - } - - /// Rewrites a request for authentication. - /// - /// This method will always return an authenticated request. If you want to - /// authenticate only if needed, by inspecting the existing cookies, use - /// request(url:cookieJar:completion:) instead - /// - func authenticatedRequest(url: URL) -> URLRequest { - var request = URLRequest(url: loginURL) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpBody = body(url: url) - if let authToken = authToken { - request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - } - return request - } -} - -private extension WebViewAuthenticator { - func request(url: URL, authenticated: Bool) -> URLRequest { - if authenticated { - return authenticatedRequest(url: url) - } else { - return unauthenticatedRequest(url: url) - } - } - - func unauthenticatedRequest(url: URL) -> URLRequest { - return URLRequest(url: url) - } - - func body(url: URL) -> Data? { - guard let redirectedUrl = redirectUrl(url: url.absoluteString) else { - return nil - } - var parameters = [URLQueryItem]() - parameters.append(URLQueryItem(name: "log", value: username)) - if let password = password { - parameters.append(URLQueryItem(name: "pwd", value: password)) - } - parameters.append(URLQueryItem(name: "rememberme", value: "true")) - parameters.append(URLQueryItem(name: "redirect_to", value: redirectedUrl)) - var components = URLComponents() - components.queryItems = parameters - - return components.percentEncodedQuery?.data(using: .utf8) - } - - func redirectUrl(url: String) -> String? { - guard case .dotCom = credentials, - let escapedUrl = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - !safeRedirect else { - return url - } - - return self.url(string: "https://wordpress.com/", parameters: [WebViewAuthenticator.redirectParameter: escapedUrl])?.absoluteString - } - - func url(string: String, parameters: [String: String]) -> URL? { - guard var components = URLComponents(string: string) else { - return nil - } - components.queryItems = parameters.map({ (key, value) in - return URLQueryItem(name: key, value: value) - }) - return components.url - } - - var username: String { - switch credentials { - case .dotCom(let username, _): - return username - case .siteLogin(_, let username, _): - return username - } - } - - var password: String? { - switch credentials { - case .dotCom: - return nil - case .siteLogin(_, _, let password): - return password - } - } - - var authToken: String? { - if case let .dotCom(_, authToken) = credentials { - return authToken - } - switch credentials { - case .dotCom(_, let authToken): - return authToken - case .siteLogin: - return nil - } - } - - var loginURL: URL { - switch credentials { - case .dotCom: - return WebViewAuthenticator.wordPressComLoginUrl - case .siteLogin(let url, _, _): - return url - } - } - - static let wordPressComLoginUrl = URL(string: "https://wordpress.com/wp-login.php")! - static let redirectParameter = "wpios_redirect" -} - -extension WebViewAuthenticator { - func isLogin(url: URL) -> Bool { - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) - components?.queryItems = nil - - return components?.url == WebViewAuthenticator.wordPressComLoginUrl - } -} diff --git a/WordPress/Classes/Utility/WebViewControllerConfiguration.swift b/WordPress/Classes/Utility/WebViewControllerConfiguration.swift index adce09d287e3..e330ffe7c2bc 100644 --- a/WordPress/Classes/Utility/WebViewControllerConfiguration.swift +++ b/WordPress/Classes/Utility/WebViewControllerConfiguration.swift @@ -6,7 +6,7 @@ class WebViewControllerConfiguration: NSObject { @objc var optionsButton: UIBarButtonItem? @objc var secureInteraction = false @objc var addsWPComReferrer = false - @objc var addsHideMasterbarParameters = true + @objc var analyticsSource: String? /// Opens any new pages in Safari. Otherwise, a new web view will be opened var opensNewInSafari = false @@ -14,8 +14,9 @@ class WebViewControllerConfiguration: NSObject { /// The behavior to use for allowing links to be loaded by the web view based var linkBehavior = LinkBehavior.all @objc var customTitle: String? - @objc var authenticator: WebViewAuthenticator? + @objc var authenticator: RequestAuthenticator? @objc weak var navigationDelegate: WebNavigationDelegate? + var onClose: (() -> Void)? @objc init(url: URL?) { self.url = url @@ -23,17 +24,15 @@ class WebViewControllerConfiguration: NSObject { } @objc func authenticate(blog: Blog) { - self.authenticator = WebViewAuthenticator(blog: blog) + self.authenticator = RequestAuthenticator(blog: blog) } @objc func authenticate(account: WPAccount) { - self.authenticator = WebViewAuthenticator(account: account) + self.authenticator = RequestAuthenticator(account: account) } @objc func authenticateWithDefaultAccount() { - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - guard let account = service.defaultWordPressComAccount() else { + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) else { return } authenticate(account: account) diff --git a/WordPress/Classes/Utility/WebViewControllerFactory.swift b/WordPress/Classes/Utility/WebViewControllerFactory.swift index 79715b1580ee..cce60657d323 100644 --- a/WordPress/Classes/Utility/WebViewControllerFactory.swift +++ b/WordPress/Classes/Utility/WebViewControllerFactory.swift @@ -1,42 +1,55 @@ import UIKit +import WebKit class WebViewControllerFactory: NSObject { @available(*, unavailable) override init() { } - @objc static func controller(configuration: WebViewControllerConfiguration) -> UIViewController { + @objc static func controller(configuration: WebViewControllerConfiguration, source: String) -> WebKitViewController { + configuration.analyticsSource = source + let controller = WebKitViewController(configuration: configuration) return controller } - @objc static func controller(url: URL) -> UIViewController { + @objc static func controller(url: URL, source: String) -> UIViewController { let configuration = WebViewControllerConfiguration(url: url) - return controller(configuration: configuration) + return controller(configuration: configuration, source: source) } - @objc static func controller(url: URL, title: String) -> UIViewController { + @objc static func controller(url: URL, title: String, source: String) -> UIViewController { let configuration = WebViewControllerConfiguration(url: url) configuration.customTitle = title - return controller(configuration: configuration) + return controller(configuration: configuration, source: source) } - @objc static func controller(url: URL, blog: Blog) -> UIViewController { + @objc static func controller(url: URL, blog: Blog, source: String, withDeviceModes: Bool = false, + onClose: (() -> Void)? = nil) -> UIViewController { let configuration = WebViewControllerConfiguration(url: url) + configuration.analyticsSource = source configuration.authenticate(blog: blog) - return controller(configuration: configuration) + configuration.onClose = onClose + return withDeviceModes ? PreviewWebKitViewController(configuration: configuration) : controller(configuration: configuration, source: source) } - @objc static func controller(url: URL, account: WPAccount) -> UIViewController { + @objc static func controller(url: URL, account: WPAccount, source: String) -> UIViewController { let configuration = WebViewControllerConfiguration(url: url) configuration.authenticate(account: account) - return controller(configuration: configuration) + return controller(configuration: configuration, source: source) } - @objc static func controllerAuthenticatedWithDefaultAccount(url: URL) -> UIViewController { + @objc static func controllerAuthenticatedWithDefaultAccount(url: URL, source: String) -> UIViewController { let configuration = WebViewControllerConfiguration(url: url) configuration.authenticateWithDefaultAccount() - return controller(configuration: configuration) + return controller(configuration: configuration, source: source) } + static func controllerWithDefaultAccountAndSecureInteraction(url: URL, source: String) -> WebKitViewController { + let configuration = WebViewControllerConfiguration(url: url) + configuration.authenticateWithDefaultAccount() + configuration.secureInteraction = true + + return controller(configuration: configuration, source: source) + } } diff --git a/WordPress/Classes/Utility/ZendeskUtils.swift b/WordPress/Classes/Utility/ZendeskUtils.swift index 625b63f1defa..6ca9727142a6 100644 --- a/WordPress/Classes/Utility/ZendeskUtils.swift +++ b/WordPress/Classes/Utility/ZendeskUtils.swift @@ -1,9 +1,11 @@ import Foundation import CoreTelephony import WordPressAuthenticator +import WordPressKit import SupportSDK import ZendeskCoreSDK +import AutomatticTracks extension NSNotification.Name { static let ZendeskPushNotificationReceivedNotification = NSNotification.Name(rawValue: "ZendeskPushNotificationReceivedNotification") @@ -22,7 +24,7 @@ extension NSNotification.Name { // MARK: - Public Properties - static var sharedInstance: ZendeskUtils = ZendeskUtils() + static var sharedInstance: ZendeskUtils = ZendeskUtils(contextManager: ContextManager.shared) static var zendeskEnabled = false @objc static var unreadNotificationsCount = 0 @@ -37,11 +39,12 @@ extension NSNotification.Name { // MARK: - Private Properties - private override init() {} private var sourceTag: WordPressSupportSourceTag? private var userName: String? private var userEmail: String? + private var userNameConfirmed = false + private var deviceID: String? private var haveUserIdentity = false private var alertNameField: UITextField? @@ -60,10 +63,16 @@ extension NSNotification.Name { return Locale.preferredLanguages[0] } + private let contextManager: CoreDataStack + // MARK: - Public Methods + init(contextManager: CoreDataStack) { + self.contextManager = contextManager + } + @objc static func setup() { - guard getZendeskCredentials() == true else { + guard getZendeskCredentials() else { return } @@ -78,7 +87,7 @@ extension NSNotification.Name { Zendesk.initialize(appId: appId, clientId: clientId, zendeskUrl: url) Support.initialize(withZendesk: Zendesk.instance) - ZendeskUtils.sharedInstance.haveUserIdentity = getUserProfile() + ZendeskUtils.fetchUserInformation() toggleZendesk(enabled: true) // User has accessed a single ticket view, typically via the Zendesk Push Notification alert. @@ -86,7 +95,7 @@ extension NSNotification.Name { NotificationCenter.default.addObserver(self, selector: #selector(ticketViewed(_:)), name: NSNotification.Name(rawValue: ZDKAPI_CommentListStarting), object: nil) // Get unread notification count from User Defaults. - unreadNotificationsCount = UserDefaults.standard.integer(forKey: Constants.userDefaultsZendeskUnreadNotifications) + unreadNotificationsCount = UserPersistentStoreFactory.instance().integer(forKey: Constants.userDefaultsZendeskUnreadNotifications) //If there are any, post NSNotification so the unread indicators are displayed. if unreadNotificationsCount > 0 { @@ -98,81 +107,54 @@ extension NSNotification.Name { // MARK: - Show Zendesk Views - /// Displays the Zendesk Help Center from the given controller, filtered by the mobile category and articles labelled as iOS. - /// - func showHelpCenterIfPossible(from controller: UIViewController, with sourceTag: WordPressSupportSourceTag? = nil) { - - presentInController = controller - let haveUserIdentity = ZendeskUtils.sharedInstance.haveUserIdentity - - // Since user information is not needed to display the Help Center, - // if a user identity has not been created, create an empty identity. - if !haveUserIdentity { - let zendeskIdentity = Identity.createAnonymous() - Zendesk.instance?.setIdentity(zendeskIdentity) - } - - self.sourceTag = sourceTag - WPAnalytics.track(.supportHelpCenterViewed) - - let helpCenterConfig = HelpCenterUiConfiguration() - helpCenterConfig.groupType = .category - helpCenterConfig.groupIds = [Constants.mobileCategoryID as NSNumber] - helpCenterConfig.labels = [Constants.articleLabel] - - // If we don't have the user's information, disable 'Contact Us' via the Help Center and Article view. - helpCenterConfig.showContactOptions = haveUserIdentity - helpCenterConfig.showContactOptionsOnEmptySearch = haveUserIdentity - let articleConfig = ArticleUiConfiguration() - articleConfig.showContactOptions = haveUserIdentity - - // Get custom request configuration so new tickets from this path have all the necessary information. - let newRequestConfig = self.createRequest() - - - let helpCenterController = HelpCenterUi.buildHelpCenterOverviewUi(withConfigs: [helpCenterConfig, articleConfig, newRequestConfig]) - ZendeskUtils.showZendeskView(helpCenterController) - } - /// Displays the Zendesk New Request view from the given controller, for users to submit new tickets. + /// If the user's identity (i.e. contact info) was updated, inform the caller in the `identityUpdated` completion block. /// - func showNewRequestIfPossible(from controller: UIViewController, with sourceTag: WordPressSupportSourceTag? = nil) { + func showNewRequestIfPossible(from controller: UIViewController, with sourceTag: WordPressSupportSourceTag? = nil, identityUpdated: ((Bool) -> Void)? = nil) { presentInController = controller - ZendeskUtils.createIdentity { success in + ZendeskUtils.createIdentity { success, newIdentity in guard success else { + identityUpdated?(false) return } self.sourceTag = sourceTag - WPAnalytics.track(.supportNewRequestViewed) + self.trackSourceEvent(.supportNewRequestViewed) + + self.createRequest() { requestConfig in + let newRequestController = RequestUi.buildRequestUi(with: [requestConfig]) + ZendeskUtils.showZendeskView(newRequestController) - let newRequestConfig = self.createRequest() - let newRequestController = RequestUi.buildRequestUi(with: [newRequestConfig]) - ZendeskUtils.showZendeskView(newRequestController) + identityUpdated?(newIdentity) + } } } /// Displays the Zendesk Request List view from the given controller, allowing user to access their tickets. + /// If the user's identity (i.e. contact info) was updated, inform the caller in the `identityUpdated` completion block. /// - func showTicketListIfPossible(from controller: UIViewController, with sourceTag: WordPressSupportSourceTag? = nil) { + func showTicketListIfPossible(from controller: UIViewController, with sourceTag: WordPressSupportSourceTag? = nil, identityUpdated: ((Bool) -> Void)? = nil) { presentInController = controller - ZendeskUtils.createIdentity { success in + ZendeskUtils.createIdentity { success, newIdentity in guard success else { + identityUpdated?(false) return } self.sourceTag = sourceTag - WPAnalytics.track(.supportTicketListViewed) + self.trackSourceEvent(.supportTicketListViewed) // Get custom request configuration so new tickets from this path have all the necessary information. - let newRequestConfig = self.createRequest() + self.createRequest() { requestConfig in + let requestListController = RequestUi.buildRequestList(with: [requestConfig]) + ZendeskUtils.showZendeskView(requestListController) - let requestListController = RequestUi.buildRequestList(with: [newRequestConfig]) - ZendeskUtils.showZendeskView(requestListController) + identityUpdated?(newIdentity) + } } } @@ -186,24 +168,61 @@ extension NSNotification.Name { } } - func cacheUnlocalizedSitePlans() { - guard !WordPressComLanguageDatabase().deviceLanguage.slug.hasPrefix("en") else { - // Don't fetch if its already "en". - return - } - - let context = ContextManager.shared.mainContext - let accountService = AccountService(managedObjectContext: context) - guard let account = accountService.defaultWordPressComAccount() else { + func cacheUnlocalizedSitePlans(planService: PlanService? = nil) { + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext) else { return } - let planService = PlanService(managedObjectContext: context) + let planService = planService ?? PlanService(coreDataStack: contextManager) planService.getAllSitesNonLocalizedPlanDescriptionsForAccount(account, success: { plans in self.sitePlansCache = plans }, failure: { error in }) } + func createRequest(planServiceRemote: PlanServiceRemote? = nil, + siteID: Int? = nil, + completion: @escaping (RequestUiConfiguration) -> Void) { + + let requestConfig = RequestUiConfiguration() + + // Set Zendesk ticket form to use + requestConfig.ticketFormID = TicketFieldIDs.form as NSNumber + + // Set form field values + var ticketFields = [CustomField]() + ticketFields.append(CustomField(fieldId: TicketFieldIDs.appVersion, value: ZendeskUtils.appVersion)) + ticketFields.append(CustomField(fieldId: TicketFieldIDs.allBlogs, value: ZendeskUtils.getBlogInformation())) + ticketFields.append(CustomField(fieldId: TicketFieldIDs.deviceFreeSpace, value: ZendeskUtils.getDeviceFreeSpace())) + ticketFields.append(CustomField(fieldId: TicketFieldIDs.networkInformation, value: ZendeskUtils.getNetworkInformation())) + ticketFields.append(CustomField(fieldId: TicketFieldIDs.logs, value: ZendeskUtils.getEncryptedLogUUID())) + ticketFields.append(CustomField(fieldId: TicketFieldIDs.currentSite, value: ZendeskUtils.getCurrentSiteDescription())) + ticketFields.append(CustomField(fieldId: TicketFieldIDs.sourcePlatform, value: Constants.sourcePlatform)) + ticketFields.append(CustomField(fieldId: TicketFieldIDs.appLanguage, value: ZendeskUtils.appLanguage)) + + ZendeskUtils.getZendeskMetadata(planServiceRemote: planServiceRemote, siteID: siteID) { result in + var tags = ZendeskUtils.getTags() + switch result { + case .success(let metadata): + guard let metadata = metadata else { + break + } + + ticketFields.append(CustomField(fieldId: TicketFieldIDs.plan, value: metadata.plan)) + ticketFields.append(CustomField(fieldId: TicketFieldIDs.addOns, value: metadata.jetpackAddons)) + tags.append(contentsOf: metadata.jetpackAddons) + case .failure(let error): + DDLogError("Unable to fetch zendesk metadata - \(error.localizedDescription)") + } + requestConfig.customFields = ticketFields + // Set tags + requestConfig.tags = tags + + // Set the ticket subject + requestConfig.subject = Constants.ticketSubject + completion(requestConfig) + } + } + // MARK: - Device Registration /// Sets the device ID to be registered with Zendesk for push notifications. @@ -270,10 +289,31 @@ extension NSNotification.Name { /// Returns the user's Support email address. /// static func userSupportEmail() -> String? { - let _ = getUserProfile() return ZendeskUtils.sharedInstance.userEmail } + /// Obtains user's name and email from first available source. + /// + static func fetchUserInformation() { + // If user information already obtained, do nothing. + guard !ZendeskUtils.sharedInstance.haveUserIdentity else { + return + } + + // Attempt to load from User Defaults. + // If nothing in UD, get from sources in `getUserInformationIfAvailable`. + if !loadUserProfile() { + ZendeskUtils.getUserInformationIfAvailable { + ZendeskUtils.createZendeskIdentity { success in + guard success else { + return + } + ZendeskUtils.sharedInstance.haveUserIdentity = true + } + } + } + } + } // MARK: - Private Extension @@ -281,20 +321,25 @@ extension NSNotification.Name { private extension ZendeskUtils { static func getZendeskCredentials() -> Bool { - guard let appId = ApiCredentials.zendeskAppId(), - let url = ApiCredentials.zendeskUrl(), - let clientId = ApiCredentials.zendeskClientId(), - appId.count > 0, - url.count > 0, - clientId.count > 0 else { - DDLogInfo("Unable to get Zendesk credentials.") - toggleZendesk(enabled: false) - return false + + let zdAppID = ApiCredentials.zendeskAppId + let zdUrl = ApiCredentials.zendeskUrl + let zdClientId = ApiCredentials.zendeskClientId + + guard + !zdAppID.isEmpty, + !zdUrl.isEmpty, + !zdClientId.isEmpty + else { + DDLogInfo("Unable to get Zendesk credentials.") + toggleZendesk(enabled: false) + return false } - zdAppID = appId - zdUrl = url - zdClientId = clientId + self.zdAppID = zdAppID + self.zdUrl = zdUrl + self.zdClientId = zdClientId + return true } @@ -303,38 +348,24 @@ private extension ZendeskUtils { DDLogInfo("Zendesk Enabled: \(enabled)") } - static func createIdentity(completion: @escaping (Bool) -> Void) { - - // If we already have an identity, do nothing. - guard ZendeskUtils.sharedInstance.haveUserIdentity == false else { - DDLogDebug("Using existing Zendesk identity: \(ZendeskUtils.sharedInstance.userEmail ?? ""), \(ZendeskUtils.sharedInstance.userName ?? "")") - completion(true) - return - } - - /* - 1. Attempt to get user information from User Defaults. - 2. If we don't have the user's information yet, attempt to get it from the account/site. - 3. Prompt the user for email & name, pre-populating with user information obtained in step 1. - 4. Create Zendesk identity with user information. - */ + /// Creates a Zendesk Identity from user information. + /// Returns two values in the completion block: + /// - Bool indicating there is an identity to use. + /// - Bool indicating if a _new_ identity was created. + /// + static func createIdentity(completion: @escaping (Bool, Bool) -> Void) { - if getUserProfile() { - ZendeskUtils.createZendeskIdentity { success in - guard success else { - DDLogInfo("Creating Zendesk identity failed.") - completion(false) - return - } - DDLogDebug("Using User Defaults for Zendesk identity.") - ZendeskUtils.sharedInstance.haveUserIdentity = true - completion(true) + // If we already have an identity, and the user has confirmed it, do nothing. + let haveUserInfo = ZendeskUtils.sharedInstance.haveUserIdentity && ZendeskUtils.sharedInstance.userNameConfirmed + guard !haveUserInfo else { + DDLogDebug("Using existing Zendesk identity: \(ZendeskUtils.sharedInstance.userEmail ?? ""), \(ZendeskUtils.sharedInstance.userName ?? "")") + completion(true, false) return - } } + // Prompt the user for information. ZendeskUtils.getUserInformationAndShowPrompt(withName: true) { success in - completion(success) + completion(success, success) } } @@ -373,8 +404,7 @@ private extension ZendeskUtils { let context = ContextManager.sharedInstance().mainContext // 1. Check for WP account - let accountService = AccountService(managedObjectContext: context) - if let defaultAccount = accountService.defaultWordPressComAccount() { + if let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: context) { DDLogDebug("Zendesk - Using defaultAccount for suggested identity.") getUserInformationFrom(wpAccount: defaultAccount) completion() @@ -382,9 +412,8 @@ private extension ZendeskUtils { } // 2. Use information from selected site. - let blogService = BlogService(managedObjectContext: context) - guard let blog = blogService.lastUsedBlog() else { + guard let blog = Blog.lastUsed(in: context) else { // We have no user information. completion() return @@ -441,33 +470,7 @@ private extension ZendeskUtils { } } - func createRequest() -> RequestUiConfiguration { - - let requestConfig = RequestUiConfiguration() - - // Set Zendesk ticket form to use - requestConfig.ticketFormID = TicketFieldIDs.form as NSNumber - - // Set form field values - var ticketFields = [CustomField]() - ticketFields.append(CustomField(fieldId: TicketFieldIDs.appVersion, value: ZendeskUtils.appVersion)) - ticketFields.append(CustomField(fieldId: TicketFieldIDs.allBlogs, value: ZendeskUtils.getBlogInformation())) - ticketFields.append(CustomField(fieldId: TicketFieldIDs.deviceFreeSpace, value: ZendeskUtils.getDeviceFreeSpace())) - ticketFields.append(CustomField(fieldId: TicketFieldIDs.networkInformation, value: ZendeskUtils.getNetworkInformation())) - ticketFields.append(CustomField(fieldId: TicketFieldIDs.logs, value: ZendeskUtils.getLogFile())) - ticketFields.append(CustomField(fieldId: TicketFieldIDs.currentSite, value: ZendeskUtils.getCurrentSiteDescription())) - ticketFields.append(CustomField(fieldId: TicketFieldIDs.sourcePlatform, value: Constants.sourcePlatform)) - ticketFields.append(CustomField(fieldId: TicketFieldIDs.appLanguage, value: ZendeskUtils.appLanguage)) - requestConfig.customFields = ticketFields - - // Set tags - requestConfig.tags = ZendeskUtils.getTags() - // Set the ticket subject - requestConfig.subject = Constants.ticketSubject - - return requestConfig - } // MARK: - View @@ -542,21 +545,32 @@ private extension ZendeskUtils { userProfile[Constants.profileEmailKey] = ZendeskUtils.sharedInstance.userEmail userProfile[Constants.profileNameKey] = ZendeskUtils.sharedInstance.userName DDLogDebug("Zendesk - saving profile to User Defaults: \(userProfile)") - UserDefaults.standard.set(userProfile, forKey: Constants.zendeskProfileUDKey) + UserPersistentStoreFactory.instance().set(userProfile, forKey: Constants.zendeskProfileUDKey) } - static func getUserProfile() -> Bool { - guard let userProfile = UserDefaults.standard.dictionary(forKey: Constants.zendeskProfileUDKey) else { + static func loadUserProfile() -> Bool { + guard let userProfile = UserPersistentStoreFactory.instance().dictionary(forKey: Constants.zendeskProfileUDKey) else { return false } + DDLogDebug("Zendesk - read profile from User Defaults: \(userProfile)") ZendeskUtils.sharedInstance.userEmail = userProfile.valueAsString(forKey: Constants.profileEmailKey) ZendeskUtils.sharedInstance.userName = userProfile.valueAsString(forKey: Constants.profileNameKey) + ZendeskUtils.sharedInstance.userNameConfirmed = true + + ZendeskUtils.createZendeskIdentity { success in + guard success else { + return + } + DDLogDebug("Using User Defaults for Zendesk identity.") + ZendeskUtils.sharedInstance.haveUserIdentity = true + } + return true } static func saveUnreadCount() { - UserDefaults.standard.set(unreadNotificationsCount, forKey: Constants.userDefaultsZendeskUnreadNotifications) + UserPersistentStoreFactory.instance().set(unreadNotificationsCount, forKey: Constants.userDefaultsZendeskUnreadNotifications) } // MARK: - Data Helpers @@ -585,28 +599,37 @@ private extension ZendeskUtils { return "\(formattedCapacity) \(sizeAbbreviation)" } - static func getLogFile() -> String { + static func getEncryptedLogUUID() -> String { - guard let appDelegate = UIApplication.shared.delegate as? WordPressAppDelegate, - let fileLogger = appDelegate.logger?.fileLogger, - let logFileInformation = fileLogger.logFileManager.sortedLogFileInfos.first, - let logData = try? Data(contentsOf: URL(fileURLWithPath: logFileInformation.filePath)), - var logText = String(data: logData, encoding: .utf8) else { - return "" + let fileLogger = WPLogger.shared().fileLogger + let dataProvider = EventLoggingDataProvider.fromDDFileLogger(fileLogger) + + guard let logFilePath = dataProvider.logFilePath(forErrorLevel: .debug, at: Date()) else { + return "Error: No log files found on device" } - // Truncate the log text so it fits in the ticket field. - if logText.count > Constants.logFieldCharacterLimit { - logText = String(logText.suffix(Constants.logFieldCharacterLimit)) + let logFile = LogFile(url: logFilePath) + + do { + let delegate = EventLoggingDelegate() + + /// Some users may be opted out – let's inform support that this is the case (otherwise the UUID just wouldn't work) + if UserSettings.userHasOptedOutOfCrashLogging { + return "No log file uploaded: User opted out" + } + + let eventLogging = EventLogging(dataSource: dataProvider, delegate: delegate) + try eventLogging.enqueueLogForUpload(log: logFile) + } + catch let err { + return "Error preparing log file: \(err.localizedDescription)" } - return logText + return logFile.uuid } static func getCurrentSiteDescription() -> String { - let blogService = BlogService(managedObjectContext: ContextManager.sharedInstance().mainContext) - - guard let blog = blogService.lastUsedBlog() else { + guard let blog = Blog.lastUsed(in: ContextManager.sharedInstance().mainContext) else { return Constants.noValue } @@ -615,10 +638,7 @@ private extension ZendeskUtils { } static func getBlogInformation() -> String { - - let blogService = BlogService(managedObjectContext: ContextManager.sharedInstance().mainContext) - - let allBlogs = blogService.blogsForAllAccounts() + let allBlogs = (try? BlogQuery().blogs(in: ContextManager.sharedInstance().mainContext)) ?? [] guard allBlogs.count > 0 else { return Constants.noValue } @@ -636,18 +656,21 @@ private extension ZendeskUtils { static func getTags() -> [String] { let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - let allBlogs = blogService.blogsForAllAccounts() + let allBlogs = (try? BlogQuery().blogs(in: context)) ?? [] + var tags = [String]() - // If there are no sites, then the user has an empty WP account. - guard allBlogs.count > 0 else { - return [Constants.wpComTag] + // Add sourceTag + if let sourceTagOrigin = ZendeskUtils.sharedInstance.sourceTag?.origin { + tags.append(sourceTagOrigin) } - // Get all unique site plans - var tags = ZendeskUtils.sharedInstance.sitePlansCache.values.compactMap { $0.name }.unique - if tags.count == 0 { - tags = allBlogs.compactMap { $0.planTitle }.unique + // Add platformTag + tags.append(Constants.platformTag) + + // If there are no sites, then the user has an empty WP account. + guard allBlogs.count > 0 else { + tags.append(Constants.wpComTag) + return tags } // If any of the sites have jetpack installed, add jetpack tag. @@ -657,26 +680,23 @@ private extension ZendeskUtils { } // If there is a WP account, add wpcom tag. - let accountService = AccountService(managedObjectContext: context) - if let _ = accountService.defaultWordPressComAccount() { + if let _ = try? WPAccount.lookupDefaultWordPressComAccount(in: context) { tags.append(Constants.wpComTag) } - // Add sourceTag - if let sourceTagOrigin = ZendeskUtils.sharedInstance.sourceTag?.origin { - tags.append(sourceTagOrigin) - } - // Add platformTag - tags.append(Constants.platformTag) // Add gutenbergIsDefault tag - if let blog = blogService.lastUsedBlog() { + if let blog = Blog.lastUsed(in: context) { if blog.isGutenbergEnabled { tags.append(Constants.gutenbergIsDefault) } } + if let currentSite = Blog.lastUsedOrFirst(in: context), !currentSite.isHostedAtWPcom, !currentSite.isAtomic() { + tags.append(Constants.mobileSelfHosted) + } + return tags } @@ -697,7 +717,7 @@ private extension ZendeskUtils { } }() - let networkCarrier = CTTelephonyNetworkInfo().subscriberCellularProvider + let networkCarrier = CTTelephonyNetworkInfo().serviceSubscriberCellularProviders?.values.first let carrierName = networkCarrier?.carrierName ?? Constants.unknownValue let carrierCountryCode = networkCarrier?.isoCountryCode ?? Constants.unknownValue @@ -708,6 +728,17 @@ private extension ZendeskUtils { return networkInformation.joined(separator: "\n") } + func trackSourceEvent(_ event: WPAnalyticsStat) { + guard let sourceTag = sourceTag else { + WPAnalytics.track(event) + return + } + + let properties = ["source": sourceTag.origin ?? sourceTag.name] + WPAnalytics.track(event, withProperties: properties) + } + + // MARK: - Push Notification Helpers static func postNotificationReceived() { @@ -757,6 +788,7 @@ private extension ZendeskUtils { if withName { ZendeskUtils.sharedInstance.userName = alertController?.textFields?.last?.text + ZendeskUtils.sharedInstance.userNameConfirmed = true } saveUserProfile() @@ -777,7 +809,9 @@ private extension ZendeskUtils { textField.placeholder = LocalizedText.emailPlaceholder textField.accessibilityLabel = LocalizedText.emailAccessibilityLabel textField.text = ZendeskUtils.sharedInstance.userEmail + textField.delegate = ZendeskUtils.sharedInstance textField.isEnabled = false + textField.keyboardType = .emailAddress textField.addTarget(self, action: #selector(emailTextFieldDidChange), @@ -799,8 +833,7 @@ private extension ZendeskUtils { // Show alert ZendeskUtils.sharedInstance.presentInController?.present(alertController, animated: true) { - // Enable text fields only after the alert is shown so that VoiceOver will dictate - // the message first. + // Enable text fields only after the alert is shown so that VoiceOver will dictate the message first. alertController.textFields?.forEach { textField in textField.isEnabled = true } @@ -819,12 +852,15 @@ private extension ZendeskUtils { } static func updateNameFieldForEmail(_ email: String) { - guard let alertController = ZendeskUtils.sharedInstance.presentInController?.presentedViewController as? UIAlertController, - let nameField = alertController.textFields?.last else { - return + guard !email.isEmpty else { + return } - guard !email.isEmpty else { + // Find the name text field if it's being displayed. + guard let alertController = ZendeskUtils.sharedInstance.presentInController?.presentedViewController as? UIAlertController, + let textFields = alertController.textFields, + textFields.count > 1, + let nameField = textFields.last else { return } @@ -834,14 +870,20 @@ private extension ZendeskUtils { } } - static func generateDisplayName(from rawEmail: String) -> String { - + static func generateDisplayName(from rawEmail: String) -> String? { // Generate Name, using the same format as Signup. // step 1: lower case let email = rawEmail.lowercased() + // step 2: remove the @ and everything after - let localPart = email.split(separator: "@")[0] + + // Verify something exists before the @. + guard email.first != "@", + let localPart = email.split(separator: "@").first else { + return nil + } + // step 3: remove all non-alpha characters let localCleaned = localPart.replacingOccurrences(of: "[^A-Za-z/.]", with: "", options: .regularExpression) // step 4: turn periods into spaces @@ -915,15 +957,121 @@ private extension ZendeskUtils { } } + // MARK: - Plans + + /// Retrieves the highest priority plan, if it exists + /// - Returns: the highest priority plan found, or an empty string if none was found + private func getHighestPriorityPlan(planService: PlanService? = nil) -> String { + + let availablePlans = getAvailablePlansWithPriority(planService: planService) + if !ZendeskUtils.sharedInstance.sitePlansCache.isEmpty { + let plans = Set(ZendeskUtils.sharedInstance.sitePlansCache.values.compactMap { $0.name }) + + for availablePlan in availablePlans { + if plans.contains(availablePlan.nonLocalizedName) { + return availablePlan.supportName + } + } + } else { + // fail safe: if the plan cache call fails for any reason, at least let's use the cached blogs + // and compare the localized names + let plans = Set(((try? BlogQuery().blogs(in: contextManager.mainContext)) ?? []).compactMap { $0.planTitle }) + + for availablePlan in availablePlans { + if plans.contains(availablePlan.name) { + return availablePlan.supportName + } + } + } + return "" + } + + /// Obtains the available plans, sorted by priority + private func getAvailablePlansWithPriority(planService: PlanService? = nil) -> [SupportPlan] { + let planService = planService ?? PlanService(coreDataStack: contextManager) + return planService.allPlans(in: contextManager.mainContext).map { + SupportPlan(priority: $0.supportPriority, + name: $0.shortname, + nonLocalizedName: $0.nonLocalizedShortname, + supportName: $0.supportName) + + } + .sorted { $0.priority > $1.priority } + } + + /// Retrieves up to date Zendesk metadata from the backend + /// - Parameters: + /// - planServiceRemote: optional plan service remote. The default is used if none is passed + /// - siteID: optional site id. The current is used if none is passed + /// - completion: completion closure executed at the completion of the remote call + static func getZendeskMetadata(planServiceRemote: PlanServiceRemote? = nil, + siteID: Int? = nil, + completion: @escaping (Result<ZendeskMetadata?, Error>) -> Void) { + + guard let service = planServiceRemote ?? defaultPlanServiceRemote, + let validSiteID = siteID ?? currentSiteID else { + + // This is not considered an error condition, there's simply no ZendeskMetaData, + // most likely because the user is logged out. + completion(.success(nil)) + return + } + + service.getZendeskMetadata(siteID: validSiteID, completion: { result in + switch result { + case .success(let metadata): + completion(.success(metadata)) + case .failure(let error): + completion(.failure(error)) + } + }) + } + + /// Provides the default PlanServiceRemote to `getZendeskMetadata` + private static var defaultPlanServiceRemote: PlanServiceRemote? { + guard let api = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext)?.wordPressComRestApi else { + return nil + } + return PlanServiceRemote(wordPressComRestApi: api) + } + + /// Provides the current site id to `getZendeskMetadata`, if it exists + private static var currentSiteID: Int? { + guard let siteID = Blog.lastUsedOrFirst(in: ContextManager.shared.mainContext)?.dotComID else { + return nil + } + return Int(truncating: siteID) + } + + struct SupportPlan { + let priority: Int + let name: String + let nonLocalizedName: String + let supportName: String + + // used to resolve discrepancies of unlocalized names between endpoints + let mappings = ["E-commerce": "eCommerce"] + + init(priority: Int16, + name: String, + nonLocalizedName: String, + supportName: String) { + + self.priority = Int(priority) + self.name = name + self.nonLocalizedName = mappings[nonLocalizedName] ?? nonLocalizedName + self.supportName = supportName + } + } + + // MARK: - Constants struct Constants { static let unknownValue = "unknown" static let noValue = "none" - static let mobileCategoryID: UInt64 = 360000041586 - static let articleLabel = "iOS" static let platformTag = "iOS" - static let ticketSubject = NSLocalizedString("WordPress for iOS Support", comment: "Subject of new Zendesk ticket.") + static let ticketSubject = AppConstants.Zendesk.ticketSubject static let blogSeperator = "\n----------\n" static let jetpackTag = "jetpack" static let wpComTag = "wpcom" @@ -937,16 +1085,16 @@ private extension ZendeskUtils { static let profileNameKey = "name" static let userDefaultsZendeskUnreadNotifications = "wp_zendesk_unread_notifications" static let nameFieldCharacterLimit = 50 - static let logFieldCharacterLimit = 50000 - static let sourcePlatform = "mobile_-_ios" + static let sourcePlatform = AppConstants.zendeskSourcePlatform static let gutenbergIsDefault = "mobile_gutenberg_is_default" + static let mobileSelfHosted = "selected_site_self_hosted" } enum TicketFieldIDs { // Zendesk expects this as NSNumber. However, it is defined as Int64 to satisfy 32-bit devices (ex: iPhone 5). // Which means it has to be converted to NSNumber when sending to Zendesk. static let form: Int64 = 360000010286 - + static let plan: Int64 = 25175963 static let appVersion: Int64 = 360000086866 static let allBlogs: Int64 = 360000087183 static let deviceFreeSpace: Int64 = 360000089123 @@ -955,6 +1103,7 @@ private extension ZendeskUtils { static let currentSite: Int64 = 360000103103 static let sourcePlatform: Int64 = 360009311651 static let appLanguage: Int64 = 360008583691 + static let addOns: Int64 = 360025010672 } struct LocalizedText { @@ -984,4 +1133,13 @@ extension ZendeskUtils: UITextFieldDelegate { return newLength <= Constants.nameFieldCharacterLimit } + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + guard textField != ZendeskUtils.sharedInstance.alertNameField, + let email = textField.text else { + return true + } + + return EmailFormatValidator.validate(string: email) + } + } diff --git a/WordPress/Classes/Utility/iAds/SearchAdsAttribution.swift b/WordPress/Classes/Utility/iAds/SearchAdsAttribution.swift index 53a573e6a6f5..bf7fd2e72e84 100644 --- a/WordPress/Classes/Utility/iAds/SearchAdsAttribution.swift +++ b/WordPress/Classes/Utility/iAds/SearchAdsAttribution.swift @@ -33,10 +33,10 @@ import AutomatticTracks /// private var isTrackingLimited: Bool { get { - return UserDefaults.standard.bool(forKey: SearchAdsAttribution.userDefaultsLimitedAdTrackingKey) + return UserPersistentStoreFactory.instance().bool(forKey: SearchAdsAttribution.userDefaultsLimitedAdTrackingKey) } set { - UserDefaults.standard.set(newValue, forKey: SearchAdsAttribution.userDefaultsLimitedAdTrackingKey) + UserPersistentStoreFactory.instance().set(newValue, forKey: SearchAdsAttribution.userDefaultsLimitedAdTrackingKey) } } @@ -45,10 +45,10 @@ import AutomatticTracks /// private var isAttributionDetailsSent: Bool { get { - return UserDefaults.standard.bool(forKey: SearchAdsAttribution.userDefaultsSentKey) + return UserPersistentStoreFactory.instance().bool(forKey: SearchAdsAttribution.userDefaultsSentKey) } set { - UserDefaults.standard.set(newValue, forKey: SearchAdsAttribution.userDefaultsSentKey) + UserPersistentStoreFactory.instance().set(newValue, forKey: SearchAdsAttribution.userDefaultsSentKey) } } @@ -123,7 +123,7 @@ import AutomatticTracks private func didReceiveError(_ error: Error) { let nsError = error as NSError - guard nsError.code == ADClientError.Code.limitAdTracking.rawValue else { + guard nsError.code == ADClientError.Code.trackingRestrictedOrDenied.rawValue else { tryAgain(after: 5) // Possible connectivity issues return } diff --git a/WordPress/Classes/ViewRelated/Activity/Activity.storyboard b/WordPress/Classes/ViewRelated/Activity/Activity.storyboard deleted file mode 100644 index 1cadd582881d..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/Activity.storyboard +++ /dev/null @@ -1,165 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14865.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait" appearance="light"/> - <dependencies> - <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14819.2"/> - <capability name="Safe area layout guides" minToolsVersion="9.0"/> - <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> - </dependencies> - <scenes> - <!--Activity Detail View Controller--> - <scene sceneID="5Wp-2y-qql"> - <objects> - <viewController storyboardIdentifier="ActivityDetailViewController" id="yav-nF-cx7" customClass="ActivityDetailViewController" customModule="WordPress" customModuleProvider="target" sceneMemberID="viewController"> - <view key="view" contentMode="scaleToFill" id="Zsg-1V-hWw"> - <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> - <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> - <subviews> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="2EY-TD-6V1" userLabel="container view"> - <rect key="frame" x="0.0" y="0.0" width="375" height="331"/> - <subviews> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="30" translatesAutoresizingMaskIntoConstraints="NO" id="mSI-6P-kcW"> - <rect key="frame" x="16" y="16" width="343" height="299"/> - <subviews> - <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="DDN-1Y-KHk" userLabel="header"> - <rect key="frame" x="0.0" y="0.0" width="343" height="38.5"/> - <subviews> - <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="PGi-rI-w2P" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="1.5" width="36" height="36"/> - <color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> - <constraints> - <constraint firstAttribute="width" constant="36" id="8ZE-h3-4gO"/> - <constraint firstAttribute="height" constant="36" id="9C4-f1-HOi"/> - </constraints> - </imageView> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="TYq-jP-lSb" userLabel="name nad role stack view"> - <rect key="frame" x="46" y="0.0" width="254" height="38.5"/> - <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="aJZ-Nu-hmA" userLabel="name"> - <rect key="frame" x="0.0" y="0.0" width="254" height="20.5"/> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> - <nil key="highlightedColor"/> - </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="hWd-Yt-cp5" userLabel="role"> - <rect key="frame" x="0.0" y="22.5" width="254" height="16"/> - <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> - <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> - <nil key="highlightedColor"/> - </label> - </subviews> - </stackView> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="RtK-ZD-5sa" userLabel="date and time stack view"> - <rect key="frame" x="310" y="2.5" width="33" height="34"/> - <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="YIh-B5-5mX"> - <rect key="frame" x="0.0" y="0.0" width="33" height="16"/> - <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> - <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> - <nil key="highlightedColor"/> - </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Cqx-0J-WUT"> - <rect key="frame" x="0.0" y="18" width="33" height="16"/> - <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> - <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> - <nil key="highlightedColor"/> - </label> - </subviews> - </stackView> - </subviews> - </stackView> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="jlr-kR-xzq" userLabel="content"> - <rect key="frame" x="0.0" y="68.5" width="343" height="230.5"/> - <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ZGb-bM-Y2p" customClass="RichTextVIew"> - <rect key="frame" x="0.0" y="0.0" width="343" height="20.5"/> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> - <nil key="highlightedColor"/> - </label> - <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="3sW-BH-Aq9"> - <rect key="frame" x="0.0" y="28.5" width="343" height="176"/> - <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> - <string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string> - <fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/> - <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> - </textView> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ASx-wD-w08"> - <rect key="frame" x="0.0" y="212.5" width="343" height="18"/> - <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> - <color key="textColor" red="0.40000000000000002" green="0.55686274509803924" blue="0.66666666666666663" alpha="1" colorSpace="calibratedRGB"/> - <nil key="highlightedColor"/> - </label> - </subviews> - </stackView> - <stackView hidden="YES" opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="Ouv-sN-axZ" userLabel="rewind"> - <rect key="frame" x="0.0" y="299" width="343" height="44.5"/> - <subviews> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="yPg-ua-Eim"> - <rect key="frame" x="0.0" y="0.0" width="343" height="0.5"/> - <color key="backgroundColor" red="0.9137254901960784" green="0.93725490196078431" blue="0.95294117647058818" alpha="1" colorSpace="calibratedRGB"/> - <constraints> - <constraint firstAttribute="height" constant="0.5" id="BVA-WF-pjw"/> - </constraints> - </view> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qzN-1h-59z"> - <rect key="frame" x="0.0" y="0.5" width="343" height="44"/> - <constraints> - <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="44" id="NG0-1F-qwe"/> - </constraints> - <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> - <color key="tintColor" red="0.0" green="0.66666666669999997" blue="0.86274509799999999" alpha="1" colorSpace="calibratedRGB"/> - <inset key="titleEdgeInsets" minX="6" minY="0.0" maxX="0.0" maxY="0.0"/> - <state key="normal" title="Button"> - <color key="titleColor" red="0.0" green="0.66666666666666663" blue="0.86274509803921573" alpha="1" colorSpace="calibratedRGB"/> - </state> - <connections> - <action selector="rewindButtonTappedWithSender:" destination="yav-nF-cx7" eventType="touchUpInside" id="u5C-lN-yLL"/> - </connections> - </button> - </subviews> - </stackView> - </subviews> - <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> - </stackView> - </subviews> - <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> - <constraints> - <constraint firstAttribute="bottom" secondItem="mSI-6P-kcW" secondAttribute="bottom" constant="16" id="Fha-kb-hYl"/> - <constraint firstAttribute="trailing" secondItem="mSI-6P-kcW" secondAttribute="trailing" constant="16" id="kcr-xl-FGY"/> - <constraint firstItem="mSI-6P-kcW" firstAttribute="leading" secondItem="2EY-TD-6V1" secondAttribute="leading" constant="16" id="nre-rr-NDD"/> - <constraint firstItem="mSI-6P-kcW" firstAttribute="top" secondItem="2EY-TD-6V1" secondAttribute="top" constant="16" id="oHE-K6-Sua"/> - </constraints> - </view> - </subviews> - <constraints> - <constraint firstItem="fnA-qw-vPe" firstAttribute="trailing" secondItem="2EY-TD-6V1" secondAttribute="trailing" id="36M-yw-daV"/> - <constraint firstItem="2EY-TD-6V1" firstAttribute="top" secondItem="fnA-qw-vPe" secondAttribute="top" id="KcE-uG-uhl"/> - <constraint firstItem="2EY-TD-6V1" firstAttribute="leading" secondItem="fnA-qw-vPe" secondAttribute="leading" id="uy5-pe-WY6"/> - </constraints> - <viewLayoutGuide key="safeArea" id="fnA-qw-vPe"/> - </view> - <connections> - <outlet property="bottomConstaint" destination="Fha-kb-hYl" id="vUN-uA-iaq"/> - <outlet property="containerView" destination="2EY-TD-6V1" id="pkP-dO-mdo"/> - <outlet property="contentStackView" destination="jlr-kR-xzq" id="bkc-3M-MDt"/> - <outlet property="dateLabel" destination="YIh-B5-5mX" id="k3s-5c-AXy"/> - <outlet property="headerStackView" destination="DDN-1Y-KHk" id="FTi-Sr-BFK"/> - <outlet property="imageView" destination="PGi-rI-w2P" id="wgx-g9-MgO"/> - <outlet property="nameLabel" destination="aJZ-Nu-hmA" id="yNG-9h-DUP"/> - <outlet property="rewindButton" destination="qzN-1h-59z" id="Xpx-fu-poI"/> - <outlet property="rewindStackView" destination="Ouv-sN-axZ" id="wix-es-Jrg"/> - <outlet property="roleLabel" destination="hWd-Yt-cp5" id="UVe-9x-2E2"/> - <outlet property="summaryLabel" destination="ASx-wD-w08" id="THa-Vd-ylW"/> - <outlet property="textLabel" destination="ZGb-bM-Y2p" id="qUF-RI-rlQ"/> - <outlet property="textView" destination="3sW-BH-Aq9" id="6w7-jX-TJa"/> - <outlet property="timeLabel" destination="Cqx-0J-WUT" id="vaU-Gy-hCg"/> - </connections> - </viewController> - <placeholder placeholderIdentifier="IBFirstResponder" id="gsR-ne-8K9" userLabel="First Responder" sceneMemberID="firstResponder"/> - </objects> - <point key="canvasLocation" x="199" y="-36"/> - </scene> - </scenes> -</document> diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityDateFormatting.swift b/WordPress/Classes/ViewRelated/Activity/ActivityDateFormatting.swift index b926d766fec4..d580b0b00270 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityDateFormatting.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityDateFormatting.swift @@ -2,8 +2,7 @@ // (at the minimum the Activity Log list, detail view, and the `ActivityStore`. // This encapsulates those needs in one place. struct ActivityDateFormatting { - static func mediumDateFormatterWithTime(for site: JetpackSiteRef, - managedObjectContext: NSManagedObjectContext = ContextManager.sharedInstance().mainContext) -> DateFormatter { + static func mediumDateFormatterWithTime(for site: JetpackSiteRef) -> DateFormatter { let formatter = DateFormatter() formatter.doesRelativeDateFormatting = true formatter.dateStyle = .medium @@ -13,24 +12,23 @@ struct ActivityDateFormatting { return formatter } - static func longDateFormatterWithoutTime(for site: JetpackSiteRef, - managedObjectContext: NSManagedObjectContext = ContextManager.sharedInstance().mainContext) -> DateFormatter { + static func longDateFormatter(for site: JetpackSiteRef, + withTime: Bool = false) -> DateFormatter { let formatter = DateFormatter() formatter.dateStyle = .long - formatter.timeStyle = .none + formatter.timeStyle = withTime ? .short : .none formatter.timeZone = timeZone(for: site) return formatter } static func timeZone(for site: JetpackSiteRef, managedObjectContext: NSManagedObjectContext = ContextManager.sharedInstance().mainContext) -> TimeZone { - let blogService = BlogService(managedObjectContext: managedObjectContext) - guard let blog = blogService.blog(byBlogId: site.siteID as NSNumber) else { + guard let blog = try? Blog.lookup(withID: site.siteID, in: managedObjectContext) else { DDLogInfo("[ActivityDateFormatting] Couldn't find a blog with specified siteID. Falling back to UTC.") return TimeZone(secondsFromGMT: 0)! } - return blogService.timeZone(for: blog) + return blog.timeZone } } diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.storyboard b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.storyboard new file mode 100644 index 000000000000..357e53ea78bf --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.storyboard @@ -0,0 +1,221 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <scenes> + <!--Activity Detail View Controller--> + <scene sceneID="5Wp-2y-qql"> + <objects> + <viewController storyboardIdentifier="ActivityDetailViewController" id="yav-nF-cx7" customClass="ActivityDetailViewController" customModule="WordPress" customModuleProvider="target" sceneMemberID="viewController"> + <view key="view" contentMode="scaleToFill" id="Zsg-1V-hWw"> + <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="2EY-TD-6V1" userLabel="container view"> + <rect key="frame" x="0.0" y="0.0" width="375" height="314.5"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="30" translatesAutoresizingMaskIntoConstraints="NO" id="mSI-6P-kcW"> + <rect key="frame" x="16" y="16" width="343" height="282.5"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="DDN-1Y-KHk" userLabel="header"> + <rect key="frame" x="0.0" y="0.0" width="343" height="37"/> + <subviews> + <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="PGi-rI-w2P" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.5" width="36" height="36"/> + <color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" constant="36" id="8ZE-h3-4gO"/> + <constraint firstAttribute="height" constant="36" id="9C4-f1-HOi"/> + </constraints> + </imageView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="TYq-jP-lSb" userLabel="name nad role stack view"> + <rect key="frame" x="46" y="0.0" width="256" height="37"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="aJZ-Nu-hmA" userLabel="name"> + <rect key="frame" x="0.0" y="0.0" width="256" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="hWd-Yt-cp5" userLabel="role"> + <rect key="frame" x="0.0" y="22.5" width="256" height="14.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="RtK-ZD-5sa" userLabel="date and time stack view"> + <rect key="frame" x="312" y="3" width="31" height="31"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="YIh-B5-5mX"> + <rect key="frame" x="0.0" y="0.0" width="31" height="14.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Cqx-0J-WUT"> + <rect key="frame" x="0.0" y="16.5" width="31" height="14.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + </subviews> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="jlr-kR-xzq" userLabel="content"> + <rect key="frame" x="0.0" y="67" width="343" height="185.5"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ZGb-bM-Y2p" customClass="RichTextVIew"> + <rect key="frame" x="0.0" y="0.0" width="343" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> + <nil key="highlightedColor"/> + </label> + <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" editable="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="3sW-BH-Aq9"> + <rect key="frame" x="0.0" y="28.5" width="343" height="134.5"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string> + <fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/> + <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> + </textView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ASx-wD-w08"> + <rect key="frame" x="0.0" y="171" width="343" height="14.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <color key="textColor" red="0.40000000000000002" green="0.55686274509803924" blue="0.66666666666666663" alpha="1" colorSpace="calibratedRGB"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="i00-9l-xYk"> + <rect key="frame" x="0.0" y="282.5" width="343" height="0.0"/> + <subviews> + <stackView hidden="YES" opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="Ouv-sN-axZ" userLabel="rewind"> + <rect key="frame" x="0.0" y="0.0" width="343" height="44.5"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="yPg-ua-Eim"> + <rect key="frame" x="0.0" y="0.0" width="343" height="0.5"/> + <color key="backgroundColor" systemColor="quaternaryLabelColor"/> + <constraints> + <constraint firstAttribute="height" constant="0.5" id="BVA-WF-pjw"/> + </constraints> + </view> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qzN-1h-59z"> + <rect key="frame" x="0.0" y="0.5" width="343" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="NG0-1F-qwe"/> + </constraints> + <inset key="titleEdgeInsets" minX="6" minY="0.0" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Button"/> + <connections> + <action selector="rewindButtonTappedWithSender:" destination="yav-nF-cx7" eventType="touchUpInside" id="u5C-lN-yLL"/> + </connections> + </button> + </subviews> + </stackView> + <stackView hidden="YES" opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="yyE-VA-gGp" userLabel="backup"> + <rect key="frame" x="0.0" y="0.0" width="343" height="44.5"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="gZy-mE-jTT"> + <rect key="frame" x="0.0" y="0.0" width="343" height="0.5"/> + <color key="backgroundColor" systemColor="quaternaryLabelColor"/> + <constraints> + <constraint firstAttribute="height" constant="0.5" id="qQX-DU-Xuz"/> + </constraints> + </view> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="bA3-ay-OFJ"> + <rect key="frame" x="0.0" y="0.5" width="343" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="5CY-sV-081"/> + </constraints> + <inset key="titleEdgeInsets" minX="6" minY="0.0" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Button"/> + <connections> + <action selector="backupButtonTappedWithSender:" destination="yav-nF-cx7" eventType="touchUpInside" id="kjv-NG-DTn"/> + </connections> + </button> + </subviews> + </stackView> + </subviews> + </stackView> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </stackView> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="bottom" secondItem="mSI-6P-kcW" secondAttribute="bottom" constant="16" id="Fha-kb-hYl"/> + <constraint firstAttribute="trailing" secondItem="mSI-6P-kcW" secondAttribute="trailing" constant="16" id="kcr-xl-FGY"/> + <constraint firstItem="mSI-6P-kcW" firstAttribute="leading" secondItem="2EY-TD-6V1" secondAttribute="leading" constant="16" id="nre-rr-NDD"/> + <constraint firstItem="mSI-6P-kcW" firstAttribute="top" secondItem="2EY-TD-6V1" secondAttribute="top" constant="16" id="oHE-K6-Sua"/> + </constraints> + </view> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="BXA-iq-lC2"> + <rect key="frame" x="16" y="330.5" width="343" height="0.0"/> + <subviews> + <button hidden="YES" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="xW5-sj-AVr" customClass="MultilineButton" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="343" height="40"/> + <constraints> + <constraint firstAttribute="height" constant="40" id="T42-yy-8v5"/> + </constraints> + <state key="normal" title="Button"/> + <connections> + <action selector="warningTapped:" destination="yav-nF-cx7" eventType="touchUpInside" id="XPM-6H-44L"/> + </connections> + </button> + <view hidden="YES" contentMode="scaleToFill" verticalHuggingPriority="249" translatesAutoresizingMaskIntoConstraints="NO" id="vfz-Oz-LdY"> + <rect key="frame" x="0.0" y="0.0" width="343" height="0.0"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </view> + </subviews> + </stackView> + </subviews> + <viewLayoutGuide key="safeArea" id="fnA-qw-vPe"/> + <constraints> + <constraint firstItem="fnA-qw-vPe" firstAttribute="trailing" secondItem="2EY-TD-6V1" secondAttribute="trailing" id="36M-yw-daV"/> + <constraint firstItem="2EY-TD-6V1" firstAttribute="top" secondItem="fnA-qw-vPe" secondAttribute="top" id="KcE-uG-uhl"/> + <constraint firstItem="BXA-iq-lC2" firstAttribute="top" secondItem="2EY-TD-6V1" secondAttribute="bottom" constant="16" id="pgh-qe-kLy"/> + <constraint firstItem="BXA-iq-lC2" firstAttribute="leading" secondItem="fnA-qw-vPe" secondAttribute="leading" constant="16" id="uoZ-PA-gzE"/> + <constraint firstItem="2EY-TD-6V1" firstAttribute="leading" secondItem="fnA-qw-vPe" secondAttribute="leading" id="uy5-pe-WY6"/> + <constraint firstItem="fnA-qw-vPe" firstAttribute="trailing" secondItem="BXA-iq-lC2" secondAttribute="trailing" constant="16" id="xFF-qM-W7h"/> + </constraints> + </view> + <connections> + <outlet property="backupButton" destination="bA3-ay-OFJ" id="b1y-2F-PX6"/> + <outlet property="backupStackView" destination="yyE-VA-gGp" id="fnD-jB-12a"/> + <outlet property="bottomConstaint" destination="Fha-kb-hYl" id="CD2-qR-3da"/> + <outlet property="containerView" destination="2EY-TD-6V1" id="pkP-dO-mdo"/> + <outlet property="contentStackView" destination="jlr-kR-xzq" id="bkc-3M-MDt"/> + <outlet property="dateLabel" destination="YIh-B5-5mX" id="k3s-5c-AXy"/> + <outlet property="headerStackView" destination="DDN-1Y-KHk" id="FTi-Sr-BFK"/> + <outlet property="imageView" destination="PGi-rI-w2P" id="wgx-g9-MgO"/> + <outlet property="jetpackBadgeView" destination="vfz-Oz-LdY" id="xt2-RM-oIk"/> + <outlet property="nameLabel" destination="aJZ-Nu-hmA" id="yNG-9h-DUP"/> + <outlet property="rewindButton" destination="qzN-1h-59z" id="Xpx-fu-poI"/> + <outlet property="rewindStackView" destination="Ouv-sN-axZ" id="wix-es-Jrg"/> + <outlet property="roleLabel" destination="hWd-Yt-cp5" id="UVe-9x-2E2"/> + <outlet property="summaryLabel" destination="ASx-wD-w08" id="THa-Vd-ylW"/> + <outlet property="textLabel" destination="ZGb-bM-Y2p" id="qUF-RI-rlQ"/> + <outlet property="textView" destination="3sW-BH-Aq9" id="6w7-jX-TJa"/> + <outlet property="timeLabel" destination="Cqx-0J-WUT" id="vaU-Gy-hCg"/> + <outlet property="warningButton" destination="xW5-sj-AVr" id="Bjc-BJ-JWN"/> + </connections> + </viewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="gsR-ne-8K9" userLabel="First Responder" sceneMemberID="firstResponder"/> + </objects> + <point key="canvasLocation" x="197.59999999999999" y="-36.431784107946029"/> + </scene> + </scenes> + <resources> + <systemColor name="quaternaryLabelColor"> + <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.17999999999999999" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift index 66090169982d..563eedad2452 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityDetailViewController.swift @@ -2,7 +2,13 @@ import UIKit import Gridicons import WordPressUI -class ActivityDetailViewController: UIViewController { +class ActivityDetailViewController: UIViewController, StoryboardLoadable { + + // MARK: - StoryboardLoadable Protocol + + static var defaultStoryboardName = defaultControllerID + + // MARK: - Properties var formattableActivity: FormattableActivity? { didSet { @@ -12,7 +18,9 @@ class ActivityDetailViewController: UIViewController { } var site: JetpackSiteRef? - weak var rewindPresenter: ActivityRewindPresenter? + var rewindStatus: RewindStatus? + + weak var presenter: ActivityPresenter? @IBOutlet private var imageView: CircularImageView! @@ -28,18 +36,24 @@ class ActivityDetailViewController: UIViewController { } } + @IBOutlet weak var jetpackBadgeView: UIView! + //TODO: remove! @IBOutlet private var textLabel: UILabel! @IBOutlet private var summaryLabel: UILabel! @IBOutlet private var headerStackView: UIStackView! @IBOutlet private var rewindStackView: UIStackView! + @IBOutlet private var backupStackView: UIStackView! @IBOutlet private var contentStackView: UIStackView! @IBOutlet private var containerView: UIView! + @IBOutlet weak var warningButton: MultilineButton! + @IBOutlet private var bottomConstaint: NSLayoutConstraint! @IBOutlet private var rewindButton: UIButton! + @IBOutlet private var backupButton: UIButton! private var activity: Activity? @@ -50,11 +64,33 @@ class ActivityDetailViewController: UIViewController { setupViews() setupText() setupAccesibility() - WPAnalytics.track(.activityLogDetailViewed) + hideRestoreIfNeeded() + showWarningIfNeeded() + WPAnalytics.track(.activityLogDetailViewed, withProperties: ["source": presentedFrom()]) } @IBAction func rewindButtonTapped(sender: UIButton) { - rewindPresenter?.presentRewindFor(activity: activity!) + guard let activity = activity else { + return + } + presenter?.presentRestoreFor(activity: activity, from: "\(presentedFrom())/detail") + } + + @IBAction func backupButtonTapped(sender: UIButton) { + guard let activity = activity else { + return + } + presenter?.presentBackupFor(activity: activity, from: "\(presentedFrom())/detail") + } + + @IBAction func warningTapped(_ sender: Any) { + guard let url = URL(string: Constants.supportUrl) else { + return + } + + let navController = UINavigationController(rootViewController: WebViewControllerFactory.controller(url: url, source: "activity_detail_warning")) + + present(navController, animated: true) } private func setupLabelStyles() { @@ -70,6 +106,9 @@ class ActivityDetailViewController: UIViewController { rewindButton.setTitleColor(.primary, for: .normal) rewindButton.setTitleColor(.primaryDark, for: .highlighted) + + backupButton.setTitleColor(.primary, for: .normal) + backupButton.setTitleColor(.primaryDark, for: .highlighted) } private func setupViews() { @@ -85,24 +124,61 @@ class ActivityDetailViewController: UIViewController { textView.textContainer.lineFragmentPadding = 0 if activity.isRewindable { - rewindStackView.isHidden = false bottomConstaint.constant = 0 + rewindStackView.isHidden = false + backupStackView.isHidden = false } if let avatar = activity.actor?.avatarURL, let avatarURL = URL(string: avatar) { imageView.backgroundColor = .neutral(.shade20) - imageView.downloadImage(from: avatarURL, placeholderImage: Gridicon.iconOfType(.user, withSize: Constants.gridiconSize)) + imageView.downloadImage(from: avatarURL, placeholderImage: .gridicon(.user, size: Constants.gridiconSize)) } else if let iconType = WPStyleGuide.ActivityStyleGuide.getGridiconTypeForActivity(activity) { imageView.contentMode = .center imageView.backgroundColor = WPStyleGuide.ActivityStyleGuide.getColorByActivityStatus(activity) - let image = Gridicon.iconOfType(iconType, withSize: Constants.gridiconSize) - imageView.image = image + imageView.image = .gridicon(iconType, size: Constants.gridiconSize) } else { imageView.isHidden = true } rewindButton.naturalContentHorizontalAlignment = .leading - rewindButton.setImage(Gridicon.iconOfType(.history, withSize: Constants.gridiconSize), for: .normal) + rewindButton.setImage(.gridicon(.history, size: Constants.gridiconSize), for: .normal) + + backupButton.naturalContentHorizontalAlignment = .leading + backupButton.setImage(.gridicon(.cloudDownload, size: Constants.gridiconSize), for: .normal) + + let attributedTitle = WPStyleGuide.Jetpack.highlightString(RewindStatus.Strings.multisiteNotAvailableSubstring, + inString: RewindStatus.Strings.multisiteNotAvailable) + + warningButton.setAttributedTitle(attributedTitle, for: .normal) + warningButton.setTitleColor(.systemGray, for: .normal) + warningButton.titleLabel?.numberOfLines = 0 + warningButton.titleLabel?.lineBreakMode = .byWordWrapping + warningButton.naturalContentHorizontalAlignment = .leading + warningButton.backgroundColor = view.backgroundColor + setupJetpackBadge() + } + + private func setupJetpackBadge() { + guard JetpackBrandingVisibility.all.enabled else { + return + } + jetpackBadgeView.isHidden = false + let textProvider = JetpackBrandingTextProvider(screen: JetpackBadgeScreen.activityDetail) + let jetpackBadgeButton = JetpackButton(style: .badge, title: textProvider.brandingText()) + jetpackBadgeButton.translatesAutoresizingMaskIntoConstraints = false + jetpackBadgeButton.addTarget(self, action: #selector(jetpackButtonTapped), for: .touchUpInside) + jetpackBadgeView.addSubview(jetpackBadgeButton) + NSLayoutConstraint.activate([ + jetpackBadgeButton.centerXAnchor.constraint(equalTo: jetpackBadgeView.centerXAnchor), + jetpackBadgeButton.topAnchor.constraint(equalTo: jetpackBadgeView.topAnchor, constant: Constants.jetpackBadgeTopInset), + jetpackBadgeButton.bottomAnchor.constraint(equalTo: jetpackBadgeView.bottomAnchor) + ]) + jetpackBadgeView.backgroundColor = .listBackground + } + + @objc private func jetpackButtonTapped() { + JetpackBrandingCoordinator.presentOverlay(from: self) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBadgeTapped(screen: .activityDetail) } private func setupText() { @@ -117,10 +193,12 @@ class ActivityDetailViewController: UIViewController { textView.attributedText = formattableActivity?.formattedContent(using: ActivityContentStyles()) summaryLabel.text = activity.summary - rewindButton.setTitle(NSLocalizedString("Rewind", comment: "Title for button allowing user to rewind their Jetpack site"), + rewindButton.setTitle(NSLocalizedString("Restore", comment: "Title for button allowing user to restore their Jetpack site"), + for: .normal) + backupButton.setTitle(NSLocalizedString("Download backup", comment: "Title for button allowing user to backup their Jetpack site"), for: .normal) - let dateFormatter = ActivityDateFormatting.longDateFormatterWithoutTime(for: site) + let dateFormatter = ActivityDateFormatting.longDateFormatter(for: site, withTime: false) dateLabel.text = dateFormatter.string(from: activity.published) let timeFormatter = DateFormatter() @@ -164,6 +242,22 @@ class ActivityDetailViewController: UIViewController { } } + private func hideRestoreIfNeeded() { + guard let isRestoreActive = rewindStatus?.isActive() else { + return + } + + rewindStackView.isHidden = !isRestoreActive + } + + private func showWarningIfNeeded() { + guard let isMultiSite = rewindStatus?.isMultisite() else { + return + } + + warningButton.isHidden = !isMultiSite + } + func setupRouter() { guard let activity = formattableActivity else { router = nil @@ -187,8 +281,23 @@ class ActivityDetailViewController: UIViewController { } } + private func presentedFrom() -> String { + if presenter is JetpackActivityLogViewController { + return "activity_log" + } else if presenter is BackupListViewController { + return "backup" + } else if presenter is DashboardActivityLogCardCell { + return "dashboard" + } else { + return "unknown" + } + } + private enum Constants { static let gridiconSize: CGSize = CGSize(width: 24, height: 24) + static let supportUrl = "https://jetpack.com/support/backup/" + // the distance ought to be 30, and the stackView spacing is 16, thus the top inset is 14. + static let jetpackBadgeTopInset: CGFloat = 14 } } diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityListRow.swift b/WordPress/Classes/ViewRelated/Activity/ActivityListRow.swift index 2d7e1ca68176..bbfcdbaf3990 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityListRow.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityListRow.swift @@ -11,17 +11,22 @@ struct ActivityListRow: ImmuTableRow { return formattableActivity.activity } let action: ImmuTableAction? + let actionButtonHandler: (UIButton) -> Void private let formattableActivity: FormattableActivity - init(formattableActivity: FormattableActivity, action: ImmuTableAction?) { + init(formattableActivity: FormattableActivity, + action: ImmuTableAction?, + actionButtonHandler: @escaping (UIButton) -> Void) { self.formattableActivity = formattableActivity self.action = action + self.actionButtonHandler = actionButtonHandler } func configureCell(_ cell: UITableViewCell) { let cell = cell as! CellType cell.configureCell(formattableActivity) cell.selectionStyle = .none + cell.actionButtonHandler = actionButtonHandler } } diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityListSectionHeaderView.xib b/WordPress/Classes/ViewRelated/Activity/ActivityListSectionHeaderView.xib index 246192f1b7ea..a8729070095c 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityListSectionHeaderView.xib +++ b/WordPress/Classes/ViewRelated/Activity/ActivityListSectionHeaderView.xib @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14865.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14819.2"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17505"/> <capability name="Named colors" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> @@ -33,6 +33,7 @@ </constraints> </view> </subviews> + <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> <constraints> <constraint firstItem="L4V-t8-P4G" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="2nH-U4-5ow"/> <constraint firstItem="L4V-t8-P4G" firstAttribute="trailing" secondItem="vUN-kp-3ea" secondAttribute="trailing" id="8H4-AD-Bu1"/> @@ -47,21 +48,20 @@ <constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="XJm-Vt-ZYG" secondAttribute="trailing" id="ypY-d4-qzK"/> </constraints> <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> - <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> <connections> <outlet property="backgroundColorView" destination="L4V-t8-P4G" id="nHv-za-Dom"/> <outlet property="separator" destination="XJm-Vt-ZYG" id="8dL-Hf-kh6"/> <outlet property="titleLabel" destination="rTR-ms-4m0" id="vUB-uB-DAt"/> </connections> - <point key="canvasLocation" x="139" y="154"/> + <point key="canvasLocation" x="137.59999999999999" y="153.82308845577214"/> </view> </objects> <resources> <namedColor name="Gray0"> - <color red="0.96862745098039216" green="0.95686274509803926" blue="0.96078431372549022" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <color red="0.96470588235294119" green="0.96862745098039216" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> </namedColor> <namedColor name="Gray50"> - <color red="0.40392156862745099" green="0.41568627450980394" blue="0.45490196078431372" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <color red="0.39215686274509803" green="0.41176470588235292" blue="0.4392156862745098" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> </namedColor> </resources> </document> diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityListViewController.swift b/WordPress/Classes/ViewRelated/Activity/ActivityListViewController.swift deleted file mode 100644 index 0d559cce3106..000000000000 --- a/WordPress/Classes/ViewRelated/Activity/ActivityListViewController.swift +++ /dev/null @@ -1,283 +0,0 @@ -import Foundation -import CocoaLumberjack -import SVProgressHUD -import WordPressShared -import WordPressFlux - -class ActivityListViewController: UITableViewController, ImmuTablePresenter { - - let site: JetpackSiteRef - let store: ActivityStore - let isFreeWPCom: Bool - - var changeReceipt: Receipt? - var isUserTriggeredRefresh: Bool = false - - fileprivate lazy var handler: ImmuTableViewHandler = { - return ImmuTableViewHandler(takeOver: self) - }() - - fileprivate var viewModel: ActivityListViewModel - private enum Constants { - static let estimatedRowHeight: CGFloat = 62 - } - - // MARK: - GUI - - fileprivate var noResultsViewController: NoResultsViewController? - - // MARK: - Constructors - - init(site: JetpackSiteRef, store: ActivityStore, isFreeWPCom: Bool = false) { - self.site = site - self.store = store - self.isFreeWPCom = isFreeWPCom - self.viewModel = ActivityListViewModel(site: site, store: store) - - super.init(style: .plain) - - self.changeReceipt = viewModel.onChange { [weak self] in - self?.refreshModel() - } - - refreshControl = UIRefreshControl() - refreshControl?.addTarget(self, action: #selector(userRefresh), for: .valueChanged) - - title = NSLocalizedString("Activity", comment: "Title for the activity list") - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @objc convenience init?(blog: Blog) { - precondition(blog.dotComID != nil) - guard let siteRef = JetpackSiteRef(blog: blog) else { - return nil - } - - - let isFreeWPCom = blog.isHostedAtWPcom && !blog.hasPaidPlan - self.init(site: siteRef, store: StoreContainer.shared.activity, isFreeWPCom: isFreeWPCom) - } - - // MARK: - View lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - - refreshModel() - - tableView.estimatedRowHeight = Constants.estimatedRowHeight - - WPStyleGuide.configureColors(view: view, tableView: tableView) - - let nib = UINib(nibName: ActivityListSectionHeaderView.identifier, bundle: nil) - tableView.register(nib, forHeaderFooterViewReuseIdentifier: ActivityListSectionHeaderView.identifier) - ImmuTable.registerRows([ActivityListRow.self, RewindStatusRow.self], tableView: tableView) - // Magic to avoid cell separators being displayed while a plain table loads - tableView.tableFooterView = UIView() - - WPAnalytics.track(.activityLogViewed) - } - - override func viewWillDisappear(_ animated: Bool) { - SVProgressHUD.dismiss() - } - - @objc func userRefresh() { - isUserTriggeredRefresh = true - viewModel.refresh() - } - - func refreshModel() { - handler.viewModel = viewModel.tableViewModel(presenter: self) - updateRefreshControl() - updateNoResults() - } - - private func updateRefreshControl() { - guard let refreshControl = refreshControl else { - return - } - - switch (viewModel.refreshing, refreshControl.isRefreshing) { - case (true, false): - if isUserTriggeredRefresh { - refreshControl.beginRefreshing() - isUserTriggeredRefresh = false - } - case (false, true): - refreshControl.endRefreshing() - default: - break - } - } - -} - -// MARK: - UITableViewDelegate - -extension ActivityListViewController { - - override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - let isLastSection = handler.viewModel.sections.count == section + 1 - - guard isFreeWPCom, isLastSection, let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: ActivityListSectionHeaderView.identifier) as? ActivityListSectionHeaderView else { - return nil - } - - cell.separator.isHidden = true - cell.titleLabel.text = NSLocalizedString("Since you're on a free plan, you'll see limited events in your Activity Log.", comment: "Text displayed as a footer of a table view with Activities when user is on a free plan") - - return cell - } - - override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - let isLastSection = handler.viewModel.sections.count == section + 1 - - guard isFreeWPCom, isLastSection else { - return 0.0 - } - - return UITableView.automaticDimension - } - - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: ActivityListSectionHeaderView.identifier) as? ActivityListSectionHeaderView else { - return nil - } - - cell.titleLabel.text = handler.tableView(tableView, titleForHeaderInSection: section)?.localizedUppercase - - return cell - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return ActivityListSectionHeaderView.height - } - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - guard let row = handler.viewModel.rowAtIndexPath(indexPath) as? ActivityListRow else { - return false - } - - return row.activity.isRewindable - } - - override func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - guard let row = handler.viewModel.rowAtIndexPath(indexPath) as? ActivityListRow, row.activity.isRewindable else { - return nil - } - - let rewindAction = UITableViewRowAction(style: .normal, - title: NSLocalizedString("Rewind", comment: "Title displayed when user swipes on a rewind cell"), - handler: { [weak self] _, indexPath in - self?.presentRewindFor(activity: row.activity) - }) - rewindAction.backgroundColor = .primary(.shade40) - - return [rewindAction] - } - -} - -// MARK: - NoResultsViewControllerDelegate - -extension ActivityListViewController: NoResultsViewControllerDelegate { - func actionButtonPressed() { - let supportVC = SupportTableViewController() - supportVC.showFromTabBar() - } -} - -// MARK: - ActivityRewindPresenter - -extension ActivityListViewController: ActivityRewindPresenter { - - func presentRewindFor(activity: Activity) { - guard activity.isRewindable, let rewindID = activity.rewindID else { - return - } - - let title = NSLocalizedString("Rewind Site", - comment: "Title displayed in the Rewind Site alert, should match Calypso") - let rewindDate = viewModel.mediumDateFormatterWithTime.string(from: activity.published) - let messageFormat = NSLocalizedString("Are you sure you want to rewind your site back to %@?\nThis will remove all content and options created or changed since then.", - comment: "Message displayed in the Rewind Site alert, the placeholder holds a date, should match Calypso.") - let message = String(format: messageFormat, rewindDate) - - let alertController = UIAlertController(title: title, - message: message, - preferredStyle: .alert) - alertController.addCancelActionWithTitle(NSLocalizedString("Cancel", comment: "Verb. A button title.")) - alertController.addDestructiveActionWithTitle(NSLocalizedString("Confirm Rewind", - comment: "Confirm Rewind button title"), - handler: { action in - self.restoreSiteToRewindID(rewindID) - }) - self.present(alertController, animated: true) - } - -} -extension ActivityListViewController: ActivityDetailPresenter { - - func presentDetailsFor(activity: FormattableActivity) { - let activityStoryboard = UIStoryboard(name: "Activity", bundle: nil) - guard let detailVC = activityStoryboard.instantiateViewController(withIdentifier: "ActivityDetailViewController") as? ActivityDetailViewController else { - return - } - - detailVC.site = site - detailVC.formattableActivity = activity - detailVC.rewindPresenter = self - - self.navigationController?.pushViewController(detailVC, animated: true) - } - -} - -// MARK: - Restores handling - -extension ActivityListViewController { - - fileprivate func restoreSiteToRewindID(_ rewindID: String) { - navigationController?.popToViewController(self, animated: true) - store.actionDispatcher.dispatch(ActivityAction.rewind(site: site, rewindID: rewindID)) - } -} - -// MARK: - NoResults Handling - -private extension ActivityListViewController { - - func updateNoResults() { - if let noResultsViewModel = viewModel.noResultsViewModel() { - showNoResults(noResultsViewModel) - } else { - noResultsViewController?.removeFromView() - } - } - - func showNoResults(_ viewModel: NoResultsViewController.Model) { - if noResultsViewController == nil { - noResultsViewController = NoResultsViewController.controller() - noResultsViewController?.delegate = self - } - - guard let noResultsViewController = noResultsViewController else { - return - } - - noResultsViewController.bindViewModel(viewModel) - - if noResultsViewController.view.superview != tableView { - tableView.addSubview(withFadeAnimation: noResultsViewController.view) - } - - addChild(noResultsViewController) - noResultsViewController.didMove(toParent: self) - - } - -} diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityListViewModel.swift b/WordPress/Classes/ViewRelated/Activity/ActivityListViewModel.swift index 5d09ba8a369c..74f0e61434a0 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityListViewModel.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityListViewModel.swift @@ -1,11 +1,10 @@ import WordPressFlux -protocol ActivityRewindPresenter: class { - func presentRewindFor(activity: Activity) -} - -protocol ActivityDetailPresenter: class { +protocol ActivityPresenter: AnyObject { func presentDetailsFor(activity: FormattableActivity) + func presentBackupOrRestoreFor(activity: Activity, from sender: UIButton) + func presentRestoreFor(activity: Activity, from: String?) + func presentBackupFor(activity: Activity, from: String?) } class ActivityListViewModel: Observable { @@ -17,8 +16,15 @@ class ActivityListViewModel: Observable { private let activitiesReceipt: Receipt private let rewindStatusReceipt: Receipt + private let noResultsTexts: ActivityListConfiguration private var storeReceipt: Receipt? + private var numberOfItemsPerPage = 20 + private var page = 0 + private(set) var after: Date? + private(set) var before: Date? + private(set) var selectedGroups: [ActivityGroup] = [] + var errorViewModel: NoResultsViewController.Model? private(set) var refreshing = false { didSet { @@ -28,9 +34,39 @@ class ActivityListViewModel: Observable { } } - init(site: JetpackSiteRef, store: ActivityStore = StoreContainer.shared.activity) { + var hasMore: Bool { + store.state.hasMore + } + + var dateFilterIsActive: Bool { + return after != nil || before != nil + } + + var groupFilterIsActive: Bool { + return !selectedGroups.isEmpty + } + + var isAnyFilterActive: Bool { + return dateFilterIsActive || groupFilterIsActive + } + + var groups: [ActivityGroup] { + return store.state.groups[site] ?? [] + } + + lazy var downloadPromptView: AppFeedbackPromptView = { + AppFeedbackPromptView() + }() + + init(site: JetpackSiteRef, + store: ActivityStore = StoreContainer.shared.activity, + configuration: ActivityListConfiguration) { self.site = site self.store = store + self.noResultsTexts = configuration + + numberOfItemsPerPage = configuration.numberOfItemsPerPage + store.numberOfItemsPerPage = numberOfItemsPerPage activitiesReceipt = store.query(.activities(site: site)) rewindStatusReceipt = store.query(.restoreStatus(site: site)) @@ -42,11 +78,50 @@ class ActivityListViewModel: Observable { private func updateState() { changeDispatcher.dispatch() - refreshing = store.isFetching(site: site) + refreshing = store.isFetchingActivities(site: site) + } + + public func refresh(after: Date? = nil, before: Date? = nil, group: [ActivityGroup] = []) { + store.fetchRewindStatus(site: site) + + ActionDispatcher.dispatch(ActivityAction.refreshBackupStatus(site: site)) + + // If a new filter is being applied, remove all activities + if isApplyingNewFilter(after: after, before: before, group: group) { + ActionDispatcher.dispatch(ActivityAction.resetActivities(site: site)) + } + + // If a new date range is being applied, remove the current activity types + if isApplyingDateFilter(after: after, before: before) { + ActionDispatcher.dispatch(ActivityAction.resetGroups(site: site)) + } + + self.page = 0 + self.after = after + self.before = before + self.selectedGroups = group + + ActionDispatcher.dispatch(ActivityAction.refreshActivities(site: site, quantity: numberOfItemsPerPage, afterDate: after, beforeDate: before, group: group.map { $0.key })) } - public func refresh() { - ActionDispatcher.dispatch(ActivityAction.refreshActivities(site: site)) + public func loadMore() { + if !store.isFetchingActivities(site: site) { + page += 1 + let offset = page * numberOfItemsPerPage + ActionDispatcher.dispatch(ActivityAction.loadMoreActivities(site: site, quantity: numberOfItemsPerPage, offset: offset, afterDate: after, beforeDate: before, group: selectedGroups.map { $0.key })) + } + } + + public func removeDateFilter() { + refresh(after: nil, before: nil, group: selectedGroups) + } + + public func removeGroupFilter() { + refresh(after: after, before: before, group: []) + } + + public func refreshGroups() { + ActionDispatcher.dispatch(ActivityAction.refreshGroups(site: site, afterDate: after, beforeDate: before)) } func noResultsViewModel() -> NoResultsViewController.Model? { @@ -55,12 +130,16 @@ class ActivityListViewModel: Observable { return nil } - if store.isFetching(site: site) { - return NoResultsViewController.Model(title: NoResultsText.loadingTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) + if store.isFetchingActivities(site: site) { + return NoResultsViewController.Model(title: noResultsTexts.loadingTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) } if let activites = store.getActivities(site: site), activites.isEmpty { - return NoResultsViewController.Model(title: NoResultsText.noActivitiesTitle, subtitle: NoResultsText.noActivitiesSubtitle) + if isAnyFilterActive { + return NoResultsViewController.Model(title: noResultsTexts.noMatchingTitle, subtitle: noResultsTexts.noMatchingSubtitle) + } else { + return NoResultsViewController.Model(title: noResultsTexts.noActivitiesTitle, subtitle: NoResultsText.noActivitiesSubtitle) + } } let appDelegate = WordPressAppDelegate.shared @@ -73,7 +152,31 @@ class ActivityListViewModel: Observable { } } - func tableViewModel(presenter: ActivityDetailPresenter) -> ImmuTable { + func noResultsGroupsViewModel() -> NoResultsViewController.Model? { + guard store.getGroups(site: site) == nil || + store.getGroups(site: site)?.isEmpty == true else { + return nil + } + + if store.isFetchingGroups(site: site) { + return NoResultsViewController.Model(title: noResultsTexts.loadingTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) + } + + if let groups = store.getGroups(site: site), groups.isEmpty { + return NoResultsViewController.Model(title: NoResultsText.noGroupsTitle, subtitle: NoResultsText.noGroupsSubtitle) + } + + let appDelegate = WordPressAppDelegate.shared + if (appDelegate?.connectionAvailable)! { + return NoResultsViewController.Model(title: NoResultsText.errorTitle, + subtitle: NoResultsText.errorSubtitle, + buttonText: NoResultsText.groupsErrorButtonText) + } else { + return NoResultsViewController.Model(title: NoResultsText.noConnectionTitle, subtitle: NoResultsText.noConnectionSubtitle) + } + } + + func tableViewModel(presenter: ActivityPresenter) -> ImmuTable { guard let activities = store.getActivities(site: site) else { return .Empty } @@ -83,6 +186,9 @@ class ActivityListViewModel: Observable { formattableActivity: formattableActivity, action: { [weak presenter] (row) in presenter?.presentDetailsFor(activity: formattableActivity) + }, + actionButtonHandler: { [weak presenter] (button) in + presenter?.presentBackupOrRestoreFor(activity: formattableActivity.activity, from: button) } ) }) @@ -98,13 +204,124 @@ class ActivityListViewModel: Observable { footerText: nil) } - return ImmuTable(optionalSections: [restoreStatusSection()] + activitiesSections) + return ImmuTable(optionalSections: [backupStatusSection(), restoreStatusSection()] + activitiesSections) // So far the only "extra" section is the restore one. In the future, this will include // showing plugin updates/CTA's and other things like this. } + func dateRangeDescription() -> String? { + guard after != nil || before != nil else { + return NSLocalizedString("Date Range", comment: "Label of a button that displays a calendar") + } + + let format = shouldDisplayFullYear(with: after, and: before) ? "MMM d, yyyy" : "MMM d" + dateFormatter.setLocalizedDateFormatFromTemplate(format) + + var formattedDateRanges: [String] = [] + + if let after = after { + formattedDateRanges.append(dateFormatter.string(from: after)) + } + + if let before = before { + formattedDateRanges.append(dateFormatter.string(from: before)) + } + + return formattedDateRanges.joined(separator: " - ") + } + + func backupDownloadHeader() -> UIView? { + guard let validUntil = store.getBackupStatus(site: site)?.validUntil, + Date() < validUntil, + let backupPoint = store.getBackupStatus(site: site)?.backupPoint, + let downloadURLString = store.getBackupStatus(site: site)?.url, + let downloadURL = URL(string: downloadURLString), + let downloadID = store.getBackupStatus(site: site)?.downloadID else { + return nil + } + + let headingMessage = NSLocalizedString("We successfully created a backup of your site as of %@", comment: "Message displayed when a backup has finished") + downloadPromptView.setupHeading(String.init(format: headingMessage, arguments: [longDateFormatterWithTime.string(from: backupPoint)])) + + let downloadTitle = NSLocalizedString("Download", comment: "Download button title") + downloadPromptView.setupYesButton(title: downloadTitle) { _ in + UIApplication.shared.open(downloadURL) + } + + let dismissTitle = NSLocalizedString( + "activityList.dismiss.title", + value: "Dismiss", + comment: "Dismiss button title" + ) + downloadPromptView.setupNoButton(title: dismissTitle) { [weak self] button in + guard let self = self else { + return + } + + ActionDispatcher.dispatch(ActivityAction.dismissBackupNotice(site: self.site, downloadID: downloadID)) + } + + return downloadPromptView + } + + func activityTypeDescription() -> String? { + if selectedGroups.isEmpty { + return NSLocalizedString("Activity Type", comment: "Label for the Activity Type filter button") + } else if selectedGroups.count > 1 { + return String.localizedStringWithFormat(NSLocalizedString("Activity Type (%1$d)", comment: "Label for the Activity Type filter button when there are more than 1 activity type selected"), selectedGroups.count) + } + + return selectedGroups.first?.name + } + + private func shouldDisplayFullYear(with firstDate: Date?, and secondDate: Date?) -> Bool { + guard let firstDate = firstDate, let secondDate = secondDate else { + return false + } + + let currentYear = Calendar.current.dateComponents([.year], from: Date()).year + let firstYear = Calendar.current.dateComponents([.year], from: firstDate).year + let secondYear = Calendar.current.dateComponents([.year], from: secondDate).year + + return firstYear != currentYear || secondYear != currentYear + } + + private func isApplyingNewFilter(after: Date? = nil, before: Date? = nil, group: [ActivityGroup]) -> Bool { + let isSameGroup = group.count == self.selectedGroups.count && self.selectedGroups.elementsEqual(group, by: { $0.key == $1.key }) + + return isApplyingDateFilter(after: after, before: before) || !isSameGroup + } + + private func isApplyingDateFilter(after: Date? = nil, before: Date? = nil) -> Bool { + after != self.after || before != self.before + } + + private func backupStatusSection() -> ImmuTableSection? { + guard let backup = store.getBackupStatus(site: site), let backupProgress = backup.progress else { + return nil + } + + let title = NSLocalizedString("Backing up site", comment: "Title of the cell displaying status of a backup in progress") + let summary: String + let progress = max(Float(backupProgress) / 100, 0.05) + // We don't want to show a completely empty progress bar — it'd seem something is broken. 5% looks acceptable + // for the starting state. + + summary = NSLocalizedString("Creating downloadable backup", comment: "Description of the cell displaying status of a backup in progress") + + let rewindRow = RewindStatusRow( + title: title, + summary: summary, + progress: progress + ) + + return ImmuTableSection(headerText: NSLocalizedString("Backup", comment: "Title of section showing backup status"), + rows: [rewindRow], + footerText: nil) + } + private func restoreStatusSection() -> ImmuTableSection? { - guard let restore = store.getRewindStatus(site: site)?.restore, restore.status == .running || restore.status == .queued else { + guard let restore = store.getCurrentRewindStatus(site: site)?.restore, restore.status == .running || restore.status == .queued else { return nil } @@ -116,8 +333,9 @@ class ActivityListViewModel: Observable { if let rewindPoint = store.getActivity(site: site, rewindID: restore.id) { let dateString = mediumDateFormatterWithTime.string(from: rewindPoint.published) - let messageFormat = NSLocalizedString("Rewinding to %@", - comment: "Text showing the point in time the site is being currently restored to. %@' is a placeholder that will expand to a date.") + + let messageFormat = NSLocalizedString("Restoring to %@", + comment: "Text showing the point in time the site is being currently restored to. %@' is a placeholder that will expand to a date.") summary = String(format: messageFormat, dateString) } else { @@ -130,29 +348,46 @@ class ActivityListViewModel: Observable { progress: progress ) - return ImmuTableSection(headerText: NSLocalizedString("Rewind", comment: "Title of section showing rewind status"), + let headerText = NSLocalizedString("Restore", comment: "Title of section showing restore status") + + return ImmuTableSection(headerText: headerText, rows: [rewindRow], footerText: nil) } private struct NoResultsText { - static let loadingTitle = NSLocalizedString("Loading Activities...", comment: "Text displayed while loading the activity feed for a site") - static let noActivitiesTitle = NSLocalizedString("No activity yet", comment: "Title for the view when there aren't any Activities to display in the Activity Log") static let noActivitiesSubtitle = NSLocalizedString("When you make changes to your site you'll be able to see your activity history here.", comment: "Text display when the view when there aren't any Activities to display in the Activity Log") static let errorTitle = NSLocalizedString("Oops", comment: "Title for the view when there's an error loading Activity Log") static let errorSubtitle = NSLocalizedString("There was an error loading activities", comment: "Text displayed when there is a failure loading the activity feed") static let errorButtonText = NSLocalizedString("Contact support", comment: "Button label for contacting support") static let noConnectionTitle = NSLocalizedString("No connection", comment: "Title for the error view when there's no connection") static let noConnectionSubtitle = NSLocalizedString("An active internet connection is required to view activities", comment: "Error message shown when trying to view the Activity Log feature and there is no internet connection.") + static let noGroupsTitle = NSLocalizedString("No activities available", comment: "Title for the view when there aren't any Activities Types to display in the Activity Log Types picker") + static let noGroupsSubtitle = NSLocalizedString("No activities recorded in the selected date range.", comment: "Text display in the view when there aren't any Activities Types to display in the Activity Log Types picker") + static let groupsErrorButtonText = NSLocalizedString("Try again", comment: "Button label for trying to retrieve the activities type again") } // MARK: - Date/Time handling lazy var longDateFormatterWithoutTime: DateFormatter = { - return ActivityDateFormatting.longDateFormatterWithoutTime(for: site) + return ActivityDateFormatting.longDateFormatter(for: site, withTime: false) + }() + + lazy var longDateFormatterWithTime: DateFormatter = { + return ActivityDateFormatting.longDateFormatter(for: site, withTime: true) }() lazy var mediumDateFormatterWithTime: DateFormatter = { return ActivityDateFormatting.mediumDateFormatterWithTime(for: site) }() + + lazy var dateFormatter: DateFormatter = { + DateFormatter() + }() +} + +extension ActivityGroup: Equatable { + public static func == (lhs: ActivityGroup, rhs: ActivityGroup) -> Bool { + lhs.key == rhs.key + } } diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift b/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift index 494349c0f172..c07cc268a6ee 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.swift @@ -2,7 +2,9 @@ import Foundation import Gridicons import WordPressShared.WPTableViewCell -open class ActivityTableViewCell: WPTableViewCell { +open class ActivityTableViewCell: WPTableViewCell, NibReusable { + + var actionButtonHandler: ((UIButton) -> Void)? // MARK: - Overwritten Methods @@ -11,22 +13,28 @@ open class ActivityTableViewCell: WPTableViewCell { assert(iconBackgroundImageView != nil) assert(contentLabel != nil) assert(summaryLabel != nil) - assert(rewindIcon != nil) - rewindIcon.image = rewindGridicon + assert(actionButton != nil) } // MARK: - Public Methods - func configureCell(_ formattableActivity: FormattableActivity) { + func configureCell(_ formattableActivity: FormattableActivity, displaysDate: Bool = false) { activity = formattableActivity.activity guard let activity = activity else { return } + dateLabel.isHidden = !displaysDate + bulletLabel.isHidden = !displaysDate + summaryLabel.text = activity.summary + dateLabel.text = activity.published.toMediumString() + bulletLabel.text = "\u{2022}" contentLabel.text = activity.text summaryLabel.textColor = .textSubtle + dateLabel.textColor = .textSubtle + bulletLabel.textColor = .textSubtle contentLabel.textColor = .text iconBackgroundImageView.backgroundColor = Style.getColorByActivityStatus(activity) @@ -38,8 +46,14 @@ open class ActivityTableViewCell: WPTableViewCell { } contentView.backgroundColor = Style.backgroundColor() - rewindIconContainer.isHidden = !activity.isRewindable + actionButtonContainer.isHidden = !activity.isRewindable || displaysDate + actionButton.setImage(actionGridicon, for: .normal) + actionButton.tintColor = .listIcon + actionButton.accessibilityIdentifier = "activity-cell-action-button" + } + @IBAction func didTapActionButton(_ sender: UIButton) { + actionButtonHandler?(sender) } typealias Style = WPStyleGuide.ActivityStyleGuide @@ -47,7 +61,9 @@ open class ActivityTableViewCell: WPTableViewCell { // MARK: - Private Properties fileprivate var activity: Activity? - fileprivate var rewindGridicon = Gridicon.iconOfType(.history) + fileprivate var actionGridicon: UIImage { + return UIImage.gridicon(.ellipsis) + } // MARK: - IBOutlets @@ -55,8 +71,10 @@ open class ActivityTableViewCell: WPTableViewCell { @IBOutlet fileprivate var iconImageView: UIImageView! @IBOutlet fileprivate var contentLabel: UILabel! @IBOutlet fileprivate var summaryLabel: UILabel! - @IBOutlet fileprivate var rewindIconContainer: UIView! - @IBOutlet fileprivate var rewindIcon: UIImageView! + @IBOutlet fileprivate var bulletLabel: UILabel! + @IBOutlet fileprivate var dateLabel: UILabel! + @IBOutlet fileprivate var actionButtonContainer: UIView! + @IBOutlet fileprivate var actionButton: UIButton! } open class RewindStatusTableViewCell: ActivityTableViewCell { @@ -78,10 +96,12 @@ open class RewindStatusTableViewCell: ActivityTableViewCell { summaryLabel.text = summary iconBackgroundImageView.backgroundColor = .primary - iconImageView.image = Gridicon.iconOfType(.noticeOutline).imageWithTintColor(.white) + iconImageView.image = UIImage.gridicon(.noticeOutline).imageWithTintColor(.white) iconImageView.isHidden = false - rewindIconContainer.isHidden = true + actionButtonContainer.isHidden = true + progressView.progressTintColor = .primary + progressView.trackTintColor = UIColor(light: (.primary(.shade5)), dark: (.primary(.shade80))) progressView.setProgress(progress, animated: true) } } diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.xib b/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.xib index b7312e36c18c..51adac74dba8 100644 --- a/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.xib +++ b/WordPress/Classes/ViewRelated/Activity/ActivityTableViewCell.xib @@ -1,11 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> @@ -15,7 +13,7 @@ <rect key="frame" x="0.0" y="0.0" width="320" height="61"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="Upg-EE-wRd" id="p6H-P7-J7f"> - <rect key="frame" x="0.0" y="0.0" width="320" height="60.5"/> + <rect key="frame" x="0.0" y="0.0" width="320" height="61"/> <autoresizingMask key="autoresizingMask"/> <subviews> <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="0Gm-n3-CNm" userLabel="icon" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> @@ -32,24 +30,41 @@ <constraint firstAttribute="width" constant="24" id="wQf-nD-j4p"/> </constraints> </imageView> - <stackView opaque="NO" contentMode="scaleToFill" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="myo-BJ-PwG"> - <rect key="frame" x="64" y="11" width="240" height="38.5"/> + <stackView opaque="NO" contentMode="scaleToFill" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="myo-BJ-PwG"> + <rect key="frame" x="64" y="11" width="240" height="39"/> <subviews> <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="fillProportionally" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="TCy-wp-wTe"> - <rect key="frame" x="0.0" y="0.0" width="218" height="38.5"/> + <rect key="frame" x="0.0" y="0.0" width="188" height="39"/> <subviews> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1000" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="218" translatesAutoresizingMaskIntoConstraints="NO" id="wDk-1k-6n4"> - <rect key="frame" x="0.0" y="0.0" width="218" height="19.5"/> + <rect key="frame" x="0.0" y="0.0" width="188" height="19.5"/> <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> <color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1000" verticalCompressionResistancePriority="749" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="218" translatesAutoresizingMaskIntoConstraints="NO" id="wVX-To-MrZ"> - <rect key="frame" x="0.0" y="21.5" width="218" height="17"/> - <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> - <color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> - <nil key="highlightedColor"/> - </label> + <stackView opaque="NO" contentMode="scaleToFill" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="p1g-Q7-EQK"> + <rect key="frame" x="0.0" y="21.5" width="188" height="17.5"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="1000" verticalCompressionResistancePriority="749" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="218" translatesAutoresizingMaskIntoConstraints="NO" id="wVX-To-MrZ"> + <rect key="frame" x="0.0" y="0.0" width="37.5" height="17.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1000" verticalCompressionResistancePriority="749" text="•" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="218" translatesAutoresizingMaskIntoConstraints="NO" id="qJJ-ML-pD7"> + <rect key="frame" x="41.5" y="0.0" width="7" height="17.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="249" verticalHuggingPriority="1000" verticalCompressionResistancePriority="749" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="218" translatesAutoresizingMaskIntoConstraints="NO" id="Y7Y-Kw-H2l"> + <rect key="frame" x="52.5" y="0.0" width="135.5" height="17.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> </subviews> <constraints> <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="38.5" id="pkC-3V-1HV"/> @@ -57,21 +72,24 @@ <edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="0.0" right="0.0"/> </stackView> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="mPY-S3-ymc"> - <rect key="frame" x="222" y="0.0" width="18" height="38.5"/> + <rect key="frame" x="196" y="0.0" width="44" height="39"/> <subviews> - <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="0lh-9o-ekV"> - <rect key="frame" x="0.0" y="10.5" width="18" height="18"/> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="L3G-1b-CDh"> + <rect key="frame" x="0.0" y="-2.5" width="44" height="44"/> <constraints> - <constraint firstAttribute="height" constant="18" id="LgG-Ii-fpv"/> - <constraint firstAttribute="width" constant="18" id="mV7-T4-hKK"/> + <constraint firstAttribute="width" secondItem="L3G-1b-CDh" secondAttribute="height" id="FB5-fM-wVF"/> + <constraint firstAttribute="width" constant="44" id="gTg-wm-xrf"/> </constraints> - </imageView> + <connections> + <action selector="didTapActionButton:" destination="Upg-EE-wRd" eventType="touchUpInside" id="qjd-lg-dH0"/> + </connections> + </button> </subviews> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <constraints> - <constraint firstAttribute="width" priority="999" constant="18" id="60X-ML-RFP"/> - <constraint firstItem="0lh-9o-ekV" firstAttribute="centerY" secondItem="mPY-S3-ymc" secondAttribute="centerY" id="Ago-3T-bGB"/> - <constraint firstItem="0lh-9o-ekV" firstAttribute="centerX" secondItem="mPY-S3-ymc" secondAttribute="centerX" id="K83-tQ-VsU"/> + <constraint firstAttribute="width" priority="999" constant="44" id="60X-ML-RFP"/> + <constraint firstItem="L3G-1b-CDh" firstAttribute="centerX" secondItem="mPY-S3-ymc" secondAttribute="centerX" id="m13-0y-mXS"/> + <constraint firstItem="L3G-1b-CDh" firstAttribute="centerY" secondItem="mPY-S3-ymc" secondAttribute="centerY" id="nwb-ZK-mHe"/> </constraints> </view> </subviews> @@ -94,11 +112,13 @@ </tableViewCellContentView> <inset key="separatorInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/> <connections> + <outlet property="actionButton" destination="L3G-1b-CDh" id="Opc-ZR-25M"/> + <outlet property="actionButtonContainer" destination="mPY-S3-ymc" id="qaR-1A-xJG"/> + <outlet property="bulletLabel" destination="qJJ-ML-pD7" id="gU6-XG-5Ik"/> <outlet property="contentLabel" destination="wDk-1k-6n4" id="iL5-j2-jY5"/> + <outlet property="dateLabel" destination="Y7Y-Kw-H2l" id="wMT-AV-WYG"/> <outlet property="iconBackgroundImageView" destination="0Gm-n3-CNm" id="fGa-Im-Uyk"/> <outlet property="iconImageView" destination="RzG-YS-PIa" id="ipG-B7-oYT"/> - <outlet property="rewindIcon" destination="0lh-9o-ekV" id="VWL-O9-f2T"/> - <outlet property="rewindIconContainer" destination="mPY-S3-ymc" id="qaR-1A-xJG"/> <outlet property="summaryLabel" destination="wVX-To-MrZ" id="mTZ-pq-Wt4"/> </connections> <point key="canvasLocation" x="34" y="49.5"/> diff --git a/WordPress/Classes/ViewRelated/Activity/ActivityTypeSelectorViewController.swift b/WordPress/Classes/ViewRelated/Activity/ActivityTypeSelectorViewController.swift new file mode 100644 index 000000000000..d67e6956e3dd --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/ActivityTypeSelectorViewController.swift @@ -0,0 +1,153 @@ +import Foundation +import WordPressFlux + +protocol ActivityTypeSelectorDelegate: AnyObject { + func didCancel(selectorViewController: ActivityTypeSelectorViewController) + func didSelect(selectorViewController: ActivityTypeSelectorViewController, groups: [ActivityGroup]) +} + +class ActivityTypeSelectorViewController: UITableViewController { + private let viewModel: ActivityListViewModel! + + private var storeReceipt: Receipt? + private var selectedGroupsKeys: [String] = [] + + private var noResultsViewController: NoResultsViewController? + + weak var delegate: ActivityTypeSelectorDelegate? + + init(viewModel: ActivityListViewModel) { + self.viewModel = viewModel + self.selectedGroupsKeys = viewModel.selectedGroups.map { $0.key } + super.init(style: .grouped) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + configureTableView() + + setupNavButtons() + + viewModel.refreshGroups() + + updateNoResults() + + storeReceipt = viewModel.store.onChange { [weak self] in + self?.tableView.reloadData() + self?.updateNoResults() + } + + title = NSLocalizedString("Filter by activity type", comment: "Title of a screen that shows activity types so the user can filter using them (eg.: posts, images, users)") + } + + private func configureTableView() { + tableView.cellLayoutMarginsFollowReadableWidth = true + tableView.register(WPTableViewCell.self, forCellReuseIdentifier: Constants.groupCellIdentifier) + WPStyleGuide.configureAutomaticHeightRows(for: tableView) + } + + private func setupNavButtons() { + let doneButton = UIBarButtonItem(title: NSLocalizedString("Done", comment: "Label for Done button"), style: .done, target: self, action: #selector(done)) + navigationItem.setRightBarButton(doneButton, animated: false) + + navigationItem.setLeftBarButton(UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)), animated: false) + } + + @objc private func done() { + let selectedGroups = viewModel.groups.filter { selectedGroupsKeys.contains($0.key) } + + delegate?.didSelect(selectorViewController: self, groups: selectedGroups) + } + + @objc private func cancel() { + delegate?.didCancel(selectorViewController: self) + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.groups.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: Constants.groupCellIdentifier, for: indexPath) as? WPTableViewCell else { + return UITableViewCell() + } + + let activityGroup = viewModel.groups[indexPath.row] + + cell.textLabel?.text = "\(activityGroup.name) (\(activityGroup.count))" + cell.accessoryType = selectedGroupsKeys.contains(activityGroup.key) ? .checkmark : .none + + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let cell = tableView.cellForRow(at: indexPath) + + let selectedGroupKey = viewModel.groups[indexPath.row].key + + if selectedGroupsKeys.contains(selectedGroupKey) { + cell?.accessoryType = .none + selectedGroupsKeys = selectedGroupsKeys.filter { $0 != selectedGroupKey } + } else { + cell?.accessoryType = .checkmark + selectedGroupsKeys.append(selectedGroupKey) + } + } + + + private enum Constants { + static let groupCellIdentifier = "GroupCellIdentifier" + } +} + +// MARK: - NoResults Handling + +private extension ActivityTypeSelectorViewController { + + func updateNoResults() { + if let noResultsViewModel = viewModel.noResultsGroupsViewModel() { + showNoResults(noResultsViewModel) + } else { + noResultsViewController?.view.isHidden = true + } + } + + func showNoResults(_ viewModel: NoResultsViewController.Model) { + if noResultsViewController == nil { + noResultsViewController = NoResultsViewController.controller() + noResultsViewController?.delegate = self + + guard let noResultsViewController = noResultsViewController else { + return + } + + if noResultsViewController.view.superview != tableView { + tableView.addSubview(withFadeAnimation: noResultsViewController.view) + } + + addChild(noResultsViewController) + } + + noResultsViewController?.bindViewModel(viewModel) + noResultsViewController?.didMove(toParent: self) + noResultsViewController?.view.translatesAutoresizingMaskIntoConstraints = false + tableView.pinSubviewToSafeArea(noResultsViewController!.view) + noResultsViewController?.view.isHidden = false + } + +} + +// MARK: - NoResultsViewControllerDelegate + +extension ActivityTypeSelectorViewController: NoResultsViewControllerDelegate { + func actionButtonPressed() { + viewModel.refreshGroups() + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController+JetpackBannerViewController.swift b/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController+JetpackBannerViewController.swift new file mode 100644 index 000000000000..5325fa684eca --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController+JetpackBannerViewController.swift @@ -0,0 +1,19 @@ +import Foundation + +extension BackupListViewController { + @objc + static func withJPBannerForBlog(_ blog: Blog) -> UIViewController? { + guard let backupListVC = BackupListViewController(blog: blog) else { + return nil + } + backupListVC.navigationItem.largeTitleDisplayMode = .never + return JetpackBannerWrapperViewController(childVC: backupListVC, screen: .backup) + } + + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + super.scrollViewDidScroll(scrollView) + if let jetpackBannerWrapper = parent as? JetpackBannerWrapperViewController { + jetpackBannerWrapper.processJetpackBannerVisibility(scrollView) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController.swift b/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController.swift new file mode 100644 index 000000000000..dd792e2f0ff4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Backup/BackupListViewController.swift @@ -0,0 +1,54 @@ +import Foundation +import Combine + +class BackupListViewController: BaseActivityListViewController { + + override init(site: JetpackSiteRef, store: ActivityStore, isFreeWPCom: Bool = false) { + store.onlyRestorableItems = true + + let activityListConfiguration = ActivityListConfiguration( + identifier: "backup", + title: NSLocalizedString("Backup", comment: "Title for the Jetpack's backup list"), + loadingTitle: NSLocalizedString("Loading Backups...", comment: "Text displayed while loading the activity feed for a site"), + noActivitiesTitle: NSLocalizedString("Your first backup will be ready soon", comment: "Title for the view when there aren't any Backups to display"), + noActivitiesSubtitle: NSLocalizedString("Your first backup will appear here within 24 hours and you will receive a notification once the backup has been completed", comment: "Text displayed in the view when there aren't any Backups to display"), + noMatchingTitle: NSLocalizedString("No matching backups found", comment: "Title for the view when there aren't any backups to display for a given filter."), + noMatchingSubtitle: NSLocalizedString("Try adjusting your date range filter", comment: "Text displayed in the view when there aren't any backups to display for a given filter."), + filterbarRangeButtonTapped: .backupFilterbarRangeButtonTapped, + filterbarSelectRange: .backupFilterbarSelectRange, + filterbarResetRange: .backupFilterbarResetRange, + numberOfItemsPerPage: 100 + ) + + super.init(site: site, store: store, configuration: activityListConfiguration, isFreeWPCom: isFreeWPCom) + + activityTypeFilterChip.isHidden = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc convenience init?(blog: Blog) { + precondition(blog.dotComID != nil) + guard let siteRef = JetpackSiteRef(blog: blog) else { + return nil + } + + + let isFreeWPCom = blog.isHostedAtWPcom && !blog.hasPaidPlan + self.init(site: siteRef, store: StoreContainer.shared.activity, isFreeWPCom: isFreeWPCom) + } + + // MARK: - View lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + extendedLayoutIncludesOpaqueBars = true + + tableView.accessibilityIdentifier = "jetpack-backup-table" + + WPAnalytics.track(.backupListOpened) + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/BaseActivityListViewController.swift b/WordPress/Classes/ViewRelated/Activity/BaseActivityListViewController.swift new file mode 100644 index 000000000000..e115d49b668f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/BaseActivityListViewController.swift @@ -0,0 +1,573 @@ +import Foundation +import CocoaLumberjack +import SVProgressHUD +import WordPressShared +import WordPressFlux + +struct ActivityListConfiguration { + /// An identifier of the View Controller + let identifier: String + + /// The title of the View Controller + let title: String + + /// The title for when loading activities + let loadingTitle: String + + /// Title for when there are no activities + let noActivitiesTitle: String + + /// Subtitle for when there are no activities + let noActivitiesSubtitle: String + + /// Title for when there are no activities for the selected filter + let noMatchingTitle: String + + /// Subtitle for when there are no activities for the selected filter + let noMatchingSubtitle: String + + /// Event to be fired when the date range button is tapped + let filterbarRangeButtonTapped: WPAnalyticsEvent + + /// Event to be fired when a date range is selected + let filterbarSelectRange: WPAnalyticsEvent + + /// Event to be fired when the range date reset button is tapped + let filterbarResetRange: WPAnalyticsEvent + + /// The number of items to be requested for each page + let numberOfItemsPerPage: Int +} + +/// ActivityListViewController is used as a base ViewController for +/// Jetpack's Activity Log and Backup +/// +class BaseActivityListViewController: UIViewController, TableViewContainer, ImmuTablePresenter { + let site: JetpackSiteRef + let store: ActivityStore + let configuration: ActivityListConfiguration + let isFreeWPCom: Bool + + var changeReceipt: Receipt? + var isUserTriggeredRefresh: Bool = false + + let containerStackView = UIStackView() + + let filterView = FilterBarView() + let dateFilterChip = FilterChipButton() + let activityTypeFilterChip = FilterChipButton() + + var tableView: UITableView = UITableView() + let refreshControl = UIRefreshControl() + + let numberOfItemsPerPage = 100 + + fileprivate lazy var handler: ImmuTableViewHandler = { + return ImmuTableViewHandler(takeOver: self, with: self) + }() + + private lazy var spinner: UIActivityIndicatorView = { + let spinner = UIActivityIndicatorView(style: .medium) + spinner.startAnimating() + spinner.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44) + return spinner + }() + + var viewModel: ActivityListViewModel + private enum Constants { + static let estimatedRowHeight: CGFloat = 62 + } + + // MARK: - GUI + + fileprivate var noResultsViewController: NoResultsViewController? + + // MARK: - Constructors + + init(site: JetpackSiteRef, + store: ActivityStore, + isFreeWPCom: Bool = false) { + fatalError("A configuration struct needs to be provided") + } + + init(site: JetpackSiteRef, + store: ActivityStore, + configuration: ActivityListConfiguration, + isFreeWPCom: Bool = false) { + self.site = site + self.store = store + self.isFreeWPCom = isFreeWPCom + self.configuration = configuration + self.viewModel = ActivityListViewModel(site: site, store: store, configuration: configuration) + + super.init(nibName: nil, bundle: nil) + + self.changeReceipt = viewModel.onChange { [weak self] in + self?.refreshModel() + } + + view.addSubview(containerStackView) + containerStackView.axis = .vertical + + if site.shouldShowActivityLogFilter() { + setupFilterBar() + } + + containerStackView.addArrangedSubview(tableView) + + containerStackView.translatesAutoresizingMaskIntoConstraints = false + view.pinSubviewToSafeArea(containerStackView) + + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(userRefresh), for: .valueChanged) + + title = configuration.title + } + + @objc private func showCalendar() { + let calendarViewController = CalendarViewController(startDate: viewModel.after, endDate: viewModel.before) + calendarViewController.delegate = self + let navigationController = UINavigationController(rootViewController: calendarViewController) + present(navigationController, animated: true, completion: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + refreshModel() + + tableView.estimatedRowHeight = Constants.estimatedRowHeight + + WPStyleGuide.configureColors(view: view, tableView: tableView) + + let nib = UINib(nibName: ActivityListSectionHeaderView.identifier, bundle: nil) + tableView.register(nib, forHeaderFooterViewReuseIdentifier: ActivityListSectionHeaderView.identifier) + ImmuTable.registerRows([ActivityListRow.self, RewindStatusRow.self], tableView: tableView) + + tableView.tableFooterView = spinner + tableView.tableFooterView?.isHidden = true + } + + override func viewWillDisappear(_ animated: Bool) { + SVProgressHUD.dismiss() + } + + @objc func userRefresh() { + isUserTriggeredRefresh = true + viewModel.refresh(after: viewModel.after, before: viewModel.before, group: viewModel.selectedGroups) + } + + func refreshModel() { + updateHeader() + handler.viewModel = viewModel.tableViewModel(presenter: self) + updateRefreshControl() + updateNoResults() + updateFilters() + } + + private func updateHeader() { + tableView.tableHeaderView = viewModel.backupDownloadHeader() + + guard let tableHeaderView = tableView.tableHeaderView else { + return + } + + tableHeaderView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + tableHeaderView.topAnchor.constraint(equalTo: tableView.topAnchor), + tableHeaderView.safeLeadingAnchor.constraint(equalTo: tableView.safeLeadingAnchor), + tableHeaderView.safeTrailingAnchor.constraint(equalTo: tableView.safeTrailingAnchor) + ]) + tableView.tableHeaderView?.layoutIfNeeded() + } + + private func updateRefreshControl() { + switch (viewModel.refreshing, refreshControl.isRefreshing) { + case (true, false): + if isUserTriggeredRefresh { + refreshControl.beginRefreshing() + isUserTriggeredRefresh = false + } else if tableView.numberOfSections > 0 { + tableView.tableFooterView?.isHidden = false + } + case (false, true): + refreshControl.endRefreshing() + default: + tableView.tableFooterView?.isHidden = true + break + } + } + + private func updateFilters() { + viewModel.dateFilterIsActive ? dateFilterChip.enableResetButton() : dateFilterChip.disableResetButton() + dateFilterChip.title = viewModel.dateRangeDescription() + + viewModel.groupFilterIsActive ? activityTypeFilterChip.enableResetButton() : activityTypeFilterChip.disableResetButton() + activityTypeFilterChip.title = viewModel.activityTypeDescription() + } + + private func setupFilterBar() { + containerStackView.addArrangedSubview(filterView) + + filterView.add(button: dateFilterChip) + filterView.add(button: activityTypeFilterChip) + + setupDateFilter() + setupActivityTypeFilter() + } + + private func setupDateFilter() { + dateFilterChip.resetButton.accessibilityLabel = NSLocalizedString("Reset Date Range filter", comment: "Accessibility label for the reset date range button") + + dateFilterChip.tapped = { [unowned self] in + WPAnalytics.track(self.configuration.filterbarRangeButtonTapped) + self.showCalendar() + } + + dateFilterChip.resetTapped = { [unowned self] in + WPAnalytics.track(self.configuration.filterbarResetRange) + self.viewModel.removeDateFilter() + self.dateFilterChip.disableResetButton() + } + } + + private func setupActivityTypeFilter() { + activityTypeFilterChip.resetButton.accessibilityLabel = NSLocalizedString("Reset Activity Type filter", comment: "Accessibility label for the reset activity type button") + + activityTypeFilterChip.tapped = { [weak self] in + guard let self = self else { + return + } + + WPAnalytics.track(.activitylogFilterbarTypeButtonTapped) + + let activityTypeSelectorViewController = ActivityTypeSelectorViewController( + viewModel: self.viewModel + ) + activityTypeSelectorViewController.delegate = self + let navigationController = UINavigationController(rootViewController: activityTypeSelectorViewController) + self.present(navigationController, animated: true, completion: nil) + } + + activityTypeFilterChip.resetTapped = { [weak self] in + WPAnalytics.track(.activitylogFilterbarResetType) + self?.viewModel.removeGroupFilter() + self?.activityTypeFilterChip.disableResetButton() + } + } + +} + +extension BaseActivityListViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + handler.tableView(tableView, numberOfRowsInSection: section) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + handler.tableView(tableView, cellForRowAt: indexPath) + } +} + +// MARK: - UITableViewDelegate + +extension BaseActivityListViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + let isLastSection = handler.viewModel.sections.count == section + 1 + + guard isFreeWPCom, isLastSection, let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: ActivityListSectionHeaderView.identifier) as? ActivityListSectionHeaderView else { + return nil + } + + cell.separator.isHidden = true + cell.titleLabel.text = NSLocalizedString("Since you're on a free plan, you'll see limited events in your Activity Log.", comment: "Text displayed as a footer of a table view with Activities when user is on a free plan") + + return cell + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + let isLastSection = handler.viewModel.sections.count == section + 1 + + guard isFreeWPCom, isLastSection else { + return 0.0 + } + + return UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: ActivityListSectionHeaderView.identifier) as? ActivityListSectionHeaderView else { + return nil + } + + cell.titleLabel.text = handler.tableView(tableView, titleForHeaderInSection: section)?.localizedUppercase + + return cell + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return ActivityListSectionHeaderView.height + } + + func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + guard let row = handler.viewModel.rowAtIndexPath(indexPath) as? ActivityListRow else { + return false + } + + return row.activity.isRewindable + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetY = scrollView.contentOffset.y + let contentHeight = scrollView.contentSize.height + let shouldLoadMore = offsetY > contentHeight - (2 * scrollView.frame.size.height) && viewModel.hasMore + + if shouldLoadMore { + viewModel.loadMore() + } + } + +} + +// MARK: - NoResultsViewControllerDelegate + +extension BaseActivityListViewController: NoResultsViewControllerDelegate { + func actionButtonPressed() { + let supportVC = SupportTableViewController() + supportVC.showFromTabBar() + } +} + +// MARK: - ActivityPresenter + +extension BaseActivityListViewController: ActivityPresenter { + + func presentDetailsFor(activity: FormattableActivity) { + let detailVC = ActivityDetailViewController.loadFromStoryboard() + + detailVC.site = site + detailVC.rewindStatus = store.state.rewindStatus[site] + detailVC.formattableActivity = activity + detailVC.presenter = self + + self.navigationController?.pushViewController(detailVC, animated: true) + } + + func presentBackupOrRestoreFor(activity: Activity, from sender: UIButton) { + let rewindStatus = store.state.rewindStatus[site] + + let title = rewindStatus?.isMultisite() == true ? RewindStatus.Strings.multisiteNotAvailable : nil + + let alertController = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) + + if rewindStatus?.state == .active { + let restoreTitle = NSLocalizedString("Restore", comment: "Title displayed for restore action.") + + let restoreOptionsVC = JetpackRestoreOptionsViewController(site: site, + activity: activity, + isAwaitingCredentials: store.isAwaitingCredentials(site: site)) + restoreOptionsVC.restoreStatusDelegate = self + restoreOptionsVC.presentedFrom = configuration.identifier + alertController.addDefaultActionWithTitle(restoreTitle, handler: { _ in + self.present(UINavigationController(rootViewController: restoreOptionsVC), animated: true) + }) + } + + let backupTitle = NSLocalizedString("Download backup", comment: "Title displayed for download backup action.") + let backupOptionsVC = JetpackBackupOptionsViewController(site: site, activity: activity) + backupOptionsVC.backupStatusDelegate = self + backupOptionsVC.presentedFrom = configuration.identifier + alertController.addDefaultActionWithTitle(backupTitle, handler: { _ in + self.present(UINavigationController(rootViewController: backupOptionsVC), animated: true) + }) + if let backupAction = alertController.actions.last { + backupAction.accessibilityIdentifier = "jetpack-download-backup-button" + } + + let cancelTitle = NSLocalizedString("Cancel", comment: "Title for cancel action. Dismisses the action sheet.") + alertController.addCancelActionWithTitle(cancelTitle) + + if let presentationController = alertController.popoverPresentationController { + presentationController.permittedArrowDirections = .any + presentationController.sourceView = sender + presentationController.sourceRect = sender.bounds + } + + self.present(alertController, animated: true, completion: nil) + } + + func presentRestoreFor(activity: Activity, from: String? = nil) { + guard activity.isRewindable, activity.rewindID != nil else { + return + } + + let restoreOptionsVC = JetpackRestoreOptionsViewController(site: site, + activity: activity, + isAwaitingCredentials: store.isAwaitingCredentials(site: site)) + + restoreOptionsVC.restoreStatusDelegate = self + restoreOptionsVC.presentedFrom = from ?? configuration.identifier + let navigationVC = UINavigationController(rootViewController: restoreOptionsVC) + self.present(navigationVC, animated: true) + } + + func presentBackupFor(activity: Activity, from: String? = nil) { + let backupOptionsVC = JetpackBackupOptionsViewController(site: site, activity: activity) + backupOptionsVC.backupStatusDelegate = self + backupOptionsVC.presentedFrom = from ?? configuration.identifier + let navigationVC = UINavigationController(rootViewController: backupOptionsVC) + self.present(navigationVC, animated: true) + } +} + +// MARK: - Restores handling + +extension BaseActivityListViewController { + + fileprivate func restoreSiteToRewindID(_ rewindID: String) { + navigationController?.popToViewController(self, animated: true) + store.actionDispatcher.dispatch(ActivityAction.rewind(site: site, rewindID: rewindID)) + } +} + +// MARK: - NoResults Handling + +private extension BaseActivityListViewController { + + func updateNoResults() { + if let noResultsViewModel = viewModel.noResultsViewModel() { + showNoResults(noResultsViewModel) + } else { + noResultsViewController?.view.isHidden = true + } + } + + func showNoResults(_ viewModel: NoResultsViewController.Model) { + if noResultsViewController == nil { + noResultsViewController = NoResultsViewController.controller() + noResultsViewController?.delegate = self + + guard let noResultsViewController = noResultsViewController else { + return + } + + if noResultsViewController.view.superview != tableView { + tableView.addSubview(withFadeAnimation: noResultsViewController.view) + } + + addChild(noResultsViewController) + + noResultsViewController.view.translatesAutoresizingMaskIntoConstraints = false + } + + noResultsViewController?.bindViewModel(viewModel) + noResultsViewController?.didMove(toParent: self) + tableView.pinSubviewToSafeArea(noResultsViewController!.view) + noResultsViewController?.view.isHidden = false + } + +} + +// MARK: - Restore Status Handling + +extension BaseActivityListViewController: JetpackRestoreStatusViewControllerDelegate { + + func didFinishViewing(_ controller: JetpackRestoreStatusViewController) { + controller.dismiss(animated: true, completion: { [weak self] in + guard let self = self else { + return + } + self.store.fetchRewindStatus(site: self.site) + }) + } +} + +// MARK: - Restore Status Handling + +extension BaseActivityListViewController: JetpackBackupStatusViewControllerDelegate { + + func didFinishViewing() { + viewModel.refresh() + } +} + +// MARK: - Calendar Handling +extension BaseActivityListViewController: CalendarViewControllerDelegate { + func didCancel(calendar: CalendarViewController) { + calendar.dismiss(animated: true, completion: nil) + } + + func didSelect(calendar: CalendarViewController, startDate: Date?, endDate: Date?) { + guard startDate != viewModel.after || endDate != viewModel.before else { + calendar.dismiss(animated: true, completion: nil) + return + } + + trackSelectedRange(startDate: startDate, endDate: endDate) + + viewModel.refresh(after: startDate, before: endDate, group: viewModel.selectedGroups) + calendar.dismiss(animated: true, completion: nil) + } + + private func trackSelectedRange(startDate: Date?, endDate: Date?) { + guard let startDate = startDate else { + if viewModel.after != nil || viewModel.before != nil { + WPAnalytics.track(configuration.filterbarResetRange) + } + + return + } + + var duration: Int // Number of selected days + var distance: Int // Distance from the startDate to today (in days) + + if let endDate = endDate { + duration = Int((endDate.timeIntervalSinceReferenceDate - startDate.timeIntervalSinceReferenceDate) / Double(24 * 60 * 60)) + 1 + } else { + duration = 1 + } + + distance = Int((Date().timeIntervalSinceReferenceDate - startDate.timeIntervalSinceReferenceDate) / Double(24 * 60 * 60)) + + WPAnalytics.track(configuration.filterbarSelectRange, properties: ["duration": duration, "distance": distance]) + } +} + +// MARK: - Activity type filter handling +extension BaseActivityListViewController: ActivityTypeSelectorDelegate { + func didCancel(selectorViewController: ActivityTypeSelectorViewController) { + selectorViewController.dismiss(animated: true, completion: nil) + } + + func didSelect(selectorViewController: ActivityTypeSelectorViewController, groups: [ActivityGroup]) { + guard groups != viewModel.selectedGroups else { + selectorViewController.dismiss(animated: true, completion: nil) + return + } + + trackSelectedGroups(groups) + + viewModel.refresh(after: viewModel.after, before: viewModel.before, group: groups) + selectorViewController.dismiss(animated: true, completion: nil) + } + + private func trackSelectedGroups(_ selectedGroups: [ActivityGroup]) { + if !viewModel.selectedGroups.isEmpty && selectedGroups.isEmpty { + WPAnalytics.track(.activitylogFilterbarResetType) + } else { + let totalActivitiesSelected = selectedGroups.map { $0.count }.reduce(0, +) + var selectTypeProperties: [AnyHashable: Any] = [:] + selectedGroups.forEach { selectTypeProperties["group_\($0.key)"] = true } + selectTypeProperties["num_groups_selected"] = selectedGroups.count + selectTypeProperties["num_total_activities_selected"] = totalActivitiesSelected + WPAnalytics.track(.activitylogFilterbarSelectType, properties: selectTypeProperties) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/CalendarViewController.swift b/WordPress/Classes/ViewRelated/Activity/CalendarViewController.swift new file mode 100644 index 000000000000..ba50e1464f1e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/CalendarViewController.swift @@ -0,0 +1,281 @@ +import UIKit + +protocol CalendarViewControllerDelegate: AnyObject { + func didCancel(calendar: CalendarViewController) + func didSelect(calendar: CalendarViewController, startDate: Date?, endDate: Date?) +} + +class CalendarViewController: UIViewController { + + private var calendarCollectionView: CalendarCollectionView! + private var startDateLabel: UILabel! + private var separatorDateLabel: UILabel! + private var endDateLabel: UILabel! + private var header: UIStackView! + private let gradient = GradientView() + + private var startDate: Date? + private var endDate: Date? + + weak var delegate: CalendarViewControllerDelegate? + + private lazy var formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return formatter + }() + + private enum Constants { + static let headerPadding: CGFloat = 16 + static let endDateLabel = NSLocalizedString("End Date", comment: "Placeholder for the end date in calendar range selection") + static let startDateLabel = NSLocalizedString("Start Date", comment: "Placeholder for the start date in calendar range selection") + static let rangeSummaryAccessibilityLabel = NSLocalizedString( + "Selected range: %1$@ to %2$@", + comment: "Accessibility label for summary of currently selected range. %1$@ is the start date, %2$@ is " + + "the end date.") + static let singleDateRangeSummaryAccessibilityLabel = NSLocalizedString( + "Selected range: %1$@ only", + comment: "Accessibility label for summary of currently single date. %1$@ is the date") + static let noRangeSelectedAccessibilityLabelPlaceholder = NSLocalizedString( + "No date range selected", + comment: "Accessibility label for no currently selected range.") + } + + /// Creates a full screen year calendar controller + /// + /// - Parameters: + /// - startDate: An optional Date representing the first selected date + /// - endDate: An optional Date representing the end selected date + init(startDate: Date? = nil, endDate: Date? = nil) { + self.startDate = startDate + self.endDate = endDate + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override func viewDidLoad() { + title = NSLocalizedString("Choose date range", comment: "Title to choose date range in a calendar") + + // Configure Calendar + let calendar = Calendar.current + self.calendarCollectionView = CalendarCollectionView( + calendar: calendar, + style: .year, + startDate: startDate, + endDate: endDate + ) + + // Configure headers and add the calendar to the view + configureHeader() + let stackView = UIStackView(arrangedSubviews: [ + header, + calendarCollectionView + ]) + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.setCustomSpacing(Constants.headerPadding, after: header) + view.addSubview(stackView) + view.pinSubviewToAllEdges(stackView, insets: UIEdgeInsets(top: Constants.headerPadding, left: 0, bottom: 0, right: 0)) + view.backgroundColor = .basicBackground + + setupNavButtons() + + setUpGradient() + + calendarCollectionView.calDataSource.didSelect = { [weak self] startDate, endDate in + self?.updateDates(startDate: startDate, endDate: endDate) + } + + calendarCollectionView.scrollsToTop = false + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + scrollToVisibleDate() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate(alongsideTransition: { _ in + self.calendarCollectionView.reloadData(withAnchor: self.startDate ?? Date(), completionHandler: nil) + }, completion: nil) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + setUpGradientColors() + } + + private func setupNavButtons() { + let doneButton = UIBarButtonItem(title: NSLocalizedString("Done", comment: "Label for Done button"), style: .done, target: self, action: #selector(done)) + navigationItem.setRightBarButton(doneButton, animated: false) + + navigationItem.setLeftBarButton(UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel)), animated: false) + } + + private func updateDates(startDate: Date?, endDate: Date?) { + self.startDate = startDate + self.endDate = endDate + + updateLabels() + } + + private func updateLabels() { + guard let startDate = startDate else { + resetLabels() + return + } + + startDateLabel.text = formatter.string(from: startDate) + startDateLabel.textColor = .text + startDateLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + + if let endDate = endDate { + endDateLabel.text = formatter.string(from: endDate) + endDateLabel.textColor = .text + endDateLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + separatorDateLabel.textColor = .text + separatorDateLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + } else { + endDateLabel.text = Constants.endDateLabel + endDateLabel.font = WPStyleGuide.fontForTextStyle(.title3) + endDateLabel.textColor = .textSubtle + separatorDateLabel.textColor = .textSubtle + } + + header.accessibilityLabel = accessibilityLabelForRangeSummary(startDate: startDate, endDate: endDate) + } + + private func configureHeader() { + header = startEndDateHeader() + resetLabels() + } + + private func startEndDateHeader() -> UIStackView { + let header = UIStackView(frame: .zero) + header.distribution = .fill + + let startDate = UILabel() + startDate.isAccessibilityElement = false + startDateLabel = startDate + startDate.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + if view.effectiveUserInterfaceLayoutDirection == .leftToRight { + // swiftlint:disable:next inverse_text_alignment + startDate.textAlignment = .right + } else { + // swiftlint:disable:next natural_text_alignment + startDate.textAlignment = .left + } + header.addArrangedSubview(startDate) + startDate.widthAnchor.constraint(equalTo: header.widthAnchor, multiplier: 0.47).isActive = true + + let separator = UILabel() + separator.isAccessibilityElement = false + separatorDateLabel = separator + separator.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + separator.textAlignment = .center + header.addArrangedSubview(separator) + separator.widthAnchor.constraint(equalTo: header.widthAnchor, multiplier: 0.06).isActive = true + + let endDate = UILabel() + endDate.isAccessibilityElement = false + endDateLabel = endDate + endDate.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + if view.effectiveUserInterfaceLayoutDirection == .leftToRight { + // swiftlint:disable:next natural_text_alignment + endDate.textAlignment = .left + } else { + // swiftlint:disable:next inverse_text_alignment + endDate.textAlignment = .right + } + header.addArrangedSubview(endDate) + endDate.widthAnchor.constraint(equalTo: header.widthAnchor, multiplier: 0.47).isActive = true + + header.isAccessibilityElement = true + header.accessibilityTraits = [.header, .summaryElement] + + return header + } + + private func scrollToVisibleDate() { + if calendarCollectionView.frame.height == 0 { + calendarCollectionView.superview?.layoutIfNeeded() + } + + if let startDate = startDate { + calendarCollectionView.scrollToDate(startDate, + animateScroll: true, + preferredScrollPosition: .centeredVertically, + extraAddedOffset: -(self.calendarCollectionView.frame.height / 2)) + } else { + calendarCollectionView.setContentOffset(CGPoint( + x: 0, + y: calendarCollectionView.contentSize.height - calendarCollectionView.frame.size.height + ), animated: false) + } + + } + + private func resetLabels() { + startDateLabel.text = Constants.startDateLabel + + separatorDateLabel.text = "-" + + endDateLabel.text = Constants.endDateLabel + + [startDateLabel, separatorDateLabel, endDateLabel].forEach { label in + label?.textColor = .textSubtle + label?.font = WPStyleGuide.fontForTextStyle(.title3) + } + + header.accessibilityLabel = accessibilityLabelForRangeSummary(startDate: nil, endDate: nil) + } + + private func accessibilityLabelForRangeSummary(startDate: Date?, endDate: Date?) -> String { + switch (startDate, endDate) { + case (nil, _): + return Constants.noRangeSelectedAccessibilityLabelPlaceholder + case (.some(let startDate), nil): + let startDateString = formatter.string(from: startDate) + return String.localizedStringWithFormat(Constants.singleDateRangeSummaryAccessibilityLabel, startDateString) + case (.some(let startDate), .some(let endDate)): + let startDateString = formatter.string(from: startDate) + let endDateString = formatter.string(from: endDate) + return String.localizedStringWithFormat(Constants.rangeSummaryAccessibilityLabel, startDateString, endDateString) + } + } + + private func setUpGradient() { + gradient.isUserInteractionEnabled = false + gradient.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(gradient) + + NSLayoutConstraint.activate([ + gradient.heightAnchor.constraint(equalToConstant: 50), + gradient.topAnchor.constraint(equalTo: calendarCollectionView.topAnchor), + gradient.leadingAnchor.constraint(equalTo: calendarCollectionView.leadingAnchor), + gradient.trailingAnchor.constraint(equalTo: calendarCollectionView.trailingAnchor) + ]) + + setUpGradientColors() + } + + private func setUpGradientColors() { + gradient.fromColor = .basicBackground + gradient.toColor = UIColor.basicBackground.withAlphaComponent(0) + } + + @objc private func done() { + delegate?.didSelect(calendar: self, startDate: startDate, endDate: endDate) + } + + @objc private func cancel() { + delegate?.didCancel(calendar: self) + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/Filter/FilterBarView.swift b/WordPress/Classes/ViewRelated/Activity/Filter/FilterBarView.swift new file mode 100644 index 000000000000..0d6bcd2eee5d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Filter/FilterBarView.swift @@ -0,0 +1,72 @@ +import UIKit + +class FilterBarView: UIScrollView { + let filterStackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + + filterStackView.alignment = .center + filterStackView.spacing = Constants.filterStackViewSpacing + filterStackView.translatesAutoresizingMaskIntoConstraints = false + + let filterIcon = UIImageView(image: UIImage.gridicon(.filter)) + filterIcon.tintColor = .listIcon + filterIcon.heightAnchor.constraint(equalToConstant: Constants.filterHeightAnchor).isActive = true + + filterStackView.addArrangedSubview(filterIcon) + + canCancelContentTouches = true + showsHorizontalScrollIndicator = false + addSubview(filterStackView) + + NSLayoutConstraint.activate([ + filterStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.filterBarHorizontalPadding), + filterStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -1 * Constants.filterBarHorizontalPadding), + filterStackView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.filterBarVerticalPadding), + filterStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: Constants.filterBarVerticalPadding), + heightAnchor.constraint(equalTo: filterStackView.heightAnchor, constant: 2 * Constants.filterBarVerticalPadding) + ]) + + // Ensure that the stackview is right aligned in RTL layouts + if userInterfaceLayoutDirection() == .rightToLeft { + transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) + filterStackView.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) + } + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + + guard let superview = superview else { + return + } + + let separator = UIView() + separator.translatesAutoresizingMaskIntoConstraints = false + superview.addSubview(separator) + NSLayoutConstraint.activate([ + separator.bottomAnchor.constraint(equalTo: bottomAnchor), + separator.trailingAnchor.constraint(equalTo: superview.trailingAnchor), + separator.leadingAnchor.constraint(equalTo: superview.leadingAnchor), + separator.heightAnchor.constraint(equalToConstant: 1) + ]) + WPStyleGuide.applyBorderStyle(separator) + separator.layer.zPosition = 10 + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + func add(button chip: FilterChipButton) { + filterStackView.addArrangedSubview(chip) + } + + private enum Constants { + static let filterHeightAnchor: CGFloat = 24 + static let filterStackViewSpacing: CGFloat = 8 + static let filterBarHorizontalPadding: CGFloat = 16 + static let filterBarVerticalPadding: CGFloat = 8 + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/Filter/FilterChipButton.swift b/WordPress/Classes/ViewRelated/Activity/Filter/FilterChipButton.swift new file mode 100644 index 000000000000..29b1894ea2e4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/Filter/FilterChipButton.swift @@ -0,0 +1,101 @@ +import Foundation + +/// A button that represents a filter chip +/// +class FilterChipButton: UIView { + /// The title of the button + var title: String? { + didSet { + mainButton.setTitle(title, for: .normal) + } + } + + let mainButton = UIButton(type: .system) + let resetButton = UIButton(type: .system) + + /// Callback called when the button is tapped + var tapped: (() -> Void)? + + /// Callback called when the reset ("X") is tapped + var resetTapped: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + + let stackView = UIStackView() + stackView.axis = .horizontal + + stackView.addArrangedSubview(mainButton) + + resetButton.widthAnchor.constraint(greaterThanOrEqualToConstant: Constants.minResetButtonWidth).isActive = true + resetButton.imageEdgeInsets = Constants.resetImageInsets + resetButton.isHidden = true + stackView.addArrangedSubview(resetButton) + + mainButton.addTarget(self, action: #selector(mainButtonTapped), for: .touchUpInside) + resetButton.addTarget(self, action: #selector(resetButtonTapped), for: .touchUpInside) + + addSubview(stackView) + pinSubviewToAllEdges(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + + layer.borderWidth = Constants.borderWidth + layer.cornerRadius = Constants.cornerRadius + + mainButton.titleLabel?.font = WPStyleGuide.fontForTextStyle(.callout) + mainButton.setTitleColor(.text, for: .normal) + mainButton.heightAnchor.constraint(greaterThanOrEqualToConstant: Constants.minButtonHeight).isActive = true + mainButton.contentEdgeInsets = Constants.buttonContentInset + + applyColors() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + /// Enables the reset button + func enableResetButton() { + resetButton.isHidden = false + mainButton.contentEdgeInsets = Constants.buttonContentInsetWithResetEnabled + } + + /// Disables the reset button + func disableResetButton() { + resetButton.isHidden = true + mainButton.contentEdgeInsets = Constants.buttonContentInset + UIAccessibility.post(notification: .layoutChanged, argument: mainButton) + } + + @objc private func mainButtonTapped() { + tapped?() + } + + @objc private func resetButtonTapped() { + resetTapped?() + } + + private func applyColors() { + layer.borderColor = UIColor.textQuaternary.cgColor + resetButton.setImage(UIImage.gridicon(.crossCircle), for: .normal) + resetButton.tintColor = .textSubtle + } + + private enum Constants { + static let minResetButtonWidth: CGFloat = 32 + static let resetImageInsets = UIEdgeInsets(top: 8, left: 6, bottom: 8, right: 10).flippedForRightToLeft + static let borderWidth: CGFloat = 1 + static let cornerRadius: CGFloat = 16 + static let minButtonHeight: CGFloat = 32 + static let buttonContentInset = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12).flippedForRightToLeft + static let buttonContentInsetWithResetEnabled = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 0).flippedForRightToLeft + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + applyColors() + } + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/FormattableContent/ActivityContentRouter.swift b/WordPress/Classes/ViewRelated/Activity/FormattableContent/ActivityContentRouter.swift index 72a11e59e973..4553426ab9a7 100644 --- a/WordPress/Classes/ViewRelated/Activity/FormattableContent/ActivityContentRouter.swift +++ b/WordPress/Classes/ViewRelated/Activity/FormattableContent/ActivityContentRouter.swift @@ -30,7 +30,8 @@ struct ActivityContentRouter: ContentRouter { } let postID = commentRange.postID as NSNumber let siteID = commentRange.siteID as NSNumber - try? coordinator.displayCommentsWithPostId(postID, siteID: siteID) + let commentID = commentRange.commentID as NSNumber + try? coordinator.displayCommentsWithPostId(postID, siteID: siteID, commentID: commentID, source: .activityLogDetail) case .plugin: guard let pluginRange = range as? ActivityPluginRange else { fallthrough @@ -39,7 +40,7 @@ struct ActivityContentRouter: ContentRouter { let pluginSlug = pluginRange.pluginSlug try? coordinator.displayPlugin(withSlug: pluginSlug, on: siteSlug) default: - coordinator.displayWebViewWithURL(url) + coordinator.displayWebViewWithURL(url, source: "activity_detail_route") } } diff --git a/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift b/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift new file mode 100644 index 000000000000..d016e4a7941c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/JetpackActivityLogViewController.swift @@ -0,0 +1,70 @@ +import UIKit +import Combine + +class JetpackActivityLogViewController: BaseActivityListViewController { + private let jetpackBannerView = JetpackBannerView() + let scrollViewTranslationPublisher = PassthroughSubject<Bool, Never>() + + override init(site: JetpackSiteRef, store: ActivityStore, isFreeWPCom: Bool = false) { + store.onlyRestorableItems = false + + let activityListConfiguration = ActivityListConfiguration( + identifier: "activity_log", + title: NSLocalizedString("Activity", comment: "Title for the activity list"), + loadingTitle: NSLocalizedString("Loading Activities...", comment: "Text displayed while loading the activity feed for a site"), + noActivitiesTitle: NSLocalizedString("No activity yet", comment: "Title for the view when there aren't any Activities to display in the Activity Log"), + noActivitiesSubtitle: NSLocalizedString("When you make changes to your site you'll be able to see your activity history here.", comment: "Text display when the view when there aren't any Activities to display in the Activity Log"), + noMatchingTitle: NSLocalizedString("No matching events found.", comment: "Title for the view when there aren't any Activities to display in the Activity Log for a given filter."), + noMatchingSubtitle: NSLocalizedString("Try adjusting your date range or activity type filters", comment: "Text display when the view when there aren't any Activities to display in the Activity Log for a given filter."), + filterbarRangeButtonTapped: .activitylogFilterbarRangeButtonTapped, + filterbarSelectRange: .activitylogFilterbarSelectRange, + filterbarResetRange: .activitylogFilterbarResetRange, + numberOfItemsPerPage: 20 + ) + + super.init(site: site, store: store, configuration: activityListConfiguration, isFreeWPCom: isFreeWPCom) + + if JetpackBrandingVisibility.all.enabled { + configureBanner() + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc convenience init?(blog: Blog) { + precondition(blog.dotComID != nil) + guard let siteRef = JetpackSiteRef(blog: blog) else { + return nil + } + + let isFreeWPCom = blog.isHostedAtWPcom && !blog.hasPaidPlan + self.init(site: siteRef, store: StoreContainer.shared.activity, isFreeWPCom: isFreeWPCom) + } + + // MARK: - View lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + extendedLayoutIncludesOpaqueBars = true + } + + private func configureBanner() { + containerStackView.addArrangedSubview(jetpackBannerView) + addTranslationObserver(jetpackBannerView) + let textProvider = JetpackBrandingTextProvider(screen: JetpackBannerScreen.activityLog) + jetpackBannerView.configure(title: textProvider.brandingText()) { [unowned self] in + JetpackBrandingCoordinator.presentOverlay(from: self) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBannerTapped(screen: .activityLog) + } + } +} + +extension JetpackActivityLogViewController: JPScrollViewDelegate { + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + super.scrollViewDidScroll(scrollView) + processJetpackBannerVisibility(scrollView) + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/RewindStatus+multiSite.swift b/WordPress/Classes/ViewRelated/Activity/RewindStatus+multiSite.swift new file mode 100644 index 000000000000..0109881caa22 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Activity/RewindStatus+multiSite.swift @@ -0,0 +1,18 @@ +import WordPressKit + +extension RewindStatus { + func isMultisite() -> Bool { + reason == "multisite_not_supported" + } + + func isActive() -> Bool { + state == .active + } + + enum Strings { + static let multisiteNotAvailable = String(format: Self.multisiteNotAvailableFormat, + Self.multisiteNotAvailableSubstring) + static let multisiteNotAvailableFormat = NSLocalizedString("Jetpack Backup for Multisite installations provides downloadable backups, no one-click restores. For more information %1$@.", comment: "Message for Jetpack users that have multisite WP installation, thus Restore is not available. %1$@ is a placeholder for the string 'visit our documentation page'.") + static let multisiteNotAvailableSubstring = NSLocalizedString("visit our documentation page", comment: "Portion of a message for Jetpack users that have multisite WP installation, thus Restore is not available. This part is a link, colored with a different color.") + } +} diff --git a/WordPress/Classes/ViewRelated/Activity/RewindStatusTableViewCell.xib b/WordPress/Classes/ViewRelated/Activity/RewindStatusTableViewCell.xib index ea939a063a76..e6be3239eb5c 100644 --- a/WordPress/Classes/ViewRelated/Activity/RewindStatusTableViewCell.xib +++ b/WordPress/Classes/ViewRelated/Activity/RewindStatusTableViewCell.xib @@ -1,11 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14269.14" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14252.5"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> @@ -15,7 +13,7 @@ <rect key="frame" x="0.0" y="0.0" width="323" height="67"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="ArX-Tt-Izx" id="CDt-z4-jmn"> - <rect key="frame" x="0.0" y="0.0" width="323" height="66.5"/> + <rect key="frame" x="0.0" y="0.0" width="323" height="67"/> <autoresizingMask key="autoresizingMask"/> <subviews> <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="6Gz-B7-Enk" userLabel="icon" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> @@ -32,20 +30,20 @@ <constraint firstAttribute="height" constant="24" id="R4E-ax-Lq1"/> </constraints> </imageView> - <stackView opaque="NO" contentMode="scaleToFill" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="NER-gg-Sq1"> - <rect key="frame" x="64" y="11" width="243" height="40.5"/> + <stackView opaque="NO" contentMode="scaleToFill" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="NER-gg-Sq1"> + <rect key="frame" x="64" y="11" width="243" height="41"/> <subviews> <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="fillProportionally" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="Tbk-Tr-FTw"> - <rect key="frame" x="0.0" y="0.0" width="221" height="40.5"/> + <rect key="frame" x="0.0" y="0.0" width="191" height="41"/> <subviews> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1000" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="218" translatesAutoresizingMaskIntoConstraints="NO" id="Dp7-4M-cWe"> - <rect key="frame" x="0.0" y="0.0" width="221" height="20.5"/> + <rect key="frame" x="0.0" y="0.0" width="191" height="21"/> <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> <color key="textColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> </label> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1000" verticalCompressionResistancePriority="749" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="218" translatesAutoresizingMaskIntoConstraints="NO" id="fTj-Ko-X8N"> - <rect key="frame" x="0.0" y="22.5" width="221" height="18"/> + <rect key="frame" x="0.0" y="23" width="191" height="18"/> <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> <color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> @@ -54,21 +52,24 @@ <edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="0.0" right="0.0"/> </stackView> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="myK-Kw-z4d"> - <rect key="frame" x="225" y="0.0" width="18" height="40.5"/> + <rect key="frame" x="199" y="0.0" width="44" height="41"/> <subviews> - <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="alw-xI-pFZ"> - <rect key="frame" x="0.0" y="11.5" width="18" height="18"/> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="hn1-5Q-f8l"> + <rect key="frame" x="0.0" y="20.5" width="44" height="0.0"/> <constraints> - <constraint firstAttribute="height" constant="18" id="7jv-wm-9my"/> - <constraint firstAttribute="width" constant="18" id="8bv-AL-pdH"/> + <constraint firstAttribute="width" secondItem="hn1-5Q-f8l" secondAttribute="height" constant="44" id="CvI-H8-egN"/> + <constraint firstAttribute="width" constant="44" id="QPV-7Q-uuK"/> </constraints> - </imageView> + <connections> + <action selector="didTapActionButton:" destination="ArX-Tt-Izx" eventType="touchUpInside" id="DeM-CI-Js2"/> + </connections> + </button> </subviews> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <constraints> - <constraint firstItem="alw-xI-pFZ" firstAttribute="centerY" secondItem="myK-Kw-z4d" secondAttribute="centerY" id="BNe-fa-7XP"/> - <constraint firstItem="alw-xI-pFZ" firstAttribute="centerX" secondItem="myK-Kw-z4d" secondAttribute="centerX" id="QrL-4S-sN5"/> - <constraint firstAttribute="width" priority="999" constant="18" id="gNd-0v-PQ6"/> + <constraint firstItem="hn1-5Q-f8l" firstAttribute="centerY" secondItem="myK-Kw-z4d" secondAttribute="centerY" id="AB0-GB-ELp"/> + <constraint firstItem="hn1-5Q-f8l" firstAttribute="centerX" secondItem="myK-Kw-z4d" secondAttribute="centerX" id="DyK-RD-eue"/> + <constraint firstAttribute="width" priority="999" constant="44" id="gNd-0v-PQ6"/> </constraints> </view> </subviews> @@ -78,12 +79,10 @@ </constraints> </stackView> <progressView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="750" insetsLayoutMarginsFromSafeArea="NO" progressViewStyle="bar" progress="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="nty-aK-z8B"> - <rect key="frame" x="0.0" y="62.5" width="323" height="5"/> + <rect key="frame" x="0.0" y="63" width="323" height="5"/> <constraints> <constraint firstAttribute="height" constant="4" id="wWm-ff-q3v"/> </constraints> - <color key="progressTintColor" red="0.0" green="0.66666666669999997" blue="0.86274509799999999" alpha="1" colorSpace="calibratedRGB"/> - <color key="trackTintColor" red="0.78431372549019607" green="0.84313725490196079" blue="0.88235294117647056" alpha="1" colorSpace="custom" customColorSpace="displayP3"/> </progressView> </subviews> <constraints> @@ -102,12 +101,12 @@ </tableViewCellContentView> <inset key="separatorInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/> <connections> + <outlet property="actionButton" destination="hn1-5Q-f8l" id="EAM-d1-ABZ"/> + <outlet property="actionButtonContainer" destination="myK-Kw-z4d" id="Uqg-x3-Qup"/> <outlet property="contentLabel" destination="Dp7-4M-cWe" id="0li-gE-dou"/> <outlet property="iconBackgroundImageView" destination="6Gz-B7-Enk" id="KLx-eM-FR4"/> <outlet property="iconImageView" destination="0Of-tF-rRE" id="B7M-m6-Cq9"/> <outlet property="progressView" destination="nty-aK-z8B" id="pGi-1E-50A"/> - <outlet property="rewindIcon" destination="alw-xI-pFZ" id="So2-ib-HaQ"/> - <outlet property="rewindIconContainer" destination="myK-Kw-z4d" id="a0I-Pa-puu"/> <outlet property="summaryLabel" destination="fTj-Ko-X8N" id="7dl-UR-Ydi"/> </connections> <point key="canvasLocation" x="35.5" y="52.5"/> diff --git a/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift b/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift index 19f9b9248609..cc281d7a36dc 100644 --- a/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift +++ b/WordPress/Classes/ViewRelated/Activity/WPStyleGuide+Activity.swift @@ -66,7 +66,7 @@ extension WPStyleGuide { return nil } - return Gridicon.iconOfType(gridiconType).imageWithTintColor(.white) + return UIImage.gridicon(gridiconType).imageWithTintColor(.white) } public static func getColorByActivityStatus(_ activity: Activity) -> UIColor { @@ -159,6 +159,12 @@ extension WPStyleGuide { "themes": GridiconType.themes, "trash": GridiconType.trash, "user": GridiconType.user, + "video": GridiconType.video, + "status": GridiconType.status, + "cart": GridiconType.cart, + "custom-post-type": GridiconType.customPostType, + "multiple-users": GridiconType.multipleUsers, + "audio": GridiconType.audio ] } } diff --git a/WordPress/Classes/ViewRelated/Aztec/Extensions/FormatBarItemProviders.swift b/WordPress/Classes/ViewRelated/Aztec/Extensions/FormatBarItemProviders.swift index e6f313870084..064cd2bff840 100644 --- a/WordPress/Classes/ViewRelated/Aztec/Extensions/FormatBarItemProviders.swift +++ b/WordPress/Classes/ViewRelated/Aztec/Extensions/FormatBarItemProviders.swift @@ -14,51 +14,51 @@ extension FormattingIdentifier: FormatBarItemProvider { var iconImage: UIImage { switch self { case .media: - return Gridicon.iconOfType(.addOutline) + return .gridicon(.addOutline) case .p: - return Gridicon.iconOfType(.heading) + return .gridicon(.heading) case .bold: - return Gridicon.iconOfType(.bold) + return .gridicon(.bold) case .italic: - return Gridicon.iconOfType(.italic) + return .gridicon(.italic) case .underline: - return Gridicon.iconOfType(.underline) + return .gridicon(.underline) case .strikethrough: - return Gridicon.iconOfType(.strikethrough) + return .gridicon(.strikethrough) case .blockquote: - return Gridicon.iconOfType(.quote) + return .gridicon(.quote) case .orderedlist: if layoutDirection == .leftToRight { - return Gridicon.iconOfType(.listOrdered) + return .gridicon(.listOrdered) } else { - return Gridicon.iconOfType(.listOrderedRTL) + return .gridicon(.listOrderedRtl) } case .unorderedlist: - return Gridicon.iconOfType(.listUnordered).imageFlippedForRightToLeftLayoutDirection() + return UIImage.gridicon(.listUnordered).imageFlippedForRightToLeftLayoutDirection() case .link: - return Gridicon.iconOfType(.link) + return .gridicon(.link) case .horizontalruler: - return Gridicon.iconOfType(.minusSmall) + return .gridicon(.minusSmall) case .sourcecode: - return Gridicon.iconOfType(.code) + return .gridicon(.code) case .more: - return Gridicon.iconOfType(.readMore) + return .gridicon(.readMore) case .header1: - return Gridicon.iconOfType(.headingH1) + return .gridicon(.headingH1) case .header2: - return Gridicon.iconOfType(.headingH2) + return .gridicon(.headingH2) case .header3: - return Gridicon.iconOfType(.headingH3) + return .gridicon(.headingH3) case .header4: - return Gridicon.iconOfType(.headingH4) + return .gridicon(.headingH4) case .header5: - return Gridicon.iconOfType(.headingH5) + return .gridicon(.headingH5) case .header6: - return Gridicon.iconOfType(.headingH6) + return .gridicon(.headingH6) case .code: - return Gridicon.iconOfType(.posts) + return .gridicon(.posts) default: - return Gridicon.iconOfType(.help) + return .gridicon(.help) } } @@ -116,45 +116,45 @@ extension FormattingIdentifier: FormatBarItemProvider { var accessibilityLabel: String { switch self { case .media: - return NSLocalizedString("Insert media", comment: "Accessibility label for insert media button on formatting toolbar.") + return AppLocalizedString("Insert media", comment: "Accessibility label for insert media button on formatting toolbar.") case .p: - return NSLocalizedString("Select paragraph style", comment: "Accessibility label for selecting paragraph style button on formatting toolbar.") + return AppLocalizedString("Select paragraph style", comment: "Accessibility label for selecting paragraph style button on formatting toolbar.") case .bold: - return NSLocalizedString("Bold", comment: "Accessibility label for bold button on formatting toolbar.") + return AppLocalizedString("Bold", comment: "Accessibility label for bold button on formatting toolbar.") case .italic: - return NSLocalizedString("Italic", comment: "Accessibility label for italic button on formatting toolbar.") + return AppLocalizedString("Italic", comment: "Accessibility label for italic button on formatting toolbar.") case .underline: - return NSLocalizedString("Underline", comment: "Accessibility label for underline button on formatting toolbar.") + return AppLocalizedString("Underline", comment: "Accessibility label for underline button on formatting toolbar.") case .strikethrough: - return NSLocalizedString("Strike Through", comment: "Accessibility label for strikethrough button on formatting toolbar.") + return AppLocalizedString("Strike Through", comment: "Accessibility label for strikethrough button on formatting toolbar.") case .blockquote: - return NSLocalizedString("Block Quote", comment: "Accessibility label for block quote button on formatting toolbar.") + return AppLocalizedString("Block Quote", comment: "Accessibility label for block quote button on formatting toolbar.") case .orderedlist: - return NSLocalizedString("Ordered List", comment: "Accessibility label for Ordered list button on formatting toolbar.") + return AppLocalizedString("Ordered List", comment: "Accessibility label for Ordered list button on formatting toolbar.") case .unorderedlist: - return NSLocalizedString("Unordered List", comment: "Accessibility label for unordered list button on formatting toolbar.") + return AppLocalizedString("Unordered List", comment: "Accessibility label for unordered list button on formatting toolbar.") case .link: - return NSLocalizedString("Insert Link", comment: "Accessibility label for insert link button on formatting toolbar.") + return AppLocalizedString("Insert Link", comment: "Accessibility label for insert link button on formatting toolbar.") case .horizontalruler: - return NSLocalizedString("Insert Horizontal Ruler", comment: "Accessibility label for insert horizontal ruler button on formatting toolbar.") + return AppLocalizedString("Insert Horizontal Ruler", comment: "Accessibility label for insert horizontal ruler button on formatting toolbar.") case .sourcecode: - return NSLocalizedString("HTML", comment: "Accessibility label for HTML button on formatting toolbar.") + return AppLocalizedString("HTML", comment: "Accessibility label for HTML button on formatting toolbar.") case .more: - return NSLocalizedString("More", comment: "Accessibility label for the More button on formatting toolbar.") + return AppLocalizedString("More", comment: "Accessibility label for the More button on formatting toolbar.") case .header1: - return NSLocalizedString("Header 1", comment: "Accessibility label for selecting h1 paragraph style button on the formatting toolbar.") + return AppLocalizedString("Header 1", comment: "Accessibility label for selecting h1 paragraph style button on the formatting toolbar.") case .header2: - return NSLocalizedString("Header 2", comment: "Accessibility label for selecting h2 paragraph style button on the formatting toolbar.") + return AppLocalizedString("Header 2", comment: "Accessibility label for selecting h2 paragraph style button on the formatting toolbar.") case .header3: - return NSLocalizedString("Header 3", comment: "Accessibility label for selecting h3 paragraph style button on the formatting toolbar.") + return AppLocalizedString("Header 3", comment: "Accessibility label for selecting h3 paragraph style button on the formatting toolbar.") case .header4: - return NSLocalizedString("Header 4", comment: "Accessibility label for selecting h4 paragraph style button on the formatting toolbar.") + return AppLocalizedString("Header 4", comment: "Accessibility label for selecting h4 paragraph style button on the formatting toolbar.") case .header5: - return NSLocalizedString("Header 5", comment: "Accessibility label for selecting h5 paragraph style button on the formatting toolbar.") + return AppLocalizedString("Header 5", comment: "Accessibility label for selecting h5 paragraph style button on the formatting toolbar.") case .header6: - return NSLocalizedString("Header 6", comment: "Accessibility label for selecting h6 paragraph style button on the formatting toolbar.") + return AppLocalizedString("Header 6", comment: "Accessibility label for selecting h6 paragraph style button on the formatting toolbar.") case .code: - return NSLocalizedString("Code", comment: "Accessibility label for selecting code style button on the formatting toolbar.") + return AppLocalizedString("Code", comment: "Accessibility label for selecting code style button on the formatting toolbar.") default: return "" } @@ -172,13 +172,13 @@ extension FormatBarMediaIdentifier: FormatBarItemProvider { var iconImage: UIImage { switch self { case .deviceLibrary: - return Gridicon.iconOfType(.imageMultiple) + return .gridicon(.imageMultiple) case .camera: - return Gridicon.iconOfType(.camera) + return .gridicon(.camera) case .mediaLibrary: - return Gridicon.iconOfType(.mySites) + return .gridicon(.mySites) case .otherApplications: - return Gridicon.iconOfType(.ellipsis) + return .gridicon(.ellipsis) } } @@ -198,13 +198,13 @@ extension FormatBarMediaIdentifier: FormatBarItemProvider { var accessibilityLabel: String { switch self { case .deviceLibrary: - return NSLocalizedString("Photo Library", comment: "Accessibility label for selecting an image or video from the device's photo library on formatting toolbar.") + return AppLocalizedString("Photo Library", comment: "Accessibility label for selecting an image or video from the device's photo library on formatting toolbar.") case .camera: - return NSLocalizedString("Camera", comment: "Accessibility label for taking an image or video with the camera on formatting toolbar.") + return AppLocalizedString("Camera", comment: "Accessibility label for taking an image or video with the camera on formatting toolbar.") case .mediaLibrary: - return NSLocalizedString("WordPress Media Library", comment: "Accessibility label for selecting an image or video from the user's WordPress media library on formatting toolbar.") + return AppLocalizedString("WordPress Media Library", comment: "Accessibility label for selecting an image or video from the user's WordPress media library on formatting toolbar.") case .otherApplications: - return NSLocalizedString("Other Apps", comment: "Accessibility label for selecting an image or video from other applications on formatting toolbar.") + return AppLocalizedString("Other Apps", comment: "Accessibility label for selecting an image or video from other applications on formatting toolbar.") } } } diff --git a/WordPress/Classes/ViewRelated/Aztec/Extensions/Header+WordPress.swift b/WordPress/Classes/ViewRelated/Aztec/Extensions/Header+WordPress.swift index eb851a89f0ef..b82b056f52c3 100644 --- a/WordPress/Classes/ViewRelated/Aztec/Extensions/Header+WordPress.swift +++ b/WordPress/Classes/ViewRelated/Aztec/Extensions/Header+WordPress.swift @@ -20,25 +20,25 @@ extension Header.HeaderType { var description: String { switch self { - case .none: return NSLocalizedString("Default", comment: "Description of the default paragraph formatting style in the editor.") - case .h1: return NSLocalizedString("Heading 1", comment: "H1 Aztec Style") - case .h2: return NSLocalizedString("Heading 2", comment: "H2 Aztec Style") - case .h3: return NSLocalizedString("Heading 3", comment: "H3 Aztec Style") - case .h4: return NSLocalizedString("Heading 4", comment: "H4 Aztec Style") - case .h5: return NSLocalizedString("Heading 5", comment: "H5 Aztec Style") - case .h6: return NSLocalizedString("Heading 6", comment: "H6 Aztec Style") + case .none: return AppLocalizedString("Default", comment: "Description of the default paragraph formatting style in the editor.") + case .h1: return AppLocalizedString("Heading 1", comment: "H1 Aztec Style") + case .h2: return AppLocalizedString("Heading 2", comment: "H2 Aztec Style") + case .h3: return AppLocalizedString("Heading 3", comment: "H3 Aztec Style") + case .h4: return AppLocalizedString("Heading 4", comment: "H4 Aztec Style") + case .h5: return AppLocalizedString("Heading 5", comment: "H5 Aztec Style") + case .h6: return AppLocalizedString("Heading 6", comment: "H6 Aztec Style") } } var accessibilityLabel: String { switch self { - case .none: return NSLocalizedString("Switches to the default Font Size", comment: "Accessibility Identifier for the Default Font Aztec Style.") - case .h1: return NSLocalizedString("Switches to the Heading 1 font size", comment: "Accessibility Identifier for the H1 Aztec Style") - case .h2: return NSLocalizedString("Switches to the Heading 2 font size", comment: "Accessibility Identifier for the H2 Aztec Style") - case .h3: return NSLocalizedString("Switches to the Heading 3 font size", comment: "Accessibility Identifier for the H3 Aztec Style") - case .h4: return NSLocalizedString("Switches to the Heading 4 font size", comment: "Accessibility Identifier for the H4 Aztec Style") - case .h5: return NSLocalizedString("Switches to the Heading 5 font size", comment: "Accessibility Identifier for the H5 Aztec Style") - case .h6: return NSLocalizedString("Switches to the Heading 6 font size", comment: "Accessibility Identifier for the H6 Aztec Style") + case .none: return AppLocalizedString("Switches to the default Font Size", comment: "Accessibility Identifier for the Default Font Aztec Style.") + case .h1: return AppLocalizedString("Switches to the Heading 1 font size", comment: "Accessibility Identifier for the H1 Aztec Style") + case .h2: return AppLocalizedString("Switches to the Heading 2 font size", comment: "Accessibility Identifier for the H2 Aztec Style") + case .h3: return AppLocalizedString("Switches to the Heading 3 font size", comment: "Accessibility Identifier for the H3 Aztec Style") + case .h4: return AppLocalizedString("Switches to the Heading 4 font size", comment: "Accessibility Identifier for the H4 Aztec Style") + case .h5: return AppLocalizedString("Switches to the Heading 5 font size", comment: "Accessibility Identifier for the H5 Aztec Style") + case .h6: return AppLocalizedString("Switches to the Heading 6 font size", comment: "Accessibility Identifier for the H6 Aztec Style") } } diff --git a/WordPress/Classes/ViewRelated/Aztec/Extensions/TextList+WordPress.swift b/WordPress/Classes/ViewRelated/Aztec/Extensions/TextList+WordPress.swift index 781f065baf51..e88c607ac0a7 100644 --- a/WordPress/Classes/ViewRelated/Aztec/Extensions/TextList+WordPress.swift +++ b/WordPress/Classes/ViewRelated/Aztec/Extensions/TextList+WordPress.swift @@ -22,8 +22,8 @@ extension TextList.Style { var accessibilityLabel: String { switch self { - case .ordered: return NSLocalizedString("Toggles the ordered list style", comment: "Accessibility Identifier for the Aztec Ordered List Style.") - case .unordered: return NSLocalizedString("Toggles the unordered list style", comment: "Accessibility Identifier for the Aztec Unordered List Style") + case .ordered: return AppLocalizedString("Toggles the ordered list style", comment: "Accessibility Identifier for the Aztec Ordered List Style.") + case .unordered: return AppLocalizedString("Toggles the unordered list style", comment: "Accessibility Identifier for the Aztec Unordered List Style") } } diff --git a/WordPress/Classes/ViewRelated/Aztec/Helpers/AztecVerificationPromptHelper.swift b/WordPress/Classes/ViewRelated/Aztec/Helpers/AztecVerificationPromptHelper.swift index 38de2a5b2613..532f5a9a8cfa 100644 --- a/WordPress/Classes/ViewRelated/Aztec/Helpers/AztecVerificationPromptHelper.swift +++ b/WordPress/Classes/ViewRelated/Aztec/Helpers/AztecVerificationPromptHelper.swift @@ -17,26 +17,26 @@ class AztecVerificationPromptHelper: NSObject, VerificationPromptHelper { private let accountService: AccountService private let wpComAccount: WPAccount + private let managedObjectContext: NSManagedObjectContext private weak var displayedAlert: FancyAlertViewController? private var completionBlock: VerificationPromptCompletion? @objc init?(account: WPAccount?) { - guard let passedAccount = account, - let managedObjectContext = account?.managedObjectContext else { - return nil - } - - accountService = AccountService(managedObjectContext: managedObjectContext) - - guard accountService.isDefaultWordPressComAccount(passedAccount), - passedAccount.needsEmailVerification else { - // if the post the user is trying to compose isn't on a WP.com account, - // or they're already verified, then the verification prompt is irrelevant. - return nil + guard + let passedAccount = account, + let managedObjectContext = account?.managedObjectContext, + passedAccount.isDefaultWordPressComAccount, + passedAccount.needsEmailVerification + else { + // if the post the user is trying to compose isn't on a WP.com account, + // or they're already verified, then the verification prompt is irrelevant. + return nil } - wpComAccount = passedAccount + self.wpComAccount = passedAccount + self.managedObjectContext = managedObjectContext + self.accountService = AccountService(coreDataStack: ContextManager.sharedInstance()) super.init() @@ -84,10 +84,13 @@ class AztecVerificationPromptHelper: NSObject, VerificationPromptHelper { // Let's make sure the alert is still on the screen and // the verification status has changed, before we call the callback. - guard let displayedAlert = self?.displayedAlert, - let updatedAccount = self?.accountService.defaultWordPressComAccount(), - !updatedAccount.needsEmailVerification else { - return + guard + let displayedAlert = self?.displayedAlert, + let managedObjectContext = self?.managedObjectContext, + let updatedAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: managedObjectContext), + !updatedAccount.needsEmailVerification + else { + return } displayedAlert.dismiss(animated: true) diff --git a/WordPress/Classes/ViewRelated/Aztec/Media/MediaEditorOperation+Description.swift b/WordPress/Classes/ViewRelated/Aztec/Media/MediaEditorOperation+Description.swift index 9a11adeab91c..0311da911787 100644 --- a/WordPress/Classes/ViewRelated/Aztec/Media/MediaEditorOperation+Description.swift +++ b/WordPress/Classes/ViewRelated/Aztec/Media/MediaEditorOperation+Description.swift @@ -16,6 +16,8 @@ extension MediaEditorOperation { return "rotate" case .filter: return "filter" + case .draw: + return "draw" case .other: return "other" } diff --git a/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift b/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift index 6f58b6ab5a03..6712c7f47498 100644 --- a/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Aztec/Media/MediaProgressCoordinator.swift @@ -3,7 +3,7 @@ import Foundation /// Media Progress Coordinator Delegate comunicates changes on media progress. /// @objc -public protocol MediaProgressCoordinatorDelegate: class { +public protocol MediaProgressCoordinatorDelegate: AnyObject { func mediaProgressCoordinator(_ mediaProgressCoordinator: MediaProgressCoordinator, progressDidChange progress: Double) func mediaProgressCoordinatorDidStartUploading(_ mediaProgressCoordinator: MediaProgressCoordinator) func mediaProgressCoordinatorDidFinishUpload(_ mediaProgressCoordinator: MediaProgressCoordinator) diff --git a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecNavigationController.swift b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecNavigationController.swift index 2521b5d63f34..4c817a2e6784 100644 --- a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecNavigationController.swift +++ b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecNavigationController.swift @@ -23,7 +23,7 @@ class AztecNavigationController: UINavigationController { } override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent + return WPStyleGuide.preferredStatusBarStyle } // MARK: - Overriden Methods diff --git a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecPostViewController.swift b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecPostViewController.swift index bc7c934443f2..5138c8f9d976 100644 --- a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecPostViewController.swift +++ b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/AztecPostViewController.swift @@ -69,6 +69,12 @@ class AztecPostViewController: UIViewController, PostEditor { mediaCoordinator.cancelUploadOfAllMedia(for: post) } + var entryPoint: PostEditorEntryPoint = .unknown { + didSet { + editorSession.entryPoint = entryPoint + } + } + /// For autosaving - The debouncer will execute local saving every defined number of seconds. /// In this case every 0.5 second /// @@ -80,6 +86,10 @@ class AztecPostViewController: UIViewController, PostEditor { self?.mapUIContentToPostAndSave(immediate: true) } + var wordCount: UInt { + return richTextView.wordCount + } + // MARK: - Styling Options private lazy var optionsTablePresenter = OptionsTablePresenter(presentingViewController: self, presentingTextView: editorView.richTextView) @@ -360,7 +370,7 @@ class AztecPostViewController: UIViewController, PostEditor { /// Active Downloads /// - fileprivate var activeMediaRequests = [ImageDownloader.Task]() + fileprivate var activeMediaRequests = [ImageDownloaderTask]() /// Media Library Data Source /// @@ -459,6 +469,20 @@ class AztecPostViewController: UIViewController, PostEditor { /// private var mediaPreviewHelper: MediaPreviewHelper? = nil + private let database: KeyValueDatabase = UserDefaults() + private enum Key { + static let classicDeprecationNoticeHasBeenShown = "kClassicDeprecationNoticeHasBeenShown" + } + + private var hasNoticeBeenShown: Bool { + get { + database.bool(forKey: Key.classicDeprecationNoticeHasBeenShown) + } + set { + database.set(newValue, forKey: Key.classicDeprecationNoticeHasBeenShown) + } + } + // MARK: - Initializers required init( @@ -530,13 +554,51 @@ class AztecPostViewController: UIViewController, PostEditor { if !editorSession.started { editorSession.start() } + + if shouldShowDeprecationNotice() { + showDeprecationNotice() + hasNoticeBeenShown = true + } + } + + private func shouldShowDeprecationNotice() -> Bool { + return hasNoticeBeenShown == false && + (post.postTitle ?? "").isEmpty && + (post.content ?? "").isEmpty + } + + private func showDeprecationNotice() { + let okButton: (title: String, handler: FancyAlertViewController.FancyAlertButtonHandler?) = + ( + title: NSLocalizedString( + "aztecPost.deprecationNotice.dismiss", + value: "Dismiss", + comment: "The title of a button to close the classic editor deprecation notice alert dialog." + ), + handler: { alert, _ in + alert.dismiss(animated: true, completion: nil) + } + ) + + let config = FancyAlertViewController.Config( + titleText: NSLocalizedString("Try the new Block Editor", comment: "The title of a notice telling users that the classic editor is deprecated and will be removed in a future version of the app."), + bodyText: NSLocalizedString("We’ll be removing the classic editor for new posts soon, but this won’t affect editing any of your existing posts or pages. Get a head start by enabling the Block Editor now in site settings.", comment: "The message of a notice telling users that the classic editor is deprecated and will be removed in a future version of the app."), + headerImage: nil, + dividerPosition: .top, + defaultButton: okButton, + cancelButton: nil + ) + + let alert = FancyAlertViewController.controllerWithConfiguration(configuration: config) + alert.modalPresentationStyle = .custom + alert.transitioningDelegate = self + present(alert, animated: true) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - resetNavigationColors() configureDismissButton() startListeningToNotifications() verificationPromptHelper?.updateVerificationStatus() @@ -571,6 +633,12 @@ class AztecPostViewController: UIViewController, PostEditor { }) optionsTablePresenter.dismiss() + + // Required to work around an issue present in iOS 14 beta 2 + // https://github.com/wordpress-mobile/WordPress-iOS/issues/14460 + if presentedViewController?.view.accessibilityIdentifier == MoreSheetAlert.accessibilityIdentifier { + dismiss(animated: true) + } } override func willMove(toParent parent: UIViewController?) { @@ -586,7 +654,6 @@ class AztecPostViewController: UIViewController, PostEditor { configureMediaProgressView(in: navigationController.navigationBar) } - // MARK: - Title and Title placeholder position methods func refreshTitlePosition() { @@ -715,14 +782,7 @@ class AztecPostViewController: UIViewController, PostEditor { navigationController?.navigationBar.accessibilityIdentifier = "Azctec Editor Navigation Bar" navigationItem.leftBarButtonItems = navigationBarManager.leftBarButtonItems navigationItem.rightBarButtonItems = navigationBarManager.rightBarButtonItems - } - - /// This is to restore the navigation bar colors after the UIDocumentPickerViewController has been dismissed, - /// either by uploading media or canceling. Doing this in the UIDocumentPickerDelegate methods either did - /// nothing or the resetting wasn't permanent. - /// - fileprivate func resetNavigationColors() { - WPStyleGuide.configureNavigationAppearance() + navigationItem.titleView = navigationBarManager.blogTitleViewLabel } func configureDismissButton() { @@ -801,23 +861,22 @@ class AztecPostViewController: UIViewController, PostEditor { } func refreshInterface() { - reloadBlogPickerButton() + reloadBlogTitleView() reloadEditorContents() reloadPublishButton() - refreshNavigationBar() + refreshTitleViewForMediaUploadIfNeeded() } - func refreshNavigationBar() { + func refreshTitleViewForMediaUploadIfNeeded() { if postEditorStateContext.isUploadingMedia { - navigationItem.leftBarButtonItems = navigationBarManager.uploadingMediaLeftBarButtonItems + navigationItem.titleView = navigationBarManager.uploadingMediaTitleView } else { - navigationItem.leftBarButtonItems = navigationBarManager.leftBarButtonItems + navigationItem.titleView = navigationBarManager.blogTitleViewLabel } } func setHTML(_ html: String) { editorView.setHTML(html) - if editorView.editingMode == .richText { processMediaAttachments() } @@ -856,13 +915,13 @@ class AztecPostViewController: UIViewController, PostEditor { setHTML(content) } - func reloadBlogPickerButton() { - var pickerTitle = post.blog.url ?? String() + func reloadBlogTitleView() { + var blogTitle = post.blog.url ?? String() if let blogName = post.blog.settings?.name, blogName.isEmpty == false { - pickerTitle = blogName + blogTitle = blogName } - navigationBarManager.reloadBlogPickerButton(with: pickerTitle, enabled: !isSingleSiteMode) + navigationBarManager.reloadBlogTitleView(text: blogTitle) } func reloadPublishButton() { @@ -918,22 +977,22 @@ class AztecPostViewController: UIViewController, PostEditor { if richTextView.isFirstResponder { return [ - UIKeyCommand(input: "B", modifierFlags: .command, action: #selector(toggleBold), discoverabilityTitle: NSLocalizedString("Bold", comment: "Discoverability title for bold formatting keyboard shortcut.")), - UIKeyCommand(input: "I", modifierFlags: .command, action: #selector(toggleItalic), discoverabilityTitle: NSLocalizedString("Italic", comment: "Discoverability title for italic formatting keyboard shortcut.")), - UIKeyCommand(input: "S", modifierFlags: [.command], action: #selector(toggleStrikethrough), discoverabilityTitle: NSLocalizedString("Strikethrough", comment: "Discoverability title for strikethrough formatting keyboard shortcut.")), - UIKeyCommand(input: "U", modifierFlags: .command, action: #selector(toggleUnderline(_:)), discoverabilityTitle: NSLocalizedString("Underline", comment: "Discoverability title for underline formatting keyboard shortcut.")), - UIKeyCommand(input: "Q", modifierFlags: [.command, .alternate], action: #selector(toggleBlockquote), discoverabilityTitle: NSLocalizedString("Block Quote", comment: "Discoverability title for block quote keyboard shortcut.")), - UIKeyCommand(input: "K", modifierFlags: .command, action: #selector(toggleLink), discoverabilityTitle: NSLocalizedString("Insert Link", comment: "Discoverability title for insert link keyboard shortcut.")), - UIKeyCommand(input: "M", modifierFlags: [.command, .alternate], action: #selector(presentMediaPickerWasPressed), discoverabilityTitle: NSLocalizedString("Insert Media", comment: "Discoverability title for insert media keyboard shortcut.")), - UIKeyCommand(input: "U", modifierFlags: [.command, .alternate], action: #selector(toggleUnorderedList), discoverabilityTitle: NSLocalizedString("Bullet List", comment: "Discoverability title for bullet list keyboard shortcut.")), - UIKeyCommand(input: "O", modifierFlags: [.command, .alternate], action: #selector(toggleOrderedList), discoverabilityTitle: NSLocalizedString("Numbered List", comment: "Discoverability title for numbered list keyboard shortcut.")), - UIKeyCommand(input: "H", modifierFlags: [.command, .shift], action: #selector(toggleEditingMode), discoverabilityTitle: NSLocalizedString("Toggle HTML Source ", comment: "Discoverability title for HTML keyboard shortcut.")) + UIKeyCommand(action: #selector(toggleBold), input: "B", modifierFlags: .command, discoverabilityTitle: NSLocalizedString("Bold", comment: "Discoverability title for bold formatting keyboard shortcut.")), + UIKeyCommand(action: #selector(toggleItalic), input: "I", modifierFlags: .command, discoverabilityTitle: NSLocalizedString("Italic", comment: "Discoverability title for italic formatting keyboard shortcut.")), + UIKeyCommand(action: #selector(toggleStrikethrough), input: "S", modifierFlags: [.command], discoverabilityTitle: NSLocalizedString("Strikethrough", comment: "Discoverability title for strikethrough formatting keyboard shortcut.")), + UIKeyCommand(action: #selector(toggleUnderline(_:)), input: "U", modifierFlags: .command, discoverabilityTitle: NSLocalizedString("Underline", comment: "Discoverability title for underline formatting keyboard shortcut.")), + UIKeyCommand(action: #selector(toggleBlockquote), input: "Q", modifierFlags: [.command, .alternate], discoverabilityTitle: NSLocalizedString("Block Quote", comment: "Discoverability title for block quote keyboard shortcut.")), + UIKeyCommand(action: #selector(toggleLink), input: "K", modifierFlags: .command, discoverabilityTitle: NSLocalizedString("Insert Link", comment: "Discoverability title for insert link keyboard shortcut.")), + UIKeyCommand(action: #selector(presentMediaPickerWasPressed), input: "M", modifierFlags: [.command, .alternate], discoverabilityTitle: NSLocalizedString("Insert Media", comment: "Discoverability title for insert media keyboard shortcut.")), + UIKeyCommand(action: #selector(toggleUnorderedList), input: "U", modifierFlags: [.command, .alternate], discoverabilityTitle: NSLocalizedString("Bullet List", comment: "Discoverability title for bullet list keyboard shortcut.")), + UIKeyCommand(action: #selector(toggleOrderedList), input: "O", modifierFlags: [.command, .alternate], discoverabilityTitle: NSLocalizedString("Numbered List", comment: "Discoverability title for numbered list keyboard shortcut.")), + UIKeyCommand(action: #selector(toggleEditingMode), input: "H", modifierFlags: [.command, .shift], discoverabilityTitle: NSLocalizedString("Toggle HTML Source ", comment: "Discoverability title for HTML keyboard shortcut.")) ] } if htmlTextView.isFirstResponder { return [ - UIKeyCommand(input: "H", modifierFlags: [.command, .shift], action: #selector(toggleEditingMode), discoverabilityTitle: NSLocalizedString("Toggle HTML Source ", comment: "Discoverability title for HTML keyboard shortcut.")) + UIKeyCommand(action: #selector(toggleEditingMode), input: "H", modifierFlags: [.command, .shift], discoverabilityTitle: NSLocalizedString("Toggle HTML Source ", comment: "Discoverability title for HTML keyboard shortcut.")) ] } @@ -1108,7 +1167,7 @@ extension AztecPostViewController { guard let action = self.postEditorStateContext.secondaryPublishButtonAction else { // If the user tapped on the secondary publish action button, it means we should have a secondary publish action. let error = NSError(domain: errorDomain, code: ErrorCode.expectedSecondaryAction.rawValue, userInfo: nil) - CrashLogging.logError(error) + WordPressAppDelegate.crashLogging?.logError(error) return } @@ -1228,9 +1287,7 @@ private extension AztecPostViewController { } } - if post.blog.isGutenbergEnabled, - let postContent = post.content, - postContent.count > 0 && post.containsGutenbergBlocks() { + if post.blog.isGutenbergEnabled, post.isContentEmpty() || post.containsGutenbergBlocks() { alert.addDefaultActionWithTitle(MoreSheetAlert.gutenbergTitle) { [unowned self] _ in self.editorSession.switch(editor: .gutenberg) @@ -1260,13 +1317,23 @@ private extension AztecPostViewController { } } - alert.addDefaultActionWithTitle(MoreSheetAlert.postSettingsTitle) { [unowned self] _ in + let settingsTitle = self.post is Page ? MoreSheetAlert.pageSettingsTitle : MoreSheetAlert.postSettingsTitle + + alert.addDefaultActionWithTitle(settingsTitle) { [unowned self] _ in self.displayPostSettings() } alert.addCancelActionWithTitle(MoreSheetAlert.keepEditingTitle) - alert.popoverPresentationController?.barButtonItem = navigationBarManager.moreBarButtonItem + if let button = navigationBarManager.moreBarButtonItem.customView { + // Required to work around an issue present in iOS 14 beta 2 + // https://github.com/wordpress-mobile/WordPress-iOS/issues/14460 + alert.popoverPresentationController?.sourceRect = button.convert(button.bounds, to: navigationController?.navigationBar) + alert.popoverPresentationController?.sourceView = navigationController?.navigationBar + alert.view.accessibilityIdentifier = MoreSheetAlert.accessibilityIdentifier + } else { + alert.popoverPresentationController?.barButtonItem = navigationBarManager.moreBarButtonItem + } present(alert, animated: true) } @@ -1757,7 +1824,7 @@ extension AztecPostViewController { let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: Constants.toolbarHeight)) toolbar.barTintColor = WPStyleGuide.aztecFormatBarBackgroundColor toolbar.tintColor = WPStyleGuide.aztecFormatBarActiveColor - let gridButton = UIBarButtonItem(image: Gridicon.iconOfType(.grid), style: .plain, target: self, action: #selector(mediaAddShowFullScreen)) + let gridButton = UIBarButtonItem(image: .gridicon(.grid), style: .plain, target: self, action: #selector(mediaAddShowFullScreen)) gridButton.accessibilityLabel = NSLocalizedString("Open full media picker", comment: "Editor button to swich the media picker from quick mode to full picker") toolbar.items = [ UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(mediaAddInputCancelled)), @@ -1849,7 +1916,7 @@ extension AztecPostViewController { options.allowCaptureOfMedia = false options.showSearchBar = true options.badgedUTTypes = [String(kUTTypeGIF)] - options.preferredStatusBarStyle = .lightContent + options.preferredStatusBarStyle = WPStyleGuide.preferredStatusBarStyle let picker = WPNavigationMediaPickerViewController() @@ -1868,6 +1935,7 @@ extension AztecPostViewController { picker.selectionActionTitle = Constants.mediaPickerInsertText picker.mediaPicker.options = options picker.delegate = self + picker.previewActionTitle = NSLocalizedString("Edit %@", comment: "Button that displays the media editor to the user") picker.modalPresentationStyle = .currentContext if let previousPicker = mediaPickerInputViewController?.mediaPicker { picker.mediaPicker.selectedAssets = previousPicker.selectedAssets @@ -1895,7 +1963,7 @@ extension AztecPostViewController { options.allowCaptureOfMedia = false options.scrollVertically = true options.badgedUTTypes = [String(kUTTypeGIF)] - options.preferredStatusBarStyle = .lightContent + options.preferredStatusBarStyle = WPStyleGuide.preferredStatusBarStyle let picker = WPInputMediaPickerViewController(options: options) mediaPickerInputViewController = picker @@ -2129,7 +2197,7 @@ extension AztecPostViewController { toolbar.selectedTintColor = WPStyleGuide.aztecFormatBarActiveColor toolbar.disabledTintColor = WPStyleGuide.aztecFormatBarDisabledColor toolbar.dividerTintColor = WPStyleGuide.aztecFormatBarDividerColor - toolbar.overflowToggleIcon = Gridicon.iconOfType(.ellipsis) + toolbar.overflowToggleIcon = .gridicon(.ellipsis) let mediaButton = makeToolbarButton(identifier: .media) mediaButton.normalTintColor = .primary @@ -2295,7 +2363,7 @@ extension AztecPostViewController { var keyboardHeight: CGFloat // Let's assume a sensible default for the keyboard height based on orientation - let keyboardFrameRatioDefault = UIApplication.shared.statusBarOrientation.isPortrait ? Constants.mediaPickerKeyboardHeightRatioPortrait : Constants.mediaPickerKeyboardHeightRatioLandscape + let keyboardFrameRatioDefault = UIApplication.shared.currentStatusBarOrientation.isPortrait ? Constants.mediaPickerKeyboardHeightRatioPortrait : Constants.mediaPickerKeyboardHeightRatioLandscape let keyboardHeightDefault = (keyboardFrameRatioDefault * UIScreen.main.bounds.height) // we need to make an assumption the hardware keyboard is attached based on @@ -2373,7 +2441,7 @@ extension AztecPostViewController { mediaProgressView.isHidden = !mediaCoordinator.isUploadingMedia(for: post) mediaProgressView.progress = Float(mediaCoordinator.totalProgress(for: post)) postEditorStateContext.update(isUploadingMedia: mediaCoordinator.isUploadingMedia(for: post)) - refreshNavigationBar() + refreshTitleViewForMediaUploadIfNeeded() } fileprivate func insert(exportableAsset: ExportableAsset, source: MediaSource, attachment: MediaAttachment? = nil) { @@ -2400,10 +2468,10 @@ extension AztecPostViewController { attachment?.uploadID = media.uploadID } - /// Sets the badge title of `attachment` to "GIF" if either the media is being imported from Giphy, + /// Sets the badge title of `attachment` to "GIF" if either the media is being imported from Tenor, /// or if it's a PHAsset with an animated playback style. private func setGifBadgeIfNecessary(for attachment: MediaAttachment, asset: ExportableAsset, source: MediaSource) { - var isGif = (source == .giphy) + var isGif = source == .tenor if let asset = asset as? PHAsset, asset.playbackStyle == .imageAnimated { @@ -2419,12 +2487,12 @@ extension AztecPostViewController { insert(exportableAsset: url as NSURL, source: .otherApps) } - fileprivate func insertImage(image: UIImage) { - insert(exportableAsset: image, source: .deviceLibrary) + fileprivate func insertImage(image: UIImage, source: MediaSource = .deviceLibrary) { + insert(exportableAsset: image, source: source) } - fileprivate func insertDeviceMedia(phAsset: PHAsset) { - insert(exportableAsset: phAsset, source: .deviceLibrary) + fileprivate func insertDeviceMedia(phAsset: PHAsset, source: MediaSource = .deviceLibrary) { + insert(exportableAsset: phAsset, source: source) } private func insertStockPhotosMedia(_ media: StockPhotosMedia) { @@ -2447,14 +2515,16 @@ extension AztecPostViewController { } } - private func insertImageAttachment(with url: URL = Constants.placeholderMediaLink) -> ImageAttachment { + private func insertImageAttachment(with url: URL = Constants.placeholderMediaLink, caption: String? = nil) -> ImageAttachment { let attachment = richTextView.replaceWithImage(at: self.richTextView.selectedRange, sourceURL: url, placeHolderImage: Assets.defaultMissingImage) attachment.size = .full if url.isGif { attachment.badgeTitle = Constants.mediaGIFBadgeTitle } - + if let caption = caption { + richTextView.replaceCaption(for: attachment, with: NSAttributedString(string: caption)) + } return attachment } @@ -2528,7 +2598,7 @@ extension AztecPostViewController { } switch media.mediaType { case .image: - let attachment = insertImageAttachment(with: remoteURL) + let attachment = insertImageAttachment(with: remoteURL, caption: media.caption) attachment.alt = media.alt WPAppAnalytics.track(.editorAddedPhotoViaWPMediaLibrary, withProperties: WPAppAnalytics.properties(for: media, selectionMethod: mediaSelectionMethod), with: post) case .video: @@ -2558,7 +2628,7 @@ extension AztecPostViewController { } var attachment: MediaAttachment? if media.mediaType == .image { - attachment = insertImageAttachment(with: tempMediaURL) + attachment = insertImageAttachment(with: tempMediaURL, caption: media.caption) } else if media.mediaType == .video, let remoteURLStr = media.remoteURL, let remoteURL = URL(string: remoteURLStr) { @@ -2662,7 +2732,7 @@ extension AztecPostViewController { let attributeMessage = NSAttributedString(string: message, attributes: Constants.mediaMessageAttributes) attachment.message = attributeMessage - attachment.overlayImage = Gridicon.iconOfType(.refresh, withSize: Constants.mediaOverlayIconSize) + attachment.overlayImage = .gridicon(.refresh, size: Constants.mediaOverlayIconSize) attachment.shouldHideBorder = true attachment.progress = nil richTextView.refresh(attachment, overlayUpdateOnly: true) @@ -2767,7 +2837,7 @@ extension AztecPostViewController { fileprivate func process(videoAttachment: VideoAttachment) { // Use a placeholder for video while trying to generate a thumbnail DispatchQueue.main.async { - videoAttachment.image = Gridicon.iconOfType(.video, withSize: Constants.mediaPlaceholderImageSize) + videoAttachment.image = .gridicon(.video, size: Constants.mediaPlaceholderImageSize) self.richTextView.refresh(videoAttachment) } if let videoSrcURL = videoAttachment.url, @@ -2775,10 +2845,12 @@ extension AztecPostViewController { let videoPressID = videoSrcURL.host { // It's videoPress video so let's fetch the information for the video let mediaService = MediaService(managedObjectContext: ContextManager.sharedInstance().mainContext) - mediaService.getMediaURL(fromVideoPressID: videoPressID, in: self.post.blog, success: { (videoURLString, posterURLString) in - videoAttachment.updateURL(URL(string: videoURLString)) - if let validPosterURLString = posterURLString, let posterURL = URL(string: validPosterURLString) { - videoAttachment.posterURL = posterURL + mediaService.getMetadataFromVideoPressID(videoPressID, in: self.post.blog, success: { (metadata) in + if let originalURL = metadata.originalURL { + videoAttachment.updateURL(metadata.getURLWithToken(url: originalURL) ?? originalURL) + } + if let posterURL = metadata.posterURL { + videoAttachment.posterURL = metadata.getURLWithToken(url: posterURL) ?? posterURL } self.richTextView.refresh(videoAttachment) }, failure: { (error) in @@ -2877,7 +2949,7 @@ extension AztecPostViewController { alertController.popoverPresentationController?.sourceRect = CGRect(origin: position, size: CGSize(width: 1, height: 1)) alertController.popoverPresentationController?.permittedArrowDirections = .any present(alertController, animated: true, completion: { () in - UIMenuController.shared.setMenuVisible(false, animated: false) + UIMenuController.shared.hideMenu() }) } @@ -2951,20 +3023,21 @@ extension AztecPostViewController { } // It's videoPress video so let's fetch the information for the video let mediaService = MediaService(managedObjectContext: ContextManager.sharedInstance().mainContext) - mediaService.getMediaURL(fromVideoPressID: videoPressID, in: self.post.blog, success: { [weak self] (videoURLString, posterURLString) in + mediaService.getMetadataFromVideoPressID(videoPressID, in: self.post.blog, success: { [weak self] (metadata) in guard let `self` = self else { return } - guard let videoURL = URL(string: videoURLString) else { + guard let originalURL = metadata.originalURL else { self.displayUnableToPlayVideoAlert() return } - videoAttachment.updateURL(videoURL) - if let validPosterURLString = posterURLString, let posterURL = URL(string: validPosterURLString) { - videoAttachment.posterURL = posterURL + let newVideoURL = metadata.getURLWithToken(url: originalURL) ?? originalURL + videoAttachment.updateURL(newVideoURL) + if let posterURL = metadata.posterURL { + videoAttachment.posterURL = metadata.getURLWithToken(url: posterURL) ?? posterURL } self.richTextView.refresh(videoAttachment) - self.displayVideoPlayer(for: videoURL) + self.displayVideoPlayer(for: newVideoURL) }, failure: { [weak self] (error) in self?.displayUnableToPlayVideoAlert() DDLogError("Unable to find information for VideoPress video with ID = \(videoPressID). Details: \(error.localizedDescription)") @@ -3313,10 +3386,10 @@ extension AztecPostViewController: StockPhotosPickerDelegate { } } -extension AztecPostViewController: GiphyPickerDelegate { - func giphyPicker(_ picker: GiphyPicker, didFinishPicking assets: [GiphyMedia]) { +extension AztecPostViewController: TenorPickerDelegate { + func tenorPicker(_ picker: TenorPicker, didFinishPicking assets: [TenorMedia]) { assets.forEach { - insert(exportableAsset: $0, source: .giphy) + insert(exportableAsset: $0, source: .tenor) } } } @@ -3331,10 +3404,10 @@ extension AztecPostViewController { } struct Assets { - static let closeButtonModalImage = Gridicon.iconOfType(.cross) + static let closeButtonModalImage = UIImage.gridicon(.cross) static let closeButtonRegularImage = UIImage(named: "icon-posts-editor-chevron") - static let defaultMissingImage = Gridicon.iconOfType(.image) - static let linkPlaceholderImage = Gridicon.iconOfType(.pages) + static let defaultMissingImage = UIImage.gridicon(.image) + static let linkPlaceholderImage = UIImage.gridicon(.pages) } struct Constants { @@ -3384,12 +3457,18 @@ extension AztecPostViewController { static let previewTitle = NSLocalizedString("Preview", comment: "Displays the Post Preview Interface") static let historyTitle = NSLocalizedString("History", comment: "Displays the History screen from the editor's alert sheet") static let postSettingsTitle = NSLocalizedString("Post Settings", comment: "Name of the button to open the post settings") + static let pageSettingsTitle = NSLocalizedString("Page Settings", comment: "Name of the button to open the page settings") static let keepEditingTitle = NSLocalizedString("Keep Editing", comment: "Goes back to editing the post.") + static let accessibilityIdentifier = "MoreSheetAccessibilityIdentifier" } struct MediaAttachmentActionSheet { static let title = NSLocalizedString("Media Options", comment: "Title for action sheet with media options.") - static let dismissActionTitle = NSLocalizedString("Dismiss", comment: "User action to dismiss media options.") + static let dismissActionTitle = NSLocalizedString( + "aztecPost.mediaAttachmentActionSheet.dismiss", + value: "Dismiss", + comment: "User action to dismiss media options." + ) static let stopUploadActionTitle = NSLocalizedString("Stop upload", comment: "User action to stop upload.") static let retryUploadActionTitle = NSLocalizedString("Retry", comment: "User action to retry media upload.") static let retryAllFailedUploadsActionTitle = NSLocalizedString("Retry all", comment: "User action to retry all failed media uploads.") @@ -3480,8 +3559,8 @@ extension AztecPostViewController: PostEditorNavigationBarManagerDelegate { displayCancelMediaUploads() } - func navigationBarManager(_ manager: PostEditorNavigationBarManager, reloadLeftNavigationItems items: [UIBarButtonItem]) { - navigationItem.leftBarButtonItems = items + func navigationBarManager(_ manager: PostEditorNavigationBarManager, reloadTitleView view: UIView) { + navigationItem.titleView = view } } @@ -3552,6 +3631,36 @@ extension AztecPostViewController { } private func edit(_ imageAttachment: ImageAttachment) { + + guard imageAttachment.mediaURL?.isGif == false else { + confirmEditingGIF(imageAttachment) + return + } + + editAttachment(imageAttachment) + } + + private func confirmEditingGIF(_ imageAttachment: ImageAttachment) { + let alertController = UIAlertController(title: GIFAlertStrings.title, + message: GIFAlertStrings.message, + preferredStyle: .alert) + + alertController.addCancelActionWithTitle(GIFAlertStrings.cancel) { _ in + if imageAttachment == self.currentSelectedAttachment { + self.currentSelectedAttachment = nil + self.resetMediaAttachmentOverlay(imageAttachment) + self.richTextView.refresh(imageAttachment) + } + } + + alertController.addActionWithTitle(GIFAlertStrings.edit, style: .destructive) { _ in + self.editAttachment(imageAttachment) + } + + present(alertController, animated: true) + } + + private func editAttachment(_ imageAttachment: ImageAttachment) { guard let image = imageAttachment.image else { return } @@ -3560,13 +3669,13 @@ extension AztecPostViewController { mediaEditor.editingAlreadyPublishedImage = true mediaEditor.edit(from: self, - onFinishEditing: { [weak self] images, actions in - guard !actions.isEmpty, let image = images.first as? UIImage else { - // If the image wasn't edited, do nothing - return - } + onFinishEditing: { [weak self] images, actions in + guard !actions.isEmpty, let image = images.first as? UIImage else { + // If the image wasn't edited, do nothing + return + } - self?.replace(attachment: imageAttachment, with: image, actions: actions) + self?.replace(attachment: imageAttachment, with: image, actions: actions) }) } @@ -3582,4 +3691,5 @@ extension AztecPostViewController { } attachment.uploadID = media.uploadID } + } diff --git a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/LinkSettingsViewController.swift b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/LinkSettingsViewController.swift index a0227fea47d3..d5c2b1bd9512 100644 --- a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/LinkSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/LinkSettingsViewController.swift @@ -151,8 +151,10 @@ class LinkSettingsViewController: UITableViewController { guard let blog = blog else { return } - let selectPostViewController = SelectPostViewController(blog: blog, selectedLink: linkSettings.url, callback: { [weak self] (url, title) in - guard let strongSelf = self else { + let selectPostViewController = SelectPostViewController(blog: blog, isSelectedPost: { [weak self] in $0.permaLink == self?.linkSettings.url }, callback: { [weak self] (post) in + guard let strongSelf = self, + let url = post.permaLink, + let title = post.titleForDisplay() else { return } strongSelf.linkSettings.url = url diff --git a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/SelectPostViewController.swift b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/SelectPostViewController.swift index ec4232e3f3f9..45d905fd672c 100644 --- a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/SelectPostViewController.swift +++ b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/SelectPostViewController.swift @@ -3,36 +3,59 @@ import UIKit class SelectPostViewController: UITableViewController { - typealias SelectPostCallback = (_ url: String, _ title: String) -> () + typealias SelectPostCallback = (AbstractPost) -> () private var callback: SelectPostCallback? private var blog: Blog! - private var selectedLink: String? + private var isSelectedPost: ((AbstractPost) -> Bool)? = nil + + /// If the cell should display the post type in the `detailTextLabel` + private var showsPostType: Bool = true + + /// An entity to fetch which is of type `AbstractPost` + private let entityName: String? + + /// The IDs of posts which should be hidden from the list + private let hiddenPosts: [Int] + + /// Only include pubilished posts + private let publishedOnly: Bool private lazy var searchController: UISearchController = { let searchController = UISearchController(searchResultsController: nil) searchController.searchResultsUpdater = self searchController.hidesNavigationBarDuringPresentation = false - searchController.dimsBackgroundDuringPresentation = false + searchController.obscuresBackgroundDuringPresentation = false searchController.searchBar.sizeToFit() return searchController }() private lazy var fetchController: NSFetchedResultsController<AbstractPost> = { - return PostCoordinator.shared.posts(for: self.blog, wichTitleContains: "") + return PostCoordinator.shared.posts(for: blog, containsTitle: "", excludingPostIDs: hiddenPosts, entityName: entityName, publishedOnly: publishedOnly) }() // MARK: - Initialization - init(blog: Blog, selectedLink: String? = nil, callback: SelectPostCallback? = nil) { + init(blog: Blog, + isSelectedPost: ((AbstractPost) -> Bool)? = nil, + showsPostType: Bool = true, + entityName: String? = nil, + hiddenPosts: [Int] = [], + publishedOnly: Bool = false, + callback: SelectPostCallback? = nil) { self.blog = blog - self.selectedLink = selectedLink + self.isSelectedPost = isSelectedPost self.callback = callback + self.showsPostType = showsPostType + self.entityName = entityName + self.hiddenPosts = hiddenPosts + self.publishedOnly = publishedOnly super.init(style: .plain) } + @available(*, unavailable) required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) + fatalError("init(coder:) has not been implemented") } // MARK: - Lifecycle methods @@ -82,12 +105,14 @@ extension SelectPostViewController { let post = fetchController.object(at: indexPath) cell.textLabel?.text = post.titleForDisplay() - if post is Page { - cell.detailTextLabel?.text = NSLocalizedString("Page", comment: "Noun. Type of content being selected is a blog page") - } else { - cell.detailTextLabel?.text = NSLocalizedString("Post", comment: "Noun. Type of content being selected is a blog post") + if showsPostType { + if post is Page { + cell.detailTextLabel?.text = NSLocalizedString("Page", comment: "Noun. Type of content being selected is a blog page") + } else { + cell.detailTextLabel?.text = NSLocalizedString("Post", comment: "Noun. Type of content being selected is a blog post") + } } - if post.permaLink == selectedLink { + if isSelectedPost?(post) == true { cell.accessoryType = .checkmark } else { cell.accessoryType = .none @@ -104,11 +129,7 @@ extension SelectPostViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let post = fetchController.object(at: indexPath) - guard let title = post.titleForDisplay(), let url = post.permaLink else { - return - } - - callback?(url, title) + callback?(post) } } @@ -119,7 +140,7 @@ extension SelectPostViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { let searchText = searchController.searchBar.text ?? "" - fetchController = PostCoordinator.shared.posts(for: blog, wichTitleContains: searchText) + fetchController = PostCoordinator.shared.posts(for: blog, containsTitle: searchText, excludingPostIDs: hiddenPosts, entityName: entityName, publishedOnly: publishedOnly) tableView.reloadData() } } diff --git a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/UnknownEditorViewController.swift b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/UnknownEditorViewController.swift index 4340cc4b1cb3..cb6ea3517324 100644 --- a/WordPress/Classes/ViewRelated/Aztec/ViewControllers/UnknownEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/Aztec/ViewControllers/UnknownEditorViewController.swift @@ -9,14 +9,14 @@ class UnknownEditorViewController: UIViewController { /// Save Bar Button /// - fileprivate(set) var saveButton: UIBarButtonItem = { + fileprivate(set) lazy var saveButton: UIBarButtonItem = { let saveTitle = NSLocalizedString("Save", comment: "Save Action") return UIBarButtonItem(title: saveTitle, style: .plain, target: self, action: #selector(saveWasPressed)) }() /// Cancel Bar Button /// - fileprivate(set) var cancelButton: UIBarButtonItem = { + fileprivate(set) lazy var cancelButton: UIBarButtonItem = { let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel Action") return UIBarButtonItem(title: cancelTitle, style: .plain, target: self, action: #selector(cancelWasPressed)) }() diff --git a/WordPress/Classes/ViewRelated/Blaze/BlazePostPreviewView.swift b/WordPress/Classes/ViewRelated/Blaze/BlazePostPreviewView.swift new file mode 100644 index 000000000000..313b56b79a8c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blaze/BlazePostPreviewView.swift @@ -0,0 +1,112 @@ +import UIKit + +final class BlazePostPreviewView: UIView { + + // MARK: - Subviews + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [labelStackView, featuredImageView]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.stackViewSpacing + stackView.axis = .horizontal + stackView.alignment = .top + stackView.distribution = .fill + return stackView + }() + + private lazy var labelStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.stackViewSpacing + stackView.axis = .vertical + stackView.alignment = .fill + stackView.distribution = .fill + return stackView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + label.numberOfLines = 0 + label.text = post.titleForDisplay() + label.textColor = .text + return label + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + label.numberOfLines = 0 + label.text = post.permaLink + label.textColor = .textSubtle + return label + }() + + private lazy var featuredImageView: CachedAnimatedImageView = { + let imageView = CachedAnimatedImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + // MARK: - Properties + + private let post: AbstractPost + + private lazy var imageLoader: ImageLoader = { + return ImageLoader(imageView: featuredImageView, gifStrategy: .mediumGIFs) + }() + + // MARK: - Initializers + + init(post: AbstractPost) { + self.post = post + super.init(frame: .zero) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupView() { + backgroundColor = .systemGroupedBackground + layer.cornerRadius = Metrics.cornerRadius + + addSubview(stackView) + pinSubviewToAllEdges(stackView) + + setupFeaturedImage() + } + + private func setupFeaturedImage() { + if let url = post.featuredImageURL { + featuredImageView.isHidden = false + + let host = MediaHost(with: post, failure: { error in + // We'll log the error, so we know it's there, but we won't halt execution. + WordPressAppDelegate.crashLogging?.logError(error) + }) + + imageLoader.loadImage(with: url, from: host, preferredSize: CGSize(width: featuredImageView.frame.width, height: featuredImageView.frame.height)) + } else { + featuredImageView.isHidden = true + } + } +} + +extension BlazePostPreviewView { + + private enum Metrics { + static let stackViewSpacing: CGFloat = 16.0 + static let cornerRadius: CGFloat = 16.0 + } + +} diff --git a/WordPress/Classes/ViewRelated/Blaze/Helpers/BlazeEventsTracker.swift b/WordPress/Classes/ViewRelated/Blaze/Helpers/BlazeEventsTracker.swift new file mode 100644 index 000000000000..8ee18ef59da2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blaze/Helpers/BlazeEventsTracker.swift @@ -0,0 +1,65 @@ +import Foundation + +@objcMembers class BlazeEventsTracker: NSObject { + + private static let currentStepPropertyKey = "current_step" + private static let errorPropertyKey = "error" + + static func trackEntryPointDisplayed(for source: BlazeSource) { + WPAnalytics.track(.blazeEntryPointDisplayed, properties: analyticsProperties(for: source)) + } + + static func trackEntryPointTapped(for source: BlazeSource) { + WPAnalytics.track(.blazeEntryPointTapped, properties: analyticsProperties(for: source)) + } + + static func trackContextualMenuAccessed(for source: BlazeSource) { + WPAnalytics.track(.blazeContextualMenuAccessed, properties: analyticsProperties(for: source)) + } + + static func trackHideThisTapped(for source: BlazeSource) { + WPAnalytics.track(.blazeCardHidden, properties: analyticsProperties(for: source)) + } + + static func trackOverlayDisplayed(for source: BlazeSource) { + WPAnalytics.track(.blazeOverlayDisplayed, properties: analyticsProperties(for: source)) + } + + static func trackOverlayButtonTapped(for source: BlazeSource) { + WPAnalytics.track(.blazeOverlayButtonTapped, properties: analyticsProperties(for: source)) + } + + static func trackOverlayDismissed(for source: BlazeSource) { + WPAnalytics.track(.blazeOverlayDismissed, properties: analyticsProperties(for: source)) + } + + static func trackBlazeFlowStarted(for source: BlazeSource) { + WPAnalytics.track(.blazeFlowStarted, properties: analyticsProperties(for: source)) + } + + static func trackBlazeFlowCompleted(for source: BlazeSource, currentStep: String) { + WPAnalytics.track(.blazeFlowCompleted, properties: analyticsProperties(for: source, currentStep: currentStep)) + } + + static func trackBlazeFlowCanceled(for source: BlazeSource, currentStep: String) { + WPAnalytics.track(.blazeFlowCanceled, properties: analyticsProperties(for: source, currentStep: currentStep)) + } + + static func trackBlazeFlowError(for source: BlazeSource, currentStep: String, error: Error? = nil) { + var properties = analyticsProperties(for: source, currentStep: currentStep) + if let error { + properties[Self.errorPropertyKey] = error.localizedDescription + } + WPAnalytics.track(.blazeFlowError, properties: properties) + } + + private static func analyticsProperties(for source: BlazeSource) -> [String: String] { + return [WPAppAnalyticsKeySource: source.description] + } + + private static func analyticsProperties(for source: BlazeSource, currentStep: String) -> [String: String] { + return [ + WPAppAnalyticsKeySource: source.description, + Self.currentStepPropertyKey: currentStep] + } +} diff --git a/WordPress/Classes/ViewRelated/Blaze/Helpers/BlazeHelper.swift b/WordPress/Classes/ViewRelated/Blaze/Helpers/BlazeHelper.swift new file mode 100644 index 000000000000..9824e6484ab1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blaze/Helpers/BlazeHelper.swift @@ -0,0 +1,28 @@ +import Foundation + +@objcMembers final class BlazeHelper: NSObject { + + static func isBlazeFlagEnabled() -> Bool { + guard AppConfiguration.isJetpack else { + return false + } + return RemoteFeatureFlag.blaze.enabled() + } + + static func shouldShowCard(for blog: Blog) -> Bool { + guard isBlazeFlagEnabled() && blog.isBlazeApproved else { + return false + } + return true + } + + static func hideBlazeCard(for blog: Blog?) { + guard let blog, + let siteID = blog.dotComID?.intValue else { + DDLogError("Blaze: error hiding blaze card.") + return + } + BlogDashboardPersonalizationService(siteID: siteID) + .setEnabled(false, for: .blaze) + } +} diff --git a/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazeOverlayViewController.swift b/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazeOverlayViewController.swift new file mode 100644 index 000000000000..ef2b16b5eae2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazeOverlayViewController.swift @@ -0,0 +1,224 @@ +import UIKit + +final class BlazeOverlayViewController: UIViewController { + + // MARK: - Subviews + + private lazy var closeButtonItem: UIBarButtonItem = { + let closeButton = CircularImageButton() + + let fontForSystemImage = UIFont.systemFont(ofSize: Metrics.closeButtonSize) + let configuration = UIImage.SymbolConfiguration(font: fontForSystemImage) + let closeButtonImage = UIImage(systemName: Constants.closeButtonSystemName, withConfiguration: configuration) + + closeButton.setImage(closeButtonImage, for: .normal) + closeButton.tintColor = UIColor(light: .systemGray6, dark: .systemGray5) + closeButton.setImageBackgroundColor(UIColor(light: .black, dark: .white)) + + NSLayoutConstraint.activate([ + closeButton.widthAnchor.constraint(equalToConstant: Metrics.closeButtonSize), + closeButton.heightAnchor.constraint(equalTo: closeButton.widthAnchor) + ]) + + closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) + + return UIBarButtonItem(customView: closeButton) + }() + + private lazy var scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.bounces = false + + scrollView.addSubview(stackView) + scrollView.pinSubviewToAllEdges(stackView) + NSLayoutConstraint.activate([ + stackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + ]) + + return scrollView + }() + + private lazy var stackView: UIStackView = { + let subviews = [ + imageView, + titleLabel, + descriptionLabel, + footerStackView + ] + let stackView = UIStackView(arrangedSubviews: subviews) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.stackViewSpacing + stackView.axis = .vertical + stackView.alignment = .center + return stackView + }() + + private lazy var imageView: UIImageView = { + let imageView = UIImageView(image: UIImage(named: viewModel.iconName)) + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = viewModel.title + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = WPStyleGuide.fontForTextStyle(.title1, fontWeight: .semibold) + label.numberOfLines = 0 + label.textAlignment = .center + label.textColor = .text + return label + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.attributedText = viewModel.bulletedDescription(font: WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular), + textColor: .textSubtle) + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.numberOfLines = 0 + return label + }() + + private lazy var footerStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [blazeButton]) + + if let post { + let previewView = BlazePostPreviewView(post: post) + previewView.translatesAutoresizingMaskIntoConstraints = false + stackView.insertArrangedSubview(previewView, at: 0) + } + + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.footerStackViewSpacing + stackView.axis = .vertical + return stackView + }() + + private lazy var blazeButton: UIButton = { + let button = FancyButton() + button.isPrimary = true + button.translatesAutoresizingMaskIntoConstraints = false + button.primaryNormalBackgroundColor = Colors.blazeButtonBackgroundColor + button.setAttributedTitle(viewModel.buttonTitle, for: .normal) + button.addTarget(self, action: #selector(blazeButtonTapped), for: .touchUpInside) + return button + }() + + // MARK: - Properties + + private let source: BlazeSource + private let blog: Blog + private let post: AbstractPost? + private let viewModel: BlazeOverlayViewModel + + // MARK: - Initializers + + init(source: BlazeSource, blog: Blog, post: AbstractPost? = nil) { + self.source = source + self.blog = blog + self.post = post + self.viewModel = BlazeOverlayViewModel(source: source, blog: blog, post: post) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + // This VC is designed to be initialized programmatically. + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setupView() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + BlazeEventsTracker.trackOverlayDisplayed(for: source) + } + + // MARK: - Setup + + private func setupNavigationBar() { + navigationItem.rightBarButtonItem = closeButtonItem + + let appearance = UINavigationBarAppearance() + appearance.backgroundColor = Colors.backgroundColor + appearance.shadowColor = .clear + navigationItem.standardAppearance = appearance + navigationItem.compactAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + if #available(iOS 15.0, *) { + navigationItem.compactScrollEdgeAppearance = appearance + } + } + + private func setupView() { + view.backgroundColor = Colors.backgroundColor + view.addSubview(scrollView) + view.pinSubviewToAllEdges(scrollView, insets: Metrics.contentInsets) + + NSLayoutConstraint.activate([ + blazeButton.heightAnchor.constraint(equalToConstant: Metrics.blazeButtonHeight), + blazeButton.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), + blazeButton.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), + ]) + + } + + // MARK: - Button Action + + @objc private func closeButtonTapped() { + BlazeEventsTracker.trackOverlayDismissed(for: source) + dismiss(animated: true) + } + + @objc private func blazeButtonTapped() { + BlazeEventsTracker.trackOverlayButtonTapped(for: source) + + guard let post else { + BlazeFlowCoordinator.presentBlazeWebFlow(in: self, source: source, blog: blog, delegate: self) + return + } + + BlazeFlowCoordinator.presentBlazeWebFlow(in: self, source: source, blog: blog, postID: post.postID, delegate: self) + } +} + +// MARK: - BlazeWebViewControllerDelegate + +extension BlazeOverlayViewController: BlazeWebViewControllerDelegate { + + func dismissBlazeWebViewController(_ controller: BlazeWebViewController) { + presentingViewController?.dismiss(animated: true) + } +} + +private extension BlazeOverlayViewController { + + enum Metrics { + static let contentInsets = UIEdgeInsets(top: 20.0, left: 20.0, bottom: 20.0, right: 20.0) + static let stackViewSpacing: CGFloat = 30.0 + static let footerStackViewSpacing: CGFloat = 10.0 + static let closeButtonSize: CGFloat = 30.0 + static let blazeButtonHeight: CGFloat = 54.0 + } + + enum Constants { + static let closeButtonSystemName = "xmark.circle.fill" + } + + enum Colors { + static let blazeButtonBackgroundColor = UIColor(light: .black, dark: UIColor(red: 0.11, green: 0.11, blue: 0.118, alpha: 1)) + static let backgroundColor = UIColor(light: .systemBackground, dark: .black) + } + +} diff --git a/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazeOverlayViewModel.swift b/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazeOverlayViewModel.swift new file mode 100644 index 000000000000..9033602a2a44 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazeOverlayViewModel.swift @@ -0,0 +1,82 @@ +import Foundation + +struct BlazeOverlayViewModel { + + let source: BlazeSource + let blog: Blog + let post: AbstractPost? + + var iconName: String { + return "flame-circle" + } + + var title: String { + return Strings.title + } + + var buttonTitle: NSAttributedString { + switch source { + case .dashboardCard: + fallthrough + case .menuItem: + return buttonTitleWithIcon(title: Strings.blazeButtonTitle) + case .postsList: + return buttonTitleWithIcon(title: Strings.blazePostButtonTitle) + case .pagesList: + return buttonTitleWithIcon(title: Strings.blazePageButtonTitle) + } + } + + func bulletedDescription(font: UIFont, textColor: UIColor) -> NSAttributedString { + let bullet = "• " + + let descriptions: [String] = [ + Strings.description1, + Strings.description2, + Strings.description3 + ] + let mappedDescriptions = descriptions.map { return bullet + $0 } + + var attributes = [NSAttributedString.Key: Any]() + attributes[.font] = font + attributes[.foregroundColor] = textColor + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.headIndent = (bullet as NSString).size(withAttributes: attributes).width + attributes[.paragraphStyle] = paragraphStyle + + let string = mappedDescriptions.joined(separator: "\n\n") + return NSAttributedString(string: string, attributes: attributes) + } + + private func buttonTitleWithIcon(title: String) -> NSAttributedString { + let string = NSMutableAttributedString(string: "\(title) ") + + let imageAttachment = NSTextAttachment() + imageAttachment.bounds = CGRect(x: 0.0, y: -Metrics.iconOffset, width: Metrics.iconSize, height: Metrics.iconSize) + imageAttachment.image = UIImage(named: "icon-blaze") + + let imageString = NSAttributedString(attachment: imageAttachment) + string.append(imageString) + + return string + } + + private enum Strings { + static let title = NSLocalizedString("blaze.overlay.title", value: "Drive more traffic to your site with Blaze", comment: "Title for the Blaze overlay.") + + static let description1 = NSLocalizedString("blaze.overlay.descriptionOne", value: "Promote any post or page in only a few minutes for just a few dollars a day.", comment: "Description for the Blaze overlay.") + static let description2 = NSLocalizedString("blaze.overlay.descriptionTwo", value: "Your content will appear on millions of WordPress and Tumblr sites.", comment: "Description for the Blaze overlay.") + static let description3 = NSLocalizedString("blaze.overlay.descriptionThree", value: "Track your campaign's performance and cancel at anytime.", comment: "Description for the Blaze overlay.") + + static let blazeButtonTitle = NSLocalizedString("blaze.overlay.buttonTitle", value: "Blaze a post now", comment: "Button title for a Blaze overlay prompting users to select a post to blaze.") + static let blazePostButtonTitle = NSLocalizedString("blaze.overlay.withPost.buttonTitle", value: "Blaze this post", comment: "Button title for the Blaze overlay prompting users to blaze the selected post.") + static let blazePageButtonTitle = NSLocalizedString("blaze.overlay.withPage.buttonTitle", value: "Blaze this page", comment: "Button title for the Blaze overlay prompting users to blaze the selected page.") + + } + + private enum Metrics { + static let iconSize: CGFloat = 24.0 + static let iconOffset: CGFloat = 5.0 + } +} diff --git a/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazePostPreviewView.swift b/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazePostPreviewView.swift new file mode 100644 index 000000000000..c2fa974e9193 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blaze/Overlay/BlazePostPreviewView.swift @@ -0,0 +1,129 @@ +import UIKit + +final class BlazePostPreviewView: UIView { + + // MARK: - Subviews + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [labelStackView, featuredImageView]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.stackViewSpacing + stackView.axis = .horizontal + stackView.alignment = .top + stackView.isLayoutMarginsRelativeArrangement = true + stackView.directionalLayoutMargins = Metrics.stackViewMargins + return stackView + }() + + private lazy var labelStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.labelStackViewSpacing + stackView.axis = .vertical + return stackView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = WPStyleGuide.fontForTextStyle(.callout, fontWeight: .semibold) + label.numberOfLines = 0 + label.text = post.titleForDisplay() + label.textColor = .text + return label + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .regular) + label.numberOfLines = 0 + label.text = post.permaLink + label.textColor = .textSubtle + return label + }() + + private lazy var featuredImageView: CachedAnimatedImageView = { + let imageView = CachedAnimatedImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: Metrics.featuredImageSize), + imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor) + ]) + + imageView.clipsToBounds = true + imageView.layer.cornerRadius = Metrics.featuredImageCornerRadius + + return imageView + }() + + // MARK: - Properties + + private let post: AbstractPost + + private lazy var imageLoader: ImageLoader = { + return ImageLoader(imageView: featuredImageView, gifStrategy: .mediumGIFs) + }() + + // MARK: - Initializers + + init(post: AbstractPost) { + self.post = post + super.init(frame: .zero) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupView() { + backgroundColor = Colors.backgroundColor + layer.cornerRadius = Metrics.cornerRadius + + addSubview(stackView) + pinSubviewToAllEdges(stackView) + + setupFeaturedImage() + } + + private func setupFeaturedImage() { + if let url = post.featuredImageURL { + featuredImageView.isHidden = false + + let host = MediaHost(with: post, failure: { error in + // We'll log the error, so we know it's there, but we won't halt execution. + WordPressAppDelegate.crashLogging?.logError(error) + }) + + let preferredSize = CGSize(width: featuredImageView.frame.width, height: featuredImageView.frame.height) + imageLoader.loadImage(with: url, from: host, preferredSize: preferredSize) + + } else { + featuredImageView.isHidden = true + } + } +} + +extension BlazePostPreviewView { + + private enum Metrics { + static let stackViewMargins = NSDirectionalEdgeInsets(top: 15.0, leading: 20.0, bottom: 15.0, trailing: 20.0) + static let stackViewSpacing: CGFloat = 15.0 + static let labelStackViewSpacing: CGFloat = 5.0 + static let cornerRadius: CGFloat = 15.0 + static let featuredImageSize: CGFloat = 80.0 + static let featuredImageCornerRadius: CGFloat = 5.0 + } + + private enum Colors { + static let backgroundColor = UIColor(light: .black, dark: .white).withAlphaComponent(0.05) + } +} diff --git a/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeFlowCoordinator.swift b/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeFlowCoordinator.swift new file mode 100644 index 000000000000..1758426229df --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeFlowCoordinator.swift @@ -0,0 +1,97 @@ +import Foundation +import UIKit + +@objc enum BlazeSource: Int, OverlaySource { + case dashboardCard + case menuItem + case postsList + case pagesList + + var description: String { + switch self { + case .dashboardCard: + return "dashboard_card" + case .menuItem: + return "menu_item" + case .postsList: + return "posts_list" + case .pagesList: + return "pages_list" + } + } + + var key: String { + return description + } + + var frequencyType: OverlayFrequencyTracker.FrequencyType { + return .showOnce + } +} + +@objcMembers class BlazeFlowCoordinator: NSObject { + + /// Used to present the blaze flow. If the blaze overlay was never presented for the provided source, + /// the overlay is shown. Otherwise, the blaze web view flow is presented. + /// Blazing a specific post and displaying a list of posts to choose from are both supported by this function. + /// - Parameters: + /// - viewController: The view controller where the web view or overlay should be presented in. + /// - source: The source that triggers the display of the blaze flow. + /// - blog: `Blog` object representing the site that is being blazed + /// - post: `AbstractPost` object representing the specific post to blaze. If `nil` is passed, + /// a general blaze overlay or web flow is displayed. If a valid value is passed, a blaze overlay with a post preview + /// or detailed web flow is displayed. + @objc(presentBlazeInViewController:source:blog:post:) + static func presentBlaze(in viewController: UIViewController, + source: BlazeSource, + blog: Blog, + post: AbstractPost? = nil) { + let frequencyTracker = OverlayFrequencyTracker(source: source, type: .blaze) + if frequencyTracker.shouldShow(forced: false) { + presentBlazeOverlay(in: viewController, source: source, blog: blog, post: post) + frequencyTracker.track() + } else { + presentBlazeWebFlow(in: viewController, source: source, blog: blog, postID: post?.postID) + } + } + + /// Used to display the blaze web flow without displaying an overlay. Blazing a specific post + /// and displaying a list of posts to choose from are both supported by this function. + /// - Parameters: + /// - viewController: The view controller where the web view should be presented in. + /// - source: The source that triggers the display of the blaze web view. + /// - blog: `Blog` object representing the site that is being blazed + /// - postID: `NSNumber` representing the ID of the post being blazed. If `nil` is passed, + /// the blaze site flow is triggered. If a valid value is passed, the blaze post flow is triggered. + /// - delegate: The delegate gets notified of changes happening in the web view. Default value is `nil` + @objc(presentBlazeWebFlowInViewController:source:blog:postID:delegate:) + static func presentBlazeWebFlow(in viewController: UIViewController, + source: BlazeSource, + blog: Blog, + postID: NSNumber? = nil, + delegate: BlazeWebViewControllerDelegate? = nil) { + let blazeViewController = BlazeWebViewController(source: source, blog: blog, postID: postID, delegate: delegate) + let navigationViewController = UINavigationController(rootViewController: blazeViewController) + navigationViewController.overrideUserInterfaceStyle = .light + navigationViewController.modalPresentationStyle = .formSheet + viewController.present(navigationViewController, animated: true) + } + + /// Used to display the blaze overlay. + /// - Parameters: + /// - viewController: The view controller where the overlay should be presented in. + /// - source: The source that triggers the display of the blaze overlay. + /// - blog: `Blog` object representing the site to blaze. + /// - post: `AbstractPost` object representing the specific post to blaze. If `nil` is passed, + /// a general blaze overlay is displayed. If a valid value is passed, a blaze overlay with a post preview + /// is displayed. + private static func presentBlazeOverlay(in viewController: UIViewController, + source: BlazeSource, + blog: Blog, + post: AbstractPost? = nil) { + let overlayViewController = BlazeOverlayViewController(source: source, blog: blog, post: post) + let navigationController = UINavigationController(rootViewController: overlayViewController) + navigationController.modalPresentationStyle = .formSheet + viewController.present(navigationController, animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeWebViewController.swift b/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeWebViewController.swift new file mode 100644 index 000000000000..e4352647d868 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeWebViewController.swift @@ -0,0 +1,214 @@ +import UIKit +import WebKit + +@objc protocol BlazeWebViewControllerDelegate { + func dismissBlazeWebViewController(_ controller: BlazeWebViewController) +} + +class BlazeWebViewController: UIViewController, BlazeWebView { + + // MARK: Private Variables + + private weak var delegate: BlazeWebViewControllerDelegate? + + private let webView: WKWebView + private var viewModel: BlazeWebViewModel? + private let progressView = WebProgressView() + private var reachabilityObserver: Any? + private var currentRequestURL: URL? + private var observingReachability: Bool { + return reachabilityObserver != nil + } + + // MARK: Lazy Loaded Views + + private lazy var dismissButton: UIBarButtonItem = { + let button = UIBarButtonItem(title: Strings.cancelButtonTitle, + style: .plain, + target: self, + action: #selector(dismissButtonTapped)) + return button + }() + + // MARK: Initializers + + init(source: BlazeSource, blog: Blog, postID: NSNumber?, delegate: BlazeWebViewControllerDelegate?) { + self.delegate = delegate + self.webView = WKWebView(frame: .zero) + super.init(nibName: nil, bundle: nil) + viewModel = BlazeWebViewModel(source: source, blog: blog, postID: postID, view: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: View Lifecycles + + override func viewDidLoad() { + super.viewDidLoad() + self.isModalInPresentation = true + overrideUserInterfaceStyle = .light + configureSubviews() + configureWebView() + configureNavBar() + viewModel?.startBlazeFlow() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopWaitingForConnectionRestored() + ReachabilityUtils.dismissNoInternetConnectionNotice() + } + + // MARK: Private Helpers + + private func configureSubviews() { + let subviews = [progressView, webView] + let stackView = UIStackView(arrangedSubviews: subviews) + stackView.axis = .vertical + view = stackView + } + + private func configureWebView() { + webView.navigationDelegate = self + webView.customUserAgent = WPUserAgent.wordPress() + progressView.observeProgress(webView: webView) + } + + private func configureNavBar() { + title = Strings.navigationTitle + navigationItem.rightBarButtonItem = dismissButton + configureNavBarAppearance() + reloadNavBar() + } + + private func configureNavBarAppearance() { + let appearance = UINavigationBarAppearance() + appearance.backgroundColor = Colors.navigationBarColor + appearance.shadowColor = .clear + navigationItem.standardAppearance = appearance + navigationItem.compactAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + if #available(iOS 15.0, *) { + navigationItem.compactScrollEdgeAppearance = appearance + } + } + + // MARK: Reachability Helpers + + private func reloadCurrentURL() { + guard let currentRequestURL else { + return + } + let request = URLRequest(url: currentRequestURL) + webView.load(request) + } + + private func observeReachability() { + if !observingReachability { + ReachabilityUtils.showNoInternetConnectionNotice() + reloadWhenConnectionRestored() + } + } + + private func reloadWhenConnectionRestored() { + reachabilityObserver = ReachabilityUtils.observeOnceInternetAvailable { [weak self] in + self?.reloadCurrentURL() + } + } + + private func stopWaitingForConnectionRestored() { + guard let reachabilityObserver = reachabilityObserver else { + return + } + + NotificationCenter.default.removeObserver(reachabilityObserver) + self.reachabilityObserver = nil + } + + // MARK: BlazeWebView + + func load(request: URLRequest) { + webView.load(request) + } + + var cookieJar: CookieJar { + webView.configuration.websiteDataStore.httpCookieStore + } + + func reloadNavBar() { + guard let viewModel else { + dismissButton.isEnabled = true + dismissButton.title = Strings.cancelButtonTitle + return + } + dismissButton.isEnabled = viewModel.isCurrentStepDismissible() + dismissButton.title = viewModel.isFlowCompleted ? Strings.doneButtonTitle : Strings.cancelButtonTitle + } + + func dismissView() { + + if let delegate { + delegate.dismissBlazeWebViewController(self) + return + } + + dismiss(animated: true) + } + + // MARK: Actions + + @objc func dismissButtonTapped() { + viewModel?.dismissTapped() + } +} + +extension BlazeWebViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let viewModel else { + decisionHandler(.cancel) + return + } + + let policy = viewModel.shouldNavigate(to: navigationAction.request, with: navigationAction.navigationType) + if policy == .allow { + currentRequestURL = navigationAction.request.mainDocumentURL + } + + guard ReachabilityUtils.isInternetReachable() else { + observeReachability() + decisionHandler(.cancel) + return + } + + decisionHandler(policy) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + DDLogInfo("\(NSStringFromClass(type(of: self))) Error Loading [\(error)]") + + if !ReachabilityUtils.isInternetReachable() { + observeReachability() + } else { + viewModel?.webViewDidFail(with: error) + DDLogError("Blaze WebView \(webView) didFailProvisionalNavigation: \(error.localizedDescription)") + } + } +} + +private extension BlazeWebViewController { + enum Strings { + static let navigationTitle = NSLocalizedString("feature.blaze.title", + value: "Blaze", + comment: "Name of a feature that allows the user to promote their posts.") + static let cancelButtonTitle = NSLocalizedString("Cancel", comment: "Cancel. Action.") + static let doneButtonTitle = NSLocalizedString("Done", comment: "Done. Action.") + } + + enum Colors { + static let navigationBarColor = UIColor(hexString: "F2F1F6")?.withAlphaComponent(0.8) + } +} diff --git a/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeWebViewModel.swift b/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeWebViewModel.swift new file mode 100644 index 000000000000..445643f26040 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blaze/Webview/BlazeWebViewModel.swift @@ -0,0 +1,172 @@ +import Foundation + +protocol BlazeWebView { + func load(request: URLRequest) + func reloadNavBar() + func dismissView() + var cookieJar: CookieJar { get } +} + +class BlazeWebViewModel { + + // MARK: Public Variables + + var isFlowCompleted = false + private(set) var currentStep: String = BlazeFlowSteps.undefinedStep + + // MARK: Private Variables + + private let source: BlazeSource + private let blog: Blog + private let postID: NSNumber? + private let view: BlazeWebView + private let remoteConfigStore: RemoteConfigStore + private let externalURLHandler: ExternalURLHandler + private var linkBehavior: LinkBehavior = .all + + // MARK: Initializer + + init(source: BlazeSource, + blog: Blog, + postID: NSNumber?, + view: BlazeWebView, + remoteConfigStore: RemoteConfigStore = RemoteConfigStore(), + externalURLHandler: ExternalURLHandler = UIApplication.shared) { + self.source = source + self.blog = blog + self.postID = postID + self.view = view + self.remoteConfigStore = remoteConfigStore + self.externalURLHandler = externalURLHandler + setLinkBehavior() + } + + // MARK: Computed Variables + + private var initialURL: URL? { + guard let siteURL = blog.displayURL else { + return nil + } + var urlString: String + if let postID { + urlString = String(format: Constants.blazePostURLFormat, siteURL, postID.intValue, source.description) + } + else { + urlString = String(format: Constants.blazeSiteURLFormat, siteURL, source.description) + } + return URL(string: urlString) + } + + private var baseURLString: String? { + guard let siteURL = blog.displayURL else { + return nil + } + return String(format: Constants.baseURLFormat, siteURL) + } + + // MARK: Public Functions + + func startBlazeFlow() { + guard let initialURL else { + BlazeEventsTracker.trackBlazeFlowError(for: source, currentStep: currentStep) + view.dismissView() + return + } + authenticatedRequest(for: initialURL, with: view.cookieJar) { [weak self] (request) in + guard let weakSelf = self else { + return + } + weakSelf.view.load(request: request) + BlazeEventsTracker.trackBlazeFlowStarted(for: weakSelf.source) + } + } + + func dismissTapped() { + view.dismissView() + if isFlowCompleted { + BlazeEventsTracker.trackBlazeFlowCompleted(for: source, currentStep: currentStep) + } else { + BlazeEventsTracker.trackBlazeFlowCanceled(for: source, currentStep: currentStep) + } + } + + func shouldNavigate(to request: URLRequest, with type: WKNavigationType) -> WKNavigationActionPolicy { + currentStep = extractCurrentStep(from: request) ?? currentStep + updateIsFlowCompleted() + view.reloadNavBar() + return linkBehavior.handle(request: request, with: type, externalURLHandler: externalURLHandler) + } + + func isCurrentStepDismissible() -> Bool { + return currentStep != RemoteConfigParameter.blazeNonDismissibleStep.value(using: remoteConfigStore) + } + + func webViewDidFail(with error: Error) { + BlazeEventsTracker.trackBlazeFlowError(for: source, currentStep: currentStep) + } + + // MARK: Helpers + + private func setLinkBehavior() { + guard let baseURLString else { + return + } + self.linkBehavior = .withBaseURLOnly(baseURLString) + } + + private func extractCurrentStep(from request: URLRequest) -> String? { + guard let url = request.url, + let baseURLString, + url.absoluteString.hasPrefix(baseURLString) else { + return nil + } + if let query = url.query, query.contains(Constants.blazeWidgetQueryIdentifier) { + if let step = url.fragment { + return step + } + else { + return BlazeFlowSteps.blazeWidgetDefaultStep + } + } + else { + if let lastPathComponent = url.pathComponents.last, lastPathComponent == Constants.blazeCampaignsURLPath { + return BlazeFlowSteps.campaignsListStep + } + else { + return BlazeFlowSteps.postsListStep + } + } + } + + private func updateIsFlowCompleted() { + if currentStep == RemoteConfigParameter.blazeFlowCompletedStep.value(using: remoteConfigStore) { + isFlowCompleted = true // mark flow as completed if completion step is reached + } + if currentStep == BlazeFlowSteps.blazeWidgetDefaultStep { + isFlowCompleted = false // reset flag is user start a new ad creation flow inside the web view + } + } +} + +extension BlazeWebViewModel: WebKitAuthenticatable { + var authenticator: RequestAuthenticator? { + RequestAuthenticator(blog: blog) + } +} + +private extension BlazeWebViewModel { + enum Constants { + static let baseURLFormat = "https://wordpress.com/advertising/%@" + static let blazeSiteURLFormat = "https://wordpress.com/advertising/%@?source=%@" + static let blazePostURLFormat = "https://wordpress.com/advertising/%@?blazepress-widget=post-%d&source=%@" + static let blazeWidgetQueryIdentifier = "blazepress-widget" + static let blazeCampaignsURLPath = "campaigns" + } + + enum BlazeFlowSteps { + static let undefinedStep = "unspecified" + static let postsListStep = "posts-list" + static let campaignsListStep = "campaigns-list" + static let blazeWidgetDefaultStep = "step-1" + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Add Site/AddSiteAlertFactory.swift b/WordPress/Classes/ViewRelated/Blog/Add Site/AddSiteAlertFactory.swift new file mode 100644 index 000000000000..8ea22062c202 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Add Site/AddSiteAlertFactory.swift @@ -0,0 +1,58 @@ +import Foundation + +/// This class takes care of constructing our "Add Site" action sheets. It does not handle any presentation logic and does +/// not know any external data sources - all of the data is received as parameters. +@objc +class AddSiteAlertFactory: NSObject { + + @objc + func makeAddSiteAlert( + source: String?, + canCreateWPComSite: Bool, + createWPComSite: @escaping () -> Void, + canAddSelfHostedSite: Bool, + addSelfHostedSite: @escaping () -> Void) -> UIAlertController { + + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + + if canCreateWPComSite { + alertController.addAction(createWPComSiteAction(handler: createWPComSite)) + } + + if canAddSelfHostedSite { + alertController.addAction(addSelfHostedSiteAction(handler: addSelfHostedSite)) + } + + alertController.addAction(cancelAction()) + + WPAnalytics.track(.addSiteAlertDisplayed, properties: ["source": source ?? "unknown"]) + + return alertController + } + + // MARK: - Alert Action Definitions + + private func addSelfHostedSiteAction(handler: @escaping () -> Void) -> UIAlertAction { + return UIAlertAction( + title: NSLocalizedString("Add self-hosted site", comment: "Add self-hosted site button"), + style: .default, + handler: { _ in + handler() + }) + } + + private func cancelAction() -> UIAlertAction { + return UIAlertAction( + title: NSLocalizedString("Cancel", comment: "Cancel button"), + style: .cancel) + } + + private func createWPComSiteAction(handler: @escaping () -> Void) -> UIAlertAction { + return UIAlertAction( + title: NSLocalizedString("Create WordPress.com site", comment: "Create WordPress.com site button"), + style: .default, + handler: { _ in + handler() + }) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog + Me/GravatarButtonView.swift b/WordPress/Classes/ViewRelated/Blog/Blog + Me/GravatarButtonView.swift new file mode 100644 index 000000000000..d5e31c5dea59 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog + Me/GravatarButtonView.swift @@ -0,0 +1,78 @@ +/// a circular image view with an auto-updating gravatar image +class GravatarButtonView: CircularImageView { + + private let tappableWidth: CGFloat + + var adjustView: ((GravatarButtonView) -> Void)? + + override var image: UIImage? { + didSet { + adjustView?(self) + } + } + + init(tappableWidth: CGFloat) { + self.tappableWidth = tappableWidth + super.init(frame: CGRect.zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + let offset = tappableWidth - self.bounds.width + + let tappableArea = bounds.insetBy(dx: -offset, dy: -offset) + return tappableArea.contains(point) + } +} + + +/// Touch animation +extension GravatarButtonView { + + private struct AnimationConfiguration { + static let startAlpha: CGFloat = 0.5 + static let endAlpha: CGFloat = 1.0 + static let animationDuration: TimeInterval = 0.3 + } + /// animates the change of opacity from the current value to AnimationConfiguration.endAlpha + private func restoreAlpha() { + UIView.animate(withDuration: AnimationConfiguration.animationDuration) { + self.alpha = AnimationConfiguration.endAlpha + } + } + + /// Custom touch animation. + override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + alpha = AnimationConfiguration.startAlpha + } + + override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + restoreAlpha() + } + + override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { + super.touchesCancelled(touches, with: event) + restoreAlpha() + } +} + + +/// Border options +extension GravatarButtonView { + + private struct StandardBorder { + static let color: UIColor = .separator + + static let width = CGFloat(0.5) + } + /// sets border color and width to the circular image view. Defaults to StandardBorder values + func setBorder(color: UIColor = StandardBorder.color, width: CGFloat = StandardBorder.width) { + self.layer.borderColor = color.cgColor + self.layer.borderWidth = width + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog + Me/UIBarButtonItem+MeBarButton.swift b/WordPress/Classes/ViewRelated/Blog/Blog + Me/UIBarButtonItem+MeBarButton.swift index 8999d097b99c..3ddd8b392b2c 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog + Me/UIBarButtonItem+MeBarButton.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog + Me/UIBarButtonItem+MeBarButton.swift @@ -1,128 +1,113 @@ import Gridicons import UIKit - /// Add a UIBarButtonItem to the navigation bar that presents the Me scene. -extension BlogDetailsViewController { - @objc - func presentHandler() { - meScenePresenter.present(on: self, animated: true, completion: nil) - } - - @objc - func addMeButtonToNavigationBar() { - navigationItem.rightBarButtonItem = UIBarButtonItem(email: blog.account?.email, - target: self, - action: #selector(presentHandler)) +extension UIViewController { + + @objc func addMeButtonToNavigationBar(email: String?, meScenePresenter: ScenePresenter? = nil) { + var action: UIBarButtonItem.TapAction? + if let meScenePresenter { + action = { [weak self] in + guard let self = self else { + return + } + meScenePresenter.present(on: self, animated: true, completion: nil) + } + } + let rightBarButtonItem = UIBarButtonItem(email: email, action: action) + self.navigationItem.rightBarButtonItem = rightBarButtonItem } } -extension BlogListViewController { - @objc - private func presentHandler() { - meScenePresenter.present(on: self, animated: true, completion: nil) - } +extension UIBarButtonItem { + typealias TapAction = () -> Void - @objc - func addMeButtonToNavigationBar(with email: String) { - navigationItem.rightBarButtonItem = UIBarButtonItem(email: email, - target: self, - action: #selector(presentHandler)) - } + /// Assign the gravatar CircularImageView to the customView property and attach the passed target/action. + convenience init( + email: String?, + style: UIBarButtonItem.Style = .plain, + action: TapAction? = nil) { + + self.init() + makeMeButtonAccessible() + customView = makeGravatarTappableView(with: email, action: action) + } } +/// Methods to set the gravatar image on the me button +fileprivate extension UIBarButtonItem { -/// methods to set the gravatar image on the me button -private extension UIBarButtonItem { /// gravatar configuration parameters struct GravatarConfiguration { static let radius: CGFloat = 32 + // used for the gravatar image with no extra border added + static let extendedRadius: CGFloat = 36 static let tappableWidth: CGFloat = 44 - static let fallBackImage = Gridicon.iconOfType(.user) + static let fallBackImage = UIImage.gridicon(.userCircle) } - /// Assign the gravatar CircularImageView to the customView property and attach the passed target/action. - convenience init(email: String?, style: UIBarButtonItem.Style = .plain, target: Any?, action: Selector?) { - self.init() - makeMeButtonAccessible() - customView = makeGravatarTappableView(with: email) - addTapToCustomView(target: target, action: action) - } - - /// Create the gravatar CircluarImageView with a fade animation on tap. + /// Create the gravatar CircluarImageView with a fade animation on tap if an action is provided. /// If no valid email is provided, fall back to the circled user icon - func makeGravatarTappableView(with email: String?) -> UIView { - let gravatarImageView = CircularImageView() + func makeGravatarTappableView(with email: String?, action: TapAction? = nil) -> UIView { + let gravatarImageView = GravatarButtonView(tappableWidth: GravatarConfiguration.tappableWidth) + + gravatarImageView.adjustView = { [weak self] view in + // if there's a gravatar, add the border, if not, remove it and resize the userCircle image + if view.image == GravatarConfiguration.fallBackImage { + view.setBorder(width: 0) + self?.setSize(of: view, size: CGSize(width: GravatarConfiguration.extendedRadius, + height: GravatarConfiguration.extendedRadius)) + } else { + view.setBorder() + self?.setSize(of: view, size: CGSize(width: GravatarConfiguration.radius, + height: GravatarConfiguration.radius)) + } + } - gravatarImageView.isUserInteractionEnabled = true - gravatarImageView.animatesTouch = true - setSize(of: gravatarImageView, size: GravatarConfiguration.radius) - gravatarImageView.contentMode = .scaleAspectFit - gravatarImageView.setBorder() + gravatarImageView.isUserInteractionEnabled = action != nil + gravatarImageView.contentMode = .scaleAspectFill if let email = email { gravatarImageView.downloadGravatarWithEmail(email, placeholderImage: GravatarConfiguration.fallBackImage) } else { gravatarImageView.image = GravatarConfiguration.fallBackImage } - let tappableView = embedInTappableArea(gravatarImageView) - return tappableView - } + if let action = action { + let tapRecognizer = BindableTapGestureRecognizer(action: { _ in action() }) + gravatarImageView.addGestureRecognizer(tapRecognizer) + } - /// adds a 'tap' action to customView - func addTapToCustomView(target: Any?, action: Selector?) { - let tapRecognizer = UITapGestureRecognizer(target: target, action: action) - customView?.addGestureRecognizer(tapRecognizer) + return embedInView(gravatarImageView) } - /// embeds a view in a larger tappable area, vertically centered and aligned to the right - func embedInTappableArea(_ imageView: UIImageView) -> UIView { - let tappableView = UIView() - setSize(of: tappableView, size: GravatarConfiguration.tappableWidth) - tappableView.addSubview(imageView) - NSLayoutConstraint(item: imageView, - attribute: .centerY, - relatedBy: .equal, - toItem: tappableView, - attribute: .centerY, - multiplier: 1, - constant: 0) - .isActive = true - - NSLayoutConstraint(item: imageView, - attribute: .trailingMargin, - relatedBy: .equal, - toItem: tappableView, - attribute: .trailingMargin, - multiplier: 1, - constant: 0) - .isActive = true - - tappableView.isUserInteractionEnabled = true - return tappableView + /// embeds a view in a transparent view, vertically centered and aligned to the right + func embedInView(_ imageView: UIImageView) -> UIView { + let view = UIView() + setSize(of: view, size: CGSize(width: GravatarConfiguration.tappableWidth, + height: GravatarConfiguration.radius)) + + view.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + return view } /// constrains a squared UIImageView to a set size - func setSize(of view: UIView, size: CGFloat) { + func setSize(of view: UIView, size: CGSize) { + view.removeConstraints(view.constraints.filter { + $0.firstAttribute == .width || $0.firstAttribute == .height + }) + view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint(item: view, - attribute: .width, - relatedBy: .equal, - toItem: nil, - attribute: .notAnAttribute, - multiplier: 1, - constant: size) - .isActive = true - - NSLayoutConstraint(item: view, - attribute: .height, - relatedBy: .equal, - toItem: nil, - attribute: .notAnAttribute, - multiplier: 1, - constant: size) - .isActive = true + + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: size.width), + view.heightAnchor.constraint(equalToConstant: size.height) + ]) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/BlogDashboardViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/BlogDashboardViewController.swift new file mode 100644 index 000000000000..7e0934cef9ed --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/BlogDashboardViewController.swift @@ -0,0 +1,368 @@ +import UIKit +import WordPressShared + +typealias DashboardCollectionViewCell = UICollectionViewCell & Reusable & BlogDashboardCardConfigurable + +final class BlogDashboardViewController: UIViewController { + + var blog: Blog + var presentedPostStatus: String? + + private let embeddedInScrollView: Bool + + private lazy var viewModel: BlogDashboardViewModel = { + BlogDashboardViewModel(viewController: self, blog: blog) + }() + + lazy var collectionView: DynamicHeightCollectionView = { + let collectionView = DynamicHeightCollectionView(frame: .zero, collectionViewLayout: createLayout()) + collectionView.translatesAutoresizingMaskIntoConstraints = false + if !embeddedInScrollView { + collectionView.refreshControl = refreshControl + } + return collectionView + }() + + private lazy var refreshControl: UIRefreshControl = { + let refreshControl = UIRefreshControl() + refreshControl.addTarget(self, action: #selector(refreshControlPulled), for: .valueChanged) + return refreshControl + }() + + /// The "My Site" parent view controller + var mySiteViewController: MySiteViewController? { + return parent as? MySiteViewController + } + + /// The "My Site" main scroll view + var mySiteScrollView: UIScrollView? { + return view.superview?.superview as? UIScrollView + } + + @objc init(blog: Blog, embeddedInScrollView: Bool) { + self.blog = blog + self.embeddedInScrollView = embeddedInScrollView + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func viewDidLoad() { + super.viewDidLoad() + setupNavigation() + setupCollectionView() + addHeightObservers() + addWillEnterForegroundObserver() + addQuickStartObserver() + viewModel.viewDidLoad() + + // Force the view to update its layout immediately, so the content size is calculated correctly + collectionView.layoutIfNeeded() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.loadCards { [weak self] cards in + guard let trackCardViewed = self?.trackCardViewed else { + return + } + cards.forEach(trackCardViewed) + } + QuickStartTourGuide.shared.currentEntryPoint = .blogDashboard + startAlertTimer() + + WPAnalytics.track(.mySiteDashboardShown) + } + + override func viewWillDisappear(_ animated: Bool) { + stopAlertTimer() + } + + func reloadCardsLocally() { + viewModel.loadCardsFromCache() + } + + /// If you want to give any feedback when the dashboard + /// started loading just change this method. + /// For not, it will be transparent + /// + func showLoading() { } + + /// If you want to give any feedback when the dashboard + /// stops loading just change this method. + /// + func stopLoading() { } + + func loadingFailure() { + displayActionableNotice(title: Strings.failureTitle, actionTitle: Strings.dismiss) + } + + func update(blog: Blog) { + guard self.blog.dotComID != blog.dotComID else { + return + } + + self.blog = blog + viewModel.blog = blog + BlogDashboardAnalytics.shared.reset() + viewModel.loadCardsFromCache() + viewModel.loadCards() + } + + @objc func refreshControlPulled() { + pulledToRefresh { [weak self] in + self?.refreshControl.endRefreshing() + } + } + + func pulledToRefresh(completion: (() -> Void)? = nil) { + viewModel.loadCards { _ in + completion?() + } + } + + private func setupNavigation() { + title = Strings.home + } + + private func setupCollectionView() { + collectionView.isScrollEnabled = !embeddedInScrollView + collectionView.backgroundColor = .listBackground + collectionView.register(DashboardMigrationSuccessCell.self, forCellWithReuseIdentifier: DashboardMigrationSuccessCell.self.defaultReuseID) + collectionView.register(DashboardQuickActionsCardCell.self, forCellWithReuseIdentifier: DashboardQuickActionsCardCell.self.defaultReuseID) + DashboardCard.allCases.forEach { + collectionView.register($0.cell, forCellWithReuseIdentifier: $0.cell.defaultReuseID) + } + + view.addSubview(collectionView) + view.pinSubviewToAllEdges(collectionView) + } + + private func addHeightObservers() { + NotificationCenter.default.addObserver(self, + selector: #selector(self.updateCollectionViewHeight(notification:)), + name: .dashboardCardTableViewSizeChanged, + object: nil) + } + + private func addWillEnterForegroundObserver() { + NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + } + + private func addQuickStartObserver() { + NotificationCenter.default.addObserver(forName: .QuickStartTourElementChangedNotification, object: nil, queue: nil) { [weak self] notification in + + guard let self = self else { + return + } + + if let info = notification.userInfo, + let element = info[QuickStartTourGuide.notificationElementKey] as? QuickStartTourElement { + + switch element { + case .setupQuickStart: + self.loadCardsFromCache() + self.displayQuickStart() + case .updateQuickStart: + self.loadCardsFromCache() + case .stats, .mediaScreen: + if self.embeddedInScrollView { + self.mySiteScrollView?.scrollToTop(animated: true) + } else { + self.collectionView.scrollToTop(animated: true) + } + default: + break + } + } + } + } + + @objc private func updateCollectionViewHeight(notification: Notification) { + collectionView.collectionViewLayout.invalidateLayout() + } + + /// Load cards if view is appearing + @objc private func loadCards() { + guard view.superview != nil else { + return + } + + viewModel.loadCards() + } + + /// Load card from cache + @objc private func loadCardsFromCache() { + viewModel.loadCardsFromCache() + } + + @objc private func willEnterForeground() { + BlogDashboardAnalytics.shared.reset() + loadCards() + } + + private func trackCardViewed(_ card: DashboardCardModel) { + guard let event = card.cardType.viewedAnalytic else { + return + } + WPAnalytics.track(event, properties: [WPAppAnalyticsKeyTabSource: "dashboard"]) + } +} + +// MARK: - Collection view layout + +extension BlogDashboardViewController { + + private func createLayout() -> UICollectionViewLayout { + UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in + self?.createLayoutSection(for: sectionIndex) + } + } + + private func createLayoutSection(for sectionIndex: Int) -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(Constants.estimatedHeight)) + + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + let isQuickActionSection = viewModel.isQuickActionsSection(sectionIndex) + let isMigrationSuccessCardSection = viewModel.isMigrationSuccessCardSection(sectionIndex) + let horizontalInset = isQuickActionSection ? 0 : Constants.horizontalSectionInset + let bottomInset = isQuickActionSection || isMigrationSuccessCardSection ? 0 : Constants.verticalSectionInset + section.contentInsets = NSDirectionalEdgeInsets(top: Constants.verticalSectionInset, + leading: horizontalInset, + bottom: bottomInset, + trailing: horizontalInset) + + section.interGroupSpacing = Constants.cellSpacing + + return section + } +} + +private var alertWorkItem: DispatchWorkItem? + +extension BlogDashboardViewController { + @objc func startAlertTimer() { + let newWorkItem = DispatchWorkItem { [weak self] in + self?.showNoticeAsNeeded() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: newWorkItem) + alertWorkItem = newWorkItem + } + + @objc func stopAlertTimer() { + alertWorkItem?.cancel() + alertWorkItem = nil + } + + private func showNoticeAsNeeded() { + let quickStartGuide = QuickStartTourGuide.shared + + guard let tourToSuggest = quickStartGuide.tourToSuggest(for: blog) else { + quickStartGuide.showCongratsNoticeIfNeeded(for: blog) + return + } + + if quickStartGuide.tourInProgress { + // If tour is in progress, show notice regardless of quickstart is shown in dashboard or my site + quickStartGuide.suggest(tourToSuggest, for: blog) + } else { + guard shouldShowQuickStartChecklist() else { + return + } + // Show initial notice only if quick start is shown in the dashboard + quickStartGuide.suggest(tourToSuggest, for: blog) + } + } + + private func shouldShowQuickStartChecklist() -> Bool { + return DashboardCard.quickStart.shouldShow(for: blog) + } + + private func displayQuickStart() { + let currentCollections = QuickStartFactory.collections(for: blog) + guard let collectionToShow = currentCollections.first else { + return + } + let checklist = QuickStartChecklistViewController(blog: blog, collection: collectionToShow) + let navigationViewController = UINavigationController(rootViewController: checklist) + present(navigationViewController, animated: true) + + QuickStartTourGuide.shared.visited(.checklist) + } +} + +extension BlogDashboardViewController { + + private enum Strings { + static let home = NSLocalizedString("Home", comment: "Title for the dashboard screen.") + static let failureTitle = NSLocalizedString("Couldn't update. Check that you're online and refresh.", comment: "Content show when the dashboard fails to load") + static let dismiss = NSLocalizedString( + "blogDashboard.dismiss", + value: "Dismiss", + comment: "Action shown in a bottom notice to dismiss it." + ) + } + + + private enum Constants { + static let estimatedWidth: CGFloat = 100 + static let estimatedHeight: CGFloat = 44 + static let horizontalSectionInset: CGFloat = 20 + static let verticalSectionInset: CGFloat = 20 + static let cellSpacing: CGFloat = 20 + } +} + +// MARK: - UI Popover Delegate + +/// This view controller may host a `DashboardPromptsCardCell` that requires presenting a `MenuSheetViewController`, +/// a fallback implementation of `UIMenu` for iOS 13. For more details, see the docs on `MenuSheetViewController`. +/// +/// NOTE: This should be removed once we drop support for iOS 13. +/// +extension BlogDashboardViewController: UIPopoverPresentationControllerDelegate { + // Force popover views to be presented as a popover (instead of being presented as a form sheet on iPhones). + public func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return .none + } +} + +// MARK: - Helper functions + +private extension Collection where Element == DashboardCardModel { + var hasPrompts: Bool { + contains(where: { $0.cardType == .prompts }) + } +} + +// MARK: - Jetpack Remote Install Delegate + +extension BlogDashboardViewController: JetpackRemoteInstallDelegate { + func jetpackRemoteInstallCompleted() { + dismiss(animated: true) { + self.pulledToRefresh() + } + } + + func jetpackRemoteInstallCanceled() { + dismiss(animated: true) { + self.pulledToRefresh() + } + } + + func jetpackRemoteInstallWebviewFallback() { + // No op + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift new file mode 100644 index 000000000000..1f7c3d187803 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell+ActivityPresenter.swift @@ -0,0 +1,83 @@ +import Foundation + +// MARK: - ActivityPresenter + +extension DashboardActivityLogCardCell: ActivityPresenter { + + func presentDetailsFor(activity: FormattableActivity) { + guard + let blog, + let site = JetpackSiteRef(blog: blog), + let presentingViewController else { + return + } + + WPAnalytics.track(.dashboardCardItemTapped, + properties: ["type": DashboardCard.activityLog.rawValue], + blog: blog) + + let detailVC = ActivityDetailViewController.loadFromStoryboard() + detailVC.site = site + detailVC.rewindStatus = store.state.rewindStatus[site] + detailVC.formattableActivity = activity + detailVC.presenter = self + + presentingViewController.navigationController?.pushViewController(detailVC, animated: true) + } + + func presentBackupOrRestoreFor(activity: Activity, from sender: UIButton) { + // Do nothing - this action isn't available for the activity log dashboard card + } + + func presentBackupFor(activity: Activity, from: String?) { + guard + let blog, + let site = JetpackSiteRef(blog: blog), + let presentingViewController else { + return + } + + let backupOptionsVC = JetpackBackupOptionsViewController(site: site, activity: activity) + backupOptionsVC.presentedFrom = from ?? Constants.sourceIdentifier + let navigationVC = UINavigationController(rootViewController: backupOptionsVC) + presentingViewController.present(navigationVC, animated: true) + } + + func presentRestoreFor(activity: Activity, from: String?) { + guard activity.isRewindable, activity.rewindID != nil else { + return + } + + guard + let blog, + let site = JetpackSiteRef(blog: blog), + let presentingViewController else { + return + } + + let restoreOptionsVC = JetpackRestoreOptionsViewController(site: site, + activity: activity, + isAwaitingCredentials: store.isAwaitingCredentials(site: site)) + + restoreOptionsVC.restoreStatusDelegate = self + restoreOptionsVC.presentedFrom = from ?? Constants.sourceIdentifier + let navigationVC = UINavigationController(rootViewController: restoreOptionsVC) + presentingViewController.present(navigationVC, animated: true) + } +} + +// MARK: - JetpackRestoreStatusViewControllerDelegate + +extension DashboardActivityLogCardCell: JetpackRestoreStatusViewControllerDelegate { + + func didFinishViewing(_ controller: JetpackRestoreStatusViewController) { + controller.dismiss(animated: true) + } +} + +extension DashboardActivityLogCardCell { + + private enum Constants { + static let sourceIdentifier = "dashboard" + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift new file mode 100644 index 000000000000..631c11953b5f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Activity Log/DashboardActivityLogCardCell.swift @@ -0,0 +1,210 @@ +import UIKit + +final class DashboardActivityLogCardCell: DashboardCollectionViewCell { + + enum ActivityLogSection: CaseIterable { + case activities + } + + typealias DataSource = UITableViewDiffableDataSource<ActivityLogSection, Activity> + typealias Snapshot = NSDiffableDataSourceSnapshot<ActivityLogSection, Activity> + + private(set) var blog: Blog? + private(set) weak var presentingViewController: BlogDashboardViewController? + private(set) lazy var dataSource = createDataSource() + + let store = StoreContainer.shared.activity + + // MARK: - Views + + private lazy var cardFrameView: BlogDashboardCardFrameView = { + let frameView = BlogDashboardCardFrameView() + frameView.translatesAutoresizingMaskIntoConstraints = false + frameView.setTitle(Strings.title) + return frameView + }() + + lazy var tableView: UITableView = { + let tableView = DashboardCardTableView() + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.isScrollEnabled = false + tableView.backgroundColor = nil + let activityCellNib = ActivityTableViewCell.defaultNib + tableView.register(activityCellNib, forCellReuseIdentifier: ActivityTableViewCell.defaultReuseID) + tableView.separatorStyle = .none + return tableView + }() + + // MARK: - Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + setupView() + } + + // MARK: - Lifecycle + + override func prepareForReuse() { + super.prepareForReuse() + tableView.dataSource = nil + } + + // MARK: - View setup + + private func setupView() { + contentView.addSubview(cardFrameView) + contentView.pinSubviewToAllEdges(cardFrameView, priority: .defaultHigh) + + cardFrameView.add(subview: tableView) + tableView.delegate = self + } + + // MARK: - BlogDashboardCardConfigurable + + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + self.blog = blog + self.presentingViewController = viewController + + tableView.dataSource = dataSource + updateDataSource(with: apiResponse?.activity?.value?.current?.orderedItems ?? []) + + configureHeaderAction(for: blog) + configureContextMenu(for: blog) + + BlogDashboardAnalytics.shared.track(.dashboardCardShown, + properties: ["type": DashboardCard.activityLog.rawValue], + blog: blog) + } + + private func configureHeaderAction(for blog: Blog) { + cardFrameView.onHeaderTap = { [weak self] in + self?.showActivityLog(for: blog, tapSource: Constants.headerTapSource) + } + } + + private func configureContextMenu(for blog: Blog) { + cardFrameView.onEllipsisButtonTap = { + BlogDashboardAnalytics.trackContextualMenuAccessed(for: .activityLog) + } + cardFrameView.ellipsisButton.showsMenuAsPrimaryAction = true + + + let activityAction = UIAction(title: Strings.allActivity, + image: Style.allActivityImage, + handler: { [weak self] _ in self?.showActivityLog(for: blog, tapSource: Constants.contextMenuTapSource) }) + + // Wrap the activity action in a menu to display a divider between the activity action and hide this action. + // https://developer.apple.com/documentation/uikit/uimenu/options/3261455-displayinline + let activitySubmenu = UIMenu(title: String(), options: .displayInline, children: [activityAction]) + + + let hideThisAction = BlogDashboardHelpers.makeHideCardAction(for: .activityLog, + siteID: blog.dotComID?.intValue ?? 0) + + cardFrameView.ellipsisButton.menu = UIMenu(title: String(), options: .displayInline, children: [ + activitySubmenu, + hideThisAction + ]) + } + + // MARK: - Navigation + + private func showActivityLog(for blog: Blog, tapSource: String) { + guard let activityLogController = JetpackActivityLogViewController(blog: blog) else { + return + } + presentingViewController?.navigationController?.pushViewController(activityLogController, animated: true) + + WPAnalytics.track(.activityLogViewed, + withProperties: [ + WPAppAnalyticsKeyTapSource: tapSource + ]) + } + +} + +// MARK: - Diffable DataSource + +extension DashboardActivityLogCardCell { + + private func createDataSource() -> DataSource { + return DataSource(tableView: tableView) { (tableView, indexPath, activity) -> UITableViewCell? in + guard let cell = tableView.dequeueReusableCell(withIdentifier: ActivityTableViewCell.defaultReuseID) as? ActivityTableViewCell else { + return nil + } + + let formattableActivity = FormattableActivity(with: activity) + cell.configureCell(formattableActivity, displaysDate: true) + return cell + } + } + + private func updateDataSource(with activities: [Activity]) { + var snapshot = Snapshot() + snapshot.appendSections(ActivityLogSection.allCases) + + let activitiesToDisplay = Array(activities.prefix(Constants.maxActivitiesCount)) + snapshot.appendItems(activitiesToDisplay, toSection: .activities) + + dataSource.apply(snapshot) + } +} + +// MARK: - UITableViewDelegate + +extension DashboardActivityLogCardCell: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let activity = dataSource.itemIdentifier(for: indexPath) else { + return + } + + let formattableActivity = FormattableActivity(with: activity) + presentDetailsFor(activity: formattableActivity) + } +} + +// MARK: - Helpers + +extension DashboardActivityLogCardCell { + + static func shouldShowCard(for blog: Blog) -> Bool { + guard RemoteFeatureFlag.activityLogDashboardCard.enabled(), + blog.supports(.activity), + !blog.isWPForTeams() else { + return false + } + + return true + } +} + +extension DashboardActivityLogCardCell { + + private enum Constants { + static let maxActivitiesCount = 3 + static let headerTapSource = "activity_card_header" + static let contextMenuTapSource = "activity_card_context_menu" + } + + private enum Strings { + static let title = NSLocalizedString("dashboardCard.ActivityLog.title", + value: "Recent activity", + comment: "Title for the Activity Log dashboard card.") + static let allActivity = NSLocalizedString("dashboardCard.ActivityLog.contextMenu.allActivity", + value: "All activity", + comment: "Title for the Activity Log dashboard card context menu item that navigates the user to the full Activity Logs screen.") + } + + private enum Style { + static let allActivityImage = UIImage(systemName: "list.bullet.indent") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Blaze/BlazeCardView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Blaze/BlazeCardView.swift new file mode 100644 index 000000000000..84a6dd156149 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Blaze/BlazeCardView.swift @@ -0,0 +1,148 @@ +import UIKit + +final class BlazeCardView: UIView { + + // MARK: - Properties + + private let viewModel: BlazeCardViewModel + + // MARK: - Views + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = Style.titleLabelFont + label.text = Strings.title + label.textColor = .text + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.font = Style.descriptionLabelFont + label.text = Strings.description + label.textColor = .textSubtle + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var contentStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [titleLabel, descriptionLabel]) + stackView.axis = .vertical + stackView.spacing = Metrics.stackViewSpacing + stackView.isLayoutMarginsRelativeArrangement = true + stackView.directionalLayoutMargins = Metrics.contentDirectionalLayoutMargins + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private lazy var flameIcon: UIImageView = { + let image = UIImage(named: "blaze-flame")?.imageFlippedForRightToLeftLayoutDirection() + let imageView = UIImageView(image: image) + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private lazy var contextMenu: UIMenu = { + let hideThisAction = UIAction(title: Strings.hideThis, + image: Style.hideThisImage, + attributes: [UIMenuElement.Attributes.destructive], + handler: viewModel.onHideThisTap) + return UIMenu(title: String(), options: .displayInline, children: [hideThisAction]) + }() + + private lazy var cardFrameView: BlogDashboardCardFrameView = { + let frameView = BlogDashboardCardFrameView() + frameView.configureButtonContainerStackView() + frameView.onEllipsisButtonTap = viewModel.onEllipsisTap + frameView.ellipsisButton.showsMenuAsPrimaryAction = true + frameView.ellipsisButton.menu = contextMenu + frameView.hideHeader() + frameView.add(subview: contentStackView) + frameView.clipsToBounds = true + frameView.translatesAutoresizingMaskIntoConstraints = false + return frameView + }() + + // MARK: - Initializers + + init(_ viewModel: BlazeCardViewModel = BlazeCardViewModel()) { + self.viewModel = viewModel + super.init(frame: .zero) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + func setupView() { + addSubview(cardFrameView) + pinSubviewToAllEdges(cardFrameView) + + cardFrameView.addSubview(flameIcon) + cardFrameView.bringSubviewToFront(flameIcon) + NSLayoutConstraint.activate([ + flameIcon.trailingAnchor.constraint(equalTo: cardFrameView.trailingAnchor), + flameIcon.bottomAnchor.constraint(equalTo: cardFrameView.bottomAnchor) + ]) + + let tap = UITapGestureRecognizer(target: self, action: #selector(viewTapped)) + addGestureRecognizer(tap) + } + + // MARK: - Private + + @objc private func viewTapped() { + viewModel.onViewTap() + } +} + +extension BlazeCardView { + + private enum Style { + static let titleLabelFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + static let descriptionLabelFont = WPStyleGuide.fontForTextStyle(.subheadline) + static let hideThisImage = UIImage(systemName: "minus.circle") + } + + private enum Metrics { + static let stackViewSpacing = 8.0 + static let contentDirectionalLayoutMargins = NSDirectionalEdgeInsets(top: 16.0, leading: 16.0, bottom: 8.0, trailing: 16.0) + } + + private enum Strings { + static let title = NSLocalizedString("blaze.dashboard.card.title", + value: "Promote your content with Blaze", + comment: "Title for the Blaze dashboard card.") + static let description = NSLocalizedString("blaze.dashboard.card.description", + value: "Display your work across millions of sites.", + comment: "Description for the Blaze dashboard card.") + static let hideThis = NSLocalizedString("blaze.dashboard.card.menu.hide", + value: "Hide this", + comment: "Title for a menu action in the context menu on the Blaze card.") + } +} + +// MARK: - BlazeCardViewModel + +struct BlazeCardViewModel { + + let onViewTap: () -> Void + let onEllipsisTap: () -> Void + let onHideThisTap: UIActionHandler + + init(onViewTap: @escaping () -> Void = {}, + onEllipsisTap: @escaping () -> Void = {}, + onHideThisTap: @escaping UIActionHandler = { _ in }) { + self.onViewTap = onViewTap + self.onEllipsisTap = onEllipsisTap + self.onHideThisTap = onHideThisTap + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Blaze/DashboardBlazeCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Blaze/DashboardBlazeCardCell.swift new file mode 100644 index 000000000000..9f286cb096f6 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Blaze/DashboardBlazeCardCell.swift @@ -0,0 +1,68 @@ +import UIKit + +class DashboardBlazeCardCell: DashboardCollectionViewCell { + + private var blog: Blog? + private weak var presentingViewController: BlogDashboardViewController? + + // MARK: - Views + + private lazy var cardViewModel: BlazeCardViewModel = { + + let onViewTap: () -> Void = { [weak self] in + guard let presentingViewController = self?.presentingViewController, + let blog = self?.blog else { + return + } + BlazeEventsTracker.trackEntryPointTapped(for: .dashboardCard) + BlazeFlowCoordinator.presentBlaze(in: presentingViewController, source: .dashboardCard, blog: blog) + } + + let onEllipsisTap: () -> Void = { [weak self] in + BlogDashboardAnalytics.trackContextualMenuAccessed(for: .blaze) + BlazeEventsTracker.trackContextualMenuAccessed(for: .dashboardCard) + } + + let onHideThisTap: UIActionHandler = { [weak self] _ in + BlogDashboardAnalytics.trackHideTapped(for: .blaze) + BlazeEventsTracker.trackHideThisTapped(for: .dashboardCard) + BlazeHelper.hideBlazeCard(for: self?.blog) + } + + return BlazeCardViewModel(onViewTap: onViewTap, + onEllipsisTap: onEllipsisTap, + onHideThisTap: onHideThisTap) + }() + + private lazy var cardView: BlazeCardView = { + let cardView = BlazeCardView(cardViewModel) + cardView.translatesAutoresizingMaskIntoConstraints = false + return cardView + }() + + // MARK: - Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View setup + + private func setupView() { + contentView.addSubview(cardView) + contentView.pinSubviewToAllEdges(cardView, priority: .defaultHigh) + } + + // MARK: - BlogDashboardCardConfigurable + + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + self.blog = blog + self.presentingViewController = viewController + BlazeEventsTracker.trackEntryPointDisplayed(for: .dashboardCard) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/BlogDashboardCardConfigurable.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/BlogDashboardCardConfigurable.swift new file mode 100644 index 000000000000..e2536e9310ec --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/BlogDashboardCardConfigurable.swift @@ -0,0 +1,17 @@ +import Foundation + +protocol BlogDashboardCardConfigurable { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) + var row: Int { get set } +} + +extension BlogDashboardCardConfigurable where Self: UIView { + var row: Int { + get { + return tag + } + set { + tag = newValue + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/BlogDashboardEmptyStateCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/BlogDashboardEmptyStateCell.swift new file mode 100644 index 000000000000..6ae71412d02b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/BlogDashboardEmptyStateCell.swift @@ -0,0 +1,55 @@ +import UIKit + +final class BlogDashboardEmptyStateCell: DashboardCollectionViewCell { + // MARK: - Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View setup + + private func setupView() { + let titleLabel = UILabel() + titleLabel.font = WPStyleGuide.fontForTextStyle(.title2, fontWeight: .regular) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.text = Strings.title + + let subtitleLabel = UILabel() + subtitleLabel.font = WPStyleGuide.fontForTextStyle(.callout, fontWeight: .regular) + subtitleLabel.adjustsFontForContentSizeCategory = true + subtitleLabel.textColor = .secondaryLabel + subtitleLabel.numberOfLines = 0 + subtitleLabel.textAlignment = .center + subtitleLabel.text = Strings.subtitle + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + subtitleLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 320).isActive = true + + let stack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(stack) + contentView.pinSubviewToAllEdges(stack, insets: .init(top: 52, left: 16, bottom: 4, right: 16)) + } + + // MARK: - BlogDashboardCardConfigurable + + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + // Do nothing + } +} + +private extension BlogDashboardEmptyStateCell { + enum Strings { + static let title = NSLocalizedString("dasboard.emptyView.title", value: "No cards to display", comment: "Title for an empty state view when no cards are displayed") + static let subtitle = NSLocalizedString("dasboard.emptyView.subtitle", value: "Add cards that fit your needs to see information about your site.", comment: "Title for an empty state view when no cards are displayed") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/BlogDashboardPersonalizeCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/BlogDashboardPersonalizeCardCell.swift new file mode 100644 index 000000000000..ed11b9aa3543 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/BlogDashboardPersonalizeCardCell.swift @@ -0,0 +1,94 @@ +import UIKit +import SwiftUI + +final class BlogDashboardPersonalizeCardCell: DashboardCollectionViewCell { + private var blog: Blog? + private weak var presentingViewController: BlogDashboardViewController? + + private let personalizeButton = UIButton(type: .system) + + // MARK: - Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View setup + + private func setupView() { + let titleLabel = UILabel() + titleLabel.text = Strings.buttonTitle + titleLabel.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + titleLabel.adjustsFontForContentSizeCategory = true + + let imageView = UIImageView(image: UIImage(named: "personalize")?.withRenderingMode(.alwaysTemplate)) + imageView.tintColor = .label + + let spacer = UIView() + spacer.translatesAutoresizingMaskIntoConstraints = false + spacer.widthAnchor.constraint(greaterThanOrEqualToConstant: 8).isActive = true + + let contents = UIStackView(arrangedSubviews: [titleLabel, spacer, imageView]) + contents.alignment = .center + contents.isUserInteractionEnabled = false + + personalizeButton.accessibilityLabel = Strings.buttonTitle + personalizeButton.setBackgroundImage(.renderBackgroundImage(fill: .tertiarySystemFill), for: .normal) + personalizeButton.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + + let container = UIView() + container.layer.cornerRadius = 10 + container.addSubview(personalizeButton) + container.addSubview(contents) + + personalizeButton.translatesAutoresizingMaskIntoConstraints = false + container.pinSubviewToAllEdges(personalizeButton) + + contents.translatesAutoresizingMaskIntoConstraints = false + container.pinSubviewToAllEdges(contents, insets: .init(allEdges: 16)) + + contentView.addSubview(container) + container.translatesAutoresizingMaskIntoConstraints = false + contentView.pinSubviewToAllEdges(container) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + personalizeButton.setBackgroundImage(.renderBackgroundImage(fill: .tertiarySystemFill), for: .normal) + } + + // MARK: - BlogDashboardCardConfigurable + + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + self.blog = blog + self.presentingViewController = viewController + } + + // MARK: - Actions + + @objc private func buttonTapped() { + guard let blog = blog, let siteID = blog.dotComID?.intValue else { + return DDLogError("Failed to show dashboard personalization screen: siteID is missing") + } + WPAnalytics.track(.dashboardCardItemTapped, properties: ["type": DashboardCard.personalize.rawValue], blog: blog) + let viewController = UIHostingController(rootView: NavigationView { + BlogDashboardPersonalizationView(viewModel: .init(service: .init(siteID: siteID))) + }.navigationViewStyle(.stack)) // .stack is required for iPad + if UIDevice.isPad() { + viewController.modalPresentationStyle = .formSheet + } + presentingViewController?.present(viewController, animated: true) + } +} + +private extension BlogDashboardPersonalizeCardCell { + struct Strings { + static let buttonTitle = NSLocalizedString("dasboard.personalizeHomeButtonTitle", value: "Personalize your home tab", comment: "Personialize home tab button title") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/DashboardJetpackInstallCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/DashboardJetpackInstallCardCell.swift new file mode 100644 index 000000000000..731b50e1e56b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/DashboardJetpackInstallCardCell.swift @@ -0,0 +1,66 @@ +import UIKit + +class DashboardJetpackInstallCardCell: DashboardCollectionViewCell { + + // MARK: Properties + + private var blog: Blog? + private weak var presenterViewController: BlogDashboardViewController? + + private lazy var cardViewModel: JetpackRemoteInstallCardViewModel = { + let onHideThisTap: UIActionHandler = { [weak self] _ in + guard let self, + let helper = JetpackInstallPluginHelper(self.blog) else { + return + } + WPAnalytics.track(.jetpackInstallFullPluginCardDismissed, properties: [WPAppAnalyticsKeyTabSource: "dashboard"]) + helper.hideCard() + self.presenterViewController?.reloadCardsLocally() + } + + let onLearnMoreTap: () -> Void = { + guard let presenterViewController = self.presenterViewController else { + return + } + WPAnalytics.track(.jetpackInstallFullPluginCardTapped, properties: [WPAppAnalyticsKeyTabSource: "dashboard"]) + JetpackInstallPluginHelper.presentOverlayIfNeeded(in: presenterViewController, + blog: self.blog, + delegate: presenterViewController, + force: true) + + } + return JetpackRemoteInstallCardViewModel(onHideThisTap: onHideThisTap, + onLearnMoreTap: onLearnMoreTap) + }() + + private lazy var cardView: JetpackRemoteInstallCardView = { + let cardView = JetpackRemoteInstallCardView(cardViewModel) + cardView.translatesAutoresizingMaskIntoConstraints = false + return cardView + }() + + // MARK: Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Functions + + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + self.blog = blog + self.presenterViewController = viewController + cardView.updatePlugin(JetpackPlugin(from: blog.jetpackConnectionActivePlugins)) + } + + private func setupView() { + contentView.addSubview(cardView) + contentView.pinSubviewToAllEdges(cardView, priority: .defaultHigh) + } + +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DashboardDomainsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DashboardDomainsCardCell.swift new file mode 100644 index 000000000000..2bab75f623c9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DashboardDomainsCardCell.swift @@ -0,0 +1,179 @@ +import UIKit + +class DashboardDomainsCardCell: DashboardCollectionViewCell { + + private var blog: Blog? + private weak var presentingViewController: BlogDashboardViewController? + + // MARK: - Views + + private lazy var cardViewModel: DashboardCardViewModel = { + + let onViewTap: () -> Void = { [weak self] in + guard let self, + let presentingViewController = self.presentingViewController, + let blog = self.blog else { + return + } + + DomainsDashboardCoordinator.presentDomainsDashboard(in: presentingViewController, + source: Strings.source, + blog: blog) + DomainsDashboardCardTracker.trackDirectDomainsPurchaseDashboardCardTapped(in: self.row) + } + + let onEllipsisTap: () -> Void = { [weak self] in + } + + let onHideThisTap: UIActionHandler = { [weak self] _ in + guard let self else { return } + + DomainsDashboardCardHelper.hideCard(for: self.blog) + DomainsDashboardCardTracker.trackDirectDomainsPurchaseDashboardCardHidden(in: self.row) + self.presentingViewController?.reloadCardsLocally() + } + + return DashboardCardViewModel(onViewTap: onViewTap, + onEllipsisTap: onEllipsisTap, + onHideThisTap: onHideThisTap) + }() + + private lazy var cardFrameView: BlogDashboardCardFrameView = { + let frameView = BlogDashboardCardFrameView() + frameView.onEllipsisButtonTap = cardViewModel.onEllipsisTap + frameView.ellipsisButton.showsMenuAsPrimaryAction = true + frameView.ellipsisButton.menu = contextMenu + frameView.setTitle(Strings.title) + frameView.clipsToBounds = true + frameView.translatesAutoresizingMaskIntoConstraints = false + return frameView + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.font = Style.descriptionLabelFont + label.text = Strings.description + label.textColor = .textSubtle + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var dashboardDomainsCardSearchView: UIView = { + let searchView = UIView.embedSwiftUIView(DashboardDomainsCardSearchView()) + searchView.translatesAutoresizingMaskIntoConstraints = false + return searchView + }() + + private lazy var containerStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [dashboardDomainsCardSearchView, descriptionLabel]) + stackView.axis = .vertical + stackView.spacing = Metrics.stackViewSpacing + stackView.alignment = .fill + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.directionalLayoutMargins = Metrics.contentDirectionalLayoutMargins + stackView.isLayoutMarginsRelativeArrangement = true + return stackView + }() + + private lazy var dashboardIcon: UIImageView = { + let image = UIImage.gridicon(.domains).withTintColor(.white).withRenderingMode(.alwaysOriginal) + let imageView = UIImageView(image: image) + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private lazy var contextMenu: UIMenu = { + let hideThisAction = UIAction(title: Strings.hideThis, + image: Style.hideThisImage, + attributes: [UIMenuElement.Attributes.destructive], + handler: cardViewModel.onHideThisTap) + return UIMenu(title: String(), options: .displayInline, children: [hideThisAction]) + }() + + // MARK: - Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View setup + + private func setupView() { + contentView.addSubview(cardFrameView) + contentView.pinSubviewToAllEdges(cardFrameView, priority: Constants.cardFrameConstraintPriority) + contentView.accessibilityIdentifier = "dashboard-domains-card-contentview" + cardFrameView.add(subview: containerStackView) + + let tap = UITapGestureRecognizer(target: self, action: #selector(viewTapped)) + addGestureRecognizer(tap) + } + + @objc private func viewTapped() { + cardViewModel.onViewTap() + } + + // MARK: - BlogDashboardCardConfigurable + + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + self.blog = blog + self.presentingViewController = viewController + + DomainsDashboardCardTracker.trackDirectDomainsPurchaseDashboardCardShown(in: row) + } +} + +extension DashboardDomainsCardCell { + + private enum Constants { + static let cardFrameConstraintPriority = UILayoutPriority(999) + } + + private enum Style { + static let descriptionLabelFont = WPStyleGuide.fontForTextStyle(.subheadline) + static let hideThisImage = UIImage(systemName: "minus.circle") + } + + private enum Metrics { + static let stackViewSpacing: CGFloat = -20 // Negative since the views should overlap + static let contentDirectionalLayoutMargins = NSDirectionalEdgeInsets(top: 8.0, + leading: 16.0, + bottom: 8.0, + trailing: 16.0) + } + + private enum Strings { + static let title = NSLocalizedString("domain.dashboard.card.shortTitle", + value: "Find a custom domain", + comment: "Title for the Domains dashboard card.") + static let description = NSLocalizedString("domain.dashboard.card.description", + value: "Stake your claim on your corner of the web with a site address that’s easy to find, share and follow.", + comment: "Description for the Domains dashboard card.") + static let hideThis = NSLocalizedString("domain.dashboard.card.menu.hide", + value: "Hide this", + comment: "Title for a menu action in the context menu on the Jetpack install card.") + static let source = "domains_dashboard_card" + } +} + +// MARK: - DashboardCardViewModel + +struct DashboardCardViewModel { + let onViewTap: () -> Void + let onEllipsisTap: () -> Void + let onHideThisTap: UIActionHandler + + init(onViewTap: @escaping () -> Void = {}, + onEllipsisTap: @escaping () -> Void = {}, + onHideThisTap: @escaping UIActionHandler = { _ in }) { + self.onViewTap = onViewTap + self.onEllipsisTap = onEllipsisTap + self.onHideThisTap = onHideThisTap + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DashboardDomainsCardSearchView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DashboardDomainsCardSearchView.swift new file mode 100644 index 000000000000..ca3508e6d38a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DashboardDomainsCardSearchView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct DashboardDomainsCardSearchView: View { + @SwiftUI.Environment(\.colorScheme) var colorScheme: ColorScheme + + var body: some View { + VStack(alignment: .center) { + HStack { + Image(systemName: Constants.iconName) + .foregroundColor(Colors.icon) + .font(.system(size: Metrics.iconSize)) + Text(Constants.searchBarPlaceholder) + .foregroundColor(Colors.text) + .font(.system(size: Metrics.fontSize)) + Spacer() + } + .padding(.horizontal, Metrics.padding) + .frame(height: Metrics.searchBarHeight) + .background( + RoundedRectangle(cornerRadius: Metrics.containerCornerRadius) + .foregroundColor(Colors.containerBackground) + ) + Spacer() + RoundedRectangle(cornerRadius: Metrics.containerCornerRadius) + .foregroundColor(Colors.containerBackground) + } + .padding([.leading, .trailing, .top], Metrics.padding) + .frame(height: Metrics.height) + .background( + ZStack { + LinearGradient( + gradient: Gradient( + colors: [ + colorScheme == .light ? Colors.gradientTopLight : Colors.gradientTopDark, + Color.clear + ] + ), + startPoint: .top, + endPoint: .bottom + ) + } + ) + .cornerRadius(Metrics.cornerRadius) + .accessibilityHidden(true) + } +} + +private extension DashboardDomainsCardSearchView { + enum Metrics { + static let padding: CGFloat = 8 + static let cornerRadius: CGFloat = 16 + static let containerCornerRadius: CGFloat = 8 + static let iconSize: CGFloat = 20 + static let fontSize: CGFloat = 15 // fixed .footnote style size + static let height: CGFloat = 110 + static let searchBarHeight: CGFloat = 40 + } + + enum Constants { + static let iconName = "globe" + static let searchBarPlaceholder = "domain.blog" + } + + enum Colors { + static let gradientTopLight = Color(UIColor.secondarySystemBackground) + static let gradientTopDark = Color(UIColor.tertiarySystemBackground) + static let containerBackground = Color(UIColor.secondarySystemGroupedBackground) + static let icon = Color(UIColor.jetpackGreen) + static let text = Color(UIColor.textSubtle) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DomainsDashboardCardHelper.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DomainsDashboardCardHelper.swift new file mode 100644 index 000000000000..fc15942927e9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DomainsDashboardCardHelper.swift @@ -0,0 +1,40 @@ +import Foundation + +@objc final class DomainsDashboardCardHelper: NSObject { + + /// Checks conditions for showing domain dashboard cards + static func shouldShowCard( + for blog: Blog, + isJetpack: Bool = AppConfiguration.isJetpack, + featureFlagEnabled: Bool = RemoteFeatureFlag.domainsDashboardCard.enabled() + ) -> Bool { + guard isJetpack, featureFlagEnabled else { + return false + } + + /// If this propery is empty, it indicates that domain information is not yet loaded + let hasLoadedDomains = blog.freeDomain != nil + let hasOtherDomains = blog.domainsList.count > 0 + let hasDomainCredit = blog.hasDomainCredit + + return blog.supports(.domains) + && hasLoadedDomains + && !hasOtherDomains + && !hasDomainCredit + } + + static func hideCard(for blog: Blog?) { + guard let blog, + let siteID = blog.dotComID?.intValue else { + DDLogError("Domains Dashboard Card: error hiding the card.") + return + } + + BlogDashboardPersonalizationService(siteID: siteID) + .setEnabled(false, for: .domainsDashboardCard) + } + + @objc static func isFeatureEnabled() -> Bool { + return AppConfiguration.isJetpack && RemoteFeatureFlag.domainsDashboardCard.enabled() + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DomainsDashboardCardTracker.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DomainsDashboardCardTracker.swift new file mode 100644 index 000000000000..c57d16c07f38 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Domains/DomainsDashboardCardTracker.swift @@ -0,0 +1,20 @@ +import Foundation + +struct DomainsDashboardCardTracker { + private static let positionKey = "position_index" + + static func trackDirectDomainsPurchaseDashboardCardShown(in position: Int) { + let properties = [positionKey: position] + WPAnalytics.track(.directDomainsPurchaseDashboardCardShown, properties: properties) + } + + static func trackDirectDomainsPurchaseDashboardCardHidden(in position: Int) { + let properties = [positionKey: position] + WPAnalytics.track(.directDomainsPurchaseDashboardCardHidden, properties: properties) + } + + static func trackDirectDomainsPurchaseDashboardCardTapped(in position: Int) { + let properties = [positionKey: position] + WPAnalytics.track(.directDomainsPurchaseDashboardCardTapped, properties: properties) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Ghost/DashboardFailureCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Ghost/DashboardFailureCardCell.swift new file mode 100644 index 000000000000..5a88cedcd149 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Ghost/DashboardFailureCardCell.swift @@ -0,0 +1,60 @@ +import UIKit + +class DashboardFailureCardCell: UICollectionViewCell, Reusable { + private lazy var contentStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Constants.spacing + return stackView + }() + + private lazy var titleLabel: UILabel = { + let title = UILabel() + title.textColor = .secondaryLabel + title.text = Strings.title + title.font = AppStyleGuide.prominentFont(textStyle: .headline, weight: .semibold) + title.textAlignment = .center + return title + }() + + private lazy var subtitleLabel: UILabel = { + let subtitle = UILabel() + subtitle.textColor = .secondaryLabel + subtitle.text = Strings.subtitle + subtitle.numberOfLines = 0 + subtitle.textAlignment = .center + subtitle.font = WPStyleGuide.fontForTextStyle(.subheadline) + return subtitle + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + contentStackView.addArrangedSubviews([ + titleLabel, + subtitleLabel + ]) + + contentView.addSubview(contentStackView) + contentView.pinSubviewToAllEdges(contentStackView, insets: Constants.contentInsets) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private enum Constants { + static let spacing: CGFloat = 4 + static let contentInsets = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0) + } + + private enum Strings { + static let title = NSLocalizedString("Some data wasn't loaded", comment: "Title shown on the dashboard when it fails to load") + static let subtitle = NSLocalizedString("Check your internet connection and pull to refresh.", comment: "Subtitle shown on the dashboard when it fails to load") + } +} + +extension DashboardFailureCardCell: BlogDashboardCardConfigurable { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Ghost/DashboardGhostCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Ghost/DashboardGhostCardCell.swift new file mode 100644 index 000000000000..904e5288c98f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Ghost/DashboardGhostCardCell.swift @@ -0,0 +1,53 @@ +import Foundation +import UIKit + +class DashboardGhostCardCell: UICollectionViewCell, Reusable, BlogDashboardCardConfigurable { + private lazy var contentStackView: UIStackView = { + let contentStackView = UIStackView() + contentStackView.axis = .vertical + contentStackView.translatesAutoresizingMaskIntoConstraints = false + contentStackView.spacing = Constants.spacing + return contentStackView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + for _ in 0..<Constants.numberOfCards { + contentStackView.addArrangedSubview(ghostCard()) + } + + contentView.addSubview(contentStackView) + contentView.pinSubviewToAllEdges(contentStackView, insets: Constants.insets, + priority: Constants.constraintPriority) + + isAccessibilityElement = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + startGhostAnimation(style: GhostCellStyle.muriel) + } + + private func ghostCard() -> BlogDashboardCardFrameView { + let frameView = BlogDashboardCardFrameView() + + let content = DashboardGhostCardContent.loadFromNib() + frameView.hideHeader() + frameView.add(subview: content) + + return frameView + } + + private enum Constants { + static let spacing: CGFloat = 20 + static let numberOfCards = 5 + static let insets = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0) + static let constraintPriority = UILayoutPriority(999) + } +} + +class DashboardGhostCardContent: UIView, NibLoadable { } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Ghost/DashboardGhostCardContent.xib b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Ghost/DashboardGhostCardContent.xib new file mode 100644 index 000000000000..f8bfe24ddd1f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Ghost/DashboardGhostCardContent.xib @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="iN0-l3-epB" customClass="DashboardGhostCardContent" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="141"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Loading......" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dXG-kD-bVH"> + <rect key="frame" x="16" y="16" width="67" height="37"/> + <color key="backgroundColor" systemColor="systemGray4Color"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" systemColor="systemGray4Color"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" text="Loading loading..." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="8RE-nb-If5"> + <rect key="frame" x="16" y="77" width="114.5" height="17"/> + <color key="backgroundColor" systemColor="systemGray4Color"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <color key="textColor" systemColor="systemGray4Color"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" text="Loading......" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="2hm-cC-kM8"> + <rect key="frame" x="16" y="102" width="67" height="15"/> + <color key="backgroundColor" systemColor="systemGray4Color"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" systemColor="systemGray4Color"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="8RE-nb-If5" firstAttribute="width" relation="lessThanOrEqual" secondItem="iN0-l3-epB" secondAttribute="width" multiplier="0.8" id="AYC-gw-whD"/> + <constraint firstItem="2hm-cC-kM8" firstAttribute="top" secondItem="8RE-nb-If5" secondAttribute="bottom" constant="8" id="T8R-3J-E7V"/> + <constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="2hm-cC-kM8" secondAttribute="bottom" constant="24" id="TK9-dv-Qgm"/> + <constraint firstItem="2hm-cC-kM8" firstAttribute="leading" secondItem="8RE-nb-If5" secondAttribute="leading" id="bMv-q3-7Kg"/> + <constraint firstItem="dXG-kD-bVH" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" constant="16" id="f7a-De-fyH"/> + <constraint firstItem="8RE-nb-If5" firstAttribute="top" secondItem="dXG-kD-bVH" secondAttribute="bottom" priority="999" constant="24" id="u3n-6g-mYa"/> + <constraint firstItem="8RE-nb-If5" firstAttribute="leading" secondItem="dXG-kD-bVH" secondAttribute="leading" id="wB3-XJ-H5o"/> + <constraint firstItem="dXG-kD-bVH" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="xje-LI-Ru5"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <point key="canvasLocation" x="137.68115942028987" y="-142.96875"/> + </view> + </objects> + <resources> + <systemColor name="systemGray4Color"> + <color red="0.81960784313725488" green="0.81960784313725488" blue="0.83921568627450982" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPageCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPageCell.swift new file mode 100644 index 000000000000..ebab0b731acc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPageCell.swift @@ -0,0 +1,261 @@ +import UIKit + +class DashboardPageCell: UITableViewCell, Reusable { + + // MARK: Views + + lazy var mainStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = Metrics.mainStackViewSpacing + stackView.directionalLayoutMargins = Metrics.defaultMainStackViewLayoutMargins + stackView.isLayoutMarginsRelativeArrangement = true + stackView.addBottomBorder(withColor: Colors.separatorColor, leadingMargin: Metrics.defaultMainStackViewLayoutMargins.leading) + stackView.addArrangedSubviews([titleLabel, detailsStackView]) + return stackView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .bold) + label.numberOfLines = 1 + label.textColor = .text + return label + }() + + lazy var detailsStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = Metrics.detailsStackViewSpacing + stackView.addArrangedSubviews([statusView, dateLabel]) + return stackView + }() + + private lazy var dateLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = WPStyleGuide.regularTextFont() + label.numberOfLines = 1 + label.textColor = .secondaryLabel + return label + }() + + private lazy var statusView = PageStatusView() + + // MARK: Initializers + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + // MARK: Public Functions + + func configure(using page: Page, rowIndex: Int) { + titleLabel.text = page.titleForDisplay() + configureDateLabel(for: page) + statusView.configure(for: page.status) + configureStackViewLayoutMargins(rowIndex: rowIndex) + } + + // MARK: Helpers + + private func commonInit() { + setupViews() + applyStyle() + } + + private func applyStyle() { + backgroundColor = .clear + } + + private func setupViews() { + contentView.addSubview(mainStackView) + contentView.pinSubviewToAllEdges(mainStackView) + } + + private func configureDateLabel(for page: Page) { + let date = page.status == .scheduled ? page.dateCreated : page.dateModified + dateLabel.text = date?.toMediumString() + } + + + /// Reduces the top spacing for the first cell to reduce the vertical spacing between the tableview and the card header + private func configureStackViewLayoutMargins(rowIndex: Int) { + let margins = rowIndex == 0 ? Metrics.firstCellMainStackViewLayoutMargins : Metrics.defaultMainStackViewLayoutMargins + mainStackView.directionalLayoutMargins = margins + } + +} + +private extension DashboardPageCell { + enum Metrics { + static let mainStackViewSpacing: CGFloat = 6 + static let detailsStackViewSpacing: CGFloat = 8 + static let defaultMainStackViewLayoutMargins: NSDirectionalEdgeInsets = .init(top: 14, leading: 16, bottom: 14, trailing: 16) + static let firstCellMainStackViewLayoutMargins: NSDirectionalEdgeInsets = .init(top: 8, leading: 16, bottom: 14, trailing: 16) + } + + enum Colors { + static let separatorColor: UIColor = .separator + } +} + +fileprivate class PageStatusView: UIView { + + enum PageStatus { + case published + case scheduled + case draft + + var title: String { + switch self { + case .published: + return Strings.publishedTitle + case .scheduled: + return Strings.scheduledTitle + case .draft: + return Strings.draftTitle + } + } + + var icon: UIImage? { + switch self { + case .published: + return UIImage(named: "icon.globe") + case .scheduled: + return UIImage(named: "icon.calendar") + case .draft: + return UIImage(named: "icon.verse") + } + } + } + + // MARK: Views + + private lazy var iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = .secondaryLabel + imageView.heightAnchor.constraint(equalToConstant: Metrics.iconImageViewSize).isActive = true + imageView.widthAnchor.constraint(equalToConstant: Metrics.iconImageViewSize).isActive = true + addSubview(imageView) + return imageView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = WPStyleGuide.regularTextFont() + label.numberOfLines = 1 + label.textColor = .secondaryLabel + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + return label + }() + + // MARK: Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + // MARK: Public Functions + + func configure(for status: BasePost.Status?) { + guard let pageStatus = status?.pageStatus else { + return + } + titleLabel.text = pageStatus.title + iconImageView.image = pageStatus.icon + } + + // MARK: Helpers + + private func commonInit() { + applyStyles() + setupViews() + } + + private func applyStyles() { + backgroundColor = Metrics.backgroundColor + layer.cornerRadius = Metrics.cornerRadius + } + + private func setupViews() { + addSubviews([iconImageView, titleLabel]) + + NSLayoutConstraint.activate([ + // Icon Image View Constraints + iconImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Metrics.iconLeadingSpace), + iconImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + + // Title label Constraints + titleLabel.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: Metrics.titleLabelMargins.leading), + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: Metrics.titleLabelMargins.top), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Metrics.titleLabelMargins.bottom), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.titleLabelMargins.trailing) + ]) + } + + private enum Metrics { + static let iconLeadingSpace: CGFloat = 4 + static let titleLabelMargins: NSDirectionalEdgeInsets = .init(top: 2, leading: 4, bottom: 2, trailing: 8) + static let iconImageViewSize: CGFloat = 16 + static let cornerRadius: CGFloat = 2 + private static let darkModeBackgroundColor = UIColor(red: 0.173, green: 0.173, blue: 0.18, alpha: 1) + static let backgroundColor: UIColor = .init(light: .secondarySystemBackground, dark: darkModeBackgroundColor) + } + + private enum Strings { + static let publishedTitle = NSLocalizedString("dashboardCard.pages.cell.status.publish", + value: "Published", + comment: "Title of label marking a published page") + static let scheduledTitle = NSLocalizedString("dashboardCard.pages.cell.status.schedule", + value: "Scheduled", + comment: "Title of label marking a scheduled page") + static let draftTitle = NSLocalizedString("dashboardCard.pages.cell.status.draft", + value: "Draft", + comment: "Title of label marking a draft page") + } + +} + +fileprivate extension BasePost.Status { + var pageStatus: PageStatusView.PageStatus? { + switch self { + case .draft: + return .draft + case .pending: + return .draft + case .publishPrivate: + return .published + case .publish: + return .published + case .scheduled: + return .scheduled + case .trash: + return nil + case .deleted: + return nil + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPageCreationCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPageCreationCell.swift new file mode 100644 index 000000000000..d74428f7c97b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPageCreationCell.swift @@ -0,0 +1,191 @@ +import UIKit + +final class DashboardPageCreationCompactCell: DashboardPageCreationCell, Reusable { + override var isCompact: Bool { + return true + } +} + +final class DashboardPageCreationExpandedCell: DashboardPageCreationCell, Reusable { } + +class DashboardPageCreationCell: UITableViewCell { + + // MARK: Variables + + /// Variable indicating the cell layout type. Cell is expanded by default + var isCompact: Bool { + return false + } + weak var viewModel: PagesCardViewModel? + + // MARK: Views + + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = Metrics.mainStackViewSpacing + stackView.directionalLayoutMargins = Metrics.mainStackViewLayoutMargins + stackView.isLayoutMarginsRelativeArrangement = true + let subviews = isCompact ? [labelsStackView] : [labelsStackView, imageSuperView] + stackView.addArrangedSubviews(subviews) + return stackView + }() + + private lazy var labelsStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = Metrics.labelsStackViewSpacing + let layoutMargins = isCompact ? Metrics.labelsStackViewCompactLayoutMargins : Metrics.labelsStackViewLayoutMargins + stackView.directionalLayoutMargins = layoutMargins + stackView.isLayoutMarginsRelativeArrangement = true + let subviews = isCompact ? [createPageButton] : [createPageButton, descriptionLabel] + stackView.addArrangedSubviews(subviews) + return stackView + }() + + private lazy var createPageButton: UIButton = { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.contentHorizontalAlignment = .leading + button.addTarget(self, action: #selector(createPageButtonTapped), for: .touchUpInside) + let font = WPStyleGuide.fontForTextStyle(.callout, fontWeight: .bold) + + if #available(iOS 15.0, *) { + var buttonConfig: UIButton.Configuration = .plain() + buttonConfig.contentInsets = Metrics.createPageButtonContentInsets + buttonConfig.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer({ incoming in + var outgoing = incoming + outgoing.font = font + return outgoing + }) + button.configuration = buttonConfig + } else { + button.titleLabel?.font = font + button.setTitleColor(.jetpackGreen, for: .normal) + button.contentEdgeInsets = Metrics.createPageButtonContentEdgeInsets + button.flipInsetsForRightToLeftLayoutDirection() + } + + return button + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontSizeToFitWidth = true + label.numberOfLines = 2 + label.font = WPStyleGuide.regularTextFont() + label.textColor = .secondaryLabel + label.text = Strings.descriptionLabelText + return label + }() + + private lazy var imageSuperView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(promoImageView) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: promoImageView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: promoImageView.trailingAnchor), + view.topAnchor.constraint(lessThanOrEqualTo: promoImageView.topAnchor, + constant: -Metrics.promoImageSuperViewInsets.top), + view.bottomAnchor.constraint(greaterThanOrEqualTo: promoImageView.bottomAnchor, + constant: Metrics.promoImageSuperViewInsets.bottom), + view.centerYAnchor.constraint(equalTo: promoImageView.centerYAnchor) + ]) + return view + }() + + private lazy var promoImageView: UIImageView = { + let image = UIImage(named: Graphics.promoImage) + let imageView = UIImageView(image: image) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.backgroundColor = Colors.promoImageBackgroundColor + imageView.clipsToBounds = true + imageView.layer.cornerRadius = Metrics.promoImageCornerRadius + NSLayoutConstraint.activate(imageView.constrain(size: Metrics.promoImageSize)) + return imageView + }() + + // MARK: Initializers + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + // MARK: Public Functions + + func configure(hasPages: Bool) { + let buttonTitle = hasPages ? Strings.createPageButtonText : Strings.addPagesButtonText + createPageButton.setTitle(buttonTitle, for: .normal) + } + + // MARK: Helpers + + private func commonInit() { + setupViews() + applyStyle() + } + + private func applyStyle() { + backgroundColor = .clear + selectionStyle = .none + } + + private func setupViews() { + contentView.addSubview(mainStackView) + contentView.pinSubviewToAllEdges(mainStackView) + } + + // MARK: Actions + + @objc func createPageButtonTapped() { + viewModel?.createPage() + } +} + +private extension DashboardPageCreationCell { + enum Metrics { + static let mainStackViewSpacing: CGFloat = 16 + static let mainStackViewLayoutMargins: NSDirectionalEdgeInsets = .init(top: 0, leading: 16, bottom: 0, trailing: 16) + static let labelsStackViewSpacing: CGFloat = 2 + static let labelsStackViewLayoutMargins: NSDirectionalEdgeInsets = .init(top: 15, leading: 0, bottom: 15, trailing: 0) + static let labelsStackViewCompactLayoutMargins: NSDirectionalEdgeInsets = .init(top: 15, leading: 0, bottom: 7, trailing: 0) + static let createPageButtonContentInsets = NSDirectionalEdgeInsets.zero + static let createPageButtonContentEdgeInsets = UIEdgeInsets.zero + static let promoImageSize: CGSize = .init(width: 110, height: 80) + static let promoImageSuperViewInsets: UIEdgeInsets = .init(top: 10, left: 0, bottom: 10, right: 0) + static let promoImageCornerRadius: CGFloat = 5 + + } + + enum Graphics { + static let promoImage = "pagesCardPromoImage" + } + + enum Colors { + private static let lightPromoImageBackgroundColor = UIColor(red: 0.937, green: 0.937, blue: 0.957, alpha: 1) + static let promoImageBackgroundColor = UIColor(light: lightPromoImageBackgroundColor, + dark: .clear) + } + + enum Strings { + static let descriptionLabelText = NSLocalizedString("dashboardCard.pages.create.description", + value: "Start with bespoke, mobile friendly layouts.", + comment: "Title of a label that encourages the user to create a new page.") + static let createPageButtonText = NSLocalizedString("dashboardCard.pages.create.button.title", + value: "Create another page", + comment: "Title of a button that starts the page creation flow.") + static let addPagesButtonText = NSLocalizedString("dashboardCard.pages.add.button.title", + value: "Add pages to your site", + comment: "Title of a button that starts the page creation flow.") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPagesListCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPagesListCardCell.swift new file mode 100644 index 000000000000..a03c3178b07b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/DashboardPagesListCardCell.swift @@ -0,0 +1,203 @@ +import UIKit + +final class DashboardPagesListCardCell: DashboardCollectionViewCell, PagesCardView { + + var parentViewController: UIViewController? { + presentingViewController + } + + private var blog: Blog? + private weak var presentingViewController: BlogDashboardViewController? + private var viewModel: PagesCardViewModel? + + // MARK: - Views + + private lazy var cardFrameView: BlogDashboardCardFrameView = { + let frameView = BlogDashboardCardFrameView() + frameView.translatesAutoresizingMaskIntoConstraints = false + frameView.setTitle(Strings.title) + return frameView + }() + + lazy var tableView: UITableView = { + let tableView = DashboardCardTableView() + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.isScrollEnabled = false + tableView.backgroundColor = nil + tableView.register(DashboardPageCell.self, + forCellReuseIdentifier: DashboardPageCell.defaultReuseID) + tableView.register(DashboardPageCreationCompactCell.self, + forCellReuseIdentifier: DashboardPageCreationCompactCell.defaultReuseID) + tableView.register(DashboardPageCreationExpandedCell.self, + forCellReuseIdentifier: DashboardPageCreationExpandedCell.defaultReuseID) + tableView.register(BlogDashboardPostCardGhostCell.defaultNib, + forCellReuseIdentifier: BlogDashboardPostCardGhostCell.defaultReuseID) + tableView.separatorStyle = .none + return tableView + }() + + // MARK: - Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + // MARK: View Lifecycle + + override func prepareForReuse() { + super.prepareForReuse() + tableView.dataSource = nil + viewModel?.tearDown() + } + + // MARK: - Helpers + + private func commonInit() { + setupView() + configureHeaderAction() + tableView.delegate = self + } + + private func setupView() { + cardFrameView.add(subview: tableView) + + contentView.addSubview(cardFrameView) + contentView.pinSubviewToAllEdges(cardFrameView, priority: .defaultHigh) + } + + private func configureHeaderAction() { + cardFrameView.onHeaderTap = { [weak self] in + self?.showPagesList(source: .header) + } + } +} + +// MARK: - BlogDashboardCardConfigurable + +extension DashboardPagesListCardCell { + func configure(blog: Blog, + viewController: BlogDashboardViewController?, + apiResponse: BlogDashboardRemoteEntity?) { + self.blog = blog + self.presentingViewController = viewController + + configureContextMenu(blog: blog) + + viewModel = PagesCardViewModel(blog: blog, view: self) + viewModel?.viewDidLoad() + tableView.dataSource = viewModel?.diffableDataSource + viewModel?.refresh() + } + + // MARK: Context Menu + + private func configureContextMenu(blog: Blog) { + cardFrameView.onEllipsisButtonTap = { + BlogDashboardAnalytics.trackContextualMenuAccessed(for: .pages) + } + cardFrameView.ellipsisButton.showsMenuAsPrimaryAction = true + + let children = [makeAllPagesAction(), makeHideCardAction(blog: blog)].compactMap { $0 } + + cardFrameView.ellipsisButton.menu = UIMenu(title: String(), options: .displayInline, children: children) + } + + private func makeAllPagesAction() -> UIMenuElement { + let allPagesAction = UIAction(title: Strings.allPages, + image: Style.allPagesImage, + handler: { _ in self.showPagesList(source: .contextMenu) }) + + // Wrap the pages action in a menu to display a divider between the pages action and hide this action. + // https://developer.apple.com/documentation/uikit/uimenu/options/3261455-displayinline + let allPagesSubmenu = UIMenu(title: String(), options: .displayInline, children: [allPagesAction]) + return allPagesSubmenu + } + + private func makeHideCardAction(blog: Blog) -> UIMenuElement? { + guard let siteID = blog.dotComID?.intValue else { + return nil + } + return BlogDashboardHelpers.makeHideCardAction(for: .pages, siteID: siteID) + } + + // MARK: Actions + + private func showPagesList(source: PagesListSource) { + guard let blog, let presentingViewController else { + return + } + PageListViewController.showForBlog(blog, from: presentingViewController) + WPAppAnalytics.track(.openedPages, + withProperties: [WPAppAnalyticsKeyTapSource: source.rawValue], + with: blog) + } +} + +// MARK: - UITableViewDelegate +extension DashboardPagesListCardCell: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let isPagesSection = indexPath.section == 0 + if isPagesSection { + handlePageSelected(at: indexPath) + } else { + handleCreatePageSectionSelected() + } + + } + + private func handlePageSelected(at indexPath: IndexPath) { + guard let page = viewModel?.pageAt(indexPath), + let presentingViewController else { + return + } + PageEditorPresenter.handle(page: page, + in: presentingViewController, + entryPoint: .dashboard) + + viewModel?.trackPageTapped() + } + + private func handleCreatePageSectionSelected() { + viewModel?.createPage() + } +} + +extension DashboardPagesListCardCell { + + static func shouldShowCard(for blog: Blog) -> Bool { + guard RemoteFeatureFlag.pagesDashboardCard.enabled(), + blog.supports(.pages) else { + return false + } + + return true + } +} + +private extension DashboardPagesListCardCell { + + enum PagesListSource: String { + case header = "pages_card_header" + case contextMenu = "pages_card_context_menu" + } + + enum Strings { + static let title = NSLocalizedString("dashboardCard.Pages.title", + value: "Pages", + comment: "Title for the Pages dashboard card.") + static let allPages = NSLocalizedString("dashboardCard.Pages.contextMenu.allPages", + value: "All pages", + comment: "Title for an action that opens the full pages list.") + } + + enum Style { + static let allPagesImage = UIImage(systemName: "doc.text") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/PagesCardViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/PagesCardViewModel.swift new file mode 100644 index 000000000000..0f53e5ee2b29 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Pages/PagesCardViewModel.swift @@ -0,0 +1,388 @@ +import Foundation +import CoreData +import UIKit + +protocol PagesCardView: AnyObject { + var tableView: UITableView { get } + var parentViewController: UIViewController? { get } +} + +fileprivate enum PagesState: CaseIterable { + case loading + case loaded +} + +enum PagesListSection: CaseIterable { + case pages + case loading + case create +} + +enum PagesListItem: Hashable { + case page(NSManagedObjectID) + case ghost(Int) + case createPage(compact: Bool, hasPages: Bool) +} + +/// Responsible for populating a table view with pages +/// And syncing them if needed. +/// +class PagesCardViewModel: NSObject { + var blog: Blog + + private let managedObjectContext: NSManagedObjectContext + + private var filter: PostListFilter = PostListFilter.allNonTrashedFilter() + + private var fetchedResultsController: NSFetchedResultsController<Page>? + + private var isSyncing = false + + private var currentState: PagesState = .loading { + didSet { + if oldValue != currentState { + forceReloadSnapshot() + trackCardDisplayedIfNeeded() + } + } + } + + private var lastPagesSnapshot: PagesSnapshot? + + private weak var view: PagesCardView? + + typealias DataSource = UITableViewDiffableDataSource<PagesListSection, PagesListItem> + typealias Snapshot = NSDiffableDataSourceSnapshot<PagesListSection, PagesListItem> + typealias PagesSnapshot = NSDiffableDataSourceSnapshot<Int, NSManagedObjectID> + + lazy var diffableDataSource = DataSource(tableView: view!.tableView) { [weak self] (tableView, indexPath, item) -> UITableViewCell? in + guard let self = self else { + return nil + } + switch item { + case .page(let objectID): + return self.configurePageCell(objectID: objectID, tableView: tableView, indexPath: indexPath) + case .ghost: + return self.configureGhostCell(tableView: tableView, indexPath: indexPath) + case .createPage(let compact, let hasPages): + return self.configureCreationCell(compact: compact, hasPages: hasPages, tableView: tableView, indexPath: indexPath) + } + + } + + init(blog: Blog, view: PagesCardView, managedObjectContext: NSManagedObjectContext = ContextManager.shared.mainContext) { + self.blog = blog + self.view = view + self.managedObjectContext = managedObjectContext + + super.init() + } + + /// Refresh the results and reload the data on the table view + func refresh() { + do { + try fetchedResultsController?.performFetch() + view?.tableView.reloadData() + showLoadingIfNeeded() + } catch { + DDLogError("Pages Dashboard Card: Failed to fetch pages from core data") + } + } + + func retry() { + showLoadingIfNeeded() + sync() + } + + /// Set up the view model to be ready for use + func viewDidLoad() { + performInitialLoading() + refresh() + } + + /// Return the page at the given IndexPath + func pageAt(_ indexPath: IndexPath) -> Page? { + fetchedResultsController?.object(at: indexPath) + } + + func createPage() { + guard let viewController = view?.parentViewController else { + return + } + PageCoordinator.showLayoutPickerIfNeeded(from: viewController, forBlog: blog) { [weak self] selectedLayout in + guard let blog = self?.blog else { + return + } + let editorViewController = EditPageViewController(blog: blog, postTitle: selectedLayout?.title, content: selectedLayout?.content, appliedTemplate: selectedLayout?.slug) + viewController.present(editorViewController, animated: false) + } + trackCreateSectionTapped() + } + + func trackPageTapped() { + trackCardItemTapped(itemType: Constants.analyticsPageItemType) + } + + private func trackCreateSectionTapped() { + trackCardItemTapped(itemType: Constants.analyticsCreationItemType) + + WPAnalytics.track(WPAnalyticsEvent.editorCreatedPage, + properties: [WPAppAnalyticsKeyTapSource: Constants.analyticsPageCreationSource], + blog: blog) + } + + private func trackCardItemTapped(itemType: String) { + let properties = [ + Constants.analyticsTypeKey: DashboardCard.pages.rawValue, + Constants.analyticsItemTypeKey: itemType + ] + WPAnalytics.track(.dashboardCardItemTapped, + properties: properties, + blog: blog) + } + + func tearDown() { + DashboardPostsSyncManager.shared.removeListener(self) + fetchedResultsController?.delegate = nil + } +} + +// MARK: Cells Configuration + +private extension PagesCardViewModel { + private func configurePageCell(objectID: NSManagedObjectID, tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { + guard let page = try? self.managedObjectContext.existingObject(with: objectID) as? Page else { + return UITableViewCell() + } + + let cell = tableView.dequeueReusableCell(withIdentifier: DashboardPageCell.defaultReuseID, for: indexPath) as? DashboardPageCell + + cell?.accessoryType = .none + cell?.configure(using: page, rowIndex: indexPath.row) + + return cell ?? UITableViewCell() + } + + private func configureGhostCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: BlogDashboardPostCardGhostCell.defaultReuseID, for: indexPath) as? BlogDashboardPostCardGhostCell + let style = GhostStyle(beatDuration: GhostStyle.Defaults.beatDuration, + beatStartColor: .placeholderElement, + beatEndColor: .placeholderElementFaded) + cell?.contentView.stopGhostAnimation() + cell?.contentView.startGhostAnimation(style: style) + return cell ?? UITableViewCell() + } + + private func configureCreationCell(compact: Bool, hasPages: Bool, tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { + var cell: DashboardPageCreationCell? + if compact { + cell = tableView.dequeueReusableCell(withIdentifier: DashboardPageCreationCompactCell.defaultReuseID, + for: indexPath) as? DashboardPageCreationCell + } else { + cell = tableView.dequeueReusableCell(withIdentifier: DashboardPageCreationExpandedCell.defaultReuseID, + for: indexPath) as? DashboardPageCreationCell + } + cell?.viewModel = self + cell?.configure(hasPages: hasPages) + return cell ?? UITableViewCell() + } + +} + +// MARK: - Private methods + +private extension PagesCardViewModel { + var numberOfPages: Int { + fetchedResultsController?.fetchedObjects?.count ?? 0 + } + + func performInitialLoading() { + DashboardPostsSyncManager.shared.addListener(self) + createFetchedResultsController() + showLoadingIfNeeded() + sync() + } + + func createFetchedResultsController() { + fetchedResultsController?.delegate = nil + fetchedResultsController = nil + + fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest(), managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: nil) + + fetchedResultsController?.delegate = self + } + + func fetchRequest() -> NSFetchRequest<Page> { + let fetchRequest = NSFetchRequest<Page>(entityName: String(describing: Page.self)) + fetchRequest.predicate = predicateForFetchRequest() + fetchRequest.sortDescriptors = sortDescriptorsForFetchRequest() + fetchRequest.fetchBatchSize = Constants.numberOfPages + fetchRequest.fetchLimit = Constants.numberOfPages + return fetchRequest + } + + func predicateForFetchRequest() -> NSPredicate { + filter.predicate(for: blog) + } + + func sortDescriptorsForFetchRequest() -> [NSSortDescriptor] { + return filter.sortDescriptors + } + + func sync() { + isSyncing = true + let filter = filter + DashboardPostsSyncManager.shared.syncPosts(blog: blog, postType: .page, statuses: filter.statuses.strings) + } + + func hideLoading() { + currentState = .loaded + } + + func showLoadingIfNeeded() { + // Only show loading state if there are no pages at all + if numberOfPages == 0 && isSyncing { + currentState = .loading + } + else { + currentState = .loaded + } + } + + func trackCardDisplayedIfNeeded() { + if currentState == .loaded { + BlogDashboardAnalytics.shared.track(.dashboardCardShown, properties: ["type": DashboardCard.pages.rawValue], blog: blog) + } + } + + enum Constants { + static let numberOfPages = 3 + static let analyticsTypeKey = "type" + static let analyticsItemTypeKey = "item_type" + static let analyticsPageItemType = "page" + static let analyticsCreationItemType = "create" + static let analyticsPageCreationSource = "pages_card" + } +} + +// MARK: DashboardPostsSyncManagerListener + +extension PagesCardViewModel: DashboardPostsSyncManagerListener { + func postsSynced(success: Bool, + blog: Blog, + postType: DashboardPostsSyncManager.PostType, + posts: [AbstractPost]?, + for statuses: [String]) { + guard postType == .page, + self.blog == blog else { + return + } + + isSyncing = false + if success { + hideLoading() + } + } +} + +// MARK: - NSFetchedResultsControllerDelegate + +extension PagesCardViewModel: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + guard let dataSource = view?.tableView.dataSource as? DataSource else { + return + } + + let pagesSnapshot = snapshot as PagesSnapshot + self.lastPagesSnapshot = pagesSnapshot + + let currentSnapshot = dataSource.snapshot() as Snapshot + let finalSnapshot = createSnapshot(currentSnapshot: currentSnapshot, pagesSnapshot: pagesSnapshot) + applySnapshot(finalSnapshot, to: dataSource) + } + + private func forceReloadSnapshot() { + guard let dataSource = view?.tableView.dataSource as? DataSource else { + return + } + let currentSnapshot = dataSource.snapshot() as Snapshot + let pagesSnapshot = self.lastPagesSnapshot ?? NSDiffableDataSourceSnapshot<Int, NSManagedObjectID>() + let snapshot = createSnapshot(currentSnapshot: currentSnapshot, pagesSnapshot: pagesSnapshot) + applySnapshot(snapshot, to: dataSource) + } + + private func createSnapshot(currentSnapshot: Snapshot, pagesSnapshot: PagesSnapshot) -> Snapshot { + var snapshot = Snapshot() + switch currentState { + case .loaded: + snapshot.appendSections([.pages, .create]) + var adjustedPagesSnapshot = pagesSnapshot + + // Delete extra pages + let sequenceToDelete = adjustedPagesSnapshot.itemIdentifiers.enumerated().filter { $0.offset > Constants.numberOfPages - 1 } + let managedObjectIDsToDelete = sequenceToDelete.map { $0.element } + adjustedPagesSnapshot.deleteItems(managedObjectIDsToDelete) + + // Add pages to new snapshot + let pageItems: [PagesListItem] = adjustedPagesSnapshot.itemIdentifiers.map { .page($0) } + snapshot.appendItems(pageItems, toSection: .pages) + + // Reload items if needed + let reloadIdentifiers = identifiersToBeReloaded(newSnapshot: snapshot, + currentSnapshot: currentSnapshot, + pagesSnapshot: pagesSnapshot) + snapshot.reloadItems(reloadIdentifiers) + + // Add Create Page Item + // Section should be compact if there are more than one pages. Should be expanded otherwise. + let createPageItem: PagesListItem = .createPage(compact: pageItems.hasMultiplePages, + hasPages: pageItems.hasPages) + snapshot.appendItems([createPageItem], toSection: .create) + + case .loading: + snapshot.appendSections([.loading]) + let items: [PagesListItem] = (0..<Constants.numberOfPages).map { .ghost($0) } + snapshot.appendItems(items, toSection: .loading) + } + return snapshot + } + + + /// Returns items that need to be reloaded. These are items that haven't changed position, but their date was updated. + /// - Parameters: + /// - newSnapshot: The snapshot that should be reloaded + /// - currentSnapshot: The old snapshot. Used to check if the item changed position + /// - pagesSnapshot: Snapshot retrieved from CoreDate + /// - Returns: Array of items that should be reloaded + private func identifiersToBeReloaded(newSnapshot: Snapshot, currentSnapshot: Snapshot, pagesSnapshot: PagesSnapshot) -> [PagesListItem] { + return newSnapshot.itemIdentifiers.compactMap { item in + guard case PagesListItem.page(let objectID) = item, + let currentIndex = currentSnapshot.indexOfItem(item), + let index = pagesSnapshot.indexOfItem(objectID), + index == currentIndex else { + return nil // No need to reload if the index changed + } + guard let existingObject = try? fetchedResultsController?.managedObjectContext.existingObject(with: objectID), + existingObject.isUpdated else { + return nil // No need to reload if the object wasn't updated + } + return item + } + } + + private func applySnapshot(_ snapshot: Snapshot, to dataSource: DataSource) { + dataSource.defaultRowAnimation = .fade + dataSource.apply(snapshot, animatingDifferences: true, completion: nil) + view?.tableView.allowsSelection = currentState == .loaded + } +} + +private extension Array where Element == PagesListItem { + var hasPages: Bool { + return !isEmpty + } + + var hasMultiplePages: Bool { + return count > 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/BlogDashboardCardFrameView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/BlogDashboardCardFrameView.swift new file mode 100644 index 000000000000..959f5620eb82 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/BlogDashboardCardFrameView.swift @@ -0,0 +1,255 @@ +import UIKit +import Gridicons + +/// A view that consists of the frame of a Dashboard card +/// Title, icon and action can be customizable +class BlogDashboardCardFrameView: UIView { + + /// The main stack view, in which the header and the content + /// are appended to. + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + /// Header in which icon, title and chevron are added + private lazy var headerStackView: UIStackView = { + let topStackView = UIStackView() + topStackView.layoutMargins = Constants.headerPaddingWithEllipsisButtonHidden + topStackView.isLayoutMarginsRelativeArrangement = true + topStackView.spacing = Constants.headerHorizontalSpacing + topStackView.alignment = .center + topStackView.axis = .horizontal + return topStackView + }() + + /// Card's title + private lazy var titleLabel: UILabel = { + let titleLabel = UILabel() + titleLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + titleLabel.accessibilityTraits = .button + titleLabel.numberOfLines = 0 + return titleLabel + }() + + /// Ellipsis Button displayed on the top right corner of the view. + /// Displayed only when an associated action is set + private(set) lazy var ellipsisButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage(named: "more-horizontal-mobile"), for: .normal) + button.tintColor = UIColor.listIcon + button.contentEdgeInsets = Constants.ellipsisButtonPadding + button.isAccessibilityElement = true + button.accessibilityLabel = Strings.ellipsisButtonAccessibilityLabel + button.accessibilityTraits = .button + button.isHidden = true + button.setContentHuggingPriority(.defaultHigh, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + button.on([.touchUpInside, .menuActionTriggered]) { [weak self] _ in + self?.onEllipsisButtonTap?() + } + return button + }() + + /// Button container stack view anchored to the top right corner of the view. + /// Displayed only when the header view is hidden. + private(set) lazy var buttonContainerStackView: UIStackView = { + let containerStackView = UIStackView() + containerStackView.translatesAutoresizingMaskIntoConstraints = false + containerStackView.axis = .horizontal + return containerStackView + }() + + private var mainStackViewTrailingConstraint: NSLayoutConstraint? + + weak var currentView: UIView? + + /// Closure to be called when anywhere in the view is tapped. + /// If set, the chevron image is displayed. + var onViewTap: (() -> Void)? { + didSet { + addViewTapGestureIfNeeded() + } + } + + /// Closure to be called when the header view is tapped. + /// If set, this overrides the `onViewTap` closure if the tap is inside the header. + /// If set, the chevron image is displayed. + var onHeaderTap: (() -> Void)? { + didSet { + addHeaderTapGestureIfNeeded() + } + } + + /// Closure to be called when the ellipsis button is tapped.. + /// If set, the ellipsis button image is displayed. + var onEllipsisButtonTap: (() -> Void)? { + didSet { + updateEllipsisButtonState() + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + self.backgroundColor = .listForeground + self.configureMainStackView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ rect: CGRect) { + super.draw(rect) + + // Update view background + self.layer.masksToBounds = true + self.layer.cornerRadius = Constants.cornerRadius + } + + /// Add a subview inside the card frame + func add(subview: UIView) { + mainStackView.addArrangedSubview(subview) + currentView = subview + } + + /// Hide the header + func hideHeader() { + headerStackView.isHidden = true + buttonContainerStackView.isHidden = false + + if !ellipsisButton.isHidden { + mainStackViewTrailingConstraint?.constant = -Constants.mainStackViewTrailingPadding + } + } + + /// Hide the header + func showHeader() { + headerStackView.isHidden = false + buttonContainerStackView.isHidden = true + + mainStackViewTrailingConstraint?.constant = 0 + } + + + /// Set's the title displayed in the card's header + /// - Parameters: + /// - title: Title to be displayed + /// - titleHint: The part in the title that needs to be highlighted + func setTitle(_ title: String?, titleHint: String? = nil) { + guard let title else { + return + } + self.titleLabel.attributedText = Self.titleAttributedText(title: title, hint: titleHint, font: titleLabel.font) + } + + private func configureMainStackView() { + addSubview(mainStackView) + + let trailingConstraint = mainStackView.trailingAnchor.constraint(equalTo: trailingAnchor) + mainStackViewTrailingConstraint = trailingConstraint + + NSLayoutConstraint.activate([ + mainStackView.topAnchor.constraint(equalTo: topAnchor), + mainStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.bottomPadding), + mainStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingConstraint + ]) + + mainStackView.addArrangedSubview(headerStackView) + headerStackView.addArrangedSubviews([titleLabel, ellipsisButton]) + } + + /// Configures button container stack view + /// Only call when the header view is hidden + func configureButtonContainerStackView() { + addSubview(buttonContainerStackView) + + NSLayoutConstraint.activate([ + buttonContainerStackView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.buttonContainerStackViewPadding), + buttonContainerStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.buttonContainerStackViewPadding) + ]) + + buttonContainerStackView.addArrangedSubviews([ + ellipsisButton + ]) + } + + func removeButtonContainerStackView() { + buttonContainerStackView.removeFromSuperview() + } + + private func updateEllipsisButtonState() { + ellipsisButton.isHidden = onEllipsisButtonTap == nil + let headerPadding = ellipsisButton.isHidden ? + Constants.headerPaddingWithEllipsisButtonHidden : + Constants.headerPaddingWithEllipsisButtonShown + headerStackView.layoutMargins = headerPadding + } + + private func addHeaderTapGestureIfNeeded() { + // Reset any previously added gesture recognizers + headerStackView.gestureRecognizers?.forEach { headerStackView.removeGestureRecognizer($0) } + + // Add gesture recognizer if needed + if onHeaderTap != nil { + let tap = UITapGestureRecognizer(target: self, action: #selector(headerTapped)) + headerStackView.addGestureRecognizer(tap) + } + } + + private func addViewTapGestureIfNeeded() { + // Reset any previously added gesture recognizers + self.gestureRecognizers?.forEach { self.removeGestureRecognizer($0) } + + // Add gesture recognizer if needed + if onViewTap != nil { + let frameTapGesture = UITapGestureRecognizer(target: self, action: #selector(viewTapped)) + self.addGestureRecognizer(frameTapGesture) + } + } + + @objc private func viewTapped() { + onViewTap?() + } + + @objc private func headerTapped() { + if let onHeaderTap = onHeaderTap { + onHeaderTap() + } + else { + onViewTap?() + } + } + + private static func titleAttributedText(title: String, hint: String?, font: UIFont?) -> NSAttributedString { + let titleString = NSMutableAttributedString(string: title) + if let hint = hint, let range = title.nsRange(of: hint) { + titleString.addAttributes([ + .foregroundColor: UIColor.primary, + .font: font as Any + ], range: range) + } + return titleString + } + + private enum Constants { + static let bottomPadding: CGFloat = 8 + static let headerPaddingWithEllipsisButtonHidden = UIEdgeInsets(top: 12, left: 16, bottom: 8, right: 16) + static let headerPaddingWithEllipsisButtonShown = UIEdgeInsets(top: 12, left: 16, bottom: 8, right: 8) + static let headerHorizontalSpacing: CGFloat = 5 + static let iconSize = CGSize(width: 18, height: 18) + static let cornerRadius: CGFloat = 10 + static let ellipsisButtonPadding = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + static let buttonContainerStackViewPadding: CGFloat = 8 + static let mainStackViewTrailingPadding: CGFloat = 32 + } + + private enum Strings { + static let ellipsisButtonAccessibilityLabel = NSLocalizedString("More", comment: "Accessibility label for more button in dashboard quick start card.") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/DashboardEmptyPostsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/DashboardEmptyPostsCardCell.swift new file mode 100644 index 000000000000..328cae093871 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/DashboardEmptyPostsCardCell.swift @@ -0,0 +1,175 @@ +import UIKit +import WordPressShared + +/// Card cell prompting the user to create their first post +final class DashboardFirstPostCardCell: DashboardEmptyPostsCardCell, BlogDashboardCardConfigurable { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + super.configure(blog: blog, viewController: viewController, apiResponse: apiResponse, cardType: .createPost) + } +} + +/// Card cell prompting the user to create their next post +final class DashboardNextPostCardCell: DashboardEmptyPostsCardCell, BlogDashboardCardConfigurable { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + super.configure(blog: blog, viewController: viewController, apiResponse: apiResponse, cardType: .nextPost) + } +} + +/// Card cell used when no posts are available to display +class DashboardEmptyPostsCardCell: UICollectionViewCell, Reusable { + + // MARK: Views + + private lazy var frameView: BlogDashboardCardFrameView = { + let frameView = BlogDashboardCardFrameView() + frameView.translatesAutoresizingMaskIntoConstraints = false + frameView.hideHeader() + return frameView + }() + + private lazy var mainStackView: UIStackView = { + let mainStackView = UIStackView() + mainStackView.translatesAutoresizingMaskIntoConstraints = false + mainStackView.axis = .horizontal + mainStackView.alignment = .center + mainStackView.distribution = .fillProportionally + mainStackView.spacing = Constants.horizontalSpacing + mainStackView.layoutMargins = Constants.padding + mainStackView.isLayoutMarginsRelativeArrangement = true + return mainStackView + }() + + private lazy var contentStackView: UIStackView = { + let contentStackView = UIStackView() + contentStackView.axis = .vertical + contentStackView.spacing = Constants.verticalSpacing + return contentStackView + }() + + private lazy var titleLabel: UILabel = { + let titleLabel = UILabel() + titleLabel.text = "Create your first post" + titleLabel.font = AppStyleGuide.prominentFont(textStyle: .title3, weight: .semibold) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.adjustsFontSizeToFitWidth = true + titleLabel.minimumScaleFactor = 0.5 + titleLabel.accessibilityTraits = .button + return titleLabel + }() + + private lazy var descriptionLabel: UILabel = { + let descriptionLabel = UILabel() + descriptionLabel.text = Strings.nextPostDescription + descriptionLabel.numberOfLines = 0 + descriptionLabel.textColor = .textSubtle + descriptionLabel.font = WPStyleGuide.fontForTextStyle(.subheadline) + descriptionLabel.accessibilityTraits = .button + return descriptionLabel + }() + + private lazy var imageView: UIImageView = { + let imageView = UIImageView() + imageView.widthAnchor.constraint(equalToConstant: Constants.imageSize.width).isActive = true + let heightAnchor = imageView.heightAnchor.constraint(equalToConstant: Constants.imageSize.height) + heightAnchor.priority = UILayoutPriority(rawValue: 999) + heightAnchor.isActive = true + imageView.contentMode = .scaleAspectFit + imageView.image = UIImage(named: "wp-illustration-first-post") + imageView.isAccessibilityElement = false + return imageView + }() + + // MARK: Private Variables + + /// The VC presenting this cell + private weak var viewController: BlogDashboardViewController? + private var blog: Blog? + private var cardType: DashboardCard? + + // MARK: Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + + contentView.addSubview(frameView) + contentView.pinSubviewToAllEdges(frameView, priority: Constants.constraintPriority) + + frameView.add(subview: mainStackView) + + mainStackView.addArrangedSubviews([ + contentStackView, + imageView + ]) + + contentStackView.addArrangedSubviews([ + titleLabel, + descriptionLabel + ]) + // Add tap gesture + let tap = UITapGestureRecognizer(target: self, action: #selector(promptTapped)) + addGestureRecognizer(tap) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Actions + + @objc private func promptTapped() { + presentEditor() + } +} + +// MARK: BlogDashboardCardConfigurable + +extension DashboardEmptyPostsCardCell { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?, cardType: DashboardCard) { + self.blog = blog + self.viewController = viewController + self.cardType = cardType + + switch cardType { + case .createPost: + titleLabel.text = Strings.firstPostTitle + descriptionLabel.text = Strings.firstPostDescription + case .nextPost: + titleLabel.text = Strings.nextPostTitle + descriptionLabel.text = Strings.nextPostDescription + default: + assertionFailure("Cell used with wrong card type") + return + } + + BlogDashboardAnalytics.shared.track(.dashboardCardShown, properties: ["type": "post", "sub_type": cardType.rawValue]) + } +} + +// MARK: Private Helpers + +private extension DashboardEmptyPostsCardCell { + func presentEditor() { + BlogDashboardAnalytics.shared.track(.dashboardCardItemTapped, properties: ["type": "post", "sub_type": cardType?.rawValue ?? ""]) + let presenter = RootViewCoordinator.sharedPresenter + presenter.showPostTab() + } +} + +// MARK: Constants + +extension DashboardEmptyPostsCardCell { + private enum Constants { + static let horizontalSpacing: CGFloat = 16 + static let verticalSpacing: CGFloat = 10 + static let padding = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + static let imageSize = CGSize(width: 70, height: 70) + static let constraintPriority = UILayoutPriority(999) + } + + private enum Strings { + static let nextPostTitle = NSLocalizedString("Create your next post", comment: "Title for the card prompting the user to create a new post.") + static let nextPostDescription = NSLocalizedString("Posting regularly helps build your audience!", comment: "Description for the card prompting the user to create a new post.") + static let firstPostTitle = NSLocalizedString("Create your first post", comment: "Title for the card prompting the user to create their first post.") + static let firstPostDescription = NSLocalizedString("Posts appear on your blog page in reverse chronological order. It's time to share your ideas with the world!", comment: "Description for the card prompting the user to create their first post.") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/DashboardPostsListCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/DashboardPostsListCardCell.swift new file mode 100644 index 000000000000..a82c1b4a7541 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/DashboardPostsListCardCell.swift @@ -0,0 +1,201 @@ +import UIKit + +final class DashboardDraftPostsCardCell: DashboardPostsListCardCell, BlogDashboardCardConfigurable { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + super.configure(blog: blog, viewController: viewController, apiResponse: apiResponse, cardType: .draftPosts) + } +} + +final class DashboardScheduledPostsCardCell: DashboardPostsListCardCell, BlogDashboardCardConfigurable { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + super.configure(blog: blog, viewController: viewController, apiResponse: apiResponse, cardType: .scheduledPosts) + } +} + +class DashboardPostsListCardCell: UICollectionViewCell, Reusable { + + // MARK: Views + + private let frameView = BlogDashboardCardFrameView() + + lazy var tableView: UITableView = { + let tableView = DashboardCardTableView() + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.isScrollEnabled = false + tableView.backgroundColor = nil + let postCompactCellNib = PostCompactCell.defaultNib + tableView.register(postCompactCellNib, forCellReuseIdentifier: PostCompactCell.defaultReuseID) + let ghostCellNib = BlogDashboardPostCardGhostCell.defaultNib + tableView.register(ghostCellNib, forCellReuseIdentifier: BlogDashboardPostCardGhostCell.defaultReuseID) + tableView.register(DashboardPostListErrorCell.self, forCellReuseIdentifier: DashboardPostListErrorCell.defaultReuseID) + tableView.separatorStyle = .none + return tableView + }() + + + // MARK: Private Variables + + private var viewModel: PostsCardViewModel? + private var blog: Blog? + private var status: BasePost.Status = .draft + + /// The VC presenting this cell + private weak var viewController: BlogDashboardViewController? + + // MARK: Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + override func prepareForReuse() { + super.prepareForReuse() + tableView.dataSource = nil + viewModel?.stopObserving() + } + + // MARK: Helpers + + private func commonInit() { + addSubviews() + tableView.delegate = self + } + + private func addSubviews() { + frameView.translatesAutoresizingMaskIntoConstraints = false + frameView.add(subview: tableView) + + contentView.addSubview(frameView) + contentView.pinSubviewToAllEdges(frameView, priority: Constants.constraintPriority) + } + + func trackPostsDisplayed() { + BlogDashboardAnalytics.shared.track(.dashboardCardShown, properties: ["type": "post", "sub_type": status.rawValue]) + } + +} + +// MARK: BlogDashboardCardConfigurable + +extension DashboardPostsListCardCell { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?, cardType: DashboardCard) { + self.blog = blog + self.viewController = viewController + + switch cardType { + case .draftPosts: + configureDraftsList(blog: blog) + status = .draft + case .scheduledPosts: + configureScheduledList(blog: blog) + status = .scheduled + default: + assertionFailure("Cell used with wrong card type") + return + } + addContextMenu(card: cardType, blog: blog) + + viewModel = PostsCardViewModel(blog: blog, status: status, view: self) + viewModel?.viewDidLoad() + tableView.dataSource = viewModel?.diffableDataSource + viewModel?.refresh() + } + + private func addContextMenu(card: DashboardCard, blog: Blog) { + guard FeatureFlag.personalizeHomeTab.enabled else { return } + + frameView.onEllipsisButtonTap = { + BlogDashboardAnalytics.trackContextualMenuAccessed(for: card) + } + frameView.ellipsisButton.showsMenuAsPrimaryAction = true + frameView.ellipsisButton.menu = UIMenu(title: "", options: .displayInline, children: [ + BlogDashboardHelpers.makeHideCardAction(for: card, siteID: blog.dotComID?.intValue ?? 0) + ]) + } + + private func configureDraftsList(blog: Blog) { + frameView.setTitle(Strings.draftsTitle, titleHint: Strings.draftsTitleHint) + frameView.onHeaderTap = { [weak self] in + self?.presentPostList(with: .draft) + } + } + + private func configureScheduledList(blog: Blog) { + frameView.setTitle(Strings.scheduledTitle) + frameView.onHeaderTap = { [weak self] in + self?.presentPostList(with: .scheduled) + } + } + + private func presentPostList(with status: BasePost.Status) { + guard let blog = blog, let viewController = viewController else { + return + } + + PostListViewController.showForBlog(blog, from: viewController, withPostStatus: status) + WPAppAnalytics.track(.openedPosts, withProperties: [WPAppAnalyticsKeyTabSource: "dashboard", WPAppAnalyticsKeyTapSource: "posts_card"], with: blog) + } + +} + +// MARK: - UITableViewDelegate +extension DashboardPostsListCardCell: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let post = viewModel?.postAt(indexPath), + let viewController = viewController else { + return + } + + WPAnalytics.track(.dashboardCardItemTapped, + properties: ["type": "post", "sub_type": status.rawValue]) + viewController.presentedPostStatus = viewModel?.currentPostStatus() + PostListEditorPresenter.handle(post: post, in: viewController, entryPoint: .dashboard) + } +} + +// MARK: PostsCardView + +extension DashboardPostsListCardCell: PostsCardView { + + func removeIfNeeded() { + viewController?.reloadCardsLocally() + } +} + +extension BlogDashboardViewController: EditorAnalyticsProperties { + func propertiesForAnalytics() -> [String: AnyObject] { + var properties = [String: AnyObject]() + + properties["type"] = PostServiceType.post.rawValue as AnyObject? + properties["filter"] = presentedPostStatus as AnyObject? + + if let dotComID = blog.dotComID { + properties[WPAppAnalyticsKeyBlogID] = dotComID + } + + return properties + } +} + +// MARK: Constants + +private extension DashboardPostsListCardCell { + + private enum Strings { + static let draftsTitle = NSLocalizedString("my-sites.drafts.card.title", value: "Work on a draft post", comment: "Title for the card displaying draft posts.") + static let draftsTitleHint = NSLocalizedString("my-sites.drafts.card.title.hint", value: "draft post", comment: "The part in the title that should be highlighted.") + static let scheduledTitle = NSLocalizedString("Upcoming scheduled posts", comment: "Title for the card displaying upcoming scheduled posts.") + } + + enum Constants { + static let iconSize = CGSize(width: 18, height: 18) + static let constraintPriority = UILayoutPriority(999) + static let numberOfPosts = 3 + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/PostsCardViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/PostsCardViewModel.swift new file mode 100644 index 000000000000..9c3367107387 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/PostsCardViewModel.swift @@ -0,0 +1,381 @@ +import Foundation +import CoreData +import UIKit + +protocol PostsCardView: AnyObject { + var tableView: UITableView { get } + + func removeIfNeeded() +} + +enum PostsListSection: CaseIterable { + case posts + case error + case loading +} + +enum PostsListItem: Hashable { + case post(NSManagedObjectID) + case error + case ghost(Int) +} + +/// Responsible for populating a table view with posts +/// And syncing them if needed. +/// +class PostsCardViewModel: NSObject { + var blog: Blog + + private let managedObjectContext: NSManagedObjectContext + + private var postListFilter: PostListFilter = PostListFilter.draftFilter() + + private var fetchedResultsController: NSFetchedResultsController<Post>! + + private var status: BasePost.Status = .draft + + private var isSyncing = false + + private var currentState: PostsListSection = .loading { + didSet { + if oldValue != currentState { + forceReloadSnapshot() + trackCardDisplayedIfNeeded() + } + } + } + + private var lastPostsSnapshot: PostsSnapshot? + + private weak var view: PostsCardView? + + typealias DataSource = UITableViewDiffableDataSource<PostsListSection, PostsListItem> + typealias Snapshot = NSDiffableDataSourceSnapshot<PostsListSection, PostsListItem> + typealias PostsSnapshot = NSDiffableDataSourceSnapshot<Int, NSManagedObjectID> + + lazy var diffableDataSource = DataSource(tableView: view!.tableView) { [weak self] (tableView, indexPath, item) -> UITableViewCell? in + guard let self = self else { + return nil + } + switch item { + case .post(let objectID): + return self.configurePostCell(objectID: objectID, tableView: tableView, indexPath: indexPath) + case .error: + return self.configureErrorCell(tableView: tableView, indexPath: indexPath) + case .ghost: + return self.configureGhostCell(tableView: tableView, indexPath: indexPath) + } + + } + + init(blog: Blog, status: BasePost.Status, view: PostsCardView, managedObjectContext: NSManagedObjectContext = ContextManager.shared.mainContext) { + self.blog = blog + self.view = view + self.managedObjectContext = managedObjectContext + self.status = status + + super.init() + } + + /// Refresh the results and reload the data on the table view + func refresh() { + do { + try fetchedResultsController.performFetch() + view?.tableView.reloadData() + removeViewIfNeeded() + showLoadingIfNeeded() + } catch { + print("Fetch failed") + } + } + + func retry() { + showLoadingIfNeeded() + sync() + } + + /// Set up the view model to be ready for use + func viewDidLoad() { + performInitialLoading() + refresh() + } + + /// Return the post at the given IndexPath + func postAt(_ indexPath: IndexPath) -> Post { + fetchedResultsController.object(at: indexPath) + } + + /// The status of post being presented (Draft, Published) + func currentPostStatus() -> String { + postListFilter.title + } + + func stopObserving() { + DashboardPostsSyncManager.shared.removeListener(self) + fetchedResultsController.delegate = nil + } +} + +// MARK: Cells Configuration + +private extension PostsCardViewModel { + private func configurePostCell(objectID: NSManagedObjectID, tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { + guard let post = try? self.managedObjectContext.existingObject(with: objectID) as? Post else { + return UITableViewCell() + } + + let cell = tableView.dequeueReusableCell(withIdentifier: PostCompactCell.defaultReuseID, for: indexPath) as? PostCompactCell + + cell?.accessoryType = .none + cell?.configureForDashboard(with: post) + + return cell ?? UITableViewCell() + } + + private func configureErrorCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: DashboardPostListErrorCell.defaultReuseID, for: indexPath) as? DashboardPostListErrorCell + + cell?.errorMessage = Strings.loadingFailure + cell?.onCellTap = { [weak self] in + self?.retry() + } + + return cell ?? UITableViewCell() + } + + private func configureGhostCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: BlogDashboardPostCardGhostCell.defaultReuseID, for: indexPath) as? BlogDashboardPostCardGhostCell + let style = GhostStyle(beatDuration: GhostStyle.Defaults.beatDuration, + beatStartColor: .placeholderElement, + beatEndColor: .placeholderElementFaded) + cell?.contentView.stopGhostAnimation() + cell?.contentView.startGhostAnimation(style: style) + return cell ?? UITableViewCell() + } +} + +// MARK: - Private methods + +private extension PostsCardViewModel { + var numberOfPosts: Int { + fetchedResultsController.fetchedObjects?.count ?? 0 + } + + func performInitialLoading() { + DashboardPostsSyncManager.shared.addListener(self) + updateFilter() + createFetchedResultsController() + showLoadingIfNeeded() + sync() + } + + func createFetchedResultsController() { + fetchedResultsController?.delegate = nil + fetchedResultsController = nil + + fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest(), managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: nil) + + fetchedResultsController.delegate = self + } + + func fetchRequest() -> NSFetchRequest<Post> { + let fetchRequest = NSFetchRequest<Post>(entityName: String(describing: Post.self)) + fetchRequest.predicate = predicateForFetchRequest() + fetchRequest.sortDescriptors = sortDescriptorsForFetchRequest() + fetchRequest.fetchBatchSize = Constants.numberOfPosts + fetchRequest.fetchLimit = Constants.numberOfPosts + return fetchRequest + } + + func predicateForFetchRequest() -> NSPredicate { + postListFilter.predicate(for: blog) + } + + func sortDescriptorsForFetchRequest() -> [NSSortDescriptor] { + return postListFilter.sortDescriptors + } + + func sync() { + isSyncing = true + let filter = postListFilter + DashboardPostsSyncManager.shared.syncPosts(blog: blog, postType: .post, statuses: filter.statuses.strings) + } + + func updateFilter() { + switch status { + case .draft: + self.postListFilter = PostListFilter.draftFilter() + case .scheduled: + self.postListFilter = PostListFilter.scheduledFilter() + default: + fatalError("Post status not supported") + } + } + + func showLoadingFailureErrorIfNeeded() { + // Only show error state if there are no posts at all + if numberOfPosts == 0 { + currentState = .error + } + else { + currentState = .posts + } + } + + func hideLoading() { + currentState = .posts + } + + func showLoadingIfNeeded() { + // Only show loading state if there are no posts at all + if numberOfPosts == 0 && isSyncing { + currentState = .loading + } + else { + currentState = .posts + } + } + + func updateDashboardStateWithSuccessfulSync() { + switch status { + case .draft: + blog.dashboardState.draftsSynced = true + case .scheduled: + blog.dashboardState.scheduledSynced = true + default: + return + } + } + + /// Triggers the view to remove itself if the posts count reached zero and if we are not currently syncing. + /// - Returns: Boolean value indicating whether an update was needed or not. + /// Returns true if update was needed, false otherwise. + @discardableResult + func removeViewIfNeeded() -> Bool { + if let postsCount = fetchedResultsController?.fetchedObjects?.count, postsCount == 0, !isSyncing { + view?.removeIfNeeded() + return true + } + return false + } + + func trackCardDisplayedIfNeeded() { + switch currentState { + case .posts: + BlogDashboardAnalytics.shared.track(.dashboardCardShown, properties: ["type": "post", "sub_type": status.rawValue]) + case .error: + BlogDashboardAnalytics.shared.track(.dashboardCardShown, properties: ["type": "post", "sub_type": "error"]) + case .loading: + return + } + } + + enum Constants { + static let numberOfPosts = 3 + static let numberOfPostsToSync: NSNumber = 3 + } + + enum Strings { + static let loadingFailure = NSLocalizedString("Unable to load posts right now.", comment: "Message for when posts fail to load on the dashboard") + } +} + +// MARK: DashboardPostsSyncManagerListener + +extension PostsCardViewModel: DashboardPostsSyncManagerListener { + func postsSynced(success: Bool, + blog: Blog, + postType: DashboardPostsSyncManager.PostType, + posts: [AbstractPost]?, + for statuses: [String]) { + let currentStatuses = postListFilter.statuses.strings + guard postType == .post, + self.blog == blog, + currentStatuses.allSatisfy(statuses.contains) else { + return + } + + isSyncing = false + if success { + updateDashboardStateWithSuccessfulSync() + if numberOfPosts == 0 { + removeViewIfNeeded() + } + + hideLoading() + } + else { + showLoadingFailureErrorIfNeeded() + } + } +} + +// MARK: - NSFetchedResultsControllerDelegate + +extension PostsCardViewModel: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + guard let dataSource = view?.tableView.dataSource as? DataSource else { + return + } + + let postsSnapshot = snapshot as PostsSnapshot + self.lastPostsSnapshot = postsSnapshot + + guard removeViewIfNeeded() == false else { + return // Don't update datasource if the view will be updated + } + + let currentSnapshot = dataSource.snapshot() as Snapshot + let finalSnapshot = createSnapshot(currentSnapshot: currentSnapshot, postsSnapshot: postsSnapshot) + applySnapshot(finalSnapshot, to: dataSource) + } + + private func forceReloadSnapshot() { + guard let dataSource = view?.tableView.dataSource as? DataSource else { + return + } + let currentSnapshot = dataSource.snapshot() as Snapshot + let postsSnapshot = self.lastPostsSnapshot ?? NSDiffableDataSourceSnapshot<Int, NSManagedObjectID>() + let snapshot = createSnapshot(currentSnapshot: currentSnapshot, postsSnapshot: postsSnapshot) + applySnapshot(snapshot, to: dataSource) + } + + private func createSnapshot(currentSnapshot: Snapshot, postsSnapshot: PostsSnapshot) -> Snapshot { + var snapshot = Snapshot() + snapshot.appendSections(PostsListSection.allCases) + switch currentState { + case .posts: + var adjustedPostsSnapshot = postsSnapshot + adjustedPostsSnapshot.deleteItems(adjustedPostsSnapshot.itemIdentifiers.enumerated().filter { $0.offset > fetchedResultsController.fetchRequest.fetchLimit - 1 }.map { $0.element }) + let postItems: [PostsListItem] = adjustedPostsSnapshot.itemIdentifiers.map { .post($0) } + snapshot.appendItems(postItems, toSection: .posts) + + let reloadIdentifiers: [PostsListItem] = snapshot.itemIdentifiers.compactMap { item in + guard case PostsListItem.post(let objectID) = item, + let currentIndex = currentSnapshot.indexOfItem(item), let index = postsSnapshot.indexOfItem(objectID), index == currentIndex else { + return nil + } + guard let existingObject = try? fetchedResultsController.managedObjectContext.existingObject(with: objectID), + existingObject.isUpdated else { + return nil + } + return item + } + snapshot.reloadItems(reloadIdentifiers) + + case .error: + snapshot.appendItems([.error], toSection: .error) + + case .loading: + let items: [PostsListItem] = (0..<Constants.numberOfPosts).map { .ghost($0) } + snapshot.appendItems(items, toSection: .loading) + } + return snapshot + } + + private func applySnapshot(_ snapshot: Snapshot, to dataSource: DataSource) { + dataSource.defaultRowAnimation = .fade + dataSource.apply(snapshot, animatingDifferences: true, completion: nil) + view?.tableView.allowsSelection = currentState == .posts + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/Views/BlogDashboardPostCardGhostCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/Views/BlogDashboardPostCardGhostCell.swift new file mode 100644 index 000000000000..ffe1d2150315 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/Views/BlogDashboardPostCardGhostCell.swift @@ -0,0 +1,13 @@ +import UIKit + +class BlogDashboardPostCardGhostCell: UITableViewCell, NibReusable { + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var timestampLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + + WPStyleGuide.configureTableViewCell(self) + WPStyleGuide.applyPostCardStyle(self) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/Views/BlogDashboardPostCardGhostCell.xib b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/Views/BlogDashboardPostCardGhostCell.xib new file mode 100644 index 000000000000..668917147d2d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/Views/BlogDashboardPostCardGhostCell.xib @@ -0,0 +1,110 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" indentationWidth="10" rowHeight="99" id="I5t-CH-v6f" customClass="BlogDashboardPostCardGhostCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="320" height="99"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="I5t-CH-v6f" id="Q79-tN-qHr"> + <rect key="frame" x="0.0" y="0.0" width="320" height="99"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="4j2-Ve-ekh"> + <rect key="frame" x="0.0" y="0.0" width="320" height="99"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="kbh-Ck-sE6" userLabel="Inner Stack View"> + <rect key="frame" x="16" y="8" width="312" height="83"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="yzx-SY-SGf"> + <rect key="frame" x="0.0" y="0.0" width="312" height="83"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Yj1-10-Dz3"> + <rect key="frame" x="0.0" y="24" width="312" height="35"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="zJb-yj-c1C"> + <rect key="frame" x="0.0" y="0.0" width="272" height="13.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="VgR-q3-yxV"> + <rect key="frame" x="0.0" y="21.5" width="192" height="13.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleCaption2"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="VgR-q3-yxV" firstAttribute="top" secondItem="zJb-yj-c1C" secondAttribute="bottom" constant="8" id="1ol-4A-Rfh"/> + <constraint firstAttribute="trailing" secondItem="zJb-yj-c1C" secondAttribute="trailing" constant="40" id="49t-Em-RBE"/> + <constraint firstItem="zJb-yj-c1C" firstAttribute="top" secondItem="Yj1-10-Dz3" secondAttribute="top" id="6t6-7q-mDD"/> + <constraint firstAttribute="bottom" secondItem="VgR-q3-yxV" secondAttribute="bottom" id="760-l2-GmL"/> + <constraint firstItem="zJb-yj-c1C" firstAttribute="leading" secondItem="Yj1-10-Dz3" secondAttribute="leading" id="Vqn-8n-spa"/> + <constraint firstAttribute="trailing" secondItem="VgR-q3-yxV" secondAttribute="trailing" constant="120" id="Wmz-X5-JO7"/> + <constraint firstItem="VgR-q3-yxV" firstAttribute="leading" secondItem="zJb-yj-c1C" secondAttribute="leading" id="fBe-hc-pjp"/> + </constraints> + </view> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="trailing" secondItem="Yj1-10-Dz3" secondAttribute="trailing" id="G43-bO-eNp"/> + <constraint firstItem="Yj1-10-Dz3" firstAttribute="centerY" secondItem="yzx-SY-SGf" secondAttribute="centerY" id="ZUc-99-5rc"/> + <constraint firstItem="Yj1-10-Dz3" firstAttribute="leading" secondItem="yzx-SY-SGf" secondAttribute="leading" id="elk-Ee-xOz"/> + </constraints> + </view> + </subviews> + <color key="backgroundColor" systemColor="secondarySystemGroupedBackgroundColor"/> + </stackView> + </subviews> + <color key="backgroundColor" systemColor="secondarySystemGroupedBackgroundColor"/> + <constraints> + <constraint firstAttribute="bottom" secondItem="kbh-Ck-sE6" secondAttribute="bottom" constant="8" id="EAD-zc-JTh"/> + <constraint firstAttribute="height" relation="greaterThanOrEqual" priority="999" constant="60" id="OcJ-tn-E7x"/> + <constraint firstAttribute="trailing" secondItem="kbh-Ck-sE6" secondAttribute="trailing" constant="-8" id="Z3w-wQ-jiF"/> + <constraint firstItem="kbh-Ck-sE6" firstAttribute="top" secondItem="4j2-Ve-ekh" secondAttribute="top" constant="8" id="a1K-da-e78"/> + <constraint firstItem="kbh-Ck-sE6" firstAttribute="leading" secondItem="4j2-Ve-ekh" secondAttribute="leading" constant="16" id="rFw-1c-vxN"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="t9m-fX-UGv"> + <rect key="frame" x="0.0" y="98" width="320" height="1"/> + <color key="backgroundColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="1" placeholder="YES" id="Eos-wQ-OaH"/> + </constraints> + </view> + </subviews> + <color key="backgroundColor" systemColor="secondarySystemGroupedBackgroundColor"/> + <constraints> + <constraint firstAttribute="bottom" secondItem="4j2-Ve-ekh" secondAttribute="bottom" id="TlK-Ne-mgt"/> + <constraint firstAttribute="trailing" secondItem="t9m-fX-UGv" secondAttribute="trailing" id="b6s-os-xxh"/> + <constraint firstItem="4j2-Ve-ekh" firstAttribute="top" secondItem="Q79-tN-qHr" secondAttribute="top" id="bN3-jA-XK5"/> + <constraint firstAttribute="trailing" secondItem="4j2-Ve-ekh" secondAttribute="trailing" priority="999" id="eNp-ns-Wbl"/> + <constraint firstItem="t9m-fX-UGv" firstAttribute="leading" secondItem="Q79-tN-qHr" secondAttribute="leading" id="poK-76-ZcI"/> + <constraint firstItem="4j2-Ve-ekh" firstAttribute="leading" secondItem="Q79-tN-qHr" secondAttribute="leading" priority="999" id="sbb-HI-X9X"/> + <constraint firstItem="t9m-fX-UGv" firstAttribute="bottom" secondItem="4j2-Ve-ekh" secondAttribute="bottom" id="uAQ-QU-sOb"/> + </constraints> + </tableViewCellContentView> + <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <inset key="separatorInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/> + <connections> + <outlet property="timestampLabel" destination="VgR-q3-yxV" id="XnM-f2-gcX"/> + <outlet property="titleLabel" destination="zJb-yj-c1C" id="pRO-vQ-ymN"/> + </connections> + <point key="canvasLocation" x="135" y="179.72222222222223"/> + </tableViewCell> + </objects> + <resources> + <systemColor name="secondarySystemGroupedBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/Views/DashboardPostListErrorCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/Views/DashboardPostListErrorCell.swift new file mode 100644 index 000000000000..1b2225b3ee72 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Posts/Views/DashboardPostListErrorCell.swift @@ -0,0 +1,108 @@ +import UIKit + +class DashboardPostListErrorCell: UITableViewCell, Reusable { + + // MARK: Public Variables + + var errorMessage: String? { + didSet { + errorTitle.text = errorMessage + } + } + + /// Closure to be called when cell is tapped + /// If set, the retry label is displayed + var onCellTap: (() -> Void)? { + didSet { + if onCellTap != nil { + showRetry() + } + else { + hideRetry() + } + } + } + + // MARK: Views + + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = Constants.spacing + return stackView + }() + + private lazy var errorTitle: UILabel = { + let errorTitle = UILabel() + errorTitle.textAlignment = .center + errorTitle.textColor = .textSubtle + WPStyleGuide.configureLabel(errorTitle, textStyle: .callout, fontWeight: .semibold) + return errorTitle + }() + + private lazy var retryLabel: UILabel = { + let retryLabel = UILabel() + retryLabel.textAlignment = .center + retryLabel.text = Strings.tapToRetry + retryLabel.textColor = .textSubtle + WPStyleGuide.configureLabel(retryLabel, textStyle: .callout, fontWeight: .regular) + return retryLabel + }() + + private var tapGestureRecognizer: UITapGestureRecognizer? + + // MARK: Initializers + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + // MARK: Helpers + + private func commonInit() { + stackView.addArrangedSubviews([errorTitle, retryLabel]) + + contentView.addSubview(stackView) + contentView.pinSubviewToAllEdges(stackView, priority: Constants.constraintPriority) + + backgroundColor = .clear + + self.tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTap)) + } + + private func showRetry() { + retryLabel.isHidden = false + isUserInteractionEnabled = true + if let tapGestureRecognizer = tapGestureRecognizer { + addGestureRecognizer(tapGestureRecognizer) + } + } + + private func hideRetry() { + retryLabel.isHidden = true + isUserInteractionEnabled = false + if let tapGestureRecognizer = tapGestureRecognizer { + removeGestureRecognizer(tapGestureRecognizer) + } + } + + @objc func didTap() { + onCellTap?() + } + + private enum Constants { + static let spacing: CGFloat = 8 + static let constraintPriority = UILayoutPriority(999) + } + + private enum Strings { + static let tapToRetry = NSLocalizedString("Tap to retry", comment: "Label for a button to retry loading posts") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/AvatarTrainView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/AvatarTrainView.swift new file mode 100644 index 000000000000..ac73162ffd1e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/AvatarTrainView.swift @@ -0,0 +1,99 @@ +import UIKit + +/// A view that shows a train of circular avatar images. +/// +final class AvatarTrainView: UIView { + + // MARK: Private Properties + + private var avatarURLs: [URL?] + + private var placeholderImage: UIImage + + private lazy var avatarStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = Constants.imageViewSpacing + + return stackView + }() + + /// The border layer "cuts" into the image height, reducing the displayable area. + /// Therefore, we need to account for the border width (on both sides) to keep the image displayed in the intended size. + var imageHeight: CGFloat { + Constants.avatarDiameter + (2 * Constants.borderWidth) + } + + // MARK: Public Methods + + init(avatarURLs: [URL?], placeholderImage: UIImage? = nil) { + self.avatarURLs = avatarURLs + self.placeholderImage = placeholderImage ?? Constants.placeholderImage + super.init(frame: .zero) + + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + // redraw border when user interface style changes. + if let previousTraitCollection = previousTraitCollection, + previousTraitCollection.userInterfaceStyle != traitCollection.userInterfaceStyle { + configureAvatarBorders() + } + } + + override func layoutSubviews() { + configureAvatarBorders() + } + +} + +// MARK: Private Helpers + +private extension AvatarTrainView { + + func setupViews() { + addSubview(avatarStackView) + pinSubviewToAllEdges(avatarStackView) + avatarStackView.addArrangedSubviews(avatarURLs.map { makeAvatarImageView(with: $0) }) + } + + func makeAvatarImageView(with avatarURL: URL? = nil) -> UIImageView { + let imageView = CircularImageView(image: placeholderImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + imageView.heightAnchor.constraint(equalToConstant: imageHeight), + imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor) + ]) + + if let avatarURL = avatarURL { + imageView.downloadImage(from: avatarURL, placeholderImage: placeholderImage) + } + + return imageView + } + + func configureAvatarBorders() { + avatarStackView.arrangedSubviews.forEach { view in + view.layer.masksToBounds = true + view.layer.borderWidth = Constants.borderWidth + view.layer.borderColor = UIColor.listForeground.cgColor + } + } + + // MARK: Constants + + struct Constants { + static let imageViewSpacing: CGFloat = -5 + static let avatarDiameter: CGFloat = 20 + static let borderWidth: CGFloat = 2 + static let placeholderImage: UIImage = .gravatarPlaceholderImage + } + +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/BloggingPromptsAttribution.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/BloggingPromptsAttribution.swift new file mode 100644 index 000000000000..0434b85fc765 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/BloggingPromptsAttribution.swift @@ -0,0 +1,44 @@ +enum BloggingPromptsAttribution: String { + case dayone + + var attributedText: NSAttributedString { + let baseText = String(format: Strings.fromTextFormat, source) + let attributedText = NSMutableAttributedString(string: baseText, attributes: Constants.baseAttributes) + guard let range = baseText.range(of: source) else { + return attributedText + } + attributedText.addAttributes(Constants.sourceAttributes, range: NSRange(range, in: baseText)) + + return attributedText + } + + var source: String { + switch self { + case .dayone: return Strings.dayOne + } + } + + var iconImage: UIImage? { + switch self { + case .dayone: return Constants.dayOneIcon + } + } + + private struct Strings { + static let fromTextFormat = NSLocalizedString("From %1$@", comment: "Format for blogging prompts attribution. %1$@ is the attribution source.") + static let dayOne = "Day One" + } + + private struct Constants { + static let baseAttributes: [NSAttributedString.Key: Any] = [ + .font: WPStyleGuide.fontForTextStyle(.caption1), + .foregroundColor: UIColor.secondaryLabel, + ] + static let sourceAttributes: [NSAttributedString.Key: Any] = [ + .font: WPStyleGuide.fontForTextStyle(.caption1, fontWeight: .medium), + .foregroundColor: UIColor.text, + ] + static let iconSize = CGSize(width: 18, height: 18) + static let dayOneIcon = UIImage(named: "logo-dayone")?.resizedImage(Constants.iconSize, interpolationQuality: .default) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift new file mode 100644 index 000000000000..e3d845b7eec0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Prompts/DashboardPromptsCardCell.swift @@ -0,0 +1,710 @@ +import UIKit +import WordPressShared +import WordPressUI +import WordPressFlux + +class DashboardPromptsCardCell: UICollectionViewCell, Reusable { + // MARK: - Public Properties + + // This is public so it can be accessed from the BloggingPromptsFeatureDescriptionView. + private(set) lazy var cardFrameView: BlogDashboardCardFrameView = { + let frameView = BlogDashboardCardFrameView() + frameView.translatesAutoresizingMaskIntoConstraints = false + frameView.setTitle(Strings.cardFrameTitle) + + // NOTE: Remove the logic when support for iOS 14 is dropped + if #available (iOS 15.0, *) { + // assign an empty closure so the button appears. + frameView.onEllipsisButtonTap = { + BlogDashboardAnalytics.trackContextualMenuAccessed(for: .prompts) + } + frameView.ellipsisButton.showsMenuAsPrimaryAction = true + frameView.ellipsisButton.menu = contextMenu + } else { + // Show a fallback implementation using `MenuSheetViewController`. + // iOS 13 doesn't support showing UIMenu programmatically. + // iOS 14 doesn't support `UIDeferredMenuElement.uncached`. + frameView.onEllipsisButtonTap = { [weak self] in + BlogDashboardAnalytics.trackContextualMenuAccessed(for: .prompts) + self?.showMenuSheet() + } + } + + return frameView + }() + + // MARK: - Private Properties + + private var blog: Blog? + + private var prompt: BloggingPrompt? { + didSet { + refreshStackView() + } + } + + private var isAnswered: Bool { + if forExampleDisplay { + return false + } + + return prompt?.answered ?? false + } + + private lazy var bloggingPromptsService: BloggingPromptsService? = { + return BloggingPromptsService(blog: blog) + }() + + /// When set to true, a "default" version of the card is displayed. That is: + /// - `maxAvatarCount` number of avatars. + /// - `exampleAnswerCount` answer count. + /// - `examplePrompt` prompt label. + /// - disabled user interaction. + private var forExampleDisplay: Bool = false { + didSet { + isUserInteractionEnabled = false + cardFrameView.isUserInteractionEnabled = false + refreshStackView() + } + } + + private var didFailLoadingPrompt: Bool = false { + didSet { + if didFailLoadingPrompt != oldValue { + refreshStackView() + } + } + } + + // This provides a quick way to toggle in flux features. + // Since they probably will not be included in Blogging Prompts V1, + // they are disabled by default. + private let sharePromptEnabled = false + + // Used to present: + // - The menu sheet for contextual menu in iOS13. + // - The Blogging Prompts list when selected from the contextual menu. + private weak var presenterViewController: BlogDashboardViewController? = nil + + private lazy var containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Constants.spacing + stackView.layoutMargins = Constants.containerMargins + stackView.isLayoutMarginsRelativeArrangement = true + return stackView + }() + + // MARK: Top row views + + private lazy var promptLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = WPStyleGuide.BloggingPrompts.promptContentFont + label.textAlignment = .center + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + + return label + }() + + private lazy var promptTitleView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(promptLabel) + view.pinSubviewToAllEdges(promptLabel, insets: UIEdgeInsets(top: Constants.spacing, left: 0, bottom: 0, right: 0)) + + return view + }() + + // MARK: Middle row views + + private var answerCount: Int { + if forExampleDisplay { + return Constants.exampleAnswerCount + } + + return Int(prompt?.answerCount ?? 0) + } + + private var answerInfoText: String { + if RemoteFeatureFlag.bloggingPromptsSocial.enabled() { + return Strings.viewAllResponses + } + + let stringFormat = (answerCount == 1 ? Strings.answerInfoSingularFormat : Strings.answerInfoPluralFormat) + return String(format: stringFormat, answerCount) + } + + private var avatarTrainContainerView: UIView { + let avatarURLs: [URL?] = { + if forExampleDisplay { + return (0..<Constants.maxAvatarCount).map { _ in nil } + } + + guard let displayAvatarURLs = prompt?.displayAvatarURLs else { + return [] + } + + return Array(displayAvatarURLs.prefix(min(answerCount, Constants.maxAvatarCount))) + }() + + let avatarTrainView = AvatarTrainView(avatarURLs: avatarURLs, placeholderImage: Style.avatarPlaceholderImage) + avatarTrainView.translatesAutoresizingMaskIntoConstraints = false + + let trainContainerView = UIView() + trainContainerView.translatesAutoresizingMaskIntoConstraints = false + trainContainerView.addSubview(avatarTrainView) + NSLayoutConstraint.activate([ + trainContainerView.centerYAnchor.constraint(equalTo: avatarTrainView.centerYAnchor), + trainContainerView.topAnchor.constraint(lessThanOrEqualTo: avatarTrainView.topAnchor), + trainContainerView.bottomAnchor.constraint(greaterThanOrEqualTo: avatarTrainView.bottomAnchor), + trainContainerView.leadingAnchor.constraint(equalTo: avatarTrainView.leadingAnchor), + trainContainerView.trailingAnchor.constraint(equalTo: avatarTrainView.trailingAnchor) + ]) + + return trainContainerView + } + + private var answerInfoButton: UIButton { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(answerInfoText, for: .normal) + button.titleLabel?.font = WPStyleGuide.BloggingPrompts.answerInfoButtonFont + button.setTitleColor( + RemoteFeatureFlag.bloggingPromptsSocial.enabled() + ? WPStyleGuide.BloggingPrompts.buttonTitleColor + : WPStyleGuide.BloggingPrompts.answerInfoButtonColor, + for: .normal + ) + button.titleLabel?.numberOfLines = 0 + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.addTarget(self, action: #selector(didTapAnswerInfoButton), for: .touchUpInside) + return button + } + + @objc + private func didTapAnswerInfoButton() { + guard RemoteFeatureFlag.bloggingPromptsSocial.enabled(), + let promptID = prompt?.promptID else { + return + } + RootViewCoordinator.sharedPresenter.readerCoordinator?.showTag(named: "\(Constants.dailyPromptTag)-\(promptID)") + WPAnalytics.track(.promptsOtherAnswersTapped) + } + + private var answerInfoView: UIView { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = Constants.answerInfoViewSpacing + stackView.addArrangedSubviews([avatarTrainContainerView, answerInfoButton]) + + let containerView = UIView() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(stackView) + + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: stackView.topAnchor), + containerView.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), + containerView.centerXAnchor.constraint(equalTo: stackView.centerXAnchor), + containerView.leadingAnchor.constraint(lessThanOrEqualTo: stackView.leadingAnchor), + containerView.trailingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor) + ]) + + return containerView + } + + private lazy var attributionIcon = UIImageView() + + private lazy var attributionSourceLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + return label + }() + + private lazy var attributionStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [attributionIcon, attributionSourceLabel]) + stackView.alignment = .center + return stackView + }() + + // MARK: Bottom row views + + private lazy var answerButton: UIButton = { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(Strings.answerButtonTitle, for: .normal) + button.setTitleColor(WPStyleGuide.BloggingPrompts.buttonTitleColor, for: .normal) + button.titleLabel?.font = WPStyleGuide.BloggingPrompts.buttonTitleFont + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.titleLabel?.adjustsFontSizeToFitWidth = true + button.addTarget(self, action: #selector(answerButtonTapped), for: .touchUpInside) + + return button + }() + + private lazy var answeredLabel: UILabel = { + let label = UILabel() + label.font = WPStyleGuide.BloggingPrompts.buttonTitleFont + label.textColor = WPStyleGuide.BloggingPrompts.answeredLabelColor + label.text = Strings.answeredLabelTitle + + // The 'answered' label needs to be close to the Share button. + // swiftlint:disable:next inverse_text_alignment + label.textAlignment = (effectiveUserInterfaceLayoutDirection == .leftToRight ? .right : .left) + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + + return label + }() + + private lazy var shareButton: UIButton = { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(Strings.shareButtonTitle, for: .normal) + button.setTitleColor(WPStyleGuide.BloggingPrompts.buttonTitleColor, for: .normal) + button.titleLabel?.font = WPStyleGuide.BloggingPrompts.buttonTitleFont + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.titleLabel?.adjustsFontSizeToFitWidth = true + button.contentHorizontalAlignment = .leading + + // TODO: Implement button tap action + + return button + }() + + private lazy var answeredStateView: UIView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = Constants.answeredButtonsSpacing + stackView.addArrangedSubviews(sharePromptEnabled ? [answeredLabel, shareButton] : [answeredLabel]) + + // center the stack view's contents based on its total intrinsic width (instead of having it stretched edge to edge). + let containerView = UIView() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(stackView) + + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: stackView.topAnchor), + containerView.bottomAnchor.constraint(equalTo: stackView.bottomAnchor), + containerView.centerXAnchor.constraint(equalTo: stackView.centerXAnchor), + containerView.leadingAnchor.constraint(lessThanOrEqualTo: stackView.leadingAnchor), + containerView.trailingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor) + ]) + + return containerView + }() + + // Defines the structure of the contextual menu items. + private var contextMenuItems: [[MenuItem]] { + let defaultItems: [MenuItem] = [ + .viewMore(viewMoreMenuTapped), + .skip(skipMenuTapped) + ] + + if FeatureFlag.bloggingPromptsEnhancements.enabled { + return [defaultItems, [.learnMore(learnMoreTapped)], [.remove(removeMenuTapped)]] + } + + return [defaultItems, [.learnMore(learnMoreTapped)]] + } + + @available(iOS 15.0, *) + private var contextMenu: UIMenu { + return .init(title: String(), options: .displayInline, children: contextMenuItems.map { menuSection in + UIMenu(title: String(), options: .displayInline, children: [ + // Use an uncached deferred element so we can track each time the menu is shown + UIDeferredMenuElement.uncached { completion in + WPAnalytics.track(.promptsDashboardCardMenu) + completion(menuSection.map { $0.toAction }) + } + ]) + }) + } + + // MARK: Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + observeManagedObjectsChange() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + // refresh when the appearance style changed so the placeholder images are correctly recolored. + if let previousTraitCollection = previousTraitCollection, + previousTraitCollection.userInterfaceStyle != traitCollection.userInterfaceStyle { + refreshStackView() + } + } + + // MARK: - Public Methods + + func configureForExampleDisplay() { + forExampleDisplay = true + } + + // Class method to determine if the Dashboard should show this card. + // Specifically, it checks if today's prompt has been skipped, + // and therefore should not be shown. + static func shouldShowCard(for blog: Blog) -> Bool { + guard FeatureFlag.bloggingPrompts.enabled, + blog.isAccessibleThroughWPCom(), + let promptsService = BloggingPromptsService(blog: blog) else { + return false + } + + guard let todaysPrompt = promptsService.localTodaysPrompt else { + // If there is no cached prompt, it can't have been skipped. So show the card. + return true + } + + return !userSkippedPrompt(todaysPrompt, for: blog) + } + +} + +// MARK: - BlogDashboardCardConfigurable + +extension DashboardPromptsCardCell: BlogDashboardCardConfigurable { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + self.blog = blog + self.presenterViewController = viewController + fetchPrompt() + } +} + +// MARK: - Private Helpers + +private extension DashboardPromptsCardCell { + + // MARK: Configure View + + func setupViews() { + contentView.addSubview(cardFrameView) + contentView.pinSubviewToAllEdges(cardFrameView, priority: Constants.cardFrameConstraintPriority) + cardFrameView.add(subview: containerStackView) + } + + func refreshStackView() { + // clear existing views. + containerStackView.removeAllSubviews() + + guard !didFailLoadingPrompt else { + promptLabel.text = Strings.errorTitle + containerStackView.addArrangedSubview(promptTitleView) + return + } + + promptLabel.text = forExampleDisplay ? Strings.examplePrompt : prompt?.textForDisplay() + containerStackView.addArrangedSubview(promptTitleView) + + if let attribution = prompt?.promptAttribution { + attributionIcon.image = attribution.iconImage + attributionSourceLabel.attributedText = attribution.attributedText + containerStackView.addArrangedSubview(attributionStackView) + } + + if answerCount > 0 { + containerStackView.addArrangedSubview(answerInfoView) + } + + containerStackView.addArrangedSubview((isAnswered ? answeredStateView : answerButton)) + presenterViewController?.collectionView.collectionViewLayout.invalidateLayout() + } + + // MARK: - Managed object observer + + func observeManagedObjectsChange() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleObjectsChange), + name: .NSManagedObjectContextObjectsDidChange, + object: ContextManager.shared.mainContext + ) + } + + @objc func handleObjectsChange(_ notification: Foundation.Notification) { + guard let prompt = prompt else { + return + } + let updated = notification.userInfo?[NSUpdatedObjectsKey] as? Set<NSManagedObject> ?? Set() + let refreshed = notification.userInfo?[NSRefreshedObjectsKey] as? Set<NSManagedObject> ?? Set() + + if updated.contains(prompt) || refreshed.contains(prompt) { + refreshStackView() + } + } + + // MARK: Prompt Fetching + + func fetchPrompt() { + guard let bloggingPromptsService = bloggingPromptsService else { + didFailLoadingPrompt = true + DDLogError("Failed creating BloggingPromptsService instance.") + return + } + + bloggingPromptsService.todaysPrompt(success: { [weak self] (prompt) in + self?.prompt = prompt + self?.didFailLoadingPrompt = false + }, failure: { [weak self] (error) in + self?.prompt = nil + self?.didFailLoadingPrompt = true + DDLogError("Failed fetching blogging prompt: \(String(describing: error))") + }) + } + + // MARK: Button actions + + @objc func answerButtonTapped() { + guard let blog = blog, + let prompt = prompt else { + return + } + WPAnalytics.track(.promptsDashboardCardAnswerPrompt) + + let editor = EditPostViewController(blog: blog, prompt: prompt) + editor.modalPresentationStyle = .fullScreen + editor.entryPoint = .bloggingPromptsDashboardCard + presenterViewController?.present(editor, animated: true) + } + + // MARK: Context menu actions + + func viewMoreMenuTapped() { + guard let blog = blog, + let presenterViewController = presenterViewController else { + DDLogError("Failed showing Blogging Prompts from Dashboard card. Missing blog or controller.") + return + } + + WPAnalytics.track(.promptsDashboardCardMenuViewMore) + BloggingPromptsViewController.show(for: blog, from: presenterViewController) + } + + func skipMenuTapped() { + WPAnalytics.track(.promptsDashboardCardMenuSkip) + saveSkippedPromptForSite() + presenterViewController?.reloadCardsLocally() + let notice = Notice(title: Strings.promptSkippedTitle, feedbackType: .success, actionTitle: Strings.undoSkipTitle) { [weak self] _ in + self?.clearSkippedPromptForSite() + self?.presenterViewController?.reloadCardsLocally() + } + ActionDispatcher.dispatch(NoticeAction.post(notice)) + } + + func removeMenuTapped() { + guard let siteID = blog?.dotComID?.intValue else { + return + } + WPAnalytics.track(.promptsDashboardCardMenuRemove) + BlogDashboardAnalytics.trackHideTapped(for: .prompts) + let service = BlogDashboardPersonalizationService(siteID: siteID) + service.setEnabled(false, for: .prompts) + let notice = Notice(title: Strings.promptRemovedTitle, message: Strings.promptRemovedSubtitle, feedbackType: .success, actionTitle: Strings.undoSkipTitle) { _ in + service.setEnabled(true, for: .prompts) + } + ActionDispatcher.dispatch(NoticeAction.post(notice)) + } + + func learnMoreTapped() { + WPAnalytics.track(.promptsDashboardCardMenuLearnMore) + guard let presenterViewController = presenterViewController else { + return + } + BloggingPromptsIntroductionPresenter(interactionType: .actionable(blog: blog)).present(from: presenterViewController) + } + + // Fallback context menu implementation for iOS 13. + func showMenuSheet() { + guard let presenterViewController = presenterViewController else { + return + } + WPAnalytics.track(.promptsDashboardCardMenu) + + let menuViewController = MenuSheetViewController(items: contextMenuItems.map { menuSection in + menuSection.map { $0.toMenuSheetItem } + }) + + menuViewController.modalPresentationStyle = .popover + if let popoverPresentationController = menuViewController.popoverPresentationController { + popoverPresentationController.delegate = presenterViewController + popoverPresentationController.sourceView = cardFrameView.ellipsisButton + popoverPresentationController.sourceRect = cardFrameView.ellipsisButton.bounds + } + + presenterViewController.present(menuViewController, animated: true) + } + + // MARK: Constants + + struct Strings { + static let examplePrompt = NSLocalizedString("Cast the movie of your life.", comment: "Example prompt for the Prompts card in Feature Introduction.") + static let cardFrameTitle = NSLocalizedString("Prompts", comment: "Title label for the Prompts card in My Sites tab.") + static let answerButtonTitle = NSLocalizedString("Answer Prompt", comment: "Title for a call-to-action button on the prompts card.") + static let answeredLabelTitle = NSLocalizedString("✓ Answered", comment: "Title label that indicates the prompt has been answered.") + static let shareButtonTitle = NSLocalizedString("Share", comment: "Title for a button that allows the user to share their answer to the prompt.") + static let answerInfoSingularFormat = NSLocalizedString("%1$d answer", comment: "Singular format string for displaying the number of users " + + "that answered the blogging prompt.") + static let answerInfoPluralFormat = NSLocalizedString("%1$d answers", comment: "Plural format string for displaying the number of users " + + "that answered the blogging prompt.") + static let viewAllResponses = NSLocalizedString("prompts.card.viewprompts.title", + value: "View all responses", + comment: "Title for a tappable string that opens the reader with a prompts tag") + static let errorTitle = NSLocalizedString("Error loading prompt", comment: "Text displayed when there is a failure loading a blogging prompt.") + static let promptSkippedTitle = NSLocalizedString("Prompt skipped", comment: "Title of the notification presented when a prompt is skipped") + static let undoSkipTitle = NSLocalizedString("Undo", comment: "Button in the notification presented when a prompt is skipped") + static let promptRemovedTitle = NSLocalizedString("prompts.notification.removed.title", + value: "Blogging Prompts hidden", + comment: "Title of the notification when prompts are hidden from the dashboard card") + static let promptRemovedSubtitle = NSLocalizedString("prompts.notification.removed.subtitle", + value: "Visit Site Settings to turn back on", + comment: "Subtitle of the notification when prompts are hidden from the dashboard card") + } + + struct Style { + static let frameIconImage = UIImage(named: "icon-lightbulb-outline")?.resizedImage(Constants.cardIconSize, interpolationQuality: .default) + static var avatarPlaceholderImage: UIImage { + // this needs to be computed so the color is correct depending on the user interface style. + return UIImage(color: .init(light: .quaternarySystemFill, dark: .systemGray4)) + } + } + + struct Constants { + static let spacing: CGFloat = 12 + static let answeredButtonsSpacing: CGFloat = 16 + static let answerInfoViewSpacing: CGFloat = 6 + static let containerMargins = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + static let maxAvatarCount = 3 + static let exampleAnswerCount = 19 + static let cardIconSize = CGSize(width: 18, height: 18) + static let cardFrameConstraintPriority = UILayoutPriority(999) + static let skippedPromptsUDKey = "wp_skipped_blogging_prompts" + static let dailyPromptTag = "dailyprompt" + } + + // MARK: Contextual Menu + + enum MenuItem { + case viewMore(_ handler: () -> Void) + case skip(_ handler: () -> Void) + case remove(_ handler: () -> Void) + case learnMore(_ handler: () -> Void) + + var title: String { + switch self { + case .viewMore: + return NSLocalizedString("View more prompts", comment: "Menu title to show more prompts.") + case .skip: + return NSLocalizedString("Skip for today", comment: "Menu title to skip today's prompt.") + case .remove: + return NSLocalizedString("Turn off prompts", comment: "Destructive menu title to remove the prompt card from the dashboard.") + case .learnMore: + return NSLocalizedString("Learn more", comment: "Menu title to show the prompts feature introduction modal.") + } + } + + var image: UIImage? { + switch self { + case .viewMore: + return .init(systemName: "ellipsis.circle") + case .skip: + return .init(systemName: "xmark.circle") + case .remove: + return .init(systemName: "minus.circle") + case .learnMore: + return .init(systemName: "info.circle") + } + } + + var menuAttributes: UIMenuElement.Attributes { + switch self { + case .remove: + return .destructive + default: + return [] + } + } + + var toAction: UIAction { + switch self { + case .viewMore(let handler), + .skip(let handler), + .remove(let handler), + .learnMore(let handler): + return UIAction(title: title, image: image, attributes: menuAttributes) { _ in + handler() + } + } + } + + var toMenuSheetItem: MenuSheetViewController.MenuItem { + switch self { + case .viewMore(let handler), + .skip(let handler), + .remove(let handler), + .learnMore(let handler): + return MenuSheetViewController.MenuItem( + title: title, + image: image, + destructive: menuAttributes.contains(.destructive), + handler: handler + ) + } + } + } +} + +// MARK: - User Defaults + +private extension DashboardPromptsCardCell { + + static var allSkippedPrompts: [[String: Int32]] { + return UserPersistentStoreFactory.instance().array(forKey: Constants.skippedPromptsUDKey) as? [[String: Int32]] ?? [] + } + + func saveSkippedPromptForSite() { + guard let prompt = prompt, + let siteID = blog?.dotComID?.stringValue else { + return + } + + clearSkippedPromptForSite() + + let skippedPrompt = [siteID: prompt.promptID] + var updatedSkippedPrompts = DashboardPromptsCardCell.allSkippedPrompts + updatedSkippedPrompts.append(skippedPrompt) + + UserPersistentStoreFactory.instance().set(updatedSkippedPrompts, forKey: Constants.skippedPromptsUDKey) + } + + func clearSkippedPromptForSite() { + guard let siteID = blog?.dotComID?.stringValue else { + return + } + + let updatedSkippedPrompts = DashboardPromptsCardCell.allSkippedPrompts.filter { $0.keys.first != siteID } + UserPersistentStoreFactory.instance().set(updatedSkippedPrompts, forKey: Constants.skippedPromptsUDKey) + } + + static func userSkippedPrompt(_ prompt: BloggingPrompt, for blog: Blog) -> Bool { + guard let siteID = blog.dotComID?.stringValue else { + return false + } + + let siteSkippedPrompts = allSkippedPrompts.filter { $0.keys.first == siteID } + let matchingPrompts = siteSkippedPrompts.filter { $0.values.first == prompt.promptID } + + return !matchingPrompts.isEmpty + } + +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift new file mode 100644 index 000000000000..f9a20639df35 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift @@ -0,0 +1,190 @@ +import UIKit +import WordPressShared + +final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable { + + private lazy var scrollView: ButtonScrollView = { + let scrollView = ButtonScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.alwaysBounceHorizontal = false + return scrollView + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + statsButton, + postsButton, + pagesButton, + mediaButton + ]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = Constants.stackViewSpacing + return stackView + }() + + private lazy var statsButton: QuickActionButton = { + let button = QuickActionButton(title: Strings.stats, image: .gridicon(.statsAlt)) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private lazy var postsButton: QuickActionButton = { + let button = QuickActionButton(title: Strings.posts, image: .gridicon(.posts)) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private lazy var mediaButton: QuickActionButton = { + let button = QuickActionButton(title: Strings.media, image: .gridicon(.image)) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + private lazy var pagesButton: QuickActionButton = { + let button = QuickActionButton(title: Strings.pages, image: .gridicon(.pages)) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + startObservingQuickStart() + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + deinit { + stopObservingQuickStart() + } +} + +// MARK: - Button Actions + +extension DashboardQuickActionsCardCell { + + func configureQuickActionButtons(for blog: Blog, with sourceController: UIViewController) { + statsButton.onTap = { [weak self] in + self?.showStats(for: blog, from: sourceController) + } + + postsButton.onTap = { [weak self] in + self?.showPostList(for: blog, from: sourceController) + } + + mediaButton.onTap = { [weak self] in + self?.showMediaLibrary(for: blog, from: sourceController) + } + + pagesButton.onTap = { [weak self] in + self?.showPageList(for: blog, from: sourceController) + } + } + + private func showStats(for blog: Blog, from sourceController: UIViewController) { + trackQuickActionsEvent(.statsAccessed, blog: blog) + StatsViewController.show(for: blog, from: sourceController) + } + + private func showPostList(for blog: Blog, from sourceController: UIViewController) { + trackQuickActionsEvent(.openedPosts, blog: blog) + PostListViewController.showForBlog(blog, from: sourceController) + } + + private func showMediaLibrary(for blog: Blog, from sourceController: UIViewController) { + trackQuickActionsEvent(.openedMediaLibrary, blog: blog) + MediaLibraryViewController.showForBlog(blog, from: sourceController) + } + + private func showPageList(for blog: Blog, from sourceController: UIViewController) { + trackQuickActionsEvent(.openedPages, blog: blog) + PageListViewController.showForBlog(blog, from: sourceController) + } + + private func trackQuickActionsEvent(_ event: WPAnalyticsStat, blog: Blog) { + WPAppAnalytics.track(event, withProperties: [WPAppAnalyticsKeyTabSource: "dashboard", WPAppAnalyticsKeyTapSource: "quick_actions"], with: blog) + } +} + +extension DashboardQuickActionsCardCell { + + private func setup() { + contentView.addSubview(scrollView) + contentView.pinSubviewToAllEdges(scrollView, priority: Constants.constraintPriority) + scrollView.addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.heightAnchor.constraint(equalTo: contentView.heightAnchor), + stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: Constants.stackViewHorizontalPadding), + stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -Constants.stackViewHorizontalPadding), + stackView.topAnchor.constraint(equalTo: scrollView.topAnchor), + stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor) + ]) + + if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft { + scrollView.transform = CGAffineTransform(scaleX: -1, y: 1) + stackView.transform = CGAffineTransform(scaleX: -1, y: 1) + } + } + + private func startObservingQuickStart() { + NotificationCenter.default.addObserver(forName: .QuickStartTourElementChangedNotification, object: nil, queue: nil) { [weak self] notification in + + if let info = notification.userInfo, + let element = info[QuickStartTourGuide.notificationElementKey] as? QuickStartTourElement { + + switch element { + case .stats: + guard QuickStartTourGuide.shared.entryPointForCurrentTour == .blogDashboard else { + return + } + + self?.autoScrollToStatsButton() + case .mediaScreen: + guard QuickStartTourGuide.shared.entryPointForCurrentTour == .blogDashboard else { + return + } + + self?.autoScrollToMediaButton() + default: + break + } + self?.statsButton.shouldShowSpotlight = element == .stats + self?.mediaButton.shouldShowSpotlight = element == .mediaScreen + } + } + } + + private func stopObservingQuickStart() { + NotificationCenter.default.removeObserver(self) + } + + private func autoScrollToStatsButton() { + scrollView.scrollHorizontallyToView(statsButton, animated: true) + } + + private func autoScrollToMediaButton() { + scrollView.scrollHorizontallyToView(mediaButton, animated: true) + } +} + +extension DashboardQuickActionsCardCell { + + private enum Strings { + static let stats = NSLocalizedString("Stats", comment: "Noun. Title for stats button.") + static let posts = NSLocalizedString("Posts", comment: "Noun. Title for posts button.") + static let media = NSLocalizedString("Media", comment: "Noun. Title for media button.") + static let pages = NSLocalizedString("Pages", comment: "Noun. Title for pages button.") + } + + private enum Constants { + static let contentViewCornerRadius = 8.0 + static let stackViewSpacing = 16.0 + static let stackViewHorizontalPadding = 20.0 + static let constraintPriority = UILayoutPriority(999) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/QuickActionButton.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/QuickActionButton.swift new file mode 100644 index 000000000000..60f18e6b8ed2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/QuickActionButton.swift @@ -0,0 +1,99 @@ +import UIKit + +final class QuickActionButton: UIButton { + + var onTap: (() -> Void)? + + var shouldShowSpotlight: Bool = false { + didSet { + spotlightView.isHidden = !shouldShowSpotlight + } + } + + private lazy var spotlightView: QuickStartSpotlightView = { + let spotlightView = QuickStartSpotlightView() + spotlightView.translatesAutoresizingMaskIntoConstraints = false + spotlightView.isHidden = true + return spotlightView + }() + + convenience init(title: String, image: UIImage) { + self.init(frame: .zero) + setTitle(title, for: .normal) + setImage(image, for: .normal) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + private func setup() { + configureTitleLabel() + configureInsets() + configureSpotlightView() + + layer.cornerRadius = Metrics.cornerRadius + backgroundColor = .listForeground + tintColor = .listIcon + + addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + private func configureTitleLabel() { + titleLabel?.adjustsFontForContentSizeCategory = true + titleLabel?.font = AppStyleGuide.prominentFont(textStyle: .body, weight: .semibold) + setTitleColor(.text, for: .normal) + } + + private func configureInsets() { + contentEdgeInsets = Metrics.contentEdgeInsets + titleEdgeInsets = Metrics.titleEdgeInsets + } + + private func configureSpotlightView() { + addSubview(spotlightView) + bringSubviewToFront(spotlightView) + + NSLayoutConstraint.activate( + [ + spotlightView.centerYAnchor.constraint(equalTo: centerYAnchor), + spotlightView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Metrics.spotlightOffset) + ] + ) + } + + @objc private func buttonTapped() { + onTap?() + } +} + +extension QuickActionButton { + + private enum Metrics { + static let cornerRadius = 8.0 + static let titleHorizontalOffset = 12.0 + static let contentVerticalOffset = 12.0 + static let contentLeadingOffset = 16.0 + static let contentTrailingOffset = 24.0 + static let spotlightOffset = 8.0 + + static let contentEdgeInsets = UIEdgeInsets( + top: contentVerticalOffset, + left: contentLeadingOffset, + bottom: contentVerticalOffset, + right: contentTrailingOffset + titleHorizontalOffset + ).flippedForRightToLeft + + static let titleEdgeInsets = UIEdgeInsets( + top: 0, + left: titleHorizontalOffset, + bottom: 0, + right: -titleHorizontalOffset + ).flippedForRightToLeft + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/DashboardQuickStartCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/DashboardQuickStartCardCell.swift new file mode 100644 index 000000000000..9bd8c12aceb8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/DashboardQuickStartCardCell.swift @@ -0,0 +1,114 @@ +import UIKit + +final class DashboardQuickStartCardCell: UICollectionViewCell, Reusable, BlogDashboardCardConfigurable { + + private weak var viewController: BlogDashboardViewController? + private var blog: Blog? + + private lazy var cardFrameView: BlogDashboardCardFrameView = { + let frameView = BlogDashboardCardFrameView() + frameView.translatesAutoresizingMaskIntoConstraints = false + return frameView + }() + + private lazy var tourStateView: QuickStartTourStateView = { + let view = QuickStartTourStateView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + guard let viewController = viewController else { + return + } + self.viewController = viewController + self.blog = blog + + configureCardFrameView(for: blog) + + let checklistTappedTracker: QuickStartChecklistTappedTracker = (event: .dashboardCardItemTapped, properties: ["type": DashboardCard.quickStart.rawValue]) + + tourStateView.configure(blog: blog, sourceController: viewController, checklistTappedTracker: checklistTappedTracker) + + BlogDashboardAnalytics.shared.track(.dashboardCardShown, + properties: ["type": DashboardCard.quickStart.rawValue], + blog: blog) + } + + private func configureCardFrameView(for blog: Blog) { + switch blog.quickStartType { + + case .undefined: + fallthrough + + case .newSite: + configureOnEllipsisButtonTap(sourceRect: cardFrameView.ellipsisButton.frame) + cardFrameView.showHeader() + + case .existingSite: + cardFrameView.configureButtonContainerStackView() + configureOnEllipsisButtonTap(sourceRect: cardFrameView.buttonContainerStackView.frame) + cardFrameView.hideHeader() + + } + + cardFrameView.setTitle(Strings.title(for: blog.quickStartType)) + } + + private func configureOnEllipsisButtonTap(sourceRect: CGRect) { + cardFrameView.onEllipsisButtonTap = { [weak self] in + guard let self = self, + let viewController = self.viewController, + let blog = self.blog else { + return + } + viewController.removeQuickStart(from: blog, sourceView: self.cardFrameView, sourceRect: sourceRect) + } + } +} + +// MARK: - Setup + +extension DashboardQuickStartCardCell { + + private func setupViews() { + contentView.addSubview(cardFrameView) + contentView.pinSubviewToAllEdges(cardFrameView, priority: Metrics.constraintPriority) + + cardFrameView.add(subview: tourStateView) + } +} + +// MARK: - Constants + +extension DashboardQuickStartCardCell { + + private enum Strings { + static let nextSteps = NSLocalizedString("Next Steps", comment: "Title for the Quick Start dashboard card.") + + static func title(for quickStartType: QuickStartType) -> String? { + switch quickStartType { + case .undefined: + fallthrough + case .newSite: + return nextSteps + case .existingSite: + return nil + } + } + } + + private enum Metrics { + static let iconSize = CGSize(width: 18, height: 18) + static let constraintPriority = UILayoutPriority(999) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/NewQuickStartChecklistView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/NewQuickStartChecklistView.swift new file mode 100644 index 000000000000..0a340b22366b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/NewQuickStartChecklistView.swift @@ -0,0 +1,246 @@ +import UIKit + +// A view representing the progress on a Quick Start checklist. Built according to new design specs. +// +// This view is used to display a single Quick Start tour collection per Quick Start card. +// +// This view can be renamed to QuickStartChecklistView once we've fully migrated to using this new view. +// See QuickStartChecklistConfigurable for more details. +// +final class NewQuickStartChecklistView: UIView, QuickStartChecklistConfigurable { + + var tours: [QuickStartTour] = [] + var blog: Blog? + var onTap: (() -> Void)? + + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + verticalStackView, + imageView + ]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.alignment = .top + stackView.distribution = .equalSpacing + return stackView + }() + + private lazy var verticalStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + titleLabel, + progressStackView + ]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + return stackView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = AppStyleGuide.prominentFont(textStyle: .title2, weight: .semibold) + label.textColor = .text + label.numberOfLines = 0 + return label + }() + + private lazy var progressStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + subtitleStackView, + progressView + ]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = Metrics.progressStackViewSpacing + return stackView + }() + + private lazy var subtitleStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + subtitleLabel, + checkmarkIcon + ]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.alignment = .center + stackView.distribution = .fill + return stackView + }() + + private lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = WPStyleGuide.fontForTextStyle(.subheadline) + label.textColor = .textSubtle + return label + }() + + private lazy var checkmarkIcon: UIImageView = { + let imageView = UIImageView(image: .gridicon(.checkmark, size: Metrics.checkmarkIconSize)) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = Colors.allTasksComplete + return imageView + }() + + private lazy var progressView: UIProgressView = { + let progressView = UIProgressView(progressViewStyle: .bar) + progressView.translatesAutoresizingMaskIntoConstraints = false + progressView.progressTintColor = .primary + progressView.trackTintColor = Colors.progressViewTrackTint + progressView.layer.cornerRadius = Metrics.progressViewHeight / 2 + progressView.clipsToBounds = true + return progressView + }() + + private lazy var imageView: UIImageView = { + let imageView = UIImageView(image: AppStyleGuide.quickStartExistingSite) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + return imageView + }() + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + setupConstraints() + startObservingQuickStart() + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + deinit { + stopObservingQuickStart() + } + + // MARK: - Trait Collection + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + configureVerticalStackViewSpacing() + } + + private func configureVerticalStackViewSpacing() { + if UIDevice.current.orientation.isLandscape { + verticalStackView.spacing = Metrics.verticalStackViewSpacingLandscape + } else { + verticalStackView.spacing = Metrics.verticalStackViewSpacingPortrait + } + } + + // MARK: - Configure + + func configure(collection: QuickStartToursCollection, blog: Blog) { + self.tours = collection.tours + self.blog = blog + titleLabel.text = collection.title + + isAccessibilityElement = true + accessibilityTraits = .button + accessibilityHint = collection.hint + + updateViews() + } +} + +extension NewQuickStartChecklistView { + + private func setupViews() { + configureVerticalStackViewSpacing() + + addSubview(mainStackView) + pinSubviewToAllEdges(mainStackView, insets: Metrics.mainStackViewInsets) + + let tap = UITapGestureRecognizer(target: self, action: #selector(didTap)) + addGestureRecognizer(tap) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: Metrics.imageWidth), + imageView.heightAnchor.constraint(equalToConstant: Metrics.imageHeight), + progressView.heightAnchor.constraint(equalToConstant: Metrics.progressViewHeight), + verticalStackView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: Metrics.verticalStackViewWidthMultiplier), + ]) + } + + private func updateViews() { + guard let blog = blog, + let title = titleLabel.text else { + return + } + + let completedToursCount = QuickStartTourGuide.shared.countChecklistCompleted(in: tours, for: blog) + + var subtitle: String + + if completedToursCount == tours.count { + subtitle = Strings.allTasksComplete + progressView.progressTintColor = Colors.allTasksComplete + checkmarkIcon.isHidden = false + } else { + subtitle = String(format: Strings.subtitleFormat, completedToursCount, tours.count) + progressView.progressTintColor = .primary + checkmarkIcon.isHidden = true + } + + subtitleLabel.text = subtitle + + // VoiceOver: Adding a period after the title to create a pause between the title and the subtitle + accessibilityLabel = "\(title). \(subtitle)" + + let progress = Float(completedToursCount) / Float(tours.count) + progressView.setProgress(progress, animated: false) + } + + private func startObservingQuickStart() { + NotificationCenter.default.addObserver(forName: .QuickStartTourElementChangedNotification, object: nil, queue: nil) { [weak self] notification in + + guard let userInfo = notification.userInfo, + let element = userInfo[QuickStartTourGuide.notificationElementKey] as? QuickStartTourElement, + element == .tourCompleted else { + return + } + + self?.updateViews() + } + } + + private func stopObservingQuickStart() { + NotificationCenter.default.removeObserver(self) + } + + @objc private func didTap() { + onTap?() + } +} + +extension NewQuickStartChecklistView { + + private enum Metrics { + static let mainStackViewInsets = UIEdgeInsets(top: 16, left: 16, bottom: 8, right: 16).flippedForRightToLeft + static let verticalStackViewWidthMultiplier = 3.0 / 5.0 + static let verticalStackViewSpacingPortrait = 12.0 + static let verticalStackViewSpacingLandscape = 16.0 + static let progressStackViewSpacing = 8.0 + static let progressViewHeight = 5.0 + static let imageWidth = 56.0 + static let imageHeight = 100.0 + static let checkmarkIconSize = CGSize(width: 16.0, height: 16.0) + } + + private enum Colors { + static let allTasksComplete = UIColor(light: .muriel(color: .jetpackGreen, .shade40), dark: .muriel(color: .jetpackGreen, .shade50)) + static let progressViewTrackTint = UIColor(light: .listBackground, dark: .systemGray3) + } + + private enum Strings { + static let subtitleFormat = NSLocalizedString("%1$d of %2$d completed", + comment: "Format string for displaying the number of completed quickstart tutorials. %1$d is the number completed, %2$d is the total number of tutorials available.") + static let allTasksComplete = NSLocalizedString("All tasks complete!", comment: "Message shown when all Quick Start tasks are complete.") + + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/QuickStartChecklistConfigurable.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/QuickStartChecklistConfigurable.swift new file mode 100644 index 000000000000..d02ffca26158 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/QuickStartChecklistConfigurable.swift @@ -0,0 +1,14 @@ +import Foundation + +typealias QuickStartChecklistConfigurableView = UIView & QuickStartChecklistConfigurable + +// A protocol to ease the transition from QuickStartChecklistView to NewQuickStartChecklistView. +// This protocol can be deleted once we've fully migrated to NewQuickStartChecklistView. +// +protocol QuickStartChecklistConfigurable { + var tours: [QuickStartTour] { get } + var blog: Blog? { get } + var onTap: (() -> Void)? { get set } + + func configure(collection: QuickStartToursCollection, blog: Blog) +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/QuickStartChecklistView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/QuickStartChecklistView.swift new file mode 100644 index 000000000000..300c7477e2ec --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/QuickStartChecklistView.swift @@ -0,0 +1,173 @@ +import UIKit +import WordPressShared + +// A view representing the progress on a Quick Start checklist. Built according to old design specs. +// +// This view is used to display multiple Quick Start tour collections per Quick Start card. +// +// This view can be deleted once we've fully migrated to using NewQuicksTartChecklistView. +// See QuickStartChecklistConfigurable for more details. +// +final class QuickStartChecklistView: UIView, QuickStartChecklistConfigurable { + + var tours: [QuickStartTour] = [] + var blog: Blog? + var onTap: (() -> Void)? + + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + labelStackView, + progressIndicatorView + ]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.alignment = .center + stackView.distribution = .fill + stackView.spacing = Metrics.mainStackViewSpacing + return stackView + }() + + private lazy var labelStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + titleLabel, + subtitleLabel + ]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = Metrics.labelStackViewSpacing + return stackView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = AppStyleGuide.prominentFont(textStyle: .body, weight: .semibold) + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = Metrics.labelMinimumScaleFactor + label.textColor = .text + return label + }() + + private lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = WPStyleGuide.fontForTextStyle(.callout) + label.textColor = .textSubtle + return label + }() + + private lazy var progressIndicatorView: ProgressIndicatorView = { + let appearance = ProgressIndicatorView.Appearance( + size: Metrics.progressIndicatorViewSize, + lineColor: .primary, + trackColor: .separator + ) + let view = ProgressIndicatorView(appearance: appearance) + view.translatesAutoresizingMaskIntoConstraints = false + view.setContentHuggingPriority(.defaultHigh, for: .horizontal) + view.isAccessibilityElement = false + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + startObservingQuickStart() + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + deinit { + stopObservingQuickStart() + } + + func configure(collection: QuickStartToursCollection, blog: Blog) { + self.tours = collection.tours + self.blog = blog + titleLabel.text = collection.title + + isAccessibilityElement = true + accessibilityTraits = .button + accessibilityHint = collection.hint + + updateViews() + } +} + +extension QuickStartChecklistView { + + private func setupViews() { + addSubview(mainStackView) + pinSubviewToAllEdges(mainStackView, insets: Metrics.mainStackViewInsets) + + let tap = UITapGestureRecognizer(target: self, action: #selector(didTap)) + addGestureRecognizer(tap) + } + + private func updateViews() { + guard let blog = blog, + let title = titleLabel.text else { + return + } + + let completedToursCount = QuickStartTourGuide.shared.countChecklistCompleted(in: tours, for: blog) + + if completedToursCount == tours.count { + titleLabel.attributedText = NSAttributedString(string: title, attributes: [NSAttributedString.Key.strikethroughStyle: NSUnderlineStyle.single.rawValue]) + titleLabel.textColor = .textSubtle + } else { + titleLabel.attributedText = NSAttributedString(string: title, attributes: [:]) + titleLabel.textColor = .text + } + + let subtitle = String(format: Strings.subtitleFormat, completedToursCount, tours.count) + subtitleLabel.text = subtitle + + // VoiceOver: Adding a period after the title to create a pause between the title and the subtitle + accessibilityLabel = "\(title). \(subtitle)" + + let progress = Double(completedToursCount) / Double(tours.count) + progressIndicatorView.updateProgressLayer(with: progress) + } + + private func startObservingQuickStart() { + NotificationCenter.default.addObserver(forName: .QuickStartTourElementChangedNotification, object: nil, queue: nil) { [weak self] notification in + + guard let userInfo = notification.userInfo, + let element = userInfo[QuickStartTourGuide.notificationElementKey] as? QuickStartTourElement, + element == .tourCompleted else { + return + } + + self?.updateViews() + } + } + + private func stopObservingQuickStart() { + NotificationCenter.default.removeObserver(self) + } + + @objc private func didTap() { + onTap?() + } +} + +extension QuickStartChecklistView { + + private enum Metrics { + static let mainStackViewInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + static let mainStackViewSpacing = 16.0 + static let labelStackViewSpacing = 4.0 + static let progressIndicatorViewSize = 24.0 + static let labelMinimumScaleFactor = 0.5 + } + + private enum Strings { + static let subtitleFormat = NSLocalizedString("%1$d of %2$d completed", + comment: "Format string for displaying number of completed quickstart tutorials. %1$d is number completed, %2$d is total number of tutorials available.") + + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/QuickStartTourStateView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/QuickStartTourStateView.swift new file mode 100644 index 000000000000..82ccf37ac885 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Start/QuickStartTourStateView.swift @@ -0,0 +1,67 @@ +import UIKit + +typealias QuickStartChecklistTappedTracker = (event: WPAnalyticsEvent, properties: [AnyHashable: Any]) + +final class QuickStartTourStateView: UIView { + + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + return stackView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(blog: Blog, sourceController: UIViewController, checklistTappedTracker: QuickStartChecklistTappedTracker? = nil) { + stackView.removeAllSubviews() + let availableCollections = QuickStartFactory.collections(for: blog) + for collection in availableCollections { + var checklistView = collection.checklistViewType.init() + checklistView.translatesAutoresizingMaskIntoConstraints = false + checklistView.configure(collection: collection, blog: blog) + checklistView.onTap = { [weak self] in + self?.showQuickStart(with: collection, from: sourceController, for: blog, tracker: checklistTappedTracker) + } + stackView.addArrangedSubview(checklistView) + } + } + +} + +// MARK: - Setup + +extension QuickStartTourStateView { + + private func setupViews() { + addSubview(stackView) + pinSubviewToAllEdges(stackView) + } +} + +// MARK: - Actions + +extension QuickStartTourStateView { + + private func showQuickStart(with collection: QuickStartToursCollection, from sourceController: UIViewController, for blog: Blog, tracker: QuickStartChecklistTappedTracker? = nil) { + + if let tracker = tracker { + WPAnalytics.trackQuickStartEvent(tracker.event, + properties: tracker.properties, + blog: blog) + } + + let checklist = QuickStartChecklistViewController(blog: blog, collection: collection) + let navigationViewController = UINavigationController(rootViewController: checklist) + sourceController.present(navigationViewController, animated: true) + + QuickStartTourGuide.shared.visited(.checklist) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardSingleStatView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardSingleStatView.swift new file mode 100644 index 000000000000..54f07c579c24 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardSingleStatView.swift @@ -0,0 +1,79 @@ +import UIKit + +final class DashboardSingleStatView: UIView { + + // MARK: Public Variables + + var countString: String? { + didSet { + self.numberLabel.text = countString + } + } + + // MARK: Private Variables + + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + titleLabel, + numberLabel + ]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .leading + stackView.distribution = .fill + stackView.spacing = Constants.mainStackViewSpacing + return stackView + }() + + private lazy var numberLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = AppStyleGuide.prominentFont(textStyle: .title1, weight: .bold) + label.textColor = .text + label.isAccessibilityElement = false + return label + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = WPStyleGuide.fontForTextStyle(.subheadline) + label.textColor = .textSubtle + label.isAccessibilityElement = false + return label + }() + + // MARK: Initializers + + convenience init(title: String) { + self.init() + self.numberLabel.text = Constants.emptyString + self.titleLabel.text = title + } + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + // MARK: Private Helpers + + private func setupViews() { + addSubview(mainStackView) + pinSubviewToAllEdges(mainStackView) + } +} + +// MARK: Constants + +private extension DashboardSingleStatView { + + enum Constants { + static let mainStackViewSpacing = 2.0 + static let emptyString = "0" + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardStatsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardStatsCardCell.swift new file mode 100644 index 000000000000..3ce37e690174 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardStatsCardCell.swift @@ -0,0 +1,153 @@ +import UIKit +import WordPressShared + +class DashboardStatsCardCell: UICollectionViewCell, Reusable { + + // MARK: Private Variables + + private var viewModel: DashboardStatsViewModel? + private let frameView = BlogDashboardCardFrameView() + private var nudgeView: DashboardStatsNudgeView? + private var statsStackView: DashboardStatsStackView? + + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Constants.spacing + return stackView + }() + + // MARK: Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + // MARK: Helpers + + private func commonInit() { + contentView.addSubview(stackView) + contentView.pinSubviewToAllEdges(stackView, priority: Constants.constraintPriority) + addSubviews() + } + + private func addSubviews() { + frameView.setTitle(Strings.statsTitle, titleHint: Strings.statsTitleHint) + + let statsStackview = DashboardStatsStackView() + frameView.add(subview: statsStackview) + self.statsStackView = statsStackview + + let nudgeView = createNudgeView() + frameView.add(subview: nudgeView) + self.nudgeView = nudgeView + + stackView.addArrangedSubview(frameView) + } + + private func createNudgeView() -> DashboardStatsNudgeView { + DashboardStatsNudgeView(title: Strings.nudgeButtonTitle, hint: Strings.nudgeButtonHint) + } +} + +// MARK: BlogDashboardCardConfigurable + +extension DashboardStatsCardCell: BlogDashboardCardConfigurable { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + guard let viewController = viewController, let apiResponse = apiResponse else { + return + } + + self.viewModel = DashboardStatsViewModel(apiResponse: apiResponse) + configureCard(for: blog, in: viewController) + } + + private func configureCard(for blog: Blog, in viewController: UIViewController) { + frameView.onViewTap = { [weak self] in + self?.showStats(for: blog, from: viewController) + } + + if FeatureFlag.personalizeHomeTab.enabled { + frameView.onEllipsisButtonTap = { + BlogDashboardAnalytics.trackContextualMenuAccessed(for: .todaysStats) + } + frameView.ellipsisButton.showsMenuAsPrimaryAction = true + frameView.ellipsisButton.menu = UIMenu(title: "", options: .displayInline, children: [ + BlogDashboardHelpers.makeHideCardAction(for: .todaysStats, siteID: blog.dotComID?.intValue ?? 0) + ]) + } + + statsStackView?.views = viewModel?.todaysViews + statsStackView?.visitors = viewModel?.todaysVisitors + statsStackView?.likes = viewModel?.todaysLikes + + nudgeView?.onTap = { [weak self] in + self?.showNudgeHint(for: blog, from: viewController) + } + + nudgeView?.isHidden = !(viewModel?.shouldDisplayNudge ?? false) + + BlogDashboardAnalytics.shared.track(.dashboardCardShown, + properties: ["type": DashboardCard.todaysStats.rawValue], + blog: blog) + } + + private func showStats(for blog: Blog, from sourceController: UIViewController) { + WPAnalytics.track(.dashboardCardItemTapped, + properties: ["type": DashboardCard.todaysStats.rawValue], + blog: blog) + StatsViewController.show(for: blog, from: sourceController) + WPAppAnalytics.track(.statsAccessed, withProperties: [WPAppAnalyticsKeyTabSource: "dashboard", WPAppAnalyticsKeyTapSource: "todays_stats_card"], with: blog) + } + + private func showNudgeHint(for blog: Blog, from sourceController: UIViewController) { + guard let url = URL(string: Constants.nudgeURLString) else { + return + } + + WPAnalytics.track(.dashboardCardItemTapped, + properties: [ + "type": DashboardCard.todaysStats.rawValue, + "sub_type": "nudge" + ], + blog: blog) + + let webViewController = WebViewControllerFactory.controller(url: url, source: "dashboard_stats_card") + let navController = UINavigationController(rootViewController: webViewController) + sourceController.present(navController, animated: true) + } +} + +extension DashboardStatsCardCell { + static func shouldShowCard(for blog: Blog) -> Bool { + return blog.supports(.stats) + } +} + +// MARK: Constants + +private extension DashboardStatsCardCell { + + enum Strings { + static let statsTitle = NSLocalizedString("my-sites.stats.card.title", value: "Today's Stats", comment: "Title for the card displaying today's stats.") + static let statsTitleHint = NSLocalizedString("my-sites.stats.card.title.hint", value: "Stats", comment: "The part of the title that needs to be emphasized") + static let nudgeButtonTitle = NSLocalizedString("Interested in building your audience? Check out our top tips", comment: "Title for a button that opens up the 'Getting More Views and Traffic' support page when tapped.") + static let nudgeButtonHint = NSLocalizedString("top tips", comment: "The part of the nudge title that should be emphasized, this content needs to match a string in 'If you want to try get more...'") + } + + enum Constants { + static let spacing: CGFloat = 20 + static let iconSize = CGSize(width: 18, height: 18) + + static let constraintPriority = UILayoutPriority(999) + + static let nudgeURLString = "https://wordpress.com/support/getting-more-views-and-traffic/" + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardStatsNudgeView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardStatsNudgeView.swift new file mode 100644 index 000000000000..a9e40200d3f4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardStatsNudgeView.swift @@ -0,0 +1,79 @@ +import UIKit + +final class DashboardStatsNudgeView: UIView { + + var onTap: (() -> Void)? { + didSet { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(buttonTapped)) + addGestureRecognizer(tapGesture) + } + } + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = WPStyleGuide.fontForTextStyle(.subheadline) + label.textColor = .textSubtle + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + return label + }() + + // MARK: - Init + + convenience init(title: String, hint: String?, insets: UIEdgeInsets = Constants.margins) { + self.init(frame: .zero) + + setup(insets: insets) + setTitle(title: title, hint: hint) + } + + // MARK: - View setup + + private func setup(insets: UIEdgeInsets) { + addSubview(titleLabel) + pinSubviewToAllEdges(titleLabel, insets: insets) + + prepareForVoiceOver() + } + + @objc private func buttonTapped() { + onTap?() + } + + private func setTitle(title: String, hint: String?) { + let externalAttachment = NSTextAttachment(image: UIImage.gridicon(.external, size: Constants.iconSize).withTintColor(.primary)) + externalAttachment.bounds = Constants.iconBounds + + let attachmentString = NSAttributedString(attachment: externalAttachment) + + let titleString = NSMutableAttributedString(string: "\(title) \u{FEFF}") + if let hint = hint, + let subStringRange = title.nsRange(of: hint) { + titleString.addAttributes([ + .foregroundColor: UIColor.primary, + .font: WPStyleGuide.fontForTextStyle(.subheadline).bold() + ], range: subStringRange) + } + + titleString.append(attachmentString) + + titleLabel.attributedText = titleString + } + + private enum Constants { + static let iconSize = CGSize(width: 16, height: 16) + static let iconBounds = CGRect(x: 0, y: -2, width: 16, height: 16) + static let margins = UIEdgeInsets(top: 0, left: 16, bottom: 8, right: 16) + } + +} + +extension DashboardStatsNudgeView: Accessible { + + func prepareForVoiceOver() { + isAccessibilityElement = false + titleLabel.isAccessibilityElement = true + titleLabel.accessibilityTraits = .button + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardStatsStackView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardStatsStackView.swift new file mode 100644 index 000000000000..2a6b40b29687 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Stats/DashboardStatsStackView.swift @@ -0,0 +1,101 @@ +import UIKit + +final class DashboardStatsStackView: UIStackView { + + // MARK: Public Variables + + var views: String? { + didSet { + viewsView?.countString = views + updateAccessibility() + } + } + + var visitors: String? { + didSet { + visitorsView?.countString = visitors + updateAccessibility() + } + } + + var likes: String? { + didSet { + likesView?.countString = likes + updateAccessibility() + } + } + + // MARK: Private Properties + + var viewsView: DashboardSingleStatView? + var visitorsView: DashboardSingleStatView? + var likesView: DashboardSingleStatView? + + // MARK: Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + // MARK: Helpers + + private func commonInit() { + setupStackView() + setupSubviews() + } + + private func setupStackView() { + axis = .horizontal + translatesAutoresizingMaskIntoConstraints = false + distribution = .fillEqually + isLayoutMarginsRelativeArrangement = true + directionalLayoutMargins = Constants.statsStackViewMargins + isAccessibilityElement = true + accessibilityTraits = .button + } + + private func setupSubviews() { + let viewsView = DashboardSingleStatView(title: Strings.viewsTitle) + let visitorsView = DashboardSingleStatView(title: Strings.visitorsTitle) + let likesView = DashboardSingleStatView(title: Strings.likesTitle) + self.viewsView = viewsView + self.visitorsView = visitorsView + self.likesView = likesView + addArrangedSubviews([viewsView, visitorsView, likesView]) + } + + private func updateAccessibility() { + guard let views = views, + let visitors = visitors, + let likes = likes else { + self.accessibilityLabel = Strings.errorTitle + return + } + let arguments = [views.accessibilityLabel ?? views, + visitors.accessibilityLabel ?? visitors, + likes.accessibilityLabel ?? likes] + self.accessibilityLabel = String(format: Strings.accessibilityLabelFormat, arguments: arguments) + } +} + +// MARK: Constants + +extension DashboardStatsStackView { + enum Strings { + static let viewsTitle = NSLocalizedString("Views", comment: "Today's Stats 'Views' label") + static let visitorsTitle = NSLocalizedString("Visitors", comment: "Today's Stats 'Visitors' label") + static let likesTitle = NSLocalizedString("Likes", comment: "Today's Stats 'Likes' label") + static let accessibilityLabelFormat = "\(viewsTitle) %@, \(visitorsTitle) %@, \(likesTitle) %@." + static let errorTitle = NSLocalizedString("Stats not loaded", comment: "The loading view title displayed when an error occurred") + } + + enum Constants { + static let statsStackViewMargins = NSDirectionalEdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/DashboardCard.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/DashboardCard.swift new file mode 100644 index 000000000000..ddcf12cec1f9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/DashboardCard.swift @@ -0,0 +1,202 @@ +import Foundation + +/// Describes all the available cards. +/// +/// Notice that the order here matters and it will take +/// precedence over the backend. +/// +/// Remote cards should be separately added to RemoteDashboardCard +enum DashboardCard: String, CaseIterable { + case jetpackInstall + case quickStart + case prompts + case blaze + case domainsDashboardCard + case todaysStats = "todays_stats" + case draftPosts + case scheduledPosts + case pages + case activityLog = "activity_log" + case nextPost = "create_next" + case createPost = "create_first" + case jetpackBadge + /// Card placeholder for when loading data + case ghost + case failure + /// Empty state when no cards are present + case empty + /// A "Personalize Home Tab" button + case personalize + + var cell: DashboardCollectionViewCell.Type { + switch self { + case .jetpackInstall: + return DashboardJetpackInstallCardCell.self + case .quickStart: + return DashboardQuickStartCardCell.self + case .draftPosts: + return DashboardDraftPostsCardCell.self + case .scheduledPosts: + return DashboardScheduledPostsCardCell.self + case .nextPost: + return DashboardNextPostCardCell.self + case .createPost: + return DashboardFirstPostCardCell.self + case .todaysStats: + return DashboardStatsCardCell.self + case .prompts: + return DashboardPromptsCardCell.self + case .ghost: + return DashboardGhostCardCell.self + case .failure: + return DashboardFailureCardCell.self + case .jetpackBadge: + return DashboardBadgeCell.self + case .blaze: + return DashboardBlazeCardCell.self + case .domainsDashboardCard: + return DashboardDomainsCardCell.self + case .empty: + return BlogDashboardEmptyStateCell.self + case .personalize: + return BlogDashboardPersonalizeCardCell.self + case .pages: + return DashboardPagesListCardCell.self + case .activityLog: + return DashboardActivityLogCardCell.self + } + } + + var viewedAnalytic: WPAnalyticsEvent? { + switch self { + case .jetpackInstall: + return .jetpackInstallFullPluginCardViewed + case .prompts: + return .promptsDashboardCardViewed + default: + return nil + } + } + + func shouldShow(for blog: Blog, apiResponse: BlogDashboardRemoteEntity? = nil, mySiteSettings: DefaultSectionProvider = MySiteSettings()) -> Bool { + switch self { + case .jetpackInstall: + return JetpackInstallPluginHelper.shouldShowCard(for: blog) + case .quickStart: + return QuickStartTourGuide.quickStartEnabled(for: blog) && mySiteSettings.defaultSection == .dashboard + case .draftPosts, .scheduledPosts: + return shouldShowRemoteCard(apiResponse: apiResponse) + case .todaysStats: + return DashboardStatsCardCell.shouldShowCard(for: blog) && shouldShowRemoteCard(apiResponse: apiResponse) + case .nextPost, .createPost: + return !DashboardPromptsCardCell.shouldShowCard(for: blog) && shouldShowRemoteCard(apiResponse: apiResponse) + case .prompts: + return DashboardPromptsCardCell.shouldShowCard(for: blog) + case .ghost: + return blog.dashboardState.isFirstLoad + case .failure: + return blog.dashboardState.isFirstLoadFailure + case .jetpackBadge: + return JetpackBrandingVisibility.all.enabled + case .blaze: + return BlazeHelper.shouldShowCard(for: blog) + case .domainsDashboardCard: + return DomainsDashboardCardHelper.shouldShowCard(for: blog) + case .empty: + return false // Controlled manually based on other cards visibility + case .personalize: + return FeatureFlag.personalizeHomeTab.enabled + case .pages: + return DashboardPagesListCardCell.shouldShowCard(for: blog) && shouldShowRemoteCard(apiResponse: apiResponse) + case .activityLog: + return DashboardActivityLogCardCell.shouldShowCard(for: blog) && shouldShowRemoteCard(apiResponse: apiResponse) + } + } + + private func shouldShowRemoteCard(apiResponse: BlogDashboardRemoteEntity?) -> Bool { + guard let apiResponse = apiResponse else { + return false + } + switch self { + case .draftPosts: + return apiResponse.hasDrafts + case .scheduledPosts: + return apiResponse.hasScheduled + case .nextPost: + return apiResponse.hasNoDraftsOrScheduled && apiResponse.hasPublished + case .createPost: + return apiResponse.hasNoDraftsOrScheduled && !apiResponse.hasPublished + case .todaysStats: + return apiResponse.hasStats + case .pages: + return apiResponse.hasPages + case .activityLog: + return apiResponse.hasActivities + default: + return false + } + } + + /// A list of cards that can be shown/hidden on a "Personalize Home Tab" screen. + static let personalizableCards: [DashboardCard] = [ + .todaysStats, + .draftPosts, + .scheduledPosts, + .blaze, + .prompts, + .pages, + .activityLog + ] + + /// Includes all cards that should be fetched from the backend + /// The `String` should match its identifier on the backend. + enum RemoteDashboardCard: String, CaseIterable { + case todaysStats = "todays_stats" + case posts + case pages + case activity + + func supported(by blog: Blog) -> Bool { + switch self { + case .todaysStats: + return DashboardStatsCardCell.shouldShowCard(for: blog) + case .posts: + return true + case .pages: + return DashboardPagesListCardCell.shouldShowCard(for: blog) + case .activity: + return DashboardActivityLogCardCell.shouldShowCard(for: blog) + } + } + } +} + +private extension BlogDashboardRemoteEntity { + var hasDrafts: Bool { + return (self.posts?.value?.draft?.count ?? 0) > 0 + } + + var hasScheduled: Bool { + return (self.posts?.value?.scheduled?.count ?? 0) > 0 + } + + var hasNoDraftsOrScheduled: Bool { + return !hasDrafts && !hasScheduled + } + + var hasPublished: Bool { + return self.posts?.value?.hasPublished ?? true + } + + var hasPages: Bool { + return self.pages?.value != nil + } + + var hasStats: Bool { + return self.todaysStats?.value != nil + } + + var hasActivities: Bool { + return (self.activity?.value?.current?.orderedItems?.count ?? 0) > 0 + } + } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/DashboardCardTableView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/DashboardCardTableView.swift new file mode 100644 index 000000000000..bf53a18d88eb --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/DashboardCardTableView.swift @@ -0,0 +1,27 @@ +import UIKit + +extension NSNotification.Name { + /// Fired when a DashboardCardTableView changes its size + static let dashboardCardTableViewSizeChanged = NSNotification.Name("DashboardCard.IntrinsicContentSizeUpdated") +} + +class DashboardCardTableView: UITableView { + private var previousHeight: CGFloat = 0 + + override var contentSize: CGSize { + didSet { + self.invalidateIntrinsicContentSize() + } + } + + /// Emits a notification when the intrinsicContentSize changes + /// This allows subscribers to update their layouts (ie.: UICollectionViews) + override var intrinsicContentSize: CGSize { + layoutIfNeeded() + if contentSize.height != previousHeight, contentSize.height != 0 { + previousHeight = contentSize.height + NotificationCenter.default.post(name: .dashboardCardTableViewSizeChanged, object: nil) + } + return CGSize(width: UIView.noIntrinsicMetric, height: contentSize.height) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/BlogDashboardAnalytics.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/BlogDashboardAnalytics.swift new file mode 100644 index 000000000000..b3e90f6d86bc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/BlogDashboardAnalytics.swift @@ -0,0 +1,41 @@ +import Foundation + +class BlogDashboardAnalytics { + static let shared = BlogDashboardAnalytics() + + private var fired: [(WPAnalyticsEvent, [AnyHashable: String])] = [] + + private init() {} + + /// Reset the history of fired events + func reset() { + fired = [] + } + + /// This will track the given event and properties given they haven't been + /// triggered before. + /// + /// - Parameters: + /// - event: a `String` that represents the event name + /// - properties: a `Hash` that represents the properties + /// - blog: a `Blog` asssociated with the event + func track(_ event: WPAnalyticsEvent, properties: [AnyHashable: String] = [:], blog: Blog? = nil) { + if !fired.contains(where: { $0 == (event, properties) }) { + fired.append((event, properties)) + + if let blog = blog { + WPAnalytics.track(event, properties: properties, blog: blog) + } else { + WPAnalytics.track(event, properties: properties) + } + } + } + + static func trackContextualMenuAccessed(for card: DashboardCard) { + WPAnalytics.track(.dashboardCardContextualMenuAccessed, properties: ["card": card.rawValue]) + } + + static func trackHideTapped(for card: DashboardCard) { + WPAnalytics.track(.dashboardCardHideTapped, properties: ["card": card.rawValue]) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/BlogDashboardHelpers.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/BlogDashboardHelpers.swift new file mode 100644 index 000000000000..beb295506968 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/BlogDashboardHelpers.swift @@ -0,0 +1,19 @@ +import Foundation + +struct BlogDashboardHelpers { + static func makeHideCardAction(for card: DashboardCard, siteID: Int) -> UIAction { + UIAction( + title: Strings.hideThis, + image: UIImage(systemName: "minus.circle"), + attributes: [.destructive], + handler: { _ in + BlogDashboardAnalytics.trackHideTapped(for: card) + BlogDashboardPersonalizationService(siteID: siteID) + .setEnabled(false, for: card) + }) + } + + private enum Strings { + static let hideThis = NSLocalizedString("blogDashboard.contextMenu.hideThis", value: "Hide this", comment: "Title for the context menu action that hides the dashboard card.") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/BlogDashboardState.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/BlogDashboardState.swift new file mode 100644 index 000000000000..8582d322fde2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/BlogDashboardState.swift @@ -0,0 +1,53 @@ +import Foundation + +class BlogDashboardState { + private static var states: [NSNumber: BlogDashboardState] = [:] + + /// If the dashboard has cached data + var hasCachedData = false + + /// If loading the cards in the dashboard failed + var failedToLoad = false + + /// If the draft posts have been synced since launch + /// If they are, the local data source should be preferred than the dashboard cards data source + var draftsSynced = false + + /// If the scheduled posts have been synced since launch + /// If they are, the local data source should be preferred than the dashboard cards data source + var scheduledSynced = false + + /// If the dashboard is currently being loaded for the very first time + /// aka: it has never been loaded before. + var isFirstLoad: Bool { + !hasCachedData && !failedToLoad + } + + /// If the initial loading of the dashboard failed + var isFirstLoadFailure: Bool { + !hasCachedData && failedToLoad + } + + @Atomic var postsSyncingStatuses: [String] = [] + @Atomic var pagesSyncingStatuses: [String] = [] + + private init() { } + + /// Return the dashboard state for the given blog + static func shared(for blog: Blog) -> BlogDashboardState { + let dotComID = blog.dotComID ?? 0 + + if let availableState = states[dotComID] { + return availableState + } else { + states[dotComID] = BlogDashboardState() + return states[dotComID]! + } + } + + /// Purge all saved dashboard states. + /// Should be called on logout + static func resetAllStates() { + states.removeAll() + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/DashboardPostsSyncManager.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/DashboardPostsSyncManager.swift new file mode 100644 index 000000000000..03c7037d04b7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Helpers/DashboardPostsSyncManager.swift @@ -0,0 +1,151 @@ +import Foundation + +protocol DashboardPostsSyncManagerListener: AnyObject { + func postsSynced(success: Bool, + blog: Blog, + postType: DashboardPostsSyncManager.PostType, + posts: [AbstractPost]?, + for statuses: [String]) +} + +class DashboardPostsSyncManager { + + enum PostType { + case post + case page + } + + // MARK: Type Aliases + + typealias SyncSuccessBlock = () -> Void + typealias SyncFailureBlock = (Error?) -> Void + + // MARK: Private Variables + + private let postService: PostService + private let blogService: BlogService + @Atomic private var listeners: [DashboardPostsSyncManagerListener] = [] + + // MARK: Shared Instance + + static let shared = DashboardPostsSyncManager() + + // MARK: Initializer + + init(postService: PostService = PostService(managedObjectContext: ContextManager.shared.mainContext), + blogService: BlogService = BlogService(coreDataStack: ContextManager.shared)) { + self.postService = postService + self.blogService = blogService + } + + // MARK: Public Functions + + func addListener(_ listener: DashboardPostsSyncManagerListener) { + listeners.append(listener) + } + + func removeListener(_ listener: DashboardPostsSyncManagerListener) { + if let index = listeners.firstIndex(where: {$0 === listener}) { + listeners.remove(at: index) + } + } + + func syncPosts(blog: Blog, postType: PostType, statuses: [String]) { + let toBeSynced = postType.statusesNotBeingSynced(statuses, for: blog) + guard toBeSynced.isEmpty == false else { + return + } + + postType.markStatusesAsBeingSynced(toBeSynced, for: blog) + + let options = PostServiceSyncOptions() + options.statuses = toBeSynced + options.authorID = blog.userID + options.number = Constants.numberOfPostsToSync + options.order = .descending + options.orderBy = .byModified + options.purgesLocalSync = true + + // If the userID is nil we need to sync authors + // But only if the user is an admin + if blog.userID == nil && blog.isAdmin { + syncAuthors(blog: blog, success: { [weak self] in + postType.stopSyncingStatuses(toBeSynced, for: blog) + self?.syncPosts(blog: blog, postType: postType, statuses: toBeSynced) + }, failure: { [weak self] error in + postType.stopSyncingStatuses(toBeSynced, for: blog) + self?.notifyListenersOfPostsSync(success: false, blog: blog, postType: postType, posts: nil, for: toBeSynced) + }) + return + } + + postService.syncPosts(ofType: postType.postServiceType, with: options, for: blog) { [weak self] posts in + postType.stopSyncingStatuses(toBeSynced, for: blog) + self?.notifyListenersOfPostsSync(success: true, blog: blog, postType: postType, posts: posts, for: toBeSynced) + } failure: { [weak self] error in + postType.stopSyncingStatuses(toBeSynced, for: blog) + self?.notifyListenersOfPostsSync(success: false, blog: blog, postType: postType, posts: nil, for: toBeSynced) + } + } + + func syncAuthors(blog: Blog, success: @escaping SyncSuccessBlock, failure: @escaping SyncFailureBlock) { + blogService.syncAuthors(for: blog, success: success, failure: failure) + } + + // MARK: Private Helpers + + private func notifyListenersOfPostsSync(success: Bool, + blog: Blog, + postType: PostType, + posts: [AbstractPost]?, + for statuses: [String]) { + for aListener in listeners { + aListener.postsSynced(success: success, blog: blog, postType: postType, posts: posts, for: statuses) + } + } + + enum Constants { + static let numberOfPostsToSync: NSNumber = 3 + } +} + +private extension DashboardPostsSyncManager.PostType { + var postServiceType: PostServiceType { + switch self { + case .post: + return .post + case .page: + return .page + } + } + + func statusesNotBeingSynced(_ statuses: [String], for blog: Blog) -> [String] { + var currentlySyncing: [String] + switch self { + case .post: + currentlySyncing = blog.dashboardState.postsSyncingStatuses + case .page: + currentlySyncing = blog.dashboardState.pagesSyncingStatuses + } + let notCurrentlySyncing = statuses.filter({ !currentlySyncing.contains($0) }) + return notCurrentlySyncing + } + + func markStatusesAsBeingSynced(_ toBeSynced: [String], for blog: Blog) { + switch self { + case .post: + blog.dashboardState.postsSyncingStatuses.append(contentsOf: toBeSynced) + case .page: + blog.dashboardState.pagesSyncingStatuses.append(contentsOf: toBeSynced) + } + } + + func stopSyncingStatuses(_ statuses: [String], for blog: Blog) { + switch self { + case .post: + blog.dashboardState.postsSyncingStatuses = blog.dashboardState.postsSyncingStatuses.filter({ !statuses.contains($0) }) + case .page: + blog.dashboardState.pagesSyncingStatuses = blog.dashboardState.pagesSyncingStatuses.filter({ !statuses.contains($0) }) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersistence.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersistence.swift new file mode 100644 index 000000000000..950911e7c18e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersistence.swift @@ -0,0 +1,29 @@ +import Foundation + +class BlogDashboardPersistence { + func persist(cards: NSDictionary, for wpComID: Int) { + do { + let directory = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + let fileURL = directory.appendingPathComponent(filename(for: wpComID)) + let data = try JSONSerialization.data(withJSONObject: cards, options: []) + try data.write(to: fileURL, options: .atomic) + } catch { + // In case of an error, nothing is done + } + } + + func getCards(for wpComID: Int) -> NSDictionary? { + do { + let directory = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) + let fileURL = directory.appendingPathComponent(filename(for: wpComID)) + let data = try Data(contentsOf: fileURL) + return try JSONSerialization.jsonObject(with: data, options: []) as? NSDictionary + } catch { + return nil + } + } + + private func filename(for blogID: Int) -> String { + "cards_\(blogID).json" + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersonalizationService.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersonalizationService.swift new file mode 100644 index 000000000000..fd6fa0e1cb2a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPersonalizationService.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Manages dashboard settings such as card visibility. +struct BlogDashboardPersonalizationService { + private let repository: UserPersistentRepository + private let siteID: String + + init(repository: UserPersistentRepository = UserDefaults.standard, + siteID: Int) { + self.repository = repository + self.siteID = String(siteID) + } + + func isEnabled(_ card: DashboardCard) -> Bool { + getSettings(for: card)[siteID] ?? true + } + + func hasPreference(for card: DashboardCard) -> Bool { + getSettings(for: card)[siteID] != nil + } + + func setEnabled(_ isEnabled: Bool, for card: DashboardCard) { + guard let key = makeKey(for: card) else { return } + + var settings = getSettings(for: card) + settings[siteID] = isEnabled + repository.set(settings, forKey: key) + + DispatchQueue.main.async { + NotificationCenter.default.post(name: .blogDashboardPersonalizationSettingsChanged, object: self) + } + } + + private func getSettings(for card: DashboardCard) -> [String: Bool] { + guard let key = makeKey(for: card) else { return [:] } + return repository.dictionary(forKey: key) as? [String: Bool] ?? [:] + } +} + +private func makeKey(for card: DashboardCard) -> String? { + switch card { + case .todaysStats: + return "todays-stats-card-enabled-site-settings" + case .draftPosts: + return "draft-posts-card-enabled-site-settings" + case .scheduledPosts: + return "scheduled-posts-card-enabled-site-settings" + case .blaze: + return "blaze-card-enabled-site-settings" + case .prompts: + // Warning: there is an irregularity with the prompts key that doesn't + // have a "-card" component in the key name. Keeping it like this to + // avoid having to migrate data. + return "prompts-enabled-site-settings" + case .domainsDashboardCard: + return "domains-dashboard-card-enabled-site-settings" + case .activityLog: + return "activity-log-card-enabled-site-settings" + case .pages: + return "pages-card-enabled-site-settings" + case .quickStart, .jetpackBadge, .jetpackInstall, .nextPost, .createPost, .failure, .ghost, .personalize, .empty: + return nil + } +} + +extension NSNotification.Name { + /// Sent whenever any of the blog settings managed by ``BlogDashboardPersonalizationService`` + /// are changed. + static let blogDashboardPersonalizationSettingsChanged = NSNotification.Name("BlogDashboardPersonalizationSettingsChanged") +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPostsParser.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPostsParser.swift new file mode 100644 index 000000000000..a7f48ca7c82c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardPostsParser.swift @@ -0,0 +1,74 @@ +import Foundation +import CoreData + +class BlogDashboardPostsParser { + private let managedObjectContext: NSManagedObjectContext + + init(managedObjectContext: NSManagedObjectContext) { + self.managedObjectContext = managedObjectContext + } + + /// Parse the posts data that comes from the API + /// We might run into cases where the API returns that there are no + /// drafts and/or scheduled posts, however the user might have + /// they locally. + /// This parser basically looks in the local content and fixes the data + /// if needed. + func parse(_ postsDictionary: NSDictionary, for blog: Blog) -> NSDictionary { + guard let posts = postsDictionary.mutableCopy() as? NSMutableDictionary else { + return postsDictionary + } + + + if let localDraftsCount = numberOfPosts(for: blog, filter: PostListFilter.draftFilter()) { + // If drafts are synced OR + // If drafts are not synced and the cards API returns zero posts + // depend on local data + if blog.dashboardState.draftsSynced || (!posts.hasDrafts && localDraftsCount > 0) { + posts["draft"] = Array(repeatElement([String: Any](), count: localDraftsCount)) + } + } + + if let localScheduledCount = numberOfPosts(for: blog, filter: PostListFilter.scheduledFilter()) { + // If scheduled posts are synced OR + // If scheduled posts are not synced and the cards API returns zero posts + // depend on local data + if blog.dashboardState.scheduledSynced || (!posts.hasScheduled && localScheduledCount > 0) { + posts["scheduled"] = Array(repeatElement([String: Any](), count: localScheduledCount)) + } + } + + // Make sure only one draft is present + if posts.hasDrafts { + posts["draft"] = [[String: Any]()] // Only one post is needed + } + + // Make sure only one scheduled post is present + if posts.hasScheduled { + posts["scheduled"] = [[String: Any]()] // Only one post is needed + } + + return posts + } + + private func numberOfPosts(for blog: Blog, filter: PostListFilter) -> Int? { + let fetchRequest = NSFetchRequest<Post>(entityName: String(describing: Post.self)) + fetchRequest.predicate = filter.predicate(for: blog) + fetchRequest.sortDescriptors = filter.sortDescriptors + fetchRequest.fetchBatchSize = 1 + fetchRequest.fetchLimit = 1 + return try? managedObjectContext.count(for: fetchRequest) + } +} + +private extension NSDictionary { + var hasDrafts: Bool { + let draftsCount = (self["draft"] as? Array<Any>)?.count ?? 0 + return draftsCount > 0 + } + + var hasScheduled: Bool { + let scheduledCount = (self["scheduled"] as? Array<Any>)?.count ?? 0 + return scheduledCount > 0 + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardRemoteEntity.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardRemoteEntity.swift new file mode 100644 index 000000000000..ef5f94260f58 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardRemoteEntity.swift @@ -0,0 +1,55 @@ +import Foundation + +struct BlogDashboardRemoteEntity: Decodable, Hashable { + + var posts: FailableDecodable<BlogDashboardPosts>? + var todaysStats: FailableDecodable<BlogDashboardStats>? + var pages: FailableDecodable<[BlogDashboardPage]>? + var activity: FailableDecodable<BlogDashboardActivity>? + + struct BlogDashboardPosts: Decodable, Hashable { + var hasPublished: Bool? + var draft: [BlogDashboardPost]? + var scheduled: [BlogDashboardPost]? + + enum CodingKeys: String, CodingKey { + case hasPublished = "has_published" + case draft + case scheduled + } + } + + // We don't rely on the data from the API to show posts + struct BlogDashboardPost: Decodable, Hashable { } + + struct BlogDashboardStats: Decodable, Hashable { + var views: Int? + var visitors: Int? + var likes: Int? + var comments: Int? + } + + // We don't rely on the data from the API to show pages + struct BlogDashboardPage: Decodable, Hashable { } + + struct BlogDashboardActivity: Decodable, Hashable { + var current: CurrentActivity? + + struct CurrentActivity: Decodable, Hashable { + var orderedItems: [Activity]? + } + } + + enum CodingKeys: String, CodingKey { + case posts + case todaysStats = "todays_stats" + case pages + case activity + } +} + +extension Activity: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(activityID) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardService.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardService.swift new file mode 100644 index 000000000000..07a12b8692e5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Service/BlogDashboardService.swift @@ -0,0 +1,126 @@ +import Foundation +import WordPressKit + +final class BlogDashboardService { + + private let remoteService: DashboardServiceRemote + private let persistence: BlogDashboardPersistence + private let postsParser: BlogDashboardPostsParser + private let repository: UserPersistentRepository + + init(managedObjectContext: NSManagedObjectContext, + remoteService: DashboardServiceRemote? = nil, + persistence: BlogDashboardPersistence = BlogDashboardPersistence(), + repository: UserPersistentRepository = UserDefaults.standard, + postsParser: BlogDashboardPostsParser? = nil) { + self.remoteService = remoteService ?? DashboardServiceRemote(wordPressComRestApi: WordPressComRestApi.defaultApi(in: managedObjectContext, localeKey: WordPressComRestApi.LocaleKeyV2)) + self.persistence = persistence + self.repository = repository + self.postsParser = postsParser ?? BlogDashboardPostsParser(managedObjectContext: managedObjectContext) + } + + /// Fetch cards from remote + func fetch(blog: Blog, completion: @escaping ([DashboardCardModel]) -> Void, failure: (([DashboardCardModel]) -> Void)? = nil) { + + guard let dotComID = blog.dotComID?.intValue else { + failure?([]) + return + } + + let cardsToFetch: [String] = DashboardCard.RemoteDashboardCard.allCases.filter {$0.supported(by: blog)}.map { $0.rawValue } + + remoteService.fetch(cards: cardsToFetch, forBlogID: dotComID, success: { [weak self] cardsDictionary in + + guard let cardsDictionary = self?.parseCardsForLocalContent(cardsDictionary, blog: blog) else { + failure?([]) + return + } + + if let cards = self?.decode(cardsDictionary, blog: blog) { + + blog.dashboardState.hasCachedData = true + blog.dashboardState.failedToLoad = false + + self?.persistence.persist(cards: cardsDictionary, for: dotComID) + + guard let items = self?.parse(cards, blog: blog, dotComID: dotComID) else { + failure?([]) + return + } + + completion(items) + } else { + blog.dashboardState.failedToLoad = true + failure?([]) + } + + }, failure: { [weak self] _ in + blog.dashboardState.failedToLoad = true + let items = self?.fetchLocal(blog: blog) + failure?(items ?? []) + }) + } + + /// Fetch cards from local + func fetchLocal(blog: Blog) -> [DashboardCardModel] { + + guard let dotComID = blog.dotComID?.intValue else { + return [] + } + + if let cardsDictionary = persistence.getCards(for: dotComID), + let cardsWithLocalData = parseCardsForLocalContent(cardsDictionary, blog: blog), + let cards = decode(cardsWithLocalData, blog: blog) { + + blog.dashboardState.hasCachedData = true + let items = parse(cards, blog: blog, dotComID: dotComID) + return items + } else { + blog.dashboardState.hasCachedData = false + return localCards(blog: blog, dotComID: dotComID) + } + } +} + +private extension BlogDashboardService { + + func parse(_ entity: BlogDashboardRemoteEntity?, blog: Blog, dotComID: Int) -> [DashboardCardModel] { + let personalizationService = BlogDashboardPersonalizationService(repository: repository, siteID: dotComID) + var cards: [DashboardCardModel] = DashboardCard.allCases.compactMap { card in + guard personalizationService.isEnabled(card), + card.shouldShow(for: blog, apiResponse: entity) else { + return nil + } + return DashboardCardModel(cardType: card, dotComID: dotComID, entity: entity) + } + if cards.isEmpty || cards.map(\.cardType) == [.personalize] { + cards.insert(DashboardCardModel(cardType: .empty, dotComID: dotComID), at: 0) + } + return cards + } + + func decode(_ cardsDictionary: NSDictionary, blog: Blog) -> BlogDashboardRemoteEntity? { + guard let data = try? JSONSerialization.data(withJSONObject: cardsDictionary, options: []) else { + return nil + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .supportMultipleDateFormats + return try? decoder.decode(BlogDashboardRemoteEntity.self, from: data) + } + + func parseCardsForLocalContent(_ cardsDictionary: NSDictionary, blog: Blog) -> NSDictionary? { + guard let cardsDictionary = cardsDictionary.mutableCopy() as? NSMutableDictionary, + let posts = cardsDictionary[DashboardCard.RemoteDashboardCard.posts.rawValue] as? NSDictionary else { + return cardsDictionary + } + + // TODO: Add similar logic here for parsing pages + cardsDictionary["posts"] = postsParser.parse(posts, for: blog) + return cardsDictionary + } + + func localCards(blog: Blog, dotComID: Int) -> [DashboardCardModel] { + parse(nil, blog: blog, dotComID: dotComID) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift new file mode 100644 index 000000000000..cd2fa344a6e1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/BlogDashboardViewModel.swift @@ -0,0 +1,240 @@ +import Foundation +import UIKit +import CoreData + +enum DashboardSection: Int, CaseIterable { + case migrationSuccess + case quickActions + case cards +} + +typealias BlogID = Int + +enum DashboardItem: Hashable { + case migrationSuccess + case quickActions(BlogID) + case cards(DashboardCardModel) +} + +typealias DashboardSnapshot = NSDiffableDataSourceSnapshot<DashboardSection, DashboardItem> +typealias DashboardDataSource = UICollectionViewDiffableDataSource<DashboardSection, DashboardItem> + +class BlogDashboardViewModel { + private weak var viewController: BlogDashboardViewController? + + private let managedObjectContext: NSManagedObjectContext + + var blog: Blog + + private var currentCards: [DashboardCardModel] = [] + + private lazy var draftStatusesToSync: [String] = { + return PostListFilter.draftFilter().statuses.strings + }() + + private lazy var scheduledStatusesToSync: [String] = { + return PostListFilter.scheduledFilter().statuses.strings + }() + + private lazy var pageStatusesToSync: [String] = { + return PostListFilter.allNonTrashedFilter().statuses.strings + }() + + private lazy var service: BlogDashboardService = { + return BlogDashboardService(managedObjectContext: managedObjectContext) + }() + + private lazy var dataSource: DashboardDataSource? = { + guard let viewController = viewController else { + return nil + } + + return DashboardDataSource(collectionView: viewController.collectionView) { [unowned self] collectionView, indexPath, item in + + var cellType: DashboardCollectionViewCell.Type + var cardType: DashboardCard + var apiResponse: BlogDashboardRemoteEntity? + switch item { + case .quickActions: + let cellType = DashboardQuickActionsCardCell.self + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellType.defaultReuseID, for: indexPath) as? DashboardQuickActionsCardCell + cell?.configureQuickActionButtons(for: blog, with: viewController) + return cell + case .cards(let cardModel): + let cellType = cardModel.cardType.cell + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellType.defaultReuseID, for: indexPath) + if var cellConfigurable = cell as? BlogDashboardCardConfigurable { + cellConfigurable.row = indexPath.row + cellConfigurable.configure(blog: blog, viewController: viewController, apiResponse: cardModel.apiResponse) + } + return cell + case .migrationSuccess: + let cellType = DashboardMigrationSuccessCell.self + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellType.defaultReuseID, for: indexPath) as? DashboardMigrationSuccessCell + cell?.configure(with: viewController) + return cell + } + + } + }() + + init(viewController: BlogDashboardViewController, managedObjectContext: NSManagedObjectContext = ContextManager.shared.mainContext, blog: Blog) { + self.viewController = viewController + self.managedObjectContext = managedObjectContext + self.blog = blog + registerNotifications() + } + + /// Apply the initial configuration when the view loaded + func viewDidLoad() { + loadCardsFromCache() + } + + /// Call the API to return cards for the current blog + func loadCards(completion: (([DashboardCardModel]) -> Void)? = nil) { + viewController?.showLoading() + + service.fetch(blog: blog, completion: { [weak self] cards in + self?.viewController?.stopLoading() + self?.updateCurrentCards(cards: cards) + completion?(cards) + }, failure: { [weak self] cards in + self?.viewController?.stopLoading() + self?.loadingFailure() + self?.updateCurrentCards(cards: cards) + + completion?(cards) + }) + } + + @objc func loadCardsFromCache() { + let cards = service.fetchLocal(blog: blog) + updateCurrentCards(cards: cards) + } + + func isQuickActionsSection(_ sectionIndex: Int) -> Bool { + let showMigration = MigrationSuccessCardView.shouldShowMigrationSuccessCard && !WPDeviceIdentification.isiPad() + let targetIndex = showMigration ? DashboardSection.quickActions.rawValue : DashboardSection.quickActions.rawValue - 1 + return sectionIndex == targetIndex + } + + func isMigrationSuccessCardSection(_ sectionIndex: Int) -> Bool { + let showMigration = MigrationSuccessCardView.shouldShowMigrationSuccessCard && !WPDeviceIdentification.isiPad() + return showMigration ? sectionIndex == DashboardSection.migrationSuccess.rawValue : false + } +} + +// MARK: - Private methods + +private extension BlogDashboardViewModel { + + func registerNotifications() { + NotificationCenter.default.addObserver(self, selector: #selector(showDraftsCardIfNeeded), name: .newPostCreated, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(showScheduledCardIfNeeded), name: .newPostScheduled, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(showNextPostCardIfNeeded), name: .newPostPublished, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(loadCardsFromCache), name: .blogDashboardPersonalizationSettingsChanged, object: nil) + } + + func updateCurrentCards(cards: [DashboardCardModel]) { + currentCards = cards + syncPosts(for: cards) + applySnapshot(for: cards) + } + + func syncPosts(for cards: [DashboardCardModel]) { + if cards.hasDrafts { + DashboardPostsSyncManager.shared.syncPosts(blog: blog, postType: .post, statuses: draftStatusesToSync) + } + if cards.hasScheduled { + DashboardPostsSyncManager.shared.syncPosts(blog: blog, postType: .post, statuses: scheduledStatusesToSync) + } + if cards.hasPages { + DashboardPostsSyncManager.shared.syncPosts(blog: blog, postType: .page, statuses: pageStatusesToSync) + } + } + + func applySnapshot(for cards: [DashboardCardModel]) { + let snapshot = createSnapshot(from: cards) + let scrollView = viewController?.mySiteScrollView + let position = scrollView?.contentOffset + + dataSource?.apply(snapshot, animatingDifferences: false) { [weak self] in + guard let scrollView = scrollView, let position = position else { + return + } + + self?.scroll(scrollView, to: position) + } + } + + func createSnapshot(from cards: [DashboardCardModel]) -> DashboardSnapshot { + let items = cards.map { DashboardItem.cards($0) } + let dotComID = blog.dotComID?.intValue ?? 0 + var snapshot = DashboardSnapshot() + if MigrationSuccessCardView.shouldShowMigrationSuccessCard, !WPDeviceIdentification.isiPad() { + snapshot.appendSections(DashboardSection.allCases) + snapshot.appendItems([.migrationSuccess], toSection: .migrationSuccess) + } else { + snapshot.appendSections([.quickActions, .cards]) + } + + snapshot.appendItems([.quickActions(dotComID)], toSection: .quickActions) + snapshot.appendItems(items, toSection: .cards) + return snapshot + } + + func scroll(_ scrollView: UIScrollView, to position: CGPoint) { + if position.y > 0 { + scrollView.setContentOffset(position, animated: false) + } + } + + // In case a draft is saved and the drafts card + // is not appearing, we show it. + @objc func showDraftsCardIfNeeded() { + if !currentCards.contains(where: { $0.cardType == .draftPosts }) { + loadCardsFromCache() + } + } + + // In case a post is scheduled and the scheduled card + // is not appearing, we show it. + @objc func showScheduledCardIfNeeded() { + if !currentCards.contains(where: { $0.cardType == .scheduledPosts }) { + loadCardsFromCache() + } + } + + // In case a post is published and create_first card + // is showing, we replace it with the create_next card. + @objc func showNextPostCardIfNeeded() { + if !currentCards.contains(where: { $0.cardType == .createPost }) { + loadCardsFromCache() + } + } +} + +// MARK: - Ghost/Skeleton cards and failures + +private extension BlogDashboardViewModel { + + func loadingFailure() { + if blog.dashboardState.hasCachedData { + viewController?.loadingFailure() + } + } +} + +private extension Collection where Element == DashboardCardModel { + var hasDrafts: Bool { + return contains(where: { $0.cardType == .draftPosts }) + } + + var hasScheduled: Bool { + return contains(where: { $0.cardType == .scheduledPosts }) + } + + var hasPages: Bool { + return contains(where: { $0.cardType == .pages }) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/DashboardCardModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/DashboardCardModel.swift new file mode 100644 index 000000000000..90631c0b7389 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/DashboardCardModel.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Represents a card in the dashboard collection view +struct DashboardCardModel: Hashable { + let cardType: DashboardCard + let dotComID: Int + let apiResponse: BlogDashboardRemoteEntity? + + /** + Initializes a new DashboardCardModel, used as a model for each dashboard card. + + - Parameters: + - id: The `DashboardCard` id of this card + - dotComID: The blog id for the blog associated with this card + - entity: A `BlogDashboardRemoteEntity?` property + + - Returns: A `DashboardCardModel` that is used by the dashboard diffable collection + view. The `id`, `dotComID` and the `entity` is used to differentiate one + card from the other. + */ + init(cardType: DashboardCard, dotComID: Int, entity: BlogDashboardRemoteEntity? = nil) { + self.cardType = cardType + self.dotComID = dotComID + self.apiResponse = entity + } + + static func == (lhs: DashboardCardModel, rhs: DashboardCardModel) -> Bool { + lhs.cardType == rhs.cardType && + lhs.dotComID == rhs.dotComID && + lhs.apiResponse == rhs.apiResponse + } + + func hash(into hasher: inout Hasher) { + hasher.combine(cardType) + hasher.combine(dotComID) + hasher.combine(apiResponse) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/DashboardStatsViewModel.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/DashboardStatsViewModel.swift new file mode 100644 index 000000000000..b6207dc8b738 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/ViewModel/DashboardStatsViewModel.swift @@ -0,0 +1,36 @@ +import Foundation + +class DashboardStatsViewModel { + + // MARK: Private Variables + + private var apiResponse: BlogDashboardRemoteEntity + + // MARK: Initializer + + init(apiResponse: BlogDashboardRemoteEntity) { + self.apiResponse = apiResponse + } + + // MARK: Public Variables + + var todaysViews: String { + apiResponse.todaysStats?.value?.views?.abbreviatedString(forHeroNumber: true) ?? "0" + } + + var todaysVisitors: String { + apiResponse.todaysStats?.value?.visitors?.abbreviatedString(forHeroNumber: true) ?? "0" + } + + var todaysLikes: String { + apiResponse.todaysStats?.value?.likes?.abbreviatedString(forHeroNumber: true) ?? "0" + } + + var shouldDisplayNudge: Bool { + guard let todaysStats = apiResponse.todaysStats?.value else { + return false + } + + return todaysStats.views == 0 && todaysStats.visitors == 0 && todaysStats.likes == 0 + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailHeaderView.h b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailHeaderView.h deleted file mode 100644 index 7f60f6f3360b..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailHeaderView.h +++ /dev/null @@ -1,29 +0,0 @@ -#import <UIKit/UIKit.h> - -extern const CGFloat BlogDetailHeaderViewBlavatarSize; - -@protocol BlogDetailHeaderViewDelegate - -- (void)siteIconTapped; -- (void)siteIconReceivedDroppedImage:(UIImage * _Nullable)image; -- (BOOL)siteIconShouldAllowDroppedImages; - -@end - -@class Blog; - -@interface BlogDetailHeaderView : UIView - -@property (nonatomic, strong, nonnull) UIImageView *blavatarImageView; -@property (nonatomic, strong, nonnull) UILabel *titleLabel; -@property (nonatomic, strong, nonnull) UILabel *subtitleLabel; -@property (nonatomic, strong, nullable) Blog *blog; -@property (nonatomic, weak, nullable) id<BlogDetailHeaderViewDelegate> delegate; -@property (nonatomic) BOOL updatingIcon; - -- (void)refreshIconImage; -- (void)setTitleText:(NSString * _Nullable)title; -- (void)setSubtitleText:(NSString * _Nullable)subtitle; -- (void)loadImageAtPath:(NSString * _Nonnull)imagePath; - -@end diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailHeaderView.m b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailHeaderView.m deleted file mode 100644 index 534f9d74260d..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailHeaderView.m +++ /dev/null @@ -1,341 +0,0 @@ -#import "BlogDetailHeaderView.h" -#import "Blog.h" -#import <WordPressUI/WordPressUI.h> -#import "WordPress-Swift.h" - - -const CGFloat BlogDetailHeaderViewBlavatarSize = 40.0; -const CGFloat BlogDetailHeaderViewLabelHorizontalPadding = 10.0; - -@interface BlogDetailHeaderView () <UIDropInteractionDelegate> - -@property (nonatomic, strong) UIStackView *stackView; -@property (nonatomic, strong) UIActivityIndicatorView *blavatarUpdateActivityIndicatorView; -@property (nonatomic, strong) UIStackView *labelsStackView; -@property (nonatomic, strong) UIView *blavatarDropTarget; -@property (nonatomic, strong) UIView *spotlightView; - -@end - -@implementation BlogDetailHeaderView - -- (void)awakeFromNib -{ - [super awakeFromNib]; - [self performSetup]; -} - -- (instancetype)initWithFrame:(CGRect)frame -{ - self = [super initWithFrame:frame]; - if (self) { - [self performSetup]; - } - return self; -} - -- (void)performSetup -{ - self.preservesSuperviewLayoutMargins = YES; - - [self setupStackView]; - [self setupBlavatarImageView]; - [self setupBlavatarDropTarget]; - [self setupLabelsStackView]; - [self setupTitleLabel]; - [self setupSubtitleLabel]; - - self.accessibilityElements = @[self.stackView, self.blavatarDropTarget]; -} - -#pragma mark - Public Methods - -- (void)setBlog:(Blog *)blog -{ - _blog = blog; - [self refreshIconImage]; - - // if the blog name is missing, we want to show the blog displayURL instead - NSString *blogName = blog.settings.name; - NSString *title = (blogName && !blogName.isEmpty) ? blogName : blog.displayURL; - [self setTitleText:title]; - [self setSubtitleText:blog.displayURL]; - [self.labelsStackView setNeedsLayout]; - - if ([self.delegate siteIconShouldAllowDroppedImages]) { - UIDropInteraction *dropInteraction = [[UIDropInteraction alloc] initWithDelegate:self]; - [self.blavatarDropTarget addInteraction:dropInteraction]; - } - - NSString *localizedLabel = - NSLocalizedString(@"%@, at %@", - @"Accessibility label for the site header. The first variable is the blog name, the second is the domain."); - self.stackView.accessibilityLabel = - [NSString stringWithFormat:localizedLabel, title, blog.displayURL]; -} - -- (void)setTitleText:(NSString *)title -{ - [self.titleLabel setText:title]; - [self.labelsStackView setNeedsLayout]; -} - -- (void)setSubtitleText:(NSString *)subtitle -{ - [self.subtitleLabel setText:subtitle]; - [self.labelsStackView setNeedsLayout]; -} - -- (void)loadImageAtPath:(NSString *)imagePath -{ - [self.blavatarImageView downloadSiteIconAt:imagePath]; -} - -- (void)applyPlaceholderBorder -{ - self.blavatarImageView.layer.borderColor = [UIColor whiteColor].CGColor; - self.blavatarImageView.layer.borderWidth = 1.0f; -} - -- (void)refreshIconImage -{ - [self applyPlaceholderBorder]; - - if (self.blog.hasIcon) { - [self.blavatarImageView downloadSiteIconFor:self.blog placeholderImage:nil]; - } else { - self.blavatarImageView.image = [UIImage siteIconPlaceholder]; - } - - [self refreshSpotlight]; -} - -- (void)refreshSpotlight { - [self removeQuickStartSpotlight]; - - if ([[QuickStartTourGuide find] isCurrentElement:QuickStartTourElementSiteIcon]) { - [self addQuickStartSpotlight]; - } -} - -- (void)addQuickStartSpotlight -{ - self.spotlightView = [QuickStartSpotlightView new]; - [self addSubview:self.spotlightView]; - - self.spotlightView.translatesAutoresizingMaskIntoConstraints = false; - [NSLayoutConstraint activateConstraints:@[ - [self.blavatarImageView.trailingAnchor constraintEqualToAnchor:self.spotlightView.trailingAnchor constant:-8.0], - [self.blavatarImageView.bottomAnchor constraintEqualToAnchor:self.spotlightView.bottomAnchor constant:-8.0] - ]]; -} - -- (void)removeQuickStartSpotlight -{ - [self.spotlightView removeFromSuperview]; -} - -#pragma mark - Subview setup - -- (void)setupStackView -{ - UIStackView *stackView = [[UIStackView alloc] init]; - stackView.translatesAutoresizingMaskIntoConstraints = NO; - stackView.axis = UILayoutConstraintAxisHorizontal; - stackView.distribution = UIStackViewDistributionFill; - stackView.alignment = UIStackViewAlignmentCenter; - stackView.spacing = BlogDetailHeaderViewLabelHorizontalPadding; - [self addSubview:stackView]; - - [NSLayoutConstraint activateConstraints:@[ - [stackView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor], - [stackView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor], - [stackView.topAnchor constraintEqualToAnchor:self.topAnchor], - [stackView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor], - ]]; - - stackView.isAccessibilityElement = YES; - - _stackView = stackView; -} - -- (void)setupBlavatarImageView -{ - NSAssert(_stackView != nil, @"stackView was nil"); - - UIImageView *imageView = [[UIImageView alloc] init]; - imageView.translatesAutoresizingMaskIntoConstraints = NO; - - [_stackView addArrangedSubview:imageView]; - - NSLayoutConstraint *heightConstraint = [imageView.heightAnchor constraintEqualToConstant:BlogDetailHeaderViewBlavatarSize]; - heightConstraint.priority = 999; - [NSLayoutConstraint activateConstraints:@[ - [imageView.widthAnchor constraintEqualToConstant:BlogDetailHeaderViewBlavatarSize], - heightConstraint - ]]; - - _blavatarImageView = imageView; -} - -- (void)setupBlavatarDropTarget -{ - self.blavatarDropTarget = [UIView new]; - [self.blavatarDropTarget setTranslatesAutoresizingMaskIntoConstraints:NO]; - self.blavatarDropTarget.backgroundColor = [UIColor clearColor]; - self.blavatarDropTarget.accessibilityLabel = NSLocalizedString(@"Site Icon", @"Site Icon accessibility label."); - self.blavatarDropTarget.accessibilityTraits = UIAccessibilityTraitImage | UIAccessibilityTraitButton; - self.blavatarDropTarget.accessibilityHint = NSLocalizedString(@"Shows a menu for changing the Site Icon.", @"Accessibility hint describing what happens if the Site Icon is tapped."); - self.blavatarDropTarget.isAccessibilityElement = YES; - - UITapGestureRecognizer *singleTap = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(blavatarImageTapped)]; - singleTap.numberOfTapsRequired = 1; - [self.blavatarDropTarget addGestureRecognizer:singleTap]; - - [self addSubview:self.blavatarDropTarget]; - [self.blavatarDropTarget pinSubviewToAllEdgeMargins:self.blavatarImageView]; -} - -- (UIActivityIndicatorView *)blavatarUpdateActivityIndicatorView { - if (!_blavatarUpdateActivityIndicatorView) { - _blavatarUpdateActivityIndicatorView = [[UIActivityIndicatorView alloc]initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge]; - _blavatarUpdateActivityIndicatorView.translatesAutoresizingMaskIntoConstraints = NO; - [self.blavatarImageView addSubview:_blavatarUpdateActivityIndicatorView]; - [self.blavatarImageView pinSubviewAtCenter:_blavatarUpdateActivityIndicatorView]; - } - return _blavatarUpdateActivityIndicatorView; -} - --(void)blavatarImageTapped -{ - [[QuickStartTourGuide find] visited:QuickStartTourElementSiteIcon]; - [self removeQuickStartSpotlight]; - - [self.delegate siteIconTapped]; -} - -- (void)setupLabelsStackView -{ - NSAssert(_stackView != nil, @"stackView was nil"); - - UIStackView *stackView = [[UIStackView alloc] init]; - stackView.translatesAutoresizingMaskIntoConstraints = NO; - stackView.axis = UILayoutConstraintAxisVertical; - stackView.distribution = UIStackViewDistributionFill; - stackView.alignment = UIStackViewAlignmentFill; - - [_stackView addArrangedSubview:stackView]; - - [stackView setContentHuggingPriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisHorizontal]; - [stackView setContentCompressionResistancePriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisHorizontal]; - - _labelsStackView = stackView; -} - -- (void)setupTitleLabel -{ - NSAssert(_labelsStackView != nil, @"labelsStackView was nil"); - - UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero]; - label.translatesAutoresizingMaskIntoConstraints = NO; - label.numberOfLines = 1; - label.backgroundColor = [UIColor clearColor]; - label.opaque = YES; - label.textColor = [UIColor murielText]; - label.adjustsFontSizeToFitWidth = NO; - [WPStyleGuide configureLabel:label textStyle:UIFontTextStyleCallout]; - - [_labelsStackView addArrangedSubview:label]; - - _titleLabel = label; -} - -- (void)setupSubtitleLabel -{ - NSAssert(_labelsStackView != nil, @"labelsStackView was nil"); - - UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero]; - label.translatesAutoresizingMaskIntoConstraints = NO; - label.numberOfLines = 1; - label.backgroundColor = [UIColor clearColor]; - label.opaque = YES; - label.textColor = [UIColor murielNeutral]; - label.adjustsFontSizeToFitWidth = NO; - [WPStyleGuide configureLabel:label textStyle:UIFontTextStyleCaption1 symbolicTraits:UIFontDescriptorTraitItalic]; - - [_labelsStackView addArrangedSubview:label]; - - _subtitleLabel = label; -} - -- (void)setUpdatingIcon:(BOOL)updatingIcon -{ - _updatingIcon = updatingIcon; - if (updatingIcon) { - [self.blavatarUpdateActivityIndicatorView startAnimating]; - } else { - [self.blavatarUpdateActivityIndicatorView stopAnimating]; - } -} - -#pragma mark - Drop Interaction Handler -- (void)dropInteraction:(UIDropInteraction *)interaction - sessionDidEnter:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) -{ - [self.blavatarImageView depressSpringAnimation:nil]; -} - -- (BOOL)dropInteraction:(UIDropInteraction *)interaction - canHandleSession:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) -{ - BOOL isAnImage = [session canLoadObjectsOfClass:[UIImage self]]; - BOOL isSingleImage = [session.items count] == 1; - return (isAnImage && isSingleImage); -} - -- (UIDropProposal *)dropInteraction:(UIDropInteraction *)interaction - sessionDidUpdate:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) -{ - CGPoint dropLocation = [session locationInView:self.blavatarDropTarget]; - - UIDropOperation dropOperation = UIDropOperationCancel; - - if (CGRectContainsPoint(self.blavatarDropTarget.bounds, dropLocation)) { - dropOperation = UIDropOperationCopy; - } - - UIDropProposal *dropProposal = [[UIDropProposal alloc] initWithDropOperation:dropOperation]; - - return dropProposal; -} - -- (void)dropInteraction:(UIDropInteraction *)interaction - performDrop:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) -{ - [self setUpdatingIcon:YES]; - [session loadObjectsOfClass:[UIImage self] completion:^(NSArray *images) { - UIImage *image = [images firstObject]; - [self.delegate siteIconReceivedDroppedImage:image]; - }]; -} - -- (void)dropInteraction:(UIDropInteraction *)interaction - concludeDrop:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) -{ - [self.blavatarImageView normalizeSpringAnimation:nil]; -} - -- (void)dropInteraction:(UIDropInteraction *)interaction - sessionDidExit:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) -{ - [self.blavatarImageView normalizeSpringAnimation:nil]; -} - -- (void)dropInteraction:(UIDropInteraction *)interaction - sessionDidEnd:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) -{ - [self.blavatarImageView normalizeSpringAnimation:nil]; -} - -@end diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsSectionHeaderView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsSectionHeaderView.swift index 98aae961a073..240b40de924e 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsSectionHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsSectionHeaderView.swift @@ -6,7 +6,7 @@ class BlogDetailsSectionHeaderView: UITableViewHeaderFooterView { @objc @IBOutlet private(set) var ellipsisButton: UIButton? { didSet { - ellipsisButton?.setImage(Gridicon.iconOfType(.ellipsis).imageWithTintColor(.listIcon), for: .normal) + ellipsisButton?.setImage(UIImage.gridicon(.ellipsis).imageWithTintColor(.listIcon), for: .normal) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsSectionHeaderView.xib b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsSectionHeaderView.xib index ff35b88b00b5..600ff5b1f423 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsSectionHeaderView.xib +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsSectionHeaderView.xib @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14865.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14819.2"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/> <capability name="Named colors" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> @@ -15,7 +15,8 @@ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="OEo-2z-Cmk"> - <rect key="frame" x="273" y="0.0" width="44" height="36"/> + <rect key="frame" x="260" y="0.0" width="44" height="36"/> + <accessibility key="accessibilityConfiguration" label="More menu"/> <constraints> <constraint firstAttribute="width" constant="44" id="NQj-8B-nY9"/> <constraint firstAttribute="height" constant="36" id="j12-ha-Xlw"/> @@ -25,20 +26,19 @@ </connections> </button> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="DATE" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eUE-Sb-m3L"> - <rect key="frame" x="16" y="0.0" width="32.5" height="36"/> + <rect key="frame" x="16" y="10" width="32.5" height="16"/> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> - <fontDescription key="fontDescription" type="system" pointSize="13"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> <color key="textColor" name="Gray50"/> <nil key="highlightedColor"/> </label> </subviews> <color key="backgroundColor" red="0.9137254901960784" green="0.93725490196078431" blue="0.95294117647058818" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <constraints> - <constraint firstAttribute="trailingMargin" secondItem="OEo-2z-Cmk" secondAttribute="trailing" constant="-13" id="EWN-SE-Ilb"/> - <constraint firstAttribute="bottom" secondItem="eUE-Sb-m3L" secondAttribute="bottom" id="ILT-ul-3i5"/> - <constraint firstItem="eUE-Sb-m3L" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leadingMargin" id="LH1-C3-ah6"/> + <constraint firstItem="eUE-Sb-m3L" firstAttribute="centerY" secondItem="OEo-2z-Cmk" secondAttribute="centerY" id="5cB-Im-fBT"/> + <constraint firstAttribute="leadingMargin" secondItem="eUE-Sb-m3L" secondAttribute="leading" id="Bi6-N7-44O"/> + <constraint firstAttribute="trailingMargin" secondItem="OEo-2z-Cmk" secondAttribute="trailing" id="EWN-SE-Ilb"/> <constraint firstAttribute="bottom" secondItem="OEo-2z-Cmk" secondAttribute="bottom" id="a35-Lf-TpM"/> - <constraint firstItem="eUE-Sb-m3L" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" id="oHh-OB-AWn"/> </constraints> <nil key="simulatedStatusBarMetrics"/> <nil key="simulatedTopBarMetrics"/> @@ -53,7 +53,7 @@ </objects> <resources> <namedColor name="Gray50"> - <color red="0.40392156862745099" green="0.41568627450980394" blue="0.45490196078431372" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <color red="0.39215686274509803" green="0.41176470588235292" blue="0.4392156862745098" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> </namedColor> </resources> </document> diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Dashboard.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Dashboard.swift new file mode 100644 index 000000000000..bc4381ad3ba9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Dashboard.swift @@ -0,0 +1,8 @@ +import Foundation + +extension BlogDetailsViewController { + + @objc func isDashboardEnabled() -> Bool { + return JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() && blog.isAccessibleThroughWPCom() + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+DomainCredit.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+DomainCredit.swift index fd5a0f364cab..9dd26e8fd6db 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+DomainCredit.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+DomainCredit.swift @@ -1,9 +1,10 @@ import Gridicons +import SwiftUI import WordPressFlux extension BlogDetailsViewController { @objc func domainCreditSectionViewModel() -> BlogDetailsSection { - let image = Gridicon.iconOfType(.info) + let image = UIImage.gridicon(.info) let row = BlogDetailsRow(title: NSLocalizedString("Register Domain", comment: "Action to redeem domain credit."), accessibilityIdentifier: "Register domain from site dashboard", image: image, @@ -20,23 +21,8 @@ extension BlogDetailsViewController { } @objc func showDomainCreditRedemption() { - guard let site = JetpackSiteRef(blog: blog) else { - DDLogError("Error: couldn't initialize `JetpackSiteRef` from blog with ID: \(blog.dotComID?.intValue ?? 0)") - let cancelActionTitle = NSLocalizedString( - "OK", - comment: "Title of an OK button. Pressing the button acknowledges and dismisses a prompt." - ) - let alertController = UIAlertController( - title: NSLocalizedString("Unable to register domain", comment: "Alert title when `JetpackSiteRef` cannot be initialized from a blog during domain credit redemption."), - message: NSLocalizedString("Something went wrong, please try again.", comment: "Alert message when `JetpackSiteRef` cannot be initialized from a blog during domain credit redemption."), - preferredStyle: .alert - ) - alertController.addCancelActionWithTitle(cancelActionTitle, handler: nil) - present(alertController, animated: true, completion: nil) - return - } let controller = RegisterDomainSuggestionsViewController - .instance(site: site, domainPurchasedCallback: { [weak self] domain in + .instance(site: blog, domainPurchasedCallback: { [weak self] domain in WPAnalytics.track(.domainCreditRedemptionSuccess) self?.presentDomainCreditRedemptionSuccess(domain: domain) }) @@ -45,28 +31,42 @@ extension BlogDetailsViewController { } private func presentDomainCreditRedemptionSuccess(domain: String) { - let controller = DomainCreditRedemptionSuccessViewController(domain: domain, delegate: self) - present(controller, animated: true, completion: nil) - } -} - -extension BlogDetailsViewController: DomainCreditRedemptionSuccessViewControllerDelegate { - func continueButtonPressed() { - dismiss(animated: true) { [weak self] in - guard let email = self?.accountEmail() else { - return + let controller = DomainCreditRedemptionSuccessViewController(domain: domain) { [weak self] _ in + self?.dismiss(animated: true) { + guard let email = self?.accountEmail() else { + return + } + let title = String(format: NSLocalizedString("Verify your email address - instructions sent to %@", comment: "Notice displayed after domain credit redemption success."), email) + ActionDispatcher.dispatch(NoticeAction.post(Notice(title: title))) + } + } + present(controller, animated: true) { [weak self] in + self?.updateTableView { + guard + let parent = self?.parent as? MySiteViewController, + let blog = self?.blog + else { + return + } + parent.sitePickerViewController?.blogDetailHeaderView.blog = blog } - let title = String(format: NSLocalizedString("Verify your email address - instructions sent to %@", comment: "Notice displayed after domain credit redemption success."), email) - ActionDispatcher.dispatch(NoticeAction.post(Notice(title: title))) } } private func accountEmail() -> String? { - let context = ContextManager.sharedInstance().mainContext - let accountService = AccountService(managedObjectContext: context) - guard let defaultAccount = accountService.defaultWordPressComAccount() else { + guard let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) else { return nil } return defaultAccount.email } } + +// MARK: - Domains Dashboard access from My Site +extension BlogDetailsViewController { + + @objc func makeDomainsDashboardViewController() -> UIViewController { + let viewController = UIHostingController(rootView: DomainsDashboardView(blog: self.blog)) + viewController.extendedLayoutIncludesOpaqueBars = true + return viewController + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+FancyAlerts.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+FancyAlerts.swift index fc3aaa2a95aa..bd82acc0aebb 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+FancyAlerts.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+FancyAlerts.swift @@ -4,17 +4,27 @@ private var alertWorkItem: DispatchWorkItem? private var observer: NSObjectProtocol? extension BlogDetailsViewController { + @objc func startObservingQuickStart() { observer = NotificationCenter.default.addObserver(forName: .QuickStartTourElementChangedNotification, object: nil, queue: nil) { [weak self] (notification) in guard self?.blog.managedObjectContext != nil else { return } - self?.refreshSiteIcon() self?.configureTableViewData() self?.reloadTableViewPreservingSelection() - if let index = QuickStartTourGuide.find()?.currentElementInt(), - let element = QuickStartTourElement(rawValue: index) { - self?.scroll(to: element) + + if let info = notification.userInfo?[QuickStartTourGuide.notificationElementKey] as? QuickStartTourElement { + switch info { + case .stats, .mediaScreen: + guard QuickStartTourGuide.shared.entryPointForCurrentTour == .blogDetails else { + return + } + fallthrough + case .pages, .sharing: + self?.scroll(to: info) + default: + break + } } } } @@ -24,12 +34,19 @@ extension BlogDetailsViewController { } @objc func startAlertTimer() { + guard shouldStartAlertTimer else { + return + } let newWorkItem = DispatchWorkItem { [weak self] in - self?.showNoticeOrAlertAsNeeded() + self?.showNoticeAsNeeded() } DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: newWorkItem) alertWorkItem = newWorkItem } + // do not start alert timer if the themes modal is still being presented + private var shouldStartAlertTimer: Bool { + !((self.presentedViewController as? UINavigationController)?.visibleViewController is WebKitViewController) + } @objc func stopAlertTimer() { alertWorkItem?.cancel() @@ -45,124 +62,71 @@ extension BlogDetailsViewController { return false } - private func showNoticeOrAlertAsNeeded() { - guard let tourGuide = QuickStartTourGuide.find() else { - showNotificationPrimerAlert() + private func showNoticeAsNeeded() { + let quickStartGuide = QuickStartTourGuide.shared + + guard let tourToSuggest = quickStartGuide.tourToSuggest(for: blog) else { + quickStartGuide.showCongratsNoticeIfNeeded(for: blog) return } - if tourGuide.shouldShowUpgradeToV2Notice(for: blog) { - showUpgradeToV2Alert(for: blog) - - tourGuide.didShowUpgradeToV2Notice(for: blog) - } else if let tourToSuggest = tourGuide.tourToSuggest(for: blog) { - tourGuide.suggest(tourToSuggest, for: blog) + if quickStartGuide.tourInProgress { + // If tour is in progress, show notice regardless of quickstart is shown in dashboard or my site + quickStartGuide.suggest(tourToSuggest, for: blog) } else { - showNotificationPrimerAlert() + guard shouldShowQuickStartChecklist() else { + return + } + // Show initial notice only if quick start is shown in my site + quickStartGuide.suggest(tourToSuggest, for: blog) } } - @objc func shouldShowQuickStartChecklist() -> Bool { - return QuickStartTourGuide.shouldShowChecklist(for: blog) - } - - @objc func showQuickStartCustomize() { - showQuickStart(with: .customize) - } - - @objc func showQuickStartGrow() { - showQuickStart(with: .grow) - } - - private func showQuickStart(with type: QuickStartType) { - let checklist = QuickStartChecklistViewController(blog: blog, type: type) - let navigationViewController = UINavigationController(rootViewController: checklist) - present(navigationViewController, animated: true, completion: nil) + @objc func shouldShowDashboard() -> Bool { + guard let parentVC = parent as? MySiteViewController, isDashboardEnabled() else { + return false + } - QuickStartTourGuide.find()?.visited(.checklist) + return parentVC.mySiteSettings.defaultSection == .dashboard } - @objc func quickStartSectionViewModel() -> BlogDetailsSection { - let detailFormatStr = NSLocalizedString("%1$d of %2$d completed", comment: "Format string for displaying number of compelted quickstart tutorials. %1$d is number completed, %2$d is total number of tutorials available.") + @objc func shouldShowQuickStartChecklist() -> Bool { + if isDashboardEnabled() { - let customizeRow = BlogDetailsRow(title: NSLocalizedString("Customize Your Site", comment: "Name of the Quick Start list that guides users through a few tasks to customize their new website."), - identifier: QuickStartListTitleCell.reuseIdentifier, - accessibilityIdentifier: "Customize Your Site Row", - image: Gridicon.iconOfType(.customize)) { [weak self] in - self?.showQuickStartCustomize() - } - customizeRow.quickStartIdentifier = .checklist - customizeRow.showsSelectionState = false - if let customizeDetailCount = QuickStartTourGuide.find()?.countChecklistCompleted(in: QuickStartTourGuide.customizeListTours, for: blog) { - customizeRow.detail = String(format: detailFormatStr, customizeDetailCount, QuickStartTourGuide.customizeListTours.count) - customizeRow.quickStartTitleState = customizeDetailCount == QuickStartTourGuide.customizeListTours.count ? .completed : .customizeIncomplete - } + guard let parentVC = parent as? MySiteViewController else { + return false + } - let growRow = BlogDetailsRow(title: NSLocalizedString("Grow Your Audience", comment: "Name of the Quick Start list that guides users through a few tasks to customize their new website."), - identifier: QuickStartListTitleCell.reuseIdentifier, - accessibilityIdentifier: "Grow Your Audience Row", - image: Gridicon.iconOfType(.multipleUsers)) { [weak self] in - self?.showQuickStartGrow() - } - growRow.quickStartIdentifier = .checklist - growRow.showsSelectionState = false - if let growDetailCount = QuickStartTourGuide.find()?.countChecklistCompleted(in: QuickStartTourGuide.growListTours, for: blog) { - growRow.detail = String(format: detailFormatStr, growDetailCount, QuickStartTourGuide.growListTours.count) - growRow.quickStartTitleState = growDetailCount == QuickStartTourGuide.growListTours.count ? .completed : .growIncomplete + return QuickStartTourGuide.quickStartEnabled(for: blog) && parentVC.mySiteSettings.defaultSection == .siteMenu } - let sectionTitle = NSLocalizedString("Next Steps", comment: "Table view title for the quick start section.") - let section = BlogDetailsSection(title: sectionTitle, andRows: [customizeRow, growRow], category: .quickStart) - section.showQuickStartMenu = true - return section + return QuickStartTourGuide.quickStartEnabled(for: blog) } - private func showNotificationPrimerAlert() { - guard noPresentedViewControllers else { + @objc func showQuickStart() { + let currentCollections = QuickStartFactory.collections(for: blog) + guard let collectionToShow = currentCollections.first else { return } + let checklist = QuickStartChecklistViewController(blog: blog, collection: collectionToShow) + let navigationViewController = UINavigationController(rootViewController: checklist) + present(navigationViewController, animated: true) - guard !UserDefaults.standard.notificationPrimerAlertWasDisplayed else { - return - } - - let mainContext = ContextManager.shared.mainContext - let accountService = AccountService(managedObjectContext: mainContext) - - guard accountService.defaultWordPressComAccount() != nil else { - return - } - - PushNotificationsManager.shared.loadAuthorizationStatus { [weak self] (enabled) in - guard enabled == .notDetermined else { - return - } - - UserDefaults.standard.notificationPrimerAlertWasDisplayed = true + QuickStartTourGuide.shared.visited(.checklist) - let alert = FancyAlertViewController.makeNotificationPrimerAlertController { (controller) in - InteractiveNotificationsManager.shared.requestAuthorization { - DispatchQueue.main.async { - controller.dismiss(animated: true) - } - } - } - alert.modalPresentationStyle = .custom - alert.transitioningDelegate = self - self?.tabBarController?.present(alert, animated: true) - } + createButtonCoordinator?.hideCreateButtonTooltip() } - 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) + @objc func quickStartSectionViewModel() -> BlogDetailsSection { + let row = BlogDetailsRow() + row.callback = {} - WPAnalytics.track(.quickStartMigrationDialogViewed) + let sectionTitle = NSLocalizedString("Next Steps", comment: "Table view title for the quick start section.") + let section = BlogDetailsSection(title: sectionTitle, + rows: [row], + footerTitle: nil, + category: .quickStart) + section.showQuickStartMenu = true + return section } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Header.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Header.swift deleted file mode 100644 index 19f8b206abc3..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Header.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Gridicons - -extension BlogDetailsViewController { - @objc func configureHeaderView() -> NewBlogDetailHeaderView { - let headerView = NewBlogDetailHeaderView(items: [ - ActionRow.Item(image: Gridicon.iconOfType(.statsAlt), title: NSLocalizedString("Stats", comment: "Noun. Abbv. of Statistics. Links to a blog's Stats screen.")) { [weak self] in - self?.tableView.deselectSelectedRowWithAnimation(false) - self?.showStats(from: .button) - }, - ActionRow.Item(image: Gridicon.iconOfType(.pages), title: NSLocalizedString("Pages", comment: "Noun. Title. Links to the blog's Pages screen.")) { [weak self] in - self?.tableView.deselectSelectedRowWithAnimation(false) - self?.showPageList(from: .button) - }, - ActionRow.Item(image: Gridicon.iconOfType(.posts), title: NSLocalizedString("Posts", comment: "Noun. Title. Links to the blog's Posts screen.")) { [weak self] in - self?.tableView.deselectSelectedRowWithAnimation(false) - self?.showPostList(from: .button) - }, - ActionRow.Item(image: Gridicon.iconOfType(.image), title: NSLocalizedString("Media", comment: "Noun. Title. Links to the blog's Media library.")) { [weak self] in - self?.tableView.deselectSelectedRowWithAnimation(false) - self?.showMediaLibrary(from: .button) - } - ]) - return headerView - } -} - -/// A protocol to temporarily share implementations between `BlogDetailHeaderView` and `NewBlogDetailHeaderView` -@objc protocol BlogDetailHeader where Self: UIView { - var blog: Blog? { get set } - var updatingIcon: Bool { get set } - @objc var blavatarImageView: UIImageView { get } - func refreshIconImage() -} - -extension NewBlogDetailHeaderView: BlogDetailHeader { -} - -extension BlogDetailHeaderView: BlogDetailHeader { -} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+QuickActions.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+QuickActions.swift new file mode 100644 index 000000000000..3be444542c2f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+QuickActions.swift @@ -0,0 +1,86 @@ +import UIKit + +// TODO: Consider completely removing all Quick Action logic +extension BlogDetailsViewController { + + @objc func quickActionsSectionViewModel() -> BlogDetailsSection { + let row = BlogDetailsRow() + row.callback = {} + return BlogDetailsSection(title: nil, + rows: [row], + footerTitle: nil, + category: .quickAction) + } + + @objc func isAccessibilityCategoryEnabled() -> Bool { + tableView.traitCollection.preferredContentSizeCategory.isAccessibilityCategory + } + + @objc func configureQuickActions(cell: QuickActionsCell) { + let actionItems = createActionItems() + + cell.configure(with: actionItems) + } + + private func createActionItems() -> [ActionRow.Item] { + let actionItems: [ActionRow.Item] = [ + .init(image: .gridicon(.statsAlt), title: NSLocalizedString("Stats", comment: "Noun. Abbv. of Statistics. Links to a blog's Stats screen.")) { [weak self] in + self?.tableView.deselectSelectedRowWithAnimation(false) + self?.showStats(from: .button) + }, + .init(image: .gridicon(.posts), title: NSLocalizedString("Posts", comment: "Noun. Title. Links to the blog's Posts screen.")) { [weak self] in + self?.tableView.deselectSelectedRowWithAnimation(false) + self?.showPostList(from: .button) + }, + .init(image: .gridicon(.image), title: NSLocalizedString("Media", comment: "Noun. Title. Links to the blog's Media library.")) { [weak self] in + self?.tableView.deselectSelectedRowWithAnimation(false) + self?.showMediaLibrary(from: .button) + }, + .init(image: .gridicon(.pages), title: NSLocalizedString("Pages", comment: "Noun. Title. Links to the blog's Pages screen.")) { [weak self] in + self?.tableView.deselectSelectedRowWithAnimation(false) + self?.showPageList(from: .button) + } + ] + + return actionItems + } +} + +@objc class QuickActionsCell: UITableViewCell { + private var actionRow: ActionRow! + + func configure(with items: [ActionRow.Item]) { + guard actionRow == nil else { + return + } + + actionRow = ActionRow(items: items) + contentView.addSubview(actionRow) + + setupConstraints() + setupCell() + } + + private func setupConstraints() { + actionRow.translatesAutoresizingMaskIntoConstraints = false + + let widthConstraint = actionRow.widthAnchor.constraint(equalToConstant: Constants.maxQuickActionsWidth) + widthConstraint.priority = .defaultHigh + + NSLayoutConstraint.activate([ + actionRow.topAnchor.constraint(equalTo: contentView.topAnchor), + actionRow.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + actionRow.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.leadingAnchor), + actionRow.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + widthConstraint + ]) + } + + private func setupCell() { + selectionStyle = .none + } + + private enum Constants { + static let maxQuickActionsWidth: CGFloat = 390 + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift index 2a2a4acceee0..509f5a57ff75 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+SectionHelpers.swift @@ -5,32 +5,116 @@ extension Array where Element: BlogDetailsSection { } extension BlogDetailsSubsection { - fileprivate var sectionCategory: BlogDetailsSectionCategory { + func sectionCategory(for blog: Blog) -> BlogDetailsSectionCategory { switch self { case .domainCredit: return .domainCredit case .quickStart: return .quickStart - case .stats, .activity: + case .activity, .jetpackSettings: + return .jetpack + case .stats where blog.shouldShowJetpackSection: + return .jetpack + case .stats where !blog.shouldShowJetpackSection: return .general case .pages, .posts, .media, .comments: - return .publish + return .content case .themes, .customize: return .personalize case .sharing, .people, .plugins: return .configure - @unknown default: + case .home: + return .home + default: fatalError() } } } extension BlogDetailsViewController { + + @objc class func mySitesCoordinator() -> MySitesCoordinator { + RootViewCoordinator.sharedPresenter.mySitesCoordinator + } + @objc func findSectionIndex(sections: [BlogDetailsSection], category: BlogDetailsSectionCategory) -> Int { return sections.findSectionIndex(of: category) ?? NSNotFound } - @objc func sectionCategory(subsection: BlogDetailsSubsection) -> BlogDetailsSectionCategory { - return subsection.sectionCategory + @objc func sectionCategory(subsection: BlogDetailsSubsection, blog: Blog) -> BlogDetailsSectionCategory { + return subsection.sectionCategory(for: blog) + } + + @objc func defaultSubsection() -> BlogDetailsSubsection { + if !JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() { + return .posts + } + if shouldShowDashboard() { + return .home + } + return .stats + } + + @objc func shouldShowStats() -> Bool { + return JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() + } + + /// Convenience method that returns the view controller for Stats based on the features removal state. + /// + /// - Returns: Either the actual Stats view, or the static poster for Stats. + @objc func viewControllerForStats() -> UIViewController { + guard shouldShowStats() else { + return MovedToJetpackViewController(source: .stats) + } + + let statsView = StatsViewController() + statsView.blog = blog + statsView.navigationItem.largeTitleDisplayMode = .never + return statsView + } + + @objc func shouldAddJetpackSection() -> Bool { + guard JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures() else { + return false + } + return blog.shouldShowJetpackSection + } + + @objc func shouldAddGeneralSection() -> Bool { + guard JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures() else { + return false + } + return blog.shouldShowJetpackSection == false + } + + @objc func shouldAddPersonalizeSection() -> Bool { + guard JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures() else { + return false + } + return blog.supports(.themeBrowsing) || blog.supports(.menus) + } + + @objc func shouldAddSharingRow() -> Bool { + guard JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures() else { + return false + } + return blog.supports(.sharing) + } + + @objc func shouldAddPeopleRow() -> Bool { + guard JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures() else { + return false + } + return blog.supports(.people) + } + + @objc func shouldAddPluginsRow() -> Bool { + return blog.supports(.pluginManagement) + } + + @objc func shouldAddDomainRegistrationRow() -> Bool { + return FeatureFlag.domains.enabled + && AppConfiguration.allowsDomainRegistration + && blog.supports(.domains) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Strings.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Strings.swift new file mode 100644 index 000000000000..653d413d6118 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Strings.swift @@ -0,0 +1,40 @@ +import Foundation + +// MARK: - Swift Interface + +extension BlogDetailsViewController { + + enum Strings { + static let contentSectionTitle = NSLocalizedString( + "my-site.menu.content.section.title", + value: "Content", + comment: "Section title for the content table section in the blog details screen" + ) + static let trafficSectionTitle = NSLocalizedString( + "my-site.menu.traffic.section.title", + value: "Traffic", + comment: "Section title for the traffic table section in the blog details screen" + ) + static let maintenanceSectionTitle = NSLocalizedString( + "my-site.menu.maintenance.section.title", + value: "Maintenance", + comment: "Section title for the maintenance table section in the blog details screen" + ) + static let socialRowTitle = NSLocalizedString( + "my-site.menu.social.row.title", + value: "Social", + comment: "Title for the social row in the blog details screen" + ) + } +} + +// MARK: - Objective-C Interface + +@objc(BlogDetailsViewControllerStrings) +class objc_BlogDetailsViewController_Strings: NSObject { + + @objc class func contentSectionTitle() -> String { BlogDetailsViewController.Strings.contentSectionTitle } + @objc class func trafficSectionTitle() -> String { BlogDetailsViewController.Strings.trafficSectionTitle } + @objc class func maintenanceSectionTitle() -> String { BlogDetailsViewController.Strings.maintenanceSectionTitle } + @objc class func socialRowTitle() -> String { BlogDetailsViewController.Strings.socialRowTitle } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h index 9ba0e9b6f315..39750dd53f4e 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.h @@ -1,19 +1,33 @@ #import <UIKit/UIKit.h> @class Blog; +@class BlogDetailHeaderView; +@class CreateButtonCoordinator; +@class IntrinsicTableView; +@protocol BlogDetailHeader; typedef NS_ENUM(NSUInteger, BlogDetailsSectionCategory) { + BlogDetailsSectionCategoryQuickAction, + BlogDetailsSectionCategoryReminders, BlogDetailsSectionCategoryDomainCredit, BlogDetailsSectionCategoryQuickStart, + BlogDetailsSectionCategoryHome, BlogDetailsSectionCategoryGeneral, - BlogDetailsSectionCategoryPublish, + BlogDetailsSectionCategoryJetpack, BlogDetailsSectionCategoryPersonalize, BlogDetailsSectionCategoryConfigure, BlogDetailsSectionCategoryExternal, - BlogDetailsSectionCategoryRemoveSite + BlogDetailsSectionCategoryRemoveSite, + BlogDetailsSectionCategoryMigrationSuccess, + BlogDetailsSectionCategoryJetpackBrandingCard, + BlogDetailsSectionCategoryJetpackInstallCard, + BlogDetailsSectionCategoryContent, + BlogDetailsSectionCategoryTraffic, + BlogDetailsSectionCategoryMaintenance }; typedef NS_ENUM(NSUInteger, BlogDetailsSubsection) { + BlogDetailsSubsectionReminders, BlogDetailsSubsectionDomainCredit, BlogDetailsSubsectionQuickStart, BlogDetailsSubsectionStats, @@ -23,10 +37,15 @@ typedef NS_ENUM(NSUInteger, BlogDetailsSubsection) { BlogDetailsSubsectionMedia, BlogDetailsSubsectionPages, BlogDetailsSubsectionActivity, + BlogDetailsSubsectionJetpackSettings, BlogDetailsSubsectionComments, BlogDetailsSubsectionSharing, BlogDetailsSubsectionPeople, - BlogDetailsSubsectionPlugins + BlogDetailsSubsectionPlugins, + BlogDetailsSubsectionHome, + BlogDetailsSubsectionMigrationSuccess, + BlogDetailsSubsectionJetpackBrandingCard, + BlogDetailsSubsectionBlaze, }; @@ -49,8 +68,7 @@ typedef NS_ENUM(NSInteger, QuickStartTourElement) { QuickStartTourElementSharing = 8, QuickStartTourElementConnections = 9, QuickStartTourElementReaderTab = 10, - QuickStartTourElementReaderBack = 11, - QuickStartTourElementReaderSearch = 12, + QuickStartTourElementReaderDiscoverSettings = 12, QuickStartTourElementTourCompleted = 13, QuickStartTourElementCongratulations = 14, QuickStartTourElementSiteIcon = 15, @@ -58,6 +76,13 @@ typedef NS_ENUM(NSInteger, QuickStartTourElement) { QuickStartTourElementNewPage = 17, QuickStartTourElementStats = 18, QuickStartTourElementPlans = 19, + QuickStartTourElementSiteTitle = 20, + QuickStartTourElementSiteMenu = 21, + QuickStartTourElementNotifications = 22, + QuickStartTourElementSetupQuickStart = 23, + QuickStartTourElementUpdateQuickStart = 24, + QuickStartTourElementMediaScreen = 25, + QuickStartTourElementMediaUpload = 26, }; typedef NS_ENUM(NSUInteger, BlogDetailsNavigationSource) { @@ -86,8 +111,9 @@ typedef NS_ENUM(NSUInteger, BlogDetailsNavigationSource) { @property (nonatomic, strong, nonnull) NSString *title; @property (nonatomic, strong, nonnull) NSString *identifier; @property (nonatomic, strong, nullable) NSString *accessibilityIdentifier; +@property (nonatomic, strong, nullable) NSString *accessibilityHint; @property (nonatomic, strong, nonnull) UIImage *image; -@property (nonatomic, strong, nonnull) UIColor *imageColor; +@property (nonatomic, strong, nullable) UIColor *imageColor; @property (nonatomic, strong, nullable) UIView *accessoryView; @property (nonatomic, strong, nullable) NSString *detail; @property (nonatomic) BOOL showsSelectionState; @@ -98,26 +124,49 @@ typedef NS_ENUM(NSUInteger, BlogDetailsNavigationSource) { @property (nonatomic) QuickStartTitleState quickStartTitleState; - (instancetype _Nonnull)initWithTitle:(NSString * __nonnull)title - identifier:(NSString * __nonnull)identifier - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - image:(UIImage * __nonnull)image - callback:(void(^_Nullable)(void))callback; + identifier:(NSString * __nonnull)identifier + accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier + image:(UIImage * __nonnull)image + callback:(void(^_Nullable)(void))callback; + +- (instancetype _Nonnull)initWithTitle:(NSString * __nonnull)title + identifier:(NSString * __nonnull)identifier + accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier + accessibilityHint:(NSString *__nullable)accessibilityHint + image:(UIImage * __nonnull)image + callback:(void(^_Nullable)(void))callback; + - (instancetype _Nonnull)initWithTitle:(NSString * __nonnull)title accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier image:(UIImage * __nonnull)image - imageColor:(UIColor * __nonnull)imageColor + imageColor:(UIColor * __nullable)imageColor + callback:(void(^_Nullable)(void))callback; + +- (instancetype _Nonnull)initWithTitle:(NSString * __nonnull)title + accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier + image:(UIImage * __nonnull)image + imageColor:(UIColor * __nullable)imageColor + renderingMode:(UIImageRenderingMode)renderingMode callback:(void(^_Nullable)(void))callback; @end @protocol ScenePresenter; -@interface BlogDetailsViewController : UITableViewController <UIViewControllerRestoration, UIViewControllerTransitioningDelegate> { +@protocol BlogDetailsPresentationDelegate +- (void)presentBlogDetailsViewController:(UIViewController * __nonnull)viewController; +@end + +@interface BlogDetailsViewController : UIViewController <UIViewControllerRestoration, UIViewControllerTransitioningDelegate> { } @property (nonatomic, strong, nonnull) Blog * blog; @property (nonatomic, strong) id<ScenePresenter> _Nonnull meScenePresenter; +@property (nonatomic, strong, readonly) CreateButtonCoordinator * _Nullable createButtonCoordinator; +@property (nonatomic, strong, readwrite) UITableView * _Nonnull tableView; +@property (nonatomic) BOOL shouldScrollToViewSite; +@property (nonatomic, weak, nullable) id<BlogDetailsPresentationDelegate> presentationDelegate; - (id _Nonnull)initWithMeScenePresenter:(id<ScenePresenter> _Nonnull)meScenePresenter; - (void)showDetailViewForSubsection:(BlogDetailsSubsection)section; @@ -125,10 +174,13 @@ typedef NS_ENUM(NSUInteger, BlogDetailsNavigationSource) { - (void)configureTableViewData; - (void)scrollToElement:(QuickStartTourElement)element; +- (void)switchToBlog:(nonnull Blog *)blog; +- (void)showInitialDetailsForBlog; - (void)showPostListFromSource:(BlogDetailsNavigationSource)source; - (void)showPageListFromSource:(BlogDetailsNavigationSource)source; - (void)showMediaLibraryFromSource:(BlogDetailsNavigationSource)source; -- (void)showStatsFromSource:(BlogDetailsNavigationSource)sourc;; -- (void)refreshSiteIcon; - +- (void)showStatsFromSource:(BlogDetailsNavigationSource)source; +- (void)updateTableView:(nullable void(^)(void))completion; +- (void)preloadMetadata; +- (void)pulledToRefreshWith:(nonnull UIRefreshControl *)refreshControl onCompletion:(nullable void(^)(void))completion; @end diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m index 7d0013fdaa0d..54c490dafafb 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController.m @@ -2,9 +2,8 @@ #import "AccountService.h" #import "BlogService.h" -#import "BlogDetailHeaderView.h" #import "CommentsViewController.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "ReachabilityUtils.h" #import "SiteSettingsViewController.h" #import "SharingViewController.h" @@ -14,6 +13,7 @@ #import "WPGUIConstants.h" #import "WordPress-Swift.h" #import "MenusViewController.h" +#import "UIViewController+RemoveQuickStart.h" #import <Reachability/Reachability.h> #import <WordPressShared/WPTableViewCell.h> @@ -23,22 +23,31 @@ static NSString *const BlogDetailsPlanCellIdentifier = @"BlogDetailsPlanCell"; static NSString *const BlogDetailsSettingsCellIdentifier = @"BlogDetailsSettingsCell"; static NSString *const BlogDetailsRemoveSiteCellIdentifier = @"BlogDetailsRemoveSiteCell"; +static NSString *const BlogDetailsQuickActionsCellIdentifier = @"BlogDetailsQuickActionsCell"; static NSString *const BlogDetailsSectionHeaderViewIdentifier = @"BlogDetailsSectionHeaderView"; static NSString *const QuickStartHeaderViewNibName = @"BlogDetailsSectionHeaderView"; -static NSString *const QuickStartListTitleCellNibName = @"QuickStartListTitleCell"; +static NSString *const BlogDetailsQuickStartCellIdentifier = @"BlogDetailsQuickStartCell"; static NSString *const BlogDetailsSectionFooterIdentifier = @"BlogDetailsSectionFooterView"; +static NSString *const BlogDetailsMigrationSuccessCellIdentifier = @"BlogDetailsMigrationSuccessCell"; +static NSString *const BlogDetailsJetpackBrandingCardCellIdentifier = @"BlogDetailsJetpackBrandingCardCellIdentifier"; +static NSString *const BlogDetailsJetpackInstallCardCellIdentifier = @"BlogDetailsJetpackInstallCardCellIdentifier"; NSString * const WPBlogDetailsRestorationID = @"WPBlogDetailsID"; NSString * const WPBlogDetailsBlogKey = @"WPBlogDetailsBlogKey"; NSString * const WPBlogDetailsSelectedIndexPathKey = @"WPBlogDetailsSelectedIndexPathKey"; -NSInteger const BlogDetailHeaderViewVerticalMargin = 18; +CGFloat const BlogDetailGridiconSize = 24.0; CGFloat const BlogDetailGridiconAccessorySize = 17.0; -CGFloat const BlogDetailBottomPaddingForQuickStartNotices = 80.0; -CGFloat const BlogDetailQuickStartSectionHeight = 35.0; +CGFloat const BlogDetailQuickStartSectionHeaderHeight = 48.0; +CGFloat const BlogDetailSectionTitleHeaderHeight = 40.0; +CGFloat const BlogDetailSectionsSpacing = 20.0; +CGFloat const BlogDetailSectionFooterHeight = 40.0; NSTimeInterval const PreloadingCacheTimeout = 60.0 * 5; // 5 minutes NSString * const HideWPAdminDate = @"2015-09-07T00:00:00Z"; +CGFloat const BlogDetailReminderSectionHeaderHeight = 8.0; +CGFloat const BlogDetailReminderSectionFooterHeight = 1.0; + // NOTE: Currently "stats" acts as the calypso dashboard with a redirect to // stats/insights. Per @mtias, if the dashboard should change at some point the // redirect will be updated to point to new content, eventhough the path is still @@ -83,6 +92,51 @@ - (instancetype)initWithTitle:(NSString * __nonnull)title image:image callback:callback]; } + +- (instancetype)initWithTitle:(NSString * __nonnull)title + identifier:(NSString * __nonnull)identifier + accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier + image:(UIImage * __nonnull)image + callback:(void(^)(void))callback +{ + return [self initWithTitle:title + identifier:identifier + accessibilityIdentifier:accessibilityIdentifier + accessibilityHint:nil + image:image + callback:callback]; +} + +- (instancetype)initWithTitle:(NSString * __nonnull)title + identifier:(NSString * __nonnull)identifier + accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier + accessibilityHint:(NSString *__nullable)accessibilityHint + image:(UIImage * __nonnull)image + callback:(void(^)(void))callback +{ + return [self initWithTitle:title + identifier:identifier + accessibilityIdentifier:accessibilityIdentifier + accessibilityHint:accessibilityHint + image:image + imageColor:[UIColor murielListIcon] + renderingMode:UIImageRenderingModeAlwaysTemplate + callback:callback]; +} + +- (instancetype)initWithTitle:(NSString * __nonnull)title + accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier + accessibilityHint:(NSString *__nullable)accessibilityHint + image:(UIImage * __nonnull)image + callback:(void(^)(void))callback +{ + return [self initWithTitle:title + identifier:BlogDetailsCellIdentifier + accessibilityIdentifier:accessibilityIdentifier + accessibilityHint:accessibilityHint + image:image + callback:callback]; +} - (instancetype)initWithTitle:(NSString *)title accessibilityIdentifier:(NSString *)accessibilityIdentifier @@ -93,40 +147,66 @@ - (instancetype)initWithTitle:(NSString *)title return [self initWithTitle:title identifier:BlogDetailsCellIdentifier accessibilityIdentifier:accessibilityIdentifier + accessibilityHint:nil image:image imageColor:imageColor + renderingMode:UIImageRenderingModeAlwaysTemplate callback:callback]; } -- (instancetype)initWithTitle:(NSString * __nonnull)title - identifier:(NSString * __nonnull)identifier - accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier - image:(UIImage * __nonnull)image - callback:(void(^)(void))callback +- (instancetype)initWithTitle:(NSString *)title + accessibilityIdentifier:(NSString *)accessibilityIdentifier + image:(UIImage *)image + imageColor:(UIColor *)imageColor + renderingMode:(UIImageRenderingMode)renderingMode + callback:(void (^)(void))callback { return [self initWithTitle:title - identifier:identifier + identifier:BlogDetailsCellIdentifier accessibilityIdentifier:accessibilityIdentifier + accessibilityHint:nil image:image - imageColor:[UIColor murielListIcon] + imageColor:imageColor + renderingMode:renderingMode callback:callback]; } - + + +- (instancetype)initWithTitle:(NSString * __nonnull)title + accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier + accessibilityHint:(NSString * __nullable)accessibilityHint + image:(UIImage * __nonnull)image + imageColor:(UIColor * __nullable)imageColor + callback:(void(^_Nullable)(void))callback +{ + return [self initWithTitle:title + identifier:BlogDetailsCellIdentifier + accessibilityIdentifier:accessibilityIdentifier + accessibilityHint:nil + image:image + imageColor:imageColor + renderingMode:UIImageRenderingModeAlwaysTemplate + callback:callback]; +} + - (instancetype)initWithTitle:(NSString * __nonnull)title identifier:(NSString * __nonnull)identifier accessibilityIdentifier:(NSString *__nullable)accessibilityIdentifier + accessibilityHint:(NSString *__nullable)accessibilityHint image:(UIImage * __nonnull)image - imageColor:(UIColor * __nonnull)imageColor + imageColor:(UIColor * __nullable)imageColor + renderingMode:(UIImageRenderingMode)renderingMode callback:(void(^)(void))callback { self = [super init]; if (self) { _title = title; - _image = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + _image = [image imageWithRenderingMode:renderingMode]; _imageColor = imageColor; _callback = callback; _identifier = identifier; _accessibilityIdentifier = accessibilityIdentifier; + _accessibilityHint = accessibilityHint; _showsSelectionState = YES; _showsDisclosureIndicator = YES; } @@ -161,9 +241,8 @@ - (instancetype)initWithTitle:(NSString *)title #pragma mark - -@interface BlogDetailsViewController () <UIActionSheetDelegate, UIAlertViewDelegate, WPSplitViewControllerDetailProvider, BlogDetailHeaderViewDelegate> +@interface BlogDetailsViewController () <UIActionSheetDelegate, UIAlertViewDelegate, WPSplitViewControllerDetailProvider, UITableViewDelegate, UITableViewDataSource> -@property (nonatomic, strong) UIView<BlogDetailHeader> *headerView; @property (nonatomic, strong) NSArray *headerViewHorizontalConstraints; @property (nonatomic, strong) NSArray<BlogDetailsSection *> *tableSections; @property (nonatomic, strong) BlogService *blogService; @@ -205,7 +284,7 @@ + (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)ide // If there's already a blog details view controller for this blog in the primary // navigation stack, we'll return that instead of creating a new one. - UISplitViewController *splitViewController = [[WPTabBarController sharedInstance] blogListSplitViewController]; + UISplitViewController *splitViewController = [self mySitesCoordinator].splitViewController; UINavigationController *navigationController = splitViewController.viewControllers.firstObject; if (navigationController && [navigationController isKindOfClass:[UINavigationController class]]) { BlogDetailsViewController *topViewController = (BlogDetailsViewController *)navigationController.topViewController; @@ -214,7 +293,7 @@ + (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)ide } } - BlogDetailsViewController *viewController = [[self alloc] initWithStyle:UITableViewStyleGrouped]; + BlogDetailsViewController *viewController = [[self alloc] init]; viewController.blog = restoredBlog; return viewController; @@ -253,28 +332,41 @@ - (void)dealloc [self stopObservingQuickStart]; } -- (id)initWithMeScenePresenter:(id<ScenePresenter>)meScenePresenter +- (instancetype)initWithMeScenePresenter:(id<ScenePresenter>)meScenePresenter { self = [super init]; - self.meScenePresenter = meScenePresenter; - return self; -} - -- (id)initWithStyle:(UITableViewStyle)style -{ - self = [super initWithStyle:UITableViewStyleGrouped]; + if (self) { self.restorationIdentifier = WPBlogDetailsRestorationID; self.restorationClass = [self class]; + _meScenePresenter = meScenePresenter; } + return self; } +- (instancetype)init +{ + return [self initWithMeScenePresenter:[MeScenePresenter new]]; +} + - (void)viewDidLoad { [super viewDidLoad]; + + _tableView = [[IntrinsicTableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped]; + self.tableView.scrollEnabled = false; + self.tableView.delegate = self; + self.tableView.dataSource = self; + self.tableView.translatesAutoresizingMaskIntoConstraints = false; + [self.view addSubview:self.tableView]; + [self.view pinSubviewToAllEdges:self.tableView]; + + UIRefreshControl *refreshControl = [UIRefreshControl new]; + [refreshControl addTarget:self action:@selector(pulledToRefresh) forControlEvents:UIControlEventValueChanged]; + self.tableView.refreshControl = refreshControl; - self.view.accessibilityIdentifier = @"Blog Details Table"; + self.tableView.accessibilityIdentifier = @"Blog Details Table"; [WPStyleGuide configureColorsForView:self.view andTableView:self.tableView]; [WPStyleGuide configureAutomaticHeightRowsFor:self.tableView]; @@ -283,76 +375,45 @@ - (void)viewDidLoad [self.tableView registerClass:[WPTableViewCellValue1 class] forCellReuseIdentifier:BlogDetailsPlanCellIdentifier]; [self.tableView registerClass:[WPTableViewCellValue1 class] forCellReuseIdentifier:BlogDetailsSettingsCellIdentifier]; [self.tableView registerClass:[WPTableViewCell class] forCellReuseIdentifier:BlogDetailsRemoveSiteCellIdentifier]; - UINib *qsHeaderViewNib = [UINib nibWithNibName:QuickStartHeaderViewNibName bundle:[NSBundle bundleForClass:[QuickStartListTitleCell class]]]; + [self.tableView registerClass:[QuickActionsCell class] forCellReuseIdentifier:BlogDetailsQuickActionsCellIdentifier]; + UINib *qsHeaderViewNib = [UINib nibWithNibName:QuickStartHeaderViewNibName bundle:[NSBundle mainBundle]]; [self.tableView registerNib:qsHeaderViewNib forHeaderFooterViewReuseIdentifier:BlogDetailsSectionHeaderViewIdentifier]; - UINib *qsTitleCellNib = [UINib nibWithNibName:QuickStartListTitleCellNibName bundle:[NSBundle bundleForClass:[QuickStartListTitleCell class]]]; - [self.tableView registerNib:qsTitleCellNib forCellReuseIdentifier:[QuickStartListTitleCell reuseIdentifier]]; + [self.tableView registerClass:[QuickStartCell class] forCellReuseIdentifier:BlogDetailsQuickStartCellIdentifier]; [self.tableView registerClass:[BlogDetailsSectionFooterView class] forHeaderFooterViewReuseIdentifier:BlogDetailsSectionFooterIdentifier]; + [self.tableView registerClass:[MigrationSuccessCell class] forCellReuseIdentifier:BlogDetailsMigrationSuccessCellIdentifier]; + [self.tableView registerClass:[JetpackBrandingMenuCardCell class] forCellReuseIdentifier:BlogDetailsJetpackBrandingCardCellIdentifier]; + [self.tableView registerClass:[JetpackRemoteInstallTableViewCell class] forCellReuseIdentifier:BlogDetailsJetpackInstallCardCellIdentifier]; - self.clearsSelectionOnViewWillAppear = NO; self.hasLoggedDomainCreditPromptShownEvent = NO; - __weak __typeof(self) weakSelf = self; NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - self.blogService = [[BlogService alloc] initWithManagedObjectContext:context]; - [self.blogService syncBlogAndAllMetadata:_blog - completionHandler:^{ - [weakSelf configureTableViewData]; - [weakSelf reloadTableViewPreservingSelection]; - }]; + self.blogService = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + [self preloadMetadata]; + if (self.blog.account && !self.blog.account.userID) { // User's who upgrade may not have a userID recorded. - AccountService *acctService = [[AccountService alloc] initWithManagedObjectContext:context]; + AccountService *acctService = [[AccountService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [acctService updateUserDetailsForAccount:self.blog.account success:nil failure:nil]; } - + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleDataModelChange:) name:NSManagedObjectContextObjectsDidChangeNotification object:context]; - [self configureBlogDetailHeader]; - [self.headerView setBlog:_blog]; [self startObservingQuickStart]; - if([Feature enabled:FeatureFlagMeMove]) { - [self addMeButtonToNavigationBar]; - } -} - -/// Resizes the `tableHeaderView` as necessary whenever its size changes. -- (void)viewDidLayoutSubviews { - [super viewDidLayoutSubviews]; - - if ([Feature enabled:FeatureFlagQuickActions]) { - UIView *headerView = self.tableView.tableHeaderView; - - CGSize size = [self.tableView.tableHeaderView systemLayoutSizeFittingSize:UILayoutFittingCompressedSize]; - if (headerView.frame.size.height != size.height) { - headerView.frame = CGRectMake(headerView.frame.origin.x, headerView.frame.origin.y, headerView.frame.size.width, size.height); - - self.tableView.tableHeaderView = headerView; - } - } + [self addMeButtonToNavigationBarWithEmail:self.blog.account.email meScenePresenter:self.meScenePresenter]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; - - if ([[QuickStartTourGuide find] currentElementInt] != NSNotFound) { - self.additionalSafeAreaInsets = UIEdgeInsetsMake(0, 0, BlogDetailBottomPaddingForQuickStartNotices, 0); - } else { - self.additionalSafeAreaInsets = UIEdgeInsetsZero; - } - + if (self.splitViewControllerIsHorizontallyCompact) { - [self animateDeselectionInteractively]; self.restorableSelectedIndexPath = nil; } - self.navigationItem.title = self.blog.settings.name; - - [self.headerView setBlog:self.blog]; + self.navigationItem.title = NSLocalizedString(@"My Site", @"Title of My Site tab"); // Configure and reload table data when appearing to ensure pending comment count is updated [self configureTableViewData]; @@ -364,20 +425,35 @@ - (void)viewWillAppear:(BOOL)animated - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; - if ([self.tabBarController isKindOfClass:[WPTabBarController class]]) { - [((WPTabBarController *)self.tabBarController).createButtonCoordinator showCreateButton]; - } [self createUserActivity]; [self startAlertTimer]; + + QuickStartTourGuide *tourGuide = [QuickStartTourGuide shared]; + + // Visiting the site menu element in viewDidAppear ensures that this view controller is visible when the next step + // in the tour is triggered. We want to avoid a situation where the next step in the tour is executed while this + // view controller isn't visible yet, as this can cause issues with scrolling to the correct quick start element. + if ([tourGuide currentElementInt] == QuickStartTourElementSiteMenu) { + [tourGuide visited: QuickStartTourElementSiteMenu]; + } + + tourGuide.currentEntryPoint = QuickStartTourEntryPointBlogDetails; + [WPAnalytics trackEvent: WPAnalyticsEventMySiteSiteMenuShown]; + + if ([self shouldShowJetpackInstallCard]) { + [WPAnalytics trackEvent:WPAnalyticsEventJetpackInstallFullPluginCardViewed + properties:@{WPAppAnalyticsKeyTabSource: @"site_menu"}]; + } + + if ([self shouldShowBlaze]) { + [BlazeEventsTracker trackEntryPointDisplayedFor:BlazeSourceMenuItem]; + } } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; [self stopAlertTimer]; - if ([self.tabBarController isKindOfClass:[WPTabBarController class]]) { - [((WPTabBarController *)self.tabBarController).createButtonCoordinator hideCreateButton]; - } } - (void)viewDidDisappear:(BOOL)animated @@ -389,6 +465,9 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { [super traitCollectionDidChange:previousTraitCollection]; + // Required to add / remove "Home" section when switching between regular and compact width + [self configureTableViewData]; + // Required to update disclosure indicators depending on split view status [self reloadTableViewPreservingSelection]; } @@ -398,8 +477,18 @@ - (void)showDetailViewForSubsection:(BlogDetailsSubsection)section NSIndexPath *indexPath = [self indexPathForSubsection:section]; switch (section) { + case BlogDetailsSubsectionReminders: case BlogDetailsSubsectionDomainCredit: + case BlogDetailsSubsectionHome: + case BlogDetailsSubsectionMigrationSuccess: + self.restorableSelectedIndexPath = indexPath; + [self.tableView selectRowAtIndexPath:indexPath + animated:NO + scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; + [self showDashboard]; + break; case BlogDetailsSubsectionQuickStart: + case BlogDetailsSubsectionJetpackBrandingCard: self.restorableSelectedIndexPath = indexPath; [self.tableView selectRowAtIndexPath:indexPath animated:NO @@ -452,12 +541,30 @@ - (void)showDetailViewForSubsection:(BlogDetailsSubsection)section [self showActivity]; } break; + case BlogDetailsSubsectionBlaze: + if ([self shouldShowBlaze]) { + self.restorableSelectedIndexPath = indexPath; + [self.tableView selectRowAtIndexPath:indexPath + animated:NO + scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; + [self showBlaze]; + } + break; + case BlogDetailsSubsectionJetpackSettings: + if ([self.blog supports:BlogFeatureActivity]) { + self.restorableSelectedIndexPath = indexPath; + [self.tableView selectRowAtIndexPath:indexPath + animated:NO + scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; + [self showJetpackSettings]; + } + break; case BlogDetailsSubsectionComments: self.restorableSelectedIndexPath = indexPath; [self.tableView selectRowAtIndexPath:indexPath animated:NO scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showComments]; + [self showCommentsFromSource:BlogDetailsNavigationSourceLink]; break; case BlogDetailsSubsectionSharing: if ([self.blog supports:BlogFeatureSharing]) { @@ -465,7 +572,7 @@ - (void)showDetailViewForSubsection:(BlogDetailsSubsection)section [self.tableView selectRowAtIndexPath:indexPath animated:NO scrollPosition:[self optimumScrollPositionForIndexPath:indexPath]]; - [self showSharing]; + [self showSharingFromSource:BlogDetailsNavigationSourceLink]; } break; case BlogDetailsSubsectionPeople: @@ -492,16 +599,25 @@ - (void)showDetailViewForSubsection:(BlogDetailsSubsection)section // MARK: Todo: this needs to adjust based on the existence of the QSv2 section - (NSIndexPath *)indexPathForSubsection:(BlogDetailsSubsection)subsection { - BlogDetailsSectionCategory sectionCategory = [self sectionCategoryWithSubsection:subsection]; + BlogDetailsSectionCategory sectionCategory = [self sectionCategoryWithSubsection:subsection blog: self.blog]; NSInteger section = [self findSectionIndexWithSections:self.tableSections category:sectionCategory]; switch (subsection) { + case BlogDetailsSubsectionReminders: + case BlogDetailsSubsectionHome: + case BlogDetailsSubsectionMigrationSuccess: + case BlogDetailsSubsectionJetpackBrandingCard: + return [NSIndexPath indexPathForRow:0 inSection:section]; case BlogDetailsSubsectionDomainCredit: return [NSIndexPath indexPathForRow:0 inSection:section]; case BlogDetailsSubsectionQuickStart: return [NSIndexPath indexPathForRow:0 inSection:section]; case BlogDetailsSubsectionStats: - return [self shouldShowQuickStartChecklist] ? [NSIndexPath indexPathForRow:1 inSection:section] : [NSIndexPath indexPathForRow:0 inSection:section]; + return [NSIndexPath indexPathForRow:0 inSection:section]; case BlogDetailsSubsectionActivity: + return [NSIndexPath indexPathForRow:0 inSection:section]; + case BlogDetailsSubsectionBlaze: + return [NSIndexPath indexPathForRow:0 inSection:section]; + case BlogDetailsSubsectionJetpackSettings: return [NSIndexPath indexPathForRow:1 inSection:section]; case BlogDetailsSubsectionPosts: return [NSIndexPath indexPathForRow:0 inSection:section]; @@ -529,8 +645,8 @@ - (NSIndexPath *)restorableSelectedIndexPath { if (!_restorableSelectedIndexPath) { // If nil, default to stats subsection. - BlogDetailsSubsection subsection = BlogDetailsSubsectionStats; - self.selectedSectionCategory = [self sectionCategoryWithSubsection:subsection]; + BlogDetailsSubsection subsection = [self defaultSubsection]; + self.selectedSectionCategory = [self sectionCategoryWithSubsection:subsection blog: self.blog]; NSUInteger section = [self findSectionIndexWithSections:self.tableSections category:self.selectedSectionCategory]; _restorableSelectedIndexPath = [NSIndexPath indexPathForRow:0 inSection:section]; } @@ -543,7 +659,9 @@ - (void)setRestorableSelectedIndexPath:(NSIndexPath *)restorableSelectedIndexPat if (restorableSelectedIndexPath != nil && restorableSelectedIndexPath.section < [self.tableSections count]) { BlogDetailsSection *section = [self.tableSections objectAtIndex:restorableSelectedIndexPath.section]; switch (section.category) { + case BlogDetailsSectionCategoryQuickAction: case BlogDetailsSectionCategoryQuickStart: + case BlogDetailsSectionCategoryJetpackBrandingCard: case BlogDetailsSectionCategoryDomainCredit: { _restorableSelectedIndexPath = nil; } @@ -570,19 +688,29 @@ - (SiteIconPickerPresenter *)siteIconPickerPresenter #pragma mark - iOS 10 bottom padding -- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section { - return UITableViewAutomaticDimension; +- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)sectionNum { + BlogDetailsSection *section = self.tableSections[sectionNum]; + BOOL isLastSection = sectionNum == self.tableSections.count - 1; + BOOL hasTitle = section.footerTitle != nil && ![section.footerTitle isEmpty]; + if (hasTitle) { + return UITableViewAutomaticDimension; + } + if (isLastSection) { + return BlogDetailSectionFooterHeight; + } + return 0; } - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)sectionNum { BlogDetailsSection *section = self.tableSections[sectionNum]; + BOOL hasTitle = section.title != nil && ![section.title isEmpty]; + if (section.showQuickStartMenu == true) { - return BlogDetailQuickStartSectionHeight; - } else if (([section.title isEmpty] || section.title == nil) && sectionNum == 0) { - // because tableView:viewForHeaderInSection: is implemented, this must explicitly be 0 - return 0.0; + return BlogDetailQuickStartSectionHeaderHeight; + } else if (hasTitle) { + return BlogDetailSectionTitleHeaderHeight; } - return UITableViewAutomaticDimension; + return BlogDetailSectionsSpacing; } - (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section { @@ -599,6 +727,229 @@ - (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger return nil; } +#pragma mark - Rows + +- (BlogDetailsRow *)postsRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Posts", @"Noun. Title. Links to the blog's Posts screen.") + accessibilityIdentifier:@"Blog Post Row" + image:[[UIImage gridiconOfType:GridiconTypePosts] imageFlippedForRightToLeftLayoutDirection] + callback:^{ + [weakSelf showPostListFromSource:BlogDetailsNavigationSourceRow]; + }]; + return row; +} + +- (BlogDetailsRow *)pagesRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Pages", @"Noun. Title. Links to the blog's Pages screen.") + accessibilityIdentifier:@"Site Pages Row" + image:[UIImage gridiconOfType:GridiconTypePages] + callback:^{ + [weakSelf showPageListFromSource:BlogDetailsNavigationSourceRow]; + }]; + row.quickStartIdentifier = QuickStartTourElementPages; + return row; +} + +- (BlogDetailsRow *)mediaRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Media", @"Noun. Title. Links to the blog's Media library.") + accessibilityIdentifier:@"Media Row" + image:[UIImage gridiconOfType:GridiconTypeImage] + callback:^{ + [weakSelf showMediaLibraryFromSource:BlogDetailsNavigationSourceRow]; + }]; + row.quickStartIdentifier = QuickStartTourElementMediaScreen; + return row; +} + +- (BlogDetailsRow *)commentsRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Comments", @"Noun. Title. Links to the blog's Comments screen.") + image:[[UIImage gridiconOfType:GridiconTypeComment] imageFlippedForRightToLeftLayoutDirection] + callback:^{ + [weakSelf showCommentsFromSource:BlogDetailsNavigationSourceRow]; + }]; + return row; +} + +- (BlogDetailsRow *)statsRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *statsRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Stats", @"Noun. Abbv. of Statistics. Links to a blog's Stats screen.") + accessibilityIdentifier:@"Stats Row" + image:[UIImage gridiconOfType:GridiconTypeStatsAlt] + callback:^{ + [weakSelf showStatsFromSource:BlogDetailsNavigationSourceRow]; + }]; + statsRow.quickStartIdentifier = QuickStartTourElementStats; + return statsRow; +} + +- (BlogDetailsRow *)blazeRow +{ + __weak __typeof(self) weakSelf = self; + CGSize iconSize = CGSizeMake(BlogDetailGridiconSize, BlogDetailGridiconSize); + UIImage *blazeIcon = [[UIImage imageNamed:@"icon-blaze"] resizedImage:iconSize interpolationQuality:kCGInterpolationHigh]; + BlogDetailsRow *blazeRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Blaze", @"Noun. Links to a blog's Blaze screen.") + accessibilityIdentifier:@"Blaze Row" + image:[blazeIcon imageFlippedForRightToLeftLayoutDirection] + imageColor:nil + renderingMode:UIImageRenderingModeAlwaysOriginal + callback:^{ + [weakSelf showBlaze]; + }]; + blazeRow.showsSelectionState = NO; + return blazeRow; +} + +- (BlogDetailsRow *)socialRow +{ + __weak __typeof(self) weakSelf = self; + + NSString *title = [AppConfiguration isWordPress] + ? NSLocalizedString(@"Sharing", @"Noun. Title. Links to a blog's sharing options.") + : [BlogDetailsViewControllerStrings socialRowTitle]; + + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:title + image:[UIImage gridiconOfType:GridiconTypeShare] + callback:^{ + [weakSelf showSharingFromSource:BlogDetailsNavigationSourceRow]; + }]; + row.quickStartIdentifier = QuickStartTourElementSharing; + return row; +} + +- (BlogDetailsRow *)activityRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Activity Log", @"Noun. Links to a blog's Activity screen.") + accessibilityIdentifier:@"Activity Log Row" + image:[UIImage gridiconOfType:GridiconTypeHistory] + callback:^{ + [weakSelf showActivity]; + }]; + return row; +} + +- (BlogDetailsRow *)backupRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Backup", @"Noun. Links to a blog's Jetpack Backups screen.") + accessibilityIdentifier:@"Backup Row" + image:[UIImage gridiconOfType:GridiconTypeCloudUpload] + callback:^{ + [weakSelf showBackup]; + }]; + return row; +} + +- (BlogDetailsRow *)scanRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Scan", @"Noun. Links to a blog's Jetpack Scan screen.") + accessibilityIdentifier:@"Scan Row" + image:[UIImage imageNamed:@"jetpack-scan-menu-icon"] + callback:^{ + [weakSelf showScan]; + }]; + return row; +} + +- (BlogDetailsRow *)peopleRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"People", @"Noun. Title. Links to the people management feature.") + accessibilityIdentifier:@"People Row" + image:[UIImage gridiconOfType:GridiconTypeUser] + callback:^{ + [weakSelf showPeople]; + }]; + return row; +} + +- (BlogDetailsRow *)pluginsRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Plugins", @"Noun. Title. Links to the plugin management feature.") + image:[UIImage gridiconOfType:GridiconTypePlugins] + callback:^{ + [weakSelf showPlugins]; + }]; + return row; +} + +- (BlogDetailsRow *)themesRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Themes", @"Themes option in the blog details") + image:[UIImage gridiconOfType:GridiconTypeThemes] + callback:^{ + [weakSelf showThemes]; + }]; + row.quickStartIdentifier = QuickStartTourElementThemes; + return row; +} + +- (BlogDetailsRow *)menuRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Menus", @"Menus option in the blog details") + image:[[UIImage gridiconOfType:GridiconTypeMenus] imageFlippedForRightToLeftLayoutDirection] + callback:^{ + [weakSelf showMenus]; + }]; + return row; +} + +- (BlogDetailsRow *)domainsRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Domains", @"Noun. Title. Links to the Domains screen.") + identifier:BlogDetailsSettingsCellIdentifier + accessibilityIdentifier:@"Domains Row" + image:[UIImage gridiconOfType:GridiconTypeDomains] + callback:^{ + [weakSelf showDomainsFromSource:BlogDetailsNavigationSourceRow]; + }]; + return row; +} + +- (BlogDetailsRow *)siteSettingsRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Site Settings", @"Noun. Title. Links to the blog's Settings screen.") + identifier:BlogDetailsSettingsCellIdentifier + accessibilityIdentifier:@"Settings Row" + image:[UIImage gridiconOfType:GridiconTypeCog] + callback:^{ + [weakSelf showSettingsFromSource:BlogDetailsNavigationSourceRow]; + }]; + return row; +} + +- (BlogDetailsRow *)adminRow +{ + __weak __typeof(self) weakSelf = self; + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:[self adminRowTitle] + image:[UIImage gridiconOfType:GridiconTypeMySites] + callback:^{ + [weakSelf showViewAdmin]; + [weakSelf.tableView deselectSelectedRowWithAnimation:YES]; + }]; + UIImage *image = [[UIImage gridiconOfType:GridiconTypeExternal withSize:CGSizeMake(BlogDetailGridiconAccessorySize, BlogDetailGridiconAccessorySize)] imageFlippedForRightToLeftLayoutDirection]; + UIImageView *accessoryView = [[UIImageView alloc] initWithImage:image]; + accessoryView.tintColor = [WPStyleGuide cellGridiconAccessoryColor]; // Match disclosure icon color. + row.accessoryView = accessoryView; + row.showsSelectionState = NO; + return row; +} + #pragma mark - Data Model setup - (void)reloadTableViewPreservingSelection @@ -614,12 +965,15 @@ - (void)reloadTableViewPreservingSelection BlogDetailsSection *section = [self.tableSections objectAtIndex:sectionIndex]; NSUInteger row = 0; - + // For QuickStart and Use Domain cases we want to select the first row on the next available section switch (section.category) { + case BlogDetailsSectionCategoryQuickAction: case BlogDetailsSectionCategoryQuickStart: + case BlogDetailsSectionCategoryJetpackBrandingCard: case BlogDetailsSectionCategoryDomainCredit: { - BlogDetailsSectionCategory category = [self sectionCategoryWithSubsection:BlogDetailsSubsectionStats]; + BlogDetailsSubsection subsection = [self defaultSubsection]; + BlogDetailsSectionCategory category = [self sectionCategoryWithSubsection:subsection blog: self.blog]; sectionIndex = [self findSectionIndexWithSections:self.tableSections category:category]; } break; @@ -635,7 +989,6 @@ - (void)reloadTableViewPreservingSelection self.restorableSelectedIndexPath.row < [self.tableView numberOfRowsInSection:self.restorableSelectedIndexPath.section]; if (isValidIndexPath && ![self splitViewControllerIsHorizontallyCompact]) { // And finally we'll reselect the selected row, if there is one - [self.tableView selectRowAtIndexPath:self.restorableSelectedIndexPath animated:NO scrollPosition:[self optimumScrollPositionForIndexPath:self.restorableSelectedIndexPath]]; @@ -653,6 +1006,19 @@ - (UITableViewScrollPosition)optimumScrollPositionForIndexPath:(NSIndexPath *)in - (void)configureTableViewData { NSMutableArray *marr = [NSMutableArray array]; + + if (MigrationSuccessCardView.shouldShowMigrationSuccessCard == YES) { + [marr addObject:[self migrationSuccessSectionViewModel]]; + } + + if ([self shouldShowJetpackInstallCard]) { + [marr addObject:[self jetpackInstallSectionViewModel]]; + } + + if (self.shouldShowTopJetpackBrandingMenuCard == YES) { + [marr addObject:[self jetpackCardSectionViewModel]]; + } + if ([DomainCreditEligibilityChecker canRedeemDomainCreditWithBlog:self.blog]) { if (!self.hasLoggedDomainCreditPromptShownEvent) { [WPAnalytics track:WPAnalyticsStatDomainCreditPromptShown]; @@ -663,110 +1029,306 @@ - (void)configureTableViewData if ([self shouldShowQuickStartChecklist]) { [marr addObject:[self quickStartSectionViewModel]]; } - [marr addObject:[self generalSectionViewModel]]; - [marr addObject:[self publishTypeSectionViewModel]]; - if ([self.blog supports:BlogFeatureThemeBrowsing] || [self.blog supports:BlogFeatureMenus]) { - [marr addObject:[self personalizeSectionViewModel]]; + if ([self isDashboardEnabled] && ![self splitViewControllerIsHorizontallyCompact]) { + [marr addObject:[self homeSectionViewModel]]; } - [marr addObject:[self configurationSectionViewModel]]; - [marr addObject:[self externalSectionViewModel]]; + + if ([AppConfiguration isWordPress]) { + if ([self shouldAddJetpackSection]) { + [marr addObject:[self jetpackSectionViewModel]]; + } + + if ([self shouldAddGeneralSection]) { + [marr addObject:[self generalSectionViewModel]]; + } + + [marr addObject:[self publishTypeSectionViewModel]]; + + if ([self shouldAddPersonalizeSection]) { + [marr addObject:[self personalizeSectionViewModel]]; + } + + [marr addObject:[self configurationSectionViewModel]]; + [marr addObject:[self externalSectionViewModel]]; + } else { + [marr addObject:[self contentSectionViewModel]]; + [marr addObject:[self trafficSectionViewModel]]; + [marr addObjectsFromArray:[self maintenanceSectionViewModel]]; + } + if ([self.blog supports:BlogFeatureRemovable]) { [marr addObject:[self removeSiteSectionViewModel]]; } + + if (self.shouldShowBottomJetpackBrandingMenuCard == YES) { + [marr addObject:[self jetpackCardSectionViewModel]]; + } // Assign non mutable copy. self.tableSections = [NSArray arrayWithArray:marr]; } +/// This section is available on Jetpack only. +- (BlogDetailsSection *)contentSectionViewModel +{ + NSMutableArray *rows = [NSMutableArray array]; + + [rows addObject:[self postsRow]]; + if ([self.blog supports:BlogFeaturePages]) { + [rows addObject:[self pagesRow]]; + } + [rows addObject:[self mediaRow]]; + [rows addObject:[self commentsRow]]; + + NSString *title = [BlogDetailsViewControllerStrings contentSectionTitle]; + return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryContent]; +} + +/// This section is available on Jetpack only. +- (BlogDetailsSection *)trafficSectionViewModel +{ + // Init rows + NSMutableArray *rows = [NSMutableArray array]; + + // Stats row + [rows addObject:[self statsRow]]; + + // Social row + if ([self shouldAddSharingRow]) { + [rows addObject:[self socialRow]]; + } + + // Blaze row + if ([self shouldShowBlaze]) { + [rows addObject:[self blazeRow]]; + } + + // Return + NSString *title = [BlogDetailsViewControllerStrings trafficSectionTitle]; + return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryTraffic]; +} + +/// Returns a list of sections. Available on Jetpack only. +- (NSArray<BlogDetailsSection *> *)maintenanceSectionViewModel +{ + // Init array + NSMutableArray<BlogDetailsSection *> *sections = [NSMutableArray array]; + NSMutableArray *firstSectionRows = [NSMutableArray array]; + NSMutableArray *secondSectionRows = [NSMutableArray array]; + NSMutableArray *thirdSectionRows = [NSMutableArray array]; + + // The 1st section + if ([self.blog supports:BlogFeatureActivity] && ![self.blog isWPForTeams]) { + [firstSectionRows addObject:[self activityRow]]; + } + if ([self.blog isBackupsAllowed]) { + [firstSectionRows addObject:[self backupRow]]; + } + + if ([self.blog isScanAllowed]) { + [firstSectionRows addObject:[self scanRow]]; + } + + // The 2nd section + if ([self shouldAddPeopleRow]) { + [secondSectionRows addObject:[self peopleRow]]; + } + if ([self shouldAddPluginsRow]) { + [secondSectionRows addObject:[self pluginsRow]]; + } + if ([self.blog supports:BlogFeatureThemeBrowsing] && ![self.blog isWPForTeams]) { + [secondSectionRows addObject:[self themesRow]]; + } + if ([self.blog supports:BlogFeatureMenus]) { + [secondSectionRows addObject:[self menuRow]]; + } + if ([self shouldAddDomainRegistrationRow]) { + [secondSectionRows addObject:[self domainsRow]]; + } + [secondSectionRows addObject:[self siteSettingsRow]]; + + // Third section + if ([self shouldDisplayLinkToWPAdmin]) { + [thirdSectionRows addObject:[self adminRow]]; + } + + // Add sections + NSString *sectionTitle = [BlogDetailsViewControllerStrings maintenanceSectionTitle]; + BOOL shouldAddSectionTitle = YES; + if ([firstSectionRows count] > 0) { + BlogDetailsSection *section = [[BlogDetailsSection alloc] initWithTitle:sectionTitle + andRows:firstSectionRows + category:BlogDetailsSectionCategoryMaintenance]; + [sections addObject:section]; + shouldAddSectionTitle = NO; + } + if ([secondSectionRows count] > 0) { + NSString *title = shouldAddSectionTitle ? sectionTitle : nil; + BlogDetailsSection *section = [[BlogDetailsSection alloc] initWithTitle:title + andRows:secondSectionRows + category:BlogDetailsSectionCategoryMaintenance]; + [sections addObject:section]; + shouldAddSectionTitle = NO; + } + if ([thirdSectionRows count] > 0) { + NSString *title = shouldAddSectionTitle ? sectionTitle : nil; + BlogDetailsSection *section = [[BlogDetailsSection alloc] initWithTitle:title + andRows:thirdSectionRows + category:BlogDetailsSectionCategoryMaintenance]; + [sections addObject:section]; + shouldAddSectionTitle = NO; + } + + // Return + return sections; +} + +- (BlogDetailsSection *)homeSectionViewModel +{ + __weak __typeof(self) weakSelf = self; + NSMutableArray *rows = [NSMutableArray array]; + + [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Home", @"Noun. Links to a blog's dashboard screen.") + accessibilityIdentifier:@"Home Row" + image:[UIImage gridiconOfType:GridiconTypeHouse] + callback:^{ + [weakSelf showDashboard]; + }]]; + + return [[BlogDetailsSection alloc] initWithTitle:nil andRows:rows category:BlogDetailsSectionCategoryHome]; +} + - (BlogDetailsSection *)generalSectionViewModel { __weak __typeof(self) weakSelf = self; NSMutableArray *rows = [NSMutableArray array]; - + BlogDetailsRow *statsRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Stats", @"Noun. Abbv. of Statistics. Links to a blog's Stats screen.") accessibilityIdentifier:@"Stats Row" - image:[Gridicon iconOfType:GridiconTypeStatsAlt] + image:[UIImage gridiconOfType:GridiconTypeStatsAlt] callback:^{ [weakSelf showStatsFromSource:BlogDetailsNavigationSourceRow]; }]; statsRow.quickStartIdentifier = QuickStartTourElementStats; [rows addObject:statsRow]; - if ([self.blog supports:BlogFeatureActivity]) { + if ([self.blog supports:BlogFeatureActivity] && ![self.blog isWPForTeams]) { [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Activity", @"Noun. Links to a blog's Activity screen.") - image:[Gridicon iconOfType:GridiconTypeHistory] + image:[UIImage gridiconOfType:GridiconTypeHistory] callback:^{ [weakSelf showActivity]; }]]; } - - if ([self.blog supports:BlogFeaturePlans]) { - BlogDetailsRow *plansRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Plans", @"Action title. Noun. Links to a blog's Plans screen.") - identifier:BlogDetailsPlanCellIdentifier - image:[Gridicon iconOfType:GridiconTypePlans] - callback:^{ - [weakSelf showPlans]; - }]; - - plansRow.detail = self.blog.planTitle; - plansRow.quickStartIdentifier = QuickStartTourElementPlans; - [rows addObject:plansRow]; - } + + if ([self shouldShowBlaze]) { + [rows addObject:[self blazeRow]]; + } + +// Temporarily disabled +// if ([self.blog supports:BlogFeaturePlans] && ![self.blog isWPForTeams]) { +// BlogDetailsRow *plansRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Plans", @"Action title. Noun. Links to a blog's Plans screen.") +// identifier:BlogDetailsPlanCellIdentifier +// image:[UIImage gridiconOfType:GridiconTypePlans] +// callback:^{ +// [weakSelf showPlansFromSource:BlogDetailsNavigationSourceRow]; +// }]; +// +// plansRow.detail = self.blog.planTitle; +// plansRow.quickStartIdentifier = QuickStartTourElementPlans; +// [rows addObject:plansRow]; +// } return [[BlogDetailsSection alloc] initWithTitle:nil andRows:rows category:BlogDetailsSectionCategoryGeneral]; } -- (BlogDetailsSection *)publishTypeSectionViewModel +- (BlogDetailsSection *)jetpackSectionViewModel { __weak __typeof(self) weakSelf = self; NSMutableArray *rows = [NSMutableArray array]; - - BlogDetailsRow *pagesRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Site Pages", @"Noun. Title. Links to the blog's Pages screen.") - accessibilityIdentifier:@"Site Pages Row" - image:[Gridicon iconOfType:GridiconTypePages] + + BlogDetailsRow *statsRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Stats", @"Noun. Abbv. of Statistics. Links to a blog's Stats screen.") + accessibilityIdentifier:@"Stats Row" + image:[UIImage gridiconOfType:GridiconTypeStatsAlt] callback:^{ - [weakSelf showPageListFromSource:BlogDetailsNavigationSourceRow]; + [weakSelf showStatsFromSource:BlogDetailsNavigationSourceRow]; }]; - pagesRow.quickStartIdentifier = QuickStartTourElementPages; - [rows addObject:pagesRow]; + statsRow.quickStartIdentifier = QuickStartTourElementStats; + [rows addObject:statsRow]; - [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Blog Posts", @"Noun. Title. Links to the blog's Posts screen.") - accessibilityIdentifier:@"Blog Post Row" - image:[[Gridicon iconOfType:GridiconTypePosts] imageFlippedForRightToLeftLayoutDirection] - callback:^{ - [weakSelf showPostListFromSource:BlogDetailsNavigationSourceRow]; - }]]; + if ([self.blog supports:BlogFeatureActivity] && ![self.blog isWPForTeams]) { + [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Activity Log", @"Noun. Links to a blog's Activity screen.") + accessibilityIdentifier:@"Activity Log Row" + image:[UIImage gridiconOfType:GridiconTypeHistory] + callback:^{ + [weakSelf showActivity]; + }]]; + } - [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Media", @"Noun. Title. Links to the blog's Media library.") - accessibilityIdentifier:@"Media Row" - image:[Gridicon iconOfType:GridiconTypeImage] - callback:^{ - [weakSelf showMediaLibraryFromSource:BlogDetailsNavigationSourceRow]; - }]]; + if ([self.blog isBackupsAllowed]) { + [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Backup", @"Noun. Links to a blog's Jetpack Backups screen.") + accessibilityIdentifier:@"Backup Row" + image:[UIImage gridiconOfType:GridiconTypeCloudUpload] + callback:^{ + [weakSelf showBackup]; + }]]; + } - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Comments", @"Noun. Title. Links to the blog's Comments screen.") - image:[[Gridicon iconOfType:GridiconTypeComment] imageFlippedForRightToLeftLayoutDirection] - callback:^{ - [weakSelf showComments]; - }]; - NSUInteger numberOfPendingComments = [self.blog numberOfPendingComments]; - if (numberOfPendingComments > 0) { - row.detail = [NSString stringWithFormat:@"%d", numberOfPendingComments]; + if ([self.blog isScanAllowed]) { + [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Scan", @"Noun. Links to a blog's Jetpack Scan screen.") + accessibilityIdentifier:@"Scan Row" + image:[UIImage imageNamed:@"jetpack-scan-menu-icon"] + callback:^{ + [weakSelf showScan]; + }]]; } - [rows addObject:row]; + + if ([self.blog supports:BlogFeatureJetpackSettings]) { + BlogDetailsRow *settingsRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Jetpack Settings", @"Noun. Title. Links to the blog's Settings screen.") + identifier:BlogDetailsSettingsCellIdentifier + accessibilityIdentifier:@"Jetpack Settings Row" + image:[UIImage gridiconOfType:GridiconTypeCog] + callback:^{ + [weakSelf showJetpackSettings]; + }]; + + [rows addObject:settingsRow]; + } + + if ([self shouldShowBlaze]) { + [rows addObject:[self blazeRow]]; + } + NSString *title = @""; + + if ([self.blog supports:BlogFeatureJetpackSettings]) { + title = NSLocalizedString(@"Jetpack", @"Section title for the publish table section in the blog details screen"); + } + + return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryJetpack]; +} + +- (BlogDetailsSection *)publishTypeSectionViewModel +{ + NSMutableArray *rows = [NSMutableArray array]; + + [rows addObject:[self postsRow]]; + [rows addObject:[self mediaRow]]; + if ([self.blog supports:BlogFeaturePages]) { + [rows addObject:[self pagesRow]]; + } + [rows addObject:[self commentsRow]]; NSString *title = NSLocalizedString(@"Publish", @"Section title for the publish table section in the blog details screen"); - return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryPublish]; + return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryContent]; } - (BlogDetailsSection *)personalizeSectionViewModel { __weak __typeof(self) weakSelf = self; NSMutableArray *rows = [NSMutableArray array]; - if ([self.blog supports:BlogFeatureThemeBrowsing]) { + if ([self.blog supports:BlogFeatureThemeBrowsing] && ![self.blog isWPForTeams]) { BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Themes", @"Themes option in the blog details") - image:[Gridicon iconOfType:GridiconTypeThemes] + image:[UIImage gridiconOfType:GridiconTypeThemes] callback:^{ [weakSelf showThemes]; }]; @@ -775,7 +1337,7 @@ - (BlogDetailsSection *)personalizeSectionViewModel } if ([self.blog supports:BlogFeatureMenus]) { [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Menus", @"Menus option in the blog details") - image:[[Gridicon iconOfType:GridiconTypeMenus] imageFlippedForRightToLeftLayoutDirection] + image:[[UIImage gridiconOfType:GridiconTypeMenus] imageFlippedForRightToLeftLayoutDirection] callback:^{ [weakSelf showMenus]; }]]; @@ -789,42 +1351,54 @@ - (BlogDetailsSection *)configurationSectionViewModel __weak __typeof(self) weakSelf = self; NSMutableArray *rows = [NSMutableArray array]; - if ([self.blog supports:BlogFeatureSharing]) { + if ([self shouldAddSharingRow]) { BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Sharing", @"Noun. Title. Links to a blog's sharing options.") - image:[Gridicon iconOfType:GridiconTypeShare] + image:[UIImage gridiconOfType:GridiconTypeShare] callback:^{ - [weakSelf showSharing]; + [weakSelf showSharingFromSource:BlogDetailsNavigationSourceRow]; }]; row.quickStartIdentifier = QuickStartTourElementSharing; [rows addObject:row]; } - if ([self.blog supports:BlogFeaturePeople]) { + if ([self shouldAddPeopleRow]) { [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"People", @"Noun. Title. Links to the people management feature.") - image:[Gridicon iconOfType:GridiconTypeUser] + accessibilityIdentifier:@"People Row" + image:[UIImage gridiconOfType:GridiconTypeUser] callback:^{ [weakSelf showPeople]; }]]; } - if ([self.blog supports:BlogFeaturePluginManagement]) { + if ([self shouldAddPluginsRow]) { [rows addObject:[[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Plugins", @"Noun. Title. Links to the plugin management feature.") - image:[Gridicon iconOfType:GridiconTypePlugins] + image:[UIImage gridiconOfType:GridiconTypePlugins] callback:^{ [weakSelf showPlugins]; }]]; } - BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Settings", @"Noun. Title. Links to the blog's Settings screen.") + BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Site Settings", @"Noun. Title. Links to the blog's Settings screen.") identifier:BlogDetailsSettingsCellIdentifier accessibilityIdentifier:@"Settings Row" - image:[Gridicon iconOfType:GridiconTypeCog] + image:[UIImage gridiconOfType:GridiconTypeCog] callback:^{ - [weakSelf showSettings]; + [weakSelf showSettingsFromSource:BlogDetailsNavigationSourceRow]; }]; [rows addObject:row]; + if ([self shouldAddDomainRegistrationRow]) { + BlogDetailsRow *domainsRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"Domains", @"Noun. Title. Links to the Domains screen.") + identifier:BlogDetailsSettingsCellIdentifier + accessibilityIdentifier:@"Domains Row" + image:[UIImage gridiconOfType:GridiconTypeDomains] + callback:^{ + [weakSelf showDomainsFromSource:BlogDetailsNavigationSourceRow]; + }]; + [rows addObject:domainsRow]; + } + NSString *title = NSLocalizedString(@"Configure", @"Section title for the configure table section in the blog details screen"); return [[BlogDetailsSection alloc] initWithTitle:title andRows:rows category:BlogDetailsSectionCategoryConfigure]; } @@ -834,22 +1408,21 @@ - (BlogDetailsSection *)externalSectionViewModel __weak __typeof(self) weakSelf = self; NSMutableArray *rows = [NSMutableArray array]; BlogDetailsRow *viewSiteRow = [[BlogDetailsRow alloc] initWithTitle:NSLocalizedString(@"View Site", @"Action title. Opens the user's site in an in-app browser") - image:[Gridicon iconOfType:GridiconTypeHouse] + image:[UIImage gridiconOfType:GridiconTypeGlobe] callback:^{ - [weakSelf showViewSite]; - }]; - viewSiteRow.quickStartIdentifier = QuickStartTourElementViewSite; + [weakSelf showViewSiteFromSource:BlogDetailsNavigationSourceRow]; + }]; viewSiteRow.showsSelectionState = NO; [rows addObject:viewSiteRow]; if ([self shouldDisplayLinkToWPAdmin]) { BlogDetailsRow *row = [[BlogDetailsRow alloc] initWithTitle:[self adminRowTitle] - image:[Gridicon iconOfType:GridiconTypeMySites] + image:[UIImage gridiconOfType:GridiconTypeMySites] callback:^{ [weakSelf showViewAdmin]; [weakSelf.tableView deselectSelectedRowWithAnimation:YES]; }]; - UIImage *image = [[Gridicon iconOfType:GridiconTypeExternal withSize:CGSizeMake(BlogDetailGridiconAccessorySize, BlogDetailGridiconAccessorySize)] imageFlippedForRightToLeftLayoutDirection]; + UIImage *image = [[UIImage gridiconOfType:GridiconTypeExternal withSize:CGSizeMake(BlogDetailGridiconAccessorySize, BlogDetailGridiconAccessorySize)] imageFlippedForRightToLeftLayoutDirection]; UIImageView *accessoryView = [[UIImageView alloc] initWithImage:image]; accessoryView.tintColor = [WPStyleGuide cellGridiconAccessoryColor]; // Match disclosure icon color. row.accessoryView = accessoryView; @@ -898,217 +1471,44 @@ - (BOOL)shouldDisplayLinkToWPAdmin } NSDate *hideWPAdminDate = [NSDate dateWithISO8601String:HideWPAdminDate]; NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:context]; return [defaultAccount.dateCreated compare:hideWPAdminDate] == NSOrderedAscending; } -#pragma mark - Configuration - -- (void)configureBlogDetailHeader -{ - if ([Feature enabled:FeatureFlagQuickActions]) { - NewBlogDetailHeaderView *headerView = [self configureHeaderView]; - headerView.delegate = self; - - self.headerView = headerView; - - self.tableView.tableHeaderView = headerView; - } else { - // Wrapper view - UIView *headerWrapper = [[UIView alloc] initWithFrame:CGRectMake(0.0, 0.0, CGRectGetWidth(self.view.bounds), BlogDetailHeaderViewBlavatarSize + BlogDetailHeaderViewVerticalMargin * 2)]; - headerWrapper.preservesSuperviewLayoutMargins = YES; - self.tableView.tableHeaderView = headerWrapper; - - // Blog detail header view - BlogDetailHeaderView *headerView = [[BlogDetailHeaderView alloc] init]; - headerView.translatesAutoresizingMaskIntoConstraints = NO; - headerView.delegate = self; - [headerWrapper addSubview:headerView]; - self.headerView = headerView; - - - UILayoutGuide *readableGuide = headerWrapper.readableContentGuide; - [NSLayoutConstraint activateConstraints:@[ - [headerView.leadingAnchor constraintEqualToAnchor:readableGuide.leadingAnchor], - [headerView.topAnchor constraintEqualToAnchor:headerWrapper.topAnchor], - [headerView.trailingAnchor constraintEqualToAnchor:readableGuide.trailingAnchor], - [headerView.bottomAnchor constraintEqualToAnchor:headerWrapper.bottomAnchor], - ]]; - } -} - -#pragma mark BlogDetailHeaderViewDelegate +#pragma mark Site Switching -- (void)siteIconTapped +- (void)switchToBlog:(Blog*)blog { - if (![self siteIconShouldAllowDroppedImages]) { - // Gracefully ignore the tap for users that can not upload files or - // blogs that do not have capabilities since those will not support the REST API icon update - return; - } - [WPAnalytics track:WPAnalyticsStatSiteSettingsSiteIconTapped]; - [self showUpdateSiteIconAlert]; + self.blog = blog; + [self showInitialDetailsForBlog]; + [self.tableView reloadData]; + [self preloadMetadata]; } -- (void)siteIconReceivedDroppedImage:(UIImage *)image +- (void)showInitialDetailsForBlog { - if (![self siteIconShouldAllowDroppedImages]) { - // Gracefully ignore the drop for users that can not upload files or - // blogs that do not have capabilities since those will not support the REST API icon update - self.headerView.updatingIcon = NO; + if ([self splitViewControllerIsHorizontallyCompact]) { return; } - [self presentCropViewControllerForDroppedSiteIcon:image]; -} - -- (BOOL)siteIconShouldAllowDroppedImages -{ - if (!self.blog.isAdmin || !self.blog.isUploadingFilesAllowed) { - return NO; - } - - return YES; -} - -#pragma mark Site Icon Update Management - -- (void)showUpdateSiteIconAlert -{ - UIAlertController *updateIconAlertController = [UIAlertController alertControllerWithTitle:nil - message:nil - preferredStyle:UIAlertControllerStyleActionSheet]; - - updateIconAlertController.popoverPresentationController.sourceView = self.headerView.blavatarImageView.superview; - updateIconAlertController.popoverPresentationController.sourceRect = self.headerView.blavatarImageView.frame; - updateIconAlertController.popoverPresentationController.permittedArrowDirections = UIPopoverArrowDirectionAny; - - [updateIconAlertController addDefaultActionWithTitle:NSLocalizedString(@"Change Site Icon", @"Change site icon button") - handler:^(UIAlertAction *action) { - [self updateSiteIcon]; - }]; - if (self.blog.hasIcon) { - [updateIconAlertController addDestructiveActionWithTitle:NSLocalizedString(@"Remove Site Icon", @"Remove site icon button") - handler:^(UIAlertAction *action) { - [self removeSiteIcon]; - }]; - } - [updateIconAlertController addCancelActionWithTitle:NSLocalizedString(@"Cancel", @"Cancel button") - handler:nil]; - - [self presentViewController:updateIconAlertController animated:YES completion:nil]; -} - -- (void)presentCropViewControllerForDroppedSiteIcon:(UIImage *)image -{ - self.imageCropViewController = [[ImageCropViewController alloc] initWithImage:image]; - self.imageCropViewController.maskShape = ImageCropOverlayMaskShapeSquare; - self.imageCropViewController.shouldShowCancelButton = YES; - __weak __typeof(self) weakSelf = self; - self.imageCropViewController.onCancel = ^(void) { - [weakSelf dismissViewControllerAnimated:YES completion:nil]; - weakSelf.headerView.updatingIcon = NO; - }; - - self.imageCropViewController.onCompletion = ^(UIImage *image, BOOL modified) { - [weakSelf dismissViewControllerAnimated:YES completion:nil]; - [weakSelf uploadDroppedSiteIcon:image onCompletion:^{ - weakSelf.headerView.blavatarImageView.image = image; - weakSelf.headerView.updatingIcon = NO; - }]; - }; - UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:self.imageCropViewController]; - navController.modalPresentationStyle = UIModalPresentationFormSheet; - [self presentViewController:navController animated:YES completion:nil]; -} - -- (void)uploadDroppedSiteIcon:(UIImage *)image onCompletion:(void(^)(void))completion -{ - if (self.blog.objectID == nil) { - return; + self.restorableSelectedIndexPath = nil; + + WPSplitViewController *splitViewController = (WPSplitViewController *)self.splitViewController; + splitViewController.isShowingInitialDetail = YES; + BlogDetailsSubsection subsection = [self defaultSubsection]; + switch (subsection) { + case BlogDetailsSubsectionHome: + [self showDetailViewForSubsection:BlogDetailsSubsectionHome]; + break; + case BlogDetailsSubsectionStats: + [self showDetailViewForSubsection:BlogDetailsSubsectionStats]; + break; + case BlogDetailsSubsectionPosts: + [self showDetailViewForSubsection: BlogDetailsSubsectionPosts]; + break; + default: + break; } - - __weak __typeof(self) weakSelf = self; - MediaService *mediaService = [[MediaService alloc] initWithManagedObjectContext:[ContextManager sharedInstance].mainContext]; - NSProgress *mediaCreateProgress; - [mediaService createMediaWith:image blog:self.blog post:nil progress:&mediaCreateProgress thumbnailCallback:nil completion:^(Media *media, NSError *error) { - if (media == nil || error != nil) { - return; - } - NSProgress *uploadProgress; - [mediaService uploadMedia:media - automatedRetry:false - progress:&uploadProgress - success:^{ - [weakSelf updateBlogIconWithMedia:media]; - completion(); - } failure:^(NSError * _Nonnull error) { - [weakSelf showErrorForSiteIconUpdate]; - completion(); - }]; - }]; -} - -- (void)updateSiteIcon -{ - self.siteIconPickerPresenter = [[SiteIconPickerPresenter alloc]initWithBlog:self.blog]; - __weak __typeof(self) weakSelf = self; - self.siteIconPickerPresenter.onCompletion = ^(Media *media, NSError *error) { - if (error) { - [weakSelf showErrorForSiteIconUpdate]; - } else if (media) { - [weakSelf updateBlogIconWithMedia:media]; - } else { - // If no media and no error the picker was canceled - [weakSelf dismissViewControllerAnimated:YES completion:nil]; - } - weakSelf.siteIconPickerPresenter = nil; - }; - self.siteIconPickerPresenter.onIconSelection = ^() { - weakSelf.headerView.updatingIcon = YES; - [weakSelf dismissViewControllerAnimated:YES completion:nil]; - }; - [self.siteIconPickerPresenter presentPickerFrom:self]; -} - -- (void)removeSiteIcon -{ - self.headerView.updatingIcon = YES; - self.blog.settings.iconMediaID = @0; - [self updateBlogSettingsAndRefreshIcon]; - [WPAnalytics track:WPAnalyticsStatSiteSettingsSiteIconRemoved]; -} - -- (void)refreshSiteIcon { - [self.headerView refreshIconImage]; -} - -- (void)updateBlogIconWithMedia:(Media *)media -{ - self.blog.settings.iconMediaID = media.mediaID; - [self updateBlogSettingsAndRefreshIcon]; -} - -- (void)updateBlogSettingsAndRefreshIcon -{ - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:self.blog.managedObjectContext]; - [blogService updateSettingsForBlog:self.blog - success:^{ - [blogService syncBlog:self.blog - success:^{ - self.headerView.updatingIcon = NO; - [self.headerView refreshIconImage]; - } failure:nil]; - } failure:^(NSError *error){ - [self showErrorForSiteIconUpdate]; - }]; -} - -- (void)showErrorForSiteIconUpdate -{ - [SVProgressHUD showDismissibleErrorWithStatus:NSLocalizedString(@"Icon update failed", @"Message to show when site icon update failed")]; - self.headerView.updatingIcon = NO; } #pragma mark - Table view data source @@ -1121,6 +1521,12 @@ - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { BlogDetailsSection *detailSection = [self.tableSections objectAtIndex:section]; + + /// For larger texts we don't show the quick actions row + if (detailSection.category == BlogDetailsSectionCategoryQuickAction && self.isAccessibilityCategoryEnabled) { + return 0; + } + return [detailSection.rows count]; } @@ -1136,16 +1542,45 @@ - (void)configureCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPa if (row.accessoryView) { cell.accessoryView = row.accessoryView; } - if ([cell isKindOfClass:[QuickStartListTitleCell class]]) { - ((QuickStartListTitleCell *) cell).state = row.quickStartTitleState; - } } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { BlogDetailsSection *section = [self.tableSections objectAtIndex:indexPath.section]; + + if (section.category == BlogDetailsSectionCategoryJetpackInstallCard) { + JetpackRemoteInstallTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:BlogDetailsJetpackInstallCardCellIdentifier]; + [cell configureWithBlog:self.blog viewController:self]; + return cell; + } + + if (section.category == BlogDetailsSectionCategoryQuickAction) { + QuickActionsCell *cell = [tableView dequeueReusableCellWithIdentifier:BlogDetailsQuickActionsCellIdentifier]; + [self configureQuickActionsWithCell: cell]; + return cell; + } + + if (section.category == BlogDetailsSectionCategoryQuickStart) { + QuickStartCell *cell = [tableView dequeueReusableCellWithIdentifier:BlogDetailsQuickStartCellIdentifier]; + [cell configureWithBlog:self.blog viewController:self]; + return cell; + } + + if (section.category == BlogDetailsSectionCategoryMigrationSuccess && MigrationSuccessCardView.shouldShowMigrationSuccessCard == YES) { + MigrationSuccessCell *cell = [tableView dequeueReusableCellWithIdentifier:BlogDetailsMigrationSuccessCellIdentifier]; + [cell configureWithViewController:self]; + return cell; + } + + if (section.category == BlogDetailsSectionCategoryJetpackBrandingCard) { + JetpackBrandingMenuCardCell *cell = [tableView dequeueReusableCellWithIdentifier:BlogDetailsJetpackBrandingCardCellIdentifier]; + [cell configureWithViewController:self]; + return cell; + } + BlogDetailsRow *row = [section.rows objectAtIndex:indexPath.row]; UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.identifier]; + cell.accessibilityHint = row.accessibilityHint; cell.accessoryView = nil; cell.textLabel.textAlignment = NSTextAlignmentNatural; if (row.forDestructiveAction) { @@ -1159,8 +1594,18 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N } [WPStyleGuide configureTableViewCell:cell]; } - if ([[QuickStartTourGuide find] isCurrentElement:row.quickStartIdentifier]) { + + QuickStartTourGuide *tourGuide = [QuickStartTourGuide shared]; + + + BOOL shouldShowSpotlight = + tourGuide.entryPointForCurrentTour == QuickStartTourEntryPointBlogDetails || + tourGuide.currentTourMustBeShownFromBlogDetails; + + if ([tourGuide isCurrentElement:row.quickStartIdentifier] && shouldShowSpotlight) { row.accessoryView = [QuickStartSpotlightView new]; + } else if ([row.accessoryView isKindOfClass:[QuickStartSpotlightView class]]) { + row.accessoryView = nil; } [self configureCell:cell atIndexPath:indexPath]; @@ -1170,6 +1615,9 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + WPSplitViewController *splitViewController = (WPSplitViewController *)self.splitViewController; + splitViewController.isShowingInitialDetail = NO; + BlogDetailsSection *section = [self.tableSections objectAtIndex:indexPath.section]; BlogDetailsRow *row = [section.rows objectAtIndex:indexPath.row]; row.callback(); @@ -1202,9 +1650,8 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger BlogDetailsSection *section = [self.tableSections objectAtIndex:sectionNum]; if (section.showQuickStartMenu) { return [self quickStartHeaderWithTitle:section.title]; - } else { - return [super tableView:tableView viewForHeaderInSection:sectionNum]; } + return nil; } - (UIView *)quickStartHeaderWithTitle:(NSString *)title @@ -1213,39 +1660,13 @@ - (UIView *)quickStartHeaderWithTitle:(NSString *)title BlogDetailsSectionHeaderView *view = [self.tableView dequeueReusableHeaderFooterViewWithIdentifier:BlogDetailsSectionHeaderViewIdentifier]; [view setTitle:title]; view.ellipsisButtonDidTouch = ^(BlogDetailsSectionHeaderView *header) { - [weakSelf removeQuickStartSection:header]; + [weakSelf removeQuickStartFromBlog:weakSelf.blog + sourceView:header + sourceRect:header.ellipsisButton.frame]; }; return view; } -- (void)removeQuickStartSection:(BlogDetailsSectionHeaderView *)view -{ - NSString *removeTitle = NSLocalizedString(@"Remove Next Steps", @"Title for action that will remove the next steps/quick start menus."); - NSString *removeMessage = NSLocalizedString(@"Removing Next Steps will hide all tours on this site. This action cannot be undone.", @"Explanation of what will happen if the user confirms this alert."); - NSString *confirmationTitle = NSLocalizedString(@"Remove", @"Title for button that will confirm removing the next steps/quick start menus."); - NSString *cancelTitle = NSLocalizedString(@"Cancel", @"Cancel button"); - - UIAlertController *removeConfirmation = [UIAlertController alertControllerWithTitle:removeTitle message:removeMessage preferredStyle:UIAlertControllerStyleAlert]; - [removeConfirmation addCancelActionWithTitle:cancelTitle handler:^(UIAlertAction * _Nonnull action) { - [WPAnalytics track:WPAnalyticsStatQuickStartRemoveDialogButtonCancelTapped]; - }]; - [removeConfirmation addDefaultActionWithTitle:confirmationTitle handler:^(UIAlertAction * _Nonnull action) { - [WPAnalytics track:WPAnalyticsStatQuickStartRemoveDialogButtonRemoveTapped]; - - [[QuickStartTourGuide find] removeFrom:self.blog]; - }]; - - UIAlertController *removeSheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; - removeSheet.popoverPresentationController.sourceView = view; - removeSheet.popoverPresentationController.sourceRect = view.ellipsisButton.frame; - [removeSheet addDestructiveActionWithTitle:removeTitle handler:^(UIAlertAction * _Nonnull action) { - [self presentViewController:removeConfirmation animated:YES completion:nil]; - }]; - [removeSheet addCancelActionWithTitle:cancelTitle handler:nil]; - - [self presentViewController:removeSheet animated:YES completion:nil]; -} - - (NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(nonnull NSIndexPath *)indexPath { BOOL isNewSelection = (indexPath != tableView.indexPathForSelectedRow); @@ -1261,26 +1682,22 @@ - (NSIndexPath *)tableView:(UITableView *)tableView willSelectRowAtIndexPath:(no - (void)trackEvent:(WPAnalyticsStat)event fromSource:(BlogDetailsNavigationSource)source { - NSString *sourceString; + NSString *sourceString = [self propertiesStringForSource:source]; + [WPAppAnalytics track:event withProperties:@{WPAppAnalyticsKeyTapSource: sourceString, WPAppAnalyticsKeyTabSource: @"site_menu"} withBlog:self.blog]; +} + +- (NSString *)propertiesStringForSource:(BlogDetailsNavigationSource)source { switch (source) { case BlogDetailsNavigationSourceRow: - sourceString = @"row"; - break; - + return @"row"; case BlogDetailsNavigationSourceLink: - sourceString = @"link"; - break; - + return @"link"; case BlogDetailsNavigationSourceButton: - sourceString = @"button"; - break; - + return @"button"; default: - break; + return @""; } - - [WPAppAnalytics track:event withProperties:@{@"tap_source": sourceString} withBlog:self.blog]; } - (void)preloadBlogData @@ -1294,6 +1711,7 @@ - (void)preloadBlogData [self preloadPages]; [self preloadComments]; [self preloadMetadata]; + [self preloadDomains]; } } @@ -1345,7 +1763,7 @@ - (void)preloadPostsOfType:(PostServiceType)postType NSError *error = nil; [self.blog.managedObjectContext save:&error]; - [postService syncPostsOfType:postType withOptions:options forBlog:self.blog success:nil failure:^(NSError *error) { + [postService syncPostsOfType:postType withOptions:options forBlog:self.blog success:nil failure:^(NSError * __unused error) { NSDate *invalidatedDate = [NSDate dateWithTimeIntervalSince1970:0.0]; if ([postType isEqual:PostServiceTypePage]) { self.blog.lastPagesSync = invalidatedDate; @@ -1358,11 +1776,10 @@ - (void)preloadPostsOfType:(PostServiceType)postType - (void)preloadComments { - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:context]; + CommentService *commentService = [[CommentService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; if ([CommentService shouldRefreshCacheFor:self.blog]) { - [commentService syncCommentsForBlog:self.blog success:nil failure:nil]; + [commentService syncCommentsForBlog:self.blog withStatus:CommentStatusFilterAll success:nil failure:nil]; } } @@ -1376,18 +1793,31 @@ - (void)preloadMetadata }]; } +- (void)preloadDomains +{ + if (![self shouldAddDomainRegistrationRow]) { + return; + } + + [self.blogService refreshDomainsFor:self.blog + success:nil + failure:nil]; +} + - (void)scrollToElement:(QuickStartTourElement) element { int sectionCount = 0; int rowCount = 0; + + MySiteViewController *parentVC = (MySiteViewController *)self.parentViewController; + for (BlogDetailsSection *section in self.tableSections) { rowCount = 0; for (BlogDetailsRow *row in section.rows) { if (row.quickStartIdentifier == element) { - self.additionalSafeAreaInsets = UIEdgeInsetsMake(0, 0, 80.0, 0); - NSIndexPath *path = [NSIndexPath indexPathForRow:rowCount inSection:sectionCount]; - [self.tableView scrollToRowAtIndexPath:path atScrollPosition:UITableViewScrollPositionTop animated:true]; + UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:path]; + [parentVC.scrollView scrollVerticallyToView:cell animated:true]; } rowCount++; } @@ -1395,80 +1825,99 @@ - (void)scrollToElement:(QuickStartTourElement) element } } -- (void)showComments +- (void)showCommentsFromSource:(BlogDetailsNavigationSource)source { - [WPAppAnalytics track:WPAnalyticsStatOpenedComments withBlog:self.blog]; - CommentsViewController *controller = [[CommentsViewController alloc] initWithStyle:UITableViewStylePlain]; - controller.blog = self.blog; - [self showDetailViewController:controller sender:self]; + [self trackEvent:WPAnalyticsStatOpenedComments fromSource:source]; + CommentsViewController *controller = [CommentsViewController controllerWithBlog:self.blog]; + controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + [self.presentationDelegate presentBlogDetailsViewController:controller]; - [[QuickStartTourGuide find] visited:QuickStartTourElementBlogDetailNavigation]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementBlogDetailNavigation]; } - (void)showPostListFromSource:(BlogDetailsNavigationSource)source { [self trackEvent:WPAnalyticsStatOpenedPosts fromSource:source]; PostListViewController *controller = [PostListViewController controllerWithBlog:self.blog]; - [self showDetailViewController:controller sender:self]; + controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + [self.presentationDelegate presentBlogDetailsViewController:controller]; - [[QuickStartTourGuide find] visited:QuickStartTourElementBlogDetailNavigation]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementBlogDetailNavigation]; } - (void)showPageListFromSource:(BlogDetailsNavigationSource)source { [self trackEvent:WPAnalyticsStatOpenedPages fromSource:source]; PageListViewController *controller = [PageListViewController controllerWithBlog:self.blog]; - [self showDetailViewController:controller sender:self]; + controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + [self.presentationDelegate presentBlogDetailsViewController:controller]; - [[QuickStartTourGuide find] visited:QuickStartTourElementPages]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementPages]; } - (void)showMediaLibraryFromSource:(BlogDetailsNavigationSource)source { [self trackEvent:WPAnalyticsStatOpenedMediaLibrary fromSource:source]; MediaLibraryViewController *controller = [[MediaLibraryViewController alloc] initWithBlog:self.blog]; - [self showDetailViewController:controller sender:self]; + controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + [self.presentationDelegate presentBlogDetailsViewController:controller]; - [[QuickStartTourGuide find] visited:QuickStartTourElementBlogDetailNavigation]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementMediaScreen]; } - (void)showPeople { - [WPAppAnalytics track:WPAnalyticsStatOpenedPeople withBlog:self.blog]; - PeopleViewController *controller = [PeopleViewController controllerWithBlog:self.blog]; - [self showDetailViewController:controller sender:self]; + UIViewController *controller = [PeopleViewController withJPBannerForBlog:self.blog]; + [self.presentationDelegate presentBlogDetailsViewController:controller]; - [[QuickStartTourGuide find] visited:QuickStartTourElementBlogDetailNavigation]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementBlogDetailNavigation]; } - (void)showPlugins { [WPAppAnalytics track:WPAnalyticsStatOpenedPluginDirectory withBlog:self.blog]; - PluginDirectoryViewController *controller = [[PluginDirectoryViewController alloc] initWithBlog:self.blog]; - [self showDetailViewController:controller sender:self]; + PluginDirectoryViewController *controller = [self makePluginDirectoryViewControllerWithBlog:self.blog]; + controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + [self.presentationDelegate presentBlogDetailsViewController:controller]; - [[QuickStartTourGuide find] visited:QuickStartTourElementBlogDetailNavigation]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementBlogDetailNavigation]; } -- (void)showPlans +- (void)showPlansFromSource:(BlogDetailsNavigationSource)source { - [WPAppAnalytics track:WPAnalyticsStatOpenedPlans withBlog:self.blog]; + [self trackEvent:WPAnalyticsStatOpenedPlans fromSource:source]; PlanListViewController *controller = [[PlanListViewController alloc] initWithStyle:UITableViewStyleGrouped]; - [self showDetailViewController:controller sender:self]; + controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + [self.presentationDelegate presentBlogDetailsViewController:controller]; - [[QuickStartTourGuide find] visited:QuickStartTourElementPlans]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementPlans]; } -- (void)showSettings +- (void)showSettingsFromSource:(BlogDetailsNavigationSource)source { - [WPAppAnalytics track:WPAnalyticsStatOpenedSiteSettings withBlog:self.blog]; + [self trackEvent:WPAnalyticsStatOpenedSiteSettings fromSource:source]; SiteSettingsViewController *controller = [[SiteSettingsViewController alloc] initWithBlog:self.blog]; - [self showDetailViewController:controller sender:self]; + controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + [self.presentationDelegate presentBlogDetailsViewController:controller]; + + [[QuickStartTourGuide shared] visited:QuickStartTourElementBlogDetailNavigation]; +} - [[QuickStartTourGuide find] visited:QuickStartTourElementBlogDetailNavigation]; +- (void)showDomainsFromSource:(BlogDetailsNavigationSource)source +{ + [DomainsDashboardCoordinator presentDomainsDashboardWithPresenter:self.presentationDelegate + source:[self propertiesStringForSource:source] + blog:self.blog]; +} + +-(void)showJetpackSettings +{ + JetpackSettingsViewController *controller = [[JetpackSettingsViewController alloc] initWithBlog:self.blog]; + controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + [self.presentationDelegate presentBlogDetailsViewController:controller]; } -- (void)showSharing +- (void)showSharingFromSource:(BlogDetailsNavigationSource)source { UIViewController *controller; if (![self.blog supportsPublicize]) { @@ -1476,20 +1925,20 @@ - (void)showSharing controller = [[SharingButtonsViewController alloc] initWithBlog:self.blog]; } else { - controller = [[SharingViewController alloc] initWithBlog:self.blog]; + controller = [[SharingViewController alloc] initWithBlog:self.blog delegate:nil]; } - [WPAppAnalytics track:WPAnalyticsStatOpenedSharingManagement withBlog:self.blog]; - [self showDetailViewController:controller sender:self]; + [self trackEvent:WPAnalyticsStatOpenedSharingManagement fromSource:source]; + controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + [self.presentationDelegate presentBlogDetailsViewController:controller]; - [[QuickStartTourGuide find] visited:QuickStartTourElementSharing]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementSharing]; } - (void)showStatsFromSource:(BlogDetailsNavigationSource)source { [self trackEvent:WPAnalyticsStatStatsAccessed fromSource:source]; - StatsViewController *statsView = [StatsViewController new]; - statsView.blog = self.blog; + UIViewController *statsView = [self viewControllerForStats]; // Calling `showDetailViewController:sender:` should do this automatically for us, // but when showing stats from our 3D Touch shortcut iOS sometimes incorrectly @@ -1499,52 +1948,114 @@ - (void)showStatsFromSource:(BlogDetailsNavigationSource)source if (self.splitViewController.isCollapsed) { [self.navigationController pushViewController:statsView animated:YES]; } else { - [self showDetailViewController:statsView sender:self]; + [self.presentationDelegate presentBlogDetailsViewController:statsView]; } - [[QuickStartTourGuide find] visited:QuickStartTourElementStats]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementStats]; +} + +- (void)showDashboard +{ + BlogDashboardViewController *controller = [[BlogDashboardViewController alloc] initWithBlog:self.blog embeddedInScrollView:NO]; + controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + controller.extendedLayoutIncludesOpaqueBars = YES; + [self.presentationDelegate presentBlogDetailsViewController:controller]; } - (void)showActivity { - ActivityListViewController *controller = [[ActivityListViewController alloc] initWithBlog:self.blog]; - [self showDetailViewController:controller sender:self]; + JetpackActivityLogViewController *controller = [[JetpackActivityLogViewController alloc] initWithBlog:self.blog]; + controller.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + [self.presentationDelegate presentBlogDetailsViewController:controller]; + + [[QuickStartTourGuide shared] visited:QuickStartTourElementBlogDetailNavigation]; + + [WPAnalytics track:WPAnalyticsStatActivityLogViewed + withProperties:@{WPAppAnalyticsKeyTapSource: @"site_menu"}]; +} + +- (void)showBlaze +{ + [BlazeEventsTracker trackEntryPointTappedFor:BlazeSourceMenuItem]; + + [BlazeFlowCoordinator presentBlazeInViewController:self + source:BlazeSourceMenuItem + blog:self.blog + post:nil]; +} + +- (void)showScan +{ + UIViewController *controller = [JetpackScanViewController withJPBannerForBlog:self.blog]; + [self.presentationDelegate presentBlogDetailsViewController:controller]; - [[QuickStartTourGuide find] visited:QuickStartTourElementBlogDetailNavigation]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementBlogDetailNavigation]; +} + +- (void)showBackup +{ + UIViewController *controller = [BackupListViewController withJPBannerForBlog:self.blog]; + [self.presentationDelegate presentBlogDetailsViewController:controller]; } - (void)showThemes { [WPAppAnalytics track:WPAnalyticsStatThemesAccessedThemeBrowser withBlog:self.blog]; ThemeBrowserViewController *viewController = [ThemeBrowserViewController browserWithBlog:self.blog]; - [self showDetailViewController:viewController sender:self]; + viewController.onWebkitViewControllerClose = ^(void) { + [self startAlertTimer]; + }; + UIViewController *jpWrappedViewController = [viewController withJPBanner]; + [self.presentationDelegate presentBlogDetailsViewController:jpWrappedViewController]; - [[QuickStartTourGuide find] visited:QuickStartTourElementThemes]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementThemes]; } - (void)showMenus { [WPAppAnalytics track:WPAnalyticsStatMenusAccessed withBlog:self.blog]; - MenusViewController *viewController = [MenusViewController controllerWithBlog:self.blog]; - [self showDetailViewController:viewController sender:self]; + UIViewController *viewController = [MenusViewController withJPBannerForBlog:self.blog]; + [self.presentationDelegate presentBlogDetailsViewController:viewController]; - [[QuickStartTourGuide find] visited:QuickStartTourElementBlogDetailNavigation]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementBlogDetailNavigation]; } -- (void)showViewSite +- (void)showViewSiteFromSource:(BlogDetailsNavigationSource)source { - [WPAppAnalytics track:WPAnalyticsStatOpenedViewSite withBlog:self.blog]; + [self trackEvent:WPAnalyticsStatOpenedViewSite fromSource:source]; + NSURL *targetURL = [NSURL URLWithString:self.blog.homeURL]; - if (self.blog.jetpack) { - targetURL = [targetURL appendingHideMasterbarParameters]; + void (^onWebViewControllerClose)(void) = ^(void) { + [self startAlertTimer]; + }; + UIViewController *webViewController = [WebViewControllerFactory controllerWithUrl:targetURL + blog:self.blog + source:@"my_site_view_site" + withDeviceModes:true + onClose:onWebViewControllerClose]; + LightNavigationController *navController = [[LightNavigationController alloc] initWithRootViewController:webViewController]; + if (self.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiomPad) { + navController.modalPresentationStyle = UIModalPresentationFullScreen; } - UIViewController *webViewController = [WebViewControllerFactory controllerWithUrl:targetURL blog:self.blog]; - UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:webViewController]; - [self presentViewController:navController animated:YES completion:nil]; + [self presentViewController:navController + animated:YES + completion:nil]; + + MySiteViewController *parentVC = (MySiteViewController *)self.parentViewController; + + QuickStartTourGuide *guide = [QuickStartTourGuide shared]; + + if ([guide isCurrentElement:QuickStartTourElementViewSite]) { + [[QuickStartTourGuide shared] visited:QuickStartTourElementViewSite]; + [parentVC toggleSpotlightOnSitePicker]; + } else { + // Just mark as completed if we've viewed the site and aren't + // currently working on the View Site tour. + [[QuickStartTourGuide shared] completeViewSiteTourForBlog:self.blog]; + } - [[QuickStartTourGuide find] visited:QuickStartTourElementViewSite]; } - (void)showViewAdmin @@ -1564,37 +2075,17 @@ - (void)showViewAdmin } [[UIApplication sharedApplication] openURL:[NSURL URLWithString:dashboardUrl] options:nil completionHandler:nil]; - [[QuickStartTourGuide find] visited:QuickStartTourElementBlogDetailNavigation]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementBlogDetailNavigation]; } -- (void)showNotificationPrimerAlert +- (BOOL)shouldShowJetpackInstallCard { - if ([[NSUserDefaults standardUserDefaults] notificationPrimerAlertWasDisplayed]) { - return; - } - NSManagedObjectContext *mainContext = [[ContextManager sharedInstance] mainContext]; - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:mainContext]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; - if (defaultAccount == nil) { - return; - } - [[PushNotificationsManager shared] loadAuthorizationStatusWithCompletion:^(UNAuthorizationStatus enabled) { - if (enabled == UNAuthorizationStatusNotDetermined) { - [NSUserDefaults standardUserDefaults].notificationPrimerAlertWasDisplayed = YES; - - FancyAlertViewController *alert = [FancyAlertViewController makeNotificationPrimerAlertControllerWithApproveAction:^(FancyAlertViewController* controller) { - [[InteractiveNotificationsManager shared] requestAuthorizationWithCompletion:^() { - dispatch_async(dispatch_get_main_queue(), ^{ - [controller dismissViewControllerAnimated:true completion:^{}]; - }); - }]; - }]; - alert.modalPresentationStyle = UIModalPresentationCustom; - alert.transitioningDelegate = self; - - [self.tabBarController presentViewController:alert animated:YES completion:nil]; - } - }]; + return ![WPDeviceIdentification isiPad] && [JetpackInstallPluginHelper shouldShowCardFor:self.blog]; +} + +- (BOOL)shouldShowBlaze +{ + return [BlazeHelper isBlazeFlagEnabled] && [self.blog supports:BlogFeatureBlaze]; } #pragma mark - Remove Site @@ -1612,7 +2103,7 @@ - (void)showRemoveSiteAlert preferredStyle:alertStyle]; [alertController addCancelActionWithTitle:cancelTitle handler:nil]; - [alertController addDestructiveActionWithTitle:destructiveTitle handler:^(UIAlertAction *action) { + [alertController addDestructiveActionWithTitle:destructiveTitle handler:^(UIAlertAction * __unused action) { [self confirmRemoveSite]; }]; @@ -1621,10 +2112,19 @@ - (void)showRemoveSiteAlert - (void)confirmRemoveSite { - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; + BlogService *blogService = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [blogService removeBlog:self.blog]; [[WordPressAppDelegate shared] trackLogoutIfNeeded]; + + if ([Feature enabled:FeatureFlagContentMigration] && [AppConfiguration isWordPress]) { + [ContentMigrationCoordinator.shared cleanupExportedDataIfNeeded]; + } + + // Delete local data after removing the last site + if (!AccountHelper.isLoggedIn) { + [AccountHelper deleteAccountData]; + } + [self.navigationController popToRootViewControllerAnimated:YES]; } @@ -1635,19 +2135,24 @@ - (void)handleDataModelChange:(NSNotification *)note NSSet *deletedObjects = note.userInfo[NSDeletedObjectsKey]; if ([deletedObjects containsObject:self.blog]) { [self.navigationController popToRootViewControllerAnimated:NO]; + return; + } + + if (self.blog.account == nil || self.blog.account.isDeleted) { + // No need to reload this screen if the blog's account is deleted (i.e. during logout) + return; } BOOL isQuickStartSectionShownBefore = [self findSectionIndexWithSections:self.tableSections category:BlogDetailsSectionCategoryQuickStart] != NSNotFound; NSSet *updatedObjects = note.userInfo[NSUpdatedObjectsKey]; if ([updatedObjects containsObject:self.blog] || [updatedObjects containsObject:self.blog.settings]) { - self.navigationItem.title = self.blog.settings.name; [self configureTableViewData]; BOOL isQuickStartSectionShownAfter = [self findSectionIndexWithSections:self.tableSections category:BlogDetailsSectionCategoryQuickStart] != NSNotFound; // quick start was just enabled if (!isQuickStartSectionShownBefore && isQuickStartSectionShownAfter) { - [self showQuickStartCustomize]; + [self showQuickStart]; } [self reloadTableViewPreservingSelection]; } @@ -1657,10 +2162,15 @@ - (void)handleDataModelChange:(NSNotification *)note - (UIViewController *)initialDetailViewControllerForSplitView:(WPSplitViewController *)splitView { - StatsViewController *statsView = [StatsViewController new]; - statsView.blog = self.blog; - - return statsView; + if ([self shouldShowStats]) { + StatsViewController *statsView = [StatsViewController new]; + statsView.blog = self.blog; + return statsView; + } else { + PostListViewController *postsView = [PostListViewController controllerWithBlog:self.blog]; + postsView.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + return postsView; + } } #pragma mark - UIViewControllerTransitioningDelegate @@ -1675,4 +2185,45 @@ - (UIPresentationController *)presentationControllerForPresentedViewController:( return nil; } +#pragma mark - Domain Registration + +- (void)updateTableViewAndHeader +{ + [self updateTableView:^{}]; +} + +/// This method syncs the blog and its metadata, then reloads the table view. +/// +- (void)updateTableView:(void(^)(void))completion +{ + __weak __typeof(self) weakSelf = self; + [self.blogService syncBlogAndAllMetadata:self.blog + completionHandler: + ^{ + [weakSelf configureTableViewData]; + [weakSelf reloadTableViewPreservingSelection]; + completion(); + }]; +} + +#pragma mark - Pull To Refresh + +- (void)pulledToRefresh { + [self pulledToRefreshWith:self.tableView.refreshControl onCompletion:^{}]; +} + +- (void)pulledToRefreshWith:(UIRefreshControl *)refreshControl onCompletion:( void(^)(void))completion { + + [self updateTableView: ^{ + // WORKAROUND: if we don't dispatch this asynchronously, the refresh end animation is clunky. + // To recognize if we can remove this, simply remove the dispatch_async call and test pulling + // down to refresh the site. + dispatch_async(dispatch_get_main_queue(), ^(void){ + [refreshControl endRefreshing]; + + completion(); + }); + }]; +} + @end diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/ActionRow.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/ActionRow.swift index dd3362773945..a05400320d24 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/ActionRow.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/ActionRow.swift @@ -3,9 +3,9 @@ class ActionButton: UIView { private enum Constants { static let maxButtonSize: CGFloat = 56 static let spacing: CGFloat = 8 - static let borderColor = UIColor.secondaryButtonBorder - static let backgroundColor = UIColor.secondaryButtonBackground - static let selectedBackgroundColor = UIColor.secondaryButtonDownBackground + static let borderColor = UIColor.quickActionButtonBorder + static let backgroundColor = UIColor.quickActionButtonBackground + static let selectedBackgroundColor = UIColor.quickActionSelectedBackground static let iconColor = UIColor.listIcon } @@ -73,6 +73,7 @@ class ActionRow: UIStackView { enum Constants { static let minimumSpacing: CGFloat = 8 + static let margins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) } struct Item { @@ -92,5 +93,27 @@ class ActionRow: UIStackView { distribution = .equalCentering spacing = Constants.minimumSpacing translatesAutoresizingMaskIntoConstraints = false + refreshStackViewVisibility() + + layoutMargins = Constants.margins + isLayoutMarginsRelativeArrangement = true + } + + // MARK: - Accessibility + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + refreshStackViewVisibility() + } + + private func refreshStackViewVisibility() { + for view in arrangedSubviews { + if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + view.isHidden = true + } else { + view.isHidden = false + } + } } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/BlogDetailHeaderView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/BlogDetailHeaderView.swift new file mode 100644 index 000000000000..9e3592c4e1cb --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/BlogDetailHeaderView.swift @@ -0,0 +1,388 @@ +import Gridicons +import UIKit + +@objc protocol BlogDetailHeaderViewDelegate { + func siteIconTapped() + func siteIconReceivedDroppedImage(_ image: UIImage?) + func siteIconShouldAllowDroppedImages() -> Bool + func siteTitleTapped() + func siteSwitcherTapped() + func visitSiteTapped() +} + +class BlogDetailHeaderView: UIView { + + // MARK: - Child Views + + private let titleView: TitleView + + // MARK: - Delegate + + @objc weak var delegate: BlogDetailHeaderViewDelegate? + + @objc var updatingIcon: Bool = false { + didSet { + if updatingIcon { + titleView.siteIconView.activityIndicator.startAnimating() + } else { + titleView.siteIconView.activityIndicator.stopAnimating() + } + } + } + + @objc var blavatarImageView: UIImageView { + return titleView.siteIconView.imageView + } + + @objc var blog: Blog? { + didSet { + refreshIconImage() + toggleSpotlightOnSiteTitle() + toggleSpotlightOnSiteUrl() + refreshSiteTitle() + + if let displayURL = blog?.displayURL as String? { + titleView.set(url: displayURL) + } + + titleView.siteIconView.allowsDropInteraction = delegate?.siteIconShouldAllowDroppedImages() == true + } + } + + @objc func refreshIconImage() { + if let blog = blog, + blog.hasIcon == true { + titleView.siteIconView.imageView.downloadSiteIcon(for: blog) + } else if let blog = blog, + blog.isWPForTeams() { + titleView.siteIconView.imageView.tintColor = UIColor.listIcon + titleView.siteIconView.imageView.image = UIImage.gridicon(.p2) + } else { + titleView.siteIconView.imageView.image = UIImage.siteIconPlaceholder + } + + toggleSpotlightOnSiteIcon() + } + + func setTitleLoading(_ isLoading: Bool) { + isLoading ? titleView.titleButton.startLoading() : titleView.titleButton.stopLoading() + } + + func refreshSiteTitle() { + let blogName = blog?.settings?.name + let title = blogName != nil && blogName?.isEmpty == false ? blogName : blog?.displayURL as String? + titleView.titleButton.setTitle(title, for: .normal) + } + + func toggleSpotlightOnSiteTitle() { + titleView.titleButton.shouldShowSpotlight = QuickStartTourGuide.shared.isCurrentElement(.siteTitle) + } + + func toggleSpotlightOnSiteUrl() { + titleView.subtitleButton.shouldShowSpotlight = QuickStartTourGuide.shared.isCurrentElement(.viewSite) + } + + func toggleSpotlightOnSiteIcon() { + titleView.siteIconView.spotlightIsShown = QuickStartTourGuide.shared.isCurrentElement(.siteIcon) + } + + private enum LayoutSpacing { + static let atSides: CGFloat = 20 + static let top: CGFloat = 10 + static let bottom: CGFloat = 16 + static let belowActionRow: CGFloat = 24 + static func betweenTitleViewAndActionRow(_ showsActionRow: Bool) -> CGFloat { + return showsActionRow ? 32 : 0 + } + + static let spacingBelowIcon: CGFloat = 16 + static let spacingBelowTitle: CGFloat = 8 + static let minimumSideSpacing: CGFloat = 8 + static let interSectionSpacing: CGFloat = 32 + static let buttonsBottomPadding: CGFloat = 40 + static let buttonsSidePadding: CGFloat = 40 + static let maxButtonWidth: CGFloat = 390 + static let siteIconSize = CGSize(width: 48, height: 48) + } + + // MARK: - Initializers + + required init(items: [ActionRow.Item]) { + titleView = TitleView(frame: .zero) + + super.init(frame: .zero) + + setupChildViews(items: items) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Child View Initialization + + private func setupChildViews(items: [ActionRow.Item]) { + titleView.siteIconView.tapped = { [weak self] in + QuickStartTourGuide.shared.visited(.siteIcon) + self?.titleView.siteIconView.spotlightIsShown = false + + self?.delegate?.siteIconTapped() + } + + titleView.siteIconView.dropped = { [weak self] images in + self?.delegate?.siteIconReceivedDroppedImage(images.first) + } + + titleView.subtitleButton.addTarget(self, action: #selector(subtitleButtonTapped), for: .touchUpInside) + titleView.titleButton.addTarget(self, action: #selector(titleButtonTapped), for: .touchUpInside) + titleView.siteSwitcherButton.addTarget(self, action: #selector(siteSwitcherTapped), for: .touchUpInside) + + titleView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(titleView) + + let showsActionRow = items.count > 0 + setupConstraintsForChildViews(showsActionRow) + } + + // MARK: - Constraints + + private var topActionRowConstraint: NSLayoutConstraint? + + private func setupConstraintsForChildViews(_ showsActionRow: Bool) { + let constraints = constraintsForTitleView() + + NSLayoutConstraint.activate(constraints) + } + + private func constraintsForTitleView() -> [NSLayoutConstraint] { + return [ + titleView.topAnchor.constraint(equalTo: topAnchor, constant: LayoutSpacing.top), + titleView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: LayoutSpacing.atSides), + titleView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -LayoutSpacing.atSides), + titleView.bottomAnchor.constraint(equalTo: bottomAnchor) + ] + } + + // MARK: - User Action Handlers + + @objc + private func siteSwitcherTapped() { + delegate?.siteSwitcherTapped() + } + + @objc + private func titleButtonTapped() { + QuickStartTourGuide.shared.visited(.siteTitle) + titleView.titleButton.shouldShowSpotlight = false + + delegate?.siteTitleTapped() + } + + @objc + private func subtitleButtonTapped() { + delegate?.visitSiteTapped() + } + + // MARK: - Accessibility + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + refreshStackViewVisibility() + } + + private func refreshStackViewVisibility() { + let showsActionRow = !traitCollection.preferredContentSizeCategory.isAccessibilityCategory + + topActionRowConstraint?.constant = LayoutSpacing.betweenTitleViewAndActionRow(showsActionRow) + } +} + +fileprivate extension BlogDetailHeaderView { + class TitleView: UIView { + + private enum LabelMinimumScaleFactor { + static let regular: CGFloat = 0.75 + static let accessibility: CGFloat = 0.5 + } + + private enum Dimensions { + static let siteIconHeight: CGFloat = 64 + static let siteIconWidth: CGFloat = 64 + static let siteSwitcherHeight: CGFloat = 36 + static let siteSwitcherWidth: CGFloat = 36 + } + + private enum LayoutSpacing { + static let betweenTitleAndSubtitleButtons: CGFloat = 8 + static let betweenSiteIconAndTitle: CGFloat = 16 + static let betweenTitleAndSiteSwitcher: CGFloat = 16 + static let betweenSiteSwitcherAndRightPadding: CGFloat = 4 + static let subtitleButtonImageInsets = UIEdgeInsets(top: 1, left: 4, bottom: 0, right: 0) + static let rtlSubtitleButtonImageInsets = UIEdgeInsets(top: 1, left: -4, bottom: 0, right: 4) + } + + // MARK: - Child Views + + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + siteIconView, + titleStackView, + siteSwitcherButton + ]) + + stackView.alignment = .center + stackView.spacing = LayoutSpacing.betweenSiteIconAndTitle + stackView.translatesAutoresizingMaskIntoConstraints = false + + return stackView + }() + + let siteIconView: SiteIconView = { + let siteIconView = SiteIconView(frame: .zero) + siteIconView.translatesAutoresizingMaskIntoConstraints = false + return siteIconView + }() + + let subtitleButton: SpotlightableButton = { + let button = SpotlightableButton(type: .custom) + + button.titleLabel?.font = WPStyleGuide.fontForTextStyle(.footnote) + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.titleLabel?.adjustsFontSizeToFitWidth = true + button.titleLabel?.minimumScaleFactor = LabelMinimumScaleFactor.regular + button.titleLabel?.lineBreakMode = .byTruncatingTail + + button.setTitleColor(.primary, for: .normal) + button.accessibilityHint = NSLocalizedString("Tap to view your site", comment: "Accessibility hint for button used to view the user's site") + + if let pointSize = button.titleLabel?.font.pointSize { + button.setImage(UIImage.gridicon(.external, size: CGSize(width: pointSize, height: pointSize)), for: .normal) + } + + // Align the image to the right + if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft { + button.semanticContentAttribute = .forceLeftToRight + button.imageEdgeInsets = LayoutSpacing.rtlSubtitleButtonImageInsets + } else { + button.semanticContentAttribute = .forceRightToLeft + button.imageEdgeInsets = LayoutSpacing.subtitleButtonImageInsets + } + + button.translatesAutoresizingMaskIntoConstraints = false + + return button + }() + + let titleButton: SpotlightableButton = { + let button = SpotlightableButton(type: .custom) + button.spotlightHorizontalPosition = .trailing + button.contentHorizontalAlignment = .leading + button.titleLabel?.font = AppStyleGuide.blogDetailHeaderTitleFont + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.titleLabel?.adjustsFontSizeToFitWidth = true + button.titleLabel?.minimumScaleFactor = LabelMinimumScaleFactor.regular + button.titleLabel?.lineBreakMode = .byTruncatingTail + button.titleLabel?.numberOfLines = 1 + + button.accessibilityHint = NSLocalizedString("Tap to change the site's title", comment: "Accessibility hint for button used to change site title") + + // I don't understand why this is needed, but without it the button has additional + // vertical padding, so it's more difficult to get the spacing we want. + button.setImage(UIImage(), for: .normal) + + button.setTitleColor(.text, for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + return button + }() + + let siteSwitcherButton: UIButton = { + let button = UIButton(frame: .zero) + let image = UIImage.gridicon(.chevronDown) + + button.setImage(image, for: .normal) + button.contentMode = .center + button.translatesAutoresizingMaskIntoConstraints = false + button.tintColor = .gray + button.accessibilityLabel = NSLocalizedString("Switch Site", comment: "Button used to switch site") + button.accessibilityHint = NSLocalizedString("Tap to switch to another site, or add a new site", comment: "Accessibility hint for button used to switch site") + button.accessibilityIdentifier = "SwitchSiteButton" + + return button + }() + + private(set) lazy var titleStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + titleButton, + subtitleButton + ]) + + stackView.alignment = .leading + stackView.distribution = .equalSpacing + stackView.axis = .vertical + stackView.spacing = LayoutSpacing.betweenTitleAndSubtitleButtons + stackView.translatesAutoresizingMaskIntoConstraints = false + + return stackView + }() + + // MARK: - Initializers + + override init(frame: CGRect) { + super.init(frame: frame) + + setupChildViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configuration + + func set(url: String) { + subtitleButton.setTitle(url, for: .normal) + } + + // MARK: - Accessibility + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + refreshMainStackViewAxis() + } + + // MARK: - Child View Setup + + private func setupChildViews() { + refreshMainStackViewAxis() + addSubview(mainStackView) + pinSubviewToAllEdges(mainStackView) + setupConstraintsForSiteSwitcher() + } + + private func refreshMainStackViewAxis() { + if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + mainStackView.axis = .vertical + + titleButton.titleLabel?.minimumScaleFactor = LabelMinimumScaleFactor.accessibility + subtitleButton.titleLabel?.minimumScaleFactor = LabelMinimumScaleFactor.accessibility + } else { + mainStackView.axis = .horizontal + + titleButton.titleLabel?.minimumScaleFactor = LabelMinimumScaleFactor.regular + subtitleButton.titleLabel?.minimumScaleFactor = LabelMinimumScaleFactor.regular + } + } + + // MARK: - Constraints + + private func setupConstraintsForSiteSwitcher() { + NSLayoutConstraint.activate([ + siteSwitcherButton.heightAnchor.constraint(equalToConstant: Dimensions.siteSwitcherHeight), + siteSwitcherButton.widthAnchor.constraint(equalToConstant: Dimensions.siteSwitcherWidth) + ]) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/NewBlogDetailHeaderView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/NewBlogDetailHeaderView.swift deleted file mode 100644 index 114b271481ac..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/NewBlogDetailHeaderView.swift +++ /dev/null @@ -1,146 +0,0 @@ -class NewBlogDetailHeaderView: UIView { - - @objc weak var delegate: BlogDetailHeaderViewDelegate? - - private let titleLabel: UILabel = { - let label = UILabel() - label.font = WPStyleGuide.fontForTextStyle(.title2, fontWeight: .bold) - label.adjustsFontForContentSizeCategory = true - return label - }() - - private let subtitleLabel: UILabel = { - let label = UILabel() - label.font = WPStyleGuide.fontForTextStyle(.subheadline) - label.textColor = UIColor.textSubtle - label.adjustsFontForContentSizeCategory = true - return label - }() - - private let siteIconView: SiteIconView = { - let view = SiteIconView(frame: .zero) - return view - }() - - @objc var updatingIcon: Bool = false { - didSet { - if updatingIcon { - siteIconView.activityIndicator.startAnimating() - } else { - siteIconView.activityIndicator.stopAnimating() - } - } - } - - @objc var blavatarImageView: UIImageView { - return siteIconView.imageView - } - - @objc var blog: Blog? { - didSet { - refreshIconImage() - - let blogName = blog?.settings?.name - let title = blogName != nil && blogName?.isEmpty == false ? blogName : blog?.displayURL as String? - titleLabel.text = title - subtitleLabel.text = blog?.displayURL as String? - - siteIconView.allowsDropInteraction = delegate?.siteIconShouldAllowDroppedImages() == true - } - } - - func refreshIconImage() { - if let blog = blog, - blog.hasIcon == true { - siteIconView.imageView.downloadSiteIcon(for: blog) - } else { - siteIconView.imageView.image = UIImage.siteIconPlaceholder - } - - siteIconView.spotlightIsShown = QuickStartTourGuide.find()?.isCurrentElement(.siteIcon) == true - } - - private enum Constants { - static let spacingBelowIcon: CGFloat = 16 - static let spacingBelowTitle: CGFloat = 8 - static let minimumSideSpacing: CGFloat = 8 - static let interSectionSpacing: CGFloat = 32 - static let buttonsBottomPadding: CGFloat = 40 - static let buttonsSidePadding: CGFloat = 40 - } - - convenience init(items: [ActionRow.Item]) { - - self.init(frame: .zero) - - siteIconView.tapped = { [weak self] in - QuickStartTourGuide.find()?.visited(.siteIcon) - self?.siteIconView.spotlightIsShown = false - - self?.delegate?.siteIconTapped() - } - - siteIconView.dropped = { [weak self] images in - self?.delegate?.siteIconReceivedDroppedImage(images.first) - } - - let buttonsStackView = ActionRow(items: items) - - let stackView = UIStackView(arrangedSubviews: [ - siteIconView, - titleLabel, - subtitleLabel, - ]) - - stackView.axis = .vertical - stackView.alignment = .center - stackView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) - stackView.translatesAutoresizingMaskIntoConstraints = false - - addSubview(stackView) - - addSubview(buttonsStackView) - - stackView.setCustomSpacing(Constants.spacingBelowIcon, after: siteIconView) - stackView.setCustomSpacing(Constants.spacingBelowTitle, after: titleLabel) - - /// Constraints for larger widths with extra padding (iPhone portrait) - let extraPaddingSideConstraints = [ - buttonsStackView.trailingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: -Constants.buttonsSidePadding), - buttonsStackView.leadingAnchor.constraint(lessThanOrEqualTo: stackView.leadingAnchor, constant: Constants.buttonsSidePadding) - ] - - /// Constraints for constrained widths (iPad portrait) - let minimumPaddingSideConstraints = [ - buttonsStackView.leadingAnchor.constraint(greaterThanOrEqualTo: stackView.leadingAnchor, constant: 0), - buttonsStackView.trailingAnchor.constraint(lessThanOrEqualTo: stackView.trailingAnchor, constant: 0), - ] - - let bottomConstraint = buttonsStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.buttonsBottomPadding) - bottomConstraint.priority = UILayoutPriority(999) // Allow to break so encapsulated height (on initial table view load) doesn't spew warnings - - /// If we are able to attach to the safe area's leading edge, we should, otherwise it can break - let leadingSafeAreaConstraint = stackView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor) - leadingSafeAreaConstraint.priority = .defaultHigh - - let edgeConstraints = [ - leadingSafeAreaConstraint, - stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), - stackView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: Constants.minimumSideSpacing), - stackView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.interSectionSpacing), - buttonsStackView.topAnchor.constraint(equalTo: stackView.bottomAnchor, constant: Constants.interSectionSpacing), - buttonsStackView.centerXAnchor.constraint(equalTo: stackView.centerXAnchor), - bottomConstraint - ] - - NSLayoutConstraint.activate(extraPaddingSideConstraints + minimumPaddingSideConstraints + edgeConstraints) - } - - override init(frame: CGRect) { - super.init(frame: frame) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/SiteIconView.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/SiteIconView.swift index 782d574a821f..29ecc61b6a89 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/SiteIconView.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/Detail Header/SiteIconView.swift @@ -4,8 +4,7 @@ class SiteIconView: UIView { static let imageSize: CGFloat = 64 static let borderRadius: CGFloat = 4 static let imageRadius: CGFloat = 2 - static let imagePadding: CGFloat = 4 - static let spotlightOffset: CGFloat = -8 + static let spotlightOffset: CGFloat = 8 } /// Whether or not to show the spotlight animation to illustrate tapping the icon. @@ -34,7 +33,7 @@ class SiteIconView: UIView { }() let activityIndicator: UIActivityIndicatorView = { - let indicatorView = UIActivityIndicatorView(style: .whiteLarge) + let indicatorView = UIActivityIndicatorView(style: .large) indicatorView.translatesAutoresizingMaskIntoConstraints = false return indicatorView }() @@ -73,13 +72,12 @@ class SiteIconView: UIView { } } - override init(frame: CGRect) { + init(frame: CGRect, insets: UIEdgeInsets = .zero) { super.init(frame: frame) - let paddingInsets = UIEdgeInsets(top: Constants.imagePadding, left: Constants.imagePadding, bottom: Constants.imagePadding, right: Constants.imagePadding) - button.addSubview(imageView) - button.pinSubviewToAllEdges(imageView, insets: paddingInsets) + button.pinSubviewToAllEdges(imageView, insets: insets) + button.addTarget(self, action: #selector(touchedButton), for: .touchUpInside) button.translatesAutoresizingMaskIntoConstraints = false @@ -94,8 +92,8 @@ class SiteIconView: UIView { addSubview(spotlightView) NSLayoutConstraint.activate([ - trailingAnchor.constraint(equalTo: spotlightView.trailingAnchor, constant: Constants.spotlightOffset), - bottomAnchor.constraint(equalTo: spotlightView.bottomAnchor, constant: Constants.spotlightOffset) + leadingAnchor.constraint(equalTo: spotlightView.leadingAnchor, constant: Constants.spotlightOffset), + topAnchor.constraint(equalTo: spotlightView.topAnchor, constant: Constants.spotlightOffset) ]) pinSubviewToAllEdges(button) diff --git a/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListDataSource.swift b/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListDataSource.swift index f933b2040bb6..80baebf50ed9 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListDataSource.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListDataSource.swift @@ -82,6 +82,20 @@ class BlogListDataSource: NSObject { @objc let recentSitesMinCount = 11 + /// If this is set to `false`, the rows will never show the disclosure indicator. + /// + @objc var shouldShowDisclosureIndicator = true + + /// If this is set to `true`, blogs that are not accessible through the WP API will be hidden + /// + @objc var shouldHideSelfHostedSites = false { + didSet { + if shouldHideSelfHostedSites != oldValue { + dataChanged?() + } + } + } + // MARK: - Inputs // Pass to the LoggedInDataSource to match a specifc blog. @@ -159,8 +173,8 @@ class BlogListDataSource: NSObject { return nil } - @objc var allBlogsCount: Int { - return allBlogs.count + @objc var blogsCount: Int { + return filteredBlogs.count } @objc var displayedBlogsCount: Int { @@ -170,7 +184,7 @@ class BlogListDataSource: NSObject { } @objc var visibleBlogsCount: Int { - return allBlogs.filter({ $0.visible }).count + return filteredBlogs.filter({ $0.visible }).count } // MARK: - Internal properties @@ -274,15 +288,18 @@ private extension BlogListDataSource { if let sections = cachedSections { return sections } - let mappedSections = mode.mapper.map(allBlogs) + let mappedSections = mode.mapper.map(filteredBlogs) cachedSections = mappedSections return mappedSections } - var allBlogs: [Blog] { - guard let blogs = resultsController.fetchedObjects else { + var filteredBlogs: [Blog] { + guard var blogs = resultsController.fetchedObjects else { return [] } + if shouldHideSelfHostedSites { + blogs = blogs.filter { $0.isAccessibleThroughWPCom() } + } guard let account = account else { return blogs } @@ -338,7 +355,7 @@ extension BlogListDataSource: UITableViewDataSource { case .loggedIn: cell.accessoryType = .none default: - cell.accessoryType = .disclosureIndicator + cell.accessoryType = shouldShowDisclosureIndicator ? .disclosureIndicator : .none } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController+SiteCreation.swift b/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController+SiteCreation.swift index 9f976c760891..328a1ad45978 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController+SiteCreation.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController+SiteCreation.swift @@ -1,11 +1,19 @@ extension BlogListViewController { @objc func launchSiteCreation() { - let wizardLauncher = SiteCreationWizardLauncher() - guard let wizard = wizardLauncher.ui else { - return - } - present(wizard, animated: true) - WPAnalytics.track(.enhancedSiteCreationAccessed) + let source = "my_sites" + JetpackFeaturesRemovalCoordinator.presentSiteCreationOverlayIfNeeded(in: self, source: source, onDidDismiss: { + guard JetpackFeaturesRemovalCoordinator.siteCreationPhase() != .two else { + return + } + + // Display site creation flow if not in phase two + let wizardLauncher = SiteCreationWizardLauncher() + guard let wizard = wizardLauncher.ui else { + return + } + self.present(wizard, animated: true) + SiteCreationAnalyticsHelper.trackSiteCreationAccessed(source: source) + }) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.h b/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.h index 4aa33232add1..25a66737bd21 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.h +++ b/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.h @@ -8,13 +8,11 @@ @property (nonatomic, strong) Blog *selectedBlog; @property (nonatomic, strong) id<ScenePresenter> meScenePresenter; - +@property (nonatomic, copy) void (^blogSelected)(BlogListViewController* blogListViewController, Blog* blog); - (id)initWithMeScenePresenter:(id<ScenePresenter>)meScenePresenter; - (void)setSelectedBlog:(Blog *)selectedBlog animated:(BOOL)animated; - (void)presentInterfaceForAddingNewSiteFrom:(UIView *)sourceView; -- (void)bypassBlogListViewController; -- (BOOL)shouldBypassBlogListViewControllerWhenSelectedFromTabBar; @end diff --git a/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.m b/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.m index 78f9be8dc49e..f7d8eb2a082c 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Blog List/BlogListViewController.m @@ -37,27 +37,27 @@ @implementation BlogListViewController + (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)identifierComponents coder:(NSCoder *)coder { - return [[WPTabBarController sharedInstance] blogListViewController]; + return nil; } - (instancetype)init +{ + return [self initWithMeScenePresenter:[MeScenePresenter new]]; +} + +- (instancetype)initWithMeScenePresenter:(id<ScenePresenter>)meScenePresenter { self = [super init]; + if (self) { self.restorationIdentifier = NSStringFromClass([self class]); self.restorationClass = [self class]; + _meScenePresenter = meScenePresenter; + [self configureDataSource]; [self configureNavigationBar]; } - return self; -} - -- (id)initWithMeScenePresenter:(id<ScenePresenter>)meScenePresenter -{ - self = [self init]; - if (self) { - self.meScenePresenter = meScenePresenter; - } + return self; } @@ -65,6 +65,8 @@ - (id)initWithMeScenePresenter:(id<ScenePresenter>)meScenePresenter - (void)configureDataSource { self.dataSource = [BlogListDataSource new]; + self.dataSource.shouldShowDisclosureIndicator = NO; + __weak __typeof(self) weakSelf = self; self.dataSource.visibilityChanged = ^(Blog *blog, BOOL visible) { [weakSelf setVisible:visible forBlog:blog]; @@ -88,10 +90,19 @@ - (void)configureNavigationBar self.addSiteButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd target:self action:@selector(addSite)]; + self.addSiteButton.accessibilityIdentifier = @"add-site-button"; + + self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel target:self action:@selector(cancelTapped)]; + self.navigationItem.leftBarButtonItem.accessibilityIdentifier = @"my-sites-cancel-button"; self.navigationItem.title = NSLocalizedString(@"My Sites", @""); } +- (void)cancelTapped +{ + [self dismissViewControllerAnimated:YES completion:nil]; +} + - (NSString *)modelIdentifierForElementAtIndexPath:(NSIndexPath *)indexPath inView:(UIView *)view { if (!indexPath || !view) { @@ -143,6 +154,8 @@ - (void)viewDidLoad [self registerForAccountChangeNotification]; [self registerForPostSignUpNotifications]; + + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherDisplayed]; } - (void)viewWillAppear:(BOOL)animated @@ -152,13 +165,12 @@ - (void)viewWillAppear:(BOOL)animated self.visible = YES; [self.tableView reloadData]; - [self updateEditButton]; + [self updateBarButtons]; [self updateSearchVisibility]; [self maybeShowNUX]; [self updateViewsForCurrentSiteCount]; [self validateBlogDetailsViewController]; [self syncBlogs]; - [self setAddSiteBarButtonItem]; [self updateCurrentBlogSelection]; } @@ -176,35 +188,25 @@ - (void)viewWillDisappear:(BOOL)animated [self.searchBar resignFirstResponder]; } self.visible = NO; + + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherDismissed]; } - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator { [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; - [coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) { + [coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull __unused context) { if (self.tableView.tableHeaderView == self.headerView) { [self updateHeaderSize]; // this forces the tableHeaderView to resize self.tableView.tableHeaderView = self.headerView; } - } completion:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) { + } completion:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull __unused context) { [self updateCurrentBlogSelection]; }]; } - -- (void)updateEditButton -{ - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; - if ([blogService blogCountForWPComAccounts] > 0) { - self.navigationItem.leftBarButtonItem = self.editButtonItem; - } else { - self.navigationItem.leftBarButtonItem = nil; - } -} - - (void)updateSearchVisibility { if (self.isEditing) { @@ -221,7 +223,7 @@ - (void)updateSearchVisibility - (void)maybeShowNUX { - NSInteger blogCount = self.dataSource.allBlogsCount; + NSInteger blogCount = self.dataSource.blogsCount; BOOL isLoggedIn = AccountHelper.isLoggedIn; if (blogCount > 0 && !isLoggedIn) { @@ -229,14 +231,14 @@ - (void)maybeShowNUX } if (![self defaultWordPressComAccount]) { - [[WordPressAppDelegate shared] showWelcomeScreenIfNeededAnimated:YES]; + [[WordPressAppDelegate shared].windowManager showFullscreenSignIn]; return; } } - (void)updateViewsForCurrentSiteCount { - NSUInteger count = self.dataSource.allBlogsCount; + NSUInteger count = self.dataSource.blogsCount; NSUInteger visibleSitesCount = self.dataSource.visibleBlogsCount; // Ensure No Results VC is not shown. Will be shown later if necessary. @@ -270,7 +272,6 @@ - (void)showNoResultsViewForSiteCount:(NSUInteger)siteCount // added a new site so we should auto-select it if (self.noResultsViewController.beingPresented && siteCount == 1) { [self.noResultsViewController removeFromView]; - [self bypassBlogListViewController]; } [self instantiateNoResultsViewControllerIfNeeded]; @@ -278,6 +279,7 @@ - (void)showNoResultsViewForSiteCount:(NSUInteger)siteCount // If we have no sites, show the No Results VC. if (siteCount == 0) { [self.noResultsViewController configureWithTitle:NSLocalizedString(@"Create a new site for your business, magazine, or personal blog; or connect an existing WordPress installation.", "Text shown when the account has no sites.") + attributedTitle:nil noConnectionTitle:nil buttonTitle:NSLocalizedString(@"Add new site","Title of button to add a new site.") subtitle:nil @@ -295,7 +297,7 @@ - (void)showNoResultsViewForAllSitesHidden { [self instantiateNoResultsViewControllerIfNeeded]; - NSUInteger count = self.dataSource.allBlogsCount; + NSUInteger count = self.dataSource.blogsCount; NSString *singularTitle = NSLocalizedString(@"You have 1 hidden WordPress site.", @"Message informing the user that all of their sites are currently hidden (singular)"); @@ -304,9 +306,10 @@ - (void)showNoResultsViewForAllSitesHidden NSString *buttonTitle = NSLocalizedString(@"Change Visibility", @"Button title to edit visibility of sites."); NSString *imageName = @"mysites-nosites"; - + if (count == 1) { [self.noResultsViewController configureWithTitle:singularTitle + attributedTitle:nil noConnectionTitle:nil buttonTitle:buttonTitle subtitle:singularTitle @@ -318,6 +321,7 @@ - (void)showNoResultsViewForAllSitesHidden accessoryView:nil]; } else { [self.noResultsViewController configureWithTitle:multipleTitle + attributedTitle:nil noConnectionTitle:nil buttonTitle:buttonTitle subtitle:multipleSubtitle @@ -361,8 +365,7 @@ - (void)syncBlogs } NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:context]; if (!defaultAccount) { [self handleSyncEnded]; @@ -380,15 +383,11 @@ - (void)syncBlogs }); }; - context = [[ContextManager sharedInstance] newDerivedContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; - - [context performBlock:^{ - [blogService syncBlogsForAccount:defaultAccount success:^{ - completionBlock(); - } failure:^(NSError * _Nonnull error) { - completionBlock(); - }]; + BlogService *blogService = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + [blogService syncBlogsForAccount:defaultAccount success:^{ + completionBlock(); + } failure:^(NSError * _Nonnull __unused error) { + completionBlock(); }]; } @@ -396,6 +395,7 @@ - (void)handleSyncEnded { self.isSyncing = NO; [self.tableView.refreshControl endRefreshing]; + [self refreshStatsWidgetsSiteList]; } - (void)removeBlogItemsFromSpotlight:(Blog *)blog { @@ -456,19 +456,6 @@ - (void)presentInterfaceForAddingNewSiteFrom:(UIView *)sourceView [self showAddSiteAlertFrom:sourceView]; } -- (BOOL)shouldBypassBlogListViewControllerWhenSelectedFromTabBar -{ - return self.dataSource.displayedBlogsCount == 1; -} - -- (void)bypassBlogListViewController -{ - if ([self shouldBypassBlogListViewControllerWhenSelectedFromTabBar]) { - // We do a delay of 0.0 so that way this doesn't kick off until the next run loop. - [self performSelector:@selector(selectFirstSite) withObject:nil afterDelay:0.0]; - } -} - - (void)selectFirstSite { [self tableView:self.tableView didSelectRowAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]]; @@ -492,7 +479,7 @@ - (void)updateCurrentBlogSelection - (UIStatusBarStyle)preferredStatusBarStyle { - return UIStatusBarStyleLightContent; + return [WPStyleGuide preferredStatusBarStyle]; } - (void)configureStackView @@ -645,47 +632,50 @@ - (UITableViewCellEditingStyle)tableView:(UITableView *)tableView editingStyleFo } } -- (NSArray *)tableView:(UITableView *)tableView editActionsForRowAtIndexPath:(NSIndexPath *)indexPath +- (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath { Blog *blog = [self.dataSource blogAtIndexPath:indexPath]; NSMutableArray *actions = [NSMutableArray array]; __typeof(self) __weak weakSelf = self; if ([blog supports:BlogFeatureRemovable]) { - UITableViewRowAction *removeAction = [UITableViewRowAction rowActionWithStyle:UITableViewRowActionStyleNormal - title:NSLocalizedString(@"Remove", @"Removes a self hosted site from the app") - handler:^(UITableViewRowAction * _Nonnull action, NSIndexPath * _Nonnull indexPath) { - [ReachabilityUtils onAvailableInternetConnectionDo:^{ - [weakSelf showRemoveSiteAlertForIndexPath:indexPath]; - }]; - }]; + UIContextualAction *removeAction = [UIContextualAction + contextualActionWithStyle:UIContextualActionStyleNormal title:NSLocalizedString(@"Remove", @"Removes a self hosted site from the app") + handler:^(UIContextualAction * _Nonnull __unused action, __kindof UIView * _Nonnull __unused sourceView, void (^ _Nonnull __unused completionHandler)(BOOL)) { + [ReachabilityUtils onAvailableInternetConnectionDo:^{ + [weakSelf showRemoveSiteAlertForIndexPath:indexPath]; + }]; + }]; + removeAction.backgroundColor = [UIColor murielError]; [actions addObject:removeAction]; } else { if (blog.visible) { - UITableViewRowAction *hideAction = [UITableViewRowAction rowActionWithStyle:UITableViewRowActionStyleNormal - title:NSLocalizedString(@"Hide", @"Hides a site from the site picker list") - handler:^(UITableViewRowAction * _Nonnull action, NSIndexPath * _Nonnull indexPath) { - [ReachabilityUtils onAvailableInternetConnectionDo:^{ - [weakSelf hideBlogAtIndexPath:indexPath]; - }]; - }]; + UIContextualAction *hideAction = [UIContextualAction + contextualActionWithStyle:UIContextualActionStyleNormal title:NSLocalizedString(@"Hide", @"Hides a site from the site picker list") + handler:^(UIContextualAction * _Nonnull __unused action, __kindof UIView * _Nonnull __unused sourceView, void (^ _Nonnull __unused completionHandler)(BOOL)) { + [ReachabilityUtils onAvailableInternetConnectionDo:^{ + [weakSelf hideBlogAtIndexPath:indexPath]; + }]; + }]; + hideAction.backgroundColor = [UIColor murielNeutral30]; [actions addObject:hideAction]; } else { - UITableViewRowAction *unhideAction = [UITableViewRowAction rowActionWithStyle:UITableViewRowActionStyleNormal - title:NSLocalizedString(@"Unhide", @"Unhides a site from the site picker list") - handler:^(UITableViewRowAction * _Nonnull action, NSIndexPath * _Nonnull indexPath) { - [ReachabilityUtils onAvailableInternetConnectionDo:^{ - [weakSelf unhideBlogAtIndexPath:indexPath]; - }]; - }]; + UIContextualAction *unhideAction = [UIContextualAction + contextualActionWithStyle:UIContextualActionStyleNormal title:NSLocalizedString(@"Unhide", @"Unhides a site from the site picker list") + handler:^(UIContextualAction * _Nonnull __unused action, __kindof UIView * _Nonnull __unused sourceView, void (^ _Nonnull __unused completionHandler)(BOOL)) { + [ReachabilityUtils onAvailableInternetConnectionDo:^{ + [weakSelf unhideBlogAtIndexPath:indexPath]; + }]; + }]; + unhideAction.backgroundColor = [UIColor murielSuccess]; [actions addObject:unhideAction]; } } - return actions; + return [UISwipeActionsConfiguration configurationWithActions:actions]; } - (void)showRemoveSiteAlertForIndexPath:(NSIndexPath *)indexPath @@ -703,7 +693,7 @@ - (void)showRemoveSiteAlertForIndexPath:(NSIndexPath *)indexPath preferredStyle:alertStyle]; [alertController addCancelActionWithTitle:cancelTitle handler:nil]; - [alertController addDestructiveActionWithTitle:destructiveTitle handler:^(UIAlertAction *action) { + [alertController addDestructiveActionWithTitle:destructiveTitle handler:^(UIAlertAction * __unused action) { [self confirmRemoveSiteForIndexPath:indexPath]; }]; [self presentViewController:alertController animated:YES completion:nil]; @@ -714,9 +704,19 @@ - (void)confirmRemoveSiteForIndexPath:(NSIndexPath *)indexPath { Blog *blog = [self.dataSource blogAtIndexPath:indexPath]; [self removeBlogItemsFromSpotlight:blog]; - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; + + BlogService *blogService = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [blogService removeBlog:blog]; + + if ([Feature enabled:FeatureFlagContentMigration] && [AppConfiguration isWordPress]) { + [ContentMigrationCoordinator.shared cleanupExportedDataIfNeeded]; + } + + // Delete local data after removing the last site + if (!AccountHelper.isLoggedIn) { + [AccountHelper deleteAccountData]; + } + [self.tableView reloadData]; } @@ -785,25 +785,14 @@ - (void)setSelectedBlog:(Blog *)selectedBlog - (void)setSelectedBlog:(Blog *)selectedBlog animated:(BOOL)animated { - if (selectedBlog != _selectedBlog || !_blogDetailsViewController) { - _selectedBlog = selectedBlog; - self.blogDetailsViewController = [self makeBlogDetailsViewController]; - self.blogDetailsViewController.blog = selectedBlog; - - if (![self splitViewControllerIsHorizontallyCompact]) { - WPSplitViewController *splitViewController = (WPSplitViewController *)self.splitViewController; - [self showDetailViewController:[(UIViewController <WPSplitViewControllerDetailProvider> *)self.blogDetailsViewController initialDetailViewControllerForSplitView:splitViewController] sender:self]; - } - } - - /// Issue #7284: - /// Prevents pushing BlogDetailsViewController, if it was already in the hierarchy. - /// - if ([self.navigationController.viewControllers containsObject:self.blogDetailsViewController]) { - return; + if (self.blogSelected != nil) { + self.blogSelected(self, selectedBlog); + } else { + // The site picker without a site-selection callback makes no sense. We'll dismiss the VC to keep + // the app running, but the user won't be able to switch sites. + DDLogError(@"There's no site-selection callback assigned to the site picker."); + [self dismissViewControllerAnimated:animated completion:nil]; } - - [self.navigationController pushViewController:self.blogDetailsViewController animated:animated]; } - (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath @@ -816,6 +805,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 @@ -824,6 +815,11 @@ - (void)searchBarTextDidBeginEditing:(UISearchBar *)searchBar [searchBar setShowsCancelButton:YES animated:YES]; } +- (void)trackSearchPerformed +{ + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherSearchPerformed]; +} + - (void)searchBarTextDidEndEditing:(UISearchBar *)searchBar { self.dataSource.searching = NO; @@ -872,67 +868,57 @@ - (void)setEditing:(BOOL)editing animated:(BOOL)animated [self updateViewsForCurrentSiteCount]; [self updateSearchVisibility]; } -} + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherToggleEditTapped properties: @{ @"state": editing ? @"edit" : @"done"}]; -- (void)toggleAddSiteButton:(BOOL)enabled -{ - self.addSiteButton.enabled = enabled; } -- (void)setAddSiteBarButtonItem +- (BOOL)shouldShowAddSiteButton { - if (self.dataSource.allBlogsCount == 0) { - if([Feature enabled:FeatureFlagMeMove]) { - [self addMeButtonToNavigationBarWith:[[self defaultWordPressComAccount] email]]; - } else { - self.navigationItem.rightBarButtonItem = nil; - } - } - else { - self.navigationItem.rightBarButtonItem = self.addSiteButton; - } + return self.dataSource.blogsCount > 0; } -- (void)addSite +- (BOOL)shouldShowEditButton { - [self showAddSiteAlertFrom:self.addSiteButton]; + NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; + return [Blog wpComBlogCountInContext:context] > 0; } -- (UIAlertController *)makeAddSiteAlertController +- (void)updateBarButtons { - UIAlertController *addSiteAlertController = [UIAlertController alertControllerWithTitle:nil - message:nil - preferredStyle:UIAlertControllerStyleActionSheet]; + BOOL showAddSiteButton = [self shouldShowAddSiteButton]; + BOOL showEditButton = [self shouldShowEditButton]; - if ([self defaultWordPressComAccount]) { - UIAlertAction *addNewWordPressAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Create WordPress.com site", @"Create WordPress.com site button") - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *action) { - [self launchSiteCreation]; - }]; - [addSiteAlertController addAction:addNewWordPressAction]; + if (!showAddSiteButton) { + [self addMeButtonToNavigationBarWithEmail:[[self defaultWordPressComAccount] email] meScenePresenter:self.meScenePresenter]; + return; } - UIAlertAction *addSiteAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Add self-hosted site", @"Add self-hosted site button") - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *action) { - [self showLoginControllerForAddingSelfHostedSite]; - }]; - [addSiteAlertController addAction:addSiteAction]; + if (![AppConfiguration allowSiteCreation]) { + self.navigationItem.rightBarButtonItem = self.editButtonItem; + return; + } - UIAlertAction *cancel = [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"Cancel button") - style:UIAlertActionStyleCancel - handler:nil]; - [addSiteAlertController addAction:cancel]; + if (showEditButton) { + self.navigationItem.rightBarButtonItems = @[ self.addSiteButton, self.editButtonItem ]; + } else { + self.navigationItem.rightBarButtonItem = self.addSiteButton; + } +} - return addSiteAlertController; +- (void)toggleAddSiteButton:(BOOL)enabled +{ + self.addSiteButton.enabled = enabled; +} + +- (void)addSite +{ + [self showAddSiteAlertFrom:self.addSiteButton]; } - (WPAccount *)defaultWordPressComAccount { NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context]; - return [accountService defaultWordPressComAccount]; + return [WPAccount lookupDefaultWordPressComAccountInContext:context]; } - (void)showLoginControllerForAddingSelfHostedSite @@ -943,7 +929,7 @@ - (void)showLoginControllerForAddingSelfHostedSite - (void)setVisible:(BOOL)visible forBlog:(Blog *)blog { - if(!visible && self.dataSource.allBlogsCount > HideAllMinSites) { + if(!visible && self.dataSource.blogsCount > HideAllMinSites) { if (self.hideCount == 0) { self.firstHide = [NSDate date]; } @@ -960,26 +946,25 @@ - (void)setVisible:(BOOL)visible forBlog:(Blog *)blog UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"Cancel") style:UIAlertActionStyleCancel - handler:^(UIAlertAction *action){}]; + handler:^(UIAlertAction * __unused action){}]; UIAlertAction *hideAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Hide All", @"Hide All") style:UIAlertActionStyleDestructive - handler:^(UIAlertAction *action){ - NSManagedObjectContext *context = [[ContextManager sharedInstance] newDerivedContext]; - [context performBlock:^{ - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context]; - WPAccount *account = [accountService defaultWordPressComAccount]; - [accountService setVisibility:visible forBlogs:[account.blogs allObjects]]; - [[ContextManager sharedInstance] saveContext:context]; - }]; - }]; + handler:^(UIAlertAction * __unused action){ + AccountService *accountService = [[AccountService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:[[ContextManager sharedInstance] mainContext]]; + [accountService setVisibility:visible forBlogs:[account.blogs allObjects]]; + }]; [alertController addAction:cancelAction]; [alertController addAction:hideAction]; [self presentViewController:alertController animated:YES completion:nil]; } } - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:[[ContextManager sharedInstance] mainContext]]; + AccountService *accountService = [[AccountService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [accountService setVisibility:visible forBlogs:@[blog]]; + + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherToggleBlogVisible properties:@{ @"visible": @(visible)} blog:blog]; + } #pragma mark - Data Listener @@ -987,7 +972,7 @@ - (void)setVisible:(BOOL)visible forBlog:(Blog *)blog - (void)dataChanged { [self.tableView reloadData]; - [self updateEditButton]; + [self updateBarButtons]; [[WordPressAppDelegate shared] trackLogoutIfNeeded]; [self maybeShowNUX]; [self updateSearchVisibility]; @@ -1005,21 +990,39 @@ - (void)actionButtonPressed { - (void)showAddSiteAlertFrom:(id)source { - if (self.dataSource.allBlogsCount > 0 && self.dataSource.visibleBlogsCount == 0) { + if (self.dataSource.blogsCount > 0 && self.dataSource.visibleBlogsCount == 0) { [self setEditing:YES animated:YES]; } else { - UIAlertController *addSiteAlertController = [self makeAddSiteAlertController]; + AddSiteAlertFactory *factory = [AddSiteAlertFactory new]; + BOOL canCreateWPComSite = [self defaultWordPressComAccount] ? YES : NO; + BOOL canAddSelfHostedSite = AppConfiguration.showAddSelfHostedSiteButton; + + // Launch directly into the add site process if we're only going to show one button + if(canCreateWPComSite && !canAddSelfHostedSite) { + [self launchSiteCreation]; + return; + } + + UIAlertController *alertController = [factory makeAddSiteAlertWithSource:@"my_site" canCreateWPComSite:canCreateWPComSite createWPComSite:^{ + [self launchSiteCreation]; + } canAddSelfHostedSite:canAddSelfHostedSite addSelfHostedSite:^{ + [self showLoginControllerForAddingSelfHostedSite]; + }]; + if ([source isKindOfClass:[UIView class]]) { UIView *sourceView = (UIView *)source; - addSiteAlertController.popoverPresentationController.sourceView = sourceView; - addSiteAlertController.popoverPresentationController.sourceRect = sourceView.bounds; + alertController.popoverPresentationController.sourceView = sourceView; + alertController.popoverPresentationController.sourceRect = sourceView.bounds; } else if ([source isKindOfClass:[UIBarButtonItem class]]) { - addSiteAlertController.popoverPresentationController.barButtonItem = source; + alertController.popoverPresentationController.barButtonItem = source; } - addSiteAlertController.popoverPresentationController.permittedArrowDirections = UIPopoverArrowDirectionUp; + alertController.popoverPresentationController.permittedArrowDirections = UIPopoverArrowDirectionUp; + + [self presentViewController:alertController animated:YES completion:nil]; + self.addSiteAlertController = alertController; - [self presentViewController:addSiteAlertController animated:YES completion:nil]; - self.addSiteAlertController = addSiteAlertController; + [WPAnalytics trackEvent:WPAnalyticsEventSiteSwitcherAddSiteTapped]; +// } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Selector/BlogSelectorViewController.h b/WordPress/Classes/ViewRelated/Blog/Blog Selector/BlogSelectorViewController.h index d3fc1fec541f..f3d06d2266a2 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Selector/BlogSelectorViewController.h +++ b/WordPress/Classes/ViewRelated/Blog/Blog Selector/BlogSelectorViewController.h @@ -20,6 +20,7 @@ typedef void (^BlogSelectorDismissHandler)(void); @property (nonatomic, assign) BOOL displaysOnlyDefaultAccountSites; @property (nonatomic, assign) BOOL displaysNavigationBarWhenSearching; @property (nonatomic, assign) BOOL displaysCancelButton; +@property (nonatomic, assign) BOOL shouldHideSelfHostedSites; @property (nonatomic, assign) BOOL dismissOnCancellation; @property (nonatomic, assign) BOOL dismissOnCompletion; diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Selector/BlogSelectorViewController.m b/WordPress/Classes/ViewRelated/Blog/Blog Selector/BlogSelectorViewController.m index 8423425d700d..b019648cdb48 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Selector/BlogSelectorViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Blog Selector/BlogSelectorViewController.m @@ -1,7 +1,7 @@ #import "BlogSelectorViewController.h" #import "BlogDetailsViewController.h" #import "WPBlogTableViewCell.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "Blog.h" #import "WPAccount.h" #import "AccountService.h" @@ -78,9 +78,7 @@ - (void)setDisplaysOnlyDefaultAccountSites:(BOOL)onlyDefault { if (onlyDefault) { NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; - self.dataSource.account = defaultAccount; + self.dataSource.account = [WPAccount lookupDefaultWordPressComAccountInContext:context]; } else { self.dataSource.account = nil; } @@ -104,7 +102,9 @@ - (void)viewDidLoad self.navigationItem.leftBarButtonItem = cancelButtonItem; } - + + self.dataSource.shouldHideSelfHostedSites = self.shouldHideSelfHostedSites; + // TableView [WPStyleGuide configureColorsForView:self.view andTableView:self.tableView]; @@ -157,7 +157,7 @@ - (void)configureSearchController self.definesPresentationContext = YES; self.searchController = [[UISearchController alloc] initWithSearchResultsController:nil]; - self.searchController.dimsBackgroundDuringPresentation = NO; + self.searchController.obscuresBackgroundDuringPresentation = NO; self.searchController.hidesNavigationBarDuringPresentation = !_displaysNavigationBarWhenSearching; self.searchController.delegate = self; self.searchController.searchResultsUpdater = self; @@ -236,18 +236,14 @@ - (void)keyboardDidHide:(NSNotification*)notification - (void)syncBlogs { - NSManagedObjectContext *context = [[ContextManager sharedInstance] newDerivedContext]; - - [context performBlock:^{ - AccountService *accountService = [[AccountService alloc] initWithManagedObjectContext:context]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; - WPAccount *defaultAccount = [accountService defaultWordPressComAccount]; - - if (!defaultAccount) { + id<CoreDataStack> coreDataStack = [ContextManager sharedInstance]; + [coreDataStack.mainContext performBlock:^{ + WPAccount *account = [WPAccount lookupDefaultWordPressComAccountInContext:coreDataStack.mainContext]; + if (account == nil) { return; } - - [blogService syncBlogsForAccount:defaultAccount success:nil failure:nil]; + BlogService *blogService = [[BlogService alloc] initWithCoreDataStack:coreDataStack]; + [blogService syncBlogsForAccount:account success:nil failure:nil]; }]; } @@ -271,8 +267,7 @@ - (NSManagedObjectID *)selectedObjectID // Retrieve NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *service = [[BlogService alloc] initWithManagedObjectContext:context]; - Blog *selectedBlog = [service blogByBlogId:self.selectedObjectDotcomID]; + Blog *selectedBlog = [Blog lookupWithID:self.selectedObjectDotcomID in:context]; // Cache _selectedObjectID = selectedBlog.objectID; diff --git a/WordPress/Classes/ViewRelated/Blog/BlogPersonalization/BlogDashboardPersonalizationView.swift b/WordPress/Classes/ViewRelated/Blog/BlogPersonalization/BlogDashboardPersonalizationView.swift new file mode 100644 index 000000000000..27101c564dcf --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/BlogPersonalization/BlogDashboardPersonalizationView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct BlogDashboardPersonalizationView: View { + @StateObject var viewModel: BlogDashboardPersonalizationViewModel + + @SwiftUI.Environment(\.presentationMode) var presentationMode + + var body: some View { + List { + Section(content: { + ForEach(viewModel.cards, content: BlogDashboardPersonalizationCardCell.init) + }, header: { + Text(Strings.sectionHeader) + }, footer: { + Text(Strings.sectionFooter) + }) + } + .listStyle(.insetGrouped) + .navigationTitle(Strings.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { closeButton } + } + } + + private var closeButton: some View { + Button(action: { presentationMode.wrappedValue.dismiss() }) { + Image(systemName: "xmark") + .font(.body.weight(.medium)) + .foregroundColor(Color.primary) + } + } +} + +private struct BlogDashboardPersonalizationCardCell: View { + @ObservedObject var viewModel: BlogDashboardPersonalizationCardCellViewModel + + var body: some View { + Toggle(viewModel.title, isOn: $viewModel.isOn) + } +} + +private extension BlogDashboardPersonalizationView { + struct Strings { + static let title = NSLocalizedString("personalizeHome.title", value: "Personalize Home Tab", comment: "Page title") + static let sectionHeader = NSLocalizedString("personalizeHome.cardsSectionHeader", value: "Add or hide cards", comment: "Section header") + static let sectionFooter = NSLocalizedString("personalizeHome.cardsSectionFooter", value: "Cards may show different content depending on what's happening on your site. We're working on more cards and controls.", comment: "Section footer displayed below the list of toggles") + } +} + +#if DEBUG +struct BlogDashboardPersonalizationView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BlogDashboardPersonalizationView(viewModel: .init(service: .init(repository: UserDefaults.standard, siteID: 1))) + } + } +} +#endif diff --git a/WordPress/Classes/ViewRelated/Blog/BlogPersonalization/BlogDashboardPersonalizationViewModel.swift b/WordPress/Classes/ViewRelated/Blog/BlogPersonalization/BlogDashboardPersonalizationViewModel.swift new file mode 100644 index 000000000000..0a654e44f98b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/BlogPersonalization/BlogDashboardPersonalizationViewModel.swift @@ -0,0 +1,56 @@ +import SwiftUI + +final class BlogDashboardPersonalizationViewModel: ObservableObject { + let cards: [BlogDashboardPersonalizationCardCellViewModel] + + init(service: BlogDashboardPersonalizationService) { + self.cards = DashboardCard.personalizableCards.map { + BlogDashboardPersonalizationCardCellViewModel(card: $0, service: service) + } + } +} + +final class BlogDashboardPersonalizationCardCellViewModel: ObservableObject, Identifiable { + private let card: DashboardCard + private let service: BlogDashboardPersonalizationService + + var id: DashboardCard { card } + var title: String { card.localizedTitle } + + var isOn: Bool { + get { service.isEnabled(card) } + set { + objectWillChange.send() + service.setEnabled(newValue, for: card) + } + } + + init(card: DashboardCard, service: BlogDashboardPersonalizationService) { + self.card = card + self.service = service + } +} + +private extension DashboardCard { + var localizedTitle: String { + switch self { + case .prompts: + return NSLocalizedString("personalizeHome.dashboardCard.prompts", value: "Blogging prompts", comment: "Card title for the pesonalization menu") + case .blaze: + return NSLocalizedString("personalizeHome.dashboardCard.blaze", value: "Blaze", comment: "Card title for the pesonalization menu") + case .todaysStats: + return NSLocalizedString("personalizeHome.dashboardCard.todaysStats", value: "Today's stats", comment: "Card title for the pesonalization menu") + case .draftPosts: + return NSLocalizedString("personalizeHome.dashboardCard.draftPosts", value: "Draft posts", comment: "Card title for the pesonalization menu") + case .scheduledPosts: + return NSLocalizedString("personalizeHome.dashboardCard.scheduledPosts", value: "Scheduled posts", comment: "Card title for the pesonalization menu") + case .activityLog: + return NSLocalizedString("personalizeHome.dashboardCard.activityLog", value: "Recent activity", comment: "Card title for the pesonalization menu") + case .pages: + return NSLocalizedString("personalizeHome.dashboardCard.pages", value: "Pages", comment: "Card title for the pesonalization menu") + case .quickStart, .nextPost, .createPost, .ghost, .failure, .personalize, .jetpackBadge, .jetpackInstall, .domainsDashboardCard, .empty: + assertionFailure("\(self) card should not appear in the personalization menus") + return "" // These cards don't appear in the personalization menus + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptCoordinator.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptCoordinator.swift new file mode 100644 index 000000000000..0e9e87cfd18d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptCoordinator.swift @@ -0,0 +1,162 @@ +import UIKit + +/// Helps manage the flow related to Blogging Prompts. +/// +@objc class BloggingPromptCoordinator: NSObject { + + private let promptsServiceFactory: BloggingPromptsServiceFactory + private let scheduler: PromptRemindersScheduler + + enum Errors: Error { + case invalidSite + case promptNotFound + case unknown + } + + /// Defines the interaction sources for Blogging Prompts. + enum Source { + case dashboard + case featureIntroduction + case actionSheetHeader + case promptNotification + case promptStaticNotification + case unknown + + var editorEntryPoint: PostEditorEntryPoint { + switch self { + case .dashboard: + return .dashboard + case .featureIntroduction: + return .bloggingPromptsFeatureIntroduction + case .actionSheetHeader: + return .bloggingPromptsActionSheetHeader + case .promptNotification, .promptStaticNotification: + return .bloggingPromptsNotification + default: + return .unknown + } + } + } + + // MARK: Public Method + + init(bloggingPromptsServiceFactory: BloggingPromptsServiceFactory = .init()) { + self.promptsServiceFactory = bloggingPromptsServiceFactory + self.scheduler = PromptRemindersScheduler(bloggingPromptsServiceFactory: bloggingPromptsServiceFactory) + } + + /// Present the post creation flow to answer the prompt with `promptID`. + /// + /// - Note: When the `promptID` is nil, the coordinator will attempt to fetch and use today's prompt from remote. + /// + /// - Parameters: + /// - viewController: The view controller that will present the post creation flow. + /// - promptID: The ID of the blogging prompt. When nil, the method will use today's prompt. + /// - blog: The blog associated with the blogging prompt. + /// - completion: Closure invoked after the post creation flow is presented. + func showPromptAnsweringFlow(from viewController: UIViewController, + promptID: Int? = nil, + blog: Blog, + source: Source, + completion: (() -> Void)? = nil) { + fetchPrompt(with: promptID, blog: blog) { result in + guard case .success(let prompt) = result else { + completion?() + return + } + + // Present the post creation flow. + let editor = EditPostViewController(blog: blog, prompt: prompt) + editor.modalPresentationStyle = .fullScreen + editor.entryPoint = source.editorEntryPoint + viewController.present(editor, animated: true) + completion?() + } + } + + /// Replaces the current blogging prompt notifications with a new timeframe that starts from today. + /// + /// - Parameters: + /// - blog: The blog associated with the blogging prompt. + /// - completion: Closure invoked after the scheduling process completes. + func updatePromptsIfNeeded(for blog: Blog, completion: ((Result<Void, Error>) -> Void)? = nil) { + guard FeatureFlag.bloggingPrompts.enabled, + let service = self.promptsServiceFactory.makeService(for: blog) else { + return + } + + // fetch and update local prompts. + service.fetchPrompts { [weak self] _ in + // try to reschedule prompts if the user has any active reminders. + self?.reschedulePromptRemindersIfNeeded(for: blog) { + completion?(.success(())) + } + } failure: { error in + completion?(.failure(error ?? Errors.unknown)) + } + } +} + +// MARK: Private Helpers + +private extension BloggingPromptCoordinator { + /// Replaces the current blogging prompt notifications with a new timeframe that starts from today. + /// + /// Prompt notifications will eventually run out, so unless the user has explicitly disabled the reminders, + /// we'll need to keep rescheduling the local notifications so the reminders can keep coming. + /// + /// - Parameters: + /// - blog: The blog associated with the blogging prompt. + /// - completion: Closure invoked after the scheduling process completes. + func reschedulePromptRemindersIfNeeded(for blog: Blog, completion: @escaping () -> Void) { + guard let service = promptsServiceFactory.makeService(for: blog), + let settings = service.localSettings, + let reminderDays = settings.reminderDays, + settings.promptRemindersEnabled else { + completion() + return + } + + // IMPORTANT: Ensure that push authorization is already granted before rescheduling. + UNUserNotificationCenter.current().getNotificationSettings { [weak self] notificationSettings in + guard let self = self, + notificationSettings.authorizationStatus == .authorized else { + completion() + return + } + + // Reschedule the prompt reminders. + let schedule = BloggingRemindersScheduler.Schedule.weekdays(reminderDays.getActiveWeekdays()) + self.scheduler.schedule(schedule, for: blog, time: settings.reminderTimeDate()) { result in + completion() + } + } + } + + func fetchPrompt(with localPromptID: Int? = nil, blog: Blog, completion: @escaping (Result<BloggingPrompt, Error>) -> Void) { + guard let service = promptsServiceFactory.makeService(for: blog) else { + completion(.failure(Errors.invalidSite)) + return + } + + // When the promptID is specified, there may be a cached prompt available. + if let promptID = localPromptID, + let prompt = service.loadPrompt(with: promptID, in: blog) { + completion(.success(prompt)) + return + } + + // Otherwise, try to fetch today's prompt from remote. + service.fetchTodaysPrompt { prompt in + guard let prompt = prompt else { + completion(.failure(Errors.promptNotFound)) + return + } + completion(.success(prompt)) + + } failure: { error in + completion(.failure(error ?? Errors.unknown)) + } + } + +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptTableViewCell.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptTableViewCell.swift new file mode 100644 index 000000000000..b8c21be32c7b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptTableViewCell.swift @@ -0,0 +1,66 @@ +class BloggingPromptTableViewCell: UITableViewCell, NibReusable { + + // MARK: - Properties + + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var dateLabel: UILabel! + @IBOutlet private weak var dateToAnswersSeparatorDot: UILabel! + @IBOutlet private weak var answerCountLabel: UILabel! + + @IBOutlet private weak var answeredStateView: UIView! + @IBOutlet private weak var answersToStateSeparatorDot: UILabel! + @IBOutlet private weak var answeredStateLabel: UILabel! + + private(set) var prompt: BloggingPrompt? + + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return formatter + }() + + + // MARK: Init + + override func awakeFromNib() { + super.awakeFromNib() + configureView() + } + + func configure(_ prompt: BloggingPrompt) { + self.prompt = prompt + titleLabel.text = prompt.textForDisplay() + dateLabel.text = dateFormatter.string(from: prompt.date) + answerCountLabel.text = answerInfoText + answeredStateView.isHidden = !prompt.answered + } +} + +private extension BloggingPromptTableViewCell { + + func configureView() { + titleLabel.textColor = .text + titleLabel.font = WPStyleGuide.notoBoldFontForTextStyle(.headline) + + dateLabel.textColor = .text + dateToAnswersSeparatorDot.textColor = .text + answerCountLabel.textColor = .text + answersToStateSeparatorDot.textColor = .text + + answeredStateLabel.text = Strings.answeredLabel + answeredStateLabel.textColor = WPStyleGuide.BloggingPrompts.answeredLabelColor + } + + var answerInfoText: String { + let answerCount = prompt?.answerCount ?? 0 + let stringFormat = (answerCount == 1 ? Strings.answerInfoSingularFormat : Strings.answerInfoPluralFormat) + return String(format: stringFormat, answerCount) + } + + enum Strings { + static let answeredLabel = NSLocalizedString("✓ Answered", comment: "Label that indicates a blogging prompt has been answered.") + static let answerInfoSingularFormat = NSLocalizedString("%1$d answer", comment: "Singular format string for displaying the number of users that answered the blogging prompt.") + static let answerInfoPluralFormat = NSLocalizedString("%1$d answers", comment: "Plural format string for displaying the number of users that answered the blogging prompt.") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptTableViewCell.xib b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptTableViewCell.xib new file mode 100644 index 000000000000..cc2c88ecb86a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptTableViewCell.xib @@ -0,0 +1,97 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" indentationWidth="10" id="KGk-i7-Jjw" customClass="BloggingPromptTableViewCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="368" height="67"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM"> + <rect key="frame" x="0.0" y="0.0" width="368" height="67"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="TopLeft" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Title" textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="9EJ-O9-rUs" userLabel="Title Label"> + <rect key="frame" x="16" y="10" width="336" height="20.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/> + <nil key="highlightedColor"/> + </label> + <stackView opaque="NO" contentMode="scaleToFill" distribution="fillProportionally" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="NgJ-e1-8mj" userLabel="Prompt Information Stack View"> + <rect key="frame" x="16" y="32.5" width="336" height="24.5"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="TopLeft" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Date" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ing-nT-4vc" userLabel="Date Label"> + <rect key="frame" x="0.0" y="0.0" width="28.5" height="24.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + <label userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="·" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="hFE-3l-xA6" userLabel="Separator Dot"> + <rect key="frame" x="34.5" y="0.0" width="4" height="24.5"/> + <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="TopLeft" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="99 answers" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ZmC-VB-tZs" userLabel="Answer Count Label"> + <rect key="frame" x="44.5" y="0.0" width="69.5" height="24.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="hLQ-2r-lrL" userLabel="Answered State View"> + <rect key="frame" x="120" y="0.0" width="216" height="24.5"/> + <subviews> + <label userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="·" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Jxn-g5-OtR" userLabel="Separator Dot"> + <rect key="frame" x="0.0" y="4.5" width="4" height="16"/> + <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="TopLeft" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Answered" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="MGq-ZW-fgb" userLabel="Answered State Label"> + <rect key="frame" x="10" y="0.0" width="206" height="24.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <constraints> + <constraint firstAttribute="trailing" secondItem="MGq-ZW-fgb" secondAttribute="trailing" id="68Y-Hc-DBg"/> + <constraint firstItem="Jxn-g5-OtR" firstAttribute="leading" secondItem="hLQ-2r-lrL" secondAttribute="leading" id="Lmu-jA-SLi"/> + <constraint firstItem="Jxn-g5-OtR" firstAttribute="centerY" secondItem="hLQ-2r-lrL" secondAttribute="centerY" id="MLC-DG-Tty"/> + <constraint firstAttribute="bottom" secondItem="MGq-ZW-fgb" secondAttribute="bottom" id="RCv-zG-krh"/> + <constraint firstItem="MGq-ZW-fgb" firstAttribute="leading" secondItem="Jxn-g5-OtR" secondAttribute="trailing" constant="6" id="bmL-rU-ENh"/> + <constraint firstItem="MGq-ZW-fgb" firstAttribute="top" secondItem="hLQ-2r-lrL" secondAttribute="top" id="i79-0y-a1t"/> + </constraints> + </view> + </subviews> + </stackView> + </subviews> + <constraints> + <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="NgJ-e1-8mj" secondAttribute="trailing" constant="16" id="NLC-oa-eTa"/> + <constraint firstAttribute="bottom" secondItem="NgJ-e1-8mj" secondAttribute="bottom" constant="10" id="XDv-Bx-crE"/> + <constraint firstAttribute="trailing" secondItem="9EJ-O9-rUs" secondAttribute="trailing" constant="16" id="adH-Dv-Kxj"/> + <constraint firstItem="9EJ-O9-rUs" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="mxH-wp-psh"/> + <constraint firstItem="NgJ-e1-8mj" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="rC4-OZ-GzD"/> + <constraint firstItem="NgJ-e1-8mj" firstAttribute="top" secondItem="9EJ-O9-rUs" secondAttribute="bottom" constant="2" id="rJj-cx-ri9"/> + <constraint firstItem="9EJ-O9-rUs" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="10" id="xlc-gR-Yni"/> + </constraints> + </tableViewCellContentView> + <connections> + <outlet property="answerCountLabel" destination="ZmC-VB-tZs" id="ES1-HG-7ps"/> + <outlet property="answeredStateLabel" destination="MGq-ZW-fgb" id="IMd-4Q-u2x"/> + <outlet property="answeredStateView" destination="hLQ-2r-lrL" id="tbz-zi-842"/> + <outlet property="answersToStateSeparatorDot" destination="Jxn-g5-OtR" id="bzX-TR-0Yy"/> + <outlet property="dateLabel" destination="ing-nT-4vc" id="V1i-lL-LCF"/> + <outlet property="dateToAnswersSeparatorDot" destination="hFE-3l-xA6" id="cbF-I5-1OK"/> + <outlet property="titleLabel" destination="9EJ-O9-rUs" id="FHY-7V-WNQ"/> + </connections> + <point key="canvasLocation" x="198.55072463768118" y="133.59375"/> + </tableViewCell> + </objects> +</document> diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.storyboard b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.storyboard new file mode 100644 index 000000000000..decdf858520a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.storyboard @@ -0,0 +1,72 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="cBt-Sc-5RS"> + <device id="retina4_7" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <scenes> + <!--Blogging Prompts View Controller--> + <scene sceneID="Zaz-EE-DkY"> + <objects> + <viewController storyboardIdentifier="BloggingPromptsViewController" useStoryboardIdentifierAsRestorationIdentifier="YES" id="cBt-Sc-5RS" customClass="BloggingPromptsViewController" customModule="WordPress" customModuleProvider="target" sceneMemberID="viewController"> + <layoutGuides> + <viewControllerLayoutGuide type="top" id="MpO-o7-pbX"/> + <viewControllerLayoutGuide type="bottom" id="uCp-uQ-tOJ"/> + </layoutGuides> + <view key="view" contentMode="scaleToFill" id="gDw-cc-yIW"> + <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="bno-oB-pDf" customClass="FilterTabBar" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="375" height="46"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="height" constant="46" id="mN2-YL-PRM"/> + </constraints> + </view> + <tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" allowsSelection="NO" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="7aR-Vp-g6a"> + <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <inset key="separatorInset" minX="16" minY="0.0" maxX="0.0" maxY="0.0"/> + <connections> + <outlet property="dataSource" destination="cBt-Sc-5RS" id="ACo-bR-be2"/> + <outlet property="delegate" destination="cBt-Sc-5RS" id="hKz-4a-sNu"/> + </connections> + </tableView> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="bno-oB-pDf" firstAttribute="leading" secondItem="gDw-cc-yIW" secondAttribute="leading" id="7Jf-su-8WM"/> + <constraint firstAttribute="trailing" secondItem="bno-oB-pDf" secondAttribute="trailing" id="AgG-aE-VG8"/> + <constraint firstItem="7aR-Vp-g6a" firstAttribute="top" secondItem="gDw-cc-yIW" secondAttribute="top" id="Ndp-h3-R7q"/> + <constraint firstItem="7aR-Vp-g6a" firstAttribute="leading" secondItem="gDw-cc-yIW" secondAttribute="leading" id="Nt5-JK-DbB"/> + <constraint firstAttribute="bottom" secondItem="7aR-Vp-g6a" secondAttribute="bottom" id="Oly-V9-tho"/> + <constraint firstItem="7aR-Vp-g6a" firstAttribute="top" secondItem="bno-oB-pDf" secondAttribute="bottom" id="Rmm-F3-kCp"/> + <constraint firstAttribute="trailing" secondItem="7aR-Vp-g6a" secondAttribute="trailing" id="htA-bQ-QaF"/> + <constraint firstItem="bno-oB-pDf" firstAttribute="top" secondItem="gDw-cc-yIW" secondAttribute="top" id="tfA-U7-Sc2"/> + </constraints> + <variation key="default"> + <mask key="constraints"> + <exclude reference="Rmm-F3-kCp"/> + </mask> + </variation> + </view> + <connections> + <outlet property="filterTabBar" destination="bno-oB-pDf" id="fxk-Bw-WWL"/> + <outlet property="tableView" destination="7aR-Vp-g6a" id="E5x-rq-5Mg"/> + </connections> + </viewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="wHe-tJ-scb" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/> + </objects> + <point key="canvasLocation" x="21.600000000000001" y="272.11394302848578"/> + </scene> + </scenes> + <resources> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.swift new file mode 100644 index 000000000000..31ee198cba1f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/BloggingPromptsViewController.swift @@ -0,0 +1,223 @@ +import UIKit + + +class BloggingPromptsViewController: UIViewController, NoResultsViewHost { + + // MARK: - Properties + + @IBOutlet private weak var tableView: UITableView! + @IBOutlet private weak var filterTabBar: FilterTabBar! + + private var blog: Blog? + private var prompts: [BloggingPrompt] = [] { + didSet { + tableView.reloadData() + showNoResultsViewIfNeeded() + } + } + + private lazy var bloggingPromptsService: BloggingPromptsService? = { + return BloggingPromptsService(blog: blog) + }() + + private var isLoading: Bool = false { + didSet { + if isLoading != oldValue { + showNoResultsViewIfNeeded() + } + } + } + + // MARK: - Init + + class func controllerWithBlog(_ blog: Blog) -> BloggingPromptsViewController { + let controller = BloggingPromptsViewController.loadFromStoryboard() + controller.blog = blog + return controller + } + + class func show(for blog: Blog, from presentingViewController: UIViewController) { + WPAnalytics.track(.promptsListViewed) + let controller = BloggingPromptsViewController.controllerWithBlog(blog) + presentingViewController.navigationController?.pushViewController(controller, animated: true) + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + title = Strings.viewTitle + configureFilterTabBar() + configureTableView() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + fetchPrompts() + } +} + +// MARK: - Private Methods + +private extension BloggingPromptsViewController { + + func configureTableView() { + tableView.register(BloggingPromptTableViewCell.defaultNib, + forCellReuseIdentifier: BloggingPromptTableViewCell.defaultReuseID) + + tableView.accessibilityIdentifier = "Blogging Prompts List" + tableView.allowsSelection = FeatureFlag.bloggingPromptsEnhancements.enabled + WPStyleGuide.configureAutomaticHeightRows(for: tableView) + WPStyleGuide.configureColors(view: view, tableView: tableView) + } + + func showNoResultsViewIfNeeded() { + guard !isLoading else { + showLoadingView() + return + } + + guard prompts.isEmpty else { + hideNoResults() + return + } + + showNoResultsView() + } + + func showNoResultsView() { + hideNoResults() + configureAndDisplayNoResults(on: view, + title: NoResults.emptyTitle, + image: NoResults.imageName) + } + + func showLoadingView() { + hideNoResults() + configureAndDisplayNoResults(on: view, + title: NoResults.loadingTitle, + accessoryView: NoResultsViewController.loadingAccessoryView()) + } + + func showErrorView() { + hideNoResults() + configureAndDisplayNoResults(on: view, + title: NoResults.errorTitle, + subtitle: NoResults.errorSubtitle, + image: NoResults.imageName) + } + + func fetchPrompts() { + guard let bloggingPromptsService = bloggingPromptsService else { + DDLogError("Failed creating BloggingPromptsService instance.") + showErrorView() + return + } + + isLoading = true + + bloggingPromptsService.fetchListPrompts(success: { [weak self] (prompts) in + self?.isLoading = false + self?.prompts = prompts.sorted(by: { $0.date.compare($1.date) == .orderedDescending }) + }, failure: { [weak self] (error) in + DDLogError("Failed fetching blogging prompts: \(String(describing: error))") + self?.isLoading = false + self?.showErrorView() + }) + } + + enum Strings { + static let viewTitle = NSLocalizedString("Prompts", comment: "View title for Blogging Prompts list.") + } + + enum NoResults { + static let loadingTitle = NSLocalizedString("Loading prompts...", comment: "Displayed while blogging prompts are being loaded.") + static let errorTitle = NSLocalizedString("Oops", comment: "Title for the view when there's an error loading blogging prompts.") + static let errorSubtitle = NSLocalizedString("There was an error loading prompts.", comment: "Text displayed when there is a failure loading blogging prompts.") + static let emptyTitle = NSLocalizedString("No prompts yet", comment: "Title displayed when there are no blogging prompts to display.") + static let imageName = "wp-illustration-empty-results" + } + +} + +// MARK: - Table Methods + +extension BloggingPromptsViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return prompts.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: BloggingPromptTableViewCell.defaultReuseID) as? BloggingPromptTableViewCell, + let prompt = prompts[safe: indexPath.row] else { + return UITableViewCell() + } + + cell.configure(prompt) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + guard FeatureFlag.bloggingPromptsEnhancements.enabled, + let blog, + let cell = tableView.cellForRow(at: indexPath) as? BloggingPromptTableViewCell, + let prompt = cell.prompt else { + return + } + + let editor = EditPostViewController(blog: blog, prompt: prompt) + editor.modalPresentationStyle = .fullScreen + editor.entryPoint = .bloggingPromptsListView + present(editor, animated: true) + } + +} + +// MARK: - Filter Tab Bar Support + +private extension BloggingPromptsViewController { + + // For Blogging Prompts V1, there is a single unfiltered prompts list. + // The expectation is it will be filtered at some point. So the FilterTabBar is hidden instead of removed. + // To show it, in the storyboard: + // - Unhide the FilterTabBar. + // - Remove the tableView top constraint to superview. + // - Enable the tableView top constraint to the FilterTabBar bottom. + + enum PromptFilter: Int, FilterTabBarItem, CaseIterable { + case all + case answered + case notAnswered + + var title: String { + switch self { + case .all: return NSLocalizedString("All", comment: "Title of all Blogging Prompts filter.") + case .answered: return NSLocalizedString("Answered", comment: "Title of answered Blogging Prompts filter.") + case .notAnswered: return NSLocalizedString("Not Answered", comment: "Title of unanswered Blogging Prompts filter.") + } + } + } + + func configureFilterTabBar() { + WPStyleGuide.configureFilterTabBar(filterTabBar) + filterTabBar.items = PromptFilter.allCases + filterTabBar.addTarget(self, action: #selector(selectedFilterDidChange(_:)), for: .valueChanged) + } + + @objc func selectedFilterDidChange(_ filterTabBar: FilterTabBar) { + // TODO: + // - track selected filter changed + // - refresh view for selected filter + } + +} + +// MARK: - StoryboardLoadable + +extension BloggingPromptsViewController: StoryboardLoadable { + static var defaultStoryboardName: String { + return "BloggingPromptsViewController" + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/RootViewCoordinator+BloggingPrompt.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/RootViewCoordinator+BloggingPrompt.swift new file mode 100644 index 000000000000..fdee3998d56e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Prompts/RootViewCoordinator+BloggingPrompt.swift @@ -0,0 +1,53 @@ + +/// Encapsulates logic related to Blogging Prompts in RootViewCoordinator. +/// +extension RootViewCoordinator { + + @objc func makeBloggingPromptCoordinator() -> BloggingPromptCoordinator { + return BloggingPromptCoordinator() + } + + @objc func updatePromptsIfNeeded() { + guard let blog = rootViewPresenter.currentOrLastBlog() else { + return + } + + bloggingPromptCoordinator.updatePromptsIfNeeded(for: blog) + } + + /// Shows prompt answering flow when a prompt notification is tapped. + /// + /// - Parameter userInfo: Notification payload. + func showPromptAnsweringFlow(with userInfo: NSDictionary) { + guard Feature.enabled(.bloggingPrompts), + let siteID = userInfo[BloggingPrompt.NotificationKeys.siteID] as? Int, + let blog = accountSites?.first(where: { $0.dotComID == NSNumber(value: siteID) }), + let viewController = rootViewPresenter.currentViewController else { + return + } + + // When the promptID is nil, it's most likely a static prompt notification. + let promptID = userInfo[BloggingPrompt.NotificationKeys.promptID] as? Int + let source: BloggingPromptCoordinator.Source = { + if promptID != nil { + return .promptNotification + } + return .promptStaticNotification + }() + + bloggingPromptCoordinator.showPromptAnsweringFlow(from: viewController, promptID: promptID, blog: blog, source: source) + } + +} + +private extension RootViewCoordinator { + + var accountSites: [Blog]? { + try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext)?.visibleBlogs + } + + struct Constants { + static let featureIntroDisplayedUDKey = "wp_intro_shown_blogging_prompts" + } + +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersActions.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersActions.swift new file mode 100644 index 000000000000..b1105e99422a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersActions.swift @@ -0,0 +1,16 @@ +/// Conform to this protocol to implement common actions for the blogging reminders flow +protocol BloggingRemindersActions: UIViewController { + func dismiss(from button: BloggingRemindersTracker.Button, + screen: BloggingRemindersTracker.Screen, + tracker: BloggingRemindersTracker) +} + +extension BloggingRemindersActions { + func dismiss(from button: BloggingRemindersTracker.Button, + screen: BloggingRemindersTracker.Screen, + tracker: BloggingRemindersTracker) { + + tracker.buttonPressed(button: button, screen: screen) + dismiss(animated: true, completion: nil) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersAnimator.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersAnimator.swift new file mode 100644 index 000000000000..978538eef3dc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersAnimator.swift @@ -0,0 +1,67 @@ +/// A transition animator that moves in the pushed view controller horizontally. +/// Does not handle the pop animation since the BloggingReminders setup flow does not allow to navigate back. +class BloggingRemindersAnimator: NSObject, UIViewControllerAnimatedTransitioning { + + var popStyle = false + + private static let animationDuration: TimeInterval = 0.2 + private static let sourceEndFrameOffset: CGFloat = -60.0 + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return Self.animationDuration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + + guard !popStyle else { + animatePop(using: transitionContext) + return + } + + guard let sourceViewController = + transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from), + let destinationViewController = + transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else { + return + } + // final position of the destination view + let destinationEndFrame = transitionContext.finalFrame(for: destinationViewController) + // final position of the source view + let sourceEndFrame = transitionContext.initialFrame(for: sourceViewController).offsetBy(dx: Self.sourceEndFrameOffset, dy: .zero) + + // initial position of the destination view + let destinationStartFrame = destinationEndFrame.offsetBy(dx: destinationEndFrame.width, dy: .zero) + destinationViewController.view.frame = destinationStartFrame + + transitionContext.containerView.insertSubview(destinationViewController.view, aboveSubview: sourceViewController.view) + + UIView.animate(withDuration: transitionDuration(using: transitionContext), + animations: { + destinationViewController.view.frame = destinationEndFrame + sourceViewController.view.frame = sourceEndFrame + }, completion: {_ in + transitionContext.completeTransition(true) + }) + } + + func animatePop(using transitionContext: UIViewControllerContextTransitioning) { + guard let sourceViewController = + transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from), + let destinationViewController = + transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else { + return + } + let destinationEndFrame = transitionContext.finalFrame(for: destinationViewController) + let destinationStartFrame = destinationEndFrame.offsetBy(dx: Self.sourceEndFrameOffset, dy: .zero) + destinationViewController.view.frame = destinationStartFrame + transitionContext.containerView.insertSubview(destinationViewController.view, belowSubview: sourceViewController.view) + + UIView.animate(withDuration: transitionDuration(using: transitionContext), + animations: { + destinationViewController.view.frame = destinationEndFrame + sourceViewController.view.transform = sourceViewController.view.transform.translatedBy(x: sourceViewController.view.frame.width, y: 0) + }, completion: {_ in + transitionContext.completeTransition(true) + }) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlow.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlow.swift new file mode 100644 index 000000000000..334c7e5dcbd3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlow.swift @@ -0,0 +1,86 @@ +import Foundation + +class BloggingRemindersFlow { + + typealias DismissClosure = () -> Void + + static func present(from viewController: UIViewController, + for blog: Blog, + source: BloggingRemindersTracker.FlowStartSource, + alwaysShow: Bool = true, + delegate: BloggingRemindersFlowDelegate? = nil, + onDismiss: DismissClosure? = nil) { + + guard Feature.enabled(.bloggingReminders) && JetpackNotificationMigrationService.shared.shouldPresentNotifications() else { + return + } + + guard alwaysShow || !hasShownWeeklyRemindersFlow(for: blog) else { + return + } + + let blogType: BloggingRemindersTracker.BlogType = blog.isHostedAtWPcom ? .wpcom : .selfHosted + + let tracker = BloggingRemindersTracker(blogType: blogType) + tracker.flowStarted(source: source) + + let flowStartViewController = makeStartViewController(for: blog, + tracker: tracker, + source: source, + delegate: delegate) + let navigationController = BloggingRemindersNavigationController( + rootViewController: flowStartViewController, + onDismiss: { + NoticesDispatch.unlock() + onDismiss?() + }) + + let bottomSheet = BottomSheetViewController(childViewController: navigationController, + customHeaderSpacing: 0) + + NoticesDispatch.lock() + bottomSheet.show(from: viewController) + setHasShownWeeklyRemindersFlow(for: blog) + } + + /// if the flow has never been seen, it starts with the intro. Otherwise it starts with the calendar settings + private static func makeStartViewController(for blog: Blog, + tracker: BloggingRemindersTracker, + source: BloggingRemindersTracker.FlowStartSource, + delegate: BloggingRemindersFlowDelegate? = nil) -> UIViewController { + + guard hasShownWeeklyRemindersFlow(for: blog) else { + return BloggingRemindersFlowIntroViewController(for: blog, + tracker: tracker, + source: source, + delegate: delegate) + } + + return (try? BloggingRemindersFlowSettingsViewController(for: blog, tracker: tracker, delegate: delegate)) ?? + BloggingRemindersFlowIntroViewController(for: blog, tracker: tracker, source: source, delegate: delegate) + } + + // MARK: - Weekly reminders flow presentation status + // + // stores a key for each blog in UserDefaults to determine if + // the flow was presented for the given blog. + private static func hasShownWeeklyRemindersFlow(for blog: Blog) -> Bool { + UserPersistentStoreFactory.instance().bool(forKey: weeklyRemindersKey(for: blog)) + } + + static func setHasShownWeeklyRemindersFlow(for blog: Blog) { + UserPersistentStoreFactory.instance().set(true, forKey: weeklyRemindersKey(for: blog)) + } + + private static func weeklyRemindersKey(for blog: Blog) -> String { + // weekly reminders key prefix + let prefix = "blogging-reminder-weekly-" + return prefix + blog.objectID.uriRepresentation().absoluteString + } + + /// By making this private we ensure this can't be instantiated. + /// + private init() { + assertionFailure() + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowCompletionViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowCompletionViewController.swift new file mode 100644 index 000000000000..bf101457a2c1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowCompletionViewController.swift @@ -0,0 +1,262 @@ +import UIKit + +class BloggingRemindersFlowCompletionViewController: UIViewController { + + // MARK: - Subviews + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.stackSpacing + stackView.axis = .vertical + stackView.alignment = .center + stackView.distribution = .equalSpacing + return stackView + }() + + private let imageView: UIImageView = { + let imageView = UIImageView(image: UIImage(named: Images.bellImageName)) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = .systemYellow + return imageView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = WPStyleGuide.serifFontForTextStyle(.title1, fontWeight: .semibold) + label.numberOfLines = 2 + label.textAlignment = .center + label.text = TextContent.completionTitle + return label + }() + + private let promptLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = .preferredFont(forTextStyle: .body) + label.numberOfLines = 6 + label.textAlignment = .center + label.textColor = .text + return label + }() + + private let hintLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = .preferredFont(forTextStyle: .footnote) + label.text = TextContent.completionUpdateHint + label.numberOfLines = 3 + label.textAlignment = .center + label.textColor = .secondaryLabel + return label + }() + + private lazy var doneButton: UIButton = { + let button = FancyButton() + button.isPrimary = true + button.setTitle(TextContent.doneButtonTitle, for: .normal) + button.addTarget(self, action: #selector(doneButtonTapped), for: .touchUpInside) + return button + }() + + // MARK: - Initializers + + let blog: Blog + let calendar: Calendar + let tracker: BloggingRemindersTracker + + init(blog: Blog, tracker: BloggingRemindersTracker, calendar: Calendar? = nil) { + self.blog = blog + self.tracker = tracker + + self.calendar = calendar ?? { + var calendar = Calendar.current + calendar.locale = Locale.autoupdatingCurrent + return calendar + }() + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + // This VC is designed to be instantiated programmatically. If we ever need to initialize this VC + // from a coder, we can implement support for it - but I don't think it's necessary right now. + // - diegoreymendez + fatalError("Use init(tracker:) instead") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .basicBackground + + configureStackView() + configureConstraints() + configurePromptLabel() + configureTitleLabel() + + navigationController?.setNavigationBarHidden(true, animated: false) + } + + override func viewDidAppear(_ animated: Bool) { + tracker.screenShown(.allSet) + + super.viewDidAppear(animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + // If a parent VC is being dismissed, and this is the last view shown in its navigation controller, we'll assume + // the flow was completed. + if isBeingDismissedDirectlyOrByAncestor() && navigationController?.viewControllers.last == self { + tracker.flowCompleted() + } + + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + calculatePreferredContentSize() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + hintLabel.isHidden = traitCollection.preferredContentSizeCategory.isAccessibilityCategory + } + + func calculatePreferredContentSize() { + let size = CGSize(width: view.bounds.width, height: UIView.layoutFittingCompressedSize.height) + preferredContentSize = view.systemLayoutSizeFitting(size) + } + + // MARK: - View Configuration + + private func configureStackView() { + view.addSubview(stackView) + + stackView.addArrangedSubviews([ + imageView, + titleLabel, + promptLabel, + hintLabel, + doneButton + ]) + stackView.setCustomSpacing(Metrics.afterHintSpacing, after: hintLabel) + } + + private func configureConstraints() { + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metrics.edgeMargins.left), + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.edgeMargins.right), + stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: Metrics.edgeMargins.top), + stackView.bottomAnchor.constraint(lessThanOrEqualTo: view.safeBottomAnchor, constant: -Metrics.edgeMargins.bottom), + + doneButton.heightAnchor.constraint(greaterThanOrEqualToConstant: Metrics.doneButtonHeight), + doneButton.widthAnchor.constraint(equalTo: stackView.widthAnchor), + ]) + } + + // Populates the prompt label with formatted text detailing the reminders set by the user. + // + private func configurePromptLabel() { + guard let scheduler = try? ReminderScheduleCoordinator() else { + return + } + + let schedule = scheduler.schedule(for: blog) + let formatter = BloggingRemindersScheduleFormatter() + + let style = NSMutableParagraphStyle() + style.lineSpacing = Metrics.promptTextLineSpacing + style.alignment = .center + + // The line break mode seems to be necessary to make it possible for the label to adjust it's + // size to stay under the allowed number of lines. + // To understand why this is necessary: turn on the largest available font size under iOS + // accessibility settings, and see that the label adjusts the font size to stay within the + // available space and allowed max number of lines. + style.lineBreakMode = .byTruncatingTail + + let defaultAttributes: [NSAttributedString.Key: AnyObject] = [ + .paragraphStyle: style, + .foregroundColor: UIColor.text, + ] + + let promptText = NSMutableAttributedString(attributedString: formatter.longScheduleDescription(for: schedule, time: scheduler.scheduledTime(for: blog).toLocalTime())) + + promptText.addAttributes(defaultAttributes, range: NSRange(location: 0, length: promptText.length)) + promptLabel.attributedText = promptText + } + + private func configureTitleLabel() { + guard let scheduler = try? ReminderScheduleCoordinator() else { + return + } + + if scheduler.schedule(for: blog) == .none { + titleLabel.text = TextContent.remindersRemovedTitle + } else { + titleLabel.text = TextContent.completionTitle + } + } +} + + // MARK: - Actions +extension BloggingRemindersFlowCompletionViewController: BloggingRemindersActions { + + // MARK: - BloggingRemindersActions + + @objc func doneButtonTapped() { + dismiss(from: .continue, screen: .allSet, tracker: tracker) + } + + @objc private func dismissTapped() { + dismiss(from: .dismiss, screen: .allSet, tracker: tracker) + } +} + +// MARK: - DrawerPresentable + +extension BloggingRemindersFlowCompletionViewController: DrawerPresentable { + var collapsedHeight: DrawerHeight { + return .intrinsicHeight + } +} + +extension BloggingRemindersFlowCompletionViewController: ChildDrawerPositionable { + var preferredDrawerPosition: DrawerPosition { + return .collapsed + } +} + +// MARK: - Constants + +private enum TextContent { + static let completionTitle = NSLocalizedString("All set!", comment: "Title of the completion screen of the Blogging Reminders Settings screen.") + + static let remindersRemovedTitle = NSLocalizedString("Reminders removed", comment: "Title of the completion screen of the Blogging Reminders Settings screen when the reminders are removed.") + + static let completionUpdateHint = NSLocalizedString("You can update this any time via My Site > Site Settings", + comment: "Prompt shown on the completion screen of the Blogging Reminders Settings screen.") + + static let doneButtonTitle = NSLocalizedString("Done", comment: "Title for a Done button.") +} + +private enum Images { + static let bellImageName = "reminders-bell" +} + +private enum Metrics { + static let edgeMargins = UIEdgeInsets(top: 46, left: 20, bottom: 20, right: 20) + static let stackSpacing: CGFloat = 20.0 + static let doneButtonHeight: CGFloat = 44.0 + static let afterHintSpacing: CGFloat = 24.0 + static let promptTextLineSpacing: CGFloat = 1.5 +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowIntroViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowIntroViewController.swift new file mode 100644 index 000000000000..a149c344d625 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowIntroViewController.swift @@ -0,0 +1,222 @@ +import UIKit + +class BloggingRemindersFlowIntroViewController: UIViewController { + + // MARK: - Subviews + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.stackSpacing + stackView.axis = .vertical + stackView.alignment = .center + stackView.distribution = .equalSpacing + return stackView + }() + + private let imageView: UIImageView = { + let imageView = UIImageView(image: UIImage(named: Images.celebrationImageName)) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = .systemYellow + return imageView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = WPStyleGuide.serifFontForTextStyle(.title1, fontWeight: .semibold) + label.numberOfLines = 2 + label.textAlignment = .center + label.text = TextContent.introTitle + return label + }() + + private let promptLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = .preferredFont(forTextStyle: .body) + label.numberOfLines = 5 + label.textAlignment = .center + return label + }() + + private lazy var getStartedButton: UIButton = { + let button = FancyButton() + button.isPrimary = true + button.setTitle(TextContent.introButtonTitle, for: .normal) + button.addTarget(self, action: #selector(getStartedTapped), for: .touchUpInside) + return button + }() + + // MARK: - Initializers + + private let blog: Blog + private let tracker: BloggingRemindersTracker + private let source: BloggingRemindersTracker.FlowStartSource + private weak var delegate: BloggingRemindersFlowDelegate? + + private var introDescription: String { + switch source { + case .publishFlow: + return TextContent.postPublishingintroDescription + case .blogSettings, + .notificationSettings, + .statsInsights, + .bloggingPromptsFeatureIntroduction: + return TextContent.siteSettingsIntroDescription + } + } + + init(for blog: Blog, + tracker: BloggingRemindersTracker, + source: BloggingRemindersTracker.FlowStartSource, + delegate: BloggingRemindersFlowDelegate? = nil) { + self.blog = blog + self.tracker = tracker + self.source = source + self.delegate = delegate + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + // This VC is designed to be instantiated programmatically. If we ever need to initialize this VC + // from a coder, we can implement support for it - but I don't think it's necessary right now. + // - diegoreymendez + fatalError("Use init(tracker:) instead") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .basicBackground + + configureStackView() + configureConstraints() + promptLabel.text = introDescription + } + + override func viewDidAppear(_ animated: Bool) { + tracker.screenShown(.main) + + super.viewDidAppear(animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + // If a parent VC is being dismissed, and this is the last view shown in its navigation controller, we'll assume + // the flow was interrupted. + if isBeingDismissedDirectlyOrByAncestor() && navigationController?.viewControllers.last == self { + tracker.flowDismissed(source: .main) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + calculatePreferredContentSize() + } + + private func calculatePreferredContentSize() { + let size = CGSize(width: view.bounds.width, height: UIView.layoutFittingCompressedSize.height) + preferredContentSize = view.systemLayoutSizeFitting(size) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + view.setNeedsLayout() + } + + // MARK: - View Configuration + + private func configureStackView() { + view.addSubview(stackView) + stackView.addArrangedSubviews([ + imageView, + titleLabel, + promptLabel, + getStartedButton + ]) + stackView.setCustomSpacing(Metrics.afterPromptSpacing, after: promptLabel) + } + + private func configureConstraints() { + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metrics.edgeMargins.left), + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.edgeMargins.right), + stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: Metrics.edgeMargins.top), + stackView.bottomAnchor.constraint(lessThanOrEqualTo: view.safeBottomAnchor, constant: -Metrics.edgeMargins.bottom), + + getStartedButton.heightAnchor.constraint(greaterThanOrEqualToConstant: Metrics.getStartedButtonHeight), + getStartedButton.widthAnchor.constraint(equalTo: stackView.widthAnchor), + ]) + } + + @objc private func getStartedTapped() { + tracker.buttonPressed(button: .continue, screen: .main) + + do { + let flowSettingsViewController = try BloggingRemindersFlowSettingsViewController(for: blog, tracker: tracker, delegate: delegate) + + navigationController?.pushViewController(flowSettingsViewController, animated: true) + } catch { + DDLogError("Could not instantiate the blogging reminders settings VC: \(error.localizedDescription)") + dismiss(animated: true, completion: nil) + } + } +} + +extension BloggingRemindersFlowIntroViewController: BloggingRemindersActions { + + @objc private func dismissTapped() { + dismiss(from: .dismiss, screen: .main, tracker: tracker) + } +} + +// MARK: - DrawerPresentable + +extension BloggingRemindersFlowIntroViewController: DrawerPresentable { + var collapsedHeight: DrawerHeight { + return .intrinsicHeight + } +} + +// MARK: - ChildDrawerPositionable + +extension BloggingRemindersFlowIntroViewController: ChildDrawerPositionable { + var preferredDrawerPosition: DrawerPosition { + return .collapsed + } +} + +// MARK: - Constants + +private enum TextContent { + static let introTitle = NSLocalizedString("Set your blogging reminders", + comment: "Title of the Blogging Reminders Settings screen.") + + static let postPublishingintroDescription = NSLocalizedString("Your post is publishing... in the meantime, set up your blogging reminders on days you want to post.", + comment: "Description on the first screen of the Blogging Reminders Settings flow called aftet post publishing.") + + static let siteSettingsIntroDescription = NSLocalizedString("Set up your blogging reminders on days you want to post.", + comment: "Description on the first screen of the Blogging Reminders Settings flow called from site settings.") + + static let introButtonTitle = NSLocalizedString("Set reminders", + comment: "Title of the set goals button in the Blogging Reminders Settings flow.") +} + +private enum Images { + static let celebrationImageName = "reminders-celebration" +} + +private enum Metrics { + static let edgeMargins = UIEdgeInsets(top: 46, left: 20, bottom: 20, right: 20) + static let stackSpacing: CGFloat = 20.0 + static let afterPromptSpacing: CGFloat = 24.0 + static let getStartedButtonHeight: CGFloat = 44.0 +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowSettingsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowSettingsViewController.swift new file mode 100644 index 000000000000..ebe9c2fe6153 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersFlowSettingsViewController.swift @@ -0,0 +1,794 @@ +import UIKit +import WordPressKit + +protocol BloggingRemindersFlowDelegate: AnyObject { + func didSetUpBloggingReminders() +} + +class BloggingRemindersFlowSettingsViewController: UIViewController { + + // MARK: - Subviews + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.stackSpacing + stackView.axis = .vertical + stackView.alignment = .center + stackView.distribution = .fill + return stackView + }() + + private let imageView: UIImageView = { + let imageView = UIImageView(image: UIImage(named: Images.calendarImageName)) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = .systemRed + return imageView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.75 + label.font = WPStyleGuide.serifFontForTextStyle(.title1, fontWeight: .semibold) + label.numberOfLines = 3 + label.textAlignment = .center + label.text = TextContent.settingsPrompt + return label + }() + + private let promptLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.75 + label.font = .preferredFont(forTextStyle: .body) + label.text = TextContent.settingsUpdatePrompt + label.numberOfLines = 2 + label.textAlignment = .center + label.textColor = .secondaryLabel + label.setContentHuggingPriority(.defaultLow, for: .vertical) + return label + }() + + private lazy var button: UIButton = { + let button = FancyButton() + button.isPrimary = true + button.addTarget(self, action: #selector(notifyMeButtonTapped), for: .touchUpInside) + return button + }() + + private let daysOuterStackView: UIStackView = { + let daysOuterStack = UIStackView() + daysOuterStack.axis = .vertical + daysOuterStack.alignment = .center + daysOuterStack.spacing = Metrics.innerStackSpacing + daysOuterStack.distribution = .fillEqually + return daysOuterStack + }() + + private let daysTopInnerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = Metrics.innerStackSpacing + return stackView + }() + + private let daysBottomInnerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = Metrics.innerStackSpacing + return stackView + }() + + private lazy var frequencyLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.numberOfLines = 2 + label.textAlignment = .center + + return label + }() + + private lazy var frequencyView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .basicBackground + view.addSubview(frequencyLabel) + return view + }() + + private lazy var topDivider: UIView = { + makeDivider() + }() + + private lazy var bottomDivider: UIView = { + makeDivider() + }() + + private lazy var timeSelectionButton: TimeSelectionButton = { + let button = TimeSelectionButton(selectedTime: scheduledTime.toLocalTime()) + button.isUserInteractionEnabled = true + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(navigateToTimePicker), for: .touchUpInside) + return button + }() + + @objc private func navigateToTimePicker() { + pushTimeSelectionViewController() + } + + private lazy var timeSelectionView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .basicBackground + view.addSubview(timeSelectionStackView) + return view + }() + + private lazy var timeSelectionStackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.addArrangedSubviews([topDivider, timeSelectionButton, bottomDivider]) + return stackView + }() + + private lazy var bloggingPromptsTitle: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.font = .preferredFont(forTextStyle: .body) + label.text = TextContent.bloggingPromptsTitle + return label + }() + + private lazy var bloggingPromptsInfoButton: UIButton = { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(.gridicon(.helpOutline), for: .normal) + button.tintColor = .listSmallIcon + button.accessibilityLabel = TextContent.bloggingPromptsInfoButton + button.addTarget(self, action: #selector(bloggingPromptsInfoButtonTapped), for: .touchUpInside) + return button + }() + + private lazy var bloggingPromptsTitleStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [bloggingPromptsTitle, bloggingPromptsInfoButton, makeSpacer()]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.BloggingPrompts.titleSpacing + stackView.alignment = .center + return stackView + }() + + private lazy var bloggingPromptsDescription: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .preferredFont(forTextStyle: .subheadline) + label.text = TextContent.bloggingPromptsDescription + label.textColor = .textSubtle + label.numberOfLines = 0 + return label + }() + + private lazy var bloggingPromptsSwitch: UISwitch = { + let bloggingPromptsSwitch = UISwitch() + bloggingPromptsSwitch.translatesAutoresizingMaskIntoConstraints = false + bloggingPromptsSwitch.isOn = promptRemindersEnabled + bloggingPromptsSwitch.addTarget(self, action: #selector(bloggingPromptsSwitchChanged), for: .valueChanged) + return bloggingPromptsSwitch + }() + + private lazy var bloggingPromptsView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubviews([bloggingPromptsTitleStackView, bloggingPromptsDescription, bloggingPromptsSwitch]) + view.isHidden = !isBloggingPromptsEnabled + return view + }() + + private lazy var bloggingPromptsToConfirmationButtonSpacer: UIView = { + makeSpacer() + }() + + // MARK: - Properties + + private let calendar: Calendar + private let scheduler: ReminderScheduleCoordinator + private let scheduleFormatter = BloggingRemindersScheduleFormatter() + private var weekdays: [BloggingRemindersScheduler.Weekday] { + didSet { + refreshNextButton() + } + } + + /// The weekdays that have been saved / scheduled in a previous blogging reminders configuration. + /// + private let previousWeekdays: [BloggingRemindersScheduler.Weekday] + + private lazy var bloggingPromptsService: BloggingPromptsService? = { + BloggingPromptsService(blog: blog) + }() + + // MARK: - Initializers + + private let blog: Blog + private let tracker: BloggingRemindersTracker + private var scheduledTime: Date + private weak var delegate: BloggingRemindersFlowDelegate? + + init( + for blog: Blog, + tracker: BloggingRemindersTracker, + calendar: Calendar? = nil, + delegate: BloggingRemindersFlowDelegate? = nil) throws { + + self.blog = blog + self.tracker = tracker + self.calendar = calendar ?? { + var calendar = Calendar.current + calendar.locale = Locale.autoupdatingCurrent + + return calendar + }() + self.delegate = delegate + + scheduler = try ReminderScheduleCoordinator() + + switch self.scheduler.schedule(for: blog) { + case .none: + previousWeekdays = [] + case .weekdays(let scheduledWeekdays): + previousWeekdays = scheduledWeekdays + } + + weekdays = previousWeekdays + + scheduledTime = scheduler.scheduledTime(for: blog) + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + // This VC is designed to be instantiated programmatically. If we ever need to initialize this VC + // from a coder, we can implement support for it - but I don't think it's necessary right now. + // - diegoreymendez + fatalError("Use init(tracker:) instead") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .basicBackground + + configureStackView() + configureConstraints() + populateCalendarDays() + refreshNextButton() + refreshFrequencyLabel() + + showFullUI(shouldShowFullUI) + } + + override func viewDidAppear(_ animated: Bool) { + tracker.screenShown(.dayPicker) + + super.viewDidAppear(animated) + calculatePreferredContentSize() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + // If a parent VC is being dismissed, and this is the last view shown in its navigation controller, we'll assume + // the flow was interrupted. + if isBeingDismissedDirectlyOrByAncestor() && navigationController?.viewControllers.last == self { + tracker.flowDismissed(source: .dayPicker) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + calculatePreferredContentSize() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + imageView.isHidden = traitCollection.preferredContentSizeCategory.isAccessibilityCategory || !shouldShowFullUI + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + showFullUI(shouldShowFullUI) + calculatePreferredContentSize() + } + + // MARK: - Actions + + @objc private func notifyMeButtonTapped() { + tracker.buttonPressed(button: .continue, screen: .dayPicker) + scheduleReminders() + } + + @objc private func bloggingPromptsInfoButtonTapped() { + WPAnalytics.track(.promptsReminderSettingsHelp) + + present(BloggingPromptsFeatureIntroduction.navigationController(interactionType: .informational), animated: true) + } + + @objc private func bloggingPromptsSwitchChanged(_ sender: UISwitch) { + WPAnalytics.track(.promptsReminderSettingsIncludeSwitch, properties: ["enabled": String(sender.isOn)]) + } + + /// Schedules the reminders and shows a VC that requests PN authorization, if necessary. + /// + /// - Parameters: + /// - showPushPrompt: if `true` the PN authorization prompt VC will be shown. + /// When `false`, the VC won't be shown. This is useful because this method + /// can also be called when the refrenced VC is already on-screen. + /// + private func scheduleReminders(showPushPrompt: Bool = true) { + let schedule: BloggingRemindersScheduler.Schedule + + if weekdays.count > 0 { + schedule = .weekdays(weekdays) + } else { + schedule = .none + } + + // update local prompt settings so that the coordinator uses the right scheduler. + let resetPromptSettingsClosure = temporarilyUpdatePromptSettings() + let promptSettingsChanged = resetPromptSettingsClosure != nil + button.isEnabled = false + + scheduler.schedule(schedule, for: blog, time: scheduledTime) { [weak self] result in + guard let self = self else { + return + } + switch result { + case .success: + self.tracker.scheduled(schedule, time: self.scheduledTime) + + DispatchQueue.main.async { [weak self] in + let completion = { + self?.delegate?.didSetUpBloggingReminders() + self?.pushCompletionViewController() + self?.button.isEnabled = true + } + + // only sync prompt settings in Blogging Prompts context. + guard promptSettingsChanged else { + completion() + return + } + + // sync the updated settings to remote. + self?.syncPromptsScheduleIfNeeded { + completion() + } + } + + case .failure(let error): + switch error { + case BloggingRemindersScheduler.Error.needsPermissionForPushNotifications where showPushPrompt == true: + DispatchQueue.main.async { [weak self] in + self?.pushPushPromptViewController() + self?.button.isEnabled = true + } + default: + // The scheduler should normally not fail unless it's because of having no push permissions. + // As a simple solution for now, we'll just avoid taking any action if the scheduler did fail. + DDLogError("Error scheduling blogging reminders: \(error)") + self.button.isEnabled = true + break + } + + // When scheduling fails, call the reset closure to reset prompt settings to its previous state. + // Note that this closure should only exist in Blogging Prompts context; in Blogging Reminders context, this should be nil. + resetPromptSettingsClosure?() + } + } + } + +} + +// MARK: - Navigation +private extension BloggingRemindersFlowSettingsViewController { + + func pushTimeSelectionViewController() { + let viewController = TimeSelectionViewController(scheduledTime: scheduler.scheduledTime(for: blog), + tracker: tracker) { [weak self] date in + self?.scheduledTime = date + self?.timeSelectionButton.setSelectedTime(date.toLocalTime()) + self?.refreshNextButton() + self?.refreshFrequencyLabel() + } + viewController.preferredWidth = self.view.frame.width + navigationController?.pushViewController(viewController, animated: true) + } + + func pushCompletionViewController() { + let viewController = BloggingRemindersFlowCompletionViewController(blog: blog, tracker: tracker, calendar: calendar) + navigationController?.pushViewController(viewController, animated: true) + } + + private func pushPushPromptViewController() { + let viewController = BloggingRemindersPushPromptViewController(tracker: tracker) { [weak self] in + self?.scheduleReminders(showPushPrompt: false) + } + navigationController?.pushViewController(viewController, animated: true) + } +} + +// MARK: - Private Helpers +private extension BloggingRemindersFlowSettingsViewController { + + /// creates an instance of a UIView with a grey background, intended to be used as a divider + func makeDivider() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = UIColor.divider + return view + } + + /// instantiates a UIView with transparent background, intented to be used as a spacer in a UIStackView + func makeSpacer() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + } + + /// Determines if the calendar image should be displayed, depending on the screen vertical size + var shouldShowFullUI: Bool { + (WPDeviceIdentification.isiPhone() && UIScreen.main.bounds.height >= Metrics.minimumHeightForFullUI) || + (WPDeviceIdentification.isiPad() && UIDevice.current.orientation.isPortrait) + } + + /// Hides/shows the optional UI Elements (dismiss button & calendar icon) + /// - Parameter isVisible: true if we need to show the elements (Full UI), false otherwise + func showFullUI(_ isVisible: Bool) { + imageView.isHidden = !isVisible + } + + /// Updates the title of the cconfirmation button depending on the action (new schedule or updated schedule) + func refreshNextButton() { + if previousWeekdays.isEmpty { + button.setTitle(TextContent.nextButtonTitle, for: .normal) + button.isEnabled = !weekdays.isEmpty + } else if (weekdays == previousWeekdays) && (scheduledTime == scheduler.scheduledTime(for: blog)) { + button.setTitle(TextContent.nextButtonTitle, for: .normal) + button.isEnabled = true + } else { + button.setTitle(TextContent.updateButtonTitle, for: .normal) + button.isEnabled = true + } + } + + /// Updates the label that contains the number of scheduled days as users change them + func refreshFrequencyLabel() { + guard weekdays.count > 0 else { + frequencyLabel.isHidden = true + timeSelectionStackView.isHidden = true + return + } + + frequencyLabel.isHidden = false + timeSelectionStackView.isHidden = false + + let defaultAttributes: [NSAttributedString.Key: AnyObject] = [ + .foregroundColor: UIColor.text, + ] + + let frequencyDescription = scheduleFormatter.shortScheduleDescription(for: .weekdays(weekdays)) + let attributedText = NSMutableAttributedString(attributedString: frequencyDescription) + attributedText.addAttributes(defaultAttributes, range: NSRange(location: 0, length: attributedText.length)) + + frequencyLabel.attributedText = attributedText + frequencyLabel.sizeToFit() + } + + func calculatePreferredContentSize() { + let size = CGSize(width: view.bounds.width, height: UIView.layoutFittingCompressedSize.height) + preferredContentSize = view.systemLayoutSizeFitting(size) + } + + func configureStackView() { + view.addSubview(stackView) + + stackView.addArrangedSubviews([ + imageView, + titleLabel, + promptLabel, + daysOuterStackView, + frequencyView, + timeSelectionView, + bloggingPromptsView, + bloggingPromptsToConfirmationButtonSpacer, + button, + ]) + + stackView.setCustomSpacing(Metrics.afterTitleLabelSpacing, after: titleLabel) + stackView.setCustomSpacing(Metrics.afterPromptLabelSpacing, after: promptLabel) + stackView.setCustomSpacing(Metrics.afterTimeSelectionViewSpacing, after: timeSelectionView) + stackView.setCustomSpacing(.zero, after: bloggingPromptsView) + stackView.setCustomSpacing(WPDeviceIdentification.isiPad() ? Metrics.stackSpacing : .zero, + after: bloggingPromptsToConfirmationButtonSpacer) + } + + func configureConstraints() { + frequencyView.pinSubviewToAllEdges(frequencyLabel) + timeSelectionView.pinSubviewToAllEdges(timeSelectionStackView) + + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + timeSelectionView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + button.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + + bloggingPromptsTitle.setContentCompressionResistancePriority(.required, for: .vertical) + bloggingPromptsDescription.setContentCompressionResistancePriority(.required, for: .vertical) + bloggingPromptsSwitch.setContentCompressionResistancePriority(.required, for: .horizontal) + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metrics.edgeMargins.left), + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.edgeMargins.right), + stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: Metrics.edgeMargins.top), + stackView.bottomAnchor.constraint(equalTo: view.safeBottomAnchor, + constant: WPDeviceIdentification.isiPad() ? Metrics.ipadBottomMargin : -Metrics.edgeMargins.bottom), + + imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor), + + button.heightAnchor.constraint(equalToConstant: Metrics.buttonHeight), + button.widthAnchor.constraint(equalTo: stackView.widthAnchor), + + topDivider.heightAnchor.constraint(equalToConstant: .hairlineBorderWidth), + bottomDivider.heightAnchor.constraint(equalToConstant: .hairlineBorderWidth), + timeSelectionView.heightAnchor.constraint(equalToConstant: Metrics.buttonHeight), + timeSelectionView.widthAnchor.constraint(equalTo: stackView.widthAnchor), + frequencyView.heightAnchor.constraint(equalToConstant: Metrics.frequencyLabelHeight), + ]) + + configureBloggingPromptsConstraints() + } + + func configureBloggingPromptsConstraints() { + guard isBloggingPromptsEnabled else { + NSLayoutConstraint.activate([ + bloggingPromptsView.widthAnchor.constraint(equalToConstant: .zero), + bloggingPromptsView.heightAnchor.constraint(equalToConstant: .zero), + ]) + return + } + + NSLayoutConstraint.activate([ + bloggingPromptsTitleStackView.leadingAnchor.constraint(equalTo: bloggingPromptsView.leadingAnchor), + bloggingPromptsTitleStackView.trailingAnchor.constraint(equalTo: bloggingPromptsView.trailingAnchor), + bloggingPromptsTitleStackView.topAnchor.constraint(equalTo: bloggingPromptsView.topAnchor), + bloggingPromptsDescription.topAnchor.constraint(equalTo: bloggingPromptsTitleStackView.bottomAnchor, + constant: Metrics.BloggingPrompts.labelsSpacing), + bloggingPromptsDescription.leadingAnchor.constraint(equalTo: bloggingPromptsView.leadingAnchor), + bloggingPromptsDescription.bottomAnchor.constraint(equalTo: bloggingPromptsView.bottomAnchor), + bloggingPromptsSwitch.leadingAnchor.constraint(greaterThanOrEqualTo: bloggingPromptsDescription.trailingAnchor, + constant: Metrics.BloggingPrompts.switchLeading), + bloggingPromptsSwitch.trailingAnchor.constraint(equalTo: bloggingPromptsView.trailingAnchor), + bloggingPromptsSwitch.centerYAnchor.constraint(equalTo: bloggingPromptsView.centerYAnchor), + bloggingPromptsInfoButton.heightAnchor.constraint(equalToConstant: Metrics.BloggingPrompts.infoButtonHeight), + bloggingPromptsInfoButton.widthAnchor.constraint(equalTo: bloggingPromptsInfoButton.heightAnchor), + bloggingPromptsView.widthAnchor.constraint(equalTo: stackView.widthAnchor), + ]) + } + + // MARK: - Calendar Days Buttons + + /// Creates the calendar day toggle buttons. This is a convenience method to take care of the mapping of the day index, from Apple's calendar, to + /// our `BloggingRemindersScheduler.Weekday`. In theory this should never return `nil`, but we're allowing it to avoid possible crashes. + /// + /// - Parameters: + /// - weekday: the weekday the button is for. + /// + /// - Returns: the requested toggle button. + /// + private func createCalendarDayToggleButton(localizedWeekdayDayIndex: Int) -> CalendarDayToggleButton? { + let weekdayIndex = calendar.unlocalizedWeekdayIndex(localizedWeekdayIndex: localizedWeekdayDayIndex) + + guard let weekday = BloggingRemindersScheduler.Weekday(rawValue: weekdayIndex) else { + return nil + } + + let isSelected = weekdays.contains(weekday) + let button = CalendarDayToggleButton( + weekday: weekday, + dayName: calendar.shortWeekdaySymbols[weekdayIndex].uppercased(), + isSelected: isSelected) { [weak self] button in + + guard let self = self else { + return + } + + if button.isSelected { + self.weekdays.append(button.weekday) + } else { + self.weekdays.removeAll { weekday in + weekday == button.weekday + } + } + + self.refreshFrequencyLabel() + } + + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.titleLabel?.adjustsFontSizeToFitWidth = true + + return button + } + + /// Adds the calendar days to the UI according to the device locale + func populateCalendarDays() { + daysOuterStackView.addArrangedSubviews([daysTopInnerStackView, daysBottomInnerStackView]) + + let topRow = 0 ..< Metrics.topRowDayCount + let bottomRow = Metrics.topRowDayCount ..< calendar.shortWeekdaySymbols.count + + daysTopInnerStackView.addArrangedSubviews(topRow.compactMap({ createCalendarDayToggleButton(localizedWeekdayDayIndex: $0) })) + daysBottomInnerStackView.addArrangedSubviews(bottomRow.compactMap({ createCalendarDayToggleButton(localizedWeekdayDayIndex: $0) })) + } +} + +// MARK: - BloggingRemindersActions +extension BloggingRemindersFlowSettingsViewController: BloggingRemindersActions { + + @objc private func dismissTapped() { + dismiss(from: .dismiss, screen: .dayPicker, tracker: tracker) + } +} + +extension BloggingRemindersFlowSettingsViewController: DrawerPresentable { + var collapsedHeight: DrawerHeight { + return .maxHeight + } +} + +extension BloggingRemindersFlowSettingsViewController: ChildDrawerPositionable { + var preferredDrawerPosition: DrawerPosition { + return .expanded + } +} + +// MARK: - Blogging Prompts Helpers + +private extension BloggingRemindersFlowSettingsViewController { + + var isBloggingPromptsEnabled: Bool { + return FeatureFlag.bloggingPrompts.enabled && blog.isAccessibleThroughWPCom() + } + + var promptRemindersEnabled: Bool { + guard isBloggingPromptsEnabled, + let settings = bloggingPromptsService?.localSettings else { + return false + } + + return settings.promptRemindersEnabled + } + + /// Temporarily update the local prompt settings with the new one. + /// The method returns a closure that will revert the changes made to the settings when executed. + /// + /// Note that the settings will only be updated when the switch to ON, or when the user turns the switch from ON to OFF. + /// + /// - Returns: A closure used to reset changes made to the prompt settings. Returns nil if the update condition is not fulfilled. + func temporarilyUpdatePromptSettings() -> (() -> Void)? { + guard isBloggingPromptsEnabled, + bloggingPromptsSwitch.isOn || (promptRemindersEnabled && !bloggingPromptsSwitch.isOn), + let settings = bloggingPromptsService?.localSettings, + let context = settings.managedObjectContext else { + return nil + } + + let previousSettings = RemoteBloggingPromptsSettings(with: settings) + + // update local settings to the selected schedule and time. + typealias Weekday = BloggingRemindersScheduler.Weekday + let selectedDays = Weekday.allCases.map { + weekdays.contains($0) + } + let days = RemoteBloggingPromptsSettings.ReminderDays( + monday: selectedDays[Weekday.monday.rawValue], + tuesday: selectedDays[Weekday.tuesday.rawValue], + wednesday: selectedDays[Weekday.wednesday.rawValue], + thursday: selectedDays[Weekday.thursday.rawValue], + friday: selectedDays[Weekday.friday.rawValue], + saturday: selectedDays[Weekday.saturday.rawValue], + sunday: selectedDays[Weekday.sunday.rawValue] + ) + let timeDateFormatter = DateFormatter() + timeDateFormatter.dateFormat = "HH.mm" + let reminderTime = timeDateFormatter.string(from: scheduledTime) + let newSettings = RemoteBloggingPromptsSettings( + promptRemindersEnabled: bloggingPromptsSwitch.isOn, + reminderDays: days, + reminderTime: reminderTime + ) + + settings.configure(with: newSettings, siteID: settings.siteID, context: context) + ContextManager.shared.saveContextAndWait(context) + + return { + settings.configure(with: previousSettings, siteID: settings.siteID, context: context) + ContextManager.shared.saveContextAndWait(context) + } + } + + /// Synchronizes the prompt settings to remote. + /// + /// - Parameter completion: Closure called when the process completes. + func syncPromptsScheduleIfNeeded(_ completion: @escaping () -> Void) { + guard isBloggingPromptsEnabled, + let service = bloggingPromptsService, + let settings = service.localSettings else { + completion() + return + } + + let newSettings = RemoteBloggingPromptsSettings(with: settings) + service.updateSettings(settings: newSettings) { updatedSettings in + completion() + } failure: { error in + DDLogError("Error saving prompt reminder schedule: \(String(describing: error))") + completion() + } + } + +} + +// MARK: - Constants +private enum TextContent { + static let settingsPrompt = NSLocalizedString("Select the days you want to blog on", + comment: "Prompt shown on the Blogging Reminders Settings screen.") + + static let settingsUpdatePrompt = NSLocalizedString("You can update this any time", + comment: "Prompt shown on the Blogging Reminders Settings screen.") + + static let nextButtonTitle = NSLocalizedString("Notify me", comment: "Title of button to navigate to the next screen of the blogging reminders flow, setting up push notifications.") + + static let updateButtonTitle = NSLocalizedString("Update", comment: "(Verb) Title of button confirming updating settings for blogging reminders.") + static let bloggingPromptsTitle = NSLocalizedString("Include a Blogging Prompt", comment: "Title of the switch to turn on or off the blogging prompts feature.") + static let bloggingPromptsDescription = NSLocalizedString("Notification will include a word or short phrase for inspiration", comment: "Description of the blogging prompts feature on the Blogging Reminders Settings screen.") + static let bloggingPromptsInfoButton = NSLocalizedString("Learn more about prompts", comment: "Accessibility label for the blogging prompts info button on the Blogging Reminders Settings screen.") +} + +private enum Images { + static let calendarImageName = "reminders-calendar" +} + +private enum Metrics { + static let edgeMargins = UIEdgeInsets(top: 46, left: 20, bottom: 56, right: 20) + static let ipadBottomMargin: CGFloat = -20.0 + + static let stackSpacing: CGFloat = 24.0 + static let innerStackSpacing: CGFloat = 8.0 + static let afterTitleLabelSpacing: CGFloat = 16.0 + static let afterPromptLabelSpacing: CGFloat = 40.0 + static let afterTimeSelectionViewSpacing: CGFloat = 10.0 + + static let buttonHeight: CGFloat = 44.0 + static let frequencyLabelHeight: CGFloat = 30 + + static let topRowDayCount = 4 + + // the smallest logical iPhone height (iPhone 12 mini) to display the full UI, which includes calendar icon. + static let minimumHeightForFullUI: CGFloat = 812 + + enum BloggingPrompts { + static let titleSpacing: CGFloat = 5.0 + static let labelsSpacing: CGFloat = 2.0 + static let infoButtonHeight: CGFloat = 16.0 + static let switchLeading: CGFloat = 16.0 + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersNavigationController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersNavigationController.swift new file mode 100644 index 000000000000..5d214bd3e902 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersNavigationController.swift @@ -0,0 +1,123 @@ +import UIKit + +protocol ChildDrawerPositionable { + var preferredDrawerPosition: DrawerPosition { get } +} + +class BloggingRemindersNavigationController: LightNavigationController { + + typealias DismissClosure = () -> Void + + private let onDismiss: DismissClosure? + + required init(rootViewController: UIViewController, onDismiss: DismissClosure? = nil) { + self.onDismiss = onDismiss + + super.init(rootViewController: rootViewController) + + delegate = self + setNavigationBarHidden(true, animated: false) + navigationBar.isTranslucent = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + if isBeingDismissedDirectlyOrByAncestor() { + onDismiss?() + } + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait + } + + override public var preferredContentSize: CGSize { + set { + viewControllers.last?.preferredContentSize = newValue + super.preferredContentSize = newValue + } + get { + guard let visibleViewController = viewControllers.last else { + return .zero + } + + return visibleViewController.preferredContentSize + } + } + + override func pushViewController(_ viewController: UIViewController, animated: Bool) { + super.pushViewController(viewController, animated: animated) + + updateDrawerPosition() + } + + override func popViewController(animated: Bool) -> UIViewController? { + let viewController = super.popViewController(animated: animated) + + updateDrawerPosition() + + return viewController + } + + private func updateDrawerPosition() { + if let bottomSheet = self.parent as? BottomSheetViewController, + let presentedVC = bottomSheet.presentedVC, + let currentVC = topViewController as? ChildDrawerPositionable { + presentedVC.transition(to: currentVC.preferredDrawerPosition) + } + } +} + +// MARK: - DrawerPresentable + +extension BloggingRemindersNavigationController: DrawerPresentable { + var allowsUserTransition: Bool { + return false + } + + var allowsDragToDismiss: Bool { + return true + } + + var allowsTapToDismiss: Bool { + return true + } + + var expandedHeight: DrawerHeight { + return .maxHeight + } + + var collapsedHeight: DrawerHeight { + if let viewController = viewControllers.last as? DrawerPresentable { + return viewController.collapsedHeight + } + + return .intrinsicHeight + } + + func handleDismiss() { + (children.last as? DrawerPresentable)?.handleDismiss() + } +} + +// MARK: - NavigationControllerDelegate + +extension BloggingRemindersNavigationController: UINavigationControllerDelegate { + + /// This implementation uses the custom `BloggingRemindersAnimator` to improve screen transitions + /// in the blogging reminders setup flow. + func navigationController(_ navigationController: UINavigationController, + animationControllerFor operation: UINavigationController.Operation, + from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { + + let animator = BloggingRemindersAnimator() + animator.popStyle = (operation == .pop) + + return animator + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersPushPromptViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersPushPromptViewController.swift new file mode 100644 index 000000000000..97bb0eb4d335 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersPushPromptViewController.swift @@ -0,0 +1,270 @@ +import UIKit + + +class BloggingRemindersPushPromptViewController: UIViewController { + + // MARK: - Subviews + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.stackSpacing + stackView.axis = .vertical + stackView.alignment = .center + stackView.distribution = .equalSpacing + return stackView + }() + + private let imageView: UIImageView = { + let imageView = UIImageView(image: UIImage(named: Images.bellImageName)) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = .systemYellow + return imageView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = WPStyleGuide.serifFontForTextStyle(.title1, fontWeight: .semibold) + label.numberOfLines = 2 + label.textAlignment = .center + label.text = TextContent.title + return label + }() + + private let promptLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = .preferredFont(forTextStyle: .body) + label.text = TextContent.prompt + label.numberOfLines = 4 + label.textAlignment = .center + label.textColor = .secondaryLabel + return label + }() + + private let hintLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.font = .preferredFont(forTextStyle: .body) + label.text = TextContent.hint + label.numberOfLines = 4 + label.textAlignment = .center + label.textColor = .secondaryLabel + return label + }() + + private lazy var turnOnNotificationsButton: UIButton = { + let button = FancyButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.isPrimary = true + button.setTitle(TextContent.turnOnButtonTitle, for: .normal) + button.addTarget(self, action: #selector(turnOnButtonTapped), for: .touchUpInside) + button.titleLabel?.adjustsFontSizeToFitWidth = true + return button + }() + + private lazy var dismissButton: UIButton = { + let button = UIButton(type: .custom) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(.gridicon(.cross), for: .normal) + button.tintColor = .secondaryLabel + button.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) + return button + }() + + // MARK: - Properties + + /// Indicates whether push notifications have been disabled or not. + /// + private var pushNotificationsAuthorized: UNAuthorizationStatus = .notDetermined { + didSet { + navigateIfNecessary() + } + } + + /// Analytics tracker + /// + private let tracker: BloggingRemindersTracker + + /// The closure that will be called once push notifications have been authorized. + /// + private let onAuthorized: () -> () + + // MARK: - Initializers + + init( + tracker: BloggingRemindersTracker, + onAuthorized: @escaping () -> ()) { + + self.tracker = tracker + self.onAuthorized = onAuthorized + + super.init(nibName: nil, bundle: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(applicationBecameActive), name: UIApplication.didBecomeActiveNotification, object: nil) + } + + required init?(coder: NSCoder) { + // This VC is designed to be initialized programmatically. + fatalError("Use init(tracker:) instead") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .basicBackground + view.addSubview(dismissButton) + + configureStackView() + + view.addSubview(turnOnNotificationsButton) + configureConstraints() + + navigationController?.setNavigationBarHidden(true, animated: false) + } + + override func viewDidAppear(_ animated: Bool) { + tracker.screenShown(.enableNotifications) + + super.viewDidAppear(animated) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + // If a parent VC is being dismissed, and this is the last view shown in its navigation controller, we'll assume + // the flow was completed. + if isBeingDismissedDirectlyOrByAncestor() && navigationController?.viewControllers.last == self { + tracker.flowDismissed(source: .enableNotifications) + } + + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + calculatePreferredContentSize() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + hintLabel.isHidden = traitCollection.preferredContentSizeCategory.isAccessibilityCategory + } + + private func calculatePreferredContentSize() { + let size = CGSize(width: view.bounds.width, height: UIView.layoutFittingCompressedSize.height) + preferredContentSize = view.systemLayoutSizeFitting(size) + } + + @objc + private func applicationBecameActive() { + refreshPushAuthorizationStatus() + } + + // MARK: - View Configuration + + private func configureStackView() { + view.addSubview(stackView) + + stackView.addArrangedSubviews([ + imageView, + titleLabel, + promptLabel, + hintLabel + ]) + } + + private func configureConstraints() { + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metrics.edgeMargins.left), + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.edgeMargins.right), + stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: Metrics.edgeMargins.top), + + turnOnNotificationsButton.topAnchor.constraint(greaterThanOrEqualTo: stackView.bottomAnchor, constant: Metrics.edgeMargins.bottom), + turnOnNotificationsButton.heightAnchor.constraint(greaterThanOrEqualToConstant: Metrics.turnOnButtonHeight), + turnOnNotificationsButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Metrics.edgeMargins.left), + turnOnNotificationsButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.edgeMargins.right), + turnOnNotificationsButton.bottomAnchor.constraint(equalTo: view.safeBottomAnchor, constant: -Metrics.edgeMargins.bottom), + + dismissButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Metrics.dismissButtonMargin), + dismissButton.topAnchor.constraint(equalTo: view.topAnchor, constant: Metrics.dismissButtonMargin) + ]) + } + + // MARK: - Actions + + @objc private func turnOnButtonTapped() { + tracker.buttonPressed(button: .notificationSettings, screen: .enableNotifications) + + if let targetURL = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(targetURL) + } else { + assertionFailure("Couldn't unwrap Settings URL") + } + } + + private func refreshPushAuthorizationStatus() { + PushNotificationsManager.shared.loadAuthorizationStatus { status in + self.pushNotificationsAuthorized = status + } + } + + func navigateIfNecessary() { + // If push has been authorized, continue the flow + if pushNotificationsAuthorized == .authorized { + onAuthorized() + } + } +} + +// MARK: - BloggingRemindersActions +extension BloggingRemindersPushPromptViewController: BloggingRemindersActions { + + @objc private func dismissTapped() { + dismiss(from: .dismiss, screen: .enableNotifications, tracker: tracker) + } +} + +// MARK: - DrawerPresentable + +extension BloggingRemindersPushPromptViewController: DrawerPresentable { + var collapsedHeight: DrawerHeight { + return .maxHeight + } +} + +extension BloggingRemindersPushPromptViewController: ChildDrawerPositionable { + var preferredDrawerPosition: DrawerPosition { + return .expanded + } +} + +// MARK: - Constants + +private enum TextContent { + static let title = NSLocalizedString("Turn on push notifications", comment: "Title of the screen in the Blogging Reminders flow which prompts users to enable push notifications.") + + static let prompt = NSLocalizedString("To use blogging reminders, you'll need to turn on push notifications.", + comment: "Prompt telling users that they need to enable push notifications on their device to use the blogging reminders feature.") + + static let hint = NSLocalizedString("Go to Settings → Notifications → WordPress, and toggle Allow Notifications.", + comment: "Instruction telling the user how to enable notifications in their device's system Settings app. The section names here should match those in Settings.") + + static let turnOnButtonTitle = NSLocalizedString("Turn on notifications", comment: "Title for a button which takes the user to the WordPress app's settings in the system Settings app.") +} + +private enum Images { + static let bellImageName = "reminders-bell" +} + +private enum Metrics { + static let dismissButtonMargin: CGFloat = 20.0 + static let edgeMargins = UIEdgeInsets(top: 80, left: 28, bottom: 80, right: 28) + static let stackSpacing: CGFloat = 20.0 + static let turnOnButtonHeight: CGFloat = 44.0 +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersTracker.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersTracker.swift new file mode 100644 index 000000000000..5770813c9d82 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/BloggingRemindersTracker.swift @@ -0,0 +1,143 @@ +import Foundation + +/// Analytics tracker for blogging reminders. +/// +class BloggingRemindersTracker { + enum BlogType: String { + case wpcom + case selfHosted = "self_hosted" + } + + private enum SharedPropertyName: String { + case blogType = "blog_type" + } + + enum Event: String { + // Flow events + case flowCompleted = "blogging_reminders_flow_completed" + case flowDismissed = "blogging_reminders_flow_dismissed" + case flowStart = "blogging_reminders_flow_start" + + // Reminders scheduling events + case remindersScheduled = "blogging_reminders_scheduled" + case remindersCancelled = "blogging_reminders_cancelled" + + // Misc UI events + case buttonPressed = "blogging_reminders_button_pressed" + case screenShown = "blogging_reminders_screen_shown" + } + + enum FlowStartSource: String { + case publishFlow = "publish_flow" + case blogSettings = "blog_settings" + case notificationSettings = "notification_settings" + case statsInsights = "stats_insights" + case bloggingPromptsFeatureIntroduction = "blogging_prompts_onboarding" + } + + enum FlowDismissSource: String { + case main + case dayPicker = "day_picker" + case enableNotifications = "enable_notifications" + case timePicker = "time_picker" + } + + enum Screen: String { + case main + case dayPicker = "day_picker" + case allSet = "all_set" + case enableNotifications = "enable_notifications" + } + + enum Button: String { + case `continue` + case dismiss + case notificationSettings + } + + enum Property: String { + case button = "button" + case daysOfWeek = "days_of_week_count" + case source = "source" + case screen = "screen" + case selectedTime = "selected_time" + case state = "state" + } + + /// The type of blog. + /// + let blogType: BlogType + + // MARK: - Initializers + + init(blogType: BlogType) { + self.blogType = blogType + } + + private func track(_ event: AnalyticsEvent) { + WPAnalytics.track(event) + } + + // MARK: - Tracking + + func buttonPressed(button: Button, screen: Screen) { + let properties = [ + Property.button.rawValue: button.rawValue, + Property.screen.rawValue: screen.rawValue, + ] + + track(event(.buttonPressed, properties: properties)) + } + + func flowCompleted() { + track(event(.flowCompleted, properties: [:])) + } + + func flowDismissed(source: FlowDismissSource) { + track(event(.flowDismissed, properties: [Property.source.rawValue: source.rawValue])) + } + + func flowStarted(source: FlowStartSource) { + track(event(.flowStart, properties: [Property.source.rawValue: source.rawValue])) + + } + + func scheduled(_ schedule: BloggingRemindersScheduler.Schedule, time: Date) { + let event: AnalyticsEvent + + switch schedule { + case .none: + event = self.event(.remindersCancelled, properties: [:]) + case .weekdays(let days): + event = self.event(.remindersScheduled, + properties: [Property.daysOfWeek.rawValue: "\(days.count)", + Property.selectedTime.rawValue: time.toLocal24HTime()]) + } + + track(event) + } + + func screenShown(_ screen: Screen) { + track(event(.screenShown, properties: [Property.screen.rawValue: screen.rawValue])) + } + + /// Private tracking method, which takes care of composing the tracking payload by adding the shared properties. + /// + private func event(_ event: Event, properties: [String: String]) -> AnalyticsEvent { + let finalProperties = sharedProperties().merging( + properties, + uniquingKeysWith: { (first, second) in + return first + }) + + return AnalyticsEvent(name: event.rawValue, properties: finalProperties) + } + + // MARK: - Properties + + /// Returns the parameters that should be present for all events tracked by this tracker. + /// + private func sharedProperties() -> [String: String] { + [SharedPropertyName.blogType.rawValue: self.blogType.rawValue] + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/CalendarDayToggleButton.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/CalendarDayToggleButton.swift new file mode 100644 index 000000000000..697ae3bf1c02 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/CalendarDayToggleButton.swift @@ -0,0 +1,111 @@ +import UIKit + + +class CalendarDayToggleButton: UIButton { + + typealias TouchUpInsideAction = (CalendarDayToggleButton) -> () + + /// The number of the day within the week. + /// + let weekday: BloggingRemindersScheduler.Weekday + + /// A closure that will be called when the button is tapped. + /// + let action: TouchUpInsideAction + + // MARK: - Initialization + + init( + weekday: BloggingRemindersScheduler.Weekday, + dayName: String, + isSelected: Bool, + action: @escaping TouchUpInsideAction) { + + self.weekday = weekday + self.action = action + + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + + setTitle(dayName, for: .normal) + + configureStyle() + configureConstraints() + + self.isSelected = isSelected + + addTarget(self, action: #selector(tapped), for: .touchUpInside) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - UIView + + override func layoutSubviews() { + super.layoutSubviews() + + layer.cornerRadius = bounds.size.width / 2 + } + + // MARK: - Configuration + + private func configureStyle() { + setTitleColor(.secondaryLabel, for: .normal) + setTitleColor(.white, for: .highlighted) + setTitleColor(.white, for: [.highlighted, .selected]) + setTitleColor(.white, for: .selected) + + titleLabel?.font = WPStyleGuide.fontForTextStyle(.callout, fontWeight: .semibold) + } + + private func configureConstraints() { + NSLayoutConstraint.activate([ + widthAnchor.constraint(greaterThanOrEqualToConstant: Metrics.toggleSize), + heightAnchor.constraint(greaterThanOrEqualToConstant: Metrics.toggleSize), + widthAnchor.constraint(equalTo: heightAnchor), + ]) + } + + // MARK: - UIControl + + override var isSelected: Bool { + didSet { + backgroundColor = backgroundColorForCurrentState + setNeedsDisplay() + } + } + + override var isHighlighted: Bool { + didSet { + backgroundColor = backgroundColorForCurrentState + setNeedsDisplay() + } + } + + // MARK: - Misc + + private var backgroundColorForCurrentState: UIColor { + switch (isSelected, isHighlighted) { + case (false, false): + return .quaternaryBackground + case (true, false): + return UIColor.muriel(name: .green, .shade20) + case (false, true): + return UIColor.muriel(name: .green, .shade10) + case (true, true): + return UIColor.muriel(name: .green, .shade10) + } + } + + @objc func tapped() { + isSelected.toggle() + + action(self) + } +} + +private enum Metrics { + static let toggleSize: CGFloat = 55.0 +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionButton.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionButton.swift new file mode 100644 index 000000000000..c96c12764364 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionButton.swift @@ -0,0 +1,90 @@ +import UIKit + +class TimeSelectionButton: UIButton { + + private(set) var selectedTime: String { + didSet { + timeLabel.text = selectedTime + } + } + + override var isHighlighted: Bool { + didSet { + backgroundColor = isHighlighted ? .divider : .basicBackground + setNeedsDisplay() + } + } + + var isChevronHidden = false { + didSet { + chevronStackView.isHidden = isChevronHidden + } + } + + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.isUserInteractionEnabled = false + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.alignment = .center + stackView.axis = .horizontal + return stackView + }() + + private lazy var pickerTitleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.text = Self.title + return label + }() + + private lazy var timeLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.text = selectedTime + label.textColor = .secondaryLabel + return label + }() + + private lazy var chevron: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.image = UIImage.gridicon(.chevronRight) + imageView.tintColor = .divider + return imageView + }() + + private lazy var chevronStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubviews([UIView(), chevron, UIView()]) + return stackView + }() + + private func configureStackView() { + stackView.addArrangedSubviews([pickerTitleLabel, UIView(), timeLabel, chevronStackView]) + chevronStackView.isHidden = isChevronHidden + } + + init(selectedTime: String, insets: UIEdgeInsets = UIEdgeInsets.zero) { + self.selectedTime = selectedTime + super.init(frame: .zero) + configureStackView() + addSubview(stackView) + pinSubviewToAllEdges(stackView, insets: insets) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setSelectedTime(_ selectedTime: String) { + self.selectedTime = selectedTime + } + + static let title = NSLocalizedString("Notification time", comment: "Title for the time picker button in Blogging Reminders.") +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionView.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionView.swift new file mode 100644 index 000000000000..8bd112f7d8f9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionView.swift @@ -0,0 +1,94 @@ +import UIKit + +/// A view that contains a time picker and a title reporting the selected time +class TimeSelectionView: UIView { + + private var selectedTime: Date + + private lazy var timePicker: UIDatePicker = { + let datePicker = UIDatePicker() + datePicker.preferredDatePickerStyle = .wheels + datePicker.datePickerMode = .time + datePicker.translatesAutoresizingMaskIntoConstraints = false + datePicker.setDate(selectedTime, animated: false) + datePicker.addTarget(self, action: #selector(onSelectedTimeChanged), for: .valueChanged) + return datePicker + }() + + @objc private func onSelectedTimeChanged() { + titleBar.setSelectedTime(timePicker.date.toLocalTime()) + } + + private lazy var timePickerContainerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(timePicker) + return view + }() + + private lazy var titleBar: TimeSelectionButton = { + let button = TimeSelectionButton(selectedTime: selectedTime.toLocalTime(), insets: Self.titleInsets) + button.translatesAutoresizingMaskIntoConstraints = false + button.isUserInteractionEnabled = false + button.isChevronHidden = true + return button + }() + + private lazy var verticalStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [titleBar, horizontalStackView, bottomSpacer]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .center + return stackView + }() + + private func makeSpacer() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + } + + private lazy var leftSpacer: UIView = { + makeSpacer() + }() + + private lazy var rightSpacer: UIView = { + makeSpacer() + }() + + private lazy var horizontalStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [leftSpacer, timePicker, rightSpacer]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.alignment = .center + return stackView + }() + + private lazy var bottomSpacer: UIView = { + makeSpacer() + }() + + init(selectedTime: Date) { + self.selectedTime = selectedTime + super.init(frame: .zero) + + backgroundColor = .basicBackground + addSubview(verticalStackView) + pinSubviewToSafeArea(verticalStackView) + NSLayoutConstraint.activate([ + timePicker.centerXAnchor.constraint(equalTo: centerXAnchor), + titleBar.widthAnchor.constraint(equalTo: widthAnchor), + bottomSpacer.heightAnchor.constraint(equalTo: titleBar.heightAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func getDate() -> Date { + timePicker.date + } + + static let titleInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) +} diff --git a/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionViewController.swift b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionViewController.swift new file mode 100644 index 000000000000..9fb89ff491e9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Blogging Reminders/Time Selector/TimeSelectionViewController.swift @@ -0,0 +1,85 @@ +import UIKit + +class TimeSelectionViewController: UIViewController { + + var preferredWidth: CGFloat? + + private let scheduledTime: Date + + private let tracker: BloggingRemindersTracker + + private var onDismiss: ((Date) -> Void)? + + private lazy var timeSelectionView: TimeSelectionView = { + let view = TimeSelectionView(selectedTime: scheduledTime) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + init(scheduledTime: Date, tracker: BloggingRemindersTracker, onDismiss: ((Date) -> Void)? = nil) { + self.scheduledTime = scheduledTime + self.tracker = tracker + self.onDismiss = onDismiss + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let mainView = timeSelectionView + if let width = preferredWidth { + mainView.widthAnchor.constraint(equalToConstant: width).isActive = true + } + self.view = mainView + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + calculatePreferredSize() + } + + private func calculatePreferredSize() { + let targetSize = CGSize(width: view.bounds.width, + height: UIView.layoutFittingCompressedSize.height) + preferredContentSize = view.systemLayoutSizeFitting(targetSize) + navigationController?.preferredContentSize = preferredContentSize + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(false, animated: false) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + navigationController?.setNavigationBarHidden(true, animated: false) + if isMovingFromParent { + onDismiss?(timeSelectionView.getDate()) + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + // If a parent VC is being dismissed, and this is the last view shown in its navigation controller, we'll assume + // the flow was interrupted. + if isBeingDismissedDirectlyOrByAncestor() && navigationController?.viewControllers.last == self { + tracker.flowDismissed(source: .timePicker) + } + } +} + +// MARK: - DrawerPresentable +extension TimeSelectionViewController: DrawerPresentable { + var collapsedHeight: DrawerHeight { + return .intrinsicHeight + } +} + +extension TimeSelectionViewController: ChildDrawerPositionable { + var preferredDrawerPosition: DrawerPosition { + return .collapsed + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Domain Credit/DomainCreditEligibilityChecker.swift b/WordPress/Classes/ViewRelated/Blog/Domain Credit/DomainCreditEligibilityChecker.swift deleted file mode 100644 index 7a7735d4e940..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Domain Credit/DomainCreditEligibilityChecker.swift +++ /dev/null @@ -1,5 +0,0 @@ -class DomainCreditEligibilityChecker: NSObject { - @objc static func canRedeemDomainCredit(blog: Blog) -> Bool { - return blog.isHostedAtWPcom && blog.hasDomainCredit - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/Domain Credit/DomainCreditRedemptionSuccessViewController.swift b/WordPress/Classes/ViewRelated/Blog/Domain Credit/DomainCreditRedemptionSuccessViewController.swift deleted file mode 100644 index 9b5713c4d864..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Domain Credit/DomainCreditRedemptionSuccessViewController.swift +++ /dev/null @@ -1,66 +0,0 @@ -import UIKit - -protocol DomainCreditRedemptionSuccessViewControllerDelegate: class { - func continueButtonPressed() -} - -/// Displays messaging after user successfully redeems domain credit. -class DomainCreditRedemptionSuccessViewController: UIViewController { - private let domain: String - - private weak var delegate: DomainCreditRedemptionSuccessViewControllerDelegate? - - init(domain: String, delegate: DomainCreditRedemptionSuccessViewControllerDelegate) { - self.domain = domain - self.delegate = delegate - super.init(nibName: nil, bundle: nil) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - let attributedSubtitleConfiguration: NoResultsViewController.AttributedSubtitleConfiguration = { - [weak self] attributedText in - guard let domain = self?.domain else { - return nil - } - return self?.applyDomainStyle(to: attributedText, domain: domain) - } - let controller = NoResultsViewController.controllerWith(title: NSLocalizedString("Congratulations", comment: "Title on domain credit redemption success screen"), - buttonTitle: NSLocalizedString("Continue", comment: "Action title to dismiss domain credit redemption success screen"), - attributedSubtitle: generateDomainDetailsAttributedString(domain: domain), - attributedSubtitleConfiguration: attributedSubtitleConfiguration, - image: "wp-illustration-domain-credit-success") - controller.delegate = self - addChild(controller) - view.addSubview(controller.view) - controller.didMove(toParent: self) - } - - private func applyDomainStyle(to attributedString: NSAttributedString, domain: String) -> NSAttributedString? { - let newAttributedString = NSMutableAttributedString(attributedString: attributedString) - let range = (newAttributedString.string as NSString).localizedStandardRange(of: domain) - guard range.location != NSNotFound else { - return nil - } - let font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) - newAttributedString.setAttributes([.font: font, .foregroundColor: UIColor.text], - range: range) - return newAttributedString - } - - private func generateDomainDetailsAttributedString(domain: String) -> NSAttributedString { - let string = String(format: NSLocalizedString("your new domain %@ is being set up. Your site is doing somersaults in excitement!", comment: "Details about recently acquired domain on domain credit redemption success screen"), domain) - let attributedString = NSMutableAttributedString(string: string) - return attributedString - } -} - -extension DomainCreditRedemptionSuccessViewController: NoResultsViewControllerDelegate { - func actionButtonPressed() { - delegate?.continueButtonPressed() - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/JetpackModuleHelper.swift b/WordPress/Classes/ViewRelated/Blog/JetpackModuleHelper.swift new file mode 100644 index 000000000000..0a73bbdfd0d1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/JetpackModuleHelper.swift @@ -0,0 +1,89 @@ +import Foundation +import UIKit + +public typealias JetpackModuleHelperViewController = JetpackModuleHelperDelegate & UIViewController + +/// Shows a NoResultsViewController on a given VC and handle enabling +/// a Jetpack module +@objc class JetpackModuleHelper: NSObject { + private weak var viewController: JetpackModuleHelperViewController? + + private let moduleName: String + private let blog: Blog + private let service: BlogJetpackSettingsService + private let blogService: BlogService + + private var noResultsViewController: NoResultsViewController? + + + /// Creates a Jetpack Module Wall that gives the user the option to enable a module + /// - Parameters: + /// - viewController: a UIViewController that conforms to JetpackModuleHelperDelegate + /// - moduleName: a `String` representing the name of the module + /// - blog: a `Blog` + @objc init(viewController: JetpackModuleHelperViewController, moduleName: String, blog: Blog) { + self.viewController = viewController + self.moduleName = moduleName + self.blog = blog + self.service = BlogJetpackSettingsService(coreDataStack: ContextManager.shared) + self.blogService = BlogService(coreDataStack: ContextManager.shared) + } + + + /// Show the No Results View Controller + /// - Parameters: + /// - title: A `String` to display on the title of the NVC + /// - subtitle: A `String` to display as the subtitle of the NVC + @objc func show(title: String, subtitle: String) { + noResultsViewController = NoResultsViewController.controller() + noResultsViewController?.configure( + title: title, + attributedTitle: nil, + noConnectionTitle: nil, + buttonTitle: NSLocalizedString("Enable", comment: "Title of button to enable publicize."), + subtitle: subtitle, + noConnectionSubtitle: nil, + attributedSubtitle: nil, + attributedSubtitleConfiguration: nil, + image: "mysites-nosites", + subtitleImage: nil, + accessoryView: nil + ) + + if let noResultsViewController = noResultsViewController, let viewController = viewController { + noResultsViewController.delegate = self + + viewController.addChild(noResultsViewController) + viewController.view.addSubview(withFadeAnimation: noResultsViewController.view) + noResultsViewController.didMove(toParent: viewController) + noResultsViewController.view.frame = viewController.view.bounds + } + } +} + +extension JetpackModuleHelper: NoResultsViewControllerDelegate { + func actionButtonPressed() { + service.updateJetpackModuleActiveSettingForBlog(blog, + module: moduleName, + active: true, + success: { [weak self] in + guard let self = self else { + return + } + + self.blogService.syncBlogAndAllMetadata(self.blog) { + self.noResultsViewController?.removeFromView() + self.viewController?.jetpackModuleEnabled() + } + }, + failure: { [weak self] _ in + self?.viewController?.displayNotice(title: Constants.error) + }) + } +} + +private extension JetpackModuleHelper { + struct Constants { + static let error = NSLocalizedString("The module couldn't be activated.", comment: "Error shown when a module can not be enabled") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteSettings.swift b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteSettings.swift new file mode 100644 index 000000000000..25c272d838c1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteSettings.swift @@ -0,0 +1,30 @@ +import Foundation +import WordPressShared + +protocol DefaultSectionProvider { + var defaultSection: MySiteViewController.Section { get } +} + +/// A helper class for My Site that manages the default section to display +/// +@objc final class MySiteSettings: NSObject, DefaultSectionProvider { + + private var userDefaults: UserPersistentRepository { + UserPersistentStoreFactory.instance() + } + + var defaultSection: MySiteViewController.Section { + let defaultSection: MySiteViewController.Section = AppConfiguration.isJetpack ? .dashboard : .siteMenu + let rawValue = userDefaults.object(forKey: Constants.defaultSectionKey) as? Int ?? defaultSection.rawValue + return MySiteViewController.Section(rawValue: rawValue) ?? defaultSection + } + + func setDefaultSection(_ tab: MySiteViewController.Section) { + userDefaults.set(tab.rawValue, forKey: Constants.defaultSectionKey) + QuickStartTourGuide.shared.refreshQuickStart() + } + + private enum Constants { + static let defaultSectionKey = "MySiteDefaultSectionKey" + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+FAB.swift b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+FAB.swift new file mode 100644 index 000000000000..15d76a732a32 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+FAB.swift @@ -0,0 +1,41 @@ + +extension MySiteViewController { + + /// Make a create button coordinator with + /// - Returns: CreateButtonCoordinator with new post, page, and story actions. + @objc func makeCreateButtonCoordinator() -> CreateButtonCoordinator { + + let newPage = { + let presenter = RootViewCoordinator.sharedPresenter + let blog = presenter.currentOrLastBlog() + presenter.showPageEditor(forBlog: blog) + } + + let newPost = { [weak self] in + let presenter = RootViewCoordinator.sharedPresenter + presenter.showPostTab(completion: { + self?.startAlertTimer() + }) + } + + let newStory = { + let presenter = RootViewCoordinator.sharedPresenter + let blog = presenter.currentOrLastBlog() + presenter.showStoryEditor(forBlog: blog) + } + + let source = "my_site" + + var actions: [ActionSheetItem] = [] + + if blog?.supports(.stories) ?? false { + actions.append(StoryAction(handler: newStory, source: source)) + } + + actions.append(PostAction(handler: newPost, source: source)) + actions.append(PageAction(handler: newPage, source: source)) + + let coordinator = CreateButtonCoordinator(self, actions: actions, source: source, blog: blog) + return coordinator + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+OnboardingPrompt.swift b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+OnboardingPrompt.swift new file mode 100644 index 000000000000..8d1ffaef503e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+OnboardingPrompt.swift @@ -0,0 +1,43 @@ +import Foundation + +extension MySiteViewController { + func startObservingOnboardingPrompt() { + NotificationCenter.default.addObserver(self, selector: #selector(onboardingPromptWasDismissed(_:)), name: .onboardingPromptWasDismissed, object: nil) + } + + @objc func onboardingPromptWasDismissed(_ notification: NSNotification) { + guard + let userInfo = notification.userInfo, + let option = userInfo["option"] as? OnboardingOption + else { + return + } + + switch option { + case .stats: + // Show the stats view for the current blog + if let blog = blog { + RootViewCoordinator.sharedPresenter.mySitesCoordinator.showStats(for: blog, timePeriod: .insights) + } + case .writing: + // Open the editor + let presenter = RootViewCoordinator.sharedPresenter + presenter.showPostTab(completion: { [weak self] in + self?.startAlertTimer() + }) + + case .showMeAround: + // Start the quick start + if let blog = blog { + let type: QuickStartType = FeatureFlag.quickStartForExistingUsers.enabled ? .existingSite : .newSite + QuickStartTourGuide.shared.setup(for: blog, type: type) + } + + case .skip, .reader, .notifications: + // Skip: Do nothing + // Reader and notifications will be handled by: + // WPAuthenticationManager.handleOnboardingQuestionsWillDismiss + break + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+QuickStart.swift b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+QuickStart.swift new file mode 100644 index 000000000000..be911fbddf9c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController+QuickStart.swift @@ -0,0 +1,34 @@ +import UIKit + +extension MySiteViewController { + + func startObservingQuickStart() { + NotificationCenter.default.addObserver(forName: .QuickStartTourElementChangedNotification, object: nil, queue: nil) { [weak self] (notification) in + + if let info = notification.userInfo, + let element = info[QuickStartTourGuide.notificationElementKey] as? QuickStartTourElement { + + self?.siteMenuSpotlightIsShown = element == .siteMenu + + switch element { + case .noSuchElement, .newpost: + self?.additionalSafeAreaInsets = .zero + + case .siteIcon, .siteTitle, .viewSite: + self?.scrollView.scrollToTop(animated: true) + fallthrough + + case .siteMenu, .pages, .sharing, .stats, .readerTab, .notifications, .mediaScreen: + self?.additionalSafeAreaInsets = Constants.quickStartNoticeInsets + + default: + break + } + } + } + } + + private enum Constants { + static let quickStartNoticeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 80, right: 0) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift new file mode 100644 index 000000000000..5c97bc11a29d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/My Site/MySiteViewController.swift @@ -0,0 +1,1105 @@ +import WordPressAuthenticator +import UIKit +import SwiftUI + +class MySiteViewController: UIViewController, NoResultsViewHost { + + enum Section: Int, CaseIterable { + case dashboard + case siteMenu + + var title: String { + switch self { + case .dashboard: + return NSLocalizedString("Home", comment: "Title for dashboard view on the My Site screen") + case .siteMenu: + return NSLocalizedString("Menu", comment: "Title for the site menu view on the My Site screen") + } + } + + var analyticsDescription: String { + switch self { + case .dashboard: + return "dashboard" + case .siteMenu: + return "site_menu" + } + } + } + + private var isShowingDashboard: Bool { + return segmentedControl.selectedSegmentIndex == Section.dashboard.rawValue + } + + private var currentSection: Section? { + Section(rawValue: segmentedControl.selectedSegmentIndex) + } + + @objc + private(set) lazy var scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.refreshControl = refreshControl + return scrollView + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .fill + stackView.distribution = .fill + stackView.spacing = 0 + return stackView + }() + + private lazy var segmentedControlContainerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var segmentedControl: UISegmentedControl = { + let segmentedControl = UISegmentedControl(items: Section.allCases.map { $0.title }) + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + segmentedControl.addTarget(self, action: #selector(segmentedControlValueChangedByUser), for: .valueChanged) + segmentedControl.selectedSegmentIndex = Section.siteMenu.rawValue + return segmentedControl + }() + + private lazy var refreshControl: UIRefreshControl = { + let refreshControl = UIRefreshControl() + refreshControl.addTarget(self, action: #selector(pulledToRefresh), for: .valueChanged) + return refreshControl + }() + + private lazy var siteMenuSpotlightView: UIView = { + let spotlightView = QuickStartSpotlightView() + spotlightView.translatesAutoresizingMaskIntoConstraints = false + spotlightView.isHidden = true + return spotlightView + }() + + /// Whether or not to show the spotlight animation to illustrate tapping the site menu. + var siteMenuSpotlightIsShown: Bool = false { + didSet { + siteMenuSpotlightView.isHidden = !siteMenuSpotlightIsShown + } + } + + /// A boolean indicating whether a site creation or adding self-hosted site flow has been initiated but not yet displayed. + var willDisplayPostSignupFlow: Bool = false + + private var createButtonCoordinator: CreateButtonCoordinator? + + private let meScenePresenter: ScenePresenter + private let blogService: BlogService + private(set) var mySiteSettings: MySiteSettings + + // MARK: - Initializers + + init(meScenePresenter: ScenePresenter, blogService: BlogService? = nil, mySiteSettings: MySiteSettings = MySiteSettings()) { + self.meScenePresenter = meScenePresenter + self.blogService = blogService ?? BlogService(coreDataStack: ContextManager.shared) + self.mySiteSettings = mySiteSettings + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("Initializer not implemented!") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Blog + + /// Convenience setter and getter for the blog. This calculated property takes care of showing the appropriate VC, depending + /// on whether there's a blog to show or not. + /// + var blog: Blog? { + set { + guard let newBlog = newValue else { + showBlogDetailsForMainBlogOrNoSites() + return + } + + showBlogDetails(for: newBlog) + showSitePicker(for: newBlog) + updateNavigationTitle(for: newBlog) + createFABIfNeeded() + updateSegmentedControl(for: newBlog, switchTabsIfNeeded: true) + fetchPrompt(for: newBlog) + } + + get { + return sitePickerViewController?.blog + } + } + + private(set) var sitePickerViewController: SitePickerViewController? + private(set) var blogDetailsViewController: BlogDetailsViewController? { + didSet { + blogDetailsViewController?.presentationDelegate = self + } + } + private(set) var blogDashboardViewController: BlogDashboardViewController? + + /// When we display a no results view, we'll do so in a scrollview so that + /// we can allow pull to refresh to sync the user's list of sites. + /// + private var noResultsScrollView: UIScrollView? + private var noResultsRefreshControl: UIRefreshControl? + + // MARK: - View Lifecycle + + override func viewDidLoad() { + setupView() + setupConstraints() + setupNavigationItem() + subscribeToPostSignupNotifications() + subscribeToModelChanges() + subscribeToContentSizeCategory() + subscribeToPostPublished() + startObservingQuickStart() + startObservingOnboardingPrompt() + subscribeToWillEnterForeground() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if blog == nil { + showBlogDetailsForMainBlogOrNoSites() + } + + setupNavBarAppearance() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + resetNavBarAppearance() + createButtonCoordinator?.hideCreateButton() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + displayJetpackInstallOverlayIfNeeded() + + displayOverlayIfNeeded() + + workaroundLargeTitleCollapseBug() + + if AppConfiguration.showsWhatIsNew { + RootViewCoordinator.shared.presentWhatIsNew(on: self) + } + + FancyAlertViewController.presentCustomAppIconUpgradeAlertIfNecessary(from: self) + + trackNoSitesVisibleIfNeeded() + + setupNavBarAppearance() + + createFABIfNeeded() + fetchPrompt(for: blog) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + createButtonCoordinator?.presentingTraitCollectionWillChange(traitCollection, newTraitCollection: traitCollection) + } + + override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) { + super.willTransition(to: newCollection, with: coordinator) + createButtonCoordinator?.presentingTraitCollectionWillChange(traitCollection, newTraitCollection: newCollection) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + guard let previousTraitCollection = previousTraitCollection, + let blog = blog else { + return + } + + // When switching between compact and regular width, we need to make sure to select the + // appropriate tab. This ensures the following: + // + // 1. Compact -> Regular: If the dashboard tab is selected, switch to the site menu tab + // so that the site menu is shown in the left pane of the split vc + // + // 2. Regular -> Compact: Switch to the default tab + // + + let isCompactToRegularWidth = + previousTraitCollection.horizontalSizeClass == .compact && + traitCollection.horizontalSizeClass == .regular + + let isRegularToCompactWidth = + previousTraitCollection.horizontalSizeClass == .regular && + traitCollection.horizontalSizeClass == .compact + + if isCompactToRegularWidth, isShowingDashboard { + segmentedControl.selectedSegmentIndex = Section.siteMenu.rawValue + segmentedControlValueChanged() + } else if isRegularToCompactWidth { + segmentedControl.selectedSegmentIndex = mySiteSettings.defaultSection.rawValue + segmentedControlValueChanged() + } + + updateSegmentedControl(for: blog) + } + + private func subscribeToContentSizeCategory() { + NotificationCenter.default.addObserver(self, + selector: #selector(didChangeDynamicType), + name: UIContentSizeCategory.didChangeNotification, + object: nil) + } + + private func subscribeToPostSignupNotifications() { + NotificationCenter.default.addObserver(self, selector: #selector(launchSiteCreationFromNotification), name: .createSite, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(showAddSelfHostedSite), name: .addSelfHosted, object: nil) + } + + private func subscribeToPostPublished() { + NotificationCenter.default.addObserver(self, selector: #selector(handlePostPublished), name: .newPostPublished, object: nil) + } + + private func subscribeToWillEnterForeground() { + NotificationCenter.default.addObserver(self, + selector: #selector(displayOverlayIfNeeded), + name: UIApplication.willEnterForegroundNotification, + object: nil) + } + + func updateNavigationTitle(for blog: Blog) { + let blogName = blog.settings?.name + let title = blogName != nil && blogName?.isEmpty == false + ? blogName + : Strings.mySite + navigationItem.title = title + } + + private func updateSegmentedControl(for blog: Blog, switchTabsIfNeeded: Bool = false) { + // The segmented control should be hidden if the blog is not a WP.com/Atomic/Jetpack site, or if the device doesn't have a horizontally compact view + let hideSegmentedControl = + !JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() || + !blog.isAccessibleThroughWPCom() || + !splitViewControllerIsHorizontallyCompact + + segmentedControlContainerView.isHidden = hideSegmentedControl + + if !hideSegmentedControl && switchTabsIfNeeded { + switchTab(to: mySiteSettings.defaultSection) + } + } + + private func setupView() { + view.backgroundColor = .listBackground + configureSegmentedControlFont() + } + + /// This method builds a layout with the following view hierarchy: + /// + /// - Scroll view + /// - Stack view + /// - Site picker view controller + /// - Segmented control container view + /// - Segmented control + /// - Blog dashboard view controller OR blog details view controller + /// + private func setupConstraints() { + view.addSubview(scrollView) + view.pinSubviewToAllEdges(scrollView) + scrollView.addSubview(stackView) + scrollView.pinSubviewToAllEdges(stackView) + segmentedControlContainerView.addSubview(segmentedControl) + stackView.addArrangedSubviews([segmentedControlContainerView]) + view.addSubview(siteMenuSpotlightView) + + let stackViewConstraints = [ + stackView.widthAnchor.constraint(equalTo: view.widthAnchor) + ] + + let segmentedControlConstraints = [ + segmentedControl.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, + constant: Constants.segmentedControlXOffset), + segmentedControl.centerXAnchor.constraint(equalTo: segmentedControlContainerView.centerXAnchor), + segmentedControl.topAnchor.constraint(equalTo: segmentedControlContainerView.topAnchor, + constant: Constants.segmentedControlYOffset), + segmentedControl.bottomAnchor.constraint(equalTo: segmentedControlContainerView.bottomAnchor), + segmentedControl.heightAnchor.constraint(equalToConstant: Constants.segmentedControlHeight) + ] + + let siteMenuSpotlightViewConstraints = [ + siteMenuSpotlightView.trailingAnchor.constraint(equalTo: segmentedControl.trailingAnchor, constant: Constants.siteMenuSpotlightOffset), + siteMenuSpotlightView.topAnchor.constraint(equalTo: segmentedControl.topAnchor, constant: -Constants.siteMenuSpotlightOffset) + ] + + NSLayoutConstraint.activate( + stackViewConstraints + + segmentedControlConstraints + + siteMenuSpotlightViewConstraints + ) + } + + // MARK: - Navigation Item + + /// In iPad and iOS 14, the large-title bar is collapsed when the VC is first loaded. Call this method from + /// `viewDidAppear(_:)` to quickly refresh the navigation bar so that it's expanded. + /// + private func workaroundLargeTitleCollapseBug() { + guard !splitViewControllerIsHorizontallyCompact else { + return + } + + navigationController?.navigationBar.sizeToFit() + } + + private func setupNavigationItem() { + navigationItem.largeTitleDisplayMode = .never + navigationItem.title = Strings.mySite + navigationItem.backButtonTitle = Strings.mySite + + // Workaround: + // + // Without the next line, the large title was being lost when going into a child VC with a small + // title and pressing "Back" in the navigation bar. + // + // I'm not sure if this makes sense - it doesn't to me right now, so I'm adding instructions to + // test the issue which will be helpful for removing the issue if the workaround is no longer + // needed. + // + // To see the issue in action, comment the line, run the App, go into "Stats" (or any other + // child VC that has a small title in the navigation bar), check that the title is small, + // press back, and check that this VC has a large title. If this VC still has a + // large title, you can remove the following line. + // + extendedLayoutIncludesOpaqueBars = true + + // Set the nav bar + navigationController?.navigationBar.accessibilityIdentifier = "my-site-navigation-bar" + } + + private func setupNavBarAppearance() { + let scrollEdgeAppearance = navigationController?.navigationBar.scrollEdgeAppearance + let transparentTitleAttributes = [NSAttributedString.Key.foregroundColor: UIColor.clear] + scrollEdgeAppearance?.titleTextAttributes = transparentTitleAttributes + scrollEdgeAppearance?.configureWithTransparentBackground() + } + + private func resetNavBarAppearance() { + navigationController?.navigationBar.scrollEdgeAppearance = UINavigationBar.appearance().scrollEdgeAppearance + } + + // MARK: - Account + + private func defaultAccount() -> WPAccount? { + try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) + } + + // MARK: - Main Blog + + /// Convenience method to retrieve the main blog for an account when none is selected. + /// + /// - Returns:the main blog for an account (last selected, or first blog in list). + /// + private func mainBlog() -> Blog? { + return Blog.lastUsedOrFirst(in: ContextManager.sharedInstance().mainContext) + } + + /// This VC is prepared to either show the details for a blog, or show a no-results VC configured to let the user know they have no blogs. + /// There's no scenario where this is shown empty, for an account that HAS blogs. + /// + /// In order to adhere to this logic, if this VC is shown without a blog being set, we will try to load the "main" blog (ie in order: the last used blog, + /// the account's primary blog, or the first blog we find for the account). + /// + private func showBlogDetailsForMainBlogOrNoSites() { + guard let mainBlog = mainBlog() else { + showNoSites() + return + } + + showBlogDetails(for: mainBlog) + showSitePicker(for: mainBlog) + updateNavigationTitle(for: mainBlog) + updateSegmentedControl(for: mainBlog, switchTabsIfNeeded: true) + } + + @objc + private func syncBlogs() { + guard let account = defaultAccount() else { + return + } + + let finishSync = { [weak self] in + self?.noResultsRefreshControl?.endRefreshing() + } + + blogService.syncBlogs(for: account) { + finishSync() + } failure: { (error) in + finishSync() + } + } + + @objc + private func pulledToRefresh() { + + guard let blog = blog, + let section = currentSection else { + return + } + + switch section { + case .siteMenu: + + blogDetailsViewController?.pulledToRefresh(with: refreshControl) { [weak self] in + guard let self = self else { + return + } + + self.updateNavigationTitle(for: blog) + self.sitePickerViewController?.blogDetailHeaderView.blog = blog + } + + + case .dashboard: + + /// The dashboard’s refresh control is intentionally not tied to blog syncing in order to keep + /// the dashboard updating fast. + blogDashboardViewController?.pulledToRefresh { [weak self] in + self?.refreshControl.endRefreshing() + } + + syncBlogAndAllMetadata(blog) + + /// Update today's prompt if the blog has blogging prompts enabled. + fetchPrompt(for: blog) + } + + WPAnalytics.track(.mySitePullToRefresh, properties: [WPAppAnalyticsKeyTabSource: section.analyticsDescription]) + } + + private func syncBlogAndAllMetadata(_ blog: Blog) { + blogService.syncBlogAndAllMetadata(blog) { [weak self] in + guard let self = self else { + return + } + + self.updateNavigationTitle(for: blog) + self.sitePickerViewController?.blogDetailHeaderView.blog = blog + self.blogDashboardViewController?.reloadCardsLocally() + } + } + + // MARK: - Segmented Control + + @objc private func segmentedControlValueChangedByUser() { + guard let section = currentSection else { + return + } + + segmentedControlValueChanged() + WPAnalytics.track(.mySiteTabTapped, properties: ["tab": section.analyticsDescription]) + } + + @objc private func segmentedControlValueChanged() { + guard let blog = blog, + let section = currentSection else { + return + } + + switch section { + case .siteMenu: + siteMenuSpotlightIsShown = false + hideDashboard() + showBlogDetails(for: blog) + case .dashboard: + hideBlogDetails() + showDashboard(for: blog) + } + } + + /// Changes between the site menu and dashboard + /// - Parameter section: The section to switch to + func switchTab(to section: Section) { + segmentedControl.selectedSegmentIndex = section.rawValue + segmentedControlValueChanged() + } + + // MARK: - Child VC logic + + private func embedChildInStackView(_ child: UIViewController) { + addChild(child) + stackView.addArrangedSubview(child.view) + child.didMove(toParent: self) + } + + private func removeChildFromStackView(_ child: UIViewController) { + guard child.parent != nil else { + return + } + + child.willMove(toParent: nil) + stackView.removeArrangedSubview(child.view) + child.view.removeFromSuperview() + child.removeFromParent() + } + + // 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() + } + + private func showNoSites() { + guard AccountHelper.isLoggedIn else { + WordPressAppDelegate.shared?.windowManager.showFullscreenSignIn() + return + } + + hideBlogDetails() + hideSplitDetailsView() + blogDetailsViewController = nil + + guard noResultsViewController.view.superview == nil else { + return + } + + addMeButtonToNavigationBar(email: defaultAccount()?.email, meScenePresenter: meScenePresenter) + + makeNoResultsScrollView() + configureNoResultsView() + addNoResultsViewAndConfigureConstraints() + createButtonCoordinator?.removeCreateButton() + } + + private func trackNoSitesVisibleIfNeeded() { + guard noResultsViewController.view.superview != nil else { + return + } + + WPAnalytics.track(.mySiteNoSitesViewDisplayed) + } + + private func makeNoResultsScrollView() { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .basicBackground + + view.addSubview(scrollView) + view.pinSubviewToAllEdges(scrollView) + + let refreshControl = UIRefreshControl() + scrollView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(syncBlogs), for: .valueChanged) + noResultsRefreshControl = refreshControl + + noResultsScrollView = scrollView + } + + private func configureNoResultsView() { + noResultsViewController.configure(title: NSLocalizedString( + "Create a new site for your business, magazine, or personal blog; or connect an existing WordPress installation.", + comment: "Text shown when the account has no sites."), + buttonTitle: NSLocalizedString( + "Add new site", + comment: "Title of button to add a new site."), + image: "mysites-nosites") + noResultsViewController.actionButtonHandler = { [weak self] in + self?.presentInterfaceForAddingNewSite() + WPAnalytics.track(.mySiteNoSitesViewActionTapped) + } + } + + private func addNoResultsViewAndConfigureConstraints() { + guard let scrollView = noResultsScrollView else { + return + } + + addChild(noResultsViewController) + scrollView.addSubview(noResultsViewController.view) + noResultsViewController.view.frame = scrollView.frame + + guard let nrv = noResultsViewController.view else { + return + } + + nrv.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + nrv.widthAnchor.constraint(equalTo: view.widthAnchor), + nrv.centerXAnchor.constraint(equalTo: view.centerXAnchor), + nrv.topAnchor.constraint(equalTo: scrollView.topAnchor), + nrv.bottomAnchor.constraint(equalTo: view.safeBottomAnchor) + ]) + + noResultsViewController.didMove(toParent: self) + } + + private func cleanupNoResultsView() { + noResultsRefreshControl?.removeFromSuperview() + noResultsRefreshControl = nil + + noResultsScrollView?.refreshControl = nil + noResultsScrollView?.removeFromSuperview() + noResultsScrollView = nil + } + + // MARK: - FAB + + private func createFABIfNeeded() { + createButtonCoordinator?.removeCreateButton() + createButtonCoordinator = makeCreateButtonCoordinator() + createButtonCoordinator?.add(to: view, + trailingAnchor: view.safeAreaLayoutGuide.trailingAnchor, + bottomAnchor: view.safeAreaLayoutGuide.bottomAnchor) + + if let blog = blog, + noResultsViewController.view.superview == nil { + createButtonCoordinator?.showCreateButton(for: blog) + } + } + +// MARK: - Add Site Alert + + @objc + func presentInterfaceForAddingNewSite() { + let canAddSelfHostedSite = AppConfiguration.showAddSelfHostedSiteButton + let addSite = { + self.launchSiteCreation(source: "my_site_no_sites") + } + + guard canAddSelfHostedSite else { + addSite() + return + } + let addSiteAlert = AddSiteAlertFactory().makeAddSiteAlert(source: "my_site_no_sites", + canCreateWPComSite: defaultAccount() != nil, + createWPComSite: { + addSite() + }, canAddSelfHostedSite: canAddSelfHostedSite, addSelfHostedSite: { + WordPressAuthenticator.showLoginForSelfHostedSite(self) + }) + + if let sourceView = noResultsViewController.actionButton, + let popoverPresentationController = addSiteAlert.popoverPresentationController { + + popoverPresentationController.sourceView = sourceView + popoverPresentationController.sourceRect = sourceView.bounds + popoverPresentationController.permittedArrowDirections = .up + } + + present(addSiteAlert, animated: true) + } + + @objc + func didChangeDynamicType() { + configureSegmentedControlFont() + } + + private func configureSegmentedControlFont() { + let font = WPStyleGuide.fontForTextStyle(.subheadline) + segmentedControl.setTitleTextAttributes([NSAttributedString.Key.font: font], for: .normal) + } + + @objc + func launchSiteCreationFromNotification() { + self.launchSiteCreation(source: "signup_epilogue") + willDisplayPostSignupFlow = false + } + + func launchSiteCreation(source: String) { + JetpackFeaturesRemovalCoordinator.presentSiteCreationOverlayIfNeeded(in: self, source: source, onDidDismiss: { + guard JetpackFeaturesRemovalCoordinator.siteCreationPhase() != .two else { + return + } + + // Display site creation flow if not in phase two + let wizardLauncher = SiteCreationWizardLauncher() + guard let wizard = wizardLauncher.ui else { + return + } + self.present(wizard, animated: true) + SiteCreationAnalyticsHelper.trackSiteCreationAccessed(source: source) + }) + } + + @objc + private func showAddSelfHostedSite() { + WordPressAuthenticator.showLoginForSelfHostedSite(self) + willDisplayPostSignupFlow = false + } + + @objc + func toggleSpotlightOnSitePicker() { + sitePickerViewController?.toggleSpotlightOnHeaderView() + } + + // MARK: - Blog Details UI Logic + + private func hideBlogDetails() { + guard let blogDetailsViewController = blogDetailsViewController else { + return + } + + removeChildFromStackView(blogDetailsViewController) + } + + /// Shows a `BlogDetailsViewController` for the specified `Blog`. If the VC doesn't exist, this method also takes care + /// of creating it. + /// + /// - Parameters: + /// - blog: The blog to show the details of. + /// + private func showBlogDetails(for blog: Blog) { + hideNoSites() + + let blogDetailsViewController = self.blogDetailsViewController(for: blog) + + addMeButtonToNavigationBar(email: blog.account?.email, meScenePresenter: meScenePresenter) + + embedChildInStackView(blogDetailsViewController) + + // This ensures that the spotlight views embedded in the site picker don't get clipped. + stackView.sendSubviewToBack(blogDetailsViewController.view) + + blogDetailsViewController.showInitialDetailsForBlog() + } + + private func blogDetailsViewController(for blog: Blog) -> BlogDetailsViewController { + guard let blogDetailsViewController = blogDetailsViewController else { + let blogDetailsViewController = makeBlogDetailsViewController(for: blog) + self.blogDetailsViewController = blogDetailsViewController + return blogDetailsViewController + } + + blogDetailsViewController.switch(to: blog) + return blogDetailsViewController + } + + private func makeBlogDetailsViewController(for blog: Blog) -> BlogDetailsViewController { + let blogDetailsViewController = BlogDetailsViewController(meScenePresenter: meScenePresenter) + blogDetailsViewController.blog = blog + + return blogDetailsViewController + } + + private func showSitePicker(for blog: Blog) { + guard let sitePickerViewController = sitePickerViewController else { + + let sitePickerViewController = makeSitePickerViewController(for: blog) + self.sitePickerViewController = sitePickerViewController + + addChild(sitePickerViewController) + stackView.insertArrangedSubview(sitePickerViewController.view, at: 0) + sitePickerViewController.didMove(toParent: self) + + return + } + + sitePickerViewController.blog = blog + } + + private func makeSitePickerViewController(for blog: Blog) -> SitePickerViewController { + let sitePickerViewController = SitePickerViewController(blog: blog, meScenePresenter: meScenePresenter) + + sitePickerViewController.onBlogSwitched = { [weak self] blog in + + guard let self = self else { + return + } + + if !blog.isAccessibleThroughWPCom() && self.isShowingDashboard { + self.switchTab(to: .siteMenu) + } + + self.updateNavigationTitle(for: blog) + self.updateSegmentedControl(for: blog) + self.updateChildViewController(for: blog) + self.createFABIfNeeded() + self.fetchPrompt(for: blog) + } + + sitePickerViewController.onBlogListDismiss = { [weak self] in + self?.displayJetpackInstallOverlayIfNeeded() + } + + return sitePickerViewController + } + + private func updateChildViewController(for blog: Blog) { + guard let section = currentSection else { + return + } + + switch section { + case .siteMenu: + blogDetailsViewController?.blog = blog + blogDetailsViewController?.configureTableViewData() + blogDetailsViewController?.tableView.reloadData() + blogDetailsViewController?.preloadMetadata() + blogDetailsViewController?.showInitialDetailsForBlog() + case .dashboard: + syncBlogAndAllMetadata(blog) + blogDashboardViewController?.update(blog: blog) + } + } + + func presentCreateSheet() { + blogDetailsViewController?.createButtonCoordinator?.showCreateSheet() + } + + // MARK: Dashboard UI Logic + + private func hideDashboard() { + guard let blogDashboardViewController = blogDashboardViewController else { + return + } + + removeChildFromStackView(blogDashboardViewController) + } + + /// Shows a `BlogDashboardViewController` for the specified `Blog`. If the VC doesn't exist, this method also takes care + /// of creating it. + /// + /// - Parameters: + /// - blog: The blog to show the details of. + /// + private func showDashboard(for blog: Blog) { + let blogDashboardViewController = self.blogDashboardViewController ?? BlogDashboardViewController(blog: blog, embeddedInScrollView: true) + blogDashboardViewController.update(blog: blog) + embedChildInStackView(blogDashboardViewController) + self.blogDashboardViewController = blogDashboardViewController + stackView.sendSubviewToBack(blogDashboardViewController.view) + } + + // MARK: - Model Changes + + private func subscribeToModelChanges() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDataModelChange(notification:)), + name: .NSManagedObjectContextObjectsDidChange, + object: ContextManager.shared.mainContext) + } + + @objc + private func handleDataModelChange(notification: NSNotification) { + if let blog = blog { + handlePossibleDeletion(of: blog, notification: notification) + } else { + handlePossiblePrimaryBlogCreation(notification: notification) + } + } + + // MARK: - Model Changes: Blog Deletion + + /// This method takes care of figuring out if the selected blog was deleted, and to address any side effect + /// of the selected blog being deleted. + /// + private func handlePossibleDeletion(of selectedBlog: Blog, notification: NSNotification) { + guard let deletedObjects = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>, + deletedObjects.contains(selectedBlog) else { + return + } + + self.blog = nil + } + + // MARK: - Model Changes: Blog Creation + + /// This method ensures that the received notification includes inserted blogs. + /// It's useful because when we call `lastUsedOrFirstBlog()` a chain of calls that ends with: + /// + /// `AccountService.defaultWordPressComAccount()` + /// > `AccountService.accountWithUUID()` + /// > `NSManagedObjectContext.executeFetchRequest(...)`. + /// + /// The issue is that `executeFetchRequest` updates the managed object context, thus triggering + /// a `NSManagedObjectContextObjectsDidChange` notification, which caused a neverending + /// loop in the observer in this VC. + /// + private func verifyThatBlogsWereInserted(in notification: NSNotification) -> Bool { + guard let insertedObjects = notification.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject>, + insertedObjects.contains(where: { $0 as? Blog != nil }) else { + return false + } + + return true + } + + /// This method takes care of figuring out if a primary blog was created, in order to show the details for such + /// blog. + /// + private func handlePossiblePrimaryBlogCreation(notification: NSNotification) { + // WORKAROUND: At first sight this guard should not be needed. + // Please read the documentation for this method carefully. + guard verifyThatBlogsWereInserted(in: notification) else { + return + } + + guard let blog = Blog.lastUsedOrFirst(in: ContextManager.sharedInstance().mainContext) else { + return + } + + self.blog = blog + } + + // MARK: - Blogging Prompts + + @objc func handlePostPublished() { + fetchPrompt(for: blog) + } + + func fetchPrompt(for blog: Blog?) { + guard FeatureFlag.bloggingPrompts.enabled, + let blog = blog, + blog.isAccessibleThroughWPCom(), + let promptsService = BloggingPromptsService(blog: blog), + let siteID = blog.dotComID?.intValue else { + return + } + + let dashboardPersonalization = BlogDashboardPersonalizationService(siteID: siteID) + guard dashboardPersonalization.isEnabled(.prompts) else { + return + } + + promptsService.fetchTodaysPrompt() + } + + // MARK: - Constants + + private enum Constants { + static let segmentedControlXOffset: CGFloat = 20 + static let segmentedControlYOffset: CGFloat = 24 + static let segmentedControlHeight: CGFloat = 32 + static let siteMenuSpotlightOffset: CGFloat = 8 + } + + private enum Strings { + static let mySite = NSLocalizedString("My Site", comment: "Title of My Site tab") + } +} + +extension MySiteViewController: WPSplitViewControllerDetailProvider { + func initialDetailViewControllerForSplitView(_ splitView: WPSplitViewController) -> UIViewController? { + guard let blogDetailsViewController = blogDetailsViewController as? WPSplitViewControllerDetailProvider else { + let emptyViewController = UIViewController() + WPStyleGuide.configureColors(view: emptyViewController.view, tableView: nil) + return emptyViewController + } + + return blogDetailsViewController.initialDetailViewControllerForSplitView(splitView) + } + + /// Removes all view controllers from the details view controller stack and leaves split view details in an empty state. + /// + private func hideSplitDetailsView() { + if let splitViewController = splitViewController as? WPSplitViewController, + splitViewController.viewControllers.count > 1, + let detailsNavigationController = splitViewController.viewControllers.last as? UINavigationController { + detailsNavigationController.setViewControllers([], animated: false) + } + } +} + +// MARK: - UIViewControllerTransitioningDelegate +// +extension MySiteViewController: UIViewControllerTransitioningDelegate { + func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + guard presented is FancyAlertViewController else { + return nil + } + + return FancyAlertPresentationController(presentedViewController: presented, presenting: presenting) + } +} + +// MARK: - QuickStart +// +extension MySiteViewController { + func startAlertTimer() { + blogDetailsViewController?.startAlertTimer() + } +} + +// MARK: - Presentation +/// Supporting presentation of BlogDetailsSubsection from both BlogDashboard and BlogDetails +extension MySiteViewController: BlogDetailsPresentationDelegate { + + /// Shows the specified `BlogDetailsSubsection` for a `Blog`. + /// + /// - Parameters: + /// - subsection: The specific subsection to show. + /// + func showBlogDetailsSubsection(_ subsection: BlogDetailsSubsection) { + blogDetailsViewController?.showDetailView(for: subsection) + } + + func presentBlogDetailsViewController(_ viewController: UIViewController) { + switch currentSection { + case .dashboard: + blogDashboardViewController?.showDetailViewController(viewController, sender: blogDashboardViewController) + case .siteMenu: + blogDetailsViewController?.showDetailViewController(viewController, sender: blogDetailsViewController) + case .none: + return + } + } +} + +// MARK: Jetpack Features Removal + +private extension MySiteViewController { + @objc func displayOverlayIfNeeded() { + if isViewOnScreen(), !willDisplayPostSignupFlow { + let didReloadUI = RootViewCoordinator.shared.reloadUIIfNeeded(blog: self.blog) + if !didReloadUI { + JetpackFeaturesRemovalCoordinator.presentOverlayIfNeeded(in: self, source: .appOpen, blog: self.blog) + } + } + } +} + +// MARK: Jetpack Install Plugin Overlay + +private extension MySiteViewController { + func displayJetpackInstallOverlayIfNeeded() { + JetpackInstallPluginHelper.presentOverlayIfNeeded(in: self, blog: blog, delegate: self) + } + + func dismissOverlayAndRefresh() { + dismiss(animated: true) { + self.pulledToRefresh() + } + } +} + +extension MySiteViewController: JetpackRemoteInstallDelegate { + func jetpackRemoteInstallCompleted() { + dismissOverlayAndRefresh() + } + + func jetpackRemoteInstallCanceled() { + dismissOverlayAndRefresh() + } + + func jetpackRemoteInstallWebviewFallback() { + // no op + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingEnableNotificationsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingEnableNotificationsViewController.swift new file mode 100644 index 000000000000..a06b0fee5d38 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingEnableNotificationsViewController.swift @@ -0,0 +1,164 @@ +import UIKit + +class OnboardingEnableNotificationsViewController: UIViewController { + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var subTitleLabel: UILabel! + @IBOutlet weak var detailView: UIView! + @IBOutlet weak var enableButton: UIButton! + @IBOutlet weak var cancelButton: UIButton! + + let option: OnboardingOption + let coordinator: OnboardingQuestionsCoordinator + + init(with coordinator: OnboardingQuestionsCoordinator, option: OnboardingOption) { + self.coordinator = coordinator + self.option = option + + super.init(nibName: nil, bundle: nil) + } + + required convenience init?(coder: NSCoder) { + self.init(with: OnboardingQuestionsCoordinator(), option: .notifications) + } + + override func viewDidLoad() { + super.viewDidLoad() + + navigationController?.navigationBar.isHidden = true + navigationController?.delegate = self + + applyStyles() + applyLocalization() + updateContent() + + coordinator.notificationsDisplayed(option: option) + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return [.portrait, .portraitUpsideDown] + } +} + +// MARK: - IBAction's +extension OnboardingEnableNotificationsViewController { + @IBAction func enableButtonTapped(_ sender: Any) { + coordinator.notificationsEnabledTapped(selection: option) + } + + @IBAction func skipButtonTapped(_ sender: Any) { + coordinator.notificationsSkipped(selection: option) + } +} + +// MARK: - Trait Collection Handling +extension OnboardingEnableNotificationsViewController { + func updateContent(for traitCollection: UITraitCollection) { + let contentSize = traitCollection.preferredContentSizeCategory + + // Hide the detail image if the text is too large + detailView.isHidden = contentSize.isAccessibilityCategory + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateContent(for: traitCollection) + } +} + +// MARK: - UINavigation Controller Delegate +extension OnboardingEnableNotificationsViewController: UINavigationControllerDelegate { + func navigationControllerSupportedInterfaceOrientations(_ navigationController: UINavigationController) -> UIInterfaceOrientationMask { + return supportedInterfaceOrientations + } + + func navigationControllerPreferredInterfaceOrientationForPresentation(_ navigationController: UINavigationController) -> UIInterfaceOrientation { + return .portrait + } +} + +// MARK: - Private Helpers +private extension OnboardingEnableNotificationsViewController { + func applyStyles() { + navigationController?.navigationBar.isHidden = true + + titleLabel.font = WPStyleGuide.serifFontForTextStyle(.title1, fontWeight: .semibold) + titleLabel.textColor = .text + + subTitleLabel.font = .preferredFont(forTextStyle: .title3) + subTitleLabel.textColor = .secondaryLabel + } + + func applyLocalization() { + titleLabel.text = Strings.title + enableButton.setTitle(Strings.enableButton, for: .normal) + cancelButton.setTitle(Strings.cancelButton, for: .normal) + } + + func updateContent() { + let text: String + let notificationContent: UnifiedPrologueNotificationsContent? + + switch option { + case .stats: + text = StatsStrings.subTitle + notificationContent = .init(topElementTitle: StatsStrings.notificationTopTitle, + middleElementTitle: StatsStrings.notificationMiddleTitle, + bottomElementTitle: StatsStrings.notificationBottomTitle, + topImage: "view-milestone-1k", + middleImage: "traffic-surge-icon") + case .writing: + text = WritingStrings.subTitle + notificationContent = nil + + case .notifications, .showMeAround, .skip: + text = DefaultStrings.subTitle + notificationContent = nil + + case .reader: + text = ReaderStrings.subTitle + notificationContent = .init(topElementTitle: ReaderStrings.notificationTopTitle, + middleElementTitle: ReaderStrings.notificationMiddleTitle, + bottomElementTitle: ReaderStrings.notificationBottomTitle) + } + + + subTitleLabel.text = text + + // Convert the image view to a UIView and embed it + let imageView = UIView.embedSwiftUIView(UnifiedPrologueNotificationsContentView(notificationContent)) + imageView.frame.size.width = detailView.frame.width + detailView.addSubview(imageView) + imageView.pinSubviewToAllEdges(detailView) + } +} + +// MARK: - Constants / Strings +private struct Strings { + static let title = NSLocalizedString("Enable Notifications?", comment: "Title of the view, asking the user if they want to enable notifications.") + static let enableButton = NSLocalizedString("Enable Notifications", comment: "Title of button that enables push notifications when tapped") + static let cancelButton = NSLocalizedString("Not Now", comment: "Title of a button that cancels enabling notifications when tapped") +} + +private struct StatsStrings { + static let subTitle = NSLocalizedString("Know when your site is getting more traffic, new followers, or when it passes a new milestone!", comment: "Subtitle giving the user more context about why to enable notifications.") + + static let notificationTopTitle = NSLocalizedString("Congratulations! Your site passed *1000 all-time* views!", comment: "Example notification content displayed on the Enable Notifications prompt that is personalized based on a users selection. Words marked between * characters will be displayed as bold text.") + static let notificationMiddleTitle = NSLocalizedString("Your site appears to be getting *more traffic* than usual!", comment: "Example notification content displayed on the Enable Notifications prompt that is personalized based on a users selection. Words marked between * characters will be displayed as bold text.") + static let notificationBottomTitle = NSLocalizedString("*Johann Brandt* is now following your site!", comment: "Example notification content displayed on the Enable Notifications prompt that is personalized based on a users selection. Words marked between * characters will be displayed as bold text.") +} + +private struct WritingStrings { + static let subTitle = NSLocalizedString("Stay in touch with your audience with like and comment notifications.", comment: "Subtitle giving the user more context about why to enable notifications.") +} + +private struct DefaultStrings { + static let subTitle = NSLocalizedString("Stay in touch with like and comment notifications.", comment: "Subtitle giving the user more context about why to enable notifications.") +} + +private struct ReaderStrings { + static let subTitle = NSLocalizedString("Know when your favorite authors post new content.", comment: "Subtitle giving the user more context about why to enable notifications.") + static let notificationTopTitle = NSLocalizedString("*Madison Ruiz* added a new post to their site", comment: "Example notification content displayed on the Enable Notifications prompt that is personalized based on a users selection. Words marked between * characters will be displayed as bold text.") + static let notificationMiddleTitle = NSLocalizedString("You received *50 likes* on your comment", comment: "Example notification content displayed on the Enable Notifications prompt that is personalized based on a users selection. Words marked between * characters will be displayed as bold text.") + static let notificationBottomTitle = NSLocalizedString("*Johann Brandt* responded to your comment", comment: "Example notification content displayed on the Enable Notifications prompt that is personalized based on a users selection. Words marked between * characters will be displayed as bold text.") +} diff --git a/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingEnableNotificationsViewController.xib b/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingEnableNotificationsViewController.xib new file mode 100644 index 000000000000..a6492aca5687 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingEnableNotificationsViewController.xib @@ -0,0 +1,142 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_7" orientation="portrait" appearance="light"/> + <accessibilityOverrides dynamicTypePreference="7"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/> + <capability name="Named colors" minToolsVersion="9.0"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="OnboardingEnableNotificationsViewController" customModule="WordPress" customModuleProvider="target"> + <connections> + <outlet property="cancelButton" destination="TsX-gV-qHT" id="LwJ-1J-eDF"/> + <outlet property="detailView" destination="E9E-nc-B5q" id="H5P-Fa-E6T"/> + <outlet property="enableButton" destination="0z0-r0-F4b" id="MYg-CR-0wL"/> + <outlet property="subTitleLabel" destination="50P-8O-Ctu" id="4dY-dn-nT7"/> + <outlet property="titleLabel" destination="cpw-1P-GER" id="tqX-v7-b8v"/> + <outlet property="view" destination="Mlb-pH-BGO" id="zZL-y0-ws4"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" id="Mlb-pH-BGO"> + <rect key="frame" x="0.0" y="0.0" width="428" height="926"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <view contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="jle-rd-niA"> + <rect key="frame" x="20" y="113" width="388" height="700"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="1000" text="Enable Notifications?" textAlignment="center" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="cpw-1P-GER"> + <rect key="frame" x="0.0" y="0.0" width="388" height="40.666666666666664"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleTitle0"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="1000" text="Know when your traffic spikes, or when your site passes a milestone." textAlignment="center" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="50P-8O-Ctu"> + <rect key="frame" x="0.0" y="55.666666666666657" width="388" height="54.333333333333343"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="YW9-Vf-agq"> + <rect key="frame" x="30" y="120" width="328" height="461"/> + <subviews> + <view contentMode="scaleToFill" verticalCompressionResistancePriority="1000" translatesAutoresizingMaskIntoConstraints="NO" id="E9E-nc-B5q"> + <rect key="frame" x="0.0" y="50.666666666666686" width="328" height="300"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="height" constant="300" id="NE1-LX-zuk"/> + </constraints> + </view> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="E9E-nc-B5q" firstAttribute="centerY" secondItem="YW9-Vf-agq" secondAttribute="centerY" constant="-30" id="6F9-GB-T7g"/> + <constraint firstAttribute="trailing" secondItem="E9E-nc-B5q" secondAttribute="trailing" id="R9d-dB-5aM"/> + <constraint firstItem="E9E-nc-B5q" firstAttribute="leading" secondItem="YW9-Vf-agq" secondAttribute="leading" id="zRY-V6-1C5"/> + </constraints> + </view> + <button opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="1000" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="wordWrap" translatesAutoresizingMaskIntoConstraints="NO" id="0z0-r0-F4b" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="591" width="388" height="55"/> + <constraints> + <constraint firstAttribute="height" constant="55" id="Pou-MS-nGp"/> + </constraints> + <fontDescription key="fontDescription" type="system" pointSize="15"/> + <inset key="contentEdgeInsets" minX="10" minY="0.0" maxX="10" maxY="0.0"/> + <inset key="titleEdgeInsets" minX="10" minY="0.0" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Enable Notifications"> + <color key="titleColor" name="AccentColor"/> + </state> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="YES"/> + </userDefinedRuntimeAttributes> + <connections> + <action selector="enableButtonTapped:" destination="-1" eventType="touchUpInside" id="C2V-Lk-KrT"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="TsX-gV-qHT"> + <rect key="frame" x="0.0" y="656" width="388" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="ejN-f9-XZG"/> + </constraints> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal" title="Not Now"/> + <connections> + <action selector="skipButtonTapped:" destination="-1" eventType="touchUpInside" id="pFf-mg-T7T"/> + </connections> + </button> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="TsX-gV-qHT" firstAttribute="leading" secondItem="jle-rd-niA" secondAttribute="leading" id="3y7-UA-k4c"/> + <constraint firstItem="0z0-r0-F4b" firstAttribute="leading" secondItem="jle-rd-niA" secondAttribute="leading" id="63t-AN-vdU"/> + <constraint firstItem="50P-8O-Ctu" firstAttribute="top" secondItem="cpw-1P-GER" secondAttribute="bottom" constant="15" id="8wy-OE-ei6"/> + <constraint firstAttribute="trailing" secondItem="cpw-1P-GER" secondAttribute="trailing" id="Bam-ge-0kU"/> + <constraint firstAttribute="trailing" secondItem="0z0-r0-F4b" secondAttribute="trailing" id="EGp-0I-avL"/> + <constraint firstItem="0z0-r0-F4b" firstAttribute="leading" secondItem="jle-rd-niA" secondAttribute="leading" id="Gse-tb-Mhf"/> + <constraint firstItem="YW9-Vf-agq" firstAttribute="top" secondItem="50P-8O-Ctu" secondAttribute="bottom" constant="10" id="Gt4-oF-RNy"/> + <constraint firstItem="YW9-Vf-agq" firstAttribute="top" secondItem="50P-8O-Ctu" secondAttribute="bottom" constant="10" id="HJz-Ew-YcL"/> + <constraint firstAttribute="trailing" secondItem="YW9-Vf-agq" secondAttribute="trailing" priority="750" constant="30" id="Jwm-7f-Vhi"/> + <constraint firstItem="50P-8O-Ctu" firstAttribute="leading" secondItem="cpw-1P-GER" secondAttribute="leading" id="Mrh-b2-OGA"/> + <constraint firstItem="0z0-r0-F4b" firstAttribute="top" secondItem="YW9-Vf-agq" secondAttribute="bottom" constant="10" id="Oaw-5C-0IF"/> + <constraint firstItem="TsX-gV-qHT" firstAttribute="top" secondItem="0z0-r0-F4b" secondAttribute="bottom" constant="10" id="QHF-Rw-hb6"/> + <constraint firstAttribute="height" relation="lessThanOrEqual" constant="700" id="S2r-1H-icd"/> + <constraint firstItem="50P-8O-Ctu" firstAttribute="trailing" secondItem="cpw-1P-GER" secondAttribute="trailing" id="U4u-i6-vOY"/> + <constraint firstAttribute="bottom" secondItem="TsX-gV-qHT" secondAttribute="bottom" id="aEv-ar-Qze"/> + <constraint firstItem="cpw-1P-GER" firstAttribute="top" secondItem="jle-rd-niA" secondAttribute="top" id="aF7-Fq-GRg"/> + <constraint firstItem="YW9-Vf-agq" firstAttribute="leading" secondItem="jle-rd-niA" secondAttribute="leading" constant="30" id="gUn-df-jQU"/> + <constraint firstItem="TsX-gV-qHT" firstAttribute="top" secondItem="0z0-r0-F4b" secondAttribute="bottom" constant="10" id="hJM-64-yNM"/> + <constraint firstAttribute="trailing" secondItem="TsX-gV-qHT" secondAttribute="trailing" id="l5Z-Y3-N2a"/> + <constraint firstItem="YW9-Vf-agq" firstAttribute="centerX" secondItem="jle-rd-niA" secondAttribute="centerX" id="nov-da-boi"/> + <constraint firstItem="cpw-1P-GER" firstAttribute="leading" secondItem="jle-rd-niA" secondAttribute="leading" id="srH-oQ-feW"/> + <constraint firstItem="0z0-r0-F4b" firstAttribute="top" secondItem="YW9-Vf-agq" secondAttribute="bottom" constant="10" id="wdk-Bg-nHe"/> + <constraint firstAttribute="width" relation="lessThanOrEqual" constant="480" id="xMW-lN-yCr"/> + </constraints> + </view> + </subviews> + <viewLayoutGuide key="safeArea" id="tzQ-KK-xbd"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="tzQ-KK-xbd" firstAttribute="trailing" secondItem="jle-rd-niA" secondAttribute="trailing" priority="750" constant="20" id="BCH-Wh-F3W"/> + <constraint firstItem="jle-rd-niA" firstAttribute="centerY" secondItem="Mlb-pH-BGO" secondAttribute="centerY" id="ehw-ke-eyA"/> + <constraint firstItem="jle-rd-niA" firstAttribute="top" secondItem="tzQ-KK-xbd" secondAttribute="top" priority="750" constant="40" id="fgC-Is-f0Z"/> + <constraint firstItem="tzQ-KK-xbd" firstAttribute="bottom" secondItem="jle-rd-niA" secondAttribute="bottom" priority="750" constant="24" id="qpa-o5-LFK"/> + <constraint firstItem="jle-rd-niA" firstAttribute="centerX" secondItem="Mlb-pH-BGO" secondAttribute="centerX" id="xiI-Qn-cUe"/> + <constraint firstItem="jle-rd-niA" firstAttribute="leading" secondItem="tzQ-KK-xbd" secondAttribute="leading" priority="750" constant="20" id="ybI-h9-jCY"/> + </constraints> + <edgeInsets key="layoutMargins" top="8" left="8" bottom="8" right="8"/> + <point key="canvasLocation" x="-1093" y="131"/> + </view> + </objects> + <resources> + <namedColor name="AccentColor"> + <color red="0.0" green="0.46000000000000002" blue="0.89000000000000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </namedColor> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingQuestionsCoordinator.swift b/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingQuestionsCoordinator.swift new file mode 100644 index 000000000000..2f8ea7ba7c22 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingQuestionsCoordinator.swift @@ -0,0 +1,93 @@ +import Foundation +import UIKit + +enum OnboardingOption: String { + case stats + case writing + case notifications + case reader + case showMeAround = "show_me_around" + case skip +} + +extension NSNotification.Name { + static let onboardingPromptWasDismissed = NSNotification.Name(rawValue: "OnboardingPromptWasDismissed") +} + +class OnboardingQuestionsCoordinator { + var navigationController: UINavigationController? + var onDismiss: ((_ selection: OnboardingOption) -> Void)? + + func dismiss(selection: OnboardingOption) { + onDismiss?(selection) + } + + func track(_ event: WPAnalyticsEvent, option: OnboardingOption? = nil) { + guard let option = option else { + WPAnalytics.track(event) + return + } + + let properties = ["item": option.rawValue] + WPAnalytics.track(event, properties: properties) + } +} + +// MARK: - Questions View Handling +extension OnboardingQuestionsCoordinator { + func questionsDisplayed() { + track(.onboardingQuestionsDisplayed) + } + + func questionsSkipped(option: OnboardingOption) { + dismiss(selection: option) + track(.onboardingQuestionsSkipped) + } + + func didSelect(option: OnboardingOption) { + guard option != .skip else { + questionsSkipped(option: option) + return + } + + track(.onboardingQuestionsItemSelected, option: option) + UserPersistentStoreFactory.instance().onboardingQuestionSelected = option + + // Check if notification's are already enabled + // If they are just dismiss, if not then prompt + UNUserNotificationCenter.current().getNotificationSettings(completionHandler: { [weak self] settings in + DispatchQueue.main.async { + guard settings.authorizationStatus == .notDetermined, let self = self else { + self?.dismiss(selection: option) + return + } + + let controller = OnboardingEnableNotificationsViewController(with: self, option: option) + self.navigationController?.pushViewController(controller, animated: true) + } + }) + } +} + +// MARK: - Notifications Handling +extension OnboardingQuestionsCoordinator { + func notificationsDisplayed(option: OnboardingOption) { + track(.onboardingEnableNotificationsDisplayed, option: option) + UserPersistentStoreFactory.instance().onboardingNotificationsPromptDisplayed = true + } + + func notificationsEnabledTapped(selection: OnboardingOption) { + track(.onboardingEnableNotificationsEnableTapped, option: selection) + + InteractiveNotificationsManager.shared.requestAuthorization { authorized in + DispatchQueue.main.async { + self.dismiss(selection: selection) + } + } + } + + func notificationsSkipped(selection: OnboardingOption) { + track(.onboardingEnableNotificationsSkipped, option: selection) + dismiss(selection: selection) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingQuestionsPromptViewController.swift b/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingQuestionsPromptViewController.swift new file mode 100644 index 000000000000..5c68850717c6 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingQuestionsPromptViewController.swift @@ -0,0 +1,188 @@ +import UIKit +import WordPressUI +import WordPressShared + +class OnboardingQuestionsPromptViewController: UIViewController { + @IBOutlet weak var stackView: UIStackView! + @IBOutlet weak var titleLabel: UILabel! + + @IBOutlet weak var statsButton: UIButton! + @IBOutlet weak var postsButton: UIButton! + @IBOutlet weak var notificationsButton: UIButton! + @IBOutlet weak var readButton: UIButton! + @IBOutlet weak var notSureButton: UIButton! + @IBOutlet weak var skipButton: UIButton! + + let coordinator: OnboardingQuestionsCoordinator + + init(with coordinator: OnboardingQuestionsCoordinator) { + self.coordinator = coordinator + super.init(nibName: nil, bundle: nil) + } + + required convenience init?(coder: NSCoder) { + self.init(with: OnboardingQuestionsCoordinator()) + } + + override func viewDidLoad() { + super.viewDidLoad() + + navigationController?.navigationBar.isHidden = true + navigationController?.delegate = self + + applyStyles() + updateButtonTitles() + + coordinator.questionsDisplayed() + } + + // MARK: - View Methods + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + configureButtons() + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return [.portrait, .portraitUpsideDown] + } +} + +// MARK: - IBAction's +extension OnboardingQuestionsPromptViewController { + @IBAction func didTapStats(_ sender: Any) { + coordinator.didSelect(option: .stats) + } + + @IBAction func didTapWriting(_ sender: Any) { + coordinator.didSelect(option: .writing) + } + + @IBAction func didTapNotifications(_ sender: Any) { + coordinator.didSelect(option: .notifications) + } + + @IBAction func didTapReader(_ sender: Any) { + coordinator.didSelect(option: .reader) + } + + @IBAction func didTapNotSure(_ sender: Any) { + coordinator.didSelect(option: .showMeAround) + } + + @IBAction func skip(_ sender: Any) { + coordinator.didSelect(option: .skip) + } +} + +// MARK: - Private Helpers +private extension OnboardingQuestionsPromptViewController { + private func applyStyles() { + titleLabel.font = WPStyleGuide.serifFontForTextStyle(.title1, fontWeight: .semibold) + titleLabel.textColor = .text + + stackView.setCustomSpacing(32, after: titleLabel) + } + + private func updateButtonTitles() { + titleLabel.text = Strings.title + + statsButton.setTitle(Strings.stats, for: .normal) + statsButton.setImage("📊".image(), for: .normal) + + postsButton.setTitle(Strings.writing, for: .normal) + postsButton.setImage("✍️".image(), for: .normal) + + notificationsButton.setTitle(Strings.notifications, for: .normal) + notificationsButton.setImage("🔔".image(), for: .normal) + + readButton.setTitle(Strings.reader, for: .normal) + readButton.setImage("📚".image(), for: .normal) + + notSureButton.setTitle(Strings.notSure, for: .normal) + notSureButton.setImage("🤔".image(), for: .normal) + + skipButton.setTitle(Strings.skip, for: .normal) + } + + private func configureButtons() { + [statsButton, postsButton, notificationsButton, readButton, notSureButton].forEach { + style(button: $0) + } + } + + private func style(button: UIButton) { + button.titleLabel?.font = WPStyleGuide.fontForTextStyle(.headline) + button.setTitleColor(.text, for: .normal) + button.titleLabel?.textAlignment = .natural + button.titleEdgeInsets.left = 10 + button.imageView?.contentMode = .scaleAspectFit + button.flipInsetsForRightToLeftLayoutDirection() + } +} + +// MARK: - UINavigation Controller Delegate +extension OnboardingQuestionsPromptViewController: UINavigationControllerDelegate { + func navigationControllerSupportedInterfaceOrientations(_ navigationController: UINavigationController) -> UIInterfaceOrientationMask { + return supportedInterfaceOrientations + } + + func navigationControllerPreferredInterfaceOrientationForPresentation(_ navigationController: UINavigationController) -> UIInterfaceOrientation { + return .portrait + } +} + + +// MARK: - CGSize Helper Extension +private extension CGSize { + + /// Get the center point of the size in the given rect + /// - Parameter rect: The rect to center the size in + /// - Returns: The center point + func centered(in rect: CGRect) -> CGPoint { + let x = rect.midX - (self.width * 0.5) + let y = rect.midY - (self.height * 0.5) + + return CGPoint(x: x, y: y) + } +} + +// MARK: - Emoji Drawing Helper Extension +private extension String { + func image() -> UIImage { + let size = Constants.iconSize + let imageSize = CGSize(width: size, height: size) + let rect = CGRect(origin: .zero, size: imageSize) + + UIGraphicsBeginImageContextWithOptions(imageSize, false, 0) + + UIColor.clear.set() + UIRectFill(rect) + + let attributes: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: size)] + + let string = self as NSString + let drawingSize = string.size(withAttributes: attributes) + string.draw(at: drawingSize.centered(in: rect), withAttributes: attributes) + + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return image?.withRenderingMode(.alwaysOriginal) ?? UIImage() + } +} + +// MARK: - Helper Structs +private struct Strings { + static let title = NSLocalizedString("What would you like to focus on first?", comment: "Title of the view asking the user what they'd like to focus on") + static let stats = NSLocalizedString("Checking stats", comment: "Title of button that asks the users if they'd like to focus on checking their sites stats") + static let writing = NSLocalizedString("Writing blog posts", comment: "Title of button that asks the users if they'd like to focus on checking their sites stats") + static let notifications = NSLocalizedString("Staying up to date with notifications", comment: "Title of button that asks the users if they'd like to focus on checking their sites stats") + static let reader = NSLocalizedString("Reading posts from other sites", comment: "Title of button that asks the users if they'd like to focus on checking their sites stats") + static let notSure = NSLocalizedString("Not sure, show me around", comment: "Button that allows users unsure of what selection they'd like ") + static let skip = NSLocalizedString("Skip", comment: "Button that allows the user to skip the prompt and be brought to the app") +} + +private struct Constants { + static let iconSize = 24.0 +} diff --git a/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingQuestionsPromptViewController.xib b/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingQuestionsPromptViewController.xib new file mode 100644 index 000000000000..415b4fe6d056 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Onboarding Questions Prompt/OnboardingQuestionsPromptViewController.xib @@ -0,0 +1,203 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="ipad11_0rounded" orientation="portrait" layout="fullscreen" appearance="light"/> + <accessibilityOverrides dynamicTypePreference="11"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/> + <capability name="Named colors" minToolsVersion="9.0"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="OnboardingQuestionsPromptViewController" customModule="WordPress" customModuleProvider="target"> + <connections> + <outlet property="notSureButton" destination="KwB-px-6X8" id="Y21-NV-nOb"/> + <outlet property="notificationsButton" destination="CDq-op-qjQ" id="HTb-da-7aL"/> + <outlet property="postsButton" destination="QSf-1g-Q0C" id="nek-wH-MJ6"/> + <outlet property="readButton" destination="Lxf-OI-DPA" id="npH-Bj-sfr"/> + <outlet property="skipButton" destination="oqk-b2-4xT" id="jVH-lF-viN"/> + <outlet property="stackView" destination="csX-G4-jUX" id="Wmx-aX-O3H"/> + <outlet property="statsButton" destination="vZc-Ui-n4c" id="8Un-B2-aod"/> + <outlet property="titleLabel" destination="dBQ-Sd-akv" id="ex9-fe-Cx3"/> + <outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" id="i5M-Pr-FkT"> + <rect key="frame" x="0.0" y="0.0" width="834" height="1194"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="5qq-DW-ke8"> + <rect key="frame" x="177" y="24" width="480" height="1150"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="SbK-VK-fi4"> + <rect key="frame" x="0.0" y="0.0" width="480" height="1150"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="center" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="csX-G4-jUX"> + <rect key="frame" x="20" y="0.0" width="440" height="1150"/> + <subviews> + <view contentMode="scaleToFill" verticalCompressionResistancePriority="1000" translatesAutoresizingMaskIntoConstraints="NO" id="WPd-b7-OmY" userLabel="Spacer View"> + <rect key="frame" x="80" y="0.0" width="280" height="40"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="height" constant="40" id="HdW-le-Jvf"/> + </constraints> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="1000" text="What would you like to focus on?" textAlignment="center" lineBreakMode="wordWrap" numberOfLines="0" minimumScaleFactor="0.5" adjustsLetterSpacingToFitWidth="YES" adjustsFontForContentSizeCategory="YES" translatesAutoresizingMaskIntoConstraints="NO" id="dBQ-Sd-akv"> + <rect key="frame" x="53.5" y="50" width="333.5" height="82"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleTitle0"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <button opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="1000" layoutMarginsFollowReadableWidth="YES" contentHorizontalAlignment="leading" contentVerticalAlignment="center" lineBreakMode="wordWrap" translatesAutoresizingMaskIntoConstraints="NO" id="vZc-Ui-n4c" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="20" y="142" width="400" height="55"/> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="55" id="vLw-WE-Hwa"/> + </constraints> + <inset key="contentEdgeInsets" minX="10" minY="0.0" maxX="10" maxY="0.0"/> + <inset key="titleEdgeInsets" minX="10" minY="0.0" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Checking stats"> + <color key="titleColor" name="AccentColor"/> + </state> + <connections> + <action selector="didTapStats:" destination="-1" eventType="touchUpInside" id="RCG-HK-Bew"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="1000" layoutMarginsFollowReadableWidth="YES" contentHorizontalAlignment="leading" contentVerticalAlignment="center" buttonType="system" lineBreakMode="wordWrap" translatesAutoresizingMaskIntoConstraints="NO" id="QSf-1g-Q0C" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="20" y="207" width="400" height="55"/> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="55" id="ajb-Ej-ySY"/> + </constraints> + <inset key="contentEdgeInsets" minX="10" minY="0.0" maxX="10" maxY="0.0"/> + <inset key="titleEdgeInsets" minX="10" minY="0.0" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Writing blog posts"/> + <connections> + <action selector="didTapWriting:" destination="-1" eventType="touchUpInside" id="HTi-5r-U8t"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="1000" layoutMarginsFollowReadableWidth="YES" contentHorizontalAlignment="leading" contentVerticalAlignment="center" lineBreakMode="wordWrap" preferredBehavioralStyle="mac" translatesAutoresizingMaskIntoConstraints="NO" id="CDq-op-qjQ" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="20" y="272" width="400" height="55"/> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="55" id="rUc-4S-Bfz"/> + </constraints> + <fontDescription key="fontDescription" type="system" pointSize="15"/> + <inset key="contentEdgeInsets" minX="10" minY="0.0" maxX="10" maxY="0.0"/> + <inset key="titleEdgeInsets" minX="10" minY="0.0" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Staying up to date with notifications"> + <color key="titleColor" name="AccentColor"/> + </state> + <connections> + <action selector="didTapNotifications:" destination="-1" eventType="touchUpInside" id="mnk-tN-bqk"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="1000" layoutMarginsFollowReadableWidth="YES" contentHorizontalAlignment="leading" contentVerticalAlignment="center" buttonType="system" lineBreakMode="wordWrap" translatesAutoresizingMaskIntoConstraints="NO" id="Lxf-OI-DPA" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="20" y="337" width="400" height="505"/> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="55" id="IXy-Xc-PPb"/> + </constraints> + <inset key="contentEdgeInsets" minX="10" minY="0.0" maxX="10" maxY="0.0"/> + <inset key="titleEdgeInsets" minX="10" minY="0.0" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Reading posts from other sites"/> + <connections> + <action selector="didTapReader:" destination="-1" eventType="touchUpInside" id="f3O-X6-kBP"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="1000" layoutMarginsFollowReadableWidth="YES" contentHorizontalAlignment="leading" contentVerticalAlignment="center" buttonType="system" lineBreakMode="wordWrap" translatesAutoresizingMaskIntoConstraints="NO" id="KwB-px-6X8" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="20" y="852" width="400" height="55"/> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="55" id="F8y-fV-N8k"/> + </constraints> + <inset key="contentEdgeInsets" minX="10" minY="0.0" maxX="10" maxY="0.0"/> + <inset key="titleEdgeInsets" minX="10" minY="0.0" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Not sure, show me around."/> + <connections> + <action selector="didTapNotSure:" destination="-1" eventType="touchUpInside" id="rgT-w3-vu7"/> + </connections> + </button> + <view contentMode="scaleToFill" verticalCompressionResistancePriority="1000" translatesAutoresizingMaskIntoConstraints="NO" id="prj-v9-v12" userLabel="Spacer View"> + <rect key="frame" x="80" y="917" width="280" height="179"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + </view> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="oqk-b2-4xT"> + <rect key="frame" x="205" y="1106" width="30" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="VDE-E0-c6D"/> + </constraints> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal" title="Skip"/> + <connections> + <action selector="skip:" destination="-1" eventType="touchUpInside" id="Reh-V6-ZJt"/> + </connections> + </button> + </subviews> + <viewLayoutGuide key="safeArea" id="BAl-eh-E95"/> + <constraints> + <constraint firstItem="Lxf-OI-DPA" firstAttribute="trailing" secondItem="vZc-Ui-n4c" secondAttribute="trailing" id="219-6B-a3L"/> + <constraint firstItem="Lxf-OI-DPA" firstAttribute="leading" secondItem="vZc-Ui-n4c" secondAttribute="leading" id="2SZ-cu-eV6"/> + <constraint firstItem="BAl-eh-E95" firstAttribute="trailing" secondItem="CDq-op-qjQ" secondAttribute="trailing" constant="20" id="5Qu-LV-Wbf"/> + <constraint firstItem="Lxf-OI-DPA" firstAttribute="leading" secondItem="BAl-eh-E95" secondAttribute="leading" constant="20" id="6CM-Tg-Iff"/> + <constraint firstItem="BAl-eh-E95" firstAttribute="trailing" secondItem="QSf-1g-Q0C" secondAttribute="trailing" constant="20" id="7wI-tw-wep"/> + <constraint firstItem="vZc-Ui-n4c" firstAttribute="leading" secondItem="BAl-eh-E95" secondAttribute="leading" constant="20" id="AJH-6i-HSQ"/> + <constraint firstItem="KwB-px-6X8" firstAttribute="leading" secondItem="BAl-eh-E95" secondAttribute="leading" constant="20" id="BTK-ku-FPr"/> + <constraint firstItem="KwB-px-6X8" firstAttribute="trailing" secondItem="vZc-Ui-n4c" secondAttribute="trailing" id="HhK-JY-opH"/> + <constraint firstItem="QSf-1g-Q0C" firstAttribute="leading" secondItem="vZc-Ui-n4c" secondAttribute="leading" id="PXu-zq-Lf6"/> + <constraint firstItem="CDq-op-qjQ" firstAttribute="leading" secondItem="vZc-Ui-n4c" secondAttribute="leading" id="UlZ-ir-I1b"/> + <constraint firstItem="QSf-1g-Q0C" firstAttribute="leading" secondItem="BAl-eh-E95" secondAttribute="leading" constant="20" id="UpQ-JE-UJZ"/> + <constraint firstItem="CDq-op-qjQ" firstAttribute="leading" secondItem="BAl-eh-E95" secondAttribute="leading" constant="20" id="f74-bo-G03"/> + <constraint firstItem="BAl-eh-E95" firstAttribute="trailing" secondItem="vZc-Ui-n4c" secondAttribute="trailing" constant="20" id="g7M-aj-LMI"/> + <constraint firstItem="BAl-eh-E95" firstAttribute="trailing" secondItem="Lxf-OI-DPA" secondAttribute="trailing" constant="20" id="pvQ-P0-VNT"/> + <constraint firstItem="QSf-1g-Q0C" firstAttribute="trailing" secondItem="vZc-Ui-n4c" secondAttribute="trailing" id="sKv-PI-NZk"/> + <constraint firstItem="CDq-op-qjQ" firstAttribute="trailing" secondItem="vZc-Ui-n4c" secondAttribute="trailing" id="tkA-Nx-CJc"/> + <constraint firstItem="BAl-eh-E95" firstAttribute="trailing" secondItem="KwB-px-6X8" secondAttribute="trailing" constant="20" id="zWA-La-6EA"/> + <constraint firstItem="KwB-px-6X8" firstAttribute="leading" secondItem="vZc-Ui-n4c" secondAttribute="leading" id="zfb-as-x9z"/> + </constraints> + </stackView> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="csX-G4-jUX" firstAttribute="top" secondItem="SbK-VK-fi4" secondAttribute="top" id="Gs8-5g-1TY"/> + <constraint firstAttribute="trailing" secondItem="csX-G4-jUX" secondAttribute="trailing" constant="20" id="Krd-03-21K"/> + <constraint firstAttribute="bottom" secondItem="csX-G4-jUX" secondAttribute="bottom" id="THZ-7c-LG3"/> + <constraint firstItem="csX-G4-jUX" firstAttribute="leading" secondItem="SbK-VK-fi4" secondAttribute="leading" constant="20" id="ccf-fU-hKg"/> + </constraints> + </view> + </subviews> + <constraints> + <constraint firstAttribute="width" relation="lessThanOrEqual" constant="480" id="Co3-Ui-CIZ"/> + <constraint firstItem="SbK-VK-fi4" firstAttribute="top" secondItem="sQ5-Oa-1dH" secondAttribute="top" id="WaL-kg-DZa"/> + <constraint firstItem="sQ5-Oa-1dH" firstAttribute="trailing" secondItem="SbK-VK-fi4" secondAttribute="trailing" id="Yz0-PM-0fE"/> + <constraint firstItem="SbK-VK-fi4" firstAttribute="centerY" secondItem="5qq-DW-ke8" secondAttribute="centerY" priority="250" id="h9l-n4-0oy"/> + <constraint firstItem="sQ5-Oa-1dH" firstAttribute="bottom" secondItem="SbK-VK-fi4" secondAttribute="bottom" priority="250" id="mwE-oe-KSc"/> + <constraint firstItem="SbK-VK-fi4" firstAttribute="centerX" secondItem="5qq-DW-ke8" secondAttribute="centerX" id="oeu-ZH-S7K"/> + <constraint firstItem="SbK-VK-fi4" firstAttribute="leading" secondItem="sQ5-Oa-1dH" secondAttribute="leading" id="s8L-kH-amq"/> + <constraint firstItem="SbK-VK-fi4" firstAttribute="height" secondItem="eNH-SE-BnM" secondAttribute="height" id="xuU-wY-Exy"/> + <constraint firstItem="SbK-VK-fi4" firstAttribute="width" secondItem="eNH-SE-BnM" secondAttribute="width" id="zZZ-wq-AZd"/> + </constraints> + <viewLayoutGuide key="contentLayoutGuide" id="sQ5-Oa-1dH"/> + <viewLayoutGuide key="frameLayoutGuide" id="eNH-SE-BnM"/> + </scrollView> + </subviews> + <viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="5qq-DW-ke8" firstAttribute="centerX" secondItem="i5M-Pr-FkT" secondAttribute="centerX" id="9HK-dL-1rG"/> + <constraint firstItem="5qq-DW-ke8" firstAttribute="centerY" secondItem="fnl-2z-Ty3" secondAttribute="centerY" id="DYZ-nG-GI6"/> + <constraint firstItem="5qq-DW-ke8" firstAttribute="leading" secondItem="fnl-2z-Ty3" secondAttribute="leading" priority="750" id="cn4-tK-ms6"/> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" secondItem="5qq-DW-ke8" secondAttribute="trailing" priority="750" id="dFk-LP-c0X"/> + <constraint firstItem="5qq-DW-ke8" firstAttribute="top" secondItem="fnl-2z-Ty3" secondAttribute="top" priority="750" id="iaA-Mr-uee"/> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="bottom" secondItem="5qq-DW-ke8" secondAttribute="bottom" id="xkg-Lr-gLu"/> + </constraints> + <point key="canvasLocation" x="-229" y="53"/> + </view> + </objects> + <resources> + <namedColor name="AccentColor"> + <color red="0.0" green="0.46000000000000002" blue="0.89000000000000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </namedColor> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Blog/PublicizeConnectionURLMatcher.swift b/WordPress/Classes/ViewRelated/Blog/PublicizeConnectionURLMatcher.swift new file mode 100644 index 000000000000..98c7673b05c8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/PublicizeConnectionURLMatcher.swift @@ -0,0 +1,159 @@ +import Foundation + +/// Used to detect whether a URL matches a particular Publicize authorization success or failure route. +struct PublicizeConnectionURLMatcher { + enum MatchComponent { + case verifyActionItem + case denyActionItem + case requestActionItem + case stateItem + case codeItem + case errorItem + + case authorizationPrefix + case declinePath + case accessDenied + + // Special handling for the inconsistent way that services respond to a user's choice to decline + // oauth authorization. + // Right now we have no clear way to know if Tumblr fails. This is something we should try + // fixing moving forward. + // Path does not set the action param or call the callback. It forwards to its own URL ending in /decline. + case userRefused + + // In most cases, we attempt to find a matching URL by checking for a specific URL component + fileprivate var queryItem: URLQueryItem? { + switch self { + case .verifyActionItem: + return URLQueryItem(name: "action", value: "verify") + case .denyActionItem: + return URLQueryItem(name: "action", value: "deny") + case .requestActionItem: + return URLQueryItem(name: "action", value: "request") + case .accessDenied: + return URLQueryItem(name: "error", value: "access_denied") + case .stateItem: + return URLQueryItem(name: "state", value: nil) + case .codeItem: + return URLQueryItem(name: "code", value: nil) + case .errorItem: + return URLQueryItem(name: "error", value: nil) + case .userRefused: + return URLQueryItem(name: "oauth_problem", value: "user_refused") + default: + return nil + } + } + + // In a handful of cases, we're just looking for a substring or prefix in the URL + fileprivate var matchString: String? { + switch self { + case .declinePath: + return "/decline" + case .authorizationPrefix: + return "https://public-api.wordpress.com/connect" + default: + return nil + } + } + } + + /// @return True if the url matches the current authorization component + /// + static func url(_ url: URL, contains matchComponent: MatchComponent) -> Bool { + if let queryItem = matchComponent.queryItem { + return self.url(url, contains: queryItem) + } + + if let matchString = matchComponent.matchString { + switch matchComponent { + case .declinePath: + return url.path.contains(matchString) + case .authorizationPrefix: + return url.absoluteString.hasPrefix(matchString) + default: + return url.absoluteString.contains(matchString) + } + } + + return false + } + + // Checks to see if the current QueryItem is present in the specified URL + private static func url(_ url: URL, contains queryItem: URLQueryItem) -> Bool { + guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: true)?.queryItems else { + return false + } + + return queryItems.contains(where: { urlItem in + var result = urlItem.name == queryItem.name + + if let value = queryItem.value { + result = result && (urlItem.value == value) + } + + return result + }) + } + + // MARK: - Authorization Actions + + /// Classify actions taken by the web API + /// + enum AuthorizeAction: Int { + case none + case unknown + case request + case verify + case deny + } + + static func authorizeAction(for matchURL: URL) -> AuthorizeAction { + // Path oauth declines are handled by a redirect to a path.com URL, so check this first. + if url(matchURL, contains: .declinePath) { + return .deny + } + + if !url(matchURL, contains: .authorizationPrefix) { + return .none + } + + if url(matchURL, contains: .requestActionItem) { + return .request + } + + // Check the rest of the various decline ranges + if url(matchURL, contains: .denyActionItem) { + return .deny + } + + // LinkedIn + if url(matchURL, contains: .userRefused) { + return .deny + } + + // Facebook and Google+ + if url(matchURL, contains: .accessDenied) { + return .deny + } + + // If we've made it this far and the `action=verify` query param is present then we're + // *probably* verifying the oauth request. There are edge cases ( :cough: tumblr :cough: ) + // where verification is declined and we get a false positive. + if url(matchURL, contains: .verifyActionItem) { + return .verify + } + + // Facebook + if url(matchURL, contains: .stateItem) && url(matchURL, contains: .codeItem) { + return .verify + } + + // Facebook failure + if url(matchURL, contains: .stateItem) && url(matchURL, contains: .errorItem) { + return .unknown + } + + return .unknown + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/PublicizeServicesState.swift b/WordPress/Classes/ViewRelated/Blog/PublicizeServicesState.swift new file mode 100644 index 000000000000..6da10639a4c8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/PublicizeServicesState.swift @@ -0,0 +1,28 @@ +import Foundation + +@objc final class PublicizeServicesState: NSObject { + private var connections = Set<PublicizeConnection>() +} + +// MARK: - Public Methods +@objc extension PublicizeServicesState { + func addInitialConnections(_ connections: [PublicizeConnection]) { + connections.forEach { self.connections.insert($0) } + } + + func hasAddedNewConnectionTo(_ connections: [PublicizeConnection]) -> Bool { + guard connections.count > 0 else { + return false + } + + if connections.count > self.connections.count { + return true + } + + for connection in connections where !self.connections.contains(connection) { + return true + } + + return false + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartCell.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartCell.swift new file mode 100644 index 000000000000..b6bfe93ab35e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartCell.swift @@ -0,0 +1,37 @@ +import UIKit + +@objc class QuickStartCell: UITableViewCell { + + private lazy var tourStateView: QuickStartTourStateView = { + let view = QuickStartTourStateView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + @objc func configure(blog: Blog, viewController: BlogDetailsViewController) { + contentView.addSubview(tourStateView) + contentView.pinSubviewToAllEdges(tourStateView, insets: Metrics.margins(for: blog.quickStartType)) + + selectionStyle = .none + + let checklistTappedTracker: QuickStartChecklistTappedTracker = (event: .quickStartTapped, properties: [:]) + + tourStateView.configure(blog: blog, + sourceController: viewController, + checklistTappedTracker: checklistTappedTracker) + } + + private enum Metrics { + static func margins(for quickStartType: QuickStartType) -> UIEdgeInsets { + switch quickStartType { + case .undefined: + fallthrough + case .newSite: + return UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) + case .existingSite: + return UIEdgeInsets(top: 0, left: 8, bottom: 8, right: 8) + } + + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistCell.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistCell.swift index 46174729f216..100e58515876 100644 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistCell.swift @@ -1,103 +1,114 @@ import Gridicons +import UIKit class QuickStartChecklistCell: UITableViewCell { @IBOutlet private var titleLabel: UILabel! { didSet { - WPStyleGuide.configureLabel(titleLabel, textStyle: .headline) + WPStyleGuide.configureLabel(titleLabel, textStyle: .callout, fontWeight: .semibold) } } @IBOutlet private var descriptionLabel: UILabel! { didSet { - WPStyleGuide.configureLabel(descriptionLabel, textStyle: .subheadline) + WPStyleGuide.configureLabel(descriptionLabel, textStyle: .footnote) } } - @IBOutlet private var iconView: UIImageView? - @IBOutlet private var stroke: UIView? { - didSet { - stroke?.backgroundColor = .divider - } - } - @IBOutlet private var topSeparator: UIView? { - didSet { - topSeparator?.backgroundColor = .divider - } + @IBOutlet private weak var descriptionContainerView: UIStackView! + @IBOutlet private weak var mainContainerView: UIView! + @IBOutlet private weak var iconContainerView: UIView! + @IBOutlet private weak var iconView: UIImageView! + @IBOutlet private weak var checkmarkContainerView: UIStackView! + @IBOutlet private weak var checkmarkImageView: UIImageView! + + override func awakeFromNib() { + super.awakeFromNib() + applyStyles() } - private var bottomStrokeLeading: NSLayoutConstraint? - private var contentViewLeadingAnchor: NSLayoutXAxisAnchor { - return WPDeviceIdentification.isiPhone() ? contentView.leadingAnchor : contentView.readableContentGuide.leadingAnchor + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + applyStyles() } - private var contentViewTrailingAnchor: NSLayoutXAxisAnchor { - return WPDeviceIdentification.isiPhone() ? contentView.trailingAnchor : contentView.readableContentGuide.trailingAnchor + + func configure(tour: QuickStartTour, completed: Bool) { + setupColors(tour: tour, completed: completed) + setupTitle(tour: tour, completed: completed) + setupContent(tour: tour) + setupCheckmarkView(completed: completed) + setupAccessibility(tour: tour, completed: completed) } - public var completed = false { - didSet { - if completed { - guard let titleText = tour?.title else { - return - } + static let reuseIdentifier = "QuickStartChecklistCell" +} - titleLabel.attributedText = NSAttributedString(string: titleText, - attributes: [.strikethroughStyle: 1, - .foregroundColor: UIColor.neutral(.shade30)]) - descriptionLabel.textColor = .neutral(.shade30) - iconView?.tintColor = .neutral(.shade30) - } else { - titleLabel.textColor = .text - descriptionLabel.textColor = .textSubtle - iconView?.tintColor = .listIcon - } - } +private extension QuickStartChecklistCell { + + func applyStyles() { + selectionStyle = .none + contentView.backgroundColor = .clear + backgroundColor = .clear + mainContainerView.layer.cornerRadius = Constants.mainContainerCornerRadius + iconContainerView.layer.cornerRadius = Constants.iconContainerCornerRadius + mainContainerView.layer.borderColor = Constants.borderColor.cgColor + } + + func setupTitle(tour: QuickStartTour, completed: Bool) { + let strikeThroughStyle = completed ? 1 : 0 + let titleColor: UIColor = completed ? .textTertiary : .label + titleLabel.attributedText = NSAttributedString(string: tour.title, + attributes: [.strikethroughStyle: strikeThroughStyle, + .foregroundColor: titleColor]) } - public var tour: QuickStartTour? { - didSet { - titleLabel.text = tour?.title - descriptionLabel.text = tour?.description - iconView?.image = tour?.icon.withRenderingMode(.alwaysTemplate) - if let hint = tour?.accessibilityHintText, !hint.isEmpty { + func setupAccessibility(tour: QuickStartTour, completed: Bool) { + if completed { + // Overrides the existing accessibility hint in the tour property observer, + // because users don't need the hint repeated to them after a task is completed. + accessibilityHint = nil + accessibilityLabel = tour.titleMarkedCompleted + } else { + let hint = tour.accessibilityHintText + if !hint.isEmpty { accessibilityHint = hint } } } - public var lastRow: Bool = false { - didSet { - bottomStrokeLeading?.isActive = !lastRow + func setupColors(tour: QuickStartTour, completed: Bool) { + descriptionLabel.textColor = .secondaryLabel + if completed { + mainContainerView.backgroundColor = .clear + iconContainerView.backgroundColor = .systemGray4 + iconView?.tintColor = Constants.iconTintColor + mainContainerView.layer.borderWidth = Constants.completedTourBorderWidth + } else { + mainContainerView.backgroundColor = .secondarySystemBackground + iconContainerView.backgroundColor = tour.iconColor + iconView?.tintColor = .white + mainContainerView.layer.borderWidth = 0 } } - public var topSeparatorIsHidden: Bool = false { - didSet { - topSeparator?.isHidden = topSeparatorIsHidden - } + func setupContent(tour: QuickStartTour) { + descriptionLabel.text = tour.description + descriptionContainerView.isHidden = !tour.showDescriptionInQuickStartModal + iconView?.image = tour.icon.withRenderingMode(.alwaysTemplate) } - - override func awakeFromNib() { - super.awakeFromNib() - contentView.backgroundColor = .listForeground - setupConstraints() + func setupCheckmarkView(completed: Bool) { + checkmarkImageView.image = .gridicon(.checkmark) + checkmarkImageView.tintColor = Constants.checkmarkColor + checkmarkContainerView.isHidden = !completed } - static let reuseIdentifier = "QuickStartChecklistCell" -} - -private extension QuickStartChecklistCell { - func setupConstraints() { - guard let stroke = stroke, - let topSeparator = topSeparator else { - return - } - - bottomStrokeLeading = stroke.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor) - bottomStrokeLeading?.isActive = true - let strokeSuperviewLeading = stroke.leadingAnchor.constraint(equalTo: contentViewLeadingAnchor) - strokeSuperviewLeading.priority = UILayoutPriority(999.0) - strokeSuperviewLeading.isActive = true - stroke.trailingAnchor.constraint(equalTo: contentViewTrailingAnchor).isActive = true - topSeparator.leadingAnchor.constraint(equalTo: contentViewLeadingAnchor).isActive = true - topSeparator.trailingAnchor.constraint(equalTo: contentViewTrailingAnchor).isActive = true + enum Constants { + static let mainContainerCornerRadius: CGFloat = 8 + static let iconContainerCornerRadius: CGFloat = 4 + static let completedTourBorderWidth: CGFloat = 0.5 + static let borderColor = UIColor(light: UIColor(hexString: "3c3c43")?.withAlphaComponent(0.36) ?? .clear, + dark: UIColor(hexString: "545458")?.withAlphaComponent(0.65) ?? .clear) + static let iconTintColor = UIColor(light: .white, + dark: UIColor(hexString: "636366") ?? .clear) + static let checkmarkColor = UIColor(light: UIColor(hexString: "AEAEB2") ?? .clear, + dark: UIColor(hexString: "636366") ?? .clear) } } diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistCell.xib b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistCell.xib index 6214b266a49c..31b3f1a6701d 100644 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistCell.xib +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistCell.xib @@ -1,88 +1,168 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina5_9" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_0" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="106" id="AyJ-E5-JK1" customClass="QuickStartChecklistCell" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="375" height="72"/> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="207" id="AyJ-E5-JK1" customClass="QuickStartChecklistCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="415" height="170"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="AyJ-E5-JK1" id="fK0-aP-tbW"> - <rect key="frame" x="0.0" y="0.0" width="375" height="71.666666666666671"/> + <rect key="frame" x="0.0" y="0.0" width="415" height="170"/> <autoresizingMask key="autoresizingMask"/> <subviews> - <view contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="vlx-fb-Uco" userLabel="Stroke"> - <rect key="frame" x="0.0" y="0.0" width="375" height="0.33333333333333331"/> - <color key="backgroundColor" red="0.7843137255" green="0.84313725490000002" blue="0.88235294119999996" alpha="1" colorSpace="custom" customColorSpace="displayP3"/> - <constraints> - <constraint firstAttribute="height" constant="0.33000000000000002" id="hlC-PA-7aV"/> - </constraints> - </view> - <imageView userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="iDO-LH-Tnw"> - <rect key="frame" x="20" y="24" width="24" height="24"/> - <constraints> - <constraint firstAttribute="width" constant="24" id="T71-iR-XpD"/> - <constraint firstAttribute="height" constant="24" id="m64-PM-I3A"/> - </constraints> - </imageView> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="253" verticalCompressionResistancePriority="751" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yc4-11-laP"> - <rect key="frame" x="64" y="15.999999999999998" width="289" height="20.333333333333329"/> - <fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/> - <color key="textColor" red="0.1803921568627451" green="0.26666666666666666" blue="0.32549019607843138" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="749" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="KNn-zz-lLY"> - <rect key="frame" x="64" y="38.333333333333336" width="289" height="18"/> - <fontDescription key="fontDescription" type="system" pointSize="15"/> - <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> - <view contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="q9O-Tl-jRn" userLabel="Stroke"> - <rect key="frame" x="64" y="71.333333333333329" width="311" height="0.3333333333333286"/> - <color key="backgroundColor" red="0.7843137255" green="0.84313725490000002" blue="0.88235294117647056" alpha="1" colorSpace="custom" customColorSpace="displayP3"/> - <constraints> - <constraint firstAttribute="height" constant="0.33000000000000002" id="I9A-zJ-Npt"/> - </constraints> - </view> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="j5b-6q-6Yj"> + <rect key="frame" x="16" y="16" width="383" height="154"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="l4h-MI-mNe"> + <rect key="frame" x="0.0" y="0.0" width="383" height="64"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="zst-03-6EO"> + <rect key="frame" x="68" y="20" width="299" height="24"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1000" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yc4-11-laP"> + <rect key="frame" x="0.0" y="0.0" width="275" height="24"/> + <fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/> + <color key="textColor" red="0.1803921568627451" green="0.26666666666666666" blue="0.32549019607843138" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <nil key="highlightedColor"/> + </label> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="cwD-FC-425"> + <rect key="frame" x="275" y="0.0" width="24" height="24"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5l4-ud-lin"> + <rect key="frame" x="0.0" y="0.0" width="24" height="0.0"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </view> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="fe7-5W-O19"> + <rect key="frame" x="0.0" y="0.0" width="24" height="24"/> + <constraints> + <constraint firstAttribute="height" constant="24" id="Vpx-8M-Hca"/> + <constraint firstAttribute="width" constant="24" id="YeU-2d-UbC"/> + </constraints> + </imageView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="bG5-HU-1yf"> + <rect key="frame" x="0.0" y="24" width="24" height="0.0"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </view> + </subviews> + <constraints> + <constraint firstItem="5l4-ud-lin" firstAttribute="height" secondItem="bG5-HU-1yf" secondAttribute="height" id="mXx-zD-MiS"/> + </constraints> + </stackView> + </subviews> + </stackView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="mnf-RQ-j3n"> + <rect key="frame" x="16" y="12" width="40" height="40"/> + <subviews> + <imageView userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="iDO-LH-Tnw"> + <rect key="frame" x="8" y="8" width="24" height="24"/> + <constraints> + <constraint firstAttribute="width" constant="24" id="T71-iR-XpD"/> + <constraint firstAttribute="height" constant="24" id="m64-PM-I3A"/> + </constraints> + </imageView> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="iDO-LH-Tnw" firstAttribute="centerY" secondItem="mnf-RQ-j3n" secondAttribute="centerY" id="GpF-B8-wO6"/> + <constraint firstItem="iDO-LH-Tnw" firstAttribute="centerX" secondItem="mnf-RQ-j3n" secondAttribute="centerX" id="L0u-DK-DSd"/> + <constraint firstAttribute="width" constant="40" id="RDc-8O-U3X"/> + <constraint firstAttribute="height" constant="40" id="uab-9G-6e9"/> + </constraints> + </view> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="height" constant="64" id="AMd-98-Nnd"/> + <constraint firstItem="mnf-RQ-j3n" firstAttribute="leading" secondItem="l4h-MI-mNe" secondAttribute="leading" constant="16" id="ASZ-Ev-RMr"/> + <constraint firstItem="zst-03-6EO" firstAttribute="leading" secondItem="mnf-RQ-j3n" secondAttribute="trailing" constant="12" id="IFT-9a-kCY"/> + <constraint firstItem="mnf-RQ-j3n" firstAttribute="centerY" secondItem="l4h-MI-mNe" secondAttribute="centerY" id="cXF-gR-ZjC"/> + <constraint firstAttribute="trailing" secondItem="zst-03-6EO" secondAttribute="trailing" constant="16" id="dfd-cs-baQ"/> + <constraint firstItem="zst-03-6EO" firstAttribute="centerY" secondItem="l4h-MI-mNe" secondAttribute="centerY" id="fGp-qv-qBS"/> + </constraints> + </view> + <stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="A3a-q5-XjM"> + <rect key="frame" x="0.0" y="68" width="383" height="86"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Ifg-E4-Cbv"> + <rect key="frame" x="0.0" y="0.0" width="8" height="86"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="width" constant="8" id="2NP-Cy-nWY"/> + </constraints> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="749" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="KNn-zz-lLY"> + <rect key="frame" x="8" y="0.0" width="367" height="86"/> + <fontDescription key="fontDescription" type="system" pointSize="15"/> + <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5SA-1v-vG8"> + <rect key="frame" x="375" y="0.0" width="8" height="86"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="width" constant="8" id="C8e-hz-hes"/> + </constraints> + </view> + </subviews> + </stackView> + </subviews> + </stackView> </subviews> <constraints> - <constraint firstItem="vlx-fb-Uco" firstAttribute="leading" secondItem="fK0-aP-tbW" secondAttribute="leading" placeholder="YES" id="0Kg-c0-gu6"/> - <constraint firstAttribute="bottom" secondItem="q9O-Tl-jRn" secondAttribute="bottom" id="6zL-eP-5kL"/> - <constraint firstItem="KNn-zz-lLY" firstAttribute="top" secondItem="Yc4-11-laP" secondAttribute="bottom" priority="999" constant="2" id="GKD-om-l0b"/> - <constraint firstItem="q9O-Tl-jRn" firstAttribute="leading" secondItem="KNn-zz-lLY" secondAttribute="leading" placeholder="YES" id="IP9-A7-b0C"/> - <constraint firstAttribute="trailing" secondItem="vlx-fb-Uco" secondAttribute="trailing" placeholder="YES" id="KAE-hZ-gmS"/> - <constraint firstItem="KNn-zz-lLY" firstAttribute="leading" secondItem="Yc4-11-laP" secondAttribute="leading" id="WKU-rc-bUU"/> - <constraint firstAttribute="trailingMargin" secondItem="Yc4-11-laP" secondAttribute="trailing" constant="6" id="ZAh-HX-yVH"/> - <constraint firstItem="iDO-LH-Tnw" firstAttribute="leading" secondItem="fK0-aP-tbW" secondAttribute="leadingMargin" constant="4" id="asw-6i-Zac"> - <variation key="heightClass=regular-widthClass=regular" constant="20"/> - </constraint> - <constraint firstItem="Yc4-11-laP" firstAttribute="top" secondItem="fK0-aP-tbW" secondAttribute="top" constant="16" id="dCt-t5-oGk"/> - <constraint firstItem="vlx-fb-Uco" firstAttribute="top" secondItem="fK0-aP-tbW" secondAttribute="top" id="ew5-JN-0t6"/> - <constraint firstItem="iDO-LH-Tnw" firstAttribute="centerY" secondItem="fK0-aP-tbW" secondAttribute="centerY" id="hrh-hQ-gVn"/> - <constraint firstAttribute="trailing" secondItem="q9O-Tl-jRn" secondAttribute="trailing" placeholder="YES" id="iWA-GZ-njV"/> - <constraint firstAttribute="trailingMargin" secondItem="KNn-zz-lLY" secondAttribute="trailing" constant="6" id="imd-Jt-Xqm"/> - <constraint firstAttribute="bottom" secondItem="KNn-zz-lLY" secondAttribute="bottom" constant="15.5" id="oba-ae-wVR"/> - <constraint firstItem="Yc4-11-laP" firstAttribute="leading" secondItem="iDO-LH-Tnw" secondAttribute="trailing" constant="20" id="yPX-QA-rpb"/> + <constraint firstItem="j5b-6q-6Yj" firstAttribute="leading" secondItem="fK0-aP-tbW" secondAttribute="leading" constant="16" id="GOF-fL-o2C"/> + <constraint firstAttribute="trailing" secondItem="j5b-6q-6Yj" secondAttribute="trailing" constant="96" id="QU9-Nx-Pk9"/> + <constraint firstItem="j5b-6q-6Yj" firstAttribute="leading" secondItem="fK0-aP-tbW" secondAttribute="leading" constant="96" id="VdG-ue-A7q"/> + <constraint firstAttribute="bottom" secondItem="j5b-6q-6Yj" secondAttribute="bottom" id="Wc8-N2-dyb"/> + <constraint firstAttribute="trailing" secondItem="j5b-6q-6Yj" secondAttribute="trailing" constant="16" id="ZOI-ar-rKJ"/> + <constraint firstItem="j5b-6q-6Yj" firstAttribute="top" secondItem="fK0-aP-tbW" secondAttribute="top" constant="16" id="kQX-v2-HNW"/> </constraints> + <variation key="default"> + <mask key="constraints"> + <exclude reference="GOF-fL-o2C"/> + <exclude reference="QU9-Nx-Pk9"/> + <exclude reference="VdG-ue-A7q"/> + <exclude reference="ZOI-ar-rKJ"/> + </mask> + </variation> + <variation key="widthClass=compact"> + <mask key="constraints"> + <include reference="GOF-fL-o2C"/> + <include reference="ZOI-ar-rKJ"/> + </mask> + </variation> + <variation key="widthClass=regular"> + <mask key="constraints"> + <include reference="QU9-Nx-Pk9"/> + <include reference="VdG-ue-A7q"/> + </mask> + </variation> </tableViewCellContentView> <accessibility key="accessibilityConfiguration"> <accessibilityTraits key="traits" button="YES"/> </accessibility> <connections> + <outlet property="checkmarkContainerView" destination="cwD-FC-425" id="tPP-jM-RUO"/> + <outlet property="checkmarkImageView" destination="fe7-5W-O19" id="cIL-55-l32"/> + <outlet property="descriptionContainerView" destination="A3a-q5-XjM" id="znu-1Y-XS8"/> <outlet property="descriptionLabel" destination="KNn-zz-lLY" id="wd6-dY-ajZ"/> - <outlet property="iconView" destination="iDO-LH-Tnw" id="459-AF-gTi"/> - <outlet property="stroke" destination="q9O-Tl-jRn" id="MDI-tG-fCN"/> + <outlet property="iconContainerView" destination="mnf-RQ-j3n" id="vOb-DN-2V7"/> + <outlet property="iconView" destination="iDO-LH-Tnw" id="jOe-P3-Hq9"/> + <outlet property="mainContainerView" destination="l4h-MI-mNe" id="3tI-sq-zXU"/> <outlet property="titleLabel" destination="Yc4-11-laP" id="fxd-Fh-PS5"/> - <outlet property="topSeparator" destination="vlx-fb-Uco" id="Tiu-qo-Q0n"/> </connections> - <point key="canvasLocation" x="-158.203125" y="-21.09375"/> + <point key="canvasLocation" x="-1217" y="-86"/> </tableViewCell> </objects> + <resources> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> </document> diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistFooter.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistFooter.swift deleted file mode 100644 index 6e9e2ec6c96a..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistFooter.swift +++ /dev/null @@ -1 +0,0 @@ -class QuickStartChecklistFooter: UIView { } diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistHeader.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistHeader.swift index 80d1a7d89a48..951e91dbb107 100644 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistHeader.swift +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistHeader.swift @@ -1,119 +1,25 @@ -import Gridicons +import UIKit -class QuickStartChecklistHeader: UIView { - var collapseListener: ((Bool) -> Void)? - var collapse: Bool = false { - didSet { - collapseListener?(collapse) - /* The animation will always take the shortest way. - * Therefore CGFloat.pi and -CGFloat.pi animates in same position. - * As we need anti-clockwise rotation we forcefully made it a shortest way by using 0.999 - */ - let rotate = (collapse ? 0.999 : 180.0) * CGFloat.pi - let alpha = collapse ? 0.0 : 1.0 - animator.animateWithDuration(0.3, animations: { [weak self] in - self?.bottomStroke.alpha = CGFloat(alpha) - self?.chevronView.transform = CGAffineTransform(rotationAngle: rotate) - }) - updateCollapseHeaderAccessibility() - } - } - var count: Int = 0 { - didSet { - titleLabel.text = String(format: Constant.title, count) - updateCollapseHeaderAccessibility() - } - } +class QuickStartChecklistHeader: UITableViewHeaderFooterView, NibReusable { - @IBOutlet private var titleLabel: UILabel! { - didSet { - WPStyleGuide.configureLabel(titleLabel, textStyle: .body) - titleLabel.textColor = .neutral(.shade30) - } - } - @IBOutlet private var chevronView: UIImageView! { - didSet { - chevronView.image = Gridicon.iconOfType(.chevronDown) - chevronView.tintColor = .textTertiary - } - } - @IBOutlet var topStroke: UIView! { - didSet { - topStroke.backgroundColor = .divider - } - } - @IBOutlet private var bottomStroke: UIView! { - didSet { - bottomStroke.backgroundColor = .divider - } - } - @IBOutlet private var contentView: UIView! { + // MARK: Public Variables + + var title: String? { didSet { - contentView.leadingAnchor.constraint(equalTo: contentViewLeadingAnchor).isActive = true - contentView.trailingAnchor.constraint(equalTo: contentViewTrailingAnchor).isActive = true + titleLabel.text = title } } - private let animator = Animator() - private var contentViewLeadingAnchor: NSLayoutXAxisAnchor { - return WPDeviceIdentification.isiPhone() ? safeAreaLayoutGuide.leadingAnchor : layoutMarginsGuide.leadingAnchor - } - private var contentViewTrailingAnchor: NSLayoutXAxisAnchor { - return WPDeviceIdentification.isiPhone() ? safeAreaLayoutGuide.trailingAnchor : layoutMarginsGuide.trailingAnchor - } + // MARK: Outlets - @IBAction private func headerDidTouch(_ sender: UIButton) { - collapse.toggle() - } + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + + // MARK: View Lifecycle override func awakeFromNib() { super.awakeFromNib() - contentView.backgroundColor = .listForeground - prepareForVoiceOver() - } -} - -private enum Constant { - static let title = NSLocalizedString("Complete (%i)", comment: "The table view header title that displays the number of completed tasks") -} - -// MARK: - Accessible - -extension QuickStartChecklistHeader: Accessible { - func prepareForVoiceOver() { - // Here we explicit configure the subviews, to prepare for the desired composite behavior - bottomStroke.isAccessibilityElement = false - contentView.isAccessibilityElement = false - titleLabel.isAccessibilityElement = false - chevronView.isAccessibilityElement = false - - // Neither the top stroke nor the button (overlay) are outlets, so we configured them in the nib - - // From an accessibility perspective, this view is essentially monolithic, so we configure it accordingly - isAccessibilityElement = true - accessibilityTraits = [.header, .button] - - updateCollapseHeaderAccessibility() - } - - func updateCollapseHeaderAccessibility() { - - let accessibilityHintText: String - let accessibilityLabelFormat: String - - if collapse { - accessibilityHintText = NSLocalizedString("Collapses the list of completed tasks.", comment: "Accessibility hint for the list of completed tasks presented during Quick Start.") - - accessibilityLabelFormat = NSLocalizedString("Expanded, %i completed tasks, toggling collapses the list of these tasks", comment: "Accessibility description for the list of completed tasks presented during Quick Start. Parameter is a number representing the count of completed tasks.") - } else { - accessibilityHintText = NSLocalizedString("Expands the list of completed tasks.", comment: "Accessibility hint for the list of completed tasks presented during Quick Start.") - - accessibilityLabelFormat = NSLocalizedString("Collapsed, %i completed tasks, toggling expands the list of these tasks", comment: "Accessibility description for the list of completed tasks presented during Quick Start. Parameter is a number representing the count of completed tasks.") - } - - accessibilityHint = accessibilityHintText - - let localizedAccessibilityDescription = String(format: accessibilityLabelFormat, arguments: [count]) - accessibilityLabel = localizedAccessibilityDescription + imageView.image = AppStyleGuide.quickStartExistingSite + titleLabel.font = AppStyleGuide.prominentFont(textStyle: .title1, weight: .semibold) } } diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistHeader.xib b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistHeader.xib index 011b1c08eb5d..72c4461b4378 100644 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistHeader.xib +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistHeader.xib @@ -1,103 +1,48 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14865.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait" appearance="light"/> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14819.2"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <view contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" id="iN0-l3-epB" customClass="QuickStartChecklistHeader" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="375" height="44"/> + <view contentMode="scaleToFill" id="iN0-l3-epB" customClass="QuickStartChecklistHeader" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> - <view contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="rkz-KZ-Ayy" userLabel="Content View"> - <rect key="frame" x="0.0" y="0.0" width="375" height="44"/> - <subviews> - <view contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="QaU-FW-PcA" userLabel="Top Stroke"> - <rect key="frame" x="0.0" y="0.0" width="375" height="0.5"/> - <color key="backgroundColor" red="0.7843137255" green="0.84313725490000002" blue="0.88235294119999996" alpha="1" colorSpace="custom" customColorSpace="displayP3"/> - <constraints> - <constraint firstAttribute="height" constant="0.33000000000000002" id="R8g-s7-4QD"/> - </constraints> - </view> - <view contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="hgL-Ff-jIb" userLabel="Bottom Stroke"> - <rect key="frame" x="0.0" y="43.5" width="375" height="0.5"/> - <color key="backgroundColor" red="0.7843137255" green="0.84313725490000002" blue="0.88235294119999996" alpha="1" colorSpace="custom" customColorSpace="displayP3"/> - <constraints> - <constraint firstAttribute="height" constant="0.33000000000000002" id="TaS-fP-pXw"/> - </constraints> - </view> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" layoutMarginsFollowReadableWidth="YES" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="xiK-ca-ccq"> - <rect key="frame" x="16" y="12.5" width="303" height="19"/> - <accessibility key="accessibilityConfiguration"> - <bool key="isElement" value="NO"/> - </accessibility> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <nil key="textColor"/> - <nil key="highlightedColor"/> - </label> - <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="2ag-iN-nJf"> - <rect key="frame" x="335" y="10" width="24" height="24"/> - <constraints> - <constraint firstAttribute="height" constant="24" id="T9J-7Q-gOT"/> - <constraint firstAttribute="width" constant="24" id="trJ-6b-F4Y"/> - </constraints> - </imageView> - <button opaque="NO" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Yus-75-Z9J"> - <rect key="frame" x="0.0" y="0.0" width="375" height="44"/> - <accessibility key="accessibilityConfiguration"> - <accessibilityTraits key="traits" none="YES"/> - <bool key="isElement" value="NO"/> - </accessibility> - <connections> - <action selector="headerDidTouch:" destination="iN0-l3-epB" eventType="touchUpInside" id="g6s-7J-Fwg"/> - </connections> - </button> - </subviews> - <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="lZp-yS-E54"> + <rect key="frame" x="165" y="44" width="84" height="150"/> <constraints> - <constraint firstItem="xiK-ca-ccq" firstAttribute="leading" secondItem="rkz-KZ-Ayy" secondAttribute="leadingMargin" constant="8" id="96L-CX-bjS"/> - <constraint firstAttribute="bottom" secondItem="Yus-75-Z9J" secondAttribute="bottom" id="FvG-Uj-huH"/> - <constraint firstItem="2ag-iN-nJf" firstAttribute="top" secondItem="rkz-KZ-Ayy" secondAttribute="top" constant="10" id="K8N-yy-qzD"/> - <constraint firstItem="QaU-FW-PcA" firstAttribute="top" secondItem="rkz-KZ-Ayy" secondAttribute="top" id="KE0-Mv-gQE"/> - <constraint firstItem="Yus-75-Z9J" firstAttribute="leading" secondItem="rkz-KZ-Ayy" secondAttribute="leading" id="Rx0-3q-tA8"/> - <constraint firstAttribute="trailingMargin" secondItem="2ag-iN-nJf" secondAttribute="trailing" constant="8" id="TIG-45-jVS"/> - <constraint firstItem="Yus-75-Z9J" firstAttribute="top" secondItem="rkz-KZ-Ayy" secondAttribute="top" id="X3c-u2-Ek8"/> - <constraint firstItem="hgL-Ff-jIb" firstAttribute="leading" secondItem="rkz-KZ-Ayy" secondAttribute="leading" id="ax6-VY-Phy"/> - <constraint firstAttribute="trailing" secondItem="hgL-Ff-jIb" secondAttribute="trailing" id="dIb-vm-arH"/> - <constraint firstAttribute="bottom" secondItem="hgL-Ff-jIb" secondAttribute="bottom" id="ela-vm-u3P"/> - <constraint firstItem="QaU-FW-PcA" firstAttribute="leading" secondItem="rkz-KZ-Ayy" secondAttribute="leading" id="hdF-lS-dwe"/> - <constraint firstItem="2ag-iN-nJf" firstAttribute="leading" secondItem="xiK-ca-ccq" secondAttribute="trailing" constant="16" id="lJ6-qk-wDP"/> - <constraint firstItem="hgL-Ff-jIb" firstAttribute="top" secondItem="xiK-ca-ccq" secondAttribute="bottom" constant="12" id="m1h-Vt-iic"/> - <constraint firstAttribute="bottom" secondItem="2ag-iN-nJf" secondAttribute="bottom" constant="10" id="nNG-B9-9qB"/> - <constraint firstAttribute="trailing" secondItem="QaU-FW-PcA" secondAttribute="trailing" id="pPf-Q9-F4G"/> - <constraint firstAttribute="trailing" secondItem="Yus-75-Z9J" secondAttribute="trailing" id="xRF-BX-1N8"/> - <constraint firstItem="xiK-ca-ccq" firstAttribute="top" secondItem="QaU-FW-PcA" secondAttribute="bottom" constant="12" id="xbd-Vu-qOe"/> + <constraint firstAttribute="width" constant="84" id="eh5-JM-wYB"/> + <constraint firstAttribute="height" constant="150" id="seO-TB-8E3"/> </constraints> - </view> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eef-Zj-N7X"> + <rect key="frame" x="16" y="210" width="382" height="652"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> </subviews> - <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> <constraints> - <constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="rkz-KZ-Ayy" secondAttribute="trailing" placeholder="YES" id="32s-vC-eRb"/> - <constraint firstItem="rkz-KZ-Ayy" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" placeholder="YES" id="3LJ-Tb-Pc8"/> - <constraint firstItem="rkz-KZ-Ayy" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" id="8R4-t4-EiC"/> - <constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="rkz-KZ-Ayy" secondAttribute="bottom" id="BeK-YT-USm"/> + <constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="eef-Zj-N7X" secondAttribute="bottom" id="GNl-cm-DIY"/> + <constraint firstItem="eef-Zj-N7X" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="Wxm-C7-bR4"/> + <constraint firstItem="lZp-yS-E54" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" id="cJC-rq-GEX"/> + <constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="eef-Zj-N7X" secondAttribute="trailing" constant="16" id="fP9-Vv-Crw"/> + <constraint firstItem="lZp-yS-E54" firstAttribute="centerX" secondItem="vUN-kp-3ea" secondAttribute="centerX" id="gqu-pg-JOg"/> + <constraint firstItem="eef-Zj-N7X" firstAttribute="top" secondItem="lZp-yS-E54" secondAttribute="bottom" constant="16" id="wmR-uE-7ta"/> </constraints> - <nil key="simulatedTopBarMetrics"/> <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> - <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> <connections> - <outlet property="bottomStroke" destination="hgL-Ff-jIb" id="xuE-EE-rcw"/> - <outlet property="chevronView" destination="2ag-iN-nJf" id="fe5-yH-BhN"/> - <outlet property="contentView" destination="rkz-KZ-Ayy" id="NET-gR-3xr"/> - <outlet property="titleLabel" destination="xiK-ca-ccq" id="HrR-PQ-Mtc"/> - <outlet property="topStroke" destination="QaU-FW-PcA" id="X0k-sy-5Eo"/> + <outlet property="imageView" destination="lZp-yS-E54" id="xLp-il-zyb"/> + <outlet property="titleLabel" destination="eef-Zj-N7X" id="1dk-PM-qr2"/> </connections> - <point key="canvasLocation" x="53.600000000000001" y="48.575712143928037"/> + <point key="canvasLocation" x="139" y="109"/> </view> </objects> </document> diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistManager.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistManager.swift index 8d5e3fee53d4..5d7de6a4ce42 100644 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistManager.swift +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistManager.swift @@ -1,24 +1,20 @@ class QuickStartChecklistManager: NSObject { typealias QuickStartChecklistDidSelectTour = (QuickStartTour) -> Void - typealias QuickStartChecklistDidTapHeader = (Bool) -> Void private var blog: Blog private var tours: [QuickStartTour] - private var todoTours: [QuickStartTour] = [] - private var completedTours: [QuickStartTour] = [] + private var title: String private var completedToursKeys = Set<String>() private var didSelectTour: QuickStartChecklistDidSelectTour - private var didTapHeader: QuickStartChecklistDidTapHeader - private var completedSectionCollapse: Bool = false init(blog: Blog, tours: [QuickStartTour], - didSelectTour: @escaping QuickStartChecklistDidSelectTour, - didTapHeader: @escaping QuickStartChecklistDidTapHeader) { + title: String, + didSelectTour: @escaping QuickStartChecklistDidSelectTour) { self.blog = blog self.tours = tours + self.title = title self.didSelectTour = didSelectTour - self.didTapHeader = didTapHeader super.init() reloadData() } @@ -26,38 +22,30 @@ class QuickStartChecklistManager: NSObject { func reloadData() { let completed = (blog.completedQuickStartTours ?? []).map { $0.tourID } completedToursKeys = Set(completed) - todoTours = tours.filter(!isCompleted) - completedTours = tours.filter(isCompleted) } func tour(at indexPath: IndexPath) -> QuickStartTour { - return tours(at: indexPath.section)[indexPath.row] - } - - func shouldShowCompleteTasksScreen() -> Bool { - return todoTours.isEmpty + return tours[indexPath.row] } } extension QuickStartChecklistManager: UITableViewDataSource { func numberOfSections(in tableView: UITableView) -> Int { - return Sections.allCases.count + return 1 } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return tours(at: section).count + return tours.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if let cell = tableView.dequeueReusableCell(withIdentifier: QuickStartChecklistCell.reuseIdentifier) as? QuickStartChecklistCell { - let tour = self.tour(at: indexPath) - cell.tour = tour - cell.completed = isCompleted(tour: tour) - cell.topSeparatorIsHidden = hideTopSeparator(at: indexPath) - cell.lastRow = isLastTour(at: indexPath) - return cell + guard let cell = tableView.dequeueReusableCell(withIdentifier: QuickStartChecklistCell.reuseIdentifier) as? QuickStartChecklistCell else { + return UITableViewCell() } - return UITableViewCell() + let tour = self.tour(at: indexPath) + let completed = isCompleted(tour: tour) + cell.configure(tour: tour, completed: completed) + return cell } } @@ -74,173 +62,26 @@ extension QuickStartChecklistManager: UITableViewDelegate { didSelectTour(tour) } - func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - if section == Sections.todo.rawValue, - !todoTours.isEmpty, - !completedTours.isEmpty { - return UIView() - } - return nil - } - - func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - if section == Sections.todo.rawValue, - !todoTours.isEmpty, - !completedTours.isEmpty { - return Sections.footerHeight - } - return 0.0 - } - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - if section == Sections.completed.rawValue, - !completedTours.isEmpty { - let headerView = Bundle.main.loadNibNamed("QuickStartChecklistHeader", owner: nil, options: nil)?.first as? QuickStartChecklistHeader - headerView?.collapse = completedSectionCollapse - headerView?.count = completedTours.count - headerView?.collapseListener = { [weak self] collapse in - self?.completedSectionCollapse = collapse - self?.tableView(tableView, reloadCompletedSection: collapse) - } - return headerView - } - return WPDeviceIdentification.isiPhone() ? nil : UIView() + let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: QuickStartChecklistHeader.identifier) as? QuickStartChecklistHeader + headerView?.title = title + return headerView } func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - if section == Sections.completed.rawValue, - !completedTours.isEmpty { - return Sections.headerHeight - } - return WPDeviceIdentification.isiPhone() ? 0.0 : Sections.iPadTopInset - } - - func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - guard let section = Sections(rawValue: indexPath.section), section == .todo else { - return .none - } - return .delete - } - - func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? { - guard let section = Sections(rawValue: indexPath.section), section == .todo else { - return nil - } - - let buttonTitle = NSLocalizedString("Skip", comment: "Button title that appears when you swipe to left the row. It indicates the possibility to skip a specific tour.") - let skip = UITableViewRowAction(style: .destructive, title: buttonTitle) { [weak self] (_, indexPath) in - self?.tableView(tableView, completeTourAt: indexPath) - } - skip.backgroundColor = .error - return [skip] + return Constants.headerHeight } } private extension QuickStartChecklistManager { - func isLastTour(at indexPath: IndexPath) -> Bool { - let tours = self.tours(at: indexPath.section) - return (tours.count - 1) == indexPath.row - } - - func hideTopSeparator(at indexPath: IndexPath) -> Bool { - guard let section = Sections(rawValue: indexPath.section) else { - return true - } - - switch section { - case .todo: - return !(WPDeviceIdentification.isiPad() && !todoTours.isEmpty && indexPath.row == 0) - case .completed: - return true - } - } - - func tours(at section: Int) -> [QuickStartTour] { - guard let section = Sections(rawValue: section) else { - return [] - } - - switch section { - case .todo: - return todoTours - case .completed: - return completedSectionCollapse ? completedTours : [] - } - } func isCompleted(tour: QuickStartTour) -> Bool { return completedToursKeys.contains(tour.key) } - - func tableView(_ tableView: UITableView, reloadCompletedSection collapsing: Bool) { - var indexPaths: [IndexPath] = [] - for (index, _) in completedTours.enumerated() { - indexPaths.append(IndexPath(row: index, section: Sections.completed.rawValue)) - } - - tableView.perform(update: { tableView in - if collapsing { - tableView.insertRows(at: indexPaths, with: .fade) - } else { - tableView.deleteRows(at: indexPaths, with: .fade) - } - }) - - didTapHeader(collapsing) - } - - func tableView(_ tableView: UITableView, completeTourAt indexPath: IndexPath) { - guard let tourGuide = QuickStartTourGuide.find() else { - return - } - let tour = todoTours[indexPath.row] - todoTours.remove(at: indexPath.row) - completedTours.append(tour) - completedToursKeys.insert(tour.key) - - WPAnalytics.track(.quickStartListItemSkipped, - withProperties: ["task_name": tour.analyticsKey]) - - tableView.perform(update: { tableView in - tableView.deleteRows(at: [indexPath], with: .automatic) - let sections = IndexSet(integer: Sections.completed.rawValue) - tableView.reloadSections(sections, with: .fade) - }) { [weak self] tableView, _ in - DispatchQueue.main.async { - guard let self = self else { - return - } - if self.shouldShowCompleteTasksScreen() { - self.didTapHeader(self.completedSectionCollapse) - } - tourGuide.complete(tour: tour, for: self.blog, postNotification: false) - let sections = IndexSet(integer: Sections.todo.rawValue) - tableView.reloadSections(sections, with: .automatic) - } - } - } } -private extension UITableView { - /// Allows multiple insert/delete/reload/move calls to be animated simultaneously. - /// - /// - Parameters: - /// - update: The block that performs the relevant insert, delete, reload, or move operations. - /// - completion: A completion handler block to execute when all of the operations are finished. The Boolean value indicating whether the animations completed successfully. The value of this parameter is false if the animations were interrupted for any reason. On iOS 10 the value is always true. - func perform(update: (UITableView) -> Void, _ completion: ((UITableView, Bool) -> Void)? = nil) { - performBatchUpdates({ - update(self) - }) { success in - completion?(self, success) - } +private extension QuickStartChecklistManager { + enum Constants { + static let headerHeight: CGFloat = 204 } } - -private enum Sections: Int, CaseIterable { - static let footerHeight: CGFloat = 20.0 - static let headerHeight: CGFloat = 44.0 - static let iPadTopInset: CGFloat = 36.0 - - case todo - case completed -} diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistViewController.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistViewController.swift index 1716cd5816d2..2120abfc1090 100644 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartChecklistViewController.swift @@ -1,24 +1,8 @@ import Gridicons -@objc enum QuickStartType: Int { - case customize - case grow -} - -private extension QuickStartType { - var analyticsKey: String { - switch self { - case .customize: - return "customize" - case .grow: - return "grow" - } - } -} - class QuickStartChecklistViewController: UITableViewController { private var blog: Blog - private var type: QuickStartType + private var collection: QuickStartToursCollection private var observer: NSObjectProtocol? private var dataManager: QuickStartChecklistManager? { didSet { @@ -26,53 +10,32 @@ class QuickStartChecklistViewController: UITableViewController { tableView?.delegate = dataManager } } - private lazy var tasksCompleteScreen: TasksCompleteScreenConfiguration = { - switch type { - case .customize: - return TasksCompleteScreenConfiguration(title: Constants.tasksCompleteScreenTitle, - subtitle: Constants.tasksCompleteScreenSubtitle, - imageName: "wp-illustration-tasks-complete-site") - case .grow: - return TasksCompleteScreenConfiguration(title: Constants.tasksCompleteScreenTitle, - subtitle: Constants.tasksCompleteScreenSubtitle, - imageName: "wp-illustration-tasks-complete-audience") - } - }() - private lazy var configuration: QuickStartChecklistConfiguration = { - switch type { - case .customize: - return QuickStartChecklistConfiguration(title: Constants.customizeYourSite, - tours: QuickStartTourGuide.customizeListTours) - case .grow: - return QuickStartChecklistConfiguration(title: Constants.growYourAudience, - tours: QuickStartTourGuide.growListTours) - } - }() - private lazy var successScreen: NoResultsViewController = { - let successScreen = NoResultsViewController.controller() - successScreen.view.frame = tableView.bounds - successScreen.view.backgroundColor = .listBackground - successScreen.configure(title: tasksCompleteScreen.title, - subtitle: tasksCompleteScreen.subtitle, - image: tasksCompleteScreen.imageName) - successScreen.updateView() - return successScreen - }() + private lazy var closeButtonItem: UIBarButtonItem = { - let cancelButton = WPStyleGuide.buttonForBar(with: Constants.closeButtonModalImage, target: self, selector: #selector(closeWasPressed)) - cancelButton.leftSpacing = Constants.cancelButtonPadding.left - cancelButton.rightSpacing = Constants.cancelButtonPadding.right - cancelButton.setContentHuggingPriority(.required, for: .horizontal) + let closeButton = UIButton() + + let configuration = UIImage.SymbolConfiguration(pointSize: Constants.closeButtonSymbolSize, weight: .bold) + closeButton.setImage(UIImage(systemName: "xmark", withConfiguration: configuration), for: .normal) + closeButton.tintColor = .secondaryLabel + closeButton.backgroundColor = .quaternarySystemFill + + NSLayoutConstraint.activate([ + closeButton.widthAnchor.constraint(equalToConstant: Constants.closeButtonRadius), + closeButton.heightAnchor.constraint(equalTo: closeButton.widthAnchor) + ]) + closeButton.layer.cornerRadius = Constants.closeButtonRadius * 0.5 let accessibleFormat = NSLocalizedString("Dismiss %@ Quick Start step", comment: "Accessibility description for the %@ step of Quick Start. Tapping this dismisses the checklist for that particular step.") - cancelButton.accessibilityLabel = String(format: accessibleFormat, self.configuration.title) + closeButton.accessibilityLabel = String(format: accessibleFormat, self.collection.title) - return UIBarButtonItem(customView: cancelButton) + closeButton.addTarget(self, action: #selector(closeWasPressed), for: .touchUpInside) + + return UIBarButtonItem(customView: closeButton) }() - init(blog: Blog, type: QuickStartType) { + init(blog: Blog, collection: QuickStartToursCollection) { self.blog = blog - self.type = type + self.collection = collection super.init(style: .plain) startObservingForQuickStart() } @@ -85,59 +48,44 @@ class QuickStartChecklistViewController: UITableViewController { super.viewDidLoad() configureTableView() - - navigationItem.title = configuration.title - navigationItem.leftBarButtonItem = closeButtonItem + configureNavigationBar() dataManager = QuickStartChecklistManager(blog: blog, - tours: configuration.tours, + tours: collection.tours, + title: collection.shortTitle, didSelectTour: { [weak self] tour in DispatchQueue.main.async { [weak self] in - WPAnalytics.track(.quickStartChecklistItemTapped, withProperties: ["task_name": tour.analyticsKey]) - guard let self = self else { return } - let tourGuide = QuickStartTourGuide.find() - tourGuide?.prepare(tour: tour, for: self.blog) + WPAnalytics.trackQuickStartStat(.quickStartChecklistItemTapped, + properties: ["task_name": tour.analyticsKey], + blog: self.blog) + + QuickStartTourGuide.shared.prepare(tour: tour, for: self.blog) self.dismiss(animated: true) { - tourGuide?.begin() + QuickStartTourGuide.shared.begin() } } - }, didTapHeader: { [unowned self] expand in - let event: WPAnalyticsStat = expand ? .quickStartListExpanded : .quickStartListCollapsed - WPAnalytics.track(event, withProperties: [Constants.analyticsTypeKey: self.type.analyticsKey]) - self.checkForSuccessScreen(expand) }) - - checkForSuccessScreen() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - // should display bg and trigger qs notification - - WPAnalytics.track(.quickStartChecklistViewed, - withProperties: [Constants.analyticsTypeKey: type.analyticsKey]) - } + tableView.flashScrollIndicators() - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - coordinator.animate(alongsideTransition: { [weak self] context in - let hideImageView = WPDeviceIdentification.isiPhone() && UIDevice.current.orientation.isLandscape - self?.successScreen.hideImageView(hideImageView) - self?.successScreen.updateAccessoryViewsVisibility() - self?.tableView.backgroundView = self?.successScreen.view - }) + WPAnalytics.trackQuickStartStat(.quickStartChecklistViewed, + properties: [Constants.analyticsTypeKey: collection.analyticsKey], + blog: blog) } } private extension QuickStartChecklistViewController { func configureTableView() { - let tableView = UITableView(frame: .zero) + let tableView = UITableView(frame: .zero, style: .grouped) tableView.estimatedRowHeight = Constants.estimatedRowHeight tableView.separatorStyle = .none @@ -145,15 +93,25 @@ private extension QuickStartChecklistViewController { let cellNib = UINib(nibName: "QuickStartChecklistCell", bundle: Bundle(for: QuickStartChecklistCell.self)) tableView.register(cellNib, forCellReuseIdentifier: QuickStartChecklistCell.reuseIdentifier) - - let hideImageView = WPDeviceIdentification.isiPhone() && UIDevice.current.orientation.isLandscape - successScreen.hideImageView(hideImageView) - - tableView.backgroundView = successScreen.view + tableView.register(QuickStartChecklistHeader.defaultNib, forHeaderFooterViewReuseIdentifier: QuickStartChecklistHeader.defaultReuseID) self.tableView = tableView WPStyleGuide.configureTableViewColors(view: self.tableView) } + func configureNavigationBar() { + navigationItem.rightBarButtonItem = closeButtonItem + + let appearance = UINavigationBarAppearance() + appearance.backgroundColor = .systemBackground + appearance.shadowColor = .clear + navigationItem.standardAppearance = appearance + navigationItem.compactAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + if #available(iOS 15.0, *) { + navigationItem.compactScrollEdgeAppearance = appearance + } + } + func startObservingForQuickStart() { observer = NotificationCenter.default.addObserver(forName: .QuickStartTourElementChangedNotification, object: nil, queue: nil) { [weak self] (notification) in guard let userInfo = notification.userInfo, @@ -170,21 +128,10 @@ private extension QuickStartChecklistViewController { tableView.reloadData() } - func checkForSuccessScreen(_ expand: Bool = false) { - if let dataManager = dataManager, - !dataManager.shouldShowCompleteTasksScreen() { - self.tableView.backgroundView?.alpha = 0 - return - } - - UIView.animate(withDuration: Constants.successScreenFadeAnimationDuration) { - self.tableView.backgroundView?.alpha = expand ? 0.0 : 1.0 - } - } - @objc private func closeWasPressed(sender: UIButton) { - WPAnalytics.track(.quickStartTypeDismissed, - withProperties: [Constants.analyticsTypeKey: type.analyticsKey]) + WPAnalytics.trackQuickStartStat(.quickStartTypeDismissed, + properties: [Constants.analyticsTypeKey: collection.analyticsKey], + blog: blog) dismiss(animated: true, completion: nil) } } @@ -202,12 +149,7 @@ private struct QuickStartChecklistConfiguration { private enum Constants { static let analyticsTypeKey = "type" - static let cancelButtonPadding = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 5) - static let closeButtonModalImage = Gridicon.iconOfType(.cross) + static let closeButtonRadius: CGFloat = 30 + static let closeButtonSymbolSize: CGFloat = 16 static let estimatedRowHeight: CGFloat = 90.0 - static let successScreenFadeAnimationDuration: TimeInterval = 0.3 - static let customizeYourSite = NSLocalizedString("Customize Your Site", comment: "Title of the Quick Start Checklist that guides users through a few tasks to customize their new website.") - static let growYourAudience = NSLocalizedString("Grow Your Audience", comment: "Title of the Quick Start Checklist that guides users through a few tasks to grow the audience of their new website.") - static let tasksCompleteScreenTitle = NSLocalizedString("All tasks complete", comment: "Title of the congratulation screen that appears when all the tasks are completed") - static let tasksCompleteScreenSubtitle = NSLocalizedString("Congratulations on completing your list. A job well done.", comment: "Subtitle of the congratulation screen that appears when all the tasks are completed") } diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartCongratulationsCell.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartCongratulationsCell.swift deleted file mode 100644 index d880f8bc550b..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartCongratulationsCell.swift +++ /dev/null @@ -1,18 +0,0 @@ -class QuickStartCongratulationsCell: UITableViewCell { - @IBOutlet var topLabel: UILabel? - @IBOutlet var bottomLabel: UILabel? - - override func awakeFromNib() { - super.awakeFromNib() - - topLabel?.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) - bottomLabel?.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) - - let tour = QuickStartCongratulationsTour() - - topLabel?.text = tour.title - bottomLabel?.text = tour.description - } - - static let reuseIdentifier = "QuickStartCongratulationsCell" -} diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartCongratulationsCell.xib b/WordPress/Classes/ViewRelated/Blog/QuickStartCongratulationsCell.xib deleted file mode 100644 index 234ef11a91dc..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartCongratulationsCell.xib +++ /dev/null @@ -1,57 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14865.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait" appearance="light"/> - <dependencies> - <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14819.2"/> - <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> - </dependencies> - <objects> - <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> - <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="88" id="ZK2-Xw-LEM" customClass="QuickStartCongratulationsCell" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="428" height="88"/> - <autoresizingMask key="autoresizingMask"/> - <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="ZK2-Xw-LEM" id="HIE-6E-scc"> - <rect key="frame" x="0.0" y="0.0" width="428" height="88"/> - <autoresizingMask key="autoresizingMask"/> - <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="tmx-Sd-E9i"> - <rect key="frame" x="16" y="16" width="396" height="36"/> - <constraints> - <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="18" id="719-qw-JbX"/> - </constraints> - <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> - <color key="textColor" red="0.1803921568627451" green="0.26666666666666666" blue="0.32549019607843138" alpha="1" colorSpace="calibratedRGB"/> - <nil key="highlightedColor"/> - </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lKH-i8-Ftz"> - <rect key="frame" x="16" y="54" width="396" height="18"/> - <constraints> - <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="18" id="Mq1-m6-Bag"/> - </constraints> - <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> - <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> - <nil key="highlightedColor"/> - </label> - </subviews> - <color key="backgroundColor" red="0.9137254901960784" green="0.93725490196078431" blue="0.95294117647058818" alpha="1" colorSpace="calibratedRGB"/> - <constraints> - <constraint firstItem="lKH-i8-Ftz" firstAttribute="top" secondItem="tmx-Sd-E9i" secondAttribute="bottom" constant="2" id="5xn-7N-iu6"/> - <constraint firstItem="tmx-Sd-E9i" firstAttribute="top" secondItem="HIE-6E-scc" secondAttribute="top" constant="16" id="N5H-YY-H3a"/> - <constraint firstItem="lKH-i8-Ftz" firstAttribute="leading" secondItem="HIE-6E-scc" secondAttribute="leading" constant="16" id="en4-Mr-nLV"/> - <constraint firstItem="tmx-Sd-E9i" firstAttribute="leading" secondItem="HIE-6E-scc" secondAttribute="leading" constant="16" id="lMQ-en-z4b"/> - <constraint firstAttribute="trailing" secondItem="tmx-Sd-E9i" secondAttribute="trailing" constant="16" id="pih-72-BdQ"/> - <constraint firstAttribute="bottom" secondItem="lKH-i8-Ftz" secondAttribute="bottom" constant="16" id="s9K-MH-Fhg"/> - <constraint firstAttribute="trailing" secondItem="lKH-i8-Ftz" secondAttribute="trailing" constant="16" id="zbe-Ov-ohI"/> - </constraints> - </tableViewCellContentView> - <inset key="separatorInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/> - <connections> - <outlet property="bottomLabel" destination="lKH-i8-Ftz" id="i7t-1x-xWb"/> - <outlet property="topLabel" destination="tmx-Sd-E9i" id="LgV-2z-JcU"/> - </connections> - <point key="canvasLocation" x="-248" y="91"/> - </tableViewCell> - </objects> -</document> diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartFactory.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartFactory.swift new file mode 100644 index 000000000000..66472382e396 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartFactory.swift @@ -0,0 +1,39 @@ +import Foundation + +enum QuickStartType: Int { + case undefined + case newSite + case existingSite + + var key: String { + switch self { + case .undefined: + return "undefined" + case .newSite: + return "new_site" + case .existingSite: + return "existing_site" + } + } +} + +class QuickStartFactory { + static func collections(for blog: Blog) -> [QuickStartToursCollection] { + switch blog.quickStartType { + case .undefined: + guard let completedTours = blog.completedQuickStartTours, completedTours.count > 0 else { + return [] + } + // This is to support tours started before quickStartType was added. + fallthrough + case .newSite: + return [QuickStartCustomizeToursCollection(blog: blog), QuickStartGrowToursCollection(blog: blog)] + case .existingSite: + return [QuickStartGetToKnowAppCollection(blog: blog)] + } + } + + static func allTours(for blog: Blog) -> [QuickStartTour] { + collections(for: blog).flatMap { $0.tours } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartListTitleCell.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartListTitleCell.swift deleted file mode 100644 index 0987754ff95f..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartListTitleCell.swift +++ /dev/null @@ -1,66 +0,0 @@ -@objc -class QuickStartListTitleCell: UITableViewCell { - @IBOutlet private var titleLabel: UILabel? - @IBOutlet private var countLabel: UILabel? - @IBOutlet private var circleImageView: CircularImageView? - @IBOutlet private var iconImageView: UIImageView? - @objc var state: QuickStartTitleState = .undefined { - didSet { - refreshIconColor() - } - } - - @objc static let reuseIdentifier = "QuickStartListTitleCell" - - override var textLabel: UILabel? { - return titleLabel - } - - override var detailTextLabel: UILabel? { - return countLabel - } - - override var imageView: UIImageView? { - return iconImageView - } - - override func layoutSubviews() { - super.layoutSubviews() - accessoryView = nil - accessoryType = .none - refreshIconColor() - refreshTitleLabel() - } - - private func refreshTitleLabel() { - guard let label = titleLabel, - let text = label.text else { - return - } - - if state == .completed { - label.textColor = .neutral(.shade30) - label.attributedText = NSAttributedString(string: text, attributes: [NSAttributedString.Key.strikethroughStyle: NSUnderlineStyle.single.rawValue]) - } - } -} - -private extension QuickStartListTitleCell { - func refreshIconColor() { - switch state { - case .customizeIncomplete: - circleImageView?.backgroundColor = .primary(.shade40) - case .growIncomplete: - circleImageView?.backgroundColor = .accent - default: - circleImageView?.backgroundColor = .neutral(.shade30) - } - - guard let iconImageView = iconImageView, - let iconImage = iconImageView.image else { - return - } - - iconImageView.image = iconImage.imageWithTintColor(.white) - } -} diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartListTitleCell.xib b/WordPress/Classes/ViewRelated/Blog/QuickStartListTitleCell.xib deleted file mode 100644 index afa3572909fb..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartListTitleCell.xib +++ /dev/null @@ -1,76 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14865.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait" appearance="light"/> - <dependencies> - <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14819.2"/> - <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> - </dependencies> - <objects> - <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> - <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="59" id="yxe-JV-A3j" customClass="QuickStartListTitleCell" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="387" height="59"/> - <autoresizingMask key="autoresizingMask"/> - <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="yxe-JV-A3j" id="bAc-r5-9Gc"> - <rect key="frame" x="0.0" y="0.0" width="387" height="59"/> - <autoresizingMask key="autoresizingMask"/> - <subviews> - <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="0G7-da-TFG" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="16" y="11" width="38" height="38"/> - <constraints> - <constraint firstAttribute="width" constant="38" id="fAA-Kg-A8Z"/> - <constraint firstAttribute="height" constant="38" id="vXF-aG-DCz"/> - </constraints> - </imageView> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="74y-RA-tA4"> - <rect key="frame" x="70" y="9" width="297" height="22"/> - <constraints> - <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="22" id="Sfz-hK-WdF"/> - </constraints> - <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> - <nil key="textColor"/> - <nil key="highlightedColor"/> - </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="4zs-C7-CtU"> - <rect key="frame" x="70" y="32" width="297" height="20"/> - <constraints> - <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="20" id="aeZ-6J-P0C"/> - </constraints> - <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> - <color key="textColor" red="0.52941176470588236" green="0.65098039215686276" blue="0.73725490196078436" alpha="1" colorSpace="calibratedRGB"/> - <nil key="highlightedColor"/> - </label> - <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="2gT-Ue-Edc"> - <rect key="frame" x="23" y="18" width="24" height="24"/> - <constraints> - <constraint firstAttribute="width" constant="24" id="D3x-JW-Pwt"/> - <constraint firstAttribute="height" constant="24" id="E8H-Nu-AhZ"/> - </constraints> - </imageView> - </subviews> - <constraints> - <constraint firstItem="2gT-Ue-Edc" firstAttribute="centerX" secondItem="0G7-da-TFG" secondAttribute="centerX" id="0GV-oa-AOY"/> - <constraint firstItem="0G7-da-TFG" firstAttribute="top" secondItem="bAc-r5-9Gc" secondAttribute="top" constant="11" id="7Dh-OZ-B2F"/> - <constraint firstItem="4zs-C7-CtU" firstAttribute="top" secondItem="74y-RA-tA4" secondAttribute="bottom" constant="1" id="7xE-ml-eAp"/> - <constraint firstAttribute="trailing" secondItem="4zs-C7-CtU" secondAttribute="trailing" constant="20" id="Iup-0F-6IA"/> - <constraint firstAttribute="bottom" secondItem="4zs-C7-CtU" secondAttribute="bottom" constant="8" id="TmD-AR-QFZ"/> - <constraint firstItem="74y-RA-tA4" firstAttribute="leading" secondItem="bAc-r5-9Gc" secondAttribute="leading" constant="70" id="b9K-cf-aMo"/> - <constraint firstItem="2gT-Ue-Edc" firstAttribute="centerY" secondItem="0G7-da-TFG" secondAttribute="centerY" id="gN9-g9-xn3"/> - <constraint firstItem="74y-RA-tA4" firstAttribute="top" secondItem="bAc-r5-9Gc" secondAttribute="top" constant="9" id="oXr-Qn-ygh"/> - <constraint firstAttribute="trailing" secondItem="74y-RA-tA4" secondAttribute="trailing" constant="20" id="uFB-ij-Uj5"/> - <constraint firstItem="4zs-C7-CtU" firstAttribute="leading" secondItem="bAc-r5-9Gc" secondAttribute="leading" constant="70" id="wIF-se-KRy"/> - <constraint firstItem="0G7-da-TFG" firstAttribute="leading" secondItem="bAc-r5-9Gc" secondAttribute="leading" constant="16" id="yYf-Se-IiO"/> - </constraints> - </tableViewCellContentView> - <inset key="separatorInset" minX="70" minY="0.0" maxX="0.0" maxY="0.0"/> - <connections> - <outlet property="circleImageView" destination="0G7-da-TFG" id="SnF-gE-qQ9"/> - <outlet property="countLabel" destination="4zs-C7-CtU" id="Ad1-1E-dfN"/> - <outlet property="iconImageView" destination="2gT-Ue-Edc" id="VLN-Rw-XzF"/> - <outlet property="titleLabel" destination="74y-RA-tA4" id="14x-He-yJd"/> - </connections> - <point key="canvasLocation" x="42.399999999999999" y="2.2488755622188905"/> - </tableViewCell> - </objects> -</document> diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartNavigationSettings.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartNavigationSettings.swift index 79cc5a08af18..971967a1bca1 100644 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartNavigationSettings.swift +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartNavigationSettings.swift @@ -3,46 +3,19 @@ class QuickStartNavigationSettings: NSObject { private var spotlightView: QuickStartSpotlightView? func updateWith(navigationController: UINavigationController, andViewController viewController: UIViewController) { - guard let tourGuide = QuickStartTourGuide.find() else { - return - } switch viewController { case is BlogListViewController: - tourGuide.visited(.noSuchElement) - tourGuide.endCurrentTour() - case is ReaderMenuViewController: - tourGuide.visited(.readerBack) - removeReaderSpotlight() - case is ReaderSearchViewController, is ReaderStreamViewController, is ReaderSavedPostsViewController: - readerNav = navigationController - checkToSpotlightReader() + QuickStartTourGuide.shared.visited(.noSuchElement) + QuickStartTourGuide.shared.endCurrentTour() default: break } } - - func shouldSkipReaderBack() -> Bool { - guard let readerNav = readerNav else { - return false - } - - return !readerNav.hasHorizontallyCompactView() - } - } private extension QuickStartNavigationSettings { - func checkToSpotlightReader() { - guard let tourGuide = QuickStartTourGuide.find(), - tourGuide.isCurrentElement(.readerBack) else { - return - } - - spotlightReaderBackButton() - } - func spotlightReaderBackButton() { guard let readerNav = readerNav else { return diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.swift new file mode 100644 index 000000000000..adff6fbea9ae --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.swift @@ -0,0 +1,171 @@ +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(set) weak var promptTitleLabel: UILabel! + @IBOutlet private(set) weak var promptDescriptionLabel: UILabel! + + /// Buttons + @IBOutlet private(set) weak var showMeAroundButton: FancyButton! + @IBOutlet private(set) weak var noThanksButton: FancyButton! + + /// Constraints + @IBOutlet private(set) weak var scrollViewTopVerticalConstraint: NSLayoutConstraint! + @IBOutlet private weak var scrollViewLeadingConstraint: NSLayoutConstraint! + @IBOutlet private weak var scrollViewTrailingConstraint: NSLayoutConstraint! + + // 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) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + setupScrollViewMargins() + } + + // 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 = AppStyleGuide.prominentFont(textStyle: .title2, weight: .semibold) + promptTitleLabel.adjustsFontForContentSizeCategory = true + promptTitleLabel.adjustsFontSizeToFitWidth = true + promptTitleLabel.textColor = .text + + promptDescriptionLabel.numberOfLines = 0 + promptDescriptionLabel.font = WPStyleGuide.fontForTextStyle(.body) + promptDescriptionLabel.adjustsFontForContentSizeCategory = true + promptDescriptionLabel.adjustsFontSizeToFitWidth = true + promptDescriptionLabel.textColor = .textSubtle + + showMeAroundButton.isPrimary = true + noThanksButton.isPrimary = false + } + + // MARK: - Setup + + private func setup() { + setupScrollViewMargins() + setupSiteInfoViews() + setupPromptInfoViews() + setupButtons() + } + + private func setupScrollViewMargins() { + let margin = view.getHorizontalMargin() + Constants.marginPadding + scrollViewLeadingConstraint.constant = margin + scrollViewTrailingConstraint.constant = margin + } + + 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.trackQuickStartStat(.quickStartRequestAlertButtonTapped, + properties: ["type": "positive"], + blog: blog) + } + + @IBAction private func noThanksButtonTapped(_ sender: Any) { + quickStartSettings.setPromptWasDismissed(true, for: blog) + onDismiss?(blog, false) + dismiss(animated: true) + + WPAnalytics.trackQuickStartStat(.quickStartRequestAlertButtonTapped, + properties: ["type": "neutral"], + blog: blog) + } +} + +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.") + } + + private enum Constants { + static let marginPadding = 20.0 + } + +} diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.xib b/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.xib new file mode 100644 index 000000000000..333c82332a46 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartPromptViewController.xib @@ -0,0 +1,140 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="QuickStartPromptViewController" customModule="WordPress" customModuleProvider="target"> + <connections> + <outlet property="noThanksButton" destination="2uc-tK-alt" id="EyT-E0-oLS"/> + <outlet property="promptDescriptionLabel" destination="Kqr-FO-d5M" id="lbG-CU-xOm"/> + <outlet property="promptTitleLabel" destination="doQ-6M-3Uv" id="i5m-E1-5kU"/> + <outlet property="scrollViewLeadingConstraint" destination="pfX-zX-22J" id="dpf-mm-9K0"/> + <outlet property="scrollViewTopVerticalConstraint" destination="0uM-8v-xeM" id="9iv-st-GfW"/> + <outlet property="scrollViewTrailingConstraint" destination="51M-BA-Z8b" id="xdo-jm-Gb6"/> + <outlet property="showMeAroundButton" destination="0WF-fO-mti" id="MyR-dp-DjP"/> + <outlet property="siteDescriptionLabel" destination="T6b-Pg-T2k" id="Jbd-bS-EqF"/> + <outlet property="siteIconView" destination="5pl-dB-rPb" id="ovD-LF-JHV"/> + <outlet property="siteTitleLabel" destination="3V2-cy-NN5" id="ool-ZA-cxV"/> + <outlet property="view" destination="i5M-Pr-FkT" id="w2c-2a-ctD"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" id="i5M-Pr-FkT"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="w31-gj-Dsv"> + <rect key="frame" x="20" y="124" width="374" height="586"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="wS6-OQ-uc5"> + <rect key="frame" x="0.0" y="0.0" width="374" height="110"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="5oK-P9-biU"> + <rect key="frame" x="0.0" y="0.0" width="374" height="45"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="5pl-dB-rPb"> + <rect key="frame" x="0.0" y="2.5" width="40" height="40"/> + <constraints> + <constraint firstAttribute="width" constant="40" id="LBc-8A-qPF"/> + <constraint firstAttribute="width" secondItem="5pl-dB-rPb" secondAttribute="height" id="VjL-ts-OLa"/> + </constraints> + </imageView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="lqh-pW-MOV"> + <rect key="frame" x="50" y="0.0" width="324" height="45"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="3V2-cy-NN5"> + <rect key="frame" x="0.0" y="0.0" width="324" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="T6b-Pg-T2k"> + <rect key="frame" x="0.0" y="24.5" width="324" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + </subviews> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="kWJ-TZ-NYM"> + <rect key="frame" x="0.0" y="61" width="374" height="49"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="doQ-6M-3Uv"> + <rect key="frame" x="0.0" y="0.0" width="374" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Kqr-FO-d5M"> + <rect key="frame" x="0.0" y="28.5" width="374" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + </subviews> + </stackView> + </subviews> + <constraints> + <constraint firstItem="wS6-OQ-uc5" firstAttribute="leading" secondItem="w31-gj-Dsv" secondAttribute="leading" id="5wb-KX-hO2"/> + <constraint firstAttribute="bottom" secondItem="wS6-OQ-uc5" secondAttribute="bottom" id="Rqm-jQ-ZC6"/> + <constraint firstAttribute="trailing" secondItem="wS6-OQ-uc5" secondAttribute="trailing" id="iWo-8u-bKd"/> + <constraint firstItem="wS6-OQ-uc5" firstAttribute="top" secondItem="w31-gj-Dsv" secondAttribute="top" id="mIb-1N-DCT"/> + <constraint firstItem="wS6-OQ-uc5" firstAttribute="width" secondItem="w31-gj-Dsv" secondAttribute="width" id="nHc-yp-wk9"/> + </constraints> + </scrollView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="88c-8d-UbI"> + <rect key="frame" x="20" y="734" width="374" height="104"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="0WF-fO-mti" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="0.0" width="374" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="B3O-Yu-fRo"/> + </constraints> + <state key="normal" title="Button"/> + <connections> + <action selector="showMeAroundButtonTapped:" destination="-1" eventType="touchUpInside" id="mp9-Ig-efP"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2uc-tK-alt" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="60" width="374" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="oud-dv-V6a"/> + </constraints> + <state key="normal" title="Button"/> + <connections> + <action selector="noThanksButtonTapped:" destination="-1" eventType="touchUpInside" id="pO2-el-6ic"/> + </connections> + </button> + </subviews> + </stackView> + </subviews> + <viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="w31-gj-Dsv" firstAttribute="top" secondItem="fnl-2z-Ty3" secondAttribute="top" constant="80" id="0uM-8v-xeM"/> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" secondItem="w31-gj-Dsv" secondAttribute="trailing" constant="20" id="51M-BA-Z8b"/> + <constraint firstItem="88c-8d-UbI" firstAttribute="top" secondItem="w31-gj-Dsv" secondAttribute="bottom" constant="24" id="cEg-cI-kct"/> + <constraint firstItem="88c-8d-UbI" firstAttribute="trailing" secondItem="w31-gj-Dsv" secondAttribute="trailing" id="cNO-Zd-ImM"/> + <constraint firstItem="88c-8d-UbI" firstAttribute="leading" secondItem="w31-gj-Dsv" secondAttribute="leading" id="gQ4-1g-2Mu"/> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="bottom" secondItem="88c-8d-UbI" secondAttribute="bottom" constant="24" id="oQS-J2-RXn"/> + <constraint firstItem="w31-gj-Dsv" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" constant="20" id="pfX-zX-22J"/> + </constraints> + <point key="canvasLocation" x="132" y="76"/> + </view> + </objects> + <resources> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartSettings.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartSettings.swift new file mode 100644 index 000000000000..caa6bbf01ec2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartSettings.swift @@ -0,0 +1,42 @@ +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? { + let siteID = blog.dotComID?.intValue ?? 0 + return "QuickStartPromptWasDismissed-\(siteID)" + } + +} diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartSkipAllCell.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartSkipAllCell.swift deleted file mode 100644 index 5acc0910f447..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartSkipAllCell.swift +++ /dev/null @@ -1,13 +0,0 @@ -class QuickStartSkipAllCell: UITableViewCell { - @IBOutlet var skipAllLabel: UILabel? - var onTap: (() -> Void)? - - override func awakeFromNib() { - super.awakeFromNib() - - let title = NSLocalizedString("Skip All", comment: "Label for button that will allow the user to skip all items in the Quick Start checklist") - skipAllLabel?.text = title - } - - static let reuseIdentifier = "QuickStartSkipAllCell" -} diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartSkipAllCell.xib b/WordPress/Classes/ViewRelated/Blog/QuickStartSkipAllCell.xib deleted file mode 100644 index ca924800640a..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartSkipAllCell.xib +++ /dev/null @@ -1,45 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14313.18" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> - <dependencies> - <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14283.14"/> - <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> - </dependencies> - <objects> - <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> - <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" shouldIndentWhileEditing="NO" rowHeight="66" id="w4N-CY-adj" customClass="QuickStartSkipAllCell" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="375" height="66"/> - <autoresizingMask key="autoresizingMask"/> - <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="w4N-CY-adj" id="crU-RO-lSU"> - <rect key="frame" x="0.0" y="0.0" width="375" height="65.5"/> - <autoresizingMask key="autoresizingMask"/> - <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="MzC-qL-Gp9"> - <rect key="frame" x="20" y="20" width="335" height="25.5"/> - <constraints> - <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="18" id="9Xs-of-cZu"/> - </constraints> - <fontDescription key="fontDescription" style="UICTFontTextStyleCallout"/> - <color key="textColor" red="0.0" green="0.52941176470588236" blue="0.74509803921568629" alpha="1" colorSpace="calibratedRGB"/> - <nil key="highlightedColor"/> - </label> - </subviews> - <constraints> - <constraint firstItem="MzC-qL-Gp9" firstAttribute="top" secondItem="crU-RO-lSU" secondAttribute="top" constant="20" id="O4P-aw-ptR"/> - <constraint firstItem="MzC-qL-Gp9" firstAttribute="leading" secondItem="crU-RO-lSU" secondAttribute="leading" constant="20" id="qAt-o2-zjA"/> - <constraint firstAttribute="trailing" secondItem="MzC-qL-Gp9" secondAttribute="trailing" constant="20" id="tFo-Ga-nse"/> - <constraint firstAttribute="bottom" secondItem="MzC-qL-Gp9" secondAttribute="bottom" constant="20" id="yc7-rA-KdO"/> - </constraints> - </tableViewCellContentView> - <inset key="separatorInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/> - <connections> - <outlet property="skipAllLabel" destination="MzC-qL-Gp9" id="QHe-zh-M5C"/> - </connections> - <point key="canvasLocation" x="232.80000000000001" y="121.4392803598201"/> - </tableViewCell> - </objects> -</document> diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartTourGuide.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartTourGuide.swift index 91397d00c148..6ffeff6add46 100644 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartTourGuide.swift +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartTourGuide.swift @@ -1,5 +1,14 @@ import WordPressFlux import Gridicons +import Foundation +import UIKit +import WordPressShared + +@objc enum QuickStartTourEntryPoint: Int { + case unknown + case blogDetails + case blogDashboard +} open class QuickStartTourGuide: NSObject { var navigationSettings = QuickStartNavigationSettings() @@ -7,47 +16,75 @@ open class QuickStartTourGuide: NSObject { private var currentTourState: TourState? private var suggestionWorkItem: DispatchWorkItem? private weak var recentlyTouredBlog: Blog? + private let noticeTag: Notice.Tag = "QuickStartTour" + private let noticeCompleteTag: Notice.Tag = "QuickStartTaskComplete" static let notificationElementKey = "QuickStartElementKey" + static let notificationDescriptionKey = "QuickStartDescriptionKey" + + /// A flag indicating if the user is currently going through a tour or not. + private(set) var tourInProgress = false + + /// A flag indidcating whether we should show the congrats notice or not. + private var shouldShowCongratsNotice = false - @objc static func find() -> QuickStartTourGuide? { - guard let tabBarController = WPTabBarController.sharedInstance(), - let tourGuide = tabBarController.tourGuide else { - return nil + /// Represents the current entry point. + @objc var currentEntryPoint: QuickStartTourEntryPoint = .unknown + + /// Represents the entry point where the current tour in progress was triggered from. + @objc var entryPointForCurrentTour: QuickStartTourEntryPoint = .unknown + + /// A flag indicating if the current tour can only be shown from blog details or not. + @objc var currentTourMustBeShownFromBlogDetails: Bool { + guard let tourState = currentTourState else { + return false } - return tourGuide + + return tourState.tour.mustBeShownInBlogDetails } - func setup(for blog: Blog) { - didShowUpgradeToV2Notice(for: blog) + @objc static let shared = QuickStartTourGuide() + private override init() {} - let createTour = QuickStartCreateTour() - completed(tour: createTour, for: blog) - } + func setup(for blog: Blog, type: QuickStartType, withCompletedSteps steps: [QuickStartTour] = []) { + guard JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() else { + return + } - @objc func remove(from blog: Blog) { - blog.removeAllTours() - } + if type == .newSite { + let createTour = QuickStartCreateTour() + completed(tour: createTour, for: blog) + } + + steps.forEach { (tour) in + completed(tour: tour, for: blog) + } + tourInProgress = false + blog.quickStartType = type - @objc static func shouldShowChecklist(for blog: Blog) -> Bool { - let list = QuickStartTourGuide.customizeListTours + QuickStartTourGuide.growListTours - let checklistCompletedCount = countChecklistCompleted(in: list, for: blog) - return checklistCompletedCount > 0 + NotificationCenter.default.post(name: .QuickStartTourElementChangedNotification, object: self) + WPAnalytics.trackQuickStartEvent(.quickStartStarted, blog: blog) + NotificationCenter.default.post(name: .QuickStartTourElementChangedNotification, + object: self, + userInfo: [QuickStartTourGuide.notificationElementKey: QuickStartTourElement.setupQuickStart]) } - func shouldShowUpgradeToV2Notice(for blog: Blog) -> Bool { - guard isQuickStartEnabled(for: blog), - !allOriginalToursCompleted(for: blog) else { - return false + func setupWithDelay(for blog: Blog, type: QuickStartType, withCompletedSteps steps: [QuickStartTour] = []) { + DispatchQueue.main.asyncAfter(deadline: .now() + Constants.quickStartDelay) { + self.setup(for: blog, type: type, withCompletedSteps: steps) } + } - let completedIDs = blog.completedQuickStartTours?.map { $0.tourID } ?? [] - return !completedIDs.contains(QuickStartUpgradeToV2Tour().key) + @objc func remove(from blog: Blog) { + blog.removeAllTours() + blog.quickStartType = .undefined + endCurrentTour() + NotificationCenter.default.post(name: .QuickStartTourElementChangedNotification, object: self) + refreshQuickStart() } - func didShowUpgradeToV2Notice(for blog: Blog) { - let v2tour = QuickStartUpgradeToV2Tour() - blog.completeTour(v2tour.key) + @objc static func quickStartEnabled(for blog: Blog) -> Bool { + return JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() && QuickStartFactory.collections(for: blog).isEmpty == false } /// Provides a tour to suggest to the user @@ -58,9 +95,9 @@ open class QuickStartTourGuide: NSObject { let completedTours: [QuickStartTourState] = blog.completedQuickStartTours ?? [] let skippedTours: [QuickStartTourState] = blog.skippedQuickStartTours ?? [] let unavailableTours = Array(Set(completedTours + skippedTours)) - let allTours = QuickStartTourGuide.customizeListTours + QuickStartTourGuide.growListTours + let allTours = QuickStartFactory.allTours(for: blog) - guard isQuickStartEnabled(for: blog), + guard QuickStartTourGuide.quickStartEnabled(for: blog), recentlyTouredBlog == blog else { return nil } @@ -82,7 +119,9 @@ open class QuickStartTourGuide: NSObject { self?.suggestionWorkItem?.cancel() self?.suggestionWorkItem = nil - self?.skipped(tour, for: blog) + if skipped { + self?.skipped(tour, for: blog) + } } let newWorkItem = DispatchWorkItem { [weak self] in @@ -97,24 +136,29 @@ open class QuickStartTourGuide: NSObject { message: tour.description, style: noticeStyle, actionTitle: tour.suggestionYesText, - cancelTitle: tour.suggestionNoText) { [weak self] accepted in + cancelTitle: tour.suggestionNoText, + tag: noticeTag) { [weak self] accepted in self?.currentSuggestion = nil if accepted { self?.prepare(tour: tour, for: blog) self?.begin() cancelTimer(false) - WPAnalytics.track(.quickStartSuggestionButtonTapped, withProperties: ["type": "positive"]) + WPAnalytics.trackQuickStartStat(.quickStartSuggestionButtonTapped, + properties: ["type": "positive"], + blog: blog) } else { self?.skipped(tour, for: blog) cancelTimer(true) - WPAnalytics.track(.quickStartSuggestionButtonTapped, withProperties: ["type": "negative"]) + WPAnalytics.trackQuickStartStat(.quickStartSuggestionButtonTapped, + properties: ["type": "negative"], + blog: blog) } } ActionDispatcher.dispatch(NoticeAction.post(notice)) - WPAnalytics.track(.quickStartSuggestionViewed) + WPAnalytics.trackQuickStartStat(.quickStartSuggestionViewed, blog: blog) } /// Prepares to begin the specified tour. @@ -127,12 +171,39 @@ open class QuickStartTourGuide: NSObject { endCurrentTour() dismissSuggestion() - switch tour { - case let tour as QuickStartFollowTour: - tour.setupReaderTab() + let adjustedTour = addSiteMenuWayPointIfNeeded(for: tour) + + switch adjustedTour { + case let adjustedTour as QuickStartFollowTour: + adjustedTour.setupReaderTab() fallthrough default: - currentTourState = TourState(tour: tour, blog: blog, step: 0) + currentTourState = TourState(tour: adjustedTour, blog: blog, step: 0) + } + } + + /// Posts a notification to trigger updates to Quick Start Cards if needed. + func refreshQuickStart() { + NotificationCenter.default.post(name: .QuickStartTourElementChangedNotification, + object: self, + userInfo: [QuickStartTourGuide.notificationElementKey: QuickStartTourElement.updateQuickStart]) + } + + func dismissTaskCompleteNotice() { + ActionDispatcher.dispatch(NoticeAction.clearWithTag(noticeCompleteTag)) + } + + private func addSiteMenuWayPointIfNeeded(for tour: QuickStartTour) -> QuickStartTour { + + if currentEntryPoint == .blogDashboard && + tour.mustBeShownInBlogDetails && + !UIDevice.isPad() { + var tourToAdjust = tour + let siteMenuWaypoint = QuickStartSiteMenu.waypoint + tourToAdjust.waypoints.insert(siteMenuWaypoint, at: 0) + return tourToAdjust + } else { + return tour } } @@ -145,13 +216,59 @@ open class QuickStartTourGuide: NSObject { return } + entryPointForCurrentTour = currentEntryPoint + tourInProgress = true showCurrentStep() } + // Required for now because obj-c doesn't know about Quick Start tours + @objc func completeSiteIconTour(forBlog blog: Blog) { + complete(tour: QuickStartSiteIconTour(), silentlyForBlog: blog) + } + + @objc func completeViewSiteTour(forBlog blog: Blog) { + complete(tour: QuickStartViewTour(blog: blog), silentlyForBlog: blog) + } + + @objc func completeSharingTour(forBlog blog: Blog) { + complete(tour: QuickStartShareTour(), silentlyForBlog: blog) + } + + /// Complete the specified tour without posting a notification. + /// + func complete(tour: QuickStartTour, silentlyForBlog blog: Blog) { + complete(tour: tour, for: blog, postNotification: false) + } + func complete(tour: QuickStartTour, for blog: Blog, postNotification: Bool = true) { + guard let tourCount = blog.quickStartTours?.count, tourCount > 0, + isTourAvailableToComplete(tour: tour, for: blog) else { + // Tours haven't been set up yet or were skipped. + // Or tour to be completed has already been completed. + // No reason to continue. + return + } completed(tour: tour, for: blog, postNotification: postNotification) } + func showCongratsNoticeIfNeeded(for blog: Blog) { + guard allToursCompleted(for: blog), shouldShowCongratsNotice else { + return + } + + shouldShowCongratsNotice = false + + let noticeStyle = QuickStartNoticeStyle(attributedMessage: nil, isDismissable: true) + let notice = Notice(title: Strings.congratulationsTitle, + message: Strings.congratulationsMessage, + style: noticeStyle, + tag: noticeTag) + + ActionDispatcher.dispatch(NoticeAction.post(notice)) + + WPAnalytics.trackQuickStartStat(.quickStartCongratulationsViewed, blog: blog) + } + // we have this because poor stupid ObjC doesn't know what the heck an optional is @objc func currentElementInt() -> Int { return currentWaypoint()?.element.rawValue ?? NSNotFound @@ -174,8 +291,8 @@ open class QuickStartTourGuide: NSObject { return } if element != currentElement { - let blogDetailEvents: [QuickStartTourElement] = [.blogDetailNavigation, .checklist, .themes, .viewSite, .sharing] - let readerElements: [QuickStartTourElement] = [.readerTab, .readerBack] + let blogDetailEvents: [QuickStartTourElement] = [.blogDetailNavigation, .checklist, .themes, .viewSite, .sharing, .siteMenu] + let readerElements: [QuickStartTourElement] = [.readerTab, .readerDiscoverSettings] if blogDetailEvents.contains(element) { endCurrentTour() @@ -188,48 +305,46 @@ open class QuickStartTourGuide: NSObject { dismissCurrentNotice() guard let nextStep = getNextStep() else { + showTaskCompleteNoticeIfNeeded(for: tourState.tour) + entryPointForCurrentTour = .unknown completed(tour: tourState.tour, for: tourState.blog) currentTourState = nil // TODO: we could put a nice animation here return } - currentTourState = nextStep - if currentElement == .readerBack && navigationSettings.shouldSkipReaderBack() { - visited(.readerBack) - return + if element == .siteMenu { + showNextStepWithDelay(nextStep) + } else { + showNextStep(nextStep) } - - showCurrentStep() } - func skipAll(for blog: Blog, whenSkipped: @escaping () -> Void) { - let title = NSLocalizedString("Skip Quick Start", comment: "Title shown in alert to confirm skipping all quick start items") - let message = NSLocalizedString("The quick start tour will guide you through building a basic site. Are you sure you want to skip? ", - comment: "Description shown in alert to confirm skipping all quick start items") - - let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + private func showTaskCompleteNoticeIfNeeded(for tour: QuickStartTour) { - alertController.addCancelActionWithTitle(NSLocalizedString("Cancel", comment: "Button label when canceling alert in quick start")) + guard let taskCompleteDescription = tour.taskCompleteDescription else { + return + } - let skipAction = alertController.addDefaultActionWithTitle(NSLocalizedString("Skip", comment: "Button label when skipping all quick start items")) { _ in - let completedTours: [QuickStartTourState] = blog.completedQuickStartTours ?? [] - let completedIDs = completedTours.map { $0.tourID } + let noticeStyle = QuickStartNoticeStyle(attributedMessage: taskCompleteDescription, isDismissable: true) + let notice = Notice(title: "", style: noticeStyle, tag: noticeCompleteTag) - for tour in QuickStartTourGuide.checklistTours { - if !completedIDs.contains(tour.key) { - blog.completeTour(tour.key) - } - } + DispatchQueue.main.asyncAfter(deadline: .now() + Constants.quickStartDelay) { + ActionDispatcher.dispatch(NoticeAction.post(notice)) + } + } - whenSkipped() + private func showNextStep(_ nextStep: TourState) { + currentTourState = nextStep + showCurrentStep() + } - WPAnalytics.track(.quickStartChecklistSkippedAll) + private func showNextStepWithDelay(_ nextStep: TourState) { + DispatchQueue.main.asyncAfter(deadline: .now() + Constants.nextStepDelay) { + self.currentTourState = nextStep + self.showCurrentStep() } - alertController.preferredAction = skipAction - - WPTabBarController.sharedInstance()?.present(alertController, animated: true) } static func countChecklistCompleted(in list: [QuickStartTour], for blog: Blog) -> Int { @@ -247,44 +362,9 @@ open class QuickStartTourGuide: NSObject { dismissCurrentNotice() currentTourState = nil } - - static let checklistTours: [QuickStartTour] = [ - QuickStartCreateTour(), - QuickStartViewTour(), - QuickStartThemeTour(), - QuickStartCustomizeTour(), - QuickStartShareTour(), - QuickStartPublishTour(), - QuickStartFollowTour() - ] - - static let customizeListTours: [QuickStartTour] = [ - QuickStartCreateTour(), - QuickStartSiteIconTour(), - QuickStartThemeTour(), - QuickStartCustomizeTour(), - QuickStartNewPageTour(), - QuickStartViewTour() - ] - - static let growListTours: [QuickStartTour] = [ - QuickStartShareTour(), - QuickStartPublishTour(), - QuickStartFollowTour(), - QuickStartCheckStatsTour(), - QuickStartExplorePlansTour() - ] } private extension QuickStartTourGuide { - func isQuickStartEnabled(for blog: Blog) -> Bool { - // there must be at least one completed tour for quick start to have been enabled - guard let completedTours = blog.completedQuickStartTours else { - return false - } - - return completedTours.count > 0 - } func completed(tour: QuickStartTour, for blog: Blog, postNotification: Bool = true) { let completedTourIDs = (blog.completedQuickStartTours ?? []).map { $0.tourID } @@ -294,25 +374,29 @@ private extension QuickStartTourGuide { blog.completeTour(tour.key) + // Create a site is completed automatically, we don't want to track + if tour.analyticsKey != "create_site" { + WPAnalytics.trackQuickStartStat(.quickStartTourCompleted, + properties: ["task_name": tour.analyticsKey], + blog: blog) + } + if postNotification { NotificationCenter.default.post(name: .QuickStartTourElementChangedNotification, object: self, userInfo: [QuickStartTourGuide.notificationElementKey: QuickStartTourElement.tourCompleted]) - WPAnalytics.track(.quickStartTourCompleted, withProperties: ["task_name": tour.analyticsKey]) + recentlyTouredBlog = blog } else { recentlyTouredBlog = nil } - guard !(tour is QuickStartCongratulationsTour) else { - WPAnalytics.track(.quickStartCongratulationsViewed) - return - } - if allToursCompleted(for: blog) { - WPAnalytics.track(.quickStartAllToursCompleted) + WPAnalytics.trackQuickStartStat(.quickStartAllToursCompleted, blog: blog) grantCongratulationsAward(for: blog) + tourInProgress = false + shouldShowCongratsNotice = true } else { if let nextTour = tourToSuggest(for: blog) { - PushNotificationsManager.shared.postNotification(for: nextTour) + PushNotificationsManager.shared.postNotification(for: nextTour, quickStartType: blog.quickStartType) } } } @@ -322,19 +406,30 @@ private extension QuickStartTourGuide { /// - Parameter blog: blog to check /// - Returns: boolean, true if all tours have been completed func allToursCompleted(for blog: Blog) -> Bool { - let list = QuickStartTourGuide.customizeListTours + QuickStartTourGuide.growListTours + let list = QuickStartFactory.allTours(for: blog) return countChecklistCompleted(in: list, for: blog) >= list.count } - /// Check if all the original (V1) tours have been completed - /// - /// - Parameter blog: a Blog to check - /// - Returns: boolean, true if all the tours have been completed - /// - Note: This method is needed for upgrade/migration to V2 and should not - /// be removed when the V2 feature flag is removed. - func allOriginalToursCompleted(for blog: Blog) -> Bool { - let list = QuickStartTourGuide.checklistTours - return countChecklistCompleted(in: list, for: blog) >= list.count + /// Returns a list of all available tours that have not yet been completed + /// - Parameter blog: blog to check + func uncompletedTours(for blog: Blog) -> [QuickStartTour] { + let completedTours: [QuickStartTourState] = blog.completedQuickStartTours ?? [] + let allTours = QuickStartFactory.allTours(for: blog) + let completedIDs = completedTours.map { $0.tourID } + let uncompletedTours = allTours.filter { !completedIDs.contains($0.key) } + return uncompletedTours + } + + /// Check if the provided tour have not yet been completed and is available to complete + /// - Parameters: + /// - tour: tour to check + /// - blog: blog to check + /// - Returns: boolean, true if the tour is not completed and is available. False otherwise + func isTourAvailableToComplete(tour: QuickStartTour, for blog: Blog) -> Bool { + let uncompletedTours = uncompletedTours(for: blog) + return uncompletedTours.contains { element in + element.key == tour.key + } } func showCurrentStep() { @@ -342,14 +437,22 @@ private extension QuickStartTourGuide { return } - showStepNotice(waypoint.description) + if let state = currentTourState, + state.tour.showWaypointNotices { + showStepNotice(waypoint.description) + } + + let userInfo: [String: Any] = [ + QuickStartTourGuide.notificationElementKey: waypoint.element, + QuickStartTourGuide.notificationDescriptionKey: waypoint.description + ] - NotificationCenter.default.post(name: .QuickStartTourElementChangedNotification, object: self, userInfo: [QuickStartTourGuide.notificationElementKey: waypoint.element]) + NotificationCenter.default.post(name: .QuickStartTourElementChangedNotification, object: self, userInfo: userInfo) } func showStepNotice(_ description: NSAttributedString) { let noticeStyle = QuickStartNoticeStyle(attributedMessage: description) - let notice = Notice(title: "Test Quick Start Notice", style: noticeStyle) + let notice = Notice(title: "Test Quick Start Notice", style: noticeStyle, tag: noticeTag) ActionDispatcher.dispatch(NoticeAction.post(notice)) } @@ -370,8 +473,9 @@ private extension QuickStartTourGuide { return } + tourInProgress = false currentSuggestion = nil - ActionDispatcher.dispatch(NoticeAction.dismiss) + ActionDispatcher.dispatch(NoticeAction.clearWithTag(noticeTag)) } func getNextStep() -> TourState? { @@ -384,24 +488,34 @@ private extension QuickStartTourGuide { } func skipped(_ tour: QuickStartTour, for blog: Blog) { + tourInProgress = false blog.skipTour(tour.key) recentlyTouredBlog = nil } + // - TODO: Research if dispatching `NoticeAction.empty` is still necessary now that we use `.clearWithTag`. func dismissCurrentNotice() { - ActionDispatcher.dispatch(NoticeAction.dismiss) + ActionDispatcher.dispatch(NoticeAction.clearWithTag(noticeTag)) + ActionDispatcher.dispatch(NoticeAction.clearWithTag(noticeCompleteTag)) ActionDispatcher.dispatch(NoticeAction.empty) NotificationCenter.default.post(name: .QuickStartTourElementChangedNotification, object: self, userInfo: [QuickStartTourGuide.notificationElementKey: QuickStartTourElement.noSuchElement]) } private func grantCongratulationsAward(for blog: Blog) { - let service = SiteManagementService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let service = SiteManagementService(coreDataStack: ContextManager.sharedInstance()) service.markQuickStartChecklistAsComplete(for: blog) } private struct Constants { static let maxSkippedTours = 3 static let suggestionTimeout = 10.0 + static let quickStartDelay: DispatchTimeInterval = .milliseconds(500) + static let nextStepDelay: DispatchTimeInterval = .milliseconds(1000) + } + + private enum Strings { + static let congratulationsTitle = NSLocalizedString("Congrats! You know your way around", comment: "Title shown when all tours have been completed.") + static let congratulationsMessage = NSLocalizedString("Doesn't it feel good to cross things off a list?", comment: "Message shown when all tours have been completed") } } diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartTours.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartTours.swift index edced5cbcd99..096e6addcef9 100644 --- a/WordPress/Classes/ViewRelated/Blog/QuickStartTours.swift +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartTours.swift @@ -1,17 +1,29 @@ import Gridicons +import Foundation +import UIKit protocol QuickStartTour { typealias WayPoint = (element: QuickStartTourElement, description: NSAttributedString) var key: String { get } var title: String { get } + var titleMarkedCompleted: String { get } // assists VoiceOver users var analyticsKey: String { get } var description: String { get } var icon: UIImage { get } + var iconColor: UIColor { get } var suggestionNoText: String { get } var suggestionYesText: String { get } - var waypoints: [WayPoint] { get } + var waypoints: [WayPoint] { get set } var accessibilityHintText: String { get } + var showWaypointNotices: Bool { get } + var taskCompleteDescription: NSAttributedString? { get } + var showDescriptionInQuickStartModal: Bool { get } + + /// Represents where the tour can be shown from. + var possibleEntryPoints: Set<QuickStartTourEntryPoint> { get } + + var mustBeShownInBlogDetails: Bool { get } } extension QuickStartTour { @@ -20,6 +32,30 @@ extension QuickStartTour { return [] } } + + var showWaypointNotices: Bool { + get { + return true + } + } + + var taskCompleteDescription: NSAttributedString? { + get { + return nil + } + } + + var mustBeShownInBlogDetails: Bool { + get { + return possibleEntryPoints == [.blogDetails] + } + } + + var showDescriptionInQuickStartModal: Bool { + get { + return false + } + } } private struct Strings { @@ -27,19 +63,28 @@ private struct Strings { static let yesShowMe = NSLocalizedString("Yes, show me", comment: "Phrase displayed to begin a quick start tour that's been suggested.") } +struct QuickStartSiteMenu { + private static let descriptionBase = NSLocalizedString("Select %@ to continue.", comment: "A step in a guided tour for quick start. %@ will be the name of the segmented control item to select on the Site Menu screen.") + private static let descriptionTarget = NSLocalizedString("Menu", comment: "The segmented control item to select during a guided tour.") + static let waypoint = QuickStartTour.WayPoint(element: .siteMenu, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: nil)) +} + struct QuickStartChecklistTour: QuickStartTour { let key = "quick-start-checklist-tour" let analyticsKey = "view_list" let title = NSLocalizedString("Continue with site setup", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Continue with site setup", comment: "The Quick Start Tour title after the user finished the step.") let description = NSLocalizedString("Time to finish setting up your site! Our checklist walks you through the next steps.", comment: "Description of a Quick Start Tour") - let icon = Gridicon.iconOfType(.external) + let icon = UIImage.gridicon(.external) + let iconColor = UIColor.systemGray4 let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails, .blogDashboard] var waypoints: [WayPoint] = { - let descriptionBase = NSLocalizedString("Tap %@ to see your checklist", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let descriptionTarget = NSLocalizedString("Quick Start", comment: "The menu item to tap during a guided tour.") - return [(element: .checklist, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: Gridicon.iconOfType(.listCheckmark)))] + let descriptionBase = NSLocalizedString("Select %@ to see your checklist", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let descriptionTarget = NSLocalizedString("Quick Start", comment: "The menu item to select during a guided tour.") + return [(element: .checklist, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: .gridicon(.listCheckmark)))] }() let accessibilityHintText = NSLocalizedString("Guides you through the process of setting up your site.", comment: "This value is used to set the accessibility hint text for setting up the user's site.") @@ -49,108 +94,86 @@ struct QuickStartCreateTour: QuickStartTour { let key = "quick-start-create-tour" let analyticsKey = "create_site" let title = NSLocalizedString("Create your site", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Create your site", comment: "The Quick Start Tour title after the user finished the step.") let description = NSLocalizedString("Get your site up and running", comment: "Description of a Quick Start Tour") - let icon = Gridicon.iconOfType(.plus) + let icon = UIImage.gridicon(.plus) + let iconColor = UIColor.systemGray4 let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails, .blogDashboard] - let waypoints: [QuickStartTour.WayPoint] = [(element: .noSuchElement, description: NSAttributedString(string: "This tour should never display as interactive."))] + var waypoints: [QuickStartTour.WayPoint] = [(element: .noSuchElement, description: NSAttributedString(string: "This tour should never display as interactive."))] 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 description = "" - let icon = Gridicon.iconOfType(.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" let title = NSLocalizedString("View your site", comment: "Title of a Quick Start Tour") - let description = NSLocalizedString("Preview your new site to see what your visitors will see.", comment: "Description of a Quick Start Tour") - let icon = Gridicon.iconOfType(.external) + let titleMarkedCompleted = NSLocalizedString("Completed: View your site", comment: "The Quick Start Tour title after the user finished the step.") + let description = NSLocalizedString("Preview your site to see what your visitors will see.", comment: "Description of a Quick Start Tour") + let icon = UIImage.gridicon(.external) + let iconColor = UIColor.muriel(color: MurielColor(name: .yellow, shade: .shade20)) let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails, .blogDashboard] - var waypoints: [WayPoint] = { - let descriptionBase = NSLocalizedString("Tap %@ to preview", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let descriptionTarget = NSLocalizedString("View Site", comment: "The menu item to tap during a guided tour.") - return [(element: .viewSite, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: Gridicon.iconOfType(.house)))] - }() + var waypoints: [WayPoint] let accessibilityHintText = NSLocalizedString("Guides you through the process of previewing your site.", comment: "This value is used to set the accessibility hint text for previewing a user's site.") + + init(blog: Blog) { + let descriptionBase = NSLocalizedString("Select %@ to view your site", comment: "A step in a guided tour for quick start. %@ will be the site url.") + let placeholder = NSLocalizedString("Site URL", comment: "The item to select during a guided tour.") + let descriptionTarget = (blog.displayURL as String?) ?? placeholder + + self.waypoints = [ + (element: .viewSite, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: nil)) + ] + } } struct QuickStartThemeTour: QuickStartTour { let key = "quick-start-theme-tour" let analyticsKey = "browse_themes" let title = NSLocalizedString("Choose a theme", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Choose a theme", comment: "The Quick Start Tour title after the user finished the step.") let description = NSLocalizedString("Browse all our themes to find your perfect fit.", comment: "Description of a Quick Start Tour") - let icon = Gridicon.iconOfType(.themes) + let icon = UIImage.gridicon(.themes) + let iconColor = UIColor.systemGray4 let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails] var waypoints: [WayPoint] = { - let descriptionBase = NSLocalizedString("Tap %@ to discover new themes", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let descriptionTarget = NSLocalizedString("Themes", comment: "The menu item to tap during a guided tour.") - return [(element: .themes, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: Gridicon.iconOfType(.themes)))] + let descriptionBase = NSLocalizedString("Select %@ to discover new themes", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let descriptionTarget = NSLocalizedString("Themes", comment: "The menu item to select during a guided tour.") + return [(element: .themes, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: .gridicon(.themes)))] }() let accessibilityHintText = NSLocalizedString("Guides you through the process of choosing a theme for your site.", comment: "This value is used to set the accessibility hint text for choosing a theme for the user's site.") } -struct QuickStartCustomizeTour: QuickStartTour { - let key = "quick-start-customize-tour" - let analyticsKey = "customize_site" - let title = NSLocalizedString("Customize your site", comment: "Title of a Quick Start Tour") - let description = NSLocalizedString("Change colors, fonts, and images for a perfectly personalized site.", comment: "Description of a Quick Start Tour") - let icon = Gridicon.iconOfType(.customize) - let suggestionNoText = Strings.notNow - let suggestionYesText = Strings.yesShowMe - - var waypoints: [WayPoint] = { - let step1DescriptionBase = NSLocalizedString("Tap %@ to continue", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let step1DescriptionTarget = NSLocalizedString("Themes", comment: "The menu item to tap during a guided tour.") - let step1: WayPoint = (element: .themes, description: step1DescriptionBase.highlighting(phrase: step1DescriptionTarget, icon: Gridicon.iconOfType(.themes))) - - let step2DescriptionBase = NSLocalizedString("Tap %@ to start personalising your site", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let step2DescriptionTarget = NSLocalizedString("Customize", comment: "The menu item to tap during a guided tour.") - let step2: WayPoint = (element: .customize, description: step2DescriptionBase.highlighting(phrase: step2DescriptionTarget, icon: Gridicon.iconOfType(.themes))) - - return [step1, step2] - }() - - let accessibilityHintText = NSLocalizedString("Guides you through the process of customizing your site.", comment: "This value is used to set the accessibility hint text for customizing a user's site.") -} - 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 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 = Gridicon.iconOfType(.share) + let icon = UIImage.gridicon(.share) + let iconColor = UIColor.muriel(color: MurielColor(name: .blue, shade: .shade40)).color(for: UITraitCollection(userInterfaceStyle: .light)) let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails] var waypoints: [WayPoint] = { - let step1DescriptionBase = NSLocalizedString("Tap %@ to continue", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let step1DescriptionTarget = NSLocalizedString("Sharing", comment: "The menu item to tap during a guided tour.") - let step1: WayPoint = (element: .sharing, description: step1DescriptionBase.highlighting(phrase: step1DescriptionTarget, icon: Gridicon.iconOfType(.share))) + let step1DescriptionBase = NSLocalizedString("Select %@ to continue", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let step1DescriptionTarget = NSLocalizedString("Sharing", comment: "The menu item to select during a guided tour.") + let step1: WayPoint = (element: .sharing, description: step1DescriptionBase.highlighting(phrase: step1DescriptionTarget, icon: .gridicon(.share))) - let step2DescriptionBase = NSLocalizedString("Tap the %@ to add your social media accounts", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let step2DescriptionTarget = NSLocalizedString("connections", comment: "The menu item to tap during a guided tour.") + let step2DescriptionBase = NSLocalizedString("Select the %@ to add your social media accounts", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let step2DescriptionTarget = NSLocalizedString("connections", comment: "The menu item to select during a guided tour.") let step2: WayPoint = (element: .connections, description: step2DescriptionBase.highlighting(phrase: step2DescriptionTarget, icon: nil)) return [step1, step2] @@ -163,14 +186,18 @@ struct QuickStartPublishTour: QuickStartTour { let key = "quick-start-publish-tour" let analyticsKey = "publish_post" let title = NSLocalizedString("Publish a post", comment: "Title of a Quick Start Tour") - let description = NSLocalizedString("It's time! Draft and publish your very first post.", comment: "Description of a Quick Start Tour") - let icon = Gridicon.iconOfType(.create) + let titleMarkedCompleted = NSLocalizedString("Completed: Publish a post", comment: "The Quick Start Tour title after the user finished the step.") + let description = NSLocalizedString("Draft and publish a post.", comment: "Description of a Quick Start Tour") + let icon = UIImage.gridicon(.create) + let iconColor = UIColor.muriel(color: MurielColor(name: .green, shade: .shade30)) let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let showWaypointNotices = false + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails, .blogDashboard] var waypoints: [WayPoint] = { - let descriptionBase = NSLocalizedString("Tap %@ to create a new post", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - return [(element: .newpost, description: descriptionBase.highlighting(phrase: "", icon: Gridicon.iconOfType(.create)))] + let descriptionBase = NSLocalizedString("Select %@ to create a new post", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + return [(element: .newpost, description: descriptionBase.highlighting(phrase: "", icon: .gridicon(.create)))] }() let accessibilityHintText = NSLocalizedString("Guides you through the process of publishing a new post on your site.", comment: "This value is used to set the accessibility hint text for publishing a new post on the user's site.") @@ -179,76 +206,110 @@ struct QuickStartPublishTour: QuickStartTour { struct QuickStartFollowTour: QuickStartTour { let key = "quick-start-follow-tour" let analyticsKey = "follow_site" - let title = NSLocalizedString("Follow other sites", comment: "Title of a Quick Start Tour") - let description = NSLocalizedString("Find sites that speak to you, and follow them to get updates when they publish.", comment: "Description of a Quick Start Tour") - let icon = Gridicon.iconOfType(.readerFollow) + let title = NSLocalizedString("Connect with other sites", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Connect with other sites", comment: "The Quick Start Tour title after the user finished the step.") + let description = NSLocalizedString("Discover and follow sites that inspire you.", comment: "Description of a Quick Start Tour") + let icon = UIImage.gridicon(.readerFollow) + let iconColor = UIColor.muriel(color: MurielColor(name: .pink, shade: .shade40)) let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails, .blogDashboard] var waypoints: [WayPoint] = { - let step1DescriptionBase = NSLocalizedString("Tap %@ to continue", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let step1DescriptionTarget = NSLocalizedString("Reader", comment: "The menu item to tap during a guided tour.") - let step1: WayPoint = (element: .readerTab, description: step1DescriptionBase.highlighting(phrase: step1DescriptionTarget, icon: Gridicon.iconOfType(.reader))) + let step1DescriptionBase = NSLocalizedString("Select %@ to find other sites.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let step1DescriptionTarget = NSLocalizedString("Reader", comment: "The menu item to select during a guided tour.") + let step1: WayPoint = (element: .readerTab, description: step1DescriptionBase.highlighting(phrase: step1DescriptionTarget, icon: .gridicon(.reader))) + + let step2DiscoverDescriptionBase = NSLocalizedString("Use %@ to find sites and tags.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let step2DiscoverDescriptionTarget = NSLocalizedString("Discover", comment: "The menu item to select during a guided tour.") + let step2DiscoverDescription = step2DiscoverDescriptionBase.highlighting(phrase: step2DiscoverDescriptionTarget, icon: nil) - let step2DescriptionBase = NSLocalizedString("Tap %@ to continue", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let step2DescriptionTarget = NSLocalizedString("Reader", comment: "The menu item to tap during a guided tour.") - let step2: WayPoint = (element: .readerBack, description: step2DescriptionBase.highlighting(phrase: step2DescriptionTarget, icon: Gridicon.iconOfType(.chevronLeft))) + let step2SettingsDescriptionBase = NSLocalizedString("Try selecting %@ to add topics you like.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let step2SettingsDescriptionTarget = NSLocalizedString("Settings", comment: "The menu item to select during a guided tour.") + let step2SettingsDescription = step2SettingsDescriptionBase.highlighting(phrase: step2SettingsDescriptionTarget, icon: .gridicon(.cog)) - let step3DescriptionBase = NSLocalizedString("Tap %@ to look for sites with similar interests", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let step3DescriptionTarget = NSLocalizedString("Search", comment: "The menu item to tap during a guided tour.") - let step3: WayPoint = (element: .readerSearch, description: step3DescriptionBase.highlighting(phrase: step3DescriptionTarget, icon: Gridicon.iconOfType(.search))) + /// Combined description for step 2 + let step2Format = NSAttributedString(string: "%@ %@") + let step2Description = NSAttributedString(format: step2Format, args: step2DiscoverDescription, step2SettingsDescription) - return [step1, step2, step3] + let step2: WayPoint = (element: .readerDiscoverSettings, description: step2Description) + + return [step1, step2] }() let accessibilityHintText = NSLocalizedString("Guides you through the process of following other sites.", comment: "This value is used to set the accessibility hint text for following the sites of other users.") func setupReaderTab() { - guard let tabBar = WPTabBarController.sharedInstance() else { - return - } + RootViewCoordinator.sharedPresenter.resetReaderTab() + } +} + +struct QuickStartSiteTitleTour: QuickStartTour { + let key = "quick-start-site-title-tour" + let analyticsKey = "site_title" + 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) + let iconColor = UIColor.muriel(color: MurielColor(name: .red, shade: .shade40)) + let suggestionNoText = Strings.notNow + let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails, .blogDashboard] - tabBar.resetReaderTab() + var waypoints: [WayPoint] + + let accessibilityHintText = NSLocalizedString("Guides you through the process of setting a title for your site.", comment: "This value is used to set the accessibility hint text for setting the site title.") + + init(blog: Blog) { + let descriptionBase = NSLocalizedString("Select %@ to set a new title.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let placeholder = NSLocalizedString("Site Title", comment: "The item to select during a guided tour.") + let descriptionTarget = blog.title ?? placeholder + + self.waypoints = [ + (element: .siteTitle, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: nil)) + ] } } 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 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 icon = Gridicon.iconOfType(.globe) + 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("Used across the web: in browser tabs, social media previews, and the WordPress.com Reader.", comment: "Description of a Quick Start Tour") + let icon = UIImage.gridicon(.globe) + let iconColor = UIColor.muriel(color: MurielColor(name: .purple, shade: .shade40)) let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails, .blogDashboard] + let showDescriptionInQuickStartModal = true var waypoints: [WayPoint] = { - let descriptionBase = NSLocalizedString("Tap %@ to upload a new one.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let descriptionTarget = NSLocalizedString("Your Site Icon", comment: "The item to tap during a guided tour.") + let descriptionBase = NSLocalizedString("Select %@ to upload a new one.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let descriptionTarget = NSLocalizedString("Your Site Icon", comment: "The item to select during a guided tour.") return [(element: .siteIcon, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: nil))] }() let accessibilityHintText = NSLocalizedString("Guides you through the process of uploading an icon for your site.", comment: "This value is used to set the accessibility hint text for uploading a site icon.") } -struct QuickStartNewPageTour: QuickStartTour { - let key = "quick-start-new-page-tour" - let analyticsKey = "new_page" - let title = NSLocalizedString("Create a new page", comment: "Title of a Quick Start Tour") - let description = NSLocalizedString("Add a page for key content — an “About” page is a great start.", comment: "Description of a Quick Start Tour") - let icon = Gridicon.iconOfType(.pages) +struct QuickStartReviewPagesTour: QuickStartTour { + let key = "quick-start-review-pages-tour" + let analyticsKey = "review_pages" + let title = NSLocalizedString("Review site pages", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Review site pages", comment: "The Quick Start Tour title after the user finished the step.") + let description = NSLocalizedString("Change, add, or remove your site's pages.", comment: "Description of a Quick Start Tour") + let icon = UIImage.gridicon(.pages) + let iconColor = UIColor.muriel(color: MurielColor(name: .celadon, shade: .shade30)) let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails] var waypoints: [WayPoint] = { - let pagesStepDesc = NSLocalizedString("Tap %@ to continue.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let pagesStepTarget = NSLocalizedString("Site Pages", comment: "The item to tap during a guided tour.") - - let newStepDesc = NSLocalizedString("Tap %@ to create a new page.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - - return [ - (element: .pages, description: pagesStepDesc.highlighting(phrase: pagesStepTarget, icon: nil)), - (element: .newPage, description: newStepDesc.highlighting(phrase: "", icon: Gridicon.iconOfType(.plus))) - ] + let descriptionBase = NSLocalizedString("Select %@ to see your page list.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let descriptionTarget = NSLocalizedString("Pages", comment: "The item to select during a guided tour.") + return [(element: .pages, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: .gridicon(.pages)))] }() let accessibilityHintText = NSLocalizedString("Guides you through the process of creating a new page for your site.", comment: "This value is used to set the accessibility hint text for creating a new page for the user's site.") @@ -258,15 +319,24 @@ struct QuickStartCheckStatsTour: QuickStartTour { let key = "quick-start-check-stats-tour" let analyticsKey = "check_stats" let title = NSLocalizedString("Check your site stats", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Check your site stats", comment: "The Quick Start Tour title after the user finished the step.") let description = NSLocalizedString("Keep up to date on your site’s performance.", comment: "Description of a Quick Start Tour") - let icon = Gridicon.iconOfType(.statsAlt) + let icon = UIImage.gridicon(.statsAlt) + let iconColor = UIColor.muriel(color: MurielColor(name: .orange, shade: .shade30)) let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails, .blogDashboard] + + var taskCompleteDescription: NSAttributedString? = { + let descriptionBase = NSLocalizedString("%@ Return to My Site screen when you're ready for the next task.", comment: "Title of the task complete hint for the Quick Start Tour") + let descriptionTarget = NSLocalizedString("Task complete.", comment: "A hint about the completed guided tour.") + return descriptionBase.highlighting(phrase: descriptionTarget, icon: .gridicon(.checkmark)) + }() var waypoints: [WayPoint] = { - let descriptionBase = NSLocalizedString("Tap %@ to see how your site is performing.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let descriptionTarget = NSLocalizedString("Stats", comment: "The item to tap during a guided tour.") - return [(element: .stats, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: Gridicon.iconOfType(.stats)))] + let descriptionBase = NSLocalizedString("Select %@ to see how your site is performing.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let descriptionTarget = NSLocalizedString("Stats", comment: "The item to select during a guided tour.") + return [(element: .stats, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: .gridicon(.stats)))] }() let accessibilityHintText = NSLocalizedString("Guides you through the process of reviewing statistics for your site.", comment: "This value is used to set the accessibility hint text for viewing Stats on the user's site.") @@ -276,34 +346,75 @@ struct QuickStartExplorePlansTour: QuickStartTour { let key = "quick-start-explore-plans-tour" let analyticsKey = "explore_plans" let title = NSLocalizedString("Explore plans", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Explore plans", comment: "The Quick Start Tour title after the user finished the step.") let description = NSLocalizedString("Learn about the marketing and SEO tools in our paid plans.", comment: "Description of a Quick Start Tour") - let icon = Gridicon.iconOfType(.plans) + let icon = UIImage.gridicon(.plans) + let iconColor = UIColor.systemGray4 let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails] var waypoints: [WayPoint] = { - let descriptionBase = NSLocalizedString("Tap %@ to see your current plan and other available plans.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to tap.") - let descriptionTarget = NSLocalizedString("Plan", comment: "The item to tap during a guided tour.") - return [(element: .plans, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: Gridicon.iconOfType(.plans)))] + let descriptionBase = NSLocalizedString("Select %@ to see your current plan and other available plans.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let descriptionTarget = NSLocalizedString("Plan", comment: "The item to select during a guided tour.") + return [(element: .plans, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: .gridicon(.plans)))] }() let accessibilityHintText = NSLocalizedString("Guides you through the process of exploring plans for your site.", comment: "This value is used to set the accessibility hint text for exploring plans on the user's site.") } -private let congratsTitle = NSLocalizedString("Congrats on finishing Quick Start 🎉", comment: "Title of a Quick Start Tour") -private let congratsDescription = NSLocalizedString("doesn’t it feel good to cross things off a list?", comment: "subhead shown to users when they complete all Quick Start items") -struct QuickStartCongratulationsTour: QuickStartTour { - let key = "quick-start-congratulations-tour" - let analyticsKey = "congratulations" - let title = congratsTitle - let description = congratsDescription - let icon = Gridicon.iconOfType(.plus) +struct QuickStartNotificationsTour: QuickStartTour { + let key = "quick-start-notifications-tour" + let analyticsKey = "notifications" + let title = NSLocalizedString("Check your notifications", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Check your notifications", comment: "The Quick Start Tour title after the user finished the step.") + let description = NSLocalizedString("Get real time updates from your pocket.", comment: "Description of a Quick Start Tour") + let icon = UIImage.gridicon(.bell) + let iconColor = UIColor.muriel(color: MurielColor(name: .purple, shade: .shade40)) let suggestionNoText = Strings.notNow let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails, .blogDashboard] - let waypoints: [QuickStartTour.WayPoint] = [(element: .congratulations, description: NSAttributedString(string: congratsTitle))] + var taskCompleteDescription: NSAttributedString? = { + let descriptionBase = NSLocalizedString("%@ Tip: get updates faster by enabling push notifications.", comment: "Title of the task complete hint for the Quick Start Tour") + let descriptionTarget = NSLocalizedString("Task complete.", comment: "A hint about the completed guided tour.") + return descriptionBase.highlighting(phrase: descriptionTarget, icon: .gridicon(.checkmark)) + }() - let accessibilityHintText = "" // Not applicable for this tour type + var waypoints: [WayPoint] = { + let descriptionBase = NSLocalizedString("Select the %@ tab to get updates on the go.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let descriptionTarget = NSLocalizedString("Notifications", comment: "The item to select during a guided tour.") + return [(element: .notifications, description: descriptionBase.highlighting(phrase: descriptionTarget, icon: .gridicon(.bell)))] + }() + + let accessibilityHintText = NSLocalizedString("Guides you through the process of checking your notifications.", comment: "This value is used to set the accessibility hint text for viewing the user's notifications.") +} + +struct QuickStartMediaUploadTour: QuickStartTour { + let key = "quick-start-media-upload-tour" + let analyticsKey = "media" + let title = NSLocalizedString("Upload photos or videos", comment: "Title of a Quick Start Tour") + let titleMarkedCompleted = NSLocalizedString("Completed: Upload photos or videos", comment: "The Quick Start Tour title after the user finished the step.") + let description = NSLocalizedString("Bring media straight from your device or camera to your site.", comment: "Description of a Quick Start Tour") + let icon = UIImage.gridicon(.addImage) + let iconColor = UIColor.muriel(color: MurielColor(name: .celadon, shade: .shade30)) + let suggestionNoText = Strings.notNow + let suggestionYesText = Strings.yesShowMe + let possibleEntryPoints: Set<QuickStartTourEntryPoint> = [.blogDetails, .blogDashboard] + + var waypoints: [WayPoint] = { + let step1DescriptionBase = NSLocalizedString("Select %@ to see your current library.", comment: "A step in a guided tour for quick start. %@ will be the name of the item to select.") + let step1DescriptionTarget = NSLocalizedString("Media", comment: "The menu item to select during a guided tour.") + let step1: WayPoint = (element: .mediaScreen, description: step1DescriptionBase.highlighting(phrase: step1DescriptionTarget, icon: .gridicon(.image))) + + let step2DescriptionBase = NSLocalizedString("Select %@to upload media. You can add it to your posts / pages from any device.", comment: "A step in a guided tour for quick start. %@ will be a plus icon.") + let step2DescriptionTarget = "" + let step2: WayPoint = (element: .mediaUpload, description: step2DescriptionBase.highlighting(phrase: step2DescriptionTarget, icon: .gridicon(.plus))) + + return [step1, step2] + }() + + let accessibilityHintText = NSLocalizedString("Guides you through the process of uploading new media.", comment: "This value is used to set the accessibility hint text for viewing the user's notifications.") } private extension String { @@ -316,12 +427,12 @@ private extension String { let resultString = NSMutableAttributedString(string: normalParts[0], attributes: [.font: Fonts.regular]) - let highlightStr = NSAttributedString(string: phrase, attributes: [.foregroundColor: Constants.highlightColor, .font: Fonts.highlight]) + let highlightStr = NSAttributedString(string: phrase, attributes: [.foregroundColor: Appearance.highlightColor, .font: Fonts.highlight]) if let icon = icon { let iconAttachment = NSTextAttachment() - iconAttachment.image = icon.imageWithTintColor(Constants.highlightColor) - iconAttachment.bounds = CGRect(x: 0.0, y: Fonts.regular.descender + Constants.iconOffset, width: Constants.iconSize, height: Constants.iconSize) + iconAttachment.image = icon.withTintColor(Appearance.highlightColor) + iconAttachment.bounds = CGRect(x: 0.0, y: Fonts.regular.descender + Appearance.iconOffset, width: Appearance.iconSize, height: Appearance.iconSize) let iconStr = NSAttributedString(attachment: iconAttachment) switch UIView.userInterfaceLayoutDirection(for: .unspecified) { @@ -347,12 +458,26 @@ private extension String { private enum Fonts { static let regular = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .medium) - static let highlight = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + static let highlight = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) } - private enum Constants { + private enum Appearance { static let iconOffset: CGFloat = 1.0 static let iconSize: CGFloat = 16.0 - static let highlightColor: UIColor = .white + static var highlightColor: UIColor { + .invertedLabel + } + } +} + +private extension NSAttributedString { + convenience init(format: NSAttributedString, args: NSAttributedString...) { + let mutableNSAttributedString = NSMutableAttributedString(attributedString: format) + + args.forEach { (attributedString) in + let range = NSString(string: mutableNSAttributedString.string).range(of: "%@") + mutableNSAttributedString.replaceCharacters(in: range, with: attributedString) + } + self.init(attributedString: mutableNSAttributedString) } } diff --git a/WordPress/Classes/ViewRelated/Blog/QuickStartToursCollection.swift b/WordPress/Classes/ViewRelated/Blog/QuickStartToursCollection.swift new file mode 100644 index 000000000000..4227eaee6bf9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/QuickStartToursCollection.swift @@ -0,0 +1,97 @@ +import Foundation + +protocol QuickStartToursCollection { + var title: String { get } + var shortTitle: String { get } + var hint: String { get } + var completedImageName: String { get } + var analyticsKey: String { get } + var tours: [QuickStartTour] { get } + var checklistViewType: QuickStartChecklistConfigurableView.Type { get } + + init(blog: Blog) +} + +struct QuickStartCustomizeToursCollection: QuickStartToursCollection { + let title: String + let shortTitle: String + let hint: String + let completedImageName: String + let analyticsKey: String + let tours: [QuickStartTour] + let checklistViewType: QuickStartChecklistConfigurableView.Type = QuickStartChecklistView.self + + init(blog: Blog) { + self.title = NSLocalizedString("Customize Your Site", + comment: "Name of the Quick Start list that guides users through a few tasks to customize their new website.") + self.shortTitle = NSLocalizedString("Customize Your Site", + comment: "Name of the Quick Start list that guides users through a few tasks to customize their new website.") + self.hint = NSLocalizedString("A series of steps showing you how to add a theme, site icon and more.", + comment: "A VoiceOver hint to explain what the user gets when they select the 'Customize Your Site' button.") + self.completedImageName = "wp-illustration-tasks-complete-site" + self.analyticsKey = "customize" + self.tours = [ + QuickStartCreateTour(), + QuickStartSiteTitleTour(blog: blog), + QuickStartSiteIconTour(), + QuickStartReviewPagesTour(), + QuickStartViewTour(blog: blog) + ] + } +} + +struct QuickStartGrowToursCollection: QuickStartToursCollection { + let title: String + let shortTitle: String + let hint: String + let completedImageName: String + let analyticsKey: String + let tours: [QuickStartTour] + let checklistViewType: QuickStartChecklistConfigurableView.Type = QuickStartChecklistView.self + + init(blog: Blog) { + self.title = NSLocalizedString("Grow Your Audience", + comment: "Name of the Quick Start list that guides users through a few tasks to customize their new website.") + self.shortTitle = NSLocalizedString("Grow Your Audience", + comment: "Name of the Quick Start list that guides users through a few tasks to customize their new website.") + self.hint = NSLocalizedString("A series of steps to assist with growing your site's audience.", + comment: "A VoiceOver hint to explain what the user gets when they select the 'Grow Your Audience' button.") + self.completedImageName = "wp-illustration-tasks-complete-audience" + self.analyticsKey = "grow" + self.tours = [ + QuickStartShareTour(), + QuickStartPublishTour(), + QuickStartFollowTour(), + QuickStartCheckStatsTour() + // Temporarily disabled + // QuickStartExplorePlansTour() + ] + } +} + +struct QuickStartGetToKnowAppCollection: QuickStartToursCollection { + let title: String + let shortTitle: String + let hint: String + let completedImageName: String + let analyticsKey: String + let tours: [QuickStartTour] + let checklistViewType: QuickStartChecklistConfigurableView.Type = NewQuickStartChecklistView.self + + init(blog: Blog) { + self.title = AppConstants.QuickStart.getToKnowTheAppTourTitle + self.shortTitle = NSLocalizedString("Get to know the app", + comment: "Name of the Quick Start list that guides users through a few tasks to explore the WordPress/Jetpack app.") + self.hint = NSLocalizedString("A series of steps helping you to explore the app.", + comment: "A VoiceOver hint to explain what the user gets when they select the 'Get to know the WordPress/Jetpack app' button.") + self.completedImageName = "wp-illustration-tasks-complete-site" + self.analyticsKey = "get-to-know" + self.tours = [ + QuickStartCheckStatsTour(), + QuickStartNotificationsTour(), + QuickStartViewTour(blog: blog), + QuickStartMediaUploadTour(), + QuickStartFollowTour() + ] + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/SharingAccountViewController.swift b/WordPress/Classes/ViewRelated/Blog/SharingAccountViewController.swift index 871189bcdfd6..8e3c0d89cd22 100644 --- a/WordPress/Classes/ViewRelated/Blog/SharingAccountViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/SharingAccountViewController.swift @@ -57,17 +57,10 @@ import WordPressShared /// Configures the appearance of the nav bar. /// fileprivate func configureNavbar() { - let image = Gridicon.iconOfType(.cross) + let image = UIImage.gridicon(.cross) let closeButton = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(SharingAccountViewController.handleCloseTapped(_:))) - closeButton.tintColor = UIColor.white + closeButton.tintColor = .appBarTint navigationItem.leftBarButtonItem = closeButton - - // The preceding WPWebViewController changes the default navbar appearance. Restore it. - if let navBar = navigationController?.navigationBar { - navBar.shadowImage = WPStyleGuide.navigationBarShadowImage() - navBar.setBackgroundImage(WPStyleGuide.navigationBarBackgroundImage(), for: .default) - navBar.barStyle = WPStyleGuide.navigationBarBarStyle() - } } diff --git a/WordPress/Classes/ViewRelated/Blog/SharingAuthorizationHelper.m b/WordPress/Classes/ViewRelated/Blog/SharingAuthorizationHelper.m index fc5a54a304f4..5477f3ee88c8 100644 --- a/WordPress/Classes/ViewRelated/Blog/SharingAuthorizationHelper.m +++ b/WordPress/Classes/ViewRelated/Blog/SharingAuthorizationHelper.m @@ -93,7 +93,7 @@ - (void)authorizeWithConnectionURL:(NSURL *)connectionURL { SharingAuthorizationWebViewController *webViewController = [[SharingAuthorizationWebViewController alloc] initWith:self.publicizeService url:connectionURL for:self.blog delegate:self]; - self.navController = [[UINavigationController alloc] initWithRootViewController:webViewController]; + self.navController = [[LightNavigationController alloc] initWithRootViewController:webViewController]; self.navController.modalPresentationStyle = UIModalPresentationFormSheet; [self.viewController presentViewController:self.navController animated:YES completion:nil]; } @@ -109,7 +109,7 @@ - (void)authorizeDidSucceed:(PublicizeService *)publicizer if (self.reconnecting) { // Resync publicize connections. - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self.blog managedObjectContext]]; + SharingSyncService *sharingService = [[SharingSyncService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [sharingService syncPublicizeConnectionsForBlog:self.blog success:^{ [self handleReconnectSucceeded]; } failure:^(NSError *error) { @@ -175,7 +175,7 @@ - (void)fetchKeyringConnectionsForService:(PublicizeService *)pubServ [self.delegate sharingAuthorizationHelper:self willFetchKeyringsForService:self.publicizeService]; } - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self.blog managedObjectContext]]; + SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; __weak __typeof__(self) weakSelf = self; [sharingService fetchKeyringConnectionsForBlog:self.blog success:^(NSArray *keyringConnections) { if ([weakSelf.delegate respondsToSelector:@selector(sharingAuthorizationHelper:didFetchKeyringsForService:)]) { @@ -212,7 +212,7 @@ - (void)fetchKeyringConnectionsForService:(PublicizeService *)pubServ } [weakSelf showAccountSelectorForKeyrings:marr]; - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { if ([self.delegate respondsToSelector:@selector(sharingAuthorizationHelper:keyringFetchFailedForService:)]) { [self.delegate sharingAuthorizationHelper:self keyringFetchFailedForService:self.publicizeService]; return; @@ -318,7 +318,7 @@ - (void)confirmNewConnection:(KeyringConnection *)keyringConnection withExternal [alertController addCancelActionWithTitle:cancel handler:nil]; - [alertController addDefaultActionWithTitle:connect handler:^(UIAlertAction *action) { + [alertController addDefaultActionWithTitle:connect handler:^(UIAlertAction * __unused action) { [self updateConnection:currentPublicizeConnection forKeyringConnection:keyringConnection withExternalID:externalID]; }]; @@ -340,7 +340,7 @@ - (void)updateConnection:(PublicizeConnection *)publicizeConnection forKeyringCo [self dismissNavViewController]; - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self.blog managedObjectContext]]; + SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; [sharingService updateExternalID:externalID forBlog:self.blog forPublicizeConnection:publicizeConnection success:^{ if ([self.delegate respondsToSelector:@selector(sharingAuthorizationHelper:didConnectToService:withPublicizeConnection:)]) { @@ -367,7 +367,7 @@ - (void)connectToServiceWithKeyringConnection:(KeyringConnection *)keyConn andEx [self dismissNavViewController]; - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self.blog managedObjectContext]]; + SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; [sharingService createPublicizeConnectionForBlog:self.blog keyring:keyConn externalUserID:externalUserID diff --git a/WordPress/Classes/ViewRelated/Blog/SharingAuthorizationWebViewController.swift b/WordPress/Classes/ViewRelated/Blog/SharingAuthorizationWebViewController.swift index 93e98d779f8a..cf8d2ddd801b 100644 --- a/WordPress/Classes/ViewRelated/Blog/SharingAuthorizationWebViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/SharingAuthorizationWebViewController.swift @@ -1,4 +1,5 @@ import WebKit +import CoreMedia @objc protocol SharingAuthorizationDelegate: NSObjectProtocol { @@ -14,31 +15,7 @@ protocol SharingAuthorizationDelegate: NSObjectProtocol { @objc class SharingAuthorizationWebViewController: WPWebViewController { - /// Classify actions taken by the web API - /// - private enum AuthorizeAction: Int { - case none - case unknown - case request - case verify - case deny - } - private static let loginURL = "https://wordpress.com/wp-login.php" - private static let authorizationPrefix = "https://public-api.wordpress.com/connect/" - private static let requestActionParameter = "action=request" - private static let verifyActionParameter = "action=verify" - private static let denyActionParameter = "action=deny" - - // Special handling for the inconsistent way that services respond to a user's choice to decline - // oauth authorization. - // Right now we have no clear way to know if Tumblr fails. This is something we should try - // fixing moving forward. - // Path does not set the action param or call the callback. It forwards to its own URL ending in /decline. - private static let declinePath = "/decline" - private static let userRefused = "oauth_problem=user_refused" - private static let authorizationDenied = "denied=" - private static let accessDenied = "error=access_denied" /// Verification loading -- dismiss on completion /// @@ -59,7 +36,7 @@ class SharingAuthorizationWebViewController: WPWebViewController { super.init(nibName: "WPWebViewController", bundle: nil) - self.authenticator = WebViewAuthenticator(blog: blog) + self.authenticator = RequestAuthenticator(blog: blog) self.secureInteraction = true self.url = url } @@ -141,49 +118,6 @@ class SharingAuthorizationWebViewController: WPWebViewController { private func displayLoadError(error: NSError) { delegate?.authorize(self.publicizer, didFailWithError: error) } - - // MARK: - URL Interpretation - - private func authorizeAction(from url: URL) -> AuthorizeAction { - let requested = url.absoluteString - - // Path oauth declines are handled by a redirect to a path.com URL, so check this first. - if requested.range(of: SharingAuthorizationWebViewController.declinePath) != nil { - return .deny - } - - if !requested.hasPrefix(SharingAuthorizationWebViewController.authorizationPrefix) { - return .none - } - - if requested.range(of: SharingAuthorizationWebViewController.requestActionParameter) != nil { - return .request - } - - // Check the rest of the various decline ranges - if requested.range(of: SharingAuthorizationWebViewController.denyActionParameter) != nil { - return .deny - } - - // LinkedIn - if requested.range(of: SharingAuthorizationWebViewController.userRefused) != nil { - return .deny - } - - // Facebook and Google+ - if requested.range(of: SharingAuthorizationWebViewController.accessDenied) != nil { - return .deny - } - - // If we've made it this far and verifyRange is found then we're *probably* - // verifying the oauth request. There are edge cases ( :cough: tumblr :cough: ) - // where verification is declined and we get a false positive. - if requested.range(of: SharingAuthorizationWebViewController.verifyActionParameter) != nil { - return .verify - } - - return .unknown - } } // MARK: - WKNavigationDelegate @@ -199,7 +133,7 @@ extension SharingAuthorizationWebViewController { return } - let action = authorizeAction(from: url) + let action = PublicizeConnectionURLMatcher.authorizeAction(for: url) switch action { case .none: diff --git a/WordPress/Classes/ViewRelated/Blog/SharingButtonsViewController.swift b/WordPress/Classes/ViewRelated/Blog/SharingButtonsViewController.swift index 8ccb12f976f6..e2a14e95a856 100644 --- a/WordPress/Classes/ViewRelated/Blog/SharingButtonsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/SharingButtonsViewController.swift @@ -40,7 +40,12 @@ import WordPressShared let labelTitle = NSLocalizedString("Label", comment: "Noun. Title for the setting to edit the sharing label text.") let twitterUsernameTitle = NSLocalizedString("Twitter Username", comment: "Title for the setting to edit the twitter username used when sharing to twitter.") let twitterServiceID = "twitter" - let managedObjectContext = ContextManager.sharedInstance().newMainContextChildContext() + + /// Core Data Context + /// + var viewContext: NSManagedObjectContext { + ContextManager.sharedInstance().mainContext + } struct SharingCellIdentifiers { static let SettingsCellIdentifier = "SettingsTableViewCellIdentifier" @@ -53,7 +58,7 @@ import WordPressShared @objc init(blog: Blog) { self.blog = blog - super.init(style: .grouped) + super.init(style: .insetGrouped) } required init?(coder aDecoder: NSCoder) { @@ -65,8 +70,15 @@ import WordPressShared navigationItem.title = NSLocalizedString("Manage", comment: "Verb. Title of the screen for managing sharing buttons and settings related to sharing.") - let service = SharingService(managedObjectContext: managedObjectContext) - buttons = service.allSharingButtonsForBlog(self.blog) + extendedLayoutIncludesOpaqueBars = true + + if isModal() { + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, + target: self, + action: #selector(doneButtonTapped)) + } + + buttons = (try? SharingButton.allSharingButtons(for: blog, in: viewContext)) ?? [] configureTableView() setupSections() @@ -385,13 +397,16 @@ import WordPressShared var rows = [SharingButtonsRow]() let row = SharingSwitchRow() - row.configureCell = {[unowned self] (cell: UITableViewCell) in + row.configureCell = {[weak self] (cell: UITableViewCell) in + guard let self = self else { return } if let switchCell = cell as? SwitchTableViewCell { cell.editingAccessoryView = cell.accessoryView cell.editingAccessoryType = cell.accessoryType switchCell.textLabel?.text = NSLocalizedString("Edit sharing buttons", comment: "Title for the edit sharing buttons section") switchCell.on = self.buttonsSection.editing - switchCell.onChange = { newValue in + switchCell.onChange = { [weak self] newValue in + guard let self = self else { return } + WPAnalytics.track(.sharingButtonsEditSharingButtonsToggled, properties: ["checked": newValue as Any], blog: self.blog) self.buttonsSection.editing = !self.buttonsSection.editing self.updateButtonOrderAfterEditing() self.reloadButtons() @@ -427,16 +442,19 @@ import WordPressShared var rows = [SharingButtonsRow]() let row = SharingSwitchRow() - row.configureCell = {[unowned self] (cell: UITableViewCell) in + row.configureCell = {[weak self] (cell: UITableViewCell) in + guard let self = self else { return } if let switchCell = cell as? SwitchTableViewCell { cell.editingAccessoryView = cell.accessoryView cell.editingAccessoryType = cell.accessoryType switchCell.textLabel?.text = NSLocalizedString("Edit \"More\" button", comment: "Title for the edit more button section") switchCell.on = self.moreSection.editing - switchCell.onChange = { newValue in + switchCell.onChange = { [weak self] newValue in + guard let self = self else { return } + WPAnalytics.track(.sharingButtonsEditMoreButtonToggled, properties: ["checked": newValue as Any], blog: self.blog) self.updateButtonOrderAfterEditing() self.moreSection.editing = !self.moreSection.editing - self.reloadButtons() + self.reloadButtons() } } } @@ -518,8 +536,7 @@ import WordPressShared tableView.reloadData() } - let context = ContextManager.sharedInstance().mainContext - let service = BlogService(managedObjectContext: context) + let service = BlogService(coreDataStack: ContextManager.shared) let dotComID = blog.dotComID service.updateSettings( for: self.blog, @@ -537,7 +554,7 @@ import WordPressShared /// when finished. Fails silently if there is an error. /// private func syncSharingButtons() { - let service = SharingService(managedObjectContext: managedObjectContext) + let service = SharingService(coreDataStack: ContextManager.shared) service.syncSharingButtonsForBlog(self.blog, success: { [weak self] in self?.reloadButtons() @@ -551,7 +568,7 @@ import WordPressShared /// when finished. Fails silently if there is an error. /// private func syncSharingSettings() { - let service = BlogService(managedObjectContext: managedObjectContext) + let service = BlogService(coreDataStack: ContextManager.shared) service.syncSettings(for: blog, success: { [weak self] in self?.reloadSettingsSections() }, @@ -613,18 +630,17 @@ import WordPressShared /// private func saveButtonChanges(_ refreshAfterSync: Bool) { let context = ContextManager.sharedInstance().mainContext - ContextManager.sharedInstance().save(context) { [weak self] in + ContextManager.sharedInstance().save(context, completion: { [weak self] in self?.reloadButtons() self?.syncButtonChangesToBlog(refreshAfterSync) - } + }, on: .main) } /// Retrives a fresh copy of the SharingButtons from core data, updating the /// `buttons` property and refreshes the button section and the more section. /// private func reloadButtons() { - let service = SharingService(managedObjectContext: managedObjectContext) - buttons = service.allSharingButtonsForBlog(blog) + buttons = (try? SharingButton.allSharingButtons(for: blog, in: viewContext)) ?? [] refreshButtonsSection() refreshMoreSection() @@ -635,7 +651,7 @@ import WordPressShared /// - Parameter refresh: True if the tableview sections should be reloaded. /// private func syncButtonChangesToBlog(_ refresh: Bool) { - let service = SharingService(managedObjectContext: managedObjectContext) + let service = SharingService(coreDataStack: ContextManager.shared) service.updateSharingButtonsForBlog(blog, sharingButtons: buttons, success: {[weak self] in @@ -668,6 +684,10 @@ import WordPressShared // MARK: - Actions + @objc private func doneButtonTapped() { + dismiss(animated: true) + } + /// Called when the user taps the label row. Shows a controller to change the /// edit label text. /// @@ -682,6 +702,7 @@ import WordPressShared guard value != self.blog.settings!.sharingLabel else { return } + WPAnalytics.track(.sharingButtonsLabelChanged, properties: [:], blog: blog) self.blog.settings!.sharingLabel = value self.saveBlogSettingsChanges(true) } diff --git a/WordPress/Classes/ViewRelated/Blog/SharingConnectionsViewController.m b/WordPress/Classes/ViewRelated/Blog/SharingConnectionsViewController.m index 0c0a0743e7ac..105bb6df687d 100644 --- a/WordPress/Classes/ViewRelated/Blog/SharingConnectionsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/SharingConnectionsViewController.m @@ -33,7 +33,7 @@ - (void)dealloc - (instancetype)initWithBlog:(Blog *)blog publicizeService:(PublicizeService *)publicizeService { NSParameterAssert([blog isKindOfClass:[Blog class]]); - self = [self initWithStyle:UITableViewStyleGrouped]; + self = [self initWithStyle:UITableViewStyleInsetGrouped]; if (self) { _blog = blog; _publicizeService = publicizeService; @@ -110,7 +110,7 @@ - (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInte if ([self hasConnectedAccounts] && section == 0) { title = NSLocalizedString(@"Connected Accounts", @"Noun. Title. Title for the list of accounts for third party sharing services."); } else { - NSString *format = NSLocalizedString(@"Publicize to %@", @"Title. `Publicize` is used as a verb here but `Share` (verb) would also work here. The `%@` is a placeholder for the service name."); + NSString *format = NSLocalizedString(@"Share post to %@", @"Title. `The `%@` is a placeholder for the service name."); title = [NSString stringWithFormat:format, self.publicizeService.label]; } return title; @@ -164,7 +164,7 @@ - (void)configureConnectionCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath cell.textLabel.textAlignment = NSTextAlignmentCenter; cell.textLabel.text = [self titleForConnectionCell]; if (self.connecting) { - UIActivityIndicatorView *activityView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + UIActivityIndicatorView *activityView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; cell.accessoryView = activityView; cell.selectionStyle = UITableViewCellSelectionStyleNone; [activityView startAnimating]; @@ -257,6 +257,8 @@ - (void)sharingAuthorizationHelper:(SharingAuthorizationHelper *)helper willConn - (void)sharingAuthorizationHelper:(SharingAuthorizationHelper *)helper didConnectToService:(PublicizeService *)service withPublicizeConnection:(PublicizeConnection *)keyringConnection { + [[QuickStartTourGuide shared] completeSharingTourForBlog:self.blog]; + self.connecting = NO; [self.tableView reloadData]; [self showDetailForConnection:keyringConnection]; @@ -278,7 +280,7 @@ - (void)sharingAuthorizationHelper:(SharingAuthorizationHelper *)helper __weak SharingConnectionsViewController *sharingConnectionsVC = self; UIAlertAction* continueAction = [UIAlertAction actionWithTitle:validationError.continueTitle style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { + handler:^(UIAlertAction * __unused action) { if (validationError.continueURL) { [sharingConnectionsVC handleContinueURLTapped: validationError.continueURL]; } diff --git a/WordPress/Classes/ViewRelated/Blog/SharingDetailViewController.m b/WordPress/Classes/ViewRelated/Blog/SharingDetailViewController.m index 422aeb156eec..7e783217a96e 100644 --- a/WordPress/Classes/ViewRelated/Blog/SharingDetailViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/SharingDetailViewController.m @@ -34,8 +34,7 @@ - (instancetype)initWithBlog:(Blog *)blog if (self) { _blog = blog; _publicizeConnection = connection; - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self managedObjectContext]]; - PublicizeService *publicizeService = [sharingService findPublicizeServiceNamed:connection.service]; + PublicizeService *publicizeService = [PublicizeService lookupPublicizeServiceNamed:connection.service inContext:[self managedObjectContext]]; if (publicizeService) { self.helper = [[SharingAuthorizationHelper alloc] initWithViewController:self blog:self.blog @@ -82,7 +81,7 @@ - (void)configureReconnectCell: (UITableViewCell *)cell cell.textLabel.text = NSLocalizedString(@"Reconnect", @"Verb. Text label. Tapping attempts to reconnect a third-party sharing service to the user's blog."); [WPStyleGuide configureTableViewActionCell:cell]; cell.textLabel.textAlignment = NSTextAlignmentCenter; - cell.textLabel.textColor = [UIColor murielAccent]; + cell.textLabel.textColor = [UIColor murielPrimary]; } - (void)configureLearnMoreCell: (UITableViewCell *)cell @@ -206,7 +205,7 @@ - (SwitchTableViewCell *)switchTableViewCell - (void)updateSharedGlobally:(BOOL)shared { __weak __typeof(self) weakSelf = self; - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self managedObjectContext]]; + SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; [sharingService updateSharedForBlog:self.blog shared:shared forPublicizeConnection:self.publicizeConnection @@ -225,7 +224,7 @@ - (void)reconnectPublicizeConnection return; } - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self managedObjectContext]]; + SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; __weak __typeof(self) weakSelf = self; if (self.helper == nil) { @@ -243,7 +242,7 @@ - (void)reconnectPublicizeConnection - (void)disconnectPublicizeConnection { - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self managedObjectContext]]; + SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; [sharingService deletePublicizeConnectionForBlog:self.blog pubConn:self.publicizeConnection success:nil failure:^(NSError *error) { DDLogError([error description]); [SVProgressHUD showDismissibleErrorWithStatus:NSLocalizedString(@"Disconnect failed", @"Message to show when Publicize disconnect failed")]; @@ -266,7 +265,7 @@ - (void)promptToConfirmDisconnect message:message preferredStyle:UIAlertControllerStyleActionSheet]; [alert addDestructiveActionWithTitle:NSLocalizedString(@"Disconnect", @"Verb. Title of a button. Tapping disconnects a third-party sharing service from the user's blog.") - handler:^(UIAlertAction *action) { + handler:^(UIAlertAction * __unused action) { [self disconnectPublicizeConnection]; }]; diff --git a/WordPress/Classes/ViewRelated/Blog/SharingViewController.h b/WordPress/Classes/ViewRelated/Blog/SharingViewController.h index d8ec343b4627..6132645f5ff4 100644 --- a/WordPress/Classes/ViewRelated/Blog/SharingViewController.h +++ b/WordPress/Classes/ViewRelated/Blog/SharingViewController.h @@ -1,11 +1,23 @@ #import <UIKit/UIKit.h> +@protocol SharingViewControllerDelegate + +- (void)didChangePublicizeServices; + +@end + +@protocol JetpackModuleHelperDelegate + +- (void)jetpackModuleEnabled; + +@end + @class Blog; /** * @brief Controller to display Calypso sharing options */ -@interface SharingViewController : UITableViewController +@interface SharingViewController : UITableViewController<UIAdaptivePresentationControllerDelegate, JetpackModuleHelperDelegate> /** * @brief Convenience initializer @@ -14,6 +26,6 @@ * * @return New instance of SharingViewController */ -- (instancetype)initWithBlog:(Blog *)blog; +- (instancetype)initWithBlog:(Blog *)blog delegate:(id)delegate; @end diff --git a/WordPress/Classes/ViewRelated/Blog/SharingViewController.m b/WordPress/Classes/ViewRelated/Blog/SharingViewController.m index 58e9772c4802..ca3947d428bf 100644 --- a/WordPress/Classes/ViewRelated/Blog/SharingViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/SharingViewController.m @@ -19,18 +19,23 @@ @interface SharingViewController () @property (nonatomic, strong, readonly) Blog *blog; @property (nonatomic, strong) NSArray *publicizeServices; +@property (nonatomic, weak) id delegate; +@property (nonatomic) PublicizeServicesState *publicizeServicesState; +@property (nonatomic) JetpackModuleHelper *jetpackModuleHelper; @end @implementation SharingViewController -- (instancetype)initWithBlog:(Blog *)blog +- (instancetype)initWithBlog:(Blog *)blog delegate:(id)delegate { NSParameterAssert([blog isKindOfClass:[Blog class]]); - self = [self initWithStyle:UITableViewStyleGrouped]; + self = [self initWithStyle:UITableViewStyleInsetGrouped]; if (self) { _blog = blog; _publicizeServices = [NSMutableArray new]; + _delegate = delegate; + _publicizeServicesState = [PublicizeServicesState new]; } return self; } @@ -40,9 +45,29 @@ - (void)viewDidLoad [super viewDidLoad]; self.navigationItem.title = NSLocalizedString(@"Sharing", @"Title for blog detail sharing screen."); + + self.extendedLayoutIncludesOpaqueBars = YES; + + if (self.isModal) { + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone + target:self + action:@selector(doneButtonTapped)]; + } [WPStyleGuide configureColorsForView:self.view andTableView:self.tableView]; - [self syncServices]; + [self.publicizeServicesState addInitialConnections:[self allConnections]]; + + self.navigationController.presentationController.delegate = self; + + if ([self.blog supportsPublicize]) { + [self syncServices]; + } else { + self.jetpackModuleHelper = [[JetpackModuleHelper alloc] initWithViewController:self moduleName:@"publicize" blog:self.blog]; + + [self.jetpackModuleHelper showWithTitle:NSLocalizedString(@"Enable Publicize", "Text shown when the site doesn't have the Publicize module enabled.") subtitle:NSLocalizedString(@"In order to share your published posts to your social media you need to enable the Publicize module.", "Title of button to enable publicize.")]; + + self.tableView.dataSource = NULL; + } } - (void)viewWillAppear:(BOOL)animated @@ -58,14 +83,23 @@ -(void)viewWillDisappear:(BOOL)animated [ReachabilityUtils dismissNoInternetConnectionNotice]; } +-(void)presentationControllerDidDismiss:(UIPresentationController *)presentationController +{ + [self notifyDelegatePublicizeServicesChangedIfNeeded]; +} + - (void)refreshPublicizers { - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self managedObjectContext]]; - self.publicizeServices = [sharingService allPublicizeServices]; + self.publicizeServices = [PublicizeService allPublicizeServicesInContext:[self managedObjectContext] error:nil]; [self.tableView reloadData]; } +- (void)doneButtonTapped +{ + [self notifyDelegatePublicizeServicesChangedIfNeeded]; + [self dismissViewControllerAnimated:YES completion:nil]; +} #pragma mark - UITableView Delegate methods @@ -82,7 +116,7 @@ - (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInte { switch (section) { case SharingPublicizeServices: - return NSLocalizedString(@"Connections", @"Section title for Publicize services in Sharing screen"); + return NSLocalizedString(@"Jetpack Social Connections", @"Section title for Publicize services in Sharing screen"); case SharingButtons: return NSLocalizedString(@"Sharing Buttons", @"Section title for the sharing buttons section in the Sharing screen"); default: @@ -137,7 +171,7 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N [WPStyleGuide configureTableViewCell:cell]; cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; - if (indexPath == [NSIndexPath indexPathForRow:0 inSection:0] && [[QuickStartTourGuide find] isCurrentElement:QuickStartTourElementConnections]) { + if (indexPath == [NSIndexPath indexPathForRow:0 inSection:0] && [[QuickStartTourGuide shared] isCurrentElement:QuickStartTourElementConnections]) { cell.accessoryView = [QuickStartSpotlightView new]; } else { cell.accessoryView = nil; @@ -205,7 +239,7 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath controller = [[SharingConnectionsViewController alloc] initWithBlog:self.blog publicizeService:publicizer]; [WPAppAnalytics track:WPAnalyticsStatSharingOpenedPublicize withBlog:self.blog]; - [[QuickStartTourGuide find] visited:QuickStartTourElementConnections]; + [[QuickStartTourGuide shared] visited:QuickStartTourElementConnections]; } else { controller = [[SharingButtonsViewController alloc] initWithBlog:self.blog]; [WPAppAnalytics track:WPAnalyticsStatSharingOpenedSharingButtonSettings withBlog:self.blog]; @@ -214,6 +248,22 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath [self.navigationController pushViewController:controller animated:YES]; } +- (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section +{ + if (section == SharingButtons && [SharingViewController jetpackBrandingVisibile]) { + return [self makeJetpackBadge]; + } + + return nil; +} + +#pragma mark - JetpackModuleHelper + +- (void)jetpackModuleEnabled +{ + self.tableView.dataSource = self; + [self syncServices]; +} #pragma mark - Publicizer management @@ -229,6 +279,25 @@ - (NSArray *)connectionsForService:(PublicizeService *)publicizeService return [NSArray arrayWithArray:connections]; } +- (NSArray *)allConnections +{ + NSMutableArray *allConnections = [NSMutableArray new]; + for (PublicizeService *service in self.publicizeServices) { + NSArray *connections = [self connectionsForService:service]; + if (connections.count > 0) { + [allConnections addObjectsFromArray:connections]; + } + } + return allConnections; +} + +-(void)notifyDelegatePublicizeServicesChangedIfNeeded +{ + if ([self.publicizeServicesState hasAddedNewConnectionTo:[self allConnections]]) { + [self.delegate didChangePublicizeServices]; + } +} + - (NSManagedObjectContext *)managedObjectContext { return self.blog.managedObjectContext; @@ -254,15 +323,15 @@ -(void)showConnectionError - (void)syncPublicizeServices { - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self managedObjectContext]]; + SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; __weak __typeof__(self) weakSelf = self; [sharingService syncPublicizeServicesForBlog:self.blog success:^{ [weakSelf syncConnections]; - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { if (!ReachabilityUtils.isInternetReachable) { [weakSelf showConnectionError]; } else { - [SVProgressHUD showDismissibleErrorWithStatus:NSLocalizedString(@"Publicize service synchronization failed", @"Message to show when Publicize service synchronization failed")]; + [SVProgressHUD showDismissibleErrorWithStatus:NSLocalizedString(@"Jetpack Social service synchronization failed", @"Message to show when Publicize service synchronization failed")]; [weakSelf refreshPublicizers]; } }]; @@ -270,15 +339,15 @@ - (void)syncPublicizeServices - (void)syncConnections { - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self managedObjectContext]]; + SharingSyncService *sharingService = [[SharingSyncService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; __weak __typeof__(self) weakSelf = self; [sharingService syncPublicizeConnectionsForBlog:self.blog success:^{ [weakSelf refreshPublicizers]; - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { if (!ReachabilityUtils.isInternetReachable) { [weakSelf showConnectionError]; } else { - [SVProgressHUD showDismissibleErrorWithStatus:NSLocalizedString(@"Publicize connection synchronization failed", @"Message to show when Publicize connection synchronization failed")]; + [SVProgressHUD showDismissibleErrorWithStatus:NSLocalizedString(@"Jetpack Social connection synchronization failed", @"Message to show when Publicize connection synchronization failed")]; [weakSelf refreshPublicizers]; } }]; @@ -288,11 +357,12 @@ - (void)syncSharingButtonsIfNeeded { // Sync sharing buttons if they have never been synced. Otherwise, the // management vc can worry about fetching the latest sharing buttons. - SharingService *sharingService = [[SharingService alloc] initWithManagedObjectContext:[self managedObjectContext]]; - NSArray *buttons = [sharingService allSharingButtonsForBlog:self.blog]; + NSArray *buttons = [SharingButton allSharingButtonsForBlog:self.blog inContext:[self managedObjectContext] error:nil]; if ([buttons count] > 0) { return; } + + SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; [sharingService syncSharingButtonsForBlog:self.blog success:nil failure:^(NSError *error) { DDLogError([error description]); }]; diff --git a/WordPress/Classes/ViewRelated/Blog/SharingViewController.swift b/WordPress/Classes/ViewRelated/Blog/SharingViewController.swift new file mode 100644 index 000000000000..711f99359053 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/SharingViewController.swift @@ -0,0 +1,28 @@ +import Foundation + +extension SharingViewController { + + static let jetpackBadgePadding: CGFloat = 30 + + @objc + static func jetpackBrandingVisibile() -> Bool { + return JetpackBrandingVisibility.all.enabled + } + + @objc + func makeJetpackBadge() -> UIView { + let textProvider = JetpackBrandingTextProvider(screen: JetpackBadgeScreen.sharing) + let badge = JetpackButton.makeBadgeView(title: textProvider.brandingText(), + topPadding: Self.jetpackBadgePadding, + bottomPadding: Self.jetpackBadgePadding, + target: self, + selector: #selector(presentJetpackOverlay)) + return badge + } + + @objc + func presentJetpackOverlay() { + JetpackBrandingCoordinator.presentOverlay(from: self) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBadgeTapped(screen: .sharing) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/DeleteSiteViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/DeleteSiteViewController.swift index df5a6184050c..6611b27bf466 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/DeleteSiteViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/DeleteSiteViewController.swift @@ -38,6 +38,7 @@ open class DeleteSiteViewController: UITableViewController { @IBOutlet fileprivate weak var supportButton: UIButton! @IBOutlet fileprivate weak var deleteSiteButton: UIButton! @IBOutlet private var deleteButtonContainerView: UIView! + private let alertHelper = DestructiveAlertHelper() // MARK: - View Lifecycle @@ -69,7 +70,7 @@ open class DeleteSiteViewController: UITableViewController { /// One time setup of section one (header) /// fileprivate func setupHeaderSection() { - let warningIcon = Gridicon.iconOfType(.notice, withSize: CGSize(width: 48.0, height: 48.0)) + let warningIcon = UIImage.gridicon(.notice, size: CGSize(width: 48.0, height: 48.0)) warningImage.image = warningIcon warningImage.tintColor = UIColor.warning siteTitleLabel.textColor = .neutral(.shade70) @@ -154,7 +155,7 @@ open class DeleteSiteViewController: UITableViewController { fileprivate func setupDeleteButton() { deleteButtonContainerView.backgroundColor = .listForeground - let trashIcon = Gridicon.iconOfType(.trash) + let trashIcon = UIImage.gridicon(.trash) deleteSiteButton.setTitle(NSLocalizedString("Delete Site", comment: "Button label for deleting the current site"), for: .normal) deleteSiteButton.tintColor = .error deleteSiteButton.setImage(trashIcon.imageWithTintColor(.error), for: .normal) @@ -167,8 +168,11 @@ open class DeleteSiteViewController: UITableViewController { // MARK: - Actions @IBAction func deleteSite(_ sender: Any) { + guard let alert = confirmDeleteController() else { + return + } tableView.deselectSelectedRowWithAnimation(true) - present(confirmDeleteController(), animated: true) + present(alert, animated: true) } @IBAction func contactSupport(_ sender: Any) { @@ -176,13 +180,9 @@ open class DeleteSiteViewController: UITableViewController { WPAppAnalytics.track(.siteSettingsStartOverContactSupportClicked, with: blog) - if ZendeskUtils.zendeskEnabled { - ZendeskUtils.sharedInstance.showNewRequestIfPossible(from: self, with: .deleteSite) - } else { - if let contact = URL(string: "https://support.wordpress.com/contact/") { - UIApplication.shared.open(contact) - } - } + let supportViewController = SupportTableViewController() + supportViewController.sourceTag = .deleteSite + supportViewController.showFromTabBar() } // MARK: - Delete Site Helpers @@ -191,55 +191,19 @@ open class DeleteSiteViewController: UITableViewController { /// /// - Returns: UIAlertController /// - fileprivate func confirmDeleteController() -> UIAlertController { - - // Create atributed strings for URL and message body so we can wrap the URL byCharWrapping. - let styledUrl: NSMutableAttributedString = NSMutableAttributedString(string: blog.displayURL! as String) - let urlParagraphStyle = NSMutableParagraphStyle() - urlParagraphStyle.lineBreakMode = .byCharWrapping - styledUrl.addAttribute(.paragraphStyle, value: urlParagraphStyle, range: NSMakeRange(0, styledUrl.string.count - 1)) - - let message = NSLocalizedString("\nTo confirm, please re-enter your site's address before deleting.\n\n", - comment: "Message of Delete Site confirmation alert; substitution is site's host.") - let styledMessage: NSMutableAttributedString = NSMutableAttributedString(string: message) - styledMessage.append(styledUrl) - - // Create alert - let confirmTitle = NSLocalizedString("Confirm Delete Site", comment: "Title of Delete Site confirmation alert") - let alertController = UIAlertController(title: confirmTitle, message: nil, preferredStyle: .alert) - alertController.setValue(styledMessage, forKey: "attributedMessage") - - let cancelTitle = NSLocalizedString("Cancel", comment: "Alert dismissal title") - alertController.addCancelActionWithTitle(cancelTitle, handler: nil) - - let deleteTitle = NSLocalizedString("Permanently Delete Site", comment: "Delete Site confirmation action title") - let deleteAction = UIAlertAction(title: deleteTitle, style: .destructive, handler: { action in - self.deleteSiteConfirmed() - }) - deleteAction.isEnabled = false - alertController.addAction(deleteAction) - - alertController.addTextField(configurationHandler: { textField in - textField.addTarget(self, action: #selector(DeleteSiteViewController.alertTextFieldDidChange(_:)), for: .editingChanged) - }) - - return alertController - } - - /// Verifies site address as password for Delete Site - /// - @objc func alertTextFieldDidChange(_ sender: UITextField) { - guard let deleteAction = (presentedViewController as? UIAlertController)?.actions.last else { - return + fileprivate func confirmDeleteController() -> UIAlertController? { + guard let value = blog.displayURL as String? else { + return nil } - guard deleteAction.style == .destructive else { - return - } + let title = NSLocalizedString("Confirm Delete Site", + comment: "Title of Delete Site confirmation alert") + let message = NSLocalizedString("\nTo confirm, please re-enter your site's address before deleting.\n\n", + comment: "Message of Delete Site confirmation alert; substitution is site's host.") + let destructiveActionTitle = NSLocalizedString("Permanently Delete Site", + comment: "Delete Site confirmation action title") - let prompt = blog.displayURL?.lowercased.trim() - let password = sender.text?.lowercased().trim() - deleteAction.isEnabled = prompt == password + return alertHelper.makeAlertWithConfirmation(title: title, message: message, valueToConfirm: value, destructiveActionTitle: destructiveActionTitle, destructiveAction: deleteSiteConfirmed) } /// Handles deletion of the blog's site and all content from WordPress.com @@ -253,7 +217,7 @@ open class DeleteSiteViewController: UITableViewController { let trackedBlog = blog WPAppAnalytics.track(.siteSettingsDeleteSiteRequested, with: trackedBlog) - let service = SiteManagementService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let service = SiteManagementService(coreDataStack: ContextManager.sharedInstance()) service.deleteSiteForBlog(blog, success: { [weak self] in WPAppAnalytics.track(.siteSettingsDeleteSiteResponseOK, with: trackedBlog) @@ -262,10 +226,13 @@ open class DeleteSiteViewController: UITableViewController { self?.updateNavigationStackAfterSiteDeletion() - let accountService = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext) - accountService.updateUserDetails(for: (accountService.defaultWordPressComAccount()!), - success: { () in }, - failure: { _ in }) + let context = ContextManager.shared.mainContext + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) + if let account = account { + AccountService(coreDataStack: ContextManager.sharedInstance()).updateUserDetails(for: account, + success: {}, + failure: { _ in }) + } }, failure: { error in DDLogError("Error deleting site: \(error.localizedDescription)") diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift new file mode 100644 index 000000000000..f5d34c42e9dc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/DestructiveAlertHelper.swift @@ -0,0 +1,56 @@ +import UIKit + +protocol DestructiveAlertHelperLogic { + var valueToConfirm: String? { get } + var alert: UIAlertController? { get } + func makeAlertWithConfirmation(title: String, message: String, valueToConfirm: String, destructiveActionTitle: String, destructiveAction: @escaping () -> Void) -> UIAlertController +} + +class DestructiveAlertHelper: DestructiveAlertHelperLogic { + private(set) var valueToConfirm: String? + private(set) var alert: UIAlertController? + + func makeAlertWithConfirmation(title: String, message: String, valueToConfirm: String, destructiveActionTitle: String, destructiveAction: @escaping () -> Void) -> UIAlertController { + self.valueToConfirm = valueToConfirm + + let attributedMessage: NSMutableAttributedString = NSMutableAttributedString(string: message) + + let attributedValue: NSMutableAttributedString = NSMutableAttributedString(string: valueToConfirm) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byCharWrapping + attributedValue.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, attributedValue.string.count - 1)) + attributedMessage.append(attributedValue) + + let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert) + alert.setValue(attributedMessage, forKey: "attributedMessage") + + let action = UIAlertAction(title: destructiveActionTitle, style: .destructive) { _ in + destructiveAction() + } + action.isEnabled = false + alert.addAction(action) + alert.addTextField { [weak self] in + $0.addTarget(self, action: #selector(self?.textFieldDidChange), for: .editingChanged) + } + + let cancelTitle = NSLocalizedString("Cancel", comment: "Alert dismissal title") + alert.addCancelActionWithTitle(cancelTitle) + self.alert = alert + + return alert + } +} + +// MARK: - Private Methods +private extension DestructiveAlertHelper { + @objc func textFieldDidChange(_ sender: UITextField) { + guard let destructiveAction = alert?.actions.first, + destructiveAction.style == .destructive else { + return + } + + let value = valueToConfirm?.lowercased().trim() + let typedValue = sender.text?.lowercased().trim() + destructiveAction.isEnabled = value == typedValue + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/SiteSettingsViewController+SiteManagement.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/SiteSettingsViewController+SiteManagement.swift index dd1ff9466e73..86bc60fbec46 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/SiteSettingsViewController+SiteManagement.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/SiteSettingsViewController+SiteManagement.swift @@ -48,7 +48,7 @@ public extension SiteSettingsViewController { let trackedBlog = blog WPAppAnalytics.track(.siteSettingsExportSiteRequested, with: trackedBlog) - let service = SiteManagementService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let service = SiteManagementService(coreDataStack: ContextManager.sharedInstance()) service.exportContentForBlog(blog, success: { WPAppAnalytics.track(.siteSettingsExportSiteResponseOK, with: trackedBlog) @@ -79,7 +79,7 @@ public extension SiteSettingsViewController { SVProgressHUD.show(withStatus: status) WPAppAnalytics.track(.siteSettingsDeleteSitePurchasesRequested, with: blog) - let service = SiteManagementService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let service = SiteManagementService(coreDataStack: ContextManager.sharedInstance()) service.getActivePurchasesForBlog(blog, success: { [weak self] purchases in SVProgressHUD.dismiss() @@ -138,7 +138,7 @@ public extension SiteSettingsViewController { let configuration = WebViewControllerConfiguration(url: url) configuration.secureInteraction = true configuration.authenticate(blog: blog) - let controller = WebViewControllerFactory.controller(configuration: configuration) + let controller = WebViewControllerFactory.controller(configuration: configuration, source: "site_settings_show_purchases") controller.loadViewIfNeeded() controller.navigationItem.titleView = nil controller.title = NSLocalizedString("Purchases", comment: "Title of screen showing site purchases") diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/SiteTagsViewController.swift index b9a746b2d569..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 { @@ -36,7 +37,7 @@ final class SiteTagsViewController: UITableViewController { fileprivate lazy var searchController: UISearchController = { let returnValue = UISearchController(searchResultsController: nil) returnValue.hidesNavigationBarDuringPresentation = false - returnValue.dimsBackgroundDuringPresentation = false + returnValue.obscuresBackgroundDuringPresentation = false returnValue.searchResultsUpdater = self returnValue.delegate = self @@ -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? { @@ -365,7 +368,7 @@ extension SiteTagsViewController { let confirmationSubtitle = NSLocalizedString("Are you sure you want to delete this tag?", comment: "Message asking for confirmation on tag deletion") let actionTitle = NSLocalizedString("Delete", comment: "Delete") let cancelTitle = NSLocalizedString("Cancel", comment: "Alert dismissal title") - let trashIcon = Gridicon.iconOfType(.trash) + let trashIcon = UIImage.gridicon(.trash) return SettingsTitleSubtitleController.Confirmation(title: confirmationTitle, subtitle: confirmationSubtitle, diff --git a/WordPress/Classes/ViewRelated/Blog/Site Management/StartOverViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Management/StartOverViewController.swift index de452f37ec08..308f3891f70f 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Management/StartOverViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Management/StartOverViewController.swift @@ -15,7 +15,17 @@ open class StartOverViewController: UITableViewController, MFMailComposeViewCont @objc let headerView: TableViewHeaderDetailView = { let header = NSLocalizedString("Let Us Help", comment: "Heading for instructions on Start Over settings page") - let detail = NSLocalizedString("If you want a site but don't want any of the posts and pages you have now, our support team can delete your posts, pages, media, and comments for you.\n\nThis will keep your site and URL active, but give you a fresh start on your content creation. Just contact us to have your current content cleared out.", comment: "Detail for instructions on Start Over settings page") + /// GlotPress breaks if iOS keys are longer than 256 characters. + /// So lets split it to keep GlotPress happy :) GH: #15353 + let detail1 = NSLocalizedString("If you want a site but do not want any of the posts and pages you have now, " + + "our support team can delete your posts, pages, media, and comments for you.", + comment: "Detailed instructions on Start Over settings page. This is the first paragraph.") + let doubleNewline = "\n\n" + let detail2 = NSLocalizedString("This will keep your site and URL active, " + + "but give you a fresh start on your content creation. " + + "Just contact us to have your current content cleared out.", + comment: "Detailed instructions on Start Over settings page. This is the second paragraph.") + let detail = String.localizedStringWithFormat("%@%@%@", detail1, doubleNewline, detail2) return TableViewHeaderDetailView(title: header, detail: detail) }() diff --git a/WordPress/Classes/ViewRelated/Blog/Site Picker/EmojiRenderer.swift b/WordPress/Classes/ViewRelated/Blog/Site Picker/EmojiRenderer.swift new file mode 100644 index 000000000000..d78a0f26a566 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Site Picker/EmojiRenderer.swift @@ -0,0 +1,42 @@ +import UIKit + +/// Renders the specified emoji character into an image with the specified background color. +/// The image size and insets for the character can be overridden if necessary. +/// +struct EmojiRenderer { + let emoji: String + let backgroundColor: UIColor + let imageSize: CGSize + let insetSize: CGFloat + + init(emoji: String, backgroundColor: UIColor, imageSize: CGSize = CGSize(width: 512.0, height: 512.0), insetSize: CGFloat = 16.0) { + self.emoji = emoji + self.backgroundColor = backgroundColor + self.imageSize = imageSize + self.insetSize = insetSize + } + + func render() -> UIImage { + let rect = CGRect(origin: .zero, size: imageSize) + let insetRect = rect.insetBy(dx: insetSize, dy: insetSize) + + // The size passed in here doesn't matter, we just need the descriptor + guard let font = UIFont.fontFittingText(emoji, in: insetRect.size, fontDescriptor: UIFont.systemFont(ofSize: 100).fontDescriptor) else { + return UIImage() + } + + let renderer = UIGraphicsImageRenderer(size: rect.size) + let img = renderer.image { ctx in + backgroundColor.setFill() + ctx.fill(rect) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + + let attrs: [NSAttributedString.Key: Any] = [.font: font, .paragraphStyle: paragraphStyle] + emoji.draw(with: insetRect, options: .usesLineFragmentOrigin, attributes: attrs, context: nil) + } + + return img + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Picker/SiteIconPickerView.swift b/WordPress/Classes/ViewRelated/Blog/Site Picker/SiteIconPickerView.swift new file mode 100644 index 000000000000..8caa77a1aa57 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Site Picker/SiteIconPickerView.swift @@ -0,0 +1,383 @@ +import SwiftUI +import UIKit + +struct SiteIconPickerView: View { + private let initialIcon = Image("blavatar-default") + + var onCompletion: ((UIImage) -> Void)? = nil + var onDismiss: (() -> Void)? = nil + + @SwiftUI.State private var currentIcon: String? = nil + @SwiftUI.State private var currentBackgroundColor: UIColor = .init(hexString: "#969CA1") ?? .gray + @SwiftUI.State private var scrollOffsetColumn: Int? = nil + + private var hasMadeSelection: Bool { + currentIcon != nil + } + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: Metrics.mainStackSpacing) { + titleText + subtitleText + iconPreview + VStack(alignment: .leading, spacing: Metrics.mainStackSpacing) { + emojiSection + colorSection + } + } + .padding() + } + ZStack { + Color(UIColor.basicBackground) + Button(action: { saveIcon() }) { + saveButton + } + .padding() + .disabled(!hasMadeSelection) + } + .fixedSize(horizontal: false, vertical: true) + .overlay(saveButtonTopShadow, alignment: .top) + } + .overlay(dismissButton, alignment: .topTrailing) + } + + // MARK: - Subviews + + private var titleText: some View { + Text(TextContent.title) + .font(Font.system(.largeTitle, design: .serif)) + .fontWeight(.semibold) + .padding(.top, Metrics.titleTopPadding) + } + + private var subtitleText: some View { + Text(TextContent.hint) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(5) // For some reason the text won't wrap unless I set a specific line limit here + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + private var iconPreview: some View { + RoundedRectangle(cornerRadius: Metrics.cornerRadius) + .foregroundColor(Color(currentBackgroundColor)) + .frame(width: Metrics.previewSize, height: Metrics.previewSize) + .overlay(previewOverlay) + .overlay( + RoundedRectangle(cornerRadius: Metrics.cornerRadius) + .stroke(Color.secondary, lineWidth: 1.0) + ) + .padding(.vertical, Metrics.previewPadding) + } + + private var previewOverlay: some View { + if let currentIcon = currentIcon { + let renderer = EmojiRenderer(emoji: currentIcon, backgroundColor: currentBackgroundColor, + imageSize: CGSize(width: Metrics.previewSize, height: Metrics.previewSize), + insetSize: 0) + return Image(uiImage: renderer.render()) + .resizable() + .frame(width: Metrics.previewIconSize, height: Metrics.previewIconSize) + .foregroundColor(nil) + } else { + return initialIcon + .resizable() + .frame(width: Metrics.previewIconSize, height: Metrics.previewIconSize) + .foregroundColor(Color.secondary) + } + } + + private var emojiSection: some View { + Group { + Text(TextContent.emojiSectionTitle) + .font(.callout) + .fontWeight(.bold) + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top) { + emojiStackContent + } + .fixedSize() + .padding(.horizontal) + } + .padding(.leading, -Metrics.emojiSectionHorizontalPadding) + .padding(.trailing, -Metrics.emojiSectionHorizontalPadding) + .padding(.bottom, Metrics.emojiSectionBottomPadding) + .onAppear(perform: { + proxy.scrollTo(0, anchor: .leading) + }) + + emojiGroupPicker(proxy) + } + } + } + + private var emojiStackContent: some View { + Group { + let columnCount = SiteIconPickerView.allEmoji.count / Metrics.emojiRowCount + + ForEach(0..<columnCount, id: \.self) { index in + let startIndex = index * Metrics.emojiRowCount + let endIndex = min(startIndex + Metrics.emojiRowCount, SiteIconPickerView.allEmoji.count) + + let emojis = Array(SiteIconPickerView.allEmoji[startIndex..<endIndex]) + HStack() { + // Spacer used to pad content out from the leading edge when we + // scroll to a specific section + Spacer() + .frame(width: Metrics.emojiSectionHorizontalPadding) + EmojiColumnView(emojis: emojis) { emoji in + currentIcon = emoji + } + } + .id(index) // Id allows us to scroll to a specific section + } + } + } + + private func emojiGroupPicker(_ proxy: ScrollViewProxy) -> some View { + Group { + HStack(spacing: Metrics.emojiGroupPickerSpacing) { + ForEach(SiteIconPickerView.emojiGroupIcons.indices, id: \.self) { index in + Button(action: { + proxy.scrollTo(SiteIconPickerView.emojiGroups[index], anchor: .leading) + }, label: { + let icon = SiteIconPickerView.emojiGroupIcons[index] + + // Icons with a - prefix are custom icons, otherwise system icons + let image = icon.hasPrefix("-") ? + Image(String(icon.dropFirst())) : Image(systemName: icon) + image + .foregroundColor(Colors.emojiGroupPickerForeground) + .frame(width: Metrics.emojiGroupPickerSize, height: Metrics.emojiGroupPickerSize) + .padding(Metrics.emojiGroupPickerPadding) + }) + } + } + .padding(Metrics.emojiGroupPickerPadding) + .frame(maxWidth: .infinity) + .background(Capsule().foregroundColor(Colors.emojiGroupPickerBackground)) + .padding(.bottom, Metrics.emojiGroupPickerBottomPadding) + } + } + + private var colorSection: some View { + Group { + Text(TextContent.colorSectionTitle) + .font(.callout) + .fontWeight(.bold) + VStack(alignment: .leading) { + colorsRow(0..<Metrics.colorColumnCount) + colorsRow(Metrics.colorColumnCount..<SiteIconPickerView.backgroundColors.count) + } + .padding(.vertical, Metrics.colorSectionVerticalPadding) + } + } + + private func colorsRow(_ range: Range<Int>) -> some View { + HStack { + ForEach(SiteIconPickerView.backgroundColors[range], id: \.self) { color in + ColorCircleView(color: Color(color), isSelected: currentBackgroundColor == color) { + currentBackgroundColor = color + } + } + } + } + + private var saveButton: some View { + Text(TextContent.saveButtonTitle) + .font(.body) + .foregroundColor(hasMadeSelection ? .white : .secondary) + .frame(maxWidth: .infinity) + .frame(height: Metrics.saveButtonHeight) + .background( + RoundedRectangle(cornerRadius: Metrics.cornerRadius) + .fill(hasMadeSelection ? Color(.primary) : Colors.disabledButton) + ) + } + + private var saveButtonTopShadow: some View { + LinearGradient(gradient: Gradient(colors: [ + Color(.sRGB, white: 0.0, opacity: 0.1), + .clear + ]), startPoint: .bottom, endPoint: .top) + .frame(height: Metrics.saveButtonTopShadowHeight) + .offset(x: 0, y: -Metrics.saveButtonTopShadowHeight) + } + + // MARK: - Actions + + private func saveIcon() { + if let currentIcon = currentIcon { + let renderer = EmojiRenderer(emoji: currentIcon, + backgroundColor: currentBackgroundColor) + onCompletion?(renderer.render()) + } + } + + private var dismissButton: some View { + Button(action: { + onDismiss?() + }) { + Image(systemName: "xmark") + .foregroundColor(.black) + .padding() + } + } + + // MARK: - Emoji definitions + + private static let allEmoji: [String] = { + do { + if let url = Bundle.main.url(forResource: "Emoji", withExtension: "txt") { + let data = try Data(contentsOf: url) + let string = NSString(data: data, encoding: String.Encoding.utf8.rawValue) + return string?.components(separatedBy: "\n") ?? [] + } + } catch { + print(error) + } + + return [] + }() + + // Some of these are only available in iOS 15, so we've added them to the + // asset catalog as custom symbols. Those are marked with a - prefix, + // so we know how to load them later. + private static let emojiGroupIcons: [String] = [ + "face.smiling", + "-pawprint", + "-fork.knife", + "gamecontroller", + "building.2", + "lightbulb", + "x.squareroot", + "flag" + ] + + // Column number where this group of emoji begins + private static let emojiGroups: [Int] = [ + 0, // smilies & people + 153, // animals and nature + 217, // food + 260, // activities + 298, // places + 342, // objects + 414, // symbols + 512 // flags + ] + + // MARK: - Constants + + private enum TextContent { + static let title = NSLocalizedString("Create a site icon", comment: "Title for the Site Icon Picker") + static let hint = NSLocalizedString("Your site icon is used across the web: in browser tabs, site previews on social media, and the WordPress.com Reader.", comment: "Subtitle for the Site Icon Picker") + static let emojiSectionTitle = NSLocalizedString("Emoji", comment: "Title for the Emoji section") + static let colorSectionTitle = NSLocalizedString("Background Color", comment: "Title for the Background Color section") + static let saveButtonTitle = NSLocalizedString("Save", comment: "Title for the button that will save the site icon") + } + + private enum Metrics { + static let titleTopPadding: CGFloat = 20 + + static let mainStackSpacing: CGFloat = 10.0 + static let cornerRadius: CGFloat = 8.0 + + static let previewSize: CGFloat = 80.0 + static let previewIconSize: CGFloat = 70.0 + static let previewPadding: CGFloat = 10.0 + + static let emojiRowCount = 3 + static let emojiSectionHorizontalPadding: CGFloat = 20.0 + static let emojiSectionBottomPadding: CGFloat = 10.0 + + static let emojiGroupPickerSpacing: CGFloat = 10.0 + static let emojiGroupPickerSize: CGFloat = 22.0 + static let emojiGroupPickerPadding: CGFloat = 2.0 + static let emojiGroupPickerBottomPadding: CGFloat = 10.0 + + static let colorSectionVerticalPadding: CGFloat = 10.0 + static let colorColumnCount = 5 + + static let saveButtonHeight: CGFloat = 44.0 + static let saveButtonTopShadowHeight: CGFloat = 6.0 + } + + private enum Colors { + static let emojiGroupPickerForeground = Color(white: 0.5) + static let emojiGroupPickerBackground = Color(white: 0.95) + static let disabledButton = Color(white: 0.95) + } + + private static let backgroundColors = [ + UIColor(hexString: "#d1e4dd"), + UIColor(hexString: "#d1dfe4"), + UIColor(hexString: "#d1d1e4"), + UIColor(hexString: "#e4d1d1"), + UIColor(hexString: "#e4dad1"), + UIColor(hexString: "#eeeadd"), + UIColor(hexString: "#ffffff"), + UIColor(hexString: "#39414d"), + UIColor(hexString: "#28303d"), + UIColor.black + ].compactMap { $0 } +} + +private struct EmojiColumnView: View { + let emojis: [String] + let action: (String) -> Void + + var body: some View { + VStack { + ForEach(emojis, id: \.self) { emoji in + EmojiButton(emoji) { + action(emoji) + } + } + } + } +} + +/// Displays a single emoji character in a button +/// +private struct EmojiButton: View { + let emoji: String + let action: () -> Void + + init(_ emoji: String, action: @escaping () -> Void) { + self.emoji = emoji + self.action = action + } + + var body: some View { + Button(action: action) { + Text(emoji) + .font(.largeTitle) + .padding(2) + } + } +} + +/// A circle filled with the specified color, and outlined with various +/// styles depending on whether or not it is currently selected +/// +private struct ColorCircleView: View { + var color: Color + var isSelected: Bool + var action: () -> Void + + var body: some View { + Button(action: action) { + Circle() + .foregroundColor(color) + .frame(width: 44, height: 44) + .padding(2) + .overlay(isSelected ? Circle().stroke(Color.gray, lineWidth: 1.0) : Circle().stroke(Color(white: 0.8), lineWidth: 1.0)) + .padding(.bottom, 8) + .padding(.trailing, 8) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Picker/SitePickerViewController+QuickStart.swift b/WordPress/Classes/ViewRelated/Blog/Site Picker/SitePickerViewController+QuickStart.swift new file mode 100644 index 000000000000..528b4d52a487 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Site Picker/SitePickerViewController+QuickStart.swift @@ -0,0 +1,50 @@ +import Foundation + +private var alertWorkItem: DispatchWorkItem? + +extension SitePickerViewController { + + // do not start alert timer if the themes modal is still being presented + private var shouldStartAlertTimer: Bool { + !((self.presentedViewController as? UINavigationController)?.visibleViewController is WebKitViewController) + } + + func startObservingQuickStart() { + NotificationCenter.default.addObserver(forName: .QuickStartTourElementChangedNotification, object: nil, queue: nil) { [weak self] (notification) in + guard self?.blog.managedObjectContext != nil else { + return + } + + self?.blogDetailHeaderView.toggleSpotlightOnSiteTitle() + self?.blogDetailHeaderView.toggleSpotlightOnSiteUrl() + self?.blogDetailHeaderView.refreshIconImage() + } + } + + func stopObservingQuickStart() { + NotificationCenter.default.removeObserver(self) + } + + func startAlertTimer() { + guard shouldStartAlertTimer else { + return + } + let newWorkItem = DispatchWorkItem { [weak self] in + self?.showNoticeAsNeeded() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: newWorkItem) + alertWorkItem = newWorkItem + } + + func toggleSpotlightOnHeaderView() { + blogDetailHeaderView.toggleSpotlightOnSiteTitle() + blogDetailHeaderView.toggleSpotlightOnSiteUrl() + blogDetailHeaderView.toggleSpotlightOnSiteIcon() + } + + private func showNoticeAsNeeded() { + if let tourToSuggest = QuickStartTourGuide.shared.tourToSuggest(for: blog) { + QuickStartTourGuide.shared.suggest(tourToSuggest, for: blog) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Picker/SitePickerViewController+SiteIcon.swift b/WordPress/Classes/ViewRelated/Blog/Site Picker/SitePickerViewController+SiteIcon.swift new file mode 100644 index 000000000000..9a6556bb30de --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Site Picker/SitePickerViewController+SiteIcon.swift @@ -0,0 +1,218 @@ +import UIKit +import WordPressFlux +import WordPressShared +import SwiftUI +import SVProgressHUD + +extension SitePickerViewController { + + func showSiteIconSelectionAlert() { + let alert = UIAlertController(title: SiteIconAlertStrings.title, + message: nil, + preferredStyle: .actionSheet) + + alert.popoverPresentationController?.sourceView = blogDetailHeaderView.blavatarImageView.superview + alert.popoverPresentationController?.sourceRect = blogDetailHeaderView.blavatarImageView.frame + alert.popoverPresentationController?.permittedArrowDirections = .any + + alert.addDefaultActionWithTitle(SiteIconAlertStrings.Actions.chooseImage) { [weak self] _ in + NoticesDispatch.unlock() + self?.updateSiteIcon() + } + + alert.addDefaultActionWithTitle(SiteIconAlertStrings.Actions.createWithEmoji) { [weak self] _ in + NoticesDispatch.unlock() + self?.showEmojiPicker() + } + + alert.addDestructiveActionWithTitle(SiteIconAlertStrings.Actions.removeSiteIcon) { [weak self] _ in + NoticesDispatch.unlock() + self?.removeSiteIcon() + } + + alert.addCancelActionWithTitle(SiteIconAlertStrings.Actions.cancel) { [weak self] _ in + NoticesDispatch.unlock() + self?.startAlertTimer() + } + + present(alert, animated: true) + } + + func showUpdateSiteIconAlert() { + let alert = UIAlertController(title: nil, + message: nil, + preferredStyle: .actionSheet) + + alert.popoverPresentationController?.sourceView = blogDetailHeaderView.blavatarImageView.superview + alert.popoverPresentationController?.sourceRect = blogDetailHeaderView.blavatarImageView.frame + alert.popoverPresentationController?.permittedArrowDirections = .any + + alert.addDefaultActionWithTitle(SiteIconAlertStrings.Actions.changeSiteIcon) { [weak self] _ in + NoticesDispatch.unlock() + self?.updateSiteIcon() + } + + if blog.hasIcon { + alert.addDestructiveActionWithTitle(SiteIconAlertStrings.Actions.removeSiteIcon) { [weak self] _ in + NoticesDispatch.unlock() + self?.removeSiteIcon() + } + } + + alert.addCancelActionWithTitle(SiteIconAlertStrings.Actions.cancel) { [weak self] _ in + NoticesDispatch.unlock() + self?.startAlertTimer() + } + + present(alert, animated: true) + } + + func updateSiteIcon() { + siteIconPickerPresenter = SiteIconPickerPresenter(blog: blog) + siteIconPickerPresenter?.onCompletion = { [ weak self] media, error in + if error != nil { + self?.showErrorForSiteIconUpdate() + } else if let media = media { + self?.updateBlogIconWithMedia(media) + } else { + // If no media and no error the picker was canceled + self?.dismiss(animated: true) + } + + self?.siteIconPickerPresenter = nil + self?.startAlertTimer() + } + + siteIconPickerPresenter?.onIconSelection = { [weak self] in + self?.blogDetailHeaderView.updatingIcon = true + self?.dismiss(animated: true) + } + + siteIconPickerPresenter?.presentPickerFrom(self) + } + + func showEmojiPicker() { + var pickerView = SiteIconPickerView() + + pickerView.onCompletion = { [weak self] image in + self?.dismiss(animated: true, completion: nil) + self?.blogDetailHeaderView.updatingIcon = true + self?.uploadDroppedSiteIcon(image, completion: {}) + } + + pickerView.onDismiss = { [weak self] in + self?.dismiss(animated: true) + } + + let controller = UIHostingController(rootView: pickerView) + present(controller, animated: true) + } + + func removeSiteIcon() { + blogDetailHeaderView.updatingIcon = true + blog.settings?.iconMediaID = NSNumber(value: 0) + updateBlogSettingsAndRefreshIcon() + WPAnalytics.track(.siteSettingsSiteIconRemoved) + } + + func showErrorForSiteIconUpdate() { + SVProgressHUD.showDismissibleError(withStatus: SiteIconAlertStrings.Errors.iconUpdateFailed) + blogDetailHeaderView.updatingIcon = false + } + + func updateBlogIconWithMedia(_ media: Media) { + QuickStartTourGuide.shared.completeSiteIconTour(forBlog: blog) + blog.settings?.iconMediaID = media.mediaID + updateBlogSettingsAndRefreshIcon() + } + + func updateBlogSettingsAndRefreshIcon() { + blogService.updateSettings(for: blog, success: { [weak self] in + guard let self = self else { + return + } + self.blogService.syncBlog(self.blog, success: { + self.blogDetailHeaderView.updatingIcon = false + self.blogDetailHeaderView.refreshIconImage() + }, failure: { _ in }) + + }, failure: { [weak self] error in + self?.showErrorForSiteIconUpdate() + }) + } + + func uploadDroppedSiteIcon(_ image: UIImage, completion: @escaping (() -> Void)) { + let service = MediaImportService(coreDataStack: ContextManager.shared) + _ = service.createMedia( + with: image, + blog: blog, + post: nil, + receiveUpdate: nil, + thumbnailCallback: nil, + completion: { [weak self] media, error in + guard let media = media, error == nil else { + return + } + + var uploadProgress: Progress? + self?.mediaService.uploadMedia( + media, + automatedRetry: false, + progress: &uploadProgress, + success: { + self?.updateBlogIconWithMedia(media) + completion() + }, failure: { error in + self?.showErrorForSiteIconUpdate() + completion() + }) + }) + } + + func presentCropViewControllerForDroppedSiteIcon(_ image: UIImage?) { + guard let image = image else { + return + } + + let imageCropController = ImageCropViewController(image: image) + imageCropController.maskShape = .square + imageCropController.shouldShowCancelButton = true + + imageCropController.onCancel = { [weak self] in + self?.dismiss(animated: true) + self?.blogDetailHeaderView.updatingIcon = false + } + + imageCropController.onCompletion = { [weak self] image, modified in + self?.dismiss(animated: true) + self?.uploadDroppedSiteIcon(image, completion: { + self?.blogDetailHeaderView.blavatarImageView.image = image + self?.blogDetailHeaderView.updatingIcon = false + }) + } + + let navigationController = UINavigationController(rootViewController: imageCropController) + navigationController.modalPresentationStyle = .formSheet + present(navigationController, animated: true) + } +} + +extension SitePickerViewController { + + private enum SiteIconAlertStrings { + + static let title = NSLocalizedString("Update Site Icon", comment: "Title for sheet displayed allowing user to update their site icon") + + enum Actions { + static let changeSiteIcon = NSLocalizedString("Change Site Icon", comment: "Change site icon button") + static let chooseImage = NSLocalizedString("Choose Image From My Device", comment: "Button allowing the user to choose an image from their device to use as their site icon") + static let createWithEmoji = NSLocalizedString("Create With Emoji", comment: "Button allowing the user to create a site icon by choosing an emoji character") + static let removeSiteIcon = NSLocalizedString("Remove Site Icon", comment: "Remove site icon button") + static let cancel = NSLocalizedString("Cancel", comment: "Cancel button") + } + + enum Errors { + static let iconUpdateFailed = NSLocalizedString("Icon update failed", comment: "Message to show when site icon update failed") + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Picker/SitePickerViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Picker/SitePickerViewController.swift new file mode 100644 index 000000000000..bc41dc10ac05 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Site Picker/SitePickerViewController.swift @@ -0,0 +1,297 @@ +import UIKit +import WordPressFlux +import WordPressShared +import SwiftUI +import SVProgressHUD + +final class SitePickerViewController: UIViewController { + + var blog: Blog { + didSet { + blogDetailHeaderView.blog = blog + } + } + + var siteIconPresenter: SiteIconPickerPresenter? + var siteIconPickerPresenter: SiteIconPickerPresenter? + var onBlogSwitched: ((Blog) -> Void)? + var onBlogListDismiss: (() -> Void)? + + let meScenePresenter: ScenePresenter + let blogService: BlogService + let mediaService: MediaService + + private(set) lazy var blogDetailHeaderView: BlogDetailHeaderView = { + let headerView = BlogDetailHeaderView(items: []) + headerView.translatesAutoresizingMaskIntoConstraints = false + return headerView + }() + + init(blog: Blog, + meScenePresenter: ScenePresenter, + blogService: BlogService? = nil, + mediaService: MediaService? = nil) { + self.blog = blog + self.meScenePresenter = meScenePresenter + self.blogService = blogService ?? BlogService(coreDataStack: ContextManager.shared) + self.mediaService = mediaService ?? MediaService(managedObjectContext: ContextManager.shared.mainContext) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupHeaderView() + startObservingQuickStart() + startObservingTitleChanges() + } + + deinit { + stopObservingQuickStart() + } + + private func setupHeaderView() { + blogDetailHeaderView.blog = blog + blogDetailHeaderView.delegate = self + view.addSubview(blogDetailHeaderView) + view.pinSubviewToAllEdges(blogDetailHeaderView) + } + + private func startObservingTitleChanges() { + NotificationCenter.default.addObserver(forName: NSNotification.Name.WPBlogUpdated, + object: nil, + queue: .main) { [weak self] _ in + + self?.updateTitles() + } + } +} + +// MARK: - BlogDetailHeaderViewDelegate + +extension SitePickerViewController: BlogDetailHeaderViewDelegate { + + func siteIconTapped() { + guard siteIconShouldAllowDroppedImages() else { + // Gracefully ignore the tap for users that can not upload files or + // blogs that do not have capabilities since those will not support the REST API icon update + return + } + + WPAnalytics.track(.siteSettingsSiteIconTapped) + + NoticesDispatch.lock() + + guard FeatureFlag.siteIconCreator.enabled else { + showUpdateSiteIconAlert() + return + } + + showSiteIconSelectionAlert() + } + + func siteIconReceivedDroppedImage(_ image: UIImage?) { + if !siteIconShouldAllowDroppedImages() { + // Gracefully ignore the drop for users that can not upload files or + // blogs that do not have capabilities since those will not support the REST API icon update + blogDetailHeaderView.updatingIcon = false + return + } + + presentCropViewControllerForDroppedSiteIcon(image) + } + + func siteIconShouldAllowDroppedImages() -> Bool { + guard blog.isAdmin, blog.isUploadingFilesAllowed() else { + return false + } + + return true + } + + func siteTitleTapped() { + showSiteTitleSettings() + } + + func siteSwitcherTapped() { + guard let blogListController = BlogListViewController(meScenePresenter: meScenePresenter) else { + return + } + + blogListController.blogSelected = { [weak self] controller, selectedBlog in + guard let blog = selectedBlog else { + return + } + self?.switchToBlog(blog) + controller?.dismiss(animated: true) { + self?.onBlogListDismiss?() + } + } + + let navigationController = UINavigationController(rootViewController: blogListController) + navigationController.modalPresentationStyle = .formSheet + present(navigationController, animated: true) + + WPAnalytics.track(.mySiteSiteSwitcherTapped) + } + + func visitSiteTapped() { + showViewSite() + } +} + +// MARK: - Helpers + +extension SitePickerViewController { + + private func switchToBlog(_ blog: Blog) { + guard self.blog != blog else { + return + } + + self.blog = blog + blogDetailHeaderView.blog = blog + + QuickStartTourGuide.shared.endCurrentTour() + toggleSpotlightOnHeaderView() + + onBlogSwitched?(blog) + } + + private func showSiteTitleSettings() { + let hint = blog.isAdmin ? SiteTitleStrings.siteTitleHint : SiteTitleStrings.notAnAdminHint + + let controller = SettingsTextViewController(text: blog.settings?.name ?? "", + placeholder: SiteTitleStrings.placeholderText, + hint: hint) + controller.title = SiteTitleStrings.settingsViewControllerTitle + controller.displaysNavigationButtons = true + + controller.onValueChanged = { [weak self] value in + guard let self = self else { + return + } + self.saveSiteTitleSettings(value, for: self.blog) + } + + controller.onDismiss = { [weak self] in + self?.startAlertTimer() + } + + let navigationController = UINavigationController(rootViewController: controller) + navigationController.modalPresentationStyle = .formSheet + present(navigationController, animated: true) + } + + private func saveSiteTitleSettings(_ title: String, for blog: Blog) { + // We'll only save for admin users, and if the title has actually changed + guard title != blog.settings?.name else { + return + } + + guard blog.isAdmin else { + let notice = Notice(title: SiteTitleStrings.notAnAdminHint, + message: nil, + feedbackType: .warning) + ActionDispatcher.global.dispatch(NoticeAction.post(notice)) + return + } + + // Save the old value in case we need to roll back + let existingBlogTitle = blog.settings?.name ?? SiteTitleStrings.defaultSiteTitle + blog.settings?.name = title + blogDetailHeaderView.setTitleLoading(true) + + QuickStartTourGuide.shared.complete(tour: QuickStartSiteTitleTour(blog: blog), + silentlyForBlog: blog) + + blogService.updateSettings(for: blog, success: { [weak self] in + + let notice = Notice(title: title, + message: SiteTitleStrings.titleChangeSuccessfulMessage, + feedbackType: .success) + ActionDispatcher.global.dispatch(NoticeAction.post(notice)) + + self?.blogDetailHeaderView.setTitleLoading(false) + NotificationCenter.default.post(name: NSNotification.Name.WPBlogUpdated, object: nil) + }, failure: { [weak self] error in + self?.blog.settings?.name = existingBlogTitle + self?.blogDetailHeaderView.setTitleLoading(false) + let notice = Notice(title: SiteTitleStrings.settingsSaveErrorTitle, + message: SiteTitleStrings.settingsSaveErrorMessage, + feedbackType: .error) + ActionDispatcher.global.dispatch(NoticeAction.post(notice)) + + DDLogError("Error while trying to update blog settings: \(error.localizedDescription)") + }) + } + + /// Updates site title and navigation bar title + private func updateTitles() { + blogDetailHeaderView.refreshSiteTitle() + + guard let parent = parent as? MySiteViewController else { + return + } + parent.updateNavigationTitle(for: blog) + } + + private func showViewSite() { + WPAppAnalytics.track(.openedViewSite, withProperties: [WPAppAnalyticsKeyTapSource: "link"], with: blog) + + guard let urlString = blog.homeURL as String?, + let url = URL(string: urlString) else { + return + } + + let webViewController = WebViewControllerFactory.controller( + url: url, + blog: blog, + source: Constants.viewSiteSource, + withDeviceModes: true, + onClose: self.startAlertTimer + ) + + let navigationController = LightNavigationController(rootViewController: webViewController) + + if traitCollection.userInterfaceIdiom == .pad { + navigationController.modalPresentationStyle = .fullScreen + } + + present(navigationController, animated: true) { + self.toggleSpotlightOnHeaderView() + } + + let tourGuide = QuickStartTourGuide.shared + if tourGuide.isCurrentElement(.viewSite) { + tourGuide.visited(.viewSite) + } else { + // Just mark as completed if we've viewed the site and aren't + // currently working on the View Site tour. + tourGuide.completeViewSiteTour(forBlog: blog) + } + } +} + +// MARK: - Constants and Strings + +extension SitePickerViewController { + + private enum Constants { + static let viewSiteSource = "my_site_view_site" + } + + private enum SiteTitleStrings { + static let siteTitleHint = NSLocalizedString("The Site Title is displayed in the title bar of a web browser and is displayed in the header for most themes.", comment: "Description of the purpose of a site's title.") + static let notAnAdminHint = NSLocalizedString("The Site Title can only be changed by a user with the administrator role.", comment: "Message informing the user that the site title can only be changed by an administrator user.") + static let placeholderText = NSLocalizedString("A title for the site", comment: "Placeholder text for the title of a site") + static let defaultSiteTitle = NSLocalizedString("Site Title", comment: "Default title for a site") + static let settingsViewControllerTitle = NSLocalizedString("Site Title", comment: "Title for screen that show site title editor") + static let titleChangeSuccessfulMessage = NSLocalizedString("Site title changed successfully", comment: "Confirmation that the user successfully changed the site's title") + static let settingsSaveErrorTitle = NSLocalizedString("Error updating site title", comment: "Error message informing the user that their site's title could not be changed") + static let settingsSaveErrorMessage = NSLocalizedString("Please try again later", comment: "Used on an error alert to prompt the user to try again") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/DateAndTimeFormatSettingsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/DateAndTimeFormatSettingsViewController.swift index ea8d2e6a92b2..e2d08a642ff6 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/DateAndTimeFormatSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/DateAndTimeFormatSettingsViewController.swift @@ -35,9 +35,9 @@ open class DateAndTimeFormatSettingsViewController: UITableViewController { // MARK: - Initializer @objc public convenience init(blog: Blog) { - self.init(style: .grouped) + self.init(style: .insetGrouped) self.blog = blog - self.service = BlogService(managedObjectContext: settings.managedObjectContext!) + self.service = BlogService(coreDataStack: ContextManager.shared) } // MARK: - View Lifecycle @@ -111,7 +111,7 @@ open class DateAndTimeFormatSettingsViewController: UITableViewController { func pressedDateFormat() -> ImmuTableAction { return { [unowned self] row in - let settingsViewController = SettingsSelectionViewController(style: .grouped) + let settingsViewController = SettingsSelectionViewController(style: .insetGrouped) settingsViewController.title = NSLocalizedString("Date Format", comment: "Writing Date Format Settings Title") settingsViewController.currentValue = self.settings.dateFormat as NSObject @@ -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") } } @@ -144,7 +145,7 @@ open class DateAndTimeFormatSettingsViewController: UITableViewController { func pressedTimeFormat() -> ImmuTableAction { return { [unowned self] row in - let settingsViewController = SettingsSelectionViewController(style: .grouped) + let settingsViewController = SettingsSelectionViewController(style: .insetGrouped) settingsViewController.title = NSLocalizedString("Time Format", comment: "Writing Time Format Settings Title") settingsViewController.currentValue = self.settings.timeFormat as NSObject @@ -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") + } } @@ -177,7 +180,7 @@ open class DateAndTimeFormatSettingsViewController: UITableViewController { func pressedStartOfWeek() -> ImmuTableAction { return { [unowned self] row in - let settingsViewController = SettingsSelectionViewController(style: .grouped) + let settingsViewController = SettingsSelectionViewController(style: .insetGrouped) settingsViewController.title = NSLocalizedString("Week starts on", comment: "Blog Writing Settings: Weeks starts on") settingsViewController.currentValue = self.settings.startOfWeek as NSObject @@ -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) } } @@ -200,7 +206,7 @@ open class DateAndTimeFormatSettingsViewController: UITableViewController { guard let url = URL(string: DateAndTimeFormatSettingsViewController.learnMoreUrl) else { return } - let webViewController = WebViewControllerFactory.controller(url: url) + let webViewController = WebViewControllerFactory.controller(url: url, source: "site_settings_date_time_format_learn_more") if presentingViewController != nil { navigationController?.pushViewController(webViewController, animated: true) @@ -222,7 +228,7 @@ open class DateAndTimeFormatSettingsViewController: UITableViewController { } fileprivate func refreshSettings() { - let service = BlogService(managedObjectContext: settings.managedObjectContext!) + let service = BlogService(coreDataStack: ContextManager.shared) service.syncSettings(for: blog, success: { [weak self] in self?.reloadViewModel() diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/DiscussionSettingsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/DiscussionSettingsViewController.swift index 78c1b9f409b7..5771933d3b3d 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/DiscussionSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/DiscussionSettingsViewController.swift @@ -7,9 +7,11 @@ 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) + self.init(style: .insetGrouped) self.blog = blog } @@ -60,7 +62,7 @@ open class DiscussionSettingsViewController: UITableViewController { // MARK: - Persistance! fileprivate func refreshSettings() { - let service = BlogService(managedObjectContext: settings.managedObjectContext!) + let service = BlogService(coreDataStack: ContextManager.shared) service.syncSettings(for: blog, success: { [weak self] in self?.tableView.reloadData() @@ -76,7 +78,7 @@ open class DiscussionSettingsViewController: UITableViewController { return } - let service = BlogService(managedObjectContext: settings.managedObjectContext!) + let service = BlogService(coreDataStack: ContextManager.shared) service.updateSettings(for: blog, success: nil, failure: { (error: Error) -> Void in @@ -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,11 +221,12 @@ open class DiscussionSettingsViewController: UITableViewController { return } + trackSettingsChange(fieldName: "require_registration", value: enabled as Any) settings.commentsRequireRegistration = enabled } fileprivate func pressedCloseCommenting(_ payload: AnyObject?) { - let pickerViewController = SettingsPickerViewController(style: .grouped) + let pickerViewController = SettingsPickerViewController(style: .insetGrouped) pickerViewController.title = NSLocalizedString("Close commenting", comment: "Close Comments Title") pickerViewController.switchVisible = true pickerViewController.switchOn = settings.commentsCloseAutomatically @@ -234,13 +241,16 @@ 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) } fileprivate func pressedSortBy(_ payload: AnyObject?) { - let settingsViewController = SettingsSelectionViewController(style: .grouped) + let settingsViewController = SettingsSelectionViewController(style: .insetGrouped) settingsViewController.title = NSLocalizedString("Sort By", comment: "Discussion Settings Title") settingsViewController.currentValue = settings.commentsSortOrder settingsViewController.titles = CommentsSorting.allTitles @@ -250,6 +260,7 @@ open class DiscussionSettingsViewController: UITableViewController { return } + self?.trackSettingsChange(fieldName: "comments_sort_by", value: selected as Any) self?.settings.commentsSorting = newSortOrder } @@ -257,7 +268,7 @@ open class DiscussionSettingsViewController: UITableViewController { } fileprivate func pressedThreading(_ payload: AnyObject?) { - let settingsViewController = SettingsSelectionViewController(style: .grouped) + let settingsViewController = SettingsSelectionViewController(style: .insetGrouped) settingsViewController.title = NSLocalizedString("Threading", comment: "Discussion Settings Title") settingsViewController.currentValue = settings.commentsThreading.rawValue as NSObject settingsViewController.titles = CommentsThreading.allTitles @@ -268,13 +279,14 @@ open class DiscussionSettingsViewController: UITableViewController { } self?.settings.commentsThreading = newThreadingDepth + self?.trackSettingsChange(fieldName: "comments_threading", value: selected as Any) } navigationController?.pushViewController(settingsViewController, animated: true) } fileprivate func pressedPaging(_ payload: AnyObject?) { - let pickerViewController = SettingsPickerViewController(style: .grouped) + let pickerViewController = SettingsPickerViewController(style: .insetGrouped) pickerViewController.title = NSLocalizedString("Paging", comment: "Comments Paging") pickerViewController.switchVisible = true pickerViewController.switchOn = settings.commentsPagingEnabled @@ -287,13 +299,16 @@ 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) } fileprivate func pressedAutomaticallyApprove(_ payload: AnyObject?) { - let settingsViewController = SettingsSelectionViewController(style: .grouped) + let settingsViewController = SettingsSelectionViewController(style: .insetGrouped) settingsViewController.title = NSLocalizedString("Automatically Approve", comment: "Discussion Settings Title") settingsViewController.currentValue = settings.commentsAutoapproval.rawValue as NSObject settingsViewController.titles = CommentsAutoapproval.allTitles @@ -305,13 +320,14 @@ open class DiscussionSettingsViewController: UITableViewController { } self?.settings.commentsAutoapproval = newApprovalStatus + self?.trackSettingsChange(fieldName: "comments_automatically_approve", value: selected as Any) } navigationController?.pushViewController(settingsViewController, animated: true) } fileprivate func pressedLinksInComments(_ payload: AnyObject?) { - let pickerViewController = SettingsPickerViewController(style: .grouped) + let pickerViewController = SettingsPickerViewController(style: .insetGrouped) pickerViewController.title = NSLocalizedString("Links in comments", comment: "Comments Paging") pickerViewController.switchVisible = false pickerViewController.selectionText = NSLocalizedString("Links in comments", comment: "A label title") @@ -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,27 +353,34 @@ open class DiscussionSettingsViewController: UITableViewController { comment: "Text rendered at the bottom of the Discussion Moderation Keys editor") settingsViewController.onChange = { [weak self] (updated: Set<String>) in self?.settings.commentsModerationKeys = updated + self?.trackSettingsChange(fieldName: "comments_hold_for_moderation", value: updated.count as Any) } navigationController?.pushViewController(settingsViewController, animated: true) } - fileprivate func pressedBlacklist(_ payload: AnyObject?) { - let blacklistKeys = settings.commentsBlacklistKeys - let settingsViewController = SettingsListEditorViewController(collection: blacklistKeys) - settingsViewController.title = NSLocalizedString("Blacklist", comment: "Blacklist Title") - settingsViewController.insertTitle = NSLocalizedString("New Blacklist Word", comment: "Blacklist Keyword Insertion Title") - settingsViewController.editTitle = NSLocalizedString("Edit Blacklist Word", comment: "Blacklist Keyword Edition Title") + fileprivate func pressedBlocklist(_ payload: AnyObject?) { + let blocklistKeys = settings.commentsBlocklistKeys + let settingsViewController = SettingsListEditorViewController(collection: blocklistKeys) + settingsViewController.title = NSLocalizedString("Blocklist", comment: "Blocklist Title") + settingsViewController.insertTitle = NSLocalizedString("New Blocklist Word", comment: "Blocklist Keyword Insertion Title") + settingsViewController.editTitle = NSLocalizedString("Edit Blocklist Word", comment: "Blocklist Keyword Edition Title") settingsViewController.footerText = NSLocalizedString("When a comment contains any of these words in its content, name, URL, e-mail, or IP, it will be marked as spam. You can enter partial words, so \"press\" will match \"WordPress\".", - comment: "Text rendered at the bottom of the Discussion Blacklist Keys editor") + comment: "Text rendered at the bottom of the Discussion Blocklist Keys editor") settingsViewController.onChange = { [weak self] (updated: Set<String>) in - self?.settings.commentsBlacklistKeys = updated + 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] { @@ -462,8 +486,8 @@ open class DiscussionSettingsViewController: UITableViewController { handler: self.pressedModeration), Row(style: .Value1, - title: NSLocalizedString("Blacklist", comment: "Settings: Comments Blacklist"), - handler: self.pressedBlacklist) + title: NSLocalizedString("Blocklist", comment: "Settings: Comments Blocklist"), + handler: self.pressedBlocklist) ] return Section(rows: rows) diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/HomepageSettingsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/HomepageSettingsViewController.swift new file mode 100644 index 000000000000..eb0a1d14b6a0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/HomepageSettingsViewController.swift @@ -0,0 +1,325 @@ +import UIKit +import WordPressFlux +import WordPressShared + +@objc open class HomepageSettingsViewController: UITableViewController { + + fileprivate enum PageSelectionType { + case homepage + case postsPage + + var title: String { + switch self { + case .homepage: + return Strings.homepage + case .postsPage: + return Strings.postsPage + } + } + + var pickerTitle: String { + switch self { + case .homepage: + return Strings.homepagePicker + case .postsPage: + return Strings.postsPagePicker + } + } + + var publishedPostsOnly: Bool { + switch self { + case .homepage: + return true + case .postsPage: + return false + } + } + } + + fileprivate lazy var handler: ImmuTableViewHandler = { + return ImmuTableViewHandler(takeOver: self) + }() + + /// Designated Initializer + /// + /// - Parameter blog: The blog for which we want to configure Homepage settings + /// + @objc public convenience init(blog: Blog) { + self.init(style: .insetGrouped) + + self.blog = blog + + let context = blog.managedObjectContext ?? ContextManager.shared.mainContext + postService = PostService(managedObjectContext: context) + } + + open override func viewDidLoad() { + super.viewDidLoad() + + title = Strings.title + clearsSelectionOnViewWillAppear = false + + // Setup tableView + WPStyleGuide.configureColors(view: view, tableView: tableView) + WPStyleGuide.configureAutomaticHeightRows(for: tableView) + + ImmuTable.registerRows([CheckmarkRow.self, NavigationItemRow.self, ActivityIndicatorRow.self], tableView: tableView) + reloadViewModel() + + fetchAllPages() + } + + private func fetchAllPages() { + let options = PostServiceSyncOptions() + options.number = 20 + + postService.syncPosts(ofType: .page, with: options, for: blog, success: { [weak self] posts in + self?.reloadViewModel() + }, failure: { _ in + + }) + } + + open override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + animateDeselectionInteractively() + } + + // MARK: - Model + + fileprivate func reloadViewModel() { + handler.viewModel = tableViewModel + } + + fileprivate var tableViewModel: ImmuTable { + guard let homepageType = blog.homepageType else { + return ImmuTable(sections: []) + } + + let homepageRows: [ImmuTableRow] + if case .homepageType = inProgressChange { + homepageRows = updatingHomepageTypeRows + } else { + homepageRows = homepageTypeRows + } + + let changeTypeSection = ImmuTableSection(headerText: nil, + rows: homepageRows, + footerText: Strings.footerText) + + let choosePagesSection = ImmuTableSection(headerText: Strings.choosePagesHeaderText, rows: selectedPagesRows) + + var sections = [changeTypeSection] + if homepageType == .page { + sections.append(choosePagesSection) + } + + return ImmuTable(sections: sections) + } + + // MARK: - Table Rows + + var updatingHomepageTypeRows: [ImmuTableRow] { + guard let homepageType = blog.homepageType else { + return [] + } + + return [ + ActivityIndicatorRow(title: HomepageType.posts.title, animating: homepageType == .posts, action: nil), + ActivityIndicatorRow(title: HomepageType.page.title, animating: homepageType == .page, action: nil) + ] + } + + var homepageTypeRows: [ImmuTableRow] { + guard let homepageType = blog.homepageType else { + return [] + } + + return [ + CheckmarkRow(title: HomepageType.posts.title, checked: homepageType == .posts, action: { [weak self] _ in + if self?.inProgressChange == nil { + self?.update(with: .homepageType(.posts)) + } + }), + CheckmarkRow(title: HomepageType.page.title, checked: homepageType == .page, action: { [weak self] _ in + if self?.inProgressChange == nil { + self?.update(with: .homepageType(.page)) + } + }) + ] + } + + var selectedPagesRows: [ImmuTableRow] { + let homepageID = blog.homepagePageID + let homepage = homepageID.flatMap { blog.lookupPost(withID: $0, in: postService.managedObjectContext) } + let homepageTitle = homepage?.titleForDisplay() ?? "" + + let postsPageID = blog.homepagePostsPageID + let postsPage = postsPageID.flatMap { blog.lookupPost(withID: $0, in: postService.managedObjectContext) } + let postsPageTitle = postsPage?.titleForDisplay() ?? "" + + let homepageRow = pageSelectionRow(selectionType: .homepage, + detail: homepageTitle, + selectedPostID: blog?.homepagePageID, + hiddenPostID: blog?.homepagePostsPageID, + isInProgress: HomepageChange.isSelectedHomepage, + changeForPost: { .selectedHomepage($0) }) + let postsPageRow = pageSelectionRow(selectionType: .postsPage, + detail: postsPageTitle, + selectedPostID: blog?.homepagePostsPageID, + hiddenPostID: blog?.homepagePageID, + isInProgress: HomepageChange.isSelectedPostsPage, + changeForPost: { .selectedPostsPage($0) }) + return [homepageRow, postsPageRow] + } + + private func pageSelectionRow(selectionType: PageSelectionType, + detail: String, + selectedPostID: Int?, + hiddenPostID: Int?, + isInProgress: (HomepageChange) -> Bool, + changeForPost: @escaping (Int) -> HomepageChange) -> ImmuTableRow { + if let inProgressChange = inProgressChange, isInProgress(inProgressChange) { + return ActivityIndicatorRow(title: selectionType.title, animating: true, action: nil) + } else { + return NavigationItemRow(title: selectionType.title, detail: detail, action: { [weak self] _ in + self?.showPageSelection(selectionType: selectionType, selectedPostID: selectedPostID, hiddenPostID: hiddenPostID, change: changeForPost) + }) + } + } + + // MARK: - Page Selection Navigation + + private func showPageSelection(selectionType: PageSelectionType, selectedPostID: Int?, hiddenPostID: Int?, change: @escaping (Int) -> HomepageChange) { + pushPageSelection(selectionType: selectionType, selectedPostID: selectedPostID, hiddenPostID: hiddenPostID) { [weak self] selected in + if let postID = selected.postID?.intValue { + self?.update(with: change(postID)) + } + } + } + + fileprivate func pushPageSelection(selectionType: PageSelectionType, selectedPostID: Int?, hiddenPostID: Int?, _ completion: @escaping (Page) -> Void) { + let hiddenPosts: [Int] + if let postID = hiddenPostID { + hiddenPosts = [postID] + } else { + hiddenPosts = [] + } + let viewController = SelectPostViewController(blog: blog, + isSelectedPost: { $0.postID?.intValue == selectedPostID }, + showsPostType: false, + entityName: Page.entityName(), + hiddenPosts: hiddenPosts, + publishedOnly: selectionType.publishedPostsOnly, + callback: { [weak self] (post) in + if let page = post as? Page { + completion(page) + } + self?.navigationController?.popViewController(animated: true) + }) + viewController.title = selectionType.pickerTitle + navigationController?.pushViewController(viewController, animated: true) + } + + // MARK: - Remote Updating + + /// The options for changing a homepage + /// Note: This is mapped to the actual property changes in `update(with change:)` + private enum HomepageChange { + case homepageType(HomepageType) + case selectedHomepage(Int) + case selectedPostsPage(Int) + + static func isHomepageType(_ change: HomepageChange) -> Bool { + if case .homepageType = change { return true } else { return false } + } + + static func isSelectedHomepage(_ change: HomepageChange) -> Bool { + if case .selectedHomepage = change { return true } else { return false } + } + + static func isSelectedPostsPage(_ change: HomepageChange) -> Bool { + if case .selectedPostsPage = change { return true } else { return false } + } + } + + /// Sends the remote service call to update `blog` homepage settings properties. + /// - Parameter change: The change to update for `blog`. + private func update(with change: HomepageChange) { + guard inProgressChange == nil else { + return + } + + /// Configure `blog` properties for the remote call + let homepageType: HomepageType + var homepagePostsPageID = blog.homepagePostsPageID + var homepagePageID = blog.homepagePageID + + switch change { + case .homepageType(let type): + homepageType = type + case .selectedPostsPage(let id): + homepageType = .page + homepagePostsPageID = id + case .selectedHomepage(let id): + homepageType = .page + homepagePageID = id + } + + /// If the blog hasn't changed, don't waste time saving it. + guard blog.homepageType != homepageType || + blog.homepagePostsPageID != homepagePostsPageID || + blog.homepagePageID != homepagePageID else { return } + + inProgressChange = change + + /// 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, coreDataStack: ContextManager.shared) + service?.setHomepageType(homepageType, + withPostsPageID: homepagePostsPageID, + homePageID: homepagePageID, + success: { [weak self] in + self?.endUpdating() + }, failure: { [weak self] error in + self?.endUpdating() + + let notice = Notice(title: Strings.updateErrorTitle, message: Strings.updateErrorMessage, feedbackType: .error) + ActionDispatcher.global.dispatch(NoticeAction.post(notice)) + }) + + reloadViewModel() + } + + fileprivate func endUpdating() { + inProgressChange = nil + tableView.allowsSelection = true + reloadViewModel() + } + + // MARK: - Private Properties + fileprivate var blog: Blog! + + fileprivate var postService: PostService! + + /// Are we currently updating the homepage type? + private var inProgressChange: HomepageChange? = nil + + fileprivate enum Strings { + static let title = NSLocalizedString("Homepage Settings", comment: "Title for the Homepage Settings screen") + static let footerText = NSLocalizedString("Choose from a homepage that displays your latest posts (classic blog) or a fixed / static page.", comment: "Explanatory text for Homepage Settings homepage type selection.") + static let homepage = NSLocalizedString("Homepage", comment: "Title for setting which shows the current page assigned as a site's homepage") + static let homepagePicker = NSLocalizedString("Choose Homepage", comment: "Title for selecting a new homepage") + static let postsPage = NSLocalizedString("Posts Page", comment: "Title for setting which shows the current page assigned as a site's posts page") + static let postsPagePicker = NSLocalizedString("Choose Posts Page", comment: "Title for selecting a new posts page") + static let choosePagesHeaderText = NSLocalizedString("Choose Pages", comment: "Title for settings section which allows user to select their home page and posts page") + static let updateErrorTitle = NSLocalizedString("Unable to update homepage settings", comment: "Error informing the user that their homepage settings could not be updated") + static let updateErrorMessage = NSLocalizedString("Please try again later.", comment: "Prompt for the user to retry a failed action again later") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/JetpackSecuritySettingsViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/JetpackSecuritySettingsViewController.swift deleted file mode 100644 index d9ee7ca3810d..000000000000 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/JetpackSecuritySettingsViewController.swift +++ /dev/null @@ -1,327 +0,0 @@ -import Foundation -import CocoaLumberjack -import WordPressShared - - -/// The purpose of this class is to render and modify the Jetpack Settings associated to a site. -/// -open class JetpackSecuritySettingsViewController: UITableViewController { - - // MARK: - Private Properties - - fileprivate var blog: Blog! - fileprivate var service: BlogJetpackSettingsService! - fileprivate lazy var handler: ImmuTableViewHandler = { - return ImmuTableViewHandler(takeOver: self) - }() - - // MARK: - Computed Properties - - fileprivate var settings: BlogSettings { - return blog.settings! - } - - // MARK: - Static Properties - - fileprivate static let footerHeight = CGFloat(34.0) - fileprivate static let learnMoreUrl = "https://jetpack.com/support/sso/" - fileprivate static let wordPressLoginSection = 2 - - // MARK: - Initializer - - @objc public convenience init(blog: Blog) { - self.init(style: .grouped) - self.blog = blog - self.service = BlogJetpackSettingsService(managedObjectContext: settings.managedObjectContext!) - } - - // MARK: - View Lifecycle - - open override func viewDidLoad() { - super.viewDidLoad() - title = NSLocalizedString("Security", comment: "Title for the Jetpack Security Settings Screen") - ImmuTable.registerRows([SwitchRow.self], tableView: tableView) - ImmuTable.registerRows([NavigationItemRow.self], tableView: tableView) - WPStyleGuide.configureColors(view: view, tableView: tableView) - reloadViewModel() - } - - open override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - tableView.reloadSelectedRow() - tableView.deselectSelectedRowWithAnimation(true) - refreshSettings() - } - - open override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - } - - // MARK: - Model - - fileprivate func reloadViewModel() { - handler.viewModel = tableViewModel() - } - - func tableViewModel() -> ImmuTable { - var monitorRows = [ImmuTableRow]() - monitorRows.append( - SwitchRow(title: NSLocalizedString("Monitor your site's uptime", - comment: "Jetpack Monitor Settings: Monitor site's uptime"), - value: self.settings.jetpackMonitorEnabled, - onChange: self.jetpackMonitorEnabledValueChanged()) - ) - - if self.settings.jetpackMonitorEnabled { - monitorRows.append( - SwitchRow(title: NSLocalizedString("Send notifications by email", - comment: "Jetpack Monitor Settings: Send notifications by email"), - value: self.settings.jetpackMonitorEmailNotifications, - onChange: self.sendNotificationsByEmailValueChanged()) - ) - monitorRows.append( - SwitchRow(title: NSLocalizedString("Send push notifications", - comment: "Jetpack Monitor Settings: Send push notifications"), - value: self.settings.jetpackMonitorPushNotifications, - onChange: self.sendPushNotificationsValueChanged()) - ) - } - - var bruteForceAttackRows = [ImmuTableRow]() - bruteForceAttackRows.append( - SwitchRow(title: NSLocalizedString("Block malicious login attempts", - comment: "Jetpack Settings: Block malicious login attempts"), - value: self.settings.jetpackBlockMaliciousLoginAttempts, - onChange: self.blockMaliciousLoginAttemptsValueChanged()) - ) - - if self.settings.jetpackBlockMaliciousLoginAttempts { - bruteForceAttackRows.append( - NavigationItemRow(title: NSLocalizedString("Whitelisted IP addresses", - comment: "Jetpack Settings: Whitelisted IP addresses"), - action: self.pressedWhitelistedIPAddresses()) - ) - } - - var wordPressLoginRows = [ImmuTableRow]() - wordPressLoginRows.append( - SwitchRow(title: NSLocalizedString("Allow WordPress.com login", - comment: "Jetpack Settings: Allow WordPress.com login"), - value: self.settings.jetpackSSOEnabled, - onChange: self.ssoEnabledChanged()) - ) - - if self.settings.jetpackSSOEnabled { - wordPressLoginRows.append( - SwitchRow(title: NSLocalizedString("Match accounts using email", - comment: "Jetpack Settings: Match accounts using email"), - value: self.settings.jetpackSSOMatchAccountsByEmail, - onChange: self.matchAccountsUsingEmailChanged()) - ) - wordPressLoginRows.append( - SwitchRow(title: NSLocalizedString("Require two-step authentication", - comment: "Jetpack Settings: Require two-step authentication"), - value: self.settings.jetpackSSORequireTwoStepAuthentication, - onChange: self.requireTwoStepAuthenticationChanged()) - ) - } - - return ImmuTable(sections: [ - ImmuTableSection( - headerText: "", - rows: monitorRows, - footerText: nil), - ImmuTableSection( - headerText: NSLocalizedString("Brute Force Attack Protection", - comment: "Jetpack Settings: Brute Force Attack Protection Section"), - rows: bruteForceAttackRows, - footerText: nil), - ImmuTableSection( - headerText: NSLocalizedString("WordPress.com login", - comment: "Jetpack Settings: WordPress.com Login settings"), - rows: wordPressLoginRows, - footerText: nil) - ]) - } - - // MARK: Learn More footer - - open override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - if section == JetpackSecuritySettingsViewController.wordPressLoginSection { - return JetpackSecuritySettingsViewController.footerHeight - } - return 0.0 - } - - open override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - if section == JetpackSecuritySettingsViewController.wordPressLoginSection { - let footer = UITableViewHeaderFooterView(frame: CGRect(x: 0.0, - y: 0.0, - width: tableView.frame.width, - height: JetpackSecuritySettingsViewController.footerHeight)) - footer.textLabel?.text = NSLocalizedString("Learn more...", - comment: "Jetpack Settings: WordPress.com Login WordPress login footer text") - footer.textLabel?.font = UIFont.preferredFont(forTextStyle: .footnote) - footer.textLabel?.isUserInteractionEnabled = true - - let tap = UITapGestureRecognizer(target: self, action: #selector(handleLearnMoreTap(_:))) - footer.addGestureRecognizer(tap) - return footer - } - return nil - } - - // MARK: - Row Handlers - - fileprivate func jetpackMonitorEnabledValueChanged() -> (_ newValue: Bool) -> Void { - return { [unowned self] newValue in - self.settings.jetpackMonitorEnabled = newValue - self.reloadViewModel() - self.service.updateJetpackSettingsForBlog(self.blog, - success: {}, - failure: { [weak self] (_) in - self?.refreshSettingsAfterSavingError() - }) - } - } - - fileprivate func sendNotificationsByEmailValueChanged() -> (_ newValue: Bool) -> Void { - return { [unowned self] newValue in - self.settings.jetpackMonitorEmailNotifications = newValue - self.service.updateJetpackMonitorSettingsForBlog(self.blog, - success: {}, - failure: { [weak self] (_) in - self?.refreshSettingsAfterSavingError() - }) - } - } - - fileprivate func sendPushNotificationsValueChanged() -> (_ newValue: Bool) -> Void { - return { [unowned self] newValue in - self.settings.jetpackMonitorPushNotifications = newValue - self.service.updateJetpackMonitorSettingsForBlog(self.blog, - success: {}, - failure: { [weak self] (_) in - self?.refreshSettingsAfterSavingError() - }) - } - } - - fileprivate func blockMaliciousLoginAttemptsValueChanged() -> (_ newValue: Bool) -> Void { - return { [unowned self] newValue in - self.settings.jetpackBlockMaliciousLoginAttempts = newValue - self.reloadViewModel() - self.service.updateJetpackSettingsForBlog(self.blog, - success: {}, - failure: { [weak self] (_) in - self?.refreshSettingsAfterSavingError() - }) - } - } - - func pressedWhitelistedIPAddresses() -> ImmuTableAction { - return { [unowned self] row in - let whiteListedIPs = self.settings.jetpackLoginWhiteListedIPAddresses - let settingsViewController = SettingsListEditorViewController(collection: whiteListedIPs) - - settingsViewController.title = NSLocalizedString("Whitelisted IP Addresses", - comment: "Whitelisted IP Addresses Title") - settingsViewController.insertTitle = NSLocalizedString("New IP or IP Range", - comment: "IP Address or Range Insertion Title") - settingsViewController.editTitle = NSLocalizedString("Edit IP or IP Range", - comment: "IP Address or Range Edition Title") - settingsViewController.footerText = NSLocalizedString("You may whitelist an IP address or series of addresses preventing them from ever being blocked by Jetpack. IPv4 and IPv6 are acceptable. To specify a range, enter the low value and high value separated by a dash. Example: 12.12.12.1-12.12.12.100.", - comment: "Text rendered at the bottom of the Whitelisted IP Addresses editor, should match Calypso.") - - settingsViewController.onChange = { [weak self] (updated: Set<String>) in - self?.settings.jetpackLoginWhiteListedIPAddresses = updated - guard let blog = self?.blog else { - return - } - self?.service.updateJetpackSettingsForBlog(blog, - success: { [weak self] in - // viewWillAppear will trigger a refresh, maybe before - // the new IPs are saved, so lets refresh again here - self?.refreshSettings() - }, - failure: { [weak self] (_) in - self?.refreshSettingsAfterSavingError() - }) - } - self.navigationController?.pushViewController(settingsViewController, animated: true) - } - } - - fileprivate func ssoEnabledChanged() -> (_ newValue: Bool) -> Void { - return { [unowned self] newValue in - self.settings.jetpackSSOEnabled = newValue - self.reloadViewModel() - self.service.updateJetpackSettingsForBlog(self.blog, - success: {}, - failure: { [weak self] (_) in - self?.refreshSettingsAfterSavingError() - }) - } - } - - fileprivate func matchAccountsUsingEmailChanged() -> (_ newValue: Bool) -> Void { - return { [unowned self] newValue in - self.settings.jetpackSSOMatchAccountsByEmail = newValue - self.service.updateJetpackSettingsForBlog(self.blog, - success: {}, - failure: { [weak self] (_) in - self?.refreshSettingsAfterSavingError() - }) - } - } - - fileprivate func requireTwoStepAuthenticationChanged() -> (_ newValue: Bool) -> Void { - return { [unowned self] newValue in - self.settings.jetpackSSORequireTwoStepAuthentication = newValue - self.service.updateJetpackSettingsForBlog(self.blog, - success: {}, - failure: { [weak self] (_) in - self?.refreshSettingsAfterSavingError() - }) - } - } - - // MARK: - Footer handler - - @objc fileprivate func handleLearnMoreTap(_ sender: UITapGestureRecognizer) { - guard let url = URL(string: JetpackSecuritySettingsViewController.learnMoreUrl) else { - return - } - let webViewController = WebViewControllerFactory.controller(url: url) - - if presentingViewController != nil { - navigationController?.pushViewController(webViewController, animated: true) - } else { - let navController = UINavigationController(rootViewController: webViewController) - present(navController, animated: true) - } - } - - // MARK: - Persistance - - fileprivate func refreshSettings() { - service.syncJetpackSettingsForBlog(blog, - success: { [weak self] in - self?.reloadViewModel() - DDLogInfo("Reloaded Jetpack Settings") - }, - failure: { (error: Error?) in - DDLogError("Error while syncing blog Jetpack Settings: \(String(describing: error))") - }) - } - - fileprivate func refreshSettingsAfterSavingError() { - let errorTitle = NSLocalizedString("Error updating Jetpack settings", - comment: "Title of error dialog when updating jetpack settins fail.") - let errorMessage = NSLocalizedString("Please contact support for assistance.", - comment: "Message displayed on an error alert to prompt the user to contact support") - WPError.showAlert(withTitle: errorTitle, message: errorMessage, withSupportButton: true) - refreshSettings() - } - -} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/LanguageSelectorViewController.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/LanguageSelectorViewController.swift index b953c6ee87a5..ad2dd77e6127 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/LanguageSelectorViewController.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/LanguageSelectorViewController.swift @@ -3,7 +3,7 @@ import WordPressShared /// Defines the methods implemented by the LanguageSelectorViewController delegate /// -protocol LanguageSelectorDelegate: class { +protocol LanguageSelectorDelegate: AnyObject { /// Called when the user tapped on a language. /// 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..be1a999aa503 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/Related Posts/RelatedPostsSettingsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/Related Posts/RelatedPostsSettingsViewController.m @@ -2,7 +2,7 @@ #import "Blog.h" #import "BlogService.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "SettingTableViewCell.h" #import "SVProgressHud+Dismiss.h" #import "RelatedPostsPreviewTableViewCell.h" @@ -43,7 +43,7 @@ @implementation RelatedPostsSettingsViewController - (instancetype)initWithBlog:(Blog *)blog { NSParameterAssert([blog isKindOfClass:[Blog class]]); - self = [super initWithStyle:UITableViewStyleGrouped]; + self = [super initWithStyle:UITableViewStyleInsetGrouped]; if (self) { _blog = blog; } @@ -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]; }; } @@ -246,10 +251,10 @@ - (IBAction)updateRelatedPostsSettings:(id)sender self.settings.relatedPostsShowHeadline = self.relatedPostsShowHeaderCell.on; self.settings.relatedPostsShowThumbnails = self.relatedPostsShowThumbnailsCell.on; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:self.blog.managedObjectContext]; + BlogService *blogService = nil; [blogService updateSettingsForBlog:self.blog success:^{ [self.tableView reloadData]; - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { [SVProgressHUD showDismissibleErrorWithStatus:NSLocalizedString(@"Settings update failed", @"Message to show when setting save failed")]; [self.tableView reloadData]; }]; diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SettingTableViewCell.h b/WordPress/Classes/ViewRelated/Blog/Site Settings/SettingTableViewCell.h index 3ba826a2d1ec..26aad6fe5aa4 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SettingTableViewCell.h +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SettingTableViewCell.h @@ -1,9 +1,12 @@ #import <WordPressShared/WPTableViewCell.h> +extern NSString * const SettingsTableViewCellReuseIdentifier; + @interface SettingTableViewCell : WPTableViewCell - (instancetype)initWithLabel:(NSString *)label editable:(BOOL)editable reuseIdentifier:(NSString *)reuseIdentifier; @property (nonatomic, copy) NSString *textValue; +@property (nonatomic, assign) BOOL editable; @end diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SettingTableViewCell.m b/WordPress/Classes/ViewRelated/Blog/Site Settings/SettingTableViewCell.m index b2913b380470..05bc1bb49a34 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SettingTableViewCell.m +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SettingTableViewCell.m @@ -1,6 +1,8 @@ #import "SettingTableViewCell.h" #import "WordPress-Swift.h" +NSString * const SettingsTableViewCellReuseIdentifier = @"org.wordpress.SettingTableViewCell"; + @implementation SettingTableViewCell - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier @@ -15,17 +17,24 @@ - (instancetype)initWithLabel:(NSString *)label editable:(BOOL)editable reuseIde self.textLabel.text = label; [WPStyleGuide configureTableViewCell:self]; self.detailTextLabel.textColor = [UIColor murielTextSubtle]; - if (editable) { - self.accessoryType = UITableViewCellAccessoryDisclosureIndicator; - self.selectionStyle = UITableViewCellSelectionStyleDefault; - } else { - self.accessoryType = UITableViewCellAccessoryNone; - self.selectionStyle = UITableViewCellSelectionStyleNone; - } + [self setEditable:editable]; } return self; } +- (void)setEditable:(BOOL)editable +{ + _editable = editable; + + if (editable) { + self.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + self.selectionStyle = UITableViewCellSelectionStyleDefault; + } else { + self.accessoryType = UITableViewCellAccessoryNone; + self.selectionStyle = UITableViewCellSelectionStyleNone; + } +} + - (void)setTextValue:(NSString *)value { self.detailTextLabel.text = value; diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteIconPickerPresenter.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteIconPickerPresenter.swift index 2a65902c1276..2df78633c172 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteIconPickerPresenter.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteIconPickerPresenter.swift @@ -36,7 +36,7 @@ class SiteIconPickerPresenter: NSObject { options.allowMultipleSelection = false options.showSearchBar = true options.badgedUTTypes = [String(kUTTypeGIF)] - options.preferredStatusBarStyle = .lightContent + options.preferredStatusBarStyle = WPStyleGuide.preferredStatusBarStyle let pickerViewController = WPNavigationMediaPickerViewController(options: options) @@ -100,15 +100,17 @@ class SiteIconPickerPresenter: NSObject { self.onCompletion?(media, nil) } else { let mediaService = MediaService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let importService = MediaImportService(coreDataStack: ContextManager.sharedInstance()) WPAnalytics.track(.siteSettingsSiteIconCropped) - mediaService.createMedia(with: image, - blog: self.blog, - post: nil, - progress: nil, - thumbnailCallback: nil, - completion: { (media, error) in + importService.createMedia( + with: image, + blog: self.blog, + post: nil, + receiveUpdate: nil, + thumbnailCallback: nil + ) { (media, error) in guard let media = media, error == nil else { WPAnalytics.track(.siteSettingsSiteIconUploadFailed) self.onCompletion?(nil, error) @@ -125,7 +127,7 @@ class SiteIconPickerPresenter: NSObject { WPAnalytics.track(.siteSettingsSiteIconUploadFailed) self.onCompletion?(nil, error) }) - }) + } } } self.mediaPickerViewController.show(after: imageCropViewController) diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Blogging.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Blogging.swift new file mode 100644 index 000000000000..17b84619c9df --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Blogging.swift @@ -0,0 +1,122 @@ + +extension SiteSettingsViewController { + + @objc var bloggingSettingsRowCount: Int { + bloggingSettingsRows.count + } + + @objc func tableView(_ tableView: UITableView, cellForBloggingSettingsInRow row: Int) -> UITableViewCell { + switch bloggingSettingsRows[row] { + case .reminders: + return remindersTableViewCell + case .prompts: + return promptsTableViewCell + } + } + + @objc func tableView(_ tableView: UITableView, didSelectInBloggingSettingsAt indexPath: IndexPath) { + switch bloggingSettingsRows[indexPath.row] { + case .reminders: + presentBloggingRemindersFlow(indexPath: indexPath) + default: + break + } + } + +} + +// MARK: - Private methods + +private extension SiteSettingsViewController { + enum BloggingSettingsRows { + case reminders + case prompts + } + + var bloggingSettingsRows: [BloggingSettingsRows] { + var rows = [BloggingSettingsRows]() + if blog.areBloggingRemindersAllowed() { + rows.append(.reminders) + } + if blog.isAccessibleThroughWPCom() && FeatureFlag.bloggingPromptsEnhancements.enabled { + rows.append(.prompts) + } + return rows + } + + // MARK: - Reminders + + var remindersTableViewCell: SettingTableViewCell { + let cell = SettingTableViewCell(label: Strings.remindersTitle, + editable: true, + reuseIdentifier: nil) + cell?.detailTextLabel?.adjustsFontSizeToFitWidth = true + cell?.detailTextLabel?.minimumScaleFactor = 0.75 + cell?.accessoryType = .none + cell?.textValue = schedule(for: blog) + return cell ?? SettingTableViewCell() + } + + + func schedule(for blog: Blog) -> String { + guard let scheduler = try? ReminderScheduleCoordinator() else { + return "" + } + + let formatter = BloggingRemindersScheduleFormatter() + return formatter.shortScheduleDescription(for: scheduler.schedule(for: blog), + time: scheduler.scheduledTime(for: blog).toLocalTime()).string + } + + func presentBloggingRemindersFlow(indexPath: IndexPath) { + BloggingRemindersFlow.present(from: self, for: blog, source: .blogSettings) { [weak self] in + guard let self = self, + let cell = self.tableView.cellForRow(at: indexPath) as? SettingTableViewCell else { + return + } + + cell.textValue = self.schedule(for: self.blog) + } + + tableView.deselectRow(at: indexPath, animated: true) + } + + // MARK: - Prompts + + var promptsTableViewCell: SwitchTableViewCell { + let cell = SwitchTableViewCell() + cell.name = Strings.promptsTitle + cell.on = isPromptsSwitchEnabled + cell.onChange = promptsSwitchOnChange + return cell + } + + var isPromptsSwitchEnabled: Bool { + guard let siteID = blog.dotComID else { + return false + } + return BlogDashboardPersonalizationService(siteID: siteID.intValue).isEnabled(.prompts) + } + + var promptsSwitchOnChange: (Bool) -> () { + return { [weak self] isPromptsEnabled in + WPAnalytics.track(.promptsSettingsShowPromptsTapped, properties: ["enabled": isPromptsEnabled]) + guard let siteID = self?.blog.dotComID else { + return + } + BlogDashboardPersonalizationService(siteID: siteID.intValue) + .setEnabled(isPromptsEnabled, for: .prompts) + } + } + + // MARK: - Constants + + struct Strings { + static let remindersTitle = NSLocalizedString("sitesettings.reminders.title", + value: "Reminders", + comment: "Label for the blogging reminders setting") + static let promptsTitle = NSLocalizedString("sitesettings.prompts.title", + value: "Show prompts", + comment: "Label for the blogging prompts setting") + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift index e5c5a3bfda6c..ca4c79bba962 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController+Swift.swift @@ -58,12 +58,29 @@ extension SiteSettingsViewController { } } + // MARK: - Homepage Settings + + @objc var homepageSettingsCell: SettingTableViewCell? { + let cell = SettingTableViewCell(label: NSLocalizedString("Homepage Settings", comment: "Label for Homepage Settings site settings section"), editable: true, reuseIdentifier: nil) + cell?.textValue = blog.homepageType?.title + return cell + } + + // MARK: - Navigation + + @objc(showHomepageSettingsForBlog:) func showHomepageSettings(for blog: Blog) { + let settingsViewController = HomepageSettingsViewController(blog: blog) + navigationController?.pushViewController(settingsViewController, animated: true) + } + @objc func showTimezoneSelector() { let controller = TimeZoneSelectorViewController(selectedValue: timezoneValue) { [weak self] (newValue) in self?.navigationController?.popViewController(animated: true) 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) } @@ -74,7 +91,7 @@ extension SiteSettingsViewController { } @objc func showPostPerPageSetting() { - let pickerViewController = SettingsPickerViewController(style: .grouped) + let pickerViewController = SettingsPickerViewController(style: .insetGrouped) pickerViewController.title = NSLocalizedString("Posts per Page", comment: "Posts per Page Title") pickerViewController.switchVisible = false pickerViewController.selectionText = NSLocalizedString("The number of posts to show per page.", @@ -90,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) @@ -132,7 +150,7 @@ extension SiteSettingsViewController { guard let url = URL(string: self.ampSupportURL) else { return } - let webViewController = WebViewControllerFactory.controller(url: url) + let webViewController = WebViewControllerFactory.controller(url: url, source: "site_settings_amp_footer") if presentingViewController != nil { navigationController?.pushViewController(webViewController, animated: true) @@ -153,3 +171,208 @@ extension SiteSettingsViewController { fileprivate var ampSupportURL: String { return "https://support.wordpress.com/amp-accelerated-mobile-pages/" } } + +// MARK: - General Settings Table Section Management + +extension SiteSettingsViewController { + + enum GeneralSettingsRow { + case title + case tagline + case url + case privacy + case language + case timezone + } + + var generalSettingsRows: [GeneralSettingsRow] { + var rows: [GeneralSettingsRow] = [.title, .tagline, .url] + + if blog.supportsSiteManagementServices() { + rows.append(contentsOf: [.privacy, .language]) + } + + if blog.supports(.wpComRESTAPI) { + rows.append(.timezone) + } + + return rows + } + + @objc + var generalSettingsRowCount: Int { + generalSettingsRows.count + } + + @objc + func tableView(_ tableView: UITableView, cellForGeneralSettingsInRow row: Int) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: SettingsTableViewCellReuseIdentifier) as! SettingTableViewCell + + switch generalSettingsRows[row] { + case .title: + configureCellForTitle(cell) + case .tagline: + configureCellForTagline(cell) + case .url: + configureCellForURL(cell) + case .privacy: + configureCellForPrivacy(cell) + case .language: + configureCellForLanguage(cell) + case .timezone: + configureCellForTimezone(cell) + } + + return cell + } + + @objc + func tableView(_ tableView: UITableView, didSelectInGeneralSettingsAt indexPath: IndexPath) { + switch generalSettingsRows[indexPath.row] { + case .title where blog.isAdmin: + showEditSiteTitleController(indexPath: indexPath) + case .tagline where blog.isAdmin: + showEditSiteTaglineController(indexPath: indexPath) + case .privacy where blog.isAdmin: + showPrivacySelector() + case .language where blog.isAdmin: + showLanguageSelector(for: blog) + case .timezone where blog.isAdmin: + showTimezoneSelector() + default: + break + } + } + + // MARK: - Cell Configuration + + private func configureCellForTitle(_ cell: SettingTableViewCell) { + let name = blog.settings?.name ?? NSLocalizedString("A title for the site", comment: "Placeholder text for the title of a site") + + cell.editable = blog.isAdmin + cell.textLabel?.text = NSLocalizedString("Site Title", comment: "Label for site title blog setting") + cell.textValue = name + } + + private func configureCellForTagline(_ cell: SettingTableViewCell) { + let tagline = blog.settings?.tagline ?? NSLocalizedString("Explain what this site is about.", comment: "Placeholder text for the tagline of a site") + + cell.editable = blog.isAdmin + cell.textLabel?.text = NSLocalizedString("Tagline", comment: "Label for tagline blog setting") + cell.textValue = tagline + } + + private func configureCellForURL(_ cell: SettingTableViewCell) { + let url: String = { + guard let url = blog.url else { + return NSLocalizedString("http://my-site-address (URL)", comment: "(placeholder) Help the user enter a URL into the field") + } + + return url + }() + + cell.editable = false + cell.textLabel?.text = NSLocalizedString("Address", comment: "Label for url blog setting") + cell.textValue = url + } + + private func configureCellForPrivacy(_ cell: SettingTableViewCell) { + cell.editable = blog.isAdmin + cell.textLabel?.text = NSLocalizedString("Privacy", comment: "Label for the privacy setting") + cell.textValue = BlogSiteVisibilityHelper.titleForCurrentSiteVisibility(of: blog) + } + + private func configureCellForLanguage(_ cell: SettingTableViewCell) { + let name: String + + if let languageId = blog.settings?.languageID.intValue { + name = WordPressComLanguageDatabase().nameForLanguageWithId(languageId) + } else { + // Since the settings can be nil, we need to handle the scenario... but it + // really should not be possible to reach this line. + name = NSLocalizedString("Undefined", comment: "When the App can't figure out what language a blog is configured to use.") + } + + cell.editable = blog.isAdmin + cell.textLabel?.text = NSLocalizedString("Language", comment: "Label for the privacy setting") + cell.textValue = name + } + + private func configureCellForTimezone(_ cell: SettingTableViewCell) { + cell.editable = blog.isAdmin + cell.textLabel?.text = NSLocalizedString("Time Zone", comment: "Label for the timezone setting") + cell.textValue = timezoneLabel() + } + + // MARK: - Handling General Setting Cell Taps + + private func showEditSiteTitleController(indexPath: IndexPath) { + guard blog.isAdmin else { + return + } + + let siteTitleViewController = SettingsTextViewController( + text: blog.settings?.name ?? "", + placeholder: NSLocalizedString("A title for the site", comment: "Placeholder text for the title of a site"), + hint: "") + + siteTitleViewController.title = NSLocalizedString("Site Title", comment: "Title for screen that show site title editor") + siteTitleViewController.onValueChanged = { [weak self] value in + guard let self = self, + let cell = self.tableView.cellForRow(at: indexPath) else { + // No need to update anything if the cell doesn't exist. + return + } + + cell.detailTextLabel?.text = value + + if value != self.blog.settings?.name { + self.blog.settings?.name = value + self.saveSettings() + + self.trackSettingsChange(fieldName: "site_title") + } + } + + self.navigationController?.pushViewController(siteTitleViewController, animated: true) + } + + private func showEditSiteTaglineController(indexPath: IndexPath) { + guard blog.isAdmin else { + return + } + + let siteTaglineViewController = SettingsTextViewController( + text: blog.settings?.tagline ?? "", + placeholder: NSLocalizedString("Explain what this site is about.", comment: "Placeholder text for the tagline of a site"), + hint: NSLocalizedString("In a few words, explain what this site is about.", comment: "Explain what is the purpose of the tagline")) + + siteTaglineViewController.title = NSLocalizedString("Tagline", comment: "Title for screen that show tagline editor") + siteTaglineViewController.onValueChanged = { [weak self] value in + guard let self = self, + let cell = self.tableView.cellForRow(at: indexPath) else { + // No need to update anything if the cell doesn't exist. + return + } + + let normalizedTagline = value.trimmingCharacters(in: .whitespacesAndNewlines) + cell.detailTextLabel?.text = normalizedTagline + + if normalizedTagline != self.blog.settings?.tagline { + self.blog.settings?.tagline = normalizedTagline + self.saveSettings() + + self.trackSettingsChange(fieldName: "tagline") + } + } + + self.navigationController?.pushViewController(siteTaglineViewController, 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.h b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.h index 1e39447d97c8..4014a5b9d95e 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.h +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.h @@ -1,8 +1,23 @@ #import <UIKit/UIKit.h> @class Blog; +@class SettingTableViewCell; @class TimeZoneObserver; +typedef NS_ENUM(NSInteger, SiteSettingsSection) { + SiteSettingsSectionGeneral = 0, + SiteSettingsSectionBlogging, + SiteSettingsSectionHomepage, + SiteSettingsSectionAccount, + SiteSettingsSectionEditor, + SiteSettingsSectionWriting, + SiteSettingsSectionMedia, + SiteSettingsSectionDiscussion, + SiteSettingsSectionTraffic, + SiteSettingsSectionJetpackSettings, + SiteSettingsSectionAdvanced, +}; + @interface SiteSettingsViewController : UITableViewController @property (nonatomic, strong, readonly) Blog *blog; @@ -12,4 +27,9 @@ - (void)saveSettings; +// General Settings: These were made available here to help with the transition to Swift. + +- (void)showPrivacySelector; +- (void)showLanguageSelectorForBlog:(Blog *)blog; + @end diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m index 37497460a791..5d250eccd5f2 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Site Settings/SiteSettingsViewController.m @@ -3,11 +3,10 @@ #import "Blog.h" #import "BlogService.h" #import "BlogSiteVisibilityHelper.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "NSURL+IDN.h" #import "PostCategory.h" #import "PostCategoryService.h" -#import "PostCategoriesViewController.h" #import "RelatedPostsSettingsViewController.h" #import "SettingsSelectionViewController.h" #import "SettingsMultiTextViewController.h" @@ -20,23 +19,17 @@ #import "AccountService.h" @import WordPressKit; - -NS_ENUM(NSInteger, SiteSettingsGeneral) { - SiteSettingsGeneralTitle = 0, - SiteSettingsGeneralTagline, - SiteSettingsGeneralURL, - SiteSettingsGeneralTimezone, - SiteSettingsGeneralLanguage, - SiteSettingsGeneralPrivacy, - SiteSettingsGeneralCount, -}; - NS_ENUM(NSInteger, SiteSettingsAccount) { SiteSettingsAccountUsername = 0, SiteSettingsAccountPassword, SiteSettingsAccountCount, }; +NS_ENUM(NSInteger, SiteSettingsHomepage) { + SiteSettingsHomepageSettings = 0, + SiteSettingsHomepageCount, +}; + NS_ENUM(NSInteger, SiteSettingsEditor) { SiteSettingsEditorSelector = 0, SiteSettingsEditorCount, @@ -50,7 +43,6 @@ SiteSettingsWritingDateAndTimeFormat, SiteSettingsPostPerPage, SiteSettingsSpeedUpYourSite, - SiteSettingsWritingCount, }; NS_ENUM(NSInteger, SiteSettingsAdvanced) { @@ -66,29 +58,10 @@ SiteSettingsJetpackCount, }; -NS_ENUM(NSInteger, SiteSettingsSection) { - SiteSettingsSectionGeneral = 0, - SiteSettingsSectionAccount, - SiteSettingsSectionEditor, - SiteSettingsSectionWriting, - SiteSettingsSectionMedia, - SiteSettingsSectionDiscussion, - SiteSettingsSectionTraffic, - SiteSettingsSectionJetpackSettings, - SiteSettingsSectionAdvanced, -}; - static NSString *const EmptySiteSupportURL = @"https://en.support.wordpress.com/empty-site"; @interface SiteSettingsViewController () <UITableViewDelegate, UITextFieldDelegate, JetpackConnectionDelegate, PostCategoriesViewControllerDelegate> -#pragma mark - General Section -@property (nonatomic, strong) SettingTableViewCell *siteTitleCell; -@property (nonatomic, strong) SettingTableViewCell *siteTaglineCell; -@property (nonatomic, strong) SettingTableViewCell *addressTextCell; -@property (nonatomic, strong) SettingTableViewCell *privacyTextCell; -@property (nonatomic, strong) SettingTableViewCell *languageTextCell; -@property (nonatomic, strong) SettingTableViewCell *timezoneTextCell; #pragma mark - Account Section @property (nonatomic, strong) SettingTableViewCell *usernameTextCell; @property (nonatomic, strong) SettingTableViewCell *passwordTextCell; @@ -120,6 +93,9 @@ @interface SiteSettingsViewController () <UITableViewDelegate, UITextFieldDelega @property (nonatomic, strong) Blog *blog; @property (nonatomic, strong) NSString *username; @property (nonatomic, strong) NSString *password; + +@property (nonatomic, strong) NSArray<NSNumber *> *tableSections; +@property (nonatomic, strong) NSArray<NSNumber *> *writingSectionRows; @end @implementation SiteSettingsViewController @@ -128,7 +104,7 @@ - (instancetype)initWithBlog:(Blog *)blog { NSParameterAssert([blog isKindOfClass:[Blog class]]); - self = [super initWithStyle:UITableViewStyleGrouped]; + self = [super initWithStyle:UITableViewStyleInsetGrouped]; if (self) { _blog = blog; _username = blog.usernameForSite; @@ -142,9 +118,12 @@ - (void)viewDidLoad { DDLogMethod(); [super viewDidLoad]; + [self.tableView registerClass:[SettingTableViewCell class] forCellReuseIdentifier:SettingsTableViewCellReuseIdentifier]; [self.tableView registerNib:MediaQuotaCell.nib forCellReuseIdentifier:MediaQuotaCell.defaultReuseIdentifier]; self.navigationItem.title = NSLocalizedString(@"Settings", @"Title for screen that allows configuration of your blog/site settings."); + + self.extendedLayoutIncludesOpaqueBars = YES; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleDataModelChange:) @@ -179,13 +158,28 @@ - (void)viewDidAppear:(BOOL)animated - (NSArray *)tableSections { + if (_tableSections) { + return _tableSections; + } + NSMutableArray *sections = [NSMutableArray arrayWithObjects:@(SiteSettingsSectionGeneral), nil]; + + if (self.bloggingSettingsRowCount > 0) { + [sections addObject:@(SiteSettingsSectionBlogging)]; + } + + if ([Feature enabled:FeatureFlagHomepageSettings] && [self.blog supports:BlogFeatureHomepageSettings]) { + [sections addObject:@(SiteSettingsSectionHomepage)]; + } if (!self.blog.account) { [sections addObject:@(SiteSettingsSectionAccount)]; } - [sections addObject:@(SiteSettingsSectionEditor)]; + // Only add the editor section if the site is not a Simple WP.com site + if (![GutenbergSettings isSimpleWPComSite:self.blog]) { + [sections addObject:@(SiteSettingsSectionEditor)]; + } if ([self.blog supports:BlogFeatureWPComRESTAPI] && self.blog.isAdmin) { [sections addObject:@(SiteSettingsSectionWriting)]; @@ -200,14 +194,42 @@ - (NSArray *)tableSections [sections addObject:@(SiteSettingsSectionJetpackSettings)]; } } - + if ([self.blog supports:BlogFeatureSiteManagement]) { [sections addObject:@(SiteSettingsSectionAdvanced)]; } + _tableSections = sections; return sections; } +- (NSArray *)writingSectionRows +{ + if (_writingSectionRows) { + return _writingSectionRows; + } + + NSMutableArray *rows = [NSMutableArray arrayWithObjects: + @(SiteSettingsWritingDefaultCategory), + @(SiteSettingsWritingTags), + @(SiteSettingsWritingDefaultPostFormat), nil]; + + BOOL jetpackFeaturesEnabled = [JetpackFeaturesRemovalCoordinator jetpackFeaturesEnabled]; + + if (jetpackFeaturesEnabled) { + [rows addObject:@(SiteSettingsWritingRelatedPosts)]; + } + + [rows addObject:@(SiteSettingsWritingDateAndTimeFormat)]; + [rows addObject:@(SiteSettingsPostPerPage)]; + + if (jetpackFeaturesEnabled && [self.blog supports:BlogFeatureJetpackImageSettings]) { + [rows addObject:@(SiteSettingsSpeedUpYourSite)]; + } + + _writingSectionRows = rows; + return rows; +} #pragma mark - UITableViewDataSource @@ -222,19 +244,13 @@ - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger switch (settingsSection) { case SiteSettingsSectionGeneral: { - NSInteger rowCount = SiteSettingsGeneralCount; - - // NOTE: Jorge Bernal (2018-01-16) - // Privacy and Language are only available for WordPress.com admins - if (!self.blog.supportsSiteManagementServices) { - rowCount -= 2; - } - // Timezone is only available for WordPress.com and Jetpack sites - if (![self.blog supports:BlogFeatureWPComRESTAPI]) { - rowCount--; - } - - return rowCount; + return self.generalSettingsRowCount; + } + case SiteSettingsSectionBlogging: + return self.bloggingSettingsRowCount; + case SiteSettingsSectionHomepage: + { + return SiteSettingsHomepageCount; } case SiteSettingsSectionAccount: { @@ -246,12 +262,7 @@ - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger } case SiteSettingsSectionWriting: { - if ([self.blog supports:BlogFeatureJetpackImageSettings]) { - return SiteSettingsWritingCount; - } else { - // The last setting, Speed Up Your Site is only available for Jetpack sites - return SiteSettingsWritingCount - 1; - } + return self.writingSectionRows.count; } case SiteSettingsSectionMedia: { @@ -458,6 +469,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; @@ -492,8 +504,9 @@ - (void)configureEditorSelectorCell - (void)configureDefaultCategoryCell { - PostCategoryService *postCategoryService = [[PostCategoryService alloc] initWithManagedObjectContext:[[ContextManager sharedInstance] mainContext]]; - PostCategory *postCategory = [postCategoryService findWithBlogObjectID:self.blog.objectID andCategoryID:self.blog.settings.defaultCategoryID]; + PostCategory *postCategory = [PostCategory lookupWithBlogObjectID:self.blog.objectID + categoryID:self.blog.settings.defaultCategoryID + inContext:[[ContextManager sharedInstance] mainContext]]; [self.defaultCategoryCell setTextValue:[postCategory categoryName]]; } @@ -519,7 +532,8 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForEditorSettingsAtR - (UITableViewCell *)tableView:(UITableView *)tableView cellForWritingSettingsAtRow:(NSInteger)row { - switch (row) { + NSInteger writingRow = [self.writingSectionRows[row] integerValue]; + switch (writingRow) { case (SiteSettingsWritingDefaultCategory): [self configureDefaultCategoryCell]; return self.defaultCategoryCell; @@ -571,119 +585,6 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForJetpackSettingsAt return nil; } -- (SettingTableViewCell *)siteTitleCell -{ - if (_siteTitleCell) { - return _siteTitleCell; - } - _siteTitleCell = [[SettingTableViewCell alloc] initWithLabel:NSLocalizedString(@"Site Title", @"Label for site title blog setting") - editable:self.blog.isAdmin - reuseIdentifier:nil]; - return _siteTitleCell; -} - -- (SettingTableViewCell *)siteTaglineCell -{ - if (_siteTaglineCell) { - return _siteTaglineCell; - } - _siteTaglineCell = [[SettingTableViewCell alloc] initWithLabel:NSLocalizedString(@"Tagline", @"Label for tagline blog setting") - editable:self.blog.isAdmin - reuseIdentifier:nil]; - return _siteTaglineCell; -} - -- (SettingTableViewCell *)addressTextCell -{ - if (_addressTextCell) { - return _addressTextCell; - } - _addressTextCell = [[SettingTableViewCell alloc] initWithLabel:NSLocalizedString(@"Address", @"Label for url blog setting") - editable:NO - reuseIdentifier:nil]; - return _addressTextCell; -} - -- (SettingTableViewCell *)privacyTextCell -{ - if (_privacyTextCell) { - return _privacyTextCell; - } - _privacyTextCell = [[SettingTableViewCell alloc] initWithLabel:NSLocalizedString(@"Privacy", @"Label for the privacy setting") - editable:self.blog.isAdmin - reuseIdentifier:nil]; - return _privacyTextCell; -} - -- (SettingTableViewCell *)languageTextCell -{ - if (_languageTextCell) { - return _languageTextCell; - } - _languageTextCell = [[SettingTableViewCell alloc] initWithLabel:NSLocalizedString(@"Language", @"Label for the privacy setting") - editable:self.blog.isAdmin - reuseIdentifier:nil]; - return _languageTextCell; -} - -- (SettingTableViewCell *)timezoneTextCell -{ - if (_timezoneTextCell) { - return _timezoneTextCell; - } - _timezoneTextCell = [[SettingTableViewCell alloc] initWithLabel:NSLocalizedString(@"Time Zone", @"Label for the timezone setting") - editable:self.blog.isAdmin - reuseIdentifier:nil]; - return _timezoneTextCell; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForGeneralSettingsInRow:(NSInteger)row -{ - switch (row) { - case SiteSettingsGeneralTitle: - { - NSString *name = self.blog.settings.name ?: NSLocalizedString(@"A title for the site", @"Placeholder text for the title of a site"); - [self.siteTitleCell setTextValue:name]; - return self.siteTitleCell; - } - case SiteSettingsGeneralTagline: - { - NSString *tagline = self.blog.settings.tagline ?: NSLocalizedString(@"Explain what this site is about.", @"Placeholder text for the tagline of a site"); - [self.siteTaglineCell setTextValue:tagline]; - return self.siteTaglineCell; - } - case SiteSettingsGeneralURL: - { - if (self.blog.url) { - [self.addressTextCell setTextValue:self.blog.url]; - } else { - [self.addressTextCell setTextValue:NSLocalizedString(@"http://my-site-address (URL)", @"(placeholder) Help the user enter a URL into the field")]; - } - return self.addressTextCell; - } - case SiteSettingsGeneralPrivacy: - { - [self.privacyTextCell setTextValue:[BlogSiteVisibilityHelper titleForCurrentSiteVisibilityOfBlog:self.blog]]; - return self.privacyTextCell; - } - case SiteSettingsGeneralLanguage: - { - NSInteger languageId = self.blog.settings.languageID.integerValue; - NSString *name = [[WordPressComLanguageDatabase new] nameForLanguageWithId:languageId]; - - [self.languageTextCell setTextValue:name]; - return self.languageTextCell; - } - case SiteSettingsGeneralTimezone: - { - [self.timezoneTextCell setTextValue:[self timezoneLabel]]; - return self.timezoneTextCell; - } - } - - return [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"NoCell"]; -} - - (SettingTableViewCell *)startOverCell { if (_startOverCell) { @@ -746,6 +647,12 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N case SiteSettingsSectionGeneral: return [self tableView:tableView cellForGeneralSettingsInRow:indexPath.row]; + case SiteSettingsSectionBlogging: + return [self tableView:tableView cellForBloggingSettingsInRow:indexPath.row]; + + case SiteSettingsSectionHomepage: + return self.homepageSettingsCell; + case SiteSettingsSectionAccount: return [self tableView:tableView cellForAccountSettingsInRow:indexPath.row]; @@ -800,6 +707,14 @@ - (NSString *)titleForHeaderInSection:(NSInteger)section case SiteSettingsSectionGeneral: headingTitle = NSLocalizedString(@"General", @"Title for the general section in site settings screen"); break; + + case SiteSettingsSectionBlogging: + headingTitle = NSLocalizedString(@"Blogging", @"Title for the blogging section in site settings screen"); + break; + + case SiteSettingsSectionHomepage: + headingTitle = NSLocalizedString(@"Homepage", @"Title for the homepage section in site settings screen"); + break; case SiteSettingsSectionAccount: headingTitle = NSLocalizedString(@"Account", @"Title for the account section in site settings screen"); @@ -877,6 +792,7 @@ - (void)showPrivacySelector if (weakSelf.blog.siteVisibility != newSiteVisibility) { weakSelf.blog.siteVisibility = newSiteVisibility; [weakSelf saveSettings]; + [WPAnalytics trackSettingsChange:@"site_settings" fieldName:@"privacy" value:status]; } } }; @@ -894,40 +810,12 @@ - (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]; } -- (void)tableView:(UITableView *)tableView didSelectInGeneralSectionRow:(NSInteger)row -{ - if (!self.blog.isAdmin) { - return; - } - - switch (row) { - case SiteSettingsGeneralTitle: - [self showEditSiteTitleController]; - break; - - case SiteSettingsGeneralTagline: - [self showEditSiteTaglineController]; - break; - - case SiteSettingsGeneralPrivacy: - [self showPrivacySelector]; - break; - - case SiteSettingsGeneralLanguage: - [self showLanguageSelectorForBlog:self.blog]; - break; - - case SiteSettingsGeneralTimezone: - [self showTimezoneSelector]; - break; - } -} - - (void)tableView:(UITableView *)tableView didSelectInAccountSectionRow:(NSInteger)row { if (row != SiteSettingsAccountPassword) { @@ -947,52 +835,12 @@ - (void)tableView:(UITableView *)tableView didSelectInAccountSectionRow:(NSInteg [self.navigationController pushViewController:siteTitleViewController animated:YES]; } -- (void)showEditSiteTitleController -{ - if (!self.blog.isAdmin) { - return; - } - - SettingsTextViewController *siteTitleViewController = [[SettingsTextViewController alloc] initWithText:self.blog.settings.name - placeholder:NSLocalizedString(@"A title for the site", @"Placeholder text for the title of a site") - hint:@""]; - siteTitleViewController.title = NSLocalizedString(@"Site Title", @"Title for screen that show site title editor"); - siteTitleViewController.onValueChanged = ^(NSString *value) { - self.siteTitleCell.detailTextLabel.text = value; - if (![value isEqualToString:self.blog.settings.name]){ - self.blog.settings.name = value; - [self saveSettings]; - } - }; - [self.navigationController pushViewController:siteTitleViewController animated:YES]; -} - -- (void)showEditSiteTaglineController -{ - if (!self.blog.isAdmin) { - return; - } - - SettingsTextViewController *siteTaglineViewController = [[SettingsTextViewController alloc] initWithText:self.blog.settings.tagline - placeholder:NSLocalizedString(@"Explain what this site is about.", @"Placeholder text for the tagline of a site") - hint:NSLocalizedString(@"In a few words, explain what this site is about.",@"Explain what is the purpose of the tagline")]; - siteTaglineViewController.title = NSLocalizedString(@"Tagline", @"Title for screen that show tagline editor"); - siteTaglineViewController.onValueChanged = ^(NSString *value) { - NSString *normalizedTagline = [value stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - self.siteTaglineCell.detailTextLabel.text = normalizedTagline; - if (![normalizedTagline isEqualToString:self.blog.settings.tagline]) { - self.blog.settings.tagline = normalizedTagline; - [self saveSettings]; - } - }; - [self.navigationController pushViewController:siteTaglineViewController animated:YES]; -} - - (void)showDefaultCategorySelector { - PostCategoryService *postCategoryService = [[PostCategoryService alloc] initWithManagedObjectContext:[[ContextManager sharedInstance] mainContext]]; NSNumber *defaultCategoryID = self.blog.settings.defaultCategoryID ?: @(PostCategoryUncategorized); - PostCategory *postCategory = [postCategoryService findWithBlogObjectID:self.blog.objectID andCategoryID:defaultCategoryID]; + PostCategory *postCategory = [PostCategory lookupWithBlogObjectID:self.blog.objectID + categoryID:defaultCategoryID + inContext:[[ContextManager sharedInstance] mainContext]]; NSArray *currentSelection = @[]; if (postCategory){ currentSelection = @[postCategory]; @@ -1028,7 +876,7 @@ - (void)showPostFormatSelector SettingsSelectionValuesKey : formats, SettingsSelectionCurrentValueKey : currentDefaultPostFormat }; - + SettingsSelectionViewController *vc = [[SettingsSelectionViewController alloc] initWithDictionary:postFormatsDict]; __weak __typeof__(self) weakSelf = self; vc.onItemSelected = ^(NSString *status) { @@ -1036,7 +884,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]; } } @@ -1055,7 +906,8 @@ - (void)showRelatedPostsSettings - (void)tableView:(UITableView *)tableView didSelectInWritingSectionRow:(NSInteger)row { - switch (row) { + NSInteger writingRow = [self.writingSectionRows[row] integerValue]; + switch (writingRow) { case SiteSettingsWritingDefaultCategory: [self showDefaultCategorySelector]; break; @@ -1104,12 +956,13 @@ - (void)showStartOverForBlog:(Blog *)blog NSParameterAssert([blog supportsSiteManagementServices]); [WPAppAnalytics track:WPAnalyticsStatSiteSettingsStartOverAccessed withBlog:self.blog]; - if (self.blog.hasPaidPlan) { + if ([SupportConfigurationObjC isStartOverSupportEnabled] && self.blog.hasPaidPlan) { StartOverViewController *viewController = [[StartOverViewController alloc] initWithBlog:blog]; [self.navigationController pushViewController:viewController animated:YES]; } else { NSURL *targetURL = [NSURL URLWithString:EmptySiteSupportURL]; - UIViewController *webViewController = [WebViewControllerFactory controllerWithUrl:targetURL]; + + UIViewController *webViewController = [WebViewControllerFactory controllerWithUrl:targetURL source:@"site_settings_start_over"]; UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:webViewController]; [self presentViewController:navController animated:YES completion:nil]; } @@ -1137,9 +990,16 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath NSInteger settingsSection = [self.tableSections[indexPath.section] intValue]; switch (settingsSection) { case SiteSettingsSectionGeneral: - [self tableView:tableView didSelectInGeneralSectionRow:indexPath.row]; + [self tableView:tableView didSelectInGeneralSettingsAt:indexPath]; + break; + + case SiteSettingsSectionBlogging: + [self tableView:tableView didSelectInBloggingSettingsAt:indexPath]; break; + case SiteSettingsSectionHomepage: + [self showHomepageSettingsForBlog:self.blog]; + case SiteSettingsSectionAccount: [self tableView:tableView didSelectInAccountSectionRow:indexPath.row]; break; @@ -1158,6 +1018,12 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath case SiteSettingsSectionAdvanced: [self tableView:tableView didSelectInAdvancedSectionRow:indexPath.row]; + + // UIKit doesn't automatically manage cell selection when a modal presentation is triggered, + // which is the case for Start Over when there's no paid plan, so we deselect the cell manually. + if (indexPath.row == SiteSettingsAdvancedStartOver) { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + } break; } } @@ -1172,13 +1038,13 @@ - (IBAction)refreshTriggered:(id)sender - (void)refreshData { __weak __typeof__(self) weakSelf = self; - NSManagedObjectContext *mainContext = [[ContextManager sharedInstance] mainContext]; - BlogService *service = [[BlogService alloc] initWithManagedObjectContext:mainContext]; + BlogService *service = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [service syncSettingsForBlog:self.blog success:^{ [weakSelf.refreshControl endRefreshing]; + self.tableSections = nil; // force the tableSections to be repopulated. [weakSelf.tableView reloadData]; - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { [weakSelf.refreshControl endRefreshing]; }]; @@ -1215,17 +1081,19 @@ - (void)validateLoginCredentials NSURL *xmlRpcURL = [NSURL URLWithString:self.blog.xmlrpc]; WordPressOrgXMLRPCApi *api = [[WordPressOrgXMLRPCApi alloc] initWithEndpoint:xmlRpcURL userAgent:[WPUserAgent wordPressUserAgent]]; __weak __typeof__(self) weakSelf = self; - [api checkCredentials:self.username password:self.password success:^(id responseObject, NSHTTPURLResponse *httpResponse) { - dispatch_async(dispatch_get_main_queue(), ^{ - [SVProgressHUD dismiss]; + [api checkCredentials:self.username password:self.password success:^(id __unused responseObject, NSHTTPURLResponse *__unused httpResponse) { + [[ContextManager sharedInstance] performAndSaveUsingBlock:^(NSManagedObjectContext *context) { __typeof__(self) strongSelf = weakSelf; if (!strongSelf) { return; } - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:strongSelf.blog.managedObjectContext]; - [blogService updatePassword:strongSelf.password forBlog:strongSelf.blog]; - }); - } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + + Blog *blogInContext = [context existingObjectWithID:strongSelf.blog.objectID error:nil]; + blogInContext.password = strongSelf.password; + } completion:^{ + [SVProgressHUD dismiss]; + } onQueue:dispatch_get_main_queue()]; + } failure:^(NSError *error, NSHTTPURLResponse * __unused httpResponse) { dispatch_async(dispatch_get_main_queue(), ^{ [SVProgressHUD dismiss]; [weakSelf loginValidationFailedWithError:error]; @@ -1268,9 +1136,9 @@ - (void)saveSettings return; } - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:self.blog.managedObjectContext]; + BlogService *blogService = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [blogService updateSettingsForBlog:self.blog success:^{ - [NSNotificationCenter.defaultCenter postNotificationName:WPBlogUpdatedNotification object:nil]; + [NSNotificationCenter.defaultCenter postNotificationName:WPBlogSettingsUpdatedNotification object:nil]; } failure:^(NSError *error) { [SVProgressHUD showDismissibleErrorWithStatus:NSLocalizedString(@"Settings update failed", @"Message to show when setting save failed")]; DDLogError(@"Error while trying to update BlogSettings: %@", error); @@ -1304,7 +1172,7 @@ - (void)showJetpackSettingsForBlog:(Blog *)blog NSParameterAssert(blog); - JetpackSecuritySettingsViewController *settings = [[JetpackSecuritySettingsViewController alloc] initWithBlog:blog]; + JetpackSettingsViewController *settings = [[JetpackSettingsViewController alloc] initWithBlog:blog]; [self.navigationController pushViewController:settings animated:YES]; } @@ -1335,6 +1203,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/Blog/WPStyleGuide+Blog.swift b/WordPress/Classes/ViewRelated/Blog/WPStyleGuide+Blog.swift index 01ab7ecf39e3..04510b6f3db6 100644 --- a/WordPress/Classes/ViewRelated/Blog/WPStyleGuide+Blog.swift +++ b/WordPress/Classes/ViewRelated/Blog/WPStyleGuide+Blog.swift @@ -11,28 +11,35 @@ extension WPStyleGuide { cell.detailTextLabel?.sizeToFit() cell.detailTextLabel?.textColor = .textSubtle - cell.imageView?.layer.borderColor = UIColor.white.cgColor - cell.imageView?.layer.borderWidth = 1 + cell.imageView?.layer.borderColor = UIColor.divider.cgColor + cell.imageView?.layer.borderWidth = .hairlineBorderWidth cell.imageView?.tintColor = .listIcon cell.backgroundColor = UIColor.listForeground } @objc public class func configureCellForLogin(_ cell: WPBlogTableViewCell) { - // TODO: make this dynamic size once @elibud's dynamic type code is merged - cell.textLabel?.font = WPFontManager.systemSemiBoldFont(ofSize: 15.0) - cell.textLabel?.sizeToFit() cell.textLabel?.textColor = .text + cell.detailTextLabel?.textColor = .textSubtle - cell.detailTextLabel?.font = UIFont.preferredFont(forTextStyle: .footnote) - cell.detailTextLabel?.sizeToFit() - cell.detailTextLabel?.textColor = .text + let fontSize = UIFont.preferredFont(forTextStyle: .subheadline).pointSize + cell.textLabel?.font = UIFont.systemFont(ofSize: fontSize, weight: .medium) + cell.detailTextLabel?.font = UIFont.systemFont(ofSize: fontSize, weight: .regular) - cell.imageView?.layer.borderColor = UIColor.neutral(.shade10).cgColor - cell.imageView?.layer.borderWidth = 1 - cell.imageView?.tintColor = .neutral(.shade30) + cell.imageView?.tintColor = .listIcon - cell.backgroundColor = .listBackground + cell.selectionStyle = .none + cell.backgroundColor = .basicBackground } - } +} + +extension LoginEpilogueBlogCell { + // Per Apple's documentation (https://developer.apple.com/documentation/xcode/supporting_dark_mode_in_your_interface), + // `cgColor` objects do not adapt to appearance changes (i.e. toggling light/dark mode). + // `tintColorDidChange` is called when the appearance changes, so re-set the border color when this occurs. + override func tintColorDidChange() { + super.tintColorDidChange() + imageView?.layer.borderColor = UIColor.neutral(.shade10).cgColor + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/WPStyleGuide+BloggingPrompts.swift b/WordPress/Classes/ViewRelated/Blog/WPStyleGuide+BloggingPrompts.swift new file mode 100644 index 000000000000..cab727e6fffe --- /dev/null +++ b/WordPress/Classes/ViewRelated/Blog/WPStyleGuide+BloggingPrompts.swift @@ -0,0 +1,12 @@ +/// This class groups styles used by blogging prompts +/// +extension WPStyleGuide { + public struct BloggingPrompts { + static let promptContentFont = AppStyleGuide.prominentFont(textStyle: .headline, weight: .semibold) + static let answerInfoButtonFont = WPStyleGuide.fontForTextStyle(.caption1) + static let answerInfoButtonColor = UIColor.textSubtle + static let buttonTitleFont = WPStyleGuide.fontForTextStyle(.subheadline) + static let buttonTitleColor = UIColor.primary + static let answeredLabelColor = UIColor.muriel(name: .green, .shade50) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/WPStyleGuide+Sharing.swift b/WordPress/Classes/ViewRelated/Blog/WPStyleGuide+Sharing.swift index da3292d80c90..75d45f2db6cb 100644 --- a/WordPress/Classes/ViewRelated/Blog/WPStyleGuide+Sharing.swift +++ b/WordPress/Classes/ViewRelated/Blog/WPStyleGuide+Sharing.swift @@ -36,15 +36,15 @@ extension WPStyleGuide { // Handle special cases switch name { - case "print" : - return Gridicon.iconOfType(.print) - case "email" : - return Gridicon.iconOfType(.mail) - case "google-plus-1" : + case "print": + return .gridicon(.print) + case "email": + return .gridicon(.mail) + case "google-plus-1": iconName = "social-google-plus" - case "press-this" : + case "press-this": iconName = "social-wordpress" - default : + default: iconName = "social-\(name)" } diff --git a/WordPress/Classes/ViewRelated/Cells/BorderedButtonTableViewCell.swift b/WordPress/Classes/ViewRelated/Cells/BorderedButtonTableViewCell.swift new file mode 100644 index 000000000000..58ea55a3cb1d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Cells/BorderedButtonTableViewCell.swift @@ -0,0 +1,168 @@ +import UIKit + +// UITableViewCell that displays a full width button with a border. +// Properties: +// - normalColor: used for the button label and border (if borderColor is not specified). +// - borderColor: used for border. Defaults to normalColor if not specified. +// - 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 button = UIButton() + private var buttonTitle = String() + private var buttonInsets = Defaults.buttonInsets + private var titleFont = Defaults.titleFont + private var normalColor = Defaults.normalColor + private var highlightedColor = Defaults.highlightedColor + private var borderColor = Defaults.normalColor + private var buttonBackgroundColor: UIColor = .basicBackground + + // Toggles the loading state of the cell. + var isLoading: Bool = false { + didSet { + toggleLoading(isLoading) + } + } + + // MARK: - Activity Indicator + + private lazy var activityIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.translatesAutoresizingMaskIntoConstraints = false + indicator.hidesWhenStopped = false + return indicator + }() + + private lazy var loadingBackgroundView: UIImageView = { + // Bordered background matching the button + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.image = UIImage.renderBackgroundImage(fill: .clear, border: borderColor) + return imageView + }() + + private lazy var loadingOverlayView: UIView = { + let view = UIView() + view.backgroundColor = .basicBackground + view.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(loadingBackgroundView) + view.pinSubviewToAllEdges(loadingBackgroundView) + loadingBackgroundView.backgroundColor = buttonBackgroundColor + loadingBackgroundView.layer.cornerRadius = 8 + + view.addSubview(activityIndicator) + view.pinSubviewAtCenter(activityIndicator) + + return view + }() + + // MARK: - Configure + + func configure(buttonTitle: String, + titleFont: UIFont = Defaults.titleFont, + normalColor: UIColor = Defaults.normalColor, + highlightedColor: UIColor = Defaults.highlightedColor, + borderColor: UIColor? = nil, + buttonInsets: UIEdgeInsets = Defaults.buttonInsets, + backgroundColor: UIColor = .basicBackground) { + self.buttonTitle = buttonTitle + self.titleFont = titleFont + self.normalColor = normalColor + self.highlightedColor = highlightedColor + self.borderColor = borderColor ?? normalColor + self.buttonInsets = buttonInsets + self.buttonBackgroundColor = backgroundColor + configureView() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + updateButtonBorderColors() + } + } + +} + +// MARK: - Private Extension + +private extension BorderedButtonTableViewCell { + + func configureView() { + selectionStyle = .none + accessibilityTraits = .button + + configureButton() + contentView.addSubview(button) + contentView.pinSubviewToAllEdges(button, insets: buttonInsets) + } + + func configureButton() { + let button = UIButton() + + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(buttonTitle, for: .normal) + + button.setTitleColor(normalColor, for: .normal) + button.setTitleColor(highlightedColor, for: .highlighted) + + button.titleLabel?.font = titleFont + button.titleLabel?.textAlignment = .center + button.titleLabel?.numberOfLines = 0 + button.backgroundColor = buttonBackgroundColor + button.layer.cornerRadius = 8 + + // 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() + } + + self.button = button + updateButtonBorderColors() + } + + func updateButtonBorderColors() { + button.setBackgroundImage(.renderBackgroundImage(fill: .clear, border: borderColor), for: .normal) + button.setBackgroundImage(.renderBackgroundImage(fill: borderColor, border: borderColor), for: .highlighted) + } + + 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 = .textInverted + } + + func toggleLoading(_ loading: Bool) { + if loadingOverlayView.superview == nil { + button.addSubview(loadingOverlayView) + button.pinSubviewToAllEdges(loadingOverlayView) + } + + if loading { + activityIndicator.startAnimating() + bringSubviewToFront(loadingOverlayView) + } else { + activityIndicator.stopAnimating() + sendSubviewToBack(loadingOverlayView) + } + + loadingOverlayView.isHidden = !loading + } + +} diff --git a/WordPress/Classes/ViewRelated/Cells/ExpandableCell.swift b/WordPress/Classes/ViewRelated/Cells/ExpandableCell.swift index f9bae5a3d4ae..460f20643521 100644 --- a/WordPress/Classes/ViewRelated/Cells/ExpandableCell.swift +++ b/WordPress/Classes/ViewRelated/Cells/ExpandableCell.swift @@ -38,7 +38,7 @@ class ExpandableCell: WPReusableTableViewCell { alpha = 0 } - UIView.animate(withDuration: 0.2) { [unowned self] in + UIView.animate(withDuration: 0.2) { self.chevronImageView?.transform = transform self.expandableTextView?.alpha = alpha } @@ -54,7 +54,7 @@ class ExpandableCell: WPReusableTableViewCell { } private func setupSubviews() { - chevronImageView?.image = Gridicon.iconOfType(.chevronDown) + chevronImageView?.image = .gridicon(.chevronDown) chevronImageView?.tintColor = WPStyleGuide.cellGridiconAccessoryColor() titleTextLabel?.textColor = .text diff --git a/WordPress/Classes/ViewRelated/Cells/InlineEditableMultiLineCell.swift b/WordPress/Classes/ViewRelated/Cells/InlineEditableMultiLineCell.swift new file mode 100644 index 000000000000..20b790d4f600 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Cells/InlineEditableMultiLineCell.swift @@ -0,0 +1,68 @@ +import Foundation + +// UITableViewCell that displays an editable UITextView to allow text to be modified inline. +// The cell height resizes as the text is modified. +// The delegate is notified when: +// - The height is updated. +// - The text is updated. + +protocol InlineEditableMultiLineCellDelegate: AnyObject { + func textViewHeightUpdatedForCell(_ cell: InlineEditableMultiLineCell) + func textUpdatedForCell(_ cell: InlineEditableMultiLineCell) +} + +class InlineEditableMultiLineCell: UITableViewCell, NibReusable { + + // MARK: - Properties + + @IBOutlet weak var textView: UITextView! + @IBOutlet weak var textViewMinHeightConstraint: NSLayoutConstraint! + weak var delegate: InlineEditableMultiLineCellDelegate? + + // MARK: - View + + override func awakeFromNib() { + super.awakeFromNib() + configureCell() + } + + func configure(text: String? = nil) { + textView.text = text + adjustHeight() + } + +} + +// MARK: - UITextViewDelegate + +extension InlineEditableMultiLineCell: UITextViewDelegate { + + func textViewDidChange(_ textView: UITextView) { + delegate?.textUpdatedForCell(self) + adjustHeight() + } + +} + +// MARK: - Private Extension + +private extension InlineEditableMultiLineCell { + + func configureCell() { + textView.font = .preferredFont(forTextStyle: .body) + textView.textColor = .text + textView.backgroundColor = .clear + } + + func adjustHeight() { + let originalHeight = textView.frame.size.height + textView.sizeToFit() + let textViewHeight = ceilf(Float(max(textView.frame.size.height, textViewMinHeightConstraint.constant))) + textView.frame.size.height = CGFloat(textViewHeight) + + if textViewHeight != Float(originalHeight) { + delegate?.textViewHeightUpdatedForCell(self) + } + } + +} diff --git a/WordPress/Classes/ViewRelated/Cells/InlineEditableMultiLineCell.xib b/WordPress/Classes/ViewRelated/Cells/InlineEditableMultiLineCell.xib new file mode 100644 index 000000000000..b8f81a62f337 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Cells/InlineEditableMultiLineCell.xib @@ -0,0 +1,57 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="LfA-no-L5x" customClass="InlineEditableMultiLineCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="364" height="150"/> + <autoresizingMask key="autoresizingMask"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="LfA-no-L5x" id="20L-Xf-3Te"> + <rect key="frame" x="0.0" y="0.0" width="364" height="150"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" contentInsetAdjustmentBehavior="never" textAlignment="natural" adjustsFontForContentSizeCategory="YES" translatesAutoresizingMaskIntoConstraints="NO" id="FkL-aB-20C"> + <rect key="frame" x="16" y="11" width="332" height="130"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="130" id="mme-Ba-30V"/> + </constraints> + <string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. </string> + <color key="textColor" systemColor="labelColor"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> + <connections> + <outlet property="delegate" destination="LfA-no-L5x" id="Uer-HX-Iil"/> + </connections> + </textView> + </subviews> + <constraints> + <constraint firstAttribute="bottom" secondItem="FkL-aB-20C" secondAttribute="bottom" constant="11" id="42s-N9-VoW"/> + <constraint firstItem="FkL-aB-20C" firstAttribute="leading" secondItem="20L-Xf-3Te" secondAttribute="leading" constant="16" id="5SV-Zt-oKA"/> + <constraint firstItem="FkL-aB-20C" firstAttribute="top" secondItem="20L-Xf-3Te" secondAttribute="top" constant="11" id="mgJ-V6-WZA"/> + <constraint firstAttribute="trailing" secondItem="FkL-aB-20C" secondAttribute="trailing" constant="16" id="z8Y-Et-ldW"/> + </constraints> + </tableViewCellContentView> + <connections> + <outlet property="textView" destination="FkL-aB-20C" id="GOY-jR-qNE"/> + <outlet property="textViewMinHeightConstraint" destination="mme-Ba-30V" id="zh4-cE-ddl"/> + </connections> + <point key="canvasLocation" x="-131.8840579710145" y="-16.071428571428569"/> + </tableViewCell> + </objects> + <resources> + <systemColor name="labelColor"> + <color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Cells/InlineEditableNameValueCell.swift b/WordPress/Classes/ViewRelated/Cells/InlineEditableNameValueCell.swift index 366c695a61dc..3155165008d4 100644 --- a/WordPress/Classes/ViewRelated/Cells/InlineEditableNameValueCell.swift +++ b/WordPress/Classes/ViewRelated/Cells/InlineEditableNameValueCell.swift @@ -1,7 +1,7 @@ import UIKit import WordPressAuthenticator -@objc protocol InlineEditableNameValueCellDelegate: class { +@objc protocol InlineEditableNameValueCellDelegate: AnyObject { @objc optional func inlineEditableNameValueCell(_ cell: InlineEditableNameValueCell, valueTextFieldDidChange value: String) @objc optional func inlineEditableNameValueCell(_ cell: InlineEditableNameValueCell, @@ -12,6 +12,7 @@ import WordPressAuthenticator } class InlineEditableNameValueCell: WPTableViewCell, NibReusable { + typealias ValueSanitizerBlock = (_ value: String?) -> String? fileprivate enum Const { enum Color { @@ -29,6 +30,7 @@ class InlineEditableNameValueCell: WPTableViewCell, NibReusable { @IBOutlet weak var nameLabel: UILabel! @IBOutlet weak var valueTextField: LoginTextField! weak var delegate: InlineEditableNameValueCellDelegate? + var valueSanitizer: ValueSanitizerBlock? override var accessoryType: UITableViewCell.AccessoryType { didSet { @@ -71,18 +73,23 @@ class InlineEditableNameValueCell: WPTableViewCell, NibReusable { @objc func textFieldDidChange(textField: UITextField) { textField.text = textField.text?.replacingOccurrences(of: Const.Text.space, with: Const.Text.nonBreakingSpace) - let text = sanitizedText(for: textField) + let text = replaceNonBreakingSpaceWithSpace(for: textField) delegate?.inlineEditableNameValueCell?(self, valueTextFieldDidChange: text) } @objc func textEditingDidEnd(textField: UITextField) { - let text = sanitizedText(for: textField) + if let valueSanitizer = valueSanitizer { + textField.text = valueSanitizer(textField.text) + } + + let text = replaceNonBreakingSpaceWithSpace(for: textField) textField.text = text + delegate?.inlineEditableNameValueCell?(self, valueTextFieldEditingDidEnd: text) } - private func sanitizedText(for textField: UITextField) -> String { + private func replaceNonBreakingSpaceWithSpace(for textField: UITextField) -> String { return textField.text?.replacingOccurrences(of: Const.Text.nonBreakingSpace, with: Const.Text.space) ?? "" } @@ -104,6 +111,7 @@ extension InlineEditableNameValueCell { var placeholder: String? var valueColor: UIColor? var accessoryType: UITableViewCell.AccessoryType? + var valueSanitizer: ValueSanitizerBlock? } func update(with model: Model) { @@ -112,5 +120,6 @@ extension InlineEditableNameValueCell { valueTextField.placeholder = model.placeholder valueTextField.textColor = model.valueColor ?? Const.Color.valueText accessoryType = model.accessoryType ?? .none + valueSanitizer = model.valueSanitizer } } diff --git a/WordPress/Classes/ViewRelated/Cells/InlineEditableSingleLineCell.swift b/WordPress/Classes/ViewRelated/Cells/InlineEditableSingleLineCell.swift new file mode 100644 index 000000000000..81e2bbd1f003 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Cells/InlineEditableSingleLineCell.swift @@ -0,0 +1,112 @@ +import Foundation + +// UITableViewCell that displays an editable UITextField to allow text to be modified inline. +// The text field and keyboard styles are set based on the TextFieldStyle. The default is `text`. +// The delegate is notified as the text is modified. + +protocol InlineEditableSingleLineCellDelegate: AnyObject { + func textUpdatedForCell(_ cell: InlineEditableSingleLineCell) +} + +// Used to determine TextField configuration options. +enum TextFieldStyle { + case text + case url + case email +} + + +class InlineEditableSingleLineCell: UITableViewCell, NibReusable { + + // MARK: - Properties + + @IBOutlet weak var textField: UITextField! + weak var delegate: InlineEditableSingleLineCellDelegate? + private(set) var textFieldStyle: TextFieldStyle = .text + private(set) var isValid: Bool = true + + // MARK: - View + + override func awakeFromNib() { + super.awakeFromNib() + configureCell() + } + + func configure(text: String? = nil, style: TextFieldStyle = .text, disabled: Bool = false) { + textField.text = text + textFieldStyle = style + applyTextFieldStyle() + configureInteraction(disabled) + } + + func showInvalidState(_ show: Bool = true) { + guard show else { + contentView.layer.borderColor = UIColor.clear.cgColor + return + } + + contentView.layer.borderColor = UIColor.error.cgColor + contentView.layer.borderWidth = 1.0 + contentView.layer.cornerRadius = 10 + } + +} + +// MARK: - UITextFieldDelegate + +extension InlineEditableSingleLineCell: UITextFieldDelegate { + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + + @IBAction func textFieldChanged(_ sender: UITextField) { + validateText(sender.text) + } + +} + +// MARK: - Private Extension + +private extension InlineEditableSingleLineCell { + + func configureCell() { + textField.font = .preferredFont(forTextStyle: .body) + textField.textColor = .text + } + + func applyTextFieldStyle() { + switch textFieldStyle { + case .text: + textField.autocorrectionType = .yes + textField.keyboardType = .default + textField.returnKeyType = .default + case .url: + textField.autocorrectionType = .no + textField.keyboardType = .URL + case .email: + textField.autocorrectionType = .no + textField.keyboardType = .emailAddress + } + } + + func validateText(_ text: String?) { + isValid = { + switch textFieldStyle { + case .email: + return text?.isValidEmail() ?? false + default: + return true + } + }() + + delegate?.textUpdatedForCell(self) + } + + func configureInteraction(_ disabled: Bool) { + isUserInteractionEnabled = !disabled + textField.textColor = disabled ? .neutral(.shade20) : .text + } + +} diff --git a/WordPress/Classes/ViewRelated/Cells/InlineEditableSingleLineCell.xib b/WordPress/Classes/ViewRelated/Cells/InlineEditableSingleLineCell.xib new file mode 100644 index 000000000000..a8ce9f371b48 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Cells/InlineEditableSingleLineCell.xib @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="LfA-no-L5x" customClass="InlineEditableSingleLineCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="364" height="127"/> + <autoresizingMask key="autoresizingMask"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="LfA-no-L5x" id="20L-Xf-3Te"> + <rect key="frame" x="0.0" y="0.0" width="364" height="127"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" textAlignment="natural" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" minimumFontSize="17" clearButtonMode="whileEditing" translatesAutoresizingMaskIntoConstraints="NO" id="2wP-Ee-7cF" userLabel="TextField"> + <rect key="frame" x="16" y="11" width="332" height="105"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <textInputTraits key="textInputTraits" enablesReturnKeyAutomatically="YES"/> + <connections> + <action selector="textFieldChanged:" destination="LfA-no-L5x" eventType="editingChanged" id="4Hk-W2-lj3"/> + <outlet property="delegate" destination="LfA-no-L5x" id="J61-eD-vXs"/> + </connections> + </textField> + </subviews> + <constraints> + <constraint firstAttribute="bottom" secondItem="2wP-Ee-7cF" secondAttribute="bottom" constant="11" id="90b-bJ-IoF"/> + <constraint firstItem="2wP-Ee-7cF" firstAttribute="leading" secondItem="20L-Xf-3Te" secondAttribute="leading" constant="16" id="LfS-0x-Dwu"/> + <constraint firstItem="2wP-Ee-7cF" firstAttribute="top" secondItem="20L-Xf-3Te" secondAttribute="top" constant="11" id="YXg-DW-6qd"/> + <constraint firstAttribute="trailing" secondItem="2wP-Ee-7cF" secondAttribute="trailing" constant="16" id="trM-a6-0C0"/> + </constraints> + </tableViewCellContentView> + <connections> + <outlet property="textField" destination="2wP-Ee-7cF" id="SX1-jf-hu9"/> + </connections> + <point key="canvasLocation" x="-131.8840579710145" y="-23.772321428571427"/> + </tableViewCell> + </objects> +</document> diff --git a/WordPress/Classes/ViewRelated/Cells/MediaItemTableViewCells.swift b/WordPress/Classes/ViewRelated/Cells/MediaItemTableViewCells.swift index cb58854ed431..04d3b4af2f33 100644 --- a/WordPress/Classes/ViewRelated/Cells/MediaItemTableViewCells.swift +++ b/WordPress/Classes/ViewRelated/Cells/MediaItemTableViewCells.swift @@ -155,9 +155,9 @@ class MediaItemDocumentTableViewCell: WPTableViewCell { let size = CGSize(width: dimension, height: dimension) if media.mediaType == .audio { - customImageView.image = Gridicon.iconOfType(.audio, withSize: size) + customImageView.image = .gridicon(.audio, size: size) } else { - customImageView.image = Gridicon.iconOfType(.pages, withSize: size) + customImageView.image = .gridicon(.pages, size: size) } } } @@ -175,6 +175,9 @@ struct MediaImageRow: ImmuTableRow { setAspectRatioFor(cell) loadImageFor(cell) cell.isVideo = media.mediaType == .video + cell.accessibilityTraits = .button + cell.accessibilityLabel = NSLocalizedString("Preview media", comment: "Accessibility label for media item preview for user's viewing an item in their media library") + cell.accessibilityHint = NSLocalizedString("Tap to view media in full screen", comment: "Accessibility hint for media item preview for user's viewing an item in their media library") } } @@ -213,7 +216,8 @@ struct MediaImageRow: ImmuTableRow { } private func loadImageFor(_ cell: MediaItemImageTableViewCell) { - cell.imageLoader.loadImage(media: media, placeholder: placeholderImage, success: nil) { (error) in + let isBlogAtomic = media.blog.isAtomic() + cell.imageLoader.loadImage(media: media, placeholder: placeholderImage, isBlogAtomic: isBlogAtomic, success: nil) { (error) in self.show(error) } } @@ -221,7 +225,11 @@ struct MediaImageRow: ImmuTableRow { private func show(_ error: Error?) { let alertController = UIAlertController(title: nil, message: NSLocalizedString("There was a problem loading the media item.", comment: "Error message displayed when the Media Library is unable to load a full sized preview of an item."), preferredStyle: .alert) - alertController.addCancelActionWithTitle(NSLocalizedString("Dismiss", comment: "Verb. User action to dismiss error alert when failing to load media item.")) + alertController.addCancelActionWithTitle(NSLocalizedString( + "mediaItemTable.errorAlert.dismissButton", + value: "Dismiss", + comment: "Verb. User action to dismiss error alert when failing to load media item." + )) alertController.presentFromRootViewController() } } @@ -239,6 +247,9 @@ struct MediaDocumentRow: ImmuTableRow { if let cell = cell as? MediaItemDocumentTableViewCell { cell.customImageView.tintColor = cell.textLabel?.textColor cell.showIconForMedia(media) + cell.accessibilityTraits = .button + cell.accessibilityLabel = NSLocalizedString("Preview media", comment: "Accessibility label for media item preview for user's viewing an item in their media library") + cell.accessibilityHint = NSLocalizedString("Tap to view media in full screen", comment: "Accessibility hint for media item preview for user's viewing an item in their media library") } } } diff --git a/WordPress/Classes/ViewRelated/Cells/PickerTableViewCell.swift b/WordPress/Classes/ViewRelated/Cells/PickerTableViewCell.swift index 11c80672a6a5..618eaaa247c5 100644 --- a/WordPress/Classes/ViewRelated/Cells/PickerTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Cells/PickerTableViewCell.swift @@ -10,7 +10,7 @@ open class PickerTableViewCell: WPTableViewCell, UIPickerViewDelegate, UIPickerV /// Closure, to be executed on selection change /// - @objc open var onChange : ((_ newValue: Int) -> ())? + @objc open var onChange: ((_ newValue: Int) -> ())? /// String Format, to be applied to the Row Titles diff --git a/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.h b/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.h index 4da86ab3382a..eae85221395c 100644 --- a/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.h +++ b/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.h @@ -1,6 +1,6 @@ #import <WordPressShared/WPTableViewCell.h> -@protocol ImageSourceInformation; +@class AbstractPost; @class PostFeaturedImageCell; @protocol PostFeaturedImageCellDelegate <NSObject> @@ -16,6 +16,6 @@ extern CGFloat const PostFeaturedImageCellMargin; @property (weak, nonatomic, nullable) id<PostFeaturedImageCellDelegate> delegate; @property (strong, nonatomic, readonly, nullable) UIImage *image; -- (void)setImageWithURL:(nonnull NSURL *)url inPost:(nonnull id<ImageSourceInformation>)postInformation withSize:(CGSize)size; +- (void)setImageWithURL:(nonnull NSURL *)url inPost:(nonnull AbstractPost *)post withSize:(CGSize)size; @end diff --git a/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.m b/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.m index 248af25858b6..40331ca999b7 100644 --- a/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.m +++ b/WordPress/Classes/ViewRelated/Cells/PostFeaturedImageCell.m @@ -25,12 +25,14 @@ - (void)setup { [self layoutImageView]; _imageLoader = [[ImageLoader alloc] initWithImageView:self.featuredImageView gifStrategy:GIFStrategyLargeGIFs]; + self.accessibilityLabel = NSLocalizedString(@"A featured image is set. Tap to change it.", @"Label for image that is set as a feature image for post/page"); + self.accessibilityIdentifier = @"CurrentFeaturedImage"; } -- (void)setImageWithURL:(NSURL *)url inPost:(id<ImageSourceInformation>)postInformation withSize:(CGSize)size +- (void)setImageWithURL:(NSURL *)url inPost:(AbstractPost *)post withSize:(CGSize)size { __weak PostFeaturedImageCell *weakSelf = self; - [self.imageLoader loadImageWithURL:url fromPost:postInformation preferredSize:size placeholder:nil success:^{ + [self.imageLoader loadImageWithURL:url fromPost:post preferredSize:size placeholder:nil success:^{ [weakSelf informDelegateImageLoaded]; } error:^(NSError * _Nullable error) { if (weakSelf && weakSelf.delegate) { diff --git a/WordPress/Classes/ViewRelated/Cells/PostGeolocationCell.h b/WordPress/Classes/ViewRelated/Cells/PostGeolocationCell.h deleted file mode 100644 index 8232016247de..000000000000 --- a/WordPress/Classes/ViewRelated/Cells/PostGeolocationCell.h +++ /dev/null @@ -1,8 +0,0 @@ -#import "Coordinate.h" -#import <WordPressShared/WPTableViewCell.h> - -@interface PostGeolocationCell : WPTableViewCell - -- (void)setCoordinate:(Coordinate *)coordinate andAddress:(NSString *)address; - -@end diff --git a/WordPress/Classes/ViewRelated/Cells/PostGeolocationCell.m b/WordPress/Classes/ViewRelated/Cells/PostGeolocationCell.m deleted file mode 100644 index 4199be3f9068..000000000000 --- a/WordPress/Classes/ViewRelated/Cells/PostGeolocationCell.m +++ /dev/null @@ -1,50 +0,0 @@ -#import "PostGeolocationCell.h" -#import <MapKit/MapKit.h> - -#import "PostGeolocationView.h" - -CGFloat const PostGeolocationCellMargin = 15.0f; - -@interface PostGeolocationCell () - -@property (nonatomic, strong) PostGeolocationView *geoView; - -@end - -@implementation PostGeolocationCell - -- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier -{ - self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; - if (self) { - [self configureSubviews]; - } - return self; -} - -- (void)configureSubviews -{ - PostGeolocationView *geoView = [[PostGeolocationView alloc] init]; - geoView.translatesAutoresizingMaskIntoConstraints = NO; - geoView.labelMargin = 0.0f; - geoView.scrollEnabled = NO; - geoView.chevronHidden = YES; - [self.contentView addSubview:geoView]; - - UILayoutGuide *readableGuide = self.contentView.readableContentGuide; - [NSLayoutConstraint activateConstraints:@[ - [geoView.leadingAnchor constraintEqualToAnchor:readableGuide.leadingAnchor], - [geoView.trailingAnchor constraintEqualToAnchor:readableGuide.trailingAnchor], - [geoView.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:PostGeolocationCellMargin], - [geoView.bottomAnchor constraintEqualToAnchor:self.contentView.bottomAnchor] - ]]; - _geoView = geoView; -} - -- (void)setCoordinate:(Coordinate *)coordinate andAddress:(NSString *)address -{ - self.geoView.coordinate = coordinate; - self.geoView.address = address; -} - -@end diff --git a/WordPress/Classes/ViewRelated/Cells/SwitchTableViewCell.swift b/WordPress/Classes/ViewRelated/Cells/SwitchTableViewCell.swift index eefbdc96dc1e..0dc947c5a55a 100644 --- a/WordPress/Classes/ViewRelated/Cells/SwitchTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Cells/SwitchTableViewCell.swift @@ -5,7 +5,7 @@ import WordPressShared /// open class SwitchTableViewCell: WPTableViewCell { // MARK: - Public Properties - @objc open var onChange : ((_ newValue: Bool) -> ())? + @objc open var onChange: ((_ newValue: Bool) -> ())? @objc open var name: String { get { diff --git a/WordPress/Classes/ViewRelated/Comments/CommentAnalytics.swift b/WordPress/Classes/ViewRelated/Comments/CommentAnalytics.swift new file mode 100644 index 000000000000..66acd29fac80 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentAnalytics.swift @@ -0,0 +1,102 @@ +import Foundation + +@objc class CommentAnalytics: NSObject { + + struct Constants { + static let sites = "sites" + static let reader = "reader" + static let notifications = "notifications" + static let unknown = "unknown" + static let context = "context" + } + + static func trackingContext() -> String { + let screen = RootViewCoordinator.sharedPresenter.currentlySelectedScreen() + switch screen { + case WPTabBarCurrentlySelectedScreenSites: + return Constants.sites + case WPTabBarCurrentlySelectedScreenReader: + return Constants.reader + case WPTabBarCurrentlySelectedScreenNotifications: + return Constants.notifications + default: + return Constants.unknown + } + } + + static func defaultProperties(comment: Comment) -> [AnyHashable: Any] { + return [ + Constants.context: trackingContext(), + WPAppAnalyticsKeyPostID: comment.postID, + WPAppAnalyticsKeyCommentID: comment.commentID + ] + } + + @objc static func trackCommentViewed(comment: Comment) { + trackCommentEvent(comment: comment, event: .commentViewed) + } + + @objc static func trackCommentEditorOpened(comment: Comment) { + trackCommentEvent(comment: comment, event: .commentEditorOpened) + } + + @objc static func trackCommentEdited(comment: Comment) { + trackCommentEvent(comment: comment, event: .commentEdited) + } + + @objc static func trackCommentApproved(comment: Comment) { + trackCommentEvent(comment: comment, event: .commentApproved) + } + + @objc static func trackCommentUnApproved(comment: Comment) { + trackCommentEvent(comment: comment, event: .commentUnApproved) + } + + @objc static func trackCommentTrashed(comment: Comment) { + trackCommentEvent(comment: comment, event: .commentTrashed) + } + + @objc static func trackCommentSpammed(comment: Comment) { + trackCommentEvent(comment: comment, event: .commentSpammed) + } + + @objc static func trackCommentLiked(comment: Comment) { + trackCommentEvent(comment: comment, event: .commentLiked) + } + + @objc static func trackCommentUnLiked(comment: Comment) { + trackCommentEvent(comment: comment, event: .commentUnliked) + } + + @objc static func trackCommentRepliedTo(comment: Comment) { + trackCommentEvent(comment: comment, event: .commentRepliedTo) + } + + private static func trackCommentEvent(comment: Comment, event: WPAnalyticsEvent) { + let properties = defaultProperties(comment: comment) + + guard let blog = comment.blog else { + WPAnalytics.track(event, properties: properties) + return + } + + WPAnalytics.track(event, properties: properties, blog: blog) + } + + static func trackCommentEditorOpened(block: FormattableCommentContent) { + WPAnalytics.track(.commentEditorOpened, properties: [ + Constants.context: CommentAnalytics.trackingContext(), + WPAppAnalyticsKeyBlogID: block.metaSiteID?.intValue ?? 0, + WPAppAnalyticsKeyCommentID: block.metaCommentID?.intValue ?? 0 + ]) + } + + static func trackCommentEdited(block: FormattableCommentContent) { + WPAnalytics.track(.commentEdited, properties: [ + Constants.context: CommentAnalytics.trackingContext(), + WPAppAnalyticsKeyBlogID: block.metaSiteID?.intValue ?? 0, + WPAppAnalyticsKeyCommentID: block.metaCommentID?.intValue ?? 0 + ]) + } + +} diff --git a/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.swift b/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.swift new file mode 100644 index 000000000000..6e1752f98a23 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.swift @@ -0,0 +1,527 @@ +import UIKit + +class CommentContentTableViewCell: UITableViewCell, NibReusable { + + // all the available images for the accessory button. + enum AccessoryButtonType { + case share + case ellipsis + case info + } + + enum RenderMethod { + /// Uses WebKit to render the comment body. + case web + + /// Uses WPRichContent to render the comment body. + case richContent + } + + // MARK: - Public Properties + + /// A closure that's called when the accessory button is tapped. + /// The button's view is sent as the closure's parameter for reference. + @objc var accessoryButtonAction: ((UIView) -> Void)? = nil + + @objc var replyButtonAction: (() -> Void)? = nil + + @objc var likeButtonAction: (() -> Void)? = nil + + @objc var contentLinkTapAction: ((URL) -> Void)? = nil + + @objc weak var richContentDelegate: WPRichContentViewDelegate? = nil + + /// Encapsulate the accessory button image assignment through an enum, to apply a standardized image configuration. + /// See `accessoryIconConfiguration` in `WPStyleGuide+CommentDetail`. + var accessoryButtonType: AccessoryButtonType = .share { + didSet { + accessoryButton.setImage(accessoryButtonImage, for: .normal) + } + } + + /// When supplied with a non-empty string, the cell will show a badge label beside the name label. + /// Note that the badge will be hidden when the title is nil or empty. + var badgeTitle: String? = nil { + didSet { + let title: String = { + if let title = badgeTitle { + return title.localizedUppercase + } + return String() + }() + + badgeLabel.setText(title) + badgeLabel.isHidden = title.isEmpty + badgeLabel.updateConstraintsIfNeeded() + } + } + + override var indentationWidth: CGFloat { + didSet { + updateContainerLeadingConstraint() + } + } + + override var indentationLevel: Int { + didSet { + updateContainerLeadingConstraint() + } + } + + /// A custom highlight style for the cell that is more controllable than `isHighlighted`. + /// Cell selection for this cell is disabled, and highlight style may be disabled based on the table view settings. + @objc var isEmphasized: Bool = false { + didSet { + backgroundColor = isEmphasized ? Style.highlightedBackgroundColor : nil + highlightBarView.backgroundColor = isEmphasized ? Style.highlightedBarBackgroundColor : .clear + } + } + + @objc var isReplyHighlighted: Bool = false { + didSet { + replyButton?.tintColor = isReplyHighlighted ? Style.highlightedReplyButtonTintColor : Style.reactionButtonTextColor + replyButton?.setTitleColor(isReplyHighlighted ? Style.highlightedReplyButtonTintColor : Style.reactionButtonTextColor, for: .normal) + replyButton?.setImage(isReplyHighlighted ? Style.highlightedReplyIconImage : Style.replyIconImage, for: .normal) + } + } + + // MARK: Constants + + private let customBottomSpacing: CGFloat = 10 + private let contentButtonsTopSpacing: CGFloat = 15 + + // MARK: Outlets + + @IBOutlet private weak var containerStackView: UIStackView! + @IBOutlet private weak var containerStackBottomConstraint: NSLayoutConstraint! + + @IBOutlet private weak var containerStackLeadingConstraint: NSLayoutConstraint! + @IBOutlet private weak var containerStackTrailingConstraint: NSLayoutConstraint! + private var defaultLeadingMargin: CGFloat = 0 + + @IBOutlet private weak var avatarImageView: CircularImageView! + @IBOutlet private weak var nameLabel: UILabel! + @IBOutlet private weak var badgeLabel: BadgeLabel! + @IBOutlet private weak var dateLabel: UILabel! + @IBOutlet private(set) weak var accessoryButton: UIButton! + + @IBOutlet private weak var contentContainerView: UIView! + @IBOutlet private weak var contentContainerHeightConstraint: NSLayoutConstraint! + + @IBOutlet private weak var replyButton: UIButton! + @IBOutlet private weak var likeButton: UIButton! + + @IBOutlet private weak var highlightBarView: UIView! + @IBOutlet private weak var separatorView: UIView! + + // MARK: Private Properties + + /// Called when the cell has finished loading and calculating the height of the HTML content. Passes the new content height as parameter. + private var onContentLoaded: ((CGFloat) -> Void)? = nil + + private var renderer: CommentContentRenderer? = nil + + private var renderMethod: RenderMethod? + + // MARK: Like Button State + + private var isLiked: Bool = false + + private var likeCount: Int = 0 + + private var isLikeButtonAnimating: Bool = false + + // MARK: Visibility Control + + private var isCommentReplyEnabled: Bool = false { + didSet { + replyButton.isHidden = !isCommentReplyEnabled + } + } + + private var isCommentLikesEnabled: Bool = false { + didSet { + likeButton.isHidden = !isCommentLikesEnabled + } + } + + private var isAccessoryButtonEnabled: Bool = false { + didSet { + accessoryButton.isHidden = !isAccessoryButtonEnabled + } + } + + private var isReactionBarVisible: Bool { + return isCommentReplyEnabled || isCommentLikesEnabled + } + + var shouldHideSeparator = false { + didSet { + separatorView.isHidden = shouldHideSeparator + } + } + + // MARK: Lifecycle + + override func prepareForReuse() { + super.prepareForReuse() + + // reset all highlight states. + isEmphasized = false + isReplyHighlighted = false + + // reset all button actions. + accessoryButtonAction = nil + replyButtonAction = nil + likeButtonAction = nil + contentLinkTapAction = nil + + onContentLoaded = nil + } + + override func awakeFromNib() { + super.awakeFromNib() + configureViews() + } + + // MARK: Public Methods + + /// Configures the cell with a `Comment` object. + /// + /// - Parameters: + /// - comment: The `Comment` object to display. + /// - renderMethod: Specifies how to display the comment body. See `RenderMethod`. + /// - onContentLoaded: Callback to be called once the content has been loaded. Provides the new content height as parameter. + func configure(with comment: Comment, renderMethod: RenderMethod = .web, onContentLoaded: ((CGFloat) -> Void)?) { + nameLabel?.setText(comment.authorForDisplay()) + dateLabel?.setText(comment.dateForDisplay()?.toMediumString() ?? String()) + + // Always cancel ongoing image downloads, just in case. This is to prevent comment cells being displayed with the wrong avatar image, + // likely resulting from previous download operation before the cell is reused. + // + // Note that when downloading an image, any ongoing operation will be cancelled in UIImageView+Networking. + // This is more of a preventative step where the cancellation is made to happen as early as possible. + // + // Ref: https://github.com/wordpress-mobile/WordPress-iOS/issues/17972 + avatarImageView.cancelImageDownload() + + if let avatarURL = URL(string: comment.authorAvatarURL) { + configureImage(with: avatarURL) + } else { + configureImageWithGravatarEmail(comment.gravatarEmailForDisplay()) + } + + updateLikeButton(liked: comment.isLiked, numberOfLikes: comment.numberOfLikes()) + + // Configure feature availability. + isCommentReplyEnabled = comment.canReply() + isCommentLikesEnabled = comment.canLike() + isAccessoryButtonEnabled = comment.isApproved() + + // When reaction bar is hidden, add some space between the webview and the moderation bar. + containerStackView.setCustomSpacing(contentButtonsTopSpacing, after: contentContainerView) + + // Configure content renderer. + self.onContentLoaded = onContentLoaded + configureRendererIfNeeded(for: comment, renderMethod: renderMethod) + } + + /// Configures the cell with a `Comment` object, to be displayed in the post details view. + /// + /// - Parameters: + /// - comment: The `Comment` object to display. + /// - onContentLoaded: Callback to be called once the content has been loaded. Provides the new content height as parameter. + func configureForPostDetails(with comment: Comment, onContentLoaded: ((CGFloat) -> Void)?) { + configure(with: comment, onContentLoaded: onContentLoaded) + + isCommentLikesEnabled = false + isCommentReplyEnabled = false + isAccessoryButtonEnabled = false + + containerStackLeadingConstraint.constant = 0 + containerStackTrailingConstraint.constant = 0 + } + + @objc func ensureRichContentTextViewLayout() { + guard renderMethod == .richContent, + let richContentTextView = contentContainerView.subviews.first as? WPRichContentView else { + return + } + + richContentTextView.updateLayoutForAttachments() + } +} + +// MARK: - CommentContentRendererDelegate + +extension CommentContentTableViewCell: CommentContentRendererDelegate { + func renderer(_ renderer: CommentContentRenderer, asyncRenderCompletedWithHeight height: CGFloat) { + if renderMethod == .web { + contentContainerHeightConstraint?.constant = height + } + onContentLoaded?(height) + } + + func renderer(_ renderer: CommentContentRenderer, interactedWithURL url: URL) { + contentLinkTapAction?(url) + } +} + +// MARK: - Helpers + +private extension CommentContentTableViewCell { + typealias Style = WPStyleGuide.CommentDetail.Content + + var accessoryButtonImage: UIImage? { + switch accessoryButtonType { + case .share: + return .init(systemName: Style.shareIconImageName, withConfiguration: Style.accessoryIconConfiguration) + case .ellipsis: + return .init(systemName: Style.ellipsisIconImageName, withConfiguration: Style.accessoryIconConfiguration) + case .info: + return .init(systemName: Style.infoIconImageName, withConfiguration: Style.accessoryIconConfiguration) + } + } + + var likeButtonTitle: String { + switch likeCount { + case .zero: + return .noLikes + case 1: + return String(format: .singularLikeFormat, likeCount) + default: + return String(format: .pluralLikesFormat, likeCount) + } + } + + // assign base styles for all the cell components. + func configureViews() { + // Store default margin for use in content layout. + defaultLeadingMargin = containerStackLeadingConstraint.constant + + selectionStyle = .none + + nameLabel?.font = Style.nameFont + nameLabel?.textColor = Style.nameTextColor + + badgeLabel?.font = Style.badgeFont + badgeLabel?.textColor = Style.badgeTextColor + badgeLabel?.backgroundColor = Style.badgeColor + badgeLabel?.adjustsFontForContentSizeCategory = true + badgeLabel?.adjustsFontSizeToFitWidth = true + + dateLabel?.font = Style.dateFont + dateLabel?.textColor = Style.dateTextColor + + accessoryButton?.tintColor = Style.buttonTintColor + accessoryButton?.setImage(accessoryButtonImage, for: .normal) + accessoryButton?.addTarget(self, action: #selector(accessoryButtonTapped), for: .touchUpInside) + + replyButton?.tintColor = Style.reactionButtonTextColor + replyButton?.titleLabel?.font = Style.reactionButtonFont + replyButton?.titleLabel?.adjustsFontSizeToFitWidth = true + replyButton?.titleLabel?.adjustsFontForContentSizeCategory = true + replyButton?.setTitle(.reply, for: .normal) + replyButton?.setTitleColor(Style.reactionButtonTextColor, for: .normal) + replyButton?.setImage(Style.replyIconImage, for: .normal) + replyButton?.addTarget(self, action: #selector(replyButtonTapped), for: .touchUpInside) + replyButton?.flipInsetsForRightToLeftLayoutDirection() + replyButton?.adjustsImageSizeForAccessibilityContentSizeCategory = true + adjustImageAndTitleEdgeInsets(for: replyButton) + replyButton?.sizeToFit() + + likeButton?.tintColor = Style.reactionButtonTextColor + likeButton?.titleLabel?.font = Style.reactionButtonFont + likeButton?.titleLabel?.adjustsFontSizeToFitWidth = true + likeButton?.titleLabel?.adjustsFontForContentSizeCategory = true + likeButton?.setTitleColor(Style.reactionButtonTextColor, for: .normal) + likeButton?.addTarget(self, action: #selector(likeButtonTapped), for: .touchUpInside) + likeButton?.flipInsetsForRightToLeftLayoutDirection() + likeButton?.adjustsImageSizeForAccessibilityContentSizeCategory = true + adjustImageAndTitleEdgeInsets(for: likeButton) + updateLikeButton(liked: false, numberOfLikes: 0) + likeButton?.sizeToFit() + } + + private func adjustImageAndTitleEdgeInsets(for button: UIButton) { + guard let imageSize = button.imageView?.frame.size, let titleSize = button.titleLabel?.frame.size else { + return + } + + let spacing: CGFloat = 3 + button.titleEdgeInsets = .init(top: 0, left: -titleSize.width, bottom: -(imageSize.height + spacing), right: 0) + button.imageEdgeInsets = .init(top: -(titleSize.height + spacing), left: imageSize.width/2, bottom: 0, right: 0) + } + + /// Configures the avatar image view with the provided URL. + /// If the URL does not contain any image, the default placeholder image will be displayed. + /// - Parameter url: The URL containing the image. + func configureImage(with url: URL?) { + if let someURL = url, let gravatar = Gravatar(someURL) { + avatarImageView.downloadGravatar(gravatar, placeholder: Style.placeholderImage, animate: true) + return + } + + // handle non-gravatar images + avatarImageView.downloadImage(from: url, placeholderImage: Style.placeholderImage) + } + + /// Configures the avatar image view from Gravatar based on provided email. + /// If the Gravatar image for the provided email doesn't exist, the default placeholder image will be displayed. + /// - Parameter gravatarEmail: The email to be used for querying the Gravatar image. + func configureImageWithGravatarEmail(_ email: String?) { + guard let someEmail = email else { + return + } + + avatarImageView.downloadGravatarWithEmail(someEmail, placeholderImage: Style.placeholderImage) + } + + func updateContainerLeadingConstraint() { + containerStackLeadingConstraint?.constant = (indentationWidth * CGFloat(indentationLevel)) + defaultLeadingMargin + } + + /// 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. + /// - numberOfLikes: The number of likes to be displayed. + /// - animated: Whether the Like button state change should be animated or not. Defaults to false. + /// - completion: Completion block called once the animation is completed. Defaults to nil. + func updateLikeButton(liked: Bool, numberOfLikes: Int, animated: Bool = false, completion: (() -> Void)? = nil) { + guard !isLikeButtonAnimating else { + return + } + + isLiked = liked + likeCount = numberOfLikes + + let onAnimationComplete = { [weak self] in + guard let self = self else { + return + } + + self.likeButton.tintColor = liked ? Style.likedTintColor : Style.reactionButtonTextColor + self.likeButton.setImage(liked ? Style.likedIconImage : Style.unlikedIconImage, for: .normal) + self.likeButton.setTitle(self.likeButtonTitle, for: .normal) + self.adjustImageAndTitleEdgeInsets(for: self.likeButton) + self.likeButton.setTitleColor(liked ? Style.likedTintColor : Style.reactionButtonTextColor, for: .normal) + completion?() + } + + guard animated else { + onAnimationComplete() + return + } + + isLikeButtonAnimating = true + + if isLiked { + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + + animateLikeButton { + onAnimationComplete() + self.isLikeButtonAnimating = false + } + } + + /// Animates the Like button state change. + func animateLikeButton(completion: @escaping () -> Void) { + guard let buttonImageView = likeButton.imageView, + let overlayImage = Style.likedIconImage?.withTintColor(Style.likedTintColor) else { + completion() + return + } + + let overlayImageView = UIImageView(image: overlayImage) + overlayImageView.frame = likeButton.convert(buttonImageView.bounds, from: buttonImageView) + likeButton.addSubview(overlayImageView) + + let animation = isLiked ? overlayImageView.fadeInWithRotationAnimation : overlayImageView.fadeOutWithRotationAnimation + animation { _ in + overlayImageView.removeFromSuperview() + completion() + } + } + + // MARK: Content Rendering + + func resetRenderedContents() { + renderer = nil + contentContainerView.subviews.forEach { $0.removeFromSuperview() } + } + + func configureRendererIfNeeded(for comment: Comment, renderMethod: RenderMethod) { + // skip creating the renderer if the content does not change. + // this prevents the cell to jump multiple times due to consecutive reloadData calls. + // + // note that this doesn't apply for `.richContent` method. Always reset the textView instead + // of reusing it to prevent crash. Ref: http://git.io/Jtl2U + if let renderer = renderer, + renderer.matchesContent(from: comment), + renderMethod == .web { + return + } + + // clean out any pre-existing renderer just to be sure. + resetRenderedContents() + + var renderer: CommentContentRenderer = { + switch renderMethod { + case .web: + return WebCommentContentRenderer(comment: comment) + case .richContent: + let renderer = RichCommentContentRenderer(comment: comment) + renderer.richContentDelegate = self.richContentDelegate + return renderer + } + }() + renderer.delegate = self + self.renderer = renderer + self.renderMethod = renderMethod + + if renderMethod == .web { + // reset height constraint to handle cases where the new content requires the webview to shrink. + contentContainerHeightConstraint?.isActive = true + contentContainerHeightConstraint?.constant = 1 + } else { + contentContainerHeightConstraint?.isActive = false + } + + let contentView = renderer.render() + contentContainerView?.addSubview(contentView) + contentContainerView?.pinSubviewToAllEdges(contentView) + } + + // MARK: Button Actions + + @objc func accessoryButtonTapped() { + accessoryButtonAction?(accessoryButton) + } + + @objc func replyButtonTapped() { + replyButtonAction?() + } + + @objc func likeButtonTapped() { + ReachabilityUtils.onAvailableInternetConnectionDo { + updateLikeButton(liked: !isLiked, numberOfLikes: isLiked ? likeCount - 1 : likeCount + 1, animated: true) { + self.likeButtonAction?() + } + } + } +} + +// MARK: - Localization + +private extension String { + static let reply = NSLocalizedString("Reply", comment: "Reply to a comment.") + static let noLikes = NSLocalizedString("Like", comment: "Button title to Like a comment.") + static let singularLikeFormat = NSLocalizedString("%1$d Like", comment: "Singular button title to Like a comment. " + + "%1$d is a placeholder for the number of Likes.") + static let pluralLikesFormat = NSLocalizedString("%1$d Likes", comment: "Plural button title to Like a comment. " + + "%1$d is a placeholder for the number of Likes.") + + // pattern that detects empty HTML elements (including HTML comments within). + static let emptyElementRegexPattern = "<[a-z]+>(<!-- [a-zA-Z0-9\\/: \"{}\\-\\.,\\?=\\[\\]]+ -->)+<\\/[a-z]+>" +} diff --git a/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.xib b/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.xib new file mode 100644 index 000000000000..57d02ed088ff --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentContentTableViewCell.xib @@ -0,0 +1,233 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/> + <capability name="Image references" minToolsVersion="12.0"/> + <capability name="Named colors" minToolsVersion="9.0"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="340" id="KGk-i7-Jjw" customClass="CommentContentTableViewCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="320" height="340"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM"> + <rect key="frame" x="0.0" y="0.0" width="320" height="340"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="mNJ-fg-sKO" userLabel="Highlighted Bar View"> + <rect key="frame" x="0.0" y="0.0" width="5" height="340"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" constant="5" id="zU4-st-LVq"/> + </constraints> + </view> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="hcN-S7-sLG" userLabel="Container Stack View"> + <rect key="frame" x="16" y="0.0" width="288" height="309"/> + <subviews> + <view contentMode="scaleToFill" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" translatesAutoresizingMaskIntoConstraints="NO" id="f2E-yC-BJS" userLabel="Header View"> + <rect key="frame" x="0.0" y="0.0" width="288" height="119"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar" translatesAutoresizingMaskIntoConstraints="NO" id="9QY-3I-cxv" userLabel="Avatar Image View" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="18" width="38" height="38"/> + <constraints> + <constraint firstAttribute="width" secondItem="9QY-3I-cxv" secondAttribute="height" multiplier="1:1" id="3HU-89-TeJ"/> + <constraint firstAttribute="width" constant="38" id="Apb-Vu-nw6"/> + </constraints> + </imageView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="CzL-pe-Tnr" userLabel="Name Stack View"> + <rect key="frame" x="48" y="18" width="208" height="31"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="po6-3F-ppN" userLabel="Name Container View"> + <rect key="frame" x="0.0" y="0.0" width="208" height="14.5"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="761" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="HpE-B7-6wr" userLabel="Name Label"> + <rect key="frame" x="0.0" y="0.0" width="169.5" height="14.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <nil key="highlightedColor"/> + </label> + <label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" verticalCompressionResistancePriority="751" text="Badge" textAlignment="natural" lineBreakMode="characterWrap" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="hDo-cU-sWp" userLabel="Badge Label" customClass="BadgeLabel" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="174.5" y="-2" width="33.5" height="13.5"/> + <color key="backgroundColor" name="Blue50"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleCaption2"/> + <color key="textColor" name="White"/> + <nil key="highlightedColor"/> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="horizontalPadding"> + <real key="value" value="5"/> + </userDefinedRuntimeAttribute> + <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius"> + <real key="value" value="3"/> + </userDefinedRuntimeAttribute> + <userDefinedRuntimeAttribute type="number" keyPath="verticalPadding"> + <real key="value" value="1"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + </label> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="HpE-B7-6wr" firstAttribute="top" secondItem="po6-3F-ppN" secondAttribute="top" id="1mu-S2-Bod"/> + <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="hDo-cU-sWp" secondAttribute="trailing" id="Kgn-8h-iRl"/> + <constraint firstItem="HpE-B7-6wr" firstAttribute="leading" secondItem="po6-3F-ppN" secondAttribute="leading" id="a9I-ZO-CNF"/> + <constraint firstAttribute="bottom" secondItem="HpE-B7-6wr" secondAttribute="bottom" id="d2g-ej-XWq"/> + <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="HpE-B7-6wr" secondAttribute="trailing" id="p0y-Cp-XXb"/> + <constraint firstItem="hDo-cU-sWp" firstAttribute="firstBaseline" secondItem="HpE-B7-6wr" secondAttribute="firstBaseline" constant="-3" id="pzL-bf-kIJ"/> + <constraint firstItem="hDo-cU-sWp" firstAttribute="leading" secondItem="HpE-B7-6wr" secondAttribute="trailing" constant="5" id="zBO-4n-va3"/> + </constraints> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ghT-Xy-q8c" userLabel="Date Label"> + <rect key="frame" x="0.0" y="16.5" width="208" height="14.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" systemColor="secondaryLabelColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + <button hidden="YES" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="1G8-cc-t5d" userLabel="Accessory Button"> + <rect key="frame" x="256" y="15" width="44" height="44"/> + <constraints> + <constraint firstAttribute="width" secondItem="1G8-cc-t5d" secondAttribute="height" multiplier="1:1" id="1CB-OD-6k3"/> + <constraint firstAttribute="height" constant="44" id="L5a-rf-l5V"/> + </constraints> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="tintColor" systemColor="secondaryLabelColor"/> + <state key="normal"> + <imageReference key="image" image="square.and.arrow.up" catalog="system" symbolScale="large" renderingMode="template"/> + <preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="font" scale="large"> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + </preferredSymbolConfiguration> + </state> + </button> + </subviews> + <constraints> + <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="CzL-pe-Tnr" secondAttribute="bottom" constant="15" id="FLO-bi-cgb"/> + <constraint firstItem="CzL-pe-Tnr" firstAttribute="top" secondItem="9QY-3I-cxv" secondAttribute="top" id="Fs5-LK-eAC"/> + <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="CzL-pe-Tnr" secondAttribute="trailing" id="R3L-jf-zLP"/> + <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="9QY-3I-cxv" secondAttribute="bottom" priority="750" constant="15" id="S4y-cM-fX9"/> + <constraint firstItem="9QY-3I-cxv" firstAttribute="top" secondItem="f2E-yC-BJS" secondAttribute="top" constant="18" id="VRu-Tu-EzK"/> + <constraint firstItem="1G8-cc-t5d" firstAttribute="centerY" secondItem="9QY-3I-cxv" secondAttribute="centerY" id="iiu-dq-fba"/> + <constraint firstItem="1G8-cc-t5d" firstAttribute="leading" secondItem="CzL-pe-Tnr" secondAttribute="trailing" id="kMf-Ux-GI7"/> + <constraint firstItem="9QY-3I-cxv" firstAttribute="leading" secondItem="f2E-yC-BJS" secondAttribute="leading" id="mzW-Rh-t4b"/> + <constraint firstItem="CzL-pe-Tnr" firstAttribute="top" relation="greaterThanOrEqual" secondItem="f2E-yC-BJS" secondAttribute="top" constant="18" id="pAn-nJ-PTk"/> + <constraint firstItem="CzL-pe-Tnr" firstAttribute="leading" secondItem="9QY-3I-cxv" secondAttribute="trailing" constant="10" id="shs-JU-Qg8"/> + <constraint firstAttribute="trailing" secondItem="1G8-cc-t5d" secondAttribute="trailing" constant="-12" id="xTt-ug-Tgu"/> + </constraints> + </view> + <view contentMode="scaleToFill" verticalHuggingPriority="252" translatesAutoresizingMaskIntoConstraints="NO" id="M0y-BK-TEh" userLabel="Content Container View"> + <rect key="frame" x="0.0" y="119" width="288" height="0.0"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" id="t5W-a4-bo7"/> + </constraints> + </view> + <stackView opaque="NO" contentMode="scaleToFill" alignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="QT8-DO-J30" userLabel="Reaction Stack View"> + <rect key="frame" x="0.0" y="119" width="288" height="190"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="761" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="VoI-YI-Qgc" userLabel="Reply Button"> + <rect key="frame" x="0.0" y="75" width="184.5" height="40"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <color key="tintColor" systemColor="secondaryLabelColor"/> + <inset key="contentEdgeInsets" minX="0.0" minY="10" maxX="15" maxY="15"/> + <inset key="titleEdgeInsets" minX="2" minY="0.0" maxX="-2" maxY="0.0"/> + <state key="normal" title="Reply"> + <color key="titleColor" systemColor="secondaryLabelColor"/> + <imageReference key="image" image="icon-reader-comment-reply" symbolScale="default" renderingMode="template"/> + <preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="font" scale="default"> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + </preferredSymbolConfiguration> + </state> + </button> + <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="762" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="X2J-8b-R5F" userLabel="Like Button"> + <rect key="frame" x="184.5" y="74.5" width="53.5" height="41"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <color key="tintColor" systemColor="secondaryLabelColor"/> + <inset key="contentEdgeInsets" minX="0.0" minY="10" maxX="15" maxY="15"/> + <inset key="titleEdgeInsets" minX="2" minY="0.0" maxX="-2" maxY="0.0"/> + <state key="normal" title="Like"> + <color key="titleColor" systemColor="secondaryLabelColor"/> + <imageReference key="image" image="star" catalog="system" symbolScale="medium" renderingMode="template"/> + <preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="font" scale="small"> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + </preferredSymbolConfiguration> + </state> + </button> + <view contentMode="scaleToFill" horizontalHuggingPriority="1" horizontalCompressionResistancePriority="250" translatesAutoresizingMaskIntoConstraints="NO" id="8GH-U7-J7H" userLabel="Spacer View"> + <rect key="frame" x="238" y="70" width="50" height="50"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="width" constant="50" placeholder="YES" id="4wt-Z8-Xp5"/> + </constraints> + </view> + </subviews> + </stackView> + </subviews> + </stackView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="qId-Th-B9r"> + <rect key="frame" x="20" y="329" width="300" height="1"/> + <color key="backgroundColor" systemColor="systemGray5Color"/> + <constraints> + <constraint firstAttribute="height" constant="1" id="HnG-UL-I2f"/> + </constraints> + </view> + </subviews> + <constraints> + <constraint firstAttribute="trailing" secondItem="hcN-S7-sLG" secondAttribute="trailing" constant="16" id="2zy-oR-X5O"/> + <constraint firstAttribute="trailing" secondItem="qId-Th-B9r" secondAttribute="trailing" id="3d4-sp-Uyb"/> + <constraint firstItem="qId-Th-B9r" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="20" id="I4L-Vv-SIn"/> + <constraint firstItem="mNJ-fg-sKO" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" id="II8-F0-CBs"/> + <constraint firstItem="mNJ-fg-sKO" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" id="eId-Od-5wj"/> + <constraint firstItem="hcN-S7-sLG" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" id="esQ-oB-yxJ"/> + <constraint firstAttribute="bottom" secondItem="mNJ-fg-sKO" secondAttribute="bottom" id="izD-cW-YFx"/> + <constraint firstItem="qId-Th-B9r" firstAttribute="top" secondItem="hcN-S7-sLG" secondAttribute="bottom" constant="20" id="pdj-JT-7h5"/> + <constraint firstAttribute="bottom" secondItem="qId-Th-B9r" secondAttribute="bottom" constant="10" id="qZq-8s-UAU"/> + <constraint firstItem="hcN-S7-sLG" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="uFL-PF-ffo"/> + </constraints> + </tableViewCellContentView> + <viewLayoutGuide key="safeArea" id="njF-e1-oar"/> + <connections> + <outlet property="accessoryButton" destination="1G8-cc-t5d" id="kLS-Ag-hAG"/> + <outlet property="avatarImageView" destination="9QY-3I-cxv" id="lbp-Hv-zRm"/> + <outlet property="badgeLabel" destination="hDo-cU-sWp" id="gVe-4c-TlR"/> + <outlet property="containerStackLeadingConstraint" destination="uFL-PF-ffo" id="6Ah-H9-d0b"/> + <outlet property="containerStackTrailingConstraint" destination="2zy-oR-X5O" id="dDR-lB-Nvs"/> + <outlet property="containerStackView" destination="hcN-S7-sLG" id="k9D-6a-BmR"/> + <outlet property="contentContainerHeightConstraint" destination="t5W-a4-bo7" id="2ox-vW-Kuh"/> + <outlet property="contentContainerView" destination="M0y-BK-TEh" id="bC5-Hn-XRQ"/> + <outlet property="dateLabel" destination="ghT-Xy-q8c" id="ffa-qV-3tn"/> + <outlet property="highlightBarView" destination="mNJ-fg-sKO" id="fjf-gA-HoE"/> + <outlet property="likeButton" destination="X2J-8b-R5F" id="6w2-io-GXb"/> + <outlet property="nameLabel" destination="HpE-B7-6wr" id="MLa-k9-IlC"/> + <outlet property="replyButton" destination="VoI-YI-Qgc" id="Z9J-Tp-bur"/> + <outlet property="separatorView" destination="qId-Th-B9r" id="VEc-Bb-Zi6"/> + </connections> + <point key="canvasLocation" x="153.62318840579712" y="329.46428571428572"/> + </tableViewCell> + </objects> + <resources> + <image name="gravatar" width="85" height="85"/> + <image name="icon-reader-comment-reply" width="13" height="12"/> + <image name="square.and.arrow.up" catalog="system" width="115" height="128"/> + <image name="star" catalog="system" width="128" height="116"/> + <namedColor name="Blue50"> + <color red="0.023529411764705882" green="0.45882352941176469" blue="0.7686274509803922" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </namedColor> + <namedColor name="White"> + <color red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </namedColor> + <systemColor name="secondaryLabelColor"> + <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + <systemColor name="systemGray5Color"> + <color red="0.89803921568627454" green="0.89803921568627454" blue="0.91764705882352937" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Comments/CommentDetailInfoViewController.swift b/WordPress/Classes/ViewRelated/Comments/CommentDetailInfoViewController.swift new file mode 100644 index 000000000000..8bb5e4faf4a0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentDetailInfoViewController.swift @@ -0,0 +1,123 @@ +import Foundation +import WordPressUI +import UIKit + +protocol CommentDetailInfoView: AnyObject { + func showAuthorPage(url: URL) +} + +final class CommentDetailInfoViewController: UIViewController { + private static let cellReuseIdentifier = "infoCell" + private let tableView: UITableView = { + $0.translatesAutoresizingMaskIntoConstraints = false + return $0 + }(UITableView()) + + private let viewModel: CommentDetailInfoViewModelType + + init(viewModel: CommentDetailInfoViewModelType) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureTableView() + addTableViewConstraints() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + setPreferredContentSize() + } + + private func configureTableView() { + tableView.dataSource = self + tableView.delegate = self + view.addSubview(tableView) + } + + private func addTableViewConstraints() { + NSLayoutConstraint.activate([ + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: tableView.trailingAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), + view.bottomAnchor.constraint(equalTo: tableView.bottomAnchor) + ]) + } + + private func setPreferredContentSize() { + tableView.layoutIfNeeded() + preferredContentSize = tableView.contentSize + } +} + +// MARK: - CommentDetailInfoView +extension CommentDetailInfoViewController: CommentDetailInfoView { + func showAuthorPage(url: URL) { + let viewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url, source: "comment_detail") + let navigationControllerToPresent = UINavigationController(rootViewController: viewController) + present(navigationControllerToPresent, animated: true, completion: nil) + } +} + +// MARK: - UITableViewDatasource +extension CommentDetailInfoViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.userDetails.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: Self.cellReuseIdentifier) + ?? .init(style: .subtitle, reuseIdentifier: Self.cellReuseIdentifier) + + let info = viewModel.userDetails[indexPath.item] + + cell.selectionStyle = .none + cell.tintColor = .primary + + cell.textLabel?.font = WPStyleGuide.fontForTextStyle(.subheadline) + cell.textLabel?.textColor = .textSubtle + cell.textLabel?.text = info.title + + cell.detailTextLabel?.font = WPStyleGuide.fontForTextStyle(.body) + cell.detailTextLabel?.textColor = .text + cell.detailTextLabel?.numberOfLines = 0 + cell.detailTextLabel?.text = info.description.isEmpty ? " " : info.description // prevent the cell from collapsing due to empty label text. + + return cell + } +} + +// MARK: - UITableViewDelegate +extension CommentDetailInfoViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + viewModel.didSelectItem(at: indexPath.item) + } +} + +// MARK: - DrawerPresentable +extension CommentDetailInfoViewController: DrawerPresentable { + var collapsedHeight: DrawerHeight { + .intrinsicHeight + } + + var allowsUserTransition: Bool { + false + } + + var compactWidth: DrawerWidth { + .maxWidth + } +} + +// MARK: - ChildDrawerPositionable +extension CommentDetailInfoViewController: ChildDrawerPositionable { + var preferredDrawerPosition: DrawerPosition { + .collapsed + } +} diff --git a/WordPress/Classes/ViewRelated/Comments/CommentDetailInfoViewModel.swift b/WordPress/Classes/ViewRelated/Comments/CommentDetailInfoViewModel.swift new file mode 100644 index 000000000000..4f858a063a58 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentDetailInfoViewModel.swift @@ -0,0 +1,78 @@ +import Foundation + +protocol CommentDetailInfoViewModelInputs { + func didSelectItem(at index: Int) +} + +protocol CommentDetailInfoViewModelOutputs { + var userDetails: [CommentDetailInfoUserDetails] { get } +} + +typealias CommentDetailInfoViewModelType = CommentDetailInfoViewModelInputs & CommentDetailInfoViewModelOutputs + +struct CommentDetailInfoUserDetails { + let title: String + let description: String +} + +final class CommentDetailInfoViewModel: CommentDetailInfoViewModelType { + private let url: URL? + private let urlToDisplay: String? + private let email: String? + private let ipAddress: String? + private let isAdmin: Bool + + let userDetails: [CommentDetailInfoUserDetails] + + weak var view: CommentDetailInfoView? + + init(url: URL?, urlToDisplay: String?, email: String?, ipAddress: String?, isAdmin: Bool) { + self.url = url + self.urlToDisplay = urlToDisplay + self.email = email + self.ipAddress = ipAddress + self.isAdmin = isAdmin + + var details: [CommentDetailInfoUserDetails] = [] + // Author URL is publicly visible, but let's hide the row if it's empty or contains invalid URL. + if let urlToDisplay = urlToDisplay, !urlToDisplay.isEmpty { + details.append(CommentDetailInfoUserDetails(title: Strings.addressLabelText, description: urlToDisplay)) + } + + // Email address and IP address fields are only visible for Editor or Administrator roles, i.e. when user is allowed to moderate the comment. + if isAdmin { + // If the comment is submitted anonymously, the email field may be empty. In this case, let's hide it. Ref: https://git.io/JzKIt + if let email = email, !email.isEmpty { + details.append(CommentDetailInfoUserDetails(title: Strings.emailAddressLabelText, description: email)) + } + + if let ipAddress = ipAddress { + details.append(CommentDetailInfoUserDetails(title: Strings.ipAddressLabelText, description: ipAddress)) + } + } + self.userDetails = details + } + + func didSelectItem(at index: Int) { + guard userDetails[index].title == Strings.addressLabelText, let url = url else { + return + } + + view?.showAuthorPage(url: url) + } + + private enum Strings { + static let addressLabelText = 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." + ) + static let ipAddressLabelText = NSLocalizedString( + "IP address", + comment: "Describes the IP address section in the comment detail screen." + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Comments/CommentDetailViewController.swift b/WordPress/Classes/ViewRelated/Comments/CommentDetailViewController.swift new file mode 100644 index 000000000000..2555daadf2e6 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentDetailViewController.swift @@ -0,0 +1,1246 @@ +import UIKit +import CoreData + +// Notification sent when a Comment is permanently deleted so the Notifications list (NotificationsViewController) is immediately updated. +extension NSNotification.Name { + static let NotificationCommentDeletedNotification = NSNotification.Name(rawValue: "NotificationCommentDeletedNotification") +} +let userInfoCommentIdKey = "commentID" + +@objc protocol CommentDetailsDelegate: AnyObject { + func nextCommentSelected() +} + +class CommentDetailViewController: UIViewController, NoResultsViewHost { + + // MARK: Properties + + private let containerStackView = UIStackView() + private let tableView = UITableView(frame: .zero, style: .plain) + + // Reply properties + private var replyTextView: ReplyTextView? + private var suggestionsTableView: SuggestionsTableView? + private var keyboardManager: KeyboardDismissHelper? + private var dismissKeyboardTapGesture = UITapGestureRecognizer() + + @objc weak var commentDelegate: CommentDetailsDelegate? + private weak var notificationDelegate: CommentDetailsNotificationDelegate? + + private var comment: Comment + private var isLastInList = true + private var managedObjectContext: NSManagedObjectContext + private var sections = [SectionType] () + private var rows = [RowType]() + private var commentStatus: CommentStatusType? { + didSet { + switch commentStatus { + case .pending: + unapproveComment() + case .approved: + approveComment() + case .spam: + spamComment() + case .unapproved: + trashComment() + default: + break + } + } + } + private var notification: Notification? + + private var isNotificationComment: Bool { + notification != nil + } + + private var viewIsVisible: Bool { + return navigationController?.visibleViewController == self + } + + private var siteID: NSNumber? { + return comment.blog?.dotComID ?? notification?.metaSiteID + } + + private var replyID: Int32 { + return comment.replyID + } + + private var isCommentReplied: Bool { + replyID > 0 + } + + // MARK: Views + + private var headerCell = CommentHeaderTableViewCell() + + private lazy var replyIndicatorCell: UITableViewCell = { + let cell = UITableViewCell() + + // display the replied icon using attributed string instead of using the default image view. + // this is because the default image view is displayed beyond the separator line (within the layout margin area). + let iconAttachment = NSTextAttachment() + iconAttachment.image = Style.ReplyIndicator.iconImage + + let attributedString = NSMutableAttributedString() + attributedString.append(.init(attachment: iconAttachment, attributes: Style.ReplyIndicator.textAttributes)) + attributedString.append(.init(string: " " + .replyIndicatorLabelText, attributes: Style.ReplyIndicator.textAttributes)) + + // reverse the attributed strings in RTL direction. + if view.effectiveUserInterfaceLayoutDirection == .rightToLeft { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.baseWritingDirection = .rightToLeft + attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: .init(location: 0, length: attributedString.length)) + } + + cell.textLabel?.attributedText = attributedString + cell.textLabel?.numberOfLines = 0 + + // setup constraints for textLabel to match the spacing specified in the design. + if let textLabel = cell.textLabel { + textLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + textLabel.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor), + textLabel.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor), + textLabel.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: Constants.replyIndicatorVerticalSpacing), + textLabel.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -Constants.replyIndicatorVerticalSpacing) + ]) + } + + return cell + }() + + private lazy var moderationCell: UITableViewCell = { + return $0 + }(UITableViewCell()) + + private lazy var deleteButtonCell: BorderedButtonTableViewCell = { + let cell = BorderedButtonTableViewCell() + cell.configure(buttonTitle: .deleteButtonText, + titleFont: WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular), + normalColor: Constants.deleteButtonNormalColor, + highlightedColor: Constants.deleteButtonHighlightColor, + buttonInsets: Constants.deleteButtonInsets) + cell.delegate = self + return cell + }() + + private lazy var trashButtonCell: BorderedButtonTableViewCell = { + let cell = BorderedButtonTableViewCell() + cell.configure(buttonTitle: .trashButtonText, + titleFont: WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular), + normalColor: Constants.deleteButtonNormalColor, + highlightedColor: Constants.trashButtonHighlightColor, + borderColor: .clear, + buttonInsets: Constants.deleteButtonInsets, + backgroundColor: Constants.trashButtonBackgroundColor) + cell.delegate = self + return cell + }() + + + private lazy var commentService: CommentService = { + return .init(coreDataStack: ContextManager.shared) + }() + + /// Ideally, this property should be configurable as one of the initialization parameters (to make this testable). + /// However, since this class is still initialized in Objective-C files, it cannot declare `ContentCoordinator` as the init parameter, unless the protocol + /// is `@objc`-ified. Let's move this to the init parameter once the caller has been converted to Swift. + private lazy var contentCoordinator: ContentCoordinator = { + return DefaultContentCoordinator(controller: self, context: managedObjectContext) + }() + + // Sometimes the parent information of a comment reply notification is in the meta block. + private var notificationParentComment: Comment? { + guard let parentID = notification?.metaParentID, + let siteID = notification?.metaSiteID, + let blog = Blog.lookup(withID: siteID, in: managedObjectContext), + let parentComment = blog.comment(withID: parentID) else { + return nil + } + + return parentComment + } + + private var parentComment: Comment? { + guard comment.hasParentComment() else { + return nil + } + + if let blog = comment.blog { + return blog.comment(withID: comment.parentID) + + } + + if let post = comment.post as? ReaderPost { + return post.comment(withID: comment.parentID) + } + + return nil + } + + // transparent navigation bar style with visual blur effect. + private lazy var blurredBarAppearance: UINavigationBarAppearance = { + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundEffect = UIBlurEffect(style: .systemThinMaterial) + return appearance + }() + + /// opaque navigation bar style. + /// this is used for iOS 14 and below, since scrollEdgeAppearance only applies for large title bars, except on iOS 15 where it applies for all navbars. + private lazy var opaqueBarAppearance: UINavigationBarAppearance = { + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + return appearance + }() + + /// Convenience property that keeps track of whether the content has scrolled. + private var isContentScrolled: Bool = false { + didSet { + if isContentScrolled == oldValue { + return + } + + // show blurred navigation bar when content is scrolled, or opaque style when the scroll position is at the top. + updateNavigationBarAppearance(isBlurred: isContentScrolled) + } + } + + // MARK: Nav Bar Buttons + + private(set) lazy var editBarButtonItem: UIBarButtonItem = { + let button = UIBarButtonItem(barButtonSystemItem: .edit, + target: self, + action: #selector(editButtonTapped)) + button.accessibilityLabel = NSLocalizedString("Edit comment", comment: "Accessibility label for button to edit a comment from a notification") + return button + }() + + private(set) lazy var shareBarButtonItem: UIBarButtonItem = { + let button = UIBarButtonItem( + image: comment.allowsModeration() + ? UIImage(systemName: Style.Content.ellipsisIconImageName) + : UIImage(systemName: Style.Content.shareIconImageName), + style: .plain, + target: self, + action: #selector(shareCommentURL) + ) + button.accessibilityLabel = NSLocalizedString("Share comment", comment: "Accessibility label for button to share a comment from a notification") + return button + }() + + // MARK: Initialization + + @objc init(comment: Comment, + isLastInList: Bool, + managedObjectContext: NSManagedObjectContext = ContextManager.sharedInstance().mainContext) { + self.comment = comment + self.commentStatus = CommentStatusType.typeForStatus(comment.status) + self.isLastInList = isLastInList + self.managedObjectContext = managedObjectContext + super.init(nibName: nil, bundle: nil) + } + + init(comment: Comment, + notification: Notification, + notificationDelegate: CommentDetailsNotificationDelegate?, + managedObjectContext: NSManagedObjectContext = ContextManager.sharedInstance().mainContext) { + self.comment = comment + self.commentStatus = CommentStatusType.typeForStatus(comment.status) + self.notification = notification + self.notificationDelegate = notificationDelegate + self.managedObjectContext = managedObjectContext + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: View lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + configureView() + configureReplyView() + setupKeyboardManager() + configureSuggestionsView() + configureNavigationBar() + configureTable() + configureSections() + refreshCommentReplyIfNeeded() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + keyboardManager?.startListeningToKeyboardNotifications() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + keyboardManager?.stopListeningToKeyboardNotifications() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + // when an orientation change is triggered, recalculate the content cell's height. + guard let contentRowIndex = rows.firstIndex(of: .content) else { + return + } + + tableView.reloadRows(at: [.init(row: contentRowIndex, section: .zero)], with: .fade) + } + + // Update the Comment being displayed. + @objc func displayComment(_ comment: Comment, isLastInList: Bool = true) { + self.comment = comment + self.isLastInList = isLastInList + replyTextView?.placeholder = String(format: .replyPlaceholderFormat, comment.authorForDisplay()) + refreshData() + refreshCommentReplyIfNeeded() + } + + // Update the Notification Comment being displayed. + func refreshView(comment: Comment, notification: Notification) { + hideNoResults() + self.notification = notification + displayComment(comment) + } + + // Show an empty view with the given values. + func showNoResultsView(title: String, subtitle: String? = nil, imageName: String? = nil, accessoryView: UIView? = nil) { + hideNoResults() + configureAndDisplayNoResults(on: tableView, + title: title, + subtitle: subtitle, + image: imageName, + accessoryView: accessoryView) + } + +} + +// MARK: - Private Helpers + +private extension CommentDetailViewController { + + typealias Style = WPStyleGuide.CommentDetail + + enum SectionType: Equatable { + case content([RowType]) + case moderation([RowType]) + } + + enum RowType: Equatable { + case header + case content + case replyIndicator + case status(status: CommentStatusType) + case deleteComment + } + + struct Constants { + 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) + static let deleteButtonNormalColor = UIColor(light: .error, dark: .muriel(name: .red, .shade40)) + static let deleteButtonHighlightColor: UIColor = .white + static let trashButtonBackgroundColor = UIColor.quaternarySystemFill + static let trashButtonHighlightColor: UIColor = UIColor.tertiarySystemFill + static let notificationDetailSource = ["source": "notification_details"] + } + + /// Convenience computed variable for an inset setting that hides a cell's separator by pushing it off the edge of the screen. + /// This needs to be computed because the frame size changes on orientation change. + /// NOTE: There's no need to flip the insets for RTL language, since it will be automatically applied. + var insetsForHiddenCellSeparator: UIEdgeInsets { + return .init(top: 0, left: -tableView.separatorInset.left, bottom: 0, right: tableView.frame.size.width) + } + + /// returns the height of the navigation bar + the status bar. + var topBarHeight: CGFloat { + return (view.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0.0) + + (navigationController?.navigationBar.frame.height ?? 0.0) + } + + /// determines the threshold for the content offset on whether the content has scrolled. + /// for translucent navigation bars, the content view spans behind the status bar and navigation bar so we'd have to account for that. + var contentScrollThreshold: CGFloat { + (navigationController?.navigationBar.isTranslucent ?? false) ? -topBarHeight : 0 + } + + func configureView() { + containerStackView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(containerStackView) + containerStackView.axis = .vertical + containerStackView.addArrangedSubview(tableView) + view.pinSubviewToAllEdges(containerStackView) + } + + func configureNavigationBar() { + if #available(iOS 15, *) { + // In iOS 15, to apply visual blur only when content is scrolled, keep the scrollEdgeAppearance unchanged as it applies to ALL navigation bars. + navigationItem.standardAppearance = blurredBarAppearance + } else { + // For iOS 14 and below, scrollEdgeAppearance only affects large title navigation bars. Therefore we need to manually detect if the content + // has been scrolled and change the appearance accordingly. + updateNavigationBarAppearance() + } + + navigationController?.navigationBar.isTranslucent = true + configureNavBarButton() + } + + /// Updates the navigation bar style based on the `isBlurred` boolean parameter. The intent is to show a visual blur effect when the content is scrolled, + /// but reverts to opaque style when the scroll position is at the top. This method may be called multiple times since it's triggered by the `didSet` + /// property observer on the `isContentScrolled` property. + func updateNavigationBarAppearance(isBlurred: Bool = false) { + navigationItem.standardAppearance = isBlurred ? blurredBarAppearance : opaqueBarAppearance + } + + func configureNavBarButton() { + var barItems: [UIBarButtonItem] = [] + barItems.append(shareBarButtonItem) + if comment.allowsModeration() { + barItems.append(editBarButtonItem) + } + navigationItem.setRightBarButtonItems(barItems, animated: false) + } + + func configureTable() { + tableView.delegate = self + tableView.dataSource = self + tableView.separatorInsetReference = .fromAutomaticInsets + + // get rid of the separator line for the last cell. + tableView.tableFooterView = UIView(frame: .init(x: 0, y: 0, width: tableView.frame.size.width, height: Constants.tableBottomMargin)) + + + // assign 20pt leading inset to the table view, as per the design. + tableView.directionalLayoutMargins = .init(top: tableView.directionalLayoutMargins.top, + leading: Constants.tableHorizontalInset, + bottom: tableView.directionalLayoutMargins.bottom, + trailing: Constants.tableHorizontalInset) + + tableView.register(CommentContentTableViewCell.defaultNib, forCellReuseIdentifier: CommentContentTableViewCell.defaultReuseID) + } + + func configureContentRows() -> [RowType] { + // Header and content cells should always be visible, regardless of user roles. + var rows: [RowType] = [.header, .content] + + if isCommentReplied { + rows.append(.replyIndicator) + } + + return rows + } + + func configureModeratationRows() -> [RowType] { + var rows: [RowType] = [] + rows.append(.status(status: .approved)) + rows.append(.status(status: .pending)) + rows.append(.status(status: .spam)) + + rows.append(.deleteComment) + + return rows + } + + func configureSections() { + var sections: [SectionType] = [] + + sections.append(.content(configureContentRows())) + if comment.allowsModeration() { + sections.append(.moderation(configureModeratationRows())) + } + self.sections = sections + } + + /// Performs a complete refresh on the table and the row configuration, since some rows may be hidden due to changes to the Comment object. + /// Use this method instead of directly calling the `reloadData` on the table view property. + func refreshData() { + configureNavBarButton() + configureSections() + tableView.reloadData() + } + + /// Checks if the index path is positioned before the delete button cell. + func shouldHideCellSeparator(for indexPath: IndexPath) -> Bool { + switch sections[indexPath.section] { + case .content: + return false + case .moderation(let rows): + guard let deleteCellIndex = rows.firstIndex(of: .deleteComment) else { + return false + } + + return indexPath.row == deleteCellIndex - 1 + } + } + + // MARK: Cell configuration + + func configureHeaderCell() { + // if the comment is a reply, show the author of the parent comment. + if let parentComment = self.parentComment ?? notificationParentComment { + 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.configure(for: .post, subtitle: comment.titleForDisplay()) + } + + func configureContentCell(_ cell: CommentContentTableViewCell, comment: Comment) { + cell.configure(with: comment) { [weak self] _ in + self?.tableView.performBatchUpdates({}) + } + + cell.contentLinkTapAction = { [weak self] url in + // open all tapped links in web view. + // TODO: Explore reusing URL handling logic from ReaderDetailCoordinator. + self?.openWebView(for: url) + } + + cell.accessoryButtonType = .info + cell.accessoryButtonAction = { [weak self] senderView in + self?.presentUserInfoSheet(senderView) + } + + cell.likeButtonAction = { [weak self] in + self?.toggleCommentLike() + } + + cell.replyButtonAction = { [weak self] in + self?.showReplyView() + } + } + + func configuredStatusCell(for status: CommentStatusType) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: .moderationCellIdentifier) ?? .init(style: .subtitle, reuseIdentifier: .moderationCellIdentifier) + + cell.selectionStyle = .none + cell.tintColor = Style.tintColor + + cell.detailTextLabel?.font = Style.textFont + cell.detailTextLabel?.textColor = Style.textColor + cell.detailTextLabel?.numberOfLines = 0 + cell.detailTextLabel?.text = status.title + + cell.accessoryView = status == commentStatus ? UIImageView(image: .gridicon(.checkmark)) : nil + + return cell + } + + // MARK: Data Sync + + func refreshCommentReplyIfNeeded() { + guard let siteID = siteID?.intValue else { + return + } + + commentService.getLatestReplyID(for: Int(comment.commentID), siteID: siteID) { [weak self] replyID in + guard let self = self else { + return + } + + // only perform Core Data updates when the replyID differs. + guard replyID != self.comment.replyID else { + return + } + + let context = self.comment.managedObjectContext ?? ContextManager.sharedInstance().mainContext + self.comment.replyID = Int32(replyID) + ContextManager.sharedInstance().saveContextAndWait(context) + + self.updateReplyIndicator() + + } failure: { error in + DDLogError("Failed fetching latest comment reply ID: \(String(describing: error))") + } + + } + + func updateReplyIndicator() { + + // If there is a reply, add reply indicator if it is not being shown. + if replyID > 0 && !rows.contains(.replyIndicator) { + // Update the rows first so replyIndicator is present in `rows`. + configureSections() + guard let replyIndicatorRow = rows.firstIndex(of: .replyIndicator) else { + tableView.reloadData() + return + } + + tableView.insertRows(at: [IndexPath(row: replyIndicatorRow, section: .zero)], with: .fade) + return + } + + // If there is not a reply, remove reply indicator if it is being shown. + if replyID == 0 && rows.contains(.replyIndicator) { + // Get the reply indicator row first before it is removed via `configureRows`. + guard let replyIndicatorRow = rows.firstIndex(of: .replyIndicator) else { + return + } + + configureSections() + tableView.deleteRows(at: [IndexPath(row: replyIndicatorRow, section: .zero)], with: .fade) + } + } + + // MARK: Actions and navigations + + // Shows the comment thread with the Notification comment highlighted. + func navigateToNotificationComment() { + if let blog = comment.blog, + !blog.supports(.wpComRESTAPI) { + openWebView(for: comment.commentURL()) + return + } + + guard let siteID = siteID else { + return + } + + // Empty Back Button + navigationItem.backBarButtonItem = UIBarButtonItem(title: String(), style: .plain, target: nil, action: nil) + + try? contentCoordinator.displayCommentsWithPostId(NSNumber(value: comment.postID), + siteID: siteID, + commentID: NSNumber(value: comment.commentID), + source: .commentNotification) + } + + + + // Shows the comment thread with the parent comment highlighted. + func navigateToParentComment() { + guard let parentComment = parentComment, + let siteID = siteID, + let blog = comment.blog, + blog.supports(.wpComRESTAPI) else { + let parentCommentURL = URL(string: parentComment?.link ?? "") + openWebView(for: parentCommentURL) + return + } + + try? contentCoordinator.displayCommentsWithPostId(NSNumber(value: comment.postID), + siteID: siteID, + commentID: NSNumber(value: parentComment.commentID), + source: .mySiteComment) + } + + func navigateToReplyComment() { + guard let siteID = siteID, + isCommentReplied else { + return + } + + try? contentCoordinator.displayCommentsWithPostId(NSNumber(value: comment.postID), + siteID: siteID, + commentID: NSNumber(value: replyID), + source: isNotificationComment ? .commentNotification : .mySiteComment) + } + + func navigateToPost() { + guard let blog = comment.blog, + let siteID = siteID, + blog.supports(.wpComRESTAPI) else { + let postPermalinkURL = URL(string: comment.post?.permaLink ?? "") + openWebView(for: postPermalinkURL) + return + } + + let readerViewController = ReaderDetailViewController.controllerWithPostID(NSNumber(value: comment.postID), siteID: siteID, isFeed: false) + navigationController?.pushFullscreenViewController(readerViewController, animated: true) + } + + func openWebView(for url: URL?) { + guard let url = url else { + DDLogError("\(Self.classNameWithoutNamespaces()): Attempted to open an invalid URL [\(url?.absoluteString ?? "")]") + return + } + + let viewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url, source: "comment_detail") + let navigationControllerToPresent = UINavigationController(rootViewController: viewController) + + present(navigationControllerToPresent, animated: true, completion: nil) + } + + @objc func editButtonTapped() { + let editCommentTableViewController = EditCommentTableViewController(comment: comment, completion: { [weak self] comment, commentChanged in + guard commentChanged else { + return + } + + self?.comment = comment + self?.refreshData() + self?.updateComment() + }) + + CommentAnalytics.trackCommentEditorOpened(comment: comment) + let navigationControllerToPresent = UINavigationController(rootViewController: editCommentTableViewController) + navigationControllerToPresent.modalPresentationStyle = .fullScreen + present(navigationControllerToPresent, animated: true) + } + + func deleteButtonTapped() { + let commentID = comment.commentID + deleteComment() { [weak self] success in + if success { + self?.postNotificationCommentDeleted(commentID) + // Dismiss the view since the Comment no longer exists. + self?.navigationController?.popViewController(animated: true) + } + } + } + + func updateComment() { + // Regardless of success or failure track the user's intent to save a change. + CommentAnalytics.trackCommentEdited(comment: comment) + + commentService.uploadComment(comment, + success: { [weak self] in + // The comment might have changed its approval status + self?.refreshData() + }, + failure: { [weak self] error in + let message = NSLocalizedString("There has been an unexpected error while editing your comment", + comment: "Error displayed if a comment fails to get updated") + self?.displayNotice(title: message) + }) + } + + func toggleCommentLike() { + guard let siteID = siteID else { + refreshData() // revert the like button state. + return + } + + if comment.isLiked { + isNotificationComment ? WPAppAnalytics.track(.notificationsCommentUnliked, withBlogID: notification?.metaSiteID) : + CommentAnalytics.trackCommentUnLiked(comment: comment) + } else { + isNotificationComment ? WPAppAnalytics.track(.notificationsCommentLiked, withBlogID: notification?.metaSiteID) : + CommentAnalytics.trackCommentLiked(comment: comment) + } + + commentService.toggleLikeStatus(for: comment, siteID: siteID, success: {}, failure: { _ in + self.refreshData() // revert the like button state. + }) + } + + @objc func shareCommentURL(_ barButtonItem: UIBarButtonItem) { + guard let commentURL = comment.commentURL() else { + return + } + + // track share intent. + WPAnalytics.track(.siteCommentsCommentShared) + + let activityViewController = UIActivityViewController(activityItems: [commentURL as Any], applicationActivities: nil) + activityViewController.popoverPresentationController?.barButtonItem = barButtonItem + present(activityViewController, animated: true, completion: nil) + } + + func presentUserInfoSheet(_ senderView: UIView) { + let viewModel = CommentDetailInfoViewModel( + url: comment.authorURL(), + urlToDisplay: comment.authorUrlForDisplay(), + email: comment.author_email, + ipAddress: comment.author_ip, + isAdmin: comment.allowsModeration() + ) + let viewController = CommentDetailInfoViewController(viewModel: viewModel) + viewModel.view = viewController + let bottomSheet = BottomSheetViewController(childViewController: viewController, customHeaderSpacing: 0) + bottomSheet.show(from: self) + } +} + +// MARK: - Strings + +private extension String { + // MARK: Constants + static let replyIndicatorCellIdentifier = "replyIndicatorCell" + static let textCellIdentifier = "textCell" + static let moderationCellIdentifier = "moderationCell" + + // MARK: Localization + 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 deleteButtonText = NSLocalizedString("Delete Permanently", comment: "Title for button on the comment details page that deletes the comment when tapped.") + static let trashButtonText = NSLocalizedString("Move to Trash", comment: "Title for button on the comment details page that moves the comment to trash when tapped.") +} + +private extension CommentStatusType { + var title: String? { + switch self { + case .pending: + return NSLocalizedString("Pending", comment: "Button title for Pending comment state.") + case .approved: + return NSLocalizedString("Approved", comment: "Button title for Approved comment state.") + case .spam: + return NSLocalizedString("Spam", comment: "Button title for Spam comment state.") + default: + return nil + } + } +} + +// MARK: - Comment Moderation Actions + +private extension CommentDetailViewController { + func unapproveComment() { + isNotificationComment ? WPAppAnalytics.track(.notificationsCommentUnapproved, + withProperties: Constants.notificationDetailSource, + withBlogID: notification?.metaSiteID) : + CommentAnalytics.trackCommentUnApproved(comment: comment) + + commentService.unapproveComment(comment, success: { [weak self] in + self?.showActionableNotice(title: ModerationMessages.pendingSuccess) + self?.refreshData() + }, failure: { [weak self] error in + self?.displayNotice(title: ModerationMessages.pendingFail) + self?.commentStatus = CommentStatusType.typeForStatus(self?.comment.status) + }) + } + + func approveComment() { + isNotificationComment ? WPAppAnalytics.track(.notificationsCommentApproved, + withProperties: Constants.notificationDetailSource, + withBlogID: notification?.metaSiteID) : + CommentAnalytics.trackCommentApproved(comment: comment) + + commentService.approve(comment, success: { [weak self] in + self?.showActionableNotice(title: ModerationMessages.approveSuccess) + self?.refreshData() + }, failure: { [weak self] error in + self?.displayNotice(title: ModerationMessages.approveFail) + self?.commentStatus = CommentStatusType.typeForStatus(self?.comment.status) + }) + } + + func spamComment() { + isNotificationComment ? WPAppAnalytics.track(.notificationsCommentFlaggedAsSpam, withBlogID: notification?.metaSiteID) : + CommentAnalytics.trackCommentSpammed(comment: comment) + + commentService.spamComment(comment, success: { [weak self] in + self?.showActionableNotice(title: ModerationMessages.spamSuccess) + self?.refreshData() + }, failure: { [weak self] error in + self?.displayNotice(title: ModerationMessages.spamFail) + self?.commentStatus = CommentStatusType.typeForStatus(self?.comment.status) + }) + } + + func trashComment() { + isNotificationComment ? WPAppAnalytics.track(.notificationsCommentTrashed, withBlogID: notification?.metaSiteID) : + CommentAnalytics.trackCommentTrashed(comment: comment) + trashButtonCell.isLoading = true + + commentService.trashComment(comment, success: { [weak self] in + self?.trashButtonCell.isLoading = false + self?.showActionableNotice(title: ModerationMessages.trashSuccess) + self?.refreshData() + }, failure: { [weak self] error in + self?.trashButtonCell.isLoading = false + self?.displayNotice(title: ModerationMessages.trashFail) + self?.commentStatus = CommentStatusType.typeForStatus(self?.comment.status) + }) + } + + func deleteComment(completion: ((Bool) -> Void)? = nil) { + CommentAnalytics.trackCommentTrashed(comment: comment) + deleteButtonCell.isLoading = true + + commentService.delete(comment, success: { [weak self] in + self?.showActionableNotice(title: ModerationMessages.deleteSuccess) + completion?(true) + }, failure: { [weak self] error in + self?.deleteButtonCell.isLoading = false + self?.displayNotice(title: ModerationMessages.deleteFail) + completion?(false) + }) + } + + func notifyDelegateCommentModerated() { + notificationDelegate?.commentWasModerated(for: notification) + } + + func postNotificationCommentDeleted(_ commentID: Int32) { + NotificationCenter.default.post(name: .NotificationCommentDeletedNotification, + object: nil, + userInfo: [userInfoCommentIdKey: commentID]) + } + + func showActionableNotice(title: String) { + guard !isNotificationComment else { + return + } + + guard viewIsVisible, !isLastInList else { + displayNotice(title: title) + return + } + + // Dismiss any old notices to avoid stacked Next notices. + dismissNotice() + + displayActionableNotice(title: title, + style: NormalNoticeStyle(showNextArrow: true), + actionTitle: ModerationMessages.next, + actionHandler: { [weak self] _ in + self?.showNextComment() + }) + } + + func showNextComment() { + guard viewIsVisible else { + return + } + + WPAnalytics.track(.commentSnackbarNext) + commentDelegate?.nextCommentSelected() + } + + struct ModerationMessages { + static let pendingSuccess = NSLocalizedString("Comment set to pending.", comment: "Message displayed when pending a comment succeeds.") + static let pendingFail = NSLocalizedString("Error setting comment to pending.", comment: "Message displayed when pending a comment fails.") + static let approveSuccess = NSLocalizedString("Comment approved.", comment: "Message displayed when approving a comment succeeds.") + static let approveFail = NSLocalizedString("Error approving comment.", comment: "Message displayed when approving a comment fails.") + static let spamSuccess = NSLocalizedString("Comment marked as spam.", comment: "Message displayed when spamming a comment succeeds.") + static let spamFail = NSLocalizedString("Error marking comment as spam.", comment: "Message displayed when spamming a comment fails.") + static let trashSuccess = NSLocalizedString("Comment moved to trash.", comment: "Message displayed when trashing a comment succeeds.") + static let trashFail = NSLocalizedString("Error moving comment to trash.", comment: "Message displayed when trashing a comment fails.") + static let deleteSuccess = NSLocalizedString("Comment deleted.", comment: "Message displayed when deleting a comment succeeds.") + static let deleteFail = NSLocalizedString("Error deleting comment.", comment: "Message displayed when deleting a comment fails.") + static let next = NSLocalizedString("Next", comment: "Next action on comment moderation snackbar.") + } + +} + +// MARK: - UITableView Methods + +extension CommentDetailViewController: UITableViewDelegate, UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch sections[section] { + case .content(let rows): + return rows.count + case .moderation(let rows): + return rows.count + } + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: UITableViewCell = { + let rows: [RowType] + switch sections[indexPath.section] { + case .content(let sectionRows), .moderation(let sectionRows): + rows = sectionRows + } + + switch rows[indexPath.row] { + case .header: + configureHeaderCell() + return headerCell + + case .content: + guard let cell = tableView.dequeueReusableCell(withIdentifier: CommentContentTableViewCell.defaultReuseID) as? CommentContentTableViewCell else { + return .init() + } + + configureContentCell(cell, comment: comment) + return cell + + case .replyIndicator: + return replyIndicatorCell + + case .deleteComment: + if comment.deleteWillBePermanent() { + return deleteButtonCell + } else { + return trashButtonCell + } + + case .status(let statusType): + return configuredStatusCell(for: statusType) + } + }() + + return cell + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .content: + return nil + case .moderation: + return NSLocalizedString("STATUS", comment: "Section title for the moderation section of the comment details screen.") + } + } + + func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { + let header = view as! UITableViewHeaderFooterView + header.textLabel?.font = Style.tertiaryTextFont + header.textLabel?.textColor = UIColor.secondaryLabel + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + // Hide cell separator if it's positioned before the delete button cell. + cell.separatorInset = self.shouldHideCellSeparator(for: indexPath) ? self.insetsForHiddenCellSeparator : .zero + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + switch sections[indexPath.section] { + case .content(let rows): + switch rows[indexPath.row] { + case .header: + if isNotificationComment { + navigateToNotificationComment() + } else { + comment.hasParentComment() ? navigateToParentComment() : navigateToPost() + } + case .replyIndicator: + navigateToReplyComment() + default: + break + } + + case .moderation(let rows): + switch rows[indexPath.row] { + case .status(let statusType): + if commentStatus == statusType { + break + } + commentStatus = statusType + notifyDelegateCommentModerated() + + guard let cell = tableView.cellForRow(at: indexPath) else { + return + } + let activityIndicator = UIActivityIndicatorView(style: .medium) + cell.accessoryView = activityIndicator + activityIndicator.startAnimating() + default: + break + } + } + + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + // keep track of whether the content has scrolled or not. This is used to update the navigation bar style in iOS 14 and below. + // in iOS 15, we don't need to do this since it's been handled automatically; hence the early return. + if #available(iOS 15, *) { + return + } + + isContentScrolled = scrollView.contentOffset.y > contentScrollThreshold + } + +} + +// MARK: - Reply Handling + +private extension CommentDetailViewController { + + func configureReplyView() { + let replyView = ReplyTextView(width: view.frame.width) + + 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 + self?.createReply(content: content) + } + + replyView.isHidden = true + containerStackView.addArrangedSubview(replyView) + replyTextView = replyView + } + + func showReplyView() { + guard replyTextView?.isFirstResponder == false else { + return + } + + replyTextView?.isHidden = false + replyTextView?.becomeFirstResponder() + addDismissKeyboardTapGesture() + } + + func setupKeyboardManager() { + guard let replyTextView = replyTextView, + let bottomLayoutConstraint = view.constraints.first(where: { $0.firstAttribute == .bottom }) else { + return + } + + keyboardManager = KeyboardDismissHelper(parentView: view, + scrollView: tableView, + dismissableControl: replyTextView, + bottomLayoutConstraint: bottomLayoutConstraint) + } + + func addDismissKeyboardTapGesture() { + dismissKeyboardTapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + tableView.addGestureRecognizer(dismissKeyboardTapGesture) + } + + @objc func dismissKeyboard() { + view.endEditing(true) + tableView.removeGestureRecognizer(dismissKeyboardTapGesture) + } + + @objc func createReply(content: String) { + isNotificationComment ? WPAppAnalytics.track(.notificationsCommentRepliedTo) : + CommentAnalytics.trackCommentRepliedTo(comment: comment) + + // If there is no Blog, try with the Post. + guard comment.blog != nil else { + createPostCommentReply(content: content) + return + } + + commentService.createReply(for: comment, content: content) { reply in + self.commentService.uploadComment(reply, success: { [weak self] in + self?.displayReplyNotice(success: true) + self?.refreshCommentReplyIfNeeded() + }, failure: { [weak self] error in + DDLogError("Failed uploading comment reply: \(String(describing: error))") + self?.displayReplyNotice(success: false) + }) + } + } + + func createPostCommentReply(content: String) { + guard let post = comment.post as? ReaderPost else { + return + } + + commentService.replyToHierarchicalComment(withID: NSNumber(value: comment.commentID), + post: post, + content: content, + success: { [weak self] in + self?.displayReplyNotice(success: true) + self?.refreshCommentReplyIfNeeded() + }, failure: { [weak self] error in + DDLogError("Failed creating post comment reply: \(String(describing: error))") + self?.displayReplyNotice(success: false) + }) + } + + func displayReplyNotice(success: Bool) { + let message = success ? ReplyMessages.successMessage : ReplyMessages.failureMessage + displayNotice(title: message) + } + + func configureSuggestionsView() { + guard shouldShowSuggestions, + let siteID = siteID, + let replyTextView = replyTextView else { + return + } + + let suggestionsView = SuggestionsTableView(siteID: siteID, suggestionType: .mention, delegate: self) + suggestionsView.translatesAutoresizingMaskIntoConstraints = false + suggestionsView.prominentSuggestionsIds = SuggestionsTableView.prominentSuggestions( + fromPostAuthorId: comment.post?.authorID, + commentAuthorId: NSNumber(value: comment.authorID), + defaultAccountId: try? WPAccount.lookupDefaultWordPressComAccount(in: self.managedObjectContext)?.userID + ) + view.addSubview(suggestionsView) + + NSLayoutConstraint.activate([ + suggestionsView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + suggestionsView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + suggestionsView.topAnchor.constraint(equalTo: view.topAnchor), + suggestionsView.bottomAnchor.constraint(equalTo: replyTextView.topAnchor) + ]) + + suggestionsTableView = suggestionsView + } + + var shouldShowSuggestions: Bool { + guard let siteID = siteID, + let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { + return false + } + + return SuggestionService.shared.shouldShowSuggestions(for: blog) + } + + struct ReplyMessages { + static let successMessage = NSLocalizedString("Reply Sent!", comment: "The app successfully sent a comment") + static let failureMessage = NSLocalizedString("There has been an unexpected error while sending your reply", comment: "Reply Failure Message") + } + +} + +// MARK: - ReplyTextViewDelegate + +extension CommentDetailViewController: ReplyTextViewDelegate { + + func textView(_ textView: UITextView, didTypeWord word: String) { + suggestionsTableView?.showSuggestions(forWord: word) + } + + func replyTextView(_ replyTextView: ReplyTextView, willEnterFullScreen controller: FullScreenCommentReplyViewController) { + let lastSearchText = suggestionsTableView?.viewModel.searchText + suggestionsTableView?.hideSuggestions() + + if let siteID = siteID { + controller.enableSuggestions(with: siteID, prominentSuggestionsIds: suggestionsTableView?.prominentSuggestionsIds, searchText: lastSearchText) + } + } + + func replyTextView(_ replyTextView: ReplyTextView, didExitFullScreen lastSearchText: String?) { + guard let lastSearchText = lastSearchText, !lastSearchText.isEmpty else { + return + } + suggestionsTableView?.viewModel.reloadData() + suggestionsTableView?.showSuggestions(forWord: lastSearchText) + } + +} + +// MARK: - SuggestionsTableViewDelegate + +extension CommentDetailViewController: SuggestionsTableViewDelegate { + + func suggestionsTableView(_ suggestionsTableView: SuggestionsTableView, didSelectSuggestion suggestion: String?, forSearchText text: String) { + replyTextView?.replaceTextAtCaret(text as NSString?, withText: suggestion) + suggestionsTableView.hideSuggestions() + } + +} + +// MARK: - BorderedButtonTableViewCellDelegate + +extension CommentDetailViewController: BorderedButtonTableViewCellDelegate { + + func buttonTapped() { + if comment.deleteWillBePermanent() { + deleteButtonTapped() + } else { + commentStatus = .unapproved + } + } + +} diff --git a/WordPress/Classes/ViewRelated/Comments/CommentHeaderTableViewCell.swift b/WordPress/Classes/ViewRelated/Comments/CommentHeaderTableViewCell.swift new file mode 100644 index 000000000000..554924b8b4f8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentHeaderTableViewCell.swift @@ -0,0 +1,80 @@ +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() { + super.init(style: .subtitle, reuseIdentifier: Self.defaultReuseID) + configureStyle() + } + + required init?(coder: NSCoder) { + 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 + + private func configureStyle() { + accessoryType = .disclosureIndicator + + textLabel?.font = Style.font + textLabel?.textColor = Style.textColor + textLabel?.numberOfLines = 2 + + detailTextLabel?.font = Style.detailFont + detailTextLabel?.textColor = Style.detailTextColor + detailTextLabel?.numberOfLines = 1 + } + +} + +// 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/Comments/CommentModerationBar.swift b/WordPress/Classes/ViewRelated/Comments/CommentModerationBar.swift new file mode 100644 index 000000000000..31171537eb6e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentModerationBar.swift @@ -0,0 +1,327 @@ +import UIKit + +protocol CommentModerationBarDelegate: AnyObject { + func statusChangedTo(_ commentStatus: CommentStatusType) +} + +private typealias Style = WPStyleGuide.CommentDetail.ModerationBar + +class CommentModerationBar: UIView { + + // MARK: - Properties + + @IBOutlet private weak var contentView: UIView! + + @IBOutlet private weak var pendingButton: UIButton! + @IBOutlet private weak var approvedButton: UIButton! + @IBOutlet private weak var spamButton: UIButton! + @IBOutlet private weak var trashButton: UIButton! + + @IBOutlet private weak var buttonStackViewLeadingConstraint: NSLayoutConstraint! + @IBOutlet private weak var buttonStackViewTrailingConstraint: NSLayoutConstraint! + + @IBOutlet private weak var firstDivider: UIView! + @IBOutlet private weak var secondDivider: UIView! + @IBOutlet private weak var thirdDivider: UIView! + + private var compactHorizontalPadding: CGFloat = 4 + private let iPadPaddingMultiplier: CGFloat = 0.33 + private let iPhonePaddingMultiplier: CGFloat = 0.15 + + weak var delegate: CommentModerationBarDelegate? + + var commentStatus: CommentStatusType? { + didSet { + guard oldValue != commentStatus else { + return + } + toggleButtonForStatus(oldValue) + toggleButtonForStatus(commentStatus) + } + } + + // MARK: - Init + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + + guard let view = loadViewFromNib() else { + DDLogError("CommentModerationBar: Failed loading view from nib.") + return + } + + // Save initial constraint value to use on device rotation. + compactHorizontalPadding = buttonStackViewLeadingConstraint.constant + + view.frame = self.bounds + configureView() + self.addSubview(view) + + NotificationCenter.default.addObserver(self, + selector: #selector(configureStackViewWidth), + name: UIDevice.orientationDidChangeNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + // readjust stack view width when horizontal size class changes. + if let previousTraitCollection = previousTraitCollection, + previousTraitCollection.horizontalSizeClass != traitCollection.horizontalSizeClass { + configureStackViewWidth() + } + } + +} + +// MARK: - Private Extension + +private extension CommentModerationBar { + + // MARK: - Configure + + func loadViewFromNib() -> UIView? { + let nib = UINib(nibName: "\(CommentModerationBar.self)", bundle: Bundle.main) + return nib.instantiate(withOwner: self, options: nil).first as? UIView + } + + func configureView() { + configureBackground() + configureDividers() + configureButtons() + configureStackViewWidth() + } + + func configureBackground() { + contentView.backgroundColor = Style.barBackgroundColor + contentView.layer.cornerRadius = Style.cornerRadius + } + + func configureDividers() { + firstDivider.configureAsDivider() + secondDivider.configureAsDivider() + thirdDivider.configureAsDivider() + } + + func configureButtons() { + pendingButton.configureFor(.pending) + approvedButton.configureFor(.approved) + spamButton.configureFor(.spam) + trashButton.configureFor(.trash) + } + + @objc func configureStackViewWidth() { + // On devices with a lot of horizontal space, increase the buttonStackView margins + // so the buttons are not severely stretched out. Specifically: + // - iPad landscape + // - Non split view iPhone landscape + let horizontalPadding: CGFloat = { + if WPDeviceIdentification.isiPad() && + UIDevice.current.orientation.isLandscape && + !isInMultitasking && + traitCollection.horizontalSizeClass == .regular { + return bounds.width * iPadPaddingMultiplier + } + + if traitCollection.horizontalSizeClass == .compact && + traitCollection.verticalSizeClass == .compact { + return bounds.width * iPhonePaddingMultiplier + } + + return compactHorizontalPadding + }() + + buttonStackViewLeadingConstraint.constant = horizontalPadding + buttonStackViewTrailingConstraint.constant = horizontalPadding + } + + func toggleButtonForStatus(_ status: CommentStatusType?) { + guard let status = status else { + return + } + + switch status { + case .pending: + togglePending() + case .approved: + toggleApproved() + case .unapproved: + toggleTrash() + case .spam: + toggleSpam() + default: + break + } + } + + func togglePending() { + pendingButton.toggleState() + firstDivider.hideDivider(pendingButton.isSelected) + } + + func toggleApproved() { + approvedButton.toggleState() + firstDivider.hideDivider(approvedButton.isSelected) + secondDivider.hideDivider(approvedButton.isSelected) + } + + func toggleSpam() { + spamButton.toggleState() + secondDivider.hideDivider(spamButton.isSelected) + thirdDivider.hideDivider(spamButton.isSelected) + } + + func toggleTrash() { + trashButton.toggleState() + thirdDivider.hideDivider(trashButton.isSelected) + } + + // MARK: - Button Actions + + @IBAction func pendingTapped() { + guard !pendingButton.isSelected else { + return + } + + updateStatusTo(.pending) + } + + @IBAction func approvedTapped() { + guard !approvedButton.isSelected else { + return + } + + updateStatusTo(.approved) + } + + @IBAction func spamTapped() { + guard !spamButton.isSelected else { + return + } + + updateStatusTo(.spam) + } + + @IBAction func trashTapped() { + guard !trashButton.isSelected else { + return + } + + updateStatusTo(.unapproved) + } + + func updateStatusTo(_ status: CommentStatusType) { + ReachabilityUtils.onAvailableInternetConnectionDo { + commentStatus = status + delegate?.statusChangedTo(status) + } + } + +} + +// MARK: - Moderation Button Types + +enum ModerationButtonType { + case pending + case approved + case spam + case trash + + var label: String { + switch self { + case .pending: + return NSLocalizedString("Pending", comment: "Button title for Pending comment state.") + case .approved: + return NSLocalizedString("Approved", comment: "Button title for Approved comment state.") + case .spam: + return NSLocalizedString("Spam", comment: "Button title for Spam comment state.") + case .trash: + return NSLocalizedString("Trash", comment: "Button title for Trash comment state.") + } + } + + var defaultIcon: UIImage? { + return Style.defaultImageFor(self) + } + + var selectedIcon: UIImage? { + return Style.selectedImageFor(self) + } +} + +// MARK: - UIButton Extension + +private extension UIButton { + + func toggleState() { + isSelected.toggle() + configureState() + } + + func configureState() { + if isSelected { + backgroundColor = Style.buttonSelectedBackgroundColor + layer.shadowColor = Style.buttonSelectedShadowColor + } else { + backgroundColor = Style.buttonDefaultBackgroundColor + layer.shadowColor = Style.buttonDefaultShadowColor + } + } + + func configureFor(_ button: ModerationButtonType) { + setTitle(button.label, for: UIControl.State()) + setImage(button.defaultIcon, for: UIControl.State()) + setImage(button.selectedIcon, for: .selected) + + commonConfigure() + } + + func commonConfigure() { + setTitleColor(Style.buttonDefaultTitleColor, for: UIControl.State()) + setTitleColor(Style.buttonSelectedTitleColor, for: .selected) + + layer.cornerRadius = Style.cornerRadius + layer.shadowOffset = Style.buttonShadowOffset + layer.shadowOpacity = Style.buttonShadowOpacity + layer.shadowRadius = Style.buttonShadowRadius + + isExclusiveTouch = true + + verticallyAlignImageAndText() + flipInsetsForRightToLeftLayoutDirection() + configureState() + } + +} + +// MARK: - UIView Extension + +private extension UIView { + + func configureAsDivider() { + hideDivider(false) + + if let existingConstraint = constraint(for: .width, withRelation: .equal) { + existingConstraint.constant = .hairlineBorderWidth + } + } + + func hideDivider(_ hidden: Bool) { + backgroundColor = hidden ? Style.dividerHiddenColor : Style.dividerColor + } + + /// Detects if the current view is displayed in multitasking context. + var isInMultitasking: Bool { + guard let window = window else { + return false + } + + return window.frame.width != window.screen.bounds.width + } + +} diff --git a/WordPress/Classes/ViewRelated/Comments/CommentModerationBar.xib b/WordPress/Classes/ViewRelated/Comments/CommentModerationBar.xib new file mode 100644 index 000000000000..cad1dcadf62e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentModerationBar.xib @@ -0,0 +1,181 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19162" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19144"/> + <capability name="Image references" minToolsVersion="12.0"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="CommentModerationBar" customModule="WordPress" customModuleProvider="target"> + <connections> + <outlet property="approvedButton" destination="LN1-7X-UXx" id="7pK-0f-GwO"/> + <outlet property="buttonStackViewLeadingConstraint" destination="zch-9z-raG" id="mVf-v5-lsa"/> + <outlet property="buttonStackViewTrailingConstraint" destination="aYU-Kb-BmX" id="SY8-T8-tH8"/> + <outlet property="contentView" destination="fad-ek-phg" id="hH2-yh-b2Q"/> + <outlet property="firstDivider" destination="hDr-cL-ID4" id="C4l-gw-gmt"/> + <outlet property="pendingButton" destination="2wQ-QH-nFG" id="fDp-PC-lKd"/> + <outlet property="secondDivider" destination="FN5-XD-9q2" id="aYi-f6-Lhj"/> + <outlet property="spamButton" destination="amj-pE-B3g" id="PdL-iS-0X2"/> + <outlet property="thirdDivider" destination="FJo-JA-CxT" id="Tts-NM-oCD"/> + <outlet property="trashButton" destination="Wss-Fs-Ja4" id="Lmy-GK-01S"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="iN0-l3-epB"> + <rect key="frame" x="0.0" y="0.0" width="414" height="83"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fad-ek-phg" userLabel="Content View"> + <rect key="frame" x="0.0" y="0.0" width="414" height="63"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="gwt-S6-iFM" userLabel="Divider Stack View"> + <rect key="frame" x="4" y="9" width="406" height="45"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="7f8-jD-YJu" userLabel="Spacer"> + <rect key="frame" x="0.0" y="0.0" width="1" height="45"/> + <viewLayoutGuide key="safeArea" id="dY1-Oo-Pki"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" constant="1" id="Gwc-JP-ez1"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="hDr-cL-ID4" userLabel="First Divider"> + <rect key="frame" x="101.5" y="0.0" width="1" height="45"/> + <viewLayoutGuide key="safeArea" id="L22-49-ute"/> + <color key="backgroundColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" constant="1" id="2ZI-sY-eMs"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="FN5-XD-9q2" userLabel="Second Divider"> + <rect key="frame" x="202.5" y="0.0" width="1" height="45"/> + <viewLayoutGuide key="safeArea" id="gqa-s3-Sh0"/> + <color key="backgroundColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" constant="1" id="Q9A-qW-yjr"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="FJo-JA-CxT" userLabel="Third Divider"> + <rect key="frame" x="304" y="0.0" width="1" height="45"/> + <viewLayoutGuide key="safeArea" id="Pw0-Rc-zvI"/> + <color key="backgroundColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" constant="1" id="uRU-KX-m19"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="NlS-jo-IAA" userLabel="Spacer"> + <rect key="frame" x="405" y="0.0" width="1" height="45"/> + <viewLayoutGuide key="safeArea" id="fBa-Mj-mfh"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" constant="1" id="fZ3-v1-IfO"/> + </constraints> + </view> + </subviews> + <constraints> + <constraint firstItem="FN5-XD-9q2" firstAttribute="height" secondItem="hDr-cL-ID4" secondAttribute="height" id="ELx-OJ-EQq"/> + <constraint firstItem="FJo-JA-CxT" firstAttribute="height" secondItem="hDr-cL-ID4" secondAttribute="height" id="rjW-WM-2pl"/> + </constraints> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" distribution="fillEqually" translatesAutoresizingMaskIntoConstraints="NO" id="au2-Qc-dqY" userLabel="Button Stack View"> + <rect key="frame" x="4" y="4" width="406" height="55"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2wQ-QH-nFG"> + <rect key="frame" x="0.0" y="0.0" width="101.5" height="55"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleCaption2"/> + <color key="tintColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal" title="Pending"> + <color key="titleColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <imageReference key="image" image="tray" catalog="system" symbolScale="large"/> + </state> + <connections> + <action selector="pendingTapped" destination="-1" eventType="touchUpInside" id="abh-IY-oNX"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="LN1-7X-UXx"> + <rect key="frame" x="101.5" y="0.0" width="101.5" height="55"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleCaption2"/> + <color key="tintColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal" title="Approved"> + <color key="titleColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <imageReference key="image" image="checkmark.circle" catalog="system" symbolScale="large"/> + </state> + <connections> + <action selector="approvedTapped" destination="-1" eventType="touchUpInside" id="B2l-HC-xk7"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="amj-pE-B3g"> + <rect key="frame" x="203" y="0.0" width="101.5" height="55"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleCaption2"/> + <color key="tintColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal" title="Spam"> + <color key="titleColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <imageReference key="image" image="exclamationmark.octagon" catalog="system" symbolScale="large"/> + </state> + <connections> + <action selector="spamTapped" destination="-1" eventType="touchUpInside" id="kJJ-NH-Awv"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Wss-Fs-Ja4"> + <rect key="frame" x="304.5" y="0.0" width="101.5" height="55"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleCaption2"/> + <color key="tintColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal" title="Trash"> + <color key="titleColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <imageReference key="image" image="trash" catalog="system" symbolScale="large"/> + </state> + <connections> + <action selector="trashTapped" destination="-1" eventType="touchUpInside" id="6fP-BE-5rs"/> + </connections> + </button> + </subviews> + </stackView> + </subviews> + <color key="backgroundColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="bottom" secondItem="au2-Qc-dqY" secondAttribute="bottom" constant="4" id="408-DG-9QK"/> + <constraint firstItem="gwt-S6-iFM" firstAttribute="width" secondItem="au2-Qc-dqY" secondAttribute="width" id="5ce-k4-say"/> + <constraint firstItem="gwt-S6-iFM" firstAttribute="trailing" secondItem="au2-Qc-dqY" secondAttribute="trailing" id="5nO-sm-GFn"/> + <constraint firstItem="gwt-S6-iFM" firstAttribute="height" secondItem="au2-Qc-dqY" secondAttribute="height" constant="-10" id="XqC-B8-Z1K"/> + <constraint firstAttribute="trailing" secondItem="au2-Qc-dqY" secondAttribute="trailing" constant="4" id="aYU-Kb-BmX"/> + <constraint firstItem="gwt-S6-iFM" firstAttribute="leading" secondItem="au2-Qc-dqY" secondAttribute="leading" id="ehD-0z-ptY"/> + <constraint firstItem="au2-Qc-dqY" firstAttribute="top" secondItem="fad-ek-phg" secondAttribute="top" constant="4" id="f8r-28-sxU"/> + <constraint firstItem="au2-Qc-dqY" firstAttribute="centerX" secondItem="fad-ek-phg" secondAttribute="centerX" id="oPi-KA-gGJ"/> + <constraint firstItem="gwt-S6-iFM" firstAttribute="centerY" secondItem="au2-Qc-dqY" secondAttribute="centerY" id="oTK-D0-x7I"/> + <constraint firstItem="au2-Qc-dqY" firstAttribute="leading" secondItem="fad-ek-phg" secondAttribute="leading" constant="4" id="zch-9z-raG"/> + </constraints> + <variation key="widthClass=regular"> + <mask key="constraints"> + <exclude reference="aYU-Kb-BmX"/> + </mask> + </variation> + </view> + </subviews> + <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="bottom" secondItem="fad-ek-phg" secondAttribute="bottom" constant="20" id="DUL-7E-nb8"/> + <constraint firstItem="fad-ek-phg" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" id="Rym-gJ-1VU"/> + <constraint firstAttribute="trailing" secondItem="fad-ek-phg" secondAttribute="trailing" id="YQ9-qW-sxc"/> + <constraint firstItem="fad-ek-phg" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="cfm-Ts-2j4"/> + </constraints> + <nil key="simulatedTopBarMetrics"/> + <nil key="simulatedBottomBarMetrics"/> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <point key="canvasLocation" x="131.8840579710145" y="-130.24553571428572"/> + </view> + </objects> + <resources> + <image name="checkmark.circle" catalog="system" width="128" height="121"/> + <image name="exclamationmark.octagon" catalog="system" width="128" height="112"/> + <image name="trash" catalog="system" width="121" height="128"/> + <image name="tray" catalog="system" width="128" height="88"/> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Comments/CommentViewController.h b/WordPress/Classes/ViewRelated/Comments/CommentViewController.h deleted file mode 100644 index ec6752fb7e25..000000000000 --- a/WordPress/Classes/ViewRelated/Comments/CommentViewController.h +++ /dev/null @@ -1,9 +0,0 @@ -#import <UIKit/UIKit.h> - -@class Comment; - -@interface CommentViewController : UIViewController - -@property (nonatomic, strong) Comment *comment; - -@end diff --git a/WordPress/Classes/ViewRelated/Comments/CommentViewController.m b/WordPress/Classes/ViewRelated/Comments/CommentViewController.m deleted file mode 100644 index a9ee3d167280..000000000000 --- a/WordPress/Classes/ViewRelated/Comments/CommentViewController.m +++ /dev/null @@ -1,721 +0,0 @@ -#import "CommentViewController.h" -#import "CommentService.h" -#import "ContextManager.h" -#import "WordPress-Swift.h" -#import "Comment.h" -#import "BasePost.h" -#import "SVProgressHUD+Dismiss.h" -#import "EditCommentViewController.h" -#import "PostService.h" -#import "BlogService.h" -#import "SuggestionsTableView.h" -#import "SuggestionService.h" -#import <WordPressUI/WordPressUI.h> - - - -#pragma mark ========================================================================================== -#pragma mark Constants -#pragma mark ========================================================================================== - -static NSInteger const CommentsDetailsNumberOfSections = 1; -static NSInteger const CommentsDetailsHiddenRowNumber = -1; - -typedef NS_ENUM(NSUInteger, CommentsDetailsRow) { - CommentsDetailsRowHeader = 0, - CommentsDetailsRowText = 1, - CommentsDetailsRowActions = 2, - CommentsDetailsRowCount = 3 // Should always be the last element -}; - - -#pragma mark ========================================================================================== -#pragma mark CommentViewController -#pragma mark ========================================================================================== - -@interface CommentViewController () <UITableViewDataSource, UITableViewDelegate, ReplyTextViewDelegate, SuggestionsTableViewDelegate> - -@property (nonatomic, strong) UITableView *tableView; -@property (nonatomic, strong) ReplyTextView *replyTextView; -@property (nonatomic, strong) SuggestionsTableView *suggestionsTableView; -@property (nonatomic, strong) NSLayoutConstraint *bottomLayoutConstraint; -@property (nonatomic, strong) KeyboardDismissHelper *keyboardManager; - -@property (nonatomic, strong) NSDictionary *reuseIdentifiersMap; -@property (nonatomic, assign) NSUInteger numberOfRows; -@property (nonatomic, assign) NSUInteger rowNumberForHeader; -@property (nonatomic, assign) NSUInteger rowNumberForComment; -@property (nonatomic, assign) NSUInteger rowNumberForActions; - -@property (nonatomic, strong) NSCache *estimatedRowHeights; - -@end - -@implementation CommentViewController - -- (void)loadView -{ - [super loadView]; - - UIGestureRecognizer *tapRecognizer = [[UITapGestureRecognizer alloc] - initWithTarget:self - action:@selector(dismissKeyboardIfNeeded:)]; - tapRecognizer.cancelsTouchesInView = NO; - - UITableView *tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain]; - tableView.translatesAutoresizingMaskIntoConstraints = NO; - tableView.cellLayoutMarginsFollowReadableWidth = YES; - tableView.separatorStyle = UITableViewCellSeparatorStyleNone; - tableView.delegate = self; - tableView.dataSource = self; - [tableView addGestureRecognizer:tapRecognizer]; - [self.view addSubview:tableView]; - - self.tableView = tableView; - - [WPStyleGuide configureColorsForView:self.view andTableView:tableView]; - - // Register Cell Nibs - NSArray *cellClassNames = @[ - NSStringFromClass([NoteBlockHeaderTableViewCell class]), - NSStringFromClass([NoteBlockCommentTableViewCell class]), - NSStringFromClass([NoteBlockActionsTableViewCell class]) - ]; - - for (NSString *cellClassName in cellClassNames) { - Class cellClass = NSClassFromString(cellClassName); - NSString *className = [cellClass classNameWithoutNamespaces]; - UINib *tableViewCellNib = [UINib nibWithNibName:className bundle:[NSBundle mainBundle]]; - - [self.tableView registerNib:tableViewCellNib forCellReuseIdentifier:[cellClass reuseIdentifier]]; - } - - [self attachSuggestionsTableViewIfNeeded]; - [self attachReplyView]; - [self setupAutolayoutConstraints]; - [self setupKeyboardManager]; - - self.estimatedRowHeights = [[NSCache alloc] init]; -} - -- (void)attachSuggestionsTableViewIfNeeded -{ - if (![self shouldAttachSuggestionsTableView]) { - return; - } - - self.suggestionsTableView = [SuggestionsTableView new]; - self.suggestionsTableView.siteID = self.comment.blog.dotComID; - self.suggestionsTableView.suggestionsDelegate = self; - [self.suggestionsTableView setTranslatesAutoresizingMaskIntoConstraints:NO]; - [self.view addSubview:self.suggestionsTableView]; -} - -- (void)attachReplyView -{ - __typeof(self) __weak weakSelf = self; - - ReplyTextView *replyTextView = [[ReplyTextView alloc] initWithWidth:CGRectGetWidth(self.view.frame)]; - replyTextView.placeholder = NSLocalizedString(@"Write a reply…", @"Placeholder text for inline compose view"); - replyTextView.onReply = ^(NSString *content) { - [weakSelf sendReplyWithNewContent:content]; - }; - replyTextView.delegate = self; - self.replyTextView = replyTextView; - - [self.view addSubview:self.replyTextView]; -} - -- (void)setupAutolayoutConstraints -{ - NSMutableDictionary *views = [@{@"tableView": self.tableView} mutableCopy]; - [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[tableView]|" - options:0 - metrics:nil - views:views]]; - self.bottomLayoutConstraint = [self.view.bottomAnchor constraintEqualToAnchor:self.replyTextView.bottomAnchor]; - self.bottomLayoutConstraint.active = YES; - - [NSLayoutConstraint activateConstraints:@[ - [self.tableView.topAnchor constraintEqualToAnchor:self.view.topAnchor], - [self.replyTextView.topAnchor constraintEqualToAnchor:self.tableView.bottomAnchor], - [self.replyTextView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], - [self.replyTextView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], - ]]; - - if ([self shouldAttachSuggestionsTableView]) { - // Pin the suggestions view left and right edges to the super view edges - NSDictionary *views = @{@"suggestionsview": self.suggestionsTableView }; - [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[suggestionsview]|" - options:0 - metrics:nil - views:views]]; - - // Pin the suggestions view top to the super view top - [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[suggestionsview]" - options:0 - metrics:nil - views:views]]; - - // Pin the suggestions view bottom to the top of the reply box - [self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.suggestionsTableView - attribute:NSLayoutAttributeBottom - relatedBy:NSLayoutRelationEqual - toItem:self.replyTextView - attribute:NSLayoutAttributeTop - multiplier:1 - constant:0]]; - } -} - -- (void)setupKeyboardManager -{ - self.keyboardManager = [[KeyboardDismissHelper alloc] initWithParentView:self.view - scrollView:self.tableView - dismissableControl:self.replyTextView - bottomLayoutConstraint:self.bottomLayoutConstraint]; -} - -- (void)viewDidLoad -{ - [super viewDidLoad]; - - [self fetchPostIfNecessary]; - [self reloadData]; -} - -- (void)viewWillAppear:(BOOL)animated -{ - [super viewWillAppear:animated]; - - [self.keyboardManager startListeningToKeyboardNotifications]; -} - -- (void)viewWillDisappear:(BOOL)animated -{ - [super viewWillDisappear:animated]; - - [self.keyboardManager stopListeningToKeyboardNotifications]; - [self dismissNotice]; -} - -#pragma mark - Fetching Post - -- (void)fetchPostIfNecessary -{ - // if the post is already set for the comment, no need to do anything else - if (self.comment.post) { - return; - } - - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - PostService *postService = [[PostService alloc] initWithManagedObjectContext:context]; - - __weak __typeof(self) weakSelf = self; - - // when the post is updated, all it's comment will be associated to it, reloading tableView is enough - [postService getPostWithID:self.comment.postID - forBlog:self.comment.blog - success:^(AbstractPost *post) { - [weakSelf reloadData]; - } - failure:nil]; -} - - -#pragma mark - Table view data source - -- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView -{ - return CommentsDetailsNumberOfSections; -} - -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section -{ - return self.numberOfRows; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath -{ - NSString *reuseIdentifier = self.reuseIdentifiersMap[@(indexPath.row)]; - NSAssert(reuseIdentifier, @"Missing Layout Identifier!"); - - NoteBlockTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:reuseIdentifier]; - NSAssert([cell isKindOfClass:[NoteBlockTableViewCell class]], @"Missing cell!"); - - [self setupCell:cell]; - [self setupSeparators:cell indexPath:indexPath]; - - return cell; -} - -- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath -{ - [self.estimatedRowHeights setObject:@(cell.frame.size.height) forKey:indexPath]; -} - -#pragma mark - Table view delegate - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath -{ - [tableView deselectRowAtIndexPath:indexPath animated:YES]; - - if (indexPath.row == self.rowNumberForHeader) { - if (![self.comment.blog supports:BlogFeatureWPComRESTAPI]) { - [self openWebViewWithURL:[NSURL URLWithString:self.comment.post.permaLink]]; - return; - } - - ReaderDetailViewController *vc = [ReaderDetailViewController controllerWithPostID:self.comment.postID siteID:self.comment.blog.dotComID isFeed:NO]; - [self.navigationController pushFullscreenViewController:vc animated:YES]; - } -} - -- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath -{ - NSNumber *cachedHeight = [self.estimatedRowHeights objectForKey:indexPath]; - if (cachedHeight.doubleValue) { - return cachedHeight.doubleValue; - } - return WPTableViewDefaultRowHeight; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath -{ - return UITableViewAutomaticDimension; -} - -#pragma mark - Setup Cells - -- (void)setupCell:(UITableViewCell *)cell -{ - NSParameterAssert(cell); - - // This is gonna look way better in Swift! - if ([cell isKindOfClass:[NoteBlockHeaderTableViewCell class]]) { - [self setupHeaderCell:(NoteBlockHeaderTableViewCell *)cell]; - } else if ([cell isKindOfClass:[NoteBlockCommentTableViewCell class]]) { - [self setupCommentCell:(NoteBlockCommentTableViewCell *)cell]; - } else if ([cell isKindOfClass:[NoteBlockActionsTableViewCell class]]) { - [self setupActionsCell:(NoteBlockActionsTableViewCell *)cell]; - } -} - -- (void)setupHeaderCell:(NoteBlockHeaderTableViewCell *)cell -{ - NSString *postTitle = [self.comment.post titleForDisplay]; - if (postTitle.length == 0) { - postTitle = [self.comment.post contentPreviewForDisplay]; - } - - // Setup the cell - cell.headerTitle = self.comment.post.authorForDisplay; - cell.headerDetails = postTitle; - - // Setup the Separator - SeparatorsView *separatorsView = cell.separatorsView; - separatorsView.bottomVisible = YES; - - // Setup the Gravatar if needed - if ([self.comment.post respondsToSelector:@selector(authorAvatarURL)]) { - [cell downloadAuthorAvatarWithURL:[NSURL URLWithString:self.comment.post.authorAvatarURL]]; - } -} - -- (void)setupCommentCell:(NoteBlockCommentTableViewCell *)cell -{ - // Setup the Cell - cell.isTextViewSelectable = YES; - cell.dataDetectors = UIDataDetectorTypeAll; - - // Setup the Fields - cell.name = self.comment.authorForDisplay; - cell.timestamp = [self.comment.dateCreated mediumString]; - cell.site = self.comment.authorUrlForDisplay; - cell.commentText = [self.comment contentForDisplay]; - cell.isApproved = [self.comment.status isEqualToString:CommentStatusApproved]; - __typeof(self) __weak weakSelf = self; - cell.onTimeStampLongPress = ^(void) { - NSURL *url = [NSURL URLWithString:weakSelf.comment.link]; - [UIAlertController presentAlertAndCopyCommentURLToClipboardWithUrl:url]; - }; - - if ([self.comment avatarURLForDisplay]) { - [cell downloadGravatarWithURL:self.comment.avatarURLForDisplay]; - } else { - [cell downloadGravatarWithEmail:[self.comment gravatarEmailForDisplay]]; - } - - cell.onUrlClick = ^(NSURL *url){ - [weakSelf openWebViewWithURL:url]; - }; - - cell.onUserClick = ^{ - NSURL *url = [NSURL URLWithString:self.comment.author_url]; - if (url) { - [weakSelf openWebViewWithURL:url]; - } - }; -} - -- (void)setupActionsCell:(NoteBlockActionsTableViewCell *)cell -{ - // Setup the Cell - cell.isReplyEnabled = [UIDevice isPad]; - cell.isLikeEnabled = [self.comment.blog supports:BlogFeatureCommentLikes]; - cell.isApproveEnabled = YES; - cell.isTrashEnabled = YES; - cell.isSpamEnabled = YES; - - cell.isApproveOn = [self.comment.status isEqualToString:CommentStatusApproved]; - cell.isLikeOn = self.comment.isLiked; - - // Setup the Callbacks - __weak __typeof(self) weakSelf = self; - - cell.onReplyClick = ^(UIButton *sender) { - [weakSelf focusOnReplyTextView]; - }; - - cell.onLikeClick = ^(UIButton *sender){ - [weakSelf toggleLikeForComment]; - }; - - cell.onUnlikeClick = ^(UIButton *sender){ - [weakSelf toggleLikeForComment]; - }; - - cell.onApproveClick = ^(UIButton *sender){ - [weakSelf approveComment]; - }; - - cell.onUnapproveClick = ^(UIButton *sender){ - [weakSelf unapproveComment]; - }; - - cell.onTrashClick = ^(UIButton *sender){ - [weakSelf trashComment]; - }; - - cell.onSpamClick = ^(UIButton *sender){ - [weakSelf spamComment]; - }; - - cell.onEditClick = ^(UIButton *sender) { - [weakSelf editComment]; - }; -} - - -#pragma mark - Setup properties required by Cell Separator Logic - -- (void)setupSeparators:(NoteBlockTableViewCell *)cell indexPath:(NSIndexPath *)indexPath -{ - cell.isLastRow = (indexPath.row >= self.numberOfRows - 1); -} - - -#pragma mark - Actions - -- (void)openWebViewWithURL:(NSURL *)url -{ - NSParameterAssert([url isKindOfClass:[NSURL class]]); - - if (![url isKindOfClass:[NSURL class]]) { - DDLogError(@"CommentsViewController: Attempted to open an invalid URL [%@]", url); - return; - } - - if (self.comment.blog.jetpack) { - url = [url appendingHideMasterbarParameters]; - } - - UIViewController *webViewController = [WebViewControllerFactory controllerAuthenticatedWithDefaultAccountWithUrl:url]; - UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:webViewController]; - [self presentViewController:navController animated:YES completion:nil]; -} - -- (void)toggleLikeForComment -{ - __typeof(self) __weak weakSelf = self; - - if (!self.comment.isLiked) { - [[UINotificationFeedbackGenerator new] notificationOccurred:UINotificationFeedbackTypeSuccess]; - } - - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:context]; - [commentService toggleLikeStatusForComment:self.comment - siteID:self.comment.blog.dotComID - success:nil - failure:^(NSError *error) { - [weakSelf reloadData]; - }]; -} - -- (void)approveComment -{ - __typeof(self) __weak weakSelf = self; - - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:context]; - [commentService approveComment:self.comment success:nil failure:^(NSError *error) { - [weakSelf reloadData]; - }]; - - [self reloadData]; -} - -- (void)unapproveComment -{ - __typeof(self) __weak weakSelf = self; - - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:context]; - [commentService unapproveComment:self.comment success:nil failure:^(NSError *error) { - [weakSelf reloadData]; - }]; - - [self reloadData]; -} - -- (void)trashComment -{ - __typeof(self) __weak weakSelf = self; - - NSString *message = NSLocalizedString(@"Are you sure you want to delete this comment?", - @"Message asking for confirmation on comment deletion"); - - UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Confirm", @"Confirm") - message:message - preferredStyle:UIAlertControllerStyleAlert]; - - UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"Cancel") - style:UIAlertActionStyleCancel - handler:^(UIAlertAction *action){}]; - - UIAlertAction *deleteAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Delete", @"Delete") - style:UIAlertActionStyleDestructive - handler:^(UIAlertAction *action){ - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:context]; - - NSError *error = nil; - Comment *reloadedComment = (Comment *)[context existingObjectWithID:weakSelf.comment.objectID error:&error]; - - if (error) { - DDLogError(@"Comment was deleted while awaiting for alertView confirmation"); - return; - } - - [commentService deleteComment:reloadedComment success:nil failure:nil]; - - // Note: the parent class of CommentsViewController will pop this as a result of NSFetchedResultsChangeDelete - }]; - [alertController addAction:cancelAction]; - [alertController addAction:deleteAction]; - [self presentViewController:alertController animated:YES completion:nil]; -} - -- (void)spamComment -{ - __typeof(self) __weak weakSelf = self; - - NSString *message = NSLocalizedString(@"Are you sure you want to mark this comment as Spam?", - @"Message asking for confirmation before marking a comment as spam"); - - - UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Confirm", @"Confirm") - message:message - preferredStyle:UIAlertControllerStyleAlert]; - - UIAlertAction *cancelAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"Cancel") - style:UIAlertActionStyleCancel - handler:^(UIAlertAction *action){}]; - - UIAlertAction *spamAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"Spam", @"Spam") - style:UIAlertActionStyleDestructive - handler:^(UIAlertAction *action){ - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:context]; - - NSError *error = nil; - Comment *reloadedComment = (Comment *)[context existingObjectWithID:weakSelf.comment.objectID error:&error]; - - if (error) { - DDLogError(@"Comment was deleted while awaiting for alertView confirmation"); - return; - } - - [commentService spamComment:reloadedComment success:nil failure:nil]; - }]; - [alertController addAction:cancelAction]; - [alertController addAction:spamAction]; - [self presentViewController:alertController animated:YES completion:nil]; -} - - -#pragma mark - Editing comment - -- (void)editComment -{ - EditCommentViewController *editViewController = [EditCommentViewController newEditViewController]; - editViewController.content = self.comment.content; - - __typeof(self) __weak weakSelf = self; - editViewController.onCompletion = ^(BOOL hasNewContent, NSString *newContent) { - [self dismissViewControllerAnimated:YES completion:^{ - if (hasNewContent) { - [weakSelf updateCommentForNewContent:newContent]; - } - }]; - }; - - UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:editViewController]; - navController.modalPresentationStyle = UIModalPresentationFormSheet; - navController.modalTransitionStyle = UIModalTransitionStyleCoverVertical; - navController.navigationBar.translucent = NO; - - [self presentViewController:navController animated:true completion:nil]; -} - -- (void)updateCommentForNewContent:(NSString *)content -{ - // Set the new Content Data - self.comment.content = content; - [self reloadData]; - - // Hit the backend - __typeof(self) __weak weakSelf = self; - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:context]; - [commentService uploadComment:self.comment - success:^{ - // The comment might have changed its approval status! - [weakSelf reloadData]; - } failure:^(NSError *error) { - NSString *message = NSLocalizedString(@"There has been an unexpected error while editing your comment", - @"Error displayed if a comment fails to get updated"); - - [weakSelf displayNoticeWithTitle:message message:nil]; - }]; -} - - -#pragma mark - Replying Comments for iPad - -- (void)focusOnReplyTextView -{ -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunused-result" - [self.replyTextView becomeFirstResponder]; -#pragma clang diagnostic pop -} - -- (void)sendReplyWithNewContent:(NSString *)content -{ - __typeof(self) __weak weakSelf = self; - - void (^successBlock)(void) = ^void() { - NSString *successMessage = NSLocalizedString(@"Reply Sent!", @"The app successfully sent a comment"); - [weakSelf displayNoticeWithTitle:successMessage message:nil]; - }; - - void (^failureBlock)(NSError *error) = ^void(NSError *error) { - NSString *message = NSLocalizedString(@"There has been an unexpected error while sending your reply", @"Reply Failure Message"); - [weakSelf displayNoticeWithTitle:message message:nil]; - }; - - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:context]; - Comment *reply = [commentService createReplyForComment:self.comment]; - reply.content = content; - [commentService uploadComment:reply success:successBlock failure:failureBlock]; -} - - -#pragma mark - UIScrollViewDelegate - -- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView -{ - [self.keyboardManager scrollViewWillBeginDragging:scrollView]; -} - -- (void)scrollViewDidScroll:(UIScrollView *)scrollView -{ - [self.keyboardManager scrollViewDidScroll:scrollView]; -} - -- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset -{ - [self.keyboardManager scrollViewWillEndDragging:scrollView withVelocity:velocity]; -} - - -#pragma mark - ReplyTextViewDelegate - -- (void)textView:(UITextView *)textView didTypeWord:(NSString *)word -{ - [self.suggestionsTableView showSuggestionsForWord:word]; -} - - -#pragma mark - SuggestionsTableViewDelegate - -- (void)suggestionsTableView:(SuggestionsTableView *)suggestionsTableView didSelectSuggestion:(NSString *)suggestion forSearchText:(NSString *)text -{ - [self.replyTextView replaceTextAtCaret:text withText:suggestion]; - [suggestionsTableView showSuggestionsForWord:@""]; -} - - -#pragma mark - Gestures Recognizer Delegate - -- (void)dismissKeyboardIfNeeded:(id)sender -{ - // Dismiss the reply field when tapping on the tableView - [self.view endEditing:YES]; -} - - -#pragma mark - Setters - -- (void)setComment:(Comment *)comment -{ - _comment = comment; - [self reloadData]; -} - - -#pragma mark - Helpers - -- (BOOL)shouldAttachSuggestionsTableView -{ - return [[SuggestionService sharedInstance] shouldShowSuggestionsForSiteID:self.comment.blog.dotComID]; -} - -- (void)reloadData -{ - // If we don't have the associated post, let's hide the Header - BOOL shouldShowHeader = self.comment.post != nil; - - // Number of Rows: - // NOTE: If the post wasn't retrieved yet, we'll need to hide the Header. - // For that reason, the Row Count is decreased, and rowNumberForHeader is set with a different index. - self.numberOfRows = shouldShowHeader ? CommentsDetailsRowCount : CommentsDetailsRowCount - 1; - self.rowNumberForHeader = shouldShowHeader ? CommentsDetailsRowHeader : CommentsDetailsHiddenRowNumber; - self.rowNumberForComment = self.rowNumberForHeader + 1; - self.rowNumberForActions = self.rowNumberForComment + 1; - - // Arrange the Reuse + Layout Identifier Map(s) - self.reuseIdentifiersMap = @{ - @(self.rowNumberForHeader) : NoteBlockHeaderTableViewCell.reuseIdentifier, - @(self.rowNumberForComment) : NoteBlockCommentTableViewCell.reuseIdentifier, - @(self.rowNumberForActions) : NoteBlockActionsTableViewCell.reuseIdentifier, - }; - - // Reload the table, at last! - [self.tableView reloadData]; -} - -@end diff --git a/WordPress/Classes/ViewRelated/Comments/CommentsList.storyboard b/WordPress/Classes/ViewRelated/Comments/CommentsList.storyboard new file mode 100644 index 000000000000..d34a64e40c96 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentsList.storyboard @@ -0,0 +1,65 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="cBt-Sc-5RS"> + <device id="retina4_7" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <scenes> + <!--Comments View Controller--> + <scene sceneID="Zaz-EE-DkY"> + <objects> + <viewController storyboardIdentifier="CommentsViewController" useStoryboardIdentifierAsRestorationIdentifier="YES" id="cBt-Sc-5RS" customClass="CommentsViewController" sceneMemberID="viewController"> + <layoutGuides> + <viewControllerLayoutGuide type="top" id="MpO-o7-pbX"/> + <viewControllerLayoutGuide type="bottom" id="uCp-uQ-tOJ"/> + </layoutGuides> + <view key="view" contentMode="scaleToFill" id="gDw-cc-yIW"> + <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="bno-oB-pDf" customClass="FilterTabBar" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="375" height="46"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="height" constant="46" id="mN2-YL-PRM"/> + </constraints> + </view> + <tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="7aR-Vp-g6a"> + <rect key="frame" x="0.0" y="46" width="375" height="621"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <connections> + <outlet property="dataSource" destination="cBt-Sc-5RS" id="ACo-bR-be2"/> + <outlet property="delegate" destination="cBt-Sc-5RS" id="hKz-4a-sNu"/> + </connections> + </tableView> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="bno-oB-pDf" firstAttribute="leading" secondItem="gDw-cc-yIW" secondAttribute="leading" id="7Jf-su-8WM"/> + <constraint firstAttribute="trailing" secondItem="bno-oB-pDf" secondAttribute="trailing" id="AgG-aE-VG8"/> + <constraint firstItem="7aR-Vp-g6a" firstAttribute="leading" secondItem="gDw-cc-yIW" secondAttribute="leading" id="Nt5-JK-DbB"/> + <constraint firstItem="uCp-uQ-tOJ" firstAttribute="top" secondItem="7aR-Vp-g6a" secondAttribute="bottom" id="Oly-V9-tho"/> + <constraint firstItem="7aR-Vp-g6a" firstAttribute="top" secondItem="bno-oB-pDf" secondAttribute="bottom" id="Rmm-F3-kCp"/> + <constraint firstAttribute="trailing" secondItem="7aR-Vp-g6a" secondAttribute="trailing" id="htA-bQ-QaF"/> + <constraint firstItem="bno-oB-pDf" firstAttribute="top" secondItem="MpO-o7-pbX" secondAttribute="bottom" id="tfA-U7-Sc2"/> + </constraints> + </view> + <connections> + <outlet property="filterTabBar" destination="bno-oB-pDf" id="HTz-ai-FRw"/> + <outlet property="tableView" destination="7aR-Vp-g6a" id="b8S-2O-Yk9"/> + </connections> + </viewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="wHe-tJ-scb" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/> + </objects> + <point key="canvasLocation" x="21.600000000000001" y="272.11394302848578"/> + </scene> + </scenes> + <resources> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Comments/CommentsTableViewCell.swift b/WordPress/Classes/ViewRelated/Comments/CommentsTableViewCell.swift deleted file mode 100644 index 396b4bf9ffa6..000000000000 --- a/WordPress/Classes/ViewRelated/Comments/CommentsTableViewCell.swift +++ /dev/null @@ -1,176 +0,0 @@ -import Foundation -import WordPressShared.WPTableViewCell - -open class CommentsTableViewCell: WPTableViewCell { - // MARK: - Public Properties - @objc open var author: String? { - didSet { - refreshDetailsLabel() - } - } - @objc open var postTitle: String? { - didSet { - refreshDetailsLabel() - } - } - @objc open var content: String? { - didSet { - refreshDetailsLabel() - } - } - @objc open var timestamp: String? { - didSet { - refreshTimestampLabel() - } - } - @objc open var approved: Bool = false { - didSet { - refreshTimestampLabel() - refreshDetailsLabel() - refreshBackground() - refreshImages() - } - } - - - // MARK: - Public Methods - @objc open func downloadGravatarWithURL(_ url: URL?) { - if url == gravatarURL { - return - } - - let gravatar = url.flatMap { Gravatar($0) } - gravatarImageView.downloadGravatar(gravatar, placeholder: placeholderImage, animate: true) - - gravatarURL = url - } - - @objc open func downloadGravatarWithGravatarEmail(_ email: String?) { - guard let unwrappedEmail = email else { - gravatarImageView.image = placeholderImage - return - } - - gravatarImageView.downloadGravatarWithEmail(unwrappedEmail, placeholderImage: placeholderImage) - } - - - // MARK: - Overwritten Methods - open override func awakeFromNib() { - super.awakeFromNib() - - assert(gravatarImageView != nil) - assert(detailsLabel != nil) - assert(timestampImageView != nil) - assert(timestampLabel != nil) - } - - open override func setSelected(_ selected: Bool, animated: Bool) { - // Note: this is required, since the cell unhighlight mechanism will reset the new background color - super.setSelected(selected, animated: animated) - refreshBackground() - } - - open override func setHighlighted(_ highlighted: Bool, animated: Bool) { - // Note: this is required, since the cell unhighlight mechanism will reset the new background color - super.setHighlighted(highlighted, animated: animated) - refreshBackground() - } - - - - // MARK: - Private Helpers - fileprivate func refreshDetailsLabel() { - detailsLabel.attributedText = attributedDetailsText(approved) - layoutIfNeeded() - } - - fileprivate func refreshTimestampLabel() { - guard let timestamp = timestamp else { - return - } - let style = Style.timestampStyle(isApproved: approved) - let formattedTimestamp: String - if approved { - formattedTimestamp = timestamp - } else { - let pendingLabel = NSLocalizedString("Pending", comment: "Status name for a comment that hasn't yet been approved.") - formattedTimestamp = "\(timestamp) · \(pendingLabel)" - } - timestampLabel?.attributedText = NSAttributedString(string: formattedTimestamp, attributes: style) - } - - fileprivate func refreshBackground() { - let color = Style.backgroundColor(isApproved: approved) - backgroundColor = color - } - - fileprivate func refreshImages() { - timestampImageView.image = Style.timestampImage(isApproved: approved) - if !approved { - timestampImageView.tintColor = WPStyleGuide.alertYellowDark() - } - } - - - - // MARK: - Details Helpers - fileprivate func attributedDetailsText(_ isApproved: Bool) -> NSAttributedString { - // Unwrap - let unwrappedAuthor = author ?? String() - let unwrappedTitle = postTitle ?? NSLocalizedString("(No Title)", comment: "Empty Post Title") - let unwrappedContent = content ?? String() - - // Styles - let detailsBoldStyle = Style.detailsBoldStyle(isApproved: isApproved) - let detailsItalicsStyle = Style.detailsItalicsStyle(isApproved: isApproved) - let detailsRegularStyle = Style.detailsRegularStyle(isApproved: isApproved) - let regularRedStyle = Style.detailsRegularRedStyle(isApproved: isApproved) - - // Localize the format - var details = NSLocalizedString("%1$@ on %2$@: %3$@", comment: "'AUTHOR on POST TITLE: COMMENT' in a comment list") - if unwrappedContent.isEmpty { - details = NSLocalizedString("%1$@ on %2$@", comment: "'AUTHOR on POST TITLE' in a comment list") - } - - // Arrange the Replacement Map - let replacementMap = [ - "%1$@": NSAttributedString(string: unwrappedAuthor, attributes: detailsBoldStyle), - "%2$@": NSAttributedString(string: unwrappedTitle, attributes: detailsItalicsStyle), - "%3$@": NSAttributedString(string: unwrappedContent, attributes: detailsRegularStyle) - ] - - // Replace Author + Title + Content - let attributedDetails = NSMutableAttributedString(string: details, attributes: regularRedStyle) - - for (key, attributedString) in replacementMap { - let range = (attributedDetails.string as NSString).range(of: key) - if range.location == NSNotFound { - continue - } - - attributedDetails.replaceCharacters(in: range, with: attributedString) - } - - return attributedDetails - } - - - - // MARK: - Aliases - typealias Style = WPStyleGuide.Comments - - // MARK: - Private Properties - fileprivate var gravatarURL: URL? - - // MARK: - Private Calculated Properties - fileprivate var placeholderImage: UIImage { - return Style.gravatarPlaceholderImage(isApproved: approved) - } - - // MARK: - IBOutlets - @IBOutlet fileprivate var gravatarImageView: CircularImageView! - @IBOutlet fileprivate var detailsLabel: UILabel! - @IBOutlet fileprivate var timestampImageView: UIImageView! - @IBOutlet fileprivate var timestampLabel: UILabel! -} diff --git a/WordPress/Classes/ViewRelated/Comments/CommentsTableViewCell.xib b/WordPress/Classes/ViewRelated/Comments/CommentsTableViewCell.xib deleted file mode 100644 index 4bd79da652ee..000000000000 --- a/WordPress/Classes/ViewRelated/Comments/CommentsTableViewCell.xib +++ /dev/null @@ -1,89 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="13529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> - <dependencies> - <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13527"/> - <capability name="Constraints to layout margins" minToolsVersion="6.0"/> - <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> - </dependencies> - <objects> - <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> - <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <tableViewCell contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" indentationWidth="10" id="Upg-EE-wRd" customClass="CommentsTableViewCell" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="320" height="74"/> - <autoresizingMask key="autoresizingMask"/> - <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="Upg-EE-wRd" id="p6H-P7-J7f"> - <rect key="frame" x="0.0" y="0.0" width="320" height="73.5"/> - <autoresizingMask key="autoresizingMask"/> - <subviews> - <stackView opaque="NO" contentMode="scaleToFill" alignment="top" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="dsX-LD-1XZ"> - <rect key="frame" x="0.0" y="11" width="0.0" height="52"/> - <subviews> - <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar" translatesAutoresizingMaskIntoConstraints="NO" id="0Gm-n3-CNm" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="46" height="46"/> - <constraints> - <constraint firstAttribute="height" priority="999" constant="46" id="h59-o3-ocT"/> - <constraint firstAttribute="width" constant="46" id="pBo-eH-W4J"/> - </constraints> - </imageView> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="TCy-wp-wTe"> - <rect key="frame" x="0.0" y="0.0" width="0.0" height="18.5"/> - <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Details" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="3" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Wrp-Wr-ZBq"> - <rect key="frame" x="0.0" y="-4" width="0.0" height="0.0"/> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> - <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="dIK-tl-nW4"> - <rect key="frame" x="0.0" y="-2" width="0.0" height="20.5"/> - <subviews> - <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="reader-postaction-time" translatesAutoresizingMaskIntoConstraints="NO" id="rcg-tb-060"> - <rect key="frame" x="0.0" y="2.5" width="16" height="16"/> - <constraints> - <constraint firstAttribute="width" constant="16" id="YZR-zM-ndp"/> - <constraint firstAttribute="height" constant="16" id="xwF-CO-6ZI"/> - </constraints> - </imageView> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Timestamp" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bDl-id-9M7"> - <rect key="frame" x="0.0" y="0.0" width="0.0" height="20.5"/> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> - </subviews> - <edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="0.0" right="0.0"/> - </stackView> - </subviews> - <edgeInsets key="layoutMargins" top="-4" left="0.0" bottom="0.0" right="0.0"/> - </stackView> - </subviews> - <constraints> - <constraint firstItem="0Gm-n3-CNm" firstAttribute="top" secondItem="dsX-LD-1XZ" secondAttribute="top" id="9dw-Qk-yyT"/> - </constraints> - </stackView> - </subviews> - <constraints> - <constraint firstItem="dsX-LD-1XZ" firstAttribute="leading" secondItem="p6H-P7-J7f" secondAttribute="leadingMargin" id="WVn-it-RvF"/> - <constraint firstAttribute="bottomMargin" secondItem="dsX-LD-1XZ" secondAttribute="bottom" id="YbA-Pd-ZGu"/> - <constraint firstAttribute="trailingMargin" secondItem="dsX-LD-1XZ" secondAttribute="trailing" id="mUy-WB-D5x"/> - <constraint firstItem="dsX-LD-1XZ" firstAttribute="top" secondItem="p6H-P7-J7f" secondAttribute="topMargin" id="qkF-E2-4Jc"/> - </constraints> - </tableViewCellContentView> - <connections> - <outlet property="detailsLabel" destination="Wrp-Wr-ZBq" id="Fos-Bf-RYL"/> - <outlet property="gravatarImageView" destination="0Gm-n3-CNm" id="GXM-xm-h6r"/> - <outlet property="timestampImageView" destination="rcg-tb-060" id="cp9-u0-P57"/> - <outlet property="timestampLabel" destination="bDl-id-9M7" id="sk9-hl-b6r"/> - </connections> - <point key="canvasLocation" x="34" y="56"/> - </tableViewCell> - </objects> - <resources> - <image name="gravatar" width="85" height="85"/> - <image name="reader-postaction-time" width="16" height="16"/> - </resources> -</document> diff --git a/WordPress/Classes/ViewRelated/Comments/CommentsViewController+Filters.swift b/WordPress/Classes/ViewRelated/Comments/CommentsViewController+Filters.swift new file mode 100644 index 000000000000..9878feaf4358 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentsViewController+Filters.swift @@ -0,0 +1,75 @@ +extension CommentsViewController { + + enum CommentFilter: Int, FilterTabBarItem, CaseIterable { + case all + case pending + case unreplied + case approved + case spam + case trashed + + var title: String { + switch self { + case .all: return NSLocalizedString("All", comment: "Title of all Comments filter.") + case .pending: return NSLocalizedString("Pending", comment: "Title of pending Comments filter.") + case .unreplied: return NSLocalizedString("Unreplied", comment: "Title of unreplied Comments filter.") + case .approved: return NSLocalizedString("Approved", comment: "Title of approved Comments filter.") + case .spam: return NSLocalizedString("Spam", comment: "Title of spam Comments filter.") + case .trashed: return NSLocalizedString("Trashed", comment: "Title of trashed Comments filter.") + } + } + + var analyticsTitle: String { + switch self { + case .all: return "All" + case .pending: return "Pending" + case .unreplied: return "Unreplied" + case .approved: return "Approved" + case .spam: return "Spam" + case .trashed: return "Trashed" + } + } + + var statusFilter: CommentStatusFilter { + switch self { + case .all: return CommentStatusFilterAll + case .pending: return CommentStatusFilterUnapproved + case .unreplied: return CommentStatusFilterAll + case .approved: return CommentStatusFilterApproved + case .spam: return CommentStatusFilterSpam + case .trashed: return CommentStatusFilterTrash + } + } + } + + @objc func configureFilterTabBar(_ filterTabBar: FilterTabBar) { + WPStyleGuide.configureFilterTabBar(filterTabBar) + filterTabBar.items = CommentFilter.allCases + filterTabBar.addTarget(self, action: #selector(selectedFilterDidChange(_:)), for: .valueChanged) + } + + @objc private func selectedFilterDidChange(_ filterTabBar: FilterTabBar) { + guard let filter = CommentFilter(rawValue: filterTabBar.selectedIndex) else { + return + } + + WPAnalytics.track(.commentFilterChanged, properties: ["selected_filter": filter.analyticsTitle]) + refresh(with: filter.statusFilter) + } + + @objc func getSelectedIndex(_ filterTabBar: FilterTabBar) -> Int { + return filterTabBar.selectedIndex + } + + @objc func setSeletedIndex(_ selectedIndex: Int, filterTabBar: FilterTabBar) { + filterTabBar.setSelectedIndex(selectedIndex, animated: false) + selectedFilterDidChange(filterTabBar) + } + + @objc func isUnrepliedFilterSelected(_ filterTabBar: FilterTabBar) -> Bool { + guard let item = filterTabBar.currentlySelectedItem as? CommentFilter else { + return false + } + return item == CommentFilter.unreplied + } +} diff --git a/WordPress/Classes/ViewRelated/Comments/CommentsViewController+NetworkAware.swift b/WordPress/Classes/ViewRelated/Comments/CommentsViewController+NetworkAware.swift deleted file mode 100644 index c89fc5441a2f..000000000000 --- a/WordPress/Classes/ViewRelated/Comments/CommentsViewController+NetworkAware.swift +++ /dev/null @@ -1,32 +0,0 @@ -import UIKit - -extension CommentsViewController: NetworkAwareUI { - func contentIsEmpty() -> Bool { - return tableViewHandler.resultsController.isEmpty() - } - - @objc func noConnectionMessage() -> String { - return NSLocalizedString("No internet connection. Some comments may be unavailable while offline.", - comment: "Error message shown when the user is browsing Site Comments without an internet connection.") - } - - @objc func connectionAvailable() -> Bool { - return ReachabilityUtils.isInternetReachable() - } - - @objc func handleConnectionError() { - if shouldPresentAlert() { - presentNoNetworkAlert() - } - } - - @objc func dismissConnectionErrorNotice() { - dismissNoNetworkAlert() - } -} - -extension CommentsViewController: NetworkStatusDelegate { - func networkStatusDidChange(active: Bool) { - refreshAndSyncIfNeeded() - } -} diff --git a/WordPress/Classes/ViewRelated/Comments/CommentsViewController.h b/WordPress/Classes/ViewRelated/Comments/CommentsViewController.h index b1b1e9550384..5e4a8b07f864 100644 --- a/WordPress/Classes/ViewRelated/Comments/CommentsViewController.h +++ b/WordPress/Classes/ViewRelated/Comments/CommentsViewController.h @@ -1,10 +1,11 @@ #import <Foundation/Foundation.h> - +#import "CommentService.h" @class Blog; -@interface CommentsViewController : UITableViewController +@interface CommentsViewController : UIViewController -@property (nonatomic, strong) Blog *blog; ++ (CommentsViewController *)controllerWithBlog:(Blog *)blog; +- (void)refreshWithStatusFilter:(CommentStatusFilter)statusFilter; @end diff --git a/WordPress/Classes/ViewRelated/Comments/CommentsViewController.m b/WordPress/Classes/ViewRelated/Comments/CommentsViewController.m index bed58a4b73e5..2d6b35667cb9 100644 --- a/WordPress/Classes/ViewRelated/Comments/CommentsViewController.m +++ b/WordPress/Classes/ViewRelated/Comments/CommentsViewController.m @@ -1,35 +1,38 @@ #import "CommentsViewController.h" -#import "CommentViewController.h" -#import "CommentService.h" -#import "Comment.h" #import "Blog.h" - #import "WordPress-Swift.h" #import "WPTableViewHandler.h" -#import "WPGUIConstants.h" -#import "UIView+Subviews.h" -#import "ContextManager.h" #import <WordPressShared/WPStyleGuide.h> -#import <WordPressUI/WordPressUI.h> - +@class Comment; static CGRect const CommentsActivityFooterFrame = {0.0, 0.0, 30.0, 30.0}; static CGFloat const CommentsActivityFooterHeight = 50.0; static NSInteger const CommentsRefreshRowPadding = 4; static NSInteger const CommentsFetchBatchSize = 10; -static NSString *CommentsReuseIdentifier = @"CommentsReuseIdentifier"; -static NSString *CommentsLayoutIdentifier = @"CommentsLayoutIdentifier"; +static NSString *RestorableBlogIdKey = @"restorableBlogIdKey"; +static NSString *RestorableFilterIndexKey = @"restorableFilterIndexKey"; - -@interface CommentsViewController () <WPTableViewHandlerDelegate, WPContentSyncHelperDelegate> +@interface CommentsViewController () <WPTableViewHandlerDelegate, WPContentSyncHelperDelegate, UIViewControllerRestoration, NoResultsViewControllerDelegate, CommentDetailsDelegate> @property (nonatomic, strong) WPTableViewHandler *tableViewHandler; @property (nonatomic, strong) WPContentSyncHelper *syncHelper; @property (nonatomic, strong) NoResultsViewController *noResultsViewController; +@property (nonatomic, strong) NoResultsViewController *noConnectionViewController; @property (nonatomic, strong) UIActivityIndicatorView *footerActivityIndicator; @property (nonatomic, strong) UIView *footerView; -@property (nonatomic, strong) NSCache *estimatedRowHeights; +@property (nonatomic, strong) Blog *blog; + +@property (nonatomic) CommentStatusFilter currentStatusFilter; +@property (nonatomic) CommentStatusFilter cachedStatusFilter; +@property (weak, nonatomic) IBOutlet FilterTabBar *filterTabBar; +@property (weak, nonatomic) IBOutlet UITableView *tableView; + +// Keep track of the index path of the Comment displayed in comment details. +// Used to advance the displayed Comment when Next is selected on the moderation confirmation snackbar. +@property (nonatomic, strong) NSIndexPath *displayedCommentIndexPath; +@property (nonatomic, strong) CommentDetailViewController *commentDetailViewController; + @end @implementation CommentsViewController @@ -40,27 +43,29 @@ - (void)dealloc _tableViewHandler.delegate = nil; } -- (instancetype)init ++ (CommentsViewController *)controllerWithBlog:(Blog *)blog { - self = [super init]; - if (self) { - self.restorationClass = [self class]; - self.restorationIdentifier = NSStringFromClass([self class]); - self.estimatedRowHeights = [[NSCache alloc] init]; - } - return self; + NSParameterAssert([blog isKindOfClass:[Blog class]]); + UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"CommentsList" bundle:nil]; + CommentsViewController *controller = [storyboard instantiateInitialViewController]; + controller.blog = blog; + controller.restorationClass = [controller class]; + return controller; } - (void)viewDidLoad { [super viewDidLoad]; - + + [self configureFilterTabBar:self.filterTabBar]; + [self getSelectedFilterFromUserDefaults]; [self configureNavBar]; [self configureLoadMoreSpinner]; - [self configureNoResultsView]; + [self initializeNoResultsViews]; [self configureRefreshControl]; [self configureSyncHelper]; [self configureTableView]; + [self configureTableViewHeader]; [self configureTableViewFooter]; [self configureTableViewHandler]; } @@ -68,25 +73,13 @@ - (void)viewDidLoad - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; - - // Manually deselect the selected row. This is required due to a bug in iOS7 / iOS8 - [self.tableView deselectSelectedRowWithAnimation:YES]; - - // Refresh the UI + [self refreshPullToRefresh]; [self refreshNoResultsView]; - [self refreshAndSyncIfNeeded]; } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; - [self dismissConnectionErrorNotice]; -} - -- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator -{ - [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; - [self.tableViewHandler clearCachedRowHeights]; } @@ -95,6 +88,8 @@ - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIVi - (void)configureNavBar { self.title = NSLocalizedString(@"Comments", @"Title for the Blog's Comments Section View"); + + self.extendedLayoutIncludesOpaqueBars = YES; } - (void)configureLoadMoreSpinner @@ -106,7 +101,7 @@ - (void)configureLoadMoreSpinner // Spinner UIActivityIndicatorView *indicator = [[UIActivityIndicatorView alloc] initWithFrame:CommentsActivityFooterFrame]; - indicator.activityIndicatorViewStyle = UIActivityIndicatorViewStyleGray; + indicator.activityIndicatorViewStyle = UIActivityIndicatorViewStyleMedium; indicator.hidesWhenStopped = YES; indicator.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin; indicator.center = footerView.center; @@ -114,27 +109,16 @@ - (void)configureLoadMoreSpinner [footerView addSubview:indicator]; - // Keep References! + // Keep References self.footerActivityIndicator = indicator; self.footerView = footerView; } -- (void)configureNoResultsView -{ - self.noResultsViewController = [NoResultsViewController controller]; -} - -- (NSString *)noResultsViewTitle -{ - NSString *noCommentsMessage = NSLocalizedString(@"No comments yet", @"Displayed when the user pulls up the comments view and they have no comments"); - return [ReachabilityUtils isInternetReachable] ? noCommentsMessage : [self noConnectionMessage]; -} - - (void)configureRefreshControl { - UIRefreshControl *refreshControl = [UIRefreshControl new]; + UIRefreshControl *refreshControl = [UIRefreshControl new]; [refreshControl addTarget:self action:@selector(refreshAndSyncWithInteraction) forControlEvents:UIControlEventValueChanged]; - self.refreshControl = refreshControl; + self.tableView.refreshControl = refreshControl; } - (void)configureSyncHelper @@ -146,22 +130,29 @@ - (void)configureSyncHelper - (void)configureTableView { - self.tableView.cellLayoutMarginsFollowReadableWidth = YES; self.tableView.accessibilityIdentifier = @"Comments Table"; - [WPStyleGuide configureColorsForView:self.view andTableView:self.tableView]; - + // Register the cells - NSString *nibName = [CommentsTableViewCell classNameWithoutNamespaces]; - UINib *nibInstance = [UINib nibWithNibName:nibName bundle:[NSBundle mainBundle]]; - [self.tableView registerNib:nibInstance forCellReuseIdentifier:CommentsReuseIdentifier]; + UINib *listCellNibInstance = [UINib nibWithNibName:[ListTableViewCell classNameWithoutNamespaces] bundle:[NSBundle mainBundle]]; + [self.tableView registerNib:listCellNibInstance forCellReuseIdentifier:ListTableViewCell.reuseIdentifier]; + + UINib *listHeaderNibInstance = [UINib nibWithNibName:[ListTableHeaderView classNameWithoutNamespaces] bundle:[NSBundle mainBundle]]; + [self.tableView registerNib:listHeaderNibInstance forHeaderFooterViewReuseIdentifier:ListTableHeaderView.reuseIdentifier]; +} + +- (void)configureTableViewHeader +{ + // Add an extra 10pt space on top of the first header view. Ref: https://git.io/JBQlU + UIView *tableHeaderView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.tableView.frame.size.width, 10)]; + tableHeaderView.backgroundColor = [UIColor systemBackgroundColor]; + self.tableView.tableHeaderView = tableHeaderView; } - (void)configureTableViewFooter { - // Notes: - // - Hide the cellSeparators, when the table is empty - self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectZero]; + // Hide the cellSeparators when the table is empty + self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.tableView.frame.size.width, 1)]; } - (void)configureTableViewHandler @@ -173,13 +164,36 @@ - (void)configureTableViewHandler #pragma mark - UITableViewDelegate Methods -- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return [self.tableViewHandler tableView:tableView numberOfRowsInSection:section]; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + return ListTableHeaderView.estimatedRowHeight; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { - NSNumber *cachedHeight = [self.estimatedRowHeights objectForKey:indexPath]; - if (cachedHeight.doubleValue) { - return cachedHeight.doubleValue; + // fetch the section information + id<NSFetchedResultsSectionInfo> sectionInfo = [self.tableViewHandler.resultsController.sections objectAtIndex:section]; + if (!sectionInfo) { + return nil; } - return WPTableViewDefaultRowHeight; + + ListTableHeaderView *headerView = (ListTableHeaderView *)[self.tableView dequeueReusableHeaderFooterViewWithIdentifier:ListTableHeaderView.reuseIdentifier]; + if (!headerView) { + headerView = [[ListTableHeaderView alloc] initWithReuseIdentifier:ListTableHeaderView.reuseIdentifier]; + } + + headerView.title = [Comment descriptionForSectionIdentifier:sectionInfo.name]; + return headerView; +} + +- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return ListTableViewCell.estimatedRowHeight; } - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath @@ -189,19 +203,15 @@ - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPa - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - CommentsTableViewCell *cell = (CommentsTableViewCell *)[tableView dequeueReusableCellWithIdentifier:CommentsReuseIdentifier]; - NSAssert([cell isKindOfClass:[CommentsTableViewCell class]], nil); - - [self configureCell:cell atIndexPath:indexPath]; - + ListTableViewCell *cell = (ListTableViewCell *)[tableView dequeueReusableCellWithIdentifier:ListTableViewCell.reuseIdentifier + forIndexPath:indexPath]; + [self configureListCell:cell atIndexPath:indexPath]; return cell; } - (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { - [self.estimatedRowHeights setObject:@(cell.frame.size.height) forKey:indexPath]; - - // Refresh only when we reach the last 3 rows in the last section! + // Refresh only when we reach the last 3 rows in the last section NSInteger numberOfRowsInSection = [self.tableViewHandler tableView:tableView numberOfRowsInSection:indexPath.section]; NSInteger lastSection = [self.tableViewHandler numberOfSectionsInTableView:tableView] - 1; @@ -214,37 +224,48 @@ - (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)ce - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { - // Failsafe: Make sure that the Comment (still) exists + [tableView deselectSelectedRowWithAnimation:YES]; + + if (![self indexPathIsValid:indexPath]) { + return; + } + + self.displayedCommentIndexPath = indexPath; + Comment *comment = [self.tableViewHandler.resultsController objectAtIndexPath:indexPath]; + self.commentDetailViewController = [[CommentDetailViewController alloc] initWithComment:comment + isLastInList:[self isLastRow:indexPath] + managedObjectContext:[ContextManager sharedInstance].mainContext]; + self.commentDetailViewController.commentDelegate = self; + [self.navigationController pushViewController:self.commentDetailViewController animated:YES]; + [CommentAnalytics trackCommentViewedWithComment:comment]; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section +{ + return 0; +} + +- (BOOL)indexPathIsValid:(NSIndexPath *)indexPath +{ NSArray *sections = self.tableViewHandler.resultsController.sections; if (indexPath.section >= sections.count) { - [tableView deselectSelectedRowWithAnimation:YES]; - return; + return NO; } id<NSFetchedResultsSectionInfo> sectionInfo = sections[indexPath.section]; if (indexPath.row >= sectionInfo.numberOfObjects) { - [tableView deselectSelectedRowWithAnimation:YES]; - return; + return NO; } - // At last, push the details - Comment *comment = [self.tableViewHandler.resultsController objectAtIndexPath:indexPath]; - CommentViewController *vc = [CommentViewController new]; - vc.comment = comment; - - [self.navigationController pushViewController:vc animated:YES]; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section -{ - // Override WPTableViewHandler's default of UITableViewAutomaticDimension, - // which results in 30pt tall headers on iOS 11 - return 0; + return YES; } -- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section +- (BOOL)isLastRow:(NSIndexPath *)indexPath { - return 0; + NSInteger lastSectionIndex = [self.tableView numberOfSections] - 1; + NSInteger lastRowIndex = [self.tableView numberOfRowsInSection:lastSectionIndex] - 1; + NSIndexPath *lastIndexPath = [NSIndexPath indexPathForRow:lastRowIndex inSection:lastSectionIndex]; + return lastIndexPath == indexPath; } #pragma mark - Comment Actions @@ -257,11 +278,17 @@ - (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *) - (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath { Comment *comment = [self.tableViewHandler.resultsController objectAtIndexPath:indexPath]; + + // If the current user cannot moderate comments, don't show the actions. + if (!comment.canModerate) { + return nil; + } + __typeof(self) __weak weakSelf = self; NSMutableArray *actions = [NSMutableArray array]; // Trash Action - UIContextualAction *trash = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:NSLocalizedString(@"Trash", @"Trashes a comment") handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { + UIContextualAction *trash = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:NSLocalizedString(@"Trash", @"Trashes a comment") handler:^(UIContextualAction * _Nonnull __unused action, __kindof UIView * _Nonnull __unused sourceView, void (^ _Nonnull completionHandler)(BOOL)) { [ReachabilityUtils onAvailableInternetConnectionDo:^{ [weakSelf deleteComment:comment]; }]; @@ -274,7 +301,7 @@ - (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView trailingSwip if (comment.isApproved) { // Unapprove Action - UIContextualAction *unapprove = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:NSLocalizedString(@"Unapprove", @"Unapproves a Comment") handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { + UIContextualAction *unapprove = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:NSLocalizedString(@"Unapprove", @"Unapproves a Comment") handler:^(UIContextualAction * _Nonnull __unused action, __kindof UIView * _Nonnull __unused sourceView, void (^ _Nonnull completionHandler)(BOOL)) { [ReachabilityUtils onAvailableInternetConnectionDo:^{ [weakSelf unapproveComment:comment]; }]; @@ -285,7 +312,7 @@ - (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView trailingSwip [actions addObject:unapprove]; } else { // Approve Action - UIContextualAction *approve = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:NSLocalizedString(@"Approve", @"Approves a Comment") handler:^(UIContextualAction * _Nonnull action, __kindof UIView * _Nonnull sourceView, void (^ _Nonnull completionHandler)(BOOL)) { + UIContextualAction *approve = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:NSLocalizedString(@"Approve", @"Approves a Comment") handler:^(UIContextualAction * _Nonnull __unused action, __kindof UIView * _Nonnull __unused sourceView, void (^ _Nonnull completionHandler)(BOOL)) { [ReachabilityUtils onAvailableInternetConnectionDo:^{ [weakSelf approveComment:comment]; }]; @@ -303,27 +330,30 @@ - (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView trailingSwip - (void)approveComment:(Comment *)comment { - CommentService *service = [[CommentService alloc] initWithManagedObjectContext:self.managedObjectContext]; - + [CommentAnalytics trackCommentUnApprovedWithComment:comment]; + CommentService *service = [[CommentService alloc] initWithCoreDataStack:[ContextManager sharedInstance]];; + [self.tableView setEditing:NO animated:YES]; [service approveComment:comment success:nil failure:^(NSError *error) { - DDLogError(@"#### Error approving comment: %@", error); + DDLogError(@"Error approving comment: %@", error); }]; } - (void)unapproveComment:(Comment *)comment { - CommentService *service = [[CommentService alloc] initWithManagedObjectContext:self.managedObjectContext]; + [CommentAnalytics trackCommentUnApprovedWithComment:comment]; + CommentService *service = [[CommentService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [self.tableView setEditing:NO animated:YES]; [service unapproveComment:comment success:nil failure:^(NSError *error) { - DDLogError(@"#### Error unapproving comment: %@", error); + DDLogError(@"Error unapproving comment: %@", error); }]; } - (void)deleteComment:(Comment *)comment { - CommentService *service = [[CommentService alloc] initWithManagedObjectContext:self.managedObjectContext]; + [CommentAnalytics trackCommentTrashedWithComment:comment]; + CommentService *service = [[CommentService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [self.tableView setEditing:NO animated:YES]; [service deleteComment:comment success:nil failure:^(NSError *error) { @@ -331,6 +361,31 @@ - (void)deleteComment:(Comment *)comment }]; } +// When `Next` is tapped on the comment moderation confirmation snackbar, +// find the next comment in the list and update comment details with it. +- (void)showNextComment +{ + NSIndexPath *nextIndexPath; + BOOL showingLastRowInSection = [self.tableViewHandler.resultsController isLastIndexPathInSection:self.displayedCommentIndexPath]; + + if (showingLastRowInSection) { + // Move to the first row in the next section. + nextIndexPath = [NSIndexPath indexPathForRow:0 + inSection:self.displayedCommentIndexPath.section + 1]; + } else { + // Move to the next row in the current section. + nextIndexPath = [NSIndexPath indexPathForRow:self.displayedCommentIndexPath.row + 1 + inSection:self.displayedCommentIndexPath.section]; + } + + if (![self indexPathIsValid:nextIndexPath] || !self.commentDetailViewController) { + return; + } + + Comment *comment = [self.tableViewHandler.resultsController objectAtIndexPath:nextIndexPath]; + [self.commentDetailViewController displayComment:comment isLastInList:[self isLastRow:nextIndexPath]]; + self.displayedCommentIndexPath = nextIndexPath; +} #pragma mark - WPTableViewHandlerDelegate Methods @@ -341,40 +396,23 @@ - (NSManagedObjectContext *)managedObjectContext - (NSFetchRequest *)fetchRequest { - NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:[self entityName]]; - fetchRequest.predicate = [NSPredicate predicateWithFormat:@"(blog == %@ AND status != %@)", self.blog, CommentStatusSpam]; - - NSSortDescriptor *sortDescriptorStatus = [NSSortDescriptor sortDescriptorWithKey:@"status" ascending:NO]; - NSSortDescriptor *sortDescriptorDate = [NSSortDescriptor sortDescriptorWithKey:@"dateCreated" ascending:NO]; - fetchRequest.sortDescriptors = @[sortDescriptorStatus, sortDescriptorDate]; - fetchRequest.fetchBatchSize = CommentsFetchBatchSize; + NSFetchRequest *fetchRequest = [NSFetchRequest fetchRequestWithEntityName:[self entityName]]; + + // CommentService purges Comments that do not belong to the current filter. + fetchRequest.predicate = [self predicateForFetchRequest:self.currentStatusFilter]; + + NSSortDescriptor *sortDescriptorDate = [NSSortDescriptor sortDescriptorWithKey:@"dateCreated" ascending:NO]; + fetchRequest.sortDescriptors = @[sortDescriptorDate]; + fetchRequest.fetchBatchSize = CommentsFetchBatchSize; return fetchRequest; } -- (void)configureCell:(CommentsTableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath +/// Configures a `ListTableViewCell` instance with a `Comment` object. +- (void)configureListCell:(nonnull ListTableViewCell *)cell atIndexPath:(nonnull NSIndexPath *)indexPath { - NSParameterAssert(cell); - NSParameterAssert(indexPath); - - Comment *comment = [self.tableViewHandler.resultsController objectAtIndexPath:indexPath]; - - cell.author = comment.authorForDisplay; - cell.approved = [comment.status isEqualToString:CommentStatusApproved]; - cell.postTitle = comment.titleForDisplay; - cell.content = comment.contentPreviewForDisplay; - cell.timestamp = [comment.dateCreated mediumString]; - - // Don't download the gravatar, if it's the layout cell! - if ([cell.reuseIdentifier isEqualToString:CommentsLayoutIdentifier]) { - return; - } - - if (comment.avatarURLForDisplay) { - [cell downloadGravatarWithURL:comment.avatarURLForDisplay]; - } else { - [cell downloadGravatarWithGravatarEmail:comment.gravatarEmailForDisplay]; - } + Comment *comment = [self.tableViewHandler.resultsController objectAtIndexPath:indexPath]; + [cell configureWithComment:comment]; } - (NSString *)entityName @@ -392,74 +430,103 @@ - (void)deletingSelectedRowAtIndexPath:(NSIndexPath *)indexPath [self.navigationController popToViewController:self animated:YES]; } +- (NSString *)sectionNameKeyPath +{ + return @"relativeDateSectionIdentifier"; +} + +- (void)configureCell:(nonnull UITableViewCell *)cell atIndexPath:(nonnull NSIndexPath *)indexPath +{ + /// No implementation needed here; This method is added to remove protocol conformance warnings. + /// + /// Note that `WPTableViewHandler` will prioritize `tableView:cellForRowAtIndexPath:` when it is available. + /// We're not using the `configureCell` method because the handler only dequeues cell with `DefaultCellIdentifier` for this method. +} + +#pragma mark - Predicate Wrangling + +- (void)updateFetchRequestPredicate:(CommentStatusFilter)statusFilter +{ + NSPredicate *predicate = [self predicateForFetchRequest:statusFilter]; + NSFetchedResultsController *resultsController = [[self tableViewHandler] resultsController]; + [[resultsController fetchRequest] setPredicate:predicate]; + NSError *error; + [resultsController performFetch:&error]; + [self.tableView reloadData]; +} + +- (NSPredicate *)predicateForFetchRequest:(CommentStatusFilter)statusFilter +{ + NSPredicate *predicate; + if (statusFilter == CommentStatusFilterAll && ![self isUnrepliedFilterSelected:self.filterTabBar]) { + predicate = [NSPredicate predicateWithFormat:@"(blog == %@)", self.blog]; + } else { + // Exclude any local replies from all filters except all. + predicate = [NSPredicate predicateWithFormat:@"(blog == %@) AND commentID != nil", self.blog]; + } + return predicate; +} #pragma mark - WPContentSyncHelper Methods - (void)syncHelper:(WPContentSyncHelper *)syncHelper syncContentWithUserInteraction:(BOOL)userInteraction success:(void (^)(BOOL))success failure:(void (^)(NSError *))failure { - NSManagedObjectContext *context = [[ContextManager sharedInstance] newDerivedContext]; - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:context]; - NSManagedObjectID *blogObjectID = self.blog.objectID; - + [self refreshNoResultsView]; + __typeof(self) __weak weakSelf = self; - [context performBlock:^{ - Blog *blogInContext = (Blog *)[context existingObjectWithID:blogObjectID error:nil]; - if (!blogInContext) { - return; + BOOL filterUnreplied = [self isUnrepliedFilterSelected:self.filterTabBar]; + + CommentService *commentService = [[CommentService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + [commentService syncCommentsForBlog:self.blog + withStatus:self.currentStatusFilter + filterUnreplied:filterUnreplied + success:^(BOOL hasMore) { + if (success) { + weakSelf.cachedStatusFilter = weakSelf.currentStatusFilter; + dispatch_async(dispatch_get_main_queue(), ^{ + success(hasMore); + }); } - - [commentService syncCommentsForBlog:blogInContext - success:^(BOOL hasMore) { - if (success) { - dispatch_async(dispatch_get_main_queue(), ^{ - success(hasMore); - }); - } - } - failure:^(NSError *error) { - if (failure) { - dispatch_async(dispatch_get_main_queue(), ^{ - failure(error); - }); - } - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [weakSelf refreshPullToRefresh]; - }); - - dispatch_async(dispatch_get_main_queue(), ^{ - [weakSelf handleConnectionError]; - }); - }]; + } + failure:^(NSError *error) { + if (failure) { + dispatch_async(dispatch_get_main_queue(), ^{ + failure(error); + }); + } + + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf.footerActivityIndicator stopAnimating]; + [weakSelf refreshNoConnectionView]; + }); }]; } - (void)syncHelper:(WPContentSyncHelper *)syncHelper syncMoreWithSuccess:(void (^)(BOOL))success failure:(void (^)(NSError *))failure { - NSManagedObjectContext *context = [[ContextManager sharedInstance] newDerivedContext]; - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:context]; - [context performBlock:^{ - Blog *blogInContext = (Blog *)[context existingObjectWithID:self.blog.objectID error:nil]; - if (!blogInContext) { - return; + __typeof(self) __weak weakSelf = self; + + CommentService *commentService = [[CommentService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + [commentService loadMoreCommentsForBlog:self.blog + withStatus:self.currentStatusFilter + success:^(BOOL hasMore) { + if (success) { + dispatch_async(dispatch_get_main_queue(), ^{ + success(hasMore); + }); } - - [commentService loadMoreCommentsForBlog:blogInContext - success:^(BOOL hasMore) { - if (success) { - dispatch_async(dispatch_get_main_queue(), ^{ - success(hasMore); - }); - } - } - failure:^(NSError *error) { - if (failure) { - dispatch_async(dispatch_get_main_queue(), ^{ - failure(error); - }); - } - }]; + } + failure:^(NSError *error) { + if (failure) { + dispatch_async(dispatch_get_main_queue(), ^{ + failure(error); + }); + } + + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf.footerActivityIndicator stopAnimating]; + }); }]; [self refreshInfiniteScroll]; @@ -482,16 +549,57 @@ - (BOOL)contentIsEmpty - (void)refreshAndSyncWithInteraction { + if (!ReachabilityUtils.isInternetReachable) { + [self refreshPullToRefresh]; + [self refreshNoConnectionView]; + return; + } + [self.syncHelper syncContentWithUserInteraction]; } - (void)refreshAndSyncIfNeeded { - if ([CommentService shouldRefreshCacheFor:self.blog]) { + if (self.blog) { [self.syncHelper syncContent]; } } +- (void)refreshWithStatusFilter:(CommentStatusFilter)statusFilter +{ + [self updateFetchRequestPredicate:statusFilter]; + [self saveSelectedFilterToUserDefaults]; + self.currentStatusFilter = statusFilter; + [self refreshWithAnimationIfNeeded]; +} + +- (void)refreshWithAnimationIfNeeded +{ + // If the refresh control is already active skip the animation. + if (self.tableView.refreshControl.refreshing) { + [self refreshAndSyncWithInteraction]; + return; + } + + // If the tableView is scrolled down skip the animation. + if (self.tableView.contentOffset.y > 60) { + [self.tableView.refreshControl beginRefreshing]; + [self refreshAndSyncWithInteraction]; + return; + } + + // Just telling the refreshControl to beginRefreshing can look jarring. + // Make it nicer by animating the tableView into position before starting + // the spinner and syncing. + [self.tableView layoutIfNeeded]; // Necessary to ensure a smooth start. + [UIView animateWithDuration:0.25 animations:^{ + self.tableView.contentOffset = CGPointMake(0, -60); + } completion:^(BOOL __unused finished) { + [self.tableView.refreshControl beginRefreshing]; + [self refreshAndSyncWithInteraction]; + }]; +} + - (void)refreshInfiniteScroll { NSParameterAssert(self.footerView); @@ -507,42 +615,190 @@ - (void)refreshInfiniteScroll - (void)refreshPullToRefresh { - if (self.refreshControl.isRefreshing) { - [self.refreshControl endRefreshing]; + if (self.tableView.refreshControl.isRefreshing) { + [self.tableView.refreshControl endRefreshing]; } } +#pragma mark - No Results Views + +- (void)initializeNoResultsViews +{ + self.noResultsViewController = [NoResultsViewController controller]; + self.noConnectionViewController = [NoResultsViewController controller]; +} + - (void)refreshNoResultsView { + if (![self contentIsEmpty]) { + [self.tableView setHidden:NO]; + [self.noResultsViewController removeFromView]; + return; + } + [self.noResultsViewController removeFromView]; + [self configureNoResults:self.noResultsViewController forNoConnection:NO]; + [self addChildViewController:self.noResultsViewController]; + [self adjustNoResultViewPlacement]; + [self.tableView addSubview:self.noResultsViewController.view]; - if (![self contentIsEmpty]) { + [self.noResultsViewController didMoveToParentViewController:self]; +} + +- (void)adjustNoResultViewPlacement +{ + // calling this too early results in wrong tableView frame used for initial state. + // ensure that either the NRV or the table view is visible. Otherwise, skip the adjustment to prevent misplacements. + if (!self.noResultsViewController.view.window && !self.tableView.window) { + return; + } + + // Adjust the NRV placement to accommodate for the filterTabBar. + CGRect noResultsFrame = self.tableView.frame; + noResultsFrame.origin.y = 0; + self.noResultsViewController.view.frame = noResultsFrame; +} + +- (void)refreshNoConnectionView +{ + if (ReachabilityUtils.isInternetReachable) { + [self.tableView setHidden:NO]; + [self.noConnectionViewController removeFromView]; + [self refreshAndSyncIfNeeded]; + + return; + } + + // Show cached results instead of No Connection view. + if (self.cachedStatusFilter == self.currentStatusFilter) { + [self.tableView setHidden:NO]; + [self.noConnectionViewController removeFromView]; + + return; + } + + // No Connection is already being shown. + if (self.noConnectionViewController.parentViewController) { return; } - [self.noResultsViewController configureWithTitle:self.noResultsViewTitle - noConnectionTitle:nil - buttonTitle:nil - subtitle:nil - noConnectionSubtitle:nil - attributedSubtitle:nil - attributedSubtitleConfiguration:nil - image:nil - subtitleImage:nil - accessoryView:nil]; + [self.noConnectionViewController removeFromView]; + [self configureNoResults:self.noConnectionViewController forNoConnection:YES]; + self.noConnectionViewController.delegate = self; + + // Because the table shows cached results from the last successful filter, + // some comments can appear below the No Connection view. + // So hide the table when showing No Connection. + [self.tableView setHidden:YES]; + [self addChildViewController:self.noConnectionViewController]; + [self.view insertSubview:self.noConnectionViewController.view belowSubview:self.filterTabBar]; + self.noConnectionViewController.view.frame = self.tableView.frame; + [self.noConnectionViewController didMoveToParentViewController:self]; +} + +- (void)configureNoResults:(NoResultsViewController *)viewController forNoConnection:(BOOL)forNoConnection { + [viewController configureWithTitle:self.noResultsTitle + attributedTitle:nil + noConnectionTitle:nil + buttonTitle:forNoConnection ? self.retryButtonTitle : nil + subtitle:nil + noConnectionSubtitle:nil + attributedSubtitle:nil + attributedSubtitleConfiguration:nil + image:@"wp-illustration-empty-results" + subtitleImage:nil + accessoryView:[self loadingAccessoryView]]; - [self addChildViewController:self.noResultsViewController]; - [self.tableView addSubviewWithFadeAnimation:self.noResultsViewController.view]; - self.noResultsViewController.view.frame = self.tableView.frame; - - // Adjust the NRV placement based on the tableHeader to accommodate for the refreshControl. - if (!self.tableView.tableHeaderView) { - CGRect noResultsFrame = self.noResultsViewController.view.frame; - noResultsFrame.origin.y -= self.refreshControl.frame.size.height; - self.noResultsViewController.view.frame = noResultsFrame; + viewController.delegate = self; +} + +- (NSString *)noResultsTitle +{ + if (self.syncHelper.isSyncing) { + return NSLocalizedString(@"Fetching comments...", + @"A brief prompt shown when the comment list is empty, letting the user know the app is currently fetching new comments."); + } + + return NSLocalizedString(@"No comments yet", @"Displayed when there are no comments in the Comments views."); +} + +- (NSString *)retryButtonTitle +{ + return NSLocalizedString(@"Retry", comment: "A prompt to attempt the failed network request again."); +} + +- (UIView *)loadingAccessoryView +{ + if (self.syncHelper.isSyncing) { + return [NoResultsViewController loadingAccessoryView]; + } + + return nil; +} + +#pragma mark - NoResultsViewControllerDelegate + +- (void)actionButtonPressed +{ + // The action button is only shown on the No Connection view. + [self refreshNoConnectionView]; +} + +#pragma mark - CommentDetailsDelegate + +- (void)nextCommentSelected +{ + [self showNextComment]; +} + +#pragma mark - State Restoration + ++ (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)identifierComponents coder:(NSCoder *)coder +{ + NSString *blogID = [coder decodeObjectForKey:RestorableBlogIdKey]; + if (!blogID) { + return nil; } - [self.noResultsViewController didMoveToParentViewController:self]; + NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; + NSManagedObjectID *objectID = [context.persistentStoreCoordinator managedObjectIDForURIRepresentation:[NSURL URLWithString:blogID]]; + if (!objectID) { + return nil; + } + + NSError *error = nil; + Blog *blog = (Blog *)[context existingObjectWithID:objectID error:&error]; + if (error || !blog) { + return nil; + } + + return [CommentsViewController controllerWithBlog:blog]; +} + +- (void)encodeRestorableStateWithCoder:(NSCoder *)coder +{ + [coder encodeObject:[[self.blog.objectID URIRepresentation] absoluteString] forKey:RestorableBlogIdKey]; + [coder encodeInteger:[self getSelectedIndex:self.filterTabBar] forKey:RestorableFilterIndexKey]; + [super encodeRestorableStateWithCoder:coder]; +} + +- (void)decodeRestorableStateWithCoder:(NSCoder *)coder +{ + [self setSeletedIndex:[coder decodeIntegerForKey:RestorableFilterIndexKey] filterTabBar:self.filterTabBar]; + [super decodeRestorableStateWithCoder:coder]; +} + +#pragma mark - User Defaults + +- (void)saveSelectedFilterToUserDefaults +{ + [NSUserDefaults.standardUserDefaults setInteger:[self getSelectedIndex:self.filterTabBar] forKey:RestorableFilterIndexKey]; +} + +- (void)getSelectedFilterFromUserDefaults +{ + NSInteger filterIndex = [NSUserDefaults.standardUserDefaults integerForKey:RestorableFilterIndexKey] ?: 0; + [self setSeletedIndex:filterIndex filterTabBar:self.filterTabBar]; } @end diff --git a/WordPress/Classes/ViewRelated/Comments/ContentRenderer/CommentContentRenderer.swift b/WordPress/Classes/ViewRelated/Comments/ContentRenderer/CommentContentRenderer.swift new file mode 100644 index 000000000000..6f4c138a2590 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/ContentRenderer/CommentContentRenderer.swift @@ -0,0 +1,26 @@ +/// Defines methods related to Comment content rendering. +/// +protocol CommentContentRenderer { + var delegate: CommentContentRendererDelegate? { get set } + + init(comment: Comment) + + /// Returns a view component that's configured to display the formatted content of the comment. + /// + /// Note that the renderer *might* return a view with the wrong sizing at first, but it should update its delegate with the correct height + /// through the `renderer(_:asyncRenderCompletedWithHeight:)` method. + func render() -> UIView + + /// Checks if the provided comment contains the same content as the current one that's processed by the renderer. + /// This is used as an optimization strategy by the caller to skip view creations if the rendered content matches the one provided in the parameter. + func matchesContent(from comment: Comment) -> Bool +} + +protocol CommentContentRendererDelegate: AnyObject { + /// Called when the rendering process completes. Note that this method is only called when using complex rendering methods that involves + /// asynchronous operations, so the container can readjust its size at a later time. + func renderer(_ renderer: CommentContentRenderer, asyncRenderCompletedWithHeight height: CGFloat) + + /// Called whenever the user interacts with a URL within the rendered content. + func renderer(_ renderer: CommentContentRenderer, interactedWithURL url: URL) +} diff --git a/WordPress/Classes/ViewRelated/Comments/ContentRenderer/RichCommentContentRenderer.swift b/WordPress/Classes/ViewRelated/Comments/ContentRenderer/RichCommentContentRenderer.swift new file mode 100644 index 000000000000..cd5e31923d26 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/ContentRenderer/RichCommentContentRenderer.swift @@ -0,0 +1,82 @@ +/// Renders the comment body through `WPRichContentView`. +/// +class RichCommentContentRenderer: NSObject, CommentContentRenderer { + weak var delegate: CommentContentRendererDelegate? + + weak var richContentDelegate: WPRichContentViewDelegate? = nil + + private let comment: Comment + + required init(comment: Comment) { + self.comment = comment + } + + func render() -> UIView { + let textView = newRichContentView() + textView.attributedText = WPRichContentView.formattedAttributedStringForString(comment.content) + textView.delegate = self + + return textView + } + + func matchesContent(from comment: Comment) -> Bool { + return self.comment.content == comment.content + } +} + +// MARK: - WPRichContentViewDelegate + +extension RichCommentContentRenderer: WPRichContentViewDelegate { + func richContentView(_ richContentView: WPRichContentView, didReceiveImageAction image: WPRichTextImage) { + richContentDelegate?.richContentView(richContentView, didReceiveImageAction: image) + } + + func interactWith(URL: URL) { + delegate?.renderer(self, interactedWithURL: URL) + } + + func richContentViewShouldUpdateLayoutForAttachments(_ richContentView: WPRichContentView) -> Bool { + richContentDelegate?.richContentViewShouldUpdateLayoutForAttachments?(richContentView) ?? false + } + + func richContentViewDidUpdateLayoutForAttachments(_ richContentView: WPRichContentView) { + richContentDelegate?.richContentViewDidUpdateLayoutForAttachments?(richContentView) + } +} + +// MARK: - Private Helpers + +private extension RichCommentContentRenderer { + struct Constants { + // Because a stackview is managing layout we tweak text insets to fine tune things. + static let textViewInsets = UIEdgeInsets(top: -8, left: -4, bottom: -24, right: 0) + } + + func newRichContentView() -> WPRichContentView { + let newTextView = WPRichContentView(frame: .zero, textContainer: nil) + newTextView.translatesAutoresizingMaskIntoConstraints = false + newTextView.isScrollEnabled = false + newTextView.isEditable = false + newTextView.backgroundColor = .clear + newTextView.mediaHost = mediaHost + newTextView.textContainerInset = Constants.textViewInsets + + return newTextView + } + + var mediaHost: MediaHost { + if let blog = comment.blog { + return MediaHost(with: blog, failure: { error in + // We'll log the error, so we know it's there, but we won't halt execution. + WordPressAppDelegate.crashLogging?.logError(error) + }) + } else if let post = comment.post as? ReaderPost, post.isPrivate() { + return MediaHost(with: post, failure: { error in + // We'll log the error, so we know it's there, but we won't halt execution. + WordPressAppDelegate.crashLogging?.logError(error) + }) + } + + return .publicSite + } +} diff --git a/WordPress/Classes/ViewRelated/Comments/ContentRenderer/WebCommentContentRenderer.swift b/WordPress/Classes/ViewRelated/Comments/ContentRenderer/WebCommentContentRenderer.swift new file mode 100644 index 000000000000..1f1815a968d5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/ContentRenderer/WebCommentContentRenderer.swift @@ -0,0 +1,184 @@ +import WebKit + +/// Renders the comment body with a web view. Provides the best visual experience but has the highest performance cost. +/// +class WebCommentContentRenderer: NSObject, CommentContentRenderer { + + // MARK: Properties + + weak var delegate: CommentContentRendererDelegate? + + private let comment: Comment + + private let webView = WKWebView(frame: .zero) + + /// Used to determine whether the cache is still valid or not. + private var commentContentCache: String? = nil + + /// Caches the HTML content, to be reused when the orientation changed. + private var htmlContentCache: String? = nil + + // MARK: Methods + + required init(comment: Comment) { + self.comment = comment + } + + func render() -> UIView { + // Do not reload if the content doesn't change. + if let contentCache = commentContentCache, contentCache == comment.content { + return webView + } + + webView.translatesAutoresizingMaskIntoConstraints = false + webView.navigationDelegate = self + webView.scrollView.bounces = false + webView.scrollView.showsVerticalScrollIndicator = false + webView.isOpaque = false // gets rid of the white flash upon content load in dark mode. + webView.configuration.allowsInlineMediaPlayback = true + webView.loadHTMLString(formattedHTMLString(for: comment.content), baseURL: Self.resourceURL) + + return webView + } + + func matchesContent(from comment: Comment) -> Bool { + // if content cache is still nil, then the comment hasn't been rendered yet. + guard let contentCache = commentContentCache else { + return false + } + + return contentCache == comment.content + } +} + +// MARK: - WKNavigationDelegate + +extension WebCommentContentRenderer: WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + // Wait until the HTML document finished loading. + // This also waits for all of resources within the HTML (images, video thumbnail images) to be fully loaded. + webView.evaluateJavaScript("document.readyState") { complete, _ in + guard complete != nil else { + return + } + + // To capture the content height, the methods to use is either `document.body.scrollHeight` or `document.documentElement.scrollHeight`. + // `document.body` does not capture margins on <body> tag, so we'll use `document.documentElement` instead. + webView.evaluateJavaScript("document.documentElement.scrollHeight") { height, _ in + guard let height = height as? CGFloat else { + return + } + + // reset the webview to opaque again so the scroll indicator is visible. + webView.isOpaque = true + self.delegate?.renderer(self, asyncRenderCompletedWithHeight: height) + } + } + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + switch navigationAction.navigationType { + case .other: + // allow local file requests. + decisionHandler(.allow) + default: + decisionHandler(.cancel) + guard let destinationURL = navigationAction.request.url else { + return + } + + self.delegate?.renderer(self, interactedWithURL: destinationURL) + } + } +} + +// MARK: - Private Methods + +private extension WebCommentContentRenderer { + struct Constants { + static let emptyElementRegexPattern = "<[a-z]+>(<!-- [a-zA-Z0-9\\/: \"{}\\-\\.,\\?=\\[\\]]+ -->)+<\\/[a-z]+>" + + static let highlightColor = UIColor(light: .primary, dark: .muriel(color: .primary, .shade30)) + + static let mentionBackgroundColor: UIColor = { + var darkColor = UIColor.muriel(color: .primary, .shade90) + + if AppConfiguration.isWordPress { + darkColor = darkColor.withAlphaComponent(0.5) + } + + return UIColor(light: .muriel(color: .primary, .shade0), dark: darkColor) + }() + } + + /// Used for the web view's `baseURL`, to reference any local files (i.e. CSS) linked from the HTML. + static let resourceURL: URL? = { + Bundle.main.resourceURL + }() + + /// Cache the HTML template format. We only need read the template once. + static let htmlTemplateFormat: String? = { + guard let templatePath = Bundle.main.path(forResource: "richCommentTemplate", ofType: "html"), + let templateStringFormat = try? String(contentsOfFile: templatePath) else { + return nil + } + + return String(format: templateStringFormat, + Constants.highlightColor.lightVariant().cssRGBAString(), + Constants.highlightColor.darkVariant().cssRGBAString(), + Constants.mentionBackgroundColor.lightVariant().cssRGBAString(), + Constants.mentionBackgroundColor.darkVariant().cssRGBAString(), + "%@") + }() + + /// Returns a formatted HTML string by loading the template for rich comment. + /// + /// The method will try to return cached content if possible, by detecting whether the content matches the previous content. + /// If it's different (e.g. due to edits), it will reprocess the HTML string. + /// + /// - Parameter content: The content value from the `Comment` object. + /// - Returns: Formatted HTML string to be displayed in the web view. + /// + func formattedHTMLString(for content: String) -> String { + // return the previous HTML string if the comment content is unchanged. + if let previousCommentContent = commentContentCache, + let previousHTMLString = htmlContentCache, + previousCommentContent == content { + return previousHTMLString + } + + // otherwise: sanitize the content, cache it, and then return it. + guard let htmlTemplateFormat = Self.htmlTemplateFormat else { + DDLogError("WebCommentContentRenderer: Failed to load HTML template format for comment content.") + return String() + } + + // remove empty HTML elements from the `content`, as the content often contains empty paragraph elements which adds unnecessary padding/margin. + // `rawContent` does not have this problem, but it's not used because `rawContent` gets rid of links (<a> tags) for mentions. + let htmlContent = String(format: htmlTemplateFormat, content + .replacingOccurrences(of: Constants.emptyElementRegexPattern, with: String(), options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines)) + + // cache the contents. + commentContentCache = content + htmlContentCache = htmlContent + + return htmlContent + } +} + +private extension UIColor { + func cssRGBAString(customAlpha: CGFloat? = nil) -> String { + let red = Int(rgbaComponents.red * 255) + let green = Int(rgbaComponents.green * 255) + let blue = Int(rgbaComponents.blue * 255) + let alpha = { + guard let customAlpha, customAlpha <= 1.0 else { + return rgbaComponents.alpha + } + return customAlpha + }() + + return "rgba(\(red), \(green), \(blue), \(alpha))" + } +} diff --git a/WordPress/Classes/ViewRelated/Comments/EditCommentTableViewController.swift b/WordPress/Classes/ViewRelated/Comments/EditCommentTableViewController.swift new file mode 100644 index 000000000000..116ca1d1b44f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/EditCommentTableViewController.swift @@ -0,0 +1,299 @@ +import Foundation + + +class EditCommentTableViewController: UITableViewController { + + // MARK: - Properties + + private let comment: Comment + + private var updatedName: String? + private var updatedWebAddress: String? + private var updatedEmailAddress: String? + private var updatedContent: String? + + private var isEmailValid = true + + // If the textView cell is recreated via dequeueReusableCell, + // the cursor location is lost when the cell is scrolled off screen. + // So save and use one instance of the cell. + private let commentContentCell = InlineEditableMultiLineCell.loadFromNib() + + // A closure executed when the view is dismissed. + // Returns the Comment object and a Bool indicating if the Comment has been changed. + @objc var completion: ((Comment, Bool) -> Void)? + + // MARK: - Init + + convenience init(comment: Comment, completion: ((Comment, Bool) -> Void)? = nil) { + self.init(comment: comment) + self.completion = completion + } + + @objc required init(comment: Comment) { + self.comment = comment + updatedName = comment.author + updatedWebAddress = comment.author_url + updatedEmailAddress = comment.author_email + updatedContent = comment.contentForEdit() + super.init(style: .insetGrouped) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + configureCommentContentCell() + setupTableView() + setupNavBar() + addDismissKeyboardTapGesture() + } + + // MARK: - UITableViewDelegate + + override func numberOfSections(in tableView: UITableView) -> Int { + return TableSections.allCases.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return TableSections(rawValue: section)?.header + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let tableSection = TableSections(rawValue: indexPath.section) else { + DDLogError("Edit Comment: invalid table section.") + return UITableViewCell() + } + + // Comment content cell + if tableSection == TableSections.comment { + return commentContentCell + } + + // All other cells + guard let cell = tableView.dequeueReusableCell(withIdentifier: InlineEditableSingleLineCell.defaultReuseID) as? InlineEditableSingleLineCell else { + return UITableViewCell() + } + + let editingDisabled = !comment.canEditAuthorData() + + switch tableSection { + case TableSections.name: + cell.configure(text: updatedName, disabled: editingDisabled) + case TableSections.webAddress: + cell.configure(text: updatedWebAddress, style: .url, disabled: editingDisabled) + case TableSections.emailAddress: + cell.configure(text: updatedEmailAddress, style: .email, disabled: editingDisabled) + default: + DDLogError("Edit Comment: unsupported table section.") + break + } + + cell.delegate = self + return cell + } + + override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + if needToShowRegisteredUserNotice(for: section) { + return UITableView.automaticDimension + } + + return CGFloat.leastNormalMagnitude + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + if needToShowRegisteredUserNotice(for: section) { + return NSLocalizedString("\nThis user is registered. Their name, web address, and email address cannot be edited.", + comment: "Notice shown on the Edit Comment view when the author is a registered user.") + } + + return nil + } + + override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + if needToShowRegisteredUserNotice(for: section) { + return super.tableView(tableView, viewForFooterInSection: section) + } + + return nil + } + +} + +// MARK: - Private Extension + +private extension EditCommentTableViewController { + + // MARK: - View config + + func setupTableView() { + tableView.cellLayoutMarginsFollowReadableWidth = true + + tableView.register(InlineEditableSingleLineCell.defaultNib, + forCellReuseIdentifier: InlineEditableSingleLineCell.defaultReuseID) + + tableView.register(InlineEditableMultiLineCell.defaultNib, + forCellReuseIdentifier: InlineEditableMultiLineCell.defaultReuseID) + } + + func configureCommentContentCell() { + commentContentCell.configure(text: updatedContent) + commentContentCell.delegate = self + } + + func setupNavBar() { + title = NSLocalizedString("Edit Comment", comment: "View title when editing a comment.") + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped)) + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonTapped)) + updateDoneButton() + } + + func addDismissKeyboardTapGesture() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + tapGesture.cancelsTouchesInView = false + tableView.addGestureRecognizer(tapGesture) + } + + // MARK: - Nav bar button actions + + @objc func cancelButtonTapped(sender: UIBarButtonItem) { + guard commentHasChanged() else { + finishWithoutUpdates() + return + } + + showConfirmationAlert() + } + + @objc func doneButtonTapped(sender: UIBarButtonItem) { + finishWithUpdates() + } + + // MARK: - Tap gesture handling + + @objc func dismissKeyboard() { + view.endEditing(true) + } + + // MARK: - View dismissal handling + + func finishWithUpdates() { + comment.author = updatedName ?? "" + comment.author_url = updatedWebAddress ?? "" + comment.author_email = updatedEmailAddress ?? "" + comment.content = updatedContent ?? "" + completion?(comment, true) + dismiss(animated: true) + } + + + func finishWithoutUpdates() { + completion?(comment, false) + dismiss(animated: true) + } + + func showConfirmationAlert() { + let title = NSLocalizedString("You have unsaved changes.", comment: "Title of message with options that shown when there are unsaved changes and the author cancelled editing a Comment.") + let discardTitle = NSLocalizedString("Discard", comment: "Button shown if there are unsaved changes and the author cancelled editing a Comment.") + let keepEditingTitle = NSLocalizedString("Keep Editing", comment: "Button shown if there are unsaved changes and the author cancelled editing a Comment.") + + let alertController = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) + alertController.addCancelActionWithTitle(keepEditingTitle) + + alertController.addDestructiveActionWithTitle(discardTitle) { [weak self] action in + self?.finishWithoutUpdates() + } + + alertController.popoverPresentationController?.barButtonItem = navigationItem.leftBarButtonItem + present(alertController, animated: true, completion: nil) + } + + // MARK: - Helpers + + func updateDoneButton() { + navigationItem.rightBarButtonItem?.isEnabled = commentHasChanged() && isEmailValid + } + + func commentHasChanged() -> Bool { + return comment.author != updatedName || + comment.author_email != updatedEmailAddress || + comment.author_url != updatedWebAddress || + comment.contentForEdit() != updatedContent + } + + func needToShowRegisteredUserNotice(for section: Int) -> Bool { + // The notice is shown in the footer of the Email Address section + return !comment.canEditAuthorData() && TableSections(rawValue: section) == .emailAddress + } + + // MARK: - Table sections + + enum TableSections: Int, CaseIterable { + // The case order dictates the table row order. + case name + case webAddress + case emailAddress + case comment + + var header: String { + switch self { + case .name: + return NSLocalizedString("Name", comment: "Header for a comment author's name, shown when editing a comment.").localizedUppercase + case .webAddress: + return NSLocalizedString("Web Address", comment: "Header for a comment author's web address, shown when editing a comment.").localizedUppercase + case .emailAddress: + return NSLocalizedString("Email Address", comment: "Header for a comment author's email address, shown when editing a comment.").localizedUppercase + case .comment: + return NSLocalizedString("Comment", comment: "Header for a comment's content, shown when editing a comment.").localizedUppercase + } + } + } + +} + +extension EditCommentTableViewController: InlineEditableSingleLineCellDelegate { + + func textUpdatedForCell(_ cell: InlineEditableSingleLineCell) { + let updatedText = cell.textField.text?.trim() + + switch cell.textFieldStyle { + case .text: + updatedName = updatedText + case .url: + updatedWebAddress = updatedText + case .email: + updatedEmailAddress = updatedText + isEmailValid = { + if updatedEmailAddress == nil || updatedEmailAddress?.isEmpty == true { + return true + } + return cell.isValid + }() + cell.showInvalidState(!isEmailValid) + } + + updateDoneButton() + } + +} + +extension EditCommentTableViewController: InlineEditableMultiLineCellDelegate { + + func textViewHeightUpdatedForCell(_ cell: InlineEditableMultiLineCell) { + tableView.performBatchUpdates({}) + } + + func textUpdatedForCell(_ cell: InlineEditableMultiLineCell) { + updatedContent = cell.textView.text.trim() + updateDoneButton() + } + +} diff --git a/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.h b/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.h index 2f209392e998..dca895fb0a54 100644 --- a/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.h +++ b/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.h @@ -7,10 +7,17 @@ typedef void (^EditCommentCompletion)(BOOL hasNewContent, NSString *newContent); @property (nonatomic, strong) NSString *content; @property (nonatomic, assign) BOOL interfaceEnabled; @property (readonly, nonatomic, weak) IBOutlet UITextView *textView; +@property (readonly, nonatomic, weak) IBOutlet UILabel *placeholderLabel; +@property (readonly, nonatomic, assign) CGRect keyboardFrame; + (instancetype)newEditViewController; ++ (NSString *)nibName; /// Triggered to indicate the content of the text view has changed /// Automatically called when the user enters text into the `textView` - (void)contentDidChange; + +// Keyboard handlers +- (void)handleKeyboardDidShow:(NSNotification *)notification; +- (void)handleKeyboardWillHide:(NSNotification *)notification; @end diff --git a/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.m b/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.m index a095dfb6ea3d..812736cca2a6 100644 --- a/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.m +++ b/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.m @@ -1,29 +1,20 @@ #import "EditCommentViewController.h" -#import "CommentViewController.h" #import "CommentService.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import <WordPressUI/WordPressUI.h> #import "WordPress-Swift.h" - -#pragma mark ========================================================================================== -#pragma mark Constants -#pragma mark ========================================================================================== - -static UIEdgeInsets EditCommentInsetsPad = {5, 15, 5, 13}; -static UIEdgeInsets EditCommentInsetsPhone = {5, 10, 5, 11}; - - #pragma mark ========================================================================================== #pragma mark Private Methods #pragma mark ========================================================================================== @interface EditCommentViewController() -@property (readwrite, nonatomic, weak) IBOutlet UITextView *textView; +@property (readwrite, nonatomic, weak) IBOutlet UITextView *textView; +@property (readwrite, nonatomic, weak) IBOutlet UILabel *placeholderLabel; @property (nonatomic, strong) NSString *pristineText; -@property (nonatomic, assign) CGRect keyboardFrame; +@property (readwrite, nonatomic, assign) CGRect keyboardFrame; - (void)handleKeyboardDidShow:(NSNotification *)notification; - (void)handleKeyboardWillHide:(NSNotification *)notification; @@ -76,13 +67,10 @@ - (void)viewDidLoad [super viewDidLoad]; self.title = NSLocalizedString(@"Edit Comment", @""); - self.view.backgroundColor = [UIColor murielBasicBackground]; - - self.textView.font = [WPStyleGuide regularTextFont]; - self.textView.textContainerInset = [UIDevice isPad] ? EditCommentInsetsPad : EditCommentInsetsPhone; self.textView.backgroundColor = [UIColor murielBasicBackground]; self.textView.textColor = [UIColor murielText]; + self.placeholderLabel.textColor = [UIColor murielTextPlaceholder]; [self showCancelBarButton]; [self showSaveBarButton]; @@ -159,30 +147,29 @@ - (void)enableSaveIfNeeded - (void)handleKeyboardDidShow:(NSNotification *)notification { - NSTimeInterval animationDuration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] floatValue]; - - _keyboardFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; - _keyboardFrame = [self.view convertRect:_keyboardFrame fromView:self.view.window]; - - [UIView animateWithDuration:animationDuration animations:^{ - CGRect frm = self.textView.frame; - frm.size.height = CGRectGetMinY(self.keyboardFrame); - self.textView.frame = frm; - }]; + CGRect keyboardRect = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; + self.keyboardFrame = keyboardRect; + CGSize kbSize = keyboardRect.size; + + UIEdgeInsets contentInsets = UIEdgeInsetsMake(0.0, 0.0, kbSize.height, 0.0); + self.textView.contentInset = contentInsets; + self.textView.scrollIndicatorInsets = contentInsets; + + + // Scroll the active text field into view. + CGRect rect = [self.textView caretRectForPosition:self.textView.selectedTextRange.start]; + + [self.textView scrollRectToVisible:rect animated:NO]; } - (void)handleKeyboardWillHide:(NSNotification *)notification { - NSTimeInterval animationDuration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] floatValue]; - - [UIView animateWithDuration:animationDuration animations:^{ - CGRect frm = self.textView.frame; - frm.size.height = CGRectGetMaxY(self.view.bounds); - self.textView.frame = frm; - }]; + UIEdgeInsets contentInsets = UIEdgeInsetsMake(0.0, 0.0, 0, 0.0); + self.textView.contentInset = contentInsets; + self.textView.scrollIndicatorInsets = contentInsets; + self.keyboardFrame = CGRectZero; } - #pragma mark - Text View Delegate Methods - (void)textViewDidChange:(UITextView *)textView @@ -207,7 +194,7 @@ - (void)btnCancelPressed handler:nil]; [alertController addActionWithTitle:NSLocalizedString(@"Discard", @"") style:UIAlertActionStyleDestructive - handler:^(UIAlertAction *alertAction) { + handler:^(UIAlertAction * __unused alertAction) { [self finishWithoutUpdates]; }]; alertController.popoverPresentationController.barButtonItem = self.navigationItem.leftBarButtonItem; diff --git a/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.xib b/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.xib index af9f811098a8..77fb9a88c708 100644 --- a/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.xib +++ b/WordPress/Classes/ViewRelated/Comments/EditCommentViewController.xib @@ -1,15 +1,16 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useSafeAreas="YES" colorMatched="YES"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19162" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useSafeAreas="YES" colorMatched="YES"> <device id="retina6_5" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19144"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="EditCommentViewController"> <connections> + <outlet property="placeholderLabel" destination="JSO-Ox-i4X" id="1Bp-ly-7Hk"/> <outlet property="textView" destination="4" id="5"/> <outlet property="view" destination="1" id="3"/> </connections> @@ -19,24 +20,36 @@ <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <subviews> - <textView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" delaysContentTouches="NO" canCancelContentTouches="NO" bouncesZoom="NO" translatesAutoresizingMaskIntoConstraints="NO" id="4"> - <rect key="frame" x="0.0" y="44" width="414" height="818"/> - <fontDescription key="fontDescription" type="system" pointSize="17"/> + <textView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="250" showsHorizontalScrollIndicator="NO" delaysContentTouches="NO" canCancelContentTouches="NO" bouncesZoom="NO" textAlignment="natural" adjustsFontForContentSizeCategory="YES" translatesAutoresizingMaskIntoConstraints="NO" id="4"> + <rect key="frame" x="20" y="44" width="374" height="818"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> <connections> <outlet property="delegate" destination="-1" id="6"/> </connections> </textView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Placeholder" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JSO-Ox-i4X" userLabel="Placeholder Label"> + <rect key="frame" x="25" y="52" width="369" height="17"/> + <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + <size key="shadowOffset" width="-1" height="-1"/> + </label> </subviews> + <viewLayoutGuide key="safeArea" id="hd0-DM-uqE"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <constraints> - <constraint firstItem="4" firstAttribute="bottom" secondItem="hd0-DM-uqE" secondAttribute="bottom" id="RoP-nH-YdT"/> - <constraint firstItem="hd0-DM-uqE" firstAttribute="trailing" secondItem="4" secondAttribute="trailing" id="SJl-lD-GAb"/> - <constraint firstItem="4" firstAttribute="top" secondItem="hd0-DM-uqE" secondAttribute="top" id="iHT-Cc-3MN"/> - <constraint firstItem="4" firstAttribute="leading" secondItem="hd0-DM-uqE" secondAttribute="leading" id="jf7-Kx-yZn"/> + <constraint firstItem="hd0-DM-uqE" firstAttribute="trailing" secondItem="JSO-Ox-i4X" secondAttribute="trailing" constant="20" id="45P-3Y-0Nx"/> + <constraint firstItem="JSO-Ox-i4X" firstAttribute="leading" secondItem="hd0-DM-uqE" secondAttribute="leading" constant="25" id="JMN-iQ-CkE"/> + <constraint firstItem="hd0-DM-uqE" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="JSO-Ox-i4X" secondAttribute="bottom" id="NPH-li-HbF"/> + <constraint firstItem="hd0-DM-uqE" firstAttribute="bottom" secondItem="4" secondAttribute="bottom" id="OOw-O4-n5X"/> + <constraint firstItem="4" firstAttribute="top" secondItem="hd0-DM-uqE" secondAttribute="top" id="OVQ-ac-W8f"/> + <constraint firstItem="hd0-DM-uqE" firstAttribute="trailing" secondItem="4" secondAttribute="trailing" constant="20" id="SJl-lD-GAb"/> + <constraint firstItem="JSO-Ox-i4X" firstAttribute="top" secondItem="hd0-DM-uqE" secondAttribute="top" constant="8" id="Tmv-JE-Bnr"/> + <constraint firstItem="4" firstAttribute="leading" secondItem="hd0-DM-uqE" secondAttribute="leading" constant="20" id="jf7-Kx-yZn"/> </constraints> - <viewLayoutGuide key="safeArea" id="hd0-DM-uqE"/> - <point key="canvasLocation" x="-507" y="38"/> + <point key="canvasLocation" x="-1414" y="-66"/> </view> </objects> </document> diff --git a/WordPress/Classes/ViewRelated/Comments/FullScreenCommentReplyViewController.swift b/WordPress/Classes/ViewRelated/Comments/FullScreenCommentReplyViewController.swift index 151094c96bdc..b0dc2f2c782d 100644 --- a/WordPress/Classes/ViewRelated/Comments/FullScreenCommentReplyViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/FullScreenCommentReplyViewController.swift @@ -1,4 +1,3 @@ - import UIKit import Gridicons @@ -6,91 +5,178 @@ import Gridicons /// After instantiating using `newEdit()` the class expects the `content` and `onExitFullscreen` /// properties to be set. -class FullScreenCommentReplyViewController: EditCommentViewController { - private struct Parameters { - /// Determines the size of the replyButton - static let replyButtonIconSize = CGSize(width: 21, height: 18) - } + +/// Keeps track of the position of the suggestions view +fileprivate enum SuggestionsPosition: Int { + case hidden + case top + case bottom +} + +public class FullScreenCommentReplyViewController: EditCommentViewController, SuggestionsTableViewDelegate { /// The completion block that is called when the view is exiting fullscreen /// - Parameter: Bool, whether or not the calling view should trigger a save /// - Parameter: String, the updated comment content - public var onExitFullscreen: ((Bool, String) -> ())? + /// - Parameter: String, the last search text used to show the suggestions list. + public var onExitFullscreen: ((Bool, String, String?) -> ())? /// The save/reply button that is displayed in the rightBarButtonItem position - private(set) var replyButton: UIButton! + private(set) var replyButton: UIBarButtonItem! - // MARK: - View Methods - override func viewDidLoad() { - super.viewDidLoad() + /// Reply Suggestions + private var siteID: NSNumber? + private var prominentSuggestionsIds: [NSNumber]? + private var suggestionsTableView: SuggestionsTableView? + private var searchText: String? + + private var viewModel: FullScreenCommentReplyViewModelType - title = NSLocalizedString("Comment", comment: "User facing, navigation bar title") + // Static margin between the suggestions view and the text cursor position + private let suggestionViewMargin: CGFloat = 5 - setupReplyButton() + public var placeholder = String() + + init(viewModel: FullScreenCommentReplyViewModelType = FullScreenCommentReplyViewModel()) { + self.viewModel = viewModel + super.init(nibName: Self.nibName(), bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Methods + public override func viewDidLoad() { + super.viewDidLoad() + placeholderLabel.text = placeholder setupNavigationItems() - configureAppearance() + configureNavigationAppearance() } - override func viewDidAppear(_ animated: Bool) { + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + refreshPlaceholder() + refreshReplyButton() + } + + public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + setupSuggestionsTableViewIfNeeded() + showSuggestionsViewIfNeeded() - enableRefreshButtonIfNeeded(animated: false) + WPAnalytics.track(.commentFullScreenEntered) } - // MARK: - UITextViewDelegate - override func textViewDidBeginEditing(_ textView: UITextView) { } - override func textViewDidEndEditing(_ textView: UITextView) { } + // MARK: - Public Methods - override func contentDidChange() { - enableRefreshButtonIfNeeded() + /// Enables the @ mention suggestions while editing + /// - Parameter siteID: The ID of the site to determine if suggestions are enabled or not + /// - Parameter prominentSuggestionsIds: The suggestions ids to display at the top of the suggestions list. + /// - Parameter searchText: The last search text used to show the suggestions list. + @objc func enableSuggestions(with siteID: NSNumber, prominentSuggestionsIds: [NSNumber]?, searchText: String?) { + self.siteID = siteID + self.prominentSuggestionsIds = prominentSuggestionsIds + self.searchText = searchText } - // MARK: - Actions - @objc func btnSavePressed() { - exitFullscreen(shouldSave: true) + /// Description + private func setupSuggestionsTableViewIfNeeded() { + guard let siteID = siteID, shouldShowSuggestions else { + return + } + suggestionsTableView = viewModel.suggestionsTableView( + with: siteID, + useTransparentHeader: true, + prominentSuggestionsIds: prominentSuggestionsIds, + delegate: self + ) + + attachSuggestionsViewIfNeeded() } - @objc func btnExitFullscreenPressed() { - exitFullscreen(shouldSave: false) + private func showSuggestionsViewIfNeeded() { + guard let searchText = searchText, !searchText.isEmpty else { + return + } + suggestionsTableView?.showSuggestions(forWord: searchText) } - // MARK: - Private: Helpers + // MARK: - UITextViewDelegate + public override func textViewDidBeginEditing(_ textView: UITextView) { } + public override func textViewDidEndEditing(_ textView: UITextView) { } - /// Updates the iOS 13 title color - private func configureAppearance() { - if #available(iOS 13.0, *) { - guard let navigationBar = navigationController?.navigationBar else { - return - } + public override func contentDidChange() { + refreshPlaceholder() + refreshReplyButton() + } + + public override func textViewDidChangeSelection(_ textView: UITextView) { + if didChangeText { + //If the didChangeText flag is true, reset it here + didChangeText = false + return + } - navigationBar.standardAppearance.titleTextAttributes = [ - NSAttributedString.Key.foregroundColor: UIColor.text - ] + //If the user just changes the selection, then hide the suggestions + suggestionsTableView?.hideSuggestions() + } + + + public override func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + guard shouldShowSuggestions else { + return true } + + let textViewText: NSString = textView.text as NSString + let prerange = NSMakeRange(0, range.location) + let pretext = textViewText.substring(with: prerange) + text + let words = pretext.components(separatedBy: CharacterSet.whitespacesAndNewlines) + let lastWord: NSString = words.last! as NSString + + didTypeWord(lastWord as String) + + didChangeText = true + return true + } + + private func didTypeWord(_ word: String) { + guard let tableView = suggestionsTableView else { + return + } + + tableView.showSuggestions(forWord: word) } - /// Creates the `replyButton` to be used as the `rightBarButtonItem` - private func setupReplyButton() { - replyButton = { - let iconSize = Parameters.replyButtonIconSize - let replyIcon = UIImage(named: "icon-comment-reply") + // MARK: - Actions + @objc func btnSavePressed() { + exitFullscreen(shouldSave: true) + } - let button = UIButton(frame: CGRect(origin: CGPoint(x: 0, y: 0), size: iconSize)) - button.setImage(replyIcon?.imageWithTintColor(WPStyleGuide.Reply.enabledColor), for: .normal) - button.setImage(replyIcon?.imageWithTintColor(WPStyleGuide.Reply.disabledColor), for: .disabled) - button.accessibilityLabel = NSLocalizedString("Reply", comment: "Accessibility label for the reply button") - button.isEnabled = false - button.addTarget(self, action: #selector(btnSavePressed), for: .touchUpInside) + @objc func btnExitFullscreenPressed() { + exitFullscreen(shouldSave: false) + } - return button - }() + // MARK: - Private: Helpers + + private func configureNavigationAppearance() { + // Remove the title + title = "" + + // Hide the bottom line on the navigation bar + let appearance = navigationController?.navigationBar.standardAppearance ?? UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundColor = .basicBackground + appearance.shadowImage = UIImage() + navigationItem.standardAppearance = appearance + navigationItem.compactAppearance = appearance } /// Creates the `leftBarButtonItem` and the `rightBarButtonItem` private func setupNavigationItems() { navigationItem.leftBarButtonItem = ({ - let image = Gridicon.iconOfType(.chevronDown).imageWithTintColor(.listIcon) + let image = UIImage.gridicon(.chevronDown).imageWithTintColor(.primary) let leftItem = UIBarButtonItem(image: image, style: .plain, target: self, @@ -102,55 +188,181 @@ class FullScreenCommentReplyViewController: EditCommentViewController { })() navigationItem.rightBarButtonItem = ({ - let rightItem = UIBarButtonItem(customView: replyButton) - - if let customView = rightItem.customView { - let iconSize = Parameters.replyButtonIconSize - - customView.widthAnchor.constraint(equalToConstant: iconSize.width).isActive = true - customView.heightAnchor.constraint(equalToConstant: iconSize.height).isActive = true - } - + let rightItem = UIBarButtonItem(title: NSLocalizedString("Reply", comment: "Reply to a comment."), + style: .plain, + target: self, + action: #selector(btnSavePressed)) + + rightItem.accessibilityLabel = NSLocalizedString("Reply", comment: "Accessibility label for the reply button") + rightItem.isEnabled = false + replyButton = rightItem return rightItem })() } /// Changes the `refreshButton` enabled state - /// - Parameter animated: Whether or not the state change should be animated - fileprivate func enableRefreshButtonIfNeeded(animated: Bool = true) { - let whitespaceCharSet = CharacterSet.whitespacesAndNewlines - let isEnabled = textView.text.trimmingCharacters(in: whitespaceCharSet).isEmpty == false + private func refreshReplyButton() { + replyButton.isEnabled = !(textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + private func refreshPlaceholder() { + placeholderLabel.isHidden = !textView.text.isEmpty + } - if isEnabled == replyButton.isEnabled { + /// Triggers the `onExitFullscreen` completion handler + /// - Parameter shouldSave: Whether or not the updated text should trigger a save + private func exitFullscreen(shouldSave: Bool) { + guard let completion = onExitFullscreen else { return } - let setEnabled = { - self.replyButton.isEnabled = isEnabled + let updatedText = textView.text ?? "" + + let lastSuggestionsSearchText = shouldSave ? nil : suggestionsTableView?.viewModel.searchText + + completion(shouldSave, updatedText, lastSuggestionsSearchText) + WPAnalytics.track(.commentFullScreenExited) + } + + var suggestionsTop: NSLayoutConstraint! + + fileprivate var initialSuggestionsPosition: SuggestionsPosition = .hidden + fileprivate var didChangeText: Bool = false +} + +// MARK: - SuggestionsTableViewDelegate +// +public extension FullScreenCommentReplyViewController { + func suggestionsTableView(_ suggestionsTableView: SuggestionsTableView, didSelectSuggestion suggestion: String?, forSearchText text: String) { + replaceTextAtCaret(text as NSString?, withText: suggestion) + suggestionsTableView.showSuggestions(forWord: String()) + } + + func suggestionsTableView(_ suggestionsTableView: SuggestionsTableView, didChangeTableBounds bounds: CGRect) { + if suggestionsTableView.isHidden { + self.initialSuggestionsPosition = .hidden + } else { + self.repositionSuggestions() } + } - if animated == false { - setEnabled() + func suggestionsTableViewMaxDisplayedRows(_ suggestionsTableView: SuggestionsTableView) -> Int { + return 3 + } + + override func handleKeyboardDidShow(_ notification: Foundation.Notification?) { + super.handleKeyboardDidShow(notification) + + self.initialSuggestionsPosition = .hidden + self.repositionSuggestions() + } + + override func handleKeyboardWillHide(_ notification: Foundation.Notification?) { + super.handleKeyboardWillHide(notification) + + self.initialSuggestionsPosition = .hidden + self.repositionSuggestions() + } +} + +// MARK: - Suggestions View Helpers +// +private extension FullScreenCommentReplyViewController { + + /// Calculates a CGRect for the text caret and converts its value to the view's coordindate system + var absoluteTextCursorRect: CGRect { + let selectedRangeStart = textView.selectedTextRange?.start ?? UITextPosition() + var caretRect = textView.caretRect(for: selectedRangeStart) + caretRect = textView.convert(caretRect, to: view) + + return caretRect.integral + } + + func repositionSuggestions() { + guard let suggestions = suggestionsTableView else { return } - UIView.transition(with: replyButton as UIView, - duration: 0.2, - options: .transitionCrossDissolve, - animations: { - setEnabled() - }) + let caretRect = absoluteTextCursorRect + let margin = suggestionViewMargin + let suggestionsHeight = suggestions.frame.height + + + // Calculates the height of the view minus the keyboard if its visible + let calculatedViewHeight = (view.frame.height - keyboardFrame.height) + + var position: SuggestionsPosition = .bottom + + // Calculates the direction the suggestions view should appear + // And the global position + + // If the estimated position of the suggestion will appear below the bottom of the view + // then display it in the top position + if (caretRect.maxY + suggestionsHeight) > calculatedViewHeight { + position = .top + } + + // If the user is typing we don't want to change the position of the suggestions view + if position == initialSuggestionsPosition || initialSuggestionsPosition == .hidden { + initialSuggestionsPosition = position + } + + var constant: CGFloat = 0 + + switch initialSuggestionsPosition { + case .top: + constant = (caretRect.minY - suggestionsHeight - margin) + + case .bottom: + constant = caretRect.maxY + margin + + case .hidden: + constant = 0 + } + + suggestionsTop.constant = constant } - /// Triggers the `onExitFullscreen` completion handler - /// - Parameter shouldSave: Whether or not the updated text should trigger a save - private func exitFullscreen(shouldSave: Bool) { - guard let completion = onExitFullscreen else { + func attachSuggestionsViewIfNeeded() { + guard let tableView = suggestionsTableView else { return } - let updatedText = textView.text ?? "" + guard shouldShowSuggestions else { + tableView.removeFromSuperview() + return + } - completion(shouldSave, updatedText) + // We're adding directly to the navigation controller view to allow the suggestions to appear + // above the nav bar, this only happens on smaller screens when the keyboard is open + navigationController?.view.addSubview(tableView) + + suggestionsTop = tableView.topAnchor.constraint(equalTo: view.topAnchor, constant: 0) + + NSLayoutConstraint.activate([ + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + suggestionsTop, + ]) } + + + /// Determine if suggestions are enabled and visible for this site + var shouldShowSuggestions: Bool { + return viewModel.shouldShowSuggestions(with: siteID) + } + + // This should be moved elsewhere + func replaceTextAtCaret(_ text: NSString?, withText replacement: String?) { + guard let replacementText = replacement, + let textToReplace = text, + let selectedRange = textView.selectedTextRange, + let newPosition = textView.position(from: selectedRange.start, offset: -textToReplace.length), + let newRange = textView.textRange(from: newPosition, to: selectedRange.start) else { + return + } + + textView.replace(newRange, withText: replacementText) + } + } diff --git a/WordPress/Classes/ViewRelated/Comments/FullScreenCommentReplyViewModel.swift b/WordPress/Classes/ViewRelated/Comments/FullScreenCommentReplyViewModel.swift new file mode 100644 index 000000000000..de9525e60b77 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/FullScreenCommentReplyViewModel.swift @@ -0,0 +1,38 @@ +import CoreData + +protocol FullScreenCommentReplyViewModelType { + var suggestionsService: SuggestionService { get } + var context: NSManagedObjectContext { get } + func suggestionsTableView(with siteID: NSNumber, useTransparentHeader: Bool, prominentSuggestionsIds: [NSNumber]?, delegate: SuggestionsTableViewDelegate) -> SuggestionsTableView + func shouldShowSuggestions(with siteID: NSNumber?) -> Bool +} + +struct FullScreenCommentReplyViewModel: FullScreenCommentReplyViewModelType { + var suggestionsService: SuggestionService + var context: NSManagedObjectContext + + init(suggestionsService: SuggestionService = SuggestionService.shared, context: NSManagedObjectContext = ContextManager.shared.mainContext) { + self.suggestionsService = suggestionsService + self.context = context + } + + func suggestionsTableView(with siteID: NSNumber, useTransparentHeader: Bool, prominentSuggestionsIds: [NSNumber]?, delegate: SuggestionsTableViewDelegate) -> SuggestionsTableView { + let suggestionListViewModel = SuggestionsListViewModel(siteID: siteID, context: context) + suggestionListViewModel.userSuggestionService = suggestionsService + suggestionListViewModel.suggestionType = .mention + let tableView = SuggestionsTableView(viewModel: suggestionListViewModel, delegate: delegate) + tableView.useTransparentHeader = useTransparentHeader + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.prominentSuggestionsIds = prominentSuggestionsIds + return tableView + } + + func shouldShowSuggestions(with siteID: NSNumber?) -> Bool { + guard let siteID = siteID, + let blog = Blog.lookup(withID: siteID, in: context) else { + return false + } + + return suggestionsService.shouldShowSuggestions(for: blog) + } +} diff --git a/WordPress/Classes/ViewRelated/Comments/ListTableViewCell+Comments.swift b/WordPress/Classes/ViewRelated/Comments/ListTableViewCell+Comments.swift new file mode 100644 index 000000000000..9d59c3dc347a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/ListTableViewCell+Comments.swift @@ -0,0 +1,54 @@ +/// Encapsulates logic that configures `ListTableViewCell` with `Comment` models. +/// +extension ListTableViewCell { + /// Configures the cell based on the provided `Comment` object. + @objc func configureWithComment(_ comment: Comment) { + // indicator view + indicatorColor = Style.pendingIndicatorColor + showsIndicator = (comment.status == CommentStatusType.pending.description) + + // avatar image + placeholderImage = Style.gravatarPlaceholderImage + if let avatarURL = comment.avatarURLForDisplay() { + configureImage(with: avatarURL) + } else { + configureImageWithGravatarEmail(comment.gravatarEmailForDisplay()) + } + + // title text + attributedTitleText = attributedTitle(for: comment.authorForDisplay(), postTitle: comment.titleForDisplay()) + + // snippet text + snippetText = comment.contentPreviewForDisplay() + } + + // MARK: Private Helpers + + private func attributedTitle(for author: String, postTitle: String) -> NSAttributedString { + let titleFormat = NSLocalizedString("%1$@ on %2$@", comment: "Label displaying the author and post title for a Comment. %1$@ is a placeholder for the author. %2$@ is a placeholder for the post title.") + + let replacementMap = [ + "%1$@": NSAttributedString(string: author, attributes: ListStyle.titleBoldAttributes), + "%2$@": NSAttributedString(string: postTitle, attributes: ListStyle.titleBoldAttributes) + ] + + // Replace Author + Title + let attributedTitle = NSMutableAttributedString(string: titleFormat, attributes: ListStyle.titleRegularAttributes) + + for (key, attributedString) in replacementMap { + let range = (attributedTitle.string as NSString).range(of: key) + if range.location != NSNotFound { + attributedTitle.replaceCharacters(in: range, with: attributedString) + } + } + + return attributedTitle + } +} + +// MARK: - Constants + +private extension ListTableViewCell { + typealias Style = WPStyleGuide.Comments + typealias ListStyle = WPStyleGuide.List +} diff --git a/WordPress/Classes/ViewRelated/Comments/WPStyleGuide+CommentDetail.swift b/WordPress/Classes/ViewRelated/Comments/WPStyleGuide+CommentDetail.swift new file mode 100644 index 000000000000..a75b5fa54af5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/WPStyleGuide+CommentDetail.swift @@ -0,0 +1,155 @@ +import WordPressShared +import UIKit +/// This class groups all of the styles used by the comment detail screen. +/// +extension WPStyleGuide { + public struct CommentDetail { + static let tintColor: UIColor = .primary + static let externalIconImage: UIImage = .gridicon(.external).imageFlippedForRightToLeftLayoutDirection() + + static let textFont = WPStyleGuide.fontForTextStyle(.body) + static let textColor = UIColor.text + + static let secondaryTextFont = WPStyleGuide.fontForTextStyle(.subheadline) + static let secondaryTextColor = UIColor.textSubtle + + static let tertiaryTextFont = WPStyleGuide.fontForTextStyle(.caption2) + + public struct Header { + static let font = CommentDetail.tertiaryTextFont + static let textColor = CommentDetail.secondaryTextColor + + static let detailFont = CommentDetail.secondaryTextFont + static let detailTextColor = CommentDetail.textColor + } + + public struct Content { + static let buttonTintColor: UIColor = .textSubtle + static let likedTintColor: UIColor = .primary + + static let nameFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + static let nameTextColor = CommentDetail.textColor + + static let badgeFont = WPStyleGuide.fontForTextStyle(.caption2, fontWeight: .semibold) + static let badgeTextColor = UIColor.white + static let badgeColor = UIColor.muriel(name: .blue, .shade50) + + static let dateFont = CommentDetail.tertiaryTextFont + static let dateTextColor = CommentDetail.secondaryTextColor + + static let reactionButtonFont = WPStyleGuide.fontForTextStyle(.caption1) + static let reactionButtonTextColor = UIColor.label + + // highlighted state + static let highlightedBackgroundColor = UIColor(light: .muriel(name: .blue, .shade0), dark: .muriel(name: .blue, .shade100)).withAlphaComponent(0.5) + static let highlightedBarBackgroundColor = UIColor.muriel(name: .blue, .shade40) + static let highlightedReplyButtonTintColor = UIColor.primary + + static let placeholderImage = UIImage.gravatarPlaceholderImage + + private static let reactionIconConfiguration = UIImage.SymbolConfiguration(font: reactionButtonFont, scale: .large) + static let unlikedIconImage = UIImage(systemName: "star", withConfiguration: reactionIconConfiguration) + static let likedIconImage = UIImage(systemName: "star.fill", withConfiguration: reactionIconConfiguration) + + static let accessoryIconConfiguration = UIImage.SymbolConfiguration(font: CommentDetail.tertiaryTextFont, scale: .medium) + static let shareIconImageName = "square.and.arrow.up" + static let ellipsisIconImageName = "ellipsis.circle" + static let infoIconImageName = "info.circle" + + + static var replyIconImage: UIImage? { + // this symbol is only available in iOS 14 and above. For iOS 13, we need to use the backported image in our assets. + let name = "arrowshape.turn.up.backward" + let image = UIImage(systemName: name) ?? UIImage(named: name) + return image?.withConfiguration(reactionIconConfiguration).imageFlippedForRightToLeftLayoutDirection() + } + + static let highlightedReplyIconImage = UIImage(systemName: "arrowshape.turn.up.left.fill", withConfiguration: reactionIconConfiguration)? + .withTintColor(highlightedReplyButtonTintColor, renderingMode: .alwaysTemplate) + .imageFlippedForRightToLeftLayoutDirection() + } + + public struct ReplyIndicator { + static let textAttributes: [NSAttributedString.Key: Any] = [ + .font: CommentDetail.secondaryTextFont, + .foregroundColor: CommentDetail.secondaryTextColor + ] + + private static let symbolConfiguration = UIImage.SymbolConfiguration(font: CommentDetail.secondaryTextFont, scale: .small) + static let iconImage: UIImage? = .init(systemName: "arrowshape.turn.up.left.circle", withConfiguration: symbolConfiguration)? + .withRenderingMode(.alwaysTemplate) + .imageFlippedForRightToLeftLayoutDirection() + } + + public struct ModerationBar { + static let barBackgroundColor: UIColor = .systemGray6 + static let cornerRadius: CGFloat = 15.0 + + static let dividerColor: UIColor = .systemGray + static let dividerHiddenColor: UIColor = .clear + + static let buttonShadowOffset = CGSize(width: 0, height: 2.0) + static let buttonShadowOpacity: Float = 0.25 + static let buttonShadowRadius: CGFloat = 2.0 + + static let buttonDefaultTitleColor = UIColor(light: .textSubtle, dark: .systemGray) + static let buttonSelectedTitleColor = UIColor(light: .black, dark: .white) + static let buttonDefaultBackgroundColor: UIColor = .clear + static let buttonDefaultShadowColor = UIColor.clear.cgColor + static let buttonSelectedBackgroundColor: UIColor = .tertiaryBackground + static let buttonSelectedShadowColor = UIColor.black.cgColor + + static let pendingImageName = "tray" + static let approvedImageName = "checkmark.circle" + static let spamImageName = "exclamationmark.octagon" + static let trashImageName = "trash" + + static let imageDefaultTintColor = buttonDefaultTitleColor + static let pendingSelectedColor: UIColor = .muriel(name: .yellow, .shade30) + static let approvedSelectedColor: UIColor = .muriel(name: .green, .shade40) + static let spamSelectedColor: UIColor = .muriel(name: .orange, .shade40) + static let trashSelectedColor: UIColor = .muriel(name: .red, .shade40) + + static func defaultImageFor(_ buttonType: ModerationButtonType) -> UIImage? { + return UIImage(systemName: imageNameFor(buttonType))? + .withTintColor(imageDefaultTintColor) + .withRenderingMode(.alwaysOriginal) + } + + static func selectedImageFor(_ buttonType: ModerationButtonType) -> UIImage? { + return UIImage(systemName: imageNameFor(buttonType, selected: true))? + .imageWithTintColor(imageTintColorFor(buttonType)) + } + + static func imageNameFor(_ buttonType: ModerationButtonType, selected: Bool = false) -> String { + let imageName: String = { + switch buttonType { + case .pending: + return pendingImageName + case .approved: + return approvedImageName + case .spam: + return spamImageName + case .trash: + return trashImageName + } + }() + + return selected ? (imageName + ".fill") : imageName + } + + static func imageTintColorFor(_ buttonType: ModerationButtonType) -> UIColor { + switch buttonType { + case .pending: + return pendingSelectedColor + case .approved: + return approvedSelectedColor + case .spam: + return spamSelectedColor + case .trash: + return trashSelectedColor + } + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Comments/WPStyleGuide+Comments.swift b/WordPress/Classes/ViewRelated/Comments/WPStyleGuide+Comments.swift index e0eab62d58a1..1d9d9bdc0261 100644 --- a/WordPress/Classes/ViewRelated/Comments/WPStyleGuide+Comments.swift +++ b/WordPress/Classes/ViewRelated/Comments/WPStyleGuide+Comments.swift @@ -6,86 +6,25 @@ import WordPressShared /// extension WPStyleGuide { public struct Comments { - // MARK: - Public Properties - // - public static func gravatarPlaceholderImage(isApproved approved: Bool) -> UIImage { - return approved ? gravatarApproved : gravatarUnapproved - } - public static func separatorsColor(isApproved approved: Bool) -> UIColor { - return .divider - } + static let gravatarPlaceholderImage = UIImage(named: "gravatar") ?? UIImage() + static let backgroundColor = UIColor.listForeground + static let pendingIndicatorColor = UIColor.muriel(color: MurielColor(name: .yellow, shade: .shade20)) - public static func detailsRegularStyle(isApproved approved: Bool) -> [NSAttributedString.Key: Any] { - return [.paragraphStyle: titleParagraph, - .font: titleRegularFont, - .foregroundColor: UIColor.textSubtle ] - } + static let detailFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) + static let detailTextColor = UIColor.textSubtle - public static func detailsRegularRedStyle(isApproved approved: Bool) -> [NSAttributedString.Key: Any] { - return [.paragraphStyle: titleParagraph, - .font: titleRegularFont, - .foregroundColor: UIColor.text ] - } + private static let titleTextColor = UIColor.text + private static let titleTextStyle = UIFont.TextStyle.headline - public static func detailsItalicsStyle(isApproved approved: Bool) -> [NSAttributedString.Key: Any] { - return [.paragraphStyle: titleParagraph, - .font: titleItalicsFont, - .foregroundColor: UIColor.text ] - } + static let titleBoldAttributes: [NSAttributedString.Key: Any] = [ + .font: WPStyleGuide.fontForTextStyle(titleTextStyle, fontWeight: .semibold), + .foregroundColor: titleTextColor + ] - public static func detailsBoldStyle(isApproved approved: Bool) -> [NSAttributedString.Key: Any] { - return [.paragraphStyle: titleParagraph, - .font: titleBoldFont, - .foregroundColor: UIColor.text ] - } - - public static func timestampStyle(isApproved approved: Bool) -> [NSAttributedString.Key: Any] { - return [.font: timestampFont, - .foregroundColor: UIColor.textSubtle ] - } - - public static func backgroundColor(isApproved approved: Bool) -> UIColor { - return approved ? .listForeground : UIColor(light: .warning(.shade0), dark: .warning(.shade90)) - } - - public static func timestampImage(isApproved approved: Bool) -> UIImage { - let timestampImage = UIImage(named: "reader-postaction-time")! - return approved ? timestampImage : timestampImage.withRenderingMode(.alwaysTemplate) - } - - - - // MARK: - Private Properties - // - fileprivate static let gravatarApproved = UIImage(named: "gravatar")! - fileprivate static let gravatarUnapproved = UIImage(named: "gravatar-unapproved")! - - private static var timestampFont: UIFont { - return WPStyleGuide.fontForTextStyle(.caption1) - } - - private static var titleRegularFont: UIFont { - return WPStyleGuide.fontForTextStyle(.footnote) - } - - private static var titleBoldFont: UIFont { - return WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .semibold) - } - - private static var titleItalicsFont: UIFont { - return WPStyleGuide.fontForTextStyle(.footnote, symbolicTraits: .traitItalic) - } - - private static var titleLineSize: CGFloat { - return WPStyleGuide.fontSizeForTextStyle(.footnote) * 1.3 - } - - private static var titleParagraph: NSMutableParagraphStyle { - return NSMutableParagraphStyle(minLineHeight: titleLineSize, - maxLineHeight: titleLineSize, - lineBreakMode: .byTruncatingTail, - alignment: .natural) - } + static let titleRegularAttributes: [NSAttributedString.Key: Any] = [ + .font: WPStyleGuide.fontForTextStyle(titleTextStyle, fontWeight: .regular), + .foregroundColor: titleTextColor + ] } } diff --git a/WordPress/Classes/ViewRelated/Developer/StoreSandboxSecretScreen.swift b/WordPress/Classes/ViewRelated/Developer/StoreSandboxSecretScreen.swift new file mode 100644 index 000000000000..04ec5d55bf13 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Developer/StoreSandboxSecretScreen.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct StoreSandboxSecretScreen: View { + private static let storeSandboxSecretKey = "store_sandbox" + + @SwiftUI.Environment(\.presentationMode) var presentationMode + @State private var secret: String + private let cookieJar: CookieJar + + var body: some View { + NavigationView { + VStack(alignment: .leading) { + Text("Enter the Store Sandbox Cookie Secret:") + TextField("Secret", text: $secret) + .padding(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)) + .border(Color.black) + Spacer() + } + .padding(EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)) + } + .onDisappear() { + // This seems to be necessary due to an iOS bug where + // accessing presentationMode.wrappedValue crashes. + DispatchQueue.main.async { + if self.presentationMode.wrappedValue.isPresented == false, + let cookie = HTTPCookie(properties: [ + .name: StoreSandboxSecretScreen.storeSandboxSecretKey, + .value: secret, + .domain: ".wordpress.com", + .path: "/" + ]) { + cookieJar.setCookies([cookie]) {} + } + } + } + } + + init(cookieJar: CookieJar) { + var cookies: [HTTPCookie] = [] + + self.cookieJar = cookieJar + + cookieJar.getCookies { jarCookies in + cookies = jarCookies + } + + if let cookie = cookies.first(where: { $0.name == StoreSandboxSecretScreen.storeSandboxSecretKey }) { + _secret = State(initialValue: cookie.value) + } else { + _secret = State(initialValue: "") + } + } +} + +struct StoreSandboxSecretScreen_Previews: PreviewProvider { + static var previews: some View { + StoreSandboxSecretScreen(cookieJar: HTTPCookieStorage.shared) + } +} diff --git a/WordPress/Classes/ViewRelated/Developer/WeeklyRoundupDebugScreen.swift b/WordPress/Classes/ViewRelated/Developer/WeeklyRoundupDebugScreen.swift new file mode 100644 index 000000000000..9b1d33590343 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Developer/WeeklyRoundupDebugScreen.swift @@ -0,0 +1,235 @@ +import BackgroundTasks +import SwiftUI + +struct BlueButton: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding() + .background(configuration.isPressed ? Color.white : Color(red: 0.5, green: 0.5, blue: 0.8)) + .foregroundColor(.white) + .clipShape(Capsule()) + } +} + +struct WeeklyRoundupDebugScreen: View { + + class Settings { + private let weeklyRoundupEnabledForA8cP2sKey = "weekly_roundup.debug.enabled_for_a8c_p2s" + + let defaultPadding = CGFloat(16) + let spacerHeight = CGFloat(16) + + var isEnabledForA8cP2s: Bool { + get { + (UserPersistentStoreFactory.instance().object(forKey: weeklyRoundupEnabledForA8cP2sKey) as? Bool) ?? false + } + + set { + UserPersistentStoreFactory.instance().set(newValue, forKey: weeklyRoundupEnabledForA8cP2sKey) + } + } + } + + @SwiftUI.Environment(\.presentationMode) var presentationMode + @State private var scheduledDate: Date? = nil + @State private var running: Bool = false + @State private var errorScheduling: Bool = false + private let settings = Settings() + + var body: some View { + VStack(alignment: .center) { + if errorScheduling { + Group { + Text("Error scheduling Weekly Roundup!") + .foregroundColor(.red) + } + + Spacer() + .frame(height: 16) + } + + Group { + Toggle("Include A8c P2s", isOn: Binding(get: { + settings.isEnabledForA8cP2s + }, set: { isOn in + settings.isEnabledForA8cP2s = isOn + })) + .padding() + + + Spacer() + .frame(height: 16) + } + + Group { + scheduleDetailsView() + + Spacer() + .frame(height: 16) + } + + Group { + HStack { + Spacer() + + Button("Schedule immediately") { + self.scheduleImmediately() + } + .buttonStyle(BlueButton()) + .frame(width: 350) + + Spacer() + } + + Spacer() + .frame(height: settings.spacerHeight) + + HStack { + Spacer() + + Button("Schedule in 10 sec / 5 min") { + self.scheduleDelayed(taskRunDelay: 10, staticNotificationDelay: 5 * 60) + } + .buttonStyle(BlueButton()) + .frame(width: 350) + + Spacer() + } + + Spacer() + .frame(height: settings.spacerHeight) + + HStack { + Spacer() + + Button("Schedule in 10 sec / 30 min") { + self.scheduleDelayed(taskRunDelay: 10, staticNotificationDelay: 30 * 60) + } + .buttonStyle(BlueButton()) + .frame(width: 350) + + Spacer() + } + + Spacer() + .frame(height: settings.spacerHeight) + + HStack { + Spacer() + + Button("Schedule in 10 sec / 60 min") { + self.scheduleDelayed(taskRunDelay: 10, staticNotificationDelay: 60 * 60) + } + .buttonStyle(BlueButton()) + .frame(width: 350) + + Spacer() + } + + Spacer() + .frame(height: settings.spacerHeight) + } + + Text("The first number is when the dynamic notification is scheduled at the earliest. It can take a lot more time to be sent since iOS basically decides when to deliver it. The second number is for the static notification which depend on the weeklyRoundupStaticNotification feature flag being enabled. The static notification will be shown if either the App is killed or if the dynamic notification isn't shown by iOS before it.") + .fixedSize(horizontal: false, vertical: true) + .padding(settings.defaultPadding) + + Spacer() + } + .navigationBarTitle("Weekly Roundup", displayMode: .inline) + .onAppear { + self.updateBackgroundTaskDate() + } + } + + func updateBackgroundTaskDate() { + DispatchQueue.main.async { + WordPressAppDelegate.shared?.backgroundTasksCoordinator.getScheduledExecutionDate(taskIdentifier: WeeklyRoundupBackgroundTask.identifier, completion: { date in + + self.scheduledDate = date + }) + } + } + + func scheduleDetailsView() -> some View { + guard !running else { + return AnyView(Text("Running...")) + } + + if let scheduledDate = self.scheduledDate { + return AnyView(HStack { + Text("Earliest begin date:") + Spacer() + Text("\(scheduledDate.shortStringWithTime())") + }) + } else { + return AnyView(HStack { + Text("Not scheduled.") + }) + } + } + + func scheduleImmediately() { + running = true + + InteractiveNotificationsManager.shared.requestAuthorization { authorized in + guard authorized else { + self.running = false + return + } + + typealias LaunchTaskWithIdentifier = @convention(c) (NSObject, Selector, NSString) -> Void + + let selector = Selector(("_simulateLaunchForTaskWithIdentifier:")) + let methodImp = BGTaskScheduler.shared.method(for: selector) + let method = unsafeBitCast(methodImp, to: LaunchTaskWithIdentifier.self) + + method(BGTaskScheduler.shared, selector, WeeklyRoundupBackgroundTask.identifier as NSString) + + self.errorScheduling = false + + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { + self.updateBackgroundTaskDate() + self.running = false + } + } + } + + func scheduleDelayed(taskRunDelay: TimeInterval, staticNotificationDelay: TimeInterval) { + updateBackgroundTaskDate() + + InteractiveNotificationsManager.shared.requestAuthorization { authorized in + if authorized { + DispatchQueue.main.async { + let taskRunDate = Date(timeIntervalSinceNow: taskRunDelay) + let staticNotificationDate = Date(timeIntervalSinceNow: staticNotificationDelay) + let calendar = Calendar.current + + let runDateComponents = calendar.dateComponents([.hour, .minute, .second], from: taskRunDate) + let staticNotificationDateComponents = calendar.dateComponents([.hour, .minute, .second], from: staticNotificationDate) + + let backgroundTask = WeeklyRoundupBackgroundTask( + runDateComponents: runDateComponents, + staticNotificationDateComponents: staticNotificationDateComponents) + + WordPressAppDelegate.shared?.backgroundTasksCoordinator.schedule(backgroundTask) { result in + switch result { + case .success: + errorScheduling = false + case .failure: + errorScheduling = true + } + + self.updateBackgroundTaskDate() + } + } + } + } + } +} + +struct WeeklyRoundupDebugScreen_Preview: PreviewProvider { + static var previews: some View { + StoreSandboxSecretScreen(cookieJar: HTTPCookieStorage.shared) + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Domain credit/DomainCreditEligibilityChecker.swift b/WordPress/Classes/ViewRelated/Domains/Domain credit/DomainCreditEligibilityChecker.swift new file mode 100644 index 000000000000..5b633e238a3b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Domain credit/DomainCreditEligibilityChecker.swift @@ -0,0 +1,5 @@ +class DomainCreditEligibilityChecker: NSObject { + @objc static func canRedeemDomainCredit(blog: Blog) -> Bool { + return (blog.isHostedAtWPcom || blog.isAtomic()) && blog.hasDomainCredit && JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Domain credit/DomainCreditRedemptionSuccessViewController.swift b/WordPress/Classes/ViewRelated/Domains/Domain credit/DomainCreditRedemptionSuccessViewController.swift new file mode 100644 index 000000000000..2b55989469a5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Domain credit/DomainCreditRedemptionSuccessViewController.swift @@ -0,0 +1,221 @@ +import UIKit +import WordPressUI + +/// Displays messaging after user successfully redeems domain credit. +class DomainCreditRedemptionSuccessViewController: UIViewController { + + private let domain: String + + private var continueButtonPressed: (String) -> Void + + // MARK: - Views + + private lazy var scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.setContentHuggingPriority(.defaultLow, for: .vertical) + scrollView.showsVerticalScrollIndicator = false + return scrollView + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = Metrics.stackViewSpacing + stackView.alignment = .fill + return stackView + }() + + private lazy var stackViewContainer: UIView = { + let stackViewContainer = UIView() + stackViewContainer.translatesAutoresizingMaskIntoConstraints = false + stackViewContainer.setContentHuggingPriority(.defaultLow, for: .vertical) + return stackViewContainer + }() + + private lazy var titleLabel: UILabel = { + let title = UILabel() + title.numberOfLines = 0 + title.lineBreakMode = .byWordWrapping + title.textAlignment = .center + title.font = WPStyleGuide.serifFontForTextStyle(.largeTitle) + title.textColor = .white + title.text = TextContent.title + title.adjustsFontForContentSizeCategory = true + return title + }() + + private lazy var subtitleLabel: UILabel = { + let subtitle = UILabel() + subtitle.numberOfLines = 0 + subtitle.lineBreakMode = .byWordWrapping + subtitle.textAlignment = .center + subtitle.textColor = .white + subtitle.adjustsFontForContentSizeCategory = true + + let subtitleText = makeDomainDetailsString(domain: domain) + subtitle.attributedText = applyDomainStyle(to: subtitleText, domain: domain) + + return subtitle + }() + + private lazy var illustration: UIImageView = { + let illustration = UIImageView(image: UIImage(named: "domains-success")) + illustration.contentMode = .scaleAspectFit + return illustration + }() + + private lazy var doneButton: FancyButton = { + let button = FancyButton() + button.isPrimary = true + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(TextContent.doneButtonTitle, for: .normal) + button.addTarget(self, action: #selector(doneButtonTapped), for: .touchUpInside) + button.isPrimary = false + button.accessibilityIdentifier = Accessibility.doneButtonIdentifier + button.accessibilityHint = Accessibility.doneButtonHint + button.secondaryNormalBackgroundColor = UIColor(light: .white, dark: .muriel(name: .blue, .shade40)) + return button + }() + + private lazy var doneButtonContainer: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(doneButton) + view.pinSubviewToAllEdges(doneButton, insets: Metrics.doneButtonInsets) + view.setContentHuggingPriority(.defaultHigh, for: .vertical) + return view + }() + + private lazy var divider: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = UIColor(white: 1.0, alpha: 0.3) + return view + }() + + + // MARK: - View lifecycle + + init(domain: String, continueButtonPressed: @escaping (String) -> Void) { + self.domain = domain + self.continueButtonPressed = continueButtonPressed + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + // Hide the illustration if we only have compact height, or if the user has + // dynamic content set to accessibility sizes. + illustration.isHidden = traitCollection.containsTraits(in: UITraitCollection(verticalSizeClass: .compact)) || traitCollection.preferredContentSizeCategory.isAccessibilityCategory + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.view.backgroundColor = UIColor(light: .primary, dark: .secondarySystemBackground) + + navigationController?.setNavigationBarHidden(true, animated: false) + + setupViewHierarchy() + configureConstraints() + } + + private func setupViewHierarchy() { + stackView.addArrangedSubviews([illustration, titleLabel, subtitleLabel]) + stackViewContainer.addSubview(stackView) + scrollView.addSubview(stackViewContainer) + view.addSubview(scrollView) + view.addSubview(doneButtonContainer) + view.addSubview(divider) + } + + private func configureConstraints() { + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: stackViewContainer.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: stackViewContainer.trailingAnchor), + stackView.topAnchor.constraint(greaterThanOrEqualTo: stackViewContainer.topAnchor), + stackView.bottomAnchor.constraint(lessThanOrEqualTo: stackViewContainer.bottomAnchor), + stackView.centerYAnchor.constraint(equalTo: stackViewContainer.centerYAnchor), + + scrollView.leadingAnchor.constraint(equalTo: view.safeLeadingAnchor, constant: Metrics.edgePadding), + scrollView.trailingAnchor.constraint(equalTo: view.safeTrailingAnchor, constant: -Metrics.edgePadding), + scrollView.topAnchor.constraint(equalTo: view.safeTopAnchor), + scrollView.bottomAnchor.constraint(equalTo: doneButtonContainer.topAnchor), + + stackViewContainer.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + stackViewContainer.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + stackViewContainer.leadingAnchor.constraint(equalTo: view.safeLeadingAnchor, constant: Metrics.edgePadding), + stackViewContainer.trailingAnchor.constraint(equalTo: view.safeTrailingAnchor, constant: -Metrics.edgePadding), + stackViewContainer.topAnchor.constraint(equalTo: scrollView.topAnchor), + stackViewContainer.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + stackViewContainer.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.heightAnchor), + + doneButtonContainer.leadingAnchor.constraint(equalTo: view.safeLeadingAnchor), + doneButtonContainer.trailingAnchor.constraint(equalTo: view.safeTrailingAnchor), + doneButtonContainer.bottomAnchor.constraint(equalTo: view.safeBottomAnchor), + doneButtonContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: Metrics.buttonControllerMinHeight), + + divider.leadingAnchor.constraint(equalTo: view.leadingAnchor), + divider.trailingAnchor.constraint(equalTo: view.trailingAnchor), + divider.bottomAnchor.constraint(equalTo: doneButtonContainer.topAnchor), + divider.heightAnchor.constraint(equalToConstant: .hairlineBorderWidth) + ]) + } + + // MARK: - Text helpers + + private func applyDomainStyle(to string: String, domain: String) -> NSAttributedString? { + let attributedString = NSAttributedString(string: string, attributes: [.font: subtitleFont]) + let newAttributedString = NSMutableAttributedString(attributedString: attributedString) + + let range = (newAttributedString.string as NSString).localizedStandardRange(of: domain) + guard range.location != NSNotFound else { + return nil + } + let font = subtitleFont.bold() + newAttributedString.setAttributes([.font: font], + range: range) + return newAttributedString + } + + private func makeDomainDetailsString(domain: String) -> String { + String(format: TextContent.domainDetailsString, domain) + } + + // MARK: - Actions + + @objc func doneButtonTapped() { + continueButtonPressed(domain) + } + + // MARK: - Constants + + private let subtitleFont = UIFont.preferredFont(forTextStyle: .title3) + + private enum TextContent { + static let title = NSLocalizedString("Congratulations on your purchase!", comment: "Title of domain name purchase success screen") + static let domainDetailsString = NSLocalizedString("Your new domain %@ is being set up. It may take up to 30 minutes for your domain to start working.", + comment: "Details about recently acquired domain on domain credit redemption success screen") + static let doneButtonTitle = NSLocalizedString("Done", + comment: "Done button title") + } + + private enum Metrics { + static let stackViewSpacing: CGFloat = 16.0 + static let buttonControllerMinHeight: CGFloat = 84.0 + static let edgePadding: CGFloat = 20.0 + static let doneButtonInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + } + + private enum Accessibility { + static let doneButtonIdentifier = "DomainsSuccessDoneButton" + static let doneButtonHint = NSLocalizedString("Dismiss screen", comment: "Accessibility hint for a done button that dismisses the current modal screen") + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Domain registration/DomainPurchasingWebFlowController.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/DomainPurchasingWebFlowController.swift new file mode 100644 index 000000000000..601a007fcc8d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/DomainPurchasingWebFlowController.swift @@ -0,0 +1,339 @@ +import UIKit +import AutomatticTracks +import Sentry + +final class DomainPurchasingWebFlowController { + + // MARK: - Constants + + fileprivate enum Constants { + static let wordpressBaseURL = "https://wordpress.com" + static let checkoutWebAddress = "\(wordpressBaseURL)/checkout" + static let checkoutSuccessURLPrefix = "\(checkoutWebAddress)/thank-you/" + static let storeSandboxCookieName = "store_sandbox" + static let storeSandboxCookieDomain = ".wordpress.com" + } + + // MARK: - Dependencies + + /// The view controller that presents the domain checkout web page. + weak private var presentingViewController: UIViewController? + + /// The service that interacts with the Backend API. + private let shoppingCartService: RegisterDomainDetailsServiceProxyProtocol + + /// Provides an API to capture errors. + private let crashLogger: CrashLogging + + // MARK: - Execution Variables + + /// Set when a domain checkout web page is presented. + private weak var presentedViewController: UINavigationController? + + /// Observe url changes and it is set when a domain checkout web page is presented. + private var webViewURLChangeObservation: NSKeyValueObservation? + + // MARK: - Init + + init(viewController: UIViewController, + shoppingCartService: RegisterDomainDetailsServiceProxyProtocol = RegisterDomainDetailsServiceProxy(), + crashLogger: CrashLogging = .main) { + self.presentingViewController = viewController + self.shoppingCartService = shoppingCartService + self.crashLogger = crashLogger + } + + // MARK: - API + + func purchase(domain: FullyQuotedDomainSuggestion, site: Blog, completion: CompletionHandler? = nil) { + purchase(domain: domain.remoteSuggestion(), site: site, completion: completion) + } + + func purchase(domain: DomainSuggestion, site: Blog, completion: CompletionHandler? = nil) { + let middleware: CompletionHandler = { [weak self] result in + if let self = self, case let .failure(error) = result, !error.trusted { + let userInfo = self.userInfoForError(error, domain: domain, site: site) + self.crashLogger.logError(error, userInfo: userInfo, level: error.level) + } + if let completion { + DispatchQueue.main.async { + completion(result) + } + } + } + guard let presentingViewController else { + middleware(.failure(.internal("The presentingViewController is deallocated"))) + return + } + guard let domain = Domain(domain: domain, site: site) else { + middleware(.failure(.invalidInput)) + return + } + self.createCartAndPresentWebView(domain: domain, in: presentingViewController, completion: middleware) + } + + // MARK: - Private + + private func createCartAndPresentWebView(domain: Domain, in presentingViewController: UIViewController, completion: CompletionHandler? = nil) { + self.shoppingCartService.createPersistentDomainShoppingCart( + siteID: domain.siteID, + domainSuggestion: domain.underlyingDomain, + privacyProtectionEnabled: domain.supportsPrivacy, + success: { [weak self] _ in + self?.presentWebViewForCurrentSite(domain: domain, in: presentingViewController, completion: completion) + }) { error in + completion?(.failure(.other(error))) + } + } + + private func presentWebViewForCurrentSite(domain: Domain, in presentingViewController: UIViewController, completion: CompletionHandler? = nil) { + // Clean up properties from previous domain purchasing execution. + self.cleanupExecutionVariables() + + // WORKAROUND: The reason why we have to use this mechanism to detect success and failure conditions + // for domain registration is because our checkout process (for some unknown reason) doesn't trigger + // call to WKWebViewDelegate methods. + // + // This was last checked by @diegoreymendez on 2021-09-22. + // + var result: Result<String, DomainPurchasingError>? + let webViewController = WebViewControllerFactory.controllerWithDefaultAccountAndSecureInteraction(url: domain.hostURL, source: "domains_register") + self.webViewURLChangeObservation = webViewController.webView.observe(\.url, options: [.new, .old]) { [weak self] _, change in + guard let self = self, let newURL = change.newValue as? URL else { + return + } + let oldURL = change.oldValue as? URL + self.handleWebViewURLChange(newURL, oldURL: oldURL, domain: domain) { innerResult in + result = innerResult + self.presentedViewController?.dismiss(animated: true) + } + } + + // 1. Inject sandbox store cookie + // 2. Present a new web view instance or reload the existing one. + let cookieStore = webViewController.webView.configuration.websiteDataStore.httpCookieStore + self.injectSandboxStoreCookie(into: cookieStore) { [weak self] _ in + guard let self else { + return + } + if let presentedViewController = self.presentedViewController { + presentedViewController.setViewControllers([webViewController], animated: false) + } else { + let navController = DomainPurchasingNavigationController(rootViewController: webViewController) + navController.onDismiss = { + let result = result ?? .failure(.canceled) + self.completeDomainPurchasing(result: result, completion: completion) + } + presentingViewController.present(navController, animated: true) + self.presentedViewController = navController + } + } + } + + /// Calls the completion handler with the result and clean up observation properties. + private func completeDomainPurchasing(result: Result<String, DomainPurchasingError>, completion: CompletionHandler? = nil) { + self.cleanupExecutionVariables() + completion?(result) + } + + /// Injects the sandbox store cookie into the cookie store. + private func injectSandboxStoreCookie(into cookieStore: WKHTTPCookieStore, completion: @escaping (Bool) -> Void) { + if let storeSandboxCookie = (HTTPCookieStorage.shared.cookies?.first { + $0.properties?[.name] as? String == Constants.storeSandboxCookieName && + $0.properties?[.domain] as? String == Constants.storeSandboxCookieDomain + }) { + cookieStore.getAllCookies { cookies in + var newCookies = cookies + newCookies.append(storeSandboxCookie) + cookieStore.setCookies(newCookies) { + completion(true) + } + } + } else { + completion(false) + } + } + + /// Handles URL changes in the web view. We only allow the user to stay within certain URLs. Falling outside these URLs + /// results in the web view being dismissed. This method also handles the success condition for a successful domain registration + /// through said web view. + /// + /// - Parameters: + /// - newURL: the newly set URL for the web view. + /// - siteID: the ID of the site we're trying to register the domain against. + /// - domain: the domain the user is purchasing. + /// - completion: the closure that will be executed when the domain registration succeeds or fails. + /// + private func handleWebViewURLChange( + _ newURL: URL, + oldURL: URL?, + domain: Domain, + completion: (Result<String, DomainPurchasingError>) -> Void + ) { + let canOpenNewURL = newURL.absoluteString.starts(with: Constants.checkoutWebAddress) + guard canOpenNewURL else { + let error = DomainPurchasingError.unsupportedRedirect(fromURL: oldURL, toURL: newURL) + completion(.failure(error)) + return + } + + let domainRegistrationSucceeded = newURL.absoluteString.starts(with: Constants.checkoutSuccessURLPrefix) + + if domainRegistrationSucceeded { + completion(.success(domain.domainName)) + } + } + + /// Nullifies the variables that were set during a domain purchasing execution. In other words, it nullifies the variables under "Execution Variables" pragma mark. + private func cleanupExecutionVariables() { + self.webViewURLChangeObservation?.invalidate() + self.webViewURLChangeObservation = nil + } + + /// Metadata to attach to error or track events. + private func userInfo(domain: DomainSuggestion, site: Blog) -> [String: Any] { + let homeURL = site.homeURL as String? + var userInfo: [String: Any] = [ + "siteID": site.dotComID?.intValue as Any, + "siteHomeURL": homeURL as Any, + "siteHostURL": URL(string: homeURL ?? "")?.host as Any, + "domainName": domain.domainName, + "domainSupportsPrivacy": domain.supportsPrivacy as Any, + "checkoutWebAddress": Self.Constants.checkoutWebAddress, + ] + if let presentingViewController { + userInfo["presentingViewController"] = String(describing: type(of: presentingViewController)) + } + return userInfo + } + + /// Metadata to attach when capturing errors. + private func userInfoForError(_ error: DomainPurchasingError, domain: DomainSuggestion, site: Blog) -> [String: Any] { + var userInfo = userInfo(domain: domain, site: site) + userInfo = userInfo.merging(error.errorUserInfo) { $1 } + return userInfo + } + + // MARK: - Types + + typealias CompletionHandler = (Result<String, DomainPurchasingError>) -> Void + + enum DomainPurchasingError: LocalizedError { + case invalidInput + case canceled + case unsupportedRedirect(fromURL: URL?, toURL: URL) + case `internal`(String) + case other(Error) + } + + /// Encapsulates the input needed for the domain purchasing logic. + fileprivate struct Domain { + let underlyingDomain: DomainSuggestion + let underlyingSite: Blog + + let siteID: Int + let homeURL: URL + let hostURL: URL + + var domainName: String { + return underlyingDomain.domainName + } + + var supportsPrivacy: Bool { + return underlyingDomain.supportsPrivacy ?? false + } + } +} + +extension DomainPurchasingWebFlowController.Domain { + + init?(domain: DomainSuggestion, site: Blog) { + guard let siteID = site.dotComID?.intValue, + let homeURLString = site.homeURL, + let homeURL = URL(string: homeURLString as String), + let hostURLString = homeURL.host, + let hostURL = URL(string: DomainPurchasingWebFlowController.Constants.checkoutWebAddress + "/\(hostURLString)") + else { + return nil + } + self.underlyingSite = site + self.underlyingDomain = domain + self.siteID = siteID + self.homeURL = homeURL + self.hostURL = hostURL + } +} + +extension DomainPurchasingWebFlowController.DomainPurchasingError: CustomNSError { + + /// Untrusted errors should be monitored and requires our attention. + var trusted: Bool { + switch self { + case .canceled, .other: return true + default: return false + } + } + + var level: SeverityLevel { + switch self { + case .unsupportedRedirect: return .warning + default: return .error + } + } + + var errorDescription: String? { + switch self { + case .invalidInput: return "Input provided is not valid to perform domain purchasing" + case .canceled: return "Domain purchasing flow is canceled" + case .unsupportedRedirect: return "Unsupported domain purchasing URL" + case .internal(let reason): return reason + case .other(let error): return (error as NSError).localizedDescription + } + } + + var errorCode: Int { + switch self { + case .canceled: return 400 + case .invalidInput: return 500 + case .internal: return 501 + case .unsupportedRedirect: return 502 + case .other(let error): return (error as NSError).code + } + } + + static var errorDomain: String { + return "DomainPurchasingWebFlowError" + } + + var errorUserInfo: [String: Any] { + var userInfo = [String: Any]() + if let errorDescription { + userInfo[NSDebugDescriptionErrorKey] = errorDescription + } + switch self { + case .unsupportedRedirect(let fromURL, let toURL): + userInfo["fromURL"] = fromURL?.absoluteString + userInfo["toURL"] = toURL.absoluteString + default: + break + } + return userInfo + } + + typealias SeverityLevel = SentryLevel +} + +// MARK: - Custom Navigation Controller + +/// Custom navigation controller to detect when the domain checkout web view is dimissed. +/// +/// This way, we guarantee that `onDismiss` will be called whether the screen was dismissed by tapping "X" button, using the swipe down gesture, +/// or even if the system decided to dismiss the screen. +private class DomainPurchasingNavigationController: UINavigationController { + + var onDismiss: (() -> Void)? + + deinit { + onDismiss?() + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomain.storyboard b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomain.storyboard similarity index 81% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomain.storyboard rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomain.storyboard index 70bd95fc3bd1..fbc60f124944 100644 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomain.storyboard +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomain.storyboard @@ -1,11 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> @@ -14,39 +12,39 @@ <scene sceneID="Ott-OI-M1X"> <objects> <viewController storyboardIdentifier="RegisterDomainSuggestionsViewController" id="czc-f5-zC7" customClass="RegisterDomainSuggestionsViewController" customModule="WordPress" customModuleProvider="target" sceneMemberID="viewController"> - <view key="view" contentMode="scaleToFill" id="hr1-AR-Kcc"> + <view key="view" autoresizesSubviews="NO" contentMode="scaleToFill" id="hr1-AR-Kcc"> <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> <containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="qRW-a0-uEt" userLabel="Table Container View"> - <rect key="frame" x="0.0" y="20" width="375" height="563"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="583"/> <connections> <segue destination="CFb-K0-jrQ" kind="embed" id="v6Z-QC-UcC"/> </connections> </containerView> - <containerView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="N47-hP-xDu" userLabel="Button Container View"> + <containerView autoresizesSubviews="NO" opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="N47-hP-xDu" userLabel="Button Container View"> <rect key="frame" x="0.0" y="583" width="375" height="84"/> + <viewLayoutGuide key="safeArea" id="0hA-Vo-wWt"/> <constraints> <constraint firstAttribute="height" constant="84" placeholder="YES" id="ATd-iH-oJG"/> <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="84" id="j00-iE-vY5"/> </constraints> - <viewLayoutGuide key="safeArea" id="0hA-Vo-wWt"/> </containerView> </subviews> + <viewLayoutGuide key="safeArea" id="qDC-aT-Sfz"/> <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <constraints> <constraint firstItem="N47-hP-xDu" firstAttribute="trailing" secondItem="hr1-AR-Kcc" secondAttribute="trailing" id="00z-KU-gma"/> - <constraint firstItem="N47-hP-xDu" firstAttribute="bottom" secondItem="hr1-AR-Kcc" secondAttribute="bottom" id="CbP-Yl-3OZ"/> + <constraint firstItem="N47-hP-xDu" firstAttribute="bottom" secondItem="hr1-AR-Kcc" secondAttribute="bottom" id="CbP-Yl-3OZ" userLabel="Button Container View Bottom Constraint"/> <constraint firstAttribute="trailing" secondItem="qRW-a0-uEt" secondAttribute="trailing" id="NvV-4f-UXu"/> <constraint firstItem="qRW-a0-uEt" firstAttribute="leading" secondItem="hr1-AR-Kcc" secondAttribute="leading" id="Owz-yP-dfr"/> <constraint firstItem="N47-hP-xDu" firstAttribute="leading" secondItem="hr1-AR-Kcc" secondAttribute="leading" id="aq0-ab-Svm"/> <constraint firstItem="qRW-a0-uEt" firstAttribute="top" secondItem="qDC-aT-Sfz" secondAttribute="top" id="g17-8h-gCH"/> <constraint firstItem="N47-hP-xDu" firstAttribute="top" secondItem="qRW-a0-uEt" secondAttribute="bottom" id="kYJ-aF-Uio"/> </constraints> - <viewLayoutGuide key="safeArea" id="qDC-aT-Sfz"/> </view> <connections> - <outlet property="buttonContainerViewBottomConstraint" destination="CbP-Yl-3OZ" id="SgM-zd-bS0"/> + <outlet property="buttonContainerBottomConstraint" destination="CbP-Yl-3OZ" id="HZV-8k-dVw"/> <outlet property="buttonContainerViewHeightConstraint" destination="j00-iE-vY5" id="TES-Ic-0gY"/> <outlet property="buttonViewContainer" destination="N47-hP-xDu" id="wQG-3m-IgK"/> </connections> @@ -55,12 +53,12 @@ </objects> <point key="canvasLocation" x="2591" y="95"/> </scene> - <!--Register Domain Suggestions Table View Controller--> + <!--Domain Suggestions Table View Controller--> <scene sceneID="MWs-Pg-0fY"> <objects> - <viewController id="CFb-K0-jrQ" customClass="RegisterDomainSuggestionsTableViewController" customModule="WordPress" customModuleProvider="target" sceneMemberID="viewController"> + <viewController id="CFb-K0-jrQ" customClass="DomainSuggestionsTableViewController" customModule="WordPress" customModuleProvider="target" sceneMemberID="viewController"> <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="UYc-Bx-TlF"> - <rect key="frame" x="0.0" y="0.0" width="375" height="563"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="583"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <inset key="separatorInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/> diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+Cells.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+Cells.swift similarity index 97% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+Cells.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+Cells.swift index 8d21fa84989c..deaba6e3ffe1 100644 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+Cells.swift +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+Cells.swift @@ -45,7 +45,8 @@ extension RegisterDomainDetailsViewController { value: row.value, placeholder: row.placeholder, valueColor: valueColor(row: row), - accessoryType: row.accessoryType() + accessoryType: row.accessoryType(), + valueSanitizer: row.valueSanitizer )) updateStyle(of: cell, at: indexPath) diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+HeaderFooter.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+HeaderFooter.swift similarity index 93% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+HeaderFooter.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+HeaderFooter.swift index e46d1907bd7a..584f175c1d03 100644 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+HeaderFooter.swift +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+HeaderFooter.swift @@ -9,9 +9,7 @@ extension RegisterDomainDetailsViewController { func configureTableFooterView(width: CGFloat = 0) { let width = width > 0 ? width : view.frame.size.width - var safeAreaInset: CGFloat = 0 - - safeAreaInset = tableView.safeAreaInsets.bottom + let safeAreaInset: CGFloat = tableView.safeAreaInsets.bottom //Creating a UIView with a custom frame because table tableFooterView doesn't support autolayout let footer = UIView(frame: CGRect(x: 0, @@ -20,12 +18,13 @@ extension RegisterDomainDetailsViewController { height: Constants.buttonContainerHeight + safeAreaInset)) footerView.frame = footer.frame footer.addSubview(footerView) - footer.addConstraints([ + + NSLayoutConstraint.activate([ footer.topAnchor.constraint(equalTo: footerView.topAnchor), - footer.rightAnchor.constraint(equalTo: footerView.rightAnchor), footer.bottomAnchor.constraint(equalTo: footerView.bottomAnchor), - footer.leftAnchor.constraint(equalTo: footerView.leftAnchor), - ]) + footer.leadingAnchor.constraint(equalTo: footerView.leadingAnchor), + footer.trailingAnchor.constraint(equalTo: footerView.trailingAnchor), + ]) tableView.tableFooterView = footer } diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+LocalizedStrings.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+LocalizedStrings.swift similarity index 97% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+LocalizedStrings.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+LocalizedStrings.swift index 9c6024172a29..0ff0f036b69c 100644 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+LocalizedStrings.swift +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController+LocalizedStrings.swift @@ -10,6 +10,10 @@ enum RegisterDomainDetails { "Please enter a valid Last Name", comment: "Register Domain - Domain contact information validation error message for an input field" ) + static let validationErrorOrganization = NSLocalizedString( + "Please enter a valid Organization", + comment: "Register Domain - Domain contact information validation error message for an input field" + ) static let validationErrorEmail = NSLocalizedString( "Please enter a valid Email", comment: "Register Domain - Domain contact information validation error message for an input field" diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController.swift similarity index 93% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController.swift index 25156c1a2b42..21e029be3b26 100644 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController.swift +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewController/RegisterDomainDetailsViewController.swift @@ -2,7 +2,7 @@ import UIKit import WordPressAuthenticator import WordPressEditor -class RegisterDomainDetailsViewController: NUXTableViewController { +class RegisterDomainDetailsViewController: UITableViewController { typealias Localized = RegisterDomainDetails.Localized typealias SectionIndex = RegisterDomainDetailsViewModel.SectionIndex @@ -22,6 +22,7 @@ class RegisterDomainDetailsViewController: NUXTableViewController { private(set) lazy var footerView: RegisterDomainDetailsFooterView = { let buttonView = RegisterDomainDetailsFooterView.loadFromNib() + buttonView.translatesAutoresizingMaskIntoConstraints = false buttonView.submitButton.isEnabled = false buttonView.submitButton.addTarget( @@ -49,6 +50,18 @@ class RegisterDomainDetailsViewController: NUXTableViewController { configureView() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // This form is only used to redeem an existing domain credit + WPAnalytics.track( + .domainsRegistrationFormViewed, + properties: WPAnalytics.domainsProperties( + usingCredit: true, + origin: .menu + ) + ) + } private func configureView() { title = NSLocalizedString("Register domain", @@ -66,10 +79,6 @@ class RegisterDomainDetailsViewController: NUXTableViewController { setupEditingEndingTapGestureRecognizer() } - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - configureTableFooterView(width: size.width) - } - private func configureTableView() { configureTableFooterView() @@ -184,6 +193,14 @@ class RegisterDomainDetailsViewController: NUXTableViewController { extension RegisterDomainDetailsViewController { @objc private func registerDomainButtonTapped(sender: UIButton) { + WPAnalytics.track( + .domainsRegistrationFormSubmitted, + properties: WPAnalytics.domainsProperties( + usingCredit: true, + origin: nil + ) + ) + viewModel.register() } @@ -207,13 +224,17 @@ extension RegisterDomainDetailsViewController: InlineEditableNameValueCellDelega viewModel.updateValue(text, at: indexPath) if sectionType == .address, - viewModel.addressSectionIndexHelper.addressField(for: indexPath.row) == .addressLine, + viewModel.addressSectionIndexHelper.addressField(for: indexPath.row) == .addressLine1, indexPath.row == viewModel.addressSectionIndexHelper.extraAddressLineCount, text.isEmpty == false { viewModel.enableAddAddressRow() } } + func inlineEditableNameValueCell(_ cell: InlineEditableNameValueCell, valueTextFieldEditingDidEnd text: String) { + inlineEditableNameValueCell(cell, valueTextFieldDidChange: text) + } + func inlineEditableNameValueCell(_ cell: InlineEditableNameValueCell, valueTextFieldShouldReturn textField: UITextField) -> Bool { guard let indexPath = tableView.indexPath(for: cell) else { return false diff --git a/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsServiceProxy.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsServiceProxy.swift new file mode 100644 index 000000000000..4c5a16233d1e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsServiceProxy.swift @@ -0,0 +1,231 @@ +import Foundation +import CoreData + +/// Protocol for cart response, empty because there are no external details. +protocol CartResponseProtocol {} + +extension CartResponse: CartResponseProtocol {} + +/// A proxy for being able to use dependency injection for RegisterDomainDetailsViewModel +/// especially for unittest mocking purposes +protocol RegisterDomainDetailsServiceProxyProtocol { + + func validateDomainContactInformation(contactInformation: [String: String], + domainNames: [String], + success: @escaping (ValidateDomainContactInformationResponse) -> Void, + failure: @escaping (Error) -> Void) + + func getDomainContactInformation(success: @escaping (DomainContactInformation) -> Void, + failure: @escaping (Error) -> Void) + + func getSupportedCountries(success: @escaping ([WPCountry]) -> Void, + failure: @escaping (Error) -> Void) + + func getStates(for countryCode: String, + success: @escaping ([WPState]) -> Void, + failure: @escaping (Error) -> Void) + + func purchaseDomainUsingCredits( + siteID: Int, + domainSuggestion: DomainSuggestion, + domainContactInformation: [String: String], + privacyProtectionEnabled: Bool, + success: @escaping (String) -> Void, + failure: @escaping (Error) -> Void) + + func createTemporaryDomainShoppingCart( + siteID: Int, + domainSuggestion: DomainSuggestion, + privacyProtectionEnabled: Bool, + success: @escaping (CartResponseProtocol) -> Void, + failure: @escaping (Error) -> Void) + + func createPersistentDomainShoppingCart(siteID: Int, + domainSuggestion: DomainSuggestion, + privacyProtectionEnabled: Bool, + success: @escaping (CartResponseProtocol) -> Void, + failure: @escaping (Error) -> Void) + + func redeemCartUsingCredits(cart: CartResponseProtocol, + domainContactInformation: [String: String], + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) + + func setPrimaryDomain( + siteID: Int, + domain: String, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) +} + +class RegisterDomainDetailsServiceProxy: RegisterDomainDetailsServiceProxyProtocol { + + private lazy var context = { + ContextManager.sharedInstance().mainContext + }() + + private lazy var restApi: WordPressComRestApi = { + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) + return account?.wordPressComRestApi ?? WordPressComRestApi.defaultApi(oAuthToken: "") + }() + + private lazy var domainService = { + DomainsService(coreDataStack: ContextManager.shared, remote: domainsServiceRemote) + }() + + private lazy var domainsServiceRemote = { + DomainsServiceRemote(wordPressComRestApi: restApi) + }() + + private lazy var transactionsServiceRemote = { + TransactionsServiceRemote(wordPressComRestApi: restApi) + }() + + func validateDomainContactInformation(contactInformation: [String: String], + domainNames: [String], + success: @escaping (ValidateDomainContactInformationResponse) -> Void, + failure: @escaping (Error) -> Void) { + domainsServiceRemote.validateDomainContactInformation( + contactInformation: contactInformation, + domainNames: domainNames, + success: success, + failure: failure + ) + } + + func getDomainContactInformation(success: @escaping (DomainContactInformation) -> Void, + failure: @escaping (Error) -> Void) { + domainsServiceRemote.getDomainContactInformation(success: success, + failure: failure) + } + + func getSupportedCountries(success: @escaping ([WPCountry]) -> Void, + failure: @escaping (Error) -> Void) { + transactionsServiceRemote.getSupportedCountries(success: success, + failure: failure) + } + + func getStates(for countryCode: String, + success: @escaping ([WPState]) -> Void, + failure: @escaping (Error) -> Void) { + domainsServiceRemote.getStates(for: countryCode, + success: success, + failure: failure) + } + + /// Convenience method to perform a full domain purchase. + /// + func purchaseDomainUsingCredits( + siteID: Int, + domainSuggestion: DomainSuggestion, + domainContactInformation: [String: String], + privacyProtectionEnabled: Bool, + success: @escaping (String) -> Void, + failure: @escaping (Error) -> Void) { + + let domainName = domainSuggestion.domainName + + createTemporaryDomainShoppingCart( + siteID: siteID, + domainSuggestion: domainSuggestion, + privacyProtectionEnabled: privacyProtectionEnabled, + success: { cart in + self.redeemCartUsingCredits( + cart: cart, + domainContactInformation: domainContactInformation, + success: { + self.recordDomainPurchase( + siteID: siteID, + domain: domainName, + isPrimaryDomain: false) + success(domainName) + }, + failure: failure) + }, failure: failure) + } + + /// Records that a domain purchase took place. + /// + func recordDomainPurchase( + siteID: Int, + domain: String, + isPrimaryDomain: Bool) { + + let domain = Domain( + domainName: domain, + isPrimaryDomain: isPrimaryDomain, + domainType: .registered) + + domainService.create(domain, forSite: siteID) + + if let blog = try? Blog.lookup(withID: siteID, in: context) { + blog.hasDomainCredit = false + } + } + + func createTemporaryDomainShoppingCart( + siteID: Int, + domainSuggestion: DomainSuggestion, + privacyProtectionEnabled: Bool, + success: @escaping (CartResponseProtocol) -> Void, + failure: @escaping (Error) -> Void) { + + transactionsServiceRemote.createTemporaryDomainShoppingCart(siteID: siteID, + domainSuggestion: domainSuggestion, + privacyProtectionEnabled: privacyProtectionEnabled, + success: success, + failure: failure) + } + + func createPersistentDomainShoppingCart(siteID: Int, + domainSuggestion: DomainSuggestion, + privacyProtectionEnabled: Bool, + success: @escaping (CartResponseProtocol) -> Void, + failure: @escaping (Error) -> Void) { + + transactionsServiceRemote.createPersistentDomainShoppingCart(siteID: siteID, + domainSuggestion: domainSuggestion, + privacyProtectionEnabled: privacyProtectionEnabled, + success: success, + failure: failure) + } + + func redeemCartUsingCredits(cart: CartResponseProtocol, + domainContactInformation: [String: String], + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + guard let cartResponse = cart as? CartResponse else { + fatalError() + } + transactionsServiceRemote.redeemCartUsingCredits(cart: cartResponse, + domainContactInformation: domainContactInformation, + success: success, + failure: failure) + } + + func setPrimaryDomain(siteID: Int, + domain: String, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + + if let blog = try? Blog.lookup(withID: siteID, in: context), + let domains = blog.domains as? Set<ManagedDomain>, + let newPrimaryDomain = domains.first(where: { $0.domainName == domain }) { + + for existingPrimaryDomain in domains.filter({ $0.isPrimary }) { + existingPrimaryDomain.isPrimary = false + } + + newPrimaryDomain.isPrimary = true + + ContextManager.shared.save(context) + } + + domainsServiceRemote.setPrimaryDomainForSite(siteID: siteID, + domain: domain, + success: success, + failure: failure) + } + + +} diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+CellIndex.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+CellIndex.swift similarity index 93% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+CellIndex.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+CellIndex.swift index 3c8dc639d705..632a01dfb62a 100644 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+CellIndex.swift +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+CellIndex.swift @@ -46,7 +46,8 @@ extension RegisterDomainDetailsViewModel { } enum AddressField { - case addressLine + case addressLine1 + case addressLine2 case addNewAddressLine case city case state @@ -90,8 +91,10 @@ extension RegisterDomainDetailsViewModel { return .state } else if postalCodeIndex == index { return .postalCode + } else if addressLine1 == index { + return .addressLine1 } - return .addressLine + return .addressLine2 } } } diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+CountryDialCodes.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+CountryDialCodes.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+CountryDialCodes.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+CountryDialCodes.swift diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowDefinitions.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowDefinitions.swift similarity index 91% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowDefinitions.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowDefinitions.swift index 8747ac7f18d3..a4c6db1f1756 100644 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowDefinitions.swift +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowDefinitions.swift @@ -40,6 +40,7 @@ extension RegisterDomainDetailsViewModel { } var validationBlock: RowValidationBlock? var errorMessage: String? + var serverSideErrorMessage: String? var context: Context var validationStateChanged: ValidationStateChangedHandler? @@ -67,6 +68,7 @@ extension RegisterDomainDetailsViewModel { typealias ValidationStateChangedHandler = ((EditableKeyValueRow, ValidationRule) -> Void) typealias ValueChangeHandler = ((EditableKeyValueRow) -> Void) + typealias ValueSanitizerBlock = (_ value: String?) -> String? enum EditingStyle: Int { case inline @@ -99,13 +101,15 @@ extension RegisterDomainDetailsViewModel { } } var valueChangeHandler: ValueChangeHandler? + var valueSanitizer: ValueSanitizerBlock? init(key: String, jsonKey: String, value: String?, placeholder: String?, editingStyle: EditingStyle, - validationRules: [ValidationRule] = []) { + validationRules: [ValidationRule] = [], + valueSanitizer: ValueSanitizerBlock? = nil) { self.key = key self.jsonKey = jsonKey @@ -113,6 +117,7 @@ extension RegisterDomainDetailsViewModel { self.placeholder = placeholder self.editingStyle = editingStyle self.validationRules = validationRules + self.valueSanitizer = valueSanitizer } private func registerForValidationStateChangedEvent() { @@ -140,8 +145,8 @@ extension RegisterDomainDetailsViewModel { func validationErrors(forContext context: ValidationRule.Context) -> [String] { return validationRules - .filter { return $0.context == context && !$0.isValid } - .compactMap { $0.errorMessage } + .filter { return $0.context == context && !$0.isValid && $0.errorMessage != nil } + .compactMap { "\($0.errorMessage ?? ""). \($0.serverSideErrorMessage ?? "")" } } func isValid(inContext context: ValidationRule.Context) -> Bool { diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowList.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowList.swift similarity index 85% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowList.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowList.swift index dec75f89aaab..c15ada3f692d 100644 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowList.swift +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+RowList.swift @@ -55,6 +55,8 @@ extension RegisterDomainDetailsViewModel { errorMessage = Localized.validationErrorFirstName case Localized.ContactInformation.lastName: errorMessage = Localized.validationErrorLastName + case Localized.ContactInformation.organization: + errorMessage = Localized.validationErrorOrganization case Localized.ContactInformation.email: errorMessage = Localized.validationErrorEmail case Localized.ContactInformation.country: @@ -79,6 +81,13 @@ extension RegisterDomainDetailsViewModel { errorMessage: errorMessage) } + static func transformToLatinASCII(value: String?) -> String? { + let toLatinASCII = StringTransform(rawValue: "Latin-ASCII") // See http://userguide.icu-project.org/transforms/general for more options. + return value?.applyingTransform(toLatinASCII, reverse: false) + } + + // MARK: - Rows + static var contactInformationRows: [RowType] { return [ .inlineEditable(.init( @@ -88,7 +97,8 @@ extension RegisterDomainDetailsViewModel { placeholder: Localized.ContactInformation.firstName, editingStyle: .inline, validationRules: [nonEmptyRule, - serverSideRule(with: Localized.ContactInformation.firstName)] + serverSideRule(with: Localized.ContactInformation.firstName)], + valueSanitizer: transformToLatinASCII )), .inlineEditable(.init( key: Localized.ContactInformation.lastName, @@ -97,14 +107,17 @@ extension RegisterDomainDetailsViewModel { placeholder: Localized.ContactInformation.lastName, editingStyle: .inline, validationRules: [nonEmptyRule, - serverSideRule(with: Localized.ContactInformation.lastName)] + serverSideRule(with: Localized.ContactInformation.lastName)], + valueSanitizer: transformToLatinASCII )), .inlineEditable(.init( key: Localized.ContactInformation.organization, jsonKey: "organization", value: nil, placeholder: Localized.ContactInformation.organizationPlaceholder, - editingStyle: .inline + editingStyle: .inline, + validationRules: [serverSideRule(with: Localized.ContactInformation.organization)], + valueSanitizer: transformToLatinASCII )), .inlineEditable(.init( key: Localized.ContactInformation.email, @@ -158,7 +171,8 @@ extension RegisterDomainDetailsViewModel { value: nil, placeholder: Localized.Address.addressPlaceholder, editingStyle: .inline, - validationRules: optional ? [] : [nonEmptyRule, serverSideRule(with: Localized.Address.addressLine)] + validationRules: optional ? [serverSideRule(with: Localized.Address.addressLine)] : [nonEmptyRule, serverSideRule(with: Localized.Address.addressLine)], + valueSanitizer: transformToLatinASCII )) } @@ -171,7 +185,8 @@ extension RegisterDomainDetailsViewModel { value: nil, placeholder: Localized.Address.city, editingStyle: .inline, - validationRules: [nonEmptyRule, serverSideRule(with: Localized.Address.city)] + validationRules: [nonEmptyRule, serverSideRule(with: Localized.Address.city)], + valueSanitizer: transformToLatinASCII )), .inlineEditable(.init( key: Localized.Address.state, @@ -179,7 +194,8 @@ extension RegisterDomainDetailsViewModel { value: nil, placeholder: Localized.Address.statePlaceHolder, editingStyle: .multipleChoice, - validationRules: [serverSideRule(with: Localized.Address.state)] + validationRules: [serverSideRule(with: Localized.Address.state)], + valueSanitizer: transformToLatinASCII )), .inlineEditable(.init( key: Localized.Address.postalCode, @@ -187,7 +203,8 @@ extension RegisterDomainDetailsViewModel { value: nil, placeholder: Localized.Address.postalCode, editingStyle: .inline, - validationRules: [nonEmptyRule, serverSideRule(with: Localized.Address.postalCode)] + validationRules: [nonEmptyRule, serverSideRule(with: Localized.Address.postalCode)], + valueSanitizer: transformToLatinASCII )) ] } diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+SectionDefinitions.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+SectionDefinitions.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+SectionDefinitions.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel+SectionDefinitions.swift diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel.swift similarity index 82% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel.swift index 45027dcf9913..58d4f8ac4b50 100644 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/ViewModel/RegisterDomainDetailsViewModel.swift @@ -23,7 +23,8 @@ class RegisterDomainDetailsViewModel { case checkMarkRowsUpdated(sectionIndex: Int) - case registerSucceeded(domain: String) + case registerSucceeded(_ domain: String) + case domainIsPrimary(domain: String) case loading(Bool) @@ -46,8 +47,8 @@ class RegisterDomainDetailsViewModel { var registerDomainDetailsService: RegisterDomainDetailsServiceProxyProtocol = RegisterDomainDetailsServiceProxy() - let domain: DomainSuggestion - let site: JetpackSiteRef + let domain: FullyQuotedDomainSuggestion + let siteID: Int let domainPurchasedCallback: ((String) -> Void) private(set) var addressSectionIndexHelper = CellIndex.AddressSectionIndexHelper() @@ -67,8 +68,8 @@ class RegisterDomainDetailsViewModel { } } - init(site: JetpackSiteRef, domain: DomainSuggestion, domainPurchasedCallback: @escaping ((String) -> Void)) { - self.site = site + init(siteID: Int, domain: FullyQuotedDomainSuggestion, domainPurchasedCallback: @escaping ((String) -> Void)) { + self.siteID = siteID self.domain = domain self.domainPurchasedCallback = domainPurchasedCallback manuallyTriggerValidation() @@ -179,67 +180,47 @@ class RegisterDomainDetailsViewModel { } func register() { + let domainSuggestion = domain + let contactInformation = jsonRepresentation() let privacyEnabled = privacySectionSelectedItem() == CellIndex.PrivacyProtection.privately + let registerDomainService = registerDomainDetailsService + let siteID = siteID + let onChange = onChange isLoading = true validateRemotely(successCompletion: { [weak self] in - guard let strongSelf = self else { - self?.isLoading = false - return - } - WPAnalytics.track(.automatedTransferCustomDomainContactInfoValidated) - // This is a bit of a callback hell, but our services aren't super mobile friendly. - // We'll manage. - - // First step is to create a cart. - strongSelf.registerDomainDetailsService.createShoppingCart(siteID: strongSelf.site.siteID, - domainSuggestion: strongSelf.domain, - privacyProtectionEnabled: privacyEnabled, - success: { cart in - - - // And now that we have a cart — time to redeem it. - strongSelf.registerDomainDetailsService.redeemCartUsingCredits(cart: cart, - domainContactInformation: strongSelf.jsonRepresentation(), - success: { - - // Hey! We redeemed the cart sucessfully. Now we just need to set the new domain to primary... - strongSelf.registerDomainDetailsService.changePrimaryDomain(siteID: strongSelf.site.siteID, - newDomain: strongSelf.domain.domainName, - success: { - - // We've succeeded! The domain is purchased and set to the primary one — time to drop out of this flow - // and return control to the Plugins, which will present the AT flow. (The VC handles that after getting the `.registerSucceeded` message.) - WPAnalytics.track(.automatedTransferCustomDomainPurchased) - - strongSelf.isLoading = false - strongSelf.onChange?(.registerSucceeded(domain: strongSelf.domain.domainName)) - }, - failure: { error in - - // This means we've sucessfully bought/redeemed the domain, but something went wrong - // when setting it to a primary. - - strongSelf.isLoading = false - strongSelf.onChange?(.prefillError(message: Localized.changingPrimaryDomainError)) + registerDomainService.purchaseDomainUsingCredits( + siteID: siteID, + domainSuggestion: domainSuggestion.remoteSuggestion(), + domainContactInformation: contactInformation, + privacyProtectionEnabled: privacyEnabled, + success: { domain in + registerDomainService.setPrimaryDomain( + siteID: siteID, + domain: domain, + success: { + self?.isLoading = false + + WPAnalytics.track(.automatedTransferCustomDomainPurchased) + + onChange?(.registerSucceeded(domain)) + onChange?(.domainIsPrimary(domain: domain)) + }, failure: { _ in + self?.isLoading = false + + // Setting the domain as primary doesn't affect the success of registering the domain + // so we'll simply ignore this for now. If we want to highlight this as an error to + // the user we could opt to show a Notice in the future. + onChange?(.registerSucceeded(domain)) }) - }, failure: { (error) in - - // Failure during the purchase step. Not much we can do from the mobile in any case, - // so let's just show a generic error message. - WPAnalytics.track(.automatedTransferCustomDomainPurchaseFailed) - strongSelf.isLoading = false - strongSelf.onChange?(.prefillError(message: Localized.redemptionError)) - }) - }) { (error) in - - // Same as above. If adding items to cart fails, not much we can do to recover :( - WPAnalytics.track(.automatedTransferCustomDomainPurchaseFailed) - strongSelf.isLoading = false - strongSelf.onChange?(.prefillError(message: Localized.redemptionError)) - } + }, failure: { error in + // Same as above. If adding items to cart fails, not much we can do to recover :( + WPAnalytics.track(.automatedTransferCustomDomainPurchaseFailed) + self?.isLoading = false + onChange?(.prefillError(message: Localized.redemptionError)) + }) }) } @@ -507,7 +488,7 @@ extension RegisterDomainDetailsViewModel { return } - if response.success { + if response.success && !response.hasMessages { strongSelf.clearValidationErrors() strongSelf.onChange?(.remoteValidationFinished) successCompletion() @@ -560,11 +541,10 @@ extension RegisterDomainDetailsViewModel { fileprivate func updateContactInformationValidationErrors(messages: ValidateDomainContactInformationResponse.Messages) { let rows = sections[SectionIndex.contactInformation.rawValue].rows for (index, row) in rows.enumerated() { - if let editableRow = row.editableRow, + if let rule = row.editableRow?.firstRule(forContext: .serverSide), let cellIndex = CellIndex.ContactInformation(rawValue: index) { - editableRow.firstRule( - forContext: .serverSide - )?.isValid = messages.isValid(for: cellIndex) + let serverSideErrorMessage = messages.serverSideErrorMessage(for: cellIndex) + update(rule: rule, with: serverSideErrorMessage) } } } @@ -572,14 +552,18 @@ extension RegisterDomainDetailsViewModel { fileprivate func updateAddressSectionValidationErrors(messages: ValidateDomainContactInformationResponse.Messages) { let rows = sections[SectionIndex.address.rawValue].rows for (index, row) in rows.enumerated() { - if let editableRow = row.editableRow { + if let rule = row.editableRow?.firstRule(forContext: .serverSide) { let addressField = addressSectionIndexHelper.addressField(for: index) - editableRow.firstRule( - forContext: .serverSide - )?.isValid = messages.isValid(addressField: addressField) + let serverSideErrorMessage = messages.serverSideErrorMessage(addressField: addressField) + update(rule: rule, with: serverSideErrorMessage) } } } + + fileprivate func update(rule: ValidationRule, with serverSideErrorMessage: String?) { + rule.isValid = (serverSideErrorMessage == nil) + rule.serverSideErrorMessage = serverSideErrorMessage + } } extension ValidateDomainContactInformationResponse.Messages { @@ -588,18 +572,18 @@ extension ValidateDomainContactInformationResponse.Messages { typealias AddressField = RegisterDomainDetailsViewModel.CellIndex.AddressField typealias PhoneNumber = RegisterDomainDetailsViewModel.CellIndex.PhoneNumber - func isValid(for index: ContactInformation) -> Bool { + func serverSideErrorMessage(for index: ContactInformation) -> String? { switch index { case .country: - return countryCode?.isEmpty ?? true + return countryCode?.first case .email: - return email?.isEmpty ?? true + return email?.first case .firstName: - return firstName?.isEmpty ?? true + return firstName?.first case .lastName: - return lastName?.isEmpty ?? true - default: - return true + return lastName?.first + case .organization: + return organization?.first } } @@ -607,18 +591,21 @@ extension ValidateDomainContactInformationResponse.Messages { return phone?.isEmpty ?? true } - func isValid(addressField: AddressField) -> Bool { + func serverSideErrorMessage(addressField: AddressField) -> String? { switch addressField { - case .addressLine: - return address1?.isEmpty ?? true + case .addressLine1: + return address1?.first + case .addressLine2: + return address2?.first case .city: - return city?.isEmpty ?? true + return city?.first case .postalCode: - return postalCode?.isEmpty ?? true + return postalCode?.first case .state: - return state?.isEmpty ?? true + return state?.first default: - return true + return nil } } + } diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/Views/RegisterDomainDetailsErrorSectionFooter.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/Views/RegisterDomainDetailsErrorSectionFooter.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/Views/RegisterDomainDetailsErrorSectionFooter.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/Views/RegisterDomainDetailsErrorSectionFooter.swift diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/Views/RegisterDomainDetailsErrorSectionFooter.xib b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/Views/RegisterDomainDetailsErrorSectionFooter.xib similarity index 100% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/Views/RegisterDomainDetailsErrorSectionFooter.xib rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/Views/RegisterDomainDetailsErrorSectionFooter.xib diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/Views/RegisterDomainDetailsFooterView.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/Views/RegisterDomainDetailsFooterView.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/Views/RegisterDomainDetailsFooterView.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/Views/RegisterDomainDetailsFooterView.swift diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/Views/RegisterDomainDetailsFooterView.xib b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/Views/RegisterDomainDetailsFooterView.xib similarity index 100% rename from WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/Views/RegisterDomainDetailsFooterView.xib rename to WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainDetails/Views/RegisterDomainDetailsFooterView.xib diff --git a/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainSuggestions/DomainSuggestionViewControllerWrapper.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainSuggestions/DomainSuggestionViewControllerWrapper.swift new file mode 100644 index 000000000000..ad420d334c3d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainSuggestions/DomainSuggestionViewControllerWrapper.swift @@ -0,0 +1,47 @@ +import SwiftUI +import UIKit +import WordPressKit + +/// Makes RegisterDomainSuggestionsViewController available to SwiftUI +struct DomainSuggestionViewControllerWrapper: UIViewControllerRepresentable { + + private let blog: Blog + private let domainType: DomainType + private let onDismiss: () -> Void + + private var domainSuggestionViewController: RegisterDomainSuggestionsViewController + + init(blog: Blog, domainType: DomainType, onDismiss: @escaping () -> Void) { + self.blog = blog + self.domainType = domainType + self.onDismiss = onDismiss + self.domainSuggestionViewController = RegisterDomainSuggestionsViewController.instance(site: blog, + domainType: domainType, + includeSupportButton: false) + } + + func makeUIViewController(context: Context) -> LightNavigationController { + let blogService = BlogService(coreDataStack: ContextManager.shared) + + self.domainSuggestionViewController.domainPurchasedCallback = { domain in + blogService.syncBlogAndAllMetadata(self.blog) { } + WPAnalytics.track(.domainCreditRedemptionSuccess) + self.presentDomainCreditRedemptionSuccess(domain: domain) + } + + let navigationController = LightNavigationController(rootViewController: domainSuggestionViewController) + return navigationController + } + + func updateUIViewController(_ uiViewController: LightNavigationController, context: Context) { } + + private func presentDomainCreditRedemptionSuccess(domain: String) { + + let controller = DomainCreditRedemptionSuccessViewController(domain: domain) { _ in + self.domainSuggestionViewController.dismiss(animated: true) { + self.onDismiss() + } + } + domainSuggestionViewController.present(controller, animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainSuggestions/DomainSuggestionsTableViewController.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainSuggestions/DomainSuggestionsTableViewController.swift new file mode 100644 index 000000000000..ea11d81e267f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainSuggestions/DomainSuggestionsTableViewController.swift @@ -0,0 +1,555 @@ +import UIKit +import SVProgressHUD +import WordPressAuthenticator + + +protocol DomainSuggestionsTableViewControllerDelegate { + func domainSelected(_ domain: FullyQuotedDomainSuggestion) + func newSearchStarted() +} + +/// This class provides domain suggestions based on keyword searches +/// performed by the user. +/// +class DomainSuggestionsTableViewController: UITableViewController { + + // MARK: - Fonts + + private let domainBaseFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + private let domainTLDFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + private let saleCostFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) + private let suggestionCostFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) + private let perYearPostfixFont = WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .regular) + private let freeForFirstYearFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) + + // MARK: - Cell Identifiers + + private static let suggestionCellIdentifier = "org.wordpress.domainsuggestionstable.suggestioncell" + + // MARK: - Properties + + var blog: Blog? + var siteName: String? + var delegate: DomainSuggestionsTableViewControllerDelegate? + var domainSuggestionType: DomainsServiceRemote.DomainSuggestionType = .noWordpressDotCom + var domainType: DomainType? + var freeSiteAddress: String = "" + + var useFadedColorForParentDomains: Bool { + return false + } + + var searchFieldPlaceholder: String { + return NSLocalizedString( + "Type to get more suggestions", + comment: "Register domain - Search field placeholder for the Suggested Domain screen" + ) + } + + private var noResultsViewController: NoResultsViewController? + private var siteTitleSuggestions: [FullyQuotedDomainSuggestion] = [] + private var searchSuggestions: [FullyQuotedDomainSuggestion] = [] { + didSet { + tableView.reloadSections(IndexSet(integer: Sections.suggestions.rawValue), with: .automatic) + } + } + private var isSearching: Bool = false + private var selectedCell: UITableViewCell? + + // API returned no domain suggestions. + private var noSuggestions: Bool = false + + fileprivate enum ViewPadding: CGFloat { + case noResultsView = 60 + } + + private var parentDomainColor: UIColor { + return useFadedColorForParentDomains ? .neutral(.shade30) : .neutral(.shade70) + } + + private let searchDebouncer = Debouncer(delay: 0.5) + + // MARK: - Init + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + override func awakeFromNib() { + super.awakeFromNib() + + let bundle = WordPressAuthenticator.bundle + tableView.register(UINib(nibName: "SearchTableViewCell", bundle: bundle), forCellReuseIdentifier: SearchTableViewCell.reuseIdentifier) + setupBackgroundTapGestureRecognizer() + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + + WPStyleGuide.configureColors(view: view, tableView: tableView) + tableView.layoutMargins = WPStyleGuide.edgeInsetForLoginTextFields() + + navigationItem.title = NSLocalizedString("Create New Site", comment: "Title for the site creation flow.") + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // only procede with initial search if we don't have site title suggestions yet + // (hopefully only the first time) + guard siteTitleSuggestions.count < 1, + let nameToSearch = siteName else { + return + } + + suggestDomains(for: nameToSearch) { [weak self] (suggestions) in + self?.siteTitleSuggestions = suggestions + self?.tableView.reloadSections(IndexSet(integer: Sections.suggestions.rawValue), with: .automatic) + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + SVProgressHUD.dismiss() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle { + tableView.reloadData() + } + } + + /// Fetches new domain suggestions based on the provided string + /// + /// - Parameters: + /// - searchTerm: string to base suggestions on + /// - addSuggestions: function to call when results arrive + private func suggestDomains(for searchTerm: String, addSuggestions: @escaping (_: [FullyQuotedDomainSuggestion]) ->()) { + guard !isSearching else { + return + } + + isSearching = true + + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) + let api = account?.wordPressComRestApi ?? WordPressComRestApi.defaultApi(oAuthToken: "") + + let service = DomainsService(coreDataStack: ContextManager.sharedInstance(), remote: DomainsServiceRemote(wordPressComRestApi: api)) + + SVProgressHUD.setContainerView(tableView) + SVProgressHUD.show(withStatus: NSLocalizedString("Loading domains", comment: "Shown while the app waits for the domain suggestions web service to return during the site creation process.")) + + service.getFullyQuotedDomainSuggestions(query: searchTerm, + domainSuggestionType: domainSuggestionType, + success: handleGetDomainSuggestionsSuccess, + failure: handleGetDomainSuggestionsFailure) + } + + private func handleGetDomainSuggestionsSuccess(_ suggestions: [FullyQuotedDomainSuggestion]) { + isSearching = false + noSuggestions = false + SVProgressHUD.dismiss() + tableView.separatorStyle = .singleLine + + searchSuggestions = suggestions + } + + private func handleGetDomainSuggestionsFailure(_ error: Error) { + DDLogError("Error getting Domain Suggestions: \(error.localizedDescription)") + isSearching = false + noSuggestions = true + SVProgressHUD.dismiss() + tableView.separatorStyle = .none + + // Dismiss the keyboard so the full no results view can be seen. + view.endEditing(true) + + // Add no suggestions to display the no results view. + searchSuggestions = [] + } + + // MARK: background gesture recognizer + + /// Sets up a gesture recognizer to detect taps on the view, but not its content. + /// + func setupBackgroundTapGestureRecognizer() { + let gestureRecognizer = UITapGestureRecognizer() + gestureRecognizer.on { [weak self](gesture) in + self?.view.endEditing(true) + } + gestureRecognizer.cancelsTouchesInView = false + view.addGestureRecognizer(gestureRecognizer) + } +} + +// MARK: - UITableViewDataSource + +extension DomainSuggestionsTableViewController { + fileprivate enum Sections: Int, CaseIterable { + case topBanner + case searchField + case suggestions + + static var count: Int { + return allCases.count + } + } + + override func numberOfSections(in tableView: UITableView) -> Int { + return Sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch section { + case Sections.topBanner.rawValue: + return shouldShowTopBanner ? 1 : 0 + case Sections.searchField.rawValue: + return 1 + case Sections.suggestions.rawValue: + if noSuggestions == true { + return 1 + } + return searchSuggestions.count > 0 ? searchSuggestions.count : siteTitleSuggestions.count + default: + return 0 + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: UITableViewCell + switch indexPath.section { + case Sections.topBanner.rawValue: + cell = topBannerCell() + case Sections.searchField.rawValue: + cell = searchFieldCell() + case Sections.suggestions.rawValue: + fallthrough + default: + if noSuggestions == true { + cell = noResultsCell() + } else { + let suggestion: FullyQuotedDomainSuggestion + if searchSuggestions.count > 0 { + suggestion = searchSuggestions[indexPath.row] + } else { + suggestion = siteTitleSuggestions[indexPath.row] + } + cell = suggestionCell(suggestion) + } + } + return cell + } + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + + if indexPath.section == Sections.suggestions.rawValue && noSuggestions == true { + // Calculate the height of the no results cell from the bottom of + // the search field to the screen bottom, minus some padding. + let searchFieldRect = tableView.rect(forSection: Sections.searchField.rawValue) + let searchFieldBottom = searchFieldRect.origin.y + searchFieldRect.height + let screenBottom = UIScreen.main.bounds.height + return screenBottom - searchFieldBottom - ViewPadding.noResultsView.rawValue + } + + return super.tableView(tableView, heightForRowAt: indexPath) + } + + override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + if section == Sections.suggestions.rawValue { + let footer = UIView() + footer.backgroundColor = .neutral(.shade10) + return footer + } + return nil + } + + override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + if section == Sections.suggestions.rawValue { + return 0.5 + } + return 0 + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + if section == Sections.searchField.rawValue { + let header = UIView() + header.backgroundColor = tableView.backgroundColor + return header + } + return nil + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + if section == Sections.searchField.rawValue { + return 10 + } + return 0 + } + + // MARK: table view cells + + private func topBannerCell() -> UITableViewCell { + let cell = UITableViewCell() + guard let textLabel = cell.textLabel else { + return cell + } + + textLabel.font = UIFont.preferredFont(forTextStyle: .body) + textLabel.numberOfLines = 3 + textLabel.lineBreakMode = .byTruncatingTail + textLabel.adjustsFontForContentSizeCategory = true + textLabel.adjustsFontSizeToFitWidth = true + textLabel.minimumScaleFactor = 0.5 + + let template = NSLocalizedString("Domains purchased on this site will redirect to %@", comment: "Description for the first domain purchased with a free plan.") + let formatted = String(format: template, freeSiteAddress) + let attributed = NSMutableAttributedString(string: formatted, attributes: [:]) + + if let range = formatted.range(of: freeSiteAddress) { + attributed.addAttributes([.font: textLabel.font.bold()], range: NSRange(range, in: formatted)) + } + + textLabel.attributedText = attributed + + return cell + } + + private var shouldShowTopBanner: Bool { + if let domainType = domainType, + domainType == .siteRedirect { + return true + } + + return false + } + + private func searchFieldCell() -> SearchTableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: SearchTableViewCell.reuseIdentifier) as? SearchTableViewCell else { + fatalError() + } + + cell.allowSpaces = false + cell.liveSearch = true + cell.placeholder = searchFieldPlaceholder + cell.reloadTextfieldStyle() + cell.delegate = self + cell.selectionStyle = .none + cell.backgroundColor = .clear + return cell + } + + private func noResultsCell() -> UITableViewCell { + let cell = UITableViewCell() + addNoResultsTo(cell: cell) + cell.isUserInteractionEnabled = false + return cell + } + + // MARK: - Suggestion Cell + + private func suggestionCell(_ suggestion: FullyQuotedDomainSuggestion) -> UITableViewCell { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: Self.suggestionCellIdentifier) + + cell.textLabel?.attributedText = attributedDomain(suggestion.domainName) + cell.textLabel?.textColor = parentDomainColor + cell.indentationWidth = 20.0 + cell.indentationLevel = 1 + + if Feature.enabled(.domains) { + cell.detailTextLabel?.attributedText = attributedCostInformation(for: suggestion) + } + + return cell + } + + private func attributedDomain(_ domain: String) -> NSAttributedString { + let attributedDomain = NSMutableAttributedString(string: domain, attributes: [.font: domainBaseFont]) + + guard let dotPosition = domain.firstIndex(of: ".") else { + return attributedDomain + } + + let tldRange = dotPosition ..< domain.endIndex + let nsRange = NSRange(tldRange, in: domain) + + attributedDomain.addAttribute(.font, + value: domainTLDFont, + range: nsRange) + + return attributedDomain + } + + private func attributedCostInformation(for suggestion: FullyQuotedDomainSuggestion) -> NSAttributedString { + let attributedString = NSMutableAttributedString() + + let hasDomainCredit = blog?.hasDomainCredit ?? false + + if hasDomainCredit { + attributedString.append(attributedFreeForTheFirstYear()) + } else if let saleCost = attributedSaleCost(for: suggestion) { + attributedString.append(saleCost) + } + + attributedString.append(attributedSuggestionCost(for: suggestion, hasDomainCredit: hasDomainCredit)) + attributedString.append(attributedPerYearPostfix(for: suggestion, hasDomainCredit: hasDomainCredit)) + + return attributedString + } + + // MARK: - Attributed partial strings + + private func attributedFreeForTheFirstYear() -> NSAttributedString { + NSAttributedString( + string: NSLocalizedString("Free for the first year ", comment: "Label shown for domains that will be free for the first year due to the user having a premium plan with available domain credit."), + attributes: [.font: freeForFirstYearFont, .foregroundColor: UIColor.muriel(name: .green, .shade50)]) + } + + private func attributedSaleCost(for suggestion: FullyQuotedDomainSuggestion) -> NSAttributedString? { + guard let saleCostString = suggestion.saleCostString else { + return nil + } + + return NSAttributedString( + string: saleCostString + " ", + attributes: suggestionSaleCostAttributes()) + } + + private func attributedSuggestionCost(for suggestion: FullyQuotedDomainSuggestion, hasDomainCredit: Bool) -> NSAttributedString { + NSAttributedString( + string: suggestion.costString, + attributes: suggestionCostAttributes(striked: mustStrikeRegularPrice(suggestion, hasDomainCredit: hasDomainCredit))) + } + + private func attributedPerYearPostfix(for suggestion: FullyQuotedDomainSuggestion, hasDomainCredit: Bool) -> NSAttributedString { + NSAttributedString( + string: NSLocalizedString(" / year", comment: "Per-year postfix shown after a domain's cost."), + attributes: perYearPostfixAttributes(striked: mustStrikeRegularPrice(suggestion, hasDomainCredit: hasDomainCredit))) + } + + // MARK: - Attributed partial string attributes + + private func mustStrikeRegularPrice(_ suggestion: FullyQuotedDomainSuggestion, hasDomainCredit: Bool) -> Bool { + suggestion.saleCostString != nil || hasDomainCredit + } + + private func suggestionSaleCostAttributes() -> [NSAttributedString.Key: Any] { + [.font: suggestionCostFont, + .foregroundColor: UIColor.muriel(name: .orange, .shade50)] + } + + private func suggestionCostAttributes(striked: Bool) -> [NSAttributedString.Key: Any] { + [.font: suggestionCostFont, + .foregroundColor: striked ? UIColor.secondaryLabel : UIColor.label, + .strikethroughStyle: striked ? 1 : 0] + } + + private func perYearPostfixAttributes(striked: Bool) -> [NSAttributedString.Key: Any] { + [.font: perYearPostfixFont, + .foregroundColor: UIColor.secondaryLabel, + .strikethroughStyle: striked ? 1 : 0] + } +} + +// MARK: - NoResultsViewController Extension + +private extension DomainSuggestionsTableViewController { + + func addNoResultsTo(cell: UITableViewCell) { + if noResultsViewController == nil { + instantiateNoResultsViewController() + } + + guard let noResultsViewController = noResultsViewController else { + return + } + + noResultsViewController.view.frame = cell.frame + cell.contentView.addSubview(noResultsViewController.view) + + addChild(noResultsViewController) + noResultsViewController.didMove(toParent: self) + } + + func removeNoResultsFromView() { + noSuggestions = false + tableView.reloadSections(IndexSet(integer: Sections.suggestions.rawValue), with: .automatic) + noResultsViewController?.removeFromView() + } + + func instantiateNoResultsViewController() { + let title = NSLocalizedString("We couldn't find any available address with the words you entered - let's try again.", comment: "Primary message shown when there are no domains that match the user entered text.") + let subtitle = NSLocalizedString("Enter different words above and we'll look for an address that matches it.", comment: "Secondary message shown when there are no domains that match the user entered text.") + + noResultsViewController = NoResultsViewController.controllerWith(title: title, buttonTitle: nil, subtitle: subtitle) + } + +} + +// MARK: - UITableViewDelegate + +extension DomainSuggestionsTableViewController { + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let selectedDomain: FullyQuotedDomainSuggestion + + switch indexPath.section { + case Sections.suggestions.rawValue: + if searchSuggestions.count > 0 { + selectedDomain = searchSuggestions[indexPath.row] + } else { + selectedDomain = siteTitleSuggestions[indexPath.row] + } + default: + return + } + + delegate?.domainSelected(selectedDomain) + + tableView.deselectSelectedRowWithAnimation(true) + + // Uncheck the previously selected cell. + if let selectedCell = selectedCell { + selectedCell.accessoryType = .none + } + + // Check the currently selected cell. + if let cell = self.tableView.cellForRow(at: indexPath) { + cell.accessoryType = .checkmark + selectedCell = cell + } + } +} + +// MARK: - SearchTableViewCellDelegate + +extension DomainSuggestionsTableViewController: SearchTableViewCellDelegate { + func startSearch(for searchTerm: String) { + searchDebouncer.call { [weak self] in + self?.search(for: searchTerm) + } + } + + private func search(for searchTerm: String) { + removeNoResultsFromView() + delegate?.newSearchStarted() + + guard searchTerm.count > 0 else { + searchSuggestions = [] + return + } + + suggestDomains(for: searchTerm) { [weak self] (suggestions) in + self?.searchSuggestions = suggestions + } + } +} + +extension SearchTableViewCell { + fileprivate func reloadTextfieldStyle() { + textField.textColor = .text + textField.leftViewImage = UIImage(named: "icon-post-search") + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainSuggestions/RegisterDomainSuggestionsViewController.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainSuggestions/RegisterDomainSuggestionsViewController.swift new file mode 100644 index 000000000000..01e434c96f90 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Domain registration/RegisterDomainSuggestions/RegisterDomainSuggestionsViewController.swift @@ -0,0 +1,376 @@ +import SwiftUI +import UIKit +import WebKit +import WordPressAuthenticator +import WordPressFlux + +class RegisterDomainSuggestionsViewController: UIViewController { + @IBOutlet weak var buttonContainerBottomConstraint: NSLayoutConstraint! + @IBOutlet weak var buttonContainerViewHeightConstraint: NSLayoutConstraint! + + private var constraintsInitialized = false + + private var site: Blog! + var domainPurchasedCallback: ((String) -> Void)! + + private var domain: FullyQuotedDomainSuggestion? + private var siteName: String? + private var domainsTableViewController: DomainSuggestionsTableViewController? + private var domainType: DomainType = .registered + private var includeSupportButton: Bool = true + + private var webViewURLChangeObservation: NSKeyValueObservation? + + override func viewDidLoad() { + super.viewDidLoad() + configure() + hideButton() + } + + @IBOutlet private var buttonViewContainer: UIView! { + didSet { + buttonViewController.move(to: self, into: buttonViewContainer) + } + } + + private lazy var buttonViewController: NUXButtonViewController = { + let buttonViewController = NUXButtonViewController.instance() + buttonViewController.view.backgroundColor = .basicBackground + buttonViewController.delegate = self + buttonViewController.setButtonTitles( + primary: TextContent.primaryButtonTitle + ) + return buttonViewController + }() + + static func instance(site: Blog, + domainType: DomainType = .registered, + includeSupportButton: Bool = true, + domainPurchasedCallback: ((String) -> Void)? = nil) -> RegisterDomainSuggestionsViewController { + let storyboard = UIStoryboard(name: Constants.storyboardIdentifier, bundle: Bundle.main) + let controller = storyboard.instantiateViewController(withIdentifier: Constants.viewControllerIdentifier) as! RegisterDomainSuggestionsViewController + controller.site = site + controller.domainType = domainType + controller.domainPurchasedCallback = domainPurchasedCallback + controller.includeSupportButton = includeSupportButton + controller.siteName = siteNameForSuggestions(for: site) + + return controller + } + + private static func siteNameForSuggestions(for site: Blog) -> String? { + if let siteTitle = site.settings?.name?.nonEmptyString() { + return siteTitle + } + + if let siteUrl = site.url { + let components = URLComponents(string: siteUrl) + if let firstComponent = components?.host?.split(separator: ".").first { + return String(firstComponent) + } + } + + return nil + } + + private func configure() { + title = TextContent.title + WPStyleGuide.configureColors(view: view, tableView: nil) + + let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, + target: self, + action: #selector(handleCancelButtonTapped)) + navigationItem.leftBarButtonItem = cancelButton + + guard includeSupportButton else { + return + } + + let supportButton = UIBarButtonItem(title: TextContent.supportButtonTitle, + style: .plain, + target: self, + action: #selector(handleSupportButtonTapped)) + navigationItem.rightBarButtonItem = supportButton + } + + // MARK: - Bottom Hideable Button + + /// Shows the domain picking button + /// + private func showButton() { + buttonContainerBottomConstraint.constant = 0 + } + + /// Shows the domain picking button + /// + /// - Parameters: + /// - animated: whether the transition is animated. + /// + private func showButton(animated: Bool) { + guard animated else { + showButton() + return + } + + UIView.animate(withDuration: WPAnimationDurationDefault, animations: { [weak self] in + guard let self = self else { + return + } + + self.showButton() + + // Since the Button View uses auto layout, need to call this so the animation works properly. + self.view.layoutIfNeeded() + }, completion: nil) + } + + private func hideButton() { + buttonViewContainer.layoutIfNeeded() + buttonContainerBottomConstraint.constant = buttonViewContainer.frame.height + } + + /// Hides the domain picking button + /// + /// - Parameters: + /// - animated: whether the transition is animated. + /// + func hideButton(animated: Bool) { + guard animated else { + hideButton() + return + } + + UIView.animate(withDuration: WPAnimationDurationDefault, animations: { [weak self] in + guard let self = self else { + return + } + + self.hideButton() + + // Since the Button View uses auto layout, need to call this so the animation works properly. + self.view.layoutIfNeeded() + }, completion: nil) + } + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + super.prepare(for: segue, sender: sender) + + if let vc = segue.destination as? DomainSuggestionsTableViewController { + vc.delegate = self + vc.siteName = siteName + vc.blog = site + vc.domainType = domainType + vc.freeSiteAddress = site.freeSiteAddress + + if site.hasBloggerPlan { + vc.domainSuggestionType = .allowlistedTopLevelDomains(["blog"]) + } + + domainsTableViewController = vc + } + } + + // MARK: - Nav Bar Button Handling + + @objc private func handleCancelButtonTapped(sender: UIBarButtonItem) { + dismiss(animated: true) + } + + @objc private func handleSupportButtonTapped(sender: UIBarButtonItem) { + let supportVC = SupportTableViewController() + supportVC.showFromTabBar() + } + +} + +// MARK: - DomainSuggestionsTableViewControllerDelegate + +extension RegisterDomainSuggestionsViewController: DomainSuggestionsTableViewControllerDelegate { + func domainSelected(_ domain: FullyQuotedDomainSuggestion) { + WPAnalytics.track(.automatedTransferCustomDomainSuggestionSelected) + self.domain = domain + showButton(animated: true) + } + + func newSearchStarted() { + WPAnalytics.track(.automatedTransferCustomDomainSuggestionQueried) + hideButton(animated: true) + } +} + +// MARK: - NUXButtonViewControllerDelegate + +extension RegisterDomainSuggestionsViewController: NUXButtonViewControllerDelegate { + func primaryButtonPressed() { + guard let domain = domain else { + return + } + + WPAnalytics.track(.domainsSearchSelectDomainTapped, properties: WPAnalytics.domainsProperties(for: site), blog: site) + + switch domainType { + case .registered: + pushRegisterDomainDetailsViewController(domain) + case .siteRedirect: + setPrimaryButtonLoading(true) + createCartAndPresentWebView(domain) + default: + break + } + } + + private func setPrimaryButtonLoading(_ isLoading: Bool, afterDelay delay: Double = 0.0) { + // We're dispatching here so that we can wait until after the webview has been + // fully presented before we switch the button back to its default state. + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + self.buttonViewController.setBottomButtonState(isLoading: isLoading, + isEnabled: !isLoading) + } + } + + private func pushRegisterDomainDetailsViewController(_ domain: FullyQuotedDomainSuggestion) { + guard let siteID = site.dotComID?.intValue else { + DDLogError("Cannot register domains for sites without a dotComID") + return + } + + let controller = RegisterDomainDetailsViewController() + controller.viewModel = RegisterDomainDetailsViewModel(siteID: siteID, domain: domain, domainPurchasedCallback: domainPurchasedCallback) + self.navigationController?.pushViewController(controller, animated: true) + } + + private func createCartAndPresentWebView(_ domain: FullyQuotedDomainSuggestion) { + guard let siteID = site.dotComID?.intValue else { + DDLogError("Cannot register domains for sites without a dotComID") + return + } + + let proxy = RegisterDomainDetailsServiceProxy() + proxy.createPersistentDomainShoppingCart(siteID: siteID, + domainSuggestion: domain.remoteSuggestion(), + privacyProtectionEnabled: domain.supportsPrivacy ?? false, + success: { [weak self] _ in + self?.presentWebViewForCurrentSite(domainSuggestion: domain) + self?.setPrimaryButtonLoading(false, afterDelay: 0.25) + }, + failure: { error in }) + } + + static private let checkoutURLPrefix = "https://wordpress.com/checkout" + static private let checkoutSuccessURLPrefix = "https://wordpress.com/checkout/thank-you/" + + /// Handles URL changes in the web view. We only allow the user to stay within certain URLs. Falling outside these URLs + /// results in the web view being dismissed. This method also handles the success condition for a successful domain registration + /// through said web view. + /// + /// - Parameters: + /// - newURL: the newly set URL for the web view. + /// - siteID: the ID of the site we're trying to register the domain against. + /// - domain: the domain the user is purchasing. + /// - onCancel: the closure that will be executed if we detect the conditions for cancelling the registration were met. + /// - onSuccess: the closure that will be executed if we detect a successful domain registration. + /// + private func handleWebViewURLChange( + _ newURL: URL, + siteID: Int, + domain: String, + onCancel: () -> Void, + onSuccess: (String) -> Void) { + + let canOpenNewURL = newURL.absoluteString.starts(with: Self.checkoutURLPrefix) + + guard canOpenNewURL else { + onCancel() + return + } + + let domainRegistrationSucceeded = newURL.absoluteString.starts(with: Self.checkoutSuccessURLPrefix) + + if domainRegistrationSucceeded { + onSuccess(domain) + + } + } + + private func presentWebViewForCurrentSite(domainSuggestion: FullyQuotedDomainSuggestion) { + guard let homeURL = site.homeURL, + let siteUrl = URL(string: homeURL as String), let host = siteUrl.host, + let url = URL(string: Constants.checkoutWebAddress + host), + let siteID = site.dotComID?.intValue else { + return + } + + let webViewController = WebViewControllerFactory.controllerWithDefaultAccountAndSecureInteraction(url: url, source: "domains_register") + let navController = LightNavigationController(rootViewController: webViewController) + + // WORKAROUND: The reason why we have to use this mechanism to detect success and failure conditions + // for domain registration is because our checkout process (for some unknown reason) doesn't trigger + // call to WKWebViewDelegate methods. + // + // This was last checked by @diegoreymendez on 2021-09-22. + // + webViewURLChangeObservation = webViewController.webView.observe(\.url, options: .new) { [weak self] _, change in + guard let self = self, + let newURL = change.newValue as? URL else { + return + } + + self.handleWebViewURLChange(newURL, siteID: siteID, domain: domainSuggestion.domainName, onCancel: { + navController.dismiss(animated: true) + }) { domain in + self.dismiss(animated: true, completion: { [weak self] in + self?.domainPurchasedCallback(domain) + }) + } + } + + WPAnalytics.track(.domainsPurchaseWebviewViewed, properties: WPAnalytics.domainsProperties(for: site), blog: site) + + if let storeSandboxCookie = (HTTPCookieStorage.shared.cookies?.first { + + $0.properties?[.name] as? String == Constants.storeSandboxCookieName && + $0.properties?[.domain] as? String == Constants.storeSandboxCookieDomain + }) { + // this code will only run if a store sandbox cookie has been set + let webView = webViewController.webView + let cookieStore = webView.configuration.websiteDataStore.httpCookieStore + cookieStore.getAllCookies { [weak self] cookies in + + var newCookies = cookies + newCookies.append(storeSandboxCookie) + + cookieStore.setCookies(newCookies) { + self?.present(navController, animated: true) + } + } + } else { + present(navController, animated: true) + } + } +} + +// MARK: - Constants +extension RegisterDomainSuggestionsViewController { + + enum TextContent { + + static let title = NSLocalizedString("Search domains", + comment: "Search domain - Title for the Suggested domains screen") + static let primaryButtonTitle = NSLocalizedString("Select domain", + comment: "Register domain - Title for the Choose domain button of Suggested domains screen") + static let supportButtonTitle = NSLocalizedString("Help", comment: "Help button") + } + + enum Constants { + // storyboard identifiers + static let storyboardIdentifier = "RegisterDomain" + static let viewControllerIdentifier = "RegisterDomainSuggestionsViewController" + + static let checkoutWebAddress = "https://wordpress.com/checkout/" + // store sandbox cookie + static let storeSandboxCookieName = "store_sandbox" + static let storeSandboxCookieDomain = ".wordpress.com" + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Views/RegisterDomainSectionHeaderView.swift b/WordPress/Classes/ViewRelated/Domains/Domain registration/Views/RegisterDomainSectionHeaderView.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Domains/Views/RegisterDomainSectionHeaderView.swift rename to WordPress/Classes/ViewRelated/Domains/Domain registration/Views/RegisterDomainSectionHeaderView.swift diff --git a/WordPress/Classes/ViewRelated/Domains/Views/RegisterDomainSectionHeaderView.xib b/WordPress/Classes/ViewRelated/Domains/Domain registration/Views/RegisterDomainSectionHeaderView.xib similarity index 100% rename from WordPress/Classes/ViewRelated/Domains/Views/RegisterDomainSectionHeaderView.xib rename to WordPress/Classes/ViewRelated/Domains/Domain registration/Views/RegisterDomainSectionHeaderView.xib diff --git a/WordPress/Classes/ViewRelated/Domains/Domains.storyboard b/WordPress/Classes/ViewRelated/Domains/Domains.storyboard deleted file mode 100644 index 90657d82c84b..000000000000 --- a/WordPress/Classes/ViewRelated/Domains/Domains.storyboard +++ /dev/null @@ -1,114 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14113" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="5v1-yD-jqT"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> - <dependencies> - <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/> - <capability name="Alignment constraints with different attributes" minToolsVersion="5.1"/> - <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> - </dependencies> - <scenes> - <!--Domains List View Controller--> - <scene sceneID="hoY-95-SVw"> - <objects> - <tableViewController id="5v1-yD-jqT" customClass="DomainsListViewController" customModule="WordPress" customModuleProvider="target" sceneMemberID="viewController"> - <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="grouped" separatorStyle="default" rowHeight="58" sectionHeaderHeight="18" sectionFooterHeight="18" id="4cz-Z4-bMm"> - <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> - <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> - <color key="backgroundColor" red="0.93725490199999995" green="0.93725490199999995" blue="0.95686274510000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <prototypes> - <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" reuseIdentifier="WordPress.WPTableViewCellDefault" textLabel="tMM-h4-9pd" rowHeight="77" style="IBUITableViewCellStyleDefault" id="QMb-zj-sgC" customClass="WPTableViewCell"> - <rect key="frame" x="0.0" y="55.5" width="375" height="77"/> - <autoresizingMask key="autoresizingMask"/> - <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="QMb-zj-sgC" id="Lr2-6U-I5b"> - <rect key="frame" x="0.0" y="0.0" width="375" height="76.5"/> - <autoresizingMask key="autoresizingMask"/> - <subviews> - <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="tMM-h4-9pd"> - <rect key="frame" x="16" y="0.0" width="343" height="76.5"/> - <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> - <fontDescription key="fontDescription" type="system" pointSize="16"/> - <color key="textColor" cocoaTouchSystemColor="darkTextColor"/> - <nil key="highlightedColor"/> - </label> - </subviews> - </tableViewCellContentView> - </tableViewCell> - <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="WordPress.DomainListDomainCell" rowHeight="77" id="fPj-2Y-QhF" customClass="DomainListDomainCell" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="132.5" width="375" height="77"/> - <autoresizingMask key="autoresizingMask"/> - <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="fPj-2Y-QhF" id="Oon-ha-GOC"> - <rect key="frame" x="0.0" y="0.0" width="341" height="76.5"/> - <autoresizingMask key="autoresizingMask"/> - <subviews> - <stackView opaque="NO" contentMode="scaleToFill" misplaced="YES" axis="vertical" distribution="equalSpacing" alignment="top" spacing="20" baselineRelativeArrangement="YES" translatesAutoresizingMaskIntoConstraints="NO" id="lEn-Qd-WOQ"> - <rect key="frame" x="21" y="20" width="299" height="41.5"/> - <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="example.com" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="h3c-Hv-w4y"> - <rect key="frame" x="0.0" y="0.0" width="96.5" height="19.5"/> - <fontDescription key="fontDescription" type="system" pointSize="16"/> - <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> - <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="13" translatesAutoresizingMaskIntoConstraints="NO" id="wYO-I9-LJI"> - <rect key="frame" x="0.0" y="19.5" width="174.5" height="22"/> - <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Registered Domain" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Wxe-EB-maA"> - <rect key="frame" x="0.0" y="3" width="115" height="16"/> - <fontDescription key="fontDescription" type="system" pointSize="13"/> - <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="Primary" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ua5-18-oiR" customClass="BadgeLabel" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="128" y="0.0" width="46.5" height="22"/> - <color key="backgroundColor" red="0.2901960784" green="0.72156862749999995" blue="0.40000000000000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstAttribute="height" constant="22" id="v23-7m-B2y"/> - </constraints> - <fontDescription key="fontDescription" type="system" pointSize="13"/> - <color key="textColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - <userDefinedRuntimeAttributes> - <userDefinedRuntimeAttribute type="color" keyPath="borderColor"> - <color key="value" red="0.2901960784" green="0.72156862749999995" blue="0.40000000000000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </userDefinedRuntimeAttribute> - <userDefinedRuntimeAttribute type="number" keyPath="borderWidth"> - <real key="value" value="0.0"/> - </userDefinedRuntimeAttribute> - <userDefinedRuntimeAttribute type="number" keyPath="horizontalPadding"> - <real key="value" value="12"/> - </userDefinedRuntimeAttribute> - </userDefinedRuntimeAttributes> - </label> - </subviews> - </stackView> - </subviews> - </stackView> - </subviews> - <constraints> - <constraint firstItem="lEn-Qd-WOQ" firstAttribute="leading" secondItem="Oon-ha-GOC" secondAttribute="leading" constant="21" id="It6-FJ-IkY"/> - <constraint firstItem="ua5-18-oiR" firstAttribute="top" secondItem="Oon-ha-GOC" secondAttribute="centerY" id="eHJ-cG-o1n"/> - <constraint firstAttribute="trailing" secondItem="lEn-Qd-WOQ" secondAttribute="trailing" constant="21" id="kpP-ze-Dvw"/> - </constraints> - </tableViewCellContentView> - <connections> - <outlet property="domainLabel" destination="h3c-Hv-w4y" id="XcM-1n-FTG"/> - <outlet property="primaryIndicatorLabel" destination="ua5-18-oiR" id="fKY-Wg-k3o"/> - <outlet property="registeredMappedLabel" destination="Wxe-EB-maA" id="Jkf-BU-D1y"/> - </connections> - </tableViewCell> - </prototypes> - <connections> - <outlet property="dataSource" destination="5v1-yD-jqT" id="2lN-je-vg5"/> - <outlet property="delegate" destination="5v1-yD-jqT" id="2mm-S1-fd7"/> - </connections> - </tableView> - </tableViewController> - <placeholder placeholderIdentifier="IBFirstResponder" id="uQB-11-4aP" userLabel="First Responder" sceneMemberID="firstResponder"/> - </objects> - <point key="canvasLocation" x="289" y="141"/> - </scene> - </scenes> -</document> diff --git a/WordPress/Classes/ViewRelated/Domains/DomainsListViewController.swift b/WordPress/Classes/ViewRelated/Domains/DomainsListViewController.swift deleted file mode 100644 index 5a75e338da69..000000000000 --- a/WordPress/Classes/ViewRelated/Domains/DomainsListViewController.swift +++ /dev/null @@ -1,161 +0,0 @@ -import UIKit -import WordPressShared - -class DomainListDomainCell: WPTableViewCell { - @IBOutlet weak var domainLabel: UILabel! - @IBOutlet weak var registeredMappedLabel: UILabel! - @IBOutlet weak var primaryIndicatorLabel: UILabel! - - override func awakeFromNib() { - super.awakeFromNib() - - domainLabel?.textColor = .neutral(.shade60) - registeredMappedLabel?.textColor = .neutral(.shade40) - } -} - -struct DomainListStaticRow: ImmuTableRow { - static let cell = ImmuTableCell.class(WPTableViewCellDefault.self) - static var customHeight: Float? - - let title: String - let action: ImmuTableAction? - - func configureCell(_ cell: UITableViewCell) { - WPStyleGuide.configureTableViewCell(cell) - - cell.textLabel?.text = title - } -} - -struct DomainListRow: ImmuTableRow { - static let cell = ImmuTableCell.class(DomainListDomainCell.self) - static var customHeight: Float? = 77 - - let domain: String - let domainType: DomainType - let isPrimary: Bool - let action: ImmuTableAction? - - func configureCell(_ cell: UITableViewCell) { - guard let cell = cell as? DomainListDomainCell else { return } - - cell.domainLabel?.text = domain - cell.registeredMappedLabel?.text = domainType.description - cell.primaryIndicatorLabel?.isHidden = !isPrimary - } -} - -class DomainsListViewController: UITableViewController, ImmuTablePresenter { - fileprivate var viewModel: ImmuTable! - - fileprivate var fetchRequest: NSFetchRequest<NSFetchRequestResult> { - let request = NSFetchRequest<NSFetchRequestResult>(entityName: ManagedDomain.entityName()) - request.predicate = NSPredicate(format: "%K == %@", ManagedDomain.Relationships.blog, blog) - request.sortDescriptors = [NSSortDescriptor(key: ManagedDomain.Attributes.isPrimary, ascending: false), - NSSortDescriptor(key: ManagedDomain.Attributes.domainName, ascending: true)] - - return request - } - - @objc var blog: Blog! { - didSet { - if let context = blog.managedObjectContext { - fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, - managedObjectContext: context, - sectionNameKeyPath: nil, - cacheName: nil) - fetchedResultsController.delegate = self - let _ = try? fetchedResultsController.performFetch() - } - - updateViewModel() - } - } - var service: DomainsService! - @objc var fetchedResultsController: NSFetchedResultsController<NSFetchRequestResult>! - - @objc class func controllerWithBlog(_ blog: Blog) -> DomainsListViewController { - let storyboard = UIStoryboard(name: "Domains", bundle: Bundle(for: self)) - let controller = storyboard.instantiateInitialViewController() as! DomainsListViewController - - controller.blog = blog - - if let account = blog.account { - controller.service = DomainsService(managedObjectContext: ContextManager.sharedInstance().mainContext, account: account) - } - - return controller - } - - override func viewDidLoad() { - super.viewDidLoad() - - title = NSLocalizedString("Domains", comment: "Title for the Domains list") - - WPStyleGuide.configureColors(view: view, tableView: tableView) - - if let dotComID = blog.dotComID?.intValue { - service.refreshDomainsForSite(dotComID) { _ in } - } - } - - fileprivate func updateViewModel() { - let searchRow = DomainListStaticRow(title: "Find a new domain", action: nil) - let connectRow = DomainListStaticRow(title: "Or connect your own domain", action: nil) - - var domainRows = [ImmuTableRow]() - if let domains = fetchedResultsController.fetchedObjects as? [ManagedDomain] { - domainRows = domains.map { DomainListRow(domain: $0.domainName, domainType: $0.domainType, isPrimary: $0.isPrimary, action: nil) - } - } - - viewModel = ImmuTable(sections: [ - ImmuTableSection(headerText: NSLocalizedString("Add A New Domain", comment: "Header title for new domain section of Domains."), - rows: [ searchRow, connectRow ], footerText: nil), - ImmuTableSection(headerText: NSLocalizedString("Your Domains", comment: "Header title for your domains section of Domains."), - rows: domainRows, footerText: nil) ] - ) - - if isViewLoaded { - tableView.reloadData() - } - } - - override func numberOfSections(in tableView: UITableView) -> Int { - return viewModel.sections.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.sections[section].rows.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let row = viewModel.rowAtIndexPath(indexPath) - let cell = tableView.dequeueReusableCell(withIdentifier: row.reusableIdentifier, for: indexPath) - - row.configureCell(cell) - - return cell - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let row = viewModel.rowAtIndexPath(indexPath) - if let customHeight = type(of: row).customHeight { - return CGFloat(customHeight) - } - return tableView.rowHeight - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - return viewModel.sections[section].headerText - } -} - -extension DomainsListViewController: NSFetchedResultsControllerDelegate { - func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) { - updateViewModel() - - tableView.reloadData() - } -} diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsServiceProxy.swift b/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsServiceProxy.swift deleted file mode 100644 index 9435c39561d6..000000000000 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainDetails/ViewModel/RegisterDomainDetailsServiceProxy.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation - -/// Protocol for cart response, empty because there are no external details. -protocol CartResponseProtocol {} - -extension CartResponse: CartResponseProtocol {} - -/// A proxy for being able to use dependency injection for RegisterDomainDetailsViewModel -/// especially for unittest mocking purposes -protocol RegisterDomainDetailsServiceProxyProtocol { - - func validateDomainContactInformation(contactInformation: [String: String], - domainNames: [String], - success: @escaping (ValidateDomainContactInformationResponse) -> Void, - failure: @escaping (Error) -> Void) - - func getDomainContactInformation(success: @escaping (DomainContactInformation) -> Void, - failure: @escaping (Error) -> Void) - - func getSupportedCountries(success: @escaping ([Country]) -> Void, - failure: @escaping (Error) -> Void) - - func getStates(for countryCode: String, - success: @escaping ([State]) -> Void, - failure: @escaping (Error) -> Void) - - func createShoppingCart(siteID: Int, - domainSuggestion: DomainSuggestion, - privacyProtectionEnabled: Bool, - success: @escaping (CartResponseProtocol) -> Void, - failure: @escaping (Error) -> Void) - - func redeemCartUsingCredits(cart: CartResponseProtocol, - domainContactInformation: [String: String], - success: @escaping () -> Void, - failure: @escaping (Error) -> Void) - - func changePrimaryDomain(siteID: Int, - newDomain: String, - success: @escaping () -> Void, - failure: @escaping (Error) -> Void) - - -} - -class RegisterDomainDetailsServiceProxy: RegisterDomainDetailsServiceProxyProtocol { - - private lazy var restApi: WordPressComRestApi = { - let accountService = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext) - return accountService.defaultWordPressComAccount()?.wordPressComRestApi ?? WordPressComRestApi.defaultApi(oAuthToken: "") - }() - - private lazy var domainsServiceRemote = { - return DomainsServiceRemote(wordPressComRestApi: restApi) - }() - - private lazy var transactionsServiceRemote = { - return TransactionsServiceRemote(wordPressComRestApi: restApi) - }() - - func validateDomainContactInformation(contactInformation: [String: String], - domainNames: [String], - success: @escaping (ValidateDomainContactInformationResponse) -> Void, - failure: @escaping (Error) -> Void) { - domainsServiceRemote.validateDomainContactInformation( - contactInformation: contactInformation, - domainNames: domainNames, - success: success, - failure: failure - ) - } - - func getDomainContactInformation(success: @escaping (DomainContactInformation) -> Void, - failure: @escaping (Error) -> Void) { - domainsServiceRemote.getDomainContactInformation(success: success, - failure: failure) - } - - func getSupportedCountries(success: @escaping ([Country]) -> Void, - failure: @escaping (Error) -> Void) { - transactionsServiceRemote.getSupportedCountries(success: success, - failure: failure) - } - - func getStates(for countryCode: String, - success: @escaping ([State]) -> Void, - failure: @escaping (Error) -> Void) { - domainsServiceRemote.getStates(for: countryCode, - success: success, - failure: failure) - } - - func createShoppingCart(siteID: Int, - domainSuggestion: DomainSuggestion, - privacyProtectionEnabled: Bool, - success: @escaping (CartResponseProtocol) -> Void, - failure: @escaping (Error) -> Void) { - transactionsServiceRemote.createShoppingCart(siteID: siteID, - domainSuggestion: domainSuggestion, - privacyProtectionEnabled: privacyProtectionEnabled, - success: success, - failure: failure) - } - - func redeemCartUsingCredits(cart: CartResponseProtocol, - domainContactInformation: [String: String], - success: @escaping () -> Void, - failure: @escaping (Error) -> Void) { - guard let cartResponse = cart as? CartResponse else { - fatalError() - } - transactionsServiceRemote.redeemCartUsingCredits(cart: cartResponse, - domainContactInformation: domainContactInformation, - success: success, - failure: failure) - } - - func changePrimaryDomain(siteID: Int, - newDomain: String, - success: @escaping () -> Void, - failure: @escaping (Error) -> Void) { - domainsServiceRemote.setPrimaryDomainForSite(siteID: siteID, - domain: newDomain, - success: success, - failure: failure) - } - - -} diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainSuggestions/RegisterDomainSuggestionsTableViewController.swift b/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainSuggestions/RegisterDomainSuggestionsTableViewController.swift deleted file mode 100644 index 60e843b8ff65..000000000000 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainSuggestions/RegisterDomainSuggestionsTableViewController.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -class RegisterDomainSuggestionsTableViewController: DomainSuggestionsTableViewController { - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - domainSuggestionType = .noWordpressDotCom - } - - override open var useFadedColorForParentDomains: Bool { - return false - } - override open var sectionTitle: String { - return "" - } - override open var sectionDescription: String { - return NSLocalizedString( - "Pick an available address", - comment: "Register domain - Suggested Domain description for the screen" - ) - } - override open var searchFieldPlaceholder: String { - return NSLocalizedString( - "Type to get more suggestions", - comment: "Register domain - Search field placeholder for the Suggested Domain screen" - ) - } -} diff --git a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainSuggestions/RegisterDomainSuggestionsViewController.swift b/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainSuggestions/RegisterDomainSuggestionsViewController.swift deleted file mode 100644 index a0c6aa804c1c..000000000000 --- a/WordPress/Classes/ViewRelated/Domains/Register/RegisterDomainSuggestions/RegisterDomainSuggestionsViewController.swift +++ /dev/null @@ -1,121 +0,0 @@ -import UIKit -import WordPressAuthenticator - -class RegisterDomainSuggestionsViewController: NUXViewController, DomainSuggestionsButtonViewPresenter { - - @IBOutlet weak var buttonContainerViewBottomConstraint: NSLayoutConstraint! - @IBOutlet weak var buttonContainerViewHeightConstraint: NSLayoutConstraint! - - private var site: JetpackSiteRef! - private var domainPurchasedCallback: ((String) -> Void)! - - private var domain: DomainSuggestion? - private var siteName: String? - private var domainsTableViewController: RegisterDomainSuggestionsTableViewController? - - override func viewDidLoad() { - super.viewDidLoad() - configure() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - showButtonView(show: false, withAnimation: false) - } - - @IBOutlet private var buttonViewContainer: UIView! { - didSet { - buttonViewController.move(to: self, into: buttonViewContainer) - } - } - - private lazy var buttonViewController: NUXButtonViewController = { - let buttonViewController = NUXButtonViewController.instance() - buttonViewController.view.backgroundColor = .basicBackground - buttonViewController.delegate = self - buttonViewController.setButtonTitles( - primary: NSLocalizedString("Choose domain", - comment: "Register domain - Title for the Choose domain button of Suggested domains screen") - ) - return buttonViewController - }() - - static func instance(site: JetpackSiteRef, domainPurchasedCallback: @escaping ((String) -> Void)) -> RegisterDomainSuggestionsViewController { - let storyboard = UIStoryboard(name: "RegisterDomain", bundle: Bundle.main) - let controller = storyboard.instantiateViewController(withIdentifier: "RegisterDomainSuggestionsViewController") as! RegisterDomainSuggestionsViewController - controller.site = site - controller.domainPurchasedCallback = domainPurchasedCallback - controller.siteName = siteNameForSuggestions(for: site) - - return controller - } - - private static func siteNameForSuggestions(for site: JetpackSiteRef) -> String? { - if let siteTitle = BlogService.blog(with: site)?.settings?.name?.nonEmptyString() { - return siteTitle - } - - if let siteUrl = BlogService.blog(with: site)?.url { - let components = URLComponents(string: siteUrl) - if let firstComponent = components?.host?.split(separator: ".").first { - return String(firstComponent) - } - } - - return nil - } - - private func configure() { - title = NSLocalizedString("Register domain", - comment: "Register domain - Title for the Suggested domains screen") - WPStyleGuide.configureColors(view: view, tableView: nil) - let backButton = UIBarButtonItem() - backButton.title = NSLocalizedString("Back", comment: "Back button title.") - navigationItem.backBarButtonItem = backButton - } - - // MARK: - Navigation - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - super.prepare(for: segue, sender: sender) - - if let vc = segue.destination as? RegisterDomainSuggestionsTableViewController { - vc.delegate = self - vc.siteName = siteName - - if BlogService.blog(with: site)?.hasBloggerPlan == true { - vc.domainSuggestionType = .whitelistedTopLevelDomains(["blog"]) - } - - domainsTableViewController = vc - } - } -} - -// MARK: - DomainSuggestionsTableViewControllerDelegate - -extension RegisterDomainSuggestionsViewController: DomainSuggestionsTableViewControllerDelegate { - func domainSelected(_ domain: DomainSuggestion) { - WPAnalytics.track(.automatedTransferCustomDomainSuggestionSelected) - self.domain = domain - showButtonView(show: true, withAnimation: true) - } - - func newSearchStarted() { - WPAnalytics.track(.automatedTransferCustomDomainSuggestionQueried) - showButtonView(show: false, withAnimation: true) - } -} - -// MARK: - NUXButtonViewControllerDelegate - -extension RegisterDomainSuggestionsViewController: NUXButtonViewControllerDelegate { - func primaryButtonPressed() { - guard let domain = domain else { - return - } - let controller = RegisterDomainDetailsViewController() - controller.viewModel = RegisterDomainDetailsViewModel(site: site, domain: domain, domainPurchasedCallback: domainPurchasedCallback) - self.navigationController?.pushViewController(controller, animated: true) - } -} diff --git a/WordPress/Classes/ViewRelated/Domains/Utility/Blog+DomainsDashboardView.swift b/WordPress/Classes/ViewRelated/Domains/Utility/Blog+DomainsDashboardView.swift new file mode 100644 index 000000000000..bcf1f9d29d65 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Utility/Blog+DomainsDashboardView.swift @@ -0,0 +1,47 @@ +/// Collection of convenience properties used in the Domains Dashboard +extension Blog { + + static let noPrimaryURLFound = NSLocalizedString("No primary site address found", + comment: "String to display in place of the site address, in case it was not retrieved from the backend.") + + struct DomainRepresentation: Identifiable { + let domain: Domain + let id = UUID() + } + + var hasDomains: Bool { + !domainsList.isEmpty + } + + var domainsList: [DomainRepresentation] { + guard let domainsSet = domains as? Set<ManagedDomain> else { + return [] + } + + return domainsSet + .filter { $0.domainType != .wpCom } + .sorted(by: { $0.domainName > $1.domainName }) + .map { DomainRepresentation(domain: Domain(managedDomain: $0)) } + + } + + var canRegisterDomainWithPaidPlan: Bool { + (isHostedAtWPcom || isAtomic()) && hasDomainCredit + } + + var freeDomain: Domain? { + guard let domainsSet = domains as? Set<ManagedDomain>, + let freeDomain = (domainsSet.first { $0.domainType == .wpCom }) else { + return nil + } + return Domain(managedDomain: freeDomain) + } + + var freeSiteAddress: String { + freeDomain?.domainName ?? "" + } + + var freeDomainIsPrimary: Bool { + freeDomain?.isPrimaryDomain ?? false + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Utility/DomainExpiryDateFormatter.swift b/WordPress/Classes/ViewRelated/Domains/Utility/DomainExpiryDateFormatter.swift new file mode 100644 index 000000000000..26f3967d0a26 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Utility/DomainExpiryDateFormatter.swift @@ -0,0 +1,25 @@ +import Foundation + +struct DomainExpiryDateFormatter { + static func expiryDate(for domain: Domain) -> String { + if domain.expiryDate.isEmpty { + return Localized.neverExpires + } else if domain.expired { + return Localized.expired + } else if domain.autoRenewing && domain.autoRenewalDate.isEmpty { + return Localized.autoRenews + } else if domain.autoRenewing { + return String(format: Localized.renewsOn, domain.autoRenewalDate) + } else { + return String(format: Localized.expiresOn, domain.expiryDate) + } + } + + enum Localized { + static let neverExpires = NSLocalizedString("Never expires", comment: "Label indicating that a domain name registration has no expiry date.") + static let autoRenews = NSLocalizedString("Auto-renew enabled", comment: "Label indicating that a domain name registration will automatically renew") + static let renewsOn = NSLocalizedString("Renews on %@", comment: "Label indicating the date on which a domain name registration will be renewed. The %@ placeholder will be replaced with a date at runtime.") + static let expiresOn = NSLocalizedString("Expires on %@", comment: "Label indicating the date on which a domain name registration will expire. The %@ placeholder will be replaced with a date at runtime.") + static let expired = NSLocalizedString("Expired", comment: "Label indicating that a domain name registration has expired.") + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Views/DomainsDashboardCoordinator.swift b/WordPress/Classes/ViewRelated/Domains/Views/DomainsDashboardCoordinator.swift new file mode 100644 index 000000000000..eceb7f70fa7d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Views/DomainsDashboardCoordinator.swift @@ -0,0 +1,22 @@ +import UIKit + +@objc final class DomainsDashboardCoordinator: NSObject { + @objc(presentDomainsDashboardWithPresenter:source:blog:) + static func presentDomainsDashboard(with presenter: BlogDetailsPresentationDelegate, + source: String, + blog: Blog) { + WPAnalytics.trackEvent(.domainsDashboardViewed, properties: [WPAppAnalyticsKeySource: source], blog: blog) + let controller = DomainsDashboardFactory.makeDomainsDashboardViewController(blog: blog) + controller.navigationItem.largeTitleDisplayMode = .never + presenter.presentBlogDetailsViewController(controller) + } + + static func presentDomainsDashboard(in dashboardViewController: BlogDashboardViewController, + source: String, + blog: Blog) { + WPAnalytics.trackEvent(.domainsDashboardViewed, properties: [WPAppAnalyticsKeySource: source], blog: blog) + let controller = DomainsDashboardFactory.makeDomainsDashboardViewController(blog: blog) + controller.navigationItem.largeTitleDisplayMode = .never + dashboardViewController.show(controller, sender: nil) + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Views/DomainsDashboardFactory.swift b/WordPress/Classes/ViewRelated/Domains/Views/DomainsDashboardFactory.swift new file mode 100644 index 000000000000..726d228a0659 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Views/DomainsDashboardFactory.swift @@ -0,0 +1,10 @@ +import Foundation +import SwiftUI + +struct DomainsDashboardFactory { + static func makeDomainsDashboardViewController(blog: Blog) -> UIViewController { + let viewController = UIHostingController(rootView: DomainsDashboardView(blog: blog)) + viewController.extendedLayoutIncludesOpaqueBars = true + return viewController + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Views/DomainsDashboardView.swift b/WordPress/Classes/ViewRelated/Domains/Views/DomainsDashboardView.swift new file mode 100644 index 000000000000..31b7676cb793 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Views/DomainsDashboardView.swift @@ -0,0 +1,203 @@ +import SwiftUI +import WordPressKit + +/// The Domains dashboard screen, accessible from My Site +struct DomainsDashboardView: View { + @ObservedObject var blog: Blog + @State var isShowingDomainRegistrationFlow = false + @State var blogService = BlogService(coreDataStack: ContextManager.shared) + @State var domainsList: [Blog.DomainRepresentation] = [] + + // Property observer + private func showingDomainRegistrationFlow(to value: Bool) { + if value { + WPAnalytics.track(.domainsDashboardAddDomainTapped, properties: WPAnalytics.domainsProperties(for: blog), blog: blog) + } + } + + var body: some View { + List { + if blog.supports(.domains) { + makeSiteAddressSection(blog: blog) + } + makeDomainsSection(blog: blog) + } + .listStyle(GroupedListStyle()) + .padding(.top, blog.supports(.domains) ? Metrics.topPadding : 0) + .buttonStyle(PlainButtonStyle()) + .onTapGesture(perform: { }) + .onAppear { + updateDomainsList() + + blogService.refreshDomains(for: blog, success: { + updateDomainsList() + }, failure: nil) + } + .navigationBarTitle(TextContent.navigationTitle) + .sheet(isPresented: $isShowingDomainRegistrationFlow, content: { + makeDomainSearch(for: blog, onDismiss: { + isShowingDomainRegistrationFlow = false + blogService.refreshDomains(for: blog, success: { + updateDomainsList() + }, failure: nil) + }) + }) + } + + @ViewBuilder + private func makeDomainsSection(blog: Blog) -> some View { + if blog.hasDomains { + makeDomainsListSection(blog: blog) + } else { + makeGetFirstDomainSection(blog: blog) + } + } + + /// Builds the site address section for the given blog + private func makeSiteAddressSection(blog: Blog) -> some View { + Section(header: makeSiteAddressHeader(), + footer: Text(TextContent.primarySiteSectionFooter(blog.hasPaidPlan))) { + VStack(alignment: .leading) { + Text(TextContent.siteAddressTitle) + Text(blog.freeSiteAddress) + .bold() + if blog.freeDomainIsPrimary { + ShapeWithTextView(title: TextContent.primaryAddressLabel) + .smallRoundedRectangle() + } + } + } + } + + @ViewBuilder + private func makeDomainCell(domain: Blog.DomainRepresentation) -> some View { + VStack(alignment: .leading) { + Text(domain.domain.domainName) + if domain.domain.isPrimaryDomain { + ShapeWithTextView(title: TextContent.primaryAddressLabel) + .smallRoundedRectangle() + } + makeExpiryRenewalLabel(domain: domain) + } + } + + /// Builds the domains list section with the` add a domain` button at the bottom, for the given blog + private func makeDomainsListSection(blog: Blog) -> some View { + Section(header: Text(TextContent.domainsListSectionHeader)) { + ForEach(domainsList) { + makeDomainCell(domain: $0) + } + if blog.supports(.domains) { + PresentationButton( + isShowingDestination: $isShowingDomainRegistrationFlow.onChange(showingDomainRegistrationFlow), + appearance: { + HStack { + Text(TextContent.additionalDomainTitle(blog.canRegisterDomainWithPaidPlan)) + .foregroundColor(Color(UIColor.primary)) + .bold() + Spacer() + } + } + ) + } + } + } + + /// Builds the Get New Domain section when no othert domains are present for the given blog + private func makeGetFirstDomainSection(blog: Blog) -> some View { + Section { + PresentationCard( + title: TextContent.firstDomainTitle(blog.canRegisterDomainWithPaidPlan), + description: TextContent.firstDomainDescription(blog.canRegisterDomainWithPaidPlan), + highlight: siteAddressForGetFirstDomainSection, + isShowingDestination: $isShowingDomainRegistrationFlow.onChange(showingDomainRegistrationFlow)) { + ShapeWithTextView(title: TextContent.firstSearchDomainButtonTitle) + .largeRoundedRectangle() + } + } + } + + private var siteAddressForGetFirstDomainSection: String { + blog.canRegisterDomainWithPaidPlan ? "" : blog.freeSiteAddress + } + + private func makeExpiryRenewalLabel(domain: Blog.DomainRepresentation) -> some View { + let stringForDomain = DomainExpiryDateFormatter.expiryDate(for: domain.domain) + + return Text(stringForDomain) + .font(.subheadline) + .foregroundColor(domain.domain.expirySoon || domain.domain.expired ? Color(UIColor.error) : Color(UIColor.textSubtle)) + } + + private func makeSiteAddressHeader() -> Divider? { + if #available(iOS 15, *) { + return nil + } + return Divider() + } + + /// Instantiates the proper search depending if it's for claiming a free domain with a paid plan or purchasing a new one + private func makeDomainSearch(for blog: Blog, onDismiss: @escaping () -> Void) -> some View { + return DomainSuggestionViewControllerWrapper(blog: blog, domainType: blog.canRegisterDomainWithPaidPlan ? .registered : .siteRedirect, onDismiss: onDismiss) + } + + private func updateDomainsList() { + domainsList = blog.domainsList + } +} + +// MARK: - Constants +private extension DomainsDashboardView { + + enum TextContent { + // Navigation bar + static let navigationTitle = NSLocalizedString("Site Domains", comment: "Title of the Domains Dashboard.") + // Site address section + static func primarySiteSectionFooter(_ paidPlan: Bool) -> String { + paidPlan ? "" : NSLocalizedString("Your primary site address is what visitors will see in their address bar when visiting your website.", + comment: "Footer of the primary site section in the Domains Dashboard.") + } + + static let siteAddressTitle = NSLocalizedString("Your free WordPress.com address is", + comment: "Title of the site address section in the Domains Dashboard.") + static let primaryAddressLabel = NSLocalizedString("Primary site address", + comment: "Primary site address label, used in the site address section of the Domains Dashboard.") + + // Domains section + static let domainsListSectionHeader: String = NSLocalizedString("Your Site Domains", + comment: "Header of the domains list section in the Domains Dashboard.") + static let paidPlanDomainSectionFooter: String = NSLocalizedString("All WordPress.com plans include a custom domain name. Register your free premium domain now.", + comment: "Footer of the free domain registration section for a paid plan.") + + static let additionalRedirectedDomainTitle: String = NSLocalizedString("Add a domain", + comment: "Label of the button that starts the purchase of an additional redirected domain in the Domains Dashboard.") + + static let firstRedirectedDomainTitle: String = NSLocalizedString("Get your domain", + comment: "Title of the card that starts the purchase of the first redirected domain in the Domains Dashboard.") + static let firstRedirectedDomainDescription = NSLocalizedString("Domains purchased on this site will redirect users to ", + comment: "Description for the first domain purchased with a free plan.") + static let firstPaidPlanRegistrationTitle: String = NSLocalizedString("Claim your free domain", + comment: "Title of the card that starts the registration of a free domain with a paid plan, in the Domains Dashboard.") + static let firstPaidPlanRegistrationDescription = NSLocalizedString("You have a free one-year domain registration with your plan", + comment: "Description for the first domain purchased with a paid plan.") + static let firstSearchDomainButtonTitle = NSLocalizedString("Search for a domain", + comment: "title of the button that searches the first domain.") + + static func firstDomainTitle(_ canRegisterDomainWithPaidPlan: Bool) -> String { + canRegisterDomainWithPaidPlan ? firstPaidPlanRegistrationTitle : firstRedirectedDomainTitle + } + + static func firstDomainDescription(_ canRegisterDomainWithPaidPlan: Bool) -> String { + canRegisterDomainWithPaidPlan ? firstPaidPlanRegistrationDescription : firstRedirectedDomainDescription + } + + static func additionalDomainTitle(_ canRegisterDomainWithPaidPlan: Bool) -> String { + canRegisterDomainWithPaidPlan ? firstPaidPlanRegistrationTitle : additionalRedirectedDomainTitle + } + } + + enum Metrics { + static let sectionPaddingDefaultHeight: CGFloat = 16.0 + static let topPadding: CGFloat = -34.0 + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Views/PresentationButton.swift b/WordPress/Classes/ViewRelated/Domains/Views/PresentationButton.swift new file mode 100644 index 000000000000..f8fe99db7ca6 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Views/PresentationButton.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct PresentationButton<Appearance: View>: View { + @Binding var isShowingDestination: Bool + var appearance: () -> Appearance + + var body: some View { + Button(action: { + isShowingDestination = true + }) { + self.appearance() + } + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Views/PresentationCard.swift b/WordPress/Classes/ViewRelated/Domains/Views/PresentationCard.swift new file mode 100644 index 000000000000..1b9aaf060825 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Views/PresentationCard.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// A card with a title, a description and a button that can present a view +struct PresentationCard<Appearance: View>: View { + var title: String + var description: String + var highlight: String + @Binding var isShowingDestination: Bool + + private let titleFontSize: CGFloat = 28 + + var appearance: () -> Appearance + + var body: some View { + VStack { + Text(title) + .font(Font.system(size: titleFontSize, + weight: .regular, + design: .serif)) + .padding() + (Text(description) + + Text(highlight).bold()) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + PresentationButton( + isShowingDestination: $isShowingDestination, + appearance: appearance) + .padding() + } + } +} diff --git a/WordPress/Classes/ViewRelated/Domains/Views/ShapeWithTextView.swift b/WordPress/Classes/ViewRelated/Domains/Views/ShapeWithTextView.swift new file mode 100644 index 000000000000..9ef91e9fb407 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Domains/Views/ShapeWithTextView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +/// A rounded rectangle shape with a white title and a primary background color +struct ShapeWithTextView: View { + var title: String + + var body: some View { + Text(title) + } + + func largeRoundedRectangle(textColor: Color = .white, + backgroundColor: Color = Appearance.largeRoundedRectangleDefaultTextColor) -> some View { + body + .frame(minWidth: 0, maxWidth: .infinity) + .padding(.all, Appearance.largeRoundedRectangleTextPadding) + .foregroundColor(textColor) + .background(backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: Appearance.largeRoundedRectangleCornerRadius, + style: .continuous)) + } + + func smallRoundedRectangle(textColor: Color = Appearance.smallRoundedRectangleDefaultTextColor, + backgroundColor: Color = Appearance.smallRoundedRectangleDefaultBackgroundColor) -> some View { + body + .font(.system(size: Appearance.smallRoundedRectangleFontSize)) + .padding(Appearance.smallRoundedRectangleInsets) + .foregroundColor(textColor) + .background(backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: Appearance.smallRoundedRectangleCornerRadius, + style: .continuous)) + + } + + private enum Appearance { + // large rounded rectangle + static let largeRoundedRectangleCornerRadius: CGFloat = 8.0 + static let largeRoundedRectangleTextPadding: CGFloat = 12.0 + static let largeRoundedRectangleDefaultTextColor = Color(UIColor.muriel(color: .primary)) + // small rounded rectangle + static let smallRoundedRectangleCornerRadius: CGFloat = 4.0 + static let smallRoundedRectangleInsets = EdgeInsets(top: 4.0, leading: 8.0, bottom: 4.0, trailing: 8.0) + static let smallRoundedRectangleDefaultBackgroundColor = Color(UIColor.muriel(name: .green, .shade5)) + static let smallRoundedRectangleDefaultTextColor = Color(UIColor.muriel(name: .green, .shade100)) + static let smallRoundedRectangleFontSize: CGFloat = 14.0 + } +} diff --git a/WordPress/Classes/ViewRelated/Feature Highlight/FeatureHighlightStore.swift b/WordPress/Classes/ViewRelated/Feature Highlight/FeatureHighlightStore.swift new file mode 100644 index 000000000000..1cf1375658d2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Feature Highlight/FeatureHighlightStore.swift @@ -0,0 +1,37 @@ +import Foundation + +struct FeatureHighlightStore { + private enum Keys { + static let didUserDismissTooltipKey = "did-user-dismiss-tooltip-key" + static let followConversationTooltipCounterKey = "follow-conversation-tooltip-counter" + } + + private let userStore: UserPersistentRepository + + init(userStore: UserPersistentRepository = UserPersistentStoreFactory.instance()) { + self.userStore = userStore + } + + var didDismissTooltip: Bool { + get { + return userStore.bool(forKey: Keys.didUserDismissTooltipKey) + } + set { + userStore.set(newValue, forKey: Keys.didUserDismissTooltipKey) + } + } + + var followConversationTooltipCounter: Int { + get { + return userStore.integer(forKey: Keys.followConversationTooltipCounterKey) + } + set { + userStore.set(newValue, forKey: Keys.followConversationTooltipCounterKey) + } + } + + /// Tooltip will only be shown 3 times if the user never interacts with it. + var shouldShowTooltip: Bool { + followConversationTooltipCounter < 3 && !didDismissTooltip + } +} diff --git a/WordPress/Classes/ViewRelated/Feature Highlight/Tooltip.swift b/WordPress/Classes/ViewRelated/Feature Highlight/Tooltip.swift new file mode 100644 index 000000000000..4338167894b8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Feature Highlight/Tooltip.swift @@ -0,0 +1,420 @@ +import UIKit + +final class Tooltip: UIView { + static let arrowWidth: CGFloat = 17 + + private enum Constants { + static let leadingIconUnicode = "✨" + static let cornerRadius: CGFloat = 4 + static let arrowTipYLength: CGFloat = 8 + static let arrowTipYControlLength: CGFloat = 9 + static let invertedTooltipBackgroundColor = UIColor( + light: UIColor.systemGray5.color(for: UITraitCollection(userInterfaceStyle: .dark)), + dark: .white + ) + + + enum Spacing { + static let contentStackViewInterItemSpacing: CGFloat = 4 + static let buttonsStackViewInterItemSpacing: CGFloat = 16 + static let contentStackViewTop: CGFloat = 12 + static let contentStackViewBottom: CGFloat = 4 + static let contentStackViewHorizontal: CGFloat = 16 + static let superHorizontalMargin: CGFloat = 16 + static let buttonStackViewHeight: CGFloat = 40 + } + } + + enum ButtonAlignment { + case left + case right + } + + enum ArrowPosition { + case top + case bottom + } + + /// Determines whether a leading icon for the title, should be placed or not. + var shouldPrefixLeadingIcon: Bool = true { + didSet { + guard let title = title else { return } + + Self.updateTitleLabel( + titleLabel, + with: title, + shouldPrefixLeadingIcon: shouldPrefixLeadingIcon + ) + } + } + + /// String for primary label. To be used as the title. + /// If `shouldPrefixLeadingIcon` is `true`, a leading icon will be prefixed. + var title: String? { + didSet { + guard let title = title else { + titleLabel.text = nil + return + } + + Self.updateTitleLabel( + titleLabel, + with: title, + shouldPrefixLeadingIcon: shouldPrefixLeadingIcon + ) + accessibilityLabel = title + } + } + + /// String for secondary label. To be used as description + var message: String? { + didSet { + messageLabel.text = message + accessibilityValue = message + } + } + + /// Determines the alignment for the action buttons. + var buttonAlignment: ButtonAlignment = .left { + didSet { + buttonsStackView.removeAllSubviews() + switch buttonAlignment { + case .left: + buttonsStackView.spacing = Constants.Spacing.buttonsStackViewInterItemSpacing + buttonsStackView.addArrangedSubviews([primaryButton, secondaryButton, UIView()]) + buttonsStackView.setCustomSpacing(0, after: secondaryButton) + case .right: + buttonsStackView.spacing = 0 + buttonsStackView.addArrangedSubviews([UIView(), primaryButton, secondaryButton]) + buttonsStackView.setCustomSpacing(Constants.Spacing.buttonsStackViewInterItemSpacing, after: primaryButton) + } + } + } + + var primaryButtonTitle: String? { + didSet { + primaryButton.setTitle(primaryButtonTitle, for: .normal) + } + } + + var secondaryButtonTitle: String? { + didSet { + secondaryButton.setTitle(secondaryButtonTitle, for: .normal) + } + } + + var dismissalAction: (() -> Void)? + var secondaryButtonAction: (() -> Void)? + + private var maxWidth: CGFloat { + UIScreen.main.bounds.width - Constants.Spacing.superHorizontalMargin + } + + private lazy var titleLabel: UILabel = { + $0.font = WPStyleGuide.fontForTextStyle(.body) + $0.textColor = .invertedLabel + $0.adjustsFontForContentSizeCategory = true + $0.numberOfLines = 0 + return $0 + }(UILabel()) + + private lazy var messageLabel: UILabel = { + $0.font = WPStyleGuide.fontForTextStyle(.body) + $0.textColor = .invertedSecondaryLabel + $0.adjustsFontForContentSizeCategory = true + $0.numberOfLines = 0 + return $0 + }(UILabel()) + + private lazy var primaryButton: UIButton = { + $0.titleLabel?.font = WPStyleGuide.fontForTextStyle(.subheadline) + $0.setTitleColor(.invertedLink, for: .normal) + $0.addTarget(self, action: #selector(didTapPrimaryButton), for: .touchUpInside) + $0.titleLabel?.adjustsFontForContentSizeCategory = true + return $0 + }(UIButton()) + + private lazy var secondaryButton: UIButton = { + $0.titleLabel?.font = WPStyleGuide.fontForTextStyle(.subheadline) + $0.setTitleColor(.invertedLink, for: .normal) + $0.addTarget(self, action: #selector(didTapSecondaryButton), for: .touchUpInside) + $0.titleLabel?.adjustsFontForContentSizeCategory = true + return $0 + }(UIButton()) + + private lazy var contentStackView: UIStackView = { + $0.addArrangedSubviews([titleLabel, messageLabel, buttonsStackView]) + $0.spacing = Constants.Spacing.contentStackViewInterItemSpacing + $0.axis = .vertical + return $0 + }(UIStackView()) + + private lazy var buttonsStackView: UIStackView = { + $0.addArrangedSubviews([primaryButton, secondaryButton, UIView()]) + $0.spacing = Constants.Spacing.buttonsStackViewInterItemSpacing + $0.setCustomSpacing(0, after: secondaryButton) + return $0 + }(UIStackView()) + + private static func updateTitleLabel( + _ titleLabel: UILabel, + with text: String, + shouldPrefixLeadingIcon: Bool) { + + if shouldPrefixLeadingIcon { + titleLabel.text = Constants.leadingIconUnicode + " " + text + } else { + titleLabel.text = text + } + } + + private let containerView = UIView() + private var containerTopConstraint: NSLayoutConstraint? + private var containerBottomConstraint: NSLayoutConstraint? + private var arrowShapeLayer: CAShapeLayer? + + init() { + super.init(frame: .zero) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + override func layoutSubviews() { + arrowShapeLayer?.strokeColor = Constants.invertedTooltipBackgroundColor.cgColor + arrowShapeLayer?.fillColor = Constants.invertedTooltipBackgroundColor.cgColor + containerView.layer.shadowOpacity = traitCollection.userInterfaceStyle == .light ? 0.5 : 0 + } + + /// Adds a tooltip Arrow Head at the given X Offset and either to the top or the bottom. + /// - Parameters: + /// - offsetX: The offset on which the arrow will be placed. The value must be above 0 and below maxX of the view. + /// - arrowPosition: Arrow will be placed either on `.top`, pointed up, or `.bottom`, pointed down. + func addArrowHead(toXPosition offsetX: CGFloat, arrowPosition: ArrowPosition) { + arrowShapeLayer?.removeFromSuperlayer() + + let arrowTipY: CGFloat + let arrowTipYControl: CGFloat + let offsetY: CGFloat + + switch arrowPosition { + case .top: + offsetY = 0 + arrowTipY = Constants.arrowTipYLength * -1 + arrowTipYControl = Constants.arrowTipYControlLength * -1 + containerTopConstraint?.constant = Constants.arrowTipYControlLength + containerBottomConstraint?.constant = 0 + case .bottom: + offsetY = Self.height(withTitle: titleLabel.text, message: message) + arrowTipY = Constants.arrowTipYLength + arrowTipYControl = Constants.arrowTipYControlLength + containerTopConstraint?.constant = 0 + containerBottomConstraint?.constant = Constants.arrowTipYControlLength + } + + let arrowPath = UIBezierPath() + arrowPath.move(to: CGPoint(x: 0, y: 0)) + let arrowOriginX = (Self.arrowWidth/2 - 1) + // In order to have a full width of `arrowWidth`, first draw the left side of the triangle until arrowOriginX. + arrowPath.addLine(to: CGPoint(x: arrowOriginX, y: arrowTipY)) + // Add curve until `arrowWidth/2 + 1` (2 points of curve for a rounded arrow tip). + arrowPath.addQuadCurve( + to: CGPoint(x: arrowOriginX + 2, y: arrowTipY), + controlPoint: CGPoint(x: Self.arrowWidth/2, y: arrowTipYControl) + ) + // Draw down to 20. + arrowPath.addLine(to: CGPoint(x: Self.arrowWidth, y: 0)) + arrowPath.close() + + arrowShapeLayer = CAShapeLayer() + guard let arrowShapeLayer = arrowShapeLayer else { + return + } + + arrowShapeLayer.path = arrowPath.cgPath + + arrowShapeLayer.strokeColor = Constants.invertedTooltipBackgroundColor.cgColor + arrowShapeLayer.fillColor = Constants.invertedTooltipBackgroundColor.cgColor + arrowShapeLayer.lineWidth = 4.0 + + arrowShapeLayer.position = CGPoint(x: offsetX - Self.arrowWidth/2, y: offsetY) + + containerView.layer.addSublayer(arrowShapeLayer) + } + + func size() -> CGSize { + CGSize( + width: Self.width( + title: titleLabel.text, + message: message, + primaryButtonTitle: primaryButton.titleLabel?.text, + secondaryButtonTitle: secondaryButton.titleLabel?.text + ), + height: Self.height( + withTitle: title, + message: message + ) + ) + } + + func copy() -> Tooltip { + let copyTooltip = Tooltip() + copyTooltip.title = title + copyTooltip.message = message + copyTooltip.primaryButtonTitle = primaryButtonTitle + copyTooltip.secondaryButtonTitle = secondaryButtonTitle + copyTooltip.dismissalAction = dismissalAction + copyTooltip.secondaryButtonAction = secondaryButtonAction + copyTooltip.shouldPrefixLeadingIcon = shouldPrefixLeadingIcon + copyTooltip.buttonAlignment = buttonAlignment + return copyTooltip + } + + private func commonInit() { + backgroundColor = .clear + + setUpContainerView() + setUpConstraints() + addShadow() + isAccessibilityElement = true + } + + private func setUpContainerView() { + containerView.backgroundColor = Constants.invertedTooltipBackgroundColor + containerView.layer.cornerRadius = Constants.cornerRadius + containerView.translatesAutoresizingMaskIntoConstraints = false + addSubview(containerView) + + containerTopConstraint = containerView.topAnchor.constraint(equalTo: topAnchor) + containerBottomConstraint = bottomAnchor.constraint(equalTo: containerView.bottomAnchor) + + NSLayoutConstraint.activate([ + containerTopConstraint!, + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + containerBottomConstraint! + ]) + } + + private func setUpConstraints() { + contentStackView.translatesAutoresizingMaskIntoConstraints = false + buttonsStackView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(contentStackView) + + NSLayoutConstraint.activate([ + contentStackView.topAnchor.constraint( + equalTo: containerView.topAnchor, + constant: Constants.Spacing.contentStackViewTop + ), + contentStackView.leadingAnchor.constraint( + equalTo: containerView.leadingAnchor, + constant: Constants.Spacing.contentStackViewHorizontal + ), + containerView.trailingAnchor.constraint( + equalTo: contentStackView.trailingAnchor, + constant: Constants.Spacing.contentStackViewHorizontal + ), + containerView.bottomAnchor.constraint( + equalTo: contentStackView.bottomAnchor, + constant: Constants.Spacing.contentStackViewBottom + ), + containerView.widthAnchor.constraint( + lessThanOrEqualToConstant: maxWidth + ), + buttonsStackView.heightAnchor.constraint(equalToConstant: Constants.Spacing.buttonStackViewHeight) + ]) + } + + private func addShadow() { + containerView.layer.shadowColor = UIColor.black.cgColor + containerView.layer.shadowOffset = CGSize(width: 0, height: 2) + containerView.layer.shadowOpacity = traitCollection.userInterfaceStyle == .light ? 0.5 : 0 + } + + @objc private func didTapPrimaryButton() { + dismissalAction?() + } + + @objc private func didTapSecondaryButton() { + secondaryButtonAction?() + } + + private static func height( + withTitle title: String?, + message: String? + ) -> CGFloat { + var totalHeight: CGFloat = 0 + + totalHeight += Constants.Spacing.contentStackViewTop + + if let title = title { + totalHeight += title.height(withMaxWidth: maxContentWidth(), font: WPStyleGuide.fontForTextStyle(.body)) + } + + totalHeight += Constants.Spacing.contentStackViewInterItemSpacing * 2 + + if let message = message { + totalHeight += message.height(withMaxWidth: maxContentWidth(), font: WPStyleGuide.fontForTextStyle(.body)) + } + + totalHeight += Constants.Spacing.buttonStackViewHeight + totalHeight += Constants.Spacing.contentStackViewBottom + + return totalHeight + } + + private static func width( + title: String?, + message: String?, + primaryButtonTitle: String?, + secondaryButtonTitle: String? + ) -> CGFloat { + let titleWidth = title?.width(withMaxWidth: maxContentWidth(), font: WPStyleGuide.fontForTextStyle(.body)) ?? 0 + let messageWidth = message?.width(withMaxWidth: maxContentWidth(), font: WPStyleGuide.fontForTextStyle(.body)) ?? 0 + + var buttonsWidth: CGFloat = 0 + if let primaryButtonTitle = primaryButtonTitle { + buttonsWidth += primaryButtonTitle.width(withMaxWidth: maxContentWidth(), font: WPStyleGuide.fontForTextStyle(.subheadline)) + } + + if let secondaryButtonTitle = secondaryButtonTitle { + buttonsWidth += secondaryButtonTitle.width( + withMaxWidth: maxContentWidth(), + font: WPStyleGuide.fontForTextStyle(.subheadline) + ) + Constants.Spacing.buttonsStackViewInterItemSpacing + } + + return max(max(titleWidth, messageWidth), buttonsWidth) + Constants.Spacing.contentStackViewHorizontal * 2 + } + + private static func maxContentWidth() -> CGFloat { + UIScreen.main.bounds.width + - Constants.Spacing.superHorizontalMargin + - (Constants.Spacing.contentStackViewHorizontal * 2) + } +} + +extension String { + private func size(withMaxWidth maxWidth: CGFloat, font: UIFont) -> CGRect { + let constraintRect = CGSize(width: maxWidth, height: .greatestFiniteMagnitude) + let boundingBox = self.boundingRect( + with: constraintRect, + options: .usesLineFragmentOrigin, + attributes: [.font: font], + context: nil + ) + + return boundingBox + } + + func height(withMaxWidth maxWidth: CGFloat, font: UIFont) -> CGFloat { + ceil(size(withMaxWidth: maxWidth, font: font).height) + } + + func width(withMaxWidth maxWidth: CGFloat, font: UIFont) -> CGFloat { + ceil(size(withMaxWidth: maxWidth, font: font).width) + } +} diff --git a/WordPress/Classes/ViewRelated/Feature Highlight/TooltipAnchor.swift b/WordPress/Classes/ViewRelated/Feature Highlight/TooltipAnchor.swift new file mode 100644 index 000000000000..b47ecbed46a8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Feature Highlight/TooltipAnchor.swift @@ -0,0 +1,86 @@ +import UIKit + +final class TooltipAnchor: UIControl { + private enum Constants { + static let horizontalMarginToBounds: CGFloat = 16 + static let verticalMarginToBounds: CGFloat = 9 + static let stackViewSpacing: CGFloat = 4 + static let viewHeight: CGFloat = 40 + } + + var title: String? { + didSet { + titleLabel.text = title + accessibilityLabel = title + } + } + + private lazy var titleLabel: UILabel = { + $0.textColor = .invertedLabel + $0.font = WPStyleGuide.fontForTextStyle(.body) + return $0 + }(UILabel()) + + private lazy var highlightLabel: UILabel = { + $0.font = WPStyleGuide.fontForTextStyle(.body) + $0.text = "✨" + return $0 + }(UILabel()) + + private lazy var stackView: UIStackView = { + $0.addArrangedSubviews([highlightLabel, titleLabel]) + $0.spacing = Constants.stackViewSpacing + $0.isUserInteractionEnabled = false + return $0 + }(UIStackView()) + + init() { + super.init(frame: .zero) + commonInit() + } + + func toggleVisibility(_ isVisible: Bool) { + UIView.animate(withDuration: 0.2) { + if isVisible { + self.alpha = 1 + } else { + self.alpha = 0 + } + } + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + setUpViewHierarchy() + configureUI() + } + + private func setUpViewHierarchy() { + stackView.translatesAutoresizingMaskIntoConstraints = false + addSubview(stackView) + + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: Constants.viewHeight), + stackView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.verticalMarginToBounds), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.horizontalMarginToBounds), + trailingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: Constants.horizontalMarginToBounds), + bottomAnchor.constraint(equalTo: stackView.bottomAnchor, constant: Constants.verticalMarginToBounds) + ]) + } + + private func configureUI() { + backgroundColor = .invertedSystem5 + layer.cornerRadius = Constants.viewHeight / 2 + addShadow() + } + + private func addShadow() { + layer.shadowColor = UIColor.black.cgColor + layer.shadowOffset = CGSize(width: 0, height: 2) + layer.shadowOpacity = 0.5 + } +} diff --git a/WordPress/Classes/ViewRelated/Feature Highlight/TooltipPresenter.swift b/WordPress/Classes/ViewRelated/Feature Highlight/TooltipPresenter.swift new file mode 100644 index 000000000000..69143b697040 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Feature Highlight/TooltipPresenter.swift @@ -0,0 +1,405 @@ +import UIKit + +/// A helper class for presentation of the Tooltip in respect to a `targetView`. +/// Must be retained to respond to device orientation and size category changes. +final class TooltipPresenter { + private enum Constants { + static let verticalTooltipDistanceToFocus: CGFloat = 8 + static let horizontalBufferMargin: CGFloat = 20 + static let tooltipTopConstraintAnimationOffset: CGFloat = 8 + static let tooltipAnimationDuration: TimeInterval = 0.2 + static let anchorBottomConstraintConstant: CGFloat = 58 + static let spotlightViewBufferHeight: CGFloat = 38 + static let spotlightViewRadius: CGFloat = 20 + } + + enum TooltipVerticalPosition { + case auto + case above + case below + } + + enum Target { + case view(UIView) + case point((() -> CGPoint)) + } + + private let containerView: UIView + private var spotlightView: QuickStartSpotlightView? + private var primaryTooltipAction: (() -> Void)? + private var secondaryTooltipAction: (() -> Void)? + private var anchor: TooltipAnchor? + private var tooltipTopConstraint: NSLayoutConstraint? + private var anchorAction: (() -> Void)? + private let target: Target + + private var targetMidX: CGFloat { + switch target { + case .view(let targetView): + return targetView.frame.midX + case .point(let targetPoint): + return targetPoint().x + } + } + + private var targetMinY: CGFloat { + switch target { + case .view(let targetView): + return targetView.frame.minY + case .point(let targetPoint): + return targetPoint().y + } + } + + + private(set) var tooltip: Tooltip + var tooltipVerticalPosition: TooltipVerticalPosition = .auto + private let shouldShowSpotlightView: Bool + + private var totalVerticalBuffer: CGFloat { + Constants.verticalTooltipDistanceToFocus + + Constants.tooltipTopConstraintAnimationOffset + } + + private var spotlightVerticalBuffer: CGFloat { + switch target { + case .view: + return totalVerticalBuffer + case .point: + return totalVerticalBuffer + Constants.spotlightViewBufferHeight + } + } + + private var previousDeviceOrientation: UIDeviceOrientation? + + init(containerView: UIView, + tooltip: Tooltip, + target: Target, + shouldShowSpotlightView: Bool, + primaryTooltipAction: (() -> Void)? = nil, + secondaryTooltipAction: (() -> Void)? = nil + ) { + self.containerView = containerView + self.tooltip = tooltip + self.shouldShowSpotlightView = shouldShowSpotlightView + self.primaryTooltipAction = primaryTooltipAction + self.secondaryTooltipAction = secondaryTooltipAction + self.target = target + + configureDismissal() + + previousDeviceOrientation = UIDevice.current.orientation + NotificationCenter.default.addObserver( + self, + selector: #selector(resetTooltipAndShow), + name: UIContentSizeCategory.didChangeNotification, object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(didDeviceOrientationChange), + name: UIDevice.orientationDidChangeNotification, + object: nil + ) + } + + func attachAnchor(withTitle title: String, onView view: UIView, anchorAction: @escaping (() -> Void)) { + let anchor = TooltipAnchor() + self.anchor = anchor + self.anchorAction = anchorAction + anchor.title = title + anchor.addTarget(self, action: #selector(didTapAnchor), for: .touchUpInside) + anchor.translatesAutoresizingMaskIntoConstraints = false + anchor.alpha = 0 + view.addSubview(anchor) + + NSLayoutConstraint.activate([ + anchor.centerXAnchor.constraint(equalTo: view.centerXAnchor), + view.safeAreaLayoutGuide.bottomAnchor.constraint( + equalTo: anchor.bottomAnchor, + constant: Constants.anchorBottomConstraintConstant + ) + ]) + } + + func toggleAnchorVisibility(_ isVisible: Bool) { + guard let anchor = anchor else { + return + } + + anchor.toggleVisibility(isVisible) + } + + func showTooltip() { + containerView.addSubview(tooltip) + self.tooltip.alpha = 0 + tooltip.addArrowHead(toXPosition: arrowOffsetX(), arrowPosition: tooltipOrientation()) + setUpTooltipConstraints() + + containerView.layoutIfNeeded() + animateTooltipIn() + } + + func dismissTooltip() { + UIView.animate( + withDuration: Constants.tooltipAnimationDuration, + delay: 0, + options: .curveEaseOut + ) { + guard let tooltipTopConstraint = self.tooltipTopConstraint else { + return + } + + self.tooltip.alpha = 0 + tooltipTopConstraint.constant += Constants.tooltipTopConstraintAnimationOffset + self.containerView.layoutIfNeeded() + } completion: { isSuccess in + self.anchor = nil + self.primaryTooltipAction?() + self.tooltip.removeFromSuperview() + self.spotlightView?.removeFromSuperview() + NotificationCenter.default.removeObserver(self) + } + } + + private func animateTooltipIn() { + UIView.animate( + withDuration: Constants.tooltipAnimationDuration, + delay: 0, + options: .curveEaseOut + ) { + guard let tooltipTopConstraint = self.tooltipTopConstraint else { + return + } + + self.tooltip.alpha = 1 + tooltipTopConstraint.constant -= Constants.tooltipTopConstraintAnimationOffset + + self.containerView.layoutIfNeeded() + } completion: { success in + if self.shouldShowSpotlightView { + self.showSpotlightView() + } + } + } + + @objc private func didTapAnchor() { + anchorAction?() + } + + private func configureDismissal() { + tooltip.dismissalAction = dismissTooltip + } + + private func setUpTooltipConstraints() { + tooltip.translatesAutoresizingMaskIntoConstraints = false + + var tooltipConstraints = [ + tooltip.centerXAnchor.constraint(equalTo: containerView.centerXAnchor, constant: extraArrowOffsetX()) + ] + + let verticalExtraSpotlightOffset: CGFloat = 14 + + switch target { + case .view(let targetView): + switch tooltipOrientation() { + case .bottom: + tooltipTopConstraint = targetView.topAnchor.constraint( + equalTo: tooltip.bottomAnchor, + constant: spotlightVerticalBuffer + verticalExtraSpotlightOffset + ) + case .top: + tooltipTopConstraint = tooltip.topAnchor.constraint( + equalTo: targetView.bottomAnchor, + constant: spotlightVerticalBuffer + verticalExtraSpotlightOffset + ) + } + case .point(let targetPoint): + switch tooltipOrientation() { + case .bottom: + tooltipTopConstraint = tooltip.bottomAnchor.constraint( + equalTo: containerView.topAnchor, + constant: targetPoint().y + totalVerticalBuffer + ) + case .top: + tooltipTopConstraint = tooltip.topAnchor.constraint( + equalTo: containerView.topAnchor, + constant: targetPoint().y + + spotlightVerticalBuffer + verticalExtraSpotlightOffset + ) + } + } + + tooltipConstraints.append(tooltipTopConstraint!) + NSLayoutConstraint.activate(tooltipConstraints) + } + + private func showSpotlightView() { + spotlightView?.removeFromSuperview() + spotlightView = QuickStartSpotlightView() + guard let spotlightView = spotlightView else { + return + } + + spotlightView.translatesAutoresizingMaskIntoConstraints = false + spotlightView.isUserInteractionEnabled = false + containerView.addSubview(spotlightView) + + if let constraints = spotlightViewConstraints() { + NSLayoutConstraint.activate(constraints) + } + } + + private func spotlightViewConstraints() -> [NSLayoutConstraint]? { + guard let spotlightView = spotlightView else { + return nil + } + + // `leftAnchor` is used because the `arrowOffsetX` is calculated as an absolute point. + // So it is required to constraint always to left (or right) to support LTR and RTL languages. + var constraints = [ + spotlightView.leftAnchor.constraint( + equalTo: containerView.leftAnchor, + constant: arrowOffsetX() + tooltip.frame.minX - Constants.spotlightViewRadius + ) + ] + + let verticalConstraint: NSLayoutConstraint + + switch target { + case .view(let targetView): + verticalConstraint = spotlightVerticalConstraint(spotlightView, targetView: targetView) + case .point(let targetPoint): + verticalConstraint = spotlightVerticalConstraint(spotlightView, targetPoint: targetPoint) + } + + constraints.append(verticalConstraint) + + return constraints + } + + private func spotlightVerticalConstraint( + _ spotlightView: QuickStartSpotlightView, + targetView: UIView) -> NSLayoutConstraint { + switch tooltipOrientation() { + case .bottom: + return targetView.topAnchor.constraint( + equalTo: spotlightView.topAnchor, + constant: spotlightVerticalBuffer + ) + case .top: + return targetView.bottomAnchor.constraint( + equalTo: spotlightView.topAnchor, + constant: Constants.spotlightViewRadius + ) + } + } + + private func spotlightVerticalConstraint( + _ spotlightView: QuickStartSpotlightView, + targetPoint: (() -> CGPoint)) -> NSLayoutConstraint { + switch tooltipOrientation() { + case .bottom: + return spotlightView.bottomAnchor.constraint( + equalTo: containerView.topAnchor, + constant: targetPoint().y + + spotlightVerticalBuffer + ) + case .top: + return spotlightView.topAnchor.constraint( + equalTo: containerView.topAnchor, + constant: targetPoint().y + + totalVerticalBuffer + ) + } + } + + /// `orientationDidChangeNotification` is published when the device is at `faceUp` or `faceDown` + /// states too. The sizing won't be affected in these cases so no need to reset the tooltip. Here we filter out changes + /// to and from `faceUp` & `faceDown`. + @objc private func didDeviceOrientationChange() { + guard let previousDeviceOrientation = previousDeviceOrientation else { + return + } + + self.previousDeviceOrientation = UIDevice.current.orientation + + switch (previousDeviceOrientation, UIDevice.current.orientation) { + case (_, .faceUp), (_, .faceDown), (.faceUp, _), (.faceDown, _): + return + default: + resetTooltipAndShow() + } + } + + @objc private func resetTooltipAndShow() { + UIView.animate( + withDuration: Constants.tooltipAnimationDuration, + delay: 0, + options: .curveEaseOut + ) { + guard let tooltipTopConstraint = self.tooltipTopConstraint else { + return + } + + self.tooltip.alpha = 0 + tooltipTopConstraint.constant += Constants.tooltipTopConstraintAnimationOffset + self.containerView.layoutIfNeeded() + } completion: { isSuccess in + self.tooltip.removeFromSuperview() + self.tooltip = self.tooltip.copy() + self.showTooltip() + } + } + + /// Calculates where the arrow needs to place in the borders of the tooltip. + /// This depends on the position of the target relative to `tooltip`. + private func arrowOffsetX() -> CGFloat { + targetMidX - ((containerView.bounds.width - tooltip.size().width) / 2) - extraArrowOffsetX() + } + + /// If the tooltip is always vertically centered, tooltip's width may not be big enough to reach to the target + /// If `xxxxxxxx` is the Tooltip and `oo` is the target: + /// | | + /// | xxxxxxxx | + /// | oo | + /// The tooltip needs an extra X offset to be aligned with target so that tooltip arrow points to the correct position. + /// Here the tooltip is pushed to the right so the arrow is pointing at the target + /// | | + /// | xxxxxxxx | + /// | oo | + /// It would be retracted instead if the target was at the left of the screen. + /// + private func extraArrowOffsetX() -> CGFloat { + let tooltipWidth = tooltip.size().width + let extraPushOffset = max( + (targetMidX + Constants.horizontalBufferMargin) - (containerView.safeAreaLayoutGuide.layoutFrame.midX + tooltipWidth / 2), + 0 + ) + + if extraPushOffset > 0 { + return extraPushOffset + } + + let extraRetractOffset = min( + (targetMidX - Constants.horizontalBufferMargin) - (containerView.safeAreaLayoutGuide.layoutFrame.midX - tooltipWidth / 2), + 0 + ) + + return extraRetractOffset + } + + + private func tooltipOrientation() -> Tooltip.ArrowPosition { + switch tooltipVerticalPosition { + case .auto: + if containerView.frame.midY < targetMinY { + return .bottom + } + return .top + case .above: + return .bottom + case .below: + return .top + } + } +} diff --git a/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsFeatureDescriptionView.swift b/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsFeatureDescriptionView.swift new file mode 100644 index 000000000000..fa37632c8125 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsFeatureDescriptionView.swift @@ -0,0 +1,99 @@ +import UIKit + +class BloggingPromptsFeatureDescriptionView: UIView, NibLoadable { + + // MARK: - Properties + + @IBOutlet private weak var promptCardView: UIView! + @IBOutlet private weak var descriptionLabel: UILabel! + @IBOutlet private weak var noteTextView: UITextView! + @IBOutlet private weak var noteAccessibilityLabel: UILabel! + + // MARK: - Init + + open override func awakeFromNib() { + super.awakeFromNib() + configureView() + } + +} + +private extension BloggingPromptsFeatureDescriptionView { + + func configureView() { + configurePromptCard() + configureDescription() + configureNote() + } + + func configurePromptCard() { + let promptCard = DashboardPromptsCardCell() + promptCard.configureForExampleDisplay() + + // The DashboardPromptsCardCell doesn't resize dynamically when used in this context. + // So use its cardFrameView instead. + promptCard.cardFrameView.translatesAutoresizingMaskIntoConstraints = false + promptCard.cardFrameView.layer.cornerRadius = Style.cardCornerRadius + promptCard.cardFrameView.layer.shadowOffset = Style.cardShadowOffset + promptCard.cardFrameView.layer.shadowOpacity = Style.cardShadowOpacity + promptCard.cardFrameView.layer.shadowRadius = Style.cardShadowRadius + + promptCardView.accessibilityElementsHidden = true + promptCardView.addSubview(promptCard.cardFrameView) + promptCardView.pinSubviewToAllEdges(promptCard.cardFrameView) + } + + func configureDescription() { + descriptionLabel.font = Style.labelFont + descriptionLabel.textColor = Style.textColor + descriptionLabel.text = Strings.featureDescription + } + + func configureNote() { + noteTextView.layer.borderWidth = Style.noteBorderWidth + noteTextView.layer.cornerRadius = Style.noteCornerRadius + noteTextView.layer.borderColor = Style.noteBorderColor + noteTextView.textContainerInset = Style.noteInsets + configureNoteText() + } + + func configureNoteText() { + let attributedString = NSMutableAttributedString() + + // These attributed string styles cannot be stored statically (i.e. in the Style enum). + // They must be dynamic to resize correctly when the text size changes. + + attributedString.append(.init(string: Strings.noteLabel, + attributes: [.foregroundColor: Style.textColor, + .font: UIFont.preferredFont(forTextStyle: .caption1).bold()])) + + attributedString.append(.init(string: " " + Strings.noteText, + attributes: [.foregroundColor: Style.textColor, + .font: UIFont.preferredFont(forTextStyle: .caption1)])) + + noteTextView.attributedText = attributedString + + noteTextView.accessibilityElementsHidden = true + noteAccessibilityLabel.accessibilityLabel = Strings.noteTextAccessibilityLabel + } + + enum Strings { + static let featureDescription: String = NSLocalizedString("We’ll show you a new prompt each day on your dashboard to help get those creative juices flowing!", comment: "Description of Blogging Prompts displayed in the Feature Introduction view.") + static let noteLabel: String = NSLocalizedString("Note:", comment: "Label for the note displayed in the Feature Introduction view.") + static let noteText: String = NSLocalizedString("You can control Blogging Prompts and Reminders at any time in My Site > Settings > Blogging", comment: "Note displayed in the Feature Introduction view.") + static let noteTextAccessibilityLabel: String = NSLocalizedString("You can control Blogging Prompts and Reminders at any time in My Site, Settings, Blogging", comment: "Accessibility hint for Note displayed in the Feature Introduction view.") + } + + enum Style { + static let labelFont = WPStyleGuide.fontForTextStyle(.body) + static let textColor: UIColor = .textSubtle + static let noteInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) + static let noteCornerRadius: CGFloat = 6 + static let noteBorderWidth: CGFloat = 1 + static let noteBorderColor = UIColor.textQuaternary.cgColor + static let cardCornerRadius: CGFloat = 10 + static let cardShadowRadius: CGFloat = 14 + static let cardShadowOpacity: Float = 0.1 + static let cardShadowOffset = CGSize(width: 0, height: 10.0) + } +} diff --git a/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsFeatureDescriptionView.xib b/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsFeatureDescriptionView.xib new file mode 100644 index 000000000000..b671088cbb2f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsFeatureDescriptionView.xib @@ -0,0 +1,89 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="iN0-l3-epB" customClass="BloggingPromptsFeatureDescriptionView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="327"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="8JU-nl-SWR" userLabel="Prompt Card"> + <rect key="frame" x="52" y="0.0" width="310.5" height="200.5"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <accessibility key="accessibilityConfiguration"> + <accessibilityTraits key="traits" notEnabled="YES"/> + </accessibility> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" id="BfW-eb-ZtV"/> + </constraints> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="We’ll show you a new prompt each day on your dashboard to help get those creative juices flowing!" textAlignment="center" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1gW-xt-yLe" userLabel="Description"> + <rect key="frame" x="16" y="224.5" width="382" height="36"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <color key="textColor" systemColor="secondaryLabelColor"/> + <nil key="highlightedColor"/> + </label> + <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" scrollEnabled="NO" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" editable="NO" text="Note: You can learn more and set up reminders at any time in My Site > Settings > Blogging Reminders." textAlignment="natural" adjustsFontForContentSizeCategory="YES" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="z9E-X2-t3c" userLabel="Note"> + <rect key="frame" x="16" y="284.5" width="382" height="42.5"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <accessibility key="accessibilityConfiguration"> + <accessibilityTraits key="traits" notEnabled="YES"/> + <bool key="isElement" value="NO"/> + </accessibility> + <color key="textColor" systemColor="secondaryLabelColor"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/> + <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> + </textView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ART-X9-BCw" userLabel="Note Accessibility Label"> + <rect key="frame" x="16" y="284.5" width="382" height="42.5"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="z9E-X2-t3c" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="1K2-WJ-vdn"/> + <constraint firstItem="8JU-nl-SWR" firstAttribute="width" secondItem="iN0-l3-epB" secondAttribute="width" multiplier="0.75" id="4C5-B7-SUy"/> + <constraint firstItem="ART-X9-BCw" firstAttribute="leading" secondItem="z9E-X2-t3c" secondAttribute="leading" id="8Qb-0Z-rT5"/> + <constraint firstItem="8JU-nl-SWR" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" id="95d-85-VYs"/> + <constraint firstAttribute="bottom" secondItem="z9E-X2-t3c" secondAttribute="bottom" id="IUZ-vA-5QN"/> + <constraint firstItem="1gW-xt-yLe" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WJ4-xD-ctR"/> + <constraint firstItem="ART-X9-BCw" firstAttribute="trailing" secondItem="z9E-X2-t3c" secondAttribute="trailing" id="lKl-p8-5Ty"/> + <constraint firstItem="8JU-nl-SWR" firstAttribute="centerX" secondItem="vUN-kp-3ea" secondAttribute="centerX" id="sjz-gT-pc8"/> + <constraint firstAttribute="trailing" secondItem="1gW-xt-yLe" secondAttribute="trailing" constant="16" id="srn-mJ-gKe"/> + <constraint firstAttribute="trailing" secondItem="z9E-X2-t3c" secondAttribute="trailing" constant="16" id="vmH-4q-pbI"/> + <constraint firstItem="ART-X9-BCw" firstAttribute="bottom" secondItem="z9E-X2-t3c" secondAttribute="bottom" id="wAs-7B-QAX"/> + <constraint firstItem="z9E-X2-t3c" firstAttribute="top" secondItem="1gW-xt-yLe" secondAttribute="bottom" constant="24" id="wED-RV-9Lf"/> + <constraint firstItem="ART-X9-BCw" firstAttribute="top" secondItem="z9E-X2-t3c" secondAttribute="top" id="xiL-2a-FCO"/> + <constraint firstItem="1gW-xt-yLe" firstAttribute="top" secondItem="8JU-nl-SWR" secondAttribute="bottom" constant="24" id="yAr-WH-wmx"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="descriptionLabel" destination="1gW-xt-yLe" id="rot-qA-zIV"/> + <outlet property="noteAccessibilityLabel" destination="ART-X9-BCw" id="cbu-ST-2Qk"/> + <outlet property="noteTextView" destination="z9E-X2-t3c" id="GcN-mv-lZH"/> + <outlet property="promptCardView" destination="8JU-nl-SWR" id="tY3-0q-Bn3"/> + </connections> + <point key="canvasLocation" x="-5.7971014492753632" y="63.28125"/> + </view> + </objects> + <resources> + <systemColor name="secondaryLabelColor"> + <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsFeatureIntroduction.swift b/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsFeatureIntroduction.swift new file mode 100644 index 000000000000..cec9ba872c46 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsFeatureIntroduction.swift @@ -0,0 +1,150 @@ +import UIKit + +/// This displays a Feature Introduction specifically for Blogging Prompts. + +class BloggingPromptsFeatureIntroduction: FeatureIntroductionViewController { + + var presenter: BloggingPromptsIntroductionPresenter? + + private var interactionType: BloggingPromptsFeatureIntroduction.InteractionType + + enum InteractionType { + // Two buttons are displayed, both perform an action. Shows a site picker + // if `blog` is `nil` and user has multiple sites. + case actionable(blog: Blog?) + // One button is displayed, which only dismisses the view. + case informational + + var primaryButtonTitle: String { + switch self { + case .actionable: + return ButtonStrings.tryIt + case .informational: + return ButtonStrings.gotIt + } + } + + var secondaryButtonTitle: String? { + switch self { + case .actionable: + return ButtonStrings.remindMe + default: + return nil + } + } + } + + class func navigationController(interactionType: BloggingPromptsFeatureIntroduction.InteractionType) -> UINavigationController { + let controller = BloggingPromptsFeatureIntroduction(interactionType: interactionType) + let navController = UINavigationController(rootViewController: controller) + return navController + } + + init(interactionType: BloggingPromptsFeatureIntroduction.InteractionType) { + + let featureDescriptionView: BloggingPromptsFeatureDescriptionView = { + let featureDescriptionView = BloggingPromptsFeatureDescriptionView.loadFromNib() + featureDescriptionView.translatesAutoresizingMaskIntoConstraints = false + return featureDescriptionView + }() + + let headerImage = UIImage(named: HeaderStyle.imageName)?.withTintColor(.clear) + + self.interactionType = interactionType + + super.init(headerTitle: HeaderStrings.title, + headerSubtitle: HeaderStrings.subtitle, + headerImage: headerImage, + featureDescriptionView: featureDescriptionView, + primaryButtonTitle: interactionType.primaryButtonTitle, + secondaryButtonTitle: interactionType.secondaryButtonTitle) + + featureIntroductionDelegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Add the gradient after the image has been added to the view so the gradient is the correct size. + addHeaderImageGradient() + } + + override func closeButtonTapped() { + WPAnalytics.track(.promptsIntroductionModalDismissed) + super.closeButtonTapped() + } + +} + +extension BloggingPromptsFeatureIntroduction: FeatureIntroductionDelegate { + + func primaryActionSelected() { + guard case .actionable = interactionType else { + WPAnalytics.track(.promptsIntroductionModalGotIt) + super.closeButtonTapped() + return + } + + WPAnalytics.track(.promptsIntroductionModalTryItNow) + presenter?.primaryButtonSelected() + } + + func secondaryActionSelected() { + guard case .actionable = interactionType else { + return + } + + WPAnalytics.track(.promptsIntroductionModalRemindMe) + presenter?.secondaryButtonSelected() + } + +} + +private extension BloggingPromptsFeatureIntroduction { + + func addHeaderImageGradient() { + // Based on https://stackoverflow.com/a/54096829 + let gradient = CAGradientLayer() + + gradient.colors = [ + HeaderStyle.startGradientColor.cgColor, + HeaderStyle.endGradientColor.cgColor + ] + + // Create a gradient from top to bottom. + gradient.startPoint = CGPoint(x: 0.5, y: 0) + gradient.endPoint = CGPoint(x: 0.5, y: 1) + gradient.frame = headerImageView.bounds + + // Add a mask to the gradient so the colors only apply to the image (and not the imageView). + let mask = CALayer() + mask.contents = headerImageView.image?.cgImage + mask.frame = gradient.bounds + gradient.mask = mask + + // Add the gradient as a sublayer to the imageView's layer. + headerImageView.layer.addSublayer(gradient) + } + + enum ButtonStrings { + static let tryIt = NSLocalizedString("Try it now", comment: "Button title on the blogging prompt's feature introduction view to answer a prompt.") + static let gotIt = NSLocalizedString("Got it", comment: "Button title on the blogging prompt's feature introduction view to dismiss the view.") + static let remindMe = NSLocalizedString("Remind me", comment: "Button title on the blogging prompt's feature introduction view to set a reminder.") + } + + enum HeaderStrings { + static let title: String = NSLocalizedString("Introducing Blogging Prompts", comment: "Title displayed on the feature introduction view.") + static let subtitle: String = NSLocalizedString("The best way to become a better writer is to build a writing habit and share with others - that’s where Prompts come in!", comment: "Subtitle displayed on the feature introduction view.") + } + + enum HeaderStyle { + static let imageName = "icon-lightbulb-outline" + static let startGradientColor: UIColor = .warning(.shade30) + static let endGradientColor: UIColor = .accent(.shade40) + } + +} diff --git a/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsIntroductionPresenter.swift b/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsIntroductionPresenter.swift new file mode 100644 index 000000000000..08f214e1fdb5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Feature Introduction/Blogging Prompts/BloggingPromptsIntroductionPresenter.swift @@ -0,0 +1,188 @@ +import Foundation +import UIKit + +/// Presents the BloggingPromptsFeatureIntroduction with actionable buttons +/// and directs the flow according to which action button is tapped. +/// - Try it: the answer prompt flow. +/// - Remind me: the blogging reminders flow. +/// - If the account has multiple sites, a site selector is displayed before either of the above. + +class BloggingPromptsIntroductionPresenter: NSObject { + + // MARK: - Properties + + private var presentingViewController: UIViewController? + private var interactionType: BloggingPromptsFeatureIntroduction.InteractionType + + private lazy var navigationController: UINavigationController = { + let vc = BloggingPromptsFeatureIntroduction(interactionType: interactionType) + vc.presenter = self + return UINavigationController(rootViewController: vc) + }() + + private var siteSelectorNavigationController: UINavigationController? + private var selectedBlog: Blog? + + private lazy var accountSites: [Blog]? = { + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) + return account?.visibleBlogs.filter { $0.isAccessibleThroughWPCom() } + }() + + private lazy var accountHasMultipleSites: Bool = { + (accountSites?.count ?? 0) > 1 + }() + + private lazy var accountHasNoSites: Bool = { + (accountSites?.count ?? 0) == 0 + }() + + private lazy var bloggingPromptsService: BloggingPromptsService? = { + return BloggingPromptsService(blog: blogToUse()) + }() + + // MARK: - Init + + init(interactionType: BloggingPromptsFeatureIntroduction.InteractionType = .actionable(blog: nil)) { + self.interactionType = interactionType + if case .actionable(let blog) = interactionType { + selectedBlog = blog + } + super.init() + } + + // MARK: - Present Feature Introduction + + func present(from presentingViewController: UIViewController) { + WPAnalytics.track(.promptsIntroductionModalViewed) + + // We shouldn't get here, but just in case - verify the account actually has a site. + // If not, fallback to the non-actionable/informational view. + if accountHasNoSites { + interactionType = .informational + } + + self.presentingViewController = presentingViewController + presentingViewController.present(navigationController, animated: true) + } + + // MARK: - Action Handling + + func primaryButtonSelected() { + showSiteSelectorIfNeeded(completion: { [weak self] in + self?.showPostCreation() + }) + } + + func secondaryButtonSelected() { + showSiteSelectorIfNeeded(completion: { [weak self] in + self?.showRemindersScheduling() + }) + } + +} + +private extension BloggingPromptsIntroductionPresenter { + + func showSiteSelectorIfNeeded(completion: @escaping () -> Void) { + guard accountHasMultipleSites, selectedBlog == nil else { + completion() + return + } + + let successHandler: BlogSelectorSuccessDotComHandler = { [weak self] (dotComID: NSNumber?) in + self?.selectedBlog = self?.accountSites?.first(where: { $0.dotComID == dotComID }) + self?.siteSelectorNavigationController?.dismiss(animated: true) + completion() + } + + let dismissHandler: BlogSelectorDismissHandler = { + completion() + } + + let selectorViewController = BlogSelectorViewController(selectedBlogDotComID: nil, + successHandler: successHandler, + dismissHandler: dismissHandler) + + selectorViewController.displaysOnlyDefaultAccountSites = true + selectorViewController.dismissOnCompletion = false + selectorViewController.dismissOnCancellation = true + selectorViewController.shouldHideSelfHostedSites = true + + let selectorNavigationController = UINavigationController(rootViewController: selectorViewController) + self.navigationController.present(selectorNavigationController, animated: true) + siteSelectorNavigationController = selectorNavigationController + } + + func showPostCreation() { + guard let blog = blogToUse(), + let presentingViewController = presentingViewController else { + navigationController.dismiss(animated: true) + return + } + + fetchPrompt(completion: { [weak self] (prompt) in + guard let prompt = prompt else { + self?.dispatchErrorNotice() + self?.navigationController.dismiss(animated: true) + return + } + + let editor = EditPostViewController(blog: blog, prompt: prompt) + editor.modalPresentationStyle = .fullScreen + editor.entryPoint = .bloggingPromptsFeatureIntroduction + + self?.navigationController.dismiss(animated: true, completion: { [weak self] in + presentingViewController.present(editor, animated: false) + self?.trackPostEditorShown(blog) + }) + }) + } + + func showRemindersScheduling() { + guard let blog = blogToUse(), + let presentingViewController = presentingViewController else { + navigationController.dismiss(animated: true) + return + } + + navigationController.dismiss(animated: true, completion: { + BloggingRemindersFlow.present(from: presentingViewController, + for: blog, + source: .bloggingPromptsFeatureIntroduction) + }) + } + + func blogToUse() -> Blog? { + return accountHasMultipleSites ? selectedBlog : accountSites?.first + } + + func trackPostEditorShown(_ blog: Blog) { + WPAppAnalytics.track(.editorCreatedPost, + withProperties: [WPAppAnalyticsKeyTapSource: "blogging_prompts_feature_introduction", WPAppAnalyticsKeyPostType: "post"], + with: blog) + } + + // MARK: Prompt Fetching + + func fetchPrompt(completion: @escaping ((_ prompt: BloggingPrompt?) -> Void)) { + // TODO: check for cached prompt first. + + guard let bloggingPromptsService = bloggingPromptsService else { + DDLogError("Feature Introduction: failed creating BloggingPromptsService instance.") + return + } + + bloggingPromptsService.fetchTodaysPrompt(success: { (prompt) in + completion(prompt) + }, failure: { (error) in + completion(nil) + DDLogError("Feature Introduction: failed fetching blogging prompt: \(String(describing: error))") + }) + } + + func dispatchErrorNotice() { + let message = NSLocalizedString("Error loading prompt", comment: "Text displayed when there is a failure loading a blogging prompt.") + presentingViewController?.displayNotice(title: message) + } + +} diff --git a/WordPress/Classes/ViewRelated/Feature Introduction/FeatureIntroductionViewController.swift b/WordPress/Classes/ViewRelated/Feature Introduction/FeatureIntroductionViewController.swift new file mode 100644 index 000000000000..22c2820e5a2a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Feature Introduction/FeatureIntroductionViewController.swift @@ -0,0 +1,119 @@ +import UIKit + +@objc protocol FeatureIntroductionDelegate: AnyObject { + func primaryActionSelected() + @objc optional func secondaryActionSelected() + @objc optional func closeButtonWasTapped() +} + +/// This is used to display a modal with information about a new feature. +/// The feature description is displayed via the provided featureDescriptionView, +/// which is presented in the scrollable area of the view. +/// A primary action button is always displayed. +/// A secondary action button is displayed if a secondaryButtonTitle is provided. + +class FeatureIntroductionViewController: CollapsableHeaderViewController { + + // MARK: - Properties + + private let scrollView: UIScrollView + private let featureDescriptionView: UIView + + // View added to scrollView that contains specific Feature Introduction content. + private lazy var contentView: UIView = { + let contentView = UIView() + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(featureDescriptionView) + contentView.pinSubviewToAllEdges(featureDescriptionView) + return contentView + }() + + weak var featureIntroductionDelegate: FeatureIntroductionDelegate? + + // MARK: - Header View Configuration + + override var separatorStyle: SeparatorStyle { + return .hidden + } + + override var alwaysResetHeaderOnRotation: Bool { + WPDeviceIdentification.isiPhone() + } + + override var alwaysShowHeaderTitles: Bool { + true + } + + // MARK: - Init + + init(headerTitle: String, + headerSubtitle: String, + headerImage: UIImage? = nil, + featureDescriptionView: UIView, + primaryButtonTitle: String, + secondaryButtonTitle: String? = nil) { + + self.featureDescriptionView = featureDescriptionView + + scrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + return scrollView + }() + + super.init( + scrollableView: scrollView, + mainTitle: headerTitle, + headerImage: headerImage, + prompt: headerSubtitle, + primaryActionTitle: primaryButtonTitle, + secondaryActionTitle: secondaryButtonTitle) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + configureView() + configureVerticalButtonView() + } + + // MARK: - Button Actions + + override func primaryActionSelected(_ sender: Any) { + featureIntroductionDelegate?.primaryActionSelected() + } + + override func secondaryActionSelected(_ sender: Any) { + featureIntroductionDelegate?.secondaryActionSelected?() + } + + @IBAction func closeButtonTapped() { + featureIntroductionDelegate?.closeButtonWasTapped?() + dismiss(animated: true) + } + +} + +private extension FeatureIntroductionViewController { + + func configureView() { + navigationItem.rightBarButtonItem = CollapsableHeaderViewController.closeButton(target: self, action: #selector(closeButtonTapped)) + scrollView.addSubview(contentView) + hideHeaderVisualEffects() + + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.rightAnchor.constraint(equalTo: scrollView.rightAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.leftAnchor.constraint(equalTo: scrollView.leftAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + ]) + } + +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/AztecAttachmentDelegate.swift b/WordPress/Classes/ViewRelated/Gutenberg/AztecAttachmentDelegate.swift index 33e0eda2d8e1..8dfa1929acbb 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/AztecAttachmentDelegate.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/AztecAttachmentDelegate.swift @@ -2,7 +2,7 @@ import Aztec class AztecAttachmentDelegate: TextViewAttachmentDelegate { private let post: AbstractPost - private var activeMediaRequests = [ImageDownloader.Task]() + private var activeMediaRequests = [ImageDownloaderTask]() private let mediaUtility = EditorMediaUtility() init(post: AbstractPost) { diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collabsable Header Filter Bar/CollabsableHeaderFilterCollectionViewCell.swift b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collabsable Header Filter Bar/CollabsableHeaderFilterCollectionViewCell.swift new file mode 100644 index 000000000000..2620d0552a6a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collabsable Header Filter Bar/CollabsableHeaderFilterCollectionViewCell.swift @@ -0,0 +1,99 @@ +import UIKit + +class CollabsableHeaderFilterCollectionViewCell: UICollectionViewCell { + + static let cellReuseIdentifier = "\(CollabsableHeaderFilterCollectionViewCell.self)" + static let nib = UINib(nibName: "\(CollabsableHeaderFilterCollectionViewCell.self)", bundle: Bundle.main) + + static var font: UIFont { + return WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + } + + private static let combinedLeftRightMargin: CGFloat = 32 + static func estimatedWidth(forFilter filter: CategorySection) -> CGFloat { + /// The emoji below is used as a placeholder to estimate the size of the title. We don't use the actual emoji provided by the API because this could be nil + /// and we want to allow space for a checkmark when the cell is selected. + let size = "👋 \(filter.title)".size(withAttributes: [ + NSAttributedString.Key.font: font + ]) + + return size.width + combinedLeftRightMargin + } + + @IBOutlet weak var filterLabel: UILabel! + @IBOutlet weak var pillBackgroundView: UIView! + @IBOutlet weak var checkmark: UIImageView! + + var filter: CategorySection? = nil { + didSet { + filterLabel.text = filterTitle + filterLabel.accessibilityLabel = filter?.title + } + } + + var filterTitle: String { + let emoji = isSelected ? nil : filter?.emoji + return [emoji, filter?.title].compactMap { $0 }.joined(separator: " ") + } + + var checkmarkTintColor: UIColor { + return UIColor { (traitCollection: UITraitCollection) -> UIColor in + if traitCollection.userInterfaceStyle == .dark { + return UIColor.darkText + } else { + return UIColor.white + } + } + } + + override var isSelected: Bool { + didSet { + checkmark.isHidden = !isSelected + filterLabel.text = filterTitle + updateSelectedStyle() + } + } + + override func awakeFromNib() { + super.awakeFromNib() + filterLabel.font = CollabsableHeaderFilterCollectionViewCell.font + checkmark.image = UIImage(systemName: "checkmark") + checkmark.tintColor = checkmarkTintColor + updateSelectedStyle() + + filterLabel.isGhostableDisabled = true + checkmark.isGhostableDisabled = true + pillBackgroundView.layer.masksToBounds = true + } + + override func prepareForReuse() { + super.prepareForReuse() + filter = nil + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + updateSelectedStyle() + } + } + + private func updateSelectedStyle() { + let oppositeInterfaceStyle: UIUserInterfaceStyle = (traitCollection.userInterfaceStyle == .dark) ? .light : .dark + let selectedColor: UIColor = UIColor.systemGray6.color(for: UITraitCollection(userInterfaceStyle: oppositeInterfaceStyle)) + pillBackgroundView.backgroundColor = isSelected ? selectedColor : .quaternarySystemFill + + if traitCollection.userInterfaceStyle == .dark { + filterLabel.textColor = isSelected ? .darkText : .white + } else { + filterLabel.textColor = isSelected ? .white : .darkText + } + } +} + +extension CollabsableHeaderFilterCollectionViewCell: GhostableView { + func ghostAnimationWillStart() { + filterLabel.text = "" + pillBackgroundView.startGhostAnimation(style: GhostCellStyle.muriel) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collabsable Header Filter Bar/CollabsableHeaderFilterCollectionViewCell.xib b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collabsable Header Filter Bar/CollabsableHeaderFilterCollectionViewCell.xib new file mode 100644 index 000000000000..5acb14c2723c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collabsable Header Filter Bar/CollabsableHeaderFilterCollectionViewCell.xib @@ -0,0 +1,75 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17156" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17125"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <collectionViewCell clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="gTV-IL-0wX" customClass="CollabsableHeaderFilterCollectionViewCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="105" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center"> + <rect key="frame" x="0.0" y="0.0" width="105" height="44"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Adq-89-2rL"> + <rect key="frame" x="0.0" y="0.0" width="105" height="44"/> + <color key="backgroundColor" systemColor="quaternarySystemFillColor"/> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius"> + <integer key="value" value="22"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + </view> + <stackView opaque="NO" contentMode="scaleToFill" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Vvr-2Z-jvR"> + <rect key="frame" x="34" y="0.0" width="37.5" height="44"/> + <subviews> + <imageView hidden="YES" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="FdX-4a-9fh"> + <rect key="frame" x="0.0" y="0.0" width="20" height="44"/> + <color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" constant="20" id="JcQ-0B-dVL"/> + </constraints> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ZXR-Oy-qLj"> + <rect key="frame" x="0.0" y="0.0" width="37.5" height="44"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + </subviews> + </view> + <viewLayoutGuide key="safeArea" id="ZTg-uK-7eu"/> + <constraints> + <constraint firstAttribute="bottom" secondItem="Adq-89-2rL" secondAttribute="bottom" id="8xh-74-ZmN"/> + <constraint firstItem="Vvr-2Z-jvR" firstAttribute="top" relation="greaterThanOrEqual" secondItem="gTV-IL-0wX" secondAttribute="top" id="9Fx-oH-x67"/> + <constraint firstItem="Adq-89-2rL" firstAttribute="top" secondItem="gTV-IL-0wX" secondAttribute="top" id="IoE-dh-5mw"/> + <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="Vvr-2Z-jvR" secondAttribute="bottom" id="WM8-xG-j4o"/> + <constraint firstAttribute="trailing" secondItem="Adq-89-2rL" secondAttribute="trailing" id="lxm-yX-9fj"/> + <constraint firstItem="Vvr-2Z-jvR" firstAttribute="centerY" secondItem="Adq-89-2rL" secondAttribute="centerY" id="um6-vS-jCs"/> + <constraint firstItem="Vvr-2Z-jvR" firstAttribute="centerX" secondItem="Adq-89-2rL" secondAttribute="centerX" id="vB0-lT-YyB"/> + <constraint firstItem="Adq-89-2rL" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" id="wlB-di-NLv"/> + </constraints> + <size key="customSize" width="366" height="140"/> + <connections> + <outlet property="checkmark" destination="FdX-4a-9fh" id="X5c-Wb-ZYz"/> + <outlet property="filterLabel" destination="ZXR-Oy-qLj" id="Pgk-G6-YP5"/> + <outlet property="pillBackgroundView" destination="Adq-89-2rL" id="3gq-mY-1FI"/> + </connections> + <point key="canvasLocation" x="365.94202898550725" y="182.8125"/> + </collectionViewCell> + </objects> + <resources> + <systemColor name="quaternarySystemFillColor"> + <color red="0.45490196078431372" green="0.45490196078431372" blue="0.50196078431372548" alpha="0.080000000000000002" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collabsable Header Filter Bar/CollapsableHeaderFilterBar.swift b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collabsable Header Filter Bar/CollapsableHeaderFilterBar.swift new file mode 100644 index 000000000000..8fc5d330941b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collabsable Header Filter Bar/CollapsableHeaderFilterBar.swift @@ -0,0 +1,103 @@ +import UIKit + +protocol CollapsableHeaderFilterBarDelegate: AnyObject { + func numberOfFilters() -> Int + func filter(forIndex: Int) -> CategorySection + func didSelectFilter(withIndex selectedIndex: IndexPath, withSelectedIndexes selectedIndexes: [IndexPath]) + func didDeselectFilter(withIndex index: IndexPath, withSelectedIndexes selectedIndexes: [IndexPath]) +} + +class CollapsableHeaderFilterBar: UICollectionView { + weak var filterDelegate: CollapsableHeaderFilterBarDelegate? + private let defaultCellHeight: CGFloat = 44 + private let defaultCellWidth: CGFloat = 105 + + var shouldShowGhostContent: Bool = false { + didSet { + reloadData() + } + } + + init() { + let collectionViewLayout = UICollectionViewFlowLayout() + collectionViewLayout.sectionInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) + collectionViewLayout.minimumInteritemSpacing = 12 + collectionViewLayout.minimumLineSpacing = 10 + collectionViewLayout.scrollDirection = .horizontal + super.init(frame: .zero, collectionViewLayout: collectionViewLayout) + showsHorizontalScrollIndicator = false + showsVerticalScrollIndicator = false + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + register(CollabsableHeaderFilterCollectionViewCell.nib, forCellWithReuseIdentifier: CollabsableHeaderFilterCollectionViewCell.cellReuseIdentifier) + self.delegate = self + self.dataSource = self + self.backgroundColor = .clear + self.isOpaque = false + } + + private func deselectItem(_ indexPath: IndexPath) { + deselectItem(at: indexPath, animated: true) + collectionView(self, didDeselectItemAt: indexPath) + } +} + +extension CollapsableHeaderFilterBar: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + if collectionView.cellForItem(at: indexPath)?.isSelected ?? false { + deselectItem(indexPath) + return false + } + return true + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let indexPathsForSelectedItems = collectionView.indexPathsForSelectedItems else { return } + filterDelegate?.didSelectFilter(withIndex: indexPath, withSelectedIndexes: indexPathsForSelectedItems) + } + + func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { + filterDelegate?.didDeselectFilter(withIndex: indexPath, withSelectedIndexes: collectionView.indexPathsForSelectedItems ?? []) + } +} + +extension CollapsableHeaderFilterBar: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + guard !shouldShowGhostContent, let filter = filterDelegate?.filter(forIndex: indexPath.item) else { + return CGSize(width: defaultCellWidth, height: defaultCellHeight) + } + + let width = CollabsableHeaderFilterCollectionViewCell.estimatedWidth(forFilter: filter) + return CGSize(width: width, height: defaultCellHeight) + } +} + +extension CollapsableHeaderFilterBar: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return shouldShowGhostContent ? 1 : (filterDelegate?.numberOfFilters() ?? 0) + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cellReuseIdentifier = CollabsableHeaderFilterCollectionViewCell.cellReuseIdentifier + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellReuseIdentifier, for: indexPath) as? CollabsableHeaderFilterCollectionViewCell else { + fatalError("Expected the cell with identifier \"\(cellReuseIdentifier)\" to be a \(CollabsableHeaderFilterCollectionViewCell.self). Please make sure the collection view is registering the correct nib before loading the data") + } + + if shouldShowGhostContent { + cell.ghostAnimationWillStart() + cell.startGhostAnimation(style: GhostCellStyle.muriel) + } else { + cell.stopGhostAnimation() + cell.filter = filterDelegate?.filter(forIndex: indexPath.item) + } + + return cell + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collapsable Header Collection View Cell/CollapsableHeaderCollectionViewCell.swift b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collapsable Header Collection View Cell/CollapsableHeaderCollectionViewCell.swift new file mode 100644 index 000000000000..3316164a7442 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collapsable Header Collection View Cell/CollapsableHeaderCollectionViewCell.swift @@ -0,0 +1,166 @@ +import UIKit +import Gutenberg +import Gridicons + +class CollapsableHeaderCollectionViewCell: UICollectionViewCell { + + static let cellReuseIdentifier = "\(CollapsableHeaderCollectionViewCell.self)" + static let nib = UINib(nibName: "\(CollapsableHeaderCollectionViewCell.self)", bundle: Bundle.main) + static let selectionAnimationSpeed: Double = 0.25 + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var checkmarkContainerView: UIView! + @IBOutlet weak var checkmarkImageView: UIImageView! { + didSet { + checkmarkImageView.image = UIImage(systemName: "checkmark.circle.fill") + checkmarkImageView.tintColor = accentColor + } + } + + /// The throttle the requests to the imageURL polling if needed. + private let throttle = Scheduler(seconds: 1) + + var previewURL: String? = nil { + didSet { + setImage(previewURL) + } + } + + var showsCheckMarkWhenSelected = true + + override func prepareForReuse() { + super.prepareForReuse() + imageView.cancelImageDownload() + previewURL = nil + stopGhostAnimation() + } + + var accentColor: UIColor { + return UIColor { (traitCollection: UITraitCollection) -> UIColor in + if traitCollection.userInterfaceStyle == .dark { + return UIColor.muriel(color: .primary, .shade40) + } else { + return UIColor.muriel(color: .primary, .shade50) + } + } + } + + var borderColor: UIColor { + return UIColor.black.withAlphaComponent(0.08) + } + + var borderWith: CGFloat = 0.5 + + override var isSelected: Bool { + didSet { + checkmarkHidden(!isSelected, animated: true) + styleSelectedBorder(animated: true) + } + } + + override func awakeFromNib() { + super.awakeFromNib() + styleSelectedBorder() + styleShadow() + checkmarkImageView.isGhostableDisabled = true + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + styleSelectedBorder() + styleShadow() + } + } + + func styleShadow() { + if traitCollection.userInterfaceStyle == .dark { + removeShadow() + } else { + addShadow() + } + } + + func addShadow() { + layer.shadowColor = UIColor.black.cgColor + layer.shadowRadius = 5.0 + layer.shadowOpacity = 0.16 + layer.shadowOffset = CGSize(width: 0, height: 2.0) + + backgroundColor = nil + } + + func removeShadow() { + layer.shadowColor = nil + } + + private func styleSelectedBorder(animated: Bool = false) { + let imageBorderColor = isSelected ? accentColor.cgColor : borderColor.cgColor + let imageBorderWidth = isSelected ? 2 : borderWith + guard animated else { + imageView.layer.borderColor = imageBorderColor + imageView.layer.borderWidth = imageBorderWidth + return + } + + let borderWidthAnimation: CABasicAnimation = CABasicAnimation(keyPath: "borderWidth") + borderWidthAnimation.fromValue = imageView.layer.borderWidth + borderWidthAnimation.toValue = imageBorderWidth + borderWidthAnimation.duration = CollapsableHeaderCollectionViewCell.selectionAnimationSpeed + + let borderColorAnimation: CABasicAnimation = CABasicAnimation(keyPath: "borderColor") + borderColorAnimation.fromValue = imageView.layer.borderColor + borderColorAnimation.toValue = imageBorderColor + borderColorAnimation.duration = CollapsableHeaderCollectionViewCell.selectionAnimationSpeed + + imageView.layer.add(borderColorAnimation, forKey: "borderColor") + imageView.layer.add(borderWidthAnimation, forKey: "borderWidth") + imageView.layer.borderColor = imageBorderColor + imageView.layer.borderWidth = imageBorderWidth + } + + private func checkmarkHidden(_ isHidden: Bool, animated: Bool = false) { + guard showsCheckMarkWhenSelected else { + checkmarkContainerView.isHidden = true + return + } + + guard animated else { + checkmarkContainerView.isHidden = isHidden + return + } + + checkmarkContainerView.isHidden = false + + // Set the inverse of the animation destination + checkmarkContainerView.alpha = isHidden ? 1 : 0 + let targetAlpha: CGFloat = isHidden ? 0 : 1 + + UIView.animate(withDuration: CollapsableHeaderCollectionViewCell.selectionAnimationSpeed, animations: { + self.checkmarkContainerView.alpha = targetAlpha + }, completion: { (_) in + self.checkmarkContainerView.isHidden = !self.isSelected + }) + } + + func setImage(_ imageURL: String?) { + guard let imageURL = imageURL, let url = URL(string: imageURL) else { return } + imageView.startGhostAnimation(style: GhostCellStyle.muriel) + imageView.downloadImage(from: url, success: { [weak self] _ in + self?.imageView.stopGhostAnimation() + }, failure: { [weak self] error in + self?.handleError(error, forURL: imageURL) + }) + } + + /// This will retry the polling of the image URL in the situation where a mismatch was recieved for the requested image. This can happen for endpoints that + /// dynamically generate the images. This will stop retrying if the view scrolled off screen. Or if the view was updated with a new URL to fetch. It will also stop + /// retrying for any other error type. + func handleError(_ error: Error?, forURL url: String?) { + guard let error = error as? UIImageView.ImageDownloadError, error == .urlMismatch else { return } + throttle.throttle { [weak self] in + guard let self = self else { return } + guard url != nil, url == self.previewURL else { return } + self.setImage(url) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collapsable Header Collection View Cell/CollapsableHeaderCollectionViewCell.xib b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collapsable Header Collection View Cell/CollapsableHeaderCollectionViewCell.xib new file mode 100644 index 000000000000..d3ed350d8f8f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/Collapsable Header Collection View Cell/CollapsableHeaderCollectionViewCell.xib @@ -0,0 +1,83 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <collectionViewCell opaque="NO" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="CollapsableHeaderCollectionViewCell" id="gTV-IL-0wX" customClass="CollapsableHeaderCollectionViewCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="160" height="230"/> + <autoresizingMask key="autoresizingMask"/> + <view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center"> + <rect key="frame" x="0.0" y="0.0" width="160" height="230"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="XpQ-DC-DLe"> + <rect key="frame" x="0.0" y="0.0" width="160" height="230"/> + <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius"> + <integer key="value" value="8"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + </imageView> + <view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="laj-HC-L8x" userLabel="Checkmark Container View"> + <rect key="frame" x="116" y="12" width="32" height="32"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="apQ-T8-du2" userLabel="Checkmark Background View"> + <rect key="frame" x="3" y="3" width="26" height="26"/> + <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius"> + <integer key="value" value="13"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + </view> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="hx0-n7-XEA"> + <rect key="frame" x="0.0" y="0.0" width="32" height="32"/> + </imageView> + </subviews> + <constraints> + <constraint firstItem="apQ-T8-du2" firstAttribute="bottom" secondItem="laj-HC-L8x" secondAttribute="bottom" constant="-3" id="4Oq-Oa-L56"/> + <constraint firstItem="hx0-n7-XEA" firstAttribute="bottom" secondItem="laj-HC-L8x" secondAttribute="bottom" id="JwU-VE-hjG"/> + <constraint firstItem="hx0-n7-XEA" firstAttribute="top" secondItem="laj-HC-L8x" secondAttribute="top" id="PJZ-WW-yRk"/> + <constraint firstItem="apQ-T8-du2" firstAttribute="top" secondItem="laj-HC-L8x" secondAttribute="top" constant="3" id="SmU-ok-LgJ"/> + <constraint firstItem="hx0-n7-XEA" firstAttribute="trailing" secondItem="laj-HC-L8x" secondAttribute="trailing" id="UeB-xi-zOK"/> + <constraint firstAttribute="width" constant="32" id="ZRH-5B-0Ih"/> + <constraint firstItem="apQ-T8-du2" firstAttribute="leading" secondItem="laj-HC-L8x" secondAttribute="leading" constant="3" id="ait-Ub-BdE"/> + <constraint firstAttribute="width" secondItem="laj-HC-L8x" secondAttribute="height" multiplier="1:1" id="ijt-QC-a3J"/> + <constraint firstItem="hx0-n7-XEA" firstAttribute="leading" secondItem="laj-HC-L8x" secondAttribute="leading" id="sJy-Qb-k7g"/> + <constraint firstItem="apQ-T8-du2" firstAttribute="trailing" secondItem="laj-HC-L8x" secondAttribute="trailing" constant="-3" id="vg8-26-oiL"/> + </constraints> + </view> + </subviews> + </view> + <viewLayoutGuide key="safeArea" id="ZTg-uK-7eu"/> + <constraints> + <constraint firstItem="laj-HC-L8x" firstAttribute="top" secondItem="XpQ-DC-DLe" secondAttribute="top" constant="12" id="2MK-HP-5J7"/> + <constraint firstItem="XpQ-DC-DLe" firstAttribute="top" secondItem="gTV-IL-0wX" secondAttribute="top" id="8aK-Nu-g20"/> + <constraint firstAttribute="trailing" secondItem="XpQ-DC-DLe" secondAttribute="trailing" id="BrN-ay-15v"/> + <constraint firstItem="laj-HC-L8x" firstAttribute="trailing" secondItem="XpQ-DC-DLe" secondAttribute="trailing" constant="-12" id="FUz-7V-mZ2"/> + <constraint firstItem="XpQ-DC-DLe" firstAttribute="leading" secondItem="gTV-IL-0wX" secondAttribute="leading" id="Hly-4U-lPA"/> + <constraint firstAttribute="bottom" secondItem="XpQ-DC-DLe" secondAttribute="bottom" id="juf-rq-F1x"/> + </constraints> + <size key="customSize" width="191" height="244"/> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius"> + <integer key="value" value="8"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + <connections> + <outlet property="checkmarkContainerView" destination="laj-HC-L8x" id="i9b-po-WdU"/> + <outlet property="checkmarkImageView" destination="hx0-n7-XEA" id="Yst-6N-Qcg"/> + <outlet property="imageView" destination="XpQ-DC-DLe" id="j69-db-QW7"/> + </connections> + <point key="canvasLocation" x="239.13043478260872" y="211.60714285714286"/> + </collectionViewCell> + </objects> +</document> diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/CollapsableHeaderView.swift b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/CollapsableHeaderView.swift new file mode 100644 index 000000000000..f8602650a74a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/CollapsableHeaderView.swift @@ -0,0 +1,14 @@ +import UIKit + +class CollapsableHeaderView: UIView { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard let result = super.hitTest(point, with: event), + result.isUserInteractionEnabled, + result != self // Ignore touches for self but accept them for the accessory view + else { + return nil + } + + return result + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/CollapsableHeaderViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/CollapsableHeaderViewController.swift new file mode 100644 index 000000000000..d63f085bf29a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/CollapsableHeaderViewController.swift @@ -0,0 +1,791 @@ +import UIKit +import WordPressUI + +class CollapsableHeaderViewController: UIViewController, NoResultsViewHost { + enum SeparatorStyle { + case visible + case automatic + case hidden + } + + let scrollableView: UIScrollView + let accessoryView: UIView? + let mainTitle: String + let navigationBarTitle: String? + let prompt: String? + let primaryActionTitle: String + let secondaryActionTitle: String? + let defaultActionTitle: String? + open var accessoryBarHeight: CGFloat { + return 44 + } + + open var separatorStyle: SeparatorStyle { + return self.hasAccessoryBar ? .visible : .automatic + } + + // If set to true, the header will always be pushed down after rotating from compact to regular + // If set to false, this will only happen for no results views (default behavior). + var alwaysResetHeaderOnRotation: Bool { + false + } + + // If set to true, all header titles will always be shown. + // If set to false, largeTitleView and promptView labels are hidden in compact height (default behavior). + // + var alwaysShowHeaderTitles: Bool { + false + } + + // Set this property to true to add a custom footerView with custom sizing when scrollableView is UITableView. + var allowCustomTableFooterView: Bool { + false + } + + private let hasDefaultAction: Bool + private var notificationObservers: [NSObjectProtocol] = [] + @IBOutlet weak var containerView: UIView! + @IBOutlet weak var headerView: CollapsableHeaderView! + + let titleView: UILabel = { + let title = UILabel(frame: .zero) + title.adjustsFontForContentSizeCategory = true + title.font = WPStyleGuide.serifFontForTextStyle(UIFont.TextStyle.largeTitle, fontWeight: .semibold).withSize(17) + title.isHidden = true + title.adjustsFontSizeToFitWidth = true + title.minimumScaleFactor = 2/3 + return title + }() + + @IBOutlet weak var largeTitleTopSpacingConstraint: NSLayoutConstraint! + + @IBOutlet weak var headerStackView: UIStackView! + @IBOutlet weak var headerImageView: UIImageView! + @IBOutlet weak var largeTitleView: UILabel! + private var headerImage: UIImage? + + @IBOutlet weak var promptView: UILabel! + @IBOutlet weak var accessoryBar: UIView! + @IBOutlet weak var accessoryBarHeightConstraint: NSLayoutConstraint! + @IBOutlet weak var accessoryBarTopCompactConstraint: NSLayoutConstraint! + @IBOutlet weak var footerView: UIView! + @IBOutlet weak var footerHeightContraint: NSLayoutConstraint! + @IBOutlet weak var defaultActionButton: UIButton! + @IBOutlet weak var secondaryActionButton: UIButton! + @IBOutlet weak var primaryActionButton: UIButton! + @IBOutlet weak var selectedStateButtonsContainer: UIStackView! + @IBOutlet weak var seperator: UIView! + + /// Flag indicating if the action button stack view (selectedStateButtonsContainer) is vertical. + /// Used when calculating the footer height. + private var usesVerticalActionButtons: Bool = false + + /// This is used as a means to adapt to different text sizes to force the desired layout and then active `headerHeightConstraint` + /// when scrolling begins to allow pushing the non static items out of the scrollable area. + @IBOutlet weak var initialHeaderTopConstraint: NSLayoutConstraint! + @IBOutlet weak var headerHeightConstraint: NSLayoutConstraint! + @IBOutlet weak var titleToSubtitleSpacing: NSLayoutConstraint! + @IBOutlet weak var subtitleToCategoryBarSpacing: NSLayoutConstraint! + + /// As the Header expands it allows a little bit of extra room between the bottom of the filter bar and the bottom of the header view. + /// These next two constaints help account for that slight adustment. + @IBOutlet weak var minHeaderBottomSpacing: NSLayoutConstraint! + @IBOutlet weak var maxHeaderBottomSpacing: NSLayoutConstraint! + @IBOutlet weak var scrollableContainerBottomConstraint: NSLayoutConstraint! + + @IBOutlet var visualEffects: [UIVisualEffectView]! { + didSet { + visualEffects.forEach { (visualEffect) in + visualEffect.effect = UIBlurEffect.init(style: .systemChromeMaterial) + // Allow touches to pass through to the scroll view behind the header. + visualEffect.contentView.isUserInteractionEnabled = false + } + } + } + + private var footerHeight: CGFloat { + let verticalMargins: CGFloat = 16 + let buttonHeight: CGFloat = 44 + let safeArea = (UIApplication.shared.mainWindow?.safeAreaInsets.bottom ?? 0) + + var height = verticalMargins + buttonHeight + verticalMargins + safeArea + + if usesVerticalActionButtons && !secondaryActionButton.isHidden { + height += (buttonHeight + selectedStateButtonsContainer.spacing) + } + + return height + } + + private var isShowingNoResults: Bool = false { + didSet { + if oldValue != isShowingNoResults { + updateHeaderDisplay() + } + } + } + + private let hasAccessoryBar: Bool + private var shouldHideAccessoryBar: Bool { + return isShowingNoResults || !hasAccessoryBar + } + + private var shouldUseCompactLayout: Bool { + return !alwaysShowHeaderTitles && traitCollection.verticalSizeClass == .compact + } + + private var topInset: CGFloat = 0 + private var _maxHeaderHeight: CGFloat = 0 + private var maxHeaderHeight: CGFloat { + if shouldUseCompactLayout { + return minHeaderHeight + } else { + return _maxHeaderHeight + } + } + + private var _midHeaderHeight: CGFloat = 0 + private var midHeaderHeight: CGFloat { + if shouldUseCompactLayout { + return minHeaderHeight + } else { + return _midHeaderHeight + } + } + private var minHeaderHeight: CGFloat = 0 + + private var accentColor: UIColor { + return UIColor { (traitCollection: UITraitCollection) -> UIColor in + if traitCollection.userInterfaceStyle == .dark { + return UIColor.muriel(color: .primary, .shade40) + } else { + return UIColor.muriel(color: .primary, .shade50) + } + } + } + + // MARK: - Static Helpers + public static func closeButton(target: Any?, action: Selector) -> UIBarButtonItem { + let closeButton = UIButton(frame: CGRect(x: 0, y: 0, width: 30, height: 30)) + closeButton.layer.cornerRadius = 15 + closeButton.accessibilityLabel = NSLocalizedString("Close", comment: "Dismisses the current screen") + closeButton.accessibilityIdentifier = "close-button" + closeButton.setImage(UIImage.gridicon(.crossSmall), for: .normal) + closeButton.addTarget(target, action: action, for: .touchUpInside) + + closeButton.tintColor = .secondaryLabel + closeButton.backgroundColor = UIColor { (traitCollection: UITraitCollection) -> UIColor in + if traitCollection.userInterfaceStyle == .dark { + return UIColor.systemFill + } else { + return UIColor.quaternarySystemFill + } + } + + return UIBarButtonItem(customView: closeButton) + } + + // MARK: - Initializers + /// Configure and display the no results view controller + /// + /// - Parameters: + /// - scrollableView: Populates the scrollable area of this container. Required. + /// - mainTitle: The Large title and small title in the header. Required. + /// - navigationBarTitle: The Large title in the header. Optional. + /// - headerImage: An image displayed in the header. Optional. + /// - prompt: The subtitle/prompt in the header. Required. + /// - primaryActionTitle: The button title for the right most button when an item is selected. Required. + /// - secondaryActionTitle: The button title for the left most button when an item is selected. Optional - nil results in the left most button being hidden when an item is selected. + /// - defaultActionTitle: The button title for the button that is displayed when no item is selected. Optional - nil results in the footer being hidden when no item is selected. + /// - accessoryView: The view to be placed in the placeholder of the accessory bar. Optional - The default is nil. + /// + init(scrollableView: UIScrollView, + mainTitle: String, + navigationBarTitle: String? = nil, + headerImage: UIImage? = nil, + prompt: String? = nil, + primaryActionTitle: String, + secondaryActionTitle: String? = nil, + defaultActionTitle: String? = nil, + accessoryView: UIView? = nil) { + self.scrollableView = scrollableView + self.mainTitle = mainTitle + self.navigationBarTitle = navigationBarTitle + self.headerImage = headerImage + self.prompt = prompt + self.primaryActionTitle = primaryActionTitle + self.secondaryActionTitle = secondaryActionTitle + self.defaultActionTitle = defaultActionTitle + self.hasAccessoryBar = (accessoryView != nil) + self.hasDefaultAction = (defaultActionTitle != nil) + self.accessoryView = accessoryView + super.init(nibName: "\(CollapsableHeaderViewController.self)", bundle: .main) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + insertChildView() + insertAccessoryView() + configureSubtitleToCategoryBarSpacing() + configureHeaderImageView() + navigationItem.titleView = titleView + largeTitleView.font = WPStyleGuide.serifFontForTextStyle(UIFont.TextStyle.largeTitle, fontWeight: .semibold) + toggleFilterBarConstraints() + styleButtons() + setStaticText() + scrollableView.delegate = self + + formatNavigationController() + extendedLayoutIncludesOpaqueBars = true + edgesForExtendedLayout = .top + updateSeperatorStyle() + } + + /// The estimated content size of the scroll view. This is used to adjust the content insests to allow the header to be scrollable to be collapsable still when + /// it's not populated with enough data. This is desirable to help maintain the header's state when the filtered options change and reduce the content size. + open func estimatedContentSize() -> CGSize { + return scrollableView.contentSize + } + + override func viewWillAppear(_ animated: Bool) { + if !isViewOnScreen() { + layoutHeader() + } + + configureHeaderTitleVisibility() + startObservingKeyboardChanges() + super.viewWillAppear(animated) + } + + override func viewWillDisappear(_ animated: Bool) { + stopObservingKeyboardChanges() + super.viewWillDisappear(animated) + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .default + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + guard isShowingNoResults || alwaysResetHeaderOnRotation else { + return + } + + coordinator.animate(alongsideTransition: nil) { (_) in + self.accessoryBarTopCompactConstraint.isActive = self.shouldUseCompactLayout + self.updateHeaderDisplay() + // we're keeping this only for no results, + // as originally intended before introducing the flag alwaysResetHeaderOnRotation + if self.shouldHideAccessoryBar, self.isShowingNoResults { + self.disableInitialLayoutHelpers() + self.snapToHeight(self.scrollableView, height: self.minHeaderHeight, animated: false) + } + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + styleButtons() + } + + if let previousTraitCollection = previousTraitCollection, traitCollection.verticalSizeClass != previousTraitCollection.verticalSizeClass { + isUserInitiatedScroll = false + configureHeaderTitleVisibility() + layoutHeaderInsets() + + // This helps reset the header changes after a rotation. + scrollViewDidScroll(scrollableView) + scrollViewDidEndDecelerating(scrollableView) + } else { + layoutHeader() + snapToHeight(scrollableView) + } + } + + // MARK: - Footer Actions + @IBAction open func defaultActionSelected(_ sender: Any) { + /* This should be overriden in a child class in order to enable support. */ + } + + @IBAction open func primaryActionSelected(_ sender: Any) { + /* This should be overriden in a child class in order to enable support. */ + } + + @IBAction open func secondaryActionSelected(_ sender: Any) { + /* This should be overriden in a child class in order to enable support. */ + } + + // MARK: - Format Nav Bar + /* + * To allow more flexibility in the navigation bar's header items, we keep the navigation bar available. + * However, that space is also essential to a uniform design of the header. This function updates the design of the + * navigation bar. We set the design to the `navigationItem`, which is ViewController specific. + */ + private func formatNavigationController() { + let newAppearance = UINavigationBarAppearance() + newAppearance.configureWithTransparentBackground() + newAppearance.backgroundColor = .clear + newAppearance.shadowColor = .clear + newAppearance.shadowImage = UIImage() + navigationItem.standardAppearance = newAppearance + navigationItem.scrollEdgeAppearance = newAppearance + navigationItem.compactAppearance = newAppearance + setNeedsStatusBarAppearanceUpdate() + } + + // MARK: - View Styling + private func setStaticText() { + titleView.text = navigationBarTitle ?? mainTitle + titleView.sizeToFit() + largeTitleView.text = mainTitle + promptView.isHidden = prompt == nil + promptView.text = prompt + primaryActionButton.setTitle(primaryActionTitle, for: .normal) + + if let defaultActionTitle = defaultActionTitle { + defaultActionButton.setTitle(defaultActionTitle, for: .normal) + } else { + footerHeightContraint.constant = 0 + footerView.layoutIfNeeded() + defaultActionButton.isHidden = true + selectedStateButtonsContainer.isHidden = false + } + + if let secondaryActionTitle = secondaryActionTitle { + secondaryActionButton.setTitle(secondaryActionTitle, for: .normal) + } else { + secondaryActionButton.isHidden = true + } + } + + private func insertChildView() { + scrollableView.translatesAutoresizingMaskIntoConstraints = false + scrollableView.clipsToBounds = false + let top = NSLayoutConstraint(item: scrollableView, attribute: .top, relatedBy: .equal, toItem: containerView, attribute: .top, multiplier: 1, constant: 0) + let bottom = NSLayoutConstraint(item: scrollableView, attribute: .bottom, relatedBy: .equal, toItem: containerView, attribute: .bottom, multiplier: 1, constant: 0) + let leading = NSLayoutConstraint(item: scrollableView, attribute: .leading, relatedBy: .equal, toItem: containerView, attribute: .leading, multiplier: 1, constant: 0) + let trailing = NSLayoutConstraint(item: scrollableView, attribute: .trailing, relatedBy: .equal, toItem: containerView, attribute: .trailing, multiplier: 1, constant: 0) + containerView.addSubview(scrollableView) + containerView.addConstraints([top, bottom, leading, trailing]) + } + + private func insertAccessoryView() { + guard let accessoryView = accessoryView else { + return + } + + accessoryView.translatesAutoresizingMaskIntoConstraints = false + let top = NSLayoutConstraint(item: accessoryView, attribute: .top, relatedBy: .equal, toItem: accessoryBar, attribute: .top, multiplier: 1, constant: 0) + let bottom = NSLayoutConstraint(item: accessoryView, attribute: .bottom, relatedBy: .equal, toItem: accessoryBar, attribute: .bottom, multiplier: 1, constant: 0) + let leading = NSLayoutConstraint(item: accessoryView, attribute: .leading, relatedBy: .equal, toItem: accessoryBar, attribute: .leading, multiplier: 1, constant: 0) + let trailing = NSLayoutConstraint(item: accessoryView, attribute: .trailing, relatedBy: .equal, toItem: accessoryBar, attribute: .trailing, multiplier: 1, constant: 0) + accessoryBar.addSubview(accessoryView) + accessoryBar.addConstraints([top, bottom, leading, trailing]) + } + + private func configureHeaderImageView() { + headerImageView.isHidden = (headerImage == nil) + headerImageView.image = headerImage + } + + private func configureSubtitleToCategoryBarSpacing() { + if prompt?.isEmpty ?? true { + subtitleToCategoryBarSpacing.constant = 0 + } + } + + func configureHeaderTitleVisibility() { + largeTitleView.isHidden = shouldUseCompactLayout + promptView.isHidden = shouldUseCompactLayout + } + + private func styleButtons() { + let seperator = UIColor.separator + + [defaultActionButton, secondaryActionButton].forEach { (button) in + button?.titleLabel?.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .medium) + button?.titleLabel?.adjustsFontSizeToFitWidth = true + button?.titleLabel?.adjustsFontForContentSizeCategory = true + button?.layer.borderColor = seperator.cgColor + button?.layer.borderWidth = 1 + button?.layer.cornerRadius = 8 + } + + primaryActionButton.titleLabel?.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .medium) + primaryActionButton.titleLabel?.adjustsFontSizeToFitWidth = true + primaryActionButton.titleLabel?.adjustsFontForContentSizeCategory = true + primaryActionButton.backgroundColor = accentColor + primaryActionButton.layer.cornerRadius = 8 + } + + // MARK: - Header and Footer Sizing + private func toggleFilterBarConstraints() { + accessoryBarHeightConstraint.constant = shouldHideAccessoryBar ? 0 : accessoryBarHeight + let collapseBottomSpacing = shouldHideAccessoryBar || (separatorStyle == .hidden) + maxHeaderBottomSpacing.constant = collapseBottomSpacing ? 1 : 24 + minHeaderBottomSpacing.constant = collapseBottomSpacing ? 1 : 9 + } + + private func updateHeaderDisplay() { + headerHeightConstraint.isActive = false + initialHeaderTopConstraint.isActive = true + toggleFilterBarConstraints() + accessoryBar.layoutIfNeeded() + headerView.layoutIfNeeded() + calculateHeaderSnapPoints() + layoutHeaderInsets() + } + + private func calculateHeaderSnapPoints() { + let accessoryBarSpacing: CGFloat + if shouldHideAccessoryBar { + minHeaderHeight = 1 + accessoryBarSpacing = minHeaderHeight + } else { + minHeaderHeight = accessoryBarHeightConstraint.constant + minHeaderBottomSpacing.constant + accessoryBarSpacing = accessoryBarHeightConstraint.constant + maxHeaderBottomSpacing.constant + } + _midHeaderHeight = titleToSubtitleSpacing.constant + promptView.frame.height + subtitleToCategoryBarSpacing.constant + accessoryBarSpacing + _maxHeaderHeight = largeTitleTopSpacingConstraint.constant + headerStackView.frame.height + _midHeaderHeight + } + + private func layoutHeaderInsets() { + let topInset: CGFloat = maxHeaderHeight + if let tableView = scrollableView as? UITableView { + tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.frame.width, height: topInset)) + tableView.tableHeaderView?.backgroundColor = .clear + } else { + self.topInset = topInset + scrollableView.contentInset.top = topInset + } + + updateFooterInsets() + } + + /* + * Calculates the needed space for the footer to allow the header to still collapse but also to prevent unneeded space + * at the bottome of the tableView when multiple cells are rendered. + */ + private func updateFooterInsets() { + /// Update the footer height if it's being displayed. + if footerHeightContraint.constant > 0 { + footerHeightContraint.constant = footerHeight + } + + /// The needed distance to fill the rest of the screen to allow the header to still collapse when scrolling (or to maintain a collapsed header if it was already collapsed when selecting a filter) + let distanceToBottom = scrollableView.frame.height - minHeaderHeight - estimatedContentSize().height + let newHeight: CGFloat = max(footerHeight, distanceToBottom) + if let tableView = scrollableView as? UITableView { + + guard !allowCustomTableFooterView else { + return + } + + tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.frame.width, height: newHeight)) + tableView.tableFooterView?.isGhostableDisabled = true + tableView.tableFooterView?.backgroundColor = .clear + } else { + scrollableView.contentInset.bottom = newHeight + } + } + + private func layoutHeader() { + [headerView, footerView].forEach({ + $0?.setNeedsLayout() + $0?.layoutIfNeeded() + }) + + calculateHeaderSnapPoints() + layoutHeaderInsets() + updateTitleViewVisibility(false) + } + + // MARK: - Subclass callbacks + + /// A public interface to notify the container that the content has loaded data or is attempting too. + public func displayNoResultsController(title: String, subtitle: String?, resultsDelegate: NoResultsViewControllerDelegate?) { + guard !isShowingNoResults else { + return + } + + isShowingNoResults = true + disableInitialLayoutHelpers() + snapToHeight(scrollableView, height: minHeaderHeight) + configureAndDisplayNoResults(on: containerView, + title: title, + subtitle: subtitle, + noConnectionSubtitle: subtitle, + buttonTitle: NSLocalizedString("Retry", comment: "A prompt to attempt the failed network request again"), + customizationBlock: { (noResultsController) in + noResultsController.delegate = resultsDelegate + }) + } + + public func dismissNoResultsController() { + guard isShowingNoResults else { + return + } + + isShowingNoResults = false + snapToHeight(scrollableView, height: maxHeaderHeight) + hideNoResults() + } + + /// A public interface to notify the container that the action buttons need to be vertical instead of horizontal (the default). + /// In this scenario, it is assumed: + /// - The primary and secondary action buttons are always displayed. + /// - The defaultActionButton is never displayed. + /// Therefore: + /// - The footerView with the action buttons is shown. + /// - The selectedStateButtonsContainer axis is set to vertical. + /// - The primaryActionButton is moved to the top of the stack view. + func configureVerticalButtonView() { + usesVerticalActionButtons = true + + footerView.backgroundColor = .systemBackground + footerHeightContraint.constant = footerHeight + selectedStateButtonsContainer.axis = .vertical + + selectedStateButtonsContainer.removeArrangedSubview(primaryActionButton) + selectedStateButtonsContainer.insertArrangedSubview(primaryActionButton, at: 0) + } + + /// A public interface to hide the header blur. + func hideHeaderVisualEffects() { + visualEffects.forEach { (visualEffect) in + visualEffect.isHidden = true + } + navigationController?.navigationBar.backgroundColor = .systemBackground + } + + /// In scenarios where the content offset before content changes doesn't align with the available space after the content changes then the offset can be lost. In + /// order to preserve the header's collpased state we cache the offset and attempt to reapply it if needed. + private var stashedOffset: CGPoint? = nil + + /// Tracks if the current scroll behavior was intiated by a user drag event + private var isUserInitiatedScroll = false + + /// A public interface to notify the container that the content size of the scroll view is about to change. This is useful in adjusting the bottom insets to allow the + /// view to still be scrollable with the content size is less than the total space of the expanded screen. + public func contentSizeWillChange() { + stashedOffset = scrollableView.contentOffset + updateFooterInsets() + } + + /// A public interface to notify the container that the selected state for an items has changed. + public func itemSelectionChanged(_ hasSelectedItem: Bool) { + let animationSpeed = CollapsableHeaderCollectionViewCell.selectionAnimationSpeed + guard hasDefaultAction else { + UIView.animate(withDuration: animationSpeed, delay: 0, options: .curveEaseInOut, animations: { + self.footerHeightContraint.constant = hasSelectedItem ? self.footerHeight : 0 + self.footerView.setNeedsLayout() + // call layoutIfNeeded on the parent view to smoothly update constraints + // more info: https://stackoverflow.com/a/12664093 + self.view.layoutIfNeeded() + }) + return + } + + guard hasSelectedItem == selectedStateButtonsContainer.isHidden else { + return + } + + defaultActionButton.isHidden = false + selectedStateButtonsContainer.isHidden = false + + defaultActionButton.alpha = hasSelectedItem ? 1 : 0 + selectedStateButtonsContainer.alpha = hasSelectedItem ? 0 : 1 + + let alpha: CGFloat = hasSelectedItem ? 0 : 1 + let selectedStateContainerAlpha: CGFloat = hasSelectedItem ? 1 : 0 + + UIView.animate(withDuration: animationSpeed, delay: 0, options: .transitionCrossDissolve, animations: { + self.defaultActionButton.alpha = alpha + self.selectedStateButtonsContainer.alpha = selectedStateContainerAlpha + }) { (_) in + self.defaultActionButton.isHidden = hasSelectedItem + self.selectedStateButtonsContainer.isHidden = !hasSelectedItem + } + } + + // MARK: - Seperator styling + private func updateSeperatorStyle(animated: Bool = true) { + let shouldBeHidden: Bool + switch separatorStyle { + case .automatic: + shouldBeHidden = headerHeightConstraint.constant > minHeaderHeight && !shouldUseCompactLayout + case .visible: + shouldBeHidden = false + case .hidden: + shouldBeHidden = true + } + + seperator.animatableSetIsHidden(shouldBeHidden, animated: animated) + } +} + +// MARK: - UIScrollViewDelegate +extension CollapsableHeaderViewController: UIScrollViewDelegate { + + private func disableInitialLayoutHelpers() { + if !headerHeightConstraint.isActive { + initialHeaderTopConstraint.isActive = false + headerHeightConstraint.isActive = true + } + } + + /// Restores the stashed content offset if it appears as if it's been reset. + private func restoreContentOffsetIfNeeded(_ scrollView: UIScrollView) { + guard var stashedOffset = stashedOffset else { + return + } + + stashedOffset = resolveContentOffsetCollisions(scrollView, cachedOffset: stashedOffset) + scrollView.contentOffset = stashedOffset + } + + private func resolveContentOffsetCollisions(_ scrollView: UIScrollView, cachedOffset: CGPoint) -> CGPoint { + var adjustedOffset = cachedOffset + + /// If the content size has changed enough to where the cached offset would scroll beyond the allowable bounds then we reset to the minum scroll height to + /// maintain the header's size. + if scrollView.contentSize.height - cachedOffset.y < scrollView.frame.height { + adjustedOffset.y = maxHeaderHeight - headerHeightConstraint.constant + stashedOffset = adjustedOffset + } + + return adjustedOffset + } + + private func resizeHeaderIfNeeded(_ scrollView: UIScrollView) { + let scrollOffset = scrollView.contentOffset.y + topInset + let newHeaderViewHeight = maxHeaderHeight - scrollOffset + + if newHeaderViewHeight < minHeaderHeight { + headerHeightConstraint.constant = minHeaderHeight + } else { + headerHeightConstraint.constant = newHeaderViewHeight + } + } + + internal func updateTitleViewVisibility(_ animated: Bool = true) { + var shouldHide = shouldUseCompactLayout ? false : (headerHeightConstraint.constant > midHeaderHeight) + shouldHide = headerHeightConstraint.isActive ? shouldHide : true + titleView.animatableSetIsHidden(shouldHide, animated: animated) + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + /// Clear the stashed offset because the user has initiated a change + stashedOffset = nil + isUserInitiatedScroll = true + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard stashedOffset == nil || stashedOffset == CGPoint.zero else { + restoreContentOffsetIfNeeded(scrollView) + return + } + + guard !shouldUseCompactLayout, + !isShowingNoResults else { + updateTitleViewVisibility(true) + updateSeperatorStyle() + return + } + disableInitialLayoutHelpers() + resizeHeaderIfNeeded(scrollView) + updateTitleViewVisibility(isUserInitiatedScroll) + updateSeperatorStyle() + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + snapToHeight(scrollView) + isUserInitiatedScroll = false + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + snapToHeight(scrollView) + } + } + + private func snapToHeight(_ scrollView: UIScrollView) { + guard !shouldUseCompactLayout else { + return + } + + if headerStackView.frame.midY > 0 { + snapToHeight(scrollView, height: maxHeaderHeight) + } else if promptView.frame.midY > 0 { + snapToHeight(scrollView, height: midHeaderHeight) + } else if headerHeightConstraint.constant != minHeaderHeight { + snapToHeight(scrollView, height: minHeaderHeight) + } + } + + public func expandHeader() { + guard !shouldUseCompactLayout else { + return + } + snapToHeight(scrollableView, height: maxHeaderHeight) + } + + private func snapToHeight(_ scrollView: UIScrollView, height: CGFloat, animated: Bool = true) { + scrollView.contentOffset.y = maxHeaderHeight - height - topInset + headerHeightConstraint.constant = height + updateTitleViewVisibility(animated) + updateSeperatorStyle(animated: animated) + + guard animated else { + headerView.setNeedsLayout() + headerView.layoutIfNeeded() + return + } + UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0.5, options: .curveEaseInOut, animations: { + self.headerView.setNeedsLayout() + self.headerView.layoutIfNeeded() + }, completion: nil) + } +} + +// MARK: - Keyboard Adjustments +extension CollapsableHeaderViewController { + private func startObservingKeyboardChanges() { + let willShowObserver = NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { (notification) in + UIView.animate(withKeyboard: notification) { (_, endFrame) in + self.scrollableContainerBottomConstraint.constant = endFrame.height - self.footerHeight + } + } + + let willHideObserver = NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { (notification) in + UIView.animate(withKeyboard: notification) { (_, _) in + self.scrollableContainerBottomConstraint.constant = 0 + } + } + + let willChangeFrameObserver = NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillChangeFrameNotification, object: nil, queue: .main) { (notification) in + UIView.animate(withKeyboard: notification) { (_, endFrame) in + self.scrollableContainerBottomConstraint.constant = endFrame.height - self.footerHeight + } + } + + notificationObservers.append(willShowObserver) + notificationObservers.append(willHideObserver) + notificationObservers.append(willChangeFrameObserver) + } + + private func stopObservingKeyboardChanges() { + notificationObservers.forEach { (observer) in + NotificationCenter.default.removeObserver(observer) + } + notificationObservers = [] + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/CollapsableHeaderViewController.xib b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/CollapsableHeaderViewController.xib new file mode 100644 index 000000000000..c262fdb13e37 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Collapsable Header/CollapsableHeaderViewController.xib @@ -0,0 +1,276 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/> + <capability name="Named colors" minToolsVersion="9.0"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="CollapsableHeaderViewController" customModule="WordPress" customModuleProvider="target"> + <connections> + <outlet property="accessoryBar" destination="DE5-R9-8tB" id="IAj-gK-2g6"/> + <outlet property="accessoryBarHeightConstraint" destination="Eej-Tm-ddy" id="xGU-f0-4kH"/> + <outlet property="accessoryBarTopCompactConstraint" destination="uVe-Ze-eIF" id="VXw-pp-QBH"/> + <outlet property="containerView" destination="WmP-bw-ceT" id="lul-gs-fMI"/> + <outlet property="defaultActionButton" destination="dmc-kV-0d8" id="NfO-bU-Zl7"/> + <outlet property="footerHeightContraint" destination="lle-48-IUZ" id="CV2-G2-ryj"/> + <outlet property="footerView" destination="UfV-hu-KZx" id="LZu-HT-o4b"/> + <outlet property="headerHeightConstraint" destination="dks-Bz-gRQ" id="PfP-C9-RWM"/> + <outlet property="headerImageView" destination="0kG-vy-JxR" id="awe-P1-sgA"/> + <outlet property="headerStackView" destination="IMu-iA-0ck" id="usY-Ed-5Mq"/> + <outlet property="headerView" destination="LGN-fD-c9Q" id="sXv-v4-bqj"/> + <outlet property="initialHeaderTopConstraint" destination="Cym-Zp-9He" id="W6g-3u-lNt"/> + <outlet property="largeTitleTopSpacingConstraint" destination="Cym-Zp-9He" id="tdu-cR-TWB"/> + <outlet property="largeTitleView" destination="nio-Wp-Ebw" id="cep-fi-6vl"/> + <outlet property="maxHeaderBottomSpacing" destination="MbU-Zk-mLK" id="LVn-jl-g9N"/> + <outlet property="minHeaderBottomSpacing" destination="wOf-4J-lXq" id="8Pp-zi-ErG"/> + <outlet property="primaryActionButton" destination="cck-0Q-rBD" id="Y0w-MZ-C18"/> + <outlet property="promptView" destination="bxz-wi-I73" id="O9L-j8-Peu"/> + <outlet property="scrollableContainerBottomConstraint" destination="Nhd-PS-v0n" id="dd9-7m-rOe"/> + <outlet property="secondaryActionButton" destination="HeJ-NR-hHA" id="afV-HA-5GH"/> + <outlet property="selectedStateButtonsContainer" destination="3RE-1V-jrs" id="wSU-hi-pPd"/> + <outlet property="seperator" destination="9w9-KC-2W9" id="641-sa-WZa"/> + <outlet property="subtitleToCategoryBarSpacing" destination="LOU-MU-PJx" id="jgY-Bn-wH6"/> + <outlet property="titleToSubtitleSpacing" destination="ojs-jr-NYN" id="RmF-DO-dvj"/> + <outlet property="view" destination="U3M-sT-nKQ" id="MUQ-Hw-DWi"/> + <outletCollection property="visualEffects" destination="0Qq-k5-kWr" collectionClass="NSMutableArray" id="O3m-zF-fOy"/> + <outletCollection property="visualEffects" destination="qxH-Si-bGn" collectionClass="NSMutableArray" id="vFL-eT-78Y"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view clipsSubviews="YES" contentMode="scaleToFill" id="U3M-sT-nKQ"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="WmP-bw-ceT" userLabel="Child Container"> + <rect key="frame" x="0.0" y="88" width="414" height="808"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + </view> + <visualEffectView opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="0Qq-k5-kWr"> + <rect key="frame" x="0.0" y="0.0" width="414" height="319"/> + <view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" alpha="0.69999998807907104" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="JJ1-pb-5F1"> + <rect key="frame" x="0.0" y="0.0" width="414" height="319"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <accessibility key="accessibilityConfiguration"> + <accessibilityTraits key="traits" notEnabled="YES"/> + </accessibility> + </view> + <color key="tintColor" systemColor="systemBackgroundColor"/> + <blurEffect style="regular"/> + </visualEffectView> + <view opaque="NO" clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LGN-fD-c9Q" userLabel="Header View" customClass="CollapsableHeaderView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="88" width="414" height="231"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="equalSpacing" alignment="center" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="IMu-iA-0ck" userLabel="Header Stack View"> + <rect key="frame" x="16" y="10" width="382" height="85"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="0kG-vy-JxR" userLabel="Header Image View"> + <rect key="frame" x="171" y="0.0" width="40" height="40"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="40" id="GQb-WH-xyi"/> + <constraint firstAttribute="width" secondItem="0kG-vy-JxR" secondAttribute="height" multiplier="1:1" id="fk3-Hl-ZJN"/> + </constraints> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1000" verticalCompressionResistancePriority="1000" text="Choose a Layout" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" adjustsLetterSpacingToFitWidth="YES" adjustsFontForContentSizeCategory="YES" translatesAutoresizingMaskIntoConstraints="NO" id="nio-Wp-Ebw" userLabel="Large Title"> + <rect key="frame" x="79" y="48" width="224" height="37"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleTitle0"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </stackView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1000" verticalCompressionResistancePriority="1000" text="Get started by choosing from a wide variety of pre-made page layouts. Or just start with a blank page." textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" adjustsLetterSpacingToFitWidth="YES" adjustsFontForContentSizeCategory="YES" translatesAutoresizingMaskIntoConstraints="NO" id="bxz-wi-I73" userLabel="Prompt"> + <rect key="frame" x="16" y="107" width="382" height="36"/> + <constraints> + <constraint firstAttribute="width" priority="750" constant="600" id="Wga-Tr-jFk"/> + </constraints> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <color key="textColor" systemColor="secondaryLabelColor"/> + <nil key="highlightedColor"/> + </label> + <view clipsSubviews="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1000" translatesAutoresizingMaskIntoConstraints="NO" id="DE5-R9-8tB" userLabel="Accessory Bar"> + <rect key="frame" x="0.0" y="163" width="414" height="44"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="Eej-Tm-ddy"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="9w9-KC-2W9" userLabel="Seperator"> + <rect key="frame" x="0.0" y="230.5" width="414" height="0.5"/> + <color key="backgroundColor" systemColor="separatorColor"/> + <accessibility key="accessibilityConfiguration"> + <accessibilityTraits key="traits" notEnabled="YES"/> + </accessibility> + <constraints> + <constraint firstAttribute="height" constant="0.5" id="Q9V-Av-JqM"/> + </constraints> + </view> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="bxz-wi-I73" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="LGN-fD-c9Q" secondAttribute="leading" constant="16" id="21t-vd-2bd"/> + <constraint firstItem="IMu-iA-0ck" firstAttribute="top" secondItem="LGN-fD-c9Q" secondAttribute="top" constant="10" id="Cym-Zp-9He"/> + <constraint firstAttribute="trailing" secondItem="IMu-iA-0ck" secondAttribute="trailing" constant="16" id="KV9-tA-ybQ"/> + <constraint firstItem="DE5-R9-8tB" firstAttribute="top" secondItem="bxz-wi-I73" secondAttribute="bottom" constant="20" id="LOU-MU-PJx"/> + <constraint firstAttribute="bottom" secondItem="DE5-R9-8tB" secondAttribute="bottom" priority="750" constant="24" id="MbU-Zk-mLK"/> + <constraint firstItem="bxz-wi-I73" firstAttribute="centerX" secondItem="LGN-fD-c9Q" secondAttribute="centerX" id="ROI-Gz-ami"/> + <constraint firstItem="9w9-KC-2W9" firstAttribute="leading" secondItem="LGN-fD-c9Q" secondAttribute="leading" id="X3f-jY-uff"/> + <constraint firstAttribute="trailing" secondItem="DE5-R9-8tB" secondAttribute="trailing" id="a3z-v1-Ujv"/> + <constraint firstAttribute="trailing" secondItem="9w9-KC-2W9" secondAttribute="trailing" id="bqi-CI-mnl"/> + <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="bxz-wi-I73" secondAttribute="trailing" constant="16" id="csW-Aq-EvI"/> + <constraint firstAttribute="height" constant="250" id="dks-Bz-gRQ"/> + <constraint firstAttribute="bottom" secondItem="9w9-KC-2W9" secondAttribute="bottom" id="ntw-Of-ryN"/> + <constraint firstItem="bxz-wi-I73" firstAttribute="top" secondItem="IMu-iA-0ck" secondAttribute="bottom" constant="12" id="ojs-jr-NYN"/> + <constraint firstItem="IMu-iA-0ck" firstAttribute="leading" secondItem="LGN-fD-c9Q" secondAttribute="leading" constant="16" id="q7J-Ng-Knf"/> + <constraint firstItem="DE5-R9-8tB" firstAttribute="leading" secondItem="LGN-fD-c9Q" secondAttribute="leading" id="qOR-nT-mJQ"/> + <constraint firstItem="DE5-R9-8tB" firstAttribute="top" secondItem="LGN-fD-c9Q" secondAttribute="top" id="uVe-Ze-eIF"/> + <constraint firstItem="DE5-R9-8tB" firstAttribute="top" relation="greaterThanOrEqual" secondItem="LGN-fD-c9Q" secondAttribute="top" id="wKv-4h-bvX"/> + <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="DE5-R9-8tB" secondAttribute="bottom" priority="999" constant="9" id="wOf-4J-lXq"/> + </constraints> + <variation key="default"> + <mask key="constraints"> + <exclude reference="dks-Bz-gRQ"/> + <exclude reference="uVe-Ze-eIF"/> + </mask> + </variation> + <variation key="heightClass=compact"> + <mask key="constraints"> + <include reference="LOU-MU-PJx"/> + <exclude reference="MbU-Zk-mLK"/> + <include reference="uVe-Ze-eIF"/> + </mask> + </variation> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="UfV-hu-KZx" userLabel="Footer View"> + <rect key="frame" x="0.0" y="786" width="414" height="110"/> + <subviews> + <visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="qxH-Si-bGn"> + <rect key="frame" x="0.0" y="0.0" width="414" height="110"/> + <view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" alpha="0.69999998807907104" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="X8b-Ge-Itm"> + <rect key="frame" x="0.0" y="0.0" width="414" height="110"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + </view> + <color key="tintColor" systemColor="systemBackgroundColor"/> + <blurEffect style="regular"/> + </visualEffectView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jCA-DJ-MgS" userLabel="Seperator"> + <rect key="frame" x="0.0" y="0.0" width="414" height="0.5"/> + <color key="backgroundColor" systemColor="separatorColor"/> + <constraints> + <constraint firstAttribute="height" constant="0.5" id="DKq-so-Z8k"/> + </constraints> + </view> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="dmc-kV-0d8" userLabel="Default Action"> + <rect key="frame" x="20" y="16" width="374" height="44"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="9bL-Jo-3aw"/> + </constraints> + <fontDescription key="fontDescription" type="system" weight="medium" pointSize="17"/> + <state key="normal" title="Create Blank Page"> + <color key="titleColor" systemColor="labelColor"/> + </state> + <connections> + <action selector="defaultActionSelected:" destination="-1" eventType="touchUpInside" id="d2H-aW-Shz"/> + </connections> + </button> + <stackView hidden="YES" opaque="NO" contentMode="scaleToFill" distribution="fillEqually" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="3RE-1V-jrs" userLabel="selectedStateButtonsContainer"> + <rect key="frame" x="20" y="16" width="374" height="44"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="HeJ-NR-hHA" userLabel="Secondry Action"> + <rect key="frame" x="0.0" y="0.0" width="182" height="44"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="HTk-qu-K7m"/> + </constraints> + <fontDescription key="fontDescription" type="system" weight="medium" pointSize="17"/> + <state key="normal" title="Preview"> + <color key="titleColor" systemColor="labelColor"/> + </state> + <connections> + <action selector="secondaryActionSelected:" destination="-1" eventType="touchUpInside" id="fGK-R9-pK4"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="cck-0Q-rBD" userLabel="Primary Action"> + <rect key="frame" x="192" y="0.0" width="182" height="44"/> + <color key="backgroundColor" name="Pink50"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="bwA-RB-jmT"/> + </constraints> + <state key="normal" title="Create Page"> + <color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </state> + <connections> + <action selector="primaryActionSelected:" destination="-1" eventType="touchUpInside" id="YUD-TB-HxA"/> + </connections> + </button> + </subviews> + </stackView> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="qxH-Si-bGn" firstAttribute="leading" secondItem="UfV-hu-KZx" secondAttribute="leading" id="4y3-db-p8j"/> + <constraint firstItem="3RE-1V-jrs" firstAttribute="top" secondItem="UfV-hu-KZx" secondAttribute="top" constant="16" id="By8-S6-dPm"/> + <constraint firstAttribute="trailing" secondItem="jCA-DJ-MgS" secondAttribute="trailing" id="Msd-NV-Dt9"/> + <constraint firstItem="qxH-Si-bGn" firstAttribute="top" secondItem="UfV-hu-KZx" secondAttribute="top" id="Sdv-Fh-3JR"/> + <constraint firstItem="jCA-DJ-MgS" firstAttribute="leading" secondItem="UfV-hu-KZx" secondAttribute="leading" id="jdx-th-4uc"/> + <constraint firstItem="dmc-kV-0d8" firstAttribute="top" secondItem="UfV-hu-KZx" secondAttribute="top" constant="16" id="lU4-Sj-dcL"/> + <constraint firstAttribute="height" constant="110" id="lle-48-IUZ"/> + <constraint firstItem="qxH-Si-bGn" firstAttribute="height" secondItem="UfV-hu-KZx" secondAttribute="height" id="tz4-DO-FSF"/> + <constraint firstItem="jCA-DJ-MgS" firstAttribute="top" secondItem="UfV-hu-KZx" secondAttribute="top" id="v6p-Jz-Saz"/> + <constraint firstAttribute="trailing" secondItem="qxH-Si-bGn" secondAttribute="trailing" id="yT0-ne-uGv"/> + </constraints> + </view> + </subviews> + <viewLayoutGuide key="safeArea" id="0a4-LI-57Y"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="bottom" secondItem="UfV-hu-KZx" secondAttribute="bottom" id="18E-U6-772"/> + <constraint firstItem="WmP-bw-ceT" firstAttribute="leading" secondItem="0a4-LI-57Y" secondAttribute="leading" id="E8Q-Ya-VH2"/> + <constraint firstItem="0Qq-k5-kWr" firstAttribute="leading" secondItem="U3M-sT-nKQ" secondAttribute="leading" id="Gbb-Mc-Rbf"/> + <constraint firstItem="WmP-bw-ceT" firstAttribute="trailing" secondItem="0a4-LI-57Y" secondAttribute="trailing" id="IEC-aq-Jdg"/> + <constraint firstAttribute="bottom" secondItem="WmP-bw-ceT" secondAttribute="bottom" id="Nhd-PS-v0n"/> + <constraint firstItem="UfV-hu-KZx" firstAttribute="trailing" secondItem="U3M-sT-nKQ" secondAttribute="trailing" id="NjJ-wX-zxg"/> + <constraint firstAttribute="top" secondItem="0Qq-k5-kWr" secondAttribute="top" id="Rmj-qj-ujs"/> + <constraint firstItem="LGN-fD-c9Q" firstAttribute="trailing" secondItem="U3M-sT-nKQ" secondAttribute="trailing" id="USb-Ex-vxP"/> + <constraint firstAttribute="trailing" secondItem="0Qq-k5-kWr" secondAttribute="trailing" id="XxU-6u-RcX"/> + <constraint firstItem="0a4-LI-57Y" firstAttribute="trailing" secondItem="dmc-kV-0d8" secondAttribute="trailing" constant="20" id="cxE-O8-ska"/> + <constraint firstItem="0a4-LI-57Y" firstAttribute="trailing" secondItem="3RE-1V-jrs" secondAttribute="trailing" constant="20" id="fwl-OM-nLV"/> + <constraint firstItem="dmc-kV-0d8" firstAttribute="leading" secondItem="0a4-LI-57Y" secondAttribute="leading" constant="20" id="gMc-5F-M6S"/> + <constraint firstItem="UfV-hu-KZx" firstAttribute="leading" secondItem="U3M-sT-nKQ" secondAttribute="leading" id="msf-aW-ktk"/> + <constraint firstItem="0Qq-k5-kWr" firstAttribute="bottom" secondItem="LGN-fD-c9Q" secondAttribute="bottom" id="p8r-IQ-pZK"/> + <constraint firstItem="LGN-fD-c9Q" firstAttribute="top" secondItem="0a4-LI-57Y" secondAttribute="top" id="sGi-xD-H1K"/> + <constraint firstItem="LGN-fD-c9Q" firstAttribute="leading" secondItem="U3M-sT-nKQ" secondAttribute="leading" id="uNo-VY-vcT"/> + <constraint firstItem="3RE-1V-jrs" firstAttribute="leading" secondItem="0a4-LI-57Y" secondAttribute="leading" constant="20" id="xx9-kd-Yte"/> + <constraint firstItem="WmP-bw-ceT" firstAttribute="top" secondItem="0a4-LI-57Y" secondAttribute="top" id="yIx-Dt-kfa"/> + </constraints> + <simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/> + <point key="canvasLocation" x="-230.43478260869566" y="-881.25"/> + </view> + </objects> + <resources> + <namedColor name="Pink50"> + <color red="0.78823529411764703" green="0.20784313725490197" blue="0.43137254901960786" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </namedColor> + <systemColor name="labelColor"> + <color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + <systemColor name="secondaryLabelColor"> + <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + <systemColor name="separatorColor"> + <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Gutenberg/EditHomepageViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/EditHomepageViewController.swift new file mode 100644 index 000000000000..e4eaf1a33bb7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/EditHomepageViewController.swift @@ -0,0 +1,45 @@ +import Foundation + +class EditHomepageViewController: GutenbergViewController { + required init( + post: AbstractPost, + loadAutosaveRevision: Bool = false, + replaceEditor: @escaping ReplaceEditorCallback, + editorSession: PostEditorAnalyticsSession? = nil, + navigationBarManager: PostEditorNavigationBarManager? = nil + ) { + let navigationBarManager = navigationBarManager ?? HomepageEditorNavigationBarManager() + super.init(post: post, loadAutosaveRevision: loadAutosaveRevision, replaceEditor: replaceEditor, editorSession: editorSession, navigationBarManager: navigationBarManager) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private(set) lazy var homepageEditorStateContext: PostEditorStateContext = { + return PostEditorStateContext(post: post, delegate: self, action: .continueFromHomepageEditing) + }() + + override var postEditorStateContext: PostEditorStateContext { + return homepageEditorStateContext + } + + // If there are changes, offer to save them, otherwise continue will dismiss the editor with no changes. + override func continueFromHomepageEditing() { + if editorHasChanges { + handlePublishButtonTap() + } else { + cancelEditing() + } + } +} + +extension EditHomepageViewController: HomepageEditorNavigationBarManagerDelegate { + var continueButtonText: String { + return postEditorStateContext.publishButtonText + } + + func navigationBarManager(_ manager: HomepageEditorNavigationBarManager, continueWasPressed sender: UIButton) { + requestHTML(for: .continueFromHomepageEditing) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/EditorMediaUtility.swift b/WordPress/Classes/ViewRelated/Gutenberg/EditorMediaUtility.swift index 1e6395c53d2b..1467f962f064 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/EditorMediaUtility.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/EditorMediaUtility.swift @@ -1,7 +1,80 @@ +import AutomatticTracks import Aztec import Gridicons +final class AuthenticatedImageDownload: AsyncOperation { + let url: URL + let blogObjectID: NSManagedObjectID + private let callbackQueue: DispatchQueue + private let onSuccess: (UIImage) -> () + private let onFailure: (Error) -> () + + init(url: URL, blogObjectID: NSManagedObjectID, callbackQueue: DispatchQueue, onSuccess: @escaping (UIImage) -> (), onFailure: @escaping (Error) -> ()) { + self.url = url + self.blogObjectID = blogObjectID + self.callbackQueue = callbackQueue + self.onSuccess = onSuccess + self.onFailure = onFailure + } + + override func main() { + let result = ContextManager.shared.performQuery { context in + Result { + let blog = try context.existingObject(with: self.blogObjectID) as! Blog + return MediaHost(with: blog) { error in + // We'll log the error, so we know it's there, but we won't halt execution. + WordPressAppDelegate.crashLogging?.logError(error) + } + } + } + + let host: MediaHost + do { + host = try result.get() + } catch { + self.state = .isFinished + self.callbackQueue.async { + self.onFailure(error) + } + return + } + + let mediaRequestAuthenticator = MediaRequestAuthenticator() + mediaRequestAuthenticator.authenticatedRequest( + for: url, + from: host, + onComplete: { request in + ImageDownloader.shared.downloadImage(for: request) { (image, error) in + self.state = .isFinished + + self.callbackQueue.async { + guard let image = image else { + DDLogError("Unable to download image for attachment with url = \(String(describing: request.url)). Details: \(String(describing: error?.localizedDescription))") + if let error = error { + self.onFailure(error) + } else { + self.onFailure(NSError(domain: NSURLErrorDomain, code: NSURLErrorUnknown, userInfo: nil)) + } + + return + } + + self.onSuccess(image) + } + } + }, + onFailure: { error in + self.state = .isFinished + self.callbackQueue.async { + self.onFailure(error) + } + } + ) + } +} + class EditorMediaUtility { + private static let InternalInconsistencyError = NSError(domain: NSExceptionName.internalInconsistencyException.rawValue, code: 0) private struct Constants { static let placeholderDocumentLink = URL(string: "documentUploading://")! @@ -12,16 +85,16 @@ class EditorMediaUtility { switch attachment { case let imageAttachment as ImageAttachment: if imageAttachment.url == Constants.placeholderDocumentLink { - icon = Gridicon.iconOfType(.pages, withSize: size) + icon = .gridicon(.pages, size: size) } else { - icon = Gridicon.iconOfType(.image, withSize: size) + icon = .gridicon(.image, size: size) } case _ as VideoAttachment: - icon = Gridicon.iconOfType(.video, withSize: size) + icon = .gridicon(.video, size: size) default: - icon = Gridicon.iconOfType(.attachment, withSize: size) + icon = .gridicon(.attachment, size: size) } - if #available(iOS 13.0, *), let color = tintColor { + if let color = tintColor { icon = icon.withTintColor(color) } icon.addAccessibilityForAttachment(attachment) @@ -45,85 +118,106 @@ class EditorMediaUtility { } - func downloadImage(from url: URL, post: AbstractPost, success: @escaping (UIImage) -> Void, onFailure failure: @escaping (Error) -> Void) -> ImageDownloader.Task { + func downloadImage( + from url: URL, + post: AbstractPost, + success: @escaping (UIImage) -> Void, + onFailure failure: @escaping (Error) -> Void) -> ImageDownloaderTask { + let imageMaxDimension = max(UIScreen.main.bounds.size.width, UIScreen.main.bounds.size.height) //use height zero to maintain the aspect ratio when fetching let size = CGSize(width: imageMaxDimension, height: 0) let scale = UIScreen.main.scale + return downloadImage(from: url, size: size, scale: scale, post: post, success: success, onFailure: failure) } - func downloadImage(from url: URL, size requestSize: CGSize, scale: CGFloat, post: AbstractPost, success: @escaping (UIImage) -> Void, onFailure failure: @escaping (Error) -> Void) -> ImageDownloader.Task { - var requestURL = url + func downloadImage( + from url: URL, + size requestSize: CGSize, + scale: CGFloat, + post: AbstractPost, + success: @escaping (UIImage) -> Void, + onFailure failure: @escaping (Error) -> Void + ) -> ImageDownloaderTask { + let imageMaxDimension = max(requestSize.width, requestSize.height) //use height zero to maintain the aspect ratio when fetching var size = CGSize(width: imageMaxDimension, height: 0) - let request: URLRequest - - if url.isFileURL { - request = URLRequest(url: url) - } else if post.blog.isPrivate() && PrivateSiteURLProtocol.urlGoes(toWPComSite: url) { - // private wpcom image needs special handling. - // the size that WPImageHelper expects is pixel size - size.width = size.width * scale - requestURL = WPImageURLHelper.imageURLWithSize(size, forImageURL: requestURL) - request = PrivateSiteURLProtocol.requestForPrivateSite(from: requestURL) - } else if !post.blog.isHostedAtWPcom && post.blog.isBasicAuthCredentialStored() { - size.width = size.width * scale - requestURL = WPImageURLHelper.imageURLWithSize(size, forImageURL: requestURL) - request = URLRequest(url: requestURL) - } else { - // the size that PhotonImageURLHelper expects is points size - requestURL = PhotonImageURLHelper.photonURL(with: size, forImageURL: requestURL) - request = URLRequest(url: requestURL) + let (requestURL, blogObjectID) = workaroundCoreDataConcurrencyIssue(accessing: post) { + let requestURL: URL + if url.isFileURL { + requestURL = url + } else if post.isPrivateAtWPCom() && url.isHostedAtWPCom { + // private wpcom image needs special handling. + // the size that WPImageHelper expects is pixel size + size.width = size.width * scale + requestURL = WPImageURLHelper.imageURLWithSize(size, forImageURL: url) + } else if !post.blog.isHostedAtWPcom && post.blog.isBasicAuthCredentialStored() { + size.width = size.width * scale + requestURL = WPImageURLHelper.imageURLWithSize(size, forImageURL: url) + } else { + // the size that PhotonImageURLHelper expects is points size + requestURL = PhotonImageURLHelper.photonURL(with: size, forImageURL: url) + } + return (requestURL, post.blog.objectID) } - return ImageDownloader.shared.downloadImage(for: request) { [weak self] (image, error) in - guard let _ = self else { + let imageDownload = AuthenticatedImageDownload( + url: requestURL, + blogObjectID: blogObjectID, + callbackQueue: .main, + onSuccess: success, + onFailure: failure) + + imageDownload.start() + return imageDownload + } + + static func fetchRemoteVideoURL(for media: Media, in post: AbstractPost, withToken: Bool = false, completion: @escaping ( Result<(URL), Error> ) -> Void) { + // Return the attachment url it it's not a VideoPress video + if media.videopressGUID == nil { + guard let videoURLString = media.remoteURL, let videoURL = URL(string: videoURLString) else { + DDLogError("Unable to find remote video URL for video with upload ID = \(media.uploadID).") + completion(Result.failure(InternalInconsistencyError)) return } - - DispatchQueue.main.async { - guard let image = image else { - DDLogError("Unable to download image for attachment with url = \(url). Details: \(String(describing: error?.localizedDescription))") - if let error = error { - failure(error) - } else { - failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorUnknown, userInfo: nil)) + completion(Result.success(videoURL)) + } + else { + fetchVideoPressMetadata(for: media, in: post) { result in + switch result { + case .success((let metadata)): + guard let originalURL = metadata.originalURL else { + DDLogError("Failed getting original URL for media with upload ID: \(media.uploadID)") + completion(Result.failure(InternalInconsistencyError)) + return } - return + if withToken { + completion(Result.success(metadata.getURLWithToken(url: originalURL) ?? originalURL)) + } + else { + completion(Result.success(originalURL)) + } + case .failure(let error): + completion(Result.failure(error)) } - - success(image) } } } - static func fetchRemoteVideoURL(for media: Media, in post: AbstractPost, completion: @escaping ( Result<(videoURL: URL, posterURL: URL?), Error> ) -> Void) { + static func fetchVideoPressMetadata(for media: Media, in post: AbstractPost, completion: @escaping ( Result<(RemoteVideoPressVideo), Error> ) -> Void) { guard let videoPressID = media.videopressGUID else { - //the site can be a self-hosted site if there's no videopressGUID - if let videoURLString = media.remoteURL, - let videoURL = URL(string: videoURLString) { - completion(Result.success((videoURL: videoURL, posterURL: nil))) - } else { - DDLogError("Unable to find remote video URL for video with upload ID = \(media.uploadID).") - completion(Result.failure(NSError())) - } + DDLogError("Unable to find metadata for video with upload ID = \(media.uploadID).") + completion(Result.failure(InternalInconsistencyError)) return } + let mediaService = MediaService(managedObjectContext: ContextManager.sharedInstance().mainContext) - mediaService.getMediaURL(fromVideoPressID: videoPressID, in: post.blog, success: { (videoURLString, posterURLString) in - guard let videoURL = URL(string: videoURLString) else { - completion(Result.failure(NSError())) - return - } - var posterURL: URL? - if let validPosterURLString = posterURLString, let url = URL(string: validPosterURLString) { - posterURL = url - } - completion(Result.success((videoURL: videoURL, posterURL: posterURL))) + mediaService.getMetadataFromVideoPressID(videoPressID, in: post.blog, success: { (metadata) in + completion(Result.success(metadata)) }, failure: { (error) in - DDLogError("Unable to find information for VideoPress video with ID = \(videoPressID). Details: \(error.localizedDescription)") + DDLogError("Unable to find metadata for VideoPress video with ID = \(videoPressID). Details: \(error.localizedDescription)") completion(Result.failure(error)) }) } diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergFeaturedImageHelper.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergFeaturedImageHelper.swift new file mode 100644 index 000000000000..34eb00c63fcd --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergFeaturedImageHelper.swift @@ -0,0 +1,38 @@ +import Foundation +import Gutenberg + +class GutenbergFeaturedImageHelper: NSObject { + fileprivate let post: AbstractPost + fileprivate let gutenberg: Gutenberg + + static let mediaIdNoFeaturedImageSet = 0 + + let event: WPAnalyticsEvent = .editorPostFeaturedImageChanged + + init(post: AbstractPost, gutenberg: Gutenberg) { + self.post = post + self.gutenberg = gutenberg + super.init() + } + + func setFeaturedImage(mediaID: Int32) { + let media = Media.existingMediaWith(mediaID: NSNumber(value: mediaID), inBlog: post.blog) + post.featuredImage = media + + if mediaID == GutenbergFeaturedImageHelper.mediaIdNoFeaturedImageSet { + gutenberg.showNotice(NSLocalizedString("Removed as featured image", comment: "Notice confirming that an image has been removed as the post's featured image.")) + WPAnalytics.track(event, properties: [ + "via": "gutenberg", + "action": "removed" + ]) + } else { + gutenberg.showNotice(NSLocalizedString("Set as featured image", comment: "Notice confirming that an image has been set as the post's featured image.")) + WPAnalytics.track(event, properties: [ + "via": "gutenberg", + "action": "added" + ]) + } + + gutenberg.featuredImageIdNativeUpdated(mediaId: mediaID) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergImageLoader.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergImageLoader.swift index be8bb63fb4db..bff482084bd3 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergImageLoader.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergImageLoader.swift @@ -13,11 +13,11 @@ class GutenbergImageLoader: NSObject, RCTImageURLLoader { self.post = post } - func canLoadImageURL(_ requestURL: URL!) -> Bool { + func canLoadImageURL(_ requestURL: URL) -> Bool { return !requestURL.isFileURL } - func loadImage(for imageURL: URL!, size: CGSize, scale: CGFloat, resizeMode: RCTResizeMode, progressHandler: RCTImageLoaderProgressBlock!, partialLoadHandler: RCTImageLoaderPartialLoadBlock!, completionHandler: RCTImageLoaderCompletionBlock!) -> RCTImageLoaderCancellationBlock! { + func loadImage(for imageURL: URL, size: CGSize, scale: CGFloat, resizeMode: RCTResizeMode, progressHandler: RCTImageLoaderProgressBlock, partialLoadHandler: RCTImageLoaderPartialLoadBlock, completionHandler: @escaping RCTImageLoaderCompletionBlock) -> RCTImageLoaderCancellationBlock? { let cacheKey = getCacheKey(for: imageURL, size: size) if let image = AnimatedImageCache.shared.cachedStaticImage(url: cacheKey) { diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergMediaInserterHelper.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergMediaInserterHelper.swift index e9bea690f9af..24823ca107e7 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergMediaInserterHelper.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergMediaInserterHelper.swift @@ -29,7 +29,11 @@ class GutenbergMediaInserterHelper: NSObject { func insertFromSiteMediaLibrary(media: [Media], callback: @escaping MediaPickerDidPickMediaCallback) { let formattedMedia = media.map { item in - return MediaInfo(id: item.mediaID?.int32Value, url: item.remoteURL, type: item.mediaTypeString) + var metadata: [String: String] = [:] + if let videopressGUID = item.videopressGUID { + metadata["videopressGUID"] = videopressGUID + } + return MediaInfo(id: item.mediaID?.int32Value, url: item.remoteURL, type: item.mediaTypeString, caption: item.caption, title: item.filename, alt: item.alt, metadata: metadata) } callback(formattedMedia) } @@ -69,6 +73,7 @@ class GutenbergMediaInserterHelper: NSObject { options.deliveryMode = .fastFormat options.version = .current options.resizeMode = .fast + options.isNetworkAccessAllowed = true let mediaUploadID = media.gutenbergUploadID // Getting a quick thumbnail of the asset to display while the image is being exported and uploaded. PHImageManager.default().requestImage(for: asset, targetSize: asset.pixelSize(), contentMode: .default, options: options) { (image, info) in @@ -97,8 +102,8 @@ class GutenbergMediaInserterHelper: NSObject { callback([MediaInfo(id: mediaUploadID, url: url.absoluteString, type: media.mediaTypeString)]) } - func insertFromImage(image: UIImage, callback: @escaping MediaPickerDidPickMediaCallback) { - guard let media = insert(exportableAsset: image, source: .mediaEditor) else { + func insertFromImage(image: UIImage, callback: @escaping MediaPickerDidPickMediaCallback, source: MediaSource = .deviceLibrary) { + guard let media = insert(exportableAsset: image, source: source) else { callback([]) return } @@ -231,40 +236,55 @@ class GutenbergMediaInserterHelper: NSObject { } private func mediaObserver(media: Media, state: MediaCoordinator.MediaState) { - // Make sure gutenberg is loaded before seding events to it. - guard gutenberg.isLoaded else { - return - } let mediaUploadID = media.gutenbergUploadID switch state { case .processing: gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .uploading, progress: 0, url: nil, serverID: nil) case .thumbnailReady(let url): + guard ReachabilityUtils.isInternetReachable() && media.remoteStatus != .failed else { + gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .failed, progress: 0, url: url, serverID: nil) + return + } gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .uploading, progress: 0.20, url: url, serverID: nil) break case .uploading: break case .ended: - guard let urlString = media.remoteURL, let url = URL(string: urlString), let mediaServerID = media.mediaID?.int32Value else { + var currentURL = media.remoteURL + + if media.remoteLargeURL != nil { + currentURL = media.remoteLargeURL + } else if media.remoteMediumURL != nil { + currentURL = media.remoteMediumURL + } + + guard let urlString = currentURL, let url = URL(string: urlString), let mediaServerID = media.mediaID?.int32Value else { break } switch media.mediaType { - case .image: - gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .succeeded, progress: 1, url: url, serverID: mediaServerID) case .video: - EditorMediaUtility.fetchRemoteVideoURL(for: media, in: post) { [weak self] (result) in - guard let strongSelf = self else { - return + // Fetch metadata when is a VideoPress video + if media.videopressGUID != nil { + EditorMediaUtility.fetchVideoPressMetadata(for: media, in: post) { [weak self] (result) in + guard let strongSelf = self else { + return + } + switch result { + case .failure: + strongSelf.gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .failed, progress: 0, url: nil, serverID: nil) + case .success(let metadata): + strongSelf.gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .succeeded, progress: 1, url: metadata.originalURL, serverID: mediaServerID, metadata: metadata.asDictionary()) + } } - switch result { - case .failure: - strongSelf.gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .failed, progress: 0, url: nil, serverID: nil) - case .success(let value): - strongSelf.gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .succeeded, progress: 1, url: value.videoURL, serverID: mediaServerID) + } else { + guard let remoteURLString = media.remoteURL, let remoteURL = URL(string: remoteURLString) else { + gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .failed, progress: 0, url: nil, serverID: nil) + return } + gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .succeeded, progress: 1, url: remoteURL, serverID: mediaServerID) } default: - break + gutenberg.mediaUploadUpdate(id: mediaUploadID, state: .succeeded, progress: 1, url: url, serverID: mediaServerID) } case .failed(let error): if error.code == NSURLErrorCancelled { diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergMediaPickerHelper.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergMediaPickerHelper.swift index d3b85ac552cf..6569b268d15c 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergMediaPickerHelper.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergMediaPickerHelper.swift @@ -1,5 +1,8 @@ import Foundation import CoreServices +import UIKit +import Photos +import WordPressShared import WPMediaPicker import Gutenberg @@ -16,7 +19,7 @@ class GutenbergMediaPickerHelper: NSObject { fileprivate let post: AbstractPost fileprivate unowned let context: UIViewController - fileprivate unowned var navigationPicker: WPNavigationMediaPickerViewController? + fileprivate weak var navigationPicker: WPNavigationMediaPickerViewController? fileprivate let noResultsView = NoResultsViewController.controller() /// Media Library Data Source @@ -31,18 +34,6 @@ class GutenbergMediaPickerHelper: NSObject { /// fileprivate lazy var devicePhotoLibraryDataSource = WPPHAssetDataSource() - fileprivate lazy var mediaPickerOptions: WPMediaPickerOptions = { - let options = WPMediaPickerOptions() - options.showMostRecentFirst = true - options.filter = [.image] - options.allowCaptureOfMedia = false - options.showSearchBar = true - options.badgedUTTypes = [String(kUTTypeGIF)] - options.allowMultipleSelection = false - options.preferredStatusBarStyle = .lightContent - return options - }() - var didPickMediaCallback: GutenbergMediaPickerHelperCallback? init(context: UIViewController, post: AbstractPost) { @@ -58,7 +49,8 @@ class GutenbergMediaPickerHelper: NSObject { didPickMediaCallback = callback - let picker = WPNavigationMediaPickerViewController() + let mediaPickerOptions = WPMediaPickerOptions.withDefaults(filter: filter, allowMultipleSelection: allowMultipleSelection) + let picker = WPNavigationMediaPickerViewController(options: mediaPickerOptions) navigationPicker = picker switch dataSourceType { case .device: @@ -72,17 +64,22 @@ class GutenbergMediaPickerHelper: NSObject { } picker.selectionActionTitle = Constants.mediaPickerInsertText - mediaPickerOptions.filter = filter - mediaPickerOptions.allowMultipleSelection = allowMultipleSelection picker.mediaPicker.options = mediaPickerOptions picker.delegate = self + picker.mediaPicker.registerClass(forReusableCellOverlayViews: DisabledVideoOverlay.self) + + if FeatureFlag.mediaPickerPermissionsNotice.enabled { + picker.mediaPicker.registerClass(forCustomHeaderView: DeviceMediaPermissionsHeader.self) + } + + picker.previewActionTitle = NSLocalizedString("Edit %@", comment: "Button that displays the media editor to the user") picker.modalPresentationStyle = .currentContext context.present(picker, animated: true) } private lazy var cameraPicker: WPMediaPickerViewController = { let cameraPicker = WPMediaPickerViewController() - cameraPicker.options = mediaPickerOptions + cameraPicker.options = WPMediaPickerOptions.withDefaults() cameraPicker.mediaPickerDelegate = self cameraPicker.dataSource = WPPHAssetDataSource.sharedInstance() return cameraPicker @@ -103,21 +100,79 @@ class GutenbergMediaPickerHelper: NSObject { cameraPicker.modalPresentationStyle = .currentContext cameraPicker.viewControllerToUseToPresent = context cameraPicker.options.filter = filter + cameraPicker.options.allowMultipleSelection = false cameraPicker.showCapture() } } +// MARK: - User messages for video limits allowances +// +extension GutenbergMediaPickerHelper: VideoLimitsAlertPresenter {} + +// MARK: - Picker Delegate +// extension GutenbergMediaPickerHelper: WPMediaPickerViewControllerDelegate { func mediaPickerController(_ picker: WPMediaPickerViewController, didFinishPicking assets: [WPMediaAsset]) { - invokeMediaPickerCallback(asset: assets) - picker.dismiss(animated: true, completion: nil) + if picker == cameraPicker, + let asset = assets.first, + !post.blog.canUploadAsset(asset) { + presentVideoLimitExceededAfterCapture(on: self.context) + } else { + invokeMediaPickerCallback(asset: assets) + picker.dismiss(animated: true, completion: nil) + } + } + + open func mediaPickerController(_ picker: WPMediaPickerViewController, handleError error: Error) -> Bool { + let alert = WPMediaPickerAlertHelper.buildAlertControllerWithError(error) + context.present(alert, animated: true) + return true } func mediaPickerControllerDidCancel(_ picker: WPMediaPickerViewController) { + mediaLibraryDataSource.searchCancelled() context.dismiss(animated: true, completion: { self.invokeMediaPickerCallback(asset: nil) }) } + func mediaPickerControllerShouldShowCustomHeaderView(_ picker: WPMediaPickerViewController) -> Bool { + guard FeatureFlag.mediaPickerPermissionsNotice.enabled, + picker !== cameraPicker else { + return false + } + + return PHPhotoLibrary.authorizationStatus(for: .readWrite) == .limited + } + + func mediaPickerControllerReferenceSize(forCustomHeaderView picker: WPMediaPickerViewController) -> CGSize { + let header = DeviceMediaPermissionsHeader() + header.translatesAutoresizingMaskIntoConstraints = false + + return header.referenceSizeInView(picker.view) + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, configureCustomHeaderView headerView: UICollectionReusableView) { + guard let headerView = headerView as? DeviceMediaPermissionsHeader else { + return + } + + headerView.presenter = picker + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, shouldShowOverlayViewForCellFor asset: WPMediaAsset) -> Bool { + picker !== cameraPicker && !post.blog.canUploadAsset(asset) + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, shouldSelect asset: WPMediaAsset) -> Bool { + if picker !== cameraPicker, + !post.blog.canUploadAsset(asset) { + + presentVideoLimitExceededFromPicker(on: picker) + return false + } + return true + } + fileprivate func invokeMediaPickerCallback(asset: [WPMediaAsset]?) { didPickMediaCallback?(asset) didPickMediaCallback = nil @@ -186,3 +241,26 @@ extension GutenbergMediaPickerHelper { }) } } + +fileprivate extension WPMediaPickerOptions { + static func withDefaults( + showMostRecentFirst: Bool = true, + filter: WPMediaType = [.image], + allowCaptureOfMedia: Bool = false, + showSearchBar: Bool = true, + badgedUTTypes: Set<String> = [String(kUTTypeGIF)], + allowMultipleSelection: Bool = false, + preferredStatusBarStyle: UIStatusBarStyle = WPStyleGuide.preferredStatusBarStyle + ) -> WPMediaPickerOptions { + let options = WPMediaPickerOptions() + options.showMostRecentFirst = showMostRecentFirst + options.filter = filter + options.allowCaptureOfMedia = allowCaptureOfMedia + options.showSearchBar = showSearchBar + options.badgedUTTypes = badgedUTTypes + options.allowMultipleSelection = allowMultipleSelection + options.preferredStatusBarStyle = preferredStatusBarStyle + + return options + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergNetworking.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergNetworking.swift index 872835cd229f..ea74303fb619 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergNetworking.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergNetworking.swift @@ -1,19 +1,34 @@ import Alamofire +import WordPressKit struct GutenbergNetworkRequest { typealias CompletionHandler = (Swift.Result<Any, NSError>) -> Void private let path: String private unowned let blog: Blog + private let method: HTTPMethod + private let data: [String: AnyObject]? - init(path: String, blog: Blog) { + enum HTTPMethod: String { + case get = "GET" + case post = "POST" + } + + init(path: String, blog: Blog, method: HTTPMethod = .get, data: [String: AnyObject]? = nil) { self.path = path self.blog = blog + self.method = method + self.data = data } func request(completion: @escaping CompletionHandler) { if blog.isAccessibleThroughWPCom(), let dotComID = blog.dotComID { - dotComRequest(with: dotComID, completion: completion) + switch method { + case .get: + dotComGetRequest(with: dotComID, completion: completion) + case .post: + dotComPostRequest(with: dotComID, data: data, completion: completion) + } } else { selfHostedRequest(completion: completion) } @@ -21,7 +36,7 @@ struct GutenbergNetworkRequest { // MARK: - dotCom - private func dotComRequest(with dotComID: NSNumber, completion: @escaping CompletionHandler) { + private func dotComGetRequest(with dotComID: NSNumber, completion: @escaping CompletionHandler) { blog.wordPressComRestApi()?.GET(dotComPath(with: dotComID), parameters: nil, success: { (response, httpResponse) in completion(.success(response)) }, failure: { (error, httpResponse) in @@ -29,31 +44,68 @@ struct GutenbergNetworkRequest { }) } + private func dotComPostRequest(with dotComID: NSNumber, data: [String: AnyObject]?, completion: @escaping CompletionHandler) { + blog.wordPressComRestApi()?.POST(dotComPath(with: dotComID), parameters: data, success: { (response, httpResponse) in + completion(.success(response)) + }, failure: { (error, httpResponse) in + completion(.failure(error.nsError(with: httpResponse))) + }) + } + private func dotComPath(with dotComID: NSNumber) -> String { return path.replacingOccurrences(of: "/wp/v2/", with: "/wp/v2/sites/\(dotComID)/") + .replacingOccurrences(of: "/wpcom/v2/", with: "/wpcom/v2/sites/\(dotComID)/") + .replacingOccurrences(of: "/oembed/1.0/", with: "/oembed/1.0/sites/\(dotComID)/") } // MARK: - Self-Hosed private func selfHostedRequest(completion: @escaping CompletionHandler) { - do { - let url = try blog.url(withPath: selfHostedPath).asURL() - performSelfHostedRequest(with: url, completion: completion) - } catch { - completion(.failure(error as NSError)) + performSelfHostedRequest(completion: completion) + } + + private func performSelfHostedRequest(completion: @escaping CompletionHandler) { + guard let api = blog.wordPressOrgRestApi else { + completion(.failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorUnknown, userInfo: nil))) + return + } + + switch method { + case .get: + api.GET(path, parameters: nil) { (result, httpResponse) in + switch result { + case .success(let response): + completion(.success(response)) + case .failure(let error): + if handleEmbedError(path: path, error: error, completion: completion) { + return + } + completion(.failure(error as NSError)) + } + } + case .post: + api.POST(path, parameters: data) { (result, httpResponse) in + switch result { + case .success(let response): + completion(.success(response)) + case .failure(let error): + if handleEmbedError(path: path, error: error, completion: completion) { + return + } + completion(.failure(error as NSError)) + } + } } } - private func performSelfHostedRequest(with url: URL, completion: @escaping CompletionHandler) { - SessionManager.default.request(url).validate().responseJSON { (response) in - switch response.result { - case .success(let response): - completion(.success(response)) - case .failure(let afError): - let error = afError.nsError(with: response.response) - completion(.failure(error)) + private func handleEmbedError(path: String, error: Error, completion: @escaping CompletionHandler) -> Bool { + if path.starts(with: "/oembed/1.0/") { + if let error = error as? AFError, error.responseCode == 404 { + completion(.failure(URLError(URLError.Code(rawValue: 404)) as NSError)) + return true } } + return false } private var selfHostedPath: String { diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergSuggestionsViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergSuggestionsViewController.swift new file mode 100644 index 000000000000..e52cb60d77f4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergSuggestionsViewController.swift @@ -0,0 +1,204 @@ +import Foundation +import UIKit + +public class GutenbergSuggestionsViewController: UIViewController { + + static let minimumHeaderHeight = CGFloat(50) + + public lazy var backgroundView: UIView = { + let view = UIView(frame: .zero) + view.backgroundColor = .listForeground + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + public lazy var separatorView: UIView = { + let view = UIView(frame: .zero) + view.backgroundColor = UIColor.divider + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = UIColor(light: UIColor.colorFromHex("e9eff3"), dark: UIColor.colorFromHex("2e2e2e")) + return view + }() + + public lazy var searchView: UITextField = { + let textField = UITextField(frame: CGRect.zero) + textField.text = suggestionType.trigger + textField.clearButtonMode = .whileEditing + textField.translatesAutoresizingMaskIntoConstraints = false + textField.delegate = self + textField.textColor = .text + return textField + }() + + public lazy var suggestionsView: SuggestionsTableView = { + let suggestionsView = SuggestionsTableView(siteID: siteID, suggestionType: suggestionType, delegate: self) + suggestionsView.animateWithKeyboard = false + suggestionsView.enabled = true + suggestionsView.showLoading = true + suggestionsView.translatesAutoresizingMaskIntoConstraints = false + suggestionsView.useTransparentHeader = false + return suggestionsView + }() + + private let siteID: NSNumber + private let suggestionType: SuggestionType + public var onCompletion: ((Result<String, NSError>) -> Void)? + + public init(siteID: NSNumber, suggestionType: SuggestionType) { + self.siteID = siteID + self.suggestionType = suggestionType + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + + let toolbarSize = CGFloat(44) + + view.addSubview(backgroundView) + NSLayoutConstraint.activate([ + backgroundView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + backgroundView.bottomAnchor.constraint(equalTo: view.safeBottomAnchor), + backgroundView.heightAnchor.constraint(equalToConstant: toolbarSize) + ]) + + let margin = CGFloat(10) + view.addSubview(searchView) + searchView.becomeFirstResponder() + NSLayoutConstraint.activate([ + searchView.leadingAnchor.constraint(equalTo: view.safeLeadingAnchor, constant: margin), + searchView.trailingAnchor.constraint(equalTo: view.safeTrailingAnchor, constant: -margin), + searchView.bottomAnchor.constraint(equalTo: view.safeBottomAnchor), + searchView.heightAnchor.constraint(equalToConstant: toolbarSize) + ]) + + view.addSubview(suggestionsView) + NSLayoutConstraint.activate([ + suggestionsView.leadingAnchor.constraint(equalTo: view.safeLeadingAnchor, constant: 0), + suggestionsView.trailingAnchor.constraint(equalTo: view.safeTrailingAnchor, constant: 0), + suggestionsView.bottomAnchor.constraint(equalTo: searchView.topAnchor), + suggestionsView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + ]) + + view.addSubview(separatorView) + NSLayoutConstraint.activate([ + separatorView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + separatorView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + separatorView.bottomAnchor.constraint(equalTo: backgroundView.topAnchor), + separatorView.heightAnchor.constraint(equalToConstant: 1.0) + ]) + + view.setNeedsUpdateConstraints() + } + + override public func viewDidAppear(_ animated: Bool) { + suggestionsView.showSuggestions(forWord: suggestionType.trigger) + } +} + +extension GutenbergSuggestionsViewController: UITextFieldDelegate { + + public func textFieldShouldClear(_ textField: UITextField) -> Bool { + onCompletion?(.failure(buildErrorForCancelation())) + return true + } + + public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + guard let nsString = textField.text as NSString? else { + return true + } + let searchWord = nsString.replacingCharacters(in: range, with: string) + + // This feels a bit hacky, so I'll explain what's being done here: + // 1. If the user types "@ ", the so-called "dismiss sequence", + // the user probably typed the space in order to dismiss the + // suggestions UI. + // 2. So when this happens, we call the success handler and + // pass in an empty string. + // We pass in an empty string because on the RN side here, the success + // handler inserts the @, followed by the username, followed by a space. + // Since we're essentially passing in an empty username, the result is + // that a "@ " is inserted into the post, which is the desired result. + // The same applies for cross-posts, with "+ " instead of "@ ". + let dismissSequence = suggestionType.trigger + " " + guard searchWord != dismissSequence else { + onCompletion?(.success("")) + return true + } + + if searchWord.hasPrefix(suggestionType.trigger) { + suggestionsView.showSuggestions(forWord: searchWord) + } else { + // We are dispatching this async to allow this delegate to finish and process the keypress before executing the cancelation. + DispatchQueue.main.async { + self.onCompletion?(.failure(self.buildErrorForCancelation())) + } + } + return true + } + + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if suggestionsView.viewModel.numberOfItems == 1 { + suggestionsView.selectSuggestion(at: IndexPath(row: 0, section: 0)) + } + return true + } +} + +extension GutenbergSuggestionsViewController: SuggestionsTableViewDelegate { + + public func suggestionsTableView(_ suggestionsTableView: SuggestionsTableView, didSelectSuggestion suggestion: String?, forSearchText text: String) { + if let suggestion = suggestion { + onCompletion?(.success(suggestion)) + } + } + + public func suggestionsTableView(_ suggestionsTableView: SuggestionsTableView, didChangeTableBounds bounds: CGRect) { + + } + + public func suggestionsTableViewMaxDisplayedRows(_ suggestionsTableView: SuggestionsTableView) -> Int { + return 7 + } + + public func suggestionsTableViewDidTapHeader(_ suggestionsTableView: SuggestionsTableView) { + onCompletion?(.failure(buildErrorForCancelation())) + } + + public func suggestionsTableViewHeaderMinimumHeight(_ suggestionsTableView: SuggestionsTableView) -> CGFloat { + return Self.minimumHeaderHeight + } +} + +extension GutenbergSuggestionsViewController { + + enum SuggestionError: CustomNSError { + case canceled + case notAvailable + + static var errorDomain: String = "SuggestionErrorDomain" + + var errorCode: Int { + switch self { + case .canceled: + return 1 + case .notAvailable: + return 2 + } + } + + var errorUserInfo: [String: Any] { + return [:] + } + } + + private func buildErrorForCancelation() -> NSError { + return SuggestionError.canceled as NSError + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+Localization.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+Localization.swift index a9cd3fc74729..163134e45269 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+Localization.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+Localization.swift @@ -11,7 +11,7 @@ extension GutenbergViewController { forResource: Localization.fileName, withExtension: "strings", subdirectory: nil, - localization: currentLProjFolderName() + localization: currentLProjFolderName(in: bundle) ) else { return nil } @@ -25,11 +25,16 @@ extension GutenbergViewController { return nil } - private func currentLProjFolderName() -> String? { - var lProjFolderName = Locale.current.identifier - if let identifierWithoutRegion = Locale.current.identifier.split(separator: "_").first { - lProjFolderName = String(identifierWithoutRegion) - } - return lProjFolderName + private func currentLProjFolderName(in bundle: Bundle) -> String? { + // Localizable.strings file path use dashes for languages and regions (e.g. pt-BR) + // We cannot use Locale.current.identifier directly because it uses underscores + // Bundle.preferredLocalizations matches what NS-LocalizedString uses + // and is safer than parsing and converting identifiers ourselves. + // + // Notice the - in the NSLocalized... method. There seem to be a bug in genstrings where + // it tries to parse lines coming from comments, too: + // + // genstrings: error: bad entry in file WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+Localization.swift (line = 31): Argument is not a literal string. + return bundle.preferredLocalizations.first } } diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+MoreActions.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+MoreActions.swift index 621ae1c80322..232b734f5717 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+MoreActions.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+MoreActions.swift @@ -1,5 +1,6 @@ import Foundation import AutomatticTracks +import WordPressFlux /// This extension handles the "more" actions triggered by the top right /// navigation bar button of Gutenberg editor. @@ -10,28 +11,26 @@ extension GutenbergViewController { } func displayMoreSheet() { + // Dismisses and locks the Notices Store from displaying any new notices. + ActionDispatcher.dispatch(NoticeAction.lock) let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - //TODO: Comment in when bridge is ready - /*if mode == .richText { + if mode == .richText, let contentInfo = contentInfo { // NB : This is a candidate for plurality via .stringsdict, but is limited by https://github.com/wordpress-mobile/WordPress-iOS/issues/6327 - let textCounterTitle = String(format: NSLocalizedString("%li words, %li characters", comment: "Displays the number of words and characters in text"), richTextView.wordCount, richTextView.characterCount) + let textCounterTitle = String(format: NSLocalizedString("Content Structure\nBlocks: %li, Words: %li, Characters: %li", comment: "Displays the number of blocks, words and characters in text"), contentInfo.blockCount, contentInfo.wordCount, contentInfo.characterCount) alert.title = textCounterTitle - }*/ + } if postEditorStateContext.isSecondaryPublishButtonShown, let buttonTitle = postEditorStateContext.secondaryPublishButtonText { alert.addDefaultActionWithTitle(buttonTitle) { _ in self.secondaryPublishButtonTapped() + ActionDispatcher.dispatch(NoticeAction.unlock) } } - alert.addDefaultActionWithTitle(MoreSheetAlert.classicTitle) { [unowned self] _ in - self.savePostEditsAndSwitchToAztec() - } - let toggleModeTitle: String = { if mode == .richText { return MoreSheetAlert.htmlTitle @@ -42,25 +41,48 @@ extension GutenbergViewController { alert.addDefaultActionWithTitle(toggleModeTitle) { [unowned self] _ in self.toggleEditingMode() + ActionDispatcher.dispatch(NoticeAction.unlock) } alert.addDefaultActionWithTitle(MoreSheetAlert.previewTitle) { [weak self] _ in self?.displayPreview() + ActionDispatcher.dispatch(NoticeAction.unlock) } if (post.revisions ?? []).count > 0 { alert.addDefaultActionWithTitle(MoreSheetAlert.historyTitle) { [weak self] _ in self?.displayHistory() + ActionDispatcher.dispatch(NoticeAction.unlock) } } - alert.addDefaultActionWithTitle(MoreSheetAlert.postSettingsTitle) { [weak self] _ in + + let settingsTitle = self.post is Page ? MoreSheetAlert.pageSettingsTitle : MoreSheetAlert.postSettingsTitle + + alert.addDefaultActionWithTitle(settingsTitle) { [weak self] _ in self?.displayPostSettings() + ActionDispatcher.dispatch(NoticeAction.unlock) } - alert.addCancelActionWithTitle(MoreSheetAlert.keepEditingTitle) + alert.addCancelActionWithTitle(MoreSheetAlert.keepEditingTitle) { _ in + ActionDispatcher.dispatch(NoticeAction.unlock) + } + + let helpTitle = JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() ? MoreSheetAlert.editorHelpAndSupportTitle : MoreSheetAlert.editorHelpTitle + alert.addDefaultActionWithTitle(helpTitle) { [weak self] _ in + self?.showEditorHelp() + ActionDispatcher.dispatch(NoticeAction.unlock) + } - alert.popoverPresentationController?.barButtonItem = navigationBarManager.moreBarButtonItem + if let button = navigationBarManager.moreBarButtonItem.customView { + // Required to work around an issue present in iOS 14 beta 2 + // https://github.com/wordpress-mobile/WordPress-iOS/issues/14460 + alert.popoverPresentationController?.sourceRect = button.convert(button.bounds, to: navigationController?.navigationBar) + alert.popoverPresentationController?.sourceView = navigationController?.navigationBar + alert.view.accessibilityIdentifier = MoreSheetAlert.accessibilityIdentifier + } else { + alert.popoverPresentationController?.barButtonItem = navigationBarManager.moreBarButtonItem + } present(alert, animated: true) } @@ -69,7 +91,7 @@ extension GutenbergViewController { guard let action = self.postEditorStateContext.secondaryPublishButtonAction else { // If the user tapped on the secondary publish action button, it means we should have a secondary publish action. let error = NSError(domain: errorDomain, code: ErrorCode.expectedSecondaryAction.rawValue, userInfo: nil) - CrashLogging.logError(error) + WordPressAppDelegate.crashLogging?.logError(error) return } @@ -93,16 +115,16 @@ extension GutenbergViewController { // MARK: - Constants extension GutenbergViewController { - private struct MoreSheetAlert { - static let classicTitle = NSLocalizedString( - "Switch to classic editor", - comment: "Switches from Gutenberg mobile to the classic editor" - ) + struct MoreSheetAlert { static let htmlTitle = NSLocalizedString("Switch to HTML Mode", comment: "Switches the Editor to HTML Mode") static let richTitle = NSLocalizedString("Switch to Visual Mode", comment: "Switches the Editor to Rich Text Mode") static let previewTitle = NSLocalizedString("Preview", comment: "Displays the Post Preview Interface") static let historyTitle = NSLocalizedString("History", comment: "Displays the History screen from the editor's alert sheet") static let postSettingsTitle = NSLocalizedString("Post Settings", comment: "Name of the button to open the post settings") + static let pageSettingsTitle = NSLocalizedString("Page Settings", comment: "Name of the button to open the page settings") static let keepEditingTitle = NSLocalizedString("Keep Editing", comment: "Goes back to editing the post.") + static let accessibilityIdentifier = "MoreSheetAccessibilityIdentifier" + static let editorHelpAndSupportTitle = NSLocalizedString("Help & Support", comment: "Open editor help options") + static let editorHelpTitle = NSLocalizedString("Help", comment: "Open editor help options") } } diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift index 787e1b5e136d..3a0b1d512882 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift @@ -2,18 +2,19 @@ import UIKit import WPMediaPicker import Gutenberg import Aztec +import WordPressFlux +import Kanvas -class GutenbergViewController: UIViewController, PostEditor { - +class GutenbergViewController: UIViewController, PostEditor, FeaturedImageDelegate, PublishingEditor { let errorDomain: String = "GutenbergViewController.errorDomain" enum RequestHTMLReason { case publish case close case more - case switchToAztec case switchBlog case autoSave + case continueFromHomepageEditing } private lazy var stockPhotos: GutenbergStockPhotos = { @@ -22,10 +23,35 @@ class GutenbergViewController: UIViewController, PostEditor { private lazy var filesAppMediaPicker: GutenbergFilesAppMediaSource = { return GutenbergFilesAppMediaSource(gutenberg: gutenberg, mediaInserter: mediaInserterHelper) }() + private lazy var tenorMediaPicker: GutenbergTenorMediaPicker = { + return GutenbergTenorMediaPicker(gutenberg: gutenberg, mediaInserter: mediaInserterHelper) + }() + + lazy var gutenbergSettings: GutenbergSettings = { + return GutenbergSettings() + }() + + lazy var ghostView: GutenGhostView = { + let view = GutenGhostView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var storyEditor: StoryEditor? + + private lazy var service: BlogJetpackSettingsService? = { + guard + let settings = post.blog.settings, + let context = settings.managedObjectContext + else { + return nil + } + return BlogJetpackSettingsService(coreDataStack: ContextManager.shared) + }() // MARK: - Aztec - internal let replaceEditor: (EditorViewController, EditorViewController) -> () + var replaceEditor: (EditorViewController, EditorViewController) -> () // MARK: - PostEditor @@ -48,6 +74,12 @@ class GutenbergViewController: UIViewController, PostEditor { } } + var entryPoint: PostEditorEntryPoint = .unknown { + didSet { + editorSession.entryPoint = entryPoint + } + } + /// Maintainer of state for editor - like for post button /// private(set) lazy var postEditorStateContext: PostEditorStateContext = { @@ -74,10 +106,6 @@ class GutenbergViewController: UIViewController, PostEditor { return mediaInserterHelper.isUploadingMedia() } - func removeFailedMedia() { - // TODO: we can only implement this when GB bridge allows removal of blocks - } - var hasFailedMedia: Bool { return mediaInserterHelper.hasFailedMedia() } @@ -129,6 +157,38 @@ class GutenbergViewController: UIViewController, PostEditor { }) } + private func editMedia(with mediaUrl: URL, callback: @escaping MediaPickerDidPickMediaCallback) { + + let image = GutenbergMediaEditorImage(url: mediaUrl, post: post) + + let mediaEditor = WPMediaEditor(image) + mediaEditor.editingAlreadyPublishedImage = true + + mediaEditor.edit(from: self, + onFinishEditing: { [weak self] images, actions in + guard let image = images.first?.editedImage else { + // If the image wasn't edited, do nothing + return + } + + self?.mediaInserterHelper.insertFromImage(image: image, callback: callback, source: .mediaEditor) + }) + } + + private func confirmEditingGIF(with mediaUrl: URL, callback: @escaping MediaPickerDidPickMediaCallback) { + let alertController = UIAlertController(title: GIFAlertStrings.title, + message: GIFAlertStrings.message, + preferredStyle: .alert) + + alertController.addCancelActionWithTitle(GIFAlertStrings.cancel) + + alertController.addActionWithTitle(GIFAlertStrings.edit, style: .destructive) { _ in + self.editMedia(with: mediaUrl, callback: callback) + } + + present(alertController, animated: true) + } + // MARK: - Set content func setTitle(_ title: String) { @@ -160,6 +220,10 @@ class GutenbergViewController: UIViewController, PostEditor { attachmentDelegate = AztecAttachmentDelegate(post: post) mediaPickerHelper = GutenbergMediaPickerHelper(context: self, post: post) mediaInserterHelper = GutenbergMediaInserterHelper(post: post, gutenberg: gutenberg) + featuredImageHelper = GutenbergFeaturedImageHelper(post: post, gutenberg: gutenberg) + stockPhotos = GutenbergStockPhotos(gutenberg: gutenberg, mediaInserter: mediaInserterHelper) + filesAppMediaPicker = GutenbergFilesAppMediaSource(gutenberg: gutenberg, mediaInserter: mediaInserterHelper) + tenorMediaPicker = GutenbergTenorMediaPicker(gutenberg: gutenberg, mediaInserter: mediaInserterHelper) gutenbergImageLoader.post = post refreshInterface() } @@ -167,9 +231,9 @@ class GutenbergViewController: UIViewController, PostEditor { /// If true, apply autosave content when the editor creates a revision. /// - private let loadAutosaveRevision: Bool + var loadAutosaveRevision: Bool - let navigationBarManager = PostEditorNavigationBarManager() + let navigationBarManager: PostEditorNavigationBarManager lazy var attachmentDelegate = AztecAttachmentDelegate(post: post) @@ -181,6 +245,10 @@ class GutenbergViewController: UIViewController, PostEditor { return GutenbergMediaInserterHelper(post: post, gutenberg: gutenberg) }() + lazy var featuredImageHelper: GutenbergFeaturedImageHelper = { + return GutenbergFeaturedImageHelper(post: post, gutenberg: gutenberg) + }() + /// For autosaving - The debouncer will execute local saving every defined number of seconds. /// In this case every 0.5 second /// @@ -192,6 +260,14 @@ class GutenbergViewController: UIViewController, PostEditor { self?.requestHTML(for: .autoSave) } + var wordCount: UInt { + guard let currentMetrics = contentInfo else { + return 0 + } + + return UInt(currentMetrics.wordCount) + } + /// Media Library Data Source /// lazy var mediaLibraryDataSource: MediaLibraryPickerDataSource = { @@ -221,29 +297,41 @@ class GutenbergViewController: UIViewController, PostEditor { private var isFirstGutenbergLayout = true var shouldPresentInformativeDialog = false lazy var shouldPresentPhase2informativeDialog: Bool = { - return GutenbergSettings().shouldPresentInformativeDialog(for: post.blog) + return gutenbergSettings.shouldPresentInformativeDialog(for: post.blog) + }() + + internal private(set) var contentInfo: ContentInfo? + lazy var editorSettingsService: BlockEditorSettingsService? = { + BlockEditorSettingsService(blog: post.blog, coreDataStack: ContextManager.sharedInstance()) }() // MARK: - Initializers + required convenience init(post: AbstractPost, loadAutosaveRevision: Bool, replaceEditor: @escaping ReplaceEditorCallback, editorSession: PostEditorAnalyticsSession?) { + self.init(post: post, loadAutosaveRevision: loadAutosaveRevision, replaceEditor: replaceEditor, editorSession: editorSession) + } + required init( post: AbstractPost, loadAutosaveRevision: Bool = false, - replaceEditor: @escaping (EditorViewController, EditorViewController) -> (), - editorSession: PostEditorAnalyticsSession? = nil) { + replaceEditor: @escaping ReplaceEditorCallback, + editorSession: PostEditorAnalyticsSession? = nil, + navigationBarManager: PostEditorNavigationBarManager? = nil + ) { self.post = post self.loadAutosaveRevision = loadAutosaveRevision self.replaceEditor = replaceEditor verificationPromptHelper = AztecVerificationPromptHelper(account: self.post.blog.account) - self.editorSession = editorSession ?? PostEditorAnalyticsSession(editor: .gutenberg, post: post) + self.editorSession = PostEditorAnalyticsSession(editor: .gutenberg, post: post) + self.navigationBarManager = navigationBarManager ?? PostEditorNavigationBarManager() super.init(nibName: nil, bundle: nil) addObservers(toPost: post) PostCoordinator.shared.cancelAnyPendingSaveOf(post: post) - navigationBarManager.delegate = self + self.navigationBarManager.delegate = self } required init?(coder aDecoder: NSCoder) { @@ -251,6 +339,7 @@ class GutenbergViewController: UIViewController, PostEditor { } deinit { + tearDownKeyboardObservers() removeObservers(fromPost: post) gutenberg.invalidate() attachmentDelegate.cancelAllPendingMediaRequests() @@ -260,6 +349,7 @@ class GutenbergViewController: UIViewController, PostEditor { override func viewDidLoad() { super.viewDidLoad() + setupKeyboardObservers() WPFontManager.loadNotoFontFamily() createRevisionOfPost(loadAutosaveRevision: loadAutosaveRevision) setupGutenbergView() @@ -267,30 +357,108 @@ class GutenbergViewController: UIViewController, PostEditor { refreshInterface() gutenberg.delegate = self - showInformativeDialogIfNecessary() + fetchBlockSettings() + presentNewPageNoticeIfNeeded() + + service?.syncJetpackSettingsForBlog(post.blog, success: { [weak self] in + self?.gutenberg.updateCapabilities() + }, failure: { (error) in + DDLogError("Error syncing JETPACK: \(String(describing: error))") + }) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) verificationPromptHelper?.updateVerificationStatus() + ghostView.startAnimation() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Handles refreshing controls with state context after options screen is dismissed + storyEditor = nil + editorContentWasUpdated() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + } + + override func viewLayoutMarginsDidChange() { + super.viewLayoutMarginsDidChange() + ghostView.frame = view.frame + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + ghostView.frame = view.frame + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + // Required to work around an issue present in iOS 14 beta 2 + // https://github.com/wordpress-mobile/WordPress-iOS/issues/14460 + if presentedViewController?.view.accessibilityIdentifier == MoreSheetAlert.accessibilityIdentifier { + dismiss(animated: true) + } + } + + override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + super.present(viewControllerToPresent, animated: flag, completion: completion) + + // Update the tint color for React Native modals when presented + let presentedView = presentedViewController?.view + presentedView?.tintColor = .editorPrimary } // MARK: - Functions + private var keyboardShowObserver: Any? + private var keyboardHideObserver: Any? + private var keyboardFrame = CGRect.zero + private var suggestionViewBottomConstraint: NSLayoutConstraint? + private var previousFirstResponder: UIView? + + private func setupKeyboardObservers() { + keyboardShowObserver = NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidShowNotification, object: nil, queue: .main) { [weak self] (notification) in + if let self = self, let keyboardRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + self.keyboardFrame = keyboardRect + self.updateConstraintsToAvoidKeyboard(frame: keyboardRect) + } + } + keyboardHideObserver = NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidShowNotification, object: nil, queue: .main) { [weak self] (notification) in + if let self = self, let keyboardRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect { + self.keyboardFrame = keyboardRect + self.updateConstraintsToAvoidKeyboard(frame: keyboardRect) + } + } + } + + private func tearDownKeyboardObservers() { + if let keyboardShowObserver = keyboardShowObserver { + NotificationCenter.default.removeObserver(keyboardShowObserver) + } + if let keyboardHideObserver = keyboardHideObserver { + NotificationCenter.default.removeObserver(keyboardHideObserver) + } + } + private func configureNavigationBar() { navigationController?.navigationBar.isTranslucent = false navigationController?.navigationBar.accessibilityIdentifier = "Gutenberg Editor Navigation Bar" navigationItem.leftBarButtonItems = navigationBarManager.leftBarButtonItems navigationItem.rightBarButtonItems = navigationBarManager.rightBarButtonItems + navigationItem.titleView = navigationBarManager.blogTitleViewLabel } - private func reloadBlogPickerButton() { - var pickerTitle = post.blog.url ?? String() + private func reloadBlogTitleView() { + var blogTitle = post.blog.url ?? String() if let blogName = post.blog.settings?.name, blogName.isEmpty == false { - pickerTitle = blogName + blogTitle = blogName } - navigationBarManager.reloadBlogPickerButton(with: pickerTitle, enabled: !isSingleSiteMode) + navigationBarManager.reloadBlogTitleView(text: blogTitle) } private func reloadEditorContents() { @@ -298,10 +466,14 @@ class GutenbergViewController: UIViewController, PostEditor { setTitle(post.postTitle ?? "") setHTML(content) + + SiteSuggestionService.shared.prefetchSuggestionsIfNeeded(for: post.blog) { [weak self] in + self?.gutenberg.updateCapabilities() + } } private func refreshInterface() { - reloadBlogPickerButton() + reloadBlogTitleView() reloadEditorContents() reloadPublishButton() } @@ -314,6 +486,14 @@ class GutenbergViewController: UIViewController, PostEditor { 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) { @@ -328,16 +508,52 @@ class GutenbergViewController: UIViewController, PostEditor { gutenberg.setFocusOnTitle() } - // MARK: - Event handlers + func showEditorHelp() { + WPAnalytics.track(.gutenbergEditorHelpShown, properties: [:], blog: post.blog) + gutenberg.showEditorHelp() + } - @objc func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { - return presentationController(forPresented: presented, presenting: presenting) + private func presentNewPageNoticeIfNeeded() { + // Validate if the post is a newly created page or not. + guard post is Page, + post.isDraft(), + post.remoteStatus == AbstractPostRemoteStatus.local else { return } + + let message = post.hasContent() ? NSLocalizedString("Page created", comment: "Notice that a page with content has been created") : NSLocalizedString("Blank page created", comment: "Notice that a page without content has been created") + gutenberg.showNotice(message) + } + + private func handleMissingBlockAlertButtonPressed() { + let blog = post.blog + let JetpackSSOEnabled = (blog.jetpack?.isConnected ?? false) && (blog.settings?.jetpackSSOEnabled ?? false) + if JetpackSSOEnabled == false { + let controller = JetpackSettingsViewController(blog: blog) + controller.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(jetpackSettingsControllerDoneButtonPressed)) + let navController = UINavigationController(rootViewController: controller) + present(navController, animated: true) + } + } + + @objc private func jetpackSettingsControllerDoneButtonPressed() { + if presentedViewController != nil { + dismiss(animated: true) { [weak self] in + self?.gutenberg.updateCapabilities() + } + } } - // MARK: - Switch to Aztec + func gutenbergDidRequestFeaturedImageId(_ mediaID: NSNumber) { + gutenberg.featuredImageIdNativeUpdated(mediaId: Int32(truncating: mediaID)) + } - func savePostEditsAndSwitchToAztec() { - requestHTML(for: .switchToAztec) + func emitPostSaveEvent() { + gutenberg.postHasBeenJustSaved() + } + + // MARK: - Event handlers + + @objc func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + return presentationController(forPresented: presented, presenting: presenting) } } @@ -346,14 +562,16 @@ class GutenbergViewController: UIViewController, PostEditor { extension GutenbergViewController { private func setupGutenbergView() { view.backgroundColor = .white + view.tintColor = .editorPrimary gutenberg.rootView.translatesAutoresizingMaskIntoConstraints = false gutenberg.rootView.backgroundColor = .basicBackground view.addSubview(gutenberg.rootView) - view.leftAnchor.constraint(equalTo: gutenberg.rootView.leftAnchor).isActive = true - view.rightAnchor.constraint(equalTo: gutenberg.rootView.rightAnchor).isActive = true - view.topAnchor.constraint(equalTo: gutenberg.rootView.topAnchor).isActive = true - view.bottomAnchor.constraint(equalTo: gutenberg.rootView.bottomAnchor).isActive = true + view.pinSubviewToAllEdges(gutenberg.rootView) + gutenberg.rootView.pinSubviewToAllEdges(ghostView) + + // Update the tint color of switches within React Native modals, as they require direct mutation + UISwitch.appearance(whenContainedInInstancesOf: [RCTModalHostViewController.self]).onTintColor = .editorPrimary } } @@ -361,8 +579,16 @@ extension GutenbergViewController { extension GutenbergViewController: GutenbergBridgeDelegate { - func gutenbergDidRequestFetch(path: String, completion: @escaping (Result<Any, NSError>) -> Void) { - GutenbergNetworkRequest(path: path, blog: post.blog).request(completion: completion) + func gutenbergDidGetRequestFetch(path: String, completion: @escaping (Result<Any, NSError>) -> Void) { + post.managedObjectContext!.perform { + GutenbergNetworkRequest(path: path, blog: self.post.blog, method: .get).request(completion: completion) + } + } + + func gutenbergDidPostRequestFetch(path: String, data: [String: AnyObject]?, completion: @escaping (Result<Any, NSError>) -> Void) { + post.managedObjectContext!.perform { + GutenbergNetworkRequest(path: path, blog: self.post.blog, method: .post, data: data).request(completion: completion) + } } func editorDidAutosave() { @@ -378,10 +604,16 @@ extension GutenbergViewController: GutenbergBridgeDelegate { gutenbergDidRequestMediaFromDevicePicker(filter: flags, allowMultipleSelection: allowMultipleSelection, with: callback) case .deviceCamera: gutenbergDidRequestMediaFromCameraPicker(filter: flags, with: callback) + case .stockPhotos: stockPhotos.presentPicker(origin: self, post: post, multipleSelection: allowMultipleSelection, callback: callback) - case .filesApp: - filesAppMediaPicker.presentPicker(origin: self, filters: filter, multipleSelection: allowMultipleSelection, callback: callback) + case .tenor: + tenorMediaPicker.presentPicker(origin: self, + post: post, + multipleSelection: allowMultipleSelection, + callback: callback) + case .otherApps, .allFiles: + filesAppMediaPicker.presentPicker(origin: self, filters: filter, allowedTypesOnBlog: post.blog.allowedTypeIdentifiers, multipleSelection: allowMultipleSelection, callback: callback) default: break } } @@ -398,13 +630,12 @@ extension GutenbergViewController: GutenbergBridgeDelegate { mediaType = mediaType | WPMediaType.audio.rawValue case .other: mediaType = mediaType | WPMediaType.other.rawValue + case .any: + mediaType = mediaType | WPMediaType.all.rawValue } } - if mediaType == 0 { - return WPMediaType.all - } else { - return WPMediaType(rawValue: mediaType) - } + + return WPMediaType(rawValue: mediaType) } func gutenbergDidRequestMediaFromSiteMediaLibrary(filter: WPMediaType, allowMultipleSelection: Bool, with callback: @escaping MediaPickerDidPickMediaCallback) { @@ -448,20 +679,13 @@ extension GutenbergViewController: GutenbergBridgeDelegate { } func gutenbergDidRequestMediaEditor(with mediaUrl: URL, callback: @escaping MediaPickerDidPickMediaCallback) { - let image = GutenbergMediaEditorImage(url: mediaUrl, post: post) - let mediaEditor = WPMediaEditor(image) - mediaEditor.editingAlreadyPublishedImage = true - - mediaEditor.edit(from: self, - onFinishEditing: { [weak self] images, actions in - guard let image = images.first?.editedImage else { - // If the image wasn't edited, do nothing - return - } + guard !mediaUrl.isGif else { + confirmEditingGIF(with: mediaUrl, callback: callback) + return + } - self?.mediaInserterHelper.insertFromImage(image: image, callback: callback) - }) + editMedia(with: mediaUrl, callback: callback) } func gutenbergDidRequestImport(from url: URL, with callback: @escaping MediaImportCallback) { @@ -481,6 +705,129 @@ extension GutenbergViewController: GutenbergBridgeDelegate { mediaInserterHelper.cancelUploadOf(media: media) } + func gutenbergDidRequestToSetFeaturedImage(for mediaID: Int32) { + let featuredImageId = post.featuredImage?.mediaID + + let presentAlert = { [weak self] in + guard let `self` = self else { return } + + guard featuredImageId as? Int32 != mediaID else { + // nothing special to do, trying to set the image that's already set as featured + return + } + + guard mediaID != GutenbergFeaturedImageHelper.mediaIdNoFeaturedImageSet else { + // user tries to clear the featured image setting + self.featuredImageHelper.setFeaturedImage(mediaID: mediaID) + return + } + + guard featuredImageId != nil else { + // current featured image is not set so, go ahead and set it to the provided one + self.featuredImageHelper.setFeaturedImage(mediaID: mediaID) + return + } + + // ask the user to confirm changing the featured image since there's already one set + self.showAlertForReplacingFeaturedImage(mediaID: mediaID) + } + + if let viewController = presentedViewController, !viewController.isBeingDismissed { + dismiss(animated: false, completion: presentAlert) + } else { + presentAlert() + } + } + + func showAlertForReplacingFeaturedImage(mediaID: Int32) { + let alertController = UIAlertController(title: NSLocalizedString("Replace current featured image?", comment: "Title message on dialog that prompts user to confirm or cancel the replacement of a featured image."), + message: NSLocalizedString("You already have a featured image set. Do you want to replace it with the new image?", comment: "Main message on dialog that prompts user to confirm or cancel the replacement of a featured image."), + preferredStyle: .actionSheet) + + let replaceAction = UIAlertAction(title: NSLocalizedString("Replace featured image", comment: "Button to confirm the replacement of a featured image."), style: .default) { (action) in + self.featuredImageHelper.setFeaturedImage(mediaID: mediaID) + } + + alertController.addAction(replaceAction) + alertController.addCancelActionWithTitle(NSLocalizedString("Keep current", comment: "Button to cancel the replacement of a featured image.")) + + alertController.popoverPresentationController?.sourceView = view + alertController.popoverPresentationController?.sourceRect = view.bounds + alertController.popoverPresentationController?.permittedArrowDirections = [] + + present(alertController, animated: true, completion: nil) + } + + struct AnyEncodable: Encodable { + + let value: Encodable + init(value: Encodable) { + self.value = value + } + + func encode(to encoder: Encoder) throws { + try value.encode(to: encoder) + } + + } + + func gutenbergDidRequestMediaFilesEditorLoad(_ mediaFiles: [[String: Any]], blockId: String) { + + if mediaFiles.isEmpty { + WPAnalytics.track(.storyBlockAddMediaTapped) + } + + let files = mediaFiles.compactMap({ content -> MediaFile? in + return MediaFile.file(from: content) + }) + + // If the story editor is already shown, ignore this new load request + guard presentedViewController is StoryEditor == false else { + return + } + + do { + try showEditor(files: files, blockID: blockId) + } catch let error { + switch error { + case StoryEditor.EditorCreationError.unsupportedDevice: + let title = NSLocalizedString("Unsupported Device", comment: "Title for stories unsupported device error.") + let message = NSLocalizedString("The Stories editor is not currently available for your iPad. Please try Stories on your iPhone.", comment: "Message for stories unsupported device error.") + let controller = UIAlertController(title: title, message: message, preferredStyle: .alert) + let dismiss = UIAlertAction(title: "Dismiss", style: .default) { _ in + controller.dismiss(animated: true, completion: nil) + } + controller.addAction(dismiss) + present(controller, animated: true, completion: nil) + default: + let title = NSLocalizedString("Unable to Create Stories Editor", comment: "Title for stories unknown error.") + let message = NSLocalizedString("There was a problem with the Stories editor. If the problem persists you can contact us via the Me > Help & Support screen.", comment: "Message for stories unknown error.") + let controller = UIAlertController(title: title, message: message, preferredStyle: .alert) + let dismiss = UIAlertAction(title: "Dismiss", style: .default) { _ in + controller.dismiss(animated: true, completion: nil) + } + controller.addAction(dismiss) + present(controller, animated: true, completion: nil) + } + } + } + + func showEditor(files: [MediaFile], blockID: String) throws { + storyEditor = try StoryEditor.editor(post: post, mediaFiles: files, publishOnCompletion: false, updated: { [weak self] result in + switch result { + case .success(let content): + self?.gutenberg.replace(blockID: blockID, content: content) + self?.dismiss(animated: true, completion: nil) + case .failure(let error): + self?.dismiss(animated: true, completion: nil) + DDLogError("Failed to update story: \(error)") + } + }) + + storyEditor?.trackOpen() + storyEditor?.present(on: self, with: files) + } + func gutenbergDidRequestMediaUploadActionDialog(for mediaID: Int32) { guard let media = mediaInserterHelper.mediaFor(uploadID: mediaID) else { @@ -495,12 +842,14 @@ extension GutenbergViewController: GutenbergBridgeDelegate { } alertController.addAction(dismissAction) - if media.remoteStatus == .pushing || media.remoteStatus == .processing { + if media.remoteStatus == .failed || media.remoteStatus == .processing || media.remoteStatus == .local || media.remoteStatus == .pushing { let cancelUploadAction = UIAlertAction(title: MediaAttachmentActionSheet.stopUploadActionTitle, style: .destructive) { (action) in self.mediaInserterHelper.cancelUploadOf(media: media) } alertController.addAction(cancelUploadAction) - } else if media.remoteStatus == .failed, let error = media.error { + } + + if media.remoteStatus == .failed, let error = media.error { message = error.localizedDescription let retryUploadAction = UIAlertAction(title: MediaAttachmentActionSheet.retryUploadActionTitle, style: .default) { (action) in self.mediaInserterHelper.retryUploadOf(media: media) @@ -516,34 +865,73 @@ extension GutenbergViewController: GutenbergBridgeDelegate { present(alertController, animated: true, completion: nil) } - func gutenbergDidProvideHTML(title: String, html: String, changed: Bool) { + func showAlertForEmptyPostPublish() { + + let title: String = (self.post is Page) ? EmptyPostActionSheet.titlePage : EmptyPostActionSheet.titlePost + let message: String = EmptyPostActionSheet.message + let alertController = UIAlertController(title: title, message: message, preferredStyle: .actionSheet) + let dismissAction = UIAlertAction(title: MediaAttachmentActionSheet.dismissActionTitle, style: .cancel) { (action) in + + } + alertController.addAction(dismissAction) + + alertController.title = title + alertController.message = message + alertController.popoverPresentationController?.sourceView = view + alertController.popoverPresentationController?.sourceRect = view.frame + alertController.popoverPresentationController?.permittedArrowDirections = .any + present(alertController, animated: true, completion: nil) + } + + func editorHasContent(title: String, content: String) -> Bool { + let hasTitle = !title.isEmpty + var hasContent = !content.isEmpty + if let contentInfo = contentInfo { + let isEmpty = contentInfo.blockCount == 0 + let isOneEmptyParagraph = (contentInfo.blockCount == 1 && contentInfo.paragraphCount == 1 && contentInfo.characterCount == 0) + hasContent = !(isEmpty || isOneEmptyParagraph) + } + return hasTitle || hasContent + } + + func gutenbergDidProvideHTML(title: String, html: String, changed: Bool, contentInfo: ContentInfo?) { if changed { self.html = html self.postTitle = title } - + self.contentInfo = contentInfo editorContentWasUpdated() mapUIContentToPostAndSave(immediate: true) if let reason = requestHTMLReason { requestHTMLReason = nil // clear the reason switch reason { case .publish: - handlePublishButtonTap() + if editorHasContent(title: title, content: html) { + handlePublishButtonTap() + } else { + showAlertForEmptyPostPublish() + } case .close: cancelEditing() case .more: displayMoreSheet() - case .switchToAztec: - editorSession.switch(editor: .classic) - EditorFactory().switchToAztec(from: self) case .switchBlog: blogPickerWasPressed() case .autoSave: break + // Inelegant :( + case .continueFromHomepageEditing: + continueFromHomepageEditing() + break } } } + // Not ideal, but seems the least bad of the alternatives + @objc func continueFromHomepageEditing() { + fatalError("This method must be overriden by the extending class") + } + func gutenbergDidLayout() { defer { isFirstGutenbergLayout = false @@ -560,7 +948,13 @@ extension GutenbergViewController: GutenbergBridgeDelegate { func gutenbergDidMount(unsupportedBlockNames: [String]) { if !editorSession.started { - editorSession.start(unsupportedBlocks: unsupportedBlockNames) + let galleryWithImageBlocks = gutenbergEditorSettings()?.galleryWithImageBlocks + + // Note that this method is also used to track startup performance + // It assumes this is being called when the editor has finished loading + // If you need to refactor this, please ensure that the startup_time_ms property + // is still reflecting the actual startup time of the editor + editorSession.start(unsupportedBlocks: unsupportedBlockNames, galleryWithImageBlocks: galleryWithImageBlocks) } } @@ -577,15 +971,6 @@ extension GutenbergViewController: GutenbergBridgeDelegate { } } - func gutenbergDidLogUserEvent(_ event: GutenbergUserEvent) { - switch event { - case .editorSessionTemplateApply(let template): - editorSession.apply(template: template) - case .editorSessionTemplatePreview(let template): - editorSession.preview(template: template) - } - } - func gutenbergDidRequestImagePreview(with fullSizeUrl: URL, thumbUrl: URL?) { navigationController?.definesPresentationContext = true @@ -601,11 +986,178 @@ extension GutenbergViewController: GutenbergBridgeDelegate { controller.modalPresentationStyle = .overCurrentContext self.present(controller, animated: true) } + + func gutenbergDidRequestUnsupportedBlockFallback(for block: Block) { + do { + let controller = try GutenbergWebNavigationController(with: post, block: block) + showGutenbergWeb(controller) + } catch { + DDLogError("Error loading Gutenberg Web with unsupported block: \(error)") + return showUnsupportedBlockUnexpectedErrorAlert() + } + } + + func showGutenbergWeb(_ controller: GutenbergWebNavigationController) { + controller.onSave = { [weak self] newBlock in + self?.gutenberg.replace(block: newBlock) + } + present(controller, animated: true) + } + + func showUnsupportedBlockUnexpectedErrorAlert() { + WPError.showAlert( + withTitle: NSLocalizedString("Error", comment: "Generic error alert title"), + message: NSLocalizedString("There has been an unexpected error.", comment: "Generic error alert message"), + withSupportButton: false + ) + } + + func updateConstraintsToAvoidKeyboard(frame: CGRect) { + keyboardFrame = frame + let minimumKeyboardHeight = CGFloat(50) + guard let suggestionViewBottomConstraint = suggestionViewBottomConstraint else { + return + } + + // There are cases where the keyboard is not visible, but the system instead of returning zero, returns a low number, for example: 0, 3, 69. + // So in those scenarios, we just need to take in account the safe area and ignore the keyboard all together. + if keyboardFrame.height < minimumKeyboardHeight { + suggestionViewBottomConstraint.constant = -self.view.safeAreaInsets.bottom + } + else { + suggestionViewBottomConstraint.constant = -self.keyboardFrame.height + } + } + + func gutenbergDidRequestMention(callback: @escaping (Swift.Result<String, NSError>) -> Void) { + DispatchQueue.main.async(execute: { [weak self] in + self?.showSuggestions(type: .mention, callback: callback) + }) + } + + func gutenbergDidRequestXpost(callback: @escaping (Swift.Result<String, NSError>) -> Void) { + DispatchQueue.main.async(execute: { [weak self] in + self?.showSuggestions(type: .xpost, callback: callback) + }) + } + + func gutenbergDidRequestFocalPointPickerTooltipShown() -> Bool { + return gutenbergSettings.focalPointPickerTooltipShown + } + + func gutenbergDidRequestSetFocalPointPickerTooltipShown(_ tooltipShown: Bool) { + gutenbergSettings.focalPointPickerTooltipShown = tooltipShown + } + + func gutenbergDidSendButtonPressedAction(_ buttonType: Gutenberg.ActionButtonType) { + switch buttonType { + case .missingBlockAlertActionButton: + handleMissingBlockAlertButtonPressed() + } + } + + func gutenbergDidRequestPreview() { + displayPreview() + } + + func gutenbergDidRequestBlockTypeImpressions() -> [String: Int] { + return gutenbergSettings.blockTypeImpressions + } + + func gutenbergDidRequestSetBlockTypeImpressions(_ impressions: [String: Int]) -> Void { + gutenbergSettings.blockTypeImpressions = impressions + } + + func gutenbergDidRequestContactCustomerSupport() { + ZendeskUtils.sharedInstance.showNewRequestIfPossible(from: self.topmostPresentedViewController, with: .editorHelp ) + } + + func gutenbergDidRequestGotoCustomerSupportOptions() { + let controller = SupportTableViewController() + let navController = UINavigationController(rootViewController: controller) + self.topmostPresentedViewController.present(navController, animated: true) + } + + func gutenbergDidRequestSendEventToHost(_ eventName: String, properties: [AnyHashable: Any]) -> Void { + post.managedObjectContext!.perform { + WPAnalytics.trackBlockEditorEvent(eventName, properties: properties, blog: self.post.blog) + } + } +} + +// MARK: - Suggestions implementation + +extension GutenbergViewController { + + private func showSuggestions(type: SuggestionType, callback: @escaping (Swift.Result<String, NSError>) -> Void) { + guard let siteID = post.blog.dotComID else { + callback(.failure(GutenbergSuggestionsViewController.SuggestionError.notAvailable as NSError)) + return + } + + switch type { + case .mention: + guard SuggestionService.shared.shouldShowSuggestions(for: post.blog) else { return } + case .xpost: + guard SiteSuggestionService.shared.shouldShowSuggestions(for: post.blog) else { return } + } + + previousFirstResponder = view.findFirstResponder() + let suggestionsController = GutenbergSuggestionsViewController(siteID: siteID, suggestionType: type) + suggestionsController.onCompletion = { (result) in + callback(result) + suggestionsController.view.removeFromSuperview() + suggestionsController.removeFromParent() + if let previousFirstResponder = self.previousFirstResponder { + previousFirstResponder.becomeFirstResponder() + } + + var analyticsName: String + switch type { + case .mention: + analyticsName = "user" + case .xpost: + analyticsName = "xpost" + } + + var didSelectSuggestion = false + if case let .success(text) = result, !text.isEmpty { + didSelectSuggestion = true + } + + let analyticsProperties: [String: Any] = [ + "suggestion_type": analyticsName, + "did_select_suggestion": didSelectSuggestion + ] + + WPAnalytics.track(.gutenbergSuggestionSessionFinished, properties: analyticsProperties) + } + addChild(suggestionsController) + view.addSubview(suggestionsController.view) + let suggestionsBottomConstraint = suggestionsController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0) + NSLayoutConstraint.activate([ + suggestionsController.view.leadingAnchor.constraint(equalTo: view.safeLeadingAnchor, constant: 0), + suggestionsController.view.trailingAnchor.constraint(equalTo: view.safeTrailingAnchor, constant: 0), + suggestionsBottomConstraint, + suggestionsController.view.topAnchor.constraint(equalTo: view.safeTopAnchor) + ]) + self.suggestionViewBottomConstraint = suggestionsBottomConstraint + updateConstraintsToAvoidKeyboard(frame: keyboardFrame) + suggestionsController.didMove(toParent: self) + } } // MARK: - GutenbergBridgeDataSource extension GutenbergViewController: GutenbergBridgeDataSource { + var isPreview: Bool { + return false + } + + var loadingView: UIView? { + return ghostView + } + func gutenbergLocale() -> String? { return WordPressComLanguageDatabase().deviceLanguage.slug } @@ -622,19 +1174,93 @@ extension GutenbergViewController: GutenbergBridgeDataSource { return post.postTitle ?? "" } + func gutenbergFeaturedImageId() -> NSNumber? { + return post.featuredImage?.mediaID + } + func gutenbergPostType() -> String { return post is Page ? "page" : "post" } + func gutenbergHostAppNamespace() -> String { + return AppConfiguration.isWordPress ? "WordPress" : "Jetpack" + } + func aztecAttachmentDelegate() -> TextViewAttachmentDelegate { return attachmentDelegate } func gutenbergMediaSources() -> [Gutenberg.MediaSource] { + workaroundCoreDataConcurrencyIssue(accessing: post) { + [ + post.blog.supports(.stockPhotos) ? .stockPhotos : nil, + post.blog.supports(.tenor) ? .tenor : nil, + .otherApps, + .allFiles, + ].compactMap { $0 } + } + } + + func gutenbergCapabilities() -> [Capabilities: Bool] { + let isFreeWPCom = post.blog.isHostedAtWPcom && !post.blog.hasPaidPlan + let isWPComSite = post.blog.isHostedAtWPcom || post.blog.isAtomic() + + // Disable Jetpack-powered editor features in WordPress app based on Features Removal coordination + if !JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() { + return [ + .mentions: false, + .xposts: false, + .contactInfoBlock: false, + .layoutGridBlock: false, + .tiledGalleryBlock: false, + .videoPressBlock: false, + .unsupportedBlockEditor: false, + .canEnableUnsupportedBlockEditor: false, + .isAudioBlockMediaUploadEnabled: !isFreeWPCom, + .mediaFilesCollectionBlock: false, + .reusableBlock: false, + .shouldUseFastImage: !post.blog.isPrivate(), + .facebookEmbed: false, + .instagramEmbed: false, + .loomEmbed: false, + .smartframeEmbed: false, + .supportSection: false, + .onlyCoreBlocks: true + ] + } + return [ - post.blog.supports(.stockPhotos) ? .stockPhotos : nil, - .filesApp, - ].compactMap { $0 } + .mentions: SuggestionService.shared.shouldShowSuggestions(for: post.blog), + .xposts: SiteSuggestionService.shared.shouldShowSuggestions(for: post.blog), + .contactInfoBlock: post.blog.supports(.contactInfo), + .layoutGridBlock: post.blog.supports(.layoutGrid), + .tiledGalleryBlock: post.blog.supports(.tiledGallery), + .videoPressBlock: post.blog.supports(.videoPress), + .unsupportedBlockEditor: isUnsupportedBlockEditorEnabled, + .canEnableUnsupportedBlockEditor: post.blog.jetpack?.isConnected ?? false, + .isAudioBlockMediaUploadEnabled: !isFreeWPCom, + .mediaFilesCollectionBlock: post.blog.supports(.stories) && !UIDevice.isPad(), + // Only enable reusable block in WP.com sites until the issue + // (https://github.com/wordpress-mobile/gutenberg-mobile/issues/3457) in self-hosted sites is fixed + .reusableBlock: isWPComSite, + .shouldUseFastImage: !post.blog.isPrivate(), + // Jetpack embeds + .facebookEmbed: post.blog.supports(.facebookEmbed), + .instagramEmbed: post.blog.supports(.instagramEmbed), + .loomEmbed: post.blog.supports(.loomEmbed), + .smartframeEmbed: post.blog.supports(.smartframeEmbed), + .supportSection: true + ] + } + + private var isUnsupportedBlockEditorEnabled: Bool { + // The Unsupported Block Editor is disabled for all self-hosted non-jetpack sites. + // This is because they can have their web editor to be set to classic and then the fallback will not work. + + let blog = post.blog + let isJetpackSSOEnabled = (blog.jetpack?.isConnected ?? false) && (blog.settings?.jetpackSSOEnabled ?? false) + + return blog.isHostedAtWPcom || isJetpackSSOEnabled } } @@ -691,9 +1317,7 @@ extension GutenbergViewController: PostEditorNavigationBarManagerDelegate { } var isPublishButtonEnabled: Bool { - // TODO: return postEditorStateContext.isPublishButtonEnabled when - // we have the required bridge communication that informs us every change - return true + return postEditorStateContext.isPublishButtonEnabled } var uploadingButtonSize: CGSize { @@ -724,8 +1348,8 @@ extension GutenbergViewController: PostEditorNavigationBarManagerDelegate { } - func navigationBarManager(_ manager: PostEditorNavigationBarManager, reloadLeftNavigationItems items: [UIBarButtonItem]) { - navigationItem.leftBarButtonItems = items + func navigationBarManager(_ manager: PostEditorNavigationBarManager, reloadTitleView view: UIView) { + navigationItem.titleView = view } } @@ -733,7 +1357,9 @@ extension GutenbergViewController: PostEditorNavigationBarManagerDelegate { extension Gutenberg.MediaSource { static let stockPhotos = Gutenberg.MediaSource(id: "wpios-stock-photo-library", label: .freePhotosLibrary, types: [.image]) - static let filesApp = Gutenberg.MediaSource(id: "wpios-files-app", label: .files, types: [.image, .video, .audio, .other]) + static let otherApps = Gutenberg.MediaSource(id: "wpios-other-files", label: .otherApps, types: [.image, .video, .audio, .other]) + static let allFiles = Gutenberg.MediaSource(id: "wpios-all-files", label: .otherApps, types: [.any]) + static let tenor = Gutenberg.MediaSource(id: "wpios-tenor", label: .tenor, types: [.image]) } private extension GutenbergViewController { @@ -744,11 +1370,45 @@ private extension GutenbergViewController { } private extension GutenbergViewController { + + struct EmptyPostActionSheet { + static let titlePost = NSLocalizedString("Can't publish an empty post", comment: "Alert message that is shown when trying to publish empty post") + static let titlePage = NSLocalizedString("Can't publish an empty page", comment: "Alert message that is shown when trying to publish empty page") + static let message = NSLocalizedString("Please add some content before trying to publish.", comment: "Suggestion to add content before trying to publish post or page") + } + struct MediaAttachmentActionSheet { static let title = NSLocalizedString("Media Options", comment: "Title for action sheet with media options.") - static let dismissActionTitle = NSLocalizedString("Dismiss", comment: "User action to dismiss media options.") + static let dismissActionTitle = NSLocalizedString( + "gutenberg.mediaAttachmentActionSheet.dismiss", + value: "Dismiss", + comment: "User action to dismiss media options." + ) static let stopUploadActionTitle = NSLocalizedString("Stop upload", comment: "User action to stop upload.") static let retryUploadActionTitle = NSLocalizedString("Retry", comment: "User action to retry media upload.") static let retryAllFailedUploadsActionTitle = NSLocalizedString("Retry all", comment: "User action to retry all failed media uploads.") } } + +// Block Editor Settings +extension GutenbergViewController { + + // GutenbergBridgeDataSource + func gutenbergEditorSettings() -> GutenbergEditorSettings? { + return editorSettingsService?.cachedSettings + } + + private func fetchBlockSettings() { + editorSettingsService?.fetchSettings({ [weak self] result in + guard let `self` = self else { return } + switch result { + case .success(let response): + if response.hasChanges { + self.gutenberg.updateEditorSettings(response.blockEditorSettings) + } + case .failure(let err): + DDLogError("Error fetching settings: \(err)") + } + }) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergWeb/GutenbergWebNavigationViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergWeb/GutenbergWebNavigationViewController.swift new file mode 100644 index 000000000000..c90ec306a1b7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergWeb/GutenbergWebNavigationViewController.swift @@ -0,0 +1,71 @@ +import Gutenberg + +class GutenbergWebNavigationController: UINavigationController { + private let gutenbergWebController: GutenbergWebViewController + private let blockName: String + + var onSave: ((Block) -> Void)? + + init(with post: AbstractPost, block: Block) throws { + gutenbergWebController = try GutenbergWebViewController(with: post, block: block) + blockName = block.name + super.init(nibName: nil, bundle: nil) + viewControllers = [gutenbergWebController] + gutenbergWebController.delegate = self + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + WPAnalytics.track(.gutenbergUnsupportedBlockWebViewShown, properties: ["block": blockName]) + } + + /// Due to a bug on iOS 13, presenting a DocumentController on a modally presented controller will result on a crash. + /// This is a workaround to prevent this crash. + /// More info: https://stackoverflow.com/questions/58164583/wkwebview-with-the-new-ios13-modal-crash-when-a-file-picker-is-invoked + /// + override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + defer { + super.present(viewControllerToPresent, animated: flag, completion: completion) + } + + guard let menuViewControllerClass = NSClassFromString("UIDocumentMenuViewController"), // Silence deprecation warning. + viewControllerToPresent.isKind(of: menuViewControllerClass), + UIDevice.current.userInterfaceIdiom == .phone + else { + return + } + + viewControllerToPresent.popoverPresentationController?.sourceView = gutenbergWebController.view + viewControllerToPresent.popoverPresentationController?.sourceRect = gutenbergWebController.view.frame + } + + private func trackWebViewClosed(action: String) { + WPAnalytics.track(.gutenbergUnsupportedBlockWebViewClosed, properties: ["action": action]) + } +} + +extension GutenbergWebNavigationController: GutenbergWebDelegate { + func webController(controller: GutenbergWebSingleBlockViewController, didPressSave block: Block) { + onSave?(block) + trackWebViewClosed(action: "save") + dismiss(webController: controller) + } + + func webControllerDidPressClose(controller: GutenbergWebSingleBlockViewController) { + trackWebViewClosed(action: "dismiss") + dismiss(webController: controller) + } + + func webController(controller: GutenbergWebSingleBlockViewController, didLog log: String) { + DDLogVerbose(log) + } + + private func dismiss(webController: GutenbergWebSingleBlockViewController) { + webController.cleanUp() + presentingViewController?.dismiss(animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergWeb/GutenbergWebViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergWeb/GutenbergWebViewController.swift new file mode 100644 index 000000000000..6928ff58644b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergWeb/GutenbergWebViewController.swift @@ -0,0 +1,177 @@ +import UIKit +import WebKit +import Gutenberg + +class GutenbergWebViewController: GutenbergWebSingleBlockViewController, WebKitAuthenticatable, NoResultsViewHost { + enum GutenbergWebError: Error { + case wrongEditorUrl(String?) + } + + let authenticator: RequestAuthenticator? + private let url: URL + private let progressView = WebProgressView() + private let userId: String + let gutenbergReadySemaphore = DispatchSemaphore(value: 0) + + init(with post: AbstractPost, block: Block) throws { + authenticator = GutenbergRequestAuthenticator(blog: post.blog) + userId = "\(post.blog.userID ?? 1)" + + guard + let siteURL = post.blog.homeURL, + // Use wp-admin URL since Calypso URL won't work retriving the block content. + let editorURL = URL(string: "\(siteURL)/wp-admin/post-new.php") + else { + throw GutenbergWebError.wrongEditorUrl(post.blog.homeURL as String?) + } + url = editorURL + + try super.init(block: block, userId: userId, isWPOrg: !post.blog.isHostedAtWPcom) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + addNavigationBarElements() + showLoadingMessage() + addProgressView() + startObservingWebView() + waitForGutenbergToLoad(fallback: showTroubleshootingInstructions) + } + + deinit { + webView.removeObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress)) + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + guard + let object = object as? WKWebView, + object == webView, + let keyPath = keyPath + else { + return + } + + switch keyPath { + case #keyPath(WKWebView.estimatedProgress): + progressView.progress = Float(webView.estimatedProgress) + progressView.isHidden = webView.estimatedProgress == 1 + default: + assertionFailure("Observed change to web view that we are not handling") + } + } + + override func getRequest(for webView: WKWebView, completion: @escaping (URLRequest) -> Void) { + authenticatedRequest(for: url, on: webView) { (request) in + completion(request) + } + } + + override func onPageLoadScripts() -> [WKUserScript] { + return [ + loadCustomScript(named: "extra-localstorage-entries", with: userId) + ].compactMap { $0 } + } + + override func onGutenbergReadyScripts() -> [WKUserScript] { + return [ + loadCustomScript(named: "remove-nux") + ].compactMap { $0 } + } + + override func onGutenbergLoadStyles() -> [WKUserScript] { + return [ + loadCustomStyles(named: "external-style-overrides") + ].compactMap { $0 } + } + + override func onGutenbergReady() { + super.onGutenbergReady() + navigationItem.rightBarButtonItem?.isEnabled = true + DispatchQueue.main.async { [weak self] in + self?.hideNoResults() + self?.gutenbergReadySemaphore.signal() + } + } + + private func loadCustomScript(named name: String, with argument: String? = nil) -> WKUserScript? { + do { + return try SourceFile(name: name, type: .js).jsScript(with: argument) + } catch { + assertionFailure("Failed to load `\(name)` JS script for Unsupported Block Editor: \(error)") + return nil + } + } + + private func loadCustomStyles(named name: String, with argument: String? = nil) -> WKUserScript? { + do { + return try SourceFile(name: name, type: .css).jsScript(with: argument) + } catch { + assertionFailure("Failed to load `\(name)` CSS script for Unsupported Block Editor: \(error)") + return nil + } + } + + private func addNavigationBarElements() { + let buttonTitle = NSLocalizedString("Continue", comment: "Apply changes localy to single block edition in the web block editor") + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: buttonTitle, + style: .done, + target: self, + action: #selector(onSaveButtonPressed) + ) + navigationItem.rightBarButtonItem?.isEnabled = false + } + + private func showLoadingMessage() { + let title = NSLocalizedString("Loading the block editor.", comment: "Loading message shown while the Unsupported Block Editor is loading.") + let subtitle = NSLocalizedString("Please ensure the block editor is enabled on your site. If it is not enabled, it will not load.", comment: "Message asking users to make sure that the block editor is enabled on their site in order for the Unsupported Block Editor to load properly.") + configureAndDisplayNoResults(on: view, title: title, subtitle: subtitle, accessoryView: NoResultsViewController.loadingAccessoryView()) + } + + private func waitForGutenbergToLoad(fallback: @escaping () -> Void) { + DispatchQueue.global(qos: .background).async { [weak self] in + let timeout: TimeInterval = 15 + // blocking call + if self?.gutenbergReadySemaphore.wait(timeout: .now() + timeout) == .timedOut { + DispatchQueue.main.async { + fallback() + } + } + } + } + + private func showTroubleshootingInstructions() { + let title = NSLocalizedString("Unable to load the block editor right now.", comment: "Title message shown when the Unsupported Block Editor fails to load.") + let subtitle = NSLocalizedString("Please ensure the block editor is enabled on your site and try again.", comment: "Subtitle message shown when the Unsupported Block Editor fails to load. It asks users to verify that the block editor is enabled on their site before trying again.") + // This does nothing if the "no results" screen is not currently displayed, which is the intended behavior + updateNoResults(title: title, subtitle: subtitle, image: "cloud") + } + + private func startObservingWebView() { + webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: [.new], context: nil) + } + + private func addProgressView() { + progressView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(progressView) + NSLayoutConstraint.activate([ + progressView.topAnchor.constraint(equalTo: view.topAnchor), + progressView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + progressView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + } +} + +extension GutenbergWebViewController { + override func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + super.webView(webView, didCommit: navigation) + if webView.url?.absoluteString.contains("reauth=1") ?? false { + hideNoResults() + removeCoverViewAnimated() + } + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/CategorySectionTableViewCell.swift b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/CategorySectionTableViewCell.swift new file mode 100644 index 000000000000..7bbd2b7e3029 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/CategorySectionTableViewCell.swift @@ -0,0 +1,207 @@ +import UIKit +import Gutenberg + +protocol CategorySectionTableViewCellDelegate: AnyObject { + func didSelectItemAt(_ position: Int, forCell cell: CategorySectionTableViewCell, slug: String) + func didDeselectItem(forCell cell: CategorySectionTableViewCell) + func accessibilityElementDidBecomeFocused(forCell cell: CategorySectionTableViewCell) + func saveHorizontalScrollPosition(forCell cell: CategorySectionTableViewCell, xPosition: CGFloat) + var selectedPreviewDevice: PreviewDeviceSelectionViewController.PreviewDevice { get } +} + +protocol Thumbnail { + var urlDesktop: String? { get } + var urlTablet: String? { get } + var urlMobile: String? { get } + var slug: String { get } +} + +protocol CategorySection { + var categorySlug: String { get } + var caption: String? { get } + var title: String { get } + var emoji: String? { get } + var description: String? { get } + var thumbnails: [Thumbnail] { get } + var thumbnailSize: CGSize { get } +} + +class CategorySectionTableViewCell: UITableViewCell { + + static let cellReuseIdentifier = "\(CategorySectionTableViewCell.self)" + static let nib = UINib(nibName: "\(CategorySectionTableViewCell.self)", bundle: Bundle.main) + static let defaultThumbnailSize = CGSize(width: 160, height: 240) + static let cellVerticalPadding: CGFloat = 70 + + @IBOutlet weak var categoryTitle: UILabel! + @IBOutlet weak var collectionView: UICollectionView! + @IBOutlet weak var collectionViewHeight: NSLayoutConstraint! + @IBOutlet weak var categoryCaptionLabel: UILabel! + + weak var delegate: CategorySectionTableViewCellDelegate? + + private var thumbnails = [Thumbnail]() { + didSet { + collectionView.reloadData() + } + } + + var section: CategorySection? = nil { + didSet { + thumbnails = section?.thumbnails ?? [] + categoryTitle.text = section?.title + setCaption() + + if let section = section { + collectionViewHeight.constant = section.thumbnailSize.height + setNeedsUpdateConstraints() + } + } + } + + var categoryTitleFont: UIFont? { + didSet { + categoryTitle.font = categoryTitleFont ?? WPStyleGuide.serifFontForTextStyle(UIFont.TextStyle.headline, fontWeight: .semibold) + } + } + + private func setCaption() { + guard let caption = section?.caption else { + categoryCaptionLabel.isHidden = true + return + } + + categoryCaptionLabel.isHidden = false + categoryCaptionLabel.setText(caption) + } + + var isGhostCell: Bool = false + var ghostThumbnailSize: CGSize = defaultThumbnailSize { + didSet { + collectionViewHeight.constant = ghostThumbnailSize.height + setNeedsUpdateConstraints() + } + } + var showsCheckMarkWhenSelected = true + var horizontalScrollOffset: CGFloat = .zero { + didSet { + collectionView.contentOffset.x = horizontalScrollOffset + } + } + + override func prepareForReuse() { + delegate = nil + super.prepareForReuse() + collectionView.contentOffset.x = 0 + categoryTitleFont = nil + } + + override func awakeFromNib() { + super.awakeFromNib() + collectionView.register(CollapsableHeaderCollectionViewCell.nib, forCellWithReuseIdentifier: CollapsableHeaderCollectionViewCell.cellReuseIdentifier) + categoryTitle.font = categoryTitleFont ?? WPStyleGuide.serifFontForTextStyle(UIFont.TextStyle.headline, fontWeight: .semibold) + categoryTitle.layer.masksToBounds = true + categoryTitle.layer.cornerRadius = 4 + categoryCaptionLabel.font = WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .regular) + setCaption() + } + + private func deselectItem(_ indexPath: IndexPath) { + collectionView.deselectItem(at: indexPath, animated: true) + collectionView(collectionView, didDeselectItemAt: indexPath) + } + + func deselectItems() { + guard let selectedItems = collectionView.indexPathsForSelectedItems else { return } + selectedItems.forEach { (indexPath) in + collectionView.deselectItem(at: indexPath, animated: true) + } + } + + func selectItemAt(_ position: Int) { + collectionView.selectItem(at: IndexPath(item: position, section: 0), animated: false, scrollPosition: []) + } +} + +extension CategorySectionTableViewCell: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + if collectionView.cellForItem(at: indexPath)?.isSelected ?? false { + deselectItem(indexPath) + return false + } + + return true + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let slug = section?.categorySlug else { return } + delegate?.didSelectItemAt(indexPath.item, forCell: self, slug: slug) + } + + func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { + delegate?.didDeselectItem(forCell: self) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + delegate?.saveHorizontalScrollPosition(forCell: self, xPosition: scrollView.contentOffset.x) + } +} + +extension CategorySectionTableViewCell: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return section?.thumbnailSize ?? ghostThumbnailSize + } +} + +extension CategorySectionTableViewCell: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return isGhostCell ? 1 : thumbnails.count + } + + func collectionView(_ LayoutPickerCategoryTableViewCell: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cellReuseIdentifier = CollapsableHeaderCollectionViewCell.cellReuseIdentifier + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellReuseIdentifier, for: indexPath) as? CollapsableHeaderCollectionViewCell else { + fatalError("Expected the cell with identifier \"\(cellReuseIdentifier)\" to be a \(CollapsableHeaderCollectionViewCell.self). Please make sure the collection view is registering the correct nib before loading the data") + } + guard !isGhostCell else { + cell.startGhostAnimation(style: GhostCellStyle.muriel) + return cell + } + + let thumbnail = thumbnails[indexPath.row] + cell.previewURL = thumbnailUrl(forThumbnail: thumbnail) + cell.showsCheckMarkWhenSelected = showsCheckMarkWhenSelected + cell.isAccessibilityElement = true + cell.accessibilityLabel = thumbnail.slug + return cell + } + + private func thumbnailUrl(forThumbnail thumbnail: Thumbnail) -> String? { + guard let delegate = delegate else { return thumbnail.urlDesktop } + switch delegate.selectedPreviewDevice { + case .desktop: + return thumbnail.urlDesktop + case .tablet: + return thumbnail.urlTablet + case .mobile: + return thumbnail.urlMobile + } + } +} + +/// Accessibility +extension CategorySectionTableViewCell { + override func accessibilityElementDidBecomeFocused() { + delegate?.accessibilityElementDidBecomeFocused(forCell: self) + } +} + +class AccessibleCollectionView: UICollectionView { + override func accessibilityElementCount() -> Int { + guard let dataSource = dataSource else { + return 0 + } + + return dataSource.collectionView(self, numberOfItemsInSection: 0) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/CategorySectionTableViewCell.xib b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/CategorySectionTableViewCell.xib new file mode 100644 index 000000000000..309efb8c7897 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/CategorySectionTableViewCell.xib @@ -0,0 +1,88 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" id="KGk-i7-Jjw" customClass="CategorySectionTableViewCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="320" height="309"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <tableViewCellContentView key="contentView" opaque="NO" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM"> + <rect key="frame" x="0.0" y="0.0" width="320" height="309"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <collectionView multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="none" springLoaded="YES" translatesAutoresizingMaskIntoConstraints="NO" id="mQ0-DH-hTW" customClass="AccessibleCollectionView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="57" width="320" height="222"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="height" priority="999" constant="240" id="iK4-y8-V36"/> + </constraints> + <collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="16" id="8Ds-sb-bxf"> + <size key="itemSize" width="160" height="230"/> + <size key="headerReferenceSize" width="0.0" height="0.0"/> + <size key="footerReferenceSize" width="0.0" height="0.0"/> + <inset key="sectionInset" minX="20" minY="0.0" maxX="20" maxY="0.0"/> + </collectionViewFlowLayout> + <connections> + <outlet property="dataSource" destination="KGk-i7-Jjw" id="kSP-oc-qGJ"/> + <outlet property="delegate" destination="KGk-i7-Jjw" id="7er-Am-6i2"/> + </connections> + </collectionView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="b14-QX-4ea"> + <rect key="frame" x="20" y="20" width="80" height="17"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ujI-Bw-5eP"> + <rect key="frame" x="0.0" y="0.0" width="80" height="0.0"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <color key="textColor" systemColor="secondaryLabelColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="3pe-2p-9as"> + <rect key="frame" x="0.0" y="2" width="80" height="15"/> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="15" id="Cne-bu-2aD"/> + <constraint firstAttribute="width" relation="greaterThanOrEqual" constant="80" id="b0Y-Uh-CXP"/> + </constraints> + <fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="b14-QX-4ea" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="20" id="P4l-8Z-ZNT"/> + <constraint firstAttribute="trailing" secondItem="mQ0-DH-hTW" secondAttribute="trailing" id="XIg-vD-05h"/> + <constraint firstAttribute="bottom" secondItem="mQ0-DH-hTW" secondAttribute="bottom" constant="30" id="aLq-Bg-Bfl"/> + <constraint firstItem="b14-QX-4ea" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="20" id="kG8-NH-vz0"/> + <constraint firstItem="mQ0-DH-hTW" firstAttribute="top" secondItem="b14-QX-4ea" secondAttribute="bottom" constant="20" id="mCX-ZL-0ie"/> + <constraint firstItem="mQ0-DH-hTW" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" id="yp2-Zm-WoY"/> + </constraints> + </tableViewCellContentView> + <viewLayoutGuide key="safeArea" id="njF-e1-oar"/> + <connections> + <outlet property="categoryCaptionLabel" destination="ujI-Bw-5eP" id="WIy-Lh-Ats"/> + <outlet property="categoryTitle" destination="3pe-2p-9as" id="iIk-5g-rMD"/> + <outlet property="collectionView" destination="mQ0-DH-hTW" id="E99-YA-UbE"/> + <outlet property="collectionViewHeight" destination="iK4-y8-V36" id="Zzr-4L-Fr9"/> + </connections> + <point key="canvasLocation" x="158" y="152"/> + </tableViewCell> + </objects> + <resources> + <systemColor name="secondaryLabelColor"> + <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/FilterableCategoriesViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/FilterableCategoriesViewController.swift new file mode 100644 index 000000000000..4e256d43d4d0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/FilterableCategoriesViewController.swift @@ -0,0 +1,304 @@ +import UIKit +import Gridicons +import Gutenberg + +class FilterableCategoriesViewController: CollapsableHeaderViewController { + private enum CategoryFilterAnalyticsKeys { + static let modifiedFilter = "filter" + static let selectedFilters = "selected_filters" + static let location = "location" + } + + typealias PreviewDevice = PreviewDeviceSelectionViewController.PreviewDevice + let tableView: UITableView + private lazy var debounceSelectionChange: Debouncer = { + Debouncer(delay: 0.1) { [weak self] in + guard let `self` = self else { return } + self.itemSelectionChanged(self.selectedItem != nil) + } + }() + internal var selectedItem: IndexPath? = nil { + didSet { + debounceSelectionChange.call() + } + } + private let filterBar: CollapsableHeaderFilterBar + + internal var categorySections: [CategorySection] { get { + fatalError("This should be overridden by the subclass to provide a conforming collection of categories") + } } + + private var filteredSections: [CategorySection]? + private var visibleSections: [CategorySection] { filteredSections ?? categorySections } + + /// Dictionary to store horizontal scroll position of sections, keyed by category slug + private var sectionHorizontalOffsets: [String: CGFloat] = [:] + + /// Should be overidden if a subclass uses different sized thumbnails. + var ghostThumbnailSize: CGSize { + return CategorySectionTableViewCell.defaultThumbnailSize + } + + internal var isLoading: Bool = true { + didSet { + if isLoading { + tableView.startGhostAnimation(style: GhostCellStyle.muriel) + } else { + tableView.stopGhostAnimation() + } + + loadingStateChanged(isLoading) + tableView.reloadData() + } + } + + var selectedPreviewDevice = PreviewDevice.default { + didSet { + tableView.reloadData() + } + } + + let analyticsLocation: String + init( + analyticsLocation: String, + mainTitle: String, + prompt: String? = nil, + primaryActionTitle: String, + secondaryActionTitle: String? = nil, + defaultActionTitle: String? = nil + ) { + self.analyticsLocation = analyticsLocation + tableView = UITableView(frame: .zero, style: .plain) + tableView.separatorStyle = .singleLine + tableView.separatorInset = .zero + tableView.showsVerticalScrollIndicator = false + + filterBar = CollapsableHeaderFilterBar() + super.init(scrollableView: tableView, + mainTitle: mainTitle, + prompt: prompt, + primaryActionTitle: primaryActionTitle, + secondaryActionTitle: secondaryActionTitle, + defaultActionTitle: defaultActionTitle, + accessoryView: filterBar) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(CategorySectionTableViewCell.nib, forCellReuseIdentifier: CategorySectionTableViewCell.cellReuseIdentifier) + filterBar.filterDelegate = self + tableView.dataSource = self + configureCloseButton() + } + + private func configureCloseButton() { + navigationItem.rightBarButtonItem = CollapsableHeaderViewController.closeButton(target: self, action: #selector(closeButtonTapped)) + } + + @objc func closeButtonTapped(_ sender: Any) { + dismiss(animated: true) + } + + override func estimatedContentSize() -> CGSize { + let height = calculateContentHeight() + return CGSize(width: tableView.contentSize.width, height: height) + } + + private func calculateContentHeight() -> CGFloat { + guard !isLoading, visibleSections.count > 0 else { + return ghostThumbnailSize.height + CategorySectionTableViewCell.cellVerticalPadding + } + + return visibleSections + .map { $0.thumbnailSize.height + CategorySectionTableViewCell.cellVerticalPadding } + .reduce(0, +) + } + + public func loadingStateChanged(_ isLoading: Bool) { + filterBar.shouldShowGhostContent = isLoading + filterBar.allowsMultipleSelection = !isLoading + filterBar.reloadData() + } +} + +// MARK: - UITableViewDataSource + +extension FilterableCategoriesViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return isLoading ? 1 : (visibleSections.count) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cellReuseIdentifier = CategorySectionTableViewCell.cellReuseIdentifier + guard let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath) as? CategorySectionTableViewCell else { + fatalError("Expected the cell with identifier \"\(cellReuseIdentifier)\" to be a \(CategorySectionTableViewCell.self). Please make sure the table view is registering the correct nib before loading the data") + } + cell.delegate = self + cell.selectionStyle = UITableViewCell.SelectionStyle.none + + if isLoading { + cell.section = nil + cell.isGhostCell = true + cell.ghostThumbnailSize = ghostThumbnailSize + cell.collectionView.allowsSelection = false + } else { + let section = visibleSections[indexPath.row] + cell.section = section + cell.isGhostCell = false + cell.collectionView.allowsSelection = true + cell.horizontalScrollOffset = sectionHorizontalOffsets[section.categorySlug] ?? .zero + } + + cell.layer.masksToBounds = false + cell.clipsToBounds = false + if let selectedItem = selectedItem, containsSelectedItem(selectedItem, atIndexPath: indexPath) { + cell.selectItemAt(selectedItem.item) + } + + return cell + } + + private func containsSelectedItem(_ selectedIndexPath: IndexPath, atIndexPath indexPath: IndexPath) -> Bool { + let rowSection = visibleSections[indexPath.row] + let sectionSlug = categorySections[selectedIndexPath.section].categorySlug + return (sectionSlug == rowSection.categorySlug) + } +} + +// MARK: - CategorySectionTableViewCellDelegate + +extension FilterableCategoriesViewController: CategorySectionTableViewCellDelegate { + func didSelectItemAt(_ position: Int, forCell cell: CategorySectionTableViewCell, slug: String) { + guard let cellIndexPath = tableView.indexPath(for: cell), + let sectionIndex = categorySections.firstIndex(where: { $0.categorySlug == slug }) + else { return } + + tableView.selectRow(at: cellIndexPath, animated: false, scrollPosition: .none) + deselectCurrentLayout() + selectedItem = IndexPath(item: position, section: sectionIndex) + } + + func didDeselectItem(forCell cell: CategorySectionTableViewCell) { + selectedItem = nil + } + + func accessibilityElementDidBecomeFocused(forCell cell: CategorySectionTableViewCell) { + guard UIAccessibility.isVoiceOverRunning, let cellIndexPath = tableView.indexPath(for: cell) else { return } + tableView.scrollToRow(at: cellIndexPath, at: .middle, animated: true) + } + + func saveHorizontalScrollPosition(forCell cell: CategorySectionTableViewCell, xPosition: CGFloat) { + guard let cellSection = cell.section else { + return + } + + sectionHorizontalOffsets[cellSection.categorySlug] = xPosition + } + + private func deselectCurrentLayout() { + guard let previousSelection = selectedItem else { return } + + tableView.indexPathsForVisibleRows?.forEach { (indexPath) in + if containsSelectedItem(previousSelection, atIndexPath: indexPath) { + (tableView.cellForRow(at: indexPath) as? CategorySectionTableViewCell)?.deselectItems() + } + } + } +} + +// MARK: - CollapsableHeaderFilterBarDelegate + +extension FilterableCategoriesViewController: CollapsableHeaderFilterBarDelegate { + func numberOfFilters() -> Int { + return categorySections.count + } + + func filter(forIndex index: Int) -> CategorySection { + return categorySections[index] + } + + func didSelectFilter(withIndex selectedIndex: IndexPath, withSelectedIndexes selectedIndexes: [IndexPath]) { + trackFiltersChangedEvent(isSelectionEvent: true, changedIndex: selectedIndex, selectedIndexes: selectedIndexes) + guard filteredSections == nil else { + insertFilterRow(withIndex: selectedIndex, withSelectedIndexes: selectedIndexes) + return + } + + let rowsToRemove = (0..<categorySections.count).compactMap { ($0 == selectedIndex.item) ? nil : IndexPath(row: $0, section: 0) } + + filteredSections = [categorySections[selectedIndex.item]] + tableView.performBatchUpdates({ + contentSizeWillChange() + tableView.deleteRows(at: rowsToRemove, with: .fade) + }) + } + + func insertFilterRow(withIndex selectedIndex: IndexPath, withSelectedIndexes selectedIndexes: [IndexPath]) { + let sortedIndexes = selectedIndexes.sorted(by: { $0.item < $1.item }) + for i in 0..<sortedIndexes.count { + if sortedIndexes[i].item == selectedIndex.item { + filteredSections?.insert(categorySections[selectedIndex.item], at: i) + break + } + } + + tableView.performBatchUpdates({ + if selectedIndexes.count == 2 { + contentSizeWillChange() + } + tableView.reloadSections([0], with: .automatic) + }) + } + + func didDeselectFilter(withIndex index: IndexPath, withSelectedIndexes selectedIndexes: [IndexPath]) { + trackFiltersChangedEvent(isSelectionEvent: false, changedIndex: index, selectedIndexes: selectedIndexes) + guard selectedIndexes.count == 0 else { + removeFilterRow(withIndex: index) + return + } + + filteredSections = nil + tableView.performBatchUpdates({ + contentSizeWillChange() + tableView.reloadSections([0], with: .fade) + }) + } + + func trackFiltersChangedEvent(isSelectionEvent: Bool, changedIndex: IndexPath, selectedIndexes: [IndexPath]) { + let event: WPAnalyticsEvent = isSelectionEvent ? .categoryFilterSelected : .categoryFilterDeselected + let filter = categorySections[changedIndex.item].categorySlug + let selectedFilters = selectedIndexes.map({ categorySections[$0.item].categorySlug }).joined(separator: ", ") + + WPAnalytics.track(event, properties: [ + CategoryFilterAnalyticsKeys.location: analyticsLocation, + CategoryFilterAnalyticsKeys.modifiedFilter: filter, + CategoryFilterAnalyticsKeys.selectedFilters: selectedFilters + ]) + } + + func removeFilterRow(withIndex index: IndexPath) { + guard let filteredSections = filteredSections else { return } + + var row: IndexPath? = nil + let rowSlug = categorySections[index.item].categorySlug + for i in 0..<filteredSections.count { + if filteredSections[i].categorySlug == rowSlug { + let indexPath = IndexPath(row: i, section: 0) + self.filteredSections?.remove(at: i) + row = indexPath + break + } + } + + guard let rowToRemove = row else { return } + tableView.performBatchUpdates({ + contentSizeWillChange() + tableView.deleteRows(at: [rowToRemove], with: .fade) + }) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/GutenbergLayoutPickerViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/GutenbergLayoutPickerViewController.swift new file mode 100644 index 000000000000..7a16663ed707 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/GutenbergLayoutPickerViewController.swift @@ -0,0 +1,208 @@ +import UIKit +import Gridicons +import Gutenberg + +extension PageTemplateLayout: Thumbnail { + var urlDesktop: String? { preview } + var urlTablet: String? { previewTablet } + var urlMobile: String? { previewMobile } +} + +class GutenbergLayoutSection: CategorySection { + var caption: String? + + var section: PageTemplateCategory + var layouts: [PageTemplateLayout] + var thumbnailSize: CGSize + + var title: String { section.title } + var emoji: String? { section.emoji } + var categorySlug: String { section.slug } + var description: String? { section.desc } + var thumbnails: [Thumbnail] { layouts } + + init(_ section: PageTemplateCategory, thumbnailSize: CGSize) { + let layouts = Array(section.layouts ?? []).sorted() + self.section = section + self.layouts = layouts + self.thumbnailSize = thumbnailSize + } +} + +class GutenbergLayoutPickerViewController: FilterableCategoriesViewController { + private var sections: [GutenbergLayoutSection] = [] + internal override var categorySections: [CategorySection] { get { sections }} + lazy var resultsController: NSFetchedResultsController<PageTemplateCategory> = { + let resultsController = PageLayoutService.resultsController(forBlog: blog, delegate: self) + sections = makeSectionData(with: resultsController) + return resultsController + }() + + let completion: PageCoordinator.TemplateSelectionCompletion + let blog: Blog + var previewDeviceButtonItem: UIBarButtonItem? + + static let thumbnailSize = CGSize(width: 160, height: 240) + + override var ghostThumbnailSize: CGSize { + return GutenbergLayoutPickerViewController.thumbnailSize + } + + init(blog: Blog, completion: @escaping PageCoordinator.TemplateSelectionCompletion) { + self.blog = blog + self.completion = completion + + super.init( + analyticsLocation: "page_picker", + mainTitle: NSLocalizedString("Choose a Layout", comment: "Title for the screen to pick a template for a page"), + prompt: NSLocalizedString("Get started by choosing from a wide variety of pre-made page layouts. Or just start with a blank page.", comment: "Prompt for the screen to pick a template for a page"), + primaryActionTitle: NSLocalizedString("Create Page", comment: "Title for button to make a page with the contents of the selected layout"), + secondaryActionTitle: NSLocalizedString("Preview", comment: "Title for button to preview a selected layout"), + defaultActionTitle: NSLocalizedString("Create Blank Page", comment: "Title for button to make a blank page") + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.backButtonTitle = NSLocalizedString("Choose layout", comment: "Shortened version of the main title to be used in back navigation") + fetchLayouts() + configureCloseButton() + configurePreviewDeviceButton() + } + + private func configureCloseButton() { + navigationItem.leftBarButtonItem = CollapsableHeaderViewController.closeButton(target: self, action: #selector(closeButtonTapped)) + } + + private func configurePreviewDeviceButton() { + let button = UIBarButtonItem(image: UIImage(named: "icon-devices"), style: .plain, target: self, action: #selector(previewDeviceButtonTapped)) + previewDeviceButtonItem = button + navigationItem.rightBarButtonItem = button + } + + @objc private func previewDeviceButtonTapped() { + LayoutPickerAnalyticsEvent.thumbnailModeButtonTapped(selectedPreviewDevice) + let popoverContentController = PreviewDeviceSelectionViewController() + popoverContentController.selectedOption = selectedPreviewDevice + popoverContentController.onDeviceChange = { [weak self] device in + guard let self = self else { return } + LayoutPickerAnalyticsEvent.previewModeChanged(device) + self.selectedPreviewDevice = device + } + + popoverContentController.modalPresentationStyle = .popover + popoverContentController.popoverPresentationController?.delegate = self + self.present(popoverContentController, animated: true, completion: nil) + } + + private func presentPreview() { + guard let sectionIndex = selectedItem?.section, let position = selectedItem?.item else { return } + let layout = sections[sectionIndex].layouts[position] + let destination = LayoutPreviewViewController(layout: layout, selectedPreviewDevice: selectedPreviewDevice, onDismissWithDeviceSelected: { [weak self] device in + self?.selectedPreviewDevice = device + }, completion: completion) + navigationController?.pushViewController(destination, animated: true) + } + + private func createPage(layout: PageTemplateLayout?) { + dismiss(animated: true) { + self.completion(layout) + } + } + + private func fetchLayouts() { + isLoading = resultsController.isEmpty() + PageLayoutService.fetchLayouts(forBlog: blog, withThumbnailSize: GutenbergLayoutPickerViewController.thumbnailSize) { [weak self] result in + DispatchQueue.main.async { + switch result { + case .success: + self?.dismissNoResultsController() + case .failure(let error): + self?.handleErrors(error) + } + } + } + } + + private func handleErrors(_ error: Error) { + guard resultsController.isEmpty() else { return } + isLoading = false + let titleText = NSLocalizedString("Unable to load this content right now.", comment: "Informing the user that a network request failed becuase the device wasn't able to establish a network connection.") + let subtitleText = NSLocalizedString("Check your network connection and try again or create a blank page.", comment: "Default subtitle for no-results when there is no connection with a prompt to create a new page instead.") + displayNoResultsController(title: titleText, subtitle: subtitleText, resultsDelegate: self) + } + + private func makeSectionData(with controller: NSFetchedResultsController<PageTemplateCategory>?) -> [GutenbergLayoutSection] { + return controller?.fetchedObjects?.map({ (category) -> GutenbergLayoutSection in + return GutenbergLayoutSection(category, thumbnailSize: GutenbergLayoutPickerViewController.thumbnailSize) + }) ?? [] + } + + + // MARK: - Footer Actions + override func defaultActionSelected(_ sender: Any) { + createPage(layout: nil) + } + + override func primaryActionSelected(_ sender: Any) { + guard let sectionIndex = selectedItem?.section, let position = selectedItem?.item else { + createPage(layout: nil) + return + } + + let layout = sections[sectionIndex].layouts[position] + LayoutPickerAnalyticsEvent.templateApplied(layout) + createPage(layout: layout) + } + + override func secondaryActionSelected(_ sender: Any) { + presentPreview() + } +} + +extension GutenbergLayoutPickerViewController: NSFetchedResultsControllerDelegate { + + func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) { + sections = makeSectionData(with: resultsController) + isLoading = resultsController.isEmpty() + contentSizeWillChange() + tableView.reloadData() + } +} + +extension GutenbergLayoutPickerViewController: NoResultsViewControllerDelegate { + func actionButtonPressed() { + fetchLayouts() + } +} + +// MARK: UIPopoverPresentationDelegate +extension GutenbergLayoutPickerViewController: UIPopoverPresentationControllerDelegate { + func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) { + guard popoverPresentationController.presentedViewController is PreviewDeviceSelectionViewController else { + return + } + + popoverPresentationController.permittedArrowDirections = .up + popoverPresentationController.barButtonItem = previewDeviceButtonItem + } + + func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return .none + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + // Reset our source rect and view for a transition to a new size + guard let popoverPresentationController = presentedViewController?.presentationController as? UIPopoverPresentationController else { + return + } + + prepareForPopoverPresentation(popoverPresentationController) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/GutenbergLightNavigationController.swift b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/GutenbergLightNavigationController.swift new file mode 100644 index 000000000000..b7b254f7dd1d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/GutenbergLightNavigationController.swift @@ -0,0 +1,36 @@ +import UIKit + +class GutenbergLightNavigationController: UINavigationController { + + var separatorColor: UIColor { + return .separator + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .default + } + + override func viewDidLoad() { + super.viewDidLoad() + let appearance = UINavigationBarAppearance() + appearance.backgroundColor = .systemBackground + appearance.shadowColor = separatorColor + navigationBar.scrollEdgeAppearance = appearance + navigationBar.standardAppearance = appearance + navigationBar.barStyle = .default + navigationBar.barTintColor = .white + + let tintColor = UIColor.lightAppBarTint + let barButtonItemAppearance = UIBarButtonItem.appearance(whenContainedInInstancesOf: [GutenbergLightNavigationController.self]) + barButtonItemAppearance.tintColor = tintColor + barButtonItemAppearance.setTitleTextAttributes([NSAttributedString.Key.font: WPFontManager.systemRegularFont(ofSize: 17.0), + NSAttributedString.Key.foregroundColor: tintColor], + for: .normal) + barButtonItemAppearance.setTitleTextAttributes([NSAttributedString.Key.font: WPFontManager.systemRegularFont(ofSize: 17.0), + NSAttributedString.Key.foregroundColor: tintColor.withAlphaComponent(0.25)], + for: .disabled) + + + setNeedsStatusBarAppearanceUpdate() + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/LayoutPickerAnalyticsEvent.swift b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/LayoutPickerAnalyticsEvent.swift new file mode 100644 index 000000000000..28524cc6e8af --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/LayoutPickerAnalyticsEvent.swift @@ -0,0 +1,60 @@ +import Foundation + +class LayoutPickerAnalyticsEvent { + typealias PreviewDevice = PreviewDeviceSelectionViewController.PreviewDevice + + private static let templateTrackingKey = "template" + private static let errorTrackingKey = "error" + private static let previewModeTrackingKey = "preview_mode" + + static func previewErrorShown(_ template: PageTemplateLayout, _ error: Error) { + WPAnalytics.track(.layoutPickerPreviewErrorShown, withProperties: commonProperties(template, error)) + } + + static func previewLoaded(_ device: PreviewDevice, _ template: PageTemplateLayout) { + WPAnalytics.track(.layoutPickerPreviewLoaded, withProperties: commonProperties(device, template)) + } + + static func previewLoading(_ device: PreviewDevice, _ template: PageTemplateLayout) { + WPAnalytics.track(.layoutPickerPreviewLoading, withProperties: commonProperties(device, template)) + } + + static func previewModeButtonTapped(_ device: PreviewDevice, _ template: PageTemplateLayout) { + WPAnalytics.track(.layoutPickerPreviewModeButtonTapped, withProperties: commonProperties(device, template)) + } + + static func previewModeChanged(_ device: PreviewDevice, _ template: PageTemplateLayout? = nil) { + WPAnalytics.track(.layoutPickerPreviewModeChanged, withProperties: commonProperties(device, template)) + } + + static func previewViewed(_ device: PreviewDevice, _ template: PageTemplateLayout) { + WPAnalytics.track(.layoutPickerPreviewViewed, withProperties: commonProperties(device, template)) + } + + static func thumbnailModeButtonTapped(_ device: PreviewDevice) { + WPAnalytics.track(.layoutPickerThumbnailModeButtonTapped, withProperties: commonProperties(device)) + } + + static func templateApplied(_ template: PageTemplateLayout) { + WPAnalytics.track(.editorSessionTemplateApply, withProperties: commonProperties(template)) + } + + // MARK: - Common + private static func commonProperties(_ properties: Any?...) -> [AnyHashable: Any] { + var result: [AnyHashable: Any] = [:] + + for property: Any? in properties { + if let template = property as? PageTemplateLayout { + result.merge([templateTrackingKey: template.slug]) { (_, new) in new } + } + if let previewMode = property as? PreviewDevice { + result.merge([previewModeTrackingKey: previewMode.rawValue]) { (_, new) in new } + } + if let error = property as? Error { + result.merge([errorTrackingKey: error]) { (_, new) in new } + } + } + + return result + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/LayoutPreviewViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/LayoutPreviewViewController.swift new file mode 100644 index 000000000000..97999a027680 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Layout Picker/LayoutPreviewViewController.swift @@ -0,0 +1,54 @@ +import UIKit + +class LayoutPreviewViewController: TemplatePreviewViewController { + let completion: PageCoordinator.TemplateSelectionCompletion + let layout: PageTemplateLayout + + init(layout: PageTemplateLayout, selectedPreviewDevice: PreviewDevice?, onDismissWithDeviceSelected: ((PreviewDevice) -> ())?, completion: @escaping PageCoordinator.TemplateSelectionCompletion) { + self.layout = layout + self.completion = completion + super.init(demoURL: layout.demoUrl, selectedPreviewDevice: selectedPreviewDevice, onDismissWithDeviceSelected: onDismissWithDeviceSelected) + delegate = self + title = NSLocalizedString("Preview", comment: "Title for screen to preview a static content.") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + primaryActionButton.setTitle(NSLocalizedString("Create Page", comment: "Button for selecting the current page template."), for: .normal) + } +} + +extension LayoutPreviewViewController: TemplatePreviewViewDelegate { + func deviceButtonTapped(_ previewDevice: PreviewDevice) { + LayoutPickerAnalyticsEvent.previewModeButtonTapped(previewDevice, layout) + } + + func deviceModeChanged(_ previewDevice: PreviewDevice) { + LayoutPickerAnalyticsEvent.previewModeChanged(previewDevice, layout) + } + + func previewError(_ error: Error) { + LayoutPickerAnalyticsEvent.previewErrorShown(layout, error) + } + + func previewViewed() { + LayoutPickerAnalyticsEvent.previewViewed(selectedPreviewDevice, layout) + } + + func previewLoading() { + LayoutPickerAnalyticsEvent.previewLoading(selectedPreviewDevice, layout) + } + + func previewLoaded() { + LayoutPickerAnalyticsEvent.previewLoaded(selectedPreviewDevice, layout) + } + + func templatePicked() { + LayoutPickerAnalyticsEvent.templateApplied(layout) + completion(layout) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergAudioUploadProcessor.swift b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergAudioUploadProcessor.swift new file mode 100644 index 000000000000..d3db038c0c6a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergAudioUploadProcessor.swift @@ -0,0 +1,54 @@ +import Foundation +import Aztec + +class GutenbergAudioUploadProcessor: Processor { + private struct AudioBlockKeys { + static let name = "wp:audio" + static let id = "id" + static let src = "src" + } + + let mediaUploadID: Int32 + let remoteURLString: String + let serverMediaID: Int + + init(mediaUploadID: Int32, serverMediaID: Int, remoteURLString: String) { + self.mediaUploadID = mediaUploadID + self.serverMediaID = serverMediaID + self.remoteURLString = remoteURLString + } + + lazy var fileHtmlProcessor = HTMLProcessor(for: "audio", replacer: { (audio) in + var attributes = audio.attributes + + attributes.set(.string(self.remoteURLString), forKey: AudioBlockKeys.src) + + var html = "<audio " + let attributeSerializer = ShortcodeAttributeSerializer() + html += attributeSerializer.serialize(attributes) + html += "></audio>" + return html + }) + + lazy var fileBlockProcessor = GutenbergBlockProcessor(for: AudioBlockKeys.name, replacer: { fileBlock in + guard let mediaID = fileBlock.attributes[AudioBlockKeys.id] as? Int, + mediaID == self.mediaUploadID else { + return nil + } + var block = "<!-- \(AudioBlockKeys.name) " + var attributes = fileBlock.attributes + attributes[AudioBlockKeys.id] = self.serverMediaID + if let jsonData = try? JSONSerialization.data(withJSONObject: attributes, options: .sortedKeys), + let jsonString = String(data: jsonData, encoding: .utf8) { + block += jsonString + } + block += " -->" + block += self.fileHtmlProcessor.process(fileBlock.content) + block += "<!-- /\(AudioBlockKeys.name) -->" + return block + }) + + func process(_ text: String) -> String { + return fileBlockProcessor.process(text) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockProcessor.swift b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockProcessor.swift index 293d3e50604f..97f2c23742d4 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockProcessor.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockProcessor.swift @@ -17,37 +17,16 @@ public class GutenbergBlockProcessor: Processor { /// public typealias Replacer = (GutenbergBlock) -> String? - // MARK: - Basic Info - let name: String - // MARK: - Regex - private enum CaptureGroups: Int { case all = 0 case name case attributes - case content - static let allValues: [CaptureGroups] = [.all, .name, .attributes, .content] + static let allValues: [CaptureGroups] = [.all, .name, .attributes] } - /// Regular expression to detect attributes - /// Capture groups: - /// - /// 1. The block id - /// 2. The block attributes - /// 3. Block content - /// - private lazy var gutenbergBlockRegexProcessor: RegexProcessor = { [weak self]() in - let pattern = "\\<!--[ ]?(\(name))([\\s\\S]*?)-->([\\s\\S]*?)<!-- \\/\(name) -->" - let regex = try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) - - return RegexProcessor(regex: regex) { (match: NSTextCheckingResult, text: String) -> String? in - return self?.process(match: match, text: text) - } - }() - // MARK: - Parsing & processing properties private let replacer: Replacer @@ -58,36 +37,159 @@ public class GutenbergBlockProcessor: Processor { self.replacer = replacer } + /// Regular expression to detect attributes of the opening tag of a block + /// Capture groups: + /// + /// 1. The block id + /// 2. The block attributes + /// + var openTagRegex: NSRegularExpression { + let pattern = "\\<!--[ ]?(\(name))([\\s\\S]*?)\\/?-->" + return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) + } + + /// Regular expression to detect the closing tag of a block + /// + var closingTagRegex: NSRegularExpression { + let pattern = "\\<!-- \\/\(name) -->" + return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) + } + // MARK: - Processing + /// Processes the block and for any needed replacements from a given opening tag match. + /// - Parameters: + /// - text: The string that the following parameter is found in. + /// - Returns: The resulting string after the necessary replacements have occured + /// public func process(_ text: String) -> String { - return gutenbergBlockRegexProcessor.process(text) + let matches = openTagRegex.matches(in: text, options: [], range: text.utf16NSRange(from: text.startIndex ..< text.endIndex)) + var replacements = [(NSRange, String)]() + + var lastReplacementBound = 0 + for match in matches { + if match.range.lowerBound >= lastReplacementBound, let replacement = process(match, in: text) { + replacements.append(replacement) + lastReplacementBound = replacement.0.upperBound + } + } + let resultText = replace(replacements, in: text) + return resultText } -} + /// Replaces the + /// - Parameters: + /// - replacements: An array of tuples representing first a range of text that needs to be replaced then the string to replace + /// - text: The string to perform the replacements on + /// + func replace(_ replacements: [(NSRange, String)], in text: String) -> String { + let mutableString = NSMutableString(string: text) + var offset = 0 + for (range, replacement) in replacements { + let lengthBefore = mutableString.length + let offsetRange = NSRange(location: range.location + offset, length: range.length) + mutableString.replaceCharacters(in: offsetRange, with: replacement) + let lengthAfter = mutableString.length + offset += (lengthAfter - lengthBefore) + } + return mutableString as String + } +} // MARK: - Regex Match Processing Logic private extension GutenbergBlockProcessor { - /// Processes an Gutenberg block regex match. + /// Processes the block and for any needed replacements from a given opening tag match. + /// - Parameters: + /// - match: The match reperesenting an opening block tag + /// - text: The string that the following parameter is found in. + /// - Returns: Any necessary replacements within the provided string /// - func process(match: NSTextCheckingResult, text: String) -> String? { + private func process(_ match: NSTextCheckingResult, in text: String) -> (NSRange, String)? { + + var result: (NSRange, String)? = nil + if isSelfClosingTag(forMatch: match, in: text) { + let attributes = readAttributes(from: match, in: text) + let block = GutenbergBlock(name: name, attributes: attributes, content: "") + + if let replacement = replacer(block) { + result = (match.range, replacement) + } + } + else if let closingRange = locateClosingTag(forMatch: match, in: text) { + let attributes = readAttributes(from: match, in: text) + let content = readContent(from: match, withClosingRange: closingRange, in: text) + let parsedContent = process(content) // Recurrsively parse nested blocks and process those seperatly + let block = GutenbergBlock(name: name, attributes: attributes, content: parsedContent) + + if let replacement = replacer(block) { + let length = closingRange.upperBound - match.range.lowerBound + let range = NSRange(location: match.range.lowerBound, length: length) + result = (range, replacement) + } + } + + return result + } - guard match.numberOfRanges == CaptureGroups.allValues.count else { + /// Determines the location of the closing block tag for the matching open tag + /// - Parameters: + /// - openTag: The match reperesenting an opening block tag + /// - text: The string that the following parameter is found in. + /// - Returns: The Range of the closing tag for the block + /// + func locateClosingTag(forMatch openTag: NSTextCheckingResult, in text: String) -> NSRange? { + guard let index = text.indexFromLocation(openTag.range.upperBound) else { return nil } - let attributes = readAttributes(from: match, in: text) - let content = readContent(from: match, in: text) - let block = GutenbergBlock(name: name, attributes: attributes, content: content) + let matches = closingTagRegex.matches(in: text, options: [], range: text.utf16NSRange(from: index ..< text.endIndex)) + + for match in matches { + let content = readContent(from: openTag, withClosingRange: match.range, in: text) - return replacer(block) + if tagsAreBalanced(in: content) { + return match.range + } + } + + return nil } - // MARK: - Regex Match Processing Logic + /// Determines if the block tag is self-closing. + /// E.g.: <!-- wp:videopress/video {"guid":"AbCdE","id":100} /--> + /// - Parameters: + /// - openTag: The match reperesenting an opening block tag + /// - text: The string that the following parameter is found in. + /// - Returns: True if the block tag is self-closing. + func isSelfClosingTag(forMatch openTag: NSTextCheckingResult, in text: String) -> Bool { + guard let tagRange = Range(openTag.range, in: text) else { + return false + } + let tagSubstring = String(text[tagRange]) + return tagSubstring.hasSuffix("/-->") + } - /// Obtains the attributes from a block match. + /// Determines if there are an equal number of opening and closing block tags in the provided text. + /// - Parameters: + /// - text: The string to test assumes that a block with an even number represents a valid block sequence. + /// - Returns: A boolean where true represents an equal number of opening and closing block tags of the desired type /// - private func readAttributes(from match: NSTextCheckingResult, in text: String) -> [String: Any] { + func tagsAreBalanced(in text: String) -> Bool { + + let range = text.utf16NSRange(from: text.startIndex ..< text.endIndex) + let openTags = openTagRegex.matches(in: text, options: [], range: range) + let closingTags = closingTagRegex.matches(in: text, options: [], range: range) + + return openTags.count == closingTags.count + } + + /// Obtains the block attributes from a regex match. + /// - Parameters: + /// - match: The `NSTextCheckingResult` from a successful regex detection of an opening block tag + /// - text: The string that the following parameter is found in. + /// - Returns: A JSON dictionary of the block attributes + /// + func readAttributes(from match: NSTextCheckingResult, in text: String) -> [String: Any] { guard let attributesText = match.captureGroup(in: CaptureGroups.attributes.rawValue, text: text), let data = attributesText.data(using: .utf8 ), let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments), @@ -98,13 +200,22 @@ private extension GutenbergBlockProcessor { return jsonDictionary } - /// Obtains the block content from a block match. + /// Obtains the block content from a regex match and range. + /// - Parameters: + /// - match: The `NSTextCheckingResult` from a successful regex detection of an opening block tag + /// - closingRange: The `NSRange` of the closing block tag + /// - text: The string that the following parameters are found in. + /// - Returns: The content between the opening and closing tags of a block /// - private func readContent(from match: NSTextCheckingResult, in text: String) -> String { - guard let content = match.captureGroup(in: CaptureGroups.content.rawValue, text: text) else { + func readContent(from match: NSTextCheckingResult, withClosingRange closingRange: NSRange, in text: String) -> String { + guard let index = text.indexFromLocation(match.range.upperBound) else { + return "" + } + + guard let closingBound = text.indexFromLocation(closingRange.lowerBound) else { return "" } - return content + return String(text[index..<closingBound]) } } diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergCoverUploadProcessor.swift b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergCoverUploadProcessor.swift new file mode 100644 index 000000000000..3595651eaf25 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergCoverUploadProcessor.swift @@ -0,0 +1,146 @@ +import Foundation +import Aztec + +class GutenbergCoverUploadProcessor: Processor { + public typealias InnerBlockProcessor = (String) -> String? + + private struct CoverBlockKeys { + static let name = "wp:cover" + static let id = "id" + static let url = "url" + static let backgroundType = "backgroundType" + static let videoType = "video" + } + + let mediaUploadID: Int32 + let remoteURLString: String + let serverMediaID: Int + + init(mediaUploadID: Int32, serverMediaID: Int, remoteURLString: String) { + self.mediaUploadID = mediaUploadID + self.serverMediaID = serverMediaID + self.remoteURLString = remoteURLString + } + + lazy var coverBlockProcessor = GutenbergBlockProcessor(for: CoverBlockKeys.name, replacer: { coverBlock in + guard let mediaID = coverBlock.attributes[CoverBlockKeys.id] as? Int, + mediaID == self.mediaUploadID else { + return nil + } + var block = "<!-- \(CoverBlockKeys.name) " + + var attributes = coverBlock.attributes + attributes[CoverBlockKeys.id] = self.serverMediaID + attributes[CoverBlockKeys.url] = self.remoteURLString + + if let jsonData = try? JSONSerialization.data(withJSONObject: attributes, options: [.sortedKeys]), + let jsonString = String(data: jsonData, encoding: .utf8) { + block += jsonString + } + + let innerProcessor = self.isVideo(attributes) ? self.videoUploadProcessor() : self.imgUploadProcessor() + + block += " -->" + block += innerProcessor.process(coverBlock.content) + block += "<!-- /\(CoverBlockKeys.name) -->" + return block + }) + + func process(_ text: String) -> String { + return coverBlockProcessor.process(text) + } + + private func processInnerBlocks(_ outerBlock: GutenbergBlock) -> String { + var block = "<!-- wp:cover " + let attributes = outerBlock.attributes + + if let jsonData = try? JSONSerialization.data(withJSONObject: attributes, options: [.sortedKeys]), + let jsonString = String(data: jsonData, encoding: .utf8) { + block += jsonString + } + + block += " -->" + block += coverBlockProcessor.process(outerBlock.content) + block += "<!-- /wp:cover -->" + return block + } +} + +// Image Support +extension GutenbergCoverUploadProcessor { + private struct ImgHTMLKeys { + static let name = "div" + static let styleComponents = "style" + static let backgroundImage = "background-image:url" + } + + private func imgUploadProcessor() -> HTMLProcessor { + return HTMLProcessor(for: ImgHTMLKeys.name, replacer: { (div) in + + guard let styleAttributeValue = div.attributes[ImgHTMLKeys.styleComponents]?.value, + case let .string(styleAttribute) = styleAttributeValue + else { + return nil + } + + let range = styleAttribute.utf16NSRange(from: styleAttribute.startIndex ..< styleAttribute.endIndex) + let regex = self.localBackgroundImageRegex() + let matches = regex.matches(in: styleAttribute, + options: [], + range: range) + guard matches.count == 1 else { + return nil + } + + let style = "\(ImgHTMLKeys.backgroundImage)(\(self.remoteURLString))" + let updatedStyleAttribute = regex.stringByReplacingMatches(in: styleAttribute, + options: [], + range: range, + withTemplate: style) + + var attributes = div.attributes + attributes.set(.string(updatedStyleAttribute), forKey: ImgHTMLKeys.styleComponents) + + let attributeSerializer = ShortcodeAttributeSerializer() + var html = "<\(ImgHTMLKeys.name) " + html += attributeSerializer.serialize(attributes) + html += ">" + html += div.content ?? "" + html += "</\(ImgHTMLKeys.name)>" + return html + }) + } + + private func localBackgroundImageRegex() -> NSRegularExpression { + let pattern = "background-image:[ ]?url\\(file:\\/\\/\\/.*\\)" + return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) + } +} + +// Video Support +extension GutenbergCoverUploadProcessor { + private struct VideoHTMLKeys { + static let name = "video" + static let source = "src" + } + + private func isVideo(_ attributes: [String: Any]) -> Bool { + guard let backgroundType = attributes[CoverBlockKeys.backgroundType] as? String else { return false } + return backgroundType == CoverBlockKeys.videoType + } + + private func videoUploadProcessor() -> HTMLProcessor { + return HTMLProcessor(for: VideoHTMLKeys.name, replacer: { (video) in + var attributes = video.attributes + attributes.set(.string(self.remoteURLString), forKey: VideoHTMLKeys.source) + + let attributeSerializer = ShortcodeAttributeSerializer() + var html = "<\(VideoHTMLKeys.name) " + html += attributeSerializer.serialize(attributes) + html += ">" + html += video.content ?? "" + html += "</\(VideoHTMLKeys.name)>" + return html + }) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergFileUploadProcessor.swift b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergFileUploadProcessor.swift new file mode 100644 index 000000000000..d41cb2e7acec --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergFileUploadProcessor.swift @@ -0,0 +1,55 @@ +import Foundation +import Aztec + +class GutenbergFileUploadProcessor: Processor { + private struct FileBlockKeys { + static var name = "wp:file" + static var id = "id" + static var href = "href" + } + + let mediaUploadID: Int32 + let remoteURLString: String + let serverMediaID: Int + + init(mediaUploadID: Int32, serverMediaID: Int, remoteURLString: String) { + self.mediaUploadID = mediaUploadID + self.serverMediaID = serverMediaID + self.remoteURLString = remoteURLString + } + + lazy var fileHtmlProcessor = HTMLProcessor(for: "a", replacer: { (file) in + var attributes = file.attributes + + attributes.set(.string(self.remoteURLString), forKey: FileBlockKeys.href) + + var html = "<a " + let attributeSerializer = ShortcodeAttributeSerializer() + html += attributeSerializer.serialize(attributes) + html += ">\(file.content ?? "")</a>" + return html + }) + + lazy var fileBlockProcessor = GutenbergBlockProcessor(for: FileBlockKeys.name, replacer: { fileBlock in + guard let mediaID = fileBlock.attributes[FileBlockKeys.id] as? Int, + mediaID == self.mediaUploadID else { + return nil + } + var block = "<!-- \(FileBlockKeys.name) " + var attributes = fileBlock.attributes + attributes[FileBlockKeys.id] = self.serverMediaID + attributes[FileBlockKeys.href] = self.remoteURLString + if let jsonData = try? JSONSerialization.data(withJSONObject: attributes, options: .sortedKeys), + let jsonString = String(data: jsonData, encoding: .utf8) { + block += jsonString + } + block += " -->" + block += self.fileHtmlProcessor.process(fileBlock.content) + block += "<!-- /\(FileBlockKeys.name) -->" + return block + }) + + func process(_ text: String) -> String { + return fileBlockProcessor.process(text) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergGalleryUploadProcessor.swift b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergGalleryUploadProcessor.swift index 58f758eea276..bd1c2718f0b0 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergGalleryUploadProcessor.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergGalleryUploadProcessor.swift @@ -137,10 +137,10 @@ class GutenbergGalleryUploadProcessor: Processor { updatedBlock += jsonString } updatedBlock += " -->" - if let linkTo = block.attributes[GalleryBlockKeys.linkTo] as? String { - if linkTo == "media" { + if let linkTo = block.attributes[GalleryBlockKeys.linkTo] as? String, linkTo != "none" { + if linkTo == "file" { self.linkToURL = self.remoteURLString - } else if linkTo == "attachment" { + } else if linkTo == "post" { self.linkToURL = self.mediaLink } updatedBlock += self.linkPostMediaUploadProcessor.process(block.content) diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergMediaFilesUploadProcessor.swift b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergMediaFilesUploadProcessor.swift new file mode 100644 index 000000000000..0be2205104c5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergMediaFilesUploadProcessor.swift @@ -0,0 +1,76 @@ +import Foundation +import Aztec + +class GutenbergMediaFilesUploadProcessor: Processor { + private struct FileBlockKeys { + static var name = "wp:jetpack/story" + } + + let mediaUploadID: Int32 + let remoteURLString: String + let serverMediaID: Int + + init(mediaUploadID: Int32, serverMediaID: Int, remoteURLString: String) { + self.mediaUploadID = mediaUploadID + self.serverMediaID = serverMediaID + self.remoteURLString = remoteURLString + } + + lazy var mediaFilesProcessor = GutenbergBlockProcessor(for: FileBlockKeys.name, replacer: { block in + + guard let mediaFileAttributes = block.attributes["mediaFiles"] as? [[String: Any]] else { + return nil + } + let mediaFiles = mediaFileAttributes.compactMap { attributes in + return MediaFile.file(from: attributes) + } + + let media: [MediaFile] = mediaFiles.map { mediaFile -> MediaFile in + guard Int32(mediaFile.id) == self.mediaUploadID else { + return mediaFile + } + + guard let newURL = StoryPoster.filePath?.appendingPathComponent(String(self.serverMediaID)) else { + return mediaFile + } + + do { + if let mediaURL = URL(string: mediaFile.url), FileManager.default.fileExists(atPath: newURL.path) == false { + try FileManager.default.moveItem(at: mediaURL, to: newURL) + } else { + DDLogError("No Media File URL was present. This is a Stories error and should be investigated") + } + } catch let error { + assertionFailure("Failed to move archived file: \(mediaFile.url) to new location: \(newURL) - \(error)") + } + + let file = MediaFile(alt: mediaFile.alt, + caption: mediaFile.caption, + id: String(self.serverMediaID), + link: mediaFile.link, + mime: mediaFile.mime, + type: mediaFile.type, + url: self.remoteURLString) + return file + } + + let story = Story(mediaFiles: media) + + let encoder = JSONEncoder() + do { + let json = String(data: try encoder.encode(story), encoding: .utf8) + if let json = json { + return StoryBlock.wrap(json, includeFooter: true) + } else { + return nil + } + } catch let error { + assertionFailure("Encoding story failed: \(error)") + return nil + } + }) + + func process(_ text: String) -> String { + return mediaFilesProcessor.process(text) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergVideoPressUploadProcessor.swift b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergVideoPressUploadProcessor.swift new file mode 100644 index 000000000000..8c3440ab584f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergVideoPressUploadProcessor.swift @@ -0,0 +1,46 @@ +import Foundation +import Aztec + +class GutenbergVideoPressUploadProcessor: Processor { + + let mediaUploadID: Int32 + let serverMediaID: Int + let videoPressGUID: String + + private enum VideoPressBlockKeys: String { + case name = "wp:videopress/video" + case id + case guid + case src + } + + init(mediaUploadID: Int32, serverMediaID: Int, videoPressGUID: String) { + self.mediaUploadID = mediaUploadID + self.serverMediaID = serverMediaID + self.videoPressGUID = videoPressGUID + } + + lazy var videoPressBlockProcessor = GutenbergBlockProcessor(for: VideoPressBlockKeys.name.rawValue, replacer: { videoPressBlock in + guard let mediaID = videoPressBlock.attributes[VideoPressBlockKeys.id.rawValue] as? Int, mediaID == self.mediaUploadID else { + return nil + } + var block = "<!-- \(VideoPressBlockKeys.name.rawValue) " + var attributes = videoPressBlock.attributes + attributes[VideoPressBlockKeys.id.rawValue] = self.serverMediaID + attributes[VideoPressBlockKeys.guid.rawValue] = self.videoPressGUID + // Removing `src` attribute if it points to a local file. + if let srcAttribute = attributes[VideoPressBlockKeys.src.rawValue] as? String, srcAttribute.starts(with: "file:") { + attributes.removeValue(forKey: VideoPressBlockKeys.src.rawValue) + } + if let jsonData = try? JSONSerialization.data(withJSONObject: attributes, options: .sortedKeys), + let jsonString = String(data: jsonData, encoding: .utf8) { + block += jsonString + } + block += " /-->" + return block + }) + + func process(_ text: String) -> String { + return videoPressBlockProcessor.process(text) + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergFilesAppMediaSource.swift b/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergFilesAppMediaSource.swift index 295616e72ea2..c560254d6173 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergFilesAppMediaSource.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergFilesAppMediaSource.swift @@ -11,15 +11,22 @@ class GutenbergFilesAppMediaSource: NSObject { self.gutenberg = gutenberg } - func presentPicker(origin: UIViewController, filters: [Gutenberg.MediaType], multipleSelection: Bool, callback: @escaping MediaPickerDidPickMediaCallback) { - let uttypeFilters = filters.compactMap { $0.typeIdentifier } + func presentPicker(origin: UIViewController, filters: [Gutenberg.MediaType], allowedTypesOnBlog: [String], multipleSelection: Bool, callback: @escaping MediaPickerDidPickMediaCallback) { mediaPickerCallback = callback - let docPicker = UIDocumentPickerViewController(documentTypes: uttypeFilters, in: .import) + let documentTypes = getDocumentTypes(filters: filters, allowedTypesOnBlog: allowedTypesOnBlog) + let docPicker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import) docPicker.delegate = self docPicker.allowsMultipleSelection = multipleSelection - WPStyleGuide.configureDocumentPickerNavBarAppearance() origin.present(docPicker, animated: true) } + + private func getDocumentTypes(filters: [Gutenberg.MediaType], allowedTypesOnBlog: [String]) -> [String] { + if filters.contains(.any) { + return allowedTypesOnBlog + } else { + return filters.map { $0.filterTypesConformingTo(allTypes: allowedTypesOnBlog) }.reduce([], +) + } + } } extension GutenbergFilesAppMediaSource: UIDocumentPickerDelegate { @@ -27,46 +34,71 @@ extension GutenbergFilesAppMediaSource: UIDocumentPickerDelegate { defer { mediaPickerCallback = nil } - if let documentURL = urls.first { - insertOnBlock(with: documentURL) - } else { + if urls.count == 0 { mediaPickerCallback?(nil) + } else { + insertOnBlock(with: urls) } } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { - WPStyleGuide.configureNavigationAppearance() mediaPickerCallback?(nil) mediaPickerCallback = nil } - /// Adds the given image object to the requesting Image Block - /// - Parameter asset: Stock Media object to add. - func insertOnBlock(with url: URL) { - WPStyleGuide.configureNavigationAppearance() + func insertOnBlock(with urls: [URL]) { guard let callback = mediaPickerCallback else { return assertionFailure("Image picked without callback") } - guard let media = self.mediaInserter.insert(exportableAsset: url as NSURL, source: .otherApps) else { - return callback([]) - } + let mediaInfo = urls.compactMap({ (url) -> MediaInfo? in + guard let media = mediaInserter.insert(exportableAsset: url as NSURL, source: .otherApps) else { + return nil + } + let mediaUploadID = media.gutenbergUploadID + return MediaInfo(id: mediaUploadID, url: url.absoluteString, type: media.mediaTypeString, title: url.lastPathComponent) + }) - let mediaUploadID = media.gutenbergUploadID - callback([MediaInfo(id: mediaUploadID, url: url.absoluteString, type: media.mediaTypeString)]) + callback(mediaInfo) } } extension Gutenberg.MediaType { - var typeIdentifier: String? { + func filterTypesConformingTo(allTypes: [String]) -> [String] { + guard let uttype = typeIdentifier else { + return [] + } + return getTypesFrom(allTypes, conformingTo: uttype) + } + + private func getTypesFrom(_ allTypes: [String], conformingTo uttype: CFString) -> [String] { + guard let requiredType = UTType(uttype as String) else { + return [] + } + + return allTypes.filter { + guard let allowedType = UTType($0) else { + return false + } + // Sometimes the compared type could be a supertype + // For example a self-hosted site without Jetpack may have "public.content" as allowedType + // Although "public.audio" conforms to "public.content", it's not true the other way around + if allowedType.isSupertype(of: requiredType) { + return true + } + return allowedType.conforms(to: requiredType) + } + } + + private var typeIdentifier: CFString? { switch self { case .image: - return String(kUTTypeImage) + return kUTTypeImage case .video: - return String(kUTTypeMovie) + return kUTTypeMovie case .audio: - return String(kUTTypeAudio) - case .other: + return kUTTypeAudio + case .other, .any: // needs to be specified by the blog's allowed types. return nil } } diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergMediaEditorImage.swift b/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergMediaEditorImage.swift index 2f3cc16761ba..97d75e5cbefa 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergMediaEditorImage.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergMediaEditorImage.swift @@ -6,7 +6,7 @@ import MediaEditor We need the full high-quality image in the Media Editor. */ class GutenbergMediaEditorImage: AsyncImage { - private var tasks: [URLSessionDataTask] = [] + private var tasks: [ImageDownloaderTask] = [] private var originalURL: URL diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergStockPhotos.swift b/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergStockPhotos.swift index 19eee9b9e608..881486d66013 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergStockPhotos.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergStockPhotos.swift @@ -15,8 +15,7 @@ class GutenbergStockPhotos { func presentPicker(origin: UIViewController, post: AbstractPost, multipleSelection: Bool, callback: @escaping MediaPickerDidPickMediaCallback) { let picker = StockPhotosPicker() stockPhotos = picker - // Forcing multiple selection while multipleSelection == false in JS side. - picker.allowMultipleSelection = true //multipleSelection + picker.allowMultipleSelection = multipleSelection picker.delegate = self mediaPickerCallback = callback picker.presentPicker(origin: origin, blog: post.blog) @@ -61,7 +60,7 @@ extension GutenbergStockPhotos: StockPhotosPickerDelegate { } let mediaInfo = assets.compactMap({ (asset) -> MediaInfo? in - guard let media = self.mediaInserter.insert(exportableAsset: asset, source: .giphy) else { + guard let media = self.mediaInserter.insert(exportableAsset: asset, source: .stockPhotos) else { return nil } let mediaUploadID = media.gutenbergUploadID @@ -75,7 +74,7 @@ extension GutenbergStockPhotos: StockPhotosPickerDelegate { /// - Parameter assets: Stock Media objects to append. func appendOnNewBlocks(assets: ArraySlice<StockPhotosMedia>) { assets.forEach { - if let media = self.mediaInserter.insert(exportableAsset: $0, source: .giphy) { + if let media = self.mediaInserter.insert(exportableAsset: $0, source: .stockPhotos) { self.gutenberg.appendMedia(id: media.gutenbergUploadID, url: $0.URL, type: .image) } } diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergTenorMediaPicker.swift b/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergTenorMediaPicker.swift new file mode 100644 index 000000000000..d22b1c8ccd0f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Utils/GutenbergTenorMediaPicker.swift @@ -0,0 +1,81 @@ +import Gutenberg + +class GutenbergTenorMediaPicker { + private var tenor: TenorPicker? + private var mediaPickerCallback: MediaPickerDidPickMediaCallback? + private let mediaInserter: GutenbergMediaInserterHelper + private unowned var gutenberg: Gutenberg + private var multipleSelection = false + + init(gutenberg: Gutenberg, mediaInserter: GutenbergMediaInserterHelper) { + self.mediaInserter = mediaInserter + self.gutenberg = gutenberg + } + + func presentPicker(origin: UIViewController, post: AbstractPost, multipleSelection: Bool, callback: @escaping MediaPickerDidPickMediaCallback) { + let picker = TenorPicker() + tenor = picker + picker.allowMultipleSelection = true + picker.delegate = self + mediaPickerCallback = callback + picker.presentPicker(origin: origin, blog: post.blog) + self.multipleSelection = multipleSelection + } +} + +extension GutenbergTenorMediaPicker: TenorPickerDelegate { + func tenorPicker(_ picker: TenorPicker, didFinishPicking assets: [TenorMedia]) { + defer { + mediaPickerCallback = nil + tenor = nil + } + guard assets.isEmpty == false else { + mediaPickerCallback?(nil) + return + } + + // For blocks that support multiple uploads this will upload all images. + // If multiple uploads are not supported then it will seperate them out to Image Blocks. + multipleSelection ? insertOnBlock(with: assets) : insertSingleImages(assets) + } + + /// Adds the given image object to the requesting block and seperates multiple images to seperate image blocks + /// - Parameter asset: Tenor Media object to add. + func insertSingleImages(_ assets: [TenorMedia]) { + // Append the first item via callback given by Gutenberg. + if let firstItem = assets.first { + insertOnBlock(with: [firstItem]) + } + // Append the rest of images via `.appendMedia` event. + // Ideally we would send all picked images via the given callback, but that seems to not be possible yet. + appendOnNewBlocks(assets: assets.dropFirst()) + } + + /// Adds the given images to the requesting block + /// - Parameter assets: Tenor Media objects to add. + func insertOnBlock(with assets: [TenorMedia]) { + guard let callback = mediaPickerCallback else { + return assertionFailure("Image picked without callback") + } + + let mediaInfo = assets.compactMap { (asset) -> MediaInfo? in + guard let media = self.mediaInserter.insert(exportableAsset: asset, source: .tenor) else { + return nil + } + let mediaUploadID = media.gutenbergUploadID + return MediaInfo(id: mediaUploadID, url: asset.URL.absoluteString, type: media.mediaTypeString) + } + + callback(mediaInfo) + } + + /// Create a new image block for each of the image objects in the slice. + /// - Parameter assets: Tenor Media objects to append. + func appendOnNewBlocks(assets: ArraySlice<TenorMedia>) { + assets.forEach { + if let media = self.mediaInserter.insert(exportableAsset: $0, source: .tenor) { + self.gutenberg.appendMedia(id: media.gutenbergUploadID, url: $0.URL, type: .image) + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Views/GutenGhostView.swift b/WordPress/Classes/ViewRelated/Gutenberg/Views/GutenGhostView.swift new file mode 100644 index 000000000000..923489ee88c6 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Views/GutenGhostView.swift @@ -0,0 +1,103 @@ +import UIKit + +class GutenGhostView: UIView { + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + @IBOutlet var toolbarViews: [UIView]! + @IBOutlet weak var toolbarTopBorderView: UIView! { + didSet { + let isDarkStyle = traitCollection.userInterfaceStyle == .dark + toolbarTopBorderView.isHidden = isDarkStyle + } + } + + @IBOutlet var blockElementViews: [UIView]! { + didSet { + blockElementViews.forEach { (view) in + view.backgroundColor = .ghostBlockBackground + } + } + } + + + @IBOutlet var buttonsViews: [UIView]! { + didSet { + buttonsViews.forEach { (view) in + view.backgroundColor = .clear + } + } + } + + @IBOutlet weak private var inserterView: UIView! { + didSet { + inserterView.layer.cornerRadius = inserterView.frame.height / 2 + inserterView.clipsToBounds = true + } + } + + @IBOutlet private var roundedCornerViews: [UIView]! { + didSet { + roundedCornerViews.forEach { (view) in + view.layer.cornerRadius = 6 + view.clipsToBounds = true + } + } + } + + @IBOutlet weak var toolbarBackgroundView: UIView! { + didSet { + toolbarBackgroundView.isGhostableDisabled = true + toolbarBackgroundView.backgroundColor = .ghostToolbarBackground + } + } + + var hidesToolbar: Bool = false { + didSet { + toolbarViews.forEach({ $0.isHidden = hidesToolbar }) + } + } + + func startAnimation() { + let style = GhostStyle(beatDuration: GhostStyle.Defaults.beatDuration, + beatStartColor: .placeholderElement, + beatEndColor: .beatEndColor) + startGhostAnimation(style: style) + } + + private func commonInit() { + let bundle = Bundle(for: GutenGhostView.self) + guard + let nibViews = bundle.loadNibNamed("GutenGhostView", owner: self, options: nil), + let contentView = nibViews.first as? UIView + else { + return + } + + addSubview(contentView) + contentView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate( + contentView.constrainToSuperViewEdges() + ) + + backgroundColor = .background + } +} + +private extension UIColor { + static let ghostToolbarBackground = UIColor(light: .clear, dark: UIColor.colorFromHex("2e2e2e")) + + static let ghostBlockBackground = UIColor(light: .clear, dark: .systemGray5) + + static let beatEndColor = UIColor(light: .systemGray6, dark: .clear) + + static let background = UIColor.systemBackground +} diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Views/GutenGhostView.xib b/WordPress/Classes/ViewRelated/Gutenberg/Views/GutenGhostView.xib new file mode 100644 index 000000000000..0783a11443e9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Views/GutenGhostView.xib @@ -0,0 +1,154 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097.3" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="dark"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="GutenGhostView" customModule="WordPress" customModuleProvider="target"> + <connections> + <outlet property="inserterView" destination="Eqp-4y-Hel" id="MEb-tR-oCx"/> + <outlet property="toolbarBackgroundView" destination="eg8-Yp-3Ni" id="5ua-1N-tAL"/> + <outlet property="toolbarTopBorderView" destination="Mdc-HJ-BN8" id="okS-yz-jtZ"/> + <outletCollection property="buttonsViews" destination="Eqp-4y-Hel" collectionClass="NSMutableArray" id="R42-Zo-VDm"/> + <outletCollection property="buttonsViews" destination="bQO-Lm-zZX" collectionClass="NSMutableArray" id="mol-hX-eDs"/> + <outletCollection property="buttonsViews" destination="xnS-Q0-UCK" collectionClass="NSMutableArray" id="WcN-7j-deH"/> + <outletCollection property="buttonsViews" destination="cfI-qF-JzB" collectionClass="NSMutableArray" id="gg4-nG-GN6"/> + <outletCollection property="buttonsViews" destination="jdv-Rj-aI2" collectionClass="NSMutableArray" id="60l-e3-tNs"/> + <outletCollection property="buttonsViews" destination="jag-cW-Iwn" collectionClass="NSMutableArray" id="Uih-nO-uQr"/> + <outletCollection property="blockElementViews" destination="MVu-rY-vQW" collectionClass="NSMutableArray" id="pnp-Gk-Isa"/> + <outletCollection property="blockElementViews" destination="LjM-uo-73n" collectionClass="NSMutableArray" id="qHS-sD-g7g"/> + <outletCollection property="roundedCornerViews" destination="bQO-Lm-zZX" collectionClass="NSMutableArray" id="TGZ-OP-IiK"/> + <outletCollection property="roundedCornerViews" destination="xnS-Q0-UCK" collectionClass="NSMutableArray" id="yT0-WR-DNO"/> + <outletCollection property="roundedCornerViews" destination="cfI-qF-JzB" collectionClass="NSMutableArray" id="1XQ-Mb-cca"/> + <outletCollection property="roundedCornerViews" destination="jdv-Rj-aI2" collectionClass="NSMutableArray" id="8eN-kc-dty"/> + <outletCollection property="roundedCornerViews" destination="jag-cW-Iwn" collectionClass="NSMutableArray" id="oQu-4L-ZAn"/> + <outletCollection property="roundedCornerViews" destination="MVu-rY-vQW" collectionClass="NSMutableArray" id="4Jg-u9-Jud"/> + <outletCollection property="roundedCornerViews" destination="LjM-uo-73n" collectionClass="NSMutableArray" id="Kxh-VE-ghW"/> + <outletCollection property="toolbarViews" destination="eg8-Yp-3Ni" collectionClass="NSMutableArray" id="5bC-yK-6PE"/> + <outletCollection property="toolbarViews" destination="5nZ-Cv-Fn8" collectionClass="NSMutableArray" id="G2q-w8-ge0"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="iN0-l3-epB"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" axis="vertical" distribution="equalCentering" alignment="top" spacing="38" translatesAutoresizingMaskIntoConstraints="NO" id="8FS-n0-wtp"> + <rect key="frame" x="0.0" y="86" width="414" height="78"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="MVu-rY-vQW"> + <rect key="frame" x="16" y="0.0" width="290" height="24"/> + <color key="backgroundColor" systemColor="separatorColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="height" constant="24" id="LuV-he-EHs"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LjM-uo-73n"> + <rect key="frame" x="16" y="62" width="165.5" height="16"/> + <color key="backgroundColor" systemColor="separatorColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="height" constant="16" id="ff0-Hi-mOG"/> + </constraints> + </view> + </subviews> + <constraints> + <constraint firstAttribute="width" relation="lessThanOrEqual" constant="580" id="61O-Rg-RQ7"/> + <constraint firstItem="MVu-rY-vQW" firstAttribute="width" secondItem="8FS-n0-wtp" secondAttribute="width" multiplier="0.7" id="e9y-mE-WOa"/> + <constraint firstItem="LjM-uo-73n" firstAttribute="width" secondItem="8FS-n0-wtp" secondAttribute="width" multiplier="0.4" id="xGQ-bh-akx"/> + </constraints> + <edgeInsets key="layoutMargins" top="0.0" left="16" bottom="0.0" right="16"/> + </stackView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="eg8-Yp-3Ni"> + <rect key="frame" x="0.0" y="818" width="414" height="78"/> + <color key="backgroundColor" systemColor="systemGray2Color" red="0.68235294120000001" green="0.68235294120000001" blue="0.69803921570000005" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </view> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="5nZ-Cv-Fn8"> + <rect key="frame" x="0.0" y="818" width="414" height="44"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Mdc-HJ-BN8"> + <rect key="frame" x="0.0" y="0.0" width="414" height="1"/> + <color key="backgroundColor" systemColor="separatorColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="height" constant="1" id="wf7-PC-9Ev"/> + </constraints> + </view> + <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="rsO-hZ-bca"> + <rect key="frame" x="0.0" y="1" width="414" height="43"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Eqp-4y-Hel"> + <rect key="frame" x="10" y="9.5" width="24" height="24"/> + <color key="backgroundColor" systemColor="separatorColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="width" constant="24" id="TkO-XK-xcB"/> + <constraint firstAttribute="height" constant="24" id="ikl-Jc-SOI"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="bQO-Lm-zZX"> + <rect key="frame" x="54" y="9.5" width="24" height="24"/> + <color key="backgroundColor" systemColor="separatorColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="width" constant="24" id="1BC-AI-125"/> + <constraint firstAttribute="height" constant="24" id="PoC-TX-1Oa"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="xnS-Q0-UCK"> + <rect key="frame" x="98" y="9.5" width="24" height="24"/> + <color key="backgroundColor" systemColor="separatorColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="width" constant="24" id="WPQ-M1-Jeg"/> + <constraint firstAttribute="height" constant="24" id="zbt-nt-N7Y"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="cfI-qF-JzB"> + <rect key="frame" x="142" y="9.5" width="24" height="24"/> + <color key="backgroundColor" systemColor="separatorColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="width" constant="24" id="1s4-yI-XW6"/> + <constraint firstAttribute="height" constant="24" id="BHU-Mt-PU5"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jdv-Rj-aI2"> + <rect key="frame" x="186" y="0.0" width="174" height="43"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jag-cW-Iwn"> + <rect key="frame" x="380" y="9.5" width="24" height="24"/> + <color key="backgroundColor" systemColor="separatorColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.28999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="height" constant="24" id="MyB-ZA-pNI"/> + <constraint firstAttribute="width" constant="24" id="sYx-mn-pJd"/> + </constraints> + </view> + </subviews> + <edgeInsets key="layoutMargins" top="0.0" left="10" bottom="0.0" right="10"/> + </stackView> + </subviews> + <constraints> + <constraint firstAttribute="height" constant="44" id="aJe-cR-uwf"/> + </constraints> + </stackView> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/> + <constraints> + <constraint firstItem="eg8-Yp-3Ni" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="2NR-iL-OLv"/> + <constraint firstItem="5nZ-Cv-Fn8" firstAttribute="bottom" secondItem="vUN-kp-3ea" secondAttribute="bottom" id="CJ0-ne-DFX"/> + <constraint firstItem="5nZ-Cv-Fn8" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="DR8-x9-dCJ"/> + <constraint firstItem="5nZ-Cv-Fn8" firstAttribute="trailing" secondItem="vUN-kp-3ea" secondAttribute="trailing" id="Eon-au-sm0"/> + <constraint firstItem="8FS-n0-wtp" firstAttribute="centerX" secondItem="iN0-l3-epB" secondAttribute="centerX" id="GHf-De-6Mc"/> + <constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="8FS-n0-wtp" secondAttribute="trailing" priority="999" id="Hkq-Cm-1A1"/> + <constraint firstAttribute="bottom" secondItem="eg8-Yp-3Ni" secondAttribute="bottom" id="SOo-Wv-gYE"/> + <constraint firstItem="eg8-Yp-3Ni" firstAttribute="trailing" secondItem="vUN-kp-3ea" secondAttribute="trailing" id="dMH-Bk-hck"/> + <constraint firstItem="8FS-n0-wtp" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" constant="42" id="fbx-S8-eNZ"/> + <constraint firstItem="8FS-n0-wtp" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" priority="999" id="x9e-vE-4i1"/> + <constraint firstItem="eg8-Yp-3Ni" firstAttribute="top" secondItem="5nZ-Cv-Fn8" secondAttribute="top" id="zYn-ZB-YT5"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> + <point key="canvasLocation" x="131.8840579710145" y="153.34821428571428"/> + </view> + </objects> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Badge/DashboardBadgeCell.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Badge/DashboardBadgeCell.swift new file mode 100644 index 000000000000..f6c9e9e61583 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Badge/DashboardBadgeCell.swift @@ -0,0 +1,52 @@ +import UIKit + +/// A collection view cell with a "Jetpack powered" badge +class DashboardBadgeCell: UICollectionViewCell, Reusable { + + private lazy var jetpackButton: JetpackButton = { + let textProvider = JetpackBrandingTextProvider(screen: JetpackBadgeScreen.home) + let button = JetpackButton(style: .badge, title: textProvider.brandingText()) + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(jetpackButtonTapped), for: .touchUpInside) + return button + }() + + private weak var presentingViewController: UIViewController? + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + contentView.backgroundColor = .listBackground + contentView.addSubview(jetpackButton) + NSLayoutConstraint.activate([ + jetpackButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + jetpackButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Self.badgeTopInset), + jetpackButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + + // takes into account the collection view cell spacing, which is 20 + // to obtain an overall distance of 30. + private static let badgeTopInset: CGFloat = 10 +} + +extension DashboardBadgeCell: BlogDashboardCardConfigurable { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + presentingViewController = viewController + } + + @objc private func jetpackButtonTapped() { + guard let viewController = presentingViewController else { + return + } + JetpackBrandingCoordinator.presentOverlay(from: viewController) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBadgeTapped(screen: .home) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JPScrollViewDelegate.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JPScrollViewDelegate.swift new file mode 100644 index 000000000000..911c90a8a014 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JPScrollViewDelegate.swift @@ -0,0 +1,22 @@ +import Combine +import CoreGraphics +import UIKit + +/// Conform to this protocol to send scrollview translations to a `JetpackBannerView` instance +protocol JPScrollViewDelegate: UIScrollViewDelegate { + + var scrollViewTranslationPublisher: PassthroughSubject<Bool, Never> { get } + func addTranslationObserver(_ receiver: JetpackBannerView) +} + +extension JPScrollViewDelegate { + + func addTranslationObserver(_ receiver: JetpackBannerView) { + scrollViewTranslationPublisher.subscribe(receiver) + } + + func processJetpackBannerVisibility(_ scrollView: UIScrollView) { + let shouldHide = JetpackBannerScrollVisibility.shouldHide(scrollView) + scrollViewTranslationPublisher.send(shouldHide) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JetpackBannerScrollVisibility.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JetpackBannerScrollVisibility.swift new file mode 100644 index 000000000000..4b6a4d5535ad --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JetpackBannerScrollVisibility.swift @@ -0,0 +1,30 @@ +import Foundation +import UIKit + +struct JetpackBannerScrollVisibility { + static func shouldHide(_ scrollView: UIScrollView) -> Bool { + return Self.shouldHide( + contentHeight: scrollView.contentSize.height, + /// We default to the superview's (UIStackView) frame height because it's unaffected by the banner's visibility, unlike the UIScrollView's frame. + /// This prevents a looping edge case where hiding the banner causes the content height to be less than the frame height, and vice versa. + frameHeight: scrollView.superview?.frame.height ?? scrollView.frame.height, + verticalContentOffset: scrollView.contentOffset.y + scrollView.adjustedContentInset.top + ) + } + + static func shouldHide( + contentHeight: CGFloat, + frameHeight: CGFloat, + verticalContentOffset: CGFloat + ) -> Bool { + /// The scrollable content isn't any larger than its frame, so don't hide the banner if the view is bounced. + if contentHeight <= frameHeight { + return false + /// Don't hide the banner until the view has scrolled down some. Currently the height of the banner itself. + } else if verticalContentOffset <= JetpackBannerView.minimumHeight { + return false + } + + return true + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JetpackBannerView.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JetpackBannerView.swift new file mode 100644 index 000000000000..57cd9e6f7a13 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JetpackBannerView.swift @@ -0,0 +1,88 @@ +import Combine +import UIKit + +class JetpackBannerView: UIView { + + // MARK: Private Variables + + private var button: JetpackButton? + private var buttonAction: (() -> Void)? + + // MARK: Initializers + + init() { + super.init(frame: .zero) + setup() + } + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setup() + } + + // MARK: Public Functions + + func configure(title: String, buttonAction: (() -> Void)?) { + self.button?.title = title + self.buttonAction = buttonAction + } + + // MARK: Private Helpers + + @objc private func jetpackButtonTapped() { + buttonAction?() + } + + private func setup() { + translatesAutoresizingMaskIntoConstraints = false + heightAnchor.constraint(equalToConstant: JetpackBannerView.minimumHeight).isActive = true + backgroundColor = Self.jetpackBannerBackgroundColor + let textProvider = JetpackBrandingTextProvider(screen: nil) // Use default text in case `configure()` is never called. + let jetpackButton = JetpackButton(style: .banner, title: textProvider.brandingText()) + jetpackButton.translatesAutoresizingMaskIntoConstraints = false + jetpackButton.addTarget(self, action: #selector(jetpackButtonTapped), for: .touchUpInside) + addSubview(jetpackButton) + self.button = jetpackButton + + pinSubviewToSafeArea(jetpackButton) + } + + // MARK: Constants + + /// Preferred minimum height to be used for constraints + static let minimumHeight: CGFloat = 44 + private static let jetpackBannerBackgroundColor = UIColor(light: .muriel(color: .jetpackGreen, .shade0), + dark: .muriel(color: .jetpackGreen, .shade90)) +} + +// MARK: Responding to scroll events +extension JetpackBannerView: Subscriber { + + typealias Input = Bool + typealias Failure = Never + + func receive(subscription: Subscription) { + subscription.request(.unlimited) + } + + func receive(_ input: Bool) -> Subscribers.Demand { + let isHidden = input + + guard self.isHidden != isHidden else { + return .unlimited + } + + UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseIn], animations: { + self.isHidden = isHidden + self.superview?.layoutIfNeeded() + }, completion: nil) + return .unlimited + } + + func receive(completion: Subscribers.Completion<Never>) {} +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JetpackBannerWrapperViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JetpackBannerWrapperViewController.swift new file mode 100644 index 000000000000..2da067b8af5f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Banner/JetpackBannerWrapperViewController.swift @@ -0,0 +1,80 @@ +import Foundation +import Combine +import UIKit +import WordPressShared + +final class JetpackBannerWrapperViewController: UIViewController { + /// The wrapped child view controller. + private(set) var childVC: UIViewController? + private var screen: JetpackBannerScreen? + /// JPScrollViewDelegate conformance. + internal var scrollViewTranslationPublisher = PassthroughSubject<Bool, Never>() + + override var navigationItem: UINavigationItem { + guard let childVC else { return super.navigationItem } + return childVC.navigationItem + } + + convenience init( + childVC: UIViewController, + screen: JetpackBannerScreen? = nil + ) { + self.init() + self.childVC = childVC + self.screen = screen + } + + override func viewDidLoad() { + super.viewDidLoad() + + extendedLayoutIncludesOpaqueBars = true + + let stackView = UIStackView() + configureStackView(stackView) + configureChildVC(stackView) + configureJetpackBanner(stackView) + } + + // MARK: Configuration + + private func configureStackView(_ stackView: UIStackView) { + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stackView.topAnchor.constraint(equalTo: view.topAnchor), + stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } + + private func configureChildVC(_ stackView: UIStackView) { + guard let childVC = childVC else { return } + + addChild(childVC) + stackView.addArrangedSubview(childVC.view) + childVC.didMove(toParent: self) + } + + private func configureJetpackBanner(_ stackView: UIStackView) { + guard JetpackBrandingVisibility.all.enabled, !isModal() else { + return + } + let textProvider = JetpackBrandingTextProvider(screen: screen) + let jetpackBannerView = JetpackBannerView() + jetpackBannerView.configure(title: textProvider.brandingText()) { [unowned self] in + JetpackBrandingCoordinator.presentOverlay(from: self) + if let screen = screen { + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBannerTapped(screen: screen) + } + } + stackView.addArrangedSubview(jetpackBannerView) + addTranslationObserver(jetpackBannerView) + } +} + +// MARK: JPScrollViewDelegate + +extension JetpackBannerWrapperViewController: JPScrollViewDelegate {} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Button/CircularImageButton.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Button/CircularImageButton.swift new file mode 100644 index 000000000000..79e4f89f2db8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Button/CircularImageButton.swift @@ -0,0 +1,41 @@ +import UIKit + +/// Use this UIButton subclass to set a custom background for the button image, different from tintColor and backgroundColor. +/// If there's not image, it has no effect. Supports only circular images. +class CircularImageButton: UIButton { + + private lazy var imageBackgroundView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + + /// Sets a custom circular background color below the imageView, different from the button background or tint color + /// - Parameters: + /// - color: the custom background color + /// - ratio: the extent of the background view that lays below the button image view (default: 0.75 of the image view) + func setImageBackgroundColor(_ color: UIColor, ratio: CGFloat = 0.75) { + guard let imageView = imageView else { + return + } + imageBackgroundView.backgroundColor = color + insertSubview(imageBackgroundView, belowSubview: imageView) + imageBackgroundView.clipsToBounds = true + imageBackgroundView.isUserInteractionEnabled = false + NSLayoutConstraint.activate([ + imageBackgroundView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), + imageBackgroundView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), + imageBackgroundView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: ratio), + imageBackgroundView.widthAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: ratio), + ]) + } + + override func layoutSubviews() { + super.layoutSubviews() + guard imageView != nil else { + return + } + imageBackgroundView.layer.cornerRadius = imageBackgroundView.frame.height / 2 + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Button/JetpackButton.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Button/JetpackButton.swift new file mode 100644 index 000000000000..8330ef237c03 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Button/JetpackButton.swift @@ -0,0 +1,141 @@ +import UIKit +import SwiftUI + +/// A "Jetpack powered" button with two different styles (`badge` or `banner`) +class JetpackButton: CircularImageButton { + + enum ButtonStyle { + case badge + case banner + } + + var title: String? { + didSet { + setTitle(title, for: .normal) + } + } + + private let style: ButtonStyle + + init(style: ButtonStyle, title: String) { + self.style = style + super.init(frame: .zero) + configureButton(with: title) + } + + required init?(coder: NSCoder) { + fatalError("Storyboard instantiation not supported.") + } + + private var buttonBackgroundColor: UIColor { + switch style { + case .badge: + return UIColor(light: .muriel(color: .jetpackGreen, .shade40), + dark: .muriel(color: .jetpackGreen, .shade90)) + case .banner: + return .clear + } + } + + private var buttonTintColor: UIColor { + switch style { + case .badge: + return UIColor(light: .white, + dark: .muriel(color: .jetpackGreen, .shade40)) + case .banner: + return .muriel(color: .jetpackGreen, .shade40) + } + } + + private var buttonTitleColor: UIColor { + switch style { + case .badge: + return .white + case .banner: + return UIColor(light: .black, dark: .white) + } + } + + private var imageBackgroundColor: UIColor { + switch style { + case .badge: + return UIColor(light: .muriel(color: .jetpackGreen, .shade40), + dark: .white) + case .banner: + return .white + } + } + + private func configureButton(with title: String) { + isUserInteractionEnabled = FeatureFlag.jetpackPoweredBottomSheet.enabled + setTitle(title, for: .normal) + tintColor = buttonTintColor + backgroundColor = buttonBackgroundColor + setTitleColor(buttonTitleColor, for: .normal) + titleLabel?.font = Appearance.titleFont + titleLabel?.adjustsFontForContentSizeCategory = true + titleLabel?.minimumScaleFactor = Appearance.minimumScaleFactor + titleLabel?.adjustsFontSizeToFitWidth = true + setImage(.gridicon(.plans), for: .normal) + contentVerticalAlignment = .fill + contentMode = .scaleAspectFit + imageEdgeInsets = Appearance.iconInsets + contentEdgeInsets = Appearance.contentInsets + imageView?.contentMode = .scaleAspectFit + flipInsetsForRightToLeftLayoutDirection() + setImageBackgroundColor(imageBackgroundColor) + } + + private enum Appearance { + static let minimumScaleFactor: CGFloat = 0.6 + static let iconInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 10) + static let contentInsets = UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 10) + static let maximumFontPointSize: CGFloat = 22 + static let imageBackgroundViewMultiplier: CGFloat = 0.75 + static var titleFont: UIFont { + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .callout) + let font = UIFont(descriptor: fontDescriptor, size: min(fontDescriptor.pointSize, maximumFontPointSize)) + return UIFontMetrics.default.scaledFont(for: font, maximumPointSize: maximumFontPointSize) + } + } + + override func layoutSubviews() { + super.layoutSubviews() + if style == .badge { + layer.cornerRadius = frame.height / 2 + layer.cornerCurve = .continuous + } + } +} + +// MARK: Badge view +extension JetpackButton { + + /// Instantiates a view containing a Jetpack powered badge + /// - Parameter title: Title of the button + /// - Parameter topPadding: top padding, defaults to 30 pt + /// - Parameter bottomPadding: bottom padding, defaults to 30 pt + /// - Parameter target: optional target for the button action + /// - Parameter selector: optional selector for the button action + /// - Returns: the view containing the badge + @objc + static func makeBadgeView(title: String, + topPadding: CGFloat = 30, + bottomPadding: CGFloat = 30, + target: Any? = nil, + selector: Selector? = nil) -> UIView { + let view = UIView() + let badge = JetpackButton(style: .badge, title: title) + badge.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(badge) + NSLayoutConstraint.activate([ + badge.centerXAnchor.constraint(equalTo: view.centerXAnchor), + badge.topAnchor.constraint(equalTo: view.topAnchor, constant: topPadding), + badge.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -bottomPadding) + ]) + if let target = target, let selector = selector { + badge.addTarget(target, action: selector, for: .touchUpInside) + } + return view + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackBrandingCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackBrandingCoordinator.swift new file mode 100644 index 000000000000..092df34f75e1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackBrandingCoordinator.swift @@ -0,0 +1,37 @@ +import UIKit + +/// A class containing convenience methods for the the Jetpack branding experience +class JetpackBrandingCoordinator { + + static func presentOverlay(from viewController: UIViewController, redirectAction: (() -> Void)? = nil) { + + let action = redirectAction ?? { + // Try to export WordPress data to a shared location before redirecting the user. + ContentMigrationCoordinator.shared.startAndDo { _ in + JetpackRedirector.redirectToJetpack() + } + } + + let jetpackOverlayViewController = JetpackOverlayViewController(viewFactory: makeJetpackOverlayView, redirectAction: action) + let bottomSheet = BottomSheetViewController(childViewController: jetpackOverlayViewController, customHeaderSpacing: 0) + bottomSheet.show(from: viewController) + } + + static func makeJetpackOverlayView(redirectAction: (() -> Void)? = nil) -> UIView { + JetpackOverlayView(buttonAction: redirectAction) + } + + static func shouldShowBannerForJetpackDependentFeatures() -> Bool { + let phase = JetpackFeaturesRemovalCoordinator.generalPhase() + switch phase { + case .two: + fallthrough + case .three: + fallthrough + case .staticScreens: + return true + default: + return false + } + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackDefaultOverlayCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackDefaultOverlayCoordinator.swift new file mode 100644 index 000000000000..a9a34a4dca49 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackDefaultOverlayCoordinator.swift @@ -0,0 +1,24 @@ +import UIKit + +final class JetpackDefaultOverlayCoordinator: JetpackOverlayCoordinator { + weak var viewModel: JetpackFullscreenOverlayViewModel? + weak var navigationController: UINavigationController? + + func navigateToPrimaryRoute() { + ContentMigrationCoordinator.shared.startAndDo { _ in + JetpackRedirector.redirectToJetpack() + } + } + + func navigateToSecondaryRoute() { + navigationController?.dismiss(animated: true) { [weak self] in + self?.viewModel?.onDidDismiss?() + } + } + + func navigateToLinkRoute(url: URL, source: String) { + let webViewController = WebViewControllerFactory.controller(url: url, source: source) + let navController = UINavigationController(rootViewController: webViewController) + navigationController?.present(navController, animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackFeaturesRemovalCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackFeaturesRemovalCoordinator.swift new file mode 100644 index 000000000000..ad8a35d4820c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackFeaturesRemovalCoordinator.swift @@ -0,0 +1,295 @@ +import Foundation + +/// A class containing convenience methods for the the Jetpack features removal experience +class JetpackFeaturesRemovalCoordinator: NSObject { + + /// Enum describing the current phase of the Jetpack features removal + enum GeneralPhase: String { + case normal + case one + case two + case three + case four + case newUsers = "new_users" + case selfHosted = "self_hosted" + case staticScreens = "static_screens" + + var frequencyConfig: OverlayFrequencyTracker.FrequencyConfig { + switch self { + case .one: + fallthrough + case .two: + return .init(featureSpecificInDays: 7, generalInDays: 2) + case .three: + return .init(featureSpecificInDays: 4, generalInDays: 1) + default: + return .defaultConfig + } + } + } + + /// Enum describing the current phase of the site creation flow removal + enum SiteCreationPhase: String { + case normal + case one + case two + } + + enum JetpackOverlaySource: String, OverlaySource { + case stats + case notifications + case reader + case card + case login + case appOpen = "app_open" + case disabledEntryPoint = "disabled_entry_point" + + /// Used to differentiate between last saved dates for different phases. + /// Should return a dynamic value if each phase should be treated differently. + /// Should return nil if all phases should be treated the same. + func frequencyTrackerPhaseString(phase: GeneralPhase) -> String? { + switch self { + case .login: + fallthrough + case .appOpen: + return phase.rawValue // Shown once per phase + default: + return nil // Phase is irrelevant. + } + } + + var key: String { + return rawValue + } + + var frequencyType: OverlayFrequencyTracker.FrequencyType { + switch self { + case .stats: + fallthrough + case .notifications: + fallthrough + case .reader: + return .respectFrequencyConfig + case .card: + fallthrough + case .disabledEntryPoint: + return .alwaysShow + case .login: + fallthrough + case .appOpen: + return .showOnce + } + } + } + + static var currentAppUIType: RootViewCoordinator.AppUIType? + + static func generalPhase(featureFlagStore: RemoteFeatureFlagStore = RemoteFeatureFlagStore()) -> GeneralPhase { + if AppConfiguration.isJetpack { + return .normal // Always return normal for Jetpack + } + + + if AccountHelper.noWordPressDotComAccount { + let selfHostedRemoval = RemoteFeatureFlag.jetpackFeaturesRemovalPhaseSelfHosted.enabled(using: featureFlagStore) + return selfHostedRemoval ? .selfHosted : .normal + } + if RemoteFeatureFlag.jetpackFeaturesRemovalPhaseNewUsers.enabled(using: featureFlagStore) { + return .newUsers + } + if RemoteFeatureFlag.jetpackFeaturesRemovalPhaseFour.enabled(using: featureFlagStore) { + return .four + } + if RemoteFeatureFlag.jetpackFeaturesRemovalStaticPosters.enabled(using: featureFlagStore) { + return .staticScreens + } + if RemoteFeatureFlag.jetpackFeaturesRemovalPhaseThree.enabled(using: featureFlagStore) { + return .three + } + if RemoteFeatureFlag.jetpackFeaturesRemovalPhaseTwo.enabled(using: featureFlagStore) { + return .two + } + if RemoteFeatureFlag.jetpackFeaturesRemovalPhaseOne.enabled(using: featureFlagStore) { + return .one + } + + return .normal + } + + static func siteCreationPhase(featureFlagStore: RemoteFeatureFlagStore = RemoteFeatureFlagStore()) -> SiteCreationPhase { + if AppConfiguration.isJetpack { + return .normal // Always return normal for Jetpack + } + + if RemoteFeatureFlag.jetpackFeaturesRemovalPhaseNewUsers.enabled(using: featureFlagStore) + || RemoteFeatureFlag.jetpackFeaturesRemovalPhaseFour.enabled(using: featureFlagStore) + || RemoteFeatureFlag.jetpackFeaturesRemovalStaticPosters.enabled(using: featureFlagStore) { + return .two + } + if RemoteFeatureFlag.jetpackFeaturesRemovalPhaseThree.enabled(using: featureFlagStore) + || RemoteFeatureFlag.jetpackFeaturesRemovalPhaseTwo.enabled(using: featureFlagStore) + || RemoteFeatureFlag.jetpackFeaturesRemovalPhaseOne.enabled(using: featureFlagStore) { + return .one + } + + return .normal + } + + static func removalDeadline(remoteConfigStore: RemoteConfigStore = RemoteConfigStore()) -> Date? { + guard let dateString: String = RemoteConfigParameter.jetpackDeadline.value(using: remoteConfigStore) else { + return nil + } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: dateString) + } + + /// Used to determine if the Jetpack features are enabled based on the current app UI type. + /// But if the current app UI type is not set, we determine if the Jetpack Features + /// are enabled based on the removal phase regardless of the app UI state. + /// It is possible for JP features to be disabled, but still be displayed (`shouldShowJetpackFeatures`) + /// This will happen in the "Static Screens" phase. + @objc + static func jetpackFeaturesEnabled() -> Bool { + return jetpackFeaturesEnabled(featureFlagStore: RemoteFeatureFlagStore()) + } + + /// Used to determine if the Jetpack features are enabled based on the current app UI type. + /// But if the current app UI type is not set, we determine if the Jetpack Features + /// are enabled based on the removal phase regardless of the app UI state. + /// It is possible for JP features to be disabled, but still be displayed (`shouldShowJetpackFeatures`) + /// This will happen in the "Static Screens" phase. + /// Using two separate methods (rather than one method with a default argument) because Obj-C. + static func jetpackFeaturesEnabled(featureFlagStore: RemoteFeatureFlagStore) -> Bool { + guard let currentAppUIType else { + return shouldEnableJetpackFeaturesBasedOnCurrentPhase(featureFlagStore: featureFlagStore) + } + return currentAppUIType == .normal + } + + /// Used to determine if the Jetpack features are to be displayed based on the current app UI type. + /// This way we ensure features are not removed before reloading the UI. + /// But if the current app UI type is not set, we determine if the Jetpack Features + /// are to be displayed based on the removal phase regardless of the app UI state. + @objc + static func shouldShowJetpackFeatures() -> Bool { + return shouldShowJetpackFeatures(featureFlagStore: RemoteFeatureFlagStore()) + } + + /// Used to determine if the Jetpack features are to be displayed based on the current app UI type. + /// This way we ensure features are not removed before reloading the UI. + /// But if the current app UI type is not set, we determine if the Jetpack Features + /// are to be displayed based on the removal phase regardless of the app UI state. + /// Using two separate methods (rather than one method with a default argument) because Obj-C. + static func shouldShowJetpackFeatures(featureFlagStore: RemoteFeatureFlagStore) -> Bool { + guard let currentAppUIType else { + return shouldShowJetpackFeaturesBasedOnCurrentPhase(featureFlagStore: featureFlagStore) + } + return currentAppUIType != .simplified + } + + + /// Used to determine if the Jetpack features are to be displayed or not based on the removal phase regardless of the app UI state. + private static func shouldShowJetpackFeaturesBasedOnCurrentPhase(featureFlagStore: RemoteFeatureFlagStore) -> Bool { + let phase = generalPhase(featureFlagStore: featureFlagStore) + switch phase { + case .four, .newUsers, .selfHosted: + return false + default: + return true + } + } + + /// Used to determine if the Jetpack features are enabled or not based on the removal phase regardless of the app UI state. + private static func shouldEnableJetpackFeaturesBasedOnCurrentPhase(featureFlagStore: RemoteFeatureFlagStore) -> Bool { + let phase = generalPhase(featureFlagStore: featureFlagStore) + switch phase { + case .four, .newUsers, .selfHosted, .staticScreens: + return false + default: + return true + } + } + + /// Used to display feature-specific or feature-collection overlays. + /// - Parameters: + /// - viewController: The view controller where the overlay should be presented in. + /// - source: The source that triggers the display of the overlay. + /// - forced: Pass `true` to override the overlay frequency logic. Default is `false`. + /// - fullScreen: If `true` and not on iPad, the fullscreen modal presentation type is used. + /// Else the form sheet type is used. Default is `false`. + /// - blog: `Blog` object used to determine if Jetpack is installed in case of the self-hosted phase. + /// - onWillDismiss: Callback block to be called when the overlay is about to be dismissed. + /// - onDidDismiss: Callback block to be called when the overlay has finished dismissing. + static func presentOverlayIfNeeded(in viewController: UIViewController, + source: JetpackOverlaySource, + forced: Bool = false, + fullScreen: Bool = false, + blog: Blog? = nil, + onWillDismiss: JetpackOverlayDismissCallback? = nil, + onDidDismiss: JetpackOverlayDismissCallback? = nil) { + let phase = generalPhase() + let frequencyConfig = phase.frequencyConfig + let frequencyTrackerPhaseString = source.frequencyTrackerPhaseString(phase: phase) + + let coordinator = JetpackDefaultOverlayCoordinator() + let viewModel = JetpackFullscreenOverlayGeneralViewModel(phase: phase, source: source, blog: blog, coordinator: coordinator) + let overlayViewController = JetpackFullscreenOverlayViewController(with: viewModel) + let navigationViewController = UINavigationController(rootViewController: overlayViewController) + coordinator.navigationController = navigationViewController + coordinator.viewModel = viewModel + viewModel.onWillDismiss = onWillDismiss + viewModel.onDidDismiss = onDidDismiss + let frequencyTracker = OverlayFrequencyTracker(source: source, + type: .featuresRemoval, + frequencyConfig: frequencyConfig, + phaseString: frequencyTrackerPhaseString) + guard viewModel.shouldShowOverlay, frequencyTracker.shouldShow(forced: forced) else { + onWillDismiss?() + onDidDismiss?() + return + } + presentOverlay(navigationViewController: navigationViewController, in: viewController, fullScreen: fullScreen) + frequencyTracker.track() + } + + /// Used to display Site Creation overlays. + /// - Parameters: + /// - viewController: The view controller where the overlay should be presented in. + /// - source: The source that triggers the display of the overlay. + /// - onWillDismiss: Callback block to be called when the overlay is about to be dismissed. + /// - onDidDismiss: Callback block to be called when the overlay has finished dismissing. + static func presentSiteCreationOverlayIfNeeded(in viewController: UIViewController, + source: String, + onWillDismiss: JetpackOverlayDismissCallback? = nil, + onDidDismiss: JetpackOverlayDismissCallback? = nil) { + let phase = siteCreationPhase() + let coordinator = JetpackDefaultOverlayCoordinator() + // + let viewModel = JetpackFullscreenOverlaySiteCreationViewModel( + phase: phase, + source: source, + coordinator: coordinator + ) + let overlayViewController = JetpackFullscreenOverlayViewController(with: viewModel) + let navigationViewController = UINavigationController(rootViewController: overlayViewController) + coordinator.viewModel = viewModel + viewModel.onWillDismiss = onWillDismiss + viewModel.onDidDismiss = onDidDismiss + guard viewModel.shouldShowOverlay else { + onWillDismiss?() + onDidDismiss?() + return + } + presentOverlay(navigationViewController: navigationViewController, in: viewController) + } + + private static func presentOverlay(navigationViewController: UINavigationController, + in viewController: UIViewController, + fullScreen: Bool = false) { + let shouldUseFormSheet = WPDeviceIdentification.isiPad() || !fullScreen + navigationViewController.modalPresentationStyle = shouldUseFormSheet ? .formSheet : .fullScreen + + viewController.present(navigationViewController, animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackOverlayCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackOverlayCoordinator.swift new file mode 100644 index 000000000000..7ad279f772fb --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackOverlayCoordinator.swift @@ -0,0 +1,5 @@ +protocol JetpackOverlayCoordinator { + func navigateToPrimaryRoute() + func navigateToSecondaryRoute() + func navigateToLinkRoute(url: URL, source: String) +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackPluginOverlayCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackPluginOverlayCoordinator.swift new file mode 100644 index 000000000000..a6593fc4810d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackPluginOverlayCoordinator.swift @@ -0,0 +1,53 @@ +import WordPressAuthenticator + +final class JetpackPluginOverlayCoordinator: JetpackOverlayCoordinator { + + // MARK: Dependencies + + private unowned let viewController: UIViewController + private weak var installDelegate: JetpackRemoteInstallDelegate? + private let blog: Blog + + // MARK: Methods + + init(blog: Blog, viewController: UIViewController, installDelegate: JetpackRemoteInstallDelegate? = nil) { + self.blog = blog + self.viewController = viewController + self.installDelegate = installDelegate + } + + func navigateToPrimaryRoute() { + let viewModel = WPComJetpackRemoteInstallViewModel() + let installViewController = JetpackRemoteInstallViewController(blog: blog, + delegate: installDelegate, + viewModel: viewModel) + + viewController.navigationController?.pushViewController(installViewController, animated: true) + } + + func navigateToSecondaryRoute() { + guard let navigationController = viewController.navigationController else { + return + } + + let supportViewController = SupportTableViewController() + supportViewController.sourceTag = Constants.supportSourceTag + navigationController.pushViewController(supportViewController, animated: true) + } + + func navigateToLinkRoute(url: URL, source: String) { + let webViewController = WebViewControllerFactory.controller(url: url, source: source) + let navigationController = UINavigationController(rootViewController: webViewController) + let presentingViewController = viewController.navigationController ?? viewController + presentingViewController.present(navigationController, animated: true) + } +} + +private extension JetpackPluginOverlayCoordinator { + enum Constants { + static let supportSourceTag = WordPressSupportSourceTag( + name: "jetpackInstallFullPluginOverlay", + origin: "origin:jp-install-full-plugin-overlay" + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackRedirector.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackRedirector.swift new file mode 100644 index 000000000000..a328d31b3ae5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Coordinator/JetpackRedirector.swift @@ -0,0 +1,37 @@ +import Foundation + +class JetpackRedirector { + + /// Used to "guess" if the Jetpack app is already installed. + /// The check is done from the WordPress side. + /// + /// Note: The string values should kept in-sync with Jetpack's URL scheme. + /// + static var jetpackDeepLinkScheme: String { + /// Important: Multiple compiler flags are set for some builds + /// so ordering matters. + #if DEBUG + return "jpdebug" + #elseif ALPHA_BUILD + return "jpalpha" + #elseif INTERNAL_BUILD + return "jpinternal" + #else + return "jetpack" + #endif + } + + static func redirectToJetpack() { + guard let jetpackDeepLinkURL = URL(string: "\(jetpackDeepLinkScheme)://app"), + let jetpackUniversalLinkURL = URL(string: "https://jetpack.com/app"), + let jetpackAppStoreURL = URL(string: "https://apps.apple.com/app/jetpack-website-builder/id1565481562") else { + return + } + + // First, check if the WordPress app can open Jetpack by testing its URL scheme. + // if we can potentially open Jetpack app, let's open it through universal link to avoid scheme conflicts (e.g., a certain game :-). + // finally, if the user might not have Jetpack installed, direct them to App Store page. + let urlToOpen = UIApplication.shared.canOpenURL(jetpackDeepLinkURL) ? jetpackUniversalLinkURL : jetpackAppStoreURL + UIApplication.shared.open(urlToOpen) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayGeneralViewModel+Analytics.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayGeneralViewModel+Analytics.swift new file mode 100644 index 000000000000..ace9dc17037a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayGeneralViewModel+Analytics.swift @@ -0,0 +1,71 @@ +import Foundation +import AutomatticTracks + +extension JetpackFullscreenOverlayGeneralViewModel { + + // MARK: Private Enum Decleration + + private enum DismissalType: String { + case close, `continue` + } + + // MARK: Static Property Keys + + private static let phasePropertyKey = "phase" + private static let sourcePropertyKey = "source" + private static let dismiassalTypePropertyKey = "dismissal_type" + + // MARK: Private Computed Property + + private var defaultProperties: [String: String] { + return [ + Self.phasePropertyKey: phase.rawValue, + Self.sourcePropertyKey: source.rawValue + ] + } + + // MARK: Analytics Implementation + + func didDisplayOverlay() { + WPAnalytics.track(.jetpackFullscreenOverlayDisplayed, properties: defaultProperties) + } + + func didTapLink() { + guard let urlString = learnMoreButtonURL, + let url = URL(string: urlString) else { + return + } + + let source = "jetpack_overlay_\(analyticsSource)" + coordinator?.navigateToLinkRoute(url: url, source: source) + WPAnalytics.track(.jetpackFullscreenOverlayLinkTapped, properties: defaultProperties) + } + + func didTapPrimary() { + // Try to export WordPress data to a shared location before redirecting the user. + ContentMigrationCoordinator.shared.startAndDo { [weak self] _ in + guard let self = self else { + return + } + JetpackRedirector.redirectToJetpack() + WPAnalytics.track(.jetpackFullscreenOverlayButtonTapped, properties: self.defaultProperties) + } + } + + func didTapClose() { + trackOverlayDismissed(dismissalType: .close) + } + + func didTapSecondary() { + trackOverlayDismissed(dismissalType: .continue) + onWillDismiss?() + } + + // MARK: Helpers + + private func trackOverlayDismissed(dismissalType: DismissalType) { + var properties = defaultProperties + properties[Self.dismiassalTypePropertyKey] = dismissalType.rawValue + WPAnalytics.track(.jetpackFullscreenOverlayDismissed, properties: properties) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayGeneralViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayGeneralViewModel.swift new file mode 100644 index 000000000000..363a2abb31cc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayGeneralViewModel.swift @@ -0,0 +1,469 @@ +import Foundation + +/// Dynamic implementation of `JetpackFullscreenOverlayViewModel` based on the general phase +/// Should be used for feature-specific and feature-collection overlays. +final class JetpackFullscreenOverlayGeneralViewModel: JetpackFullscreenOverlayViewModel { + let phase: JetpackFeaturesRemovalCoordinator.GeneralPhase + let source: JetpackFeaturesRemovalCoordinator.JetpackOverlaySource + let blog: Blog? + let actionInfoText: NSAttributedString? + + let coordinator: JetpackDefaultOverlayCoordinator? + + init(phase: JetpackFeaturesRemovalCoordinator.GeneralPhase, + source: JetpackFeaturesRemovalCoordinator.JetpackOverlaySource, + blog: Blog?, + actionInfoText: NSAttributedString? = nil, + coordinator: JetpackDefaultOverlayCoordinator) { + self.phase = phase + self.source = source + self.blog = blog + self.actionInfoText = actionInfoText + self.coordinator = coordinator + } + + var shouldShowOverlay: Bool { + switch (phase, source) { + + // Phase One: Only show feature-specific overlays + case (.one, .stats): + fallthrough + case (.one, .notifications): + fallthrough + case (.one, .reader): + return true + + // Phase Two: Only show feature-specific overlays + case (.two, .stats): + fallthrough + case (.two, .notifications): + fallthrough + case (.two, .reader): + return true + + // Phase Three: Show all overlays + case (.three, _): + return true + + // Do not show feature overlays in phases where they are removed. + case (_, .stats): + fallthrough + case (_, .reader): + fallthrough + case (_, .notifications): + return false + + // Phase Four: Show feature-collection overlays. + case (.four, _): + return true + + // New Users Phase: Show feature-collection overlays. + case (.newUsers, _): + return true + + // Self-Hosted Users Phase: Show feature-collection overlays. + case (.selfHosted, _): + return blog?.jetpackIsConnected ?? false + + default: + return false + } + } + + var title: String { + switch (phase, source) { + // Phase One + case (.one, .stats): + return Strings.PhaseOne.Stats.title + case (.one, .notifications): + return Strings.PhaseOne.Notifications.title + case (.one, .reader): + return Strings.PhaseOne.Reader.title + + // Phase Two + case (.two, .stats): + return Strings.PhaseTwoAndThree.statsTitle + case (.two, .notifications): + return Strings.PhaseTwoAndThree.notificationsTitle + case (.two, .reader): + return Strings.PhaseTwoAndThree.readerTitle + + // Phase Three + case (.three, .stats): + return Strings.PhaseTwoAndThree.statsTitle + case (.three, .notifications): + return Strings.PhaseTwoAndThree.notificationsTitle + case (.three, .reader): + return Strings.PhaseTwoAndThree.readerTitle + case (.three, _): + return Strings.PhaseThree.generalTitle + + // Phase Four + case (.four, _): + return Strings.PhaseFour.generalTitle + + // New Users + case (.newUsers, _): + return Strings.NewUsers.generalTitle + + // Self-Hosted + case (.selfHosted, _): + return Strings.SelfHosted.generalTitle + default: + return "" + } + } + + var subtitle: NSAttributedString { + switch (phase, source) { + // Phase One + case (.one, .stats): + return attributedSubtitle(with: Strings.PhaseOne.Stats.subtitle) + case (.one, .notifications): + return attributedSubtitle(with: Strings.PhaseOne.Notifications.subtitle) + case (.one, .reader): + return attributedSubtitle(with: Strings.PhaseOne.Reader.subtitle) + + // Phase Two + case (.two, _): + fallthrough + + // Phase Three + case (.three, _): + return phaseTwoAndThreeSubtitle() + + // Phase Four + case (.four, _): + return attributedSubtitle(with: Strings.PhaseFour.subtitle) + + // New Users + case (.newUsers, _): + return attributedSubtitle(with: Strings.NewUsers.subtitle) + + // Self-Hosted + case (.selfHosted, _): + return attributedSubtitle(with: Strings.SelfHosted.subtitle) + + default: + return attributedSubtitle(with: "") + } + } + + var animationLtr: String { + switch (source, phase) { + case (.stats, _): + return Constants.statsLogoAnimationLtr + case (.notifications, _): + return Constants.notificationsLogoAnimationLtr + case (.reader, _): + return Constants.readerLogoAnimationLtr + case (_, .newUsers): + fallthrough + case (_, .selfHosted): + return Constants.wpJetpackLogoAnimationLtr + case (.card, _): + fallthrough + case (.login, _): + fallthrough + case (.appOpen, _): + fallthrough + case (.disabledEntryPoint, _): + return Constants.allFeaturesLogosAnimationLtr + } + } + + var animationRtl: String { + switch (source, phase) { + case (.stats, _): + return Constants.statsLogoAnimationRtl + case (.notifications, _): + return Constants.notificationsLogoAnimationRtl + case (.reader, _): + return Constants.readerLogoAnimationRtl + case (_, .newUsers): + fallthrough + case (_, .selfHosted): + return Constants.wpJetpackLogoAnimationRtl + case (.card, _): + fallthrough + case (.login, _): + fallthrough + case (.appOpen, _): + fallthrough + case (.disabledEntryPoint, _): + return Constants.allFeaturesLogosAnimationRtl + } + } + + var footnote: String? { + switch phase { + case .one: + fallthrough + case .two: + fallthrough + case .newUsers: + return nil + case .three: + fallthrough + case .four: + fallthrough + case .selfHosted: + return Strings.General.footnote + default: + return nil + } + } + + var learnMoreButtonURL: String? { + switch phase { + case .one: + return nil + case .two: + return RemoteConfigParameter.phaseTwoBlogPostUrl.value() + case .three: + return RemoteConfigParameter.phaseThreeBlogPostUrl.value() + case .four: + return RemoteConfigParameter.phaseFourBlogPostUrl.value() + default: + return nil + } + } + + var switchButtonText: String { + switch phase { + case .one: + fallthrough + case .two: + return Strings.General.earlyPhasesSwitchButtonTitle + case .three: + fallthrough + case .four: + fallthrough + case .newUsers: + fallthrough + case .selfHosted: + return Strings.General.latePhasesSwitchButtonTitle + default: + return "" + } + } + + var continueButtonText: String? { + switch (source, phase) { + case (.stats, _): + return Strings.General.statsContinueButtonTitle + case (.notifications, _): + return Strings.General.notificationsContinueButtonTitle + case (.reader, _): + return Strings.General.readerContinueButtonTitle + case (_, .four): + return Strings.PhaseFour.generalContinueButtonTitle + default: + return Strings.General.continueButtonTitle + } + } + + var shouldShowCloseButton: Bool { + switch phase { + case .one: + fallthrough + case .two: + return true // Only show close button in phases 1 & 2 + default: + return false + } + } + + var shouldDismissOnSecondaryButtonTap: Bool { + return true + } + + var analyticsSource: String { + return source.rawValue + } + + var onWillDismiss: JetpackOverlayDismissCallback? + + var onDidDismiss: JetpackOverlayDismissCallback? + + var secondaryView: UIView? { + switch phase { + case .newUsers: + return JetpackNewUsersOverlaySecondaryView() + default: + return nil + } + } + + var isCompact: Bool { + return phase == .newUsers + } + + func didTapActionInfo() { + // No op. + } +} + +// MARK: Helpers + +private extension JetpackFullscreenOverlayGeneralViewModel { + static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM d, yyyy" + return formatter + }() + + func attributedSubtitle(with string: String) -> NSAttributedString { + let font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + return NSAttributedString(string: string, attributes: [.font: font]) + } + + func phaseTwoAndThreeSubtitle() -> NSAttributedString { + guard let deadline = JetpackFeaturesRemovalCoordinator.removalDeadline() else { + return attributedSubtitle(with: Strings.PhaseTwoAndThree.fallbackSubtitle) + } + + let formattedDate = Self.dateFormatter.string(from: deadline) + let subtitle = String.localizedStringWithFormat(Strings.PhaseTwoAndThree.subtitle, formattedDate) + + let rangeOfDate = (subtitle as NSString).range(of: formattedDate) + let plainFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + let boldFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .bold) + let attributedSubtitle = NSMutableAttributedString(string: subtitle, attributes: [.font: plainFont]) + attributedSubtitle.addAttribute(.font, value: boldFont, range: rangeOfDate) + + return attributedSubtitle + } +} + +// MARK: Constants + +private extension JetpackFullscreenOverlayGeneralViewModel { + enum Constants { + static let statsLogoAnimationLtr = "JetpackStatsLogoAnimation_ltr" + static let statsLogoAnimationRtl = "JetpackStatsLogoAnimation_rtl" + static let readerLogoAnimationLtr = "JetpackReaderLogoAnimation_ltr" + static let readerLogoAnimationRtl = "JetpackReaderLogoAnimation_rtl" + static let notificationsLogoAnimationLtr = "JetpackNotificationsLogoAnimation_ltr" + static let notificationsLogoAnimationRtl = "JetpackNotificationsLogoAnimation_rtl" + static let allFeaturesLogosAnimationLtr = "JetpackAllFeaturesLogosAnimation_ltr" + static let allFeaturesLogosAnimationRtl = "JetpackAllFeaturesLogosAnimation_rtl" + static let wpJetpackLogoAnimationLtr = "JetpackWordPressLogoAnimation_ltr" + static let wpJetpackLogoAnimationRtl = "JetpackWordPressLogoAnimation_rtl" + } + + enum Strings { + + enum General { + static let earlyPhasesSwitchButtonTitle = NSLocalizedString("jetpack.fullscreen.overlay.early.switch.title", + value: "Switch to the new Jetpack app", + comment: "Title of a button that navigates the user to the Jetpack app if installed, or to the app store.") + static let latePhasesSwitchButtonTitle = NSLocalizedString("jetpack.fullscreen.overlay.late.switch.title", + value: "Switch to the Jetpack app", + comment: "Title of a button that navigates the user to the Jetpack app if installed, or to the app store.") + static let statsContinueButtonTitle = NSLocalizedString("jetpack.fullscreen.overlay.stats.continue.title", + value: "Continue to Stats", + comment: "Title of a button that dismisses an overlay and displays the Stats screen.") + static let readerContinueButtonTitle = NSLocalizedString("jetpack.fullscreen.overlay.reader.continue.title", + value: "Continue to Reader", + comment: "Title of a button that dismisses an overlay and displays the Reader screen.") + static let notificationsContinueButtonTitle = NSLocalizedString("jetpack.fullscreen.overlay.notifications.continue.title", + value: "Continue to Notifications", + comment: "Title of a button that dismisses an overlay and displays the Notifications screen.") + static let continueButtonTitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseThree.general.continue.title", + value: "Continue without Jetpack", + comment: "Title of a button that dismisses an overlay that showcases the Jetpack app.") + static let footnote = NSLocalizedString("jetpack.fullscreen.overlay.phaseThree.footnote", + value: "Switching is free and only takes a minute.", + comment: "A footnote in a screen displayed when the user accesses a Jetpack powered feature from the WordPress app. The screen showcases the Jetpack app.") + } + + enum PhaseOne { + + enum Stats { + static let title = NSLocalizedString("jetpack.fullscreen.overlay.phaseOne.stats.title", + value: "Get your stats using the new Jetpack app", + comment: "Title of a screen displayed when the user accesses the Stats screen from the WordPress app. The screen showcases the Jetpack app.") + static let subtitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseOne.stats.subtitle", + value: "Switch to the Jetpack app to watch your site’s traffic grow with stats and insights.", + comment: "Subtitle of a screen displayed when the user accesses the Stats screen from the WordPress app. The screen showcases the Jetpack app.") + } + + enum Reader { + static let title = NSLocalizedString("jetpack.fullscreen.overlay.phaseOne.reader.title", + value: "Follow any site with the Jetpack app", + comment: "Title of a screen displayed when the user accesses the Reader screen from the WordPress app. The screen showcases the Jetpack app.") + static let subtitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseOne.reader.subtitle", + value: "Switch to the Jetpack app to find, follow, and like all your favorite sites and posts with Reader.", + comment: "Subtitle of a screen displayed when the user accesses the Reader screen from the WordPress app. The screen showcases the Jetpack app.") + } + + enum Notifications { + static let title = NSLocalizedString("jetpack.fullscreen.overlay.phaseOne.notifications.title", + value: "Get your notifications with the Jetpack app", + comment: "Title of a screen displayed when the user accesses the Notifications screen from the WordPress app. The screen showcases the Jetpack app.") + static let subtitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseOne.notifications.subtitle", + value: "Switch to the Jetpack app to keep receiving real-time notifications on your device.", + comment: "Subtitle of a screen displayed when the user accesses the Notifications screen from the WordPress app. The screen showcases the Jetpack app.") + } + } + + enum PhaseTwoAndThree { + static let statsTitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseTwoAndThree.stats.title", + value: "Stats are moving to the Jetpack app", + comment: "Title of a screen displayed when the user accesses the Stats screen from the WordPress app. The screen showcases the Jetpack app.") + static let readerTitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseTwoAndThree.reader.title", + value: "Reader is moving to the Jetpack app", + comment: "Title of a screen displayed when the user accesses the Reader screen from the WordPress app. The screen showcases the Jetpack app.") + static let notificationsTitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseTwoAndThree.notifications.title", + value: "Notifications are moving to Jetpack", + comment: "Title of a screen displayed when the user accesses the Notifications screen from the WordPress app. The screen showcases the Jetpack app.") + static let subtitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseTwoAndThree.subtitle", + value: "Stats, Reader, Notifications and other Jetpack powered features will be removed from the WordPress app on %@.", + comment: "Subtitle of a screen displayed when the user accesses a Jetpack-powered feature from the WordPress app. The '%@' characters are a placeholder for the date the features will be removed.") + static let fallbackSubtitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseTwoAndThree.fallbackSubtitle", + value: "Stats, Reader, Notifications and other Jetpack powered features will be removed from the WordPress app soon.", + comment: "Subtitle of a screen displayed when the user accesses a Jetpack-powered feature from the WordPress app.") + } + + enum PhaseThree { + static let generalTitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseThree.general.title", + value: "Jetpack features are moving soon.", + comment: "Title of a screen that showcases the Jetpack app.") + } + + enum PhaseFour { + static let generalTitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseFour.title", + value: "Jetpack features have moved.", + comment: "Title of a screen that prompts the user to switch the Jetpack app.") + + static let subtitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseFour.subtitle", + value: "Stats, Reader, Notifications and other Jetpack powered features have been removed from the WordPress app.", + comment: "Title of a screen that prompts the user to switch the Jetpack app.") + + static let generalContinueButtonTitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseFour.general.continue.title", + value: "Do this later", + comment: "Title of a button that dismisses an overlay that prompts the user to switch the Jetpack app.") + } + + enum NewUsers { + static let generalTitle = NSLocalizedString("jetpack.fullscreen.overlay.newUsers.title", + value: "Give WordPress a boost with Jetpack", + comment: "Title of a screen that prompts the user to switch the Jetpack app.") + + static let subtitle = NSLocalizedString("jetpack.fullscreen.overlay.newUsers.subtitle", + value: "Jetpack lets you do more with your WordPress site. Switching is free and only takes a minute.", + comment: "Title of a screen that prompts the user to switch the Jetpack app.") + } + + enum SelfHosted { + static let generalTitle = NSLocalizedString("jetpack.fullscreen.overlay.selfHosted.title", + value: "Your site has the Jetpack plugin", + comment: "Title of a screen that prompts the user to switch the Jetpack app.") + + static let subtitle = NSLocalizedString("jetpack.fullscreen.overlay.selfHosted.subtitle", + value: "The Jetpack mobile app is designed to work in companion with the Jetpack plugin. Switch now to get access to Stats, Reader, Notifications and more.", + comment: "Title of a screen that prompts the user to switch the Jetpack app.") + } + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift new file mode 100644 index 000000000000..2dcdf06d1e0e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift @@ -0,0 +1,64 @@ +import Foundation +import AutomatticTracks + +extension JetpackFullscreenOverlaySiteCreationViewModel { + + // MARK: Private Enum Decleration + + private enum DismissalType: String { + case close, `continue` + } + + // MARK: Static Property Keys + + private static let phasePropertyKey = "site_creation_phase" + private static let sourcePropertyKey = "source" + private static let dismiassalTypePropertyKey = "dismissal_type" + + // MARK: Private Computed Property + + private var defaultProperties: [String: String] { + return [ + Self.phasePropertyKey: phase.rawValue, + Self.sourcePropertyKey: source + ] + } + + // MARK: Analytics Implementation + + func didDisplayOverlay() { + WPAnalytics.track(.jetpackSiteCreationOverlayDisplayed, properties: defaultProperties) + } + + func didTapLink() { + assert(false, "Not implemnted because it should never be called.") + } + + func didTapPrimary() { + // Try to export WordPress data to a shared location before redirecting the user. + ContentMigrationCoordinator.shared.startAndDo { [weak self] _ in + guard let self = self else { + return + } + JetpackRedirector.redirectToJetpack() + WPAnalytics.track(.jetpackFullscreenOverlayButtonTapped, properties: self.defaultProperties) + } + } + + func didTapClose() { + trackOverlayDismissed(dismissalType: .close) + } + + func didTapSecondary() { + trackOverlayDismissed(dismissalType: .continue) + onWillDismiss?() + } + + // MARK: Helpers + + private func trackOverlayDismissed(dismissalType: DismissalType) { + var properties = defaultProperties + properties[Self.dismiassalTypePropertyKey] = dismissalType.rawValue + WPAnalytics.track(.jetpackSiteCreationOverlayDismissed, properties: properties) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlaySiteCreationViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlaySiteCreationViewModel.swift new file mode 100644 index 000000000000..79c512bf5302 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlaySiteCreationViewModel.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Dynamic implementation of `JetpackFullscreenOverlayViewModel` based on the site creation phase +/// Should be used for Site Creation overlays. +final class JetpackFullscreenOverlaySiteCreationViewModel: JetpackFullscreenOverlayViewModel { + let phase: JetpackFeaturesRemovalCoordinator.SiteCreationPhase + let source: String + let actionInfoText: NSAttributedString? + let footnote: String? + let learnMoreButtonURL: String? + + let coordinator: JetpackDefaultOverlayCoordinator? + + init(phase: JetpackFeaturesRemovalCoordinator.SiteCreationPhase, + source: String, + actionInfoText: NSAttributedString? = nil, + learnMoreButtonURL: String? = nil, + footnote: String? = nil, + coordinator: JetpackDefaultOverlayCoordinator) { + self.phase = phase + self.source = source + self.footnote = footnote + self.actionInfoText = actionInfoText + self.learnMoreButtonURL = learnMoreButtonURL + self.coordinator = coordinator + } + + var shouldShowOverlay: Bool { + switch phase { + case .normal: + return false + case .one: + fallthrough + case .two: + return true + } + } + + var title: String { + return Strings.title + } + + var subtitle: NSAttributedString { + switch phase { + case .one: + return .init(string: Strings.phaseOneSubtitle) + case .two: + return .init(string: Strings.phaseTwoSubtitle) + default: + return .init(string: "") + } + } + + var animationLtr: String { + return Constants.wpJetpackLogoAnimationLtr + } + + var animationRtl: String { + return Constants.wpJetpackLogoAnimationRtl + } + + var switchButtonText: String { + return Strings.switchButtonTitle + } + + var continueButtonText: String? { + switch phase { + // Show only in phase one + case .one: + return Strings.continueButtonTitle + default: + return nil + } + } + + var shouldShowCloseButton: Bool { + return true + } + + var shouldDismissOnSecondaryButtonTap: Bool { + return true + } + + var analyticsSource: String { + return Constants.analyticsSource + } + + var onWillDismiss: JetpackOverlayDismissCallback? + + var onDidDismiss: JetpackOverlayDismissCallback? + + var secondaryView: UIView? { + return nil + } + + var isCompact: Bool { + return false + } + + func didTapActionInfo() { + // No op + } +} + +private extension JetpackFullscreenOverlaySiteCreationViewModel { + enum Constants { + static let wpJetpackLogoAnimationLtr = "JetpackWordPressLogoAnimation_ltr" + static let wpJetpackLogoAnimationRtl = "JetpackWordPressLogoAnimation_rtl" + static let analyticsSource = "site_creation" + } + + enum Strings { + static let title = NSLocalizedString("jetpack.fullscreen.overlay.siteCreation.title", + value: "Create a new WordPress site with the Jetpack app", + comment: "Title of a screen displayed when the user trys creating a new site from the WordPress app. The screen showcases the Jetpack app.") + static let phaseOneSubtitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseOne.siteCreation.subtitle", + value: "Jetpack provides stats, notifications and more to help you build and grow the WordPress site of your dreams.", + comment: "Subtitle of a screen displayed when the user trys creating a new site from the WordPress app. The screen showcases the Jetpack app.") + static let phaseTwoSubtitle = NSLocalizedString("jetpack.fullscreen.overlay.phaseTwo.siteCreation.subtitle", + value: "Jetpack provides stats, notifications and more to help you build and grow the WordPress site of your dreams.\n\nThe WordPress app no longer supports creating a new site.", + comment: "Subtitle of a screen displayed when the user trys creating a new site from the WordPress app. The screen showcases the Jetpack app.") + static let switchButtonTitle = NSLocalizedString("jetpack.fullscreen.overlay.siteCreation.switch.title", + value: "Try the new Jetpack app", + comment: "Title of a button that navigates the user to the Jetpack app if installed, or to the app store.") + static let continueButtonTitle = NSLocalizedString("jetpack.fullscreen.overlay.siteCreation.continue.title", + value: "Continue without Jetpack", + comment: "Title of a button that navigates the user to the Jetpack app if installed, or to the app store.") + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.swift new file mode 100644 index 000000000000..88d863db4fa7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.swift @@ -0,0 +1,329 @@ +import UIKit +import Lottie + +class JetpackFullscreenOverlayViewController: UIViewController { + + // MARK: Variables + + private let viewModel: JetpackFullscreenOverlayViewModel + + /// Sets the animation based on the language orientation + private var animation: Animation? { + traitCollection.layoutDirection == .leftToRight ? + Animation.named(viewModel.animationLtr) : + Animation.named(viewModel.animationRtl) + } + + // MARK: Lazy Views + + private var closeButtonImage: UIImage { + let fontForSystemImage = UIFont.systemFont(ofSize: Metrics.closeButtonRadius) + let configuration = UIImage.SymbolConfiguration(font: fontForSystemImage) + + // fallback to the gridicon if for any reason the system image fails to render + return UIImage(systemName: Constants.closeButtonSystemName, withConfiguration: configuration) ?? + UIImage.gridicon(.crossCircle, size: CGSize(width: Metrics.closeButtonRadius, height: Metrics.closeButtonRadius)) + } + + private lazy var closeButtonItem: UIBarButtonItem = { + let closeButton = CircularImageButton() + + closeButton.setImage(closeButtonImage, for: .normal) + closeButton.tintColor = Colors.closeButtonTintColor + closeButton.setImageBackgroundColor(UIColor(light: .black, dark: .white)) + + NSLayoutConstraint.activate([ + closeButton.widthAnchor.constraint(equalToConstant: Metrics.closeButtonRadius), + closeButton.heightAnchor.constraint(equalTo: closeButton.widthAnchor) + ]) + + closeButton.addTarget(self, action: #selector(closeButtonPressed), for: .touchUpInside) + + return UIBarButtonItem(customView: closeButton) + }() + + // MARK: Outlets + + @IBOutlet weak var contentStackView: UIStackView! + @IBOutlet weak var animationView: AnimationView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var subtitleLabel: UILabel! + @IBOutlet weak var footnoteLabel: UILabel! + @IBOutlet weak var learnMoreButton: UIButton! + @IBOutlet weak var learnMoreSuperView: UIView! + @IBOutlet weak var switchButton: UIButton! + @IBOutlet weak var continueButton: UIButton! + @IBOutlet weak var buttonsSuperViewBottomConstraint: NSLayoutConstraint! + @IBOutlet weak var actionInfoButton: UIButton! + + // MARK: Initializers + + init(with viewModel: JetpackFullscreenOverlayViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + self.isModalInPresentation = true + addSecondaryViewIfAvailable() + configureNavigationBar() + applyStyles() + setupConstraints() + setupContent() + setupColors() + setupFonts() + setupButtons() + animationView.play() + viewModel.didDisplayOverlay() + } + + // MARK: Helpers + + private func addSecondaryViewIfAvailable() { + guard let secondaryView = viewModel.secondaryView, + let index = contentStackView.arrangedSubviews.firstIndex(of: learnMoreSuperView) else { + return + } + contentStackView.insertArrangedSubview(secondaryView, at: index) + } + + private func configureNavigationBar() { + addCloseButtonIfNeeded() + + let appearance = UINavigationBarAppearance() + appearance.backgroundColor = Colors.backgroundColor + appearance.shadowColor = .clear + navigationItem.standardAppearance = appearance + navigationItem.compactAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + if #available(iOS 15.0, *) { + navigationItem.compactScrollEdgeAppearance = appearance + } + } + + private func addCloseButtonIfNeeded() { + guard viewModel.shouldShowCloseButton else { + return + } + + navigationItem.rightBarButtonItem = closeButtonItem + } + + private func applyStyles() { + contentStackView.spacing = viewModel.isCompact ? Metrics.compactStackViewSpacing : Metrics.normalStackViewSpacing + switchButton.layer.cornerRadius = Metrics.switchButtonCornerRadius + } + + private func setupConstraints() { + // Animation constraint + let animationSize = animation?.size ?? .init(width: 1, height: 1) + let ratio = animationSize.width / animationSize.height + animationView.widthAnchor.constraint(equalTo: animationView.heightAnchor, multiplier: ratio).isActive = true + + // Buttons bottom constraint + buttonsSuperViewBottomConstraint.constant = viewModel.continueButtonIsHidden ? Metrics.singleButtonBottomSpacing : Metrics.buttonsNormalBottomSpacing + } + + private func setupContent() { + animationView.animation = animation + setTitle() + subtitleLabel.attributedText = viewModel.subtitle + footnoteLabel.text = viewModel.footnote + switchButton.setTitle(viewModel.switchButtonText, for: .normal) + continueButton.setTitle(viewModel.continueButtonText, for: .normal) + footnoteLabel.isHidden = viewModel.footnoteIsHidden + learnMoreButton.isHidden = viewModel.learnMoreButtonIsHidden + continueButton.isHidden = viewModel.continueButtonIsHidden + setupLearnMoreButtonTitle() + setupActionInfoButtonTitle() + } + + private func setTitle() { + let style = NSMutableParagraphStyle() + style.lineHeightMultiple = Metrics.titleLineHeightMultiple + style.lineBreakMode = .byTruncatingTail + + let defaultAttributes: [NSAttributedString.Key: Any] = [ + .paragraphStyle: style, + .kern: Metrics.titleKern + ] + let attributedString = NSMutableAttributedString(string: viewModel.title) + attributedString.addAttributes(defaultAttributes, range: NSRange(location: 0, length: attributedString.length)) + titleLabel.attributedText = attributedString + } + + private func setupColors() { + view.backgroundColor = Colors.backgroundColor + footnoteLabel.textColor = Colors.footnoteTextColor + actionInfoButton.setTitleColor(Colors.actionInfoTextColor, for: .normal) + learnMoreButton.tintColor = Colors.learnMoreButtonTextColor + switchButton.backgroundColor = Colors.switchButtonBackgroundColor + switchButton.tintColor = Colors.switchButtonTextColor + continueButton.tintColor = Colors.continueButtonTextColor + } + + private func setupFonts() { + titleLabel.font = WPStyleGuide.fontForTextStyle(.largeTitle, fontWeight: .bold) + titleLabel.adjustsFontForContentSizeCategory = true + subtitleLabel.adjustsFontForContentSizeCategory = true + footnoteLabel.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + footnoteLabel.adjustsFontForContentSizeCategory = true + learnMoreButton.titleLabel?.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + switchButton.titleLabel?.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + continueButton.titleLabel?.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + } + + private func setupButtons() { + setupButtonInsets() + switchButton.titleLabel?.textAlignment = .center + continueButton.titleLabel?.textAlignment = .center + } + + private func setupButtonInsets() { + if #available(iOS 15.0, *) { + // Continue & Switch Buttons + var buttonConfig: UIButton.Configuration = .plain() + buttonConfig.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer({ incoming in + var outgoing = incoming + outgoing.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + return outgoing + }) + buttonConfig.contentInsets = Metrics.mainButtonsContentInsets + continueButton.configuration = buttonConfig + switchButton.configuration = buttonConfig + + // Learn More Button + var learnMoreButtonConfig: UIButton.Configuration = .plain() + learnMoreButtonConfig.contentInsets = Metrics.learnMoreButtonContentInsets + learnMoreButton.configuration = learnMoreButtonConfig + } else { + // Continue Button + continueButton.contentEdgeInsets = Metrics.mainButtonsContentEdgeInsets + + // Switch Button + switchButton.contentEdgeInsets = Metrics.mainButtonsContentEdgeInsets + + // Learn More Button + learnMoreButton.contentEdgeInsets = Metrics.learnMoreButtonContentEdgeInsets + learnMoreButton.flipInsetsForRightToLeftLayoutDirection() + } + } + + private func setupLearnMoreButtonTitle() { + let externalAttachment = NSTextAttachment(image: UIImage.gridicon(.external, size: Metrics.externalIconSize).withTintColor(Colors.learnMoreButtonTextColor)) + externalAttachment.bounds = Metrics.externalIconBounds + let attachmentString = NSAttributedString(attachment: externalAttachment) + + let learnMoreText = NSMutableAttributedString(string: "\(Strings.learnMoreButtonText) \u{FEFF}") + learnMoreText.append(attachmentString) + learnMoreButton.setAttributedTitle(learnMoreText, for: .normal) + } + + private func setupActionInfoButtonTitle() { + actionInfoButton.setAttributedTitle(viewModel.actionInfoText, for: .normal) + actionInfoButton.isHidden = viewModel.actionInfoText == nil + + if let actionInfoText = viewModel.actionInfoText, + !actionInfoText.string.isEmpty, + let titleLabel = actionInfoButton.titleLabel { + titleLabel.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 0 + actionInfoButton.pinSubviewToAllEdges(titleLabel) + } + } + + private func dismissOverlay() { + viewModel.onWillDismiss?() + dismiss(animated: true) { [weak self] in + self?.viewModel.onDidDismiss?() + } + } + + // MARK: Actions + + @objc private func closeButtonPressed(sender: UIButton) { + viewModel.didTapClose() + dismissOverlay() + } + + + @IBAction func switchButtonPressed(_ sender: Any) { + viewModel.didTapPrimary() + } + + @IBAction func continueButtonPressed(_ sender: Any) { + viewModel.didTapSecondary() + + if viewModel.shouldDismissOnSecondaryButtonTap { + dismissOverlay() + } + } + + @IBAction func learnMoreButtonPressed(_ sender: Any) { + viewModel.didTapLink() + } + + @IBAction func actionInfoButtonTapped(_ sender: Any) { + viewModel.didTapActionInfo() + } +} + +// MARK: Constants + +private extension JetpackFullscreenOverlayViewController { + enum Strings { + static let learnMoreButtonText = NSLocalizedString("jetpack.fullscreen.overlay.learnMore", + value: "Learn more at jetpack.com", + comment: "Title of a button that displays a blog post in a web view.") + } + + enum Metrics { + static let normalStackViewSpacing: CGFloat = 20 + static let compactStackViewSpacing: CGFloat = 10 + static let closeButtonRadius: CGFloat = 30 + static let mainButtonsContentInsets = NSDirectionalEdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12) + static let mainButtonsContentEdgeInsets = UIEdgeInsets(top: 4, left: 12, bottom: 4, right: 12) + static let learnMoreButtonContentInsets = NSDirectionalEdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 24) + static let learnMoreButtonContentEdgeInsets = UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 24) + static let externalIconSize = CGSize(width: 16, height: 16) + static let externalIconBounds = CGRect(x: 0, y: -2, width: 16, height: 16) + static let switchButtonCornerRadius: CGFloat = 6 + static let titleLineHeightMultiple: CGFloat = 0.88 + static let titleKern: CGFloat = 0.37 + static let buttonsNormalBottomSpacing: CGFloat = 30 + static let singleButtonBottomSpacing: CGFloat = 60 + static let actionInfoButtonBottomSpacing: CGFloat = 24 + } + + enum Constants { + static let closeButtonSystemName = "xmark.circle.fill" + } + + enum Colors { + private static let jetpackGreen50 = UIColor.muriel(color: .jetpackGreen, .shade50).lightVariant() + private static let jetpackGreen30 = UIColor.muriel(color: .jetpackGreen, .shade30).lightVariant() + private static let jetpackGreen90 = UIColor.muriel(color: .jetpackGreen, .shade90).lightVariant() + + static let backgroundColor = UIColor(light: .systemBackground, + dark: .muriel(color: .jetpackGreen, .shade100)) + static let footnoteTextColor = UIColor(light: .muriel(color: .gray, .shade50), + dark: .muriel(color: .gray, .shade5)) + static let actionInfoTextColor = UIColor.textSubtle + static let learnMoreButtonTextColor = UIColor(light: jetpackGreen50, dark: jetpackGreen30) + static let switchButtonBackgroundColor = jetpackGreen50 + static let continueButtonTextColor = UIColor(light: jetpackGreen50, dark: .white) + static let switchButtonTextColor = UIColor.white + static let closeButtonTintColor = UIColor(light: .muriel(color: .gray, .shade5), + dark: jetpackGreen90) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.xib b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.xib new file mode 100644 index 000000000000..88a5ffb9a04f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewController.xib @@ -0,0 +1,203 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="JetpackFullscreenOverlayViewController" customModule="WordPress" customModuleProvider="target"> + <connections> + <outlet property="actionInfoButton" destination="gHv-Wo-L7c" id="cmp-X3-xhS"/> + <outlet property="animationView" destination="v3d-AD-Jsf" id="8wg-bB-WH5"/> + <outlet property="buttonsSuperViewBottomConstraint" destination="zhh-p2-mZx" id="4cZ-ra-9SF"/> + <outlet property="contentStackView" destination="TTg-Z6-h4X" id="rbe-WF-3kb"/> + <outlet property="continueButton" destination="hPW-8A-Di4" id="fym-yu-QBz"/> + <outlet property="footnoteLabel" destination="1l4-qY-6ZA" id="Szg-6d-q2E"/> + <outlet property="learnMoreButton" destination="n55-iX-u43" id="xmI-UK-MhK"/> + <outlet property="learnMoreSuperView" destination="0z9-x4-AgY" id="iAg-eE-Znk"/> + <outlet property="subtitleLabel" destination="n6H-KY-dMw" id="mA3-01-Yg4"/> + <outlet property="switchButton" destination="VGE-FS-gsd" id="FEz-5f-kSS"/> + <outlet property="titleLabel" destination="EqD-nO-Q6T" id="J4a-QK-Do3"/> + <outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT"> + <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Qla-b8-9Ag"> + <rect key="frame" x="0.0" y="0.0" width="375" height="456"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="PbU-M0-J6O" userLabel="Scroll Content View"> + <rect key="frame" x="0.0" y="0.0" width="375" height="456"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="TTg-Z6-h4X" userLabel="Content Stack View"> + <rect key="frame" x="29" y="86.5" width="317" height="283"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="bUe-f2-zzc" userLabel="Icon Super View"> + <rect key="frame" x="0.0" y="0.0" width="317" height="75"/> + <subviews> + <view contentMode="scaleToFill" placeholderIntrinsicWidth="120" placeholderIntrinsicHeight="100" translatesAutoresizingMaskIntoConstraints="NO" id="v3d-AD-Jsf" customClass="AnimationView" customModule="Lottie"> + <rect key="frame" x="0.0" y="0.0" width="120" height="65"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="65" id="j0D-cP-R17"/> + </constraints> + </view> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="v3d-AD-Jsf" firstAttribute="leading" secondItem="bUe-f2-zzc" secondAttribute="leading" id="0UI-m4-Lgf"/> + <constraint firstItem="v3d-AD-Jsf" firstAttribute="top" secondItem="bUe-f2-zzc" secondAttribute="top" id="OaQ-NS-YRw"/> + <constraint firstAttribute="bottom" secondItem="v3d-AD-Jsf" secondAttribute="bottom" constant="10" id="fBl-FF-uc8"> + <variation key="heightClass=compact" constant="0.0"/> + </constraint> + </constraints> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Title Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" minimumScaleFactor="0.59999999999999998" translatesAutoresizingMaskIntoConstraints="NO" id="EqD-nO-Q6T"> + <rect key="frame" x="0.0" y="95" width="317" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Subtitle Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" minimumScaleFactor="0.59999999999999998" translatesAutoresizingMaskIntoConstraints="NO" id="n6H-KY-dMw"> + <rect key="frame" x="0.0" y="135.5" width="317" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Footnote Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.59999999999999998" translatesAutoresizingMaskIntoConstraints="NO" id="1l4-qY-6ZA"> + <rect key="frame" x="0.0" y="176" width="317" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="0z9-x4-AgY" userLabel="Learn More Super View"> + <rect key="frame" x="0.0" y="216.5" width="317" height="66.5"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="leading" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="n55-iX-u43"> + <rect key="frame" x="0.0" y="0.0" width="128" height="66.5"/> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal" title="Learn More Button"/> + <connections> + <action selector="learnMoreButtonPressed:" destination="-1" eventType="touchUpInside" id="sWE-LZ-Gma"/> + </connections> + </button> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="n55-iX-u43" firstAttribute="leading" secondItem="0z9-x4-AgY" secondAttribute="leading" id="Nmm-lY-pyp"/> + <constraint firstAttribute="bottom" secondItem="n55-iX-u43" secondAttribute="bottom" id="YeY-ye-Dpy"/> + <constraint firstItem="n55-iX-u43" firstAttribute="top" secondItem="0z9-x4-AgY" secondAttribute="top" id="smu-t8-a6s"/> + </constraints> + </view> + </subviews> + </stackView> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="gHv-Wo-L7c" userLabel="Action Info Button"> + <rect key="frame" x="29" y="418" width="317" height="30"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal"> + <color key="titleColor" systemColor="secondaryLabelColor"/> + </state> + <connections> + <action selector="actionInfoButtonTapped:" destination="-1" eventType="touchUpInside" id="ceP-s4-257"/> + </connections> + </button> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="TTg-Z6-h4X" firstAttribute="leading" secondItem="PbU-M0-J6O" secondAttribute="leading" constant="29" id="2d6-L5-bKl"/> + <constraint firstItem="TTg-Z6-h4X" firstAttribute="top" relation="greaterThanOrEqual" secondItem="PbU-M0-J6O" secondAttribute="top" id="2nW-cN-NOi"/> + <constraint firstItem="gHv-Wo-L7c" firstAttribute="leading" secondItem="TTg-Z6-h4X" secondAttribute="leading" id="9zz-z1-X7R"/> + <constraint firstAttribute="trailing" secondItem="TTg-Z6-h4X" secondAttribute="trailing" constant="29" id="Q2G-S5-HVM"/> + <constraint firstAttribute="bottom" secondItem="gHv-Wo-L7c" secondAttribute="bottom" constant="8" id="SJ0-jW-GXy"/> + <constraint firstItem="gHv-Wo-L7c" firstAttribute="top" relation="greaterThanOrEqual" secondItem="TTg-Z6-h4X" secondAttribute="bottom" constant="20" id="xfD-Di-6UY"/> + <constraint firstItem="gHv-Wo-L7c" firstAttribute="trailing" secondItem="TTg-Z6-h4X" secondAttribute="trailing" id="yKf-rX-Gsr"/> + </constraints> + </view> + </subviews> + <constraints> + <constraint firstItem="TTg-Z6-h4X" firstAttribute="centerY" secondItem="Qla-b8-9Ag" secondAttribute="centerY" priority="750" id="Bb8-jX-f5p"/> + <constraint firstItem="PbU-M0-J6O" firstAttribute="top" secondItem="Qla-b8-9Ag" secondAttribute="top" id="VPH-IZ-hYf"/> + <constraint firstAttribute="trailing" secondItem="PbU-M0-J6O" secondAttribute="trailing" id="VQr-HE-qvN"/> + <constraint firstAttribute="bottom" secondItem="PbU-M0-J6O" secondAttribute="bottom" id="ZRn-ZX-cw2"/> + <constraint firstItem="PbU-M0-J6O" firstAttribute="leading" secondItem="Qla-b8-9Ag" secondAttribute="leading" id="fpw-tR-aHt"/> + <constraint firstItem="PbU-M0-J6O" firstAttribute="height" secondItem="Qla-b8-9Ag" secondAttribute="height" priority="250" id="isP-b7-xPT"/> + <constraint firstItem="PbU-M0-J6O" firstAttribute="width" secondItem="Qla-b8-9Ag" secondAttribute="width" id="nwA-1q-Anl"/> + </constraints> + </scrollView> + <stackView opaque="NO" contentMode="scaleToFill" placeholderIntrinsicWidth="354" placeholderIntrinsicHeight="166" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="vcE-wc-fHe"> + <rect key="frame" x="10.5" y="471" width="354" height="166"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="VGE-FS-gsd"> + <rect key="frame" x="0.0" y="0.0" width="354" height="50"/> + <constraints> + <constraint firstAttribute="width" relation="lessThanOrEqual" constant="354" id="93l-ng-h5B"/> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="50" id="DD0-7r-C5O"> + <variation key="heightClass=compact-widthClass=regular" constant="44"/> + </constraint> + </constraints> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal" title="Switch Button"/> + <connections> + <action selector="switchButtonPressed:" destination="-1" eventType="touchUpInside" id="Uk1-44-IEW"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="249" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="hPW-8A-Di4"> + <rect key="frame" x="0.0" y="58" width="354" height="108"/> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="50" id="cH2-GJ-f7z"> + <variation key="heightClass=compact-widthClass=regular" constant="44"/> + </constraint> + </constraints> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal" title="Continue Button"/> + <connections> + <action selector="continueButtonPressed:" destination="-1" eventType="touchUpInside" id="cuM-YO-Ih2"/> + </connections> + </button> + <view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="c3P-qm-yq7" userLabel="Horizontal Filler View"> + <rect key="frame" x="0.0" y="166" width="354" height="0.0"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <variation key="heightClass=compact" hidden="NO"/> + </view> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <variation key="heightClass=compact" axis="horizontal"/> + </stackView> + </subviews> + <viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" secondItem="Qla-b8-9Ag" secondAttribute="trailing" id="3NU-Qw-DLj"/> + <constraint firstItem="Qla-b8-9Ag" firstAttribute="leading" secondItem="fnl-2z-Ty3" secondAttribute="leading" id="MRH-0j-XGN"/> + <constraint firstItem="Qla-b8-9Ag" firstAttribute="top" secondItem="fnl-2z-Ty3" secondAttribute="top" id="RSB-Hy-hKO"/> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" secondItem="vcE-wc-fHe" secondAttribute="trailing" priority="750" constant="30" id="lCr-Kp-cW8"/> + <constraint firstItem="vcE-wc-fHe" firstAttribute="centerX" secondItem="i5M-Pr-FkT" secondAttribute="centerX" id="q3g-0n-Ixk"/> + <constraint firstItem="vcE-wc-fHe" firstAttribute="top" secondItem="Qla-b8-9Ag" secondAttribute="bottom" constant="15" id="ryc-Sp-vAj"/> + <constraint firstItem="vcE-wc-fHe" firstAttribute="leading" secondItem="fnl-2z-Ty3" secondAttribute="leading" priority="750" constant="30" id="tbg-oo-MPL"/> + <constraint firstAttribute="bottom" secondItem="vcE-wc-fHe" secondAttribute="bottom" constant="30" id="zhh-p2-mZx"/> + </constraints> + <point key="canvasLocation" x="-22" y="14"/> + </view> + </objects> + <designables> + <designable name="v3d-AD-Jsf"> + <size key="intrinsicContentSize" width="120" height="100"/> + </designable> + </designables> + <resources> + <systemColor name="secondaryLabelColor"> + <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewModel.swift new file mode 100644 index 000000000000..32bb5572e9c8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackFullscreenOverlayViewModel.swift @@ -0,0 +1,50 @@ +import Foundation + +typealias JetpackOverlayDismissCallback = () -> Void + +/// Protocol used to configure `JetpackFullscreenOverlayViewController` +protocol JetpackFullscreenOverlayViewModel: AnyObject { + var title: String { get } + var subtitle: NSAttributedString { get } + var animationLtr: String { get } + var animationRtl: String { get } + var footnote: String? { get } + var learnMoreButtonURL: String? { get } + var switchButtonText: String { get } + var continueButtonText: String? { get } + var shouldShowCloseButton: Bool { get } + var shouldDismissOnSecondaryButtonTap: Bool { get } + var analyticsSource: String { get } + var actionInfoText: NSAttributedString? { get } + var onWillDismiss: JetpackOverlayDismissCallback? { get } + var onDidDismiss: JetpackOverlayDismissCallback? { get } + + /// An optional view. + /// If provided, the view will be added to the overlay before the learn more button + var secondaryView: UIView? { get } + + /// If `true`, the overlay uses tighter spacings between subviews. + /// Useful for packed overlays. + var isCompact: Bool { get } + + func didDisplayOverlay() + func didTapLink() + func didTapPrimary() + func didTapClose() + func didTapSecondary() + func didTapActionInfo() +} + +extension JetpackFullscreenOverlayViewModel { + var learnMoreButtonIsHidden: Bool { + learnMoreButtonURL == nil + } + + var footnoteIsHidden: Bool { + footnote == nil + } + + var continueButtonIsHidden: Bool { + continueButtonText == nil + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackNewUsersOverlaySecondaryView.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackNewUsersOverlaySecondaryView.swift new file mode 100644 index 000000000000..0fbbcebed774 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackNewUsersOverlaySecondaryView.swift @@ -0,0 +1,175 @@ +import UIKit + +class JetpackNewUsersOverlaySecondaryView: UIView { + + // MARK: Lazy Loading Views + + private lazy var containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .fill + stackView.distribution = .fillEqually + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Metrics.stackViewSpacing + stackView.directionalLayoutMargins = Metrics.stackViewLayoutMargins + stackView.isLayoutMarginsRelativeArrangement = true + stackView.addArrangedSubviews(featureRows) + return stackView + }() + + private lazy var featureRows: [FeatureDetailsView] = { + return [statsDetails, readerDetails, notificationsDetails] + }() + + private lazy var statsDetails: FeatureDetailsView = { + let icon = UIImage(named: Constants.statsIcon) + let view = FeatureDetailsView(image: icon, title: Strings.statsTitle, subtitle: Strings.statsSubtitle) + return view + }() + + private lazy var readerDetails: FeatureDetailsView = { + let icon = UIImage(named: Constants.readerIcon) + let view = FeatureDetailsView(image: icon, title: Strings.readerTitle, subtitle: Strings.readerSubtitle) + return view + }() + + private lazy var notificationsDetails: FeatureDetailsView = { + let icon = UIImage(named: Constants.notificationsIcon) + let view = FeatureDetailsView(image: icon, title: Strings.notificationsTitle, subtitle: Strings.notificationsSubtitle) + return view + }() + + // MARK: Initializers + + init() { + super.init(frame: .zero) + configureView() + } + + required init?(coder: NSCoder) { + fatalError("Storyboard instantiation not supported.") + } + + // MARK: Helpers + + private func configureView() { + addSubview(containerStackView) + pinSubviewToAllEdges(containerStackView) + } +} + +private extension JetpackNewUsersOverlaySecondaryView { + class FeatureDetailsView: UIView { + + // MARK: Lazy Loading Views + + private lazy var iconImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.heightAnchor.constraint(equalToConstant: Metrics.iconImageViewSize).isActive = true + imageView.widthAnchor.constraint(equalToConstant: Metrics.iconImageViewSize).isActive = true + addSubview(imageView) + return imageView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + label.textColor = .label + label.numberOfLines = 1 + label.adjustsFontForContentSizeCategory = true + label.setContentHuggingPriority(.defaultHigh, for: .vertical) + addSubview(label) + return label + }() + + private lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) + label.textColor = .secondaryLabel + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + addSubview(label) + return label + }() + + // MARK: Initializers + + init(image: UIImage?, title: String, subtitle: String) { + super.init(frame: .zero) + configureView(image: image, title: title, subtitle: subtitle) + } + + required init?(coder: NSCoder) { + fatalError("Storyboard instantiation not supported.") + } + + // MARK: Helpers + + private func configureView(image: UIImage?, title: String, subtitle: String) { + setupContent(image: image, title: title, subtitle: subtitle) + setupConstraints() + } + + private func setupContent(image: UIImage?, title: String, subtitle: String) { + iconImageView.image = image + titleLabel.text = title + subtitleLabel.text = subtitle + } + + private func setupConstraints() { + // Icon Image View + iconImageView.topAnchor.constraint(equalTo: topAnchor).isActive = true + iconImageView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true + + // Title Label + titleLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true + titleLabel.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, + constant: Metrics.labelsAndIconSpacing).isActive = true + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true + + // Subtitle Label + subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor).isActive = true + subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true + subtitleLabel.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, + constant: Metrics.labelsAndIconSpacing).isActive = true + subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true + } + } +} + +private extension JetpackNewUsersOverlaySecondaryView { + enum Metrics { + static let stackViewSpacing: CGFloat = 30 + static let stackViewLayoutMargins: NSDirectionalEdgeInsets = .init(top: 30, leading: 0, bottom: 0, trailing: 0) + static let iconImageViewSize: CGFloat = 30 + static let labelsAndIconSpacing: CGFloat = 14 + } + + enum Constants { + static let statsIcon = "jp-stats-icon" + static let readerIcon = "jp-reader-icon" + static let notificationsIcon = "jp-notif-icon" + } + + enum Strings { + static let statsTitle = NSLocalizedString("jetpack.fullscreen.overlay.newUsers.stats.title", + value: "Stats & Insights", + comment: "Name of the Statistics feature.") + static let readerTitle = NSLocalizedString("Reader", + comment: "Name of the Reader feature.") + static let notificationsTitle = NSLocalizedString("Notifications", + comment: "Name of the Notifications feature.") + static let statsSubtitle = NSLocalizedString("jetpack.fullscreen.overlay.newUsers.stats.subtitle", + value: "Watch your traffic grow with helpful insights and comprehensive stats.", + comment: "Description of the Statistics feature.") + static let readerSubtitle = NSLocalizedString("jetpack.fullscreen.overlay.newUsers.reader.subtitle", + value: "Find and follow your favorite sites and communities, and share you content.", + comment: "Description of the Reader feature.") + static let notificationsSubtitle = NSLocalizedString("jetpack.fullscreen.overlay.newUsers.notifications.subtitle", + value: "Get notifications for new comments, likes, views, and more.", + comment: "Description of the Notifications feature.") + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackPluginOverlayViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackPluginOverlayViewModel.swift new file mode 100644 index 000000000000..77b1e6380099 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Fullscreen Overlay/JetpackPluginOverlayViewModel.swift @@ -0,0 +1,349 @@ +import Foundation + +class JetpackPluginOverlayViewModel: JetpackFullscreenOverlayViewModel { + private enum Constants { + static let lottieLTRFileName = "JetpackInstallPluginLogoAnimation_ltr" + static let lottieRTLFileName = "JetpackInstallPluginLogoAnimation_rtl" + static let termsURL = URL(string: "https://wordpress.com/tos") + static let webViewSource = "jetpack_plugin_install_overlay" + } + + // MARK: View Model Properties + + var title: String { strings.title } + var subtitle: NSAttributedString { subtitle(withSiteName: siteName, plugin: plugin) } + let animationLtr: String = Constants.lottieLTRFileName + let animationRtl: String = Constants.lottieRTLFileName + let footnote: String? = nil + var actionInfoText: NSAttributedString? { actionInfoString() } + let learnMoreButtonURL: String? = nil + var switchButtonText: String { strings.primaryButtonTitle } + var continueButtonText: String? { strings.secondaryButtonTitle } + let shouldShowCloseButton = true + let shouldDismissOnSecondaryButtonTap = false + let analyticsSource: String = "" + var onWillDismiss: JetpackOverlayDismissCallback? + var onDidDismiss: JetpackOverlayDismissCallback? + var secondaryView: UIView? = nil + let isCompact = false // compact layout is not supported for this overlay. + + // MARK: Dependencies + + var coordinator: JetpackOverlayCoordinator? + + // MARK: Private Properties + + private let siteName: String + private let plugin: JetpackPlugin + private let strings: JetpackPluginOverlayStrings + + // MARK: Methods + + init(siteName: String, plugin: JetpackPlugin) { + self.siteName = siteName + self.plugin = plugin + self.strings = AppConfiguration.isWordPress ? WordPressStrings() : JetpackStrings() + } + + func didDisplayOverlay() { + track(.viewed) + } + + func didTapLink() { + // TODO: coordinator?.navigateToLinkRoute + } + + func didTapPrimary() { + coordinator?.navigateToPrimaryRoute() + track(.primaryButtonTapped) + } + + func didTapClose() { + track(.dismissed) + } + + func didTapSecondary() { + coordinator?.navigateToSecondaryRoute() + + if AppConfiguration.isWordPress { + track(.dismissed) + } + } + + func didTapActionInfo() { + guard let termsURL = Constants.termsURL else { + return + } + coordinator?.navigateToLinkRoute(url: termsURL, source: Constants.webViewSource) + } + +} + +// MARK: - Private Helpers + +private extension JetpackPluginOverlayViewModel { + + enum JetpackPluginOverlayAnalytics { + case viewed + case dismissed + case primaryButtonTapped + + var key: WPAnalyticsEvent { + AppConfiguration.isWordPress ? wordPressKey : jetpackKey + } + + private var wordPressKey: WPAnalyticsEvent { + switch self { + case .viewed: + return .wordPressInstallPluginModalViewed + case .dismissed: + return .wordPressInstallPluginModalDismissed + case .primaryButtonTapped: + return .wordPressInstallPluginModalSwitchTapped + } + } + + private var jetpackKey: WPAnalyticsEvent { + switch self { + case .viewed: + return .jetpackInstallPluginModalViewed + case .dismissed: + return .jetpackInstallPluginModalDismissed + case .primaryButtonTapped: + return .jetpackInstallPluginModalInstallTapped + } + } + } + + func track(_ event: JetpackPluginOverlayAnalytics) { + WPAnalytics.track(event.key) + } + + func subtitle(withSiteName siteName: String, plugin: JetpackPlugin) -> NSAttributedString { + switch plugin { + case .multiple: + return subtitleForPluralPlugins(withSiteName: siteName) + default: + return subtitleForSinglePlugin(withSiteName: siteName, pluginName: plugin.displayName) + } + } + + func subtitleForPluralPlugins(withSiteName siteName: String) -> NSAttributedString { + let siteNameAttributedText = attributedSubtitle( + with: siteName, + fontWeight: .bold + ) + + if let jetpackPluginText = strings.jetpackPluginText { + let jetpackPluginAttributedText = attributedSubtitle( + with: jetpackPluginText, + fontWeight: .bold + ) + return NSAttributedString( + format: attributedSubtitle(with: strings.subtitlePlural, fontWeight: .regular), + args: ("%1$@", siteNameAttributedText), ("%2$@", jetpackPluginAttributedText) + ) + } + + return NSAttributedString( + format: attributedSubtitle(with: strings.subtitlePlural, fontWeight: .regular), + args: ("%1$@", siteNameAttributedText) + ) + } + + func subtitleForSinglePlugin(withSiteName siteName: String, pluginName: String) -> NSAttributedString { + let siteNameAttributedText = attributedSubtitle(with: siteName, fontWeight: .bold) + let jetpackBackupAttributedText = attributedSubtitle(with: pluginName, + fontWeight: .bold + ) + + if let jetpackPluginText = strings.jetpackPluginText { + let jetpackPluginAttributedText = attributedSubtitle( + with: jetpackPluginText, + fontWeight: .bold + ) + return NSAttributedString( + format: attributedSubtitle( + with: strings.subtitleSingular, + fontWeight: .regular), + args: ("%1$@", siteNameAttributedText), ("%2$@", jetpackBackupAttributedText), ("%3$@", jetpackPluginAttributedText) + ) + } + + return NSAttributedString( + format: attributedSubtitle( + with: strings.subtitleSingular, + fontWeight: .regular), + args: ("%1$@", siteNameAttributedText), ("%2$@", jetpackBackupAttributedText) + ) + } + + func actionInfoString() -> NSAttributedString? { + guard let footnote = strings.footnote, + let termsAndConditions = strings.termsAndConditions else { + return nil + } + let actionInfoBaseFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) + let actionInfoBaseText = NSAttributedString(string: footnote, attributes: [.font: actionInfoBaseFont]) + + let actionInfoTermsText = NSAttributedString( + string: termsAndConditions, + attributes: [ + .font: actionInfoBaseFont, + .underlineStyle: NSUnderlineStyle.single.rawValue + ] + ) + + return NSAttributedString( + format: actionInfoBaseText, + args: ("%@", actionInfoTermsText) + ) + } + + func attributedSubtitle(with string: String, fontWeight: UIFont.Weight) -> NSAttributedString { + let font = WPStyleGuide.fontForTextStyle(.body, fontWeight: fontWeight) + return NSAttributedString(string: string, attributes: [.font: font]) + } +} + +private extension NSAttributedString { + convenience init(format: NSAttributedString, args: (String, NSAttributedString)...) { + let mutableNSAttributedString = NSMutableAttributedString(attributedString: format) + + args.forEach { (key, attributedString) in + let range = NSString(string: mutableNSAttributedString.string).range(of: key) + mutableNSAttributedString.replaceCharacters(in: range, with: attributedString) + } + self.init(attributedString: mutableNSAttributedString) + } +} + +// MARK: - Strings + +private protocol JetpackPluginOverlayStrings { + var title: String { get } + var subtitleSingular: String { get } + var subtitlePlural: String { get } + var jetpackPluginText: String? { get } + var footnote: String? { get } + var termsAndConditions: String? { get } + var primaryButtonTitle: String { get } + var secondaryButtonTitle: String { get } +} + +extension JetpackPluginOverlayStrings { + var jetpackPluginText: String? { nil } + var footnote: String? { nil } + var termsAndConditions: String? { nil } +} + +private struct JetpackStrings: JetpackPluginOverlayStrings { + let title = NSLocalizedString( + "jetpack.plugin.modal.title", + value: "Please install the full Jetpack plugin", + comment: "Jetpack Plugin Modal title" + ) + + let subtitleSingular = NSLocalizedString( + "jetpack.plugin.modal.subtitle.singular", + value: """ + %1$@ is using the %2$@ plugin, which doesn't support all features of the app yet. + + Please install the %3$@ to use the app with this site. + """, + comment: """ + Jetpack Plugin Modal (single plugin) subtitle with formatted texts. + %1$@ is for the site name, %2$@ for the specific plugin name, + and %3$@ is for 'full Jetpack plugin' in bold style. + """ + ) + + let subtitlePlural = NSLocalizedString( + "jetpack.plugin.modal.subtitle.plural", + value: """ + %1$@ is using individual Jetpack plugins, which don't support all features of the app yet. + + Please install the %2$@ to use the app with this site. + """, + comment: """ + Jetpack Plugin Modal (multiple plugins) subtitle with formatted texts. + %1$@ is for the site name, and %2$@ for 'full Jetpack plugin' in bold style. + """ + ) + + let jetpackPluginText: String? = NSLocalizedString( + "jetpack.plugin.modal.subtitle.jetpack.plugin", + value: "full Jetpack plugin", + comment: "The 'full Jetpack plugin' string in the subtitle" + ) + + let footnote: String? = NSLocalizedString( + "jetpack.plugin.modal.footnote", + value: "By setting up Jetpack you agree to our %@", + comment: "Jetpack Plugin Modal footnote" + ) + + let termsAndConditions: String? = NSLocalizedString( + "jetpack.plugin.modal.terms", + value: "Terms and Conditions", + comment: "Jetpack Plugin Modal footnote terms and conditions" + ) + + let primaryButtonTitle = NSLocalizedString( + "jetpack.plugin.modal.primary.button.title", + value: "Install the full plugin", + comment: "Jetpack Plugin Modal primary button title" + ) + + let secondaryButtonTitle = NSLocalizedString( + "jetpack.plugin.modal.secondary.button.title", + value: "Contact Support", + comment: "Jetpack Plugin Modal secondary button title" + ) +} + +private struct WordPressStrings: JetpackPluginOverlayStrings { + let title = NSLocalizedString( + "wordpress.jetpack.plugin.modal.title", + value: "Sorry, this site isn't supported by the WordPress app", + comment: "Jetpack Plugin Modal title in WordPress" + ) + + var subtitleSingular: String { + let singularFormat = NSLocalizedString( + "wordpress.jetpack.plugin.modal.subtitle.singular", + value: "%1$@ is using the %2$@ plugin, which isn't supported by the WordPress App.", + comment: "Jetpack Plugin Modal on WordPress (single plugin) subtitle with formatted texts. " + + "%1$@ is for the site name and %2$@ is for the specific plugin name." + ) + return singularFormat + "\n\n" + switchToJetpack + } + + var subtitlePlural: String { + let pluralFormat = NSLocalizedString( + "wordpress.jetpack.plugin.modal.subtitle.plural", + value: "%1$@ is using individual Jetpack plugins, which isn't supported by the WordPress App.", + comment: "Jetpack Plugin Modal (multiple plugins) on WordPress subtitle with formatted texts. %1$@ is for the site name." + ) + return pluralFormat + "\n\n" + switchToJetpack + } + + let switchToJetpack = NSLocalizedString( + "wordpress.jetpack.plugin.modal.subtitle.switch", + value: "Please switch to the Jetpack app where we'll guide you through connecting the full " + + "Jetpack plugin so that you can use all the apps features for this site.", + comment: "Second paragraph of the Jetpack Plugin Modal on WordPress asking the user to switch to Jetpack." + ) + + let primaryButtonTitle = NSLocalizedString( + "wordpress.jetpack.plugin.modal.primary.button.title", + value: "Switch to the Jetpack app", + comment: "Jetpack Plugin Modal on WordPress primary button title" + ) + + let secondaryButtonTitle = NSLocalizedString( + "wordpress.jetpack.plugin.modal.secondary.button.title", + value: "Continue without Jetpack", + comment: "Jetpack Plugin Modal on WordPress secondary button title" + ) +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandedScreen.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandedScreen.swift new file mode 100644 index 000000000000..087a4ed50851 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandedScreen.swift @@ -0,0 +1,129 @@ +import Foundation + +protocol JetpackBrandedScreen { + + /// Name to use when constructing phase three branding text. + /// Set to `nil` to fallback to the default branding text. + var featureName: String? { get } + + /// Whether the feature is in plural form. + /// Used to decide on using "is" or "are" when constructing the branding text. + var isPlural: Bool { get } +} + +enum JetpackBannerScreen: String, JetpackBrandedScreen { + case activityLog = "activity_log" + case backup + case menus + case notifications + case people + case reader + case readerSearch = "reader_search" + case scan + case stats + case themes + + var featureName: String? { + switch self { + case .activityLog: + return NSLocalizedString("Activity", comment: "Noun. Name of the Activity Log feature") + case .backup: + return NSLocalizedString("Backup", comment: "Noun. Name of the Backup feature") + case .menus: + return NSLocalizedString("Menus", comment: "Noun. Name of the Menus feature") + case .notifications: + return NSLocalizedString("Notifications", comment: "Noun. Name of the Notifications feature") + case .people: + return nil + case .reader: + return NSLocalizedString("Reader", comment: "Noun. Name of the Reader feature") + case .readerSearch: + return NSLocalizedString("Reader", comment: "Noun. Name of the Reader feature") + case .scan: + return NSLocalizedString("Scan", comment: "Noun. Name of the Scan feature") + case .stats: + return NSLocalizedString("Stats", comment: "Noun. Abbreviation of Statistics. Name of the Stats feature") + case .themes: + return NSLocalizedString("Themes", comment: "Noun. Name of the Themes feature") + } + } + + var isPlural: Bool { + switch self { + case .activityLog: + fallthrough + case .backup: + fallthrough + case .reader: + fallthrough + case .people: + fallthrough + case .scan: + fallthrough + case .readerSearch: + return false + case .notifications: + fallthrough + case .menus: + fallthrough + case .themes: + fallthrough + case .stats: + return true + } + } +} + +enum JetpackBadgeScreen: String, JetpackBrandedScreen { + case activityDetail = "activity_detail" + case appSettings = "app_settings" + case home + case me + case notificationsSettings = "notifications_settings" + case person + case readerDetail = "reader_detail" + case sharing + + var featureName: String? { + switch self { + case .appSettings: + fallthrough + case .home: + fallthrough + case .person: + fallthrough + case .me: + return nil + case .activityDetail: + return NSLocalizedString("Activity", comment: "Noun. Name of the Activity Log feature") + case .notificationsSettings: + return NSLocalizedString("Notifications", comment: "Noun. Name of the Notifications feature") + case .readerDetail: + return NSLocalizedString("Reader", comment: "Noun. Name of the Reader feature") + case .sharing: + return NSLocalizedString("Sharing", comment: "Noun. Name of the Social Sharing feature") + } + + } + + var isPlural: Bool { + switch self { + case .appSettings: + fallthrough + case .home: + fallthrough + case .me: + fallthrough + case .activityDetail: + fallthrough + case .readerDetail: + fallthrough + case .person: + fallthrough + case .sharing: + return false + case .notificationsSettings: + return true + } + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandingAnalyticsHelper.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandingAnalyticsHelper.swift new file mode 100644 index 000000000000..7b8220e4b5d1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandingAnalyticsHelper.swift @@ -0,0 +1,23 @@ +import Foundation +import AutomatticTracks + +struct JetpackBrandingAnalyticsHelper { + private static let screenPropertyKey = "screen" + + // MARK: - Jetpack powered badge tapped + static func trackJetpackPoweredBadgeTapped(screen: JetpackBadgeScreen) { + let properties = [screenPropertyKey: screen.rawValue] + WPAnalytics.track(.jetpackPoweredBadgeTapped, properties: properties) + } + + // MARK: - Jetpack powered banner tapped + static func trackJetpackPoweredBannerTapped(screen: JetpackBannerScreen) { + let properties = [screenPropertyKey: screen.rawValue] + WPAnalytics.track(.jetpackPoweredBannerTapped, properties: properties) + } + + // MARK: - Jetpack powered bottom sheet button tapped + static func trackJetpackPoweredBottomSheetButtonTapped() { + WPAnalytics.track(.jetpackPoweredBottomSheetButtonTapped) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandingTextProvider.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandingTextProvider.swift new file mode 100644 index 000000000000..c74bc3387bc0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandingTextProvider.swift @@ -0,0 +1,139 @@ +import Foundation + +struct JetpackBrandingTextProvider { + + // MARK: Private Variables + + private let screen: JetpackBrandedScreen? + private let featureFlagStore: RemoteFeatureFlagStore + private let remoteConfigStore: RemoteConfigStore + private let currentDateProvider: CurrentDateProvider + + private var phase: JetpackFeaturesRemovalCoordinator.GeneralPhase { + return JetpackFeaturesRemovalCoordinator.generalPhase(featureFlagStore: featureFlagStore) + } + + // MARK: Initializer + + init(screen: JetpackBrandedScreen?, + featureFlagStore: RemoteFeatureFlagStore = RemoteFeatureFlagStore(), + remoteConfigStore: RemoteConfigStore = RemoteConfigStore(), + currentDateProvider: CurrentDateProvider = DefaultCurrentDateProvider()) { + self.screen = screen + self.featureFlagStore = featureFlagStore + self.remoteConfigStore = remoteConfigStore + self.currentDateProvider = currentDateProvider + } + + // MARK: Public Functions + + func brandingText() -> String { + switch phase { + case .two: + return Strings.phaseTwoText + case .three: + return phaseThreeText() + case .staticScreens: + return staticScreensPhaseText() + default: + return Strings.defaultText + } + } + + // MARK: Helpers + + private func phaseThreeText() -> String { + guard let screen = screen, let featureName = screen.featureName else { + return Strings.defaultText // Screen not provided, or was opted out by defining a nil featureName + } + + guard let deadline = JetpackFeaturesRemovalCoordinator.removalDeadline(remoteConfigStore: remoteConfigStore) else { + return String(format: movingSoonString, featureName) // Couldn't retrieve the deadline + } + + let now = currentDateProvider.date() + guard now < deadline else { + return Strings.defaultText // Deadline has passed. Avoid displaying negative values. + } + + guard let dateString = dateString(now: now, deadline: deadline) else { + return String(format: movingSoonString, featureName) // Deadline is more than a month away + } + + return String(format: movingInString, featureName, dateString) + } + + private func staticScreensPhaseText() -> String { + guard let screen = screen, let _ = screen.featureName else { + return Strings.defaultText // Screen not provided, or was opted out by defining a nil featureName + } + + return Strings.phaseStaticScreensText + } + + private func dateString(now: Date, deadline: Date) -> String? { + let calendar = Calendar.current + var components = calendar.dateComponents([.month], from: now, to: deadline) + let months = components.month ?? 0 + if months > 0 { + return nil // Fallback to moving soon text + } + + let formatter = DateComponentsFormatter() + formatter.maximumUnitCount = 1 + formatter.unitsStyle = .full + + components = calendar.dateComponents([.weekOfMonth], from: now, to: deadline) + let weeks = components.weekOfMonth ?? 0 + if weeks > 0 { + formatter.allowedUnits = [.weekOfMonth] + return formatter.string(from: components) // Deadline is x weeks away + } + + components = calendar.dateComponents([.day], from: now, to: deadline) + let days = max(components.day ?? 0, 1) // Avoid displaying "0 days" + components.day = days + formatter.allowedUnits = [.day] + return formatter.string(from: components) // Deadline is x days away + } +} + +private extension JetpackBrandingTextProvider { + enum Strings { + static let defaultText = NSLocalizedString("jetpack.branding.badge_banner.title", + value: "Jetpack powered", + comment: "Title of the Jetpack powered badge.") + static let phaseTwoText = NSLocalizedString("jetpack.branding.badge_banner.title.phase2", + value: "Get the Jetpack app", + comment: "Title of the Jetpack powered badge.") + static let phaseThreePluralMovingSoonText = NSLocalizedString("jetpack.branding.badge_banner.moving_soon.plural", + value: "%@ are moving soon", + comment: "Title of a badge indicating that a feature in plural form will be removed soon. First argument is the feature name. Ex: Notifications are moving soon") + static let phaseThreeSingularMovingSoonText = NSLocalizedString("jetpack.branding.badge_banner.moving_soon.singular", + value: "%@ is moving soon", + comment: "Title of a badge indicating that a feature in singular form will be removed soon. First argument is the feature name. Ex: Reader is moving soon") + static let phaseThreePluralMovingInText = NSLocalizedString("jetpack.branding.badge_banner.moving_in.plural", + value: "%@ are moving in %@", + comment: "Title of a badge indicating when a feature in plural form will be removed. First argument is the feature name. Second argument is the number of days/weeks it will be removed in. Ex: Notifications are moving in 2 weeks") + static let phaseThreeSingularMovingInText = NSLocalizedString("jetpack.branding.badge_banner.moving_in.singular", + value: "%@ is moving in %@", + comment: "Title of a badge indicating when a feature in singular form will be removed. First argument is the feature name. Second argument is the number of days/weeks it will be removed in. Ex: Reader is moving in 2 weeks") + static let phaseStaticScreensText = NSLocalizedString( + "jetpack.branding.badge_banner.moving_in_days.plural", + value: "Moving to the Jetpack app in a few days.", + comment: "Title of a badge or banner indicating that this feature will be moved in a few days." + ) + } + + private var isPlural: Bool { + screen?.isPlural ?? false + } + + var movingSoonString: String { + return isPlural ? Strings.phaseThreePluralMovingSoonText : Strings.phaseThreeSingularMovingSoonText + } + + var movingInString: String { + return isPlural ? Strings.phaseThreePluralMovingInText : Strings.phaseThreeSingularMovingInText + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandingVisibility.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandingVisibility.swift new file mode 100644 index 000000000000..73599cbe2678 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/JetpackBrandingVisibility.swift @@ -0,0 +1,31 @@ + +import Foundation + +/// An enum that unifies the checks to limit the visibility of the Jetpack branding elements (banners and badges) +enum JetpackBrandingVisibility { + + case all + case wordPressApp + case dotcomAccounts + case dotcomAccountsOnWpApp // useful if we want to release in phases and exclude the feature flag in some cases + case featureFlagBased + + var enabled: Bool { + switch self { + case .all: + return AppConfiguration.isWordPress && + AccountHelper.isDotcomAvailable() && + FeatureFlag.jetpackPowered.enabled && + JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures() + case .wordPressApp: + return AppConfiguration.isWordPress + case .dotcomAccounts: + return AccountHelper.isDotcomAvailable() + case .dotcomAccountsOnWpApp: + return AppConfiguration.isWordPress && + AccountHelper.isDotcomAvailable() + case .featureFlagBased: + return FeatureFlag.jetpackPowered.enabled + } + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/BlogDetailsViewController+JetpackBrandingMenuCard.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/BlogDetailsViewController+JetpackBrandingMenuCard.swift new file mode 100644 index 000000000000..4fdf7ff0142d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/BlogDetailsViewController+JetpackBrandingMenuCard.swift @@ -0,0 +1,34 @@ +import Foundation + +extension BlogDetailsViewController { + + @objc var shouldShowTopJetpackBrandingMenuCard: Bool { + let presenter = JetpackBrandingMenuCardPresenter(blog: self.blog) + return presenter.shouldShowTopCard() + } + + @objc var shouldShowBottomJetpackBrandingMenuCard: Bool { + let presenter = JetpackBrandingMenuCardPresenter(blog: self.blog) + return presenter.shouldShowBottomCard() + } + + @objc func jetpackCardSectionViewModel() -> BlogDetailsSection { + let row = BlogDetailsRow() + row.callback = { + let presenter = JetpackBrandingMenuCardPresenter(blog: self.blog) + JetpackFeaturesRemovalCoordinator.presentOverlayIfNeeded(in: self, source: .card, blog: self.blog) + presenter.trackCardTapped() + } + + let section = BlogDetailsSection(title: nil, + rows: [row], + footerTitle: nil, + category: .jetpackBrandingCard) + return section + } + + func reloadTableView() { + configureTableViewData() + reloadTableViewPreservingSelection() + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/JetpackBrandingMenuCardCell.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/JetpackBrandingMenuCardCell.swift new file mode 100644 index 000000000000..f3912c63bd1c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/JetpackBrandingMenuCardCell.swift @@ -0,0 +1,426 @@ +import UIKit +import Lottie + +class JetpackBrandingMenuCardCell: UITableViewCell { + + // MARK: Private Variables + + private weak var viewController: BlogDetailsViewController? + private var presenter: JetpackBrandingMenuCardPresenter? + private var config: JetpackBrandingMenuCardPresenter.Config? + + /// Sets the animation based on the language orientation + private var animation: Animation? { + traitCollection.layoutDirection == .leftToRight ? + Animation.named(Constants.animationLtr) : + Animation.named(Constants.animationRtl) + } + + private var cardType: JetpackBrandingMenuCardPresenter.Config.CardType { + config?.type ?? .expanded + } + + // MARK: Lazy Loading General Views + + private lazy var cardFrameView: BlogDashboardCardFrameView = { + let frameView = BlogDashboardCardFrameView() + frameView.translatesAutoresizingMaskIntoConstraints = false + frameView.hideHeader() + return frameView + }() + + private lazy var containerStackView: UIStackView = { + let stackView = UIStackView() + stackView.alignment = .fill + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.isLayoutMarginsRelativeArrangement = true + return stackView + }() + + // MARK: Lazy Loading Expanded Card Views + + private lazy var logosSuperview: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + view.addSubview(logosAnimationView) + + view.topAnchor.constraint(equalTo: logosAnimationView.topAnchor).isActive = true + view.bottomAnchor.constraint(equalTo: logosAnimationView.bottomAnchor).isActive = true + view.leadingAnchor.constraint(equalTo: logosAnimationView.leadingAnchor).isActive = true + + return view + }() + + private lazy var logosAnimationView: AnimationView = { + let view = AnimationView() + view.translatesAutoresizingMaskIntoConstraints = false + view.animation = animation + + // Height Constraint + view.heightAnchor.constraint(equalToConstant: Metrics.Expanded.animationsViewHeight).isActive = true + + // Width constraint to achieve aspect ratio + let animationSize = animation?.size ?? .init(width: 1, height: 1) + let ratio = animationSize.width / animationSize.height + view.widthAnchor.constraint(equalTo: view.heightAnchor, multiplier: ratio).isActive = true + + return view + }() + + private lazy var label: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + return label + }() + + private lazy var learnMoreSuperview: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + view.addSubview(learnMoreButton) + + view.topAnchor.constraint(equalTo: learnMoreButton.topAnchor).isActive = true + view.bottomAnchor.constraint(equalTo: learnMoreButton.bottomAnchor).isActive = true + view.leadingAnchor.constraint(equalTo: learnMoreButton.leadingAnchor).isActive = true + + return view + }() + + private lazy var learnMoreButton: UIButton = { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.tintColor = Metrics.learnMoreButtonTextColor + button.titleLabel?.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.setTitle(Strings.learnMoreButtonText, for: .normal) + button.addTarget(self, action: #selector(learnMoreButtonTapped), for: .touchUpInside) + + if #available(iOS 15.0, *) { + var learnMoreButtonConfig: UIButton.Configuration = .plain() + learnMoreButtonConfig.contentInsets = Metrics.learnMoreButtonContentInsets + button.configuration = learnMoreButtonConfig + } else { + button.contentEdgeInsets = Metrics.learnMoreButtonContentEdgeInsets + button.flipInsetsForRightToLeftLayoutDirection() + } + + return button + }() + + // MARK: Lazy Loading Compact Card Views + + private lazy var jetpackIconImageView: UIImageView = { + let imageView = UIImageView() + let image = UIImage(named: Constants.jetpackIcon) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.image = image + imageView.heightAnchor.constraint(equalToConstant: Metrics.Compact.logoImageViewSize).isActive = true + imageView.widthAnchor.constraint(equalToConstant: Metrics.Compact.logoImageViewSize).isActive = true + return imageView + }() + + private lazy var ellipsisButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(UIImage.gridicon(.ellipsis).imageWithTintColor(Metrics.Compact.ellipsisButtonColor), for: .normal) + button.contentEdgeInsets = Metrics.Compact.ellipsisButtonPadding + button.isAccessibilityElement = true + button.accessibilityLabel = Strings.ellipsisButtonAccessibilityLabel + button.accessibilityTraits = .button + button.setContentHuggingPriority(.defaultHigh, for: .horizontal) + button.showsMenuAsPrimaryAction = true + button.menu = contextMenu + button.on([.touchUpInside, .menuActionTriggered]) { [weak self] _ in + self?.presenter?.trackContextualMenuAccessed() + } + return button + }() + + // MARK: Initializers + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + addSubviews() + } + + // MARK: Cell Lifecycle + + override func prepareForReuse() { + containerStackView.removeAllSubviews() + } + + // MARK: Helpers + + private func configure() { + setupContent() + applyStyles() + configureCardFrame() + + presenter?.trackCardShown() + } + + private func addSubviews() { + contentView.addSubview(cardFrameView) + contentView.pinSubviewToAllEdges(cardFrameView, priority: Metrics.cardFrameConstraintPriority) + cardFrameView.add(subview: containerStackView) + } + + private func setupContent() { + containerStackView.addArrangedSubviews(stackViewSubviews) + logosAnimationView.currentProgress = 1.0 + label.text = config?.description + } + + private func applyStyles() { + containerStackView.axis = stackViewAxis + containerStackView.spacing = stackViewSpacing + containerStackView.directionalLayoutMargins = stackViewLayoutMargins + label.font = labelFont + label.textColor = labelTextColor + label.numberOfLines = labelNumberOfLines + } + + private func configureCardFrame() { + if cardType == .expanded { + cardFrameView.configureButtonContainerStackView() + cardFrameView.onEllipsisButtonTap = { [weak self] in + self?.presenter?.trackContextualMenuAccessed() + } + cardFrameView.ellipsisButton.showsMenuAsPrimaryAction = true + cardFrameView.ellipsisButton.menu = contextMenu + } + else { + cardFrameView.removeButtonContainerStackView() + cardFrameView.onEllipsisButtonTap = nil + } + } + + // MARK: Actions + + @objc private func learnMoreButtonTapped() { + guard let viewController else { + return + } + + JetpackFeaturesRemovalCoordinator.presentOverlayIfNeeded(in: viewController, + source: .card, + blog: viewController.blog) + presenter?.trackLinkTapped() + } +} + +// MARK: Contextual Menu + +private extension JetpackBrandingMenuCardCell { + + // MARK: Items + + // Defines the structure of the contextual menu items. + private var contextMenuItems: [MenuItem] { + return [.remindLater(remindMeLaterTapped), .hide(hideThisTapped)] + } + + // MARK: Menu Creation + + private var contextMenu: UIMenu { + let actions = contextMenuItems.map { $0.toAction } + return .init(title: String(), options: .displayInline, children: actions) + } + + // MARK: Actions + + private func remindMeLaterTapped() { + presenter?.remindLaterTapped() + viewController?.reloadTableView() + } + + private func hideThisTapped() { + presenter?.hideThisTapped() + viewController?.reloadTableView() + } +} + +private extension JetpackBrandingMenuCardCell { + var stackViewAxis: NSLayoutConstraint.Axis { + switch cardType { + case .compact: + return .horizontal + case .expanded: + return .vertical + } + } + + var stackViewSpacing: CGFloat { + switch cardType { + case .compact: + return Metrics.Compact.spacing + case .expanded: + return Metrics.Expanded.spacing + } + } + + var stackViewLayoutMargins: NSDirectionalEdgeInsets { + switch cardType { + case .compact: + return Metrics.Compact.containerMargins + case .expanded: + return Metrics.Expanded.containerMargins + } + } + + var stackViewSubviews: [UIView] { + switch cardType { + case .compact: + return [jetpackIconImageView, label, ellipsisButton] + case .expanded: + return [logosSuperview, label, learnMoreSuperview] + } + } + + var labelFont: UIFont { + switch cardType { + case .compact: + return Metrics.Compact.labelFont + case .expanded: + return Metrics.Expanded.labelFont + } + } + + var labelTextColor: UIColor { + switch cardType { + case .compact: + return Metrics.Compact.labelTextColor + case .expanded: + return Metrics.Expanded.labelTextColor + } + } + + var labelNumberOfLines: Int { + switch cardType { + case .compact: + return 1 + case .expanded: + return 0 + } + } + +} + +private extension JetpackBrandingMenuCardCell { + + enum Metrics { + // General + enum Expanded { + static let spacing: CGFloat = 10 + static let containerMargins = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 12, trailing: 20) + static let animationsViewHeight: CGFloat = 32 + static var labelFont: UIFont { + let maximumFontPointSize: CGFloat = 16 + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + let font = UIFont(descriptor: fontDescriptor, size: min(fontDescriptor.pointSize, maximumFontPointSize)) + return UIFontMetrics.default.scaledFont(for: font) + } + static let labelTextColor: UIColor = .label + } + + enum Compact { + static let spacing: CGFloat = 15 + static let containerMargins = NSDirectionalEdgeInsets(top: 15, leading: 20, bottom: 7, trailing: 12) + static let logoImageViewSize: CGFloat = 24 + static let ellipsisButtonPadding = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + static let ellipsisButtonColor = UIColor.muriel(color: .gray, .shade20) + static var labelFont: UIFont { + let maximumFontPointSize: CGFloat = 17 + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + let font = UIFont(descriptor: fontDescriptor, size: min(fontDescriptor.pointSize, maximumFontPointSize)) + return UIFontMetrics.default.scaledFont(for: font) + } + static let labelTextColor: UIColor = UIColor.muriel(color: .jetpackGreen, .shade40) + } + + static let cardFrameConstraintPriority = UILayoutPriority(999) + + // Learn more button + static let learnMoreButtonContentInsets = NSDirectionalEdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 24) + static let learnMoreButtonContentEdgeInsets = UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 24) + static let learnMoreButtonTextColor: UIColor = UIColor.muriel(color: .jetpackGreen, .shade40) + } + + enum Constants { + static let animationLtr = "JetpackAllFeaturesLogosAnimation_ltr" + static let animationRtl = "JetpackAllFeaturesLogosAnimation_rtl" + static let analyticsSource = "jetpack_menu_card" + static let remindMeLaterSystemImageName = "alarm" + static let hideThisLaterSystemImageName = "eye.slash" + static let jetpackIcon = "icon-jetpack" + } + + enum Strings { + static let learnMoreButtonText = NSLocalizedString("jetpack.menuCard.learnMore", + value: "Learn more", + comment: "Title of a button that displays a blog post in a web view.") + static let remindMeLaterMenuItemTitle = NSLocalizedString("jetpack.menuCard.remindLater", + value: "Remind me later", + comment: "Menu item title to hide the card for now and show it later.") + static let hideCardMenuItemTitle = NSLocalizedString("jetpack.menuCard.hide", + value: "Hide this", + comment: "Menu item title to hide the card.") + static let ellipsisButtonAccessibilityLabel = NSLocalizedString("ellipsisButton.AccessibilityLabel", + value: "More", + comment: "Accessibility label for more button in dashboard quick start card.") + } + + enum MenuItem { + case remindLater(_ handler: () -> Void) + case hide(_ handler: () -> Void) + + var title: String { + switch self { + case .remindLater: + return Strings.remindMeLaterMenuItemTitle + case .hide: + return Strings.hideCardMenuItemTitle + } + } + + var image: UIImage? { + switch self { + case .remindLater: + return .init(systemName: Constants.remindMeLaterSystemImageName) + case .hide: + return .init(systemName: Constants.hideThisLaterSystemImageName) + } + } + + var toAction: UIAction { + switch self { + case .remindLater(let handler), + .hide(let handler): + return UIAction(title: title, image: image, attributes: []) { _ in + handler() + } + } + } + } +} + +extension JetpackBrandingMenuCardCell { + + @objc(configureWithViewController:) + func configure(with viewController: BlogDetailsViewController) { + self.viewController = viewController + presenter = JetpackBrandingMenuCardPresenter(blog: viewController.blog) + config = presenter?.cardConfig() + configure() + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/JetpackBrandingMenuCardPresenter.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/JetpackBrandingMenuCardPresenter.swift new file mode 100644 index 000000000000..85b7fdd7c123 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Menu Card/JetpackBrandingMenuCardPresenter.swift @@ -0,0 +1,204 @@ +import Foundation + +class JetpackBrandingMenuCardPresenter { + + struct Config { + + enum CardType { + case compact, expanded + } + + let description: String + let type: CardType + } + + // MARK: Private Variables + + private let blog: Blog? + private let remoteConfigStore: RemoteConfigStore + private let persistenceStore: UserPersistentRepository + private let currentDateProvider: CurrentDateProvider + private let featureFlagStore: RemoteFeatureFlagStore + private var phase: JetpackFeaturesRemovalCoordinator.GeneralPhase { + return JetpackFeaturesRemovalCoordinator.generalPhase(featureFlagStore: featureFlagStore) + } + + // MARK: Initializers + + init(blog: Blog?, + remoteConfigStore: RemoteConfigStore = RemoteConfigStore(), + featureFlagStore: RemoteFeatureFlagStore = RemoteFeatureFlagStore(), + persistenceStore: UserPersistentRepository = UserDefaults.standard, + currentDateProvider: CurrentDateProvider = DefaultCurrentDateProvider()) { + self.blog = blog + self.remoteConfigStore = remoteConfigStore + self.persistenceStore = persistenceStore + self.currentDateProvider = currentDateProvider + self.featureFlagStore = featureFlagStore + } + + // MARK: Public Functions + + func cardConfig() -> Config? { + switch phase { + case .three: + let description = Strings.phaseThreeDescription + return .init(description: description, type: .expanded) + case .four: + let description = Strings.phaseFourTitle + return .init(description: description, type: .compact) + case .newUsers: + let description = Strings.newUsersPhaseDescription + return .init(description: description, type: .expanded) + case .selfHosted: + let description = Strings.selfHostedPhaseDescription + return .init(description: description, type: .expanded) + default: + return nil + } + } + + func shouldShowTopCard() -> Bool { + guard isCardEnabled() else { + return false + } + let jetpackFeaturesEnabled = JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures(featureFlagStore: featureFlagStore) + switch (phase, jetpackFeaturesEnabled) { + case (.three, true): + return true + case (.selfHosted, false): + return blog?.jetpackIsConnected ?? false + default: + return false + } + } + + func shouldShowBottomCard() -> Bool { + guard isCardEnabled() else { + return false + } + let jetpackFeaturesEnabled = JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures(featureFlagStore: featureFlagStore) + switch (phase, jetpackFeaturesEnabled) { + case (.four, false): + fallthrough + case (.newUsers, false): + return true + default: + return false + } + } + + private func isCardEnabled() -> Bool { + let showCardOnDate = showCardOnDate ?? .distantPast // If not set, then return distant past so that the condition below always succeeds + guard shouldHideCard == false, // Card not hidden + showCardOnDate < currentDateProvider.date() else { // Interval has passed if temporarily hidden + return false + } + return true + } + + func remindLaterTapped() { + let now = currentDateProvider.date() + let duration = Constants.remindLaterDurationInDays * Constants.secondsInDay + let newDate = now.addingTimeInterval(TimeInterval(duration)) + showCardOnDate = newDate + trackRemindMeLaterTapped() + } + + func hideThisTapped() { + shouldHideCard = true + trackHideThisTapped() + } +} + +// MARK: Analytics + +extension JetpackBrandingMenuCardPresenter { + + func trackCardShown() { + WPAnalytics.track(.jetpackBrandingMenuCardDisplayed, properties: analyticsProperties) + } + + func trackLinkTapped() { + WPAnalytics.track(.jetpackBrandingMenuCardLinkTapped, properties: analyticsProperties) + } + + func trackCardTapped() { + WPAnalytics.track(.jetpackBrandingMenuCardTapped, properties: analyticsProperties) + } + + func trackContextualMenuAccessed() { + WPAnalytics.track(.jetpackBrandingMenuCardContextualMenuAccessed, properties: analyticsProperties) + } + + func trackHideThisTapped() { + WPAnalytics.track(.jetpackBrandingMenuCardHidden, properties: analyticsProperties) + } + + func trackRemindMeLaterTapped() { + WPAnalytics.track(.jetpackBrandingMenuCardRemindLater, properties: analyticsProperties) + } + + private var analyticsProperties: [String: String] { + let phase = JetpackFeaturesRemovalCoordinator.generalPhase(featureFlagStore: featureFlagStore) + return [Constants.phaseAnalyticsKey: phase.rawValue] + } +} + +private extension JetpackBrandingMenuCardPresenter { + + // MARK: Dynamic Keys + + var shouldHideCardKey: String { + return "\(Constants.shouldHideCardKey)-\(phase.rawValue)" + } + + var showCardOnDateKey: String { + return "\(Constants.showCardOnDateKey)-\(phase.rawValue)" + } + + // MARK: Persistence Variables + + var shouldHideCard: Bool { + get { + persistenceStore.bool(forKey: shouldHideCardKey) + } + + set { + persistenceStore.set(newValue, forKey: shouldHideCardKey) + } + } + + var showCardOnDate: Date? { + get { + persistenceStore.object(forKey: showCardOnDateKey) as? Date + } + + set { + persistenceStore.set(newValue, forKey: showCardOnDateKey) + } + } +} + +private extension JetpackBrandingMenuCardPresenter { + enum Constants { + static let secondsInDay = 86_400 + static let remindLaterDurationInDays = 4 + static let shouldHideCardKey = "JetpackBrandingShouldHideCardKey" + static let showCardOnDateKey = "JetpackBrandingShowCardOnDateKey" + static let phaseAnalyticsKey = "phase" + } + + enum Strings { + static let phaseThreeDescription = NSLocalizedString("jetpack.menuCard.description", + value: "Stats, Reader, Notifications and other features will move to the Jetpack mobile app soon.", + comment: "Description inside a menu card communicating that features are moving to the Jetpack app.") + static let phaseFourTitle = NSLocalizedString("jetpack.menuCard.phaseFour.title", + value: "Switch to Jetpack", + comment: "Title of a button prompting users to switch to the Jetpack app.") + static let newUsersPhaseDescription = NSLocalizedString("jetpack.menuCard.newUsers.title", + value: "Unlock your site’s full potential. Get Stats, Reader, Notifications and more with Jetpack.", + comment: "Description inside a menu card prompting users to switch to the Jetpack app.") + static let selfHostedPhaseDescription = newUsersPhaseDescription + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Moved To Jetpack/MovedToJetpackEventsTracker.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Moved To Jetpack/MovedToJetpackEventsTracker.swift new file mode 100644 index 000000000000..d52459f7aa5d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Moved To Jetpack/MovedToJetpackEventsTracker.swift @@ -0,0 +1,22 @@ +import Foundation + +struct MovedToJetpackEventsTracker { + + let source: MovedToJetpackSource + + func trackScreenDisplayed() { + WPAnalytics.track(.removeStaticPosterDisplayed, properties: analyticsProperties(for: source)) + } + + func trackJetpackButtonTapped() { + WPAnalytics.track(.removeStaticPosterButtonTapped, properties: analyticsProperties(for: source)) + } + + func trackJetpackLinkTapped() { + WPAnalytics.track(.removeStaticPosterLinkTapped, properties: analyticsProperties(for: source)) + } + + private func analyticsProperties(for source: MovedToJetpackSource) -> [String: String] { + return [WPAppAnalyticsKeySource: source.description] + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Moved To Jetpack/MovedToJetpackViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Moved To Jetpack/MovedToJetpackViewController.swift new file mode 100644 index 000000000000..3958bdb859b5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Moved To Jetpack/MovedToJetpackViewController.swift @@ -0,0 +1,263 @@ +import UIKit +import Lottie + +final class MovedToJetpackViewController: UIViewController { + + // MARK: - Subviews + + private lazy var scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.bounces = false + + /// Configure constraints + scrollView.addSubview(containerView) + scrollView.pinSubviewToAllEdges(containerView) + + return scrollView + }() + + private lazy var containerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + + /// Configure constraints + view.addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor, constant: Metrics.stackViewMargin), + stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + stackView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor, constant: Metrics.stackViewMargin), + stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor) + ]) + + return view + }() + + private lazy var stackView: UIStackView = { + let subviews = [ + animationContainerView, + titleLabel, + descriptionLabel, + hintLabel, + jetpackButton, + learnMoreButton + ] + let stackView = UIStackView(arrangedSubviews: subviews) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .leading + + /// Configure spacing + stackView.spacing = Metrics.stackViewSpacing + stackView.setCustomSpacing(Metrics.hintToJetpackButtonSpacing, after: hintLabel) + + return stackView + }() + + private lazy var animationContainerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + + /// Configure constraints + view.addSubview(animationView) + view.pinSubviewToAllEdges(animationView) + + return view + }() + + private lazy var animationView: AnimationView = { + let animationView = AnimationView() + animationView.translatesAutoresizingMaskIntoConstraints = false + animationView.animation = animation + return animationView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = viewModel.title + label.font = WPStyleGuide.fontForTextStyle(.largeTitle, fontWeight: .bold) + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.numberOfLines = 0 + label.textColor = .text + return label + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = viewModel.description + label.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.numberOfLines = 0 + label.textColor = .text + return label + }() + + private lazy var hintLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = viewModel.hint + label.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.numberOfLines = 0 + label.textColor = .textSubtle + return label + }() + + private lazy var jetpackButton: UIButton = { + let button = FancyButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(viewModel.jetpackButtonTitle, for: .normal) + button.isPrimary = true + button.primaryNormalBackgroundColor = .jetpackGreen + button.primaryHighlightBackgroundColor = .muriel(color: .jetpackGreen, .shade80) + button.addTarget(self, action: #selector(jetpackButtonTapped), for: .touchUpInside) + return button + }() + + private lazy var learnMoreButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setAttributedTitle(learnMoreAttributedString(), for: .normal) + button.tintColor = .jetpackGreen + button.titleLabel?.font = WPStyleGuide.fontForTextStyle(.headline, fontWeight: .regular) + button.addTarget(self, action: #selector(learnMoreButtonTapped), for: .touchUpInside) + return button + }() + + // MARK: - Properties + + private let source: MovedToJetpackSource + private let viewModel: MovedToJetpackViewModel + private let tracker: MovedToJetpackEventsTracker + + /// Sets the animation based on the language orientation + private var animation: Animation? { + traitCollection.layoutDirection == .leftToRight ? + Animation.named(viewModel.animationLtr) : + Animation.named(viewModel.animationRtl) + } + + // MARK: - Initializers + + @objc init(source: MovedToJetpackSource) { + self.source = source + self.viewModel = MovedToJetpackViewModel(source: source) + self.tracker = MovedToJetpackEventsTracker(source: source) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + // This VC is designed to be initialized programmatically. + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + navigationController?.delegate = self + setupView() + animationView.play() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + tracker.trackScreenDisplayed() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + animationView.currentProgress = 1.0 + } + + // MARK: - Navigation overrides + + override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return .portrait + } + + // MARK: - View setup + + private func setupView() { + view.backgroundColor = .basicBackground + view.addSubview(scrollView) + view.pinSubviewToAllEdges(scrollView) + + NSLayoutConstraint.activate([ + containerView.widthAnchor.constraint(equalTo: view.widthAnchor), + containerView.heightAnchor.constraint(greaterThanOrEqualTo: view.heightAnchor, constant: 0), + jetpackButton.heightAnchor.constraint(equalToConstant: Metrics.buttonHeight), + jetpackButton.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), + jetpackButton.centerXAnchor.constraint(equalTo: stackView.centerXAnchor), + learnMoreButton.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), + learnMoreButton.centerXAnchor.constraint(equalTo: stackView.centerXAnchor) + ]) + } + + private func learnMoreAttributedString() -> NSAttributedString { + let externalAttachment = NSTextAttachment(image: UIImage.gridicon(.external, size: Metrics.externalIconSize).withTintColor(.jetpackGreen)) + externalAttachment.bounds = Metrics.externalIconBounds + let attachmentString = NSAttributedString(attachment: externalAttachment) + let learnMoreText = NSMutableAttributedString(string: "\(viewModel.learnMoreButtonTitle) \u{FEFF}") + learnMoreText.append(attachmentString) + return NSAttributedString(attributedString: learnMoreText) + } + + // MARK: - Button action + + @objc private func jetpackButtonTapped() { + // Try to export WordPress data to a shared location before redirecting the user. + ContentMigrationCoordinator.shared.startAndDo { [weak self] _ in + JetpackRedirector.redirectToJetpack() + self?.tracker.trackJetpackButtonTapped() + } + } + + @objc private func learnMoreButtonTapped() { + guard let url = URL(string: Constants.learnMoreButtonURL) else { + return + } + + let webViewController = WebViewControllerFactory.controller(url: url, source: Constants.learnMoreWebViewSource) + let navigationController = UINavigationController(rootViewController: webViewController) + self.present(navigationController, animated: true) + + tracker.trackJetpackLinkTapped() + } +} + +// MARK: - UINavigationControllerDelegate + +extension MovedToJetpackViewController: UINavigationControllerDelegate { + + func navigationControllerSupportedInterfaceOrientations(_ navigationController: UINavigationController) -> UIInterfaceOrientationMask { + return supportedInterfaceOrientations + } + + func navigationControllerPreferredInterfaceOrientationForPresentation(_ navigationController: UINavigationController) -> UIInterfaceOrientation { + return .portrait + } +} + +extension MovedToJetpackViewController { + + private enum Constants { + static let learnMoreButtonURL = "https://jetpack.com/support/switch-to-the-jetpack-app/" + static let learnMoreWebViewSource = "jp_removal_static_poster" + } + + private enum Metrics { + static let stackViewMargin: CGFloat = 20 + static let stackViewSpacing: CGFloat = 20 + static let hintToJetpackButtonSpacing: CGFloat = 40 + static let buttonHeight: CGFloat = 50 + static let externalIconSize = CGSize(width: 16, height: 16) + static let externalIconBounds = CGRect(x: 0, y: -2, width: 16, height: 16) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Moved To Jetpack/MovedToJetpackViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Moved To Jetpack/MovedToJetpackViewModel.swift new file mode 100644 index 000000000000..2c43a68dbdfe --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Moved To Jetpack/MovedToJetpackViewModel.swift @@ -0,0 +1,131 @@ +import Foundation + +@objc enum MovedToJetpackSource: Int { + case stats + case reader + case notifications + + var description: String { + switch self { + case .stats: + return "stats" + case .reader: + return "reader" + case .notifications: + return "notifications" + } + } +} + +struct MovedToJetpackViewModel { + + let source: MovedToJetpackSource + + var animationLtr: String { + switch source { + case .stats: + return Constants.statsLogoAnimationLtr + case .reader: + return Constants.readerLogoAnimationLtr + case .notifications: + return Constants.notificationsLogoAnimationLtr + } + } + + var animationRtl: String { + switch source { + case .stats: + return Constants.statsLogoAnimationRtl + case .reader: + return Constants.readerLogoAnimationRtl + case .notifications: + return Constants.notificationsLogoAnimationRtl + } + } + + var title: String { + switch source { + case .stats: + return Strings.statsTitle + case .reader: + return Strings.readerTitle + case .notifications: + return Strings.notificationsTitle + } + } + + var description: String { + return Strings.description + } + + var hint: String { + return Strings.hint + } + + var jetpackButtonTitle: String { + return Strings.jetpackButtonTitle + } + + var learnMoreButtonTitle: String { + return Strings.learnMoreButtonTitle + } + +} + +extension MovedToJetpackViewModel { + + private enum Constants { + static let statsLogoAnimationLtr = "JetpackStatsLogoAnimation_ltr" + static let statsLogoAnimationRtl = "JetpackStatsLogoAnimation_rtl" + static let readerLogoAnimationLtr = "JetpackReaderLogoAnimation_ltr" + static let readerLogoAnimationRtl = "JetpackReaderLogoAnimation_rtl" + static let notificationsLogoAnimationLtr = "JetpackNotificationsLogoAnimation_ltr" + static let notificationsLogoAnimationRtl = "JetpackNotificationsLogoAnimation_rtl" + } + + private enum Strings { + + static let statsTitle = NSLocalizedString( + "movedToJetpack.stats.title", + value: "Stats have moved to the Jetpack app.", + comment: "Title for the static screen displayed in the Stats screen prompting users to switch to the Jetpack app." + ) + + static let readerTitle = NSLocalizedString( + "movedToJetpack.reader.title", + value: "Reader has moved to the Jetpack app.", + comment: "Title for the static screen displayed in the Reader screen prompting users to switch to the Jetpack app." + ) + + static let notificationsTitle = NSLocalizedString( + "movedToJetpack.notifications.title", + value: "Notifications have moved to the Jetpack app.", + comment: "Title for the static screen displayed in the Stats screen prompting users to switch to the Jetpack app." + ) + + static let description = NSLocalizedString( + "movedToJetpack.description", + value: "Stats, Reader, Notifications and other Jetpack powered features have been removed from the WordPress app, and can now only be found in the Jetpack app.", + comment: "Description for the static screen displayed prompting users to switch the Jetpack app." + ) + + static let hint = NSLocalizedString( + "movedToJetpack.hint", + value: "Switching is free and only takes a minute.", + comment: "Hint for the static screen displayed prompting users to switch the Jetpack app." + ) + + static let jetpackButtonTitle = NSLocalizedString( + "movedToJetpack.jetpackButtonTitle", + value: "Switch to the Jetpack app", + comment: "Title for a button that prompts users to switch to the Jetpack app." + ) + + static let learnMoreButtonTitle = NSLocalizedString( + "movedToJetpack.learnMoreButtonTitle", + value: "Learn more at jetpack.com", + comment: "Title for a button that displays a blog post in a web view." + ) + + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayView.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayView.swift new file mode 100644 index 000000000000..13be6eddb0e0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayView.swift @@ -0,0 +1,222 @@ +import Lottie +import UIKit + +class JetpackOverlayView: UIView { + + private var buttonAction: (() -> Void)? + + private var dismissButtonTintColor: UIColor { + UIColor(light: .muriel(color: .gray, .shade5), + dark: .muriel(color: .jetpackGreen, .shade90).lightVariant()) + } + + private var dismissButtonImage: UIImage { + let fontForSystemImage = UIFont.systemFont(ofSize: Metrics.dismissButtonSize) + let configuration = UIImage.SymbolConfiguration(font: fontForSystemImage) + + // fallback to the gridicon if for any reason the system image fails to render + return UIImage(systemName: Graphics.dismissButtonSystemName, withConfiguration: configuration) ?? + UIImage.gridicon(.crossCircle, size: CGSize(width: Metrics.dismissButtonSize, height: Metrics.dismissButtonSize)) + } + + /// Sets the animation based on the language orientation + private var animation: Animation? { + traitCollection.layoutDirection == .leftToRight ? + Animation.named(Graphics.wpJetpackLogoAnimationLtr) : + Animation.named(Graphics.wpJetpackLogoAnimationRtl) + } + + private lazy var dismissButton: CircularImageButton = { + let button = CircularImageButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(dismissButtonImage, for: .normal) + button.tintColor = dismissButtonTintColor + button.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) + return button + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [animationContainerView, titleLabel, descriptionLabel, getJetpackButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .leading + return stackView + }() + + private lazy var animationContainerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var animationView: AnimationView = { + let animationView = AnimationView() + animationView.animation = animation + animationView.translatesAutoresizingMaskIntoConstraints = false + return animationView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = Metrics.minimumScaleFactor + label.font = Metrics.titleFont + label.numberOfLines = Metrics.titleLabelNumberOfLines + label.textAlignment = .natural + label.text = TextContent.title + label.setContentCompressionResistancePriority(Metrics.titleCompressionResistance, for: .vertical) + return label + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = Metrics.minimumScaleFactor + label.font = Metrics.descriptionFont + label.numberOfLines = Metrics.descriptionLabelNumberOfLines + label.textAlignment = .natural + label.text = TextContent.description + label.setContentCompressionResistancePriority(Metrics.descriptionCompressionResistance, for: .vertical) + return label + }() + + private lazy var getJetpackButton: UIButton = { + let button = UIButton() + button.backgroundColor = .muriel(color: .jetpackGreen, .shade40) + button.setTitle(TextContent.buttonTitle, for: .normal) + button.titleLabel?.adjustsFontSizeToFitWidth = true + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.layer.cornerRadius = Metrics.tryJetpackButtonCornerRadius + button.layer.cornerCurve = .continuous + return button + }() + + @objc private func didTapButton() { + buttonAction?() + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBottomSheetButtonTapped() + } + + @objc private func dismissTapped() { + guard let presentingViewController = next as? UIViewController else { + return + } + presentingViewController.dismiss(animated: true) + } + + private func setup() { + backgroundColor = UIColor(light: .white, + dark: .muriel(color: .jetpackGreen, .shade100)) + addSubview(dismissButton) + addSubview(stackView) + stackView.setCustomSpacing(Metrics.imageToTitleSpacing, after: animationContainerView) + stackView.setCustomSpacing(Metrics.titleToDescriptionSpacing, after: titleLabel) + stackView.setCustomSpacing(Metrics.descriptionToButtonSpacing, after: descriptionLabel) + animationContainerView.addSubview(animationView) + getJetpackButton.addTarget(self, action: #selector(didTapButton), for: .touchUpInside) + configureConstraints() + dismissButton.setImageBackgroundColor(UIColor(light: .black, dark: .white)) + animationView.play() + } + + init(buttonAction: (() -> Void)? = nil) { + self.buttonAction = buttonAction + super.init(frame: .zero) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureConstraints() { + animationContainerView.pinSubviewToAllEdges(animationView) + + + let stackViewTrailingConstraint = stackView.trailingAnchor.constraint(equalTo: trailingAnchor, + constant: -Metrics.edgeMargins.right) + stackViewTrailingConstraint.priority = Metrics.veryHighPriority + let stackViewBottomConstraint = stackView.bottomAnchor.constraint(lessThanOrEqualTo: safeBottomAnchor, + constant: -Metrics.edgeMargins.bottom) + stackViewBottomConstraint.priority = Metrics.veryHighPriority + + NSLayoutConstraint.activate([ + dismissButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Metrics.dismissButtonTrailingPadding), + dismissButton.topAnchor.constraint(equalTo: topAnchor, constant: Metrics.dismissButtonTopPadding), + dismissButton.heightAnchor.constraint(equalToConstant: Metrics.dismissButtonSize), + dismissButton.widthAnchor.constraint(equalToConstant: Metrics.dismissButtonSize), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Metrics.edgeMargins.left), + stackViewTrailingConstraint, + stackView.topAnchor.constraint(equalTo: dismissButton.bottomAnchor), + stackViewBottomConstraint, + + getJetpackButton.heightAnchor.constraint(equalToConstant: Metrics.tryJetpackButtonHeight), + getJetpackButton.widthAnchor.constraint(equalTo: stackView.widthAnchor), + ]) + } +} + +// MARK: Appearance +private extension JetpackOverlayView { + + enum Graphics { + static let wpJetpackLogoAnimationLtr = "JetpackWordPressLogoAnimation_ltr" + static let wpJetpackLogoAnimationRtl = "JetpackWordPressLogoAnimation_rtl" + static let dismissButtonSystemName = "xmark.circle.fill" + } + + enum Metrics { + // stack view + static let imageToTitleSpacing: CGFloat = 24 + static let titleToDescriptionSpacing: CGFloat = 10 + static let descriptionToButtonSpacing: CGFloat = 40 + static let edgeMargins = UIEdgeInsets(top: 46, left: 30, bottom: 20, right: 30) + // dismiss button + static let dismissButtonTopPadding: CGFloat = 10 // takes into account the gripper + static let dismissButtonTrailingPadding: CGFloat = 20 + static let dismissButtonSize: CGFloat = 30 + // labels + static let maximumFontSize: CGFloat = 32 + static let minimumScaleFactor: CGFloat = 0.6 + + static let titleLabelNumberOfLines = 2 + + static let descriptionLabelNumberOfLines = 0 + + static let titleCompressionResistance = UILayoutPriority(rawValue: 751) + static let descriptionCompressionResistance = UILayoutPriority(rawValue: 749) + + static var titleFont: UIFont { + let weightTrait = [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold] + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .title1).addingAttributes([.traits: weightTrait]) + let font = UIFont(descriptor: fontDescriptor, size: min(fontDescriptor.pointSize, maximumFontSize)) + return UIFontMetrics.default.scaledFont(for: font, maximumPointSize: maximumFontSize) + } + + static var descriptionFont: UIFont { + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) + let font = UIFont(descriptor: fontDescriptor, size: min(fontDescriptor.pointSize, maximumFontSize)) + return UIFontMetrics.default.scaledFont(for: font, maximumPointSize: maximumFontSize) + } + // "Try Jetpack" button + static let tryJetpackButtonHeight: CGFloat = 44 + static let tryJetpackButtonCornerRadius: CGFloat = 6 + // constraints + static let veryHighPriority = UILayoutPriority(rawValue: 999) + } + + enum TextContent { + static let title = NSLocalizedString("jetpack.branding.overlay.title", + value: "WordPress is better with Jetpack", + comment: "Title of the Jetpack powered overlay.") + + static let description = NSLocalizedString("jetpack.branding.overlay.description", + value: "The new Jetpack app has Stats, Reader, Notifications, and more that make your WordPress better.", + comment: "Description of the Jetpack powered overlay.") + + static let buttonTitle = NSLocalizedString("jetpack.branding.overlay.button.title", + value: "Try the new Jetpack app", + comment: "Button title of the Jetpack powered overlay.") + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayViewController.swift new file mode 100644 index 000000000000..2c58c8bf9e00 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Branding/Overlay/JetpackOverlayViewController.swift @@ -0,0 +1,58 @@ +import UIKit + +class JetpackOverlayViewController: UIViewController { + + private var redirectAction: (() -> Void)? + + private var viewFactory: ((() -> Void)?) -> UIView + + init(viewFactory: @escaping ((() -> Void)?) -> UIView, redirectAction: (() -> Void)? = nil) { + self.redirectAction = redirectAction + self.viewFactory = viewFactory + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = viewFactory(redirectAction) + } + + private func setPreferredContentSize() { + let size = CGSize(width: view.bounds.width, height: UIView.layoutFittingCompressedSize.height) + preferredContentSize = view.systemLayoutSizeFitting(size) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + setPreferredContentSize() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + view.setNeedsLayout() + } +} + +extension JetpackOverlayViewController: DrawerPresentable { + var collapsedHeight: DrawerHeight { + .intrinsicHeight + } + + var allowsUserTransition: Bool { + false + } + + var compactWidth: DrawerWidth { + .maxWidth + } +} + +extension JetpackOverlayViewController: ChildDrawerPositionable { + var preferredDrawerPosition: DrawerPosition { + .collapsed + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackInstallPluginHelper.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackInstallPluginHelper.swift new file mode 100644 index 000000000000..2784d1d01f98 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackInstallPluginHelper.swift @@ -0,0 +1,306 @@ +@objc +class JetpackInstallPluginHelper: NSObject { + + // MARK: Dependencies + + private let repository: UserPersistentRepository + private let currentDateProvider: CurrentDateProvider + private let remoteConfigStore: RemoteConfigStore + private let receipt: RecentJetpackInstallReceipt + private let blog: Blog + private let siteIDString: String + + /// Determines whether the install cards should be shown for this `blog` in the My Site screen. + var shouldShowCard: Bool { + shouldPromptInstall && !isCardHidden + } + + /// Determines whether the plugin install overlay should be shown for this `blog`. + var shouldShowOverlay: Bool { + if AppConfiguration.isJetpack { + // For Jetpack, the overlay will be shown once per site. + return shouldPromptInstall && !isOverlayAlreadyShown + } + + return shouldShowOverlayInWordPress + } + + // MARK: Methods + + /// Convenient static method that determines whether we should show the install cards for the given `blog`. + /// + /// - Parameter blog: The `Blog` to show the install cards for, + /// - Returns: True if the install cards should be shown for this blog. + @objc static func shouldShowCard(for blog: Blog?) -> Bool { + // cards are only shown in Jetpack. + guard AppConfiguration.isJetpack, + let helper = JetpackInstallPluginHelper(blog) else { + return false + } + + return helper.shouldShowCard + } + + /// Convenience entry point to show the Jetpack Install Plugin overlay when needed. + /// The overlay will only be shown when: + /// 1. User accesses this `Blog` via their WordPress.com account, + /// 2. The `Blog` has individual Jetpack plugin(s) installed, without the full Jetpack plugin, + /// 3. The overlay has never been shown for this site before (overrideable by setting `force` to `true`). + /// + /// - Parameters: + /// - blog: The Blog that might need the full Jetpack plgin. + /// - presentingViewController: The view controller that will be presenting the overlay. + /// - force: Whether the overlay should be shown regardless if the overlay has been shown previously. + static func presentOverlayIfNeeded(in presentingViewController: UIViewController, + blog: Blog?, + delegate: JetpackRemoteInstallDelegate?, + force: Bool = false) { + guard let blog, + let siteURLString = blog.displayURL as? String, // just the host URL without the scheme. + let plugin = JetpackPlugin(from: blog.jetpackConnectionActivePlugins), + let helper = JetpackInstallPluginHelper(blog), + helper.shouldShowOverlay || force else { + return + } + + // create the overlay stack. + let viewModel = JetpackPluginOverlayViewModel(siteName: siteURLString, plugin: plugin) + let overlayViewController = JetpackFullscreenOverlayViewController(with: viewModel) + var coordinator: JetpackOverlayCoordinator? + + // present the overlay. + let navigationViewController = UINavigationController(rootViewController: overlayViewController) + if AppConfiguration.isWordPress { + let defaultCoordinator = JetpackDefaultOverlayCoordinator() + defaultCoordinator.navigationController = navigationViewController + coordinator = defaultCoordinator + } else { + coordinator = JetpackPluginOverlayCoordinator(blog: blog, + viewController: overlayViewController, + installDelegate: delegate) + } + viewModel.coordinator = coordinator + let shouldUseFormSheet = WPDeviceIdentification.isiPad() + navigationViewController.modalPresentationStyle = shouldUseFormSheet ? .formSheet : .fullScreen + presentingViewController.present(navigationViewController, animated: true) { + helper.markOverlayAsShown() + } + } + + init?(_ blog: Blog?, + repository: UserPersistentRepository = UserPersistentStoreFactory.instance(), + currentDateProvider: CurrentDateProvider = DefaultCurrentDateProvider(), + remoteConfigStore: RemoteConfigStore = .init(), + receipt: RecentJetpackInstallReceipt = .shared) { + guard let blog, + let siteID = blog.dotComID?.stringValue, + blog.account != nil, + JetpackInstallPluginHelper.isFeatureEnabled else { + return nil + } + + self.blog = blog + self.siteIDString = siteID + self.repository = repository + self.currentDateProvider = currentDateProvider + self.remoteConfigStore = remoteConfigStore + self.receipt = receipt + } + + func hideCard() { + if isCardHidden { + return + } + cardHiddenSites += [siteIDString] + } + + func markOverlayAsShown() { + guard AppConfiguration.isJetpack else { + markOverlayShownInWordPress() + return + } + + if isOverlayAlreadyShown { + return + } + overlayShownSites += [siteIDString] + } +} + +// MARK: - Private Helpers + +private extension JetpackInstallPluginHelper { + + static var isFeatureEnabled: Bool { + if AppConfiguration.isJetpack { + return FeatureFlag.jetpackIndividualPluginSupport.enabled + } + + return RemoteFeatureFlag.wordPressIndividualPluginSupport.enabled() + } + + /// Returns true if the card has been set to hidden for `blog`. For Jetpack only. + var isCardHidden: Bool { + cardHiddenSites.contains { $0 == siteIDString } + } + + var cardHiddenSites: [String] { + get { + (repository.array(forKey: Constants.cardHiddenSitesKey) as? [String]) ?? [String]() + } + set { + repository.set(newValue, forKey: Constants.cardHiddenSitesKey) + } + } + + /// Returns true if the overlay has been shown for `blog`. For Jetpack only. + var isOverlayAlreadyShown: Bool { + overlayShownSites.contains { $0 == siteIDString } + } + + var overlayShownSites: [String] { + get { + (repository.array(forKey: Constants.overlayShownSitesKey) as? [String]) ?? [String]() + } + set { + repository.set(newValue, forKey: Constants.overlayShownSitesKey) + } + } + + var shouldPromptInstall: Bool { + blog.jetpackIsConnectedWithoutFullPlugin && !receipt.installed(for: siteIDString) + } + + // MARK: Constants + + struct Constants { + static let cardHiddenSitesKey = "jetpack-install-card-hidden-sites" + static let overlayShownSitesKey = "jetpack-install-overlay-shown-sites" + } +} + +// MARK: - Recent Jetpack Install Receipt + +/// A simple helper class that tracks recent Jetpack installations in-memory. +/// This is done to help keep things updated as soon as the plugin is installed. +/// Otherwise, we'd have to manually call sync from each callsites, wait for them to complete, and _then_ update. +class RecentJetpackInstallReceipt { + private(set) static var shared = RecentJetpackInstallReceipt() + + private var siteIDs = Set<String>() + + func installed(for siteID: String) -> Bool { + return siteIDs.contains(siteID) + } + + func store(_ siteID: String) { + siteIDs.insert(siteID) + } +} + +// MARK: - WordPress Helpers + +private extension JetpackInstallPluginHelper { + + var maxOverlayShownPerSite: Int { + RemoteConfigParameter.wordPressPluginOverlayMaxShown.value(using: remoteConfigStore) ?? 0 + } + + var shouldShowOverlayInWordPress: Bool { + let overlayInfo = WordPressOverlayInfo(siteID: siteIDString, + repository: repository, + currentDateProvider: currentDateProvider) + + guard overlayInfo.amountShown < maxOverlayShownPerSite, + currentDateProvider.date() >= overlayInfo.nextOccurrence else { + return false + } + + return shouldPromptInstall + } + + func markOverlayShownInWordPress() { + let overlayInfo = WordPressOverlayInfo(siteID: siteIDString, + repository: repository, + currentDateProvider: currentDateProvider) + + overlayInfo.updateNextOccurrence() + } +} + +private class WordPressOverlayInfo { + private static let dateFormatter = ISO8601DateFormatter() + private let siteID: String + private let repository: UserPersistentRepository + private let currentDateProvider: CurrentDateProvider + + /// Tracks the overlay occurrences for all sites in dictionary format. + /// + /// The occurrences are stored as an array of date strings, and the amount of strings tell how many times + /// the overlay has been shown for the site. + /// + /// For example, given `["2023-03-17 17:00", "2023-03-25 11:00"]`, this tells that: + /// - The overlay has been shown 2 times, and + /// - The third overlay may be shown after 2023-03-25 11:00. + /// + private var overlayOccurrenceSites: [String: [String]] { + get { + (repository.dictionary(forKey: Constants.overlayOccurrenceSitesKey) as? [String: [String]]) ?? .init() + } + set { + repository.set(newValue, forKey: Constants.overlayOccurrenceSitesKey) + } + } + + /// How many times the overlay has been shown for the site. + var amountShown: Int { + overlayOccurrenceSites[siteID]?.count ?? 0 + } + + /// The minimum date before the overlay can be shown again for the site. + private(set) var nextOccurrence: Date { + get { + guard let dateString = overlayOccurrenceSites[siteID]?.last, + let date = Self.dateFormatter.date(from: dateString) else { + return .distantPast + } + return date + } + set { + var mutableDictionary = overlayOccurrenceSites + var occurrencesForSite = mutableDictionary[siteID] ?? [String]() + occurrencesForSite.append(Self.dateFormatter.string(from: newValue)) + mutableDictionary[siteID] = occurrencesForSite + + overlayOccurrenceSites = mutableDictionary + } + } + + init(siteID: String, repository: UserPersistentRepository, currentDateProvider: CurrentDateProvider) { + self.siteID = siteID + self.repository = repository + self.currentDateProvider = currentDateProvider + } + + func updateNextOccurrence() { + nextOccurrence = currentDateProvider.date().addingTimeInterval(delay(after: amountShown + 1)) + } + + private func delay(after amountShown: Int) -> TimeInterval { + switch amountShown { + case 1: + return Constants.oneDayInterval + case 2: + return Constants.threeDaysInterval + default: + return Constants.oneWeekInterval + } + } + + private struct Constants { + static let overlayOccurrenceSitesKey = "jetpack-install-overlay-occurrence-sites" + static let oneDayInterval: TimeInterval = 60 * 60 * 24 + static let threeDaysInterval: TimeInterval = oneDayInterval * 3 + static let oneWeekInterval: TimeInterval = oneDayInterval * 7 + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackNativeConnectionService.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackNativeConnectionService.swift new file mode 100644 index 000000000000..933cf33a7d1a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackNativeConnectionService.swift @@ -0,0 +1,87 @@ +import Foundation + +enum JetpackNativeConnectionURLError: Error { + case jetpackSiteNotRegistered + case remote(String) +} + +enum JetpackNativeConnectionDataError: Error { + case parsingError + case remote(String) +} + +struct JetpackUserData: Codable { + let currentUser: JetpackUser +} + +struct JetpackUser: Codable { + let isConnected: Bool +} + +final class JetpackNativeConnectionService: NSObject { + private let api: WordPressOrgRestApi + + init(api: WordPressOrgRestApi) { + self.api = api + } + + private struct Constants { + static let jetpackAccountConnectionURL = "https://jetpack.wordpress.com/jetpack.authorize" + } + + private struct Path { + static let getConnectionURL = "jetpack/v4/connection/url" + static let getJetpackUserData = "jetpack/v4/connection/data" + } + + + /// Fetches Jetpack Connection URL that can be used to start Jetpack plugin connection process using Jetpack REST API + /// https://github.com/Automattic/jetpack/blob/trunk/docs/rest-api.md#get-wp-jsonjetpackv4connectionurl + /// + /// - Parameter completion: Result with either Jetpack connection URL or JetpackNativeConnectionURLError + /// + func fetchJetpackConnectionURL(completion: @escaping (Result<URL, JetpackNativeConnectionURLError>) -> ()) { + api.request(method: .get, path: Path.getConnectionURL, parameters: [:], completion: { result, response in + switch result { + case .success(let data): + if let urlString = data as? String, + let url = URL(string: urlString), + urlString.hasPrefix(Constants.jetpackAccountConnectionURL) { + completion(.success(url)) + } else { + /// If the site didn't implement the site-level connection, the URL would be at the form: https://{site_url}/wp-admin/admin.php?page=jetpack&action=register&_wpnonce={nonce} + /// In this case, we need to take cookies from current response, call the returned URL, + /// and get the connection URL through redirection (See https://github.com/woocommerce/woocommerce-android/issues/7525) + + /// When site-level connection is not implemented, JetpackConnectionWebViewController + /// does not use JetpackNativeConnectionService so this case is ignored for now + completion(.failure(.jetpackSiteNotRegistered)) + } + case .failure(let error): + completion(.failure(.remote(error.localizedDescription))) + } + }) + } + + /// Fetches Jetpack User that contains Jetpack plugin connection information using Jetpack REST API + /// https://github.com/Automattic/jetpack/blob/trunk/docs/rest-api.md#get-wp-jsonjetpackv4connectiondata + /// + /// - Parameter completion: Result with either JetpackUser or JetpackNativeConnectionDataError + /// + func fetchJetpackUser(completion: @escaping (Result<JetpackUser, JetpackNativeConnectionDataError>) -> ()) { + api.request(method: .get, path: Path.getJetpackUserData, parameters: [:], completion: { result, response in + switch result { + case .success(let json): + do { + let data = try JSONSerialization.data(withJSONObject: json) + let jetpackUserData = try JSONDecoder().decode(JetpackUserData.self, from: data) + completion(.success(jetpackUserData.currentUser)) + } catch { + completion(.failure(.parsingError)) + } + case .failure(let error): + completion(.failure(.remote(error.localizedDescription))) + } + }) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackPlugin.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackPlugin.swift new file mode 100644 index 000000000000..01bac275df53 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackPlugin.swift @@ -0,0 +1,43 @@ +enum JetpackPlugin: String { + case search = "jetpack-search" + case backup = "jetpack-backup" + case protect = "jetpack-protect" + case videoPress = "jetpack-videopress" + case social = "jetpack-social" + case boost = "jetpack-boost" + case multiple + + init?(from rawValues: [String]?) { + guard let rawValues, + !rawValues.isEmpty else { + return nil + } + + guard rawValues.count == 1, + let rawValue = rawValues.first else { + self = .multiple + return + } + + self.init(rawValue: rawValue) + } + + var displayName: String { + switch self { + case .search: + return "Jetpack Search" + case .backup: + return "Jetpack VaultPress Backup" + case .protect: + return "Jetpack Protect" + case .videoPress: + return "Jetpack VideoPress" + case .social: + return "Jetpack Social" + case .boost: + return "Jetpack Boost" + case .multiple: + return "" + } + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackRemoteInstallViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackRemoteInstallViewController.swift index b85ebdd52379..3e6c6143921e 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackRemoteInstallViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/JetpackRemoteInstallViewController.swift @@ -1,25 +1,23 @@ import WordPressAuthenticator -protocol JetpackRemoteInstallDelegate: class { +protocol JetpackRemoteInstallDelegate: AnyObject { func jetpackRemoteInstallCompleted() func jetpackRemoteInstallCanceled() func jetpackRemoteInstallWebviewFallback() } class JetpackRemoteInstallViewController: UIViewController { - private typealias JetpackInstallBlock = (String, String, String, WPAnalyticsStat) -> Void - private weak var delegate: JetpackRemoteInstallDelegate? - private var promptType: JetpackLoginPromptType private var blog: Blog private let jetpackView = JetpackRemoteInstallStateView() private let viewModel: JetpackRemoteInstallViewModel - init(blog: Blog, delegate: JetpackRemoteInstallDelegate?, promptType: JetpackLoginPromptType) { + init(blog: Blog, + delegate: JetpackRemoteInstallDelegate?, + viewModel: JetpackRemoteInstallViewModel = SelfHostedJetpackRemoteInstallViewModel()) { self.blog = blog self.delegate = delegate - self.promptType = promptType - self.viewModel = JetpackRemoteInstallViewModel() + self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } @@ -64,62 +62,78 @@ private extension JetpackRemoteInstallViewController { } func setupViewModel() { - viewModel.onChangeState = { [weak self] state in + viewModel.onChangeState = { [weak self] state, viewData in + guard let self else { + return + } + DispatchQueue.main.async { - self?.jetpackView.setupView(for: state) + self.jetpackView.configure(with: viewData) } switch state { + case .install: + self.viewModel.track(.initial) + case .installing: + self.viewModel.track(.loading) case .success: - WPAnalytics.track(.installJetpackRemoteCompleted) + self.viewModel.track(.completed) + + // Hide the Cancel button if the flow skips the Jetpack connection. + if !self.viewModel.shouldConnectToJetpack { + self.navigationItem.setLeftBarButton(nil, animated: false) + } + case .failure(let error): - WPAnalytics.track(.installJetpackRemoteFailed, - withProperties: ["error": error.type.rawValue, - "site_url": self?.blog.url ?? "unknown"]) - let url = self?.blog.url ?? "unknown" + let blogURLString = self.blog.url ?? "unknown" + self.viewModel.track(.failed(description: error.description, siteURLString: blogURLString)) + let title = error.title ?? "no error message" let type = error.type.rawValue let code = error.code - DDLogError("Jetpack Remote Install error for site \(url) – \(title) (\(code): \(type))") + DDLogError("Jetpack Remote Install error for site \(blogURLString) – \(title) (\(code): \(type))") if error.isBlockingError { DDLogInfo("Jetpack Remote Install error - Blocking error") - self?.delegate?.jetpackRemoteInstallWebviewFallback() + self.delegate?.jetpackRemoteInstallWebviewFallback() } - default: - break } } } func openInstallJetpackURL() { - let event: WPAnalyticsStat = AccountHelper.isLoggedIn ? .installJetpackRemoteConnect : .installJetpackRemoteLogin - WPAnalytics.track(event) + viewModel.track(AccountHelper.isLoggedIn ? .connect : .login) let controller = JetpackConnectionWebViewController(blog: blog) controller.delegate = self navigationController?.pushViewController(controller, animated: true) } - func installJetpack(with url: String, username: String, password: String, event: WPAnalyticsStat) { - WPAnalytics.track(event) - viewModel.installJetpack(with: url, username: username, password: password) - } - + /// Cancels the flow. @objc func cancel() { + viewModel.track(.cancel) + viewModel.cancelTapped() delegate?.jetpackRemoteInstallCanceled() } + + /// Completes the Jetpack installation flow. + func complete() { + if let siteID = blog.dotComID?.stringValue { + RecentJetpackInstallReceipt.shared.store(siteID) + } + delegate?.jetpackRemoteInstallCompleted() + } } // MARK: - Jetpack Connection Web Delegate extension JetpackRemoteInstallViewController: JetpackConnectionWebDelegate { func jetpackConnectionCanceled() { - delegate?.jetpackRemoteInstallCanceled() + cancel() } func jetpackConnectionCompleted() { - delegate?.jetpackRemoteInstallCompleted() + complete() } } @@ -127,18 +141,19 @@ extension JetpackRemoteInstallViewController: JetpackConnectionWebDelegate { extension JetpackRemoteInstallViewController: JetpackRemoteInstallStateViewDelegate { func mainButtonDidTouch() { - guard let url = blog.url, - let username = blog.username, - let password = blog.password else { - return - } - switch viewModel.state { case .install: - installJetpack(with: url, username: username, password: password, event: .installJetpackRemoteStart) + viewModel.track(.start) + viewModel.installJetpack(for: blog, isRetry: false) case .failure: - installJetpack(with: url, username: username, password: password, event: .installJetpackRemoteRetry) + viewModel.track(.retry) + viewModel.installJetpack(for: blog, isRetry: true) case .success: + viewModel.track(.completePrimaryButtonTapped) + guard viewModel.shouldConnectToJetpack else { + complete() + return + } openInstallJetpackURL() default: break @@ -146,6 +161,21 @@ extension JetpackRemoteInstallViewController: JetpackRemoteInstallStateViewDeleg } func customerSupportButtonDidTouch() { - navigationController?.pushViewController(SupportTableViewController(), animated: true) + let supportViewController = SupportTableViewController() + supportViewController.sourceTag = viewModel.supportSourceTag + navigationController?.pushViewController(supportViewController, animated: true) + } +} + +// MARK: - Error Helpers + +extension JetpackInstallError { + /// When the error is unknown, return the error title (if it exists) to get a more descriptive reason. + var description: String { + if let title, + type == .unknown { + return title + } + return type.rawValue } } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallCardView.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallCardView.swift new file mode 100644 index 000000000000..6260de7578f3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallCardView.swift @@ -0,0 +1,180 @@ +import UIKit +import Lottie + +class JetpackRemoteInstallCardView: UIView { + + // MARK: Properties + + private var viewModel: JetpackRemoteInstallCardViewModel + + private lazy var animation: Animation? = { + effectiveUserInterfaceLayoutDirection == .leftToRight ? + Animation.named(Constants.lottieLTRFileName) : + Animation.named(Constants.lottieRTLFileName) + }() + + private lazy var logosAnimationView: AnimationView = { + let view = AnimationView() + view.translatesAutoresizingMaskIntoConstraints = false + view.animation = animation + let animationSize = animation?.size ?? .init(width: 1, height: 1) + let ratio = animationSize.width / animationSize.height + view.addConstraints([ + view.heightAnchor.constraint(equalToConstant: Constants.iconHeight), + view.widthAnchor.constraint(equalTo: view.heightAnchor, multiplier: ratio), + ]) + view.currentProgress = 1.0 + + return view + }() + + private lazy var logosStackView: UIStackView = { + return UIStackView(arrangedSubviews: [logosAnimationView, UIView()]) + }() + + private lazy var noticeLabel: UILabel = { + let label = UILabel() + label.font = Constants.noticeLabelFont + label.attributedText = viewModel.noticeLabel + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + return label + }() + + private lazy var learnMoreButton: UIButton = { + let button = UIButton() + button.setTitle(Strings.learnMore, for: .normal) + button.setTitleColor(.primary, for: .normal) + button.titleLabel?.font = Constants.learnMoreFont + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.contentHorizontalAlignment = .leading + button.addTarget(self, action: #selector(onLearnMoreTap), for: .touchUpInside) + return button + }() + + private lazy var contentStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [logosStackView, noticeLabel, learnMoreButton]) + stackView.axis = .vertical + stackView.spacing = Constants.contentSpacing + stackView.isLayoutMarginsRelativeArrangement = true + stackView.directionalLayoutMargins = Constants.contentDirectionalLayoutMargins + return stackView + }() + + private lazy var contextMenu: UIMenu = { + let hideThisAction = UIAction(title: Strings.hideThis, + image: Constants.hideThisImage, + attributes: [UIMenuElement.Attributes.destructive], + handler: viewModel.onHideThisTap) + return UIMenu(title: String(), options: .displayInline, children: [hideThisAction]) + }() + + private lazy var cardFrameView: BlogDashboardCardFrameView = { + let frameView = BlogDashboardCardFrameView() + frameView.translatesAutoresizingMaskIntoConstraints = false + frameView.onEllipsisButtonTap = {} + frameView.ellipsisButton.showsMenuAsPrimaryAction = true + frameView.ellipsisButton.menu = contextMenu + frameView.add(subview: contentStackView) + return frameView + }() + + // MARK: Initializers + + init(_ viewModel: JetpackRemoteInstallCardViewModel = JetpackRemoteInstallCardViewModel()) { + self.viewModel = viewModel + super.init(frame: .zero) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Functions + + func updatePlugin(_ plugin: JetpackPlugin?) { + guard let plugin else { + return + } + viewModel.installedPlugin = plugin + noticeLabel.attributedText = viewModel.noticeLabel + } + + @objc func onLearnMoreTap() { + viewModel.onLearnMoreTap() + } + + private func setupView() { + addSubview(cardFrameView) + pinSubviewToAllEdges(cardFrameView) + } + + // MARK: Constants + + struct Constants { + static let lottieLTRFileName = "JetpackInstallPluginLogoAnimation_ltr" + static let lottieRTLFileName = "JetpackInstallPluginLogoAnimation_rtl" + static let hideThisImage = UIImage(systemName: "eye.slash") + static let iconHeight: CGFloat = 30.0 + static let contentSpacing: CGFloat = 10.0 + static let noticeLabelFont = WPStyleGuide.fontForTextStyle(.callout) + static let learnMoreFont = WPStyleGuide.fontForTextStyle(.callout).semibold() + static let contentDirectionalLayoutMargins = NSDirectionalEdgeInsets(top: -24.0, leading: 20.0, bottom: 12.0, trailing: 20.0) + } + + struct Strings { + static let learnMore = NSLocalizedString("jetpackinstallcard.button.learn", + value: "Learn more", + comment: "Title for a call-to-action button on the Jetpack install card.") + static let hideThis = NSLocalizedString("jetpackinstallcard.menu.hide", + value: "Hide this", + comment: "Title for a menu action in the context menu on the Jetpack install card.") + + } + +} + +// MARK: - JetpackRemoteInstallCardViewModel + +struct JetpackRemoteInstallCardViewModel { + + let onHideThisTap: UIActionHandler + let onLearnMoreTap: () -> Void + var installedPlugin: JetpackPlugin + + var noticeLabel: NSAttributedString { + switch installedPlugin { + case .multiple: + return NSAttributedString(string: Strings.multiplePlugins) + default: + let noticeText = String(format: Strings.individualPluginFormat, installedPlugin.displayName) + let boldNoticeText = NSMutableAttributedString(string: noticeText) + guard let range = noticeText.nsRange(of: installedPlugin.displayName) else { + return boldNoticeText + } + boldNoticeText.addAttributes([.font: WPStyleGuide.fontForTextStyle(.callout, fontWeight: .bold)], range: range) + return boldNoticeText + } + } + + init(onHideThisTap: @escaping UIActionHandler = { _ in }, + onLearnMoreTap: @escaping () -> Void = {}, + installedPlugin: JetpackPlugin = .multiple) { + self.onHideThisTap = onHideThisTap + self.onLearnMoreTap = onLearnMoreTap + self.installedPlugin = installedPlugin + } + + // MARK: Constants + + private struct Strings { + static let individualPluginFormat = NSLocalizedString("jetpackinstallcard.notice.individual", + value: "This site is using the %1$@ plugin, which doesn't support all features of the app yet. Please install the full Jetpack plugin.", + comment: "Text displayed in the Jetpack install card on the Home screen and Menu screen when a user has an individual Jetpack plugin installed but not the full plugin. %1$@ is a placeholder for the plugin the user has installed. %1$@ is bold.") + static let multiplePlugins = NSLocalizedString("jetpackinstallcard.notice.multiple", + value: "This site is using individual Jetpack plugins, which don’t support all features of the app yet. Please install the full Jetpack plugin.", + comment: "Text displayed in the Jetpack install card on the Home screen and Menu screen when a user has multiple installed individual Jetpack plugins but not the full plugin.") + } + +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.swift index f9a837caaf4d..347f6a6bb336 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.swift @@ -1,19 +1,36 @@ import WordPressAuthenticator -protocol JetpackRemoteInstallStateViewDelegate: class { +protocol JetpackRemoteInstallStateViewDelegate: AnyObject { func mainButtonDidTouch() func customerSupportButtonDidTouch() } +struct JetpackRemoteInstallStateViewModel { + let image: UIImage? + let titleText: String + let descriptionText: String + let buttonTitleText: String + + let hidesLoadingIndicator: Bool + let hidesSupportButton: Bool +} + class JetpackRemoteInstallStateView: UIViewController { weak var delegate: JetpackRemoteInstallStateViewDelegate? @IBOutlet private var imageView: UIImageView! @IBOutlet private var titleLabel: UILabel! @IBOutlet private var descriptionLabel: UILabel! - @IBOutlet private var mainButton: NUXButton! + @IBOutlet private var mainButton: UIButton! @IBOutlet private var supportButton: UIButton! - @IBOutlet private var activityIndicatorContainer: UIView! + + private var activityIndicator: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .medium) + view.color = Constants.MainButton.activityIndicatorColor + view.translatesAutoresizingMaskIntoConstraints = false + + return view + }() override func viewDidLoad() { super.viewDidLoad() @@ -24,42 +41,69 @@ class JetpackRemoteInstallStateView: UIViewController { imageView.isHidden = collection.containsTraits(in: UITraitCollection(verticalSizeClass: .compact)) } - func setupView(for state: JetpackRemoteInstallState) { - imageView.image = state.image + func configure(with viewModel: JetpackRemoteInstallStateViewModel) { + imageView.image = viewModel.image - titleLabel.text = state.title - descriptionLabel.text = state.message + titleLabel.text = viewModel.titleText + descriptionLabel.text = viewModel.descriptionText - mainButton.isHidden = state == .installing - mainButton.setTitle(state.buttonTitle, for: .normal) + mainButton.setTitle(viewModel.buttonTitleText, for: .normal) - activityIndicatorContainer.isHidden = state != .installing + toggleLoading(!viewModel.hidesLoadingIndicator) - switch state { - case .failure: - supportButton.isHidden = false - default: - supportButton.isHidden = true - } + supportButton.isHidden = viewModel.hidesSupportButton + view.layoutIfNeeded() } } private extension JetpackRemoteInstallStateView { func setupUI() { - view.backgroundColor = .neutral(.shade5) + WPStyleGuide.configureColors(view: view, tableView: nil) - titleLabel.font = WPStyleGuide.fontForTextStyle(.title2) - titleLabel.textColor = .neutral(.shade40) + titleLabel.font = Constants.Title.font + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.textColor = Constants.Title.color - descriptionLabel.font = WPStyleGuide.fontForTextStyle(.body) - descriptionLabel.textColor = .neutral(.shade70) + descriptionLabel.font = Constants.Description.font + descriptionLabel.adjustsFontForContentSizeCategory = true + descriptionLabel.textColor = Constants.Description.color + configureTitleLabel(for: mainButton, font: Constants.MainButton.font) + mainButton.setTitleColor(Constants.MainButton.titleColor, for: .normal) + mainButton.setTitle(String(), for: .disabled) + mainButton.setBackgroundImage(Constants.MainButton.normalBackground, for: .normal) + mainButton.setBackgroundImage(Constants.MainButton.loadingBackground, for: .disabled) mainButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 20, bottom: 12, right: 20) - supportButton.titleLabel?.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .medium) - supportButton.setTitleColor(.primary, for: .normal) - supportButton.setTitle(NSLocalizedString("Contact Support", comment: "Contact Support button title"), - for: .normal) + mainButton.addSubview(activityIndicator) + mainButton.pinSubviewAtCenter(activityIndicator) + + configureTitleLabel(for: supportButton, font: Constants.SupportButton.font) + supportButton.setTitleColor(Constants.SupportButton.color, for: .normal) + supportButton.setTitle(Constants.SupportButton.text, for: .normal) + } + + // enables multi-line support for the button. + func configureTitleLabel(for button: UIButton, font: UIFont) { + guard let label = button.titleLabel else { + return + } + + label.font = font + label.adjustsFontForContentSizeCategory = true + label.textAlignment = .center + label.lineBreakMode = .byWordWrapping + button.pinSubviewToAllEdges(label) + } + + func toggleLoading(_ loading: Bool) { + mainButton.isEnabled = !loading + + if loading { + activityIndicator.startAnimating() + } else { + activityIndicator.stopAnimating() + } } @IBAction func mainButtonAction(_ sender: NUXButton) { @@ -69,4 +113,32 @@ private extension JetpackRemoteInstallStateView { @IBAction func customSupportButtonAction(_ sender: UIButton) { delegate?.customerSupportButtonDidTouch() } + + // MARK: Constants + + struct Constants { + struct Title { + static let font = WPStyleGuide.fontForTextStyle(.title2) + static let color = UIColor.text + } + + struct Description { + static let font = WPStyleGuide.fontForTextStyle(.callout) + static let color = UIColor.textSubtle + } + + struct MainButton { + static let normalBackground = UIImage.renderBackgroundImage(fill: .brand) + static let loadingBackground = UIImage.renderBackgroundImage(fill: .muriel(color: .jetpackGreen, .shade70)) + static let titleColor = UIColor.white + static let font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + static let activityIndicatorColor = UIColor.white + } + + struct SupportButton { + static let color = UIColor.brand + static let font = WPStyleGuide.fontForTextStyle(.body) + static let text = NSLocalizedString("Contact Support", comment: "Contact Support button title") + } + } } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.xib b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.xib index e69276b77041..d54f529350e5 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.xib +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallStateView.xib @@ -1,18 +1,16 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina4_0" orientation="landscape" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="JetpackRemoteInstallStateView" customModule="WordPress" customModuleProvider="target"> <connections> - <outlet property="activityIndicatorContainer" destination="Icp-bh-lNJ" id="uqQ-AV-37V"/> <outlet property="descriptionLabel" destination="zXT-1B-yPn" id="oqE-xJ-QJD"/> <outlet property="imageView" destination="94F-yf-VuS" id="p2p-6I-LIB"/> <outlet property="mainButton" destination="jqR-mb-7fp" id="hAW-4w-PnP"/> @@ -23,66 +21,115 @@ </placeholder> <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> <view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT"> - <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <rect key="frame" x="0.0" y="0.0" width="568" height="320"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="equalSpacing" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="nWU-Jq-CLu"> - <rect key="frame" x="30" y="189.5" width="315" height="308.5"/> + <scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="DTJ-qg-yLb"> + <rect key="frame" x="0.0" y="0.0" width="568" height="320"/> <subviews> - <imageView userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="jetpack-install-logo" translatesAutoresizingMaskIntoConstraints="NO" id="94F-yf-VuS"> - <rect key="frame" x="0.0" y="0.0" width="315" height="88"/> - </imageView> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="Qbk-tR-L9z" userLabel="Labels"> - <rect key="frame" x="0.0" y="108" width="315" height="48.5"/> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="i2o-j9-ORU" userLabel="Scroll Content View"> + <rect key="frame" x="0.0" y="0.0" width="568" height="406.5"/> <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Title" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="tQC-sI-Otj"> - <rect key="frame" x="0.0" y="0.0" width="315" height="20.5"/> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <nil key="textColor"/> - <nil key="highlightedColor"/> - </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Description" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="zXT-1B-yPn"> - <rect key="frame" x="0.0" y="30.5" width="315" height="18"/> - <fontDescription key="fontDescription" type="system" pointSize="15"/> - <nil key="textColor"/> - <nil key="highlightedColor"/> - </label> - </subviews> - </stackView> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="x93-K4-TlZ" userLabel="Main buttons"> - <rect key="frame" x="0.0" y="176.5" width="315" height="78"/> - <subviews> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="jqR-mb-7fp" customClass="NUXButton" customModule="WordPressAuthenticator"> - <rect key="frame" x="130.5" y="0.0" width="54" height="34"/> - <state key="normal" title="Button"> - <color key="titleColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </state> - <userDefinedRuntimeAttributes> - <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="YES"/> - </userDefinedRuntimeAttributes> - <connections> - <action selector="mainButtonAction:" destination="-1" eventType="touchUpInside" id="uD5-jW-rwF"/> - </connections> - </button> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Icp-bh-lNJ"> - <rect key="frame" x="37.5" y="34" width="240" height="44"/> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="center" spacing="24" translatesAutoresizingMaskIntoConstraints="NO" id="nWU-Jq-CLu"> + <rect key="frame" x="20" y="0.0" width="528" height="406.5"/> <subviews> - <activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" animating="YES" style="gray" translatesAutoresizingMaskIntoConstraints="NO" id="hEV-SM-qHu"> - <rect key="frame" x="110" y="12" width="20" height="20"/> - </activityIndicatorView> + <view contentMode="scaleToFill" verticalHuggingPriority="252" verticalCompressionResistancePriority="248" translatesAutoresizingMaskIntoConstraints="NO" id="km5-zZ-6nf" userLabel="Top Padding View"> + <rect key="frame" x="0.0" y="0.0" width="528" height="48"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + </view> + <imageView userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="jetpack-install-logo" translatesAutoresizingMaskIntoConstraints="NO" id="94F-yf-VuS" userLabel="Image"> + <rect key="frame" x="232" y="58" width="64" height="64"/> + <constraints> + <constraint firstAttribute="height" constant="64" id="ffX-S5-aNP"/> + </constraints> + </imageView> + <stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="251" axis="vertical" alignment="center" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="Qbk-tR-L9z" userLabel="Labels"> + <rect key="frame" x="225" y="132" width="78.5" height="46.5"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Title" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="tQC-sI-Otj"> + <rect key="frame" x="22.5" y="0.0" width="33" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Description" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="zXT-1B-yPn"> + <rect key="frame" x="0.0" y="28.5" width="78.5" height="18"/> + <fontDescription key="fontDescription" type="system" pointSize="15"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + <view contentMode="scaleToFill" verticalHuggingPriority="248" verticalCompressionResistancePriority="752" translatesAutoresizingMaskIntoConstraints="NO" id="Em5-kw-OtL" userLabel="Bottom Padding View"> + <rect key="frame" x="127" y="188.5" width="274" height="218"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + </view> </subviews> - <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <constraints> - <constraint firstItem="hEV-SM-qHu" firstAttribute="centerY" secondItem="Icp-bh-lNJ" secondAttribute="centerY" id="IWW-xL-Slj"/> - <constraint firstAttribute="height" constant="44" id="bJ8-5Q-Xtx"/> - <constraint firstItem="hEV-SM-qHu" firstAttribute="centerX" secondItem="Icp-bh-lNJ" secondAttribute="centerX" id="gEU-dm-C8L"/> + <constraint firstAttribute="width" relation="lessThanOrEqual" constant="540" id="R8i-Zc-kJY"/> + <constraint firstItem="km5-zZ-6nf" firstAttribute="leading" secondItem="nWU-Jq-CLu" secondAttribute="leading" id="RrV-eB-cmL"/> + <constraint firstAttribute="trailing" secondItem="km5-zZ-6nf" secondAttribute="trailing" id="tLt-uX-GaM"/> </constraints> - </view> + <variation key="heightClass=compact" spacing="10"/> + </stackView> </subviews> - </stackView> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="nWU-Jq-CLu" secondAttribute="trailing" constant="20" id="9YJ-3k-6eR"/> + <constraint firstItem="nWU-Jq-CLu" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="i2o-j9-ORU" secondAttribute="leading" constant="20" id="AyI-aY-e9Y"/> + <constraint firstItem="nWU-Jq-CLu" firstAttribute="top" secondItem="i2o-j9-ORU" secondAttribute="top" id="BRv-RC-ze4"/> + <constraint firstAttribute="trailing" secondItem="nWU-Jq-CLu" secondAttribute="trailing" priority="750" constant="20" id="D1V-Av-iIt"/> + <constraint firstItem="nWU-Jq-CLu" firstAttribute="centerX" secondItem="i2o-j9-ORU" secondAttribute="centerX" id="ORF-3N-kmk"/> + <constraint firstItem="nWU-Jq-CLu" firstAttribute="leading" secondItem="i2o-j9-ORU" secondAttribute="leading" priority="750" constant="20" id="s9x-VO-ri1"/> + <constraint firstAttribute="bottom" secondItem="nWU-Jq-CLu" secondAttribute="bottom" id="tfv-iV-Eaz"/> + </constraints> + </view> + </subviews> + <constraints> + <constraint firstItem="km5-zZ-6nf" firstAttribute="height" secondItem="xw4-Jn-6cd" secondAttribute="height" multiplier="0.15" priority="250" id="41n-Je-13n"/> + <constraint firstItem="i2o-j9-ORU" firstAttribute="leading" secondItem="bFB-4p-ybZ" secondAttribute="leading" id="F0S-rg-Z7N"/> + <constraint firstItem="km5-zZ-6nf" firstAttribute="height" relation="lessThanOrEqual" secondItem="xw4-Jn-6cd" secondAttribute="height" multiplier="0.15" id="VNQ-LN-Ftx"/> + <constraint firstItem="i2o-j9-ORU" firstAttribute="width" secondItem="xw4-Jn-6cd" secondAttribute="width" id="a5P-Vd-2XK"/> + <constraint firstItem="i2o-j9-ORU" firstAttribute="height" relation="greaterThanOrEqual" secondItem="xw4-Jn-6cd" secondAttribute="height" id="f0t-95-giC"/> + <constraint firstItem="i2o-j9-ORU" firstAttribute="top" secondItem="bFB-4p-ybZ" secondAttribute="top" id="i16-AD-oMS"/> + <constraint firstItem="bFB-4p-ybZ" firstAttribute="trailing" secondItem="i2o-j9-ORU" secondAttribute="trailing" id="nfi-2k-ykd"/> + <constraint firstItem="bFB-4p-ybZ" firstAttribute="bottom" secondItem="i2o-j9-ORU" secondAttribute="bottom" id="yrm-pa-vFv"/> + </constraints> + <viewLayoutGuide key="contentLayoutGuide" id="bFB-4p-ybZ"/> + <viewLayoutGuide key="frameLayoutGuide" id="xw4-Jn-6cd"/> + <variation key="default"> + <mask key="constraints"> + <exclude reference="f0t-95-giC"/> + </mask> + </variation> + </scrollView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Fuj-yb-RAI" userLabel="Stack Background VIew"> + <rect key="frame" x="0.0" y="224" width="568" height="96"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + </view> + <stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="249" axis="vertical" distribution="fillEqually" alignment="center" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="x93-K4-TlZ" userLabel="Main buttons"> + <rect key="frame" x="20" y="244" width="528" height="50"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="249" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="jqR-mb-7fp"> + <rect key="frame" x="0.0" y="0.0" width="264" height="50"/> + <constraints> + <constraint firstAttribute="width" relation="lessThanOrEqual" constant="360" id="Jom-r9-nbK"/> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="50" id="Ndo-if-7zc"/> + <constraint firstAttribute="width" priority="750" constant="360" id="rHu-vJ-hh5"/> + </constraints> + <state key="normal" title="Primary Button"> + <color key="titleColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </state> + <connections> + <action selector="mainButtonAction:" destination="-1" eventType="touchUpInside" id="uD5-jW-rwF"/> + </connections> + </button> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Pr8-fC-fXM"> - <rect key="frame" x="0.0" y="274.5" width="315" height="34"/> - <state key="normal" title="Custom Support Button"> + <rect key="frame" x="264" y="0.0" width="264" height="50"/> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="50" id="6Ty-2J-OXD"/> + </constraints> + <state key="normal" title="Secondary Button"> <color key="titleColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> </state> <connections> @@ -90,25 +137,36 @@ </connections> </button> </subviews> - <constraints> - <constraint firstAttribute="width" relation="lessThanOrEqual" constant="360" id="V09-6L-ebW"/> - </constraints> - <variation key="heightClass=compact" spacing="10"/> + <variation key="heightClass=compact" axis="horizontal"/> </stackView> </subviews> + <viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <constraints> - <constraint firstItem="nWU-Jq-CLu" firstAttribute="centerX" secondItem="fnl-2z-Ty3" secondAttribute="centerX" id="Cbs-ru-8dd"/> - <constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" secondItem="nWU-Jq-CLu" secondAttribute="trailing" priority="999" constant="30" id="Szi-pY-3qd"/> - <constraint firstItem="nWU-Jq-CLu" firstAttribute="leading" secondItem="fnl-2z-Ty3" secondAttribute="leading" priority="999" constant="30" id="Usc-Gj-TSe"/> - <constraint firstItem="nWU-Jq-CLu" firstAttribute="centerY" secondItem="fnl-2z-Ty3" secondAttribute="centerY" id="p5R-il-Vxd"/> + <constraint firstItem="Fuj-yb-RAI" firstAttribute="height" secondItem="x93-K4-TlZ" secondAttribute="height" constant="46" id="4eW-uT-aEu"/> + <constraint firstItem="x93-K4-TlZ" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="fnl-2z-Ty3" secondAttribute="leading" constant="20" id="695-2C-4zA"/> + <constraint firstItem="x93-K4-TlZ" firstAttribute="centerX" secondItem="fnl-2z-Ty3" secondAttribute="centerX" id="CnU-ud-d0u"/> + <constraint firstItem="DTJ-qg-yLb" firstAttribute="top" secondItem="fnl-2z-Ty3" secondAttribute="top" id="Ezd-pT-IQF"/> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="bottom" secondItem="x93-K4-TlZ" secondAttribute="bottom" constant="26" id="F79-25-Qtm"/> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="bottom" secondItem="Fuj-yb-RAI" secondAttribute="bottom" id="IBC-Cj-C4Y"/> + <constraint firstItem="DTJ-qg-yLb" firstAttribute="leading" secondItem="fnl-2z-Ty3" secondAttribute="leading" id="JjI-8B-BDy"/> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="x93-K4-TlZ" secondAttribute="trailing" constant="20" id="SST-57-CGP"/> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" secondItem="Fuj-yb-RAI" secondAttribute="trailing" id="aag-Se-jrL"/> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="bottom" secondItem="DTJ-qg-yLb" secondAttribute="bottom" id="b1l-2B-mFN"/> + <constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" secondItem="DTJ-qg-yLb" secondAttribute="trailing" id="cdW-Jm-v5g"/> + <constraint firstItem="Fuj-yb-RAI" firstAttribute="leading" secondItem="fnl-2z-Ty3" secondAttribute="leading" id="kiE-a1-3XW"/> + <constraint firstItem="i2o-j9-ORU" firstAttribute="height" secondItem="fnl-2z-Ty3" secondAttribute="height" priority="250" id="mcs-rS-2A0"/> + <constraint firstItem="Em5-kw-OtL" firstAttribute="height" relation="greaterThanOrEqual" secondItem="x93-K4-TlZ" secondAttribute="height" constant="46" id="zAv-12-2Fx"/> </constraints> <nil key="simulatedTopBarMetrics"/> <nil key="simulatedBottomBarMetrics"/> - <viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/> + <point key="canvasLocation" x="137" y="21"/> </view> </objects> <resources> - <image name="jetpack-install-logo" width="88" height="88"/> + <image name="jetpack-install-logo" width="64" height="64"/> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> </resources> </document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallTableViewCell.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallTableViewCell.swift new file mode 100644 index 000000000000..00d24d05586c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/View/JetpackRemoteInstallTableViewCell.swift @@ -0,0 +1,93 @@ +import UIKit + +@objcMembers +class JetpackRemoteInstallTableViewCell: UITableViewCell { + + // MARK: Properties + + private var blog: Blog? + private weak var presenterViewController: BlogDetailsViewController? + + private lazy var cardViewModel: JetpackRemoteInstallCardViewModel = { + let onHideThisTap: UIActionHandler = { [weak self] _ in + guard let self, + let helper = JetpackInstallPluginHelper(self.blog) else { + return + } + WPAnalytics.track(.jetpackInstallFullPluginCardDismissed, properties: [WPAppAnalyticsKeyTabSource: "site_menu"]) + helper.hideCard() + self.presenterViewController?.reloadTableView() + } + let onLearnMoreTap: () -> Void = { + guard let presenterViewController = self.presenterViewController else { + return + } + WPAnalytics.track(.jetpackInstallFullPluginCardTapped, properties: [WPAppAnalyticsKeyTabSource: "site_menu"]) + JetpackInstallPluginHelper.presentOverlayIfNeeded(in: presenterViewController, + blog: self.blog, + delegate: presenterViewController, + force: true) + } + return JetpackRemoteInstallCardViewModel(onHideThisTap: onHideThisTap, + onLearnMoreTap: onLearnMoreTap) + }() + + private lazy var cardView: JetpackRemoteInstallCardView = { + let cardView = JetpackRemoteInstallCardView(cardViewModel) + cardView.translatesAutoresizingMaskIntoConstraints = false + return cardView + }() + + // MARK: Initializers + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Functions + + func configure(blog: Blog, viewController: BlogDetailsViewController?) { + self.blog = blog + self.presenterViewController = viewController + cardView.updatePlugin(JetpackPlugin(from: blog.jetpackConnectionActivePlugins)) + } + + private func setupView() { + contentView.addSubview(cardView) + contentView.pinSubviewToAllEdges(cardView, priority: .defaultHigh) + } + +} + +// MARK: - BlogDetailsViewController view model + +extension BlogDetailsViewController: JetpackRemoteInstallDelegate { + + @objc func jetpackInstallSectionViewModel() -> BlogDetailsSection { + let row = BlogDetailsRow() + row.callback = {} + let section = BlogDetailsSection(title: nil, + rows: [row], + footerTitle: nil, + category: .jetpackInstallCard) + return section + } + + func jetpackRemoteInstallCompleted() { + dismiss(animated: true) + } + + func jetpackRemoteInstallCanceled() { + dismiss(animated: true) + } + + func jetpackRemoteInstallWebviewFallback() { + // No op + } + +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/ViewModel/JetpackRemoteInstallViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/ViewModel/JetpackRemoteInstallViewModel.swift index 75fb603e0298..90752ef56d81 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Install/ViewModel/JetpackRemoteInstallViewModel.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/ViewModel/JetpackRemoteInstallViewModel.swift @@ -1,36 +1,111 @@ -import WordPressFlux +import WordPressAuthenticator -class JetpackRemoteInstallViewModel { - typealias JetpackRemoteInstallOnChangeState = (JetpackRemoteInstallState) -> Void +/// Represents the core logic behind the Jetpack Remote Install. +/// +/// This protocol is mainly used by `JetpackRemoteInstallViewController`, and allows the installation process +/// to be abstracted since there are many different ways to install the Jetpack plugin. +/// +protocol JetpackRemoteInstallViewModel: AnyObject { - var onChangeState: JetpackRemoteInstallOnChangeState? - private let store = StoreContainer.shared.jetpackInstall - private var storeReceipt: Receipt? + // MARK: Properties - private(set) var state: JetpackRemoteInstallState = .install { - didSet { - onChangeState?(state) - } - } + /// The view controller can implement the closure to subscribe to every `state` changes. + var onChangeState: ((JetpackRemoteInstallState, JetpackRemoteInstallStateViewModel) -> Void)? { get set } + + /// An enum that represents the current installation state. + var state: JetpackRemoteInstallState { get } + + /// Whether the install flow should continue with establishing a Jetpack connection for the site. + var shouldConnectToJetpack: Bool { get } + + /// The source tag to be used when the user opens the Support screen in case of installation errors. + var supportSourceTag: WordPressSupportSourceTag? { get } + + // MARK: Methods + + /// Called by the view controller when it's ready to receive user interaction. + func viewReady() + + /// Starts the Jetpack plugin installation. + /// + /// The progress will be reflected into the `state` object, which should be subscribed by + /// the view controller through the `onChangeState` method. + /// + /// - Parameters: + /// - blog: The Blog to install the Jetpack plugin. + /// - isRetry: For tracking purposes. True means this is a retry attempt. + func installJetpack(for blog: Blog, isRetry: Bool) - func viewReady() { - state = .install - - storeReceipt = store.onStateChange { [weak self] (_, state) in - switch state.current { - case .loading: - self?.state = .installing - case .success: - self?.state = .success - case .failure(let error): - self?.state = .failure(error) + /// Abstracted tracking implementation for the `JetpackRemoteInstallEvent`. + /// + /// - Parameter event: The events to track. See `JetpackRemoteInstallEvent` for more info. + func track(_ event: JetpackRemoteInstallEvent) + + /// Called by the view controller when the user taps Cancel. + /// + /// This allows the view model to perform necessary operation cleanups, but note that + /// the actual navigation actions upon cancellation should be controlled by `JetpackRemoteInstallDelegate`. + func cancelTapped() +} + +// MARK: - Default Init Jetpack State View Model + +extension JetpackRemoteInstallStateViewModel { + + init(state: JetpackRemoteInstallState, + image: UIImage? = nil, + titleText: String? = nil, + descriptionText: String? = nil, + buttonTitleText: String? = nil, + hidesLoadingIndicator: Bool? = nil, + hidesSupportButton: Bool? = nil) { + self.image = image ?? state.image + self.titleText = titleText ?? state.title + self.descriptionText = descriptionText ?? state.message + self.buttonTitleText = buttonTitleText ?? state.buttonTitle + self.hidesLoadingIndicator = hidesLoadingIndicator ?? (state != .installing) + self.hidesSupportButton = hidesSupportButton ?? { + switch state { + case .failure: + return false default: - break + return true } - } + }() } - func installJetpack(with url: String, username: String, password: String) { - store.onDispatch(JetpackInstallAction.install(url: url, username: username, password: password)) - } +} + +// MARK: - Jetpack Remote Install Events + +enum JetpackRemoteInstallEvent { + /// User is seeing the initial installation screen. + case initial + + /// User initiated the Jetpack installation process. + case start + + /// Jetpack plugin is being installed. + case loading + + /// Jetpack plugin installation succeeded. + case completed + + /// Jetpack plugin installation failed. + case failed(description: String, siteURLString: String) + + /// User retried the Jetpack installation process. + case retry + + /// User cancelled the installation process. + case cancel + + /// User tapped the primary button in the completed state. + case completePrimaryButtonTapped + + /// User initiated the Jetpack connection authorization. + case connect + + /// User initiated a login to authorize the Jetpack connection. + case login } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/ViewModel/SelfHostedJetpackRemoteInstallViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/ViewModel/SelfHostedJetpackRemoteInstallViewModel.swift new file mode 100644 index 000000000000..d3d1e96512fd --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/ViewModel/SelfHostedJetpackRemoteInstallViewModel.swift @@ -0,0 +1,70 @@ +import WordPressFlux +import WordPressAuthenticator + +class SelfHostedJetpackRemoteInstallViewModel: JetpackRemoteInstallViewModel { + var onChangeState: ((JetpackRemoteInstallState, JetpackRemoteInstallStateViewModel) -> Void)? + private let store = StoreContainer.shared.jetpackInstall + private var storeReceipt: Receipt? + + /// Always proceed to the Jetpack Connection flow after successfully installing Jetpack. + let shouldConnectToJetpack = true + + let supportSourceTag: WordPressSupportSourceTag? = nil + + private(set) var state: JetpackRemoteInstallState = .install { + didSet { + onChangeState?(state, .init(state: state)) + } + } + + func viewReady() { + state = .install + + storeReceipt = store.onStateChange { [weak self] (_, state) in + switch state.current { + case .loading: + self?.state = .installing + case .success: + self?.state = .success + case .failure(let error): + self?.state = .failure(error) + default: + break + } + } + } + + func installJetpack(for blog: Blog, isRetry: Bool = false) { + guard let url = blog.url, + let username = blog.username, + let password = blog.password else { + return + } + + store.onDispatch(JetpackInstallAction.install(url: url, username: username, password: password)) + } + + func track(_ event: JetpackRemoteInstallEvent) { + switch event { + case .start: + WPAnalytics.track(.installJetpackRemoteStart) + case .completed: + WPAnalytics.track(.installJetpackRemoteCompleted) + case .failed(let description, let siteURLString): + WPAnalytics.track(.installJetpackRemoteFailed, + withProperties: ["error_type": description, "site_url": siteURLString]) + case .retry: + WPAnalytics.track(.installJetpackRemoteRetry) + case .connect: + WPAnalytics.track(.installJetpackRemoteConnect) + case .login: + WPAnalytics.track(.installJetpackRemoteLogin) + default: + break + } + } + + func cancelTapped() { + // No op + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/ViewModel/WPComJetpackRemoteInstallViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/ViewModel/WPComJetpackRemoteInstallViewModel.swift new file mode 100644 index 000000000000..a5984fca279a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/ViewModel/WPComJetpackRemoteInstallViewModel.swift @@ -0,0 +1,161 @@ +import WordPressAuthenticator + +/// Controls the Jetpack Remote Install flow for Jetpack-connected self-hosted sites. +/// +/// A site can establish a Jetpack connection through individual Jetpack plugins, but the site may not have +/// the full Jetpack plugin. This covers the logic behind the plugin installation process, and will stop the +/// process before proceeding to the Jetpack connection step (since the site is already connected). +/// +class WPComJetpackRemoteInstallViewModel { + + // MARK: Dependencies + + private let service: PluginJetpackProxyService + private let tracker: EventTracker + + /// For request cancellation purposes. + private var progress: Progress? = nil + + // MARK: Properties + + // The flow should always complete after the plugin is installed. + let shouldConnectToJetpack = false + + let supportSourceTag: WordPressSupportSourceTag? = .jetpackFullPluginInstallErrorSourceTag + + var onChangeState: ((JetpackRemoteInstallState, JetpackRemoteInstallStateViewModel) -> Void)? = nil + + private(set) var state: JetpackRemoteInstallState = .install { + didSet { + onChangeState?(state, stateViewModel) + } + } + + // MARK: Methods + + init(service: PluginJetpackProxyService = .init(), + tracker: EventTracker = DefaultEventTracker()) { + self.service = service + self.tracker = tracker + } +} + +// MARK: - View Model Implementation + +extension WPComJetpackRemoteInstallViewModel: JetpackRemoteInstallViewModel { + func viewReady() { + // set the initial state & trigger the callback. + state = .install + } + + func installJetpack(for blog: Blog, isRetry: Bool) { + // Ensure that the blog is accessible through a WP.com account, + // and doesn't already have the Jetpack plugin. + guard let siteID = blog.dotComID?.intValue, + blog.jetpackIsConnectedWithoutFullPlugin else { + // In this case, let's do nothing for now. Falling to this state should be a logic error. + return + } + + // trigger the loading state. + state = .installing + + progress = service.installPlugin(for: siteID, pluginSlug: Constants.jetpackSlug, active: true) { [weak self] result in + switch result { + case .success: + self?.state = .success + case .failure(let error): + DDLogError("Error: Jetpack plugin installation via proxy failed. \(error.localizedDescription)") + let installError = JetpackInstallError(title: error.localizedDescription, type: .unknown) + self?.state = .failure(installError) + } + } + } + + func track(_ event: JetpackRemoteInstallEvent) { + switch event { + case .initial, .loading: + tracker.track(.jetpackInstallFullPluginViewed, properties: ["status": state.statusForTracks]) + case .failed(let description, _): + tracker.track(.jetpackInstallFullPluginViewed, + properties: ["status": state.statusForTracks, "description": description]) + case .cancel: + tracker.track(.jetpackInstallFullPluginCancelTapped, properties: ["status": state.statusForTracks]) + case .start: + tracker.track(.jetpackInstallFullPluginInstallTapped) + case .retry: + tracker.track(.jetpackInstallFullPluginRetryTapped) + case .completePrimaryButtonTapped: + tracker.track(.jetpackInstallFullPluginDoneTapped) + case .completed: + tracker.track(.jetpackInstallFullPluginCompleted) + default: + break + } + } + + /// NOTE: There's no guarantee that the plugin installation will be properly cancelled. + /// We *might* be able to cancel if the request hasn't been fired; but if it has, it'll probably succeed. + /// + /// An alternative would be to have a listener that checks if installation completes after cancellation, + /// and fires background request to uninstall the plugin. But this will not be implemented now. + func cancelTapped() { + progress?.cancel() + progress = nil + } +} + +// MARK: - Private Helpers + +private extension WPComJetpackRemoteInstallViewModel { + + enum Constants { + // The identifier for the Jetpack plugin, used for the proxied .org plugin endpoint. + static let jetpackSlug = "jetpack" + + static let successDescriptionText = NSLocalizedString( + "jetpack.install-flow.success.description", + value: "Ready to use this site with the app.", + comment: "The description text shown after the user has successfully installed the Jetpack plugin." + ) + + static let successButtonTitleText = NSLocalizedString( + "jetpack.install-flow.success.primaryButtonText", + value: "Done", + comment: "Title of the primary button shown after the Jetpack plugin has been installed. " + + "Tapping on the button dismisses the installation screen." + ) + } + + // State view model overrides. + var stateViewModel: JetpackRemoteInstallStateViewModel { + return .init( + state: state, + descriptionText: (state == .success ? Constants.successDescriptionText : state.message), + buttonTitleText: (state == .success ? Constants.successButtonTitleText : state.buttonTitle) + ) + } +} + +extension WordPressSupportSourceTag { + static var jetpackFullPluginInstallErrorSourceTag: WordPressSupportSourceTag { + .init(name: "jetpackInstallFullPluginError", origin: "origin:jp-install-full-plugin-error") + } +} + +// MARK: - Tracking Helpers + +private extension JetpackRemoteInstallState { + var statusForTracks: String { + switch self { + case .install: + return "initial" + case .installing: + return "loading" + case .failure: + return "error" + default: + return String() + } + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Install/Webview/JetpackConnectionWebViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Install/Webview/JetpackConnectionWebViewController.swift index c6ddd1520d33..7fadd93c71ca 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Install/Webview/JetpackConnectionWebViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Install/Webview/JetpackConnectionWebViewController.swift @@ -2,7 +2,7 @@ import UIKit import WebKit import Gridicons import WordPressAuthenticator - +import Combine protocol JetpackConnectionWebDelegate { func jetpackConnectionCompleted() @@ -23,6 +23,10 @@ class JetpackConnectionWebViewController: UIViewController { private var analyticsErrorWasTracked = false + /// Only used to handle site-connection state and establish user-connection required for the app + private var nativeConnectionService: JetpackNativeConnectionService? + private var subscriptions: Set<AnyCancellable> = [] + init(blog: Blog) { self.blog = blog let configuration = WKWebViewConfiguration() @@ -56,16 +60,19 @@ class JetpackConnectionWebViewController: UIViewController { navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(JetpackConnectionWebViewController.cancel)) } - startConnectionFlow() + if let jetpack = blog.jetpack, jetpack.isSiteConnection { + startNativeConnectionFlow() + } else { + startConnectionFlow() + } } func startConnectionFlow() { - let locale = WordPressComLanguageDatabase().deviceLanguage.slug let url: URL if let escapedSiteURL = blog.homeURL?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { - url = URL(string: "https://wordpress.com/jetpack/connect/\(locale)?url=\(escapedSiteURL)&mobile_redirect=\(mobileRedirectURL)&from=mobile")! + url = URL(string: "https://wordpress.com/jetpack/connect?url=\(escapedSiteURL)&mobile_redirect=\(mobileRedirectURL)&from=mobile")! } else { - url = URL(string: "https://wordpress.com/jetpack/connect/\(locale)?mobile_redirect=\(mobileRedirectURL)&from=mobile")! + url = URL(string: "https://wordpress.com/jetpack/connect?mobile_redirect=\(mobileRedirectURL)&from=mobile")! } let request = URLRequest(url: url) @@ -231,8 +238,7 @@ private extension JetpackConnectionWebViewController { } func handleMobileRedirect() { - let context = ContextManager.sharedInstance().mainContext - let service = BlogService(managedObjectContext: context) + let service = BlogService(coreDataStack: ContextManager.shared) let success: () -> Void = { [weak self] in self?.delegate?.jetpackConnectionCompleted() } @@ -243,11 +249,15 @@ private extension JetpackConnectionWebViewController { service.syncBlog( blog, success: { [weak self] in - guard let account = self?.account ?? self?.defaultAccount() else { - // If there's no account let's pretend this worked - // We don't know what to do, but at least it will dismiss - // the connection flow and refresh the site state - success() + guard let self else { return } + + guard let account = self.account ?? self.defaultAccount() else { + // There could be no account in some cases where user has connected + // their site to .com account on webView + // without logging into the account in the app + self.startObservingLoginNotifications() + WordPressAuthenticator.showLoginForJustWPCom(from: self, jetpackLogin: true, connectedEmail: self.blog.jetpack?.connectedEmail) + return } service.associateSyncedBlogs( @@ -261,15 +271,17 @@ private extension JetpackConnectionWebViewController { } func performSiteLogin(redirect: URL, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - guard let authenticator = WebViewAuthenticator(blog: blog) else { - decisionHandler(.allow) - return + guard let authenticator = RequestAuthenticator(blog: blog) else { + decisionHandler(.allow) + return } decisionHandler(.cancel) - let request = authenticator.authenticatedRequest(url: redirect) - DDLogDebug("Performing site login to \(String(describing: request.url))") - pendingSiteRedirect = redirect - webView.load(request) + + authenticator.request(url: redirect, cookieJar: webView.configuration.websiteDataStore.httpCookieStore, completion: { request in + DDLogDebug("Performing site login to \(String(describing: request.url))") + self.pendingSiteRedirect = redirect + self.webView.load(request) + }) } func performDotComLogin(redirect: URL) { @@ -283,18 +295,19 @@ private extension JetpackConnectionWebViewController { } func authenticateWithDotCom(username: String, token: String, redirect: URL) { - let authenticator = WebViewAuthenticator(credentials: .dotCom(username: username, authToken: token)) - authenticator.safeRedirect = true - let request = authenticator.authenticatedRequest(url: redirect) - DDLogDebug("Performing WordPress.com login to \(String(describing: request.url))") - webView.load(request) + let authenticator = RequestAuthenticator(credentials: .dotCom(username: username, authToken: token, authenticationType: .regular)) + + authenticator.request(url: redirect, cookieJar: webView.configuration.websiteDataStore.httpCookieStore, completion: { request in + DDLogDebug("Performing WordPress.com login to \(String(describing: request.url))") + self.webView.load(request) + }) } func presentDotComLogin(redirect: URL) { pendingDotComRedirect = redirect startObservingLoginNotifications() - WordPressAuthenticator.showLoginForJustWPCom(from: self, xmlrpc: blog.xmlrpc, username: blog.username, connectedEmail: blog.jetpack?.connectedEmail) + WordPressAuthenticator.showLoginForJustWPCom(from: self, jetpackLogin: true, connectedEmail: blog.jetpack?.connectedEmail) } func startObservingLoginNotifications() { @@ -318,13 +331,13 @@ private extension JetpackConnectionWebViewController { account = notification.object as? WPAccount if let redirect = pendingDotComRedirect { performDotComLogin(redirect: redirect) + } else { + delegate?.jetpackConnectionCompleted() } } func defaultAccount() -> WPAccount? { - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - return service.defaultWordPressComAccount() + try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) } enum Debug { @@ -340,3 +353,76 @@ private extension JetpackConnectionWebViewController { } } } + +/// If Jetpack is in site-connection state (Jetpack plugin is connected to the site but the site is not connected to .com account) +/// JetpackConnectionWebViewController conection flows that use jetpack/connect/ URL do not work +/// Using Jetpack REST APIs to fetch the required connection URLs to establish user-connection state +/// See https://github.com/wordpress-mobile/WordPress-iOS/issues/16489 +/// +private extension JetpackConnectionWebViewController { + func startNativeConnectionFlow() { + guard let api = blog.wordPressOrgRestApi else { + DDLogInfo("WordPressOrgRestAPI not loaded to perform native Jetpack connection") + startConnectionFlow() + return + } + + WPAnalytics.track(.jetpackPluginConnectUserAccountStarted) + + /// Observe all types of redictions happening on WKWebView which are not triggering decidePolicy delegate + subscriptions.removeAll() + webView.publisher(for: \.url) + .sink { [weak self] url in + guard let self, let siteURL = self.blog.url else { return } + self.handleNativeConnection(url?.absoluteString ?? "", siteURL: siteURL) + } + .store(in: &subscriptions) + + nativeConnectionService = JetpackNativeConnectionService(api: api) + + nativeConnectionService?.fetchJetpackConnectionURL() { [weak self] result in + guard let self else { return } + + switch result { + case .success(let url): + let request = URLRequest(url: url) + self.webView.load(request) + case .failure(let error): + DDLogError("Failed fetching Jetpack connection URL: \(error.localizedDescription)") + self.delegate?.jetpackConnectionCanceled() + + WPAnalytics.track(.jetpackPluginConnectUserAccountFailed) + } + } + } + + func handleNativeConnection(_ url: String, siteURL: String) { + let plansPage = "https://wordpress.com/jetpack/connect/plans" + // When the web view navigates to Jetpack plans page we can assume that the setup has completed. + if url.hasPrefix(plansPage) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.nativeConnectionService?.fetchJetpackUser() { result in + switch result { + case .success(let user): + if user.isConnected { + DDLogInfo("Jetpack user is connected after native connection flow is completed") + + WPAnalytics.track(.jetpackPluginConnectUserAccountCompleted) + } else { + DDLogError("Jetpack user is not connected after native connection flow is completed") + + WPAnalytics.track(.jetpackPluginConnectUserAccountFailed) + } + case .failure(let error): + DDLogError("Failed fetching Jetpack user: \(error.localizedDescription)") + + WPAnalytics.track(.jetpackPluginConnectUserAccountFailed) + } + + self.handleMobileRedirect() + } + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift new file mode 100644 index 000000000000..7743e5cde73b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/BaseRestoreCompleteViewController.swift @@ -0,0 +1,114 @@ +import Foundation +import CocoaLumberjack +import WordPressShared + +struct JetpackRestoreCompleteConfiguration { + let title: String + let iconImage: UIImage + let iconImageColor: UIColor + let messageTitle: String + let messageDescription: String + let primaryButtonTitle: String? + let secondaryButtonTitle: String? + let hint: String? +} + +class BaseRestoreCompleteViewController: UIViewController { + + // MARK: - Private Properties + + private(set) var site: JetpackSiteRef + private let activity: Activity + private let configuration: JetpackRestoreCompleteConfiguration + + private lazy var dateFormatter: DateFormatter = { + return ActivityDateFormatting.mediumDateFormatterWithTime(for: site) + }() + + private lazy var completeView: RestoreCompleteView = { + let completeView = RestoreCompleteView.loadFromNib() + completeView.translatesAutoresizingMaskIntoConstraints = false + return completeView + }() + + // MARK: - Initialization + + init(site: JetpackSiteRef, activity: Activity) { + fatalError("A configuration struct needs to be provided") + } + + init(site: JetpackSiteRef, + activity: Activity, + configuration: JetpackRestoreCompleteConfiguration) { + self.site = site + self.activity = activity + self.configuration = configuration + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + configureTitle() + configureNavigation() + configureRestoreCompleteView() + } + + // MARK: - Public + + func primaryButtonTapped() { + fatalError("Must override in subclass") + } + + func secondaryButtonTapped(from sender: UIButton) { + fatalError("Must override in subclass") + } + + // MARK: - Configure + + private func configureTitle() { + title = configuration.title + } + + private func configureNavigation() { + navigationItem.hidesBackButton = true + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, + target: self, + action: #selector(doneTapped)) + } + + private func configureRestoreCompleteView() { + let publishedDate = dateFormatter.string(from: activity.published) + + completeView.configure( + iconImage: configuration.iconImage, + iconImageColor: configuration.iconImageColor, + title: configuration.messageTitle, + description: String(format: configuration.messageDescription, publishedDate), + primaryButtonTitle: configuration.primaryButtonTitle, + secondaryButtonTitle: configuration.secondaryButtonTitle, + hint: configuration.hint + ) + + completeView.primaryButtonHandler = { [weak self] in + self?.primaryButtonTapped() + } + + completeView.secondaryButtonHandler = { [weak self] sender in + self?.secondaryButtonTapped(from: sender) + } + + view.addSubview(completeView) + view.pinSubviewToAllEdges(completeView) + } + + @objc private func doneTapped() { + self.dismiss(animated: true) + } + +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackBackupCompleteViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackBackupCompleteViewController.swift new file mode 100644 index 000000000000..dba0cf1607ab --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackBackupCompleteViewController.swift @@ -0,0 +1,88 @@ +import UIKit +import CocoaLumberjack +import WordPressFlux +import WordPressShared +import WordPressUI + +class JetpackBackupCompleteViewController: BaseRestoreCompleteViewController { + + private let backup: JetpackBackup + + // MARK: - Initialization + + init(site: JetpackSiteRef, activity: Activity, backup: JetpackBackup) { + self.backup = backup + + let restoreCompleteConfiguration = JetpackRestoreCompleteConfiguration( + title: NSLocalizedString("Backup", comment: "Title for Jetpack Backup Complete screen"), + iconImage: .gridicon(.history), + iconImageColor: .success, + messageTitle: NSLocalizedString("Your backup is now available for download", comment: "Title for the Jetpack Backup Complete message."), + messageDescription: NSLocalizedString("We successfully created a backup of your site from %1$@.", comment: "Description for the Jetpack Backup Complete message. %1$@ is a placeholder for the selected date."), + primaryButtonTitle: NSLocalizedString("Download file", comment: "Title for the button that will download the backup file."), + secondaryButtonTitle: NSLocalizedString("Share link", comment: "Title for the button that will share the link for the downlodable backup file"), + hint: NSLocalizedString("We've also emailed you a link to your file.", comment: "A hint to users indicating a link to the downloadable backup file has also been sent to their email.") + ) + + super.init(site: site, activity: activity, configuration: restoreCompleteConfiguration) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + } + + // MARK: - Override + + override func primaryButtonTapped() { + downloadFile() + WPAnalytics.track(.backupFileDownloadTapped) + } + + override func secondaryButtonTapped(from sender: UIButton) { + shareLink(from: sender) + WPAnalytics.track(.backupDownloadShareLinkTapped) + } + + // MARK: - Private + + private func downloadFile() { + guard let url = backup.url, + let downloadURL = URL(string: url) else { + + let title = NSLocalizedString("Unable to download file", comment: "Message displayed when opening the link to the downloadable backup fails.") + let notice = Notice(title: title) + ActionDispatcher.dispatch(NoticeAction.post(notice)) + + return + } + + UIApplication.shared.open(downloadURL) + } + + private func shareLink(from sender: UIButton) { + guard let url = backup.url, + let downloadURL = URL(string: url), + let activities = WPActivityDefaults.defaultActivities() as? [UIActivity] else { + + let title = NSLocalizedString("Unable to share link", comment: "Message displayed when sharing a link to the downloadable backup fails.") + let notice = Notice(title: title) + ActionDispatcher.dispatch(NoticeAction.post(notice)) + + return + } + + let activityVC = UIActivityViewController(activityItems: [downloadURL], applicationActivities: activities) + activityVC.popoverPresentationController?.sourceView = sender + activityVC.modalPresentationStyle = .popover + + + self.present(activityVC, animated: true) + } + +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackRestoreCompleteViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackRestoreCompleteViewController.swift new file mode 100644 index 000000000000..600d89b528eb --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackRestoreCompleteViewController.swift @@ -0,0 +1,63 @@ +import UIKit +import CocoaLumberjack +import WordPressFlux +import WordPressShared +import WordPressUI + +class JetpackRestoreCompleteViewController: BaseRestoreCompleteViewController { + + // MARK: - Initialization + + override init(site: JetpackSiteRef, activity: Activity) { + let restoreCompleteConfiguration = JetpackRestoreCompleteConfiguration( + title: NSLocalizedString("Restore", comment: "Title for Jetpack Restore Complete screen"), + iconImage: .gridicon(.history), + iconImageColor: .success, + messageTitle: NSLocalizedString("Your site has been restored", comment: "Title for the Jetpack Restore Complete message."), + messageDescription: NSLocalizedString("All of your selected items are now restored back to %1$@.", comment: "Description for the Jetpack Backup Restore message. %1$@ is a placeholder for the selected date."), + primaryButtonTitle: NSLocalizedString("Done", comment: "Title for the button that will dismiss this view."), + secondaryButtonTitle: NSLocalizedString("Visit site", comment: "Title for the button that will open a link to this site."), + hint: nil + ) + super.init(site: site, activity: activity, configuration: restoreCompleteConfiguration) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + } + + // MARK: - Override + + override func primaryButtonTapped() { + self.dismiss(animated: true) + } + + override func secondaryButtonTapped(from sender: UIButton) { + visitSite() + } + + // MARK: - Private + + private func visitSite() { + guard let homeURL = URL(string: site.homeURL) else { + + let title = NSLocalizedString("Unable to visit site", comment: "Message displayed when visiting a site fails.") + let notice = Notice(title: title) + ActionDispatcher.dispatch(NoticeAction.post(notice)) + + return + } + + let webVC = WebViewControllerFactory.controller(url: homeURL, source: "jetpack_restore_complete") + let navigationVC = LightNavigationController(rootViewController: webVC) + + self.present(navigationVC, animated: true) + } + +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackRestoreFailedViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackRestoreFailedViewController.swift new file mode 100644 index 000000000000..8202d18ca3f2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/JetpackRestoreFailedViewController.swift @@ -0,0 +1,40 @@ +import UIKit +import CocoaLumberjack +import WordPressShared +import WordPressUI + +class JetpackRestoreFailedViewController: BaseRestoreCompleteViewController { + + // MARK: - Initialization + + override init(site: JetpackSiteRef, activity: Activity) { + let restoreCompleteConfiguration = JetpackRestoreCompleteConfiguration( + title: NSLocalizedString("Restore Failed", comment: "Title for Jetpack Restore Failed screen"), + iconImage: .gridicon(.notice), + iconImageColor: .error, + messageTitle: NSLocalizedString("Unable to restore your site", comment: "Title for the Jetpack Restore Failed message."), + messageDescription: NSLocalizedString("Please try again later or contact support.", comment: "Description for the Jetpack Restore Failed message."), + primaryButtonTitle: NSLocalizedString("Done", comment: "Title for the button that will dismiss this view."), + secondaryButtonTitle: nil, + hint: nil + ) + super.init(site: site, activity: activity, configuration: restoreCompleteConfiguration) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + } + + // MARK: - Override + + override func primaryButtonTapped() { + self.dismiss(animated: true) + } + +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/Views/RestoreCompleteView.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/Views/RestoreCompleteView.swift new file mode 100644 index 000000000000..0b6b52e92ed7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/Views/RestoreCompleteView.swift @@ -0,0 +1,98 @@ +import Foundation +import Gridicons +import WordPressUI + +class RestoreCompleteView: UIView, NibLoadable { + + @IBOutlet private weak var icon: UIImageView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var descriptionLabel: UILabel! + @IBOutlet private weak var buttonStackView: UIStackView! + @IBOutlet private weak var primaryButton: FancyButton! + @IBOutlet private weak var secondaryButton: FancyButton! + @IBOutlet private weak var hintLabel: UILabel! + + var primaryButtonHandler: (() -> Void)? + var secondaryButtonHandler: ((_ sender: UIButton) -> Void)? + + // MARK: - Initialization + + override func awakeFromNib() { + super.awakeFromNib() + applyStyles() + } + + // MARK: - Styling + + private func applyStyles() { + backgroundColor = .basicBackground + + titleLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + titleLabel.textColor = .text + titleLabel.numberOfLines = 0 + + descriptionLabel.font = WPStyleGuide.fontForTextStyle(.body) + descriptionLabel.textColor = .textSubtle + descriptionLabel.numberOfLines = 0 + + primaryButton.isPrimary = true + + secondaryButton.isPrimary = false + + hintLabel.font = WPStyleGuide.fontForTextStyle(.subheadline) + hintLabel.textColor = .textSubtle + hintLabel.numberOfLines = 0 + hintLabel.textAlignment = .center + } + + // MARK: - Configuration + + func configure(iconImage: UIImage, + iconImageColor: UIColor, + title: String, + description: String, + primaryButtonTitle: String?, + secondaryButtonTitle: String?, + hint: String?) { + + icon.image = iconImage + icon.tintColor = iconImageColor + + titleLabel.text = title + + descriptionLabel.text = description + + secondaryButton.setTitle(secondaryButtonTitle, for: .normal) + + if let primaryButtonTitle = primaryButtonTitle { + primaryButton.setTitle(primaryButtonTitle, for: .normal) + primaryButton.isHidden = false + } else { + primaryButton.isHidden = true + } + + if let secondaryButtonTitle = secondaryButtonTitle { + secondaryButton.setTitle(secondaryButtonTitle, for: .normal) + secondaryButton.isHidden = false + } else { + secondaryButton.isHidden = true + } + + if let hint = hint { + hintLabel.text = hint + hintLabel.isHidden = false + } else { + hintLabel.isHidden = true + } + } + + // MARK: - IBAction + + @IBAction private func primaryButtonTapped(_ sender: Any) { + primaryButtonHandler?() + } + + @IBAction private func secondaryButtonTapped(_ sender: UIButton) { + secondaryButtonHandler?(sender as UIButton) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/Views/RestoreCompleteView.xib b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/Views/RestoreCompleteView.xib new file mode 100644 index 000000000000..cb0bf3eb9f13 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Complete/Views/RestoreCompleteView.xib @@ -0,0 +1,106 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17505"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="exg-17-kx5" customClass="RestoreCompleteView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="350"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Xmd-ci-SgX"> + <rect key="frame" x="16" y="24" width="32" height="32"/> + <constraints> + <constraint firstAttribute="width" secondItem="Xmd-ci-SgX" secondAttribute="height" id="Mi9-JV-xJD"/> + <constraint firstAttribute="width" constant="32" id="syu-pv-krR"/> + </constraints> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Kg2-kU-wlb"> + <rect key="frame" x="16" y="64" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="9e7-M2-foI"> + <rect key="frame" x="16" y="88.5" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="9rC-xp-cP4"> + <rect key="frame" x="16" y="133" width="382" height="104"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="gJa-oG-pp6" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="0.0" width="382" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="AiN-Cg-lVl"/> + </constraints> + <state key="normal" title="Button"/> + <connections> + <action selector="primaryButtonTapped:" destination="exg-17-kx5" eventType="touchUpInside" id="5nW-xM-Szd"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="ILP-H9-MIK" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="60" width="382" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="BtB-g7-9OH"/> + </constraints> + <state key="normal" title="Button"/> + <connections> + <action selector="secondaryButtonTapped:" destination="exg-17-kx5" eventType="touchUpInside" id="pUd-qm-1I8"/> + </connections> + </button> + </subviews> + </stackView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="PnT-bx-sf4"> + <rect key="frame" x="16" y="261" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <viewLayoutGuide key="safeArea" id="9ja-u2-XuL"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="9e7-M2-foI" firstAttribute="leading" secondItem="9ja-u2-XuL" secondAttribute="leading" constant="16" id="58r-qQ-W5Y"/> + <constraint firstItem="9rC-xp-cP4" firstAttribute="leading" secondItem="9ja-u2-XuL" secondAttribute="leading" constant="16" id="9L8-NJ-EDU"/> + <constraint firstItem="PnT-bx-sf4" firstAttribute="centerX" secondItem="9ja-u2-XuL" secondAttribute="centerX" id="COi-iQ-bxn"/> + <constraint firstItem="PnT-bx-sf4" firstAttribute="top" secondItem="9rC-xp-cP4" secondAttribute="bottom" constant="24" id="J9r-Lr-Qk2"/> + <constraint firstItem="9rC-xp-cP4" firstAttribute="centerX" secondItem="9ja-u2-XuL" secondAttribute="centerX" id="Jku-Ok-wCQ"/> + <constraint firstItem="9rC-xp-cP4" firstAttribute="top" secondItem="9e7-M2-foI" secondAttribute="bottom" constant="24" id="LRi-lZ-oWc"/> + <constraint firstItem="Kg2-kU-wlb" firstAttribute="centerX" secondItem="9ja-u2-XuL" secondAttribute="centerX" id="MbH-NR-37E"/> + <constraint firstItem="Xmd-ci-SgX" firstAttribute="leading" secondItem="9ja-u2-XuL" secondAttribute="leading" constant="16" id="NXw-Vm-6Tb"/> + <constraint firstItem="9e7-M2-foI" firstAttribute="centerX" secondItem="9ja-u2-XuL" secondAttribute="centerX" id="SBh-qd-SWd"/> + <constraint firstItem="PnT-bx-sf4" firstAttribute="leading" secondItem="9ja-u2-XuL" secondAttribute="leading" constant="16" id="bFX-up-vkG"/> + <constraint firstItem="9e7-M2-foI" firstAttribute="top" secondItem="Kg2-kU-wlb" secondAttribute="bottom" constant="4" id="gUW-80-a8v"/> + <constraint firstItem="9ja-u2-XuL" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="PnT-bx-sf4" secondAttribute="bottom" constant="24" id="ja1-gz-xXh"/> + <constraint firstItem="Xmd-ci-SgX" firstAttribute="top" secondItem="exg-17-kx5" secondAttribute="top" constant="24" id="mGb-xf-mkY"/> + <constraint firstItem="Kg2-kU-wlb" firstAttribute="top" secondItem="Xmd-ci-SgX" secondAttribute="bottom" constant="8" id="vUU-fK-kAZ"/> + <constraint firstItem="Kg2-kU-wlb" firstAttribute="leading" secondItem="9ja-u2-XuL" secondAttribute="leading" constant="16" id="xMV-Un-wQc"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="buttonStackView" destination="9rC-xp-cP4" id="1t2-ch-M9B"/> + <outlet property="descriptionLabel" destination="9e7-M2-foI" id="oXu-gs-N0e"/> + <outlet property="hintLabel" destination="PnT-bx-sf4" id="t1b-q8-hM2"/> + <outlet property="icon" destination="Xmd-ci-SgX" id="czX-HI-IVu"/> + <outlet property="primaryButton" destination="gJa-oG-pp6" id="jgz-HP-fjS"/> + <outlet property="secondaryButton" destination="ILP-H9-MIK" id="Yrp-f1-fO3"/> + <outlet property="titleLabel" destination="Kg2-kU-wlb" id="a3s-eL-yex"/> + </connections> + <point key="canvasLocation" x="-1520" y="-7"/> + </view> + </objects> + <resources> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/BaseRestoreOptionsViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/BaseRestoreOptionsViewController.swift new file mode 100644 index 000000000000..316b6d7e99da --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/BaseRestoreOptionsViewController.swift @@ -0,0 +1,287 @@ +import Foundation +import CocoaLumberjack +import WordPressShared + +typealias HighlightedText = (substring: String, string: String) + +struct JetpackRestoreOptionsConfiguration { + let title: String + let iconImage: UIImage + let messageTitle: String + let messageDescription: String + let generalSectionHeaderText: String + let buttonTitle: String + let warningButtonTitle: HighlightedText? + let isRestoreTypesConfigurable: Bool +} + +class BaseRestoreOptionsViewController: UITableViewController { + + // MARK: - Properties + + lazy var restoreTypes: JetpackRestoreTypes = { + if configuration.isRestoreTypesConfigurable { + return JetpackRestoreTypes() + } + return JetpackRestoreTypes(themes: false, + plugins: false, + uploads: false, + sqls: false, + roots: false, + contents: false) + }() + + // MARK: - Private Properties + + private(set) var site: JetpackSiteRef + private(set) var activity: Activity + private let configuration: JetpackRestoreOptionsConfiguration + + private lazy var handler: ImmuTableViewHandler = { + return ImmuTableViewHandler(takeOver: self) + }() + + private lazy var dateFormatter: DateFormatter = { + return ActivityDateFormatting.mediumDateFormatterWithTime(for: site) + }() + + private lazy var headerView: JetpackRestoreHeaderView = { + return JetpackRestoreHeaderView.loadFromNib() + }() + + /// A String identifier from the screen that presented this VC + var presentedFrom: String = "unknown" + + // MARK: - Initialization + + init(site: JetpackSiteRef, activity: Activity) { + fatalError("A configuration struct needs to be provided") + } + + init(site: JetpackSiteRef, + activity: Activity, + configuration: JetpackRestoreOptionsConfiguration) { + self.site = site + self.activity = activity + self.configuration = configuration + super.init(style: .grouped) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + configureTitle() + configureNavigation() + configureTableView() + configureTableHeaderView() + reloadViewModel() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + tableView.layoutHeaderView() + } + + // MARK: - Public + + func actionButtonTapped() { + fatalError("Must override in subclass") + } + + func detailActionButtonTapped() { + fatalError("Must override in subclass") + } + + // MARK: - Configure + + private func configureTitle() { + title = configuration.title + } + + private func configureNavigation() { + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, + target: self, + action: #selector(cancelTapped)) + } + + private func configureTableView() { + WPStyleGuide.configureColors(view: view, tableView: tableView) + ImmuTable.registerRows([SwitchRow.self], tableView: tableView) + } + + private func configureTableHeaderView() { + let publishedDate = dateFormatter.string(from: activity.published) + + headerView.configure( + iconImage: configuration.iconImage, + title: configuration.messageTitle, + description: String(format: configuration.messageDescription, publishedDate), + buttonTitle: configuration.buttonTitle, + warningButtonTitle: configuration.warningButtonTitle + ) + + headerView.toggleActionButton(isEnabled: configuration.isRestoreTypesConfigurable) + + headerView.actionButtonHandler = { [weak self] in + self?.actionButtonTapped() + } + + headerView.warningButtonHandler = { [weak self] in + self?.detailActionButtonTapped() + } + + self.tableView.tableHeaderView = headerView + } + + // MARK: - Model + + private func reloadViewModel() { + handler.viewModel = tableViewModel() + } + + private func tableViewModel() -> ImmuTable { + return ImmuTable( + sections: [ + generalSection(), + contentSection(), + databaseSection() + ] + ) + } + + private func generalSection() -> ImmuTableSection { + let themesRow = SwitchRow( + title: Strings.themesRowTitle, + value: restoreTypes.themes, + isUserInteractionEnabled: configuration.isRestoreTypesConfigurable, + onChange: toggleThemes(value:) + ) + let pluginsRow = SwitchRow( + title: Strings.pluginsRowTitle, + value: restoreTypes.plugins, + isUserInteractionEnabled: configuration.isRestoreTypesConfigurable, + onChange: togglePlugins(value:) + ) + let mediaUploadsRow = SwitchRow( + title: Strings.mediaUploadsRowTitle, + value: restoreTypes.uploads, + isUserInteractionEnabled: configuration.isRestoreTypesConfigurable, + onChange: toggleUploads(value:) + ) + let rootRow = SwitchRow( + title: Strings.rootRowTitle, + value: restoreTypes.roots, + isUserInteractionEnabled: configuration.isRestoreTypesConfigurable, + onChange: toggleRoots(value:) + ) + + return ImmuTableSection( + headerText: configuration.generalSectionHeaderText, + rows: [ + themesRow, + pluginsRow, + mediaUploadsRow, + rootRow + ], + footerText: Strings.generalSectionFooterText + ) + } + + private func contentSection() -> ImmuTableSection { + let contentRow = SwitchRow( + title: Strings.contentRowTitle, + value: restoreTypes.contents, + isUserInteractionEnabled: configuration.isRestoreTypesConfigurable, + onChange: toggleContents(value:) + ) + + return ImmuTableSection( + headerText: "", + rows: [contentRow], + footerText: Strings.contentSectionFooterText + ) + } + + private func databaseSection() -> ImmuTableSection { + let databaseRow = SwitchRow( + title: Strings.databaseRowTitle, + value: restoreTypes.sqls, + isUserInteractionEnabled: configuration.isRestoreTypesConfigurable, + onChange: toggleSqls(value:) + ) + + return ImmuTableSection( + headerText: "", + rows: [databaseRow], + footerText: nil + ) + } + + + // MARK: - Private Helpers + + @objc private func cancelTapped() { + self.dismiss(animated: true) + } + + private func toggleThemes(value: Bool) { + restoreTypes.themes = value + updateHeaderView() + } + + private func togglePlugins(value: Bool) { + restoreTypes.plugins = value + updateHeaderView() + } + + private func toggleUploads(value: Bool) { + restoreTypes.uploads = value + updateHeaderView() + } + + private func toggleRoots(value: Bool) { + restoreTypes.roots = value + updateHeaderView() + } + + private func toggleContents(value: Bool) { + restoreTypes.contents = value + updateHeaderView() + } + + private func toggleSqls(value: Bool) { + restoreTypes.sqls = value + updateHeaderView() + } + + private func updateHeaderView() { + let isItemSelectionEmpty = + restoreTypes.themes == false && + restoreTypes.plugins == false && + restoreTypes.uploads == false && + restoreTypes.roots == false && + restoreTypes.contents == false && + restoreTypes.sqls == false + + headerView.toggleActionButton(isEnabled: !isItemSelectionEmpty) + } +} + +extension BaseRestoreOptionsViewController { + + private enum Strings { + static let themesRowTitle = NSLocalizedString("WordPress Themes", comment: "Downloadable/Restorable items: WordPress Themes") + static let pluginsRowTitle = NSLocalizedString("WordPress Plugins", comment: "Downloadable/Restorable items: WordPress Plugins") + static let mediaUploadsRowTitle = NSLocalizedString("Media Uploads", comment: "Downloadable/Restorable items: Media Uploads") + static let rootRowTitle = NSLocalizedString("WordPress root", comment: "Downloadable/Restorable items: WordPress root") + static let generalSectionFooterText = NSLocalizedString("Includes wp-config.php and any non WordPress files", comment: "Downloadable/Restorable items: general section footer text") + static let contentRowTitle = NSLocalizedString("WP-content directory", comment: "Downloadable/Restorable items: WP-content directory") + static let contentSectionFooterText = NSLocalizedString("Excludes themes, plugins, and uploads", comment: "Downloadable/Restorable items: content section footer text") + static let databaseRowTitle = NSLocalizedString("Site database", comment: "Downloadable/Restorable items: Site Database") + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/Coordinators/JetpackBackupOptionsCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/Coordinators/JetpackBackupOptionsCoordinator.swift new file mode 100644 index 000000000000..ddb8005a1b81 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/Coordinators/JetpackBackupOptionsCoordinator.swift @@ -0,0 +1,58 @@ +import Foundation + +protocol JetpackBackupOptionsView { + func showNoInternetConnection() + func showBackupAlreadyRunning() + func showBackupRequestFailed() + func showBackupStarted(for downloadID: Int) +} + +class JetpackBackupOptionsCoordinator { + + // MARK: - Properties + + private let service: JetpackBackupService + private let site: JetpackSiteRef + private let rewindID: String? + private let restoreTypes: JetpackRestoreTypes + private let view: JetpackBackupOptionsView + + // MARK: - Init + + init(site: JetpackSiteRef, + rewindID: String?, + restoreTypes: JetpackRestoreTypes, + view: JetpackBackupOptionsView, + service: JetpackBackupService? = nil, + coreDataStack: CoreDataStack = ContextManager.sharedInstance()) { + self.service = service ?? JetpackBackupService(coreDataStack: coreDataStack) + self.site = site + self.rewindID = rewindID + self.restoreTypes = restoreTypes + self.view = view + } + + // MARK: - Public + + func prepareBackup() { + guard ReachabilityUtils.isInternetReachable() else { + self.view.showNoInternetConnection() + return + } + + service.prepareBackup(for: site, rewindID: rewindID, restoreTypes: restoreTypes, success: { [weak self] backup in + + guard let rewindID = self?.rewindID, rewindID == backup.rewindID else { + self?.view.showBackupAlreadyRunning() + return + } + + self?.view.showBackupStarted(for: backup.downloadID) + + }, failure: { [weak self] error in + DDLogError("Error preparing downloadable backup object: \(error.localizedDescription)") + + self?.view.showBackupRequestFailed() + }) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/JetpackBackupOptionsViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/JetpackBackupOptionsViewController.swift new file mode 100644 index 000000000000..3eeda1044bb4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/JetpackBackupOptionsViewController.swift @@ -0,0 +1,97 @@ +import UIKit +import CocoaLumberjack +import Gridicons +import WordPressFlux +import WordPressUI +import WordPressShared + +class JetpackBackupOptionsViewController: BaseRestoreOptionsViewController { + + // MARK: - Properties + + weak var backupStatusDelegate: JetpackBackupStatusViewControllerDelegate? + + // MARK: - Private Properties + + private lazy var coordinator: JetpackBackupOptionsCoordinator = { + return JetpackBackupOptionsCoordinator(site: self.site, + rewindID: self.activity.rewindID, + restoreTypes: self.restoreTypes, + view: self) + }() + + // MARK: - Initialization + + override init(site: JetpackSiteRef, activity: Activity) { + let restoreOptionsConfiguration = JetpackRestoreOptionsConfiguration( + title: NSLocalizedString("Download Backup", comment: "Title for the Jetpack Download Backup Site Screen"), + iconImage: UIImage.gridicon(.history), + messageTitle: NSLocalizedString("Create downloadable backup", comment: "Label that describes the download backup action"), + messageDescription: NSLocalizedString("%1$@ is the selected point to create a downloadable backup.", comment: "Description for the download backup action. $1$@ is a placeholder for the selected date."), + generalSectionHeaderText: NSLocalizedString("Choose the items to download", comment: "Downloadable items: general section title"), + buttonTitle: NSLocalizedString("Create downloadable file", comment: "Button title for download backup action"), + warningButtonTitle: nil, + isRestoreTypesConfigurable: true + ) + super.init(site: site, activity: activity, configuration: restoreOptionsConfiguration) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + WPAnalytics.track(.backupDownloadOpened, properties: ["source": presentedFrom]) + } + + // MARK: - Override + + override func actionButtonTapped() { + WPAnalytics.track(.backupDownloadConfirmed, properties: ["restore_types": [ + "themes": restoreTypes.themes, + "plugins": restoreTypes.plugins, + "uploads": restoreTypes.uploads, + "sqls": restoreTypes.sqls, + "roots": restoreTypes.roots, + "contents": restoreTypes.contents + ]]) + + coordinator.prepareBackup() + } +} + +extension JetpackBackupOptionsViewController: JetpackBackupOptionsView { + + func showNoInternetConnection() { + ReachabilityUtils.showAlertNoInternetConnection() + WPAnalytics.track(.backupFileDownloadError, properties: ["cause": "offline"]) + } + + func showBackupAlreadyRunning() { + let title = NSLocalizedString("There's a backup currently being prepared, please wait before starting the next one", comment: "Text displayed when user tries to create a downloadable backup when there is already one being prepared") + let notice = Notice(title: title) + ActionDispatcher.dispatch(NoticeAction.post(notice)) + WPAnalytics.track(.backupFileDownloadError, properties: ["cause": "other"]) + } + + func showBackupRequestFailed() { + let errorTitle = NSLocalizedString("Backup failed", comment: "Title for error displayed when preparing a backup fails.") + let errorMessage = NSLocalizedString("We couldn't create your backup. Please try again later.", comment: "Message for error displayed when preparing a backup fails.") + let notice = Notice(title: errorTitle, message: errorMessage) + ActionDispatcher.dispatch(NoticeAction.post(notice)) + WPAnalytics.track(.backupFileDownloadError, properties: ["cause": "remote"]) + } + + func showBackupStarted(for downloadID: Int) { + let statusVC = JetpackBackupStatusViewController(site: site, + activity: activity, + downloadID: downloadID) + statusVC.delegate = backupStatusDelegate + self.navigationController?.pushViewController(statusVC, animated: true) + } + +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/JetpackRestoreOptionsViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/JetpackRestoreOptionsViewController.swift new file mode 100644 index 000000000000..70e13ed114b0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/JetpackRestoreOptionsViewController.swift @@ -0,0 +1,79 @@ +import Foundation +import CocoaLumberjack +import Gridicons +import WordPressUI +import WordPressShared + +class JetpackRestoreOptionsViewController: BaseRestoreOptionsViewController { + + // MARK: - Properties + + weak var restoreStatusDelegate: JetpackRestoreStatusViewControllerDelegate? + + // MARK: - Private Property + + private let isAwaitingCredentials: Bool + + // MARK: - Initialization + + init(site: JetpackSiteRef, activity: Activity, isAwaitingCredentials: Bool) { + + let highlightedSubstring = NSLocalizedString("Enter your server credentials", comment: "Error message displayed when site credentials aren't configured.") + let warningFormat = NSLocalizedString("%1$@ to enable one click site restores from backups.", comment: "Error message displayed when restoring a site fails due to credentials not being configured. %1$@ is a placeholder for the string 'Enter your server credentials'.") + let warningString = String(format: warningFormat, highlightedSubstring) + let warningButtonTitle = HighlightedText(substring: highlightedSubstring, string: warningString) + + let restoreOptionsConfiguration = JetpackRestoreOptionsConfiguration( + title: NSLocalizedString("Restore", comment: "Title for the Jetpack Restore Site Screen"), + iconImage: UIImage.gridicon(.history), + messageTitle: NSLocalizedString("Restore site", comment: "Label that describes the restore site action"), + messageDescription: NSLocalizedString("%1$@ is the selected point for your restore.", comment: "Description for the restore action. $1$@ is a placeholder for the selected date."), + generalSectionHeaderText: NSLocalizedString("Choose the items to restore", comment: "Restorable items: general section title"), + buttonTitle: NSLocalizedString("Restore to this point", comment: "Button title for restore site action"), + warningButtonTitle: isAwaitingCredentials ? warningButtonTitle : nil, + isRestoreTypesConfigurable: !isAwaitingCredentials + ) + + self.isAwaitingCredentials = isAwaitingCredentials + + super.init(site: site, activity: activity, configuration: restoreOptionsConfiguration) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + WPAnalytics.track(.restoreOpened, properties: ["source": presentedFrom]) + } + + // MARK: - Override + + override func actionButtonTapped() { + let warningVC = JetpackRestoreWarningViewController(site: site, + activity: activity, + restoreTypes: restoreTypes) + warningVC.restoreStatusDelegate = restoreStatusDelegate + self.navigationController?.pushViewController(warningVC, animated: true) + } + + override func detailActionButtonTapped() { + guard let controller = JetpackWebViewControllerFactory.settingsController(siteID: site.siteID) else { + + let title = NSLocalizedString("Unable to visit Jetpack settings for site", comment: "Message displayed when visiting the Jetpack settings page fails.") + + displayNotice(title: title) + + return + } + + let navigationVC = UINavigationController(rootViewController: controller) + + present(navigationVC, animated: true) + } + +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/Views/JetpackRestoreHeaderView.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/Views/JetpackRestoreHeaderView.swift new file mode 100644 index 000000000000..592193668586 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/Views/JetpackRestoreHeaderView.swift @@ -0,0 +1,81 @@ +import Foundation +import Gridicons +import WordPressUI + +class JetpackRestoreHeaderView: UIView, NibReusable { + + @IBOutlet private weak var icon: UIImageView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var descriptionLabel: UILabel! + @IBOutlet private weak var actionButton: FancyButton! + @IBOutlet private weak var warningButton: MultilineButton! + + var actionButtonHandler: (() -> Void)? + var warningButtonHandler: (() -> Void)? + + override func awakeFromNib() { + super.awakeFromNib() + applyStyles() + } + + // MARK: - Styling + + private func applyStyles() { + icon.tintColor = .success + + titleLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + titleLabel.textColor = .text + + descriptionLabel.font = WPStyleGuide.fontForTextStyle(.body) + descriptionLabel.textColor = .textSubtle + descriptionLabel.numberOfLines = 0 + descriptionLabel.preferredMaxLayoutWidth = descriptionLabel.bounds.width + + actionButton.isPrimary = true + + warningButton.setTitleColor(.text, for: .normal) + warningButton.titleLabel?.lineBreakMode = .byWordWrapping + warningButton.titleLabel?.numberOfLines = 0 + } + + // MARK: - Configuration + + func configure(iconImage: UIImage, + title: String, + description: String, + buttonTitle: String, + warningButtonTitle: HighlightedText?) { + icon.image = iconImage + titleLabel.text = title + descriptionLabel.text = description + actionButton.setTitle(buttonTitle, for: .normal) + + if let warningButtonTitle = warningButtonTitle { + let attributedTitle = WPStyleGuide.Jetpack.highlightString(warningButtonTitle.substring, + inString: warningButtonTitle.string) + warningButton.setAttributedTitle(attributedTitle, for: .normal) + + warningButton.setImage(.gridicon(.plusSmall), for: .normal) + + warningButton.isHidden = false + } else { + warningButton.isHidden = true + } + } + + // MARK: - Public + + func toggleActionButton(isEnabled: Bool) { + actionButton.isEnabled = isEnabled + } + + // MARK: - IBActions + + @IBAction private func actionButtonTapped(_ sender: UIButton) { + actionButtonHandler?() + } + + @IBAction private func warningButtonTapped(_ sender: UIButton) { + warningButtonHandler?() + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/Views/JetpackRestoreHeaderView.xib b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/Views/JetpackRestoreHeaderView.xib new file mode 100644 index 000000000000..f8fa91a50099 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Options/Views/JetpackRestoreHeaderView.xib @@ -0,0 +1,95 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="iN0-l3-epB" customClass="JetpackRestoreHeaderView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="400"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="n7M-ds-fce"> + <rect key="frame" x="16" y="68" width="32" height="32"/> + <constraints> + <constraint firstAttribute="width" constant="32" id="4tx-db-V8c"/> + <constraint firstAttribute="width" secondItem="n7M-ds-fce" secondAttribute="height" id="d6T-OY-eph"/> + </constraints> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Mst-Hr-reS"> + <rect key="frame" x="12" y="108" width="390" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="6ag-Xy-HZc"> + <rect key="frame" x="12" y="132.5" width="390" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="kb2-Th-YeS"> + <rect key="frame" x="16" y="177" width="382" height="84"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="ZHA-ex-Sko" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="0.0" width="382" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="dbx-D0-wqq"/> + </constraints> + <state key="normal" title="Button"/> + <connections> + <action selector="actionButtonTapped:" destination="iN0-l3-epB" eventType="touchUpInside" id="u3x-lF-FgS"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="leading" contentVerticalAlignment="top" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="5jP-J3-oaJ" customClass="MultilineButton" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="60" width="382" height="24"/> + <inset key="contentEdgeInsets" minX="-4" minY="0.0" maxX="0.0" maxY="0.0"/> + <inset key="titleEdgeInsets" minX="2" minY="2" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Button" image="plus-small"/> + <connections> + <action selector="warningButtonTapped:" destination="iN0-l3-epB" eventType="touchUpInside" id="ehh-9G-PcQ"/> + </connections> + </button> + </subviews> + </stackView> + </subviews> + <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="6ag-Xy-HZc" firstAttribute="top" secondItem="Mst-Hr-reS" secondAttribute="bottom" constant="4" id="8VK-Hs-pYo"/> + <constraint firstItem="6ag-Xy-HZc" firstAttribute="centerX" secondItem="vUN-kp-3ea" secondAttribute="centerX" id="KEq-KC-yYS"/> + <constraint firstItem="Mst-Hr-reS" firstAttribute="centerX" secondItem="vUN-kp-3ea" secondAttribute="centerX" id="MJ8-yT-Xv2"/> + <constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="kb2-Th-YeS" secondAttribute="bottom" constant="24" id="Tzw-JQ-f3a"/> + <constraint firstItem="6ag-Xy-HZc" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="12" id="Z4E-ad-gtP"/> + <constraint firstItem="kb2-Th-YeS" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="bZA-XA-AV3"/> + <constraint firstItem="n7M-ds-fce" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="c74-aq-KMQ"/> + <constraint firstItem="Mst-Hr-reS" firstAttribute="top" secondItem="n7M-ds-fce" secondAttribute="bottom" constant="8" id="dbE-sr-re1"/> + <constraint firstItem="kb2-Th-YeS" firstAttribute="centerX" secondItem="vUN-kp-3ea" secondAttribute="centerX" id="nF2-Oy-LII"/> + <constraint firstItem="n7M-ds-fce" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" constant="24" id="oEw-bW-mvS"/> + <constraint firstItem="Mst-Hr-reS" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="12" id="qKc-j2-bGE"/> + <constraint firstItem="kb2-Th-YeS" firstAttribute="top" secondItem="6ag-Xy-HZc" secondAttribute="bottom" constant="24" id="qbp-om-LNE"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="actionButton" destination="ZHA-ex-Sko" id="ftK-cT-i0k"/> + <outlet property="descriptionLabel" destination="6ag-Xy-HZc" id="ZBU-Lb-HZp"/> + <outlet property="icon" destination="n7M-ds-fce" id="XL3-jc-2wb"/> + <outlet property="titleLabel" destination="Mst-Hr-reS" id="yvm-WQ-P3n"/> + <outlet property="warningButton" destination="5jP-J3-oaJ" id="BH1-El-vym"/> + </connections> + <point key="canvasLocation" x="-17" y="69"/> + </view> + </objects> + <resources> + <image name="plus-small" width="24" height="24"/> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/BaseRestoreStatusFailedViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/BaseRestoreStatusFailedViewController.swift new file mode 100644 index 000000000000..7abda5578966 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/BaseRestoreStatusFailedViewController.swift @@ -0,0 +1,78 @@ +import UIKit +import WordPressUI + +struct RestoreStatusFailedConfiguration { + let title: String + let messageTitle: String + let firstHint: String + let secondHint: String + let thirdHint: String +} + +class BaseRestoreStatusFailedViewController: UIViewController { + + private let configuration: RestoreStatusFailedConfiguration + + lazy var restoreStatusFailedView: RestoreStatusFailedView = { + let restoreStatusFailedView = RestoreStatusFailedView.loadFromNib() + restoreStatusFailedView.translatesAutoresizingMaskIntoConstraints = false + return restoreStatusFailedView + }() + + // MARK: - Init + + init() { + fatalError("A configuration struct needs to be provided") + } + + init(configuration: RestoreStatusFailedConfiguration) { + self.configuration = configuration + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + configureTitle() + configureNavigation() + configureRestoreStatusFailedView() + } + + // MARK: - Private + + private func configureTitle() { + title = configuration.title + } + + private func configureNavigation() { + navigationItem.hidesBackButton = true + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, + target: self, + action: #selector(doneTapped)) + } + + private func configureRestoreStatusFailedView() { + restoreStatusFailedView.configure( + title: configuration.messageTitle, + firstHint: configuration.firstHint, + secondHint: configuration.secondHint, + thirdHint: configuration.thirdHint + ) + + restoreStatusFailedView.doneButtonHandler = { [weak self] in + self?.doneTapped() + } + + view.addSubview(restoreStatusFailedView) + view.pinSubviewToAllEdges(restoreStatusFailedView) + } + + @objc private func doneTapped() { + self.dismiss(animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/JetpackBackupStatusFailedViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/JetpackBackupStatusFailedViewController.swift new file mode 100644 index 000000000000..22fae122cdcf --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/JetpackBackupStatusFailedViewController.swift @@ -0,0 +1,31 @@ +import UIKit +import CocoaLumberjack +import WordPressShared +import WordPressUI + +class JetpackBackupStatusFailedViewController: BaseRestoreStatusFailedViewController { + + // MARK: - Initialization + + override init() { + let configuration = RestoreStatusFailedConfiguration( + title: NSLocalizedString("Backup", comment: "Title for Jetpack Backup Update Status Failed screen"), + messageTitle: NSLocalizedString("Hmm, we couldn’t find your backup status", comment: "Message title displayed when we fail to fetch the status of the backup in progress."), + firstHint: NSLocalizedString("We couldn’t find the status to say how long your backup will take.", comment: "Hint displayed when we fail to fetch the status of the backup in progress."), + secondHint: NSLocalizedString("We’ll still attempt to backup your site.", comment: "Hint displayed when we fail to fetch the status of the backup in progress."), + thirdHint: NSLocalizedString("We’ll notify you when its done.", comment: "Hint displayed when we fail to fetch the status of the backup in progress.") + ) + super.init(configuration: configuration) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + } + +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/JetpackRestoreStatusFailedViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/JetpackRestoreStatusFailedViewController.swift new file mode 100644 index 000000000000..633c3a88388a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/JetpackRestoreStatusFailedViewController.swift @@ -0,0 +1,31 @@ +import UIKit +import CocoaLumberjack +import WordPressShared +import WordPressUI + +class JetpackRestoreStatusFailedViewController: BaseRestoreStatusFailedViewController { + + // MARK: - Initialization + + override init() { + let configuration = RestoreStatusFailedConfiguration( + title: NSLocalizedString("Restore", comment: "Title for Jetpack Restore Status Failed screen"), + messageTitle: NSLocalizedString("Hmm, we couldn’t find your restore status", comment: "Message title displayed when we fail to fetch the status of the restore in progress."), + firstHint: NSLocalizedString("We couldn’t find the status to say how long your restore will take.", comment: "Hint displayed when we fail to fetch the status of the restore in progress."), + secondHint: NSLocalizedString("We’ll still attempt to restore your site.", comment: "Hint displayed when we fail to fetch the status of the restore in progress."), + thirdHint: NSLocalizedString("We’ll notify you when its done.", comment: "Hint displayed when we fail to fetch the status of the restore in progress.") + ) + super.init(configuration: configuration) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + } + +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/Views/RestoreStatusFailedView.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/Views/RestoreStatusFailedView.swift new file mode 100644 index 000000000000..944fae74258b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/Views/RestoreStatusFailedView.swift @@ -0,0 +1,75 @@ +import Foundation +import Gridicons +import WordPressUI + +class RestoreStatusFailedView: UIView, NibLoadable { + + @IBOutlet private weak var messageTitleLabel: UILabel! + @IBOutlet private weak var firstHintIcon: UIImageView! + @IBOutlet private weak var firstHintLabel: UILabel! + @IBOutlet private weak var secondHintIcon: UIImageView! + @IBOutlet private weak var secondHintLabel: UILabel! + @IBOutlet private weak var thirdHintIcon: UIImageView! + @IBOutlet private weak var thirdHintLabel: UILabel! + @IBOutlet private weak var doneButton: FancyButton! + + var doneButtonHandler: (() -> Void)? + + // MARK: - Initialization + + override func awakeFromNib() { + super.awakeFromNib() + applyStyles() + } + + // MARK: - Styling + + private func applyStyles() { + backgroundColor = .basicBackground + + messageTitleLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + messageTitleLabel.textColor = .text + messageTitleLabel.numberOfLines = 0 + + firstHintIcon.image = .gridicon(.history) + firstHintIcon.tintColor = .warning + + secondHintIcon.image = .gridicon(.checkmarkCircle) + secondHintIcon.tintColor = .success + + thirdHintIcon.image = .gridicon(.checkmarkCircle) + thirdHintIcon.tintColor = .success + + let messageLabels = [firstHintLabel, secondHintLabel, thirdHintLabel] + for label in messageLabels { + label?.font = WPStyleGuide.fontForTextStyle(.body) + label?.textColor = .text + label?.numberOfLines = 0 + } + + doneButton.isPrimary = true + } + + // MARK: - Configuration + + func configure(title: String, + firstHint: String, + secondHint: String, + thirdHint: String) { + messageTitleLabel.text = title + firstHintLabel.text = firstHint + secondHintLabel.text = secondHint + thirdHintLabel.text = thirdHint + doneButton.setTitle(Strings.done, for: .normal) + } + + // MARK: - IBAction + + @IBAction private func doneButtonTapped(_ sender: Any) { + doneButtonHandler?() + } + + private enum Strings { + static let done = NSLocalizedString("Done", comment: "Title for button that will dismiss this view") + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/Views/RestoreStatusFailedView.xib b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/Views/RestoreStatusFailedView.xib new file mode 100644 index 000000000000..b72b55821aa1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status Failed/Views/RestoreStatusFailedView.xib @@ -0,0 +1,126 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17505"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="iN0-l3-epB" customClass="RestoreStatusFailedView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="24" translatesAutoresizingMaskIntoConstraints="NO" id="QUb-8V-Y1D"> + <rect key="frame" x="24" y="16" width="366" height="216.5"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bhH-0J-HIL"> + <rect key="frame" x="0.0" y="0.0" width="366" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="Ruk-nf-DEv"> + <rect key="frame" x="0.0" y="44.5" width="366" height="104"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="24" translatesAutoresizingMaskIntoConstraints="NO" id="XcH-4D-Yzx"> + <rect key="frame" x="0.0" y="0.0" width="366" height="24"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="teu-El-4Ap"> + <rect key="frame" x="0.0" y="0.0" width="24" height="24"/> + <constraints> + <constraint firstAttribute="width" constant="24" id="Gfx-MC-N7D"/> + <constraint firstAttribute="width" secondItem="teu-El-4Ap" secondAttribute="height" id="Y1G-pw-hLt"/> + </constraints> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="kAC-BG-edi"> + <rect key="frame" x="48" y="2" width="318" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="24" translatesAutoresizingMaskIntoConstraints="NO" id="AYv-8j-cHK"> + <rect key="frame" x="0.0" y="40" width="366" height="24"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="4Xi-iN-y9N"> + <rect key="frame" x="0.0" y="0.0" width="24" height="24"/> + <constraints> + <constraint firstAttribute="width" constant="24" id="hbM-XR-xgW"/> + <constraint firstAttribute="width" secondItem="4Xi-iN-y9N" secondAttribute="height" id="s7f-YK-yB7"/> + </constraints> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="yda-gY-uDz"> + <rect key="frame" x="48" y="2" width="318" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="24" translatesAutoresizingMaskIntoConstraints="NO" id="QSY-9M-Zws"> + <rect key="frame" x="0.0" y="80" width="366" height="24"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="OSR-5b-ieX"> + <rect key="frame" x="0.0" y="0.0" width="24" height="24"/> + <constraints> + <constraint firstAttribute="width" constant="24" id="7J1-E9-iJJ"/> + <constraint firstAttribute="width" secondItem="OSR-5b-ieX" secondAttribute="height" id="OMt-Ua-t8q"/> + </constraints> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="aBu-lO-YzM"> + <rect key="frame" x="48" y="2" width="318" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + </subviews> + </stackView> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="an7-Qi-Z6y" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="172.5" width="366" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="tUK-bJ-9vS"/> + </constraints> + <state key="normal" title="Button"/> + <connections> + <action selector="doneButtonTapped:" destination="iN0-l3-epB" eventType="touchUpInside" id="pqN-W2-mNT"/> + </connections> + </button> + </subviews> + </stackView> + </subviews> + <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="QUb-8V-Y1D" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" constant="16" id="GIU-9h-WuB"/> + <constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="QUb-8V-Y1D" secondAttribute="bottom" constant="24" id="ILu-Fs-rLY"/> + <constraint firstItem="QUb-8V-Y1D" firstAttribute="centerX" secondItem="iN0-l3-epB" secondAttribute="centerX" id="naC-Ty-V1O"/> + <constraint firstItem="QUb-8V-Y1D" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="24" id="pR1-Ih-sSH"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="doneButton" destination="an7-Qi-Z6y" id="DqA-cP-xYp"/> + <outlet property="firstHintIcon" destination="teu-El-4Ap" id="1N8-tM-u1v"/> + <outlet property="firstHintLabel" destination="kAC-BG-edi" id="qmD-sF-PIG"/> + <outlet property="messageTitleLabel" destination="bhH-0J-HIL" id="oCB-Ib-I1z"/> + <outlet property="secondHintIcon" destination="4Xi-iN-y9N" id="Zbd-hb-EXF"/> + <outlet property="secondHintLabel" destination="yda-gY-uDz" id="f14-Cf-swd"/> + <outlet property="thirdHintIcon" destination="OSR-5b-ieX" id="GQt-rV-XMa"/> + <outlet property="thirdHintLabel" destination="aBu-lO-YzM" id="xoC-16-DVW"/> + </connections> + <point key="canvasLocation" x="-78" y="58"/> + </view> + </objects> + <resources> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift new file mode 100644 index 000000000000..fe3c257b47a2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/BaseRestoreStatusViewController.swift @@ -0,0 +1,102 @@ +import Foundation +import CocoaLumberjack +import WordPressShared + +struct JetpackRestoreStatusConfiguration { + let title: String + let iconImage: UIImage + let messageTitle: String + let messageDescription: String + let hint: String + let primaryButtonTitle: String + let placeholderProgressTitle: String? + let progressDescription: String? +} + +class BaseRestoreStatusViewController: UIViewController { + + // MARK: - Public Properties + + lazy var statusView: RestoreStatusView = { + let statusView = RestoreStatusView.loadFromNib() + statusView.translatesAutoresizingMaskIntoConstraints = false + return statusView + }() + + // MARK: - Private Properties + + private(set) var site: JetpackSiteRef + private(set) var activity: Activity + private(set) var configuration: JetpackRestoreStatusConfiguration + + private lazy var dateFormatter: DateFormatter = { + return ActivityDateFormatting.mediumDateFormatterWithTime(for: site) + }() + + // MARK: - Initialization + + init(site: JetpackSiteRef, activity: Activity) { + fatalError("A configuration struct needs to be provided") + } + + init(site: JetpackSiteRef, + activity: Activity, + configuration: JetpackRestoreStatusConfiguration) { + self.site = site + self.activity = activity + self.configuration = configuration + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + configureTitle() + configureNavigation() + configureRestoreStatusView() + } + + // MARK: - Public + + func primaryButtonTapped() { + fatalError("Must override in subclass") + } + + // MARK: - Configure + + private func configureTitle() { + title = configuration.title + } + + private func configureNavigation() { + navigationItem.hidesBackButton = true + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, + target: self, + action: #selector(doneTapped)) + } + + private func configureRestoreStatusView() { + let publishedDate = dateFormatter.string(from: activity.published) + + statusView.configure( + iconImage: configuration.iconImage, + title: configuration.messageTitle, + description: String(format: configuration.messageDescription, publishedDate), + hint: configuration.hint + ) + + statusView.update(progress: 0, progressTitle: configuration.placeholderProgressTitle, progressDescription: nil) + + view.addSubview(statusView) + view.pinSubviewToAllEdges(statusView) + } + + @objc private func doneTapped() { + primaryButtonTapped() + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Coordinators/JetpackBackupStatusCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Coordinators/JetpackBackupStatusCoordinator.swift new file mode 100644 index 000000000000..2390d90a045a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Coordinators/JetpackBackupStatusCoordinator.swift @@ -0,0 +1,110 @@ +import Foundation + +protocol JetpackBackupStatusView { + func render(_ backup: JetpackBackup) + func showBackupStatusUpdateFailed() + func showBackupComplete(_ backup: JetpackBackup) +} + +class JetpackBackupStatusCoordinator { + + // MARK: - Properties + + private let service: JetpackBackupService + private let site: JetpackSiteRef + private let downloadID: Int + private let view: JetpackBackupStatusView + + private var isLoading: Bool = false + private var timer: Timer? + private var retryCount: Int = 0 + + // MARK: - Init + + init(site: JetpackSiteRef, + downloadID: Int, + view: JetpackBackupStatusView, + service: JetpackBackupService? = nil, + coreDataStack: CoreDataStack = ContextManager.sharedInstance()) { + self.service = service ?? JetpackBackupService(coreDataStack: coreDataStack) + self.site = site + self.downloadID = downloadID + self.view = view + } + + // MARK: - Public + + func viewDidLoad() { + startPolling(for: downloadID) + } + + func viewWillDisappear() { + stopPolling() + } + + // MARK: - Private + + private func startPolling(for downloadID: Int) { + guard timer == nil else { + return + } + + timer = Timer.scheduledTimer(withTimeInterval: Constants.pollingInterval, repeats: true) { [weak self] _ in + self?.refreshBackupStatus(downloadID: downloadID) + } + } + + private func stopPolling() { + timer?.invalidate() + timer = nil + } + + private func refreshBackupStatus(downloadID: Int) { + guard !isLoading else { + return + } + + isLoading = true + + service.getBackupStatus(for: self.site, downloadID: downloadID, success: { [weak self] backup in + guard let self = self else { + return + } + + self.isLoading = false + + // If a backup url exists, then we've finished creating a downloadable backup. + if backup.url != nil { + self.view.showBackupComplete(backup) + return + } + + self.view.render(backup) + + }, failure: { [weak self] error in + DDLogError("Error fetching backup object: \(error.localizedDescription)") + + guard let self = self else { + return + } + + self.isLoading = false + + guard self.retryCount >= Constants.maxRetryCount else { + self.retryCount += 1 + return + } + + self.stopPolling() + self.view.showBackupStatusUpdateFailed() + }) + } +} + +extension JetpackBackupStatusCoordinator { + + private enum Constants { + static let pollingInterval: TimeInterval = 3 + static let maxRetryCount: Int = 3 + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Coordinators/JetpackRestoreStatusCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Coordinators/JetpackRestoreStatusCoordinator.swift new file mode 100644 index 000000000000..9d396c261d58 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Coordinators/JetpackRestoreStatusCoordinator.swift @@ -0,0 +1,99 @@ +import Foundation + +protocol JetpackRestoreStatusView { + func render(_ rewindStatus: RewindStatus) + func showRestoreStatusUpdateFailed() + func showRestoreFailed() + func showRestoreComplete() +} + +class JetpackRestoreStatusCoordinator { + + // MARK: - Properties + + private let service: JetpackRestoreService + private let site: JetpackSiteRef + private let view: JetpackRestoreStatusView + + private var timer: Timer? + private var retryCount: Int = 0 + + // MARK: - Init + + init(site: JetpackSiteRef, + view: JetpackRestoreStatusView, + service: JetpackRestoreService? = nil, + coreDataStack: CoreDataStack = ContextManager.shared) { + self.service = service ?? JetpackRestoreService(coreDataStack: coreDataStack) + self.site = site + self.view = view + } + + // MARK: - Public + + func viewDidLoad() { + startPolling() + } + + func viewWillDisappear() { + stopPolling() + } + + // MARK: - Private + + private func startPolling() { + guard timer == nil else { + return + } + + timer = Timer.scheduledTimer(withTimeInterval: Constants.pollingInterval, repeats: true) { [weak self] _ in + self?.refreshRestoreStatus() + } + } + + private func stopPolling() { + timer?.invalidate() + timer = nil + } + + private func refreshRestoreStatus() { + service.getRewindStatus(for: self.site, success: { [weak self] rewindStatus in + guard let self = self, let restoreStatus = rewindStatus.restore else { + return + } + + switch restoreStatus.status { + case .running, .queued: + self.view.render(rewindStatus) + case .finished: + self.view.showRestoreComplete() + case .fail: + self.view.showRestoreFailed() + } + + }, failure: { [weak self] error in + DDLogError("Error fetching rewind status object: \(error.localizedDescription)") + + guard let self = self else { + return + } + + if self.retryCount == Constants.maxRetryCount { + self.stopPolling() + self.view.showRestoreStatusUpdateFailed() + return + } + + self.retryCount += 1 + }) + } + +} + +extension JetpackRestoreStatusCoordinator { + + private enum Constants { + static let pollingInterval: TimeInterval = 5 + static let maxRetryCount: Int = 3 + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/JetpackBackupStatusViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/JetpackBackupStatusViewController.swift new file mode 100644 index 000000000000..b9acc803da55 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/JetpackBackupStatusViewController.swift @@ -0,0 +1,91 @@ +import UIKit +import CocoaLumberjack +import WordPressShared +import WordPressUI + +protocol JetpackBackupStatusViewControllerDelegate: AnyObject { + func didFinishViewing() +} + +class JetpackBackupStatusViewController: BaseRestoreStatusViewController { + + // MARK: - Properties + + weak var delegate: JetpackBackupStatusViewControllerDelegate? + + // MARK: - Pivate Properties + + private let downloadID: Int + + private lazy var coordinator: JetpackBackupStatusCoordinator = { + return JetpackBackupStatusCoordinator(site: self.site, + downloadID: self.downloadID, + view: self) + }() + + // MARK: - Initialization + + init(site: JetpackSiteRef, activity: Activity, downloadID: Int) { + self.downloadID = downloadID + + let restoreStatusConfiguration = JetpackRestoreStatusConfiguration( + title: NSLocalizedString("Backup", comment: "Title for Jetpack Backup Status screen"), + iconImage: .gridicon(.history), + messageTitle: NSLocalizedString("Currently creating a downloadable backup of your site", comment: "Title for the Jetpack Backup Status message."), + messageDescription: NSLocalizedString("We're creating a downloadable backup of your site from %1$@.", comment: "Description for the Jetpack Backup Status message. %1$@ is a placeholder for the selected date."), + hint: NSLocalizedString("No need to wait around. We'll notify you when your backup is ready.", comment: "A hint to users about creating a downloadable backup of their site."), + primaryButtonTitle: NSLocalizedString("Let me know when finished!", comment: "Title for the button that will dismiss this view."), + placeholderProgressTitle: nil, + progressDescription: nil + ) + + super.init(site: site, activity: activity, configuration: restoreStatusConfiguration) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + coordinator.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + coordinator.viewWillDisappear() + delegate?.didFinishViewing() + } + + // MARK: - Override + + override func primaryButtonTapped() { + dismiss(animated: true, completion: { [weak self] in + self?.delegate?.didFinishViewing() + }) + WPAnalytics.track(.backupNotifiyMeButtonTapped) + } +} + +extension JetpackBackupStatusViewController: JetpackBackupStatusView { + + func render(_ backup: JetpackBackup) { + guard let progress = backup.progress else { + return + } + + statusView.update(progress: progress) + } + + func showBackupStatusUpdateFailed() { + let statusFailedVC = JetpackBackupStatusFailedViewController() + self.navigationController?.pushViewController(statusFailedVC, animated: true) + } + + func showBackupComplete(_ backup: JetpackBackup) { + let completeVC = JetpackBackupCompleteViewController(site: site, activity: activity, backup: backup) + self.navigationController?.pushViewController(completeVC, animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/JetpackRestoreStatusViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/JetpackRestoreStatusViewController.swift new file mode 100644 index 000000000000..9bc2ce51701c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/JetpackRestoreStatusViewController.swift @@ -0,0 +1,95 @@ +import UIKit +import CocoaLumberjack +import WordPressShared +import WordPressUI + +protocol JetpackRestoreStatusViewControllerDelegate: AnyObject { + func didFinishViewing(_ controller: JetpackRestoreStatusViewController) +} + +class JetpackRestoreStatusViewController: BaseRestoreStatusViewController { + + // MARK: - Properties + + weak var delegate: JetpackRestoreStatusViewControllerDelegate? + + // MARK: - Private Properties + + private lazy var coordinator: JetpackRestoreStatusCoordinator = { + return JetpackRestoreStatusCoordinator(site: self.site, view: self) + }() + + + // MARK: - Initialization + + override init(site: JetpackSiteRef, activity: Activity) { + let restoreStatusConfiguration = JetpackRestoreStatusConfiguration( + title: NSLocalizedString("Restore", comment: "Title for Jetpack Restore Status screen"), + iconImage: .gridicon(.history), + messageTitle: NSLocalizedString("Currently restoring site", comment: "Title for the Jetpack Restore Status message."), + messageDescription: NSLocalizedString("We're restoring your site back to %1$@.", comment: "Description for the Jetpack Restore Status message. %1$@ is a placeholder for the selected date."), + hint: NSLocalizedString("No need to wait around. We'll notify you when your site has been fully restored.", comment: "A hint to users about restoring their site."), + primaryButtonTitle: NSLocalizedString("Let me know when finished!", comment: "Title for the button that will dismiss this view."), + placeholderProgressTitle: NSLocalizedString("Initializing the restore process", comment: "Placeholder for the restore progress title."), + progressDescription: NSLocalizedString("Currently restoring: %1$@", comment: "Description of the current entry being restored. %1$@ is a placeholder for the specific entry being restored.") + ) + super.init(site: site, activity: activity, configuration: restoreStatusConfiguration) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + coordinator.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + coordinator.viewWillDisappear() + } + + // MARK: - Override + + override func primaryButtonTapped() { + delegate?.didFinishViewing(self) + WPAnalytics.track(.restoreNotifiyMeButtonTapped) + } +} + +extension JetpackRestoreStatusViewController: JetpackRestoreStatusView { + + func render(_ rewindStatus: RewindStatus) { + guard let progress = rewindStatus.restore?.progress else { + return + } + + var progressDescription: String? + if let progressDescriptionFormat = configuration.progressDescription, + let currentEntry = rewindStatus.restore?.currentEntry { + progressDescription = String(format: progressDescriptionFormat, currentEntry) + } + + statusView.update(progress: progress, + progressTitle: rewindStatus.restore?.message, + progressDescription: progressDescription) + } + + func showRestoreStatusUpdateFailed() { + let statusFailedVC = JetpackRestoreStatusFailedViewController() + self.navigationController?.pushViewController(statusFailedVC, animated: true) + } + + func showRestoreFailed() { + let failedVC = JetpackRestoreFailedViewController(site: site, activity: activity) + self.navigationController?.pushViewController(failedVC, animated: true) + } + + func showRestoreComplete() { + let completeVC = JetpackRestoreCompleteViewController(site: site, activity: activity) + self.navigationController?.pushViewController(completeVC, animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Views/RestoreStatusView.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Views/RestoreStatusView.swift new file mode 100644 index 000000000000..6b9fba383f2b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Views/RestoreStatusView.swift @@ -0,0 +1,97 @@ +import Foundation +import Gridicons +import WordPressUI + +class RestoreStatusView: UIView, NibLoadable { + + // MARK: - Properties + + @IBOutlet private weak var icon: UIImageView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var descriptionLabel: UILabel! + @IBOutlet private weak var progressTitleLabel: UILabel! + @IBOutlet private weak var progressValueLabel: UILabel! + @IBOutlet private weak var progressView: UIProgressView! + @IBOutlet private weak var progressDescriptionLabel: UILabel! + @IBOutlet private weak var hintLabel: UILabel! + + // MARK: - Initialization + + override func awakeFromNib() { + super.awakeFromNib() + applyStyles() + } + + // MARK: - Styling + + private func applyStyles() { + backgroundColor = .basicBackground + + icon.tintColor = .success + + titleLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + titleLabel.textColor = .text + titleLabel.numberOfLines = 0 + + descriptionLabel.font = WPStyleGuide.fontForTextStyle(.body) + descriptionLabel.textColor = .textSubtle + descriptionLabel.numberOfLines = 0 + + progressValueLabel.font = WPStyleGuide.fontForTextStyle(.body) + progressValueLabel.textColor = .text + + progressTitleLabel.font = WPStyleGuide.fontForTextStyle(.body) + progressTitleLabel.textColor = .text + if effectiveUserInterfaceLayoutDirection == .leftToRight { + // swiftlint:disable:next inverse_text_alignment + progressTitleLabel.textAlignment = .right + } else { + // swiftlint:disable:next natural_text_alignment + progressTitleLabel.textAlignment = .left + } + + progressView.layer.cornerRadius = Constants.progressViewCornerRadius + progressView.clipsToBounds = true + + progressDescriptionLabel.font = WPStyleGuide.fontForTextStyle(.subheadline) + progressDescriptionLabel.textColor = .textSubtle + + hintLabel.font = WPStyleGuide.fontForTextStyle(.subheadline) + hintLabel.textColor = .textSubtle + hintLabel.numberOfLines = 0 + } + + // MARK: - Configuration + + func configure(iconImage: UIImage, title: String, description: String, hint: String) { + icon.image = iconImage + titleLabel.text = title + descriptionLabel.text = description + hintLabel.text = hint + } + + func update(progress: Int, progressTitle: String? = nil, progressDescription: String? = nil) { + + progressValueLabel.text = "\(progress)%" + progressView.progress = Float(progress) / 100 + + if let progressTitle = progressTitle { + progressTitleLabel.text = progressTitle + progressTitleLabel.isHidden = false + } else { + progressTitleLabel.isHidden = true + } + + if let progressDescription = progressDescription { + progressDescriptionLabel.text = progressDescription + progressDescriptionLabel.isHidden = false + } else { + progressDescriptionLabel.isHidden = true + } + } + + // MARK: - IBAction + private enum Constants { + static let progressViewCornerRadius: CGFloat = 4 + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Views/RestoreStatusView.xib b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Views/RestoreStatusView.xib new file mode 100644 index 000000000000..c321c91d2d50 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Status/Views/RestoreStatusView.xib @@ -0,0 +1,115 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="iN0-l3-epB" customClass="RestoreStatusView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="400"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Zbf-a2-qml"> + <rect key="frame" x="16" y="24" width="32" height="32"/> + <constraints> + <constraint firstAttribute="width" secondItem="Zbf-a2-qml" secondAttribute="height" id="xhB-kE-Ayz"/> + <constraint firstAttribute="width" constant="32" id="zq7-pS-AZv"/> + </constraints> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Q7a-aL-FAq"> + <rect key="frame" x="16" y="64" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="EU1-QH-BCU"> + <rect key="frame" x="16" y="88.5" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="Qit-9h-kwB"> + <rect key="frame" x="16" y="133" width="382" height="65"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" spacing="24" translatesAutoresizingMaskIntoConstraints="NO" id="mwm-EE-zeC"> + <rect key="frame" x="0.0" y="0.0" width="382" height="20.5"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="HwS-sP-YxX"> + <rect key="frame" x="0.0" y="0.0" width="41.5" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="BtU-j2-Om5"> + <rect key="frame" x="65.5" y="0.0" width="316.5" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + <progressView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="750" progress="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="M1m-lM-aaN"> + <rect key="frame" x="0.0" y="28.5" width="382" height="8"/> + <constraints> + <constraint firstAttribute="height" constant="8" id="QwX-Xy-ihg"/> + </constraints> + </progressView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="V9F-s0-Tu4"> + <rect key="frame" x="0.0" y="44.5" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rvV-Do-FdP"> + <rect key="frame" x="16" y="230" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="Qit-9h-kwB" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="BgX-pz-NzM"/> + <constraint firstItem="Zbf-a2-qml" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="E6I-XW-B4q"/> + <constraint firstItem="EU1-QH-BCU" firstAttribute="centerX" secondItem="vUN-kp-3ea" secondAttribute="centerX" id="Egi-bC-BEI"/> + <constraint firstItem="rvV-Do-FdP" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="GUX-PU-Nl8"/> + <constraint firstItem="Q7a-aL-FAq" firstAttribute="centerX" secondItem="vUN-kp-3ea" secondAttribute="centerX" id="HBk-MF-c0Q"/> + <constraint firstItem="Q7a-aL-FAq" firstAttribute="top" secondItem="Zbf-a2-qml" secondAttribute="bottom" constant="8" id="QzA-kg-v3A"/> + <constraint firstItem="Qit-9h-kwB" firstAttribute="top" secondItem="EU1-QH-BCU" secondAttribute="bottom" constant="24" id="RlB-1f-7TW"/> + <constraint firstItem="EU1-QH-BCU" firstAttribute="top" secondItem="Q7a-aL-FAq" secondAttribute="bottom" constant="4" id="Sut-hg-iNf"/> + <constraint firstItem="Qit-9h-kwB" firstAttribute="centerX" secondItem="vUN-kp-3ea" secondAttribute="centerX" id="TZd-8T-tTk"/> + <constraint firstItem="rvV-Do-FdP" firstAttribute="centerX" secondItem="vUN-kp-3ea" secondAttribute="centerX" id="XMw-pV-Ghf"/> + <constraint firstItem="EU1-QH-BCU" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="dAL-PC-Gen"/> + <constraint firstItem="Zbf-a2-qml" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" constant="24" id="lDf-7L-QK2"/> + <constraint firstItem="rvV-Do-FdP" firstAttribute="top" secondItem="Qit-9h-kwB" secondAttribute="bottom" constant="32" id="rZ7-kB-oAY"/> + <constraint firstItem="Q7a-aL-FAq" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="vR9-NA-xhO"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="descriptionLabel" destination="EU1-QH-BCU" id="D3v-9H-Gsa"/> + <outlet property="hintLabel" destination="rvV-Do-FdP" id="uos-pD-3KW"/> + <outlet property="icon" destination="Zbf-a2-qml" id="6eT-n5-AER"/> + <outlet property="progressDescriptionLabel" destination="V9F-s0-Tu4" id="LAS-wy-Gbm"/> + <outlet property="progressTitleLabel" destination="BtU-j2-Om5" id="iQo-mC-OMS"/> + <outlet property="progressValueLabel" destination="HwS-sP-YxX" id="hZq-vg-wC1"/> + <outlet property="progressView" destination="M1m-lM-aaN" id="4mg-wq-x6m"/> + <outlet property="titleLabel" destination="Q7a-aL-FAq" id="TPC-5L-ZvC"/> + </connections> + <point key="canvasLocation" x="-16" y="110"/> + </view> + </objects> + <resources> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/Coordinators/JetpackRestoreWarningCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/Coordinators/JetpackRestoreWarningCoordinator.swift new file mode 100644 index 000000000000..a59db1936cbe --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/Coordinators/JetpackRestoreWarningCoordinator.swift @@ -0,0 +1,58 @@ +import Foundation + +protocol JetpackRestoreWarningView { + func showNoInternetConnection() + func showRestoreAlreadyRunning() + func showRestoreRequestFailed() + func showRestoreStarted() +} + +class JetpackRestoreWarningCoordinator { + + // MARK: - Properties + + private let service: JetpackRestoreService + private let site: JetpackSiteRef + private let rewindID: String? + private let restoreTypes: JetpackRestoreTypes + private let view: JetpackRestoreWarningView + + // MARK: - Init + + init(site: JetpackSiteRef, + restoreTypes: JetpackRestoreTypes, + rewindID: String?, + view: JetpackRestoreWarningView, + service: JetpackRestoreService? = nil, + coreDataStack: CoreDataStack = ContextManager.shared) { + self.service = service ?? JetpackRestoreService(coreDataStack: coreDataStack) + self.site = site + self.rewindID = rewindID + self.restoreTypes = restoreTypes + self.view = view + } + + // MARK: - Public + + func restoreSite() { + guard ReachabilityUtils.isInternetReachable() else { + self.view.showNoInternetConnection() + return + } + + service.restoreSite(site, rewindID: rewindID, restoreTypes: restoreTypes, success: { [weak self] _, jobID in + + if jobID == 0 { + self?.view.showRestoreAlreadyRunning() + return + } + + self?.view.showRestoreStarted() + + }, failure: { [weak self] error in + DDLogError("Error restoring site: \(error.localizedDescription)") + + self?.view.showRestoreRequestFailed() + }) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift new file mode 100644 index 000000000000..bf5b97459668 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/JetpackRestoreWarningViewController.swift @@ -0,0 +1,117 @@ +import UIKit +import CocoaLumberjack +import WordPressFlux +import WordPressShared + +class JetpackRestoreWarningViewController: UIViewController { + + // MARK: - Properties + + weak var restoreStatusDelegate: JetpackRestoreStatusViewControllerDelegate? + + // MARK: - Private Properties + + private lazy var coordinator: JetpackRestoreWarningCoordinator = { + return JetpackRestoreWarningCoordinator(site: self.site, + restoreTypes: self.restoreTypes, + rewindID: self.activity.rewindID, + view: self) + }() + + // MARK: - Private Properties + + private let site: JetpackSiteRef + private let activity: Activity + private let restoreTypes: JetpackRestoreTypes + + private lazy var dateFormatter: DateFormatter = { + return ActivityDateFormatting.mediumDateFormatterWithTime(for: site) + }() + + // MARK: - Initialization + + init(site: JetpackSiteRef, + activity: Activity, + restoreTypes: JetpackRestoreTypes) { + self.site = site + self.activity = activity + self.restoreTypes = restoreTypes + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + title = NSLocalizedString("Warning", comment: "Title for Jetpack Restore Warning screen") + configureWarningView() + } + + // MARK: - Configure + + private func configureWarningView() { + let warningView = RestoreWarningView.loadFromNib() + let publishedDate = dateFormatter.string(from: activity.published) + warningView.configure(with: publishedDate) + + warningView.confirmHandler = { [weak self] in + guard let self = self else { + return + } + + WPAnalytics.track(.restoreConfirmed, properties: ["restore_types": [ + "themes": self.restoreTypes.themes, + "plugins": self.restoreTypes.plugins, + "uploads": self.restoreTypes.uploads, + "sqls": self.restoreTypes.sqls, + "roots": self.restoreTypes.roots, + "contents": self.restoreTypes.contents + ]]) + + self.coordinator.restoreSite() + } + + warningView.cancelHandler = { [weak self] in + self?.dismiss(animated: true) + } + + warningView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(warningView) + view.pinSubviewToAllEdges(warningView) + } + +} + +extension JetpackRestoreWarningViewController: JetpackRestoreWarningView { + + func showNoInternetConnection() { + ReachabilityUtils.showAlertNoInternetConnection() + WPAnalytics.track(.restoreError, properties: ["cause": "offline"]) + } + + func showRestoreAlreadyRunning() { + let title = NSLocalizedString("There's a restore currently in progress, please wait before starting the next one", comment: "Text displayed when user tries to start a restore when there is already one running") + let notice = Notice(title: title) + ActionDispatcher.dispatch(NoticeAction.post(notice)) + WPAnalytics.track(.restoreError, properties: ["cause": "other"]) + } + + func showRestoreRequestFailed() { + let errorTitle = NSLocalizedString("Restore failed", comment: "Title for error displayed when restoring a site fails.") + let errorMessage = NSLocalizedString("We couldn't restore your site. Please try again later.", comment: "Message for error displayed when restoring a site fails.") + let notice = Notice(title: errorTitle, message: errorMessage) + ActionDispatcher.dispatch(NoticeAction.post(notice)) + WPAnalytics.track(.restoreError, properties: ["cause": "remote"]) + } + + func showRestoreStarted() { + let statusVC = JetpackRestoreStatusViewController(site: site, + activity: activity) + statusVC.delegate = restoreStatusDelegate + self.navigationController?.pushViewController(statusVC, animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/Views/RestoreWarningView.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/Views/RestoreWarningView.swift new file mode 100644 index 000000000000..997907457d79 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/Views/RestoreWarningView.swift @@ -0,0 +1,70 @@ +import Foundation +import Gridicons +import WordPressUI + +class RestoreWarningView: UIView, NibLoadable { + + // MARK: - Properties + + @IBOutlet private weak var icon: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var descriptionLabel: UILabel! + @IBOutlet private weak var confirmButton: FancyButton! + @IBOutlet private weak var cancelButton: FancyButton! + + var confirmHandler: (() -> Void)? + var cancelHandler: (() -> Void)? + + // MARK: - Initialization + + override func awakeFromNib() { + super.awakeFromNib() + applyStyles() + } + + // MARK: - Styling + + private func applyStyles() { + backgroundColor = .basicBackground + + icon.tintColor = .error + + titleLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + titleLabel.textColor = .text + + descriptionLabel.font = WPStyleGuide.fontForTextStyle(.body) + descriptionLabel.textColor = .text + descriptionLabel.numberOfLines = 0 + + confirmButton.isPrimary = true + + cancelButton.isPrimary = false + } + + // MARK: - Configuration + + func configure(with publishedDate: String) { + icon.image = .gridicon(.notice) + titleLabel.text = Strings.title + descriptionLabel.text = String(format: Strings.descriptionFormat, publishedDate) + confirmButton.setTitle(Strings.confirmButtonTitle, for: .normal) + cancelButton.setTitle(Strings.cancelButtonTitle, for: .normal) + } + + // MARK: - IBAction + + @IBAction private func confirmButtonTapped(_ sender: Any) { + confirmHandler?() + } + + @IBAction private func cancelButtonTapped(_ sender: Any) { + cancelHandler?() + } + + private enum Strings { + static let title = NSLocalizedString("Warning", comment: "Noun. Title for Jetpack Restore warning.") + static let descriptionFormat = NSLocalizedString("Are you sure you want to restore your site back to %1$@? This will remove content and options created or changed since then.", comment: "Description for the confirm restore action. %1$@ is a placeholder for the selected date.") + static let confirmButtonTitle = NSLocalizedString("Confirm", comment: "Verb. Title for Jetpack Restore confirm button.") + static let cancelButtonTitle = NSLocalizedString("Cancel", comment: "Verb. Title for Jetpack Restore cancel button.") + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/Views/RestoreWarningView.xib b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/Views/RestoreWarningView.xib new file mode 100644 index 000000000000..6dd1e7412eb2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Restore/Restore Warning/Views/RestoreWarningView.xib @@ -0,0 +1,95 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17505"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="ZqQ-sO-2aH" customClass="RestoreWarningView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="300"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="HDs-Xm-1E7"> + <rect key="frame" x="16" y="24" width="32" height="32"/> + <constraints> + <constraint firstAttribute="width" constant="32" id="2DD-LR-cMX"/> + <constraint firstAttribute="width" secondItem="HDs-Xm-1E7" secondAttribute="height" id="L2O-KB-45i"/> + </constraints> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Xzv-RX-M1d"> + <rect key="frame" x="16" y="64" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="EHP-Zk-FQR"> + <rect key="frame" x="16" y="88.5" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="3lx-s4-WO5"> + <rect key="frame" x="16" y="133" width="382" height="104"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="kdA-Zi-bHr" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="0.0" width="382" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="IDx-VK-mCB"/> + </constraints> + <state key="normal" title="Button"/> + <connections> + <action selector="confirmButtonTapped:" destination="ZqQ-sO-2aH" eventType="touchUpInside" id="8jb-1i-R6Z"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="9ga-Hc-Cyk" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="60" width="382" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="304-IK-GSL"/> + </constraints> + <state key="normal" title="Button"/> + <connections> + <action selector="cancelButtonTapped:" destination="ZqQ-sO-2aH" eventType="touchUpInside" id="rMH-2Q-VIW"/> + </connections> + </button> + </subviews> + </stackView> + </subviews> + <viewLayoutGuide key="safeArea" id="Wc3-vw-3AF"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="Xzv-RX-M1d" firstAttribute="top" secondItem="HDs-Xm-1E7" secondAttribute="bottom" constant="8" id="7Yh-9u-yUf"/> + <constraint firstItem="3lx-s4-WO5" firstAttribute="leading" secondItem="HDs-Xm-1E7" secondAttribute="leading" id="BKP-q6-Gfg"/> + <constraint firstItem="3lx-s4-WO5" firstAttribute="top" secondItem="EHP-Zk-FQR" secondAttribute="bottom" constant="24" id="CTq-CU-aC5"/> + <constraint firstItem="HDs-Xm-1E7" firstAttribute="leading" secondItem="Wc3-vw-3AF" secondAttribute="leading" constant="16" id="ElR-MJ-DQZ"/> + <constraint firstItem="EHP-Zk-FQR" firstAttribute="centerX" secondItem="Wc3-vw-3AF" secondAttribute="centerX" id="FbP-Ff-GQP"/> + <constraint firstItem="Xzv-RX-M1d" firstAttribute="leading" secondItem="HDs-Xm-1E7" secondAttribute="leading" id="QRi-IH-z2j"/> + <constraint firstItem="3lx-s4-WO5" firstAttribute="centerX" secondItem="Wc3-vw-3AF" secondAttribute="centerX" id="Qbz-iX-gih"/> + <constraint firstItem="EHP-Zk-FQR" firstAttribute="top" secondItem="Xzv-RX-M1d" secondAttribute="bottom" constant="4" id="SQ1-Qt-2Jj"/> + <constraint firstItem="Xzv-RX-M1d" firstAttribute="centerX" secondItem="Wc3-vw-3AF" secondAttribute="centerX" id="cZk-1s-YIn"/> + <constraint firstItem="HDs-Xm-1E7" firstAttribute="top" secondItem="ZqQ-sO-2aH" secondAttribute="top" constant="24" id="cwU-fN-VR4"/> + <constraint firstItem="EHP-Zk-FQR" firstAttribute="leading" secondItem="HDs-Xm-1E7" secondAttribute="leading" id="fE4-bh-EOu"/> + <constraint firstItem="Wc3-vw-3AF" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="3lx-s4-WO5" secondAttribute="bottom" constant="24" id="mRN-j3-WFJ"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="cancelButton" destination="9ga-Hc-Cyk" id="wPA-30-GH5"/> + <outlet property="confirmButton" destination="kdA-Zi-bHr" id="AHu-Q6-GjN"/> + <outlet property="descriptionLabel" destination="EHP-Zk-FQR" id="1la-9s-vkS"/> + <outlet property="icon" destination="HDs-Xm-1E7" id="VuX-co-wO6"/> + <outlet property="titleLabel" destination="Xzv-RX-M1d" id="gTa-o0-hV7"/> + </connections> + <point key="canvasLocation" x="-1520" y="-7"/> + </view> + </objects> + <resources> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanCoordinator.swift new file mode 100644 index 000000000000..98947066f16f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanCoordinator.swift @@ -0,0 +1,408 @@ +import Foundation + +protocol JetpackScanView { + func render() + + func showLoading() + func showNoConnectionError() + func showGenericError() + func showScanStartError() + func showMultisiteNotSupportedError() + func vaultPressActiveOnSite() + + func toggleHistoryButton(_ isEnabled: Bool) + + func presentAlert(_ alert: UIAlertController) + func presentNotice(with title: String, message: String?) + + func showIgnoreThreatSuccess(for threat: JetpackScanThreat) + func showIgnoreThreatError(for threat: JetpackScanThreat) + + func showJetpackSettings(with siteID: Int) +} + +class JetpackScanCoordinator { + private let service: JetpackScanService + private let view: JetpackScanView + + private(set) var scan: JetpackScan? { + didSet { + configureSections() + scanDidChange(from: oldValue, to: scan) + } + } + + var hasValidCredentials: Bool { + return scan?.hasValidCredentials ?? false + } + + let blog: Blog + + /// Returns the threats if we're in the idle state + var threats: [JetpackScanThreat]? { + let returnThreats: [JetpackScanThreat]? + + if scan?.state == .fixingThreats { + returnThreats = scan?.threatFixStatus?.compactMap { $0.threat } ?? nil + } else { + returnThreats = scan?.state == .idle ? scan?.threats : nil + } + + // Sort the threats by date then by threat ID + return returnThreats?.sorted(by: { + if $0.firstDetected != $1.firstDetected { + return $0.firstDetected > $1.firstDetected + } + + return $0.id > $1.id + }) + } + + var sections: [JetpackThreatSection]? + + private var actionButtonState: ErrorButtonAction? + + init(blog: Blog, + view: JetpackScanView, + service: JetpackScanService? = nil, + coreDataStack: CoreDataStack = ContextManager.shared) { + + self.service = service ?? JetpackScanService(coreDataStack: coreDataStack) + self.blog = blog + self.view = view + } + + public func viewDidLoad() { + view.showLoading() + + refreshData() + } + + public func refreshData() { + service.getScanWithFixableThreatsStatus(for: blog) { [weak self] scanObj in + self?.refreshDidSucceed(with: scanObj) + + } failure: { [weak self] error in + DDLogError("Error fetching scan object: \(String(describing: error?.localizedDescription))") + + self?.refreshDidFail(with: error) + } + } + + public func viewWillDisappear() { + stopPolling() + } + + public func startScan() { + // Optimistically trigger the scanning state + scan?.state = .scanning + + // Refresh the view's scan state + view.render() + + // Since we've locally entered the scanning state, start polling + // but don't trigger a refresh immediately after calling because the + // server doesn't update its state immediately after starting a scan + startPolling(triggerImmediately: false) + + service.startScan(for: blog) { [weak self] (success) in + if success == false { + DDLogError("Error starting scan: Scan response returned false") + + WPAnalytics.track(.jetpackScanError, properties: ["action": "scan", + "cause": "scan response returned false"]) + + self?.stopPolling() + self?.view.showScanStartError() + } + } failure: { [weak self] (error) in + DDLogError("Error starting scan: \(String(describing: error?.localizedDescription))") + + WPAnalytics.track(.jetpackScanError, properties: ["action": "scan", + "cause": error?.localizedDescription ?? "remote"]) + + self?.refreshDidFail(with: error) + } + } + + // MARK: - Public Actions + public func presentFixAllAlert() { + let threatCount = scan?.fixableThreats?.count ?? 0 + + let title: String + let message: String + + if threatCount == 1 { + title = Strings.fixAllSingleAlertTitle + message = Strings.fixAllSingleAlertMessage + } else { + title = String(format: Strings.fixAllAlertTitleFormat, threatCount) + message = Strings.fixAllAlertTitleMessage + } + + let controller = UIAlertController(title: title, message: message, preferredStyle: .alert) + + controller.addAction(UIAlertAction(title: Strings.fixAllAlertCancelButtonTitle, style: .cancel, handler: nil)) + controller.addAction(UIAlertAction(title: Strings.fixAllAlertConfirmButtonTitle, style: .default, handler: { [weak self] _ in + WPAnalytics.track(.jetpackScanAllthreatsFixTapped, properties: ["threats_fixed": threatCount]) + + self?.fixAllThreats() + })) + + view.presentAlert(controller) + } + + private func fixThreats(threats: [JetpackScanThreat]) { + // If there are no fixable threats just reload the state since it may be out of date + guard threats.count > 0 else { + refreshData() + return + } + + // Optimistically trigger the fixing state + // and map all the fixable threats to in progress threats + scan?.state = .fixingThreats + scan?.threatFixStatus = threats.compactMap { + var threatCopy = $0 + threatCopy.status = .fixing + return JetpackThreatFixStatus(with: threatCopy) + } + + // Refresh the view to show the new scan state + view.render() + + startPolling(triggerImmediately: false) + + service.fixThreats(threats, blog: blog) { [weak self] (response) in + if response.success == false { + DDLogError("Error starting scan: Scan response returned false") + self?.stopPolling() + self?.view.showScanStartError() + } else { + self?.refreshData() + } + } failure: { [weak self] (error) in + DDLogError("Error fixing threats: \(String(describing: error.localizedDescription))") + + self?.refreshDidFail(with: error) + } + } + + public func fixAllThreats() { + let fixableThreats = threats?.filter { $0.fixable != nil } ?? [] + fixThreats(threats: fixableThreats) + } + + public func fixThreat(threat: JetpackScanThreat) { + fixThreats(threats: [threat]) + } + + public func ignoreThreat(threat: JetpackScanThreat) { + service.ignoreThreat(threat, blog: blog, success: { [weak self] in + self?.view.showIgnoreThreatSuccess(for: threat) + }, failure: { [weak self] error in + DDLogError("Error ignoring threat: \(error.localizedDescription)") + + WPAnalytics.track(.jetpackScanError, properties: ["action": "ignore", + "cause": error.localizedDescription]) + + self?.view.showIgnoreThreatError(for: threat) + }) + } + + public func openSupport() { + let supportVC = SupportTableViewController() + supportVC.showFromTabBar() + } + + public func openJetpackSettings() { + guard let siteID = blog.dotComID as? Int else { + view.presentNotice(with: Strings.jetpackSettingsNotice.title, message: nil) + return + } + view.showJetpackSettings(with: siteID) + } + + public func noResultsButtonPressed() { + guard let action = actionButtonState else { + return + } + + switch action { + case .contactSupport: + openSupport() + case .tryAgain: + refreshData() + } + } + + private func configureSections() { + guard let threats = self.threats, let siteRef = JetpackSiteRef(blog: self.blog) else { + sections = nil + return + } + + guard scan?.state == .fixingThreats else { + sections = JetpackScanThreatSectionGrouping(threats: threats, siteRef: siteRef).sections + + return + } + + sections = [JetpackThreatSection(title: nil, date: Date(), threats: threats)] + } + + // MARK: - Private: Network Handlers + private func refreshDidSucceed(with scanObj: JetpackScan) { + scan = scanObj + + switch (scanObj.state, scanObj.reason) { + case (.unavailable, JetpackScan.Reason.multiSiteNotSupported): + view.showMultisiteNotSupportedError() + case (.unavailable, JetpackScan.Reason.vaultPressActiveOnSite): + view.vaultPressActiveOnSite() + default: + view.render() + } + + view.toggleHistoryButton(scan?.isEnabled ?? false) + + togglePolling() + } + + private func refreshDidFail(with error: Error? = nil) { + let appDelegate = WordPressAppDelegate.shared + + guard + let connectionAvailable = appDelegate?.connectionAvailable, connectionAvailable == true + else { + view.showNoConnectionError() + actionButtonState = .tryAgain + + return + } + + view.showGenericError() + actionButtonState = .contactSupport + } + + private func scanDidChange(from: JetpackScan?, to: JetpackScan?) { + let fromState = from?.state ?? .unknown + let toState = to?.state ?? .unknown + + // Trigger scan finished alert + guard fromState == .scanning, toState == .idle else { + return + } + + let threatCount = threats?.count ?? 0 + + let message: String + + switch threatCount { + case 0: + message = Strings.scanNotice.message + + case 1: + message = Strings.scanNotice.messageSingleThreatFound + + default: + message = String(format: Strings.scanNotice.messageThreatsFound, threatCount) + } + + view.presentNotice(with: Strings.scanNotice.title, message: message) + } + + // MARK: - Private: Refresh Timer + private var refreshTimer: Timer? + + /// Starts or stops the refresh timer based on the status of the scan + private func togglePolling() { + switch scan?.state { + case .provisioning, .scanning, .fixingThreats: + startPolling() + default: + stopPolling() + } + } + + private func stopPolling() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + private func startPolling(triggerImmediately: Bool = true) { + guard refreshTimer == nil else { + return + } + + refreshTimer = Timer.scheduledTimer(withTimeInterval: Constants.refreshTimerInterval, repeats: true, block: { [weak self] (_) in + self?.refreshData() + }) + + // Immediately trigger the refresh if needed + guard triggerImmediately else { + return + } + + refreshData() + } + + private struct Constants { + static let refreshTimerInterval: TimeInterval = 5 + } + + private struct Strings { + struct scanNotice { + static let title = NSLocalizedString("Scan Finished", comment: "Title for a notice informing the user their scan has completed") + static let message = NSLocalizedString("No threats found", comment: "Message for a notice informing the user their scan completed and no threats were found") + static let messageThreatsFound = NSLocalizedString("%d potential threats found", comment: "Message for a notice informing the user their scan completed and %d threats were found") + static let messageSingleThreatFound = NSLocalizedString("1 potential threat found", comment: "Message for a notice informing the user their scan completed and 1 threat was found") + } + + struct jetpackSettingsNotice { + static let title = NSLocalizedString("Unable to visit Jetpack settings for site", comment: "Message displayed when visiting the Jetpack settings page fails.") + } + + static let fixAllAlertTitleFormat = NSLocalizedString("Please confirm you want to fix all %1$d active threats", comment: "Confirmation title presented before fixing all the threats, displays the number of threats to be fixed") + static let fixAllSingleAlertTitle = NSLocalizedString("Please confirm you want to fix this threat", comment: "Confirmation title presented before fixing a single threat") + static let fixAllAlertTitleMessage = NSLocalizedString("Jetpack will be fixing all the detected active threats.", comment: "Confirmation message presented before fixing all the threats, displays the number of threats to be fixed") + static let fixAllSingleAlertMessage = NSLocalizedString("Jetpack will be fixing the detected active threat.", comment: "Confirmation message presented before fixing a single threat") + + static let fixAllAlertCancelButtonTitle = NSLocalizedString("Cancel", comment: "Button title, cancel fixing all threats") + static let fixAllAlertConfirmButtonTitle = NSLocalizedString("Fix all threats", comment: "Button title, confirm fixing all threats") + } + + private enum ErrorButtonAction { + case contactSupport + case tryAgain + } +} + +extension JetpackScan { + var hasValidCredentials: Bool { + return credentials?.first?.stillValid ?? false + } + + var hasFixableThreats: Bool { + let count = fixableThreats?.count ?? 0 + return count > 0 + } + + var fixableThreats: [JetpackScanThreat]? { + return threats?.filter { $0.fixable != nil } + } +} + +extension JetpackScan { + struct Reason { + static let multiSiteNotSupported = "multisite_not_supported" + static let vaultPressActiveOnSite = "vp_active_on_site" + } +} + +/// Represents a sorted section of threats +struct JetpackThreatSection { + let title: String? + let date: Date + let threats: [JetpackScanThreat] +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanHistoryCoordinator.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanHistoryCoordinator.swift new file mode 100644 index 000000000000..38773608ea22 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanHistoryCoordinator.swift @@ -0,0 +1,212 @@ +import Foundation + +class JetpackScanHistoryCoordinator { + private let service: JetpackScanService + private let view: JetpackScanHistoryView + + private(set) var model: JetpackScanHistory? + + let blog: Blog + + // Filtering + var activeFilter: Filter = .all + let filterItems = [Filter.all, .fixed, .ignored] + + private var actionButtonState: ErrorButtonAction? + private var isLoading: Bool? + + private var threats: [JetpackScanThreat]? { + guard let threats = model?.threats else { + return nil + } + + switch activeFilter { + case .all: + return threats + case .fixed: + return threats.filter { $0.status == .fixed } + case .ignored: + return threats.filter { $0.status == .ignored } + } + } + + var sections: [JetpackThreatSection]? + + init(blog: Blog, + view: JetpackScanHistoryView, + service: JetpackScanService? = nil, + coreDataStack: CoreDataStack = ContextManager.shared) { + + self.service = service ?? JetpackScanService(coreDataStack: coreDataStack) + self.blog = blog + self.view = view + } + + // MARK: - Public Methods + public func viewDidLoad() { + view.showLoading() + + refreshData() + } + + public func refreshData() { + isLoading = true + service.getHistory(for: blog) { [weak self] scanObj in + self?.refreshDidSucceed(with: scanObj) + } failure: { [weak self] error in + DDLogError("Error fetching scan object: \(String(describing: error.localizedDescription))") + + WPAnalytics.track(.jetpackScanError, properties: ["action": "fetch_scan_history", + "cause": error.localizedDescription]) + self?.refreshDidFail(with: error) + } + } + + public func noResultsButtonPressed() { + guard let action = actionButtonState else { + return + } + + switch action { + case .contactSupport: + openSupport() + case .tryAgain: + refreshData() + } + } + + private func openSupport() { + let supportVC = SupportTableViewController() + supportVC.showFromTabBar() + } + + // MARK: - Private: Handling + private func refreshDidSucceed(with model: JetpackScanHistory) { + isLoading = false + self.model = model + + threatsDidChange() + } + + private func refreshDidFail(with error: Error? = nil) { + isLoading = false + + let appDelegate = WordPressAppDelegate.shared + + guard + let connectionAvailable = appDelegate?.connectionAvailable, connectionAvailable == true + else { + view.showNoConnectionError() + actionButtonState = .tryAgain + + return + } + + view.showGenericError() + actionButtonState = .contactSupport + } + + private func threatsDidChange() { + guard let threatCount = threats?.count, threatCount > 0 else { + sections = nil + + switch activeFilter { + case .all: + view.showNoHistory() + case .fixed: + view.showNoFixedThreats() + case .ignored: + view.showNoIgnoredThreats() + } + return + } + + groupThreats() + view.render() + } + + private func groupThreats() { + guard let threats = self.threats, + let siteRef = JetpackSiteRef(blog: self.blog) + else { + sections = nil + return + } + + self.sections = JetpackScanThreatSectionGrouping(threats: threats, siteRef: siteRef).sections + } + + // MARK: - Filters + func changeFilter(_ filter: Filter) { + // Don't do anything if we're already on the filter + guard activeFilter != filter else { + return + } + + activeFilter = filter + + // Don't refresh UI if we're still loading + guard isLoading == false else { + return + } + + threatsDidChange() + } + + enum Filter: Int, FilterTabBarItem { + case all = 0 + case fixed = 1 + case ignored = 2 + + var title: String { + switch self { + case .all: + return NSLocalizedString("All", comment: "Displays all of the historical threats") + case .fixed: + return NSLocalizedString("Fixed", comment: "Displays the fixed threats") + case .ignored: + return NSLocalizedString("Ignored", comment: "Displays the ignored threats") + } + } + + var accessibilityIdentifier: String { + switch self { + case .all: + return "filter_toolbar_all" + case .fixed: + return "filter_toolbar_fixed" + case .ignored: + return "filter_toolbar_ignored" + } + } + + var eventProperty: String { + switch self { + case .all: + return "" + case .fixed: + return "fixed" + case .ignored: + return "ignored" + } + } + } + + private enum ErrorButtonAction { + case contactSupport + case tryAgain + } +} + +protocol JetpackScanHistoryView { + func render() + + func showLoading() + + // Errors + func showNoHistory() + func showNoFixedThreats() + func showNoIgnoredThreats() + func showNoConnectionError() + func showGenericError() +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanHistoryViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanHistoryViewController.swift new file mode 100644 index 000000000000..7fa1d1b2b0f3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanHistoryViewController.swift @@ -0,0 +1,292 @@ +import UIKit + +class JetpackScanHistoryViewController: UIViewController { + private let blog: Blog + + lazy var coordinator: JetpackScanHistoryCoordinator = { + return JetpackScanHistoryCoordinator(blog: blog, view: self) + }() + + @IBOutlet weak var filterTabBar: FilterTabBar! + + // Table View + @IBOutlet weak var tableView: UITableView! + let refreshControl = UIRefreshControl() + + // Loading / Errors + private var noResultsViewController: NoResultsViewController? + + // MARK: - Initializers + @objc init(blog: Blog) { + self.blog = blog + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Methods + override func viewDidLoad() { + super.viewDidLoad() + + self.title = NSLocalizedString("Scan History", comment: "Title of the view") + + configureTableView() + configureFilterTabBar() + coordinator.viewDidLoad() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + WPAnalytics.track(.jetpackScanHistoryAccessed) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate(alongsideTransition: { _ in + self.tableView.beginUpdates() + self.tableView.endUpdates() + }) + } + + // MARK: - Actions + @objc func selectedFilterDidChange(_ filterBar: FilterTabBar) { + let selectedIndex = filterTabBar.selectedIndex + guard let filter = JetpackScanHistoryCoordinator.Filter(rawValue: selectedIndex) else { + return + } + + coordinator.changeFilter(filter) + + WPAnalytics.track(.jetpackScanHistoryFilter, properties: ["filter": filter.eventProperty]) + } + + // MARK: - Private: Config + private func configureFilterTabBar() { + WPStyleGuide.configureFilterTabBar(filterTabBar) + filterTabBar.superview?.backgroundColor = .filterBarBackground + + filterTabBar.tabSizingStyle = .equalWidths + filterTabBar.items = coordinator.filterItems + filterTabBar.addTarget(self, action: #selector(selectedFilterDidChange(_:)), for: .valueChanged) + } + + private func configureTableView() { + tableView.register(JetpackScanThreatCell.defaultNib, forCellReuseIdentifier: Constants.threatCellIdentifier) + + tableView.register(ActivityListSectionHeaderView.defaultNib, + forHeaderFooterViewReuseIdentifier: ActivityListSectionHeaderView.identifier) + + tableView.tableFooterView = UIView() + + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(userRefresh), for: .valueChanged) + } + + @objc func userRefresh() { + coordinator.refreshData() + } + + // MARK: - Private: Config + private struct Constants { + static let threatCellIdentifier = "ThreatHistoryCell" + } +} + +extension JetpackScanHistoryViewController: JetpackScanHistoryView { + func render() { + updateNoResults(nil) + + refreshControl.endRefreshing() + tableView.reloadData() + } + + func showLoading() { + let model = NoResultsViewController.Model(title: NoResultsText.loading.title, + accessoryView: NoResultsViewController.loadingAccessoryView()) + updateNoResults(model) + } + + func showGenericError() { + let model = NoResultsViewController.Model(title: NoResultsText.error.title, + subtitle: NoResultsText.error.subtitle, + buttonText: NoResultsText.error.buttonText) + + updateNoResults(model) + } + + func showNoConnectionError() { + let model = NoResultsViewController.Model(title: NoResultsText.noConnection.title, + subtitle: NoResultsText.noConnection.subtitle, + buttonText: NoResultsText.tryAgainButtonText) + + updateNoResults(model) + } + + func showNoHistory() { + let model = NoResultsViewController.Model(title: NoResultsText.noHistory.title) + updateNoResults(model) + } + + func showNoIgnoredThreats() { + let model = NoResultsViewController.Model(title: NoResultsText.noIgnoredThreats.title, + subtitle: NoResultsText.noIgnoredThreats.subtitle) + + updateNoResults(model) + } + + func showNoFixedThreats() { + let model = NoResultsViewController.Model(title: NoResultsText.noFixedThreats.title, + subtitle: NoResultsText.noFixedThreats.subtitle) + + updateNoResults(model) + } +} +// MARK: - Table View +extension JetpackScanHistoryViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { + return coordinator.sections?.count ?? 0 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard let historySection = coordinator.sections?[section] else { + return 0 + } + + return historySection.threats.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + var cell: UITableViewCell + + let threatCell = tableView.dequeueReusableCell(withIdentifier: Constants.threatCellIdentifier) as? JetpackScanThreatCell ?? JetpackScanThreatCell(style: .default, reuseIdentifier: Constants.threatCellIdentifier) + + if let threat = threat(for: indexPath) { + configureThreatCell(cell: threatCell, threat: threat) + } + + cell = threatCell + + return cell + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let historySection = coordinator.sections?[section], + let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: ActivityListSectionHeaderView.identifier) as? ActivityListSectionHeaderView else { + return UIView(frame: .zero) + } + + cell.titleLabel.text = historySection.title + + return cell + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return ActivityListSectionHeaderView.height + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + guard let threat = threat(for: indexPath) else { + return + } + + let threatDetailsVC = JetpackScanThreatDetailsViewController(blog: blog, threat: threat) + self.navigationController?.pushViewController(threatDetailsVC, animated: true) + + WPAnalytics.track(.jetpackScanThreatListItemTapped, properties: ["threat_signature": threat.signature, "section": "history"]) + + } + + private func configureThreatCell(cell: JetpackScanThreatCell, threat: JetpackScanThreat) { + let model = JetpackScanThreatViewModel(threat: threat) + cell.configure(with: model) + } + + private func threat(for indexPath: IndexPath) -> JetpackScanThreat? { + guard let section = coordinator.sections?[indexPath.section] else { + return nil + } + + return section.threats[indexPath.row] + } +} + +// MARK: - Loading / Errors +extension JetpackScanHistoryViewController: NoResultsViewControllerDelegate { + func updateNoResults(_ viewModel: NoResultsViewController.Model?) { + if let noResultsViewModel = viewModel { + showNoResults(noResultsViewModel) + } else { + noResultsViewController?.view.isHidden = true + } + + tableView.reloadData() + } + + private func showNoResults(_ viewModel: NoResultsViewController.Model) { + if noResultsViewController == nil { + noResultsViewController = NoResultsViewController.controller() + noResultsViewController?.delegate = self + + guard let noResultsViewController = noResultsViewController else { + return + } + + if noResultsViewController.view.superview != tableView { + tableView.addSubview(withFadeAnimation: noResultsViewController.view) + } + + addChild(noResultsViewController) + + noResultsViewController.view.translatesAutoresizingMaskIntoConstraints = false + } + + noResultsViewController?.bindViewModel(viewModel) + noResultsViewController?.didMove(toParent: self) + tableView.pinSubviewToSafeArea(noResultsViewController!.view) + noResultsViewController?.view.isHidden = false + } + + func actionButtonPressed() { + coordinator.noResultsButtonPressed() + } + + private struct NoResultsText { + struct loading { + static let title = NSLocalizedString("Loading Scan History...", comment: "Text displayed while loading the scan history for a site") + } + + struct noHistory { + static let title = NSLocalizedString("No history yet", comment: "Title for the view when there aren't any history items to display") + } + + struct error { + static let title = NSLocalizedString("Oops", comment: "Title for the view when there's an error loading Activity Log") + static let subtitle = NSLocalizedString("There was an error loading the scan history", comment: "Text displayed when there is a failure loading the history feed") + static let buttonText = NSLocalizedString("Contact support", comment: "Button label for contacting support") + } + + struct noConnection { + static let title = NSLocalizedString("No connection", comment: "Title for the error view when there's no connection") + static let subtitle = NSLocalizedString("An active internet connection is required to view the history", comment: "Error message shown when trying to view the Scan History feature and there is no internet connection.") + } + + struct noFixedThreats { + static let title = NSLocalizedString("No fixed threats", comment: "Title for the view when there aren't any fixed threats to display") + static let subtitle = NSLocalizedString("So far, there are no fixed threats on your site.", comment: "Text display in the view when there aren't any Activities Types to display in the Activity Log Types picker") + } + + struct noIgnoredThreats { + static let title = NSLocalizedString("No ignored threats", comment: "Title for the view when there aren't any ignored threats to display") + static let subtitle = NSLocalizedString("So far, there are no ignored threats on your site.", comment: "Text display in the view when there aren't any Activities Types to display in the Activity Log Types picker") + } + + static let tryAgainButtonText = NSLocalizedString("Try again", comment: "Button label for trying to retrieve the history again") + } +} + +extension ActivityListSectionHeaderView: NibLoadable { } diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanHistoryViewController.xib b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanHistoryViewController.xib new file mode 100644 index 000000000000..316ddc3acae3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanHistoryViewController.xib @@ -0,0 +1,69 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="JetpackScanHistoryViewController" customModule="WordPress" customModuleProvider="target"> + <connections> + <outlet property="filterTabBar" destination="KMc-An-tq8" id="xZq-gg-wy1"/> + <outlet property="tableView" destination="BpW-wq-UHw" id="Pbl-Xs-Odk"/> + <outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="200" sectionHeaderHeight="-1" estimatedSectionHeaderHeight="-1" sectionFooterHeight="-1" estimatedSectionFooterHeight="-1" translatesAutoresizingMaskIntoConstraints="NO" id="BpW-wq-UHw"> + <rect key="frame" x="0.0" y="90" width="414" height="772"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <connections> + <outlet property="dataSource" destination="-1" id="RB8-lG-jMi"/> + <outlet property="delegate" destination="-1" id="4BX-Me-DA4"/> + </connections> + </tableView> + <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="49F-8n-f06" userLabel="Filters View"> + <rect key="frame" x="0.0" y="44" width="414" height="46"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="KMc-An-tq8" customClass="FilterTabBar" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="46"/> + <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </view> + </subviews> + <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstItem="KMc-An-tq8" firstAttribute="top" secondItem="49F-8n-f06" secondAttribute="top" id="DO5-55-Nhr"/> + <constraint firstAttribute="trailing" secondItem="KMc-An-tq8" secondAttribute="trailing" priority="999" id="SO6-jc-9bb"/> + <constraint firstAttribute="height" constant="46" id="eMJ-mf-u4z"/> + <constraint firstItem="KMc-An-tq8" firstAttribute="leading" secondItem="49F-8n-f06" secondAttribute="leading" priority="999" id="jTs-cu-YlP"/> + <constraint firstAttribute="bottom" secondItem="KMc-An-tq8" secondAttribute="bottom" id="vSb-2b-9Ii"/> + </constraints> + </view> + </subviews> + <viewLayoutGuide key="safeArea" id="Q5M-cg-NOt"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="Q5M-cg-NOt" firstAttribute="trailing" secondItem="BpW-wq-UHw" secondAttribute="trailing" id="8cK-P3-nEg"/> + <constraint firstItem="49F-8n-f06" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" id="NcO-k6-puo"/> + <constraint firstItem="Q5M-cg-NOt" firstAttribute="trailing" secondItem="49F-8n-f06" secondAttribute="trailing" id="XpG-c3-bvz"/> + <constraint firstItem="49F-8n-f06" firstAttribute="top" secondItem="Q5M-cg-NOt" secondAttribute="top" id="jlC-rL-GYK"/> + <constraint firstItem="BpW-wq-UHw" firstAttribute="leading" secondItem="Q5M-cg-NOt" secondAttribute="leading" id="o9A-DJ-hFI"/> + <constraint firstItem="Q5M-cg-NOt" firstAttribute="bottom" secondItem="BpW-wq-UHw" secondAttribute="bottom" id="pbj-UL-GjY"/> + <constraint firstItem="BpW-wq-UHw" firstAttribute="top" secondItem="49F-8n-f06" secondAttribute="bottom" id="vBW-Yn-4cl"/> + </constraints> + <point key="canvasLocation" x="-133.33333333333334" y="146.65178571428569"/> + </view> + </objects> + <resources> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanStatusCell.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanStatusCell.swift new file mode 100644 index 000000000000..2f585a67dbbd --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanStatusCell.swift @@ -0,0 +1,120 @@ +import UIKit + +class JetpackScanStatusCell: UITableViewCell, NibReusable { + @IBOutlet weak var iconContainerView: UIView! + @IBOutlet weak var iconImageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var descriptionLabel: UILabel! + @IBOutlet weak var primaryButton: FancyButton! + @IBOutlet weak var secondaryButton: FancyButton! + @IBOutlet weak var warningButton: MultilineButton! + @IBOutlet weak var progressView: UIProgressView! + + private var model: JetpackScanStatusViewModel? + + override func awakeFromNib() { + super.awakeFromNib() + + primaryButton.isHidden = true + secondaryButton.isHidden = true + configureProgressView() + } + + public func configure(with model: JetpackScanStatusViewModel) { + self.model = model + + iconImageView.image = UIImage(named: model.imageName) + titleLabel.text = model.title + descriptionLabel.text = model.description + + configurePrimaryButton(model) + configureSecondaryButton(model) + configureWarningButton(model) + configureProgressView(model) + } + + private func configurePrimaryButton(_ model: JetpackScanStatusViewModel) { + guard let primaryTitle = model.primaryButtonTitle else { + primaryButton.isHidden = true + return + } + + primaryButton.setTitle(primaryTitle, for: .normal) + primaryButton.isEnabled = model.primaryButtonEnabled + primaryButton.isHidden = false + } + + private func configureSecondaryButton(_ model: JetpackScanStatusViewModel) { + guard let secondaryTitle = model.secondaryButtonTitle else { + secondaryButton.isHidden = true + return + } + + secondaryButton.setTitle(secondaryTitle, for: .normal) + secondaryButton.isHidden = false + } + + private func configureWarningButton(_ model: JetpackScanStatusViewModel) { + guard let warningButtonTitle = model.warningButtonTitle else { + warningButton.isHidden = true + return + } + + let attributedTitle = WPStyleGuide.Jetpack.highlightString(warningButtonTitle.substring, + inString: warningButtonTitle.string) + + warningButton.setAttributedTitle(attributedTitle, for: .normal) + warningButton.setImage(.gridicon(.plusSmall), for: .normal) + warningButton.setTitleColor(.text, for: .normal) + warningButton.titleLabel?.numberOfLines = 0 + warningButton.titleLabel?.lineBreakMode = .byWordWrapping + + warningButton.isHidden = false + } + + private func configureProgressView(_ model: JetpackScanStatusViewModel) { + guard let progress = model.progress else { + progressView.isHidden = true + return + } + + progressView.isHidden = false + progressView.setProgress(progress, animated: true) + } + + // MARK: - IBAction's + @IBAction func primaryButtonTapped(_ sender: Any) { + guard let viewModel = model else { + return + } + + viewModel.primaryButtonTapped(sender) + } + + @IBAction func secondaryButtonTapped(_ sender: Any) { + guard let viewModel = model else { + return + } + + viewModel.secondaryButtonTapped(sender) + } + + @IBAction func warningButtonTapped(_ sender: Any) { + guard let viewModel = model else { + return + } + + viewModel.warningButtonTapped(sender) + } + + // MARK: - Private: View Configuration + private func configureProgressView() { + progressView.isHidden = true + + progressView.layer.cornerRadius = 4 + progressView.clipsToBounds = true + + let color = UIColor.muriel(color: .jetpackGreen, .shade50) + progressView.tintColor = color + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanStatusCell.xib b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanStatusCell.xib new file mode 100644 index 000000000000..e10262e5aa07 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanStatusCell.xib @@ -0,0 +1,133 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" insetsLayoutMarginsFromSafeArea="NO" selectionStyle="none" indentationWidth="10" focusStyle="custom" id="KGk-i7-Jjw" customClass="JetpackScanStatusCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="445" height="300"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM"> + <rect key="frame" x="0.0" y="0.0" width="445" height="300"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" axis="vertical" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="VTB-TG-U9C"> + <rect key="frame" x="0.0" y="0.0" width="445" height="300"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="i3y-0r-qdg" userLabel="Header View"> + <rect key="frame" x="10" y="10" width="425" height="48"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="jetpack-scan-state-progress" translatesAutoresizingMaskIntoConstraints="NO" id="6Dq-eg-0YB"> + <rect key="frame" x="0.0" y="2" width="36" height="44"/> + </imageView> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="6Dq-eg-0YB" firstAttribute="centerY" secondItem="i3y-0r-qdg" secondAttribute="centerY" id="M4I-5P-Mua"/> + <constraint firstItem="6Dq-eg-0YB" firstAttribute="leading" secondItem="i3y-0r-qdg" secondAttribute="leading" id="MAz-OV-10H"/> + <constraint firstAttribute="height" constant="48" id="NvK-uw-EMq"/> + </constraints> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Loading..." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="KGG-nP-4Q3"> + <rect key="frame" x="10" y="68" width="425" height="20.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <progressView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="3IH-nw-vyL"> + <rect key="frame" x="10" y="98.5" width="425" height="8"/> + <constraints> + <constraint firstAttribute="height" constant="8" id="ffh-oY-vRs"/> + </constraints> + </progressView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="749" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="B0Y-0s-mES"> + <rect key="frame" x="10" y="116.5" width="425" height="21.5"/> + <string key="text">We will send you an email if security threats are found. In the meantime feel free to continue to use your site as normal, you can check back on progress at any time.</string> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="hhh-D4-rZn"> + <rect key="frame" x="10" y="148" width="425" height="142"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="1000" layoutMarginsFollowReadableWidth="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="UhM-1P-HcV" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="0.0" width="425" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="Ha4-Re-jtu"/> + </constraints> + <directionalEdgeInsets key="directionalLayoutMargins" top="8" leading="20" bottom="8" trailing="20"/> + <state key="normal" title="Fix All"> + <color key="titleColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </state> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="YES"/> + </userDefinedRuntimeAttributes> + <connections> + <action selector="primaryButtonTapped:" destination="KGk-i7-Jjw" eventType="touchUpInside" id="bK2-6E-P6z"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="leading" contentVerticalAlignment="top" buttonType="system" lineBreakMode="wordWrap" translatesAutoresizingMaskIntoConstraints="NO" id="BEu-Yz-3GU" customClass="MultilineButton" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="54" width="425" height="34"/> + <inset key="contentEdgeInsets" minX="-4" minY="0.0" maxX="0.0" maxY="10"/> + <inset key="titleEdgeInsets" minX="2" minY="2" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Button" image="plus-small"/> + <connections> + <action selector="warningButtonTapped:" destination="KGk-i7-Jjw" eventType="touchUpInside" id="KSU-5Y-L9w"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="1000" verticalCompressionResistancePriority="749" layoutMarginsFollowReadableWidth="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="gTh-o0-42f" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="98" width="425" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="tnl-xQ-FTS"/> + </constraints> + <directionalEdgeInsets key="directionalLayoutMargins" top="8" leading="20" bottom="8" trailing="20"/> + <state key="normal" title="Scan Again"> + <color key="titleColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </state> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="NO"/> + </userDefinedRuntimeAttributes> + <connections> + <action selector="secondaryButtonTapped:" destination="KGk-i7-Jjw" eventType="touchUpInside" id="f7N-nO-b4k"/> + </connections> + </button> + </subviews> + </stackView> + </subviews> + <edgeInsets key="layoutMargins" top="10" left="10" bottom="10" right="10"/> + </stackView> + </subviews> + <constraints> + <constraint firstItem="VTB-TG-U9C" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" id="TCO-Xm-kR6"/> + <constraint firstItem="VTB-TG-U9C" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" id="TW5-LQ-P4L"/> + <constraint firstAttribute="bottom" secondItem="VTB-TG-U9C" secondAttribute="bottom" id="fGK-mF-eqi"/> + <constraint firstAttribute="trailing" secondItem="VTB-TG-U9C" secondAttribute="trailing" id="vsP-zd-JTi"/> + </constraints> + </tableViewCellContentView> + <connections> + <outlet property="descriptionLabel" destination="B0Y-0s-mES" id="Xjm-eu-MWa"/> + <outlet property="iconContainerView" destination="i3y-0r-qdg" id="a8h-6D-MT1"/> + <outlet property="iconImageView" destination="6Dq-eg-0YB" id="Yi8-cN-DY3"/> + <outlet property="primaryButton" destination="UhM-1P-HcV" id="SZs-Xy-hpD"/> + <outlet property="progressView" destination="3IH-nw-vyL" id="BOZ-G9-iFl"/> + <outlet property="secondaryButton" destination="gTh-o0-42f" id="nUe-7r-qNn"/> + <outlet property="titleLabel" destination="KGG-nP-4Q3" id="ci3-ID-vFr"/> + <outlet property="warningButton" destination="BEu-Yz-3GU" id="pOo-M1-Euh"/> + </connections> + <point key="canvasLocation" x="-292" y="104"/> + </tableViewCell> + </objects> + <resources> + <image name="jetpack-scan-state-progress" width="36" height="44"/> + <image name="plus-small" width="24" height="24"/> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatCell.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatCell.swift new file mode 100644 index 000000000000..25a985e305a9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatCell.swift @@ -0,0 +1,40 @@ +import UIKit + +class JetpackScanThreatCell: UITableViewCell, NibReusable { + @IBOutlet weak var iconBackgroundImageView: UIImageView! + @IBOutlet weak var iconImageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var detailLabel: UILabel! + @IBOutlet weak var activityIndicator: UIActivityIndicatorView! + + func configure(with model: JetpackScanThreatViewModel) { + applyStyles() + + iconBackgroundImageView.backgroundColor = model.iconImageColor + iconImageView.image = model.iconImage + titleLabel.text = model.title + + detailLabel.text = model.description ?? "" + detailLabel.isHidden = model.description == nil + + iconImageView.isHidden = model.isFixing + iconBackgroundImageView.isHidden = model.isFixing + + selectionStyle = model.isFixing ? .none : .default + accessoryType = model.isFixing ? .none : .disclosureIndicator + + if model.isFixing { + activityIndicator.startAnimating() + } else { + activityIndicator.stopAnimating() + } + } + + private func applyStyles() { + titleLabel.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .medium) + titleLabel.textColor = .text + + detailLabel.textColor = .textSubtle + detailLabel.font = WPStyleGuide.fontForTextStyle(.subheadline) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatCell.xib b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatCell.xib new file mode 100644 index 000000000000..e18e4adc902b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatCell.xib @@ -0,0 +1,82 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" rowHeight="76" id="dOQ-a3-TAQ" customClass="JetpackScanThreatCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="447" height="76"/> + <autoresizingMask key="autoresizingMask"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="dOQ-a3-TAQ" id="VqL-rg-lUd"> + <rect key="frame" x="0.0" y="0.0" width="416" height="76"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Xwk-8V-RnB" userLabel="Icon Color Image" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="16" y="19" width="38" height="38"/> + <constraints> + <constraint firstAttribute="width" constant="38" id="eA9-MV-PUz"/> + <constraint firstAttribute="height" constant="38" id="hFv-UU-ZHg"/> + </constraints> + </imageView> + <imageView userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="6FZ-37-5Ra" userLabel="Icon Image View"> + <rect key="frame" x="23" y="26" width="24" height="24"/> + <constraints> + <constraint firstAttribute="width" constant="24" id="bUU-9d-QIs"/> + <constraint firstAttribute="height" constant="24" id="nHD-U8-Yz1"/> + </constraints> + </imageView> + <stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="1000" verticalCompressionResistancePriority="1000" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="tN7-BN-vA3"> + <rect key="frame" x="64" y="10" width="342" height="56"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="750" verticalHuggingPriority="750" text="The file EXAMPLE.php contains a malicious code pattern" textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Ln2-7E-dac"> + <rect key="frame" x="0.0" y="0.0" width="342" height="42.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <color key="textColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="750" verticalHuggingPriority="750" verticalCompressionResistancePriority="749" text="The file EXAMPLE.php contains a malicious code pattern" textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="SUt-dt-Wuj"> + <rect key="frame" x="0.0" y="44.5" width="342" height="11.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <color key="textColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="40" id="4k3-TD-NZ4"/> + </constraints> + </stackView> + <activityIndicatorView hidden="YES" opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" style="medium" translatesAutoresizingMaskIntoConstraints="NO" id="vaM-RG-gON"> + <rect key="frame" x="25" y="28" width="20" height="20"/> + </activityIndicatorView> + </subviews> + <constraints> + <constraint firstItem="6FZ-37-5Ra" firstAttribute="centerX" secondItem="Xwk-8V-RnB" secondAttribute="centerX" id="4mJ-oa-RdS"/> + <constraint firstAttribute="bottom" secondItem="tN7-BN-vA3" secondAttribute="bottom" constant="10" id="7AR-Aw-mz8"/> + <constraint firstItem="Xwk-8V-RnB" firstAttribute="centerY" secondItem="tN7-BN-vA3" secondAttribute="centerY" id="9Pl-cR-Jh8"/> + <constraint firstAttribute="trailing" secondItem="tN7-BN-vA3" secondAttribute="trailing" constant="10" id="EZS-oh-Y4X"/> + <constraint firstItem="vaM-RG-gON" firstAttribute="centerY" secondItem="Xwk-8V-RnB" secondAttribute="centerY" id="FAw-O2-YxC"/> + <constraint firstItem="tN7-BN-vA3" firstAttribute="leading" secondItem="Xwk-8V-RnB" secondAttribute="trailing" constant="10" id="OxF-tf-6WS"/> + <constraint firstItem="6FZ-37-5Ra" firstAttribute="centerY" secondItem="Xwk-8V-RnB" secondAttribute="centerY" id="WML-DP-ykT"/> + <constraint firstItem="tN7-BN-vA3" firstAttribute="top" secondItem="VqL-rg-lUd" secondAttribute="top" constant="10" id="brR-tY-mGP"/> + <constraint firstItem="Xwk-8V-RnB" firstAttribute="leading" secondItem="VqL-rg-lUd" secondAttribute="leading" constant="16" id="uKd-B8-GmI"/> + <constraint firstItem="vaM-RG-gON" firstAttribute="centerX" secondItem="Xwk-8V-RnB" secondAttribute="centerX" id="zWw-pf-OFc"/> + </constraints> + <edgeInsets key="layoutMargins" top="8" left="8" bottom="8" right="8"/> + </tableViewCellContentView> + <inset key="separatorInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/> + <connections> + <outlet property="activityIndicator" destination="vaM-RG-gON" id="jnj-mu-MeZ"/> + <outlet property="detailLabel" destination="SUt-dt-Wuj" id="m82-tV-rYf"/> + <outlet property="iconBackgroundImageView" destination="Xwk-8V-RnB" id="1B0-cm-GXk"/> + <outlet property="iconImageView" destination="6FZ-37-5Ra" id="HaR-XU-Gx3"/> + <outlet property="titleLabel" destination="Ln2-7E-dac" id="VQ2-hY-wf4"/> + </connections> + <point key="canvasLocation" x="-1163" y="14"/> + </tableViewCell> + </objects> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatDetailsViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatDetailsViewController.swift new file mode 100644 index 000000000000..662969bb9131 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatDetailsViewController.swift @@ -0,0 +1,280 @@ +import UIKit +import WordPressFlux + +protocol JetpackScanThreatDetailsViewControllerDelegate: AnyObject { + func willFixThreat(_ threat: JetpackScanThreat, controller: JetpackScanThreatDetailsViewController) + func willIgnoreThreat(_ threat: JetpackScanThreat, controller: JetpackScanThreatDetailsViewController) +} + +class JetpackScanThreatDetailsViewController: UIViewController { + + // MARK: - IBOutlets + + /// General info + @IBOutlet private weak var generalInfoStackView: UIStackView! + @IBOutlet private weak var icon: UIImageView! + @IBOutlet private weak var generalInfoTitleLabel: UILabel! + @IBOutlet private weak var generalInfoDescriptionLabel: UILabel! + + /// Problem + @IBOutlet private weak var problemStackView: UIStackView! + @IBOutlet private weak var problemTitleLabel: UILabel! + @IBOutlet private weak var problemDescriptionLabel: UILabel! + + /// Technical details + @IBOutlet private weak var technicalDetailsStackView: UIStackView! + @IBOutlet private weak var technicalDetailsTitleLabel: UILabel! + @IBOutlet private weak var technicalDetailsDescriptionLabel: UILabel! + @IBOutlet private weak var technicalDetailsFileContainerView: UIView! + @IBOutlet private weak var technicalDetailsFileLabel: UILabel! + @IBOutlet private weak var technicalDetailsContextLabel: UILabel! + + /// Fix + @IBOutlet private weak var fixStackView: UIStackView! + @IBOutlet private weak var fixTitleLabel: UILabel! + @IBOutlet private weak var fixDescriptionLabel: UILabel! + + /// Buttons + @IBOutlet private weak var buttonsStackView: UIStackView! + @IBOutlet private weak var fixThreatButton: FancyButton! + @IBOutlet private weak var ignoreThreatButton: FancyButton! + @IBOutlet private weak var warningButton: MultilineButton! + @IBOutlet weak var ignoreActivityIndicatorView: UIActivityIndicatorView! + + // MARK: - Properties + + weak var delegate: JetpackScanThreatDetailsViewControllerDelegate? + + private let blog: Blog + private let threat: JetpackScanThreat + private let hasValidCredentials: Bool + + private lazy var viewModel: JetpackScanThreatViewModel = { + return JetpackScanThreatViewModel(threat: threat, hasValidCredentials: hasValidCredentials) + }() + + // MARK: - Init + + init(blog: Blog, threat: JetpackScanThreat, hasValidCredentials: Bool = false) { + self.blog = blog + self.threat = threat + self.hasValidCredentials = hasValidCredentials + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + title = Strings.title + configure(with: viewModel) + } + + // MARK: - IBActions + + @IBAction private func fixThreatButtonTapped(_ sender: Any) { + let alert = UIAlertController(title: viewModel.fixActionTitle, + message: viewModel.fixDescription, + preferredStyle: .alert) + + alert.addAction(UIAlertAction(title: Strings.cancel, style: .cancel)) + alert.addAction(UIAlertAction(title: Strings.ok, style: .default, handler: { [weak self] _ in + guard let self = self else { + return + } + self.delegate?.willFixThreat(self.threat, controller: self) + self.trackEvent(.jetpackScanThreatFixTapped) + })) + + present(alert, animated: true) + + trackEvent(.jetpackScanFixThreatDialogOpen) + } + + @IBAction private func ignoreThreatButtonTapped(_ sender: Any) { + guard let blogName = blog.settings?.name else { + return + } + + let alert = UIAlertController(title: viewModel.ignoreActionTitle, + message: String(format: viewModel.ignoreActionMessage, blogName), + preferredStyle: .alert) + + alert.addAction(UIAlertAction(title: Strings.cancel, style: .cancel)) + alert.addAction(UIAlertAction(title: Strings.ok, style: .default, handler: { [weak self] _ in + guard let self = self else { + return + } + + self.ignoreThreatButton.isHidden = true + self.ignoreActivityIndicatorView.startAnimating() + + self.delegate?.willIgnoreThreat(self.threat, controller: self) + self.trackEvent(.jetpackScanThreatIgnoreTapped) + })) + + present(alert, animated: true) + + trackEvent(.jetpackScanIgnoreThreatDialogOpen) + } + + @IBAction func warningButtonTapped(_ sender: Any) { + guard let siteID = blog.dotComID as? Int, + let controller = JetpackWebViewControllerFactory.settingsController(siteID: siteID) else { + displayNotice(title: Strings.jetpackSettingsNotice) + return + } + + let navVC = UINavigationController(rootViewController: controller) + present(navVC, animated: true) + } + + // MARK: - Private + + private func trackEvent(_ event: WPAnalyticsEvent) { + WPAnalytics.track(event, properties: ["threat_signature": threat.signature]) + } +} + +extension JetpackScanThreatDetailsViewController { + + // MARK: - Configure + + func configure(with viewModel: JetpackScanThreatViewModel) { + icon.image = viewModel.detailIconImage + icon.tintColor = viewModel.detailIconImageColor + + generalInfoTitleLabel.text = viewModel.title + generalInfoDescriptionLabel.text = viewModel.description + + problemTitleLabel.text = viewModel.problemTitle + problemDescriptionLabel.text = viewModel.problemDescription + + if let attributedFileContext = self.viewModel.attributedFileContext { + technicalDetailsTitleLabel.text = viewModel.technicalDetailsTitle + technicalDetailsDescriptionLabel.text = viewModel.technicalDetailsDescription + technicalDetailsFileLabel.text = viewModel.fileName + technicalDetailsContextLabel.attributedText = attributedFileContext + technicalDetailsStackView.isHidden = false + } else { + technicalDetailsStackView.isHidden = true + } + + fixTitleLabel.text = viewModel.fixTitle + fixDescriptionLabel.text = viewModel.fixDescription + + if let fixActionTitle = viewModel.fixActionTitle { + fixThreatButton.setTitle(fixActionTitle, for: .normal) + fixThreatButton.isEnabled = viewModel.fixActionEnabled + fixThreatButton.isHidden = false + } else { + fixThreatButton.isHidden = true + } + + if let ignoreActionTitle = viewModel.ignoreActionTitle { + ignoreThreatButton.setTitle(ignoreActionTitle, for: .normal) + ignoreThreatButton.isHidden = false + } else { + ignoreThreatButton.isHidden = true + } + + if let warningActionTitle = viewModel.warningActionTitle { + + let attributedTitle = WPStyleGuide.Jetpack.highlightString(warningActionTitle.substring, + inString: warningActionTitle.string) + + warningButton.setAttributedTitle(attributedTitle, for: .normal) + + warningButton.isHidden = false + + } else { + warningButton.isHidden = true + } + + applyStyles() + } + + // MARK: - Styling + + private func applyStyles() { + view.backgroundColor = .basicBackground + styleGeneralInfoSection() + styleProblemSection() + styleTechnicalDetailsSection() + styleFixSection() + styleButtons() + } + + private func styleGeneralInfoSection() { + generalInfoTitleLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + generalInfoTitleLabel.textColor = .error + generalInfoTitleLabel.numberOfLines = 0 + + generalInfoDescriptionLabel.font = WPStyleGuide.fontForTextStyle(.body) + generalInfoDescriptionLabel.textColor = .text + generalInfoDescriptionLabel.numberOfLines = 0 + } + + private func styleProblemSection() { + problemTitleLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + problemTitleLabel.textColor = .text + problemTitleLabel.numberOfLines = 0 + + problemDescriptionLabel.font = WPStyleGuide.fontForTextStyle(.body) + problemDescriptionLabel.textColor = .text + problemDescriptionLabel.numberOfLines = 0 + } + + private func styleTechnicalDetailsSection() { + technicalDetailsTitleLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + technicalDetailsTitleLabel.textColor = .text + technicalDetailsTitleLabel.numberOfLines = 0 + + technicalDetailsFileContainerView.backgroundColor = viewModel.fileNameBackgroundColor + + technicalDetailsFileLabel.font = viewModel.fileNameFont + technicalDetailsFileLabel.textColor = viewModel.fileNameColor + technicalDetailsFileLabel.numberOfLines = 0 + + technicalDetailsDescriptionLabel.font = WPStyleGuide.fontForTextStyle(.body) + technicalDetailsDescriptionLabel.textColor = .text + technicalDetailsDescriptionLabel.numberOfLines = 0 + + technicalDetailsContextLabel.numberOfLines = 0 + } + + private func styleFixSection() { + fixTitleLabel.font = WPStyleGuide.fontForTextStyle(.title3, fontWeight: .semibold) + fixTitleLabel.textColor = .text + fixTitleLabel.numberOfLines = 0 + + fixDescriptionLabel.font = WPStyleGuide.fontForTextStyle(.body) + fixDescriptionLabel.textColor = .text + fixDescriptionLabel.numberOfLines = 0 + } + + private func styleButtons() { + fixThreatButton.isPrimary = true + + ignoreThreatButton.isPrimary = false + + warningButton.setTitleColor(.text, for: .normal) + warningButton.titleLabel?.lineBreakMode = .byWordWrapping + warningButton.titleLabel?.numberOfLines = 0 + warningButton.setImage(.gridicon(.plusSmall), for: .normal) + } +} + +extension JetpackScanThreatDetailsViewController { + + private enum Strings { + static let title = NSLocalizedString("Threat details", comment: "Title for the Jetpack Scan Threat Details screen") + static let ok = NSLocalizedString("OK", comment: "OK button for alert") + static let cancel = NSLocalizedString("Cancel", comment: "Cancel button for alert") + static let jetpackSettingsNotice = NSLocalizedString("Unable to visit Jetpack settings for site", comment: "Message displayed when visiting the Jetpack settings page fails.") + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatDetailsViewController.xib b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatDetailsViewController.xib new file mode 100644 index 000000000000..e4d59204c92d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatDetailsViewController.xib @@ -0,0 +1,247 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="JetpackScanThreatDetailsViewController" customModule="WordPress" customModuleProvider="target"> + <connections> + <outlet property="buttonsStackView" destination="VuK-rQ-GXS" id="Grq-sl-ZgQ"/> + <outlet property="fixDescriptionLabel" destination="E8E-uJ-fIb" id="fER-IW-V6l"/> + <outlet property="fixStackView" destination="Loe-yJ-BrU" id="MHW-bH-8t4"/> + <outlet property="fixThreatButton" destination="lSM-Kx-kG9" id="Kkl-wN-la1"/> + <outlet property="fixTitleLabel" destination="Nha-cq-HJ2" id="LxL-Sw-eaN"/> + <outlet property="generalInfoDescriptionLabel" destination="zOA-QB-SGe" id="ZQj-cb-MkQ"/> + <outlet property="generalInfoStackView" destination="qsb-nP-xMd" id="0Bj-0p-hiz"/> + <outlet property="generalInfoTitleLabel" destination="NW5-tp-l3e" id="kbo-Nz-Ahz"/> + <outlet property="icon" destination="DG0-pO-N3I" id="hkl-TO-NeO"/> + <outlet property="ignoreActivityIndicatorView" destination="TJx-Vn-QMb" id="vKx-Vx-KzB"/> + <outlet property="ignoreThreatButton" destination="UtF-fM-vn6" id="1mF-Ng-Xuq"/> + <outlet property="problemDescriptionLabel" destination="5lm-cp-cZQ" id="NET-pY-Cv5"/> + <outlet property="problemStackView" destination="1LA-OE-cJv" id="Ch9-bH-eFe"/> + <outlet property="problemTitleLabel" destination="MX5-JO-1Ua" id="R6U-2i-iFQ"/> + <outlet property="technicalDetailsContextLabel" destination="TLX-Yf-pFt" id="43q-Ez-Yts"/> + <outlet property="technicalDetailsDescriptionLabel" destination="WOy-h0-pT7" id="Cld-BF-fT8"/> + <outlet property="technicalDetailsFileContainerView" destination="Kb4-mw-b1c" id="Ln8-TV-fkv"/> + <outlet property="technicalDetailsFileLabel" destination="6mI-Os-bNJ" id="atX-z1-PRD"/> + <outlet property="technicalDetailsStackView" destination="2UH-SE-rOp" id="XPk-GV-hIs"/> + <outlet property="technicalDetailsTitleLabel" destination="tO3-nw-cpH" id="eT7-Uc-mRF"/> + <outlet property="view" destination="Jav-0d-xYk" id="ugv-Oj-yJp"/> + <outlet property="warningButton" destination="0AT-2h-oW4" id="Fd2-zm-851"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="Jav-0d-xYk"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <subviews> + <scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" translatesAutoresizingMaskIntoConstraints="NO" id="d27-uZ-8gs"> + <rect key="frame" x="0.0" y="44" width="414" height="852"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="xhr-6F-hsq"> + <rect key="frame" x="0.0" y="0.0" width="414" height="713"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="24" translatesAutoresizingMaskIntoConstraints="NO" id="2wc-xZ-ViF"> + <rect key="frame" x="16" y="24" width="382" height="665"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="qsb-nP-xMd"> + <rect key="frame" x="0.0" y="0.0" width="382" height="89"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="DG0-pO-N3I"> + <rect key="frame" x="0.0" y="0.0" width="32" height="32"/> + <constraints> + <constraint firstAttribute="width" constant="32" id="AXw-I5-nwN"/> + <constraint firstAttribute="width" secondItem="DG0-pO-N3I" secondAttribute="height" multiplier="1:1" id="ITy-26-mpp"/> + </constraints> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="NW5-tp-l3e"> + <rect key="frame" x="0.0" y="40" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="zOA-QB-SGe"> + <rect key="frame" x="0.0" y="68.5" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <constraints> + <constraint firstAttribute="trailing" secondItem="zOA-QB-SGe" secondAttribute="trailing" id="1uk-7h-zNV"/> + <constraint firstAttribute="trailing" secondItem="NW5-tp-l3e" secondAttribute="trailing" id="WzK-GX-nbo"/> + <constraint firstItem="zOA-QB-SGe" firstAttribute="leading" secondItem="qsb-nP-xMd" secondAttribute="leading" id="il9-x4-Wfp"/> + <constraint firstItem="NW5-tp-l3e" firstAttribute="leading" secondItem="qsb-nP-xMd" secondAttribute="leading" id="pcn-vH-XR2"/> + </constraints> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="1LA-OE-cJv"> + <rect key="frame" x="0.0" y="113" width="382" height="49"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="MX5-JO-1Ua"> + <rect key="frame" x="0.0" y="0.0" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="5lm-cp-cZQ"> + <rect key="frame" x="0.0" y="28.5" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="2UH-SE-rOp"> + <rect key="frame" x="0.0" y="186" width="382" height="186"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="tO3-nw-cpH"> + <rect key="frame" x="0.0" y="0.0" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="WOy-h0-pT7"> + <rect key="frame" x="0.0" y="28.5" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Kb4-mw-b1c"> + <rect key="frame" x="0.0" y="57" width="382" height="60.5"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="6mI-Os-bNJ"> + <rect key="frame" x="12" y="12" width="358" height="36.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="6mI-Os-bNJ" firstAttribute="leading" secondItem="Kb4-mw-b1c" secondAttribute="leading" constant="12" id="FmS-3u-Zbo"/> + <constraint firstItem="6mI-Os-bNJ" firstAttribute="top" secondItem="Kb4-mw-b1c" secondAttribute="top" constant="12" id="Kmr-5b-ZPl"/> + <constraint firstItem="6mI-Os-bNJ" firstAttribute="centerX" secondItem="Kb4-mw-b1c" secondAttribute="centerX" id="N8A-Ya-bjN"/> + <constraint firstItem="6mI-Os-bNJ" firstAttribute="centerY" secondItem="Kb4-mw-b1c" secondAttribute="centerY" id="Tix-Hs-fqI"/> + </constraints> + </view> + <scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsVerticalScrollIndicator="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nSN-dv-qY1"> + <rect key="frame" x="0.0" y="125.5" width="382" height="60.5"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="TLX-Yf-pFt"> + <rect key="frame" x="0.0" y="0.0" width="41.5" height="60.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <constraints> + <constraint firstAttribute="bottom" secondItem="TLX-Yf-pFt" secondAttribute="bottom" id="J55-2E-YjO"/> + <constraint firstAttribute="trailing" secondItem="TLX-Yf-pFt" secondAttribute="trailing" id="h7V-Re-zD0"/> + <constraint firstItem="TLX-Yf-pFt" firstAttribute="top" secondItem="nSN-dv-qY1" secondAttribute="top" id="kuB-Nh-W8y"/> + <constraint firstItem="TLX-Yf-pFt" firstAttribute="height" secondItem="nSN-dv-qY1" secondAttribute="height" id="laQ-NJ-RY6"/> + <constraint firstItem="TLX-Yf-pFt" firstAttribute="leading" secondItem="nSN-dv-qY1" secondAttribute="leading" id="mRx-b5-YuU"/> + </constraints> + </scrollView> + </subviews> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="Loe-yJ-BrU"> + <rect key="frame" x="0.0" y="396" width="382" height="49"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Nha-cq-HJ2"> + <rect key="frame" x="0.0" y="0.0" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="E8E-uJ-fIb"> + <rect key="frame" x="0.0" y="28.5" width="382" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="VuK-rQ-GXS"> + <rect key="frame" x="0.0" y="469" width="382" height="196"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="lSM-Kx-kG9" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="0.0" width="382" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="HH9-aw-lY0"/> + </constraints> + <state key="normal" title="Button"/> + <connections> + <action selector="fixThreatButtonTapped:" destination="-1" eventType="touchUpInside" id="ARq-1t-2nf"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="leading" contentVerticalAlignment="top" buttonType="system" lineBreakMode="wordWrap" translatesAutoresizingMaskIntoConstraints="NO" id="0AT-2h-oW4" customClass="MultilineButton" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="54" width="382" height="34"/> + <inset key="contentEdgeInsets" minX="-4" minY="0.0" maxX="0.0" maxY="10"/> + <inset key="titleEdgeInsets" minX="2" minY="2" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Button" image="plus-small"/> + <connections> + <action selector="warningButtonTapped:" destination="-1" eventType="touchUpInside" id="rT7-WX-vvI"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="UtF-fM-vn6" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="98" width="382" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="rml-Co-TMA"/> + </constraints> + <state key="normal" title="Button"/> + <connections> + <action selector="ignoreThreatButtonTapped:" destination="-1" eventType="touchUpInside" id="hAL-WY-v7K"/> + </connections> + </button> + <activityIndicatorView hidden="YES" opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" style="medium" translatesAutoresizingMaskIntoConstraints="NO" id="TJx-Vn-QMb"> + <rect key="frame" x="0.0" y="152" width="382" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="Xpd-PH-uoL"/> + </constraints> + </activityIndicatorView> + </subviews> + </stackView> + </subviews> + </stackView> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="2wc-xZ-ViF" firstAttribute="centerY" secondItem="xhr-6F-hsq" secondAttribute="centerY" id="4cc-AD-d3I"/> + <constraint firstItem="2wc-xZ-ViF" firstAttribute="top" secondItem="xhr-6F-hsq" secondAttribute="top" constant="24" id="Fc4-tP-5OT"/> + <constraint firstItem="2wc-xZ-ViF" firstAttribute="leading" secondItem="xhr-6F-hsq" secondAttribute="leading" constant="16" id="e4z-WJ-DKa"/> + <constraint firstItem="2wc-xZ-ViF" firstAttribute="centerX" secondItem="xhr-6F-hsq" secondAttribute="centerX" id="tfO-A1-wbA"/> + </constraints> + </view> + </subviews> + <constraints> + <constraint firstItem="xhr-6F-hsq" firstAttribute="top" secondItem="d27-uZ-8gs" secondAttribute="top" id="7QB-wN-3ux"/> + <constraint firstAttribute="trailing" secondItem="xhr-6F-hsq" secondAttribute="trailing" id="8M4-QJ-2TJ"/> + <constraint firstAttribute="bottom" secondItem="xhr-6F-hsq" secondAttribute="bottom" id="RzN-Gk-HGW"/> + <constraint firstItem="xhr-6F-hsq" firstAttribute="leading" secondItem="d27-uZ-8gs" secondAttribute="leading" id="aIq-Mh-mkZ"/> + </constraints> + </scrollView> + </subviews> + <viewLayoutGuide key="safeArea" id="u2O-Y0-NU2"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="xhr-6F-hsq" firstAttribute="width" secondItem="Jav-0d-xYk" secondAttribute="width" id="0Pe-eZ-1NZ"/> + <constraint firstItem="d27-uZ-8gs" firstAttribute="leading" secondItem="Jav-0d-xYk" secondAttribute="leading" id="A8t-fj-Nwo"/> + <constraint firstAttribute="bottom" secondItem="d27-uZ-8gs" secondAttribute="bottom" id="O4q-w1-pNT"/> + <constraint firstItem="d27-uZ-8gs" firstAttribute="top" secondItem="u2O-Y0-NU2" secondAttribute="top" id="P56-Z5-cUZ"/> + <constraint firstItem="u2O-Y0-NU2" firstAttribute="trailing" secondItem="d27-uZ-8gs" secondAttribute="trailing" id="a5h-cO-Uf6"/> + </constraints> + <point key="canvasLocation" x="-38" y="-475"/> + </view> + </objects> + <resources> + <image name="plus-small" width="24" height="24"/> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatSectionGrouping.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatSectionGrouping.swift new file mode 100644 index 000000000000..872cf57683bd --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanThreatSectionGrouping.swift @@ -0,0 +1,27 @@ +import Foundation + +struct JetpackScanThreatSectionGrouping { + public var sections: [JetpackThreatSection]? + + init(threats: [JetpackScanThreat], siteRef: JetpackSiteRef) { + let grouping: [DateComponents: [JetpackScanThreat]] = Dictionary(grouping: threats) { (threat) -> DateComponents in + return Calendar.current.dateComponents([.day, .year, .month], from: threat.firstDetected) + } + + let keys = grouping.keys + let formatter = ActivityDateFormatting.longDateFormatter(for: siteRef, withTime: false) + var sectionsArray: [JetpackThreatSection] = [] + for key in keys { + guard let date = Calendar.current.date(from: key), + let threats = grouping[key] + else { + continue + } + + let title = formatter.string(from: date) + sectionsArray.append(JetpackThreatSection(title: title, date: date, threats: threats)) + } + + self.sections = sectionsArray.sorted(by: { $0.date > $1.date }) + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanViewController+JetpackBannerViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanViewController+JetpackBannerViewController.swift new file mode 100644 index 000000000000..7b4c4d142bc7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanViewController+JetpackBannerViewController.swift @@ -0,0 +1,18 @@ +import Foundation + +extension JetpackScanViewController { + @objc + static func withJPBannerForBlog(_ blog: Blog) -> UIViewController { + let jetpackScanVC = JetpackScanViewController(blog: blog) + jetpackScanVC.navigationItem.largeTitleDisplayMode = .never + return JetpackBannerWrapperViewController(childVC: jetpackScanVC, screen: .scan) + } +} + +extension JetpackScanViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if let jetpackBannerWrapper = parent as? JetpackBannerWrapperViewController { + jetpackBannerWrapper.processJetpackBannerVisibility(scrollView) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanViewController.swift new file mode 100644 index 000000000000..30a2fb90c8d8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanViewController.swift @@ -0,0 +1,396 @@ +import UIKit +import WordPressFlux + +class JetpackScanViewController: UIViewController, JetpackScanView { + private let blog: Blog + + lazy var coordinator: JetpackScanCoordinator = { + return JetpackScanCoordinator(blog: blog, view: self) + }() + + // Table View + @IBOutlet weak var tableView: UITableView! + let refreshControl = UIRefreshControl() + + // Loading / Errors + private var noResultsViewController: NoResultsViewController? + + // MARK: - Initializers + @objc init(blog: Blog) { + self.blog = blog + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Methods + override func viewDidLoad() { + super.viewDidLoad() + + self.title = NSLocalizedString("Scan", comment: "Title of the view") + + extendedLayoutIncludesOpaqueBars = true + + configureTableView() + coordinator.viewDidLoad() + + navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("History", comment: "Title of a navigation button that opens the scan history view"), + style: .plain, + target: self, + action: #selector(showHistory)) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + WPAnalytics.track(.jetpackScanAccessed) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + coordinator.viewWillDisappear() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate(alongsideTransition: { _ in + self.tableView.beginUpdates() + self.tableView.endUpdates() + }) + } + + // MARK: - JetpackScanView + func render() { + updateNoResults(nil) + + refreshControl.endRefreshing() + tableView.reloadData() + } + + func toggleHistoryButton(_ isEnabled: Bool) { + navigationItem.rightBarButtonItem?.isEnabled = isEnabled + } + + func showLoading() { + let model = NoResultsViewController.Model(title: NoResultsText.loading.title, + accessoryView: NoResultsViewController.loadingAccessoryView()) + updateNoResults(model) + } + + func showGenericError() { + let model = NoResultsViewController.Model(title: NoResultsText.error.title, + subtitle: NoResultsText.error.subtitle, + buttonText: NoResultsText.contactSupportButtonText) + updateNoResults(model) + } + + func showNoConnectionError() { + let model = NoResultsViewController.Model(title: NoResultsText.noConnection.title, + subtitle: NoResultsText.noConnection.subtitle, + buttonText: NoResultsText.tryAgainButtonText) + updateNoResults(model) + } + + func showScanStartError() { + let model = NoResultsViewController.Model(title: NoResultsText.scanStartError.title, + subtitle: NoResultsText.scanStartError.subtitle, + buttonText: NoResultsText.contactSupportButtonText) + updateNoResults(model) + } + + func showMultisiteNotSupportedError() { + let model = NoResultsViewController.Model(title: NoResultsText.multisiteError.title, + subtitle: NoResultsText.multisiteError.subtitle, + imageName: NoResultsText.multisiteError.imageName) + updateNoResults(model) + refreshControl.endRefreshing() + } + + func vaultPressActiveOnSite() { + let model = NoResultsViewController.Model(title: NoResultsText.vaultPressError.title, + subtitle: NoResultsText.vaultPressError.subtitle, + buttonText: NoResultsText.vaultPressError.buttonLabel, + imageName: NoResultsText.multisiteError.imageName) + updateNoResults(model) + + noResultsViewController?.actionButtonHandler = { [weak self] in + let dashboardURL = URL(string: "https://dashboard.vaultpress.com/")! + let webViewController = WebViewControllerFactory.controller(url: dashboardURL, source: "jetpack_backup") + let webViewNavigationController = UINavigationController(rootViewController: webViewController) + self?.present(webViewNavigationController, animated: true) + } + + refreshControl.endRefreshing() + } + + func presentAlert(_ alert: UIAlertController) { + present(alert, animated: true, completion: nil) + } + + func presentNotice(with title: String, message: String?) { + displayNotice(title: title, message: message) + } + + func showIgnoreThreatSuccess(for threat: JetpackScanThreat) { + navigationController?.popViewController(animated: true) + coordinator.refreshData() + + let model = JetpackScanThreatViewModel(threat: threat) + let notice = Notice(title: model.ignoreSuccessTitle) + ActionDispatcher.dispatch(NoticeAction.post(notice)) + } + + func showIgnoreThreatError(for threat: JetpackScanThreat) { + navigationController?.popViewController(animated: true) + coordinator.refreshData() + + let model = JetpackScanThreatViewModel(threat: threat) + let notice = Notice(title: model.ignoreErrorTitle) + ActionDispatcher.dispatch(NoticeAction.post(notice)) + } + + func showJetpackSettings(with siteID: Int) { + guard let controller = JetpackWebViewControllerFactory.settingsController(siteID: siteID) else { + + let title = NSLocalizedString("Unable to visit Jetpack settings for site", comment: "Message displayed when visiting the Jetpack settings page fails.") + displayNotice(title: title) + return + } + + let navigationVC = UINavigationController(rootViewController: controller) + present(navigationVC, animated: true) + } + + // MARK: - Actions + @objc func showHistory() { + let viewController = JetpackScanHistoryViewController(blog: blog) + navigationController?.pushViewController(viewController, animated: true) + } + + // MARK: - Private: + private func configureTableView() { + tableView.delegate = self + tableView.register(JetpackScanStatusCell.defaultNib, forCellReuseIdentifier: Constants.statusCellIdentifier) + tableView.register(JetpackScanThreatCell.defaultNib, forCellReuseIdentifier: Constants.threatCellIdentifier) + tableView.register(ActivityListSectionHeaderView.defaultNib, + forHeaderFooterViewReuseIdentifier: ActivityListSectionHeaderView.identifier) + + tableView.tableFooterView = UIView() + + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(userRefresh), for: .valueChanged) + } + + @objc func userRefresh() { + coordinator.refreshData() + } + + // MARK: - Private: Config + private struct Constants { + static let statusCellIdentifier = "StatusCell" + static let threatCellIdentifier = "ThreatCell" + + /// The number of header rows, used to get the threat rows + static let tableHeaderCountOffset = 1 + } +} + +extension JetpackScanViewController: JetpackScanThreatDetailsViewControllerDelegate { + + func willFixThreat(_ threat: JetpackScanThreat, controller: JetpackScanThreatDetailsViewController) { + navigationController?.popViewController(animated: true) + + coordinator.fixThreat(threat: threat) + } + + func willIgnoreThreat(_ threat: JetpackScanThreat, controller: JetpackScanThreatDetailsViewController) { + coordinator.ignoreThreat(threat: threat) + } +} + +// MARK: - Table View +extension JetpackScanViewController: UITableViewDataSource, UITableViewDelegate { + func numberOfSections(in tableView: UITableView) -> Int { + let count = coordinator.sections?.count ?? 0 + return count + Constants.tableHeaderCountOffset + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if section < Constants.tableHeaderCountOffset { + return 1 + } + + guard let historySection = threatSection(for: section) else { + return 0 + } + + return historySection.threats.count + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let title = threatSection(for: section)?.title, + let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: ActivityListSectionHeaderView.identifier) as? ActivityListSectionHeaderView else { + return UIView(frame: .zero) + } + + cell.titleLabel.text = title + + return cell + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + if threatSection(for: section)?.title == nil { + return 0 + } + + return ActivityListSectionHeaderView.height + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + var cell: UITableViewCell + + if let threat = threat(for: indexPath) { + let threatCell = tableView.dequeueReusableCell(withIdentifier: Constants.threatCellIdentifier) as? JetpackScanThreatCell ?? JetpackScanThreatCell(style: .default, reuseIdentifier: Constants.threatCellIdentifier) + + configureThreatCell(cell: threatCell, threat: threat) + + cell = threatCell + } else { + let statusCell = tableView.dequeueReusableCell(withIdentifier: Constants.statusCellIdentifier) as? JetpackScanStatusCell ?? JetpackScanStatusCell(style: .default, reuseIdentifier: Constants.statusCellIdentifier) + + configureStatusCell(cell: statusCell) + + cell = statusCell + + } + return cell + } + + private func configureStatusCell(cell: JetpackScanStatusCell) { + guard let model = JetpackScanStatusViewModel(coordinator: coordinator) else { + // TODO: handle error + return + } + + cell.configure(with: model) + } + + private func configureThreatCell(cell: JetpackScanThreatCell, threat: JetpackScanThreat) { + let model = JetpackScanThreatViewModel(threat: threat) + cell.configure(with: model) + } + + private func threatSection(for index: Int) -> JetpackThreatSection? { + let adjustedIndex = index - Constants.tableHeaderCountOffset + guard + adjustedIndex >= 0, let section = coordinator.sections?[adjustedIndex] else { + return nil + } + + return section + } + + private func threat(for indexPath: IndexPath) -> JetpackScanThreat? { + guard let section = threatSection(for: indexPath.section) else { + return nil + } + + return section.threats[indexPath.row] + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + guard let threat = threat(for: indexPath), threat.status != .fixing else { + return + } + + let threatDetailsVC = JetpackScanThreatDetailsViewController(blog: blog, + threat: threat, + hasValidCredentials: coordinator.hasValidCredentials) + threatDetailsVC.delegate = self + self.navigationController?.pushViewController(threatDetailsVC, animated: true) + + WPAnalytics.track(.jetpackScanThreatListItemTapped, properties: ["threat_signature": threat.signature, "section": "scanner"]) + } +} + +// MARK: - Loading / Errors +extension JetpackScanViewController: NoResultsViewControllerDelegate { + func updateNoResults(_ viewModel: NoResultsViewController.Model?) { + if let noResultsViewModel = viewModel { + showNoResults(noResultsViewModel) + } else { + noResultsViewController?.view.isHidden = true + tableView.reloadData() + } + } + + private func showNoResults(_ viewModel: NoResultsViewController.Model) { + if noResultsViewController == nil { + noResultsViewController = NoResultsViewController.controller() + noResultsViewController?.delegate = self + + guard let noResultsViewController = noResultsViewController else { + return + } + + if noResultsViewController.view.superview != tableView { + tableView.addSubview(noResultsViewController.view) + } + + addChild(noResultsViewController) + + noResultsViewController.view.translatesAutoresizingMaskIntoConstraints = false + } + + noResultsViewController?.bindViewModel(viewModel) + noResultsViewController?.didMove(toParent: self) + tableView.pinSubviewToSafeArea(noResultsViewController!.view) + noResultsViewController?.view.isHidden = false + } + + func actionButtonPressed() { + coordinator.noResultsButtonPressed() + } + + private struct NoResultsText { + struct loading { + static let title = NSLocalizedString("Loading Scan...", comment: "Text displayed while loading the scan section for a site") + } + + struct scanStartError { + static let title = NSLocalizedString("Something went wrong", comment: "Title for the error view when the scan start has failed") + static let subtitle = NSLocalizedString("Jetpack Scan couldn't complete a scan of your site. Please check to see if your site is down – if it's not, try again. If it is, or if Jetpack Scan is still having problems, contact our support team.", comment: "Error message shown when the scan start has failed.") + } + + struct multisiteError { + static let title = NSLocalizedString("WordPress multisites are not supported", comment: "Title for label when the user's site is a multisite.") + static let subtitle = NSLocalizedString("We're sorry, Jetpack Scan is not compatible with multisite WordPress installations at this time.", comment: "Description for label when the user's site is a multisite.") + static let imageName = "jetpack-scan-state-error" + } + + struct vaultPressError { + static let title = NSLocalizedString("Your site has VaultPress", comment: "Title for label when the user has VaultPress enabled.") + static let subtitle = NSLocalizedString("Your site already is protected by VaultPress. You can find a link to your VaultPress dashboard below.", comment: "Description for label when the user has a site with VaultPress.") + static let buttonLabel = NSLocalizedString("Visit Dashboard", comment: "Text of a button that links to the VaultPress dashboard.") + static let imageName = "jetpack-scan-state-error" + } + + struct error { + static let title = NSLocalizedString("Oops", comment: "Title for the view when there's an error loading scan status") + static let subtitle = NSLocalizedString("There was an error loading the scan status", comment: "Text displayed when there is a failure loading the status") + } + + struct noConnection { + static let title = NSLocalizedString("No connection", comment: "Title for the error view when there's no connection") + static let subtitle = NSLocalizedString("An active internet connection is required to view Jetpack Scan", comment: "Error message shown when trying to view the scan status and there is no internet connection.") + } + + static let tryAgainButtonText = NSLocalizedString("Try again", comment: "Button label for trying to retrieve the scan status again") + static let contactSupportButtonText = NSLocalizedString("Contact support", comment: "Button label for contacting support") + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanViewController.xib b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanViewController.xib new file mode 100644 index 000000000000..da3e7c750ff4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/JetpackScanViewController.xib @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="JetpackScanViewController"> + <connections> + <outlet property="tableView" destination="BpW-wq-UHw" id="Pbl-Xs-Odk"/> + <outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="200" sectionHeaderHeight="-1" estimatedSectionHeaderHeight="-1" sectionFooterHeight="-1" estimatedSectionFooterHeight="-1" translatesAutoresizingMaskIntoConstraints="NO" id="BpW-wq-UHw"> + <rect key="frame" x="0.0" y="44" width="414" height="818"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <connections> + <outlet property="dataSource" destination="-1" id="RB8-lG-jMi"/> + <outlet property="delegate" destination="-1" id="4BX-Me-DA4"/> + </connections> + </tableView> + </subviews> + <viewLayoutGuide key="safeArea" id="Q5M-cg-NOt"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="Q5M-cg-NOt" firstAttribute="trailing" secondItem="BpW-wq-UHw" secondAttribute="trailing" id="8cK-P3-nEg"/> + <constraint firstItem="BpW-wq-UHw" firstAttribute="leading" secondItem="Q5M-cg-NOt" secondAttribute="leading" id="o9A-DJ-hFI"/> + <constraint firstItem="Q5M-cg-NOt" firstAttribute="bottom" secondItem="BpW-wq-UHw" secondAttribute="bottom" id="pbj-UL-GjY"/> + <constraint firstItem="BpW-wq-UHw" firstAttribute="top" secondItem="Q5M-cg-NOt" secondAttribute="top" id="vBW-Yn-4cl"/> + </constraints> + <point key="canvasLocation" x="139" y="144"/> + </view> + </objects> + <resources> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanStatusViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanStatusViewModel.swift new file mode 100644 index 000000000000..782692af4839 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanStatusViewModel.swift @@ -0,0 +1,259 @@ +struct JetpackScanStatusViewModel { + let imageName: String + let title: String + let description: String + + private(set) var primaryButtonTitle: String? + private(set) var primaryButtonEnabled: Bool = true + private(set) var secondaryButtonTitle: String? + private(set) var warningButtonTitle: HighlightedText? + private(set) var progress: Float? + + private let coordinator: JetpackScanCoordinator + + private var primaryButtonAction: ButtonAction? + private var secondaryButtonAction: ButtonAction? + private var warningButtonAction: ButtonAction? + + init?(coordinator: JetpackScanCoordinator) { + self.coordinator = coordinator + + guard let scan = coordinator.scan else { + return nil + } + + let blog = coordinator.blog + let state = Self.viewState(for: scan) + + switch state { + case .noThreats: + let descriptionTitle: String + + if let mostRecent = scan.mostRecent, let startDate = mostRecent.startDate, let duration = mostRecent.duration { + // Calculate the end date of the scan which is the start date + the duration + let lastScanDate = startDate.addingTimeInterval(duration) + let dateString = Self.relativeTimeString(for: lastScanDate) + + descriptionTitle = String(format: Strings.noThreatsDescriptionFormat, dateString) + } else { + descriptionTitle = Strings.noThreatsDescription + } + + imageName = "jetpack-scan-state-okay" + title = Strings.noThreatsTitle + description = descriptionTitle + + secondaryButtonTitle = Strings.scanNowTitle + secondaryButtonAction = .triggerScan + + case .hasThreats, .hasFixableThreats, .fixingThreats: + imageName = "jetpack-scan-state-error" + + if state == .fixingThreats { + title = Strings.fixing.title + description = Strings.fixing.details + } else { + let threatCount = scan.threats?.count ?? 0 + let blogName = blog.title ?? "" + + let descriptionTitle: String + if threatCount == 1 { + descriptionTitle = String(format: Strings.hasSingleThreatDescriptionFormat, blogName) + } else { + descriptionTitle = String(format: Strings.hasThreatsDescriptionFormat, threatCount, blogName) + } + + title = Strings.hasThreatsTitle + description = descriptionTitle + + if state == .hasThreats { + secondaryButtonTitle = Strings.scanNowTitle + secondaryButtonAction = .triggerScan + } else { + primaryButtonTitle = Strings.fixAllTitle + primaryButtonAction = .fixAll + + secondaryButtonTitle = Strings.scanAgainTitle + secondaryButtonAction = .triggerScan + + if !scan.hasValidCredentials { + let warningString = String(format: Strings.enterServerCredentialsFormat, + Strings.enterServerCredentialsSubstring) + warningButtonTitle = HighlightedText(substring: Strings.enterServerCredentialsSubstring, + string: warningString) + warningButtonAction = .enterServerCredentials + + primaryButtonEnabled = false + } + } + } + + case .preparingToScan: + imageName = "jetpack-scan-state-progress" + title = Strings.preparingTitle + description = Strings.scanningDescription + progress = 0 + + case .scanning: + imageName = "jetpack-scan-state-progress" + title = Strings.scanningTitle + description = Strings.scanningDescription + progress = Float(scan.current?.progress ?? 0) / 100.0 + + case .error: + imageName = "jetpack-scan-state-error" + title = Strings.errorTitle + description = Strings.errorDescription + + primaryButtonTitle = Strings.contactSupportTitle + primaryButtonAction = .contactSupport + + secondaryButtonTitle = Strings.retryScanTitle + secondaryButtonAction = .triggerScan + } + } + + // MARK: - Button Actions + private enum ButtonAction { + case triggerScan + case fixAll + case contactSupport + case enterServerCredentials + } + + func primaryButtonTapped(_ sender: Any) { + guard let action = primaryButtonAction else { + return + } + + buttonTapped(action: action) + } + + func secondaryButtonTapped(_ sender: Any) { + guard let action = secondaryButtonAction else { + return + } + + buttonTapped(action: action) + } + + func warningButtonTapped(_ sender: Any) { + guard let action = warningButtonAction else { + return + } + + buttonTapped(action: action) + } + + private func buttonTapped(action: ButtonAction) { + switch action { + case .fixAll: + coordinator.presentFixAllAlert() + WPAnalytics.track(.jetpackScanAllThreatsOpen) + + case .triggerScan: + coordinator.startScan() + WPAnalytics.track(.jetpackScanRunTapped) + + case .contactSupport: + coordinator.openSupport() + + case .enterServerCredentials: + coordinator.openJetpackSettings() + } + } + + // MARK: - View State + + /// The potential states the view can be in based on the scan state + private enum StatusViewState { + case noThreats + case hasThreats + case hasFixableThreats + case preparingToScan + case scanning + case fixingThreats + case error + } + + private static func viewState(for scan: JetpackScan) -> StatusViewState { + let viewState: StatusViewState + + switch scan.state { + case .idle: + if let threats = scan.threats, threats.count > 0 { + viewState = scan.hasFixableThreats ? .hasFixableThreats : .hasThreats + } else { + if scan.mostRecent?.didFail ?? false { + return .error + } + + viewState = .noThreats + } + case .fixingThreats: + viewState = .fixingThreats + + case .provisioning: + viewState = .preparingToScan + + case .scanning: + let isPreparing = (scan.current?.progress ?? 0) == 0 + + viewState = isPreparing ? .preparingToScan : .scanning + + case .unavailable: + viewState = .error + + default: + viewState = .noThreats + } + + return viewState + } + + /// Converts a date into a relative time (X seconds ago, X hours ago, etc) + private static func relativeTimeString(for date: Date) -> String { + let dateString: String + + // Temporary check until iOS 13 is the deployment target + let formatter = RelativeDateTimeFormatter() + formatter.dateTimeStyle = .named + formatter.unitsStyle = .full + + dateString = formatter.localizedString(for: date, relativeTo: Date()) + return dateString + } + + // MARK: - Localized Strings + private struct Strings { + static let enterServerCredentialsSubstring = NSLocalizedString("Enter your server credentials", comment: "Error message displayed when site credentials aren't configured.") + static let enterServerCredentialsFormat = NSLocalizedString("%1$@ to fix threats.", comment: "Title for button when a site is missing server credentials. %1$@ is a placeholder for the string 'Enter your server credentials'.") + static let noThreatsTitle = NSLocalizedString("Don’t worry about a thing", comment: "Title for label when there are no threats on the users site") + static let noThreatsDescriptionFormat = NSLocalizedString("The last Jetpack scan ran %1$@ and did not find any risks.\n\nTo review your site again run a manual scan, or wait for Jetpack to scan your site later today.", comment: "Description for label when there are no threats on a users site and how long ago the scan ran.") + static let noThreatsDescription = NSLocalizedString("The last jetpack scan did not find any risks.\n\nTo review your site again run a manual scan, or wait for Jetpack to scan your site later today.", + comment: "Description that informs for label when there are no threats on a users site") + + static let hasThreatsTitle = NSLocalizedString("Your site may be at risk", comment: "Title for label when there are threats on the users site") + static let hasThreatsDescriptionFormat = NSLocalizedString("Jetpack Scan found %1$d potential threats with %2$@. Please review them below and take action or tap the fix all button. We are here to help if you need us.", comment: "Description for a label when there are threats on the site, displays the number of threats, and the site's title") + static let hasSingleThreatDescriptionFormat = NSLocalizedString("Jetpack Scan found 1 potential threat with %1$@. Please review them below and take action or tap the fix all button. We are here to help if you need us.", comment: "Description for a label when there is a single threat on the site, displays the site's title") + + static let preparingTitle = NSLocalizedString("Preparing to scan", comment: "Title for label when the preparing to scan the users site") + static let scanningTitle = NSLocalizedString("Scanning files", comment: "Title for label when the actively scanning the users site") + static let scanningDescription = NSLocalizedString("We will send you an email if security threats are found. In the meantime feel free to continue to use your site as normal, you can check back on progress at any time.", comment: "Description for label when the actively scanning the users site") + + static let errorTitle = NSLocalizedString("Something went wrong", comment: "Title for a label that appears when the scan failed") + static let errorDescription = NSLocalizedString("Jetpack Scan couldn't complete a scan of your site. Please check to see if your site is down – if it's not, try again. If it is, or if Jetpack Scan is still having problems, contact our support team.", comment: "Description for a label when the scan has failed") + + struct fixing { + static let title = NSLocalizedString("Fixing Threats", comment: "Subtitle displayed while the server is fixing threats") + static let details = NSLocalizedString("We're hard at work fixing these threats in the background. In the meantime feel free to continue to use your site as normal, you can check back on progress at any time.", comment: "Detail text display informing the user that we're fixing threats") + } + + // Buttons + static let contactSupportTitle = NSLocalizedString("Contact Support", comment: "Button title that opens the support page") + static let retryScanTitle = NSLocalizedString("Retry Scan", comment: "Button title that triggers a scan") + static let scanNowTitle = NSLocalizedString("Scan Now", comment: "Button title that triggers a scan") + static let scanAgainTitle = NSLocalizedString("Scan Again", comment: "Button title that triggers a scan") + static let fixAllTitle = NSLocalizedString("Fix All", comment: "Button title that attempts to fix all fixable threats") + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanThreatViewModel.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanThreatViewModel.swift new file mode 100644 index 000000000000..abcfe5182bad --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Scan/View Models/JetpackScanThreatViewModel.swift @@ -0,0 +1,518 @@ +import Foundation + +struct JetpackScanThreatViewModel { + + private let threat: JetpackScanThreat + + let iconImage: UIImage? + let iconImageColor: UIColor + let title: String + let description: String? + let isFixing: Bool + private let hasValidCredentials: Bool? + + // Threat Details + let detailIconImage: UIImage? + let detailIconImageColor: UIColor + let problemTitle: String + let problemDescription: String + let fixTitle: String? + let fixDescription: String? + let technicalDetailsTitle: String + let technicalDetailsDescription: String + let fileName: String? + let fileNameBackgroundColor: UIColor + let fileNameColor: UIColor + let fileNameFont: UIFont + + // Threat Detail Action + let fixActionTitle: String? + let fixActionEnabled: Bool + let ignoreActionTitle: String? + let ignoreActionMessage: String + let warningActionTitle: HighlightedText? + + // Threat Detail Success + let fixSuccessTitle: String + let ignoreSuccessTitle: String + + // Threat Detail Error + let fixErrorTitle: String + let ignoreErrorTitle: String + + // Attributed string + lazy var attributedFileContext: NSAttributedString? = { + let contextConfig = JetpackThreatContext.JetpackThreatContextRendererConfig( + numberAttributes: [ + NSAttributedString.Key.font: Constants.monospacedFont, + NSAttributedString.Key.foregroundColor: Constants.colors.normal.numberText, + NSAttributedString.Key.backgroundColor: Constants.colors.normal.numberBackground + ], + highlightedNumberAttributes: [ + NSAttributedString.Key.font: Constants.monospacedFont, + NSAttributedString.Key.foregroundColor: Constants.colors.normal.numberText, + NSAttributedString.Key.backgroundColor: Constants.colors.highlighted.numberBackground + ], + contentsAttributes: [ + NSAttributedString.Key.font: Constants.monospacedFont, + NSAttributedString.Key.foregroundColor: Constants.colors.normal.text, + NSAttributedString.Key.backgroundColor: Constants.colors.normal.background + ], + highlightedContentsAttributes: [ + NSAttributedString.Key.font: Constants.monospacedFont, + NSAttributedString.Key.foregroundColor: Constants.colors.normal.text, + NSAttributedString.Key.backgroundColor: Constants.colors.highlighted.numberBackground + ], + highlightedSectionAttributes: [ + NSAttributedString.Key.font: Constants.monospacedFont, + NSAttributedString.Key.foregroundColor: Constants.colors.highlighted.text, + NSAttributedString.Key.backgroundColor: Constants.colors.highlighted.background + ] + ) + + return threat.context?.attributedString(with: contextConfig) + }() + + init(threat: JetpackScanThreat, hasValidCredentials: Bool? = nil) { + self.threat = threat + self.hasValidCredentials = hasValidCredentials + + let status = threat.status + + iconImage = Self.iconImage(for: status) + iconImageColor = Self.iconColor(for: status) + title = Self.title(for: threat) + description = Self.description(for: threat) + isFixing = status == .fixing + + // Threat Details + detailIconImage = UIImage(named: "jetpack-scan-state-error") + detailIconImageColor = .error + problemTitle = Strings.details.titles.problem + problemDescription = threat.description + fixTitle = Self.fixTitle(for: threat) + fixDescription = Self.fixDescription(for: threat) + technicalDetailsTitle = Strings.details.titles.technicalDetails + technicalDetailsDescription = Strings.details.descriptions.technicalDetails + fileName = threat.fileName + fileNameBackgroundColor = Constants.colors.normal.background + fileNameFont = Constants.monospacedFont + fileNameColor = Constants.colors.normal.text + + // Threat Details Action + fixActionTitle = Self.fixActionTitle(for: threat) + fixActionEnabled = hasValidCredentials ?? false + ignoreActionTitle = Self.ignoreActionTitle(for: threat) + ignoreActionMessage = Strings.details.actions.messages.ignore + warningActionTitle = Self.warningActionTitle(for: threat, hasValidCredentials: hasValidCredentials) + + // Threat Detail Success + fixSuccessTitle = Strings.details.success.fix + ignoreSuccessTitle = Strings.details.success.ignore + + // Threat Details Error + fixErrorTitle = Strings.details.error.fix + ignoreErrorTitle = Strings.details.error.ignore + } + + private static func fixTitle(for threat: JetpackScanThreat) -> String? { + guard let status = threat.status else { + return nil + } + + let fixable = threat.fixable?.type != nil + + switch (status, fixable) { + case (.fixed, _): + return Strings.details.titles.fix.fixed + case (.current, false): + return Strings.details.titles.fix.notFixable + default: + return Strings.details.titles.fix.default + } + } + + private static func fixDescription(for threat: JetpackScanThreat) -> String? { + guard let fixType = threat.fixable?.type else { + return Strings.details.descriptions.fix.notFixable + } + + let description: String + + switch fixType { + case .replace: + description = Strings.details.descriptions.fix.replace + case .delete: + description = Strings.details.descriptions.fix.delete + case .update: + description = Strings.details.descriptions.fix.update + case .edit: + description = Strings.details.descriptions.fix.edit + case .rollback: + if let target = threat.fixable?.target { + description = String(format: Strings.details.descriptions.fix.rollback.withTarget, target) + } else { + description = Strings.details.descriptions.fix.rollback.withoutTarget + } + default: + description = Strings.details.descriptions.fix.unknown + } + return description + } + + private static func description(for threat: JetpackScanThreat) -> String? { + guard threat.status != .fixing else { + return Self.fixDescription(for: threat) + } + + let type = threat.type + let description: String? + + switch type { + case .core: + description = Strings.description.core + case .file: + let signature = threat.signature + description = String(format: Strings.description.file, signature) + case .plugin: + description = Strings.description.plugin + case .theme: + description = Strings.description.theme + case .database: + description = Strings.description.database + default: + description = Strings.description.unknown + } + + return description + } + + private static func title(for threat: JetpackScanThreat) -> String { + let type = threat.type + let title: String + + switch type { + case .core: + if let fileName = threat.fileName?.fileName() { + title = String(format: Strings.titles.core.multiple, fileName) + } else { + title = Strings.titles.core.singular + } + + case .file: + if let fileName = threat.fileName?.fileName() { + title = String(format: Strings.titles.file.multiple, fileName) + } else { + title = Strings.titles.file.singular + } + + case .plugin: + if let plugin = threat.`extension` { + title = String(format: Strings.titles.plugin.multiple, plugin.slug, plugin.version) + } else { + title = Strings.titles.plugin.singular + } + + case .theme: + if let plugin = threat.`extension` { + title = String(format: Strings.titles.theme.multiple, plugin.slug, plugin.version) + } else { + title = Strings.titles.theme.singular + } + + case .database: + if let rowCount = threat.rows?.count, rowCount > 0 { + title = String(format: Strings.titles.database.multiple, "\(rowCount)") + } else { + title = Strings.titles.database.singular + } + + default: + title = Strings.titles.unknown + } + + return title + } + + private static func iconColor(for status: JetpackScanThreat.ThreatStatus?) -> UIColor { + switch status { + case .current: + return .error + case .fixed: + return .success + default: + return .neutral(.shade20) + } + } + + private static func iconImage(for status: JetpackScanThreat.ThreatStatus?) -> UIImage? { + var image: UIImage = .gridicon(.notice) + + if status == .fixed { + if let icon = UIImage(named: "jetpack-scan-threat-fixed") { + image = icon + } + } + + return image.imageWithTintColor(.white) + } + + private static func fixActionTitle(for threat: JetpackScanThreat) -> String? { + guard + threat.fixable?.type != nil, + threat.status != .fixed && threat.status != .ignored + else { + return nil + } + + return Strings.details.actions.titles.fixable + } + + private static func ignoreActionTitle(for threat: JetpackScanThreat) -> String? { + guard threat.status != .fixed && threat.status != .ignored else { + return nil + } + + return Strings.details.actions.titles.ignore + } + + private static func warningActionTitle(for threat: JetpackScanThreat, hasValidCredentials: Bool?) -> HighlightedText? { + guard fixActionTitle(for: threat) != nil, + let hasValidCredentials = hasValidCredentials, + !hasValidCredentials else { + return nil + } + + let warningString = String(format: Strings.details.actions.titles.enterServerCredentialsFormat, + Strings.details.actions.titles.enterServerCredentialsSubstring) + + let warningActionTitle = HighlightedText(substring: Strings.details.actions.titles.enterServerCredentialsSubstring, + string: warningString) + + return warningActionTitle + } + + private struct Strings { + + struct details { + + struct titles { + static let problem = NSLocalizedString("What was the problem?", comment: "Title for the problem section in the Threat Details") + static let technicalDetails = NSLocalizedString("The technical details", comment: "Title for the technical details section in Threat Details") + + struct fix { + static let `default` = NSLocalizedString("How will we fix it?", comment: "Title for the fix section in Threat Details") + static let fixed = NSLocalizedString("How did Jetpack fix it?", comment: "Title for the fix section in Threat Details: Threat is fixed") + static let notFixable = NSLocalizedString("Resolving the threat", comment: "Title for the fix section in Threat Details: Threat is not fixable") + } + } + + struct descriptions { + static let technicalDetails = NSLocalizedString("Threat found in file:", comment: "Description for threat file") + + struct fix { + static let replace = NSLocalizedString("Jetpack Scan will replace the affected file or directory.", comment: "Description that explains how we will fix the threat") + static let delete = NSLocalizedString("Jetpack Scan will delete the affected file or directory.", comment: "Description that explains how we will fix the threat") + static let update = NSLocalizedString("Jetpack Scan will update to a newer version.", comment: "Description that explains how we will fix the threat") + static let edit = NSLocalizedString("Jetpack Scan will edit the affected file or directory.", comment: "Description that explains how we will fix the threat") + struct rollback { + static let withTarget = NSLocalizedString("Jetpack Scan will rollback the affected file to the version from %1$@.", comment: "Description that explains how we will fix the threat") + static let withoutTarget = NSLocalizedString("Jetpack Scan will rollback the affected file to an older (clean) version.", comment: "Description that explains how we will fix the threat") + } + + static let unknown = NSLocalizedString("Jetpack Scan will resolve the threat.", comment: "Description that explains how we will fix the threat") + + static let notFixable = NSLocalizedString("Jetpack Scan cannot automatically fix this threat. We suggest that you resolve the threat manually: ensure that WordPress, your theme, and all of your plugins are up to date, and remove the offending code, theme, or plugin from your site.", comment: "Description that explains that we are unable to auto fix the threat") + } + } + + struct actions { + + struct titles { + static let ignore = NSLocalizedString("Ignore threat", comment: "Title for button that will ignore the threat") + static let fixable = NSLocalizedString("Fix threat", comment: "Title for button that will fix the threat") + static let enterServerCredentialsSubstring = NSLocalizedString("Enter your server credentials", comment: "Error message displayed when site credentials aren't configured.") + static let enterServerCredentialsFormat = NSLocalizedString("%1$@ to fix this threat.", comment: "Title for button when a site is missing server credentials. %1$@ is a placeholder for the string 'Enter your server credentials'.") + } + + struct messages { + static let ignore = NSLocalizedString("You shouldn’t ignore a security issue unless you are absolutely sure it’s harmless. If you choose to ignore this threat, it will remain on your site \"%1$@\".", comment: "Message displayed in ignore threat alert. %1$@ is a placeholder for the blog name.") + } + } + + struct success { + static let fix = NSLocalizedString("The threat was successfully fixed.", comment: "Message displayed when a threat is fixed successfully.") + static let ignore = NSLocalizedString("Threat ignored.", comment: "Message displayed when a threat is ignored successfully.") + } + + struct error { + static let fix = NSLocalizedString("Error fixing threat. Please contact our support.", comment: "Error displayed when fixing a threat fails.") + static let ignore = NSLocalizedString("Error ignoring threat. Please contact our support.", comment: "Error displayed when ignoring a threat fails.") + } + } + + + struct titles { + struct core { + static let singular = NSLocalizedString("Infected core file", comment: "Title for a threat") + static let multiple = NSLocalizedString("Infected core file: %1$@", comment: "Title for a threat that includes the file name of the file") + } + + struct file { + static let singular = NSLocalizedString("A file contains a malicious code pattern", comment: "Title for a threat") + static let multiple = NSLocalizedString("The file %1$@ contains a malicious code pattern", comment: "Title for a threat that includes the file name of the file") + } + + struct plugin { + static let singular = NSLocalizedString("Vulnerable Plugin", comment: "Title for a threat") + static let multiple = NSLocalizedString("Vulnerable Plugin: %1$@ (version %2$@)", comment: "Title for a threat that includes the file name of the plugin and the affected version") + } + + struct theme { + static let singular = NSLocalizedString("Vulnerable Theme", comment: "Title for a threat") + static let multiple = NSLocalizedString("Vulnerable Theme %1$@ (version %2$@)", comment: "Title for a threat that includes the file name of the theme and the affected version") + } + + struct database { + static let singular = NSLocalizedString("Database threat", comment: "Title for a threat") + static let multiple = NSLocalizedString("Database %1$d threats", comment: "Title for a threat that includes the number of database rows affected") + } + + static let unknown = NSLocalizedString("Threat Found", comment: "Title for a threat") + } + + struct description { + static let core = NSLocalizedString("Vulnerability found in WordPress", comment: "Summary description for a threat") + static let file = NSLocalizedString("Threat found %1$@", comment: "Summary description for a threat that includes the threat signature") + static let plugin = NSLocalizedString("Vulnerability found in plugin", comment: "Summary description for a threat") + static let theme = NSLocalizedString("Vulnerability found in theme", comment: "Summary description for a threat") + static let database: String? = nil + static let unknown = NSLocalizedString("Miscellaneous vulnerability", comment: "Summary description for a threat") + } + } + + private struct Constants { + static let monospacedFont = WPStyleGuide.monospacedSystemFontForTextStyle(.footnote) + + struct colors { + struct normal { + static let text = UIColor.muriel(color: .gray, .shade100) + static let background = UIColor.muriel(color: .gray, .shade5) + static let numberText = UIColor.muriel(color: .gray, .shade100) + static let numberBackground = UIColor.muriel(color: .gray, .shade20) + } + + struct highlighted { + static let text = UIColor.white + static let background = UIColor.muriel(color: .error, .shade50) + static let numberBackground = UIColor.muriel(color: .error, .shade5) + } + } + } +} + +private extension JetpackThreatContext { + + struct JetpackThreatContextRendererConfig { + let numberAttributes: [NSAttributedString.Key: Any] + let highlightedNumberAttributes: [NSAttributedString.Key: Any] + let contentsAttributes: [NSAttributedString.Key: Any] + let highlightedContentsAttributes: [NSAttributedString.Key: Any] + let highlightedSectionAttributes: [NSAttributedString.Key: Any] + } + + func attributedString(with config: JetpackThreatContextRendererConfig) -> NSAttributedString? { + + guard let longestLine = lines.sorted(by: { $0.contents.count > $1.contents.count }).first else { + return nil + } + + // Calculates the "longest" number string length + // This will be used to pad the number column to make sure it's all the same size regardless of + // the line number length + // + // i.e. Last line number is 1000, which is 4 chars + // When processing line 50 we'll append 2 spaces to the end so it ends up being equal to 4 + + let lastNumberLength = String(lines.last?.lineNumber ?? 0).count + + let longestLineLength = longestLine.contents.count + + let attrString = NSMutableAttributedString() + + for line in lines { + + // Number attributed string + + var numberStr = String(line.lineNumber) + let numberPadding = String().padding(toLength: lastNumberLength - numberStr.count, + withPad: Constants.noBreakSpace, startingAt: 0) + numberStr = numberPadding + numberStr + let numberAttr = NSMutableAttributedString(string: numberStr) + + // Contents attributed string + + let contents = line.contents + let contentsPadding = String().padding(toLength: longestLineLength - contents.count, + withPad: Constants.noBreakSpace, startingAt: 0) + + let contentsStr = String(format: Constants.contentsStrFormat, Constants.columnSpacer + contents + contentsPadding) + let contentsAttr = NSMutableAttributedString(string: contentsStr) + + // Highlight logic + + if let highlights = line.highlights { + + numberAttr.setAttributes(config.highlightedNumberAttributes, + range: NSRange(location: 0, length: numberStr.count)) + + contentsAttr.addAttributes(config.highlightedContentsAttributes, + range: NSRange(location: 0, length: contentsStr.count)) + + for highlight in highlights { + let location = highlight.location + let length = highlight.length + Constants.columnSpacer.count + let range = NSRange(location: location, length: length) + + contentsAttr.addAttributes(config.highlightedSectionAttributes, range: range) + } + + } else { + numberAttr.setAttributes(config.numberAttributes, + range: NSRange(location: 0, length: numberStr.count)) + + contentsAttr.setAttributes(config.contentsAttributes, + range: NSRange(location: 0, length: contentsStr.count)) + } + + attrString.append(numberAttr) + attrString.append(contentsAttr) + } + + return attrString + } + + private struct Constants { + static let contentsStrFormat = "%@\n" + static let noBreakSpace = "\u{00a0}" + static let columnSpacer = " " + } +} + +private extension String { + func fileName() -> String { + return (self as NSString).lastPathComponent + } +} + + +private extension WPStyleGuide { + static func monospacedSystemFontForTextStyle(_ style: UIFont.TextStyle, + fontWeight weight: UIFont.Weight = .regular) -> UIFont { + guard let fontDescriptor = WPStyleGuide.fontForTextStyle(style, fontWeight: weight).fontDescriptor.withDesign(.monospaced) else { + return UIFont.monospacedSystemFont(ofSize: 13, weight: weight) + } + + return UIFontMetrics.default.scaledFont(for: UIFont(descriptor: fontDescriptor, size: 0.0)) + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/JetpackConnectionViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackConnectionViewController.swift similarity index 77% rename from WordPress/Classes/ViewRelated/Blog/Site Settings/JetpackConnectionViewController.swift rename to WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackConnectionViewController.swift index 82f5b6c39e04..ba33e831f15b 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/JetpackConnectionViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackConnectionViewController.swift @@ -9,6 +9,16 @@ protocol JetpackConnectionDelegate { /// open class JetpackConnectionViewController: UITableViewController { + // MARK: - Views + + private lazy var activityIndicatorView: UIActivityIndicatorView = { + let indicatorView = UIActivityIndicatorView(style: .large) + indicatorView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(indicatorView) + view.pinSubviewAtCenter(indicatorView) + return indicatorView + }() + // MARK: - Private Properties fileprivate var blog: Blog! @@ -24,9 +34,9 @@ open class JetpackConnectionViewController: UITableViewController { // MARK: - Initializer @objc public convenience init(blog: Blog) { - self.init(style: .grouped) + self.init(style: .insetGrouped) self.blog = blog - self.service = BlogJetpackSettingsService(managedObjectContext: blog.managedObjectContext!) + self.service = BlogJetpackSettingsService(coreDataStack: ContextManager.shared) } // MARK: - View Lifecycle @@ -84,26 +94,27 @@ open class JetpackConnectionViewController: UITableViewController { handler: { action in self.disconnectJetpack() }) + WPAnalytics.trackEvent(.jetpackDisconnectTapped) self.present(alertController, animated: true) } } @objc func disconnectJetpack() { + WPAnalytics.trackEvent(.jetpackDisconnectRequested) + startLoading() self.service.disconnectJetpackFromBlog(self.blog, success: { [weak self] in + self?.stopLoading() if let blog = self?.blog { - // Jetpack was successfully disconnected, lets hide the blog, - // it should become unavailable the next time blogs are fetched - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - service.setVisibility(false, forBlogs: [blog]) - try? context.save() + let service = BlogService(coreDataStack: ContextManager.sharedInstance()) + service.remove(blog) self?.delegate?.jetpackDisconnectedForBlog(blog) } else { self?.dismiss() } }, - failure: { error in + failure: { [weak self] error in + self?.stopLoading() let errorTitle = NSLocalizedString("Error disconnecting Jetpack", comment: "Title of error dialog when disconnecting jetpack fails.") let errorMessage = NSLocalizedString("Please contact support for assistance.", @@ -122,3 +133,20 @@ open class JetpackConnectionViewController: UITableViewController { } } + +// MARK: - Loading + +/// Loading blocks user interactions while loading is in progress since navigating from this view controller +/// during Jetpack connection or disconnection process can leave the application in an undetermined state. +/// +private extension JetpackConnectionViewController { + func startLoading() { + activityIndicatorView.startAnimating() + UIApplication.shared.mainWindow?.isUserInteractionEnabled = false + } + + func stopLoading() { + activityIndicatorView.stopAnimating() + UIApplication.shared.mainWindow?.isUserInteractionEnabled = true + } +} diff --git a/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSettingsViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSettingsViewController.swift new file mode 100644 index 000000000000..940fad24733b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSettingsViewController.swift @@ -0,0 +1,369 @@ +import Foundation +import CocoaLumberjack +import WordPressShared + + +/// The purpose of this class is to render and modify the Jetpack Settings associated to a site. +/// +open class JetpackSettingsViewController: UITableViewController { + + // MARK: - Private Properties + + fileprivate var blog: Blog! + fileprivate var service: BlogJetpackSettingsService! + fileprivate lazy var handler: ImmuTableViewHandler = { + return ImmuTableViewHandler(takeOver: self) + }() + + // MARK: - Computed Properties + + fileprivate var settings: BlogSettings { + return blog.settings! + } + + // MARK: - Static Properties + + fileprivate static let footerHeight = CGFloat(34.0) + fileprivate static let learnMoreUrl = "https://jetpack.com/support/sso/" + fileprivate static let wordPressLoginSection = 3 + + // MARK: - Initializer + + @objc public convenience init(blog: Blog) { + self.init(style: .insetGrouped) + self.blog = blog + self.service = BlogJetpackSettingsService(coreDataStack: ContextManager.shared) + } + + // MARK: - View Lifecycle + + open override func viewDidLoad() { + super.viewDidLoad() + WPAnalytics.trackEvent(.jetpackSettingsViewed) + title = NSLocalizedString("Settings", comment: "Title for the Jetpack Security Settings Screen") + extendedLayoutIncludesOpaqueBars = true + ImmuTable.registerRows([SwitchRow.self], tableView: tableView) + ImmuTable.registerRows([NavigationItemRow.self], tableView: tableView) + WPStyleGuide.configureColors(view: view, tableView: tableView) + reloadViewModel() + } + + open override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + tableView.reloadSelectedRow() + tableView.deselectSelectedRowWithAnimation(true) + refreshSettings() + } + + open override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + } + + // MARK: - Model + + fileprivate func reloadViewModel() { + handler.viewModel = tableViewModel() + } + + func tableViewModel() -> ImmuTable { + var monitorRows = [ImmuTableRow]() + monitorRows.append( + SwitchRow(title: NSLocalizedString("Monitor your site's uptime", + comment: "Jetpack Monitor Settings: Monitor site's uptime"), + value: self.settings.jetpackMonitorEnabled, + onChange: self.jetpackMonitorEnabledValueChanged()) + ) + + if self.settings.jetpackMonitorEnabled { + monitorRows.append( + SwitchRow(title: NSLocalizedString("Send notifications by email", + comment: "Jetpack Monitor Settings: Send notifications by email"), + value: self.settings.jetpackMonitorEmailNotifications, + onChange: self.sendNotificationsByEmailValueChanged()) + ) + monitorRows.append( + SwitchRow(title: NSLocalizedString("Send push notifications", + comment: "Jetpack Monitor Settings: Send push notifications"), + value: self.settings.jetpackMonitorPushNotifications, + onChange: self.sendPushNotificationsValueChanged()) + ) + } + + var bruteForceAttackRows = [ImmuTableRow]() + bruteForceAttackRows.append( + SwitchRow(title: NSLocalizedString("Block malicious login attempts", + comment: "Jetpack Settings: Block malicious login attempts"), + value: self.settings.jetpackBlockMaliciousLoginAttempts, + onChange: self.blockMaliciousLoginAttemptsValueChanged()) + ) + + if self.settings.jetpackBlockMaliciousLoginAttempts { + bruteForceAttackRows.append( + NavigationItemRow(title: NSLocalizedString("Allowlisted IP addresses", + comment: "Jetpack Settings: Allowlisted IP addresses"), + action: self.pressedAllowlistedIPAddresses()) + ) + } + + var wordPressLoginRows = [ImmuTableRow]() + wordPressLoginRows.append( + SwitchRow(title: NSLocalizedString("Allow WordPress.com login", + comment: "Jetpack Settings: Allow WordPress.com login"), + value: self.settings.jetpackSSOEnabled, + onChange: self.ssoEnabledChanged()) + ) + + if self.settings.jetpackSSOEnabled { + wordPressLoginRows.append( + SwitchRow(title: NSLocalizedString("Match accounts using email", + comment: "Jetpack Settings: Match accounts using email"), + value: self.settings.jetpackSSOMatchAccountsByEmail, + onChange: self.matchAccountsUsingEmailChanged()) + ) + wordPressLoginRows.append( + SwitchRow(title: NSLocalizedString("Require two-step authentication", + comment: "Jetpack Settings: Require two-step authentication"), + value: self.settings.jetpackSSORequireTwoStepAuthentication, + onChange: self.requireTwoStepAuthenticationChanged()) + ) + } + + var manageConnectionRows = [ImmuTableRow]() + manageConnectionRows.append( + NavigationItemRow(title: NSLocalizedString("Manage Connection", + comment: "Jetpack Settings: Manage Connection"), + action: self.pressedManageConnection()) + ) + + return ImmuTable(sections: [ + ImmuTableSection( + headerText: "", + rows: monitorRows, + footerText: nil), + ImmuTableSection( + headerText: NSLocalizedString("Brute Force Attack Protection", + comment: "Jetpack Settings: Brute Force Attack Protection Section"), + rows: bruteForceAttackRows, + footerText: nil), + ImmuTableSection( + headerText: "", + rows: manageConnectionRows, + footerText: nil), + ImmuTableSection( + headerText: NSLocalizedString("WordPress.com login", + comment: "Jetpack Settings: WordPress.com Login settings"), + rows: wordPressLoginRows, + footerText: nil) + ]) + } + + // MARK: Learn More footer + + open override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + if section == JetpackSettingsViewController.wordPressLoginSection { + return JetpackSettingsViewController.footerHeight + } + return 0.0 + } + + open override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + if section == JetpackSettingsViewController.wordPressLoginSection { + let footer = UITableViewHeaderFooterView(frame: CGRect(x: 0.0, + y: 0.0, + width: tableView.frame.width, + height: JetpackSettingsViewController.footerHeight)) + footer.textLabel?.text = NSLocalizedString("Learn more...", + comment: "Jetpack Settings: WordPress.com Login WordPress login footer text") + footer.textLabel?.font = UIFont.preferredFont(forTextStyle: .footnote) + footer.textLabel?.isUserInteractionEnabled = true + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleLearnMoreTap(_:))) + footer.addGestureRecognizer(tap) + return footer + } + return nil + } + + // MARK: - Row Handlers + + 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, + success: {}, + failure: { [weak self] (_) in + self?.refreshSettingsAfterSavingError() + }) + } + } + + 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: {}, + failure: { [weak self] (_) in + self?.refreshSettingsAfterSavingError() + }) + } + } + + 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: {}, + failure: { [weak self] (_) in + self?.refreshSettingsAfterSavingError() + }) + } + } + + 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, + success: {}, + failure: { [weak self] (_) in + self?.refreshSettingsAfterSavingError() + }) + } + } + + func pressedAllowlistedIPAddresses() -> ImmuTableAction { + return { [unowned self] row in + let allowListedIPs = self.settings.jetpackLoginAllowListedIPAddresses + let settingsViewController = SettingsListEditorViewController(collection: allowListedIPs) + + settingsViewController.title = NSLocalizedString("Allowlisted IP Addresses", + comment: "Allowlisted IP Addresses Title") + settingsViewController.insertTitle = NSLocalizedString("New IP or IP Range", + comment: "IP Address or Range Insertion Title") + settingsViewController.editTitle = NSLocalizedString("Edit IP or IP Range", + comment: "IP Address or Range Edition Title") + settingsViewController.footerText = NSLocalizedString("You may allowlist an IP address or series of addresses preventing them from ever being blocked by Jetpack. IPv4 and IPv6 are acceptable. To specify a range, enter the low value and high value separated by a dash. Example: 12.12.12.1-12.12.12.100.", + comment: "Text rendered at the bottom of the Allowlisted IP Addresses editor, should match Calypso.") + + settingsViewController.onChange = { [weak self] (updated: Set<String>) in + self?.settings.jetpackLoginAllowListedIPAddresses = updated + guard let blog = self?.blog else { + return + } + self?.service.updateJetpackSettingsForBlog(blog, + success: { [weak self] in + // viewWillAppear will trigger a refresh, maybe before + // the new IPs are saved, so lets refresh again here + self?.refreshSettings() + WPAnalytics.track(.jetpackAllowlistedIpsChanged) + }, + failure: { [weak self] (_) in + self?.refreshSettingsAfterSavingError() + }) + } + self.navigationController?.pushViewController(settingsViewController, animated: true) + WPAnalytics.track(.jetpackAllowlistedIpsViewed) + } + } + + 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, + success: {}, + failure: { [weak self] (_) in + self?.refreshSettingsAfterSavingError() + }) + } + } + + 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: {}, + failure: { [weak self] (_) in + self?.refreshSettingsAfterSavingError() + }) + } + } + + 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: {}, + failure: { [weak self] (_) in + self?.refreshSettingsAfterSavingError() + }) + } + } + + fileprivate func pressedManageConnection() -> ImmuTableAction { + return { [unowned self] row in + WPAnalytics.trackEvent(.jetpackManageConnectionViewed) + let jetpackConnectionVC = JetpackConnectionViewController(blog: blog) + jetpackConnectionVC.delegate = self + self.navigationController?.pushViewController(jetpackConnectionVC, animated: true) + } + } + + // MARK: - Footer handler + + @objc fileprivate func handleLearnMoreTap(_ sender: UITapGestureRecognizer) { + guard let url = URL(string: JetpackSettingsViewController.learnMoreUrl) else { + return + } + let webViewController = WebViewControllerFactory.controller(url: url, source: "jetpack_settings_learn_more") + + if presentingViewController != nil { + navigationController?.pushViewController(webViewController, animated: true) + } else { + let navController = UINavigationController(rootViewController: webViewController) + present(navController, animated: true) + } + } + + // MARK: - Persistance + + fileprivate func refreshSettings() { + service.syncJetpackSettingsForBlog(blog, + success: { [weak self] in + guard self?.blog?.settings != nil else { + return + } + self?.reloadViewModel() + DDLogInfo("Reloaded Jetpack Settings") + }, + failure: { (error: Error?) in + DDLogError("Error while syncing blog Jetpack Settings: \(String(describing: error))") + }) + } + + fileprivate func refreshSettingsAfterSavingError() { + let errorTitle = NSLocalizedString("Error updating Jetpack settings", + comment: "Title of error dialog when updating jetpack settins fail.") + let errorMessage = NSLocalizedString("Please contact support for assistance.", + comment: "Message displayed on an error alert to prompt the user to contact support") + WPError.showAlert(withTitle: errorTitle, message: errorMessage, withSupportButton: true) + refreshSettings() + } + +} + +extension JetpackSettingsViewController: JetpackConnectionDelegate { + func jetpackDisconnectedForBlog(_ blog: Blog) { + if blog == self.blog { + navigationController?.popToRootViewController(animated: true) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Blog/Site Settings/JetpackSpeedUpSiteSettingsViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSpeedUpSiteSettingsViewController.swift similarity index 94% rename from WordPress/Classes/ViewRelated/Blog/Site Settings/JetpackSpeedUpSiteSettingsViewController.swift rename to WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSpeedUpSiteSettingsViewController.swift index 1d413d7fc5ef..914aab4e29a2 100644 --- a/WordPress/Classes/ViewRelated/Blog/Site Settings/JetpackSpeedUpSiteSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Jetpack Settings/JetpackSpeedUpSiteSettingsViewController.swift @@ -24,9 +24,9 @@ open class JetpackSpeedUpSiteSettingsViewController: UITableViewController { // MARK: - Initializer @objc public convenience init(blog: Blog) { - self.init(style: .grouped) + self.init(style: .insetGrouped) self.blog = blog - self.service = BlogJetpackSettingsService(managedObjectContext: settings.managedObjectContext!) + self.service = BlogJetpackSettingsService(coreDataStack: ContextManager.shared) } // MARK: - View Lifecycle @@ -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/Jetpack/Login/JetpackLoginViewController.swift b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackLoginViewController.swift index d1bee85dcf5d..4d7d11fd9e9a 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackLoginViewController.swift +++ b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackLoginViewController.swift @@ -27,20 +27,11 @@ class JetpackLoginViewController: UIViewController { @IBOutlet fileprivate weak var jetpackImage: UIImageView! @IBOutlet fileprivate weak var descriptionLabel: UILabel! @IBOutlet fileprivate weak var signinButton: WPNUXMainButton! + @IBOutlet fileprivate weak var connectUserButton: NUXButton! @IBOutlet fileprivate weak var installJetpackButton: WPNUXMainButton! @IBOutlet private var tacButton: UIButton! @IBOutlet private var faqButton: UIButton! - /// Returns true if the blog has the proper version of Jetpack installed, - /// otherwise false - /// - fileprivate var hasJetpack: Bool { - guard let jetpack = blog.jetpack else { - return false - } - return (jetpack.isConnected && jetpack.isUpdatedToRequiredVersion) - } - // MARK: - Initializers /// Required initializer for JetpackLoginViewController @@ -60,7 +51,7 @@ class JetpackLoginViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .neutral(.shade5) + WPStyleGuide.configureColors(view: view, tableView: nil) setupControls() } @@ -78,7 +69,7 @@ class JetpackLoginViewController: UIViewController { toggleHidingImageView(for: traitCollection) descriptionLabel.font = WPStyleGuide.fontForTextStyle(.body) - descriptionLabel.textColor = .neutral(.shade70) + descriptionLabel.textColor = .text tacButton.titleLabel?.numberOfLines = 0 @@ -121,32 +112,40 @@ class JetpackLoginViewController: UIViewController { // MARK: - UI Helpers func updateMessageAndButton() { - guard let jetPack = blog.jetpack else { + guard let jetpack = blog.jetpack else { return } var message: String - if jetPack.isConnected { - message = jetPack.isUpdatedToRequiredVersion ? Constants.Jetpack.isUpdated : Constants.Jetpack.updateRequired + if jetpack.isSiteConnection { + message = promptType.connectMessage + } else if jetpack.isConnected { + message = jetpack.isUpdatedToRequiredVersion ? Constants.Jetpack.isUpdated : Constants.Jetpack.updateRequired } else { - message = promptType.message + message = promptType.installMessage } + descriptionLabel.text = message descriptionLabel.sizeToFit() installJetpackButton.setTitle(Constants.Buttons.jetpackInstallTitle, for: .normal) - installJetpackButton.isHidden = hasJetpack + installJetpackButton.isHidden = blog.hasJetpack installJetpackButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 20, bottom: 12, right: 20) + connectUserButton.setTitle(Constants.Buttons.connectUserTitle, for: .normal) + connectUserButton.isHidden = !(blog.hasJetpack && jetpack.isSiteConnection) + connectUserButton.titleLabel?.numberOfLines = 2 + connectUserButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 20, bottom: 12, right: 20) + signinButton.setTitle(Constants.Buttons.loginTitle, for: .normal) - signinButton.isHidden = !hasJetpack + signinButton.isHidden = !(blog.hasJetpack && !jetpack.isSiteConnection) let paragraph = NSMutableParagraphStyle(minLineHeight: WPStyleGuide.fontSizeForTextStyle(.footnote), lineBreakMode: .byWordWrapping, alignment: .center) let attributes: [NSAttributedString.Key: Any] = [.font: WPStyleGuide.fontForTextStyle(.footnote), - .foregroundColor: UIColor.neutral(.shade70), + .foregroundColor: UIColor.textSubtle, .paragraphStyle: paragraph] let attributedTitle = NSMutableAttributedString(string: Constants.Buttons.termsAndConditionsTitle, attributes: attributes) @@ -178,7 +177,7 @@ class JetpackLoginViewController: UIViewController { fileprivate func signIn() { observeLoginNotifications(true) - WordPressAuthenticator.showLoginForJustWPCom(from: self, xmlrpc: blog.xmlrpc, username: blog.username, connectedEmail: blog.jetpack?.connectedEmail) + WordPressAuthenticator.showLoginForJustWPCom(from: self, jetpackLogin: true, connectedEmail: blog.jetpack?.connectedEmail) } fileprivate func trackStat(_ stat: WPAnalyticsStat, blog: Blog? = nil) { @@ -202,7 +201,7 @@ class JetpackLoginViewController: UIViewController { return } - let webviewViewController = WebViewControllerFactory.controller(url: url) + let webviewViewController = WebViewControllerFactory.controller(url: url, source: "jetpack_login") let navigationViewController = UINavigationController(rootViewController: webviewViewController) present(navigationViewController, animated: true, completion: nil) } @@ -220,9 +219,7 @@ class JetpackLoginViewController: UIViewController { private func openJetpackRemoteInstall() { trackStat(.selectedInstallJetpack) - let controller = JetpackRemoteInstallViewController(blog: blog, - delegate: self, - promptType: promptType) + let controller = JetpackRemoteInstallViewController(blog: blog, delegate: self) let navController = UINavigationController(rootViewController: controller) navController.modalPresentationStyle = .fullScreen present(navController, animated: true) @@ -238,6 +235,10 @@ class JetpackLoginViewController: UIViewController { openJetpackRemoteInstall() } + @IBAction func didTouchConnectUserAccountButton(_ sender: Any) { + openInstallJetpackURL() + } + @IBAction func didTouchTacButton(_ sender: Any) { openWebView(for: .tac) } @@ -287,7 +288,7 @@ public enum JetpackLoginPromptType { } } - var message: String { + var installMessage: String { switch self { case .stats: return NSLocalizedString("To use stats on your site, you'll need to install the Jetpack plugin.", @@ -297,6 +298,19 @@ public enum JetpackLoginPromptType { comment: "Message asking the user if they want to set up Jetpack from notifications") } } + + var connectMessage: String { + switch self { + case .stats: + return NSLocalizedString("jetpack.install.connectUser.stats.description", + value: "To use stats on your site, you'll need to connect the Jetpack plugin to your user account.", + comment: "Message asking the user if they want to set up Jetpack from stats by connecting their user account") + case .notifications: + return NSLocalizedString("jetpack.install.connectUser.notifications.description", + value: "To get helpful notifications on your phone from your WordPress site, you'll need to connect to your user account.", + comment: "Message asking the user if they want to set up Jetpack from notifications") + } + } } private enum JetpackWebviewType { @@ -321,6 +335,7 @@ private enum Constants { static let faqTitle = NSLocalizedString("Jetpack FAQ", comment: "Title of the button which opens the Jetpack FAQ page.") static let jetpackInstallTitle = NSLocalizedString("Install Jetpack", comment: "Title of a button for Jetpack Installation.") static let loginTitle = NSLocalizedString("Log in", comment: "Title of a button for signing in.") + static let connectUserTitle = NSLocalizedString("jetpack.install.connectUser.button.title", value: "Connect your user account", comment: "Title of a button for connecting user account to Jetpack.") } enum Jetpack { diff --git a/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackLoginViewController.xib b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackLoginViewController.xib index f2917f4c90e0..434ed3b09271 100644 --- a/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackLoginViewController.xib +++ b/WordPress/Classes/ViewRelated/Jetpack/Login/JetpackLoginViewController.xib @@ -1,16 +1,15 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina5_9" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> + <device id="retina5_9" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="JetpackLoginViewController" customModule="WordPress" customModuleProvider="target"> <connections> + <outlet property="connectUserButton" destination="VTV-9i-gcK" id="0bg-yr-qg2"/> <outlet property="descriptionLabel" destination="5EX-8b-0NL" id="D6j-Vu-Grr"/> <outlet property="faqButton" destination="qJb-qz-DEY" id="3Bw-KJ-XM8"/> <outlet property="installJetpackButton" destination="Qm1-Pi-98R" id="nNU-KI-fYA"/> @@ -26,7 +25,7 @@ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> <stackView autoresizesSubviews="NO" opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="equalSpacing" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="wZ5-Fs-qlA"> - <rect key="frame" x="30" y="203" width="315" height="406"/> + <rect key="frame" x="30" y="186" width="315" height="440"/> <subviews> <imageView userInteractionEnabled="NO" contentMode="center" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="wp-illustration-stats" translatesAutoresizingMaskIntoConstraints="NO" id="GNi-Lj-sLf"> <rect key="frame" x="0.0" y="0.0" width="315" height="154"/> @@ -39,7 +38,7 @@ <nil key="highlightedColor"/> </label> <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="iCv-gL-vpC" userLabel="Main buttons"> - <rect key="frame" x="0.0" y="230" width="315" height="68"/> + <rect key="frame" x="0.0" y="230" width="315" height="102"/> <subviews> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="lMy-o2-gd3" customClass="NUXButton" customModule="WordPressAuthenticator"> <rect key="frame" x="133" y="0.0" width="49" height="34"/> @@ -54,7 +53,7 @@ </connections> </button> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Qm1-Pi-98R" customClass="NUXButton" customModule="WordPressAuthenticator"> - <rect key="frame" x="96.666666666666686" y="34" width="122" height="34"/> + <rect key="frame" x="97" y="34" width="121" height="34"/> <state key="normal" title="Set up Jetpack"> <color key="titleColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> </state> @@ -65,11 +64,24 @@ <action selector="didTouchInstallJetpackButton:" destination="-1" eventType="touchUpInside" id="jig-Gw-45O"/> </connections> </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="VTV-9i-gcK" userLabel="Connect your user accont" customClass="NUXButton" customModule="WordPressAuthenticator"> + <rect key="frame" x="48.666666666666686" y="68" width="218" height="34"/> + <state key="normal" title="Connect your user account"> + <color key="titleColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </state> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="YES"/> + </userDefinedRuntimeAttributes> + <connections> + <action selector="didTouchConnectUserAccountButton:" destination="-1" eventType="touchUpInside" id="rbB-O7-30R"/> + <action selector="didTouchInstallJetpackButton:" destination="-1" eventType="touchUpInside" id="o2L-ud-Obn"/> + </connections> + </button> </subviews> <edgeInsets key="layoutMargins" top="0.0" left="20" bottom="0.0" right="20"/> </stackView> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qvD-Ju-Pvz"> - <rect key="frame" x="0.0" y="318" width="315" height="34"/> + <rect key="frame" x="0.0" y="352" width="315" height="34"/> <fontDescription key="fontDescription" name=".AppleSystemUIFont" family=".AppleSystemUIFont" pointSize="18"/> <state key="normal" title="Terms and conditions"> <color key="titleColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> @@ -79,7 +91,7 @@ </connections> </button> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qJb-qz-DEY"> - <rect key="frame" x="0.0" y="372" width="315" height="34"/> + <rect key="frame" x="0.0" y="406" width="315" height="34"/> <state key="normal" title="FAQ"> <color key="titleColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> </state> diff --git a/WordPress/Classes/ViewRelated/Likes/LikesListController.swift b/WordPress/Classes/ViewRelated/Likes/LikesListController.swift new file mode 100644 index 000000000000..5f0b99ea7b09 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Likes/LikesListController.swift @@ -0,0 +1,426 @@ +import Foundation +import UIKit +import WordPressKit + + +/// Convenience class that manages the data and display logic for likes. +/// This is intended to be used as replacement for table view delegate and data source. + + +@objc protocol LikesListControllerDelegate: AnyObject { + /// Reports to the delegate that the header cell has been tapped. + @objc optional func didSelectHeader() + + /// Reports to the delegate that the user cell has been tapped. + /// - Parameter user: A LikeUser instance representing the user at the selected row. + func didSelectUser(_ user: LikeUser, at indexPath: IndexPath) + + /// Ask the delegate to show an error view when fetching fails or there is no connection. + func showErrorView(title: String, subtitle: String?) + + /// Send likes count to delegate. + @objc optional func updatedTotalLikes(_ totalLikes: Int) +} + +class LikesListController: NSObject { + + private let formatter = FormattableContentFormatter() + private let content: ContentIdentifier + private let siteID: NSNumber + private var notification: Notification? = nil + private var readerPost: ReaderPost? = nil + private let tableView: UITableView + private var loadingIndicator = UIActivityIndicatorView() + private weak var delegate: LikesListControllerDelegate? + + // Used to control pagination. + private var isFirstLoad = true + private var totalLikes = 0 + private var totalLikesFetched = 0 + private var lastFetchedDate: String? + private var excludeUserIDs: [NSNumber]? + + private let errorTitle = NSLocalizedString("Error loading likes", + comment: "Text displayed when there is a failure loading notification likes.") + + private var hasMoreLikes: Bool { + return totalLikesFetched < totalLikes + } + + private var isLoadingContent = false { + didSet { + if isLoadingContent != oldValue { + isLoadingContent ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating() + // Refresh the footer view's frame + tableView.tableFooterView = loadingIndicator + } + } + } + + private var likingUsers: [LikeUser] = [] { + didSet { + tableView.reloadData() + } + } + + private lazy var postService: PostService = { + PostService(managedObjectContext: ContextManager.shared.mainContext) + }() + + private lazy var commentService: CommentService = { + CommentService(coreDataStack: ContextManager.shared) + }() + + // Notification Likes has a table header. Post Likes does not. + // Thus this is used to determine table layout depending on which is being shown. + private var showingNotificationLikes: Bool { + return notification != nil + } + + private var usersSectionIndex: Int { + return showingNotificationLikes ? 1 : 0 + } + + private var numberOfSections: Int { + return showingNotificationLikes ? 2 : 1 + } + + // MARK: Init + + /// Init with Notification + /// + init?(tableView: UITableView, notification: Notification, delegate: LikesListControllerDelegate? = nil) { + + guard let siteID = notification.metaSiteID else { + return nil + } + + switch notification.kind { + case .like: + // post likes + guard let postID = notification.metaPostID else { + return nil + } + content = .post(id: postID) + + case .commentLike: + // comment likes + guard let commentID = notification.metaCommentID else { + return nil + } + content = .comment(id: commentID) + + default: + // other notification kinds are not supported + return nil + } + + self.notification = notification + self.siteID = siteID + self.tableView = tableView + self.delegate = delegate + + super.init() + configureLoadingIndicator() + } + + /// Init with ReaderPost + /// + init?(tableView: UITableView, post: ReaderPost, delegate: LikesListControllerDelegate? = nil) { + + guard let postID = post.postID else { + return nil + } + + content = .post(id: postID) + readerPost = post + siteID = post.siteID + self.tableView = tableView + self.delegate = delegate + + super.init() + configureLoadingIndicator() + } + + private func configureLoadingIndicator() { + loadingIndicator = UIActivityIndicatorView(style: .medium) + loadingIndicator.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44) + } + + // MARK: Methods + + /// Load likes data from remote, and display it in the table view. + func refresh() { + + guard !isLoadingContent else { + return + } + + isLoadingContent = true + + if isFirstLoad { + fetchStoredLikes() + } + + guard ReachabilityUtils.isInternetReachable() else { + isLoadingContent = false + + if likingUsers.isEmpty { + delegate?.showErrorView(title: errorTitle, subtitle: nil) + } + + return + } + + fetchLikes(success: { [weak self] users, totalLikes, likesPerPage in + guard let self = self else { + return + } + + if self.isFirstLoad { + self.delegate?.updatedTotalLikes?(totalLikes) + } + + self.likingUsers = users + self.totalLikes = totalLikes + self.totalLikesFetched = users.count + self.lastFetchedDate = users.last?.dateLikedString + + if !self.isFirstLoad && !users.isEmpty { + self.trackFetched(likesPerPage: likesPerPage) + } + + self.isFirstLoad = false + self.isLoadingContent = false + self.trackUsersToExclude() + }, failure: { [weak self] error in + guard let self = self else { + return + } + + let errorMessage: String? = { + // Get error message from API response if provided. + if let error = error, + let message = (error as NSError).userInfo[WordPressComRestApi.ErrorKeyErrorMessage] as? String, + !message.isEmpty { + return message + } + return nil + }() + + self.isLoadingContent = false + self.delegate?.showErrorView(title: self.errorTitle, subtitle: errorMessage) + }) + } + + private func trackFetched(likesPerPage: Int) { + var properties: [String: Any] = [:] + properties["source"] = showingNotificationLikes ? "notifications" : "reader" + properties["per_page"] = likesPerPage + + if likesPerPage > 0 { + properties["page"] = Int(ceil(Double(likingUsers.count) / Double(likesPerPage))) + } + + WPAnalytics.track(.likeListFetchedMore, properties: properties) + } + + /// Fetch Likes from Core Data depending on the notification's content type. + private func fetchStoredLikes() { + switch content { + case .post(let postID): + likingUsers = postService.likeUsersFor(postID: postID, siteID: siteID) + case .comment(let commentID): + likingUsers = commentService.likeUsersFor(commentID: commentID, siteID: siteID) + } + } + + /// Fetch Likes depending on the notification's content type. + /// - Parameters: + /// - success: Closure to be called when the fetch is successful. + /// - failure: Closure to be called when the fetch failed. + private func fetchLikes(success: @escaping ([LikeUser], Int, Int) -> Void, failure: @escaping (Error?) -> Void) { + + var beforeStr = lastFetchedDate + + if beforeStr != nil, + let modifiedDate = modifiedBeforeDate() { + // The endpoints expect a format like YYYY-MM-DD HH:MM:SS. It isn't expecting the T or Z, hence the replacingMatches calls. + beforeStr = ISO8601DateFormatter().string(from: modifiedDate).replacingMatches(of: "T", with: " ").replacingMatches(of: "Z", with: "") + } + + switch content { + case .post(let postID): + postService.getLikesFor(postID: postID, + siteID: siteID, + before: beforeStr, + excludingIDs: excludeUserIDs, + purgeExisting: isFirstLoad, + success: success, + failure: failure) + case .comment(let commentID): + commentService.getLikesFor(commentID: commentID, + siteID: siteID, + before: beforeStr, + excludingIDs: excludeUserIDs, + purgeExisting: isFirstLoad, + success: success, + failure: failure) + } + } + + // There is a scenario where multiple users might like a post/comment at the same time, + // and then end up split between pages of results. So we'll track which users we've already + // fetched for the lastFetchedDate, and send those to the endpoints to filter out of the response + // so we don't get duplicates or gaps. + private func trackUsersToExclude() { + guard let modifiedDate = modifiedBeforeDate() else { + return + } + + var fetchedUsers = [LikeUser]() + switch content { + case .post(let postID): + fetchedUsers = postService.likeUsersFor(postID: postID, siteID: siteID, after: modifiedDate) + case .comment(let commentID): + fetchedUsers = commentService.likeUsersFor(commentID: commentID, siteID: siteID, after: modifiedDate) + } + + excludeUserIDs = fetchedUsers.map { NSNumber(value: $0.userID) } + } + + private func modifiedBeforeDate() -> Date? { + guard let lastDate = likingUsers.last?.dateLiked else { + return nil + } + + return Calendar.current.date(byAdding: .second, value: 1, to: lastDate) + } + +} + +// MARK: - Table View Related + +extension LikesListController: UITableViewDataSource, UITableViewDelegate { + + func numberOfSections(in tableView: UITableView) -> Int { + return numberOfSections + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + // Header section + if showingNotificationLikes && section == Constants.headerSectionIndex { + return Constants.numberOfHeaderRows + } + + // Users section + return likingUsers.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if showingNotificationLikes && indexPath.section == Constants.headerSectionIndex { + return headerCell() + } + + return userCell(for: indexPath) + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let isUsersSection = indexPath.section == usersSectionIndex + let isLastRow = indexPath.row == totalLikesFetched - 1 + + guard !isLoadingContent && hasMoreLikes && isUsersSection && isLastRow else { + return + } + + refresh() + } + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return LikeUserTableViewCell.estimatedRowHeight + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + if showingNotificationLikes && indexPath.section == Constants.headerSectionIndex { + delegate?.didSelectHeader?() + return + } + + guard !isLoadingContent, + let user = likingUsers[safe: indexPath.row] else { + return + } + + delegate?.didSelectUser(user, at: indexPath) + } + +} + +// MARK: - Notification Cell Handling + +private extension LikesListController { + + func headerCell() -> NoteBlockHeaderTableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: NoteBlockHeaderTableViewCell.reuseIdentifier()) as? NoteBlockHeaderTableViewCell, + let group = notification?.headerAndBodyContentGroups[Constants.headerRowIndex] else { + DDLogError("Error: couldn't get a header cell or FormattableContentGroup.") + return NoteBlockHeaderTableViewCell() + } + + setupHeaderCell(cell: cell, group: group) + return cell + } + + func setupHeaderCell(cell: NoteBlockHeaderTableViewCell, group: FormattableContentGroup) { + cell.attributedHeaderTitle = nil + cell.attributedHeaderDetails = nil + + guard let gravatarBlock: NotificationTextContent = group.blockOfKind(.image), + let snippetBlock: NotificationTextContent = group.blockOfKind(.text) else { + return + } + + cell.attributedHeaderTitle = formatter.render(content: gravatarBlock, with: HeaderContentStyles()) + cell.attributedHeaderDetails = formatter.render(content: snippetBlock, with: HeaderDetailsContentStyles()) + + // Download the Gravatar + let mediaURL = gravatarBlock.media.first?.mediaURL + cell.downloadAuthorAvatar(with: mediaURL) + } + + func userCell(for indexPath: IndexPath) -> UITableViewCell { + guard let user = likingUsers[safe: indexPath.row], + let cell = tableView.dequeueReusableCell(withIdentifier: LikeUserTableViewCell.defaultReuseID) as? LikeUserTableViewCell else { + DDLogError("Failed dequeueing LikeUserTableViewCell") + return UITableViewCell() + } + + cell.configure(withUser: user, isLastRow: (indexPath.row == likingUsers.endIndex - 1)) + return cell + } + +} + +// MARK: - Private Definitions + +private extension LikesListController { + + /// Convenient type that categorizes notification content and its ID. + enum ContentIdentifier { + case post(id: NSNumber) + case comment(id: NSNumber) + } + + struct Constants { + static let headerSectionIndex = 0 + static let headerRowIndex = 0 + static let numberOfHeaderRows = 1 + } + +} diff --git a/WordPress/Classes/ViewRelated/Me/Account Settings/AccountSettingsViewController.swift b/WordPress/Classes/ViewRelated/Me/Account Settings/AccountSettingsViewController.swift index 0a14a8edbc84..4ec7206092bb 100644 --- a/WordPress/Classes/ViewRelated/Me/Account Settings/AccountSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/Account Settings/AccountSettingsViewController.swift @@ -8,29 +8,37 @@ func AccountSettingsViewController(account: WPAccount) -> ImmuTableViewControlle return nil } let service = AccountSettingsService(userID: account.userID.intValue, api: api) - return AccountSettingsViewController(service: service) + return AccountSettingsViewController(accountSettingsService: service) } -func AccountSettingsViewController(service: AccountSettingsService) -> ImmuTableViewController { - let controller = AccountSettingsController(service: service) - let viewController = ImmuTableViewController(controller: controller) +func AccountSettingsViewController(accountSettingsService: AccountSettingsService) -> ImmuTableViewController { + let controller = AccountSettingsController(accountSettingsService: accountSettingsService) + let viewController = ImmuTableViewController(controller: controller, style: .insetGrouped) + viewController.handler.automaticallyDeselectCells = true return viewController } private class AccountSettingsController: SettingsController { + var trackingKey: String { + return "account_settings" + } + let title = NSLocalizedString("Account Settings", comment: "Account Settings Title") var immuTableRows: [ImmuTableRow.Type] { return [ TextRow.self, - EditableTextRow.self + EditableTextRow.self, + DestructiveButtonRow.self ] } // MARK: - Initialization - let service: AccountSettingsService + private let accountSettingsService: AccountSettingsService + private let accountService: AccountService + var settings: AccountSettings? { didSet { NotificationCenter.default.post(name: Foundation.Notification.Name(rawValue: ImmuTableViewController.modelChangedNotification), object: nil) @@ -41,9 +49,12 @@ private class AccountSettingsController: SettingsController { NotificationCenter.default.post(name: Foundation.Notification.Name(rawValue: ImmuTableViewController.modelChangedNotification), object: nil) } } + private let alertHelper = DestructiveAlertHelper() - init(service: AccountSettingsService) { - self.service = service + init(accountSettingsService: AccountSettingsService, + accountService: AccountService = AccountService(coreDataStack: ContextManager.sharedInstance())) { + self.accountSettingsService = accountSettingsService + self.accountService = accountService let notificationCenter = NotificationCenter.default notificationCenter.addObserver(self, selector: #selector(AccountSettingsController.loadStatus), name: NSNotification.Name.AccountSettingsServiceRefreshStatusChanged, object: nil) notificationCenter.addObserver(self, selector: #selector(AccountSettingsController.loadSettings), name: NSNotification.Name.AccountSettingsChanged, object: nil) @@ -51,15 +62,15 @@ private class AccountSettingsController: SettingsController { } func refreshModel() { - service.refreshSettings() + accountSettingsService.refreshSettings() } @objc func loadStatus() { - noticeMessage = service.status.errorMessage ?? noticeForAccountSettings(service.settings) + noticeMessage = accountSettingsService.status.errorMessage ?? noticeForAccountSettings(accountSettingsService.settings) } @objc func loadSettings() { - settings = service.settings + settings = accountSettingsService.settings // Status is affected by settings changes (for pending email), so let's load that as well loadStatus() } @@ -68,7 +79,7 @@ private class AccountSettingsController: SettingsController { // MARK: - ImmuTableViewController func tableViewModelWithPresenter(_ presenter: ImmuTablePresenter) -> ImmuTable { - return mapViewModel(settings, service: service, presenter: presenter) + return mapViewModel(settings, service: accountSettingsService, presenter: presenter) } @@ -84,41 +95,52 @@ 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 ?? "", - action: presenter.push(editEmailAddress(settings, service: service)) + accessoryImage: emailAccessoryImage(), + action: presenter.push(editEmailAddress(settings, service: service)), + fieldName: "email" ) var primarySiteName = settings.flatMap { service.primarySiteNameForSettings($0) } ?? "" // If the primary site has no Site Title, then show the displayURL. if primarySiteName.isEmpty { - let blogService = BlogService(managedObjectContext: ContextManager.sharedInstance().mainContext) - primarySiteName = blogService.primaryBlog()?.displayURL as String? ?? "" + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.sharedInstance().mainContext) + primarySiteName = account?.defaultBlog?.displayURL as String? ?? "" } 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( + title: NSLocalizedString("Close Account", comment: "Close account action label"), + action: closeAccountAction, + accessibilityIdentifier: "closeAccountButtonRow") + return ImmuTable(sections: [ ImmuTableSection( rows: [ @@ -127,11 +149,14 @@ private class AccountSettingsController: SettingsController { password, primarySite, webAddress + ]), + ImmuTableSection( + rows: [ + closeAccount ]) - ]) + ]) } - // MARK: - Actions func editEmailAddress(_ settings: AccountSettings?, service: AccountSettingsService) -> (ImmuTableRow) -> SettingsTextViewController { @@ -187,12 +212,10 @@ private class AccountSettingsController: SettingsController { } func refreshAccountDetails(finished: @escaping () -> Void) { - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - guard let account = service.defaultWordPressComAccount() else { + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) else { return } - service.updateUserDetails(for: account, success: { () in + accountService.updateUserDetails(for: account, success: { () in finished() }, failure: { _ in finished() @@ -211,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) } @@ -227,6 +252,138 @@ 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) + case false: + self.showCloseAccountAlert() + } + } + } + + private var hasAtomicSite: Bool { + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) + return account?.hasAtomicSite() ?? false + } + + private func showCloseAccountAlert() { + guard let value = settings?.username else { + return + } + + let title = NSLocalizedString("Confirm Close Account", comment: "Close Account alert title") + let message = NSLocalizedString("\nTo confirm, please re-enter your username before closing.\n\n", + comment: "Message of Close Account confirmation alert") + let destructiveActionTitle = NSLocalizedString("Permanently Close Account", + comment: "Close Account confirmation action title") + + let alert = alertHelper.makeAlertWithConfirmation(title: title, message: message, valueToConfirm: value, destructiveActionTitle: destructiveActionTitle, destructiveAction: closeAccount) + alert.presentFromRootViewController() + } + + private func closeAccount() { + let status = NSLocalizedString("Closing account…", comment: "Overlay message displayed while closing account") + SVProgressHUD.setDefaultMaskType(.black) + SVProgressHUD.show(withStatus: status) + + accountSettingsService.closeAccount { [weak self] in + 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)) + } + } + } + + private func showCloseAccountErrorAlert(message: String) { + let title = NSLocalizedString("Couldn’t close account automatically", + comment: "Error title displayed when unable to close user account.") + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + + let contactSupportTitle = NSLocalizedString("Contact Support", + comment: "Title for a button displayed when unable to close user account due to having atomic site.") + alert.addActionWithTitle(contactSupportTitle, style: .default, handler: contactSupportAction) + let cancelAction = NSLocalizedString("Cancel", comment: "Alert dismissal title") + alert.addCancelActionWithTitle(cancelAction) + + alert.presentFromRootViewController() + } + + 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.", + comment: "Error message displayed when unable to close user account due to being unauthorized.") + case "atomic-site": + return localizedErrorMessageForAtomicSites + case "chargebacked-site": + return NSLocalizedString("This user account cannot be closed if there are unresolved chargebacks.", + comment: "Error message displayed when unable to close user account due to unresolved chargebacks.") + case "active-subscriptions": + return NSLocalizedString("This user account cannot be closed while it has active subscriptions.", + comment: "Error message displayed when unable to close user account due to having active subscriptions.") + case "active-memberships": + return NSLocalizedString("This user account cannot be closed while it has active purchases.", + comment: "Error message displayed when unable to close user account due to having active purchases.") + default: + return NSLocalizedString("An error occured while closing account.", + comment: "Default error message displayed when unable to close user account.") + } + } + + private var localizedErrorMessageForAtomicSites: String { + // Based on https://github.com/Automattic/wp-calypso/pull/65780 + NSLocalizedString( + "accountSettings.closeAccount.error.atomicSite", + value: "This user account cannot be closed immediately because it has active purchases. Please contact our support team to finish deleting the account.", + comment: "Error message displayed when unable to close user account due to having active atomic site." + ) + } + + private var contactSupportAction: ((UIAlertAction) -> Void) { + return { action in + if ZendeskUtils.zendeskEnabled { + guard let leafViewController = UIApplication.shared.leafViewController else { + return + } + ZendeskUtils.sharedInstance.showNewRequestIfPossible(from: leafViewController, with: .closeAccount) { [weak self] identityUpdated in + if identityUpdated { + self?.refreshModel() + } + } + } else { + guard let url = Constants.forumsURL else { + return + } + UIApplication.shared.open(url) + } + } + } + @objc fileprivate func showSettingsChangeErrorMessage(notification: NSNotification) { guard let error = notification.userInfo?[NSUnderlyingErrorKey] as? NSError, let errorMessage = error.userInfo[WordPressComRestApi.ErrorKeyErrorMessage] as? String else { @@ -238,7 +395,8 @@ private class AccountSettingsController: SettingsController { // MARK: - Private Helpers fileprivate func noticeForAccountSettings(_ settings: AccountSettings?) -> String? { - guard let pendingAddress = settings?.emailPendingAddress, settings?.emailPendingChange == true else { + guard settings?.emailPendingChange == true, + let pendingAddress = settings?.emailPendingAddress else { return nil } @@ -248,6 +406,13 @@ private class AccountSettingsController: SettingsController { return String(format: localizedNotice, pendingAddress) } + fileprivate func emailAccessoryImage() -> UIImage? { + guard settings?.emailPendingChange == true else { + return nil + } + + return UIImage.gridicon(.noticeOutline).imageWithTintColor(.error) + } // MARK: - Constants @@ -257,5 +422,6 @@ private class AccountSettingsController: SettingsController { static let changedPasswordSuccess = NSLocalizedString("Password changed successfully", comment: "Loader title displayed by the loading view while the password is changed successfully") static let changePasswordGenericError = NSLocalizedString("There was an error changing the password", comment: "Text displayed when there is a failure loading the history.") static let usernameChanged = NSLocalizedString("Username changed to %@", comment: "Message displayed in a Notice when the username has changed successfully. The placeholder is the new username.") + static let forumsURL = URL(string: "https://ios.forums.wordpress.org") } } diff --git a/WordPress/Classes/ViewRelated/Me/Account Settings/ChangePasswordViewController.swift b/WordPress/Classes/ViewRelated/Me/Account Settings/ChangePasswordViewController.swift index 9fc95d324b49..ec362db124dd 100644 --- a/WordPress/Classes/ViewRelated/Me/Account Settings/ChangePasswordViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/Account Settings/ChangePasswordViewController.swift @@ -13,7 +13,7 @@ class ChangePasswordViewController: SettingsTextViewController, UITextFieldDeleg }() convenience init(username: String, onSaveActionPress: @escaping ChangePasswordSaveAction) { - self.init(text: "", placeholder: "\(Constants.title)...", hint: Constants.description) + self.init(text: "", placeholder: "\(Constants.placeholder)", hint: Constants.description) self.onSaveActionPress = onSaveActionPress self.username = username } @@ -79,6 +79,7 @@ class ChangePasswordViewController: SettingsTextViewController, UITextFieldDeleg static let title = NSLocalizedString("Change Password", comment: "Main title") static let description = NSLocalizedString("Your password should be at least six characters long. To make it stronger, use upper and lower case letters, numbers, and symbols like ! \" ? $ % ^ & ).", comment: "Help text that describes how the password should be. It appears while editing the password") static let actionButtonTitle = NSLocalizedString("Save", comment: "Settings Text save button title") + static let placeholder = NSLocalizedString("New password", comment: "Placeholder text for password field") } } 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/AboutViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutViewController.swift deleted file mode 100644 index be4f50b90879..000000000000 --- a/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutViewController.swift +++ /dev/null @@ -1,251 +0,0 @@ -import Foundation -import WordPressShared -// FIXME: comparison operators with optionals were removed from the Swift Standard Libary. -// Consider refactoring the code to use the non-optional operators. -fileprivate func < <T: Comparable>(lhs: T?, rhs: T?) -> Bool { - switch (lhs, rhs) { - case let (l?, r?): - return l < r - case (nil, _?): - return true - default: - return false - } -} - -// FIXME: comparison operators with optionals were removed from the Swift Standard Libary. -// Consider refactoring the code to use the non-optional operators. -fileprivate func > <T: Comparable>(lhs: T?, rhs: T?) -> Bool { - switch (lhs, rhs) { - case let (l?, r?): - return l > r - default: - return rhs < lhs - } -} - - -open class AboutViewController: UITableViewController { - open override func viewDidLoad() { - super.viewDidLoad() - - setupNavigationItem() - setupTableView() - setupDismissButtonIfNeeded() - } - - - // MARK: - Private Helpers - fileprivate func setupNavigationItem() { - title = NSLocalizedString("About", comment: "About this app (information page title)") - - // Don't show 'About' in the next-view back button - navigationItem.backBarButtonItem = UIBarButtonItem(title: String(), style: .plain, target: nil, action: nil) - } - - fileprivate func setupTableView() { - // Load and Tint the Logo - let color = UIColor.primary - let tintedImage = UIImage(named: "icon-wp")?.withRenderingMode(.alwaysTemplate) - let imageView = UIImageView(image: tintedImage) - imageView.tintColor = color - imageView.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin] - imageView.contentMode = .top - - // Let's add a bottom padding! - imageView.frame.size.height += iconBottomPadding - - // Finally, setup the TableView - tableView.tableHeaderView = imageView - tableView.contentInset = WPTableViewContentInsets - - WPStyleGuide.configureColors(view: view, tableView: tableView) - WPStyleGuide.configureAutomaticHeightRows(for: tableView) - } - - fileprivate func setupDismissButtonIfNeeded() { - // Don't display a dismiss button, unless this is the only view in the stack! - if navigationController?.viewControllers.count > 1 { - return - } - - let title = NSLocalizedString("Close", comment: "Dismiss the current view") - let style = WPStyleGuide.barButtonStyleForBordered() - navigationItem.leftBarButtonItem = UIBarButtonItem(title: title, style: style, target: self, action: #selector(AboutViewController.dismissWasPressed(_:))) - } - - - - // MARK: - Button Helpers - @IBAction func dismissWasPressed(_ sender: AnyObject) { - dismiss(animated: true) - } - - - - // MARK: - UITableView Methods - open override func numberOfSections(in tableView: UITableView) -> Int { - return rows.count - } - - open override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return rows[section].count - } - - open override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - var cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier) - if cell == nil { - cell = WPTableViewCell(style: .value1, reuseIdentifier: reuseIdentifier) - } - - let row = rows[indexPath.section][indexPath.row] - - cell!.textLabel?.text = row.title - cell!.detailTextLabel?.text = row.details ?? String() - if row.handler != nil { - WPStyleGuide.configureTableViewActionCell(cell) - } else { - WPStyleGuide.configureTableViewCell(cell) - cell?.selectionStyle = .none - } - - return cell! - } - - open override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - if section != (rows.count - 1) { - return nil - } - return footerTitleText - } - - open override func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) { - WPStyleGuide.configureTableViewSectionFooter(view) - if let footerView = view as? UITableViewHeaderFooterView { - footerView.textLabel?.textAlignment = .center - } - } - - open override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectSelectedRowWithAnimation(true) - - if let handler = rows[indexPath.section][indexPath.row].handler { - handler() - } - } - - - - // MARK: - Private Helpers - fileprivate func displayWebView(_ urlString: String) { - displayWebView(URL(string: urlString)) - } - - private func displayWebView(_ url: URL?, title: String? = nil) { - guard let url = url else { - return - } - - if let title = title { - present(webViewController: WebViewControllerFactory.controller(url: url, title: title)) - } - else { - present(webViewController: WebViewControllerFactory.controller(url: url)) - } - } - - private func present(webViewController: UIViewController) { - let navController = UINavigationController(rootViewController: webViewController) - present(navController, animated: true) - } - - fileprivate func displayRatingPrompt() { - // Note: - // Let's follow the same procedure executed as in NotificationsViewController, so that if the user - // manually decides to rate the app, we don't render the prompt! - // - WPAnalytics.track(.appReviewsRatedApp) - AppRatingUtility.shared.ratedCurrentVersion() - UIApplication.shared.open(AppRatingUtility.shared.appReviewUrl) - } - - fileprivate func displayTwitterAccount() { - let twitterURL = URL(string: WPTwitterWordPressMobileURL)! - UIApplication.shared.open(twitterURL) - } - - // MARK: - Nested Row Class - fileprivate class Row { - let title: String - let details: String? - let handler: (() -> Void)? - - init(title: String, details: String?, handler: (() -> Void)?) { - self.title = title - self.details = details - self.handler = handler - } - } - - - - // MARK: - Private Constants - fileprivate let reuseIdentifier = "reuseIdentifierValue1" - fileprivate let iconBottomPadding = CGFloat(30) - fileprivate let footerBottomPadding = CGFloat(12) - - - - // MARK: - Private Properties - fileprivate lazy var footerTitleText: String = { - let year = Calendar.current.component(.year, from: Date()) - let localizedTitleText = NSLocalizedString("© %ld Automattic, Inc.", comment: "About View's Footer Text. The variable is the current year") - return String(format: localizedTitleText, year) - }() - - fileprivate var rows: [[Row]] { - let appsBlogHostname = URL(string: WPAutomatticAppsBlogURL)?.host ?? String() - - let acknowledgementsString = NSLocalizedString("Acknowledgements", comment: "Displays the list of third-party libraries we use") - - return [ - [ - Row(title: NSLocalizedString("Version", comment: "Displays the version of the App"), - details: Bundle.main.shortVersionString(), - handler: nil), - - Row(title: NSLocalizedString("Terms of Service", comment: "Opens the Terms of Service Web"), - details: nil, - handler: { self.displayWebView(URL(string: WPAutomatticTermsOfServiceURL)?.appendingLocale()) }), - - Row(title: NSLocalizedString("Privacy Policy", comment: "Opens the Privacy Policy Web"), - details: nil, - handler: { self.displayWebView(WPAutomatticPrivacyURL) }), - ], - [ - Row(title: NSLocalizedString("Twitter", comment: "Launches the Twitter App"), - details: WPTwitterWordPressHandle, - handler: { self.displayTwitterAccount() }), - - Row(title: NSLocalizedString("Blog", comment: "Opens the WordPress Mobile Blog"), - details: appsBlogHostname, - handler: { self.displayWebView(WPAutomatticAppsBlogURL) }), - - Row(title: NSLocalizedString("Rate us on the App Store", comment: "Prompts the user to rate us on the store"), - details: nil, - handler: { self.displayRatingPrompt() }), - - Row(title: NSLocalizedString("Source Code", comment: "Opens the Github Repository Web"), - details: nil, - handler: { self.displayWebView(WPGithubMainURL) }), - - Row(title: acknowledgementsString, - details: nil, - handler: { - let url = Bundle.main.url(forResource: "acknowledgements", withExtension: "html") - self.displayWebView(url, title: acknowledgementsString) - }), - ] - ] - } -} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutViewController.xib b/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutViewController.xib deleted file mode 100644 index c86bfb77a2ad..000000000000 --- a/WordPress/Classes/ViewRelated/Me/App Settings/About/AboutViewController.xib +++ /dev/null @@ -1,24 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="12120" systemVersion="16E195" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> - <dependencies> - <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12088"/> - <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> - </dependencies> - <objects> - <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="AboutViewController" customModule="WordPress" customModuleProvider="target"> - <connections> - <outlet property="view" destination="fZ7-an-PJC" id="NJF-bF-w1c"/> - </connections> - </placeholder> - <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <tableView clipsSubviews="YES" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" alwaysBounceVertical="YES" style="grouped" separatorStyle="default" rowHeight="44" sectionHeaderHeight="10" sectionFooterHeight="10" id="fZ7-an-PJC"> - <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> - <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> - <color key="backgroundColor" red="0.93725490199999995" green="0.93725490199999995" blue="0.95686274510000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </tableView> - </objects> -</document> 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..c86ca0820269 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/About/AppAboutScreenConfiguration.swift @@ -0,0 +1,157 @@ +import Foundation +import UIKit +import WordPressShared +import AutomatticAbout + +struct WebViewPresenter { + func present(for url: URL, context: AboutItemActionContext) { + let webViewController = WebViewControllerFactory.controller(url: url, source: "about") + let navigationController = UINavigationController(rootViewController: webViewController) + context.viewController.present(navigationController, animated: true, completion: nil) + } +} + +class AppAboutScreenConfiguration: AboutScreenConfiguration { + static var appInfo: AboutScreenAppInfo { + AboutScreenAppInfo(name: (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) ?? "", + version: Bundle.main.detailedVersionNumber() ?? "", + icon: UIImage(named: AppIcon.currentOrDefaultIconName) ?? 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: AppConstants.shareAppName, in: context.viewController, source: .about, sourceView: context.sourceView) + }), + AboutItem(title: TextContent.twitter, subtitle: AppConstants.productTwitterHandle, cellStyle: .value1, action: { [weak self] context in + self?.tracker.buttonPressed(.twitter) + self?.webViewPresenter.present(for: Links.twitter, context: context) + }), + AboutItem(title: AppConstants.AboutScreen.blogName, subtitle: AppConstants.productBlogDisplayURL, 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()) + }), + ], + AppConfiguration.isJetpack ? + [ + 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) + ] : nil, + [ + AboutItem(title: AppConstants.AboutScreen.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) + }), + ] + ].compactMap { $0 } + }() + + 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 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 var workWithUsSubtitle = AppConfiguration.isJetpack ? 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") : nil + } + + private enum Links { + static let twitter = URL(string: AppConstants.productTwitterURL)! + static let blog = URL(string: AppConstants.productBlogURL)! + static let workWithUs = URL(string: AppConstants.AboutScreen.workWithUsURL)! + 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/App Icons/AppIconListViewModel.swift b/WordPress/Classes/ViewRelated/Me/App Settings/App Icons/AppIconListViewModel.swift new file mode 100644 index 000000000000..3f5e5a135bb2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/App Icons/AppIconListViewModel.swift @@ -0,0 +1,61 @@ +import Foundation + +final class AppIconListViewModel: AppIconListViewModelType { + + // MARK: - Data + + private(set) var icons: [AppIconListSection] = [] + + // MARK: - Init + + init() { + self.load() + } + + private func load() { + let allIcons = AppIcon.allIcons + + // Produces a closure which sorts alphabetically, giving priority to items + // beginning with the specified prefix. + func sortWithPriority(toItemsWithPrefix prefix: String) -> ((AppIcon, AppIcon) -> Bool) { + return { (first, second) in + let firstIsDefault = first.name.hasPrefix(prefix) + let secondIsDefault = second.name.hasPrefix(prefix) + + if firstIsDefault && !secondIsDefault { + return true + } else if !firstIsDefault && secondIsDefault { + return false + } + + return first.name < second.name + } + } + + // Filter out the current and legacy icon groups, with the Blue icons sorted to the top. + let currentColorfulIcons = allIcons.filter({ $0.isLegacy == false && $0.isBordered == false }) + .sorted(by: sortWithPriority(toItemsWithPrefix: AppIcon.defaultIconName)) + let currentLightIcons = allIcons.filter({ $0.isLegacy == false && $0.isBordered == true }) + .sorted(by: sortWithPriority(toItemsWithPrefix: AppIcon.defaultIconName)) + let legacyIcons = { + let icons = allIcons.filter({ $0.isLegacy == true }) + + guard let legacyIconName = AppIcon.defaultLegacyIconName else { + return icons + } + + return icons.sorted(by: sortWithPriority(toItemsWithPrefix: legacyIconName)) + }() + + // Set icons + let colorfulIconsTitle = NSLocalizedString("Colorful backgrounds", comment: "Title displayed for selection of custom app icons that have colorful backgrounds.") + let lightIconsTitle = NSLocalizedString("Light backgrounds", comment: "Title displayed for selection of custom app icons that have white backgrounds.") + let legacyIconsTitle = NSLocalizedString("Legacy Icons", comment: "Title displayed for selection of custom app icons that may be removed in a future release of the app.") + self.icons = [ + .init(title: colorfulIconsTitle, items: currentColorfulIcons), + .init(title: lightIconsTitle, items: currentLightIcons), + .init(title: legacyIconsTitle, items: legacyIcons) + ].filter { !$0.items.isEmpty } + } + +} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/App Icons/AppIconListViewModelType.swift b/WordPress/Classes/ViewRelated/Me/App Settings/App Icons/AppIconListViewModelType.swift new file mode 100644 index 000000000000..6aca4ac31d39 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/App Icons/AppIconListViewModelType.swift @@ -0,0 +1,14 @@ +import Foundation + +protocol AppIconListViewModelType { + var icons: [AppIconListSection] { get } +} + +struct AppIconListSection { + let title: String? + let items: [AppIcon] + + subscript(_ index: Int) -> AppIcon { + return items[index] + } +} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/App Icons/AppIconViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/App Icons/AppIconViewController.swift new file mode 100644 index 000000000000..330db18d9d43 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/App Icons/AppIconViewController.swift @@ -0,0 +1,131 @@ +import Foundation +import WordPressShared + +open class AppIconViewController: UITableViewController { + + // MARK: - Data + + private let viewModel: AppIconListViewModelType = AppIconListViewModel() + + private var icons: [AppIconListSection] { + return viewModel.icons + } + + // MARK: - Init + + public init() { + super.init(style: .insetGrouped) + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + open override func viewDidLoad() { + super.viewDidLoad() + + title = NSLocalizedString("App Icon", comment: "Title of screen to change the app's icon") + + WPStyleGuide.configureColors(view: view, tableView: tableView) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: Constants.cellIdentifier) + tableView.rowHeight = Constants.rowHeight + + if isModal() { + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) + } + } + + @objc + private func cancelTapped() { + dismiss(animated: true, completion: nil) + } + + // MARK: - UITableview Data Source + + open override func numberOfSections(in tableView: UITableView) -> Int { + return icons.count + } + + open override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return icons[section].title + } + + open override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return icons[section].items.count + } + + open override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let icon = icons[indexPath.section][indexPath.row] + + let cell = tableView.dequeueReusableCell(withIdentifier: Constants.cellIdentifier, for: indexPath) + + cell.textLabel?.text = icon.displayName + + if let imageView = cell.imageView { + imageView.image = UIImage(named: icon.imageName) + imageView.layer.cornerRadius = Constants.cornerRadius + imageView.layer.masksToBounds = true + imageView.layer.borderColor = Constants.iconBorderColor?.cgColor + imageView.layer.borderWidth = icon.isBordered ? .hairlineBorderWidth : 0 + imageView.layer.cornerCurve = .continuous + } + + cell.accessoryType = iconIsSelected(for: indexPath) ? .checkmark : .none + + return cell + } + + private func iconIsSelected(for indexPath: IndexPath) -> Bool { + let currentIconName = UIApplication.shared.alternateIconName + + // If there's no custom icon in use and we're checking the top (default) row + let isDefaultIconInUse = currentIconName == nil + if isDefaultIconInUse && isOriginalIcon(at: indexPath) { + return true + } + + let icon = icons[indexPath.section][indexPath.row] + return currentIconName == icon.name + } + + open override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard !iconIsSelected(for: indexPath) else { + tableView.deselectRow(at: indexPath, animated: true) + return + } + + let isOriginalIcon = self.isOriginalIcon(at: indexPath) + let iconName = isOriginalIcon ? nil : icons[indexPath.section][indexPath.row].name + + // Prevent showing the custom icon upgrade alert to a user + // who's just set an icon for the first time. + // We'll remove this alert after a couple of releases. + UserPersistentStoreFactory.instance().hasShownCustomAppIconUpgradeAlert = true + + UIApplication.shared.setAlternateIconName(iconName, completionHandler: { [weak self] error in + if error == nil { + if isOriginalIcon { + WPAppAnalytics.track(.appIconReset) + } else { + WPAppAnalytics.track(.appIconChanged, withProperties: ["icon_name": iconName ?? "default"]) + } + } + + self?.tableView.reloadData() + }) + } + + private func isOriginalIcon(at indexPath: IndexPath) -> Bool { + return icons[indexPath.section][indexPath.row].isPrimary + } + + private enum Constants { + static let rowHeight: CGFloat = 89.0 + static let cornerRadius: CGFloat = 13.0 + static let iconBorderColor: UIColor? = UITableView().separatorColor + + static let cellIdentifier = "IconCell" + } +} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/AppIconViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/AppIconViewController.swift deleted file mode 100644 index c83e59c2e0e0..000000000000 --- a/WordPress/Classes/ViewRelated/Me/App Settings/AppIconViewController.swift +++ /dev/null @@ -1,154 +0,0 @@ -import Foundation -import WordPressShared - -open class AppIconViewController: UITableViewController { - - private enum Constants { - static let rowHeight: CGFloat = 58.0 - static let cornerRadius: CGFloat = 4.0 - static let iconBorderColor: UIColor? = UITableView().separatorColor - static let iconBorderWidth: CGFloat = 0.5 - - static let cellIdentifier = "IconCell" - - static let iconPreviewBaseName = "icon_40pt" - static let defaultIconName = "WordPress" - static let jetpackIconName = "Jetpack Green" - - static let infoPlistBundleIconsKey = "CFBundleIcons" - static let infoPlistAlternateIconsKey = "CFBundleAlternateIcons" - static let infoPlistRequiresBorderKey = "WPRequiresBorder" - } - - private var icons: [String] = [] - private var borderedIcons: [String] = [] - - convenience init() { - self.init(style: .grouped) - - loadIcons() - loadBorderedIcons() - } - - open override func viewDidLoad() { - super.viewDidLoad() - - title = NSLocalizedString("App Icon", comment: "Title of screen to change the app's icon") - - WPStyleGuide.configureColors(view: view, tableView: tableView) - tableView.register(UITableViewCell.self, forCellReuseIdentifier: Constants.cellIdentifier) - tableView.rowHeight = Constants.rowHeight - } - - // MARK: - UITableview Data Source - - open override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return icons.count - } - - open override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let icon = icons[indexPath.row] - - let cell = tableView.dequeueReusableCell(withIdentifier: Constants.cellIdentifier, for: indexPath) - - cell.textLabel?.text = icon - - if let imageView = cell.imageView { - let image = UIImage(named: previewImageName(for: icon)) - imageView.image = image - imageView.layer.cornerRadius = Constants.cornerRadius - imageView.layer.masksToBounds = true - imageView.layer.borderColor = Constants.iconBorderColor?.cgColor - imageView.layer.borderWidth = borderedIcons.contains(icon) ? Constants.iconBorderWidth : 0 - } - - let isDefaultIconInUse = UIApplication.shared.alternateIconName == nil - if (isDefaultIconInUse && indexPath.row == 0) || UIApplication.shared.alternateIconName == icon { - cell.accessoryType = .checkmark - } else { - cell.accessoryType = .none - } - - return cell - } - - private func previewImageName(for icon: String) -> String { - let lowered = icon.lowercased().replacingMatches(of: " ", with: "_") - return "\(lowered)_\(Constants.iconPreviewBaseName)" - } - - open override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let isOriginalIconRow = (indexPath.row == 0) - let icon = isOriginalIconRow ? nil : icons[indexPath.row] - - UIApplication.shared.setAlternateIconName(icon, completionHandler: { [weak self] error in - if error == nil { - let event: WPAnalyticsStat = isOriginalIconRow ? .appIconReset : .appIconChanged - WPAppAnalytics.track(event) - } - - self?.tableView.reloadData() - }) - } - - // MARK: - Private helpers - - private func loadIcons() { - var icons = [Constants.defaultIconName] - - // Load the names of the alternative app icons from the info plist - guard let iconDict = infoPlistIconsDict else { - self.icons = icons - return - } - - // Add them (sorted) to the default key – first any prefixed with WordPress, then the rest. - let keys = Set(iconDict.keys) - let wordPressKeys = keys.filter({$0.hasPrefix("WordPress")}).sorted() - let otherKeys = keys.subtracting(wordPressKeys).sorted() - icons.append(contentsOf: (wordPressKeys + otherKeys)) - - // Only show the Jetpack icon if the user has a Jetpack-connected site in the app. - if !shouldShowJetpackIcon { - icons.removeAll(where: { $0 == Constants.jetpackIconName }) - } - - self.icons = icons - } - - private var shouldShowJetpackIcon: Bool { - let context = ContextManager.shared.mainContext - let hasJetpackSite = BlogService(managedObjectContext: context).hasAnyJetpackBlogs() - let hasWPComSite = AccountService(managedObjectContext: context).defaultWordPressComAccount() != nil - - return hasJetpackSite || hasWPComSite - } - - private func loadBorderedIcons() { - guard let iconDict = infoPlistIconsDict else { - return - } - - var icons: [String] = [] - - // Find any icons that require a border – they have the `WPRequiresBorder` key set to YES. - for (key, value) in iconDict { - if let value = value as? [String: Any], - let requiresBorder = value[Constants.infoPlistRequiresBorderKey] as? Bool, - requiresBorder == true { - icons.append(key) - } - } - - self.borderedIcons = icons - } - - private var infoPlistIconsDict: [String: Any]? { - guard let bundleDict = Bundle.main.object(forInfoDictionaryKey: Constants.infoPlistBundleIconsKey) as? [String: Any], - let iconDict = bundleDict[Constants.infoPlistAlternateIconsKey] as? [String: Any] else { - return nil - } - - return iconDict - } -} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift index b8e64fc1df5e..7a9febfee0e5 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/AppSettingsViewController.swift @@ -1,5 +1,6 @@ import Foundation import UIKit +import SwiftUI import Gridicons import WordPressShared import SVProgressHUD @@ -25,13 +26,14 @@ class AppSettingsViewController: UITableViewController { } required convenience init() { - self.init(style: .grouped) + self.init(style: .insetGrouped) } override func viewDidLoad() { super.viewDidLoad() ImmuTable.registerRows([ + BrandedNavigationRow.self, DestructiveButtonRow.self, TextRow.self, ImageSizingRow.self, @@ -132,15 +134,6 @@ class AppSettingsViewController: UITableViewController { } } - fileprivate func clearMediaCache() { - setMediaCacheRowDescription(status: .clearingCache) - MediaFileManager.clearAllMediaCacheFiles(onCompletion: { [weak self] in - self?.updateMediaCacheSize() - }, onError: { [weak self] (error) in - self?.updateMediaCacheSize() - }) - } - // MARK: - Actions @objc func imageSizeChanged() -> (Int) -> Void { @@ -148,13 +141,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, @@ -190,6 +190,13 @@ class AppSettingsViewController: UITableViewController { } } + func openMediaCacheSettings() -> ImmuTableAction { + return { [weak self] _ in + let controller = MediaCacheSettingsViewController(style: .insetGrouped) + self?.navigationController?.pushViewController(controller, animated: true) + } + } + @objc func mediaRemoveLocationChanged() -> (Bool) -> Void { return { value in MediaSettings().removeLocationSetting = value @@ -197,36 +204,80 @@ class AppSettingsViewController: UITableViewController { } } + func pushAppearanceSettings() -> ImmuTableAction { + return { [weak self] row in + let values = UIUserInterfaceStyle.allStyles + + let rawValues = values.map({ $0.rawValue }) + let titles = values.map({ $0.appearanceDescription }) + + let currentStyle = AppAppearance.current + + let settingsSelectionConfiguration = [SettingsSelectionDefaultValueKey: AppAppearance.default.rawValue, + SettingsSelectionCurrentValueKey: currentStyle.rawValue, + SettingsSelectionTitleKey: NSLocalizedString("Appearance", comment: "The title of the app appearance settings screen"), + SettingsSelectionTitlesKey: titles, + SettingsSelectionValuesKey: rawValues] as [String: Any] + + let viewController = SettingsSelectionViewController(dictionary: settingsSelectionConfiguration) + + viewController?.onItemSelected = { [weak self] (style: Any!) -> () in + guard let style = style as? Int, + let newStyle = UIUserInterfaceStyle(rawValue: style) else { + return + } + + self?.overrideAppAppearance(with: newStyle) + } + + self?.navigationController?.pushViewController(viewController!, animated: true) + } + } + + private func overrideAppAppearance(with style: UIUserInterfaceStyle) { + let transitionView: UIView = WordPressAppDelegate.shared?.window ?? view + UIView.transition(with: transitionView, + duration: 0.3, + options: .transitionCrossDissolve, + animations: { + AppAppearance.overrideAppearance(with: style) + }) + } + func pushDebugMenu() -> ImmuTableAction { return { [weak self] row in - let controller = DebugMenuViewController() + let controller = DebugMenuViewController(style: .insetGrouped) self?.navigationController?.pushViewController(controller, animated: true) } } - func pushAppIconSwitcher() -> ImmuTableAction { + func pushDesignSystemGallery() -> ImmuTableAction { return { [weak self] row in - let controller = AppIconViewController() + let controller = UIHostingController(rootView: ColorGallery()) self?.navigationController?.pushViewController(controller, animated: true) } } - func pushAbout() -> ImmuTableAction { + func pushAppIconSwitcher() -> ImmuTableAction { return { [weak self] row in - let controller = AboutViewController() + let controller = AppIconViewController() self?.navigationController?.pushViewController(controller, animated: true) } } func openPrivacySettings() -> ImmuTableAction { return { [weak self] _ in - let controller = PrivacySettingsViewController() + WPAnalytics.track(.privacySettingsOpened) + + let controller = PrivacySettingsViewController(style: .insetGrouped) self?.navigationController?.pushViewController(controller, animated: true) } } func openApplicationSettings() -> ImmuTableAction { return { [weak self] row in + WPAnalytics.track(.appSettingsOpenDeviceSettingsTapped) + if let targetURL = URL(string: UIApplication.openSettingsURLString) { UIApplication.shared.open(targetURL) @@ -240,19 +291,21 @@ class AppSettingsViewController: UITableViewController { func clearSiriActivityDonations() -> ImmuTableAction { return { [tableView] _ in + WPAnalytics.track(.appSettingsClearSiriSuggestionsTapped) + tableView?.deselectSelectedRowWithAnimation(true) - if #available(iOS 12.0, *) { - NSUserActivity.deleteAllSavedUserActivities {} - } + 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."), @@ -260,6 +313,49 @@ class AppSettingsViewController: UITableViewController { ActionDispatcher.dispatch(NoticeAction.post(notice)) } } + + func presentWhatIsNew() -> ImmuTableAction { + return { [weak self] row in + guard let self = self else { + return + } + self.tableView.deselectSelectedRowWithAnimation(true) + RootViewCoordinator.shared.presentWhatIsNew(on: self) + } + } + + func pushInitialScreenSettings() -> ImmuTableAction { + return { [weak self] row in + let values = MySiteViewController.Section.allCases + + let rawValues = values.map({ $0.rawValue }) + let titles = values.map({ $0.title }) + + let currentStyle = MySiteSettings().defaultSection + + let settingsSelectionConfiguration = [SettingsSelectionDefaultValueKey: AppAppearance.default.rawValue, + SettingsSelectionCurrentValueKey: currentStyle.rawValue, + SettingsSelectionTitleKey: NSLocalizedString("Initial Screen", comment: "The title of the app initial screen settings screen"), + SettingsSelectionTitlesKey: titles, + SettingsSelectionValuesKey: rawValues] as [String: Any] + + let viewController = SettingsSelectionViewController(dictionary: settingsSelectionConfiguration) + + viewController?.onItemSelected = { (section: Any!) -> () in + let oldDefaultSection = MySiteSettings().defaultSection + guard let section = section as? Int, + let newDefaultSection = MySiteViewController.Section(rawValue: section), + newDefaultSection != oldDefaultSection else { + return + } + + WPAnalytics.track(.initialScreenChanged, properties: ["selected": newDefaultSection.analyticsDescription]) + MySiteSettings().setDefaultSection(newDefaultSection) + } + + self?.navigationController?.pushViewController(viewController!, animated: true) + } + } } // MARK: - SearchableActivity Conformance @@ -329,23 +425,17 @@ private extension AppSettingsViewController { detail: MediaSettings().maxVideoSizeSetting.description, action: pushVideoResolutionSettings()) - let mediaCacheRow = TextRow(title: NSLocalizedString("Media Cache Size", comment: "Label for size of media cache in the app."), - value: mediaCacheRowDescription) - - let mediaClearCacheRow = DestructiveButtonRow( - title: NSLocalizedString("Clear Device Media Cache", comment: "Label for button that clears all media cache."), - action: { [weak self] row in - self?.clearMediaCache() - }, - accessibilityIdentifier: "mediaClearCacheButton") + let mediaCacheRow = NavigationItemRow( + title: NSLocalizedString("Media Cache", comment: "Label for the media cache navigation row in the app."), + detail: mediaCacheRowDescription, + action: openMediaCacheSettings()) return ImmuTableSection( headerText: mediaHeader, rows: [ imageSizingRow, videoSizingRow, - mediaCacheRow, - mediaClearCacheRow + mediaCacheRow ], footerText: NSLocalizedString("Free up storage space on this device by deleting temporary media files. This will not affect the media on your site.", comment: "Explanatory text for clearing device media cache.") @@ -366,7 +456,7 @@ private extension AppSettingsViewController { action: openPrivacySettings() ) - let spotlightClearCacheRow = DestructiveButtonRow( + let spotlightClearCacheRow = BrandedNavigationRow( title: NSLocalizedString("Clear Spotlight Index", comment: "Label for button that clears the spotlight index on device."), action: clearSpotlightCache(), accessibilityIdentifier: "spotlightClearCacheButton") @@ -376,14 +466,12 @@ private extension AppSettingsViewController { spotlightClearCacheRow ] - if #available(iOS 12.0, *) { - let siriClearCacheRow = DestructiveButtonRow( - title: NSLocalizedString("Siri Reset Prompt", comment: "Label for button that clears user activities donated to Siri."), - action: clearSiriActivityDonations(), - accessibilityIdentifier: "spotlightClearCacheButton") + let siriClearCacheRow = BrandedNavigationRow( + 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") - tableRows.append(siriClearCacheRow) - } + tableRows.append(siriClearCacheRow) tableRows.append(mediaRemoveLocation) let removeLocationFooterText = NSLocalizedString("Removes location metadata from photos before uploading them to your site.", comment: "Explanatory text for removing the location from uploaded media.") @@ -400,10 +488,15 @@ private extension AppSettingsViewController { let debugRow = NavigationItemRow( title: NSLocalizedString("Debug", comment: "Navigates to debug menu only available in development builds"), - icon: Gridicon.iconOfType(.bug), + icon: .gridicon(.bug), action: pushDebugMenu() ) + let designSystem = NavigationItemRow( + title: NSLocalizedString("Design System", comment: "Navigates to design system gallery only available in development builds"), + action: pushDesignSystemGallery() + ) + let iconRow = NavigationItemRow( title: NSLocalizedString("App Icon", comment: "Navigates to picker screen to change the app's icon"), action: pushAppIconSwitcher() @@ -414,24 +507,61 @@ private extension AppSettingsViewController { action: openApplicationSettings() ) - let aboutRow = NavigationItemRow( - title: NSLocalizedString("About WordPress for iOS", comment: "Link to About screen for WordPress for iOS"), - action: pushAbout() - ) + var rows: [ImmuTableRow] = [settingsRow] + + if AppConfiguration.allowsCustomAppIcons && UIApplication.shared.supportsAlternateIcons { + // We don't show custom icons for Jetpack + rows.insert(iconRow, at: 0) + } + + if JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() { + let initialScreen = NavigationItemRow(title: NSLocalizedString("Initial Screen", comment: "Title of the option to change the default initial screen"), detail: MySiteSettings().defaultSection.title, action: pushInitialScreenSettings()) - var rows = [settingsRow, aboutRow] - if #available(iOS 10.3, *), - UIApplication.shared.supportsAlternateIcons { - rows.insert(iconRow, at: 0) + rows.append(initialScreen) } if FeatureFlag.debugMenu.enabled { rows.append(debugRow) + rows.append(designSystem) } + if let presenter = RootViewCoordinator.shared.whatIsNewScenePresenter as? WhatIsNewScenePresenter, + presenter.versionHasAnnouncements, + AppConfiguration.showsWhatIsNew { + let whatIsNewRow = NavigationItemRow(title: AppConstants.Settings.whatIsNewTitle, + action: presentWhatIsNew()) + rows.append(whatIsNewRow) + } + + let appearanceRow = NavigationItemRow(title: NSLocalizedString("Appearance", comment: "The title of the app appearance settings screen"), detail: AppAppearance.current.appearanceDescription, action: pushAppearanceSettings()) + + rows.insert(appearanceRow, at: 0) + return ImmuTableSection( headerText: otherHeader, rows: rows, footerText: nil) } } + +// MARK: - Jetpack powered badge +extension AppSettingsViewController { + + override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + guard section == handler.viewModel.sections.count - 1, + JetpackBrandingVisibility.all.enabled else { + return nil + } + let textProvider = JetpackBrandingTextProvider(screen: JetpackBadgeScreen.appSettings) + let jetpackButton = JetpackButton.makeBadgeView(title: textProvider.brandingText(), + target: self, + selector: #selector(jetpackButtonTapped)) + + return jetpackButton + } + + @objc private func jetpackButtonTapped() { + JetpackBrandingCoordinator.presentOverlay(from: self) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBadgeTapped(screen: .appSettings) + } +} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/DebugMenuViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/DebugMenuViewController.swift index 7a95fa47aeaf..7d46004cb479 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/DebugMenuViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/DebugMenuViewController.swift @@ -1,7 +1,11 @@ import UIKit +import AutomatticTracks +import SwiftUI class DebugMenuViewController: UITableViewController { - fileprivate var handler: ImmuTableViewHandler! + private var handler: ImmuTableViewHandler! + private let remoteStore = RemoteFeatureFlagStore() + private let overrideStore = FeatureFlagOverrideStore() override init(style: UITableView.Style) { super.init(style: style) @@ -22,7 +26,9 @@ class DebugMenuViewController: UITableViewController { super.viewDidLoad() ImmuTable.registerRows([ - SwitchWithSubtitleRow.self + SwitchWithSubtitleRow.self, + ButtonRow.self, + EditableTextRow.self ], tableView: tableView) handler = ImmuTableViewHandler(takeOver: self) @@ -33,27 +39,181 @@ class DebugMenuViewController: UITableViewController { } private func reloadViewModel() { - let cases = FeatureFlag.allCases.filter({ $0.canOverride }) - let rows: [ImmuTableRow] = cases.map({ makeRow(for: $0) }) + let remoteFeatureFlags = RemoteFeatureFlag.allCases.filter({ $0.canOverride }) + let remoteFeatureFlagsRows: [ImmuTableRow] = remoteFeatureFlags.map({ makeRemoteFeatureFlagsRows(for: $0) }) + + let localFeatureFlags = FeatureFlag.allCases.filter({ $0.canOverride }) + let localFeatureFlagsRows: [ImmuTableRow] = localFeatureFlags.map({ makeLocalFeatureFlagsRow(for: $0) }) handler.viewModel = ImmuTable(sections: [ - ImmuTableSection(headerText: Strings.featureFlags, rows: rows) + ImmuTableSection(headerText: Strings.remoteFeatureFlags, rows: remoteFeatureFlagsRows), + ImmuTableSection(headerText: Strings.localFeatureFlags, rows: localFeatureFlagsRows), + ImmuTableSection(headerText: Strings.tools, rows: toolsRows), + ImmuTableSection(headerText: Strings.crashLogging, rows: crashLoggingRows), + ImmuTableSection(headerText: Strings.reader, rows: readerRows), ]) } - private func makeRow(for flag: FeatureFlag) -> ImmuTableRow { - let store = FeatureFlagOverrideStore() - - let overridden: String? = store.isOverridden(flag) ? Strings.overridden : nil + private func makeLocalFeatureFlagsRow(for flag: FeatureFlag) -> ImmuTableRow { + let overridden: String? = overrideStore.isOverridden(flag) ? Strings.overridden : nil return SwitchWithSubtitleRow(title: String(describing: flag), value: flag.enabled, subtitle: overridden, onChange: { isOn in - try? store.override(flag, withValue: isOn) + try? self.overrideStore.override(flag, withValue: isOn) self.reloadViewModel() }) } + private func makeRemoteFeatureFlagsRows(for flag: RemoteFeatureFlag) -> ImmuTableRow { + let overridden: String? = overrideStore.isOverridden(flag) ? Strings.overridden : nil + let enabled = flag.enabled(using: remoteStore, overrideStore: overrideStore) + return SwitchWithSubtitleRow(title: String(describing: flag), value: enabled, subtitle: overridden, onChange: { isOn in + try? self.overrideStore.override(flag, withValue: isOn) + self.reloadViewModel() + }) + } + + // MARK: Tools + + private var toolsRows: [ImmuTableRow] { + var toolsRows = [ + ButtonRow(title: Strings.quickStartForNewSiteRow, action: { [weak self] _ in + self?.displayBlogPickerForQuickStart(type: .newSite) + }), + ButtonRow(title: Strings.quickStartForExistingSiteRow, action: { [weak self] _ in + self?.displayBlogPickerForQuickStart(type: .existingSite) + }), + ButtonRow(title: Strings.sandboxStoreCookieSecretRow, action: { [weak self] _ in + self?.displayStoreSandboxSecretInserter() + }), + ButtonRow(title: Strings.remoteConfigTitle, action: { [weak self] _ in + self?.displayRemoteConfigDebugMenu() + }), + ] + + if Feature.enabled(.weeklyRoundup) { + toolsRows.append(ButtonRow(title: "Weekly Roundup", action: { [weak self] _ in + self?.displayWeeklyRoundupDebugTools() + })) + } + + return toolsRows + } + + // MARK: Crash Logging + + private var crashLoggingRows: [ImmuTableRow] { + + var rows: [ImmuTableRow] = [ + ButtonRow(title: Strings.sendLogMessage, action: { _ in + WordPressAppDelegate.crashLogging?.logMessage("Debug Log Message \(UUID().uuidString)") + self.tableView.deselectSelectedRowWithAnimationAfterDelay(true) + }), + ButtonRow(title: Strings.sendTestCrash, action: { _ in + DDLogInfo("Initiating user-requested crash") + WordPressAppDelegate.crashLogging?.crash() + }) + ] + + if let eventLogging = WordPressAppDelegate.eventLogging { + let tableViewController = EncryptedLogTableViewController(eventLogging: eventLogging) + let encryptedLoggingRow = ButtonRow(title: Strings.encryptedLogging) { _ in + self.navigationController?.pushViewController(tableViewController, animated: true) + } + rows.append(encryptedLoggingRow) + } + + let alwaysSendLogsRow = SwitchWithSubtitleRow(title: Strings.alwaysSendLogs, value: UserSettings.userHasForcedCrashLoggingEnabled) { isOn in + UserSettings.userHasForcedCrashLoggingEnabled = isOn + } + + rows.append(alwaysSendLogsRow) + + return rows + } + + private func displayBlogPickerForQuickStart(type: QuickStartType) { + let successHandler: BlogSelectorSuccessHandler = { [weak self] selectedObjectID in + guard let blog = ContextManager.shared.mainContext.object(with: selectedObjectID) as? Blog else { + return + } + + self?.dismiss(animated: true) { [weak self] in + self?.enableQuickStart(for: blog, type: type) + } + } + + let selectorViewController = BlogSelectorViewController(selectedBlogObjectID: nil, + successHandler: successHandler, + dismissHandler: nil) + + selectorViewController.displaysNavigationBarWhenSearching = WPDeviceIdentification.isiPad() + selectorViewController.dismissOnCancellation = true + selectorViewController.displaysOnlyDefaultAccountSites = true + + let navigationController = UINavigationController(rootViewController: selectorViewController) + present(navigationController, animated: true) + } + + private func displayStoreSandboxSecretInserter() { + let view = StoreSandboxSecretScreen(cookieJar: HTTPCookieStorage.shared) + let viewController = UIHostingController(rootView: view) + + self.navigationController?.pushViewController(viewController, animated: true) + } + + private func displayWeeklyRoundupDebugTools() { + let view = WeeklyRoundupDebugScreen() + let viewController = UIHostingController(rootView: view) + + self.navigationController?.pushViewController(viewController, animated: true) + } + + private func enableQuickStart(for blog: Blog, type: QuickStartType) { + QuickStartTourGuide.shared.setup(for: blog, type: type) + } + + private func displayRemoteConfigDebugMenu() { + let viewController = RemoteConfigDebugViewController() + self.navigationController?.pushViewController(viewController, animated: true) + } + + // MARK: Reader + + private var readerRows: [ImmuTableRow] { + return [ + EditableTextRow(title: Strings.readerCssTitle, value: ReaderCSS().customAddress ?? "") { row in + let textViewController = SettingsTextViewController(text: ReaderCSS().customAddress, placeholder: Strings.readerURLPlaceholder, hint: Strings.readerURLHint) + textViewController.title = Strings.readerCssTitle + textViewController.onAttributedValueChanged = { [weak self] url in + var readerCSS = ReaderCSS() + readerCSS.customAddress = url.string + self?.reloadViewModel() + } + + self.navigationController?.pushViewController(textViewController, animated: true) + } + ] + } + enum Strings { static let overridden = NSLocalizedString("Overridden", comment: "Used to indicate a setting is overridden in debug builds of the app") - static let featureFlags = NSLocalizedString("Feature flags", comment: "Title of the Feature Flags screen used in debug builds of the app") + static let localFeatureFlags = NSLocalizedString("debugMenu.section.localFeatureFlags", value: "Local Feature Flags", comment: "Title of the Local Feature Flags screen used in debug builds of the app") + static let remoteFeatureFlags = NSLocalizedString("debugMenu.section.remoteFeatureFlags", value: "Remote Feature Flags", comment: "Title of the Remote Feature Flags screen used in debug builds of the app") + static let tools = NSLocalizedString("Tools", comment: "Title of the Tools section of the debug screen used in debug builds of the app") + static let sandboxStoreCookieSecretRow = NSLocalizedString("Use Sandbox Store", comment: "Title of a row displayed on the debug screen used to configure the sandbox store use in the App.") + static let quickStartForNewSiteRow = NSLocalizedString("Enable Quick Start for New Site", comment: "Title of a row displayed on the debug screen used in debug builds of the app") + static let quickStartForExistingSiteRow = NSLocalizedString("Enable Quick Start for Existing Site", comment: "Title of a row displayed on the debug screen used in debug builds of the app") + static let sendTestCrash = NSLocalizedString("Send Test Crash", comment: "Title of a row displayed on the debug screen used to crash the app and send a crash report to the crash logging provider to ensure everything is working correctly") + static let sendLogMessage = NSLocalizedString("Send Log Message", comment: "Title of a row displayed on the debug screen used to send a pretend error message to the crash logging provider to ensure everything is working correctly") + static let alwaysSendLogs = NSLocalizedString("Always Send Crash Logs", comment: "Title of a row displayed on the debug screen used to indicate whether crash logs should be forced to send, even if they otherwise wouldn't") + static let crashLogging = NSLocalizedString("Crash Logging", comment: "Title of a section on the debug screen that shows a list of actions related to crash logging") + static let encryptedLogging = NSLocalizedString("Encrypted Logs", comment: "Title of a row displayed on the debug screen used to display a screen that shows a list of encrypted logs") + static let reader = NSLocalizedString("Reader", comment: "Title of the Reader section of the debug screen used in debug builds of the app") + static let readerCssTitle = NSLocalizedString("Reader CSS URL", comment: "Title of the screen that allows the user to change the Reader CSS URL for debug builds") + static let readerURLPlaceholder = NSLocalizedString("Default URL", comment: "Placeholder for the reader CSS URL") + static let readerURLHint = NSLocalizedString("Add a custom CSS URL here to be loaded in Reader. If you're running Calypso locally this can be something like: http://192.168.15.23:3000/calypso/reader-mobile.css", comment: "Hint for the reader CSS URL field") + static let remoteConfigTitle = NSLocalizedString("debugMenu.remoteConfig.title", + value: "Remote Config", + comment: "Remote Config debug menu title") } } diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/EncryptedLogTableViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/EncryptedLogTableViewController.swift new file mode 100644 index 000000000000..05eed770e2f5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/EncryptedLogTableViewController.swift @@ -0,0 +1,123 @@ +import UIKit +import AutomatticTracks + +class EncryptedLogTableViewController: UITableViewController { + + /// Internal storage for the log list + private var logs: [LogFile] = [] + + /// The label displaying the current status + private let toolbarLabel = UIBarButtonItem(title: "Running", style: .plain, target: nil, action: nil) + + private let eventLogging: EventLogging + + init(eventLogging: EventLogging) { + self.eventLogging = eventLogging + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: UIViewController Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + + self.title = "Encrypted Log Queue" + self.tableView.register(SubtitleTableViewCell.self, forCellReuseIdentifier: "reuseIdentifier") + self.updateData() + + let item = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addEncryptedLog)) + self.navigationItem.rightBarButtonItem = item + + let name = WPLoggingStack.QueuedLogsDidChangeNotification + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { _ in + self.updateData() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + let spacer = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + self.setToolbarItems([spacer, self.toolbarLabel, spacer], animated: animated) + tableView.tableFooterView = UIView(frame: .zero) /// hide lines for empty cells + + navigationController?.setToolbarHidden(false, animated: animated) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + self.setToolbarItems(nil, animated: animated) + navigationController?.setToolbarHidden(true, animated: animated) + } + + // MARK: UITableViewController Data Source + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return logs.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + let log = logs[indexPath.item] + let cell = tableView.dequeueReusableCell(withIdentifier: "reuseIdentifier", for: indexPath) + cell.textLabel?.text = log.uuid + + if let date = try? FileManager.default.attributesOfItem(atPath: log.url.path)[.creationDate] as? Date { + cell.detailTextLabel?.text = DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .short) + } + else { + cell.detailTextLabel?.text = "Unknown" + } + + return cell + } + + // MARK: Internal Helpers + private func updateData() { + self.logs = eventLogging.queuedLogFiles + self.tableView.reloadData() + + if let date = self.eventLogging.uploadsPausedUntil { + let dateString = DateFormatter.localizedString(from: date, dateStyle: .short, timeStyle: .medium) + self.toolbarLabel.title = "Paused until \(dateString)" + } + else { + self.toolbarLabel.title = self.logs.isEmpty ? "All Logs Uploaded" : "Running" + } + } + + @objc + private func addEncryptedLog() { + do { + /// For now, just enqueue any file – doesn't have to be the log + let data = try Data(contentsOf: Bundle.main.url(forResource: "acknowledgements", withExtension: "html")!) + + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try data.write(to: url) + + try self.eventLogging.enqueueLogForUpload(log: LogFile(url: url)) + } + catch let err { + let alert = UIAlertController(title: "Unable to create log", message: err.localizedDescription, preferredStyle: .actionSheet) + self.present(alert, animated: true) + } + } +} + +fileprivate class SubtitleTableViewCell: UITableViewCell { + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/MediaCacheSettingsViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/MediaCacheSettingsViewController.swift new file mode 100644 index 000000000000..c7574f30a759 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/MediaCacheSettingsViewController.swift @@ -0,0 +1,126 @@ +import Foundation + +class MediaCacheSettingsViewController: UITableViewController { + fileprivate var handler: ImmuTableViewHandler? + + override init(style: UITableView.Style) { + super.init(style: .insetGrouped) + handler = ImmuTableViewHandler(takeOver: self) + navigationItem.title = NSLocalizedString("Media Cache", comment: "Media Cache title") + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + required convenience init() { + self.init(style: .plain) + } + + override func viewDidLoad() { + super.viewDidLoad() + + ImmuTable.registerRows([ + TextRow.self, + BrandedNavigationRow.self + ], tableView: self.tableView) + + reloadViewModel() + + WPStyleGuide.configureColors(view: view, tableView: tableView) + WPStyleGuide.configureAutomaticHeightRows(for: tableView) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + updateMediaCacheSize() + } + + // MARK: - Model mapping + + fileprivate func reloadViewModel() { + handler?.viewModel = tableViewModel() + } + + func tableViewModel() -> ImmuTable { + let mediaCacheRow = TextRow( + title: NSLocalizedString("Media Cache Size", + comment: "Label for size of media cache in the app."), + value: mediaCacheRowDescription) + + let mediaClearCacheRow = BrandedNavigationRow( + title: NSLocalizedString("Clear Device Media Cache", + comment: "Label for button that clears all media cache."), + action: { [weak self] row in + self?.clearMediaCache() + }, + accessibilityIdentifier: "mediaClearCacheButton") + + return ImmuTable(sections: [ + ImmuTableSection(rows: [ + mediaCacheRow + ]), + ImmuTableSection(rows: [ + mediaClearCacheRow + ]), + ]) + } + + // MARK: - Media cache methods + + fileprivate enum MediaCacheSettingsStatus { + case calculatingSize + case clearingCache + case unknown + case empty + } + + fileprivate var mediaCacheRowDescription = "" { + didSet { + reloadViewModel() + } + } + + fileprivate func setMediaCacheRowDescription(allocatedSize: Int64?) { + guard let allocatedSize = allocatedSize else { + setMediaCacheRowDescription(status: .unknown) + return + } + if allocatedSize == 0 { + setMediaCacheRowDescription(status: .empty) + return + } + mediaCacheRowDescription = ByteCountFormatter.string(fromByteCount: allocatedSize, countStyle: ByteCountFormatter.CountStyle.file) + } + + fileprivate func setMediaCacheRowDescription(status: MediaCacheSettingsStatus) { + switch status { + case .clearingCache: + mediaCacheRowDescription = NSLocalizedString("Clearing...", comment: "Label for size of media while it's being cleared.") + case .calculatingSize: + mediaCacheRowDescription = NSLocalizedString("Calculating...", comment: "Label for size of media while it's being calculated.") + case .unknown: + mediaCacheRowDescription = NSLocalizedString("Unknown", comment: "Label for size of media when it's not possible to calculate it.") + case .empty: + mediaCacheRowDescription = NSLocalizedString("Empty", comment: "Label for size of media when the cache is empty.") + } + } + + fileprivate func updateMediaCacheSize() { + setMediaCacheRowDescription(status: .calculatingSize) + MediaFileManager.calculateSizeOfMediaDirectories { [weak self] (allocatedSize) in + self?.setMediaCacheRowDescription(allocatedSize: allocatedSize) + } + } + + fileprivate func clearMediaCache() { + WPAnalytics.track(.appSettingsClearMediaCacheTapped) + + setMediaCacheRowDescription(status: .clearingCache) + MediaFileManager.clearAllMediaCacheFiles(onCompletion: { [weak self] in + self?.updateMediaCacheSize() + }, onError: { [weak self] (error) in + self?.updateMediaCacheSize() + }) + } +} diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/PrivacySettingsViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/PrivacySettingsViewController.swift index cb00f7c7cc58..ff8ec5846862 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/PrivacySettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/PrivacySettingsViewController.swift @@ -15,14 +15,14 @@ class PrivacySettingsViewController: UITableViewController { } required convenience init() { - self.init(style: .grouped) + self.init(style: .insetGrouped) } override func viewDidLoad() { super.viewDidLoad() ImmuTable.registerRows([ - InfoRow.self, + PaddedInfoRow.self, SwitchRow.self, PaddedLinkRow.self ], tableView: self.tableView) @@ -55,13 +55,12 @@ class PrivacySettingsViewController: UITableViewController { let collectInformation = SwitchRow( title: NSLocalizedString("Collect information", comment: "Label for switch to turn on/off sending app usage data"), value: !WPAppAnalytics.userHasOptedOut(), - icon: Gridicon.iconOfType(.stats), - onChange: usageTrackingChanged() + icon: .gridicon(.stats), + onChange: usageTrackingChanged ) - let shareInfoText = InfoRow( - title: NSLocalizedString("Share information with our analytics tool about your use of services while logged in to your WordPress.com account.", comment: "Informational text for Collect Information setting"), - icon: Gridicon.iconOfType(.info) + let shareInfoText = PaddedInfoRow( + title: NSLocalizedString("Share information with our analytics tool about your use of services while logged in to your WordPress.com account.", comment: "Informational text for Collect Information setting") ) let shareInfoLink = PaddedLinkRow( @@ -69,9 +68,8 @@ class PrivacySettingsViewController: UITableViewController { action: openCookiePolicy() ) - let privacyText = InfoRow( - title: NSLocalizedString("This information helps us improve our products, make marketing to you more relevant, personalize your WordPress.com experience, and more as detailed in our privacy policy.", comment: "Informational text for the privacy policy link"), - icon: Gridicon.iconOfType(.userCircle) + let privacyText = PaddedInfoRow( + title: NSLocalizedString("This information helps us improve our products, make marketing to you more relevant, personalize your WordPress.com experience, and more as detailed in our privacy policy.", comment: "Informational text for the privacy policy link") ) let privacyLink = PaddedLinkRow( @@ -79,9 +77,13 @@ class PrivacySettingsViewController: UITableViewController { action: openPrivacyPolicy() ) - let otherTracking = InfoRow( - title: NSLocalizedString("We use other tracking tools, including some from third parties. Read about these and how to control them.", comment: "Informational text about link to other tracking tools"), - icon: Gridicon.iconOfType(.briefcase) + let ccpaLink = PaddedLinkRow( + title: NSLocalizedString("Privacy notice for California users", comment: "Link to the CCPA privacy notice for residents of California."), + action: openCCPANotice() + ) + + let otherTracking = PaddedInfoRow( + title: NSLocalizedString("We use other tracking tools, including some from third parties. Read about these and how to control them.", comment: "Informational text about link to other tracking tools") ) let otherTrackingLink = PaddedLinkRow( @@ -89,56 +91,85 @@ class PrivacySettingsViewController: UITableViewController { action: openCookiePolicy() ) + let reportCrashes = SwitchRow( + title: NSLocalizedString("Crash reports", comment: "Label for switch to turn on/off sending crashes info"), + value: !UserSettings.userHasOptedOutOfCrashLogging, + icon: .gridicon(.bug), + onChange: crashReportingChanged + ) + + let reportCrashesInfoText = PaddedInfoRow( + title: NSLocalizedString("To help us improve the app’s performance and fix the occasional bug, enable automatic crash reports.", comment: "Informational text for Report Crashes setting") + ) + return ImmuTable(sections: [ ImmuTableSection(rows: [ collectInformation, shareInfoText, - shareInfoLink - ]), - ImmuTableSection(rows: [ + shareInfoLink, privacyText, - privacyLink - ]), - ImmuTableSection(rows: [ + privacyLink, + ccpaLink, otherTracking, otherTrackingLink - ]) + ]), + ImmuTableSection(rows: [ + reportCrashes, + reportCrashesInfoText ]) + ]) } - func usageTrackingChanged() -> (Bool) -> Void { - return { enabled in - let appAnalytics = WordPressAppDelegate.shared?.analytics - appAnalytics?.setUserHasOptedOut(!enabled) + func usageTrackingChanged(_ enabled: Bool) { + let appAnalytics = WordPressAppDelegate.shared?.analytics + appAnalytics?.setUserHasOptedOut(!enabled) - let accountService = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext) - AccountSettingsHelper(accountService: accountService).updateTracksOptOutSetting(!enabled) - - CrashLogging.setNeedsDataRefresh() + let context = ContextManager.shared.mainContext + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) else { + return } + + let change = AccountSettingsChange.tracksOptOut(!enabled) + AccountSettingsService(userID: account.userID.intValue, api: account.wordPressComRestApi).saveChange(change) } func openCookiePolicy() -> ImmuTableAction { return { [weak self] _ in + self?.tableView.deselectSelectedRowWithAnimation(true) self?.displayWebView(WPAutomatticCookiesURL) } } func openPrivacyPolicy() -> ImmuTableAction { return { [weak self] _ in + self?.tableView.deselectSelectedRowWithAnimation(true) self?.displayWebView(WPAutomatticPrivacyURL) } } + func openCCPANotice() -> ImmuTableAction { + return { [weak self] _ in + self?.tableView.deselectSelectedRowWithAnimation(true) + self?.displayWebView(WPAutomatticCCPAPrivacyNoticeURL) + } + } + func displayWebView(_ urlString: String) { guard let url = URL(string: urlString) else { return } - let webViewController = WebViewControllerFactory.controller(url: url) + let webViewController = WebViewControllerFactory.controller(url: url, source: "privacy_settings") let navigation = UINavigationController(rootViewController: webViewController) present(navigation, animated: true) } + func crashReportingChanged(_ enabled: Bool) { + UserSettings.userHasOptedOutOfCrashLogging = !enabled + + WPAnalytics.track(.privacySettingsReportCrashesToggled, properties: ["enabled": enabled]) + + WordPressAppDelegate.crashLogging?.setNeedsDataRefresh() + } } private class InfoCell: WPTableViewCellDefault { @@ -180,17 +211,16 @@ private class InfoCell: WPTableViewCellDefault { } } -private struct InfoRow: ImmuTableRow { +private struct PaddedInfoRow: ImmuTableRow { static let cell = ImmuTableCell.class(InfoCell.self) let title: String - let icon: UIImage let action: ImmuTableAction? = nil func configureCell(_ cell: UITableViewCell) { cell.textLabel?.text = title cell.textLabel?.numberOfLines = 10 - cell.imageView?.image = icon + cell.imageView?.image = UIImage(color: .clear, havingSize: Gridicon.defaultSize) cell.selectionStyle = .none WPStyleGuide.configureTableViewCell(cell) @@ -208,5 +238,6 @@ private struct PaddedLinkRow: ImmuTableRow { cell.imageView?.image = UIImage(color: .clear, havingSize: Gridicon.defaultSize) WPStyleGuide.configureTableViewActionCell(cell) + cell.textLabel?.textColor = .primary } } diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/RemoteConfigDebugViewController.swift b/WordPress/Classes/ViewRelated/Me/App Settings/RemoteConfigDebugViewController.swift new file mode 100644 index 000000000000..7a8775625c35 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/App Settings/RemoteConfigDebugViewController.swift @@ -0,0 +1,124 @@ +import UIKit + +class RemoteConfigDebugViewController: UITableViewController { + + private var handler: ImmuTableViewHandler! + + override init(style: UITableView.Style) { + super.init(style: style) + + title = Strings.title + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + required convenience init() { + self.init(style: .grouped) + } + + override func viewDidLoad() { + super.viewDidLoad() + + ImmuTable.registerRows([ + CheckmarkRow.self + ], tableView: tableView) + + handler = ImmuTableViewHandler(takeOver: self) + reloadViewModel() + + WPStyleGuide.configureColors(view: view, tableView: tableView) + WPStyleGuide.configureAutomaticHeightRows(for: tableView) + } + + private func reloadViewModel() { + let remoteConfigStore = RemoteConfigStore() + let overrideStore = RemoteConfigOverrideStore() + let rows = RemoteConfigParameter.allCases.map({ + makeRemoteConfigParamRow(for: $0, remoteConfigStore: remoteConfigStore, overrideStore: overrideStore) + }) + + handler.viewModel = ImmuTable(sections: [ + ImmuTableSection(rows: rows, footerText: Strings.footer) + ]) + } + + private func makeRemoteConfigParamRow(for param: RemoteConfigParameter, + remoteConfigStore: RemoteConfigStore, + overrideStore: RemoteConfigOverrideStore) -> ImmuTableRow { + var overriddenValueText: String? + var currentValueText: String + var placeholderText: String + var isOverridden = false + + if let originalValue = param.originalValue(using: remoteConfigStore) { + placeholderText = String(describing: originalValue) + currentValueText = String(describing: originalValue) + } + else { + placeholderText = Strings.defaultPlaceholder + currentValueText = "nil" + } + + if let overriddenValue = overrideStore.overriddenValue(for: param) { + overriddenValueText = String(describing: overriddenValue) + currentValueText = String(describing: overriddenValue) + isOverridden = true + } + + return CheckmarkRow(title: param.description, subtitle: currentValueText, checked: isOverridden) { [weak self] row in + self?.displaySettingsTextViewController(for: param, + text: overriddenValueText, + placeholder: placeholderText, + overrideStore: overrideStore) + } + } + + private func displaySettingsTextViewController(for param: RemoteConfigParameter, + text: String?, + placeholder: String, + overrideStore: RemoteConfigOverrideStore) { + let textViewController = SettingsTextViewController(text: text, placeholder: placeholder, hint: Strings.hint) + textViewController.title = param.description + textViewController.mode = .lowerCaseText + textViewController.autocorrectionType = .no + textViewController.onAttributedValueChanged = { [weak self] newValue in + if newValue.string.isEmpty { + overrideStore.reset(param) + } else { + overrideStore.override(param, withValue: newValue.string) + } + self?.reloadViewModel() + } + + self.navigationController?.pushViewController(textViewController, animated: true) + } + +} + +private extension RemoteConfigDebugViewController { + enum Strings { + static let title = NSLocalizedString("debugMenu.remoteConfig.title", + value: "Remote Config", + comment: "Remote Config debug menu title") + static let defaultPlaceholder = NSLocalizedString("debugMenu.remoteConfig.placeholder", + value: "No remote or default value", + comment: "Placeholder for overriding remote config params") + static let hint = NSLocalizedString("debugMenu.remoteConfig.hint", + value: "Override the chosen param by defining a new value here.", + comment: "Hint for overriding remote config params") + static let footer = NSLocalizedString("debugMenu.remoteConfig.footer", + value: "Overridden parameters are denoted by a checkmark.", + comment: "Remote config params debug menu footer explaining the meaning of a cell with a checkmark.") + } +} + +private extension RemoteConfigParameter { + func originalValue(using store: RemoteConfigStore = .init()) -> Any? { + if let value = store.value(for: key) { + return value + } + return defaultValue + } +} diff --git a/WordPress/Classes/ViewRelated/Me/Help & Support/Activity Logs/ActivityLogViewController.m b/WordPress/Classes/ViewRelated/Me/Help & Support/Activity Logs/ActivityLogViewController.m index 51acfc472d81..69bc0354cb0c 100644 --- a/WordPress/Classes/ViewRelated/Me/Help & Support/Activity Logs/ActivityLogViewController.m +++ b/WordPress/Classes/ViewRelated/Me/Help & Support/Activity Logs/ActivityLogViewController.m @@ -21,9 +21,7 @@ - (id)init { self = [super initWithStyle:UITableViewStyleGrouped]; if (self) { - // TODO - Replace this call with an injected value, depending on design conventions already in place - WordPressAppDelegate *delegate = (WordPressAppDelegate *)[[UIApplication sharedApplication] delegate]; - _fileLogger = delegate.logger.fileLogger; + _fileLogger = [WPLogger shared].fileLogger; self.title = NSLocalizedString(@"Activity Logs", @""); @@ -141,16 +139,35 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath forDateString:[self.dateFormatter stringFromDate:logFileInfo.creationDate]]; [self.navigationController pushViewController:detailViewController animated:YES]; } else { - for (DDLogFileInfo *logFileInfo in self.logFiles) { - if (logFileInfo.isArchived) { - [[NSFileManager defaultManager] removeItemAtPath:logFileInfo.filePath error:nil]; - } - } - - DDLogWarn(@"All archived log files erased."); - - [self loadLogFiles]; - [self.tableView reloadData]; + //Delete old activity logs + NSString *titleText = NSLocalizedString(@"Delete", comment: @"Title of the trash confirmation alert."); + NSString *messageText = NSLocalizedString(@"Clear all old activity logs?", comment: @"Message of the trash confirmation alert."); + NSString *trashButtonTitle = NSLocalizedString(@"Yes", comment: @"Label for a button that clears all old activity logs"); + NSString *cancelButtonTitle = NSLocalizedString(@"No", comment: @"Label for a cancel button"); + + UIAlertController *alert = [UIAlertController + alertControllerWithTitle: titleText + message: messageText + preferredStyle:UIAlertControllerStyleAlert]; + + //Add Buttons + UIAlertAction *yesButton = [UIAlertAction + actionWithTitle:trashButtonTitle + style:UIAlertActionStyleDestructive + handler:^(UIAlertAction * __unused action) { + [WPLogger.shared deleteArchivedLogs]; + [self loadLogFiles]; + [self.tableView reloadData]; + }]; + + UIAlertAction *noButton = [UIAlertAction + actionWithTitle:cancelButtonTitle + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * __unused action) { + }]; + [alert addAction:noButton]; + [alert addAction:yesButton]; + [self presentViewController:alert animated:YES completion:nil]; } } diff --git a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController+UIViewControllerRestoration.swift b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController+UIViewControllerRestoration.swift index 359e32396b89..ea769ab8e68f 100644 --- a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController+UIViewControllerRestoration.swift +++ b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController+UIViewControllerRestoration.swift @@ -8,23 +8,17 @@ extension MeViewController: UIViewControllerRestoration { static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { - if FeatureFlag.meMove.enabled { - return MeViewController() - } else { - return WPTabBarController.sharedInstance().meViewController - } + return MeViewController() } override func decodeRestorableState(with coder: NSCoder) { super.decodeRestorableState(with: coder) - if FeatureFlag.meMove.enabled { - // needs to be done after self has been initialized, so we do it in this method - let doneButton = UIBarButtonItem(target: self, action: #selector(dismissHandler)) - navigationItem.rightBarButtonItem = doneButton - if WPDeviceIdentification.isiPad() { - navigationController?.modalPresentationStyle = .formSheet - navigationController?.modalTransitionStyle = .coverVertical - } + // needs to be done after self has been initialized, so we do it in this method + let doneButton = UIBarButtonItem(target: self, action: #selector(dismissHandler)) + navigationItem.rightBarButtonItem = doneButton + if WPDeviceIdentification.isiPad() { + navigationController?.modalPresentationStyle = .formSheet + navigationController?.modalTransitionStyle = .coverVertical } } diff --git a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift index 8e670a66ca4a..2f3de784edee 100644 --- a/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/Me Main/MeViewController.swift @@ -3,7 +3,7 @@ import CocoaLumberjack import WordPressShared import Gridicons import WordPressAuthenticator - +import AutomatticAbout class MeViewController: UITableViewController { var handler: ImmuTableViewHandler! @@ -59,7 +59,7 @@ class MeViewController: UITableViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - refreshAccountDetails() + refreshAccountDetailsAndSettings() if splitViewControllerIsHorizontallyCompact { animateDeselectionInteractively() @@ -78,7 +78,6 @@ class MeViewController: UITableViewController { @objc fileprivate func reloadViewModel() { let account = defaultAccount() - let loggedIn = account != nil // Warning: If you set the header view after the table model, the // table's top margin will be wrong. @@ -86,7 +85,9 @@ class MeViewController: UITableViewController { // My guess is the table view adjusts the height of the first section // based on if there's a header or not. tableView.tableHeaderView = account.map { headerViewForAccount($0) } - handler.viewModel = tableViewModel(loggedIn) + + // Then we'll reload the table view model (prompting a table reload) + handler.viewModel = tableViewModel(with: account) } fileprivate func headerViewForAccount(_ account: WPAccount) -> MeHeaderView { @@ -102,32 +103,40 @@ class MeViewController: UITableViewController { return NavigationItemRow( title: RowTitles.appSettings, - icon: Gridicon.iconOfType(.phone), + icon: .gridicon(.phone), accessoryType: accessoryType, action: pushAppSettings(), accessibilityIdentifier: "appSettings") } - fileprivate func tableViewModel(_ loggedIn: Bool) -> ImmuTable { + fileprivate func tableViewModel(with account: WPAccount?) -> ImmuTable { let accessoryType: UITableViewCell.AccessoryType = .disclosureIndicator + let loggedIn = account != nil let myProfile = NavigationItemRow( title: RowTitles.myProfile, - icon: Gridicon.iconOfType(.user), + icon: .gridicon(.user), accessoryType: accessoryType, action: pushMyProfile(), accessibilityIdentifier: "myProfile") + let qrLogin = NavigationItemRow( + title: RowTitles.qrLogin, + icon: .gridicon(.camera), + accessoryType: accessoryType, + action: presentQRLogin(), + accessibilityIdentifier: "qrLogin") + let accountSettings = NavigationItemRow( title: RowTitles.accountSettings, - icon: Gridicon.iconOfType(.cog), + icon: .gridicon(.cog), accessoryType: accessoryType, action: pushAccountSettings(), accessibilityIdentifier: "accountSettings") let helpAndSupportIndicator = IndicatorNavigationItemRow( title: RowTitles.support, - icon: Gridicon.iconOfType(.help), + icon: .gridicon(.help), showIndicator: ZendeskUtils.showSupportNotificationIndicator, accessoryType: accessoryType, action: pushHelp()) @@ -143,35 +152,49 @@ class MeViewController: UITableViewController { let wordPressComAccount = HeaderTitles.wpAccount - if loggedIn { - return ImmuTable( - sections: [ - ImmuTableSection(rows: [ - myProfile, - accountSettings, - appSettingsRow - ]), - ImmuTableSection(rows: [helpAndSupportIndicator]), - ImmuTableSection( - headerText: wordPressComAccount, - rows: [ - logOut - ]) - ]) - } else { // Logged out - return ImmuTable( - sections: [ - ImmuTableSection(rows: [ - appSettingsRow, - ]), - ImmuTableSection(rows: [helpAndSupportIndicator]), - ImmuTableSection( - headerText: wordPressComAccount, - rows: [ - logIn - ]) - ]) - } + let shouldShowQRLoginRow = AppConfiguration.qrLoginEnabled + && FeatureFlag.qrLogin.enabled + && !(account?.settings?.twoStepEnabled ?? false) + + return ImmuTable(sections: [ + // first section + .init(rows: { + var rows: [ImmuTableRow] = [appSettingsRow] + if loggedIn { + var loggedInRows = [myProfile, accountSettings] + if shouldShowQRLoginRow { + loggedInRows.append(qrLogin) + } + + rows = loggedInRows + rows + } + return rows + }()), + + // middle section + .init(rows: { + var rows: [ImmuTableRow] = [helpAndSupportIndicator] + + rows.append(NavigationItemRow(title: ShareAppContentPresenter.RowConstants.buttonTitle, + icon: ShareAppContentPresenter.RowConstants.buttonIconImage, + accessoryType: accessoryType, + action: displayShareFlow(), + loading: sharePresenter.isLoading)) + + rows.append(NavigationItemRow(title: RowTitles.about, + icon: UIImage.gridicon(AppConfiguration.isJetpack ? .plans : .mySites), + accessoryType: .disclosureIndicator, + action: pushAbout(), + accessibilityIdentifier: "About")) + + return rows + }()), + + // last section + .init(headerText: wordPressComAccount, rows: { + return [loggedIn ? logOut : logIn] + }()) + ]) } // MARK: - UITableViewDelegate @@ -220,10 +243,22 @@ class MeViewController: UITableViewController { self.navigationController?.pushViewController(controller, animated: true, rightBarButton: self.navigationItem.rightBarButtonItem) + } } } + private func presentQRLogin() -> ImmuTableAction { + return { [weak self] row in + guard let self = self else { + return + } + + self.tableView.deselectSelectedRowWithAnimation(true) + QRLoginCoordinator.present(from: self, origin: .menu) + } + } + func pushAppSettings() -> ImmuTableAction { return { [unowned self] row in WPAppAnalytics.track(.openedAppSettings) @@ -236,13 +271,40 @@ class MeViewController: UITableViewController { func pushHelp() -> ImmuTableAction { return { [unowned self] row in - let controller = SupportTableViewController() + let controller = SupportTableViewController(style: .insetGrouped) self.navigationController?.pushViewController(controller, animated: true, rightBarButton: self.navigationItem.rightBarButtonItem) } } + private func pushAbout() -> ImmuTableAction { + return { [unowned self] _ in + 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) + } + } + } + + func displayShareFlow() -> ImmuTableAction { + return { [unowned self] row in + defer { + self.tableView.deselectSelectedRowWithAnimation(true) + } + + guard let selectedIndexPath = self.tableView.indexPathForSelectedRow, + let selectedCell = self.tableView.cellForRow(at: selectedIndexPath) else { + return + } + + self.sharePresenter.present(for: AppConstants.shareAppName, in: self, source: .me, sourceView: selectedCell) + } + } + fileprivate func presentLogin() -> ImmuTableAction { return { [unowned self] row in self.tableView.deselectSelectedRowWithAnimation(true) @@ -306,25 +368,49 @@ class MeViewController: UITableViewController { // FIXME: (@koke 2015-12-17) Not cool. Let's stop passing managed objects // and initializing stuff with safer values like userID fileprivate func defaultAccount() -> WPAccount? { - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - let account = service.defaultWordPressComAccount() - return account + return try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) } - fileprivate func refreshAccountDetails() { - guard let account = defaultAccount() else { + fileprivate func refreshAccountDetailsAndSettings() { + guard let account = defaultAccount(), let api = account.wordPressComRestApi else { reloadViewModel() return } - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - service.updateUserDetails(for: account, success: { [weak self] in - self?.reloadViewModel() - }, failure: { error in + let accountService = AccountService(coreDataStack: ContextManager.sharedInstance()) + let accountSettingsService = AccountSettingsService(userID: account.userID.intValue, api: api) + + Task { + do { + async let refreshDetails: Void = Self.refreshAccountDetails(with: accountService, account: account) + async let refreshSettings: Void = Self.refreshAccountSettings(with: accountSettingsService) + let _ = try await [refreshDetails, refreshSettings] + self.reloadViewModel() + } catch let error { DDLogError(error.localizedDescription) - }) + } + } + } + + fileprivate static func refreshAccountDetails(with service: AccountService, account: WPAccount) async throws { + return try await withCheckedThrowingContinuation { continuation in + service.updateUserDetails(for: account, success: { + continuation.resume() + }, failure: { error in + continuation.resume(throwing: error) + }) + } + } + + fileprivate static func refreshAccountSettings(with service: AccountSettingsService) async throws { + return try await withCheckedThrowingContinuation { continuation in + service.refreshSettings { result in + switch result { + case .success: continuation.resume() + case .failure(let error): continuation.resume(throwing: error) + } + } + } } // MARK: - LogOut @@ -332,8 +418,10 @@ class MeViewController: UITableViewController { private func displayLogOutAlert() { let alert = UIAlertController(title: logOutAlertTitle, message: nil, preferredStyle: .alert) alert.addActionWithTitle(LogoutAlert.cancelAction, style: .cancel) - alert.addActionWithTitle(LogoutAlert.logoutAction, style: .destructive) { _ in - AccountHelper.logOutDefaultWordPressComAccount() + alert.addActionWithTitle(LogoutAlert.logoutAction, style: .destructive) { [weak self] _ in + self?.dismiss(animated: true) { + AccountHelper.logOutDefaultWordPressComAccount() + } } present(alert, animated: true) @@ -341,8 +429,7 @@ class MeViewController: UITableViewController { private var logOutAlertTitle: String { let context = ContextManager.sharedInstance().mainContext - let service = PostService(managedObjectContext: context) - let count = service.countPostsWithoutRemote() + let count = AbstractPost.countLocalPosts(in: context) guard count > 0 else { return LogoutAlert.defaultTitle @@ -390,6 +477,12 @@ class MeViewController: UITableViewController { fileprivate func promptForLoginOrSignup() { WordPressAuthenticator.showLogin(from: self, animated: true, showCancel: true, restrictToWPCom: true) } + + private lazy var sharePresenter: ShareAppContentPresenter = { + let presenter = ShareAppContentPresenter(account: defaultAccount()) + presenter.delegate = self + return presenter + }() } // MARK: - SearchableActivity Conformance @@ -443,9 +536,11 @@ private extension MeViewController { static let appSettings = NSLocalizedString("App Settings", comment: "Link to App Settings section") static let myProfile = NSLocalizedString("My Profile", comment: "Link to My Profile section") static let accountSettings = NSLocalizedString("Account Settings", comment: "Link to Account Settings section") + static let qrLogin = NSLocalizedString("Scan Login Code", comment: "Link to opening the QR login scanner") static let support = NSLocalizedString("Help & Support", comment: "Link to Help section") static let logIn = NSLocalizedString("Log In", comment: "Label for logging in to WordPress.com account") static let logOut = NSLocalizedString("Log Out", comment: "Label for logging out from WordPress.com account") + static let about = AppConstants.Settings.aboutTitle } enum HeaderTitles { @@ -453,7 +548,7 @@ private extension MeViewController { } enum LogoutAlert { - static let defaultTitle = NSLocalizedString("Log out of WordPress?", comment: "LogOut confirmation text, whenever there are no local changes") + static let defaultTitle = AppConstants.Logout.alertTitle static let unsavedTitleSingular = NSLocalizedString("You have changes to %d post that hasn't been uploaded to your site. Logging out now will delete those changes. Log out anyway?", comment: "Warning displayed before logging out. The %d placeholder will contain the number of local posts (SINGULAR!)") static let unsavedTitlePlural = NSLocalizedString("You have changes to %d posts that haven’t been uploaded to your site. Logging out now will delete those changes. Log out anyway?", @@ -471,3 +566,31 @@ private extension MeViewController { reloadViewModel() } } + +// MARK: - ShareAppContentPresenterDelegate + +extension MeViewController: ShareAppContentPresenterDelegate { + func didUpdateLoadingState(_ loading: Bool) { + reloadViewModel() + } +} + +// MARK: - Jetpack powered badge +extension MeViewController { + + override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + guard section == handler.viewModel.sections.count - 1, + JetpackBrandingVisibility.all.enabled else { + return nil + } + let textProvider = JetpackBrandingTextProvider(screen: JetpackBadgeScreen.me) + return JetpackButton.makeBadgeView(title: textProvider.brandingText(), + target: self, + selector: #selector(jetpackButtonTapped)) + } + + @objc private func jetpackButtonTapped() { + JetpackBrandingCoordinator.presentOverlay(from: self) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBadgeTapped(screen: .me) + } +} diff --git a/WordPress/Classes/ViewRelated/Me/Me Main/Presenter/MeScenePresenter.swift b/WordPress/Classes/ViewRelated/Me/Me Main/Presenter/MeScenePresenter.swift index b058b9d1a84e..1d0f650afdfa 100644 --- a/WordPress/Classes/ViewRelated/Me/Me Main/Presenter/MeScenePresenter.swift +++ b/WordPress/Classes/ViewRelated/Me/Me Main/Presenter/MeScenePresenter.swift @@ -6,6 +6,7 @@ import UIKit /// in a UISplitViewController/UINavigationController view hierarchy @objc class MeScenePresenter: NSObject, ScenePresenter { + /// weak reference to the presented scene (no reference retained after it's dismissed) private(set) weak var presentedViewController: UIViewController? @@ -37,7 +38,7 @@ private extension MeScenePresenter { } func makeMeViewController() -> MeViewController { - return MeViewController() + return MeViewController(style: .insetGrouped) } func makeNavigationController() -> UINavigationController { diff --git a/WordPress/Classes/ViewRelated/Me/My Profile/Change Username/ChangeUsernameViewController.swift b/WordPress/Classes/ViewRelated/Me/My Profile/Change Username/ChangeUsernameViewController.swift index 73074cb1b2dc..b1eddc602519 100644 --- a/WordPress/Classes/ViewRelated/Me/My Profile/Change Username/ChangeUsernameViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/My Profile/Change Username/ChangeUsernameViewController.swift @@ -1,6 +1,11 @@ +import WordPressAuthenticator + class ChangeUsernameViewController: SignupUsernameTableViewController { typealias CompletionBlock = (String?) -> Void + override var analyticsSource: String { + return "account_settings" + } private let viewModel: ChangeUsernameViewModel private let completionBlock: CompletionBlock private lazy var saveBarButtonItem: UIBarButtonItem = { @@ -39,6 +44,8 @@ class ChangeUsernameViewController: SignupUsernameTableViewController { override func startSearch(for searchTerm: String) { saveBarButtonItem.isEnabled = false viewModel.suggestUsernames(for: searchTerm, reloadingAllSections: false) + + trackSearchPerformed() } } @@ -55,12 +62,12 @@ private extension ChangeUsernameViewController { viewModel.suggestionsListener = { [weak self] state, suggestions, reloadAllSections in switch state { case .loading: - SVProgressHUD.show(withStatus: Constants.Alert.loading) + self?.showLoader() case .success: if suggestions.isEmpty { WPAppAnalytics.track(.accountSettingsChangeUsernameSuggestionsFailed) } - SVProgressHUD.dismiss() + self?.hideLoader() self?.suggestions = suggestions self?.reloadSections(includingAllSections: reloadAllSections) default: diff --git a/WordPress/Classes/ViewRelated/Me/My Profile/Change Username/View Model/ChangeUsernameViewModel.swift b/WordPress/Classes/ViewRelated/Me/My Profile/Change Username/View Model/ChangeUsernameViewModel.swift index 323fcffaed70..0042ca4c573a 100644 --- a/WordPress/Classes/ViewRelated/Me/My Profile/Change Username/View Model/ChangeUsernameViewModel.swift +++ b/WordPress/Classes/ViewRelated/Me/My Profile/Change Username/View Model/ChangeUsernameViewModel.swift @@ -10,15 +10,15 @@ class ChangeUsernameViewModel { var username: String { return settings?.username ?? "" } + var displayName: String { return settings?.displayName ?? "" } - var formattedCreatedDate: String? { - return accountService.defaultWordPressComAccount()?.dateCreated.mediumString() - } + var isReachable: Bool { return reachability?.isReachable() ?? false } + var usernameIsValidToBeChanged: Bool { return selectedUsername != username && !selectedUsername.isEmpty } @@ -38,7 +38,6 @@ class ChangeUsernameViewModel { private let settings: AccountSettings? private let store: AccountSettingsStore private let reachability = Reachability.forInternetConnection() - private let accountService = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext) private var receipt: Receipt? private var saveUsernameBlock: StateBlock? private var reloadAllSections: Bool = true diff --git a/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarPickerViewController.swift b/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarPickerViewController.swift index f8c872c96e2c..0cdd8432c7a9 100644 --- a/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarPickerViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarPickerViewController.swift @@ -15,16 +15,7 @@ class GravatarPickerViewController: UIViewController, WPMediaPickerViewControlle fileprivate var mediaPickerViewController: WPNavigationMediaPickerViewController! - fileprivate lazy var mediaPickerAssetDataSource: WPPHAssetDataSource? = { - let collectionsFetchResult = PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .smartAlbumSelfPortraits, options: nil) - guard let assetCollection = collectionsFetchResult.firstObject else { - return nil - } - - let dataSource = WPPHAssetDataSource() - dataSource.setSelectedGroup(PHAssetCollectionForWPMediaGroup(collection: assetCollection, mediaType: .image)) - return dataSource - }() + fileprivate lazy var mediaPickerAssetDataSource = WPPHAssetDataSource() // MARK: - View Lifecycle Methods @@ -106,7 +97,7 @@ class GravatarPickerViewController: UIViewController, WPMediaPickerViewControlle options.preferFrontCamera = true options.allowMultipleSelection = false options.badgedUTTypes = [String(kUTTypeGIF)] - options.preferredStatusBarStyle = .lightContent + options.preferredStatusBarStyle = WPStyleGuide.preferredStatusBarStyle let pickerViewController = WPNavigationMediaPickerViewController(options: options) pickerViewController.delegate = self @@ -128,7 +119,7 @@ class GravatarPickerViewController: UIViewController, WPMediaPickerViewControlle } override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent + return WPStyleGuide.preferredStatusBarStyle } override var childForStatusBarStyle: UIViewController? { diff --git a/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarUploader.swift b/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarUploader.swift index 30f18578a495..40ed093bea46 100644 --- a/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarUploader.swift +++ b/WordPress/Classes/ViewRelated/Me/My Profile/Gravatar/GravatarUploader.swift @@ -27,8 +27,8 @@ extension GravatarUploader { func uploadGravatarImage(_ newGravatar: UIImage) { let context = ContextManager.sharedInstance().mainContext - let accountService = AccountService(managedObjectContext: context) - guard let account = accountService.defaultWordPressComAccount() else { + + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) else { return } diff --git a/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileHeaderView.swift b/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileHeaderView.swift index 82eab87b02f2..66ec36c9fdc8 100644 --- a/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileHeaderView.swift @@ -1,12 +1,12 @@ import Foundation -class MyProfileHeaderView: WPTableViewCell { +class MyProfileHeaderView: UITableViewHeaderFooterView { // MARK: - Public Properties and Outlets @IBOutlet var gravatarImageView: CircularImageView! @IBOutlet var gravatarButton: UIButton! var onAddUpdatePhoto: (() -> Void)? - let activityIndicator = UIActivityIndicatorView(style: .white) + let activityIndicator = UIActivityIndicatorView(style: .medium) var showsActivityIndicator: Bool { get { return activityIndicator.isAnimating @@ -27,10 +27,6 @@ class MyProfileHeaderView: WPTableViewCell { } } - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - } - required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } diff --git a/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileViewController.swift b/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileViewController.swift index 81788e7bc213..ee0ea91bf103 100644 --- a/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileViewController.swift +++ b/WordPress/Classes/ViewRelated/Me/My Profile/MyProfileViewController.swift @@ -13,7 +13,7 @@ func MyProfileViewController(account: WPAccount) -> ImmuTableViewController? { func MyProfileViewController(account: WPAccount, service: AccountSettingsService, headerView: MyProfileHeaderView) -> ImmuTableViewController { let controller = MyProfileController(account: account, service: service, headerView: headerView) - let viewController = ImmuTableViewController(controller: controller) + let viewController = ImmuTableViewController(controller: controller, style: .insetGrouped) headerView.onAddUpdatePhoto = { [weak controller, weak viewController] in if let viewController = viewController { controller?.presentGravatarPicker(viewController) @@ -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: [ @@ -180,11 +187,6 @@ private class MyProfileController: SettingsController { // FIXME: (@koke 2015-12-17) Not cool. Let's stop passing managed objects // and initializing stuff with safer values like userID fileprivate func defaultAccount() -> WPAccount? { - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - let account = service.defaultWordPressComAccount() - // Again, ! isn't cool, but let's keep it for now until we refactor the VC - // initialization parameters. - return account + try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) } } diff --git a/WordPress/Classes/ViewRelated/Me/Me Main/Header/MeHeaderView.h b/WordPress/Classes/ViewRelated/Me/Views/Header/MeHeaderView.h similarity index 100% rename from WordPress/Classes/ViewRelated/Me/Me Main/Header/MeHeaderView.h rename to WordPress/Classes/ViewRelated/Me/Views/Header/MeHeaderView.h diff --git a/WordPress/Classes/ViewRelated/Me/Me Main/Header/MeHeaderView.m b/WordPress/Classes/ViewRelated/Me/Views/Header/MeHeaderView.m similarity index 95% rename from WordPress/Classes/ViewRelated/Me/Me Main/Header/MeHeaderView.m rename to WordPress/Classes/ViewRelated/Me/Views/Header/MeHeaderView.m index 1a07dc72d313..2ed1d155d624 100644 --- a/WordPress/Classes/ViewRelated/Me/Me Main/Header/MeHeaderView.m +++ b/WordPress/Classes/ViewRelated/Me/Views/Header/MeHeaderView.m @@ -226,7 +226,7 @@ - (UIView *)newDropTargetForGravatar - (UIActivityIndicatorView *)newSpinner { - UIActivityIndicatorView *indicatorView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhite]; + UIActivityIndicatorView *indicatorView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; indicatorView.hidesWhenStopped = YES; indicatorView.translatesAutoresizingMaskIntoConstraints = NO; @@ -261,7 +261,7 @@ - (IBAction)handleHeaderPress:(UIGestureRecognizer *)sender #pragma mark - Drop Interaction Handler - (BOOL)dropInteraction:(UIDropInteraction *)interaction - canHandleSession:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) + canHandleSession:(id<UIDropSession>)session { BOOL isAnImage = [session canLoadObjectsOfClass:[UIImage self]]; BOOL isSingleImage = [session.items count] == 1; @@ -269,13 +269,13 @@ - (BOOL)dropInteraction:(UIDropInteraction *)interaction } - (void)dropInteraction:(UIDropInteraction *)interaction - sessionDidEnter:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) + sessionDidEnter:(id<UIDropSession>)session { [self.gravatarImageView depressSpringAnimation:nil]; } - (UIDropProposal *)dropInteraction:(UIDropInteraction *)interaction - sessionDidUpdate:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) + sessionDidUpdate:(id<UIDropSession>)session { CGPoint dropLocation = [session locationInView:self.gravatarDropTarget]; @@ -291,7 +291,7 @@ - (UIDropProposal *)dropInteraction:(UIDropInteraction *)interaction } - (void)dropInteraction:(UIDropInteraction *)interaction - performDrop:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) + performDrop:(id<UIDropSession>)session { [self setShowsActivityIndicator:YES]; [session loadObjectsOfClass:[UIImage self] completion:^(NSArray *images) { @@ -303,19 +303,19 @@ - (void)dropInteraction:(UIDropInteraction *)interaction } - (void)dropInteraction:(UIDropInteraction *)interaction - concludeDrop:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) + concludeDrop:(id<UIDropSession>)session { [self.gravatarImageView normalizeSpringAnimation:nil]; } - (void)dropInteraction:(UIDropInteraction *)interaction - sessionDidExit:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) + sessionDidExit:(id<UIDropSession>)session { [self.gravatarImageView normalizeSpringAnimation:nil]; } - (void)dropInteraction:(UIDropInteraction *)interaction - sessionDidEnd:(id<UIDropSession>)session API_AVAILABLE(ios(11.0)) + sessionDidEnd:(id<UIDropSession>)session { [self.gravatarImageView normalizeSpringAnimation:nil]; } diff --git a/WordPress/Classes/ViewRelated/Me/Views/Header/MeHeaderViewConfiguration.swift b/WordPress/Classes/ViewRelated/Me/Views/Header/MeHeaderViewConfiguration.swift new file mode 100644 index 000000000000..0380f233acb3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Me/Views/Header/MeHeaderViewConfiguration.swift @@ -0,0 +1,30 @@ +import UIKit + +struct MeHeaderViewConfiguration { + + let gravatarEmail: String? + let username: String + let displayName: String +} + +extension MeHeaderViewConfiguration { + + init(account: WPAccount) { + self.init( + gravatarEmail: account.email, + username: account.username, + displayName: account.displayName + ) + } +} + +extension MeHeaderView { + + func update(with configuration: Configuration) { + self.gravatarEmail = configuration.gravatarEmail + self.username = configuration.username + self.displayName = configuration.displayName + } + + typealias Configuration = MeHeaderViewConfiguration +} diff --git a/WordPress/Classes/ViewRelated/Media/AnimatedImageCache.swift b/WordPress/Classes/ViewRelated/Media/AnimatedImageCache.swift index 2cd1229c2691..ab086dddf8c1 100644 --- a/WordPress/Classes/ViewRelated/Media/AnimatedImageCache.swift +++ b/WordPress/Classes/ViewRelated/Media/AnimatedImageCache.swift @@ -1,4 +1,4 @@ -import Foundation +import UIKit /// AnimatedImageCache is an image + animated gif data cache used in /// CachedAnimatedImageView. It should be accessed via the `shared` singleton. @@ -71,7 +71,7 @@ class AnimatedImageCache { func animatedImage(_ urlRequest: URLRequest, placeholderImage: UIImage?, - success: ((Data, UIImage?) -> Void)? , + success: ((Data, UIImage?) -> Void)?, failure: ((NSError?) -> Void)? ) -> URLSessionTask? { if let cachedImageData = cachedData(url: urlRequest.url) { diff --git a/WordPress/Classes/ViewRelated/Media/CachedAnimatedImageView.swift b/WordPress/Classes/ViewRelated/Media/CachedAnimatedImageView.swift index 329d52e2a2e1..aea9a4e638bc 100644 --- a/WordPress/Classes/ViewRelated/Media/CachedAnimatedImageView.swift +++ b/WordPress/Classes/ViewRelated/Media/CachedAnimatedImageView.swift @@ -53,7 +53,7 @@ public class CachedAnimatedImageView: UIImageView, GIFAnimatable { private var customLoadingIndicator: ActivityIndicatorType? private lazy var defaultLoadingIndicator: UIActivityIndicatorView = { - let loadingIndicator = UIActivityIndicatorView(style: .gray) + let loadingIndicator = UIActivityIndicatorView(style: .medium) layoutViewCentered(loadingIndicator, size: nil) return loadingIndicator }() @@ -96,7 +96,14 @@ public class CachedAnimatedImageView: UIImageView, GIFAnimatable { // MARK: - Public methods - override public func display(_ layer: CALayer) { + override open func display(_ layer: CALayer) { + // Fixes an unrecognized selector crash on iOS 13 and below when calling super.display(_:) directly + // This was first reported here: p5T066-1xs-p2#comment-5908 + // Investigating the issue I came across this discussion with a workaround in the Gifu repo: https://git.io/JUPxC + if UIImageView.instancesRespond(to: #selector(display(_:))) { + super.display(layer) + } + updateImageIfNeeded() } diff --git a/WordPress/Classes/ViewRelated/Media/CameraCaptureCoordinator.swift b/WordPress/Classes/ViewRelated/Media/CameraCaptureCoordinator.swift index 1acf251f5638..6a86e209cef7 100644 --- a/WordPress/Classes/ViewRelated/Media/CameraCaptureCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Media/CameraCaptureCoordinator.swift @@ -4,7 +4,10 @@ import MobileCoreServices final class CameraCaptureCoordinator { private var capturePresenter: WPMediaCapturePresenter? + private weak var origin: UIViewController? + func presentMediaCapture(origin: UIViewController, blog: Blog) { + self.origin = origin capturePresenter = WPMediaCapturePresenter(presenting: origin) capturePresenter!.completionBlock = { [weak self] mediaInfo in if let mediaInfo = mediaInfo as NSDictionary? { @@ -16,14 +19,18 @@ final class CameraCaptureCoordinator { capturePresenter!.presentCapture() } - private func processMediaCaptured(_ mediaInfo: NSDictionary, blog: Blog) { + private func processMediaCaptured(_ mediaInfo: NSDictionary, blog: Blog, origin: UIViewController? = nil) { let completionBlock: WPMediaAddedBlock = { media, error in if error != nil || media == nil { print("Adding media failed: ", error?.localizedDescription ?? "no media") return } - guard let media = media as? PHAsset else { - return + guard let media = media as? PHAsset, + blog.canUploadAsset(media) else { + if let origin = origin ?? self.origin { + self.presentVideoLimitExceededAfterCapture(on: origin) + } + return } let info = MediaAnalyticsInfo(origin: .mediaLibrary(.camera), selectionMethod: .fullScreenPicker) @@ -47,3 +54,6 @@ final class CameraCaptureCoordinator { } } } + +/// User messages for video limits allowances +extension CameraCaptureCoordinator: VideoLimitsAlertPresenter {} diff --git a/WordPress/Classes/ViewRelated/Media/CircularProgressView.swift b/WordPress/Classes/ViewRelated/Media/CircularProgressView.swift index 339e04790d77..a07083334314 100644 --- a/WordPress/Classes/ViewRelated/Media/CircularProgressView.swift +++ b/WordPress/Classes/ViewRelated/Media/CircularProgressView.swift @@ -28,13 +28,13 @@ class CircularProgressView: UIView { @objc(CircularProgressViewStyle) enum Style: Int { - case wordPressBlue + case primary case white case mediaCell fileprivate var appearance: Appearance { switch self { - case .wordPressBlue: + case .primary: return Appearance( progressIndicatorAppearance: ProgressIndicatorView.Appearance(lineColor: .primary(.shade40)), backgroundColor: .clear, @@ -188,7 +188,7 @@ class CircularProgressView: UIView { private func configureRetryViews() { configureAccessoryView(retryView) retryView.label.text = NSLocalizedString("Retry", comment: "Retry. Verb – retry a failed media upload.") - retryView.imageView.image = Gridicon.iconOfType(.refresh) + retryView.imageView.image = .gridicon(.refresh) } private func configureAccessoryView(_ view: UIView) { @@ -288,7 +288,7 @@ final class ProgressIndicatorView: UIView { private let progressTrackLayer = CAShapeLayer() private let progressLayer = CAShapeLayer() - fileprivate struct Appearance { + struct Appearance { let defaultSize: CGFloat let lineWidth: CGFloat let lineColor: UIColor @@ -323,7 +323,7 @@ final class ProgressIndicatorView: UIView { private let appearance: Appearance private var isAnimating = false - fileprivate init(appearance: Appearance = Appearance()) { + init(appearance: Appearance = Appearance()) { self.appearance = appearance super.init(frame: CGRect(x: 0, y: 0, width: appearance.defaultSize, height: appearance.defaultSize)) setup() @@ -354,6 +354,11 @@ final class ProgressIndicatorView: UIView { layer.isHidden = true } + private func updateColors() { + indeterminateLayer.strokeColor = appearance.lineColor.cgColor + progressTrackLayer.strokeColor = appearance.trackColor.cgColor + } + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -370,6 +375,12 @@ final class ProgressIndicatorView: UIView { } } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + updateColors() + setNeedsDisplay() + } + private func stateDidChange() { switch state { case .stopped: diff --git a/WordPress/Classes/ViewRelated/Media/DeviceMediaPermissionsHeader.swift b/WordPress/Classes/ViewRelated/Media/DeviceMediaPermissionsHeader.swift new file mode 100644 index 000000000000..89bde5265129 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/DeviceMediaPermissionsHeader.swift @@ -0,0 +1,195 @@ +import UIKit +import PhotosUI + +/// Displays a notice at the top of a media picker view in the event that the user has only given the app +/// limited photo library permissions. Contains buttons allowing the user to select more images or review their settings. +/// +class DeviceMediaPermissionsHeader: UICollectionReusableView { + + weak var presenter: UIViewController? + + private let label: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontForContentSizeCategory = true + label.setContentHuggingPriority(.defaultLow, for: .vertical) + label.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + label.text = TextContent.message + label.textColor = .invertedLabel + label.font = .preferredFont(forTextStyle: .subheadline) + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + + return label + }() + + private lazy var selectButton: UIButton = { + let selectButton = UIButton() + configureButton(selectButton) + selectButton.setTitle(TextContent.selectButtonTitle, for: .normal) + selectButton.addTarget(self, action: #selector(selectMoreTapped), for: .touchUpInside) + + return selectButton + }() + + private lazy var settingsButton: UIButton = { + let settingsButton = UIButton() + configureButton(settingsButton) + settingsButton.setTitle(TextContent.settingsButtonTitle, for: .normal) + settingsButton.addTarget(self, action: #selector(changeSettingsTapped), for: .touchUpInside) + + return settingsButton + }() + + private let infoIcon: UIImageView = { + let infoIcon = UIImageView(image: UIImage.gridicon(.info)) + infoIcon.translatesAutoresizingMaskIntoConstraints = false + infoIcon.tintColor = .invertedLabel + return infoIcon + }() + + private let buttonStackView: UIStackView = { + let buttonStackView = UIStackView() + buttonStackView.translatesAutoresizingMaskIntoConstraints = false + buttonStackView.distribution = .fillEqually + buttonStackView.spacing = Metrics.spacing + return buttonStackView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + commonInit() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + var background: UIView! + + private func commonInit() { + background = UIView() + background.translatesAutoresizingMaskIntoConstraints = false + background.backgroundColor = .invertedSystem5 + addSubview(background) + + background.layer.cornerRadius = Metrics.padding + + let outerStackView = UIStackView() + outerStackView.translatesAutoresizingMaskIntoConstraints = false + outerStackView.axis = .horizontal + outerStackView.alignment = .top + outerStackView.spacing = Metrics.padding + outerStackView.distribution = .fill + background.addSubview(outerStackView) + + let labelButtonsStackView = UIStackView() + labelButtonsStackView.translatesAutoresizingMaskIntoConstraints = false + labelButtonsStackView.axis = .vertical + labelButtonsStackView.alignment = .leading + labelButtonsStackView.distribution = .fillProportionally + labelButtonsStackView.spacing = Metrics.spacing + + outerStackView.addArrangedSubviews([infoIcon, labelButtonsStackView]) + labelButtonsStackView.addArrangedSubviews([label, buttonStackView]) + buttonStackView.addArrangedSubviews([selectButton, settingsButton]) + + activateBackgroundConstraints() + + NSLayoutConstraint.activate([ + outerStackView.leadingAnchor.constraint(equalTo: background.leadingAnchor, constant: Metrics.padding), + outerStackView.trailingAnchor.constraint(equalTo: background.trailingAnchor, constant: -Metrics.padding), + outerStackView.topAnchor.constraint(equalTo: background.topAnchor, constant: Metrics.padding), + outerStackView.bottomAnchor.constraint(equalTo: background.bottomAnchor, constant: -Metrics.padding), + + infoIcon.widthAnchor.constraint(equalTo: infoIcon.heightAnchor), + infoIcon.widthAnchor.constraint(equalToConstant: Metrics.iconSize) + ]) + + configureViewsForContentSizeCategoryChange() + } + + private func configureButton(_ button: UIButton) { + button.translatesAutoresizingMaskIntoConstraints = false + button.titleLabel?.font = .preferredFont(forTextStyle: .subheadline).bold() + button.titleLabel?.lineBreakMode = .byTruncatingTail + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.contentHorizontalAlignment = .leading + button.setTitleColor(.invertedLink, for: .normal) + } + + private func activateBackgroundConstraints() { + NSLayoutConstraint.activate([ + background.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: Metrics.padding), + background.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -Metrics.padding), + background.topAnchor.constraint(equalTo: topAnchor, constant: Metrics.padding), + background.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Metrics.padding) + ]) + } + + private func configureViewsForContentSizeCategoryChange() { + let isAccessibilityCategory = traitCollection.preferredContentSizeCategory.isAccessibilityCategory + + buttonStackView.axis = isAccessibilityCategory ? .vertical : .horizontal + buttonStackView.spacing = isAccessibilityCategory ? Metrics.spacing / 2.0 : Metrics.spacing + + infoIcon.isHidden = isAccessibilityCategory + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + configureViewsForContentSizeCategoryChange() + } + + // MARK: - Actions + + @objc private func selectMoreTapped() { + if let presenter = presenter { + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: presenter) + } + } + + @objc private func changeSettingsTapped() { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + + /// Returns the correct size for the header view, accounting for multi-line labels. + /// We constrain it to the same width as the host view itself, and ask the system for the appropriate size. + func referenceSizeInView(_ view: UIView) -> CGSize { + // We'll work with just the background view, as iOS 14 has issues if we attempt to layout the header view itself. + // We need to remove the background view from the header view while we do our calculations. + background.removeFromSuperview() + + // Constrain the background view to match the width of the parent view + let width = view.frame.size.width - (Metrics.padding * 2) + let widthConstraint = background.widthAnchor.constraint(equalToConstant: width) + widthConstraint.isActive = true + background.layoutIfNeeded() + + // Ask the system to calculate the correct height + let size = background.systemLayoutSizeFitting(CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)) + + // Put everything back how we found it + widthConstraint.isActive = false + addSubview(background) + activateBackgroundConstraints() + + return CGSize(width: size.width, height: size.height + (Metrics.padding * 2)) + } + + private enum Metrics { + static let padding: CGFloat = 8.0 + static let spacing: CGFloat = 16.0 + static let iconSize: CGFloat = 22.0 + } + + private enum TextContent { + static let message = NSLocalizedString("Only the selected photos you've given access to are available.", comment: "Message telling the user that they've only enabled limited photo library permissions for the app.") + static let selectButtonTitle = NSLocalizedString("Select More", comment: "Title of button that allows the user to select more photos to access within the app") + static let settingsButtonTitle = NSLocalizedString("Change Settings", comment: "Title of button that takes user to the system Settings section for the app") + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyDataLoader.swift b/WordPress/Classes/ViewRelated/Media/Giphy/GiphyDataLoader.swift deleted file mode 100644 index 661e72401a50..000000000000 --- a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyDataLoader.swift +++ /dev/null @@ -1,60 +0,0 @@ -/// Implementations of this protocol will be notified when data is loaded from the GiphyService -protocol GiphyDataLoaderDelegate: class { - func didLoad(media: [GiphyMedia], reset: Bool) -} - -/// Uses the GiphyService to load GIFs, handling pagination -final class GiphyDataLoader { - private let service: GiphyService - private var request: GiphySearchParams? - - private weak var delegate: GiphyDataLoaderDelegate? - - fileprivate enum State { - case loading - case idle - } - - fileprivate var state: State = .idle - - init(service: GiphyService, delegate: GiphyDataLoaderDelegate) { - self.service = service - self.delegate = delegate - } - - func search(_ params: GiphySearchParams) { - request = params - let isFirstPage = request?.pageable?.pageIndex == GiphyPageable.defaultPageIndex - state = .loading - DispatchQueue.main.async { [weak self] in - WPAnalytics.track(.giphySearched) - self?.service.search(params: params) { resultsPage in - self?.state = .idle - self?.request = GiphySearchParams(text: self?.request?.text, pageable: resultsPage.nextPageable()) - - if let content = resultsPage.content() { - self?.delegate?.didLoad(media: content, reset: isFirstPage) - } - } - } - } - - func loadNextPage() { - // Bail out if there is another active request - guard state == .idle else { - return - } - - // Bail out if we are not aware of the pagination status - guard let request = request else { - return - } - - // Bail out if we do not expect more pages of data - guard request.pageable?.next() != nil else { - return - } - - search(request) - } -} diff --git a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyDataSource.swift b/WordPress/Classes/ViewRelated/Media/Giphy/GiphyDataSource.swift deleted file mode 100644 index 915b33bd339b..000000000000 --- a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyDataSource.swift +++ /dev/null @@ -1,199 +0,0 @@ -import WPMediaPicker - - -/// Data Source for Giphy -final class GiphyDataSource: NSObject, WPMediaCollectionDataSource { - fileprivate static let paginationThreshold = 10 - - fileprivate var gifMedia = [GiphyMedia]() - var observers = [String: WPMediaChangesBlock]() - private var dataLoader: GiphyDataLoader? - - var onStartLoading: (() -> Void)? - var onStopLoading: (() -> Void)? - - private let scheduler = Scheduler(seconds: 0.5) - - private(set) var searchQuery: String = "" - - init(service: GiphyService) { - super.init() - self.dataLoader = GiphyDataLoader(service: service, delegate: self) - } - - func clearSearch(notifyObservers shouldNotify: Bool) { - gifMedia.removeAll() - if shouldNotify { - notifyObservers() - } - } - - func search(for searchText: String?) { - searchQuery = searchText ?? "" - - guard searchText?.isEmpty == false else { - clearSearch(notifyObservers: true) - scheduler.cancel() - return - } - - scheduler.debounce { [weak self] in - let params = GiphySearchParams(text: searchText, pageable: GiphyPageable.first()) - self?.search(params) - self?.onStartLoading?() - } - } - - private func search(_ params: GiphySearchParams) { - dataLoader?.search(params) - } - - func numberOfGroups() -> Int { - return 1 - } - - func group(at index: Int) -> WPMediaGroup { - return GiphyMediaGroup() - } - - func selectedGroup() -> WPMediaGroup? { - return GiphyMediaGroup() - } - - func numberOfAssets() -> Int { - return gifMedia.count - } - - func media(at index: Int) -> WPMediaAsset { - fetchMoreContentIfNecessary(index) - return gifMedia[index] - } - - func media(withIdentifier identifier: String) -> WPMediaAsset? { - return gifMedia.filter { $0.identifier() == identifier }.first - } - - func registerChangeObserverBlock(_ callback: @escaping WPMediaChangesBlock) -> NSObjectProtocol { - let blockKey = UUID().uuidString - observers[blockKey] = callback - return blockKey as NSString - } - - func unregisterChangeObserver(_ blockKey: NSObjectProtocol) { - guard let key = blockKey as? String else { - assertionFailure("blockKey must be of type String") - return - } - observers.removeValue(forKey: key) - } - - func registerGroupChangeObserverBlock(_ callback: @escaping WPMediaGroupChangesBlock) -> NSObjectProtocol { - // The group never changes - return NSNull() - } - - func unregisterGroupChangeObserver(_ blockKey: NSObjectProtocol) { - // The group never changes - } - - func loadData(with options: WPMediaLoadOptions, success successBlock: WPMediaSuccessBlock?, failure failureBlock: WPMediaFailureBlock? = nil) { - successBlock?() - } - - func mediaTypeFilter() -> WPMediaType { - return .image - } - - func ascendingOrdering() -> Bool { - return true - } - - func searchCancelled() { - searchQuery = "" - clearSearch(notifyObservers: true) - } - - // MARK: Unused protocol methods - - func setSelectedGroup(_ group: WPMediaGroup) { - // - } - - func add(_ image: UIImage, metadata: [AnyHashable: Any]?, completionBlock: WPMediaAddedBlock? = nil) { - // - } - - func addVideo(from url: URL, completionBlock: WPMediaAddedBlock? = nil) { - // - } - - func setMediaTypeFilter(_ filter: WPMediaType) { - // - } - - func setAscendingOrdering(_ ascending: Bool) { - // - } -} - - -// MARK: - Helpers - -extension GiphyDataSource { - private func notifyObservers(incremental: Bool = false, inserted: IndexSet = IndexSet()) { - DispatchQueue.main.async { - self.observers.forEach { - $0.value(incremental, IndexSet(), inserted, IndexSet(), []) - } - } - } -} - -// MARK: - Pagination - -extension GiphyDataSource { - fileprivate func fetchMoreContentIfNecessary(_ index: Int) { - if shouldLoadMore(index) { - dataLoader?.loadNextPage() - } - } - - private func shouldLoadMore(_ index: Int) -> Bool { - return index + type(of: self).paginationThreshold >= numberOfAssets() - } -} - -extension GiphyDataSource: GiphyDataLoaderDelegate { - func didLoad(media: [GiphyMedia], reset: Bool) { - defer { - onStopLoading?() - } - - guard media.count > 0 && searchQuery.count > 0 else { - clearSearch(notifyObservers: true) - return - } - - if reset { - overwriteMedia(with: media) - } else { - appendMedia(with: media) - } - } - - private func overwriteMedia(with media: [GiphyMedia]) { - gifMedia = media - notifyObservers(incremental: false) - } - - private func appendMedia(with media: [GiphyMedia]) { - let currentMaxIndex = gifMedia.count - let newMaxIndex = currentMaxIndex + media.count - 1 - - let isIncremental = currentMaxIndex != 0 - let insertedIndexes = IndexSet(integersIn: currentMaxIndex...newMaxIndex) - - gifMedia.append(contentsOf: media) - notifyObservers(incremental: isIncremental, inserted: insertedIndexes) - } -} diff --git a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyMedia.swift b/WordPress/Classes/ViewRelated/Media/Giphy/GiphyMedia.swift deleted file mode 100644 index 892218f64c5e..000000000000 --- a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyMedia.swift +++ /dev/null @@ -1,102 +0,0 @@ -import WPMediaPicker -import MobileCoreServices - -struct GiphyImageCollection { - private(set) var largeURL: URL - private(set) var previewURL: URL - private(set) var staticThumbnailURL: URL - private(set) var largeSize: CGSize -} - -/// Models a Giphy image -/// -final class GiphyMedia: NSObject { - private(set) var id: String - private(set) var name: String - private(set) var caption: String - private let updatedDate: Date - private let images: GiphyImageCollection - - init(id: String, name: String, caption: String, images: GiphyImageCollection, date: Date? = nil) { - self.id = id - self.name = name - self.caption = caption - self.updatedDate = date ?? Date() - self.images = images - } -} - -extension GiphyMedia: WPMediaAsset { - func image(with size: CGSize, completionHandler: @escaping WPMediaImageBlock) -> WPMediaRequestID { - let url = imageURL(with: size) - - DispatchQueue.global().async { - do { - let data = try Data(contentsOf: url) - let image = UIImage(data: data) - completionHandler(image, nil) - } catch { - completionHandler(nil, error) - } - } - - // Giphy API doesn't return a numerical ID value - return 0 - } - - private func imageURL(with size: CGSize) -> URL { - return size == .zero ? images.previewURL : images.staticThumbnailURL - } - - func cancelImageRequest(_ requestID: WPMediaRequestID) { - // Can't be canceled - } - - func videoAsset(completionHandler: @escaping WPMediaAssetBlock) -> WPMediaRequestID { - return 0 - } - - func assetType() -> WPMediaType { - return .image - } - - func duration() -> TimeInterval { - return 0 - } - - func baseAsset() -> Any { - return self - } - - func identifier() -> String { - return id - } - - func date() -> Date { - return updatedDate - } - - func pixelSize() -> CGSize { - return images.largeSize - } - - func utTypeIdentifier() -> String? { - return String(kUTTypeGIF) - } -} - -// MARK: - ExportableAsset conformance - -extension GiphyMedia: ExportableAsset { - var assetMediaType: MediaType { - return .image - } -} - -// MARK: - MediaExternalAsset conformance -// -extension GiphyMedia: MediaExternalAsset { - var URL: URL { - return images.previewURL - } -} diff --git a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyMediaGroup.swift b/WordPress/Classes/ViewRelated/Media/Giphy/GiphyMediaGroup.swift deleted file mode 100644 index 469ea0bcc829..000000000000 --- a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyMediaGroup.swift +++ /dev/null @@ -1,27 +0,0 @@ -import WPMediaPicker - -final class GiphyMediaGroup: NSObject, WPMediaGroup { - func name() -> String { - return String.giphy - } - - func image(with size: CGSize, completionHandler: @escaping WPMediaImageBlock) -> WPMediaRequestID { - return 0 - } - - func cancelImageRequest(_ requestID: WPMediaRequestID) { - // - } - - func baseGroup() -> Any { - return "" - } - - func identifier() -> String { - return "group id" - } - - func numberOfAssets(of mediaType: WPMediaType, completionHandler: WPMediaCountBlock? = nil) -> Int { - return 10 - } -} diff --git a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyPageable.swift b/WordPress/Classes/ViewRelated/Media/Giphy/GiphyPageable.swift deleted file mode 100644 index 479153b46845..000000000000 --- a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyPageable.swift +++ /dev/null @@ -1,39 +0,0 @@ - -struct GiphyPageable: Pageable { - let itemsPerPage: Int - let pageHandle: Int - - static let defaultPageSize = 40 - static let defaultPageIndex = 0 - - func next() -> Pageable? { - if pageHandle == 0 { - return nil - } - - return GiphyPageable(itemsPerPage: itemsPerPage, pageHandle: pageHandle) - } - - var pageSize: Int { - return itemsPerPage - } - - var pageIndex: Int { - return pageHandle - } -} - -extension GiphyPageable { - /// Builds the Pageable corresponding to the first page, with the default page size. - /// - /// - Returns: A GiphyPageable configured with the default page size and the initial page handle - static func first() -> GiphyPageable { - return GiphyPageable(itemsPerPage: defaultPageSize, pageHandle: defaultPageIndex) - } -} - -extension GiphyPageable: CustomStringConvertible { - var description: String { - return "Giphy Pageable: count \(itemsPerPage) next: \(pageHandle)" - } -} diff --git a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyPicker.swift b/WordPress/Classes/ViewRelated/Media/Giphy/GiphyPicker.swift deleted file mode 100644 index fa9b855e79bb..000000000000 --- a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyPicker.swift +++ /dev/null @@ -1,149 +0,0 @@ -import WPMediaPicker -import MobileCoreServices - -protocol GiphyPickerDelegate: AnyObject { - func giphyPicker(_ picker: GiphyPicker, didFinishPicking assets: [GiphyMedia]) -} - -/// Presents the Giphy main interface -final class GiphyPicker: NSObject { - private lazy var dataSource: GiphyDataSource = { - return GiphyDataSource(service: giphyService) - }() - - private lazy var giphyService: GiphyService = { - return GiphyService() - }() - - /// Helps choosing the correct view controller for previewing a media asset - /// - private var mediaPreviewHelper: MediaPreviewHelper! - - weak var delegate: GiphyPickerDelegate? - private var blog: Blog? - private var observerToken: NSObjectProtocol? - - private let searchHint = NoResultsViewController.controller() - - private var pickerOptions: WPMediaPickerOptions = { - let options = WPMediaPickerOptions() - options.showMostRecentFirst = true - options.filter = [.all] - options.allowCaptureOfMedia = false - options.showSearchBar = true - options.badgedUTTypes = [String(kUTTypeGIF)] - options.preferredStatusBarStyle = .lightContent - return options - }() - - private lazy var picker: WPNavigationMediaPickerViewController = { - let picker = WPNavigationMediaPickerViewController(options: pickerOptions) - picker.delegate = self - picker.startOnGroupSelector = false - picker.showGroupSelector = false - picker.dataSource = dataSource - picker.cancelButtonTitle = .closePicker - return picker - }() - - func presentPicker(origin: UIViewController, blog: Blog) { - NoResultsGiphyConfiguration.configureAsIntro(searchHint) - self.blog = blog - - origin.present(picker, animated: true) { - self.picker.mediaPicker.searchBar?.becomeFirstResponder() - } - - observeDataSource() - trackAccess() - } - - private func observeDataSource() { - observerToken = dataSource.registerChangeObserverBlock { [weak self] (_, _, _, _, assets) in - self?.updateHintView() - } - dataSource.onStartLoading = { [weak self] in - NoResultsGiphyConfiguration.configureAsLoading(self!.searchHint) - } - dataSource.onStopLoading = { [weak self] in - self?.updateHintView() - } - } - - private func shouldShowNoResults() -> Bool { - return dataSource.searchQuery.count > 0 && dataSource.numberOfAssets() == 0 - } - - private func updateHintView() { - searchHint.removeFromView() - if shouldShowNoResults() { - NoResultsGiphyConfiguration.configure(searchHint) - } else { - NoResultsGiphyConfiguration.configureAsIntro(searchHint) - } - } - - deinit { - if let token = observerToken { - dataSource.unregisterChangeObserver(token) - } - } -} - -extension GiphyPicker: WPMediaPickerViewControllerDelegate { - func mediaPickerController(_ picker: WPMediaPickerViewController, didFinishPicking assets: [WPMediaAsset]) { - guard let assets = assets as? [GiphyMedia] else { - assertionFailure("assets should be of type `[GiphyMedia]`") - return - } - delegate?.giphyPicker(self, didFinishPicking: assets) - picker.dismiss(animated: true) - dataSource.clearSearch(notifyObservers: false) - hideKeyboard(from: picker.searchBar) - } - - func emptyViewController(forMediaPickerController picker: WPMediaPickerViewController) -> UIViewController? { - return searchHint - } - - func mediaPickerControllerDidEndLoadingData(_ picker: WPMediaPickerViewController) { - if let searchBar = picker.searchBar { - WPStyleGuide.configureSearchBar(searchBar) - } - } - - func mediaPickerControllerDidCancel(_ picker: WPMediaPickerViewController) { - picker.dismiss(animated: true) - dataSource.clearSearch(notifyObservers: false) - hideKeyboard(from: picker.searchBar) - } - - func mediaPickerController(_ picker: WPMediaPickerViewController, didSelect asset: WPMediaAsset) { - hideKeyboard(from: picker.searchBar) - } - - func mediaPickerController(_ picker: WPMediaPickerViewController, didDeselect asset: WPMediaAsset) { - hideKeyboard(from: picker.searchBar) - } - - func mediaPickerController(_ picker: WPMediaPickerViewController, previewViewControllerFor assets: [WPMediaAsset], selectedIndex selected: Int) -> UIViewController? { - mediaPreviewHelper = MediaPreviewHelper(assets: assets) - return mediaPreviewHelper.previewViewController(selectedIndex: selected) - } - - private func hideKeyboard(from view: UIView?) { - if let view = view, view.isFirstResponder { - //Fix animation conflict between dismissing the keyboard and showing the accessory input view - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - view.resignFirstResponder() - } - } - } -} - -// MARK: - Tracks -extension GiphyPicker { - fileprivate func trackAccess() { - WPAnalytics.track(.giphyAccessed) - } -} diff --git a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyResultsPage.swift b/WordPress/Classes/ViewRelated/Media/Giphy/GiphyResultsPage.swift deleted file mode 100644 index cfd9f8721071..000000000000 --- a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyResultsPage.swift +++ /dev/null @@ -1,24 +0,0 @@ - -final class GiphyResultsPage: ResultsPage { - private let results: [GiphyMedia] - private let pageable: Pageable? - - init(results: [GiphyMedia], pageable: Pageable? = nil) { - self.results = results - self.pageable = pageable - } - - func content() -> [GiphyMedia]? { - return results - } - - func nextPageable() -> Pageable? { - return pageable?.next() - } -} - -extension GiphyResultsPage { - static func empty() -> GiphyResultsPage { - return GiphyResultsPage(results: [], pageable: nil) - } -} diff --git a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyService.swift b/WordPress/Classes/ViewRelated/Media/Giphy/GiphyService.swift deleted file mode 100644 index 58281c6540ef..000000000000 --- a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyService.swift +++ /dev/null @@ -1,30 +0,0 @@ -/// Encapsulates search parameters (text, pagination, etc) -struct GiphySearchParams { - let text: String - let pageable: Pageable? - - init(text: String?, pageable: Pageable?) { - self.text = text ?? "" - self.pageable = pageable - } -} - -struct GiphyService { - func search(params: GiphySearchParams, completion: @escaping (GiphyResultsPage) -> Void) { - completion(GiphyResultsPage.empty()) - } -} - - - -// Allows us to mock out the pagination in tests -protocol GPHPaginationType { - /// Total Result Count. - var totalCount: Int { get } - - /// Actual Result Count (not always == limit) - var count: Int { get } - - /// Offset to start next set of results. - var offset: Int { get } -} diff --git a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyStrings.swift b/WordPress/Classes/ViewRelated/Media/Giphy/GiphyStrings.swift deleted file mode 100644 index 36bc7669f246..000000000000 --- a/WordPress/Classes/ViewRelated/Media/Giphy/GiphyStrings.swift +++ /dev/null @@ -1,25 +0,0 @@ -/// Extension on String containing the literals for the Giphy feature -extension String { - // MARK: - Entry point: alert controller - static var giphy: String { - return NSLocalizedString("Giphy", comment: "One of the options when selecting More in the Post Editor's format bar") - } - - // MARK: - Placeholder - static var giphyPlaceholderTitle: String { - return NSLocalizedString("Search to find GIFs to add to your Media Library!", comment: "Title for placeholder in Giphy picker") - } - - static var giphyPlaceholderSubtitle: String { - return NSLocalizedString("Powered by Giphy", comment: "Subtitle for placeholder in Giphy picker. `The company name 'Giphy' should always be written as it is.") - } - - static var giphySearchNoResult: String { - return NSLocalizedString("No media matching your search", comment: "Phrase to show when the user searches for GIFs but there are no result to show.") - } - - static var giphySearchLoading: String { - return NSLocalizedString("Loading GIFs...", comment: "Phrase to show when the user has searched for GIFs and they are being loaded.") - } - -} diff --git a/WordPress/Classes/ViewRelated/Media/Giphy/NoResultsGiphyConfiguration.swift b/WordPress/Classes/ViewRelated/Media/Giphy/NoResultsGiphyConfiguration.swift deleted file mode 100644 index 796a72c286d7..000000000000 --- a/WordPress/Classes/ViewRelated/Media/Giphy/NoResultsGiphyConfiguration.swift +++ /dev/null @@ -1,28 +0,0 @@ -// Empty state for Giphy - -struct NoResultsGiphyConfiguration { - - static func configureAsIntro(_ viewController: NoResultsViewController) { - viewController.configure(title: .giphyPlaceholderTitle, - image: Constants.imageName, - subtitleImage: "giphy-attribution") - - viewController.view.layoutIfNeeded() - } - - static func configureAsLoading(_ viewController: NoResultsViewController) { - viewController.configure(title: .giphySearchLoading, - image: Constants.imageName) - - viewController.view.layoutIfNeeded() - } - - static func configure(_ viewController: NoResultsViewController) { - viewController.configureForNoSearchResults(title: .giphySearchNoResult) - viewController.view.layoutIfNeeded() - } - - private enum Constants { - static let imageName = "media-no-results" - } -} diff --git a/WordPress/Classes/ViewRelated/Media/MediaItemViewController.swift b/WordPress/Classes/ViewRelated/Media/MediaItemViewController.swift index 23ad40ab55c8..d235fc32379e 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaItemViewController.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaItemViewController.swift @@ -1,4 +1,5 @@ import AVKit +import Combine import UIKit import Gridicons import SVProgressHUD @@ -7,6 +8,13 @@ import WordPressShared /// Displays an image preview and metadata for a single Media asset. /// class MediaItemViewController: UITableViewController { + + class DownloadDelegate: NSObject, AVAssetDownloadDelegate { + + } + + let delegate = DownloadDelegate() + @objc let media: Media fileprivate var viewModel: ImmuTable! @@ -114,7 +122,7 @@ class MediaItemViewController: UITableViewController { default: break } - rows.append(TextRow(title: NSLocalizedString("Uploaded", comment: "Label for the date a media asset (image / video) was uploaded"), value: media.creationDate?.mediumString() ?? "")) + rows.append(TextRow(title: NSLocalizedString("Uploaded", comment: "Label for the date a media asset (image / video) was uploaded"), value: media.creationDate?.toMediumString() ?? "")) return rows } @@ -151,15 +159,17 @@ class MediaItemViewController: UITableViewController { private func updateNavigationItem() { if mediaMetadata.matches(media) { navigationItem.leftBarButtonItem = nil - let shareItem = UIBarButtonItem(image: Gridicon.iconOfType(.shareIOS), + let shareItem = UIBarButtonItem(image: .gridicon(.shareiOS), style: .plain, target: self, action: #selector(shareTapped(_:))) + shareItem.accessibilityLabel = NSLocalizedString("Share", comment: "Accessibility label for share buttons in nav bars") - let trashItem = UIBarButtonItem(image: Gridicon.iconOfType(.trash), + let trashItem = UIBarButtonItem(image: .gridicon(.trash), style: .plain, target: self, action: #selector(trashTapped(_:))) + trashItem.accessibilityLabel = NSLocalizedString("Trash", comment: "Accessibility label for trash buttons in nav bars") if media.blog.supports(.mediaDeletion) { navigationItem.rightBarButtonItems = [ shareItem, trashItem ] @@ -215,7 +225,7 @@ class MediaItemViewController: UITableViewController { guard let remoteURL = media.remoteURL, let url = URL(string: remoteURL) else { return } - let controller = WebViewControllerFactory.controller(url: url, blog: media.blog) + let controller = WebViewControllerFactory.controller(url: url, blog: media.blog, source: "media_item") controller.loadViewIfNeeded() controller.navigationItem.titleView = nil controller.title = media.title ?? "" @@ -236,21 +246,35 @@ class MediaItemViewController: UITableViewController { // MARK: - Actions + private var shareVideoCancellable: AnyCancellable? = nil + @objc private func shareTapped(_ sender: UIBarButtonItem) { - if let remoteURLStr = media.remoteURL, let url = URL(string: remoteURLStr) { - let activityController = UIActivityViewController(activityItems: [ url ], applicationActivities: nil) - activityController.modalPresentationStyle = .popover - activityController.popoverPresentationController?.barButtonItem = sender - activityController.completionWithItemsHandler = { [weak self] _, completed, _, _ in - if completed { - WPAppAnalytics.track(.mediaLibrarySharedItemLink, with: self?.media.blog) + switch media.mediaType { + case .image: + media.image(with: .zero) { [weak self] image, error in + guard let image = image else { + if let error = error { + DDLogError("Error when attempting to share image: \(error)") } + return } - present(activityController, animated: true) - } else { - let alertController = UIAlertController(title: nil, message: NSLocalizedString("Unable to get URL for media item.", comment: "Error message displayed when we were unable to copy the URL for an item in the user's media library."), preferredStyle: .alert) - alertController.addCancelActionWithTitle(NSLocalizedString("Dismiss", comment: "Verb. User action to dismiss error alert when failing to share media.")) - present(alertController, animated: true) + + self?.share(media: image, sender: sender) + } + case .audio, .video: + shareVideoCancellable = media.videoURLPublisher(skipTransformCheck: true).sink { [weak self] completion in + if case .failure(let error) = completion { + DDLogError("Error when attempting to share video: \(error)") + } + + self?.shareVideoCancellable = nil + } receiveValue: { [weak self] url in + DispatchQueue.main.async { [weak self] in + self?.share(media: url, sender: sender) + } + } + default: + break } } @@ -368,6 +392,34 @@ class MediaItemViewController: UITableViewController { navigationController?.pushViewController(controller, animated: true) } + + // MARK: - Sharing Logic + + private func mediaURL() -> URL? { + guard let remoteURL = media.remoteURL, + let url = URL(string: remoteURL) else { + return nil + } + + return url + } + + private func share(media: Any, sender: UIBarButtonItem) { + share([media], sender: sender) + } + + private func share(_ activityItems: [Any], sender: UIBarButtonItem) { + let activityController = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + activityController.modalPresentationStyle = .popover + activityController.popoverPresentationController?.barButtonItem = sender + activityController.completionWithItemsHandler = { [weak self] _, completed, _, _ in + if completed { + WPAppAnalytics.track(.mediaLibrarySharedItemLink, with: self?.media.blog) + } + } + + present(activityController, animated: true) + } } // MARK: - UITableViewDataSource diff --git a/WordPress/Classes/ViewRelated/Media/MediaLibraryMediaPickingCoordinator.swift b/WordPress/Classes/ViewRelated/Media/MediaLibraryMediaPickingCoordinator.swift index 6390e9863886..e73857e10fd2 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaLibraryMediaPickingCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaLibraryMediaPickingCoordinator.swift @@ -3,15 +3,18 @@ import WPMediaPicker /// Prepares the alert controller that will be presented when tapping the "+" button in Media Library final class MediaLibraryMediaPickingCoordinator { - private let stockPhotos = StockPhotosPicker() - private var giphy = GiphyPicker() + typealias PickersDelegate = StockPhotosPickerDelegate & WPMediaPickerViewControllerDelegate & TenorPickerDelegate + private weak var delegate: PickersDelegate? + private var tenor: TenorPicker? + + private var stockPhotos: StockPhotosPicker? private let cameraCapture = CameraCaptureCoordinator() private let mediaLibrary = MediaLibraryPicker() - init(delegate: StockPhotosPickerDelegate & WPMediaPickerViewControllerDelegate & GiphyPickerDelegate) { - stockPhotos.delegate = delegate + init(delegate: PickersDelegate) { + self.delegate = delegate + mediaLibrary.delegate = delegate - giphy.delegate = delegate } func present(context: MediaPickingContext) { @@ -35,6 +38,9 @@ final class MediaLibraryMediaPickingCoordinator { if blog.supports(.stockPhotos) { menuAlert.addAction(freePhotoAction(origin: origin, blog: blog)) } + if blog.supports(.tenor) { + menuAlert.addAction(tenorAction(origin: origin, blog: blog)) + } menuAlert.addAction(otherAppsAction(origin: origin, blog: blog)) menuAlert.addAction(cancelAction()) @@ -64,15 +70,14 @@ final class MediaLibraryMediaPickingCoordinator { }) } - - private func giphyAction(origin: UIViewController, blog: Blog) -> UIAlertAction { - return UIAlertAction(title: .giphy, style: .default, handler: { [weak self] action in - self?.showGiphy(origin: origin, blog: blog) + private func tenorAction(origin: UIViewController, blog: Blog) -> UIAlertAction { + return UIAlertAction(title: .tenor, style: .default, handler: { [weak self] action in + self?.showTenor(origin: origin, blog: blog) }) } private func otherAppsAction(origin: UIViewController & UIDocumentPickerDelegate, blog: Blog) -> UIAlertAction { - return UIAlertAction(title: .files, style: .default, handler: { [weak self] action in + return UIAlertAction(title: .otherApps, style: .default, handler: { [weak self] action in self?.showDocumentPicker(origin: origin, blog: blog) }) } @@ -86,25 +91,28 @@ final class MediaLibraryMediaPickingCoordinator { } private func showStockPhotos(origin: UIViewController, blog: Blog) { - stockPhotos.presentPicker(origin: origin, blog: blog) + let picker = StockPhotosPicker() + // add delegate conformance, allow release of picker in the same manner as the tenor picker + // in order to prevent duplicated uploads and botched de-selection on second upload + picker.delegate = self + picker.presentPicker(origin: origin, blog: blog) + stockPhotos = picker } - private func showGiphy(origin: UIViewController, blog: Blog) { - let delegate = giphy.delegate - - // Create a new GiphyPicker each time so we don't save state - giphy = GiphyPicker() - giphy.delegate = delegate - - giphy.presentPicker(origin: origin, blog: blog) + private func showTenor(origin: UIViewController, blog: Blog) { + let picker = TenorPicker() + // Delegate to the PickerCoordinator so we can release the Tenor instance + picker.delegate = self + picker.presentPicker(origin: origin, blog: blog) + tenor = picker } + private func showDocumentPicker(origin: UIViewController & UIDocumentPickerDelegate, blog: Blog) { let docTypes = blog.allowedTypeIdentifiers let docPicker = UIDocumentPickerViewController(documentTypes: docTypes, in: .import) docPicker.delegate = origin docPicker.allowsMultipleSelection = true - WPStyleGuide.configureDocumentPickerNavBarAppearance() origin.present(docPicker, animated: true) } @@ -112,3 +120,17 @@ final class MediaLibraryMediaPickingCoordinator { mediaLibrary.presentPicker(origin: origin, blog: blog) } } + +extension MediaLibraryMediaPickingCoordinator: TenorPickerDelegate { + func tenorPicker(_ picker: TenorPicker, didFinishPicking assets: [TenorMedia]) { + delegate?.tenorPicker(picker, didFinishPicking: assets) + tenor = nil + } +} + +extension MediaLibraryMediaPickingCoordinator: StockPhotosPickerDelegate { + func stockPhotosPicker(_ picker: StockPhotosPicker, didFinishPicking assets: [StockPhotosMedia]) { + delegate?.stockPhotosPicker(picker, didFinishPicking: assets) + stockPhotos = nil + } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaLibraryPicker.swift b/WordPress/Classes/ViewRelated/Media/MediaLibraryPicker.swift index 780a2198da76..db8972367dd4 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaLibraryPicker.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaLibraryPicker.swift @@ -1,5 +1,7 @@ import WPMediaPicker import MobileCoreServices +import CoreGraphics +import Photos /// Encapsulates launching and customization of a media picker to import media from the Photos Library final class MediaLibraryPicker: NSObject { @@ -15,12 +17,32 @@ final class MediaLibraryPicker: NSObject { options.filter = [.all] options.allowCaptureOfMedia = false options.badgedUTTypes = [String(kUTTypeGIF)] - options.preferredStatusBarStyle = .lightContent + options.preferredStatusBarStyle = WPStyleGuide.preferredStatusBarStyle let picker = WPNavigationMediaPickerViewController(options: options) picker.dataSource = dataSource picker.delegate = delegate + picker.mediaPicker.registerClass(forReusableCellOverlayViews: DisabledVideoOverlay.self) + + if FeatureFlag.mediaPickerPermissionsNotice.enabled { + picker.mediaPicker.registerClass(forCustomHeaderView: DeviceMediaPermissionsHeader.self) + } origin.present(picker, animated: true) } } + +/// An overlay for videos that exceed allowed duration +class DisabledVideoOverlay: UIView { + + static let overlayTransparency: CGFloat = 0.8 + + init() { + super.init(frame: .zero) + backgroundColor = .gray.withAlphaComponent(Self.overlayTransparency) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaLibraryPickerDataSource.m b/WordPress/Classes/ViewRelated/Media/MediaLibraryPickerDataSource.m index 87407828dc65..5f89dea12b9b 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaLibraryPickerDataSource.m +++ b/WordPress/Classes/ViewRelated/Media/MediaLibraryPickerDataSource.m @@ -2,7 +2,7 @@ #import "Media.h" #import "MediaService.h" #import "Blog.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "WordPress-Swift.h" @interface MediaLibraryPickerDataSource() <NSFetchedResultsControllerDelegate> @@ -24,10 +24,27 @@ @interface MediaLibraryPickerDataSource() <NSFetchedResultsControllerDelegate> @end +@interface MediaLibraryGroup() +@property (nonatomic, strong) Blog *blog; +@property (nonatomic, assign) NSInteger itemsCount; +@property (nonatomic, strong) NSManagedObjectID *imageMediaID; + +- (void)refreshImageMedia; +@end + @implementation MediaLibraryPickerDataSource - (instancetype)initWithBlog:(Blog *)blog { + /// Temporary logging to try and narrow down an issue: + /// + /// REF: https://github.com/wordpress-mobile/WordPress-iOS/issues/15335 + /// + if (blog == nil || blog.objectID == nil) { + DDLogError(@"🔴 Error: missing object ID (please contact @diegoreymendez with this log)"); + DDLogError(@"%@", [NSThread callStackSymbols]); + } + self = [super init]; if (self) { _mediaGroup = [[MediaLibraryGroup alloc] initWithBlog:blog]; @@ -44,7 +61,7 @@ - (instancetype)initWithBlog:(Blog *)blog } - (instancetype)initWithPost:(AbstractPost *)post -{ +{ self = [self initWithBlog:post.blog]; if (self) { _post = post; @@ -139,6 +156,15 @@ - (void)loadDataWithOptions:(WPMediaLoadOptions)options success:(WPMediaSuccessB // try to sync from the server MediaCoordinator *mediaCoordinator = [MediaCoordinator shared]; + /// Temporary logging to try and narrow down an issue: + /// + /// REF: https://github.com/wordpress-mobile/WordPress-iOS/issues/15335 + /// + if (self.blog == nil || self.blog.objectID == nil) { + DDLogError(@"🔴 Error: missing object ID (please contact @diegoreymendez with this log)"); + DDLogError(@"%@", [NSThread callStackSymbols]); + } + __block BOOL ignoreSyncError = self.ignoreSyncErrors; [mediaCoordinator syncMediaFor:self.blog success:^{ @@ -281,8 +307,8 @@ -(void)addMediaFromAssetIdentifier:(NSString *)assetIdentifier } PHFetchResult *result = [PHAsset fetchAssetsWithLocalIdentifiers:@[assetIdentifier] options:nil]; PHAsset *asset = [result firstObject]; - MediaService *mediaService = [[MediaService alloc] initWithManagedObjectContext:self.blog.managedObjectContext]; - [mediaService createMediaWith:asset blog:self.blog post: self.post progress:nil thumbnailCallback:nil completion:^(Media *media, NSError *error) { + MediaImportService *service = [[MediaImportService alloc] initWithContextManager:[ContextManager sharedInstance]]; + [service createMediaWith:asset blog:self.blog post: self.post receiveUpdate:nil thumbnailCallback:nil completion:^(Media *media, NSError *error) { [self loadDataWithOptions:WPMediaLoadOptionsAssets success:^{ completionBlock(media, error); } failure:^(NSError *error) { @@ -296,13 +322,13 @@ -(void)addMediaFromAssetIdentifier:(NSString *)assetIdentifier -(void)addMediaFromURL:(NSURL *)url completionBlock:(WPMediaAddedBlock)completionBlock { - MediaService *mediaService = [[MediaService alloc] initWithManagedObjectContext:self.blog.managedObjectContext]; - [mediaService createMediaWith:url - blog:self.blog - post:self.post - progress:nil - thumbnailCallback:nil - completion:^(Media *media, NSError *error) { + MediaImportService *service = [[MediaImportService alloc] initWithContextManager:[ContextManager sharedInstance]]; + [service createMediaWith:url + blog:self.blog + post:self.post + receiveUpdate:nil + thumbnailCallback:nil + completion:^(Media *media, NSError *error) { [self loadDataWithOptions:WPMediaLoadOptionsAssets success:^{ completionBlock(media, error); } failure:^(NSError *error) { @@ -344,7 +370,7 @@ -(WPMediaType)mediaTypeFilter return nil; } [mainContext performBlockAndWait:^{ - NSManagedObjectID *assetID = [[[ContextManager sharedInstance] persistentStoreCoordinator] managedObjectIDForURIRepresentation:assetURL]; + NSManagedObjectID *assetID = [[mainContext persistentStoreCoordinator] managedObjectIDForURIRepresentation:assetURL]; media = (Media *)[mainContext objectWithID:assetID]; }]; @@ -485,7 +511,9 @@ - (void)controller:(NSFetchedResultsController *)controller didChangeObject:(id) - (void)controllerDidChangeContent:(NSFetchedResultsController *)controller { - if ([self.mediaChanged containsIndex:0] || [self.mediaInserted containsIndex:0] || [self.mediaRemoved containsIndex:0]) { + NSManagedObjectID *oldGroupMediaID = self.mediaGroup.imageMediaID; + [self.mediaGroup refreshImageMedia]; + if (![oldGroupMediaID isEqual:self.mediaGroup.imageMediaID]) { [self notifyGroupObservers]; } [self notifyObserversWithIncrementalChanges:YES @@ -497,11 +525,6 @@ - (void)controllerDidChangeContent:(NSFetchedResultsController *)controller { @end -@interface MediaLibraryGroup() - @property (nonatomic, strong) Blog *blog; - @property (nonatomic, assign) NSInteger itemsCount; -@end - @implementation MediaLibraryGroup - (instancetype)initWithBlog:(Blog *)blog @@ -510,11 +533,20 @@ - (instancetype)initWithBlog:(Blog *)blog if (self) { _blog = blog; _filter = WPMediaTypeAll; - _itemsCount = NSNotFound; + _itemsCount = NSNotFound; + [self refreshImageMedia]; } return self; } +- (void)setFilter:(WPMediaType)filter { + if (_filter != filter) { + _filter = filter; + + [self refreshImageMedia]; + } +} + - (id)baseGroup { return self; @@ -525,27 +557,38 @@ - (NSString *)name return NSLocalizedString(@"WordPress Media", @"Name for the WordPress Media Library"); } -- (WPMediaRequestID)imageWithSize:(CGSize)size completionHandler:(WPMediaImageBlock)completionHandler +- (void)refreshImageMedia { NSString *entityName = NSStringFromClass([Media class]); NSFetchRequest *request = [NSFetchRequest fetchRequestWithEntityName:entityName]; request.predicate = [MediaLibraryPickerDataSource predicateForFilter:self.filter blog:self.blog]; NSSortDescriptor *sortDescriptor = [NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]; request.sortDescriptors = @[sortDescriptor]; + request.fetchLimit = 1; NSError *error; NSArray *mediaAssets = [[[ContextManager sharedInstance] mainContext] executeFetchRequest:request error:&error]; - if (mediaAssets.count == 0) - { - if (completionHandler){ - completionHandler(nil, nil); - } - } Media *media = [mediaAssets firstObject]; - if (!media) { + self.imageMediaID = media.objectID; +} + +- (WPMediaRequestID)imageWithSize:(CGSize)size completionHandler:(WPMediaImageBlock)completionHandler +{ + Media *media = nil; + + if (self.imageMediaID == nil) { + [self refreshImageMedia]; + } + + if (self.imageMediaID != nil) { + media = [[[ContextManager sharedInstance] mainContext] existingObjectWithID:self.imageMediaID error:nil]; + } + + if (media == nil) { UIImage *placeholderImage = [UIImage imageNamed:@"WordPress-share"]; completionHandler(placeholderImage, nil); return 0; } + return [media imageWithSize:size completionHandler:completionHandler]; } @@ -572,22 +615,23 @@ - (NSInteger)numberOfAssetsOfType:(WPMediaType)mediaType completionHandler:(WPMe [mediaTypes addObject:@(MediaTypeAudio)]; } - NSManagedObjectContext *mainContext = [[ContextManager sharedInstance] newDerivedContext]; - MediaService *mediaService = [[MediaService alloc] initWithManagedObjectContext:mainContext]; - NSInteger count = [mediaService getMediaLibraryCountForBlog:self.blog - forMediaTypes:mediaTypes]; - // If we have a count diferent of zero assume it's correct but sync with the server always in the background + NSInteger count = [self.blog mediaLibraryCountForTypes:mediaTypes]; + // If we have a count difference of zero, we assume it's correct. But we still sync with the server in the background. if (count != 0) { self.itemsCount = count; } + __weak __typeof__(self) weakSelf = self; - [mediaService getMediaLibraryServerCountForBlog:self.blog forMediaTypes:mediaTypes success:^(NSInteger count) { - weakSelf.itemsCount = count; - completionHandler(count, nil); - } failure:^(NSError * _Nonnull error) { - DDLogError(@"%@", [error localizedDescription]); - weakSelf.itemsCount = count; - completionHandler(count, error); + [[ContextManager sharedInstance] performAndSaveUsingBlock:^(NSManagedObjectContext *context) { + MediaService *mediaService = [[MediaService alloc] initWithManagedObjectContext:context]; + [mediaService getMediaLibraryServerCountForBlog:self.blog forMediaTypes:mediaTypes success:^(NSInteger count) { + weakSelf.itemsCount = count; + completionHandler(count, nil); + } failure:^(NSError * _Nonnull error) { + DDLogError(@"%@", [error localizedDescription]); + weakSelf.itemsCount = count; + completionHandler(count, error); + }]; }]; return self.itemsCount; diff --git a/WordPress/Classes/ViewRelated/Media/MediaLibraryViewController.swift b/WordPress/Classes/ViewRelated/Media/MediaLibraryViewController.swift index 5c48908b3ae0..c88443f787e2 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaLibraryViewController.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaLibraryViewController.swift @@ -16,6 +16,9 @@ class MediaLibraryViewController: WPMediaPickerViewController { fileprivate var isLoading: Bool = false fileprivate let noResultsView = NoResultsViewController.controller() + fileprivate let addButton: SpotlightableButton = SpotlightableButton(type: .custom) + + fileprivate var kvoTokens: [NSKeyValueObservation]? fileprivate var selectedAsset: Media? = nil @@ -57,9 +60,18 @@ class MediaLibraryViewController: WPMediaPickerViewController { fatalError("init(coder:) has not been implemented") } + static func showForBlog(_ blog: Blog, from sourceController: UIViewController) { + let controller = MediaLibraryViewController(blog: blog) + controller.navigationItem.largeTitleDisplayMode = .never + sourceController.navigationController?.pushViewController(controller, animated: true) + + QuickStartTourGuide.shared.visited(.mediaScreen) + } + deinit { unregisterChangeObserver() unregisterUploadCoordinatorObserver() + stopObservingNavigationBarClipsToBounds() } private class func pickerOptions() -> WPMediaPickerOptions { @@ -71,7 +83,7 @@ class MediaLibraryViewController: WPMediaPickerViewController { options.showSearchBar = true options.showActionBar = false options.badgedUTTypes = [String(kUTTypeGIF)] - options.preferredStatusBarStyle = .lightContent + options.preferredStatusBarStyle = WPStyleGuide.preferredStatusBarStyle return options } @@ -83,6 +95,8 @@ class MediaLibraryViewController: WPMediaPickerViewController { title = NSLocalizedString("Media", comment: "Title for Media Library section of the app.") + extendedLayoutIncludesOpaqueBars = true + registerChangeObserver() registerUploadCoordinatorObserver() @@ -94,21 +108,9 @@ class MediaLibraryViewController: WPMediaPickerViewController { if let collectionView = collectionView { WPStyleGuide.configureColors(view: view, collectionView: collectionView) } - } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - resetNavigationColors() - } - - /* - This is to restore the navigation bar colors after the UIDocumentPickerViewController has been dismissed, - either by uploading media or canceling. Doing this in the UIDocumentPickerDelegate methods either did nothing - or the resetting wasn't permanent. - */ - fileprivate func resetNavigationColors() { - WPStyleGuide.configureNavigationAppearance() + navigationController?.navigationBar.subviews.forEach ({ $0.clipsToBounds = false }) + startObservingNavigationBarClipsToBounds() } override func viewDidAppear(_ animated: Bool) { @@ -125,6 +127,11 @@ class MediaLibraryViewController: WPMediaPickerViewController { } } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + addButton.shouldShowSpotlight = QuickStartTourGuide.shared.isCurrentElement(.mediaUpload) + } + // MARK: - Update view state fileprivate func updateViewState(for assetCount: Int) { @@ -137,7 +144,9 @@ class MediaLibraryViewController: WPMediaPickerViewController { if isEditing { navigationItem.setLeftBarButton(UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(editTapped)), animated: false) - let trashButton = UIBarButtonItem(image: Gridicon.iconOfType(.trash), style: .plain, target: self, action: #selector(trashTapped)) + let trashButton = UIBarButtonItem(image: .gridicon(.trash), style: .plain, target: self, action: #selector(trashTapped)) + trashButton.accessibilityLabel = NSLocalizedString("Trash", comment: "Accessibility label for trash button to delete items from the user's media library") + trashButton.accessibilityHint = NSLocalizedString("Trash selected media", comment: "Accessibility hint for trash button to delete items from the user's media library") navigationItem.setRightBarButtonItems([trashButton], animated: true) navigationItem.rightBarButtonItem?.isEnabled = false } else { @@ -145,19 +154,29 @@ class MediaLibraryViewController: WPMediaPickerViewController { var barButtonItems = [UIBarButtonItem]() - if blog.userCanUploadMedia && assetCount > 0 { - let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addTapped)) - barButtonItems.append(addButton) + if blog.userCanUploadMedia { + addButton.spotlightOffset = Constants.addButtonSpotlightOffset + let config = UIImage.SymbolConfiguration(textStyle: .body, scale: .large) + let image = UIImage(systemName: "plus", withConfiguration: config) ?? .gridicon(.plus) + addButton.setImage(image, for: .normal) + addButton.contentEdgeInsets = Constants.addButtonContentInset + addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside) + addButton.accessibilityLabel = NSLocalizedString("Add", comment: "Accessibility label for add button to add items to the user's media library") + addButton.accessibilityHint = NSLocalizedString("Add new media", comment: "Accessibility hint for add button to add items to the user's media library") + + let addBarButton = UIBarButtonItem(customView: addButton) + barButtonItems.append(addBarButton) } if blog.supports(.mediaDeletion) && assetCount > 0 { let editButton = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(editTapped)) + editButton.accessibilityLabel = NSLocalizedString("Edit", comment: "Accessibility label for edit button to enable multi selection mode in the user's media library") + editButton.accessibilityHint = NSLocalizedString("Enter edit mode to enable multi select to delete", comment: "Accessibility hint for edit button to enable multi selection mode in the user's media library") + barButtonItems.append(editButton) - navigationItem.setRightBarButtonItems(barButtonItems, animated: false) - } else { - navigationItem.setRightBarButtonItems(barButtonItems, animated: false) } + navigationItem.setRightBarButtonItems(barButtonItems, animated: false) } } @@ -252,6 +271,8 @@ class MediaLibraryViewController: WPMediaPickerViewController { // MARK: - Actions @objc fileprivate func addTapped() { + QuickStartTourGuide.shared.visited(.mediaUpload) + addButton.shouldShowSpotlight = QuickStartTourGuide.shared.isCurrentElement(.mediaUpload) showOptionsMenu() } @@ -342,7 +363,13 @@ class MediaLibraryViewController: WPMediaPickerViewController { } } - alertController.addCancelActionWithTitle(NSLocalizedString("Dismiss", comment: "Verb. Button title. Tapping dismisses a prmopt.")) + alertController.addCancelActionWithTitle( + NSLocalizedString( + "mediaLibrary.retryOptionsAlert.dismissButton", + value: "Dismiss", + comment: "Verb. Button title. Tapping dismisses a prompt." + ) + ) present(alertController, animated: true) } @@ -424,6 +451,25 @@ class MediaLibraryViewController: WPMediaPickerViewController { MediaCoordinator.shared.removeObserver(withUUID: uuid) } } + + // MARK: ClipsToBounds KVO Observer + + /// The content view of the navigation bar causes the spotlight view on the add button to be clipped. + /// This ensures that `clipsToBounds` of the content view is always `false`. + /// Without this, `clipsToBounds` reverts to `true` at some point during the view lifecycle. This happens asynchronously, + /// so we can't confidently reset it. Hence the need for KVO. + private func startObservingNavigationBarClipsToBounds() { + kvoTokens = navigationController?.navigationBar.subviews.map({ subview in + return subview.observe(\.clipsToBounds, options: .new, changeHandler: { view, change in + guard let newValue = change.newValue, newValue else { return } + view.clipsToBounds = false + }) + }) + } + + private func stopObservingNavigationBarClipsToBounds() { + kvoTokens?.forEach({ $0.invalidate() }) + } } // MARK: - UIDocumentPickerDelegate @@ -449,6 +495,9 @@ extension MediaLibraryViewController: NoResultsViewControllerDelegate { } } +// MARK: - User messages for video limits allowances +extension MediaLibraryViewController: VideoLimitsAlertPresenter {} + // MARK: - WPMediaPickerViewControllerDelegate extension MediaLibraryViewController: WPMediaPickerViewControllerDelegate { @@ -513,6 +562,9 @@ extension MediaLibraryViewController: WPMediaPickerViewControllerDelegate { } func mediaPickerController(_ picker: WPMediaPickerViewController, shouldShowOverlayViewForCellFor asset: WPMediaAsset) -> Bool { + if picker != self, !blog.canUploadAsset(asset) { + return true + } if let media = asset as? Media { return media.remoteStatus != .sync } @@ -520,6 +572,31 @@ extension MediaLibraryViewController: WPMediaPickerViewControllerDelegate { return false } + func mediaPickerControllerShouldShowCustomHeaderView(_ picker: WPMediaPickerViewController) -> Bool { + guard FeatureFlag.mediaPickerPermissionsNotice.enabled, + picker != self else { + return false + } + + // Show the device media permissions header if photo library access is limited + return PHPhotoLibrary.authorizationStatus(for: .readWrite) == .limited + } + + func mediaPickerControllerReferenceSize(forCustomHeaderView picker: WPMediaPickerViewController) -> CGSize { + let header = DeviceMediaPermissionsHeader() + header.translatesAutoresizingMaskIntoConstraints = false + + return header.referenceSizeInView(picker.view) + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, configureCustomHeaderView headerView: UICollectionReusableView) { + guard let headerView = headerView as? DeviceMediaPermissionsHeader else { + return + } + + headerView.presenter = picker + } + func mediaPickerController(_ picker: WPMediaPickerViewController, previewViewControllerFor asset: WPMediaAsset) -> UIViewController? { guard picker == self else { return WPAssetViewController(asset: asset) } @@ -533,6 +610,11 @@ extension MediaLibraryViewController: WPMediaPickerViewControllerDelegate { } func mediaPickerController(_ picker: WPMediaPickerViewController, shouldSelect asset: WPMediaAsset) -> Bool { + if picker != self, !blog.canUploadAsset(asset) { + presentVideoLimitExceededFromPicker(on: picker) + return false + } + guard picker == self else { return true } @@ -610,6 +692,17 @@ extension MediaLibraryViewController: WPMediaPickerViewControllerDelegate { updateViewState(for: pickerDataSource.numberOfAssets()) } + + func mediaPickerController(_ picker: WPMediaPickerViewController, handleError error: Error) -> Bool { + guard picker == self else { return false } + + let nserror = error as NSError + if let mediaLibrary = self.blog.media, !mediaLibrary.isEmpty { + let title = NSLocalizedString("Unable to Sync", comment: "Title of error prompt shown when a sync the user initiated fails.") + WPError.showNetworkingNotice(title: title, error: nserror) + } + return true + } } // MARK: - State restoration @@ -662,27 +755,36 @@ extension MediaLibraryViewController: StockPhotosPickerDelegate { } let mediaCoordinator = MediaCoordinator.shared - assets.forEach { + assets.forEach { stockPhoto in let info = MediaAnalyticsInfo(origin: .mediaLibrary(.stockPhotos), selectionMethod: .fullScreenPicker) - mediaCoordinator.addMedia(from: $0, to: blog, analyticsInfo: info) + mediaCoordinator.addMedia(from: stockPhoto, to: blog, analyticsInfo: info) WPAnalytics.track(.stockMediaUploaded) } } } -// MARK: Giphy Picker Delegate +// MARK: Tenor Picker Delegate -extension MediaLibraryViewController: GiphyPickerDelegate { - func giphyPicker(_ picker: GiphyPicker, didFinishPicking assets: [GiphyMedia]) { +extension MediaLibraryViewController: TenorPickerDelegate { + func tenorPicker(_ picker: TenorPicker, didFinishPicking assets: [TenorMedia]) { guard assets.count > 0 else { return } let mediaCoordinator = MediaCoordinator.shared - assets.forEach { giphyMedia in - let info = MediaAnalyticsInfo(origin: .mediaLibrary(.giphy), selectionMethod: .fullScreenPicker) - mediaCoordinator.addMedia(from: giphyMedia, to: blog, analyticsInfo: info) - WPAnalytics.track(.giphyUploaded) + assets.forEach { tenorMedia in + let info = MediaAnalyticsInfo(origin: .mediaLibrary(.tenor), selectionMethod: .fullScreenPicker) + mediaCoordinator.addMedia(from: tenorMedia, to: blog, analyticsInfo: info) + WPAnalytics.track(.tenorUploaded) } } } + +// MARK: Constants + +extension MediaLibraryViewController { + private enum Constants { + static let addButtonSpotlightOffset = UIOffset(horizontal: 20, vertical: -10) + static let addButtonContentInset = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 0) + } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaNoticeNavigationCoordinator.swift b/WordPress/Classes/ViewRelated/Media/MediaNoticeNavigationCoordinator.swift index a485f322abfc..d2f49a0cc7a4 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaNoticeNavigationCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaNoticeNavigationCoordinator.swift @@ -16,13 +16,13 @@ class MediaNoticeNavigationCoordinator { let editor = EditPostViewController(blog: blog) editor.modalPresentationStyle = .fullScreen editor.insertedMedia = media - WPTabBarController.sharedInstance().present(editor, animated: false) - WPAppAnalytics.track(.editorCreatedPost, withProperties: ["tap_source": source], with: blog) + RootViewCoordinator.sharedPresenter.rootViewController.present(editor, animated: false) + WPAppAnalytics.track(.editorCreatedPost, withProperties: [WPAppAnalyticsKeyTapSource: source, WPAppAnalyticsKeyPostType: "post"], with: blog) } static func navigateToMediaLibrary(with userInfo: NSDictionary) { if let blog = blog(from: userInfo) { - WPTabBarController.sharedInstance().switchMySitesTabToMedia(for: blog) + RootViewCoordinator.sharedPresenter.showMedia(for: blog) } } diff --git a/WordPress/Classes/ViewRelated/Media/MediaProgressCoordinatorNoticeViewModel.swift b/WordPress/Classes/ViewRelated/Media/MediaProgressCoordinatorNoticeViewModel.swift index 3840213f0c1e..f66789c8adb3 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaProgressCoordinatorNoticeViewModel.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaProgressCoordinatorNoticeViewModel.swift @@ -163,12 +163,15 @@ struct MediaProgressCoordinatorNoticeViewModel { } let context = ContextManager.sharedInstance().mainContext + var blog: Blog? = nil - var blog = media.blog - if blog.managedObjectContext != context, - let objectInContext = try? context.existingObject(with: blog.objectID), - let blogInContext = objectInContext as? Blog { - blog = blogInContext + context.performAndWait { + guard let mediaInContext = try? context.existingObject(with: media.objectID) as? Media else { + DDLogError("The media object no longer exists") + return + } + + blog = mediaInContext.blog } return blog diff --git a/WordPress/Classes/ViewRelated/Media/StockPhotos/AztecMediaPickingCoordinator.swift b/WordPress/Classes/ViewRelated/Media/StockPhotos/AztecMediaPickingCoordinator.swift index 3d5911c8245a..c9b108a8772b 100644 --- a/WordPress/Classes/ViewRelated/Media/StockPhotos/AztecMediaPickingCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Media/StockPhotos/AztecMediaPickingCoordinator.swift @@ -3,11 +3,13 @@ import WPMediaPicker /// Prepares the alert controller that will be presented when tapping the "more" button in Aztec's Format Bar final class AztecMediaPickingCoordinator { - private let giphy = GiphyPicker() + typealias PickersDelegate = StockPhotosPickerDelegate & TenorPickerDelegate + private weak var delegate: PickersDelegate? + private var tenor: TenorPicker? private let stockPhotos = StockPhotosPicker() - init(delegate: GiphyPickerDelegate & StockPhotosPickerDelegate) { - giphy.delegate = delegate + init(delegate: PickersDelegate) { + self.delegate = delegate stockPhotos.delegate = delegate } @@ -16,11 +18,16 @@ final class AztecMediaPickingCoordinator { let blog = context.blog let fromView = context.view - let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + let alertController = UIAlertController(title: nil, + message: nil, + preferredStyle: UIDevice.isPad() ? .alert : .actionSheet) if blog.supports(.stockPhotos) { alertController.addAction(freePhotoAction(origin: origin, blog: blog)) } + if blog.supports(.tenor) { + alertController.addAction(tenorAction(origin: origin, blog: blog)) + } alertController.addAction(otherAppsAction(origin: origin, blog: blog)) alertController.addAction(cancelAction()) @@ -38,14 +45,14 @@ final class AztecMediaPickingCoordinator { }) } - private func giphyAction(origin: UIViewController, blog: Blog) -> UIAlertAction { - return UIAlertAction(title: .giphy, style: .default, handler: { [weak self] action in - self?.showGiphy(origin: origin, blog: blog) + private func tenorAction(origin: UIViewController, blog: Blog) -> UIAlertAction { + return UIAlertAction(title: .tenor, style: .default, handler: { [weak self] action in + self?.showTenor(origin: origin, blog: blog) }) } private func otherAppsAction(origin: UIViewController & UIDocumentPickerDelegate, blog: Blog) -> UIAlertAction { - return UIAlertAction(title: .files, style: .default, handler: { [weak self] action in + return UIAlertAction(title: .otherApps, style: .default, handler: { [weak self] action in self?.showDocumentPicker(origin: origin, blog: blog) }) } @@ -58,8 +65,11 @@ final class AztecMediaPickingCoordinator { stockPhotos.presentPicker(origin: origin, blog: blog) } - private func showGiphy(origin: UIViewController, blog: Blog) { - giphy.presentPicker(origin: origin, blog: blog) + private func showTenor(origin: UIViewController, blog: Blog) { + let picker = TenorPicker() + picker.delegate = self + picker.presentPicker(origin: origin, blog: blog) + tenor = picker } private func showDocumentPicker(origin: UIViewController & UIDocumentPickerDelegate, blog: Blog) { @@ -67,7 +77,13 @@ final class AztecMediaPickingCoordinator { let docPicker = UIDocumentPickerViewController(documentTypes: docTypes, in: .import) docPicker.delegate = origin docPicker.allowsMultipleSelection = true - WPStyleGuide.configureDocumentPickerNavBarAppearance() origin.present(docPicker, animated: true) } } + +extension AztecMediaPickingCoordinator: TenorPickerDelegate { + func tenorPicker(_ picker: TenorPicker, didFinishPicking assets: [TenorMedia]) { + delegate?.tenorPicker(picker, didFinishPicking: assets) + tenor = nil + } +} diff --git a/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosDataLoader.swift b/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosDataLoader.swift index 11ebf23c36a6..e6ff8c1f4ae2 100644 --- a/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosDataLoader.swift +++ b/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosDataLoader.swift @@ -1,5 +1,5 @@ /// Implementations of this protocol will be notified when data is loaded from the StockPhotosService -protocol StockPhotosDataLoaderDelegate: class { +protocol StockPhotosDataLoaderDelegate: AnyObject { func didLoad(media: [StockPhotosMedia], reset: Bool) } @@ -49,7 +49,7 @@ final class StockPhotosDataLoader { return } - // Bail out if we do not expect more pages of data + // Bail out if we do not expect more pages of data guard request.pageable?.next() != nil else { return } diff --git a/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosPicker.swift b/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosPicker.swift index cbb4ec692cd4..56a5865c56d2 100644 --- a/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosPicker.swift +++ b/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosPicker.swift @@ -6,7 +6,11 @@ protocol StockPhotosPickerDelegate: AnyObject { /// Presents the Stock Photos main interface final class StockPhotosPicker: NSObject { - var allowMultipleSelection = true + var allowMultipleSelection = true { + didSet { + pickerOptions.allowMultipleSelection = allowMultipleSelection + } + } private lazy var dataSource: StockPhotosDataSource = { return StockPhotosDataSource(service: stockPhotosService) @@ -34,7 +38,7 @@ final class StockPhotosPicker: NSObject { options.filter = [.all] options.allowCaptureOfMedia = false options.showSearchBar = true - options.preferredStatusBarStyle = .lightContent + options.preferredStatusBarStyle = WPStyleGuide.preferredStatusBarStyle options.allowMultipleSelection = allowMultipleSelection return options }() diff --git a/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosStrings.swift b/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosStrings.swift index fc12d78dfc40..9ccb0d1a9aca 100644 --- a/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosStrings.swift +++ b/WordPress/Classes/ViewRelated/Media/StockPhotos/StockPhotosStrings.swift @@ -5,7 +5,7 @@ extension String { return NSLocalizedString("Free Photo Library", comment: "One of the options when selecting More in the Post Editor's format bar") } - static var files: String { + static var otherApps: String { return NSLocalizedString("Other Apps", comment: "Menu option used for adding media from other applications.") } @@ -14,7 +14,11 @@ extension String { } static var cancelMoreOptions: String { - return NSLocalizedString("Dismiss", comment: "Dismiss the AlertView") + return NSLocalizedString( + "stockPhotos.strings.dismiss", + value: "Dismiss", + comment: "Dismiss the AlertView" + ) } // MARK: - Placeholder diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/NoResultsTenorConfiguration.swift b/WordPress/Classes/ViewRelated/Media/Tenor/NoResultsTenorConfiguration.swift new file mode 100644 index 000000000000..f0f775c0df49 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/NoResultsTenorConfiguration.swift @@ -0,0 +1,28 @@ +// Empty state for Tenor + +struct NoResultsTenorConfiguration { + static func configureAsIntro(_ viewController: NoResultsViewController) { + viewController.configure(title: .tenorPlaceholderTitle, + image: Constants.imageName, + subtitleImage: Constants.subtitleImageName) + + viewController.view.layoutIfNeeded() + } + + static func configureAsLoading(_ viewController: NoResultsViewController) { + viewController.configure(title: .tenorSearchLoading, + image: Constants.imageName) + + viewController.view.layoutIfNeeded() + } + + static func configure(_ viewController: NoResultsViewController) { + viewController.configureForNoSearchResults(title: .tenorSearchNoResult) + viewController.view.layoutIfNeeded() + } + + private enum Constants { + static let imageName = "media-no-results" + static let subtitleImageName = "tenor-attribution" + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorClient.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorClient.swift new file mode 100644 index 000000000000..10313cfb6b5c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorClient.swift @@ -0,0 +1,105 @@ +// Encapsulates Tenor API Client to interact with Tenor + +import Foundation +import Alamofire + +// MARK: Search endpoint & params + +private enum EndPoints: String { + case search = "https://api.tenor.com/v1/search" +} + +private struct SearchParams { + static let key = "key" + static let searchString = "q" + static let limit = "limit" + static let position = "pos" + static let contentFilter = "contentfilter" +} + +// Reference: https://tenor.com/gifapi/documentation#contentfilter +enum TenorContentFilter: String { + /// G, PG, PG-13, R rated + case off + /// G, PG, PG-13 rated + case low + /// G and PG rated + case medium + /// G rated + case high +} + +// MARK: - TenorClient + +private struct ClientConfig { + var apiKey: String? +} + +struct TenorClient { + typealias TenorSearchResult = ((_ data: [TenorGIF]?, _ position: String?, _ error: Error?) -> Void) + + private var config: ClientConfig + static var shared = TenorClient() + + private init() { + config = ClientConfig() + } + + static func configure(apiKey: String) { + shared.config.apiKey = apiKey + } + + // MARK: - Public Methods + + /// Return a list of GIFs from Tenor for a given search query + /// - Parameters: + /// - query: a search string + /// - limit: return up to a specified number of results (max "limit" is 50 enforced by Tenor, default is 20 if unspecified) + /// - position: return results starting from "position" (use it's the last "position" of the previous search, for paging purpose) + /// - contentFilter: specify the content safety filter level + /// - completion: the handler which will be called on completion + public func search(for query: String, limit: Int = 20, + from position: String?, + contentFilter: TenorContentFilter = .high, + completion: @escaping TenorSearchResult) { + assert(limit <= 50, "Tenor allows a maximum 50 images per search") + + guard let url = URL(string: EndPoints.search.rawValue) else { + return + } + + let params: [String: Any] = [ + SearchParams.key: config.apiKey!, + SearchParams.searchString: query, + SearchParams.limit: limit, + SearchParams.position: position ?? "", + SearchParams.contentFilter: contentFilter.rawValue, + ] + + Alamofire.request(url, method: .get, parameters: params).responseData { response in + + switch response.result { + case .success: + guard let data = response.data else { + let error = NSError(domain: "TenorClient", code: -1, userInfo: nil) + completion(nil, nil, error) + return + } + + do { + let parser = TenorResponseParser<TenorGIF>() + try parser.parse(data) + + completion(parser.results ?? [], parser.next, nil) + } catch { + DDLogError("Couldn't decode API response from Tenor. Required to check https://tenor.com/gifapi/documentation for breaking changes if needed") + + completion(nil, nil, error) + } + + case .failure(let error): + completion(nil, nil, error) + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorGIF.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorGIF.swift new file mode 100644 index 000000000000..a2b97c719247 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorGIF.swift @@ -0,0 +1,15 @@ +// Encapsulates Tenor GIF API object +import Foundation + +struct TenorGIF: Decodable { + let id: String + let created: Date? + let title: String? + + let media: [TenorGIFCollection] + + enum CodingKeys: String, CodingKey { + case id, created, title + case media + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorGIFCollection.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorGIFCollection.swift new file mode 100644 index 000000000000..581f7a461b50 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorGIFCollection.swift @@ -0,0 +1,18 @@ +// Encapsulates Tenor GIFFormat API object + +import Foundation + +// Each GIF Object in Tenor is offered with different format (size) +struct TenorGIFCollection: Decodable { + let gif: TenorMediaObject? // The largest size returned by Tenor + let mediumGIF: TenorMediaObject? + let tinyGIF: TenorMediaObject? + let nanoGIF: TenorMediaObject? + + enum CodingKeys: String, CodingKey { + case nanoGIF = "nanogif" + case tinyGIF = "tinygif" + case gif + case mediumGIF = "mediumgif" + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorMediaObject.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorMediaObject.swift new file mode 100644 index 000000000000..8a3ac32b714b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorMediaObject.swift @@ -0,0 +1,25 @@ +// Encapsulates Tenor GIF Media object + +import Foundation + +struct TenorMediaObject: Decodable { + let url: URL + let dimension: [Int] + let preview: URL + let size: Int64 + + enum CodingKeys: String, CodingKey { + case url + case dimension = "dims" + case preview + case size + } + + var mediaSize: CGSize { + guard dimension.count == 2 else { + return .zero + } + + return CGSize(width: dimension[0], height: dimension[1]) + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorReponseParser.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorReponseParser.swift new file mode 100644 index 000000000000..6fd70e101a30 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorReponseParser.swift @@ -0,0 +1,17 @@ +// Parse a Tenor API response +import Foundation + +class TenorResponseParser<T> where T: Decodable { + private(set) var results: [T]? + private(set) var next: String? + + func parse(_ data: Data) throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + + let response = try decoder.decode(TenorResponse<[T]>.self, from: data) + + results = response.results ?? [] + next = response.next + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorResponse.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorResponse.swift new file mode 100644 index 000000000000..01cb55469e5c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorAPI/TenorResponse.swift @@ -0,0 +1,15 @@ +// Encapsulates Tenor API response + +import Foundation + +struct TenorResponse<T>: Decodable where T: Decodable { + let webURL: URL? + let results: T? + let next: String? + + enum CodingKeys: String, CodingKey { + case webURL = "weburl" + case results + case next + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorDataLoader.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorDataLoader.swift new file mode 100644 index 000000000000..78c7f6d9f1de --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorDataLoader.swift @@ -0,0 +1,60 @@ +/// Implementations of this protocol will be notified when data is loaded from the TenorService +protocol TenorDataLoaderDelegate: AnyObject { + func didLoad(media: [TenorMedia], reset: Bool) +} + +/// Uses the TenorService to load GIFs, handling pagination +final class TenorDataLoader { + private let service: TenorService + private var request: TenorSearchParams? + + private weak var delegate: TenorDataLoaderDelegate? + + fileprivate enum State { + case loading + case idle + } + + fileprivate var state: State = .idle + + init(service: TenorService, delegate: TenorDataLoaderDelegate) { + self.service = service + self.delegate = delegate + } + + func search(_ params: TenorSearchParams) { + request = params + let isFirstPage = request?.pageable?.pageIndex == TenorPageable.defaultPageIndex + state = .loading + DispatchQueue.main.async { [weak self] in + WPAnalytics.track(.tenorSearched) + self?.service.search(params: params) { resultsPage in + self?.state = .idle + self?.request = TenorSearchParams(text: self?.request?.text, pageable: resultsPage.nextPageable()) + + if let content = resultsPage.content() { + self?.delegate?.didLoad(media: content, reset: isFirstPage) + } + } + } + } + + func loadNextPage() { + // Bail out if there is another active request + guard state == .idle else { + return + } + + // Bail out if we are not aware of the pagination status + guard let request = request else { + return + } + + // Bail out if we do not expect more pages of data + guard request.pageable?.next() != nil else { + return + } + + search(request) + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorDataSource.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorDataSource.swift new file mode 100644 index 000000000000..35861882e252 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorDataSource.swift @@ -0,0 +1,197 @@ +import WPMediaPicker + +/// Data Source for Tenor +final class TenorDataSource: NSObject, WPMediaCollectionDataSource { + fileprivate static let paginationThreshold = 10 + + fileprivate var tenorMedia = [TenorMedia]() + var observers = [String: WPMediaChangesBlock]() + private var dataLoader: TenorDataLoader? + + var onStartLoading: (() -> Void)? + var onStopLoading: (() -> Void)? + + private let scheduler = Scheduler(seconds: 0.5) + + private(set) var searchQuery: String = "" + + init(service: TenorService) { + super.init() + self.dataLoader = TenorDataLoader(service: service, delegate: self) + } + + func clearSearch(notifyObservers shouldNotify: Bool) { + tenorMedia.removeAll() + if shouldNotify { + notifyObservers() + } + } + + func search(for searchText: String?) { + searchQuery = searchText ?? "" + + guard searchText?.isEmpty == false else { + clearSearch(notifyObservers: true) + scheduler.cancel() + return + } + + scheduler.debounce { [weak self] in + let params = TenorSearchParams(text: searchText, pageable: TenorPageable.first()) + self?.search(params) + self?.onStartLoading?() + } + } + + private func search(_ params: TenorSearchParams) { + dataLoader?.search(params) + } + + func numberOfGroups() -> Int { + return 1 + } + + func group(at index: Int) -> WPMediaGroup { + return TenorMediaGroup() + } + + func selectedGroup() -> WPMediaGroup? { + return TenorMediaGroup() + } + + func numberOfAssets() -> Int { + return tenorMedia.count + } + + func media(at index: Int) -> WPMediaAsset { + fetchMoreContentIfNecessary(index) + return tenorMedia[index] + } + + func media(withIdentifier identifier: String) -> WPMediaAsset? { + return tenorMedia.filter { $0.identifier() == identifier }.first + } + + func registerChangeObserverBlock(_ callback: @escaping WPMediaChangesBlock) -> NSObjectProtocol { + let blockKey = UUID().uuidString + observers[blockKey] = callback + return blockKey as NSString + } + + func unregisterChangeObserver(_ blockKey: NSObjectProtocol) { + guard let key = blockKey as? String else { + assertionFailure("blockKey must be of type String") + return + } + observers.removeValue(forKey: key) + } + + func registerGroupChangeObserverBlock(_ callback: @escaping WPMediaGroupChangesBlock) -> NSObjectProtocol { + // The group never changes + return NSNull() + } + + func unregisterGroupChangeObserver(_ blockKey: NSObjectProtocol) { + // The group never changes + } + + func loadData(with options: WPMediaLoadOptions, success successBlock: WPMediaSuccessBlock?, failure failureBlock: WPMediaFailureBlock? = nil) { + successBlock?() + } + + func mediaTypeFilter() -> WPMediaType { + return .image + } + + func ascendingOrdering() -> Bool { + return true + } + + func searchCancelled() { + searchQuery = "" + clearSearch(notifyObservers: true) + } + + // MARK: Unused protocol methods + + func setSelectedGroup(_ group: WPMediaGroup) { + // + } + + func add(_ image: UIImage, metadata: [AnyHashable: Any]?, completionBlock: WPMediaAddedBlock? = nil) { + // + } + + func addVideo(from url: URL, completionBlock: WPMediaAddedBlock? = nil) { + // + } + + func setMediaTypeFilter(_ filter: WPMediaType) { + // + } + + func setAscendingOrdering(_ ascending: Bool) { + // + } +} + +// MARK: - Helpers + +extension TenorDataSource { + private func notifyObservers(incremental: Bool = false, inserted: IndexSet = IndexSet()) { + DispatchQueue.main.async { + self.observers.forEach { + $0.value(incremental, IndexSet(), inserted, IndexSet(), []) + } + } + } +} + +// MARK: - Pagination + +extension TenorDataSource { + fileprivate func fetchMoreContentIfNecessary(_ index: Int) { + if shouldLoadMore(index) { + dataLoader?.loadNextPage() + } + } + + private func shouldLoadMore(_ index: Int) -> Bool { + return index + type(of: self).paginationThreshold >= numberOfAssets() + } +} + +extension TenorDataSource: TenorDataLoaderDelegate { + func didLoad(media: [TenorMedia], reset: Bool) { + defer { + onStopLoading?() + } + + guard media.count > 0, searchQuery.count > 0 else { + clearSearch(notifyObservers: true) + return + } + + if reset { + overwriteMedia(with: media) + } else { + appendMedia(with: media) + } + } + + private func overwriteMedia(with media: [TenorMedia]) { + tenorMedia = media + notifyObservers(incremental: false) + } + + private func appendMedia(with media: [TenorMedia]) { + let currentMaxIndex = tenorMedia.count + let newMaxIndex = currentMaxIndex + media.count - 1 + + let isIncremental = currentMaxIndex != 0 + let insertedIndexes = IndexSet(integersIn: currentMaxIndex...newMaxIndex) + + tenorMedia.append(contentsOf: media) + notifyObservers(incremental: isIncremental, inserted: insertedIndexes) + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorMedia.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorMedia.swift new file mode 100644 index 000000000000..9a5a0ae8b2d5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorMedia.swift @@ -0,0 +1,123 @@ +import MobileCoreServices +import WPMediaPicker + +struct TenorImageCollection { + let largeURL: URL + let previewURL: URL + let staticThumbnailURL: URL + let largeSize: CGSize +} + +// Models a Tenor image + +final class TenorMedia: NSObject { + let id: String + let name: String + let updatedDate: Date + let images: TenorImageCollection + + init(id: String, name: String, images: TenorImageCollection, date: Date? = nil) { + self.id = id + self.name = name + self.updatedDate = date ?? Date() + self.images = images + } +} + +// MARK: - Create Tenor media from API GIF Entity + +extension TenorMedia { + convenience init?(tenorGIF gif: TenorGIF) { + let largeGif = gif.media.first { $0.gif != nil }?.gif + let previewGif = gif.media.first { $0.tinyGIF != nil }?.tinyGIF + let thumbnailGif = gif.media.first { $0.nanoGIF != nil }?.nanoGIF + + guard let largeURL = largeGif?.url, + let previewURL = previewGif?.url, + let staticThumbnailURL = thumbnailGif?.url, + let largeSize = largeGif?.mediaSize else { + return nil + } + + let images = TenorImageCollection(largeURL: largeURL, + previewURL: previewURL, + staticThumbnailURL: staticThumbnailURL, + largeSize: largeSize) + + self.init(id: gif.id, name: gif.title ?? "", images: images, date: gif.created) + } +} + +// MARK: - WPMediaAsset + +extension TenorMedia: WPMediaAsset { + func image(with size: CGSize, completionHandler: @escaping WPMediaImageBlock) -> WPMediaRequestID { + // We don't need to download any image here, leave it for the overlay to handle + return 0 + } + + func cancelImageRequest(_ requestID: WPMediaRequestID) { + // Nothing to do + } + + func videoAsset(completionHandler: @escaping WPMediaAssetBlock) -> WPMediaRequestID { + return 0 + } + + func assetType() -> WPMediaType { + return .image + } + + func duration() -> TimeInterval { + return 0 + } + + func baseAsset() -> Any { + return self + } + + func identifier() -> String { + return id + } + + func date() -> Date { + return updatedDate + } + + func pixelSize() -> CGSize { + return images.largeSize + } + + func utTypeIdentifier() -> String? { + return String(kUTTypeGIF) + } +} + +// MARK: - ExportableAsset conformance + +extension TenorMedia: ExportableAsset { + var assetMediaType: MediaType { + return .image + } +} + +// MARK: - MediaExternalAsset conformance + +extension TenorMedia: MediaExternalAsset { + // The URL source for saving into user's media library as well as GIF preview + var URL: URL { + return images.largeURL + } + + var caption: String { + return "" + } +} + +// Overlay +extension TenorMedia { + // Return the smallest GIF size for previewing + var previewURL: URL { + return images.staticThumbnailURL + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorMediaGroup.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorMediaGroup.swift new file mode 100644 index 000000000000..8257a220078b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorMediaGroup.swift @@ -0,0 +1,27 @@ +import WPMediaPicker + +final class TenorMediaGroup: NSObject, WPMediaGroup { + func name() -> String { + return String.tenor + } + + func image(with size: CGSize, completionHandler: @escaping WPMediaImageBlock) -> WPMediaRequestID { + return 0 + } + + func cancelImageRequest(_ requestID: WPMediaRequestID) { + // + } + + func baseGroup() -> Any { + return "" + } + + func identifier() -> String { + return "group id" + } + + func numberOfAssets(of mediaType: WPMediaType, completionHandler: WPMediaCountBlock? = nil) -> Int { + return 10 + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorPageable.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorPageable.swift new file mode 100644 index 000000000000..dd9f0ba2e2c0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorPageable.swift @@ -0,0 +1,50 @@ + +struct TenorPageable: Pageable { + let itemsPerPage: Int + let position: String? + let currentPageIndex: Int + + static let defaultPageSize = 40 // same size as StockPhotos + static let defaultPageIndex = 0 + static let defaultPosition: String? = nil + + func next() -> Pageable? { + guard let position = position, + let currentPosition = Int(position) else { + return nil + } + + // If the last page is not full, there is no more to load (thus there is no next). + let totalPossibleResults = (currentPageIndex + 1) * itemsPerPage + let remainingPageSpace = totalPossibleResults - currentPosition + + if remainingPageSpace < itemsPerPage { + return nil + } + + return TenorPageable(itemsPerPage: itemsPerPage, position: position, currentPageIndex: currentPageIndex + 1) + } + + var pageSize: Int { + return itemsPerPage + } + + var pageIndex: Int { + return currentPageIndex + } +} + +extension TenorPageable { + /// Builds the Pageable corresponding to the first page, with the default page size. + /// + /// - Returns: A TenorPageable configured with the default page size and the initial page handle + static func first() -> TenorPageable { + return TenorPageable(itemsPerPage: defaultPageSize, position: defaultPosition, currentPageIndex: defaultPageIndex) + } +} + +extension TenorPageable: CustomStringConvertible { + var description: String { + return "Tenor Pageable: count \(itemsPerPage) next: \(position ?? "")" + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorPicker.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorPicker.swift new file mode 100644 index 000000000000..7e7e74de467d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorPicker.swift @@ -0,0 +1,183 @@ +import MobileCoreServices +import WPMediaPicker + +protocol TenorPickerDelegate: AnyObject { + func tenorPicker(_ picker: TenorPicker, didFinishPicking assets: [TenorMedia]) +} + +/// Presents the Tenor main interface +final class TenorPicker: NSObject { + // MARK: - Public properties + + var allowMultipleSelection = true { + didSet { + pickerOptions.allowMultipleSelection = allowMultipleSelection + } + } + + // MARK: - Private properties + + private lazy var dataSource: TenorDataSource = { + TenorDataSource(service: tenorService) + }() + + private lazy var tenorService: TenorService = { + TenorService() + }() + + /// Helps choosing the correct view controller for previewing a media asset + /// + private var mediaPreviewHelper: MediaPreviewHelper! + + weak var delegate: TenorPickerDelegate? + private var blog: Blog? + private var observerToken: NSObjectProtocol? + + private let searchHint = NoResultsViewController.controller() + + private lazy var pickerOptions: WPMediaPickerOptions = { + let options = WPMediaPickerOptions() + options.showMostRecentFirst = true + options.filter = [.all] + options.allowCaptureOfMedia = false + options.showSearchBar = true + options.badgedUTTypes = [String(kUTTypeGIF)] + options.preferredStatusBarStyle = WPStyleGuide.preferredStatusBarStyle + options.allowMultipleSelection = allowMultipleSelection + return options + }() + + private lazy var picker: WPNavigationMediaPickerViewController = { + let picker = WPNavigationMediaPickerViewController(options: pickerOptions) + picker.delegate = self + picker.startOnGroupSelector = false + picker.showGroupSelector = false + picker.dataSource = dataSource + picker.cancelButtonTitle = .closePicker + picker.mediaPicker.registerClass(forReusableCellOverlayViews: CachedAnimatedImageView.self) + return picker + }() + + func presentPicker(origin: UIViewController, blog: Blog) { + NoResultsTenorConfiguration.configureAsIntro(searchHint) + self.blog = blog + + origin.present(picker, animated: true) { + self.picker.mediaPicker.searchBar?.becomeFirstResponder() + } + + observeDataSource() + WPAnalytics.track(.tenorAccessed) + } + + private func observeDataSource() { + observerToken = dataSource.registerChangeObserverBlock { [weak self] _, _, _, _, _ in + self?.updateHintView() + } + dataSource.onStartLoading = { [weak self] in + guard let strongSelf = self else { + return + } + NoResultsTenorConfiguration.configureAsLoading(strongSelf.searchHint) + } + dataSource.onStopLoading = { [weak self] in + self?.updateHintView() + } + } + + private func shouldShowNoResults() -> Bool { + return dataSource.searchQuery.count > 0 && dataSource.numberOfAssets() == 0 + } + + private func updateHintView() { + searchHint.removeFromView() + if shouldShowNoResults() { + NoResultsTenorConfiguration.configure(searchHint) + } else { + NoResultsTenorConfiguration.configureAsIntro(searchHint) + } + } + + deinit { + if let token = observerToken { + dataSource.unregisterChangeObserver(token) + } + } +} + +extension TenorPicker: WPMediaPickerViewControllerDelegate { + func mediaPickerController(_ picker: WPMediaPickerViewController, didFinishPicking assets: [WPMediaAsset]) { + guard let assets = assets as? [TenorMedia] else { + assertionFailure("assets should be of type `[TenorMedia]`") + return + } + delegate?.tenorPicker(self, didFinishPicking: assets) + picker.dismiss(animated: true) + dataSource.clearSearch(notifyObservers: false) + hideKeyboard(from: picker.searchBar) + } + + func emptyViewController(forMediaPickerController picker: WPMediaPickerViewController) -> UIViewController? { + return searchHint + } + + func mediaPickerControllerDidEndLoadingData(_ picker: WPMediaPickerViewController) { + if let searchBar = picker.searchBar { + WPStyleGuide.configureSearchBar(searchBar) + } + } + + func mediaPickerControllerDidCancel(_ picker: WPMediaPickerViewController) { + picker.dismiss(animated: true) + dataSource.clearSearch(notifyObservers: false) + hideKeyboard(from: picker.searchBar) + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, didSelect asset: WPMediaAsset) { + hideKeyboard(from: picker.searchBar) + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, didDeselect asset: WPMediaAsset) { + hideKeyboard(from: picker.searchBar) + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, shouldShowOverlayViewForCellFor asset: WPMediaAsset) -> Bool { + return true + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, + willShowOverlayView overlayView: UIView, + forCellFor asset: WPMediaAsset) { + guard let animatedImageView = overlayView as? CachedAnimatedImageView else { + return + } + + guard let tenorMedia = asset as? TenorMedia else { + assertionFailure("asset should be of type `TenorMedia`") + return + } + + animatedImageView.prepForReuse() + animatedImageView.gifStrategy = .tinyGIFs + animatedImageView.contentMode = .scaleAspectFill + animatedImageView.clipsToBounds = true + animatedImageView.setAnimatedImage(URLRequest(url: tenorMedia.previewURL), + placeholderImage: nil, + success: nil, + failure: nil) + } + + func mediaPickerController(_ picker: WPMediaPickerViewController, previewViewControllerFor assets: [WPMediaAsset], selectedIndex selected: Int) -> UIViewController? { + mediaPreviewHelper = MediaPreviewHelper(assets: assets) + return mediaPreviewHelper.previewViewController(selectedIndex: selected) + } + + private func hideKeyboard(from view: UIView?) { + if let view = view, view.isFirstResponder { + // Fix animation conflict between dismissing the keyboard and showing the accessory input view + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + view.resignFirstResponder() + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorResultsPage.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorResultsPage.swift new file mode 100644 index 000000000000..a213831927a7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorResultsPage.swift @@ -0,0 +1,24 @@ + +final class TenorResultsPage: ResultsPage { + private let results: [TenorMedia] + private let pageable: Pageable? + + init(results: [TenorMedia], pageable: Pageable? = nil) { + self.results = results + self.pageable = pageable + } + + func content() -> [TenorMedia]? { + return results + } + + func nextPageable() -> Pageable? { + return pageable?.next() + } +} + +extension TenorResultsPage { + static func empty() -> TenorResultsPage { + return TenorResultsPage(results: [], pageable: nil) + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorService.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorService.swift new file mode 100644 index 000000000000..7fc14a79ae8d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorService.swift @@ -0,0 +1,42 @@ +/// Encapsulates search parameters (text, pagination, etc) +struct TenorSearchParams { + let text: String + let pageable: Pageable? + let limit: Int + + init(text: String?, pageable: Pageable?) { + self.text = text ?? "" + self.pageable = pageable + self.limit = pageable != nil ? pageable!.pageSize : TenorPageable.defaultPageSize + } +} + +class TenorService { + static let tenor: TenorClient = { + TenorClient.configure(apiKey: ApiCredentials.tenorApiKey) + return TenorClient.shared + }() + + func search(params: TenorSearchParams, completion: @escaping (TenorResultsPage) -> Void) { + let tenorPageable = params.pageable as? TenorPageable + let currentPageIndex = tenorPageable?.pageIndex + + TenorService.tenor.search(for: params.text, + limit: params.limit, + from: tenorPageable?.position) { gifs, position, error in + + guard let gifObjects = gifs, error == nil else { + completion(TenorResultsPage.empty()) + return + } + + let medias = gifObjects.compactMap { TenorMedia(tenorGIF: $0) } + let nextPageable = TenorPageable(itemsPerPage: params.limit, + position: position, + currentPageIndex: currentPageIndex ?? 0) + let result = TenorResultsPage(results: medias, + pageable: nextPageable) + completion(result) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Media/Tenor/TenorStrings.swift b/WordPress/Classes/ViewRelated/Media/Tenor/TenorStrings.swift new file mode 100644 index 000000000000..4f82400180fd --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/Tenor/TenorStrings.swift @@ -0,0 +1,34 @@ +/// Extension on String containing the literals for the Tenor feature +extension String { + // MARK: - Entry point: alert controller + + static var tenor: String { + return NSLocalizedString("Free GIF Library", comment: "One of the options when selecting More in the Post Editor's format bar") + } + + // MARK: - Placeholder + + static var tenorPlaceholderTitle: String { + return NSLocalizedString("Search to find GIFs to add to your Media Library!", comment: "Title for placeholder in Tenor picker") + } + + static var tenorPlaceholderSubtitle: String { + return NSLocalizedString("Powered by Tenor", comment: "Subtitle for placeholder in Tenor picker. `The company name 'Tenor' should always be written as it is.") + } + + static var tenorSearchNoResult: String { + return NSLocalizedString("No media matching your search", comment: "Phrase to show when the user searches for GIFs but there are no result to show.") + } + + static var tenorSearchLoading: String { + return NSLocalizedString("Loading GIFs...", comment: "Phrase to show when the user has searched for GIFs and they are being loaded.") + } + +} + +enum GIFAlertStrings { + static let title = NSLocalizedString("Warning", comment: "Editing GIF alert title.") + static let message = NSLocalizedString("Editing this GIF will remove its animation.", comment: "Editing GIF alert message.") + static let cancel = NSLocalizedString("Cancel", comment: "Editing GIF alert cancel action button.") + static let edit = NSLocalizedString("Edit", comment: "Editing GIF alert default action button.") +} diff --git a/WordPress/Classes/ViewRelated/Media/WPAndDeviceMediaLibraryDataSource.m b/WordPress/Classes/ViewRelated/Media/WPAndDeviceMediaLibraryDataSource.m index f31bba20c8a5..468216dbb8b1 100644 --- a/WordPress/Classes/ViewRelated/Media/WPAndDeviceMediaLibraryDataSource.m +++ b/WordPress/Classes/ViewRelated/Media/WPAndDeviceMediaLibraryDataSource.m @@ -212,7 +212,7 @@ - (void)loadDataWithOptions:(WPMediaLoadOptions)options successBlock(); } [self.mediaLibraryDataSource loadDataWithOptions:options success:successBlock failure:failureBlock]; - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { [self.mediaLibraryDataSource loadDataWithOptions:options success:successBlock failure:failureBlock]; }]; } else { diff --git a/WordPress/Classes/ViewRelated/Menus/MenuDetailsViewController.m b/WordPress/Classes/ViewRelated/Menus/MenuDetailsViewController.m index 0a61bc23a471..ed13fb04b209 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuDetailsViewController.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuDetailsViewController.m @@ -55,6 +55,7 @@ - (void)setupTextField textField.textColor = [UIColor murielText]; textField.tintColor = [UIColor murielListIcon]; textField.adjustsFontForContentSizeCategory = YES; + textField.accessibilityLabel = NSLocalizedString(@"Menu name", @"Screen Reader: Description for text field that edits the menu name."); [self updateTextFieldFont]; [textField addTarget:self action:@selector(hideTextFieldKeyboard) forControlEvents:UIControlEventEditingDidEndOnExit]; [textField addTarget:self action:@selector(textFieldValueChanged:) forControlEvents:UIControlEventEditingChanged]; @@ -75,10 +76,11 @@ - (void)setupTrashButton UIButton *trashButton = self.trashButton; [trashButton setTitle:nil forState:UIControlStateNormal]; trashButton.tintColor = [UIColor murielListIcon]; - [trashButton setImage:[Gridicon iconOfType:GridiconTypeTrash] forState:UIControlStateNormal]; + [trashButton setImage:[UIImage gridiconOfType:GridiconTypeTrash] forState:UIControlStateNormal]; [trashButton addTarget:self action:@selector(trashButtonPressed) forControlEvents:UIControlEventTouchUpInside]; trashButton.backgroundColor = [UIColor clearColor]; trashButton.adjustsImageWhenHighlighted = YES; + trashButton.accessibilityLabel = NSLocalizedString(@"Delete menu", @"Screen Reader: Button that deletes a menu."); } - (void)setupTextFieldDesignViews @@ -86,7 +88,7 @@ - (void)setupTextFieldDesignViews UIView *textFieldDesignView = self.textFieldDesignView; textFieldDesignView.layer.cornerRadius = MenusDesignDefaultCornerRadius; - UIImage *image = [Gridicon iconOfType:GridiconTypePencil]; + UIImage *image = [UIImage gridiconOfType:GridiconTypePencil]; UIImageView *imageView = [[UIImageView alloc] initWithImage:image]; imageView.translatesAutoresizingMaskIntoConstraints = NO; imageView.tintColor = [UIColor murielListIcon]; diff --git a/WordPress/Classes/ViewRelated/Menus/MenuHeaderViewController.m b/WordPress/Classes/ViewRelated/Menus/MenuHeaderViewController.m index 9bc1ce9c7c11..fb1bcb1759f9 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuHeaderViewController.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuHeaderViewController.m @@ -77,12 +77,14 @@ - (void)setSelectedLocation:(MenuLocation *)location { MenusSelectionItem *locationItem = [self.locationsView selectionItemForObject:location]; [self.locationsView setSelectedItem:locationItem]; + [self prepareForVoiceOver]; } - (void)setSelectedMenu:(Menu *)menu { MenusSelectionItem *menuItem = [self.menusView selectionItemForObject:menu]; [self.menusView setSelectedItem:menuItem]; + [self prepareForVoiceOver]; } - (void)refreshMenuViewsUsingMenu:(Menu *)menu @@ -109,6 +111,7 @@ - (void)configureTextLabel self.textLabel.backgroundColor = [UIColor clearColor]; self.textLabel.textColor = [UIColor murielNeutral]; self.textLabel.text = NSLocalizedString(@"USES", @"Menus label for describing which menu the location uses in the header."); + self.textLabel.isAccessibilityElement = NO; } #pragma mark - MenusSelectionViewDelegate @@ -139,4 +142,36 @@ - (void)selectionViewSelectedOptionForCreatingNewItem:(MenusSelectionView *)sele [self contractSelectionsIfNeeded]; } +#pragma mark - accessibility + +- (void)prepareForVoiceOver +{ + [self configureLocationAccessibility]; + [self configureMenuAccessibility]; +} + +- (void)configureLocationAccessibility +{ + // Menu area: Header. 3 menu areas available. Button. [hint] Expands to select a different menu area. + NSString *format = NSLocalizedString(@"Menu area: %@, %d menu areas available", @"Screen reader string too choose a menu area to edit. %@ is the name of the menu area (Primary, Footer, etc...). %d is a number indicating the ammount of menu areas available."); + MenusSelectionItem *selectedItem = self.locationsView.selectedItem; + self.locationsView.accessibilityLabel = [NSString stringWithFormat:format, + selectedItem.displayName, + self.blog.menuLocations.count]; + self.locationsView.accessibilityHint = NSLocalizedString(@"Expands to select a different menu area", @"Screen reader hint (non-imperative) about what does the site menu area selector button do."); +} + +- (void)configureMenuAccessibility +{ + // Menu in area Header: Primary. 3 menus available. Button. [hint] Expands to select a different menu to edit. + NSString *format = NSLocalizedString(@"Menu in area %@: %@, %d menus available", @"Screen reader string too choose a menu to edit. First %@ is the name of the menu area (Primary, Footer, etc...). Second %@ is name of the menu currently selected. %d is a number indicating the ammount of menus available in the selected menu area."); + MenusSelectionItem *selectedItem = self.menusView.selectedItem; + MenusSelectionItem *selectedLocationItem = self.locationsView.selectedItem; + self.menusView.accessibilityLabel = [NSString stringWithFormat:format, + selectedLocationItem.displayName, + selectedItem.displayName, + self.blog.menus.count]; + self.menusView.accessibilityHint = NSLocalizedString(@"Expands to select a different menu", @"Screen reader hint (non-imperative) about what does the site menu selector button do."); +} + @end diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItem+ViewDesign.m b/WordPress/Classes/ViewRelated/Menus/MenuItem+ViewDesign.m index d6428257a706..209e5c9c077b 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItem+ViewDesign.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItem+ViewDesign.m @@ -11,16 +11,16 @@ + (UIImage *)iconImageForItemType:(NSString *)itemType UIImage *image = nil; if ([itemType isEqualToString:MenuItemTypePage]) { - image = [Gridicon iconOfType:GridiconTypePages]; + image = [UIImage gridiconOfType:GridiconTypePages]; } else if ([itemType isEqualToString:MenuItemTypeCustom]) { - image = [Gridicon iconOfType:GridiconTypeLink]; + image = [UIImage gridiconOfType:GridiconTypeLink]; } else if ([itemType isEqualToString:MenuItemTypeCategory]) { - image = [Gridicon iconOfType:GridiconTypeFolder]; + image = [UIImage gridiconOfType:GridiconTypeFolder]; } else if ([itemType isEqualToString:MenuItemTypeTag]) { - image = [Gridicon iconOfType:GridiconTypeTag]; + image = [UIImage gridiconOfType:GridiconTypeTag]; } - return image ?: [Gridicon iconOfType:GridiconTypePosts]; + return image ?: [UIImage gridiconOfType:GridiconTypePosts]; } @end diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemAbstractPostsViewController.m b/WordPress/Classes/ViewRelated/Menus/MenuItemAbstractPostsViewController.m index 45cdbe227f20..577ef3ce57b5 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemAbstractPostsViewController.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemAbstractPostsViewController.m @@ -62,7 +62,7 @@ - (void)syncPosts forBlog:[self blog] success:^(NSArray *posts) { [self didFinishSyncingPosts:posts options:options]; - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { [self didFinishSyncingPosts:nil options:options]; [self showLoadingErrorMessageForResults]; }]; @@ -153,7 +153,7 @@ - (void)scrollingWillDisplayEndOfTableView:(UITableView *)tableView success:^(NSArray *posts) { [self didFinishSyncingPosts:posts options:options]; self.isSyncingAdditionalPosts = NO; - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { [self didFinishSyncingPosts:nil options:options]; self.isSyncingAdditionalPosts = NO; [self showLoadingErrorMessageForResults]; @@ -209,9 +209,9 @@ - (void)searchBarInputChangeDetectedForRemoteResultsUpdateWithText:(NSString *)s [service syncPostsOfType:[self sourceItemType] withOptions:options forBlog:[self blog] - success:^(NSArray *posts) { + success:^(NSArray * __unused posts) { stopLoading(); - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { stopLoading(); [self showLoadingErrorMessageForResults]; }]; diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemAbstractView.m b/WordPress/Classes/ViewRelated/Menus/MenuItemAbstractView.m index b7c2c35ff7c8..c81ae039d481 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemAbstractView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemAbstractView.m @@ -126,6 +126,10 @@ - (void)setupTextLabel label.adjustsFontForContentSizeCategory = YES; label.backgroundColor = [UIColor clearColor]; + // Taps are handled by the UITouches API. + // Marking this label as a button is the simplest way to show tappability to a VoiceOver user. + label.accessibilityTraits = UIAccessibilityTraitButton; + NSAssert(_stackView != nil, @"stackView is nil"); [_stackView addArrangedSubview:label]; diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemCategoriesViewController.m b/WordPress/Classes/ViewRelated/Menus/MenuItemCategoriesViewController.m index 2f95aef922e0..10a29b1d8f2a 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemCategoriesViewController.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemCategoriesViewController.m @@ -4,7 +4,7 @@ #import "Menu.h" #import "MenuItem.h" #import "Blog.h" -#import "WPCategoryTree.h" +#import "WordPress-Swift.h" static NSUInteger const CategorySyncLimit = 1000; static NSString * const CategorySortKey = @"categoryName"; @@ -55,13 +55,13 @@ - (void)syncCategories void(^stopLoading)(void) = ^() { [self hideLoadingSourcesIndicator]; }; - PostCategoryService *categoryService = [[PostCategoryService alloc] initWithManagedObjectContext:[self managedObjectContext]]; + PostCategoryService *categoryService = [[PostCategoryService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [categoryService syncCategoriesForBlog:[self blog] number:@(CategorySyncLimit) offset:@(0) - success:^(NSArray<PostCategory *> *categories) { + success:^(NSArray<PostCategory *> * __unused categories) { stopLoading(); - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { stopLoading(); [self showLoadingErrorMessageForResults]; }]; diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemCheckButtonView.m b/WordPress/Classes/ViewRelated/Menus/MenuItemCheckButtonView.m index edfbc4d7c295..6cfe9b870ba2 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemCheckButtonView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemCheckButtonView.m @@ -38,7 +38,7 @@ - (void)setupIconView { UIImageView *iconView = [[UIImageView alloc] init]; iconView.translatesAutoresizingMaskIntoConstraints = NO; - iconView.image = [Gridicon iconOfType:GridiconTypeCheckmark]; + iconView.image = [UIImage gridiconOfType:GridiconTypeCheckmark]; iconView.tintColor = [UIColor murielListIcon]; iconView.contentMode = UIViewContentModeScaleAspectFit; iconView.backgroundColor = [UIColor clearColor]; diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemEditingFooterView.m b/WordPress/Classes/ViewRelated/Menus/MenuItemEditingFooterView.m index c52420a90b96..7e85a405dd1e 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemEditingFooterView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemEditingFooterView.m @@ -42,7 +42,7 @@ - (void)setupTrashButton button.adjustsImageWhenHighlighted = YES; [button setTitle:nil forState:UIControlStateNormal]; button.tintColor = [UIColor murielNeutral30]; - [button setImage:[Gridicon iconOfType:GridiconTypeTrash] forState:UIControlStateNormal]; + [button setImage:[UIImage gridiconOfType:GridiconTypeTrash] forState:UIControlStateNormal]; [button addTarget:self action:@selector(trashButtonPressed) forControlEvents:UIControlEventTouchUpInside]; } diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemEditingHeaderView.m b/WordPress/Classes/ViewRelated/Menus/MenuItemEditingHeaderView.m index 1877ae6db970..a1f6fc37def3 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemEditingHeaderView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemEditingHeaderView.m @@ -137,7 +137,7 @@ - (void)setNeedsTopConstraintsUpdateForStatusBarAppearence:(BOOL)hidden } else { - self.stackViewTopConstraint.constant = [self defaultStackDesignMargin] + [[UIApplication sharedApplication] statusBarFrame].size.height; + self.stackViewTopConstraint.constant = [self defaultStackDesignMargin] + [[UIApplication sharedApplication] currentStatusBarFrame].size.height; } } diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemEditingViewController.m b/WordPress/Classes/ViewRelated/Menus/MenuItemEditingViewController.m index e08815beff5a..ed85f186cad4 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemEditingViewController.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemEditingViewController.m @@ -6,7 +6,7 @@ #import "MenuItemEditingFooterView.h" #import "MenuItemSourceViewController.h" #import "MenuItemTypeViewController.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import <WordPressShared/WPDeviceIdentification.h> #import <WordPressShared/WPStyleGuide.h> #import "WordPress-Swift.h" diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemInsertionView.m b/WordPress/Classes/ViewRelated/Menus/MenuItemInsertionView.m index 9d3afce6d5a8..82809cd24539 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemInsertionView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemInsertionView.m @@ -16,7 +16,7 @@ - (id)init [self.contentView addGestureRecognizer:tap]; self.iconView.tintColor = [UIColor murielPrimary]; - self.iconView.image = [Gridicon iconOfType:GridiconTypePlus]; + self.iconView.image = [UIImage gridiconOfType:GridiconTypePlus]; } return self; diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemSourceFooterView.m b/WordPress/Classes/ViewRelated/Menus/MenuItemSourceFooterView.m index 20b008999054..23a00eb1b8fc 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemSourceFooterView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemSourceFooterView.m @@ -4,48 +4,25 @@ #import <WordPressShared/WPStyleGuide.h> #import "WordPress-Swift.h" -static NSTimeInterval const PulseAnimationDuration = 0.35; - -@protocol MenuItemSourceLoadingDrawViewDelegate <NSObject> - -- (void)drawViewDrawRect:(CGRect)rect; - -@end - -@interface MenuItemSourceLoadingDrawView : UIView - -@property (nonatomic, weak) id <MenuItemSourceLoadingDrawViewDelegate> drawDelegate; - -@end - -@interface MenuItemSourceFooterView () <MenuItemSourceLoadingDrawViewDelegate> +@interface MenuItemSourceFooterView () @property (nonatomic, copy) NSString *labelText; @property (nonatomic, assign) BOOL drawsLabelTextIfNeeded; @property (nonatomic, strong) MenuItemSourceCell *sourceCell; -@property (nonatomic, strong) MenuItemSourceLoadingDrawView *drawView; -@property (nonatomic, strong) NSTimer *beginLoadingAnimationsTimer; -@property (nonatomic, strong) NSTimer *endLoadingAnimationsTimer; +@property (nonatomic, strong) UIActivityIndicatorView *activityIndicator; @end @implementation MenuItemSourceFooterView -- (void)dealloc -{ - [self.beginLoadingAnimationsTimer invalidate]; - [self.endLoadingAnimationsTimer invalidate]; -} - - (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { - self.backgroundColor = [UIColor murielListBackground]; + self.backgroundColor = [UIColor murielBasicBackground]; [self setupSourceCell]; - [self setupDrawView]; } return self; @@ -60,23 +37,22 @@ - (void)setupSourceCell [cell setTitle:@"Dummy Text For Sizing the Label"]; [self addSubview:cell]; self.sourceCell = cell; + [self setupActivityIndicator]; } -- (void)setupDrawView +- (void)setupActivityIndicator { - MenuItemSourceLoadingDrawView *drawView = [[MenuItemSourceLoadingDrawView alloc] initWithFrame:self.bounds]; - drawView.backgroundColor = [UIColor murielListBackground]; - drawView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - drawView.drawDelegate = self; - drawView.contentMode = UIViewContentModeRedraw; - [self.sourceCell addSubview:drawView]; - self.drawView = drawView; + _activityIndicator = [[UIActivityIndicatorView alloc] init]; // defaults to Medium + _activityIndicator.translatesAutoresizingMaskIntoConstraints = false; + + [self addSubview: _activityIndicator]; + [self pinSubviewAtCenter: _activityIndicator]; } - (void)toggleMessageWithText:(NSString *)text { self.labelText = text; - if (!self.beginLoadingAnimationsTimer && !self.endLoadingAnimationsTimer) { + if (!self.activityIndicator.isAnimating) { self.drawsLabelTextIfNeeded = YES; } } @@ -89,19 +65,9 @@ - (void)startLoadingIndicatorAnimation self.drawsLabelTextIfNeeded = NO; - [self.beginLoadingAnimationsTimer invalidate]; - self.beginLoadingAnimationsTimer = nil; - [self.endLoadingAnimationsTimer invalidate]; - self.endLoadingAnimationsTimer = nil; - + [self.activityIndicator startAnimating]; self.isAnimating = YES; self.sourceCell.hidden = NO; - - // Will begin animations on next runloop incase there are upcoming layout upates in-which the animation won't play. - NSTimer *timer = [NSTimer timerWithTimeInterval:0.0 target:self selector:@selector(beginCellAnimations) userInfo:nil repeats:NO]; - self.beginLoadingAnimationsTimer = timer; - // Add the timer to the runloop scheduling under common modes, to not pause for UIScrollView scrolling. - [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]; } - (void)stopLoadingIndicatorAnimation @@ -110,37 +76,8 @@ - (void)stopLoadingIndicatorAnimation return; } - [self.beginLoadingAnimationsTimer invalidate]; - self.beginLoadingAnimationsTimer = nil; - [self.endLoadingAnimationsTimer invalidate]; - self.endLoadingAnimationsTimer = nil; - + [self.activityIndicator stopAnimating]; self.isAnimating = NO; - // Let the animation play for just a bit before ending it. This avoids flickering. - NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:1.0 target:self selector:@selector(endCellAnimations) userInfo:nil repeats:NO]; - self.endLoadingAnimationsTimer = timer; - // Add the timer to the runloop scheduling under common modes so it does not pause while a UIScrollView scrolls. - [[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes]; -} - -- (void)beginCellAnimations -{ - CABasicAnimation *animation = [CABasicAnimation new]; - animation.fromValue = @(0.0); - animation.toValue = @(1.0); - animation.keyPath = @"opacity"; - animation.autoreverses = YES; - animation.repeatCount = HUGE_VALF; - animation.duration = PulseAnimationDuration; - [self.sourceCell.layer addAnimation:animation forKey:@"pulse"]; -} - -- (void)endCellAnimations -{ - self.drawsLabelTextIfNeeded = YES; - - [self.sourceCell.layer removeAllAnimations]; - self.sourceCell.hidden = YES; } - (void)setLabelText:(NSString *)labelText @@ -176,23 +113,4 @@ - (void)drawRect:(CGRect)rect } } -#pragma mark - MenuItemSourceLoadingDrawViewDelegate - -- (void)drawViewDrawRect:(CGRect)rect -{ - CGContextRef context = UIGraphicsGetCurrentContext(); - CGContextSetFillColorWithColor(context, [[UIColor murielNeutral0] CGColor]); - CGRect labelRect = self.sourceCell.drawingRectForLabel; - CGContextFillRect(context, labelRect); -} - -@end - -@implementation MenuItemSourceLoadingDrawView - -- (void)drawRect:(CGRect)rect -{ - [self.drawDelegate drawViewDrawRect:rect]; -} - @end diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemSourceHeaderView.m b/WordPress/Classes/ViewRelated/Menus/MenuItemSourceHeaderView.m index d1a4ce97fd7e..572e061d66b9 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemSourceHeaderView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemSourceHeaderView.m @@ -69,7 +69,7 @@ - (void)setupIconView iconView.contentMode = UIViewContentModeScaleAspectFit; iconView.backgroundColor = [UIColor clearColor]; iconView.tintColor = [UIColor murielNeutral30]; - iconView.image = [Gridicon iconOfType:GridiconTypeChevronLeft]; + iconView.image = [UIImage gridiconOfType:GridiconTypeChevronLeft]; NSAssert(_stackView != nil, @"stackView is nil"); [_stackView addArrangedSubview:iconView]; diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemSourceTextBar.m b/WordPress/Classes/ViewRelated/Menus/MenuItemSourceTextBar.m index c492e6e30594..0b8b2a04b56f 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemSourceTextBar.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemSourceTextBar.m @@ -54,7 +54,7 @@ - (id)initAsSearchBar NSAssert(_iconView != nil, @"iconView is nil"); - _iconView.image = [Gridicon iconOfType:GridiconTypeSearch]; + _iconView.image = [UIImage gridiconOfType:GridiconTypeSearch]; _iconView.hidden = NO; NSAssert(_textField != nil, @"textField is nil"); diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemTagsViewController.m b/WordPress/Classes/ViewRelated/Menus/MenuItemTagsViewController.m index 692e3e2fb52d..7e2cabc7b989 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemTagsViewController.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemTagsViewController.m @@ -74,9 +74,9 @@ - (void)syncTags [tagService syncTagsForBlog:[self blog] number:@(MenuItemSourceTagSyncLimit) offset:@(0) - success:^(NSArray<PostTag *> *tags) { + success:^(NSArray<PostTag *> * __unused tags) { stopLoading(); - } failure:^(NSError *error) { + } failure:^(NSError * __unused error) { stopLoading(); [self showLoadingErrorMessageForResults]; }]; @@ -162,7 +162,7 @@ - (void)scrollingWillDisplayEndOfTableView:(UITableView *)tableView stopLoading(); } - failure:^(NSError *error) { + failure:^(NSError * __unused error) { stopLoading(); [self showLoadingErrorMessageForResults]; }]; @@ -216,10 +216,10 @@ - (void)searchBarInputChangeDetectedForRemoteResultsUpdateWithText:(NSString *)s DDLogDebug(@"MenuItemSourceTagView: Searching tags PostTagService"); [self.searchTagService searchTagsWithName:searchText blog:[self blog] - success:^(NSArray<PostTag *> *tags) { + success:^(NSArray<PostTag *> * __unused tags) { stopLoading(); } - failure:^(NSError *error) { + failure:^(NSError * __unused error) { stopLoading(); [self showLoadingErrorMessageForResults]; }]; diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemTypeSelectionView.m b/WordPress/Classes/ViewRelated/Menus/MenuItemTypeSelectionView.m index 5e618549dac5..546204c2bdb5 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemTypeSelectionView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemTypeSelectionView.m @@ -111,7 +111,7 @@ - (void)setupArrowIconView iconView.contentMode = UIViewContentModeScaleAspectFit; iconView.backgroundColor = [UIColor clearColor]; iconView.tintColor = [UIColor murielListIcon]; - iconView.image = [Gridicon iconOfType:GridiconTypeChevronRight]; + iconView.image = [UIImage gridiconOfType:GridiconTypeChevronRight]; NSAssert(_stackView != nil, @"stackView is nil"); [_stackView addArrangedSubview:iconView]; diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemTypeViewController.m b/WordPress/Classes/ViewRelated/Menus/MenuItemTypeViewController.m index 3eea54ab7aa9..8bd50349b9c7 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemTypeViewController.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemTypeViewController.m @@ -54,7 +54,7 @@ - (void)loadPostTypesForBlog:(Blog *)blog // Sync the available postTypes for blog __weak __typeof__(self) weakSelf = self; - BlogService *service = [[BlogService alloc] initWithManagedObjectContext:blog.managedObjectContext]; + BlogService *service = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [service syncPostTypesForBlog:blog success:^{ // synced post types [weakSelf addCustomBlogPostTypesIfNeeded:blog]; diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemView.h b/WordPress/Classes/ViewRelated/Menus/MenuItemView.h index 9ee8661f0fed..cfdf1fe43f66 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemView.h +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemView.h @@ -29,6 +29,11 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)refresh; +/** + Refresh the accessibility labels. + */ +- (void)refreshAccessibilityLabels; + /** The detectedable region of the view for allowing ordering. */ diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemView.m b/WordPress/Classes/ViewRelated/Menus/MenuItemView.m index 8aa702d1d99d..23e9cd8b7743 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemView.m @@ -30,6 +30,7 @@ - (id)init [self setupCancelButton]; self.highlighted = NO; + self.textLabel.accessibilityHint = NSLocalizedString(@"Edits this menu item", @"Screen reader hint for button to edit a menu item"); } return self; @@ -37,7 +38,8 @@ - (id)init - (void)setupAddButton { - UIButton *button = [self addAccessoryButtonIconViewWithImage:[Gridicon iconOfType:GridiconTypePlus]]; + UIButton *button = [self addAccessoryButtonIconViewWithImage:[UIImage gridiconOfType:GridiconTypePlus]]; + button.accessibilityLabel = NSLocalizedString(@"Add new menu item", @"Screen reader text for button that adds a menu item"); [button addTarget:self action:@selector(addButtonPressed) forControlEvents:UIControlEventTouchUpInside]; _addButton = button; } @@ -46,7 +48,11 @@ - (void)setupOrderingButton { UIImage *image = [[UIImage imageNamed:@"menus-move-icon"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; UIButton *button = [self addAccessoryButtonIconViewWithImage:image]; + button.accessibilityLabel = NSLocalizedString(@"Move menu item", @"Screen reader text for button that will move the menu item"); + button.accessibilityHint = NSLocalizedString(@"Double tap and hold to move this menu item up or down. Move horizontally to change hierarchy.", @"Screen reader hint for button that will move the menu item"); button.userInteractionEnabled = NO; + // Override the accessibility traits so that VoiceOver doesn't read "Dimmed" due to userInteractionEnabled = NO + [button setAccessibilityTraits:UIAccessibilityTraitButton]; _orderingButton = button; } @@ -105,6 +111,21 @@ - (void)refresh { self.iconView.image = [MenuItem iconImageForItemType:self.item.type]; self.textLabel.text = self.item.name; + [self refreshAccessibilityLabels]; +} + +- (void)refreshAccessibilityLabels +{ + NSString *parentString; + if (self.item.parent) { + parentString = [NSString stringWithFormat:NSLocalizedString(@"Child of %@", @"Screen reader text expressing the menu item is a child of another menu item. Argument is a name for another menu item."), self.item.parent.name]; + } else { + parentString = NSLocalizedString(@"Top level", @"Screen reader text expressing the menu item is at the top level and has no parent."); + } + self.textLabel.accessibilityLabel = [NSString stringWithFormat:@"%@. %@", self.textLabel.text, parentString]; + + NSString *labelString = NSLocalizedString(@"Move %@", @"Screen reader text for button that will move the menu item. Argument is menu item's name."); + self.orderingButton.accessibilityLabel = [NSString stringWithFormat:labelString, self.item.name]; } - (CGRect)orderingToggleRect diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemsViewController.h b/WordPress/Classes/ViewRelated/Menus/MenuItemsViewController.h index ebcb6e527f42..670f476bf5e2 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemsViewController.h +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemsViewController.h @@ -27,6 +27,14 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)removeItem:(MenuItem *)item; +/// Generates a string used by VoiceOver to announce menu ordering. +/// @param parent A new parent of the current item. +/// @param parentChanged If the parent changed. Needed to infer "Top level" when parent is nil. +/// @param before A menu item that now precedes the current item. +/// @param after A menu item that now succeeds the current item. +/// @return An NSString to announce, or nil. ++ (nullable NSString *)generateOrderingChangeVOString:(nullable MenuItem *)parent parentChanged:(BOOL)parentChanged before:(nullable MenuItem *)before after:(nullable MenuItem *)after; + @end @protocol MenuItemsViewControllerDelegate <NSObject> diff --git a/WordPress/Classes/ViewRelated/Menus/MenuItemsViewController.m b/WordPress/Classes/ViewRelated/Menus/MenuItemsViewController.m index 7c72b4f063f7..cc98f72bee52 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenuItemsViewController.m +++ b/WordPress/Classes/ViewRelated/Menus/MenuItemsViewController.m @@ -5,7 +5,7 @@ #import "MenuItemView.h" #import "MenuItemInsertionView.h" #import "MenuItemsVisualOrderingView.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "Menu+ViewDesign.h" #import "WPGUIConstants.h" #import <WordPressShared/WPDeviceIdentification.h> @@ -162,6 +162,8 @@ - (void)updateParentChildIndentationForItemViews itemView.indentationLevel++; parentItem = parentItem.parent; } + + [itemView refreshAccessibilityLabels]; } } @@ -287,7 +289,7 @@ - (void)removeItemInsertionViews:(BOOL)animated // a delegate will likely scroll the content with the size change [self.delegate itemsViewAnimatingContentSizeChanges:self focusedRect:previousRect updatedFocusRect:updatedRect]; - } completion:^(BOOL finished) { + } completion:^(BOOL __unused finished) { [self removeItemInsertionViews]; }]; @@ -488,6 +490,9 @@ - (void)orderingTouchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)ev if (newParent) { selectedItem.parent = newParent; + MenuItem *precedingSibling = [selectedItem precedingSiblingInOrderedItems:orderedItems]; + [self announceOrderingChange:newParent parentChanged:YES before:nil after:precedingSibling]; + modelUpdated = YES; } @@ -500,6 +505,9 @@ - (void)orderingTouchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)ev // try to move up the parent tree MenuItem *parent = selectedItem.parent.parent; selectedItem.parent = parent; + MenuItem *precedingSibling = [selectedItem precedingSiblingInOrderedItems:orderedItems]; + [self announceOrderingChange:parent parentChanged:YES before:nil after:precedingSibling]; + modelUpdated = YES; } } @@ -606,6 +614,8 @@ - (BOOL)handleOrderingTouchForItemView:(MenuItemView *)itemView withOtherItemVie if ([self nextAvailableItemForOrderingAfterItem:item] == otherItem) { // take the parent of the otherItem, or nil item.parent = otherItem.parent; + [self announceOrderingChange:item.parent parentChanged:YES before:otherItem after:nil]; + updated = YES; } } @@ -616,9 +626,11 @@ - (BOOL)handleOrderingTouchForItemView:(MenuItemView *)itemView withOtherItemVie if (otherItem.children.count) { // if ordering after a parent, we need to become a child item.parent = otherItem; + [self announceOrderingChange:otherItem parentChanged:YES before:nil after:nil]; } else { // assuming the item will take the parent of the otherItem's parent, or nil item.parent = otherItem.parent; + [self announceOrderingChange:nil parentChanged:NO before:nil after:otherItem]; } moveItemAndDescendantsOrderingWithOtherItem(YES); @@ -631,6 +643,11 @@ - (BOOL)handleOrderingTouchForItemView:(MenuItemView *)itemView withOtherItemVie if (orderingTouchesBeforeOtherItem) { // trying to order the item before the otherItem + if (item.parent != otherItem.parent) { + [self announceOrderingChange:otherItem.parent parentChanged:YES before:otherItem after:nil]; + } else { + [self announceOrderingChange:nil parentChanged:NO before:otherItem after:nil]; + } // assuming the item will become the parent of the otherItem's parent, or nil item.parent = otherItem.parent; @@ -899,4 +916,49 @@ - (void)cleanUpOrderingFeedbackGenerator self.orderingFeedbackGenerator = nil; } +#pragma mark - VoiceOver + ++ (nullable NSString *)generateOrderingChangeVOString:(nullable MenuItem *)parent parentChanged:(BOOL)parentChanged before:(nullable MenuItem *)before after:(nullable MenuItem *)after { + NSMutableArray *stringArray = [[NSMutableArray alloc] init]; + + if (parentChanged) { + if (parent) { + NSString *parentString = NSLocalizedString(@"Child of %@", @"Screen reader text expressing the menu item is a child of another menu item. Argument is a name for another menu item."); + [stringArray addObject:[NSString stringWithFormat:parentString, parent.name]]; + } else { + NSString *parentString = NSLocalizedString(@"Top level", @"Screen reader text expressing the menu item is at the top level and has no parent."); + [stringArray addObject:parentString]; + } + } + if (after) { + NSString *afterString = NSLocalizedString(@"After %@", @"Screen reader text expressing the menu item is after another menu item. Argument is a name for another menu item."); + [stringArray addObject:[NSString stringWithFormat:afterString, after.name]]; + } + if (before) { + NSString *beforeString = NSLocalizedString(@"Before %@", @"Screen reader text expressing the menu item is before another menu item. Argument is a name for another menu item."); + [stringArray addObject:[NSString stringWithFormat:beforeString, before.name]]; + } + if (!stringArray.count) { + return nil; + } + + return [stringArray componentsJoinedByString:@". "]; +} + +/// Used by VoiceOver to announce changes of the Menu UI. +/// @param parent A new parent of the current item. +/// @param parentChanged If the parent changed. Needed to infer "Top level" when parent is nil. +/// @param before A menu item that now precedes the current item. +/// @param after A menu item that now succeeds the current item. +- (void)announceOrderingChange:(MenuItem *)parent parentChanged:(BOOL)parentChanged before:(MenuItem *)before after:(MenuItem *)after { + if (!UIAccessibilityIsVoiceOverRunning()) { + return; + } + + NSString *announcement = [MenuItemsViewController generateOrderingChangeVOString:parent parentChanged:parentChanged before:before after:after]; + if (announcement) { + UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, announcement); + } +} + @end diff --git a/WordPress/Classes/ViewRelated/Menus/MenusSelectionDetailView.m b/WordPress/Classes/ViewRelated/Menus/MenusSelectionDetailView.m index 6adbddbe0b75..b0cae422ee07 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenusSelectionDetailView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenusSelectionDetailView.m @@ -42,6 +42,7 @@ - (void)awakeFromNib [self setupSubtTitleLabel]; [self setupTitleLabel]; [self setupAccessoryView]; + [self prepareForVoiceOver]; self.backgroundColor = [UIColor clearColor]; @@ -109,7 +110,7 @@ - (void)setupAccessoryView { UIImageView *accessoryView = [[UIImageView alloc] init]; accessoryView.contentMode = UIViewContentModeScaleAspectFit; - accessoryView.image = [Gridicon iconOfType:GridiconTypeChevronDown]; + accessoryView.image = [UIImage gridiconOfType:GridiconTypeChevronDown]; accessoryView.tintColor = [UIColor murielTextTertiary]; [accessoryView.widthAnchor constraintEqualToConstant:24].active = YES; [accessoryView.heightAnchor constraintEqualToConstant:24].active = YES; @@ -141,7 +142,7 @@ - (void)updatewithAvailableItems:(NSUInteger)numItemsAvailable selectedItem:(Men } else { localizedFormat = NSLocalizedString(@"%i menu area in this theme", @"One menu area available in the theme"); } - self.iconView.image = [Gridicon iconOfType:GridiconTypeLayout]; + self.iconView.image = [UIImage gridiconOfType:GridiconTypeLayout]; } else if ([selectedItem isMenu]) { @@ -150,7 +151,7 @@ - (void)updatewithAvailableItems:(NSUInteger)numItemsAvailable selectedItem:(Men } else { localizedFormat = NSLocalizedString(@"%i menu available", @"One menu is available in the site and area"); } - self.iconView.image = [Gridicon iconOfType:GridiconTypeMenus]; + self.iconView.image = [UIImage gridiconOfType:GridiconTypeMenus]; } [self setTitleText:selectedItem.displayName subTitleText:[NSString stringWithFormat:localizedFormat, numItemsAvailable]]; @@ -198,4 +199,12 @@ - (void)tellDelegateTouchesHighlightedStateChanged:(BOOL)highlighted } } +#pragma mark - Accessibility + +- (void)prepareForVoiceOver +{ + self.isAccessibilityElement = YES; + self.accessibilityTraits = UIAccessibilityTraitButton; +} + @end diff --git a/WordPress/Classes/ViewRelated/Menus/MenusSelectionItemView.m b/WordPress/Classes/ViewRelated/Menus/MenusSelectionItemView.m index 142a32043a15..cbbda9685bc4 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenusSelectionItemView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenusSelectionItemView.m @@ -36,6 +36,7 @@ - (id)init UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tellDelegateViewWasSelected)]; [self addGestureRecognizer:tap]; + [self prepareForVoiceOver]; } return self; @@ -85,7 +86,7 @@ - (void)setupIconImageView UIImageView *imageView = [[UIImageView alloc] init]; imageView.tintColor = [UIColor murielNeutral40]; imageView.contentMode = UIViewContentModeScaleAspectFit; - imageView.image = [Gridicon iconOfType:GridiconTypeCheckmark]; + imageView.image = [UIImage gridiconOfType:GridiconTypeCheckmark]; NSLayoutConstraint *width = [imageView.widthAnchor constraintEqualToConstant:20.0]; width.priority = 999; @@ -107,6 +108,7 @@ - (void)setItem:(MenusSelectionItem *)item } self.label.text = item.displayName; self.iconImageView.hidden = !item.selected; + [self prepareForVoiceOver]; } - (void)setDrawsDesignLineSeparator:(BOOL)drawsDesignLineSeparator @@ -194,6 +196,21 @@ - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event - (void)itemSelectionChanged:(NSNotification *)notification { self.iconImageView.hidden = !self.item.selected; + [self prepareForVoiceOver]; +} + +#pragma mark - Accessibility + +- (void)prepareForVoiceOver +{ + self.isAccessibilityElement = YES; + self.accessibilityTraits = UIAccessibilityTraitButton; + self.accessibilityLabel = self.item.displayName; + + NSString *selectedLocalizedString = NSLocalizedString(@"Selected", @"Screen reader text to represent the selected state of a button"); + NSString *localizedHint = NSLocalizedString(@"Selects this item", @"Screen reader hint (non-imperative) about what does the menu item selection button do"); + self.accessibilityValue = self.item.selected ? selectedLocalizedString : nil; + self.accessibilityHint = self.item.selected ? nil : localizedHint; } @end diff --git a/WordPress/Classes/ViewRelated/Menus/MenusSelectionView.m b/WordPress/Classes/ViewRelated/Menus/MenusSelectionView.m index b0043a88a4b9..3d1232b9927c 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenusSelectionView.m +++ b/WordPress/Classes/ViewRelated/Menus/MenusSelectionView.m @@ -42,6 +42,8 @@ - (void)awakeFromNib self.detailView.delegate = self; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(selectionItemObjectWasUpdatedNotification:) name:MenusSelectionViewItemUpdatedItemObjectNotification object:nil]; + + [self prepareForVoiceOver]; } @@ -125,6 +127,8 @@ - (void)setSelectionItemsExpanded:(BOOL)selectionItemsExpanded } self.detailView.showsDesignActive = selectionItemsExpanded; + [self prepareForVoiceOver]; + UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil); } } @@ -141,6 +145,16 @@ - (void)setSelectionItemsExpanded:(BOOL)selectionItemsExpanded animated:(BOOL)an #pragma mark - private +- (void)setAccessibilityLabel:(NSString *)accessibilityLabel +{ + self.detailView.accessibilityLabel = accessibilityLabel; +} + +-(void)setAccessibilityHint:(NSString *)accessibilityHint +{ + self.detailView.accessibilityHint = accessibilityHint; +} + - (MenusSelectionItemView *)insertSelectionItemViewWithItem:(MenusSelectionItem *)item { MenusSelectionItemView *itemView = [[MenusSelectionItemView alloc] init]; @@ -182,6 +196,7 @@ - (void)updateDetailsView { if (self.selectedItem) { [self.detailView updatewithAvailableItems:self.items.count selectedItem:self.selectedItem]; + [self prepareForVoiceOver]; } } @@ -254,6 +269,7 @@ - (void)selectionItemObjectWasUpdatedNotification:(NSNotification *)notification if (updatedItem.selected) { // update the detailView [self.detailView updatewithAvailableItems:self.items.count selectedItem:updatedItem]; + [self prepareForVoiceOver]; } // update any itemViews using this item @@ -266,4 +282,20 @@ - (void)selectionItemObjectWasUpdatedNotification:(NSNotification *)notification } } +#pragma mark - accessibility + +- (void)prepareForVoiceOver +{ + NSString *expandedLocalizedString = NSLocalizedString(@"Expanded", @"Screen reader text to represent the expanded state of a UI control"); + self.detailView.accessibilityValue = self.selectionItemsExpanded ? expandedLocalizedString : nil; + self.addNewItemView.accessibilityLabel = NSLocalizedString(@"Add new menu", @"Screen reader text for Menus button that adds a new menu to a site."); + + [self configureAccessibilityGroups]; +} + +- (void)configureAccessibilityGroups +{ + self.accessibilityElements = self.selectionItemsExpanded ? self.stackView.arrangedSubviews : nil; +} + @end diff --git a/WordPress/Classes/ViewRelated/Menus/MenusViewController+JetpackBannerViewController.swift b/WordPress/Classes/ViewRelated/Menus/MenusViewController+JetpackBannerViewController.swift new file mode 100644 index 000000000000..a4117f951275 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Menus/MenusViewController+JetpackBannerViewController.swift @@ -0,0 +1,19 @@ +import Foundation + +@objc +extension MenusViewController { + static func withJPBannerForBlog(_ blog: Blog) -> UIViewController { + let menusVC = MenusViewController(blog: blog) + menusVC.navigationItem.largeTitleDisplayMode = .never + guard JetpackBrandingCoordinator.shouldShowBannerForJetpackDependentFeatures() else { + return menusVC + } + return JetpackBannerWrapperViewController(childVC: menusVC, screen: .menus) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if let jetpackBannerWrapper = parent as? JetpackBannerWrapperViewController { + jetpackBannerWrapper.processJetpackBannerVisibility(scrollView) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Menus/MenusViewController.m b/WordPress/Classes/ViewRelated/Menus/MenusViewController.m index 48ea9c5fd540..3075301e90c7 100644 --- a/WordPress/Classes/ViewRelated/Menus/MenusViewController.m +++ b/WordPress/Classes/ViewRelated/Menus/MenusViewController.m @@ -9,7 +9,7 @@ #import "MenuItemsViewController.h" #import "MenuItemEditingViewController.h" #import "Menu+ViewDesign.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "WPAppAnalytics.h" #import "WordPress-Swift.h" #import <WordPressShared/WPFontManager.h> @@ -38,6 +38,7 @@ @interface MenusViewController () <UIScrollViewDelegate, MenuHeaderViewControlle @property (nonatomic, strong) MenuLocation *selectedMenuLocation; @property (nonatomic, strong) Menu *updatedMenuForSaving; +@property (nonatomic, strong) Menu *initialMenuSelection; @property (nonatomic, assign) BOOL observesKeyboardChanges; @property (nonatomic, assign) BOOL animatesAppearanceAfterSync; @@ -83,9 +84,12 @@ - (void)viewDidLoad self.navigationItem.title = NSLocalizedString(@"Menus", @"Title for screen that allows configuration of your site's menus"); self.view.backgroundColor = [UIColor murielListBackground]; + self.extendedLayoutIncludesOpaqueBars = YES; + self.scrollView.backgroundColor = self.view.backgroundColor; self.scrollView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive; self.scrollView.alpha = 0.0; + self.scrollView.delegate = self; // add a bit of padding to the scrollable content self.stackView.layoutMargins = UIEdgeInsetsMake(0, 0, 10, 0); @@ -221,7 +225,8 @@ - (void)setSelectedMenu:(Menu *)menu */ [self setNeedsSave:YES forMenu:self.selectedMenuLocation.menu significantChanges:NO]; } else { - [self setNeedsSave:YES forMenu:menu significantChanges:NO]; + BOOL needsSave = (menu != self.initialMenuSelection) || self.hasMadeSignificantMenuChanges; + [self setNeedsSave:needsSave forMenu:menu significantChanges:NO]; } self.selectedMenuLocation.menu = menu; [self.headerViewController setSelectedMenu:menu]; @@ -238,7 +243,7 @@ - (void)syncWithBlogMenus success:^{ [self didSyncBlog]; } - failure:^(NSError *error) { + failure:^(NSError * __unused error) { DDLogDebug(@"MenusViewController could not sync menus for blog"); [self showNoResultsWithTitle:[self noResultsErrorTitle]]; }]; @@ -256,13 +261,14 @@ - (void)didSyncBlog self.headerViewController.blog = self.blog; MenuLocation *selectedLocation = [self.blog.menuLocations firstObject]; self.selectedMenuLocation = selectedLocation; + self.initialMenuSelection = selectedLocation.menu; if (!self.animatesAppearanceAfterSync) { self.scrollView.alpha = 1.0; } else { [UIView animateWithDuration:0.20 animations:^{ self.scrollView.alpha = 1.0; - } completion:^(BOOL finished) { + } completion:^(BOOL __unused finished) { }]; } } @@ -347,7 +353,7 @@ - (void)loadDefaultMenuItemsIfNeeded } } }; - void(^failureBlock)(NSError *) = ^(NSError *error) { + void(^failureBlock)(NSError *) = ^(NSError * __unused error) { weakSelf.itemsLoadingLabel.text = NSLocalizedString(@"An error occurred loading the menu, please check your internet connection.", @"Menus error message seen when an error occurred loading a specific menu."); }; [self.menusService generateDefaultMenuItemsForBlog:self.blog @@ -529,6 +535,7 @@ - (void)showNoResultsWithTitle:(NSString *) title } [self.noResultsViewController configureWithTitle:title + attributedTitle:nil noConnectionTitle:nil buttonTitle:nil subtitle:nil @@ -642,9 +649,11 @@ - (void)headerViewController:(MenuHeaderViewController *)headerViewController se [self promptForDiscardingChangesBeforeSelectingADifferentLocation:^{ [self discardAllChanges]; self.selectedMenuLocation = location; + self.initialMenuSelection = location.menu; } cancellation:nil]; } else { self.selectedMenuLocation = location; + self.initialMenuSelection = location.menu; } } @@ -702,7 +711,7 @@ - (void)detailsViewControllerSelectedToDeleteMenu:(MenuDetailsViewController *)d NSString *cancelTitle = NSLocalizedString(@"Cancel", @"Menus cancel button for deleting a menu."); UIAlertAction *confirmAction = [UIAlertAction actionWithTitle:confirmTitle style:UIAlertActionStyleDestructive - handler:^(UIAlertAction * _Nonnull action) { + handler:^(UIAlertAction * _Nonnull __unused action) { [weakSelf deleteMenu:menuToDelete]; }]; [alertController addAction:confirmAction]; @@ -802,7 +811,7 @@ - (void)promptForDiscardingChangesBeforeSelectingADifferentLocation:(void(^)(voi NSString *confirmationTitle = NSLocalizedString(@"Discard and Select Location", @"Menus alert button title to continue selecting a menu location and discarding current changes."); [alert addDestructiveActionWithTitle:confirmationTitle - handler:^(UIAlertAction * _Nonnull action) { + handler:^(UIAlertAction * _Nonnull __unused action) { if (confirmationBlock) { confirmationBlock(); } @@ -810,7 +819,7 @@ - (void)promptForDiscardingChangesBeforeSelectingADifferentLocation:(void(^)(voi NSString *cancelTitle = NSLocalizedString(@"Cancel and Keep Changes", @"Menus alert button title to cancel discarding changes and not select a new menu location"); [alert addCancelActionWithTitle:cancelTitle - handler:^(UIAlertAction * _Nonnull action) { + handler:^(UIAlertAction * _Nonnull __unused action) { if (cancellationBlock) { cancellationBlock(); } @@ -828,7 +837,7 @@ - (void)promptForDiscardingChangesBeforeSelectingADifferentMenu:(void(^)(void))c NSString *confirmationTitle = NSLocalizedString(@"Discard and Select Menu", @"Menus alert button title to continue selecting a menu and discarding current changes."); [alert addDestructiveActionWithTitle:confirmationTitle - handler:^(UIAlertAction * _Nonnull action) { + handler:^(UIAlertAction * _Nonnull __unused action) { if (confirmationBlock) { confirmationBlock(); } @@ -836,7 +845,7 @@ - (void)promptForDiscardingChangesBeforeSelectingADifferentMenu:(void(^)(void))c NSString *cancelTitle = NSLocalizedString(@"Cancel and Keep Changes", @"Menus alert button title to cancel discarding changes and not select a new menu"); [alert addCancelActionWithTitle:cancelTitle - handler:^(UIAlertAction * _Nonnull action) { + handler:^(UIAlertAction * _Nonnull __unused action) { if (cancellationBlock) { cancellationBlock(); } @@ -854,7 +863,7 @@ - (void)promptForDiscardingChangesBeforeCreatingNewMenu:(void(^)(void))confirmat NSString *confirmationTitle = NSLocalizedString(@"Discard and Create New Menu", @"Menus alert button title to continue creating a menu and discarding current changes."); [alert addDestructiveActionWithTitle:confirmationTitle - handler:^(UIAlertAction * _Nonnull action) { + handler:^(UIAlertAction * _Nonnull __unused action) { if (confirmationBlock) { confirmationBlock(); } @@ -862,7 +871,7 @@ - (void)promptForDiscardingChangesBeforeCreatingNewMenu:(void(^)(void))confirmat NSString *cancelTitle = NSLocalizedString(@"Cancel and Keep Changes", @"Menus alert button title to cancel discarding changes and not createa a new menu."); [alert addCancelActionWithTitle:cancelTitle - handler:^(UIAlertAction * _Nonnull action) { + handler:^(UIAlertAction * _Nonnull __unused action) { if (cancellationBlock) { cancellationBlock(); } @@ -880,7 +889,7 @@ - (void)promptForDiscardingChangesByTheLeftBarButtonItem:(void(^)(void))confirma NSString *confirmationTitle = NSLocalizedString(@"Discard Changes", @"Menus alert button title to discard changes."); [alert addDestructiveActionWithTitle:confirmationTitle - handler:^(UIAlertAction * _Nonnull action) { + handler:^(UIAlertAction * _Nonnull __unused action) { if (confirmationBlock) { confirmationBlock(); } @@ -888,7 +897,7 @@ - (void)promptForDiscardingChangesByTheLeftBarButtonItem:(void(^)(void))confirma NSString *cancelTitle = NSLocalizedString(@"Continue Working", @"Menus alert button title to continue making changes."); [alert addCancelActionWithTitle:cancelTitle - handler:^(UIAlertAction * _Nonnull action) { + handler:^(UIAlertAction * _Nonnull __unused action) { if (cancellationBlock) { cancellationBlock(); } @@ -905,7 +914,7 @@ - (void)updateWithKeyboardNotification:(NSNotification *)notification frame = [self.view.window convertRect:frame toView:self.view]; UIEdgeInsets inset = self.scrollView.contentInset; - UIEdgeInsets scrollInset = self.scrollView.scrollIndicatorInsets; + UIEdgeInsets scrollInset = self.scrollView.verticalScrollIndicatorInsets; if (frame.origin.y > self.view.frame.size.height) { inset.bottom = 0.0; @@ -924,7 +933,7 @@ - (void)keyboardWillHideNotification:(NSNotification *)notification self.observesKeyboardChanges = NO; UIEdgeInsets inset = self.scrollView.contentInset; - UIEdgeInsets scrollInset = self.scrollView.scrollIndicatorInsets; + UIEdgeInsets scrollInset = self.scrollView.verticalScrollIndicatorInsets; inset.bottom = 0; scrollInset.bottom = 0; self.scrollView.contentInset = inset; diff --git a/WordPress/Classes/ViewRelated/NUX/ContentViews/Background/UnifiedPrologueBackgroundView.swift b/WordPress/Classes/ViewRelated/NUX/ContentViews/Background/UnifiedPrologueBackgroundView.swift new file mode 100644 index 000000000000..9167f9566268 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/ContentViews/Background/UnifiedPrologueBackgroundView.swift @@ -0,0 +1,72 @@ +import SwiftUI + +struct UnifiedPrologueBackgroundView: View { + var body: some View { + GeometryReader { content in + let height = content.size.height + let width = content.size.width + let radius = min(height, width) * 0.16 + + let purpleCircleColor = Color(UIColor(light: .muriel(name: .purple, .shade10), dark: .muriel(name: .purple, .shade70))) + let greenCircleColor = Color(UIColor(light: .muriel(name: .celadon, .shade5), dark: .muriel(name: .celadon, .shade70))) + let blueCircleColor = Color(UIColor(light: .muriel(name: .blue, .shade20), dark: .muriel(name: .blue, .shade80))) + let circleOpacity: Double = 0.8 + + VStack { + // This is a bit of a hack, but without this disabled ScrollView, + // the position of circles would change depending on some traits changes. + ScrollView { + CircledPathView(center: CGPoint(x: radius, y: -radius * 0.5), + radius: radius, + startAngle: 335, + endAngle: 180, + clockWise: false, + color: purpleCircleColor, + lineWidth: 3.0) + .opacity(circleOpacity) + + CircledPathView(center: CGPoint(x: width + radius / 4, y: height - radius * 1.5), + radius: radius, + startAngle: 90, + endAngle: 270, + clockWise: false, + color: greenCircleColor, + lineWidth: 3.0) + .opacity(circleOpacity) + + CircledPathView(center: CGPoint(x: 0, y: height - radius * 2), + radius: radius, + startAngle: 270, + endAngle: 90, + clockWise: false, + color: blueCircleColor, + lineWidth: 3.0) + .opacity(circleOpacity) + } + .disabled(true) + } + } + } +} + +struct CircledPathView: View { + let center: CGPoint + let radius: CGFloat + let startAngle: Double + let endAngle: Double + let clockWise: Bool + let color: Color + let lineWidth: CGFloat + + var body: some View { + Path { path in + path.addArc(center: center, + radius: radius, + startAngle: .degrees(startAngle), + endAngle: .degrees(endAngle), + clockwise: clockWise) + } + .stroke(color, lineWidth: lineWidth) + + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/ContentViews/Components/CircledIcon.swift b/WordPress/Classes/ViewRelated/NUX/ContentViews/Components/CircledIcon.swift new file mode 100644 index 000000000000..3f36325a5aa7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/ContentViews/Components/CircledIcon.swift @@ -0,0 +1,48 @@ +import Gridicons +import SwiftUI + +/// view that renders a gridIcon in a circle of the given color +struct CircledIcon: View { + + private let size: CGFloat + private let xOffset: CGFloat + private let yOffset: CGFloat + + private let iconType: GridiconType + + private let backgroundColor: Color + private let iconColor: Color + + private let shadowRadius: CGFloat = 4 + private let shadowColor = Color.gray.opacity(0.4) + + init(size: CGFloat, + xOffset: CGFloat, + yOffset: CGFloat, + iconType: GridiconType, + backgroundColor: Color, + iconColor: Color = .white) { + + self.size = size + self.xOffset = xOffset + self.yOffset = yOffset + self.iconType = iconType + self.backgroundColor = backgroundColor + self.iconColor = iconColor + } + + + var body: some View { + ZStack { + Circle() + .foregroundColor(backgroundColor) + .shadow(color: shadowColor, radius: shadowRadius) + .frame(width: size, height: size) + + Image(uiImage: UIImage.gridicon(iconType, size: CGSize(width: size / 2, height: size / 2))) + .foregroundColor(iconColor) + } + .fixedSize() + .offset(x: xOffset, y: yOffset) + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/ContentViews/Components/RoundRectangleView.swift b/WordPress/Classes/ViewRelated/NUX/ContentViews/Components/RoundRectangleView.swift new file mode 100644 index 000000000000..775afb29dbbc --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/ContentViews/Components/RoundRectangleView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +/// General purpose container view with a rounded rectangle background +struct RoundRectangleView<Content: View>: View { + private let content: Content + + private let rectangleFillColor = Color(UIColor(light: .white, dark: .gray(.shade90))) + private let cornerRadius: CGFloat = 4 + private let shadowRadius: CGFloat = 4 + private let shadowColor = Color.gray.opacity(0.4) + + private var alignment: Alignment + + init(alignment: Alignment = .center, @ViewBuilder content: @escaping () -> Content) { + self.alignment = alignment + self.content = content() + } + + var body: some View { + ZStack(alignment: alignment) { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .foregroundColor(rectangleFillColor) + .shadow(color: shadowColor, radius: shadowRadius) + content + } + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/ContentViews/Components/Text+BoldSubString.swift b/WordPress/Classes/ViewRelated/NUX/ContentViews/Components/Text+BoldSubString.swift new file mode 100644 index 000000000000..7e775fdde3b4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/ContentViews/Components/Text+BoldSubString.swift @@ -0,0 +1,33 @@ +import SwiftUI + +extension Text { + + /// This initializer constructs a Text from a String that contains simple markers to delineate + /// a section to highlight in bold. It can only handle a single bold section. For example: + /// "This is my *example string* with one bold section." + /// + init(string: String, boldMarker: String = "*") { + self.init("") + + let parts = string.components(separatedBy: boldMarker) + guard parts.count == 3 else { + // This only works with exactly one bold substring, enclosed by * characters + self = Text(string) + return + } + + var text = Text("") + + parts.enumerated().forEach { (index, part) in + let partText = Text(part) + + if index == parts.count-2 { // last-but-one part + text = text + partText.bold() + } else { + text = text + partText + } + } + + self = text + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/ContentViews/Editor/UnifiedPrologueEditorContentView.swift b/WordPress/Classes/ViewRelated/NUX/ContentViews/Editor/UnifiedPrologueEditorContentView.swift new file mode 100644 index 000000000000..7a983fa10e8c --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/ContentViews/Editor/UnifiedPrologueEditorContentView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +/// Prologue editor page contents +struct UnifiedPrologueEditorContentView: View { + + var body: some View { + GeometryReader { content in + + VStack { + + RoundRectangleView { + HStack { + Text(Appearance.topElementTitle) + .font(Font.system(size: content.size.height * 0.08, + weight: .semibold, + design: .serif)) + Spacer() + } + .padding(.all, content.size.height * 0.06) + } + .frame(idealHeight: content.size.height * 0.2) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: content.size.height * 0.03) + .fixedSize(horizontal: false, vertical: true) + + RoundRectangleView(alignment: .top) { + (Text(Appearance.middleElementTitle) + + Text(Appearance.middleElementTerminator) + .foregroundColor(.blue)) + .font(Font.system(size: content.size.height * 0.06, + weight: .regular, + design: .default)) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(.none) + .padding(.all, content.size.height * 0.06) + + HStack { + let alignImageLeftIconSize = content.size.height * 0.15 + CircledIcon(size: alignImageLeftIconSize, + xOffset: -alignImageLeftIconSize * 0.75, + yOffset: alignImageLeftIconSize * 0.75, + iconType: .alignImageLeft, + backgroundColor: Color(UIColor.muriel(name: .purple, .shade50))) + + Spacer() + + let plusIconSize = content.size.height * 0.2 + CircledIcon(size: plusIconSize, + xOffset: plusIconSize * 0.66, + yOffset: -plusIconSize * 0.66, + iconType: .plus, + backgroundColor: Color(UIColor.muriel(name: .blue, .shade50))) + } + } + .frame(idealHeight: content.size.height * 0.41) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: content.size.height * 0.03) + .fixedSize(horizontal: false, vertical: true) + + RoundRectangleView { + HStack(spacing: content.size.height * 0.03) { + Image("page2Img1Sea") + .resizable() + .aspectRatio(contentMode: .fit) + + ZStack(alignment: .bottomLeading) { + Image("page2Img2Trees") + .resizable() + .aspectRatio(contentMode: .fit) + + let imageMultipleIconSize = content.size.height * 0.18 + CircledIcon(size: imageMultipleIconSize, + xOffset: -imageMultipleIconSize / 2, + yOffset: imageMultipleIconSize / 2, + iconType: .imageMultiple, + backgroundColor: Color(UIColor.muriel(name: .pink, .shade40))) + } + + Image("page2Img3Food") + .resizable() + .aspectRatio(contentMode: .fit) + } + .padding(.all, content.size.height * 0.03) + } + .frame(idealHeight: content.size.height * 0.33) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + +private extension UnifiedPrologueEditorContentView { + + enum Appearance { + static let topElementTitle = NSLocalizedString("Getting Inspired", comment: "Example post title used in the login prologue screens.") + static let middleElementTitle = NSLocalizedString("I am so inspired by photographer Cameron Karsten's work. I will be trying these techniques on my next", comment: "Example post content used in the login prologue screens.") + static let middleElementTerminator = "|" + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/ContentViews/Editor/UnifiedPrologueNotificationsContentView.swift b/WordPress/Classes/ViewRelated/NUX/ContentViews/Editor/UnifiedPrologueNotificationsContentView.swift new file mode 100644 index 000000000000..831d0b71f6e7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/ContentViews/Editor/UnifiedPrologueNotificationsContentView.swift @@ -0,0 +1,138 @@ +import SwiftUI + +struct UnifiedPrologueNotificationsContent { + let topElementTitle: String + let middleElementTitle: String + let bottomElementTitle: String + + var topImage: String = UnifiedPrologueNotificationsContentView.Appearance.topImage + var middleImage: String = UnifiedPrologueNotificationsContentView.Appearance.middleImage + var bottomImage: String = UnifiedPrologueNotificationsContentView.Appearance.bottomImage +} + +/// Prologue notifications page contents +struct UnifiedPrologueNotificationsContentView: View { + private let textContent: UnifiedPrologueNotificationsContent + + init(_ textContent: UnifiedPrologueNotificationsContent? = nil) { + self.textContent = textContent ?? Appearance.textContent + } + + var body: some View { + GeometryReader { content in + let spacingUnit = content.size.height * 0.06 + let notificationIconSize = content.size.height * 0.2 + let smallIconSize = content.size.height * 0.175 + let largerIconSize = content.size.height * 0.2 + let fontSize = content.size.height * 0.055 + let notificationFont = Font.system(size: fontSize, + weight: .regular, + design: .default) + + + VStack { + Spacer() + RoundRectangleView { + HStack { + NotificationIcon(image: textContent.topImage, size: notificationIconSize) + Text(string: textContent.topElementTitle) + .font(notificationFont) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(.none) + Spacer() + } + .padding(spacingUnit / 2) + + HStack { + CircledIcon(size: smallIconSize, + xOffset: -smallIconSize * 0.7, + yOffset: smallIconSize * 0.7, + iconType: .reply, + backgroundColor: Color(UIColor.muriel(name: .celadon, .shade30))) + + Spacer() + + CircledIcon(size: smallIconSize, + xOffset: smallIconSize * 0.25, + yOffset: -smallIconSize * 0.7, + iconType: .star, + backgroundColor: Color(UIColor.muriel(name: .yellow, .shade20))) + } + } + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: spacingUnit / 2) + .fixedSize(horizontal: false, vertical: true) + + RoundRectangleView { + HStack { + NotificationIcon(image: textContent.middleImage, size: notificationIconSize) + Text(string: textContent.middleElementTitle) + .font(notificationFont) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(.none) + Spacer() + } + .padding(spacingUnit / 2) + } + .fixedSize(horizontal: false, vertical: true) + .offset(x: spacingUnit) + + Spacer(minLength: spacingUnit / 2) + .fixedSize(horizontal: false, vertical: true) + + RoundRectangleView { + HStack { + NotificationIcon(image: textContent.bottomImage, size: notificationIconSize) + Text(string: textContent.bottomElementTitle) + .font(notificationFont) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(.none) + Spacer() + } + .padding(spacingUnit / 2) + + HStack { + Spacer() + + CircledIcon(size: largerIconSize, + xOffset: largerIconSize * 0.6, + yOffset: largerIconSize * 0.3, + iconType: .comment, + backgroundColor: Color(UIColor.muriel(name: .blue, .shade50))) + } + } + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + } + } +} + +private struct NotificationIcon: View { + let image: String + let size: CGFloat + + var body: some View { + Image(image) + .resizable() + .frame(width: size, height: size) + .clipShape(Circle()) + } +} + +private extension UnifiedPrologueNotificationsContentView { + enum Appearance { + static let topImage = "page3Avatar1" + static let middleImage = "page3Avatar2" + static let bottomImage = "page3Avatar3" + + static let topElementTitle: String = NSLocalizedString("*Madison Ruiz* liked your post", comment: "Example Like notification displayed in the prologue carousel of the app. Username should be marked with * characters and will be displayed as bold text.") + static let middleElementTitle: String = NSLocalizedString("You received *50 likes* on your site today", comment: "Example Likes notification displayed in the prologue carousel of the app. Number of likes should marked with * characters and will be displayed as bold text.") + static let bottomElementTitle: String = NSLocalizedString("*Johann Brandt* responded to your post", comment: "Example Comment notification displayed in the prologue carousel of the app. Username should be marked with * characters and will be displayed as bold text.") + + static let textContent = UnifiedPrologueNotificationsContent(topElementTitle: Self.topElementTitle, + middleElementTitle: Self.middleElementTitle, + bottomElementTitle: Self.bottomElementTitle) + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/ContentViews/Editor/UnifiedPrologueReaderContentView.swift b/WordPress/Classes/ViewRelated/NUX/ContentViews/Editor/UnifiedPrologueReaderContentView.swift new file mode 100644 index 000000000000..9e7acd7978ec --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/ContentViews/Editor/UnifiedPrologueReaderContentView.swift @@ -0,0 +1,222 @@ +import SwiftUI + +/// Prologue reader page contents +struct UnifiedPrologueReaderContentView: View { + var body: some View { + GeometryReader { content in + let spacingUnit = content.size.height * 0.06 + let feedSize = content.size.height * 0.1 + + let smallIconSize = content.size.height * 0.175 + let largerIconSize = content.size.height * 0.25 + + let fontSize = content.size.height * 0.05 + let smallFontSize = content.size.height * 0.045 + let smallFont = Font.system(size: smallFontSize, weight: .regular, design: .default) + let feedFont = Font.system(size: fontSize, + weight: .regular, + design: .default) + + VStack { + RoundRectangleView { + HStack { + VStack { + FeedRow(image: Appearance.feedTopImage, + imageSize: feedSize, + title: Strings.feedTopTitle, + font: feedFont) + FeedRow(image: Appearance.feedMiddleImage, + imageSize: feedSize, + title: Strings.feedMiddleTitle, + font: feedFont) + FeedRow(image: Appearance.feedBottomImage, + imageSize: feedSize, + title: Strings.feedBottomTitle, + font: feedFont) + } + } + .padding(spacingUnit / 2) + + HStack { + Spacer() + + CircledIcon(size: largerIconSize, + xOffset: largerIconSize * 0.7, + yOffset: -largerIconSize * 0.05, + iconType: .readerFollow, + backgroundColor: Color(UIColor.muriel(name: .red, .shade40))) + } + } + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, spacingUnit) + + Spacer(minLength: spacingUnit / 2) + .fixedSize(horizontal: false, vertical: true) + + RoundRectangleView { + ScrollView(.horizontal) { + VStack(alignment: .leading) { + HStack { + ForEach([Strings.tagArt, Strings.tagCooking, Strings.tagFootball], id: \.self) { item in + Text(item) + .tagItemStyle(with: smallFont, horizontalPadding: spacingUnit, verticalPadding: spacingUnit * 0.25) + } + } + HStack { + ForEach([Strings.tagGardening, Strings.tagMusic, Strings.tagPolitics], id: \.self) { item in + Text(item) + .tagItemStyle(with: smallFont, horizontalPadding: spacingUnit, verticalPadding: spacingUnit * 0.25) + } + } + } + .padding(spacingUnit / 2) + } + .disabled(true) + + HStack { + CircledIcon(size: smallIconSize, + xOffset: -smallIconSize * 0.5, + yOffset: -smallIconSize * 0.7, + iconType: .star, + backgroundColor: Color(UIColor.muriel(name: .yellow, .shade20))) + + Spacer() + } + } + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: spacingUnit / 2) + .fixedSize(horizontal: false, vertical: true) + + let postWidth = content.size.width * 0.3 + + RoundRectangleView { + ScrollView(.horizontal) { + HStack { + PostView(image: Appearance.firstPostImage, title: Strings.firstPostTitle, size: postWidth, font: smallFont) + PostView(image: Appearance.secondPostImage, title: Strings.secondPostTitle, size: postWidth, font: smallFont) + PostView(image: Appearance.thirdPostImage, title: Strings.thirdPostTitle, size: postWidth, font: smallFont) + } + .padding(spacingUnit / 2) + + } + .disabled(true) + + HStack { + Spacer() + + CircledIcon(size: smallIconSize, + xOffset: smallIconSize * 0.75, + yOffset: -smallIconSize * 0.85, + iconType: .bookmarkOutline, + backgroundColor: Color(UIColor.muriel(name: .purple, .shade50))) + } + } + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, spacingUnit) + } + } + } +} + +private extension UnifiedPrologueReaderContentView { + enum Appearance { + static let feedTopImage = "page5Avatar1" + static let feedMiddleImage = "page5Avatar2" + static let feedBottomImage = "page5Avatar3" + + static let firstPostImage = "page5Img1Coffee" + static let secondPostImage = "page5Img2Stadium" + static let thirdPostImage = "page5Img3Museum" + } + + enum Strings { + static let feedTopTitle: String = "Pamela Nguyen" + static let feedMiddleTitle: String = NSLocalizedString("Web News", comment: "Example Reader feed title") + static let feedBottomTitle: String = NSLocalizedString("Rock 'n Roll Weekly", comment: "Example Reader feed title") + + static let tagArt: String = NSLocalizedString("Art", comment: "An example tag used in the login prologue screens.") + static let tagCooking: String = NSLocalizedString("Cooking", comment: "An example tag used in the login prologue screens.") + static let tagFootball: String = NSLocalizedString("Football", comment: "An example tag used in the login prologue screens.") + static let tagGardening: String = NSLocalizedString("Gardening", comment: "An example tag used in the login prologue screens.") + static let tagMusic: String = NSLocalizedString("Music", comment: "An example tag used in the login prologue screens.") + static let tagPolitics: String = NSLocalizedString("Politics", comment: "An example tag used in the login prologue screens.") + + static let firstPostTitle: String = NSLocalizedString("My Top Ten Cafes", comment: "Example post title used in the login prologue screens.") + static let secondPostTitle: String = NSLocalizedString("The World's Best Fans", comment: "Example post title used in the login prologue screens. This is a post about football fans.") + static let thirdPostTitle: String = NSLocalizedString("Museums to See In London", comment: "Example post title used in the login prologue screens.") + } +} + +// MARK: - Views + +/// A view showing an icon followed by a title for an example feed. +/// +private struct FeedRow: View { + let image: String + let imageSize: CGFloat + let title: String + let font: Font + + private let cornerRadius: CGFloat = 4.0 + + var body: some View { + HStack { + Image(image) + .resizable() + .frame(width: imageSize, height: imageSize) + .cornerRadius(cornerRadius) + Text(title) + .font(font) + .bold() + Spacer() + } + } +} + +/// A view modifier that applies style to a text to make it look like a tag token. +/// +private struct TagItem: ViewModifier { + let font: Font + let horizontalPadding: CGFloat + let verticalPadding: CGFloat + + func body(content: Content) -> some View { + content + .font(font) + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .background(Color(UIColor(light: UIColor.muriel(color: MurielColor(name: .gray, shade: .shade0)), + dark: UIColor.muriel(color: MurielColor(name: .gray, shade: .shade70))))) + .clipShape(Capsule()) + } +} + +extension View { + func tagItemStyle(with font: Font, horizontalPadding: CGFloat, verticalPadding: CGFloat) -> some View { + self.modifier(TagItem(font: font, horizontalPadding: horizontalPadding, verticalPadding: verticalPadding)) + } +} + +/// A view showing an image with title below for an example post. +/// +private struct PostView: View { + let image: String + let title: String + let size: CGFloat + let font: Font + + var body: some View { + VStack(alignment: .leading) { + Image(image) + .resizable() + .aspectRatio(contentMode: .fit) + Text(title) + .lineLimit(2) + .font(font) + } + .frame(width: size) + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/ContentViews/Stats/UnifiedPrologueStatsContentView.swift b/WordPress/Classes/ViewRelated/NUX/ContentViews/Stats/UnifiedPrologueStatsContentView.swift new file mode 100644 index 000000000000..538f6156d304 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/ContentViews/Stats/UnifiedPrologueStatsContentView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +/// Prologue stats page contents +struct UnifiedPrologueStatsContentView: View { + + var body: some View { + + GeometryReader { content in + let globeIconSize = content.size.height * 0.22 + let statusIconSize = content.size.height * 0.165 + let usersIconSize = content.size.height * 0.185 + + VStack { + + RoundRectangleView { + Image("page4Map") + .resizable() + .aspectRatio(contentMode: .fit) + .padding(EdgeInsets(top: content.size.height * 0.01, + leading: content.size.height * 0.03, + bottom: content.size.height * 0.01, + trailing: content.size.height * 0.03)) + + HStack { + Spacer() + CircledIcon(size: globeIconSize, + xOffset: globeIconSize * 0.62, + yOffset: -globeIconSize * 0.125, + iconType: .globe, + backgroundColor: Color(UIColor.muriel(name: .celadon, .shade30))) + + } + + } + .frame(idealHeight: content.size.height * 0.52) + .fixedSize(horizontal: false, vertical: true) + .offset(x: -content.size.height * 0.03, y: 0) + + Spacer(minLength: content.size.height * 0.03) + .fixedSize(horizontal: false, vertical: true) + + RoundRectangleView { + Image("barGraph") + .resizable() + .aspectRatio(contentMode: .fit) + .padding(.all, content.size.height * 0.05) + HStack { + + CircledIcon(size: statusIconSize, + xOffset: -statusIconSize * 0.75, + yOffset: -statusIconSize * 1.25, + iconType: .status, + backgroundColor: Color(UIColor.muriel(name: .purple, .shade50))) + + Spacer() + + CircledIcon(size: usersIconSize, + xOffset: usersIconSize * 0.25, + yOffset: usersIconSize * 0.875, + iconType: .multipleUsers, + backgroundColor: Color(UIColor.muriel(name: .blue, .shade50))) + } + } + .frame(idealHeight: content.size.height * 0.45) + .fixedSize(horizontal: false, vertical: true) + .offset(x: content.size.height * 0.03, y: 0) + + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/ContentViews/UnifiedPrologueIntroContentView.swift b/WordPress/Classes/ViewRelated/NUX/ContentViews/UnifiedPrologueIntroContentView.swift new file mode 100644 index 000000000000..9f7b3e522805 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/ContentViews/UnifiedPrologueIntroContentView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +/// Prologue intro view +struct UnifiedPrologueIntroContentView: View { + + var body: some View { + GeometryReader { content in + HStack(spacing: content.size.width * 0.067) { + + VStack(alignment: .trailing, spacing: content.size.height * 0.03) { + PrologueIntroImage(imageName: "introWebsite1", idealHeight: content.size.height * 0.6) + + ZStack(alignment: .bottomLeading) { + PrologueIntroImage(imageName: "introWebsite4", idealHeight: content.size.height * 0.38) + CircledIcon(size: content.size.width * 0.15, + xOffset: -content.size.width * 0.125, + yOffset: content.size.height * 0.04, + iconType: .pages, + backgroundColor: Color(UIColor.muriel(name: .celadon, .shade30))) + } + } + + VStack { + ZStack(alignment: .bottomLeading) { + PrologueIntroImage(imageName: "introWebsite2", idealHeight: content.size.height * 0.5) + CircledIcon(size: content.size.width * 0.18, + xOffset: -content.size.width * 0.115, + yOffset: -content.size.height * 0.07, + iconType: .customize, + backgroundColor: Color(UIColor.muriel(name: .orange, .shade30))) + } + .offset(x: 0, y: -content.size.height * 0.04) + + HStack(alignment: .top, spacing: content.size.width * 0.067) { + PrologueIntroImage(imageName: "introWebsite5", idealHeight: content.size.height * 0.28) + + PrologueIntroImage(imageName: "introWebsite6", idealHeight: content.size.height * 0.28) + .offset(x: 0, y: content.size.height * 0.067) + } + } + + VStack(alignment: .trailing) { + PrologueIntroImage(imageName: "introWebsite3", idealHeight: content.size.height * 0.6) + + ZStack(alignment: .topTrailing) { + PrologueIntroImage(imageName: "introWebsite7", idealHeight: content.size.height * 0.43) + CircledIcon(size: content.size.width * 0.22, + xOffset: content.size.width * 0.06, + yOffset: -content.size.height * 0.19, + iconType: .create, + backgroundColor: Color(UIColor.muriel(name: .pink, .shade40))) + } + .offset(x: 0, y: content.size.height * 0.03) + } + } + } + } +} + + +struct PrologueIntroImage: View { + + let imageName: String + let idealHeight: CGFloat + private let shadowRadius: CGFloat = 4 + private let shadowColor = Color.gray.opacity(0.4) + + var body: some View { + Image(imageName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(idealHeight: idealHeight) + .fixedSize(horizontal: false, vertical: true) + .shadow(color: shadowColor, radius: shadowRadius) + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/DomainSuggestionsButtonViewPresenter.swift b/WordPress/Classes/ViewRelated/NUX/DomainSuggestionsButtonViewPresenter.swift deleted file mode 100644 index a1d9b7681302..000000000000 --- a/WordPress/Classes/ViewRelated/NUX/DomainSuggestionsButtonViewPresenter.swift +++ /dev/null @@ -1,40 +0,0 @@ -import UIKit - -/// UIViewController subclasses who has a bottom view that should have a hiding -/// ability can conform to this protocol so showing/hiding is already handled. -protocol DomainSuggestionsButtonViewPresenter { - - /// Distance constraint between the hidable view's bottom and the conforming view's bottom - var buttonContainerViewBottomConstraint: NSLayoutConstraint! { get } - - /// Height constraint of the hidable view - var buttonContainerViewHeightConstraint: NSLayoutConstraint! { get } -} - -extension DomainSuggestionsButtonViewPresenter where Self: UIViewController { - - /// Shows/hides bottom view - /// - /// - Parameters: - /// - show: true shows, false hides - /// - animation: showing/hiding is done animatedly if true - func showButtonView(show: Bool, withAnimation animation: Bool) { - - let duration = animation ? WPAnimationDurationDefault : 0 - - UIView.animate(withDuration: duration, animations: { - if show { - self.buttonContainerViewBottomConstraint.constant = 0 - } - else { - // Move the view down double the height to ensure it's off the screen. - // i.e. to defy iPhone X bottom gap. - self.buttonContainerViewBottomConstraint.constant += - self.buttonContainerViewHeightConstraint.constant * 2 - } - - // Since the Button View uses auto layout, need to call this so the animation works properly. - self.view.layoutIfNeeded() - }, completion: nil) - } -} diff --git a/WordPress/Classes/ViewRelated/NUX/DomainSuggestionsTableViewController.swift b/WordPress/Classes/ViewRelated/NUX/DomainSuggestionsTableViewController.swift deleted file mode 100644 index 9456492f3a60..000000000000 --- a/WordPress/Classes/ViewRelated/NUX/DomainSuggestionsTableViewController.swift +++ /dev/null @@ -1,397 +0,0 @@ -import UIKit -import SVProgressHUD -import WordPressAuthenticator - - -protocol DomainSuggestionsTableViewControllerDelegate { - func domainSelected(_ domain: DomainSuggestion) - func newSearchStarted() -} - -/// This is intended to be an abstract base class that provides domain -/// suggestions for the keyword that user searches. -/// Subclasses should override the open variables to make customizations. -class DomainSuggestionsTableViewController: NUXTableViewController { - - // MARK: - Properties - - open var siteName: String? - open var delegate: DomainSuggestionsTableViewControllerDelegate? - open var domainSuggestionType: DomainsServiceRemote.DomainSuggestionType = .onlyWordPressDotCom - - open var useFadedColorForParentDomains: Bool { - return true - } - open var sectionTitle: String { - return "" - } - open var sectionDescription: String { - return "" - } - open var searchFieldPlaceholder: String { - return "" - } - - private var noResultsViewController: NoResultsViewController? - private var service: DomainsService? - private var siteTitleSuggestions: [DomainSuggestion] = [] - private var searchSuggestions: [DomainSuggestion] = [] - private var isSearching: Bool = false - private var selectedCell: UITableViewCell? - - // API returned no domain suggestions. - private var noSuggestions: Bool = false - - fileprivate enum ViewPadding: CGFloat { - case noResultsView = 60 - } - - private var parentDomainColor: UIColor { - return useFadedColorForParentDomains ? .neutral(.shade30) : .neutral(.shade70) - } - - // MARK: - Init - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - override func awakeFromNib() { - super.awakeFromNib() - - let bundle = WordPressAuthenticator.bundle - tableView.register(UINib(nibName: "SearchTableViewCell", bundle: bundle), forCellReuseIdentifier: SearchTableViewCell.reuseIdentifier) - setupBackgroundTapGestureRecognizer() - } - - // MARK: - View - - override func viewDidLoad() { - super.viewDidLoad() - - WPStyleGuide.configureColors(view: view, tableView: tableView) - tableView.layoutMargins = WPStyleGuide.edgeInsetForLoginTextFields() - - navigationItem.title = NSLocalizedString("Create New Site", comment: "Title for the site creation flow.") - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - // only procede with initial search if we don't have site title suggestions yet - // (hopefully only the first time) - guard siteTitleSuggestions.count < 1, - let nameToSearch = siteName else { - return - } - - suggestDomains(for: nameToSearch) { [weak self] (suggestions) in - self?.siteTitleSuggestions = suggestions - self?.tableView.reloadSections(IndexSet(integer: Sections.suggestions.rawValue), with: .automatic) - } - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - SVProgressHUD.dismiss() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if #available(iOS 13, *) { - if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle { - tableView.reloadData() - } - } - } - - /// Fetches new domain suggestions based on the provided string - /// - /// - Parameters: - /// - searchTerm: string to base suggestions on - /// - addSuggestions: function to call when results arrive - private func suggestDomains(for searchTerm: String, addSuggestions: @escaping (_: [DomainSuggestion]) ->()) { - guard !isSearching else { - return - } - - isSearching = true - - let accountService = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext) - let api = accountService.defaultWordPressComAccount()?.wordPressComRestApi ?? WordPressComRestApi.defaultApi(oAuthToken: "") - - let service = DomainsService(managedObjectContext: ContextManager.sharedInstance().mainContext, remote: DomainsServiceRemote(wordPressComRestApi: api)) - SVProgressHUD.show(withStatus: NSLocalizedString("Loading domains", comment: "Shown while the app waits for the domain suggestions web service to return during the site creation process.")) - - service.getDomainSuggestions(base: searchTerm, - domainSuggestionType: domainSuggestionType, - success: { [weak self] (suggestions) in - self?.isSearching = false - self?.noSuggestions = false - SVProgressHUD.dismiss() - self?.tableView.separatorStyle = .singleLine - // Dismiss the keyboard so the full results list can be seen. - self?.view.endEditing(true) - addSuggestions(suggestions) - }) { [weak self] (error) in - DDLogError("Error getting Domain Suggestions: \(error.localizedDescription)") - self?.isSearching = false - self?.noSuggestions = true - SVProgressHUD.dismiss() - self?.tableView.separatorStyle = .none - // Dismiss the keyboard so the full no results view can be seen. - self?.view.endEditing(true) - // Add no suggestions to display the no results view. - addSuggestions([]) - } - } - - // MARK: background gesture recognizer - - /// Sets up a gesture recognizer to detect taps on the view, but not its content. - /// - func setupBackgroundTapGestureRecognizer() { - let gestureRecognizer = UITapGestureRecognizer() - gestureRecognizer.on { [weak self](gesture) in - self?.view.endEditing(true) - } - gestureRecognizer.cancelsTouchesInView = false - view.addGestureRecognizer(gestureRecognizer) - } -} - -// MARK: - UITableViewDataSource - -extension DomainSuggestionsTableViewController { - fileprivate enum Sections: Int { - case titleAndDescription = 0 - case searchField = 1 - case suggestions = 2 - - static var count: Int { - return suggestions.rawValue + 1 - } - } - - override func numberOfSections(in tableView: UITableView) -> Int { - return Sections.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch section { - case Sections.titleAndDescription.rawValue, - Sections.searchField.rawValue: - return 1 - case Sections.suggestions.rawValue: - if noSuggestions == true { - return 1 - } - return searchSuggestions.count > 0 ? searchSuggestions.count : siteTitleSuggestions.count - default: - return 0 - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: UITableViewCell - switch indexPath.section { - case Sections.titleAndDescription.rawValue: - cell = titleAndDescriptionCell() - case Sections.searchField.rawValue: - cell = searchFieldCell() - case Sections.suggestions.rawValue: - fallthrough - default: - if noSuggestions == true { - cell = noResultsCell() - } else { - let suggestion: String - if searchSuggestions.count > 0 { - suggestion = searchSuggestions[indexPath.row].domainName - } else { - suggestion = siteTitleSuggestions[indexPath.row].domainName - } - cell = suggestionCell(domain: suggestion) - } - } - return cell - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - - if indexPath.section == Sections.suggestions.rawValue && noSuggestions == true { - // Calculate the height of the no results cell from the bottom of - // the search field to the screen bottom, minus some padding. - let searchFieldRect = tableView.rect(forSection: Sections.searchField.rawValue) - let searchFieldBottom = searchFieldRect.origin.y + searchFieldRect.height - let screenBottom = UIScreen.main.bounds.height - return screenBottom - searchFieldBottom - ViewPadding.noResultsView.rawValue - } - - return super.tableView(tableView, heightForRowAt: indexPath) - } - - override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - if section == Sections.suggestions.rawValue { - let footer = UIView() - footer.backgroundColor = .neutral(.shade10) - return footer - } - return nil - } - - override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - if section == Sections.suggestions.rawValue { - return 0.5 - } - return 0 - } - - // MARK: table view cells - - @objc func titleAndDescriptionCell() -> UITableViewCell { - let cell = LoginSocialErrorCell(title: sectionTitle, - description: sectionDescription) - cell.selectionStyle = .none - return cell - } - - private func searchFieldCell() -> SearchTableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: SearchTableViewCell.reuseIdentifier) as? SearchTableViewCell else { - fatalError() - } - - cell.placeholder = searchFieldPlaceholder - cell.reloadTextfieldStyle() - cell.delegate = self - cell.selectionStyle = .none - cell.backgroundColor = .clear - return cell - } - - private func noResultsCell() -> UITableViewCell { - let cell = UITableViewCell() - addNoResultsTo(cell: cell) - cell.isUserInteractionEnabled = false - return cell - } - - private func suggestionCell(domain: String) -> UITableViewCell { - let cell = UITableViewCell() - - cell.textLabel?.attributedText = styleDomain(domain) - cell.textLabel?.textColor = parentDomainColor - cell.indentationWidth = 20.0 - cell.indentationLevel = 1 - return cell - } - - private func styleDomain(_ domain: String) -> NSAttributedString { - let styledDomain: NSMutableAttributedString = NSMutableAttributedString(string: domain) - guard let dotPosition = domain.firstIndex(of: ".") else { - return styledDomain - } - styledDomain.addAttribute(.foregroundColor, - value: UIColor.neutral(.shade70), - range: NSMakeRange(0, dotPosition.utf16Offset(in: domain))) - return styledDomain - } -} - -// MARK: - NoResultsViewController Extension - -private extension DomainSuggestionsTableViewController { - - func addNoResultsTo(cell: UITableViewCell) { - if noResultsViewController == nil { - instantiateNoResultsViewController() - } - - guard let noResultsViewController = noResultsViewController else { - return - } - - noResultsViewController.view.frame = cell.frame - cell.contentView.addSubview(noResultsViewController.view) - - addChild(noResultsViewController) - noResultsViewController.didMove(toParent: self) - } - - func removeNoResultsFromView() { - noSuggestions = false - tableView.reloadSections(IndexSet(integer: Sections.suggestions.rawValue), with: .automatic) - noResultsViewController?.removeFromView() - } - - func instantiateNoResultsViewController() { - let title = NSLocalizedString("We couldn't find any available address with the words you entered - let's try again.", comment: "Primary message shown when there are no domains that match the user entered text.") - let subtitle = NSLocalizedString("Enter different words above and we'll look for an address that matches it.", comment: "Secondary message shown when there are no domains that match the user entered text.") - - noResultsViewController = NoResultsViewController.controllerWith(title: title, buttonTitle: nil, subtitle: subtitle) - } - -} - -// MARK: - UITableViewDelegate - -extension DomainSuggestionsTableViewController { - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let selectedDomain: DomainSuggestion - - switch indexPath.section { - case Sections.suggestions.rawValue: - if searchSuggestions.count > 0 { - selectedDomain = searchSuggestions[indexPath.row] - } else { - selectedDomain = siteTitleSuggestions[indexPath.row] - } - default: - return - } - - delegate?.domainSelected(selectedDomain) - - tableView.deselectSelectedRowWithAnimation(true) - - // Uncheck the previously selected cell. - if let selectedCell = selectedCell { - selectedCell.accessoryType = .none - } - - // Check the currently selected cell. - if let cell = self.tableView.cellForRow(at: indexPath) { - cell.accessoryType = .checkmark - selectedCell = cell - } - } -} - -// MARK: - SearchTableViewCellDelegate - -extension DomainSuggestionsTableViewController: SearchTableViewCellDelegate { - func startSearch(for searchTerm: String) { - - removeNoResultsFromView() - delegate?.newSearchStarted() - - guard searchTerm.count > 0 else { - searchSuggestions = [] - tableView.reloadSections(IndexSet(integer: Sections.suggestions.rawValue), with: .automatic) - return - } - - suggestDomains(for: searchTerm) { [weak self] (suggestions) in - self?.searchSuggestions = suggestions - self?.tableView.reloadSections(IndexSet(integer: Sections.suggestions.rawValue), with: .automatic) - } - } -} - -extension SearchTableViewCell { - fileprivate func reloadTextfieldStyle() { - textField.textColor = .text - textField.leftViewImage = UIImage(named: "icon-post-search") - } -} diff --git a/WordPress/Classes/ViewRelated/NUX/EpilogueSectionHeaderFooter.xib b/WordPress/Classes/ViewRelated/NUX/EpilogueSectionHeaderFooter.xib index 3042f823eaec..ef5507ed11ee 100644 --- a/WordPress/Classes/ViewRelated/NUX/EpilogueSectionHeaderFooter.xib +++ b/WordPress/Classes/ViewRelated/NUX/EpilogueSectionHeaderFooter.xib @@ -1,11 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16086"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> diff --git a/WordPress/Classes/ViewRelated/NUX/EpilogueUserInfoCell.swift b/WordPress/Classes/ViewRelated/NUX/EpilogueUserInfoCell.swift index a93fd391320b..aa6dd5600ac3 100644 --- a/WordPress/Classes/ViewRelated/NUX/EpilogueUserInfoCell.swift +++ b/WordPress/Classes/ViewRelated/NUX/EpilogueUserInfoCell.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressAuthenticator protocol EpilogueUserInfoCellViewControllerProvider { func viewControllerForEpilogueUserInfoCell() -> UIViewController @@ -24,33 +25,16 @@ class EpilogueUserInfoCell: UITableViewCell { @IBOutlet var gravatarView: UIImageView! @IBOutlet var fullNameLabel: UILabel! @IBOutlet var usernameLabel: UILabel! - @IBOutlet var topBorder: UIView! - @IBOutlet var bottomBorder: UIView! open var viewControllerProvider: EpilogueUserInfoCellViewControllerProvider? private var gravatarStatus: GravatarUploaderStatus = .idle private var email: String? override func awakeFromNib() { super.awakeFromNib() - - gravatarView.image = .gravatarPlaceholderImage - - let accessibilityDescription = NSLocalizedString("Add account image", comment: "Accessibility description for adding an image to a new user account. Tapping this initiates that flow.") - gravatarButton.accessibilityLabel = accessibilityDescription - - let accessibilityHint = NSLocalizedString("Adds image, or avatar, to represent this new account.", comment: "Accessibility hint text for adding an image to a new user account.") - gravatarButton.accessibilityHint = accessibilityHint - + configureImages() configureColors() } - func configureColors() { - fullNameLabel.textColor = .text - usernameLabel.textColor = .textSubtle - topBorder.backgroundColor = .divider - bottomBorder.backgroundColor = .divider - } - /// Configures the cell so that the LoginEpilogueUserInfo's payload is displayed /// func configure(userInfo: LoginEpilogueUserInfo, showEmail: Bool = false, allowGravatarUploads: Bool = false) { @@ -59,22 +43,30 @@ class EpilogueUserInfoCell: UITableViewCell { fullNameLabel.text = userInfo.fullName fullNameLabel.fadeInAnimation() - usernameLabel.text = showEmail ? userInfo.email : "@\(userInfo.username)" + var displayUsername: String { + if showEmail && !userInfo.email.isEmpty { + return userInfo.email + } + + return "@\(userInfo.username)" + } + + usernameLabel.text = displayUsername usernameLabel.fadeInAnimation() - usernameLabel.accessibilityIdentifier = "login-epilogue-username-label" gravatarAddIcon.isHidden = !allowGravatarUploads + configureAccessibility() switch gravatarStatus { - case .uploading(image: _): + case .uploading: gravatarActivityIndicator.startAnimating() case .finished: gravatarActivityIndicator.stopAnimating() case .idle: - let placeholder: UIImage = allowGravatarUploads ? .gravatarUploadablePlaceholderImage : .gravatarPlaceholderImage if let gravatarUrl = userInfo.gravatarUrl, let url = URL(string: gravatarUrl) { gravatarView.downloadImage(from: url) } else { + let placeholder: UIImage = allowGravatarUploads ? .gravatarUploadablePlaceholderImage : .gravatarPlaceholderImage gravatarView.downloadGravatarWithEmail(userInfo.email, rating: .x, placeholderImage: placeholder) } } @@ -88,7 +80,7 @@ class EpilogueUserInfoCell: UITableViewCell { activityIndicator.startAnimating() } - /// Stops the Activity Indicator Animation, and hides the Username + Fullname labels. + /// Stops the Activity Indicator Animation, and shows the Username + Fullname labels. /// func stopSpinner() { fullNameLabel.isHidden = false @@ -97,10 +89,60 @@ class EpilogueUserInfoCell: UITableViewCell { } } +// MARK: - Private Methods +// +private extension EpilogueUserInfoCell { + + func configureImages() { + gravatarAddIcon.image = .gridicon(.add) + gravatarView.image = .gravatarPlaceholderImage + } + + func configureColors() { + gravatarAddIcon.tintColor = .primary + gravatarAddIcon.backgroundColor = .basicBackground + + fullNameLabel.textColor = .text + fullNameLabel.font = AppStyleGuide.epilogueTitleFont + + usernameLabel.textColor = .textSubtle + usernameLabel.font = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .headline).pointSize, weight: .regular) + } + + func configureAccessibility() { + usernameLabel.accessibilityIdentifier = "login-epilogue-username-label" + accessibilityTraits = .none + + let accessibilityFormat = NSLocalizedString("Account Information. %@. %@.", comment: "Accessibility description for account information after logging in.") + accessibilityLabel = String(format: accessibilityFormat, fullNameLabel.text ?? "", usernameLabel.text ?? "") + + fullNameLabel.isAccessibilityElement = false + usernameLabel.isAccessibilityElement = false + gravatarButton.isAccessibilityElement = false + + if !gravatarAddIcon.isHidden { + configureSignupAccessibility() + } + } + + func configureSignupAccessibility() { + gravatarButton.isAccessibilityElement = true + let accessibilityDescription = NSLocalizedString("Add account image.", comment: "Accessibility description for adding an image to a new user account. Tapping this initiates that flow.") + gravatarButton.accessibilityLabel = accessibilityDescription + + let accessibilityHint = NSLocalizedString("Add image, or avatar, to represent this new account.", comment: "Accessibility hint text for adding an image to a new user account.") + gravatarButton.accessibilityHint = accessibilityHint + } + +} + + // MARK: - Gravatar uploading // extension EpilogueUserInfoCell: GravatarUploader { @IBAction func gravatarTapped() { + AuthenticatorAnalyticsTracker.shared.track(click: .selectAvatar) + guard let vcProvider = viewControllerProvider else { return } @@ -125,6 +167,6 @@ extension UIImage { /// Returns a Gravatar Placeholder Image when uploading is allowed /// fileprivate static var gravatarUploadablePlaceholderImage: UIImage { - return UIImage(named: "gravatar-hollow", in: nil, compatibleWith: nil)! + return UIImage(named: "gravatar-hollow") ?? UIImage() } } diff --git a/WordPress/Classes/ViewRelated/NUX/EpilogueUserInfoCell.xib b/WordPress/Classes/ViewRelated/NUX/EpilogueUserInfoCell.xib index c8654f737363..e12ddfa30b79 100644 --- a/WordPress/Classes/ViewRelated/NUX/EpilogueUserInfoCell.xib +++ b/WordPress/Classes/ViewRelated/NUX/EpilogueUserInfoCell.xib @@ -1,128 +1,104 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097.3" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="userInfo" rowHeight="148" id="uln-Hd-OJc" customClass="EpilogueUserInfoCell" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="383" height="148"/> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" selectionStyle="none" indentationWidth="10" reuseIdentifier="userInfo" id="uln-Hd-OJc" customClass="EpilogueUserInfoCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="383" height="174"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="uln-Hd-OJc" id="HfW-Dl-Z89"> - <rect key="frame" x="0.0" y="0.0" width="383" height="147.5"/> + <rect key="frame" x="0.0" y="0.0" width="383" height="174"/> <autoresizingMask key="autoresizingMask"/> <subviews> <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="SqA-Tm-XVX" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="161.5" y="20" width="60" height="60"/> + <rect key="frame" x="20" y="32" width="72" height="72"/> <constraints> - <constraint firstAttribute="width" constant="60" id="EbB-sV-0zn"/> - <constraint firstAttribute="height" constant="60" id="iY5-3Z-SCE"/> + <constraint firstAttribute="width" constant="72" id="9bQ-aU-GQb"/> + <constraint firstAttribute="height" constant="72" id="UTE-fR-kMZ"/> </constraints> </imageView> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="@username" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="89f-vg-HlI"> - <rect key="frame" x="0.0" y="109.5" width="383" height="18"/> - <accessibility key="accessibilityConfiguration" identifier="username"/> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Full Name" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="MbK-Ns-TbF"> + <rect key="frame" x="20" y="119" width="343" height="22"/> <constraints> - <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="18" id="OMo-Z3-lLF"/> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="20" id="LJN-z2-A6L"/> </constraints> - <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> - <color key="textColor" red="0.30980392159999998" green="0.4549019608" blue="0.5568627451" alpha="1" colorSpace="calibratedRGB"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleTitle0"/> + <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> <nil key="highlightedColor"/> </label> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="m3s-Kz-bOH" userLabel="bottom hairline"> - <rect key="frame" x="0.0" y="147" width="383" height="0.5"/> - <color key="backgroundColor" red="0.7843137255" green="0.84313725490000002" blue="0.84313725490000002" alpha="1" colorSpace="custom" customColorSpace="displayP3"/> - <constraints> - <constraint firstAttribute="height" constant="0.5" id="e8g-eP-IfD"/> - </constraints> - </view> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Ddl-7i-jhj" userLabel="top hairline"> - <rect key="frame" x="0.0" y="0.0" width="383" height="0.5"/> - <color key="backgroundColor" red="0.7843137255" green="0.84313725490000002" blue="0.88235294119999996" alpha="1" colorSpace="custom" customColorSpace="displayP3"/> - <constraints> - <constraint firstAttribute="height" constant="0.5" id="crl-gJ-xpM"/> - </constraints> - </view> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Full Name" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="MbK-Ns-TbF"> - <rect key="frame" x="20" y="89" width="343" height="20.5"/> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="89f-vg-HlI"> + <rect key="frame" x="20" y="141" width="343" height="18"/> + <accessibility key="accessibilityConfiguration" identifier="username"/> <constraints> - <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="20" id="LJN-z2-A6L"/> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="18" id="OMo-Z3-lLF"/> </constraints> - <fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/> - <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <color key="textColor" red="0.30980392159999998" green="0.4549019608" blue="0.5568627451" alpha="1" colorSpace="calibratedRGB"/> <nil key="highlightedColor"/> </label> <activityIndicatorView hidden="YES" opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" style="gray" translatesAutoresizingMaskIntoConstraints="NO" id="xGK-LG-0cx"> - <rect key="frame" x="181.5" y="101" width="20" height="20"/> + <rect key="frame" x="181.5" y="139" width="20" height="20"/> </activityIndicatorView> <activityIndicatorView hidden="YES" opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" style="whiteLarge" translatesAutoresizingMaskIntoConstraints="NO" id="2Ri-os-5ZW"> - <rect key="frame" x="173" y="31.5" width="37" height="37"/> + <rect key="frame" x="38" y="50" width="36" height="36"/> <constraints> - <constraint firstAttribute="width" constant="37" id="9OE-Qz-4LV"/> - <constraint firstAttribute="height" constant="37" id="h6l-wf-0gr"/> + <constraint firstAttribute="width" secondItem="2Ri-os-5ZW" secondAttribute="height" multiplier="1:1" id="Dlu-jo-OXt"/> </constraints> </activityIndicatorView> - <imageView hidden="YES" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar-add-button" translatesAutoresizingMaskIntoConstraints="NO" id="GYR-3W-aku"> - <rect key="frame" x="199.5" y="16" width="26" height="26"/> + <imageView hidden="YES" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar-add-button" translatesAutoresizingMaskIntoConstraints="NO" id="GYR-3W-aku" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="68" y="32" width="24" height="24"/> <constraints> - <constraint firstAttribute="width" constant="26" id="gm6-03-VBa"/> - <constraint firstAttribute="height" constant="26" id="uxU-Ng-JTY"/> + <constraint firstAttribute="width" constant="24" id="49J-ox-VuO"/> + <constraint firstAttribute="width" secondItem="GYR-3W-aku" secondAttribute="height" multiplier="1:1" id="CCT-Ge-nHv"/> </constraints> </imageView> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="11G-E4-8Jh"> - <rect key="frame" x="161.5" y="20" width="60" height="60"/> + <rect key="frame" x="20" y="32" width="72" height="72"/> <connections> <action selector="gravatarTapped" destination="uln-Hd-OJc" eventType="touchUpInside" id="kQu-u7-vJd"/> </connections> </button> </subviews> - <color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/> <constraints> - <constraint firstItem="89f-vg-HlI" firstAttribute="centerX" secondItem="HfW-Dl-Z89" secondAttribute="centerX" id="1Nn-hQ-J3l"/> - <constraint firstItem="Ddl-7i-jhj" firstAttribute="leading" secondItem="HfW-Dl-Z89" secondAttribute="leading" id="26n-rP-Lk3"/> - <constraint firstItem="m3s-Kz-bOH" firstAttribute="leading" secondItem="HfW-Dl-Z89" secondAttribute="leading" id="3RW-aO-2xM"/> - <constraint firstAttribute="trailing" secondItem="Ddl-7i-jhj" secondAttribute="trailing" id="5hU-nQ-buv"/> - <constraint firstAttribute="bottom" secondItem="m3s-Kz-bOH" secondAttribute="bottom" id="9jv-Kv-ip5"/> - <constraint firstItem="xGK-LG-0cx" firstAttribute="top" secondItem="SqA-Tm-XVX" secondAttribute="bottom" constant="21" id="Acl-tc-kbv"/> - <constraint firstItem="SqA-Tm-XVX" firstAttribute="centerX" secondItem="HfW-Dl-Z89" secondAttribute="centerX" id="FIj-Nh-OhB"/> - <constraint firstItem="MbK-Ns-TbF" firstAttribute="centerX" secondItem="HfW-Dl-Z89" secondAttribute="centerX" id="FOE-Sh-cNK"/> <constraint firstItem="11G-E4-8Jh" firstAttribute="centerY" secondItem="SqA-Tm-XVX" secondAttribute="centerY" id="IxS-CJ-S1j"/> <constraint firstItem="11G-E4-8Jh" firstAttribute="centerX" secondItem="SqA-Tm-XVX" secondAttribute="centerX" id="M29-uj-sGk"/> + <constraint firstItem="89f-vg-HlI" firstAttribute="leading" secondItem="HfW-Dl-Z89" secondAttribute="leading" constant="20" id="M44-Jk-uWN"/> + <constraint firstItem="2Ri-os-5ZW" firstAttribute="width" secondItem="SqA-Tm-XVX" secondAttribute="width" multiplier="0.5" id="MZT-fb-8wR"/> <constraint firstItem="xGK-LG-0cx" firstAttribute="centerX" secondItem="HfW-Dl-Z89" secondAttribute="centerX" id="Mwp-7z-bUJ"/> - <constraint firstItem="GYR-3W-aku" firstAttribute="top" secondItem="SqA-Tm-XVX" secondAttribute="top" constant="-4" id="QV1-8T-K0y"/> - <constraint firstItem="89f-vg-HlI" firstAttribute="width" secondItem="HfW-Dl-Z89" secondAttribute="width" id="ZdQ-N7-B5U"/> + <constraint firstItem="xGK-LG-0cx" firstAttribute="bottom" secondItem="89f-vg-HlI" secondAttribute="bottom" id="OBY-RQ-kRg"/> + <constraint firstItem="GYR-3W-aku" firstAttribute="top" secondItem="SqA-Tm-XVX" secondAttribute="top" id="QV1-8T-K0y"/> <constraint firstItem="11G-E4-8Jh" firstAttribute="width" secondItem="SqA-Tm-XVX" secondAttribute="width" id="dBO-2f-cNz"/> + <constraint firstAttribute="trailing" secondItem="89f-vg-HlI" secondAttribute="trailing" constant="20" id="fIW-sh-8b3"/> + <constraint firstAttribute="trailing" secondItem="MbK-Ns-TbF" secondAttribute="trailing" constant="20" id="hBZ-XF-2Ok"/> <constraint firstItem="89f-vg-HlI" firstAttribute="top" secondItem="MbK-Ns-TbF" secondAttribute="bottom" id="hGl-uc-uSH"/> - <constraint firstItem="Ddl-7i-jhj" firstAttribute="top" secondItem="HfW-Dl-Z89" secondAttribute="top" id="jCf-sF-Hsj"/> <constraint firstItem="2Ri-os-5ZW" firstAttribute="centerY" secondItem="SqA-Tm-XVX" secondAttribute="centerY" id="jFZ-xH-4vw"/> - <constraint firstItem="SqA-Tm-XVX" firstAttribute="top" secondItem="HfW-Dl-Z89" secondAttribute="top" constant="20" id="jnf-Zw-TmW"/> - <constraint firstAttribute="trailing" secondItem="m3s-Kz-bOH" secondAttribute="trailing" id="m14-pX-Baq"/> - <constraint firstItem="MbK-Ns-TbF" firstAttribute="width" secondItem="HfW-Dl-Z89" secondAttribute="width" constant="-40" id="mQP-tr-tpn"/> - <constraint firstAttribute="bottom" secondItem="89f-vg-HlI" secondAttribute="bottom" constant="20" id="o6T-dK-g7J"/> + <constraint firstItem="SqA-Tm-XVX" firstAttribute="top" secondItem="HfW-Dl-Z89" secondAttribute="top" constant="32" id="jnf-Zw-TmW"/> + <constraint firstItem="SqA-Tm-XVX" firstAttribute="leading" secondItem="HfW-Dl-Z89" secondAttribute="leading" constant="20" id="l1U-Lm-Q7b"/> + <constraint firstAttribute="bottom" secondItem="89f-vg-HlI" secondAttribute="bottom" constant="15" id="o6T-dK-g7J"/> <constraint firstItem="2Ri-os-5ZW" firstAttribute="centerX" secondItem="SqA-Tm-XVX" secondAttribute="centerX" id="tF0-R9-e3X"/> - <constraint firstItem="MbK-Ns-TbF" firstAttribute="top" secondItem="SqA-Tm-XVX" secondAttribute="bottom" constant="9" id="tp1-hu-yDU"/> + <constraint firstItem="MbK-Ns-TbF" firstAttribute="top" secondItem="SqA-Tm-XVX" secondAttribute="bottom" constant="15" id="tp1-hu-yDU"/> <constraint firstItem="11G-E4-8Jh" firstAttribute="height" secondItem="SqA-Tm-XVX" secondAttribute="height" id="vjj-dA-RLw"/> - <constraint firstItem="GYR-3W-aku" firstAttribute="trailing" secondItem="SqA-Tm-XVX" secondAttribute="trailing" constant="4" id="wL3-WH-8HH"/> + <constraint firstItem="MbK-Ns-TbF" firstAttribute="leading" secondItem="HfW-Dl-Z89" secondAttribute="leading" constant="20" id="vv4-33-weD"/> + <constraint firstItem="GYR-3W-aku" firstAttribute="trailing" secondItem="SqA-Tm-XVX" secondAttribute="trailing" id="wL3-WH-8HH"/> </constraints> </tableViewCellContentView> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <connections> <outlet property="activityIndicator" destination="xGK-LG-0cx" id="cvq-jE-eTh"/> - <outlet property="bottomBorder" destination="m3s-Kz-bOH" id="OI6-YV-Aed"/> <outlet property="fullNameLabel" destination="MbK-Ns-TbF" id="V0e-0h-jX6"/> <outlet property="gravatarActivityIndicator" destination="2Ri-os-5ZW" id="VjF-HG-SdZ"/> <outlet property="gravatarAddIcon" destination="GYR-3W-aku" id="Sdn-zL-dLo"/> <outlet property="gravatarButton" destination="11G-E4-8Jh" id="yiB-I0-zZi"/> <outlet property="gravatarView" destination="SqA-Tm-XVX" id="ag5-hS-Y5D"/> - <outlet property="topBorder" destination="Ddl-7i-jhj" id="10u-ag-28x"/> <outlet property="usernameLabel" destination="89f-vg-HlI" id="u0g-9B-dLf"/> </connections> - <point key="canvasLocation" x="-206.5" y="-63"/> + <point key="canvasLocation" x="-207.19999999999999" y="-52.173913043478265"/> </tableViewCell> </objects> <resources> diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogue.storyboard b/WordPress/Classes/ViewRelated/NUX/LoginEpilogue.storyboard index 6399069e388d..93c3f7cbf5bd 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginEpilogue.storyboard +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogue.storyboard @@ -1,11 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="FBE-8U-liw"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="19162" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="FBE-8U-liw"> + <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19144"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> @@ -19,160 +17,159 @@ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> <containerView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="qDW-L0-z8a"> - <rect key="frame" x="-4" y="20" width="383" height="519"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="563"/> <connections> <segue destination="UFO-Sm-cpW" kind="embed" id="rhb-gY-oAu"/> </connections> </containerView> <view opaque="NO" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="LF6-fg-TWa"> - <rect key="frame" x="-4" y="525" width="383" height="176"/> + <rect key="frame" x="0.0" y="563" width="375" height="104"/> <subviews> - <imageView hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="darkgrey-shadow" translatesAutoresizingMaskIntoConstraints="NO" id="z66-ET-PAh"> - <rect key="frame" x="0.0" y="-10" width="383" height="10"/> + <visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="E7v-Xt-6uK"> + <rect key="frame" x="0.0" y="0.0" width="375" height="104"/> + <view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="TIz-aA-WwG"> + <rect key="frame" x="0.0" y="0.0" width="375" height="104"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + </view> + <blurEffect style="regular"/> + </visualEffectView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="aTr-q6-PbK" userLabel="Top Line"> + <rect key="frame" x="0.0" y="0.0" width="375" height="0.5"/> + <color key="backgroundColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <constraints> - <constraint firstAttribute="height" constant="10" id="LbP-86-YIj"/> + <constraint firstAttribute="height" constant="0.5" id="kg0-Rj-t1o"/> </constraints> - </imageView> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="ydF-z6-fhv"> - <rect key="frame" x="20" y="20" width="343" height="108"/> - <subviews> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tcY-eU-7MY" customClass="FancyButton" customModule="WordPressUI"> - <rect key="frame" x="0.0" y="0.0" width="343" height="44"/> - <accessibility key="accessibilityConfiguration" identifier="connectSite"/> - <constraints> - <constraint firstAttribute="height" constant="44" id="kqU-iq-5lx"/> - </constraints> - <fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/> - <state key="normal" title="Connect another site"> - <color key="titleColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> - </state> - <userDefinedRuntimeAttributes> - <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="NO"/> - </userDefinedRuntimeAttributes> - <connections> - <action selector="handleConnectAnotherButton" destination="FBE-8U-liw" eventType="touchUpInside" id="WYK-td-6aY"/> - </connections> - </button> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" adjustsImageWhenHighlighted="NO" adjustsImageWhenDisabled="NO" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="51h-er-sEL" customClass="FancyButton" customModule="WordPressUI"> - <rect key="frame" x="0.0" y="64" width="343" height="44"/> - <accessibility key="accessibilityConfiguration" identifier="continueToSites"/> - <constraints> - <constraint firstAttribute="height" constant="44" id="8TV-5o-H50"/> - </constraints> - <fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/> - <state key="normal" title="Continue"> - <color key="titleColor" white="1" alpha="1" colorSpace="calibratedWhite"/> - </state> - <state key="selected" backgroundImage="beveled-blue-button-down"/> - <state key="highlighted" backgroundImage="beveled-blue-button-down"> - <color key="titleColor" white="1" alpha="1" colorSpace="calibratedWhite"/> - </state> - <userDefinedRuntimeAttributes> - <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="YES"/> - </userDefinedRuntimeAttributes> - <connections> - <action selector="dismissEpilogue" destination="FBE-8U-liw" eventType="touchUpInside" id="BG7-Nv-R8k"/> - </connections> - </button> - </subviews> - </stackView> + </view> </subviews> - <color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/> <constraints> - <constraint firstAttribute="trailing" secondItem="z66-ET-PAh" secondAttribute="trailing" id="F4j-Xk-TjB"/> - <constraint firstItem="ydF-z6-fhv" firstAttribute="leading" secondItem="LF6-fg-TWa" secondAttribute="leading" constant="20" id="GcQ-4u-N2l"/> - <constraint firstAttribute="trailing" secondItem="ydF-z6-fhv" secondAttribute="trailing" constant="20" id="KgB-4F-KG7"/> - <constraint firstItem="ydF-z6-fhv" firstAttribute="top" secondItem="z66-ET-PAh" secondAttribute="bottom" constant="20" id="h2J-Jd-7K1"/> - <constraint firstItem="z66-ET-PAh" firstAttribute="top" secondItem="LF6-fg-TWa" secondAttribute="top" constant="-10" id="tgY-xe-QBV"/> - <constraint firstAttribute="bottom" secondItem="ydF-z6-fhv" secondAttribute="bottom" constant="48" id="uw8-vj-VKy"/> - <constraint firstItem="z66-ET-PAh" firstAttribute="leading" secondItem="LF6-fg-TWa" secondAttribute="leading" id="yi8-OP-SB5"/> + <constraint firstItem="E7v-Xt-6uK" firstAttribute="top" secondItem="LF6-fg-TWa" secondAttribute="top" id="4cD-GC-Ud9"/> + <constraint firstAttribute="trailing" secondItem="E7v-Xt-6uK" secondAttribute="trailing" id="YmO-ZG-PD3"/> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="84" id="bjg-yv-Bi5"/> + <constraint firstAttribute="trailing" secondItem="aTr-q6-PbK" secondAttribute="trailing" id="cQt-u6-ypc"/> + <constraint firstAttribute="bottom" secondItem="E7v-Xt-6uK" secondAttribute="bottom" id="mNZ-iM-0Uu"/> + <constraint firstItem="aTr-q6-PbK" firstAttribute="leading" secondItem="LF6-fg-TWa" secondAttribute="leading" id="oAL-rY-TS7"/> + <constraint firstItem="E7v-Xt-6uK" firstAttribute="leading" secondItem="LF6-fg-TWa" secondAttribute="leading" id="phn-sq-0JJ"/> + <constraint firstItem="aTr-q6-PbK" firstAttribute="top" secondItem="LF6-fg-TWa" secondAttribute="top" id="zIw-8v-OFh"/> </constraints> </view> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" adjustsImageWhenHighlighted="NO" adjustsImageWhenDisabled="NO" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="51h-er-sEL" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="20" y="603" width="335" height="44"/> + <accessibility key="accessibilityConfiguration" identifier="continueToSites"/> + <constraints> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="44" id="8TV-5o-H50"/> + </constraints> + <fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/> + <state key="normal" title="Create a new site"> + <color key="titleColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </state> + <state key="selected" backgroundImage="beveled-blue-button-down"/> + <state key="highlighted" backgroundImage="beveled-blue-button-down"> + <color key="titleColor" white="1" alpha="1" colorSpace="calibratedWhite"/> + </state> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="NO"/> + </userDefinedRuntimeAttributes> + <connections> + <action selector="createANewSite" destination="FBE-8U-liw" eventType="touchUpInside" id="pVd-x6-7g4"/> + </connections> + </button> </subviews> - <color key="backgroundColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <viewLayoutGuide key="safeArea" id="NNF-sA-UQ4"/> <constraints> - <constraint firstItem="LF6-fg-TWa" firstAttribute="top" secondItem="qDW-L0-z8a" secondAttribute="bottom" constant="-14" id="3xo-Xc-gY6"/> - <constraint firstItem="qDW-L0-z8a" firstAttribute="leading" secondItem="CSv-1Z-yIA" secondAttribute="leadingMargin" constant="-20" id="7zX-it-ASF"/> - <constraint firstAttribute="trailingMargin" secondItem="LF6-fg-TWa" secondAttribute="trailing" constant="-20" id="980-CW-Iwa"/> - <constraint firstItem="LF6-fg-TWa" firstAttribute="leading" secondItem="CSv-1Z-yIA" secondAttribute="leadingMargin" constant="-20" id="ME9-0u-Vx1"/> - <constraint firstItem="NNF-sA-UQ4" firstAttribute="bottom" secondItem="LF6-fg-TWa" secondAttribute="bottom" constant="-34" id="ePC-QY-FqS"/> + <constraint firstItem="51h-er-sEL" firstAttribute="leading" secondItem="qDW-L0-z8a" secondAttribute="leading" constant="20" id="0rB-nq-HUv"/> + <constraint firstItem="LF6-fg-TWa" firstAttribute="trailing" secondItem="NNF-sA-UQ4" secondAttribute="trailing" id="980-CW-Iwa"/> + <constraint firstItem="51h-er-sEL" firstAttribute="top" secondItem="LF6-fg-TWa" secondAttribute="top" constant="40" id="M0r-84-tJ6"/> + <constraint firstItem="NNF-sA-UQ4" firstAttribute="leading" secondItem="LF6-fg-TWa" secondAttribute="leading" id="ME9-0u-Vx1"/> + <constraint firstItem="NNF-sA-UQ4" firstAttribute="trailing" secondItem="qDW-L0-z8a" secondAttribute="trailing" id="au1-yD-v4h"/> + <constraint firstItem="LF6-fg-TWa" firstAttribute="bottom" secondItem="CSv-1Z-yIA" secondAttribute="bottom" id="ePC-QY-FqS"/> + <constraint firstItem="qDW-L0-z8a" firstAttribute="trailing" secondItem="51h-er-sEL" secondAttribute="trailing" constant="20" id="gKl-WT-Qzi"/> + <constraint firstItem="NNF-sA-UQ4" firstAttribute="bottom" secondItem="51h-er-sEL" secondAttribute="bottom" constant="20" id="jN3-1W-Vzo"/> + <constraint firstItem="NNF-sA-UQ4" firstAttribute="bottom" secondItem="qDW-L0-z8a" secondAttribute="bottom" constant="104" id="mVQ-Dg-W4g"/> + <constraint firstItem="qDW-L0-z8a" firstAttribute="leading" secondItem="NNF-sA-UQ4" secondAttribute="leading" id="pOP-pd-DMR"/> <constraint firstItem="qDW-L0-z8a" firstAttribute="top" secondItem="NNF-sA-UQ4" secondAttribute="top" id="yLi-rM-L1b"/> - <constraint firstAttribute="trailingMargin" secondItem="qDW-L0-z8a" secondAttribute="trailing" constant="-20" id="zBp-f1-PDL"/> </constraints> - <viewLayoutGuide key="safeArea" id="NNF-sA-UQ4"/> </view> <extendedEdge key="edgesForExtendedLayout"/> <simulatedStatusBarMetrics key="simulatedStatusBarMetrics"/> <connections> + <outlet property="blurEffectView" destination="E7v-Xt-6uK" id="RIm-YT-MPx"/> <outlet property="buttonPanel" destination="LF6-fg-TWa" id="XBZ-Df-emN"/> - <outlet property="connectButton" destination="tcY-eU-7MY" id="zbf-nO-Ysg"/> - <outlet property="continueButton" destination="51h-er-sEL" id="Ig7-t1-vXb"/> - <outlet property="shadowView" destination="z66-ET-PAh" id="lFb-op-yOy"/> + <outlet property="createANewSiteButton" destination="51h-er-sEL" id="703-qw-kKG"/> + <outlet property="tableViewBottomContraint" destination="mVQ-Dg-W4g" id="IbU-ct-ltZ"/> + <outlet property="tableViewLeadingConstraint" destination="pOP-pd-DMR" id="Oat-2L-Iay"/> + <outlet property="tableViewTrailingConstraint" destination="au1-yD-v4h" id="jYm-53-dls"/> + <outlet property="topLine" destination="aTr-q6-PbK" id="IR4-UV-gef"/> + <outlet property="topLineHeightConstraint" destination="kg0-Rj-t1o" id="EVj-Bd-wuC"/> </connections> </viewController> <placeholder placeholderIdentifier="IBFirstResponder" id="0zu-w6-ZSd" userLabel="First Responder" sceneMemberID="firstResponder"/> </objects> - <point key="canvasLocation" x="4117.6000000000004" y="927.88605697151434"/> + <point key="canvasLocation" x="4105" y="893"/> </scene> <!--Login Epilogue Table View Controller--> <scene sceneID="sgW-0t-bH0"> <objects> <tableViewController id="UFO-Sm-cpW" customClass="LoginEpilogueTableViewController" customModule="WordPress" customModuleProvider="target" sceneMemberID="viewController"> - <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="none" allowsSelection="NO" rowHeight="154" sectionHeaderHeight="28" sectionFooterHeight="28" id="bES-Rc-GFi"> - <rect key="frame" x="0.0" y="0.0" width="383" height="519"/> + <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" rowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="bES-Rc-GFi"> + <rect key="frame" x="0.0" y="0.0" width="375" height="563"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <color key="backgroundColor" red="0.95294117649999999" green="0.96470588239999999" blue="0.97254901959999995" alpha="1" colorSpace="calibratedRGB"/> <inset key="separatorInset" minX="20" minY="0.0" maxX="0.0" maxY="0.0"/> <prototypes> - <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="BlogCell" rowHeight="52" id="jbC-9p-MD0" customClass="LoginEpilogueBlogCell" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="28" width="383" height="52"/> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" selectionStyle="none" indentationWidth="10" reuseIdentifier="BlogCell" rowHeight="58" id="jbC-9p-MD0" customClass="LoginEpilogueBlogCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="44.5" width="375" height="58"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="jbC-9p-MD0" id="WSD-zl-Z4K"> - <rect key="frame" x="0.0" y="0.0" width="383" height="52"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="58"/> <autoresizingMask key="autoresizingMask"/> <subviews> <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="diS-LA-oFB"> - <rect key="frame" x="20" y="2" width="40" height="40"/> + <rect key="frame" x="20" y="10" width="40" height="38"/> <constraints> - <constraint firstAttribute="height" constant="40" id="4VT-eg-pia"/> - <constraint firstAttribute="width" constant="40" id="ODd-06-5TA"/> + <constraint firstAttribute="width" secondItem="diS-LA-oFB" secondAttribute="height" multiplier="1:1" id="EDm-IG-hab"/> + <constraint firstAttribute="width" constant="40" id="jXu-be-bSL"/> </constraints> </imageView> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Masdftg" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="sHR-j2-pY2"> - <rect key="frame" x="72" y="2" width="57" height="18"/> - <constraints> - <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="18" id="R7w-fr-KPL"/> - </constraints> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Site Name" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="sHR-j2-pY2"> + <rect key="frame" x="70" y="10" width="285" height="18"/> <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> <nil key="textColor"/> <nil key="highlightedColor"/> </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Mastg" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="w2L-7L-x21"> - <rect key="frame" x="72" y="24" width="37.5" height="16"/> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="siteurl.wordpress.com" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="w2L-7L-x21"> + <rect key="frame" x="70" y="32" width="285" height="16"/> <accessibility key="accessibilityConfiguration" identifier="siteUrl"/> - <constraints> - <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="16" id="wjh-eu-vg5"/> - </constraints> <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> <nil key="textColor"/> <nil key="highlightedColor"/> </label> </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <constraints> - <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="diS-LA-oFB" secondAttribute="bottom" constant="10" id="0Hw-qs-uwj"/> + <constraint firstItem="diS-LA-oFB" firstAttribute="centerY" secondItem="WSD-zl-Z4K" secondAttribute="centerY" id="0PF-9R-ocT"/> <constraint firstItem="diS-LA-oFB" firstAttribute="leading" secondItem="WSD-zl-Z4K" secondAttribute="leading" constant="20" id="2hH-m8-OhI"/> - <constraint firstAttribute="bottom" secondItem="w2L-7L-x21" secondAttribute="bottom" constant="12" id="A1p-yb-QUQ"/> - <constraint firstItem="sHR-j2-pY2" firstAttribute="leading" secondItem="diS-LA-oFB" secondAttribute="trailing" constant="12" id="Cvx-Jj-a6t"/> - <constraint firstItem="w2L-7L-x21" firstAttribute="top" secondItem="sHR-j2-pY2" secondAttribute="bottom" constant="4" id="OwV-pc-9CY"/> - <constraint firstItem="w2L-7L-x21" firstAttribute="leading" secondItem="sHR-j2-pY2" secondAttribute="leading" id="dyh-JD-mdD"/> - <constraint firstItem="sHR-j2-pY2" firstAttribute="top" secondItem="diS-LA-oFB" secondAttribute="top" id="e2f-fO-Hfw"/> - <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="sHR-j2-pY2" secondAttribute="trailing" constant="20" id="f0o-9q-hP2"/> - <constraint firstItem="diS-LA-oFB" firstAttribute="top" secondItem="WSD-zl-Z4K" secondAttribute="top" constant="2" id="l6p-pV-WNp"/> + <constraint firstItem="sHR-j2-pY2" firstAttribute="centerY" secondItem="diS-LA-oFB" secondAttribute="centerY" id="CgY-JP-zlk"/> + <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="diS-LA-oFB" secondAttribute="bottom" constant="10" id="EI4-C6-JZL"/> + <constraint firstItem="diS-LA-oFB" firstAttribute="top" relation="greaterThanOrEqual" secondItem="WSD-zl-Z4K" secondAttribute="top" constant="10" id="FIZ-Oq-Edg"/> + <constraint firstAttribute="trailing" secondItem="w2L-7L-x21" secondAttribute="trailing" constant="20" id="Jga-aE-6wc"/> + <constraint firstItem="sHR-j2-pY2" firstAttribute="top" relation="greaterThanOrEqual" secondItem="WSD-zl-Z4K" secondAttribute="top" constant="10" id="NRK-5z-MD7"/> + <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="w2L-7L-x21" secondAttribute="bottom" constant="10" id="NxC-la-3Ir"/> + <constraint firstItem="sHR-j2-pY2" firstAttribute="leading" secondItem="diS-LA-oFB" secondAttribute="trailing" constant="10" id="On8-Kk-6Od"/> + <constraint firstAttribute="trailing" secondItem="sHR-j2-pY2" secondAttribute="trailing" constant="20" id="QcF-DX-Fd5"/> + <constraint firstItem="w2L-7L-x21" firstAttribute="leading" secondItem="diS-LA-oFB" secondAttribute="trailing" constant="10" id="hC5-KQ-cuE"/> + <constraint firstItem="w2L-7L-x21" firstAttribute="top" secondItem="sHR-j2-pY2" secondAttribute="bottom" constant="4" id="kxP-qc-0Hr"/> </constraints> + <variation key="default"> + <mask key="constraints"> + <exclude reference="CgY-JP-zlk"/> + </mask> + </variation> </tableViewCellContentView> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <connections> <outlet property="siteIcon" destination="diS-LA-oFB" id="bxR-sb-EKi"/> <outlet property="siteNameLabel" destination="sHR-j2-pY2" id="fqH-OJ-s9y"/> + <outlet property="siteNameVerticalConstraint" destination="CgY-JP-zlk" id="2c5-wO-cyj"/> <outlet property="urlLabel" destination="w2L-7L-x21" id="9Ww-4p-I50"/> </connections> </tableViewCell> @@ -190,6 +187,5 @@ </scenes> <resources> <image name="beveled-blue-button-down" width="18.5" height="19.5"/> - <image name="darkgrey-shadow" width="10" height="10"/> </resources> </document> diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueAnimator.swift b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueAnimator.swift new file mode 100644 index 000000000000..bfe0b0b887db --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueAnimator.swift @@ -0,0 +1,88 @@ +import UIKit + +final class LoginEpilogueAnimator: NSObject, UIViewControllerAnimatedTransitioning { + + private enum Constants { + static let loginAnimationDuration = 0.3 + static let loginAnimationScaleX = 1.0 + static let loginAnimationScaleY = 1.2 + static let quickStartAnimationDuration = 0.3 + static let quickStartPromptStartAlpha = 0.2 + static let quickStartTopEndConstraint = 80.0 + static let hiddenAlpha = 0.0 + static let visibleAlpha = 1.0 + } + + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return Constants.loginAnimationDuration + Constants.quickStartAnimationDuration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let loginEpilogueViewController = transitionContext.viewController(forKey: .from) as? LoginEpilogueViewController, + let quickStartPromptViewController = transitionContext.viewController(forKey: .to) as? QuickStartPromptViewController, + let selectedCell = loginEpilogueViewController.tableViewController?.selectedCell else { + return + } + + let containerView = transitionContext.containerView + containerView.backgroundColor = loginEpilogueViewController.view.backgroundColor + + let cellSnapshot = selectedCell.contentView.snapshotView(afterScreenUpdates: false) + let cellSnapshotFrame = containerView.convert(selectedCell.contentView.frame, from: selectedCell) + cellSnapshot?.frame = cellSnapshotFrame + selectedCell.contentView.alpha = Constants.hiddenAlpha + + let loginContainer = UIView(frame: loginEpilogueViewController.view.frame) + let loginSnapshot = loginEpilogueViewController.view.snapshotView(afterScreenUpdates: true) + loginContainer.backgroundColor = loginEpilogueViewController.view.backgroundColor + + if let loginSnapshot = loginSnapshot { + loginSnapshot.layer.anchorPoint = CGPoint(x: 0.5, y: cellSnapshotFrame.origin.y / loginContainer.bounds.height) + loginSnapshot.frame = loginContainer.frame + loginContainer.addSubview(loginSnapshot) + containerView.addSubview(loginContainer) + } + + if let cellSnapshot = cellSnapshot { + containerView.addSubview(cellSnapshot) + } + + quickStartPromptViewController.view.alpha = Constants.hiddenAlpha + let safeAreaTop = loginEpilogueViewController.view.safeAreaInsets.top + quickStartPromptViewController.scrollViewTopVerticalConstraint.constant = cellSnapshotFrame.origin.y - safeAreaTop + quickStartPromptViewController.promptTitleLabel.alpha = Constants.quickStartPromptStartAlpha + quickStartPromptViewController.promptDescriptionLabel.alpha = Constants.quickStartPromptStartAlpha + quickStartPromptViewController.showMeAroundButton.alpha = Constants.hiddenAlpha + quickStartPromptViewController.noThanksButton.alpha = Constants.hiddenAlpha + quickStartPromptViewController.view.layoutIfNeeded() + containerView.addSubview(quickStartPromptViewController.view) + + let quickStartAnimator = UIViewPropertyAnimator(duration: Constants.quickStartAnimationDuration, curve: .easeInOut) { + quickStartPromptViewController.promptTitleLabel.alpha = Constants.visibleAlpha + quickStartPromptViewController.promptDescriptionLabel.alpha = Constants.visibleAlpha + quickStartPromptViewController.showMeAroundButton.alpha = Constants.visibleAlpha + quickStartPromptViewController.noThanksButton.alpha = Constants.visibleAlpha + quickStartPromptViewController.scrollViewTopVerticalConstraint.constant = Constants.quickStartTopEndConstraint + quickStartPromptViewController.view.layoutIfNeeded() + } + + quickStartAnimator.addCompletion { position in + cellSnapshot?.alpha = Constants.hiddenAlpha + selectedCell.contentView.alpha = Constants.visibleAlpha + transitionContext.completeTransition(position == .end) + } + + let loginAnimator = UIViewPropertyAnimator(duration: Constants.loginAnimationDuration, curve: .easeOut) { + loginSnapshot?.alpha = Constants.hiddenAlpha + loginSnapshot?.transform = CGAffineTransform(scaleX: Constants.loginAnimationScaleX, y: Constants.loginAnimationScaleY) + } + + loginAnimator.addCompletion { _ in + quickStartPromptViewController.view.alpha = Constants.visibleAlpha + quickStartAnimator.startAnimation() + } + + loginAnimator.startAnimation() + } + +} diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueBlogCell.swift b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueBlogCell.swift index 3c0ba17590a0..60ae52b0ea34 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueBlogCell.swift +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueBlogCell.swift @@ -4,6 +4,7 @@ class LoginEpilogueBlogCell: WPBlogTableViewCell { @IBOutlet var siteNameLabel: UILabel? @IBOutlet var urlLabel: UILabel? @IBOutlet var siteIcon: UIImageView? + @IBOutlet var siteNameVerticalConstraint: NSLayoutConstraint! override var textLabel: UILabel? { get { @@ -22,4 +23,10 @@ class LoginEpilogueBlogCell: WPBlogTableViewCell { return siteIcon } } + + func adjustSiteNameConstraint() { + // If the URL is nil, center the Site Name vertically with the site icon. + siteNameVerticalConstraint.isActive = (urlLabel?.text == nil) + } + } 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/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 d5840508c263..2742158d6e16 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueTableViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueTableViewController.swift @@ -7,6 +7,10 @@ import WordPressAuthenticator // class LoginEpilogueTableViewController: UITableViewController { + /// Currently selected cell + /// + private(set) weak var selectedCell: LoginEpilogueBlogCell? + /// TableView's Datasource /// private let blogDataSource = BlogListDataSource() @@ -23,19 +27,40 @@ class LoginEpilogueTableViewController: UITableViewController { /// private var credentials: AuthenticatorCredentials? + /// Flag indicating if the Create A New Site button should be displayed. + /// + private var showCreateNewSite: Bool { + guard AppConfiguration.allowsConnectSite else { + return false + } + + guard let wpcom = credentials?.wpcom else { + return true + } + + return !wpcom.isJetpackLogin + } + + private var tracker: AuthenticatorAnalyticsTracker { + 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) - - view.backgroundColor = .listBackground + 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. @@ -63,118 +88,163 @@ extension LoginEpilogueTableViewController { } } + // Add one for Create A New Site if there are no sites from blogDataSource. + if adjustedNumberOfSections == 0 && showCreateNewSite { + adjustedNumberOfSections += 1 + } + + // Add one for User Info return adjustedNumberOfSections + 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if section == Sections.userInfoSection { - return 1 + return 2 } let correctedSection = section - 1 - return blogDataSource.tableView(tableView, numberOfRowsInSection: correctedSection) - } + let siteRows = blogDataSource.tableView(tableView, numberOfRowsInSection: correctedSection) - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard indexPath.section == Sections.userInfoSection else { - let wrappedPath = IndexPath(row: indexPath.row, section: indexPath.section-1) - return blogDataSource.tableView(tableView, cellForRowAt: wrappedPath) + // Add one for Create new site cell + + guard let parent = parent as? LoginEpilogueViewController else { + return siteRows } - let cell = tableView.dequeueReusableCell(withIdentifier: Settings.userCellReuseIdentifier) as! EpilogueUserInfoCell - if let info = epilogueUserInfo { - cell.stopSpinner() - cell.configure(userInfo: info) + if siteRows <= Constants.createNewSiteRowThreshold { + parent.hideButtonPanel() + return showCreateNewSite ? siteRows + 1 : siteRows } else { - cell.startSpinner() + if !showCreateNewSite { + parent.hideButtonPanel() + } + return siteRows } - - return cell } - override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard cell is EpilogueUserInfoCell else { - return - } + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - cell.contentView.backgroundColor = .listForeground - } + // User Info Row + if indexPath.section == Sections.userInfoSection { + 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() + } - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: Settings.headerReuseIdentifier) as? EpilogueSectionHeaderFooter else { - fatalError("Failed to get a section header 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 + } } - cell.titleLabel?.text = title(for: section) - cell.accessibilityIdentifier = "Login Cell" + // Create new site row + let siteRows = blogDataSource.tableView(tableView, numberOfRowsInSection: indexPath.section - 1) - return cell - } + let isCreateNewSiteRow = + showCreateNewSite && + siteRows <= Constants.createNewSiteRowThreshold && + indexPath.row == lastRowInSection(indexPath.section) - override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - if indexPath.section == 0 { - return Settings.profileRowHeight + if isCreateNewSiteRow { + guard let cell = tableView.dequeueReusableCell(withIdentifier: Settings.createNewSiteReuseIdentifier, for: indexPath) as? LoginEpilogueCreateNewSiteCell else { + return UITableViewCell() + } + cell.delegate = self + removeSeparatorFor(cell) + return cell } - return Settings.blogRowHeight - } - - override func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat { - return Settings.headerHeight - } + // Site Rows + let wrappedPath = IndexPath(row: indexPath.row, section: indexPath.section - 1) + let cell = blogDataSource.tableView(tableView, cellForRowAt: wrappedPath) - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension + 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, heightForHeaderInSection section: Int) -> CGFloat { - return UITableView.automaticDimension + override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return indexPath.section == Sections.userInfoSection ? Settings.profileRowHeight : Settings.blogRowHeight } override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { return false } -} - -// MARK: - UITableViewDelegate methods -// -extension LoginEpilogueTableViewController { - - override func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { - guard let headerView = view as? UITableViewHeaderFooterView else { + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let parent = parent as? LoginEpilogueViewController, + let cell = tableView.cellForRow(at: indexPath) as? LoginEpilogueBlogCell else { return } - headerView.textLabel?.font = UIFont.preferredFont(forTextStyle: .footnote) - headerView.textLabel?.textColor = .neutral(.shade50) - headerView.contentView.backgroundColor = .listBackground + let wrappedPath = IndexPath(row: indexPath.row, section: indexPath.section - 1) + let blog = blogDataSource.blog(at: wrappedPath) + + selectedCell = cell + parent.blogSelected(blog) + } + + private enum Constants { + static let createNewSiteRowThreshold = 3 } } +// MARK: - LoginEpilogueCreateNewSiteCellDelegate +extension LoginEpilogueTableViewController: LoginEpilogueCreateNewSiteCellDelegate { + func didTapCreateNewSite() { + guard let parent = parent as? LoginEpilogueViewController else { + return + } + parent.createNewSite() + } +} -// MARK: - Private Methods +// MARK: - Private Extension // private extension LoginEpilogueTableViewController { - - /// Returns the title for the current section!. + /// Returns the last row index for a given section. /// - func title(for section: Int) -> String { - if section == Sections.userInfoSection { - return NSLocalizedString("Logged In As", comment: "Header for user info, shown after loggin in").localizedUppercase - } + func lastRowInSection(_ section: Int) -> Int { + return (tableView.numberOfRows(inSection: section) - 1) + } - let rowCount = blogDataSource.tableView(tableView, numberOfRowsInSection: section-1) - if rowCount > 1 { - return NSLocalizedString("My Sites", comment: "Header for list of multiple sites, shown after loggin in").localizedUppercase - } + func removeSeparatorFor(_ cell: UITableViewCell) { + cell.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: .greatestFiniteMagnitude) + } - return NSLocalizedString("My Site", comment: "Header for a single site, shown after loggin in").localizedUppercase + enum Sections { + static let userInfoSection = 0 + } + + 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) } } -// MARK: - Loading! +// MARK: - Loading // private extension LoginEpilogueTableViewController { @@ -197,19 +267,17 @@ private extension LoginEpilogueTableViewController { /// Loads the Blog for a given Username / XMLRPC, if any. /// - private func loadBlog(username: String, xmlrpc: String) -> Blog? { + func loadBlog(username: String, xmlrpc: String) -> Blog? { let context = ContextManager.sharedInstance().mainContext - let service = BlogService(managedObjectContext: context) - - return service.findBlog(withXmlrpc: xmlrpc, andUsername: username) + return Blog.lookup(username: username, xmlrpc: xmlrpc, in: context) } /// The self-hosted flow sets user info, if no user info is set, assume a wpcom flow and try the default wp account. /// - private func loadEpilogueForDotcom() -> LoginEpilogueUserInfo { + func loadEpilogueForDotcom() -> LoginEpilogueUserInfo { let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - guard let account = service.defaultWordPressComAccount() else { + + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) else { fatalError() } @@ -218,7 +286,7 @@ private extension LoginEpilogueTableViewController { /// Loads the EpilogueInfo for a SelfHosted site, with the specified credentials, at the given endpoint. /// - private func loadEpilogueForSelfhosted(username: String, password: String, xmlrpc: String, completion: @escaping (LoginEpilogueUserInfo?) -> ()) { + func loadEpilogueForSelfhosted(username: String, password: String, xmlrpc: String, completion: @escaping (LoginEpilogueUserInfo?) -> ()) { guard let service = UsersService(username: username, password: password, xmlrpc: xmlrpc) else { completion(nil) return @@ -247,21 +315,3 @@ private extension LoginEpilogueTableViewController { } } } - - -// MARK: - UITableViewDelegate methods -// -private extension LoginEpilogueTableViewController { - - enum Sections { - static let userInfoSection = 0 - } - - enum Settings { - static let headerReuseIdentifier = "SectionHeader" - static let userCellReuseIdentifier = "userInfo" - static let profileRowHeight = CGFloat(140) - static let blogRowHeight = CGFloat(52) - static let headerHeight = CGFloat(50) - } -} diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueUserInfo.swift b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueUserInfo.swift index 9900a86bd0f4..f2bb616004fa 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueUserInfo.swift +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueUserInfo.swift @@ -49,13 +49,7 @@ extension LoginEpilogueUserInfo { /// Updates the Epilogue properties, given a SocialService instance. /// mutating func update(with service: SocialService) { - switch service { - case .google(let user): - fullName = user.profile.name - email = user.profile.email - case .apple(let user): - fullName = user.fullName - email = user.email - } + fullName = service.user.fullName + email = service.user.email } } diff --git a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueViewController.swift b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueViewController.swift index 8b9f13c9988d..29d9a5deb4ba 100644 --- a/WordPress/Classes/ViewRelated/NUX/LoginEpilogueViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/LoginEpilogueViewController.swift @@ -7,29 +7,51 @@ import WordPressAuthenticator // class LoginEpilogueViewController: UIViewController { - /// Button's Container View. + /// Button Container View. /// @IBOutlet var buttonPanel: UIView! + @IBOutlet var blurEffectView: UIVisualEffectView! - /// Separator: to be displayed above the actual buttons. + /// Line displayed atop the buttonPanel when the table is scrollable. /// - @IBOutlet var shadowView: UIView! + @IBOutlet var topLine: UIView! + @IBOutlet var topLineHeightConstraint: NSLayoutConstraint! - /// Connect Button! + /// Create a new site button. /// - @IBOutlet var connectButton: UIButton! + @IBOutlet var createANewSiteButton: UIButton! - /// Continue Button. + /// 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 /// - @IBOutlet var continueButton: UIButton! + private var blurEffect: UIBlurEffect.Style { + return .systemChromeMaterial + } + + private var dividerView: LoginEpilogueDividerView? /// Links to the Epilogue TableViewController /// - private var tableViewController: LoginEpilogueTableViewController? + private(set) var tableViewController: LoginEpilogueTableViewController? + + /// Analytics Tracker + /// + private let tracker = AuthenticatorAnalyticsTracker.shared - /// Closure to be executed upon dismissal. + /// Closure to be executed upon blog selection. /// - var onDismiss: (() -> Void)? + var onBlogSelected: ((Blog) -> Void)? + + /// Closure to be executed upon a new site creation. + /// + var onCreateNewSite: (() -> Void)? /// Site that was just connected to our awesome app. /// @@ -53,9 +75,15 @@ class LoginEpilogueViewController: UIViewController { fatalError() } + view.backgroundColor = .basicBackground + topLine.backgroundColor = .divider + defaultTableViewMargin = tableViewLeadingConstraint.constant + setTableViewMargins() refreshInterface(with: credentials) + WordPressAuthenticator.track(.loginEpilogueViewed) - view.backgroundColor = .neutral(.shade0) + // If the user just signed in, refresh the A/B assignments + ABTest.start() } override func viewWillAppear(_ animated: Bool) { @@ -63,11 +91,6 @@ class LoginEpilogueViewController: UIViewController { navigationController?.setNavigationBarHidden(true, animated: false) } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - WordPressAuthenticator.track(.loginEpilogueViewed) - } - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { super.prepare(for: segue, sender: sender) @@ -89,85 +112,106 @@ class LoginEpilogueViewController: UIViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - configurePanelBasedOnTableViewContents() + configureButtonPanel() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + setTableViewMargins() } -} + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + setTableViewMargins() + } -// MARK: - Configuration + 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 // private extension LoginEpilogueViewController { /// Refreshes the UI so that the specified WordPressSite is displayed. /// func refreshInterface(with credentials: AuthenticatorCredentials) { - if credentials.wporg != nil { - configureButtons() - } else if let wpcom = credentials.wpcom { - configureButtons(numberOfBlogs: numberOfWordPressComBlogs, hidesConnectButton: wpcom.isJetpackLogin) - } + configureCreateANewSiteButton() } - /// Returns the number of WordPress.com sites. + /// Setup: Buttons /// - var numberOfWordPressComBlogs: Int { - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - - return service.defaultWordPressComAccount()?.blogs.count ?? 0 + func configureCreateANewSiteButton() { + createANewSiteButton.setTitle(NSLocalizedString("Create a new site", comment: "A button title"), for: .normal) + createANewSiteButton.accessibilityIdentifier = "Create a new site" } - /// Setup: Buttons + /// Setup: Button Panel /// - func configureButtons(numberOfBlogs: Int = 1, hidesConnectButton: Bool = false) { - let connectTitle: String - if numberOfBlogs == 0 { - connectTitle = NSLocalizedString("Connect a site", comment: "Button title") - } else { - connectTitle = NSLocalizedString("Connect another site", comment: "Button title") - } + func configureButtonPanel() { + topLineHeightConstraint.constant = .hairlineBorderWidth + buttonPanel.backgroundColor = .quaternaryBackground + topLine.isHidden = false + blurEffectView.effect = UIBlurEffect(style: blurEffect) + blurEffectView.isHidden = false + setupDividerLineIfNeeded() + } - continueButton.setTitle(NSLocalizedString("Continue", comment: "A button title"), for: .normal) - continueButton.accessibilityIdentifier = "Continue" - connectButton.setTitle(connectTitle, for: .normal) - connectButton.isHidden = hidesConnectButton - connectButton.accessibilityIdentifier = "Connect" + func setTableViewMargins() { + tableViewLeadingConstraint.constant = view.getHorizontalMargin(compactMargin: defaultTableViewMargin) + tableViewTrailingConstraint.constant = view.getHorizontalMargin(compactMargin: defaultTableViewMargin) } - /// Setup: Button Panel - /// - func configurePanelBasedOnTableViewContents() { - guard let tableView = tableViewController?.tableView else { - return - } + 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) + ]) + } - let contentSize = tableView.contentSize - let screenHeight = UIScreen.main.bounds.height - let panelHeight = buttonPanel.frame.height + private enum Constants { + static let dividerViewHeight: CGFloat = 40.0 + } - if contentSize.height > (screenHeight - panelHeight) { - buttonPanel.backgroundColor = WordPressAuthenticator.shared.style.viewControllerBackgroundColor - shadowView.isHidden = false - } else { - buttonPanel.backgroundColor = .listBackground - shadowView.isHidden = true - } + // MARK: - Actions + + @IBAction func createANewSite() { + createNewSite() } } +// MARK: - UINavigationControllerDelegate -// MARK: - Actions -// -extension LoginEpilogueViewController { +extension LoginEpilogueViewController: UINavigationControllerDelegate { - @IBAction func dismissEpilogue() { - onDismiss?() - navigationController?.dismiss(animated: true) - } + func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { + guard operation == .push, fromVC is LoginEpilogueViewController, toVC is QuickStartPromptViewController else { + return nil + } - @IBAction func handleConnectAnotherButton() { - onDismiss?() - let controller = WordPressAuthenticator.signinForWPOrg() - navigationController?.setViewControllers([controller], animated: true) + return LoginEpilogueAnimator() } + } 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 c7469dfb09ed..15e9fd05ea52 100644 --- a/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialViewController.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressAuthenticator extension NSNotification.Name { static let createSite = NSNotification.Name(rawValue: "PSICreateSite") @@ -12,11 +13,6 @@ extension NSNotification.Name { private struct Constants { // I18N Strings - static let welcomeTitleText = NSLocalizedString( - "Welcome to WordPress", - comment: "Post Signup Interstitial Title Text" - ) - static let subTitleText = NSLocalizedString( "Whatever you want to create or share, we'll help you do it right here.", comment: "Post Signup Interstitial Subtitle Text" @@ -44,10 +40,21 @@ class PostSignUpInterstitialViewController: UIViewController { @IBOutlet weak var createSiteButton: UIButton! @IBOutlet weak var addSelfHostedButton: UIButton! @IBOutlet weak var cancelButton: UIButton! + @IBOutlet weak var imageView: UIImageView! + + enum DismissAction { + case none + case createSite + case addSelfHosted + } /// Closure to be executed upon dismissal. /// - var onDismiss: (() -> Void)? + var dismiss: ((_ action: DismissAction) -> Void)? + + /// Analytics tracker + /// + private let tracker = AuthenticatorAnalyticsTracker.shared // MARK: - View Methods override func viewDidLoad() { @@ -55,10 +62,12 @@ class PostSignUpInterstitialViewController: UIViewController { view.backgroundColor = .listBackground - configureI18N() + // Update the banner image for Jetpack + if AppConfiguration.isJetpack, let image = UIImage(named: "wp-illustration-construct-site-jetpack") { + imageView.image = image + } - let coordinator = PostSignUpInterstitialCoordinator() - coordinator.markAsSeen() + configureI18N() WPAnalytics.track(.welcomeNoSitesInterstitialShown) } @@ -74,35 +83,37 @@ class PostSignUpInterstitialViewController: UIViewController { // MARK: - IBAction's @IBAction func createSite(_ sender: Any) { - onDismiss?() - navigationController?.dismiss(animated: false) { - NotificationCenter.default.post(name: .createSite, object: nil) - } + tracker.track(click: .createNewSite, ifTrackingNotEnabled: { + WPAnalytics.track(.welcomeNoSitesInterstitialButtonTapped, withProperties: ["button": "create_new_site"]) + }) + + RootViewCoordinator.sharedPresenter.willDisplayPostSignupFlow() + dismiss?(.createSite) - WPAnalytics.track(.welcomeNoSitesInterstitialButtonTapped, withProperties: ["button": "create_new_site"]) } @IBAction func addSelfHosted(_ sender: Any) { - onDismiss?() - navigationController?.dismiss(animated: false) { - NotificationCenter.default.post(name: .addSelfHosted, object: nil) - } + tracker.track(click: .addSelfHostedSite, ifTrackingNotEnabled: { + WPAnalytics.track(.welcomeNoSitesInterstitialButtonTapped, withProperties: ["button": "add_self_hosted_site"]) + }) - WPAnalytics.track(.welcomeNoSitesInterstitialButtonTapped, withProperties: ["button": "add_self_hosted_site"]) + RootViewCoordinator.sharedPresenter.willDisplayPostSignupFlow() + dismiss?(.addSelfHosted) } @IBAction func cancel(_ sender: Any) { - onDismiss?() + dismiss?(.none) - WPTabBarController.sharedInstance().showReaderTab() - navigationController?.dismiss(animated: true, completion: nil) + RootViewCoordinator.sharedPresenter.showReaderTab() - WPAnalytics.track(.welcomeNoSitesInterstitialDismissed) + tracker.track(click: .dismiss, ifTrackingNotEnabled: { + WPAnalytics.track(.welcomeNoSitesInterstitialDismissed) + }) } // MARK: - Private private func configureI18N() { - welcomeLabel.text = Constants.welcomeTitleText + welcomeLabel.text = AppConstants.PostSignUpInterstitial.welcomeTitleText subTitleLabel.text = Constants.subTitleText createSiteButton.setTitle(Constants.createSiteButtonTitleText, for: .normal) addSelfHostedButton.setTitle(Constants.addSelfHostedButtonTitleText, for: .normal) @@ -111,18 +122,14 @@ class PostSignUpInterstitialViewController: UIViewController { /// Determines whether or not the PSI should be displayed for the logged in user @objc class func shouldDisplay() -> Bool { - let numberOfBlogs = self.numberOfBlogs() + guard AppConfiguration.allowSiteCreation else { + return false + } - let coordinator = PostSignUpInterstitialCoordinator() - return coordinator.shouldDisplay(numberOfBlogs: numberOfBlogs) + return self.numberOfBlogs() == 0 } private class func numberOfBlogs() -> Int { - let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - - let blogCount = blogService.blogCountForAllAccounts() - - return blogCount + return Blog.count(in: ContextManager.sharedInstance().mainContext) } } diff --git a/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialViewController.xib b/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialViewController.xib index 4a6eff8ca12b..0f343a88745d 100644 --- a/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialViewController.xib +++ b/WordPress/Classes/ViewRelated/NUX/Post Signup Interstitial/PostSignUpInterstitialViewController.xib @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19455" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <device id="retina5_5" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19454"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> @@ -13,6 +13,7 @@ <outlet property="addSelfHostedButton" destination="JzC-d5-c3A" id="32W-0f-Xl2"/> <outlet property="cancelButton" destination="kkA-2A-oQc" id="8c3-7h-dBg"/> <outlet property="createSiteButton" destination="r6K-uZ-29W" id="7AY-Mm-WeY"/> + <outlet property="imageView" destination="eYR-bH-2jT" id="1KO-P6-LzI"/> <outlet property="subTitleLabel" destination="qaw-pG-rKU" id="JPQ-AV-SRz"/> <outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/> <outlet property="welcomeLabel" destination="4Zg-pY-BXB" id="Cjg-0o-iCM"/> @@ -29,7 +30,7 @@ <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="aWd-hf-9Yb"> <rect key="frame" x="0.0" y="280" width="327" height="164"/> <subviews> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="r6K-uZ-29W" customClass="NUXButton" customModule="WordPressAuthenticator"> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="r6K-uZ-29W" customClass="NUXButton" customModule="WordPressAuthenticator"> <rect key="frame" x="0.0" y="0.0" width="327" height="44"/> <constraints> <constraint firstAttribute="height" constant="44" id="umu-lG-4yV"/> @@ -40,7 +41,7 @@ <action selector="createSite:" destination="-1" eventType="touchUpInside" id="PvC-HP-RQK"/> </connections> </button> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="JzC-d5-c3A" customClass="NUXButton" customModule="WordPressAuthenticator"> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="JzC-d5-c3A" customClass="NUXButton" customModule="WordPressAuthenticator"> <rect key="frame" x="0.0" y="60" width="327" height="44"/> <constraints> <constraint firstAttribute="height" constant="44" id="xFj-m5-SMx"/> @@ -51,7 +52,7 @@ <action selector="addSelfHosted:" destination="-1" eventType="touchUpInside" id="igX-Gf-1No"/> </connections> </button> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="kkA-2A-oQc"> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="kkA-2A-oQc"> <rect key="frame" x="0.0" y="120" width="327" height="44"/> <constraints> <constraint firstAttribute="height" constant="44" id="Q2e-2m-ELz"/> @@ -98,6 +99,7 @@ </constraints> </view> </subviews> + <viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <constraints> <constraint firstItem="Vgl-ir-q3p" firstAttribute="centerX" secondItem="i5M-Pr-FkT" secondAttribute="centerX" id="4cW-ud-JFW"/> @@ -105,7 +107,6 @@ <constraint firstItem="Vgl-ir-q3p" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" priority="999" constant="24" id="GMi-uW-igj"/> <constraint firstItem="Vgl-ir-q3p" firstAttribute="centerY" secondItem="i5M-Pr-FkT" secondAttribute="centerY" id="k5v-HX-Ggv"/> </constraints> - <viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/> <point key="canvasLocation" x="-2445" y="-60"/> </view> </objects> diff --git a/WordPress/Classes/ViewRelated/NUX/SignupEpilogue.storyboard b/WordPress/Classes/ViewRelated/NUX/SignupEpilogue.storyboard index 8fbb7be89912..ffd6f61b766e 100644 --- a/WordPress/Classes/ViewRelated/NUX/SignupEpilogue.storyboard +++ b/WordPress/Classes/ViewRelated/NUX/SignupEpilogue.storyboard @@ -1,12 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14113" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="e6u-CC-aMV"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="e6u-CC-aMV"> + <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/> - <capability name="Constraints to layout margins" minToolsVersion="6.0"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> @@ -16,11 +13,11 @@ <objects> <viewController storyboardIdentifier="usernames" id="CFB-8a-SBe" customClass="SignupUsernameViewController" customModule="WordPress" customModuleProvider="target" sceneMemberID="viewController"> <view key="view" contentMode="scaleToFill" id="mFL-Rq-P6f"> - <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="647"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> <containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="tCm-cu-ulI"> - <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <rect key="frame" x="0.0" y="-20" width="375" height="667"/> <connections> <segue destination="QJL-0i-O1l" kind="embed" id="9tJ-oY-a4R"/> </connections> @@ -68,34 +65,50 @@ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> <containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="egq-OL-lqe" userLabel="Table Container View"> - <rect key="frame" x="0.0" y="20" width="375" height="563"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="583"/> <connections> <segue destination="wAQ-AF-rOn" kind="embed" identifier="showEpilogueTable" id="PlO-Bm-BvC"/> </connections> </containerView> - <containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="240-cE-xSD" userLabel="Button Container View"> - <rect key="frame" x="0.0" y="583" width="375" height="84"/> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" adjustsImageWhenHighlighted="NO" adjustsImageWhenDisabled="NO" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="atb-5g-t4d" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="20" y="603" width="335" height="44"/> + <accessibility key="accessibilityConfiguration" identifier="continueToSites"/> <constraints> - <constraint firstAttribute="height" constant="84" placeholder="YES" id="4ne-6n-cLh"/> - <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="84" id="dqd-0X-vaL"/> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="44" id="4AO-og-Qy8"/> </constraints> - </containerView> + <fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/> + <state key="normal" title="Done"> + <color key="titleColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </state> + <state key="selected" backgroundImage="beveled-blue-button-down"/> + <state key="highlighted" backgroundImage="beveled-blue-button-down"> + <color key="titleColor" white="1" alpha="1" colorSpace="calibratedWhite"/> + </state> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="YES"/> + </userDefinedRuntimeAttributes> + <connections> + <action selector="doneButtonPressed" destination="e6u-CC-aMV" eventType="touchUpInside" id="hmp-NW-iMZ"/> + </connections> + </button> </subviews> <color key="backgroundColor" red="0.91372549020000005" green="0.93725490199999995" blue="0.95294117649999999" alpha="1" colorSpace="custom" customColorSpace="displayP3"/> <constraints> - <constraint firstItem="240-cE-xSD" firstAttribute="top" secondItem="egq-OL-lqe" secondAttribute="bottom" id="6xk-4S-cWw"/> - <constraint firstItem="240-cE-xSD" firstAttribute="leading" secondItem="Zhy-FD-gA0" secondAttribute="leading" id="Jll-EX-f1p"/> + <constraint firstItem="atb-5g-t4d" firstAttribute="top" secondItem="egq-OL-lqe" secondAttribute="bottom" constant="20" id="A2e-QJ-VPZ"/> + <constraint firstItem="Zhy-FD-gA0" firstAttribute="bottom" secondItem="atb-5g-t4d" secondAttribute="bottom" constant="20" id="DX5-KQ-d7O"/> <constraint firstItem="egq-OL-lqe" firstAttribute="leading" secondItem="Zhy-FD-gA0" secondAttribute="leading" id="Ke8-3y-IW1"/> <constraint firstItem="Zhy-FD-gA0" firstAttribute="top" secondItem="egq-OL-lqe" secondAttribute="top" id="VpG-I9-eZL"/> - <constraint firstItem="240-cE-xSD" firstAttribute="trailing" secondItem="Zhy-FD-gA0" secondAttribute="trailing" id="Yrw-fx-rz4"/> <constraint firstItem="Zhy-FD-gA0" firstAttribute="trailing" secondItem="egq-OL-lqe" secondAttribute="trailing" id="drs-fr-MgU"/> - <constraint firstItem="240-cE-xSD" firstAttribute="bottom" secondItem="YP6-Jc-5Xa" secondAttribute="bottom" id="nht-aN-2J4"/> + <constraint firstItem="egq-OL-lqe" firstAttribute="trailing" secondItem="atb-5g-t4d" secondAttribute="trailing" constant="20" id="eR1-Ec-XkA"/> + <constraint firstItem="atb-5g-t4d" firstAttribute="leading" secondItem="egq-OL-lqe" secondAttribute="leading" constant="20" id="n1E-2N-8ez"/> </constraints> <viewLayoutGuide key="safeArea" id="Zhy-FD-gA0"/> </view> <connections> - <outlet property="buttonViewContainer" destination="240-cE-xSD" id="JhA-hU-tpt"/> - <segue destination="CFB-8a-SBe" kind="show" identifier="showUsernames" id="39l-r4-qyx"/> + <outlet property="doneButton" destination="atb-5g-t4d" id="JdZ-x9-aXK"/> + <outlet property="tableViewLeadingConstraint" destination="Ke8-3y-IW1" id="gra-zm-3j4"/> + <outlet property="tableViewTrailingConstraint" destination="drs-fr-MgU" id="1WO-CG-vgL"/> + <segue destination="CFB-8a-SBe" kind="show" identifier="SignupUsernameViewController" id="39l-r4-qyx"/> </connections> </viewController> <placeholder placeholderIdentifier="IBFirstResponder" id="jgc-gi-57Y" userLabel="First Responder" sceneMemberID="firstResponder"/> @@ -106,10 +119,9 @@ <scene sceneID="GXq-Sg-B5O"> <objects> <tableViewController id="wAQ-AF-rOn" customClass="SignupEpilogueTableViewController" customModule="WordPress" customModuleProvider="target" sceneMemberID="viewController"> - <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="ACZ-Xu-JZ4"> - <rect key="frame" x="0.0" y="0.0" width="375" height="563"/> + <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="none" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="ACZ-Xu-JZ4"> + <rect key="frame" x="0.0" y="0.0" width="375" height="583"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> - <color key="backgroundColor" cocoaTouchSystemColor="groupTableViewBackgroundColor"/> <inset key="separatorInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/> <connections> <outlet property="dataSource" destination="wAQ-AF-rOn" id="Pe8-CE-a7F"/> @@ -122,4 +134,7 @@ <point key="canvasLocation" x="1009" y="351"/> </scene> </scenes> + <resources> + <image name="beveled-blue-button-down" width="18.5" height="19.5"/> + </resources> </document> diff --git a/WordPress/Classes/ViewRelated/NUX/SignupEpilogueCell.swift b/WordPress/Classes/ViewRelated/NUX/SignupEpilogueCell.swift index 303de2c5a47a..86ded546898e 100644 --- a/WordPress/Classes/ViewRelated/NUX/SignupEpilogueCell.swift +++ b/WordPress/Classes/ViewRelated/NUX/SignupEpilogueCell.swift @@ -5,10 +5,9 @@ import WordPressAuthenticator protocol SignupEpilogueCellDelegate { func updated(value: String, forType: EpilogueCellType) func changed(value: String, forType: EpilogueCellType) - func usernameSelected() } -enum EpilogueCellType { +enum EpilogueCellType: Int { case displayName case username case password @@ -21,11 +20,32 @@ class SignupEpilogueCell: UITableViewCell { @IBOutlet weak var cellLabel: UILabel! @IBOutlet weak var cellField: LoginTextField! + // Used to layout cellField when cellLabel is shown or hidden. + @IBOutlet var cellFieldLeadingConstraintWithLabel: NSLayoutConstraint! + @IBOutlet var cellFieldLeadingConstraintWithoutLabel: NSLayoutConstraint! + + // Used to layout cellField when disclosure icon is shown or hidden. + @IBOutlet var cellFieldTrailingConstraint: NSLayoutConstraint! + private var cellFieldTrailingMarginDefault: CGFloat = 0 + private let cellFieldTrailingMarginDisclosure: CGFloat = 10 + + // Used to inset the separator lines. + @IBOutlet var cellLabelLeadingConstraint: NSLayoutConstraint! + + // Used to apply a top margin to the Password field. + @IBOutlet var cellFieldTopConstraint: NSLayoutConstraint! + private let passwordTopMargin: CGFloat = 16 + private var cellType: EpilogueCellType? open var delegate: SignupEpilogueCellDelegate? // MARK: - UITableViewCell + override func awakeFromNib() { + super.awakeFromNib() + cellFieldTrailingMarginDefault = cellFieldTrailingConstraint.constant + } + override func prepareForReuse() { super.prepareForReuse() @@ -64,28 +84,83 @@ class SignupEpilogueCell: UITableViewCell { // MARK: - Public Methods func configureCell(forType newCellType: EpilogueCellType, - labelText: String, + labelText: String? = nil, fieldValue: String? = nil, fieldPlaceholder: String? = nil) { + cellType = newCellType + cellLabel.text = labelText cellLabel.textColor = .text cellField.text = fieldValue - cellField.textColor = .text cellField.placeholder = fieldPlaceholder cellField.delegate = self - cellField.isSecureTextEntry = (cellType == .password) + + configureForPassword() + selectionStyle = .none configureAccessoryType(for: newCellType) configureTextContentTypeIfNeeded(for: newCellType) configureAccessibility(for: newCellType) + configureEditable(for: newCellType) + configureKeyboardReturnKey(for: newCellType) + + addBottomBorder(withColor: .divider, leadingMargin: cellLabelLeadingConstraint.constant) + + // TODO: remove this when `WordPressAuthenticatorStyle:textFieldBackgroundColor` is updated. + // This background color should be inherited from LoginTextField. + // However, since the Auth views haven't been updated, the color is incorrect. + // So for now we'll override it here. + cellField.backgroundColor = .basicBackground + } + +} + +extension SignupEpilogueCell: UITextFieldDelegate { + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let cellType = cellType, + let originalText = textField.text, + cellType == .displayName || cellType == .password { + let updatedText = NSString(string: originalText).replacingCharacters(in: range, with: string) + delegate?.changed(value: updatedText, forType: cellType) + } + + return true + } + + func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { + if let cellType = cellType, + let updatedText = textField.text { + delegate?.updated(value: updatedText, forType: cellType) + } + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + cellField.endEditing(true) + return true } +} + +private extension SignupEpilogueCell { - // MARK: - Private behavior + func configureForPassword() { + let isPassword = (cellType == .password) + cellLabel.isHidden = isPassword - private func configureAccessibility(for cellType: EpilogueCellType) { + cellField.isSecureTextEntry = isPassword + cellField.showSecureTextEntryToggle = isPassword + cellField.textAlignment = isPassword ? .left : .right + cellField.textColor = isPassword ? .text : .textSubtle + + cellFieldLeadingConstraintWithLabel.isActive = !isPassword + cellFieldLeadingConstraintWithoutLabel.isActive = isPassword + cellFieldTopConstraint.constant = isPassword ? passwordTopMargin : 0 + } + + func configureAccessibility(for cellType: EpilogueCellType) { if cellType == .username { accessibilityTraits.insert(.button) // selection transitions to SignupUsernameViewController isAccessibilityElement = true // this assures double-tap properly captures cell selection @@ -98,22 +173,21 @@ class SignupEpilogueCell: UITableViewCell { cellField.accessibilityIdentifier = "Username Field" case .password: cellField.accessibilityIdentifier = "Password Field" + cellLabel.isAccessibilityElement = false } } - private func configureAccessoryType(for cellType: EpilogueCellType) { + func configureAccessoryType(for cellType: EpilogueCellType) { if cellType == .username { accessoryType = .disclosureIndicator + cellFieldTrailingConstraint.constant = cellFieldTrailingMarginDisclosure } else { accessoryType = .none + cellFieldTrailingConstraint.constant = cellFieldTrailingMarginDefault } } - private func configureTextContentTypeIfNeeded(for cellType: EpilogueCellType) { - guard #available(iOS 12, *) else { - return - } - + func configureTextContentTypeIfNeeded(for cellType: EpilogueCellType) { switch cellType { case .displayName: cellField.textContentType = .name @@ -123,38 +197,19 @@ class SignupEpilogueCell: UITableViewCell { cellField.textContentType = .newPassword } } -} - - -extension SignupEpilogueCell: UITextFieldDelegate { - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - if let cellType = cellType, cellType == .displayName || cellType == .password { - let updatedText = NSString(string: textField.text!).replacingCharacters(in: range, with: string) - delegate?.changed(value: updatedText, forType: cellType) - } - - return true - } - - func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { - if let cellType = cellType, - let updatedText = textField.text { - delegate?.updated(value: updatedText, forType: cellType) + func configureEditable(for cellType: EpilogueCellType) { + if cellType == .username { + cellField.isEnabled = false + } else { + cellField.isEnabled = true } } - func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { - if let cellType = cellType, - cellType == .username { - delegate?.usernameSelected() - return false + func configureKeyboardReturnKey(for cellType: EpilogueCellType) { + if cellType == .displayName { + cellField.enablesReturnKeyAutomatically = true } - return true } - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - cellField.endEditing(true) - return true - } } diff --git a/WordPress/Classes/ViewRelated/NUX/SignupEpilogueCell.xib b/WordPress/Classes/ViewRelated/NUX/SignupEpilogueCell.xib index feb81b63695b..73e7ca33856d 100644 --- a/WordPress/Classes/ViewRelated/NUX/SignupEpilogueCell.xib +++ b/WordPress/Classes/ViewRelated/NUX/SignupEpilogueCell.xib @@ -1,11 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="13771" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13772"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> @@ -15,43 +13,59 @@ <rect key="frame" x="0.0" y="0.0" width="375" height="44"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Xj3-oP-MOq" id="j3X-VY-JUT"> - <rect key="frame" x="0.0" y="0.0" width="375" height="43.5"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="44"/> <autoresizingMask key="autoresizingMask"/> <subviews> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="IPK-b3-7Fi"> - <rect key="frame" x="10" y="0.0" width="42" height="43.5"/> + <rect key="frame" x="20" y="0.0" width="35.5" height="44"/> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> - <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="calibratedRGB"/> + <color key="textColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> </label> <textField opaque="NO" clipsSubviews="YES" contentMode="scaleToFill" semanticContentAttribute="forceLeftToRight" contentHorizontalAlignment="left" contentVerticalAlignment="center" textAlignment="right" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="BeK-ke-PFi" customClass="LoginTextField" customModule="WordPressAuthenticator"> - <rect key="frame" x="62" y="0.0" width="303" height="43.5"/> + <rect key="frame" x="55.5" y="0.0" width="299.5" height="44"/> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <accessibility key="accessibilityConfiguration" identifier="nuxEmailField"/> - <color key="textColor" red="0.1803921568627451" green="0.26666666666666666" blue="0.32549019607843138" alpha="1" colorSpace="calibratedRGB"/> + <color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> - <textInputTraits key="textInputTraits" autocorrectionType="no" returnKeyType="done" enablesReturnKeyAutomatically="YES"/> + <textInputTraits key="textInputTraits" autocorrectionType="no" returnKeyType="done"/> <userDefinedRuntimeAttributes> <userDefinedRuntimeAttribute type="boolean" keyPath="showTopLineSeparator" value="NO"/> </userDefinedRuntimeAttributes> </textField> </subviews> <constraints> - <constraint firstItem="BeK-ke-PFi" firstAttribute="leading" secondItem="IPK-b3-7Fi" secondAttribute="trailing" constant="10" id="E6f-YY-zY1"/> + <constraint firstItem="BeK-ke-PFi" firstAttribute="leading" secondItem="IPK-b3-7Fi" secondAttribute="trailing" id="E6f-YY-zY1"/> <constraint firstAttribute="bottom" secondItem="IPK-b3-7Fi" secondAttribute="bottom" id="OHt-cu-IFp"/> - <constraint firstItem="IPK-b3-7Fi" firstAttribute="top" secondItem="j3X-VY-JUT" secondAttribute="top" id="TVg-hg-tb6"/> - <constraint firstItem="IPK-b3-7Fi" firstAttribute="leading" secondItem="j3X-VY-JUT" secondAttribute="leading" constant="10" id="adA-oh-hDL"/> <constraint firstAttribute="bottom" secondItem="BeK-ke-PFi" secondAttribute="bottom" id="al2-lo-1kk"/> - <constraint firstAttribute="trailing" secondItem="BeK-ke-PFi" secondAttribute="trailing" constant="10" id="nx1-DQ-7CM"/> + <constraint firstItem="IPK-b3-7Fi" firstAttribute="top" secondItem="BeK-ke-PFi" secondAttribute="top" id="edJ-SL-RYu"/> + <constraint firstItem="BeK-ke-PFi" firstAttribute="leading" secondItem="j3X-VY-JUT" secondAttribute="leading" constant="12" id="hbw-Vo-dIm"/> + <constraint firstItem="IPK-b3-7Fi" firstAttribute="leading" secondItem="j3X-VY-JUT" secondAttribute="leading" constant="20" id="hj5-JA-DrQ"/> + <constraint firstAttribute="trailing" secondItem="BeK-ke-PFi" secondAttribute="trailing" constant="20" id="nx1-DQ-7CM"/> <constraint firstItem="BeK-ke-PFi" firstAttribute="top" secondItem="j3X-VY-JUT" secondAttribute="top" id="qjh-pQ-X5J"/> </constraints> + <variation key="default"> + <mask key="constraints"> + <exclude reference="hbw-Vo-dIm"/> + </mask> + </variation> </tableViewCellContentView> <connections> <outlet property="cellField" destination="BeK-ke-PFi" id="jXq-d3-n8z"/> + <outlet property="cellFieldLeadingConstraintWithLabel" destination="E6f-YY-zY1" id="Jul-fb-CwR"/> + <outlet property="cellFieldLeadingConstraintWithoutLabel" destination="hbw-Vo-dIm" id="G0A-xS-DO4"/> + <outlet property="cellFieldTopConstraint" destination="qjh-pQ-X5J" id="Fnd-0I-Qj1"/> + <outlet property="cellFieldTrailingConstraint" destination="nx1-DQ-7CM" id="07I-Dc-Ij6"/> <outlet property="cellLabel" destination="IPK-b3-7Fi" id="zEA-to-D3Z"/> + <outlet property="cellLabelLeadingConstraint" destination="hj5-JA-DrQ" id="Owe-3a-Gi4"/> </connections> <point key="canvasLocation" x="-298.5" y="-176"/> </tableViewCell> </objects> + <designables> + <designable name="BeK-ke-PFi"> + <size key="intrinsicContentSize" width="4" height="18.5"/> + </designable> + </designables> </document> diff --git a/WordPress/Classes/ViewRelated/NUX/SignupEpilogueTableViewController.swift b/WordPress/Classes/ViewRelated/NUX/SignupEpilogueTableViewController.swift index e2688cc8861e..9a16b374cfb8 100644 --- a/WordPress/Classes/ViewRelated/NUX/SignupEpilogueTableViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/SignupEpilogueTableViewController.swift @@ -17,7 +17,7 @@ protocol SignupEpilogueTableViewControllerDataSource { var username: String? { get } } -class SignupEpilogueTableViewController: NUXTableViewController, EpilogueUserInfoCellViewControllerProvider { +class SignupEpilogueTableViewController: UITableViewController, EpilogueUserInfoCellViewControllerProvider { // MARK: - Properties @@ -31,37 +31,11 @@ class SignupEpilogueTableViewController: NUXTableViewController, EpilogueUserInf private var showPassword: Bool = true private var reloaded: Bool = false - private struct Constants { - static let numberOfSections = 3 - static let namesSectionRows = 2 - static let sectionRows = 1 - static let headerFooterHeight: CGFloat = 50 - } - - private struct TableSections { - static let userInfo = 0 - static let names = 1 - static let password = 2 - } - - private struct CellIdentifiers { - static let sectionHeaderFooter = "SectionHeaderFooter" - static let signupEpilogueCell = "SignupEpilogueCell" - static let epilogueUserInfoCell = "userInfo" - } - - private struct CellNibNames { - static let sectionHeaderFooter = "EpilogueSectionHeaderFooter" - static let signupEpilogueCell = "SignupEpilogueCell" - static let epilogueUserInfoCell = "EpilogueUserInfoCell" - } - // MARK: - View override func viewDidLoad() { super.viewDidLoad() - - view.backgroundColor = .listBackground + view.backgroundColor = .basicBackground } override func viewWillAppear(_ animated: Bool) { @@ -78,51 +52,54 @@ class SignupEpilogueTableViewController: NUXTableViewController, EpilogueUserInf // MARK: - Table view data source override func numberOfSections(in tableView: UITableView) -> Int { - return showPassword == true ? Constants.numberOfSections : Constants.numberOfSections - 1 + return Constants.numberOfSections } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if section == TableSections.names { - return Constants.namesSectionRows + + guard section != TableSections.userInfo else { + return Constants.userInfoRows } - return Constants.sectionRows + return showPassword ? Constants.allAccountRows : Constants.noPasswordRows } override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - var sectionTitle = "" - if section == TableSections.userInfo { - sectionTitle = NSLocalizedString("New Account", comment: "Header for user info, shown after account created.").localizedUppercase + // Don't show section header for User Info + guard section != TableSections.userInfo, + let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: CellIdentifiers.sectionHeaderFooter) as? EpilogueSectionHeaderFooter else { + return nil } - guard let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: CellIdentifiers.sectionHeaderFooter) as? EpilogueSectionHeaderFooter else { - fatalError("Failed to get a section header cell") - } - cell.titleLabel?.text = sectionTitle + cell.titleLabel?.text = NSLocalizedString("Account Details", comment: "Header for account details, shown after signing up.").localizedUppercase cell.titleLabel?.accessibilityIdentifier = "New Account Header" + cell.accessibilityLabel = cell.titleLabel?.text + return cell } override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - if section == TableSections.password { - guard let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: CellIdentifiers.sectionHeaderFooter) as? EpilogueSectionHeaderFooter else { - fatalError("Failed to get a section footer cell") - } - cell.titleLabel?.numberOfLines = 0 - cell.titleLabel?.text = NSLocalizedString("You can always log in with a magic link like the one you just used, but you can also set up a password if you prefer.", comment: "Information shown below the optional password field after new account creation.") - - return cell + guard section != TableSections.userInfo, + showPassword, + let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: CellIdentifiers.sectionHeaderFooter) as? EpilogueSectionHeaderFooter else { + return nil } - return nil + cell.titleLabel?.numberOfLines = 0 + cell.topConstraint.constant = Constants.footerTopMargin + cell.titleLabel?.text = NSLocalizedString("You can always log in with a link like the one you just used, but you can also set up a password if you prefer.", comment: "Information shown below the optional password field after new account creation.") + cell.accessibilityLabel = cell.titleLabel?.text + + return cell } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + // User Info Row if indexPath.section == TableSections.userInfo { guard let cell = tableView.dequeueReusableCell(withIdentifier: CellIdentifiers.epilogueUserInfoCell) as? EpilogueUserInfoCell else { - fatalError("Failed to get a user info cell") + return UITableViewCell() } if let epilogueUserInfo = epilogueUserInfo { @@ -133,29 +110,13 @@ class SignupEpilogueTableViewController: NUXTableViewController, EpilogueUserInf return cell } - if indexPath.section == TableSections.names { - if indexPath.row == 0 { - return getEpilogueCellFor(cellType: .displayName) - } - - if indexPath.row == 1 { - return getEpilogueCellFor(cellType: .username) - } + // Account Details Rows + guard let cellType = EpilogueCellType(rawValue: indexPath.row) else { + return UITableViewCell() } - if indexPath.section == TableSections.password { - return getEpilogueCellFor(cellType: .password) - } + return getEpilogueCellFor(cellType: cellType) - return super.tableView(tableView, cellForRowAt: indexPath) - } - - override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard cell is EpilogueUserInfoCell else { - return - } - - cell.contentView.backgroundColor = .listForeground } override func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat { @@ -163,7 +124,7 @@ class SignupEpilogueTableViewController: NUXTableViewController, EpilogueUserInf } override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return UITableView.automaticDimension + return section == TableSections.userInfo ? 0 : UITableView.automaticDimension } override func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat { @@ -171,10 +132,18 @@ class SignupEpilogueTableViewController: NUXTableViewController, EpilogueUserInf } override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - if section == TableSections.password { - return UITableView.automaticDimension + guard section != TableSections.userInfo, showPassword else { + return 0 + } + + return UITableView.automaticDimension + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let cellType = EpilogueCellType(rawValue: indexPath.row), + cellType == .username { + delegate?.usernameTapped(userInfo: epilogueUserInfo) } - return CGFloat.leastNormalMagnitude } } @@ -194,6 +163,7 @@ private extension SignupEpilogueTableViewController { tableView.register(userInfoNib, forCellReuseIdentifier: CellIdentifiers.epilogueUserInfoCell) WPStyleGuide.configureColors(view: view, tableView: tableView) + tableView.backgroundColor = .basicBackground // remove empty cells tableView.tableFooterView = UIView() @@ -201,8 +171,7 @@ private extension SignupEpilogueTableViewController { func getUserInfo() { - let service = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext) - guard let account = service.defaultWordPressComAccount() else { + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) else { return } @@ -214,7 +183,7 @@ private extension SignupEpilogueTableViewController { if let customDisplayName = dataSource?.customDisplayName { userInfo.fullName = customDisplayName } else { - let autoDisplayName = generateDisplayName(from: userInfo.email) + let autoDisplayName = Self.generateDisplayName(from: userInfo.email) userInfo.fullName = autoDisplayName delegate?.displayNameAutoGenerated(newDisplayName: autoDisplayName) } @@ -222,24 +191,9 @@ private extension SignupEpilogueTableViewController { epilogueUserInfo = userInfo } - private func generateDisplayName(from rawEmail: String) -> String { - // step 1: lower case - let email = rawEmail.lowercased() - // step 2: remove the @ and everything after - let localPart = email.split(separator: "@")[0] - // step 3: remove all non-alpha characters - let localCleaned = localPart.replacingOccurrences(of: "[^A-Za-z/.]", with: "", options: .regularExpression) - // step 4: turn periods into spaces - let nameLowercased = localCleaned.replacingOccurrences(of: ".", with: " ") - // step 5: capitalize - let autoDisplayName = nameLowercased.capitalized - - return autoDisplayName - } - func getEpilogueCellFor(cellType: EpilogueCellType) -> SignupEpilogueCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: CellIdentifiers.signupEpilogueCell) as? SignupEpilogueCell else { - fatalError("Failed to get epilogue cell") + return SignupEpilogueCell() } switch cellType { @@ -253,15 +207,39 @@ private extension SignupEpilogueTableViewController { fieldValue: dataSource?.username ?? epilogueUserInfo?.username) case .password: cell.configureCell(forType: .password, - labelText: NSLocalizedString("Password", comment: "Password label text."), fieldValue: dataSource?.password, - fieldPlaceholder: NSLocalizedString("Optional", comment: "Password field placeholder text")) + fieldPlaceholder: NSLocalizedString("Password (optional)", comment: "Password field placeholder text")) } cell.delegate = self return cell } + struct Constants { + static let numberOfSections = 2 + static let userInfoRows = 1 + static let noPasswordRows = 2 + static let allAccountRows = 3 + static let headerFooterHeight: CGFloat = 50 + static let footerTrailingMargin: CGFloat = 16 + static let footerTopMargin: CGFloat = 8 + } + + struct TableSections { + static let userInfo = 0 + } + + struct CellIdentifiers { + static let sectionHeaderFooter = "SectionHeaderFooter" + static let signupEpilogueCell = "SignupEpilogueCell" + static let epilogueUserInfoCell = "userInfo" + } + + struct CellNibNames { + static let sectionHeaderFooter = "EpilogueSectionHeaderFooter" + static let signupEpilogueCell = "SignupEpilogueCell" + static let epilogueUserInfoCell = "EpilogueUserInfoCell" + } } // MARK: - SignupEpilogueCellDelegate @@ -288,8 +266,34 @@ extension SignupEpilogueTableViewController: SignupEpilogueCellDelegate { } } - func usernameSelected() { - delegate?.usernameTapped(userInfo: epilogueUserInfo) - } +} +extension SignupEpilogueTableViewController { + + // Notice that this duplicates almost one-to-one the logic from + // `ZendeskUtils.generateDisplayName(from:)` with the only difference being the method on + // `ZendeskUtils` returns `nil` if there is no "@" in the input. + // + // Later down the track, we might want to merge the two, ideally by updating this code to + // handle a `String?` value. Alternativetly, we could define an `Email` `String` wrapper and + // push the responsibility to validate the input as an email up the chain. + // + // At the time of writing, it was better to ensure the code didn't crash rather than + // restructuring the callsite. + // + // See https://github.com/wordpress-mobile/WordPressAuthenticator-iOS/issues/759 + static func generateDisplayName(from rawEmail: String) -> String { + // step 1: lower case + let email = rawEmail.lowercased() + // step 2: remove the @ and everything after + let localPart = email.split(separator: "@")[0] + // step 3: remove all non-alpha characters + let localCleaned = localPart.replacingOccurrences(of: "[^A-Za-z/.]", with: "", options: .regularExpression) + // step 4: turn periods into spaces + let nameLowercased = localCleaned.replacingOccurrences(of: ".", with: " ") + // step 5: capitalize + let autoDisplayName = nameLowercased.capitalized + + return autoDisplayName + } } diff --git a/WordPress/Classes/ViewRelated/NUX/SignupEpilogueViewController.swift b/WordPress/Classes/ViewRelated/NUX/SignupEpilogueViewController.swift index 32cf3fe461fa..9a5165918be1 100644 --- a/WordPress/Classes/ViewRelated/NUX/SignupEpilogueViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/SignupEpilogueViewController.swift @@ -1,8 +1,11 @@ import SVProgressHUD import WordPressAuthenticator +class SignupEpilogueViewController: UIViewController { -class SignupEpilogueViewController: NUXViewController { + // MARK: - Analytics Tracking + + let tracker = AuthenticatorAnalyticsTracker.shared // MARK: - Public Properties @@ -15,11 +18,7 @@ class SignupEpilogueViewController: NUXViewController { // MARK: - Outlets - @IBOutlet private var buttonViewContainer: UIView! { - didSet { - buttonViewController.move(to: self, into: buttonViewContainer) - } - } + @IBOutlet var doneButton: UIButton! // MARK: - Private Properties @@ -30,28 +29,41 @@ class SignupEpilogueViewController: NUXViewController { private var displayNameAutoGenerated: Bool = false private var changesMade = false - // MARK: - Lazy Properties - - private lazy var buttonViewController: NUXButtonViewController = { - let buttonViewController = NUXButtonViewController.instance() - buttonViewController.delegate = self - buttonViewController.setButtonTitles(primary: ButtonTitles.primary, primaryAccessibilityId: ButtonTitles.primaryAccessibilityId) - buttonViewController.backgroundColor = WordPressAuthenticator.shared.style.viewControllerBackgroundColor - return buttonViewController - }() - + /// Constraints on the table view container. + /// Used to adjust the width on iPad. + @IBOutlet var tableViewLeadingConstraint: NSLayoutConstraint! + @IBOutlet var tableViewTrailingConstraint: NSLayoutConstraint! + private var defaultTableViewMargin: CGFloat = 0 // MARK: - View override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = .basicBackground + defaultTableViewMargin = tableViewLeadingConstraint.constant + configureDoneButton() + setTableViewMargins() + WordPressAuthenticator.track(.signupEpilogueViewed, properties: tracksProperties()) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: false) - view.backgroundColor = .neutral(.shade0) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + setTableViewMargins() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + setTableViewMargins() + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return UIDevice.isPad() ? .all : .portrait } // MARK: - Navigation @@ -75,31 +87,6 @@ class SignupEpilogueViewController: NUXViewController { } } - // MARK: - analytics - - private func tracksProperties() -> [AnyHashable: Any] { - let source: String = { - guard let service = socialService else { - return "email" - } - switch service { - case .google: - return "google" - case .apple: - return "apple" - } - }() - - return ["source": source] - } -} - -// MARK: - NUXButtonViewControllerDelegate - -extension SignupEpilogueViewController: NUXButtonViewControllerDelegate { - func primaryButtonPressed() { - saveChanges() - } } // MARK: - SignupEpilogueTableViewControllerDataSource @@ -133,21 +120,37 @@ extension SignupEpilogueViewController: SignupEpilogueTableViewControllerDelegat } func passwordUpdated(newPassword: String) { - if !newPassword.isEmpty { - updatedPassword = newPassword - } + updatedPassword = newPassword.isEmpty ? nil : newPassword } func usernameTapped(userInfo: LoginEpilogueUserInfo?) { epilogueUserInfo = userInfo - performSegue(withIdentifier: .showUsernames, sender: self) - WordPressAuthenticator.track(.signupEpilogueUsernameTapped, properties: self.tracksProperties()) + performSegue(withIdentifier: SignupUsernameViewController.classNameWithoutNamespaces(), sender: self) + + tracker.track(click: .editUsername, ifTrackingNotEnabled: { + WordPressAuthenticator.track(.signupEpilogueUsernameTapped, properties: self.tracksProperties()) + }) } } // MARK: - Private Extension private extension SignupEpilogueViewController { + + func configureDoneButton() { + doneButton.setTitle(ButtonTitle.title, for: .normal) + doneButton.accessibilityIdentifier = ButtonTitle.accessibilityId + } + + func setTableViewMargins() { + tableViewLeadingConstraint.constant = view.getHorizontalMargin(compactMargin: defaultTableViewMargin) + tableViewTrailingConstraint.constant = view.getHorizontalMargin(compactMargin: defaultTableViewMargin) + } + + @IBAction func doneButtonPressed() { + saveChanges() + } + func saveChanges() { if let newUsername = updatedUsername { SVProgressHUD.show(withStatus: HUDMessages.changingUsername) @@ -165,7 +168,7 @@ private extension SignupEpilogueViewController { self.updatedDisplayName = nil self.saveChanges() } - } else if let newPassword = updatedPassword { + } else if let newPassword = updatedPassword, !newPassword.isEmpty { SVProgressHUD.show(withStatus: HUDMessages.changingPassword) changePassword(to: newPassword) { success, error in if success { @@ -194,26 +197,30 @@ private extension SignupEpilogueViewController { } let context = ContextManager.sharedInstance().mainContext - let accountService = AccountService(managedObjectContext: context) - guard let account = accountService.defaultWordPressComAccount(), - let api = account.wordPressComRestApi else { - navigationController?.popViewController(animated: true) - return + + guard + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context), + let api = account.wordPressComRestApi + else { + navigationController?.popViewController(animated: true) + return } let settingsService = AccountSettingsService(userID: account.userID.intValue, api: api) settingsService.changeUsername(to: newUsername, success: { WordPressAuthenticator.track(.signupEpilogueUsernameUpdateSucceeded, properties: self.tracksProperties()) + finished() }) { WordPressAuthenticator.track(.signupEpilogueUsernameUpdateFailed, properties: self.tracksProperties()) + finished() } } func changeDisplayName(to newDisplayName: String, finished: @escaping (() -> Void)) { let context = ContextManager.sharedInstance().mainContext - guard let defaultAccount = AccountService(managedObjectContext: context).defaultWordPressComAccount(), + guard let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: context), let restApi = defaultAccount.wordPressComRestApi else { finished() return @@ -233,26 +240,38 @@ private extension SignupEpilogueViewController { let context = ContextManager.sharedInstance().mainContext - guard let defaultAccount = AccountService(managedObjectContext: context).defaultWordPressComAccount(), - let restApi = defaultAccount.wordPressComRestApi else { + do { + let defaultAccount = try WPAccount.lookupDefaultWordPressComAccount(in: context) + + guard + let account = defaultAccount, + let restApi = account.wordPressComRestApi + else { finished(false, nil) return - } + } - let accountSettingService = AccountSettingsService(userID: defaultAccount.userID.intValue, api: restApi) + let accountSettingService = AccountSettingsService(userID: account.userID.intValue, api: restApi) + + accountSettingService.updatePassword(newPassword) { success, error in + if success { + WordPressAuthenticator.track(.signupEpiloguePasswordUpdateSucceeded, properties: self.tracksProperties()) + } else { + WordPressAuthenticator.track(.signupEpiloguePasswordUpdateFailed, properties: self.tracksProperties()) + } - accountSettingService.updatePassword(newPassword) { (success, error) in - if success { - WordPressAuthenticator.track(.signupEpiloguePasswordUpdateSucceeded, properties: self.tracksProperties()) - } else { - WordPressAuthenticator.track(.signupEpiloguePasswordUpdateFailed, properties: self.tracksProperties()) + finished(success, error) } - finished(success, error) + } catch let err { + finished(false, err) + return } } func dismissEpilogue() { + tracker.track(click: .continue) + guard let onContinue = self.onContinue else { self.navigationController?.dismiss(animated: true) return @@ -263,39 +282,43 @@ private extension SignupEpilogueViewController { func refreshAccountDetails(finished: @escaping () -> Void) { let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - guard let account = service.defaultWordPressComAccount() else { + + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) else { self.dismissEpilogue() return } - service.updateUserDetails(for: account, success: { () in + AccountService(coreDataStack: ContextManager.sharedInstance()).updateUserDetails(for: account, success: { () in finished() }, failure: { _ in finished() }) } - private func showPasswordError(_ error: Error? = nil) { + func showPasswordError(_ error: Error? = nil) { let errorMessage = error?.localizedDescription ?? HUDMessages.changePasswordGenericError SVProgressHUD.showError(withStatus: errorMessage) } -} -extension SignupEpilogueViewController: SignupUsernameViewControllerDelegate { - func usernameSelected(_ username: String) { - if username.isEmpty || username == epilogueUserInfo?.username { - updatedUsername = nil - } else { - updatedUsername = username - } - } -} + func tracksProperties() -> [AnyHashable: Any] { + let source: String = { + guard let service = socialService else { + return "email" + } + switch service { + case .google: + return "google" + case .apple: + return "apple" + } + }() + return ["source": source] + } -private extension SignupEpilogueViewController { - enum ButtonTitles { - static let primary = NSLocalizedString("Continue", comment: "Button text on site creation epilogue page to proceed to My Sites.") - static let primaryAccessibilityId = "Continue Button" + enum ButtonTitle { + static let title = NSLocalizedString("Done", comment: "Button text on site creation epilogue page to proceed to My Sites.") + // TODO: change UI Test when change this + static let accessibilityId = "Done Button" } enum HUDMessages { @@ -304,6 +327,17 @@ private extension SignupEpilogueViewController { static let changingPassword = NSLocalizedString("Changing password", comment: "Shown while the app waits for the password changing web service to return.") static let changePasswordGenericError = NSLocalizedString("There was an error changing the password", comment: "Text displayed when there is a failure changing the password.") } + +} + +extension SignupEpilogueViewController: SignupUsernameViewControllerDelegate { + func usernameSelected(_ username: String) { + if username.isEmpty || username == epilogueUserInfo?.username { + updatedUsername = nil + } else { + updatedUsername = username + } + } } // MARK: - User Defaults diff --git a/WordPress/Classes/ViewRelated/NUX/SignupUsernameTableViewController.swift b/WordPress/Classes/ViewRelated/NUX/SignupUsernameTableViewController.swift index 6a2e7db20af6..be770ab09c33 100644 --- a/WordPress/Classes/ViewRelated/NUX/SignupUsernameTableViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/SignupUsernameTableViewController.swift @@ -1,8 +1,7 @@ import SVProgressHUD import WordPressAuthenticator - -class SignupUsernameTableViewController: NUXTableViewController, SearchTableViewCellDelegate { +class SignupUsernameTableViewController: UITableViewController, SearchTableViewCellDelegate { open var currentUsername: String? open var displayName: String? open var delegate: SignupUsernameViewControllerDelegate? @@ -11,6 +10,10 @@ class SignupUsernameTableViewController: NUXTableViewController, SearchTableView private var isSearching: Bool = false private var selectedCell: UITableViewCell? + var analyticsSource: String { + return "signup_epilogue" + } + override func awakeFromNib() { super.awakeFromNib() @@ -21,6 +24,8 @@ class SignupUsernameTableViewController: NUXTableViewController, SearchTableView override func viewDidLoad() { super.viewDidLoad() + trackViewLoaded() + WPStyleGuide.configureColors(view: view, tableView: tableView) tableView.layoutMargins = WPStyleGuide.edgeInsetForLoginTextFields() @@ -45,6 +50,8 @@ class SignupUsernameTableViewController: NUXTableViewController, SearchTableView override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) SVProgressHUD.dismiss() + + trackViewDismissed() } func registerNibs() { @@ -86,6 +93,22 @@ class SignupUsernameTableViewController: NUXTableViewController, SearchTableView return description } + // MARK: - Tracking + func trackViewLoaded() { + WPAnalytics.track(.changeUsernameDisplayed, properties: ["source": analyticsSource]) + } + + func trackViewDismissed() { + WPAnalytics.track(.changeUsernameDismissed, properties: ["source": analyticsSource]) + } + + private var searchCount: Int = 0 + func trackSearchPerformed() { + searchCount += 1 + + WPAnalytics.track(.changeUsernameSearchPerformed, properties: ["search_count": searchCount, "source": analyticsSource]) + } + // MARK: - SearchTableViewCellDelegate func startSearch(for searchTerm: String) { @@ -93,6 +116,8 @@ class SignupUsernameTableViewController: NUXTableViewController, SearchTableView return } + trackSearchPerformed() + suggestUsernames(for: searchTerm) { [weak self] suggestions in self?.suggestions = suggestions self?.reloadSections(includingAllSections: false) @@ -188,6 +213,7 @@ extension SignupUsernameTableViewController { cell.placeholder = NSLocalizedString("Type a keyword for more ideas", comment: "Placeholder text for domain search during site creation.") cell.delegate = self cell.selectionStyle = .none + cell.textField.leftViewImage = UIImage(named: "icon-post-search-highlight") return cell } @@ -197,8 +223,13 @@ extension SignupUsernameTableViewController { cell.textLabel?.text = username cell.textLabel?.textColor = .neutral(.shade70) + + cell.textLabel?.numberOfLines = 0 + cell.textLabel?.lineBreakMode = .byCharWrapping + cell.indentationWidth = SuggestionStyles.indentationWidth cell.indentationLevel = SuggestionStyles.indentationLevel + if checked { cell.accessoryType = .checkmark } @@ -213,12 +244,15 @@ extension SignupUsernameTableViewController { isSearching = true let context = ContextManager.sharedInstance().mainContext - let accountService = AccountService(managedObjectContext: context) - guard let account = accountService.defaultWordPressComAccount(), - let api = account.wordPressComRestApi else { - return + + guard + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context), + let api = account.wordPressComRestApi + else { + return } - SVProgressHUD.show(withStatus: NSLocalizedString("Loading usernames", comment: "Shown while the app waits for the username suggestions web service to return during the site creation process.")) + + showLoader() let service = AccountSettingsService(userID: account.userID.intValue, api: api) service.suggestUsernames(base: searchTerm) { [weak self] (newSuggestions) in @@ -226,12 +260,28 @@ extension SignupUsernameTableViewController { WordPressAuthenticator.track(.signupEpilogueUsernameSuggestionsFailed) } self?.isSearching = false - SVProgressHUD.dismiss() + self?.hideLoader() addSuggestions(newSuggestions) } } } +// MARK: - Loader + +extension SignupUsernameTableViewController { + func showLoader() { + searchCell?.showLoader() + } + + func hideLoader() { + searchCell?.hideLoader() + } + + private var searchCell: SearchTableViewCell? { + tableView.cellForRow(at: IndexPath(row: 0, section: 1)) as? SearchTableViewCell + } +} + extension String { private func nsRange(from range: Range<Index>) -> NSRange { let from = range.lowerBound diff --git a/WordPress/Classes/ViewRelated/NUX/SignupUsernameViewController.swift b/WordPress/Classes/ViewRelated/NUX/SignupUsernameViewController.swift index 6b7c8ffb6f83..74b3b20f6bc6 100644 --- a/WordPress/Classes/ViewRelated/NUX/SignupUsernameViewController.swift +++ b/WordPress/Classes/ViewRelated/NUX/SignupUsernameViewController.swift @@ -6,18 +6,13 @@ protocol SignupUsernameViewControllerDelegate { func usernameSelected(_ username: String) } -class SignupUsernameViewController: NUXViewController { +class SignupUsernameViewController: UIViewController { + // MARK: - Properties + open var currentUsername: String? open var displayName: String? open var delegate: SignupUsernameViewControllerDelegate? - - override var sourceTag: WordPressSupportSourceTag { - get { - return .wpComCreateSiteUsername - } - } - private var usernamesTableViewController: SignupUsernameTableViewController? // MARK: - View @@ -28,12 +23,6 @@ class SignupUsernameViewController: NUXViewController { navigationController?.setNavigationBarHidden(false, animated: false) } - private func configureView() { - _ = addHelpButtonToNavController() - navigationItem.title = NSLocalizedString("Change Username", comment: "Change Username title.") - WPStyleGuide.configureColors(view: view, tableView: nil) - } - // MARK: - Segue override func prepare(for segue: UIStoryboardSegue, sender: Any?) { @@ -48,6 +37,29 @@ class SignupUsernameViewController: NUXViewController { } } +// MARK: - Private Extension + +private extension SignupUsernameViewController { + + func configureView() { + navigationItem.title = NSLocalizedString("Change Username", comment: "Change Username title.") + WPStyleGuide.configureColors(view: view, tableView: nil) + + let supportButton = UIBarButtonItem(title: NSLocalizedString("Help", comment: "Help button"), + style: .plain, + target: self, + action: #selector(handleSupportButtonTapped)) + navigationItem.rightBarButtonItem = supportButton + } + + @objc func handleSupportButtonTapped(sender: UIBarButtonItem) { + let supportVC = SupportTableViewController() + supportVC.sourceTag = .wpComCreateSiteUsername + supportVC.showFromTabBar() + } + +} + // MARK: - SignupUsernameTableViewControllerDelegate extension SignupUsernameViewController: SignupUsernameViewControllerDelegate { diff --git a/WordPress/Classes/ViewRelated/NUX/SplashPrologueStyleGuide.swift b/WordPress/Classes/ViewRelated/NUX/SplashPrologueStyleGuide.swift new file mode 100644 index 000000000000..c571837503df --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/SplashPrologueStyleGuide.swift @@ -0,0 +1,51 @@ +import Foundation +import SwiftUI +import WordPressAuthenticator + +struct SplashPrologueStyleGuide { + static let backgroundColor = UIColor(light: .colorFromHex("F6F7F7"), dark: .colorFromHex("2C3338")) + + struct Title { + static let font = Font.custom("EBGaramond-Regular", size: 25) + static let textColor = UIColor(light: .colorFromHex("101517"), dark: .white) + } + + struct BrushStroke { + static let color = UIColor(light: .colorFromHex("BBE0FA"), dark: .colorFromHex("101517")).withAlphaComponent(0.3) + } + + /// Use the same shade for light and dark modes + private static let primaryButtonColor: UIColor = .muriel(color: .primary) + .resolvedColor(with: UITraitCollection(userInterfaceStyle: .light)) + private static let primaryButtonHighlightedColor: UIColor = .muriel(color: .primary, .shade60) + .resolvedColor(with: UITraitCollection(userInterfaceStyle: .light)) + + private static let secondaryButtonColor: UIColor = .white + private static let secondaryButtonHighlightedColor: UIColor = .muriel(color: .gray, .shade5) + + static let primaryButtonStyle = NUXButtonStyle( + normal: .init(backgroundColor: Self.primaryButtonColor, + borderColor: Self.primaryButtonColor, + titleColor: .white), + + highlighted: .init(backgroundColor: Self.primaryButtonHighlightedColor, + borderColor: Self.primaryButtonHighlightedColor, + titleColor: .white), + + disabled: .init(backgroundColor: .white, + borderColor: .white, + titleColor: Self.backgroundColor)) + + static let secondaryButtonStyle = NUXButtonStyle( + normal: .init(backgroundColor: Self.secondaryButtonColor, + borderColor: Self.secondaryButtonHighlightedColor, + titleColor: .black), + + highlighted: .init(backgroundColor: Self.secondaryButtonHighlightedColor, + borderColor: Self.secondaryButtonHighlightedColor, + titleColor: .black), + + disabled: .init(backgroundColor: .white, + borderColor: .white, + titleColor: Self.backgroundColor)) +} diff --git a/WordPress/Classes/ViewRelated/NUX/SplashPrologueView.swift b/WordPress/Classes/ViewRelated/NUX/SplashPrologueView.swift new file mode 100644 index 000000000000..e23ae43bd74e --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/SplashPrologueView.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct SplashPrologueView: View { + + var body: some View { + ZStack { + Color(SplashPrologueStyleGuide.backgroundColor) + GeometryReader { proxy in + Image("splashBrushStroke") + .resizable() + .scaledToFill() + .frame(width: Constants.splashBrushWidth) + .offset(x: (proxy.size.width - Constants.splashBrushWidth)/2) + .offset(x: Constants.splashBrushOffset.x, y: Constants.splashBrushOffset.y) + .foregroundColor(Color(SplashPrologueStyleGuide.BrushStroke.color)) + .accessibility(hidden: true) + } + VStack { + Image("splashLogo") + .resizable() + .frame(width: 50, height: 50) + .padding(10) + .accessibility(hidden: true) + Text(Self.caption) + .multilineTextAlignment(.center) + .font(SplashPrologueStyleGuide.Title.font) + .foregroundColor(Color(SplashPrologueStyleGuide.Title.textColor)) + } + } + .edgesIgnoringSafeArea(.all) + } + + private struct Constants { + static let splashBrushWidth: CGFloat = 179.3 + static let splashBrushOffset: CGPoint = .init(x: 98, y: -71) + } +} + +private extension SplashPrologueView { + static let caption = NSLocalizedString( + "wordpress.prologue.splash.caption", + value: """ + Write, edit, and publish + from anywhere. + """, + comment: "Caption displayed during the login flow." + ) +} diff --git a/WordPress/Classes/ViewRelated/NUX/SplashPrologueViewController.swift b/WordPress/Classes/ViewRelated/NUX/SplashPrologueViewController.swift new file mode 100644 index 000000000000..83a1c8052261 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/SplashPrologueViewController.swift @@ -0,0 +1,11 @@ +import Foundation + +class SplashPrologueViewController: UIViewController { + override func viewDidLoad() { + super.viewDidLoad() + + let contentView = UIView.embedSwiftUIView(SplashPrologueView()) + view.addSubview(contentView) + view.pinSubviewToAllEdges(contentView) + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/UIView+Margins.swift b/WordPress/Classes/ViewRelated/NUX/UIView+Margins.swift new file mode 100644 index 000000000000..73f7c8f2d879 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/UIView+Margins.swift @@ -0,0 +1,24 @@ +import UIKit + +extension UIView { + + func getHorizontalMargin(compactMargin: CGFloat = 0.0) -> CGFloat { + guard traitCollection.verticalSizeClass == .regular, + traitCollection.horizontalSizeClass == .regular else { + return compactMargin + } + + let isLandscape = UIDevice.current.orientation.isLandscape + let multiplier: CGFloat = isLandscape ? .ipadLandscape : .ipadPortrait + + return frame.width * multiplier + } + +} + +private extension CGFloat { + + static let ipadPortrait: CGFloat = 0.1667 + static let ipadLandscape: CGFloat = 0.25 + +} diff --git a/WordPress/Classes/ViewRelated/NUX/UnifiedProloguePages.swift b/WordPress/Classes/ViewRelated/NUX/UnifiedProloguePages.swift new file mode 100644 index 000000000000..5841a98af57d --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/UnifiedProloguePages.swift @@ -0,0 +1,247 @@ +import SwiftUI +import UIKit + +enum UnifiedProloguePageType: CaseIterable { + case intro + case editor + case notifications + case analytics + case reader + + var title: String { + switch self { + case .intro: + return NSLocalizedString("Welcome to the world's most popular website builder.", comment: "Caption displayed in promotional screens shown during the login flow.") + case .editor: + return NSLocalizedString("With this powerful editor you can post on the go.", comment: "Caption displayed in promotional screens shown during the login flow.") + case .notifications: + return NSLocalizedString("See comments and notifications in real time.", comment: "Caption displayed in promotional screens shown during the login flow.") + case .analytics: + return NSLocalizedString("Watch your audience grow with in-depth analytics.", comment: "Caption displayed in promotional screens shown during the login flow.") + case .reader: + return NSLocalizedString("Follow your favorite sites and discover new blogs.", comment: "Caption displayed in promotional screens shown during the login flow.") + } + } +} + +/// Simple container for each page of the login prologue. +/// +class UnifiedProloguePageViewController: UIViewController { + + private let titleLabel = UILabel() + + lazy private var contentView: UIView = { + makeContentView() + }() + + private var pageType: UnifiedProloguePageType! + + let mainStackView = UIStackView() + let titleTopSpacer = UIView() + let titleContentSpacer = UIView() + let contentBottomSpacer = UIView() + + var mainStackViewLeadingConstraint: NSLayoutConstraint? + var mainStackViewTrailingConstraint: NSLayoutConstraint? + var mainStackViewAspectConstraint: NSLayoutConstraint? + var mainStackViewCenterAnchor: NSLayoutConstraint? + + init(pageType: UnifiedProloguePageType) { + self.pageType = pageType + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = UIView() + view.backgroundColor = .clear + + titleTopSpacer.translatesAutoresizingMaskIntoConstraints = false + titleContentSpacer.translatesAutoresizingMaskIntoConstraints = false + contentBottomSpacer.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + + configureMainStackView() + + configureTitle() + } + + override func viewDidLoad() { + activateConstraints() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + + guard let previousTraitCollection = previousTraitCollection, + traitCollection.horizontalSizeClass != previousTraitCollection.horizontalSizeClass else { + + return + } + + configureTitleFont() + + if traitCollection.horizontalSizeClass == .compact { + deactivateRegularWidthConstraints() + activateCompactWidthConstraints() + + } else { + deactivateCompactWidthConstraints() + activateRegularWidthConstraints() + } + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + // change the aspect ratio of the content in regular horizontal size class (iPad) depending on the orientation + guard mainStackViewAspectConstraint?.isActive == true else { + return + } + mainStackViewAspectConstraint?.isActive = false + mainStackViewAspectConstraint = mainStackView.heightAnchor.constraint(equalTo: mainStackView.widthAnchor, multiplier: iPadAspectRatio) + mainStackViewAspectConstraint?.isActive = true + } + + private func configureMainStackView() { + mainStackView.axis = .vertical + mainStackView.alignment = .center + mainStackView.translatesAutoresizingMaskIntoConstraints = false + + mainStackView.addArrangedSubviews([titleTopSpacer, + titleLabel, + titleContentSpacer, + contentView, + contentBottomSpacer]) + + view.addSubview(mainStackView) + } + + private func configureTitle() { + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + configureTitleFont() + titleLabel.textColor = .text + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 0 + titleLabel.adjustsFontSizeToFitWidth = true + + titleLabel.text = pageType.title + } + + private func configureTitleFont() { + + guard let fontDescriptor = WPStyleGuide.fontForTextStyle(.title1, fontWeight: .regular).fontDescriptor.withDesign(.serif) else { + return + } + let size: CGFloat = traitCollection.horizontalSizeClass == .compact ? 0.0 : 40.0 + titleLabel.font = UIFontMetrics.default.scaledFont(for: UIFont(descriptor: fontDescriptor, size: size)) + } + + private func activateConstraints() { + + setMainStackViewConstraints() + + let centeredContentViewConstraint = NSLayoutConstraint(item: contentView, + attribute: .centerY, + relatedBy: .equal, + toItem: view, + attribute: .centerY, + multiplier: 1.15, + constant: 0) + centeredContentViewConstraint.priority = .init(999) + + NSLayoutConstraint.activate([contentView.heightAnchor.constraint(equalTo: contentView.widthAnchor), + mainStackView.topAnchor.constraint(equalTo: view.topAnchor), + mainStackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: mainStackView.widthAnchor, multiplier: 0.7), + titleTopSpacer.heightAnchor.constraint(greaterThanOrEqualTo: contentView.heightAnchor, multiplier: 0.1), + titleContentSpacer.heightAnchor.constraint(equalTo: contentView.heightAnchor, multiplier: 0.2), + centeredContentViewConstraint, + titleLabel.widthAnchor.constraint(equalTo: mainStackView.widthAnchor, multiplier: 0.95), + contentBottomSpacer.heightAnchor.constraint(greaterThanOrEqualTo: view.heightAnchor, multiplier: 0.1)]) + + if traitCollection.horizontalSizeClass == .compact { + + activateCompactWidthConstraints() + } else { + + activateRegularWidthConstraints() + } + } + + private func activateRegularWidthConstraints() { + guard let stackViewAspect = mainStackViewAspectConstraint, + let stackViewCenter = mainStackViewCenterAnchor else { + return + } + + NSLayoutConstraint.activate([stackViewAspect, stackViewCenter]) + } + + private func activateCompactWidthConstraints() { + guard let stackViewLeading = mainStackViewLeadingConstraint, + let stackViewTrailing = mainStackViewTrailingConstraint else { + return + } + NSLayoutConstraint.activate([stackViewLeading, stackViewTrailing]) + } + + private func deactivateRegularWidthConstraints() { + guard let stackViewAspect = mainStackViewAspectConstraint, + let stackViewCenter = mainStackViewCenterAnchor else { + return + } + NSLayoutConstraint.deactivate([stackViewAspect, stackViewCenter]) + } + + private func deactivateCompactWidthConstraints() { + guard let stackViewLeading = mainStackViewLeadingConstraint, + let stackViewTrailing = mainStackViewTrailingConstraint else { + return + } + NSLayoutConstraint.deactivate([stackViewLeading, stackViewTrailing]) + } + + private func setMainStackViewConstraints() { + + mainStackViewLeadingConstraint = mainStackView.leadingAnchor.constraint(equalTo: view.leadingAnchor) + mainStackViewTrailingConstraint = mainStackView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + mainStackViewAspectConstraint = mainStackView.heightAnchor.constraint(equalTo: mainStackView.widthAnchor, multiplier: iPadAspectRatio) + mainStackViewCenterAnchor = mainStackView.centerXAnchor.constraint(equalTo: view.centerXAnchor) + } + + // use different aspect ratios on iPad depending on the orientation + private var iPadAspectRatio: CGFloat { + UIDevice.current.orientation.isPortrait ? 1.78 : 1.4 + } + + private func embedSwiftUIView<Content: View>(_ view: Content) -> UIView { + UIView.embedSwiftUIView(view) + } + + private func makeContentView() -> UIView { + switch pageType { + case .intro: + return embedSwiftUIView(UnifiedPrologueIntroContentView()) + case .editor: + return embedSwiftUIView(UnifiedPrologueEditorContentView()) + case .analytics: + return embedSwiftUIView(UnifiedPrologueStatsContentView()) + case .notifications: + return embedSwiftUIView(UnifiedPrologueNotificationsContentView()) + case .reader: + return embedSwiftUIView(UnifiedPrologueReaderContentView()) + default: + return UIView() + } + } + + enum Metrics { + static let topInset: CGFloat = 96.0 + static let horizontalInset: CGFloat = 24.0 + static let titleToContentSpacing: CGFloat = 48.0 + static let heightRatio: CGFloat = WPDeviceIdentification.isiPad() ? 0.5 : 0.4 + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/UnifiedPrologueViewController.swift b/WordPress/Classes/ViewRelated/NUX/UnifiedPrologueViewController.swift new file mode 100644 index 000000000000..bcf79baaee24 --- /dev/null +++ b/WordPress/Classes/ViewRelated/NUX/UnifiedPrologueViewController.swift @@ -0,0 +1,113 @@ +import UIKit +import WordPressAuthenticator +import SwiftUI + +class UnifiedPrologueViewController: UIPageViewController { + + fileprivate var pages: [UIViewController] = [] + + fileprivate var pageControl: UIPageControl! + + fileprivate struct Constants { + static let pagerPadding: CGFloat = 16.0 + } + + init() { + super.init(transitionStyle: .scroll, navigationOrientation: .horizontal) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + dataSource = self + delegate = self + + UnifiedProloguePageType.allCases.forEach({ type in + pages.append(UnifiedProloguePageViewController(pageType: type)) + }) + + setViewControllers([pages[0]], direction: .forward, animated: false) + view.backgroundColor = .prologueBackground + + addPageControl() + let backgroundView = UIView.embedSwiftUIView(UnifiedPrologueBackgroundView()) + view.insertSubview(backgroundView, at: 0) + view.pinSubviewToAllEdges(backgroundView) + } + + private func addPageControl() { + let pageControl = UIPageControl() + pageControl.currentPageIndicatorTintColor = .text + pageControl.pageIndicatorTintColor = .textSubtle + + pageControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(pageControl) + + NSLayoutConstraint.activate([ + pageControl.leftAnchor.constraint(equalTo: view.leftAnchor), + pageControl.rightAnchor.constraint(equalTo: view.rightAnchor), + pageControl.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -Constants.pagerPadding) + ]) + + pageControl.numberOfPages = pages.count + pageControl.addTarget(self, action: #selector(handlePageControlValueChanged(sender:)), for: .valueChanged) + self.pageControl = pageControl + } + + @objc func handlePageControlValueChanged(sender: UIPageControl) { + guard let currentPage = viewControllers?.first, + let currentIndex = pages.firstIndex(of: currentPage) else { + return + } + + let direction: UIPageViewController.NavigationDirection = sender.currentPage > currentIndex ? .forward : .reverse + setViewControllers([pages[sender.currentPage]], direction: direction, animated: true) + WordPressAuthenticator.track(.loginProloguePaged) + } +} + +extension UnifiedPrologueViewController: UIPageViewControllerDataSource { + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let index = pages.firstIndex(of: viewController), + index > 0 else { + return nil + } + + return pages[index - 1] + } + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let index = pages.firstIndex(of: viewController), + index < pages.count - 1 else { + return nil + } + + return pages[index + 1] + } +} + +extension UnifiedPrologueViewController: UIPageViewControllerDelegate { + func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + let toVC = previousViewControllers[0] + guard let index = pages.firstIndex(of: toVC) else { + return + } + if !completed { + pageControl?.currentPage = index + } else { + WordPressAuthenticator.track(.loginProloguePaged) + } + } + + func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { + let toVC = pendingViewControllers[0] + guard let index = pages.firstIndex(of: toVC) else { + return + } + pageControl?.currentPage = index + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/WordPressAuthenticationManager.swift b/WordPress/Classes/ViewRelated/NUX/WordPressAuthenticationManager.swift index d4a4414abd50..a5bcc6074deb 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 @@ -8,6 +9,29 @@ import Gridicons @objc class WordPressAuthenticationManager: NSObject { static var isPresentingSignIn = false + private let windowManager: WindowManager + + /// Allows overriding some WordPressAuthenticator delegate methods + /// without having to reimplement WordPressAuthenticatorDelegate + private let authenticationHandler: AuthenticationHandler? + + private let quickStartSettings: QuickStartSettings + + private let recentSiteService: RecentSitesService + + private let remoteFeaturesStore: RemoteFeatureFlagStore + + init(windowManager: WindowManager, + authenticationHandler: AuthenticationHandler? = nil, + quickStartSettings: QuickStartSettings = QuickStartSettings(), + recentSiteService: RecentSitesService = RecentSitesService(), + remoteFeaturesStore: RemoteFeatureFlagStore) { + self.windowManager = windowManager + self.authenticationHandler = authenticationHandler + self.quickStartSettings = quickStartSettings + self.recentSiteService = recentSiteService + self.remoteFeaturesStore = remoteFeaturesStore + } /// Support is only available to the WordPress iOS App. Our Authentication Framework doesn't have direct access. /// We'll setup a mechanism to relay the Support event back to the Authenticator. @@ -16,57 +40,164 @@ class WordPressAuthenticationManager: NSObject { NotificationCenter.default.addObserver(self, selector: #selector(supportPushNotificationReceived), name: .ZendeskPushNotificationReceivedNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(supportPushNotificationCleared), name: .ZendeskPushNotificationClearedNotification, object: nil) } +} +// MARK: - Initialization Methods +// +extension WordPressAuthenticationManager { /// Initializes WordPressAuthenticator with all of the parameters that will be needed during the login flow. /// func initializeWordPressAuthenticator() { + let displayStrings = WordPressAuthenticatorDisplayStrings( + continueWithWPButtonTitle: AppConstants.Login.continueButtonTitle + ) + + WordPressAuthenticator.initialize(configuration: authenticatorConfiguation(), + style: authenticatorStyle(), + unifiedStyle: unifiedStyle(), + displayStrings: displayStrings) + } + 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 = !(BuildConfiguration.current ~= [.a8cBranchTest, .a8cPrereleaseTesting]) - let configuration = WordPressAuthenticatorConfiguration(wpcomClientId: ApiCredentials.client(), - wpcomSecret: ApiCredentials.secret(), - wpcomScheme: WPComScheme, - wpcomTermsOfServiceURL: WPAutomatticTermsOfServiceURL, - wpcomBaseURL: WordPressComOAuthClient.WordPressComOAuthDefaultBaseUrl, - wpcomAPIBaseURL: Environment.current.wordPressComApiBase, - googleLoginClientId: ApiCredentials.googleLoginClientId(), - googleLoginServerClientId: ApiCredentials.googleLoginServerClientId(), - googleLoginScheme: ApiCredentials.googleLoginSchemeId(), - userAgent: WPUserAgent.wordPress(), - showLoginOptions: true, - enableSignInWithApple: enableSignInWithApple, - enableUnifiedAuth: FeatureFlag.unifiedAuth.enabled) - - let style = WordPressAuthenticatorStyle(primaryNormalBackgroundColor: .primaryButtonBackground, - primaryNormalBorderColor: nil, - primaryHighlightBackgroundColor: .primaryButtonDownBackground, - primaryHighlightBorderColor: nil, - secondaryNormalBackgroundColor: .secondaryButtonBackground, - secondaryNormalBorderColor: .secondaryButtonBorder, - secondaryHighlightBackgroundColor: .secondaryButtonDownBackground, - secondaryHighlightBorderColor: .secondaryButtonDownBorder, - disabledBackgroundColor: .textInverted, - disabledBorderColor: .neutral(.shade10), - primaryTitleColor: .white, - secondaryTitleColor: .text, - disabledTitleColor: .neutral(.shade20), - textButtonColor: .primary, - textButtonHighlightColor: .primaryDark, - instructionColor: .text, - subheadlineColor: .textSubtle, - placeholderColor: .textPlaceholder, - viewControllerBackgroundColor: .listBackground, - textFieldBackgroundColor: .listForeground, - navBarImage: Gridicon.iconOfType(.mySites), - navBarBadgeColor: .accent(.shade20), - prologueBackgroundColor: .primary, - prologueTitleColor: .textInverted, - statusBarStyle: .lightContent) - - WordPressAuthenticator.initialize(configuration: configuration, - style: style) + let googleLogingWithoutSDK: Bool = { + switch BuildConfiguration.current { + case .appStore: + // Rely on the remote flag in production + return RemoteFeatureFlag.sdkLessGoogleSignIn.enabled(using: remoteFeaturesStore) + default: + return true + } + }() + + return WordPressAuthenticatorConfiguration(wpcomClientId: ApiCredentials.client, + wpcomSecret: ApiCredentials.secret, + wpcomScheme: WPComScheme, + wpcomTermsOfServiceURL: WPAutomatticTermsOfServiceURL, + wpcomBaseURL: WordPressComOAuthClient.WordPressComOAuthDefaultBaseUrl, + wpcomAPIBaseURL: Environment.current.wordPressComApiBase, + googleLoginClientId: ApiCredentials.googleLoginClientId, + googleLoginServerClientId: ApiCredentials.googleLoginServerClientId, + googleLoginScheme: ApiCredentials.googleLoginSchemeId, + userAgent: WPUserAgent.wordPress(), + showLoginOptions: true, + enableSignUp: AppConfiguration.allowSignUp, + enableSignInWithApple: enableSignInWithApple, + enableSignupWithGoogle: AppConfiguration.allowSignUp, + enableUnifiedAuth: true, + enableUnifiedCarousel: FeatureFlag.unifiedPrologueCarousel.enabled, + enableSocialLogin: true, + googleLoginWithoutSDK: googleLogingWithoutSDK) + } + + private func authenticatorStyle() -> WordPressAuthenticatorStyle { + let prologueVC: UIViewController? = { + guard let viewController = authenticationHandler?.prologueViewController else { + if FeatureFlag.newWordPressLandingScreen.enabled { + return SplashPrologueViewController() + } + + if FeatureFlag.unifiedPrologueCarousel.enabled { + return UnifiedPrologueViewController() + } + + return nil + } + + return viewController + }() + + let statusBarStyle: UIStatusBarStyle = { + guard let statusBarStyle = authenticationHandler?.statusBarStyle else { + return FeatureFlag.unifiedPrologueCarousel.enabled ? .default : .lightContent + } + + return statusBarStyle + }() + + let buttonViewTopShadowImage: UIImage? = { + guard let image = authenticationHandler?.buttonViewTopShadowImage else { + return UIImage(named: "darkgrey-shadow") + } + + return image + }() + + var prologuePrimaryButtonStyle: NUXButtonStyle? + var prologueSecondaryButtonStyle: NUXButtonStyle? + + if FeatureFlag.newWordPressLandingScreen.enabled, AppConfiguration.isWordPress { + prologuePrimaryButtonStyle = SplashPrologueStyleGuide.primaryButtonStyle + prologueSecondaryButtonStyle = SplashPrologueStyleGuide.secondaryButtonStyle + } else { + prologuePrimaryButtonStyle = authenticationHandler?.prologuePrimaryButtonStyle + prologueSecondaryButtonStyle = authenticationHandler?.prologueSecondaryButtonStyle + } + + return WordPressAuthenticatorStyle(primaryNormalBackgroundColor: .primaryButtonBackground, + primaryNormalBorderColor: nil, + primaryHighlightBackgroundColor: .primaryButtonDownBackground, + primaryHighlightBorderColor: nil, + secondaryNormalBackgroundColor: .authSecondaryButtonBackground, + secondaryNormalBorderColor: .secondaryButtonBorder, + secondaryHighlightBackgroundColor: .secondaryButtonDownBackground, + secondaryHighlightBorderColor: .secondaryButtonDownBorder, + disabledBackgroundColor: .textInverted, + disabledBorderColor: .neutral(.shade10), + primaryTitleColor: .white, + secondaryTitleColor: .text, + disabledTitleColor: .neutral(.shade20), + disabledButtonActivityIndicatorColor: .text, + textButtonColor: .primary, + textButtonHighlightColor: .primaryDark, + instructionColor: .text, + subheadlineColor: .textSubtle, + placeholderColor: .textPlaceholder, + viewControllerBackgroundColor: .listBackground, + textFieldBackgroundColor: .listForeground, + buttonViewBackgroundColor: .authButtonViewBackground, + buttonViewTopShadowImage: buttonViewTopShadowImage, + navBarImage: .gridicon(.mySites), + navBarBadgeColor: .accent(.shade20), + navBarBackgroundColor: .appBarBackground, + prologueBackgroundColor: .primary, + prologueTitleColor: .textInverted, + prologuePrimaryButtonStyle: prologuePrimaryButtonStyle, + prologueSecondaryButtonStyle: prologueSecondaryButtonStyle, + prologueTopContainerChildViewController: prologueVC, + statusBarStyle: statusBarStyle) + } + + private func unifiedStyle() -> WordPressAuthenticatorUnifiedStyle { + let prologueButtonsBackgroundColor: UIColor = { + guard let color = authenticationHandler?.prologueButtonsBackgroundColor else { + return .clear + } + + return color + }() + + + /// Uses the same prologueButtonsBackgroundColor but we need to be able to return nil + let prologueViewBackgroundColor: UIColor? = authenticationHandler?.prologueButtonsBackgroundColor + + return WordPressAuthenticatorUnifiedStyle(borderColor: .divider, + errorColor: .error, + textColor: .text, + textSubtleColor: .textSubtle, + textButtonColor: .primary, + textButtonHighlightColor: .primaryDark, + viewControllerBackgroundColor: .basicBackground, + prologueButtonsBackgroundColor: prologueButtonsBackgroundColor, + prologueViewBackgroundColor: prologueViewBackgroundColor, + prologueBackgroundImage: authenticationHandler?.prologueBackgroundImage, + prologueButtonsBlurEffect: authenticationHandler?.prologueButtonsBlurEffect, + navBarBackgroundColor: .appBarBackground, + navButtonTextColor: .appBarTint, + navTitleTextColor: .appBarText) } } @@ -83,8 +214,7 @@ extension WordPressAuthenticationManager { @objc class func signinForWPComFixingAuthToken(_ onDismissed: ((_ cancelled: Bool) -> Void)? = nil) -> UIViewController { let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - let account = service.defaultWordPressComAccount() + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) return WordPressAuthenticator.signinForWPCom(dotcomEmailAddress: account?.email, dotcomUsername: account?.username, onDismissed: onDismissed) } @@ -94,7 +224,7 @@ extension WordPressAuthenticationManager { /// @objc class func showSigninForWPComFixingAuthToken() { - guard let presenter = UIApplication.shared.keyWindow?.rootViewController else { + guard let presenter = UIApplication.shared.mainWindow?.rootViewController else { assertionFailure() return } @@ -136,9 +266,8 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { /// var dismissActionEnabled: Bool { let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - return AccountHelper.isDotcomAvailable() || blogService.blogCountForAllAccounts() > 0 + return AccountHelper.isDotcomAvailable() || Blog.count(in: context) > 0 } /// Indicates whether if the Support Action should be enabled, or not. @@ -147,6 +276,12 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { return true } + /// Indicates whether a link to WP.com TOS should be available, or not. + /// + var wpcomTermsOfServiceEnabled: Bool { + return true + } + /// Indicates if Support is Enabled. /// var supportEnabled: Bool { @@ -159,29 +294,28 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { return ZendeskUtils.showSupportNotificationIndicator } + private var tracker: AuthenticatorAnalyticsTracker { + AuthenticatorAnalyticsTracker.shared + } + /// We allow to connect with WordPress.com account only if there is no default account connected already. var allowWPComLogin: Bool { - let accountService = AccountService(managedObjectContext: ContextManager.shared.mainContext) - return accountService.defaultWordPressComAccount() == nil + (try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext)) == nil } /// Returns an instance of a SupportView, configured to be displayed from a specified Support Source. /// - func presentSupport(from sourceViewController: UIViewController, sourceTag: WordPressSupportSourceTag) { - let controller = SupportTableViewController() - controller.sourceTag = sourceTag - - let navController = UINavigationController(rootViewController: controller) - navController.modalPresentationStyle = .formSheet - - sourceViewController.present(navController, animated: true) + func presentSupport(from sourceViewController: UIViewController, sourceTag: WordPressSupportSourceTag, + lastStep: AuthenticatorAnalyticsTracker.Step, + lastFlow: AuthenticatorAnalyticsTracker.Flow) { + presentSupport(from: sourceViewController, sourceTag: sourceTag) } /// Presents Support new request, with the specified ViewController as a source. /// Additional metadata is supplied, such as the sourceTag and Login details. /// func presentSupportRequest(from sourceViewController: UIViewController, sourceTag: WordPressSupportSourceTag) { - ZendeskUtils.sharedInstance.showNewRequestIfPossible(from: sourceViewController, with: sourceTag) + presentSupport(from: sourceViewController, sourceTag: sourceTag) } /// A self-hosted site URL is available and needs validated @@ -190,33 +324,90 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { /// - site: passes in the site information to the delegate method. /// - onCompletion: Closure to be executed on completion. /// - func shouldPresentUsernamePasswordController(for siteInfo: WordPressComSiteInfo?, onCompletion: @escaping (Error?, Bool) -> Void) { - onCompletion(nil, true) + func shouldPresentUsernamePasswordController(for siteInfo: WordPressComSiteInfo?, onCompletion: @escaping (WordPressAuthenticatorResult) -> Void) { + + let result: WordPressAuthenticatorResult = .presentPasswordController(value: true) + onCompletion(result) } /// Presents the Login Epilogue, in the specified NavigationController. /// - func presentLoginEpilogue(in navigationController: UINavigationController, for credentials: AuthenticatorCredentials, onDismiss: @escaping () -> Void) { + func presentLoginEpilogue(in navigationController: UINavigationController, for credentials: AuthenticatorCredentials, source: SignInSource?, onDismiss: @escaping () -> Void) { + + // 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) { + + if self.windowManager.isShowingFullscreenSignIn { + self.windowManager.dismissFullscreenSignIn(blogToShow: blog) + } else { + navigationController.dismiss(animated: true) + } + + return + } + if PostSignUpInterstitialViewController.shouldDisplay() { self.presentPostSignUpInterstitial(in: navigationController, onDismiss: onDismiss) return } - //Present the epilogue view + // Present the epilogue view let storyboard = UIStoryboard(name: "LoginEpilogue", bundle: .main) guard let epilogueViewController = storyboard.instantiateInitialViewController() as? LoginEpilogueViewController else { fatalError() } + let onBlogSelected: ((Blog) -> Void) = { [weak self] blog in + guard let self = self else { + return + } + + // If the user just signed in, refresh the A/B assignments + ABTest.start() + + self.recentSiteService.touch(blog: blog) + self.presentOnboardingQuestionsPrompt(in: navigationController, blog: blog, onDismiss: onDismiss) + } + + // If the user has only 1 blog, skip the site selector and go right to the next step + if numberOfBlogs() == 1, let firstBlog = firstBlog() { + onBlogSelected(firstBlog) + return + } + epilogueViewController.credentials = credentials - epilogueViewController.onDismiss = onDismiss + epilogueViewController.onBlogSelected = onBlogSelected + + let onDismissQuickStartPromptForNewSiteHandler = onDismissQuickStartPromptHandler(type: .newSite, onDismiss: onDismiss) + + epilogueViewController.onCreateNewSite = { + let source = "login_epilogue" + JetpackFeaturesRemovalCoordinator.presentSiteCreationOverlayIfNeeded(in: navigationController, source: source, onDidDismiss: { + guard JetpackFeaturesRemovalCoordinator.siteCreationPhase() != .two else { + return + } + + // Display site creation flow if not in phase two + let wizardLauncher = SiteCreationWizardLauncher(onDismiss: onDismissQuickStartPromptForNewSiteHandler) + guard let wizard = wizardLauncher.ui else { + return + } + + navigationController.present(wizard, animated: true) + SiteCreationAnalyticsHelper.trackSiteCreationAccessed(source: source) + }) + } + navigationController.delegate = epilogueViewController navigationController.pushViewController(epilogueViewController, animated: true) + } /// Presents the Signup Epilogue, in the specified NavigationController. /// func presentSignupEpilogue(in navigationController: UINavigationController, for credentials: AuthenticatorCredentials, service: SocialService?) { + let storyboard = UIStoryboard(name: "SignupEpilogue", bundle: .main) guard let epilogueViewController = storyboard.instantiateInitialViewController() as? SignupEpilogueViewController else { fatalError() @@ -224,12 +415,22 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { epilogueViewController.credentials = credentials epilogueViewController.socialService = service - epilogueViewController.onContinue = { + epilogueViewController.onContinue = { [weak self] in + guard let self = self else { + return + } + if PostSignUpInterstitialViewController.shouldDisplay() { self.presentPostSignUpInterstitial(in: navigationController) } else { - navigationController.dismiss(animated: true) + if self.windowManager.isShowingFullscreenSignIn { + self.windowManager.dismissFullscreenSignIn() + } else { + navigationController.dismiss(animated: true) + } } + + UserPersistentStoreFactory.instance().set(false, forKey: UserPersistentStoreFactory.instance().welcomeNotificationSeenKey) } navigationController.pushViewController(epilogueViewController, animated: true) @@ -243,11 +444,7 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { return true } - let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - let numberOfBlogs = service.defaultWordPressComAccount()?.blogs?.count ?? 0 - - return numberOfBlogs > 0 + return numberOfBlogs() > 0 } /// Indicates if the Signup Epilogue should be displayed. @@ -256,18 +453,18 @@ 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. /// func createdWordPressComAccount(username: String, authToken: String) { + let service = AccountService(coreDataStack: ContextManager.sharedInstance()) let context = ContextManager.sharedInstance().mainContext - let service = AccountService(managedObjectContext: context) - - let account = service.createOrUpdateAccount(withUsername: username, authToken: authToken) - if service.defaultWordPressComAccount() == nil { - service.setDefaultWordPressComAccount(account) + let accountID = service.createOrUpdateAccount(withUsername: username, authToken: authToken) + guard let account = try? context.existingObject(with: accountID) as? WPAccount else { + DDLogError("Failed to find the account") + return } + service.setDefaultWordPressComAccount(account) } /// When an Apple account is used during the Auth flow, save the Apple user id to the keychain. @@ -294,6 +491,22 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { } } + /// Indicates if the given Auth error should be handled by the host app. + /// + func shouldHandleError(_ error: Error) -> Bool { + // Here for protocol compliance. + return false + } + + /// Handles the given error. + /// Called if `shouldHandleError` is true. + /// + func handleError(_ error: Error, onCompletion: @escaping (UIViewController) -> Void) { + // Here for protocol compliance. + let vc = UIViewController() + onCompletion(vc) + } + /// Tracks a given Analytics Event. /// func track(event: WPAnalyticsStat) { @@ -313,14 +526,162 @@ extension WordPressAuthenticationManager: WordPressAuthenticatorDelegate { } } +// MARK: - Blog Count Helpers +private extension WordPressAuthenticationManager { + private func numberOfBlogs() -> Int { + let context = ContextManager.sharedInstance().mainContext + let numberOfBlogs = (try? WPAccount.lookupDefaultWordPressComAccount(in: context))?.blogs?.count ?? 0 + + return numberOfBlogs + } + + private func firstBlog() -> Blog? { + let context = ContextManager.sharedInstance().mainContext + return try? WPAccount.lookupDefaultWordPressComAccount(in: context)?.blogs?.first + } +} + +// MARK: - Onboarding Questions Prompt +private extension WordPressAuthenticationManager { + private func presentOnboardingQuestionsPrompt(in navigationController: UINavigationController, blog: Blog, onDismiss: (() -> Void)? = nil) { + let windowManager = self.windowManager + + guard JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() else { + if self.windowManager.isShowingFullscreenSignIn { + self.windowManager.dismissFullscreenSignIn(blogToShow: blog) + } else { + self.windowManager.showAppUI(for: blog) + } + return + } + + let coordinator = OnboardingQuestionsCoordinator() + coordinator.navigationController = navigationController + + let viewController = OnboardingQuestionsPromptViewController(with: coordinator) + + coordinator.onDismiss = { selectedOption in + self.handleOnboardingQuestionsWillDismiss(option: selectedOption) + + let completion: (() -> Void)? = { + let userInfo = ["option": selectedOption] + NotificationCenter.default.post(name: .onboardingPromptWasDismissed, object: nil, userInfo: userInfo) + } + + if windowManager.isShowingFullscreenSignIn { + windowManager.dismissFullscreenSignIn(completion: completion) + } else { + navigationController.dismiss(animated: true, completion: completion) + } + + onDismiss?() + } + + navigationController.pushViewController(viewController, animated: true) + } + + + /// To prevent a weird jump from the MySite tab to the reader/notifications tab + /// We'll pre-switch to the users selected tab before the login flow dismisses + private func handleOnboardingQuestionsWillDismiss(option: OnboardingOption) { + if option == .reader { + RootViewCoordinator.sharedPresenter.showReaderTab() + } else if option == .notifications { + RootViewCoordinator.sharedPresenter.showNotificationsTab() + } + } + + +} + +// MARK: - Quick Start Prompt +private extension WordPressAuthenticationManager { + + typealias QuickStartOnDismissHandler = (Blog, Bool) -> Void + + func presentQuickStartPrompt(for blog: Blog, in navigationController: UINavigationController, onDismiss: QuickStartOnDismissHandler?) { + // 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 onDismissQuickStartPromptHandler(type: QuickStartType, onDismiss: @escaping () -> Void) -> QuickStartOnDismissHandler { + return { [weak self] blog, _ in + guard let self = self else { + // If self is nil the user will be stuck on the login and not able to progress + // Trigger a fatal so we can track this better in Sentry. + // This case should be very rare. + fatalError("Could not get a reference to self when selecting the blog on the login epilogue") + } + + 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, type: type) + }) + } + } +} + // MARK: - WordPressAuthenticatorManager // private extension WordPressAuthenticationManager { /// Displays the post sign up interstitial if needed, if it's not displayed - private func presentPostSignUpInterstitial(in navigationController: UINavigationController, onDismiss: (() -> Void)? = nil) { + private func presentPostSignUpInterstitial( + in navigationController: UINavigationController, + onDismiss: (() -> Void)? = nil) { + let viewController = PostSignUpInterstitialViewController() - viewController.onDismiss = onDismiss + let windowManager = self.windowManager + + viewController.dismiss = { dismissAction in + let completion: (() -> Void)? + + switch dismissAction { + case .none: + completion = nil + case .addSelfHosted: + completion = { + NotificationCenter.default.post(name: .addSelfHosted, object: nil) + } + case .createSite: + completion = { + NotificationCenter.default.post(name: .createSite, object: nil) + } + } + + if windowManager.isShowingFullscreenSignIn { + windowManager.dismissFullscreenSignIn(completion: completion) + } else { + navigationController.dismiss(animated: true, completion: completion) + } + + onDismiss?() + } navigationController.pushViewController(viewController, animated: true) } @@ -330,6 +691,11 @@ private extension WordPressAuthenticationManager { private func syncWPCom(authToken: String, isJetpackLogin: Bool, onCompletion: @escaping () -> ()) { let service = WordPressComSyncService() + // Create a dispatch group to wait for both API calls. + let syncGroup = DispatchGroup() + + // Sync account and blog + syncGroup.enter() service.syncWPCom(authToken: authToken, isJetpackLogin: isJetpackLogin, onSuccess: { account in /// HACK: An alternative notification to LoginFinished. Observe this instead of `WPSigninDidFinishNotification` for Jetpack logins. @@ -338,11 +704,21 @@ private extension WordPressAuthenticationManager { let notification = isJetpackLogin == true ? .wordpressLoginFinishedJetpackLogin : Foundation.Notification.Name(rawValue: WordPressAuthenticator.WPSigninDidFinishNotification) NotificationCenter.default.post(name: notification, object: account) - onCompletion() - + syncGroup.leave() }, onFailure: { _ in - onCompletion() + syncGroup.leave() + }) + + // Refresh Remote Feature Flags + syncGroup.enter() + WordPressAppDelegate.shared?.updateFeatureFlags(authToken: authToken, completion: { + syncGroup.leave() }) + + // Sync done + syncGroup.notify(queue: .main) { + onCompletion() + } } /// Synchronizes a WordPress.org account with the specified credentials. @@ -356,3 +732,31 @@ private extension WordPressAuthenticationManager { } } } + +// MARK: - Support Helper +// +private extension WordPressAuthenticationManager { + /// Presents the support screen which displays different support options depending on whether this is the WordPress app or the Jetpack app. + private func presentSupport(from sourceViewController: UIViewController, sourceTag: WordPressSupportSourceTag) { + // Reset the nav style so the Support nav bar has the WP style, not the Auth style. + WPStyleGuide.configureNavigationAppearance() + + // Since we're presenting the support VC as a form sheet, the parent VC's viewDidAppear isn't called + // when this VC is dismissed. This means the tracking step isn't reset properly, so we'll need to do + // it here manually before tracking the new step. + let step = tracker.state.lastStep + + tracker.track(step: .help) + + let controller = SupportTableViewController { [weak self] in + self?.tracker.track(click: .dismiss) + self?.tracker.set(step: step) + } + controller.sourceTag = sourceTag + + let navController = UINavigationController(rootViewController: controller) + navController.modalPresentationStyle = .formSheet + + sourceViewController.present(navController, animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/NUX/WordPressSupportSourceTag+Helpers.swift b/WordPress/Classes/ViewRelated/NUX/WordPressSupportSourceTag+Helpers.swift index 1be14114cacb..46a23cd15cd4 100644 --- a/WordPress/Classes/ViewRelated/NUX/WordPressSupportSourceTag+Helpers.swift +++ b/WordPress/Classes/ViewRelated/NUX/WordPressSupportSourceTag+Helpers.swift @@ -28,4 +28,7 @@ extension WordPressSupportSourceTag { public static var deleteSite: WordPressSupportSourceTag { return WordPressSupportSourceTag(name: "deleteSite", origin: "origin:delete-site") } + public static var closeAccount: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "closeAccount", origin: "origin:close-account") + } } diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationCommentDetailViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationCommentDetailViewController.swift new file mode 100644 index 000000000000..c0f78c2a1a2f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationCommentDetailViewController.swift @@ -0,0 +1,329 @@ +import UIKit + +class NotificationCommentDetailViewController: UIViewController, NoResultsViewHost { + + // MARK: - Properties + + private var notification: Notification { + didSet { + title = notification.title + } + } + + private var comment: Comment? { + didSet { + updateDisplayedComment() + } + } + + private var commentID: NSNumber? { + notification.metaCommentID + } + + private var blog: Blog? { + guard let siteID = notification.metaSiteID else { + return nil + } + return Blog.lookup(withID: siteID, in: managedObjectContext) + } + + // If the user does not have permission to the Blog, it will be nil. + // In this case, use the Post to obtain Comment information. + private var post: ReaderPost? + + private var commentDetailViewController: CommentDetailViewController? + private weak var notificationDelegate: CommentDetailsNotificationDelegate? + private let managedObjectContext = ContextManager.shared.mainContext + + private lazy var commentService: CommentService = { + return .init(coreDataStack: ContextManager.shared) + }() + + private lazy var postService: ReaderPostService = { + return .init(coreDataStack: ContextManager.shared) + }() + + // MARK: - Notification Navigation Buttons + + private lazy var nextButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(.gridicon(.arrowUp), for: .normal) + button.addTarget(self, action: #selector(nextButtonTapped), for: .touchUpInside) + button.accessibilityLabel = NSLocalizedString("Next notification", comment: "Accessibility label for the next notification button") + return button + }() + + private lazy var previousButton: UIButton = { + let button = UIButton(type: .custom) + button.setImage(.gridicon(.arrowDown), for: .normal) + button.addTarget(self, action: #selector(previousButtonTapped), for: .touchUpInside) + button.accessibilityLabel = NSLocalizedString("Previous notification", comment: "Accessibility label for the previous notification button") + return button + }() + + var previousButtonEnabled = false { + didSet { + previousButton.isEnabled = previousButtonEnabled + } + } + + var nextButtonEnabled = false { + didSet { + nextButton.isEnabled = nextButtonEnabled + } + } + + // MARK: - Init + + init(notification: Notification, + notificationDelegate: CommentDetailsNotificationDelegate) { + self.notification = notification + self.notificationDelegate = notificationDelegate + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + configureNavBar() + view.backgroundColor = .basicBackground + loadComment() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + configureNavBarButtons() + } + + func refreshViewController(notification: Notification) { + self.notification = notification + loadComment() + } + +} + +private extension NotificationCommentDetailViewController { + + func configureNavBar() { + title = notification.title + // Empty Back Button + navigationItem.backBarButtonItem = UIBarButtonItem(title: String(), style: .plain, target: nil, action: nil) + } + + func configureNavBarButtons() { + var barButtonItems: [UIBarButtonItem] = [] + + if splitViewControllerIsHorizontallyCompact { + barButtonItems.append(makeNavigationButtons()) + } + + if let comment = comment, + comment.allowsModeration(), + let commentDetailViewController = commentDetailViewController { + barButtonItems.append(commentDetailViewController.editBarButtonItem) + } + + navigationItem.setRightBarButtonItems(barButtonItems, animated: false) + } + + func makeNavigationButtons() -> UIBarButtonItem { + // Create custom view to match that in NotificationDetailsViewController. + let buttonStackView = UIStackView(arrangedSubviews: [nextButton, previousButton]) + buttonStackView.axis = .horizontal + buttonStackView.spacing = Constants.arrowButtonSpacing + + let width = (Constants.arrowButtonSize * 2) + Constants.arrowButtonSpacing + buttonStackView.frame = CGRect(x: 0, y: 0, width: width, height: Constants.arrowButtonSize) + + return UIBarButtonItem(customView: buttonStackView) + } + + @objc func previousButtonTapped() { + notificationDelegate?.previousNotificationTapped(current: notification) + } + + @objc func nextButtonTapped() { + notificationDelegate?.nextNotificationTapped(current: notification) + } + + func updateDisplayedComment() { + guard let comment = comment else { + return + } + + if commentDetailViewController != nil { + commentDetailViewController?.refreshView(comment: comment, notification: notification) + } else { + let commentDetailViewController = CommentDetailViewController(comment: comment, + notification: notification, + notificationDelegate: notificationDelegate, + managedObjectContext: managedObjectContext) + + commentDetailViewController.view.translatesAutoresizingMaskIntoConstraints = false + add(commentDetailViewController) + view.pinSubviewToAllEdges(commentDetailViewController.view) + self.commentDetailViewController = commentDetailViewController + } + + configureNavBarButtons() + } + + func loadComment() { + showLoadingView() + + loadPostIfNeeded(completion: { [weak self] in + + self?.fetchParentCommentIfNeeded(completion: { [weak self] in + guard let self = self else { + return + } + + if let comment = self.loadCommentFromCache(self.commentID) { + self.comment = comment + return + } + + self.fetchComment(self.commentID, completion: { [weak self] comment in + guard let comment = comment else { + self?.showErrorView() + return + } + + self?.comment = comment + }) + }) + }) + } + + func loadPostIfNeeded(completion: @escaping () -> Void) { + + // The post is only needed if there is no Blog. + guard blog == nil, + let postID = notification.metaPostID, + let siteID = notification.metaSiteID else { + completion() + return + } + + if let post = try? ReaderPost.lookup(withID: postID, forSiteWithID: siteID, in: managedObjectContext) { + self.post = post + completion() + return + } + + postService.fetchPost(postID.uintValue, + forSite: siteID.uintValue, + isFeed: false, + success: { [weak self] post in + self?.post = post + completion() + }, failure: { [weak self] _ in + self?.post = nil + completion() + }) + } + + func loadCommentFromCache(_ commentID: NSNumber?) -> Comment? { + guard let commentID = commentID else { + DDLogError("Notification Comment: unable to load comment due to missing commentID.") + return nil + } + + if let blog = blog { + return blog.comment(withID: commentID) + } + + if let post = post { + return post.comment(withID: commentID) + } + + return nil + } + + func fetchComment(_ commentID: NSNumber?, completion: @escaping (Comment?) -> Void) { + guard let commentID = commentID else { + DDLogError("Notification Comment: unable to fetch comment due to missing commentID.") + completion(nil) + return + } + + if let blog = blog { + commentService.loadComment(withID: commentID, for: blog, success: { comment in + completion(comment) + }, failure: { error in + completion(nil) + }) + return + } + + if let post = post { + commentService.loadComment(withID: commentID, for: post, success: { comment in + completion(comment) + }, failure: { error in + completion(nil) + }) + return + } + + completion(nil) + } + + func fetchParentCommentIfNeeded(completion: @escaping () -> Void) { + // If the comment has a parent and it is not cached, fetch it so the details header is correct. + guard let parentID = notification.metaParentID, + loadCommentFromCache(parentID) == nil else { + completion() + return + } + + fetchComment(parentID, completion: { _ in + completion() + }) + } + + struct Constants { + static let arrowButtonSize: CGFloat = 24 + static let arrowButtonSpacing: CGFloat = 12 + } + + // MARK: - No Results Views + + func showLoadingView() { + if let commentDetailViewController = commentDetailViewController { + commentDetailViewController.showNoResultsView(title: NoResults.loadingTitle, + accessoryView: NoResultsViewController.loadingAccessoryView()) + } else { + hideNoResults() + configureAndDisplayNoResults(on: view, + title: NoResults.loadingTitle, + accessoryView: NoResultsViewController.loadingAccessoryView()) + } + } + + func showErrorView() { + if let commentDetailViewController = commentDetailViewController { + commentDetailViewController.showNoResultsView(title: NoResults.errorTitle, + subtitle: NoResults.errorSubtitle, + imageName: NoResults.imageName) + } else { + hideNoResults() + configureAndDisplayNoResults(on: view, + title: NoResults.errorTitle, + subtitle: NoResults.errorSubtitle, + image: NoResults.imageName) + } + } + + struct NoResults { + static let loadingTitle = NSLocalizedString("Loading comment...", comment: "Displayed while a comment is being loaded.") + static let errorTitle = NSLocalizedString("Oops", comment: "Title for the view when there's an error loading a comment.") + static let errorSubtitle = NSLocalizedString("There was an error loading the comment.", comment: "Text displayed when there is a failure loading a comment.") + static let imageName = "wp-illustration-notifications" + } + +} diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift index 8746e0305307..20309004a8e0 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift @@ -16,7 +16,7 @@ struct NotificationContentRouter { do { try displayContent(of: range, with: url) } catch { - coordinator.displayWebViewWithURL(url) + coordinator.displayWebViewWithURL(url, source: "notifications") } } @@ -31,7 +31,7 @@ struct NotificationContentRouter { do { try displayNotificationSource() } catch { - coordinator.displayWebViewWithURL(fallbackURL) + coordinator.displayWebViewWithURL(fallbackURL, source: "notifications") } } @@ -52,9 +52,19 @@ struct NotificationContentRouter { case .post: try coordinator.displayReaderWithPostId(notification.metaPostID, siteID: notification.metaSiteID) case .comment: - fallthrough + // Focus on the primary comment, and default to the reply ID if its set + let commentID = notification.metaCommentID ?? notification.metaReplyID + try coordinator.displayCommentsWithPostId(notification.metaPostID, + siteID: notification.metaSiteID, + commentID: commentID, + source: .commentNotification) case .commentLike: - try coordinator.displayCommentsWithPostId(notification.metaPostID, siteID: notification.metaSiteID) + // Focus on the primary comment, and default to the reply ID if its set + let commentID = notification.metaCommentID ?? notification.metaReplyID + try coordinator.displayCommentsWithPostId(notification.metaPostID, + siteID: notification.metaSiteID, + commentID: commentID, + source: .commentLikeNotification) default: throw DefaultContentCoordinator.DisplayError.unsupportedType } @@ -71,13 +81,27 @@ struct NotificationContentRouter { case .post: try coordinator.displayReaderWithPostId(range.postID, siteID: range.siteID) case .comment: - try coordinator.displayCommentsWithPostId(range.postID, siteID: range.siteID) + // Focus on the comment reply if it's set over the primary comment ID + let commentID = notification.metaReplyID ?? notification.metaCommentID + try coordinator.displayCommentsWithPostId(range.postID, + siteID: range.siteID, + commentID: commentID, + source: .commentNotification) case .stats: - try coordinator.displayStatsWithSiteID(range.siteID) + /// Backup notifications are configured as "stat" notifications + /// For now this is just a workaround to fix the routing + if url.absoluteString.matches(regex: "\\/backup\\/").count > 0 { + try coordinator.displayBackupWithSiteID(range.siteID) + } else { + try coordinator.displayStatsWithSiteID(range.siteID, url: url) + } case .follow: try coordinator.displayFollowersWithSiteID(range.siteID, expirationTime: expirationFiveMinutes) case .user: try coordinator.displayStreamWithSiteID(range.siteID) + case .scan: + try coordinator.displayScanWithSiteID(range.siteID) + default: throw DefaultContentCoordinator.DisplayError.unsupportedType } diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationDetailsViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationDetailsViewController.swift index fa46b6d3415a..dc2073dee2fb 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationDetailsViewController.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationDetailsViewController.swift @@ -7,7 +7,7 @@ import WordPressShared /// /// -protocol NotificationsNavigationDataSource: class { +protocol NotificationsNavigationDataSource: AnyObject { func notification(succeeding note: Notification) -> Notification? func notification(preceding note: Notification) -> Notification? } @@ -15,7 +15,8 @@ protocol NotificationsNavigationDataSource: class { // MARK: - Renders a given Notification entity, onscreen // -class NotificationDetailsViewController: UIViewController { +class NotificationDetailsViewController: UIViewController, NoResultsViewHost { + // MARK: - Properties let formatter = FormattableContentFormatter() @@ -54,7 +55,7 @@ class NotificationDetailsViewController: UIViewController { /// Reply Suggestions /// - @IBOutlet var suggestionsTableView: SuggestionsTableView! + @IBOutlet var suggestionsTableView: SuggestionsTableView? /// Embedded Media Downloader /// @@ -68,6 +69,10 @@ class NotificationDetailsViewController: UIViewController { /// fileprivate let estimatedRowHeightsCache = NSCache<AnyObject, AnyObject>() + /// A Reader Detail VC to display post content if needed + /// + private var readerDetailViewController: ReaderDetailViewController? + /// Previous NavBar Navigation Button /// var previousNavigationButton: UIButton! @@ -80,20 +85,29 @@ class NotificationDetailsViewController: UIViewController { /// weak var dataSource: NotificationsNavigationDataSource? - /// Notification to-be-displayed + /// Used to present CommentDetailViewController when previous/next notification is a Comment. + /// + weak var notificationCommentDetailCoordinator: NotificationCommentDetailCoordinator? + + /// Notification being displayed /// var note: Notification! { didSet { guard oldValue != note && isViewLoaded else { return } - + confettiWasShown = false router = makeRouter() + setupTableDelegates() refreshInterface() markAsReadIfNeeded() } } + /// Whether a confetti animation was presented on this notification or not + /// + private var confettiWasShown = false + lazy var coordinator: ContentCoordinator = { return DefaultContentCoordinator(controller: self, context: mainContext) }() @@ -114,6 +128,7 @@ class NotificationDetailsViewController: UIViewController { /// var onSelectedNoteChange: ((Notification) -> Void)? + var likesListController: LikesListController? deinit { // Failsafe: Manually nuke the tableView dataSource and delegate. Make sure not to force a loadView event! @@ -137,6 +152,7 @@ class NotificationDetailsViewController: UIViewController { setupMainView() setupTableView() setupTableViewCells() + setupTableDelegates() setupReplyTextView() setupSuggestionsView() setupKeyboardManager() @@ -146,7 +162,6 @@ class NotificationDetailsViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - tableView.deselectSelectedRowWithAnimation(true) keyboardManager?.startListeningToKeyboardNotifications() @@ -155,6 +170,11 @@ class NotificationDetailsViewController: UIViewController { setupNotificationListeners() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + showConfettiIfNeeded() + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) keyboardManager?.stopListeningToKeyboardNotifications() @@ -174,6 +194,7 @@ class NotificationDetailsViewController: UIViewController { super.viewDidLayoutSubviews() refreshNavigationBar() + adjustLayoutConstraintsIfNeeded() } private func makeRouter() -> NotificationContentRouter { @@ -291,11 +312,10 @@ extension NotificationDetailsViewController: UITableViewDelegate, UITableViewDat let group = contentGroup(for: indexPath) let reuseIdentifier = reuseIdentifierForGroup(group) guard let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) as? NoteBlockTableViewCell else { - fatalError() + DDLogError("Failed dequeueing NoteBlockTableViewCell.") + return UITableViewCell() } - setupSeparators(cell, indexPath: indexPath) - setup(cell, withContentGroupAt: indexPath) return cell @@ -309,6 +329,12 @@ extension NotificationDetailsViewController: UITableViewDelegate, UITableViewDat func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { estimatedRowHeightsCache.setObject(cell.frame.height as AnyObject, forKey: indexPath as AnyObject) + + guard let cell = cell as? NoteBlockTableViewCell else { + return + } + + setupSeparators(cell, indexPath: indexPath) } func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { @@ -361,11 +387,11 @@ extension NotificationDetailsViewController { navigationItem.backBarButtonItem = backButton let next = UIButton(type: .custom) - next.setImage(Gridicon.iconOfType(.arrowUp), for: .normal) + next.setImage(.gridicon(.arrowUp), for: .normal) next.addTarget(self, action: #selector(nextNotificationWasPressed), for: .touchUpInside) let previous = UIButton(type: .custom) - previous.setImage(Gridicon.iconOfType(.arrowDown), for: .normal) + previous.setImage(.gridicon(.arrowDown), for: .normal) previous.addTarget(self, action: #selector(previousNotificationWasPressed), for: .touchUpInside) previousNavigationButton = previous @@ -375,15 +401,14 @@ extension NotificationDetailsViewController { } func setupMainView() { - view.backgroundColor = .listBackground + view.backgroundColor = note.isBadge ? .ungroupedListBackground : .listBackground } func setupTableView() { tableView.separatorStyle = .none tableView.keyboardDismissMode = .interactive - tableView.backgroundColor = .neutral(.shade5) tableView.accessibilityIdentifier = NSLocalizedString("Notification Details Table", comment: "Notifications Details Accessibility Identifier") - tableView.backgroundColor = .listBackground + tableView.backgroundColor = note.isBadge ? .ungroupedListBackground : .listBackground } func setupTableViewCells() { @@ -393,7 +418,8 @@ extension NotificationDetailsViewController { NoteBlockActionsTableViewCell.self, NoteBlockCommentTableViewCell.self, NoteBlockImageTableViewCell.self, - NoteBlockUserTableViewCell.self + NoteBlockUserTableViewCell.self, + NoteBlockButtonTableViewCell.self ] for cellClass in cellClassNames { @@ -402,13 +428,36 @@ extension NotificationDetailsViewController { tableView.register(nib, forCellReuseIdentifier: cellClass.reuseIdentifier()) } + + tableView.register(LikeUserTableViewCell.defaultNib, + forCellReuseIdentifier: LikeUserTableViewCell.defaultReuseID) + + } + + /// Configure the delegate and data source for the table view based on notification type. + /// This method may be called several times, especially upon previous/next button click + /// since notification kind may change. + func setupTableDelegates() { + if note.kind == .like || note.kind == .commentLike, + let likesListController = LikesListController(tableView: tableView, notification: note, delegate: self) { + tableView.delegate = likesListController + tableView.dataSource = likesListController + self.likesListController = likesListController + + // always call refresh to ensure that the controller fetches the data. + likesListController.refresh() + + } else { + tableView.delegate = self + tableView.dataSource = self + } } func setupReplyTextView() { let previousReply = NotificationReplyStore.shared.loadReply(for: note.notificationId) let replyTextView = ReplyTextView(width: view.frame.width) - replyTextView.placeholder = NSLocalizedString("Write a reply…", comment: "Placeholder text for inline compose view") + replyTextView.placeholder = NSLocalizedString("Write a reply", comment: "Placeholder text for inline compose view") replyTextView.text = previousReply replyTextView.accessibilityIdentifier = NSLocalizedString("Reply Text", comment: "Notifications Reply Accessibility Identifier") replyTextView.delegate = self @@ -426,10 +475,18 @@ extension NotificationDetailsViewController { } func setupSuggestionsView() { - suggestionsTableView = SuggestionsTableView() - suggestionsTableView.siteID = note.metaSiteID - suggestionsTableView.suggestionsDelegate = self - suggestionsTableView.translatesAutoresizingMaskIntoConstraints = false + guard let siteID = note.metaSiteID else { + return + } + + suggestionsTableView = SuggestionsTableView(siteID: siteID, suggestionType: .mention, delegate: self) + suggestionsTableView?.prominentSuggestionsIds = SuggestionsTableView.prominentSuggestions( + fromPostAuthorId: nil, + commentAuthorId: note.metaCommentAuthorID, + defaultAccountId: try? WPAccount.lookupDefaultWordPressComAccount(in: self.mainContext)?.userID + ) + + suggestionsTableView?.translatesAutoresizingMaskIntoConstraints = false } func setupKeyboardManager() { @@ -473,7 +530,7 @@ extension NotificationDetailsViewController { } var shouldAttachReplyView: Bool { - // Attach the Reply component only if the noficiation has a comment, and it can be replied-to + // Attach the Reply component only if the notification has a comment, and it can be replied to. // guard let block: FormattableCommentContent = note.contentGroup(ofKind: .comment)?.blockOfKind(.comment) else { return false @@ -492,12 +549,37 @@ extension NotificationDetailsViewController { +// MARK: - Reader Helpers +// +private extension NotificationDetailsViewController { + func attachReaderViewIfNeeded() { + guard shouldAttachReaderView, + let postID = note.metaPostID, + let siteID = note.metaSiteID else { + readerDetailViewController?.remove() + return + } + + readerDetailViewController?.remove() + let readerDetailViewController = ReaderDetailViewController.controllerWithPostID(postID, siteID: siteID) + add(readerDetailViewController) + readerDetailViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.pinSubviewToSafeArea(readerDetailViewController.view) + self.readerDetailViewController = readerDetailViewController + } + + var shouldAttachReaderView: Bool { + return note.kind == .newPost + } +} + + // MARK: - Suggestions View Helpers // private extension NotificationDetailsViewController { func attachSuggestionsViewIfNeeded() { - guard shouldAttachSuggestionsView else { - suggestionsTableView.removeFromSuperview() + guard shouldAttachSuggestionsView, let suggestionsTableView = self.suggestionsTableView else { + self.suggestionsTableView?.removeFromSuperview() return } @@ -508,15 +590,15 @@ private extension NotificationDetailsViewController { suggestionsTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), suggestionsTableView.topAnchor.constraint(equalTo: view.topAnchor), suggestionsTableView.bottomAnchor.constraint(equalTo: replyTextView.topAnchor) - ]) + ]) } var shouldAttachSuggestionsView: Bool { - guard let siteID = note.metaSiteID else { + guard let siteID = note.metaSiteID, + let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { return false } - - return shouldAttachReplyView && SuggestionService.sharedInstance().shouldShowSuggestions(forSiteID: siteID) + return shouldAttachReplyView && SuggestionService.shared.shouldShowSuggestions(for: blog) } } @@ -565,6 +647,8 @@ private extension NotificationDetailsViewController { return NoteBlockImageTableViewCell.reuseIdentifier() case .user: return NoteBlockUserTableViewCell.reuseIdentifier() + case .button: + return NoteBlockButtonTableViewCell.reuseIdentifier() default: assertionFailure("Unmanaged group kind: \(blockGroup.kind)") return NoteBlockTextTableViewCell.reuseIdentifier() @@ -574,6 +658,8 @@ private extension NotificationDetailsViewController { func setupSeparators(_ cell: NoteBlockTableViewCell, indexPath: IndexPath) { cell.isBadge = note.isBadge cell.isLastRow = (indexPath.row >= note.headerAndBodyContentGroups.count - 1) + + cell.refreshSeparators() } } @@ -598,6 +684,8 @@ private extension NotificationDetailsViewController { setupImageCell(cell, blockGroup: blockGroup) case let cell as NoteBlockTextTableViewCell: setupTextCell(cell, blockGroup: blockGroup, at: indexPath) + case let cell as NoteBlockButtonTableViewCell: + setupButtonCell(cell, blockGroup: blockGroup) default: assertionFailure("NotificationDetails: Please, add support for \(cell)") } @@ -610,15 +698,17 @@ private extension NotificationDetailsViewController { // - UITableViewCell's taps don't require a Gestures Recognizer. No big deal, but less code! // - let snippetBlock: NotificationTextContent? = blockGroup.blockOfKind(.text) - cell.headerDetails = snippetBlock?.text cell.attributedHeaderTitle = nil + cell.attributedHeaderDetails = nil - guard let gravatarBlock: NotificationTextContent = blockGroup.blockOfKind(.image) else { - return + guard let gravatarBlock: NotificationTextContent = blockGroup.blockOfKind(.image), + let snippetBlock: NotificationTextContent = blockGroup.blockOfKind(.text) else { + return } cell.attributedHeaderTitle = formatter.render(content: gravatarBlock, with: HeaderContentStyles()) + cell.attributedHeaderDetails = formatter.render(content: snippetBlock, with: HeaderDetailsContentStyles()) + // Download the Gravatar let mediaURL = gravatarBlock.media.first?.mediaURL cell.downloadAuthorAvatar(with: mediaURL) @@ -695,6 +785,11 @@ private extension NotificationDetailsViewController { cell.attributedCommentText = text.trimNewlines() cell.isApproved = commentBlock.isCommentApproved + // Add comment author's name to Reply placeholder. + let placeholderFormat = NSLocalizedString("Reply to %1$@", + comment: "Placeholder text for replying to a comment. %1$@ is a placeholder for the comment author's name.") + replyTextView.placeholder = String(format: placeholderFormat, cell.name ?? String()) + // Setup: Callbacks cell.onUserClick = { [weak self] in guard let homeURL = userBlock.metaLinksHome else { @@ -749,7 +844,6 @@ private extension NotificationDetailsViewController { // Setup: Callbacks cell.onReplyClick = { [weak self] _ in self?.focusOnReplyTextViewWithBlock(commentBlock) - WPAppAnalytics.track(.notificationsCommentRepliedTo) } cell.onLikeClick = { [weak self] _ in @@ -789,6 +883,10 @@ private extension NotificationDetailsViewController { let mediaURL = imageBlock.media.first?.mediaURL cell.downloadImage(mediaURL) + + if note.isViewMilestone { + cell.backgroundImage = UIImage(named: Assets.confettiBackground) + } } func setupTextCell(_ cell: NoteBlockTextTableViewCell, blockGroup: FormattableContentGroup, at indexPath: IndexPath) { @@ -802,9 +900,15 @@ private extension NotificationDetailsViewController { let mediaRanges = textBlock.buildRangesToImagesMap(mediaMap) // Load the attributedText - let text = note.isBadge ? - formatter.render(content: textBlock, with: BadgeContentStyles(cachingKey: "Badge-\(indexPath)")) : - formatter.render(content: textBlock, with: RichTextContentStyles(key: "Rich-Text-\(indexPath)")) + let text: NSAttributedString + + if note.isBadge { + let isFirstTextGroup = indexPath.row == indexOfFirstContentGroup(ofKind: .text) + text = formatter.render(content: textBlock, with: BadgeContentStyles(cachingKey: "Badge-\(indexPath)", isTitle: isFirstTextGroup)) + cell.isTitle = isFirstTextGroup + } else { + text = formatter.render(content: textBlock, with: RichTextContentStyles(key: "Rich-Text-\(indexPath)")) + } // Setup: Properties cell.attributedText = text.stringByEmbeddingImageAttachments(mediaRanges) @@ -818,6 +922,26 @@ private extension NotificationDetailsViewController { self.displayURL(url) } } + + func setupButtonCell(_ cell: NoteBlockButtonTableViewCell, blockGroup: FormattableContentGroup) { + guard let textBlock = blockGroup.blocks.first as? NotificationTextContent else { + assertionFailure("Missing Text Block for Notification \(note.notificationId)") + return + } + + cell.title = textBlock.text + + if let linkRange = textBlock.ranges.map({ $0 as? LinkContentRange }).first, + let url = linkRange?.url { + cell.action = { [weak self] in + guard let `self` = self, self.isViewOnScreen() else { + return + } + + self.displayURL(url) + } + } + } } @@ -856,6 +980,7 @@ extension NotificationDetailsViewController { // MARK: - Resources // private extension NotificationDetailsViewController { + func displayURL(_ url: URL?) { guard let url = url else { tableView.deselectSelectedRowWithAnimation(true) @@ -871,8 +996,19 @@ private extension NotificationDetailsViewController { tableView.deselectSelectedRowWithAnimation(true) } } -} + func displayUserProfile(_ user: LikeUser, from indexPath: IndexPath) { + let userProfileVC = UserProfileSheetViewController(user: user) + userProfileVC.blogUrlPreviewedSource = "notif_like_list_user_profile" + let bottomSheet = BottomSheetViewController(childViewController: userProfileVC) + + let sourceView = tableView.cellForRow(at: indexPath) ?? view + bottomSheet.show(from: self, sourceView: sourceView) + + WPAnalytics.track(.userProfileSheetShown, properties: ["source": "like_notification_list"]) + } + +} // MARK: - Helpers @@ -882,6 +1018,10 @@ private extension NotificationDetailsViewController { func contentGroup(for indexPath: IndexPath) -> FormattableContentGroup { return note.headerAndBodyContentGroups[indexPath.row] } + + func indexOfFirstContentGroup(ofKind kind: FormattableContentGroup.Kind) -> Int? { + return note.headerAndBodyContentGroups.firstIndex(where: { $0.kind == kind }) + } } @@ -1041,6 +1181,7 @@ private extension NotificationDetailsViewController { let actionContext = ActionContext(block: block, content: content) { [weak self] (request, success) in if success { + WPAppAnalytics.track(.notificationsCommentRepliedTo) let message = NSLocalizedString("Reply Sent!", comment: "The app successfully sent a comment") self?.displayNotice(title: message) } else { @@ -1063,6 +1204,7 @@ private extension NotificationDetailsViewController { let actionContext = ActionContext(block: block, content: content) { [weak self] (request, success) in guard success == false else { + CommentAnalytics.trackCommentEdited(block: block) return } @@ -1115,6 +1257,7 @@ private extension NotificationDetailsViewController { navController.modalTransitionStyle = .coverVertical navController.navigationBar.isTranslucent = false + CommentAnalytics.trackCommentEditorOpened(block: block) present(navController, animated: true) } @@ -1144,11 +1287,26 @@ private extension NotificationDetailsViewController { // extension NotificationDetailsViewController: ReplyTextViewDelegate { func textView(_ textView: UITextView, didTypeWord word: String) { - suggestionsTableView.showSuggestions(forWord: word) + suggestionsTableView?.showSuggestions(forWord: word) } -} + func replyTextView(_ replyTextView: ReplyTextView, willEnterFullScreen controller: FullScreenCommentReplyViewController) { + guard let siteID = note.metaSiteID, let suggestionsTableView = self.suggestionsTableView else { + return + } + + let lastSearchText = suggestionsTableView.viewModel.searchText + suggestionsTableView.hideSuggestions() + controller.enableSuggestions(with: siteID, prominentSuggestionsIds: suggestionsTableView.prominentSuggestionsIds, searchText: lastSearchText) + } + func replyTextView(_ replyTextView: ReplyTextView, didExitFullScreen lastSearchText: String?) { + guard let lastSearchText = lastSearchText, !lastSearchText.isEmpty else { + return + } + suggestionsTableView?.showSuggestions(forWord: lastSearchText) + } +} // MARK: - UIScrollViewDelegate // @@ -1177,7 +1335,31 @@ extension NotificationDetailsViewController: SuggestionsTableViewDelegate { } } +// MARK: - Milestone notifications +// +private extension NotificationDetailsViewController { + func showConfettiIfNeeded() { + guard FeatureFlag.milestoneNotifications.enabled, + note.isViewMilestone, + !confettiWasShown, + let view = UIApplication.shared.mainWindow, + let frame = navigationController?.view.frame else { + return + } + // This method will remove any existing `ConfettiView` before adding a new one + // This ensures that when we navigate through notifications, if there is an + // ongoging animation, it will be removed and replaced by a new one + ConfettiView.cleanupAndAnimate(on: view, frame: frame) { confettiView in + + // removing this instance when the animation completes, will prevent + // the animation to suddenly stop if users navigate away from the note + confettiView.removeFromSuperview() + } + + confettiWasShown = true + } +} // MARK: - Navigation Helpers // @@ -1187,8 +1369,8 @@ extension NotificationDetailsViewController { return } - onSelectedNoteChange?(previous) - note = previous + WPAnalytics.track(.notificationsPreviousTapped) + refreshView(with: previous) } @IBAction func nextNotificationWasPressed() { @@ -1196,8 +1378,39 @@ extension NotificationDetailsViewController { return } - onSelectedNoteChange?(next) - note = next + WPAnalytics.track(.notificationsNextTapped) + refreshView(with: next) + } + + private func refreshView(with note: Notification) { + onSelectedNoteChange?(note) + trackDetailsOpened(for: note) + + if FeatureFlag.notificationCommentDetails.enabled, + note.kind == .comment { + showCommentDetails(with: note) + return + } + + hideNoResults() + self.note = note + showConfettiIfNeeded() + } + + private func showCommentDetails(with note: Notification) { + guard let commentDetailViewController = notificationCommentDetailCoordinator?.createViewController(with: note) else { + DDLogError("Notification Details: failed creating Comment Detail view.") + return + } + + notificationCommentDetailCoordinator?.onSelectedNoteChange = self.onSelectedNoteChange + weak var navigationController = navigationController + + dismiss(animated: true, completion: { + commentDetailViewController.navigationItem.largeTitleDisplayMode = .never + navigationController?.popViewController(animated: false) + navigationController?.pushViewController(commentDetailViewController, animated: false) + }) } var shouldEnablePreviousButton: Bool { @@ -1210,6 +1423,27 @@ extension NotificationDetailsViewController { } +// MARK: - LikesListController Delegate +// +extension NotificationDetailsViewController: LikesListControllerDelegate { + + func didSelectHeader() { + displayNotificationSource() + } + + func didSelectUser(_ user: LikeUser, at indexPath: IndexPath) { + displayUserProfile(user, from: indexPath) + } + + func showErrorView(title: String, subtitle: String?) { + hideNoResults() + configureAndDisplayNoResults(on: tableView, + title: title, + subtitle: subtitle, + image: "wp-illustration-notifications") + } + +} // MARK: - Private Properties // @@ -1219,7 +1453,7 @@ private extension NotificationDetailsViewController { } var actionsService: NotificationActionsService { - return NotificationActionsService(managedObjectContext: mainContext) + return NotificationActionsService(coreDataStack: ContextManager.shared) } enum DisplayError: Error { @@ -1245,4 +1479,17 @@ private extension NotificationDetailsViewController { static let estimatedRowHeight = CGFloat(44) static let expirationFiveMinutes = TimeInterval(60 * 5) } + + enum Assets { + static let confettiBackground = "notifications-confetti-background" + } +} + +// MARK: - Tracks +extension NotificationDetailsViewController { + /// Tracks notification details opened + private func trackDetailsOpened(for note: Notification) { + let properties = ["notification_type": note.type ?? "unknown"] + WPAnalytics.track(.openedNotificationDetails, withProperties: properties) + } } diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingDetailsViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingDetailsViewController.swift index e4ae1c166c78..8badd974b3e5 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingDetailsViewController.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingDetailsViewController.swift @@ -23,7 +23,7 @@ class NotificationSettingDetailsViewController: UITableViewController { /// TableView Sections to be rendered /// - private var sections = [Section]() + private var sections = [SettingsSection]() /// Contains all of the updated Stream Settings /// @@ -98,8 +98,8 @@ class NotificationSettingDetailsViewController: UITableViewController { private func setupTableView() { // Register the cells - tableView.register(SwitchTableViewCell.self, forCellReuseIdentifier: Row.Kind.Setting.rawValue) - tableView.register(WPTableViewCell.self, forCellReuseIdentifier: Row.Kind.Text.rawValue) + tableView.register(SwitchTableViewCell.self, forCellReuseIdentifier: CellKind.Setting.rawValue) + tableView.register(WPTableViewCellValue1.self, forCellReuseIdentifier: CellKind.Text.rawValue) // Hide the separators, whenever the table is empty tableView.tableFooterView = UIView() @@ -122,62 +122,78 @@ class NotificationSettingDetailsViewController: UITableViewController { // MARK: - Private Helpers - private func sectionsForSettings(_ settings: NotificationSettings, stream: NotificationSettings.Stream) -> [Section] { + private func sectionsForSettings(_ settings: NotificationSettings, stream: NotificationSettings.Stream) -> [SettingsSection] { // WordPress.com Channel requires a brief description per row. // For that reason, we'll render each row in its own section, with it's very own footer let singleSectionMode = settings.channel != .wordPressCom // Parse the Rows - var rows = [Row]() + var rows = [SettingsRow]() for key in settings.sortedPreferenceKeys(stream) { let description = settings.localizedDescription(key) let value = stream.preferences?[key] ?? true - let row = Row(kind: .Setting, description: description, key: key, value: value) + let row = SwitchSettingsRow(kind: .Setting, description: description, key: key, value: value) rows.append(row) } // Single Section Mode: A single section will contain all of the rows if singleSectionMode { - return [Section(rows: rows)] + // Switch on stream type to provide descriptive text in footer for more context + switch stream.kind { + case .Device: + if Feature.enabled(.bloggingReminders), JetpackNotificationMigrationService.shared.shouldPresentNotifications(), let blog = settings.blog { + // This should only be added for the device push notifications settings view + rows.append(TextSettingsRow(kind: .Text, description: NSLocalizedString("Blogging Reminders", comment: "Label for the blogging reminders setting"), value: schedule(for: blog), onTap: { [weak self] in + self?.presentBloggingRemindersFlow() + })) + } + + return [SettingsSection(rows: rows, footerText: NSLocalizedString("Settings for push notifications that appear on your mobile device.", comment: "Descriptive text for the Push Notifications Settings"))] + case .Email: + return [SettingsSection(rows: rows, footerText: NSLocalizedString("Settings for notifications that are sent to the email tied to your account.", comment: "Descriptive text for the Email Notifications Settings"))] + case .Timeline: + return [SettingsSection(rows: rows, footerText: NSLocalizedString("Settings for notifications that appear in the Notifications tab.", comment: "Descriptive text for the Notifications Tab Settings"))] + } } + // Multi Section Mode: We'll have one Section per Row - var sections = [Section]() + var sections = [SettingsSection]() for row in rows { let unwrappedKey = row.key ?? String() let footerText = settings.localizedDetails(unwrappedKey) - let section = Section(rows: [row], footerText: footerText) + let section = SettingsSection(rows: [row], footerText: footerText) sections.append(section) } return sections } - private func sectionsForDisabledDeviceStream() -> [Section] { + private func sectionsForDisabledDeviceStream() -> [SettingsSection] { let description = NSLocalizedString("Go to iOS Settings", comment: "Opens WPiOS Settings.app Section") - let row = Row(kind: .Text, description: description, key: nil, value: nil) + let row = TextSettingsRow(kind: .Text, description: description, value: "") let footerText = NSLocalizedString("Push Notifications have been turned off in iOS Settings App. " + "Toggle \"Allow Notifications\" to turn them back on.", comment: "Suggests to enable Push Notification Settings in Settings.app") - let section = Section(rows: [row], footerText: footerText) + let section = SettingsSection(rows: [row], footerText: footerText) return [section] } - private func sectionsForUnknownDeviceStream() -> [Section] { + private func sectionsForUnknownDeviceStream() -> [SettingsSection] { defer { WPAnalytics.track(.pushNotificationPrimerSeen, withProperties: [Analytics.locationKey: Analytics.alertKey]) } let description = NSLocalizedString("Allow push notifications", comment: "Shown to the user in settings when they haven't yet allowed or denied push notifications") - let row = Row(kind: .Text, description: description, key: nil, value: nil) + let row = TextSettingsRow(kind: .Text, description: description, value: "") let footerText = NSLocalizedString("Allow WordPress to send you push notifications", comment: "Suggests the user allow push notifications. Appears within app settings.") - let section = Section(rows: [row], footerText: footerText) + let section = SettingsSection(rows: [row], footerText: footerText) return [section] } @@ -225,7 +241,13 @@ class NotificationSettingDetailsViewController: UITableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectSelectedRowWithAnimation(true) - if isDeviceStreamDisabled() { + let section = sections[indexPath.section] + + if let row = section.rows[indexPath.row] as? TextSettingsRow, + let onTap = row.onTap { + onTap() + return + } else if isDeviceStreamDisabled() { openApplicationSettings() } else if isDeviceStreamUnknown() { requestNotificationAuthorization() @@ -234,12 +256,21 @@ class NotificationSettingDetailsViewController: UITableViewController { // MARK: - UITableView Helpers - private func configureTextCell(_ cell: WPTableViewCell, row: Row) { - cell.textLabel?.text = row.description + private func configureTextCell(_ cell: WPTableViewCell, row: SettingsRow) { + guard let row = row as? TextSettingsRow else { + return + } + + cell.textLabel?.text = row.description + cell.detailTextLabel?.text = row.value WPStyleGuide.configureTableViewCell(cell) } - private func configureSwitchCell(_ cell: SwitchTableViewCell, row: Row) { + private func configureSwitchCell(_ cell: SwitchTableViewCell, row: SettingsRow) { + guard let row = row as? SwitchSettingsRow else { + return + } + let settingKey = row.key ?? String() cell.name = row.description @@ -268,7 +299,7 @@ class NotificationSettingDetailsViewController: UITableViewController { defer { WPAnalytics.track(.pushNotificationPrimerAllowTapped, withProperties: [Analytics.locationKey: Analytics.alertKey]) } - InteractiveNotificationsManager.shared.requestAuthorization { [weak self] in + InteractiveNotificationsManager.shared.requestAuthorization { [weak self] _ in self?.refreshPushAuthorizationStatus() } } @@ -286,8 +317,7 @@ class NotificationSettingDetailsViewController: UITableViewController { return } - let context = ContextManager.sharedInstance().mainContext - let service = NotificationSettingsService(managedObjectContext: context) + let service = NotificationSettingsService(coreDataStack: ContextManager.shared) service.updateSettings(settings!, stream: stream!, @@ -319,35 +349,25 @@ class NotificationSettingDetailsViewController: UITableViewController { alertController.presentFromRootViewController() } + // MARK: - Blogging Reminders - // MARK: - Private Nested Class'ess - private class Section { - var rows: [Row] - var footerText: String? + func presentBloggingRemindersFlow() { + guard let blog = settings?.blog else { + return + } - init(rows: [Row], footerText: String? = nil) { - self.rows = rows - self.footerText = footerText + BloggingRemindersFlow.present(from: self, for: blog, source: .notificationSettings) { [weak self] in + self?.reloadTable() } } - private class Row { - let description: String - let kind: Kind - let key: String? - let value: Bool? - - init(kind: Kind, description: String, key: String? = nil, value: Bool? = nil) { - self.description = description - self.kind = kind - self.key = key - self.value = value + private func schedule(for blog: Blog) -> String { + guard let scheduler = try? ReminderScheduleCoordinator() else { + return NSLocalizedString("None set", comment: "Title shown on table row where no blogging reminders have been set up yet") } - enum Kind: String { - case Setting = "SwitchCell" - case Text = "TextCell" - } + let formatter = BloggingRemindersScheduleFormatter() + return formatter.shortScheduleDescription(for: scheduler.schedule(for: blog), time: scheduler.scheduledTime(for: blog).toLocalTime()).string } private struct Analytics { @@ -355,3 +375,53 @@ class NotificationSettingDetailsViewController: UITableViewController { static let alertKey = "settings" } } + +private enum CellKind: String { + case Setting = "SwitchCell" + case Text = "TextCell" +} + +private protocol SettingsRow { + var description: String { get } + var kind: CellKind { get } + var key: String? { get } +} + +private struct SwitchSettingsRow: SettingsRow { + let description: String + let kind: CellKind + let key: String? + let value: Bool? + + init(kind: CellKind, description: String, key: String? = nil, value: Bool? = nil) { + self.description = description + self.kind = kind + self.key = key + self.value = value + } +} + +private struct TextSettingsRow: SettingsRow { + let description: String + let kind: CellKind + let key: String? = nil + let value: String + let onTap: (() -> Void)? + + init(kind: CellKind, description: String, value: String, onTap: (() -> Void)? = nil) { + self.description = description + self.kind = kind + self.value = value + self.onTap = onTap + } +} + +private struct SettingsSection { + var rows: [SettingsRow] + var footerText: String? + + init(rows: [SettingsRow], footerText: String? = nil) { + self.rows = rows + self.footerText = footerText + } +} diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingStreamsViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingStreamsViewController.swift index 53629bad3803..13db329b9518 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingStreamsViewController.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingStreamsViewController.swift @@ -192,7 +192,11 @@ class NotificationSettingStreamsViewController: UITableViewController { "3. Select **WordPress**\n" + "4. Turn on **Allow Notifications**", comment: "Displayed when Push Notifications are disabled (iOS 7)") - let button = NSLocalizedString("Dismiss", comment: "Dismiss the AlertView") + let button = NSLocalizedString( + "notificationSettingStreams.pushNotificationAlert.dismissButton", + value: "Dismiss", + comment: "Dismiss the alert with instructions on how to enable push notifications." + ) let alert = AlertView(title: title, message: message, button: button) alert.show() diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingsViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingsViewController.swift index 7b5db5a85636..bd6eb02b15d8 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingsViewController.swift @@ -1,5 +1,5 @@ -import Foundation -import WordPressShared.WPStyleGuide +import UIKit +import WordPressShared /// The purpose of this class is to retrieve the collection of NotificationSettings from WordPress.com @@ -7,7 +7,61 @@ import WordPressShared.WPStyleGuide /// On Row Press, we'll push the list of available Streams, which will, in turn, push the Details View /// itself, which is in charge of rendering the actual available settings. /// -open class NotificationSettingsViewController: UIViewController { +class NotificationSettingsViewController: UIViewController { + + // MARK: - Properties + + private lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .grouped) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.rowHeight = UITableView.automaticDimension + tableView.sectionFooterHeight = UITableView.automaticDimension + return tableView + }() + + private lazy var activityIndicatorView: UIActivityIndicatorView = { + let indicatorView = UIActivityIndicatorView() + indicatorView.translatesAutoresizingMaskIntoConstraints = false + return indicatorView + }() + + private lazy var mainView: UIView = { + let view = UIView() + view.addSubviews([tableView, activityIndicatorView]) + return view + }() + + + // MARK: - Private Constants + + fileprivate let blogReuseIdentifier = WPBlogTableViewCell.classNameWithoutNamespaces() + fileprivate let blogRowHeight = CGFloat(54.0) + + fileprivate let defaultReuseIdentifier = WPTableViewCell.classNameWithoutNamespaces() + fileprivate let switchReuseIdentifier = SwitchTableViewCell.classNameWithoutNamespaces() + + fileprivate let emptyCount = 0 + fileprivate let loadMoreRowIndex = 3 + fileprivate let loadMoreRowCount = 4 + + + // MARK: - Private Properties + + fileprivate var groupedSettings: [Section: [NotificationSettings]] = [:] + fileprivate var displayBlogMoreWasAccepted = false + fileprivate var displayFollowedMoreWasAccepted = false + fileprivate var followedSites: [ReaderSiteTopic] = [] + fileprivate var tableSections: [Section] = [] + + private var notificationsEnabled = false + + override func loadView() { + mainView.pinSubviewToAllEdges(tableView) + mainView.pinSubviewAtCenter(activityIndicatorView) + + view = mainView + } + // MARK: - View Lifecycle open override func viewDidLoad() { @@ -48,6 +102,9 @@ open class NotificationSettingsViewController: UIViewController { // Register the cells tableView.register(WPBlogTableViewCell.self, forCellReuseIdentifier: blogReuseIdentifier) tableView.register(WPTableViewCell.self, forCellReuseIdentifier: defaultReuseIdentifier) + tableView.register(SwitchTableViewCell.self, forCellReuseIdentifier: switchReuseIdentifier) + tableView.dataSource = self + tableView.delegate = self // Hide the separators, whenever the table is empty tableView.tableFooterView = UIView() @@ -55,26 +112,30 @@ open class NotificationSettingsViewController: UIViewController { // Style! WPStyleGuide.configureColors(view: view, tableView: tableView) WPStyleGuide.configureAutomaticHeightRows(for: tableView) + + activityIndicatorView.tintColor = .textSubtle } // MARK: - Service Helpers fileprivate func reloadSettings() { - let service = NotificationSettingsService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let service = NotificationSettingsService(coreDataStack: ContextManager.sharedInstance()) let dispatchGroup = DispatchGroup() - let siteService = ReaderTopicService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let siteService = ReaderTopicService(coreDataStack: ContextManager.shared) activityIndicatorView.startAnimating() - dispatchGroup.enter() - siteService.fetchFollowedSites(success: { - dispatchGroup.leave() - }, failure: { (error) in - dispatchGroup.leave() - DDLogError("Could not sync sites: \(String(describing: error))") - }) + if AppConfiguration.showsFollowedSitesSettings { + dispatchGroup.enter() + siteService.fetchFollowedSites(success: { + dispatchGroup.leave() + }, failure: { (error) in + dispatchGroup.leave() + DDLogError("Could not sync sites: \(String(describing: error))") + }) + } dispatchGroup.enter() service.getAllSettings({ [weak self] (settings: [NotificationSettings]) in @@ -85,8 +146,14 @@ open class NotificationSettingsViewController: UIViewController { self?.handleLoadError() }) + dispatchGroup.enter() + UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in + self?.notificationsEnabled = settings.authorizationStatus == .authorized + dispatchGroup.leave() + } + dispatchGroup.notify(queue: .main) { [weak self] in - self?.followedSites = (siteService.allSiteTopics() ?? []).filter { !$0.isExternal } + self?.followedSites = ((try? ReaderAbstractTopic.lookupAllSites(in: ContextManager.shared.mainContext)) ?? []).filter { !$0.isExternal } self?.setupSections() self?.activityIndicatorView.stopAnimating() self?.tableView.reloadData() @@ -95,18 +162,20 @@ open class NotificationSettingsViewController: UIViewController { fileprivate func groupSettings(_ settings: [NotificationSettings]) -> [Section: [NotificationSettings]] { // Find the Default Blog ID - let service = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext) - let defaultAccount = service.defaultWordPressComAccount() - let primaryBlogId = defaultAccount?.defaultBlog?.dotComID as? Int + let defaultAccount = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) + let primaryBlogId = defaultAccount?.defaultBlog?.dotComID as? Int // Proceed Grouping - var blogSettings = [NotificationSettings]() - var otherSettings = [NotificationSettings]() - var wpcomSettings = [NotificationSettings]() + var blogSettings = [NotificationSettings]() + var otherSettings = [NotificationSettings]() + var wpcomSettings = [NotificationSettings]() for setting in settings { switch setting.channel { case let .blog(blogId): + guard setting.blog != nil else { + continue + } // Make sure that the Primary Blog is the first one in its category if blogId == primaryBlogId { blogSettings.insert(setting, at: 0) @@ -131,12 +200,16 @@ open class NotificationSettingsViewController: UIViewController { // fileprivate func setupSections() { var section: [Section] = groupedSettings.isEmpty ? [] : [.blog, .other, .wordPressCom] - if !followedSites.isEmpty && !section.isEmpty { + if !followedSites.isEmpty && !section.isEmpty && AppConfiguration.showsFollowedSitesSettings { section.insert(.followedSites, at: 1) - } else if !followedSites.isEmpty && section.isEmpty { + } else if !followedSites.isEmpty && section.isEmpty && AppConfiguration.showsFollowedSitesSettings { section.append(.followedSites) } + if JetpackNotificationMigrationService.shared.shouldShowNotificationControl() && notificationsEnabled { + section.insert(.notificationControl, at: 0) + } + tableSections = section } @@ -150,11 +223,11 @@ open class NotificationSettingsViewController: UIViewController { // MARK: - Error Handling fileprivate func handleLoadError() { - let title = NSLocalizedString("Oops!", comment: "An informal exclaimation meaning `something went wrong`.") - let message = NSLocalizedString("There has been a problem while loading your Notification Settings", + let title = NSLocalizedString("Oops!", comment: "An informal exclaimation meaning `something went wrong`.") + let message = NSLocalizedString("There has been a problem while loading your Notification Settings", comment: "Displayed after Notification Settings failed to load") - let cancelText = NSLocalizedString("Cancel", comment: "Cancel. Action.") - let retryText = NSLocalizedString("Try Again", comment: "Try Again. Action") + let cancelText = NSLocalizedString("Cancel", comment: "Cancel. Action.") + let retryText = NSLocalizedString("Try Again", comment: "Try Again. Action") let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) @@ -168,11 +241,12 @@ open class NotificationSettingsViewController: UIViewController { present(alertController, animated: true) } +} +// MARK: - UITableView Datasource Methods +extension NotificationSettingsViewController: UITableViewDataSource { - // MARK: - UITableView Datasource Methods - - @objc open func numberOfSectionsInTableView(_ tableView: UITableView) -> Int { + func numberOfSections(in tableView: UITableView) -> Int { return tableSections.count } @@ -183,21 +257,24 @@ open class NotificationSettingsViewController: UIViewController { return displayBlogMoreWasAccepted ? rowCountForBlogSection + 1 : loadMoreRowCount case .followedSites: return displayFollowedMoreWasAccepted ? rowCountForFollowedSite + 1 : min(loadMoreRowCount, rowCountForFollowedSite) + case .notificationControl: + return 1 default: return groupedSettings[section]?.count ?? 0 } } - @objc open func tableView(_ tableView: UITableView, cellForRowAtIndexPath indexPath: IndexPath) -> UITableViewCell { - let identifier = reusableIdentifierForIndexPath(indexPath) - let cell = tableView.dequeueReusableCell(withIdentifier: identifier)! + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let identifier = reusableIdentifierForIndexPath(indexPath) + + let cell = tableView.dequeueReusableCell(withIdentifier: identifier)! configureCell(cell, indexPath: indexPath) return cell } - @objc open func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { // Hide when the section is empty! if isSectionEmpty(section) { return nil @@ -206,25 +283,12 @@ open class NotificationSettingsViewController: UIViewController { let theSection = self.section(at: section) return theSection.headerText() } +} - @objc open func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - // Hide when the section is empty! - if isSectionEmpty(section) { - return nil - } - - let theSection = self.section(at: section) - return theSection.footerText() - } - - @objc open func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) { - WPStyleGuide.configureTableViewSectionFooter(view) - } - - - // MARK: - UITableView Delegate Methods +// MARK: - UITableView Delegate Methods +extension NotificationSettingsViewController: UITableViewDelegate { - @objc open func tableView(_ tableView: UITableView, didSelectRowAtIndexPath indexPath: IndexPath) { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if isPaginationRow(indexPath) { toggleDisplayMore(at: indexPath) } else if let siteTopic = siteTopic(at: indexPath) { @@ -236,24 +300,84 @@ open class NotificationSettingsViewController: UIViewController { } } + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + let currentSection = self.section(at: section) + + guard !isSectionEmpty(section), let text = currentSection.footerText() else { + return nil + } + return makeFooterView(showBadge: currentSection.showBadge, text: text) + } +} + +// MARK: - UITableView Helpers +private extension NotificationSettingsViewController { + + /// Creates a label to be inserted in the sites section footer + /// - Parameter text: the text of the label + /// - Returns: the label + func makeFooterLabelView(text: String) -> UIView { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = WPStyleGuide.fontForTextStyle(.footnote) + label.numberOfLines = 0 + label.text = text + label.textColor = .secondaryLabel + + let labelView = UIView() + labelView.addSubview(label) + labelView.pinSubviewToAllEdges(label, insets: FooterMetrics.footerLabelInsets) + return labelView + } + + + /// Creates the footer for the my sites section + /// - Parameter text: the text to be used in the label + /// - Returns: the footer view + func makeFooterView(showBadge: Bool = false, text: String) -> UIView { + let labelView = makeFooterLabelView(text: text) + + guard showBadge else { + return labelView + } + + labelView.translatesAutoresizingMaskIntoConstraints = false - // MARK: - UITableView Helpers + let textProvider = JetpackBrandingTextProvider(screen: JetpackBadgeScreen.notificationsSettings) + let badgeView = JetpackButton.makeBadgeView(title: textProvider.brandingText(), + topPadding: FooterMetrics.jetpackBadgeTopPadding, + bottomPadding: FooterMetrics.jetpackBadgeBottomPatting, + target: self, + selector: #selector(jetpackButtonTapped)) + badgeView.translatesAutoresizingMaskIntoConstraints = false - fileprivate func reusableIdentifierForIndexPath(_ indexPath: IndexPath) -> String { + let stackView = UIStackView(arrangedSubviews: [labelView, badgeView]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + + let view = UIView() + view.addSubview(stackView) + view.pinSubviewToAllEdges(stackView) + return view + } + + func reusableIdentifierForIndexPath(_ indexPath: IndexPath) -> String { switch section(at: indexPath.section) { case .blog where !isPaginationRow(indexPath), .followedSites where !isPaginationRow(indexPath): return blogReuseIdentifier + case .notificationControl: + return switchReuseIdentifier default: return defaultReuseIdentifier } } - fileprivate func configureCell(_ cell: UITableViewCell, indexPath: IndexPath) { + func configureCell(_ cell: UITableViewCell, indexPath: IndexPath) { // Pagination Rows don't really have a Settings entity if isPaginationRow(indexPath) { - cell.textLabel?.text = paginationRowDescription(indexPath) - cell.textLabel?.textAlignment = .natural - cell.accessoryType = .none + cell.textLabel?.text = paginationRowDescription(indexPath) + cell.textLabel?.textAlignment = .natural + cell.accessoryType = .none WPStyleGuide.configureTableViewCell(cell) return } @@ -273,6 +397,11 @@ open class NotificationSettingsViewController: UIViewController { return } + if let cell = cell as? SwitchTableViewCell { + configureNotificationSwitchCell(cell) + return + } + // Proceed rendering the settings guard let settings = settingsForRowAtIndexPath(indexPath) else { return @@ -280,9 +409,9 @@ open class NotificationSettingsViewController: UIViewController { switch settings.channel { case .blog: - cell.textLabel?.text = settings.blog?.settings?.name ?? settings.channel.description() - cell.detailTextLabel?.text = settings.blog?.displayURL as String? ?? String() - cell.accessoryType = .disclosureIndicator + cell.textLabel?.text = settings.blog?.settings?.name ?? settings.channel.description() + cell.detailTextLabel?.text = settings.blog?.displayURL as String? ?? String() + cell.accessoryType = .disclosureIndicator if let blog = settings.blog { cell.imageView?.downloadSiteIcon(for: blog) @@ -293,16 +422,16 @@ open class NotificationSettingsViewController: UIViewController { WPStyleGuide.configureTableViewSmallSubtitleCell(cell) default: - cell.textLabel?.text = settings.channel.description() - cell.textLabel?.textAlignment = .natural - cell.accessoryType = .disclosureIndicator + cell.textLabel?.text = settings.channel.description() + cell.textLabel?.textAlignment = .natural + cell.accessoryType = .disclosureIndicator WPStyleGuide.configureTableViewCell(cell) } } - fileprivate func siteTopic(at index: IndexPath) -> ReaderSiteTopic? { + func siteTopic(at index: IndexPath) -> ReaderSiteTopic? { guard !followedSites.isEmpty, - index.row <= (followedSites.count - 1) else { + index.row <= (followedSites.count - 1) else { return nil } @@ -315,7 +444,7 @@ open class NotificationSettingsViewController: UIViewController { } } - fileprivate func settingsForRowAtIndexPath(_ indexPath: IndexPath) -> NotificationSettings? { + func settingsForRowAtIndexPath(_ indexPath: IndexPath) -> NotificationSettings? { let section = self.section(at: indexPath.section) guard let settings = groupedSettings[section] else { return nil @@ -324,7 +453,7 @@ open class NotificationSettingsViewController: UIViewController { return settings[indexPath.row] } - fileprivate func isSectionEmpty(_ sectionIndex: Int) -> Bool { + func isSectionEmpty(_ sectionIndex: Int) -> Bool { let section = self.section(at: sectionIndex) switch section { case .followedSites: @@ -335,8 +464,67 @@ open class NotificationSettingsViewController: UIViewController { } } + enum Section: Int { + case blog + case followedSites + case other + case wordPressCom + case notificationControl + + func headerText() -> String? { + switch self { + case .blog: + return NSLocalizedString("Your Sites", comment: "Displayed in the Notification Settings View") + case .followedSites: + return NSLocalizedString("Followed Sites", comment: "Displayed in the Notification Settings View") + case .other: + return NSLocalizedString("Other", comment: "Displayed in the Notification Settings View") + case .wordPressCom: + return nil + case .notificationControl: + return nil + } + } + + func footerText() -> String? { + switch self { + case .blog: + return NSLocalizedString("Customize your site settings for Likes, Comments, Follows, and more.", + comment: "Notification Settings for your own blogs") + case .followedSites: + return NSLocalizedString("Customize your followed site settings for New Posts and Comments", + comment: "Notification Settings for your followed sites") + case .other: + return nil + case .wordPressCom: + return NSLocalizedString("We’ll always send important emails regarding your account, " + + "but you can get some helpful extras, too.", + comment: "Title displayed in the Notification Settings for WordPress.com") + case .notificationControl: + return NSLocalizedString("Turning the switch off will disable all notifications from this app, regardless of type.", + comment: "Notification Settings switch for the app.") + } + } + + var showBadge: Bool { + switch self { + case .blog: + return JetpackBrandingVisibility.all.enabled + default: + return false + } + } + } + + enum FooterMetrics { + static let footerLabelInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + static let jetpackBadgeTopPadding: CGFloat = 22 + static let jetpackBadgeBottomPatting: CGFloat = 8 + } +} - // MARK: - Load More Helpers +// MARK: - Load More Helpers +extension NotificationSettingsViewController { fileprivate var rowCountForFollowedSite: Int { return followedSites.count @@ -410,21 +598,21 @@ open class NotificationSettingsViewController: UIViewController { default: return } - // And refresh the section let sections = IndexSet(integer: index.section) tableView.reloadSections(sections, with: .fade) } +} +// MARK: - Navigation +private extension NotificationSettingsViewController { - // MARK: - Segue Helpers - - fileprivate func displayDetails(for siteId: Int) { + func displayDetails(for siteId: Int) { let siteSubscriptionsViewController = NotificationSiteSubscriptionViewController(siteId: siteId) navigationController?.pushViewController(siteSubscriptionsViewController, animated: true) } - fileprivate func displayDetailsForSettings(_ settings: NotificationSettings) { + func displayDetailsForSettings(_ settings: NotificationSettings) { switch settings.channel { case .wordPressCom: // WordPress.com Row will push the SettingDetails ViewController, directly @@ -437,85 +625,15 @@ open class NotificationSettingsViewController: UIViewController { } } - - // MARK: - Table Sections - - fileprivate enum Section: Int { - case blog - case followedSites - case other - case wordPressCom - - func headerText() -> String { - switch self { - case .blog: - return NSLocalizedString("Your Sites", comment: "Displayed in the Notification Settings View") - case .followedSites: - return NSLocalizedString("Followed Sites", comment: "Displayed in the Notification Settings View") - case .other: - return NSLocalizedString("Other", comment: "Displayed in the Notification Settings View") - case .wordPressCom: - return String() - } - } - - func footerText() -> String { - switch self { - case .blog: - return NSLocalizedString("Customize your site settings for Likes, Comments, Follows, and more.", - comment: "Notification Settings for your own blogs") - case .followedSites: - return NSLocalizedString("Customize your followed site settings for New Posts and Comments", - comment: "Notification Settings for your followed sites") - case .other: - return String() - case .wordPressCom: - return NSLocalizedString("We’ll always send important emails regarding your account, " + - "but you can get some helpful extras, too.", - comment: "Title displayed in the Notification Settings for WordPress.com") - } - } - - - // MARK: - Private Constants - - fileprivate static let paddingZero = CGFloat(0) - fileprivate static let paddingWordPress = CGFloat(40) + @objc func jetpackButtonTapped() { + JetpackBrandingCoordinator.presentOverlay(from: self) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBadgeTapped(screen: .notificationsSettings) } - - - - // MARK: - Private Outlets - - @IBOutlet fileprivate var tableView: UITableView! - @IBOutlet fileprivate var activityIndicatorView: UIActivityIndicatorView! - - - // MARK: - Private Constants - - fileprivate let blogReuseIdentifier = WPBlogTableViewCell.classNameWithoutNamespaces() - fileprivate let blogRowHeight = CGFloat(54.0) - - fileprivate let defaultReuseIdentifier = WPTableViewCell.classNameWithoutNamespaces() - - fileprivate let emptyCount = 0 - fileprivate let loadMoreRowIndex = 3 - fileprivate let loadMoreRowCount = 4 - - - // MARK: - Private Properties - - fileprivate var groupedSettings: [Section: [NotificationSettings]] = [:] - fileprivate var displayBlogMoreWasAccepted = false - fileprivate var displayFollowedMoreWasAccepted = false - fileprivate var followedSites: [ReaderSiteTopic] = [] - fileprivate var tableSections: [Section] = [] } - // MARK: - SearchableActivity Conformance - extension NotificationSettingsViewController: SearchableActivityConvertable { + var activityType: String { return WPActivityType.notificationSettings.rawValue } @@ -532,7 +650,17 @@ extension NotificationSettingsViewController: SearchableActivityConvertable { guard !keywordArray.isEmpty else { return nil } - return Set(keywordArray) } } + +// MARK: - Notification Switch Cell +extension NotificationSettingsViewController { + private func configureNotificationSwitchCell(_ cell: SwitchTableViewCell) { + cell.name = NSLocalizedString("Allow Notifications", comment: "Title for a cell with switch control that allows to enable or disable notifications") + cell.on = JetpackNotificationMigrationService.shared.wordPressNotificationsEnabled + cell.onChange = { newValue in + JetpackNotificationMigrationService.shared.wordPressNotificationsEnabled = newValue + } + } +} diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingsViewController.xib b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingsViewController.xib deleted file mode 100644 index 8e7fd5d05fc7..000000000000 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSettingsViewController.xib +++ /dev/null @@ -1,46 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="11762" systemVersion="16D32" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> - <dependencies> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="11757"/> - <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> - </dependencies> - <objects> - <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="NotificationSettingsViewController" customModule="WordPress" customModuleProvider="target"> - <connections> - <outlet property="activityIndicatorView" destination="6HB-Vi-ACi" id="NmI-Sl-0Ou"/> - <outlet property="tableView" destination="n1E-Bx-YgH" id="1q0-78-HjG"/> - <outlet property="view" destination="iN0-l3-epB" id="0Tr-J2-N89"/> - </connections> - </placeholder> - <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <view contentMode="scaleToFill" id="iN0-l3-epB"> - <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> - <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> - <subviews> - <tableView clipsSubviews="YES" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" alwaysBounceVertical="YES" style="grouped" separatorStyle="default" rowHeight="44" sectionHeaderHeight="10" sectionFooterHeight="10" translatesAutoresizingMaskIntoConstraints="NO" id="n1E-Bx-YgH"> - <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> - <color key="backgroundColor" red="0.93725490199999995" green="0.93725490199999995" blue="0.95686274510000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <connections> - <outlet property="dataSource" destination="-1" id="7RH-jP-uWL"/> - <outlet property="delegate" destination="-1" id="jp2-Hq-Fdm"/> - </connections> - </tableView> - <activityIndicatorView hidden="YES" opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" style="gray" translatesAutoresizingMaskIntoConstraints="NO" id="6HB-Vi-ACi"> - <rect key="frame" x="177.5" y="323.5" width="20" height="20"/> - </activityIndicatorView> - </subviews> - <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstItem="n1E-Bx-YgH" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="0QE-fW-0sh"/> - <constraint firstAttribute="centerX" secondItem="6HB-Vi-ACi" secondAttribute="centerX" id="D0c-0c-rp2"/> - <constraint firstAttribute="trailing" secondItem="n1E-Bx-YgH" secondAttribute="trailing" id="DMD-Zl-pSF"/> - <constraint firstAttribute="bottom" secondItem="n1E-Bx-YgH" secondAttribute="bottom" id="F9e-HT-UCI"/> - <constraint firstAttribute="centerY" secondItem="6HB-Vi-ACi" secondAttribute="centerY" id="c4t-cA-hN6"/> - <constraint firstItem="n1E-Bx-YgH" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" id="kIz-he-XXG"/> - </constraints> - </view> - </objects> -</document> diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSiteSubscriptionViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSiteSubscriptionViewController.swift index 28639eae1fcd..0d9b55ec5111 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSiteSubscriptionViewController.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationSiteSubscriptionViewController.swift @@ -64,7 +64,7 @@ class NotificationSiteSubscriptionViewController: UITableViewController { } private var sections: [Section] = [] - private let service = ReaderTopicService(managedObjectContext: ContextManager.sharedInstance().mainContext) + private let service = ReaderTopicService(coreDataStack: ContextManager.shared) private let siteId: Int private var siteTopic: ReaderSiteTopic? private let siteSubscription = SiteSubscription() @@ -93,7 +93,7 @@ class NotificationSiteSubscriptionViewController: UITableViewController { // MARK: - Private methods private func setupData() { - siteTopic = service.findSiteTopic(withSiteID: NSNumber(value: siteId)) + siteTopic = try? ReaderSiteTopic.lookup(withSiteID: NSNumber(value: siteId), in: ContextManager.shared.mainContext) siteSubscription.postsNotification = siteTopic?.postSubscription?.sendPosts ?? false siteSubscription.emailsNotification = siteTopic?.emailSubscription?.sendPosts ?? false @@ -193,7 +193,7 @@ class NotificationSiteSubscriptionViewController: UITableViewController { } @objc func followingSiteStateToggled() { - if let siteTopic = service.findSiteTopic(withSiteID: NSNumber(value: siteId)), !siteTopic.following { + if let siteTopic = try? ReaderSiteTopic.lookup(withSiteID: NSNumber(value: siteId), in: ContextManager.shared.mainContext), !siteTopic.following { navigationController?.popToRootViewController(animated: true) } } diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+AppRatings.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+AppRatings.swift index 0ae2571aa46e..5812b212b254 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+AppRatings.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+AppRatings.swift @@ -6,11 +6,10 @@ extension NotificationsViewController { static let contactURL = "https://support.wordpress.com/contact/" func setupAppRatings() { - inlinePromptView.setupHeading(NSLocalizedString("What do you think about WordPress?", - comment: "This is the string we display when prompting the user to review the app")) - let yesTitle = NSLocalizedString("I Like It", + inlinePromptView.setupHeading(AppConstants.AppRatings.prompt) + let yesTitle = NSLocalizedString("notifications.appRatings.prompt.yes.buttonTitle", value: "I like it", comment: "This is one of the buttons we display inside of the prompt to review the app") - let noTitle = NSLocalizedString("Could Be Better", + let noTitle = NSLocalizedString("notifications.appRatings.prompt.no.buttonTitle", value: "Could improve", comment: "This is one of the buttons we display inside of the prompt to review the app") inlinePromptView.setupYesButton(title: yesTitle) { [weak self] button in @@ -22,6 +21,7 @@ extension NotificationsViewController { } AppRatingUtility.shared.userWasPromptedToReview() + WPAnalytics.track(.appReviewsSawPrompt) } private func likedApp() { @@ -54,9 +54,9 @@ extension NotificationsViewController { UIView.animate(withDuration: 0.3) { [weak self] in self?.inlinePromptView.setupHeading(NSLocalizedString("Could you tell us how we could improve?", comment: "This is the text we display to the user when we ask them for a review and they've indicated they don't like the app")) - let yesTitle = NSLocalizedString("Send Feedback", + let yesTitle = NSLocalizedString("notifications.appRatings.sendFeedback.yes.buttonTitle", value: "Send feedback", comment: "This is one of the buttons we display when prompting the user for a review") - let noTitle = NSLocalizedString("No Thanks", + let noTitle = NSLocalizedString("notifications.appRatings.sendFeedback.no.buttonTitle", value: "No thanks", comment: "This is one of the buttons we display when prompting the user for a review") self?.inlinePromptView.setupYesButton(title: yesTitle) { [weak self] button in self?.gatherFeedback() diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+JetpackPrompt.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+JetpackPrompt.swift index 31a13f64a0e3..caf32fa94ed5 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+JetpackPrompt.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+JetpackPrompt.swift @@ -1,7 +1,11 @@ extension NotificationsViewController { + var blogForJetpackPrompt: Blog? { + return Blog.lastUsed(in: managedObjectContext()) + } + func promptForJetpackCredentials() { - guard let blog = blogService.lastUsedBlog() else { + guard let blog = blogForJetpackPrompt else { return } @@ -14,9 +18,16 @@ extension NotificationsViewController { controller.promptType = .notifications addChild(controller) tableView.addSubview(withFadeAnimation: controller.view) - controller.view.frame = CGRect(origin: .zero, size: view.frame.size) + controller.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + controller.view.topAnchor.constraint(equalTo: tableView.safeAreaLayoutGuide.topAnchor), + controller.view.leadingAnchor.constraint(equalTo: tableView.safeAreaLayoutGuide.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: tableView.safeAreaLayoutGuide.trailingAnchor), + controller.view.bottomAnchor.constraint(equalTo: tableView.safeAreaLayoutGuide.bottomAnchor) + ]) configureControllerCompletion(controller, withBlog: blog) jetpackLoginViewController = controller + controller.didMove(toParent: self) } } @@ -29,6 +40,7 @@ extension NotificationsViewController { controller?.view.removeFromSuperview() controller?.removeFromParent() self?.jetpackLoginViewController = nil + self?.configureJetpackBanner() self?.tableView.reloadData() } else { self?.activityIndicator.stopAnimating() @@ -37,11 +49,4 @@ extension NotificationsViewController { } } - - // MARK: - Private Computed Properties - - fileprivate var blogService: BlogService { - return BlogService(managedObjectContext: managedObjectContext()) - } - } diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+PushPrimer.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+PushPrimer.swift index bbe26c7b210a..a90e08b7da16 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+PushPrimer.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController+PushPrimer.swift @@ -8,7 +8,7 @@ extension NotificationsViewController { var shouldShowPrimeForPush: Bool { get { - return !UserDefaults.standard.notificationPrimerInlineWasAcknowledged + return !UserPersistentStoreFactory.instance().notificationPrimerInlineWasAcknowledged } } @@ -20,6 +20,8 @@ extension NotificationsViewController { case .denied: self?.setupWinback() default: + // The user has already allowed notifications so we set the inline prompt to acknowledged so it isn't called anymore + UserPersistentStoreFactory.instance().notificationPrimerInlineWasAcknowledged = true break } } @@ -41,10 +43,10 @@ extension NotificationsViewController { defer { WPAnalytics.track(.pushNotificationPrimerAllowTapped, withProperties: [Analytics.locationKey: Analytics.inlineKey]) } - InteractiveNotificationsManager.shared.requestAuthorization { + InteractiveNotificationsManager.shared.requestAuthorization { _ in DispatchQueue.main.async { self?.hideInlinePrompt(delay: 0.0) - UserDefaults.standard.notificationPrimerInlineWasAcknowledged = true + UserPersistentStoreFactory.instance().notificationPrimerInlineWasAcknowledged = true } } } @@ -54,7 +56,7 @@ extension NotificationsViewController { WPAnalytics.track(.pushNotificationPrimerNoTapped, withProperties: [Analytics.locationKey: Analytics.inlineKey]) } self?.hideInlinePrompt(delay: 0.0) - UserDefaults.standard.notificationPrimerInlineWasAcknowledged = true + UserPersistentStoreFactory.instance().notificationPrimerInlineWasAcknowledged = true } // We _seriously_ need to call the following method at last. @@ -65,9 +67,9 @@ extension NotificationsViewController { private func setupWinback() { // only show the winback for folks that denied without seeing the post-login primer: aka users of a previous version - guard !UserDefaults.standard.notificationPrimerAlertWasDisplayed else { + guard !UserPersistentStoreFactory.instance().notificationPrimerAlertWasDisplayed else { // they saw the primer, and denied us. they aren't coming back, we aren't bothering them anymore. - UserDefaults.standard.notificationPrimerInlineWasAcknowledged = true + UserPersistentStoreFactory.instance().notificationPrimerInlineWasAcknowledged = true return } @@ -91,7 +93,7 @@ extension NotificationsViewController { self?.hideInlinePrompt(delay: 0.0) let targetURL = URL(string: UIApplication.openSettingsURLString) UIApplication.shared.open(targetURL!) - UserDefaults.standard.notificationPrimerInlineWasAcknowledged = true + UserPersistentStoreFactory.instance().notificationPrimerInlineWasAcknowledged = true } inlinePromptView.setupNoButton(title: noTitle) { [weak self] button in @@ -99,24 +101,7 @@ extension NotificationsViewController { WPAnalytics.track(.pushNotificationWinbackNoTapped, withProperties: [Analytics.locationKey: Analytics.inlineKey]) } self?.hideInlinePrompt(delay: 0.0) - UserDefaults.standard.notificationPrimerInlineWasAcknowledged = true - } - } -} - -// MARK: - User Defaults for Push Notifications - -extension UserDefaults { - private enum Keys: String { - case notificationPrimerInlineWasAcknowledged = "notificationPrimerInlineWasAcknowledged" - } - - var notificationPrimerInlineWasAcknowledged: Bool { - get { - return bool(forKey: Keys.notificationPrimerInlineWasAcknowledged.rawValue) - } - set { - set(newValue, forKey: Keys.notificationPrimerInlineWasAcknowledged.rawValue) + UserPersistentStoreFactory.instance().notificationPrimerInlineWasAcknowledged = true } } } diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController.swift index b8fddec5f1c3..bddc589e66d0 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationsViewController.swift @@ -1,9 +1,11 @@ import Foundation +import Combine import CoreData import CocoaLumberjack import WordPressShared import WordPressAuthenticator import Gridicons +import UIKit /// The purpose of this class is to render the collection of Notifications, associated to the main /// WordPress.com account. @@ -11,7 +13,7 @@ import Gridicons /// Plus, we provide a simple mechanism to render the details for a specific Notification, /// given its remote identifier. /// -class NotificationsViewController: UITableViewController, UIViewControllerRestoration { +class NotificationsViewController: UIViewController, UIViewControllerRestoration, UITableViewDataSource, UITableViewDelegate { @objc static let selectedNotificationRestorationIdentifier = "NotificationsSelectedNotificationKey" @objc static let selectedSegmentIndexRestorationIdentifier = "NotificationsSelectedSegmentIndexKey" @@ -20,6 +22,9 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor let formatter = FormattableContentFormatter() + /// Table View + /// + @IBOutlet weak var tableView: UITableView! /// TableHeader /// @IBOutlet var tableHeaderView: UIView! @@ -28,13 +33,14 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor /// @IBOutlet weak var filterTabBar: FilterTabBar! - /// Inline Prompt Header View + /// Jetpack Banner View + /// Only visible in WordPress /// - @IBOutlet var inlinePromptView: AppFeedbackPromptView! + @IBOutlet weak var jetpackBannerView: JetpackBannerView! - /// Ensures the segmented control is below the feedback prompt + /// Inline Prompt Header View /// - @IBOutlet var inlinePromptSpaceConstraint: NSLayoutConstraint! + @IBOutlet var inlinePromptView: AppFeedbackPromptView! /// TableView Handler: Our commander in chief! /// @@ -50,7 +56,7 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor /// Indicates whether the view is required to reload results on viewWillAppear, or not /// - fileprivate var needsReloadResults = false + var needsReloadResults = false /// Cached values used for returning the estimated row heights of autosizing cells. /// @@ -81,21 +87,66 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor /// internal var jetpackLoginViewController: JetpackLoginViewController? = nil + /// Timestamp of the most recent note before updates + /// Used to count notifications to show the second notifications prompt + /// + private var timestampBeforeUpdatesForSecondAlert: String? + + private lazy var notificationCommentDetailCoordinator: NotificationCommentDetailCoordinator = { + return NotificationCommentDetailCoordinator(notificationsNavigationDataSource: self) + }() + /// Activity Indicator to be shown when refreshing a Jetpack site status. /// let activityIndicator: UIActivityIndicatorView = { - let indicator = UIActivityIndicatorView(style: .white) + let indicator = UIActivityIndicatorView(style: .medium) indicator.hidesWhenStopped = true return indicator }() /// Notification Settings button - lazy var notificationSettingsButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: Gridicon.iconOfType(.cog), style: .plain, target: self, action: #selector(showNotificationSettings)) - button.accessibilityLabel = NSLocalizedString("Notification Settings", comment: "Link to Notification Settings section") - return button + private lazy var settingsBarButtonItem: UIBarButtonItem = { + let settingsButton = UIBarButtonItem( + image: .gridicon(.cog), + style: .plain, + target: self, + action: #selector(showNotificationSettings) + ) + settingsButton.accessibilityLabel = NSLocalizedString( + "Notification Settings", + comment: "Link to Notification Settings section" + ) + return settingsButton + }() + + /// Mark All As Read button + private lazy var markAllAsReadBarButtonItem: UIBarButtonItem = { + let markButton = UIBarButtonItem( + image: .gridicon(.checkmark), + style: .plain, + target: self, + action: #selector(showMarkAllAsReadConfirmation) + ) + markButton.accessibilityLabel = NSLocalizedString( + "Mark All As Read", + comment: "Marks all notifications under the filter as read" + ) + return markButton }() + /// Used by JPScrollViewDelegate to send scroll position + internal let scrollViewTranslationPublisher = PassthroughSubject<Bool, Never>() + + /// The last time when user seen notifications + var lastSeenTime: String? { + get { + return userDefaults.string(forKey: Settings.lastSeenTime) + } + set { + userDefaults.set(newValue, forKey: Settings.lastSeenTime) + } + } + // MARK: - View Lifecycle required init?(coder aDecoder: NSCoder) { @@ -111,21 +162,27 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor super.viewDidLoad() setupNavigationBar() + setupTableHandler() setupTableView() setupTableFooterView() - layoutHeaderIfNeeded() - setupConstraints() - setupTableHandler() setupRefreshControl() setupNoResultsView() setupFilterBar() + tableView.tableHeaderView = tableHeaderView + setupConstraints() + configureJetpackBanner() + reloadTableViewPreservingSelection() + startListeningToCommentDeletedNotifications() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + JetpackFeaturesRemovalCoordinator.presentOverlayIfNeeded(in: self, source: .notifications) + + syncNotificationsWithModeratedComments() setupInlinePrompt() // Manually deselect the selected row. @@ -149,27 +206,56 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor // Refresh the UI reloadResultsControllerIfNeeded() + updateMarkAllAsReadButton() if !splitViewControllerIsHorizontallyCompact { reloadTableViewPreservingSelection() } - if !AccountHelper.isDotcomAvailable() { + if shouldDisplayJetpackPrompt { promptForJetpackCredentials() } else { - jetpackLoginViewController?.view.removeFromSuperview() - jetpackLoginViewController?.removeFromParent() + jetpackLoginViewController?.remove() } showNoResultsViewIfNeeded() + selectFirstNotificationIfAppropriate() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + + defer { + if AppConfiguration.showsWhatIsNew { + RootViewCoordinator.shared.presentWhatIsNew(on: self) + } + } + syncNewNotifications() markSelectedNotificationAsRead() registerUserActivity() + + markWelcomeNotificationAsSeenIfNeeded() + + if userDefaults.notificationsTabAccessCount < Constants.inlineTabAccessCount { + userDefaults.notificationsTabAccessCount += 1 + } + + // Don't show the notification primers if we already asked during onboarding + if userDefaults.onboardingNotificationsPromptDisplayed, userDefaults.notificationsTabAccessCount == 1 { + return + } + + if shouldShowPrimeForPush { + setupNotificationPrompt() + } else if AppRatingUtility.shared.shouldPromptForAppReview(section: InlinePrompt.section) { + setupAppRatings() + self.showInlinePrompt() + } + + showNotificationPrimerAlertIfNeeded() + showSecondNotificationsAlertIfNeeded() } override func viewWillDisappear(_ animated: Bool) { @@ -184,30 +270,9 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - layoutHeaderIfNeeded() - } - - private func layoutHeaderIfNeeded() { - precondition(tableHeaderView != nil) - // Fix: Update the Frame manually: Autolayout doesn't really help us, when it comes to Table Headers - let requiredSize = tableHeaderView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) - var headerFrame = tableHeaderView.frame - if headerFrame.height != requiredSize.height { - headerFrame.size.height = requiredSize.height - tableHeaderView.frame = headerFrame - adjustNoResultsViewSize() - - tableHeaderView.layoutIfNeeded() - - // We reassign the tableHeaderView to force the UI to refresh. Yes, really. - tableView.tableHeaderView = tableHeaderView - tableView.setNeedsLayout() - } + tableView.layoutHeaderView() } - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - return true - } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) @@ -224,13 +289,15 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor selectFirstNotificationIfAppropriate() } } + + tableView.tableHeaderView = tableHeaderView } // MARK: - State Restoration static func viewController(withRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { - return WPTabBarController.sharedInstance().notificationsViewController + return RootViewCoordinator.sharedPresenter.notificationsViewController } override func encodeRestorableState(with coder: NSCoder) { @@ -281,6 +348,7 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor return } + // TODO: add check for CommentDetailViewController for case let detailVC as NotificationDetailsViewController in navigationControllers { if detailVC.onDeletionRequestCallback == nil, let note = detailVC.note { configureDetailsViewController(detailVC, withNote: note) @@ -288,57 +356,68 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor } } - // MARK: - UITableView Methods + // MARK: - UITableViewDataSource Methods + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + tableViewHandler.tableView(tableView, numberOfRowsInSection: section) + } + + func numberOfSections(in tableView: UITableView) -> Int { + tableViewHandler.numberOfSections(in: tableView) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: ListTableViewCell.defaultReuseID, for: indexPath) + configureCell(cell, at: indexPath) + return cell + } + + func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + return true + } + + func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + return .none + } + + // MARK: - UITableViewDelegate Methods - override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let sectionInfo = tableViewHandler.resultsController.sections?[section] else { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let sectionInfo = tableViewHandler.resultsController.sections?[section], + let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: ListTableHeaderView.defaultReuseID) as? ListTableHeaderView else { return nil } - let headerView = NoteTableHeaderView.makeFromNib() headerView.title = Notification.descriptionForSectionIdentifier(sectionInfo.name) - headerView.separatorColor = tableView.separatorColor - return headerView } - override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { // Make sure no SectionFooter is rendered return CGFloat.leastNormalMagnitude } - override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { // Make sure no SectionFooter is rendered return nil } - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let identifier = NoteTableViewCell.reuseIdentifier() - guard let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as? NoteTableViewCell else { - fatalError() - } - - configureCell(cell, at: indexPath) - - return cell - } - - override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { estimatedRowHeightsCache.setObject(cell.frame.height as AnyObject, forKey: indexPath as AnyObject) } - override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { if let height = estimatedRowHeightsCache.object(forKey: indexPath as AnyObject) as? CGFloat { return height } return Settings.estimatedRowHeight } - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { // Failsafe: Make sure that the Notification (still) exists guard let note = tableViewHandler.resultsController.managedObject(atUnsafe: indexPath) as? Notification else { tableView.deselectSelectedRowWithAnimation(true) @@ -352,25 +431,31 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor selectedNotification = note showDetails(for: note) - } - override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - return .none + if !splitViewControllerIsHorizontallyCompact { + syncNotificationsWithModeratedComments() + } + } - override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - guard let note = tableViewHandler.resultsController.object(at: indexPath) as? Notification else { + func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + // skip when the notification is marked for deletion. + guard let note = tableViewHandler.resultsController.object(at: indexPath) as? Notification, + deletionRequestForNoteWithID(note.objectID) == nil else { return nil } let isRead = note.read - let title = isRead ? NSLocalizedString("Mark Unread", comment: "Marks a notification as unread") : NSLocalizedString("Mark Read", comment: "Marks a notification as unread") + let title = isRead ? NSLocalizedString("Mark Unread", comment: "Marks a notification as unread") : + NSLocalizedString("Mark Read", comment: "Marks a notification as unread") let action = UIContextualAction(style: .normal, title: title, handler: { (action, view, completionHandler) in if isRead { + WPAnalytics.track(.notificationMarkAsUnreadTapped) self.markAsUnread(note: note) } else { + WPAnalytics.track(.notificationMarkAsReadTapped) self.markAsRead(note: note) } completionHandler(true) @@ -380,49 +465,34 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor return UISwipeActionsConfiguration(actions: [action]) } - override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + // skip when the notification is marked for deletion. guard let note = tableViewHandler.resultsController.object(at: indexPath) as? Notification, - let block: FormattableCommentContent = note.contentGroup(ofKind: .comment)?.blockOfKind(.comment) else { + let block: FormattableCommentContent = note.contentGroup(ofKind: .comment)?.blockOfKind(.comment), + deletionRequestForNoteWithID(note.objectID) == nil else { return nil } - var actions: [UIContextualAction] = [] - - // Trash comment - if let trashAction = block.action(id: TrashCommentAction.actionIdentifier()), - let command = trashAction.command, - let title = command.actionTitle { - let action = UIContextualAction(style: .normal, title: title, handler: { (_, _, completionHandler) in - let actionContext = ActionContext(block: block, completion: { [weak self] (request, success) in - guard let request = request else { - return - } - self?.showUndeleteForNoteWithID(note.objectID, request: request) - }) - trashAction.execute(context: actionContext) - completionHandler(true) - }) - action.backgroundColor = command.actionColor - actions.append(action) - } - // Approve comment - guard let approveEnabled = block.action(id: ApproveCommentAction.actionIdentifier())?.enabled, approveEnabled == true else { + guard let approveEnabled = block.action(id: ApproveCommentAction.actionIdentifier())?.enabled, + approveEnabled == true, + let approveAction = block.action(id: ApproveCommentAction.actionIdentifier()), + let actionTitle = approveAction.command?.actionTitle else { return nil } - let approveAction = block.action(id: ApproveCommentAction.actionIdentifier()) - if let title = approveAction?.command?.actionTitle { - let action = UIContextualAction(style: .normal, title: title, handler: { (_, _, completionHandler) in - let actionContext = ActionContext(block: block) - approveAction?.execute(context: actionContext) - completionHandler(true) - }) - action.backgroundColor = approveAction?.command?.actionColor - actions.append(action) - } + let action = UIContextualAction(style: .normal, title: actionTitle, handler: { (_, _, completionHandler) in + WPAppAnalytics.track(approveAction.on ? .notificationsCommentUnapproved : .notificationsCommentApproved, + withProperties: [Stats.sourceKey: Stats.sourceValue], + withBlogID: block.metaSiteID) - let configuration = UISwipeActionsConfiguration(actions: actions) + let actionContext = ActionContext(block: block) + approveAction.execute(context: actionContext) + completionHandler(true) + }) + action.backgroundColor = approveAction.command?.actionColor + + let configuration = UISwipeActionsConfiguration(actions: [action]) configuration.performsFirstActionWithFullSwipe = false return configuration } @@ -440,7 +510,9 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor } fileprivate func configureDetailsViewController(_ detailsViewController: NotificationDetailsViewController, withNote note: Notification) { + detailsViewController.navigationItem.largeTitleDisplayMode = .never detailsViewController.dataSource = self + detailsViewController.notificationCommentDetailCoordinator = notificationCommentDetailCoordinator detailsViewController.note = note detailsViewController.onDeletionRequestCallback = { request in self.showUndeleteForNoteWithID(note.objectID, request: request) @@ -457,13 +529,26 @@ class NotificationsViewController: UITableViewController, UIViewControllerRestor // private extension NotificationsViewController { func setupNavigationBar() { + navigationController?.navigationBar.prefersLargeTitles = false + navigationItem.largeTitleDisplayMode = .never + // Don't show 'Notifications' in the next-view back button - navigationItem.backBarButtonItem = UIBarButtonItem(title: String(), style: .plain, target: nil, action: nil) + // we are using a space character because we need a non-empty string to ensure a smooth + // transition back, with large titles enabled. + navigationItem.backBarButtonItem = UIBarButtonItem(title: " ", style: .plain, target: nil, action: nil) navigationItem.title = NSLocalizedString("Notifications", comment: "Notifications View Controller title") } func updateNavigationItems() { - navigationItem.rightBarButtonItem = shouldDisplaySettingsButton ? notificationSettingsButton : nil + var barItems: [UIBarButtonItem] = [] + + if shouldDisplaySettingsButton { + barItems.append(settingsBarButtonItem) + } + + barItems.append(markAllAsReadBarButtonItem) + + navigationItem.setRightBarButtonItems(barItems, animated: false) } @objc func closeNotificationSettings() { @@ -471,21 +556,27 @@ private extension NotificationsViewController { } func setupConstraints() { - precondition(inlinePromptSpaceConstraint != nil) - // Inline prompt is initially hidden! - tableHeaderView.translatesAutoresizingMaskIntoConstraints = false inlinePromptView.translatesAutoresizingMaskIntoConstraints = false + filterTabBar.tabBarHeightConstraintPriority = 999 + + let leading = tableHeaderView.safeLeadingAnchor.constraint(equalTo: tableView.safeLeadingAnchor) + let trailing = tableHeaderView.safeTrailingAnchor.constraint(equalTo: tableView.safeTrailingAnchor) - tableHeaderView.centerXAnchor.constraint(equalTo: tableView.centerXAnchor).isActive = true - tableHeaderView.topAnchor.constraint(equalTo: tableView.topAnchor).isActive = true - tableHeaderView.widthAnchor.constraint(equalTo: tableView.widthAnchor).isActive = true + leading.priority = UILayoutPriority(999) + trailing.priority = UILayoutPriority(999) + + NSLayoutConstraint.activate([ + tableHeaderView.topAnchor.constraint(equalTo: tableView.topAnchor), + leading, + trailing + ]) } func setupTableView() { // Register the cells - let nib = UINib(nibName: NoteTableViewCell.classNameWithoutNamespaces(), bundle: Bundle.main) - tableView.register(nib, forCellReuseIdentifier: NoteTableViewCell.reuseIdentifier()) + tableView.register(ListTableHeaderView.defaultNib, forHeaderFooterViewReuseIdentifier: ListTableHeaderView.defaultReuseID) + tableView.register(ListTableViewCell.defaultNib, forCellReuseIdentifier: ListTableViewCell.defaultReuseID) // UITableView tableView.accessibilityIdentifier = "Notifications Table" @@ -497,7 +588,7 @@ private extension NotificationsViewController { func setupTableFooterView() { // Fix: Hide the cellSeparators, when the table is empty - tableView.tableFooterView = UIView() + tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: self.tableView.frame.size.width, height: 1)) } func setupTableHandler() { @@ -512,23 +603,13 @@ private extension NotificationsViewController { inlinePromptView.alpha = WPAlphaZero - // this allows the selector to move to the top - inlinePromptSpaceConstraint.isActive = false - - if shouldShowPrimeForPush { - setupNotificationPrompt() - } else if AppRatingUtility.shared.shouldPromptForAppReview(section: InlinePrompt.section) { - setupAppRatings() - showInlinePrompt() - } - - layoutHeaderIfNeeded() + inlinePromptView.isHidden = true } func setupRefreshControl() { let control = UIRefreshControl() control.addTarget(self, action: #selector(refresh), for: .valueChanged) - refreshControl = control + tableView.refreshControl = control } func setupNoResultsView() { @@ -537,13 +618,32 @@ private extension NotificationsViewController { func setupFilterBar() { WPStyleGuide.configureFilterTabBar(filterTabBar) - filterTabBar.superview?.backgroundColor = .filterBarBackground + filterTabBar.superview?.backgroundColor = .systemBackground - filterTabBar.items = Filter.allFilters + filterTabBar.items = Filter.allCases filterTabBar.addTarget(self, action: #selector(selectedFilterDidChange(_:)), for: .valueChanged) } } +// MARK: - Jetpack banner UI Initialization +// +extension NotificationsViewController { + + /// Called on view load to determine whether the Jetpack banner should be shown on the view + /// Also called in the completion block of the JetpackLoginViewController to show the banner once the user connects to a .com account + func configureJetpackBanner() { + guard JetpackBrandingVisibility.all.enabled else { + return + } + let textProvider = JetpackBrandingTextProvider(screen: JetpackBannerScreen.notifications) + jetpackBannerView.configure(title: textProvider.brandingText()) { [unowned self] in + JetpackBrandingCoordinator.presentOverlay(from: self) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBannerTapped(screen: .notifications) + } + jetpackBannerView.isHidden = false + addTranslationObserver(jetpackBannerView) + } +} // MARK: - Notifications @@ -569,6 +669,13 @@ private extension NotificationsViewController { object: nil) } + func startListeningToCommentDeletedNotifications() { + NotificationCenter.default.addObserver(self, + selector: #selector(removeDeletedNotification), + name: .NotificationCommentDeletedNotification, + object: nil) + } + func stopListeningToNotifications() { let nc = NotificationCenter.default nc.removeObserver(self, @@ -591,10 +698,14 @@ private extension NotificationsViewController { } @objc func defaultAccountDidChange(_ note: Foundation.Notification) { - needsReloadResults = true resetNotifications() resetLastSeenTime() resetApplicationBadge() + guard isViewLoaded == true && view.window != nil else { + needsReloadResults = true + return + } + reloadResultsController() syncNewNotifications() } @@ -673,32 +784,65 @@ extension NotificationsViewController { // Display Details // - if let postID = note.metaPostID, let siteID = note.metaSiteID, note.kind == .matcher || note.kind == .newPost { + if let postID = note.metaPostID, + let siteID = note.metaSiteID, + note.kind == .matcher || note.kind == .newPost { let readerViewController = ReaderDetailViewController.controllerWithPostID(postID, siteID: siteID) + readerViewController.navigationItem.largeTitleDisplayMode = .never showDetailViewController(readerViewController, sender: nil) + return } + presentDetails(for: note) + } + + private func presentDetails(for note: Notification) { // This dispatch avoids a bug that was occurring occasionally where navigation (nav bar and tab bar) // would be missing entirely when launching the app from the background and presenting a notification. // The issue seems tied to performing a `pop` in `prepareToShowDetails` and presenting // the new detail view controller at the same time. More info: https://github.com/wordpress-mobile/WordPress-iOS/issues/6976 // // Plus: Avoid pushing multiple DetailsViewController's, upon quick & repeated touch events. - // + view.isUserInteractionEnabled = false - DispatchQueue.main.async { - self.performSegue(withIdentifier: NotificationDetailsViewController.classNameWithoutNamespaces(), sender: note) + DispatchQueue.main.async { [weak self] in + guard let self = self else { + return + } + self.view.isUserInteractionEnabled = true + + if FeatureFlag.notificationCommentDetails.enabled, + note.kind == .comment { + guard let commentDetailViewController = self.notificationCommentDetailCoordinator.createViewController(with: note) else { + DDLogError("Notifications: failed creating Comment Detail view.") + return + } + + self.notificationCommentDetailCoordinator.onSelectedNoteChange = { [weak self] note in + self?.selectRow(for: note) + } + + commentDetailViewController.navigationItem.largeTitleDisplayMode = .never + self.showDetailViewController(commentDetailViewController, sender: nil) + + return + } + + self.performSegue(withIdentifier: NotificationDetailsViewController.classNameWithoutNamespaces(), sender: note) } } /// Tracks: Details Event! /// private func trackWillPushDetails(for note: Notification) { - let properties = [Stats.noteTypeKey: note.type ?? Stats.noteTypeUnknown] - WPAnalytics.track(.openedNotificationDetails, withProperties: properties) + // Ensure we don't track if the app has been launched by a push notification in the background + if UIApplication.shared.applicationState != .background { + let properties = [Stats.noteTypeKey: note.type ?? Stats.noteTypeUnknown] + WPAnalytics.track(.openedNotificationDetails, withProperties: properties) + } } /// Failsafe: Make sure the Notifications List is onscreen! @@ -730,7 +874,7 @@ extension NotificationsViewController { } /// Will display an Undelete button on top of a given notification. - /// On timeout, the destructive action (received via parameter) will be exeuted, and the notification + /// On timeout, the destructive action (received via parameter) will be executed, and the notification /// will (supposedly) get deleted. /// /// - Parameters: @@ -796,6 +940,73 @@ private extension NotificationsViewController { func deletionRequestForNoteWithID(_ noteObjectID: NSManagedObjectID) -> NotificationDeletionRequest? { return notificationDeletionRequests[noteObjectID] } + + + // MARK: - Notifications Deletion from CommentDetailViewController + + // With the `notificationCommentDetails` feature, Comment moderation is handled by the view. + // To avoid updating the Notifications here prematurely, affecting the previous/next buttons, + // the Notifications are tracked in NotificationCommentDetailCoordinator when their comments are moderated. + // Those Notifications are updated here when the view is shown to update the list accordingly. + func syncNotificationsWithModeratedComments() { + selectNextAvailableNotification(ignoring: notificationCommentDetailCoordinator.notificationsCommentModerated) + + notificationCommentDetailCoordinator.notificationsCommentModerated.forEach { + syncNotification(with: $0.notificationId, timeout: Syncing.pushMaxWait, success: {_ in }) + } + + notificationCommentDetailCoordinator.notificationsCommentModerated = [] + } + + @objc func removeDeletedNotification(notification: NSNotification) { + guard let userInfo = notification.userInfo, + let deletedCommentID = userInfo[userInfoCommentIdKey] as? Int32, + let notifications = tableViewHandler.resultsController.fetchedObjects as? [Notification] else { + return + } + + let notification = notifications.first(where: { notification -> Bool in + guard let commentID = notification.metaCommentID else { + return false + } + + return commentID.intValue == deletedCommentID + }) + + syncDeletedNotification(notification) + } + + func syncDeletedNotification(_ notification: Notification?) { + guard let notification = notification else { + return + } + + selectNextAvailableNotification(ignoring: [notification]) + + syncNotification(with: notification.notificationId, timeout: Syncing.pushMaxWait, success: { [weak self] notification in + self?.notificationCommentDetailCoordinator.notificationsCommentModerated.removeAll(where: { $0.notificationId == notification.notificationId }) + }) + } + + func selectNextAvailableNotification(ignoring: [Notification]) { + // If the currently selected notification is about to be removed, find the next available and select it. + // This is only necessary for split view to prevent the details from showing for removed notifications. + if !splitViewControllerIsHorizontallyCompact, + let selectedNotification = selectedNotification, + ignoring.contains(selectedNotification) { + + guard let notifications = tableViewHandler.resultsController.fetchedObjects as? [Notification], + let nextAvailable = notifications.first(where: { !ignoring.contains($0) }), + let indexPath = tableViewHandler.resultsController.indexPath(forObject: nextAvailable) else { + self.selectedNotification = nil + return + } + + self.selectedNotification = nextAvailable + tableView(tableView, didSelectRowAt: indexPath) + } + } + } @@ -803,6 +1014,17 @@ private extension NotificationsViewController { // MARK: - Marking as Read // private extension NotificationsViewController { + private enum Localization { + static let markAllAsReadNoticeSuccess = NSLocalizedString( + "Notifications marked as read", + comment: "Title for mark all as read success notice" + ) + + static let markAllAsReadNoticeFailure = NSLocalizedString( + "Failed marking Notifications as read", + comment: "Message for mark all as read success notice" + ) + } func markSelectedNotificationAsRead() { guard let note = selectedNotification else { @@ -820,12 +1042,97 @@ private extension NotificationsViewController { NotificationSyncMediator()?.markAsRead(note) } + /// Marks all messages as read under the selected filter. + /// + @objc func markAllAsRead() { + guard let notes = tableViewHandler.resultsController.fetchedObjects as? [Notification] else { + return + } + + WPAnalytics.track(.notificationsMarkAllReadTapped) + + let unreadNotifications = notes.filter { + !$0.read + } + + NotificationSyncMediator()?.markAsRead(unreadNotifications, completion: { [weak self] error in + let notice = Notice( + title: error != nil ? Localization.markAllAsReadNoticeFailure : Localization.markAllAsReadNoticeSuccess + ) + ActionDispatcherFacade().dispatch(NoticeAction.post(notice)) + self?.updateMarkAllAsReadButton() + }) + } + + /// Presents a confirmation action sheet for mark all as read action. + @objc func showMarkAllAsReadConfirmation() { + let title: String + + switch filter { + case .none: + title = NSLocalizedString( + "Mark all notifications as read?", + comment: "Confirmation title for marking all notifications as read." + ) + + default: + title = NSLocalizedString( + "Mark all %1$@ notifications as read?", + comment: "Confirmation title for marking all notifications under a filter as read. %1$@ is replaced by the filter name." + ) + } + + let cancelTitle = NSLocalizedString( + "Cancel", + comment: "Cancels the mark all as read action." + ) + let markAllTitle = NSLocalizedString( + "OK", + comment: "Marks all notifications as read." + ) + + let alertController = UIAlertController( + title: String.localizedStringWithFormat(title, filter.confirmationMessageTitle), + message: nil, + preferredStyle: .alert + ) + alertController.view.accessibilityIdentifier = "mark-all-as-read-alert" + + alertController.addCancelActionWithTitle(cancelTitle) + + alertController.addActionWithTitle(markAllTitle, style: .default) { [weak self] _ in + self?.markAllAsRead() + } + + present(alertController, animated: true, completion: nil) + } + func markAsUnread(note: Notification) { guard note.read else { return } NotificationSyncMediator()?.markAsUnread(note) + updateMarkAllAsReadButton() + } + + func markWelcomeNotificationAsSeenIfNeeded() { + let welcomeNotificationSeenKey = userDefaults.welcomeNotificationSeenKey + if !userDefaults.bool(forKey: welcomeNotificationSeenKey) { + userDefaults.set(true, forKey: welcomeNotificationSeenKey) + resetApplicationBadge() + } + } + + func updateMarkAllAsReadButton() { + guard let notes = tableViewHandler.resultsController.fetchedObjects as? [Notification] else { + return + } + + let isEnabled = notes.first { !$0.read } != nil + + markAllAsReadBarButtonItem.tintColor = isEnabled ? .primary : .textTertiary + markAllAsReadBarButtonItem.isEnabled = isEnabled } } @@ -899,6 +1206,7 @@ private extension NotificationsViewController { // Don't overwork! lastReloadDate = Date() needsReloadResults = false + updateMarkAllAsReadButton() } func reloadRowForNotificationWithID(_ noteObjectID: NSManagedObjectID) { @@ -917,10 +1225,16 @@ private extension NotificationsViewController { scrollPosition: UITableView.ScrollPosition = .none) { selectedNotification = notification - if let indexPath = tableViewHandler.resultsController.indexPath(forObject: notification), indexPath != tableView.indexPathForSelectedRow { - DDLogInfo("\(self) \(#function) Selecting row at \(indexPath) for Notification: \(notification.notificationId) (\(notification.type ?? "Unknown type")) - \(notification.title ?? "No title")") - tableView.selectRow(at: indexPath, animated: animated, scrollPosition: scrollPosition) - } + // also ensure that the index path returned from results controller does not have negative row index. + // ref: https://github.com/wordpress-mobile/WordPress-iOS/issues/15370 + guard let indexPath = tableViewHandler.resultsController.indexPath(forObject: notification), + indexPath != tableView.indexPathForSelectedRow, + 0..<tableView.numberOfRows(inSection: indexPath.section) ~= indexPath.row else { + return + } + + DDLogInfo("\(self) \(#function) Selecting row at \(indexPath) for Notification: \(notification.notificationId) (\(notification.type ?? "Unknown type")) - \(notification.title ?? "No title")") + tableView.selectRow(at: indexPath, animated: animated, scrollPosition: scrollPosition) } func reloadTableViewPreservingSelection() { @@ -938,7 +1252,7 @@ private extension NotificationsViewController { extension NotificationsViewController { @objc func refresh() { guard let mediator = NotificationSyncMediator() else { - refreshControl?.endRefreshing() + tableView.refreshControl?.endRefreshing() return } @@ -950,7 +1264,7 @@ extension NotificationsViewController { let delay = DispatchTime.now() + Double(Int64(delta * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC) DispatchQueue.main.asyncAfter(deadline: delay) { - self?.refreshControl?.endRefreshing() + self?.tableView.refreshControl?.endRefreshing() self?.clearUnreadNotifications() if let _ = error { @@ -985,7 +1299,7 @@ extension NotificationsViewController { @objc func selectedFilterDidChange(_ filterBar: FilterTabBar) { selectedNotification = nil - let properties = [Stats.selectedFilter: filter.title] + let properties = [Stats.selectedFilter: filter.analyticsTitle] WPAnalytics.track(.notificationsTappedSegmentedControl, withProperties: properties) updateUnreadNotificationsForFilterTabChange() @@ -996,18 +1310,26 @@ extension NotificationsViewController { } @objc func selectFirstNotificationIfAppropriate() { - // If we don't currently have a selected notification and there is a notification - // in the list, then select it. - if !splitViewControllerIsHorizontallyCompact && selectedNotification == nil { - if let firstNotification = tableViewHandler.resultsController.fetchedObjects?.first as? Notification, - let indexPath = tableViewHandler.resultsController.indexPath(forObject: firstNotification) { - selectRow(for: firstNotification, animated: false, scrollPosition: .none) - self.tableView(tableView, didSelectRowAt: indexPath) - } else { - // If there's no notification to select, we should wipe out - // any detail view controller that may be present. - showDetailViewController(UIViewController(), sender: nil) - } + guard !splitViewControllerIsHorizontallyCompact && selectedNotification == nil else { + return + } + + // If we don't currently have a selected notification and there is a notification in the list, then select it. + if let firstNotification = tableViewHandler.resultsController.fetchedObjects?.first as? Notification, + let indexPath = tableViewHandler.resultsController.indexPath(forObject: firstNotification) { + selectRow(for: firstNotification, animated: false, scrollPosition: .none) + self.tableView(tableView, didSelectRowAt: indexPath) + return + } + + // If we're not showing the Jetpack prompt or the fullscreen No Results View, + // then clear any detail view controller that may be present. + // (i.e. don't add an empty detail VC if the primary is full width) + if let splitViewController = splitViewController as? WPSplitViewController, + splitViewController.wpPrimaryColumnWidth != WPSplitViewControllerPrimaryColumnWidth.full { + let controller = UIViewController() + controller.navigationItem.largeTitleDisplayMode = .never + showDetailViewController(controller, sender: nil) } } @@ -1057,35 +1379,21 @@ extension NotificationsViewController: WPTableViewHandlerDelegate { } func configureCell(_ cell: UITableViewCell, at indexPath: IndexPath) { - // iOS 8 has a nice bug in which, randomly, the last cell per section was getting an extra separator. - // For that reason, we draw our own separators. - // - guard let note = tableViewHandler.resultsController.object(at: indexPath) as? Notification else { - return - } - - guard let cell = cell as? NoteTableViewCell else { + guard let note = tableViewHandler.resultsController.object(at: indexPath) as? Notification, + let cell = cell as? ListTableViewCell else { return } - let deletionRequest = deletionRequestForNoteWithID(note.objectID) - let isLastRow = tableViewHandler.resultsController.isLastIndexPathInSection(indexPath) - - cell.attributedSubject = note.renderSubject() - cell.attributedSnippet = note.renderSnippet() + cell.configureWithNotification(note) - cell.read = note.read - cell.noticon = note.noticon - cell.unapproved = note.isUnapprovedComment - cell.showsBottomSeparator = !isLastRow - cell.undeleteOverlayText = deletionRequest?.kind.legendText - cell.onUndelete = { [weak self] in + // handle undo overlays + let deletionRequest = deletionRequestForNoteWithID(note.objectID) + cell.configureUndeleteOverlay(with: deletionRequest?.kind.legendText) { [weak self] in self?.cancelDeletionRequestForNoteWithID(note.objectID) } + // additional configurations cell.accessibilityHint = Self.accessibilityHint(for: note) - - cell.downloadIconWithURL(note.iconURL) } func sectionNameKeyPath() -> String { @@ -1096,19 +1404,23 @@ extension NotificationsViewController: WPTableViewHandlerDelegate { return Notification.classNameWithoutNamespaces() } - func tableViewDidChangeContent(_ tableView: UITableView) { - // Due to an UIKit bug, we need to draw our own separators (Issue #2845). Let's update the separator status - // after a DB OP. This loop has been measured in the order of milliseconds (iPad Mini) - // - for indexPath in tableView.indexPathsForVisibleRows ?? [] { - guard let cell = tableView.cellForRow(at: indexPath) as? NoteTableViewCell else { - continue - } + private var shouldCountNotificationsForSecondAlert: Bool { + userDefaults.notificationPrimerInlineWasAcknowledged && + userDefaults.secondNotificationsAlertCount != Constants.secondNotificationsAlertDisabled + } - let isLastRow = tableViewHandler.resultsController.isLastIndexPathInSection(indexPath) - cell.showsBottomSeparator = !isLastRow + func tableViewWillChangeContent(_ tableView: UITableView) { + guard shouldCountNotificationsForSecondAlert, + let notification = tableViewHandler.resultsController.fetchedObjects?.first as? Notification, + let timestamp = notification.timestamp else { + timestampBeforeUpdatesForSecondAlert = nil + return } + timestampBeforeUpdatesForSecondAlert = timestamp + } + + func tableViewDidChangeContent(_ tableView: UITableView) { refreshUnreadNotifications() // Update NoResults View @@ -1119,6 +1431,32 @@ extension NotificationsViewController: WPTableViewHandlerDelegate { } else { selectFirstNotificationIfAppropriate() } + // count new notifications for second alert + guard shouldCountNotificationsForSecondAlert else { + return + } + + userDefaults.secondNotificationsAlertCount += newNotificationsForSecondAlert + + if isViewOnScreen() { + showSecondNotificationsAlertIfNeeded() + } + } + + // counts the new notifications for the second alert + private var newNotificationsForSecondAlert: Int { + + guard let previousTimestamp = timestampBeforeUpdatesForSecondAlert, + let notifications = tableViewHandler.resultsController.fetchedObjects as? [Notification] else { + + return 0 + } + for notification in notifications.enumerated() { + if let timestamp = notification.element.timestamp, timestamp <= previousTimestamp { + return notification.offset + } + } + return 0 } private static func accessibilityHint(for note: Notification) -> String? { @@ -1145,18 +1483,18 @@ extension NotificationsViewController: WPTableViewHandlerDelegate { // private extension NotificationsViewController { func showFiltersSegmentedControlIfApplicable() { - guard tableHeaderView.alpha == WPAlphaZero && shouldDisplayFilters == true else { + guard filterTabBar.isHidden == true && shouldDisplayFilters == true else { return } UIView.animate(withDuration: WPAnimationDurationDefault, animations: { - self.tableHeaderView.alpha = WPAlphaFull + self.filterTabBar.isHidden = false }) } func hideFiltersSegmentedControlIfApplicable() { - if tableHeaderView.alpha == WPAlphaFull && shouldDisplayFilters == false { - tableHeaderView.alpha = WPAlphaZero + if filterTabBar.isHidden == false && shouldDisplayFilters == false { + self.filterTabBar.isHidden = true } } @@ -1206,30 +1544,44 @@ private extension NotificationsViewController { addChild(noResultsViewController) tableView.insertSubview(noResultsViewController.view, belowSubview: tableHeaderView) noResultsViewController.view.frame = tableView.frame - adjustNoResultsViewSize() + setupNoResultsViewConstraints() noResultsViewController.didMove(toParent: self) } - func adjustNoResultsViewSize() { - noResultsViewController.view.frame.origin.y = tableHeaderView.frame.size.height - - if inlinePromptView.alpha == WPAlphaFull { - noResultsViewController.view.frame.size.height -= tableHeaderView.frame.size.height - } else { - noResultsViewController.view.frame.size.height = tableView.frame.size.height - tableHeaderView.frame.size.height + func setupNoResultsViewConstraints() { + guard let nrv = noResultsViewController.view else { + return } + + tableHeaderView.translatesAutoresizingMaskIntoConstraints = false + nrv.translatesAutoresizingMaskIntoConstraints = false + nrv.setContentHuggingPriority(.defaultLow, for: .horizontal) + + NSLayoutConstraint.activate([ + nrv.widthAnchor.constraint(equalTo: view.widthAnchor), + nrv.centerXAnchor.constraint(equalTo: view.centerXAnchor), + nrv.topAnchor.constraint(equalTo: tableHeaderView.bottomAnchor), + nrv.bottomAnchor.constraint(equalTo: view.safeBottomAnchor) + ]) } func updateSplitViewAppearanceForNoResultsView() { - if let splitViewController = splitViewController as? WPSplitViewController { - let columnWidth: WPSplitViewControllerPrimaryColumnWidth = (shouldDisplayFullscreenNoResultsView || shouldDisplayJetpackPrompt) ? .full : .default - if splitViewController.wpPrimaryColumnWidth != columnWidth { - splitViewController.wpPrimaryColumnWidth = columnWidth - } + guard let splitViewController = splitViewController as? WPSplitViewController else { + return + } - if columnWidth == .default { - splitViewController.dimDetailViewController(shouldDimDetailViewController) - } + // Ref: https://github.com/wordpress-mobile/WordPress-iOS/issues/14547 + // Don't attempt to resize the columns for full width. + let columnWidth: WPSplitViewControllerPrimaryColumnWidth = .default + // The above line should be replace with the following line when the full width issue is resolved. + // let columnWidth: WPSplitViewControllerPrimaryColumnWidth = (shouldDisplayFullscreenNoResultsView || shouldDisplayJetpackPrompt) ? .full : .default + + if splitViewController.wpPrimaryColumnWidth != columnWidth { + splitViewController.wpPrimaryColumnWidth = columnWidth + } + + if columnWidth == .default { + splitViewController.dimDetailViewController(shouldDimDetailViewController, withAlpha: WPAlphaZero) } } @@ -1241,16 +1593,16 @@ private extension NotificationsViewController { return filter.noResultsTitle } - var noResultsMessageText: String { + var noResultsMessageText: String? { return filter.noResultsMessage } - var noResultsButtonText: String { + var noResultsButtonText: String? { return filter.noResultsButtonTitle } var shouldDisplayJetpackPrompt: Bool { - return AccountHelper.isDotcomAvailable() == false + return AccountHelper.isDotcomAvailable() == false && blogForJetpackPrompt != nil } var shouldDisplaySettingsButton: Bool { @@ -1268,6 +1620,7 @@ private extension NotificationsViewController { var shouldDimDetailViewController: Bool { return shouldDisplayNoResultsView && filter != .none } + } // MARK: - NoResultsViewControllerDelegate @@ -1281,10 +1634,10 @@ extension NotificationsViewController: NoResultsViewControllerDelegate { .follow, .like: WPAnalytics.track(.notificationsTappedViewReader, withProperties: properties) - WPTabBarController.sharedInstance().showReaderTab() + RootViewCoordinator.sharedPresenter.showReaderTab() case .unread: WPAnalytics.track(.notificationsTappedNewPost, withProperties: properties) - WPTabBarController.sharedInstance().showPostTab() + RootViewCoordinator.sharedPresenter.showPostTab() } } } @@ -1293,30 +1646,28 @@ extension NotificationsViewController: NoResultsViewControllerDelegate { // internal extension NotificationsViewController { func showInlinePrompt() { - guard inlinePromptView.alpha != WPAlphaFull else { + guard inlinePromptView.alpha != WPAlphaFull, + userDefaults.notificationPrimerAlertWasDisplayed, + userDefaults.notificationsTabAccessCount >= Constants.inlineTabAccessCount else { return } - // allows the inline prompt to push the selector down - inlinePromptSpaceConstraint.isActive = true - - // Layout immediately the TableHeaderView. Otherwise we'll see a seriously uncool Buttons Resizing animation. - tableHeaderView.layoutIfNeeded() + UIView.animate(withDuration: WPAnimationDurationDefault, delay: 0, options: .curveEaseIn, animations: { + self.inlinePromptView.isHidden = false + }) - UIView.animate(withDuration: WPAnimationDurationDefault, delay: InlinePrompt.animationDelay, options: .curveEaseIn, animations: { + UIView.animate(withDuration: WPAnimationDurationDefault * 0.5, delay: WPAnimationDurationDefault * 0.75, options: .curveEaseIn, animations: { self.inlinePromptView.alpha = WPAlphaFull - self.layoutHeaderIfNeeded() }) - - WPAnalytics.track(.appReviewsSawPrompt) } func hideInlinePrompt(delay: TimeInterval) { - inlinePromptSpaceConstraint.isActive = false - - UIView.animate(withDuration: WPAnimationDurationDefault, delay: delay, animations: { + UIView.animate(withDuration: WPAnimationDurationDefault * 0.75, delay: delay, animations: { self.inlinePromptView.alpha = WPAlphaZero - self.layoutHeaderIfNeeded() + }) + + UIView.animate(withDuration: WPAnimationDurationDefault, delay: delay + WPAnimationDurationDefault * 0.5, animations: { + self.inlinePromptView.isHidden = true }) } } @@ -1415,7 +1766,6 @@ private extension NotificationsViewController { selectedNotification = nil mainContext.deleteAllObjects(ofType: Notification.self) try mainContext.save() - tableView.reloadData() } catch { DDLogError("Error while trying to nuke Notifications Collection: [\(error)]") } @@ -1436,25 +1786,11 @@ private extension NotificationsViewController { // extension NotificationsViewController: WPSplitViewControllerDetailProvider { func initialDetailViewControllerForSplitView(_ splitView: WPSplitViewController) -> UIViewController? { - guard let note = selectedNotification ?? fetchFirstNotification() else { - return nil - } - - selectedNotification = note - - trackWillPushDetails(for: note) - ensureNotificationsListIsOnscreen() - - if let postID = note.metaPostID, let siteID = note.metaSiteID, note.kind == .matcher || note.kind == .newPost { - return ReaderDetailViewController.controllerWithPostID(postID, siteID: siteID) - } - - if let detailsViewController = storyboard?.instantiateViewController(withIdentifier: "NotificationDetailsViewController") as? NotificationDetailsViewController { - configureDetailsViewController(detailsViewController, withNote: note) - return detailsViewController - } - - return nil + // The first notification view will be populated by `selectFirstNotificationIfAppropriate` + // on viewWillAppear, so we'll just return an empty view here. + let controller = UIViewController() + controller.view.backgroundColor = .basicBackground + return controller } private func fetchFirstNotification() -> Notification? { @@ -1515,20 +1851,11 @@ private extension NotificationsViewController { } var actionsService: NotificationActionsService { - return NotificationActionsService(managedObjectContext: mainContext) + return NotificationActionsService(coreDataStack: ContextManager.shared) } - var userDefaults: UserDefaults { - return UserDefaults.standard - } - - var lastSeenTime: String? { - get { - return userDefaults.string(forKey: Settings.lastSeenTime) - } - set { - userDefaults.setValue(newValue, forKey: Settings.lastSeenTime) - } + var userDefaults: UserPersistentRepository { + return UserPersistentStoreFactory.instance() } var filter: Filter { @@ -1542,7 +1869,7 @@ private extension NotificationsViewController { } } - enum Filter: Int, FilterTabBarItem { + enum Filter: Int, FilterTabBarItem, CaseIterable { case none = 0 case unread = 1 case comment = 2 @@ -1569,6 +1896,26 @@ private extension NotificationsViewController { } } + var analyticsTitle: String { + switch self { + case .none: return "All" + case .unread: return "Unread" + case .comment: return "Comments" + case .follow: return "Follows" + case .like: return "Likes" + } + } + + var confirmationMessageTitle: String { + switch self { + case .none: return "" + case .unread: return NSLocalizedString("unread", comment: "Displayed in the confirmation alert when marking unread notifications as read.") + case .comment: return NSLocalizedString("comment", comment: "Displayed in the confirmation alert when marking comment notifications as read.") + case .follow: return NSLocalizedString("follow", comment: "Displayed in the confirmation alert when marking follow notifications as read.") + case .like: return NSLocalizedString("like", comment: "Displayed in the confirmation alert when marking like notifications as read.") + } + } + var noResultsTitle: String { switch self { case .none: return NSLocalizedString("No notifications yet", @@ -1611,7 +1958,6 @@ private extension NotificationsViewController { } static let sortKey = "timestamp" - static let allFilters = [Filter.none, .unread, .comment, .follow, .like] } enum Settings { @@ -1637,6 +1983,112 @@ private extension NotificationsViewController { enum InlinePrompt { static let section = "notifications" - static let animationDelay = TimeInterval(0.5) + } +} + +// MARK: - Push Notifications Permission Alert +extension NotificationsViewController: UIViewControllerTransitioningDelegate { + + func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + guard let fancyAlertController = presented as? FancyAlertViewController else { + return nil + } + return FancyAlertPresentationController(presentedViewController: fancyAlertController, presenting: presenting) + } + + private func showNotificationPrimerAlertIfNeeded() { + guard shouldShowPrimeForPush, !userDefaults.notificationPrimerAlertWasDisplayed else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + Constants.displayAlertDelay) { + self.showNotificationPrimerAlert() + } + } + + private func notificationAlertApproveAction(_ controller: FancyAlertViewController) { + InteractiveNotificationsManager.shared.requestAuthorization { allowed in + if allowed { + // User has allowed notifications so we don't need to show the inline prompt + UserPersistentStoreFactory.instance().notificationPrimerInlineWasAcknowledged = true + } + + DispatchQueue.main.async { + controller.dismiss(animated: true) + } + } + } + + private func showNotificationPrimerAlert() { + let alertController = FancyAlertViewController.makeNotificationPrimerAlertController(approveAction: notificationAlertApproveAction(_:)) + showNotificationAlert(alertController) + } + + private func showSecondNotificationAlert() { + let alertController = FancyAlertViewController.makeNotificationSecondAlertController(approveAction: notificationAlertApproveAction(_:)) + showNotificationAlert(alertController) + } + + private func showNotificationAlert(_ alertController: FancyAlertViewController) { + let mainContext = ContextManager.shared.mainContext + guard (try? WPAccount.lookupDefaultWordPressComAccount(in: mainContext)) != nil else { + return + } + + PushNotificationsManager.shared.loadAuthorizationStatus { [weak self] (enabled) in + guard enabled == .notDetermined else { + return + } + + UserPersistentStoreFactory.instance().notificationPrimerAlertWasDisplayed = true + + let alert = alertController + alert.modalPresentationStyle = .custom + alert.transitioningDelegate = self + self?.tabBarController?.present(alert, animated: true) + } + } + + private func showSecondNotificationsAlertIfNeeded() { + guard userDefaults.secondNotificationsAlertCount >= Constants.secondNotificationsAlertThreshold else { + return + } + showSecondNotificationAlert() + userDefaults.secondNotificationsAlertCount = Constants.secondNotificationsAlertDisabled + } + + private enum Constants { + static let inlineTabAccessCount = 6 + static let displayAlertDelay = 0.2 + // number of notifications after which the second alert will show up + static let secondNotificationsAlertThreshold = 10 + static let secondNotificationsAlertDisabled = -1 + } +} + +// MARK: - Scrolling +// +extension NotificationsViewController: WPScrollableViewController { + // Used to scroll view to top when tapping on tab bar item when VC is already visible. + func scrollViewToTop() { + if isViewLoaded { + tableView.scrollRectToVisible(CGRect(x: 0, y: 0, width: 1, height: 1), animated: true) + } + } +} + +// MARK: - Jetpack banner delegate +// +extension NotificationsViewController: JPScrollViewDelegate { + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + processJetpackBannerVisibility(scrollView) + } +} + +// MARK: - StoryboardLoadable + +extension NotificationsViewController: StoryboardLoadable { + static var defaultStoryboardName: String { + return "Notifications" } } diff --git a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Notifiable.swift b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Notifiable.swift index 901a3452cbe1..a4796a83d579 100644 --- a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Notifiable.swift +++ b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Notifiable.swift @@ -15,6 +15,8 @@ enum NotificationKind: String { case newPost = "new_post" case post = "post" case user = "user" + case login = "push_auth" + case viewMilestone = "view_milestone" case unknown = "unknown" } @@ -24,13 +26,15 @@ extension NotificationKind { .comment, .commentLike, .like, - .matcher + .matcher, + .login, ] /// Enumerates the Kinds of rich notifications that include body text private static var kindsWithoutRichNotificationBodyText: Set<NotificationKind> = [ .commentLike, .like, + .login, ] /// Indicates whether or not a given kind of rich notification has a body support. @@ -49,6 +53,13 @@ extension NotificationKind { return kindsWithRichNotificationSupport.contains(kind) } + /// Indicates whether or not a given kind is view milestone. + /// - Parameter kind: the notification type to evaluate + /// - Returns: `true` if the notification kind is `viewMilestone`, `false` otherwise + static func isViewMilestone(_ kind: NotificationKind) -> Bool { + return kind == .viewMilestone + } + /// Returns a client-side notification category. The category provides a match to ensure that the Long Look /// can be presented. /// @@ -56,7 +67,7 @@ extension NotificationKind { /// var contentExtensionCategoryIdentifier: String? { switch self { - case .commentLike, .like, .matcher: + case .commentLike, .like, .matcher, .login: return rawValue default: return nil diff --git a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/NotificationTextContent.swift b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/NotificationTextContent.swift index fd7e60b161fc..9259402faa2d 100644 --- a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/NotificationTextContent.swift +++ b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/NotificationTextContent.swift @@ -5,6 +5,7 @@ extension FormattableContentKind { static let image = FormattableContentKind("image") static let comment = FormattableContentKind("comment") static let user = FormattableContentKind("user") + static let button = FormattableContentKind("button") } protocol FormattableMediaContent { @@ -62,6 +63,13 @@ class NotificationTextContent: FormattableTextContent, FormattableMediaContent { if let firstMedia = media.first, (firstMedia.kind == .image || firstMedia.kind == .badge) { return .image } + + if let meta = meta, + let buttonValue = meta[Constants.MetaKeys.Button] as? Bool, + buttonValue == true { + return .button + } + return super.kind } @@ -98,4 +106,8 @@ private enum Constants { static let Text = "text" static let Meta = "meta" } + + fileprivate enum MetaKeys { + static let Button = "is_mobile_button" + } } diff --git a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/BadgeContentStyles.swift b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/BadgeContentStyles.swift index 19954aafd3f4..2f055b87cd8c 100644 --- a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/BadgeContentStyles.swift +++ b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/BadgeContentStyles.swift @@ -2,25 +2,47 @@ class BadgeContentStyles: FormattableContentStyles { let key: String + let isTitle: Bool - init(cachingKey: String) { + init(cachingKey: String, isTitle: Bool) { key = cachingKey + self.isTitle = isTitle } var attributes: [NSAttributedString.Key: Any] { + if FeatureFlag.milestoneNotifications.enabled && isTitle { + return WPStyleGuide.Notifications.badgeTitleStyle + } + return WPStyleGuide.Notifications.badgeRegularStyle } var quoteStyles: [NSAttributedString.Key: Any]? { + if FeatureFlag.milestoneNotifications.enabled && isTitle { + return WPStyleGuide.Notifications.badgeTitleBoldStyle + } + return WPStyleGuide.Notifications.badgeBoldStyle } var rangeStylesMap: [FormattableRangeKind: [NSAttributedString.Key: Any]]? { + if FeatureFlag.milestoneNotifications.enabled && isTitle { + return [ + .user: WPStyleGuide.Notifications.badgeTitleBoldStyle, + .post: WPStyleGuide.Notifications.badgeTitleItalicsStyle, + .comment: WPStyleGuide.Notifications.badgeTitleItalicsStyle, + .blockquote: WPStyleGuide.Notifications.badgeTitleQuotedStyle, + .site: WPStyleGuide.Notifications.badgeTitleBoldStyle, + .strong: WPStyleGuide.Notifications.badgeTitleBoldStyle + ] + } + return [ .user: WPStyleGuide.Notifications.badgeBoldStyle, .post: WPStyleGuide.Notifications.badgeItalicsStyle, .comment: WPStyleGuide.Notifications.badgeItalicsStyle, - .blockquote: WPStyleGuide.Notifications.badgeQuotedStyle + .blockquote: WPStyleGuide.Notifications.badgeQuotedStyle, + .site: WPStyleGuide.Notifications.badgeBoldStyle ] } diff --git a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/HeaderDetailsContentStyles.swift b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/HeaderDetailsContentStyles.swift new file mode 100644 index 000000000000..19100b2009ad --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/HeaderDetailsContentStyles.swift @@ -0,0 +1,13 @@ +class HeaderDetailsContentStyles: FormattableContentStyles { + var attributes: [NSAttributedString.Key: Any] { + return WPStyleGuide.Notifications.headerDetailsRegularStyle + } + + var quoteStyles: [NSAttributedString.Key: Any]? + + var rangeStylesMap: [FormattableRangeKind: [NSAttributedString.Key: Any]]? + + var linksColor: UIColor? + + var key: String = "HeaderDetailsContentStyles" +} diff --git a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SnippetsContentStyles.swift b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SnippetsContentStyles.swift index 666a5aa6709a..4993787b87da 100644 --- a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SnippetsContentStyles.swift +++ b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SnippetsContentStyles.swift @@ -11,5 +11,5 @@ class SnippetsContentStyles: FormattableContentStyles { var linksColor: UIColor? - var key: String = "SnipetsContentStyles" + var key: String = "SnippetsContentStyles" } diff --git a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SubjectContentStyles.swift b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SubjectContentStyles.swift index af8d27f36d00..322c4c1a7508 100644 --- a/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SubjectContentStyles.swift +++ b/WordPress/Classes/ViewRelated/Notifications/FormattableContent/Styles/SubjectContentStyles.swift @@ -11,9 +11,10 @@ class SubjectContentStyles: FormattableContentStyles { var rangeStylesMap: [FormattableRangeKind: [NSAttributedString.Key: Any]]? { return [ - .user: WPStyleGuide.Notifications.subjectBoldStyle, - .post: WPStyleGuide.Notifications.subjectBoldStyle, - .comment: WPStyleGuide.Notifications.subjectBoldStyle, + .user: WPStyleGuide.Notifications.subjectSemiBoldStyle, + .post: WPStyleGuide.Notifications.subjectSemiBoldStyle, + .site: WPStyleGuide.Notifications.subjectSemiBoldStyle, + .comment: WPStyleGuide.Notifications.subjectSemiBoldStyle, .blockquote: WPStyleGuide.Notifications.subjectQuotedStyle, .noticon: WPStyleGuide.Notifications.subjectNoticonStyle ] diff --git a/WordPress/Classes/ViewRelated/Notifications/Milestone Notifications/ConfettiView.swift b/WordPress/Classes/ViewRelated/Notifications/Milestone Notifications/ConfettiView.swift new file mode 100644 index 000000000000..01390459fb72 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/Milestone Notifications/ConfettiView.swift @@ -0,0 +1,257 @@ +import UIKit +import WordPressUI + +class ConfettiView: UIView { + public struct EmitterConfig { + + /// How long the emitter run before fading out + /// higher number means more particles for longer + var duration: TimeInterval = 2.0 + + /// The number of particles created every second + /// higher number = lots more particles moving faster + var birthRate: Float = 40 + + /// A range of when particles are created + /// honestly not really sure, 10 seems to be good though heh. + var lifetime: Float = 15 + + /// Percent value that defines the range of sizes the particles can be + var scaleRange: CGFloat = 0.1 + + /// Percent value that defines the scale of the contents of the particle + /// based on the the Particle image size + var scale: CGFloat = 0.4 + } + + public struct Particle { + let image: UIImage + let tintColor: UIColor + + func tintedImage() -> UIImage { + guard let returnImage = image.imageWithTintColor(tintColor) else { + return image + } + + return returnImage + } + } + + typealias AnimationCompletion = (ConfettiView) -> Void + public var onAnimationCompletion: AnimationCompletion? + + // MARK: - Config + override init(frame: CGRect) { + super.init(frame: .zero) + + configure() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + configure() + } + + private func configure() { + isUserInteractionEnabled = false + backgroundColor = .clear + } + + // MARK: - Public: Animations + public func emit(with particles: [Particle], config: EmitterConfig) { + let emitterLayer = ParticleEmitterLayer(with: particles, config: config) + emitterLayer.frame = bounds + + layer.addSublayer(emitterLayer) + + fadeOut(layer: emitterLayer, after: config.duration) + } + + private func fadeOut(layer: ParticleEmitterLayer, after duration: TimeInterval) { + let animation = CAKeyframeAnimation(keyPath: #keyPath(CAEmitterLayer.birthRate)) + animation.duration = duration + animation.timingFunction = CAMediaTimingFunction(name: .easeOut) + animation.fillMode = .forwards + animation.values = [1, 0, 0] + animation.keyTimes = [0, 0.5, 1] + animation.isRemovedOnCompletion = false + + layer.birthRate = 1.0 + + CATransaction.begin() + CATransaction.setCompletionBlock { + let transition = CATransition() + transition.delegate = self + transition.type = .fade + transition.duration = duration * 0.5 + transition.timingFunction = CAMediaTimingFunction(name: .easeOut) + transition.setValue(layer, forKey: Constants.animationLayerKey) + transition.isRemovedOnCompletion = false + + layer.add(transition, forKey: nil) + layer.opacity = 0 + } + + layer.add(animation, forKey: nil) + CATransaction.commit() + } + + // MARK: - Private: ParticleEmitterLayer + private class ParticleEmitterLayer: CAEmitterLayer { + init(with particles: [Particle], config: EmitterConfig) { + super.init() + + needsDisplayOnBoundsChange = true + emitterCells = particles.map { ParticleCell(with: $0, config: config) } + } + + override func layoutSublayers() { + super.layoutSublayers() + + emitterMode = .outline + emitterShape = .line + emitterSize = CGSize(width: bounds.width, height: 1.0) + emitterPosition = CGPoint(x: bounds.midX, y: 0) + } + + override init(layer: Any) { + super.init(layer: layer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private class ParticleCell: CAEmitterCell { + init(with particle: Particle, config: EmitterConfig) { + super.init() + + contents = particle.tintedImage().cgImage + birthRate = config.birthRate + lifetime = config.lifetime + lifetimeRange = 7 + scale = config.scale + scaleRange = config.scaleRange + beginTime = CACurrentMediaTime() + velocity = CGFloat(birthRate * lifetime) + velocityRange = velocity * 0.5 + emissionLongitude = .pi + emissionRange = .pi / 4 + spinRange = .pi * 8 + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + } + + private struct Constants { + static let animationLayerKey = "org.wordpress.confetti" + } +} + +// MARK: - Animation Delegate +extension ConfettiView: CAAnimationDelegate { + func animationDidStop(_ anim: CAAnimation, finished flag: Bool) { + guard let layer = anim.value(forKey: Constants.animationLayerKey) as? ParticleEmitterLayer else { + return + } + + layer.removeAllAnimations() + layer.removeFromSuperlayer() + + onAnimationCompletion?(self) + } +} + + +extension ConfettiView { + + func emitConfetti() { + // Images + guard let star = UIImage(named: "confetti-star"), + let circle = UIImage(named: "confetti-circle"), + let hotdog = UIImage(named: "confetti-hotdog") else { + return + } + + // Colors + let purple = UIColor(light: .muriel(name: .purple, .shade20), dark: .muriel(name: .purple, .shade40)) + + let orange = UIColor(light: .muriel(name: .orange, .shade10), dark: .muriel(name: .orange, .shade30)) + + let green = UIColor(light: .muriel(name: .green, .shade10), dark: .muriel(name: .green, .shade30)) + + let celadon = UIColor(light: .muriel(name: .celadon, .shade10), dark: .muriel(name: .celadon, .shade20)) + + let pink = UIColor(light: .muriel(name: .pink, .shade20), dark: .muriel(name: .pink, .shade40)) + + let red = UIColor(light: .muriel(name: .red, .shade10), dark: .muriel(name: .red, .shade30)) + + let blue = UIColor(light: .muriel(name: .blue, .shade30), dark: .muriel(name: .blue, .shade50)) + + let yellow = UIColor(light: .muriel(name: .yellow, .shade10), dark: .muriel(name: .yellow, .shade30)) + + + let starParticles = [purple, orange, green, blue].map { Particle(image: star, tintColor: $0) } + let circleParticles = [celadon, pink, red, yellow].map { Particle(image: circle, tintColor: $0) } + let hotdogParticles = [orange, pink, blue, red].map { Particle(image: hotdog, tintColor: $0) } + + let particles = starParticles + circleParticles + hotdogParticles + + self.emit(with: particles, config: ConfettiView.EmitterConfig()) + } +} + +// MARK: - Convenience methods to add/remove ConfettiView from any view +extension ConfettiView { + + /// Adds an instance of ConfettiView to the specified view + /// - Parameters: + /// - view: the view where to add the ConfettiView + /// - frame: optional frame for ConfettiView + /// - onAnimationCompletion: optional closure to be executed when the animation ends + /// - Returns: the newly created instance of ConfettiView + static func add(on view: UIView, + frame: CGRect? = nil, + onAnimationCompletion: AnimationCompletion? = nil) -> ConfettiView { + + let confettiView = ConfettiView() + + if let frame = frame { + confettiView.frame = frame + } + + confettiView.onAnimationCompletion = onAnimationCompletion + + view.addSubview(confettiView) + + return confettiView + } + + /// Remove any existing instance of ConfettiView from the specified view + /// - Parameter view: the view to remove ConfettiView instances from + static func removeAll(from view: UIView) { + + let existingConfettiViews = view.subviews.filter { $0.isKind(of: ConfettiView.self) } + + existingConfettiViews.forEach { + $0.removeFromSuperview() + } + } + + /// combines the two previous methods, removing any existing ConfettiView instance before adding a new one and firing the animation + static func cleanupAndAnimate(on view: UIView, + frame: CGRect? = nil, + onAnimationCompletion: AnimationCompletion? = nil) { + + removeAll(from: view) + + add(on: view, + frame: frame, + onAnimationCompletion: onAnimationCompletion) + .emitConfetti() + } +} diff --git a/WordPress/Classes/ViewRelated/Notifications/Notifications.storyboard b/WordPress/Classes/ViewRelated/Notifications/Notifications.storyboard index 10f580d8bfd3..fd4aaa9028e7 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Notifications.storyboard +++ b/WordPress/Classes/ViewRelated/Notifications/Notifications.storyboard @@ -1,80 +1,93 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="doV-5W-Rtg"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="aRr-Yu-ntD"> + <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <scenes> <!--Notifications View Controller--> - <scene sceneID="CAK-Wk-k64"> + <scene sceneID="30e-Wx-jwX"> <objects> - <tableViewController storyboardIdentifier="NotificationsViewController" useStoryboardIdentifierAsRestorationIdentifier="YES" id="doV-5W-Rtg" customClass="NotificationsViewController" customModule="WordPress" sceneMemberID="viewController"> - <tableView key="view" opaque="NO" clipsSubviews="YES" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="none" rowHeight="44" sectionHeaderHeight="28" sectionFooterHeight="28" id="XCV-Uv-qac"> + <viewController storyboardIdentifier="NotificationsViewController" extendedLayoutIncludesOpaqueBars="YES" id="aRr-Yu-ntD" customClass="NotificationsViewController" customModule="WordPress" customModuleProvider="target" sceneMemberID="viewController"> + <layoutGuides> + <viewControllerLayoutGuide type="top" id="6eI-Lt-Q4C"/> + <viewControllerLayoutGuide type="bottom" id="X8S-C3-Z2w"/> + </layoutGuides> + <view key="view" contentMode="scaleToFill" id="TcG-d5-FzX"> <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> - <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <inset key="separatorInset" minX="12" minY="0.0" maxX="0.0" maxY="0.0"/> - <connections> - <outlet property="dataSource" destination="doV-5W-Rtg" id="rEG-tS-Iui"/> - <outlet property="delegate" destination="doV-5W-Rtg" id="Owq-LQ-4wG"/> - </connections> - </tableView> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="a0S-nk-6Lg"> + <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <subviews> + <tableView opaque="NO" clipsSubviews="YES" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="44" sectionHeaderHeight="28" sectionFooterHeight="28" id="vdx-hy-x4l"> + <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <inset key="separatorInset" minX="12" minY="0.0" maxX="0.0" maxY="0.0"/> + </tableView> + <view hidden="YES" contentMode="scaleToFill" verticalHuggingPriority="750" id="fx7-Fo-KUl" customClass="JetpackBannerView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="667" width="375" height="0.0"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </view> + </subviews> + </stackView> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="trailing" secondItem="a0S-nk-6Lg" secondAttribute="trailing" id="1pV-6w-3ye"/> + <constraint firstItem="a0S-nk-6Lg" firstAttribute="top" secondItem="TcG-d5-FzX" secondAttribute="top" id="9mC-4L-0ob"/> + <constraint firstAttribute="bottom" secondItem="a0S-nk-6Lg" secondAttribute="bottom" id="G65-sR-kE3"/> + <constraint firstItem="a0S-nk-6Lg" firstAttribute="leading" secondItem="TcG-d5-FzX" secondAttribute="leading" id="ctz-Ij-aHv"/> + </constraints> + </view> + <extendedEdge key="edgesForExtendedLayout" top="YES"/> <connections> - <outlet property="filterTabBar" destination="LBU-zb-iai" id="qqB-Tp-xXt"/> - <outlet property="inlinePromptSpaceConstraint" destination="MBK-Ez-h7B" id="NJj-XL-4AG"/> - <outlet property="inlinePromptView" destination="ZnY-3K-upT" id="lUG-fd-Stc"/> - <outlet property="tableHeaderView" destination="Uvo-9e-l6I" id="dxx-mK-msp"/> - <segue destination="veA-Pg-QAw" kind="showDetail" identifier="NotificationDetailsViewController" id="qci-jy-59F"/> + <outlet property="filterTabBar" destination="XgY-0j-kFz" id="LlQ-Kc-Yyz"/> + <outlet property="inlinePromptView" destination="jc0-PX-EvN" id="yPU-Nj-hlp"/> + <outlet property="jetpackBannerView" destination="fx7-Fo-KUl" id="YYv-mK-dV3"/> + <outlet property="tableHeaderView" destination="9K7-oU-83Y" id="dac-fs-2oN"/> + <outlet property="tableView" destination="vdx-hy-x4l" id="fyJ-yE-QCH"/> + <segue destination="veA-Pg-QAw" kind="showDetail" identifier="NotificationDetailsViewController" id="yX6-4W-SGq"/> </connections> - </tableViewController> - <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" id="Uvo-9e-l6I" userLabel="Header View"> + </viewController> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="equalSpacing" id="9K7-oU-83Y"> <rect key="frame" x="0.0" y="0.0" width="600" height="144"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <subviews> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ZnY-3K-upT" userLabel="Ratings View" customClass="AppFeedbackPromptView" customModule="WordPress" customModuleProvider="target"> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jc0-PX-EvN" userLabel="Ratings View" customClass="AppFeedbackPromptView" customModule="WordPress" customModuleProvider="target"> <rect key="frame" x="0.0" y="0.0" width="600" height="100"/> <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> <constraints> - <constraint firstAttribute="height" constant="100" placeholder="YES" id="R1T-Iv-5Q8"/> + <constraint firstAttribute="height" constant="100" placeholder="YES" id="pxu-yE-rHC"/> </constraints> </view> - <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="pEF-oN-cih" userLabel="Filters View"> + <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="rKm-gY-cFw" userLabel="Filters View"> <rect key="frame" x="0.0" y="100" width="600" height="44"/> <subviews> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LBU-zb-iai" customClass="FilterTabBar" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="600" height="44"/> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="XgY-0j-kFz" customClass="FilterTabBar" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="600" height="34"/> <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> </view> </subviews> - <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> <constraints> - <constraint firstAttribute="bottom" secondItem="LBU-zb-iai" secondAttribute="bottom" id="6uy-tW-55g"/> - <constraint firstItem="LBU-zb-iai" firstAttribute="leading" secondItem="pEF-oN-cih" secondAttribute="leading" priority="999" id="9sh-kF-RQv"/> - <constraint firstItem="LBU-zb-iai" firstAttribute="top" secondItem="pEF-oN-cih" secondAttribute="top" id="Ib2-7V-nPZ"/> - <constraint firstAttribute="trailing" secondItem="LBU-zb-iai" secondAttribute="trailing" priority="999" id="Zhl-Rn-WR6"/> + <constraint firstAttribute="trailing" secondItem="XgY-0j-kFz" secondAttribute="trailing" priority="999" id="7Mp-Sv-zuM"/> + <constraint firstItem="XgY-0j-kFz" firstAttribute="leading" secondItem="rKm-gY-cFw" secondAttribute="leading" priority="999" id="JVy-y6-rph"/> + <constraint firstAttribute="height" priority="999" constant="44" id="dte-R2-ISl"/> + <constraint firstAttribute="bottom" secondItem="XgY-0j-kFz" secondAttribute="bottom" constant="10" id="mxz-xp-47Y"/> + <constraint firstItem="XgY-0j-kFz" firstAttribute="top" secondItem="rKm-gY-cFw" secondAttribute="top" id="udW-ag-i42"/> </constraints> </view> </subviews> - <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstItem="pEF-oN-cih" firstAttribute="leading" secondItem="Uvo-9e-l6I" secondAttribute="leading" priority="999" identifier="FiltersLeading" id="0TE-M7-WfE"/> - <constraint firstAttribute="trailing" secondItem="pEF-oN-cih" secondAttribute="trailing" priority="999" identifier="FiltersTrailing" id="222-MX-igM"/> - <constraint firstItem="ZnY-3K-upT" firstAttribute="leading" secondItem="Uvo-9e-l6I" secondAttribute="leading" priority="999" identifier="RatingsLeading" id="27p-cV-siE"/> - <constraint firstItem="pEF-oN-cih" firstAttribute="top" secondItem="ZnY-3K-upT" secondAttribute="bottom" identifier="FiltersTop" id="MBK-Ez-h7B"/> - <constraint firstItem="ZnY-3K-upT" firstAttribute="top" secondItem="Uvo-9e-l6I" secondAttribute="top" priority="750" identifier="RatingsTop" id="SJu-PU-nzt"/> - <constraint firstAttribute="trailing" secondItem="ZnY-3K-upT" secondAttribute="trailing" priority="999" identifier="RatingsTrailing" id="b8L-0Q-JCB"/> - <constraint firstItem="pEF-oN-cih" firstAttribute="top" relation="greaterThanOrEqual" secondItem="Uvo-9e-l6I" secondAttribute="top" id="iOI-vG-EsO"/> - <constraint firstItem="pEF-oN-cih" firstAttribute="centerX" secondItem="Uvo-9e-l6I" secondAttribute="centerX" identifier="FiltersCenter" id="rGu-9k-JxJ"/> - <constraint firstAttribute="bottom" secondItem="pEF-oN-cih" secondAttribute="bottom" identifier="FiltersBottom" id="zwD-z0-d6i"/> - </constraints> - </view> - <placeholder placeholderIdentifier="IBFirstResponder" id="9wK-eg-RBm" userLabel="First Responder" sceneMemberID="firstResponder"/> + </stackView> + <placeholder placeholderIdentifier="IBFirstResponder" id="KJC-DS-ThZ" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/> </objects> - <point key="canvasLocation" x="700" y="-1061"/> + <point key="canvasLocation" x="372" y="-1061"/> </scene> <!--Notification Details--> <scene sceneID="0B7-mU-JSs"> @@ -85,11 +98,11 @@ <viewControllerLayoutGuide type="bottom" id="6LW-NS-qSh"/> </layoutGuides> <view key="view" contentMode="scaleToFill" id="lvM-1n-Dgf"> - <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="647"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="t2r-NP-ili"> - <rect key="frame" x="0.0" y="20" width="375" height="647"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="647"/> <subviews> <tableView opaque="NO" clipsSubviews="YES" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" rowHeight="44" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="Dcn-Il-AtN" customClass="IntrinsicTableView" customModule="WordPress"> <rect key="frame" x="0.0" y="0.0" width="375" height="647"/> @@ -131,4 +144,9 @@ <point key="canvasLocation" x="1446" y="-1061"/> </scene> </scenes> + <resources> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> </document> diff --git a/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyBezierView.swift b/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyBezierView.swift deleted file mode 100644 index 7391afe274f5..000000000000 --- a/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyBezierView.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -import WordPressShared.WPStyleGuide - -// NOTE: -// ReplyBezierView is a helper class, used to render the ReplyTextView bubble -// -class ReplyBezierView: UIView { - @objc var outerColor = WPStyleGuide.Reply.backgroundColor { - didSet { - setNeedsDisplay() - } - } - @objc var bezierColor = WPStyleGuide.Reply.separatorColor { - didSet { - setNeedsDisplay() - } - } - - @objc var bezierFillColor: UIColor? = nil { - didSet { - setNeedsDisplay() - } - } - - @objc var bezierRadius = CGFloat(5) { - didSet { - setNeedsDisplay() - } - } - @objc var insets = UIEdgeInsets(top: 8, left: 1, bottom: 8, right: 1) { - didSet { - setNeedsDisplay() - } - } - - // MARK: - Initializers - required init(coder aDecoder: NSCoder) { - super.init(coder: aDecoder)! - setupView() - } - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - } - - fileprivate func setupView() { - // Make sure this is re-drawn on rotation events - layer.needsDisplayOnBoundsChange = true - } - - // MARK: - View Methods - override func draw(_ rect: CGRect) { - // Draw the background, while clipping a rounded rect with the given insets - var bezierRect = bounds - bezierRect.origin.x += insets.left - bezierRect.origin.y += insets.top - bezierRect.size.height -= insets.top + insets.bottom - bezierRect.size.width -= insets.left + insets.right - let bezier = UIBezierPath(roundedRect: bezierRect, cornerRadius: bezierRadius) - let outer = UIBezierPath(rect: bounds) - - if let fillColor = bezierFillColor { - fillColor.set() - bezier.fill() - } - - bezierColor.set() - bezier.stroke() - - outerColor.set() - bezier.append(outer) - bezier.usesEvenOddFillRule = true - bezier.fill() - } -} diff --git a/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.swift b/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.swift index 8ea74bd7e5e3..8776ab45a89d 100644 --- a/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.swift +++ b/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.swift @@ -6,16 +6,16 @@ import Gridicons // @objc public protocol ReplyTextViewDelegate: UITextViewDelegate { @objc optional func textView(_ textView: UITextView, didTypeWord word: String) + + @objc optional func replyTextView(_ replyTextView: ReplyTextView, willEnterFullScreen controller: FullScreenCommentReplyViewController) + + @objc optional func replyTextView(_ replyTextView: ReplyTextView, didExitFullScreen lastSearchText: String?) } // MARK: - ReplyTextView // @objc open class ReplyTextView: UIView, UITextViewDelegate { - private struct AnimationParameters { - static let focusTransitionTime = TimeInterval(0.3) - static let stateTransitionTime = TimeInterval(0.2) - } // MARK: - Initializers @objc public convenience init(width: CGFloat) { @@ -107,7 +107,6 @@ import Gridicons open func textViewDidBeginEditing(_ textView: UITextView) { delegate?.textViewDidBeginEditing?(textView) - transitionReplyButton() } open func textViewShouldEndEditing(_ textView: UITextView) -> Bool { @@ -116,7 +115,6 @@ import Gridicons open func textViewDidEndEditing(_ textView: UITextView) { delegate?.textViewDidEndEditing?(textView) - transitionReplyButton() } open func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { @@ -175,22 +173,24 @@ import Gridicons } @IBAction fileprivate func btnEnterFullscreenPressed(_ sender: Any) { - guard let editViewController = FullScreenCommentReplyViewController.newEdit() else { - return - } + let rootViewController = RootViewCoordinator.sharedPresenter.rootViewController - guard let presenter = WPTabBarController.sharedInstance() else { - return + let editViewController = FullScreenCommentReplyViewController() + + // Inform any listeners + let respondsToWillEnter = delegate?.responds(to: #selector(ReplyTextViewDelegate.replyTextView(_:willEnterFullScreen:))) ?? false + + if respondsToWillEnter { + delegate?.replyTextView?(self, willEnterFullScreen: editViewController) } // Snapshot the first reponder status before presenting so we can restore it later let didHaveFirstResponder = textView.isFirstResponder editViewController.content = textView.text - if #available(iOS 13.0, *) { - editViewController.isModalInPresentation = true - } - editViewController.onExitFullscreen = { (shouldSave, updatedContent) in + editViewController.placeholder = placeholder + editViewController.isModalInPresentation = true + editViewController.onExitFullscreen = { (shouldSave, updatedContent, lastSearchText) in self.text = updatedContent // If the user was editing before they entered fullscreen, then restore that state @@ -207,14 +207,20 @@ import Gridicons self.btnReplyPressed() } - //Dimiss the fullscreen view, once it has fully closed process the saving if needed - presenter.dismiss(animated: true) + // Dismiss the fullscreen view, once it has fully closed process the saving if needed + rootViewController.dismiss(animated: true) { + let respondsToDidExit = self.delegate?.responds(to: #selector(ReplyTextViewDelegate.replyTextView(_:didExitFullScreen:))) ?? false + + if respondsToDidExit { + self.delegate?.replyTextView?(self, didExitFullScreen: lastSearchText) + } + } } self.resignFirstResponder() let navController = LightNavigationController(rootViewController: editViewController) - presenter.present(navController, animated: true) + rootViewController.present(navController, animated: true) } // MARK: - Gestures Recognizers @@ -262,55 +268,38 @@ import Gridicons contentView.translatesAutoresizingMaskIntoConstraints = false pinSubviewToAllEdges(contentView) - // Setup the TextView + // TextView textView.delegate = self textView.scrollsToTop = false textView.contentInset = .zero textView.textContainerInset = .zero - textView.backgroundColor = WPStyleGuide.Reply.textViewBackground - textView.font = WPStyleGuide.Reply.textFont - textView.textColor = WPStyleGuide.Reply.textColor + textView.autocorrectionType = .yes + textView.textColor = Style.textColor textView.textContainer.lineFragmentPadding = 0 textView.layoutManager.allowsNonContiguousLayout = false textView.accessibilityIdentifier = "ReplyText" - // Enable QuickType - textView.autocorrectionType = .yes - // Placeholder - placeholderLabel.font = WPStyleGuide.Reply.textFont - placeholderLabel.textColor = WPStyleGuide.Reply.placeholderColor + placeholderLabel.textColor = Style.placeholderColor // Fullscreen toggle button - let fullscreenImage = Gridicon.iconOfType(.chevronUp) - fullscreenToggleButton.setImage(fullscreenImage, for: .normal) + fullscreenToggleButton.setImage(.gridicon(.chevronUp), for: .normal) fullscreenToggleButton.tintColor = .listIcon fullscreenToggleButton.accessibilityLabel = NSLocalizedString("Enter Full Screen", comment: "Accessibility Label for the enter full screen button on the comment reply text view") - - // Reply - let replyIcon = UIImage(named: "icon-comment-reply") - replyButton.setImage(replyIcon?.imageWithTintColor(WPStyleGuide.Reply.enabledColor), for: .normal) - replyButton.setImage(replyIcon?.imageWithTintColor(WPStyleGuide.Reply.disabledColor), for: .disabled) - - replyButton.isEnabled = false + // Reply button + replyButton.setTitleColor(Style.replyButtonColor, for: .normal) + replyButton.titleLabel?.text = NSLocalizedString("Reply", comment: "Reply to a comment.") replyButton.accessibilityLabel = NSLocalizedString("Reply", comment: "Accessibility label for the reply button") - - transitionReplyButton(animated: false) + refreshReplyButton() // Background - contentView.backgroundColor = WPStyleGuide.Reply.backgroundColor - bezierContainerView.outerColor = WPStyleGuide.Reply.backgroundColor + contentView.backgroundColor = Style.backgroundColor - // Bezier - bezierContainerView.bezierColor = WPStyleGuide.Reply.backgroundColor - bezierContainerView.bezierFillColor = WPStyleGuide.Reply.textViewBackground - bezierContainerView.translatesAutoresizingMaskIntoConstraints = false - - // Separators - separatorsView.topColor = WPStyleGuide.Reply.separatorColor - separatorsView.topVisible = true + // Top Separator + topSeparator.backgroundColor = Style.separatorColor + topSeparatorHeightConstraint.constant = .hairlineBorderWidth // Recognizers let recognizer = UITapGestureRecognizer(target: self, action: #selector(ReplyTextView.backgroundWasTapped)) @@ -324,7 +313,7 @@ import Gridicons // MARK: - Refresh Helpers fileprivate func refreshInterface() { refreshPlaceholder() - enableRefreshButtonIfNeeded() + refreshReplyButton() refreshSizeIfNeeded() refreshScrollPosition() } @@ -344,20 +333,8 @@ import Gridicons placeholderLabel.isHidden = !textView.text.isEmpty } - private func enableRefreshButtonIfNeeded() { - let whitespaceCharSet = CharacterSet.whitespacesAndNewlines - let isEnabled = self.textView.text.trimmingCharacters(in: whitespaceCharSet).isEmpty == false - - if isEnabled == self.replyButton.isEnabled { - return - } - - UIView.transition(with: replyButton as UIView, - duration: AnimationParameters.stateTransitionTime, - options: .transitionCrossDissolve, - animations: { - self.replyButton.isEnabled = isEnabled - }) + private func refreshReplyButton() { + replyButtonView.isHidden = textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } fileprivate func refreshScrollPosition() { @@ -367,37 +344,22 @@ import Gridicons textView.scrollRectToVisible(caretRect, animated: false) } - fileprivate func transitionReplyButton(animated: Bool = true) { - replyButtonTrailingConstraint.constant = isFirstResponder ? 0.0 : -(frame.width * 2) - - let updateFrame = { - self.layoutIfNeeded() - } - - if animated { - UIView.animate(withDuration: AnimationParameters.focusTransitionTime) { - updateFrame() - } - } - else { - updateFrame() - } - } - // MARK: - Private Properties fileprivate var bundle: NSArray? + private typealias Style = WPStyleGuide.Reply // MARK: - IBOutlets - @IBOutlet private var textView: UITextView! - @IBOutlet private var placeholderLabel: UILabel! - @IBOutlet private var replyButton: UIButton! - @IBOutlet private var bezierContainerView: ReplyBezierView! - @IBOutlet private var separatorsView: SeparatorsView! - @IBOutlet private var contentView: UIView! - @IBOutlet private var bezierTopConstraint: NSLayoutConstraint! - @IBOutlet private var bezierBottomConstraint: NSLayoutConstraint! - @IBOutlet private weak var replyButtonTrailingConstraint: NSLayoutConstraint! - @IBOutlet weak var fullscreenToggleButton: UIButton! + @IBOutlet private weak var contentView: UIView! + @IBOutlet private weak var topSeparator: UIView! + @IBOutlet private weak var topSeparatorHeightConstraint: NSLayoutConstraint! + @IBOutlet private weak var stackViewTopConstraint: NSLayoutConstraint! + @IBOutlet private weak var stackViewBottomConstraint: NSLayoutConstraint! + @IBOutlet private weak var fullscreenToggleButton: UIButton! + @IBOutlet private weak var textContainerView: UIView! + @IBOutlet private weak var textView: UITextView! + @IBOutlet private weak var placeholderLabel: UILabel! + @IBOutlet private weak var replyButtonView: UIView! + @IBOutlet private weak var replyButton: UIButton! } @@ -405,11 +367,10 @@ import Gridicons // private extension ReplyTextView { - /// Padding: Bezier Margins (Top / Bottom) + Bezier Constraints (Top / Bottom) + /// Padding /// var contentPadding: CGFloat { - return bezierContainerView.layoutMargins.top + bezierContainerView.layoutMargins.bottom - + bezierTopConstraint.constant + bezierBottomConstraint.constant + return stackViewTopConstraint.constant + stackViewBottomConstraint.constant } /// Returns the Content Height (non capped). diff --git a/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.xib b/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.xib index feece4a8da26..bff11aee058d 100644 --- a/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.xib +++ b/WordPress/Classes/ViewRelated/Notifications/ReplyTextView/ReplyTextView.xib @@ -1,233 +1,118 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ReplyTextView" customModule="WordPress"> <connections> - <outlet property="bezierBottomConstraint" destination="rqH-Tc-Wyo" id="U8p-lD-ep8"/> - <outlet property="bezierContainerView" destination="t6q-rh-Bzh" id="eED-vb-5Ix"/> - <outlet property="bezierTopConstraint" destination="f52-SP-Tv4" id="rx5-UP-7ZA"/> <outlet property="contentView" destination="iN0-l3-epB" id="dtx-td-PDh"/> <outlet property="fullscreenToggleButton" destination="wxv-ga-1LS" id="4rW-sS-Jh9"/> <outlet property="placeholderLabel" destination="6Lf-XI-exE" id="vNK-7w-Wk1"/> <outlet property="replyButton" destination="8sg-79-AsR" id="z4S-0x-kJt"/> - <outlet property="replyButtonTrailingConstraint" destination="4MX-yD-sG2" id="oKk-La-NVz"/> - <outlet property="separatorsView" destination="IdZ-UI-Nwf" id="Nuf-kh-FbD"/> + <outlet property="replyButtonView" destination="lA2-1V-bck" id="sGU-CP-aET"/> + <outlet property="stackViewBottomConstraint" destination="xfU-fS-R04" id="mPP-8B-XRQ"/> + <outlet property="stackViewTopConstraint" destination="wjV-vs-veI" id="ti5-gz-g7H"/> + <outlet property="textContainerView" destination="t6q-rh-Bzh" id="Ffo-To-7ad"/> <outlet property="textView" destination="gfH-NN-dph" id="95e-jd-3uz"/> + <outlet property="topSeparator" destination="IdZ-UI-Nwf" id="hBU-TV-xqu"/> + <outlet property="topSeparatorHeightConstraint" destination="5oc-pc-NAl" id="ndQ-4v-qgX"/> </connections> </placeholder> <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" id="iN0-l3-epB"> - <rect key="frame" x="0.0" y="0.0" width="320" height="75"/> + <rect key="frame" x="0.0" y="0.0" width="348" height="57"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> - <view userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IdZ-UI-Nwf" userLabel="Separators" customClass="SeparatorsView" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="320" height="75"/> - <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <view userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IdZ-UI-Nwf" userLabel="Top Separator"> + <rect key="frame" x="0.0" y="0.0" width="348" height="1"/> + <color key="backgroundColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="1" id="5oc-pc-NAl"/> + </constraints> </view> <stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="dls-e1-onf"> - <rect key="frame" x="15" y="1" width="290" height="73"/> + <rect key="frame" x="15" y="12" width="318" height="33"/> <subviews> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="iV9-sG-UuA" userLabel="Fullscreen Button Stackview"> - <rect key="frame" x="0.0" y="0.0" width="32" height="73"/> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="OaT-BL-6lY" userLabel="Fullscreen Button View"> + <rect key="frame" x="0.0" y="0.0" width="33" height="33"/> <subviews> - <view contentMode="scaleToFill" horizontalHuggingPriority="1" verticalHuggingPriority="1" horizontalCompressionResistancePriority="1" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="k4t-g3-awh" userLabel="Spacer View"> - <rect key="frame" x="0.0" y="0.0" width="32" height="40"/> - <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/> - </view> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="OaT-BL-6lY"> - <rect key="frame" x="0.0" y="40" width="32" height="33"/> - <subviews> - <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="252" horizontalCompressionResistancePriority="1000" verticalCompressionResistancePriority="250" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="wxv-ga-1LS" userLabel="Fullscreen toggle"> - <rect key="frame" x="0.0" y="0.0" width="32" height="33"/> - <constraints> - <constraint firstAttribute="height" constant="33" id="Sqb-v9-tHq"/> - </constraints> - <inset key="contentEdgeInsets" minX="5" minY="0.0" maxX="6" maxY="15"/> - <state key="normal" image="icon-nav-chevron-highlight"> - <color key="titleColor" red="0.034757062790000001" green="0.31522077320000003" blue="0.81491315360000005" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </state> - <connections> - <action selector="btnEnterFullscreenPressed:" destination="-1" eventType="touchUpInside" id="JxD-g0-38z"/> - </connections> - </button> - </subviews> - <color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/> + <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="252" horizontalCompressionResistancePriority="1000" verticalCompressionResistancePriority="250" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="wxv-ga-1LS" userLabel="Fullscreen Button"> + <rect key="frame" x="0.0" y="0.0" width="33" height="33"/> <constraints> - <constraint firstAttribute="width" constant="32" id="fO4-iS-maV"/> - <constraint firstItem="wxv-ga-1LS" firstAttribute="top" secondItem="OaT-BL-6lY" secondAttribute="top" id="lxs-W5-jNr"/> - <constraint firstItem="wxv-ga-1LS" firstAttribute="width" secondItem="OaT-BL-6lY" secondAttribute="width" id="nT8-R3-cWk"/> - <constraint firstAttribute="height" constant="33" id="ukr-Eh-Mam"/> - <constraint firstAttribute="trailing" secondItem="wxv-ga-1LS" secondAttribute="trailing" id="vum-BA-PAg"/> - <constraint firstAttribute="bottom" secondItem="wxv-ga-1LS" secondAttribute="bottom" id="xN7-KB-Uya"/> + <constraint firstAttribute="width" constant="33" id="9Tt-IW-C84"/> + <constraint firstAttribute="height" constant="33" id="re6-7y-Hef"/> </constraints> - </view> + <state key="normal" image="icon-nav-chevron-highlight"> + <color key="titleColor" red="0.034757062790000001" green="0.31522077320000003" blue="0.81491315360000005" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </state> + <connections> + <action selector="btnEnterFullscreenPressed:" destination="-1" eventType="touchUpInside" id="JxD-g0-38z"/> + </connections> + </button> </subviews> - </stackView> - <view multipleTouchEnabled="YES" contentMode="scaleToFill" horizontalHuggingPriority="240" horizontalCompressionResistancePriority="740" translatesAutoresizingMaskIntoConstraints="NO" id="t6q-rh-Bzh" customClass="ReplyBezierView" customModule="WordPress"> - <rect key="frame" x="32" y="0.0" width="226" height="73"/> + <constraints> + <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="wxv-ga-1LS" secondAttribute="bottom" id="EMu-Xg-vmD"/> + <constraint firstItem="wxv-ga-1LS" firstAttribute="top" secondItem="OaT-BL-6lY" secondAttribute="top" id="lxs-W5-jNr"/> + <constraint firstItem="wxv-ga-1LS" firstAttribute="width" secondItem="OaT-BL-6lY" secondAttribute="width" id="nT8-R3-cWk"/> + <constraint firstAttribute="trailing" secondItem="wxv-ga-1LS" secondAttribute="trailing" id="vum-BA-PAg"/> + </constraints> + </view> + <view multipleTouchEnabled="YES" contentMode="scaleToFill" horizontalHuggingPriority="240" horizontalCompressionResistancePriority="740" translatesAutoresizingMaskIntoConstraints="NO" id="t6q-rh-Bzh" userLabel="Text Container View"> + <rect key="frame" x="33" y="0.0" width="225" height="33"/> <subviews> - <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" bounces="NO" showsHorizontalScrollIndicator="NO" bouncesZoom="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="gfH-NN-dph"> - <rect key="frame" x="8" y="14" width="214" height="45"/> - <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <fontDescription key="fontDescription" type="system" pointSize="13"/> + <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" bounces="NO" showsHorizontalScrollIndicator="NO" bouncesZoom="NO" textAlignment="natural" adjustsFontForContentSizeCategory="YES" translatesAutoresizingMaskIntoConstraints="NO" id="gfH-NN-dph"> + <rect key="frame" x="0.0" y="5" width="225" height="28"/> + <color key="textColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> </textView> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Placeholder" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="6Lf-XI-exE" userLabel="Placeholder"> - <rect key="frame" x="8" y="14" width="214" height="45"/> - <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Placeholder" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="6Lf-XI-exE" userLabel="Placeholder"> + <rect key="frame" x="0.0" y="0.0" width="225" height="33"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> <size key="shadowOffset" width="-1" height="-1"/> </label> </subviews> - <color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/> <constraints> - <constraint firstItem="6Lf-XI-exE" firstAttribute="bottom" secondItem="gfH-NN-dph" secondAttribute="bottom" id="KLM-qn-AgQ"/> - <constraint firstItem="6Lf-XI-exE" firstAttribute="leading" secondItem="gfH-NN-dph" secondAttribute="leading" id="S0i-Tb-kpK"/> - <constraint firstAttribute="trailingMargin" secondItem="gfH-NN-dph" secondAttribute="trailing" id="UsD-Qh-geJ"/> - <constraint firstItem="gfH-NN-dph" firstAttribute="leading" secondItem="t6q-rh-Bzh" secondAttribute="leadingMargin" id="Wnr-UR-Oup"/> - <constraint firstItem="6Lf-XI-exE" firstAttribute="top" secondItem="gfH-NN-dph" secondAttribute="top" id="ZOH-0A-OT7"/> - <constraint firstItem="gfH-NN-dph" firstAttribute="top" secondItem="t6q-rh-Bzh" secondAttribute="topMargin" id="f52-SP-Tv4"/> - <constraint firstItem="6Lf-XI-exE" firstAttribute="trailing" secondItem="gfH-NN-dph" secondAttribute="trailing" id="gUe-w8-JiB"/> - <constraint firstAttribute="bottomMargin" secondItem="gfH-NN-dph" secondAttribute="bottom" id="rqH-Tc-Wyo"/> + <constraint firstAttribute="trailing" secondItem="gfH-NN-dph" secondAttribute="trailing" id="6tb-kb-jpl"/> + <constraint firstAttribute="bottom" secondItem="gfH-NN-dph" secondAttribute="bottom" id="HVi-Py-SH4"/> + <constraint firstItem="gfH-NN-dph" firstAttribute="leading" secondItem="t6q-rh-Bzh" secondAttribute="leading" id="WEa-Si-qus"/> + <constraint firstItem="6Lf-XI-exE" firstAttribute="leading" secondItem="t6q-rh-Bzh" secondAttribute="leading" id="cVC-qr-WEL"/> + <constraint firstItem="6Lf-XI-exE" firstAttribute="top" secondItem="t6q-rh-Bzh" secondAttribute="top" id="coT-RG-6rz"/> + <constraint firstAttribute="trailing" secondItem="6Lf-XI-exE" secondAttribute="trailing" id="nk9-ec-6ee"/> + <constraint firstItem="gfH-NN-dph" firstAttribute="top" secondItem="t6q-rh-Bzh" secondAttribute="top" constant="5" id="oW6-af-l7q"/> + <constraint firstAttribute="bottom" secondItem="6Lf-XI-exE" secondAttribute="bottom" id="pZt-mP-ney"/> </constraints> - <edgeInsets key="layoutMargins" top="14" left="8" bottom="14" right="4"/> + <edgeInsets key="layoutMargins" top="0.0" left="0.0" bottom="0.0" right="0.0"/> </view> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="VGi-tL-gUc" userLabel="Reply Button Stack View"> - <rect key="frame" x="258" y="0.0" width="32" height="73"/> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="lA2-1V-bck" userLabel="Reply Button View"> + <rect key="frame" x="258" y="0.0" width="60" height="33"/> <subviews> - <view contentMode="scaleToFill" horizontalHuggingPriority="1" verticalHuggingPriority="1" horizontalCompressionResistancePriority="1" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="Chg-FI-dzN" userLabel="Spacer View"> - <rect key="frame" x="0.0" y="0.0" width="32" height="40"/> - <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/> - </view> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="lA2-1V-bck"> - <rect key="frame" x="0.0" y="40" width="32" height="33"/> - <subviews> - <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="252" horizontalCompressionResistancePriority="1000" verticalCompressionResistancePriority="250" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="8sg-79-AsR" userLabel="Reply Button"> - <rect key="frame" x="0.0" y="0.0" width="32" height="33"/> - <constraints> - <constraint firstAttribute="height" constant="33" id="9Ah-tj-7rP"/> - </constraints> - <inset key="contentEdgeInsets" minX="5" minY="0.0" maxX="6" maxY="15"/> - <state key="normal" image="icon-comment-reply"> - <color key="titleColor" red="0.034757062790000001" green="0.31522077320000003" blue="0.81491315360000005" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </state> - <connections> - <action selector="btnReplyPressed" destination="-1" eventType="touchUpInside" id="Jeq-hG-haN"/> - </connections> - </button> - </subviews> - <color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/> + <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="252" horizontalCompressionResistancePriority="1000" verticalCompressionResistancePriority="250" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="8sg-79-AsR" userLabel="Reply Button"> + <rect key="frame" x="0.0" y="0.0" width="60" height="33"/> <constraints> - <constraint firstAttribute="height" constant="33" id="2HH-iA-geS"/> - <constraint firstAttribute="trailing" secondItem="8sg-79-AsR" secondAttribute="trailing" id="4MX-yD-sG2"/> - <constraint firstItem="8sg-79-AsR" firstAttribute="width" secondItem="lA2-1V-bck" secondAttribute="width" id="UVG-FL-ebz"/> - <constraint firstItem="8sg-79-AsR" firstAttribute="top" secondItem="lA2-1V-bck" secondAttribute="top" id="awo-Br-IxO"/> - <constraint firstAttribute="bottom" secondItem="8sg-79-AsR" secondAttribute="bottom" id="oAO-1n-mSt"/> - <constraint firstAttribute="width" constant="32" id="xZs-Ma-Hq7"/> + <constraint firstAttribute="height" constant="33" id="Ehr-Ib-Hp7"/> + <constraint firstAttribute="width" relation="greaterThanOrEqual" constant="43" id="l81-pf-M8M"/> </constraints> - </view> - </subviews> - </stackView> - </subviews> - </stackView> - </subviews> - <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstItem="dls-e1-onf" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="15" id="Grf-Yd-1ll"/> - <constraint firstAttribute="trailing" secondItem="IdZ-UI-Nwf" secondAttribute="trailing" id="Vdg-Rq-CX8"/> - <constraint firstAttribute="trailing" secondItem="dls-e1-onf" secondAttribute="trailing" constant="15" id="Zht-SW-LEv"/> - <constraint firstAttribute="bottom" secondItem="IdZ-UI-Nwf" secondAttribute="bottom" id="bXl-m5-l4L"/> - <constraint firstItem="IdZ-UI-Nwf" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" id="eHd-2o-Jp5"/> - <constraint firstItem="dls-e1-onf" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" constant="1" id="wjV-vs-veI"/> - <constraint firstItem="IdZ-UI-Nwf" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="xW3-yX-z7r"/> - <constraint firstAttribute="bottom" secondItem="dls-e1-onf" secondAttribute="bottom" constant="1" id="xfU-fS-R04"/> - </constraints> - <nil key="simulatedStatusBarMetrics"/> - <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> - <point key="canvasLocation" x="-560" y="462"/> - </view> - <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" id="hNB-0z-3Ev"> - <rect key="frame" x="0.0" y="0.0" width="320" height="75"/> - <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> - <subviews> - <view userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="crq-fQ-SAf" userLabel="Separators" customClass="SeparatorsView" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="320" height="75"/> - <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </view> - <view multipleTouchEnabled="YES" contentMode="scaleToFill" horizontalHuggingPriority="240" horizontalCompressionResistancePriority="740" translatesAutoresizingMaskIntoConstraints="NO" id="ADY-Um-fmy" customClass="ReplyBezierView" customModule="WordPress"> - <rect key="frame" x="15" y="1" width="257" height="73"/> - <subviews> - <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" bounces="NO" showsHorizontalScrollIndicator="NO" bouncesZoom="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="g5R-fe-bKm"> - <rect key="frame" x="8" y="14" width="245" height="45"/> - <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <fontDescription key="fontDescription" type="system" pointSize="13"/> - <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> - </textView> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Placeholder" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="14c-si-389" userLabel="Placeholder"> - <rect key="frame" x="8" y="14" width="245" height="45"/> - <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - <size key="shadowOffset" width="-1" height="-1"/> - </label> - </subviews> - <color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/> - <constraints> - <constraint firstItem="14c-si-389" firstAttribute="trailing" secondItem="g5R-fe-bKm" secondAttribute="trailing" id="2mz-kY-vpc"/> - <constraint firstItem="14c-si-389" firstAttribute="leading" secondItem="g5R-fe-bKm" secondAttribute="leading" id="NNs-bn-f8R"/> - <constraint firstItem="14c-si-389" firstAttribute="bottom" secondItem="g5R-fe-bKm" secondAttribute="bottom" id="XPf-1g-EG9"/> - <constraint firstAttribute="trailingMargin" secondItem="g5R-fe-bKm" secondAttribute="trailing" id="bhq-d4-7Xg"/> - <constraint firstItem="g5R-fe-bKm" firstAttribute="leading" secondItem="ADY-Um-fmy" secondAttribute="leadingMargin" id="fK3-6M-uFm"/> - <constraint firstItem="g5R-fe-bKm" firstAttribute="top" secondItem="ADY-Um-fmy" secondAttribute="topMargin" id="gx1-Ae-Su2"/> - <constraint firstAttribute="bottomMargin" secondItem="g5R-fe-bKm" secondAttribute="bottom" id="qer-mH-TlK"/> - <constraint firstItem="14c-si-389" firstAttribute="top" secondItem="g5R-fe-bKm" secondAttribute="top" id="xcG-eB-PrB"/> - </constraints> - <edgeInsets key="layoutMargins" top="14" left="8" bottom="14" right="4"/> - </view> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="eUv-C6-3UC"> - <rect key="frame" x="272" y="1" width="32" height="73"/> - <subviews> - <view contentMode="scaleToFill" horizontalHuggingPriority="1" verticalHuggingPriority="1" horizontalCompressionResistancePriority="1" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="g5p-IH-fde" userLabel="Spacer View"> - <rect key="frame" x="0.0" y="0.0" width="32" height="40"/> - <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/> - </view> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="AGX-B8-fdi"> - <rect key="frame" x="0.0" y="40" width="32" height="33"/> - <subviews> - <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="252" horizontalCompressionResistancePriority="1000" verticalCompressionResistancePriority="250" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Xso-07-FWB" userLabel="Reply Button"> - <rect key="frame" x="0.0" y="0.0" width="32" height="33"/> - <constraints> - <constraint firstAttribute="height" constant="33" id="lle-bn-TcB"/> - </constraints> - <inset key="contentEdgeInsets" minX="5" minY="0.0" maxX="6" maxY="15"/> - <state key="normal" image="icon-comment-reply"> - <color key="titleColor" red="0.034757062790000001" green="0.31522077320000003" blue="0.81491315360000005" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </state> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal" title="Reply"/> <connections> - <action selector="btnReplyPressed" destination="-1" eventType="touchUpInside" id="WDF-l4-nwZ"/> + <action selector="btnReplyPressed" destination="-1" eventType="touchUpInside" id="Jeq-hG-haN"/> </connections> </button> </subviews> - <color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/> <constraints> - <constraint firstItem="Xso-07-FWB" firstAttribute="top" secondItem="AGX-B8-fdi" secondAttribute="top" id="732-9J-ceF"/> - <constraint firstAttribute="height" constant="33" id="Kxy-5c-l3R"/> - <constraint firstAttribute="width" constant="32" id="M9R-kK-JHs"/> - <constraint firstItem="Xso-07-FWB" firstAttribute="width" secondItem="AGX-B8-fdi" secondAttribute="width" id="kJG-vG-VGB"/> - <constraint firstAttribute="trailing" secondItem="Xso-07-FWB" secondAttribute="trailing" id="l6E-9v-4m1"/> - <constraint firstAttribute="bottom" secondItem="Xso-07-FWB" secondAttribute="bottom" id="x0e-02-EuG"/> + <constraint firstAttribute="trailing" secondItem="8sg-79-AsR" secondAttribute="trailing" id="4MX-yD-sG2"/> + <constraint firstItem="8sg-79-AsR" firstAttribute="width" secondItem="lA2-1V-bck" secondAttribute="width" id="UVG-FL-ebz"/> + <constraint firstItem="8sg-79-AsR" firstAttribute="top" secondItem="lA2-1V-bck" secondAttribute="top" id="awo-Br-IxO"/> + <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="8sg-79-AsR" secondAttribute="bottom" id="fbB-yv-eOu"/> </constraints> </view> </subviews> @@ -235,25 +120,20 @@ </subviews> <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> <constraints> - <constraint firstAttribute="bottom" secondItem="ADY-Um-fmy" secondAttribute="bottom" priority="750" constant="1" id="57U-QA-VDF"/> - <constraint firstItem="eUv-C6-3UC" firstAttribute="top" secondItem="ADY-Um-fmy" secondAttribute="top" id="5aA-em-rck"/> - <constraint firstItem="crq-fQ-SAf" firstAttribute="leading" secondItem="hNB-0z-3Ev" secondAttribute="leading" id="8b3-WW-grK"/> - <constraint firstItem="ADY-Um-fmy" firstAttribute="leading" secondItem="hNB-0z-3Ev" secondAttribute="leadingMargin" constant="-1" id="Fif-6U-g0x"/> - <constraint firstItem="ADY-Um-fmy" firstAttribute="top" secondItem="hNB-0z-3Ev" secondAttribute="top" priority="750" constant="1" id="GEE-5I-9e2"/> - <constraint firstItem="eUv-C6-3UC" firstAttribute="bottom" secondItem="ADY-Um-fmy" secondAttribute="bottom" id="QNQ-AU-bbb"/> - <constraint firstAttribute="trailingMargin" secondItem="eUv-C6-3UC" secondAttribute="trailing" id="TbZ-X5-hhz"/> - <constraint firstAttribute="bottom" secondItem="crq-fQ-SAf" secondAttribute="bottom" id="jX3-zb-en9"/> - <constraint firstItem="eUv-C6-3UC" firstAttribute="leading" secondItem="ADY-Um-fmy" secondAttribute="trailing" id="vnQ-c1-epV"/> - <constraint firstAttribute="trailing" secondItem="crq-fQ-SAf" secondAttribute="trailing" id="xKt-ws-mjQ"/> - <constraint firstItem="crq-fQ-SAf" firstAttribute="top" secondItem="hNB-0z-3Ev" secondAttribute="top" id="yoi-28-Bbr"/> + <constraint firstItem="dls-e1-onf" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="15" id="Grf-Yd-1ll"/> + <constraint firstAttribute="trailing" secondItem="IdZ-UI-Nwf" secondAttribute="trailing" id="Vdg-Rq-CX8"/> + <constraint firstAttribute="trailing" secondItem="dls-e1-onf" secondAttribute="trailing" constant="15" id="Zht-SW-LEv"/> + <constraint firstItem="IdZ-UI-Nwf" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" id="eHd-2o-Jp5"/> + <constraint firstItem="dls-e1-onf" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" constant="12" id="wjV-vs-veI"/> + <constraint firstItem="IdZ-UI-Nwf" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="xW3-yX-z7r"/> + <constraint firstAttribute="bottom" secondItem="dls-e1-onf" secondAttribute="bottom" constant="12" id="xfU-fS-R04"/> </constraints> <nil key="simulatedStatusBarMetrics"/> <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> - <point key="canvasLocation" x="-560" y="561"/> + <point key="canvasLocation" x="-520" y="195.6521739130435"/> </view> </objects> <resources> - <image name="icon-comment-reply" width="42" height="36"/> <image name="icon-nav-chevron-highlight" width="14" height="8"/> </resources> </document> diff --git a/WordPress/Classes/ViewRelated/Notifications/Style/WPStyleGuide+Notifications.swift b/WordPress/Classes/ViewRelated/Notifications/Style/WPStyleGuide+Notifications.swift index 716ac943d90f..6e9e198f5733 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Style/WPStyleGuide+Notifications.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Style/WPStyleGuide+Notifications.swift @@ -10,7 +10,7 @@ extension WPStyleGuide { // // NoteTableViewHeader - public static let sectionHeaderBackgroundColor = UIColor.listBackground + public static let sectionHeaderBackgroundColor = UIColor.ungroupedListBackground public static var sectionHeaderRegularStyle: [NSAttributedString.Key: Any] { return [.paragraphStyle: sectionHeaderParagraph, @@ -18,19 +18,22 @@ extension WPStyleGuide { .foregroundColor: sectionHeaderTextColor] } - // NoteTableViewCell + // ListTableViewCell + public static let unreadIndicatorColor = UIColor.primaryLight + + // Notification cells public static let noticonFont = UIFont(name: "Noticons", size: 16) public static let noticonTextColor = UIColor.textInverted public static let noticonReadColor = UIColor.listSmallIcon public static let noticonUnreadColor = UIColor.primary public static let noticonUnmoderatedColor = UIColor.warning - public static let noteBackgroundReadColor = UIColor.listForeground - public static let noteBackgroundUnreadColor = UIColor.listForegroundUnread + public static let noteBackgroundReadColor = UIColor.ungroupedListBackground + public static let noteBackgroundUnreadColor = UIColor.ungroupedListUnread public static let noteSeparatorColor = blockSeparatorColor - // NoteUndoOverlayView + // Notification undo overlay public static let noteUndoBackgroundColor = UIColor.error public static let noteUndoTextColor = UIColor.white public static let noteUndoTextFont = subjectRegularFont @@ -42,9 +45,9 @@ extension WPStyleGuide { .foregroundColor: subjectTextColor ] } - public static var subjectBoldStyle: [NSAttributedString.Key: Any] { + public static var subjectSemiBoldStyle: [NSAttributedString.Key: Any] { return [.paragraphStyle: subjectParagraph, - .font: subjectBoldFont ] + .font: subjectSemiBoldFont ] } public static var subjectItalicsStyle: [NSAttributedString.Key: Any] { @@ -67,6 +70,13 @@ extension WPStyleGuide { .foregroundColor: snippetColor ] } + public static var headerDetailsRegularStyle: [NSAttributedString.Key: Any] { + return [.paragraphStyle: snippetHeaderParagraph, + .font: headerDetailsRegularFont, + .foregroundColor: headerDetailsColor + ] + } + // MARK: - Styles used by NotificationDetailsViewController // @@ -102,14 +112,41 @@ extension WPStyleGuide { // Badges public static let badgeBackgroundColor = UIColor.clear public static let badgeLinkColor = blockLinkColor + public static let badgeTextColor = blockTextColor + public static let badgeQuotedColor = blockQuotedColor + + public static let badgeRegularFont = UIFont.preferredFont(forTextStyle: .body) + public static let badgeBoldFont = badgeRegularFont.semibold() + public static let badgeItalicsFont = badgeRegularFont.italic() + + public static let badgeTitleFont = WPStyleGuide.serifFontForTextStyle(.title1) + public static let badgeTitleBoldFont = badgeTitleFont.semibold() + public static let badgeTitleItalicsFont = badgeTitleFont.italic() + + public static var badgeRegularStyle: [NSAttributedString.Key: Any] { + badgeStyle(withFont: FeatureFlag.milestoneNotifications.enabled ? badgeRegularFont : blockRegularFont) + } + + public static var badgeBoldStyle: [NSAttributedString.Key: Any] { + FeatureFlag.milestoneNotifications.enabled ? badgeStyle(withFont: badgeBoldFont) : blockBoldStyle + } + + public static var badgeItalicsStyle: [NSAttributedString.Key: Any] { + FeatureFlag.milestoneNotifications.enabled ? badgeStyle(withFont: badgeItalicsFont) : blockItalicsStyle + } + + public static var badgeQuotedStyle: [NSAttributedString.Key: Any] { + FeatureFlag.milestoneNotifications.enabled ? badgeStyle(withFont: badgeItalicsFont, color: badgeQuotedColor) : blockQuotedStyle + } - public static let badgeRegularStyle: [NSAttributedString.Key: Any] = [.paragraphStyle: badgeParagraph, - .font: blockRegularFont, - .foregroundColor: blockTextColor] + public static let badgeTitleStyle: [NSAttributedString.Key: Any] = badgeStyle(withFont: badgeTitleFont) + public static var badgeTitleBoldStyle: [NSAttributedString.Key: Any] = badgeStyle(withFont: badgeTitleBoldFont) + public static var badgeTitleItalicsStyle: [NSAttributedString.Key: Any] = badgeStyle(withFont: badgeTitleItalicsFont) + public static var badgeTitleQuotedStyle: [NSAttributedString.Key: Any] = badgeStyle(withFont: badgeTitleItalicsFont, color: badgeQuotedColor) - public static let badgeBoldStyle = blockBoldStyle - public static let badgeItalicsStyle = blockItalicsStyle - public static let badgeQuotedStyle = blockQuotedStyle + private static func badgeStyle(withFont font: UIFont, color: UIColor = badgeTextColor) -> [NSAttributedString.Key: Any] { + return [.paragraphStyle: badgeParagraph, .font: font, .foregroundColor: color ] + } // Blocks public static let contentBlockRegularFont = WPFontManager.notoRegularFont(ofSize: blockFontSize) @@ -256,8 +293,8 @@ extension WPStyleGuide { // Image(s) let side = WPStyleGuide.fontSizeForTextStyle(.subheadline) let size = CGSize(width: side, height: side) - let followIcon = Gridicon.iconOfType(.readerFollow, withSize: size) - let followingIcon = Gridicon.iconOfType(.readerFollowing, withSize: size) + let followIcon = UIImage.gridicon(.readerFollow, size: size) + let followingIcon = UIImage.gridicon(.readerFollowing, size: size) button.setImage(followIcon.imageWithTintColor(normalColor), for: .normal) button.setImage(followingIcon.imageWithTintColor(selectedColor), for: .selected) @@ -307,15 +344,18 @@ extension WPStyleGuide { fileprivate static let snippetParagraph = NSMutableParagraphStyle( minLineHeight: snippetLineSize, lineBreakMode: .byWordWrapping, alignment: .natural ) + fileprivate static let snippetHeaderParagraph = NSMutableParagraphStyle( + minLineHeight: snippetLineSize, lineBreakMode: .byTruncatingTail, alignment: .natural + ) fileprivate static let blockParagraph = NSMutableParagraphStyle( minLineHeight: blockLineSize, lineBreakMode: .byWordWrapping, alignment: .natural ) fileprivate static let contentBlockParagraph = NSMutableParagraphStyle( minLineHeight: contentBlockLineSize, lineBreakMode: .byWordWrapping, alignment: .natural ) - fileprivate static let badgeParagraph = NSMutableParagraphStyle( - minLineHeight: blockLineSize, maxLineHeight: blockLineSize, lineBreakMode: .byWordWrapping, alignment: .center - ) + fileprivate static let badgeParagraph = FeatureFlag.milestoneNotifications.enabled ? + NSMutableParagraphStyle(minLineHeight: blockLineSize, lineBreakMode: .byWordWrapping, alignment: .center) : + NSMutableParagraphStyle(minLineHeight: blockLineSize, maxLineHeight: blockLineSize, lineBreakMode: .byWordWrapping, alignment: .center) // Colors fileprivate static let sectionHeaderTextColor = UIColor.textSubtle @@ -333,8 +373,8 @@ extension WPStyleGuide { fileprivate static var subjectRegularFont: UIFont { return WPStyleGuide.fontForTextStyle(.subheadline) } - fileprivate static var subjectBoldFont: UIFont { - return WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .bold) + fileprivate static var subjectSemiBoldFont: UIFont { + return WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) } fileprivate static var subjectItalicsFont: UIFont { return WPStyleGuide.fontForTextStyle(.subheadline, symbolicTraits: .traitItalic) diff --git a/WordPress/Classes/ViewRelated/Notifications/Style/WPStyleGuide+Reply.swift b/WordPress/Classes/ViewRelated/Notifications/Style/WPStyleGuide+Reply.swift index ab35b8cc87d8..acf2ec49b0d5 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Style/WPStyleGuide+Reply.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Style/WPStyleGuide+Reply.swift @@ -5,19 +5,10 @@ extension WPStyleGuide { public struct Reply { // Styles used by ReplyTextView // - public static var buttonFont: UIFont { - return WPStyleGuide.fontForTextStyle(.footnote, symbolicTraits: .traitBold) - } - public static var textFont: UIFont { - return WPStyleGuide.regularTextFont() - } - - public static let enabledColor = UIColor.primary - public static let disabledColor = UIColor.listSmallIcon - public static let placeholderColor = UIColor.textPlaceholder - public static let textColor = UIColor.text - public static let separatorColor = UIColor.divider - public static let textViewBackground = UIColor.basicBackground - public static let backgroundColor = UIColor.basicBackground + public static let placeholderColor = UIColor.textPlaceholder + public static let textColor = UIColor.text + public static let separatorColor = UIColor.divider + public static let backgroundColor = UIColor.basicBackground + public static let replyButtonColor = UIColor.primary } } diff --git a/WordPress/Classes/ViewRelated/Notifications/Tools/IntrinsicTableView.swift b/WordPress/Classes/ViewRelated/Notifications/Tools/IntrinsicTableView.swift index f87e48c892f3..c223906861a6 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Tools/IntrinsicTableView.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Tools/IntrinsicTableView.swift @@ -9,7 +9,7 @@ import Foundation /// - https://developer.apple.com/library/ios/technotes/tn2154/_index.html#//apple_ref/doc/uid/DTS40013309 /// - http://stackoverflow.com/questions/17334478/uitableview-within-uiscrollview-using-autolayout /// -class IntrinsicTableView: UITableView { +@objc class IntrinsicTableView: UITableView { override var contentSize: CGSize { didSet { self.invalidateIntrinsicContentSize() diff --git a/WordPress/Classes/ViewRelated/Notifications/Tools/KeyboardDismissHelper.swift b/WordPress/Classes/ViewRelated/Notifications/Tools/KeyboardDismissHelper.swift index 90440f503d37..82396c876f8b 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Tools/KeyboardDismissHelper.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Tools/KeyboardDismissHelper.swift @@ -213,14 +213,12 @@ import UIKit } // Proceed Animating - UIView.beginAnimations(nil, context: nil) - UIView.setAnimationCurve(curve) - UIView.setAnimationDuration(duration) + let options = UIView.AnimationOptions(rawValue: UInt(curve.rawValue)) + UIView.animate(withDuration: duration, delay: 0, options: options) { [weak self] in + self?.bottomLayoutConstraint.constant = newBottomInset + self?.parentView.layoutIfNeeded() + } completion: { _ in } - bottomLayoutConstraint.constant = newBottomInset - parentView.layoutIfNeeded() - - UIView.commitAnimations() } fileprivate func bottomInsetFromKeyboardNote(_ note: Foundation.Notification) -> CGFloat { diff --git a/WordPress/Classes/ViewRelated/Notifications/Tools/NotificationCommentDetailCoordinator.swift b/WordPress/Classes/ViewRelated/Notifications/Tools/NotificationCommentDetailCoordinator.swift new file mode 100644 index 000000000000..6062e13ec63f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/Tools/NotificationCommentDetailCoordinator.swift @@ -0,0 +1,172 @@ +import Foundation + +// This facilitates showing the CommentDetailViewController within the context of Notifications. + + +protocol CommentDetailsNotificationDelegate: AnyObject { + func previousNotificationTapped(current: Notification?) + func nextNotificationTapped(current: Notification?) + func commentWasModerated(for notification: Notification?) +} + +class NotificationCommentDetailCoordinator: NSObject { + + // MARK: - Properties + + private var viewController: NotificationCommentDetailViewController? + private let managedObjectContext = ContextManager.shared.mainContext + + private var notification: Notification? { + didSet { + markNotificationReadIfNeeded() + } + } + + // Arrow navigation data source + private weak var notificationsNavigationDataSource: NotificationsNavigationDataSource? + + // Closure to be executed whenever the notification that's being currently displayed, changes. + // This happens due to Navigation Events (Next / Previous) + var onSelectedNoteChange: ((Notification) -> Void)? + + // Keep track of Notifications that have moderated Comments so they can be updated + // the next time the Notifications list is displayed. + var notificationsCommentModerated: [Notification] = [] + + // MARK: - Init + + init(notificationsNavigationDataSource: NotificationsNavigationDataSource? = nil) { + self.notificationsNavigationDataSource = notificationsNavigationDataSource + super.init() + } + + // MARK: - Public Methods + + func createViewController(with notification: Notification) -> NotificationCommentDetailViewController? { + self.notification = notification + viewController = NotificationCommentDetailViewController(notification: notification, notificationDelegate: self) + updateNavigationButtonStates() + return viewController + } + +} + +// MARK: - Private Extension + +private extension NotificationCommentDetailCoordinator { + + func markNotificationReadIfNeeded() { + guard let notification = notification, !notification.read else { + return + } + + NotificationSyncMediator()?.markAsRead(notification) + } + + func updateViewWith(notification: Notification) { + trackDetailsOpened(for: notification) + onSelectedNoteChange?(notification) + + guard notification.kind == .comment else { + showNotificationDetails(with: notification) + return + } + + refreshViewControllerWith(notification) + } + + func showNotificationDetails(with notification: Notification) { + let storyboard = UIStoryboard(name: Notifications.storyboardName, bundle: nil) + + guard let viewController = viewController, + let notificationDetailsViewController = storyboard.instantiateViewController(withIdentifier: Notifications.viewControllerName) as? NotificationDetailsViewController else { + DDLogError("NotificationCommentDetailCoordinator: missing view controller.") + return + } + + notificationDetailsViewController.note = notification + notificationDetailsViewController.notificationCommentDetailCoordinator = self + notificationDetailsViewController.dataSource = notificationsNavigationDataSource + notificationDetailsViewController.onSelectedNoteChange = onSelectedNoteChange + + weak var navigationController = viewController.navigationController + + viewController.dismiss(animated: true, completion: { + notificationDetailsViewController.navigationItem.largeTitleDisplayMode = .never + navigationController?.popViewController(animated: false) + navigationController?.pushViewController(notificationDetailsViewController, animated: false) + }) + } + + func refreshViewControllerWith(_ notification: Notification) { + self.notification = notification + viewController?.refreshViewController(notification: notification) + updateNavigationButtonStates() + } + + func updateNavigationButtonStates() { + viewController?.previousButtonEnabled = hasPreviousNotification + viewController?.nextButtonEnabled = hasNextNotification + } + + var hasPreviousNotification: Bool { + guard let notification = notification else { + return false + } + + return notificationsNavigationDataSource?.notification(preceding: notification) != nil + } + + var hasNextNotification: Bool { + guard let notification = notification else { + return false + } + return notificationsNavigationDataSource?.notification(succeeding: notification) != nil + } + + func trackDetailsOpened(for notification: Notification) { + let properties = ["notification_type": notification.type ?? "unknown"] + WPAnalytics.track(.openedNotificationDetails, withProperties: properties) + } + + enum Notifications { + static let storyboardName = "Notifications" + static let viewControllerName = NotificationDetailsViewController.classNameWithoutNamespaces() + } + +} + +// MARK: - CommentDetailsNotificationDelegate + +extension NotificationCommentDetailCoordinator: CommentDetailsNotificationDelegate { + + func previousNotificationTapped(current: Notification?) { + guard let current = current, + let previousNotification = notificationsNavigationDataSource?.notification(preceding: current) else { + return + } + + WPAnalytics.track(.notificationsPreviousTapped) + updateViewWith(notification: previousNotification) + } + + func nextNotificationTapped(current: Notification?) { + guard let current = current, + let nextNotification = notificationsNavigationDataSource?.notification(succeeding: current) else { + return + } + + WPAnalytics.track(.notificationsNextTapped) + updateViewWith(notification: nextNotification) + } + + func commentWasModerated(for notification: Notification?) { + guard let notification = notification, + !notificationsCommentModerated.contains(notification) else { + return + } + + notificationsCommentModerated.append(notification) + } + +} diff --git a/WordPress/Classes/ViewRelated/Notifications/Tools/NotificationMediaDownloader.swift b/WordPress/Classes/ViewRelated/Notifications/Tools/NotificationMediaDownloader.swift index 30c53b66cd27..bb116d0c8e43 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Tools/NotificationMediaDownloader.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Tools/NotificationMediaDownloader.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit /// The purpose of this class is to provide a simple API to download assets from the web. @@ -97,7 +98,7 @@ class NotificationMediaDownloader: NSObject { let targetSize = cappedImageSize(originalImage.size, maximumWidth: maximumWidth) let resizedImage = resizedImagesMap[url] - if resizedImage == nil || resizedImage?.size == targetSize { + if resizedImage == nil || resizedImage?.size == targetSize || resizedImage as? AnimatedImageWrapper != nil { continue } @@ -191,6 +192,12 @@ class NotificationMediaDownloader: NSObject { /// - callback: A closure to be called, on the main thread, on completion /// private func resizeImageIfNeeded(_ image: UIImage, maximumWidth: CGFloat, callback: @escaping (UIImage) -> Void) { + // Animated images aren't actually resized, so return the image itself if we've already recorded the target size + if let animatedImage = image as? AnimatedImageWrapper, animatedImage.targetSize != nil { + callback(animatedImage) + return + } + let targetSize = cappedImageSize(image.size, maximumWidth: maximumWidth) if image.size == targetSize { callback(image) @@ -198,7 +205,17 @@ class NotificationMediaDownloader: NSObject { } resizeQueue.async { - let resizedImage = image.resizedImage(targetSize, interpolationQuality: .high) + let resizedImage: UIImage? + + // If we try to resize the animate image it will lose all of its frames + // Instead record the target size so we can properly set the bounds of the view later + if let animatedImage = image as? AnimatedImageWrapper, animatedImage.gifData != nil { + animatedImage.targetSize = targetSize + resizedImage = animatedImage + } else { + resizedImage = image.resizedImage(targetSize, interpolationQuality: .high) + } + DispatchQueue.main.async { callback(resizedImage!) } diff --git a/WordPress/Classes/ViewRelated/Notifications/Tools/NotificationReplyStore.swift b/WordPress/Classes/ViewRelated/Notifications/Tools/NotificationReplyStore.swift index 8e6400a0df4e..350ed207cc87 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Tools/NotificationReplyStore.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Tools/NotificationReplyStore.swift @@ -97,11 +97,11 @@ private extension NotificationReplyStore { /// var replies: [String: String] { get { - let replies = UserDefaults.standard.dictionary(forKey: Settings.contentsKey) as? [String: String] + let replies = UserPersistentStoreFactory.instance().dictionary(forKey: Settings.contentsKey) as? [String: String] return replies ?? [String: String]() } set { - UserDefaults.standard.set(newValue, forKey: Settings.contentsKey) + UserPersistentStoreFactory.instance().set(newValue, forKey: Settings.contentsKey) } } @@ -109,11 +109,11 @@ private extension NotificationReplyStore { /// var timestamps: [String: Date] { get { - let timestamps = UserDefaults.standard.dictionary(forKey: Settings.timestampsKey) as? [String: Date] + let timestamps = UserPersistentStoreFactory.instance().dictionary(forKey: Settings.timestampsKey) as? [String: Date] return timestamps ?? [String: Date]() } set { - UserDefaults.standard.set(newValue, forKey: Settings.timestampsKey) + UserPersistentStoreFactory.instance().set(newValue, forKey: Settings.timestampsKey) } } } diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/LikeUserTableViewCell.swift b/WordPress/Classes/ViewRelated/Notifications/Views/LikeUserTableViewCell.swift new file mode 100644 index 000000000000..2ef714f1b345 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/Views/LikeUserTableViewCell.swift @@ -0,0 +1,65 @@ +import Foundation + +class LikeUserTableViewCell: UITableViewCell, NibReusable { + + // MARK: - Properties + + @IBOutlet weak var gravatarImageView: CircularImageView! + @IBOutlet weak var nameLabel: UILabel! + @IBOutlet weak var usernameLabel: UILabel! + @IBOutlet weak var separatorView: UIView! + @IBOutlet weak var separatorHeightConstraint: NSLayoutConstraint! + @IBOutlet weak var separatorLeadingConstraint: NSLayoutConstraint! + @IBOutlet weak var cellStackViewLeadingConstraint: NSLayoutConstraint! + + static let estimatedRowHeight: CGFloat = 80 + private typealias Style = WPStyleGuide.Notifications + + // MARK: - View + + override func awakeFromNib() { + super.awakeFromNib() + configureCell() + } + + // MARK: - Public Methods + + func configure(withUser user: LikeUser, isLastRow: Bool = false) { + nameLabel.text = user.displayName + usernameLabel.text = String(format: Constants.usernameFormat, user.username) + downloadGravatarWithURL(user.avatarUrl) + separatorLeadingConstraint.constant = isLastRow ? 0 : cellStackViewLeadingConstraint.constant + } + +} + +// MARK: - Private Extension + +private extension LikeUserTableViewCell { + + func configureCell() { + nameLabel.textColor = Style.blockTextColor + usernameLabel.textColor = .textSubtle + backgroundColor = Style.blockBackgroundColor + separatorView.backgroundColor = Style.blockSeparatorColor + separatorHeightConstraint.constant = .hairlineBorderWidth + } + + func downloadGravatarWithURL(_ url: String?) { + // Always reset gravatar + gravatarImageView.cancelImageDownload() + gravatarImageView.image = .gravatarPlaceholderImage + + guard let url = url, + let gravatarURL = URL(string: url) else { + return + } + + gravatarImageView.downloadImage(from: gravatarURL, placeholderImage: .gravatarPlaceholderImage) + } + + struct Constants { + static let usernameFormat = NSLocalizedString("@%1$@", comment: "Label displaying the user's username preceeded by an '@' symbol. %1$@ is a placeholder for the username.") + } + +} diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/LikeUserTableViewCell.xib b/WordPress/Classes/ViewRelated/Notifications/Views/LikeUserTableViewCell.xib new file mode 100644 index 000000000000..43b23f19547e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/Views/LikeUserTableViewCell.xib @@ -0,0 +1,91 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="Named colors" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="-12" id="vmg-Uv-oZf" customClass="LikeUserTableViewCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="435" height="86"/> + <autoresizingMask key="autoresizingMask"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="vmg-Uv-oZf" id="AVd-CT-xQi"> + <rect key="frame" x="0.0" y="0.0" width="435" height="86"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="12" translatesAutoresizingMaskIntoConstraints="NO" id="ZWJ-8F-a7O" userLabel="Cell Stack View"> + <rect key="frame" x="20" y="12" width="395" height="62"/> + <subviews> + <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="pe3-Qf-lcv" userLabel="Gravatar Image View" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="8" width="46" height="46"/> + <constraints> + <constraint firstAttribute="width" secondItem="pe3-Qf-lcv" secondAttribute="height" multiplier="1:1" id="MlL-OM-UpJ"/> + <constraint firstAttribute="width" constant="46" id="se3-n4-gId"/> + </constraints> + </imageView> + <stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="1000" axis="vertical" alignment="top" translatesAutoresizingMaskIntoConstraints="NO" id="wev-aD-HGJ" userLabel="Label Stack View"> + <rect key="frame" x="58" y="12" width="337" height="38.5"/> + <subviews> + <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="TopLeft" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="IUe-FJ-l5m" userLabel="Name Label"> + <rect key="frame" x="0.0" y="0.0" width="45" height="20.5"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <color key="textColor" systemColor="darkTextColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" clipsSubviews="YES" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="LDG-hl-sfI" userLabel="Username Label"> + <rect key="frame" x="0.0" y="20.5" width="81.5" height="18"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <color key="textColor" name="Gray"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + </subviews> + </stackView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="7UX-Gg-MNF" userLabel="Separator"> + <rect key="frame" x="20" y="85" width="415" height="1"/> + <color key="backgroundColor" systemColor="systemGrayColor"/> + <constraints> + <constraint firstAttribute="height" constant="1" id="mB6-co-8Ym"/> + </constraints> + </view> + </subviews> + <constraints> + <constraint firstItem="ZWJ-8F-a7O" firstAttribute="leading" secondItem="AVd-CT-xQi" secondAttribute="leading" constant="20" id="GhN-Ib-f12"/> + <constraint firstAttribute="trailing" secondItem="ZWJ-8F-a7O" secondAttribute="trailing" constant="20" id="P9x-je-fd1"/> + <constraint firstAttribute="trailing" secondItem="7UX-Gg-MNF" secondAttribute="trailing" id="aCd-hU-kEG"/> + <constraint firstItem="7UX-Gg-MNF" firstAttribute="leading" secondItem="AVd-CT-xQi" secondAttribute="leading" constant="20" id="kUW-yY-J0j"/> + <constraint firstAttribute="bottom" secondItem="ZWJ-8F-a7O" secondAttribute="bottom" constant="12" id="mWv-qn-qTN"/> + <constraint firstItem="ZWJ-8F-a7O" firstAttribute="top" secondItem="AVd-CT-xQi" secondAttribute="top" constant="12" id="oBa-U2-Pc3"/> + <constraint firstAttribute="bottom" secondItem="7UX-Gg-MNF" secondAttribute="bottom" id="wFA-8X-y0T"/> + </constraints> + </tableViewCellContentView> + <connections> + <outlet property="cellStackViewLeadingConstraint" destination="GhN-Ib-f12" id="xoY-Yw-dKq"/> + <outlet property="gravatarImageView" destination="pe3-Qf-lcv" id="Nm7-aR-dtA"/> + <outlet property="nameLabel" destination="IUe-FJ-l5m" id="sey-v1-1fE"/> + <outlet property="separatorHeightConstraint" destination="mB6-co-8Ym" id="53f-fU-ylZ"/> + <outlet property="separatorLeadingConstraint" destination="kUW-yY-J0j" id="3Yg-OB-oRi"/> + <outlet property="separatorView" destination="7UX-Gg-MNF" id="ElU-fs-oMZ"/> + <outlet property="usernameLabel" destination="LDG-hl-sfI" id="lAD-3V-QRG"/> + </connections> + <point key="canvasLocation" x="-238.40579710144928" y="-22.098214285714285"/> + </tableViewCell> + </objects> + <resources> + <namedColor name="Gray"> + <color red="0.39215686274509803" green="0.41176470588235292" blue="0.4392156862745098" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </namedColor> + <systemColor name="darkTextColor"> + <color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + <systemColor name="systemGrayColor"> + <color red="0.55686274509803924" green="0.55686274509803924" blue="0.57647058823529407" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/ListTableViewCell+Notifications.swift b/WordPress/Classes/ViewRelated/Notifications/Views/ListTableViewCell+Notifications.swift new file mode 100644 index 000000000000..56e8ab42dfa2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/Views/ListTableViewCell+Notifications.swift @@ -0,0 +1,65 @@ +/// Encapsulates logic that configures `ListTableViewCell` with `Notification` models. +/// +extension ListTableViewCell { + func configureWithNotification(_ notification: Notification) { + indicatorColor = Style.unreadIndicatorColor + showsIndicator = !notification.read + configureImage(with: notification.iconURL) + attributedTitleText = notification.renderSubject() + snippetText = notification.renderSnippet()?.string ?? String() + } + + /// Toggles an overlay view that presents an undo option for the user. + /// Whether the view is displayed or not depends on the `text` parameter. + /// Given an empty text, the method will try to dismiss the overlay text (if it exists). + /// Otherwise, it will try to show the overlay view on top of the cell. + /// - Parameters: + /// - text: The text describing the action that can be undone. + /// - onUndelete: The closure to be executed when the undo button is tapped. + func configureUndeleteOverlay(with text: String?, onUndelete: @escaping () -> Void) { + guard let someText = text, !someText.isEmpty else { + dismissOverlay() + return + } + + let undoOverlayView = makeUndoOverlayView() + undoOverlayView.textLabel.text = text + undoOverlayView.actionButton.on(.touchUpInside) { _ in + onUndelete() + } + + showOverlay(with: undoOverlayView) + } +} + +// MARK: - Constants and Helpers + +private extension ListTableViewCell { + /// Creates a pre-styled overlay view based on `ListSimpleOverlayView`. + /// This will be used to show options for the user to revert the action performed on the notification. + func makeUndoOverlayView() -> ListSimpleOverlayView { + let overlayView = ListSimpleOverlayView.loadFromNib() + overlayView.translatesAutoresizingMaskIntoConstraints = false + overlayView.backgroundColor = Style.noteUndoBackgroundColor + + // text label + overlayView.textLabel.font = Style.noteUndoTextFont + overlayView.textLabel.textColor = Style.noteUndoTextColor + + // action button + overlayView.actionButton.titleLabel?.font = Style.noteUndoTextFont + overlayView.actionButton.setTitleColor(Style.noteUndoTextColor, for: .normal) + overlayView.actionButton.setTitle(Localization.undoButtonText, for: .normal) + overlayView.actionButton.accessibilityHint = Localization.undoButtonHint + + return overlayView + } + + typealias Style = WPStyleGuide.Notifications + + struct Localization { + static let undoButtonText = NSLocalizedString("Undo", comment: "Revert an operation") + static let undoButtonHint = NSLocalizedString("Reverts the action performed on this notification.", + comment: "Accessibility hint describing what happens if the undo button is tapped.") + } +} diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockActionsTableViewCell.swift b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockActionsTableViewCell.swift index cbe5f81e56b2..95878a73e2ef 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockActionsTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockActionsTableViewCell.swift @@ -116,6 +116,17 @@ class NoteBlockActionsTableViewCell: NoteBlockTableViewCell { } } + /// Indicates if all actions are disabled. + /// + @objc var allActionsDisabled: Bool { + return !isReplyEnabled && + !isLikeEnabled && + !isApproveEnabled && + !isTrashEnabled && + !isSpamEnabled && + !isEditEnabled + } + /// Indicates whether Like is in it's "Selected" state, or not /// @objc var isLikeOn: Bool { @@ -194,6 +205,7 @@ class NoteBlockActionsTableViewCell: NoteBlockTableViewCell { btnReply.setTitleColor(textNormalColor, for: UIControl.State()) btnReply.accessibilityLabel = ReplyToComment.title btnReply.accessibilityHint = ReplyToComment.hint + btnReply.accessibilityIdentifier = ReplyToComment.identifier btnLike.setTitle(LikeComment.TitleStrings.like, for: UIControl.State()) btnLike.setTitle(LikeComment.TitleStrings.unlike, for: .highlighted) @@ -361,6 +373,6 @@ private extension NoteBlockActionsTableViewCell { struct Constants { static let buttonSpacing = CGFloat(20) - static let buttonSpacingCompact = CGFloat(9) + static let buttonSpacingCompact = CGFloat(2) } } diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockButtonTableViewCell.swift b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockButtonTableViewCell.swift new file mode 100644 index 000000000000..be036a18888e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockButtonTableViewCell.swift @@ -0,0 +1,32 @@ +import UIKit + +class NoteBlockButtonTableViewCell: NoteBlockTableViewCell { + + @IBOutlet weak var button: FancyButton! + + var title: String? { + set { + button.setTitle(newValue, for: .normal) + } + get { + return button.title(for: .normal) + } + } + + /// An block to be invoked when the button is tapped. + var action: (() -> Void)? + + override func awakeFromNib() { + super.awakeFromNib() + + backgroundColor = .clear + selectionStyle = .none + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + @objc + private func buttonTapped() { + action?() + } +} diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockButtonTableViewCell.xib b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockButtonTableViewCell.xib new file mode 100644 index 000000000000..0d5bd58253c5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockButtonTableViewCell.xib @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="blue" hidesAccessoryWhenEditing="NO" indentationLevel="1" indentationWidth="0.0" rowHeight="44" id="NzO-7n-jZU" userLabel="Button" customClass="NoteBlockButtonTableViewCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="320" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="NzO-7n-jZU" id="7nQ-Pw-2Hj"> + <rect key="frame" x="0.0" y="0.0" width="320" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="fNm-7n-pb1" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="145" y="11" width="30" height="22"/> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="YES"/> + </userDefinedRuntimeAttributes> + </button> + </subviews> + <constraints> + <constraint firstAttribute="trailingMargin" relation="greaterThanOrEqual" secondItem="fNm-7n-pb1" secondAttribute="trailing" id="O5C-7d-F5c"/> + <constraint firstAttribute="bottomMargin" relation="greaterThanOrEqual" secondItem="fNm-7n-pb1" secondAttribute="bottom" id="P4g-7U-30C"/> + <constraint firstItem="fNm-7n-pb1" firstAttribute="top" relation="greaterThanOrEqual" secondItem="7nQ-Pw-2Hj" secondAttribute="topMargin" id="PMm-dk-cEA"/> + <constraint firstItem="fNm-7n-pb1" firstAttribute="centerY" secondItem="7nQ-Pw-2Hj" secondAttribute="centerY" id="Rnd-wT-hOY"/> + <constraint firstItem="fNm-7n-pb1" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="7nQ-Pw-2Hj" secondAttribute="leadingMargin" id="a8r-7h-rRY"/> + <constraint firstItem="fNm-7n-pb1" firstAttribute="centerX" secondItem="7nQ-Pw-2Hj" secondAttribute="centerX" id="cyh-RX-rwg"/> + </constraints> + </tableViewCellContentView> + <connections> + <outlet property="button" destination="fNm-7n-pb1" id="8dM-Eg-3E9"/> + </connections> + <point key="canvasLocation" x="-354" y="154"/> + </tableViewCell> + </objects> +</document> diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockCommentTableViewCell.swift b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockCommentTableViewCell.swift index 915a7ed97f24..c8c4c1c0d51c 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockCommentTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockCommentTableViewCell.swift @@ -58,10 +58,14 @@ class NoteBlockCommentTableViewCell: NoteBlockTextTableViewCell { /// Indicates if the comment is approved, or not. /// + /// - Note: + /// After setting this property you should explicitly call `refreshSeparators` from within `UITableView.willDisplayCell`. + /// We're not doing so from `didSet` anymore since doing so might yield invalid `intrinsicContentSize` calculations, + /// which appears to be cached, and results in incorrect layouts. + /// @objc var isApproved: Bool = false { didSet { refreshApprovalColors() - refreshSeparators() } } @@ -131,6 +135,13 @@ class NoteBlockCommentTableViewCell: NoteBlockTextTableViewCell { // MARK: - Approval Color Helpers + /// Updates the Separators Insets / Style. This API should be called from within `UITableView.willDisplayCell`. + /// + /// - Note: + /// `readableSeparatorInsets`, if executed from within `cellForRowAtIndexPath`, will produce an "invalid" layout cycle (since there won't + /// be a superview). Such "Invalid" layout cycle appears to be yielding an invalid `intrinsicContentSize` calculation, which is then cached, + /// and we end up with strings cutoff onScreen. =( + /// override func refreshSeparators() { // Left Separator separatorsView.leftVisible = !isApproved diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockHeaderTableViewCell.swift b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockHeaderTableViewCell.swift index 706fabf7e668..f07aa682ce7d 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockHeaderTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockHeaderTableViewCell.swift @@ -43,6 +43,15 @@ class NoteBlockHeaderTableViewCell: NoteBlockTableViewCell { } } + @objc var attributedHeaderDetails: NSAttributedString? { + set { + headerDetailsLabel.attributedText = newValue + } + get { + return headerDetailsLabel.attributedText + } + } + // MARK: - Public Methods diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockImageTableViewCell.swift b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockImageTableViewCell.swift index 0a255c90e183..94261f2a4216 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockImageTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockImageTableViewCell.swift @@ -11,6 +11,12 @@ class NoteBlockImageTableViewCell: NoteBlockTableViewCell { } } + var backgroundImage: UIImage? { + didSet { + backgroundImageView?.image = backgroundImage + } + } + // MARK: - Public Methods /// Downloads a remote image, given it's URL, assuming that we're already not displaying that very same image. @@ -40,7 +46,9 @@ class NoteBlockImageTableViewCell: NoteBlockTableViewCell { super.prepareForReuse() blockImageView.image = nil + backgroundImageView.image = nil imageURL = nil + backgroundImage = nil } // MARK: - Helpers @@ -48,4 +56,5 @@ class NoteBlockImageTableViewCell: NoteBlockTableViewCell { // MARK: - IBOutlets @IBOutlet weak var blockImageView: UIImageView! + @IBOutlet weak var backgroundImageView: UIImageView! } diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockImageTableViewCell.xib b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockImageTableViewCell.xib index fde4c913a826..37bce1649cc1 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockImageTableViewCell.xib +++ b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockImageTableViewCell.xib @@ -1,11 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="11762" systemVersion="16D32" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="11757"/> - <capability name="Constraints to layout margins" minToolsVersion="6.0"/> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> @@ -15,14 +13,21 @@ <rect key="frame" x="0.0" y="0.0" width="320" height="200"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="ksP-zj-xJe" id="VFE-5W-RpG"> - <rect key="frame" x="0.0" y="0.0" width="320" height="199"/> + <rect key="frame" x="0.0" y="0.0" width="320" height="200"/> <autoresizingMask key="autoresizingMask"/> <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="XVh-J2-FNg" userLabel="Background Image View"> + <rect key="frame" x="16" y="11" width="288" height="178"/> + <constraints> + <constraint firstAttribute="width" constant="288" placeholder="YES" id="1WG-qL-9Ha"/> + <constraint firstAttribute="height" constant="178" placeholder="YES" id="UCI-AF-bdf"/> + </constraints> + </imageView> <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="0ai-ZU-3Mq"> - <rect key="frame" x="15" y="11" width="290" height="178"/> + <rect key="frame" x="16" y="11" width="288" height="178"/> <subviews> <imageView userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="AhG-3g-brM"> - <rect key="frame" x="55" y="0.0" width="180" height="178"/> + <rect key="frame" x="54" y="0.0" width="180" height="178"/> <constraints> <constraint firstAttribute="width" constant="180" id="Iwx-yF-VHD"/> <constraint firstAttribute="height" priority="999" constant="180" id="zb7-mc-OZC"/> @@ -34,14 +39,18 @@ <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> <constraints> <constraint firstAttribute="trailingMargin" secondItem="0ai-ZU-3Mq" secondAttribute="trailing" id="Grq-KW-jwk"/> + <constraint firstItem="XVh-J2-FNg" firstAttribute="centerY" secondItem="0ai-ZU-3Mq" secondAttribute="centerY" id="ICu-CY-2QB"/> + <constraint firstItem="XVh-J2-FNg" firstAttribute="centerX" secondItem="0ai-ZU-3Mq" secondAttribute="centerX" id="V63-9b-ZG1"/> <constraint firstItem="0ai-ZU-3Mq" firstAttribute="leading" secondItem="VFE-5W-RpG" secondAttribute="leadingMargin" id="XpY-6Z-eka"/> <constraint firstAttribute="bottomMargin" secondItem="0ai-ZU-3Mq" secondAttribute="bottom" id="cKd-u6-56O"/> <constraint firstItem="0ai-ZU-3Mq" firstAttribute="top" secondItem="VFE-5W-RpG" secondAttribute="topMargin" id="u4H-e4-vsA"/> </constraints> </tableViewCellContentView> <connections> + <outlet property="backgroundImageView" destination="XVh-J2-FNg" id="U85-go-fhS"/> <outlet property="blockImageView" destination="AhG-3g-brM" id="3yg-Cg-uLk"/> </connections> + <point key="canvasLocation" x="-75.200000000000003" y="149.32533733133434"/> </tableViewCell> </objects> </document> diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTableViewCell.swift b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTableViewCell.swift index fcb31084fb77..56508b46cbeb 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTableViewCell.swift @@ -2,17 +2,29 @@ import Foundation import WordPressShared class NoteBlockTableViewCell: WPTableViewCell { - // MARK: - Public Properties - @objc var isBadge: Bool = false { - didSet { - refreshSeparators() - } - } - @objc var isLastRow: Bool = false { + + /// Represents the (full, side to side) Separator Insets + /// + private let fullSeparatorInsets = UIEdgeInsets.zero + + /// Indicates if the receiver represents a Badge Block + /// + /// - Note: After setting this property you should explicitly call `refreshSeparators` from within `UITableView.willDisplayCell`. + /// + @objc var isBadge = false { didSet { - refreshSeparators() + separatorsView.backgroundColor = WPStyleGuide.Notifications.blockBackgroundColorForRichText(isBadge) } } + + /// Indicates if the receiver is the last row in the group. + /// + /// - Note: After setting this property you should explicitly call `refreshSeparators` from within `UITableView.willDisplayCell`. + /// + @objc var isLastRow = false + + /// Readability Insets + /// @objc var readableSeparatorInsets: UIEdgeInsets { var insets = UIEdgeInsets.zero let readableLayoutFrame = readableContentGuide.layoutFrame @@ -20,18 +32,39 @@ class NoteBlockTableViewCell: WPTableViewCell { insets.right = frame.size.width - (readableLayoutFrame.origin.x + readableLayoutFrame.size.width) return insets } + + /// Separators View + /// @objc var separatorsView: SeparatorsView = { let view = SeparatorsView() view.backgroundColor = .listForeground return view }() + + // MARK: - Overridden Methods + override func layoutSubviews() { super.layoutSubviews() refreshSeparators() } - // MARK: - Public Methods + override func awakeFromNib() { + super.awakeFromNib() + backgroundView = separatorsView + backgroundColor = .listForeground + } + + + // MARK: - Public API + + /// Updates the Separators Insets / Visibility. This API should be called from within `UITableView.willDisplayCell`. + /// + /// - Note: + /// `readableSeparatorInsets`, if executed from within `cellForRowAtIndexPath`, will produce an "invalid" layout cycle (since there won't + /// be a superview). Such "Invalid" layout cycle appears to be yielding an invalid `intrinsicContentSize` calculation, which is then cached, + /// and we end up with strings cutoff onScreen. =( + /// @objc func refreshSeparators() { // Exception: Badges require no separators if isBadge { @@ -43,17 +76,8 @@ class NoteBlockTableViewCell: WPTableViewCell { separatorsView.bottomInsets = isLastRow ? fullSeparatorInsets : readableSeparatorInsets separatorsView.bottomVisible = true } + @objc class func reuseIdentifier() -> String { return classNameWithoutNamespaces() } - - // MARK: - View Methods - override func awakeFromNib() { - super.awakeFromNib() - backgroundView = separatorsView - backgroundColor = .listForeground - } - - // MARK: - Private - fileprivate let fullSeparatorInsets = UIEdgeInsets.zero } diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTextTableViewCell.swift b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTextTableViewCell.swift index 5c56ac518187..b9162a93125e 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTextTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTextTableViewCell.swift @@ -8,6 +8,7 @@ class NoteBlockTextTableViewCell: NoteBlockTableViewCell, RichTextViewDataSource // MARK: - IBOutlets @IBOutlet private weak var textView: RichTextView! + @IBOutlet private var horizontalEdgeConstraints: [NSLayoutConstraint]! /// onUrlClick: Called whenever a URL is pressed within the textView's Area. /// @@ -37,6 +38,21 @@ class NoteBlockTextTableViewCell: NoteBlockTableViewCell, RichTextViewDataSource } } + /// Indicates if this should be styled as a larger title + /// + var isTitle: Bool = false { + didSet { + guard FeatureFlag.milestoneNotifications.enabled else { + return + } + + let spacing: CGFloat = isTitle ? Metrics.titleHorizontalSpacing : Metrics.standardHorizontalSpacing + // Conditional chaining here means this won't crash for any subclasses + // that don't have edge constraints (such as comment cells) + horizontalEdgeConstraints?.forEach({ $0.constant = spacing }) + } + } + /// TextView's NSLink Color /// @objc var linkColor: UIColor? { @@ -101,6 +117,11 @@ class NoteBlockTextTableViewCell: NoteBlockTableViewCell, RichTextViewDataSource textView.translatesAutoresizingMaskIntoConstraints = false } + override func prepareForReuse() { + super.prepareForReuse() + + isTitle = false + } // MARK: - RichTextView Data Source @@ -117,4 +138,9 @@ class NoteBlockTextTableViewCell: NoteBlockTableViewCell, RichTextViewDataSource onAttachmentClick?(textAttachment) return false } + + private enum Metrics { + static let standardHorizontalSpacing: CGFloat = 20 + static let titleHorizontalSpacing: CGFloat = 40 + } } diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTextTableViewCell.xib b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTextTableViewCell.xib index 089a11f1aa8a..b680cfdcbb05 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTextTableViewCell.xib +++ b/WordPress/Classes/ViewRelated/Notifications/Views/NoteBlockTextTableViewCell.xib @@ -1,11 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="11762" systemVersion="16D32" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="11757"/> - <capability name="Constraints to layout margins" minToolsVersion="6.0"/> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> @@ -15,24 +13,27 @@ <rect key="frame" x="0.0" y="0.0" width="320" height="44"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="iFe-U5-rWI" id="viy-Fi-ur6"> - <rect key="frame" x="0.0" y="0.0" width="320" height="43"/> + <rect key="frame" x="0.0" y="0.0" width="320" height="44"/> <autoresizingMask key="autoresizingMask"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="hE4-fp-yCe" customClass="RichTextView" customModule="WordPress"> - <rect key="frame" x="15" y="8" width="290" height="25"/> + <rect key="frame" x="20" y="8" width="280" height="25"/> <color key="backgroundColor" red="0.90245449542999268" green="0.91623926162719727" blue="0.93599998950958252" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> </view> </subviews> <constraints> <constraint firstItem="hE4-fp-yCe" firstAttribute="top" secondItem="viy-Fi-ur6" secondAttribute="topMargin" constant="-3" id="NKT-QG-MST"/> <constraint firstAttribute="bottomMargin" secondItem="hE4-fp-yCe" secondAttribute="bottom" id="PRW-Vw-tNv"/> - <constraint firstAttribute="trailingMargin" secondItem="hE4-fp-yCe" secondAttribute="trailing" id="dkA-7C-ajm"/> - <constraint firstItem="hE4-fp-yCe" firstAttribute="leading" secondItem="viy-Fi-ur6" secondAttribute="leadingMargin" id="hYV-Xw-F9X"/> + <constraint firstAttribute="trailing" secondItem="hE4-fp-yCe" secondAttribute="trailing" constant="20" id="dkA-7C-ajm"/> + <constraint firstItem="hE4-fp-yCe" firstAttribute="leading" secondItem="viy-Fi-ur6" secondAttribute="leading" constant="20" id="hYV-Xw-F9X"/> </constraints> </tableViewCellContentView> <connections> <outlet property="textView" destination="hE4-fp-yCe" id="dPZ-RS-zf9"/> + <outletCollection property="horizontalEdgeConstraints" destination="hYV-Xw-F9X" collectionClass="NSMutableArray" id="Oe5-3o-grc"/> + <outletCollection property="horizontalEdgeConstraints" destination="dkA-7C-ajm" collectionClass="NSMutableArray" id="XLl-RR-YO8"/> </connections> + <point key="canvasLocation" x="139" y="154"/> </tableViewCell> </objects> </document> diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableHeaderView.swift b/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableHeaderView.swift deleted file mode 100644 index fef97b0442a7..000000000000 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableHeaderView.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation -import WordPressShared - -/// This class renders a view with top and bottom separators, meant to be used as UITableView section -/// header in NotificationsViewController. -/// -class NoteTableHeaderView: UIView { - // MARK: - Public Properties - @objc var title: String? { - set { - // For layout reasons, we need to ensure that the titleLabel uses an exact Paragraph Height! - let unwrappedTitle = newValue?.localizedUppercase ?? String() - let attributes = Style.sectionHeaderRegularStyle - titleLabel.attributedText = NSAttributedString(string: unwrappedTitle, attributes: attributes) - - contentView.accessibilityLabel = unwrappedTitle - } - get { - return titleLabel.text - } - } - - @objc var separatorColor: UIColor? { - set { - contentView.bottomColor = newValue ?? UIColor.clear - contentView.topColor = newValue ?? UIColor.clear - } - get { - return contentView.bottomColor - } - } - - @objc class func makeFromNib() -> NoteTableHeaderView { - return Bundle.main.loadNibNamed("NoteTableHeaderView", owner: self, options: nil)?.first as! NoteTableHeaderView - } - - // MARK: - Convenience Initializers - override func awakeFromNib() { - super.awakeFromNib() - - // Make sure the Outlets are loaded - assert(contentView != nil) - assert(titleLabel != nil) - - // Background + Separators - backgroundColor = UIColor.clear - - contentView.backgroundColor = Style.sectionHeaderBackgroundColor - contentView.bottomVisible = true - contentView.topVisible = true - } - - - // MARK: - Aliases - typealias Style = WPStyleGuide.Notifications - - // MARK: - Static Properties - @objc static let estimatedHeight = CGFloat(26) - - // MARK: - Outlets - @IBOutlet fileprivate var contentView: SeparatorsView! - @IBOutlet fileprivate var titleLabel: UILabel! -} diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableHeaderView.xib b/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableHeaderView.xib deleted file mode 100644 index 8fa0cf975628..000000000000 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableHeaderView.xib +++ /dev/null @@ -1,57 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15705" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait" appearance="light"/> - <dependencies> - <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15706"/> - <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> - </dependencies> - <objects> - <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> - <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" id="v2Z-s3-Kgc" customClass="NoteTableHeaderView" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="700" height="40"/> - <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> - <subviews> - <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="AjL-Ni-9e0" customClass="SeparatorsView" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="700" height="40"/> - <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="240" verticalHuggingPriority="240" verticalCompressionResistancePriority="1000" text="Label" textAlignment="natural" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="zK2-Ta-Z1W" userLabel="Title"> - <rect key="frame" x="20" y="16" width="660" height="16"/> - <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <nil key="textColor"/> - <nil key="highlightedColor"/> - <size key="shadowOffset" width="0.0" height="0.0"/> - </label> - </subviews> - <accessibility key="accessibilityConfiguration"> - <accessibilityTraits key="traits" header="YES"/> - <bool key="isElement" value="YES"/> - </accessibility> - <constraints> - <constraint firstItem="zK2-Ta-Z1W" firstAttribute="top" secondItem="AjL-Ni-9e0" secondAttribute="top" constant="16" id="84n-R2-3hp"/> - <constraint firstAttribute="bottom" secondItem="zK2-Ta-Z1W" secondAttribute="bottom" constant="8" id="H5r-aJ-Jgz"/> - <constraint firstItem="zK2-Ta-Z1W" firstAttribute="leading" secondItem="AjL-Ni-9e0" secondAttribute="leading" constant="20" symbolic="YES" id="a2X-M1-Zdx"/> - <constraint firstItem="zK2-Ta-Z1W" firstAttribute="trailing" secondItem="AjL-Ni-9e0" secondAttribute="trailingMargin" id="seF-xR-8yi"/> - </constraints> - </view> - </subviews> - <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstItem="AjL-Ni-9e0" firstAttribute="leading" secondItem="v2Z-s3-Kgc" secondAttribute="leading" id="KBr-LI-hk5"/> - <constraint firstAttribute="trailing" secondItem="AjL-Ni-9e0" secondAttribute="trailing" id="SCN-H1-mER"/> - <constraint firstAttribute="bottom" secondItem="AjL-Ni-9e0" secondAttribute="bottom" id="TqB-Ef-M2T"/> - <constraint firstItem="AjL-Ni-9e0" firstAttribute="top" secondItem="v2Z-s3-Kgc" secondAttribute="top" id="VYd-Ct-yx9"/> - </constraints> - <nil key="simulatedStatusBarMetrics"/> - <nil key="simulatedTopBarMetrics"/> - <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> - <connections> - <outlet property="contentView" destination="AjL-Ni-9e0" id="kwM-G7-5r8"/> - <outlet property="titleLabel" destination="zK2-Ta-Z1W" id="Rq8-8Y-X4c"/> - </connections> - <point key="canvasLocation" x="38.399999999999999" y="69.715142428785612"/> - </view> - </objects> -</document> diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableViewCell.swift b/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableViewCell.swift deleted file mode 100644 index 7d82e08985f8..000000000000 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableViewCell.swift +++ /dev/null @@ -1,277 +0,0 @@ -import Foundation -import WordPressShared - -/// The purpose of this class is to render a Notification entity, onscreen. -/// This cell should be loaded from its nib, since the autolayout constraints and outlets are not generated -/// via code. -/// Supports specific styles for Unapproved Comment Notifications, Unread Notifications, and a brand -/// new "Undo Deletion" mechanism has been implemented. See "NoteUndoOverlayView" for reference. -/// -class NoteTableViewCell: UITableViewCell { - // MARK: - Public Properties - @objc var read: Bool = false { - didSet { - if read != oldValue { - refreshBackgrounds() - } - } - } - @objc var unapproved: Bool = false { - didSet { - if unapproved != oldValue { - refreshBackgrounds() - } - } - } - @objc var showsUndeleteOverlay: Bool { - get { - return undeleteOverlayText != nil - } - } - @objc var showsBottomSeparator: Bool { - set { - separatorsView.bottomVisible = newValue - } - get { - return separatorsView.bottomVisible == false - } - } - @objc var attributedSubject: NSAttributedString? { - set { - subjectLabel.attributedText = newValue - setNeedsLayout() - } - get { - return subjectLabel.attributedText - } - } - @objc var attributedSnippet: NSAttributedString? { - set { - snippetLabel.attributedText = newValue - refreshNumberOfLines() - setNeedsLayout() - } - get { - return snippetLabel.attributedText - } - } - @objc var undeleteOverlayText: String? { - didSet { - if undeleteOverlayText != oldValue { - refreshSubviewVisibility() - refreshBackgrounds() - refreshUndoOverlay() - refreshSelectionStyle() - } - } - } - @objc var noticon: String? { - set { - noticonLabel.text = newValue - } - get { - return noticonLabel.text - } - } - @objc var onUndelete: (() -> Void)? - - - - // MARK: - Public Methods - @objc class func reuseIdentifier() -> String { - return classNameWithoutNamespaces() - } - - @objc func downloadIconWithURL(_ url: URL?) { - let isGravatarURL = url.map { Gravatar.isGravatarURL($0) } ?? false - if isGravatarURL { - downloadGravatarWithURL(url) - return - } - - // Handle non-gravatar images - let placeholderImage = Style.blockGravatarPlaceholderImage(isApproved: !unapproved) - iconImageView.downloadImage(from: url, placeholderImage: placeholderImage, failure: { [weak self] error in - // Note: Don't cache 404's. Otherwise Unapproved / Approved gravatars won't switch! - if (self?.gravatarURL == url) == true { - self?.gravatarURL = nil - } - }) - - gravatarURL = url - } - - - // MARK: - Gravatar Helpers - fileprivate func downloadGravatarWithURL(_ url: URL?) { - if url == gravatarURL { - return - } - - let placeholderImage = Style.blockGravatarPlaceholderImage(isApproved: !unapproved) - let gravatar = url.flatMap { Gravatar($0) } - - if gravatar == nil { - // Note: If we've got any issues with the Gravatar instance, fallback to the placeholder, and dont' - // cache the URL! - iconImageView.image = placeholderImage - gravatarURL = nil - return - } - - iconImageView.downloadGravatar(gravatar, - placeholder: placeholderImage, - animate: false, - failure: {[weak self] (error: Error?) in - // Note: Don't cache 404's. Otherwise Unapproved / Approved gravatars won't switch! - if (self?.gravatarURL == url) == true { - self?.gravatarURL = nil - } - }) - - gravatarURL = url - } - - - - // MARK: - UITableViewCell Methods - override func awakeFromNib() { - super.awakeFromNib() - - iconImageView.image = .gravatarPlaceholderImage - - noticonContainerView.layer.cornerRadius = Settings.noticonContainerRadius - - noticonView.layer.cornerRadius = Settings.noticonRadius - noticonLabel.font = Style.noticonFont - noticonLabel.textColor = Style.noticonTextColor - - subjectLabel.numberOfLines = Settings.subjectNumberOfLinesWithSnippet - subjectLabel.shadowOffset = CGSize.zero - - snippetLabel.numberOfLines = Settings.snippetNumberOfLines - - // Separators: Setup bottom separators! - separatorsView.bottomColor = WPStyleGuide.Notifications.noteSeparatorColor - backgroundView = separatorsView - } - - override func layoutSubviews() { - refreshBackgrounds() - super.layoutSubviews() - refreshSeparators() - } - - override func setSelected(_ selected: Bool, animated: Bool) { - // Note: this is required, since the cell unhighlight mechanism will reset the new background color - super.setSelected(selected, animated: animated) - refreshBackgrounds() - } - - override func setHighlighted(_ highlighted: Bool, animated: Bool) { - // Note: this is required, since the cell unhighlight mechanism will reset the new background color - super.setHighlighted(highlighted, animated: animated) - refreshBackgrounds() - } - - - // MARK: - Private Methods - - @objc func refreshSeparators() { - separatorsView.bottomInsets = .zero - } - - fileprivate func refreshBackgrounds() { - // Noticon Background - if unapproved { - noticonView.backgroundColor = Style.noticonUnmoderatedColor - noticonContainerView.backgroundColor = Style.noticonTextColor - } else if read { - noticonView.backgroundColor = Style.noticonReadColor - noticonContainerView.backgroundColor = Style.noticonTextColor - } else { - noticonView.backgroundColor = Style.noticonUnreadColor - noticonContainerView.backgroundColor = Style.noteBackgroundUnreadColor - } - - // Cell Background - backgroundColor = read ? Style.noteBackgroundReadColor : Style.noteBackgroundUnreadColor - } - - fileprivate func refreshSelectionStyle() { - selectionStyle = showsUndeleteOverlay ? .none : .gray - } - - fileprivate func refreshSubviewVisibility() { - for subview in contentView.subviews { - subview.isHidden = showsUndeleteOverlay - } - } - - fileprivate func refreshNumberOfLines() { - // When the snippet is present, let's clip the number of lines in the subject - let showsSnippet = attributedSnippet != nil - subjectLabel.numberOfLines = Settings.subjectNumberOfLines(showsSnippet) - } - - fileprivate func refreshUndoOverlay() { - // Remove - guard showsUndeleteOverlay else { - undoOverlayView?.removeFromSuperview() - undoOverlayView = nil - return - } - - // Lazy Load - if undoOverlayView == nil { - let nibName = NoteUndoOverlayView.classNameWithoutNamespaces() - Bundle.main.loadNibNamed(nibName, owner: self, options: nil) - undoOverlayView.translatesAutoresizingMaskIntoConstraints = false - - contentView.addSubview(undoOverlayView) - contentView.pinSubviewToAllEdges(undoOverlayView) - } - - undoOverlayView.isHidden = false - undoOverlayView.legendText = undeleteOverlayText - } - - - - // MARK: - Action Handlers - @IBAction func undeleteWasPressed(_ sender: AnyObject) { - onUndelete?() - } - - - // MARK: - Private Alias - fileprivate typealias Style = WPStyleGuide.Notifications - - // MARK: - Private Settings - fileprivate struct Settings { - static let subjectNumberOfLinesWithoutSnippet = 3 - static let subjectNumberOfLinesWithSnippet = 2 - static let snippetNumberOfLines = 2 - static let noticonRadius = CGFloat(10) - static let noticonContainerRadius = CGFloat(12) - - static func subjectNumberOfLines(_ showsSnippet: Bool) -> Int { - return showsSnippet ? subjectNumberOfLinesWithSnippet : subjectNumberOfLinesWithoutSnippet - } - } - - // MARK: - Private Properties - fileprivate var gravatarURL: URL? - fileprivate var separatorsView = SeparatorsView() - - // MARK: - IBOutlets - @IBOutlet var iconImageView: CircularImageView! - @IBOutlet var noticonLabel: UILabel! - @IBOutlet var noticonContainerView: UIView! - @IBOutlet var noticonView: UIView! - @IBOutlet var subjectLabel: UILabel! - @IBOutlet var snippetLabel: UILabel! - - // MARK: - Undo Overlay Optional - @IBOutlet var undoOverlayView: NoteUndoOverlayView! -} diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableViewCell.xib b/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableViewCell.xib deleted file mode 100644 index a7f09bdb7fc1..000000000000 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteTableViewCell.xib +++ /dev/null @@ -1,114 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15705" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait" appearance="light"/> - <dependencies> - <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15706"/> - <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> - </dependencies> - <objects> - <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> - <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <tableViewCell contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="blue" hidesAccessoryWhenEditing="NO" indentationLevel="1" indentationWidth="0.0" id="9ZS-co-ZCe" customClass="NoteTableViewCell" customModule="WordPress"> - <rect key="frame" x="0.0" y="0.0" width="320" height="87"/> - <autoresizingMask key="autoresizingMask"/> - <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="9ZS-co-ZCe" id="9Ex-yA-LkS"> - <rect key="frame" x="0.0" y="0.0" width="320" height="87"/> - <autoresizingMask key="autoresizingMask"/> - <subviews> - <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="JUi-EY-Obv" userLabel="IconImageView" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="16" y="16" width="46" height="46"/> - <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstAttribute="width" constant="46" id="JQZ-Z6-YPJ"/> - <constraint firstAttribute="height" constant="46" id="R98-OQ-ZNf"/> - </constraints> - </imageView> - <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="WuB-h0-mSd"> - <rect key="frame" x="78" y="13" width="226" height="40"/> - <subviews> - <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="TopLeft" horizontalHuggingPriority="251" verticalHuggingPriority="1000" text="Text" lineBreakMode="wordWrap" numberOfLines="5" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="81k-rx-Ws8" userLabel="SubjectLabel"> - <rect key="frame" x="0.0" y="0.0" width="226" height="19"/> - <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <color key="tintColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <fontDescription key="fontDescription" name="HelveticaNeue" family="Helvetica Neue" pointSize="16"/> - <color key="textColor" cocoaTouchSystemColor="darkTextColor"/> - <nil key="highlightedColor"/> - </label> - <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="TopLeft" text="Snippet" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="LwI-5x-Xx8" userLabel="SnippetLabel"> - <rect key="frame" x="0.0" y="21" width="226" height="19"/> - <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <color key="tintColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> - <fontDescription key="fontDescription" name="HelveticaNeue" family="Helvetica Neue" pointSize="16"/> - <color key="textColor" cocoaTouchSystemColor="darkTextColor"/> - <nil key="highlightedColor"/> - </label> - </subviews> - </stackView> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Z3U-oU-FSh" userLabel="NoticonContainerView"> - <rect key="frame" x="46" y="46" width="24" height="24"/> - <subviews> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="v6e-fz-xh3" userLabel="NoticonView"> - <rect key="frame" x="2" y="2" width="20" height="20"/> - <subviews> - <label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="p3T-gY-I5l" userLabel="NoticonLabel"> - <rect key="frame" x="2" y="2" width="16" height="16"/> - <accessibility key="accessibilityConfiguration"> - <accessibilityTraits key="traits" image="YES"/> - <bool key="isElement" value="NO"/> - </accessibility> - <constraints> - <constraint firstAttribute="height" constant="16" id="6re-w2-PC4"/> - <constraint firstAttribute="width" constant="16" id="Ftz-BS-hba"/> - </constraints> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <color key="textColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> - </subviews> - <color key="backgroundColor" cocoaTouchSystemColor="scrollViewTexturedBackgroundColor"/> - <constraints> - <constraint firstItem="p3T-gY-I5l" firstAttribute="top" secondItem="v6e-fz-xh3" secondAttribute="top" constant="2" id="Qmb-sM-qOL"/> - <constraint firstAttribute="width" constant="20" id="h8E-AX-vFT"/> - <constraint firstAttribute="centerX" secondItem="p3T-gY-I5l" secondAttribute="centerX" id="hGJ-FW-Tia"/> - <constraint firstAttribute="height" constant="20" id="ubF-uU-Q8E"/> - </constraints> - </view> - </subviews> - <color key="backgroundColor" cocoaTouchSystemColor="scrollViewTexturedBackgroundColor"/> - <constraints> - <constraint firstAttribute="centerX" secondItem="v6e-fz-xh3" secondAttribute="centerX" id="1eN-Ve-4wo"/> - <constraint firstAttribute="width" constant="24" id="7VV-Ux-nIo"/> - <constraint firstAttribute="centerY" secondItem="v6e-fz-xh3" secondAttribute="centerY" id="MCB-Ul-UcB"/> - <constraint firstAttribute="height" constant="24" id="NW8-Gc-54w"/> - </constraints> - </view> - </subviews> - <constraints> - <constraint firstItem="Z3U-oU-FSh" firstAttribute="centerX" secondItem="JUi-EY-Obv" secondAttribute="centerX" constant="19" id="6if-Fj-ZiN"/> - <constraint firstItem="WuB-h0-mSd" firstAttribute="top" secondItem="9Ex-yA-LkS" secondAttribute="top" constant="13" id="CiS-sV-QaR"/> - <constraint firstItem="JUi-EY-Obv" firstAttribute="leading" secondItem="9Ex-yA-LkS" secondAttribute="leading" constant="16" id="FNd-KV-ja4"/> - <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="WuB-h0-mSd" secondAttribute="bottom" constant="16" id="W83-eQ-gaV"/> - <constraint firstItem="WuB-h0-mSd" firstAttribute="leading" secondItem="JUi-EY-Obv" secondAttribute="trailing" constant="16" id="Wef-tC-bRq"/> - <constraint firstAttribute="top" secondItem="JUi-EY-Obv" secondAttribute="top" constant="-16" id="Zav-nr-UzA"/> - <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="JUi-EY-Obv" secondAttribute="bottom" priority="999" constant="16" id="bb5-uM-agq"/> - <constraint firstItem="Z3U-oU-FSh" firstAttribute="centerY" secondItem="JUi-EY-Obv" secondAttribute="centerY" constant="19" id="h5p-yY-IvK"/> - <constraint firstAttribute="trailing" secondItem="WuB-h0-mSd" secondAttribute="trailing" constant="16" id="tuw-OS-5Gn"/> - </constraints> - </tableViewCellContentView> - <accessibility key="accessibilityConfiguration"> - <accessibilityTraits key="traits" button="YES"/> - </accessibility> - <inset key="separatorInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/> - <connections> - <outlet property="iconImageView" destination="JUi-EY-Obv" id="TRa-w4-qvm"/> - <outlet property="noticonContainerView" destination="Z3U-oU-FSh" id="sEj-sO-l50"/> - <outlet property="noticonLabel" destination="p3T-gY-I5l" id="fpd-ba-G4z"/> - <outlet property="noticonView" destination="v6e-fz-xh3" id="mLR-H8-dHz"/> - <outlet property="snippetLabel" destination="LwI-5x-Xx8" id="cNh-MV-sUL"/> - <outlet property="subjectLabel" destination="81k-rx-Ws8" id="w03-Ne-Ka1"/> - </connections> - <point key="canvasLocation" x="34" y="10"/> - </tableViewCell> - </objects> -</document> diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteUndoOverlayView.swift b/WordPress/Classes/ViewRelated/Notifications/Views/NoteUndoOverlayView.swift deleted file mode 100644 index b095a863e9e2..000000000000 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteUndoOverlayView.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation -import WordPressShared - - -/// This class renders a simple overlay view, with a Legend Label on its right, and an undo button on its -/// right side. -/// The purpose of this helper view is to act as a simple drop-in overlay, to be used by NoteTableViewCell. -/// By doing this, we avoid the need of having yet another UITableViewCell subclass, and thus, -/// we don't need to duplicate any of the mechanisms already available in NoteTableViewCell, such as -/// custom cell separators and Height Calculation. -/// -class NoteUndoOverlayView: UIView { - // MARK: - Properties - - /// Legend Text - /// - @objc var legendText: String? { - get { - return legendLabel.text - } - set { - legendLabel.text = newValue - } - } - - /// Action Button Text - /// - @objc var buttonText: String? { - get { - return undoButton.title(for: UIControl.State()) - } - set { - undoButton.setTitle(newValue, for: UIControl.State()) - } - } - - - // MARK: - Overriden Methods - override func awakeFromNib() { - super.awakeFromNib() - backgroundColor = Style.noteUndoBackgroundColor - - // Legend - legendLabel.textColor = Style.noteUndoTextColor - legendLabel.font = Style.noteUndoTextFont - - // Button - undoButton.titleLabel?.font = Style.noteUndoTextFont - undoButton.setTitle(NSLocalizedString("Undo", comment: "Revert an operation"), for: UIControl.State()) - undoButton.setTitleColor(Style.noteUndoTextColor, for: UIControl.State()) - } - - - // MARK: - Private Alias - fileprivate typealias Style = WPStyleGuide.Notifications - - // MARK: - Private Outlets - @IBOutlet fileprivate weak var legendLabel: UILabel! - @IBOutlet fileprivate weak var undoButton: UIButton! -} diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/NoteUndoOverlayView.xib b/WordPress/Classes/ViewRelated/Notifications/Views/NoteUndoOverlayView.xib deleted file mode 100644 index 7b2401957f3a..000000000000 --- a/WordPress/Classes/ViewRelated/Notifications/Views/NoteUndoOverlayView.xib +++ /dev/null @@ -1,60 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="11762" systemVersion="16D32" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> - <dependencies> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="11757"/> - <capability name="Constraints to layout margins" minToolsVersion="6.0"/> - <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> - </dependencies> - <objects> - <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="NoteTableViewCell" customModule="WordPress"> - <connections> - <outlet property="undoOverlayView" destination="kuu-Cw-2jl" id="TAB-tg-sVT"/> - </connections> - </placeholder> - <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> - <view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" id="kuu-Cw-2jl" customClass="NoteUndoOverlayView" customModule="WordPress"> - <rect key="frame" x="0.0" y="0.0" width="320" height="90"/> - <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> - <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.80000000000000004" translatesAutoresizingMaskIntoConstraints="NO" id="1qq-Os-ZKG"> - <rect key="frame" x="8" y="0.0" width="234" height="90"/> - <fontDescription key="fontDescription" type="system" pointSize="17"/> - <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="UAJ-NN-HGG"> - <rect key="frame" x="242" y="0.0" width="70" height="90"/> - <constraints> - <constraint firstAttribute="width" constant="70" id="yhn-4B-gyH"/> - </constraints> - <state key="normal" title="Button"> - <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </state> - <connections> - <action selector="undeleteWasPressed:" destination="-1" eventType="touchUpInside" id="Tby-x9-r52"/> - </connections> - </button> - </subviews> - <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstItem="UAJ-NN-HGG" firstAttribute="top" secondItem="kuu-Cw-2jl" secondAttribute="top" id="BlT-Kc-tJD"/> - <constraint firstItem="1qq-Os-ZKG" firstAttribute="leading" secondItem="kuu-Cw-2jl" secondAttribute="leadingMargin" id="VBo-LR-Vzu"/> - <constraint firstItem="UAJ-NN-HGG" firstAttribute="leading" secondItem="1qq-Os-ZKG" secondAttribute="trailing" id="azB-bP-rYN"/> - <constraint firstItem="1qq-Os-ZKG" firstAttribute="top" secondItem="kuu-Cw-2jl" secondAttribute="top" id="dIv-Mo-dKw"/> - <constraint firstAttribute="bottom" secondItem="UAJ-NN-HGG" secondAttribute="bottom" id="hSW-vG-9k2"/> - <constraint firstAttribute="trailingMargin" secondItem="UAJ-NN-HGG" secondAttribute="trailing" id="nKd-Kd-xGt"/> - <constraint firstAttribute="bottom" secondItem="1qq-Os-ZKG" secondAttribute="bottom" id="tUC-8v-XvD"/> - </constraints> - <nil key="simulatedStatusBarMetrics"/> - <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> - <connections> - <outlet property="legendLabel" destination="1qq-Os-ZKG" id="T0S-hf-uGp"/> - <outlet property="undoButton" destination="UAJ-NN-HGG" id="ND7-42-hWl"/> - </connections> - <point key="canvasLocation" x="159" y="292"/> - </view> - </objects> -</document> diff --git a/WordPress/Classes/ViewRelated/Pages/EditPageViewController.swift b/WordPress/Classes/ViewRelated/Pages/EditPageViewController.swift new file mode 100644 index 000000000000..d0ca698f6abe --- /dev/null +++ b/WordPress/Classes/ViewRelated/Pages/EditPageViewController.swift @@ -0,0 +1,157 @@ +import UIKit + +class EditPageViewController: UIViewController { + var entryPoint: PostEditorEntryPoint = .unknown + fileprivate var page: Page? + fileprivate var blog: Blog + fileprivate var postTitle: String? + fileprivate var content: String? + fileprivate var hasShownEditor = false + fileprivate var isHomePageEditor = false + private var homepageEditorCompletion: HomepageEditorCompletion? + + convenience init(page: Page) { + self.init(page: page, blog: page.blog, postTitle: nil, content: nil, appliedTemplate: nil) + } + + convenience init(homepage: Page, completion: @escaping HomepageEditorCompletion) { + self.init(page: homepage) + isHomePageEditor = true + homepageEditorCompletion = completion + } + + convenience init(blog: Blog, postTitle: String?, content: String?, appliedTemplate: String?) { + self.init(page: nil, blog: blog, postTitle: postTitle, content: content, appliedTemplate: appliedTemplate) + } + + fileprivate init(page: Page?, blog: Blog, postTitle: String?, content: String?, appliedTemplate: String?) { + self.page = page + self.blog = blog + self.postTitle = postTitle + self.content = content + + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .overFullScreen + modalTransitionStyle = .coverVertical + restorationIdentifier = RestorationKey.viewController.rawValue + restorationClass = EditPageViewController.self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if !hasShownEditor { + if isHomePageEditor { + showHomepageEditor() + } else { + showEditor() + } + hasShownEditor = true + } + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return WPStyleGuide.preferredStatusBarStyle + } + + fileprivate func pageToEdit() -> Page { + if let page = self.page { + return page + } else { + let newPage = blog.createDraftPage() + newPage.content = self.content + newPage.postTitle = self.postTitle + self.page = newPage + return newPage + } + } + + fileprivate func showHomepageEditor() { + let editorFactory = EditorFactory() + let gutenbergVC = editorFactory.createHomepageGutenbergVC(with: self.pageToEdit(), loadAutosaveRevision: false, replaceEditor: { [weak self] (editor, replacement) in + self?.replaceEditor(editor: editor, replacement: replacement) + }) + + show(gutenbergVC) + } + + fileprivate func showEditor() { + let editorFactory = EditorFactory() + + let editorViewController = editorFactory.instantiateEditor( + for: self.pageToEdit(), + replaceEditor: { [weak self] (editor, replacement) in + self?.replaceEditor(editor: editor, replacement: replacement) + }) + + show(editorViewController) + } + + private func show(_ editor: EditorViewController) { + editor.entryPoint = entryPoint + editor.onClose = { [weak self] _, _ in + // Dismiss navigation controller + self?.dismiss(animated: true) { + // Dismiss self + self?.dismiss(animated: false) { + // Invoke completion + self?.homepageEditorCompletion?() + } + } + } + + let navController = AztecNavigationController(rootViewController: editor) + navController.modalPresentationStyle = .fullScreen + + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.prepare() + + present(navController, animated: true) { + generator.impactOccurred() + } + } + + func replaceEditor(editor: EditorViewController, replacement: EditorViewController) { + editor.dismiss(animated: true) { [weak self] in + self?.show(replacement) + } + } + +} + + +extension EditPageViewController: UIViewControllerRestoration { + enum RestorationKey: String { + case viewController = "EditPageViewControllerRestorationID" + case page = "EditPageViewControllerPageRestorationID" + } + + class func viewController(withRestorationIdentifierPath identifierComponents: [String], + coder: NSCoder) -> UIViewController? { + guard let identifier = identifierComponents.last, identifier == RestorationKey.viewController.rawValue else { + return nil + } + + let context = ContextManager.sharedInstance().mainContext + + guard let pageURL = coder.decodeObject(forKey: RestorationKey.page.rawValue) as? URL, + let pageID = context.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: pageURL), + let page = try? context.existingObject(with: pageID), + let reloadedPage = page as? Page + else { + return nil + } + + return EditPageViewController(page: reloadedPage) + } + + override func encodeRestorableState(with coder: NSCoder) { + super.encodeRestorableState(with: coder) + if let page = self.page { + coder.encode(page.objectID.uriRepresentation(), forKey: RestorationKey.page.rawValue) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Pages/PageEditorPresenter.swift b/WordPress/Classes/ViewRelated/Pages/PageEditorPresenter.swift new file mode 100644 index 000000000000..99191c4cb591 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Pages/PageEditorPresenter.swift @@ -0,0 +1,44 @@ +import Foundation +import WordPressFlux + +struct PageEditorPresenter { + @discardableResult + static func handle(page: Page, in presentingViewController: UIViewController, entryPoint: PostEditorEntryPoint) -> Bool { + guard !page.isSitePostsPage else { + showSitePostPageUneditableNotice() + return false + } + + guard page.status != .trash else { + return false + } + + guard !PostCoordinator.shared.isUploading(post: page) else { + presentAlertForPageBeingUploaded() + return false + } + + QuickStartTourGuide.shared.endCurrentTour() + + let editorViewController = EditPageViewController(page: page) + editorViewController.entryPoint = entryPoint + presentingViewController.present(editorViewController, animated: false) + return true + } + + private static func showSitePostPageUneditableNotice() { + let sitePostPageUneditableNotice = NSLocalizedString("The content of your latest posts page is automatically generated and cannot be edited.", comment: "Message informing the user that posts page cannot be edited") + let notice = Notice(title: sitePostPageUneditableNotice, feedbackType: .warning) + ActionDispatcher.global.dispatch(NoticeAction.post(notice)) + } + + private static func presentAlertForPageBeingUploaded() { + let message = NSLocalizedString("This page is currently uploading. It won't take long – try again soon and you'll be able to edit it.", comment: "Prompts the user that the page is being uploaded and cannot be edited while that process is ongoing.") + + let alertCancel = NSLocalizedString("OK", comment: "Title of an OK button. Pressing the button acknowledges and dismisses a prompt.") + + let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alertController.addCancelActionWithTitle(alertCancel, handler: nil) + alertController.presentFromRootViewController() + } +} diff --git a/WordPress/Classes/ViewRelated/Pages/PageListTableViewCell.m b/WordPress/Classes/ViewRelated/Pages/PageListTableViewCell.m index 62fd416d9992..3497f942ba5a 100644 --- a/WordPress/Classes/ViewRelated/Pages/PageListTableViewCell.m +++ b/WordPress/Classes/ViewRelated/Pages/PageListTableViewCell.m @@ -13,6 +13,8 @@ @interface PageListTableViewCell() @property (nonatomic, strong) IBOutlet UILabel *titleLabel; @property (nonatomic, strong) IBOutlet UILabel *timestampLabel; @property (nonatomic, strong) IBOutlet UILabel *badgesLabel; +@property (nonatomic, strong) IBOutlet UILabel *typeLabel; +@property (nonatomic, strong) IBOutlet UIImageView *typeIcon; @property (strong, nonatomic) IBOutlet CachedAnimatedImageView *featuredImageView; @property (nonatomic, strong) IBOutlet UIButton *menuButton; @property (nonatomic, strong) IBOutlet NSLayoutConstraint *labelsContainerTrailing; @@ -31,7 +33,7 @@ @implementation PageListTableViewCell { - (void)awakeFromNib { [super awakeFromNib]; - + self.contentView.autoresizingMask = UIViewAutoresizingFlexibleHeight; [self applyStyles]; [self setupAccessibility]; } @@ -107,14 +109,18 @@ - (void)applyStyles [WPStyleGuide configureTableViewCell:self]; [WPStyleGuide configureLabel:self.timestampLabel textStyle:UIFontTextStyleSubheadline]; [WPStyleGuide configureLabel:self.badgesLabel textStyle:UIFontTextStyleSubheadline]; + [WPStyleGuide configureLabel:self.typeLabel textStyle:UIFontTextStyleSubheadline]; self.titleLabel.font = [WPStyleGuide notoBoldFontForTextStyle:UIFontTextStyleHeadline]; self.titleLabel.adjustsFontForContentSizeCategory = YES; self.titleLabel.textColor = [UIColor murielText]; self.badgesLabel.textColor = [UIColor murielTextSubtle]; + self.typeLabel.textColor = [UIColor murielTextSubtle]; self.menuButton.tintColor = [UIColor murielTextSubtle]; - [self.menuButton setImage:[Gridicon iconOfType:GridiconTypeEllipsis] forState:UIControlStateNormal]; + [self.menuButton setImage:[UIImage gridiconOfType:GridiconTypeEllipsis] forState:UIControlStateNormal]; + + self.typeIcon.tintColor = [UIColor murielTextSubtle]; self.backgroundColor = [UIColor murielNeutral5]; self.contentView.backgroundColor = [UIColor murielNeutral5]; @@ -146,10 +152,27 @@ - (void)configureBadges Page *page = (Page *)self.post; NSMutableArray<NSString *> *badges = [NSMutableArray new]; - - NSString *timestamp = [self.post isScheduled] ? [self.dateFormatter stringFromDate:self.post.dateCreated] : [self.post.dateCreated mediumString]; - [badges addObject:timestamp]; + [self.typeLabel setText:@""]; + [self.typeIcon setImage:nil]; + + if (self.post.dateCreated != nil) { + NSString *timestamp = [self.post isScheduled] ? [self.dateFormatter stringFromDate:self.post.dateCreated] : [self.post.dateCreated mediumString]; + [badges addObject:timestamp]; + } + + if (page.isSiteHomepage) { + [badges addObject:@""]; + [self.typeLabel setText:NSLocalizedString(@"Homepage", @"Title of the Homepage Badge")]; + [self.typeIcon setImage:[UIImage gridiconOfType:GridiconTypeHouse]]; + } + + if (page.isSitePostsPage) { + [badges addObject:@""]; + [self.typeLabel setText:NSLocalizedString(@"Posts page", @"Title of the Posts Page Badge")]; + [self.typeIcon setImage:[UIImage gridiconOfType:GridiconTypePosts]]; + } + if (page.hasPrivateState) { [badges addObject:NSLocalizedString(@"Private", @"Title of the Private Badge")]; } else if (page.hasPendingReviewState) { @@ -170,11 +193,13 @@ - (void)configureFeaturedImage BOOL hideFeaturedImage = page.featuredImage == nil; self.featuredImageView.hidden = hideFeaturedImage; self.labelsContainerTrailing.active = !hideFeaturedImage; + BOOL isBlogAtomic = [page.featuredImage.blog isAtomic]; if (!hideFeaturedImage) { [self.featuredImageLoader loadImageFromMedia:page.featuredImage preferredSize:CGSizeMake(FeaturedImageSize, FeaturedImageSize) placeholder:nil + isBlogAtomic:isBlogAtomic success:nil error:^(NSError *error) { DDLogError(@"Failed to load the media: %@", error); diff --git a/WordPress/Classes/ViewRelated/Pages/PageListTableViewCell.xib b/WordPress/Classes/ViewRelated/Pages/PageListTableViewCell.xib index 0347c4ba7813..e42c25955b09 100644 --- a/WordPress/Classes/ViewRelated/Pages/PageListTableViewCell.xib +++ b/WordPress/Classes/ViewRelated/Pages/PageListTableViewCell.xib @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14865.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14819.2"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <customFonts key="customFonts"> @@ -19,89 +19,115 @@ <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM"> <rect key="frame" x="0.0" y="0.0" width="320" height="60"/> - <autoresizingMask key="autoresizingMask"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <subviews> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="6Um-2J-DCT"> - <rect key="frame" x="0.0" y="0.0" width="320" height="60"/> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="6l3-mv-Tbu" userLabel="Labels"> + <rect key="frame" x="16" y="8" width="188.5" height="44"/> <subviews> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="6l3-mv-Tbu" userLabel="Labels"> - <rect key="frame" x="16" y="7.5" width="185" height="43.5"/> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="S9J-0t-GbV"> + <rect key="frame" x="0.0" y="0.0" width="188.5" height="24"/> + <fontDescription key="fontDescription" name="NotoSerif-Bold" family="Noto Serif" pointSize="17"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="xDu-Qf-38X" userLabel="BottomLabels"> + <rect key="frame" x="0.0" y="26" width="188.5" height="18"/> <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="S9J-0t-GbV"> - <rect key="frame" x="0.0" y="0.0" width="185" height="23.5"/> - <fontDescription key="fontDescription" name="NotoSerif-Bold" family="Noto Serif" pointSize="17"/> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="1000" text="6 days ago •" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsLetterSpacingToFitWidth="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="s9t-JC-dzu"> + <rect key="frame" x="0.0" y="0.0" width="86" height="18"/> + <fontDescription key="fontDescription" type="system" pointSize="15"/> + <nil key="textColor"/> <nil key="highlightedColor"/> </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" text="Private • Local changes" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="s9t-JC-dzu"> - <rect key="frame" x="0.0" y="25.5" width="185" height="18"/> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" horizontalCompressionResistancePriority="749" text="Posts Page" textAlignment="natural" lineBreakMode="tailTruncation" minimumScaleFactor="0.5" adjustsLetterSpacingToFitWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="s1k-m6-54I"> + <rect key="frame" x="112" y="0.0" width="76.5" height="18"/> <fontDescription key="fontDescription" type="system" pointSize="15"/> <nil key="textColor"/> <nil key="highlightedColor"/> </label> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="woP-g8-6RQ" customClass="FixedSizeImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="90" y="0.0" width="18" height="18"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" secondItem="woP-g8-6RQ" secondAttribute="height" multiplier="1:1" id="cZt-Vh-3bN"/> + <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="16" id="jKE-Hl-Flm"/> + </constraints> + </imageView> </subviews> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <constraints> - <constraint firstItem="s9t-JC-dzu" firstAttribute="top" secondItem="S9J-0t-GbV" secondAttribute="bottom" constant="2" id="0iK-yR-Vxs"/> - <constraint firstAttribute="trailing" secondItem="S9J-0t-GbV" secondAttribute="trailing" id="1zk-Kl-c5k"/> - <constraint firstItem="s9t-JC-dzu" firstAttribute="leading" secondItem="6l3-mv-Tbu" secondAttribute="leading" id="G0U-lY-puO"/> - <constraint firstAttribute="bottom" secondItem="s9t-JC-dzu" secondAttribute="bottom" id="GJL-jP-3j6"/> - <constraint firstItem="S9J-0t-GbV" firstAttribute="leading" secondItem="6l3-mv-Tbu" secondAttribute="leading" id="WD8-Px-1ST"/> - <constraint firstAttribute="trailing" secondItem="s9t-JC-dzu" secondAttribute="trailing" id="pMN-9O-aLr"/> - <constraint firstItem="S9J-0t-GbV" firstAttribute="top" secondItem="6l3-mv-Tbu" secondAttribute="top" id="tEH-TE-pPc"/> + <constraint firstItem="s1k-m6-54I" firstAttribute="leading" secondItem="woP-g8-6RQ" secondAttribute="trailing" constant="4" id="1te-ed-JRy"/> + <constraint firstAttribute="bottom" secondItem="s1k-m6-54I" secondAttribute="bottom" id="3sS-fd-Nqp"/> + <constraint firstAttribute="bottom" secondItem="s9t-JC-dzu" secondAttribute="bottom" id="CHe-Hx-ZZS"/> + <constraint firstItem="s9t-JC-dzu" firstAttribute="bottom" secondItem="woP-g8-6RQ" secondAttribute="bottom" id="KUW-e7-Eol"/> + <constraint firstAttribute="trailing" secondItem="s1k-m6-54I" secondAttribute="trailing" id="b5q-O9-5WV"/> + <constraint firstItem="woP-g8-6RQ" firstAttribute="leading" secondItem="s9t-JC-dzu" secondAttribute="trailing" constant="4" id="bRn-b2-vuI"/> + <constraint firstItem="s9t-JC-dzu" firstAttribute="top" secondItem="xDu-Qf-38X" secondAttribute="top" id="hrq-w2-vZo"/> + <constraint firstItem="woP-g8-6RQ" firstAttribute="centerY" secondItem="xDu-Qf-38X" secondAttribute="centerY" id="kz0-fF-8o1"/> + <constraint firstItem="s9t-JC-dzu" firstAttribute="leading" secondItem="xDu-Qf-38X" secondAttribute="leading" id="l7P-y8-q8H"/> + <constraint firstItem="s1k-m6-54I" firstAttribute="top" secondItem="xDu-Qf-38X" secondAttribute="top" id="tVd-gE-mcR"/> </constraints> </view> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="VPZ-yr-cJj"> - <rect key="frame" x="264" y="6.5" width="56" height="47"/> - <constraints> - <constraint firstAttribute="height" constant="47" id="3OU-Uq-a1V"/> - <constraint firstAttribute="width" constant="56" id="Smb-26-LSz"/> - </constraints> - <state key="normal" image="icon-post-actionbar-more"> - <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </state> - <connections> - <action selector="onAction:" destination="KGk-i7-Jjw" eventType="touchUpInside" id="asl-aB-XVB"/> - </connections> - </button> - <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="8cG-SG-iYd" customClass="CachedAnimatedImageView" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="224" y="10" width="40" height="40"/> - <color key="backgroundColor" red="0.84705882352941175" green="0.84705882352941175" blue="0.84705882352941175" alpha="1" colorSpace="custom" customColorSpace="displayP3"/> - <constraints> - <constraint firstAttribute="height" constant="40" id="BMG-CS-ohd"/> - <constraint firstAttribute="width" constant="40" id="QSf-IN-2xw"/> - </constraints> - </imageView> </subviews> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <constraints> - <constraint firstItem="VPZ-yr-cJj" firstAttribute="leading" secondItem="8cG-SG-iYd" secondAttribute="trailing" id="B58-5v-Mt7"/> - <constraint firstItem="6l3-mv-Tbu" firstAttribute="leading" secondItem="6Um-2J-DCT" secondAttribute="leadingMargin" constant="8" id="Qas-h4-x8w"/> - <constraint firstItem="8cG-SG-iYd" firstAttribute="leading" secondItem="6l3-mv-Tbu" secondAttribute="trailing" constant="23" id="TnQ-N0-CaM"/> - <constraint firstAttribute="trailingMargin" secondItem="VPZ-yr-cJj" secondAttribute="trailing" constant="-8" id="UBU-ep-8EU"/> - <constraint firstItem="6l3-mv-Tbu" firstAttribute="centerY" secondItem="6Um-2J-DCT" secondAttribute="centerY" constant="-1" id="Xj2-9t-jAt"/> - <constraint firstItem="VPZ-yr-cJj" firstAttribute="centerY" secondItem="6Um-2J-DCT" secondAttribute="centerY" id="ZcN-mh-atJ"/> - <constraint firstItem="6l3-mv-Tbu" firstAttribute="trailing" secondItem="VPZ-yr-cJj" secondAttribute="leading" priority="750" id="hi6-ev-Kqn"/> - <constraint firstItem="8cG-SG-iYd" firstAttribute="centerY" secondItem="6Um-2J-DCT" secondAttribute="centerY" id="mQR-FT-yMz"/> + <constraint firstItem="xDu-Qf-38X" firstAttribute="leading" secondItem="6l3-mv-Tbu" secondAttribute="leading" id="NnC-w2-v3s"/> + <constraint firstItem="S9J-0t-GbV" firstAttribute="top" secondItem="6l3-mv-Tbu" secondAttribute="top" id="QNh-aI-AhO"/> + <constraint firstAttribute="trailing" secondItem="xDu-Qf-38X" secondAttribute="trailing" id="SDb-3M-tG7"/> + <constraint firstItem="xDu-Qf-38X" firstAttribute="top" secondItem="S9J-0t-GbV" secondAttribute="bottom" constant="2" id="Y59-os-off"/> + <constraint firstItem="S9J-0t-GbV" firstAttribute="leading" secondItem="6l3-mv-Tbu" secondAttribute="leading" id="eew-mw-osH"/> + <constraint firstAttribute="trailing" secondItem="S9J-0t-GbV" secondAttribute="trailing" id="l2p-jb-Zgl"/> + <constraint firstAttribute="bottom" secondItem="xDu-Qf-38X" secondAttribute="bottom" id="lNZ-1o-IZt"/> </constraints> </view> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="VPZ-yr-cJj"> + <rect key="frame" x="264" y="8" width="56" height="44"/> + <constraints> + <constraint firstAttribute="width" constant="56" id="Smb-26-LSz"/> + </constraints> + <state key="normal" image="icon-post-actionbar-more"> + <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </state> + <connections> + <action selector="onAction:" destination="KGk-i7-Jjw" eventType="touchUpInside" id="asl-aB-XVB"/> + </connections> + </button> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="8cG-SG-iYd" customClass="CachedAnimatedImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="224" y="10" width="40" height="40"/> + <color key="backgroundColor" red="0.84705882352941175" green="0.84705882352941175" blue="0.84705882352941175" alpha="1" colorSpace="custom" customColorSpace="displayP3"/> + <constraints> + <constraint firstAttribute="height" constant="40" id="BMG-CS-ohd"/> + <constraint firstAttribute="width" constant="40" id="QSf-IN-2xw"/> + </constraints> + </imageView> </subviews> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <constraints> - <constraint firstAttribute="trailing" secondItem="6Um-2J-DCT" secondAttribute="trailing" id="0dW-cw-1nD"/> - <constraint firstItem="6Um-2J-DCT" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" id="El0-aj-PpS"/> - <constraint firstAttribute="bottom" secondItem="6Um-2J-DCT" secondAttribute="bottom" id="FJb-cO-dUC"/> - <constraint firstItem="6Um-2J-DCT" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" id="z04-LN-xfz"/> + <constraint firstItem="8cG-SG-iYd" firstAttribute="centerY" secondItem="H2p-sc-9uM" secondAttribute="centerY" id="9Bt-em-j0v"/> + <constraint firstAttribute="bottom" secondItem="6l3-mv-Tbu" secondAttribute="bottom" constant="8" id="DsN-Mu-qqd"/> + <constraint firstAttribute="bottom" secondItem="VPZ-yr-cJj" secondAttribute="bottom" constant="8" id="G9p-Qe-UId"/> + <constraint firstAttribute="trailing" secondItem="VPZ-yr-cJj" secondAttribute="trailing" id="GM1-H8-CAw"/> + <constraint firstItem="VPZ-yr-cJj" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="8" id="Kd0-0B-kMr"/> + <constraint firstItem="VPZ-yr-cJj" firstAttribute="leading" secondItem="8cG-SG-iYd" secondAttribute="trailing" id="Oo4-Rn-cwL"/> + <constraint firstItem="6l3-mv-Tbu" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="16" id="cbd-Rs-BSZ"/> + <constraint firstItem="6l3-mv-Tbu" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="8" id="mKG-Ys-p8a"/> + <constraint firstItem="8cG-SG-iYd" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="6l3-mv-Tbu" secondAttribute="trailing" constant="16" id="sdy-kN-Fk2"/> </constraints> </tableViewCellContentView> <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="trailing" secondItem="H2p-sc-9uM" secondAttribute="trailing" id="Vwm-HE-eyX"/> + <constraint firstItem="H2p-sc-9uM" firstAttribute="leading" secondItem="KGk-i7-Jjw" secondAttribute="leading" id="XPT-Lu-l6E"/> + <constraint firstItem="H2p-sc-9uM" firstAttribute="top" secondItem="KGk-i7-Jjw" secondAttribute="top" id="mFj-3U-Up8"/> + <constraint firstAttribute="bottom" secondItem="H2p-sc-9uM" secondAttribute="bottom" id="tl6-HM-KhD"/> + </constraints> <inset key="separatorInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/> <connections> <outlet property="badgesLabel" destination="s9t-JC-dzu" id="gL0-B9-ehI"/> <outlet property="featuredImageView" destination="8cG-SG-iYd" id="NXo-l4-Mfu"/> - <outlet property="labelsContainerTrailing" destination="TnQ-N0-CaM" id="BbR-qQ-JWD"/> - <outlet property="leadingContentConstraint" destination="El0-aj-PpS" id="LqZ-K0-Tc4"/> <outlet property="menuButton" destination="VPZ-yr-cJj" id="yjZ-Su-mum"/> <outlet property="titleLabel" destination="S9J-0t-GbV" id="yd0-0r-RpF"/> + <outlet property="typeIcon" destination="woP-g8-6RQ" id="VeK-Fz-TtV"/> + <outlet property="typeLabel" destination="s1k-m6-54I" id="sWf-eU-2vR"/> </connections> <point key="canvasLocation" x="137.59999999999999" y="153.82308845577214"/> </tableViewCell> diff --git a/WordPress/Classes/ViewRelated/Pages/PageListTableViewHandler.swift b/WordPress/Classes/ViewRelated/Pages/PageListTableViewHandler.swift index 143532f1c900..c513d64380a7 100644 --- a/WordPress/Classes/ViewRelated/Pages/PageListTableViewHandler.swift +++ b/WordPress/Classes/ViewRelated/Pages/PageListTableViewHandler.swift @@ -71,7 +71,6 @@ final class PageListTableViewHandler: WPTableViewHandler { } } - // MARK: - Public methods func page(at indexPath: IndexPath) -> Page { @@ -101,7 +100,7 @@ final class PageListTableViewHandler: WPTableViewHandler { do { try publishedResultController.performFetch() if let pages = publishedResultController.fetchedObjects as? [Page] { - return pages.hierarchySort() + return pages.setHomePageFirst().hierarchySort() } } catch { DDLogError("Error fetching pages after refreshing the table: \(error)") @@ -164,6 +163,6 @@ final class PageListTableViewHandler: WPTableViewHandler { return [] } - return status == .published ? pages.hierarchySort() : pages + return status == .published ? pages.setHomePageFirst().hierarchySort() : pages } } diff --git a/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift b/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift index 00db6e1ea17d..4ddf43ab351f 100644 --- a/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift +++ b/WordPress/Classes/ViewRelated/Pages/PageListViewController.swift @@ -1,7 +1,8 @@ import Foundation import CocoaLumberjack import WordPressShared - +import WordPressFlux +import UIKit class PageListViewController: AbstractPostListViewController, UIViewControllerRestoration { private struct Constant { @@ -20,6 +21,11 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe static let restorePageCellNibName = "RestorePageTableViewCell" static let currentPageListStatusFilterKey = "CurrentPageListStatusFilterKey" } + + struct Events { + static let source = "page_list" + static let pagePostType = "page" + } } fileprivate lazy var sectionFooterSeparatorView: UIView = { @@ -45,6 +51,16 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe } } + lazy var homepageSettingsService = { + return HomepageSettingsService(blog: blog, coreDataStack: ContextManager.shared) + }() + + private lazy var createButtonCoordinator: CreateButtonCoordinator = { + let action = PageAction(handler: { [weak self] in + self?.createPost() + }, source: Constant.Events.source) + return CreateButtonCoordinator(self, actions: [action], source: Constant.Events.source) + }() // MARK: - GUI @@ -63,9 +79,21 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe controller.blog = blog controller.restorationClass = self + if QuickStartTourGuide.shared.isCurrentElement(.pages) { + controller.filterSettings.setFilterWithPostStatus(BasePost.Status.publish) + } + return controller } + static func showForBlog(_ blog: Blog, from sourceController: UIViewController) { + let controller = PageListViewController.controllerWithBlog(blog) + controller.navigationItem.largeTitleDisplayMode = .never + sourceController.navigationController?.pushViewController(controller, animated: true) + + QuickStartTourGuide.shared.visited(.pages) + } + // MARK: - UIViewControllerRestoration class func viewController(withRestorationIdentifierPath identifierComponents: [String], @@ -109,15 +137,17 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe override func viewDidLoad() { super.viewDidLoad() - if QuickStartTourGuide.find()?.isCurrentElement(.newPage) ?? false { + if QuickStartTourGuide.shared.isCurrentElement(.newPage) { updateFilterWithPostStatus(.publish) } super.updateAndPerformFetchRequest() - title = NSLocalizedString("Site Pages", comment: "Title of the screen showing the list of pages for a blog.") + title = NSLocalizedString("Pages", comment: "Title of the screen showing the list of pages for a blog.") configureFilterBarTopConstraint() + + createButtonCoordinator.add(to: view, trailingAnchor: view.safeAreaLayoutGuide.trailingAnchor, bottomAnchor: view.safeAreaLayoutGuide.bottomAnchor) } override func viewWillAppear(_ animated: Bool) { @@ -127,6 +157,27 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe _tableViewHandler.refreshTableView() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if traitCollection.horizontalSizeClass == .compact { + createButtonCoordinator.showCreateButton(for: blog) + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + QuickStartTourGuide.shared.endCurrentTour() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.horizontalSizeClass == .compact { + createButtonCoordinator.showCreateButton(for: blog) + } else { + createButtonCoordinator.hideCreateButton() + } + } // MARK: - Configuration @@ -156,7 +207,7 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe tableView.tableHeaderView = searchController.searchBar - tableView.scrollIndicatorInsets.top = searchController.searchBar.bounds.height + tableView.verticalScrollIndicatorInsets.top = searchController.searchBar.bounds.height } override func configureAuthorFilter() { @@ -168,6 +219,14 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe tableView.tableFooterView = UIView(frame: .zero) } + fileprivate func beginRefreshingManually() { + guard let refreshControl = refreshControl else { + return + } + + refreshControl.beginRefreshing() + tableView.setContentOffset(CGPoint(x: 0, y: tableView.contentOffset.y - refreshControl.frame.size.height), animated: true) + } // MARK: - Sync Methods @@ -340,10 +399,6 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe return Constant.Size.pageSectionHeaderHeight } - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return Constant.Size.pageCellWithTagEstimatedRowHeight - } - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { guard _tableViewHandler.groupResults else { return UIView(frame: .zero) @@ -364,11 +419,6 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe tableView.deselectRow(at: indexPath, animated: true) let page = pageAtIndexPath(indexPath) - - guard page.status != .trash else { - return - } - editPage(page) } @@ -430,69 +480,57 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe // MARK: - Post Actions override func createPost() { - let context = ContextManager.sharedInstance().mainContext - let postService = PostService(managedObjectContext: context) - let page = postService.createDraftPage(for: blog) - WPAppAnalytics.track(.editorCreatedPost, withProperties: ["tap_source": "posts_view"], with: blog) - showEditor(post: page) + WPAppAnalytics.track(.editorCreatedPost, withProperties: [WPAppAnalyticsKeyTapSource: Constant.Events.source, WPAppAnalyticsKeyPostType: Constant.Events.pagePostType], with: blog) - QuickStartTourGuide.find()?.visited(.newPage) - } - - fileprivate func editPage(_ apost: AbstractPost) { - guard !PostCoordinator.shared.isUploading(post: apost) else { - presentAlertForPageBeingUploaded() - return + PageCoordinator.showLayoutPickerIfNeeded(from: self, forBlog: blog) { [weak self] (selectedLayout) in + self?.createPage(selectedLayout) } - WPAppAnalytics.track(.postListEditAction, withProperties: propertiesForAnalytics(), with: apost) - showEditor(post: apost) - } - - fileprivate func retryPage(_ apost: AbstractPost) { - PostCoordinator.shared.save(apost) } - fileprivate func showEditor(post: AbstractPost) { - let editorFactory = EditorFactory() + private func createPage(_ starterLayout: PageTemplateLayout?) { + let editorViewController = EditPageViewController(blog: blog, postTitle: starterLayout?.title, content: starterLayout?.content, appliedTemplate: starterLayout?.slug) + present(editorViewController, animated: false) - let postViewController = editorFactory.instantiateEditor( - for: post, - replaceEditor: { [weak self] (editor, replacement) in - self?.replaceEditor(editor: editor, replacement: replacement) - }) - - show(postViewController) + QuickStartTourGuide.shared.visited(.newPage) } - private func show(_ editorViewController: EditorViewController) { - editorViewController.onClose = { [weak self, weak editorViewController] _, _ in - self?._tableViewHandler.isSearching = false - editorViewController?.dismiss(animated: true) - } - - let navController = UINavigationController(rootViewController: editorViewController) - navController.restorationIdentifier = Restorer.Identifier.navigationController.rawValue - navController.modalPresentationStyle = .fullScreen - - present(navController, animated: true, completion: nil) + private func blazePage(_ page: AbstractPost) { + BlazeEventsTracker.trackEntryPointTapped(for: .pagesList) + BlazeFlowCoordinator.presentBlaze(in: self, source: .pagesList, blog: blog, post: page) } - func replaceEditor(editor: EditorViewController, replacement: EditorViewController) { - editor.dismiss(animated: true) { [weak self] in - self?.show(replacement) + fileprivate func editPage(_ page: Page) { + let didOpenEditor = PageEditorPresenter.handle(page: page, in: self, entryPoint: .pagesList) + + if didOpenEditor { + WPAppAnalytics.track(.postListEditAction, withProperties: propertiesForAnalytics(), with: page) } } - // MARK: - Alert - - func presentAlertForPageBeingUploaded() { - let message = NSLocalizedString("This page is currently uploading. It won't take long – try again soon and you'll be able to edit it.", comment: "Prompts the user that the page is being uploaded and cannot be edited while that process is ongoing.") + fileprivate func copyPage(_ page: Page) { + // Analytics + WPAnalytics.track(.postListDuplicateAction, withProperties: propertiesForAnalytics()) + // Copy Page + let newPage = page.blog.createDraftPage() + newPage.postTitle = page.postTitle + newPage.content = page.content + // Open Editor + let editorViewController = EditPageViewController(page: newPage) + present(editorViewController, animated: false) + } - let alertCancel = NSLocalizedString("OK", comment: "Title of an OK button. Pressing the button acknowledges and dismisses a prompt.") + fileprivate func copyLink(_ page: Page) { + let pasteboard = UIPasteboard.general + guard let link = page.permaLink else { return } + pasteboard.string = link as String + let noticeTitle = NSLocalizedString("Link Copied to Clipboard", comment: "Link copied to clipboard notice title") + let notice = Notice(title: noticeTitle, feedbackType: .success) + ActionDispatcher.dispatch(NoticeAction.dismiss) // Dismiss any old notices + ActionDispatcher.dispatch(NoticeAction.post(notice)) + } - let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert) - alertController.addCancelActionWithTitle(alertCancel, handler: nil) - alertController.presentFromRootViewController() + fileprivate func retryPage(_ apost: AbstractPost) { + PostCoordinator.shared.save(apost) } fileprivate func draftPage(_ apost: AbstractPost, at indexPath: IndexPath?) { @@ -559,17 +597,8 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe let indexPath = tableView.indexPath(for: cell) let filter = filterSettings.currentPostListFilter().filterType - + let isHomepage = ((page as? Page)?.isSiteHomepage ?? false) if filter == .trashed { - alertController.addActionWithTitle(publishButtonTitle, style: .default, handler: { [weak self] (action) in - guard let strongSelf = self, - let page = strongSelf.pageForObjectID(objectID) else { - return - } - - strongSelf.publishPost(page) - }) - alertController.addActionWithTitle(draftButtonTitle, style: .default, handler: { [weak self] (action) in guard let strongSelf = self, let page = strongSelf.pageForObjectID(objectID) else { @@ -579,13 +608,13 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe strongSelf.draftPage(page, at: indexPath) }) - alertController.addActionWithTitle(deleteButtonTitle, style: .default, handler: { [weak self] (action) in + alertController.addActionWithTitle(deleteButtonTitle, style: .destructive, handler: { [weak self] (action) in guard let strongSelf = self, let page = strongSelf.pageForObjectID(objectID) else { return } - strongSelf.deletePost(page) + strongSelf.handleTrashPage(page) }) } else if filter == .published { if page.isFailed { @@ -598,6 +627,8 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe strongSelf.retryPage(page) }) } else { + addEditAction(to: alertController, for: page) + alertController.addActionWithTitle(viewButtonTitle, style: .default, handler: { [weak self] (action) in guard let strongSelf = self, let page = strongSelf.pageForObjectID(objectID) else { @@ -607,26 +638,36 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe strongSelf.viewPost(page) }) + addBlazeAction(to: alertController, for: page) addSetParentAction(to: alertController, for: page, at: indexPath) - - alertController.addActionWithTitle(draftButtonTitle, style: .default, handler: { [weak self] (action) in - guard let strongSelf = self, - let page = strongSelf.pageForObjectID(objectID) else { + addSetHomepageAction(to: alertController, for: page, at: indexPath) + addSetPostsPageAction(to: alertController, for: page, at: indexPath) + addDuplicateAction(to: alertController, for: page) + + if !isHomepage { + alertController.addActionWithTitle(draftButtonTitle, style: .default, handler: { [weak self] (action) in + guard let strongSelf = self, + let page = strongSelf.pageForObjectID(objectID) else { return - } + } - strongSelf.draftPage(page, at: indexPath) - }) + strongSelf.draftPage(page, at: indexPath) + }) + } } - alertController.addActionWithTitle(trashButtonTitle, style: .default, handler: { [weak self] (action) in - guard let strongSelf = self, - let page = strongSelf.pageForObjectID(objectID) else { + addCopyLinkAction(to: alertController, for: page) + + if !isHomepage { + alertController.addActionWithTitle(trashButtonTitle, style: .destructive, handler: { [weak self] (action) in + guard let strongSelf = self, + let page = strongSelf.pageForObjectID(objectID) else { return - } + } - strongSelf.deletePost(page) - }) + strongSelf.handleTrashPage(page) + }) + } } else { if page.isFailed { alertController.addActionWithTitle(retryButtonTitle, style: .default, handler: { [weak self] (action) in @@ -638,6 +679,8 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe strongSelf.retryPage(page) }) } else { + addEditAction(to: alertController, for: page) + alertController.addActionWithTitle(viewButtonTitle, style: .default, handler: { [weak self] (action) in guard let strongSelf = self, let page = strongSelf.pageForObjectID(objectID) else { @@ -648,6 +691,7 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe }) addSetParentAction(to: alertController, for: page, at: indexPath) + addDuplicateAction(to: alertController, for: page) alertController.addActionWithTitle(publishButtonTitle, style: .default, handler: { [weak self] (action) in guard let strongSelf = self, @@ -659,13 +703,15 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe }) } - alertController.addActionWithTitle(trashButtonTitle, style: .default, handler: { [weak self] (action) in + addCopyLinkAction(to: alertController, for: page) + + alertController.addActionWithTitle(trashButtonTitle, style: .destructive, handler: { [weak self] (action) in guard let strongSelf = self, let page = strongSelf.pageForObjectID(objectID) else { return } - strongSelf.deletePost(page) + strongSelf.handleTrashPage(page) }) } @@ -681,6 +727,60 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe } } + override func deletePost(_ apost: AbstractPost) { + super.deletePost(apost) + } + + private func addBlazeAction(to controller: UIAlertController, for page: AbstractPost) { + guard BlazeHelper.isBlazeFlagEnabled() && page.canBlaze else { + return + } + + let buttonTitle = NSLocalizedString("pages.blaze.actionTitle", value: "Promote with Blaze", comment: "Promote the page with Blaze.") + controller.addActionWithTitle(buttonTitle, style: .default, handler: { [weak self] _ in + self?.blazePage(page) + }) + + BlazeEventsTracker.trackEntryPointDisplayed(for: .pagesList) + } + + private func addEditAction(to controller: UIAlertController, for page: AbstractPost) { + guard let page = page as? Page else { return } + + if page.status == .trash || page.isSitePostsPage { + return + } + + let buttonTitle = NSLocalizedString("Edit", comment: "Label for a button that opens the Edit Page view controller") + controller.addActionWithTitle(buttonTitle, style: .default, handler: { [weak self] _ in + if let page = self?.pageForObjectID(page.objectID) { + self?.editPage(page) + } + }) + } + + private func addDuplicateAction(to controller: UIAlertController, for page: AbstractPost) { + if page.status != .publish && page.status != .draft { + return + } + + let buttonTitle = NSLocalizedString("Duplicate", comment: "Label for page duplicate option. Tapping creates a copy of the page.") + controller.addActionWithTitle(buttonTitle, style: .default, handler: { [weak self] _ in + if let page = self?.pageForObjectID(page.objectID) { + self?.copyPage(page) + } + }) + } + + private func addCopyLinkAction(to controller: UIAlertController, for page: AbstractPost) { + let buttonTitle = NSLocalizedString("Copy Link", comment: "Label for page copy link. Tapping copy the url of page") + controller.addActionWithTitle(buttonTitle, style: .default) { [weak self] _ in + if let page = self?.pageForObjectID(page.objectID) { + self?.copyLink(page) + } + } + } + private func addSetParentAction(to controller: UIAlertController, for page: AbstractPost, at index: IndexPath?) { /// This button is disabled for trashed pages // @@ -705,12 +805,21 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe let selectedPage = pageAtIndexPath(index) let newIndex = _tableViewHandler.index(for: selectedPage) let pages = _tableViewHandler.removePage(from: newIndex) - let parentPageNavigationController = ParentPageSettingsViewController.navigationController(with: pages, selectedPage: selectedPage) { - self._tableViewHandler.isSearching = false - } + let parentPageNavigationController = ParentPageSettingsViewController.navigationController(with: pages, selectedPage: selectedPage, onClose: { [weak self] in + self?._tableViewHandler.isSearching = false + self?._tableViewHandler.refreshTableView(at: index) + }, onSuccess: { [weak self] in + self?.handleSetParentSuccess() + } ) present(parentPageNavigationController, animated: true) } + private func handleSetParentSuccess() { + let setParentSuccefullyNotice = NSLocalizedString("Parent page successfully updated.", comment: "Message informing the user that their pages parent has been set successfully") + let notice = Notice(title: setParentSuccefullyNotice, feedbackType: .success) + ActionDispatcher.global.dispatch(NoticeAction.post(notice)) + } + fileprivate func pageForObjectID(_ objectID: NSManagedObjectID) -> Page? { var pageManagedOjbect: NSManagedObject @@ -736,6 +845,118 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe } } + private func addSetHomepageAction(to controller: UIAlertController, for page: AbstractPost, at index: IndexPath?) { + let objectID = page.objectID + + /// This button is enabled if + /// - Page is not trashed + /// - The site's homepage type is .page + /// - The page isn't currently the homepage + // + guard page.status != .trash, + let homepageType = blog.homepageType, + homepageType == .page, + let page = pageForObjectID(objectID), + page.isSiteHomepage == false else { + return + } + + let setHomepageButtonTitle = NSLocalizedString("Set as Homepage", comment: "Label for a button that sets the selected page as the site's Homepage") + 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() + self?.handleHomepageSettingsSuccess() + }, failure: { error in + self?.refreshControl?.endRefreshing() + self?.handleHomepageSettingsFailure() + }) + } + }) + } + + private func addSetPostsPageAction(to controller: UIAlertController, for page: AbstractPost, at index: IndexPath?) { + let objectID = page.objectID + + /// This button is enabled if + /// - Page is not trashed + /// - The site's homepage type is .page + /// - The page isn't currently the posts page + // + guard page.status != .trash, + let homepageType = blog.homepageType, + homepageType == .page, + let page = pageForObjectID(objectID), + page.isSitePostsPage == false else { + return + } + + let setPostsPageButtonTitle = NSLocalizedString("Set as Posts Page", comment: "Label for a button that sets the selected page as the site's Posts page") + 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() + self?.handleHomepagePostsPageSettingsSuccess() + }, failure: { error in + self?.refreshControl?.endRefreshing() + self?.handleHomepageSettingsFailure() + }) + } + }) + } + + private func handleHomepageSettingsSuccess() { + let notice = Notice(title: HomepageSettingsText.updateHomepageSuccessTitle, feedbackType: .success) + ActionDispatcher.global.dispatch(NoticeAction.post(notice)) + } + + private func handleHomepagePostsPageSettingsSuccess() { + let notice = Notice(title: HomepageSettingsText.updatePostsPageSuccessTitle, feedbackType: .success) + ActionDispatcher.global.dispatch(NoticeAction.post(notice)) + } + + private func handleHomepageSettingsFailure() { + let notice = Notice(title: HomepageSettingsText.updateErrorTitle, message: HomepageSettingsText.updateErrorMessage, feedbackType: .error) + ActionDispatcher.global.dispatch(NoticeAction.post(notice)) + } + + private func handleTrashPage(_ post: AbstractPost) { + guard ReachabilityUtils.isInternetReachable() else { + let offlineMessage = NSLocalizedString("Unable to trash pages while offline. Please try again later.", comment: "Message that appears when a user tries to trash a page while their device is offline.") + ReachabilityUtils.showNoInternetConnectionNotice(message: offlineMessage) + return + } + + let cancelText = NSLocalizedString("Cancel", comment: "Cancels an Action") + let deleteText: String + let messageText: String + let titleText: String + + if post.status == .trash { + deleteText = NSLocalizedString("Delete Permanently", comment: "Delete option in the confirmation alert when deleting a page from the trash.") + titleText = NSLocalizedString("Delete Permanently?", comment: "Title of the confirmation alert when deleting a page from the trash.") + messageText = NSLocalizedString("Are you sure you want to permanently delete this page?", comment: "Message of the confirmation alert when deleting a page from the trash.") + } else { + deleteText = NSLocalizedString("Move to Trash", comment: "Trash option in the trash page confirmation alert.") + titleText = NSLocalizedString("Trash this page?", comment: "Title of the trash page confirmation alert.") + messageText = NSLocalizedString("Are you sure you want to trash this page?", comment: "Message of the trash page confirmation alert.") + } + + let alertController = UIAlertController(title: titleText, message: messageText, preferredStyle: .alert) + + alertController.addCancelActionWithTitle(cancelText) + alertController.addDestructiveActionWithTitle(deleteText) { [weak self] action in + self?.deletePost(post) + } + alertController.presentFromRootViewController() + } + // MARK: - UISearchControllerDelegate override func willPresentSearchController(_ searchController: UISearchController) { @@ -757,7 +978,7 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe } func didPresentSearchController(_ searchController: UISearchController) { - tableView.scrollIndicatorInsets.top = searchController.searchBar.bounds.height + searchController.searchBar.frame.origin.y - view.safeAreaInsets.top + tableView.verticalScrollIndicatorInsets.top = searchController.searchBar.bounds.height + searchController.searchBar.frame.origin.y - view.safeAreaInsets.top } func didDismissSearchController(_ searchController: UISearchController) { @@ -778,6 +999,13 @@ class PageListViewController: AbstractPostListViewController, UIViewControllerRe return NSLocalizedString("No internet connection. Some pages may be unavailable while offline.", comment: "Error message shown when the user is browsing Site Pages without an internet connection.") } + + struct HomepageSettingsText { + static let updateErrorTitle = NSLocalizedString("Unable to update homepage settings", comment: "Error informing the user that their homepage settings could not be updated") + static let updateErrorMessage = NSLocalizedString("Please try again later.", comment: "Prompt for the user to retry a failed action again later") + static let updateHomepageSuccessTitle = NSLocalizedString("Homepage successfully updated", comment: "Message informing the user that their static homepage page was set successfully") + static let updatePostsPageSuccessTitle = NSLocalizedString("Posts page successfully updated", comment: "Message informing the user that their static homepage for posts was set successfully") + } } // MARK: - No Results Handling @@ -843,6 +1071,8 @@ private extension PageListViewController { return NoResultsText.noTrashedTitle case .published: return NoResultsText.noPublishedTitle + case .allNonTrashed: + return "" } } diff --git a/WordPress/Classes/ViewRelated/Pages/ParentPageSettingsViewController.swift b/WordPress/Classes/ViewRelated/Pages/ParentPageSettingsViewController.swift index cfa06921092a..cc915a2751c4 100644 --- a/WordPress/Classes/ViewRelated/Pages/ParentPageSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Pages/ParentPageSettingsViewController.swift @@ -15,7 +15,7 @@ private struct Row: ImmuTableRow { var title: String { switch type { case .topLevel: return NSLocalizedString("Top level", comment: "Cell title for the Top Level option case") - case .child: return page?.postTitle ?? "" + case .child: return page?.titleForDisplay() ?? "" } } @@ -43,6 +43,7 @@ extension Row: Equatable { class ParentPageSettingsViewController: UIViewController { var onClose: (() -> Void)? + var onSuccess: (() -> Void)? @IBOutlet private var cancelButton: UIBarButtonItem! @IBOutlet private var doneButton: UIBarButtonItem! @@ -245,6 +246,7 @@ class ParentPageSettingsViewController: UIViewController { self?.selectedPage.parentID = parentId } else { self?.dismiss() + self?.onSuccess?() } } } @@ -304,7 +306,7 @@ extension ParentPageSettingsViewController: UITableViewDelegate { /// ParentPageSettingsViewController class constructor // extension ParentPageSettingsViewController { - class func navigationController(with pages: [Page], selectedPage: Page, onClose: (() -> Void)? = nil) -> UINavigationController { + class func navigationController(with pages: [Page], selectedPage: Page, onClose: (() -> Void)? = nil, onSuccess: (() -> Void)? = nil) -> UINavigationController { let storyBoard = UIStoryboard(name: "Pages", bundle: Bundle.main) guard let controller = storyBoard.instantiateViewController(withIdentifier: "ParentPageSettings") as? UINavigationController else { fatalError("A navigation view controller is required for Parent Page Settings") @@ -314,6 +316,7 @@ extension ParentPageSettingsViewController { } parentPageSettingsViewController.set(pages: pages, for: selectedPage) parentPageSettingsViewController.onClose = onClose + parentPageSettingsViewController.onSuccess = onSuccess return controller } } diff --git a/WordPress/Classes/ViewRelated/Pages/RestorePageTableViewCell.m b/WordPress/Classes/ViewRelated/Pages/RestorePageTableViewCell.m index c80cd9673ebe..c05e0d4ce98c 100644 --- a/WordPress/Classes/ViewRelated/Pages/RestorePageTableViewCell.m +++ b/WordPress/Classes/ViewRelated/Pages/RestorePageTableViewCell.m @@ -1,6 +1,8 @@ #import "RestorePageTableViewCell.h" #import "WPStyleGuide+Pages.h" +@import Gridicons; + @interface RestorePageTableViewCell() @property (nonatomic, strong) IBOutlet UILabel *restoreLabel; @@ -32,6 +34,9 @@ - (void)configureView self.restoreLabel.text = NSLocalizedString(@"Page moved to trash.", @"A short message explaining that a page was moved to the trash bin."); NSString *buttonTitle = NSLocalizedString(@"Undo", @"The title of an 'undo' button. Tapping the button moves a trashed page out of the trash folder."); [self.restoreButton setTitle:buttonTitle forState:UIControlStateNormal]; + [self.restoreButton setImage:[UIImage gridiconOfType:GridiconTypeUndo + withSize:CGSizeMake(18.0, 18.0)] + forState:UIControlStateNormal]; } @end diff --git a/WordPress/Classes/ViewRelated/Pages/RestorePageTableViewCell.xib b/WordPress/Classes/ViewRelated/Pages/RestorePageTableViewCell.xib index 47d496811d34..2904556a70cb 100644 --- a/WordPress/Classes/ViewRelated/Pages/RestorePageTableViewCell.xib +++ b/WordPress/Classes/ViewRelated/Pages/RestorePageTableViewCell.xib @@ -1,8 +1,10 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="10117" systemVersion="15G31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES"> +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> <dependencies> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/> - <capability name="Constraints to layout margins" minToolsVersion="6.0"/> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> @@ -11,19 +13,19 @@ <rect key="frame" x="0.0" y="0.0" width="320" height="47"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="AgI-Sn-JeL" id="QqJ-JC-nik"> - <rect key="frame" x="0.0" y="0.0" width="320" height="46.5"/> + <rect key="frame" x="0.0" y="0.0" width="320" height="47"/> <autoresizingMask key="autoresizingMask"/> <subviews> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" text="Page moved to trash." lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="au3-8f-nAh"> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" misplaced="YES" text="Page moved to trash." lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="au3-8f-nAh"> <rect key="frame" x="15" y="10" width="219" height="26"/> <constraints> <constraint firstAttribute="height" constant="26" id="qR0-Nb-d3i"/> </constraints> <fontDescription key="fontDescription" type="system" pointSize="14"/> - <color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="calibratedWhite"/> + <color key="textColor" red="0.3333333432674408" green="0.3333333432674408" blue="0.3333333432674408" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <nil key="highlightedColor"/> </label> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" reversesTitleShadowWhenHighlighted="YES" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="dPd-xc-GkE"> + <button opaque="NO" contentMode="scaleToFill" misplaced="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" reversesTitleShadowWhenHighlighted="YES" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="dPd-xc-GkE"> <rect key="frame" x="234" y="10" width="86" height="26"/> <constraints> <constraint firstAttribute="width" constant="86" id="E4N-1W-JWu"/> @@ -31,16 +33,16 @@ <fontDescription key="fontDescription" type="system" pointSize="14"/> <inset key="contentEdgeInsets" minX="3" minY="0.0" maxX="0.0" maxY="0.0"/> <inset key="imageEdgeInsets" minX="-3" minY="0.0" maxX="3" maxY="0.0"/> - <state key="normal" title="Undo" image="icon-post-undo"> - <color key="titleColor" white="0.33333333333333331" alpha="1" colorSpace="calibratedWhite"/> - <color key="titleShadowColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/> + <state key="normal" title="Undo"> + <color key="titleColor" red="0.3333333432674408" green="0.3333333432674408" blue="0.3333333432674408" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <color key="titleShadowColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> </state> <connections> <action selector="onAction:" destination="AgI-Sn-JeL" eventType="touchUpInside" id="4Wu-zq-FWn"/> </connections> </button> </subviews> - <color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/> + <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <constraints> <constraint firstAttribute="bottomMargin" secondItem="au3-8f-nAh" secondAttribute="bottom" id="89D-jc-v4X"/> <constraint firstItem="au3-8f-nAh" firstAttribute="trailing" secondItem="dPd-xc-GkE" secondAttribute="leading" id="AiD-9Q-fgp"/> @@ -59,7 +61,4 @@ <point key="canvasLocation" x="568" y="209.5"/> </tableViewCell> </objects> - <resources> - <image name="icon-post-undo" width="18" height="18"/> - </resources> </document> diff --git a/WordPress/Classes/ViewRelated/People/InvitePersonViewController.swift b/WordPress/Classes/ViewRelated/People/InvitePersonViewController.swift index a9aecfb5ac62..d833047689d1 100644 --- a/WordPress/Classes/ViewRelated/People/InvitePersonViewController.swift +++ b/WordPress/Classes/ViewRelated/People/InvitePersonViewController.swift @@ -56,7 +56,7 @@ class InvitePersonViewController: UITableViewController { let blogRoles = blog?.sortedRoles ?? [] var roles = [RemoteRole]() let inviteRole: RemoteRole - if blog.isPrivate() { + if blog.isPrivateAtWPCom() { inviteRole = RemoteRole.viewer } else { inviteRole = RemoteRole.follower @@ -66,16 +66,61 @@ class InvitePersonViewController: UITableViewController { return roles } - /// Last Section Index - /// - fileprivate var lastSectionIndex: Int { - return tableView.numberOfSections - 1 + private lazy var inviteActivityView: UIActivityIndicatorView = { + let activityView = UIActivityIndicatorView(style: .medium) + activityView.startAnimating() + return activityView + }() + + private var updatingInviteLinks = false { + didSet { + guard updatingInviteLinks != oldValue else { + return + } + + if updatingInviteLinks == false { + generateShareCell.accessoryView = nil + disableLinksCell.accessoryView = nil + return + } + + if blog.inviteLinks?.count == 0 { + generateShareCell.accessoryView = inviteActivityView + } else { + disableLinksCell.accessoryView = inviteActivityView + } + } } - /// Last Section Footer Text - /// - fileprivate let lastSectionFooterText = NSLocalizedString("Add a custom message (optional).", comment: "Invite Footer Text") + private var sortedInviteLinks: [InviteLinks] { + guard + let links = blog.inviteLinks?.array as? [InviteLinks] + else { + return [] + } + return availableRoles.compactMap { role -> InviteLinks? in + return links.first { link -> Bool in + link.role == role.slug + } + } + } + + private var selectedInviteLinkIndex = 0 { + didSet { + tableView.reloadData() + } + } + + private var currentInviteLink: InviteLinks? { + let links = sortedInviteLinks + guard links.count > 0 && selectedInviteLinkIndex < links.count else { + return nil + } + return links[selectedInviteLinkIndex] + } + private let rolesDefinitionUrl = "https://wordpress.com/support/user-roles/" + private let messageCharacterLimit = 500 // MARK: - Outlets @@ -97,16 +142,46 @@ class InvitePersonViewController: UITableViewController { } } + /// Message Placeholder Label + /// + @IBOutlet private var placeholderLabel: UILabel! { + didSet { + setupPlaceholderLabel() + } + } + /// Message Cell /// - @IBOutlet fileprivate var messageTextView: UITextView! { + @IBOutlet private var messageTextView: UITextView! { didSet { setupMessageTextView() refreshMessageTextView() } } + @IBOutlet private var generateShareCell: UITableViewCell! { + didSet { + refreshGenerateShareCell() + } + } + @IBOutlet private var currentInviteCell: UITableViewCell! { + didSet { + refreshCurrentInviteCell() + } + } + + @IBOutlet private var expirationCell: UITableViewCell! { + didSet { + refreshExpirationCell() + } + } + + @IBOutlet private var disableLinksCell: UITableViewCell! { + didSet { + refreshDisableLinkCell() + } + } // MARK: - View Lifecyle Methods @@ -116,6 +191,12 @@ class InvitePersonViewController: UITableViewController { setupDefaultRole() WPStyleGuide.configureColors(view: view, tableView: tableView) WPStyleGuide.configureAutomaticHeightRows(for: tableView) + // Use the system separator color rather than the one defined by WPStyleGuide + // so cell separators stand out in darkmode. + tableView.separatorColor = .separator + if blog.isWPForTeams() { + syncInviteLinks() + } } override func viewWillAppear(_ animated: Bool) { @@ -126,27 +207,76 @@ class InvitePersonViewController: UITableViewController { // MARK: - UITableView Methods - override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { - guard section == lastSectionIndex else { + override func numberOfSections(in tableView: UITableView) -> Int { + // Hide the last section if the site is not a p2. + let count = super.numberOfSections(in: tableView) + return blog.isWPForTeams() ? count : count - 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard + blog.isWPForTeams(), + section == numberOfSections(in: tableView) - 1 + else { + // If not a P2 or not the last section, just call super. + return super.tableView(tableView, numberOfRowsInSection: section) + } + // One cell for no cached inviteLinks. Otherwise 4. + return blog.inviteLinks?.count == 0 ? 1 : 4 + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + guard Section.inviteLink == Section(rawValue: section) else { return nil } - return lastSectionFooterText + return NSLocalizedString("Invite Link", comment: "Title for the Invite Link section of the Invite Person screen.") + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + let sectionType = Section(rawValue: section) + var footerText = sectionType?.footerText + + if sectionType == .message, + let footerFormat = footerText { + footerText = String(format: footerFormat, messageCharacterLimit) + } + + return footerText } override func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) { + addTapGesture(toView: view, inSection: section) WPStyleGuide.configureTableViewSectionFooter(view) } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { // Workaround for UIKit issue where labels text are set to nil // when user changes system font size in static tables (dynamic type) - setupRoleCell() - refreshRoleCell() - refreshUsernameCell() - refreshMessageTextView() + switch Section(rawValue: indexPath.section) { + case .username: + refreshUsernameCell() + case .role: + setupRoleCell() + refreshRoleCell() + case .message: + refreshMessageTextView() + case .inviteLink: + refreshInviteLinkCell(indexPath: indexPath) + case .none: + break + } + return super.tableView(tableView, cellForRowAt: indexPath) } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.section == Section.inviteLink.rawValue else { + // There is no valid `super` implementation so do not call it. + return + } + tableView.deselectRow(at: indexPath, animated: true) + handleInviteLinkRowTapped(indexPath: indexPath) + } // MARK: - Storyboard Methods @@ -162,6 +292,8 @@ class InvitePersonViewController: UITableViewController { setupRoleSegue(segue) case .Message: setupMessageSegue(segue) + case .InviteRole: + setupInviteRoleSegue(segue) } } @@ -171,7 +303,7 @@ class InvitePersonViewController: UITableViewController { } let title = NSLocalizedString("Recipient", comment: "Invite Person: Email or Username Edition Title") - let placeholder = NSLocalizedString("Email or Username...", comment: "A placeholder for the username textfield.") + let placeholder = NSLocalizedString("Email or Username…", comment: "A placeholder for the username textfield.") let hint = NSLocalizedString("Email or Username of the person that should receive your invitation.", comment: "Username Placeholder") textViewController.title = title @@ -206,25 +338,73 @@ class InvitePersonViewController: UITableViewController { } let title = NSLocalizedString("Message", comment: "Invite Message Editor's Title") - let hint = NSLocalizedString("Optional message to be included in the invitation.", comment: "Invite: Message Hint") + let hintFormat = NSLocalizedString("Optional message up to %1$d characters to be included in the invitation.", comment: "Invite: Message Hint. %1$d is the maximum number of characters allowed.") + let hint = String(format: hintFormat, messageCharacterLimit) textViewController.title = title textViewController.text = message textViewController.hint = hint textViewController.isPassword = false + textViewController.maxCharacterCount = messageCharacterLimit textViewController.onValueChanged = { [unowned self] value in self.message = value } } + private func setupInviteRoleSegue(_ segue: UIStoryboardSegue) { + guard let roleViewController = segue.destination as? RoleViewController else { + return + } + + roleViewController.roles = availableRoles + roleViewController.selectedRole = currentInviteLink?.role + roleViewController.onChange = { [unowned self] newRole in + self.selectedInviteLinkIndex = self.availableRoles.firstIndex(where: { $0.slug == newRole }) ?? 0 + } + } + // MARK: - Private Enums - fileprivate enum SegueIdentifier: String { + private enum SegueIdentifier: String { case Username = "username" case Role = "role" case Message = "message" + case InviteRole = "inviteRole" + } + + // The case order matches the custom sections order in People.storyboard. + private enum Section: Int { + case username + case role + case message + case inviteLink + + var footerText: String? { + switch self { + case .role: + return NSLocalizedString("Learn more about roles", comment: "Footer text for Invite People role field.") + case .message: + // messageCharacterLimit cannot be accessed here, so the caller will insert it in the string. + return NSLocalizedString("Optional: Enter a custom message up to %1$d characters to be sent with your invitation.", comment: "Footer text for Invite People message field. %1$d is the maximum number of characters allowed.") + case .inviteLink: + return NSLocalizedString("invite_people_invite_link_footer", + value: "Use this link to onboard your team members without having to invite them one by one. Anybody visiting this URL will be able to sign up to your organization, even if they received the link from somebody else, so make sure that you share it with trusted people.", + comment: "Footer text for Invite Links section of the Invite People screen.") + default: + return nil + } + } } + + // These represent the rows of the invite links section, in the order the rows appear. + private enum InviteLinkRow: Int { + case generateShare + case role + case expires + case disable + } + } @@ -257,7 +437,7 @@ extension InvitePersonViewController { } @objc func sendInvitation(_ blog: Blog, recipient: String, role: String, message: String) { - guard let service = PeopleService(blog: blog, context: context) else { + guard let service = PeopleService(blog: blog, coreDataStack: ContextManager.shared) else { return } @@ -265,6 +445,7 @@ extension InvitePersonViewController { let success = NSLocalizedString("Invitation Sent!", comment: "The app successfully sent an invitation") SVProgressHUD.showDismissibleSuccess(withStatus: success) + WPAnalytics.track(.peopleUserInvited, properties: ["role": role], blog: blog) }, failure: { error in self.handleSendError() { self.sendInvitation(blog, recipient: recipient, role: role, message: message) @@ -287,6 +468,33 @@ extension InvitePersonViewController { // Note: This viewController might not be visible anymore alertController.presentFromRootViewController() } + + private func addTapGesture(toView footerView: UIView, inSection section: Int) { + guard let footer = footerView as? UITableViewHeaderFooterView else { + return + } + guard Section(rawValue: section) == .role else { + footer.textLabel?.isUserInteractionEnabled = false + footer.accessibilityTraits = .staticText + footer.gestureRecognizers?.removeAll() + return + } + + footer.textLabel?.isUserInteractionEnabled = true + footer.accessibilityTraits = .link + let tap = UITapGestureRecognizer(target: self, action: #selector(handleRoleFooterTap(_:))) + footer.addGestureRecognizer(tap) + } + + @objc private func handleRoleFooterTap(_ sender: UITapGestureRecognizer) { + guard let url = URL(string: rolesDefinitionUrl) else { + return + } + + let webViewController = WebViewControllerFactory.controller(url: url, source: "invite_person_role_learn_more") + let navController = UINavigationController(rootViewController: webViewController) + present(navController, animated: true) + } } @@ -295,7 +503,7 @@ extension InvitePersonViewController { private extension InvitePersonViewController { func validateInvitation() { - guard let usernameOrEmail = usernameOrEmail, let service = PeopleService(blog: blog, context: context) else { + guard let usernameOrEmail = usernameOrEmail, let service = PeopleService(blog: blog, coreDataStack: ContextManager.shared) else { sendActionEnabled = false return } @@ -360,6 +568,227 @@ private extension InvitePersonViewController { } } +// MARK: - Invite Links related. +// +private extension InvitePersonViewController { + + func refreshInviteLinkCell(indexPath: IndexPath) { + guard let row = InviteLinkRow(rawValue: indexPath.row) else { + return + } + switch row { + case .generateShare: + refreshGenerateShareCell() + case .role: + refreshCurrentInviteCell() + case .expires: + refreshExpirationCell() + case .disable: + refreshDisableLinkCell() + } + } + + func refreshGenerateShareCell() { + if blog.inviteLinks?.count == 0 { + generateShareCell.textLabel?.text = NSLocalizedString("Generate new link", comment: "Title. A call to action to generate a new invite link.") + generateShareCell.textLabel?.font = WPStyleGuide.tableviewTextFont() + } else { + generateShareCell.textLabel?.attributedText = createAttributedShareInviteText() + } + generateShareCell.textLabel?.font = WPStyleGuide.tableviewTextFont() + generateShareCell.textLabel?.textAlignment = .center + generateShareCell.textLabel?.textColor = .primary + } + + func createAttributedShareInviteText() -> NSAttributedString { + let pStyle = NSMutableParagraphStyle() + pStyle.alignment = .center + let font = WPStyleGuide.tableviewTextFont() + let textAttributes: [NSAttributedString.Key: Any] = [ + .font: font, + .paragraphStyle: pStyle + ] + + let image = UIImage.gridicon(.shareiOS) + let attachment = NSTextAttachment(image: image) + attachment.bounds = CGRect(x: 0, + y: (font.capHeight - image.size.height)/2, + width: image.size.width, + height: image.size.height) + let textStr = NSAttributedString(string: NSLocalizedString("Share invite link", comment: "Title. A call to action to share an invite link."), attributes: textAttributes) + let attrStr = NSMutableAttributedString(attachment: attachment) + attrStr.append(NSAttributedString(string: " ")) + attrStr.append(textStr) + return attrStr + } + + func refreshCurrentInviteCell() { + guard selectedInviteLinkIndex < availableRoles.count else { + return + } + + currentInviteCell.textLabel?.text = NSLocalizedString("Role", comment: "Title. Indicates the user role an invite link is for.") + currentInviteCell.textLabel?.textColor = .text + + // sortedInviteLinks and availableRoles should be complimentary. We can cheat a little and + // get the localized "display name" to use from availableRoles rather than + // trying to capitalize the role slug from the current invite link. + let role = availableRoles[selectedInviteLinkIndex] + currentInviteCell.detailTextLabel?.text = role.name + + WPStyleGuide.configureTableViewCell(currentInviteCell) + } + + func refreshExpirationCell() { + guard + let invite = currentInviteLink, + invite.expiry > 0 + else { + return + } + + expirationCell.textLabel?.text = NSLocalizedString("Expires on", comment: "Title. Indicates an expiration date.") + expirationCell.textLabel?.textColor = .text + + let formatter = DateFormatter() + formatter.dateStyle = .medium + let date = Date(timeIntervalSince1970: Double(invite.expiry)) + expirationCell.detailTextLabel?.text = formatter.string(from: date) + + WPStyleGuide.configureTableViewCell(expirationCell) + } + + func refreshDisableLinkCell() { + disableLinksCell.textLabel?.text = NSLocalizedString("Disable invite link", comment: "Title. A call to action to disable invite links.") + disableLinksCell.textLabel?.font = WPStyleGuide.tableviewTextFont() + disableLinksCell.textLabel?.textColor = .error + disableLinksCell.textLabel?.textAlignment = .center + } + + func syncInviteLinks() { + guard let siteID = blog.dotComID?.intValue else { + return + } + let service = PeopleService(blog: blog, coreDataStack: ContextManager.shared) + service?.fetchInviteLinks(siteID, success: { [weak self] _ in + self?.bumpStat(event: .inviteLinksGetStatus, error: nil) + self?.updatingInviteLinks = false + self?.tableView.reloadData() + }, failure: { [weak self] error in + // Fail silently. + self?.bumpStat(event: .inviteLinksGetStatus, error: error) + self?.updatingInviteLinks = false + DDLogError("Error syncing invite links. \(error)") + }) + } + + func generateInviteLinks() { + guard + updatingInviteLinks == false, + let siteID = blog.dotComID?.intValue + else { + return + } + updatingInviteLinks = true + let service = PeopleService(blog: blog, coreDataStack: ContextManager.shared) + service?.generateInviteLinks(siteID, success: { [weak self] _ in + self?.bumpStat(event: .inviteLinksGenerate, error: nil) + self?.updatingInviteLinks = false + self?.tableView.reloadData() + }, failure: { [weak self] error in + self?.bumpStat(event: .inviteLinksGenerate, error: error) + self?.updatingInviteLinks = false + self?.displayNotice(title: NSLocalizedString("Unable to create new invite links.", comment: "An error message shown when there is an issue creating new invite links.")) + DDLogError("Error generating invite links. \(error)") + }) + } + + func shareInviteLink() { + guard + let link = currentInviteLink?.link, + let url = URL(string: link) as NSURL? + else { + return + } + + let controller = PostSharingController() + controller.shareURL(url: url, fromRect: generateShareCell.frame, inView: view, inViewController: self) + bumpStat(event: .inviteLinksShare, error: nil) + } + + func handleDisableTapped() { + guard updatingInviteLinks == false else { + return + } + + let title = NSLocalizedString("Disable invite link", comment: "Title. Title of a prompt to disable group invite links.") + let message = NSLocalizedString("Once this invite link is disabled, nobody will be able to use it to join your team. Are you sure?", comment: "Warning message about disabling group invite links.") + let controller = UIAlertController(title: title, message: message, preferredStyle: .alert) + controller.addCancelActionWithTitle(NSLocalizedString("Cancel", comment: "Title. Title of a cancel button. Tapping disnisses an alert.")) + let action = UIAlertAction(title: NSLocalizedString("Disable", comment: "Title. Title of a button that will disable group invite links when tapped."), + style: .destructive) { [weak self] _ in + self?.disableInviteLinks() + } + controller.addAction(action) + controller.preferredAction = action + present(controller, animated: true, completion: nil) + } + + func disableInviteLinks() { + guard let siteID = blog.dotComID?.intValue else { + return + } + updatingInviteLinks = true + let service = PeopleService(blog: blog, coreDataStack: ContextManager.shared) + service?.disableInviteLinks(siteID, success: { [weak self] in + self?.bumpStat(event: .inviteLinksDisable, error: nil) + self?.updatingInviteLinks = false + self?.tableView.reloadData() + }, failure: { [weak self] error in + self?.bumpStat(event: .inviteLinksDisable, error: error) + self?.updatingInviteLinks = false + self?.displayNotice(title: NSLocalizedString("Unable to disable invite links.", comment: "An error message shown when there is an issue creating new invite links.")) + DDLogError("Error disabling invite links. \(error)") + }) + } + + func handleInviteLinkRowTapped(indexPath: IndexPath) { + guard let row = InviteLinkRow(rawValue: indexPath.row) else { + return + } + switch row { + case .generateShare: + if blog.inviteLinks?.count == 0 { + generateInviteLinks() + } else { + shareInviteLink() + } + case .disable: + handleDisableTapped() + default: + // .role is handled by a segue. + // .expires is a no op + break + } + } + + func bumpStat(event: WPAnalyticsEvent, error: Error?) { + let resultKey = "invite_links_action_result" + let errorKey = "invite_links_action_error_message" + var props = [AnyHashable: Any]() + if let err = error { + props = [ + resultKey: "error", + errorKey: "\(err)" + ] + } else { + props = [ + resultKey: "success" + ] + } + WPAnalytics.track(event, properties: props, blog: blog) + } +} // MARK: - Private Helpers: Initializing Interface // @@ -381,10 +810,17 @@ private extension InvitePersonViewController { messageTextView.font = WPStyleGuide.tableviewTextFont() messageTextView.textColor = .text messageTextView.backgroundColor = .listForeground + messageTextView.delegate = self + } + + func setupPlaceholderLabel() { + placeholderLabel.text = NSLocalizedString("Custom message…", comment: "Placeholder for Invite People message field.") + placeholderLabel.font = WPStyleGuide.tableviewTextFont() + placeholderLabel.textColor = UIColor.textPlaceholder } func setupNavigationBar() { - title = NSLocalizedString("Add a Person", comment: "Invite People Title") + title = NSLocalizedString("Invite People", comment: "Invite People Title") navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, @@ -415,7 +851,7 @@ private extension InvitePersonViewController { func refreshUsernameCell() { guard let usernameOrEmail = usernameOrEmail?.nonEmptyString() else { - usernameCell.textLabel?.text = NSLocalizedString("Email or Username...", comment: "Invite Username Placeholder") + usernameCell.textLabel?.text = NSLocalizedString("Email or Username…", comment: "Invite Username Placeholder") usernameCell.textLabel?.textColor = .textPlaceholder return } @@ -430,5 +866,22 @@ private extension InvitePersonViewController { func refreshMessageTextView() { messageTextView.text = message + refreshPlaceholderLabel() + } + + func refreshPlaceholderLabel() { + placeholderLabel?.isHidden = !messageTextView.text.isEmpty } } + +// MARK: - UITextViewDelegate + +extension InvitePersonViewController: UITextViewDelegate { + func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { + // This calls the segue in People.storyboard + // that shows the SettingsMultiTextViewController. + performSegue(withIdentifier: "message", sender: nil) + return false + } + +} diff --git a/WordPress/Classes/ViewRelated/People/People.storyboard b/WordPress/Classes/ViewRelated/People/People.storyboard index be0be4470acb..c6020e76d931 100644 --- a/WordPress/Classes/ViewRelated/People/People.storyboard +++ b/WordPress/Classes/ViewRelated/People/People.storyboard @@ -1,9 +1,10 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14865.1" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="5ll-RY-leg"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="19455" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="5ll-RY-leg"> <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14819.2"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19454"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <scenes> @@ -16,7 +17,7 @@ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <color key="backgroundColor" red="0.93725490199999995" green="0.93725490199999995" blue="0.95686274510000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <view key="tableFooterView" contentMode="scaleToFill" id="1wT-dA-R90" userLabel="FooterView"> - <rect key="frame" x="0.0" y="142.5" width="375" height="30"/> + <rect key="frame" x="0.0" y="136.5" width="375" height="30"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <subviews> <activityIndicatorView hidden="YES" opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" style="gray" translatesAutoresizingMaskIntoConstraints="NO" id="L1Z-xm-37g"> @@ -31,10 +32,10 @@ </view> <prototypes> <tableViewCell contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="PeopleCell" rowHeight="86" id="Wdc-br-fiq" customClass="PeopleCell" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="55.5" width="375" height="86"/> + <rect key="frame" x="0.0" y="49.5" width="375" height="86"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="Wdc-br-fiq" id="enq-cQ-RBD"> - <rect key="frame" x="0.0" y="0.0" width="347.5" height="86"/> + <rect key="frame" x="0.0" y="0.0" width="349.5" height="86"/> <autoresizingMask key="autoresizingMask"/> <subviews> <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar" translatesAutoresizingMaskIntoConstraints="NO" id="8zx-wm-uHU" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> @@ -44,79 +45,89 @@ <constraint firstAttribute="width" constant="56" id="W04-cD-T1Y"/> </constraints> </imageView> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Jorge Bernal" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="8Vt-sK-TAK"> - <rect key="frame" x="87" y="20" width="252.5" height="17"/> - <fontDescription key="fontDescription" type="boldSystem" pointSize="14"/> - <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="@koke" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1F5-Qy-cZx"> - <rect key="frame" x="87" y="35.5" width="252.5" height="13.5"/> - <fontDescription key="fontDescription" type="system" pointSize="11"/> - <color key="textColor" red="0.0" green="0.66666666669999997" blue="0.86274509799999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <nil key="highlightedColor"/> - </label> - <stackView opaque="NO" contentMode="scaleToFill" distribution="fillProportionally" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="Obq-4m-pnw"> - <rect key="frame" x="87" y="51.5" width="118.5" height="16"/> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="wQ7-8f-MoC"> + <rect key="frame" x="87" y="18" width="254.5" height="50.5"/> <subviews> - <view contentMode="left" horizontalCompressionResistancePriority="1000" placeholderIntrinsicWidth="39.5" placeholderIntrinsicHeight="16" translatesAutoresizingMaskIntoConstraints="NO" id="5Qj-Ek-EUq" customClass="PeopleRoleBadgeLabel" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="0.0" width="39.5" height="16"/> - <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <userDefinedRuntimeAttributes> - <userDefinedRuntimeAttribute type="string" keyPath="text" value="Editor"/> - <userDefinedRuntimeAttribute type="color" keyPath="textColor"> - <color key="value" red="0.97037827970000001" green="0.97034931179999995" blue="0.97036576269999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </userDefinedRuntimeAttribute> - <userDefinedRuntimeAttribute type="color" keyPath="borderColor"> - <color key="value" red="0.027369353919999999" green="0.2405227721" blue="0.43405210970000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </userDefinedRuntimeAttribute> - <userDefinedRuntimeAttribute type="color" keyPath="backgroundColor"> - <color key="value" red="0.027369353919999999" green="0.2405227721" blue="0.43405210970000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </userDefinedRuntimeAttribute> - </userDefinedRuntimeAttributes> - </view> - <view contentMode="left" horizontalCompressionResistancePriority="1000" placeholderIntrinsicWidth="71" placeholderIntrinsicHeight="16" translatesAutoresizingMaskIntoConstraints="NO" id="MH9-O4-5bD" customClass="PeopleRoleBadgeLabel" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="47.5" y="0.0" width="71" height="16"/> - <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <userDefinedRuntimeAttributes> - <userDefinedRuntimeAttribute type="string" keyPath="text" value="Superadmin"/> - <userDefinedRuntimeAttribute type="color" keyPath="textColor"> - <color key="value" red="0.97037827970000001" green="0.97034931179999995" blue="0.97036576269999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </userDefinedRuntimeAttribute> - <userDefinedRuntimeAttribute type="color" keyPath="borderColor"> - <color key="value" red="0.027369353919999999" green="0.2405227721" blue="0.43405210970000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </userDefinedRuntimeAttribute> - <userDefinedRuntimeAttribute type="color" keyPath="backgroundColor"> - <color key="value" red="0.027369353919999999" green="0.2405227721" blue="0.43405210970000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - </userDefinedRuntimeAttribute> - </userDefinedRuntimeAttributes> - </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="Jorge Bernal" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="8Vt-sK-TAK"> + <rect key="frame" x="0.0" y="0.0" width="254.5" height="17"/> + <fontDescription key="fontDescription" type="boldSystem" pointSize="14"/> + <color key="textColor" red="0.18039215689999999" green="0.2666666667" blue="0.32549019610000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" ambiguous="YES" text="@koke" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1F5-Qy-cZx"> + <rect key="frame" x="0.0" y="19" width="254.5" height="13.5"/> + <fontDescription key="fontDescription" type="system" pointSize="11"/> + <color key="textColor" red="0.0" green="0.66666666669999997" blue="0.86274509799999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <nil key="highlightedColor"/> + </label> + <stackView opaque="NO" contentMode="scaleToFill" distribution="fillProportionally" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="Obq-4m-pnw"> + <rect key="frame" x="0.0" y="34.5" width="118.5" height="16"/> + <subviews> + <view contentMode="left" horizontalCompressionResistancePriority="1000" placeholderIntrinsicWidth="39.5" placeholderIntrinsicHeight="16" translatesAutoresizingMaskIntoConstraints="NO" id="5Qj-Ek-EUq" customClass="PeopleRoleBadgeLabel" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="39.5" height="16"/> + <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="string" keyPath="text" value="Editor"/> + <userDefinedRuntimeAttribute type="color" keyPath="textColor"> + <color key="value" red="0.97037827970000001" green="0.97034931179999995" blue="0.97036576269999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </userDefinedRuntimeAttribute> + <userDefinedRuntimeAttribute type="color" keyPath="borderColor"> + <color key="value" red="0.027369353919999999" green="0.2405227721" blue="0.43405210970000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </userDefinedRuntimeAttribute> + <userDefinedRuntimeAttribute type="color" keyPath="backgroundColor"> + <color key="value" red="0.027369353919999999" green="0.2405227721" blue="0.43405210970000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + </view> + <view contentMode="left" horizontalCompressionResistancePriority="1000" placeholderIntrinsicWidth="71" placeholderIntrinsicHeight="16" translatesAutoresizingMaskIntoConstraints="NO" id="MH9-O4-5bD" customClass="PeopleRoleBadgeLabel" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="47.5" y="0.0" width="71" height="16"/> + <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="string" keyPath="text" value="Superadmin"/> + <userDefinedRuntimeAttribute type="color" keyPath="textColor"> + <color key="value" red="0.97037827970000001" green="0.97034931179999995" blue="0.97036576269999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </userDefinedRuntimeAttribute> + <userDefinedRuntimeAttribute type="color" keyPath="borderColor"> + <color key="value" red="0.027369353919999999" green="0.2405227721" blue="0.43405210970000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </userDefinedRuntimeAttribute> + <userDefinedRuntimeAttribute type="color" keyPath="backgroundColor"> + <color key="value" red="0.027369353919999999" green="0.2405227721" blue="0.43405210970000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + </view> + </subviews> + </stackView> </subviews> + <constraints> + <constraint firstAttribute="trailing" secondItem="8Vt-sK-TAK" secondAttribute="trailing" id="6Oa-ES-DW4"/> + <constraint firstItem="Obq-4m-pnw" firstAttribute="leading" secondItem="wQ7-8f-MoC" secondAttribute="leading" id="FSB-lP-asa"/> + <constraint firstItem="8Vt-sK-TAK" firstAttribute="leading" secondItem="wQ7-8f-MoC" secondAttribute="leading" id="MDD-xa-Vmv"/> + <constraint firstAttribute="trailing" secondItem="1F5-Qy-cZx" secondAttribute="trailing" id="dwy-hn-flP"/> + <constraint firstItem="1F5-Qy-cZx" firstAttribute="leading" secondItem="wQ7-8f-MoC" secondAttribute="leading" id="g0y-Ma-gzE"/> + </constraints> </stackView> </subviews> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <constraints> <constraint firstItem="8zx-wm-uHU" firstAttribute="leading" secondItem="enq-cQ-RBD" secondAttribute="leadingMargin" id="1v8-wR-Ev4"/> - <constraint firstItem="Obq-4m-pnw" firstAttribute="top" secondItem="1F5-Qy-cZx" secondAttribute="bottom" constant="2.5" id="4GJ-w3-eLn"/> - <constraint firstAttribute="bottomMargin" secondItem="Obq-4m-pnw" secondAttribute="bottom" constant="7.5" id="BTe-0Y-DDV"/> + <constraint firstItem="8zx-wm-uHU" firstAttribute="top" relation="greaterThanOrEqual" secondItem="enq-cQ-RBD" secondAttribute="topMargin" constant="4" id="AH3-YX-fdj"/> + <constraint firstItem="wQ7-8f-MoC" firstAttribute="centerY" secondItem="8zx-wm-uHU" secondAttribute="centerY" id="JvQ-NW-Dfb"/> <constraint firstItem="8zx-wm-uHU" firstAttribute="centerY" secondItem="enq-cQ-RBD" secondAttribute="centerY" id="KB9-ag-uwt"/> - <constraint firstAttribute="trailingMargin" relation="greaterThanOrEqual" secondItem="Obq-4m-pnw" secondAttribute="trailing" id="Nre-5n-77P"/> - <constraint firstItem="8Vt-sK-TAK" firstAttribute="leading" secondItem="8zx-wm-uHU" secondAttribute="trailing" constant="15" id="c8g-6f-yIm"/> - <constraint firstItem="8Vt-sK-TAK" firstAttribute="leading" secondItem="1F5-Qy-cZx" secondAttribute="leading" id="fRh-sS-rQC"/> - <constraint firstItem="1F5-Qy-cZx" firstAttribute="top" secondItem="8Vt-sK-TAK" secondAttribute="baseline" constant="2" id="fd0-7w-Ggb"/> - <constraint firstItem="8Vt-sK-TAK" firstAttribute="top" secondItem="enq-cQ-RBD" secondAttribute="topMargin" constant="9" id="ncq-6e-LBk"/> - <constraint firstItem="Obq-4m-pnw" firstAttribute="leading" secondItem="8zx-wm-uHU" secondAttribute="trailing" constant="15" id="wD2-uX-fiy"/> - <constraint firstAttribute="trailingMargin" secondItem="8Vt-sK-TAK" secondAttribute="trailing" id="wbZ-Ob-JZD"/> - <constraint firstAttribute="trailingMargin" secondItem="1F5-Qy-cZx" secondAttribute="trailing" id="xf5-aw-0r1"/> + <constraint firstAttribute="bottomMargin" relation="greaterThanOrEqual" secondItem="8zx-wm-uHU" secondAttribute="bottom" constant="4" id="Se7-ti-JS0"/> + <constraint firstAttribute="bottomMargin" relation="greaterThanOrEqual" secondItem="wQ7-8f-MoC" secondAttribute="bottom" constant="4" id="WaT-ai-39H"/> + <constraint firstItem="wQ7-8f-MoC" firstAttribute="leading" secondItem="8zx-wm-uHU" secondAttribute="trailing" constant="15" id="Y62-F8-A5E"/> + <constraint firstAttribute="trailingMargin" secondItem="wQ7-8f-MoC" secondAttribute="trailing" id="b1I-eY-gMo"/> + <constraint firstItem="wQ7-8f-MoC" firstAttribute="top" relation="greaterThanOrEqual" secondItem="enq-cQ-RBD" secondAttribute="topMargin" constant="4" id="bkP-uz-P4o"/> </constraints> </tableViewCellContentView> <connections> <outlet property="avatarImageView" destination="8zx-wm-uHU" id="ODg-qJ-CjU"/> + <outlet property="badgeStackView" destination="Obq-4m-pnw" id="eUG-qv-VRg"/> <outlet property="displayNameLabel" destination="8Vt-sK-TAK" id="O18-P1-Bcr"/> <outlet property="roleBadge" destination="5Qj-Ek-EUq" id="uWz-UU-nAn"/> <outlet property="superAdminRoleBadge" destination="MH9-O4-5bD" id="iAa-YF-4Oi"/> <outlet property="usernameLabel" destination="1F5-Qy-cZx" id="UcH-Xr-wm1"/> - <segue destination="IBP-CG-w3b" kind="show" identifier="person" id="EfS-w6-Nga"/> + <segue destination="IBP-CG-w3b" kind="show" identifier="person" destinationCreationSelector="createPersonViewController:" id="EfS-w6-Nga"/> </connections> </tableViewCell> </prototypes> @@ -147,7 +158,7 @@ <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> </view> </objects> - <point key="canvasLocation" x="1280" y="738"/> + <point key="canvasLocation" x="1279.2" y="737.18140929535241"/> </scene> <!--Roles View Controller--> <scene sceneID="yMT-e5-sxj"> @@ -159,7 +170,7 @@ <color key="backgroundColor" red="0.93725490199999995" green="0.93725490199999995" blue="0.95686274510000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <prototypes> <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="roleCell" id="uAe-CK-SUe" customClass="WPTableViewCell"> - <rect key="frame" x="0.0" y="55.5" width="375" height="44"/> + <rect key="frame" x="0.0" y="49.5" width="375" height="44"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="uAe-CK-SUe" id="vI7-cS-zeY"> <rect key="frame" x="0.0" y="0.0" width="375" height="44"/> @@ -173,6 +184,7 @@ <outlet property="delegate" destination="TOR-rB-bMi" id="KKV-OZ-YuV"/> </connections> </tableView> + <navigationItem key="navigationItem" id="Ki5-lV-mp6"/> </tableViewController> <placeholder placeholderIdentifier="IBFirstResponder" id="tmn-bm-4UY" userLabel="First Responder" sceneMemberID="firstResponder"/> </objects> @@ -188,7 +200,7 @@ <color key="backgroundColor" red="0.93725490199999995" green="0.93725490199999995" blue="0.95686274510000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <prototypes> <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" reuseIdentifier="PersonHeaderCell" rowHeight="87" id="VTq-Of-ZQm" customClass="PersonHeaderCell" customModule="WordPress" customModuleProvider="target"> - <rect key="frame" x="0.0" y="55.5" width="375" height="87"/> + <rect key="frame" x="0.0" y="49.5" width="375" height="87"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="VTq-Of-ZQm" id="v2L-Gv-S2P"> <rect key="frame" x="0.0" y="0.0" width="375" height="87"/> @@ -202,16 +214,16 @@ </constraints> </imageView> <view contentMode="scaleToFill" verticalCompressionResistancePriority="1000" translatesAutoresizingMaskIntoConstraints="NO" id="Mh2-fk-1eW"> - <rect key="frame" x="81" y="25" width="278" height="37.5"/> + <rect key="frame" x="81" y="27.5" width="278" height="32.5"/> <subviews> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="1000" text="Full Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="iII-kx-uUz"> - <rect key="frame" x="0.0" y="0.0" width="278" height="19.5"/> + <rect key="frame" x="0.0" y="0.0" width="278" height="16"/> <fontDescription key="fontDescription" style="UICTFontTextStyleCallout"/> <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <nil key="highlightedColor"/> </label> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="1000" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="PK5-u5-2r7"> - <rect key="frame" x="0.0" y="21.5" width="278" height="16"/> + <rect key="frame" x="0.0" y="18" width="278" height="14.5"/> <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <nil key="highlightedColor"/> @@ -233,12 +245,10 @@ <constraint firstItem="Mh2-fk-1eW" firstAttribute="leading" secondItem="yMs-a3-NfF" secondAttribute="trailing" constant="8" id="HG9-YQ-Uv0"/> <constraint firstAttribute="bottomMargin" relation="greaterThanOrEqual" secondItem="yMs-a3-NfF" secondAttribute="bottom" priority="750" constant="4" id="NwY-Hh-w1T"/> <constraint firstItem="Mh2-fk-1eW" firstAttribute="centerY" secondItem="v2L-Gv-S2P" secondAttribute="centerY" id="QRn-W5-5We"/> - <constraint firstAttribute="bottomMargin" relation="greaterThanOrEqual" secondItem="Mh2-fk-1eW" secondAttribute="bottom" constant="4" id="RJO-Q4-rcK"/> <constraint firstItem="yMs-a3-NfF" firstAttribute="leading" secondItem="v2L-Gv-S2P" secondAttribute="leadingMargin" id="VPt-Vf-Ir3"/> <constraint firstAttribute="trailingMargin" secondItem="Mh2-fk-1eW" secondAttribute="trailing" id="ZV4-gb-pqe"/> <constraint firstItem="yMs-a3-NfF" firstAttribute="centerY" secondItem="v2L-Gv-S2P" secondAttribute="centerY" id="pZ8-9v-EOW"/> <constraint firstItem="yMs-a3-NfF" firstAttribute="top" relation="greaterThanOrEqual" secondItem="v2L-Gv-S2P" secondAttribute="topMargin" priority="750" constant="4" id="qTc-fI-WmB"/> - <constraint firstItem="Mh2-fk-1eW" firstAttribute="top" relation="greaterThanOrEqual" secondItem="v2L-Gv-S2P" secondAttribute="topMargin" constant="4" id="we0-kz-Epc"/> </constraints> </tableViewCellContentView> <connections> @@ -273,11 +283,11 @@ <sections> <tableViewSection id="TSO-dY-4Cz"> <cells> - <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" indentationWidth="10" textLabel="RII-cM-wkT" detailTextLabel="igf-wZ-skT" style="IBUITableViewCellStyleValue1" id="i2R-sD-lxa"> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="RII-cM-wkT" detailTextLabel="igf-wZ-skT" style="IBUITableViewCellStyleValue1" id="i2R-sD-lxa"> <rect key="frame" x="0.0" y="18" width="375" height="44"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="i2R-sD-lxa" id="wJl-zz-akk"> - <rect key="frame" x="0.0" y="0.0" width="375" height="44"/> + <rect key="frame" x="0.0" y="0.0" width="349.5" height="44"/> <autoresizingMask key="autoresizingMask"/> <subviews> <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" text="Username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="RII-cM-wkT"> @@ -288,7 +298,7 @@ <nil key="highlightedColor"/> </label> <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" text="" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="igf-wZ-skT"> - <rect key="frame" x="317.5" y="13" width="41.5" height="19.5"/> + <rect key="frame" x="299.5" y="13" width="42" height="19.5"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <fontDescription key="fontDescription" type="system" pointSize="16"/> <color key="textColor" red="0.55686274509803924" green="0.55686274509803924" blue="0.57647058823529407" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> @@ -304,11 +314,11 @@ </tableViewSection> <tableViewSection id="UIx-0o-NSH"> <cells> - <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" indentationWidth="10" textLabel="zZ5-1U-KBD" detailTextLabel="w3p-Ei-KVz" style="IBUITableViewCellStyleValue1" id="JUH-Ao-gEv"> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="zZ5-1U-KBD" detailTextLabel="w3p-Ei-KVz" style="IBUITableViewCellStyleValue1" id="JUH-Ao-gEv"> <rect key="frame" x="0.0" y="98" width="375" height="44"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="JUH-Ao-gEv" id="TzL-Fg-C5x"> - <rect key="frame" x="0.0" y="0.0" width="375" height="44"/> + <rect key="frame" x="0.0" y="0.0" width="349.5" height="44"/> <autoresizingMask key="autoresizingMask"/> <subviews> <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" text="Role" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="zZ5-1U-KBD"> @@ -319,7 +329,7 @@ <nil key="highlightedColor"/> </label> <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" text="Detail" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="w3p-Ei-KVz"> - <rect key="frame" x="317.5" y="13" width="41.5" height="19.5"/> + <rect key="frame" x="299.5" y="13" width="42" height="19.5"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <fontDescription key="fontDescription" type="system" pointSize="16"/> <color key="textColor" red="0.55686274509803924" green="0.55686274509803924" blue="0.57647058823529407" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> @@ -335,34 +345,36 @@ </tableViewSection> <tableViewSection id="KTt-dH-XgO"> <cells> - <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" indentationWidth="10" rowHeight="88" id="sdj-uY-1LY"> - <rect key="frame" x="0.0" y="178" width="375" height="88"/> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="none" indentationWidth="10" rowHeight="100" id="sdj-uY-1LY"> + <rect key="frame" x="0.0" y="178" width="375" height="100"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="sdj-uY-1LY" id="Y8v-KJ-xnc"> - <rect key="frame" x="0.0" y="0.0" width="375" height="88"/> + <rect key="frame" x="0.0" y="0.0" width="375" height="100"/> <autoresizingMask key="autoresizingMask"/> <subviews> - <textView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" scrollEnabled="NO" showsHorizontalScrollIndicator="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nh9-39-Mdw"> - <rect key="frame" x="16" y="11" width="343" height="66"/> + <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" textAlignment="natural" translatesAutoresizingMaskIntoConstraints="NO" id="nh9-39-Mdw"> + <rect key="frame" x="16" y="0.0" width="343" height="100"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstAttribute="height" constant="72" id="ARy-hL-nFx"/> - </constraints> - <string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string> + <string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</string> <fontDescription key="fontDescription" type="system" pointSize="17"/> <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> - <variation key="default"> - <mask key="constraints"> - <exclude reference="ARy-hL-nFx"/> - </mask> - </variation> </textView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Custom message..." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Jyo-4B-Bt9" userLabel="Placeholder Label"> + <rect key="frame" x="16" y="11" width="116" height="16"/> + <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleCallout"/> + <color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + <size key="shadowOffset" width="-1" height="-1"/> + </label> </subviews> <constraints> - <constraint firstAttribute="trailingMargin" secondItem="nh9-39-Mdw" secondAttribute="trailing" id="06N-1n-lgP"/> - <constraint firstAttribute="bottomMargin" secondItem="nh9-39-Mdw" secondAttribute="bottom" id="Avz-ZP-kHa"/> - <constraint firstItem="nh9-39-Mdw" firstAttribute="top" secondItem="Y8v-KJ-xnc" secondAttribute="topMargin" id="HlP-n4-smH"/> - <constraint firstItem="nh9-39-Mdw" firstAttribute="leading" secondItem="Y8v-KJ-xnc" secondAttribute="leadingMargin" id="Sq4-Av-m3J"/> + <constraint firstItem="nh9-39-Mdw" firstAttribute="leading" secondItem="Y8v-KJ-xnc" secondAttribute="leading" constant="16" id="0cN-hB-5DE"/> + <constraint firstAttribute="trailing" secondItem="nh9-39-Mdw" secondAttribute="trailing" constant="16" id="3YE-iy-glN"/> + <constraint firstItem="Jyo-4B-Bt9" firstAttribute="top" secondItem="Y8v-KJ-xnc" secondAttribute="topMargin" id="KCB-49-L7U"/> + <constraint firstItem="Jyo-4B-Bt9" firstAttribute="leading" secondItem="Y8v-KJ-xnc" secondAttribute="leadingMargin" id="Tt0-eO-4t1"/> + <constraint firstItem="nh9-39-Mdw" firstAttribute="top" secondItem="Y8v-KJ-xnc" secondAttribute="top" id="eJ6-Gv-uEP"/> + <constraint firstAttribute="bottom" secondItem="nh9-39-Mdw" secondAttribute="bottom" id="qeC-n5-gJx"/> </constraints> </tableViewCellContentView> <connections> @@ -371,6 +383,95 @@ </tableViewCell> </cells> </tableViewSection> + <tableViewSection id="eT7-if-lvZ"> + <cells> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" indentationWidth="10" textLabel="Zm5-2s-RgM" style="IBUITableViewCellStyleDefault" id="SnT-bj-ro5"> + <rect key="frame" x="0.0" y="314" width="375" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="SnT-bj-ro5" id="BXy-E7-KZb"> + <rect key="frame" x="0.0" y="0.0" width="375" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Share invite link" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="Zm5-2s-RgM"> + <rect key="frame" x="16" y="0.0" width="343" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <color key="textColor" systemColor="systemBlueColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </tableViewCellContentView> + </tableViewCell> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="g40-Lu-Tb6" detailTextLabel="ni2-FN-yoP" style="IBUITableViewCellStyleValue1" id="1pf-gg-Sfn"> + <rect key="frame" x="0.0" y="358" width="375" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="1pf-gg-Sfn" id="lBf-2e-rNX"> + <rect key="frame" x="0.0" y="0.0" width="349.5" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Role" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="g40-Lu-Tb6"> + <rect key="frame" x="16" y="12" width="33.5" height="20.5"/> + <autoresizingMask key="autoresizingMask"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Detail" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="ni2-FN-yoP"> + <rect key="frame" x="297.5" y="12" width="44" height="20.5"/> + <autoresizingMask key="autoresizingMask"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </tableViewCellContentView> + <connections> + <segue destination="TOR-rB-bMi" kind="show" identifier="inviteRole" id="djZ-XX-wel"/> + </connections> + </tableViewCell> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="none" indentationWidth="10" textLabel="iIk-xV-Xdo" detailTextLabel="t88-zf-B8l" style="IBUITableViewCellStyleValue1" id="dOH-RD-Vok"> + <rect key="frame" x="0.0" y="402" width="375" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="dOH-RD-Vok" id="y24-Tt-Qm5"> + <rect key="frame" x="0.0" y="0.0" width="375" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Expires on" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="iIk-xV-Xdo"> + <rect key="frame" x="16" y="12" width="79" height="20.5"/> + <autoresizingMask key="autoresizingMask"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Detail" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="t88-zf-B8l"> + <rect key="frame" x="315" y="12" width="44" height="20.5"/> + <autoresizingMask key="autoresizingMask"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </tableViewCellContentView> + </tableViewCell> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" selectionStyle="default" indentationWidth="10" textLabel="JlS-gd-WUF" style="IBUITableViewCellStyleDefault" id="Kcp-F4-LVf"> + <rect key="frame" x="0.0" y="446" width="375" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" layoutMarginsFollowReadableWidth="YES" tableViewCell="Kcp-F4-LVf" id="MDM-e0-ex4"> + <rect key="frame" x="0.0" y="0.0" width="375" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Disable invite link" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="JlS-gd-WUF"> + <rect key="frame" x="16" y="0.0" width="343" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <color key="textColor" systemColor="systemRedColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </tableViewCellContentView> + </tableViewCell> + </cells> + </tableViewSection> </sections> <connections> <outlet property="dataSource" destination="jns-Ol-UES" id="7TX-3y-QSm"/> @@ -379,14 +480,19 @@ </tableView> <navigationItem key="navigationItem" title="Add a Person" id="MO7-QW-hIa"/> <connections> + <outlet property="currentInviteCell" destination="1pf-gg-Sfn" id="Xfc-o1-ziz"/> + <outlet property="disableLinksCell" destination="Kcp-F4-LVf" id="QnD-EY-DE7"/> + <outlet property="expirationCell" destination="dOH-RD-Vok" id="IbZ-VI-hTQ"/> + <outlet property="generateShareCell" destination="SnT-bj-ro5" id="Bn1-fV-5wZ"/> <outlet property="messageTextView" destination="nh9-39-Mdw" id="EvO-0d-MLp"/> + <outlet property="placeholderLabel" destination="Jyo-4B-Bt9" id="xG0-wq-EAn"/> <outlet property="roleCell" destination="JUH-Ao-gEv" id="xIB-S2-RYP"/> <outlet property="usernameCell" destination="i2R-sD-lxa" id="wgx-nw-l50"/> </connections> </tableViewController> <placeholder placeholderIdentifier="IBFirstResponder" id="fuT-BL-XQM" userLabel="First Responder" sceneMemberID="firstResponder"/> </objects> - <point key="canvasLocation" x="1994" y="1519"/> + <point key="canvasLocation" x="1992.8" y="1518.8905547226389"/> </scene> <!--Username TextViewController--> <scene sceneID="tMT-sn-IIW"> @@ -442,10 +548,16 @@ <point key="canvasLocation" x="1282" y="1519"/> </scene> </scenes> + <inferredMetricsTieBreakers> + <segue reference="djZ-XX-wel"/> + </inferredMetricsTieBreakers> <resources> <image name="gravatar" width="85" height="85"/> + <systemColor name="systemBlueColor"> + <color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + <systemColor name="systemRedColor"> + <color red="1" green="0.23137254901960785" blue="0.18823529411764706" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> </resources> - <inferredMetricsTieBreakers> - <segue reference="e6P-Kc-Mxa"/> - </inferredMetricsTieBreakers> </document> diff --git a/WordPress/Classes/ViewRelated/People/PeopleCell.swift b/WordPress/Classes/ViewRelated/People/PeopleCell.swift index 0c4bbd1adbeb..50cf1eb568a3 100644 --- a/WordPress/Classes/ViewRelated/People/PeopleCell.swift +++ b/WordPress/Classes/ViewRelated/People/PeopleCell.swift @@ -2,11 +2,12 @@ import UIKit import WordPressShared class PeopleCell: WPTableViewCell { - @IBOutlet var avatarImageView: CircularImageView! - @IBOutlet var displayNameLabel: UILabel! - @IBOutlet var usernameLabel: UILabel! - @IBOutlet var roleBadge: PeopleRoleBadgeLabel! - @IBOutlet var superAdminRoleBadge: PeopleRoleBadgeLabel! + @IBOutlet private weak var avatarImageView: CircularImageView! + @IBOutlet private weak var displayNameLabel: UILabel! + @IBOutlet private weak var usernameLabel: UILabel! + @IBOutlet private weak var roleBadge: PeopleRoleBadgeLabel! + @IBOutlet private weak var superAdminRoleBadge: PeopleRoleBadgeLabel! + @IBOutlet private weak var badgeStackView: UIStackView! override func awakeFromNib() { WPStyleGuide.configureLabel(displayNameLabel, textStyle: .callout) @@ -18,6 +19,7 @@ class PeopleCell: WPTableViewCell { displayNameLabel.text = viewModel.displayName displayNameLabel.textColor = viewModel.usernameColor usernameLabel.text = viewModel.usernameText + usernameLabel.isHidden = viewModel.usernameHidden roleBadge.borderColor = viewModel.roleBorderColor roleBadge.backgroundColor = viewModel.roleBackgroundColor roleBadge.textColor = viewModel.roleTextColor @@ -27,6 +29,7 @@ class PeopleCell: WPTableViewCell { superAdminRoleBadge.isHidden = viewModel.superAdminHidden superAdminRoleBadge.borderColor = viewModel.superAdminBorderColor superAdminRoleBadge.backgroundColor = viewModel.superAdminBackgroundColor + badgeStackView.isHidden = viewModel.roleHidden && viewModel.superAdminHidden } @objc func setAvatarURL(_ avatarURL: URL?) { diff --git a/WordPress/Classes/ViewRelated/People/PeopleCellViewModel.swift b/WordPress/Classes/ViewRelated/People/PeopleCellViewModel.swift index 9a95b9ae5099..07cd2c9f3e01 100644 --- a/WordPress/Classes/ViewRelated/People/PeopleCellViewModel.swift +++ b/WordPress/Classes/ViewRelated/People/PeopleCellViewModel.swift @@ -20,6 +20,10 @@ struct PeopleCellViewModel { return "@" + username } + var usernameHidden: Bool { + return username.isEmpty + } + var usernameColor: UIColor { return .text } diff --git a/WordPress/Classes/ViewRelated/People/PeopleRoleBadgeLabel.swift b/WordPress/Classes/ViewRelated/People/PeopleRoleBadgeLabel.swift index f11714d8cf6c..3d96a0c86ab1 100644 --- a/WordPress/Classes/ViewRelated/People/PeopleRoleBadgeLabel.swift +++ b/WordPress/Classes/ViewRelated/People/PeopleRoleBadgeLabel.swift @@ -1,10 +1,7 @@ import UIKit import WordPressShared.WPStyleGuide -@IBDesignable class PeopleRoleBadgeLabel: BadgeLabel { - // MARK: Initialization - override init(frame: CGRect) { super.init(frame: frame) setupView() @@ -15,7 +12,7 @@ class PeopleRoleBadgeLabel: BadgeLabel { setupView() } - fileprivate func setupView() { + private func setupView() { adjustsFontForContentSizeCategory = true adjustsFontSizeToFitWidth = true horizontalPadding = WPStyleGuide.People.RoleBadge.padding diff --git a/WordPress/Classes/ViewRelated/People/PeopleViewController+JetpackBannerViewController.swift b/WordPress/Classes/ViewRelated/People/PeopleViewController+JetpackBannerViewController.swift new file mode 100644 index 000000000000..90de2c174e9e --- /dev/null +++ b/WordPress/Classes/ViewRelated/People/PeopleViewController+JetpackBannerViewController.swift @@ -0,0 +1,21 @@ +import Foundation + +@objc +extension PeopleViewController { + static func withJPBannerForBlog(_ blog: Blog) -> UIViewController? { + guard let peopleViewVC = PeopleViewController.controllerWithBlog(blog) else { + return nil + } + peopleViewVC.navigationItem.largeTitleDisplayMode = .never + guard JetpackBrandingCoordinator.shouldShowBannerForJetpackDependentFeatures() else { + return peopleViewVC + } + return JetpackBannerWrapperViewController(childVC: peopleViewVC, screen: .people) + } + + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + if let jetpackBannerWrapper = parent as? JetpackBannerWrapperViewController { + jetpackBannerWrapper.processJetpackBannerVisibility(scrollView) + } + } +} diff --git a/WordPress/Classes/ViewRelated/People/PeopleViewController.swift b/WordPress/Classes/ViewRelated/People/PeopleViewController.swift index 55bbf5fde8fa..b76f595d77d0 100644 --- a/WordPress/Classes/ViewRelated/People/PeopleViewController.swift +++ b/WordPress/Classes/ViewRelated/People/PeopleViewController.swift @@ -1,4 +1,5 @@ import UIKit +import Combine import CocoaLumberjack import WordPressShared @@ -69,18 +70,16 @@ class PeopleViewController: UITableViewController, UIViewControllerRestoration { // Followers must be sorted out by creationDate! // switch filter { - case .followers: + case .followers, .email: return [NSSortDescriptor(key: "creationDate", ascending: true, selector: #selector(NSDate.compare(_:)))] default: return [NSSortDescriptor(key: "displayName", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare(_:)))] } } - /// Core Data Context - /// - private lazy var context: NSManagedObjectContext = { - return ContextManager.sharedInstance().newMainContextChildContext() - }() + private var viewContext: NSManagedObjectContext { + ContextManager.sharedInstance().mainContext + } /// Core Data FRC /// @@ -90,7 +89,7 @@ class PeopleViewController: UITableViewController, UIViewControllerRestoration { request.predicate = self.predicate request.sortDescriptors = self.sortDescriptors - let frc = NSFetchedResultsController(fetchRequest: request, managedObjectContext: self.context, sectionNameKeyPath: nil, cacheName: nil) + let frc = NSFetchedResultsController(fetchRequest: request, managedObjectContext: viewContext, sectionNameKeyPath: nil, cacheName: nil) frc.delegate = self return frc }() @@ -112,6 +111,11 @@ class PeopleViewController: UITableViewController, UIViewControllerRestoration { // MARK: UITableViewDataSource override func numberOfSections(in tableView: UITableView) -> Int { + guard !isInitialLoad else { + // Until the initial load has been completed, no data should be rendered in the table. + return 0 + } + return resultsController.sections?.count ?? 0 } @@ -124,6 +128,12 @@ class PeopleViewController: UITableViewController, UIViewControllerRestoration { fatalError() } + guard let sections = resultsController.sections, sections[indexPath.section].numberOfObjects > indexPath.row else { + DDLogError("Error: PeopleViewController table tried to render a cell that didn't exist in Core Data") + cell.isHidden = true + return cell + } + let person = personAtIndexPath(indexPath) let role = self.role(person: person) let viewModel = PeopleCellViewModel(person: person, role: role) @@ -162,7 +172,12 @@ class PeopleViewController: UITableViewController, UIViewControllerRestoration { super.viewWillAppear(animated) tableView.deselectSelectedRowWithAnimation(true) refreshNoResultsView() - WPAnalytics.track(.openedPeople) + + guard let blog else { + return + } + + WPAppAnalytics.track(.openedPeople, with: blog) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -170,22 +185,18 @@ class PeopleViewController: UITableViewController, UIViewControllerRestoration { tableView.reloadData() } - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if let personViewController = segue.destination as? PersonViewController, - let selectedIndexPath = tableView.indexPathForSelectedRow { - personViewController.context = context - personViewController.blog = blog - personViewController.person = personAtIndexPath(selectedIndexPath) - switch filter { - case .followers: - personViewController.screenMode = .Follower - case .users: - personViewController.screenMode = .User - case .viewers: - personViewController.screenMode = .Viewer - } + @IBSegueAction func createPersonViewController(_ coder: NSCoder) -> PersonViewController? { + guard let selectedIndexPath = tableView.indexPathForSelectedRow, let blog = blog else { return nil } - } else if let navController = segue.destination as? UINavigationController, + return PersonViewController(coder: coder, + blog: blog, + context: viewContext, + person: personAtIndexPath(selectedIndexPath), + screenMode: filter.screenMode) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if let navController = segue.destination as? UINavigationController, let inviteViewController = navController.topViewController as? InvitePersonViewController { inviteViewController.blog = blog } @@ -218,7 +229,6 @@ class PeopleViewController: UITableViewController, UIViewControllerRestoration { @IBAction func refresh() { - resetManagedPeople() refreshPeople() } @@ -263,10 +273,11 @@ private extension PeopleViewController { case users = "users" case followers = "followers" + case email = "email" case viewers = "viewers" static var defaultFilters: [Filter] { - return [.users, .followers] + return [.users, .followers, .email] } var title: String { @@ -277,6 +288,8 @@ private extension PeopleViewController { return NSLocalizedString("Followers", comment: "Blog Followers") case .viewers: return NSLocalizedString("Viewers", comment: "Blog Viewers") + case .email: + return NSLocalizedString("Email Followers", comment: "Blog Email Followers") } } @@ -288,6 +301,21 @@ private extension PeopleViewController { return .follower case .viewers: return .viewer + case .email: + return .emailFollower + } + } + + var screenMode: PersonViewController.ScreenMode { + switch self { + case .users: + return .User + case .followers: + return .Follower + case .viewers: + return .Viewer + case .email: + return .Email } } } @@ -351,7 +379,7 @@ private extension PeopleViewController { func resetManagedPeople() { isInitialLoad = true - guard let blog = blog, let service = PeopleService(blog: blog, context: context) else { + guard let blog = blog, let service = PeopleService(blog: blog, coreDataStack: ContextManager.shared) else { return } @@ -373,7 +401,7 @@ private extension PeopleViewController { } func loadPeoplePage(_ offset: Int = 0, success: @escaping ((_ retrieved: Int, _ shouldLoadMore: Bool) -> Void)) { - guard let blog = blog, let service = PeopleService(blog: blog, context: context) else { + guard let blog = blog, let service = PeopleService(blog: blog, coreDataStack: ContextManager.shared) else { return } @@ -384,13 +412,15 @@ private extension PeopleViewController { loadUsersPage(offset, success: success) case .viewers: service.loadViewersPage(offset, success: success) + case .email: + service.loadEmailFollowersPage(offset, success: success) } } func loadUsersPage(_ offset: Int = 0, success: @escaping ((_ retrieved: Int, _ shouldLoadMore: Bool) -> Void)) { guard let blog = blogInContext, - let peopleService = PeopleService(blog: blog, context: context), - let roleService = RoleService(blog: blog, context: context) else { + let peopleService = PeopleService(blog: blog, coreDataStack: ContextManager.shared), + let roleService = RoleService(blog: blog, coreDataStack: ContextManager.shared) else { return } @@ -408,7 +438,7 @@ private extension PeopleViewController { }) group.enter() - roleService.fetchRoles(success: {_ in + roleService.fetchRoles(success: { group.leave() }, failure: { error in loadError = error @@ -428,7 +458,7 @@ private extension PeopleViewController { var blogInContext: Blog? { guard let objectID = blog?.objectID, - let object = try? context.existingObject(with: objectID) else { + let object = try? viewContext.existingObject(with: objectID) else { return nil } @@ -438,32 +468,29 @@ private extension PeopleViewController { // MARK: No Results Helpers func refreshNoResultsView() { - noResultsViewController.removeFromView() - - if isInitialLoad { - displayNoResultsView(forLoading: true) - return - } - guard resultsController.fetchedObjects?.count == 0 else { + noResultsViewController.removeFromView() return } - displayNoResultsView() + displayNoResultsView(isLoading: isInitialLoad) } - func displayNoResultsView(forLoading: Bool = false) { - let accessoryView = forLoading ? NoResultsViewController.loadingAccessoryView() : nil + func displayNoResultsView(isLoading: Bool = false) { + let accessoryView = isLoading ? NoResultsViewController.loadingAccessoryView() : nil noResultsViewController.configure(title: noResultsTitle(), accessoryView: accessoryView) - addChild(noResultsViewController) - tableView.addSubview(withFadeAnimation: noResultsViewController.view) - // Set the NRV top as the filterBar bottom so the NRV // adjusts correctly when refreshControl is active. let filterBarBottom = filterBar.frame.origin.y + filterBar.frame.size.height noResultsViewController.view.frame.origin.y = filterBarBottom + guard noResultsViewController.parent == nil else { + noResultsViewController.updateView() + return + } + addChild(noResultsViewController) + tableView.addSubview(withFadeAnimation: noResultsViewController.view) noResultsViewController.didMove(toParent: self) } @@ -494,11 +521,10 @@ private extension PeopleViewController { } func role(person: Person) -> Role? { - guard let blog = blog, - let service = RoleService(blog: blog, context: context) else { - return nil + guard let blog = blog else { + return nil } - return service.getRole(slug: person.role) + return try? Role.lookup(withBlogID: blog.objectID, slug: person.role, in: viewContext) } func setupFilterBar() { @@ -526,6 +552,8 @@ private extension PeopleViewController { func setupView() { title = NSLocalizedString("People", comment: "Noun. Title of the people management feature.") + extendedLayoutIncludesOpaqueBars = true + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(invitePersonWasPressed)) @@ -557,5 +585,10 @@ extension PeopleViewController { func selectedFilterDidChange(_ filterBar: FilterTabBar) { let selectedFilter = Filter.allCases[filterBar.selectedIndex] filter = selectedFilter + + guard let blog = blog else { + return + } + WPAnalytics.track(.peopleFilterChanged, properties: [:], blog: blog) } } diff --git a/WordPress/Classes/ViewRelated/People/PersonViewController.swift b/WordPress/Classes/ViewRelated/People/PersonViewController.swift index 4616c62ea259..b8a4ad68436a 100644 --- a/WordPress/Classes/ViewRelated/People/PersonViewController.swift +++ b/WordPress/Classes/ViewRelated/People/PersonViewController.swift @@ -21,6 +21,7 @@ final class PersonViewController: UITableViewController { case User = "user" case Follower = "follower" case Viewer = "viewer" + case Email = "email" var title: String { switch self { @@ -30,50 +31,54 @@ final class PersonViewController: UITableViewController { return NSLocalizedString("Blog's Follower", comment: "Blog's Follower Profile. Displayed when the name is empty!") case .Viewer: return NSLocalizedString("Blog's Viewer", comment: "Blog's Viewer Profile. Displayed when the name is empty!") - } - } - - var name: String { - switch self { - case .User: - return NSLocalizedString("user", comment: "Noun. Describes a site's user.") - case .Follower: - return NSLocalizedString("follower", comment: "Noun. Describes a site's follower.") - case .Viewer: - return NSLocalizedString("viewer", comment: "Noun. Describes a site's viewer.") + case .Email: + return NSLocalizedString("Blog's Email Follower", comment: "Blog's Email Follower Profile. Displayed when the name is empty!") } } } - // MARK: - Public Properties /// Blog to which the Person belongs /// - @objc var blog: Blog! + private let blog: Blog /// Core Data Context that should be used /// - @objc var context: NSManagedObjectContext! + private let context: NSManagedObjectContext /// Person to be displayed /// - var person: Person! { + private var person: Person { didSet { refreshInterfaceIfNeeded() } } - /// Mode: User / Follower / Viewer + /// Mode: User / Follower / Viewer / Email Follower /// - var screenMode: ScreenMode = .User + private let screenMode: ScreenMode + private let service: PeopleService? + + // MARK: - Initializers + + init?(coder: NSCoder, blog: Blog, context: NSManagedObjectContext, person: Person, screenMode: ScreenMode) { + self.blog = blog + self.context = context + self.person = person + self.screenMode = screenMode + self.service = PeopleService(blog: blog, coreDataStack: ContextManager.shared) + + super.init(coder: coder) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } // MARK: - View Lifecyle Methods override func viewDidLoad() { - assert(person != nil) - assert(blog != nil) - super.viewDidLoad() title = person.fullName.nonEmptyString() ?? screenMode.title @@ -185,9 +190,12 @@ final class PersonViewController: UITableViewController { lastNameIndexPath, displayNameIndexPath ]) - model.append([ - removeIndexPath - ]) + + if isRemoveEnabled { + model.append([ + removeIndexPath + ]) + } return model }() } @@ -202,9 +210,9 @@ private extension PersonViewController { } func removeWasPressed() { - let titleFormat = NSLocalizedString("Remove @%@", comment: "Remove Person Alert Title") - let titleText = String(format: titleFormat, person.username) - let name = person.firstName?.nonEmptyString() ?? person.username + let titleFormat = NSLocalizedString("Remove %@", comment: "Remove Person Alert Title") + let titleText = String(format: titleFormat, isEmailFollower ? person.displayName : "@" + person.username) + let name = person.firstName?.nonEmptyString() ?? (isEmailFollower ? person.displayName : person.username) let message = warningTextForRemovingPerson(name) let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel Action") let removeTitle = NSLocalizedString("Remove", comment: "Remove Action") @@ -226,6 +234,8 @@ private extension PersonViewController { case .Viewer: strongSelf.deleteViewer() return + case .Email: + strongSelf.deleteEmailFollower() } } @@ -240,11 +250,14 @@ private extension PersonViewController { comment: "First line of remove user warning in confirmation dialog. Note: '%@' is the placeholder for the user's name and it must exist twice in this string.") messageFirstLine = String.localizedStringWithFormat(text, name, name) case .Follower: - messageFirstLine = NSLocalizedString("If removed, this follower will stop receiving notifications about this site, unless they re-follow.", + messageFirstLine = NSLocalizedString("Removing followers makes them stop receiving updates from your site. If they choose to, they can still visit your site, and follow it again.", comment: "First line of remove follower warning in confirmation dialog.") case .Viewer: messageFirstLine = NSLocalizedString("If you remove this viewer, he or she will not be able to visit this site.", comment: "First line of remove viewer warning in confirmation dialog.") + case .Email: + messageFirstLine = NSLocalizedString("Removing followers makes them stop receiving updates from your site. If they choose to, they can still visit your site, and follow it again.", + comment: "First line of remove email follower warning in confirmation dialog.") } let messageSecondLineText = NSLocalizedString("Would you still like to remove this person?", @@ -260,18 +273,14 @@ private extension PersonViewController { return } - let service = PeopleService(blog: blog, context: context) service?.deleteUser(user, success: { WPAnalytics.track(.personRemoved) }, failure: {[weak self] (error: Error?) -> () in guard let strongSelf = self, let error = error as NSError? else { return } - guard let personWithError = strongSelf.person else { - return - } - strongSelf.handleRemoveUserError(error, userName: "@" + personWithError.username) + strongSelf.handleRemoveUserError(error, userName: "@" + strongSelf.person.username) }) _ = navigationController?.popViewController(animated: true) } @@ -283,7 +292,6 @@ private extension PersonViewController { return } - let service = PeopleService(blog: blog, context: context) service?.deleteFollower(follower, failure: {[weak self] (error: Error?) -> () in guard let strongSelf = self, let error = error as NSError? else { return @@ -294,6 +302,23 @@ private extension PersonViewController { _ = navigationController?.popViewController(animated: true) } + func deleteEmailFollower() { + guard let emailFollower = emailFollower, isEmailFollower else { + DDLogError("Error: Only email followers can be deleted here") + assertionFailure() + return + } + + service?.deleteEmailFollower(emailFollower, failure: { [weak self] error in + guard let strongSelf = self, let error = error as NSError? else { + return + } + + strongSelf.handleRemoveViewerOrFollowerError(error) + }) + _ = navigationController?.popViewController(animated: true) + } + func deleteViewer() { guard let viewer = viewer, isViewer else { DDLogError("Error: Only Viewers can be deleted here") @@ -301,7 +326,6 @@ private extension PersonViewController { return } - let service = PeopleService(blog: blog, context: context) service?.deleteViewer(viewer, success: { WPAnalytics.track(.personRemoved) }, failure: {[weak self] (error: Error?) -> () in @@ -344,20 +368,18 @@ private extension PersonViewController { return } - guard let service = PeopleService(blog: blog, context: context) else { + guard let service = service else { DDLogError("Couldn't instantiate People Service") return } - let updated = service.updateUser(user, role: newRole) { (error, reloadedPerson) in - self.person = reloadedPerson - self.retryUpdatingRole(newRole) + service.updateUser(user, role: newRole) { updated in + self.person = updated + WPAnalytics.track(.personUpdated) + } failure: { [weak self] _, reloadedPerson in + self?.person = reloadedPerson + self?.retryUpdatingRole(newRole) } - - // Optimistically refresh the UI - self.person = updated - - WPAnalytics.track(.personUpdated) } func retryUpdatingRole(_ newRole: String) { @@ -399,11 +421,11 @@ private extension PersonViewController { } headerCell.fullNameLabel.font = WPStyleGuide.tableviewTextFont() headerCell.fullNameLabel.textColor = .text - headerCell.fullNameLabel.text = person.fullName + headerCell.fullNameLabel.text = isEmailFollower ? person.displayName : person.fullName headerCell.userNameLabel.font = WPStyleGuide.tableviewSectionHeaderFont() headerCell.userNameLabel.textColor = .primary - headerCell.userNameLabel.text = "@" + person.username + headerCell.userNameLabel.text = person.username.count > 0 ? "@" + person.username : "" refreshGravatarImage(in: headerCell.gravatarImageView) } @@ -427,8 +449,8 @@ private extension PersonViewController { func configureRemoveCell(_ cell: UITableViewCell) { WPStyleGuide.configureTableViewDestructiveActionCell(cell) - let removeFormat = NSLocalizedString("Remove @%@", comment: "Remove User. Verb") - let removeText = String(format: removeFormat, person.username) + let removeFormat = NSLocalizedString("Remove %@", comment: "Remove User. Verb") + let removeText = String(format: removeFormat, isEmailFollower ? person.displayName : "@" + person.username) cell.textLabel?.text = removeText as String cell.isHidden = !isRemoveEnabled } @@ -437,17 +459,20 @@ private extension PersonViewController { cell.textLabel?.text = NSLocalizedString("First Name", comment: "User's First Name") cell.detailTextLabel?.text = person.firstName cell.isHidden = isFullnamePrivate + cell.isUserInteractionEnabled = false } func configureLastNameCell(_ cell: UITableViewCell) { cell.textLabel?.text = NSLocalizedString("Last Name", comment: "User's Last Name") cell.detailTextLabel?.text = person.lastName cell.isHidden = isFullnamePrivate + cell.isUserInteractionEnabled = false } func configureDisplayNameCell(_ cell: UITableViewCell) { cell.textLabel?.text = NSLocalizedString("Display Name", comment: "User's Display Name") cell.detailTextLabel?.text = person.displayName + cell.isUserInteractionEnabled = false } func configureRoleCell(_ cell: UITableViewCell) { @@ -527,6 +552,8 @@ private extension PersonViewController { return isFollower == true case .Viewer: return isViewer == true + case .Email: + return isEmailFollower } } @@ -546,6 +573,14 @@ private extension PersonViewController { return person as? Follower } + var isEmailFollower: Bool { + return person is EmailFollower + } + + var emailFollower: EmailFollower? { + return person as? EmailFollower + } + var isViewer: Bool { return viewer != nil } @@ -561,10 +596,51 @@ private extension PersonViewController { case .Viewer: return .viewer case .User: - guard let service = RoleService(blog: blog, context: context) else { - return nil - } - return service.getRole(slug: person.role)?.toUnmanaged() + return try? Role.lookup(withBlogID: blog.objectID, slug: person.role, in: context)?.toUnmanaged() + case .Email: + return .follower } } } + +// MARK: - Jetpack powered badge +extension PersonViewController { + + private static let jetpackBadgeHeight: CGFloat = 96 + private static func shouldShowJetpackBadge() -> Bool { + JetpackBrandingVisibility.all.enabled && + JetpackBrandingCoordinator.shouldShowBannerForJetpackDependentFeatures() + } + private var lastSection: Int { + viewModel.count - 1 + } + + override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + guard + section == lastSection, + Self.shouldShowJetpackBadge() + else { + return nil + } + let textProvider = JetpackBrandingTextProvider(screen: JetpackBadgeScreen.person) + return JetpackButton.makeBadgeView(title: textProvider.brandingText(), + target: self, + selector: #selector(jetpackButtonTapped)) + } + + override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + guard + section == lastSection, + Self.shouldShowJetpackBadge() + else { + return UITableView.automaticDimension + } + + return Self.jetpackBadgeHeight + } + + @objc private func jetpackButtonTapped() { + JetpackBrandingCoordinator.presentOverlay(from: self) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBadgeTapped(screen: .person) + } +} diff --git a/WordPress/Classes/ViewRelated/People/RoleViewController.swift b/WordPress/Classes/ViewRelated/People/RoleViewController.swift index 5b1259062435..33d45da76749 100644 --- a/WordPress/Classes/ViewRelated/People/RoleViewController.swift +++ b/WordPress/Classes/ViewRelated/People/RoleViewController.swift @@ -20,7 +20,7 @@ class RoleViewController: UITableViewController { /// Activity Spinner, to be animated during Backend Interaction /// - fileprivate let activityIndicator = UIActivityIndicatorView(style: .gray) + fileprivate let activityIndicator = UIActivityIndicatorView(style: .medium) // MARK: - View Lifecyle Methods override func viewDidLoad() { diff --git a/WordPress/Classes/ViewRelated/Plans/PlanComparisonViewController.swift b/WordPress/Classes/ViewRelated/Plans/PlanComparisonViewController.swift index a39513e807dc..2bae0dc75498 100644 --- a/WordPress/Classes/ViewRelated/Plans/PlanComparisonViewController.swift +++ b/WordPress/Classes/ViewRelated/Plans/PlanComparisonViewController.swift @@ -13,7 +13,7 @@ class PlanComparisonViewController: PagedViewController { fileprivate let detailViewControllers: [PlanDetailViewController] lazy fileprivate var cancelXButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: Gridicon.iconOfType(.cross), style: .plain, target: self, action: #selector(PlanComparisonViewController.closeTapped)) + let button = UIBarButtonItem(image: .gridicon(.cross), style: .plain, target: self, action: #selector(PlanComparisonViewController.closeTapped)) button.accessibilityLabel = NSLocalizedString("Close", comment: "Dismiss the current view") return button diff --git a/WordPress/Classes/ViewRelated/Plans/PlanListViewController.swift b/WordPress/Classes/ViewRelated/Plans/PlanListViewController.swift index c3cf8e98158e..82c95e93a537 100644 --- a/WordPress/Classes/ViewRelated/Plans/PlanListViewController.swift +++ b/WordPress/Classes/ViewRelated/Plans/PlanListViewController.swift @@ -33,7 +33,7 @@ final class PlanListViewController: UITableViewController, ImmuTablePresenter { override func viewDidLoad() { super.viewDidLoad() - WPStyleGuide.configureColors(view: view, tableView: tableView) + configureAppearance() ImmuTable.registerRows([PlanListRow.self], tableView: tableView) handler.viewModel = viewModel.tableViewModelWithPresenter(self) updateNoResults() @@ -43,12 +43,12 @@ final class PlanListViewController: UITableViewController, ImmuTablePresenter { func syncPlans() { let context = ContextManager.shared.mainContext - let accountService = AccountService(managedObjectContext: context) - guard let account = accountService.defaultWordPressComAccount() else { + + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) else { return } - let plansService = PlanService.init(managedObjectContext: ContextManager.sharedInstance().mainContext) + let plansService = PlanService(coreDataStack: ContextManager.shared) plansService.getWpcomPlans(account, success: { [weak self] in self?.updateViewModel() @@ -59,15 +59,21 @@ final class PlanListViewController: UITableViewController, ImmuTablePresenter { } func updateViewModel() { - let service = PlanService.init(managedObjectContext: ContextManager.sharedInstance().mainContext) - let allPlans = service.allPlans() + let contextManager = ContextManager.shared + let service = PlanService(coreDataStack: contextManager) + let allPlans = service.allPlans(in: contextManager.mainContext) guard allPlans.count > 0 else { viewModel = .error return } - viewModel = .ready(allPlans, service.allPlanFeatures()) + viewModel = .ready(allPlans, service.allPlanFeatures(in: contextManager.mainContext)) } + func configureAppearance() { + WPStyleGuide.configureColors(view: view, tableView: tableView) + + extendedLayoutIncludesOpaqueBars = true + } // MARK: - ImmuTablePresenter diff --git a/WordPress/Classes/ViewRelated/Plugins/CollectionViewContainerRow.swift b/WordPress/Classes/ViewRelated/Plugins/CollectionViewContainerRow.swift index d9de406d1577..ba12b35ea476 100644 --- a/WordPress/Classes/ViewRelated/Plugins/CollectionViewContainerRow.swift +++ b/WordPress/Classes/ViewRelated/Plugins/CollectionViewContainerRow.swift @@ -134,24 +134,21 @@ class CollectionViewContainerCell: UITableViewCell { oldValue?.removeFromView() guard let noResultsView = noResultsView else { - return + return } - let containerView = UIView(frame: collectionView.frame) - containerView.translatesAutoresizingMaskIntoConstraints = false - noResultsView.view.backgroundColor = .clear - noResultsView.view.frame = containerView.frame + noResultsView.view.frame = collectionView.frame noResultsView.view.frame.origin.y = 0 + noResultsView.view.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(noResultsView.view) - addSubview(containerView) + addSubview(noResultsView.view) NSLayoutConstraint.activate([ - containerView.topAnchor.constraint(equalTo: collectionView.topAnchor), - containerView.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor), - containerView.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor) + noResultsView.view.topAnchor.constraint(equalTo: collectionView.topAnchor), + noResultsView.view.bottomAnchor.constraint(equalTo: collectionView.bottomAnchor), + noResultsView.view.leadingAnchor.constraint(equalTo: collectionView.leadingAnchor), + noResultsView.view.trailingAnchor.constraint(equalTo: collectionView.trailingAnchor) ]) } } @@ -210,12 +207,15 @@ class CollectionViewContainerCell: UITableViewCell { collectionView.showsHorizontalScrollIndicator = false collectionView.backgroundColor = .clear - self.addSubview(collectionView) + contentView.addSubview(collectionView) collectionView.topAnchor.constraint(equalTo: titleLabel.lastBaselineAnchor, constant: Constants.labelVerticalSpacing).isActive = true - collectionView.leadingAnchor.constraint(equalTo: self.leadingAnchor).isActive = true - collectionView.trailingAnchor.constraint(equalTo: self.trailingAnchor).isActive = true - collectionView.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true + collectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true + collectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true + collectionView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true + + contentView.clipsToBounds = false + collectionView.clipsToBounds = false } override func prepareForReuse() { diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginDetailViewHeaderCell.swift b/WordPress/Classes/ViewRelated/Plugins/PluginDetailViewHeaderCell.swift index 06d550d6bbc3..59f361ab6178 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginDetailViewHeaderCell.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginDetailViewHeaderCell.swift @@ -19,7 +19,7 @@ class PluginDetailViewHeaderCell: UITableViewCell { headerImageView?.isHidden = true } - let iconPlaceholder = Gridicon.iconOfType(.plugins, withSize: CGSize(width: 40, height: 40)) + let iconPlaceholder = UIImage.gridicon(.plugins, size: CGSize(width: 40, height: 40)) iconImageView?.downloadImage(from: directoryEntry.icon, placeholderImage: iconPlaceholder) iconImageView?.backgroundColor = .listForeground iconImageView?.tintColor = .neutral diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryAccessoryItem.swift b/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryAccessoryItem.swift index 6783cbf4f07b..293a6d4118c2 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryAccessoryItem.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryAccessoryItem.swift @@ -34,7 +34,7 @@ struct PluginDirectoryAccessoryItem { } private static func active() -> UIView { - let icon = Gridicon.iconOfType(.checkmark, withSize: Constants.imageSize) + let icon = UIImage.gridicon(.checkmark, size: Constants.imageSize) let color = UIColor.success let text = NSLocalizedString("Active", comment: "Describes a status of a plugin") @@ -42,7 +42,7 @@ struct PluginDirectoryAccessoryItem { } private static func inactive() -> UIView { - let icon = Gridicon.iconOfType(.cross, withSize: Constants.imageSize) + let icon = UIImage.gridicon(.cross, size: Constants.imageSize) let color = UIColor.neutral(.shade40) let text = NSLocalizedString("Inactive", comment: "Describes a status of a plugin") @@ -50,7 +50,7 @@ struct PluginDirectoryAccessoryItem { } private static func needsUpdate() -> UIView { - let icon = Gridicon.iconOfType(.sync, withSize: Constants.imageSize) + let icon = UIImage.gridicon(.sync, size: Constants.imageSize) let color = UIColor.warning let text = NSLocalizedString("Needs Update", comment: "Describes a status of a plugin") @@ -58,7 +58,7 @@ struct PluginDirectoryAccessoryItem { } private static func updating() -> UIView { - let icon = Gridicon.iconOfType(.sync, withSize: Constants.imageSize) + let icon = UIImage.gridicon(.sync, size: Constants.imageSize) let color = UIColor.warning let text = NSLocalizedString("Updating", comment: "Describes a status of a plugin") @@ -128,10 +128,10 @@ struct PluginDirectoryAccessoryItem { let color: UIColor if i <= Int(wholeStars) { - image = Gridicon.iconOfType(.star, withSize: Constants.starImageSize) + image = .gridicon(.star, size: Constants.starImageSize) color = .primary(.shade40) } else { - image = Gridicon.iconOfType(.starOutline, withSize: Constants.starImageSize) + image = .gridicon(.starOutline, size: Constants.starImageSize) color = .neutral(.shade10) } @@ -155,7 +155,7 @@ struct PluginDirectoryAccessoryItem { container.widthAnchor.constraint(equalToConstant: size.width) ]) - let leftHalf = UIImageView(image: Gridicon.iconOfType(.star, withSize: size)) + let leftHalf = UIImageView(image: .gridicon(.star, size: size)) leftHalf.tintColor = color leftHalf.translatesAutoresizingMaskIntoConstraints = false leftHalf.contentMode = .left @@ -164,7 +164,7 @@ struct PluginDirectoryAccessoryItem { leftHalf.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) leftHalf.addConstraint(NSLayoutConstraint(item: leftHalf, attribute: .width, relatedBy: .equal, toItem: leftHalf, attribute: .height, multiplier: 0.5, constant: 0)) - let rightHalf = UIImageView(image: Gridicon.iconOfType(.starOutline, withSize: size)) + let rightHalf = UIImageView(image: .gridicon(.starOutline, size: size)) rightHalf.tintColor = .neutral(.shade10) rightHalf.translatesAutoresizingMaskIntoConstraints = false rightHalf.contentMode = .right diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryCollectionViewCell.swift b/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryCollectionViewCell.swift index dbfc5d880357..41c94bf453e3 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryCollectionViewCell.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryCollectionViewCell.swift @@ -47,7 +47,7 @@ class PluginDirectoryCollectionViewCell: UICollectionViewCell { } func configure(name: String, author: String, image: URL?) { - let iconPlaceholder = Gridicon.iconOfType(.plugins, withSize: CGSize(width: 98, height: 98)) + let iconPlaceholder = UIImage.gridicon(.plugins, size: CGSize(width: 98, height: 98)) if let imageURL = image { logoImageView?.downloadImage(from: imageURL, placeholderImage: iconPlaceholder) diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryViewController.swift b/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryViewController.swift index d19ab3f9c36b..149ac150e74b 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryViewController.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryViewController.swift @@ -23,15 +23,6 @@ class PluginDirectoryViewController: UITableViewController { fatalError("init(coder:) has not been implemented") } - @objc convenience init?(blog: Blog) { - guard let site = JetpackSiteRef(blog: blog) else { - return nil - } - - self.init(site: site) - } - - override func viewDidLoad() { super.viewDidLoad() @@ -70,7 +61,7 @@ class PluginDirectoryViewController: UITableViewController { containerView.addSubview(searchController.searchBar) tableView.tableHeaderView = containerView - tableView.scrollIndicatorInsets.top = searchController.searchBar.bounds.height + tableView.verticalScrollIndicatorInsets.top = searchController.searchBar.bounds.height // for some... particlar reason, which I haven't been able to fully track down, if the searchBar is added directly // as the tableHeaderView, the UITableView sort of freaks out and adds like 400pts of random padding // below the content of the tableView. Wrapping it in this container fixes it ¯\_(ツ)_/¯ @@ -83,7 +74,6 @@ class PluginDirectoryViewController: UITableViewController { let controller = UISearchController(searchResultsController: resultsController) controller.obscuresBackgroundDuringPresentation = false - controller.dimsBackgroundDuringPresentation = false controller.searchResultsUpdater = self controller.delegate = self @@ -153,7 +143,7 @@ extension PluginDirectoryViewController: UISearchControllerDelegate { searchController.searchBar.becomeFirstResponder() } updateTableHeaderSize() - tableView.scrollIndicatorInsets.top = searchWrapperView.bounds.height + tableView.verticalScrollIndicatorInsets.top = searchWrapperView.bounds.height tableView.contentInset.top = 0 } @@ -225,10 +215,23 @@ extension PluginDirectoryViewController: PluginListPresenter { if let listType = listType { let properties = ["type": listType] - WPAppAnalytics.track(.openedPluginList, withProperties: properties, withBlogID: site.siteID as NSNumber) + let siteID: NSNumber? = (site.isSelfHostedWithoutJetpack ? nil : site.siteID) as NSNumber? + + WPAppAnalytics.track(.openedPluginList, withProperties: properties, withBlogID: siteID) } let listVC = PluginListViewController(site: site, query: query) navigationController?.pushViewController(listVC, animated: true) } } + +extension BlogDetailsViewController { + + @objc func makePluginDirectoryViewController(blog: Blog) -> PluginDirectoryViewController? { + guard let site = JetpackSiteRef(blog: blog) else { + return nil + } + + return PluginDirectoryViewController(site: site) + } +} diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryViewModel.swift b/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryViewModel.swift index 39bf69966344..33305cc72e6b 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryViewModel.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginDirectoryViewModel.swift @@ -2,7 +2,7 @@ import Foundation import WordPressFlux import Gridicons -protocol PluginListPresenter: class { +protocol PluginListPresenter: AnyObject { func present(site: JetpackSiteRef, query: PluginQuery) } diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginListRow.swift b/WordPress/Classes/ViewRelated/Plugins/PluginListRow.swift index 2109dd454a00..351728293338 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginListRow.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginListRow.swift @@ -22,7 +22,7 @@ struct PluginListRow: ImmuTableRow { cell.nameLabel?.text = name cell.authorLabel?.text = author - let iconPlaceholder = Gridicon.iconOfType(.plugins, withSize: iconSize) + let iconPlaceholder = UIImage.gridicon(.plugins, size: iconSize) cell.iconImageView?.cancelImageDownload() if let iconURL = iconURL { diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginListViewController.swift b/WordPress/Classes/ViewRelated/Plugins/PluginListViewController.swift index 4e1ac9de6a67..d3b37deea32a 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginListViewController.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginListViewController.swift @@ -95,6 +95,17 @@ class PluginListViewController: UITableViewController, ImmuTablePresenter { case .replace: tableView.reloadData() case .selective(let changedRows): + guard + // There is a strange scenario where the view model has multiple + // sections defined but the tableView thinks it has only zero, + // so make sure they are in agreement. + // See https://github.com/wordpress-mobile/WordPress-iOS/issues/14790 + tableViewModel.sections.count == tableView.numberOfSections + else { + tableView.reloadData() + return + } + if tableView.numberOfRows(inSection: 0) == changedRows.count { let indexPaths = changedRows.map { IndexPath(row: $0, section: 0) } tableView.reloadRows(at: indexPaths, with: .none) diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginListViewModel.swift b/WordPress/Classes/ViewRelated/Plugins/PluginListViewModel.swift index 1861ba73cfa8..6ade734fdcc4 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginListViewModel.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginListViewModel.swift @@ -1,7 +1,7 @@ import WordPressKit import WordPressFlux -protocol PluginPresenter: class { +protocol PluginPresenter: AnyObject { func present(plugin: Plugin, capabilities: SitePluginCapabilities) func present(directoryEntry: PluginDirectoryEntry) } diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginViewController.swift b/WordPress/Classes/ViewRelated/Plugins/PluginViewController.swift index 0d56acf00806..d77aa47c9d62 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginViewController.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginViewController.swift @@ -140,19 +140,21 @@ private extension PluginViewController { noResultsViewController.bindViewModel(viewModel) - addAsSubviewIfNeeded(noResultsViewController.view) - addChildController(noResultsViewController) + addAsSubviewIfNeeded(noResultsViewController) } - private func addAsSubviewIfNeeded(_ view: UIView) { - if view.superview != tableView { - tableView.addSubview(withFadeAnimation: view) + private func addAsSubviewIfNeeded(_ noResultsViewController: NoResultsViewController) { + if noResultsViewController.view.superview != tableView { + tableView.addSubview(withFadeAnimation: noResultsViewController.view) + addChild(noResultsViewController) + noResultsViewController.didMove(toParent: self) + noResultsViewController.view.translatesAutoresizingMaskIntoConstraints = false + tableView.pinSubviewToSafeArea(noResultsViewController.view) } } private func addChildController(_ controller: UIViewController) { - addChild(controller) - controller.didMove(toParent: self) + } private func getNoResultsViewController() -> NoResultsViewController { diff --git a/WordPress/Classes/ViewRelated/Plugins/PluginViewModel.swift b/WordPress/Classes/ViewRelated/Plugins/PluginViewModel.swift index 2bef3484f0ad..b01a48adea0f 100644 --- a/WordPress/Classes/ViewRelated/Plugins/PluginViewModel.swift +++ b/WordPress/Classes/ViewRelated/Plugins/PluginViewModel.swift @@ -63,7 +63,16 @@ class PluginViewModel: Observable { private let store: PluginStore init(plugin: Plugin, capabilities: SitePluginCapabilities, site: JetpackSiteRef, store: PluginStore = StoreContainer.shared.plugin) { - self.state = .plugin(plugin) + + var updatedPlugin = plugin + + // Self hosted non-Jetpack plugins may not have the directory entry set + // attempt to find one for this plugin + if updatedPlugin.directoryEntry == nil { + updatedPlugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.id) + } + + self.state = .plugin(updatedPlugin) self.capabilities = capabilities self.site = site self.isInstallingPlugin = false @@ -71,18 +80,27 @@ class PluginViewModel: Observable { queryReceipt = nil storeReceipt = store.onChange { [weak self] in - guard let plugin = store.getPlugin(id: plugin.id, site: site) else { + guard var plugin = store.getPlugin(id: plugin.id, site: site) else { self?.dismiss?() return } + if plugin.directoryEntry == nil { + plugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.id) + } + self?.state = .plugin(plugin) } } convenience init(directoryEntry: PluginDirectoryEntry, site: JetpackSiteRef, store: PluginStore = StoreContainer.shared.plugin) { let state: State - if let plugin = store.getPlugin(slug: directoryEntry.slug, site: site) { + if var plugin = store.getPlugin(slug: directoryEntry.slug, site: site) { + // Self hosted non-Jetpack plugins may not have the directory entry set + if plugin.directoryEntry == nil { + plugin.directoryEntry = directoryEntry + } + state = .plugin(plugin) } else { state = .directoryEntry(directoryEntry) @@ -92,7 +110,11 @@ class PluginViewModel: Observable { convenience init(slug: String, site: JetpackSiteRef, store: PluginStore = StoreContainer.shared.plugin) { let state: State - if let plugin = store.getPlugin(slug: slug, site: site) { + if var plugin = store.getPlugin(slug: slug, site: site) { + if plugin.directoryEntry == nil { + plugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.id) + } + state = .plugin(plugin) } else { state = .loading @@ -115,7 +137,11 @@ class PluginViewModel: Observable { return } - if let plugin = self?.store.getPlugin(slug: entry.slug, site: site) { + if var plugin = self?.store.getPlugin(slug: entry.slug, site: site) { + if plugin.directoryEntry == nil { + plugin.directoryEntry = store.getPluginDirectoryEntry(slug: plugin.id) + } + self?.state = .plugin(plugin) } else { self?.state = .directoryEntry(entry) @@ -270,7 +296,7 @@ class PluginViewModel: Observable { private func activeRow(plugin: Plugin?) -> ImmuTableRow? { guard let activationPlugin = plugin, - activationPlugin.state.deactivateAllowed else { return nil } + activationPlugin.deactivateAllowed else { return nil } return SwitchRow( title: NSLocalizedString("Active", comment: "Whether a plugin is active on a site"), @@ -301,8 +327,9 @@ class PluginViewModel: Observable { private func removeRow(plugin: Plugin?, capabilities: SitePluginCapabilities?) -> ImmuTableRow? { guard let pluginToRemove = plugin, - let siteCapabilities = capabilities, - siteCapabilities.modify && pluginToRemove.state.deactivateAllowed else { return nil } + let siteCapabilities = capabilities, + siteCapabilities.modify, + pluginToRemove.deactivateAllowed else { return nil } return DestructiveButtonRow( title: NSLocalizedString("Remove Plugin", comment: "Button to remove a plugin from a site"), @@ -313,10 +340,10 @@ class PluginViewModel: Observable { accessibilityIdentifier: "remove-plugin") } - private func settingsLinkRow(state: PluginState?) -> ImmuTableRow? { - guard let pluginState = state, - let settingsURL = pluginState.settingsURL, - pluginState.deactivateAllowed == true else { + private func settingsLinkRow(plugin: Plugin?) -> ImmuTableRow? { + guard let plugin, + let settingsURL = plugin.state.settingsURL, + plugin.deactivateAllowed == true else { return nil } @@ -419,7 +446,7 @@ class PluginViewModel: Observable { let active = activeRow(plugin: plugin) let autoupdates = autoUpdatesRow(plugin: plugin, capabilities: capabilities) - let settingsLink = settingsLinkRow(state: plugin?.state) + let settingsLink = settingsLinkRow(plugin: plugin) let wpOrgPluginLink = wpOrgLinkRow(directoryEntry: directoryEntry, state: plugin?.state) let homeLink = homeLinkRow(state: plugin?.state) @@ -508,7 +535,12 @@ class PluginViewModel: Observable { } private func presentDomainRegistration(for directoryEntry: PluginDirectoryEntry) { - let controller = RegisterDomainSuggestionsViewController.instance(site: site, domainPurchasedCallback: { [weak self] domain in + guard let blog = BlogService.blog(with: site) else { + DDLogError("Error obtaining the blog from the jetpack site ref.") + return + } + + let controller = RegisterDomainSuggestionsViewController.instance(site: blog, domainPurchasedCallback: { [weak self] domain in guard let strongSelf = self, let atHelper = AutomatedTransferHelper(site: strongSelf.site, plugin: directoryEntry) else { @@ -524,7 +556,7 @@ class PluginViewModel: Observable { } private func presentBrowser(`for` url: URL) { - let controller = WebViewControllerFactory.controller(url: url) + let controller = WebViewControllerFactory.controller(url: url, source: "plugins") let navigationController = UINavigationController(rootViewController: controller) self.present?(navigationController) } diff --git a/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift b/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift index ade3d3f77b0e..d49d057d7bbf 100644 --- a/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift @@ -3,6 +3,7 @@ import Gridicons import CocoaLumberjack import WordPressShared import wpxmlrpc +import WordPressFlux // FIXME: comparison operators with optionals were removed from the Swift Standard Libary. // Consider refactoring the code to use the non-optional operators. @@ -128,12 +129,6 @@ class AbstractPostListViewController: UIViewController, @IBOutlet var filterTabBar: FilterTabBar! - @objc lazy var addButton: UIBarButtonItem = { - let addButton = UIBarButtonItem(image: Gridicon.iconOfType(.plus), style: .plain, target: self, action: #selector(handleAddButtonTapped)) - addButton.accessibilityLabel = NSLocalizedString("Add", comment: "Button to create a new post.") - return addButton - }() - @objc var searchController: UISearchController! @objc var recentlyTrashedPostObjectIDs = [NSManagedObjectID]() // IDs of trashed posts. Cleared on refresh or when filter changes. @@ -208,8 +203,8 @@ class AbstractPostListViewController: UIViewController, override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - if searchController.isActive { - searchController.isActive = false + if searchController?.isActive == true { + searchController?.isActive = false } dismissAllNetworkErrorNotices() @@ -223,17 +218,12 @@ class AbstractPostListViewController: UIViewController, return type(of: self).defaultHeightForFooterView } - override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent - } - func configureNavbar() { // IMPORTANT: this code makes sure that the back button in WPPostViewController doesn't show // this VC's title. // let backButton = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) navigationItem.backBarButtonItem = backButton - navigationItem.rightBarButtonItem = addButton } func configureFilterBar() { @@ -310,7 +300,7 @@ class AbstractPostListViewController: UIViewController, definesPresentationContext = true searchController = UISearchController(searchResultsController: nil) - searchController.dimsBackgroundDuringPresentation = false + searchController.obscuresBackgroundDuringPresentation = false searchController.delegate = self searchController.searchResultsUpdater = self @@ -575,10 +565,6 @@ class AbstractPostListViewController: UIViewController, WPAnalytics.track(.postListPullToRefresh, withProperties: propertiesForAnalytics()) } - @objc func handleAddButtonTapped() { - createPost() - } - // MARK: - Synching @objc func automaticallySyncIfAppropriate() { @@ -812,7 +798,9 @@ class AbstractPostListViewController: UIViewController, return } - ghostableTableView.startGhostAnimation() + if isViewOnScreen() { + ghostableTableView.startGhostAnimation() + } ghostableTableView.isHidden = false noResultsViewController.view.isHidden = true } @@ -910,7 +898,7 @@ class AbstractPostListViewController: UIViewController, // MARK: - Actions - @objc func publishPost(_ apost: AbstractPost) { + @objc func publishPost(_ apost: AbstractPost, completion: (() -> Void)? = nil) { let title = NSLocalizedString("Are you sure you want to publish?", comment: "Title of the message shown when the user taps Publish in the post list.") let cancelTitle = NSLocalizedString("Cancel", comment: "Button shown when the author is asked for publishing confirmation.") @@ -924,6 +912,7 @@ class AbstractPostListViewController: UIViewController, WPAnalytics.track(.postListPublishAction, withProperties: self.propertiesForAnalytics()) PostCoordinator.shared.publish(apost) + completion?() } present(alertController, animated: true) @@ -940,12 +929,15 @@ class AbstractPostListViewController: UIViewController, let post = apost.hasRevision() ? apost.revision! : apost - let controller = PreviewWebKitViewController(post: post) + let controller = PreviewWebKitViewController(post: post, source: "posts_pages_view_post") controller.trackOpenEvent() // NOTE: We'll set the title to match the title of the View action button. // If the button title changes we should also update the title here. controller.navigationItem.title = NSLocalizedString("View", comment: "Verb. The screen title shown when viewing a post inside the app.") let navWrapper = LightNavigationController(rootViewController: controller) + if navigationController?.traitCollection.userInterfaceIdiom == .pad { + navWrapper.modalPresentationStyle = .fullScreen + } navigationController?.present(navWrapper, animated: true) } @@ -1012,6 +1004,12 @@ class AbstractPostListViewController: UIViewController, recentlyTrashedPostObjectIDs.remove(at: index) } + if filterSettings.currentPostListFilter().filterType != .draft { + // Needed or else the post will remain in the published list. + updateAndPerformFetchRequest() + tableView.reloadData() + } + let postService = PostService(managedObjectContext: ContextManager.sharedInstance().mainContext) postService.restore(apost, success: { [weak self] in @@ -1064,6 +1062,16 @@ class AbstractPostListViewController: UIViewController, } } + @objc func copyPostLink(_ apost: AbstractPost) { + let pasteboard = UIPasteboard.general + guard let link = apost.permaLink else { return } + pasteboard.string = link as String + let noticeTitle = NSLocalizedString("Link Copied to Clipboard", comment: "Link copied to clipboard notice title") + let notice = Notice(title: noticeTitle, feedbackType: .success) + ActionDispatcher.dispatch(NoticeAction.dismiss) // Dismiss any old notices + ActionDispatcher.dispatch(NoticeAction.post(notice)) + } + @objc func promptThatPostRestoredToFilter(_ filter: PostListFilter) { assert(false, "You should implement this method in the subclass") } @@ -1186,6 +1194,8 @@ extension AbstractPostListViewController: NetworkStatusDelegate { } } +extension AbstractPostListViewController: EditorAnalyticsProperties { } + // MARK: - NoResultsViewControllerDelegate extension AbstractPostListViewController: NoResultsViewControllerDelegate { diff --git a/WordPress/Classes/ViewRelated/Post/AuthorFilterButton.swift b/WordPress/Classes/ViewRelated/Post/AuthorFilterButton.swift index cee706b8b1bd..fea2e21f82a1 100644 --- a/WordPress/Classes/ViewRelated/Post/AuthorFilterButton.swift +++ b/WordPress/Classes/ViewRelated/Post/AuthorFilterButton.swift @@ -58,7 +58,7 @@ class AuthorFilterButton: UIControl { didSet { switch filterType { case .everyone: - authorImageView.image = Gridicon.iconOfType(.multipleUsers, withSize: Metrics.multipleUsersGravatarSize) + authorImageView.image = .gridicon(.multipleUsers, size: Metrics.multipleUsersGravatarSize) authorImageView.contentMode = .center case .user(let email): authorImageView.contentMode = .scaleAspectFill @@ -103,7 +103,7 @@ class AuthorFilterButton: UIControl { prepareForVoiceOver() } - private let gravatarPlaceholder: UIImage = Gridicon.iconOfType(.user, withSize: Metrics.gravatarSize) + private let gravatarPlaceholder: UIImage = .gridicon(.user, size: Metrics.gravatarSize) private enum Metrics { static let chevronSize = CGSize(width: 10.0, height: 5.0) diff --git a/WordPress/Classes/ViewRelated/Post/AuthorFilterViewController.swift b/WordPress/Classes/ViewRelated/Post/AuthorFilterViewController.swift index 62011506c667..e8182a810192 100644 --- a/WordPress/Classes/ViewRelated/Post/AuthorFilterViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/AuthorFilterViewController.swift @@ -48,11 +48,7 @@ class AuthorFilterViewController: UITableViewController { tableView.separatorColor = .clear tableView.isScrollEnabled = false tableView.showsVerticalScrollIndicator = false - if #available(iOS 13, *) { - tableView.contentInset = .zero - } else { - tableView.contentInset = UIEdgeInsets(top: -Metrics.topinset, left: 0, bottom: 0, right: 0) - } + tableView.contentInset = .zero } required init?(coder aDecoder: NSCoder) { @@ -235,13 +231,13 @@ private class AuthorFilterCell: UITableViewCell { didSet { switch filterType { case .everyone: - gravatarImageView.image = Gridicon.iconOfType(.multipleUsers, withSize: Metrics.multipleGravatarSize) + gravatarImageView.image = .gridicon(.multipleUsers, size: Metrics.multipleGravatarSize) gravatarImageView.contentMode = .center accessibilityHint = NSLocalizedString("Select to show everyone's posts.", comment: "Voiceover accessibility hint, informing the user they can select an item to show posts written by all users on the site") case .user(let email): gravatarImageView.contentMode = .scaleAspectFill - let placeholder = Gridicon.iconOfType(.user, withSize: Metrics.gravatarSize) + let placeholder = UIImage.gridicon(.user, size: Metrics.gravatarSize) if let email = email { gravatarImageView.downloadGravatarWithEmail(email, placeholderImage: placeholder) } else { diff --git a/WordPress/Classes/ViewRelated/Post/Categories/PostCategoriesViewController.h b/WordPress/Classes/ViewRelated/Post/Categories/PostCategoriesViewController.h deleted file mode 100644 index 0b152f2d165f..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Categories/PostCategoriesViewController.h +++ /dev/null @@ -1,33 +0,0 @@ -#import <UIKit/UIKit.h> -#import "PostCategory.h" - -typedef enum { - CategoriesSelectionModePost = 0, - CategoriesSelectionModeParent, - CategoriesSelectionModeBlogDefault -} CategoriesSelectionMode; - -@protocol PostCategoriesViewControllerDelegate; - - -@interface PostCategoriesViewController : UITableViewController - -@property (nonatomic, weak) id<PostCategoriesViewControllerDelegate>delegate; - -- (instancetype)initWithBlog:(Blog *)blog - currentSelection:(NSArray *)originalSelection - selectionMode:(CategoriesSelectionMode)selectionMode; - -- (BOOL)hasChanges; - -@end - - -@protocol PostCategoriesViewControllerDelegate <NSObject> - -@optional -- (void)postCategoriesViewController:(PostCategoriesViewController *)controller didSelectCategory:(PostCategory *)category; - -- (void)postCategoriesViewController:(PostCategoriesViewController *)controller didUpdateSelectedCategories:(NSSet *)categories; - -@end diff --git a/WordPress/Classes/ViewRelated/Post/Categories/PostCategoriesViewController.m b/WordPress/Classes/ViewRelated/Post/Categories/PostCategoriesViewController.m deleted file mode 100644 index 7650ab727df9..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Categories/PostCategoriesViewController.m +++ /dev/null @@ -1,334 +0,0 @@ -#import "PostCategoriesViewController.h" -#import "PostCategory.h" -#import "WPAddPostCategoryViewController.h" -#import "WPCategoryTree.h" -#import "CustomHighlightButton.h" -#import "PostCategoryService.h" -#import <WordPressShared/NSString+XMLExtensions.h> -#import <WordPressShared/WPTableViewCell.h> -#import "WordPress-Swift.h" - -static NSString * const CategoryCellIdentifier = @"CategoryCellIdentifier"; -static const CGFloat CategoryCellIndentation = 16.0; - -@interface PostCategoriesViewController () <WPAddPostCategoryViewControllerDelegate> - -@property (nonatomic, strong) Blog *blog; -@property (nonatomic, strong) NSMutableDictionary *categoryIndentationDict; -@property (nonatomic, strong) NSMutableArray *selectedCategories; -@property (nonatomic, strong) NSArray *originalSelection; -@property (nonatomic, strong) NSArray *categories; -@property (nonatomic, assign) CategoriesSelectionMode selectionMode; -@property (nonatomic, assign) BOOL addingNewCategory; -@property (nonatomic, assign) BOOL hasInitiallySyncedCategories; - -@end - -@implementation PostCategoriesViewController - -- (instancetype)initWithBlog:(Blog *)blog - currentSelection:(NSArray *)originalSelection - selectionMode:(CategoriesSelectionMode)selectionMode -{ - self = [super initWithStyle:UITableViewStyleGrouped]; - if (self) { - _selectionMode = selectionMode; - _blog = blog; - _originalSelection = originalSelection; - } - return self; -} - -- (void)viewDidLoad -{ - [super viewDidLoad]; - - self.tableView.accessibilityIdentifier = @"CategoriesList"; - self.tableView.cellLayoutMarginsFollowReadableWidth = YES; - [WPStyleGuide configureColorsForView:self.view andTableView:self.tableView]; - [WPStyleGuide configureAutomaticHeightRowsFor:self.tableView]; - - // Hide extra cell separators. - self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectZero]; - [self.tableView registerClass:[WPTableViewCell class] forCellReuseIdentifier:CategoryCellIdentifier]; - - [self setupRefreshControl]; - - // Show the add category button if we're selecting categories for a post. - if (self.selectionMode == CategoriesSelectionModePost || self.selectionMode == CategoriesSelectionModeBlogDefault) { - UIBarButtonItem *rightBarButtonItem = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"icon-post-add"] - style:UIBarButtonItemStylePlain - target:self - action:@selector(showAddNewCategory)]; - self.navigationItem.rightBarButtonItem = rightBarButtonItem; - } - - switch (self.selectionMode) { - case (CategoriesSelectionModeParent): { - self.title = NSLocalizedString(@"Parent Category", @"Title for selecting parent category of a category"); - } break; - case (CategoriesSelectionModePost): { - self.title = NSLocalizedString(@"Post Categories", @"Title for selecting categories for a post"); - } break; - case (CategoriesSelectionModeBlogDefault): { - self.title = NSLocalizedString(@"Default Category", @"Title for selecting a default category for a post"); - } - } -} - -- (void)viewWillAppear:(BOOL)animated -{ - [super viewWillAppear:animated]; - - [self reloadCategoriesTableViewData]; - - if (!self.hasInitiallySyncedCategories) { - self.hasInitiallySyncedCategories = YES; - [self syncCategories]; - } -} - -#pragma mark - Instance Methods - -- (void)setupRefreshControl -{ - if (self.refreshControl) { - return; - } - UIRefreshControl *refreshControl = [[UIRefreshControl alloc] init]; - [refreshControl addTarget:self action:@selector(refreshCategoriesWithInteraction:) forControlEvents:UIControlEventValueChanged]; - self.refreshControl = refreshControl; -} - -- (BOOL)hasChanges -{ - return [self.originalSelection isEqualToArray:self.selectedCategories]; -} - -- (void)showAddNewCategory -{ - WPAddPostCategoryViewController *addCategoryViewController = [[WPAddPostCategoryViewController alloc] initWithBlog:self.blog]; - addCategoryViewController.delegate = self; - - UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:addCategoryViewController]; - navigationController.modalPresentationStyle = UIModalPresentationFormSheet; - - [self presentViewController:navigationController - animated:YES - completion:nil]; -} - -- (void)syncCategories -{ - __weak __typeof__(self) weakSelf = self; - PostCategoryService *service = [[PostCategoryService alloc] initWithManagedObjectContext:self.blog.managedObjectContext]; - [service syncCategoriesForBlog:self.blog success:^{ - [weakSelf reloadCategoriesTableViewData]; - [weakSelf.refreshControl endRefreshing]; - } failure:^(NSError * _Nonnull error) { - [weakSelf.refreshControl endRefreshing]; - }]; -} - -- (void)reloadCategoriesTableViewData -{ - if (!self.selectedCategories) { - self.selectedCategories = [self.originalSelection mutableCopy]; - } - self.categoryIndentationDict = [NSMutableDictionary dictionary]; - - // Get sorted categories by parent/child relationship - WPCategoryTree *tree = [[WPCategoryTree alloc] initWithParent:nil]; - [tree getChildrenFromObjects:[self.blog sortedCategories]]; - self.categories = [tree getAllObjects]; - - // Get the indentation level of each category. - NSUInteger count = [self.categories count]; - - NSMutableDictionary *categoryDict = [NSMutableDictionary dictionary]; - for (NSInteger i = 0; i < count; i++) { - PostCategory *category = [self.categories objectAtIndex:i]; - [categoryDict setObject:category forKey:category.categoryID]; - } - - for (NSInteger i = 0; i < count; i++) { - PostCategory *category = [self.categories objectAtIndex:i]; - - NSInteger indentationLevel = [self indentationLevelForCategory:category.parentID categoryCollection:categoryDict]; - - [self.categoryIndentationDict setValue:[NSNumber numberWithInteger:indentationLevel] - forKey:[category.categoryID stringValue]]; - } - - // Remove any previously selected category objects that are no longer available. - NSArray *selectedCategories = [self.selectedCategories copy]; - for (PostCategory *category in selectedCategories) { - if ([category isDeleted] || ![self.blog.sortedCategories containsObject:category]) { - [self.selectedCategories removeObject:category]; - } - } - // Notify the delegate of any changes for selectedCategories. - if (self.selectedCategories.count != selectedCategories.count) { - if ([self.delegate respondsToSelector:@selector(postCategoriesViewController:didUpdateSelectedCategories:)]) { - [self.delegate postCategoriesViewController:self didUpdateSelectedCategories:[NSSet setWithArray:self.selectedCategories]]; - } - } - - [self.tableView reloadData]; -} - -- (NSInteger)indentationLevelForCategory:(NSNumber *)parentID categoryCollection:(NSMutableDictionary *)categoryDict -{ - if ([parentID intValue] == 0) { - return 0; - } - - PostCategory *category = [categoryDict objectForKey:parentID]; - return ([self indentationLevelForCategory:category.parentID categoryCollection:categoryDict]) + 1; -} - -#pragma mark - Button Actions - -- (void)refreshCategoriesWithInteraction:(UIRefreshControl *)refreshControl -{ - [self syncCategories]; -} - -#pragma mark - UITableView Delegate & DataSource - -- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView -{ - return 1; -} - -- (NSInteger)tableView:(UITableView *)aTableView numberOfRowsInSection:(NSInteger)section -{ - NSInteger result = [self.categories count]; - - if (self.selectionMode == CategoriesSelectionModeParent) { - result += 1; - } - - return result; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath -{ - WPTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CategoryCellIdentifier forIndexPath:indexPath]; - - NSInteger row = indexPath.row; // Use this index for the remainder for this method. - - // When showing this VC in mode CategoriesSelectionModeParent, we want the first item to be - // "No Category" and come up in red, to allow the user to select no category at all. - // - if (self.selectionMode == CategoriesSelectionModeParent) { - if (row == 0) { - [WPStyleGuide configureTableViewDestructiveActionCell:cell]; - cell.textLabel.textAlignment = NSTextAlignmentNatural; - - cell.textLabel.text = NSLocalizedString(@"No Category", - @"Text shown (to select no-category) in the parent-category-selection screen when creating a new category."); - - if (self.selectedCategories == nil) { - cell.accessoryType = UITableViewCellAccessoryCheckmark; - } else { - cell.accessoryType = UITableViewCellAccessoryNone; - } - - return cell; - } else { - row -= 1; - } - } - - PostCategory* category = self.categories[row]; - NSInteger indentationLevel = [[self.categoryIndentationDict objectForKey:[category.categoryID stringValue]] integerValue]; - cell.indentationLevel = indentationLevel; - cell.indentationWidth = CategoryCellIndentation; - cell.textLabel.text = [category.categoryName stringByDecodingXMLCharacters]; - [WPStyleGuide configureTableViewCell:cell]; - - if ([self.selectedCategories containsObject:category]) { - cell.accessoryType = UITableViewCellAccessoryCheckmark; - } else { - cell.accessoryType = UITableViewCellAccessoryNone; - } - - return cell; -} - - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath -{ - NSIndexPath *currentSelectedIndexPath = [tableView indexPathForSelectedRow]; - - [tableView deselectRowAtIndexPath:currentSelectedIndexPath animated:YES]; - - PostCategory *category = nil; - - if (self.selectionMode == CategoriesSelectionModeParent) { - if (indexPath.row > 0) { - category = self.categories[indexPath.row - 1]; - } - } else { - category = self.categories[indexPath.row]; - } - - switch (self.selectionMode) { - case (CategoriesSelectionModeParent): { - // If we're choosing a parent category then we're done. - if ([self.delegate respondsToSelector:@selector(postCategoriesViewController:didSelectCategory:)]) { - [self.delegate postCategoriesViewController:self didSelectCategory:category]; - } - - [self.navigationController popViewControllerAnimated:YES]; - return; - } break; - case (CategoriesSelectionModePost): { - if ([self.selectedCategories containsObject:category]) { - [self.selectedCategories removeObject:category]; - [tableView cellForRowAtIndexPath:indexPath].accessoryType = UITableViewCellAccessoryNone; - } else { - [self.selectedCategories addObject:category]; - [tableView cellForRowAtIndexPath:indexPath].accessoryType = UITableViewCellAccessoryCheckmark; - } - - if ([self.delegate respondsToSelector:@selector(postCategoriesViewController:didUpdateSelectedCategories:)]) { - [self.delegate postCategoriesViewController:self didUpdateSelectedCategories:[NSSet setWithArray:self.selectedCategories]]; - } - } break; - case (CategoriesSelectionModeBlogDefault): { - if ([self.selectedCategories containsObject:category]){ - return; - } - [self.selectedCategories removeAllObjects]; - [self.selectedCategories addObject:category]; - [self.tableView reloadData]; - if ([self.delegate respondsToSelector:@selector(postCategoriesViewController:didSelectCategory:)]) { - [self.delegate postCategoriesViewController:self didSelectCategory:category]; - } - } - } -} - -#pragma mark - WPAddPostCategoryViewControllerDelegate - -- (void)addPostCategoryViewController:(WPAddPostCategoryViewController *)controller didAddCategory:(PostCategory *)category -{ - if (self.selectionMode == CategoriesSelectionModeBlogDefault) { - [self.selectedCategories removeAllObjects]; - [self.selectedCategories addObject:category]; - if ([self.delegate respondsToSelector:@selector(postCategoriesViewController:didSelectCategory:)]) { - [self.delegate postCategoriesViewController:self didSelectCategory:category]; - } - } else { - [self.selectedCategories addObject:category]; - if ([self.delegate respondsToSelector:@selector(postCategoriesViewController:didUpdateSelectedCategories:)]) { - [self.delegate postCategoriesViewController:self didUpdateSelectedCategories:[NSSet setWithArray:self.selectedCategories]]; - } - } - - [self reloadCategoriesTableViewData]; -} - -@end diff --git a/WordPress/Classes/ViewRelated/Post/Categories/PostCategoriesViewController.swift b/WordPress/Classes/ViewRelated/Post/Categories/PostCategoriesViewController.swift new file mode 100644 index 000000000000..d8965f0ca5f5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Categories/PostCategoriesViewController.swift @@ -0,0 +1,297 @@ +import Foundation + +@objc protocol PostCategoriesViewControllerDelegate { + @objc optional func postCategoriesViewController(_ controller: PostCategoriesViewController, didSelectCategory category: PostCategory) + @objc optional func postCategoriesViewController(_ controller: PostCategoriesViewController, didUpdateSelectedCategories categories: NSSet) +} + +@objc enum CategoriesSelectionMode: Int { + case post + case parent + case blogDefault +} + +@objc class PostCategoriesViewController: UITableViewController { + @objc weak var delegate: PostCategoriesViewControllerDelegate? + + var onCategoriesChanged: (() -> Void)? + var onTableViewHeightDetermined: (() -> Void)? + + private var blog: Blog + private var originalSelection: [PostCategory]? + private var selectionMode: CategoriesSelectionMode + + private var categories = [PostCategory]() + private var categoryIndentationDict = [Int: Int]() + private var selectedCategories = [PostCategory]() + + private var saveButtonItem: UIBarButtonItem? + + private var hasSyncedCategories = false + + @objc init(blog: Blog, currentSelection: [PostCategory]?, selectionMode: CategoriesSelectionMode) { + self.blog = blog + self.selectionMode = selectionMode + self.originalSelection = currentSelection + super.init(style: .insetGrouped) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureTableView() + configureView() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + reloadCategories() + if !hasSyncedCategories { + syncCategories() + } + + preferredContentSize = tableView.contentSize + onTableViewHeightDetermined?() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + onCategoriesChanged?() + } + + private func configureTableView() { + tableView.accessibilityIdentifier = "CategoriesList" + tableView.cellLayoutMarginsFollowReadableWidth = true + tableView.register(WPTableViewCell.self, forCellReuseIdentifier: Constants.categoryCellIdentifier) + WPStyleGuide.configureAutomaticHeightRows(for: tableView) + } + + private func configureView() { + WPStyleGuide.configureColors(view: view, tableView: tableView) + + refreshControl = UIRefreshControl() + refreshControl!.addTarget(self, action: #selector(refreshCategoriesWithInteraction), for: .valueChanged) + + let rightBarButtonItem = UIBarButtonItem(image: UIImage(named: "icon-post-add"), style: .plain, target: self, action: #selector(showAddNewCategory)) + + switch selectionMode { + case .post: + navigationItem.rightBarButtonItem = rightBarButtonItem + title = NSLocalizedString("Post Categories", comment: "Title for selecting categories for a post") + case .parent: + navigationItem.rightBarButtonItem = rightBarButtonItem + title = NSLocalizedString("Parent Category", comment: "Title for selecting parent category of a category") + case .blogDefault: + title = NSLocalizedString("Default Category", comment: "Title for selecting a default category for a post") + } + } + + @objc private func refreshCategoriesWithInteraction() { + syncCategories() + } + + @objc private func showAddNewCategory() { + guard let addCategoriesViewController = WPAddPostCategoryViewController(blog: blog) else { + return + } + + addCategoriesViewController.delegate = self + + let addCategoriesNavigationController = UINavigationController(rootViewController: addCategoriesViewController) + navigationController?.modalPresentationStyle = .formSheet + present(addCategoriesNavigationController, animated: true, completion: nil) + } + + private func syncCategories() { + let service = PostCategoryService(coreDataStack: ContextManager.shared) + service.syncCategories(for: blog, success: { [weak self] in + self?.reloadCategories() + self?.refreshControl?.endRefreshing() + self?.hasSyncedCategories = true + }) { [weak self] error in + self?.refreshControl?.endRefreshing() + } + } + + private func reloadCategories() { + if selectedCategories.isEmpty { + selectedCategories = originalSelection ?? [] + } + + // Sort categories by parent/child relationship + let tree = WPCategoryTree(parent: nil) + tree.getChildrenFromObjects(blog.sortedCategories() ?? []) + categories = tree.getAllObjects() + + var categoryDict = [Int: PostCategory]() + categoryIndentationDict = [:] + + categories.forEach { category in + let categoryID = category.categoryID.intValue + let parentID = category.parentID.intValue + + categoryDict[categoryID] = category + let indentationLevel = indentationLevelForCategory(parentID: parentID, categoryCollection: categoryDict) + categoryIndentationDict[categoryID] = indentationLevel + } + + // Remove any previously selected category objects that are no longer available. + let selectedCategories = self.selectedCategories + self.selectedCategories = selectedCategories.filter { category in + if let sortedCategories = blog.sortedCategories() as? [PostCategory], sortedCategories.contains(category), !category.isDeleted { + return true + } + + return false + } + + // Notify the delegate of any changes for selectedCategories. + if selectedCategories.count != self.selectedCategories.count { + delegate?.postCategoriesViewController?(self, didUpdateSelectedCategories: NSSet(array: self.selectedCategories)) + } + + tableView.reloadData() + } + + private func indentationLevelForCategory(parentID: Int, categoryCollection: [Int: PostCategory]) -> Int { + guard parentID != 0, let category = categoryCollection[parentID] else { + return 0 + } + + return indentationLevelForCategory(parentID: category.parentID.intValue, categoryCollection: categoryCollection) + 1 + } + + private func configureNoCategoryRow(cell: WPTableViewCell) { + WPStyleGuide.configureTableViewDestructiveActionCell(cell) + cell.textLabel?.textAlignment = .natural + cell.textLabel?.text = NSLocalizedString("No Category", comment: "Text shown (to select no-category) in the parent-category-selection screen when creating a new category.") + if selectedCategories.isEmpty { + cell.accessoryType = selectedCategories.isEmpty ? .checkmark : .none + } else { + cell.accessoryType = .none + } + } + + private func configureRow(for category: PostCategory, cell: WPTableViewCell) { + let indentationLevel = categoryIndentationDict[category.categoryID.intValue] + cell.indentationLevel = indentationLevel ?? 0 + cell.indentationWidth = Constants.categoryCellIndentation + cell.textLabel?.text = category.categoryName.stringByDecodingXMLCharacters() + WPStyleGuide.configureTableViewCell(cell) + if selectedCategories.contains(category) { + cell.accessoryType = .checkmark + } else { + cell.accessoryType = .none + } + } + + //tableView + + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + var result = categories.count + if selectionMode == .parent { + result = result + 1 + } + return result + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: Constants.categoryCellIdentifier, for: indexPath) as? WPTableViewCell else { + return UITableViewCell() + } + + var row = indexPath.row + + // When showing this VC in mode CategoriesSelectionModeParent, we want the first item to be + // "No Category" and come up in red, to allow the user to select no category at all. + if selectionMode == .parent { + if row == 0 { + configureNoCategoryRow(cell: cell) + return cell + } else { + row = row - 1 + } + } + + let category = categories[row] + configureRow(for: category, cell: cell) + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let currentSelectedIndexPath = tableView.indexPathForSelectedRow { + tableView.deselectRow(at: currentSelectedIndexPath, animated: true) + } + + var category: PostCategory? + let row = indexPath.row + + switch selectionMode { + case .parent: + if indexPath.row > 0 { + category = categories[row - 1] + } + // If we're choosing a parent category then we're done. + if let category = category { + delegate?.postCategoriesViewController?(self, didSelectCategory: category) + navigationController?.popViewController(animated: true) + } + case .post: + category = categories[row] + if let category = category { + if selectedCategories.contains(category), + let index = selectedCategories.firstIndex(of: category) { + selectedCategories.remove(at: index) + tableView.cellForRow(at: indexPath)?.accessoryType = .none + } else { + selectedCategories.append(category) + tableView.cellForRow(at: indexPath)?.accessoryType = .checkmark + } + + delegate?.postCategoriesViewController?(self, didUpdateSelectedCategories: NSSet(array: selectedCategories)) + } + + case .blogDefault: + category = categories[row] + if let category = category { + if selectedCategories.contains(category) { + return + } + selectedCategories.removeAll() + selectedCategories.append(category) + tableView.reloadData() + delegate?.postCategoriesViewController?(self, didSelectCategory: category) + } + } + } +} + +private extension PostCategoriesViewController { + struct Constants { + static let categoryCellIdentifier = "CategoryCellIdentifier" + static let categoryCellIndentation = CGFloat(16.0) + } +} + +extension PostCategoriesViewController: WPAddPostCategoryViewControllerDelegate { + func addPostCategoryViewController(_ controller: WPAddPostCategoryViewController, didAdd category: PostCategory) { + switch selectionMode { + case .post, .parent: + selectedCategories.append(category) + delegate?.postCategoriesViewController?(self, didUpdateSelectedCategories: NSSet(array: selectedCategories)) + case .blogDefault: + selectedCategories.removeAll() + selectedCategories.append(category) + delegate?.postCategoriesViewController?(self, didSelectCategory: category) + } + + reloadCategories() + } +} diff --git a/WordPress/Classes/ViewRelated/Post/Categories/WPAddPostCategoryViewController.m b/WordPress/Classes/ViewRelated/Post/Categories/WPAddPostCategoryViewController.m index ac5443b202f2..ecc98152a395 100644 --- a/WordPress/Classes/ViewRelated/Post/Categories/WPAddPostCategoryViewController.m +++ b/WordPress/Classes/ViewRelated/Post/Categories/WPAddPostCategoryViewController.m @@ -1,11 +1,10 @@ #import "WPAddPostCategoryViewController.h" #import "Blog.h" #import "PostCategory.h" -#import "PostCategoriesViewController.h" #import "Constants.h" #import "SiteSettingsViewController.h" #import "PostCategoryService.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "BlogService.h" #import "WordPress-Swift.h" #import <WordPressShared/NSString+Util.h> @@ -26,7 +25,7 @@ @implementation WPAddPostCategoryViewController - (instancetype)initWithBlog:(Blog *)blog { - self = [super initWithStyle:UITableViewStyleGrouped]; + self = [super initWithStyle:UITableViewStyleInsetGrouped]; if (self) { _blog = blog; } @@ -41,6 +40,7 @@ - (void)viewDidLoad self.tableView.sectionFooterHeight = 0.0f; // Set this to 0 to avoid automattic sizing of cells and getting label cell to short. self.tableView.estimatedRowHeight = 0.0f; + self.view.tintColor = [UIColor murielEditorPrimary]; self.saveButtonItem = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Save", @"Save button label (saving content, ex: Post, Page, Comment, Category).") style:[WPStyleGuide barButtonStyleForDone] @@ -64,7 +64,7 @@ - (void)clearUI - (void)addProgressIndicator { - UIActivityIndicatorView *activityView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + UIActivityIndicatorView *activityView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; UIBarButtonItem *activityButtonItem = [[UIBarButtonItem alloc] initWithCustomView:activityView]; [activityView startAnimating]; @@ -84,7 +84,7 @@ - (IBAction)dismiss:(id)sender - (void)saveAddCategory:(id)sender { NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - PostCategoryService *categoryService = [[PostCategoryService alloc] initWithManagedObjectContext:context]; + PostCategoryService *categoryService = [[PostCategoryService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; NSString *catName = [self.createCategoryCell.textField.text trim]; if (!catName ||[catName length] == 0) { @@ -96,7 +96,10 @@ - (void)saveAddCategory:(id)sender return; } - PostCategory *category = [categoryService findWithBlogObjectID:self.blog.objectID parentID:self.parentCategory.categoryID andName:catName]; + PostCategory *category = [PostCategory lookupWithBlogObjectID:self.blog.objectID + parentCategoryID:self.parentCategory.categoryID + categoryName:catName + inContext:context]; if (category) { // If there's an existing category with that name and parent, let's use that [self dismissWithCategory:category]; diff --git a/WordPress/Classes/ViewRelated/Post/Categories/WPCategoryTree.h b/WordPress/Classes/ViewRelated/Post/Categories/WPCategoryTree.h deleted file mode 100644 index 12aa60f3a2ad..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Categories/WPCategoryTree.h +++ /dev/null @@ -1,13 +0,0 @@ -#import <UIKit/UIKit.h> -#import "PostCategory.h" - -@interface WPCategoryTree : NSObject - -@property (nonatomic, strong) PostCategory *parent; -@property (nonatomic, strong) NSMutableArray *children; - -- (id)initWithParent:(PostCategory *)parent; -- (NSArray *)getAllObjects; -- (void)getChildrenFromObjects:(NSArray *)collection; - -@end diff --git a/WordPress/Classes/ViewRelated/Post/Categories/WPCategoryTree.m b/WordPress/Classes/ViewRelated/Post/Categories/WPCategoryTree.m deleted file mode 100644 index d06cf9fd39a5..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Categories/WPCategoryTree.m +++ /dev/null @@ -1,47 +0,0 @@ -#import "WPCategoryTree.h" - -@implementation WPCategoryTree - -- (id)initWithParent:(PostCategory *)parent -{ - if (self = [super init]) { - self.parent = parent; - self.children = [NSMutableArray array]; - } - - return self; -} - -- (void)getChildrenFromObjects:(NSArray *)collection -{ - NSUInteger count = [collection count]; - - for (NSUInteger i = 0; i < count; i++) { - PostCategory *category = [collection objectAtIndex:i]; - - // self.parent can be nil, so compare int values to avoid badness - if ([category.parentID intValue] == [self.parent.categoryID intValue]) { - WPCategoryTree *child = [[WPCategoryTree alloc] initWithParent:category]; - [child getChildrenFromObjects:collection]; - [self.children addObject:child]; - } - } -} - -- (NSArray *)getAllObjects -{ - NSMutableArray *allObjects = [NSMutableArray array]; - NSUInteger count = [self.children count]; - - if (self.parent) { - [allObjects addObject:self.parent]; - } - - for (NSUInteger i = 0; i < count; i++) { - [allObjects addObjectsFromArray:[[self.children objectAtIndex:i] getAllObjects]]; - } - - return allObjects; -} - -@end diff --git a/WordPress/Classes/ViewRelated/Post/Categories/WPCategoryTree.swift b/WordPress/Classes/ViewRelated/Post/Categories/WPCategoryTree.swift new file mode 100644 index 000000000000..3eaf9ee9f2f0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Categories/WPCategoryTree.swift @@ -0,0 +1,44 @@ +import Foundation + +class WPCategoryTree: NSObject { + var parent: PostCategory? + var children = [WPCategoryTree]() + + @objc init(parent: PostCategory?) { + self.parent = parent + } + + @objc func getChildrenFromObjects(_ collection: [Any]) { + collection.forEach { + guard let category = $0 as? PostCategory else { + return + } + + if isParentChild(category: category, parent: parent) { + let child = WPCategoryTree(parent: category) + child.getChildrenFromObjects(collection) + children.append(child) + } + } + } + + @objc func getAllObjects() -> [PostCategory] { + var allObjects = [PostCategory]() + if let parent = parent { + allObjects.append(parent) + } + + children.forEach { + allObjects.append(contentsOf: $0.getAllObjects()) + } + return allObjects + } + + private func isParentChild(category: PostCategory, parent: PostCategory?) -> Bool { + guard let parent = parent else { + return category.parentID == 0 + } + + return category.parentID == parent.categoryID + } +} diff --git a/WordPress/Classes/ViewRelated/Post/EditPostViewController.swift b/WordPress/Classes/ViewRelated/Post/EditPostViewController.swift index 0f8ecf2aa0cf..47531d14bb82 100644 --- a/WordPress/Classes/ViewRelated/Post/EditPostViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/EditPostViewController.swift @@ -17,10 +17,13 @@ class EditPostViewController: UIViewController { var insertedMedia: [Media]? = nil /// is editing a reblogged post var postIsReblogged = false + /// the entry point for the editor + var entryPoint: PostEditorEntryPoint = .unknown private let loadAutosaveRevision: Bool @objc fileprivate(set) var post: Post? + private let prompt: BloggingPrompt? fileprivate var hasShownEditor = false fileprivate var editingExistingPost = false fileprivate let blog: Blog @@ -58,13 +61,21 @@ class EditPostViewController: UIViewController { self.init(post: nil, blog: blog) } + /// Initialize as an editor to create a new post for the provided blog and prompt + /// + /// - Parameter blog: blog to create a new post for + /// - Parameter prompt: blogging prompt to configure the new post for + convenience init(blog: Blog, prompt: BloggingPrompt) { + self.init(post: nil, blog: blog, prompt: prompt) + } + /// Initialize as an editor with a specified post to edit and blog to post too. /// /// - Parameters: /// - post: the post to edit /// - blog: the blog to create a post for, if post is nil /// - Note: it's preferable to use one of the convenience initializers - fileprivate init(post: Post?, blog: Blog, loadAutosaveRevision: Bool = false) { + fileprivate init(post: Post?, blog: Blog, loadAutosaveRevision: Bool = false, prompt: BloggingPrompt? = nil) { self.post = post self.loadAutosaveRevision = loadAutosaveRevision if let post = post { @@ -75,6 +86,7 @@ class EditPostViewController: UIViewController { post.fixLocalMediaURLs() } self.blog = blog + self.prompt = prompt super.init(nibName: nil, bundle: nil) modalPresentationStyle = .fullScreen modalTransitionStyle = .coverVertical @@ -110,16 +122,19 @@ class EditPostViewController: UIViewController { } override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent + if openWithPostPost { + return .darkContent + } else { + return WPStyleGuide.preferredStatusBarStyle + } } fileprivate func postToEdit() -> Post { if let post = post { return post } else { - let context = ContextManager.sharedInstance().mainContext - let postService = PostService(managedObjectContext: context) - let newPost = postService.createDraftPost(for: blog) + let newPost = blog.createDraftPost() + newPost.prepareForPrompt(prompt) post = newPost return newPost } @@ -135,6 +150,7 @@ class EditPostViewController: UIViewController { self?.replaceEditor(editor: editor, replacement: replacement) }) editor.postIsReblogged = postIsReblogged + editor.entryPoint = entryPoint showEditor(editor) } @@ -201,6 +217,7 @@ class EditPostViewController: UIViewController { } postPost.setup(post: post) + postPost.hideEditButton = isPresentingOverEditor() postPost.onClose = { self.closePostPost(animated: true) } @@ -212,6 +229,17 @@ class EditPostViewController: UIViewController { } } + /// - Returns: `true` if `self` was presented over an existing `EditPostViewController`, otherwise `false`. + private func isPresentingOverEditor() -> Bool { + guard + let aztecNavigationController = presentingViewController as? AztecNavigationController, + aztecNavigationController.presentingViewController is EditPostViewController + else { + return false + } + return true + } + @objc func shouldShowPostPost(hasChanges: Bool) -> Bool { guard let post = post else { return false @@ -236,16 +264,35 @@ class EditPostViewController: UIViewController { return } - let controller = PreviewWebKitViewController(post: post) + let controller = PreviewWebKitViewController(post: post, source: "edit_post_preview") controller.trackOpenEvent() let navWrapper = LightNavigationController(rootViewController: controller) + if postPost.traitCollection.userInterfaceIdiom == .pad { + navWrapper.modalPresentationStyle = .fullScreen + } postPost.present(navWrapper, animated: true) {} } @objc func closePostPost(animated: Bool) { + // this reference is needed in the completion + let presentingController = self.presentingViewController // will dismiss self dismiss(animated: animated) { [weak self] in - self?.afterDismiss?() + guard let self = self else { + return + } + self.afterDismiss?() + guard let post = self.post, + post.isPublished(), + !self.editingExistingPost, + let controller = presentingController else { + return + } + + BloggingRemindersFlow.present(from: controller, + for: self.blog, + source: .publishFlow, + alwaysShow: false) } } } diff --git a/WordPress/Classes/ViewRelated/Post/FeaturedImageViewController.m b/WordPress/Classes/ViewRelated/Post/FeaturedImageViewController.m index 52f63546c6a0..4cd80d1dec7a 100644 --- a/WordPress/Classes/ViewRelated/Post/FeaturedImageViewController.m +++ b/WordPress/Classes/ViewRelated/Post/FeaturedImageViewController.m @@ -117,7 +117,7 @@ - (void)removeFeaturedImage handler:nil]; [alertController addActionWithTitle:NSLocalizedString(@"Remove", @"Remove an image/posts/etc") style:UIAlertActionStyleDestructive - handler:^(UIAlertAction *alertAction) { + handler:^(UIAlertAction * __unused alertAction) { if (self.delegate) { [self.delegate FeaturedImageViewControllerOnRemoveImageButtonPressed:self]; } diff --git a/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationView.h b/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationView.h deleted file mode 100644 index b20d366ace81..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationView.h +++ /dev/null @@ -1,16 +0,0 @@ -#import <UIKit/UIKit.h> -#import <MapKit/MapKit.h> -#import "Coordinate.h" - -@interface PostGeolocationView : UIView - -@property (nonatomic, strong) Coordinate *coordinate; -@property (nonatomic, readonly) MKCoordinateRegion region; -@property (nonatomic, strong) NSString *address; -@property (nonatomic) CGFloat labelMargin; -@property (nonatomic) BOOL scrollEnabled; -@property (nonatomic) BOOL chevronHidden; - -- (void)setCoordinate:(Coordinate *)coordinate region:(MKCoordinateRegion)region; - -@end diff --git a/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationView.m b/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationView.m deleted file mode 100644 index e3443db8afb0..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationView.m +++ /dev/null @@ -1,165 +0,0 @@ -#import "PostGeolocationView.h" -#import "PostAnnotation.h" -#import <WordPressShared/WPFontManager.h> -#import "WordPress-Swift.h" - -const CGFloat DefaultLabelMargin = 20.0f; -const CGFloat GeoViewMinHeight = 130.0f; - -@interface PostGeolocationView () - -@property (nonatomic, strong) MKMapView *mapView; -@property (nonatomic, strong) UILabel *addressLabel; -@property (nonatomic, strong) PostAnnotation *annotation; -@property (nonatomic, strong) UIImageView *chevron; - -@end - -@implementation PostGeolocationView - -- (id)initWithFrame:(CGRect)frame -{ - self = [super initWithFrame:frame]; - if (self) { - [self setupSubviews]; - self.labelMargin = DefaultLabelMargin; - } - return self; -} - -- (void)setupSubviews -{ - self.mapView = [[MKMapView alloc] initWithFrame:CGRectMake(0.0f, 0.0f, self.frame.size.width, self.frame.size.height)]; - self.mapView.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleBottomMargin; - [self addSubview:self.mapView]; - - CGFloat x = self.labelMargin; - CGFloat w = self.frame.size.width - (2 * x); - - self.addressLabel = [[UILabel alloc] initWithFrame:CGRectMake(x, 130.0f, w, 60.0)]; - self.addressLabel.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleTopMargin; - self.addressLabel.font = [WPStyleGuide regularTextFont]; - self.addressLabel.textColor = [UIColor murielNeutral70]; - self.addressLabel.numberOfLines = 0; - self.addressLabel.lineBreakMode = NSLineBreakByWordWrapping; - - [self addSubview:self.addressLabel]; - - self.chevron = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"disclosure-chevron"]]; - self.chevron.hidden = YES; - [self addSubview:self.chevron]; -} - -- (void)layoutSubviews -{ - [super layoutSubviews]; - - CGFloat availableHeight = MAX(CGRectGetHeight(self.frame), GeoViewMinHeight); - CGFloat addressLabelHeight = 80.0f; - CGFloat mapHeight = availableHeight - addressLabelHeight; - - CGFloat width = CGRectGetWidth(self.frame); - CGFloat labelX = self.labelMargin; - CGFloat labelWidth = CGRectGetWidth(self.frame) - (2 * labelX); - - self.mapView.frame = CGRectMake(0.0, 0.0, width, mapHeight); - self.addressLabel.frame = CGRectMake(labelX, mapHeight, labelWidth, addressLabelHeight); - CGSize chevronSize = self.chevron.frame.size; - CGFloat chevronX= labelWidth-chevronSize.width; - CGFloat chevronY= mapHeight+((addressLabelHeight - chevronSize.height) / 2.0); - self.chevron.frame = CGRectMake(chevronX, chevronY, chevronSize.width, chevronSize.height); -} - -- (void)setAddress:(NSString *)address -{ - _address = address; - [self updateAddressLabel]; -} - -- (MKCoordinateRegion)region { - return self.mapView.region; -} - -- (void)setCoordinate:(Coordinate *)coordinate -{ - MKCoordinateRegion defaultRegion = MKCoordinateRegionMakeWithDistance(coordinate.coordinate, 200.0, 100.0); - [self setCoordinate:coordinate region:defaultRegion]; -} - -- (void)setCoordinate:(Coordinate *)coordinate region:(MKCoordinateRegion)region -{ - if ([coordinate isEqual:_coordinate]) { - return; - } - - _coordinate = coordinate; - - [self.mapView removeAnnotation:self.annotation]; - - if (coordinate.latitude == 0 && coordinate.longitude == 0) { - [self.mapView setRegion:MKCoordinateRegionForMapRect(MKMapRectWorld) animated:NO]; - } else { - self.annotation = [[PostAnnotation alloc] initWithCoordinate:self.coordinate.coordinate]; - [self.mapView addAnnotation:self.annotation]; - - [self.mapView setRegion:region animated:YES]; - } - - [self updateAddressLabel]; - -} - -- (void)updateAddressLabel -{ - NSString *coordText = @""; - if (self.coordinate != nil) { - CLLocationDegrees latitude = self.coordinate.latitude; - CLLocationDegrees longitude = self.coordinate.longitude; - NSInteger latD = trunc(fabs(latitude)); - NSInteger latM = trunc((fabs(latitude) - latD) * 60); - NSInteger lonD = trunc(fabs(longitude)); - NSInteger lonM = trunc((fabs(longitude) - lonD) * 60); - NSString *latDir = (latitude > 0) ? NSLocalizedString(@"North", @"Used for Geo-tagging posts by latitude and longitude. Basic form.") : NSLocalizedString(@"South", @"Used for Geo-tagging posts by latitude and longitude. Basic form."); - NSString *lonDir = (longitude > 0) ? NSLocalizedString(@"East", @"Used for Geo-tagging posts by latitude and longitude. Basic form.") : NSLocalizedString(@"West", @"Used for Geo-tagging posts by latitude and longitude. Basic form."); - latDir = [latDir uppercaseString]; - lonDir = [lonDir uppercaseString]; - if (latitude == 0.0) latDir = @""; - if (longitude == 0.0) lonDir = @""; - - coordText = [NSString stringWithFormat:@"%i°%i' %@, %i°%i' %@", - latD, latM, latDir, - lonD, lonM, lonDir]; - } - NSString *address = self.address ? [self.address stringByAppendingString:@"\n"] : @""; - - NSDictionary *addressStyle = @{NSFontAttributeName:[WPStyleGuide regularTextFont], NSForegroundColorAttributeName:[UIColor murielNeutral70]}; - NSDictionary *coordinateStyle = @{NSFontAttributeName:[WPFontManager systemSemiBoldFontOfSize:11.0], NSForegroundColorAttributeName:[UIColor murielNeutral30]}; - - NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:address attributes:addressStyle]; - NSAttributedString *coordinates = [[NSMutableAttributedString alloc] initWithString:coordText attributes:coordinateStyle]; - [attributedString appendAttributedString:coordinates]; - self.addressLabel.attributedText = attributedString; -} - -- (BOOL)scrollEnabled -{ - return self.mapView.scrollEnabled; -} - -- (void)setScrollEnabled:(BOOL)scrollEnabled -{ - self.mapView.scrollEnabled = scrollEnabled; -} - -- (BOOL)chevronHidden -{ - return self.chevron.hidden; -} - -- (void)setChevronHidden:(BOOL)hidden -{ - self.chevron.hidden = hidden; -} - - -@end diff --git a/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationViewController.h b/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationViewController.h deleted file mode 100644 index ca23487af27d..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationViewController.h +++ /dev/null @@ -1,10 +0,0 @@ -#import <UIKit/UIKit.h> - -@class Post; -@class LocationService; - -@interface PostGeolocationViewController : UIViewController - -- (id)initWithPost:(Post *)post locationService:(LocationService *)locationService; - -@end diff --git a/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationViewController.m b/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationViewController.m deleted file mode 100644 index 520ed9dc99d8..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Geolocation/PostGeolocationViewController.m +++ /dev/null @@ -1,364 +0,0 @@ -#import "PostGeolocationViewController.h" - -#import <CoreLocation/CoreLocation.h> -#import <MapKit/MapKit.h> - -#import "LocationService.h" -#import "PostAnnotation.h" -#import "PostGeolocationView.h" -#import <WordPressShared/WPTableViewCell.h> -#import "WordPress-Swift.h" - -@import Gridicons; - -static NSString *CLPlacemarkTableViewCellIdentifier = @"CLPlacemarkTableViewCellIdentifier"; - -typedef NS_ENUM(NSInteger, SearchResultsSection) { - SearchResultsSectionCurrentLocation = 0, - SearchResultsSectionSearchResults = 1 -}; - -@interface PostGeolocationViewController () <MKMapViewDelegate, UISearchBarDelegate, UITableViewDelegate, UITableViewDataSource> - -@property (nonatomic, strong) Post *post; -@property (nonatomic, strong) PostGeolocationView *geoView; -@property (nonatomic, strong) UITableViewCell *currentLocationCell; -@property (nonatomic, strong) UIBarButtonItem *removeButton; -@property (nonatomic, strong) UIBarButtonItem *doneButton; -@property (nonatomic, strong) LocationService *locationService; -@property (nonatomic, strong) UIView *searchBarTop; -@property (nonatomic, strong) UISearchBar *searchBar; -@property (nonatomic, strong) UITableView *tableView; -@property (nonatomic, strong) NSArray<CLPlacemark *> *placemarks; - -@end - -@implementation PostGeolocationViewController - -- (id)initWithPost:(Post *)post locationService:(LocationService *)locationService -{ - self = [super init]; - if (self) { - _post = post; - _locationService = locationService; - } - return self; -} - -- (void)viewDidLoad -{ - [super viewDidLoad]; - self.view.backgroundColor = [UIColor murielNeutral5]; - self.title = NSLocalizedString(@"Location", @"Title for screen to select post location"); - [self.view addSubview:self.geoView]; - self.navigationItem.leftBarButtonItems = @[self.removeButton]; - self.navigationItem.rightBarButtonItems = @[self.doneButton]; - [self.view addSubview:self.searchBar]; - [self.view addSubview:self.tableView]; - [self.view addSubview:self.searchBarTop]; -} - -- (void)viewWillLayoutSubviews -{ - [super viewWillLayoutSubviews]; - self.searchBarTop.hidden = !self.navigationController.navigationBarHidden; - self.searchBarTop.frame = CGRectMake(0.0, 0.0, self.view.frame.size.width, self.view.safeAreaInsets.top); - self.searchBar.frame = CGRectMake(0.0, self.view.safeAreaInsets.top-1, self.view.frame.size.width, 44.0); - self.tableView.frame = CGRectMake(0.0, CGRectGetMaxY(self.searchBar.frame), self.view.frame.size.width, self.view.frame.size.height-CGRectGetMaxY(self.searchBar.frame)); -} - -- (void)viewWillAppear:(BOOL)animated -{ - [super viewWillAppear:animated]; - - if (self.post.geolocation) { - [self refreshView]; - } else { - [self searchCurrentLocationFromUserRequest:NO]; - } -} - -- (UIView *)searchBarTop -{ - if (_searchBarTop == nil) { - _searchBarTop = [[UIView alloc] init]; - _searchBarTop.backgroundColor = [UIColor murielPrimary]; - } - return _searchBarTop; -} - -#pragma mark - View Properties - -- (UISearchBar *)searchBar -{ - if (_searchBar == nil) { - NSAttributedString *placeholderString = [[NSAttributedString alloc] initWithString:NSLocalizedString(@"Search Locations", @"Prompt in the location search bar.") attributes:[WPStyleGuide defaultSearchBarTextAttributes:[UIColor murielNeutral30]]]; - [[UITextField appearanceWhenContainedInInstancesOfClasses:@[[UISearchBar class], [PostGeolocationViewController class]]] setDefaultTextAttributes:[WPStyleGuide defaultSearchBarTextAttributes:[UIColor murielNeutral70]]]; - [[UITextField appearanceWhenContainedInInstancesOfClasses:@[[UISearchBar class], [PostGeolocationViewController class]]] setAttributedPlaceholder:placeholderString]; - _searchBar = [[UISearchBar alloc] init]; - _searchBar.delegate = self; - _searchBar.barTintColor = [UIColor murielNeutral5]; - _searchBar.tintColor = [UIColor murielNeutral30]; - UIImage *clearImage = [[UIImage imageNamed:@"icon-clear-searchfield"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; - [_searchBar setImage:clearImage forSearchBarIcon:UISearchBarIconClear state:UIControlStateNormal]; - UIImage *searchImage = [[UIImage imageNamed:@"icon-post-list-search"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; - [_searchBar setImage:searchImage forSearchBarIcon:UISearchBarIconSearch state:UIControlStateNormal]; - _searchBar.accessibilityIdentifier = @"Search"; - } - return _searchBar; -} - -- (UITableView *)tableView -{ - if (_tableView == nil) { - _tableView = [[UITableView alloc] initWithFrame:self.geoView.frame style:UITableViewStylePlain]; - _tableView.hidden = YES; - _tableView.delegate = self; - _tableView.rowHeight = 90; - UIVisualEffectView *visualEffect = [[UIVisualEffectView alloc] initWithEffect:[UIBlurEffect effectWithStyle:UIBlurEffectStyleLight]]; - _tableView.backgroundView = visualEffect; - _tableView.backgroundColor = [UIColor clearColor]; - _tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectZero]; - _tableView.dataSource = self; - } - return _tableView; -} - -- (UIBarButtonItem *)doneButton -{ - if (!_doneButton) { - _doneButton = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Done", @"Label for confirm location of a post") - style:UIBarButtonItemStylePlain - target:self - action:@selector(confirmGeolocation)]; - } - return _doneButton; -} - - -- (UIBarButtonItem *)removeButton -{ - if (!_removeButton) { - _removeButton = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Remove", @"Label for remove location button") - style:UIBarButtonItemStylePlain - target:self - action:@selector(removeGeolocation)]; - } - return _removeButton; -} - -- (PostGeolocationView *)geoView -{ - if (!_geoView) { - CGRect frame = self.view.bounds; - UIViewAutoresizing mask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth; - _geoView = [[PostGeolocationView alloc] initWithFrame:frame]; - _geoView.autoresizingMask = mask; - _geoView.backgroundColor = [UIColor whiteColor]; - } - return _geoView; -} - -- (void)removeGeolocation -{ - self.post.geolocation = nil; - [self.presentingViewController dismissViewControllerAnimated:YES completion:nil]; -} - -- (void)confirmGeolocation -{ - self.post.geolocation = self.geoView.coordinate; - [self.presentingViewController dismissViewControllerAnimated:YES completion:nil]; -} - -- (void)searchCurrentLocationFromUserRequest:(BOOL)userRequest -{ - if ([self.locationService locationServicesDisabled] || [self.locationService locationServicesDenied]) { - if (userRequest) { - [self.locationService showAlertForLocationServicesDisabled]; - } - return; - } - __weak __typeof__(self) weakSelf = self; - [self.locationService getCurrentLocationAndAddress:^(CLLocation *location, NSString *address, NSError *error) { - dispatch_async(dispatch_get_main_queue(), ^{ - __typeof__(weakSelf) strongSelf = weakSelf; - if (error) { - [strongSelf refreshView]; - if (userRequest) { - [strongSelf.locationService showAlertForLocationError:error]; - } - return; - } - if (location) { - Coordinate *coord = [[Coordinate alloc] initWithCoordinate:location.coordinate]; - strongSelf.post.geolocation = coord; - [strongSelf refreshView]; - } - }); - }]; - - [self refreshView]; -} - -- (void)refreshView -{ - if ([self.locationService locationServiceRunning]) { - self.geoView.coordinate = nil; - self.geoView.address = NSLocalizedString(@"Finding your location...", @"Geo-tagging posts, status message when geolocation is found."); - - } else if (self.post.geolocation) { - self.geoView.coordinate = self.post.geolocation; - self.geoView.address = [self.locationService lastGeocodedAddress]; - - } else { - self.geoView.coordinate = nil; - self.geoView.address = [self.locationService lastGeocodedAddress]; - } -} - -- (void)enableSearch:(BOOL)searchOn -{ - self.searchBar.showsCancelButton = searchOn; - [self.navigationController setNavigationBarHidden:searchOn animated:YES]; - self.searchBar.translucent = NO; - self.tableView.hidden = !searchOn; - _searchBar.barTintColor = searchOn ? [UIColor murielPrimary]:[UIColor murielNeutral5]; -} - -#pragma mark - UISearchBarDelegate - -- (void)searchBarTextDidBeginEditing:(UISearchBar *)searchBar -{ - [self enableSearch:YES]; -} - -- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText -{ - NSString *query = searchText; - if (query.length < 3) { - return; - } - __weak __typeof__(self) weakSelf = self; - [self.locationService searchPlacemarksWithQuery:query region:self.geoView.region completion:^(NSArray *placemarks, NSError *error) { - if (error) { - return; - } - dispatch_async(dispatch_get_main_queue(), ^{ - [weakSelf showSearchResults:placemarks]; - }); - - }]; -} - -- (void)searchBarCancelButtonClicked:(UISearchBar *)searchBar -{ - [self enableSearch:NO]; - [self.searchBar resignFirstResponder]; -} - -- (void)showSearchResults:(NSArray<CLPlacemark *> *)placemarks -{ - self.placemarks = placemarks; - [self.tableView reloadData]; -} - -#pragma mark - UITableViewDelegate - -- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView -{ - return 2; -} - -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section -{ - switch (section) { - case SearchResultsSectionCurrentLocation: - return 1; - case SearchResultsSectionSearchResults: - return self.placemarks.count; - } - return 0; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath -{ - switch (indexPath.section) { - case SearchResultsSectionCurrentLocation: - return self.currentLocationCell; - case SearchResultsSectionSearchResults: { - UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:CLPlacemarkTableViewCellIdentifier]; - if (!cell) { - cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:CLPlacemarkTableViewCellIdentifier]; - } - CLPlacemark *placemark = self.placemarks[indexPath.row]; - cell.textLabel.text = placemark.name; - cell.detailTextLabel.text = placemark.formattedAddress; - cell.detailTextLabel.numberOfLines = 3; - cell.detailTextLabel.font = [WPStyleGuide regularTextFont]; - cell.detailTextLabel.textColor = [UIColor murielNeutral70]; - cell.backgroundColor = [UIColor clearColor]; - cell.selected = NO; - return cell; - } - } - - return nil; -} - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath -{ - [self enableSearch:NO]; - [self.searchBar resignFirstResponder]; - [self.tableView deselectRowAtIndexPath:indexPath animated:YES]; - switch (indexPath.section) { - case SearchResultsSectionCurrentLocation: - [self searchCurrentLocationFromUserRequest:YES]; - break; - case SearchResultsSectionSearchResults: { - CLPlacemark *placemark = self.placemarks[indexPath.row]; - Coordinate *coordinate = [[Coordinate alloc] initWithCoordinate:placemark.location.coordinate]; - CLRegion *placemarkRegion = placemark.region; - if ([placemarkRegion isKindOfClass:[CLCircularRegion class]]) { - CLCircularRegion *circularRegion = (CLCircularRegion *)placemarkRegion; - MKCoordinateRegion region = MKCoordinateRegionMakeWithDistance(circularRegion.center, circularRegion.radius, circularRegion.radius); - [self.geoView setCoordinate:coordinate region:region]; - } else { - [self.geoView setCoordinate:coordinate]; - } - self.geoView.address = [NSString stringWithFormat:@"%@, %@]", placemark.name, placemark.formattedAddress]; - self.post.geolocation = self.geoView.coordinate; - } - break; - } - -} - -- (UITableViewCell *)currentLocationCell { - if (_currentLocationCell == nil) { - UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; - cell.textLabel.text = NSLocalizedString(@"Use Current Location", @"Label for cell that sets the location of a post to the current location"); - cell.imageView.image = [Gridicon iconOfType:GridiconTypeLocation]; - cell.imageView.tintColor = [UIColor murielPrimary40]; - cell.textLabel.font = [WPStyleGuide regularTextFont]; - cell.textLabel.textColor = [UIColor murielNeutral70]; - cell.backgroundColor = [UIColor clearColor]; - _currentLocationCell = cell; - } - _currentLocationCell.selected = NO; - return _currentLocationCell; -} - -#pragma mark - Status bar management - -- (UIStatusBarStyle)preferredStatusBarStyle -{ - return UIStatusBarStyleLightContent; -} - -- (UIViewController *)childViewControllerForStatusBarStyle -{ - return nil; -} - -@end diff --git a/WordPress/Classes/ViewRelated/Post/HomepageEditorNavigationBarManager.swift b/WordPress/Classes/ViewRelated/Post/HomepageEditorNavigationBarManager.swift new file mode 100644 index 000000000000..25805a848900 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/HomepageEditorNavigationBarManager.swift @@ -0,0 +1,58 @@ +import Foundation + +protocol HomepageEditorNavigationBarManagerDelegate: PostEditorNavigationBarManagerDelegate { + var continueButtonText: String { get } + + func navigationBarManager(_ manager: HomepageEditorNavigationBarManager, continueWasPressed sender: UIButton) +} + +class HomepageEditorNavigationBarManager: PostEditorNavigationBarManager { + weak var homepageEditorNavigationBarManagerDelegate: HomepageEditorNavigationBarManagerDelegate? + + override weak var delegate: PostEditorNavigationBarManagerDelegate? { + get { + return homepageEditorNavigationBarManagerDelegate + } + set { + if let newDelegate = newValue { + if let newHomepageDelegate = newDelegate as? HomepageEditorNavigationBarManagerDelegate { + homepageEditorNavigationBarManagerDelegate = newHomepageDelegate + } else { + // This should not happen, but fail fast in case + fatalError("homepageEditorNavigationBarManagerDelegate must be of type HomepageEditorNavigationBarManagerDelegate?") + } + } else { + homepageEditorNavigationBarManagerDelegate = nil + } + } + } + + /// Continue Button + private(set) lazy var continueButton: UIButton = { + let button = UIButton(type: .system) + button.addTarget(self, action: #selector(continueButtonTapped(sender:)), for: .touchUpInside) + button.setTitle(homepageEditorNavigationBarManagerDelegate?.continueButtonText ?? "", for: .normal) + button.sizeToFit() + button.isEnabled = true + button.setContentHuggingPriority(.required, for: .horizontal) + return button + }() + + /// Continue Button + private(set) lazy var continueBarButtonItem: UIBarButtonItem = { + let button = UIBarButtonItem(customView: self.continueButton) + return button + }() + + @objc private func continueButtonTapped(sender: UIButton) { + homepageEditorNavigationBarManagerDelegate?.navigationBarManager(self, continueWasPressed: sender) + } + + override var leftBarButtonItems: [UIBarButtonItem] { + return [] + } + + override var rightBarButtonItems: [UIBarButtonItem] { + return [moreBarButtonItem, continueBarButtonItem, separatorButtonItem] + } +} diff --git a/WordPress/Classes/ViewRelated/Post/InteractivePostView.swift b/WordPress/Classes/ViewRelated/Post/InteractivePostView.swift index 35d9a2a7b58d..4ba9cada6d06 100644 --- a/WordPress/Classes/ViewRelated/Post/InteractivePostView.swift +++ b/WordPress/Classes/ViewRelated/Post/InteractivePostView.swift @@ -1,6 +1,6 @@ import Foundation -@objc protocol InteractivePostView { +protocol InteractivePostView { func setInteractionDelegate(_ delegate: InteractivePostViewDelegate) - @objc optional func setActionSheetDelegate(_ delegate: PostActionSheetDelegate) + func setActionSheetDelegate(_ delegate: PostActionSheetDelegate) } diff --git a/WordPress/Classes/ViewRelated/Post/InteractivePostViewDelegate.swift b/WordPress/Classes/ViewRelated/Post/InteractivePostViewDelegate.swift index 5eb6f3b3fce4..6e169d6f0da4 100644 --- a/WordPress/Classes/ViewRelated/Post/InteractivePostViewDelegate.swift +++ b/WordPress/Classes/ViewRelated/Post/InteractivePostViewDelegate.swift @@ -1,13 +1,17 @@ import Foundation -@objc protocol InteractivePostViewDelegate { +protocol InteractivePostViewDelegate: AnyObject { func edit(_ post: AbstractPost) func view(_ post: AbstractPost) func stats(for post: AbstractPost) + func duplicate(_ post: AbstractPost) func publish(_ post: AbstractPost) func trash(_ post: AbstractPost) func restore(_ post: AbstractPost) func draft(_ post: AbstractPost) func retry(_ post: AbstractPost) func cancelAutoUpload(_ post: AbstractPost) + func share(_ post: AbstractPost, fromView view: UIView) + func copyLink(_ post: AbstractPost) + func blaze(_ post: AbstractPost) } diff --git a/WordPress/Classes/ViewRelated/Post/PostActionSheet.swift b/WordPress/Classes/ViewRelated/Post/PostActionSheet.swift index e6cb60030479..cc214f729202 100644 --- a/WordPress/Classes/ViewRelated/Post/PostActionSheet.swift +++ b/WordPress/Classes/ViewRelated/Post/PostActionSheet.swift @@ -1,7 +1,7 @@ import Foundation import AutomatticTracks -@objc protocol PostActionSheetDelegate { +protocol PostActionSheetDelegate: AnyObject { func showActionSheet(_ postCardStatusViewModel: PostCardStatusViewModel, from view: UIView) } @@ -43,6 +43,10 @@ class PostActionSheet { actionSheetController.addDefaultActionWithTitle(Titles.stats) { [weak self] _ in self?.interactivePostViewDelegate?.stats(for: post) } + case .duplicate: + actionSheetController.addDefaultActionWithTitle(Titles.duplicate) { [weak self] _ in + self?.interactivePostViewDelegate?.duplicate(post) + } case .publish: actionSheetController.addDefaultActionWithTitle(Titles.publish) { [weak self] _ in self?.interactivePostViewDelegate?.publish(post) @@ -68,8 +72,21 @@ class PostActionSheet { actionSheetController.addDefaultActionWithTitle(Titles.edit) { [weak self] _ in self?.interactivePostViewDelegate?.edit(post) } + case .share: + actionSheetController.addDefaultActionWithTitle(Titles.share) { [weak self] _ in + self?.interactivePostViewDelegate?.share(post, fromView: view) + } + case .blaze: + BlazeEventsTracker.trackEntryPointDisplayed(for: .postsList) + actionSheetController.addDefaultActionWithTitle(Titles.blaze) { [weak self] _ in + self?.interactivePostViewDelegate?.blaze(post) + } case .more: - CrashLogging.logMessage("Cannot handle unexpected button for post action sheet: \(button). This is a configuration error.", level: .error) + WordPressAppDelegate.crashLogging?.logMessage("Cannot handle unexpected button for post action sheet: \(button). This is a configuration error.", level: .error) + case .copyLink: + actionSheetController.addDefaultActionWithTitle(Titles.copyLink) { [weak self] _ in + self?.interactivePostViewDelegate?.copyLink(post) + } } } @@ -86,6 +103,7 @@ class PostActionSheet { static let cancel = NSLocalizedString("Cancel", comment: "Dismiss the post action sheet") static let cancelAutoUpload = NSLocalizedString("Cancel Upload", comment: "Label for the Post List option that cancels automatic uploading of a post.") static let stats = NSLocalizedString("Stats", comment: "Label for post stats option. Tapping displays statistics for a post.") + static let duplicate = NSLocalizedString("Duplicate", comment: "Label for post duplicate option. Tapping creates a copy of the post.") static let publish = NSLocalizedString("Publish Now", comment: "Label for an option that moves a publishes a post immediately") static let draft = NSLocalizedString("Move to Draft", comment: "Label for an option that moves a post to the draft folder") static let delete = NSLocalizedString("Delete Permanently", comment: "Label for the delete post option. Tapping permanently deletes a post.") @@ -93,5 +111,8 @@ class PostActionSheet { static let view = NSLocalizedString("View", comment: "Label for the view post button. Tapping displays the post as it appears on the web.") static let retry = NSLocalizedString("Retry", comment: "Retry uploading the post.") static let edit = NSLocalizedString("Edit", comment: "Edit the post.") + static let share = NSLocalizedString("Share", comment: "Share the post.") + static let blaze = NSLocalizedString("posts.blaze.actionTitle", value: "Promote with Blaze", comment: "Promote the post with Blaze.") + static let copyLink = NSLocalizedString("Copy Link", comment: "Copy the post url and paste anywhere in phone") } } diff --git a/WordPress/Classes/ViewRelated/Post/PostAuthorSelectorViewController.swift b/WordPress/Classes/ViewRelated/Post/PostAuthorSelectorViewController.swift new file mode 100644 index 000000000000..d9af206ff2a9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostAuthorSelectorViewController.swift @@ -0,0 +1,82 @@ +import UIKit + +@objc class PostAuthorSelectorViewController: SettingsSelectionViewController { + /// The post to change the author. + private var post: AbstractPost! + + /// A completion block that is called after the user selects an option. + @objc var completion: (() -> Void)? + + /// Representation of an Author used by the view. + private typealias Author = (displayName: String, userID: NSNumber, avatarURL: String?) + + // MARK: - Constructors + + @objc init(_ post: AbstractPost) { + self.post = post + + let authors = PostAuthorSelectorViewController.sortedActiveAuthors(for: post.blog) + + guard !authors.isEmpty, let currentAuthorID = post.authorID else { + super.init(style: .plain) + return + } + + let authorsDict: [AnyHashable: Any] = [ + "DefaultValue": currentAuthorID, + "Title": NSLocalizedString("Author", comment: "Author label."), + "Titles": authors.map { $0.displayName }, + "Values": authors.map { $0.userID }, + "CurrentValue": currentAuthorID + ] + + super.init(dictionary: authorsDict) + + onItemSelected = { [weak self] authorID in + guard + let authorID = authorID as? NSNumber, + let author = authors.first(where: { $0.userID == authorID }), + !post.isFault, post.managedObjectContext != nil + else { + return + } + + post.authorID = author.userID + post.author = author.displayName + post.authorAvatarURL = author.avatarURL + + self?.completion?() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init!(style: UITableView.Style, andDictionary dictionary: [AnyHashable: Any]!) { + super.init(style: style, andDictionary: dictionary) + } + + override init(style: UITableView.Style) { + super.init(style: style) + } + + // MARK: - Class Methods + + /// Sort authors by their display name in lexicographical order, accounting for diacritical marks. + private static func sortedActiveAuthors(for blog: Blog) -> [Author] { + /// Don't include any deleted authors. + guard let activeAuthors = blog.authors?.filter ({ !$0.deletedFromBlog }) else { + return [] + } + + return activeAuthors.compactMap { + /// Require a display name to be available. + guard let displayName = $0.displayName else { + return nil + } + + return (displayName, $0.userID, $0.avatarURL) + }.sorted(by: { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }) + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostCardCell.swift b/WordPress/Classes/ViewRelated/Post/PostCardCell.swift index 82a4c87dd0dc..b4611e2e91bf 100644 --- a/WordPress/Classes/ViewRelated/Post/PostCardCell.swift +++ b/WordPress/Classes/ViewRelated/Post/PostCardCell.swift @@ -1,3 +1,4 @@ +import AutomatticTracks import UIKit import Gridicons @@ -39,14 +40,18 @@ class PostCardCell: UITableViewCell, ConfigurablePostView { private var currentLoadedFeaturedImage: String? private weak var interactivePostViewDelegate: InteractivePostViewDelegate? private weak var actionSheetDelegate: PostActionSheetDelegate? - var isAuthorHidden: Bool = false { + var shouldHideAuthor: Bool = false { didSet { - authorLabel.isHidden = isAuthorHidden - separatorLabel.isHidden = isAuthorHidden + let emptyAuthor = viewModel?.author.isEmpty ?? true + + authorLabel.isHidden = shouldHideAuthor || emptyAuthor + separatorLabel.isHidden = shouldHideAuthor || emptyAuthor } } func configure(with post: Post) { + assert(post.managedObjectContext != nil) + if post != self.post { viewModel = PostCardStatusViewModel(post: post) } @@ -186,7 +191,7 @@ class PostCardCell: UITableViewCell, ConfigurablePostView { } if let url = post.featuredImageURL, - let desiredWidth = UIApplication.shared.keyWindow?.frame.size.width { + let desiredWidth = UIApplication.shared.mainWindow?.frame.size.width { featuredImageStackView.isHidden = false topPadding.constant = Constants.margin loadFeaturedImageIfNeeded(url, preferredSize: CGSize(width: desiredWidth, height: featuredImage.frame.height)) @@ -201,9 +206,14 @@ class PostCardCell: UITableViewCell, ConfigurablePostView { return } + let host = MediaHost(with: post) { error in + // We'll log the error, so we know it's there, but we won't halt execution. + WordPressAppDelegate.crashLogging?.logError(error) + } + if currentLoadedFeaturedImage != url.absoluteString { currentLoadedFeaturedImage = url.absoluteString - imageLoader.loadImage(with: url, from: post, preferredSize: preferredSize) + imageLoader.loadImage(with: url, from: host, preferredSize: preferredSize) } } @@ -374,7 +384,7 @@ class PostCardCell: UITableViewCell, ConfigurablePostView { private func setupLabels() { retryButton.setTitle(NSLocalizedString("Retry", comment: "Label for the retry post upload button. Tapping attempts to upload the post again."), for: .normal) - retryButton.setImage(Gridicon.iconOfType(.refresh, withSize: CGSize(width: 18, height: 18)), for: .normal) + retryButton.setImage(.gridicon(.refresh, size: CGSize(width: 18, height: 18)), for: .normal) cancelAutoUploadButton.setTitle(NSLocalizedString("Cancel", comment: "Label for the auto-upload cancelation button in the post list. Tapping will prevent the app from auto-uploading the post."), for: .normal) diff --git a/WordPress/Classes/ViewRelated/Post/PostCardCell.xib b/WordPress/Classes/ViewRelated/Post/PostCardCell.xib index 9ebc1d4ddeed..919b583fd874 100644 --- a/WordPress/Classes/ViewRelated/Post/PostCardCell.xib +++ b/WordPress/Classes/ViewRelated/Post/PostCardCell.xib @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19162" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19144"/> <capability name="Named colors" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> @@ -52,7 +52,7 @@ <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <nil key="highlightedColor"/> </label> - <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="1000" verticalHuggingPriority="1000" verticalCompressionResistancePriority="1000" text="Snippet" lineBreakMode="tailTruncation" numberOfLines="3" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="0.0" translatesAutoresizingMaskIntoConstraints="NO" id="POV-pe-wu8"> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1000" verticalCompressionResistancePriority="1000" text="Snippet" lineBreakMode="tailTruncation" numberOfLines="3" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="0.0" translatesAutoresizingMaskIntoConstraints="NO" id="POV-pe-wu8"> <rect key="frame" x="16" y="153" width="294" height="17"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <fontDescription key="fontDescription" type="system" pointSize="14"/> @@ -125,10 +125,10 @@ <edgeInsets key="layoutMargins" top="0.0" left="16" bottom="0.0" right="16"/> </stackView> <stackView hidden="YES" opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="O86-Za-gd3"> - <rect key="frame" x="0.0" y="378" width="326" height="24"/> + <rect key="frame" x="0.0" y="378" width="326" height="0.0"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="pUW-s6-y3t"> - <rect key="frame" x="16" y="0.0" width="294" height="24"/> + <rect key="frame" x="16" y="0.0" width="294" height="0.0"/> <subviews> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Fbo-xb-RzU"> <rect key="frame" x="0.0" y="0.0" width="179" height="0.0"/> @@ -137,13 +137,13 @@ <nil key="highlightedColor"/> </label> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="i9p-kc-f73"> - <rect key="frame" x="0.0" y="16" width="264" height="0.0"/> + <rect key="frame" x="0.0" y="-8" width="264" height="0.0"/> <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> <nil key="textColor"/> <nil key="highlightedColor"/> </label> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="GjI-kU-Yyb"> - <rect key="frame" x="0.0" y="24" width="229" height="0.0"/> + <rect key="frame" x="0.0" y="0.0" width="229" height="0.0"/> <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> <nil key="textColor"/> <nil key="highlightedColor"/> @@ -156,7 +156,7 @@ <constraint firstAttribute="trailing" secondItem="i9p-kc-f73" secondAttribute="trailing" constant="30" id="Lmh-6K-FTN"/> <constraint firstItem="i9p-kc-f73" firstAttribute="leading" secondItem="Fbo-xb-RzU" secondAttribute="leading" id="Xbv-bg-VQw"/> <constraint firstAttribute="trailing" secondItem="Fbo-xb-RzU" secondAttribute="trailing" constant="115" id="eDn-2Z-X3D"/> - <constraint firstItem="i9p-kc-f73" firstAttribute="top" secondItem="Fbo-xb-RzU" secondAttribute="bottom" constant="16" id="h9D-W5-py9"/> + <constraint firstItem="i9p-kc-f73" firstAttribute="top" secondItem="Fbo-xb-RzU" secondAttribute="bottom" priority="999" constant="16" id="h9D-W5-py9"/> <constraint firstAttribute="bottom" secondItem="GjI-kU-Yyb" secondAttribute="bottom" id="iQA-lR-YoB"/> <constraint firstItem="Fbo-xb-RzU" firstAttribute="leading" secondItem="pUW-s6-y3t" secondAttribute="leading" id="koW-HA-yJ4"/> <constraint firstItem="GjI-kU-Yyb" firstAttribute="leading" secondItem="i9p-kc-f73" secondAttribute="leading" id="muB-zW-FhM"/> @@ -238,7 +238,7 @@ <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="ud6-kQ-Qkb"> <rect key="frame" x="217.5" y="8" width="108.5" height="44"/> <constraints> - <constraint firstAttribute="height" constant="44" id="xGx-cq-ksn"/> + <constraint firstAttribute="height" priority="999" constant="44" id="xGx-cq-ksn"/> </constraints> <fontDescription key="fontDescription" type="system" pointSize="15"/> <inset key="titleEdgeInsets" minX="8" minY="0.0" maxX="0.0" maxY="0.0"/> @@ -257,7 +257,7 @@ <progressView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="750" progressViewStyle="bar" progress="0.90000000000000002" translatesAutoresizingMaskIntoConstraints="NO" id="33v-Uz-9S6"> <rect key="frame" x="0.0" y="446" width="326" height="5"/> <constraints> - <constraint firstAttribute="height" constant="4" id="gFE-it-kOj"/> + <constraint firstAttribute="height" priority="999" constant="4" id="gFE-it-kOj"/> </constraints> <color key="trackTintColor" white="1" alpha="1" colorSpace="calibratedWhite"/> </progressView> @@ -266,7 +266,7 @@ <color key="backgroundColor" name="Gray10"/> <constraints> <constraint firstAttribute="height" constant="1" id="Nuu-dr-YF0"/> - <constraint firstAttribute="width" constant="326" id="Zzv-2b-WyX"/> + <constraint firstAttribute="width" priority="999" constant="326" id="Zzv-2b-WyX"/> </constraints> </view> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="9eW-ex-CSu"> @@ -285,12 +285,12 @@ <constraint firstItem="33v-Uz-9S6" firstAttribute="leading" secondItem="Lk7-nZ-b0t" secondAttribute="leading" id="84P-oH-EZa"/> <constraint firstAttribute="trailing" secondItem="kcO-mG-FcD" secondAttribute="trailing" id="AEy-Bg-FgQ"/> <constraint firstAttribute="trailing" secondItem="9eW-ex-CSu" secondAttribute="trailing" id="HUj-hh-Ili"/> - <constraint firstAttribute="trailing" secondItem="JqA-of-VBn" secondAttribute="trailing" id="MY8-mP-ASa"/> - <constraint firstAttribute="bottom" secondItem="JqA-of-VBn" secondAttribute="bottom" id="NwA-tt-aRf"/> + <constraint firstAttribute="trailing" secondItem="JqA-of-VBn" secondAttribute="trailing" priority="999" id="MY8-mP-ASa"/> + <constraint firstAttribute="bottom" secondItem="JqA-of-VBn" secondAttribute="bottom" priority="999" id="NwA-tt-aRf"/> <constraint firstItem="kcO-mG-FcD" firstAttribute="leading" secondItem="Lk7-nZ-b0t" secondAttribute="leading" id="WNn-kw-WAj"/> <constraint firstItem="sev-PH-HG8" firstAttribute="leading" secondItem="Lk7-nZ-b0t" secondAttribute="leading" id="Yrw-8m-Cu0"/> <constraint firstAttribute="bottom" secondItem="9eW-ex-CSu" secondAttribute="bottom" id="a2Y-qy-DA8"/> - <constraint firstItem="JqA-of-VBn" firstAttribute="leading" secondItem="Lk7-nZ-b0t" secondAttribute="leading" id="bPW-Sv-fZu"/> + <constraint firstItem="JqA-of-VBn" firstAttribute="leading" secondItem="Lk7-nZ-b0t" secondAttribute="leading" priority="999" id="bPW-Sv-fZu"/> <constraint firstItem="JqA-of-VBn" firstAttribute="top" secondItem="Lk7-nZ-b0t" secondAttribute="top" constant="16" id="hLL-be-nDD"/> <constraint firstItem="A8f-pJ-oKt" firstAttribute="top" secondItem="kcO-mG-FcD" secondAttribute="bottom" id="rKR-Zt-Tm7"/> <constraint firstAttribute="trailing" secondItem="sev-PH-HG8" secondAttribute="trailing" id="uus-mZ-ErN"/> @@ -349,7 +349,7 @@ <image name="icon-post-actionbar-view" width="18" height="18"/> <image name="icon-post-undo" width="18" height="18"/> <namedColor name="Gray10"> - <color red="0.80392156862745101" green="0.78823529411764703" blue="0.80392156862745101" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <color red="0.76470588235294112" green="0.7686274509803922" blue="0.7803921568627451" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> </namedColor> </resources> </document> diff --git a/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift index 10051a78863d..15c7a51bbad3 100644 --- a/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift @@ -13,9 +13,13 @@ class PostCardStatusViewModel: NSObject { case more case publish case stats + case duplicate case moveToDraft case trash case cancelAutoUpload + case share + case copyLink + case blaze } struct ButtonGroups: Equatable { @@ -32,6 +36,8 @@ class PostCardStatusViewModel: NSObject { private let isInternetReachable: Bool + private let isBlazeFlagEnabled: Bool + var progressBlock: ((Float) -> Void)? = nil { didSet { if let _ = oldValue, let uuid = progressObserverUUID { @@ -48,9 +54,12 @@ class PostCardStatusViewModel: NSObject { } } - init(post: Post, isInternetReachable: Bool = ReachabilityUtils.isInternetReachable()) { + init(post: Post, + isInternetReachable: Bool = ReachabilityUtils.isInternetReachable(), + isBlazeFlagEnabled: Bool = BlazeHelper.isBlazeFlagEnabled()) { self.post = post self.isInternetReachable = isInternetReachable + self.isBlazeFlagEnabled = isBlazeFlagEnabled super.init() } @@ -176,13 +185,28 @@ class PostCardStatusViewModel: NSObject { } if post.status == .publish && post.hasRemote() { - buttons.append(.stats) + if JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() { + buttons.append(.stats) + } + buttons.append(.share) + } + + if isBlazeFlagEnabled && post.canBlaze { + buttons.append(.blaze) + } + + if post.status == .publish || post.status == .draft { + buttons.append(.duplicate) } if post.status != .draft { buttons.append(.moveToDraft) } + if post.status != .trash { + buttons.append(.copyLink) + } + buttons.append(.trash) return buttons diff --git a/WordPress/Classes/ViewRelated/Post/PostCompactCell.swift b/WordPress/Classes/ViewRelated/Post/PostCompactCell.swift index 24727bbeb9d0..834400a4055a 100644 --- a/WordPress/Classes/ViewRelated/Post/PostCompactCell.swift +++ b/WordPress/Classes/ViewRelated/Post/PostCompactCell.swift @@ -1,3 +1,4 @@ +import AutomatticTracks import UIKit import Gridicons @@ -14,6 +15,11 @@ class PostCompactCell: UITableViewCell, ConfigurablePostView { @IBOutlet weak var progressView: UIProgressView! @IBOutlet weak var separator: UIView! + @IBOutlet weak var trailingContentConstraint: NSLayoutConstraint! + + private var iPadReadableLeadingAnchor: NSLayoutConstraint? + private var iPadReadableTrailingAnchor: NSLayoutConstraint? + private weak var actionSheetDelegate: PostActionSheetDelegate? lazy var imageLoader: ImageLoader = { @@ -29,6 +35,7 @@ class PostCompactCell: UITableViewCell, ConfigurablePostView { viewModel = PostCardStatusViewModel(post: post) } } + private var viewModel: PostCardStatusViewModel? func configure(with post: Post) { @@ -70,14 +77,14 @@ class PostCompactCell: UITableViewCell, ConfigurablePostView { WPStyleGuide.configureLabel(timestampLabel, textStyle: .subheadline) WPStyleGuide.configureLabel(badgesLabel, textStyle: .subheadline) - titleLabel.font = WPStyleGuide.notoBoldFontForTextStyle(.headline) + titleLabel.font = AppStyleGuide.prominentFont(textStyle: .headline, weight: .bold) titleLabel.adjustsFontForContentSizeCategory = true titleLabel.textColor = .text timestampLabel.textColor = .textSubtle menuButton.tintColor = .textSubtle - menuButton.setImage(Gridicon.iconOfType(.ellipsis), for: .normal) + menuButton.setImage(.gridicon(.ellipsis), for: .normal) featuredImageView.layer.cornerRadius = Constants.imageRadius @@ -93,14 +100,23 @@ class PostCompactCell: UITableViewCell, ConfigurablePostView { private func setupReadableGuideForiPad() { guard WPDeviceIdentification.isiPad() else { return } - innerView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor).isActive = true - innerView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor).isActive = true + iPadReadableLeadingAnchor = innerView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor) + iPadReadableTrailingAnchor = innerView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor) + + iPadReadableLeadingAnchor?.isActive = true + iPadReadableTrailingAnchor?.isActive = true } private func configureFeaturedImage() { if let post = post, let url = post.featuredImageURL { featuredImageView.isHidden = false - imageLoader.loadImage(with: url, from: post, preferredSize: CGSize(width: featuredImageView.frame.width, height: featuredImageView.frame.height)) + + let host = MediaHost(with: post, failure: { error in + // We'll log the error, so we know it's there, but we won't halt execution. + WordPressAppDelegate.crashLogging?.logError(error) + }) + + imageLoader.loadImage(with: url, from: host, preferredSize: CGSize(width: featuredImageView.frame.width, height: featuredImageView.frame.height)) } else { featuredImageView.isHidden = true } @@ -119,6 +135,15 @@ class PostCompactCell: UITableViewCell, ConfigurablePostView { timestampLabel.isHidden = false } + private func configureExcerpt() { + guard let post = post else { + return + } + + timestampLabel.text = post.contentPreviewForDisplay() + timestampLabel.isHidden = false + } + private func configureStatus() { guard let viewModel = viewModel else { return @@ -165,12 +190,13 @@ class PostCompactCell: UITableViewCell, ConfigurablePostView { static let imageRadius: CGFloat = 2 static let labelsVerticalAlignment: CGFloat = -1 static let opacity: Float = 1 + static let margin: CGFloat = 16 } } extension PostCompactCell: InteractivePostView { func setInteractionDelegate(_ delegate: InteractivePostViewDelegate) { - + // Do nothing, since this cell doesn't support actions in `InteractivePostViewDelegate`. } func setActionSheetDelegate(_ delegate: PostActionSheetDelegate) { @@ -197,3 +223,34 @@ extension PostCompactCell: GhostableView { static let opacity: Float = 0.5 } } + +extension PostCompactCell: NibReusable { } + +// MARK: - For display on the Posts Card (Dashboard) + +extension PostCompactCell { + /// Configure the cell to be displayed in the Posts Card + /// No "more" button and show a description, instead of a date + func configureForDashboard(with post: Post) { + configure(with: post) + separator.isHidden = true + menuButton.isHidden = true + trailingContentConstraint.constant = Constants.margin + headerStackView.spacing = Constants.margin + + disableiPadReadableMargin() + + if !post.isScheduled() { + configureExcerpt() + } + } + + func hideSeparator() { + separator.isHidden = true + } + + func disableiPadReadableMargin() { + iPadReadableLeadingAnchor?.isActive = false + iPadReadableTrailingAnchor?.isActive = false + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostCompactCell.xib b/WordPress/Classes/ViewRelated/Post/PostCompactCell.xib index 82be83dbdf2a..cce35ccd31dc 100644 --- a/WordPress/Classes/ViewRelated/Post/PostCompactCell.xib +++ b/WordPress/Classes/ViewRelated/Post/PostCompactCell.xib @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <device id="retina5_9" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <customFonts key="customFonts"> @@ -31,16 +31,16 @@ <rect key="frame" x="0.0" y="0.0" width="312" height="0.0"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ESK-bT-Bix"> - <rect key="frame" x="0.0" y="-18" width="312" height="36"/> + <rect key="frame" x="0.0" y="-17.333333333333336" width="312" height="34.666666666666664"/> <subviews> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="6QQ-XY-q2l"> - <rect key="frame" x="0.0" y="0.0" width="272" height="14.666666666666666"/> + <rect key="frame" x="0.0" y="0.0" width="272" height="13.333333333333334"/> <fontDescription key="fontDescription" style="UICTFontTextStyleCaption1"/> <nil key="textColor"/> <nil key="highlightedColor"/> </label> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text=" " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="yxn-r2-waJ"> - <rect key="frame" x="0.0" y="22.666666666666664" width="192" height="13.333333333333336"/> + <rect key="frame" x="0.0" y="21.333333333333336" width="192" height="13.333333333333336"/> <fontDescription key="fontDescription" style="UICTFontTextStyleCaption2"/> <nil key="textColor"/> <nil key="highlightedColor"/> @@ -172,6 +172,7 @@ <outlet property="separator" destination="y9d-AS-bhE" id="jTZ-52-pNX"/> <outlet property="timestampLabel" destination="fEo-hS-PK1" id="Ih0-91-vRW"/> <outlet property="titleLabel" destination="ZUq-Dg-elW" id="Dzt-PV-spZ"/> + <outlet property="trailingContentConstraint" destination="U3Z-UB-o0I" id="bo6-AM-2S7"/> </connections> <point key="canvasLocation" x="135" y="179.72222222222223"/> </tableViewCell> diff --git a/WordPress/Classes/ViewRelated/Post/PostEditor+BlogPicker.swift b/WordPress/Classes/ViewRelated/Post/PostEditor+BlogPicker.swift index f98199212d75..c09371461e39 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditor+BlogPicker.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditor+BlogPicker.swift @@ -1,6 +1,6 @@ import UIKit -extension PostEditor where Self: UIViewController { +extension PostEditor { func blogPickerWasPressed() { assert(isSingleSiteMode == false) @@ -21,6 +21,7 @@ extension PostEditor where Self: UIViewController { // Setup Handlers let successHandler: BlogSelectorSuccessHandler = { selectedObjectID in self.dismiss(animated: true) + WPAnalytics.track(.editorPostSiteChanged) guard let blog = self.mainContext.object(with: selectedObjectID) as? Blog else { return @@ -68,8 +69,7 @@ extension PostEditor where Self: UIViewController { // TODO: Rip this and put it into PostService, as well func recreatePostRevision(in blog: Blog) { let shouldCreatePage = post is Page - let postService = PostService(managedObjectContext: mainContext) - let newPost = shouldCreatePage ? postService.createDraftPage(for: blog) : postService.createDraftPost(for: blog) + let newPost = shouldCreatePage ? blog.createDraftPage() : blog.createDraftPost() // if it's a reblog, use the existing content and don't strip the image newPost.content = postIsReblogged ? post.content : contentByStrippingMediaAttachments() newPost.postTitle = post.postTitle diff --git a/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift b/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift index d834a388d2f5..f414d444a80d 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditor+MoreOptions.swift @@ -1,7 +1,7 @@ import Foundation import WordPressFlux -extension PostEditor where Self: UIViewController { +extension PostEditor { func displayPostSettings() { let settingsViewController: PostSettingsViewController @@ -10,6 +10,7 @@ extension PostEditor where Self: UIViewController { } else { settingsViewController = PostSettingsViewController(post: post) } + settingsViewController.featuredImageDelegate = self as? FeaturedImageDelegate settingsViewController.hidesBottomBarWhenPushed = true self.navigationController?.pushViewController(settingsViewController, animated: true) } @@ -32,7 +33,7 @@ extension PostEditor where Self: UIViewController { return } - navigationBarManager.reloadLeftBarButtonItems(navigationBarManager.generatingPreviewLeftBarButtonItems) + navigationBarManager.reloadTitleView(navigationBarManager.generatingPreviewTitleView) postService.autoSave(post, success: { [weak self] savedPost, previewURL in @@ -65,12 +66,26 @@ extension PostEditor where Self: UIViewController { } func displayPreview() { + guard !isUploadingMedia else { + displayMediaIsUploadingAlert() + return + } + + guard post.remoteStatus != .pushing else { + displayPostIsUploadingAlert() + return + } + + emitPostSaveEvent() + savePostBeforePreview() { [weak self] previewURLString, error in guard let self = self else { return } + let navigationBarManager = self.navigationBarManager - navigationBarManager.reloadLeftBarButtonItems(navigationBarManager.leftBarButtonItems) + navigationBarManager.reloadTitleView(navigationBarManager.blogTitleViewLabel) + if error != nil { let title = NSLocalizedString("Preview Unavailable", comment: "Title on display preview error" ) self.displayPreviewNotAvailable(title: title) @@ -79,17 +94,20 @@ extension PostEditor where Self: UIViewController { let previewController: PreviewWebKitViewController if let previewURLString = previewURLString, let previewURL = URL(string: previewURLString) { - previewController = PreviewWebKitViewController(post: self.post, previewURL: previewURL) + previewController = PreviewWebKitViewController(post: self.post, previewURL: previewURL, source: "edit_post_more_preview") } else { if self.post.permaLink == nil { DDLogError("displayPreview: Post permalink is unexpectedly nil") self.displayPreviewNotAvailable(title: NSLocalizedString("Preview Unavailable", comment: "Title on display preview error" )) return } - previewController = PreviewWebKitViewController(post: self.post) + previewController = PreviewWebKitViewController(post: self.post, source: "edit_post_more_preview") } previewController.trackOpenEvent() let navWrapper = LightNavigationController(rootViewController: previewController) + if self.navigationController?.traitCollection.userInterfaceIdiom == .pad { + navWrapper.modalPresentationStyle = .fullScreen + } self.navigationController?.present(navWrapper, animated: true) } } diff --git a/WordPress/Classes/ViewRelated/Post/PostEditor+Publish.swift b/WordPress/Classes/ViewRelated/Post/PostEditor+Publish.swift index 9f7c42eb4358..34e1cc50ce0c 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditor+Publish.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditor+Publish.swift @@ -1,7 +1,71 @@ import Foundation import WordPressFlux -extension PostEditor where Self: UIViewController { +protocol PublishingEditor where Self: UIViewController { + //TODO: Add publishing things + var post: AbstractPost { get set } + + var isUploadingMedia: Bool { get } + + /// Post editor state context + var postEditorStateContext: PostEditorStateContext { get } + + /// Editor Session information for analytics reporting + var editorSession: PostEditorAnalyticsSession { get set } + + /// Verification prompt helper + var verificationPromptHelper: VerificationPromptHelper? { get } + + /// Describes the editor type to be used in analytics reporting + var analyticsEditorSource: String { get } + + /// Title of the post + var postTitle: String { get set } + + var prepublishingSourceView: UIView? { get } + + var alertBarButtonItem: UIBarButtonItem? { get } + + /// Closure to be executed when the editor gets closed. + var onClose: ((_ changesSaved: Bool, _ shouldShowPostPost: Bool) -> Void)? { get set } + + /// Return the current html in the editor + func getHTML() -> String + + /// Cancels all ongoing uploads + func cancelUploadOfAllMedia(for post: AbstractPost) + + /// When the Prepublishing sheet or Prepublishing alert is dismissed, this is called. + func publishingDismissed() + + func removeFailedMedia() + + /// Returns the word counts of the content in the editor. + var wordCount: UInt { get } + + /// Debouncer used to save the post locally with a delay + var debouncer: Debouncer { get } + + var prepublishingIdentifiers: [PrepublishingIdentifier] { get } + + func emitPostSaveEvent() +} + +var postPublishedReceipt: Receipt? + +extension PublishingEditor { + + func publishingDismissed() { + // Default implementation is empty, can be optionally implemented by other classes. + } + + func emitPostSaveEvent() { + // Default implementation is empty, can be optionally implemented by other classes. + } + + func removeFailedMedia() { + // TODO: we can only implement this when GB bridge allows removal of blocks + } // The debouncer will perform this callback every 500ms in order to save the post locally with a delay. var debouncerCallback: (() -> Void) { @@ -28,6 +92,8 @@ extension PostEditor where Self: UIViewController { analyticsStat: self.postEditorStateContext.publishActionAnalyticsStat) } + + func publishPost( action: PostEditorAction, dismissWhenDone: Bool, @@ -55,8 +121,6 @@ extension PostEditor where Self: UIViewController { return } - let isPage = post is Page - let publishBlock = { [unowned self] in if action == .saveAsDraft { self.post.status = .draft @@ -78,10 +142,33 @@ extension PostEditor where Self: UIViewController { self.post.status = .pending } + self.post.isFirstTimePublish = action == .publish || action == .publishNow + self.post.shouldAttemptAutoUpload = true + emitPostSaveEvent() + if let analyticsStat = analyticsStat { - self.trackPostSave(stat: analyticsStat) + if self is StoryEditor { + postPublishedReceipt = ActionDispatcher.global.subscribe({ [self] action in + if let noticeAction = action as? NoticeAction { + switch noticeAction { + case .post: + self.trackPostSave(stat: analyticsStat) + default: + break + } + postPublishedReceipt = nil + } + }) + } else { + self.trackPostSave(stat: analyticsStat) + } + } + + if self.post.isFirstTimePublish { + QuickStartTourGuide.shared.complete(tour: QuickStartPublishTour(), + silentlyForBlog: self.post.blog) } if dismissWhenDone { @@ -97,30 +184,28 @@ extension PostEditor where Self: UIViewController { } } - let promoBlock = { [unowned self] in - UserDefaults.standard.asyncPromoWasDisplayed = true - - let controller = FancyAlertViewController.makeAsyncPostingAlertController(action: action, isPage: isPage, onConfirm: publishBlock) - controller.modalPresentationStyle = .custom - controller.transitioningDelegate = self - self.present(controller, animated: true, completion: nil) - } - + if action.isAsync, + let postStatus = self.post.original?.status ?? self.post.status, + ![.publish, .publishPrivate].contains(postStatus) { + WPAnalytics.track(.editorPostPublishTap) - if action.isAsync && - !UserDefaults.standard.asyncPromoWasDisplayed { - promoBlock() - } else if action.isAsync, - let postStatus = self.post.status, - ![.publish, .publishPrivate].contains(postStatus) { // Only display confirmation alert for unpublished posts - displayPublishConfirmationAlert(for: action, onPublish: publishBlock) + displayPublishConfirmationAlert(for: action, onPublish: publishBlock, onDismiss: { [weak self] in + self?.publishingDismissed() + WPAnalytics.track(.editorPostPublishDismissed) + }) } else { publishBlock() } } - fileprivate func displayMediaIsUploadingAlert() { + func displayPostIsUploadingAlert() { + let alertController = UIAlertController(title: PostUploadingAlert.title, message: PostUploadingAlert.message, preferredStyle: .alert) + alertController.addDefaultActionWithTitle(PostUploadingAlert.acceptTitle) + present(alertController, animated: true, completion: nil) + } + + func displayMediaIsUploadingAlert() { let alertController = UIAlertController(title: MediaUploadingAlert.title, message: MediaUploadingAlert.message, preferredStyle: .alert) alertController.addDefaultActionWithTitle(MediaUploadingAlert.acceptTitle) present(alertController, animated: true, completion: nil) @@ -137,20 +222,65 @@ extension PostEditor where Self: UIViewController { present(alertController, animated: true, completion: nil) } + /// If the user is publishing a post, displays the Prepublishing Nudges + /// Otherwise, shows a confirmation Action Sheet. + /// + /// - Parameters: + /// - action: Publishing action being performed + /// + fileprivate func displayPublishConfirmationAlert(for action: PostEditorAction, onPublish publishAction: @escaping () -> (), onDismiss dismissAction: @escaping () -> ()) { + if let post = post as? Post { + displayPrepublishingNudges(post: post, onPublish: publishAction, onDismiss: dismissAction) + } else { + displayPublishConfirmationAlertForPage(for: action, onPublish: publishAction, onDismiss: dismissAction) + } + } + + /// Displays the Prepublishing Nudges Bottom Sheet + /// + /// - Parameters: + /// - action: Publishing action being performed + /// + fileprivate func displayPrepublishingNudges(post: Post, onPublish publishAction: @escaping () -> (), onDismiss dismissAction: @escaping () -> ()) { + // End editing to avoid issues with accessibility + view.endEditing(true) + + let prepublishing = PrepublishingViewController(post: post, identifiers: prepublishingIdentifiers) { [weak self] result in + switch result { + case .completed(let post): + self?.post = post + publishAction() + case .dismissed: + dismissAction() + } + } + + let isTitleDisplayed = prepublishingIdentifiers.contains { $0 == .title } + let shouldDisplayPortrait = WPDeviceIdentification.isiPhone() && isTitleDisplayed + let prepublishingNavigationController = PrepublishingNavigationController(rootViewController: prepublishing, shouldDisplayPortrait: shouldDisplayPortrait) + let bottomSheet = BottomSheetViewController(childViewController: prepublishingNavigationController, customHeaderSpacing: 0) + if let sourceView = prepublishingSourceView { + bottomSheet.show(from: self, sourceView: sourceView) + } else { + bottomSheet.show(from: self.topmostPresentedViewController) + } + } + /// Displays a publish confirmation alert with two options: "Keep Editing" and String for Action. /// /// - Parameters: /// - action: Publishing action being performed - /// - dismissWhenDone: if `true`, the VC will be dismissed if the user picks "Publish". /// - fileprivate func displayPublishConfirmationAlert(for action: PostEditorAction, onPublish publishAction: @escaping () -> ()) { + fileprivate func displayPublishConfirmationAlertForPage(for action: PostEditorAction, onPublish publishAction: @escaping () -> (), onDismiss dismissAction: @escaping () -> ()) { let title = action.publishingActionQuestionLabel let keepEditingTitle = NSLocalizedString("Keep Editing", comment: "Button shown when the author is asked for publishing confirmation.") let publishTitle = action.publishActionLabel let style: UIAlertController.Style = UIDevice.isPad() ? .alert : .actionSheet let alertController = UIAlertController(title: title, message: nil, preferredStyle: style) - alertController.addCancelActionWithTitle(keepEditingTitle) + alertController.addCancelActionWithTitle(keepEditingTitle) { _ in + dismissAction() + } alertController.addDefaultActionWithTitle(publishTitle) { _ in publishAction() } @@ -158,23 +288,27 @@ extension PostEditor where Self: UIViewController { } private func trackPostSave(stat: WPAnalyticsStat) { + let postTypeValue = post is Page ? "page" : "post" + guard stat != .editorSavedDraft && stat != .editorQuickSavedDraft else { - WPAppAnalytics.track(stat, withProperties: [WPAppAnalyticsKeyEditorSource: analyticsEditorSource], with: post.blog) + WPAppAnalytics.track(stat, withProperties: [WPAppAnalyticsKeyEditorSource: analyticsEditorSource, WPAppAnalyticsKeyPostType: postTypeValue], with: post.blog) return } - let originalWordCount = post.original?.content?.wordCount() ?? 0 - let wordCount = post.content?.wordCount() ?? 0 + let wordCount = self.wordCount var properties: [String: Any] = ["word_count": wordCount, WPAppAnalyticsKeyEditorSource: analyticsEditorSource] - if post.hasRemote() { - properties["word_diff_count"] = originalWordCount - } + + properties[WPAppAnalyticsKeyPostType] = postTypeValue if stat == .editorPublishedPost { properties[WPAnalyticsStatEditorPublishedPostPropertyCategory] = post.hasCategories() properties[WPAnalyticsStatEditorPublishedPostPropertyPhoto] = post.hasPhoto() properties[WPAnalyticsStatEditorPublishedPostPropertyTag] = post.hasTags() properties[WPAnalyticsStatEditorPublishedPostPropertyVideo] = post.hasVideo() + + if let post = post as? Post, let promptId = post.bloggingPromptID { + properties["prompt_id"] = promptId + } } WPAppAnalytics.track(stat, withProperties: properties, with: post) @@ -191,7 +325,12 @@ extension PostEditor where Self: UIViewController { /// Otherwise, we'll show an Action Sheet with options. if post.shouldAttemptAutoUpload && post.canSave() { editorSession.end(outcome: .cancel) - dismissOrPopView(didSave: false) + /// If there are ongoing media uploads, save with completion processing + if MediaCoordinator.shared.isUploadingMedia(for: post) { + resumeSaving() + } else { + dismissOrPopView(didSave: false) + } } else if post.canSave() { showPostHasChangesAlert() } else { @@ -200,6 +339,13 @@ extension PostEditor where Self: UIViewController { } } + private func resumeSaving() { + post.shouldAttemptAutoUpload = false + let action: PostEditorAction = post.status == .draft ? .update : .publish + self.postEditorStateContext.action = action + self.publishPost(action: action, dismissWhenDone: true, analyticsStat: nil) + } + func discardUnsavedChangesAndUpdateGUI() { let postDeleted = discardChanges() dismissOrPopView(didSave: !postDeleted) @@ -285,7 +431,9 @@ extension PostEditor where Self: UIViewController { // The post is a local or remote draft alertController.addDefaultActionWithTitle(title) { _ in - self.publishPost(action: self.editorAction(), dismissWhenDone: true, analyticsStat: self.postEditorStateContext.publishActionAnalyticsStat) + let action = self.editorAction() + self.postEditorStateContext.action = action + self.publishPost(action: action, dismissWhenDone: true, analyticsStat: self.postEditorStateContext.publishActionAnalyticsStat) } } @@ -295,7 +443,7 @@ extension PostEditor where Self: UIViewController { self.discardUnsavedChangesAndUpdateGUI() } - alertController.popoverPresentationController?.barButtonItem = navigationBarManager.closeBarButtonItem + alertController.popoverPresentationController?.barButtonItem = alertBarButtonItem present(alertController, animated: true, completion: nil) } @@ -304,11 +452,15 @@ extension PostEditor where Self: UIViewController { return .save } - return post.status == .draft ? .saveAsDraft : .publish + if post.isLocalDraft { + return .save + } + + return post.status == .draft ? .update : .publish } } -extension PostEditor where Self: UIViewController { +extension PublishingEditor { func presentationController(forPresented presented: UIViewController, presenting: UIViewController?) -> UIPresentationController? { guard presented is FancyAlertViewController else { return nil @@ -320,7 +472,7 @@ extension PostEditor where Self: UIViewController { // MARK: - Publishing -extension PostEditor where Self: UIViewController { +extension PublishingEditor { /// Shows the publishing overlay and starts the publishing process. /// @@ -373,7 +525,8 @@ extension PostEditor where Self: UIViewController { PostCoordinator.shared.save(post) - dismissOrPopView() + let presentBloggingReminders = Feature.enabled(.bloggingReminders) && JetpackNotificationMigrationService.shared.shouldPresentNotifications() + dismissOrPopView(presentBloggingReminders: presentBloggingReminders) self.postEditorStateContext.updated(isBeingPublished: false) } @@ -390,17 +543,36 @@ extension PostEditor where Self: UIViewController { post = originalPost } - func dismissOrPopView(didSave: Bool = true) { + func dismissOrPopView(didSave: Bool = true, presentBloggingReminders: Bool = false) { stopEditing() WPAppAnalytics.track(.editorClosed, withProperties: [WPAppAnalyticsKeyEditorSource: analyticsEditorSource], with: post) if let onClose = onClose { + // if this closure exists, the presentation of the Blogging Reminders flow (if needed) + // needs to happen in the closure. onClose(didSave, false) - } else if isModal() { - presentingViewController?.dismiss(animated: true, completion: nil) + } else if isModal(), let controller = presentingViewController { + controller.dismiss(animated: true) { + if presentBloggingReminders { + BloggingRemindersFlow.present(from: controller, + for: self.post.blog, + source: .publishFlow, + alwaysShow: false) + } + } } else { - _ = navigationController?.popViewController(animated: true) + navigationController?.popViewController(animated: true) + guard let controller = navigationController?.topViewController else { + return + } + + if presentBloggingReminders { + BloggingRemindersFlow.present(from: controller, + for: self.post.blog, + source: .publishFlow, + alwaysShow: false) + } } } @@ -465,6 +637,14 @@ extension PostEditor where Self: UIViewController { ContextManager.sharedInstance().save(managedObjectContext) } } + + var uploadFailureNoticeTag: Notice.Tag { + return "PostEditor.UploadFailed" + } + + func uploadFailureNotice(action: PostEditorAction) -> Notice { + return Notice(title: action.publishingErrorLabel, tag: uploadFailureNoticeTag) + } } struct PostEditorDebouncerConstants { @@ -477,6 +657,12 @@ private struct MediaUploadingAlert { static let acceptTitle = NSLocalizedString("OK", comment: "Accept Action") } +private struct PostUploadingAlert { + static let title = NSLocalizedString("Uploading post", comment: "Title for alert when trying to preview a post before the uploading process is complete.") + static let message = NSLocalizedString("Your post is currently being uploaded. Please wait until this completes.", comment: "This is a notification the user receives if they are trying to preview a post before the upload process is complete.") + static let acceptTitle = NSLocalizedString("OK", comment: "Accept Action") +} + private struct FailedMediaRemovalAlert { static let title = NSLocalizedString("Uploads failed", comment: "Title for alert when trying to save post with failed media items") static let message = NSLocalizedString("Some media uploads failed. This action will remove all failed media from the post.\nSave anyway?", comment: "Confirms with the user if they save the post all media that failed to upload will be removed from it.") diff --git a/WordPress/Classes/ViewRelated/Post/PostEditor.swift b/WordPress/Classes/ViewRelated/Post/PostEditor.swift index c766a0491c2a..7badef9fb5b9 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditor.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditor.swift @@ -16,19 +16,16 @@ enum EditMode { } typealias EditorViewController = UIViewController & PostEditor +typealias ReplaceEditorCallback = (EditorViewController, EditorViewController) -> () /// Common interface to all editors /// -protocol PostEditor: class, UIViewControllerTransitioningDelegate { +protocol PostEditor: PublishingEditor, UIViewControllerTransitioningDelegate { /// The post being edited. /// var post: AbstractPost { get set } - /// Closure to be executed when the editor gets closed. - /// - var onClose: ((_ changesSaved: Bool, _ shouldShowPostPost: Bool) -> Void)? { get set } - /// Whether the editor should open directly to the media picker. /// var isOpenedDirectlyForPhotoPost: Bool { get set } @@ -44,7 +41,7 @@ protocol PostEditor: class, UIViewControllerTransitioningDelegate { init( post: AbstractPost, loadAutosaveRevision: Bool, - replaceEditor: @escaping (EditorViewController, EditorViewController) -> (), + replaceEditor: @escaping ReplaceEditorCallback, editorSession: PostEditorAnalyticsSession?) /// Media items to be inserted on the post after creation @@ -63,8 +60,6 @@ protocol PostEditor: class, UIViewControllerTransitioningDelegate { var isUploadingMedia: Bool { get } - func removeFailedMedia() - /// Verification prompt helper var verificationPromptHelper: VerificationPromptHelper? { get } @@ -95,9 +90,6 @@ protocol PostEditor: class, UIViewControllerTransitioningDelegate { /// Returns the media attachment removed version of html func contentByStrippingMediaAttachments() -> String - /// Debouncer used to save the post locally with a delay - var debouncer: Debouncer { get } - /// Navigation bar manager for this post editor var navigationBarManager: PostEditorNavigationBarManager { get } @@ -106,11 +98,15 @@ protocol PostEditor: class, UIViewControllerTransitioningDelegate { /// Closure to call when the editor needs to be replaced with a different editor /// First argument is the existing editor, second argument is the replacement editor - var replaceEditor: (EditorViewController, EditorViewController) -> () { get } + var replaceEditor: ReplaceEditorCallback { get } var autosaver: Autosaver { get set } + /// true if the post is the result of a reblog var postIsReblogged: Bool { get set } + + /// From where the editor was shown (for analytics reporting) + var entryPoint: PostEditorEntryPoint { get set } } extension PostEditor { @@ -133,19 +129,34 @@ extension PostEditor { } var currentBlogCount: Int { - let service = BlogService(managedObjectContext: mainContext) - return postIsReblogged ? service.blogCountForWPComAccounts() : service.blogCountForAllAccounts() + return postIsReblogged ? BlogQuery().hostedByWPCom(true).count(in: mainContext) : Blog.count(in: mainContext) } var isSingleSiteMode: Bool { return currentBlogCount <= 1 || post.hasRemote() } - var uploadFailureNoticeTag: Notice.Tag { - return "PostEditor.UploadFailed" + var alertBarButtonItem: UIBarButtonItem? { + return navigationBarManager.closeBarButtonItem + } + + var prepublishingSourceView: UIView? { + return navigationBarManager.publishButton } - func uploadFailureNotice(action: PostEditorAction) -> Notice { - return Notice(title: action.publishingErrorLabel, tag: uploadFailureNoticeTag) + var prepublishingIdentifiers: [PrepublishingIdentifier] { + return [.visibility, .schedule, .tags, .categories] } } + +enum PostEditorEntryPoint: String { + case unknown + case postsList + case pagesList + case dashboard + case bloggingPromptsFeatureIntroduction = "blogging_prompts_introduction" + case bloggingPromptsActionSheetHeader = "add_new_sheet_answer_prompt" + case bloggingPromptsNotification = "blogging_reminders_notification_answer_prompt" + case bloggingPromptsDashboardCard = "my_site_card_answer_prompt" + case bloggingPromptsListView = "blogging_prompts_list_view" +} diff --git a/WordPress/Classes/ViewRelated/Post/PostEditorAnalyticsSession.swift b/WordPress/Classes/ViewRelated/Post/PostEditorAnalyticsSession.swift index ab1eae5d03ba..9a98ad75eb5e 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditorAnalyticsSession.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditorAnalyticsSession.swift @@ -3,46 +3,56 @@ import Foundation struct PostEditorAnalyticsSession { private let sessionId = UUID().uuidString let postType: String + let blogID: NSNumber? let blogType: String let contentType: String var started = false var currentEditor: Editor var hasUnsupportedBlocks = false var outcome: Outcome? = nil - var template: String? + var entryPoint: PostEditorEntryPoint? + private let startTime = DispatchTime.now().uptimeNanoseconds init(editor: Editor, post: AbstractPost) { currentEditor = editor postType = post.analyticsPostType ?? "unsupported" + blogID = post.blog.dotComID blogType = post.blog.analyticsType.rawValue contentType = ContentType(post: post).rawValue } - mutating func start(unsupportedBlocks: [String] = []) { + mutating func start(unsupportedBlocks: [String] = [], galleryWithImageBlocks: Bool? = nil) { assert(!started, "An editor session was attempted to start more than once") hasUnsupportedBlocks = !unsupportedBlocks.isEmpty - let properties = startEventProperties(with: unsupportedBlocks) + let properties = startEventProperties(with: unsupportedBlocks, galleryWithImageBlocks: galleryWithImageBlocks) WPAppAnalytics.track(.editorSessionStart, withProperties: properties) started = true } - mutating func apply(template: String) { - self.template = template - WPAnalytics.track(.editorSessionTemplateApply, withProperties: commonProperties) - } + private func startEventProperties(with unsupportedBlocks: [String], galleryWithImageBlocks: Bool?) -> [String: Any] { + // On Android, we are tracking this in milliseconds, which seems like a good enough time scale + // Let's make sure to round the value and send an integer for consistency + let startupTimeNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime + let startupTimeMilliseconds = Int(Double(startupTimeNanoseconds) / 1_000_000) + var properties: [String: Any] = [ Property.startupTime: startupTimeMilliseconds ] + + // Tracks custom event types can't be arrays so we need to convert this to JSON + if let data = try? JSONSerialization.data(withJSONObject: unsupportedBlocks, options: .fragmentsAllowed) { + let blocksJSON = String(data: data, encoding: .utf8) + properties[Property.unsupportedBlocks] = blocksJSON + } - func preview(template: String) { - let properties = commonProperties.merging([ Property.template: template], uniquingKeysWith: { $1 }) + if let galleryWithImageBlocks = galleryWithImageBlocks { + properties[Property.unstableGalleryWithImageBlocks] = "\(galleryWithImageBlocks)" + } else { + properties[Property.unstableGalleryWithImageBlocks] = "unknown" + } - WPAnalytics.track(.editorSessionTemplatePreview, withProperties: properties) - } + properties[Property.entryPoint] = (entryPoint ?? .unknown).rawValue - private func startEventProperties(with unsupportedBlocks: [String]) -> [String: Any] { - return [ - Property.unsupportedBlocks: unsupportedBlocks - ].merging(commonProperties, uniquingKeysWith: { $1 }) + return properties.merging(commonProperties, uniquingKeysWith: { $1 }) } mutating func `switch`(editor: Editor) { @@ -65,7 +75,10 @@ struct PostEditorAnalyticsSession { func end(outcome endOutcome: Outcome) { let outcome = self.outcome ?? endOutcome - let properties = [ Property.outcome: outcome.rawValue].merging(commonProperties, uniquingKeysWith: { $1 }) + let properties: [String: Any] = [ + Property.outcome: outcome.rawValue, + Property.entryPoint: (entryPoint ?? .unknown).rawValue + ].merging(commonProperties, uniquingKeysWith: { $1 }) WPAppAnalytics.track(.editorSessionEnd, withProperties: properties) } @@ -73,6 +86,7 @@ struct PostEditorAnalyticsSession { private extension PostEditorAnalyticsSession { enum Property { + static let blogID = "blog_id" static let blogType = "blog_type" static let contentType = "content_type" static let editor = "editor" @@ -82,6 +96,9 @@ private extension PostEditorAnalyticsSession { static let outcome = "outcome" static let sessionId = "session_id" static let template = "template" + static let startupTime = "startup_time_ms" + static let unstableGalleryWithImageBlocks = "unstable_gallery_with_image_blocks" + static let entryPoint = "entry_point" } var commonProperties: [String: String] { @@ -89,10 +106,10 @@ private extension PostEditorAnalyticsSession { Property.editor: currentEditor.rawValue, Property.contentType: contentType, Property.postType: postType, + Property.blogID: blogID?.stringValue, Property.blogType: blogType, Property.sessionId: sessionId, Property.hasUnsupportedBlocks: hasUnsupportedBlocks ? "1" : "0", - Property.template: template ].compactMapValues { $0 } } } @@ -100,6 +117,7 @@ private extension PostEditorAnalyticsSession { extension PostEditorAnalyticsSession { enum Editor: String { case gutenberg + case stories case classic case html } diff --git a/WordPress/Classes/ViewRelated/Post/PostEditorNavigationBarManager.swift b/WordPress/Classes/ViewRelated/Post/PostEditorNavigationBarManager.swift index d43fdfee960b..b81d4d5cce67 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditorNavigationBarManager.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditorNavigationBarManager.swift @@ -1,6 +1,6 @@ import Gridicons -protocol PostEditorNavigationBarManagerDelegate: class { +protocol PostEditorNavigationBarManagerDelegate: AnyObject { var publishButtonText: String { get } var isPublishButtonEnabled: Bool { get } var uploadingButtonSize: CGSize { get } @@ -11,7 +11,7 @@ protocol PostEditorNavigationBarManagerDelegate: class { func navigationBarManager(_ manager: PostEditorNavigationBarManager, blogPickerWasPressed sender: UIButton) func navigationBarManager(_ manager: PostEditorNavigationBarManager, publishButtonWasPressed sender: UIButton) func navigationBarManager(_ manager: PostEditorNavigationBarManager, displayCancelMediaUploads sender: UIButton) - func navigationBarManager(_ manager: PostEditorNavigationBarManager, reloadLeftNavigationItems items: [UIBarButtonItem]) + func navigationBarManager(_ manager: PostEditorNavigationBarManager, reloadTitleView view: UIView) } // A class to share the navigation bar UI of the Post Editor. @@ -30,11 +30,12 @@ class PostEditorNavigationBarManager { cancelButton.rightSpacing = Constants.cancelButtonPadding.right cancelButton.setContentHuggingPriority(.required, for: .horizontal) cancelButton.accessibilityIdentifier = "editor-close-button" + cancelButton.tintColor = .editorPrimary return cancelButton }() private lazy var moreButton: UIButton = { - let image = Gridicon.iconOfType(.ellipsis) + let image = UIImage.gridicon(.ellipsis) let button = UIButton(type: .system) button.setImage(image, for: .normal) button.frame = CGRect(origin: .zero, size: image.size) @@ -55,6 +56,14 @@ class PostEditorNavigationBarManager { return button }() + /// Blog TitleView Label + lazy var blogTitleViewLabel: UILabel = { + let label = UILabel() + label.textColor = .appBarText + label.font = Fonts.blogTitle + return label + }() + /// Publish Button private(set) lazy var publishButton: UIButton = { let button = UIButton(type: .system) @@ -63,6 +72,7 @@ class PostEditorNavigationBarManager { button.sizeToFit() button.isEnabled = delegate?.isPublishButtonEnabled ?? false button.setContentHuggingPriority(.required, for: .horizontal) + button.tintColor = .editorPrimary return button }() @@ -84,21 +94,11 @@ class PostEditorNavigationBarManager { return view }() - /// Draft Saving Button - /// - private lazy var savingDraftButton: WPUploadStatusButton = { - let button = WPUploadStatusButton(frame: CGRect(origin: .zero, size: delegate?.savingDraftButtonSize ?? .zero)) - button.setTitle(NSLocalizedString("Saving Draft", comment: "Message to indicate progress of saving draft"), for: .normal) - button.translatesAutoresizingMaskIntoConstraints = false - button.setContentHuggingPriority(.defaultLow, for: .horizontal) - return button - }() - // MARK: - Bar button items /// Negative Offset BarButtonItem: Used to fine tune navigationBar Items /// - private lazy var separatorButtonItem: UIBarButtonItem = { + internal lazy var separatorButtonItem: UIBarButtonItem = { let separator = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) return separator }() @@ -113,39 +113,6 @@ class PostEditorNavigationBarManager { return cancelItem }() - - /// NavigationBar's Blog Picker Button - /// - private lazy var blogPickerBarButtonItem: UIBarButtonItem = { - let pickerItem = UIBarButtonItem(customView: self.blogPickerButton) - pickerItem.accessibilityLabel = NSLocalizedString("Switch Blog", comment: "Action button to switch the blog to which you'll be posting") - return pickerItem - }() - - /// Media Uploading Status Button - /// - private lazy var mediaUploadingBarButtonItem: UIBarButtonItem = { - let barButton = UIBarButtonItem(customView: self.mediaUploadingButton) - barButton.accessibilityLabel = NSLocalizedString("Media Uploading", comment: "Message to indicate progress of uploading media to server") - return barButton - }() - - /// Preview Generating Status Button - /// - private lazy var previewGeneratingBarButtonItem: UIBarButtonItem = { - let barButton = UIBarButtonItem(customView: self.previewGeneratingView) - barButton.accessibilityLabel = NSLocalizedString("Generating Preview", comment: "Message to indicate progress of generating preview") - return barButton - }() - - /// Saving draft Status Button - /// - private lazy var savingDraftBarButtonItem: UIBarButtonItem = { - let barButton = UIBarButtonItem(customView: self.savingDraftButton) - barButton.accessibilityLabel = NSLocalizedString("Saving Draft", comment: "Message to indicate progress of saving draft") - return barButton - }() - /// Publish Button private(set) lazy var publishBarButtonItem: UIBarButtonItem = { let button = UIBarButtonItem(customView: self.publishButton) @@ -185,19 +152,15 @@ class PostEditorNavigationBarManager { // MARK: - Public var leftBarButtonItems: [UIBarButtonItem] { - return [separatorButtonItem, closeBarButtonItem, blogPickerBarButtonItem] - } - - var uploadingMediaLeftBarButtonItems: [UIBarButtonItem] { - return [separatorButtonItem, closeBarButtonItem, mediaUploadingBarButtonItem] + return [separatorButtonItem, closeBarButtonItem] } - var generatingPreviewLeftBarButtonItems: [UIBarButtonItem] { - return [separatorButtonItem, closeBarButtonItem, previewGeneratingBarButtonItem] + var uploadingMediaTitleView: UIView { + mediaUploadingButton } - var savingDraftLeftBarButtonItems: [UIBarButtonItem] { - return [separatorButtonItem, closeBarButtonItem, savingDraftBarButtonItem] + var generatingPreviewTitleView: UIView { + previewGeneratingView } var rightBarButtonItems: [UIBarButtonItem] { @@ -206,20 +169,16 @@ class PostEditorNavigationBarManager { func reloadPublishButton() { publishButton.setTitle(delegate?.publishButtonText ?? "", for: .normal) + publishButton.sizeToFit() publishButton.isEnabled = delegate?.isPublishButtonEnabled ?? true } - func reloadBlogPickerButton(with title: String, enabled: Bool) { - - let titleText = NSAttributedString(string: title, attributes: [.font: Fonts.blogPicker]) - - blogPickerButton.setAttributedTitle(titleText, for: .normal) - blogPickerButton.buttonMode = enabled ? .multipleSite : .singleSite - blogPickerButton.isEnabled = enabled + func reloadBlogTitleView(text: String) { + blogTitleViewLabel.text = text } - func reloadLeftBarButtonItems(_ items: [UIBarButtonItem]) { - delegate?.navigationBarManager(self, reloadLeftNavigationItems: items) + func reloadTitleView(_ view: UIView) { + delegate?.navigationBarManager(self, reloadTitleView: view) } } @@ -230,10 +189,12 @@ extension PostEditorNavigationBarManager { private enum Fonts { static let semiBold = WPFontManager.systemSemiBoldFont(ofSize: 16) - static let blogPicker = Fonts.semiBold + static var blogTitle: UIFont { + WPStyleGuide.navigationBarStandardFont + } } private enum Assets { - static let closeButtonModalImage = Gridicon.iconOfType(.cross) + static let closeButtonModalImage = UIImage.gridicon(.cross) } } diff --git a/WordPress/Classes/ViewRelated/Post/PostEditorState.swift b/WordPress/Classes/ViewRelated/Post/PostEditorState.swift index de5b2d0d2c04..4765eac86ac0 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditorState.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditorState.swift @@ -13,6 +13,7 @@ public enum PostEditorAction { case publishNow case update case submitForReview + case continueFromHomepageEditing var dismissesEditor: Bool { switch self { @@ -48,6 +49,8 @@ public enum PostEditorAction { return NSLocalizedString("Submit for Review", comment: "Submit for review button label (saving content, ex: Post, Page, Comment).") case .update: return NSLocalizedString("Update", comment: "Update button label (saving content, ex: Post, Page, Comment).") + case .continueFromHomepageEditing: + return NSLocalizedString("Continue", comment: "Continue button (used to finish editing the home page during site creation).") } } @@ -67,6 +70,9 @@ public enum PostEditorAction { return NSLocalizedString("Are you sure you want to submit for review?", comment: "Title of message shown when user taps submit for review.") case .update: return NSLocalizedString("Are you sure you want to update?", comment: "Title of message shown when user taps update.") + // Note: when continue is pressed with no changes, it will close without prompt + case .continueFromHomepageEditing: + return NSLocalizedString("Are you sure you want to update your homepage?", comment: "Title of message shown when user taps continue during homepage editing in site creation.") } } @@ -80,7 +86,9 @@ public enum PostEditorAction { return NSLocalizedString("Scheduling...", comment: "Text displayed in HUD while a post is being scheduled to be published.") case .submitForReview: return NSLocalizedString("Submitting for Review...", comment: "Text displayed in HUD while a post is being submitted for review.") - case .update: + // not sure if we want to use "Updating..." or "Publishing..." for home page changes? + // Note: when continue is pressed with no changes, it will close without prompt + case .update, .continueFromHomepageEditing: return NSLocalizedString("Updating...", comment: "Text displayed in HUD while a draft or scheduled post is being updated.") } } @@ -91,7 +99,8 @@ public enum PostEditorAction { return NSLocalizedString("Error occurred during publishing", comment: "Text displayed in notice while a post is being published.") case .schedule: return NSLocalizedString("Error occurred during scheduling", comment: "Text displayed in notice while a post is being scheduled to be published.") - case .save, .saveAsDraft, .submitForReview, .update: + // Note: when continue is pressed with no changes, it will close without prompt + case .save, .saveAsDraft, .submitForReview, .update, .continueFromHomepageEditing: return NSLocalizedString("Error occurred during saving", comment: "Text displayed in notice after attempting to save a draft post and an error occurred.") } } @@ -100,7 +109,8 @@ public enum PostEditorAction { switch self { case .save, .saveAsDraft, .update: return .save - case .publish, .publishNow, .schedule, .submitForReview: + // TODO: make a new analytics event(s) for site creation homepage changes + case .publish, .publishNow, .schedule, .submitForReview, .continueFromHomepageEditing: return .publish } } @@ -114,7 +124,7 @@ public enum PostEditorAction { case .publish: return .saveAsDraft case .update: - return .publishNow + return .publish default: return nil } @@ -132,7 +142,8 @@ public enum PostEditorAction { return .editorPublishedPost case .publishNow: return .editorPublishedPost - case .update: + // TODO: make a new analytics event(s) + case .update, .continueFromHomepageEditing: return .editorUpdatedPost case .submitForReview: // TODO: When support is added for submit for review, add a new stat to support it @@ -141,7 +152,7 @@ public enum PostEditorAction { } } -public protocol PostEditorStateContextDelegate: class { +public protocol PostEditorStateContextDelegate: AnyObject { func context(_ context: PostEditorStateContext, didChangeAction: PostEditorAction) func context(_ context: PostEditorStateContext, didChangeActionAllowed: Bool) } @@ -196,7 +207,8 @@ public class PostEditorStateContext { } convenience init(post: AbstractPost, - delegate: PostEditorStateContextDelegate) { + delegate: PostEditorStateContextDelegate, + action: PostEditorAction? = nil) { var originalPostStatus: BasePost.Status? = nil if let originalPost = post.original, let postStatus = originalPost.status, originalPost.hasRemote() { @@ -212,6 +224,10 @@ public class PostEditorStateContext { userCanPublish: userCanPublish, publishDate: post.dateCreated, delegate: delegate) + + if let action = action { + self.action = action + } } /// The default initializer @@ -353,6 +369,7 @@ public class PostEditorStateContext { return action.publishActionAnalyticsStat } + // TODO: Remove as dead code? /// Indicates if the editor should be dismissed when the publish button is tapped /// var publishActionDismissesEditor: Bool { diff --git a/WordPress/Classes/ViewRelated/Post/PostListEditorPresenter.swift b/WordPress/Classes/ViewRelated/Post/PostListEditorPresenter.swift index a284b748d0e3..c46fd0ff9f21 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListEditorPresenter.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListEditorPresenter.swift @@ -1,4 +1,12 @@ import Foundation +import UIKit + +typealias EditorPresenterViewController = UIViewController & EditorAnalyticsProperties + +/// Provide properties for when showing the editor (like type of post, filter, etc) +protocol EditorAnalyticsProperties: AnyObject { + func propertiesForAnalytics() -> [String: AnyObject] +} /// Handle a user tapping a post in the post list. If an autosave revision is available, give the /// user the option through a dialog alert to load the autosave (or just load the regular post) into @@ -6,24 +14,59 @@ import Foundation /// Analytics are also tracked. struct PostListEditorPresenter { - static func handle(post: Post, in postListViewController: PostListViewController) { + static func handle(post: Post, in postListViewController: EditorPresenterViewController, entryPoint: PostEditorEntryPoint = .unknown) { // Autosaves are ignored for posts with local changes. if !post.hasLocalChanges(), post.hasAutosaveRevision, let saveDate = post.dateModified, let autosaveDate = post.autosaveModifiedDate { let autosaveViewController = autosaveOptionsViewController(forSaveDate: saveDate, autosaveDate: autosaveDate, didTapOption: { loadAutosaveRevision in - openEditor(with: post, loadAutosaveRevision: loadAutosaveRevision, in: postListViewController) + openEditor(with: post, loadAutosaveRevision: loadAutosaveRevision, in: postListViewController, entryPoint: entryPoint) }) postListViewController.present(autosaveViewController, animated: true) } else { - openEditor(with: post, loadAutosaveRevision: false, in: postListViewController) + openEditor(with: post, loadAutosaveRevision: false, in: postListViewController, entryPoint: entryPoint) } } - private static func openEditor(with post: Post, loadAutosaveRevision: Bool, in postListViewController: PostListViewController) { + static func handleCopy(post: Post, in postListViewController: EditorPresenterViewController) { + // Autosaves are ignored for posts with local changes. + if !post.hasLocalChanges(), post.hasAutosaveRevision { + let conflictsResolutionViewController = copyConflictsResolutionViewController(didTapOption: { copyLocal, cancel in + if cancel { + return + } + if copyLocal { + openEditorWithCopy(with: post, in: postListViewController) + } else { + handle(post: post, in: postListViewController) + } + }) + postListViewController.present(conflictsResolutionViewController, animated: true) + } else { + openEditorWithCopy(with: post, in: postListViewController) + } + } + + private static func openEditor(with post: Post, loadAutosaveRevision: Bool, in postListViewController: EditorPresenterViewController, entryPoint: PostEditorEntryPoint = .unknown) { let editor = EditPostViewController(post: post, loadAutosaveRevision: loadAutosaveRevision) editor.modalPresentationStyle = .fullScreen + editor.entryPoint = entryPoint + postListViewController.present(editor, animated: false) + } + + private static func openEditorWithCopy(with post: Post, in postListViewController: EditorPresenterViewController) { + // Copy Post + let newPost = post.blog.createDraftPost() + newPost.postTitle = post.postTitle + newPost.content = post.content + newPost.categories = post.categories + newPost.postFormat = post.postFormat + // Open Editor + let editor = EditPostViewController(post: newPost, loadAutosaveRevision: false) + editor.modalPresentationStyle = .fullScreen + editor.entryPoint = .postsList postListViewController.present(editor, animated: false) - WPAppAnalytics.track(.postListEditAction, withProperties: postListViewController.propertiesForAnalytics(), with: post) + // Track Analytics event + WPAppAnalytics.track(.postListDuplicateAction, withProperties: postListViewController.propertiesForAnalytics(), with: post) } private static let dateFormatter: DateFormatter = { @@ -68,4 +111,31 @@ struct PostListEditorPresenter { return alertController } + + /// A dialog giving the user the choice between copying the current version of the post or resolving conflicts with edit. + private static func copyConflictsResolutionViewController(didTapOption: @escaping (_ copyLocal: Bool, _ cancel: Bool) -> Void) -> UIAlertController { + + let title = NSLocalizedString("Post sync conflict", comment: "Title displayed in popup when user tries to copy a post with unsaved changes") + + let message = NSLocalizedString("The post you are trying to copy has two versions that are in conflict or you recently made changes but didn\'t save them.\nEdit the post first to resolve any conflict or proceed with copying the version from this app.", comment: "Message displayed in popup when user tries to copy a post with conflicts") + + let editFirstButtonTitle = NSLocalizedString("Edit the post first", comment: "Button title displayed in popup indicating that the user edits the post first") + let copyLocalButtonTitle = NSLocalizedString("Copy the version from this app", comment: "Button title displayed in popup indicating the user copied the local copy") + let cancelButtonTitle = NSLocalizedString("Cancel", comment: "Cancel button.") + + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: editFirstButtonTitle, style: .default) { _ in + didTapOption(false, false) + }) + alertController.addAction(UIAlertAction(title: copyLocalButtonTitle, style: .default) { _ in + didTapOption(true, false) + }) + alertController.addAction(UIAlertAction(title: cancelButtonTitle, style: .cancel) { _ in + didTapOption(false, true) + }) + + alertController.view.accessibilityIdentifier = "copy-version-conflict-alert" + + return alertController + } } diff --git a/WordPress/Classes/ViewRelated/Post/PostListFilter.swift b/WordPress/Classes/ViewRelated/Post/PostListFilter.swift index 03d339c8ad34..aae36c9450ea 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListFilter.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListFilter.swift @@ -7,6 +7,7 @@ import Foundation case draft case scheduled case trashed + case allNonTrashed } @objc var hasMore: Bool @@ -41,6 +42,8 @@ import Foundation var sortField: AbstractPost.SortField { switch filterType { case .draft: + fallthrough + case .allNonTrashed: return .dateModified default: return .dateCreated @@ -185,4 +188,53 @@ import Foundation return filter } + + @objc class func allNonTrashedFilter() -> PostListFilter { + let filterType: Status = .allNonTrashed + let statuses: [BasePost.Status] = [.draft, .pending, .publish, .publishPrivate, .scheduled] + + let query = + // Existing non-trashed posts + "(statusAfterSync = status AND status IN (%@))" + // Existing non-trashed posts transitioned to another status but not uploaded yet + + " OR (statusAfterSync != status AND statusAfterSync IN (%@))" + // Non-trashed posts existing only on the device + + " OR (postID = %i AND status IN (%@))" + // Include other existing non-trashed posts with `nil` `statusAfterSync`. This is + // unlikely but this ensures that those posts will show up somewhere. + + " OR (postID > %i AND statusAfterSync = nil AND status IN (%@))" + let predicate = NSPredicate(format: query, + statuses.strings, + statuses.strings, + BasePost.defaultPostIDValue, + statuses.strings, + BasePost.defaultPostIDValue, + statuses.strings) + + let title = NSLocalizedString("All", comment: "Title of the drafts filter. This filter shows a list of draft posts.") + + let filter = PostListFilter(title: title, filterType: filterType, predicate: predicate, statuses: statuses) + filter.accessibilityIdentifier = "all" + + return filter + } + + func predicate(for blog: Blog) -> NSPredicate { + var predicates = [NSPredicate]() + + // Show all original posts without a revision & revision posts. + let basePredicate = NSPredicate(format: "blog = %@ && revision = nil", blog) + predicates.append(basePredicate) + + predicates.append(predicateForFetchRequest) + + if let myAuthorID = blog.userID { + // Brand new local drafts have an authorID of 0. + let authorPredicate = NSPredicate(format: "authorID = %@ || authorID = 0", myAuthorID) + predicates.append(authorPredicate) + } + + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + return predicate + } } diff --git a/WordPress/Classes/ViewRelated/Post/PostListFilterSettings.swift b/WordPress/Classes/ViewRelated/Post/PostListFilterSettings.swift index 471619f07284..844fb5324d0c 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListFilterSettings.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListFilterSettings.swift @@ -104,7 +104,7 @@ class PostListFilterSettings: NSObject { /// currentPostListFilter: returns the index of the last active PostListFilter @objc func currentFilterIndex() -> Int { - let userDefaults = UserDefaults.standard + let userDefaults = UserPersistentStoreFactory.instance() if let filter = userDefaults.object(forKey: keyForCurrentListStatusFilter()) as? Int, filter < availablePostListFilters().count { @@ -122,7 +122,7 @@ class PostListFilterSettings: NSObject { return } - UserDefaults.standard.set(newIndex, forKey: self.keyForCurrentListStatusFilter()) + UserPersistentStoreFactory.instance().set(newIndex, forKey: self.keyForCurrentListStatusFilter()) UserDefaults.resetStandardUserDefaults() } @@ -150,7 +150,7 @@ class PostListFilterSettings: NSObject { return .everyone } - if let filter = UserDefaults.standard.object(forKey: type(of: self).currentPostAuthorFilterKey) { + if let filter = UserPersistentStoreFactory.instance().object(forKey: type(of: self).currentPostAuthorFilterKey) { if (filter as AnyObject).uintValue == AuthorFilter.everyone.rawValue { return .everyone } @@ -168,7 +168,7 @@ class PostListFilterSettings: NSObject { WPAnalytics.track(.postListAuthorFilterChanged, withProperties: propertiesForAnalytics()) - UserDefaults.standard.set(filter.rawValue, forKey: type(of: self).currentPostAuthorFilterKey) + UserPersistentStoreFactory.instance().set(filter.rawValue, forKey: type(of: self).currentPostAuthorFilterKey) UserDefaults.resetStandardUserDefaults() } diff --git a/WordPress/Classes/ViewRelated/Post/PostListFooterView.m b/WordPress/Classes/ViewRelated/Post/PostListFooterView.m index 7f0d5eed616c..da05db3e3b5c 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListFooterView.m +++ b/WordPress/Classes/ViewRelated/Post/PostListFooterView.m @@ -4,7 +4,6 @@ @interface PostListFooterView() -@property (nonatomic, strong) IBOutlet UIView *bannerView; @property (nonatomic, strong) IBOutlet UIActivityIndicatorView *activityView; @end @@ -16,8 +15,6 @@ - (void)awakeFromNib [super awakeFromNib]; self.backgroundColor = [UIColor clearColor]; - self.bannerView.backgroundColor = [UIColor murielNeutral0]; - self.bannerView.hidden = YES; } - (void)showSpinner:(BOOL)show diff --git a/WordPress/Classes/ViewRelated/Post/PostListFooterView.xib b/WordPress/Classes/ViewRelated/Post/PostListFooterView.xib index a260d03ee0cc..801284fde8dd 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListFooterView.xib +++ b/WordPress/Classes/ViewRelated/Post/PostListFooterView.xib @@ -1,12 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14113" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina4_7" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19162" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> + <device id="retina4_7" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/> - <capability name="Constraints to layout margins" minToolsVersion="6.0"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19144"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> @@ -16,83 +13,21 @@ <rect key="frame" x="0.0" y="0.0" width="300" height="44"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <subviews> - <activityIndicatorView hidden="YES" opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" style="gray" translatesAutoresizingMaskIntoConstraints="NO" id="F4q-47-pPM"> + <activityIndicatorView hidden="YES" opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" hidesWhenStopped="YES" style="gray" translatesAutoresizingMaskIntoConstraints="NO" id="F4q-47-pPM"> <rect key="frame" x="140" y="12" width="20" height="20"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> </activityIndicatorView> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="AoX-vc-zrF"> - <rect key="frame" x="7" y="11" width="286" height="22"/> - <subviews> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="MBZ-lN-HGt"> - <rect key="frame" x="0.0" y="11" width="124" height="1"/> - <color key="backgroundColor" red="0.73886972665786743" green="0.8065076470375061" blue="0.85388457775115967" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstAttribute="height" constant="1" id="gOn-eC-zhZ"/> - </constraints> - </view> - <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wKm-Mr-TCA"> - <rect key="frame" x="162" y="11" width="124" height="1"/> - <color key="backgroundColor" red="0.73886972665786743" green="0.8065076470375061" blue="0.85388457775115967" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstAttribute="height" constant="1" id="k2g-PF-m8U"/> - </constraints> - </view> - <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="icon-post-list-footer" translatesAutoresizingMaskIntoConstraints="NO" id="2lx-TZ-H9w"> - <rect key="frame" x="132" y="0.0" width="22" height="22"/> - <constraints> - <constraint firstAttribute="width" constant="22" id="3hc-zb-3fV"/> - <constraint firstAttribute="height" constant="22" id="cJ3-uM-7zP"/> - </constraints> - </imageView> - </subviews> - <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstAttribute="centerX" secondItem="2lx-TZ-H9w" secondAttribute="centerX" id="BXl-cW-Ywt"/> - <constraint firstAttribute="centerY" secondItem="MBZ-lN-HGt" secondAttribute="centerY" constant="-0.5" id="SM7-09-i4J"/> - <constraint firstAttribute="centerY" secondItem="wKm-Mr-TCA" secondAttribute="centerY" constant="-0.5" id="X37-jz-nrb"/> - <constraint firstItem="2lx-TZ-H9w" firstAttribute="leading" secondItem="MBZ-lN-HGt" secondAttribute="trailing" constant="8" id="Y0M-I9-BOr"/> - <constraint firstAttribute="trailing" secondItem="wKm-Mr-TCA" secondAttribute="trailing" id="b0b-rA-YhB"/> - <constraint firstItem="MBZ-lN-HGt" firstAttribute="leading" secondItem="AoX-vc-zrF" secondAttribute="leading" id="gr5-vp-wLh"/> - <constraint firstAttribute="height" constant="22" id="jnj-8K-NpN"/> - <constraint firstItem="wKm-Mr-TCA" firstAttribute="leading" secondItem="2lx-TZ-H9w" secondAttribute="trailing" constant="8" id="qGB-cG-EAL"/> - <constraint firstAttribute="centerY" secondItem="2lx-TZ-H9w" secondAttribute="centerY" id="wf6-gD-lPc"/> - </constraints> - </view> </subviews> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> - <constraints> - <constraint firstAttribute="bottom" secondItem="AoX-vc-zrF" secondAttribute="bottom" constant="11" id="CZF-8o-geK"/> - <constraint firstAttribute="trailing" secondItem="AoX-vc-zrF" secondAttribute="trailing" constant="7" id="ciu-4z-aHt"/> - <constraint firstItem="F4q-47-pPM" firstAttribute="centerX" secondItem="AoX-vc-zrF" secondAttribute="centerX" id="oYN-Kz-3Zu"/> - <constraint firstAttribute="trailingMargin" secondItem="AoX-vc-zrF" secondAttribute="trailing" priority="999" id="s16-Gc-tvU"/> - <constraint firstItem="AoX-vc-zrF" firstAttribute="leading" secondItem="TCZ-n9-coy" secondAttribute="leadingMargin" id="yzD-7E-gw6"/> - <constraint firstItem="AoX-vc-zrF" firstAttribute="leading" secondItem="TCZ-n9-coy" secondAttribute="leading" constant="7" id="znD-ba-XuU"/> - <constraint firstItem="F4q-47-pPM" firstAttribute="centerY" secondItem="AoX-vc-zrF" secondAttribute="centerY" id="zvW-CV-pmP"/> - </constraints> <nil key="simulatedStatusBarMetrics"/> <nil key="simulatedTopBarMetrics"/> <nil key="simulatedBottomBarMetrics"/> <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> - <variation key="default"> - <mask key="constraints"> - <exclude reference="s16-Gc-tvU"/> - <exclude reference="yzD-7E-gw6"/> - </mask> - </variation> - <variation key="widthClass=regular" layoutMarginsFollowReadableWidth="YES" preservesSuperviewLayoutMargins="YES"> - <mask key="constraints"> - <exclude reference="ciu-4z-aHt"/> - <include reference="s16-Gc-tvU"/> - <include reference="yzD-7E-gw6"/> - <exclude reference="znD-ba-XuU"/> - </mask> - </variation> + <variation key="widthClass=regular" layoutMarginsFollowReadableWidth="YES" preservesSuperviewLayoutMargins="YES"/> <connections> <outlet property="activityView" destination="F4q-47-pPM" id="zVo-Q1-ggr"/> - <outlet property="bannerView" destination="AoX-vc-zrF" id="hSc-aF-tjn"/> </connections> + <point key="canvasLocation" x="139" y="112"/> </view> </objects> - <resources> - <image name="icon-post-list-footer" width="22" height="22"/> - </resources> </document> diff --git a/WordPress/Classes/ViewRelated/Post/PostListViewController.swift b/WordPress/Classes/ViewRelated/Post/PostListViewController.swift index eb3fabe817bc..b72197ccf335 100644 --- a/WordPress/Classes/ViewRelated/Post/PostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/PostListViewController.swift @@ -2,6 +2,7 @@ import Foundation import CocoaLumberjack import WordPressShared import Gridicons +import UIKit // FIXME: comparison operators with optionals were removed from the Swift Standard Libary. // Consider refactoring the code to use the non-optional operators. @@ -55,7 +56,7 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe @IBOutlet weak var filterTabBarBottomConstraint: NSLayoutConstraint! @IBOutlet weak var tableViewTopConstraint: NSLayoutConstraint! - private var database: KeyValueDatabase = UserDefaults.standard + private var database: UserPersistentRepository = UserPersistentStoreFactory.instance() private lazy var _tableViewHandler: PostListTableViewHandler = { let tableViewHandler = PostListTableViewHandler(tableView: tableView) @@ -74,7 +75,7 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe } private var postViewIcon: UIImage? { - return isCompact ? UIImage(named: "icon-post-view-card") : Gridicon.iconOfType(.listUnordered) + return isCompact ? UIImage(named: "icon-post-view-card") : .gridicon(.listUnordered) } private lazy var postActionSheet: PostActionSheet = { @@ -96,6 +97,9 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe } } + /// If set, when the post list appear it will show the tab for this status + var initialFilterWithPostStatus: BasePost.Status? + // MARK: - Convenience constructors @objc class func controllerWithBlog(_ blog: Blog) -> PostListViewController { @@ -108,6 +112,15 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe return controller } + static func showForBlog(_ blog: Blog, from sourceController: UIViewController, withPostStatus postStatus: BasePost.Status? = nil) { + let controller = PostListViewController.controllerWithBlog(blog) + controller.navigationItem.largeTitleDisplayMode = .never + controller.initialFilterWithPostStatus = postStatus + sourceController.navigationController?.pushViewController(controller, animated: true) + + QuickStartTourGuide.shared.visited(.blogDetailNavigation) + } + // MARK: - UIViewControllerRestoration class func viewController(withRestorationIdentifierPath identifierComponents: [String], @@ -153,17 +166,69 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe override func viewDidLoad() { super.viewDidLoad() - title = NSLocalizedString("Blog Posts", comment: "Title of the screen showing the list of posts for a blog.") + title = NSLocalizedString("Posts", comment: "Title of the screen showing the list of posts for a blog.") - configureCompactOrDefault() configureFilterBarTopConstraint() updateGhostableTableViewOptions() configureNavigationButtons() + + configureInitialFilterIfNeeded() + listenForAppComingToForeground() + + createButtonCoordinator.add(to: view, trailingAnchor: view.safeAreaLayoutGuide.trailingAnchor, bottomAnchor: view.safeAreaLayoutGuide.bottomAnchor) + } + + private lazy var createButtonCoordinator: CreateButtonCoordinator = { + var actions: [ActionSheetItem] = [ + PostAction(handler: { [weak self] in + self?.dismiss(animated: false, completion: nil) + self?.createPost() + }, source: Constants.source) + ] + if blog.supports(.stories) { + actions.insert(StoryAction(handler: { [weak self] in + guard let self = self else { + return + } + let presenter = RootViewCoordinator.sharedPresenter + presenter.showStoryEditor(blog: self.blog, title: nil, content: nil) + }, source: Constants.source), at: 0) + } + return CreateButtonCoordinator(self, actions: actions, source: Constants.source, blog: blog) + }() + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if traitCollection.horizontalSizeClass == .compact { + createButtonCoordinator.showCreateButton(for: blog) + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + configureCompactOrDefault() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + toggleCreateButton() + } + + /// Shows/hides the create button based on the trait collection horizontal size class + @objc + private func toggleCreateButton() { + if traitCollection.horizontalSizeClass == .compact { + createButtonCoordinator.showCreateButton(for: blog) + } else { + createButtonCoordinator.hideCreateButton() + } } func configureNavigationButtons() { - navigationItem.rightBarButtonItems = [addButton, postsViewButtonItem] + navigationItem.rightBarButtonItems = [postsViewButtonItem] } @objc func togglePostsView() { @@ -210,10 +275,14 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe super.selectedFilterDidChange(filterBar) } + override func refresh(_ sender: AnyObject) { + updateGhostableTableViewOptions() + super.refresh(sender) + } /// Update the `GhostOptions` to correctly show compact or default cells private func updateGhostableTableViewOptions() { - let ghostOptions = GhostOptions(displaysSectionHeader: false, reuseIdentifier: postCellIdentifier, rowsPerSection: [10]) + let ghostOptions = GhostOptions(displaysSectionHeader: false, reuseIdentifier: postCellIdentifier, rowsPerSection: [50]) let style = GhostStyle(beatDuration: GhostStyle.Defaults.beatDuration, beatStartColor: .placeholderElement, beatEndColor: .placeholderElementFaded) @@ -280,7 +349,7 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe searchWrapperView.addSubview(searchController.searchBar) - tableView.scrollIndicatorInsets.top = searchController.searchBar.bounds.height + tableView.verticalScrollIndicatorInsets.top = searchController.searchBar.bounds.height updateTableHeaderSize() } @@ -298,16 +367,33 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe } func showCompactOrDefault() { - tableView.reloadSections([0], with: .automatic) - updateGhostableTableViewOptions() - ghostableTableView.reloadSections([0], with: .automatic) postsViewButtonItem.accessibilityLabel = NSLocalizedString("List style", comment: "The accessibility label for the list style button in the Post List.") postsViewButtonItem.accessibilityValue = isCompact ? NSLocalizedString("Compact", comment: "Accessibility indication that the current Post List style is currently Compact.") : NSLocalizedString("Expanded", comment: "Accessibility indication that the current Post List style is currently Expanded.") postsViewButtonItem.image = postViewIcon + + if isViewOnScreen() { + tableView.reloadSections([0], with: .automatic) + ghostableTableView.reloadSections([0], with: .automatic) + } } + private func configureInitialFilterIfNeeded() { + guard let initialFilterWithPostStatus = initialFilterWithPostStatus else { + return + } + + filterSettings.setFilterWithPostStatus(initialFilterWithPostStatus) + } + + /// Listens for the app coming to foreground in order to properly set the create button + private func listenForAppComingToForeground() { + NotificationCenter.default.addObserver(self, + selector: #selector(toggleCreateButton), + name: UIApplication.willEnterForegroundNotification, + object: nil) + } // Mark - Layout Methods override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) { @@ -474,7 +560,7 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe } interactivePostView.setInteractionDelegate(self) - interactivePostView.setActionSheetDelegate?(self) + interactivePostView.setActionSheetDelegate(self) configurablePostView.configure(with: post) @@ -499,7 +585,7 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe return } - cell.isAuthorHidden = showingJustMyPosts + cell.shouldHideAuthor = showingJustMyPosts } private func configureRestoreCell(_ cell: UITableViewCell) { @@ -515,8 +601,9 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe override func createPost() { let editor = EditPostViewController(blog: blog) editor.modalPresentationStyle = .fullScreen + editor.entryPoint = .postsList present(editor, animated: false, completion: nil) - WPAppAnalytics.track(.editorCreatedPost, withProperties: ["tap_source": "posts_view"], with: blog) + WPAppAnalytics.track(.editorCreatedPost, withProperties: [WPAppAnalyticsKeyTapSource: "posts_view", WPAppAnalyticsKeyPostType: "post"], with: blog) } private func editPost(apost: AbstractPost) { @@ -528,7 +615,16 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe return } - PostListEditorPresenter.handle(post: post, in: self) + WPAppAnalytics.track(.postListEditAction, withProperties: propertiesForAnalytics(), with: post) + PostListEditorPresenter.handle(post: post, in: self, entryPoint: .postsList) + } + + private func editDuplicatePost(apost: AbstractPost) { + guard let post = apost as? Post else { + return + } + + PostListEditorPresenter.handleCopy(post: post, in: self) } func presentAlertForPostBeingUploaded() { @@ -578,14 +674,14 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe return } - let service = BlogService(managedObjectContext: ContextManager.sharedInstance().mainContext) - SiteStatsInformation.sharedInstance.siteTimeZone = service.timeZone(for: blog) + SiteStatsInformation.sharedInstance.siteTimeZone = blog.timeZone SiteStatsInformation.sharedInstance.oauth2Token = blog.authToken SiteStatsInformation.sharedInstance.siteID = blog.dotComID let postURL = URL(string: apost.permaLink! as String) - let postStatsTableViewController = PostStatsTableViewController.loadFromStoryboard() - postStatsTableViewController.configure(postID: postID, postTitle: apost.titleForDisplay(), postURL: postURL) + let postStatsTableViewController = PostStatsTableViewController.withJPBannerForBlog(postID: postID, + postTitle: apost.titleForDisplay(), + postURL: postURL) navigationController?.pushViewController(postStatsTableViewController, animated: true) } @@ -605,8 +701,22 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe } } + func duplicate(_ post: AbstractPost) { + editDuplicatePost(apost: post) + } + func publish(_ post: AbstractPost) { - publishPost(post) + publishPost(post) { + + BloggingRemindersFlow.present(from: self, + for: post.blog, + source: .publishFlow, + alwaysShow: false) + } + } + + func copyLink(_ post: AbstractPost) { + copyPostLink(post) } func trash(_ post: AbstractPost) { @@ -662,6 +772,22 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe PostCoordinator.shared.cancelAutoUploadOf(post) } + func share(_ apost: AbstractPost, fromView view: UIView) { + guard let post = apost as? Post else { + return + } + + WPAnalytics.track(.postListShareAction, properties: propertiesForAnalytics()) + + let shareController = PostSharingController() + shareController.sharePost(post, fromView: view, inViewController: self) + } + + func blaze(_ post: AbstractPost) { + BlazeEventsTracker.trackEntryPointTapped(for: .postsList) + BlazeFlowCoordinator.presentBlaze(in: self, source: .postsList, blog: blog, post: post) + } + // MARK: - Searching override func updateForLocalPostsMatchingSearchText() { @@ -684,7 +810,7 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe updateTableHeaderSize() _tableViewHandler.isSearching = true - tableView.scrollIndicatorInsets.top = searchWrapperView.bounds.height + tableView.verticalScrollIndicatorInsets.top = searchWrapperView.bounds.height tableView.contentInset.top = 0 } @@ -727,6 +853,7 @@ class PostListViewController: AbstractPostListViewController, UIViewControllerRe static let searchHeaderHeight: CGFloat = 40 static let card = "card" static let compact = "compact" + static let source = "post_list" } } @@ -793,6 +920,8 @@ private extension PostListViewController { return NoResultsText.noTrashedTitle case .published: return NoResultsText.noPublishedTitle + case .allNonTrashed: + return "" } } diff --git a/WordPress/Classes/ViewRelated/Post/PostNoticeNavigationCoordinator.swift b/WordPress/Classes/ViewRelated/Post/PostNoticeNavigationCoordinator.swift index e9f47186f2f3..6b91ed91e1b5 100644 --- a/WordPress/Classes/ViewRelated/Post/PostNoticeNavigationCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Post/PostNoticeNavigationCoordinator.swift @@ -23,12 +23,14 @@ class PostNoticeNavigationCoordinator { return } - let controller = PreviewWebKitViewController(post: page) + let controller = PreviewWebKitViewController(post: page, source: "post_notice_preview") controller.trackOpenEvent() controller.navigationItem.title = NSLocalizedString("View", comment: "Verb. The screen title shown when viewing a post inside the app.") - let navigationController = UINavigationController(rootViewController: controller) - navigationController.modalPresentationStyle = .formSheet + let navigationController = LightNavigationController(rootViewController: controller) + if presenter.traitCollection.userInterfaceIdiom == .pad { + navigationController.modalPresentationStyle = .fullScreen + } presenter.present(navigationController, animated: true) } diff --git a/WordPress/Classes/ViewRelated/Post/PostNoticeViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostNoticeViewModel.swift index 1d0db3585139..2c926804fa53 100644 --- a/WordPress/Classes/ViewRelated/Post/PostNoticeViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostNoticeViewModel.swift @@ -87,7 +87,11 @@ struct PostNoticeViewModel { case .pending: return NSLocalizedString("Page pending review", comment: "Title of notification displayed when a page has been successfully saved as a draft.") default: - return NSLocalizedString("Page published", comment: "Title of notification displayed when a page has been successfully published.") + if page.isFirstTimePublish { + return NSLocalizedString("Page published", comment: "Title of notification displayed when a page has been successfully published.") + } else { + return NSLocalizedString("Page updated", comment: "Title of notification displayed when a page has been successfully updated.") + } } } @@ -102,7 +106,11 @@ struct PostNoticeViewModel { case .pending: return NSLocalizedString("Post pending review", comment: "Title of notification displayed when a post has been successfully saved as a draft.") default: - return NSLocalizedString("Post published", comment: "Title of notification displayed when a post has been successfully published.") + if post.isFirstTimePublish { + return NSLocalizedString("Post published", comment: "Title of notification displayed when a post has been successfully published.") + } else { + return NSLocalizedString("Post updated", comment: "Title of notification displayed when a post has been successfully updated.") + } } } @@ -117,8 +125,8 @@ struct PostNoticeViewModel { } private var message: String { - let title = post.postTitle ?? "" - if title.count > 0 { + let title = post.titleForDisplay() ?? "" + if !title.isEmpty { return title } @@ -217,6 +225,7 @@ struct PostNoticeViewModel { post.status = .publish post.shouldAttemptAutoUpload = true + post.isFirstTimePublish = true postCoordinator.save(post) } diff --git a/WordPress/Classes/ViewRelated/Post/PostPost.storyboard b/WordPress/Classes/ViewRelated/Post/PostPost.storyboard index 3ac99eb182c8..9d05209a8ea9 100644 --- a/WordPress/Classes/ViewRelated/Post/PostPost.storyboard +++ b/WordPress/Classes/ViewRelated/Post/PostPost.storyboard @@ -1,11 +1,10 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina5_5" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> + <device id="retina5_5" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <customFonts key="customFonts"> @@ -44,13 +43,12 @@ <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="28" id="GcR-J5-grU"/> </constraints> <fontDescription key="fontDescription" name="NotoSerif-Bold" family="Noto Serif" pointSize="22"/> - <color key="textColor" white="1" alpha="1" colorSpace="calibratedWhite"/> <nil key="highlightedColor"/> </label> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Published just now on" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="IEW-y3-Pzp"> <rect key="frame" x="0.0" y="42" width="636" height="18"/> <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> - <color key="textColor" red="0.47058823529999999" green="0.86274509799999999" blue="0.98039215690000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <color key="textColor" systemColor="secondaryLabelColor"/> <nil key="highlightedColor"/> </label> <stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="12" translatesAutoresizingMaskIntoConstraints="NO" id="rKW-qs-6cA"> @@ -84,7 +82,6 @@ <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="22" id="w6b-ZM-JsE"/> </constraints> <fontDescription key="fontDescription" name="HelveticaNeue-Bold" family="Helvetica Neue" pointSize="13"/> - <color key="textColor" white="1" alpha="1" colorSpace="calibratedWhite"/> <nil key="highlightedColor"/> </label> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Blog Url" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="B9s-7Y-oQQ"> @@ -93,7 +90,6 @@ <constraint firstAttribute="height" relation="greaterThanOrEqual" constant="22" id="yos-Ix-MsU"/> </constraints> <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> - <color key="textColor" white="1" alpha="1" colorSpace="calibratedWhite"/> <nil key="highlightedColor"/> </label> </subviews> @@ -129,29 +125,32 @@ <state key="normal" title="Share"> <color key="titleColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> </state> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="YES"/> + </userDefinedRuntimeAttributes> <connections> <action selector="shareTapped" destination="0ZV-gF-7z9" eventType="touchUpInside" id="qhf-Zm-wdL"/> </connections> </button> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="wfC-rz-tLa" customClass="FancyButton" customModule="WordPressUI"> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="wfC-rz-tLa" customClass="FancyButton" customModule="WordPressUI"> <rect key="frame" x="0.0" y="66" width="636" height="44"/> <constraints> <constraint firstAttribute="height" constant="44" id="vDI-tK-HOm"/> </constraints> <state key="normal" title="Edit Post"> - <color key="titleColor" red="0.96862745100000003" green="0.96862745100000003" blue="0.96862745100000003" alpha="1" colorSpace="calibratedRGB"/> + <color key="titleColor" systemColor="labelColor"/> </state> <connections> <action selector="editTapped" destination="0ZV-gF-7z9" eventType="touchUpInside" id="nBy-5P-2fK"/> </connections> </button> - <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="WGH-Vv-Uun" customClass="FancyButton" customModule="WordPressUI"> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="WGH-Vv-Uun" customClass="FancyButton" customModule="WordPressUI"> <rect key="frame" x="0.0" y="132" width="636" height="44"/> <constraints> <constraint firstAttribute="height" constant="44" id="r9K-HZ-toB"/> </constraints> <state key="normal" title="View Post"> - <color key="titleColor" red="0.96862745100000003" green="0.96862745100000003" blue="0.96862745100000003" alpha="1" colorSpace="calibratedRGB"/> + <color key="titleColor" systemColor="labelColor"/> </state> <connections> <action selector="viewTapped" destination="0ZV-gF-7z9" eventType="touchUpInside" id="ABP-oS-VPb"/> @@ -179,17 +178,10 @@ </constraints> </view> <navigationBar contentMode="scaleToFill" barStyle="black" translucent="NO" translatesAutoresizingMaskIntoConstraints="NO" id="FQW-wc-apn"> - <rect key="frame" x="0.0" y="20" width="700" height="44"/> - <color key="barTintColor" red="0.0" green="0.52941176469999995" blue="0.74509803919999995" alpha="1" colorSpace="calibratedRGB"/> + <rect key="frame" x="0.0" y="0.0" width="700" height="44"/> + <color key="barTintColor" systemColor="systemBackgroundColor"/> <items> - <navigationItem id="cdd-ao-D6H"> - <barButtonItem key="rightBarButtonItem" title="Done" style="done" id="JM6-CY-cxQ"> - <color key="tintColor" white="1" alpha="1" colorSpace="calibratedWhite"/> - <connections> - <action selector="doneTapped" destination="0ZV-gF-7z9" id="clo-aS-TDk"/> - </connections> - </barButtonItem> - </navigationItem> + <navigationItem id="cdd-ao-D6H"/> </items> </navigationBar> <view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Fmh-Yp-NIl"> @@ -197,11 +189,12 @@ <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/> </view> </subviews> - <color key="backgroundColor" red="0.0" green="0.52941176469999995" blue="0.74509803919999995" alpha="1" colorSpace="calibratedRGB"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> <constraints> <constraint firstItem="HyV-qN-PlX" firstAttribute="height" secondItem="2Pd-T6-hcw" secondAttribute="height" priority="750" constant="-32" id="2MU-G6-Bg5"/> <constraint firstItem="FQW-wc-apn" firstAttribute="leading" secondItem="2Pd-T6-hcw" secondAttribute="leading" id="4vQ-ZV-a19"/> <constraint firstItem="HyV-qN-PlX" firstAttribute="centerX" secondItem="2Pd-T6-hcw" secondAttribute="centerX" id="H0w-X7-Hck"/> + <constraint firstItem="8Oz-au-Onc" firstAttribute="top" secondItem="HyV-qN-PlX" secondAttribute="bottom" id="Jy4-34-3F4"/> <constraint firstItem="FQW-wc-apn" firstAttribute="top" secondItem="T6C-gu-aFL" secondAttribute="bottom" id="OyB-Yv-CVc"/> <constraint firstItem="Fmh-Yp-NIl" firstAttribute="leading" secondItem="2Pd-T6-hcw" secondAttribute="leading" id="Rk9-It-Cw6"/> <constraint firstAttribute="trailing" secondItem="Fmh-Yp-NIl" secondAttribute="trailing" id="c3V-j3-PXM"/> @@ -211,6 +204,11 @@ <constraint firstItem="Fmh-Yp-NIl" firstAttribute="top" secondItem="2Pd-T6-hcw" secondAttribute="top" id="pHx-pm-5En"/> <constraint firstItem="HyV-qN-PlX" firstAttribute="width" secondItem="2Pd-T6-hcw" secondAttribute="width" priority="750" constant="-64" id="rcL-v9-I3y"/> </constraints> + <variation key="default"> + <mask key="constraints"> + <exclude reference="oJd-r7-ndv"/> + </mask> + </variation> </view> <navigationItem key="navigationItem" id="mYP-bF-EKS"/> <simulatedStatusBarMetrics key="simulatedStatusBarMetrics" statusBarStyle="lightContent"/> @@ -239,4 +237,15 @@ <point key="canvasLocation" x="2836.2318840579715" y="309.78260869565219"/> </scene> </scenes> + <resources> + <systemColor name="labelColor"> + <color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + <systemColor name="secondaryLabelColor"> + <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> </document> diff --git a/WordPress/Classes/ViewRelated/Post/PostPostViewController.swift b/WordPress/Classes/ViewRelated/Post/PostPostViewController.swift index 1451cc1c63c5..42bfd11987a1 100644 --- a/WordPress/Classes/ViewRelated/Post/PostPostViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/PostPostViewController.swift @@ -33,6 +33,8 @@ class PostPostViewController: UIViewController { @objc var onClose: (() -> ())? @objc var reshowEditor: (() -> ())? @objc var preview: (() -> ())? + /// Set to `true` to hide the edit button from the view. + var hideEditButton = false init() { super.init(nibName: nil, bundle: nil) @@ -43,41 +45,39 @@ class PostPostViewController: UIViewController { } override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent + return .darkContent } - override func viewDidLoad() { super.viewDidLoad() - setupActionButtons() + setupLabels() + setupNavBar() } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) + private func setupNavBar() { + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + navBar.standardAppearance = appearance + navBar.compactAppearance = appearance - view.backgroundColor = .primary + let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped)) + doneButton.setTitleTextAttributes([NSAttributedString.Key.foregroundColor: UIColor.barButtonItemTitle], for: .normal) + doneButton.accessibilityIdentifier = "doneButton" + navBar.topItem?.rightBarButtonItem = doneButton + } - if #available(iOS 13.0, *) { - let appearance = UINavigationBarAppearance() - appearance.configureWithTransparentBackground() - navBar.standardAppearance = appearance - } else { - navBar.isTranslucent = true - navBar.barTintColor = UIColor.clear - navBar.tintColor = UIColor.white - let clearImage = UIImage(color: UIColor.clear, havingSize: CGSize(width: 1, height: 1)) - navBar.shadowImage = clearImage - navBar.setBackgroundImage(clearImage, for: .default) - } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) - navBar.topItem?.rightBarButtonItem?.title = NSLocalizedString("Done", comment: "Label on button to dismiss view presented after publishing a post") - navBar.topItem?.rightBarButtonItem?.accessibilityIdentifier = "doneButton" + view.backgroundColor = .basicBackground view.alpha = WPAlphaZero shareButton.setTitle(NSLocalizedString("Share", comment: "Button label to share a post"), for: .normal) shareButton.accessibilityIdentifier = "sharePostButton" - shareButton.setImage(Gridicon.iconOfType(.shareIOS, withSize: CGSize(width: 18, height: 18)), for: .normal) + shareButton.setImage(.gridicon(.shareiOS, size: CGSize(width: 18, height: 18)), for: .normal) + shareButton.tintColor = .white + shareButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 8) editButton.setTitle(NSLocalizedString("Edit Post", comment: "Button label for editing a post"), for: .normal) editButton.accessibilityIdentifier = "editPostButton" @@ -94,18 +94,11 @@ class PostPostViewController: UIViewController { } } - private func setupActionButtons() { - shareButton.secondaryTitleColor = .primary - shareButton.secondaryNormalBackgroundColor = .white - shareButton.secondaryHighlightBackgroundColor = .white - - editButton.secondaryTitleColor = .white - editButton.secondaryNormalBackgroundColor = .clear - editButton.secondaryHighlightBackgroundColor = .clear - - viewButton.secondaryTitleColor = .white - viewButton.secondaryNormalBackgroundColor = .clear - viewButton.secondaryHighlightBackgroundColor = .clear + private func setupLabels() { + titleLabel.textColor = .label + postStatusLabel.textColor = .secondaryLabel + siteNameLabel.textColor = .label + siteUrlLabel.textColor = .label } @objc func animatePostPost() { @@ -184,6 +177,7 @@ class PostPostViewController: UIViewController { siteUrlLabel.text = post.blog.displayURL as String? siteUrlLabel.accessibilityIdentifier = "siteUrl" siteIconView.downloadSiteIcon(for: post.blog) + editButton.isHidden = hideEditButton let isPrivate = !post.blog.visible if isPrivate { shareButton.isHidden = true diff --git a/WordPress/Classes/ViewRelated/Post/PostPreviewGenerator.swift b/WordPress/Classes/ViewRelated/Post/PostPreviewGenerator.swift deleted file mode 100644 index fd9d4ccd60dc..000000000000 --- a/WordPress/Classes/ViewRelated/Post/PostPreviewGenerator.swift +++ /dev/null @@ -1,135 +0,0 @@ -import Foundation -import AutomatticTracks - -@objc -protocol PostPreviewGeneratorDelegate { - func preview(_ generator: PostPreviewGenerator, attemptRequest request: URLRequest) - func preview(_ generator: PostPreviewGenerator, loadHTML html: String) - func previewFailed(_ generator: PostPreviewGenerator, message: String) -} - -class PostPreviewGenerator: NSObject { - @objc let post: AbstractPost - @objc var previewURL: URL? - @objc weak var delegate: PostPreviewGeneratorDelegate? - fileprivate let authenticator: WebViewAuthenticator? - - @objc convenience init(post: AbstractPost) { - self.init(post: post, previewURL: nil) - } - - @objc init(post: AbstractPost, previewURL: URL? = nil) { - self.post = post - self.previewURL = previewURL - authenticator = WebViewAuthenticator(blog: post.blog) - super.init() - } - - @objc func generate() { - if let previewURL = previewURL { - attemptPreview(url: previewURL) - } else { - guard let url = post.permaLink.flatMap(URL.init(string:)) else { - previewRequestFailed(reason: "preview failed because post permalink is unexpectedly nil") - return - } - attemptPreview(url: url) - } - } - - @objc func previewRequestFailed(reason: String) { - delegate?.previewFailed(self, message: NSLocalizedString("There has been an error while trying to reach your site.", comment: "An error message.")) - } - - @objc func interceptRedirect(request: URLRequest) -> URLRequest? { - return authenticator?.interceptRedirect(request: request) - } -} - - -// MARK: - Authentication - -private extension PostPreviewGenerator { - func attemptPreview(url: URL) { - - // Attempt to append params. If that fails, fall back to the original url. - let url = url.appendingHideMasterbarParameters() ?? url - - switch authenticationRequired { - case .nonce: - attemptNonceAuthenticatedRequest(url: url) - case .cookie: - attemptCookieAuthenticatedRequest(url: url) - case .none: - attemptUnauthenticatedRequest(url: url) - } - } - - var authenticationRequired: Authentication { - guard needsLogin() else { - return .none - } - if post.blog.supports(.noncePreviews) { - return .nonce - } else { - return .cookie - } - } - - enum Authentication { - case nonce - case cookie - case none - } - - func needsLogin() -> Bool { - guard let status = post.status else { - assertionFailure("A post should always have a status") - return false - } - switch status { - case .draft, .publishPrivate, .pending, .scheduled, .publish: - return true - default: - return post.blog.isPrivate() - } - } - - func attemptUnauthenticatedRequest(url: URL) { - let request = URLRequest(url: url) - delegate?.preview(self, attemptRequest: request) - } - - func attemptNonceAuthenticatedRequest(url: URL) { - guard let nonce = post.blog.getOptionValue("frame_nonce") as? String, - let authenticatedUrl = addNonce(nonce, to: url) else { - previewRequestFailed(reason: "preview failed because url with nonce is unexpectedly nil") - return - } - let request = URLRequest(url: authenticatedUrl) - delegate?.preview(self, attemptRequest: request) - } - - func attemptCookieAuthenticatedRequest(url: URL) { - guard let authenticator = authenticator else { - previewRequestFailed(reason: "preview failed because authenticator is unexpectedly nil") - return - } - authenticator.request(url: url, cookieJar: HTTPCookieStorage.shared, completion: { [weak delegate] request in - delegate?.preview(self, attemptRequest: request) - }) - } -} - -private extension PostPreviewGenerator { - func addNonce(_ nonce: String, to url: URL) -> URL? { - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return nil - } - var queryItems = components.queryItems ?? [] - queryItems.append(URLQueryItem(name: "preview", value: "true")) - queryItems.append(URLQueryItem(name: "frame-nonce", value: nonce)) - components.queryItems = queryItems - return components.url - } -} diff --git a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+FeaturedImageUpload.swift b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+FeaturedImageUpload.swift index 528f78972e06..0766a2027d99 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+FeaturedImageUpload.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController+FeaturedImageUpload.swift @@ -18,6 +18,10 @@ extension PostSettingsViewController { MediaCoordinator.shared.retryMedia(media) setupObservingOf(media: media) } + + if let mediaIdentifier = apost.featuredImage?.mediaID { + featuredImageDelegate?.gutenbergDidRequestFeaturedImageId(mediaIdentifier) + } } @objc func removeMediaObserver() { @@ -114,10 +118,26 @@ extension PostSettingsViewController { } struct FeaturedImageActionSheet { - static let title = NSLocalizedString("Featured Image Options", comment: "Title for action sheet with featured media options.") - static let dismissActionTitle = NSLocalizedString("Dismiss", comment: "User action to dismiss featured media options.") - static let retryUploadActionTitle = NSLocalizedString("Retry", comment: "User action to retry featured media upload.") - static let removeActionTitle = NSLocalizedString("Remove", comment: "User action to remove featured media.") + static let title = NSLocalizedString( + "postSettings.featuredImageUploadActionSheet.title", + value: "Featured Image Options", + comment: "Title for action sheet with featured media options." + ) + static let dismissActionTitle = NSLocalizedString( + "postSettings.featuredImageUploadActionSheet.dismiss", + value: "Dismiss", + comment: "User action to dismiss featured media options." + ) + static let retryUploadActionTitle = NSLocalizedString( + "postSettings.featuredImageUploadActionSheet.retryUpload", + value: "Retry", + comment: "User action to retry featured media upload." + ) + static let removeActionTitle = NSLocalizedString( + "postSettings.featuredImageUploadActionSheet.remove", + value: "Remove", + comment: "User action to remove featured media." + ) } } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.h b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.h index c2a1b2d10d12..ab3e496d27c5 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.h +++ b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.h @@ -1,6 +1,12 @@ #import <UIKit/UIKit.h> #import "AbstractPost.h" +@protocol FeaturedImageDelegate + +- (void)gutenbergDidRequestFeaturedImageId:(nonnull NSNumber *)mediaID; + +@end + @interface PostSettingsViewController : UITableViewController - (nonnull instancetype)initWithPost:(nonnull AbstractPost *)aPost; @@ -8,4 +14,6 @@ @property (nonnull, nonatomic, strong, readonly) AbstractPost *apost; +@property (nonatomic, weak, nullable) id<FeaturedImageDelegate> featuredImageDelegate; + @end diff --git a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.m b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.m index ad2797a8aa57..a2668450d0da 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.m +++ b/WordPress/Classes/ViewRelated/Post/PostSettingsViewController.m @@ -1,18 +1,13 @@ #import "PostSettingsViewController.h" #import "PostSettingsViewController_Internal.h" - -#import "PostCategoriesViewController.h" #import "FeaturedImageViewController.h" -#import "LocationService.h" #import "Media.h" #import "PostFeaturedImageCell.h" -#import "PostGeolocationCell.h" -#import "PostGeolocationViewController.h" #import "SettingsSelectionViewController.h" #import "SharingDetailViewController.h" #import "WPTableViewActivityCell.h" #import "WPTableImageSource.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "MediaService.h" #import "WPProgressTableViewCell.h" #import "WPAndDeviceMediaLibraryDataSource.h" @@ -32,6 +27,7 @@ typedef NS_ENUM(NSInteger, PostSettingsRow) { PostSettingsRowCategories = 0, PostSettingsRowTags, + PostSettingsRowAuthor, PostSettingsRowPublishDate, PostSettingsRowStatus, PostSettingsRowVisibility, @@ -43,7 +39,6 @@ typedef NS_ENUM(NSInteger, PostSettingsRow) { PostSettingsRowFeaturedLoading, PostSettingsRowShareConnection, PostSettingsRowShareMessage, - PostSettingsRowGeolocation, PostSettingsRowSlug, PostSettingsRowExcerpt }; @@ -51,17 +46,12 @@ typedef NS_ENUM(NSInteger, PostSettingsRow) { static CGFloat CellHeight = 44.0f; static CGFloat LoadingIndicatorHeight = 28.0f; -static NSInteger RowIndexForPassword = 3; -static CGFloat LocationCellHeightToWidthAspectRatio = 0.5f; - static NSString *const TableViewActivityCellIdentifier = @"TableViewActivityCellIdentifier"; static NSString *const TableViewProgressCellIdentifier = @"TableViewProgressCellIdentifier"; static NSString *const TableViewFeaturedImageCellIdentifier = @"TableViewFeaturedImageCellIdentifier"; static NSString *const TableViewStickyPostCellIdentifier = @"TableViewStickyPostCellIdentifier"; -static void *PostGeoLocationObserverContext = &PostGeoLocationObserverContext; - @interface PostSettingsViewController () <UITextFieldDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate, UIPopoverControllerDelegate, WPMediaPickerViewControllerDelegate, @@ -71,6 +61,7 @@ @interface PostSettingsViewController () <UITextFieldDelegate, @property (nonatomic, strong) AbstractPost *apost; @property (nonatomic, strong) UITextField *passwordTextField; @property (nonatomic, strong) UIButton *passwordVisibilityButton; +@property (nonatomic, strong) NSArray *postMetaSectionRows; @property (nonatomic, strong) NSArray *visibilityList; @property (nonatomic, strong) NSArray *formatsList; @property (nonatomic, strong) WPTableImageSource *imageSource; @@ -83,9 +74,6 @@ @interface PostSettingsViewController () <UITextFieldDelegate, @property (nonatomic, strong) WPAndDeviceMediaLibraryDataSource *mediaDataSource; @property (nonatomic, strong) NSArray *publicizeConnections; -@property (nonatomic, strong) PostGeolocationCell *postGeoLocationCell; -@property (nonatomic, strong) WPTableViewCell *setGeoLocationCell; - @property (nonatomic, strong) NoResultsViewController *noResultsView; @property (nonatomic, strong) NSObject *mediaLibraryChangeObserverKey; @@ -95,7 +83,6 @@ @interface PostSettingsViewController () <UITextFieldDelegate, @property (nonatomic, strong, readonly) BlogService *blogService; @property (nonatomic, strong, readonly) SharingService *sharingService; -@property (nonatomic, strong, readonly) LocationService *locationService; #pragma mark - Properties: Reachability @@ -111,13 +98,12 @@ - (void)dealloc { [self.internetReachability stopNotifier]; - [self removePostPropertiesObserver]; [self removeMediaObserver]; } - (instancetype)initWithPost:(AbstractPost *)aPost { - self = [super initWithStyle:UITableViewStyleGrouped]; + self = [super initWithStyle:UITableViewStyleInsetGrouped]; if (self) { self.apost = aPost; } @@ -130,8 +116,12 @@ - (void)viewDidLoad { [super viewDidLoad]; - self.title = NSLocalizedString(@"Post Settings", @"The title of the Post Settings screen."); - + if ([self.apost isKindOfClass:[Page class]]) { + self.title = NSLocalizedString(@"Page Settings", @"The title of the Page Settings screen."); + } else { + self.title = NSLocalizedString(@"Post Settings", @"The title of the Post Settings screen."); + } + DDLogInfo(@"%@ %@", self, NSStringFromSelector(_cmd)); [WPStyleGuide configureColorsForView:self.view andTableView:self.tableView]; @@ -157,9 +147,7 @@ - (void)viewDidLoad self.tableView.accessibilityIdentifier = @"SettingsTable"; self.isUploadingMedia = NO; - NSManagedObjectContext *mainContext = [[ContextManager sharedInstance] mainContext]; - _blogService = [[BlogService alloc] initWithManagedObjectContext:mainContext]; - _locationService = [LocationService sharedService]; + _blogService = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; [self setupPostDateFormatter]; @@ -178,6 +166,7 @@ - (void)viewWillAppear:(BOOL)animated [self.navigationController setNavigationBarHidden:NO animated:NO]; [self.navigationController setToolbarHidden:YES]; + [self configureMetaSectionRows]; [self reloadData]; } @@ -223,9 +212,9 @@ - (void)refreshPasswordVisibilityButton BOOL passwordIsVisible = !self.passwordTextField.secureTextEntry; if (passwordIsVisible) { - icon = [Gridicon iconOfType:GridiconTypeVisible]; + icon = [UIImage gridiconOfType:GridiconTypeVisible]; } else { - icon = [Gridicon iconOfType:GridiconTypeNotVisible]; + icon = [UIImage gridiconOfType:GridiconTypeNotVisible]; } [self.passwordVisibilityButton setImage:icon forState:UIControlStateNormal]; @@ -250,7 +239,7 @@ - (void)setupReachability __weak __typeof(self) weakSelf = self; - self.internetReachability.reachableBlock = ^void(Reachability * reachability) { + self.internetReachability.reachableBlock = ^void(Reachability * __unused reachability) { [weakSelf internetIsReachableAgain]; }; @@ -262,7 +251,7 @@ - (void)setupPostDateFormatter NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; dateFormatter.dateStyle = NSDateFormatterLongStyle; dateFormatter.timeStyle = NSDateFormatterShortStyle; - dateFormatter.timeZone = [self.blogService timeZoneForBlog:self.apost.blog]; + dateFormatter.timeZone = [self.apost.blog timeZone]; self.postDateFormatter = dateFormatter; } @@ -295,40 +284,11 @@ - (void)synchPostFormatsAndDo:(void(^)(void))completionBlock [self.blogService syncPostFormatsForBlog:self.apost.blog success:^{ [weakSelf setupFormatsList]; completionBlock(); - } failure:^(NSError * _Nonnull error) { + } failure:^(NSError * _Nonnull __unused error) { completionBlock(); }]; } -#pragma mark - KVO - -- (void)addPostPropertiesObserver -{ - [self.post addObserver:self - forKeyPath:NSStringFromSelector(@selector(geolocation)) - options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld - context:PostGeoLocationObserverContext]; -} - -- (void)removePostPropertiesObserver -{ - [self.post removeObserver:self forKeyPath:NSStringFromSelector(@selector(geolocation))]; -} - -- (void)observeValueForKeyPath:(NSString *)keyPath - ofObject:(id)object - change:(NSDictionary *)change - context:(void *)context -{ - if (context == PostGeoLocationObserverContext && object == self.post) { - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - [self.tableView reloadData]; - }]; - } else { - [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; - } -} - #pragma mark - Instance Methods - (void)setApost:(AbstractPost *)apost @@ -336,11 +296,7 @@ - (void)setApost:(AbstractPost *)apost if ([apost isEqual:_apost]) { return; } - if (_apost) { - [self removePostPropertiesObserver]; - } _apost = apost; - [self addPostPropertiesObserver]; } - (Post *)post @@ -407,9 +363,8 @@ - (void)configureSections @(PostSettingsSectionFeaturedImage), stickyPostSection, @(PostSettingsSectionShare), - @(PostSettingsSectionGeolocation), @(PostSettingsSectionMoreOptions) ] mutableCopy]; - // Remove sticky post section for self-hosted non JetPack site + // Remove sticky post section for self-hosted non Jetpack site // and non admin user // if (![self.apost.blog supports:BlogFeatureWPComRESTAPI] && !self.apost.blog.isAdmin) { @@ -433,10 +388,7 @@ - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger return 2; } else if (sec == PostSettingsSectionMeta) { - if (self.apost.password) { - return 4; - } - return 3; + return [self.postMetaSectionRows count]; } else if (sec == PostSettingsSectionFormat) { return 1; @@ -450,9 +402,6 @@ - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger } else if (sec == PostSettingsSectionShare) { return [self numberOfRowsForShareSection]; - } else if (sec == PostSettingsSectionGeolocation) { - return 1; - } else if (sec == PostSettingsSectionMoreOptions) { return 2; @@ -480,11 +429,8 @@ - (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInte return NSLocalizedString(@"Mark as Sticky", @"Label for the Mark as Sticky option in post settings."); } else if (sec == PostSettingsSectionShare && [self numberOfRowsForShareSection] > 0) { - return NSLocalizedString(@"Sharing", @"Label for the Sharing section in post Settings. Should be the same as WP core."); + return NSLocalizedString(@"Jetpack Social", @"Label for the Sharing section in post Settings. Should be the same as WP core."); - } else if (sec == PostSettingsSectionGeolocation) { - return NSLocalizedString(@"Location", @"Label for the geolocation feature (tagging posts by their physical location)."); - } else if (sec == PostSettingsSectionMoreOptions) { return NSLocalizedString(@"More Options", @"Label for the More Options area in post settings. Should use the same translation as core WP."); @@ -512,13 +458,8 @@ - (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSIntege - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { - CGFloat width = CGRectGetWidth(self.tableView.frame); NSInteger sectionId = [[self.sections objectAtIndex:indexPath.section] integerValue]; - if (sectionId == PostSettingsSectionGeolocation && self.post.geolocation != nil) { - return ceilf(width * LocationCellHeightToWidthAspectRatio); - } - if (sectionId == PostSettingsSectionFeaturedImage) { if ([self isUploadingMedia]) { return CellHeight + (2.f * PostFeaturedImageCellMargin); @@ -530,7 +471,8 @@ - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPa } if (sectionId == PostSettingsSectionMeta) { - if (indexPath.row == RowIndexForPassword) { + NSInteger row = [[self.postMetaSectionRows objectAtIndex:indexPath.row] integerValue]; + if (row == PostSettingsRowPassword) { return CellHeight; } } @@ -556,8 +498,6 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N cell = [self configureStickyPostCellForIndexPath:indexPath]; } else if (sec == PostSettingsSectionShare) { cell = [self configureShareCellForIndexPath:indexPath]; - } else if (sec == PostSettingsSectionGeolocation) { - cell = [self configureGeolocationCellForIndexPath:indexPath]; } else if (sec == PostSettingsSectionMoreOptions) { cell = [self configureMoreOptionsCellForIndexPath:indexPath]; } @@ -581,6 +521,8 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath [self showPostStatusSelector]; } else if (cell.tag == PostSettingsRowVisibility) { [self showPostVisibilitySelector]; + } else if (cell.tag == PostSettingsRowAuthor) { + [self showPostAuthorSelector]; } else if (cell.tag == PostSettingsRowFormat) { [self showPostFormatSelector]; } else if (cell.tag == PostSettingsRowFeaturedImage) { @@ -593,8 +535,6 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath [self toggleShareConnectionForIndexPath:indexPath]; } else if (cell.tag == PostSettingsRowShareMessage) { [self showEditShareMessageController]; - } else if (cell.tag == PostSettingsRowGeolocation) { - [self showPostGeolocationSelector]; } else if (cell.tag == PostSettingsRowSlug) { [self showEditSlugController]; } else if (cell.tag == PostSettingsRowExcerpt) { @@ -633,10 +573,38 @@ - (UITableViewCell *)configureTaxonomyCellForIndexPath:(NSIndexPath *)indexPath return cell; } +- (void)configureMetaSectionRows +{ + NSMutableArray *metaRows = [[NSMutableArray alloc] init]; + + if (self.apost.isMultiAuthorBlog) { + [metaRows addObject:@(PostSettingsRowAuthor)]; + } + + [metaRows addObjectsFromArray:@[ @(PostSettingsRowPublishDate), + @(PostSettingsRowStatus), + @(PostSettingsRowVisibility) ]]; + + if (self.apost.password) { + [metaRows addObject:@(PostSettingsRowPassword)]; + } + + self.postMetaSectionRows = [metaRows copy]; +} + - (UITableViewCell *)configureMetaPostMetaCellForIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell; - if (indexPath.row == 0) { + NSInteger row = [[self.postMetaSectionRows objectAtIndex:indexPath.row] integerValue]; + + if (row == PostSettingsRowAuthor) { + // Author + cell = [self getWPTableViewDisclosureCell]; + cell.textLabel.text = NSLocalizedString(@"Author", @"The author of the post or page."); + cell.accessibilityIdentifier = @"SetAuthor"; + cell.detailTextLabel.text = [self.apost authorNameForDisplay]; + cell.tag = PostSettingsRowAuthor; + } else if (row == PostSettingsRowPublishDate) { // Publish date cell = [self getWPTableViewDisclosureCell]; if (self.apost.dateCreated && ![self.apost shouldPublishImmediately]) { @@ -648,11 +616,19 @@ - (UITableViewCell *)configureMetaPostMetaCellForIndexPath:(NSIndexPath *)indexP cell.detailTextLabel.text = [self.postDateFormatter stringFromDate:self.apost.dateCreated]; } else { - cell.textLabel.text = NSLocalizedString(@"Publish", @"Label for the publish (verb) button. Tapping publishes a draft post."); + cell.textLabel.text = NSLocalizedString(@"Publish Date", @"Label for the publish date button."); cell.detailTextLabel.text = NSLocalizedString(@"Immediately", @""); } + + if ([self.apost.status isEqualToString:PostStatusPrivate]) { + [cell disable]; + } else { + [cell enable]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + } + cell.tag = PostSettingsRowPublishDate; - } else if (indexPath.row == 1) { + } else if (row == PostSettingsRowStatus) { // Publish Status cell = [self getWPTableViewDisclosureCell]; cell.textLabel.text = NSLocalizedString(@"Status", @"The status of the post. Should be the same as in core WP."); @@ -667,15 +643,15 @@ - (UITableViewCell *)configureMetaPostMetaCellForIndexPath:(NSIndexPath *)indexP cell.tag = PostSettingsRowStatus; - } else if (indexPath.row == 2) { + } else if (row == PostSettingsRowVisibility) { // Visibility cell = [self getWPTableViewDisclosureCell]; cell.textLabel.text = NSLocalizedString(@"Visibility", @"The visibility settings of the post. Should be the same as in core WP."); - cell.detailTextLabel.text = [self titleForVisibility]; + cell.detailTextLabel.text = [self.apost titleForVisibility]; cell.tag = PostSettingsRowVisibility; cell.accessibilityIdentifier = @"Visibility"; - } else { + } else if (row == PostSettingsRowPassword) { cell = [self configurePasswordCell]; } @@ -687,6 +663,7 @@ - (UITableViewCell *)configurePasswordCell // Password WPTextFieldTableViewCell *textCell = [self getWPTableViewTextFieldCell]; textCell.textLabel.text = NSLocalizedString(@"Password", @"Label for the password field. Should be the same as WP core."); + textCell.textField.textColor = [UIColor murielText]; textCell.textField.text = self.apost.password; textCell.textField.attributedPlaceholder = nil; textCell.textField.placeholder = NSLocalizedString(@"Enter a password", @""); @@ -767,6 +744,7 @@ - (UITableViewCell *)configureStickyPostCellForIndexPath:(NSIndexPath *)indexPat cell.name = NSLocalizedString(@"Stick post to the front page", @"This is the cell title."); cell.on = self.post.isStickyPost; cell.onChange = ^(BOOL newValue) { + [WPAnalytics trackEvent:WPAnalyticsEventEditorPostStickyChanged properties:@{@"via": @"settings"}]; weakSelf.post.isStickyPost = newValue; }; return cell; @@ -864,54 +842,6 @@ - (UITableViewCell *)configureShareCellForIndexPath:(NSIndexPath *)indexPath return cell; } -- (PostGeolocationCell *)postGeoLocationCell { - if (!_postGeoLocationCell) { - _postGeoLocationCell = [[PostGeolocationCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; - _postGeoLocationCell.tag = PostSettingsRowGeolocation; - } - Coordinate *coordinate = self.post.geolocation; - NSString *address = NSLocalizedString(@"Finding your location...", @"Geo-tagging posts, status message when geolocation is found."); - if (coordinate) { - CLLocation *postLocation = [[CLLocation alloc] initWithLatitude:coordinate.latitude longitude:coordinate.longitude]; - if ([self.locationService hasAddressForLocation:postLocation]) { - address = self.locationService.lastGeocodedAddress; - } else { - address = NSLocalizedString(@"Looking up address...", @"Used with posts that are geo-tagged. Let's the user know the the app is looking up the address for the coordinates tagging the post."); - __weak __typeof__(self) weakSelf = self; - [self.locationService getAddressForLocation:postLocation - completion:^(CLLocation *location, NSString *address, NSError *error) { - [weakSelf.tableView reloadData]; - }]; - - } - } - [_postGeoLocationCell setCoordinate:coordinate andAddress:address]; - return _postGeoLocationCell; -} - -- (WPTableViewCell *)setGeoLocationCell { - if (!_setGeoLocationCell) { - _setGeoLocationCell = [[WPTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; - _setGeoLocationCell.accessoryType = UITableViewCellAccessoryNone; - _setGeoLocationCell.textLabel.text = NSLocalizedString(@"Set Location", @"Label for cell that allow users to set the location of a post"); - _setGeoLocationCell.tag = PostSettingsRowGeolocation; - _setGeoLocationCell.textLabel.textAlignment = NSTextAlignmentCenter; - [WPStyleGuide configureTableViewActionCell:_setGeoLocationCell]; - } - return _setGeoLocationCell; -} - -- (UITableViewCell *)configureGeolocationCellForIndexPath:(NSIndexPath *)indexPath -{ - WPTableViewCell *cell; - if (self.post.geolocation == nil) { - return self.setGeoLocationCell; - } else { - return self.postGeoLocationCell; - } - return cell; -} - - (UITableViewCell *)configureMoreOptionsCellForIndexPath:(NSIndexPath *)indexPath { if (indexPath.row == 0) { @@ -1025,6 +955,7 @@ - (void)showPostStatusSelector SettingsSelectionViewController *vc = [[SettingsSelectionViewController alloc] initWithDictionary:statusDict]; __weak SettingsSelectionViewController *weakVc = vc; vc.onItemSelected = ^(NSString *status) { + [WPAnalytics trackEvent:WPAnalyticsEventEditorPostStatusChanged properties:@{@"via": @"settings"}]; self.apost.status = status; [weakVc dismiss]; [self.tableView reloadData]; @@ -1034,59 +965,23 @@ - (void)showPostStatusSelector - (void)showPostVisibilitySelector { - NSArray *titles = @[ - NSLocalizedString(@"Public", @"Privacy setting for posts set to 'Public' (default). Should be the same as in core WP."), - NSLocalizedString(@"Password protected", @"Privacy setting for posts set to 'Password protected'. Should be the same as in core WP."), - NSLocalizedString(@"Private", @"Privacy setting for posts set to 'Private'. Should be the same as in core WP.") - ]; - NSDictionary *visiblityDict = @{ - @"DefaultValue": NSLocalizedString(@"Public", @"Privacy setting for posts set to 'Public' (default). Should be the same as in core WP."), - @"Title" : NSLocalizedString(@"Visibility", nil), - @"Titles" : titles, - @"Values" : titles, - @"CurrentValue" : [self titleForVisibility]}; - SettingsSelectionViewController *vc = [[SettingsSelectionViewController alloc] initWithDictionary:visiblityDict]; - __weak SettingsSelectionViewController *weakVc = vc; - vc.onItemSelected = ^(NSString *visibility) { + PostVisibilitySelectorViewController *vc = [[PostVisibilitySelectorViewController alloc] init:self.apost]; + __weak PostVisibilitySelectorViewController *weakVc = vc; + vc.completion = ^(NSString *__unused visibility) { + [WPAnalytics trackEvent:WPAnalyticsEventEditorPostVisibilityChanged properties:@{@"via": @"settings"}]; [weakVc dismiss]; - - NSAssert(self.apost != nil, @"The post should not be nil here."); - NSAssert(!self.apost.isFault, @"The post should not be a fault here here."); - NSAssert(self.apost.managedObjectContext != nil, @"The post's MOC should not be nil here."); - - if ([visibility isEqualToString:NSLocalizedString(@"Private", @"Post privacy status in the Post Editor/Settings area (compare with WP core translations).")]) { - self.apost.status = PostStatusPrivate; - self.apost.password = nil; - } else { - if ([self.apost.status isEqualToString:PostStatusPrivate]) { - if ([self.apost.original.status isEqualToString:PostStatusPrivate]) { - self.apost.status = PostStatusPublish; - } else { - // restore the original status - self.apost.status = self.apost.original.status; - } - } - if ([visibility isEqualToString:NSLocalizedString(@"Password protected", @"Post password protection in the Post Editor/Settings area (compare with WP core translations).")]) { - - NSString *password = @""; - - NSAssert(self.apost.original != nil, - @"We're expecting to have a reference to the original post here."); - NSAssert(!self.apost.original.isFault, - @"The original post should not be a fault here here."); - NSAssert(self.apost.original.managedObjectContext != nil, - @"The original post's MOC should not be nil here."); - - if (self.apost.original.password) { - // restore the original password - password = self.apost.original.password; - } - self.apost.password = password; - } else { - self.apost.password = nil; - } - } + [self.tableView reloadData]; + }; + [self.navigationController pushViewController:vc animated:YES]; +} +- (void)showPostAuthorSelector +{ + PostAuthorSelectorViewController *vc = [[PostAuthorSelectorViewController alloc] init:self.apost]; + __weak PostAuthorSelectorViewController *weakVc = vc; + vc.completion = ^{ + [WPAnalytics trackEvent:WPAnalyticsEventEditorPostAuthorChanged properties:@{@"via": @"settings"}]; + [weakVc dismiss]; [self.tableView reloadData]; }; [self.navigationController pushViewController:vc animated:YES]; @@ -1122,6 +1017,7 @@ - (void)showPostFormatSelector vc.onItemSelected = ^(NSString *status) { // Check if the object passed is indeed an NSString, otherwise we don't want to try to set it as the post format if ([status isKindOfClass:[NSString class]]) { + [WPAnalytics trackEvent:WPAnalyticsEventEditorPostFormatChanged properties:@{@"via": @"settings"}]; post.postFormatText = status; [weakVc dismiss]; [self.tableView reloadData]; @@ -1200,14 +1096,6 @@ - (void)showEditShareMessageController [self.navigationController pushViewController:vc animated:YES]; } -- (void)showPostGeolocationSelector -{ - PostGeolocationViewController *controller = [[PostGeolocationViewController alloc] initWithPost:self.post locationService:self.locationService]; - UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:controller]; - navigationController.modalPresentationStyle = UIModalPresentationFormSheet; - [self presentViewController:navigationController animated:YES completion:nil]; -} - - (void)showFeaturedImageSelector { if (self.apost.featuredImage) { @@ -1244,6 +1132,7 @@ - (void)showEditSlugController vc.title = NSLocalizedString(@"Slug", @"Label for the slug field. Should be the same as WP core."); vc.autocapitalizationType = UITextAutocapitalizationTypeNone; vc.onValueChanged = ^(NSString *value) { + [WPAnalytics trackEvent:WPAnalyticsEventEditorPostSlugChanged properties:@{@"via": @"settings"}]; self.apost.wp_slug = value; [self.tableView reloadData]; }; @@ -1258,6 +1147,10 @@ - (void)showEditExcerptController isPassword:NO]; vc.title = NSLocalizedString(@"Excerpt", @"Label for the excerpt field. Should be the same as WP core."); vc.onValueChanged = ^(NSString *value) { + if (self.apost.mt_excerpt != value) { + [WPAnalytics trackEvent:WPAnalyticsEventEditorPostExcerptChanged properties:@{@"via": @"settings"}]; + } + self.apost.mt_excerpt = value; [self.tableView reloadData]; }; @@ -1272,7 +1165,7 @@ - (void)showMediaPicker options.filter = WPMediaTypeImage; options.showSearchBar = YES; options.badgedUTTypes = [NSSet setWithObject: (__bridge NSString *)kUTTypeGIF]; - options.preferredStatusBarStyle = UIStatusBarStyleLightContent; + options.preferredStatusBarStyle = [WPStyleGuide preferredStatusBarStyle]; WPNavigationMediaPickerViewController *picker = [[WPNavigationMediaPickerViewController alloc] initWithOptions:options]; WPAndDeviceMediaLibraryDataSource *mediaDataSource = [[WPAndDeviceMediaLibraryDataSource alloc] initWithPost:self.apost @@ -1303,9 +1196,7 @@ - (void)showTagsPicker PostTagPickerViewController *tagsPicker = [[PostTagPickerViewController alloc] initWithTags:self.post.tags blog:self.post.blog]; tagsPicker.onValueChanged = ^(NSString * _Nonnull value) { - if (!value.isEmpty) { - [WPAnalytics track:WPAnalyticsStatPostSettingsTagsAdded]; - } + [WPAnalytics trackEvent:WPAnalyticsEventEditorPostTagsChanged properties:@{@"via": @"settings"}]; self.post.tags = value; }; @@ -1330,17 +1221,6 @@ - (void)featuredImageFailedLoading:(NSIndexPath *)indexPath withError:(NSError * cell.textLabel.text = NSLocalizedString(@"Featured Image did not load", @""); } -- (NSString *)titleForVisibility -{ - if (self.apost.password) { - return NSLocalizedString(@"Password protected", @"Privacy setting for posts set to 'Password protected'. Should be the same as in core WP."); - } else if ([self.apost.status isEqualToString:PostStatusPrivate]) { - return NSLocalizedString(@"Private", @"Privacy setting for posts set to 'Private'. Should be the same as in core WP."); - } - - return NSLocalizedString(@"Public", @"Privacy setting for posts set to 'Public' (default). Should be the same as in core WP."); -} - - (NoResultsViewController *)noResultsView { if (!_noResultsView) { @@ -1353,7 +1233,7 @@ - (void)registerChangeObserverForPicker:(WPMediaPickerViewController *)picker { NSAssert(self.mediaLibraryChangeObserverKey == nil, nil); __weak PostSettingsViewController * weakSelf = self; - self.mediaLibraryChangeObserverKey = [self.mediaDataSource registerChangeObserverBlock:^(BOOL incrementalChanges, NSIndexSet * _Nonnull removed, NSIndexSet * _Nonnull inserted, NSIndexSet * _Nonnull changed, NSArray<id<WPMediaMove>> * _Nonnull moves) { + self.mediaLibraryChangeObserverKey = [self.mediaDataSource registerChangeObserverBlock:^(BOOL __unused incrementalChanges, NSIndexSet * _Nonnull __unused removed, NSIndexSet * _Nonnull __unused inserted, NSIndexSet * _Nonnull __unused changed, NSArray<id<WPMediaMove>> * _Nonnull __unused moves) { [weakSelf updateSearchBarForPicker:picker]; BOOL isNotSearching = [weakSelf.mediaDataSource.searchQuery isEmpty]; @@ -1433,6 +1313,8 @@ - (void)mediaPickerController:(WPMediaPickerViewController *)picker didFinishPic [self unregisterChangeObserver]; [self.mediaDataSource searchCancelled]; + [WPAnalytics trackEvent:WPAnalyticsEventEditorPostFeaturedImageChanged properties:@{@"via": @"settings", @"action": @"added"}]; + if ([[assets firstObject] isKindOfClass:[PHAsset class]]){ PHAsset *asset = [assets firstObject]; self.isUploadingMedia = YES; @@ -1460,6 +1342,8 @@ - (void)mediaPickerControllerDidCancel:(WPMediaPickerViewController *)picker { - (void)postCategoriesViewController:(PostCategoriesViewController *)controller didUpdateSelectedCategories:(NSSet *)categories { + [WPAnalytics trackEvent:WPAnalyticsEventEditorPostCategoryChanged properties:@{@"via": @"settings"}]; + // Save changes. self.post.categories = [categories mutableCopy]; [self.post save]; @@ -1494,8 +1378,7 @@ - (void)postFeatureImageCell:(PostFeaturedImageCell *)cell didFinishLoadingImage - (void)updateFeaturedImageCell:(PostFeaturedImageCell *)cell { - self.featuredImage = cell.image; - cell.accessibilityIdentifier = @"Current Featured Image"; + self.featuredImage = cell.image; NSInteger featuredImageSection = [self.sections indexOfObject:@(PostSettingsSectionFeaturedImage)]; NSIndexSet *featuredImageSectionSet = [NSIndexSet indexSetWithIndex:featuredImageSection]; [self.tableView reloadSections:featuredImageSectionSet withRowAnimation:UITableViewRowAnimationNone]; @@ -1505,10 +1388,13 @@ - (void)updateFeaturedImageCell:(PostFeaturedImageCell *)cell - (void)FeaturedImageViewControllerOnRemoveImageButtonPressed:(FeaturedImageViewController *)controller { + [WPAnalytics trackEvent:WPAnalyticsEventEditorPostFeaturedImageChanged properties:@{@"via": @"settings", @"action": @"removed"}]; self.featuredImage = nil; self.animatedFeaturedImageData = nil; [self.apost setFeaturedImage:nil]; [self dismissViewControllerAnimated:YES completion:nil]; + [self.tableView reloadData]; + [self.featuredImageDelegate gutenbergDidRequestFeaturedImageId:nil]; } @end diff --git a/WordPress/Classes/ViewRelated/Post/PostSharingController.swift b/WordPress/Classes/ViewRelated/Post/PostSharingController.swift index 18a450f13041..02c57c2f1075 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSharingController.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSharingController.swift @@ -44,6 +44,10 @@ import SVProgressHUD } @objc func sharePost(_ title: String, summary: String, link: String?, fromView anchorView: UIView, inViewController viewController: UIViewController) { + sharePost(title, summary: summary, link: link, fromAnchor: .view(anchorView), inViewController: viewController) + } + + private func sharePost(_ title: String, summary: String, link: String?, fromAnchor anchor: PopoverAnchor, inViewController viewController: UIViewController) { let controller = shareController( title, summary: summary, @@ -59,8 +63,13 @@ import SVProgressHUD viewController.present(controller, animated: true) if let presentationController = controller.popoverPresentationController { presentationController.permittedArrowDirections = .any - presentationController.sourceView = anchorView - presentationController.sourceRect = anchorView.bounds + switch anchor { + case .barButtonItem(let item): + presentationController.barButtonItem = item + case .view(let anchorView): + presentationController.sourceView = anchorView + presentationController.sourceRect = anchorView.bounds + } } } @@ -84,6 +93,16 @@ import SVProgressHUD inViewController: viewController) } + func shareReaderPost(_ post: ReaderPost, fromAnchor anchor: PopoverAnchor, inViewController viewController: UIViewController) { + + sharePost( + post.titleForDisplay(), + summary: post.contentPreviewForDisplay(), + link: post.permaLink, + fromAnchor: anchor, + inViewController: viewController) + } + @objc func shareReaderPost(_ post: ReaderPost, fromView anchorView: UIView, inViewController viewController: UIViewController) { sharePost( @@ -114,4 +133,6 @@ import SVProgressHUD } } + + typealias PopoverAnchor = UIPopoverPresentationController.PopoverAnchor } diff --git a/WordPress/Classes/ViewRelated/Post/PostTagPickerViewController.swift b/WordPress/Classes/ViewRelated/Post/PostTagPickerViewController.swift index 88bce076fad9..ad9a01117b44 100644 --- a/WordPress/Classes/ViewRelated/Post/PostTagPickerViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/PostTagPickerViewController.swift @@ -25,6 +25,7 @@ class PostTagPickerViewController: UIViewController { fileprivate let textView = UITextView() private let textViewContainer = UIView() fileprivate let tableView = UITableView(frame: .zero, style: .grouped) + private let descriptionLabel = UILabel() fileprivate var dataSource: PostTagPickerDataSource = LoadingDataSource() { didSet { tableView.dataSource = dataSource @@ -32,12 +33,15 @@ class PostTagPickerViewController: UIViewController { } } + var onContentViewHeightDetermined: (() -> Void)? + override func viewDidLoad() { super.viewDidLoad() WPStyleGuide.configureTableViewColors(tableView: tableView) view.backgroundColor = .listBackground + view.tintColor = .editorPrimary textView.delegate = self // Do any additional setup after loading the view, typically from a nib. @@ -67,17 +71,28 @@ class PostTagPickerViewController: UIViewController { textViewContainer.addSubview(textView) view.addSubview(textViewContainer) + descriptionLabel.text = NSLocalizedString("Tags help tell readers what a post is about. Separate different tags with commas.", comment: "Label explaining why users might want to add tags.") + descriptionLabel.numberOfLines = 0 + WPStyleGuide.configureLabelForRegularFontStyle(descriptionLabel) + descriptionLabel.textColor = .textSubtle + view.addSubview(descriptionLabel) + textView.translatesAutoresizingMaskIntoConstraints = false textViewContainer.translatesAutoresizingMaskIntoConstraints = false tableView.translatesAutoresizingMaskIntoConstraints = false + descriptionLabel.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ + descriptionLabel.topAnchor.constraint(equalTo: view.readableContentGuide.topAnchor, constant: 10), + descriptionLabel.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), + descriptionLabel.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), + textView.topAnchor.constraint(equalTo: textViewContainer.topAnchor), textView.bottomAnchor.constraint(equalTo: textViewContainer.bottomAnchor), textView.leadingAnchor.constraint(equalTo: view.readableContentGuide.leadingAnchor), textView.trailingAnchor.constraint(equalTo: view.readableContentGuide.trailingAnchor), - textViewContainer.topAnchor.constraint(equalTo: view.topAnchor, constant: 35), + textViewContainer.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 10), textViewContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: -1), textViewContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 1), textViewContainer.bottomAnchor.constraint(equalTo: tableView.topAnchor), @@ -93,13 +108,26 @@ class PostTagPickerViewController: UIViewController { textViewContainer.layer.masksToBounds = false keyboardObserver.tableView = tableView + + let doneButton = UIBarButtonItem(title: NSLocalizedString("Done", comment: "Done button title"), style: .plain, target: self, action: #selector(doneButtonPressed)) + navigationItem.setRightBarButton(doneButton, animated: false) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + textView.becomeFirstResponder() + updateContainerHeight() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) updateSuggestions() - textView.becomeFirstResponder() loadTags() + + tableView.contentInset.bottom += descriptionLabel.frame.height + 20 + + updateTableViewBottomInset() } override func viewWillDisappear(_ animated: Bool) { @@ -111,18 +139,38 @@ class PostTagPickerViewController: UIViewController { onValueChanged?(tags.joined(separator: ", ")) } WPError.dismissNetworkingNotice() + + textView.resignFirstResponder() } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - if #available(iOS 13, *) { - textViewContainer.layer.borderColor = UIColor.divider.cgColor - } + textViewContainer.layer.borderColor = UIColor.divider.cgColor + } + + @objc func doneButtonPressed() { + navigationController?.popViewController(animated: true) } fileprivate func reloadTableData() { tableView.reloadData() } + + fileprivate func updateTableViewBottomInset() { + guard !UIDevice.isPad() else { + return + } + + tableView.contentInset.bottom += presentedVC?.yPosition ?? 0 + } + + fileprivate func updateContainerHeight() { + descriptionLabel.layoutIfNeeded() + textViewContainer.layoutIfNeeded() + let contentHeight = tableView.contentSize.height + descriptionLabel.bounds.size.height + textViewContainer.bounds.height + preferredContentSize = CGSize(width: view.bounds.width, height: max(300.0, contentHeight)) + onContentViewHeightDetermined?() + } } @@ -420,3 +468,13 @@ extension WPStyleGuide { cell.backgroundColor = .listForeground } } + +extension PostTagPickerViewController: DrawerPresentable { + var collapsedHeight: DrawerHeight { + return .contentHeight(300) + } + + var scrollableView: UIScrollView? { + return tableView + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PostVisibilitySelectorViewController.swift b/WordPress/Classes/ViewRelated/Post/PostVisibilitySelectorViewController.swift new file mode 100644 index 000000000000..9f52020e622f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PostVisibilitySelectorViewController.swift @@ -0,0 +1,89 @@ +import UIKit + +@objc class PostVisibilitySelectorViewController: SettingsSelectionViewController { + /// The post to change the visibility + private var post: AbstractPost! + + /// A completion block that is called after the user select an option + @objc var completion: ((String) -> Void)? + + // MARK: - Constructors + + @objc init(_ post: AbstractPost) { + self.post = post + + let titles: NSArray = [ + NSLocalizedString("Public", comment: "Privacy setting for posts set to 'Public' (default). Should be the same as in core WP."), + NSLocalizedString("Password protected", comment: "Privacy setting for posts set to 'Password protected'. Should be the same as in core WP."), + NSLocalizedString("Private", comment: "Privacy setting for posts set to 'Private'. Should be the same as in core WP.") + ] + + let visiblityDict: [AnyHashable: Any] = [ + "DefaultValue": NSLocalizedString("Public", comment: "Privacy setting for posts set to 'Public' (default). Should be the same as in core WP."), + "Title": NSLocalizedString("Visibility", comment: "Visibility label"), + "Titles": titles, + "Values": titles, + "CurrentValue": post.titleForVisibility + ] + + super.init(dictionary: visiblityDict) + + onItemSelected = { [weak self] visibility in + guard let visibility = visibility as? String, + !post.isFault, post.managedObjectContext != nil else { + return + } + + if visibility == AbstractPost.privateLabel { + if post.isScheduled() { + // Make sure the post is not scheduled anymore. The user can't schedule a private post + post.publishImmediately() + } + post.status = .publishPrivate + post.password = nil + } else { + if post.status == .publishPrivate { + if post.original?.status == .publishPrivate { + post.status = .publish + } else { + // restore the original status + post.status = post.original?.status + } + } + + if visibility == AbstractPost.passwordProtectedLabel { + var password = "" + + assert(post.original != nil, + "We're expecting to have a reference to the original post here.") + assert(!post.original!.isFault, + "We're expecting to have a reference to the original post here.") + assert(post.original!.managedObjectContext != nil, + "The original post's MOC should not be nil here.") + + if let originalPassword = post.original?.password { + password = originalPassword + } + post.password = password + } else { + post.password = nil + } + } + + self?.completion?(visibility) + + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init!(style: UITableView.Style, andDictionary dictionary: [AnyHashable: Any]!) { + super.init(style: style, andDictionary: dictionary) + } + + override init(style: UITableView.Style) { + super.init(style: style) + } +} diff --git a/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/Blog+Title.swift b/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/Blog+Title.swift new file mode 100644 index 000000000000..f4fbf625cff5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/Blog+Title.swift @@ -0,0 +1,13 @@ +import Foundation + +extension Blog { + + /// The title of the blog + var title: String? { + guard let blogName = settings?.name, !blogName.isEmpty else { + return displayURL as String? + } + + return blogName + } +} diff --git a/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PasswordAlertController.swift b/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PasswordAlertController.swift new file mode 100644 index 000000000000..daed4debdad7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PasswordAlertController.swift @@ -0,0 +1,70 @@ +import UIKit +import Gridicons + +/// Display an Alert Controller that prompts for a password +class PasswordAlertController { + + var passwordField: UITextField! + + var onSubmit: ((String?) -> Void)? + + var onCancel: (() -> Void)? + + init(onSubmit: @escaping (String?) -> Void, onCancel: @escaping () -> Void) { + self.onSubmit = onSubmit + self.onCancel = onCancel + } + + /// Show the Alert Controller from a given view controller + func show(from viewController: UIViewController) { + let alertController = UIAlertController( + title: AbstractPost.passwordProtectedLabel, + message: Constants.passwordMessage, + preferredStyle: .alert + ) + + let submitAction = UIAlertAction(title: Constants.alertSubmit, style: .default) { _ in + self.onSubmit?(self.passwordField.text) + self.onSubmit = nil + self.onCancel = nil + alertController.dismiss(animated: true) + } + + let cancelAction = UIAlertAction(title: Constants.alertCancel, style: .cancel) { _ in + self.onCancel?() + self.onSubmit = nil + self.onCancel = nil + alertController.dismiss(animated: true) + } + + alertController.addTextField { textField in + self.passwordField = textField + textField.placeholder = Constants.postPassword + let button = UIButton() + textField.rightView = button + textField.rightViewMode = .always + self.togglePassword(button) + button.addTarget(self, action: #selector(self.togglePassword(_:)), for: .touchUpInside) + } + + alertController.addAction(submitAction) + alertController.addAction(cancelAction) + + viewController.present(alertController, animated: true, completion: nil) + } + + /// Toggle the UITextField isSecureTextEntry on/off + @objc func togglePassword(_ sender: UIButton) { + let isSecureTextEntry = !passwordField.isSecureTextEntry + passwordField.isSecureTextEntry = isSecureTextEntry + sender.setImage(isSecureTextEntry ? .gridicon(.visible) : .gridicon(.notVisible), for: .normal) + } + + private enum Constants { + static let alertSubmit = NSLocalizedString("OK", comment: "Submit button on prompt for user information.") + static let alertCancel = NSLocalizedString("Cancel", comment: "Cancel prompt for user information.") + static let postPassword = NSLocalizedString("Enter password", comment: "Placeholder of a field to type a password to protect the post.") + static let passwordMessage = NSLocalizedString("Enter a password to protect this post", comment: "Message explaining why the user might enter a password.") + } + +} diff --git a/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PrepublishingHeaderView.swift b/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PrepublishingHeaderView.swift new file mode 100644 index 000000000000..708d15c43344 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PrepublishingHeaderView.swift @@ -0,0 +1,98 @@ +import UIKit +import Gridicons + +protocol PrepublishingHeaderViewDelegate: AnyObject { + func closeButtonTapped() +} + +class PrepublishingHeaderView: UITableViewHeaderFooterView, NibLoadable { + + @IBOutlet weak var blogImageView: UIImageView! + @IBOutlet weak var publishingToLabel: UILabel! + @IBOutlet weak var blogTitleLabel: UILabel! + @IBOutlet weak var closeButtonView: UIView! + @IBOutlet weak var leadingConstraint: NSLayoutConstraint! + @IBOutlet weak var closeButton: UIButton! + @IBOutlet weak var separator: UIView! + + weak var delegate: PrepublishingHeaderViewDelegate? + + func configure(_ blog: Blog) { + blogImageView.downloadSiteIcon(for: blog) + blogTitleLabel.text = blog.title + } + + // MARK: - Close button + + func toggleCloseButton(visible: Bool) { + closeButtonView.layer.opacity = visible ? 1 : 0 + closeButtonView.isHidden = visible ? false : true + leadingConstraint.constant = visible ? 0 : Constants.leftRightInset + layoutIfNeeded() + } + + @IBAction func closeButtonTapped(_ sender: Any) { + delegate?.closeButtonTapped() + } + + // MARK: - Style + + override func awakeFromNib() { + super.awakeFromNib() + configureBackgroundView() + configureBackButton() + configurePublishingToLabel() + configureBlogTitleLabel() + configureBlogImage() + configureSeparator() + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.delegate = nil + } + + private func configureBackgroundView() { + backgroundView = UIView() + backgroundView?.backgroundColor = .basicBackground + } + + private func configureBackButton() { + closeButtonView.isHidden = true + closeButton.setImage(.gridicon(.cross, size: Constants.backButtonSize), for: .normal) + closeButton.accessibilityLabel = Constants.close + closeButton.accessibilityHint = Constants.doubleTapToDismiss + + // Only show close button for accessibility purposes + toggleCloseButton(visible: UIAccessibility.isVoiceOverRunning) + } + + private func configurePublishingToLabel() { + publishingToLabel.text = publishingToLabel.text?.uppercased() + publishingToLabel.font = WPStyleGuide.TableViewHeaderDetailView.titleFont + publishingToLabel.textColor = WPStyleGuide.TableViewHeaderDetailView.titleColor + } + + private func configureBlogImage() { + blogImageView.layer.cornerRadius = Constants.imageRadius + blogImageView.clipsToBounds = true + } + + private func configureBlogTitleLabel() { + WPStyleGuide.applyPostTitleStyle(blogTitleLabel) + } + + private func configureSeparator() { + WPStyleGuide.applyBorderStyle(separator) + } + + private enum Constants { + static let backButtonSize = CGSize(width: 28, height: 28) + static let imageRadius: CGFloat = 4 + static let leftRightInset: CGFloat = 16 + static let title = NSLocalizedString("Publishing To", comment: "Label that describes in which blog the user is publishing to") + static let close = NSLocalizedString("Close", comment: "Voiceover accessibility label informing the user that this button dismiss the current view") + static let doubleTapToDismiss = NSLocalizedString("Double tap to dismiss", comment: "Voiceover accessibility hint informing the user they can double tap a modal alert to dismiss it") + } +} diff --git a/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PrepublishingHeaderView.xib b/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PrepublishingHeaderView.xib new file mode 100644 index 000000000000..a0b33b8abbd5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PrepublishingHeaderView.xib @@ -0,0 +1,140 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina5_9" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/> + <capability name="Named colors" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="iN0-l3-epB" customClass="PrepublishingHeaderView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="110"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="U3e-kO-v2p"> + <rect key="frame" x="16" y="16" width="382" height="78"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Lqd-3Z-R2I"> + <rect key="frame" x="0.0" y="0.0" width="48" height="78"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="nQB-ct-GJB"> + <rect key="frame" x="2" y="17" width="44" height="44"/> + <constraints> + <constraint firstAttribute="width" constant="44" id="Hzn-wu-Giy"/> + <constraint firstAttribute="height" constant="44" id="nMd-Hh-E65"/> + </constraints> + <connections> + <action selector="closeButtonTapped:" destination="iN0-l3-epB" eventType="touchUpInside" id="MTX-wi-cBI"/> + </connections> + </button> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="nQB-ct-GJB" firstAttribute="centerX" secondItem="Lqd-3Z-R2I" secondAttribute="centerX" id="DVJ-yA-JIy"/> + <constraint firstAttribute="width" constant="48" id="FFk-Fx-qiq"/> + <constraint firstItem="nQB-ct-GJB" firstAttribute="centerY" secondItem="Lqd-3Z-R2I" secondAttribute="centerY" id="rFk-IZ-GkX"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ojd-6X-LSJ"> + <rect key="frame" x="58" y="0.0" width="38" height="78"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="7Pf-bk-gek"> + <rect key="frame" x="0.0" y="20" width="38" height="38"/> + <constraints> + <constraint firstAttribute="width" constant="38" id="Lsz-mz-2BC"/> + <constraint firstAttribute="height" constant="38" id="fup-nP-Afc"/> + </constraints> + </imageView> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" constant="38" id="Bbw-ho-Dov"/> + <constraint firstItem="7Pf-bk-gek" firstAttribute="centerY" secondItem="ojd-6X-LSJ" secondAttribute="centerY" id="JU5-yJ-j1c"/> + <constraint firstItem="7Pf-bk-gek" firstAttribute="centerX" secondItem="ojd-6X-LSJ" secondAttribute="centerX" id="cpO-u7-1VR"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="zDg-a5-aiB"> + <rect key="frame" x="106" y="0.0" width="276" height="78"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="fillEqually" alignment="top" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="3YM-UU-GGo"> + <rect key="frame" x="0.0" y="19.666666666666664" width="276" height="39"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Publishing To" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ZFo-FV-nl8"> + <rect key="frame" x="0.0" y="0.0" width="81" height="17"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleCallout"/> + <color key="textColor" name="Gray30"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="bottom" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Blog Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="fYs-yz-ucu"> + <rect key="frame" x="0.0" y="22" width="61" height="17"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <color key="textColor" name="Gray80"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + </subviews> + <constraints> + <constraint firstAttribute="trailing" secondItem="3YM-UU-GGo" secondAttribute="trailing" id="08r-Oh-snD"/> + <constraint firstItem="3YM-UU-GGo" firstAttribute="top" relation="greaterThanOrEqual" secondItem="zDg-a5-aiB" secondAttribute="top" id="Ser-tB-aOA"/> + <constraint firstItem="3YM-UU-GGo" firstAttribute="leading" secondItem="zDg-a5-aiB" secondAttribute="leading" id="oJg-wJ-uCW"/> + <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="3YM-UU-GGo" secondAttribute="bottom" id="rz0-ZC-C4g"/> + <constraint firstItem="3YM-UU-GGo" firstAttribute="centerY" secondItem="zDg-a5-aiB" secondAttribute="centerY" id="tpf-SY-Ods"/> + </constraints> + </view> + </subviews> + <constraints> + <constraint firstAttribute="bottom" secondItem="zDg-a5-aiB" secondAttribute="bottom" id="FNJ-sP-0eT"/> + <constraint firstItem="zDg-a5-aiB" firstAttribute="top" secondItem="U3e-kO-v2p" secondAttribute="top" id="gm5-rC-c5v"/> + <constraint firstAttribute="trailing" secondItem="zDg-a5-aiB" secondAttribute="trailing" id="lcJ-qG-Eo6"/> + </constraints> + </stackView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="FfF-E5-Fym"> + <rect key="frame" x="0.0" y="109" width="414" height="1"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="height" constant="1" id="PaW-Rp-6In"/> + </constraints> + </view> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <color key="tintColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="top" secondItem="U3e-kO-v2p" secondAttribute="top" constant="-16" id="6Jf-9O-ejp"/> + <constraint firstAttribute="bottom" secondItem="U3e-kO-v2p" secondAttribute="bottom" constant="16" id="IsF-tS-X0B"/> + <constraint firstAttribute="trailing" secondItem="U3e-kO-v2p" secondAttribute="trailing" constant="16" id="LSI-AS-5M2"/> + <constraint firstAttribute="bottom" secondItem="FfF-E5-Fym" secondAttribute="bottom" id="QyB-Vo-bfq"/> + <constraint firstItem="U3e-kO-v2p" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="tyj-l1-rNT"/> + <constraint firstAttribute="trailing" secondItem="FfF-E5-Fym" secondAttribute="trailing" id="ufv-zd-CIh"/> + <constraint firstItem="FfF-E5-Fym" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="x0z-h5-7dj"/> + </constraints> + <nil key="simulatedTopBarMetrics"/> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="blogImageView" destination="7Pf-bk-gek" id="eLY-zs-ARs"/> + <outlet property="blogTitleLabel" destination="fYs-yz-ucu" id="sA7-6P-XAg"/> + <outlet property="closeButton" destination="nQB-ct-GJB" id="iVs-bk-h0W"/> + <outlet property="closeButtonView" destination="Lqd-3Z-R2I" id="SxC-Uc-zrp"/> + <outlet property="leadingConstraint" destination="tyj-l1-rNT" id="Qlz-fc-hJn"/> + <outlet property="publishingToLabel" destination="ZFo-FV-nl8" id="bSu-i4-xEU"/> + <outlet property="separator" destination="FfF-E5-Fym" id="63L-2I-pWd"/> + </connections> + <point key="canvasLocation" x="137.68115942028987" y="-229.6875"/> + </view> + </objects> + <resources> + <namedColor name="Gray30"> + <color red="0.5490196078431373" green="0.5607843137254902" blue="0.58039215686274515" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </namedColor> + <namedColor name="Gray80"> + <color red="0.17254901960784313" green="0.20000000000000001" blue="0.2196078431372549" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </namedColor> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PrepublishingNavigationController.swift b/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PrepublishingNavigationController.swift new file mode 100644 index 000000000000..1eed1b0d73bc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Prepublishing Nudge/PrepublishingNavigationController.swift @@ -0,0 +1,97 @@ +import UIKit + +protocol PrepublishingDismissible { + func handleDismiss() +} + +class PrepublishingNavigationController: LightNavigationController { + + private let shouldDisplayPortrait: Bool + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + shouldDisplayPortrait ? .portrait : .all + } + + // We are using intrinsicHeight as the view's collapsedHeight which is calculated from the preferredContentSize. + override public var preferredContentSize: CGSize { + set { + viewControllers.last?.preferredContentSize = newValue + super.preferredContentSize = newValue + } + get { + guard let visibleViewController = viewControllers.last else { + return .zero + } + + return visibleViewController.preferredContentSize + } + } + + override func pushViewController(_ viewController: UIViewController, animated: Bool) { + super.pushViewController(viewController, animated: animated) + + transition() + } + + override func popViewController(animated: Bool) -> UIViewController? { + let viewController = super.popViewController(animated: animated) + + transition() + + return viewController + } + + init(rootViewController: UIViewController, shouldDisplayPortrait: Bool) { + self.shouldDisplayPortrait = shouldDisplayPortrait + super.init(rootViewController: rootViewController) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func transition() { + if let bottomSheet = self.parent as? BottomSheetViewController, let presentedVC = bottomSheet.presentedVC { + presentedVC.transition(to: .collapsed) + } + } + + private enum Constants { + static let iPadPreferredContentSize = CGSize(width: 300.0, height: 300.0) + } +} + + +// MARK: - DrawerPresentable + +extension PrepublishingNavigationController: DrawerPresentable { + var allowsUserTransition: Bool { + guard let visibleDrawer = visibleViewController as? DrawerPresentable else { + return true + } + + return visibleDrawer.allowsUserTransition + } + + var expandedHeight: DrawerHeight { + return .topMargin(20) + } + + var collapsedHeight: DrawerHeight { + guard let visibleDrawer = visibleViewController as? DrawerPresentable else { + return .contentHeight(300) + } + + return visibleDrawer.collapsedHeight + } + + var scrollableView: UIScrollView? { + return topViewController?.view as? UIScrollView + } + + func handleDismiss() { + if let rootViewController = viewControllers.first as? PrepublishingDismissible { + rootViewController.handleDismiss() + } + } +} diff --git a/WordPress/Classes/ViewRelated/Post/PrepublishingViewController.swift b/WordPress/Classes/ViewRelated/Post/PrepublishingViewController.swift new file mode 100644 index 000000000000..60230af09e7a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/PrepublishingViewController.swift @@ -0,0 +1,528 @@ +import UIKit +import WordPressAuthenticator +import Combine + +private struct PrepublishingOption { + let id: PrepublishingIdentifier + let title: String + let type: PrepublishingCellType +} + +private enum PrepublishingCellType { + case value + case textField + + var cellType: UITableViewCell.Type { + switch self { + case .value: + return WPTableViewCell.self + case .textField: + return WPTextFieldTableViewCell.self + } + } +} + +enum PrepublishingIdentifier { + case title + case schedule + case visibility + case tags + case categories +} + +class PrepublishingViewController: UITableViewController { + let post: Post + + private lazy var publishSettingsViewModel: PublishSettingsViewModel = { + return PublishSettingsViewModel(post: post) + }() + + private lazy var presentedVC: DrawerPresentationController? = { + return (navigationController as? PrepublishingNavigationController)?.presentedVC + }() + + enum CompletionResult { + case completed(AbstractPost) + case dismissed + } + + private let completion: (CompletionResult) -> () + + private let options: [PrepublishingOption] + + private var didTapPublish = false + + let publishButton: NUXButton = { + let nuxButton = NUXButton() + nuxButton.isPrimary = true + + return nuxButton + }() + + private weak var titleField: UITextField? + + /// Determines whether the text has been first responder already. If it has, don't force it back on the user unless it's been selected by them. + private var hasSelectedText: Bool = false + + init(post: Post, identifiers: [PrepublishingIdentifier], completion: @escaping (CompletionResult) -> ()) { + self.post = post + self.options = identifiers.map { identifier in + return PrepublishingOption(identifier: identifier) + } + self.completion = completion + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var cancellables = Set<AnyCancellable>() + @Published private var keyboardShown: Bool = false + + override func viewDidLoad() { + super.viewDidLoad() + + title = "" + + let nib = UINib(nibName: "PrepublishingHeaderView", bundle: nil) + tableView.register(nib, forHeaderFooterViewReuseIdentifier: Constants.headerReuseIdentifier) + + setupPublishButton() + setupFooterSeparator() + + updatePublishButtonLabel() + announcePublishButton() + + configureKeyboardToggle() + } + + /// Toggles `keyboardShown` as the keyboard notifications come in + private func configureKeyboardToggle() { + NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification) + .map { _ in return true } + .assign(to: \.keyboardShown, on: self) + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UIResponder.keyboardDidHideNotification) + .map { _ in return false } + .assign(to: \.keyboardShown, on: self) + .store(in: &cancellables) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + preferredContentSize = tableView.contentSize + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + + // Setting titleField first resonder alongside our transition to avoid layout issues. + transitionCoordinator?.animateAlongsideTransition(in: nil, animation: { [weak self] _ in + if self?.hasSelectedText == false { + self?.titleField?.becomeFirstResponder() + self?.hasSelectedText = true + } + }) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + let isPresentingAViewController = navigationController?.viewControllers.count ?? 0 > 1 + if isPresentingAViewController { + navigationController?.setNavigationBarHidden(false, animated: animated) + } + } + + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + // Forced unwrap copied from this guide by Apple: + // https://developer.apple.com/documentation/uikit/views_and_controls/table_views/adding_headers_and_footers_to_table_sections + // + let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: Constants.headerReuseIdentifier) as! PrepublishingHeaderView + + header.delegate = self + header.configure(post.blog) + + return header + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return UITableView.automaticDimension + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return options.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + let option = options[indexPath.row] + + let cell = dequeueCell(for: option.type, indexPath: indexPath) + + cell.preservesSuperviewLayoutMargins = false + cell.separatorInset = .zero + cell.layoutMargins = Constants.cellMargins + + switch option.type { + case .textField: + if let cell = cell as? WPTextFieldTableViewCell { + setupTextFieldCell(cell) + } + case .value: + cell.accessoryType = .disclosureIndicator + cell.textLabel?.text = option.title + } + + switch option.id { + case .title: + if let cell = cell as? WPTextFieldTableViewCell { + configureTitleCell(cell) + } + case .tags: + configureTagCell(cell) + case .visibility: + configureVisibilityCell(cell) + case .schedule: + configureScheduleCell(cell) + case .categories: + configureCategoriesCell(cell) + } + + return cell + } + + private func dequeueCell(for type: PrepublishingCellType, indexPath: IndexPath) -> WPTableViewCell { + switch type { + case .textField: + guard let cell = tableView.dequeueReusableCell(withIdentifier: Constants.textFieldReuseIdentifier) as? WPTextFieldTableViewCell else { + return WPTextFieldTableViewCell.init(style: .default, reuseIdentifier: Constants.textFieldReuseIdentifier) + } + return cell + case .value: + guard let cell = tableView.dequeueReusableCell(withIdentifier: Constants.reuseIdentifier) as? WPTableViewCell else { + return WPTableViewCell.init(style: .value1, reuseIdentifier: Constants.reuseIdentifier) + } + return cell + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch options[indexPath.row].id { + case .title: + break + case .tags: + didTapTagCell() + case .visibility: + didTapVisibilityCell() + case .schedule: + didTapSchedule(indexPath) + case .categories: + didTapCategoriesCell() + } + } + + private func reloadData() { + tableView.reloadData() + } + + private func setupTextFieldCell(_ cell: WPTextFieldTableViewCell) { + WPStyleGuide.configureTableViewTextCell(cell) + cell.delegate = self + } + + // MARK: - Title + + private func configureTitleCell(_ cell: WPTextFieldTableViewCell) { + cell.textField.text = post.postTitle + cell.textField.adjustsFontForContentSizeCategory = true + cell.textField.font = .preferredFont(forTextStyle: .body) + cell.textField.textColor = .text + cell.textField.placeholder = Constants.titlePlaceholder + cell.textField.heightAnchor.constraint(equalToConstant: 40).isActive = true + cell.textField.autocorrectionType = .yes + cell.textField.autocapitalizationType = .sentences + titleField = cell.textField + } + + // MARK: - Tags + + private func configureTagCell(_ cell: WPTableViewCell) { + cell.detailTextLabel?.text = post.tags + } + + private func didTapTagCell() { + let tagPickerViewController = PostTagPickerViewController(tags: post.tags ?? "", blog: post.blog) + + tagPickerViewController.onValueChanged = { [weak self] tags in + WPAnalytics.track(.editorPostTagsChanged, properties: Constants.analyticsDefaultProperty) + + self?.post.tags = tags + self?.reloadData() + } + + tagPickerViewController.onContentViewHeightDetermined = { [weak self] in + self?.presentedVC?.containerViewWillLayoutSubviews() + } + + navigationController?.pushViewController(tagPickerViewController, animated: true) + } + + private func configureCategoriesCell(_ cell: WPTableViewCell) { + cell.detailTextLabel?.text = post.categories?.array.map { $0.categoryName }.joined(separator: ",") + } + + private func didTapCategoriesCell() { + let categoriesViewController = PostCategoriesViewController(blog: post.blog, currentSelection: post.categories?.array, selectionMode: .post) + categoriesViewController.delegate = self + categoriesViewController.onCategoriesChanged = { [weak self] in + self?.presentedVC?.containerViewWillLayoutSubviews() + self?.tableView.reloadData() + } + + categoriesViewController.onTableViewHeightDetermined = { [weak self] in + self?.presentedVC?.containerViewWillLayoutSubviews() + } + + navigationController?.pushViewController(categoriesViewController, animated: true) + } + + // MARK: - Visibility + + private func configureVisibilityCell(_ cell: WPTableViewCell) { + cell.detailTextLabel?.text = post.titleForVisibility + } + + private func didTapVisibilityCell() { + let visbilitySelectorViewController = PostVisibilitySelectorViewController(post) + + visbilitySelectorViewController.completion = { [weak self] option in + self?.reloadData() + self?.updatePublishButtonLabel() + + WPAnalytics.track(.editorPostVisibilityChanged, properties: Constants.analyticsDefaultProperty) + + // If tue user selects password protected, prompt for a password + if option == AbstractPost.passwordProtectedLabel { + self?.showPasswordAlert() + } else { + self?.navigationController?.popViewController(animated: true) + } + } + + navigationController?.pushViewController(visbilitySelectorViewController, animated: true) + } + + // MARK: - Schedule + + func configureScheduleCell(_ cell: WPTableViewCell) { + cell.textLabel?.text = post.shouldPublishImmediately() ? Constants.publishDateLabel : Constants.scheduledLabel + cell.detailTextLabel?.text = publishSettingsViewModel.detailString + post.status == .publishPrivate ? cell.disable() : cell.enable() + } + + func didTapSchedule(_ indexPath: IndexPath) { + transitionIfVoiceOverDisabled(to: .hidden) + let viewController = PresentableSchedulingViewControllerProvider.viewController( + sourceView: tableView.cellForRow(at: indexPath)?.contentView, + sourceRect: nil, + viewModel: publishSettingsViewModel, + transitioningDelegate: nil, + updated: { [weak self] date in + WPAnalytics.track(.editorPostScheduledChanged, properties: Constants.analyticsDefaultProperty) + self?.publishSettingsViewModel.setDate(date) + self?.reloadData() + self?.updatePublishButtonLabel() + }, + onDismiss: { [weak self] in + self?.reloadData() + self?.transitionIfVoiceOverDisabled(to: .collapsed) + } + ) + present(viewController, animated: true) + } + + // MARK: - Publish Button + + private func setupPublishButton() { + let footer = UIView(frame: Constants.footerFrame) + footer.addSubview(publishButton) + footer.pinSubviewToSafeArea(publishButton, insets: Constants.nuxButtonInsets) + publishButton.translatesAutoresizingMaskIntoConstraints = false + tableView.tableFooterView = footer + publishButton.addTarget(self, action: #selector(publish(_:)), for: .touchUpInside) + updatePublishButtonLabel() + } + + private func setupFooterSeparator() { + guard let footer = tableView.tableFooterView else { + return + } + + let separator = UIView() + separator.translatesAutoresizingMaskIntoConstraints = false + footer.addSubview(separator) + NSLayoutConstraint.activate([ + separator.topAnchor.constraint(equalTo: footer.topAnchor), + separator.leftAnchor.constraint(equalTo: footer.leftAnchor), + separator.rightAnchor.constraint(equalTo: footer.rightAnchor), + separator.heightAnchor.constraint(equalToConstant: 1) + ]) + WPStyleGuide.applyBorderStyle(separator) + } + + private func updatePublishButtonLabel() { + publishButton.setTitle(post.isScheduled() ? Constants.scheduleNow : Constants.publishNow, for: .normal) + } + + @objc func publish(_ sender: UIButton) { + didTapPublish = true + navigationController?.dismiss(animated: true) { + WPAnalytics.track(.editorPostPublishNowTapped) + self.completion(.completed(self.post)) + } + } + + // MARK: - Password Prompt + + private func showPasswordAlert() { + let passwordAlertController = PasswordAlertController(onSubmit: { [weak self] password in + guard let password = password, !password.isEmpty else { + self?.cancelPasswordProtectedPost() + return + } + + self?.post.password = password + self?.navigationController?.popViewController(animated: true) + }, onCancel: { [weak self] in + self?.cancelPasswordProtectedPost() + }) + + passwordAlertController.show(from: self) + } + + private func cancelPasswordProtectedPost() { + post.status = .publish + post.password = nil + reloadData() + } + + // MARK: - Accessibility + + private func announcePublishButton() { + DispatchQueue.main.asyncAfter(deadline: .now()) { + UIAccessibility.post(notification: .screenChanged, argument: self.publishButton) + } + } + + /// Only perform a transition if Voice Over is disabled + /// This avoids some unresponsiveness + private func transitionIfVoiceOverDisabled(to position: DrawerPosition) { + guard !UIAccessibility.isVoiceOverRunning else { + return + } + + presentedVC?.transition(to: position) + } + + fileprivate enum Constants { + static let reuseIdentifier = "wpTableViewCell" + static let headerReuseIdentifier = "wpTableViewHeader" + static let textFieldReuseIdentifier = "wpTextFieldCell" + static let nuxButtonInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + static let cellMargins = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + static let footerFrame = CGRect(x: 0, y: 0, width: 100, height: 80) + static let publishNow = NSLocalizedString("Publish Now", comment: "Label for a button that publishes the post") + static let scheduleNow = NSLocalizedString("Schedule Now", comment: "Label for the button that schedules the post") + static let publishDateLabel = NSLocalizedString("Publish Date", comment: "Label for Publish date") + static let scheduledLabel = NSLocalizedString("Scheduled for", comment: "Scheduled for [date]") + static let titlePlaceholder = NSLocalizedString("Title", comment: "Placeholder for title") + static let analyticsDefaultProperty = ["via": "prepublishing_nudges"] + } +} + +extension PrepublishingViewController: PrepublishingHeaderViewDelegate { + func closeButtonTapped() { + dismiss(animated: true) + } +} + +extension PrepublishingViewController: PrepublishingDismissible { + func handleDismiss() { + defer { completion(.dismissed) } + guard + !didTapPublish, + post.status == .publishPrivate, + let originalStatus = post.original?.status else { + return + } + + post.status = originalStatus + } +} + +extension PrepublishingViewController: WPTextFieldTableViewCellDelegate { + func cellWants(toSelectNextField cell: WPTextFieldTableViewCell!) { + + } + + func cellTextDidChange(_ cell: WPTextFieldTableViewCell!) { + WPAnalytics.track(.editorPostTitleChanged, properties: Constants.analyticsDefaultProperty) + post.postTitle = cell.textField.text + } +} + +extension PrepublishingViewController: PostCategoriesViewControllerDelegate { + func postCategoriesViewController(_ controller: PostCategoriesViewController, didUpdateSelectedCategories categories: NSSet) { + WPAnalytics.track(.editorPostCategoryChanged, properties: ["via": "prepublishing_nudges"]) + + // Save changes. + guard let categories = categories as? Set<PostCategory> else { + return + } + post.categories = categories + post.save() + } +} + +extension Set { + var array: [Element] { + return Array(self) + } +} + + +// MARK: - DrawerPresentable +extension PrepublishingViewController: DrawerPresentable { + var allowsUserTransition: Bool { + return keyboardShown == false + } + + var collapsedHeight: DrawerHeight { + return .intrinsicHeight + } +} + +private extension PrepublishingOption { + init(identifier: PrepublishingIdentifier) { + switch identifier { + case .title: + self.init(id: .title, title: PrepublishingViewController.Constants.titlePlaceholder, type: .textField) + case .schedule: + self.init(id: .schedule, title: PrepublishingViewController.Constants.publishDateLabel, type: .value) + case .categories: + self.init(id: .categories, title: NSLocalizedString("Categories", comment: "Label for Categories"), type: .value) + case .visibility: + self.init(id: .visibility, title: NSLocalizedString("Visibility", comment: "Label for Visibility"), type: .value) + case .tags: + self.init(id: .tags, title: NSLocalizedString("Tags", comment: "Label for Tags"), type: .value) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Post/Preview/PreviewDeviceSelectionViewController.swift b/WordPress/Classes/ViewRelated/Post/Preview/PreviewDeviceSelectionViewController.swift index 92965a6b93b6..fd58d5526ea8 100644 --- a/WordPress/Classes/ViewRelated/Post/Preview/PreviewDeviceSelectionViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Preview/PreviewDeviceSelectionViewController.swift @@ -1,34 +1,60 @@ import Foundation class PreviewDeviceSelectionViewController: UIViewController { - enum PreviewDevice: CaseIterable { - case desktop - case `default` - case mobile + enum PreviewDevice: String, CaseIterable { + case desktop = "desktop" + case tablet = "tablet" + case mobile = "mobile" + + static var `default`: PreviewDevice { + return UIDevice.current.userInterfaceIdiom == .pad ? .tablet : .mobile + } var title: String { switch self { case .desktop: return NSLocalizedString("Desktop", comment: "Title for the desktop web preview") - case .`default`: - return NSLocalizedString("Default", comment: "Title for the default web preview") + case .tablet: + return NSLocalizedString("Tablet", comment: "Title for the tablet web preview") case .mobile: return NSLocalizedString("Mobile", comment: "Title for the mobile web preview") } } - static var available: [PreviewDevice] { - if UIDevice.current.userInterfaceIdiom == .pad { - return [.mobile, .default] - } else { - return [.desktop, .default] + var width: CGFloat { + switch self { + case .desktop: + return 1200 + case .tablet: + return 800 + case .mobile: + return 400 } } + + static var available: [PreviewDevice] { + return [.mobile, .tablet, .desktop] + } + + var viewportScript: String { + let js = """ + // remove all existing viewport meta tags - some themes included multiple, which is invalid + document.querySelectorAll("meta[name=viewport]").forEach( e => e.remove() ); + // create our new meta element + const viewportMeta = document.createElement("meta"); + viewportMeta.name = "viewport"; + viewportMeta.content = "width=%1$d"; + // insert the correct viewport meta tag + document.getElementsByTagName("head")[0].append(viewportMeta); + """ + + return String(format: js, NSInteger(width)) + } } - var selectedOption: PreviewDevice = .default + var selectedOption: PreviewDevice = PreviewDevice.default - var dismissHandler: ((PreviewDevice) -> Void)? + var onDeviceChange: ((PreviewDevice) -> Void)? lazy var tableView: UITableView = { let tableView = UITableView() @@ -44,11 +70,7 @@ class PreviewDeviceSelectionViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let blurEffect: UIBlurEffect - if #available(iOS 13.0, *) { - blurEffect = UIBlurEffect(style: .systemMaterial) - } else { - blurEffect = UIBlurEffect(style: .light) - } + blurEffect = UIBlurEffect(style: .systemMaterial) let effectView = UIVisualEffectView(effect: blurEffect) @@ -119,7 +141,10 @@ extension PreviewDeviceSelectionViewController: UITableViewDataSource { extension PreviewDeviceSelectionViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - dismissHandler?(PreviewDevice.available[indexPath.row]) + let newlySelectedDeviceMode = PreviewDevice.available[indexPath.row] + if newlySelectedDeviceMode != selectedOption { + onDeviceChange?(newlySelectedDeviceMode) + } dismiss(animated: true, completion: nil) } } diff --git a/WordPress/Classes/ViewRelated/Post/Preview/PreviewNonceHandler.swift b/WordPress/Classes/ViewRelated/Post/Preview/PreviewNonceHandler.swift index e52a7b816a34..96ad670856a5 100644 --- a/WordPress/Classes/ViewRelated/Post/Preview/PreviewNonceHandler.swift +++ b/WordPress/Classes/ViewRelated/Post/Preview/PreviewNonceHandler.swift @@ -1,25 +1,81 @@ struct PreviewNonceHandler { static func nonceURL(post: AbstractPost, previewURL: URL?) -> URL? { let permalink = post.permaLink.flatMap(URL.init(string:)) - guard let url = previewURL ?? permalink else { + guard var url = previewURL ?? permalink else { return nil } - if post.blog.supports(.noncePreviews), let nonce = post.blog.getOptionValue("frame_nonce") as? String { - return addNonce(nonce, to: url) - } else { - return url + if shouldComposePreviewURL(post: post) { + url = addPreviewIfNecessary(url: url) ?? url + url = unmapURL(post: post, url: url) ?? url + url = addNonceIfNecessary(post: post, url: url) ?? url } + + return url } - private static func addNonce(_ nonce: String, to url: URL) -> URL? { - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return nil + private static func addNonceIfNecessary(post: AbstractPost, url: URL) -> URL? { + guard + post.blog.supports(.noncePreviews), + let nonce = post.blog.getOptionValue("frame_nonce") as? String, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { + return url } + var queryItems = components.queryItems ?? [] - queryItems.append(URLQueryItem(name: "preview", value: "true")) queryItems.append(URLQueryItem(name: "frame-nonce", value: nonce)) components.queryItems = queryItems return components.url } + + private static func unmapURL(post: AbstractPost, url: URL) -> URL? { + guard + var components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let unmappedSite = post.blog.getOptionValue("unmapped_url") as? String, + let unmappedSiteURL = URL(string: unmappedSite) + else { + return url + } + + components.scheme = unmappedSiteURL.scheme + components.host = unmappedSiteURL.host + + return components.url + } + + private static func shouldComposePreviewURL(post: AbstractPost) -> Bool { + // If the post is not published, add the preview param. + if post.status!.rawValue != PostStatusPublish { + return true + } + + // If the post is published, but has changes, add the preview param. + if post.hasUnsavedChanges() || post.hasRemoteChanges() { + return true + } + + return false + } + + private static func addPreviewIfNecessary(url: URL) -> URL? { + guard + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { + return url + } + let queryItems = components.queryItems ?? [] + components.queryItems = addPreviewIfNecessary(items: queryItems) + return components.url + } + + private static func addPreviewIfNecessary(items: [URLQueryItem]) -> [URLQueryItem] { + var queryItems = items + // Only add the preview query param if it doesn't exist. + if !(queryItems.map { $0.name }).contains("preview") { + queryItems.append(URLQueryItem(name: "preview", value: "true")) + } + return queryItems + } + } diff --git a/WordPress/Classes/ViewRelated/Post/Preview/PreviewWebKitViewController.swift b/WordPress/Classes/ViewRelated/Post/Preview/PreviewWebKitViewController.swift index da42c93f2abf..9eb31a39f1f6 100644 --- a/WordPress/Classes/ViewRelated/Post/Preview/PreviewWebKitViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Preview/PreviewWebKitViewController.swift @@ -1,10 +1,11 @@ import Gridicons import WebKit +import WordPressShared /// An augmentation of WebKitViewController to provide Previewing for different devices class PreviewWebKitViewController: WebKitViewController { - let post: AbstractPost + let post: AbstractPost? private let canPublish: Bool @@ -13,13 +14,11 @@ class PreviewWebKitViewController: WebKitViewController { private var selectedDevice: PreviewDeviceSelectionViewController.PreviewDevice = .default { didSet { if selectedDevice != oldValue { - switch selectedDevice { - case .mobile: - setWidth(Constants.mobilePreviewWidth) - default: - setWidth(nil) - } - webView.reload() + UIView.animate(withDuration: 0.2, animations: { + self.webView.alpha = 0 + }, completion: { _ in + self.webView.reload() + }) } showLabel(device: selectedDevice) } @@ -53,7 +52,7 @@ class PreviewWebKitViewController: WebKitViewController { /// - Parameters: /// - post: The post to use for generating the preview URL and authenticating to the blog. **NOTE**: `previewURL` will be used as the URL instead, when available. /// - previewURL: The URL to display in the preview web view. - init(post: AbstractPost, previewURL: URL? = nil) { + init(post: AbstractPost, previewURL: URL? = nil, source: String) { self.post = post @@ -70,9 +69,24 @@ class PreviewWebKitViewController: WebKitViewController { let isPage = post is Page let configuration = WebViewControllerConfiguration(url: url) - configuration.linkBehavior = isPage ? .hostOnly(url) : .urlOnly(url) + + /// When the counterpart app is installed, revert to the default link behavior from `WebKitViewController` + /// to prevent links from being opened through the `externalLinkHandler`. + /// + /// This can be removed once the universal link routes for the WP app is removed. + if !MigrationAppDetection.isCounterpartAppInstalled { + configuration.linkBehavior = isPage ? .hostOnly(url) : .urlOnly(url) + } + configuration.opensNewInSafari = true configuration.authenticate(blog: post.blog) + configuration.analyticsSource = source + super.init(configuration: configuration) + } + + @objc override init(configuration: WebViewControllerConfiguration) { + post = nil + canPublish = false super.init(configuration: configuration) } @@ -81,6 +95,8 @@ class PreviewWebKitViewController: WebKitViewController { } func trackOpenEvent() { + guard let post = post else { return } + let eventProperties: [String: Any] = [ "post_type": post.analyticsPostType ?? "unsupported", "blog_type": post.blog.analyticsType.rawValue @@ -89,6 +105,8 @@ class PreviewWebKitViewController: WebKitViewController { } override func viewDidLoad() { + webView.alpha = 0 + super.viewDidLoad() if webView.url?.absoluteString == Constants.blankURL?.absoluteString { showNoResults(withTitle: Constants.noPreviewTitle) @@ -96,6 +114,11 @@ class PreviewWebKitViewController: WebKitViewController { setupDeviceLabel() } + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + setWidth(selectedDevice.width, viewWidth: size.width) + } + // MARK: Toolbar Items override func configureToolbarButtons() { @@ -120,8 +143,12 @@ class PreviewWebKitViewController: WebKitViewController { space, shareButton, space, - safariButton + safariButton, + space, + previewButton ] + case .withBaseURLOnly: + fallthrough case .hostOnly: if canPublish { items = [ @@ -168,7 +195,8 @@ class PreviewWebKitViewController: WebKitViewController { alertController.addCancelActionWithTitle(cancelTitle) alertController.addDefaultActionWithTitle(publishTitle) { [unowned self] _ in - PostCoordinator.shared.publish(self.post) + guard let post = self.post else { return } + PostCoordinator.shared.publish(post) if let editorVC = (self.presentingViewController?.presentingViewController as? EditPostViewController) { editorVC.closeEditor(true, showPostEpilogue: false, from: self) @@ -183,8 +211,14 @@ class PreviewWebKitViewController: WebKitViewController { @objc private func previewButtonPressed(_ sender: UIBarButtonItem) { let popoverContentController = PreviewDeviceSelectionViewController() popoverContentController.selectedOption = selectedDevice - popoverContentController.dismissHandler = { [weak self] option in + popoverContentController.onDeviceChange = { [weak self] option in self?.selectedDevice = option + + let properties: [AnyHashable: Any] = [ + "source": self?.analyticsSource ?? "unknown", + "option": option.rawValue + ] + WPAnalytics.track(.previewWebKitViewDeviceChanged, properties: properties) } popoverContentController.modalPresentationStyle = .popover @@ -228,17 +262,13 @@ class PreviewWebKitViewController: WebKitViewController { static let deviceLabelBackgroundColor = UIColor.text.withAlphaComponent(0.8) - static let devicePickerPopoverOffset: (CGFloat, CGFloat) = (x: -36, y: -2) - static let noPreviewTitle = NSLocalizedString("No Preview URL available", comment: "missing preview URL for blog post preview") static let publishButtonTitle = NSLocalizedString("Publish", comment: "Label for the publish (verb) button. Tapping publishes a draft post.") - static let publishButtonColor = UIColor.muriel(color: MurielColor.accent) + static let publishButtonColor = UIColor.primary static let blankURL = URL(string: "about:blank") - - static let mobilePreviewWidth: CGFloat = 460 } } @@ -247,15 +277,13 @@ class PreviewWebKitViewController: WebKitViewController { extension PreviewWebKitViewController { override func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) { - guard let navigationController = navigationController, popoverPresentationController.presentedViewController is PreviewDeviceSelectionViewController else { + guard popoverPresentationController.presentedViewController is PreviewDeviceSelectionViewController else { super.prepareForPopoverPresentation(popoverPresentationController) return } popoverPresentationController.permittedArrowDirections = .down - - popoverPresentationController.sourceRect = sourceRect(for: navigationController.toolbar, offsetBy: Constants.devicePickerPopoverOffset) - popoverPresentationController.sourceView = navigationController.toolbar.superview + popoverPresentationController.barButtonItem = previewButton } func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { @@ -265,28 +293,11 @@ extension PreviewWebKitViewController { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - // Reset our source rect and view for a transition to a new size - guard let navigationController = navigationController, - let popoverPresentationController = presentedViewController?.presentationController as? UIPopoverPresentationController, - popoverPresentationController.presentedViewController is PreviewDeviceSelectionViewController else { + guard let popoverPresentationController = presentedViewController?.presentationController as? UIPopoverPresentationController else { return } - popoverPresentationController.sourceRect = sourceRect(for: navigationController.toolbar, offsetBy: Constants.devicePickerPopoverOffset) - popoverPresentationController.sourceView = navigationController.toolbar.superview - } - - /// Returns a rect that represents the far right corner of `view` offset by `offsetBy`. - /// - Parameter view: The view to use for finding the upper right corner. - /// - Parameter offsetBy: An x, y pair to offset the view's coordinates by - func sourceRect(for view: UIView, offsetBy offset: (x: CGFloat, y: CGFloat)) -> CGRect { - return CGRect(origin: view.frame.topRightVertex, size: .zero).offsetBy(dx: offset.x, dy: offset.y) - } -} - -private extension CGRect { - var topRightVertex: CGPoint { - return CGPoint(x: maxX, y: minY) + prepareForPopoverPresentation(popoverPresentationController) } } @@ -294,9 +305,13 @@ private extension CGRect { extension PreviewWebKitViewController { func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { - if selectedDevice == .desktop { - // Change the viewport scale to match a desktop environment - webView.evaluateJavaScript("let parent = document.querySelector('meta[name=viewport]'); parent.setAttribute('content','initial-scale=0');", completionHandler: nil) + setWidth(selectedDevice.width) + webView.evaluateJavaScript(selectedDevice.viewportScript, completionHandler: nil) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + UIView.animate(withDuration: 0.2) { + self.webView.alpha = 1 } } } diff --git a/WordPress/Classes/ViewRelated/Post/PrivateSiteURLProtocol.h b/WordPress/Classes/ViewRelated/Post/PrivateSiteURLProtocol.h deleted file mode 100644 index 1a29eb914c94..000000000000 --- a/WordPress/Classes/ViewRelated/Post/PrivateSiteURLProtocol.h +++ /dev/null @@ -1,27 +0,0 @@ -#import <Foundation/Foundation.h> - -@interface PrivateSiteURLProtocol : NSURLProtocol - -/** - (Un)RegisterPrivateSiteURLProtocol are convenience methods for registering and - unregistering the protocol safely. - - For performance reasons we do not want to register the protocol for the - lifecycle of the app -- potentially `canInitWithRequest` would be called for every - http request. Register the protocol for use when its needed and unregister it when - its not. - - Use registerPrivateSiteURLProtocol and unregisterPrivateSiteURLProtocol to - keep track of the number of users of the protocol. The call to - `NSURLProtcol unregisterClass:` is only made when there are no longer any uses - remaining. This will help avoid edgecases where the protcol could be potentially - unregistered by one user immediately after another user had registered it. - */ -+ (void)registerPrivateSiteURLProtocol; -+ (void)unregisterPrivateSiteURLProtocol; - -+ (nonnull NSURLRequest *)requestForPrivateSiteFromURL:(nonnull NSURL *)url; - -+ (BOOL)urlGoesToWPComSite:(nonnull NSURL *)url; - -@end diff --git a/WordPress/Classes/ViewRelated/Post/PrivateSiteURLProtocol.m b/WordPress/Classes/ViewRelated/Post/PrivateSiteURLProtocol.m deleted file mode 100644 index 236a8e9322a9..000000000000 --- a/WordPress/Classes/ViewRelated/Post/PrivateSiteURLProtocol.m +++ /dev/null @@ -1,256 +0,0 @@ -#import "PrivateSiteURLProtocol.h" -#import "AccountService.h" -#import "ContextManager.h" -#import "WPAccount.h" -#import "Blog.h" - -@interface PrivateSiteURLProtocolSession: NSObject <NSURLSessionDelegate, NSURLSessionTaskDelegate> - -+ (instancetype) sharedInstance; -- (NSURLSessionTask *)createSessionTaskForRequest:(NSURLRequest *)request forProtocol:(NSURLProtocol *)protocol; -- (void)stopSessionTask:(NSURLSessionTask *)sessionTask; - -@end - -@interface PrivateSiteURLProtocol() - -@property (nonatomic, strong) NSURLSessionTask *sessionTask; - -@end - -static NSInteger regcount = 0; -static NSString const * mutex = @"PrivateSiteURLProtocol-Mutex"; -static NSString *cachedToken; - -@implementation PrivateSiteURLProtocol - -+ (void)registerPrivateSiteURLProtocol -{ - @synchronized(mutex) { - if (regcount == 0) { - if (![NSURLProtocol registerClass:[self class]]) { - NSAssert(YES, @"Unable to register protocol"); - DDLogInfo(@"Unable to register protocol"); - } - } - regcount++; - } -} - -+ (void)unregisterPrivateSiteURLProtocol -{ - @synchronized(mutex) { - cachedToken = nil; - if (regcount > 0) { - regcount--; - if (regcount == 0) { - [NSURLProtocol unregisterClass:[self class]]; - } - } else { - DDLogInfo(@"Detected unbalanced register/unregister private site protocol."); - } - } -} - -+ (BOOL)canInitWithRequest:(NSURLRequest *)request -{ - NSString *authHeader = [request.allHTTPHeaderFields stringForKey:@"Authorization"]; - if (authHeader && [authHeader rangeOfString:@"Bearer"].location != NSNotFound){ - return NO; - } - if (![self requestGoesToWPComSite:request]){ - return NO; - } - if (![self bearerToken]) { - return NO; - } - return YES; -} - -+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request -{ - return request; -} - -+ (NSString *)bearerToken -{ - if (cachedToken) { - return cachedToken; - } - // Thread Safety: Make sure we're running on the Main Thread - if ([NSThread isMainThread]) { - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - AccountService *service = [[AccountService alloc] initWithManagedObjectContext:context]; - return service.defaultWordPressComAccount.authToken; - } - - // Otherwise, let's use a Derived Context - __block NSString *authToken = nil; - NSManagedObjectContext *derived = [[ContextManager sharedInstance] newDerivedContext]; - AccountService *service = [[AccountService alloc] initWithManagedObjectContext:derived]; - - [derived performBlockAndWait:^{ - authToken = service.defaultWordPressComAccount.authToken; - }]; - cachedToken = authToken; - return cachedToken; -} - -+ (BOOL)requestGoesToWPComSite:(NSURLRequest *)request -{ - return [self urlGoesToWPComSite:request.URL]; -} - -+ (BOOL)urlGoesToWPComSite:(NSURL *)url -{ - if ([url.scheme isEqualToString:@"https"] && [url.host hasSuffix:@".wordpress.com"]) { - return YES; - } - - return NO; -} - -+ (NSURLRequest *)requestForPrivateSiteFromURL:(NSURL *)url -{ - if (![self urlGoesToWPComSite:url]) { - return [NSURLRequest requestWithURL:url]; - } - NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES]; - //make sure the scheme used is https - [urlComponents setScheme:@"https"]; - NSURL *httpsURL = [urlComponents URL]; - NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:httpsURL]; - NSString *bearerToken = [NSString stringWithFormat:@"Bearer %@", [self bearerToken]]; - [request addValue:bearerToken forHTTPHeaderField:@"Authorization"]; - return request; -} - -- (void)startLoading -{ - NSMutableURLRequest *mRequest = [self.request mutableCopy]; - [mRequest addValue:[NSString stringWithFormat:@"Bearer %@", [[self class] bearerToken]] forHTTPHeaderField:@"Authorization"]; - self.sessionTask = [[PrivateSiteURLProtocolSession sharedInstance] createSessionTaskForRequest:mRequest forProtocol:self]; - -} - -- (void)stopLoading -{ - [[PrivateSiteURLProtocolSession sharedInstance] stopSessionTask:self.sessionTask]; - self.sessionTask = nil; -} - -@end - -@interface PrivateSiteURLProtocolSession() - -@property (nonatomic, strong) NSMutableDictionary *taskToProtocolMapping; -@property (nonatomic, strong) NSURLSession *session; - -@end - -@implementation PrivateSiteURLProtocolSession - -+ (instancetype) sharedInstance -{ - static id _sharedInstance = nil; - static dispatch_once_t _onceToken; - dispatch_once(&_onceToken, ^{ - _sharedInstance = [[self alloc] init]; - }); - - return _sharedInstance; -} - -- (instancetype)init -{ - self = [super init]; - if (self) { - _taskToProtocolMapping = [NSMutableDictionary dictionary]; - } - return self; -} - -- (NSURLSession *)session -{ - if (_session == nil) { - NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration]; - _session = [NSURLSession sessionWithConfiguration:sessionConfiguration delegate:self delegateQueue:nil]; - } - return _session; -} - -- (NSURLSessionTask *)createSessionTaskForRequest:(NSURLRequest *)request forProtocol:(NSURLProtocol *)protocol -{ - NSURLSessionTask *sessionTask = [self.session dataTaskWithRequest:request]; - [sessionTask resume]; - - self.taskToProtocolMapping[sessionTask] = protocol; - return sessionTask; -} - -- (void)stopSessionTask:(NSURLSessionTask *)sessionTask -{ - if (sessionTask == nil) { - return; - } - [self.taskToProtocolMapping removeObjectForKey:sessionTask]; - - [sessionTask cancel]; -} - -- (void)URLSession:(NSURLSession *)session - dataTask:(NSURLSessionDataTask *)dataTask - didReceiveData:(NSData *)data -{ - NSURLProtocol *protocol = self.taskToProtocolMapping[dataTask]; - id<NSURLProtocolClient> client = protocol.client; - - [client URLProtocol:protocol didLoadData:data]; -} - -- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error -{ - NSURLProtocol *protocol = self.taskToProtocolMapping[task]; - id<NSURLProtocolClient> client = protocol.client; - - if (error) { - [client URLProtocol:protocol didFailWithError:error]; - } else { - [client URLProtocolDidFinishLoading:protocol]; - } - [self.taskToProtocolMapping removeObjectForKey:task]; -} - -- (void)URLSession:(NSURLSession *)session didBecomeInvalidWithError:(NSError *)error -{ - if (session == _session) { - _session = nil; - } -} - -- (void)URLSession:(NSURLSession *)session - dataTask:(NSURLSessionDataTask *)dataTask -didReceiveResponse:(NSURLResponse *)response - completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler -{ - NSURLProtocol *protocol = self.taskToProtocolMapping[dataTask]; - id<NSURLProtocolClient> client = protocol.client; - - [client URLProtocol:protocol didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed]; - completionHandler(NSURLSessionResponseAllow); -} - -- (void)URLSession:(NSURLSession *)session - task:(NSURLSessionTask *)task -willPerformHTTPRedirection:(NSHTTPURLResponse *)response - newRequest:(NSURLRequest *)request - completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler -{ - NSURLProtocol *protocol = self.taskToProtocolMapping[task]; - id<NSURLProtocolClient> client = protocol.client; - - [client URLProtocol:protocol wasRedirectedToRequest:request redirectResponse:response]; - completionHandler(nil); -} - -@end diff --git a/WordPress/Classes/ViewRelated/Post/RestorePostTableViewCell.swift b/WordPress/Classes/ViewRelated/Post/RestorePostTableViewCell.swift index 38d2055aacb6..81ecf62681cd 100644 --- a/WordPress/Classes/ViewRelated/Post/RestorePostTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Post/RestorePostTableViewCell.swift @@ -43,8 +43,8 @@ class RestorePostTableViewCell: UITableViewCell, ConfigurablePostView, Interacti restoreLabel.text = NSLocalizedString("Post moved to trash.", comment: "A short message explaining that a post was moved to the trash bin.") let buttonTitle = NSLocalizedString("Undo", comment: "The title of an 'undo' button. Tapping the button moves a trashed post out of the trash folder.") restoreButton.setTitle(buttonTitle, for: .normal) - restoreButton.setImage(Gridicon.iconOfType(.undo, withSize: CGSize(width: Constants.imageSize, - height: Constants.imageSize)), for: .normal) + restoreButton.setImage(.gridicon(.undo, size: CGSize(width: Constants.imageSize, + height: Constants.imageSize)), for: .normal) } private func configureCompact() { @@ -76,6 +76,10 @@ class RestorePostTableViewCell: UITableViewCell, ConfigurablePostView, Interacti self.delegate = delegate } + func setActionSheetDelegate(_ delegate: PostActionSheetDelegate) { + // Do nothing, since this cell doesn't have an action to present an action sheet. + } + private enum Constants { static let defaultMargin: CGFloat = 16 static let compactMargin: CGFloat = 0 diff --git a/WordPress/Classes/ViewRelated/Post/RestorePostTableViewCell.xib b/WordPress/Classes/ViewRelated/Post/RestorePostTableViewCell.xib index 7cae0c2cc5ee..90a2bf15f525 100644 --- a/WordPress/Classes/ViewRelated/Post/RestorePostTableViewCell.xib +++ b/WordPress/Classes/ViewRelated/Post/RestorePostTableViewCell.xib @@ -1,11 +1,9 @@ <?xml version="1.0" encoding="UTF-8"?> -<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> - <device id="retina6_1" orientation="portrait"> - <adaptation id="fullscreen"/> - </device> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> <dependencies> <deployment identifier="iOS"/> - <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14490.49"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> @@ -15,7 +13,7 @@ <rect key="frame" x="0.0" y="0.0" width="320" height="66"/> <autoresizingMask key="autoresizingMask"/> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" ambiguous="YES" tableViewCell="cm8-te-h9Y" id="n47-YY-a6J"> - <rect key="frame" x="0.0" y="0.0" width="320" height="65.5"/> + <rect key="frame" x="0.0" y="0.0" width="320" height="66"/> <autoresizingMask key="autoresizingMask"/> <subviews> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="XWn-76-OyW"> @@ -35,7 +33,7 @@ <fontDescription key="fontDescription" type="system" pointSize="15"/> <inset key="contentEdgeInsets" minX="3" minY="0.0" maxX="0.0" maxY="0.0"/> <inset key="imageEdgeInsets" minX="-3" minY="0.0" maxX="3" maxY="0.0"/> - <state key="normal" title="Undo" image="icon-post-undo"> + <state key="normal" title="Undo"> <color key="titleColor" red="0.3333333432674408" green="0.3333333432674408" blue="0.3333333432674408" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="titleShadowColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> </state> @@ -79,7 +77,4 @@ <point key="canvasLocation" x="568" y="225"/> </tableViewCell> </objects> - <resources> - <image name="icon-post-undo" width="18" height="18"/> - </resources> </document> diff --git a/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Diffs/RevisionDiffsPageManager.swift b/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Diffs/RevisionDiffsPageManager.swift index 2a2a81cd226a..e58f164692a1 100644 --- a/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Diffs/RevisionDiffsPageManager.swift +++ b/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Diffs/RevisionDiffsPageManager.swift @@ -1,7 +1,7 @@ import Foundation -protocol RevisionDiffsPageManagerDelegate: class { +protocol RevisionDiffsPageManagerDelegate: AnyObject { func currentIndex() -> Int func pageWillScroll(to direction: UIPageViewController.NavigationDirection) func pageDidFinishAnimating(completed: Bool) diff --git a/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Preview/RevisionPreviewTextViewManager.swift b/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Preview/RevisionPreviewTextViewManager.swift index 041113b144c9..9b53f309e66f 100644 --- a/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Preview/RevisionPreviewTextViewManager.swift +++ b/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Preview/RevisionPreviewTextViewManager.swift @@ -6,7 +6,7 @@ class RevisionPreviewTextViewManager: NSObject { var post: AbstractPost? private let mediaUtility = EditorMediaUtility() - private var activeMediaRequests = [ImageDownloader.Task]() + private var activeMediaRequests = [ImageDownloaderTask]() private enum Constants { static let mediaPlaceholderImageSize = CGSize(width: 128, height: 128) diff --git a/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Preview/RevisionPreviewViewController.swift b/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Preview/RevisionPreviewViewController.swift index 8b5dc4c438e8..fa19665386e0 100644 --- a/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Preview/RevisionPreviewViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Revisions/Browser/Preview/RevisionPreviewViewController.swift @@ -60,6 +60,7 @@ private extension RevisionPreviewViewController { private func setupAztec() { textView.load(WordPressPlugin()) textView.textAttachmentDelegate = textViewManager + textView.preBackgroundColor = .preformattedBackground let providers: [TextViewAttachmentImageProvider] = [ SpecialTagAttachmentRenderer(), diff --git a/WordPress/Classes/ViewRelated/Post/Revisions/Browser/RevisionDiffsBrowserViewController.swift b/WordPress/Classes/ViewRelated/Post/Revisions/Browser/RevisionDiffsBrowserViewController.swift index 909089644037..294a40e07a56 100644 --- a/WordPress/Classes/ViewRelated/Post/Revisions/Browser/RevisionDiffsBrowserViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Revisions/Browser/RevisionDiffsBrowserViewController.swift @@ -29,7 +29,7 @@ class RevisionDiffsBrowserViewController: UIViewController { }() private lazy var moreBarButtonItem: UIBarButtonItem = { - let image = Gridicon.iconOfType(.ellipsis) + let image = UIImage.gridicon(.ellipsis) let button = UIButton(type: .system) button.setImage(image, for: .normal) button.frame = CGRect(origin: .zero, size: image.size) @@ -121,20 +121,20 @@ private extension RevisionDiffsBrowserViewController { } let revision = revisionState.currentRevision() - revisionTitle?.text = revision.revisionDate.mediumString() + revisionTitle?.text = revision.revisionDate.toMediumString() operationVC?.revision = revision updateNextPreviousButtons() } private func setNextPreviousButtons() { - previousButton.setImage(Gridicon.iconOfType(.chevronLeft), for: .normal) + previousButton.setImage(.gridicon(.chevronLeft), for: .normal) previousButton.tintColor = .neutral(.shade70) previousButton.on(.touchUpInside) { [weak self] _ in self?.showPrevious() } - nextButton.setImage(Gridicon.iconOfType(.chevronRight), for: .normal) + nextButton.setImage(.gridicon(.chevronRight), for: .normal) nextButton.tintColor = .neutral(.shade70) nextButton.on(.touchUpInside) { [weak self] _ in self?.showNext() diff --git a/WordPress/Classes/ViewRelated/Post/Revisions/RevisionsTableViewController.swift b/WordPress/Classes/ViewRelated/Post/Revisions/RevisionsTableViewController.swift index 037e38b31db9..5fc234c82587 100644 --- a/WordPress/Classes/ViewRelated/Post/Revisions/RevisionsTableViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Revisions/RevisionsTableViewController.swift @@ -96,8 +96,11 @@ private extension RevisionsTableViewController { } private func getAuthor(for id: NSNumber?) -> BlogAuthor? { - let authors: [BlogAuthor]? = post?.blog.authors?.allObjects as? [BlogAuthor] - return authors?.first { $0.userID == id } + guard let authorId = id else { + return nil + } + + return post?.blog.getAuthorWith(id: authorId) } private func getRevisionState(at indexPath: IndexPath) -> RevisionBrowserState { @@ -193,12 +196,13 @@ extension RevisionsTableViewController: WPTableViewHandlerDelegate { } let revision = getRevision(at: indexPath) - let authors = getAuthor(for: revision.postAuthorId) + let author = getAuthor(for: revision.postAuthorId) + cell.title = revision.revisionDate.shortTimeString() - cell.subtitle = authors?.username ?? revision.revisionDate.mediumString() + cell.subtitle = author?.username ?? revision.revisionDate.toMediumString() cell.totalAdd = revision.diff?.totalAdditions.intValue cell.totalDel = revision.diff?.totalDeletions.intValue - cell.avatarURL = authors?.avatarURL + cell.avatarURL = author?.avatarURL } diff --git a/WordPress/Classes/ViewRelated/Post/Revisions/ShowRevisionsListManger.swift b/WordPress/Classes/ViewRelated/Post/Revisions/ShowRevisionsListManger.swift index 31e89fb78032..72176566a245 100644 --- a/WordPress/Classes/ViewRelated/Post/Revisions/ShowRevisionsListManger.swift +++ b/WordPress/Classes/ViewRelated/Post/Revisions/ShowRevisionsListManger.swift @@ -1,4 +1,4 @@ -protocol RevisionsView: class { +protocol RevisionsView: AnyObject { func stopLoading(success: Bool, error: Error?) } @@ -29,7 +29,7 @@ final class ShowRevisionsListManger { isLoading = true - postService.getPostRevisions(for: post, success: { [weak self] _ in + postService.getPostRevisions(for: post, success: { [weak self] in DispatchQueue.main.async { self?.isLoading = false self?.revisionsView?.stopLoading(success: true, error: nil) diff --git a/WordPress/Classes/ViewRelated/Post/Revisions/Views/Operation/RevisionOperation.swift b/WordPress/Classes/ViewRelated/Post/Revisions/Views/Operation/RevisionOperation.swift index 95f852dc90da..d6d40cda9e68 100644 --- a/WordPress/Classes/ViewRelated/Post/Revisions/Views/Operation/RevisionOperation.swift +++ b/WordPress/Classes/ViewRelated/Post/Revisions/Views/Operation/RevisionOperation.swift @@ -55,8 +55,8 @@ class RevisionOperationView: UIView { var icon: UIImage { switch self { - case .add: return Gridicon.iconOfType(.plusSmall) - case .del: return Gridicon.iconOfType(.minusSmall) + case .add: return .gridicon(.plusSmall) + case .del: return .gridicon(.minusSmall) } } } diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarCollectionView.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarCollectionView.swift index 2c5f27a8e5a2..5987e81ed8e6 100644 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarCollectionView.swift +++ b/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarCollectionView.swift @@ -1,17 +1,36 @@ import Foundation import JTAppleCalendar -class CalendarCollectionView: JTACMonthView { +enum CalendarCollectionViewStyle { + case month + case year +} + +class CalendarCollectionView: WPJTACMonthView { + + let calDataSource: CalendarDataSource + let style: CalendarCollectionViewStyle - let calDataSource = CalendarDataSource() + init(calendar: Calendar, + style: CalendarCollectionViewStyle = .month, + startDate: Date? = nil, + endDate: Date? = nil) { + calDataSource = CalendarDataSource( + calendar: calendar, + style: style, + startDate: startDate, + endDate: endDate + ) - override init() { + self.style = style super.init() setup() } required init?(coder aDecoder: NSCoder) { + calDataSource = CalendarDataSource(calendar: Calendar.current, style: .month) + style = .month super.init(coder: aDecoder) setup() @@ -19,29 +38,105 @@ class CalendarCollectionView: JTACMonthView { private func setup() { register(DateCell.self, forCellWithReuseIdentifier: DateCell.Constants.reuseIdentifier) + register(CalendarYearHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: CalendarYearHeaderView.reuseIdentifier) backgroundColor = .clear - scrollDirection = .horizontal - scrollingMode = .stopAtEachCalendarFrame + switch style { + case .month: + scrollDirection = .horizontal + scrollingMode = .stopAtEachCalendarFrame + case .year: + scrollDirection = .vertical + + allowsMultipleSelection = true + allowsRangedSelection = true + rangeSelectionMode = .continuous + + minimumLineSpacing = 0 + minimumInteritemSpacing = 0 + + cellSize = 50 + } + showsHorizontalScrollIndicator = false isDirectionalLockEnabled = true calendarDataSource = calDataSource calendarDelegate = calDataSource } + + /// VoiceOver scrollback workaround + /// When using VoiceOver, moving focus from the surrounding elements (usually the next month button) to the calendar DateCells, a + /// scrollback to 0 was triggered by the system. This appears to be expected (though irritating) behaviour with a paging UICollectionView. + /// The impact of this scrollback for the month view calendar (as used to schedule a post) is that the calendar jumps to 1951-01-01, with + /// the only way to navigate forwards being to tap the "next month" button repeatedly. + /// Ignoring these scrolls back to 0 when VoiceOver is in use prevents this issue, while not impacting other use of the calendar. + /// Similar behaviour sometimes occurs with the non-paging year view calendar (as used for activity log filtering) which is harder to reproduce, + /// but also remedied by this change. + override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) { + if shouldPreventAccessibilityFocusScrollback(for: contentOffset) { + return + } + super.setContentOffset(contentOffset, animated: animated) + } + + func shouldPreventAccessibilityFocusScrollback(for newContentOffset: CGPoint) -> Bool { + if UIAccessibility.isVoiceOverRunning { + switch style { + case .month: + return newContentOffset.x == 0 && contentOffset.x > 0 + case .year: + return newContentOffset.y == 0 && contentOffset.y > 0 + } + } + return false + } } class CalendarDataSource: JTACMonthViewDataSource { var willScroll: ((DateSegmentInfo) -> Void)? var didScroll: ((DateSegmentInfo) -> Void)? - var didSelect: ((Date) -> Void)? + var didSelect: ((Date?, Date?) -> Void)? + + // First selected date + var firstDate: Date? + + // End selected date + var endDate: Date? + + private let calendar: Calendar + private let style: CalendarCollectionViewStyle + + init(calendar: Calendar, + style: CalendarCollectionViewStyle, + startDate: Date? = nil, + endDate: Date? = nil) { + self.calendar = calendar + self.style = style + self.firstDate = startDate + self.endDate = endDate + } func configureCalendar(_ calendar: JTACMonthView) -> ConfigurationParameters { + /// When style is year, display the last 20 years til this month + if style == .year { + var dateComponent = DateComponents() + dateComponent.year = -20 + let startDate = Calendar.current.date(byAdding: dateComponent, to: Date()) + let endDate = Date().endOfMonth + + if let startDate = startDate, let endDate = endDate { + return ConfigurationParameters(startDate: startDate, endDate: endDate, calendar: self.calendar) + } + } + let startDate = Date.farPastDate let endDate = Date.farFutureDate - return ConfigurationParameters(startDate: startDate, endDate: endDate) + return ConfigurationParameters(startDate: startDate, endDate: endDate, calendar: self.calendar) } } @@ -67,34 +162,71 @@ extension CalendarDataSource: JTACMonthViewDelegate { } func calendar(_ calendar: JTACMonthView, didSelectDate date: Date, cell: JTACDayCell?, cellState: CellState, indexPath: IndexPath) { + if style == .year { + // If the date is in the future, bail out + if date > Date() { + return + } + + if let firstDate = firstDate { + if let endDate = endDate { + // When tapping a selected firstDate or endDate reset the rest + if date == firstDate || date == endDate { + self.firstDate = date + self.endDate = nil + // Increase the range at the left side + } else if date < firstDate { + self.firstDate = date + // Increase the range at the right side + } else { + self.endDate = date + } + // When tapping a single selected date, deselect everything + } else if date == firstDate { + self.firstDate = nil + self.endDate = nil + // When selecting a second date + } else { + self.firstDate = min(firstDate, date) + endDate = max(firstDate, date) + } + // When selecting the first date + } else { + firstDate = date + } + // Monthly calendar only selects a single date + } else { + firstDate = date + } + + didSelect?(firstDate, endDate) + UIView.performWithoutAnimation { + calendar.reloadItems(at: calendar.indexPathsForVisibleItems) + } + configure(cell: cell, with: cellState) - didSelect?(date) } func calendar(_ calendar: JTACMonthView, didDeselectDate date: Date, cell: JTACDayCell?, cellState: CellState, indexPath: IndexPath) { configure(cell: cell, with: cellState) } - private func configure(cell: JTACDayCell?, with state: CellState) { - let cell = cell as? DateCell - cell?.configure(with: state) + func calendarSizeForMonths(_ calendar: JTACMonthView?) -> MonthSize? { + return style == .year ? MonthSize(defaultSize: 50) : nil } - private func handleCellTextColor(cell: DateCell, cellState: CellState) { - - let textColor: UIColor - - if cellState.isSelected { - textColor = .textInverted - } else if cellState.dateBelongsTo == .thisMonth { - textColor = .text - } else { - textColor = .textSubtle - } - - cell.dateLabel.textColor = textColor + func calendar(_ calendar: JTACMonthView, headerViewForDateRange range: (start: Date, end: Date), at indexPath: IndexPath) -> JTACMonthReusableView { + let date = range.start + let formatter = DateFormatter() + formatter.dateFormat = "MMMM yyyy" + let header = calendar.dequeueReusableJTAppleSupplementaryView(withReuseIdentifier: CalendarYearHeaderView.reuseIdentifier, for: indexPath) + (header as! CalendarYearHeaderView).titleLabel.text = formatter.string(from: date) + return header + } - cell.dateLabel.backgroundColor = cellState.isSelected ? WPStyleGuide.wordPressBlue() : .clear + private func configure(cell: JTACDayCell?, with state: CellState) { + let cell = cell as? DateCell + cell?.configure(with: state, startDate: firstDate, endDate: endDate, hideInOutDates: style == .year) } } @@ -103,9 +235,16 @@ class DateCell: JTACDayCell { struct Constants { static let labelSize: CGFloat = 28 static let reuseIdentifier = "dateCell" + static var selectedColor: UIColor { + UIColor(light: .primary(.shade5), dark: .primary(.shade90)) + } } let dateLabel = UILabel() + let leftPlaceholder = UIView() + let rightPlaceholder = UIView() + + let dateFormatter = DateFormatter() override init(frame: CGRect) { super.init(frame: frame) @@ -134,26 +273,187 @@ class DateCell: JTACDayCell { dateLabel.centerYAnchor.constraint(equalTo: centerYAnchor), dateLabel.centerXAnchor.constraint(equalTo: centerXAnchor) ]) + + leftPlaceholder.translatesAutoresizingMaskIntoConstraints = false + rightPlaceholder.translatesAutoresizingMaskIntoConstraints = false + + addSubview(leftPlaceholder) + addSubview(rightPlaceholder) + + NSLayoutConstraint.activate([ + leftPlaceholder.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.6), + leftPlaceholder.heightAnchor.constraint(equalTo: dateLabel.heightAnchor), + leftPlaceholder.trailingAnchor.constraint(equalTo: centerXAnchor), + leftPlaceholder.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + + NSLayoutConstraint.activate([ + rightPlaceholder.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.5), + rightPlaceholder.heightAnchor.constraint(equalTo: dateLabel.heightAnchor), + rightPlaceholder.leadingAnchor.constraint(equalTo: centerXAnchor, constant: 0), + rightPlaceholder.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + + bringSubviewToFront(dateLabel) } } extension DateCell { - func configure(with state: CellState) { + /// Configure the DateCell + /// + /// - Parameters: + /// - state: the representation of the cell state + /// - startDate: the first Date selected + /// - endDate: the last Date selected + /// - hideInOutDates: a Bool to hide/display dates outside of the current month (filling the entire row) + /// - Returns: UIColor. Red in cases of error + func configure(with state: CellState, + startDate: Date? = nil, + endDate: Date? = nil, + hideInOutDates: Bool = false) { dateLabel.text = state.text - let textColor: UIColor + dateFormatter.setLocalizedDateFormatFromTemplate("EEE MMM d, yyyy") + dateLabel.accessibilityLabel = dateFormatter.string(from: state.date) + dateLabel.accessibilityTraits = .button - if state.isSelected { - textColor = .textInverted - } else if state.dateBelongsTo == .thisMonth { - textColor = .text + var textColor: UIColor + + if hideInOutDates && state.dateBelongsTo != .thisMonth { + isHidden = true } else { - textColor = .textSubtle + isHidden = false + } + + // Reset state + leftPlaceholder.backgroundColor = .clear + rightPlaceholder.backgroundColor = .clear + dateLabel.backgroundColor = .clear + textColor = .text + dateLabel.accessibilityTraits = .button + if state.isSelected { + dateLabel.accessibilityTraits.insert(.selected) + } + + switch position(for: state.date, startDate: startDate, endDate: endDate) { + case .middle: + textColor = .text + leftPlaceholder.backgroundColor = Constants.selectedColor + rightPlaceholder.backgroundColor = Constants.selectedColor + dateLabel.backgroundColor = .clear + case .left: + textColor = .white + dateLabel.backgroundColor = .primary + rightPlaceholder.backgroundColor = Constants.selectedColor + case .right: + textColor = .white + dateLabel.backgroundColor = .primary + leftPlaceholder.backgroundColor = Constants.selectedColor + case .full: + textColor = .textInverted + leftPlaceholder.backgroundColor = .clear + rightPlaceholder.backgroundColor = .clear + dateLabel.backgroundColor = .primary + case .none: + leftPlaceholder.backgroundColor = .clear + rightPlaceholder.backgroundColor = .clear + dateLabel.backgroundColor = .clear + if state.date > Date() { + textColor = .textSubtle + } else if state.dateBelongsTo == .thisMonth { + textColor = .text + } else { + textColor = .textSubtle + } } dateLabel.textColor = textColor + } + + func position(for date: Date, startDate: Date?, endDate: Date?) -> SelectionRangePosition { + if let startDate = startDate, let endDate = endDate { + if date == startDate { + return .left + } else if date == endDate { + return .right + } else if date > startDate && date < endDate { + return .middle + } + } else if let startDate = startDate { + if date == startDate { + return .full + } + } + + return .none + } +} + +// MARK: - Year Header View +class CalendarYearHeaderView: JTACMonthReusableView { + static let reuseIdentifier = "CalendarYearHeaderView" + + let titleLabel: UILabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = Constants.stackViewSpacing + + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + pinSubviewToSafeArea(stackView) - dateLabel.backgroundColor = state.isSelected ? WPStyleGuide.wordPressBlue() : .clear + stackView.addArrangedSubview(titleLabel) + titleLabel.font = .preferredFont(forTextStyle: .headline) + titleLabel.textAlignment = .center + titleLabel.textColor = Constants.titleColor + titleLabel.accessibilityTraits = .header + + let weekdaysView = WeekdaysHeaderView(calendar: Calendar.current) + stackView.addArrangedSubview(weekdaysView) + + stackView.setCustomSpacing(Constants.spacingAfterWeekdays, after: weekdaysView) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private enum Constants { + static let stackViewSpacing: CGFloat = 16 + static let spacingAfterWeekdays: CGFloat = 8 + static let titleColor = UIColor(light: .gray(.shade70), dark: .textSubtle) + } +} + +extension Date { + var startOfMonth: Date? { + return Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: Calendar.current.startOfDay(for: self))) + } + + var endOfMonth: Date? { + guard let startOfMonth = startOfMonth else { + return nil + } + + return Calendar.current.date(byAdding: DateComponents(month: 1, day: -1), to: startOfMonth) + } +} + +class WPJTACMonthView: JTACMonthView { + + // Avoids content to scroll above/below the maximum/minimum size + override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) { + let maxY = contentSize.height - frame.size.height + if contentOffset.y > maxY { + super.setContentOffset(CGPoint(x: contentOffset.x, y: maxY), animated: animated) + } else if contentOffset.y < 0 { + super.setContentOffset(CGPoint(x: contentOffset.x, y: 0), animated: animated) + } else { + super.setContentOffset(contentOffset, animated: animated) + } } } diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarMonthView.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarMonthView.swift index 4c07d7252e4d..54b7c4e87cc8 100644 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarMonthView.swift +++ b/WordPress/Classes/ViewRelated/Post/Scheduling/CalendarMonthView.swift @@ -22,10 +22,14 @@ class CalendarMonthView: UIView { } } - private let calendarCollectionView = CalendarCollectionView() + private let calendar: Calendar + private let calendarCollectionView: CalendarCollectionView + + init(calendar: Calendar) { + self.calendar = calendar + self.calendarCollectionView = CalendarCollectionView(calendar: calendar) + super.init(frame: .zero) - override init(frame: CGRect) { - super.init(frame: frame) setup() } @@ -34,8 +38,8 @@ class CalendarMonthView: UIView { } private func setup() { - let weekdaysHeaderView = WeekdaysHeaderView(calendar: Calendar.current) - let calendarHeaderView = CalendarHeaderView(next: (self, #selector(CalendarMonthView.nextMonth)), previous: (self, #selector(CalendarMonthView.previousMonth))) + let weekdaysHeaderView = WeekdaysHeaderView(calendar: calendar) + let calendarHeaderView = CalendarHeaderView(calendar: calendar, next: (self, #selector(CalendarMonthView.nextMonth)), previous: (self, #selector(CalendarMonthView.previousMonth))) let stackView = UIStackView(arrangedSubviews: [ calendarHeaderView, @@ -57,7 +61,11 @@ class CalendarMonthView: UIView { calendarHeaderView?.set(date: visibleDate) } } - calendarCollectionView.calDataSource.didSelect = { [weak self] dateSegment in + calendarCollectionView.calDataSource.didSelect = { [weak self] dateSegment, _ in + guard let dateSegment = dateSegment else { + return + } + self?.updated?(dateSegment) } } @@ -102,14 +110,14 @@ class CalendarMonthView: UIView { // MARK: Navigation button selectors @objc func previousMonth(_ sender: Any) { if let lastVisibleDate = calendarCollectionView.visibleDates().monthDates.first?.date, - let nextVisibleDate = Calendar.current.date(byAdding: .day, value: -1, to: lastVisibleDate, wrappingComponents: false) { + let nextVisibleDate = calendar.date(byAdding: .day, value: -1, to: lastVisibleDate, wrappingComponents: false) { calendarCollectionView.scrollToDate(nextVisibleDate) } } @objc func nextMonth(_ sender: Any) { if let lastVisibleDate = calendarCollectionView.visibleDates().monthDates.last?.date, - let nextVisibleDate = Calendar.current.date(byAdding: .day, value: 1, to: lastVisibleDate, wrappingComponents: false) { + let nextVisibleDate = calendar.date(byAdding: .day, value: 1, to: lastVisibleDate, wrappingComponents: false) { calendarCollectionView.scrollToDate(nextVisibleDate) } } @@ -118,43 +126,62 @@ class CalendarMonthView: UIView { /// A view containing two buttons to navigate forward and backward and a class CalendarHeaderView: UIStackView { + private enum Constants { + static let buttonSize = CGSize(width: 24, height: 24) + static let titeLabelColor: UIColor = .neutral(.shade60) + static let dateFormat = "MMMM, YYYY" + static let previousMonthButtonAccessibilityLabel = NSLocalizedString( + "Previous month", + comment: "Accessibility label for the button which shows the previous month in the monthly calendar view") + static let nextMonthButtonAccessibilityLabel = NSLocalizedString( + "Next month", + comment: "Accessibility label for the button which shows the previous month in the monthly calendar view") + } + typealias TargetSelector = (target: Any?, selector: Selector) /// A function to set the string of the title label to a given date /// - Parameter date: The date to set the `titleLabel`'s text to func set(date: Date) { - titleLabel.text = CalendarHeaderView.dateFormatter.string(from: date) + titleLabel.text = dateFormatter?.string(from: date) } - private let titleLabel: UILabel = { + let titleLabel: UILabel = { let label = UILabel() label.textAlignment = .center - label.textColor = .neutral(.shade60) + label.textColor = Constants.titeLabelColor return label }() - private static var dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "MMMM, YYYY" - return formatter - }() + private var dateFormatter: DateFormatter? = nil - convenience init(next: TargetSelector, previous: TargetSelector) { - let previousButton = UIButton(frame: CGRect(x: 0, y: 0, width: 24, height: 24)) + convenience init(calendar: Calendar, next: TargetSelector, previous: TargetSelector) { + let previousButton = UIButton(frame: CGRect(origin: .zero, size: Constants.buttonSize)) previousButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) - previousButton.setImage(Gridicon.iconOfType(.chevronLeft).imageFlippedForRightToLeftLayoutDirection(), for: .normal) + previousButton.setImage(UIImage.gridicon(.chevronLeft).imageFlippedForRightToLeftLayoutDirection(), for: .normal) + previousButton.accessibilityLabel = Constants.previousMonthButtonAccessibilityLabel + + let forwardButton = UIButton(frame: CGRect(origin: .zero, size: Constants.buttonSize)) + forwardButton.setImage(UIImage.gridicon(.chevronRight).imageFlippedForRightToLeftLayoutDirection(), for: .normal) + forwardButton.accessibilityLabel = Constants.nextMonthButtonAccessibilityLabel - let forwardButton = UIButton(frame: CGRect(x: 0, y: 0, width: 24, height: 24)) - forwardButton.setImage(Gridicon.iconOfType(.chevronRight).imageFlippedForRightToLeftLayoutDirection(), for: .normal) forwardButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) self.init() + addArrangedSubviews([ previousButton, titleLabel, forwardButton ]) + let formatter = DateFormatter() + formatter.dateFormat = Constants.dateFormat + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + + dateFormatter = formatter + alignment = .center previousButton.addTarget(previous.target, action: previous.selector, for: .touchUpInside) @@ -173,6 +200,7 @@ class WeekdaysHeaderView: UIStackView { label.textAlignment = .center label.font = UIFont.preferredFont(forTextStyle: .caption1) label.textColor = .neutral(.shade30) + label.isAccessibilityElement = false return label })) self.distribution = .fillEqually diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/HalfScreenPresentationController.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/HalfScreenPresentationController.swift deleted file mode 100644 index 4ada30d0af56..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/HalfScreenPresentationController.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation - -class HalfScreenPresentationController: FancyAlertPresentationController { - - private weak var tapGestureRecognizer: UITapGestureRecognizer? - - override var frameOfPresentedViewInContainerView: CGRect { - - /// If we are in compact mode, don't override the default - guard traitCollection.verticalSizeClass != .compact else { - return super.frameOfPresentedViewInContainerView - } - - let height = containerView?.bounds.height ?? 0 - let width = containerView?.bounds.width ?? 0 - - return CGRect(x: 0, y: height/2, width: width, height: height/2) - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - coordinator.animate(alongsideTransition: { _ in - self.presentedView?.frame = self.frameOfPresentedViewInContainerView - }, completion: nil) - super.viewWillTransition(to: size, with: coordinator) - } - - override func containerViewDidLayoutSubviews() { - super.containerViewDidLayoutSubviews() - - if tapGestureRecognizer == nil { - addGestureRecognizer() - } - } - - private func addGestureRecognizer() { - let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismiss)) - gestureRecognizer.cancelsTouchesInView = false - gestureRecognizer.delegate = self - containerView?.addGestureRecognizer(gestureRecognizer) - tapGestureRecognizer = gestureRecognizer - } - - /// This may need to be added to FancyAlertPresentationController - override var shouldPresentInFullscreen: Bool { - return false - } - - @objc func dismiss() { - presentedViewController.dismiss(animated: true, completion: nil) - } -} - -extension HalfScreenPresentationController: UIGestureRecognizerDelegate { - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - - /// Shouldn't happen; should always have container & presented view when tapped - guard let containerView = containerView, let presentedView = presentedView else { - return false - } - - let touchPoint = touch.location(in: containerView) - let isInPresentedView = presentedView.frame.contains(touchPoint) - - /// Do not accept the touch if inside of the presented view - return (gestureRecognizer == tapGestureRecognizer) && isInPresentedView == false - } -} diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/PartScreenPresentationController.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/PartScreenPresentationController.swift new file mode 100644 index 000000000000..1fa71d36c922 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Scheduling/PartScreenPresentationController.swift @@ -0,0 +1,77 @@ +import Foundation + +class PartScreenPresentationController: FancyAlertPresentationController { + + var minimumHeight: CGFloat { + return presentedViewController.preferredContentSize.height + } + + private weak var tapGestureRecognizer: UITapGestureRecognizer? + + override var frameOfPresentedViewInContainerView: CGRect { + /// If we are in compact mode, don't override the default + guard traitCollection.verticalSizeClass != .compact else { + return super.frameOfPresentedViewInContainerView + } + guard let containerView = containerView else { + return .zero + } + let height = max(containerView.bounds.height/2, minimumHeight) + let width = containerView.bounds.width + + return CGRect(x: 0, y: containerView.bounds.height - height, width: width, height: height) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + coordinator.animate(alongsideTransition: { _ in + self.presentedView?.frame = self.frameOfPresentedViewInContainerView + }, completion: nil) + super.viewWillTransition(to: size, with: coordinator) + } + + override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) { + super.preferredContentSizeDidChange(forChildContentContainer: container) + presentedViewController.view.frame = frameOfPresentedViewInContainerView + } + + override func containerViewDidLayoutSubviews() { + super.containerViewDidLayoutSubviews() + + if tapGestureRecognizer == nil { + addGestureRecognizer() + } + } + + private func addGestureRecognizer() { + let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismiss)) + gestureRecognizer.cancelsTouchesInView = false + gestureRecognizer.delegate = self + containerView?.addGestureRecognizer(gestureRecognizer) + tapGestureRecognizer = gestureRecognizer + } + + /// This may need to be added to FancyAlertPresentationController + override var shouldPresentInFullscreen: Bool { + return false + } + + @objc func dismiss() { + presentedViewController.dismiss(animated: true, completion: nil) + } +} + +extension PartScreenPresentationController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + + /// Shouldn't happen; should always have container & presented view when tapped + guard let containerView = containerView, let presentedView = presentedView else { + return false + } + + let touchPoint = touch.location(in: containerView) + let isInPresentedView = presentedView.frame.contains(touchPoint) + + /// Do not accept the touch if inside of the presented view + return (gestureRecognizer == tapGestureRecognizer) && isInPresentedView == false + } +} diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift index f7c46ffb30d2..66e7a9ca6d4a 100644 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift @@ -12,12 +12,28 @@ struct PublishSettingsViewModel { case scheduled(Date) case published(Date) case immediately + + init(post: AbstractPost) { + if let dateCreated = post.dateCreated, post.shouldPublishImmediately() == false { + self = post.hasFuturePublishDate() ? .scheduled(dateCreated) : .published(dateCreated) + } else { + self = .immediately + } + } } private(set) var state: State let timeZone: TimeZone let title: String? + var detailString: String { + if let date = date, !post.shouldPublishImmediately() { + return dateTimeFormatter.string(from: date) + } else { + return NSLocalizedString("Immediately", comment: "Undated post time label") + } + } + private let post: AbstractPost let dateFormatter: DateFormatter @@ -33,12 +49,10 @@ struct PublishSettingsViewModel { self.post = post title = post.postTitle + timeZone = post.blog.timeZone - dateFormatter = SiteDateFormatters.dateFormatter(for: post.blog, dateStyle: .long, timeStyle: .none, managedObjectContext: context) - dateTimeFormatter = SiteDateFormatters.dateFormatter(for: post.blog, dateStyle: .long, timeStyle: .short, managedObjectContext: context) - - let blogService = BlogService(managedObjectContext: context) - timeZone = blogService.timeZone(for: post.blog) + dateFormatter = SiteDateFormatters.dateFormatter(for: timeZone, dateStyle: .long, timeStyle: .none) + dateTimeFormatter = SiteDateFormatters.dateFormatter(for: timeZone, dateStyle: .medium, timeStyle: .short) } var cells: [PublishSettingsCell] { @@ -61,22 +75,24 @@ struct PublishSettingsViewModel { mutating func setDate(_ date: Date?) { if let date = date { - state = .scheduled(date) + // If a date to schedule the post was given post.dateCreated = date - } else { - state = .immediately - } - - /// Set the post's status to scheduled or published depending on our date value - switch state { - case .scheduled: - post.status = .scheduled - case .immediately: + if post.shouldPublishImmediately() { + post.status = .publish + } else { + post.status = .scheduled + } + } else if post.originalIsDraft() { + // If the original is a draft, keep the post as a draft + post.status = .draft + post.publishImmediately() + } else if post.hasFuturePublishDate() { + // If the original is a already scheduled post, change it to publish immediately + // In this case the user had scheduled, but now wants to publish right away post.publishImmediately() - case .published: - /// Don't need to do anything for published states (based on previous logic in PostSettingsViewController) - break } + + state = State(post: post) } } @@ -107,10 +123,13 @@ 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) - let viewController = ImmuTableViewController(controller: controller) + let viewController = ImmuTableViewController(controller: controller, style: .insetGrouped) controller.viewController = viewController return viewController } @@ -147,15 +166,9 @@ private struct DateAndTimeRow: ImmuTableRow { let rows: [ImmuTableRow] = viewModel.cells.map { cell in switch cell { case .dateTime: - let detailString: String - if let date = viewModel.date { - detailString = viewModel.dateTimeFormatter.string(from: date) - } else { - detailString = NSLocalizedString("Immediately", comment: "Undated post time label") - } return DateAndTimeRow( title: NSLocalizedString("Date and Time", comment: "Date and Time"), - detail: detailString, + detail: viewModel.detailString, accessibilityIdentifier: "Date and Time Row", action: presenter.present(dateTimeCalendarViewController(with: viewModel)) ) @@ -188,42 +201,33 @@ private struct DateAndTimeRow: ImmuTableRow { } func dateTimeCalendarViewController(with model: PublishSettingsViewModel) -> (ImmuTableRow) -> UIViewController { - return { [weak self] row in - - let schedulingCalendarViewController = SchedulingCalendarViewController() - schedulingCalendarViewController.coordinator = DateCoordinator(date: model.date, timeZone: model.timeZone, dateFormatter: model.dateFormatter, dateTimeFormatter: model.dateTimeFormatter) { [weak self] date in + return { [weak self] _ in + return PresentableSchedulingViewControllerProvider.viewController(sourceView: self?.viewController?.tableView, + sourceRect: self?.rectForSelectedRow() ?? .zero, + viewModel: model, + transitioningDelegate: self, + updated: { [weak self] date in + WPAnalytics.track(.editorPostScheduledChanged, properties: ["via": "settings"]) self?.viewModel.setDate(date) NotificationCenter.default.post(name: Foundation.Notification.Name(rawValue: ImmuTableViewController.modelChangedNotification), object: nil) - } - - return self?.calendarNavigationController(rootViewController: schedulingCalendarViewController) ?? UINavigationController() + }, + onDismiss: nil) } } - private func calendarNavigationController(rootViewController: UIViewController) -> UINavigationController { - let navigationController = LightNavigationController(rootViewController: rootViewController) - - if viewController?.traitCollection.userInterfaceIdiom == .pad { - navigationController.modalPresentationStyle = .popover - } else { - navigationController.modalPresentationStyle = .custom - navigationController.transitioningDelegate = self - } - - if let popoverController = navigationController.popoverPresentationController, - let selectedIndexPath = viewController?.tableView.indexPathForSelectedRow { - popoverController.sourceView = viewController?.tableView - popoverController.sourceRect = viewController?.tableView.rectForRow(at: selectedIndexPath) ?? .zero + private func rectForSelectedRow() -> CGRect? { + guard let viewController = viewController, + let selectedIndexPath = viewController.tableView.indexPathForSelectedRow else { + return nil } - - return navigationController + return viewController.tableView.rectForRow(at: selectedIndexPath) } } // The calendar sheet is shown towards the bottom half of the screen so a custom transitioning delegate is needed. extension PublishSettingsController: UIViewControllerTransitioningDelegate, UIAdaptivePresentationControllerDelegate { func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { - let presentationController = HalfScreenPresentationController(presentedViewController: presented, presenting: presenting) + let presentationController = PartScreenPresentationController(presentedViewController: presented, presenting: presenting) presentationController.delegate = self return presentationController } diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingCalendarViewController+PresentFrom.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingCalendarViewController+PresentFrom.swift new file mode 100644 index 000000000000..93762c5c5b52 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingCalendarViewController+PresentFrom.swift @@ -0,0 +1,37 @@ +import Foundation + +extension SchedulingCalendarViewController: SchedulingViewControllerPresenting { + static func present(from viewController: UIViewController, sourceView: UIView?, viewModel: PublishSettingsViewModel, updated: @escaping (Date?) -> Void, onDismiss: @escaping () -> Void) { + let schedulingCalendarViewController = SchedulingCalendarViewController() + schedulingCalendarViewController.coordinator = DateCoordinator(date: viewModel.date, timeZone: viewModel.timeZone, dateFormatter: viewModel.dateFormatter, dateTimeFormatter: viewModel.dateTimeFormatter, updated: updated) + let vc = SchedulingLightNavigationController(rootViewController: schedulingCalendarViewController) + vc.onDismiss = onDismiss + + if UIDevice.isPad() { + vc.modalPresentationStyle = .popover + } else { + vc.modalPresentationStyle = .custom + vc.transitioningDelegate = schedulingCalendarViewController + } + + if let popoverController = vc.popoverPresentationController, + let sourceView = sourceView { + popoverController.sourceView = sourceView + popoverController.sourceRect = sourceView.frame + } + + viewController.present(vc, animated: true) + } +} + +extension SchedulingCalendarViewController: UIViewControllerTransitioningDelegate, UIAdaptivePresentationControllerDelegate { + func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + let presentationController = HalfScreenPresentationController(presentedViewController: presented, presenting: presenting) + presentationController.delegate = self + return presentationController + } + + func adaptivePresentationStyle(for: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return traitCollection.verticalSizeClass == .compact ? .overFullScreen : .none + } +} diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingCalendarViewController.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingCalendarViewController.swift deleted file mode 100644 index 920267504a98..000000000000 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingCalendarViewController.swift +++ /dev/null @@ -1,251 +0,0 @@ -import Foundation -import Gridicons - -protocol DateCoordinatorHandler: class { - var coordinator: DateCoordinator? { get set } -} - -class DateCoordinator { - - var date: Date? - let timeZone: TimeZone - let dateFormatter: DateFormatter - let dateTimeFormatter: DateFormatter - let updated: (Date?) -> Void - - init(date: Date?, timeZone: TimeZone, dateFormatter: DateFormatter, dateTimeFormatter: DateFormatter, updated: @escaping (Date?) -> Void) { - self.date = date - self.timeZone = timeZone - self.dateFormatter = dateFormatter - self.dateTimeFormatter = dateTimeFormatter - self.updated = updated - } -} - -// MARK: - Date Picker - -class SchedulingCalendarViewController: UIViewController, DatePickerSheet, DateCoordinatorHandler { - - var coordinator: DateCoordinator? = nil - - let chosenValueRow = ChosenValueRow(frame: .zero) - - lazy var calendarMonthView: CalendarMonthView = { - let calendarMonthView = CalendarMonthView(frame: .zero) - calendarMonthView.translatesAutoresizingMaskIntoConstraints = false - - let selectedDate = coordinator?.date ?? Date() - calendarMonthView.selectedDate = selectedDate - calendarMonthView.updated = { [weak self] date in - var newDate = date - - // Since the date from the calendar will not include hours and minutes, replace with the original date (either the current, or previously entered date) - let selectedComponents = Calendar.current.dateComponents([.hour, .minute], from: selectedDate) - newDate = Calendar.current.date(bySettingHour: selectedComponents.hour ?? 0, minute: selectedComponents.minute ?? 0, second: 0, of: newDate) ?? newDate - - self?.coordinator?.date = newDate - self?.chosenValueRow.detailLabel.text = self?.coordinator?.dateFormatter.string(from: date) - } - - return calendarMonthView - }() - - private lazy var closeButton: UIBarButtonItem = { - let item = UIBarButtonItem(image: Gridicon.iconOfType(.cross), - style: .plain, - target: self, - action: #selector(closeButtonPressed)) - item.accessibilityLabel = NSLocalizedString("Close", comment: "Accessibility label for the date picker's close button.") - return item - }() - private lazy var publishButton = UIBarButtonItem(title: NSLocalizedString("Publish immediately", comment: "Immediately publish button title"), style: .plain, target: self, action: #selector(SchedulingCalendarViewController.publishImmediately)) - - override func viewDidLoad() { - super.viewDidLoad() - - chosenValueRow.titleLabel.text = NSLocalizedString("Choose a date", comment: "Label for Publish date picker") - - let nextButton = UIBarButtonItem(title: NSLocalizedString("Next", comment: "Next screen button title"), style: .plain, target: self, action: #selector(nextButtonPressed)) - navigationItem.setRightBarButton(nextButton, animated: false) - - setup(topView: chosenValueRow, pickerView: calendarMonthView) - - calendarMonthView.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) - calendarMonthView.setContentHuggingPriority(.defaultHigh, for: .horizontal) - calendarMonthView.setContentHuggingPriority(.defaultHigh, for: .vertical) - - setupForAccessibility() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - calculatePreferredSize() - } - - private func calculatePreferredSize() { - let targetSize = CGSize(width: view.bounds.width, - height: UIView.layoutFittingCompressedSize.height) - preferredContentSize = view.systemLayoutSizeFitting(targetSize) - } - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - (segue.destination as? DateCoordinatorHandler)?.coordinator = coordinator - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - resetNavigationButtons() - } - - @objc func closeButtonPressed() { - dismiss(animated: true, completion: nil) - } - - override func accessibilityPerformEscape() -> Bool { - dismiss(animated: true, completion: nil) - return true - } - - @objc func publishImmediately() { - coordinator?.updated(nil) - navigationController?.dismiss(animated: true, completion: nil) - } - - @objc func nextButtonPressed() { - let vc = TimePickerViewController() - vc.coordinator = coordinator - navigationController?.pushViewController(vc, animated: true) - } - - @objc private func resetNavigationButtons() { - let includeCloseButton = traitCollection.verticalSizeClass == .compact || - (isVoiceOverOrSwitchControlRunning && navigationController?.modalPresentationStyle != .popover) - - if includeCloseButton { - navigationItem.leftBarButtonItems = [closeButton, publishButton] - } else { - navigationItem.leftBarButtonItems = [publishButton] - } - } -} - -// MARK: Accessibility - -private extension SchedulingCalendarViewController { - func setupForAccessibility() { - let notificationNames = [ - UIAccessibility.voiceOverStatusDidChangeNotification, - UIAccessibility.switchControlStatusDidChangeNotification - ] - NotificationCenter.default.addObserver(self, - selector: #selector(resetNavigationButtons), - names: notificationNames, - object: nil) - } - - var isVoiceOverOrSwitchControlRunning: Bool { - UIAccessibility.isVoiceOverRunning || UIAccessibility.isSwitchControlRunning - } -} - -// MARK: - Time Picker - -class TimePickerViewController: UIViewController, DatePickerSheet, DateCoordinatorHandler { - - var coordinator: DateCoordinator? = nil - - let chosenValueRow = ChosenValueRow(frame: .zero) - - private lazy var datePicker: UIDatePicker = { - let datePicker = UIDatePicker() - datePicker.datePickerMode = .time - datePicker.timeZone = coordinator?.timeZone - datePicker.addTarget(self, action: #selector(timePickerChanged(_:)), for: .valueChanged) - if let date = coordinator?.date { - datePicker.date = date - } - return datePicker - }() - - override func viewDidLoad() { - super.viewDidLoad() - chosenValueRow.titleLabel.text = NSLocalizedString("Choose a time", comment: "Label for Publish time picker") - chosenValueRow.detailLabel.text = coordinator?.dateTimeFormatter.string(from: datePicker.date) - let doneButton = UIBarButtonItem(title: NSLocalizedString("Done", comment: "Label for Done button"), style: .done, target: self, action: #selector(done)) - - setup(topView: chosenValueRow, pickerView: datePicker) - - navigationItem.setRightBarButton(doneButton, animated: false) - } - - // MARK: Change Selectors - @objc func timePickerChanged(_ sender: Any) { - chosenValueRow.detailLabel.text = coordinator?.dateTimeFormatter.string(from: datePicker.date) - coordinator?.date = datePicker.date - } - - @objc func done() { - coordinator?.updated(coordinator?.date) - navigationController?.dismiss(animated: true, completion: nil) - } -} - -// MARK: DatePickerSheet Protocol -protocol DatePickerSheet { - func configureStackView(topView: UIView, pickerView: UIView) -> UIView -} - -extension DatePickerSheet { - - /// Constructs a view with `topView` on top and `pickerView` on bottom - /// - Parameter topView: A view to be shown above `pickerView` - /// - Parameter pickerView: A view to be shown on the bottom - func configureStackView(topView: UIView, pickerView: UIView) -> UIView { - pickerView.translatesAutoresizingMaskIntoConstraints = false - - let pickerWrapperView = UIView() - pickerWrapperView.addSubview(pickerView) - - let sideConstraints: [NSLayoutConstraint] = [ - pickerView.leftAnchor.constraint(equalTo: pickerWrapperView.leftAnchor), - pickerView.rightAnchor.constraint(equalTo: pickerWrapperView.rightAnchor) - ] - - // Allow these to break on larger screen sizes and just center the content - sideConstraints.forEach() { constraint in - constraint.priority = .defaultHigh - } - - NSLayoutConstraint.activate([ - pickerView.centerXAnchor.constraint(equalTo: pickerWrapperView.safeCenterXAnchor), - pickerView.topAnchor.constraint(equalTo: pickerWrapperView.topAnchor), - pickerView.bottomAnchor.constraint(equalTo: pickerWrapperView.bottomAnchor) - ]) - - NSLayoutConstraint.activate(sideConstraints) - - let stackView = UIStackView(arrangedSubviews: [ - topView, - pickerWrapperView - ]) - stackView.axis = .vertical - stackView.translatesAutoresizingMaskIntoConstraints = false - - return stackView - } -} - -extension DatePickerSheet where Self: UIViewController { - - /// Adds `topView` and `pickerView` to view hierarchy + standard styling for the view controller's view - /// - Parameter topView: A view to show above `pickerView` (see `ChosenValueRow`) - /// - Parameter pickerView: A view to show below the top view - func setup(topView: UIView, pickerView: UIView) { - WPStyleGuide.configureColors(view: view, tableView: nil) - - let stackView = configureStackView(topView: topView, pickerView: pickerView) - - view.addSubview(stackView) - view.pinSubviewToSafeArea(stackView) - } -} diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingDatePickerViewController.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingDatePickerViewController.swift new file mode 100644 index 000000000000..439148936c99 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingDatePickerViewController.swift @@ -0,0 +1,214 @@ +import Foundation +import Gridicons +import UIKit + +protocol DateCoordinatorHandler: AnyObject { + var coordinator: DateCoordinator? { get set } +} + +class DateCoordinator { + + var date: Date? + let timeZone: TimeZone + let dateFormatter: DateFormatter + let dateTimeFormatter: DateFormatter + let updated: (Date?) -> Void + + init(date: Date?, timeZone: TimeZone, dateFormatter: DateFormatter, dateTimeFormatter: DateFormatter, updated: @escaping (Date?) -> Void) { + self.date = date + self.timeZone = timeZone + self.dateFormatter = dateFormatter + self.dateTimeFormatter = dateTimeFormatter + self.updated = updated + } +} + +// MARK: - Date Picker + +class SchedulingDatePickerViewController: UIViewController, DatePickerSheet, DateCoordinatorHandler, UIViewControllerTransitioningDelegate, UIAdaptivePresentationControllerDelegate { + + var coordinator: DateCoordinator? = nil + + let chosenValueRow = ChosenValueRow(frame: .zero) + + lazy var datePickerView: UIDatePicker = { + let datePicker = UIDatePicker() + datePicker.preferredDatePickerStyle = .inline + datePicker.calendar = Calendar.current + if let timeZone = coordinator?.timeZone { + datePicker.timeZone = timeZone + } + datePicker.date = coordinator?.date ?? Date() + datePicker.translatesAutoresizingMaskIntoConstraints = false + datePicker.addTarget(self, action: #selector(datePickerValueChanged(sender:)), for: .valueChanged) + + return datePicker + }() + + @objc private func datePickerValueChanged(sender: UIDatePicker) { + let date = sender.date + coordinator?.date = date + chosenValueRow.detailLabel.text = coordinator?.dateFormatter.string(from: date) + } + + private lazy var closeButton: UIBarButtonItem = { + let item = UIBarButtonItem(image: .gridicon(.cross), + style: .plain, + target: self, + action: #selector(closeButtonPressed)) + item.accessibilityLabel = NSLocalizedString("Close", comment: "Accessibility label for the date picker's close button.") + return item + }() + + private lazy var publishButton = UIBarButtonItem(title: NSLocalizedString("Publish immediately", comment: "Immediately publish button title"), style: .plain, target: self, action: #selector(SchedulingDatePickerViewController.publishImmediately)) + + override func viewDidLoad() { + super.viewDidLoad() + + chosenValueRow.titleLabel.text = NSLocalizedString("Choose a date", comment: "Label for Publish date picker") + + let doneButton = UIBarButtonItem(title: NSLocalizedString("Done", comment: "Label for Done button"), style: .done, target: self, action: #selector(done)) + + navigationItem.setRightBarButton(doneButton, animated: false) + + setup(topView: chosenValueRow, pickerView: datePickerView) + view.tintColor = .editorPrimary + + setupForAccessibility() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + preferredContentSize = calculatePreferredSize() + } + + private func calculatePreferredSize() -> CGSize { + let targetSize = CGSize(width: view.bounds.width, + height: UIView.layoutFittingCompressedSize.height) + return view.systemLayoutSizeFitting(targetSize) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + (segue.destination as? DateCoordinatorHandler)?.coordinator = coordinator + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + resetNavigationButtons() + } + + @objc func closeButtonPressed() { + dismiss(animated: true, completion: nil) + } + + override func accessibilityPerformEscape() -> Bool { + dismiss(animated: true, completion: nil) + return true + } + + @objc func publishImmediately() { + coordinator?.updated(nil) + navigationController?.dismiss(animated: true, completion: nil) + } + + @objc func done() { + coordinator?.updated(coordinator?.date) + navigationController?.dismiss(animated: true, completion: nil) + } + + @objc private func resetNavigationButtons() { + let includeCloseButton = traitCollection.verticalSizeClass == .compact || + (isVoiceOverOrSwitchControlRunning && navigationController?.modalPresentationStyle != .popover) + + if includeCloseButton { + navigationItem.leftBarButtonItems = [closeButton, publishButton] + } else { + navigationItem.leftBarButtonItems = [publishButton] + } + } +} + +extension SchedulingDatePickerViewController { + @objc func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + let presentationController = PartScreenPresentationController(presentedViewController: presented, presenting: presenting) + presentationController.delegate = self + return presentationController + } + + @objc func adaptivePresentationStyle(for: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return traitCollection.verticalSizeClass == .compact ? .overFullScreen : .none + } +} + +// MARK: Accessibility + +private extension SchedulingDatePickerViewController { + func setupForAccessibility() { + let notificationNames = [ + UIAccessibility.voiceOverStatusDidChangeNotification, + UIAccessibility.switchControlStatusDidChangeNotification + ] + NotificationCenter.default.addObserver(self, + selector: #selector(resetNavigationButtons), + names: notificationNames, + object: nil) + } + + var isVoiceOverOrSwitchControlRunning: Bool { + UIAccessibility.isVoiceOverRunning || UIAccessibility.isSwitchControlRunning + } +} + +// MARK: DatePickerSheet Protocol +protocol DatePickerSheet { + func configureStackView(topView: UIView, pickerView: UIView) -> UIView +} + +extension DatePickerSheet { + /// Constructs a view with `topView` on top and `pickerView` on bottom + /// - Parameter topView: A view to be shown above `pickerView` + /// - Parameter pickerView: A view to be shown on the bottom + func configureStackView(topView: UIView, pickerView: UIView) -> UIView { + pickerView.translatesAutoresizingMaskIntoConstraints = false + + let pickerWrapperView = UIView() + pickerWrapperView.addSubview(pickerView) + + let sideConstraints: [NSLayoutConstraint] = [ + pickerView.leftAnchor.constraint(equalTo: pickerWrapperView.leftAnchor), + pickerView.rightAnchor.constraint(equalTo: pickerWrapperView.rightAnchor) + ] + + NSLayoutConstraint.activate([ + pickerView.centerXAnchor.constraint(equalTo: pickerWrapperView.safeCenterXAnchor), + pickerView.topAnchor.constraint(equalTo: pickerWrapperView.topAnchor), + pickerView.bottomAnchor.constraint(equalTo: pickerWrapperView.bottomAnchor) + ]) + + NSLayoutConstraint.activate(sideConstraints) + + let stackView = UIStackView(arrangedSubviews: [ + topView, + pickerWrapperView + ]) + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + + return stackView + } +} + +extension DatePickerSheet where Self: UIViewController { + /// Adds `topView` and `pickerView` to view hierarchy + standard styling for the view controller's view + /// - Parameter topView: A view to show above `pickerView` (see `ChosenValueRow`) + /// - Parameter pickerView: A view to show below the top view + func setup(topView: UIView, pickerView: UIView) { + WPStyleGuide.configureColors(view: view, tableView: nil) + + let stackView = configureStackView(topView: topView, pickerView: pickerView) + + view.addSubview(stackView) + + view.pinSubviewToSafeArea(stackView) + } +} diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingViewControllerPresenter.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingViewControllerPresenter.swift new file mode 100644 index 000000000000..2ac06408e9f4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Post/Scheduling/SchedulingViewControllerPresenter.swift @@ -0,0 +1,69 @@ +import Foundation +import UIKit + +protocol PresentableSchedulingViewControllerProviding { + static func viewController(sourceView: UIView?, + sourceRect: CGRect?, + viewModel: PublishSettingsViewModel, + transitioningDelegate: UIViewControllerTransitioningDelegate?, + updated: @escaping (Date?) -> Void, + onDismiss: (() -> Void)?) -> UINavigationController +} + +class PresentableSchedulingViewControllerProvider: PresentableSchedulingViewControllerProviding { + static func viewController(sourceView: UIView?, + sourceRect: CGRect?, + viewModel: PublishSettingsViewModel, + transitioningDelegate: UIViewControllerTransitioningDelegate?, + updated: @escaping (Date?) -> Void, + onDismiss: (() -> Void)?) -> UINavigationController { + let schedulingViewController = schedulingViewController(with: viewModel, updated: updated) + return wrappedSchedulingViewController(schedulingViewController, + sourceView: sourceView, + sourceRect: sourceRect, + transitioningDelegate: transitioningDelegate, + onDismiss: onDismiss) + } + + static func wrappedSchedulingViewController(_ schedulingViewController: SchedulingDatePickerViewController, + sourceView: UIView?, + sourceRect: CGRect?, + transitioningDelegate: UIViewControllerTransitioningDelegate?, + onDismiss: (() -> Void)?) -> SchedulingLightNavigationController { + let vc = SchedulingLightNavigationController(rootViewController: schedulingViewController) + vc.onDismiss = onDismiss + + if UIDevice.isPad() { + vc.modalPresentationStyle = .popover + } else { + vc.modalPresentationStyle = .custom + vc.transitioningDelegate = transitioningDelegate ?? schedulingViewController + } + + if let popoverController = vc.popoverPresentationController, + let sourceView = sourceView { + popoverController.sourceView = sourceView + popoverController.sourceRect = sourceRect ?? sourceView.frame + } + return vc + } + + static func schedulingViewController(with viewModel: PublishSettingsViewModel, updated: @escaping (Date?) -> Void) -> SchedulingDatePickerViewController { + let schedulingViewController = SchedulingDatePickerViewController() + schedulingViewController.coordinator = DateCoordinator(date: viewModel.date, + timeZone: viewModel.timeZone, + dateFormatter: viewModel.dateFormatter, + dateTimeFormatter: viewModel.dateTimeFormatter, + updated: updated) + return schedulingViewController + } +} + +class SchedulingLightNavigationController: LightNavigationController { + var onDismiss: (() -> Void)? + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + onDismiss?() + } +} diff --git a/WordPress/Classes/ViewRelated/Post/WPPickerView.m b/WordPress/Classes/ViewRelated/Post/WPPickerView.m index 6af1af9ac92c..4004f21012df 100644 --- a/WordPress/Classes/ViewRelated/Post/WPPickerView.m +++ b/WordPress/Classes/ViewRelated/Post/WPPickerView.m @@ -187,7 +187,6 @@ - (UIPickerView *)pickerView picker.autoresizingMask = UIViewAutoresizingFlexibleWidth; picker.dataSource = self; picker.delegate = self; - picker.showsSelectionIndicator = YES; self.pickerView = picker; [self setPickerStartingIndexes]; } diff --git a/WordPress/Classes/ViewRelated/Post/WPStyleGuide+Pages.m b/WordPress/Classes/ViewRelated/Post/WPStyleGuide+Pages.m index a19329fe9bcb..4ecf1c79270e 100644 --- a/WordPress/Classes/ViewRelated/Post/WPStyleGuide+Pages.m +++ b/WordPress/Classes/ViewRelated/Post/WPStyleGuide+Pages.m @@ -11,7 +11,7 @@ @implementation WPStyleGuide (Pages) + (void)applyRestorePageLabelStyle:(UILabel *)label { label.font = [WPStyleGuide regularFont]; - label.textColor = [self grey]; + label.textColor = [UIColor murielTextSubtle]; } + (void)applyRestorePageButtonStyle:(UIButton *)button @@ -19,14 +19,15 @@ + (void)applyRestorePageButtonStyle:(UIButton *)button [WPStyleGuide configureLabel:button.titleLabel textStyle:UIFontTextStyleCallout fontWeight:UIFontWeightSemibold]; - [button setTitleColor:[WPStyleGuide wordPressBlue] forState:UIControlStateNormal]; - [button setTitleColor:[WPStyleGuide darkBlue] forState:UIControlStateHighlighted]; + [button setTitleColor:[UIColor murielPrimary] forState:UIControlStateNormal]; + [button setTitleColor:[UIColor murielPrimaryDark] forState:UIControlStateHighlighted]; + button.tintColor = [UIColor murielPrimary]; } + (void)applyRestoreSavedPostLabelStyle:(UILabel *)label { [WPStyleGuide configureLabel:label textStyle:UIFontTextStyleCallout]; - label.textColor = [self greyDarken10]; + label.textColor = [UIColor murielTextSubtle]; } + (void)applyRestoreSavedPostTitleLabelStyle:(UILabel *)label @@ -39,7 +40,7 @@ + (void)applyRestoreSavedPostTitleLabelStyle:(UILabel *)label UIFontDescriptorSymbolicTraits traits = [descriptor symbolicTraits]; descriptor = [descriptor fontDescriptorWithSymbolicTraits:traits | UIFontDescriptorTraitItalic]; label.font = [UIFont fontWithDescriptor:descriptor size:label.font.pointSize]; - label.textColor = [self greyDarken10]; + label.textColor = [UIColor murielTextSubtle]; } + (void)applyRestoreSavedPostButtonStyle:(UIButton *)button @@ -47,8 +48,7 @@ + (void)applyRestoreSavedPostButtonStyle:(UIButton *)button [WPStyleGuide configureLabel:button.titleLabel textStyle:UIFontTextStyleCallout fontWeight:UIFontWeightSemibold]; - [button setTitleColor:[WPStyleGuide wordPressBlue] forState:UIControlStateNormal]; - [button setTitleColor:[WPStyleGuide darkBlue] forState:UIControlStateHighlighted]; + [button setTitleColor:[UIColor murielPrimary] forState:UIControlStateNormal]; } + (UIFont *)regularFont { diff --git a/WordPress/Classes/ViewRelated/Post/WPStyleGuide+Posts.swift b/WordPress/Classes/ViewRelated/Post/WPStyleGuide+Posts.swift index 671655d070b5..ec6b8d82c530 100644 --- a/WordPress/Classes/ViewRelated/Post/WPStyleGuide+Posts.swift +++ b/WordPress/Classes/ViewRelated/Post/WPStyleGuide+Posts.swift @@ -55,9 +55,9 @@ extension WPStyleGuide { class func applyRestorePostButtonStyle(_ button: UIButton) { configureLabelForRegularFontStyle(button.titleLabel) - button.setTitleColor(.accent, for: .normal) - button.setTitleColor(.accentDark, for: .highlighted) - button.tintColor = .accent + button.setTitleColor(.primary, for: .normal) + button.setTitleColor(.primaryDark, for: .highlighted) + button.tintColor = .primary } class func applyBorderStyle(_ view: UIView) { @@ -111,18 +111,19 @@ extension WPStyleGuide { static var navigationBarButtonRect = CGRect(x: 0, y: 0, width: 30, height: 30) - static var spacingBetweeenNavbarButtons: CGFloat = 40 + static var spacingBetweenNavbarButtons: CGFloat = 40 class func buttonForBar(with image: UIImage, target: Any?, selector: Selector) -> WPButtonForNavigationBar { let button = WPButtonForNavigationBar(frame: navigationBarButtonRect) - button.tintColor = .white + button.tintColor = .appBarTint + button.setImage(image, for: .normal) button.addTarget(target, action: selector, for: .touchUpInside) button.removeDefaultLeftSpacing = true button.removeDefaultRightSpacing = true - button.rightSpacing = spacingBetweeenNavbarButtons / 2 - button.leftSpacing = spacingBetweeenNavbarButtons / 2 + button.rightSpacing = spacingBetweenNavbarButtons / 2 + button.leftSpacing = spacingBetweenNavbarButtons / 2 return button } diff --git a/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginCoordinator.swift b/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginCoordinator.swift new file mode 100644 index 000000000000..a4736bf43701 --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginCoordinator.swift @@ -0,0 +1,117 @@ +import UIKit + +struct QRLoginCoordinator: QRLoginParentCoordinator { + enum QRLoginOrigin: String { + case menu + case deepLink = "deep_link" + } + + let navigationController: UINavigationController + let origin: QRLoginOrigin + + init(navigationController: UINavigationController = UINavigationController(), origin: QRLoginOrigin) { + self.navigationController = navigationController + self.origin = origin + + configureNavigationController() + } + + static func didHandle(url: URL) -> Bool { + guard + let token = QRLoginURLParser(urlString: url.absoluteString).parse(), + let source = UIApplication.shared.leafViewController + else { + return false + } + + self.init(origin: .deepLink).showVerifyAuthorization(token: token, from: source) + return true + } + + func showCameraScanningView(from source: UIViewController? = nil) { + pushOrPresent(scanningViewController(), from: source) + } + + func showVerifyAuthorization(token: QRLoginToken, from source: UIViewController? = nil) { + let controller = QRLoginVerifyAuthorizationViewController() + controller.coordinator = QRLoginVerifyCoordinator(token: token, + view: controller, + parentCoordinator: self) + + pushOrPresent(controller, from: source) + } +} + +// MARK: - QRLoginParentCoordinator Child Coordinator Interactions +extension QRLoginCoordinator { + func dismiss() { + navigationController.dismiss(animated: true) + } + + func didScanToken(_ token: QRLoginToken) { + showVerifyAuthorization(token: token) + } + + func scanAgain() { + QRLoginCameraPermissionsHandler().checkCameraPermissions(from: navigationController, origin: origin) { + self.navigationController.setViewControllers([self.scanningViewController()], animated: true) + } + } + + func track(_ event: WPAnalyticsEvent) { + self.track(event, properties: nil) + } + + func track(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any]? = nil) { + var props: [AnyHashable: Any] = ["origin": origin.rawValue] + + guard let properties = properties else { + WPAnalytics.track(event, properties: props) + return + } + + props.merge(properties) { (_, new) in new } + WPAnalytics.track(event, properties: props) + } +} + +// MARK: - Private +private extension QRLoginCoordinator { + func configureNavigationController() { + navigationController.isNavigationBarHidden = true + navigationController.modalPresentationStyle = .fullScreen + } + + func pushOrPresent(_ controller: UIViewController, from source: UIViewController?) { + guard source != nil else { + navigationController.pushViewController(controller, animated: true) + return + } + + navigationController.setViewControllers([controller], animated: false) + source?.present(navigationController, animated: true) + } + + private func scanningViewController() -> QRLoginScanningViewController { + let controller = QRLoginScanningViewController() + controller.coordinator = QRLoginScanningCoordinator(view: controller, parentCoordinator: self) + + return controller + } +} + +// MARK: - Presenting the QR Login Flow +extension QRLoginCoordinator { + /// Present the QR login flow starting with the scanning step + static func present(from source: UIViewController, origin: QRLoginOrigin) { + QRLoginCameraPermissionsHandler().checkCameraPermissions(from: source, origin: origin) { + QRLoginCoordinator(origin: origin).showCameraScanningView(from: source) + } + } + + /// Display QR validation flow with a specific code, skipping the scanning step + /// and going to the validation flow + static func present(token: QRLoginToken, from source: UIViewController, origin: QRLoginOrigin) { + QRLoginCoordinator(origin: origin).showVerifyAuthorization(token: token, from: source) + } +} diff --git a/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginScanningCoordinator.swift b/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginScanningCoordinator.swift new file mode 100644 index 000000000000..c1efd3c2ec44 --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginScanningCoordinator.swift @@ -0,0 +1,99 @@ +import Foundation + +class QRLoginScanningCoordinator: NSObject { + let parentCoordinator: QRLoginParentCoordinator + let view: QRLoginScanningView + var cameraSession: QRCodeScanningSession + + init(view: QRLoginScanningView, parentCoordinator: QRLoginParentCoordinator, cameraSession: QRCodeScanningSession = QRLoginCameraSession()) { + self.view = view + self.parentCoordinator = parentCoordinator + self.cameraSession = cameraSession + } + + func start() { + cameraSession.scanningDelegate = self + + parentCoordinator.track(.qrLoginScannerDisplayed) + cameraSession.configure() + + // Check if the camera is not accessible, and display an error if needed + guard cameraSession.hasCamera else { + showNoCameraError() + return + } + + configureCameraPreview() + } + + // MARK: - Strings + private enum Strings { + static let noCameraError = NSLocalizedString("This app needs permission to access the Camera to capture new media, please change the privacy settings if you wish to allow this.", comment: "An error message display if the users device does not have a camera input available") + } +} + +// MARK: - View Interactions +extension QRLoginScanningCoordinator { + func viewDidAppear() { + cameraSession.start() + } + + func viewWillDisappear() { + cameraSession.stop() + } + + func didTapDismiss() { + parentCoordinator.track(.qrLoginScannerDismissed) + parentCoordinator.dismiss() + } + + func didScanToken(_ token: QRLoginToken) { + parentCoordinator.track(.qrLoginScannerScannedCode) + + // Give the user a tap to let them know they've successfully scanned the code + UINotificationFeedbackGenerator().notificationOccurred(.success) + + // Stop the camera immediately to prevent further scanning + cameraSession.stop() + + // Show the next step in the flow + parentCoordinator.didScanToken(token) + } +} + +// MARK: - Private: Camera Related Code +private extension QRLoginScanningCoordinator { + func showNoCameraError() { + QRLoginCameraPermissionsHandler().showNeedAccessAlert(from: nil) + + view.showError(Strings.noCameraError) + } + + func configureCameraPreview() { + guard let previewLayer = cameraSession.previewLayer else { + showNoCameraError() + return + } + + view.showCameraLayer(previewLayer) + } +} + +// MARK: - AVCaptureMetadataOutputObjectsDelegate +extension QRLoginScanningCoordinator: QRCodeScanningDelegate { + func validLink(_ stringValue: String) -> Bool { + guard let url = URL(string: stringValue), QRLoginURLParser.isValidHost(url: url) else { + return false + } + + return true + } + + func didScanURLString(_ urlString: String) { + guard let token = QRLoginURLParser(urlString: urlString).parse() else { + return + } + + didScanToken(token) + } +} diff --git a/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginVerifyCoordinator.swift b/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginVerifyCoordinator.swift new file mode 100644 index 000000000000..2da6e785a8cb --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/Coordinators/QRLoginVerifyCoordinator.swift @@ -0,0 +1,114 @@ +import Foundation +import WordPressKit + +class QRLoginVerifyCoordinator { + private let parentCoordinator: QRLoginParentCoordinator + private let view: QRLoginVerifyView + private let token: QRLoginToken + private let service: QRLoginService + private let connectionChecker: QRLoginConnectionChecker + + var state: ViewState = .verifyingCode + + init(token: QRLoginToken, + view: QRLoginVerifyView, + parentCoordinator: QRLoginParentCoordinator, + connectionChecker: QRLoginConnectionChecker = QRLoginInternetConnectionChecker(), + service: QRLoginService? = nil, + coreDataStack: CoreDataStack = ContextManager.shared) { + self.token = token + self.view = view + self.connectionChecker = connectionChecker + self.parentCoordinator = parentCoordinator + self.service = service ?? QRLoginService(coreDataStack: coreDataStack) + } + + enum ViewState { + case verifyingCode + case waitingForUserVerification + case authenticating + case error + case done + } +} + +// MARK: - View Interactions +extension QRLoginVerifyCoordinator { + func start() { + parentCoordinator.track(.qrLoginVerifyCodeDisplayed) + state = .verifyingCode + + view.showLoading() + + service.validate(token: token) { response in + self.parentCoordinator.track(.qrLoginVerifyCodeTokenValidated) + self.state = .waitingForUserVerification + self.view.render(response: response) + } failure: { _, qrLoginError in + self.state = .error + + guard self.connectionChecker.connectionAvailable else { + self.parentCoordinator.track(.qrLoginVerifyCodeFailed, properties: ["error": "no_internet"]) + self.view.showNoConnectionError() + return + } + + let errorType: String + switch qrLoginError { + case .invalidData: + errorType = "invalid_data" + case .expired: + errorType = "expired_token" + case .none: + errorType = "unknown" + } + + self.parentCoordinator.track(.qrLoginVerifyCodeFailed, properties: ["error": errorType]) + self.view.showQRLoginError(error: qrLoginError) + } + } + + func confirm() { + // If we're in the done state, dismiss the flow + // If we're in the error state, do something + switch state { + case .done: + parentCoordinator.track(.qrLoginVerifyCodeDismissed) + parentCoordinator.dismiss() + return + case .error: + parentCoordinator.track(.qrLoginVerifyCodeScanAgain) + parentCoordinator.scanAgain() + return + + default: break + } + + parentCoordinator.track(.qrLoginVerifyCodeApproved) + + view.showAuthenticating() + state = .authenticating + + service.authenticate(token: token) { success in + self.parentCoordinator.track(.qrLoginAuthenticated) + self.state = .done + self.view.renderCompletion() + } failure: { error in + self.state = .error + + guard self.connectionChecker.connectionAvailable else { + self.parentCoordinator.track(.qrLoginVerifyCodeFailed, properties: ["error": "no_internet"]) + self.view.showNoConnectionError() + return + } + + self.view.showAuthenticationFailedError() + self.parentCoordinator.track(.qrLoginVerifyCodeFailed, properties: ["error": "authentication_failed"]) + } + } + + func cancel() { + parentCoordinator.track(.qrLoginVerifyCodeCancelled) + parentCoordinator.dismiss() + } +} diff --git a/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginCameraPermissionsHandler.swift b/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginCameraPermissionsHandler.swift new file mode 100644 index 000000000000..49ad573f9516 --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginCameraPermissionsHandler.swift @@ -0,0 +1,58 @@ +import Foundation + +struct QRLoginCameraPermissionsHandler: QRCameraPermissionsHandler { + func needsCameraAccess() -> Bool { + return AVCaptureDevice.authorizationStatus(for: .video) != .authorized + } + + func requestCameraAccess(_ completion: @escaping (Bool) -> Void) { + AVCaptureDevice.requestAccess(for: .video, completionHandler: completion) + } + + func showNeedAccessAlert(from source: UIViewController?) { + let alert = UIAlertController(title: Strings.title, + message: Strings.message, + preferredStyle: .alert) + + alert.addActionWithTitle(Strings.dismiss, style: .cancel) + alert.addDefaultActionWithTitle(Strings.openSettings) { action in + UIApplication.shared.openSettings() + } + + guard let source = source else { + alert.presentFromRootViewController() + return + } + + source.present(alert, animated: true) + } + + func checkCameraPermissions(from source: UIViewController, origin: QRLoginCoordinator.QRLoginOrigin, completion: @escaping () -> Void) { + guard needsCameraAccess() else { + completion() + return + } + + WPAnalytics.track(.qrLoginCameraPermissionDisplayed, properties: ["origin": origin.rawValue]) + + requestCameraAccess { granted in + DispatchQueue.main.async { + guard granted else { + WPAnalytics.track(.qrLoginCameraPermissionDenied, properties: ["origin": origin.rawValue]) + self.showNeedAccessAlert(from: source) + return + } + + WPAnalytics.track(.qrLoginCameraPermissionApproved, properties: ["origin": origin.rawValue]) + completion() + } + } + } + + private enum Strings { + static let title = NSLocalizedString("Camera access needed to scan login codes", comment: "Title of an alert informing the user the camera permission for the app is disabled and its needed to proceed") + static let message = NSLocalizedString("This app needs permission to access the Camera to scan login codes, tap on the Open Settings button to enable it.", comment: "A description informing the user in order to proceed with this feature we will need camera permissions, and how to enable it.") + static let openSettings = NSLocalizedString("Open Settings", comment: "Title of a button that opens the apps settings in the system Settings.app") + static let dismiss = NSLocalizedString("Cancel", comment: "Title of a button that dismisses the permissions alert") + } +} diff --git a/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginCameraSession.swift b/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginCameraSession.swift new file mode 100644 index 000000000000..f8a51fb77cc3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginCameraSession.swift @@ -0,0 +1,118 @@ +import Foundation +import AVFoundation + +class QRLoginCameraSession: NSObject, QRCodeScanningSession { + var session: AVCaptureSession? + // > Delegate any interaction with the AVCaptureSession—including its inputs and outputs—to a + // > dedicated serial dispatch queue, so that the interaction doesn’t block the main queue. + // > + // > – https://developer.apple.com/documentation/avfoundation/capture_setup/avcam_building_a_camera_app + let sessionQueue = DispatchQueue(label: "qrlogincamerasession.queue.serial") + + var cameraDevice: AVCaptureDevice? + + var hasCamera: Bool { + return cameraDevice != nil + } + + var previewLayer: CALayer? { + guard let session = session else { + return nil + } + + return AVCaptureVideoPreviewLayer(session: session) + } + + var scanningDelegate: QRCodeScanningDelegate? + + func configure() { + configureCamera() + } + + func start() { + sessionQueue.async { [weak self] in + self?.session?.startRunning() + } + } + + func stop() { + sessionQueue.async { [weak self] in + self?.session?.stopRunning() + } + } +} + +private extension QRLoginCameraSession { + func startCameraSession() { + sessionQueue.async { [weak self] in + self?.session?.startRunning() + } + } + + func stopCameraSession() { + sessionQueue.async { [weak self] in + self?.session?.stopRunning() + } + } + + func configureCamera() { + try? configureCaptureDevice() + configureCaptureSession() + } + + // Attempts to grab the default camera for the device + func configureCaptureDevice() throws { + guard let camera = AVCaptureDevice.default(for: .video) else { + return + } + + if camera.isFocusModeSupported(.continuousAutoFocus) { + try camera.lockForConfiguration() + camera.focusMode = .continuousAutoFocus + camera.unlockForConfiguration() + } + + cameraDevice = camera + } + + func configureCaptureSession() { + guard let cameraDevice = cameraDevice, let deviceInput = try? AVCaptureDeviceInput(device: cameraDevice) else { + return + } + + let session = AVCaptureSession() + if session.canAddInput(deviceInput) { + session.addInput(deviceInput) + } + + let output = AVCaptureMetadataOutput() + + // Add the QR Code scanning + if session.canAddOutput(output) { + session.addOutput(output) + output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) + output.metadataObjectTypes = [.qr] + } + + self.session = session + } +} + +extension QRLoginCameraSession: AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + // Wait until we have at least 1 scanned object with a URL + guard let first = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let string = first.stringValue else { + return + } + + guard let delegate = scanningDelegate else { + return + } + + guard delegate.validLink(string) else { + return + } + + delegate.didScanURLString(string) + } +} diff --git a/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginInternetConnectionChecker.swift b/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginInternetConnectionChecker.swift new file mode 100644 index 000000000000..5cc78f4ecbee --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginInternetConnectionChecker.swift @@ -0,0 +1,13 @@ +import Foundation + +struct QRLoginInternetConnectionChecker: QRLoginConnectionChecker { + var connectionAvailable: Bool { + let appDelegate = WordPressAppDelegate.shared + + guard let connectionAvailable = appDelegate?.connectionAvailable, connectionAvailable == true else { + return false + } + + return true + } +} diff --git a/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginProtocols.swift b/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginProtocols.swift new file mode 100644 index 000000000000..43534801c62b --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginProtocols.swift @@ -0,0 +1,71 @@ +import Foundation +import QuartzCore + +/// Encapsulates the interactions between the child and parent coordinators +protocol QRLoginParentCoordinator { + func track(_ event: WPAnalyticsEvent) + func track(_ event: WPAnalyticsEvent, properties: [AnyHashable: Any]?) + + func scanAgain() + func didScanToken(_ token: QRLoginToken) + func dismiss() +} + +/// A simplified representation of a way to check whether the internet connection is available +protocol QRLoginConnectionChecker { + var connectionAvailable: Bool { get } +} + +/// Login camera scanning view +protocol QRLoginScanningView { + func showError(_ message: String) + func showCameraLayer(_ layer: CALayer) +} + +/// Login verify view and all its states +protocol QRLoginVerifyView { + /* Completion States */ + func render(response: QRLoginValidationResponse) + func renderCompletion() + + /* Loading States */ + func showLoading() + func showAuthenticating() + + /* Error States */ + func showNoConnectionError() + func showQRLoginError(error: QRLoginError?) + func showAuthenticationFailedError() +} + +/// Generic camera permissions handler +protocol CameraPermissionsHandler { + func needsCameraAccess() -> Bool + func requestCameraAccess(_ completion: @escaping (Bool) -> Void) + func showNeedAccessAlert(from source: UIViewController?) +} + +/// QR Login Specific Permissions handler +protocol QRCameraPermissionsHandler: CameraPermissionsHandler { + func checkCameraPermissions(from source: UIViewController, origin: QRLoginCoordinator.QRLoginOrigin, completion: @escaping () -> Void) +} + +/// A delegate that handles when a code was scanned and whether its valid or not +protocol QRCodeScanningDelegate { + func validLink(_ stringValue: String) -> Bool + func didScanURLString(_ urlString: String) +} + +/// Manages the camera scanning session +protocol QRCodeScanningSession { + var hasCamera: Bool { get } + var session: AVCaptureSession? { get } + var previewLayer: CALayer? { get } + + var scanningDelegate: QRCodeScanningDelegate? { get set } + + func configure() + + func start() + func stop() +} diff --git a/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginURLParser.swift b/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginURLParser.swift new file mode 100644 index 000000000000..35f0af39284a --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/Helpers/QRLoginURLParser.swift @@ -0,0 +1,71 @@ +import Foundation + +struct QRLoginToken: Equatable { + let token: String + let data: String + + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.token == rhs.token && lhs.data == rhs.data + } +} + +struct QRLoginURLParser { + private let urlString: String + + init(urlString: String) { + self.urlString = urlString + } + + /// Attempts to retrieve the QR Login token information from the incoming urlString + /// - Returns: QRLoginToken or nil if the parsing fails for any reason + func parse() -> QRLoginToken? { + // Early validation, making sure this is a valid URL from a valid host + guard let url = URL(string: urlString), Self.isValidHost(url: url) else { + return nil + } + + // Try extracting the token URL query from the URL + // The #qr-code-login?token=TOKEN&data=DATA fragment + // Then try pulling the token and data from the components + guard + let tokenComponents = extractTokenComponents(from: url), + let token = tokenComponents[Constants.tokenKey], + let data = tokenComponents[Constants.dataKey] + else { + return nil + } + + return QRLoginToken(token: token, data: data) + } + + /// Validates that the input URL is coming from a valid host + static func isValidHost(url: URL) -> Bool { + guard let host = url.host else { + return false + } + + return host == Constants.validHost + } + + private func extractTokenComponents(from url: URL) -> [String: String]? { + guard let fragment = url.fragment else { + return nil + } + + guard let meow = URLComponents(string: fragment)?.queryItems else { + return nil + } + + // Map the URLQueryItem array to a dict so we can easily pull info out + var dict: [String: String] = [:] + meow.forEach { dict[$0.name] = $0.value } + + return dict + } + + private struct Constants { + static let validHost = "apps.wordpress.com" + static let tokenKey = "token" + static let dataKey = "data" + } +} diff --git a/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginScanningViewController.swift b/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginScanningViewController.swift new file mode 100644 index 000000000000..9cae50abd4af --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginScanningViewController.swift @@ -0,0 +1,119 @@ +import UIKit +import AVFoundation + +class QRLoginScanningViewController: UIViewController { + @IBOutlet weak var overlayView: UIView! + @IBOutlet weak var scanFocusImageView: UIImageView! + @IBOutlet weak var errorLabel: UILabel! + + var coordinator: QRLoginScanningCoordinator? +} + +extension QRLoginScanningViewController: QRLoginScanningView { + func showError(_ message: String) { + scanFocusImageView.isHidden = true + stopAnimations() + + view.backgroundColor = .black + + errorLabel.text = message + errorLabel.font = WPStyleGuide.regularTextFont() + errorLabel.textColor = .white + errorLabel.isHidden = false + } + + func showCameraLayer(_ previewLayer: CALayer) { + if let cameraLayer = previewLayer as? AVCaptureVideoPreviewLayer { + cameraLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill + } + + previewLayer.frame = view.layer.bounds + + // Insert the layer below our scan focus overlay image + view.layer.insertSublayer(previewLayer, below: overlayView.layer) + } +} + +// MARK: - View Methods +extension QRLoginScanningViewController { + override func viewDidLoad() { + super.viewDidLoad() + + navigationController?.delegate = self + + errorLabel.isHidden = true + coordinator?.start() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + coordinator?.viewDidAppear() + startAnimations() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + coordinator?.viewWillDisappear() + stopAnimations() + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return [.portrait, .portraitUpsideDown] + } + + @IBAction func didTapCloseButton(_ sender: Any) { + coordinator?.didTapDismiss() + } +} + +// MARK: - UINavigation Controller Delegate +extension QRLoginScanningViewController: UINavigationControllerDelegate { + func navigationControllerSupportedInterfaceOrientations(_ navigationController: UINavigationController) -> UIInterfaceOrientationMask { + return supportedInterfaceOrientations + } + + func navigationControllerPreferredInterfaceOrientationForPresentation(_ navigationController: UINavigationController) -> UIInterfaceOrientation { + return .portrait + } +} + +// MARK: - Animations +private extension QRLoginScanningViewController { + func startAnimations() { + pulsateFocusArea() + } + + func stopAnimations() { + scanFocusImageView.layer.removeAllAnimations() + } + + /// Creates a pulsing animation that scales the focus area corners up and down + func pulsateFocusArea() { + let layerAnimation = CABasicAnimation(keyPath: "transform.scale") + layerAnimation.fromValue = AnimationConstants.scale.min + layerAnimation.toValue = AnimationConstants.scale.max + layerAnimation.duration = AnimationConstants.timing.durationInSeconds + layerAnimation.repeatCount = AnimationConstants.timing.repeatCount + + layerAnimation.isAdditive = false + layerAnimation.fillMode = .forwards + layerAnimation.isRemovedOnCompletion = true + layerAnimation.autoreverses = true + + scanFocusImageView.layer.add(layerAnimation, forKey: "pulsateAnimation") + } + + enum AnimationConstants { + enum scale { + static let min = 1 + static let max = 1.05 + } + + enum timing { + static let durationInSeconds: CFTimeInterval = 1 + static let repeatCount: Float = .infinity + } + } +} diff --git a/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginScanningViewController.xib b/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginScanningViewController.xib new file mode 100644 index 000000000000..8b45c12118f2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginScanningViewController.xib @@ -0,0 +1,86 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="QRLoginScanningViewController" customModule="WordPress" customModuleProvider="target"> + <connections> + <outlet property="errorLabel" destination="R3V-Yi-h49" id="cgV-3h-ITM"/> + <outlet property="overlayView" destination="P35-nk-5DE" id="ZKm-Wj-tfe"/> + <outlet property="scanFocusImageView" destination="3uw-ff-pHi" id="tFs-sG-yb5"/> + <outlet property="view" destination="hCm-wc-qEY" id="ewF-2O-dCk"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="hCm-wc-qEY"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="P35-nk-5DE" userLabel="Overlay View"> + <rect key="frame" x="0.0" y="44" width="414" height="818"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="No Camera Detected" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="R3V-Yi-h49"> + <rect key="frame" x="20" y="399" width="374" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <button opaque="NO" contentMode="scaleAspectFit" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="SIh-BQ-5W3" userLabel="Close Button"> + <rect key="frame" x="0.0" y="0.0" width="64" height="64"/> + <constraints> + <constraint firstAttribute="width" constant="64" id="PEh-Hz-xbS"/> + <constraint firstAttribute="height" constant="64" id="iz3-SB-kUt"/> + </constraints> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/> + <state key="normal" image="qr-login-close-icon"/> + <connections> + <action selector="didTapCloseButton:" destination="-1" eventType="touchUpInside" id="2kK-eK-5ez"/> + </connections> + </button> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="qr-scan-focus" translatesAutoresizingMaskIntoConstraints="NO" id="3uw-ff-pHi"> + <rect key="frame" x="92" y="294" width="230" height="230"/> + <constraints> + <constraint firstAttribute="width" secondItem="3uw-ff-pHi" secondAttribute="height" multiplier="1:1" id="KF4-On-LLx"/> + <constraint firstAttribute="width" relation="lessThanOrEqual" constant="248" id="nth-R6-1fS"/> + </constraints> + </imageView> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <color key="tintColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="R3V-Yi-h49" firstAttribute="centerX" secondItem="P35-nk-5DE" secondAttribute="centerX" id="2cG-97-fRz"/> + <constraint firstAttribute="trailing" secondItem="R3V-Yi-h49" secondAttribute="trailing" constant="20" id="Cgu-aa-Lmo"/> + <constraint firstItem="3uw-ff-pHi" firstAttribute="centerY" secondItem="P35-nk-5DE" secondAttribute="centerY" id="Q1G-2o-Ade"/> + <constraint firstItem="SIh-BQ-5W3" firstAttribute="top" secondItem="P35-nk-5DE" secondAttribute="top" id="UWJ-f4-sMz"/> + <constraint firstItem="R3V-Yi-h49" firstAttribute="centerY" secondItem="P35-nk-5DE" secondAttribute="centerY" id="W98-z2-umF"/> + <constraint firstItem="3uw-ff-pHi" firstAttribute="centerX" secondItem="P35-nk-5DE" secondAttribute="centerX" id="Wb4-Mq-KAo"/> + <constraint firstItem="R3V-Yi-h49" firstAttribute="leading" secondItem="P35-nk-5DE" secondAttribute="leading" constant="20" id="oqQ-jj-3NA"/> + <constraint firstItem="SIh-BQ-5W3" firstAttribute="leading" secondItem="P35-nk-5DE" secondAttribute="leading" id="zYB-wY-eDV"/> + </constraints> + </view> + </subviews> + <viewLayoutGuide key="safeArea" id="GRm-ae-H7Y"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="P35-nk-5DE" firstAttribute="top" secondItem="GRm-ae-H7Y" secondAttribute="top" id="1du-9G-Kz0"/> + <constraint firstItem="P35-nk-5DE" firstAttribute="trailing" secondItem="hCm-wc-qEY" secondAttribute="trailing" id="mE3-K0-qEH"/> + <constraint firstItem="P35-nk-5DE" firstAttribute="leading" secondItem="GRm-ae-H7Y" secondAttribute="leading" id="rb0-hd-UfQ"/> + <constraint firstItem="GRm-ae-H7Y" firstAttribute="bottom" secondItem="P35-nk-5DE" secondAttribute="bottom" id="wyo-sE-23c"/> + </constraints> + <point key="canvasLocation" x="47.826086956521742" y="-7.3660714285714279"/> + </view> + </objects> + <resources> + <image name="qr-login-close-icon" width="23" height="23"/> + <image name="qr-scan-focus" width="230" height="230"/> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginVerifyAuthorizationViewController.swift b/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginVerifyAuthorizationViewController.swift new file mode 100644 index 000000000000..c28f5b8878e2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginVerifyAuthorizationViewController.swift @@ -0,0 +1,246 @@ +import UIKit + +class QRLoginVerifyAuthorizationViewController: UIViewController { + @IBOutlet weak var stackView: UIStackView! + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var subTitleLabel: UILabel! + @IBOutlet weak var loadingIndicator: UIActivityIndicatorView! + + + @IBOutlet weak var confirmButton: UIButton! + @IBOutlet weak var cancelButton: UIButton! + + var coordinator: QRLoginVerifyCoordinator? +} + +// MARK: - View Methods +extension QRLoginVerifyAuthorizationViewController { + override func viewDidLoad() { + super.viewDidLoad() + + navigationController?.delegate = self + + coordinator?.start() + + applyStyles() + } + + @IBAction func didTapConfirm(_ sender: Any) { + coordinator?.confirm() + } + + @IBAction func didTapCancel(_ sender: Any) { + coordinator?.cancel() + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return [.portrait, .portraitUpsideDown] + } +} + +// MARK: - UINavigation Controller Delegate +extension QRLoginVerifyAuthorizationViewController: UINavigationControllerDelegate { + func navigationControllerSupportedInterfaceOrientations(_ navigationController: UINavigationController) -> UIInterfaceOrientationMask { + return supportedInterfaceOrientations + } + + func navigationControllerPreferredInterfaceOrientationForPresentation(_ navigationController: UINavigationController) -> UIInterfaceOrientation { + return .portrait + } +} + +// MARK: - QRLoginVerifyView +extension QRLoginVerifyAuthorizationViewController: QRLoginVerifyView { + func render(response: QRLoginValidationResponse) { + let title: String + if let browser = response.browser { + title = String(format: Strings.title, browser, response.location) + } else { + title = String(format: Strings.defaultTitle, response.location) + } + + update(imageName: Strings.imageName, + title: title, + subTitle: Strings.subtitle, + confirmButton: Strings.confirmButton, + cancelButton: Strings.cancelButton) + + stackView.isHidden = false + hideLoading() + } + + func renderCompletion() { + update(imageName: Strings.completed.imageName, + title: Strings.completed.title, + subTitle: Strings.completed.subtitle, + confirmButton: Strings.completed.confirmButton, + cancelButton: nil) + + cancelButton.isHidden = true + subTitleLabel.textColor = .secondaryLabel + + hideLoading() + + UINotificationFeedbackGenerator().notificationOccurred(.success) + + ConfettiView.cleanupAndAnimate(on: view, frame: navigationController?.view.frame ?? view.frame) { confettiView in + // removing this instance when the animation completes, will prevent + // the animation to suddenly stop if users navigate away early + confettiView.removeFromSuperview() + } + } + + func showLoading() { + stackView.isHidden = true + startLoading() + } + + func showAuthenticating() { + stackView.layer.opacity = 0.5 + + startLoading() + } + + func showNoConnectionError() { + update(imageName: Strings.noConnection.imageName, + title: Strings.noConnection.title, + subTitle: Strings.noConnection.subtitle, + confirmButton: Strings.noConnection.confirmButton, + cancelButton: Strings.noConnection.cancelButton) + + hideLoading() + + subTitleLabel.textColor = .secondaryLabel + } + + func showQRLoginError(error: QRLoginError?) { + switch error ?? .invalidData { + case .invalidData: + update(imageName: Strings.validationError.imageName, + title: Strings.validationError.invalidData.title, + subTitle: Strings.validationError.invalidData.subtitle, + confirmButton: Strings.validationError.confirmButton, + cancelButton: Strings.validationError.cancelButton) + + case .expired: + update(imageName: Strings.validationError.imageName, + title: Strings.validationError.expired.title, + subTitle: Strings.validationError.expired.subtitle, + confirmButton: Strings.validationError.confirmButton, + cancelButton: Strings.validationError.cancelButton) + } + + hideLoading() + + subTitleLabel.textColor = .secondaryLabel + } + + func showAuthenticationFailedError() { + update(imageName: Strings.validationError.imageName, + title: Strings.validationError.authenticationFailed.title, + subTitle: Strings.validationError.authenticationFailed.subtitle, + confirmButton: Strings.validationError.confirmButton, + cancelButton: Strings.validationError.cancelButton) + + hideLoading() + + subTitleLabel.textColor = .secondaryLabel + } +} + +// MARK: - Private: View Helpers +extension QRLoginVerifyAuthorizationViewController { + private func applyStyles() { + titleLabel.font = WPStyleGuide.serifFontForTextStyle(.title1, fontWeight: .semibold) + titleLabel.textColor = .text + + subTitleLabel.font = .preferredFont(forTextStyle: .headline) + subTitleLabel.textColor = .systemRed + } + + private func hideLoading() { + stackView.isHidden = false + stackView.layer.opacity = 1 + + loadingIndicator.isHidden = true + loadingIndicator.stopAnimating() + } + + private func startLoading() { + loadingIndicator.startAnimating() + loadingIndicator.isHidden = false + } + + private func update(imageName: String, title: String, subTitle: String, confirmButton: String, cancelButton: String?) { + imageView.image = UIImage(named: imageName) + titleLabel.text = title + subTitleLabel.text = subTitle + self.confirmButton.setTitle(confirmButton, for: .normal) + + guard let cancelButton = cancelButton else { + self.cancelButton.isHidden = true + return + } + + self.cancelButton.setTitle(cancelButton, for: .normal) + } + + private enum Strings { + static let imageName = "wp-illustration-mobile-save-for-later" + static let title = NSLocalizedString("Are you trying to log in to %1$@ near %2$@?", comment: "Title that asks the user if they are the trying to login. %1$@ is a placeholder for the browser name (Chrome/Firefox), %2$@ is a placeholder for the users location") + static let defaultTitle = NSLocalizedString("Are you trying to log in to your web browser near %1$@?", comment: "Title that asks the user if they are the trying to log in. %1$@ is a placeholder for the users location") + static let subtitle = NSLocalizedString("Only scan QR codes taken directly from your web browser. Never scan a code sent to you by anyone else.", comment: "Warning label that informs the user to only scan login codes that they generated.") + static let confirmButton = NSLocalizedString("Yes, log me in", comment: "Button label that confirms the user wants to log in and will authenticate them via the browser") + static let cancelButton = NSLocalizedString("Cancel", comment: "Button label that dismisses the qr log in flow and returns the user back to the previous screen") + + enum completed { + static let imageName = "domains-success" + static let title = NSLocalizedString( + "qrLoginVerifyAuthorization.completedInstructions.title", + value: "You're logged in!", + comment: "Title for the success view when the user has successfully logged in" + ) + private static let subtitleFormat = NSLocalizedString( + "qrLoginVerifyAuthorization.completedInstructions.subtitle", + value: "Tap '%@' and head back to your web browser to continue.", + comment: "Subtitle instructing the user to tap the dismiss button to leave the log in flow. %@ is a placeholder for the dismiss button name." + ) + static let confirmButton = NSLocalizedString( + "qrLoginVerifyAuthorization.completedInstructions.dismiss", + value: "Dismiss", + comment: "Button label that dismisses the qr log in flow and returns the user back to the previous screen" + ) + static let subtitle = String(format: subtitleFormat, Self.confirmButton) + } + + enum noConnection { + static let imageName = "wp-illustration-empty-results" + static let title = NSLocalizedString("No connection", comment: "Title for the error view when there's no connection") + static let subtitle = NSLocalizedString("An active internet connection is required to scan log in codes", comment: "Error message shown when trying to scan a log in code without an active internet connection.") + static let confirmButton = NSLocalizedString("Scan Again", comment: "Button label that prompts the user to scan the log in code again") + static let cancelButton = NSLocalizedString("Cancel", comment: "Button label that dismisses the qr log in flow and returns the user back to the previous screen") + } + + enum validationError { + static let imageName = "wp-illustration-empty-results" + static let confirmButton = NSLocalizedString("Scan Again", comment: "Button label that prompts the user to scan the log in code again") + static let cancelButton = NSLocalizedString("Cancel", comment: "Button label that dismisses the qr log in flow and returns the user back to the previous screen") + + enum invalidData { + static let title = NSLocalizedString("Could not validate the log in code", comment: "Title for the error view when the user scanned an invalid log in code") + static let subtitle = NSLocalizedString("The log in code that was scanned could not be validated. Please tap the Scan Again button to rescan the code.", comment: "Error message shown when trying to scan an invalid log in code.") + } + + enum expired { + static let title = NSLocalizedString("Expired log in code", comment: "Title for the error view when the user scanned an expired log in code") + static let subtitle = NSLocalizedString("This log in code has expired. Please tap the Scan Again button to rescan the code.", comment: "Error message shown when the user scanned an expired log in code.") + } + + enum authenticationFailed { + static let title = NSLocalizedString("Authentication Failed", comment: "Title for the error view when the authentication failed for any reason") + static let subtitle = NSLocalizedString("Could not log you in using this log in code. Please tap the Scan Again button to rescan the code.", comment: "Error message shown when the user scanned an expired log in code.") + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginVerifyAuthorizationViewController.xib b/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginVerifyAuthorizationViewController.xib new file mode 100644 index 000000000000..c2f54e3493d0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/QR Login/View Controllers/QRLoginVerifyAuthorizationViewController.xib @@ -0,0 +1,132 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_7" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/> + <capability name="Named colors" minToolsVersion="9.0"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="QRLoginVerifyAuthorizationViewController" customModule="WordPress" customModuleProvider="target"> + <connections> + <outlet property="cancelButton" destination="W5i-iw-Ujl" id="sNO-uy-ySz"/> + <outlet property="confirmButton" destination="Eh1-Z2-8sk" id="BH3-Sr-3cn"/> + <outlet property="imageView" destination="zoR-M4-iZL" id="7z6-LS-oO2"/> + <outlet property="loadingIndicator" destination="E5R-yl-70T" id="Wf6-ze-DuL"/> + <outlet property="stackView" destination="1NA-vr-klW" id="d86-Sn-TW8"/> + <outlet property="subTitleLabel" destination="y6J-3G-KqQ" id="iVw-Bl-ftC"/> + <outlet property="titleLabel" destination="QsB-if-dr3" id="4US-G8-37N"/> + <outlet property="view" destination="zua-ds-OJd" id="6qQ-7d-RHd"/> + </connections> + </placeholder> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" id="zua-ds-OJd"> + <rect key="frame" x="0.0" y="0.0" width="428" height="926"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <view contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="4ra-2v-IcO"> + <rect key="frame" x="20" y="113" width="388" height="700"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="10" translatesAutoresizingMaskIntoConstraints="NO" id="1NA-vr-klW"> + <rect key="frame" x="0.0" y="147.66666666666669" width="388" height="405.00000000000006"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="wp-illustration-mobile-save-for-later" translatesAutoresizingMaskIntoConstraints="NO" id="zoR-M4-iZL"> + <rect key="frame" x="0.0" y="0.0" width="388" height="128"/> + <constraints> + <constraint firstAttribute="height" constant="128" id="C8g-9P-XDZ"/> + </constraints> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="1000" verticalCompressionResistancePriority="1000" text="Are you trying to login on your web browser?" textAlignment="center" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="QsB-if-dr3"> + <rect key="frame" x="0.0" y="138" width="388" height="75"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleTitle0"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Only scan QR codes taken directly from your web browser. Never scan a code sent to you by anyone else." textAlignment="center" lineBreakMode="wordWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="y6J-3G-KqQ"> + <rect key="frame" x="0.0" y="223" width="388" height="61"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="zZJ-sf-yub" userLabel="Spacer View"> + <rect key="frame" x="0.0" y="293.99999999999994" width="388" height="0.0"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="height" id="pY0-WJ-dvF"/> + </constraints> + </view> + <button opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="1000" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="wordWrap" translatesAutoresizingMaskIntoConstraints="NO" id="Eh1-Z2-8sk" customClass="FancyButton" customModule="WordPressUI"> + <rect key="frame" x="0.0" y="303.99999999999994" width="388" height="55"/> + <constraints> + <constraint firstAttribute="height" constant="55" id="1oy-o6-oS6"/> + </constraints> + <fontDescription key="fontDescription" type="system" pointSize="15"/> + <inset key="contentEdgeInsets" minX="10" minY="0.0" maxX="10" maxY="0.0"/> + <inset key="titleEdgeInsets" minX="10" minY="0.0" maxX="0.0" maxY="0.0"/> + <state key="normal" title="Yes, log me in"> + <color key="titleColor" name="AccentColor"/> + </state> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="YES"/> + </userDefinedRuntimeAttributes> + <connections> + <action selector="didTapConfirm:" destination="-1" eventType="touchUpInside" id="ANT-TV-QuC"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="W5i-iw-Ujl"> + <rect key="frame" x="0.0" y="368.99999999999994" width="388" height="36"/> + <constraints> + <constraint firstAttribute="height" constant="36" id="Is5-MR-2Ez"/> + </constraints> + <state key="normal" title="Button"/> + <buttonConfiguration key="configuration" style="plain" title="Cancel"/> + <connections> + <action selector="didTapCancel:" destination="-1" eventType="touchUpInside" id="kjZ-ub-4ow"/> + </connections> + </button> + </subviews> + </stackView> + <activityIndicatorView hidden="YES" opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" hidesWhenStopped="YES" style="medium" translatesAutoresizingMaskIntoConstraints="NO" id="E5R-yl-70T"> + <rect key="frame" x="184" y="340" width="20" height="20"/> + </activityIndicatorView> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="width" relation="lessThanOrEqual" constant="480" id="5lD-iz-qYp"/> + <constraint firstAttribute="height" relation="lessThanOrEqual" constant="700" id="6yB-T7-vdv"/> + <constraint firstItem="1NA-vr-klW" firstAttribute="leading" secondItem="4ra-2v-IcO" secondAttribute="leading" id="JCt-gN-0Ww"/> + <constraint firstItem="1NA-vr-klW" firstAttribute="centerY" secondItem="4ra-2v-IcO" secondAttribute="centerY" id="S5X-vw-Zh1"/> + <constraint firstItem="E5R-yl-70T" firstAttribute="centerX" secondItem="4ra-2v-IcO" secondAttribute="centerX" id="efc-Xl-H7f"/> + <constraint firstItem="E5R-yl-70T" firstAttribute="centerY" secondItem="4ra-2v-IcO" secondAttribute="centerY" id="ofT-HG-rfp"/> + <constraint firstAttribute="trailing" secondItem="1NA-vr-klW" secondAttribute="trailing" id="q4t-mP-e3S"/> + </constraints> + </view> + </subviews> + <viewLayoutGuide key="safeArea" id="XP9-J1-qjj"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="4ra-2v-IcO" firstAttribute="leading" secondItem="XP9-J1-qjj" secondAttribute="leading" priority="950" constant="20" id="0EI-6m-5ud"/> + <constraint firstItem="4ra-2v-IcO" firstAttribute="centerY" secondItem="zua-ds-OJd" secondAttribute="centerY" id="4Ty-l6-5Ze"/> + <constraint firstItem="XP9-J1-qjj" firstAttribute="trailing" secondItem="4ra-2v-IcO" secondAttribute="trailing" priority="950" constant="20" id="KRu-MP-Tsf"/> + <constraint firstItem="4ra-2v-IcO" firstAttribute="centerX" secondItem="zua-ds-OJd" secondAttribute="centerX" id="PCz-ep-bsO"/> + <constraint firstItem="XP9-J1-qjj" firstAttribute="bottom" secondItem="4ra-2v-IcO" secondAttribute="bottom" priority="750" constant="24" id="ZsH-gG-Fay"/> + <constraint firstItem="4ra-2v-IcO" firstAttribute="top" secondItem="XP9-J1-qjj" secondAttribute="top" priority="750" constant="40" id="sdZ-2U-aGF"/> + <constraint firstItem="4ra-2v-IcO" firstAttribute="top" secondItem="XP9-J1-qjj" secondAttribute="top" priority="750" id="yKt-2L-6Hv"/> + </constraints> + <edgeInsets key="layoutMargins" top="8" left="8" bottom="8" right="8"/> + <point key="canvasLocation" x="-1813" y="106"/> + </view> + </objects> + <resources> + <image name="wp-illustration-mobile-save-for-later" width="139" height="154"/> + <namedColor name="AccentColor"> + <color red="0.0" green="0.46000000000000002" blue="0.89000000000000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </namedColor> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Ratings/AppFeedbackPromptView.swift b/WordPress/Classes/ViewRelated/Ratings/AppFeedbackPromptView.swift index b09d49cebd13..7789c0ad5c6c 100644 --- a/WordPress/Classes/ViewRelated/Ratings/AppFeedbackPromptView.swift +++ b/WordPress/Classes/ViewRelated/Ratings/AppFeedbackPromptView.swift @@ -44,6 +44,7 @@ class AppFeedbackPromptView: UIView { buttonStack.translatesAutoresizingMaskIntoConstraints = false buttonStack.axis = .horizontal buttonStack.spacing = LayoutConstants.buttonSpacing + buttonStack.isLayoutMarginsRelativeArrangement = true addSubview(buttonStack) // Yes Button @@ -55,7 +56,7 @@ class AppFeedbackPromptView: UIView { leftButton.accessibilityIdentifier = "yes-button" buttonStack.addArrangedSubview(leftButton) - // Could be Better Button + // Could improve Button rightButton.translatesAutoresizingMaskIntoConstraints = false rightButton.backgroundColor = .secondaryButtonBackground rightButton.borderWidth = 1.0 @@ -77,20 +78,23 @@ class AppFeedbackPromptView: UIView { leftButton.removeTarget(nil, action: nil, for: .touchUpInside) leftButton.setTitle(title, for: .normal) leftButton.on(.touchUpInside, call: tapHandler) + evaluateStackAxisMode() } func setupNoButton(title: String, tapHandler: @escaping (UIControl) -> Void) { rightButton.removeTarget(nil, action: nil, for: .touchUpInside) rightButton.setTitle(title, for: .normal) rightButton.on(.touchUpInside, call: tapHandler) + evaluateStackAxisMode() } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) + evaluateStackAxisMode() + } - buttonStack.axis = .horizontal - buttonStack.isLayoutMarginsRelativeArrangement = true - + /// Evaluate the width of the buttons to determine if the stack view should go into vertical mode. + private func evaluateStackAxisMode() { // measure the width of the view with the new font sizes to see if the buttons are too wide. leftButton.updateFontSizeToMatchSystem() rightButton.updateFontSizeToMatchSystem() diff --git a/WordPress/Classes/ViewRelated/Reader/Analytics/ReaderTracker.swift b/WordPress/Classes/ViewRelated/Reader/Analytics/ReaderTracker.swift new file mode 100644 index 000000000000..4ee12ed0a941 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Analytics/ReaderTracker.swift @@ -0,0 +1,65 @@ +import Foundation + +class ReaderTracker: NSObject { + @objc static let shared = ReaderTracker() + + enum Section: String, CaseIterable { + /// Time spent in the main Reader view (the one with the tabs) + case main = "time_in_main_reader" + + /// Time spent in the Following tab with an active filter + case filteredList = "time_in_reader_filtered_list" + + /// Time spent reading article + case readerPost = "time_in_reader_post" + } + + private var now: () -> Date + private var startTime: [Section: Date] = [:] + private var totalTimeInSeconds: [Section: TimeInterval] = [:] + + init(now: @escaping () -> Date = { return Date() }) { + self.now = now + } + + /// Returns a dictionary with a key and the time spent in that section + @objc func data() -> [String: Double] { + return Section.allCases.reduce([String: Double]()) { dict, section in + var dict = dict + dict[section.rawValue] = totalTimeInSeconds[section] ?? 0 + return dict + } + } + + /// Start counting time spent for a given section + func start(_ section: Section) { + guard startTime[section] == nil else { + return + } + + startTime[section] = now() + } + + /// Stop counting time spent for a given section + func stop(_ section: Section) { + guard let startTime = startTime[section] else { + return + } + + let timeSince = now().timeIntervalSince(startTime) + + totalTimeInSeconds[section] = (totalTimeInSeconds[section] ?? 0) + round(timeSince) + self.startTime.removeValue(forKey: section) + } + + /// Stop counting time for all sections + @objc func stopAll() { + Section.allCases.forEach { stop($0) } + } + + /// Stop counting time for all sections and reset them to zero + @objc func reset() { + startTime = [:] + totalTimeInSeconds = [:] + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsFollowPresenter.swift b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsFollowPresenter.swift new file mode 100644 index 000000000000..d3d24c21c008 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsFollowPresenter.swift @@ -0,0 +1,255 @@ +import Foundation + +/// Methods used by the Reader in the Follow Conversation flow to: +/// - subscribe to post comments +/// - subscribe to in-app notifications + +@objc protocol ReaderCommentsFollowPresenterDelegate: AnyObject { + func followConversationComplete(success: Bool, post: ReaderPost) + func toggleNotificationComplete(success: Bool, post: ReaderPost) +} + +class ReaderCommentsFollowPresenter: NSObject { + + // MARK: - Properties + + private let post: ReaderPost + private weak var delegate: ReaderCommentsFollowPresenterDelegate? + private let presentingViewController: UIViewController + private let followCommentsService: FollowCommentsService? + + // MARK: - Initialization + + @objc required init(post: ReaderPost, + delegate: ReaderCommentsFollowPresenterDelegate? = nil, + presentingViewController: UIViewController) { + self.post = post + self.delegate = delegate + self.presentingViewController = presentingViewController + followCommentsService = FollowCommentsService.createService(with: post) + } + + // MARK: - Subscriptions + + /// Toggles the state of conversation subscription. + /// When enabled, the user will receive emails and in-app notifications for new comments. + /// + @objc func handleFollowConversationButtonTapped() { + trackFollowToggled() + + let generator = UINotificationFeedbackGenerator() + generator.prepare() + + let oldIsSubscribed = post.isSubscribedComments + let newIsSubscribed = !oldIsSubscribed + + // Define success block + let successBlock = { [weak self] (taskSucceeded: Bool) in + guard taskSucceeded else { + DispatchQueue.main.async { + generator.notificationOccurred(.error) + let noticeTitle = newIsSubscribed ? Messages.followFail : Messages.unfollowFail + self?.presentingViewController.displayNotice(title: noticeTitle) + self?.informDelegateFollowComplete(success: false) + } + return + } + + DispatchQueue.main.async { + generator.notificationOccurred(.success) + self?.informDelegateFollowComplete(success: true) + + guard newIsSubscribed else { + let noticeTitle = newIsSubscribed ? Messages.followSuccess : Messages.unfollowSuccess + self?.presentingViewController.displayNotice(title: noticeTitle) + return + } + + // Show notice with Undo option. Push Notifications are opt-out. + self?.updateNotificationSettings(shouldEnableNotifications: true, canUndo: true) + } + } + + // Define failure block + let failureBlock = { [weak self] (error: Error?) in + DDLogError("Reader Comments: error toggling subscription status: \(String(describing: error))") + + DispatchQueue.main.async { + generator.notificationOccurred(.error) + let noticeTitle = newIsSubscribed ? Messages.subscribeFail : Messages.unsubscribeFail + self?.presentingViewController.displayNotice(title: noticeTitle) + self?.informDelegateFollowComplete(success: false) + } + } + + // Call the service to toggle the subscription status + followCommentsService?.toggleSubscribed(oldIsSubscribed, success: successBlock, failure: failureBlock) + } + + /// Toggles the state of comment subscription notifications. + /// When enabled, the user will receive in-app notifications for new comments. + /// + /// - Parameter canUndo: Boolean. When true, this provides a way for the user to revert their actions. + /// - Parameter completion: Block called as soon the view controller has been removed. + /// + @objc func handleNotificationsButtonTapped(canUndo: Bool, completion: ((Bool) -> Void)? = nil) { + trackNotificationsToggled(isNotificationEnabled: !post.receivesCommentNotifications) + + let shouldEnableNotifications = !self.post.receivesCommentNotifications + + updateNotificationSettings(shouldEnableNotifications: shouldEnableNotifications, canUndo: canUndo, completion: completion) + } + + // MARK: - Notification Sheet + + @objc func showNotificationSheet(sourceBarButtonItem: UIBarButtonItem?) { + showBottomSheet(sourceBarButtonItem: sourceBarButtonItem) + } + + func showNotificationSheet(sourceView: UIView?) { + showBottomSheet(sourceView: sourceView) + } + +} + +// MARK: - Private Extension + +private extension ReaderCommentsFollowPresenter { + + private func updateNotificationSettings(shouldEnableNotifications: Bool, canUndo: Bool, completion: ((Bool) -> Void)? = nil) { + let action: ReaderHelpers.PostSubscriptionAction = shouldEnableNotifications ? .enableNotification : .disableNotification + + followCommentsService?.toggleNotificationSettings(shouldEnableNotifications, success: { [weak self] in + completion?(true) + self?.informDelegateNotificationComplete(success: true) + + guard let self = self else { + return + } + + guard canUndo else { + let title = ReaderHelpers.noticeTitle(forAction: action, success: true) + self.presentingViewController.displayNotice(title: title) + return + } + + self.presentingViewController.displayActionableNotice( + title: Messages.promptTitle, + message: Messages.promptMessage, + actionTitle: Messages.undoActionTitle, + actionHandler: { (accepted: Bool) in + self.handleNotificationsButtonTapped(canUndo: false) + }) + }, failure: { [weak self] error in + DDLogError("Reader Comments: error toggling notification status: \(String(describing: error)))") + let title = ReaderHelpers.noticeTitle(forAction: action, success: false) + self?.presentingViewController.displayNotice(title: title) + completion?(false) + self?.informDelegateNotificationComplete(success: false) + }) + } + + func showBottomSheet(sourceView: UIView? = nil, sourceBarButtonItem: UIBarButtonItem? = nil) { + let sheetViewController = ReaderCommentsNotificationSheetViewController(isNotificationEnabled: post.receivesCommentNotifications, delegate: self) + let bottomSheet = BottomSheetViewController(childViewController: sheetViewController) + bottomSheet.show(from: presentingViewController, sourceView: sourceView, sourceBarButtonItem: sourceBarButtonItem) + } + + func informDelegateFollowComplete(success: Bool) { + delegate?.followConversationComplete(success: success, post: post) + } + + func informDelegateNotificationComplete(success: Bool) { + delegate?.toggleNotificationComplete(success: success, post: post) + } + + struct Messages { + // Follow Conversation + static let followSuccess = NSLocalizedString("Successfully followed conversation", comment: "The app successfully subscribed to the comments for the post") + static let unfollowSuccess = NSLocalizedString("Successfully unfollowed conversation", comment: "The app successfully unsubscribed from the comments for the post") + static let followFail = NSLocalizedString("Unable to follow conversation", comment: "The app failed to subscribe to the comments for the post") + static let unfollowFail = NSLocalizedString("Failed to unfollow conversation", comment: "The app failed to unsubscribe from the comments for the post") + + // Subscribe to Comments + static let subscribeFail = NSLocalizedString("Could not subscribe to comments", comment: "The app failed to subscribe to the comments for the post") + static let unsubscribeFail = NSLocalizedString("Could not unsubscribe from comments", comment: "The app failed to unsubscribe from the comments for the post") + + // In-app notifications prompt + static let promptTitle = NSLocalizedString("Following this conversation", comment: "The app successfully subscribed to the comments for the post") + static let promptMessage = NSLocalizedString("You'll get notifications in the app", comment: "Message for the action with opt-out revert action.") + static let enableActionTitle = NSLocalizedString("Enable", comment: "Button title to enable notifications for new comments") + static let undoActionTitle = NSLocalizedString("Undo", comment: "Button title. Reverts the previous notification operation") + } + + // MARK: - Tracks + + func trackFollowToggled() { + var properties = [String: Any]() + let followAction: FollowAction = !post.isSubscribedComments ? .followed : .unfollowed + properties[WPAppAnalyticsKeyFollowAction] = followAction.rawValue + properties[WPAppAnalyticsKeyBlogID] = post.siteID + properties[WPAppAnalyticsKeySource] = sourceForTracks() + WPAnalytics.trackReader(.readerToggleFollowConversation, properties: properties) + } + + func trackNotificationsToggled(isNotificationEnabled: Bool) { + var properties = [String: Any]() + properties[AnalyticsKeys.notificationsEnabled] = isNotificationEnabled + properties[WPAppAnalyticsKeyBlogID] = post.siteID + properties[WPAppAnalyticsKeySource] = sourceForTracks() + WPAnalytics.trackReader(.readerToggleCommentNotifications, properties: properties) + } + + func sourceForTracks() -> String { + if presentingViewController is ReaderCommentsViewController { + return AnalyticsSource.comments.description() + } + + if presentingViewController is ReaderDetailViewController { + return AnalyticsSource.postDetails.description() + } + + return AnalyticsSource.unknown.description() + } + + enum FollowAction: String { + case followed + case unfollowed + } + + private struct AnalyticsKeys { + static let notificationsEnabled = "notifications_enabled" + } + + private enum AnalyticsSource: String { + case comments + case postDetails + case unknown + + func description() -> String { + switch self { + case .comments: + return "reader_threaded_comments" + case .postDetails: + return "reader_post_details_comments" + case .unknown: + return "unknown" + } + } + } + +} + +// MARK: - ReaderCommentsNotificationSheetDelegate Methods + +extension ReaderCommentsFollowPresenter: ReaderCommentsNotificationSheetDelegate { + + func didToggleNotificationSwitch(_ isOn: Bool, completion: @escaping (Bool) -> Void) { + handleNotificationsButtonTapped(canUndo: false, completion: completion) + } + + func didTapUnfollowConversation() { + handleFollowConversationButtonTapped() + } + +} diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsNotificationSheetViewController.swift b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsNotificationSheetViewController.swift new file mode 100644 index 000000000000..dccb6db733f3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsNotificationSheetViewController.swift @@ -0,0 +1,304 @@ +import Foundation +import WordPressFlux + +@objc public protocol ReaderCommentsNotificationSheetDelegate: AnyObject { + func didToggleNotificationSwitch(_ isOn: Bool, completion: @escaping (Bool) -> Void) + func didTapUnfollowConversation() +} + +@objc class ReaderCommentsNotificationSheetViewController: UIViewController { + + // MARK: Properties + + private weak var delegate: ReaderCommentsNotificationSheetDelegate? + + private var isNotificationEnabled: Bool { + didSet { + guard oldValue != isNotificationEnabled else { + return + } + + updateViews(updatesContentSize: true) + } + } + + // MARK: Views + + private lazy var scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.bounces = false + + scrollView.addSubview(containerStackView) + scrollView.pinSubviewToAllEdges(containerStackView) + NSLayoutConstraint.activate([ + containerStackView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + ]) + + return scrollView + }() + + private lazy var containerStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [descriptionLabel, switchContainer, unfollowButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.setCustomSpacing(Constants.switchContainerInsets.top, after: descriptionLabel) + stackView.setCustomSpacing(Constants.switchContainerInsets.bottom, after: switchContainer) + + return stackView + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = Style.descriptionLabelFont + label.textColor = Style.textColor + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + label.setText(.descriptionTextForDisabledNotifications) + + return label + }() + + private lazy var switchContainer: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubviews([switchLabel, switchButton]) + + NSLayoutConstraint.activate([ + switchLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: Constants.switchLabelVerticalPadding), + switchLabel.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -Constants.switchLabelVerticalPadding), + switchLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor), + switchLabel.trailingAnchor.constraint(equalTo: switchButton.leadingAnchor, constant: Constants.switchContainerContentSpacing), + + // prevent the UISwitch from getting shrinked in large content sizes. + switchButton.widthAnchor.constraint(equalToConstant: switchButton.intrinsicContentSize.width), + switchButton.centerYAnchor.constraint(equalTo: switchLabel.centerYAnchor), + + // prevent the edge of UISwitch from being clipped. + switchButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -Constants.switchButtonTrailingPadding) + ]) + + return view + }() + + private lazy var switchLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = Style.switchLabelFont + label.textColor = Style.textColor + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + label.setText(.notificationSwitchLabelText) + + return label + }() + + private lazy var switchButton: UISwitch = { + let switchButton = UISwitch() + switchButton.translatesAutoresizingMaskIntoConstraints = false + switchButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + switchButton.onTintColor = Style.switchOnTintColor + switchButton.isOn = isNotificationEnabled + + switchButton.on(.valueChanged, call: switchValueChanged) + return switchButton + }() + + private lazy var unfollowButton: UIButton = { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(.unfollowButtonTitle, for: .normal) + button.setTitleColor(Style.textColor, for: .normal) + button.setBackgroundImage(.renderBackgroundImage(fill: .clear, border: Style.buttonBorderColor), for: .normal) + + button.titleLabel?.font = Style.buttonTitleLabelFont + button.titleLabel?.textAlignment = .center + button.titleLabel?.numberOfLines = 0 + button.titleLabel?.adjustsFontForContentSizeCategory = true + + // add constraints to the button's title label so it can contain multi-line cases properly. + if let label = button.titleLabel { + button.pinSubviewToAllEdgeMargins(label) + } + + button.on(.touchUpInside, call: unfollowButtonTapped) + return button + }() + + // MARK: Lifecycle + + required init(isNotificationEnabled: Bool, delegate: ReaderCommentsNotificationSheetDelegate? = nil) { + self.isNotificationEnabled = isNotificationEnabled + self.delegate = delegate + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureViews() + + // prevent Notices from being shown while the bottom sheet is displayed in iPhone. + toggleNoticeLock(true) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updatePreferredContentSize() + } +} + +// MARK: - Drawer Presentable + +extension ReaderCommentsNotificationSheetViewController: DrawerPresentable { + var allowsUserTransition: Bool { + return false + } + + var collapsedHeight: DrawerHeight { + if traitCollection.verticalSizeClass == .compact { + return .maxHeight + } + + view.layoutIfNeeded() + return .intrinsicHeight + } + + var scrollableView: UIScrollView? { + return scrollView + } + + func handleDismiss() { + toggleNoticeLock(false) + } +} + +// MARK: - Private Helpers + +private extension ReaderCommentsNotificationSheetViewController { + typealias Style = WPStyleGuide.ReaderCommentsNotificationSheet + + struct Constants { + /// On iPad, the sheet is displayed without the `gripButton` and the additional top spacing that comes with it. + static var contentInsets = UIEdgeInsets(top: WPDeviceIdentification.isiPad() ? 20 : 0, left: 20, bottom: 20, right: 20) + static var switchContainerInsets = UIEdgeInsets(top: 15, left: 0, bottom: 21, right: 0) + static var switchContainerContentSpacing: CGFloat = 4 + static var switchLabelVerticalPadding: CGFloat = 6 + static var switchButtonTrailingPadding: CGFloat = 2 + static var iPadAdditionalBottomPadding: CGFloat = 5 + } + + /// Returns the vertical padding outside the intrinsic height of the `containerStackView`, so the component is displayed properly. + var verticalPadding: CGFloat { + return Constants.contentInsets.top + + Constants.contentInsets.bottom + + additionalVerticalPadding + } + + /// Calculates the default top margin from the `BottomSheetViewController`, plus the bottom safe area inset. + /// The 5pt is for an extra bottom padding on iPad, to make it look better. + var additionalVerticalPadding: CGFloat { + WPDeviceIdentification.isiPad() ? Constants.iPadAdditionalBottomPadding + : BottomSheetViewController.Constants.additionalContentTopMargin + view.safeAreaInsets.bottom + } + + func configureViews() { + view.addSubview(scrollView) + view.pinSubviewToAllEdges(scrollView, insets: Constants.contentInsets) + + // don't update the content size at this state, because the layout pass has not completed. + // doing so will cause the height to be incorrectly assigned to the preferredContentSize. + updateViews(updatesContentSize: false) + } + + func updateViews(updatesContentSize: Bool) { + descriptionLabel.setText(isNotificationEnabled ? .descriptionTextForEnabledNotifications : .descriptionTextForDisabledNotifications) + switchButton.isOn = isNotificationEnabled + + if updatesContentSize { + view.layoutIfNeeded() + updatePreferredContentSize() + } + + // readjust drawer height on content size changes. + if let drawer = presentedVC { + drawer.transition(to: drawer.currentPosition) + } + } + + func updatePreferredContentSize() { + preferredContentSize = CGSize(width: preferredContentSize.width, height: scrollView.contentSize.height + verticalPadding) + } + + func toggleNoticeLock(_ locked: Bool) { + // only enable locking/unlocking notices on iPhone. Notices should always be shown in iPad since it's displayed in a popover view. + guard WPDeviceIdentification.isiPhone() else { + return + } + + ActionDispatcher.dispatch(locked ? NoticeAction.lock : NoticeAction.unlock) + } + + // MARK: Actions + + func switchValueChanged(_ sender: UISwitch) { + // nil delegate is most likely an implementation bug. For now, revert the changes on the switch button when this happens. + guard let delegate = delegate else { + DDLogInfo("\(Self.classNameWithoutNamespaces()): delegate instance is nil") + isNotificationEnabled = !sender.isOn + return + } + + // prevent spam clicks by disabling the user interaction on the switch button. + // the tint color is temporarily changed to indicate that some process is in progress. + switchButton.onTintColor = Style.switchInProgressTintColor + switchButton.isUserInteractionEnabled = false + + // optimistically update the views first. + isNotificationEnabled = sender.isOn + + delegate.didToggleNotificationSwitch(sender.isOn) { success in + if !success { + // in case of failure, revert state changes. + self.isNotificationEnabled = !sender.isOn + } + self.switchButton.onTintColor = Style.switchOnTintColor + self.switchButton.isUserInteractionEnabled = true + } + } + + func unfollowButtonTapped(_ sender: UIButton) { + dismiss(animated: true) { + self.delegate?.didTapUnfollowConversation() + + // On iPad, the view is displayed with a popover. Since the dismiss is called programmatically, it will not trigger `handleDismiss` + // properly, causing the Notice to be forever locked. `handleDismiss` is called here to prevent such event from happening. + // + // Consecutive calls to the NoticeAction's `lock` or `unlock` does nothing if they're already in the desired state, so calling + // `handleDismiss` multiple times should be fine. + self.handleDismiss() + } + } +} + +// MARK: - Localization + +private extension String { + static let descriptionTextForDisabledNotifications = NSLocalizedString("You’re following this conversation. " + + "You will receive an email whenever a new comment is made.", + comment: "Describes the expected behavior when the user enables in-app " + + "notifications in Reader Comments.") + static let descriptionTextForEnabledNotifications = NSLocalizedString("You’re following this conversation. " + + "You will receive an email and a notification whenever a new comment is made.", + comment: "Describes the expected behavior when the user disables in-app " + + "notifications in Reader Comments.") + static let notificationSwitchLabelText = NSLocalizedString("Enable in-app notifications", + comment: "Describes a switch component that toggles in-app notifications for a followed post.") + static let unfollowButtonTitle = NSLocalizedString("Unfollow Conversation", + comment: "Title for a button that unsubscribes the user from the post.") +} diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.h b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.h new file mode 100644 index 000000000000..07b2c381e0d2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.h @@ -0,0 +1,39 @@ +#import <UIKit/UIKit.h> + +// Used for event tracking source property +// to track where comments are viewed from. +typedef NS_ENUM(NSUInteger, ReaderCommentsSource) { + ReaderCommentsSourcePostCard, + ReaderCommentsSourcePostDetails, + ReaderCommentsSourcePostDetailsComments, + ReaderCommentsSourceCommentNotification, + ReaderCommentsSourceCommentLikeNotification, + ReaderCommentsSourceMySiteComment, + ReaderCommentsSourceActivityLogDetail +}; + + +@class ReaderPost; + +@interface ReaderCommentsViewController : UIViewController + +@property (nonatomic, strong, readonly) ReaderPost *post; +@property (nonatomic, assign, readwrite) BOOL allowsPushingPostDetails; +@property (nonatomic, assign, readwrite) ReaderCommentsSource source; + +- (void)setupWithPostID:(NSNumber *)postID siteID:(NSNumber *)siteID; + ++ (instancetype)controllerWithPost:(ReaderPost *)post source:(ReaderCommentsSource)source; ++ (instancetype)controllerWithPostID:(NSNumber *)postID siteID:(NSNumber *)siteID source:(ReaderCommentsSource)source; + +/// Opens the Add Comment when the view appears +@property (nonatomic) BOOL promptToAddComment; +/// Navigates to the specified comment when the view appears +@property (nonatomic, strong) NSNumber *navigateToCommentID; + + +// Comment moderation support. +@property (nonatomic, assign, readwrite) BOOL commentModified; +- (void)refreshAfterCommentModeration; + +@end diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.m b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.m new file mode 100644 index 000000000000..b13cbc3922f7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.m @@ -0,0 +1,1387 @@ +#import "ReaderCommentsViewController.h" + +#import "CommentService.h" +#import "CoreDataStack.h" +#import "ReaderPost.h" +#import "ReaderPostService.h" +#import "UIView+Subviews.h" +#import "WPImageViewController.h" +#import "WPTableViewHandler.h" +#import "SuggestionsTableView.h" +#import "WordPress-Swift.h" +#import "WPAppAnalytics.h" +#import <WordPressUI/WordPressUI.h> + +@class Comment; + +// NOTE: We want the cells to have a rather large estimated height. This avoids a peculiar +// crash in certain circumstances when the tableView lays out its visible cells, +// and those cells contain WPRichTextEmbeds. -- Aerych, 2016.11.30 +static CGFloat const EstimatedCommentRowHeight = 300.0; +static NSString *RestorablePostObjectIDURLKey = @"RestorablePostObjectIDURLKey"; +static NSString *CommentContentCellIdentifier = @"CommentContentTableViewCell"; + + +@interface ReaderCommentsViewController () <NSFetchedResultsControllerDelegate, + WPRichContentViewDelegate, // TODO: Remove once we switch to the `.web` rendering method. + ReplyTextViewDelegate, + UIViewControllerRestoration, + WPContentSyncHelperDelegate, + WPTableViewHandlerDelegate, + SuggestionsTableViewDelegate, + ReaderCommentsFollowPresenterDelegate> + +@property (nonatomic, strong, readwrite) ReaderPost *post; +@property (nonatomic, strong) NSNumber *postSiteID; +@property (nonatomic, strong) UIGestureRecognizer *tapOffKeyboardGesture; +@property (nonatomic, strong) UIActivityIndicatorView *activityFooter; +@property (nonatomic, strong) WPContentSyncHelper *syncHelper; +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) WPTableViewHandler *tableViewHandler; +@property (nonatomic, strong) NoResultsViewController *noResultsViewController; +@property (nonatomic, strong) ReplyTextView *replyTextView; +@property (nonatomic, strong) KeyboardDismissHelper *keyboardManager; +@property (nonatomic, strong) SuggestionsTableView *suggestionsTableView; +@property (nonatomic, strong) NSIndexPath *indexPathForCommentRepliedTo; +@property (nonatomic, strong) NSLayoutConstraint *replyTextViewHeightConstraint; +@property (nonatomic, strong) NSLayoutConstraint *replyTextViewBottomConstraint; +@property (nonatomic, strong) NSCache *estimatedRowHeights; +@property (nonatomic) BOOL isLoggedIn; +@property (nonatomic) BOOL needsUpdateAttachmentsAfterScrolling; +@property (nonatomic) BOOL needsRefreshTableViewAfterScrolling; +@property (nonatomic) BOOL failedToFetchComments; +@property (nonatomic) BOOL deviceIsRotating; +@property (nonatomic) BOOL userInterfaceStyleChanged; +@property (nonatomic, strong) NSCache *cachedAttributedStrings; +@property (nonatomic, strong) FollowCommentsService *followCommentsService; +@property (nonatomic, strong) ReaderCommentsFollowPresenter *readerCommentsFollowPresenter; +@property (nonatomic, strong) UIBarButtonItem *followBarButtonItem; +@property (nonatomic, strong) UIBarButtonItem *subscriptionSettingsBarButtonItem; + +/// A cached instance for the new comment header view. +@property (nonatomic, strong) UIView *cachedHeaderView; + +/// Convenience computed variable that returns a separator inset that "hides" the separator by pushing it off the screen. +@property (nonatomic, assign) UIEdgeInsets hiddenSeparatorInsets; + +@property (nonatomic, strong) NSIndexPath *highlightedIndexPath; + +@end + + +@implementation ReaderCommentsViewController + +#pragma mark - Static Helpers + ++ (instancetype)controllerWithPost:(ReaderPost *)post source:(ReaderCommentsSource)source +{ + ReaderCommentsViewController *controller = [[self alloc] init]; + controller.post = post; + controller.source = source; + return controller; +} + ++ (instancetype)controllerWithPostID:(NSNumber *)postID siteID:(NSNumber *)siteID source:(ReaderCommentsSource)source +{ + ReaderCommentsViewController *controller = [[self alloc] init]; + [controller setupWithPostID:postID siteID:siteID]; + [controller trackCommentsOpenedWithPostID:postID siteID:siteID source:source]; + return controller; +} + + +#pragma mark - State Restoration + ++ (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)identifierComponents coder:(NSCoder *)coder +{ + NSString *path = [coder decodeObjectForKey:RestorablePostObjectIDURLKey]; + if (!path) { + return nil; + } + + NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; + NSManagedObjectID *objectID = [context.persistentStoreCoordinator managedObjectIDForURIRepresentation:[NSURL URLWithString:path]]; + if (!objectID) { + return nil; + } + + NSError *error = nil; + ReaderPost *restoredPost = (ReaderPost *)[context existingObjectWithID:objectID error:&error]; + if (error || !restoredPost) { + return nil; + } + + return [self controllerWithPost:restoredPost source:ReaderCommentsSourcePostDetails]; +} + +- (void)encodeRestorableStateWithCoder:(NSCoder *)coder +{ + [coder encodeObject:[[self.post.objectID URIRepresentation] absoluteString] forKey:RestorablePostObjectIDURLKey]; + [super encodeRestorableStateWithCoder:coder]; +} + + +#pragma mark - LifeCycle Methods + +- (instancetype)init +{ + self = [super init]; + if (self) { + self.restorationIdentifier = NSStringFromClass([self class]); + self.restorationClass = [self class]; + } + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.view.backgroundColor = [UIColor murielBasicBackground]; + self.commentModified = NO; + + [self checkIfLoggedIn]; + + [self configureNavbar]; + [self configureTableView]; + [self configureTableViewHandler]; + [self configureNoResultsView]; + [self configureReplyTextView]; + [self configureSuggestionsTableView]; + [self configureKeyboardGestureRecognizer]; + [self configureViewConstraints]; + [self configureKeyboardManager]; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [self.keyboardManager startListeningToKeyboardNotifications]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleApplicationDidBecomeActive:) + name:UIApplicationDidBecomeActiveNotification + object:nil]; + + [self refreshAndSync]; +} + +- (void)viewDidAppear:(BOOL)animated +{ + [super viewDidAppear:animated]; + [self.tableView reloadData]; + + if (self.promptToAddComment) { + [self.replyTextView becomeFirstResponder]; + + // Reset the value to prevent prompting again if the user leaves and comes back + self.promptToAddComment = NO; + } +} + +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + [self dismissNotice]; + + if (self.commentModified) { + // Don't post the notification until the view is being dismissed to avoid purging cached comments prematurely. + [self postCommentModifiedNotification]; + } + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunused-result" + [self.replyTextView resignFirstResponder]; +#pragma clang diagnostic pop + [self.keyboardManager stopListeningToKeyboardNotifications]; + [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidBecomeActiveNotification object:nil]; +} + +- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator +{ + [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; + self.deviceIsRotating = true; + + [coordinator animateAlongsideTransition:nil completion:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull __unused context) { + self.deviceIsRotating = false; + NSIndexPath *selectedIndexPath = [self.tableView indexPathForSelectedRow]; + // Make sure a selected comment is visible after rotating, and that the replyTextView is still the first responder. + if (selectedIndexPath) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunused-result" + [self.replyTextView becomeFirstResponder]; +#pragma clang diagnostic pop + [self.tableView selectRowAtIndexPath:selectedIndexPath animated:NO scrollPosition:UITableViewScrollPositionNone]; + } + }]; +} + +- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection +{ + [super traitCollectionDidChange:previousTraitCollection]; + + // Update cached attributed strings when toggling light/dark mode. + self.userInterfaceStyleChanged = self.traitCollection.userInterfaceStyle != previousTraitCollection.userInterfaceStyle; + [self refreshTableViewAndNoResultsView]; +} + +#pragma mark - Split View Support + +/** + We need to refresh media layout when the app's size changes due the the user adjusting + the split view grip. Respond to the UIApplicationDidBecomeActiveNotification notification + dispatched when the grip is changed and refresh media layout. + */ +- (void)handleApplicationDidBecomeActive:(NSNotification *)notification +{ + [self.view layoutIfNeeded]; +} + +#pragma mark - Tracking methods + +-(void)trackCommentLikedOrUnliked:(Comment *) comment { + ReaderPost *post = self.post; + WPAnalyticsStat stat; + if (comment.isLiked) { + stat = WPAnalyticsStatReaderArticleCommentLiked; + } else { + stat = WPAnalyticsStatReaderArticleCommentUnliked; + } + + NSMutableDictionary *properties = [NSMutableDictionary dictionary]; + properties[WPAppAnalyticsKeyPostID] = post.postID; + properties[WPAppAnalyticsKeyBlogID] = post.siteID; + [WPAnalytics trackReaderStat:stat properties:properties]; +} + +-(void)trackReplyTo:(BOOL)replyTarget { + ReaderPost *post = self.post; + NSDictionary *railcar = post.railcarDictionary; + NSMutableDictionary *properties = [NSMutableDictionary dictionary]; + properties[WPAppAnalyticsKeyBlogID] = post.siteID; + properties[WPAppAnalyticsKeyPostID] = post.postID; + properties[WPAppAnalyticsKeyIsJetpack] = @(post.isJetpack); + properties[WPAppAnalyticsKeyReplyingTo] = replyTarget ? @"comment" : @"post"; + if (post.feedID && post.feedItemID) { + properties[WPAppAnalyticsKeyFeedID] = post.feedID; + properties[WPAppAnalyticsKeyFeedItemID] = post.feedItemID; + } + [WPAnalytics trackReaderStat:WPAnalyticsStatReaderArticleCommentedOn properties:properties]; + if (railcar) { + [WPAppAnalytics trackTrainTracksInteraction:WPAnalyticsStatTrainTracksInteract withProperties:railcar]; + } +} +#pragma mark - Configuration + +- (void)configureNavbar +{ + // Don't show 'Reader' in the next-view back button + UIBarButtonItem *backButton = [[UIBarButtonItem alloc] initWithTitle:@" " style:UIBarButtonItemStylePlain target:nil action:nil]; + self.navigationItem.backBarButtonItem = backButton; + + self.title = NSLocalizedString(@"Comments", @"Title of the reader's comments screen"); + self.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + + [self refreshFollowButton]; +} + +- (void)configureTableView +{ + self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain]; + self.tableView.translatesAutoresizingMaskIntoConstraints = NO; + self.tableView.cellLayoutMarginsFollowReadableWidth = YES; + self.tableView.preservesSuperviewLayoutMargins = YES; + self.tableView.backgroundColor = [UIColor murielBasicBackground]; + [self.view addSubview:self.tableView]; + + // register the content cell + UINib *nib = [UINib nibWithNibName:[CommentContentTableViewCell classNameWithoutNamespaces] bundle:nil]; + [self.tableView registerNib:nib forCellReuseIdentifier:CommentContentCellIdentifier]; + + // configure table view separator + self.tableView.separatorStyle = UITableViewCellSeparatorStyleSingleLine; + self.tableView.separatorInsetReference = UITableViewSeparatorInsetFromAutomaticInsets; + + // hide cell separator for the last row + self.tableView.tableFooterView = [self tableFooterViewForHiddenSeparators]; + + self.tableView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive; + + self.estimatedRowHeights = [[NSCache alloc] init]; + self.cachedAttributedStrings = [[NSCache alloc] init]; +} + +- (void)configureTableViewHandler +{ + self.tableViewHandler = [[WPTableViewHandler alloc] initWithTableView:self.tableView]; + self.tableViewHandler.updateRowAnimation = UITableViewRowAnimationNone; + self.tableViewHandler.insertRowAnimation = UITableViewRowAnimationNone; + self.tableViewHandler.moveRowAnimation = UITableViewRowAnimationNone; + self.tableViewHandler.deleteRowAnimation = UITableViewRowAnimationNone; + self.tableViewHandler.delegate = self; + [self.tableViewHandler setListensForContentChanges:NO]; +} + +- (void)configureNoResultsView +{ + self.noResultsViewController = [NoResultsViewController controller]; +} + +- (void)configureReplyTextView +{ + __typeof(self) __weak weakSelf = self; + + ReplyTextView *replyTextView = [[ReplyTextView alloc] initWithWidth:CGRectGetWidth(self.view.frame)]; + replyTextView.onReply = ^(NSString *content) { + [weakSelf sendReplyWithNewContent:content]; + }; + replyTextView.delegate = self; + self.replyTextView = replyTextView; + + [self refreshReplyTextViewPlaceholder]; + + [self.view addSubview:self.replyTextView]; + [self.view bringSubviewToFront:self.replyTextView]; +} + +- (void)configureSuggestionsTableView +{ + NSNumber *siteID = self.siteID; + NSParameterAssert(siteID); + + self.suggestionsTableView = [[SuggestionsTableView alloc] initWithSiteID:siteID suggestionType:SuggestionTypeMention delegate:self]; + [self.suggestionsTableView setTranslatesAutoresizingMaskIntoConstraints:NO]; + [self.view addSubview:self.suggestionsTableView]; +} + +- (void)configureKeyboardGestureRecognizer +{ + self.tapOffKeyboardGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapRecognized:)]; + self.tapOffKeyboardGesture.enabled = NO; + [self.view addGestureRecognizer:self.tapOffKeyboardGesture]; +} + +- (void)configureKeyboardManager +{ + self.keyboardManager = [[KeyboardDismissHelper alloc] initWithParentView:self.view + scrollView:self.tableView + dismissableControl:self.replyTextView + bottomLayoutConstraint:self.replyTextViewBottomConstraint]; + + __weak UITableView *weakTableView = self.tableView; + __weak ReaderCommentsViewController *weakSelf = self; + self.keyboardManager.onWillHide = ^{ + [weakTableView deselectSelectedRowWithAnimation:YES]; + [weakSelf refreshNoResultsView]; + }; + self.keyboardManager.onWillShow = ^{ + [weakSelf refreshNoResultsView]; + }; +} + +#pragma mark - Autolayout Helpers + +- (void)configureViewConstraints +{ + NSMutableDictionary *views = [[NSMutableDictionary alloc] initWithDictionary:@{ + @"tableView" : self.tableView, + @"mainView" : self.view, + @"suggestionsview" : self.suggestionsTableView, + @"replyTextView" : self.replyTextView + }]; + + NSString *verticalVisualFormatString = @"V:|[tableView][replyTextView]"; + + // TableView Contraints + [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:verticalVisualFormatString + options:0 + metrics:nil + views:views]]; + + [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"|[tableView]|" + options:0 + metrics:nil + views:views]]; + + // ReplyTextView Constraints + [[self.replyTextView.leftAnchor constraintEqualToAnchor:self.tableView.leftAnchor] setActive:YES]; + [[self.replyTextView.rightAnchor constraintEqualToAnchor:self.tableView.rightAnchor] setActive:YES]; + + self.replyTextViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.view + attribute:NSLayoutAttributeBottom + relatedBy:NSLayoutRelationEqual + toItem:self.replyTextView + attribute:NSLayoutAttributeBottom + multiplier:1.0 + constant:0.0]; + self.replyTextViewBottomConstraint.priority = UILayoutPriorityDefaultHigh; + + [self.view addConstraint:self.replyTextViewBottomConstraint]; + + // Suggestions Constraints + // Pin the suggestions view left and right edges to the reply view edges + [self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.suggestionsTableView + attribute:NSLayoutAttributeLeft + relatedBy:NSLayoutRelationEqual + toItem:self.replyTextView + attribute:NSLayoutAttributeLeft + multiplier:1.0 + constant:0.0]]; + + [self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.suggestionsTableView + attribute:NSLayoutAttributeRight + relatedBy:NSLayoutRelationEqual + toItem:self.replyTextView + attribute:NSLayoutAttributeRight + multiplier:1.0 + constant:0.0]]; + + [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[suggestionsview][replyTextView]" + options:0 + metrics:nil + views:views]]; + + // TODO: + // This LayoutConstraint is just a helper, meant to hide / display the ReplyTextView, as needed. + // Whenever iOS 8 is set as the deployment target, let's always attach this one, and enable / disable it as needed! + self.replyTextViewHeightConstraint = [NSLayoutConstraint constraintWithItem:self.replyTextView + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationEqual + toItem:nil + attribute:0 + multiplier:1 + constant:0]; +} + + +#pragma mark - Helpers + +- (NSString *)noResultsTitleText +{ + // Let's just display the same message, for consistency's sake + if (self.isLoadingPost || self.syncHelper.isSyncing) { + return NSLocalizedString(@"Fetching comments...", @"A brief prompt shown when the comment list is empty, letting the user know the app is currently fetching new comments."); + } + // If we couldn't fetch the comments lets let the user know + if (self.failedToFetchComments) { + return NSLocalizedString(@"There has been an unexpected error while loading the comments.", @"Message shown when comments for a post can not be loaded."); + } + return NSLocalizedString(@"Be the first to leave a comment.", @"Message shown encouraging the user to leave a comment on a post in the reader."); +} + +- (UIView *)noResultsAccessoryView +{ + UIView *loadingAccessoryView = nil; + if (self.isLoadingPost || self.syncHelper.isSyncing) { + loadingAccessoryView = [NoResultsViewController loadingAccessoryView]; + } + return loadingAccessoryView; +} + +- (void)checkIfLoggedIn +{ + self.isLoggedIn = [AccountHelper isDotcomAvailable]; +} + +- (UIView *)tableFooterViewForHiddenSeparators +{ + return [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.tableView.frame.size.width, 0)]; +} + +- (void)setHighlightedIndexPath:(NSIndexPath *)highlightedIndexPath +{ + if (_highlightedIndexPath) { + CommentContentTableViewCell *previousCell = (CommentContentTableViewCell *)[self.tableView cellForRowAtIndexPath:_highlightedIndexPath]; + previousCell.isEmphasized = NO; + } + + if (highlightedIndexPath) { + CommentContentTableViewCell *cell = (CommentContentTableViewCell *)[self.tableView cellForRowAtIndexPath:highlightedIndexPath]; + cell.isEmphasized = YES; + } + + _highlightedIndexPath = highlightedIndexPath; +} + +- (void)setIndexPathForCommentRepliedTo:(NSIndexPath *)indexPathForCommentRepliedTo +{ + // un-highlight the cell if a highlighted Reply button is tapped. + if (_indexPathForCommentRepliedTo && indexPathForCommentRepliedTo && _indexPathForCommentRepliedTo == indexPathForCommentRepliedTo) { + [self tapRecognized:nil]; + return; + } + + if (_indexPathForCommentRepliedTo) { + CommentContentTableViewCell *previousCell = (CommentContentTableViewCell *)[self.tableView cellForRowAtIndexPath:_indexPathForCommentRepliedTo]; + previousCell.isReplyHighlighted = NO; + } + + if (indexPathForCommentRepliedTo) { + CommentContentTableViewCell *cell = (CommentContentTableViewCell *)[self.tableView cellForRowAtIndexPath:indexPathForCommentRepliedTo]; + cell.isReplyHighlighted = YES; + } + + self.highlightedIndexPath = indexPathForCommentRepliedTo; + _indexPathForCommentRepliedTo = indexPathForCommentRepliedTo; + + [self refreshProminentSuggestions]; +} + +- (UIView *)cachedHeaderView { + if (!_cachedHeaderView) { + _cachedHeaderView = [self configuredHeaderViewFor:self.tableView]; + } + + return _cachedHeaderView; +} + +- (UIBarButtonItem *)followBarButtonItem +{ + if (!_followBarButtonItem) { + _followBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Follow", @"Button title. Follow the comments on a post.") + style:UIBarButtonItemStylePlain + target:self + action:@selector(handleFollowConversationButtonTapped)]; + } + + return _followBarButtonItem; +} + +- (UIBarButtonItem *)subscriptionSettingsBarButtonItem +{ + if (!_subscriptionSettingsBarButtonItem) { + _subscriptionSettingsBarButtonItem = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"bell"] + style:UIBarButtonItemStylePlain + target:self + action:@selector(subscriptionSettingsButtonTapped)]; + _subscriptionSettingsBarButtonItem.accessibilityHint = NSLocalizedString(@"Open subscription settings for the post", + @"VoiceOver hint. Informs the user that the button allows the user to access " + + "post subscription settings."); + } + + return _subscriptionSettingsBarButtonItem; +} + +/// NOTE: In order for the inset to work across orientations, the tableView should use `UITableViewSeparatorInsetFromAutomaticInsets` to +/// base the separator insets on the cell layout margins instead of the edges. +/// +/// With the default inset reference (i.e. `UITableViewSeparatorInsetFromCellEdges`), sometimes the cell configuration is called before the +/// orientation animation is completed – and this caused the computed separator insets to intermittently return the wrong table view size. +/// +- (UIEdgeInsets)hiddenSeparatorInsets { + CGFloat rightInset = CGRectGetWidth(self.tableView.frame); + + // Add an extra inset for landscape iPad (without a split view) where the separator does reach the trailing edge. + // Otherwise, after orientation the inset may not be enough to hide the separator. + if (self.view.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassRegular) { + rightInset -= self.tableView.separatorInset.left; + } + + // Note: no need to flip the insets manually for RTL layout. The system will automatically take care of this. + return UIEdgeInsetsMake(0, -self.tableView.separatorInset.left, 0, rightInset); +} + +/// Determines whether a separator should be drawn for the provided index path. +/// The method returns YES if the index path represent a comment that is placed before a top-level comment. +/// +/// Example: +/// +/// - comment 1 +/// - comment 2 +/// - comment 3 --> returns YES. +/// - comment 4 +/// - comment 5 +/// - comment 6 +/// - comment 7 +/// - comment 8 --> returns YES. +/// - comment 9 +/// +- (BOOL)shouldShowSeparatorForIndexPath:(NSIndexPath *)indexPath +{ + NSIndexPath *nextIndexPath = [NSIndexPath indexPathForRow:indexPath.row + 1 inSection:indexPath.section]; + NSArray<id<NSFetchedResultsSectionInfo>> *sections = self.tableViewHandler.resultsController.sections; + + if (sections && sections[indexPath.section] && nextIndexPath.row < sections[indexPath.section].numberOfObjects) { + Comment *nextComment = [self.tableViewHandler.resultsController objectAtIndexPath:nextIndexPath]; + return [nextComment isTopLevelComment]; + } + + return NO; +} + +#pragma mark - Accessor methods + +- (void)setPost:(ReaderPost *)post +{ + if (post == _post) { + return; + } + + _post = post; + + if (_post.isWPCom || _post.isJetpack) { + self.syncHelper = [[WPContentSyncHelper alloc] init]; + self.syncHelper.delegate = self; + } + + _followCommentsService = [FollowCommentsService createServiceWith:_post]; + _readerCommentsFollowPresenter = [[ReaderCommentsFollowPresenter alloc] initWithPost:_post delegate:self presentingViewController:self]; +} + +- (NSNumber *)siteID +{ + // If the post isn't loaded yet, maybe we're asynchronously retrieving it? + return self.post.siteID ?: self.postSiteID; +} + +- (UIActivityIndicatorView *)activityFooter +{ + if (_activityFooter) { + return _activityFooter; + } + + _activityFooter = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + _activityFooter.activityIndicatorViewStyle = UIActivityIndicatorViewStyleMedium; + _activityFooter.hidesWhenStopped = YES; + _activityFooter.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin; + [_activityFooter stopAnimating]; + + return _activityFooter; +} + +- (BOOL)isLoadingPost +{ + return self.post == nil; +} + +- (BOOL)canComment +{ + return self.post.commentsOpen && self.isLoggedIn; +} + +- (BOOL)canFollowConversation +{ + return [self.followCommentsService canFollowConversation]; +} + +- (BOOL)shouldDisplayReplyTextView +{ + return self.canComment; +} + +- (BOOL)shouldDisplaySuggestionsTableView +{ + return self.shouldDisplayReplyTextView && [self shouldShowSuggestionsFor:self.post.siteID]; +} + +#pragma mark - View Refresh Helpers + +- (void)refreshAndSync +{ + [self refreshFollowButton]; + [self refreshSubscriptionStatusIfNeeded]; + [self refreshReplyTextView]; + [self refreshSuggestionsTableView]; + [self refreshInfiniteScroll]; + [self refreshTableViewAndNoResultsView]; + [self.syncHelper syncContent]; +} + +- (void)refreshFollowButton +{ + if (!self.canFollowConversation) { + return; + } + + self.navigationItem.rightBarButtonItem = self.post.isSubscribedComments ? self.subscriptionSettingsBarButtonItem : self.followBarButtonItem; +} + +- (void)refreshSubscriptionStatusIfNeeded +{ + __weak __typeof(self) weakSelf = self; + [self.followCommentsService fetchSubscriptionStatusWithSuccess:^(BOOL isSubscribed) { + // update the ReaderPost button to keep it in-sync. + weakSelf.post.isSubscribedComments = isSubscribed; + [ContextManager.sharedInstance saveContext:weakSelf.post.managedObjectContext]; + } failure:^(NSError *error) { + DDLogError(@"Error fetching subscription status for post: %@", error); + }]; +} + +- (void)refreshReplyTextView +{ + BOOL showsReplyTextView = self.shouldDisplayReplyTextView; + self.replyTextView.hidden = !showsReplyTextView; + + if (showsReplyTextView) { + [self.view removeConstraint:self.replyTextViewHeightConstraint]; + } else { + [self.view addConstraint:self.replyTextViewHeightConstraint]; + } +} + +- (void)refreshSuggestionsTableView +{ + self.suggestionsTableView.enabled = self.shouldDisplaySuggestionsTableView; + [self refreshProminentSuggestions]; +} + +- (void)refreshProminentSuggestions +{ + NSIndexPath *commentIndexPath = self.indexPathForCommentRepliedTo; + WPAccount *defaultAccount = [WPAccount lookupDefaultWordPressComAccountInContext:self.managedObjectContext]; + NSNumber *defaultAccountId = defaultAccount ? defaultAccount.userID : nil; + NSNumber *postAuthorId = self.post ? self.post.authorID : nil; + Comment *comment = commentIndexPath ? [self.tableViewHandler.resultsController objectAtIndexPath:commentIndexPath] : nil; + NSNumber *commentAuthorId = comment ? [NSNumber numberWithInt:comment.authorID] : nil; + self.suggestionsTableView.prominentSuggestionsIds = [SuggestionsTableView prominentSuggestionsFromPostAuthorId:postAuthorId + commentAuthorId:commentAuthorId + defaultAccountId:defaultAccountId]; +} + +- (void)refreshReplyTextViewPlaceholder +{ + if (self.tableView.indexPathForSelectedRow) { + Comment *comment = [self.tableViewHandler.resultsController objectAtIndexPath:self.indexPathForCommentRepliedTo]; + NSString *placeholderFormat = NSLocalizedString(@"Reply to %1$@", @"Placeholder text for replying to a comment. %1$@ is a placeholder for the comment author's name."); + self.replyTextView.placeholder = [NSString stringWithFormat:placeholderFormat, [comment authorForDisplay]]; + } else { + self.replyTextView.accessibilityIdentifier = @"reply-to-post-text-field"; + self.replyTextView.placeholder = NSLocalizedString(@"Reply to post", @"Placeholder text for replying to a post"); + } +} + +- (void)refreshInfiniteScroll +{ + if (self.syncHelper.hasMoreContent) { + CGFloat width = CGRectGetWidth(self.tableView.bounds); + UIView *footerView = [[UIView alloc] initWithFrame:CGRectMake(0.0f, 0.0f, width, 50.0f)]; + footerView.autoresizingMask = UIViewAutoresizingFlexibleWidth; + CGRect rect = self.activityFooter.frame; + rect.origin.x = (width - rect.size.width) / 2.0; + self.activityFooter.frame = rect; + + [footerView addSubview:self.activityFooter]; + self.tableView.tableFooterView = footerView; + + } else { + self.tableView.tableFooterView = [self tableFooterViewForHiddenSeparators]; + self.activityFooter = nil; + } +} + +- (void)refreshNoResultsView +{ + // During rotation, the keyboard hides and shows. + // To prevent view flashing, do nothing until rotation is finished. + if (self.deviceIsRotating) { + return; + } + + [self.noResultsViewController removeFromView]; + + BOOL isTableViewEmpty = (self.tableViewHandler.resultsController.fetchedObjects.count == 0); + if (!isTableViewEmpty) { + return; + } + + // Because the replyTextView grows, limit what is displayed with the keyboard visible: + // iPhone landscape: show nothing. + // iPhone portrait: hide the image. + // iPad landscape: hide the image. + + BOOL isLandscape = UIDevice.currentDevice.orientation != UIDeviceOrientationPortrait; + BOOL hideImageView = false; + if (self.keyboardManager.isKeyboardVisible) { + + if (WPDeviceIdentification.isiPhone && isLandscape) { + return; + } + + hideImageView = (WPDeviceIdentification.isiPhone && !isLandscape) || (WPDeviceIdentification.isiPad && isLandscape); + } + [self.noResultsViewController configureWithTitle:self.noResultsTitleText + attributedTitle:nil + noConnectionTitle:nil + buttonTitle:nil + subtitle:nil + noConnectionSubtitle:nil + attributedSubtitle:nil + attributedSubtitleConfiguration:nil + image:nil + subtitleImage:nil + accessoryView:[self noResultsAccessoryView]]; + + [self.noResultsViewController hideImageView:hideImageView]; + [self addChildViewController:self.noResultsViewController]; + + // when the table view is not yet properly initialized, use the view's frame instead to prevent wrong frame values. + if (self.tableView.window == nil) { + self.noResultsViewController.view.frame = self.view.frame; + } else { + self.noResultsViewController.view.frame = self.tableView.frame; + } + + [self.view insertSubview:self.noResultsViewController.view belowSubview:self.replyTextView]; + [self.noResultsViewController didMoveToParentViewController:self]; +} + +- (void)refreshAfterCommentModeration +{ + [self.tableViewHandler refreshTableView]; + [self refreshNoResultsView]; +} + +- (void)updateTableViewForAttachments +{ + [self.tableView performBatchUpdates:nil completion:nil]; +} + + +- (void)refreshTableViewAndNoResultsView +{ + [self.tableViewHandler refreshTableView]; + [self refreshNoResultsView]; + [self.managedObjectContext performBlock:^{ + [self updateCachedContent]; + }]; + + [self navigateToCommentIDIfNeeded]; +} + +- (void)updateCachedContent +{ + NSArray *comments = self.tableViewHandler.resultsController.fetchedObjects; + for(Comment *comment in comments) { + [self cacheContentForComment:comment]; + } +} + + +- (NSAttributedString *)cacheContentForComment:(Comment *)comment +{ + NSAttributedString *attrStr = [self.cachedAttributedStrings objectForKey:[NSNumber numberWithInt:comment.commentID]]; + if (!attrStr || self.userInterfaceStyleChanged == YES) { + attrStr = [WPRichContentView formattedAttributedStringForString: comment.content]; + [self.cachedAttributedStrings setObject:attrStr forKey:[NSNumber numberWithInt:comment.commentID]]; + } + return attrStr; +} + +/// If we've been provided with a comment ID on initialization, then this +/// method locates that comment and scrolls the tableview to display it. +- (void)navigateToCommentIDIfNeeded +{ + if (self.navigateToCommentID != nil) { + // Find the comment if it exists + NSArray<Comment *> *comments = [self.tableViewHandler.resultsController fetchedObjects]; + NSArray<Comment *> *filteredComments = [comments filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"commentID == %@", self.navigateToCommentID]]; + Comment *comment = [filteredComments firstObject]; + + if (!comment) { + return; + } + + // Force the table view to be laid out first before scrolling to indexPath. + // This avoids a case where a cell instance could be orphaned and displayed randomly on top of the other cells. + NSIndexPath *indexPath = [self.tableViewHandler.resultsController indexPathForObject:comment]; + [self.tableView layoutIfNeeded]; + [self.tableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionTop animated:YES]; + + self.highlightedIndexPath = indexPath; + + // Reset the commentID so we don't do this again. + self.navigateToCommentID = nil; + } +} + +#pragma mark - Actions + +- (void)tapRecognized:(id)sender +{ + self.tapOffKeyboardGesture.enabled = NO; + self.indexPathForCommentRepliedTo = nil; + [self.tableView deselectSelectedRowWithAnimation:YES]; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunused-result" + [self.replyTextView resignFirstResponder]; +#pragma clang diagnostic pop + [self refreshReplyTextViewPlaceholder]; +} + +- (void)sendReplyWithNewContent:(NSString *)content +{ + __typeof(self) __weak weakSelf = self; + + BOOL replyToComment = self.indexPathForCommentRepliedTo != nil; + UINotificationFeedbackGenerator *generator = [UINotificationFeedbackGenerator new]; + [generator prepare]; + + void (^successBlock)(void) = ^void() { + [generator notificationOccurred:UINotificationFeedbackTypeSuccess]; + NSString *successMessage = NSLocalizedString(@"Reply Sent!", @"The app successfully sent a comment"); + [weakSelf displayNoticeWithTitle:successMessage message:nil]; + + [weakSelf trackReplyTo:replyToComment]; + [weakSelf.tableView deselectSelectedRowWithAnimation:YES]; + [weakSelf refreshReplyTextViewPlaceholder]; + + // Dispatch is used here to address an issue in iOS 15 where some cells could disappear from the screen after `reloadData`. + // This seems to be affecting the Simulator environment only since I couldn't reproduce it on the device, but I'm fixing it just in case. + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf refreshTableViewAndNoResultsView]; + }); + }; + + void (^failureBlock)(NSError *error) = ^void(NSError *error) { + DDLogError(@"Error sending reply: %@", error); + [generator notificationOccurred:UINotificationFeedbackTypeError]; + NSString *message = NSLocalizedString(@"There has been an unexpected error while sending your reply", "Reply Failure Message"); + [weakSelf displayNoticeWithTitle:message message:nil]; + + [weakSelf refreshTableViewAndNoResultsView]; + }; + + CommentService *service = [[CommentService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + + if (replyToComment) { + Comment *comment = [self.tableViewHandler.resultsController objectAtIndexPath:self.indexPathForCommentRepliedTo]; + [service replyToHierarchicalCommentWithID:[NSNumber numberWithInt:comment.commentID] + post:self.post + content:content + success:successBlock + failure:failureBlock]; + } else { + [service replyToPost:self.post + content:content + success:successBlock + failure:failureBlock]; + } + self.indexPathForCommentRepliedTo = nil; +} + +- (void)didTapReplyAtIndexPath:(NSIndexPath *)indexPath +{ + if (!indexPath || !self.canComment) { + return; + } + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunused-result" + [self.replyTextView becomeFirstResponder]; +#pragma clang diagnostic pop + + self.indexPathForCommentRepliedTo = indexPath; + [self.tableView selectRowAtIndexPath:self.indexPathForCommentRepliedTo animated:YES scrollPosition:UITableViewScrollPositionTop]; + [self refreshReplyTextViewPlaceholder]; +} + +- (void)didTapLikeForComment:(Comment *)comment atIndexPath:(NSIndexPath *)indexPath +{ + CommentService *commentService = [[CommentService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + + if (!comment.isLiked) { + [[UINotificationFeedbackGenerator new] notificationOccurred:UINotificationFeedbackTypeSuccess]; + } + + __typeof(self) __weak weakSelf = self; + [commentService toggleLikeStatusForComment:comment siteID:self.post.siteID success:^{ + [weakSelf trackCommentLikedOrUnliked:comment]; + } failure:^(NSError * __unused error) { + // in case of failure, revert the cell's like state. + [weakSelf.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationAutomatic]; + }]; +} + +#pragma mark - Sync methods + +- (void)syncHelper:(WPContentSyncHelper *)syncHelper syncContentWithUserInteraction:(BOOL)userInteraction success:(void (^)(BOOL))success failure:(void (^)(NSError *))failure +{ + self.failedToFetchComments = NO; + + CommentService *service = [[CommentService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + [service syncHierarchicalCommentsForPost:self.post page:1 success:^(BOOL hasMore, NSNumber * __unused totalComments) { + if (success) { + success(hasMore); + } + } failure:failure]; + + [self refreshNoResultsView]; +} + +- (void)syncHelper:(WPContentSyncHelper *)syncHelper syncMoreWithSuccess:(void (^)(BOOL))success failure:(void (^)(NSError *))failure +{ + self.failedToFetchComments = NO; + [self.activityFooter startAnimating]; + + CommentService *service = [[CommentService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + NSInteger page = [service numberOfHierarchicalPagesSyncedforPost:self.post] + 1; + [service syncHierarchicalCommentsForPost:self.post page:page success:^(BOOL hasMore, NSNumber * __unused totalComments) { + if (success) { + success(hasMore); + } + } failure:failure]; +} + +- (void)syncContentEnded:(WPContentSyncHelper *)syncHelper +{ + [self.activityFooter stopAnimating]; + if ([self.tableViewHandler isScrolling]) { + self.needsRefreshTableViewAfterScrolling = YES; + return; + } + + [self refreshTableViewAndNoResultsView]; +} + +- (void)syncContentFailed:(WPContentSyncHelper *)syncHelper +{ + self.failedToFetchComments = YES; + [self.activityFooter stopAnimating]; + [self refreshTableViewAndNoResultsView]; +} + +#pragma mark - Async Loading Helpers + +- (void)setupWithPostID:(NSNumber *)postID siteID:(NSNumber *)siteID +{ + ReaderPostService *service = [[ReaderPostService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + __weak __typeof(self) weakSelf = self; + + self.postSiteID = siteID; + + [service fetchPost:postID.integerValue forSite:siteID.integerValue isFeed:NO success:^(ReaderPost *post) { + + [weakSelf setPost:post]; + [weakSelf refreshAndSync]; + + } failure:^(NSError *error) { + DDLogError(@"[RestAPI] %@", error); + }]; +} + + +#pragma mark - UITableView Delegate Methods + +- (NSManagedObjectContext *)managedObjectContext +{ + return [[ContextManager sharedInstance] mainContext]; +} + +- (NSFetchRequest *)fetchRequest +{ + if (!self.post) { + return nil; + } + + // Moderated comments could still be cached, so filter out non-approved comments. + NSString *approvedStatus = [Comment descriptionFor:CommentStatusTypeApproved]; + + NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:NSStringFromClass([Comment class])]; + fetchRequest.predicate = [NSPredicate predicateWithFormat:@"post = %@ AND status = %@ AND visibleOnReader = %@", self.post, approvedStatus, @YES]; + NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"hierarchy" ascending:YES]; + [fetchRequest setSortDescriptors:@[sortDescriptor]]; + + return fetchRequest; +} + +- (void)configureCell:(UITableViewCell *)aCell atIndexPath:(NSIndexPath *)indexPath +{ + // When backgrounding, the app takes a snapshot, which triggers a layout pass, + // which refreshes the cells, and for some reason triggers an assertion failure + // in NSMutableAttributedString(data:,options:,documentAttributes:) when + // the NSDocumentTypeDocumentAttribute option is NSHTMLTextDocumentType. + // *** Assertion failure in void _prepareForCAFlush(UIApplication *__strong)(), + // /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3600.6.21/UIApplication.m:2377 + // *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', + // reason: 'unexpected start state' + // This seems like a framework bug, so to avoid it skip configuring cells + // while the app is backgrounded. + if ([[UIApplication sharedApplication] applicationState] == UIApplicationStateBackground) { + return; + } + + Comment *comment = [self.tableViewHandler.resultsController objectAtIndexPath:indexPath]; + CommentContentTableViewCell *cell = (CommentContentTableViewCell *)aCell; + [self configureContentCell:cell comment:comment indexPath:indexPath handler:self.tableViewHandler]; + + if (self.highlightedIndexPath) { + cell.isEmphasized = (indexPath == self.highlightedIndexPath); + } + + if (self.indexPathForCommentRepliedTo) { + cell.isReplyHighlighted = (indexPath == self.indexPathForCommentRepliedTo); + } + + // support for legacy content rendering method. + cell.richContentDelegate = self; + + // show separator when the comment is the "last leaf" of its top-level comment. + cell.separatorInset = [self shouldShowSeparatorForIndexPath:indexPath] ? UIEdgeInsetsZero : self.hiddenSeparatorInsets; + + // configure button actions. + __weak __typeof(self) weakSelf = self; + + cell.accessoryButtonAction = ^(UIView * _Nonnull sourceView) { + if (comment && [self isModerationMenuEnabledFor:comment]) { + // NOTE: Remove when minimum version is bumped to iOS 14. + [self showMenuSheetFor:comment indexPath:indexPath handler:weakSelf.tableViewHandler sourceView:sourceView]; + } else { + [self shareComment:comment sourceView:sourceView]; + } + }; + + cell.replyButtonAction = ^{ + [weakSelf didTapReplyAtIndexPath:indexPath]; + }; + + cell.likeButtonAction = ^{ + [weakSelf didTapLikeForComment:comment atIndexPath:indexPath]; + }; + + cell.contentLinkTapAction = ^(NSURL * _Nonnull url) { + [weakSelf interactWithURL:url]; + }; +} + +- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + // NOTE: When using a `CommentContentTableViewCell` with `.web` rendering method, this method needs to return `UITableViewAutomaticDimension`. + // Using cached estimated heights could get some cells to keep reloading their HTMLs indefinitely, causing the app to hang! + + NSNumber *cachedHeight = [self.estimatedRowHeights objectForKey:indexPath]; + if (cachedHeight.doubleValue) { + return cachedHeight.doubleValue; + } + return EstimatedCommentRowHeight; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath +{ + return UITableViewAutomaticDimension; +} + +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section +{ + return self.cachedHeaderView; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + NSString *cellIdentifier = CommentContentCellIdentifier; + UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:cellIdentifier forIndexPath:indexPath]; + [self configureCell:cell atIndexPath:indexPath]; + return cell; +} + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +{ + [self.tableView deselectRowAtIndexPath:indexPath animated:NO]; +} + +- (BOOL)tableView:(UITableView *)tableView shouldHighlightRowAtIndexPath:(NSIndexPath *)indexPath +{ + return NO; +} + +- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath +{ + [self.estimatedRowHeights setObject:@(cell.frame.size.height) forKey:indexPath]; + + // Are we approaching the end of the table? + if ((indexPath.section + 1 == [self.tableViewHandler numberOfSectionsInTableView:tableView]) && + (indexPath.row + 4 >= [self.tableViewHandler tableView:tableView numberOfRowsInSection:indexPath.section])) { + + // Only 3 rows till the end of table + if (self.syncHelper.hasMoreContent) { + [self.syncHelper syncMoreContent]; + } + } +} + +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section +{ + return UITableViewAutomaticDimension; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section +{ + return 0; +} + +#pragma mark - UIScrollView Delegate Methods + +- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView +{ + [self.keyboardManager scrollViewWillBeginDragging:scrollView]; +} + +- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView +{ + [self refreshReplyTextViewPlaceholder]; + + [self.tableView deselectSelectedRowWithAnimation:YES]; + + if (self.needsRefreshTableViewAfterScrolling) { + self.needsRefreshTableViewAfterScrolling = NO; + [self refreshTableViewAndNoResultsView]; + + // If we reloaded the tableView we also updated cell heights + // so there is no need to update for attachments. + self.needsUpdateAttachmentsAfterScrolling = NO; + } + + if (self.needsUpdateAttachmentsAfterScrolling) { + self.needsUpdateAttachmentsAfterScrolling = NO; + + for (UITableViewCell *cell in [self.tableView visibleCells]) { + if ([cell isKindOfClass:[CommentContentTableViewCell class]]) { + [(CommentContentTableViewCell *)cell ensureRichContentTextViewLayout]; + } + } + [self updateTableViewForAttachments]; + } +} + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView +{ + [self.keyboardManager scrollViewDidScroll:scrollView]; +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + [self.keyboardManager scrollViewWillEndDragging:scrollView withVelocity:velocity]; +} + + +#pragma mark - SuggestionsTableViewDelegate + +- (void)suggestionsTableView:(SuggestionsTableView *)suggestionsTableView didSelectSuggestion:(NSString *)suggestion forSearchText:(NSString *)text +{ + [self.replyTextView replaceTextAtCaret:text withText:suggestion]; + [suggestionsTableView showSuggestionsForWord:@""]; + self.tapOffKeyboardGesture.enabled = YES; +} + + +- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange interaction:(UITextItemInteraction)interaction +{ + return NO; +} + +#pragma mark - WPRichContentDelegate Methods + +- (void)richContentView:(WPRichContentView *)richContentView didReceiveImageAction:(WPRichTextImage *)image +{ + UIViewController *controller = nil; + BOOL isSupportedNatively = [WPImageViewController isUrlSupported:image.linkURL]; + + if (image.imageView.animatedGifData) { + controller = [[WPImageViewController alloc] initWithGifData:image.imageView.animatedGifData]; + } else if (isSupportedNatively) { + controller = [[WPImageViewController alloc] initWithImage:image.imageView.image andURL:image.linkURL]; + } else if (image.linkURL) { + [self presentWebViewControllerWithURL:image.linkURL]; + return; + } else if (image.imageView.image) { + controller = [[WPImageViewController alloc] initWithImage:image.imageView.image]; + } + + if (controller) { + controller.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; + controller.modalPresentationStyle = UIModalPresentationFullScreen; + [self presentViewController:controller animated:YES completion:nil]; + } +} + +- (void)interactWithURL:(NSURL *)URL +{ + [self presentWebViewControllerWithURL:URL]; +} + +- (BOOL)richContentViewShouldUpdateLayoutForAttachments:(WPRichContentView *)richContentView +{ + if (self.tableViewHandler.isScrolling) { + self.needsUpdateAttachmentsAfterScrolling = YES; + return NO; + } + + return YES; +} + +- (void)richContentViewDidUpdateLayoutForAttachments:(WPRichContentView *)richContentView +{ + [self updateTableViewForAttachments]; +} + +- (void)presentWebViewControllerWithURL:(NSURL *)URL +{ + NSURL *linkURL = URL; + NSURLComponents *components = [NSURLComponents componentsWithString:[URL absoluteString]]; + if (!components.host) { + linkURL = [components URLRelativeToURL:[NSURL URLWithString:self.post.blogURL]]; + } + + WebViewControllerConfiguration *configuration = [[WebViewControllerConfiguration alloc] initWithUrl:linkURL]; + [configuration authenticateWithDefaultAccount]; + [configuration setAddsWPComReferrer:YES]; + UIViewController *webViewController = [WebViewControllerFactory controllerWithConfiguration:configuration source:@"reader_comments"]; + UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:webViewController]; + [self presentViewController:navController animated:YES completion:nil]; +} + +#pragma mark - ReaderCommentsFollowPresenterDelegate Methods + +- (void)followConversationCompleteWithSuccess:(BOOL)success post:(ReaderPost *)post +{ + self.post = post; + [self refreshFollowButton]; +} + +- (void)toggleNotificationCompleteWithSuccess:(BOOL)success post:(ReaderPost *)post +{ + self.post = post; +} + +#pragma mark - Nav bar button helpers + +- (void)handleFollowConversationButtonTapped +{ + [self.readerCommentsFollowPresenter handleFollowConversationButtonTapped]; +} + +- (void)subscriptionSettingsButtonTapped +{ + [self.readerCommentsFollowPresenter showNotificationSheetWithSourceBarButtonItem:self.navigationItem.rightBarButtonItem]; +} + +#pragma mark - UITextViewDelegate methods + +- (BOOL)textViewShouldBeginEditing:(UITextView *)textView +{ + self.tapOffKeyboardGesture.enabled = YES; + return YES; +} + +- (void)textView:(UITextView *)textView didTypeWord:(NSString *)word +{ + // Disable the gestures recognizer when showing suggestions + BOOL showsSuggestions = [self.suggestionsTableView showSuggestionsForWord:word]; + self.tapOffKeyboardGesture.enabled = !showsSuggestions; +} + +- (void)replyTextView:(ReplyTextView *)replyTextView willEnterFullScreen:(FullScreenCommentReplyViewController *)controller +{ + NSString *searchText = [self.suggestionsTableView viewModel].searchText; + [self.suggestionsTableView hideSuggestions]; + [controller enableSuggestionsWith:self.siteID prominentSuggestionsIds:self.suggestionsTableView.prominentSuggestionsIds + searchText:searchText]; +} + +- (void)replyTextView:(ReplyTextView *)replyTextView didExitFullScreen:(NSString *)lastSearchText +{ + if ([lastSearchText length] != 0) { + [self.suggestionsTableView showSuggestionsForWord:lastSearchText]; + } +} + +@end diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift new file mode 100644 index 000000000000..362f46688bb5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift @@ -0,0 +1,417 @@ +import Foundation +import UIKit +import WordPressShared + +// Notification sent when a comment is moderated/edited to allow views that display Comments to update if necessary. +// Specifically, the Comments snippet on ReaderDetailViewController. +extension NSNotification.Name { + static let ReaderCommentModifiedNotification = NSNotification.Name(rawValue: "ReaderCommentModifiedNotification") +} + +@objc public extension ReaderCommentsViewController { + func shouldShowSuggestions(for siteID: NSNumber?) -> Bool { + guard let siteID = siteID, let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { return false } + return SuggestionService.shared.shouldShowSuggestions(for: blog) + } + + func handleHeaderTapped() { + guard let post = post, + allowsPushingPostDetails else { + return + } + + // Note: Let's manually hide the comments button, in order to prevent recursion in the flow + let controller = ReaderDetailViewController.controllerWithPost(post) + controller.shouldHideComments = true + navigationController?.pushFullscreenViewController(controller, animated: true) + } + + // MARK: New Comment Threads + + func configuredHeaderView(for tableView: UITableView) -> UIView { + guard let post = post else { + return .init() + } + + let cell = CommentHeaderTableViewCell() + cell.backgroundColor = .systemBackground + cell.configure(for: .thread, subtitle: post.titleForDisplay(), showsDisclosureIndicator: allowsPushingPostDetails) + cell.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleHeaderTapped))) + + // the table view does not render separators for the section header views, so we need to create one. + cell.contentView.addBottomBorder(withColor: .separator, leadingMargin: tableView.separatorInset.left) + + return cell + } + + func configureContentCell(_ cell: UITableViewCell, comment: Comment, indexPath: IndexPath, handler: WPTableViewHandler) { + guard let cell = cell as? CommentContentTableViewCell else { + return + } + + cell.badgeTitle = comment.isFromPostAuthor() ? .authorBadgeText : nil + cell.indentationWidth = Constants.indentationWidth + cell.indentationLevel = min(Constants.maxIndentationLevel, Int(comment.depth)) + cell.accessoryButtonType = isModerationMenuEnabled(for: comment) ? .ellipsis : .share + cell.shouldHideSeparator = true + + // if the comment can be moderated, show the context menu when tapping the accessory button. + // Note that accessoryButtonAction will be ignored when the menu is assigned. + if #available (iOS 14.0, *) { + cell.accessoryButton.showsMenuAsPrimaryAction = isModerationMenuEnabled(for: comment) + cell.accessoryButton.menu = isModerationMenuEnabled(for: comment) ? menu(for: comment, + indexPath: indexPath, + handler: handler, + sourceView: cell.accessoryButton) : nil + } + + cell.configure(with: comment, renderMethod: .richContent) { _ in + // don't adjust cell height when it's already scrolled out of viewport. + guard let visibleIndexPaths = handler.tableView.indexPathsForVisibleRows, + visibleIndexPaths.contains(indexPath) else { + return + } + + handler.tableView.performBatchUpdates({}) + } + } + + /// Opens a share sheet, prompting the user to share the URL of the provided comment. + /// + func shareComment(_ comment: Comment, sourceView: UIView?) { + guard let commentURL = comment.commentURL() else { + return + } + + // track share intent. + WPAnalytics.track(.readerArticleCommentShared) + + let activityViewController = UIActivityViewController(activityItems: [commentURL as Any], applicationActivities: nil) + activityViewController.popoverPresentationController?.sourceView = sourceView + present(activityViewController, animated: true, completion: nil) + } + + /// Shows a contextual menu through `UIPopoverPresentationController`. This is a fallback implementation for iOS 13, since the menu can't be + /// shown programmatically or through a single tap. + /// + /// NOTE: Remove this once we bump the minimum version to iOS 14. + /// + func showMenuSheet(for comment: Comment, indexPath: IndexPath, handler: WPTableViewHandler, sourceView: UIView?) { + let commentMenus = commentMenu(for: comment, indexPath: indexPath, handler: handler, sourceView: sourceView) + let menuViewController = MenuSheetViewController(items: commentMenus.map { menuSection in + // Convert ReaderCommentMenu to MenuSheetViewController.MenuItem + menuSection.map { $0.toMenuItem } + }) + + menuViewController.modalPresentationStyle = .popover + if let popoverPresentationController = menuViewController.popoverPresentationController { + popoverPresentationController.delegate = self + popoverPresentationController.sourceView = sourceView + popoverPresentationController.sourceRect = sourceView?.bounds ?? .null + } + + present(menuViewController, animated: true) + } + + func isModerationMenuEnabled(for comment: Comment) -> Bool { + return comment.allowsModeration() + } + + // MARK: - Tracking + + func trackCommentsOpened() { + var properties: [AnyHashable: Any] = [ + WPAppAnalyticsKeySource: descriptionForSource(source) + ] + + if let post = post { + properties[WPAppAnalyticsKeyPostID] = post.postID + properties[WPAppAnalyticsKeyBlogID] = post.siteID + } + + WPAnalytics.trackReader(.readerArticleCommentsOpened, properties: properties) + } + + @objc func trackCommentsOpened(postID: NSNumber, siteID: NSNumber, source: ReaderCommentsSource) { + let properties: [AnyHashable: Any] = [ + WPAppAnalyticsKeyPostID: postID, + WPAppAnalyticsKeyBlogID: siteID, + WPAppAnalyticsKeySource: descriptionForSource(source) + ] + + WPAnalytics.trackReader(.readerArticleCommentsOpened, properties: properties) + } + + // MARK: - Notification + + @objc func postCommentModifiedNotification() { + NotificationCenter.default.post(name: .ReaderCommentModifiedNotification, object: nil) + } + +} + +// MARK: - Popover Presentation Delegate + +extension ReaderCommentsViewController: UIPopoverPresentationControllerDelegate { + // Force popover views to be presented as a popover (instead of being presented as a form sheet on iPhones). + public func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return .none + } +} + +// MARK: - Private Helpers + +private extension ReaderCommentsViewController { + struct Constants { + static let indentationWidth: CGFloat = 15.0 + static let maxIndentationLevel: Int = 4 + } + + var commentService: CommentService { + return CommentService(coreDataStack: ContextManager.shared) + } + + /// Returns a `UIMenu` structure to be displayed when the accessory button is tapped. + /// Note that this should only be called on iOS version 14 and above. + /// + /// For example, given an comment menu list `[[Foo, Bar], [Baz]]`, it will generate a menu as below: + /// ________ + /// | Foo •| + /// | Bar •| + /// |--------| + /// | Baz •| + /// -------- + /// + func menu(for comment: Comment, indexPath: IndexPath, handler: WPTableViewHandler, sourceView: UIView?) -> UIMenu { + let commentMenus = commentMenu(for: comment, indexPath: indexPath, handler: handler, sourceView: sourceView) + return UIMenu(title: "", options: .displayInline, children: commentMenus.map { + UIMenu(title: "", options: .displayInline, children: $0.map({ menu in menu.toAction })) + }) + } + + /// Returns a list of array that each contains a menu item. Separators will be shown between each array. Note that + /// the order of comment menu will determine the order of appearance for the corresponding menu element. + /// + func commentMenu(for comment: Comment, indexPath: IndexPath, handler: WPTableViewHandler, sourceView: UIView?) -> [[ReaderCommentMenu]] { + return [ + [ + .unapprove { [weak self] in + self?.moderateComment(comment, status: .pending) + }, + .spam { [weak self] in + self?.moderateComment(comment, status: .spam) + }, + .trash { [weak self] in + self?.moderateComment(comment, status: .unapproved) + } + ], + [ + .edit { [weak self] in + self?.editMenuTapped(for: comment, indexPath: indexPath, tableView: handler.tableView) + }, + .share { [weak self] in + self?.shareComment(comment, sourceView: sourceView) + } + ] + ] + } + + func editMenuTapped(for comment: Comment, indexPath: IndexPath, tableView: UITableView) { + let editCommentTableViewController = EditCommentTableViewController(comment: comment) { [weak self] comment, commentChanged in + guard commentChanged else { + return + } + + // optimistically update the comment in the thread with local changes. + tableView.reloadRows(at: [indexPath], with: .automatic) + + // track user's intent to edit the comment. + CommentAnalytics.trackCommentEdited(comment: comment) + + self?.commentService.uploadComment(comment, success: { + self?.commentModified = true + + // update the thread again in case the approval status changed. + tableView.reloadRows(at: [indexPath], with: .automatic) + }, failure: { _ in + self?.displayNotice(title: .editCommentFailureNoticeText) + }) + } + + let navigationControllerToPresent = UINavigationController(rootViewController: editCommentTableViewController) + navigationControllerToPresent.modalPresentationStyle = .fullScreen + present(navigationControllerToPresent, animated: true) + } + + func moderateComment(_ comment: Comment, status: CommentStatusType) { + let successBlock: (String) -> Void = { [weak self] noticeText in + guard let self = self else { + return + } + + // when a comment is unapproved/spammed/trashed, ensure that all of the replies are hidden. + self.commentService.updateRepliesVisibility(for: comment) { + self.commentModified = true + self.refreshAfterCommentModeration() + + // Dismiss any old notices to avoid stacked Undo notices. + self.dismissNotice() + + // If the status is Approved, the user has undone a comment moderation. + // So don't show the Undo option in this case. + (status == .approved) ? self.displayNotice(title: noticeText) : + self.showActionableNotice(title: noticeText, comment: comment) + } + } + + switch status { + case .pending: + commentService.unapproveComment(comment) { + successBlock(.pendingSuccess) + } failure: { [weak self] _ in + self?.displayNotice(title: .pendingFailed) + } + + case .spam: + commentService.spamComment(comment) { + successBlock(.spamSuccess) + } failure: { [weak self] _ in + self?.displayNotice(title: .spamFailed) + } + + case .unapproved: // trash + commentService.trashComment(comment) { + successBlock(.trashSuccess) + } failure: { [weak self] _ in + self?.displayNotice(title: .trashFailed) + } + case .approved: + commentService.approve(comment) { + successBlock(.approveSuccess) + } failure: { [weak self] _ in + self?.displayNotice(title: .approveFailed) + } + default: + break + } + } + + func showActionableNotice(title: String, comment: Comment) { + displayActionableNotice(title: title, + actionTitle: .undoActionTitle, + actionHandler: { [weak self] _ in + // Set the Comment's status back to Approved when the user selects Undo on the notice. + self?.moderateComment(comment, status: .approved) + }) + } + + func descriptionForSource(_ source: ReaderCommentsSource) -> String { + switch source { + case .postCard: + return "reader_post_card" + case .postDetails: + return "reader_post_details" + case .postDetailsComments: + return "reader_post_details_comments" + case .commentNotification: + return "comment_notification" + case .commentLikeNotification: + return "comment_like_notification" + case .mySiteComment: + return "my_site_comment" + case .activityLogDetail: + return "activity_log_detail" + default: + return "unknown" + } + } + +} + +// MARK: - Localization + +private extension String { + static let authorBadgeText = NSLocalizedString("Author", comment: "Title for a badge displayed beside the comment writer's name. " + + "Shown when the comment is written by the post author.") + static let editCommentFailureNoticeText = NSLocalizedString("There has been an unexpected error while editing the comment", + comment: "Error displayed if a comment fails to get updated") + static let undoActionTitle = NSLocalizedString("Undo", comment: "Button title. Reverts a comment moderation action.") + + // moderation messages + static let pendingSuccess = NSLocalizedString("Comment set to pending.", comment: "Message displayed when pending a comment succeeds.") + static let pendingFailed = NSLocalizedString("Error setting comment to pending.", comment: "Message displayed when pending a comment fails.") + static let spamSuccess = NSLocalizedString("Comment marked as spam.", comment: "Message displayed when spamming a comment succeeds.") + static let spamFailed = NSLocalizedString("Error marking comment as spam.", comment: "Message displayed when spamming a comment fails.") + static let trashSuccess = NSLocalizedString("Comment moved to trash.", comment: "Message displayed when trashing a comment succeeds.") + static let trashFailed = NSLocalizedString("Error moving comment to trash.", comment: "Message displayed when trashing a comment fails.") + static let approveSuccess = NSLocalizedString("Comment set to approved.", comment: "Message displayed when approving a comment succeeds.") + static let approveFailed = NSLocalizedString("Error setting comment to approved.", comment: "Message displayed when approving a comment fails.") +} + +// MARK: - Reader Comment Menu + +/// Represents the available menu when the ellipsis accessory button on the comment cell is tapped. +enum ReaderCommentMenu { + case unapprove(_ handler: () -> Void) + case spam(_ handler: () -> Void) + case trash(_ handler: () -> Void) + case edit(_ handler: () -> Void) + case share(_ handler: () -> Void) + + var title: String { + switch self { + case .unapprove: + return NSLocalizedString("Unapprove", comment: "Unapproves a comment") + case .spam: + return NSLocalizedString("Mark as Spam", comment: "Marks comment as spam") + case .trash: + return NSLocalizedString("Move to Trash", comment: "Trashes the comment") + case .edit: + return NSLocalizedString("Edit", comment: "Edits the comment") + case .share: + return NSLocalizedString("Share", comment: "Shares the comment URL") + } + } + + var image: UIImage? { + switch self { + case .unapprove: + return .init(systemName: "x.circle") + case .spam: + return .init(systemName: "exclamationmark.octagon") + case .trash: + return .init(systemName: "trash") + case .edit: + return .init(systemName: "pencil") + case .share: + return .init(systemName: "square.and.arrow.up") + } + } + + var toAction: UIAction { + switch self { + case .unapprove(let handler), + .spam(let handler), + .trash(let handler), + .edit(let handler), + .share(let handler): + return UIAction(title: title, image: image) { _ in + handler() + } + } + } + + /// NOTE: Remove when minimum version is bumped to iOS 14. + var toMenuItem: MenuSheetViewController.MenuItem { + switch self { + case .unapprove(let handler), + .spam(let handler), + .trash(let handler), + .edit(let handler), + .share(let handler): + return MenuSheetViewController.MenuItem(title: title, image: image) { + handler() + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/DefaultNewsManager.swift b/WordPress/Classes/ViewRelated/Reader/DefaultNewsManager.swift deleted file mode 100644 index 2066d09e860e..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/DefaultNewsManager.swift +++ /dev/null @@ -1,176 +0,0 @@ -import WordPressShared - -/** Default implementation of the NewsManager protocol. - * The card is shown if it has not been dismissed yet - * AND - * The card is shown on the first Reader filter that users navigate to - * AND - * If users navigate to another Reader filter, or another screen, the card disappears, but if they navigate back to the filter where it was presented first, it’ll be visible again - * AND - * If users tap dismiss, the card disappears and will never be displayed again for the same app version - */ -final class DefaultNewsManager: NewsManager { - enum DatabaseKeys { - static let lastDismissedCardVersion = "com.wordpress.newscard.last-dismissed-card-version" - static let cardContainerIdentifier = "com.wordpress.newscard.cardcontaineridentifier" - } - - private let service: NewsService - private let database: KeyValueDatabase - weak var delegate: NewsManagerDelegate? - private let stats: NewsStats - - private var result: Result<NewsItem, Error>? - - init(service: NewsService, database: KeyValueDatabase, stats: NewsStats, delegate: NewsManagerDelegate? = nil) { - self.service = service - self.database = database - self.stats = stats - self.delegate = delegate - load() - } - - func dismiss() { - deactivateCurrentCard() - delegate?.didDismissNews() - - trackCardDismissed() - } - - func readMore() { - guard let actualResult = result else { - return - } - - switch actualResult { - case .success(let value): - trackRequestedExtendedInfo() - delegate?.didSelectReadMore(value.extendedInfoURL) - case .failure: - return - } - } - - func shouldPresentCard(contextId: Identifier) -> Bool { - let canPresentCard = cardIsAllowedInContext(contextId: contextId) && - currentCardVersionIsGreaterThanLastDismissedCardVersion() && - cardVersionMatchesBuild() - - if canPresentCard { - saveCardContext(contextId) - } - - return canPresentCard - } - - func didPresentCard() { - trackCardPresented() - - NotificationCenter.default.post(name: NSNotification.NewsCardNotAvailable, object: nil) - } - - private func load() { - service.load { [weak self] result in - self?.result = result - } - } - - func load(then completion: @escaping (Result<NewsItem, Error>) -> Void) { - if let loadedResult = result { - completion(loadedResult) - return - } - - service.load { [weak self] newResult in - self?.result = newResult - completion(newResult) - } - } - - private func cardIsAllowedInContext(contextId: Identifier) -> Bool { - let savedContext = savedCardContext() - - return savedContext == contextId || - savedContext == Identifier.empty() - } - - private func savedCardContext() -> Identifier { - guard let savedCardContext = database.object(forKey: DatabaseKeys.cardContainerIdentifier) as? String else { - return Identifier.empty() - } - - return Identifier(value: savedCardContext) - } - - private func cardVersionMatchesBuild() -> Bool { - guard let actualResult = result else { - return false - } - - switch actualResult { - case .success(let value): - return currentBuildVersion() >= value.version - case .failure: - return false - } - } - - private func currentBuildVersion() -> Decimal { - guard let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { - DDLogError("No CFBundleShortVersionString found in Info.plist") - return Decimal() - } - - return Decimal(string: version) ?? Decimal() - } - - private func currentCardVersion() -> Decimal { - guard let actualResult = result else { - return Decimal(floatLiteral: 0.0) - } - - switch actualResult { - case .failure: - return Decimal(floatLiteral: 0.0) - case .success(let newsItem): - return newsItem.version - } - } - - private func currentCardVersionIsGreaterThanLastDismissedCardVersion() -> Bool { - guard let lastSavedVersion = database.object(forKey: DatabaseKeys.lastDismissedCardVersion) as? NSNumber else { - return true - } - - return Decimal(floatLiteral: lastSavedVersion.doubleValue) < currentCardVersion() - } - - private func deactivateCurrentCard() { - guard let actualResult = result else { - return - } - - switch actualResult { - case .failure: - return - case .success(let newsItem): - database.set(newsItem.version, forKey: DatabaseKeys.lastDismissedCardVersion) - } - } - - private func saveCardContext(_ identifier: Identifier) { - database.set(identifier.description, forKey: DatabaseKeys.cardContainerIdentifier) - } - - private func trackCardPresented() { - stats.trackPresented(news: result) - } - - private func trackCardDismissed() { - stats.trackDismissed(news: result) - } - - private func trackRequestedExtendedInfo() { - stats.trackRequestedExtendedInfo(news: result) - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift new file mode 100644 index 000000000000..6c68f9f49e12 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailCoordinator.swift @@ -0,0 +1,767 @@ +import Foundation +import WordPressShared + +class ReaderDetailCoordinator { + + /// Key for restoring the VC post + static let restorablePostObjectURLKey: String = "RestorablePostObjectURLKey" + + /// A post to be displayed + var post: ReaderPost? { + didSet { + postInUse(true) + indexReaderPostInSpotlight() + } + } + + /// Used to determine if block and report are shown in the options menu. + var readerTopic: ReaderAbstractTopic? + + /// Used for analytics + var remoteSimplePost: RemoteReaderSimplePost? + + /// A post URL to be loaded and be displayed + var postURL: URL? + + /// A comment ID used to navigate to a comment + var commentID: Int? { + // Comment fragments have the form #comment-50484 + // If one is present, we'll extract the ID and return it. + if let fragment = postURL?.fragment, + fragment.hasPrefix("comment-"), + let idString = fragment.components(separatedBy: "comment-").last { + return Int(idString) + } + + return nil + } + + /// Called if the view controller's post fails to load + var postLoadFailureBlock: (() -> Void)? = nil + + /// An authenticator to ensure any request made to WP sites is properly authenticated + lazy var authenticator: RequestAuthenticator? = { + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: coreDataStack.mainContext) else { + DDLogInfo("Account not available for Reader authentication") + return nil + } + + return RequestAuthenticator(account: account) + }() + + /// Core Data stack manager + private let coreDataStack: CoreDataStack + + /// Reader Post Service + private let readerPostService: ReaderPostService + + /// Reader Topic Service + private let topicService: ReaderTopicService + + /// Post Service + private let postService: PostService + + /// Comment Service + private let commentService: CommentService + private let commentsDisplayed: UInt = 1 + + /// Post Sharing Controller + private let sharingController: PostSharingController + + /// Reader Link Router + private let readerLinkRouter: UniversalLinkRouter + + /// Reader View + private weak var view: ReaderDetailView? + + /// Reader View Controller + private var viewController: UIViewController? { + return view as? UIViewController + } + + /// A post ID to fetch + private(set) var postID: NSNumber? + + /// A site ID to be used to fetch a post + private(set) var siteID: NSNumber? + + /// If the site is an external feed (not hosted at WPcom and not using Jetpack) + private(set) var isFeed: Bool? + + /// The perma link URL for the loaded post + private var permaLinkURL: URL? { + guard let postURLString = post?.permaLink else { + return nil + } + + return URL(string: postURLString) + } + + private var followCommentsService: FollowCommentsService? + + /// The total number of Likes for the post. + /// Passed to ReaderDetailLikesListController to display in the view title. + private var totalLikes = 0 + + /// Initialize the Reader Detail Coordinator + /// + /// - Parameter service: a Reader Post Service + init(coreDataStack: CoreDataStack = ContextManager.shared, + readerPostService: ReaderPostService = ReaderPostService(coreDataStack: ContextManager.shared), + topicService: ReaderTopicService = ReaderTopicService(coreDataStack: ContextManager.shared), + postService: PostService = PostService(managedObjectContext: ContextManager.sharedInstance().mainContext), + commentService: CommentService = CommentService(coreDataStack: ContextManager.shared), + sharingController: PostSharingController = PostSharingController(), + readerLinkRouter: UniversalLinkRouter = UniversalLinkRouter(routes: UniversalLinkRouter.readerRoutes), + view: ReaderDetailView) { + self.coreDataStack = coreDataStack + self.readerPostService = readerPostService + self.topicService = topicService + self.postService = postService + self.commentService = commentService + self.sharingController = sharingController + self.readerLinkRouter = readerLinkRouter + self.view = view + } + + deinit { + postInUse(false) + } + + /// Start the coordinator + /// + func start() { + view?.showLoading() + + if post != nil { + renderPostAndBumpStats() + } else if let siteID = siteID, let postID = postID, let isFeed = isFeed { + fetch(postID: postID, siteID: siteID, isFeed: isFeed) + } else if let postURL = postURL { + fetch(postURL) + } + } + + /// Fetch related posts for the current post + /// + func fetchRelatedPosts(for post: ReaderPost) { + readerPostService.fetchRelatedPosts(for: post) { [weak self] relatedPosts in + self?.view?.renderRelatedPosts(relatedPosts) + } failure: { error in + DDLogError("Error fetching related posts for detail: \(String(describing: error?.localizedDescription))") + } + } + + /// Fetch Likes for the current post. + /// Returns `ReaderDetailLikesView.maxAvatarsDisplayed` number of Likes. + /// + func fetchLikes(for post: ReaderPost) { + guard let postID = post.postID else { + return + } + + // Fetch a full page of Likes but only return the `maxAvatarsDisplayed` number. + // That way the first page will already be cached if the user displays the full Likes list. + postService.getLikesFor(postID: postID, + siteID: post.siteID, + success: { [weak self] users, totalLikes, _ in + var filteredUsers = users + var currentLikeUser: LikeUser? = nil + let totalLikesExcludingSelf = totalLikes - (post.isLiked ? 1 : 0) + + // Split off current user's like from the list. + // Likes from self will always be placed in the last position, regardless of the when the post was liked. + if let userID = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext)?.userID.int64Value, + let userIndex = filteredUsers.firstIndex(where: { $0.userID == userID }) { + currentLikeUser = filteredUsers.remove(at: userIndex) + } + + self?.totalLikes = totalLikes + self?.view?.updateLikes(with: filteredUsers.prefix(ReaderDetailLikesView.maxAvatarsDisplayed).map { $0.avatarUrl }, + totalLikes: totalLikesExcludingSelf) + // Only pass current user's avatar when we know *for sure* that the post is liked. + // This is to work around a possible race condition that causes an unliked post to have current user's LikeUser, which + // would cause a display bug in ReaderDetailLikesView. The race condition issue will be investigated separately. + self?.view?.updateSelfLike(with: post.isLiked ? currentLikeUser?.avatarUrl : nil) + }, failure: { [weak self] error in + self?.view?.updateLikes(with: [String](), totalLikes: 0) + DDLogError("Error fetching Likes for post detail: \(String(describing: error?.localizedDescription))") + }) + } + + /// 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: { error in + DDLogError("Failed fetching post detail comments: \(String(describing: error))") + }) + } + + 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) { + self.share(fromAnchor: .view(anchorView)) + } + + /// Share the current post + /// + func share(fromAnchor anchor: UIPopoverPresentationController.PopoverAnchor) { + guard let post = post, let view = viewController else { + return + } + + sharingController.shareReaderPost(post, fromAnchor: anchor, inViewController: view) + + WPAnalytics.trackReader(.readerSharedItem) + } + + /// Set a postID, siteID and isFeed + /// + /// - Parameter postID: A post ID to fetch + /// - Parameter siteID: A site ID to fetch + /// - Parameter isFeed: If the site is an external feed (not hosted at WPcom and not using Jetpack) + func set(postID: NSNumber, siteID: NSNumber, isFeed: Bool) { + self.postID = postID + self.siteID = siteID + self.isFeed = isFeed + } + + /// Show more about a specific site in Discovery + /// + func showMore() { + guard let post = post, post.sourceAttribution != nil else { + return + } + + if let blogID = post.sourceAttribution.blogID { + let controller = ReaderStreamViewController.controllerWithSiteID(blogID, isFeed: false) + viewController?.navigationController?.pushViewController(controller, animated: true) + return + } + + var path: String? + if post.sourceAttribution.attributionType == SourcePostAttributionTypePost { + path = post.sourceAttribution.permalink + } else { + path = post.sourceAttribution.blogURL + } + + if let path = path, let linkURL = URL(string: path) { + presentWebViewController(linkURL) + } + } + + /// Loads an image (or GIF) from a URL and displays it in fullscreen + /// + /// - Parameter url: URL of the image or gif + func presentImage(_ url: URL) { + WPAnalytics.trackReader(.readerArticleImageTapped) + + let imageViewController = WPImageViewController(url: url) + imageViewController.readerPost = post + imageViewController.modalTransitionStyle = .crossDissolve + imageViewController.modalPresentationStyle = .fullScreen + + viewController?.present(imageViewController, animated: true) + } + + /// Open the postURL in a separated view controller + /// + func openInBrowser() { + + let url: URL? = { + // For Reader posts, use post link. + if let permaLink = post?.permaLink { + return URL(string: permaLink) + } + // For Related posts, use postURL. + return postURL + }() + + guard let postURL = url else { + return + } + + WPAnalytics.trackReader(.readerArticleVisited) + presentWebViewController(postURL) + } + + /// Some posts have content from private sites that need special cookies + /// + /// Use this method to make sure these cookies are downloaded. + /// - Parameter webView: the webView where the post will be rendered + /// - Parameter completion: a completion block + func storeAuthenticationCookies(in webView: WKWebView, completion: @escaping () -> Void) { + guard let authenticator = authenticator, + let postURL = permaLinkURL else { + completion() + return + } + + authenticator.request(url: postURL, cookieJar: webView.configuration.websiteDataStore.httpCookieStore) { _ in + completion() + } + } + + /// Requests a ReaderPost from the service and updates the View. + /// + /// Use this method to fetch a ReaderPost. + /// - Parameter postID: a post identification + /// - Parameter siteID: a site identification + /// - Parameter isFeed: a Boolean indicating if the site is an external feed (not hosted at WPcom and not using Jetpack) + private func fetch(postID: NSNumber, siteID: NSNumber, isFeed: Bool) { + readerPostService.fetchPost(postID.uintValue, + forSite: siteID.uintValue, + isFeed: isFeed, + success: { [weak self] post in + self?.post = post + self?.renderPostAndBumpStats() + }, failure: { [weak self] _ in + self?.postURL == nil ? self?.view?.showError() : self?.view?.showErrorWithWebAction() + self?.reportPostLoadFailure() + }) + } + + + /// Requests a ReaderPost from the service and updates the View. + /// + /// Use this method to fetch a ReaderPost from a URL. + /// - Parameter url: a post URL + private func fetch(_ url: URL) { + readerPostService.fetchPost(at: url, + success: { [weak self] post in + self?.post = post + self?.renderPostAndBumpStats() + }, failure: { [weak self] error in + DDLogError("Error fetching post for detail: \(String(describing: error?.localizedDescription))") + self?.postURL == nil ? self?.view?.showError() : self?.view?.showErrorWithWebAction() + self?.reportPostLoadFailure() + }) + } + + private func renderPostAndBumpStats() { + guard let post = post else { + return + } + + view?.render(post) + + bumpStats() + bumpPageViewsForPost() + markPostAsSeen() + } + + private func markPostAsSeen() { + guard let post = post, !post.isSeen else { + return + } + + readerPostService.toggleSeen(for: post, success: { + NotificationCenter.default.post(name: .ReaderPostSeenToggled, + object: nil, + userInfo: [ReaderNotificationKeys.post: post]) + }, failure: nil) + } + + /// If the loaded URL contains a hash/anchor then jump to that spot in the post content + /// once it loads + /// + private func scrollToHashIfNeeded() { + guard + let url = postURL, + let hash = URLComponents(url: url, resolvingAgainstBaseURL: true)?.fragment + else { + return + } + + view?.scroll(to: hash) + } + + /// Shows the current post site posts in a new screen + /// + private func previewSite() { + guard let post = post else { + return + } + + let controller = ReaderStreamViewController.controllerWithSiteID(post.siteID, isFeed: post.isExternal) + viewController?.navigationController?.pushViewController(controller, animated: true) + + let properties = ReaderHelpers.statsPropertiesForPost(post, andValue: post.blogURL as AnyObject?, forKey: "URL") + WPAppAnalytics.track(.readerSitePreviewed, withProperties: properties) + } + + /// Show a menu with options for the current post's site + /// + private func showMenu(_ anchor: UIPopoverPresentationController.PopoverAnchor) { + guard let post = post, + let context = post.managedObjectContext, + let viewController = viewController, + let followCommentsService = FollowCommentsService(post: post) else { + return + } + + self.followCommentsService = followCommentsService + + ReaderMenuAction(logged: ReaderHelpers.isLoggedIn()).execute( + post: post, + context: context, + readerTopic: readerTopic, + anchor: anchor, + vc: viewController, + source: ReaderPostMenuSource.details, + followCommentsService: followCommentsService + ) + + WPAnalytics.trackReader(.readerArticleDetailMoreTapped) + } + + private func showTopic(_ topic: String) { + let controller = ReaderStreamViewController.controllerWithTagSlug(topic) + viewController?.navigationController?.pushViewController(controller, animated: true) + } + + /// Show a list with posts containing this tag + /// + private func showTag() { + guard let post = post else { + return + } + + let controller = ReaderStreamViewController.controllerWithTagSlug(post.primaryTagSlug) + viewController?.navigationController?.pushViewController(controller, animated: true) + + let properties = ReaderHelpers.statsPropertiesForPost(post, andValue: post.primaryTagSlug as AnyObject?, forKey: "tag") + WPAppAnalytics.track(.readerTagPreviewed, withProperties: properties) + } + + /// Given a URL presents it the best way possible. + /// + /// If it's an image, shows it fullscreen. + /// If it's a fullscreen Story link, open it in the webview controller. + /// If it's a post, open a new detail screen. + /// If it's a link protocol (tel: / sms: / mailto:), take the correct action. + /// If it's a regular URL, open it in the webview controller. + /// + /// - Parameter url: the URL to be handled + func handle(_ url: URL) { + // If the URL has an anchor (#) + // and the URL is equal to the current post URL + if let hash = URLComponents(url: url, resolvingAgainstBaseURL: true)?.fragment, + let postURL = permaLinkURL, + postURL.isHostAndPathEqual(to: url) { + view?.scroll(to: hash) + } else if url.pathExtension.contains("gif") || + url.pathExtension.contains("jpg") || + url.pathExtension.contains("jpeg") || + url.pathExtension.contains("png") { + presentImage(url) + } else if url.query?.contains("wp-story") ?? false { + presentWebViewController(url) + } else if readerLinkRouter.canHandle(url: url) { + readerLinkRouter.handle(url: url, shouldTrack: false, source: .inApp(presenter: viewController)) + } else if url.isWordPressDotComPost { + presentReaderDetail(url) + } else if url.isLinkProtocol { + readerLinkRouter.handle(url: url, shouldTrack: false, source: .inApp(presenter: viewController)) + } else { + WPAnalytics.trackReader(.readerArticleLinkTapped) + + presentWebViewController(url) + } + } + + + /// Called after the webView fully loads + func webViewDidLoad() { + scrollToHashIfNeeded() + } + + /// Show the featured image fullscreen + /// + private func showFeaturedImage(_ sender: CachedAnimatedImageView) { + guard let post = post else { + return + } + + var controller: WPImageViewController + if post.featuredImageURL.isGif, let data = sender.animatedGifData { + controller = WPImageViewController(gifData: data) + } else if let featuredImage = sender.image { + controller = WPImageViewController(image: featuredImage) + } else { + return + } + + controller.modalTransitionStyle = .crossDissolve + controller.modalPresentationStyle = .fullScreen + viewController?.present(controller, animated: true) + } + + private func followSite(completion: @escaping () -> Void) { + guard let post = post else { + return + } + + ReaderFollowAction().execute(with: post, + context: coreDataStack.mainContext, + completion: { [weak self] follow in + ReaderHelpers.dispatchToggleFollowSiteMessage(post: post, follow: follow, success: true) + self?.view?.updateHeader() + completion() + }, + failure: { [weak self] follow, _ in + ReaderHelpers.dispatchToggleFollowSiteMessage(post: post, follow: follow, success: false) + self?.view?.updateHeader() + completion() + }) + } + + /// Given a URL presents it in a new Reader detail screen + /// + private func presentReaderDetail(_ url: URL) { + + // In cross post Notifications, if the user tapped the link to the original post in the Notification body, + // use the original post's info to display reader detail. + // The API endpoint used by controllerWithPostID returns subscription flags for the post. + // The API endpoint used by controllerWithPostURL does not return this information. + // These flags are needed to display the `Follow conversation by email` option. + // So if we can call controllerWithPostID, do so. Otherwise, fallback to controllerWithPostURL. + // Ref: https://github.com/wordpress-mobile/WordPress-iOS/issues/17158 + + let readerDetail: ReaderDetailViewController = { + if let post = post, + selectedUrlIsCrossPost(url) { + return ReaderDetailViewController.controllerWithPostID(post.crossPostMeta.postID, siteID: post.crossPostMeta.siteID) + } + + return ReaderDetailViewController.controllerWithPostURL(url) + }() + + viewController?.navigationController?.pushViewController(readerDetail, animated: true) + } + + private func selectedUrlIsCrossPost(_ url: URL) -> Bool { + // Trim trailing slashes to facilitate URL comparison. + let characterSet = CharacterSet(charactersIn: "/") + + guard let post = post, + post.isCross(), + let crossPostMeta = post.crossPostMeta, + let crossPostURL = URL(string: crossPostMeta.postURL.trimmingCharacters(in: characterSet)), + let selectedURL = URL(string: url.absoluteString.trimmingCharacters(in: characterSet)) else { + return false + } + + return crossPostURL.isHostAndPathEqual(to: selectedURL) + } + + /// Given a URL presents it in a web view controller screen + /// + /// - Parameter url: the URL to be loaded + private func presentWebViewController(_ url: URL) { + var url = url + if url.host == nil { + if let postURL = permaLinkURL { + url = URL(string: url.absoluteString, relativeTo: postURL)! + } + } + let configuration = WebViewControllerConfiguration(url: url) + configuration.authenticateWithDefaultAccount() + configuration.addsWPComReferrer = true + let controller = WebViewControllerFactory.controller(configuration: configuration, source: "reader_detail") + let navController = LightNavigationController(rootViewController: controller) + viewController?.present(navController, animated: true) + } + + /// Report to the callback that the post failed to load + private func reportPostLoadFailure() { + postLoadFailureBlock?() + + // We'll nil out the failure block so we don't perform multiple callbacks + postLoadFailureBlock = nil + } + + /// Change post's inUse property and saves the context + private func postInUse(_ inUse: Bool) { + guard let context = post?.managedObjectContext else { + return + } + + post?.inUse = inUse + coreDataStack.save(context) + } + + private func showLikesList() { + guard let post = post else { + return + } + + let controller = ReaderDetailLikesListController(post: post, totalLikes: totalLikes) + viewController?.navigationController?.pushViewController(controller, animated: true) + } + + /// Index the post in Spotlight + private func indexReaderPostInSpotlight() { + guard let post = post else { + return + } + + SearchManager.shared.indexItem(post) + } + + // MARK: - Analytics + + /// Bump WP App Analytics + /// + private func bumpStats() { + guard let readerPost = post else { + return + } + + let isOfflineView = ReachabilityUtils.isInternetReachable() ? "no" : "yes" + let detailType = readerPost.topic?.type == ReaderSiteTopic.TopicType ? DetailAnalyticsConstants.TypePreviewSite : DetailAnalyticsConstants.TypeNormal + + + var properties = ReaderHelpers.statsPropertiesForPost(readerPost, andValue: nil, forKey: nil) + properties[DetailAnalyticsConstants.TypeKey] = detailType + properties[DetailAnalyticsConstants.OfflineKey] = isOfflineView + + + // Track related post tapped + if let simplePost = remoteSimplePost { + switch simplePost.postType { + case .local: + WPAnalytics.track(.readerRelatedPostFromSameSiteClicked, properties: properties) + case .global: + WPAnalytics.track(.readerRelatedPostFromOtherSiteClicked, properties: properties) + default: + DDLogError("Unknown related post type: \(String(describing: simplePost.postType))") + } + } + + // Track open + WPAppAnalytics.track(.readerArticleOpened, withProperties: properties) + + if let railcar = readerPost.railcarDictionary() { + WPAppAnalytics.trackTrainTracksInteraction(.readerArticleOpened, withProperties: railcar) + } + } + + /// Bump post page view + /// + private func bumpPageViewsForPost() { + guard let readerPost = post else { + return + } + + ReaderHelpers.bumpPageViewForPost(readerPost) + } + + private struct DetailAnalyticsConstants { + static let TypeKey = "post_detail_type" + static let TypeNormal = "normal" + static let TypePreviewSite = "preview_site" + static let OfflineKey = "offline_view" + static let PixelStatReferrer = "https://wordpress.com/" + } +} + +// MARK: - ReaderDetailHeaderViewDelegate +extension ReaderDetailCoordinator: ReaderDetailHeaderViewDelegate { + func didTapBlogName() { + previewSite() + } + + func didTapMenuButton(_ sender: UIBarButtonItem) { + showMenu(.barButtonItem(sender)) + } + + func didTapMenuButton(_ sender: UIView) { + showMenu(.view(sender)) + } + + func didTapTagButton() { + showTag() + } + + func didTapHeaderAvatar() { + previewSite() + } + + func didTapFollowButton(completion: @escaping () -> Void) { + followSite(completion: completion) + } + + func didSelectTopic(_ topic: String) { + showTopic(topic) + } +} + +// MARK: - ReaderDetailFeaturedImageViewDelegate +extension ReaderDetailCoordinator: ReaderDetailFeaturedImageViewDelegate { + func didTapFeaturedImage(_ sender: CachedAnimatedImageView) { + showFeaturedImage(sender) + } +} + +// MARK: - ReaderDetailLikesViewDelegate +extension ReaderDetailCoordinator: ReaderDetailLikesViewDelegate { + func didTapLikesView() { + showLikesList() + } +} + +// MARK: - ReaderDetailToolbarDelegate +extension ReaderDetailCoordinator: ReaderDetailToolbarDelegate { + func didTapLikeButton(isLiked: Bool) { + guard let userAvatarURL = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext)?.avatarURL else { + return + } + + self.view?.updateSelfLike(with: isLiked ? userAvatarURL : nil) + } +} + +// MARK: - State Restoration + +extension ReaderDetailCoordinator { + static func viewController(withRestorationIdentifierPath identifierComponents: [String], + coder: NSCoder) -> UIViewController? { + guard let path = coder.decodeObject(forKey: restorablePostObjectURLKey) as? String else { + return nil + } + + let context = ContextManager.sharedInstance().mainContext + guard let url = URL(string: path), + let objectID = context.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: url) else { + return nil + } + + guard let post = (try? context.existingObject(with: objectID)) as? ReaderPost else { + return nil + } + + return ReaderDetailViewController.controllerWithPost(post) + } + + + func encodeRestorableState(with coder: NSCoder) { + if let post = post { + coder.encode(post.objectID.uriRepresentation().absoluteString, forKey: type(of: self).restorablePostObjectURLKey) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard new file mode 100644 index 000000000000..ae90b5beee53 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.storyboard @@ -0,0 +1,351 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="19162" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19144"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <scenes> + <!--Reader Detail View Controller--> + <scene sceneID="gjG-xz-jMc"> + <objects> + <viewController storyboardIdentifier="ReaderDetailViewController" extendedLayoutIncludesOpaqueBars="YES" useStoryboardIdentifierAsRestorationIdentifier="YES" id="Ene-ma-Cpi" customClass="ReaderDetailViewController" customModule="WordPress" sceneMemberID="viewController"> + <view key="view" contentMode="scaleToFill" id="HkO-UB-8qv"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" alwaysBounceVertical="YES" translatesAutoresizingMaskIntoConstraints="NO" id="9JA-VQ-zzw"> + <rect key="frame" x="0.0" y="44" width="414" height="768"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Xyq-y6-zPR"> + <rect key="frame" x="0.0" y="0.0" width="446" height="222.5"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </view> + <wkWebView contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="iSu-TI-yew" customClass="ReaderWebView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="16" y="238.5" width="414" height="0.0"/> + <color key="backgroundColor" red="0.36078431370000003" green="0.38823529410000002" blue="0.4039215686" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="width" constant="414" placeholder="YES" id="akw-kl-dl7"/> + <constraint firstAttribute="height" id="ywz-kG-xyW"/> + </constraints> + <wkWebViewConfiguration key="configuration"> + <dataDetectorTypes key="dataDetectorTypes" none="YES"/> + <audiovisualMediaTypes key="mediaTypesRequiringUserActionForPlayback" audio="YES" video="YES"/> + <wkPreferences key="preferences"/> + </wkWebViewConfiguration> + </wkWebView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="qXQ-id-Ffz" userLabel="Likes Container View"> + <rect key="frame" x="16" y="238.5" width="414" height="0.0"/> + <constraints> + <constraint firstAttribute="height" placeholder="YES" id="C8J-Hu-daf"/> + </constraints> + </view> + <tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" scrollEnabled="NO" dataMode="prototypes" style="plain" separatorStyle="none" allowsSelection="NO" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="6yS-ZE-nbR" customClass="IntrinsicTableView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="16" y="238.5" width="414" height="0.0"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="height" placeholder="YES" id="hNK-J4-GC2"/> + </constraints> + <sections/> + </tableView> + <tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="CpT-U7-bfv" customClass="IntrinsicTableView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="16" y="238.5" width="414" height="0.0"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="height" placeholder="YES" id="tci-Li-Egi"/> + </constraints> + </tableView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="O4e-BA-8jp"> + <rect key="frame" x="16" y="238.5" width="414" height="20.5"/> + <subviews> + <view clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Ewc-f7-89P" customClass="ReaderCardDiscoverAttributionView" customModule="WordPress"> + <rect key="frame" x="0.0" y="0.0" width="414" height="20.5"/> + <subviews> + <imageView userInteractionEnabled="NO" contentMode="scaleAspectFit" translatesAutoresizingMaskIntoConstraints="NO" id="NEe-UN-zaj" customClass="CircularImageView" customModule="WordPress"> + <rect key="frame" x="0.0" y="0.0" width="20" height="20"/> + <constraints> + <constraint firstAttribute="height" priority="999" constant="20" id="LME-RR-daf"/> + <constraint firstAttribute="width" constant="20" id="NrG-FK-J1s"/> + </constraints> + </imageView> + <label userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="1000" horizontalCompressionResistancePriority="740" verticalCompressionResistancePriority="1000" text="Attribution" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="D7G-k1-H0E"> + <rect key="frame" x="28" y="0.0" width="386" height="20.5"/> + <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <color key="textColor" red="0.66666666669999997" green="0.66666666669999997" blue="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstItem="NEe-UN-zaj" firstAttribute="leading" secondItem="Ewc-f7-89P" secondAttribute="leading" id="GKo-Xg-pwk"/> + <constraint firstAttribute="trailing" secondItem="D7G-k1-H0E" secondAttribute="trailing" id="Hwq-jr-GrU"/> + <constraint firstItem="D7G-k1-H0E" firstAttribute="top" secondItem="Ewc-f7-89P" secondAttribute="top" id="WUy-M3-Hb6"/> + <constraint firstItem="D7G-k1-H0E" firstAttribute="height" relation="greaterThanOrEqual" secondItem="NEe-UN-zaj" secondAttribute="height" id="iqj-BZ-ezI"/> + <constraint firstItem="NEe-UN-zaj" firstAttribute="top" secondItem="Ewc-f7-89P" secondAttribute="top" id="lFz-U6-ykF"/> + <constraint firstItem="D7G-k1-H0E" firstAttribute="leading" secondItem="NEe-UN-zaj" secondAttribute="trailing" constant="8" id="wYe-dA-TcC"/> + <constraint firstAttribute="bottom" secondItem="D7G-k1-H0E" secondAttribute="bottom" id="xYY-66-i2k"/> + </constraints> + <connections> + <outlet property="imageView" destination="NEe-UN-zaj" id="9sM-RI-9rU"/> + <outlet property="textLabel" destination="D7G-k1-H0E" id="437-Ec-cHF"/> + </connections> + </view> + </subviews> + </stackView> + </subviews> + <constraints> + <constraint firstItem="CpT-U7-bfv" firstAttribute="bottom" secondItem="O4e-BA-8jp" secondAttribute="top" id="167-j2-IYs"/> + <constraint firstItem="qXQ-id-Ffz" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="2pM-dV-doK"/> + <constraint firstItem="O4e-BA-8jp" firstAttribute="centerX" secondItem="iSu-TI-yew" secondAttribute="centerX" id="5DL-7x-ujm"/> + <constraint firstItem="qXQ-id-Ffz" firstAttribute="top" secondItem="iSu-TI-yew" secondAttribute="bottom" id="5ig-aY-DHW"/> + <constraint firstItem="CpT-U7-bfv" firstAttribute="centerX" secondItem="iSu-TI-yew" secondAttribute="centerX" id="6NH-H7-oE8"/> + <constraint firstItem="O4e-BA-8jp" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="8SP-Rw-zUY"/> + <constraint firstItem="iSu-TI-yew" firstAttribute="leading" secondItem="9JA-VQ-zzw" secondAttribute="leading" constant="16" placeholder="YES" id="9Vy-Wt-ZIb"/> + <constraint firstItem="6yS-ZE-nbR" firstAttribute="top" secondItem="qXQ-id-Ffz" secondAttribute="bottom" id="DJi-VX-sTS"/> + <constraint firstAttribute="trailing" secondItem="iSu-TI-yew" secondAttribute="trailing" constant="16" placeholder="YES" id="FvD-7O-znG"/> + <constraint firstItem="iSu-TI-yew" firstAttribute="top" secondItem="Xyq-y6-zPR" secondAttribute="bottom" constant="16" id="IET-mv-Ieo"/> + <constraint firstItem="Xyq-y6-zPR" firstAttribute="top" secondItem="9JA-VQ-zzw" secondAttribute="top" id="JZU-vN-GKO"/> + <constraint firstItem="6yS-ZE-nbR" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="LmZ-4g-gFE"/> + <constraint firstItem="Xyq-y6-zPR" firstAttribute="centerX" secondItem="iSu-TI-yew" secondAttribute="centerX" id="RTC-cI-v2j"/> + <constraint firstAttribute="bottom" secondItem="O4e-BA-8jp" secondAttribute="bottom" id="eFL-lL-cEF"/> + <constraint firstItem="qXQ-id-Ffz" firstAttribute="centerX" secondItem="iSu-TI-yew" secondAttribute="centerX" id="hjJ-VB-Pf0"/> + <constraint firstItem="eXr-4k-Adq" firstAttribute="bottom" secondItem="O4e-BA-8jp" secondAttribute="bottom" constant="509" placeholder="YES" id="pTD-l7-TPF"/> + <constraint firstItem="6yS-ZE-nbR" firstAttribute="centerX" secondItem="iSu-TI-yew" secondAttribute="centerX" id="r3l-5t-XeA"/> + <constraint firstItem="CpT-U7-bfv" firstAttribute="top" secondItem="6yS-ZE-nbR" secondAttribute="bottom" id="sQt-BP-vDY"/> + <constraint firstItem="CpT-U7-bfv" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="wUK-AO-ZOc"/> + <constraint firstItem="Xyq-y6-zPR" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" constant="32" id="xfj-7c-Lke"/> + </constraints> + <viewLayoutGuide key="contentLayoutGuide" id="QF8-fp-xzq"/> + <viewLayoutGuide key="frameLayoutGuide" id="eXr-4k-Adq"/> + <connections> + <outlet property="delegate" destination="HkO-UB-8qv" id="IYT-YI-eUs"/> + </connections> + </scrollView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Qzd-gm-oIu"> + <rect key="frame" x="0.0" y="812" width="414" height="50"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="50" id="jvh-iQ-g9a"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ERb-e0-U8L"> + <rect key="frame" x="0.0" y="862" width="414" height="34"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + </view> + <view contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="qnQ-Ld-x9K"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" verticalCompressionResistancePriority="250" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="vGc-hu-x5V"> + <rect key="frame" x="0.0" y="44" width="414" height="131"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="jeJ-N0-e8Q"> + <rect key="frame" x="0.0" y="0.0" width="414" height="66"/> + <subviews> + <imageView contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="post-blavatar-placeholder" translatesAutoresizingMaskIntoConstraints="NO" id="Mdc-5J-YVM"> + <rect key="frame" x="0.0" y="17" width="32" height="32"/> + <gestureRecognizers/> + <constraints> + <constraint firstAttribute="height" constant="32" id="TnR-00-9uC"/> + <constraint firstAttribute="width" constant="32" id="rQz-GG-hBa"/> + </constraints> + </imageView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wMZ-jx-Iu5"> + <rect key="frame" x="42" y="17" width="372" height="32"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + </view> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <gestureRecognizers/> + <constraints> + <constraint firstAttribute="trailing" secondItem="wMZ-jx-Iu5" secondAttribute="trailing" id="0YH-BF-9c5"/> + <constraint firstAttribute="height" constant="66" id="BqJ-nS-7ny"/> + <constraint firstItem="Mdc-5J-YVM" firstAttribute="leading" secondItem="jeJ-N0-e8Q" secondAttribute="leading" id="D7g-KO-qmC"/> + <constraint firstItem="wMZ-jx-Iu5" firstAttribute="centerY" secondItem="Mdc-5J-YVM" secondAttribute="centerY" id="TIH-Vj-Odd"/> + <constraint firstAttribute="centerY" secondItem="Mdc-5J-YVM" secondAttribute="centerY" id="Ug5-2K-LRx"/> + <constraint firstItem="wMZ-jx-Iu5" firstAttribute="leading" secondItem="Mdc-5J-YVM" secondAttribute="trailing" constant="10" id="cbv-bl-Hip"/> + <constraint firstItem="wMZ-jx-Iu5" firstAttribute="height" secondItem="Mdc-5J-YVM" secondAttribute="height" id="eET-vx-maV"/> + </constraints> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="1000" text="Post Title" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="0.0" translatesAutoresizingMaskIntoConstraints="NO" id="urB-nS-vfS"> + <rect key="frame" x="0.0" y="70" width="414" height="37"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleTitle0"/> + <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="xdF-0Y-F5a"> + <rect key="frame" x="0.0" y="111" width="414" height="20"/> + <subviews> + <imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar" translatesAutoresizingMaskIntoConstraints="NO" id="PpC-lI-LMW" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="20" height="20"/> + <constraints> + <constraint firstAttribute="height" constant="20" id="5xO-Xl-T5c"/> + <constraint firstAttribute="width" constant="20" id="7aG-wH-nxz"/> + </constraints> + </imageView> + <scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" translatesAutoresizingMaskIntoConstraints="NO" id="K5R-wY-gYF"> + <rect key="frame" x="20" y="0.0" width="394" height="20"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="2zG-Kt-Fnn"> + <rect key="frame" x="0.0" y="0.0" width="177.5" height="20"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QdV-SZ-JAP" userLabel="Padding View"> + <rect key="frame" x="0.0" y="0.0" width="0.0" height="20"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" id="5vc-RG-Jbg"/> + </constraints> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="af5-7i-SFO"> + <rect key="frame" x="6" y="0.0" width="165.5" height="20"/> + <fontDescription key="fontDescription" type="system" pointSize="12"/> + <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="KtF-pc-GzE" userLabel="Padding View"> + <rect key="frame" x="177.5" y="0.0" width="0.0" height="20"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="width" id="A21-ok-abH"/> + </constraints> + </view> + </subviews> + </stackView> + </subviews> + <constraints> + <constraint firstItem="2zG-Kt-Fnn" firstAttribute="top" secondItem="K5R-wY-gYF" secondAttribute="top" id="AF0-sH-g0R"/> + <constraint firstItem="2zG-Kt-Fnn" firstAttribute="leading" secondItem="K5R-wY-gYF" secondAttribute="leading" id="KaK-6W-xdL"/> + <constraint firstAttribute="bottom" secondItem="2zG-Kt-Fnn" secondAttribute="bottom" id="MWr-GO-USs"/> + <constraint firstAttribute="trailing" secondItem="2zG-Kt-Fnn" secondAttribute="trailing" id="sZp-bj-8d1"/> + </constraints> + </scrollView> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="bottom" secondItem="K5R-wY-gYF" secondAttribute="bottom" id="9fG-y3-oz6"/> + <constraint firstItem="PpC-lI-LMW" firstAttribute="centerY" secondItem="af5-7i-SFO" secondAttribute="centerY" id="AOo-da-S1K"/> + <constraint firstItem="PpC-lI-LMW" firstAttribute="leading" secondItem="xdF-0Y-F5a" secondAttribute="leading" id="Kkd-Jb-Quw"/> + <constraint firstItem="K5R-wY-gYF" firstAttribute="height" secondItem="PpC-lI-LMW" secondAttribute="height" id="L8B-jw-BTm"/> + <constraint firstAttribute="trailing" secondItem="K5R-wY-gYF" secondAttribute="trailing" id="PTB-ua-ci1"/> + <constraint firstItem="K5R-wY-gYF" firstAttribute="leading" secondItem="PpC-lI-LMW" secondAttribute="trailing" id="VmW-yg-0BW"/> + <constraint firstAttribute="height" secondItem="af5-7i-SFO" secondAttribute="height" id="oO2-UM-KHi"/> + <constraint firstItem="af5-7i-SFO" firstAttribute="width" secondItem="xdF-0Y-F5a" secondAttribute="width" multiplier="0.4" id="vPg-Rg-qCm"/> + <constraint firstItem="K5R-wY-gYF" firstAttribute="top" secondItem="xdF-0Y-F5a" secondAttribute="top" id="xOC-Y7-NN9"/> + </constraints> + </view> + </subviews> + <constraints> + <constraint firstItem="xdF-0Y-F5a" firstAttribute="leading" secondItem="vGc-hu-x5V" secondAttribute="leading" id="7qG-IM-xG2"/> + <constraint firstItem="urB-nS-vfS" firstAttribute="leading" secondItem="vGc-hu-x5V" secondAttribute="leading" id="9eq-9L-OWz"/> + <constraint firstAttribute="trailing" secondItem="urB-nS-vfS" secondAttribute="trailing" id="G5F-5x-a2E"/> + <constraint firstAttribute="trailing" secondItem="xdF-0Y-F5a" secondAttribute="trailing" id="LjP-fR-n8n"/> + <constraint firstItem="jeJ-N0-e8Q" firstAttribute="leading" secondItem="vGc-hu-x5V" secondAttribute="leading" id="d5b-d4-ImR"/> + <constraint firstAttribute="trailing" secondItem="jeJ-N0-e8Q" secondAttribute="trailing" id="f5F-p2-C8N"/> + </constraints> + </stackView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="EKd-IB-Upn"> + <rect key="frame" x="0.0" y="207" width="414" height="203"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="i7W-OY-6MY"> + <rect key="frame" x="0.0" y="0.0" width="414" height="17"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="vbt-ra-94i"> + <rect key="frame" x="0.0" y="25" width="414" height="17"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UdQ-G5-Hl2"> + <rect key="frame" x="0.0" y="50" width="414" height="17"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="xrm-JQ-hpv"> + <rect key="frame" x="0.0" y="75" width="414" height="128"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Jvg-nM-lV9"> + <rect key="frame" x="0.0" y="0.0" width="345" height="128"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstAttribute="bottom" secondItem="Jvg-nM-lV9" secondAttribute="bottom" id="05b-PA-2Gv"/> + <constraint firstItem="Jvg-nM-lV9" firstAttribute="top" secondItem="xrm-JQ-hpv" secondAttribute="top" id="2sk-af-ZIb"/> + <constraint firstItem="Jvg-nM-lV9" firstAttribute="leading" secondItem="xrm-JQ-hpv" secondAttribute="leading" id="LrL-Gi-lK2"/> + <constraint firstAttribute="trailing" secondItem="Jvg-nM-lV9" secondAttribute="trailing" multiplier="1.2" id="jsh-eE-xqT"/> + </constraints> + </view> + </subviews> + </stackView> + </subviews> + <viewLayoutGuide key="safeArea" id="EtS-rx-pLc"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="EKd-IB-Upn" firstAttribute="width" secondItem="vGc-hu-x5V" secondAttribute="width" id="aRu-Sg-NTM"/> + <constraint firstItem="vGc-hu-x5V" firstAttribute="top" secondItem="EtS-rx-pLc" secondAttribute="top" id="kf9-7G-BWF"/> + <constraint firstItem="EKd-IB-Upn" firstAttribute="centerX" secondItem="vGc-hu-x5V" secondAttribute="centerX" id="mCt-mM-2bL"/> + <constraint firstItem="EKd-IB-Upn" firstAttribute="top" secondItem="vGc-hu-x5V" secondAttribute="bottom" constant="32" id="p5l-dZ-qKp"/> + <constraint firstItem="vGc-hu-x5V" firstAttribute="centerX" secondItem="qnQ-Ld-x9K" secondAttribute="centerX" id="pMy-9R-nWF"/> + </constraints> + </view> + </subviews> + <viewLayoutGuide key="safeArea" id="Tqp-x3-yXv"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <constraints> + <constraint firstItem="Tqp-x3-yXv" firstAttribute="trailing" secondItem="qnQ-Ld-x9K" secondAttribute="trailing" id="7q2-Rq-Wbt"/> + <constraint firstItem="ERb-e0-U8L" firstAttribute="top" secondItem="Qzd-gm-oIu" secondAttribute="bottom" id="AWw-Um-NsT"/> + <constraint firstItem="Qzd-gm-oIu" firstAttribute="top" secondItem="9JA-VQ-zzw" secondAttribute="bottom" id="BHA-14-Hde"/> + <constraint firstItem="ERb-e0-U8L" firstAttribute="width" secondItem="Qzd-gm-oIu" secondAttribute="width" id="D8P-Xt-kyR"/> + <constraint firstItem="9JA-VQ-zzw" firstAttribute="top" secondItem="Tqp-x3-yXv" secondAttribute="top" id="JMy-49-ddC"/> + <constraint firstItem="9JA-VQ-zzw" firstAttribute="leading" secondItem="Tqp-x3-yXv" secondAttribute="leading" id="KOc-Yv-UWy"/> + <constraint firstItem="Qzd-gm-oIu" firstAttribute="leading" secondItem="Tqp-x3-yXv" secondAttribute="leading" id="PNw-Cb-AvC"/> + <constraint firstAttribute="bottom" secondItem="ERb-e0-U8L" secondAttribute="bottom" id="fPU-nx-gzV"/> + <constraint firstAttribute="bottom" secondItem="qnQ-Ld-x9K" secondAttribute="bottom" id="jDb-2v-GlQ"/> + <constraint firstItem="ERb-e0-U8L" firstAttribute="centerX" secondItem="Qzd-gm-oIu" secondAttribute="centerX" id="kuZ-bk-VtY"/> + <constraint firstItem="qnQ-Ld-x9K" firstAttribute="leading" secondItem="Tqp-x3-yXv" secondAttribute="leading" id="mRn-49-Gms"/> + <constraint firstItem="Tqp-x3-yXv" firstAttribute="bottom" secondItem="Qzd-gm-oIu" secondAttribute="bottom" id="p2r-l3-0Mh"/> + <constraint firstItem="vGc-hu-x5V" firstAttribute="width" secondItem="iSu-TI-yew" secondAttribute="width" id="tF3-Q4-q8b"/> + <constraint firstItem="Tqp-x3-yXv" firstAttribute="trailing" secondItem="9JA-VQ-zzw" secondAttribute="trailing" id="u3i-rm-kZv"/> + <constraint firstItem="Tqp-x3-yXv" firstAttribute="trailing" secondItem="Qzd-gm-oIu" secondAttribute="trailing" id="zR2-IL-BwU"/> + <constraint firstItem="qnQ-Ld-x9K" firstAttribute="top" secondItem="HkO-UB-8qv" secondAttribute="top" id="zgv-gp-j1Q"/> + </constraints> + </view> + <connections> + <outlet property="actionStackView" destination="O4e-BA-8jp" id="Ro3-aL-ekY"/> + <outlet property="attributionView" destination="Ewc-f7-89P" id="Pwq-Hm-VfQ"/> + <outlet property="commentsTableView" destination="6yS-ZE-nbR" id="Va9-bB-B8V"/> + <outlet property="headerContainerView" destination="Xyq-y6-zPR" id="duy-5z-Fdl"/> + <outlet property="likesContainerView" destination="qXQ-id-Ffz" id="DL3-un-wtF"/> + <outlet property="loadingView" destination="qnQ-Ld-x9K" id="D1T-sa-IvL"/> + <outlet property="relatedPostsTableView" destination="CpT-U7-bfv" id="Ndh-H4-FlR"/> + <outlet property="scrollView" destination="9JA-VQ-zzw" id="lCO-o1-bLB"/> + <outlet property="toolbarContainerView" destination="Qzd-gm-oIu" id="Esk-Iq-Wbd"/> + <outlet property="toolbarSafeAreaView" destination="ERb-e0-U8L" id="sVN-sI-9e5"/> + <outlet property="webView" destination="iSu-TI-yew" id="DQy-Fd-C3y"/> + <outlet property="webViewHeight" destination="ywz-kG-xyW" id="q3p-wI-yeb"/> + </connections> + </viewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="aGx-LJ-atS" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/> + </objects> + <point key="canvasLocation" x="148" y="32"/> + </scene> + </scenes> + <resources> + <image name="gravatar" width="85" height="85"/> + <image name="post-blavatar-placeholder" width="32" height="32"/> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift new file mode 100644 index 000000000000..f258f1b51ff5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/ReaderDetailViewController.swift @@ -0,0 +1,1200 @@ +import UIKit + +typealias RelatedPostsSection = (postType: RemoteReaderSimplePost.PostType, posts: [RemoteReaderSimplePost]) + +protocol ReaderDetailView: AnyObject { + func render(_ post: ReaderPost) + func renderRelatedPosts(_ posts: [RemoteReaderSimplePost]) + func showLoading() + func showError() + func showErrorWithWebAction() + func scroll(to: String) + func updateHeader() + + /// Shows likes view containing avatars of users that liked the post. + /// The number of avatars displayed is limited to `ReaderDetailView.maxAvatarDisplayed` plus the current user's avatar. + /// Note that the current user's avatar is displayed through a different method. + /// + /// - Seealso: `updateSelfLike(with avatarURLString: String?)` + /// - Parameters: + /// - avatarURLStrings: A list of URL strings for the liking users' avatars. + /// - totalLikes: The total number of likes for this post. + func updateLikes(with avatarURLStrings: [String], totalLikes: Int) + + /// Updates the likes view to append an additional avatar for the current user, indicating that the post is liked by current user. + /// - Parameter avatarURLString: The URL string for the current user's avatar. Optional. + func updateSelfLike(with avatarURLString: String?) + + /// Updates comments table to display the post's comments. + /// - Parameters: + /// - comments: Comments to be displayed. + /// - totalComments: The total number of comments for this post. + func updateComments(_ comments: [Comment], totalComments: Int) +} + +class ReaderDetailViewController: UIViewController, ReaderDetailView { + + /// Content scroll view + @IBOutlet weak var scrollView: UIScrollView! + + /// A ReaderWebView + @IBOutlet weak var webView: ReaderWebView! + + /// WebView height constraint + @IBOutlet weak var webViewHeight: NSLayoutConstraint! + + /// The table view that displays Comments + @IBOutlet weak var commentsTableView: IntrinsicTableView! + private let commentsTableViewDelegate = ReaderDetailCommentsTableViewDelegate() + + /// The table view that displays Related Posts + @IBOutlet weak var relatedPostsTableView: IntrinsicTableView! + + /// Header container + @IBOutlet weak var headerContainerView: UIView! + + /// Wrapper for the toolbar + @IBOutlet weak var toolbarContainerView: UIView! + + /// Wrapper for the Likes summary view + @IBOutlet weak var likesContainerView: UIView! + + /// The loading view, which contains all the ghost views + @IBOutlet weak var loadingView: UIView! + + /// The loading view, which contains all the ghost views + @IBOutlet weak var actionStackView: UIStackView! + + /// Attribution view for Discovery posts + @IBOutlet weak var attributionView: ReaderCardDiscoverAttributionView! + + /// The actual header + private let featuredImage: ReaderDetailFeaturedImageView = .loadFromNib() + + /// The actual header + private let header: ReaderDetailHeaderView = .loadFromNib() + + /// Bottom toolbar + private let toolbar: ReaderDetailToolbar = .loadFromNib() + + /// Likes summary view + private let likesSummary: ReaderDetailLikesView = .loadFromNib() + + /// A view that fills the bottom portion outside of the safe area + @IBOutlet weak var toolbarSafeAreaView: UIView! + + /// View used to show errors + private let noResultsViewController = NoResultsViewController.controller() + + /// An observer of the content size of the webview + private var scrollObserver: NSKeyValueObservation? + + private var featureHighlightStore = FeatureHighlightStore() + private var lastToggleAnchorVisibility = false + private var didShowTooltip = false { + didSet { + featureHighlightStore.followConversationTooltipCounter += 1 + } + } + + /// The coordinator, responsible for the logic + var coordinator: ReaderDetailCoordinator? + + /// Hide the comments button in the toolbar + @objc var shouldHideComments: Bool = false { + didSet { + toolbar.shouldHideComments = shouldHideComments + } + } + + /// The post being shown + @objc var post: ReaderPost? { + return coordinator?.post + } + + /// The related posts for the post being shown + var relatedPosts: [RelatedPostsSection] = [] + + /// Called if the view controller's post fails to load + var postLoadFailureBlock: (() -> Void)? { + didSet { + coordinator?.postLoadFailureBlock = postLoadFailureBlock + } + } + + var currentPreferredStatusBarStyle = UIStatusBarStyle.lightContent { + didSet { + setNeedsStatusBarAppearanceUpdate() + } + } + + override open var preferredStatusBarStyle: UIStatusBarStyle { + return currentPreferredStatusBarStyle + } + + override var hidesBottomBarWhenPushed: Bool { + set { } + get { true } + } + + /// Tracks whether the webview has called -didFinish:navigation + var isLoadingWebView = true + + /// Temporary work around until white headers are shipped app-wide, + /// allowing Reader Detail to use a blue navbar. + var useCompatibilityMode: Bool { + // Use compatibility mode if not presented within the Reader + guard let readerNavigationController = RootViewCoordinator.sharedPresenter.readerNavigationController else { + return false + } + return readerNavigationController.viewControllers.contains(self) == false + } + + /// Used to disable ineffective buttons when a Related post fails to load. + var enableRightBarButtons = true + + /// Track whether we've automatically navigated to the comments view or not. + /// This may happen if we initialize our coordinator with a postURL that + /// has a comment anchor fragment. + private var hasAutomaticallyTriggeredCommentAction = false + + private var tooltipPresenter: TooltipPresenter? + + override func viewDidLoad() { + super.viewDidLoad() + + configureNavigationBar() + applyStyles() + configureWebView() + configureFeaturedImage() + configureHeader() + configureRelatedPosts() + configureToolbar() + configureNoResultsViewController() + observeWebViewHeight() + configureNotifications() + configureCommentsTable() + + coordinator?.start() + + // Fixes swipe to go back not working when leftBarButtonItem is set + navigationController?.interactivePopGestureRecognizer?.delegate = self + + // When comments are moderated or edited from the Comments view, update the Comments snippet here. + NotificationCenter.default.addObserver(self, selector: #selector(fetchComments), name: .ReaderCommentModifiedNotification, object: nil) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + setupFeaturedImage() + updateFollowButtonState() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + guard let controller = navigationController, !controller.isBeingDismissed else { + return + } + + featuredImage.viewWillDisappear() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + ReaderTracker.shared.start(.readerPost) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidAppear(animated) + + ReaderTracker.shared.stop(.readerPost) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate(alongsideTransition: { _ in + self.featuredImage.deviceDidRotate() + }) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + // Bar items may change if we're moving single pane to split view + self.configureNavigationBar() + } + + override func accessibilityPerformEscape() -> Bool { + navigationController?.popViewController(animated: true) + return true + } + + func render(_ post: ReaderPost) { + configureDiscoverAttribution(post) + + featuredImage.configure(for: post, with: self) + toolbar.configure(for: post, in: self) + header.configure(for: post) + fetchLikes() + fetchComments() + + if let postURLString = post.permaLink, + let postURL = URL(string: postURLString) { + webView.postURL = postURL + } + + coordinator?.storeAuthenticationCookies(in: webView) { [weak self] in + self?.webView.loadHTMLString(post.contentForDisplay()) + } + + guard !featuredImage.isLoaded else { + return + } + + // Load the image + featuredImage.load { [weak self] in + self?.hideLoading() + } + + navigateToCommentIfNecessary() + } + + func renderRelatedPosts(_ posts: [RemoteReaderSimplePost]) { + let groupedPosts = Dictionary(grouping: posts, by: { $0.postType }) + let sections = groupedPosts.map { RelatedPostsSection(postType: $0.key, posts: $0.value) } + relatedPosts = sections.sorted { $0.postType.rawValue < $1.postType.rawValue } + relatedPostsTableView.reloadData() + relatedPostsTableView.invalidateIntrinsicContentSize() + } + + private func tooltipTargetPoint() -> CGPoint { + setupFeaturedImage() + updateFollowButtonState() + guard let followButtonMidPoint = commentsTableViewDelegate.followButtonMidPoint() else { + return .zero + } + + return CGPoint( + x: commentsTableView.frame.minX + followButtonMidPoint.x, + y: commentsTableView.frame.minY + followButtonMidPoint.y + ) + } + + private func configureTooltipPresenter(anchorAction: (() -> Void)?) { + let tooltip = Tooltip() + + tooltip.title = Strings.tooltipTitle + tooltip.message = Strings.tooltipMessage + tooltip.primaryButtonTitle = Strings.tooltipButtonTitle + + tooltipPresenter = TooltipPresenter( + containerView: scrollView, + tooltip: tooltip, + target: .point(tooltipTargetPoint), + shouldShowSpotlightView: true, + primaryTooltipAction: { [weak self] in + self?.featureHighlightStore.didDismissTooltip = true + WPAnalytics.trackReader(.readerFollowConversationTooltipTapped) + } + ) + tooltipPresenter?.tooltipVerticalPosition = .above + + if let anchorAction = anchorAction { + tooltipPresenter?.attachAnchor( + withTitle: Strings.tooltipAnchorTitle, + onView: view, + anchorAction: anchorAction + ) + } + + scrollView.delegate = self + + let isCommentsTableViewVisible = isVisibleInScrollView(commentsTableView) + if isCommentsTableViewVisible { + tooltipPresenter?.showTooltip() + didShowTooltip = true + scrollView.layoutIfNeeded() + } + + tooltipPresenter?.toggleAnchorVisibility(!isCommentsTableViewVisible) + } + + private func scrollToTooltip() { + scrollView.setContentOffset(CGPoint(x: 0, y: tooltipTargetPoint().y - scrollView.frame.height/2), animated: true) + scrollView.layoutIfNeeded() + } + + private func navigateToCommentIfNecessary() { + if let post = post, + let commentID = coordinator?.commentID, + !hasAutomaticallyTriggeredCommentAction { + hasAutomaticallyTriggeredCommentAction = true + + ReaderCommentAction().execute(post: post, + origin: self, + promptToAddComment: false, + navigateToCommentID: commentID, + source: .postDetails) + } + } + + /// Show ghost cells indicating the content is loading + func showLoading() { + let style = GhostStyle(beatDuration: GhostStyle.Defaults.beatDuration, + beatStartColor: .placeholderElement, + beatEndColor: .placeholderElementFaded) + + loadingView.startGhostAnimation(style: style) + } + + /// Hide the ghost cells + func hideLoading() { + guard !featuredImage.isLoading, !isLoadingWebView else { + return + } + + UIView.animate(withDuration: 0.3, animations: { + self.loadingView.alpha = 0.0 + }) { (_) in + self.loadingView.isHidden = true + self.loadingView.stopGhostAnimation() + self.loadingView.alpha = 1.0 + } + + guard let post = post else { + return + } + + coordinator?.fetchRelatedPosts(for: post) + } + + /// Shown an error + func showError() { + isLoadingWebView = false + hideLoading() + + displayLoadingView(title: LoadingText.errorLoadingTitle) + } + + /// Shown an error with a button to open the post on the browser + func showErrorWithWebAction() { + displayLoadingViewWithWebAction(title: LoadingText.errorLoadingTitle) + } + + @objc func willEnterForeground() { + guard isViewOnScreen() else { + return + } + + ReaderTracker.shared.start(.readerPost) + } + + /// Scroll the content to a given #hash + /// + func scroll(to hash: String) { + webView.evaluateJavaScript("document.getElementById('\(hash)').offsetTop", completionHandler: { [unowned self] height, _ in + guard let height = height as? CGFloat else { + return + } + + self.scrollView.setContentOffset(CGPoint(x: 0, y: height + self.webView.frame.origin.y), animated: true) + }) + } + + func updateHeader() { + header.refreshFollowButton() + } + + func updateLikes(with avatarURLStrings: [String], totalLikes: Int) { + // always configure likes summary view first regardless of totalLikes, since it can affected by self likes. + likesSummary.configure(with: avatarURLStrings, totalLikes: totalLikes) + + guard totalLikes > 0 else { + hideLikesView() + return + } + + if likesSummary.superview == nil { + configureLikesSummary() + } + + scrollView.layoutIfNeeded() + + // Delay configuration due to the sideeffect in fresh install. + // The position calculation is wrong for the first time this VC is opened + // regardless of the post. It never happens after that. Although the timing + // of this call is accurate, the calculation returns wrong result on that case. + // This manually delays the configuration and hacks the issue. + // We can remove this once the culprit is out. + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + if self.shouldConfigureTooltipPresenter() { + self.configureTooltipPresenter { [weak self] in + self?.scrollToTooltip() + WPAnalytics.trackReader(.readerFollowConversationAnchorTapped) + } + } + } + } + + private func shouldConfigureTooltipPresenter() -> Bool { + FeatureFlag.featureHighlightTooltip.enabled + && featureHighlightStore.shouldShowTooltip + && (post?.canSubscribeComments ?? false) + && (!(post?.isSubscribedComments ?? false)) + } + + func updateSelfLike(with avatarURLString: String?) { + // only animate changes when the view is visible. + let shouldAnimate = isVisibleInScrollView(likesSummary) + guard let someURLString = avatarURLString else { + likesSummary.removeSelfAvatar(animated: shouldAnimate) + if likesSummary.totalLikesForDisplay == 0 { + hideLikesView() + } + return + } + + if likesSummary.superview == nil { + configureLikesSummary() + } + + likesSummary.addSelfAvatar(with: someURLString, animated: shouldAnimate) + } + + func updateComments(_ comments: [Comment], totalComments: Int) { + guard let post = post else { + DDLogError("Missing post when updating Reader post detail comments.") + return + } + + // Moderated comments could still be cached, so filter out non-approved comments. + let approvedStatus = Comment.descriptionFor(.approved) + let approvedComments = comments.filter({ $0.status == approvedStatus}) + + // Set the delegate here so the table isn't shown until fetching is complete. + commentsTableView.delegate = commentsTableViewDelegate + commentsTableView.dataSource = commentsTableViewDelegate + commentsTableViewDelegate.followButtonTappedClosure = { [weak self] in + guard let tooltipPresenter = self?.tooltipPresenter else { + return + } + + self?.featureHighlightStore.didDismissTooltip = true + tooltipPresenter.dismissTooltip() + } + + commentsTableViewDelegate.updateWith(post: post, + comments: approvedComments, + totalComments: totalComments, + presentingViewController: self, + buttonDelegate: self) + + commentsTableView.reloadData() + } + + func updateFollowButtonState() { + guard let post = post else { + return + } + + commentsTableViewDelegate.updateFollowButtonState(post: post) + } + + deinit { + scrollObserver?.invalidate() + NotificationCenter.default.removeObserver(self) + } + + /// Apply view styles + private func applyStyles() { + guard let readableGuide = webView.superview?.readableContentGuide else { + return + } + + NSLayoutConstraint.activate([ + webView.rightAnchor.constraint(equalTo: readableGuide.rightAnchor, constant: -Constants.margin), + webView.leftAnchor.constraint(equalTo: readableGuide.leftAnchor, constant: Constants.margin) + ]) + + webView.translatesAutoresizingMaskIntoConstraints = false + + // Webview is scroll is done by it's superview + webView.scrollView.isScrollEnabled = false + } + + /// Configure the webview + private func configureWebView() { + webView.navigationDelegate = self + } + + /// Updates the webview height constraint with it's height + private func observeWebViewHeight() { + scrollObserver = webView.scrollView.observe(\.contentSize, options: .new) { [weak self] _, change in + guard let height = change.newValue?.height else { + return + } + + /// ScrollHeight returned by JS is always more accurated as the value from the contentSize + /// (except for a few times when it returns a very big weird number) + /// We use that value so the content is not displayed with weird empty space at the bottom + /// + self?.webView.evaluateJavaScript("document.body.scrollHeight", completionHandler: { (webViewHeight, error) in + guard let webViewHeight = webViewHeight as? CGFloat else { + self?.webViewHeight.constant = height + return + } + + self?.webViewHeight.constant = min(height, webViewHeight) + }) + } + } + + private func setupFeaturedImage() { + configureFeaturedImage() + + featuredImage.configure( + scrollView: scrollView, + navigationBar: navigationController?.navigationBar, + navigationItem: navigationItem + ) + + guard !featuredImage.isLoaded else { + return + } + + // Load the image + featuredImage.load { [unowned self] in + self.hideLoading() + } + } + + private func configureFeaturedImage() { + guard featuredImage.superview == nil else { + return + } + + featuredImage.useCompatibilityMode = useCompatibilityMode + + featuredImage.delegate = coordinator + + view.insertSubview(featuredImage, belowSubview: loadingView) + + NSLayoutConstraint.activate([ + featuredImage.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0), + featuredImage.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0), + featuredImage.topAnchor.constraint(equalTo: view.topAnchor, constant: 0) + ]) + + headerContainerView.translatesAutoresizingMaskIntoConstraints = false + } + + private func configureHeader() { + header.useCompatibilityMode = useCompatibilityMode + header.delegate = coordinator + headerContainerView.addSubview(header) + headerContainerView.translatesAutoresizingMaskIntoConstraints = false + + headerContainerView.pinSubviewToAllEdges(header) + headerContainerView.heightAnchor.constraint(equalTo: header.heightAnchor).isActive = true + } + + private func fetchLikes() { + guard let post = post else { + return + } + + coordinator?.fetchLikes(for: post) + } + + private func configureLikesSummary() { + likesSummary.delegate = coordinator + likesContainerView.addSubview(likesSummary) + likesContainerView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + likesSummary.topAnchor.constraint(equalTo: likesContainerView.topAnchor), + likesSummary.bottomAnchor.constraint(equalTo: likesContainerView.bottomAnchor), + likesSummary.leadingAnchor.constraint(equalTo: likesContainerView.leadingAnchor), + likesSummary.trailingAnchor.constraint(lessThanOrEqualTo: likesContainerView.trailingAnchor) + ]) + } + + private func hideLikesView() { + // Because other components are constrained to the likesContainerView, simply hiding it leaves a gap. + likesSummary.removeFromSuperview() + likesContainerView.frame.size.height = 0 + view.setNeedsDisplay() + } + + @objc private func fetchComments() { + guard let post = post else { + return + } + + coordinator?.fetchComments(for: post) + } + + private func configureCommentsTable() { + commentsTableView.register(ReaderDetailCommentsHeader.defaultNib, + forHeaderFooterViewReuseIdentifier: ReaderDetailCommentsHeader.defaultReuseID) + commentsTableView.register(CommentContentTableViewCell.defaultNib, + forCellReuseIdentifier: CommentContentTableViewCell.defaultReuseID) + commentsTableView.register(ReaderDetailNoCommentCell.defaultNib, + forCellReuseIdentifier: ReaderDetailNoCommentCell.defaultReuseID) + } + + private func configureRelatedPosts() { + relatedPostsTableView.isScrollEnabled = false + relatedPostsTableView.separatorStyle = .none + + relatedPostsTableView.register(ReaderRelatedPostsCell.defaultNib, + forCellReuseIdentifier: ReaderRelatedPostsCell.defaultReuseID) + relatedPostsTableView.register(ReaderRelatedPostsSectionHeaderView.defaultNib, + forHeaderFooterViewReuseIdentifier: ReaderRelatedPostsSectionHeaderView.defaultReuseID) + + relatedPostsTableView.dataSource = self + relatedPostsTableView.delegate = self + } + + private func configureToolbar() { + toolbar.delegate = coordinator + toolbarContainerView.addSubview(toolbar) + toolbarContainerView.translatesAutoresizingMaskIntoConstraints = false + + toolbarContainerView.pinSubviewToAllEdges(toolbar) + toolbarSafeAreaView.backgroundColor = toolbar.backgroundColor + } + + private func configureDiscoverAttribution(_ post: ReaderPost) { + if post.sourceAttributionStyle() == .none { + attributionView.isHidden = true + } else { + attributionView.displayAsLink = true + attributionView.translatesAutoresizingMaskIntoConstraints = false + attributionView.configureViewWithVerboseSiteAttribution(post) + attributionView.delegate = self + attributionView.backgroundColor = .clear + } + } + + /// Configure the NoResultsViewController + /// + private func configureNoResultsViewController() { + noResultsViewController.delegate = self + } + + private func configureNotifications() { + NotificationCenter.default.addObserver(self, + selector: #selector(willEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(siteBlocked(_:)), + name: .ReaderSiteBlocked, + object: nil) + } + + @objc private func siteBlocked(_ notification: Foundation.Notification) { + navigationController?.popViewController(animated: true) + dismiss(animated: true, completion: nil) + } + + /// Ask the coordinator to present the share sheet + /// + @objc func didTapShareButton(_ sender: UIBarButtonItem) { + coordinator?.share(fromAnchor: .barButtonItem(sender)) + } + + @objc func didTapMenuButton(_ sender: UIBarButtonItem) { + coordinator?.didTapMenuButton(sender) + } + + @objc func didTapBrowserButton(_ sender: UIBarButtonItem) { + coordinator?.openInBrowser() + } + + /// A View Controller that displays a Post content. + /// + /// Use this method to present content for the user. + /// - Parameter postID: a post identification + /// - Parameter siteID: a site identification + /// - Parameter isFeed: a Boolean indicating if the site is an external feed (not hosted at WPcom and not using Jetpack) + /// - Returns: A `ReaderDetailViewController` instance + @objc class func controllerWithPostID(_ postID: NSNumber, siteID: NSNumber, isFeed: Bool = false) -> ReaderDetailViewController { + let controller = ReaderDetailViewController.loadFromStoryboard() + let coordinator = ReaderDetailCoordinator(view: controller) + coordinator.set(postID: postID, siteID: siteID, isFeed: isFeed) + controller.coordinator = coordinator + + return controller + } + + /// A View Controller that displays a Post content. + /// + /// Use this method to present content for the user. + /// - Parameter url: an URL of the post. + /// - Returns: A `ReaderDetailViewController` instance + @objc class func controllerWithPostURL(_ url: URL) -> ReaderDetailViewController { + let controller = ReaderDetailViewController.loadFromStoryboard() + let coordinator = ReaderDetailCoordinator(view: controller) + coordinator.postURL = url + controller.coordinator = coordinator + + return controller + } + + + /// Creates an instance from a Related post / Simple Post + /// - Parameter simplePost: The related post object + /// - Returns: If the related post URL is not valid + class func controllerWithSimplePost(_ simplePost: RemoteReaderSimplePost) -> ReaderDetailViewController? { + guard !simplePost.postUrl.isEmpty(), let url = URL(string: simplePost.postUrl) else { + return nil + } + + let controller = ReaderDetailViewController.loadFromStoryboard() + let coordinator = ReaderDetailCoordinator(view: controller) + coordinator.postURL = url + coordinator.remoteSimplePost = simplePost + controller.coordinator = coordinator + + controller.postLoadFailureBlock = { + controller.enableRightBarButtons = false + } + + return controller + } + + /// A View Controller that displays a Post content. + /// + /// Use this method to present content for the user. + /// - Parameter post: a Reader Post + /// - Returns: A `ReaderDetailViewController` instance + @objc class func controllerWithPost(_ post: ReaderPost) -> ReaderDetailViewController { + if post.sourceAttributionStyle() == .post && + post.sourceAttribution.postID != nil && + post.sourceAttribution.blogID != nil { + return ReaderDetailViewController.controllerWithPostID(post.sourceAttribution.postID!, siteID: post.sourceAttribution.blogID!) + } else if post.isCross() { + return ReaderDetailViewController.controllerWithPostID(post.crossPostMeta.postID, siteID: post.crossPostMeta.siteID) + } else { + let controller = ReaderDetailViewController.loadFromStoryboard() + let coordinator = ReaderDetailCoordinator(view: controller) + coordinator.post = post + controller.coordinator = coordinator + return controller + } + } + + private enum Constants { + static let margin: CGFloat = UIDevice.isPad() ? 0 : 8 + static let bottomMargin: CGFloat = 16 + static let toolbarHeight: CGFloat = 50 + static let delay: Double = 50 + } +} + +// MARK: - StoryboardLoadable + +extension ReaderDetailViewController: StoryboardLoadable { + static var defaultStoryboardName: String { + return "ReaderDetailViewController" + } +} + +// MARK: - Related Posts + +extension ReaderDetailViewController: UITableViewDataSource, UITableViewDelegate { + + func numberOfSections(in tableView: UITableView) -> Int { + return relatedPosts.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return relatedPosts[section].posts.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: ReaderRelatedPostsCell.defaultReuseID, for: indexPath) as? ReaderRelatedPostsCell else { + fatalError("Expected RelatedPostsTableViewCell with identifier: \(ReaderRelatedPostsCell.defaultReuseID)") + } + + let post = relatedPosts[indexPath.section].posts[indexPath.row] + cell.configure(for: post) + return cell + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let title = getSectionTitle(for: relatedPosts[section].postType), + let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: ReaderRelatedPostsSectionHeaderView.defaultReuseID) as? ReaderRelatedPostsSectionHeaderView else { + return UIView(frame: .zero) + } + + header.titleLabel.text = title + + return header + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return ReaderRelatedPostsSectionHeaderView.height + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let post = relatedPosts[indexPath.section].posts[indexPath.row] + + guard let controller = ReaderDetailViewController.controllerWithSimplePost(post) else { + return + } + + // Related posts should be presented in its own nav stack, + // so that a user can return to the original post by dismissing the related posts nav stack. + if navigationController?.viewControllers.first is ReaderDetailViewController { + navigationController?.pushViewController(controller, animated: true) + } else { + let nav = UINavigationController(rootViewController: controller) + self.present(nav, animated: true) + } + } + + private func getSectionTitle(for postType: RemoteReaderSimplePost.PostType) -> String? { + switch postType { + case .local: + guard let blogName = post?.blogNameForDisplay() else { + return nil + } + return String(format: Strings.localPostsSectionTitle, blogName) + case .global: + return Strings.globalPostsSectionTitle + default: + return nil + } + } +} + +// MARK: - UIGestureRecognizerDelegate +extension ReaderDetailViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } +} + +// MARK: - Reader Card Discover + +extension ReaderDetailViewController: ReaderCardDiscoverAttributionViewDelegate { + public func attributionActionSelectedForVisitingSite(_ view: ReaderCardDiscoverAttributionView) { + coordinator?.showMore() + } +} + +// MARK: - UpdatableStatusBarStyle +extension ReaderDetailViewController: UpdatableStatusBarStyle { + func updateStatusBarStyle(to style: UIStatusBarStyle) { + guard style != currentPreferredStatusBarStyle else { + return + } + + currentPreferredStatusBarStyle = style + } +} + +// MARK: - Transitioning Delegate + +extension ReaderDetailViewController: UIViewControllerTransitioningDelegate { + public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + guard presented is FancyAlertViewController else { + return nil + } + + return FancyAlertPresentationController(presentedViewController: presented, presenting: presenting) + } +} + +// MARK: - Navigation Delegate + +extension ReaderDetailViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + coordinator?.webViewDidLoad() + self.webView.loadMedia() + + isLoadingWebView = false + hideLoading() + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if navigationAction.navigationType == .linkActivated { + if let url = navigationAction.request.url { + coordinator?.handle(url) + } + decisionHandler(.cancel) + } else { + decisionHandler(.allow) + } + } +} + +// MARK: - Error View Handling (NoResultsViewController) + +private extension ReaderDetailViewController { + func displayLoadingView(title: String, accessoryView: UIView? = nil) { + noResultsViewController.configure(title: title, accessoryView: accessoryView) + showLoadingView() + } + + func displayLoadingViewWithWebAction(title: String, accessoryView: UIView? = nil) { + noResultsViewController.configure(title: title, + buttonTitle: LoadingText.errorLoadingPostURLButtonTitle, + accessoryView: accessoryView) + showLoadingView() + } + + func showLoadingView() { + hideLoadingView() + addChild(noResultsViewController) + view.addSubview(withFadeAnimation: noResultsViewController.view) + noResultsViewController.didMove(toParent: self) + + noResultsViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.pinSubviewToAllEdges(noResultsViewController.view) + } + + func hideLoadingView() { + noResultsViewController.removeFromView() + } + + struct LoadingText { + static let errorLoadingTitle = NSLocalizedString("Error Loading Post", comment: "Text displayed when load post fails.") + static let errorLoadingPostURLButtonTitle = NSLocalizedString("Open in browser", comment: "Button title to load a post in an in-app web view") + } + +} + +// MARK: - Navigation Bar Configuration +private extension ReaderDetailViewController { + + func configureNavigationBar() { + + // If a Related post fails to load, disable the More and Share buttons as they won't do anything. + let rightItems = [ + moreButtonItem(enabled: enableRightBarButtons), + shareButtonItem(enabled: enableRightBarButtons), + safariButtonItem() + ] + + if !isModal() { + navigationItem.leftBarButtonItem = backButtonItem() + } else { + navigationItem.leftBarButtonItem = dismissButtonItem() + } + navigationItem.largeTitleDisplayMode = .never + navigationItem.rightBarButtonItems = rightItems.compactMap({ $0 }) + } + + func backButtonItem() -> UIBarButtonItem { + let button = barButtonItem(with: .gridicon(.chevronLeft), action: #selector(didTapBackButton(_:))) + button.accessibilityLabel = Strings.backButtonAccessibilityLabel + + return button + } + + @objc func didTapBackButton(_ sender: UIButton) { + navigationController?.popViewController(animated: true) + } + + func dismissButtonItem() -> UIBarButtonItem { + let button = barButtonItem(with: .gridicon(.chevronDown), action: #selector(didTapDismissButton(_:))) + button.accessibilityLabel = Strings.dismissButtonAccessibilityLabel + + return button + } + + @objc func didTapDismissButton(_ sender: UIButton) { + dismiss(animated: true) + } + + func safariButtonItem() -> UIBarButtonItem? { + let button = barButtonItem(with: .gridicon(.globe), action: #selector(didTapBrowserButton(_:))) + button.accessibilityLabel = Strings.safariButtonAccessibilityLabel + + return button + } + + func moreButtonItem(enabled: Bool = true) -> UIBarButtonItem? { + guard let icon = UIImage(named: "icon-menu-vertical-ellipsis") else { + return nil + } + + let button = barButtonItem(with: icon, action: #selector(didTapMenuButton(_:))) + button.accessibilityLabel = Strings.moreButtonAccessibilityLabel + button.isEnabled = enabled + + return button + } + + func shareButtonItem(enabled: Bool = true) -> UIBarButtonItem? { + let button = barButtonItem(with: .gridicon(.shareiOS), action: #selector(didTapShareButton(_:))) + button.accessibilityLabel = Strings.shareButtonAccessibilityLabel + button.isEnabled = enabled + + return button + } + + func barButtonItem(with image: UIImage, action: Selector) -> UIBarButtonItem { + let image = image.withRenderingMode(.alwaysTemplate) + return UIBarButtonItem(image: image, style: .plain, target: self, action: action) + } + + /// Checks if the view is visible in the viewport. + func isVisibleInScrollView(_ view: UIView) -> Bool { + guard view.superview != nil, !view.isHidden else { + return false + } + + let scrollViewFrame = CGRect(origin: scrollView.contentOffset, size: scrollView.frame.size) + let convertedViewFrame = scrollView.convert(view.bounds, from: view) + return scrollViewFrame.intersects(convertedViewFrame) + } +} + +// MARK: - NoResultsViewControllerDelegate +/// +extension ReaderDetailViewController: NoResultsViewControllerDelegate { + func actionButtonPressed() { + coordinator?.openInBrowser() + } +} + +// MARK: - State Restoration + +extension ReaderDetailViewController: UIViewControllerRestoration { + public static func viewController(withRestorationIdentifierPath identifierComponents: [String], + coder: NSCoder) -> UIViewController? { + return ReaderDetailCoordinator.viewController(withRestorationIdentifierPath: identifierComponents, coder: coder) + } + + + open override func encodeRestorableState(with coder: NSCoder) { + coordinator?.encodeRestorableState(with: coder) + + super.encodeRestorableState(with: coder) + } + + open override func awakeAfter(using aDecoder: NSCoder) -> Any? { + restorationClass = type(of: self) + + return super.awakeAfter(using: aDecoder) + } +} + +// MARK: - Strings +extension ReaderDetailViewController { + private struct Strings { + static let backButtonAccessibilityLabel = NSLocalizedString( + "readerDetail.backButton.accessibilityLabel", + value: "Back", + comment: "Spoken accessibility label" + ) + static let dismissButtonAccessibilityLabel = NSLocalizedString( + "readerDetail.dismissButton.accessibilityLabel", + value: "Dismiss", + comment: "Spoken accessibility label" + ) + static let safariButtonAccessibilityLabel = NSLocalizedString( + "readerDetail.safariButton.accessibilityLabel", + value: "Open in Safari", + comment: "Spoken accessibility label" + ) + static let shareButtonAccessibilityLabel = NSLocalizedString( + "readerDetail.shareButton.accessibilityLabel", + value: "Share", + comment: "Spoken accessibility label" + ) + static let moreButtonAccessibilityLabel = NSLocalizedString( + "readerDetail.moreButton.accessibilityLabel", + value: "More", + comment: "Spoken accessibility label" + ) + static let localPostsSectionTitle = NSLocalizedString( + "readerDetail.localPostsSection.accessibilityLabel", + value: "More from %1$@", + comment: "Section title for local related posts. %1$@ is a placeholder for the blog display name." + ) + static let globalPostsSectionTitle = NSLocalizedString( + "readerDetail.globalPostsSection.accessibilityLabel", + value: "More on WordPress.com", + comment: "Section title for global related posts." + ) + static let tooltipTitle = NSLocalizedString( + "readerDetail.followConversationTooltipTitle.accessibilityLabel", + value: "Follow the conversation", + comment: "Title of follow conversations tooltip." + ) + static let tooltipMessage = NSLocalizedString( + "readerDetail.followConversationTooltipMessage.accessibilityLabel", + value: "Get notified when new comments are added to this post.", + comment: "Message for the follow conversations tooltip." + ) + static let tooltipButtonTitle = NSLocalizedString( + "readerDetail.followConversationTooltipButton.accessibilityLabel", + value: "Got it", + comment: "Button title for the follow conversations tooltip." + ) + static let tooltipAnchorTitle = NSLocalizedString( + "readerDetail.tooltipAnchorTitle.accessibilityLabel", + value: "New", + comment: "Title for the tooltip anchor." + ) + } +} + +// MARK: - DefinesVariableStatusBarStyle +// Allows this VC to control the statusbar style dynamically +extension ReaderDetailViewController: DefinesVariableStatusBarStyle {} + +// MARK: - BorderedButtonTableViewCellDelegate +// For the `View All Comments` button. +extension ReaderDetailViewController: BorderedButtonTableViewCellDelegate { + func buttonTapped() { + guard let post = post else { + return + } + + ReaderCommentAction().execute(post: post, + origin: self, + promptToAddComment: commentsTableViewDelegate.totalComments == 0, + source: .postDetailsComments) + } +} + +// MARK: - UIScrollViewDelegate +extension ReaderDetailViewController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard didShowTooltip else { + if isVisibleInScrollView(commentsTableView) { + tooltipPresenter?.showTooltip() + didShowTooltip = true + } + return + } + + guard let tooltip = tooltipPresenter?.tooltip else { + return + } + + let currentToggleVisibility = isVisibleInScrollView(tooltip) + + if lastToggleAnchorVisibility != currentToggleVisibility { + tooltipPresenter?.toggleAnchorVisibility(!currentToggleVisibility) + } + + lastToggleAnchorVisibility = currentToggleVisibility + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailCommentsHeader.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailCommentsHeader.swift new file mode 100644 index 000000000000..962a8ec5b5eb --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailCommentsHeader.swift @@ -0,0 +1,158 @@ +import UIKit + +class ReaderDetailCommentsHeader: UITableViewHeaderFooterView, NibReusable { + + // MARK: - Properties + + static let estimatedHeight: CGFloat = 80 + @IBOutlet private weak var contentStackView: UIStackView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var followButton: UIButton! + private var post: ReaderPost? + private var readerCommentsFollowPresenter: ReaderCommentsFollowPresenter? + private var followButtonTappedClosure: (() ->Void)? + + private var totalComments = 0 { + didSet { + configureTitleLabel() + } + } + + private var followConversationEnabled = false { + didSet { + followButton.isHidden = !followConversationEnabled + } + } + + private var isSubscribedComments: Bool { + return post?.isSubscribedComments ?? false + } + + override func awakeFromNib() { + super.awakeFromNib() + configureView() + } + + // MARK: - Configure + + func configure( + post: ReaderPost, + totalComments: Int, + presentingViewController: UIViewController, + followButtonTappedClosure: (() -> Void)? + ) { + self.post = post + self.totalComments = totalComments + self.followConversationEnabled = post.commentsOpen && post.canSubscribeComments + self.followButtonTappedClosure = followButtonTappedClosure + + configureButton() + + readerCommentsFollowPresenter = ReaderCommentsFollowPresenter.init( + post: post, + delegate: self, + presentingViewController: presentingViewController + ) + } + + func updateFollowButtonState(post: ReaderPost) { + self.post = post + configureButton() + } + + func followButtonMidPoint() -> CGPoint { + CGPoint(x: followButton.frame.midX, y: followButton.frame.minY) + } +} + +// MARK: - Private Extension + +private extension ReaderDetailCommentsHeader { + + func configureView() { + contentView.backgroundColor = .basicBackground + addBottomBorder(withColor: .divider) + configureTitle() + + } + + func configureTitle() { + titleLabel.textColor = .text + titleLabel.font = WPStyleGuide.serifFontForTextStyle(.title3, fontWeight: .semibold) + } + + func configureTitleLabel() { + titleLabel.text = { + switch totalComments { + case 0: + return Titles.comments + case 1: + return String(format: Titles.singularCommentFormat, totalComments) + default: + return String(format: Titles.pluralCommentsFormat, totalComments) + } + }() + } + + func configureButton() { + configureStackView() + followButton.addTarget(self, action: #selector(followButtonTapped), for: .touchUpInside) + + if isSubscribedComments { + followButton.setImage(UIImage.init(systemName: "bell"), for: .normal) + followButton.setTitle(nil, for: .normal) + } else { + followButton.setTitle(Titles.followButton, for: .normal) + followButton.setTitleColor(.primary, for: .normal) + followButton.titleLabel?.font = WPStyleGuide.fontForTextStyle(.footnote) + followButton.setImage(nil, for: .normal) + } + } + + func configureStackView() { + // If isAccessibilityCategory, display the content vertically. + // This makes the Follow button "wrap" and appear under the title label. + if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + contentStackView.axis = .vertical + contentStackView.alignment = .leading + contentStackView.distribution = .fill + contentStackView.spacing = 10 + } else { + contentStackView.axis = .horizontal + contentStackView.alignment = .center + contentStackView.distribution = .fill + contentStackView.spacing = 0 + } + } + + @objc func followButtonTapped() { + isSubscribedComments ? readerCommentsFollowPresenter?.showNotificationSheet(sourceView: followButton) : + readerCommentsFollowPresenter?.handleFollowConversationButtonTapped() + if !isSubscribedComments { + followButtonTappedClosure?() + } + } + + struct Titles { + static let singularCommentFormat = NSLocalizedString("%1$d Comment", comment: "Singular label displaying number of comments. %1$d is a placeholder for the number of Comments.") + static let pluralCommentsFormat = NSLocalizedString("%1$d Comments", comment: "Plural label displaying number of comments. %1$d is a placeholder for the number of Comments.") + static let comments = NSLocalizedString("Comments", comment: "Comments table header label.") + static let followButton = NSLocalizedString("Follow Conversation", comment: "Button title. Follow the comments on a post.") + } + +} + +// MARK: - ReaderCommentsFollowPresenterDelegate + +extension ReaderDetailCommentsHeader: ReaderCommentsFollowPresenterDelegate { + + func followConversationComplete(success: Bool, post: ReaderPost) { + self.post = post + configureButton() + } + + func toggleNotificationComplete(success: Bool, post: ReaderPost) { + self.post = post + } + +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailCommentsHeader.xib b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailCommentsHeader.xib new file mode 100644 index 000000000000..42c6092a7194 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailCommentsHeader.xib @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19162" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <accessibilityOverrides isEnabled="YES" dynamicTypePreference="7"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19144"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="kPA-oR-TOp" customClass="ReaderDetailCommentsHeader" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="394" height="69"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" alignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="st6-L0-qny" userLabel="Content Stack View"> + <rect key="frame" x="0.0" y="16" width="394" height="43"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Comments" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="QfN-CJ-PfS"> + <rect key="frame" x="0.0" y="3" width="157" height="37"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleTitle3"/> + <color key="textColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="761" contentHorizontalAlignment="center" contentVerticalAlignment="center" adjustsImageSizeForAccessibilityContentSizeCategory="YES" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="dZm-SW-a1v" userLabel="Follow Button"> + <rect key="frame" x="157" y="3.5" width="237" height="36"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <state key="normal" title="Follow Conversation" image="bell"> + <color key="titleColor" systemColor="systemBlueColor"/> + <preferredSymbolConfiguration key="preferredSymbolConfiguration" configurationType="font" scale="default"> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + </preferredSymbolConfiguration> + </state> + </button> + </subviews> + </stackView> + </subviews> + <viewLayoutGuide key="safeArea" id="epR-21-48u"/> + <constraints> + <constraint firstAttribute="bottom" secondItem="st6-L0-qny" secondAttribute="bottom" constant="10" id="SVv-uo-3jT"/> + <constraint firstItem="st6-L0-qny" firstAttribute="leading" secondItem="kPA-oR-TOp" secondAttribute="leading" id="cq9-h9-LZl"/> + <constraint firstAttribute="trailing" secondItem="st6-L0-qny" secondAttribute="trailing" id="hmg-uy-OM6"/> + <constraint firstItem="st6-L0-qny" firstAttribute="top" secondItem="kPA-oR-TOp" secondAttribute="top" constant="16" id="oyV-UY-eNS"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="contentStackView" destination="st6-L0-qny" id="6gl-E7-3tT"/> + <outlet property="followButton" destination="dZm-SW-a1v" id="T1e-6U-kD8"/> + <outlet property="titleLabel" destination="QfN-CJ-PfS" id="TVn-gJ-PFg"/> + </connections> + <point key="canvasLocation" x="36.231884057971016" y="81.361607142857139"/> + </view> + </objects> + <resources> + <image name="bell" width="24" height="24"/> + <systemColor name="systemBlueColor"> + <color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailCommentsTableViewDelegate.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailCommentsTableViewDelegate.swift new file mode 100644 index 000000000000..4cf701ed5f16 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailCommentsTableViewDelegate.swift @@ -0,0 +1,175 @@ +import UIKit +/// Table View delegate to handle the Comments table displayed in Reader Post details. +/// +class ReaderDetailCommentsTableViewDelegate: NSObject, UITableViewDataSource, UITableViewDelegate { + + // MARK: - Private Properties + + private(set) var totalComments = 0 + private var post: ReaderPost? + private var presentingViewController: UIViewController? + private weak var buttonDelegate: BorderedButtonTableViewCellDelegate? + private(set) var headerView: ReaderDetailCommentsHeader? + var followButtonTappedClosure: (() ->Void)? + + private var totalRows = 0 + private var hideButton = true + + private var comments: [Comment] = [] { + didSet { + totalRows = { + // If there are no comments and commenting is closed, 1 empty cell. + if hideButton { + return 1 + } + + // If there are no comments, 1 empty cell + 1 button. + if comments.count == 0 { + return 2 + } + + // Otherwise add 1 for the button. + return comments.count + 1 + }() + } + } + + private var commentsEnabled: Bool { + return post?.commentsOpen ?? false + } + + // MARK: - Public Methods + + func updateWith(post: ReaderPost, + comments: [Comment] = [], + totalComments: Int = 0, + presentingViewController: UIViewController, + buttonDelegate: BorderedButtonTableViewCellDelegate? = nil) { + self.post = post + hideButton = (comments.count == 0 && !commentsEnabled) + self.comments = comments + self.totalComments = totalComments + self.presentingViewController = presentingViewController + self.buttonDelegate = buttonDelegate + } + + func updateFollowButtonState(post: ReaderPost) { + self.post = post + headerView?.updateFollowButtonState(post: post) + } + + func followButtonMidPoint() -> CGPoint? { + headerView?.followButtonMidPoint() + } + + // MARK: - Table Methods + + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return totalRows + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if indexPath.row == (totalRows - 1) && !hideButton { + return showCommentsButtonCell() + } + + if let comment = comments[safe: indexPath.row] { + guard let cell = tableView.dequeueReusableCell(withIdentifier: CommentContentTableViewCell.defaultReuseID) as? CommentContentTableViewCell else { + return UITableViewCell() + } + + cell.configureForPostDetails(with: comment) { _ in + tableView.performBatchUpdates({}) + } + + return cell + } + + guard let cell = tableView.dequeueReusableCell(withIdentifier: ReaderDetailNoCommentCell.defaultReuseID) as? ReaderDetailNoCommentCell else { + return UITableViewCell() + } + + cell.titleLabel.text = commentsEnabled ? Constants.noComments : Constants.closedComments + return cell + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: ReaderDetailCommentsHeader.defaultReuseID) as? ReaderDetailCommentsHeader, + let post = post, + let presentingViewController = presentingViewController else { + return nil + } + + header.configure( + post: post, + totalComments: totalComments, + presentingViewController: presentingViewController, + followButtonTappedClosure: followButtonTappedClosure + ) + headerView = header + return header + } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + /// We used this method to show the Jetpack badge rather than setting `tableFooterView` because it scaled better with Dynamic type. + guard section == 0, JetpackBrandingVisibility.all.enabled else { + return nil + } + let textProvider = JetpackBrandingTextProvider(screen: JetpackBadgeScreen.readerDetail) + return JetpackButton.makeBadgeView(title: textProvider.brandingText(), + bottomPadding: Constants.jetpackBadgeBottomPadding, + target: self, + selector: #selector(jetpackButtonTapped)) + } + + func tableView(_ tableView: UITableView, estimatedHeightForHeaderInSection section: Int) -> CGFloat { + return ReaderDetailCommentsHeader.estimatedHeight + } + + func tableView(_ tableView: UITableView, estimatedHeightForFooterInSection section: Int) -> CGFloat { + return ReaderDetailCommentsHeader.estimatedHeight + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + guard section == 0, JetpackBrandingVisibility.all.enabled else { + return 0 + } + return UITableView.automaticDimension + } +} + +private extension ReaderDetailCommentsTableViewDelegate { + + func showCommentsButtonCell() -> BorderedButtonTableViewCell { + let cell = BorderedButtonTableViewCell() + let title = totalComments == 0 ? Constants.leaveCommentButtonTitle : Constants.viewAllButtonTitle + cell.configure(buttonTitle: title, borderColor: .textTertiary, buttonInsets: Constants.buttonInsets) + cell.delegate = buttonDelegate + return cell + } + + @objc func jetpackButtonTapped() { + guard let presentingViewController = presentingViewController else { + return + } + JetpackBrandingCoordinator.presentOverlay(from: presentingViewController) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBadgeTapped(screen: .readerDetail) + } + + struct Constants { + static let noComments = NSLocalizedString("No comments yet", comment: "Displayed on the post details page when there are no post comments.") + static let closedComments = NSLocalizedString("Comments are closed", comment: "Displayed on the post details page when there are no post comments and commenting is closed.") + static let viewAllButtonTitle = NSLocalizedString("View all comments", comment: "Title for button on the post details page to show all comments when tapped.") + static let leaveCommentButtonTitle = NSLocalizedString("Be the first to comment", comment: "Title for button on the post details page when there are no comments.") + static let buttonInsets = UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0) + static let jetpackBadgeBottomPadding: CGFloat = 10 + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.swift new file mode 100644 index 000000000000..3de61d8d4301 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.swift @@ -0,0 +1,456 @@ +import UIKit + +protocol ReaderDetailFeaturedImageViewDelegate: AnyObject { + func didTapFeaturedImage(_ sender: CachedAnimatedImageView) +} + +protocol UpdatableStatusBarStyle: UIViewController { + func updateStatusBarStyle(to style: UIStatusBarStyle) +} + +class ReaderDetailFeaturedImageView: UIView, NibLoadable { + + // MARK: - Constants + + struct Constants { + struct multipliers { + static let maxPortaitHeight: CGFloat = 0.70 + static let maxPadPortaitHeight: CGFloat = 0.50 + static let maxLandscapeHeight: CGFloat = 0.30 + } + + static let imageLoadingTimeout: TimeInterval = 4 + } + + struct Styles { + static let startTintColor: UIColor = .white + static let endTintColor: UIColor = .text + } + + // MARK: - Private: IBOutlets + + @IBOutlet private weak var imageView: CachedAnimatedImageView! + @IBOutlet private weak var gradientView: UIView! + @IBOutlet private weak var heightConstraint: NSLayoutConstraint! + @IBOutlet private weak var loadingView: UIView! + + // MARK: - Public: Properties + + weak var delegate: ReaderDetailFeaturedImageViewDelegate? + + /// Keeps track if the featured image is loading + private(set) var isLoading: Bool = false + + /// Keeps track of if we've loaded the image before + private(set) var isLoaded: Bool = false + + /// Temporary work around until white headers are shipped app-wide, + /// allowing Reader Detail to use a blue navbar. + var useCompatibilityMode: Bool = false { + didSet { + updateIfNotLoading() + } + } + + // MARK: - Private: Properties + + /// Image loader for the featured image + /// + private lazy var imageLoader: ImageLoader = { + // Allow for large GIFs to animate on the detail page + return ImageLoader(imageView: imageView, gifStrategy: .largeGIFs) + }() + + /// The reader post that the toolbar interacts with + private var post: ReaderPost? + + private weak var scrollView: UIScrollView? + private weak var navigationBar: UINavigationBar? + private weak var navigationItem: UINavigationItem? + + private var currentStatusBarStyle: UIStatusBarStyle = .lightContent { + didSet { + statusBarUpdater?.updateStatusBarStyle(to: currentStatusBarStyle) + } + } + + private weak var statusBarUpdater: UpdatableStatusBarStyle? + + /// Listens for contentOffset changes to track when the user scrolls + private var scrollViewObserver: NSKeyValueObservation? + + /// The navigation bar tint color changes depending on whether the featured image is visible or not. + private var navBarTintColor: UIColor? { + get { + return navigationBar?.tintColor + } + set(newValue) { + self.navigationItem?.setTintColor(useCompatibilityMode ? .textInverted : newValue) + } + } + + private var imageSize: CGSize? + private var timeoutTimer: Timer? + + // MARK: - View Methods + + deinit { + scrollViewObserver?.invalidate() + } + + override func awakeFromNib() { + super.awakeFromNib() + + loadingView.backgroundColor = .placeholderElement + isUserInteractionEnabled = false + + reset() + } + + func viewWillDisappear() { + scrollViewObserver?.invalidate() + scrollViewObserver = nil + } + + // MARK: - Public: Configuration + + func configure(scrollView: UIScrollView, navigationBar: UINavigationBar?, navigationItem: UINavigationItem) { + guard self.scrollView == nil else { + configureNavigationBar() + addScrollObserver() + return + } + self.navigationBar = navigationBar + self.navigationItem = navigationItem + self.scrollView = scrollView + self.configureNavigationBar() + self.addScrollObserver() + self.addTapGesture() + } + + func configure(for post: ReaderPost, with statusBarUpdater: UpdatableStatusBarStyle) { + self.post = post + self.statusBarUpdater = statusBarUpdater + self.isLoaded = false + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateIfNotLoading() + } + + // MARK: - Public: Fetching Featured Image + + public func load(completion: @escaping () -> Void) { + guard + !useCompatibilityMode, + !isLoading, + let post = self.post, + let imageURL = URL(string: post.featuredImage), + Self.shouldDisplayFeaturedImage(with: post) + else { + reset() + isLoaded = true + completion() + return + } + + loadingView.isHidden = false + + isLoading = true + isLoaded = true + + var timedOut = false + + let completionHandler: (CGSize) -> Void = { [weak self] size in + guard let self = self else { + return + } + + self.timeoutTimer?.invalidate() + self.timeoutTimer = nil + + self.imageSize = size + self.didFinishLoading(timedOut: timedOut) + self.isLoading = false + + completion() + } + + let failureHandler: () -> Void = { [weak self] in + self?.reset() + self?.isLoading = false + completion() + } + + // Times out if the loading is taking too long + // this prevents the user from being stuck on the loading view for too long + timeoutTimer = Timer.scheduledTimer(withTimeInterval: Constants.imageLoadingTimeout, repeats: false, block: { _ in + timedOut = true + failureHandler() + }) + + self.imageLoader.imageDimensionsHandler = { _, size in + completionHandler(size) + } + + self.imageLoader.loadImage(with: imageURL, from: post, placeholder: nil, success: { [weak self] in + // If we haven't loaded the image size yet + // trigger the handler to update the height, etc. + if self?.imageSize == nil { + if let size = self?.imageView.image?.size { + self?.imageSize = size + completionHandler(size) + } + } + + self?.hideLoading() + }) { _ in + failureHandler() + } + } + + // MARK: - Public: Helpers + + public func deviceDidRotate() { + guard !useCompatibilityMode else { + return + } + + updateInitialHeight(resetContentOffset: false) + } + + static func shouldDisplayFeaturedImage(with post: ReaderPost) -> Bool { + let imageURL = URL(string: post.featuredImage) + return imageURL != nil && !post.contentIncludesFeaturedImage() + } + + // MARK: - Private: Config + + private func configureNavigationBar() { + self.applyTransparentNavigationBarAppearance() + } + + private func addScrollObserver() { + guard scrollViewObserver == nil, + let scrollView = self.scrollView else { + return + } + + scrollViewObserver = scrollView.observe(\.contentOffset, options: .new) { [weak self] _, _ in + self?.scrollViewDidScroll() + } + } + + // MARK: - Private: Tap Gesture + + private func addTapGesture() { + guard let scrollView = scrollView else { + return + } + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(imageTapped(_:))) + tapGesture.cancelsTouchesInView = false + tapGesture.delegate = self + scrollView.addGestureRecognizer(tapGesture) + } + + @objc private func imageTapped(_ sender: UITapGestureRecognizer) { + delegate?.didTapFeaturedImage(imageView) + } + + // MARK: - Private: Updating UI and Handling Scrolls + + /// Updates the UI if the image is not loading. + private func updateIfNotLoading() { + guard !isLoading else { + return + } + self.update() + } + + private func update() { + guard + !useCompatibilityMode, + imageSize != nil, + let scrollView = self.scrollView + else { + reset() + return + } + + let offsetY = scrollView.contentOffset.y + + updateFeaturedImageHeight(with: offsetY) + updateNavigationBar(with: offsetY) + } + + private func hideLoading() { + UIView.animate(withDuration: 0.3, animations: { + self.loadingView.alpha = 0.0 + }) { (_) in + self.loadingView.isHidden = true + self.loadingView.alpha = 1.0 + } + } + + private func scrollViewDidScroll() { + self.updateIfNotLoading() + } + + private func updateFeaturedImageHeight(with offset: CGFloat) { + let height = featuredImageHeight() + + guard height > 0 else { + return + } + + let y = height - ((offset - topMargin()) + height) + + heightConstraint.constant = max(y, 0) + } + + private func updateNavigationBar(with offset: CGFloat) { + let fullProgress = (offset / heightConstraint.constant) + let progress = fullProgress.clamp(min: 0, max: 1) + + let tintColor = UIColor.interpolate(from: Styles.startTintColor, + to: Styles.endTintColor, + with: progress) + + if traitCollection.userInterfaceStyle == .light { + currentStatusBarStyle = fullProgress >= 2.5 ? .darkContent : .lightContent + } else { + currentStatusBarStyle = .lightContent + } + + navBarTintColor = tintColor + } + + private func applyTransparentNavigationBarAppearance() { + guard let navigationItem, !useCompatibilityMode + else { + return + } + + // Transparent navigation bar + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + navigationItem.standardAppearance = appearance + navigationItem.compactAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + if #available(iOS 15.0, *) { + navigationItem.compactScrollEdgeAppearance = appearance + } + + if isLoaded, imageView.image == nil { + navBarTintColor = Styles.endTintColor + } + + updateIfNotLoading() + } + + // MARK: - Private: Network Helpers + + private func didFinishLoading(timedOut: Bool = false) { + // Don't reset the scroll position if we timed out to prevent a jump + // if the user has started reading / scrolling + updateInitialHeight(resetContentOffset: !timedOut) + update() + + isHidden = false + } + + private func updateInitialHeight(resetContentOffset: Bool = true) { + let height = featuredImageHeight() - topMargin() + + heightConstraint.constant = height + + if let scrollView = self.scrollView { + if height > 0 { + // Only adjust insets when height is a positive value to avoid clipping. + scrollView.contentInset = UIEdgeInsets(top: height, left: 0, bottom: 0, right: 0) + } + if resetContentOffset { + scrollView.setContentOffset(CGPoint(x: 0, y: -height), animated: false) + } + } + } + + private func reset() { + navigationItem?.setTintColor(useCompatibilityMode ? .appBarTint : Styles.endTintColor) + + resetStatusBarStyle() + heightConstraint.constant = 0 + isHidden = true + + loadingView.isHidden = true + } + + private func resetStatusBarStyle() { + let isDark = traitCollection.userInterfaceStyle == .dark + + currentStatusBarStyle = isDark ? .lightContent : .darkContent + } + + // MARK: - Private: Calculations + + private func featuredImageHeight() -> CGFloat { + guard + let imageSize = self.imageSize, + let superview = self.superview + else { + return 0 + } + + let aspectRatio = imageSize.width / imageSize.height + let height = bounds.width / aspectRatio + + let isLandscape = UIDevice.current.orientation.isLandscape + let maxHeightMultiplier: CGFloat = isLandscape ? Constants.multipliers.maxLandscapeHeight : UIDevice.isPad() ? Constants.multipliers.maxPadPortaitHeight : Constants.multipliers.maxPortaitHeight + + let result = min(height, superview.bounds.height * maxHeightMultiplier) + + // Restrict the min height of the view to twice the size of the top margin + // This prevents high aspect ratio images from appearing too small + return max(result, topMargin() * 2) + } + + private var statusBarHeight: CGFloat { + return max(UIApplication.shared.currentStatusBarFrame.size.height, UIApplication.shared.delegate?.window??.safeAreaInsets.top ?? 0) + } + + private func topMargin() -> CGFloat { + let navBarHeight = navigationBar?.frame.height ?? 0 + return statusBarHeight + navBarHeight + } +} + +// MARK: - UIGestureRecognizerDelegate + +extension ReaderDetailFeaturedImageView: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + let touchPoint = touch.location(in: self) + let isOutsideView = !imageView.frame.contains(touchPoint) + + /// Do not accept the touch if outside the featured image view + return isOutsideView == false + } +} + +// MARK: - Private: Navigation Item Extension + +private extension UINavigationItem { + + func setTintColor(_ color: UIColor?) { + self.leftBarButtonItem?.tintColor = color + self.rightBarButtonItem?.tintColor = color + self.leftBarButtonItems?.forEach { + $0.tintColor = color + } + self.rightBarButtonItems?.forEach { + $0.tintColor = color + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.xib b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.xib new file mode 100644 index 000000000000..e55e5a8668aa --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.xib @@ -0,0 +1,67 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097.2" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="3ME-FR-9gA" userLabel="Featured Image Container View" customClass="ReaderDetailFeaturedImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="233"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" translatesAutoresizingMaskIntoConstraints="NO" id="g3L-dQ-aNo" customClass="CachedAnimatedImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="233"/> + </imageView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="psH-HG-Bd4"> + <rect key="frame" x="0.0" y="0.0" width="414" height="233"/> + <color key="backgroundColor" white="0.0" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </view> + <view contentMode="scaleAspectFill" translatesAutoresizingMaskIntoConstraints="NO" id="jUL-7Q-S12" customClass="LinearGradientView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="233"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="point" keyPath="endPoint"> + <point key="value" x="0.0" y="0.69999999999999996"/> + </userDefinedRuntimeAttribute> + <userDefinedRuntimeAttribute type="point" keyPath="startPoint"> + <point key="value" x="0.0" y="0.0"/> + </userDefinedRuntimeAttribute> + <userDefinedRuntimeAttribute type="color" keyPath="startColor"> + <color key="value" white="0.0" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </userDefinedRuntimeAttribute> + <userDefinedRuntimeAttribute type="color" keyPath="endColor"> + <color key="value" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </userDefinedRuntimeAttribute> + </userDefinedRuntimeAttributes> + </view> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="jUL-7Q-S12" firstAttribute="top" secondItem="3ME-FR-9gA" secondAttribute="top" id="AGY-8k-KlY"/> + <constraint firstAttribute="bottom" secondItem="psH-HG-Bd4" secondAttribute="bottom" id="IjQ-Jm-92U"/> + <constraint firstItem="psH-HG-Bd4" firstAttribute="leading" secondItem="3ME-FR-9gA" secondAttribute="leading" id="Nzn-y7-7VD"/> + <constraint firstAttribute="trailing" secondItem="jUL-7Q-S12" secondAttribute="trailing" id="Rcc-Ng-nPp"/> + <constraint firstItem="g3L-dQ-aNo" firstAttribute="top" secondItem="3ME-FR-9gA" secondAttribute="top" id="VeT-YY-RXS"/> + <constraint firstItem="g3L-dQ-aNo" firstAttribute="leading" secondItem="3ME-FR-9gA" secondAttribute="leading" id="WbY-1R-a9N"/> + <constraint firstAttribute="trailing" secondItem="g3L-dQ-aNo" secondAttribute="trailing" id="et6-bo-VIT"/> + <constraint firstAttribute="height" constant="233" id="m8s-lI-vbl"/> + <constraint firstAttribute="trailing" secondItem="psH-HG-Bd4" secondAttribute="trailing" id="mGb-A8-fqN"/> + <constraint firstItem="psH-HG-Bd4" firstAttribute="top" secondItem="3ME-FR-9gA" secondAttribute="top" id="ojD-Qy-nJn"/> + <constraint firstItem="jUL-7Q-S12" firstAttribute="leading" secondItem="3ME-FR-9gA" secondAttribute="leading" id="tj2-9u-jcw"/> + <constraint firstAttribute="bottom" secondItem="g3L-dQ-aNo" secondAttribute="bottom" id="yqT-Lf-oU9"/> + <constraint firstAttribute="bottom" secondItem="jUL-7Q-S12" secondAttribute="bottom" id="yzT-CN-GIv"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="gradientView" destination="jUL-7Q-S12" id="whu-sb-l49"/> + <outlet property="heightConstraint" destination="m8s-lI-vbl" id="ozk-yh-EF4"/> + <outlet property="imageView" destination="g3L-dQ-aNo" id="jmJ-uP-SHb"/> + <outlet property="loadingView" destination="psH-HG-Bd4" id="iot-aN-g2U"/> + </connections> + <point key="canvasLocation" x="58" y="-209"/> + </view> + </objects> +</document> diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift new file mode 100644 index 000000000000..e568c03ecef8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift @@ -0,0 +1,291 @@ +import UIKit +import AutomatticTracks + +protocol ReaderDetailHeaderViewDelegate { + func didTapBlogName() + func didTapMenuButton(_ sender: UIView) + func didTapHeaderAvatar() + func didTapFollowButton(completion: @escaping () -> Void) + func didSelectTopic(_ topic: String) +} + +class ReaderDetailHeaderView: UIStackView, NibLoadable { + @IBOutlet weak var headerView: UIView! + @IBOutlet weak var blavatarImageView: UIImageView! + @IBOutlet weak var blogURLLabel: UILabel! + @IBOutlet weak var blogNameButton: UIButton! + @IBOutlet weak var menuButton: UIButton! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var titleBottomPaddingView: UIView! + @IBOutlet weak var byLabel: UILabel! + @IBOutlet weak var authorLabel: UILabel! + @IBOutlet weak var authorSeparatorLabel: UILabel! + @IBOutlet weak var dateLabel: UILabel! + @IBOutlet weak var followButton: UIButton! + @IBOutlet weak var iPadFollowButton: UIButton! + + @IBOutlet weak var collectionViewPaddingView: UIView! + @IBOutlet weak var topicsCollectionView: TopicsCollectionView! + + /// Temporary work around until white headers are shipped app-wide, + /// allowing Reader Detail to use a blue navbar. + var useCompatibilityMode: Bool = false + + /// The post to show details in the header + /// + private var post: ReaderPost? + + /// The user interface direction for the view's semantic content attribute. + /// + private var layoutDirection: UIUserInterfaceLayoutDirection { + return UIView.userInterfaceLayoutDirection(for: semanticContentAttribute) + } + + /// Any interaction with the header is sent to the delegate + /// + var delegate: ReaderDetailHeaderViewDelegate? + + func configure(for post: ReaderPost) { + self.post = post + + configureSiteImage() + configureURL() + configureBlogName() + configureTitle() + configureByLabel() + configureAuthorLabel() + configureDateLabel() + configureFollowButton() + configureNotifications() + configureTopicsCollectionView() + + prepareForVoiceOver() + prepareMenuForVoiceOver() + preparePostTitleForVoiceOver() + } + + func refreshFollowButton() { + configureFollowButton() + } + + @IBAction func didTapBlogName(_ sender: Any) { + delegate?.didTapBlogName() + } + + @IBAction func didTapMenuButton(_ sender: UIButton) { + delegate?.didTapMenuButton(sender) + } + + @IBAction func didTapFollowButton(_ sender: Any) { + followButton.isSelected = !followButton.isSelected + iPadFollowButton.isSelected = !followButton.isSelected + followButton.isUserInteractionEnabled = false + + delegate?.didTapFollowButton() { [weak self] in + self?.followButton.isUserInteractionEnabled = true + } + } + + @objc func didTapHeaderAvatar(_ gesture: UITapGestureRecognizer) { + if gesture.state != .ended { + return + } + + delegate?.didTapHeaderAvatar() + } + + override func awakeFromNib() { + super.awakeFromNib() + + WPStyleGuide.applyReaderCardBylineLabelStyle(blogURLLabel) + WPStyleGuide.applyReaderCardTitleLabelStyle(titleLabel) + + titleLabel.backgroundColor = .basicBackground + blogNameButton.setTitleColor(WPStyleGuide.readerCardBlogNameLabelTextColor(), for: .normal) + blogNameButton.titleLabel?.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .bold) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + configureFollowButton() + } + + private func configureSiteImage() { + let placeholder = UIImage(named: "post-blavatar-placeholder") + blavatarImageView.image = placeholder + + let size = blavatarImageView.frame.size.width * UIScreen.main.scale + if let url = post?.siteIconForDisplay(ofSize: Int(size)) { + blavatarImageView.downloadImage(from: url, placeholderImage: placeholder) + } + } + + private func configureURL() { + guard let siteURL = post?.siteURLForDisplay() as NSString? else { + return + } + + blogURLLabel.text = siteURL.components(separatedBy: "//").last + } + + private func configureBlogName() { + let blogName = post?.blogNameForDisplay() + blogNameButton.setTitle(blogName, for: UIControl.State()) + blogNameButton.setTitle(blogName, for: .highlighted) + blogNameButton.setTitle(blogName, for: .disabled) + blogNameButton.isAccessibilityElement = false + blogNameButton.naturalContentHorizontalAlignment = .leading + + // Enable button only if not previewing a site. + if let topic = post?.topic { + blogNameButton.isEnabled = !ReaderHelpers.isTopicSite(topic) + } + + // If the button is enabled also listen for taps on the avatar. + if blogNameButton.isEnabled { + let tgr = UITapGestureRecognizer(target: self, action: #selector(didTapHeaderAvatar(_:))) + blavatarImageView.addGestureRecognizer(tgr) + } + } + + private func configureTitle() { + if let title = post?.titleForDisplay() { + titleLabel.attributedText = NSAttributedString(string: title, attributes: WPStyleGuide.readerDetailTitleAttributes()) + titleLabel.isHidden = false + + } else { + titleLabel.attributedText = nil + titleLabel.isHidden = true + } + } + + private func configureByLabel() { + byLabel.text = NSLocalizedString("By ", comment: "Label for the post author in the post detail.") + } + + private func configureAuthorLabel() { + guard + let displayName = post?.authorDisplayName, + !displayName.isEmpty + else { + authorLabel.isHidden = true + authorSeparatorLabel.isHidden = true + byLabel.isHidden = true + return + } + + authorLabel.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .bold) + authorLabel.text = displayName + + authorLabel.isHidden = false + authorSeparatorLabel.isHidden = false + byLabel.isHidden = false + + } + + private func configureDateLabel() { + dateLabel.text = post?.dateForDisplay()?.toMediumString() + } + + private func configureFollowButton() { + followButton.isSelected = post?.isFollowing() ?? false + iPadFollowButton.isSelected = post?.isFollowing() ?? false + + followButton.setImage(UIImage.gridicon(.readerFollow, size: CGSize(width: 24, height: 24)).imageWithTintColor(.primary), for: .normal) + followButton.setImage(UIImage.gridicon(.readerFollowing, size: CGSize(width: 24, height: 24)).imageWithTintColor(.gray(.shade20)), for: .selected) + WPStyleGuide.applyReaderFollowButtonStyle(iPadFollowButton) + + let isCompact = traitCollection.horizontalSizeClass == .compact + followButton.isHidden = !isCompact + iPadFollowButton.isHidden = isCompact + } + + private func configureNotifications() { + NotificationCenter.default.addObserver(self, selector: #selector(preferredContentSizeChanged), name: UIContentSizeCategory.didChangeNotification, object: nil) + } + + func configureTopicsCollectionView() { + guard + let post = post, + let tags = post.tagsForDisplay(), + !tags.isEmpty + else { + topicsCollectionView.isHidden = true + collectionViewPaddingView.isHidden = true + return + } + + let featuredImageIsDisplayed = useCompatibilityMode || ReaderDetailFeaturedImageView.shouldDisplayFeaturedImage(with: post) + collectionViewPaddingView.isHidden = !featuredImageIsDisplayed + + topicsCollectionView.topicDelegate = self + topicsCollectionView.topics = tags + topicsCollectionView.isHidden = false + } + + @objc private func preferredContentSizeChanged() { + configureTitle() + } + + private func prepareForVoiceOver() { + guard let post = post else { + blogNameButton.isAccessibilityElement = false + return + } + + blogNameButton.isAccessibilityElement = true + blogNameButton.accessibilityTraits = [.staticText, .button] + blogNameButton.accessibilityHint = NSLocalizedString("Shows the site's posts.", comment: "Accessibility hint for the site name and URL button on Reader's Post Details.") + if let label = blogNameLabel(post) { + blogNameButton.accessibilityLabel = label + } + } + + private func prepareMenuForVoiceOver() { + menuButton.accessibilityLabel = NSLocalizedString("More", comment: "Accessibility label for the More button on Reader's post details") + menuButton.accessibilityTraits = UIAccessibilityTraits.button + menuButton.accessibilityHint = NSLocalizedString("Shows more options.", comment: "Accessibility hint for the More button on Reader's post details") + } + + private func blogNameLabel(_ post: ReaderPost) -> String? { + guard let postedIn = post.blogNameForDisplay(), + let postedBy = post.authorDisplayName, + let postedAtURL = post.siteURLForDisplay()?.components(separatedBy: "//").last else { + return nil + } + + guard let postedOn = post.dateCreated?.toMediumString() else { + let format = NSLocalizedString("Posted in %@, at %@, by %@.", comment: "Accessibility label for the blog name in the Reader's post details, without date. Placeholders are blog title, blog URL, author name") + return String(format: format, postedIn, postedAtURL, postedBy) + } + + let format = NSLocalizedString("Posted in %@, at %@, by %@, %@", comment: "Accessibility label for the blog name in the Reader's post details. Placeholders are blog title, blog URL, author name, published date") + return String(format: format, postedIn, postedAtURL, postedBy, postedOn) + } + + private func preparePostTitleForVoiceOver() { + guard let title = post?.titleForDisplay() else { + return + } + isAccessibilityElement = false + + titleLabel.accessibilityLabel = title + titleLabel.accessibilityTraits = .header + } + +} + +extension ReaderDetailHeaderView: ReaderTopicCollectionViewCoordinatorDelegate { + func coordinator(_ coordinator: ReaderTopicCollectionViewCoordinator, didChangeState: ReaderTopicCollectionViewState) { + self.layoutIfNeeded() + } + + func coordinator(_ coordinator: ReaderTopicCollectionViewCoordinator, didSelectTopic topic: String) { + delegate?.didSelectTopic(topic) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.xib b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.xib new file mode 100644 index 000000000000..d4a9600d71c9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.xib @@ -0,0 +1,232 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <customFonts key="customFonts"> + <array key="TimesNewRoman.ttf"> + <string>.SFUI-Regular</string> + </array> + </customFonts> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <stackView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" verticalCompressionResistancePriority="250" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" alignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="qVA-Br-hgv" customClass="ReaderDetailHeaderView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="200"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QPG-TJ-mSj"> + <rect key="frame" x="0.0" y="0.0" width="414" height="20"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="20" id="4PO-aU-A3l"/> + </constraints> + </view> + <collectionView hidden="YES" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" placeholderIntrinsicWidth="infinite" placeholderIntrinsicHeight="20" bounces="NO" scrollEnabled="NO" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" bouncesZoom="NO" dataMode="none" translatesAutoresizingMaskIntoConstraints="NO" id="hs4-NA-2lr" customClass="TopicsCollectionView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="16" y="20" width="382" height="0.0"/> + <color key="backgroundColor" systemColor="systemBackgroundColor"/> + <collectionViewLayout key="collectionViewLayout" id="Ucj-vS-mRR"/> + </collectionView> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="PfO-Ue-G3x"> + <rect key="frame" x="0.0" y="20" width="414" height="8"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="8" id="icJ-zH-xxc"/> + </constraints> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="1000" text="Title Jj" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" preferredMaxLayoutWidth="0.0" translatesAutoresizingMaskIntoConstraints="NO" id="vvq-7F-Zcz"> + <rect key="frame" x="16" y="28" width="382" height="91"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <fontDescription key="fontDescription" type="system" pointSize="20"/> + <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <nil key="highlightedColor"/> + </label> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="VZW-x2-i8F"> + <rect key="frame" x="0.0" y="119" width="414" height="10"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="10" id="SyW-fr-iz9"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="KHc-Ky-UGH"> + <rect key="frame" x="0.0" y="129" width="414" height="40"/> + <subviews> + <imageView contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="post-blavatar-placeholder" translatesAutoresizingMaskIntoConstraints="NO" id="kSE-xw-mlu"> + <rect key="frame" x="16" y="0.0" width="40" height="40"/> + <gestureRecognizers/> + <constraints> + <constraint firstAttribute="width" constant="40" id="HVX-17-EuG"/> + <constraint firstAttribute="height" constant="40" id="X5i-qN-sxF"/> + </constraints> + <userDefinedRuntimeAttributes> + <userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius"> + <integer key="value" value="20"/> + </userDefinedRuntimeAttribute> + <userDefinedRuntimeAttribute type="boolean" keyPath="layer.masksToBounds" value="YES"/> + </userDefinedRuntimeAttributes> + </imageView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="www.blogname.com" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="LdR-mh-skh"> + <rect key="frame" x="66" y="22" width="304" height="14.5"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <accessibility key="accessibilityConfiguration"> + <bool key="isElement" value="NO"/> + </accessibility> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <nil key="highlightedColor"/> + </label> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" lineBreakMode="tailTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="nBe-4U-XT8"> + <rect key="frame" x="66" y="2" width="304" height="32"/> + <constraints> + <constraint firstAttribute="height" constant="32" id="I9E-ox-fvW"/> + </constraints> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <inset key="titleEdgeInsets" minX="0.0" minY="0.0" maxX="0.0" maxY="15"/> + <state key="normal" title="Blog name"> + <color key="titleColor" red="0.0" green="0.47843137250000001" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </state> + <connections> + <action selector="didTapBlogName:" destination="qVA-Br-hgv" eventType="touchUpInside" id="EEe-cO-K8a"/> + </connections> + </button> + <button hidden="YES" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="1v1-3D-xzR"> + <rect key="frame" x="374" y="4" width="24" height="32"/> + <constraints> + <constraint firstAttribute="height" constant="32" id="7u8-dA-Nsi"/> + <constraint firstAttribute="width" constant="24" id="Twn-TV-EFP"/> + </constraints> + <state key="normal" image="icon-menu-ellipsis"> + <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </state> + <connections> + <action selector="didTapMenuButton:" destination="qVA-Br-hgv" eventType="touchUpInside" id="haw-4k-Oh6"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="EaI-jA-EG4"> + <rect key="frame" x="370" y="0.0" width="44" height="40"/> + <constraints> + <constraint firstAttribute="width" constant="44" id="20k-ke-hxQ"/> + <constraint firstAttribute="height" constant="40" id="DvC-fl-3Tm"/> + </constraints> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="8" maxY="0.0"/> + <connections> + <action selector="didTapFollowButton:" destination="qVA-Br-hgv" eventType="touchUpInside" id="EQE-Mj-ned"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="yYQ-om-wOU"> + <rect key="frame" x="368" y="3" width="30" height="34"/> + <connections> + <action selector="didTapFollowButton:" destination="qVA-Br-hgv" eventType="touchUpInside" id="tiw-jb-V5i"/> + </connections> + </button> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <gestureRecognizers/> + <constraints> + <constraint firstAttribute="trailing" secondItem="1v1-3D-xzR" secondAttribute="trailing" constant="16" id="4Ry-dT-Hke"/> + <constraint firstItem="1v1-3D-xzR" firstAttribute="centerY" secondItem="KHc-Ky-UGH" secondAttribute="centerY" id="5Sv-47-x6Q"/> + <constraint firstItem="kSE-xw-mlu" firstAttribute="leading" secondItem="KHc-Ky-UGH" secondAttribute="leading" constant="16" id="8Sk-Ir-KaR"/> + <constraint firstAttribute="trailing" secondItem="LdR-mh-skh" secondAttribute="trailing" constant="44" id="LTM-v5-cek"/> + <constraint firstAttribute="trailing" secondItem="nBe-4U-XT8" secondAttribute="trailing" constant="44" id="P2W-lX-oLR"/> + <constraint firstAttribute="centerY" secondItem="kSE-xw-mlu" secondAttribute="centerY" id="WSf-k0-EUE"/> + <constraint firstAttribute="trailing" secondItem="yYQ-om-wOU" secondAttribute="trailing" constant="16" id="Ygo-oV-pD2"/> + <constraint firstAttribute="height" constant="40" id="d4f-EY-yyM"/> + <constraint firstItem="LdR-mh-skh" firstAttribute="leading" secondItem="kSE-xw-mlu" secondAttribute="trailing" constant="10" id="eQL-lo-SDI"/> + <constraint firstItem="yYQ-om-wOU" firstAttribute="centerY" secondItem="KHc-Ky-UGH" secondAttribute="centerY" id="fAJ-J7-77g"/> + <constraint firstItem="nBe-4U-XT8" firstAttribute="leading" secondItem="kSE-xw-mlu" secondAttribute="trailing" constant="10" id="iQ2-fQ-TJ2"/> + <constraint firstAttribute="trailing" secondItem="EaI-jA-EG4" secondAttribute="trailing" id="lhy-BW-geI"/> + <constraint firstItem="LdR-mh-skh" firstAttribute="top" secondItem="KHc-Ky-UGH" secondAttribute="top" constant="22" id="mVJ-rr-XuT"/> + <constraint firstItem="nBe-4U-XT8" firstAttribute="top" secondItem="KHc-Ky-UGH" secondAttribute="top" constant="2" id="ryU-tO-9B4"/> + <constraint firstItem="EaI-jA-EG4" firstAttribute="centerY" secondItem="KHc-Ky-UGH" secondAttribute="centerY" id="zQS-zc-m5Y"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="DqP-ww-h6l"> + <rect key="frame" x="87" y="169" width="240" height="8"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="8" id="7qG-SX-aNN"/> + </constraints> + </view> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="9pz-cp-bwp"> + <rect key="frame" x="0.0" y="177" width="414" height="4"/> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstAttribute="height" constant="4" id="A1z-fd-csA"/> + </constraints> + </view> + <stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="dMP-sD-Xuf"> + <rect key="frame" x="16" y="181" width="382" height="19"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="By " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="jmd-NR-EOk"> + <rect key="frame" x="0.0" y="0.0" width="18" height="19"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Author" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="9vn-tU-OV2"> + <rect key="frame" x="18" y="0.0" width="38" height="19"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text=" · " textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="sev-Gx-i9y"> + <rect key="frame" x="56" y="0.0" width="14.5" height="19"/> + <fontDescription key="fontDescription" name=".SFUI-Regular" family=".AppleSystemUIFont" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="249" verticalHuggingPriority="251" text="Date" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="CJe-cC-zGx"> + <rect key="frame" x="70.5" y="0.0" width="311.5" height="19"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + </subviews> + <constraints> + <constraint firstItem="vvq-7F-Zcz" firstAttribute="leading" secondItem="qVA-Br-hgv" secondAttribute="leading" constant="16" id="5Yh-Yt-Pm9"/> + <constraint firstAttribute="trailing" secondItem="hs4-NA-2lr" secondAttribute="trailing" constant="16" id="7E7-9R-UTl"/> + <constraint firstAttribute="trailing" secondItem="dMP-sD-Xuf" secondAttribute="trailing" constant="16" id="9eO-Rp-2dJ"/> + <constraint firstItem="9pz-cp-bwp" firstAttribute="leading" secondItem="qVA-Br-hgv" secondAttribute="leading" id="G5t-Sf-gmz"/> + <constraint firstAttribute="trailing" secondItem="9pz-cp-bwp" secondAttribute="trailing" id="Jf3-x1-ED8"/> + <constraint firstItem="hs4-NA-2lr" firstAttribute="leading" secondItem="qVA-Br-hgv" secondAttribute="leading" constant="16" id="akO-co-t2p"/> + <constraint firstItem="VZW-x2-i8F" firstAttribute="leading" secondItem="qVA-Br-hgv" secondAttribute="leading" id="jFa-IF-qGe"/> + <constraint firstAttribute="trailing" secondItem="vvq-7F-Zcz" secondAttribute="trailing" constant="16" id="tTY-SF-4qB"/> + <constraint firstItem="dMP-sD-Xuf" firstAttribute="leading" secondItem="qVA-Br-hgv" secondAttribute="leading" constant="16" id="vQH-5F-dZg"/> + <constraint firstAttribute="trailing" secondItem="KHc-Ky-UGH" secondAttribute="trailing" id="vfN-ya-w0j"/> + <constraint firstAttribute="trailing" secondItem="VZW-x2-i8F" secondAttribute="trailing" id="wwt-hq-gza"/> + <constraint firstItem="KHc-Ky-UGH" firstAttribute="leading" secondItem="qVA-Br-hgv" secondAttribute="leading" id="xE4-lN-GnY"/> + </constraints> + <connections> + <outlet property="authorLabel" destination="9vn-tU-OV2" id="GCo-Yk-Zr1"/> + <outlet property="authorSeparatorLabel" destination="sev-Gx-i9y" id="GUX-tE-Cs8"/> + <outlet property="blavatarImageView" destination="kSE-xw-mlu" id="vAD-l5-b6W"/> + <outlet property="blogNameButton" destination="nBe-4U-XT8" id="XDf-iB-EKh"/> + <outlet property="blogURLLabel" destination="LdR-mh-skh" id="Jpq-BT-i1D"/> + <outlet property="byLabel" destination="jmd-NR-EOk" id="MiZ-ih-Gdl"/> + <outlet property="collectionViewPaddingView" destination="QPG-TJ-mSj" id="IxN-dx-KTC"/> + <outlet property="dateLabel" destination="CJe-cC-zGx" id="cIX-oU-7Gh"/> + <outlet property="followButton" destination="EaI-jA-EG4" id="PMJ-Ah-b0r"/> + <outlet property="headerView" destination="KHc-Ky-UGH" id="eLs-Cu-7cX"/> + <outlet property="iPadFollowButton" destination="yYQ-om-wOU" id="9W4-fb-MS4"/> + <outlet property="menuButton" destination="1v1-3D-xzR" id="12P-dV-Zzr"/> + <outlet property="titleBottomPaddingView" destination="VZW-x2-i8F" id="hHj-gn-v51"/> + <outlet property="titleLabel" destination="vvq-7F-Zcz" id="c17-PA-dEb"/> + <outlet property="topicsCollectionView" destination="hs4-NA-2lr" id="GvY-pa-Wyc"/> + </connections> + <point key="canvasLocation" x="720" y="-107"/> + </stackView> + </objects> + <resources> + <image name="icon-menu-ellipsis" width="24" height="24"/> + <image name="post-blavatar-placeholder" width="32" height="32"/> + <systemColor name="systemBackgroundColor"> + <color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesListController.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesListController.swift new file mode 100644 index 000000000000..56aefb9c2b78 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesListController.swift @@ -0,0 +1,89 @@ +import Foundation + +class ReaderDetailLikesListController: UITableViewController, NoResultsViewHost { + + // MARK: - Properties + private let post: ReaderPost + private var likesListController: LikesListController? + private var totalLikes = 0 + + // MARK: - Init + init(post: ReaderPost, totalLikes: Int) { + self.post = post + self.totalLikes = totalLikes + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View + override func viewDidLoad() { + configureViewTitle() + configureTable() + WPAnalytics.track(.likeListOpened, properties: ["list_type": "post", "source": "like_reader_list"]) + } + +} + +private extension ReaderDetailLikesListController { + + func configureViewTitle() { + let titleFormat = totalLikes == 1 ? TitleFormats.singular : TitleFormats.plural + navigationItem.title = String(format: titleFormat, totalLikes) + } + + func configureTable() { + tableView.register(LikeUserTableViewCell.defaultNib, + forCellReuseIdentifier: LikeUserTableViewCell.defaultReuseID) + + likesListController = LikesListController(tableView: tableView, post: post, delegate: self) + tableView.delegate = likesListController + tableView.dataSource = likesListController + + // The separator is controlled by LikeUserTableViewCell + tableView.separatorStyle = .none + + // Call refresh to ensure that the controller fetches the data. + likesListController?.refresh() + } + + func displayUserProfile(_ user: LikeUser, from indexPath: IndexPath) { + let userProfileVC = UserProfileSheetViewController(user: user) + userProfileVC.blogUrlPreviewedSource = "reader_like_list_user_profile" + let bottomSheet = BottomSheetViewController(childViewController: userProfileVC) + let sourceView = tableView.cellForRow(at: indexPath) ?? view + bottomSheet.show(from: self, sourceView: sourceView) + WPAnalytics.track(.userProfileSheetShown, properties: ["source": "like_reader_list"]) + } + + struct TitleFormats { + static let singular = NSLocalizedString("%1$d Like", + comment: "Singular format string for view title displaying the number of post likes. %1$d is the number of likes.") + static let plural = NSLocalizedString("%1$d Likes", + comment: "Plural format string for view title displaying the number of post likes. %1$d is the number of likes.") + } + +} + +// MARK: - LikesListController Delegate +// +extension ReaderDetailLikesListController: LikesListControllerDelegate { + + func didSelectUser(_ user: LikeUser, at indexPath: IndexPath) { + displayUserProfile(user, from: indexPath) + } + + func showErrorView(title: String, subtitle: String?) { + configureAndDisplayNoResults(on: tableView, + title: title, + subtitle: subtitle, + image: "wp-illustration-reader-empty") + } + + func updatedTotalLikes(_ totalLikes: Int) { + self.totalLikes = totalLikes + configureViewTitle() + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesView.swift new file mode 100644 index 000000000000..6e220b113af7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesView.swift @@ -0,0 +1,196 @@ +import UIKit + +protocol ReaderDetailLikesViewDelegate { + func didTapLikesView() +} + +class ReaderDetailLikesView: UIView, NibLoadable { + + @IBOutlet weak var avatarStackView: UIStackView! + @IBOutlet weak var summaryLabel: UILabel! + + /// The UIImageView used to display the current user's avatar image. This view is hidden by default. + @IBOutlet private weak var selfAvatarImageView: CircularImageView! + + static let maxAvatarsDisplayed = 5 + var delegate: ReaderDetailLikesViewDelegate? + + /// Stores the number of total likes _without_ adding the like from self. + private var totalLikes: Int = 0 + + /// Convenience property that adds up the total likes and self like for display purposes. + var totalLikesForDisplay: Int { + return displaysSelfAvatar ? totalLikes + 1 : totalLikes + } + + /// Convenience property that checks whether or not the self avatar image view is being displayed. + private var displaysSelfAvatar: Bool { + !selfAvatarImageView.isHidden + } + + override func awakeFromNib() { + super.awakeFromNib() + applyStyles() + } + + func configure(with avatarURLStrings: [String], totalLikes: Int) { + self.totalLikes = totalLikes + updateSummaryLabel() + updateAvatars(with: avatarURLStrings) + addTapGesture() + } + + func addSelfAvatar(with urlString: String, animated: Bool = false) { + downloadGravatar(for: selfAvatarImageView, withURL: urlString) + + // pre-animation state + // set initial position from the left in LTR, or from the right in RTL. + selfAvatarImageView.alpha = 0 + let directionalMultiplier: CGFloat = userInterfaceLayoutDirection() == .leftToRight ? -1.0 : 1.0 + selfAvatarImageView.transform = CGAffineTransform(translationX: Constants.animationDeltaX * directionalMultiplier, y: 0) + + UIView.animate(withDuration: animated ? Constants.animationDuration : 0) { + // post-animation state + self.selfAvatarImageView.alpha = 1 + self.selfAvatarImageView.isHidden = false + self.selfAvatarImageView.transform = .identity + } + + updateSummaryLabel() + } + + func removeSelfAvatar(animated: Bool = false) { + // pre-animation state + selfAvatarImageView.alpha = 1 + self.selfAvatarImageView.transform = .identity + + UIView.animate(withDuration: animated ? Constants.animationDuration : 0) { + // post-animation state + // moves to the left in LTR, or to the right in RTL. + self.selfAvatarImageView.alpha = 0 + self.selfAvatarImageView.isHidden = true + let directionalMultiplier: CGFloat = self.userInterfaceLayoutDirection() == .leftToRight ? -1.0 : 1.0 + self.selfAvatarImageView.transform = CGAffineTransform(translationX: Constants.animationDeltaX * directionalMultiplier, y: 0) + } + + updateSummaryLabel() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + applyStyles() + } + +} + +private extension ReaderDetailLikesView { + + func applyStyles() { + // Set border on all the avatar views + for subView in avatarStackView.subviews { + subView.layer.borderWidth = 1 + subView.layer.borderColor = UIColor.basicBackground.cgColor + } + } + + func updateSummaryLabel() { + switch (displaysSelfAvatar, totalLikes) { + case (true, 0): + summaryLabel.attributedText = highlightedText(SummaryLabelFormats.onlySelf) + case (true, 1): + summaryLabel.attributedText = highlightedText(SummaryLabelFormats.singularWithSelf) + case (true, _) where totalLikes > 1: + summaryLabel.attributedText = highlightedText(String(format: SummaryLabelFormats.pluralWithSelf, totalLikes)) + case (false, 1): + summaryLabel.attributedText = highlightedText(SummaryLabelFormats.singular) + default: + summaryLabel.attributedText = highlightedText(String(format: SummaryLabelFormats.plural, totalLikes)) + } + } + + func updateAvatars(with urlStrings: [String]) { + for (index, subView) in avatarStackView.subviews.enumerated() { + guard let avatarImageView = subView as? UIImageView else { + return + } + + if avatarImageView == selfAvatarImageView { + continue + } + + if let urlString = urlStrings[safe: index] { + downloadGravatar(for: avatarImageView, withURL: urlString) + } else { + avatarImageView.isHidden = true + } + } + } + + func downloadGravatar(for avatarImageView: UIImageView, withURL url: String?) { + // Always reset gravatar + avatarImageView.cancelImageDownload() + avatarImageView.image = .gravatarPlaceholderImage + + guard let url = url, + let gravatarURL = URL(string: url) else { + return + } + + avatarImageView.downloadImage(from: gravatarURL, placeholderImage: .gravatarPlaceholderImage) + } + + func addTapGesture() { + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapView(_:)))) + } + + @objc func didTapView(_ gesture: UITapGestureRecognizer) { + guard gesture.state == .ended else { + return + } + + delegate?.didTapLikesView() + } + + struct Constants { + static let animationDuration: TimeInterval = 0.3 + static let animationDeltaX: CGFloat = 16.0 + } + + struct SummaryLabelFormats { + static let onlySelf = NSLocalizedString("_You_ like this.", + comment: "Describes that the current user is the only one liking a post." + + " The underscores denote underline and is not displayed.") + static let singularWithSelf = NSLocalizedString("_You and another blogger_ like this.", + comment: "Describes that the current user and one other user like a post." + + " The underscores denote underline and is not displayed.") + static let pluralWithSelf = NSLocalizedString("_You and %1$d bloggers_ like this.", + comment: "Plural format string for displaying the number of post likes, including the like from the current user." + + " %1$d is the number of likes, excluding the like by current user." + + " The underscores denote underline and is not displayed.") + static let singular = NSLocalizedString("_One blogger_ likes this.", + comment: "Describes that only one user likes a post. " + + " The underscores denote underline and is not displayed.") + static let plural = NSLocalizedString("_%1$d bloggers_ like this.", + comment: "Plural format string for displaying the number of post likes." + + " %1$d is the number of likes. The underscores denote underline and is not displayed.") + } + + func highlightedText(_ text: String) -> NSAttributedString { + let labelParts = text.components(separatedBy: "_") + + let firstPart = labelParts.first ?? "" + let countPart = labelParts[safe: 1] ?? "" + let lastPart = labelParts.last ?? "" + + let foregroundAttributes: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.secondaryLabel] + let underlineAttributes: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.primary, + .underlineStyle: NSUnderlineStyle.single.rawValue] + + let attributedString = NSMutableAttributedString(string: firstPart, attributes: foregroundAttributes) + attributedString.append(NSAttributedString(string: countPart, attributes: underlineAttributes)) + attributedString.append(NSAttributedString(string: lastPart, attributes: foregroundAttributes)) + + return attributedString + } + +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesView.xib b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesView.xib new file mode 100644 index 000000000000..ed0e07cf33a3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesView.xib @@ -0,0 +1,101 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="System colors in document resources" minToolsVersion="11.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="V9g-yi-NfW" customClass="ReaderDetailLikesView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="336" height="42"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" distribution="fillEqually" spacing="-3" translatesAutoresizingMaskIntoConstraints="NO" id="ZiQ-mD-QJO" userLabel="Avatar Stack View"> + <rect key="frame" x="0.0" y="5" width="148" height="32"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar" translatesAutoresizingMaskIntoConstraints="NO" id="J4T-hG-6iv" userLabel="Avatar Image View" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="32" height="32"/> + <constraints> + <constraint firstAttribute="width" secondItem="J4T-hG-6iv" secondAttribute="height" multiplier="1:1" id="Erj-G5-zsW"/> + <constraint firstAttribute="width" constant="32" id="P5Z-kv-aye"/> + </constraints> + </imageView> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar" translatesAutoresizingMaskIntoConstraints="NO" id="q0R-6s-hGt" userLabel="Avatar Image View" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="29" y="0.0" width="32" height="32"/> + <constraints> + <constraint firstAttribute="width" secondItem="q0R-6s-hGt" secondAttribute="height" multiplier="1:1" id="bTt-k0-tgL"/> + <constraint firstAttribute="width" constant="32" id="nSK-es-kMa"/> + </constraints> + </imageView> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar" translatesAutoresizingMaskIntoConstraints="NO" id="phg-GI-hbS" userLabel="Avatar Image View" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="58" y="0.0" width="32" height="32"/> + <constraints> + <constraint firstAttribute="width" secondItem="phg-GI-hbS" secondAttribute="height" multiplier="1:1" id="6GW-VE-wil"/> + <constraint firstAttribute="width" constant="32" id="Peg-NZ-LE6"/> + </constraints> + </imageView> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar" translatesAutoresizingMaskIntoConstraints="NO" id="0vX-TT-MJH" userLabel="Avatar Image View" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="87" y="0.0" width="32" height="32"/> + <constraints> + <constraint firstAttribute="width" constant="32" id="fJi-9t-l8g"/> + <constraint firstAttribute="width" secondItem="0vX-TT-MJH" secondAttribute="height" multiplier="1:1" id="ksb-WV-X7F"/> + </constraints> + </imageView> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar" translatesAutoresizingMaskIntoConstraints="NO" id="Fsb-ih-Vxd" userLabel="Avatar Image View" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="116" y="0.0" width="32" height="32"/> + <constraints> + <constraint firstAttribute="width" constant="32" id="9Zr-kG-reL"/> + <constraint firstAttribute="width" secondItem="Fsb-ih-Vxd" secondAttribute="height" multiplier="1:1" id="RMT-Xw-wR0"/> + </constraints> + </imageView> + <imageView hidden="YES" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar" translatesAutoresizingMaskIntoConstraints="NO" id="Pod-MR-Wzr" userLabel="Self Avatar Image View" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="148" y="0.0" width="32" height="32"/> + <constraints> + <constraint firstAttribute="width" secondItem="Pod-MR-Wzr" secondAttribute="height" multiplier="1:1" id="a3o-uv-Xqr"/> + <constraint firstAttribute="width" constant="32" id="mtM-Ff-eOI"/> + </constraints> + </imageView> + </subviews> + <constraints> + <constraint firstAttribute="height" constant="32" id="deH-0r-cgY"/> + </constraints> + </stackView> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ilc-NW-l0X" userLabel="Summary Label"> + <rect key="frame" x="160" y="0.0" width="0.0" height="42"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <color key="textColor" systemColor="secondaryLabelColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <viewLayoutGuide key="safeArea" id="vd2-A8-uVO"/> + <constraints> + <constraint firstItem="ilc-NW-l0X" firstAttribute="centerY" secondItem="ZiQ-mD-QJO" secondAttribute="centerY" id="0MZ-FZ-HuG"/> + <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="ilc-NW-l0X" secondAttribute="bottom" id="10Y-sE-pDS"/> + <constraint firstAttribute="top" relation="greaterThanOrEqual" secondItem="ilc-NW-l0X" secondAttribute="top" id="CbZ-7d-cly"/> + <constraint firstItem="ZiQ-mD-QJO" firstAttribute="leading" secondItem="V9g-yi-NfW" secondAttribute="leading" id="IaP-Se-6v5"/> + <constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="ZiQ-mD-QJO" secondAttribute="bottom" constant="5" id="j4v-s8-8Xz"/> + <constraint firstItem="ZiQ-mD-QJO" firstAttribute="top" relation="greaterThanOrEqual" secondItem="V9g-yi-NfW" secondAttribute="top" constant="5" id="kPA-tt-hzr"/> + <constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="ilc-NW-l0X" secondAttribute="trailing" id="qnT-KT-Btp"/> + <constraint firstItem="ilc-NW-l0X" firstAttribute="leading" secondItem="ZiQ-mD-QJO" secondAttribute="trailing" constant="12" id="tBZ-4w-7g4"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="avatarStackView" destination="ZiQ-mD-QJO" id="hMm-un-1IW"/> + <outlet property="selfAvatarImageView" destination="Pod-MR-Wzr" id="jSJ-DS-UWW"/> + <outlet property="summaryLabel" destination="ilc-NW-l0X" id="C48-jT-Uq7"/> + </connections> + <point key="canvasLocation" x="-1275.3623188405797" y="-395.75892857142856"/> + </view> + </objects> + <resources> + <image name="gravatar" width="85" height="85"/> + <systemColor name="secondaryLabelColor"> + <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> + </systemColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailNoCommentCell.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailNoCommentCell.swift new file mode 100644 index 000000000000..5e6a8b9bf542 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailNoCommentCell.swift @@ -0,0 +1,13 @@ +import UIKit + +class ReaderDetailNoCommentCell: UITableViewCell, NibReusable { + + @IBOutlet weak var titleLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + contentView.backgroundColor = .basicBackground + titleLabel.textColor = .textSubtle + } + +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailNoCommentCell.xib b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailNoCommentCell.xib new file mode 100644 index 000000000000..13f04669d2f0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailNoCommentCell.xib @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="19162" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19144"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view userInteractionEnabled="NO" contentMode="scaleToFill" id="kPA-oR-TOp" customClass="ReaderDetailNoCommentCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="394" height="69"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="No comments yet" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="QfN-CJ-PfS"> + <rect key="frame" x="0.0" y="20" width="394" height="49"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/> + <color key="textColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <viewLayoutGuide key="safeArea" id="epR-21-48u"/> + <constraints> + <constraint firstItem="QfN-CJ-PfS" firstAttribute="top" secondItem="kPA-oR-TOp" secondAttribute="top" constant="20" id="6FS-1m-LFu"/> + <constraint firstAttribute="bottom" secondItem="QfN-CJ-PfS" secondAttribute="bottom" id="RVn-Cf-JSe"/> + <constraint firstItem="QfN-CJ-PfS" firstAttribute="leading" secondItem="kPA-oR-TOp" secondAttribute="leading" id="VQC-4P-3s7"/> + <constraint firstAttribute="trailing" secondItem="QfN-CJ-PfS" secondAttribute="trailing" id="zK3-Y8-YOU"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="titleLabel" destination="QfN-CJ-PfS" id="GBW-w4-V0a"/> + </connections> + <point key="canvasLocation" x="36.231884057971016" y="81.361607142857139"/> + </view> + </objects> +</document> diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.swift new file mode 100644 index 000000000000..9dbec72871a0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.swift @@ -0,0 +1,392 @@ +import UIKit + +protocol ReaderDetailToolbarDelegate: AnyObject { + func didTapLikeButton(isLiked: Bool) +} + +class ReaderDetailToolbar: UIView, NibLoadable { + @IBOutlet weak var dividerView: UIView! + @IBOutlet weak var saveForLaterButton: UIButton! + @IBOutlet weak var reblogButton: PostMetaButton! + @IBOutlet weak var commentButton: PostMetaButton! + @IBOutlet weak var likeButton: PostMetaButton! + + /// The reader post that the toolbar interacts with + private var post: ReaderPost? + + /// The VC where the toolbar is inserted + private weak var viewController: UIViewController? + + /// An observer of the number of likes of the post + private var likeCountObserver: NSKeyValueObservation? + + /// An observer of the number of likes of the post + private var commentCountObserver: NSKeyValueObservation? + + /// If we should hide the comments button + var shouldHideComments = false + + weak var delegate: ReaderDetailToolbarDelegate? = nil + + override func awakeFromNib() { + super.awakeFromNib() + + applyStyles() + + adjustInsetsForTextDirection() + + prepareActionButtonsForVoiceOver() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + configureActionButtons() + } + configureButtonTitles() + } + + func configure(for post: ReaderPost, in viewController: UIViewController) { + self.post = post + self.viewController = viewController + + likeCountObserver = post.observe(\.likeCount, options: .new) { [weak self] updatedPost, _ in + self?.configureLikeActionButton(true) + self?.delegate?.didTapLikeButton(isLiked: updatedPost.isLiked) + } + + commentCountObserver = post.observe(\.commentCount, options: .new) { [weak self] _, _ in + self?.configureCommentActionButton() + } + + configureActionButtons() + } + + deinit { + likeCountObserver?.invalidate() + commentCountObserver?.invalidate() + } + + // MARK: - Actions + + @IBAction func didTapSaveForLater(_ sender: Any) { + guard let readerPost = post, let context = readerPost.managedObjectContext, + let viewController = viewController as? UIViewController & UIViewControllerTransitioningDelegate else { + return + } + + if !readerPost.isSavedForLater { + FancyAlertViewController.presentReaderSavedPostsAlertControllerIfNecessary(from: viewController) + } + + ReaderSaveForLaterAction().execute(with: readerPost, context: context, origin: .postDetail, viewController: viewController) { [weak self] in + self?.saveForLaterButton.isSelected = readerPost.isSavedForLater + self?.prepareActionButtonsForVoiceOver() + } + } + + @IBAction func didTapReblog(_ sender: Any) { + guard let post = post, let viewController = viewController else { + return + } + + ReaderReblogAction().execute(readerPost: post, origin: viewController, reblogSource: .detail) + } + + @IBAction func didTapComment(_ sender: Any) { + guard let post = post, let viewController = viewController else { + return + } + + ReaderCommentAction().execute(post: post, origin: viewController, source: .postDetails) + } + + @IBAction func didTapLike(_ sender: Any) { + guard let post = post else { + return + } + + if !post.isLiked { + UINotificationFeedbackGenerator().notificationOccurred(.success) + } + + let service = ReaderPostService(coreDataStack: ContextManager.shared) + service.toggleLiked(for: post, success: { [weak self] in + self?.trackArticleDetailsLikedOrUnliked() + }, failure: { [weak self] (error: Error?) in + self?.trackArticleDetailsLikedOrUnliked() + if let anError = error { + DDLogError("Error (un)liking post: \(anError.localizedDescription)") + } + }) + } + + // MARK: - Styles + + private func applyStyles() { + backgroundColor = .listForeground + dividerView.backgroundColor = .divider + + WPStyleGuide.applyReaderCardActionButtonStyle(commentButton) + WPStyleGuide.applyReaderCardActionButtonStyle(likeButton) + } + + // MARK: - Configuration + + private func configureActionButtons() { + resetActionButton(likeButton) + resetActionButton(commentButton) + resetActionButton(saveForLaterButton) + resetActionButton(reblogButton) + + configureLikeActionButton() + configureCommentActionButton() + configureReblogButton() + configureSaveForLaterButton() + configureButtonTitles() + } + + private func resetActionButton(_ button: UIButton) { + button.setTitle(nil, for: UIControl.State()) + button.setTitle(nil, for: .highlighted) + button.setTitle(nil, for: .disabled) + button.setImage(nil, for: UIControl.State()) + button.setImage(nil, for: .highlighted) + button.setImage(nil, for: .disabled) + button.isSelected = false + button.isEnabled = true + } + + private func configureActionButton(_ button: UIButton, title: String?, image: UIImage?, highlightedImage: UIImage?, selected: Bool) { + button.setTitle(title, for: UIControl.State()) + button.setTitle(title, for: .highlighted) + button.setTitle(title, for: .disabled) + button.setImage(image, for: UIControl.State()) + button.setImage(highlightedImage, for: .highlighted) + button.setImage(highlightedImage, for: .selected) + button.setImage(highlightedImage, for: [.highlighted, .selected]) + button.setImage(image, for: .disabled) + button.isSelected = selected + + configureActionButtonStyle(button) + } + + private func configureActionButtonStyle(_ button: UIButton) { + let disabledColor = UIColor(light: .muriel(color: .gray, .shade10), + dark: .textSubtle) + + WPStyleGuide.applyReaderActionButtonStyle(button, + titleColor: .textSubtle, + imageColor: .textSubtle, + disabledColor: disabledColor) + } + + private func configureLikeActionButton(_ animated: Bool = false) { + guard let post = post else { + return + } + + let likeCount = post.likeCount?.intValue ?? 0 + likeButton.isEnabled = (ReaderHelpers.isLoggedIn() || likeCount > 0) && !post.isExternal + // as by design spec, only display like counts + let title = likeLabel(count: likeCount) + + let selected = post.isLiked + let likeImage = UIImage(named: "icon-reader-like") + let likedImage = UIImage(named: "icon-reader-liked") + + configureActionButton(likeButton, title: title, image: likeImage, highlightedImage: likedImage, selected: selected) + + if animated { + playLikeButtonAnimation() + } + } + + /// Uses the configuration in WPStyleGuide for the reblog button + private func configureReblogButton() { + guard let post = post else { + return + } + + reblogButton.isEnabled = ReaderHelpers.isLoggedIn() && !post.isPrivate() + WPStyleGuide.applyReaderReblogActionButtonStyle(reblogButton, showTitle: false) + + configureActionButtonStyle(reblogButton) + } + + private func playLikeButtonAnimation() { + let likeImageView = likeButton.imageView! + + let imageView = UIImageView(image: UIImage(named: "icon-reader-liked")) + likeButton.addSubview(imageView) + + let animationDuration = 0.3 + + if likeButton.isSelected { + // Prep a mask to hide the likeButton's image, since changes to visiblility and alpha are ignored + let mask = UIView(frame: frame) + mask.backgroundColor = backgroundColor + likeImageView.addSubview(mask) + likeImageView.pinSubviewToAllEdges(mask) + mask.translatesAutoresizingMaskIntoConstraints = false + likeButton.bringSubviewToFront(imageView) + + // Configure starting state + imageView.alpha = 0.0 + let angle = (-270.0 * CGFloat.pi) / 180.0 + let rotate = CGAffineTransform(rotationAngle: angle) + let scale = CGAffineTransform(scaleX: 3.0, y: 3.0) + imageView.transform = rotate.concatenating(scale) + + // Perform the animations + UIView.animate(withDuration: animationDuration, + animations: { () in + let angle = (1.0 * CGFloat.pi) / 180.0 + let rotate = CGAffineTransform(rotationAngle: angle) + let scale = CGAffineTransform(scaleX: 0.75, y: 0.75) + imageView.transform = rotate.concatenating(scale) + imageView.alpha = 1.0 + imageView.center = likeImageView.center // In case the button's imageView shifted position + }, + completion: { (_) in + UIView.animate(withDuration: animationDuration, + animations: { () in + imageView.transform = CGAffineTransform(scaleX: 1.0, y: 1.0) + }, + completion: { (_) in + mask.removeFromSuperview() + imageView.removeFromSuperview() + }) + }) + + } else { + + UIView .animate(withDuration: animationDuration, + animations: { () -> Void in + let angle = (120.0 * CGFloat.pi) / 180.0 + let rotate = CGAffineTransform(rotationAngle: angle) + let scale = CGAffineTransform(scaleX: 3.0, y: 3.0) + imageView.transform = rotate.concatenating(scale) + imageView.alpha = 0 + }, + completion: { (_) in + imageView.removeFromSuperview() + }) + + } + } + + private func configureCommentActionButton() { + WPStyleGuide.applyReaderCardCommentButtonStyle(commentButton, defaultSize: true) + commentButton.isEnabled = shouldShowCommentActionButton + + configureActionButtonStyle(commentButton) + } + + private var shouldShowCommentActionButton: Bool { + // Show comments if logged in and comments are enabled, or if comments exist. + // But only if it is from wpcom (jetpack and external is not yet supported). + // Nesting this conditional cos it seems clearer that way + guard let post = post else { + return false + } + + if (post.isWPCom || post.isJetpack) && !shouldHideComments { + let commentCount = post.commentCount?.intValue ?? 0 + if (ReaderHelpers.isLoggedIn() && post.commentsOpen) || commentCount > 0 { + return true + } + } + + return false + } + + private func configureSaveForLaterButton() { + WPStyleGuide.applyReaderSaveForLaterButtonStyle(saveForLaterButton) + WPStyleGuide.applyReaderSaveForLaterButtonTitles(saveForLaterButton, showTitle: false) + + saveForLaterButton.isSelected = post?.isSavedForLater ?? false + + configureActionButtonStyle(saveForLaterButton) + } + + private func adjustInsetsForTextDirection() { + let buttonsToAdjust: [UIButton] = [ + likeButton, + commentButton, + saveForLaterButton, + reblogButton] + for button in buttonsToAdjust { + button.flipInsetsForRightToLeftLayoutDirection() + } + } + + fileprivate func configureButtonTitles() { + guard let post = post else { + return + } + + let likeCount = post.likeCount()?.intValue ?? 0 + let commentCount = post.commentCount()?.intValue ?? 0 + + let likeTitle = likeLabel(count: likeCount) + let commentTitle: String = commentLabel(count: commentCount) + let showTitle: Bool = traitCollection.horizontalSizeClass != .compact + + likeButton.setTitle(likeTitle, for: .normal) + likeButton.setTitle(likeTitle, for: .highlighted) + + commentButton.setTitle(commentTitle, for: .normal) + commentButton.setTitle(commentTitle, for: .highlighted) + + WPStyleGuide.applyReaderSaveForLaterButtonTitles(saveForLaterButton, showTitle: showTitle) + WPStyleGuide.applyReaderReblogActionButtonTitle(reblogButton, showTitle: showTitle) + } + + private func commentLabel(count: Int) -> String { + if traitCollection.horizontalSizeClass == .compact { + return count > 0 ? String(count) : "" + } else { + return WPStyleGuide.commentCountForDisplay(count) + } + } + + private func likeLabel(count: Int) -> String { + if traitCollection.horizontalSizeClass == .compact { + return count > 0 ? String(count) : "" + } else { + return WPStyleGuide.likeCountForDisplay(count) + } + } + + // MARK: - Analytics + + private func trackArticleDetailsLikedOrUnliked() { + guard let post = post else { + return + } + + let stat: WPAnalyticsStat = post.isLiked + ? .readerArticleDetailLiked + : .readerArticleDetailUnliked + + var properties = [AnyHashable: Any]() + properties[WPAppAnalyticsKeyBlogID] = post.siteID + properties[WPAppAnalyticsKeyPostID] = post.postID + WPAnalytics.track(stat, withProperties: properties) + } + + // MARK: - Voice Over + + private func prepareActionButtonsForVoiceOver() { + let isSavedForLater = post?.isSavedForLater ?? false + saveForLaterButton.accessibilityLabel = isSavedForLater ? NSLocalizedString("Saved Post", comment: "Accessibility label for the 'Save Post' button when a post has been saved.") : NSLocalizedString("Save post", comment: "Accessibility label for the 'Save Post' button.") + saveForLaterButton.accessibilityHint = isSavedForLater ? NSLocalizedString("Remove this post from my saved posts.", comment: "Accessibility hint for the 'Save Post' button when a post is already saved.") : NSLocalizedString("Saves this post for later.", comment: "Accessibility hint for the 'Save Post' button.") + } + + private func prepareReblogForVoiceOver() { + reblogButton.accessibilityLabel = NSLocalizedString("Reblog post", comment: "Accessibility label for the reblog button.") + reblogButton.accessibilityHint = NSLocalizedString("Reblog this post", comment: "Accessibility hint for the reblog button.") + reblogButton.accessibilityTraits = UIAccessibilityTraits.button + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.xib b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.xib new file mode 100644 index 000000000000..69c5760ab561 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.xib @@ -0,0 +1,112 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17156" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17125"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view clipsSubviews="YES" contentMode="scaleToFill" layoutMarginsFollowReadableWidth="YES" insetsLayoutMarginsFromSafeArea="NO" translatesAutoresizingMaskIntoConstraints="NO" id="opk-iy-IwF" customClass="ReaderDetailToolbar" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="414" height="896"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="RGe-0W-AQZ"> + <rect key="frame" x="0.0" y="0.0" width="414" height="0.5"/> + <color key="backgroundColor" red="0.7843137255" green="0.84313725490000002" blue="0.88235294119999996" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + <constraints> + <constraint firstAttribute="height" constant="0.5" id="ikP-PI-Is1"/> + </constraints> + </view> + <stackView opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="1000" layoutMarginsFollowReadableWidth="YES" insetsLayoutMarginsFromSafeArea="NO" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="TSN-68-1mv"> + <rect key="frame" x="28" y="426" width="358" height="44"/> + <subviews> + <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="kYk-HZ-8zf"> + <rect key="frame" x="0.0" y="0.0" width="44" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="fqQ-GT-Dks"/> + <constraint firstAttribute="width" relation="greaterThanOrEqual" constant="44" id="sbH-ur-3hQ"/> + </constraints> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <inset key="titleEdgeInsets" minX="4" minY="0.0" maxX="-4" maxY="0.0"/> + <state key="normal" title="0" image="icon-reader-comment"> + <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </state> + <connections> + <action selector="didTapSaveForLater:" destination="opk-iy-IwF" eventType="touchUpInside" id="PDn-1T-rlh"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="1000" horizontalCompressionResistancePriority="1000" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="TVh-aP-o67" customClass="PostMetaButton"> + <rect key="frame" x="100.5" y="0.0" width="48" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="Z2e-Ky-bYe"/> + <constraint firstAttribute="width" relation="greaterThanOrEqual" constant="44" id="j4t-0h-sz7"/> + </constraints> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <inset key="titleEdgeInsets" minX="4" minY="0.0" maxX="-4" maxY="0.0"/> + <state key="normal" title="Reblog"> + <color key="titleShadowColor" red="0.50196078430000002" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </state> + <connections> + <action selector="didTapReblog:" destination="opk-iy-IwF" eventType="touchUpInside" id="4SX-vl-xza"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="1000" horizontalCompressionResistancePriority="1000" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Ndi-cj-PVP" customClass="PostMetaButton"> + <rect key="frame" x="205.5" y="0.0" width="44" height="44"/> + <constraints> + <constraint firstAttribute="width" relation="greaterThanOrEqual" constant="44" id="GbG-ii-IcP"/> + <constraint firstAttribute="height" constant="44" id="qNK-wQ-gIc"/> + </constraints> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="4" maxY="0.0"/> + <state key="normal" title="0" image="icon-reader-comment"> + <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </state> + <connections> + <action selector="didTapComment:" destination="opk-iy-IwF" eventType="touchUpInside" id="QGB-ei-h8b"/> + </connections> + </button> + <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="1000" horizontalCompressionResistancePriority="999" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="1Ht-4U-b7J" customClass="PostMetaButton"> + <rect key="frame" x="306" y="0.0" width="52" height="44"/> + <constraints> + <constraint firstAttribute="height" constant="44" id="Fio-gF-mFy"/> + <constraint firstAttribute="width" relation="greaterThanOrEqual" constant="44" id="JqZ-KF-mxs"/> + </constraints> + <fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/> + <inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="4" maxY="0.0"/> + <state key="normal" title="Like" image="icon-reader-like"> + <color key="titleShadowColor" red="0.5" green="0.5" blue="0.5" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </state> + <connections> + <action selector="didTapLike:" destination="opk-iy-IwF" eventType="touchUpInside" id="GcL-iJ-QE5"/> + </connections> + </button> + </subviews> + </stackView> + </subviews> + <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <constraints> + <constraint firstItem="TSN-68-1mv" firstAttribute="centerY" secondItem="opk-iy-IwF" secondAttribute="centerY" id="12d-dS-uUK"/> + <constraint firstAttribute="trailingMargin" secondItem="TSN-68-1mv" secondAttribute="trailing" constant="8" id="ibp-zt-iup"/> + <constraint firstItem="RGe-0W-AQZ" firstAttribute="top" secondItem="opk-iy-IwF" secondAttribute="top" id="p1n-uB-ayF"/> + <constraint firstAttribute="trailing" secondItem="RGe-0W-AQZ" secondAttribute="trailing" id="p2i-u4-L3o"/> + <constraint firstItem="TSN-68-1mv" firstAttribute="leading" secondItem="opk-iy-IwF" secondAttribute="leadingMargin" constant="8" id="sW1-Ik-cCa"/> + <constraint firstItem="RGe-0W-AQZ" firstAttribute="leading" secondItem="opk-iy-IwF" secondAttribute="leading" id="z4Y-xe-owz"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="commentButton" destination="Ndi-cj-PVP" id="xFQ-Uo-73x"/> + <outlet property="dividerView" destination="RGe-0W-AQZ" id="jlw-3l-w15"/> + <outlet property="likeButton" destination="1Ht-4U-b7J" id="cZs-ZL-BT4"/> + <outlet property="reblogButton" destination="TVh-aP-o67" id="iyb-fz-V0I"/> + <outlet property="saveForLaterButton" destination="kYk-HZ-8zf" id="YaG-nx-qsB"/> + </connections> + <point key="canvasLocation" x="167" y="-132"/> + </view> + </objects> + <resources> + <image name="icon-reader-comment" width="24" height="24"/> + <image name="icon-reader-like" width="24" height="24"/> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsCell.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsCell.swift new file mode 100644 index 000000000000..81fa2f3820cc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsCell.swift @@ -0,0 +1,65 @@ +import UIKit +import WordPressShared + +class ReaderRelatedPostsCell: UITableViewCell, NibReusable { + + @IBOutlet weak var featuredImageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var excerptLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + applyStyles() + } + + private func applyStyles() { + featuredImageView.clipsToBounds = true + featuredImageView.layer.cornerRadius = Constants.cornerRadius + featuredImageView.contentMode = .scaleAspectFill + featuredImageView.backgroundColor = .placeholderElement + + titleLabel.numberOfLines = 0 + titleLabel.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + titleLabel.textColor = .text + + excerptLabel.numberOfLines = 3 + excerptLabel.font = WPStyleGuide.fontForTextStyle(.footnote) + excerptLabel.textColor = .text + } + + override func prepareForReuse() { + super.prepareForReuse() + featuredImageView.image = nil + } + + func configure(for post: RemoteReaderSimplePost) { + configureFeaturedImage(for: post) + configureLabels(for: post) + } + + private func configureFeaturedImage(for post: RemoteReaderSimplePost) { + var featuredImageUrlString: String? = nil + if let urlString = post.featuredImageUrl, !urlString.isEmpty { + featuredImageUrlString = urlString + } else if let uriString = post.featuredMedia?.uri, !uriString.isEmpty { + featuredImageUrlString = uriString + } + + guard let urlString = featuredImageUrlString, + let featuredImageUrl = URL(string: urlString) else { + featuredImageView.isHidden = true + return + } + + featuredImageView.downloadImage(from: featuredImageUrl) + } + + private func configureLabels(for post: RemoteReaderSimplePost) { + titleLabel.text = post.title.makePlainText() + excerptLabel.text = post.excerpt.makePlainText() + } + + private enum Constants { + static let cornerRadius: CGFloat = 4 + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsCell.xib b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsCell.xib new file mode 100644 index 000000000000..bb705735c82b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsCell.xib @@ -0,0 +1,66 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17505"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" id="KGk-i7-Jjw" customClass="ReaderRelatedPostsCell" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="320" height="116"/> + <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM"> + <rect key="frame" x="0.0" y="0.0" width="320" height="116"/> + <autoresizingMask key="autoresizingMask"/> + <subviews> + <stackView opaque="NO" contentMode="scaleToFill" alignment="top" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="s0z-hn-tv5"> + <rect key="frame" x="0.0" y="8" width="320" height="100"/> + <subviews> + <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="GT5-Ba-NHj"> + <rect key="frame" x="0.0" y="0.0" width="142" height="80"/> + <constraints> + <constraint firstAttribute="width" secondItem="GT5-Ba-NHj" secondAttribute="height" multiplier="16:9" id="Fdq-E8-B0g"/> + <constraint firstAttribute="height" constant="80" id="OVJ-LX-8yx"/> + </constraints> + </imageView> + <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="DBq-yS-yFy"> + <rect key="frame" x="158" y="0.0" width="162" height="49"/> + <subviews> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="TUE-Aq-2F6"> + <rect key="frame" x="0.0" y="0.0" width="162" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="FoT-uQ-Ghm"> + <rect key="frame" x="0.0" y="28.5" width="162" height="20.5"/> + <fontDescription key="fontDescription" type="system" pointSize="17"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + </stackView> + </subviews> + </stackView> + </subviews> + <constraints> + <constraint firstItem="s0z-hn-tv5" firstAttribute="centerY" secondItem="H2p-sc-9uM" secondAttribute="centerY" id="9UT-cU-JMr"/> + <constraint firstItem="s0z-hn-tv5" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" id="Zyp-Lz-ujW"/> + <constraint firstItem="s0z-hn-tv5" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="8" id="nCf-mf-dT4"/> + <constraint firstItem="s0z-hn-tv5" firstAttribute="centerX" secondItem="H2p-sc-9uM" secondAttribute="centerX" id="uiX-Fb-YtT"/> + </constraints> + </tableViewCellContentView> + <viewLayoutGuide key="safeArea" id="njF-e1-oar"/> + <connections> + <outlet property="excerptLabel" destination="FoT-uQ-Ghm" id="mq4-BS-Zmm"/> + <outlet property="featuredImageView" destination="GT5-Ba-NHj" id="zvZ-S5-xgC"/> + <outlet property="titleLabel" destination="TUE-Aq-2F6" id="OaO-PC-TNi"/> + </connections> + <point key="canvasLocation" x="-59" y="-32"/> + </tableViewCell> + </objects> +</document> diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsSectionHeaderView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsSectionHeaderView.swift new file mode 100644 index 000000000000..d248b6c34693 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsSectionHeaderView.swift @@ -0,0 +1,24 @@ +import UIKit + +class ReaderRelatedPostsSectionHeaderView: UITableViewHeaderFooterView, NibReusable { + + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var backgroundColorView: UIView! + + static let height: CGFloat = 50 + + override func awakeFromNib() { + super.awakeFromNib() + applyStyles() + } + + private func applyStyles() { + titleLabel.numberOfLines = 0 + titleLabel.font = WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .semibold) + titleLabel.textColor = .text + titleLabel.textAlignment = .natural + + backgroundColorView.backgroundColor = .basicBackground + } + +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsSectionHeaderView.xib b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsSectionHeaderView.xib new file mode 100644 index 000000000000..abba9e119f35 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderRelatedPostsSectionHeaderView.xib @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> + <device id="retina6_1" orientation="portrait" appearance="light"/> + <dependencies> + <deployment identifier="iOS"/> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> + <capability name="Named colors" minToolsVersion="9.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <objects> + <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/> + <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> + <view contentMode="scaleToFill" id="a9P-L3-LHn" customClass="ReaderRelatedPostsSectionHeaderView" customModule="WordPress" customModuleProvider="target"> + <rect key="frame" x="0.0" y="0.0" width="320" height="50"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <subviews> + <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="7uD-Fr-xJb"> + <rect key="frame" x="0.0" y="0.0" width="320" height="50"/> + <color key="backgroundColor" name="Gray0"/> + </view> + <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="dXt-RE-kiw"> + <rect key="frame" x="0.0" y="26" width="320" height="16"/> + <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/> + <nil key="textColor"/> + <nil key="highlightedColor"/> + </label> + </subviews> + <constraints> + <constraint firstAttribute="bottom" secondItem="dXt-RE-kiw" secondAttribute="bottom" constant="8" id="FBf-3W-cmq"/> + <constraint firstAttribute="trailing" secondItem="7uD-Fr-xJb" secondAttribute="trailing" id="KOL-j8-B5N"/> + <constraint firstItem="dXt-RE-kiw" firstAttribute="leading" secondItem="a9P-L3-LHn" secondAttribute="leading" id="RuY-uz-bCj"/> + <constraint firstItem="7uD-Fr-xJb" firstAttribute="top" secondItem="a9P-L3-LHn" secondAttribute="top" id="Zct-7M-Z9D"/> + <constraint firstAttribute="bottom" secondItem="7uD-Fr-xJb" secondAttribute="bottom" id="hQv-XG-92D"/> + <constraint firstItem="dXt-RE-kiw" firstAttribute="centerX" secondItem="a9P-L3-LHn" secondAttribute="centerX" id="mxe-af-sd6"/> + <constraint firstItem="7uD-Fr-xJb" firstAttribute="leading" secondItem="a9P-L3-LHn" secondAttribute="leading" id="sik-xg-xOL"/> + </constraints> + <freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/> + <connections> + <outlet property="backgroundColorView" destination="7uD-Fr-xJb" id="FmU-3A-jQg"/> + <outlet property="titleLabel" destination="dXt-RE-kiw" id="N44-Ym-Wr6"/> + </connections> + <point key="canvasLocation" x="137.59999999999999" y="153.82308845577214"/> + </view> + </objects> + <resources> + <namedColor name="Gray0"> + <color red="0.96470588235294119" green="0.96862745098039216" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> + </namedColor> + </resources> +</document> diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/WebView/OfflineReaderWebView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/WebView/OfflineReaderWebView.swift new file mode 100644 index 000000000000..8a8d901cdde9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/WebView/OfflineReaderWebView.swift @@ -0,0 +1,39 @@ +import Foundation + +/// A WKWebView used to save post content to be available when offline +/// The mechanism is quite simple: open the post in a hidden webview so the images are cached +/// +class OfflineReaderWebView: ReaderWebView { + func saveForLater(_ post: ReaderPost, viewController: UIViewController) { + guard let contentForDisplay = post.contentForDisplay() else { + return + } + + navigationDelegate = self + + frame = CGRect(x: 0, y: 0, width: viewController.view.frame.width, height: viewController.view.frame.height) + + isHidden = true + + viewController.view.addSubview(self) + + load(contentForDisplay) + } + + private func load(_ string: String) { + // Remove all srcset from the images, only the URL in the src tag will be cached + let content = super.formattedContent(string, additionalJavaScript: jsToRemoveSrcSet) + + super.loadHTMLString(content, baseURL: Bundle.wordPressSharedBundle.bundleURL) + } +} + +extension OfflineReaderWebView: WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + // We wait 5 secs before removing the WebView to give it time to be rendered + // If its removed before it was rendered, the images won't be cached + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + self.removeFromSuperview() + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/WebView/ReaderCSS.swift b/WordPress/Classes/ViewRelated/Reader/Detail/WebView/ReaderCSS.swift new file mode 100644 index 000000000000..1f9730e0fb65 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/WebView/ReaderCSS.swift @@ -0,0 +1,73 @@ +import Foundation + +/// A struct that returns the Reader CSS URL +/// If you need to fix an issue in the CSS, see pbArwn-GU-p2 +/// +struct ReaderCSS { + private let store: KeyValueDatabase + + private let now: Int + + private let isInternetReachable: () -> Bool + + private let expirationDays: Int = 5 + + private var expirationDaysInSeconds: Int { + return expirationDays * 60 * 60 * 24 + } + + static let updatedKey = "ReaderCSSLastUpdated" + + /// Returns a custom Reader CSS URL + /// This value can be changed under Settings > Debug + /// + var customAddress: String? { + get { + return store.object(forKey: "reader-css-url") as? String + } + set { + store.set(newValue, forKey: "reader-css-url") + } + } + + /// Returns the Reader CSS appending a timestamp + /// We force it to update based on the `expirationDays` property + /// + var address: String { + // Always returns a fresh CSS if the flag is enabled + guard !FeatureFlag.readerCSS.enabled else { + return url(appendingTimestamp: now) + } + + guard let lastUpdated = store.object(forKey: type(of: self).updatedKey) as? Int, + (now - lastUpdated < expirationDaysInSeconds + || !isInternetReachable()) else { + saveCurrentDate() + return url(appendingTimestamp: now) + } + + return url(appendingTimestamp: lastUpdated) + } + + init(now: Int = Int(Date().timeIntervalSince1970), + store: KeyValueDatabase = UserPersistentStoreFactory.instance(), + isInternetReachable: @escaping () -> Bool = ReachabilityUtils.isInternetReachable) { + self.store = store + self.now = now + self.isInternetReachable = isInternetReachable + } + + private func saveCurrentDate() { + store.set(now, forKey: type(of: self).updatedKey) + } + + private func url(appendingTimestamp appending: Int) -> String { + guard let customURL = customAddress, !customURL.isEmpty else { + let timestamp = String(appending) + return "https://wordpress.com/calypso/reader-mobile.css?\(timestamp)" + } + + let timestamp = String(appending) + return "\(customURL)?\(timestamp)" + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/WebView/ReaderWebView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/WebView/ReaderWebView.swift new file mode 100644 index 000000000000..18b7314fa24b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/WebView/ReaderWebView.swift @@ -0,0 +1,194 @@ +import UIKit + +/// A WKWebView that renders post content with styles applied +/// +class ReaderWebView: WKWebView { + + /// HTML elements that will load only after the text has appeared + /// From: https://www.w3schools.com/tags/att_src.asp + private let elements = ["audio", "embed", "iframe", "img", "input", "script", "source", "track", "video"] + + let jsToRemoveSrcSet = "document.querySelectorAll('img, img-placeholder').forEach((el) => {el.removeAttribute('srcset')})" + + var postURL: URL? = nil + + /// Make the webview transparent + /// + override func awakeFromNib() { + isOpaque = false + backgroundColor = .clear + } + + /// Loads a HTML content into the webview and apply styles + /// + func loadHTMLString(_ string: String) { + // If the user is offline, we remove the srcset from all images + // This is because only the image inside the src tag is previously saved + let additionalJavaScript = ReachabilityUtils.isInternetReachable() ? "" : jsToRemoveSrcSet + + let content = formattedContent(addPlaceholder(string), additionalJavaScript: additionalJavaScript) + + super.loadHTMLString(content, baseURL: Bundle.wordPressSharedBundle.bundleURL) + } + + /// Given a HTML content, returns it formatted. + /// Ie.: Including tags, CSS, JS, etc. + /// + func formattedContent(_ content: String, additionalJavaScript: String = "") -> String { + return """ + <!DOCTYPE html><html><head><meta charset='UTF-8' /> + <title>Reader Post + + + + + \(content) + + + + """ + } + + /// Tell the webview to load all media + /// You want to use this method only after the webview has appearead (after a didFinish, for example) + func loadMedia() { + evaluateJavaScript(""" + var elements = ["\(elements.joined(separator: "\",\""))"] + + elements.forEach((element) => { + document.querySelectorAll(`${element}-placeholder`).forEach((el) => { + var regex = new RegExp(`${element}-placeholder`, "g") + el.outerHTML = el.outerHTML.replace(regex, element) + }) + }) + + // Make all images tappable + // Exception for images in Stories, which have their own link structure + // and images that already have a link + document.querySelectorAll('img:not(.wp-story-image)').forEach((el) => { + if (el.parentNode.nodeName.toLowerCase() !== 'a') { + el.outerHTML = `${el.outerHTML}`; + } + }) + + // Only display images after they have fully loaded, to have a native feel + document.querySelectorAll('img').forEach((el) => { + var img = new Image(); + img.addEventListener('load', () => { el.style.opacity = "1" }, false); + img.src = el.currentSrc; + el.src = img.src; + }) + + // Load all embeds + const embedsToLookFor = { + 'blockquote[class^="instagram-"]': 'https://www.instagram.com/embed.js', + 'blockquote[class^="twitter-"], a[class^="twitter-"]': 'https://platform.twitter.com/widgets.js', + 'fb\\\\:post, [class^=fb-]': 'https://connect.facebook.net/en_US/sdk.js#xfbml=1&version=v2.2', + '[class^=tumblr-]': 'https://assets.tumblr.com/post.js', + '.embed-reddit': 'https://embed.redditmedia.com/widgets/platform.js', + }; + + Object.keys(embedsToLookFor).forEach((key) => { + if (document.querySelectorAll(key).length > 0) { + var s = document.createElement( 'script' ); + s.setAttribute( 'src', embedsToLookFor[key] ); + document.body.appendChild( s ); + } + }) + + """, completionHandler: nil) + } + + /// Change all occurences of elements to change it's HTML tag to "element-placeholder" + /// Ie.: img -> img-placeholder + /// This will make the text to appear fast, so the user can start reading + /// + private func addPlaceholder(_ htmlContent: String) -> String { + var content = htmlContent + + elements.forEach { content = content.replacingMatches(of: "<\($0)", with: "<\($0)-placeholder") } + + return content + } + + /// Returns the content of reader.css + /// + private func cssStyles() -> String { + guard let cssURL = Bundle.main.url(forResource: "reader", withExtension: "css") else { + return "" + } + + let cssContent = try? String(contentsOf: cssURL) + return cssContent ?? "" + } + + /// Maps app colors to CSS colors to be applied in the webview + /// + private func cssColors() -> String { + return """ + @media (prefers-color-scheme: dark) { + \(mappedCSSColors(.dark)) + } + + @media (prefers-color-scheme: light) { + \(mappedCSSColors(.light)) + } + """ + } + + private func mappedCSSColors(_ style: UIUserInterfaceStyle) -> String { + let trait = UITraitCollection(userInterfaceStyle: style) + UIColor(light: .muriel(color: .gray, .shade40), + dark: .muriel(color: .gray, .shade20)).color(for: trait).hexString() + return """ + :root { + --color-text: #\(UIColor.text.color(for: trait).hexString() ?? ""); + --color-neutral-0: #\(UIColor.listForegroundUnread.color(for: trait).hexString() ?? ""); + --color-neutral-5: #\(UIColor(light: .muriel(color: .gray, .shade5), + dark: .muriel(color: .gray, .shade80)).color(for: trait).hexString() ?? ""); + --color-neutral-10: #\(UIColor(light: .muriel(color: .gray, .shade10), + dark: .muriel(color: .gray, .shade30)).color(for: trait).hexString() ?? ""); + --color-neutral-40: #\(UIColor(light: .muriel(color: .gray, .shade40), + dark: .muriel(color: .gray, .shade20)).color(for: trait).hexString() ?? ""); + --color-neutral-50: #\(UIColor.textSubtle.color(for: trait).hexString() ?? ""); + --color-neutral-70: #\(UIColor.text.color(for: trait).hexString() ?? ""); + --main-link-color: #\(UIColor.primary.color(for: trait).hexString() ?? ""); + --main-link-active-color: #\(UIColor.primaryDark.color(for: trait).hexString() ?? ""); + } + """ + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/WebView/reader.css b/WordPress/Classes/ViewRelated/Reader/Detail/WebView/reader.css new file mode 100644 index 000000000000..1661d90ba13b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Detail/WebView/reader.css @@ -0,0 +1,62 @@ +@font-face { font-family: 'Noto Serif'; src: local('NotoSerif-Regular'), url('NotoSerif-Regular.ttf') format('truetype'); } +@font-face { font-family: 'Noto Serif'; src: local('NotoSerif-Bold'), url('NotoSerif-Bold.ttf') format('truetype'); font-weight: bold; } +@font-face { font-family: 'Noto Serif'; src: local('NotoSerif-Italic'), url('NotoSerif-Italic.ttf') format('truetype'); font-style: italic; } +@font-face { font-family: 'Noto Serif'; src: local('NotoSerif-BoldItalic'), url('NotoSerif-BoldItalic.ttf') format('truetype'); font-weight: bold; font-style: italic; } + +html, body { + max-width: 100%; + overflow-x: hidden; +} + +body { + -webkit-text-size-adjust: none !important; font: -apple-system-body !important; font-family: 'Noto Serif', serif !important; font-weight: 400 !important; padding: 0 !important; margin: 0; background-color: transparent; +} + +a { text-decoration: none; color: var(--main-link-color); } + +pre { overflow-x: scroll; background-color: var(--color-neutral-0); padding: 1em; } + +p, div, li { line-height: 1.6em; font-size: 100%; } + +h1, h2, h3, h4, h5, h6 { line-height: 1.6em; } + +hr { + border: 1px solid var(--color-neutral-0); +} + +table { + width: 100% +} + +.wp-block-table { + overflow-x: auto; +} + +div.feedflare { display: none; } + +.sharedaddy, .jp-relatedposts, .mc4wp-form, .wpcnt, .OUTBRAIN, .adsbygoogle { display: none; } + +img { + opacity: 0; +} + +iframe { + max-width: 100%; +} + +@media (max-width: 660px) { + .alignleft img, + .alignright img { + display: block; + margin: 0 auto; + } +} + +/* Safari overrides */ + +figure { + margin-block-start: 0; + margin-block-end: 0; + margin-inline-start: 0; + margin-inline-end: 0; +} diff --git a/WordPress/Classes/ViewRelated/Reader/DiscoverMenuItemCreator.swift b/WordPress/Classes/ViewRelated/Reader/DiscoverMenuItemCreator.swift deleted file mode 100644 index 67627e9af516..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/DiscoverMenuItemCreator.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Gridicons - -/// Encapsulates creating of a ReaderMenuItem for Discover -final class DiscoverMenuItemCreator: ReaderMenuItemCreator { - func supports(_ topic: ReaderAbstractTopic) -> Bool { - return ReaderHelpers.topicIsDiscover(topic) - } - - func menuItem(with topic: ReaderAbstractTopic) -> ReaderMenuItem { - var item = ReaderMenuItem(title: topic.title, - type: .topic, - icon: Gridicon.iconOfType(.mySites), - topic: topic) - item.order = ReaderDefaultMenuItemOrder.discover.rawValue - - return item - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/Filter/EmptyActionView.swift b/WordPress/Classes/ViewRelated/Reader/Filter/EmptyActionView.swift new file mode 100644 index 000000000000..490353ea9a11 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Filter/EmptyActionView.swift @@ -0,0 +1,92 @@ +/// A view with a label and action button +class EmptyActionView: UIView { + + enum Constants { + static let labelOffset: CGFloat = -87 + static let buttonLabelSpacing: CGFloat = 20 + static let labelWidth: CGFloat = 320 + static let labelTextStyle: UIFont.TextStyle = .title3 + } + + var title: String? { + set { + button.setTitle(newValue, for: .normal) + } + get { + return button.title(for: .normal) + } + } + + var labelText: String? { + set { + label.text = newValue + } + get { + return label.text + } + } + + lazy var button: UIButton = { + let button = FancyButton() + button.isPrimary = true + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(tappedButton), for: .touchUpInside) + return button + }() + + lazy var label: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + label.textAlignment = .center + WPStyleGuide.configureLabel(label, textStyle: Constants.labelTextStyle) + return label + }() + + private let tappedBlock: () -> Void + + private var offsetCenteredLabelContraint: NSLayoutConstraint? + private var centeredLabelConstraint: NSLayoutConstraint? + + init(tappedButton: @escaping () -> Void) { + tappedBlock = tappedButton + + super.init(frame: .zero) + addSubviews([label, button]) + + /// A constraint to center the label + button between the center of the full sized sheet view and its' top + let dimensionBefore = safeAreaLayoutGuide.topAnchor.anchorWithOffset(to: label.bottomAnchor) + let dimensionAfter = label.bottomAnchor.anchorWithOffset(to: safeAreaLayoutGuide.centerYAnchor) + offsetCenteredLabelContraint = dimensionBefore.constraint(equalTo: dimensionAfter, constant: traitCollection.userInterfaceIdiom == .pad ? 0 : Constants.labelOffset) + centeredLabelConstraint = label.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.centerYAnchor) + + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: label.bottomAnchor, constant: Constants.buttonLabelSpacing), + label.widthAnchor.constraint(lessThanOrEqualToConstant: Constants.labelWidth), + label.trailingAnchor.constraint(lessThanOrEqualToSystemSpacingAfter: trailingAnchor, multiplier: 1), + label.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: leadingAnchor, multiplier: 1), + label.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor), + button.centerXAnchor.constraint(equalTo: safeAreaLayoutGuide.centerXAnchor) + ]) + } + + override func updateConstraints() { + super.updateConstraints() + + centeredLabelConstraint?.isActive = traitCollection.verticalSizeClass == .compact + offsetCenteredLabelContraint?.isActive = traitCollection.verticalSizeClass != .compact + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + setNeedsUpdateConstraints() + } + + @objc func tappedButton() { + tappedBlock() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Filter/FilterProvider.swift b/WordPress/Classes/ViewRelated/Reader/Filter/FilterProvider.swift new file mode 100644 index 000000000000..c596c5ef5826 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Filter/FilterProvider.swift @@ -0,0 +1,288 @@ +import WordPressFlux + +class FilterProvider: Observable, FilterTabBarItem { + + enum State { + case loading + case ready([TableDataItem]) + case error(Error) + + var isReady: Bool { + switch self { + case .ready: + return true + case .error, .loading: + return false + } + } + } + + var title: String { + return titleFunc(state) + } + + var state: State = .loading { + didSet { + emitChange() + } + } + + var items: [TableDataItem] { + switch state { + case .loading, .error: + return [] + case .ready(let items): + return FilterProvider.filterItems(items, siteType: siteType) + } + } + + typealias Provider = (@escaping (Result<[TableDataItem], Error>) -> Void) -> Void + + let accessibilityIdentifier: String + let cellClass: UITableViewCell.Type + let reuseIdentifier: String + let emptyTitle: String + let emptyActionTitle: String + let section: ReaderManageScenePresenter.TabbedSection + let siteType: SiteOrganizationType? + + private let titleFunc: (State?) -> String + private let provider: Provider + + let changeDispatcher = Dispatcher() + + init(title: @escaping (State?) -> String, + accessibilityIdentifier: String, + cellClass: UITableViewCell.Type, + reuseIdentifier: String, + emptyTitle: String, + emptyActionTitle: String, + section: ReaderManageScenePresenter.TabbedSection, + provider: @escaping Provider, + siteType: SiteOrganizationType? = nil) { + + titleFunc = title + self.accessibilityIdentifier = accessibilityIdentifier + self.cellClass = cellClass + self.reuseIdentifier = reuseIdentifier + self.emptyTitle = emptyTitle + self.emptyActionTitle = emptyActionTitle + self.section = section + self.provider = provider + self.siteType = siteType + } + + func refresh() { + state = .loading + provider() { [weak self] result in + switch result { + case .success(let items): + self?.state = .ready(items) + case .failure(let error): + self?.state = .error(error) + } + } + } +} + +extension FilterProvider { + + func showAdd(on presenterViewController: UIViewController, sceneDelegate: ScenePresenterDelegate?) { + let presenter = ReaderManageScenePresenter(selected: section, sceneDelegate: sceneDelegate) + presenter.present(on: presenterViewController, animated: true, completion: nil) + } + + static func filterItems(_ items: [TableDataItem], siteType: SiteOrganizationType?) -> [TableDataItem] { + // If a site type is specified, filter items by it. + // Otherwise, just return all items. + guard let siteType = siteType else { + return items + } + + var filteredItems = [TableDataItem]() + + for item in items { + if let topic = item.topic as? ReaderSiteTopic, + topic.organizationType == siteType { + filteredItems.append(item) + } + } + + return filteredItems + } + +} + +extension ReaderSiteTopic { + + static func filterProvider(for siteType: SiteOrganizationType?) -> FilterProvider { + let titleFunction: (FilterProvider.State?) -> String = { state in + switch state { + case .loading, .error, .none: + return NSLocalizedString("Sites", comment: "Sites Filter Tab Title") + case .ready(let items): + let filteredItems = FilterProvider.filterItems(items, siteType: siteType) + return String(format: NSLocalizedString("Sites (%lu)", comment: "Sites Filter Tab Title with Count"), filteredItems.count) + } + } + + let emptyTitle = NSLocalizedString("Add a site", comment: "No Tags View Button Label") + let emptyActionTitle = NSLocalizedString("You can follow posts on a specific site by following it.", comment: "No Sites View Label") + + return FilterProvider(title: titleFunction, + accessibilityIdentifier: "SitesFilterTab", + cellClass: SiteTableViewCell.self, + reuseIdentifier: "Sites", + emptyTitle: emptyTitle, + emptyActionTitle: emptyActionTitle, + section: .sites, + provider: tableProvider, + siteType: siteType) + } + + private static func tableProvider(completion: @escaping (Result<[TableDataItem], Error>) -> Void) { + let completionBlock: (Result<[ReaderSiteTopic], Error>) -> Void = { result in + let itemResult = result.map { sites in + sites.map { topic in + return TableDataItem(topic: topic, configure: { cell in + cell.textLabel?.text = topic.title + cell.detailTextLabel?.text = topic.siteURL + addUnseenPostCount(topic, with: cell) + }) + } + } + completion(itemResult) + } + + fetchStoredFollowedSites(completion: completionBlock) + fetchFollowedSites(completion: completionBlock) + } + + /// Fetch sites from remote service + /// + private static func fetchFollowedSites(completion: @escaping (Result<[ReaderSiteTopic], Error>) -> Void) { + let siteService = ReaderTopicService(coreDataStack: ContextManager.shared) + + siteService.fetchFollowedSites(success: { + let sites = (try? ReaderAbstractTopic.lookupAllSites(in: ContextManager.shared.mainContext)) ?? [] + completion(.success(sites)) + }, failure: { error in + DDLogError("Could not sync sites: \(String(describing: error))") + let remoteServiceError = NSError(domain: WordPressComRestApiErrorDomain, code: -1, userInfo: nil) + completion(.failure(error ?? remoteServiceError)) + }) + } + + /// Fetch sites from Core Data + /// + private static func fetchStoredFollowedSites(completion: @escaping (Result<[ReaderSiteTopic], Error>) -> Void) { + let sites = (try? ReaderAbstractTopic.lookupAllSites(in: ContextManager.shared.mainContext)) ?? [] + completion(.success(sites)) + } + + /// Adds a custom accessory view displaying the unseen post count. + /// + private static func addUnseenPostCount(_ topic: ReaderSiteTopic, with cell: UITableViewCell) { + + // Always reset first. + cell.accessoryView = nil + + guard topic.unseenCount > 0 else { + return + } + + // Create background view + let unseenCountView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: UnseenCountConstants.viewSize)) + unseenCountView.layer.cornerRadius = UnseenCountConstants.cornerRadius + unseenCountView.backgroundColor = .tertiaryFill + + // Create count label + let countLabel = UILabel() + countLabel.font = WPStyleGuide.subtitleFont() + countLabel.textColor = .text + countLabel.backgroundColor = .clear + countLabel.text = topic.unseenCount.abbreviatedString() + + let accessibilityFormat = topic.unseenCount == 1 ? UnseenCountConstants.singularUnseen : UnseenCountConstants.pluralUnseen + countLabel.accessibilityLabel = String(format: accessibilityFormat, topic.unseenCount) + + countLabel.sizeToFit() + + // Resize views + unseenCountView.frame.size.width = max(countLabel.frame.width + UnseenCountConstants.labelPadding, UnseenCountConstants.viewSize) + countLabel.center = unseenCountView.center + + // Display in cell's accessory view + unseenCountView.addSubview(countLabel) + cell.accessoryView = unseenCountView + } + + private struct UnseenCountConstants { + static let cornerRadius: CGFloat = 15 + static let viewSize: CGFloat = 30 + static let labelPadding: CGFloat = 20 + static let singularUnseen = NSLocalizedString("%1$d unseen post", comment: "Format string for single unseen post count. The %1$d is a placeholder for the count.") + static let pluralUnseen = NSLocalizedString("%1$d unseen posts", comment: "Format string for plural unseen posts count. The %1$d is a placeholder for the count.") + } + +} + +extension ReaderTagTopic { + + static func filterProvider() -> FilterProvider { + let titleFunction: (FilterProvider.State?) -> String = { state in + switch state { + case .loading, .error, .none: + return NSLocalizedString("Topics", comment: "Topics Filter Tab Title") + case .ready(let items): + return String(format: NSLocalizedString("Topics (%lu)", comment: "Topics Filter Tab Title with Count"), items.count) + } + } + + let emptyTitle = NSLocalizedString("Add a topic", comment: "No Topics View Button Label") + let emptyActionTitle = NSLocalizedString("You can follow posts on a specific subject by adding a topic.", comment: "No Topics View Label") + + return FilterProvider(title: titleFunction, + accessibilityIdentifier: "TagsFilterTab", + cellClass: UITableViewCell.self, + reuseIdentifier: "Tags", + emptyTitle: emptyTitle, + emptyActionTitle: emptyActionTitle, + section: .tags, + provider: tableProvider) + } + + private static func tableProvider(completion: @escaping (Result<[TableDataItem], Error>) -> Void) { + fetchFollowedTags(completion: { result in + let itemResult = result.map { tags in + tags.map { topic in + return TableDataItem(topic: topic, configure: { (cell) in + cell.textLabel?.text = topic.slug + }) + } + } + completion(itemResult) + }) + } + + static var tagsFetchRequest: NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "ReaderTagTopic") + // Only show following tags, even if the user is logged out + fetchRequest.predicate = NSPredicate(format: "following == YES AND showInMenu == YES AND type == 'tag'") + + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "title", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare))] + return fetchRequest + } + + private static func fetchFollowedTags(completion: @escaping (Result<[ReaderTagTopic], Error>) -> Void) { + do { + guard let topics = try ContextManager.sharedInstance().mainContext.fetch(tagsFetchRequest) as? [ReaderTagTopic] else { + return + } + completion(.success(topics)) + } catch { + DDLogError("There was a problem fetching followed tags." + error.localizedDescription) + completion(.failure(error)) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Filter/FilterSheetView.swift b/WordPress/Classes/ViewRelated/Reader/Filter/FilterSheetView.swift new file mode 100644 index 000000000000..051e91c09725 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Filter/FilterSheetView.swift @@ -0,0 +1,201 @@ +import WordPressFlux + +class FilterSheetView: UIView { + + private struct HeaderConstants { + static let spacing: CGFloat = 16 + static let insets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 18, bottom: 0, right: 18) + static let font = WPStyleGuide.fontForTextStyle(.headline) + } + + // MARK: View Setup + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.tableFooterView = UIView() // To hide the separators for empty cells + tableView.separatorStyle = .none + tableView.delegate = self + return tableView + }() + + private lazy var emptyView: EmptyActionView = { + let view = EmptyActionView(tappedButton: tappedEmptyAddButton) + + // Hide the button if the user is not logged in + view.button.isHidden = !ReaderHelpers.isLoggedIn() + + return view + }() + + private lazy var ghostableTableView: UITableView = { + let tableView = UITableView() + tableView.allowsSelection = false + tableView.isScrollEnabled = false + tableView.separatorStyle = .none + return tableView + }() + + private lazy var filterTabBar: FilterTabBar = { + let tabBar = FilterTabBar() + WPStyleGuide.configureFilterTabBar(tabBar) + tabBar.tabSizingStyle = .equalWidths + tabBar.addTarget(self, action: #selector(FilterSheetView.changedTab(_:)), for: .valueChanged) + return tabBar + }() + + private lazy var headerLabelView: UIView = { + let labelView = UIView() + let label = UILabel() + label.font = HeaderConstants.font + label.text = viewTitle + label.translatesAutoresizingMaskIntoConstraints = false + labelView.addSubview(label) + labelView.pinSubviewToAllEdges(label, insets: HeaderConstants.insets) + return labelView + }() + + private lazy var stackView: UIStackView = { + let stack = UIStackView(arrangedSubviews: [ + headerLabelView, + filterTabBar, + tableView, + ghostableTableView, + emptyView + ]) + + stack.setCustomSpacing(HeaderConstants.spacing, after: headerLabelView) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + return stack + }() + + // MARK: Properties + + private weak var presentationController: UIViewController? + private var subscriptions: [Receipt]? + private var changedFilter: (ReaderAbstractTopic) -> Void + private var dataSource: FilterTableViewDataSource? { + didSet { + tableView.dataSource = dataSource + tableView.reloadData() + } + } + + private func tappedEmptyAddButton() { + if let controller = presentationController { + selectedFilter?.showAdd(on: controller, sceneDelegate: self) + } + } + + private var selectedFilter: FilterProvider? { + set { + if let filter = newValue { + dataSource = FilterTableViewDataSource(data: filter.items, reuseIdentifier: filter.reuseIdentifier) + if !filter.state.isReady { + /// Loading state + emptyView.isHidden = true + tableView.isHidden = true + updateGhostableTableViewOptions(cellClass: filter.cellClass, identifier: filter.reuseIdentifier) + } else { + /// Finished loading + ghostableTableView.stopGhostAnimation() + ghostableTableView.isHidden = true + + let isEmpty = filter.items.isEmpty + if isEmpty { + refreshEmpty(filter: filter) + } + emptyView.isHidden = !isEmpty + tableView.isHidden = isEmpty + } + } + } + get { + return filterTabBar.items[filterTabBar.selectedIndex] as? FilterProvider + } + } + + private let viewTitle: String + private let filters: [FilterProvider] + + // MARK: Methods + + init(viewTitle: String, + filters: [FilterProvider], + presentationController: UIViewController, + changedFilter: @escaping (ReaderAbstractTopic) -> Void) { + self.viewTitle = viewTitle + self.filters = filters + self.presentationController = presentationController + self.changedFilter = changedFilter + + super.init(frame: .zero) + + filterTabBar.items = filters + filters.forEach { filter in + tableView.register(filter.cellClass, forCellReuseIdentifier: filter.reuseIdentifier) + } + selectedFilter = filters.first + + // If there is only one filter, don't show the filter tab bar. + filterTabBar.isHidden = filters.count == 1 + + addSubview(stackView) + pinSubviewToAllEdges(stackView) + + subscriptions = filters.map() { filter in + filter.onChange() { [weak self] in + if self?.selectedFilter?.accessibilityIdentifier == filter.accessibilityIdentifier { + self?.selectedFilter = filter + } + self?.filterTabBar.items = filters + } + } + + refresh(filters: filters) + } + + private func refresh(filters: [FilterProvider]) { + filters.forEach({ provider in + provider.refresh() + }) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateGhostableTableViewOptions(cellClass: UITableViewCell.Type, identifier: String) { + ghostableTableView.register(cellClass, forCellReuseIdentifier: identifier) + let ghostOptions = GhostOptions(displaysSectionHeader: false, reuseIdentifier: identifier, rowsPerSection: [15]) + let style = GhostStyle(beatDuration: GhostStyle.Defaults.beatDuration, + beatStartColor: .placeholderElement, + beatEndColor: .placeholderElementFaded) + ghostableTableView.removeGhostContent() + ghostableTableView.isHidden = false + ghostableTableView.displayGhostContent(options: ghostOptions, style: style) + } + + @objc func changedTab(_ sender: FilterTabBar) { + selectedFilter = filterTabBar.items[sender.selectedIndex] as? FilterProvider + } + + private func refreshEmpty(filter: FilterProvider) { + emptyView.title = filter.emptyTitle + emptyView.labelText = filter.emptyActionTitle + } +} + +extension FilterSheetView: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let topic = dataSource?.data[indexPath.row].topic { + changedFilter(topic) + } + } +} + +extension FilterSheetView: ScenePresenterDelegate { + func didDismiss(presenter: ScenePresenter) { + refresh(filters: filters) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Filter/FilterSheetViewController.swift b/WordPress/Classes/ViewRelated/Reader/Filter/FilterSheetViewController.swift new file mode 100644 index 000000000000..4f4833dafcb9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Filter/FilterSheetViewController.swift @@ -0,0 +1,44 @@ +class FilterSheetViewController: UIViewController { + + private let viewTitle: String + private let filters: [FilterProvider] + private let changedFilter: (ReaderAbstractTopic) -> Void + + init(viewTitle: String, + filters: [FilterProvider], + changedFilter: @escaping (ReaderAbstractTopic) -> Void) { + self.viewTitle = viewTitle + self.filters = filters + self.changedFilter = changedFilter + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = FilterSheetView(viewTitle: viewTitle, + filters: filters, + presentationController: self, + changedFilter: changedFilter) + } +} + +extension FilterSheetViewController: DrawerPresentable { + func handleDismiss() { + WPAnalytics.track(.readerFilterSheetDismissed) + } + + var scrollableView: UIScrollView? { + return (view as? FilterSheetView)?.tableView + } + + var collapsedHeight: DrawerHeight { + if traitCollection.verticalSizeClass == .compact { + return .maxHeight + } else { + return .contentHeight(0) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Filter/FilterTableData.swift b/WordPress/Classes/ViewRelated/Reader/Filter/FilterTableData.swift new file mode 100644 index 000000000000..04d735fe26db --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Filter/FilterTableData.swift @@ -0,0 +1,55 @@ +struct TableDataItem { + let topic: ReaderAbstractTopic + let configure: (UITableViewCell) -> Void +} + +class FilterTableViewDataSource: NSObject, UITableViewDataSource { + + let data: [TableDataItem] + private let reuseIdentifier: String + + init(data: [TableDataItem], reuseIdentifier: String) { + self.data = data + self.reuseIdentifier = reuseIdentifier + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return data.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let item = data[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: reuseIdentifier, for: indexPath) + + item.configure(cell) + + return cell + } +} + +class SiteTableViewCell: UITableViewCell, GhostableView { + + enum Constants { + static let textLabelCharacterWidth = 40 // Number of characters in text label + static let detailLabelCharacterWidth = 80 // Number of characters in detail label + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) + detailTextLabel?.textColor = UIColor.systemGray + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func ghostAnimationWillStart() { + contentView.subviews.forEach { view in + view.isGhostableDisabled = true + } + textLabel?.text = String(repeating: " ", count: Constants.textLabelCharacterWidth) + textLabel?.isGhostableDisabled = false + detailTextLabel?.text = String(repeating: " ", count: Constants.detailLabelCharacterWidth) + detailTextLabel?.isGhostableDisabled = false + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/FollowingMenuItemCreator.swift b/WordPress/Classes/ViewRelated/Reader/FollowingMenuItemCreator.swift deleted file mode 100644 index 84f68eae4f4d..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/FollowingMenuItemCreator.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Gridicons - -/// Encapsulates creating of a ReaderMenuItem for Following -final class FollowingMenuItemCreator: ReaderMenuItemCreator { - func supports(_ topic: ReaderAbstractTopic) -> Bool { - return ReaderHelpers.topicIsFollowing(topic) - } - - func menuItem(with topic: ReaderAbstractTopic) -> ReaderMenuItem { - var item = ReaderMenuItem(title: topic.title, - type: .topic, - icon: Gridicon.iconOfType(.checkmarkCircle), - topic: topic) - item.order = ReaderDefaultMenuItemOrder.followed.rawValue - - return item - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/LikedMenuItemCreator.swift b/WordPress/Classes/ViewRelated/Reader/LikedMenuItemCreator.swift deleted file mode 100644 index 57e6892d73da..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/LikedMenuItemCreator.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Gridicons - -/// Encapsulates creating of a ReaderMenuItem for Liked -final class LikedMenuItemCreator: ReaderMenuItemCreator { - func supports(_ topic: ReaderAbstractTopic) -> Bool { - return ReaderHelpers.topicIsLiked(topic) - } - - func menuItem(with topic: ReaderAbstractTopic) -> ReaderMenuItem { - var item = ReaderMenuItem(title: topic.title, - type: .topic, - icon: Gridicon.iconOfType(.star), - topic: topic) - item.order = ReaderDefaultMenuItemOrder.likes.rawValue - - return item - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/Manage/OffsetTableViewHandler.swift b/WordPress/Classes/ViewRelated/Reader/Manage/OffsetTableViewHandler.swift new file mode 100644 index 000000000000..aa416d5a5877 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Manage/OffsetTableViewHandler.swift @@ -0,0 +1,43 @@ +// A table view handler offset by 1 (for Add a Topic in Reader Tags) +class OffsetTableViewHandler: WPTableViewHandler { + + func object(at indexPath: IndexPath) -> NSFetchRequestResult? { + guard let indexPath = adjusted(indexPath: indexPath) else { + return nil + } + return resultsController.object(at: indexPath) + } + + func adjusted(indexPath: IndexPath) -> IndexPath? { + guard indexPath.row > 0 else { + return nil + } + return IndexPath(row: indexPath.row - 1, section: indexPath.section) + } + + func adjustedToTable(indexPath: IndexPath) -> IndexPath { + let newIndexPath = IndexPath(row: indexPath.row + 1, section: indexPath.section) + return newIndexPath + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return super.tableView(tableView, numberOfRowsInSection: section) + 1 + } + + override func controller(_ controller: NSFetchedResultsController, + didChange anObject: Any, + at indexPath: IndexPath?, + for type: NSFetchedResultsChangeType, + newIndexPath: IndexPath?) { + + let oldIndexPath = indexPath.map { + adjustedToTable(indexPath: $0) + } ?? nil + + let newPath = newIndexPath.map { + adjustedToTable(indexPath: $0) + } ?? nil + + super.controller(controller, didChange: anObject, at: oldIndexPath, for: type, newIndexPath: newPath) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Manage/ReaderManageScenePresenter.swift b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderManageScenePresenter.swift new file mode 100644 index 000000000000..c15871f9f6b7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderManageScenePresenter.swift @@ -0,0 +1,84 @@ +extension NSNotification.Name { + static let readerManageControllerWasDismissed = NSNotification.Name("ReaderManageControllerWasDismissed") +} + +class ReaderManageScenePresenter: ScenePresenter { + + enum TabbedSection { + case tags + case sites + + private func makeViewController() -> UIViewController { + switch self { + case .tags: + return ReaderTagsTableViewController(style: .grouped) + case .sites: + return ReaderFollowedSitesViewController.controller(showsAccessoryFollowButtons: true, showsSectionTitle: false) + } + } + + var tabbedItem: TabbedViewController.TabbedItem { + switch self { + case .tags: + return TabbedViewController.TabbedItem(title: NSLocalizedString("Followed Topics", comment: "Followed Topics Title"), + viewController: makeViewController(), + accessibilityIdentifier: "FollowedTags") + case .sites: + return TabbedViewController.TabbedItem(title: NSLocalizedString("Followed Sites", comment: "Followed Sites Title"), + viewController: makeViewController(), + accessibilityIdentifier: "FollowedSites") + } + } + } + + weak var presentedViewController: UIViewController? + + private let sections: [TabbedSection] + private let selectedSection: TabbedSection? + private weak var delegate: ScenePresenterDelegate? + + init(sections tabbedSections: [TabbedSection] = [TabbedSection.tags, TabbedSection.sites], + selected: TabbedSection? = nil, sceneDelegate: ScenePresenterDelegate? = nil) { + sections = tabbedSections + selectedSection = selected + delegate = sceneDelegate + } + + func present(on viewController: UIViewController, animated: Bool, completion: (() -> Void)?) { + guard presentedViewController == nil else { + completion?() + return + } + let navigationController = makeNavigationController() + presentedViewController = navigationController + viewController.present(navigationController, animated: true, completion: nil) + + QuickStartTourGuide.shared.visited(.readerDiscoverSettings) + WPAnalytics.track(.readerManageViewDisplayed) + } +} + +private extension ReaderManageScenePresenter { + func makeViewController() -> TabbedViewController { + let tabbedItems = sections.map({ item in + return item.tabbedItem + }) + + let tabbedViewController = TabbedViewController(items: tabbedItems, onDismiss: { + self.delegate?.didDismiss(presenter: self) + NotificationCenter.default.post(name: .readerManageControllerWasDismissed, object: self) + WPAnalytics.track(.readerManageViewDismissed) + }) + tabbedViewController.title = NSLocalizedString("Manage", comment: "Title for the Reader Manage screen.") + if let section = selectedSection, let firstSelection = sections.firstIndex(of: section) { + tabbedViewController.selection = firstSelection + } + + return tabbedViewController + } + + func makeNavigationController() -> UINavigationController { + let navigationController = UINavigationController(rootViewController: makeViewController()) + return navigationController + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsFooter.swift b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsFooter.swift new file mode 100644 index 000000000000..6a0dc842b894 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsFooter.swift @@ -0,0 +1,20 @@ +import UIKit + +class ReaderTagsFooter: UITableViewHeaderFooterView, NibReusable { + + @IBOutlet weak var actionButton: FancyButton! + + var actionButtonHandler: (() -> Void)? + + override func awakeFromNib() { + super.awakeFromNib() + contentView.backgroundColor = .basicBackground + actionButton.isPrimary = false + } + + // MARK: - IBAction + + @IBAction private func actionButtonTapped(_ sender: UIButton) { + actionButtonHandler?() + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsFooter.xib b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsFooter.xib new file mode 100644 index 000000000000..b8e9904c259d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsFooter.xib @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewController+Cells.swift b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewController+Cells.swift new file mode 100644 index 000000000000..0bb94999d9e3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewController+Cells.swift @@ -0,0 +1,41 @@ +extension ReaderTagsTableViewModel { + func configure(cell: UITableViewCell, for topic: ReaderTagTopic?) { + guard let topic = topic else { + configureAddTag(cell: cell) + return + } + + cell.textLabel?.text = topic.title + + let button = UIButton.closeAccessoryButton() + button.addTarget(self, action: #selector(tappedAccessory(_:)), for: .touchUpInside) + let unfollowString = NSLocalizedString("Unfollow %@", comment: "Accessibility label for unfollowing a tag") + button.accessibilityLabel = String(format: unfollowString, topic.title) + cell.accessoryView = button + cell.accessibilityElements = [button] + } + + private func configureAddTag(cell: UITableViewCell) { + cell.textLabel?.text = NSLocalizedString("Add a Topic", comment: "Title of a feature to add a new topic to the topics subscribed by the user.") + cell.accessoryView = UIImageView(image: UIImage.gridicon(.plusSmall)) + } +} + +// MARK: - Close Accessory Button +private extension UIButton { + + enum Constants { + static let size = CGSize(width: 40, height: 40) + static let image = UIImage.gridicon(.crossSmall) + static let tintColor = MurielColor(name: .gray, shade: .shade10) + static let insets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: -8) // To better align with the plus sign accessory view + } + + static func closeAccessoryButton() -> UIButton { + let button = UIButton(frame: CGRect(origin: .zero, size: Constants.size)) + button.setImage(Constants.image, for: .normal) + button.imageEdgeInsets = Constants.insets + button.imageView?.tintColor = UIColor.muriel(color: Constants.tintColor) + return button + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewController.swift b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewController.swift new file mode 100644 index 000000000000..d288e7590bc0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewController.swift @@ -0,0 +1,30 @@ +class ReaderTagsTableViewController: UIViewController { + + private let style: UITableView.Style + + private lazy var tableView: UITableView = { + let tableView = UITableView(frame: .zero, style: style) + tableView.translatesAutoresizingMaskIntoConstraints = false + return tableView + }() + + private var viewModel: ReaderTagsTableViewModel? + + init(style: UITableView.Style) { + self.style = style + super.init(nibName: nil, bundle: nil) + + viewModel = ReaderTagsTableViewModel(tableView: tableView, presenting: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.addSubview(tableView) + view.pinSubviewToAllEdges(tableView) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewModel.swift b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewModel.swift new file mode 100644 index 000000000000..4e5f941bc1ab --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Manage/ReaderTagsTableViewModel.swift @@ -0,0 +1,214 @@ +class ReaderTagsTableViewModel: NSObject { + + private let tableViewHandler: OffsetTableViewHandler + private let context: NSManagedObjectContext + private weak var tableView: UITableView? + private weak var presentingViewController: UIViewController? + + init(tableView: UITableView, + presenting viewController: UIViewController, + context: NSManagedObjectContext = ContextManager.sharedInstance().mainContext) { + let handler = OffsetTableViewHandler(tableView: tableView) + tableViewHandler = handler + presentingViewController = viewController + self.tableView = tableView + self.context = context + super.init() + handler.delegate = self + + tableView.register(ReaderTagsFooter.defaultNib, forHeaderFooterViewReuseIdentifier: ReaderTagsFooter.defaultReuseID) + } +} + +// MARK: - WPTableViewHandler + +extension ReaderTagsTableViewModel: WPTableViewHandlerDelegate { + func managedObjectContext() -> NSManagedObjectContext { + return context + } + + func fetchRequest() -> NSFetchRequest { + return ReaderTagTopic.tagsFetchRequest + } + + /// Disable highlighting for all rows but "Add a Topic" this allows the rows to be "flashed" but not show taps + func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { + let adjustedPath = tableViewHandler.adjusted(indexPath: indexPath) + return adjustedPath == nil + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectSelectedRowWithAnimation(true) + guard tableViewHandler.adjusted(indexPath: indexPath) == nil else { + return + } + showAddTag() + } + + func configureCell(_ cell: UITableViewCell, at indexPath: IndexPath) { + let topic = tableViewHandler.object(at: indexPath) as? ReaderTagTopic + configure(cell: cell, for: topic) + } + + // MARK: - Discover more topics footer + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + guard section == 0 else { + return CGFloat.leastNormalMagnitude + } + return UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + guard section == 0, + let footer = tableView.dequeueReusableHeaderFooterView(withIdentifier: ReaderTagsFooter.defaultReuseID) as? ReaderTagsFooter else { + return nil + } + + let title = NSLocalizedString("Discover more topics", comment: "Button title. Tapping shows the Follow Topics screen.") + footer.actionButton.setTitle(title, for: .normal) + + footer.actionButtonHandler = { [weak self] in + self?.showSelectInterests() + } + + return footer + } +} + +// MARK: - Actions + +extension ReaderTagsTableViewModel { + @objc func tappedAccessory(_ sender: UIButton) { + guard let point = sender.superview?.convert(sender.center, to: tableView), + let indexPath = tableView?.indexPathForRow(at: point), + let adjustIndexPath = tableViewHandler.adjusted(indexPath: indexPath), + let topic = tableViewHandler.resultsController.object(at: adjustIndexPath) as? ReaderTagTopic else { + return + } + + unfollow(topic) + NotificationCenter.default.post(name: .ReaderTopicUnfollowed, + object: nil, + userInfo: [ReaderNotificationKeys.topic: topic]) + } + + /// Presents a new view controller for subscribing to a new tag. + private func showAddTag() { + + let placeholder = NSLocalizedString("Add any topic", comment: "Placeholder text. A call to action for the user to type any topic to which they would like to subscribe.") + let controller = SettingsTextViewController(text: nil, placeholder: placeholder, hint: nil) + controller.title = NSLocalizedString("Add a Topic", comment: "Title of a feature to add a new topic to the topics subscribed by the user.") + controller.onValueChanged = { [weak self] value in + self?.follow(tagName: value) + } + controller.mode = .lowerCaseText + controller.displaysActionButton = true + controller.actionText = NSLocalizedString("Add Topic", comment: "Button Title. Tapping subscribes the user to a new topic.") + controller.onActionPress = { [weak self] in + self?.dismissModal() + } + + let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(dismissModal)) + controller.navigationItem.leftBarButtonItem = cancelButton + + let navController = UINavigationController(rootViewController: controller) + navController.modalPresentationStyle = .formSheet + + presentingViewController?.present(navController, animated: true, completion: nil) + } + + /// Presents a new view controller for selecting topics to follow. + private func showSelectInterests() { + let configuration = ReaderSelectInterestsConfiguration( + title: NSLocalizedString("Follow topics", comment: "Screen title. Reader select interests title label text."), + subtitle: nil, + buttonTitle: nil, + loading: NSLocalizedString("Following new topics...", comment: "Label displayed to the user while loading their selected interests") + ) + + let topics = tableViewHandler.resultsController.fetchedObjects as? [ReaderTagTopic] ?? [] + + let controller = ReaderSelectInterestsViewController(configuration: configuration, + topics: topics) + + controller.didSaveInterests = { [weak self] _ in + self?.dismissModal() + } + + let navController = UINavigationController(rootViewController: controller) + navController.modalPresentationStyle = .formSheet + + presentingViewController?.present(navController, animated: true, completion: nil) + } + + @objc func dismissModal() { + presentingViewController?.dismiss(animated: true, completion: nil) + } + + /// Follow a new tag with the specified tag name. + /// + /// - Parameters: + /// - tagName: The name of the tag to follow. + private func follow(tagName: String) { + + let tagName = tagName.trim() + guard !tagName.isEmpty() else { + return + } + + let service = ReaderTopicService(coreDataStack: ContextManager.shared) + + let generator = UINotificationFeedbackGenerator() + generator.prepare() + + service.followTagNamed(tagName, withSuccess: { [weak self] in + generator.notificationOccurred(.success) + + guard let self else { return } + + // A successful follow makes the new tag the currentTopic. + if let tag = service.currentTopic(in: self.context) as? ReaderTagTopic { + self.scrollToTag(tag) + } + }, failure: { (error) in + DDLogError("Could not follow topic named \(tagName) : \(String(describing: error))") + + generator.notificationOccurred(.error) + + let title = NSLocalizedString("Could Not Follow Topic", comment: "Title of a prompt informing the user there was a probem unsubscribing from a topic in the reader.") + let message = error?.localizedDescription + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addCancelActionWithTitle(NSLocalizedString("OK", comment: "Button title. An acknowledgement of the message displayed in a prompt.")) + alert.presentFromRootViewController() + }, source: "manage") + } + + /// Tells the ReaderTopicService to unfollow the specified topic. + /// + /// - Parameters: + /// - topic: The tag topic that is to be unfollowed. + private func unfollow(_ topic: ReaderTagTopic) { + let service = ReaderTopicService(coreDataStack: ContextManager.shared) + service.unfollowTag(topic, withSuccess: nil) { (error) in + DDLogError("Could not unfollow topic \(topic), \(String(describing: error))") + + let title = NSLocalizedString("Could Not Remove Topic", comment: "Title of a prompt informing the user there was a probem unsubscribing from a topic in the reader.") + let message = error?.localizedDescription + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addCancelActionWithTitle(NSLocalizedString("OK", comment: "Button title. An acknowledgement of the message displayed in a prompt.")) + alert.presentFromRootViewController() + } + } + + /// Scrolls the tableView so the specified tag is in view and flashes that row + /// + /// - Parameters: + /// - tag: The tag to scroll into view. + private func scrollToTag(_ tag: ReaderTagTopic) { + guard let indexPath = tableViewHandler.resultsController.indexPath(forObject: tag) else { + return + } + tableView?.flashRowAtIndexPath(tableViewHandler.adjustedToTable(indexPath: indexPath), scrollPosition: .middle, completion: {}) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Manage/TabbedViewController.swift b/WordPress/Classes/ViewRelated/Reader/Manage/TabbedViewController.swift new file mode 100644 index 000000000000..b47dfde97924 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Manage/TabbedViewController.swift @@ -0,0 +1,89 @@ +/// Contains multiple Child View Controllers with a Filter Tab Bar to switch between them. +class TabbedViewController: UIViewController { + + struct TabbedItem: FilterTabBarItem { + let title: String + let viewController: UIViewController + let accessibilityIdentifier: String + } + + /// The selected view controller + var selection: Int { + set { + tabBar.setSelectedIndex(newValue) + } + get { + return tabBar.selectedIndex + } + } + + private let items: [TabbedItem] + private let onDismiss: (() -> Void)? + + private lazy var tabBar: FilterTabBar = { + let bar = FilterTabBar() + WPStyleGuide.configureFilterTabBar(bar) + bar.tabSizingStyle = .equalWidths + bar.translatesAutoresizingMaskIntoConstraints = false + bar.addTarget(self, action: #selector(changedItem(sender:)), for: .valueChanged) + return bar + }() + + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + return stackView + }() + + private weak var child: UIViewController? { + didSet { + oldValue?.remove() + + if let child = child, child.parent != self { + addChild(child) + stackView.addArrangedSubview(child.view) + child.didMove(toParent: self) + } + } + } + + init(items: [TabbedItem], onDismiss: (() -> Void)? = nil) { + self.items = items + self.onDismiss = onDismiss + super.init(nibName: nil, bundle: nil) + tabBar.items = items + + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(donePressed)) + + stackView.addArrangedSubview(tabBar) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func donePressed() { + onDismiss?() + dismiss(animated: true, completion: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.addSubview(stackView) + view.pinSubviewToAllEdges(stackView) + + setInitialChild() + } + + private func setInitialChild() { + let initialItem: TabbedItem = items[selection] + child = initialItem.viewController + } + + @objc func changedItem(sender: FilterTabBar) { + let item = items[sender.selectedIndex] + child = item.viewController + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/News.swift b/WordPress/Classes/ViewRelated/Reader/News.swift deleted file mode 100644 index 06005e39e8f9..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/News.swift +++ /dev/null @@ -1,18 +0,0 @@ -/// Puts together the implementation of the News Card -final class News { - private let manager: NewsManager - private let ui: NewsCard - - init(manager: NewsManager, ui: NewsCard) { - self.manager = manager - self.ui = ui - } - - func card(containerId: Identifier) -> NewsCard? { - guard manager.shouldPresentCard(contextId: containerId) else { - return nil - } - - return ui - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/NewsCard.swift b/WordPress/Classes/ViewRelated/Reader/NewsCard.swift deleted file mode 100644 index de3755da86b9..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/NewsCard.swift +++ /dev/null @@ -1,156 +0,0 @@ -import UIKit -import WordPressShared.WPStyleGuide -import Gridicons - -/// UI of the New Card -final class NewsCard: UIViewController { - @IBOutlet weak var dismiss: UIButton! - @IBOutlet weak var illustration: UIImageView! - @IBOutlet weak var newsTitle: UILabel! - @IBOutlet weak var newsSubtitle: UILabel! - @IBOutlet weak var readMore: UIButton! - @IBOutlet weak var borderedView: UIView! - - private let manager: NewsManager - - init(manager: NewsManager) { - self.manager = manager - super.init(nibName: "NewsCard", bundle: nil) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - setupUI() - applyStyles() - loadContent() - prepareForVoiceOver() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - manager.didPresentCard() - } - - private func applyStyles() { - styleBackground() - styleBorderedView() - styleLabels() - styleReadMoreButton() - styleDismissButton() - } - - private func setupUI() { - setUpDismissButton() - setUpReadMoreButton() - populateIllustration() - } - - private func setUpDismissButton() { - dismiss.addTarget(self, action: #selector(dismissAction), for: .touchUpInside) - } - - private func setUpReadMoreButton() { - readMore.addTarget(self, action: #selector(readMoreAction), for: .touchUpInside) - } - - private func populateIllustration() { - illustration.image = UIImage(named: "wp-illustration-notifications")?.imageFlippedForRightToLeftLayoutDirection() - } - - @objc private func dismissAction() { - manager.dismiss() - willMove(toParent: nil) - view.removeFromSuperview() - removeFromParent() - } - - @objc private func readMoreAction() { - manager.readMore() - } - - private func loadContent() { - manager.load { [weak self] newsItem in - switch newsItem { - case .failure(let error): - self?.errorLoading(error) - case .success(let item): - self?.populate(item) - } - } - } - - private func styleBackground() { - view.backgroundColor = .listForeground - } - - private func styleBorderedView() { - borderedView.layer.borderColor = WPStyleGuide.readerCardCellBorderColor().cgColor - borderedView.layer.borderWidth = .hairlineBorderWidth - } - - private func styleLabels() { - WPStyleGuide.applyReaderStreamHeaderTitleStyle(newsTitle) - WPStyleGuide.applyReaderStreamHeaderDetailStyle(newsSubtitle) - } - - private func styleReadMoreButton() { - readMore.naturalContentHorizontalAlignment = .leading - let title = NSLocalizedString("Read More", comment: "Button providing More information in the News Card") - readMore.setTitle(title, for: .normal) - } - - private func styleDismissButton() { - let dismissIcon = Gridicon.iconOfType(.crossCircle, withSize: CGSize(width: 40, height: 40)) - dismiss.setImage(dismissIcon, for: .normal) - dismiss.setTitle(nil, for: .normal) - } - - private func errorLoading(_ error: Error) { - manager.dismiss() - } - - private func populate(_ item: NewsItem) { - let title = item.title - let content = item.content - - newsTitle.text = title - newsSubtitle.text = content - - prepareTitleForVoiceOver(label: title) - prepareSubtitleForVoiceOver(label: content) - } -} - -// MARK: - Accessibility -extension NewsCard: Accessible { - func prepareForVoiceOver() { - prepareDismissButtonForVoiceOver() - prepareReadMoreButtonForVoiceOver() - } - - private func prepareDismissButtonForVoiceOver() { - dismiss.accessibilityLabel = NSLocalizedString("Dismiss", comment: "Accessibility label for the Dismiss button on Reader's News Card") - dismiss.accessibilityTraits = UIAccessibilityTraits.button - dismiss.accessibilityHint = NSLocalizedString("Dismisses the News Card.", comment: "Accessibility hint for the dismiss button on Reader's News Card") - } - - fileprivate func prepareTitleForVoiceOver(label: String) { - newsTitle.accessibilityLabel = label - newsTitle.accessibilityTraits = UIAccessibilityTraits.staticText - } - - fileprivate func prepareSubtitleForVoiceOver(label: String) { - newsSubtitle.accessibilityLabel = label - newsSubtitle.accessibilityTraits = UIAccessibilityTraits.staticText - } - - private func prepareReadMoreButtonForVoiceOver() { - readMore.accessibilityLabel = NSLocalizedString("Read More", comment: "Accessibility label for the Read More button on Reader's News Card") - dismiss.accessibilityTraits = UIAccessibilityTraits.button - dismiss.accessibilityHint = NSLocalizedString("Provides more information.", comment: "Accessibility hint for the Read More button on Reader's News Card") - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/NewsCard.xib b/WordPress/Classes/ViewRelated/Reader/NewsCard.xib deleted file mode 100644 index f6b77d7f0bbe..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/NewsCard.xib +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Reader/NewsManager.swift b/WordPress/Classes/ViewRelated/Reader/NewsManager.swift deleted file mode 100644 index 9f27da03ced7..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/NewsManager.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation -/// Abstracts the business logic supporting the New Card -protocol NewsManager { - func dismiss() - func readMore() - func shouldPresentCard(contextId: Identifier) -> Bool - func didPresentCard() - func load(then completion: @escaping (Result) -> Void) -} - -protocol NewsManagerDelegate: class { - func didDismissNews() - func didSelectReadMore(_ url: URL) -} - -extension NSNotification.Name { - static let NewsCardAvailable = NSNotification.Name(rawValue: "org.wordpress.newscardavailable") - static let NewsCardNotAvailable = NSNotification.Name(rawValue: "org.wordpress.newscardnotavailable") -} - -@objc extension NSNotification { - public static let NewsCardAvailable = NSNotification.Name.NewsCardAvailable - public static let NewsCardNotAvailable = NSNotification.Name.NewsCardNotAvailable -} diff --git a/WordPress/Classes/ViewRelated/Reader/NewsStats.swift b/WordPress/Classes/ViewRelated/Reader/NewsStats.swift deleted file mode 100644 index b2ad0018495c..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/NewsStats.swift +++ /dev/null @@ -1,51 +0,0 @@ -/// Abstract stats tracking -protocol NewsStats { - func trackPresented(news: Result?) - func trackDismissed(news: Result?) - func trackRequestedExtendedInfo(news: Result?) -} - -/// Implementation of the NewsStats protocol that provides Tracks integration for the NewsCard -final class TracksNewsStats: NewsStats { - private let origin: String - - enum StatsKeys { - static let origin = "origin" - static let version = "version" - } - - init(origin: String) { - self.origin = origin - } - - func trackPresented(news: Result?) { - track(event: .newsCardViewed, news: news) - } - - func trackDismissed(news: Result?) { - track(event: .newsCardDismissed, news: news) - } - - func trackRequestedExtendedInfo(news: Result?) { - track(event: .newsCardRequestedExtendedInfo, news: news) - } - - private func eventProperties(version: Decimal) -> [AnyHashable: Any] { - return [StatsKeys.origin: origin, - StatsKeys.version: version.description] - } - - private func track(event: WPAnalyticsStat, news: Result?) { - guard let actualNews = news else { - return - } - - switch actualNews { - case .failure: - return - case .success(let newsItem): - WPAppAnalytics.track(event, withProperties: eventProperties(version: newsItem.version)) - } - } - -} diff --git a/WordPress/Classes/ViewRelated/Reader/OtherMenuItemCreator.swift b/WordPress/Classes/ViewRelated/Reader/OtherMenuItemCreator.swift deleted file mode 100644 index 80c07015a375..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/OtherMenuItemCreator.swift +++ /dev/null @@ -1,17 +0,0 @@ - -/// Encapsulates creating of a ReaderMenuItem for Other -final class OtherMenuItemCreator: ReaderMenuItemCreator { - func supports(_ topic: ReaderAbstractTopic) -> Bool { - return false - } - - func menuItem(with topic: ReaderAbstractTopic) -> ReaderMenuItem { - var item = ReaderMenuItem(title: topic.title, - type: .topic, - icon: nil, - topic: topic) - item.order = ReaderDefaultMenuItemOrder.other.rawValue - - return item - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/Reader.storyboard b/WordPress/Classes/ViewRelated/Reader/Reader.storyboard index a885f6c840c9..2a4597cd3926 100644 --- a/WordPress/Classes/ViewRelated/Reader/Reader.storyboard +++ b/WordPress/Classes/ViewRelated/Reader/Reader.storyboard @@ -1,9 +1,9 @@ - + - + @@ -16,7 +16,7 @@ - + @@ -30,431 +30,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -468,14 +43,14 @@ - + - + @@ -483,31 +58,15 @@ - + - - - - - - - - - - - - - - - - - + @@ -530,45 +89,36 @@ - + - + - + - - - - @@ -577,7 +127,6 @@ - @@ -608,7 +157,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderCommentsViewController.h b/WordPress/Classes/ViewRelated/Reader/ReaderCommentsViewController.h deleted file mode 100644 index 36ec10adf6d6..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/ReaderCommentsViewController.h +++ /dev/null @@ -1,15 +0,0 @@ -#import - -@class ReaderPost; - -@interface ReaderCommentsViewController : UIViewController - -@property (nonatomic, strong, readonly) ReaderPost *post; -@property (nonatomic, assign, readwrite) BOOL allowsPushingPostDetails; - -- (void)setupWithPostID:(NSNumber *)postID siteID:(NSNumber *)siteID; - -+ (instancetype)controllerWithPost:(ReaderPost *)post; -+ (instancetype)controllerWithPostID:(NSNumber *)postID siteID:(NSNumber *)siteID; - -@end diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderCommentsViewController.m b/WordPress/Classes/ViewRelated/Reader/ReaderCommentsViewController.m deleted file mode 100644 index e03461948a2b..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/ReaderCommentsViewController.m +++ /dev/null @@ -1,1189 +0,0 @@ -#import "ReaderCommentsViewController.h" - -#import "Comment.h" -#import "CommentService.h" -#import "ContextManager.h" -#import "ReaderPost.h" -#import "ReaderPostService.h" -#import "ReaderPostHeaderView.h" -#import "UIView+Subviews.h" -#import "WPImageViewController.h" -#import "WPTableViewHandler.h" -#import "SuggestionsTableView.h" -#import "SuggestionService.h" -#import "WordPress-Swift.h" -#import "WPAppAnalytics.h" -#import - - -// NOTE: We want the cells to have a rather large estimated height. This avoids a peculiar -// crash in certain circumstances when the tableView lays out its visible cells, -// and those cells contain WPRichTextEmbeds. -- Aerych, 2016.11.30 -static CGFloat const EstimatedCommentRowHeight = 300.0; -static NSInteger const MaxCommentDepth = 4.0; -static CGFloat const CommentIndentationWidth = 40.0; - -static NSString *CommentCellIdentifier = @"CommentDepth0CellIdentifier"; -static NSString *RestorablePostObjectIDURLKey = @"RestorablePostObjectIDURLKey"; - -@interface ReaderCommentsViewController () - -@property (nonatomic, strong, readwrite) ReaderPost *post; -@property (nonatomic, strong) NSNumber *postSiteID; -@property (nonatomic, strong) UIGestureRecognizer *tapOffKeyboardGesture; -@property (nonatomic, strong) UIActivityIndicatorView *activityFooter; -@property (nonatomic, strong) WPContentSyncHelper *syncHelper; -@property (nonatomic, strong) UITableView *tableView; -@property (nonatomic, strong) WPTableViewHandler *tableViewHandler; -@property (nonatomic, strong) NoResultsViewController *noResultsViewController; -@property (nonatomic, strong) ReplyTextView *replyTextView; -@property (nonatomic, strong) KeyboardDismissHelper *keyboardManager; -@property (nonatomic, strong) SuggestionsTableView *suggestionsTableView; -@property (nonatomic, strong) UIView *postHeaderWrapper; -@property (nonatomic, strong) ReaderPostHeaderView *postHeaderView; -@property (nonatomic, strong) NSIndexPath *indexPathForCommentRepliedTo; -@property (nonatomic, strong) NSLayoutConstraint *replyTextViewHeightConstraint; -@property (nonatomic, strong) NSLayoutConstraint *replyTextViewBottomConstraint; -@property (nonatomic, strong) NSCache *estimatedRowHeights; -@property (nonatomic) BOOL isLoggedIn; -@property (nonatomic) BOOL needsUpdateAttachmentsAfterScrolling; -@property (nonatomic) BOOL needsRefreshTableViewAfterScrolling; -@property (nonatomic) BOOL failedToFetchComments; -@property (nonatomic) BOOL deviceIsRotating; -@property (nonatomic, strong) NSCache *cachedAttributedStrings; - -@end - - -@implementation ReaderCommentsViewController - -#pragma mark - Static Helpers - -+ (instancetype)controllerWithPost:(ReaderPost *)post -{ - ReaderCommentsViewController *controller = [[self alloc] init]; - controller.post = post; - return controller; -} - -+ (instancetype)controllerWithPostID:(NSNumber *)postID siteID:(NSNumber *)siteID -{ - ReaderCommentsViewController *controller = [[self alloc] init]; - [controller setupWithPostID:postID siteID:siteID]; - return controller; -} - - -#pragma mark - State Restoration - -+ (UIViewController *)viewControllerWithRestorationIdentifierPath:(NSArray *)identifierComponents coder:(NSCoder *)coder -{ - NSString *path = [coder decodeObjectForKey:RestorablePostObjectIDURLKey]; - if (!path) { - return nil; - } - - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - NSManagedObjectID *objectID = [context.persistentStoreCoordinator managedObjectIDForURIRepresentation:[NSURL URLWithString:path]]; - if (!objectID) { - return nil; - } - - NSError *error = nil; - ReaderPost *restoredPost = (ReaderPost *)[context existingObjectWithID:objectID error:&error]; - if (error || !restoredPost) { - return nil; - } - - return [self controllerWithPost:restoredPost]; -} - -- (void)encodeRestorableStateWithCoder:(NSCoder *)coder -{ - [coder encodeObject:[[self.post.objectID URIRepresentation] absoluteString] forKey:RestorablePostObjectIDURLKey]; - [super encodeRestorableStateWithCoder:coder]; -} - - -#pragma mark - LifeCycle Methods - -- (instancetype)init -{ - self = [super init]; - if (self) { - self.restorationIdentifier = NSStringFromClass([self class]); - self.restorationClass = [self class]; - } - return self; -} - -- (void)viewDidLoad -{ - [super viewDidLoad]; - self.view.backgroundColor = [UIColor murielBasicBackground]; - - [self checkIfLoggedIn]; - - [self configureNavbar]; - [self configurePostHeader]; - [self configureTableView]; - [self configureTableViewHandler]; - [self configureNoResultsView]; - [self configureReplyTextView]; - [self configureSuggestionsTableView]; - [self configureKeyboardGestureRecognizer]; - [self configureViewConstraints]; - [self configureKeyboardManager]; - - [self refreshAndSync]; -} - -- (void)viewWillAppear:(BOOL)animated -{ - [super viewWillAppear:animated]; - - [self.keyboardManager startListeningToKeyboardNotifications]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(handleApplicationDidBecomeActive:) - name:UIApplicationDidBecomeActiveNotification - object:nil]; -} - -- (void)viewDidAppear:(BOOL)animated -{ - [super viewDidAppear:animated]; - [self.tableView reloadData]; -} - - -- (void)viewWillDisappear:(BOOL)animated -{ - [super viewWillDisappear:animated]; - [self dismissNotice]; - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunused-result" - [self.replyTextView resignFirstResponder]; -#pragma clang diagnostic pop - [self.keyboardManager stopListeningToKeyboardNotifications]; - [[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidBecomeActiveNotification object:nil]; -} - -- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator -{ - [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; - self.deviceIsRotating = true; - - [coordinator animateAlongsideTransition:nil completion:^(id _Nonnull context) { - self.deviceIsRotating = false; - NSIndexPath *selectedIndexPath = [self.tableView indexPathForSelectedRow]; - // Make sure a selected comment is visible after rotating, and that the replyTextView is still the first responder. - if (selectedIndexPath) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunused-result" - [self.replyTextView becomeFirstResponder]; -#pragma clang diagnostic pop - [self.tableView selectRowAtIndexPath:selectedIndexPath animated:NO scrollPosition:UITableViewScrollPositionNone]; - } - }]; -} - - -#pragma mark - Split View Support - -/** - We need to refresh media layout when the app's size changes due the the user adjusting - the split view grip. Respond to the UIApplicationDidBecomeActiveNotification notification - dispatched when the grip is changed and refresh media layout. - */ -- (void)handleApplicationDidBecomeActive:(NSNotification *)notification -{ - [self.view layoutIfNeeded]; -} - -#pragma mark - Tracking methods - --(void)trackCommentsOpened { - NSMutableDictionary *properties = [NSMutableDictionary dictionary]; - properties[WPAppAnalyticsKeyPostID] = self.post.postID; - properties[WPAppAnalyticsKeyBlogID] = self.post.siteID; - [WPAppAnalytics track:WPAnalyticsStatReaderArticleCommentsOpened withProperties:properties]; -} - --(void)trackCommentLikedOrUnliked:(Comment *) comment { - ReaderPost *post = self.post; - WPAnalyticsStat stat; - if (comment.isLiked) { - stat = WPAnalyticsStatReaderArticleCommentLiked; - } else { - stat = WPAnalyticsStatReaderArticleCommentUnliked; - } - - NSMutableDictionary *properties = [NSMutableDictionary dictionary]; - properties[WPAppAnalyticsKeyPostID] = post.postID; - properties[WPAppAnalyticsKeyBlogID] = post.siteID; - [WPAppAnalytics track: stat withProperties:properties]; -} - --(void)trackReplyToComment { - ReaderPost *post = self.post; - NSDictionary *railcar = post.railcarDictionary; - NSMutableDictionary *properties = [NSMutableDictionary dictionary]; - properties[WPAppAnalyticsKeyBlogID] = post.siteID; - properties[WPAppAnalyticsKeyPostID] = post.postID; - properties[WPAppAnalyticsKeyIsJetpack] = @(post.isJetpack); - if (post.feedID && post.feedItemID) { - properties[WPAppAnalyticsKeyFeedID] = post.feedID; - properties[WPAppAnalyticsKeyFeedItemID] = post.feedItemID; - } - [WPAppAnalytics track:WPAnalyticsStatReaderArticleCommentedOn withProperties:properties]; - if (railcar) { - [WPAppAnalytics trackTrainTracksInteraction:WPAnalyticsStatTrainTracksInteract withProperties:railcar]; - } -} -#pragma mark - Configuration - -- (void)configureNavbar -{ - // Don't show 'Reader' in the next-view back button - UIBarButtonItem *backButton = [[UIBarButtonItem alloc] initWithTitle:@" " style:UIBarButtonItemStylePlain target:nil action:nil]; - self.navigationItem.backBarButtonItem = backButton; - - self.title = NSLocalizedString(@"Comments", @"Title of the reader's comments screen"); -} - -- (void)configurePostHeader -{ - __typeof(self) __weak weakSelf = self; - - // Wrapper view - UIView *headerWrapper = [UIView new]; - headerWrapper.translatesAutoresizingMaskIntoConstraints = NO; - headerWrapper.preservesSuperviewLayoutMargins = YES; - headerWrapper.backgroundColor = [UIColor whiteColor]; - headerWrapper.clipsToBounds = YES; - - // Post header view - ReaderPostHeaderView *headerView = [[ReaderPostHeaderView alloc] init]; - headerView.onClick = ^{ - [weakSelf handleHeaderTapped]; - }; - headerView.translatesAutoresizingMaskIntoConstraints = NO; - headerView.showsDisclosureIndicator = self.allowsPushingPostDetails; - [headerView setSubtitle:NSLocalizedString(@"Comments on", @"Sentence fragment. The full phrase is 'Comments on' followed by the title of a post on a separate line.")]; - [headerWrapper addSubview:headerView]; - - // Border - CGSize borderSize = CGSizeMake(CGRectGetWidth(self.view.bounds), 1.0); - UIImage *borderImage = [UIImage imageWithColor:[UIColor murielNeutral5] havingSize:borderSize]; - UIImageView *borderView = [[UIImageView alloc] initWithImage:borderImage]; - borderView.translatesAutoresizingMaskIntoConstraints = NO; - borderView.contentMode = UIViewContentModeScaleAspectFill; - [headerWrapper addSubview:borderView]; - - // Layout - NSDictionary *views = NSDictionaryOfVariableBindings(headerView, borderView); - [headerWrapper addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"|[headerView]|" - options:0 - metrics:nil - views:views]]; - [headerWrapper addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[headerView][borderView(1@1000)]|" - options:0 - metrics:nil - views:views]]; - [headerWrapper addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"|[borderView]|" - options:0 - metrics:nil - views:views]]; - - self.postHeaderView = headerView; - self.postHeaderWrapper = headerWrapper; - [self.view addSubview:self.postHeaderWrapper]; -} - -- (void)configureTableView -{ - self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain]; - self.tableView.translatesAutoresizingMaskIntoConstraints = NO; - self.tableView.cellLayoutMarginsFollowReadableWidth = YES; - self.tableView.preservesSuperviewLayoutMargins = YES; - self.tableView.backgroundColor = [UIColor murielBasicBackground]; - [self.view addSubview:self.tableView]; - - UINib *commentNib = [UINib nibWithNibName:@"ReaderCommentCell" bundle:nil]; - [self.tableView registerNib:commentNib forCellReuseIdentifier:CommentCellIdentifier]; - - self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone; - self.tableView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive; - - self.estimatedRowHeights = [[NSCache alloc] init]; - self.cachedAttributedStrings = [[NSCache alloc] init]; -} - -- (void)configureTableViewHandler -{ - self.tableViewHandler = [[WPTableViewHandler alloc] initWithTableView:self.tableView]; - self.tableViewHandler.updateRowAnimation = UITableViewRowAnimationNone; - self.tableViewHandler.insertRowAnimation = UITableViewRowAnimationNone; - self.tableViewHandler.moveRowAnimation = UITableViewRowAnimationNone; - self.tableViewHandler.deleteRowAnimation = UITableViewRowAnimationNone; - self.tableViewHandler.delegate = self; - [self.tableViewHandler setListensForContentChanges:NO]; -} - -- (void)configureNoResultsView -{ - self.noResultsViewController = [NoResultsViewController controller]; -} - -- (void)configureReplyTextView -{ - __typeof(self) __weak weakSelf = self; - - ReplyTextView *replyTextView = [[ReplyTextView alloc] initWithWidth:CGRectGetWidth(self.view.frame)]; - replyTextView.onReply = ^(NSString *content) { - [weakSelf sendReplyWithNewContent:content]; - }; - replyTextView.delegate = self; - self.replyTextView = replyTextView; - - [self refreshReplyTextViewPlaceholder]; - - [self.view addSubview:self.replyTextView]; - [self.view bringSubviewToFront:self.replyTextView]; -} - -- (void)configureSuggestionsTableView -{ - NSNumber *siteID = self.siteID; - NSParameterAssert(siteID); - - self.suggestionsTableView = [SuggestionsTableView new]; - self.suggestionsTableView.siteID = siteID; - self.suggestionsTableView.suggestionsDelegate = self; - [self.suggestionsTableView setTranslatesAutoresizingMaskIntoConstraints:NO]; - [self.view addSubview:self.suggestionsTableView]; -} - -- (void)configureKeyboardGestureRecognizer -{ - self.tapOffKeyboardGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapRecognized:)]; - self.tapOffKeyboardGesture.enabled = NO; - [self.view addGestureRecognizer:self.tapOffKeyboardGesture]; -} - -- (void)configureKeyboardManager -{ - self.keyboardManager = [[KeyboardDismissHelper alloc] initWithParentView:self.view - scrollView:self.tableView - dismissableControl:self.replyTextView - bottomLayoutConstraint:self.replyTextViewBottomConstraint]; - - __weak UITableView *weakTableView = self.tableView; - __weak ReaderCommentsViewController *weakSelf = self; - self.keyboardManager.onWillHide = ^{ - [weakTableView deselectSelectedRowWithAnimation:YES]; - [weakSelf refreshNoResultsView]; - }; - self.keyboardManager.onWillShow = ^{ - [weakSelf refreshNoResultsView]; - }; -} - - -#pragma mark - Autolayout Helpers - -- (void)configureViewConstraints -{ - NSDictionary *views = @{ - @"tableView" : self.tableView, - @"postHeader" : self.postHeaderWrapper, - @"mainView" : self.view, - @"suggestionsview" : self.suggestionsTableView, - @"replyTextView" : self.replyTextView - }; - - // PostHeader Constraints - [[self.postHeaderWrapper.leftAnchor constraintEqualToAnchor:self.tableView.leftAnchor] setActive:YES]; - [[self.postHeaderWrapper.rightAnchor constraintEqualToAnchor:self.tableView.rightAnchor] setActive:YES]; - - // TableView Contraints - [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[postHeader][tableView][replyTextView]" - options:0 - metrics:nil - views:views]]; - - [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"|[tableView]|" - options:0 - metrics:nil - views:views]]; - - // ReplyTextView Constraints - [[self.replyTextView.leftAnchor constraintEqualToAnchor:self.tableView.leftAnchor] setActive:YES]; - [[self.replyTextView.rightAnchor constraintEqualToAnchor:self.tableView.rightAnchor] setActive:YES]; - - self.replyTextViewBottomConstraint = [NSLayoutConstraint constraintWithItem:self.view - attribute:NSLayoutAttributeBottom - relatedBy:NSLayoutRelationEqual - toItem:self.replyTextView - attribute:NSLayoutAttributeBottom - multiplier:1.0 - constant:0.0]; - self.replyTextViewBottomConstraint.priority = UILayoutPriorityDefaultHigh; - - [self.view addConstraint:self.replyTextViewBottomConstraint]; - - // Suggestions Constraints - // Pin the suggestions view left and right edges to the reply view edges - [self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.suggestionsTableView - attribute:NSLayoutAttributeLeft - relatedBy:NSLayoutRelationEqual - toItem:self.replyTextView - attribute:NSLayoutAttributeLeft - multiplier:1.0 - constant:0.0]]; - - [self.view addConstraint:[NSLayoutConstraint constraintWithItem:self.suggestionsTableView - attribute:NSLayoutAttributeRight - relatedBy:NSLayoutRelationEqual - toItem:self.replyTextView - attribute:NSLayoutAttributeRight - multiplier:1.0 - constant:0.0]]; - - [self.view addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"V:|[suggestionsview][replyTextView]" - options:0 - metrics:nil - views:views]]; - - // TODO: - // This LayoutConstraint is just a helper, meant to hide / display the ReplyTextView, as needed. - // Whenever iOS 8 is set as the deployment target, let's always attach this one, and enable / disable it as needed! - self.replyTextViewHeightConstraint = [NSLayoutConstraint constraintWithItem:self.replyTextView - attribute:NSLayoutAttributeHeight - relatedBy:NSLayoutRelationEqual - toItem:nil - attribute:0 - multiplier:1 - constant:0]; -} - - -#pragma mark - Helpers - -- (NSString *)noResultsTitleText -{ - // Let's just display the same message, for consistency's sake - if (self.isLoadingPost || self.syncHelper.isSyncing) { - return NSLocalizedString(@"Fetching comments...", @"A brief prompt shown when the comment list is empty, letting the user know the app is currently fetching new comments."); - } - // If we couldn't fetch the comments lets let the user know - if (self.failedToFetchComments) { - return NSLocalizedString(@"There has been an unexpected error while loading the comments.", @"Message shown when comments for a post can not be loaded."); - } - return NSLocalizedString(@"Be the first to leave a comment.", @"Message shown encouraging the user to leave a comment on a post in the reader."); -} - -- (UIView *)noResultsAccessoryView -{ - UIView *loadingAccessoryView = nil; - if (self.isLoadingPost || self.syncHelper.isSyncing) { - loadingAccessoryView = [NoResultsViewController loadingAccessoryView]; - } - return loadingAccessoryView; -} - -- (void)checkIfLoggedIn -{ - self.isLoggedIn = [AccountHelper isDotcomAvailable]; -} - -#pragma mark - Accessor methods - -- (void)setPost:(ReaderPost *)post -{ - if (post == _post) { - return; - } - - _post = post; - [self trackCommentsOpened]; - if (_post.isWPCom || _post.isJetpack) { - self.syncHelper = [[WPContentSyncHelper alloc] init]; - self.syncHelper.delegate = self; - } -} - -- (NSNumber *)siteID -{ - // If the post isn't loaded yet, maybe we're asynchronously retrieving it? - return self.post.siteID ?: self.postSiteID; -} - -- (UIActivityIndicatorView *)activityFooter -{ - if (_activityFooter) { - return _activityFooter; - } - - _activityFooter = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhite]; - _activityFooter.activityIndicatorViewStyle = UIActivityIndicatorViewStyleGray; - _activityFooter.hidesWhenStopped = YES; - _activityFooter.autoresizingMask = UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin; - [_activityFooter stopAnimating]; - - return _activityFooter; -} - -- (BOOL)isLoadingPost -{ - return self.post == nil; -} - -- (BOOL)canComment -{ - return self.post.commentsOpen && self.isLoggedIn; -} - -- (BOOL)shouldDisplayReplyTextView -{ - return self.canComment; -} - -- (BOOL)shouldDisplaySuggestionsTableView -{ - return self.shouldDisplayReplyTextView && [[SuggestionService sharedInstance] shouldShowSuggestionsForSiteID:self.post.siteID]; -} - - -#pragma mark - View Refresh Helpers - -- (void)refreshAndSync -{ - [self refreshPostHeaderView]; - [self refreshReplyTextView]; - [self refreshSuggestionsTableView]; - [self refreshInfiniteScroll]; - [self refreshTableViewAndNoResultsView]; - - [self.syncHelper syncContent]; -} - -- (void)refreshPostHeaderView -{ - NSParameterAssert(self.postHeaderView); - NSParameterAssert(self.postHeaderWrapper); - - self.postHeaderWrapper.hidden = self.isLoadingPost; - if (self.isLoadingPost) { - return; - } - - [self.postHeaderView setTitle:self.post.titleForDisplay]; - - CGFloat scale = [[UIScreen mainScreen] scale]; - CGSize imageSize = CGSizeMake(PostHeaderViewAvatarSize * scale, PostHeaderViewAvatarSize * scale); - UIImage *image = [self.post cachedAvatarWithSize:imageSize]; - if (image) { - [self.postHeaderView setAvatarImage:image]; - } else { - [self.post fetchAvatarWithSize:imageSize success:^(UIImage *image) { - [self.postHeaderView setAvatarImage:image]; - }]; - } -} - -- (void)refreshReplyTextView -{ - BOOL showsReplyTextView = self.shouldDisplayReplyTextView; - self.replyTextView.hidden = !showsReplyTextView; - - if (showsReplyTextView) { - [self.view removeConstraint:self.replyTextViewHeightConstraint]; - } else { - [self.view addConstraint:self.replyTextViewHeightConstraint]; - } -} - -- (void)refreshSuggestionsTableView -{ - self.suggestionsTableView.enabled = self.shouldDisplaySuggestionsTableView; -} - -- (void)refreshReplyTextViewPlaceholder -{ - if (self.tableView.indexPathForSelectedRow) { - self.replyTextView.placeholder = NSLocalizedString(@"Reply to comment…", @"Placeholder text for replying to a comment"); - } else { - self.replyTextView.placeholder = NSLocalizedString(@"Reply to post…", @"Placeholder text for replying to a post"); - } -} - -- (void)refreshInfiniteScroll -{ - if (self.syncHelper.hasMoreContent) { - CGFloat width = CGRectGetWidth(self.tableView.bounds); - UIView *footerView = [[UIView alloc] initWithFrame:CGRectMake(0.0f, 0.0f, width, 50.0f)]; - footerView.autoresizingMask = UIViewAutoresizingFlexibleWidth; - CGRect rect = self.activityFooter.frame; - rect.origin.x = (width - rect.size.width) / 2.0; - self.activityFooter.frame = rect; - - [footerView addSubview:self.activityFooter]; - self.tableView.tableFooterView = footerView; - - } else { - self.tableView.tableFooterView = nil; - self.activityFooter = nil; - } -} - -- (void)refreshNoResultsView -{ - // During rotation, the keyboard hides and shows. - // To prevent view flashing, do nothing until rotation is finished. - if (self.deviceIsRotating) { - return; - } - - [self.noResultsViewController removeFromView]; - - BOOL isTableViewEmpty = (self.tableViewHandler.resultsController.fetchedObjects.count == 0); - if (!isTableViewEmpty) { - return; - } - - // Because the replyTextView grows, limit what is displayed with the keyboard visible: - // iPhone landscape: show nothing. - // iPhone portrait: hide the image. - // iPad landscape: hide the image. - - BOOL isLandscape = UIDevice.currentDevice.orientation != UIDeviceOrientationPortrait; - BOOL hideImageView = false; - if (self.keyboardManager.isKeyboardVisible) { - - if (WPDeviceIdentification.isiPhone && isLandscape) { - return; - } - - hideImageView = (WPDeviceIdentification.isiPhone && !isLandscape) || (WPDeviceIdentification.isiPad && isLandscape); - } - [self.noResultsViewController configureWithTitle:self.noResultsTitleText - noConnectionTitle:nil - buttonTitle:nil - subtitle:nil - noConnectionSubtitle:nil - attributedSubtitle:nil - attributedSubtitleConfiguration:nil - image:nil - subtitleImage:nil - accessoryView:[self noResultsAccessoryView]]; - - [self.noResultsViewController hideImageView:hideImageView]; - [self.noResultsViewController.view setBackgroundColor:[UIColor clearColor]]; - [self addChildViewController:self.noResultsViewController]; - [self.view addSubviewWithFadeAnimation:self.noResultsViewController.view]; - self.noResultsViewController.view.frame = self.tableView.frame; - [self.noResultsViewController didMoveToParentViewController:self]; -} - -- (void)updateTableViewForAttachments -{ - [self.tableView performBatchUpdates:nil completion:nil]; -} - - -- (void)refreshTableViewAndNoResultsView -{ - [self.tableViewHandler refreshTableView]; - [self refreshNoResultsView]; - [self.managedObjectContext performBlock:^{ - [self updateCachedContent]; - }]; - -} - - -- (void)updateCachedContent -{ - NSArray *comments = self.tableViewHandler.resultsController.fetchedObjects; - for(Comment *comment in comments) { - [self cacheContentForComment:comment]; - } -} - - -- (NSAttributedString *)cacheContentForComment:(Comment *)comment -{ - NSAttributedString *attrStr = [self.cachedAttributedStrings objectForKey:comment.commentID]; - if (!attrStr) { - attrStr = [WPRichContentView formattedAttributedStringForString: comment.content]; - [self.cachedAttributedStrings setObject:attrStr forKey:comment.commentID]; - } - return attrStr; -} - - -#pragma mark - Actions - -- (void)tapRecognized:(id)sender -{ - self.tapOffKeyboardGesture.enabled = NO; - self.indexPathForCommentRepliedTo = nil; - [self.tableView deselectSelectedRowWithAnimation:YES]; -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunused-result" - [self.replyTextView resignFirstResponder]; -#pragma clang diagnostic pop - [self refreshReplyTextViewPlaceholder]; -} - -- (void)sendReplyWithNewContent:(NSString *)content -{ - __typeof(self) __weak weakSelf = self; - - UINotificationFeedbackGenerator *generator = [UINotificationFeedbackGenerator new]; - [generator prepare]; - - void (^successBlock)(void) = ^void() { - [generator notificationOccurred:UINotificationFeedbackTypeSuccess]; - NSString *successMessage = NSLocalizedString(@"Reply Sent!", @"The app successfully sent a comment"); - [weakSelf displayNoticeWithTitle:successMessage message:nil]; - - [weakSelf trackReplyToComment]; - [weakSelf.tableView deselectSelectedRowWithAnimation:YES]; - [weakSelf refreshReplyTextViewPlaceholder]; - - [weakSelf refreshTableViewAndNoResultsView]; - }; - - void (^failureBlock)(NSError *error) = ^void(NSError *error) { - DDLogError(@"Error sending reply: %@", error); - [generator notificationOccurred:UINotificationFeedbackTypeError]; - NSString *message = NSLocalizedString(@"There has been an unexpected error while sending your reply", "Reply Failure Message"); - [weakSelf displayNoticeWithTitle:message message:nil]; - - [weakSelf refreshTableViewAndNoResultsView]; - }; - - CommentService *service = [[CommentService alloc] initWithManagedObjectContext:self.managedObjectContext]; - - if (self.indexPathForCommentRepliedTo) { - Comment *comment = [self.tableViewHandler.resultsController objectAtIndexPath:self.indexPathForCommentRepliedTo]; - [service replyToHierarchicalCommentWithID:comment.commentID - post:self.post - content:content - success:successBlock - failure:failureBlock]; - } else { - [service replyToPost:self.post - content:content - success:successBlock - failure:failureBlock]; - } - self.indexPathForCommentRepliedTo = nil; -} - - -#pragma mark - Sync methods - -- (void)syncHelper:(WPContentSyncHelper *)syncHelper syncContentWithUserInteraction:(BOOL)userInteraction success:(void (^)(BOOL))success failure:(void (^)(NSError *))failure -{ - self.failedToFetchComments = NO; - CommentService *service = [[CommentService alloc] initWithManagedObjectContext:[[ContextManager sharedInstance] newDerivedContext]]; - [service syncHierarchicalCommentsForPost:self.post page:1 success:^(NSInteger count, BOOL hasMore) { - if (success) { - success(hasMore); - } - } failure:failure]; - [self refreshNoResultsView]; -} - -- (void)syncHelper:(WPContentSyncHelper *)syncHelper syncMoreWithSuccess:(void (^)(BOOL))success failure:(void (^)(NSError *))failure -{ - self.failedToFetchComments = NO; - [self.activityFooter startAnimating]; - - CommentService *service = [[CommentService alloc] initWithManagedObjectContext:[[ContextManager sharedInstance] newDerivedContext]]; - NSInteger page = [service numberOfHierarchicalPagesSyncedforPost:self.post] + 1; - [service syncHierarchicalCommentsForPost:self.post page:page success:^(NSInteger count, BOOL hasMore) { - if (success) { - success(hasMore); - } - } failure:failure]; -} - -- (void)syncContentEnded:(WPContentSyncHelper *)syncHelper -{ - [self.activityFooter stopAnimating]; - if ([self.tableViewHandler isScrolling]) { - self.needsRefreshTableViewAfterScrolling = YES; - return; - } - [self refreshTableViewAndNoResultsView]; -} - -- (void)syncContentFailed:(WPContentSyncHelper *)syncHelper -{ - self.failedToFetchComments = YES; - [self.activityFooter stopAnimating]; - [self refreshTableViewAndNoResultsView]; -} - -#pragma mark - Async Loading Helpers - -- (void)setupWithPostID:(NSNumber *)postID siteID:(NSNumber *)siteID -{ - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - ReaderPostService *service = [[ReaderPostService alloc] initWithManagedObjectContext:context]; - __weak __typeof(self) weakSelf = self; - - self.postSiteID = siteID; - - [service fetchPost:postID.integerValue forSite:siteID.integerValue isFeed:NO success:^(ReaderPost *post) { - - [weakSelf setPost:post]; - [weakSelf refreshAndSync]; - - } failure:^(NSError *error) { - DDLogError(@"[RestAPI] %@", error); - }]; -} - - -#pragma mark - UITableView Delegate Methods - -- (NSManagedObjectContext *)managedObjectContext -{ - return [[ContextManager sharedInstance] mainContext]; -} - -- (NSFetchRequest *)fetchRequest -{ - if (!self.post) { - return nil; - } - - NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] initWithEntityName:NSStringFromClass([Comment class])]; - fetchRequest.predicate = [NSPredicate predicateWithFormat:@"post = %@", self.post]; - - NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"hierarchy" ascending:YES]; - [fetchRequest setSortDescriptors:@[sortDescriptor]]; - - return fetchRequest; -} - -- (void)configureCell:(UITableViewCell *)aCell atIndexPath:(NSIndexPath *)indexPath -{ - ReaderCommentCell *cell = (ReaderCommentCell *)aCell; - - Comment *comment = [self.tableViewHandler.resultsController objectAtIndexPath:indexPath]; - - cell.indentationWidth = CommentIndentationWidth; - cell.indentationLevel = MIN([comment.depth integerValue], MaxCommentDepth); - cell.delegate = self; - cell.accessoryType = UITableViewCellAccessoryNone; - cell.enableLoggedInFeatures = [self isLoggedIn]; - cell.onTimeStampLongPress = ^(void) { - NSURL *url = [NSURL URLWithString:comment.link]; - [UIAlertController presentAlertAndCopyCommentURLToClipboardWithUrl:url]; - }; - - // When backgrounding, the app takes a snapshot, which triggers a layout pass, - // which refreshes the cells, and for some reason triggers an assertion failure - // in NSMutableAttributedString(data:,options:,documentAttributes:) when - // the NSDocumentTypeDocumentAttribute option is NSHTMLTextDocumentType. - // *** Assertion failure in void _prepareForCAFlush(UIApplication *__strong)(), - // /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKit_Sim/UIKit-3600.6.21/UIApplication.m:2377 - // *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', - // reason: 'unexpected start state' - // This seems like a framework bug, so to avoid it skip configuring cells - // while the app is backgrounded. - if ([[UIApplication sharedApplication] applicationState] == UIApplicationStateBackground) { - return; - } - - NSAttributedString *attrStr = [self cacheContentForComment:comment]; - [cell configureCellWithComment:comment attributedString:attrStr]; -} - -- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath -{ - NSNumber *cachedHeight = [self.estimatedRowHeights objectForKey:indexPath]; - if (cachedHeight.doubleValue) { - return cachedHeight.doubleValue; - } - return EstimatedCommentRowHeight; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath -{ - return UITableViewAutomaticDimension; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath -{ - ReaderCommentCell *cell = (ReaderCommentCell *)[self.tableView dequeueReusableCellWithIdentifier:CommentCellIdentifier]; - [self configureCell:cell atIndexPath:indexPath]; - return cell; -} - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath -{ - [self.tableView deselectRowAtIndexPath:indexPath animated:NO]; -} - -- (BOOL)tableView:(UITableView *)tableView shouldHighlightRowAtIndexPath:(NSIndexPath *)indexPath -{ - return NO; -} - -- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath -{ - [self.estimatedRowHeights setObject:@(cell.frame.size.height) forKey:indexPath]; - - // Are we approaching the end of the table? - if ((indexPath.section + 1 == [self.tableViewHandler numberOfSectionsInTableView:tableView]) && - (indexPath.row + 4 >= [self.tableViewHandler tableView:tableView numberOfRowsInSection:indexPath.section])) { - - // Only 3 rows till the end of table - if (self.syncHelper.hasMoreContent) { - [self.syncHelper syncMoreContent]; - } - } -} - -- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section -{ - // Override WPTableViewHandler's default of UITableViewAutomaticDimension, - // which results in 30pt tall headers on iOS 11 - return 0; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section -{ - return 0; -} - -#pragma mark - UIScrollView Delegate Methods - -- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView -{ - [self.keyboardManager scrollViewWillBeginDragging:scrollView]; -} - -- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView -{ - [self refreshReplyTextViewPlaceholder]; - - [self.tableView deselectSelectedRowWithAnimation:YES]; - - if (self.needsRefreshTableViewAfterScrolling) { - self.needsRefreshTableViewAfterScrolling = NO; - [self refreshTableViewAndNoResultsView]; - - // If we reloaded the tableView we also updated cell heights - // so there is no need to update for attachments. - self.needsUpdateAttachmentsAfterScrolling = NO; - } - - if (self.needsUpdateAttachmentsAfterScrolling) { - self.needsUpdateAttachmentsAfterScrolling = NO; - - for (ReaderCommentCell *cell in [self.tableView visibleCells]) { - [cell ensureTextViewLayout]; - } - [self updateTableViewForAttachments]; - } -} - -- (void)scrollViewDidScroll:(UIScrollView *)scrollView -{ - [self.keyboardManager scrollViewDidScroll:scrollView]; -} - -- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset -{ - [self.keyboardManager scrollViewWillEndDragging:scrollView withVelocity:velocity]; -} - - -#pragma mark - SuggestionsTableViewDelegate - -- (void)suggestionsTableView:(SuggestionsTableView *)suggestionsTableView didSelectSuggestion:(NSString *)suggestion forSearchText:(NSString *)text -{ - [self.replyTextView replaceTextAtCaret:text withText:suggestion]; - [suggestionsTableView showSuggestionsForWord:@""]; - self.tapOffKeyboardGesture.enabled = YES; -} - - -#pragma mark - ReaderCommentCell Delegate Methods - -- (void)cell:(ReaderCommentCell *)cell didTapAuthor:(Comment *)comment -{ - NSURL *url = [comment authorURL]; - WebViewControllerConfiguration *configuration = [[WebViewControllerConfiguration alloc] initWithUrl:url]; - [configuration authenticateWithDefaultAccount]; - [configuration setAddsWPComReferrer:YES]; - UIViewController *webViewController = [WebViewControllerFactory controllerWithConfiguration:configuration]; - UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:webViewController]; - [self presentViewController:navController animated:YES completion:nil]; -} - -- (void)cell:(ReaderCommentCell *)cell didTapReply:(Comment *)comment -{ - // if a row is already selected don't allow selection of another - if (self.replyTextView.isFirstResponder) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunused-result" - [self.replyTextView resignFirstResponder]; -#pragma clang diagnostic pop - return; - } - - if (!self.canComment) { - return; - } - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunused-result" - [self.replyTextView becomeFirstResponder]; -#pragma clang diagnostic pop - - self.indexPathForCommentRepliedTo = [self.tableViewHandler.resultsController indexPathForObject:comment]; - [self.tableView selectRowAtIndexPath:self.indexPathForCommentRepliedTo animated:YES scrollPosition:UITableViewScrollPositionTop]; - [self refreshReplyTextViewPlaceholder]; -} - -- (void)cell:(ReaderCommentCell *)cell didTapLike:(Comment *)comment -{ - - if (![WordPressAppDelegate shared].connectionAvailable) { - NSString *title = NSLocalizedString(@"No Connection", @"Title of error prompt when no internet connection is available."); - NSString *message = NSLocalizedString(@"The Internet connection appears to be offline.", @"Message of error prompt shown when a user tries to perform an action without an internet connection."); - [WPError showAlertWithTitle:title message:message]; - return; - } - - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - CommentService *commentService = [[CommentService alloc] initWithManagedObjectContext:context]; - - if (!comment.isLiked) { - [[UINotificationFeedbackGenerator new] notificationOccurred:UINotificationFeedbackTypeSuccess]; - } - - __typeof(self) __weak weakSelf = self; - [commentService toggleLikeStatusForComment:comment siteID:self.post.siteID success:^{ - [weakSelf trackCommentLikedOrUnliked:comment]; - - [weakSelf.tableView reloadData]; - } failure:^(NSError *error) { - - [weakSelf.tableView reloadData]; - }]; - - [self.tableView reloadData]; -} - -- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange interaction:(UITextItemInteraction)interaction -{ - return NO; -} - -- (void)richContentView:(WPRichContentView *)richContentView didReceiveImageAction:(WPRichTextImage *)image -{ - UIViewController *controller = nil; - BOOL isSupportedNatively = [WPImageViewController isUrlSupported:image.linkURL]; - - if (image.imageView.animatedGifData) { - controller = [[WPImageViewController alloc] initWithGifData:image.imageView.animatedGifData]; - } else if (isSupportedNatively) { - controller = [[WPImageViewController alloc] initWithImage:image.imageView.image andURL:image.linkURL]; - } else if (image.linkURL) { - [self presentWebViewControllerWithURL:image.linkURL]; - return; - } else if (image.imageView.image) { - controller = [[WPImageViewController alloc] initWithImage:image.imageView.image]; - } - - if (controller) { - controller.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; - controller.modalPresentationStyle = UIModalPresentationFullScreen; - [self presentViewController:controller animated:YES completion:nil]; - } -} - -- (void)interactWithURL:(NSURL *) URL { - [self presentWebViewControllerWithURL:URL]; - } - -- (BOOL)richContentViewShouldUpdateLayoutForAttachments:(WPRichContentView *)richContentView -{ - if (self.tableViewHandler.isScrolling) { - self.needsUpdateAttachmentsAfterScrolling = YES; - return NO; - } - - return YES; -} - -- (void)richContentViewDidUpdateLayoutForAttachments:(WPRichContentView *)richContentView -{ - [self updateTableViewForAttachments]; -} - -- (void)presentWebViewControllerWithURL:(NSURL *)URL -{ - NSURL *linkURL = URL; - NSURLComponents *components = [NSURLComponents componentsWithString:[URL absoluteString]]; - if (!components.host) { - linkURL = [components URLRelativeToURL:[NSURL URLWithString:self.post.blogURL]]; - } - - WebViewControllerConfiguration *configuration = [[WebViewControllerConfiguration alloc] initWithUrl:linkURL]; - [configuration authenticateWithDefaultAccount]; - [configuration setAddsWPComReferrer:YES]; - UIViewController *webViewController = [WebViewControllerFactory controllerWithConfiguration:configuration]; - UINavigationController *navController = [[UINavigationController alloc] initWithRootViewController:webViewController]; - [self presentViewController:navController animated:YES completion:nil]; -} - - -#pragma mark - PostHeaderView helpers - -- (void)handleHeaderTapped -{ - if (!self.allowsPushingPostDetails) { - return; - } - - // Note: Let's manually hide the comments button, in order to prevent recursion in the flow - ReaderDetailViewController *controller = [ReaderDetailViewController controllerWithPost:self.post]; - controller.shouldHideComments = YES; - [self.navigationController pushFullscreenViewController:controller animated:YES]; -} - - -#pragma mark - UITextViewDelegate methods - -- (BOOL)textViewShouldBeginEditing:(UITextView *)textView -{ - self.tapOffKeyboardGesture.enabled = YES; - return YES; -} - -- (void)textView:(UITextView *)textView didTypeWord:(NSString *)word -{ - // Disable the gestures recognizer when showing suggestions - BOOL showsSuggestions = [self.suggestionsTableView showSuggestionsForWord:word]; - self.tapOffKeyboardGesture.enabled = !showsSuggestions; -} - -@end diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderCrossPostCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderCrossPostCell.swift index 9d268b7ac65f..849f986a4427 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderCrossPostCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderCrossPostCell.swift @@ -1,27 +1,31 @@ +import AlamofireImage import Foundation +import AutomatticTracks import WordPressShared.WPStyleGuide open class ReaderCrossPostCell: UITableViewCell { - @IBOutlet fileprivate weak var blavatarImageView: UIImageView! - @IBOutlet fileprivate weak var avatarImageView: UIImageView! - @IBOutlet fileprivate weak var label: UILabel! - @objc open weak var contentProvider: ReaderPostContentProvider? + // MARK: - Properties - @objc let blavatarPlaceholder = "post-blavatar-placeholder" - @objc let xPostTitlePrefix = "X-post: " + @IBOutlet private weak var blavatarImageView: UIImageView! + @IBOutlet private weak var avatarImageView: UIImageView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var label: UILabel! + @IBOutlet private weak var borderView: UIView! + + private weak var contentProvider: ReaderPostContentProvider? // MARK: - Accessors - fileprivate lazy var readerCrossPostTitleAttributes: [NSAttributedString.Key: Any] = { + private lazy var readerCrossPostTitleAttributes: [NSAttributedString.Key: Any] = { return WPStyleGuide.readerCrossPostTitleAttributes() }() - fileprivate lazy var readerCrossPostSubtitleAttributes: [NSAttributedString.Key: Any] = { + private lazy var readerCrossPostSubtitleAttributes: [NSAttributedString.Key: Any] = { return WPStyleGuide.readerCrossPostSubtitleAttributes() }() - fileprivate lazy var readerCrossPostBoldSubtitleAttributes: [NSAttributedString.Key: Any] = { + private lazy var readerCrossPostBoldSubtitleAttributes: [NSAttributedString.Key: Any] = { return WPStyleGuide.readerCrossPostBoldSubtitleAttributes() }() @@ -42,7 +46,6 @@ open class ReaderCrossPostCell: UITableViewCell { applyHighlightedEffect(highlighted, animated: animated) } - // MARK: - Lifecycle Methods open override func awakeFromNib() { @@ -50,17 +53,46 @@ open class ReaderCrossPostCell: UITableViewCell { applyStyles() } + // MARK: - Configuration + + @objc open func configureCell(_ contentProvider: ReaderPostContentProvider) { + self.contentProvider = contentProvider + + configureTitleLabel() + configureLabel() + configureBlavatarImage() + configureAvatarImageView() + } + +} + +// MARK: - Private Methods + +private extension ReaderCrossPostCell { + + struct Constants { + static let blavatarPlaceholderImage: UIImage? = UIImage(named: "post-blavatar-placeholder") + static let avatarPlaceholderImage: UIImage? = UIImage(named: "gravatar") + static let imageBorderWidth: CGFloat = 1 + static let xPostTitlePrefix = "X-post: " + static let commentTemplate = "%@ left a comment on %@, cross-posted to %@" + static let siteTemplate = "%@ cross-posted from %@ to %@" + } // MARK: - Appearance - fileprivate func applyStyles() { + func applyStyles() { + backgroundColor = .clear contentView.backgroundColor = .listBackground - label?.backgroundColor = .listBackground + borderView?.backgroundColor = .listForeground + label?.backgroundColor = .listForeground + titleLabel?.backgroundColor = .listForeground } - fileprivate func applyHighlightedEffect(_ highlighted: Bool, animated: Bool) { + func applyHighlightedEffect(_ highlighted: Bool, animated: Bool) { func updateBorder() { label.alpha = highlighted ? 0.50 : WPAlphaFull + titleLabel.alpha = highlighted ? 0.50 : WPAlphaFull } guard animated else { updateBorder() @@ -72,81 +104,102 @@ open class ReaderCrossPostCell: UITableViewCell { animations: updateBorder) } - // MARK: - Configuration - @objc open func configureCell(_ contentProvider: ReaderPostContentProvider) { - self.contentProvider = contentProvider - - configureLabel() - configureBlavatarImage() - configureAvatarImageView() - } + func configureBlavatarImage() { + configureAvatarBorder(blavatarImageView) + let placeholder = Constants.blavatarPlaceholderImage + let size = blavatarImageView.frame.size.width * UIScreen.main.scale - fileprivate func configureBlavatarImage() { // Always reset - blavatarImageView.image = nil + blavatarImageView.image = placeholder - let placeholder = UIImage(named: blavatarPlaceholder) + guard let contentProvider = contentProvider, + let url = contentProvider.siteIconForDisplay(ofSize: Int(size)) else { + return + } - let size = blavatarImageView.frame.size.width * UIScreen.main.scale - let url = contentProvider?.siteIconForDisplay(ofSize: Int(size)) - if url != nil { - blavatarImageView.downloadImage(from: url, placeholderImage: placeholder) - } else { - blavatarImageView.image = placeholder + let host = MediaHost(with: contentProvider) { error in + WordPressAppDelegate.crashLogging?.logError(error) + } + + let mediaAuthenticator = MediaRequestAuthenticator() + mediaAuthenticator.authenticatedRequest(for: url, from: host, onComplete: { [weak self] request in + self?.blavatarImageView.af_setImage(withURLRequest: request, placeholderImage: placeholder) + }) { [weak self] error in + WordPressAppDelegate.crashLogging?.logError(error) + self?.blavatarImageView.image = placeholder } } - fileprivate func configureAvatarImageView() { - // Always reset - avatarImageView.image = nil + func configureAvatarImageView() { + configureAvatarBorder(avatarImageView) + let placeholder = Constants.avatarPlaceholderImage - let placeholder = UIImage(named: blavatarPlaceholder) + // Always reset + avatarImageView.image = placeholder - let url = contentProvider?.avatarURLForDisplay() - if url != nil { + if let url = contentProvider?.avatarURLForDisplay() { avatarImageView.downloadImage(from: url, placeholderImage: placeholder) - } else { - avatarImageView.image = placeholder } } - fileprivate func configureLabel() { + func configureAvatarBorder(_ imageView: UIImageView) { + imageView.layer.borderColor = WPStyleGuide.readerCardBlogIconBorderColor().cgColor + imageView.layer.borderWidth = Constants.imageBorderWidth + imageView.layer.masksToBounds = true + } - // Compose the title. - var title = contentProvider!.titleForDisplay() ?? "" - if let prefixRange = title.range(of: xPostTitlePrefix) { - title.removeSubrange(prefixRange) + func configureTitleLabel() { + if var title = contentProvider?.titleForDisplay(), !title.isEmpty() { + if let prefixRange = title.range(of: Constants.xPostTitlePrefix) { + title.removeSubrange(prefixRange) + } + + titleLabel.attributedText = NSAttributedString(string: title, attributes: readerCrossPostTitleAttributes) + titleLabel.isHidden = false + } else { + titleLabel.attributedText = nil + titleLabel.isHidden = true } + } - let attrText = NSMutableAttributedString(string: "\(title)\n", attributes: readerCrossPostTitleAttributes) + func configureLabel() { + guard let contentProvider = contentProvider else { + return + } // Compose the subtitle // These templates are deliberately not localized (for now) given the intended audience. - let commentTemplate = "%@ left a comment on %@, cross-posted to %@" - let siteTemplate = "%@ cross-posted from %@ to %@" - let template = contentProvider!.isCommentCrossPost() ? commentTemplate : siteTemplate + let template = contentProvider.isCommentCrossPost() ? Constants.commentTemplate : Constants.siteTemplate - let authorName: NSString = contentProvider!.authorForDisplay() as NSString - let siteName = subDomainNameFromPath(contentProvider!.siteURLForDisplay()) - let originName = subDomainNameFromPath(contentProvider!.crossPostOriginSiteURLForDisplay()) + let authorName: NSString = contentProvider.authorForDisplay() as NSString + let siteName = subDomainNameFromPath(contentProvider.siteURLForDisplay()) + let originName = subDomainNameFromPath(contentProvider.crossPostOriginSiteURLForDisplay()) let subtitle = NSString(format: template as NSString, authorName, originName, siteName) as String let attrSubtitle = NSMutableAttributedString(string: subtitle, attributes: readerCrossPostSubtitleAttributes) + attrSubtitle.setAttributes(readerCrossPostBoldSubtitleAttributes, range: NSRange(location: 0, length: authorName.length)) - // Add the subtitle to the attributed text - attrText.append(attrSubtitle) + if let siteRange = subtitle.nsRange(of: siteName) { + attrSubtitle.setAttributes(readerCrossPostBoldSubtitleAttributes, range: siteRange) + } + + if let originRange = subtitle.nsRange(of: originName) { + attrSubtitle.setAttributes(readerCrossPostBoldSubtitleAttributes, range: originRange) + } - label.attributedText = attrText + label.attributedText = attrSubtitle } - fileprivate func subDomainNameFromPath(_ path: String) -> String { - if let url = URL(string: path), let host = url.host { - let arr = host.components(separatedBy: ".") - return "+\(arr.first!)" + func subDomainNameFromPath(_ path: String) -> String { + guard let url = URL(string: path), + let host = url.host else { + return "" } - return "" + + return host.components(separatedBy: ".").first ?? "" } + } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderCrossPostCell.xib b/WordPress/Classes/ViewRelated/Reader/ReaderCrossPostCell.xib index 3304a3265619..ba7054a2219a 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderCrossPostCell.xib +++ b/WordPress/Classes/ViewRelated/Reader/ReaderCrossPostCell.xib @@ -1,63 +1,98 @@ - + + - - + + - - + + - - + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - + + + + + + + + - - - - + + + + + - + - - - diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderDetailViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderDetailViewController.swift deleted file mode 100644 index 986b6125a2ed..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/ReaderDetailViewController.swift +++ /dev/null @@ -1,1691 +0,0 @@ -import Foundation -import CocoaLumberjack -import WordPressShared -import WordPressUI -import QuartzCore -import Gridicons -import MobileCoreServices - -class ReaderPlaceholderAttachment: NSTextAttachment { - init() { - // Initialize with default image data to prevent placeholder graphics appearing on iOS 13. - super.init(data: UIImage(color: .basicBackground).pngData(), ofType: kUTTypePNG as String) - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - } - - override var accessibilityLabel: String? { - get { - // Setting isAccessibilityElement to false does not seem to work for this - // `NSTextAttachment`. VoiceOver will still dictate “Attachment. PNG. File” which is - // really weird. Returning an empty label here so nothing will just be dictated at all. - return "" - } - set { - super.accessibilityLabel = newValue - } - } -} - -open class ReaderDetailViewController: UIViewController, UIViewControllerRestoration { - @objc static let restorablePostObjectURLhKey: String = "RestorablePostObjectURLKey" - - // Structs for Constants - - fileprivate struct DetailConstants { - static let LikeCountKeyPath = "likeCount" - static let CommentCountKeyPath = "commentCount" - static let MarginOffset = CGFloat(8.0) - } - - - fileprivate struct DetailAnalyticsConstants { - static let TypeKey = "post_detail_type" - static let TypeNormal = "normal" - static let TypePreviewSite = "preview_site" - static let OfflineKey = "offline_view" - static let PixelStatReferrer = "https://wordpress.com/" - } - - - // MARK: - Properties & Accessors - - // Callbacks - /// Called if the view controller's post fails to load - var postLoadFailureBlock: (() -> Void)? = nil - - // Footer views - @IBOutlet fileprivate weak var footerView: UIView! - @IBOutlet fileprivate weak var tagButton: UIButton! - @IBOutlet fileprivate weak var reblogButton: UIButton! - @IBOutlet fileprivate weak var commentButton: UIButton! - @IBOutlet fileprivate weak var likeButton: UIButton! - @IBOutlet fileprivate weak var footerViewHeightConstraint: NSLayoutConstraint! - @IBOutlet fileprivate weak var saveForLaterButton: UIButton! - // Wrapper views - @IBOutlet fileprivate weak var textHeaderStackView: UIStackView! - @IBOutlet fileprivate weak var textFooterStackView: UIStackView! - fileprivate var textFooterTopConstraint: NSLayoutConstraint! - - // Header realated Views - @IBOutlet fileprivate weak var headerView: UIView! - @IBOutlet fileprivate weak var headerViewBackground: UIView! - @IBOutlet fileprivate weak var blavatarImageView: UIImageView! - @IBOutlet fileprivate weak var blogNameButton: UIButton! - @IBOutlet fileprivate weak var blogURLLabel: UILabel! - @IBOutlet fileprivate weak var menuButton: UIButton! - - // Content views - @IBOutlet fileprivate weak var featuredImageView: CachedAnimatedImageView! - @IBOutlet fileprivate weak var titleLabel: UILabel! - @IBOutlet fileprivate weak var bylineView: UIView! - @IBOutlet fileprivate weak var bylineScrollView: UIScrollView! - @IBOutlet fileprivate var bylineGradientViews: [GradientView]! - @IBOutlet fileprivate weak var avatarImageView: CircularImageView! - @IBOutlet fileprivate weak var bylineLabel: UILabel! - @IBOutlet fileprivate weak var attributionView: ReaderCardDiscoverAttributionView! - private let textView: WPRichContentView = { - let textView = WPRichContentView(frame: .zero, textContainer: nil) - textView.translatesAutoresizingMaskIntoConstraints = false - textView.alpha = 0 - textView.isEditable = false - - return textView - }() - - // Spacers - @IBOutlet fileprivate weak var featuredImageBottomPaddingView: UIView! - @IBOutlet fileprivate weak var titleBottomPaddingView: UIView! - @IBOutlet fileprivate weak var bylineBottomPaddingView: UIView! - @IBOutlet fileprivate weak var footerDivider: UIView! - - @objc open var shouldHideComments = false - fileprivate var didBumpStats = false - fileprivate var didBumpPageViews = false - fileprivate var footerViewHeightConstraintConstant = CGFloat(0.0) - - fileprivate let sharingController = PostSharingController() - - private let noResultsViewController = NoResultsViewController.controller() - - private let readerLinkRouter = UniversalLinkRouter(routes: UniversalLinkRouter.readerRoutes) - - private let topMarginAttachment = ReaderPlaceholderAttachment() - - private let bottomMarginAttachment = ReaderPlaceholderAttachment() - - private var lightTextViewAttributedString: NSAttributedString? - private var darkTextViewAttributedString: NSAttributedString? - - @objc var currentPreferredStatusBarStyle = UIStatusBarStyle.lightContent { - didSet { - setNeedsStatusBarAppearanceUpdate() - } - } - - override open var preferredStatusBarStyle: UIStatusBarStyle { - return currentPreferredStatusBarStyle - } - - @objc open var post: ReaderPost? { - didSet { - oldValue?.removeObserver(self, forKeyPath: DetailConstants.LikeCountKeyPath) - oldValue?.removeObserver(self, forKeyPath: DetailConstants.CommentCountKeyPath) - oldValue?.inUse = false - - if let newPost = post, let context = newPost.managedObjectContext { - newPost.inUse = true - ContextManager.sharedInstance().save(context) - newPost.addObserver(self, forKeyPath: DetailConstants.LikeCountKeyPath, options: .new, context: nil) - newPost.addObserver(self, forKeyPath: DetailConstants.CommentCountKeyPath, options: .new, context: nil) - } - if isViewLoaded { - configureView() - } - } - } - - open var postURL: URL? = nil - - fileprivate var isLoaded: Bool { - return post != nil - } - - fileprivate lazy var featuredImageLoader: ImageLoader = { - // Allow for large GIFs to animate on the detail page - return ImageLoader(imageView: featuredImageView, gifStrategy: .largeGIFs) - }() - - /// The user interface direction for the view's semantic content attribute. - /// - private var layoutDirection: UIUserInterfaceLayoutDirection { - return UIView.userInterfaceLayoutDirection(for: self.view.semanticContentAttribute) - } - - // MARK: - Convenience Factories - - - /// Convenience method for instantiating an instance of ReaderDetailViewController - /// for a particular topic. - /// - /// - Parameters: - /// - topic: The reader topic for the list. - /// - /// - Return: A ReaderListViewController instance. - /// - @objc open class func controllerWithPost(_ post: ReaderPost) -> ReaderDetailViewController { - let storyboard = UIStoryboard(name: "Reader", bundle: Bundle.main) - let controller = storyboard.instantiateViewController(withIdentifier: "ReaderDetailViewController") as! ReaderDetailViewController - controller.post = post - - return controller - } - - - @objc open class func controllerWithPostID(_ postID: NSNumber, siteID: NSNumber, isFeed: Bool = false) -> ReaderDetailViewController { - let storyboard = UIStoryboard(name: "Reader", bundle: Bundle.main) - let controller = storyboard.instantiateViewController(withIdentifier: "ReaderDetailViewController") as! ReaderDetailViewController - controller.setupWithPostID(postID, siteID: siteID, isFeed: isFeed) - - return controller - } - - @objc open class func controllerWithPostURL(_ url: URL) -> ReaderDetailViewController { - - let storyboard = UIStoryboard(name: "Reader", bundle: Bundle.main) - let controller = storyboard.instantiateViewController(withIdentifier: "ReaderDetailViewController") as! ReaderDetailViewController - controller.setupWithPostURL(url) - - return controller - } - - // MARK: - State Restoration - - - public static func viewController(withRestorationIdentifierPath identifierComponents: [String], - coder: NSCoder) -> UIViewController? { - guard let path = coder.decodeObject(forKey: restorablePostObjectURLhKey) as? String else { - return nil - } - - let context = ContextManager.sharedInstance().mainContext - guard let url = URL(string: path), - let objectID = context.persistentStoreCoordinator?.managedObjectID(forURIRepresentation: url) else { - return nil - } - - guard let post = (try? context.existingObject(with: objectID)) as? ReaderPost else { - return nil - } - - return controllerWithPost(post) - } - - - open override func encodeRestorableState(with coder: NSCoder) { - if let post = post { - coder.encode(post.objectID.uriRepresentation().absoluteString, forKey: type(of: self).restorablePostObjectURLhKey) - } - - super.encodeRestorableState(with: coder) - } - - - // MARK: - LifeCycle Methods - - - deinit { - if let post = post, let context = post.managedObjectContext { - post.inUse = false - ContextManager.sharedInstance().save(context) - post.removeObserver(self, forKeyPath: DetailConstants.LikeCountKeyPath) - post.removeObserver(self, forKeyPath: DetailConstants.CommentCountKeyPath) - } - NotificationCenter.default.removeObserver(self) - } - - - open override func awakeAfter(using aDecoder: NSCoder) -> Any? { - restorationClass = type(of: self) - - return super.awakeAfter(using: aDecoder) - } - - - open override func viewDidLoad() { - super.viewDidLoad() - - setupTextView() - setupContentHeaderAndFooter() - footerView.isHidden = true - - // Hide the featured image and its padding until we know there is one to load. - featuredImageView.isHidden = true - featuredImageBottomPaddingView.isHidden = true - - noResultsViewController.delegate = self - - // Styles - applyStyles() - - setupNavBar() - - if let _ = post { - configureView() - } - - prepareForVoiceOver() - } - - - open override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - // The UIApplicationDidBecomeActiveNotification notification is broadcast - // when the app is resumed as a part of split screen multitasking on the iPad. - NotificationCenter.default.addObserver(self, - selector: #selector(handleApplicationDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil) - - bumpStats() - bumpPageViewsForPost() - indexReaderPostInSpotlight() - } - - - open override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - setBarsHidden(false, animated: animated) - - NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil) - } - - - open override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - - updateContentInsets() - updateTextViewMargins() - } - - - open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - // This is something we do to help with the resizing that can occur with - // split screen multitasking on the iPad. - view.layoutIfNeeded() - - if #available(iOS 13.0, *) { - if previousTraitCollection?.hasDifferentColorAppearance(comparedTo: traitCollection) == true { - reloadGradientColors() - updateRichText() - } - } - } - - - open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - let y = textView.contentOffset.y - let position = textView.closestPosition(to: CGPoint(x: 0.0, y: y)) - - coordinator.animate( - alongsideTransition: { (_) in - if let position = position, - let textRange = self.textView.textRange(from: position, to: position) { - - let rect = self.textView.firstRect(for: textRange) - - if rect.origin.y.isFinite { - self.textView.setContentOffset(CGPoint(x: 0.0, y: rect.origin.y), animated: false) - } - } - }, - completion: { (_) in - self.updateContentInsets() - self.updateTextViewMargins() - }) - - // Make sure that the bars are visible after switching from landscape - // to portrait orientation. The content might have been scrollable in landscape - // orientation, but it might not be in portrait orientation. We'll assume the bars - // should be visible for safety sake and for performance since WPRichTextView updates - // its intrinsicContentSize too late for get an accurate scrollWiew.contentSize - // in the completion handler below. - if size.height > size.width { - self.setBarsHidden(false) - } - } - - open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { - guard - let object = object as? NSObject, - let keyPath = keyPath - else { - return - } - - if object != post { - return - } - - if keyPath == DetailConstants.LikeCountKeyPath { - // Note: The intent here is to update the action buttons, specifically the - // like button, *after* both likeCount and isLiked has changed. The order - // of the properties is important. - configureLikeActionButton(true) - } - - else if keyPath == DetailConstants.CommentCountKeyPath { - configureCommentActionButton() - } - } - - - // MARK: - Multitasking Splitview Support - - @objc func handleApplicationDidBecomeActive(_ notification: Foundation.Notification) { - view.layoutIfNeeded() - } - - // MARK: - Setup - - @objc open func setupWithPostID(_ postID: NSNumber, siteID: NSNumber, isFeed: Bool) { - - configureAndDisplayLoadingView(title: LoadingText.loadingTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) - - textView.alpha = 0.0 - - let context = ContextManager.sharedInstance().mainContext - let service = ReaderPostService(managedObjectContext: context) - - service.fetchPost( - postID.uintValue, - forSite: siteID.uintValue, - isFeed: isFeed, - success: {[weak self] (post: ReaderPost?) in - self?.hideLoadingView() - self?.textView.alpha = 1.0 - self?.post = post - }, failure: {[weak self] (error: Error?) in - DDLogError("Error fetching post for detail: \(String(describing: error?.localizedDescription))") - self?.configureAndDisplayLoadingView(title: LoadingText.errorLoadingTitle) - self?.reportPostLoadFailure() - } - ) - } - - @objc open func setupWithPostURL(_ postURL: URL) { - self.postURL = postURL - - configureAndDisplayLoadingView(title: LoadingText.loadingTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) - - textView.alpha = 0.0 - - let context = ContextManager.sharedInstance().mainContext - let service = ReaderPostService(managedObjectContext: context) - - service.fetchPost(at: postURL, - success: { [weak self] post in - self?.hideLoadingView() - self?.textView.alpha = 1.0 - self?.post = post - }, failure: {[weak self] (error: Error?) in - DDLogError("Error fetching post for detail: \(String(describing: error?.localizedDescription))") - self?.configureAndDisplayLoadingViewWithWebAction(title: LoadingText.errorLoadingTitle) - }) - } - - /// Setup the Text View. - fileprivate func setupTextView() { - // This method should be called exactly once. - assert(textView.superview == nil) - - textView.delegate = self - - view.addSubview(textView) - view.addConstraints([ - view.safeAreaLayoutGuide.topAnchor.constraint(equalTo: textView.topAnchor), - view.leadingAnchor.constraint(equalTo: textView.leadingAnchor), - view.trailingAnchor.constraint(equalTo: textView.trailingAnchor), - textView.bottomAnchor.constraint(equalTo: footerView.topAnchor), - ]) - } - - /// Composes the views for the post header and Discover attribution. - fileprivate func setupContentHeaderAndFooter() { - // Add the footer first so its behind the header. This way the header - // obscures the footer until its properly positioned. - textView.addSubview(textFooterStackView) - textView.addSubview(textHeaderStackView) - - textHeaderStackView.topAnchor.constraint(equalTo: textView.topAnchor).isActive = true - - textFooterTopConstraint = NSLayoutConstraint(item: textFooterStackView!, - attribute: .top, - relatedBy: .equal, - toItem: textView, - attribute: .top, - multiplier: 1.0, - constant: 0.0) - textView.addConstraint(textFooterTopConstraint) - textFooterTopConstraint.constant = textFooterYOffset() - textView.setContentOffset(CGPoint.zero, animated: false) - } - - - /// Sets the left and right textContainerInset to preserve readable content margins. - fileprivate func updateContentInsets() { - var insets = textView.textContainerInset - let margin = view.readableContentGuide.layoutFrame.origin.x - - insets.left = margin - DetailConstants.MarginOffset - insets.right = margin - DetailConstants.MarginOffset - textView.textContainerInset = insets - textView.layoutIfNeeded() - } - - - /// Returns the y position for the textfooter. Assign to the textFooter's top - /// constraint constant to correctly position the view. - fileprivate func textFooterYOffset() -> CGFloat { - let length = textView.textStorage.length - if length == 0 { - return textView.contentSize.height - textFooterStackView.frame.height - } - let range = NSRange(location: length - 1, length: 0) - let frame = textView.frameForTextInRange(range) - if frame.minY == CGFloat.infinity { - // A value of infinity can occur when a device is rotated 180 degrees. - // It will sort it self out as the rotation aniation progresses, - // so just return the existing constant. - return textFooterTopConstraint.constant - } - return frame.minY - } - - - /// Updates the bounds of the placeholder top and bottom text attachments so - /// there is enough vertical space for the text header and footer views. - fileprivate func updateTextViewMargins() { - updateTopMargin() - updateBottomMargin() - textFooterTopConstraint.constant = textFooterYOffset() - } - - fileprivate func updateTopMargin() { - var bounds = topMarginAttachment.bounds - bounds.size.height = max(1, textHeaderStackView.frame.height) - bounds.size.width = textView.textContainer.size.width - topMarginAttachment.bounds = bounds - textView.ensureLayoutForAttachment(topMarginAttachment) - } - - fileprivate func updateBottomMargin() { - var bounds = bottomMarginAttachment.bounds - bounds.size.height = max(1, textFooterStackView.frame.height) - bounds.size.width = textView.textContainer.size.width - bottomMarginAttachment.bounds = bounds - textView.ensureLayoutForAttachment(bottomMarginAttachment) - } - - - fileprivate func setupNavBar() { - configureNavTitle() - - // Don't show 'Reader' in the next-view back button - navigationItem.backBarButtonItem = UIBarButtonItem(title: " ", style: .plain, target: nil, action: nil) - } - - - // MARK: - Configuration - - /** - Applies the default styles to the cell's subviews - */ - fileprivate func applyStyles() { - WPStyleGuide.applyReaderCardSiteButtonStyle(blogNameButton) - WPStyleGuide.applyReaderCardBylineLabelStyle(bylineLabel) - WPStyleGuide.applyReaderCardBylineLabelStyle(blogURLLabel) - WPStyleGuide.applyReaderCardTitleLabelStyle(titleLabel) - WPStyleGuide.applyReaderCardTagButtonStyle(tagButton) - WPStyleGuide.applyReaderCardActionButtonStyle(commentButton) - WPStyleGuide.applyReaderCardActionButtonStyle(likeButton) - if !FeatureFlag.postReblogging.enabled { - // this becomes redundant, as saveForLaterButton does not have a label anymore - // and applyReaderActionButtonStyle() is called by applyReaderSaveForLaterButtonStyle - // which in turn is called by configureSaveForLaterButton. Same considerations for - // reblog button - WPStyleGuide.applyReaderCardActionButtonStyle(saveForLaterButton) - } - - view.backgroundColor = .listBackground - - titleLabel.backgroundColor = .basicBackground - titleBottomPaddingView.backgroundColor = .basicBackground - bylineView.backgroundColor = .basicBackground - bylineBottomPaddingView.backgroundColor = .basicBackground - - headerView.backgroundColor = .listForeground - footerView.backgroundColor = .listForeground - footerDivider.backgroundColor = .divider - - if #available(iOS 13.0, *) { - if traitCollection.userInterfaceStyle == .dark { - attributionView.backgroundColor = .listBackground - } - } - - reloadGradientColors() - } - - fileprivate func reloadGradientColors() { - bylineGradientViews.forEach({ view in - view.fromColor = .basicBackground - view.toColor = UIColor.basicBackground.withAlphaComponent(0.0) - }) - } - - - fileprivate func configureView() { - textView.alpha = 1 - configureNavTitle() - configureShareButton() - configureHeader() - configureFeaturedImage() - configureTitle() - configureByLine() - configureAttributedString() - configureRichText() - configureDiscoverAttribution() - configureTag() - configureActionButtons() - configureFooterIfNeeded() - adjustInsetsForTextDirection() - - bumpStats() - bumpPageViewsForPost() - - NotificationCenter.default.addObserver(self, - selector: #selector(ReaderDetailViewController.handleBlockSiteNotification(_:)), - name: NSNotification.Name(rawValue: ReaderPostMenu.BlockSiteNotification), - object: nil) - - view.layoutIfNeeded() - textView.setContentOffset(CGPoint.zero, animated: false) - } - - - fileprivate func configureNavTitle() { - let placeholder = NSLocalizedString("Post", comment: "Placeholder title for ReaderPostDetails.") - self.title = post?.postTitle ?? placeholder - } - - - private func configureShareButton() { - let image = Gridicon.iconOfType(.shareIOS).withRenderingMode(UIImage.RenderingMode.alwaysTemplate) - let button = CustomHighlightButton(frame: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) - button.setImage(image, for: UIControl.State()) - button.addTarget(self, action: #selector(ReaderDetailViewController.didTapShareButton(_:)), for: .touchUpInside) - - let shareButton = UIBarButtonItem(customView: button) - shareButton.accessibilityLabel = NSLocalizedString("Share", comment: "Spoken accessibility label") - WPStyleGuide.setRightBarButtonItemWithCorrectSpacing(shareButton, for: navigationItem) - } - - - fileprivate func configureHeader() { - // Blavatar - let placeholder = UIImage(named: "post-blavatar-placeholder") - blavatarImageView.image = placeholder - - let size = blavatarImageView.frame.size.width * UIScreen.main.scale - if let url = post?.siteIconForDisplay(ofSize: Int(size)) { - blavatarImageView.downloadImage(from: url, placeholderImage: placeholder) - } - // Site name - let blogName = post?.blogNameForDisplay() - blogNameButton.setTitle(blogName, for: UIControl.State()) - blogNameButton.setTitle(blogName, for: .highlighted) - blogNameButton.setTitle(blogName, for: .disabled) - blogNameButton.isAccessibilityElement = false - blogNameButton.naturalContentHorizontalAlignment = .leading - - // Enable button only if not previewing a site. - if let topic = post!.topic { - blogNameButton.isEnabled = !ReaderHelpers.isTopicSite(topic) - } - - // If the button is enabled also listen for taps on the avatar. - if blogNameButton.isEnabled { - let tgr = UITapGestureRecognizer(target: self, action: #selector(ReaderDetailViewController.didTapHeaderAvatar(_:))) - blavatarImageView.addGestureRecognizer(tgr) - } - - if let siteURL: NSString = post!.siteURLForDisplay() as NSString? { - blogURLLabel.text = siteURL.components(separatedBy: "//").last - } - } - - - fileprivate func configureFeaturedImage() { - guard let post = post, - !post.contentIncludesFeaturedImage(), - let featuredImageURL = post.featuredImageURLForDisplay() else { - return - } - - let postInfo = ReaderCardContent(provider: post) - let maxImageWidth = min(view.frame.width, view.frame.height) - let imageWidthSize = CGSize(width: maxImageWidth, height: 0) // height 0: preserves aspect ratio. - featuredImageLoader.loadImage(with: featuredImageURL, from: postInfo, preferredSize: imageWidthSize, placeholder: nil, success: { [weak self] in - guard let strongSelf = self, let size = strongSelf.featuredImageView.image?.size else { - return - } - DispatchQueue.main.async { - strongSelf.configureFeaturedImageConstraints(with: size) - strongSelf.configureFeaturedImageGestures() - } - }) { error in - DDLogError("Error loading featured image in reader detail: \(String(describing: error))") - } - } - - fileprivate func configureFeaturedImageConstraints(with size: CGSize) { - // Unhide the views - featuredImageView.isHidden = false - featuredImageBottomPaddingView.isHidden = false - - // Now that we have the image, create an aspect ratio constraint for - // the featuredImageView - let ratio = size.height / size.width - let constraint = NSLayoutConstraint(item: featuredImageView as Any, - attribute: .height, - relatedBy: .equal, - toItem: featuredImageView!, - attribute: .width, - multiplier: ratio, - constant: 0) - constraint.priority = .defaultHigh - featuredImageView.addConstraint(constraint) - featuredImageView.setNeedsUpdateConstraints() - } - - - fileprivate func configureFeaturedImageGestures() { - // Listen for taps so we can display the image detail - let tgr = UITapGestureRecognizer(target: self, action: #selector(ReaderDetailViewController.didTapFeaturedImage(_:))) - featuredImageView.addGestureRecognizer(tgr) - - view.layoutIfNeeded() - updateTextViewMargins() - } - - - fileprivate func requestForURL(_ url: URL) -> URLRequest { - var requestURL = url - - let absoluteString = requestURL.absoluteString - if !absoluteString.hasPrefix("https") { - let sslURL = absoluteString.replacingOccurrences(of: "http", with: "https") - requestURL = URL(string: sslURL)! - } - - let request = NSMutableURLRequest(url: requestURL) - - let acctServ = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext) - if let account = acctServ.defaultWordPressComAccount() { - let token = account.authToken - let headerValue = String(format: "Bearer %@", token!) - request.addValue(headerValue, forHTTPHeaderField: "Authorization") - } - - return request as URLRequest - } - - - fileprivate func configureTitle() { - if let title = post?.titleForDisplay() { - titleLabel.attributedText = NSAttributedString(string: title, attributes: WPStyleGuide.readerDetailTitleAttributes()) - titleLabel.isHidden = false - - } else { - titleLabel.attributedText = nil - titleLabel.isHidden = true - } - } - - - fileprivate func configureByLine() { - // Avatar - let placeholder = UIImage(named: "gravatar") - - if let avatarURLString = post?.authorAvatarURL, - let url = URL(string: avatarURLString) { - avatarImageView.downloadImage(from: url, placeholderImage: placeholder) - } - - // Byline - let author = post?.authorForDisplay() - let dateAsString = post?.dateForDisplay()?.mediumString() - let byline: String - - if let author = author, let date = dateAsString { - byline = author + " · " + date - } else { - byline = author ?? dateAsString ?? String() - } - - bylineLabel.text = byline - - flipBylineViewIfNeeded() - } - - private func flipBylineViewIfNeeded() { - if layoutDirection == .rightToLeft { - bylineScrollView.transform = CGAffineTransform(scaleX: -1, y: 1) - bylineScrollView.subviews.first?.transform = CGAffineTransform(scaleX: -1, y: 1) - - for gradientView in bylineGradientViews { - let start = gradientView.startPoint - let end = gradientView.endPoint - - gradientView.startPoint = end - gradientView.endPoint = start - } - } - } - - fileprivate func configureRichText() { - guard let post = post else { - return - } - - textView.isPrivate = post.isPrivate() - textView.content = post.contentForDisplay() - - updateRichText() - updateTextViewMargins() - } - - private func updateRichText() { - guard let post = post else { - return - } - - if #available(iOS 13, *) { - let isDark = traitCollection.userInterfaceStyle == .dark - textView.attributedText = isDark ? darkTextViewAttributedString : lightTextViewAttributedString - } else { - let attrStr = WPRichContentView.formattedAttributedStringForString(post.contentForDisplay()) - textView.attributedText = attributedString(with: attrStr) - } - } - - private func configureAttributedString() { - if #available(iOS 13, *), let post = post { - let light = WPRichContentView.formattedAttributedString(for: post.contentForDisplay(), style: .light) - let dark = WPRichContentView.formattedAttributedString(for: post.contentForDisplay(), style: .dark) - lightTextViewAttributedString = attributedString(with: light) - darkTextViewAttributedString = attributedString(with: dark) - } - } - - private func attributedString(with attributedString: NSAttributedString) -> NSAttributedString { - let mAttrStr = NSMutableAttributedString(attributedString: attributedString) - - // Ensure the starting paragraph style is applied to the topMarginAttachment else the - // first paragraph might not have the correct line height. - var paraStyle = NSParagraphStyle.default - if attributedString.length > 0 { - if let pstyle = attributedString.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle { - paraStyle = pstyle - } - } - - mAttrStr.insert(NSAttributedString(attachment: topMarginAttachment), at: 0) - mAttrStr.addAttributes([.paragraphStyle: paraStyle], range: NSRange(location: 0, length: 1)) - mAttrStr.append(NSAttributedString(attachment: bottomMarginAttachment)) - - return mAttrStr - } - - fileprivate func configureDiscoverAttribution() { - if post?.sourceAttributionStyle() == SourceAttributionStyle.none { - attributionView.isHidden = true - } else { - attributionView.configureViewWithVerboseSiteAttribution(post!) - attributionView.delegate = self - } - } - - - fileprivate func configureTag() { - var tag = "" - if let rawTag = post?.primaryTag { - if rawTag.count > 0 { - tag = "#\(rawTag)" - } - } - tagButton.isHidden = tag.count == 0 - tagButton.setTitle(tag, for: UIControl.State()) - tagButton.setTitle(tag, for: .highlighted) - } - - - fileprivate func configureActionButtons() { - resetActionButton(likeButton) - resetActionButton(commentButton) - resetActionButton(saveForLaterButton) - resetActionButton(reblogButton) - - guard let post = post else { - assertionFailure() - return - } - - // Show likes if logged in, or if likes exist, but not if external - if (ReaderHelpers.isLoggedIn() || post.likeCount.intValue > 0) && !post.isExternal { - configureLikeActionButton() - } - - // Show comments if logged in and comments are enabled, or if comments exist. - // But only if it is from wpcom (jetpack and external is not yet supported). - // Nesting this conditional cos it seems clearer that way - if (post.isWPCom || post.isJetpack) && !shouldHideComments { - let commentCount = post.commentCount?.intValue ?? 0 - if (ReaderHelpers.isLoggedIn() && post.commentsOpen) || commentCount > 0 { - configureCommentActionButton() - } - } - // Show reblog only if logged in - if ReaderHelpers.isLoggedIn(), !post.isPrivate() { - configureReblogButton() - } - configureSaveForLaterButton() - } - - - fileprivate func resetActionButton(_ button: UIButton) { - button.setTitle(nil, for: UIControl.State()) - button.setTitle(nil, for: .highlighted) - button.setTitle(nil, for: .disabled) - button.setImage(nil, for: UIControl.State()) - button.setImage(nil, for: .highlighted) - button.setImage(nil, for: .disabled) - button.isSelected = false - button.isHidden = true - button.isEnabled = true - } - - - fileprivate func configureActionButton(_ button: UIButton, title: String?, image: UIImage?, highlightedImage: UIImage?, selected: Bool) { - button.setTitle(title, for: UIControl.State()) - button.setTitle(title, for: .highlighted) - button.setTitle(title, for: .disabled) - button.setImage(image, for: UIControl.State()) - button.setImage(highlightedImage, for: .highlighted) - button.setImage(highlightedImage, for: .selected) - button.setImage(highlightedImage, for: [.highlighted, .selected]) - button.setImage(image, for: .disabled) - button.isSelected = selected - button.isHidden = false - - WPStyleGuide.applyReaderActionButtonStyle(button) - } - - - fileprivate func configureLikeActionButton(_ animated: Bool = false) { - likeButton.isEnabled = ReaderHelpers.isLoggedIn() - // as by design spec, only display like counts - let likeCount = post?.likeCount()?.intValue ?? 0 - let shortTitle = likeCount > 0 ? "\(likeCount)" : "" - - let title = FeatureFlag.postReblogging.enabled ? shortTitle : post?.likeCountForDisplay() - - let selected = post?.isLiked ?? false - let likeImage = UIImage(named: "icon-reader-like") - let likedImage = UIImage(named: "icon-reader-liked") - - configureActionButton(likeButton, title: title, image: likeImage, highlightedImage: likedImage, selected: selected) - - if animated { - playLikeButtonAnimation() - } - } - - /// Uses the configuration in WPStyleGuide for the reblog button - fileprivate func configureReblogButton() { - guard FeatureFlag.postReblogging.enabled else { - return - } - reblogButton.isHidden = false - WPStyleGuide.applyReaderReblogActionButtonStyle(reblogButton, showTitle: false) - } - - fileprivate func playLikeButtonAnimation() { - let likeImageView = likeButton.imageView! - let frame = likeButton.convert(likeImageView.frame, from: likeImageView) - - let imageView = UIImageView(image: UIImage(named: "icon-reader-liked")) - imageView.frame = frame - likeButton.addSubview(imageView) - - let animationDuration = 0.3 - - if likeButton.isSelected { - // Prep a mask to hide the likeButton's image, since changes to visiblility and alpha are ignored - let mask = UIView(frame: frame) - mask.backgroundColor = footerView.backgroundColor - likeButton.addSubview(mask) - likeButton.bringSubviewToFront(imageView) - - // Configure starting state - imageView.alpha = 0.0 - let angle = (-270.0 * CGFloat.pi) / 180.0 - let rotate = CGAffineTransform(rotationAngle: angle) - let scale = CGAffineTransform(scaleX: 3.0, y: 3.0) - imageView.transform = rotate.concatenating(scale) - - // Perform the animations - UIView.animate(withDuration: animationDuration, - animations: { () in - let angle = (1.0 * CGFloat.pi) / 180.0 - let rotate = CGAffineTransform(rotationAngle: angle) - let scale = CGAffineTransform(scaleX: 0.75, y: 0.75) - imageView.transform = rotate.concatenating(scale) - imageView.alpha = 1.0 - imageView.center = likeImageView.center // In case the button's imageView shifted position - }, - completion: { (_) in - UIView.animate(withDuration: animationDuration, - animations: { () in - imageView.transform = CGAffineTransform(scaleX: 1.0, y: 1.0) - }, - completion: { (_) in - mask.removeFromSuperview() - imageView.removeFromSuperview() - }) - }) - - } else { - - UIView .animate(withDuration: animationDuration, - animations: { () -> Void in - let angle = (120.0 * CGFloat.pi) / 180.0 - let rotate = CGAffineTransform(rotationAngle: angle) - let scale = CGAffineTransform(scaleX: 3.0, y: 3.0) - imageView.transform = rotate.concatenating(scale) - imageView.alpha = 0 - }, - completion: { (_) in - imageView.removeFromSuperview() - }) - - } - } - - - fileprivate func configureCommentActionButton() { - guard let commentCount = post?.commentCount else { - return - } - - let title = commentCount.stringValue - let image = UIImage(named: "icon-reader-comment")?.imageFlippedForRightToLeftLayoutDirection() - let highlightImage = UIImage(named: "icon-reader-comment-highlight")?.imageFlippedForRightToLeftLayoutDirection() - configureActionButton(commentButton, title: title, image: image, highlightedImage: highlightImage, selected: false) - } - - fileprivate func configureSaveForLaterButton() { - WPStyleGuide.applyReaderSaveForLaterButtonStyle(saveForLaterButton) - if FeatureFlag.postReblogging.enabled { - WPStyleGuide.applyReaderSaveForLaterButtonTitles(saveForLaterButton, showTitle: false) - } else { - WPStyleGuide.applyReaderSaveForLaterButtonTitles(saveForLaterButton) - } - - - saveForLaterButton.isHidden = false - saveForLaterButton.isSelected = post?.isSavedForLater ?? false - } - - - fileprivate func configureFooterIfNeeded() { - self.footerView.isHidden = tagButton.isHidden && likeButton.isHidden && commentButton.isHidden - if self.footerView.isHidden { - footerViewHeightConstraint.constant = 0 - } - footerViewHeightConstraintConstant = footerViewHeightConstraint.constant - } - - fileprivate func adjustInsetsForTextDirection() { - let buttonsToAdjust: [UIButton] = [ - likeButton, - commentButton, - saveForLaterButton, - reblogButton] - for button in buttonsToAdjust { - button.flipInsetsForRightToLeftLayoutDirection() - } - } - - - // MARK: - Instance Methods - - @objc func presentReaderDetailViewControllerWithURL(_ url: URL) { - let viewController = ReaderDetailViewController.controllerWithPostURL(url) - navigationController?.pushViewController(viewController, animated: true) - } - - @objc func presentWebViewControllerWithURL(_ url: URL) { - var url = url - if url.host == nil { - if let postURLString = post?.permaLink { - let postURL = URL(string: postURLString) - url = URL(string: url.absoluteString, relativeTo: postURL)! - } - } - let configuration = WebViewControllerConfiguration(url: url) - configuration.authenticateWithDefaultAccount() - configuration.addsWPComReferrer = true - let controller = WebViewControllerFactory.controller(configuration: configuration) - let navController = UINavigationController(rootViewController: controller) - present(navController, animated: true) - } - - @objc func presentFullScreenGif(with animatedGifData: Data?) { - guard let animatedGifData = animatedGifData else { - return - } - let controller = WPImageViewController(gifData: animatedGifData) - - controller.modalTransitionStyle = .crossDissolve - controller.modalPresentationStyle = .fullScreen - present(controller, animated: true) - } - - @objc func presentFullScreenImage(with image: UIImage?, linkURL: URL? = nil) { - var controller: WPImageViewController - - if let linkURL = linkURL { - controller = WPImageViewController(image: image, andURL: linkURL) - } else if let image = image { - controller = WPImageViewController(image: image) - } else { - return - } - - controller.modalTransitionStyle = .crossDissolve - controller.modalPresentationStyle = .fullScreen - present(controller, animated: true) - } - - @objc func previewSite() { - let controller = ReaderStreamViewController.controllerWithSiteID(post!.siteID, isFeed: post!.isExternal) - navigationController?.pushViewController(controller, animated: true) - - let properties = ReaderHelpers.statsPropertiesForPost(post!, andValue: post!.blogURL as AnyObject?, forKey: "URL") - WPAppAnalytics.track(.readerSitePreviewed, withProperties: properties) - } - - @objc func setBarsHidden(_ hidden: Bool, animated: Bool = true) { - if navigationController?.isNavigationBarHidden == hidden { - return - } - - if hidden { - // Do not hide the navigation bars if VoiceOver is running because switching between - // hidden and visible causes the dictation to assume that the number of pages has - // changed. For example, when transitioning from hidden to visible, VoiceOver will - // dictate "page 4 of 4" and then dictate "page 5 of 5". - if UIAccessibility.isVoiceOverRunning { - return - } - - // Hides the navbar and footer view - navigationController?.setNavigationBarHidden(true, animated: animated) - currentPreferredStatusBarStyle = .default - footerViewHeightConstraint.constant = 0.0 - UIView.animate(withDuration: animated ? 0.2 : 0, - delay: 0.0, - options: [.beginFromCurrentState, .allowUserInteraction], - animations: { - self.view.layoutIfNeeded() - }) - - } else { - // Shows the navbar and footer view - let pinToBottom = isScrollViewAtBottom() - - currentPreferredStatusBarStyle = .lightContent - footerViewHeightConstraint.constant = footerViewHeightConstraintConstant - UIView.animate(withDuration: animated ? 0.2 : 0, - delay: 0.0, - options: [.beginFromCurrentState, .allowUserInteraction], - animations: { - self.view.layoutIfNeeded() - self.navigationController?.setNavigationBarHidden(false, animated: animated) - if pinToBottom { - let contentSizeHeight = self.textView.contentSize.height - let frameHeight = self.textView.frame.height - let y = contentSizeHeight - frameHeight - self.textView.setContentOffset(CGPoint(x: 0, y: y), animated: false) - } - - }) - } - } - - - @objc func isScrollViewAtBottom() -> Bool { - return textView.contentOffset.y + textView.frame.height == textView.contentSize.height - } - - @objc func indexReaderPostInSpotlight() { - guard let post = post else { - return - } - - SearchManager.shared.indexItem(post) - } - - private func reportPostLoadFailure() { - postLoadFailureBlock?() - - // We'll nil out the failure block so we don't perform multiple callbacks - postLoadFailureBlock = nil - } - - // MARK: - Analytics - - fileprivate func bumpStats() { - if didBumpStats { - return - } - - guard let readerPost = post, isViewLoaded && view.window != nil else { - return - } - - didBumpStats = true - - let isOfflineView = ReachabilityUtils.isInternetReachable() ? "no" : "yes" - let detailType = readerPost.topic?.type == ReaderSiteTopic.TopicType ? DetailAnalyticsConstants.TypePreviewSite : DetailAnalyticsConstants.TypeNormal - - - var properties = ReaderHelpers.statsPropertiesForPost(readerPost, andValue: nil, forKey: nil) - properties[DetailAnalyticsConstants.TypeKey] = detailType - properties[DetailAnalyticsConstants.OfflineKey] = isOfflineView - WPAppAnalytics.track(.readerArticleOpened, withProperties: properties) - - // We can remove the nil check and use `if let` when `ReaderPost` adopts nullibility. - let railcar = readerPost.railcarDictionary() - if railcar != nil { - WPAppAnalytics.trackTrainTracksInteraction(.readerArticleOpened, withProperties: railcar) - } - } - - - fileprivate func bumpPageViewsForPost() { - if didBumpPageViews { - return - } - - guard let readerPost = post, isViewLoaded && view.window != nil else { - return - } - - didBumpPageViews = true - ReaderHelpers.bumpPageViewForPost(readerPost) - } - - - // MARK: - Actions - - @IBAction func didTapSaveForLaterButton(_ sender: UIButton) { - guard let readerPost = post, let context = readerPost.managedObjectContext else { - return - } - - if !readerPost.isSavedForLater { - FancyAlertViewController.presentReaderSavedPostsAlertControllerIfNecessary(from: self) - } - - ReaderSaveForLaterAction().execute(with: readerPost, context: context, origin: .postDetail) { [weak self] in - self?.saveForLaterButton.isSelected = readerPost.isSavedForLater - self?.prepareActionButtonsForVoiceOver() - } - } - - @IBAction func didTapTagButton(_ sender: UIButton) { - if !isLoaded { - return - } - - let controller = ReaderStreamViewController.controllerWithTagSlug(post!.primaryTagSlug) - navigationController?.pushViewController(controller, animated: true) - - let properties = ReaderHelpers.statsPropertiesForPost(post!, andValue: post!.primaryTagSlug as AnyObject?, forKey: "tag") - WPAppAnalytics.track(.readerTagPreviewed, withProperties: properties) - } - - - @IBAction func didTapCommentButton(_ sender: UIButton) { - if !isLoaded { - return - } - - guard let post = self.post else { - return - } - - ReaderCommentAction().execute(post: post, origin: self) - } - - - @IBAction func didTapLikeButton(_ sender: UIButton) { - if !isLoaded { - return - } - - guard let post = post else { - return - } - - if !post.isLiked { - UINotificationFeedbackGenerator().notificationOccurred(.success) - } - - let service = ReaderPostService(managedObjectContext: post.managedObjectContext!) - service.toggleLiked(for: post, success: nil, failure: { [weak self] (error: Error?) in - self?.trackArticleDetailsLikedOrUnliked() - if let anError = error { - DDLogError("Error (un)liking post: \(anError.localizedDescription)") - } - }) - } - - @IBAction func didTapReblogButton(_ sender: Any) { - guard let post = self.post else { - return - } - ReaderReblogAction().execute(readerPost: post, origin: self, reblogSource: .detail) - } - - @objc func didTapHeaderAvatar(_ gesture: UITapGestureRecognizer) { - if gesture.state != .ended { - return - } - previewSite() - } - - - @IBAction func didTapBlogNameButton(_ sender: UIButton) { - previewSite() - } - - - @IBAction func didTapMenuButton(_ sender: UIButton) { - guard let post = post, - let context = post.managedObjectContext else { - return - } - - guard post.isFollowing else { - ReaderPostMenu.showMenuForPost(post, fromView: menuButton, inViewController: self) - return - } - - let service = ReaderTopicService(managedObjectContext: context) - if let topic = service.findSiteTopic(withSiteID: post.siteID) { - ReaderPostMenu.showMenuForPost(post, topic: topic, fromView: menuButton, inViewController: self) - return - } - } - - - @objc func didTapFeaturedImage(_ gesture: UITapGestureRecognizer) { - guard gesture.state == .ended, let post = post else { - return - } - - var controller: WPImageViewController - if post.featuredImageURL.isGif, let data = featuredImageView.animatedGifData { - controller = WPImageViewController(gifData: data) - } else if let featuredImage = featuredImageView.image { - controller = WPImageViewController(image: featuredImage) - } else { - return - } - controller.modalTransitionStyle = .crossDissolve - controller.modalPresentationStyle = .fullScreen - present(controller, animated: true) - } - - - @objc func didTapDiscoverAttribution() { - if post?.sourceAttribution == nil { - return - } - - if let blogID = post?.sourceAttribution.blogID { - let controller = ReaderStreamViewController.controllerWithSiteID(blogID, isFeed: false) - navigationController?.pushViewController(controller, animated: true) - return - } - - var path: String? - if post?.sourceAttribution.attributionType == SourcePostAttributionTypePost { - path = post?.sourceAttribution.permalink - } else { - path = post?.sourceAttribution.blogURL - } - - if let linkURL = URL(string: path!) { - presentWebViewControllerWithURL(linkURL) - } - } - - - @objc func didTapShareButton(_ sender: UIButton) { - sharingController.shareReaderPost(post!, fromView: sender, inViewController: self) - } - - - @objc func handleBlockSiteNotification(_ notification: Foundation.Notification) { - if let userInfo = notification.userInfo, let aPost = userInfo["post"] as? NSObject { - if aPost == post! { - _ = navigationController?.popViewController(animated: true) - } - } - } -} - -// MARK: - Loading View Handling - -private extension ReaderDetailViewController { - - func configureAndDisplayLoadingView(title: String, accessoryView: UIView? = nil) { - noResultsViewController.configure(title: title, accessoryView: accessoryView) - showLoadingView() - } - - func configureAndDisplayLoadingViewWithWebAction(title: String, accessoryView: UIView? = nil) { - noResultsViewController.configure(title: title, - buttonTitle: LoadingText.errorLoadingPostURLButtonTitle, - accessoryView: accessoryView) - showLoadingView() - } - - func showLoadingView() { - hideLoadingView() - addChild(noResultsViewController) - view.addSubview(withFadeAnimation: noResultsViewController.view) - noResultsViewController.didMove(toParent: self) - } - - func hideLoadingView() { - noResultsViewController.removeFromView() - } - - struct LoadingText { - static let loadingTitle = NSLocalizedString("Loading Post...", comment: "Text displayed while loading a post.") - static let errorLoadingTitle = NSLocalizedString("Error Loading Post", comment: "Text displayed when load post fails.") - static let errorLoadingPostURLButtonTitle = NSLocalizedString("Open in browser", comment: "Button title to load a post in an in-app web view") - } - -} - -// MARK: - ReaderCardDiscoverAttributionView Delegate Methods - -extension ReaderDetailViewController: ReaderCardDiscoverAttributionViewDelegate { - public func attributionActionSelectedForVisitingSite(_ view: ReaderCardDiscoverAttributionView) { - didTapDiscoverAttribution() - } -} - - -// MARK: - UITextView/WPRichContentView Delegate Methods - -extension ReaderDetailViewController: WPRichContentViewDelegate { - public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool { - presentWebViewControllerWithURL(URL) - return false - } - - public func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { - if interaction == .presentActions { - // show - let frame = textView.frameForTextInRange(characterRange) - let shareController = PostSharingController() - shareController.shareURL(url: URL as NSURL, fromRect: frame, inView: textView, inViewController: self) - } - return false - } - - func richContentView(_ richContentView: WPRichContentView, didReceiveImageAction image: WPRichTextImage) { - // If we have gif data availible, present that - if let animatedGifData = image.imageView.animatedGifData { - presentFullScreenGif(with: animatedGifData) - return - } - - // Otherwise try to present the static image/URL - if let linkURL = image.linkURL, WPImageViewController.isUrlSupported(linkURL) { - presentFullScreenImage(with: image.imageView.image, linkURL: linkURL) - } else if let linkURL = image.linkURL { - presentWebViewControllerWithURL(linkURL as URL) - } else if let staticImage = image.imageView.image { - presentFullScreenImage(with: staticImage) - } - } - - func interactWith(URL: URL) { - if readerLinkRouter.canHandle(url: URL) { - readerLinkRouter.handle(url: URL, shouldTrack: false, source: self) - } else if URL.isWordPressDotComPost { - presentReaderDetailViewControllerWithURL(URL) - } else { - presentWebViewControllerWithURL(URL) - } - } -} - - -// MARK: - UIScrollView Delegate Methods - -extension ReaderDetailViewController: UIScrollViewDelegate { - - public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { - if UIDevice.isPad() || footerView.isHidden || !isLoaded { - return - } - - // The threshold for hiding the bars is twice the height of the hidden bars. - // This ensures that once the bars are hidden the view can still be scrolled - // and thus can unhide the bars. - var threshold = footerViewHeightConstraintConstant - if let navHeight = navigationController?.navigationBar.frame.height { - threshold += navHeight - } - threshold *= 2.0 - - let y = targetContentOffset.pointee.y - if y > scrollView.contentOffset.y && y > threshold { - setBarsHidden(true) - } else { - // Velocity will be 0,0 if the user taps to stop an in progress scroll. - // If the bars are already visible its fine but if the bars are hidden - // we don't want to jar the user by having them reappear. - if !velocity.equalTo(CGPoint.zero) { - setBarsHidden(false) - } - } - } - - - public func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { - setBarsHidden(false) - } - - - public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - if isScrollViewAtBottom() { - setBarsHidden(false) - } - } - -} - -// Expand this view controller to full screen if possible -extension ReaderDetailViewController: PrefersFullscreenDisplay {} - -// Let's the split view know this vc changes the status bar style. -extension ReaderDetailViewController: DefinesVariableStatusBarStyle {} - -extension ReaderDetailViewController: Accessible { - func prepareForVoiceOver() { - prepareMenuForVoiceOver() - prepareHeaderForVoiceOver() - prepareContentForVoiceOver() - prepareActionButtonsForVoiceOver() - if FeatureFlag.postReblogging.enabled { - prepareReblogForVoiceOver() - } - - NotificationCenter.default.addObserver(self, - selector: #selector(setBarsAsVisibleIfVoiceOverIsEnabled), - name: UIAccessibility.voiceOverStatusDidChangeNotification, - object: nil) - } - - @objc func setBarsAsVisibleIfVoiceOverIsEnabled() { - if UIAccessibility.isVoiceOverRunning { - setBarsHidden(false) - } - } - - private func prepareMenuForVoiceOver() { - menuButton.accessibilityLabel = NSLocalizedString("More", comment: "Accessibility label for the More button on Reader's post details") - menuButton.accessibilityTraits = UIAccessibilityTraits.button - menuButton.accessibilityHint = NSLocalizedString("Shows more options.", comment: "Accessibility hint for the More button on Reader's post details") - } - - private func prepareHeaderForVoiceOver() { - guard let post = post else { - blogNameButton.isAccessibilityElement = false - return - } - blogNameButton.isAccessibilityElement = true - blogNameButton.accessibilityTraits = [.staticText, .button] - blogNameButton.accessibilityHint = NSLocalizedString("Shows the site's posts.", comment: "Accessibility hint for the site name and URL button on Reader's Post Details.") - if let label = blogNameLabel(post) { - blogNameButton.accessibilityLabel = label - } - } - - private func blogNameLabel(_ post: ReaderPost) -> String? { - guard let postedIn = post.blogNameForDisplay(), - let postedBy = post.authorDisplayName, - let postedAtURL = post.siteURLForDisplay()?.components(separatedBy: "//").last else { - return nil - } - - guard let postedOn = post.dateCreated?.mediumString() else { - let format = NSLocalizedString("Posted in %@, at %@, by %@.", comment: "Accessibility label for the blog name in the Reader's post details, without date. Placeholders are blog title, blog URL, author name") - return String(format: format, postedIn, postedAtURL, postedBy) - } - - let format = NSLocalizedString("Posted in %@, at %@, by %@, %@", comment: "Accessibility label for the blog name in the Reader's post details. Placeholders are blog title, blog URL, author name, published date") - return String(format: format, postedIn, postedAtURL, postedBy, postedOn) - } - - private func prepareContentForVoiceOver() { - preparePostTitleForVoiceOver() - } - - private func preparePostTitleForVoiceOver() { - guard let post = post else { - return - } - - guard let title = post.titleForDisplay() else { - return - } - textHeaderStackView.isAccessibilityElement = false - - titleLabel.accessibilityLabel = title - titleLabel.accessibilityTraits = UIAccessibilityTraits.staticText - } - - private func prepareActionButtonsForVoiceOver() { - let isSavedForLater = post?.isSavedForLater ?? false - saveForLaterButton.accessibilityLabel = isSavedForLater ? NSLocalizedString("Saved Post", comment: "Accessibility label for the 'Save Post' button when a post has been saved.") : NSLocalizedString("Save post", comment: "Accessibility label for the 'Save Post' button.") - saveForLaterButton.accessibilityHint = isSavedForLater ? NSLocalizedString("Remove this post from my saved posts.", comment: "Accessibility hint for the 'Save Post' button when a post is already saved.") : NSLocalizedString("Saves this post for later.", comment: "Accessibility hint for the 'Save Post' button.") - } - - private func prepareReblogForVoiceOver() { - reblogButton.accessibilityLabel = NSLocalizedString("Reblog post", comment: "Accessibility label for the reblog button.") - reblogButton.accessibilityHint = NSLocalizedString("Reblog this post", comment: "Accessibility hint for the reblog button.") - reblogButton.accessibilityTraits = UIAccessibilityTraits.button - } -} - - -// MARK: - UIViewControllerTransitioningDelegate -//// -extension ReaderDetailViewController: UIViewControllerTransitioningDelegate { - public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { - guard presented is FancyAlertViewController else { - return nil - } - - return FancyAlertPresentationController(presentedViewController: presented, presenting: presenting) - } -} - -// MARK: - NoResultsViewControllerDelegate -/// -extension ReaderDetailViewController: NoResultsViewControllerDelegate { - func actionButtonPressed() { - if let postURL = postURL { - presentWebViewControllerWithURL(postURL) - navigationController?.popViewController(animated: true) - } - } -} - -// MARK: - Tracking events - -private extension ReaderDetailViewController { - func trackArticleDetailsLikedOrUnliked() { - guard let post = post else { - return - } - - let stat: WPAnalyticsStat = post.isLiked - ? .readerArticleDetailLiked - : .readerArticleDetailUnliked - - var properties = [AnyHashable: Any]() - properties[WPAppAnalyticsKeyBlogID] = post.siteID - properties[WPAppAnalyticsKeyPostID] = post.postID - WPAnalytics.track(stat, withProperties: properties) - } -} - -// MARK: - Testing - -extension ReaderDetailViewController { - // Returns the reblogButton instance for testing - func getReblogButtonForTesting() -> UIButton { - return reblogButton - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderFollowAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderFollowAction.swift index 3bd690164cd1..01f57bc698f7 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderFollowAction.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderFollowAction.swift @@ -1,36 +1,16 @@ /// Encapsulates a command to toggle following a post final class ReaderFollowAction { - func execute(with post: ReaderPost, context: NSManagedObjectContext, completion: (() -> Void)? = nil) { - let siteID = post.siteID - var errorMessage: String - var errorTitle: String - if post.isFollowing { - errorTitle = NSLocalizedString("Problem Unfollowing Site", comment: "Title of a prompt") - errorMessage = NSLocalizedString("There was a problem unfollowing the site. If the problem persists you can contact us via the Me > Help & Support screen.", comment: "Short notice that there was a problem unfollowing a site and instructions on how to notify us of the problem.") - } else { - errorTitle = NSLocalizedString("Problem Following Site", comment: "Title of a prompt") - errorMessage = NSLocalizedString("There was a problem following the site. If the problem persists you can contact us via the Me > Help & Support screen.", comment: "Short notice that there was a problem following a site and instructions on how to notify us of the problem.") - } - - let postService = ReaderPostService(managedObjectContext: context) - let toFollow = !post.isFollowing - - if !toFollow { - ReaderSubscribingNotificationAction().execute(for: siteID, context: context, value: false) + func execute(with post: ReaderPost, + context: NSManagedObjectContext, + completion: ((Bool) -> Void)? = nil, + failure: ((Bool, Error?) -> Void)? = nil) { + if post.isFollowing { + ReaderSubscribingNotificationAction().execute(for: post.siteID, context: context, subscribe: false) + WPAnalytics.track(.readerListNotificationMenuOff) } - postService.toggleFollowing(for: post, - success: { - completion?() - }, - failure: { _ in - let cancelTitle = NSLocalizedString("OK", comment: "Text of an OK button to dismiss a prompt.") - let alertController = UIAlertController(title: errorTitle, - message: errorMessage, - preferredStyle: .alert) - alertController.addCancelActionWithTitle(cancelTitle, handler: nil) - alertController.presentFromRootViewController() - }) + let postService = ReaderPostService(coreDataStack: ContextManager.shared) + postService.toggleFollowing(for: post, success: completion, failure: failure) } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderFollowedSitesStreamHeader.swift b/WordPress/Classes/ViewRelated/Reader/ReaderFollowedSitesStreamHeader.swift deleted file mode 100644 index c9e439d5b7d3..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/ReaderFollowedSitesStreamHeader.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import Gridicons -import WordPressShared.WPStyleGuide - -@objc open class ReaderFollowedSitesStreamHeader: UIView, ReaderStreamHeader { - @IBOutlet fileprivate weak var borderedView: UIView! - @IBOutlet fileprivate weak var imageView: UIImageView! - @IBOutlet fileprivate weak var titleLabel: UILabel! - @IBOutlet fileprivate weak var disclosureIcon: UIImageView! - @IBOutlet fileprivate weak var contentButton: UIButton! - - open weak var delegate: ReaderStreamHeaderDelegate? - - - // MARK: - Lifecycle Methods - - - open override func awakeFromNib() { - super.awakeFromNib() - - applyStyles() - prepareForVoiceOver() - } - - - @objc func applyStyles() { - backgroundColor = .clear - borderedView.backgroundColor = .listForeground - borderedView.layer.borderColor = WPStyleGuide.readerCardCellBorderColor().cgColor - borderedView.layer.borderWidth = .hairlineBorderWidth - - titleLabel.font = WPStyleGuide.tableviewTextFont() - titleLabel.textColor = .neutral(.shade70) - titleLabel.text = NSLocalizedString("Manage", comment: "Button title. Tapping lets the user manage the sites they follow.") - - disclosureIcon.image = Gridicon.iconOfType(.chevronRight, withSize: disclosureIcon.frame.size).imageFlippedForRightToLeftLayoutDirection() - disclosureIcon.tintColor = .neutral(.shade30) - - imageView.image = Gridicon.iconOfType(.cog) - imageView.tintColor = UIColor.white - } - - - // MARK: - Configuration - - - @objc open func configureHeader(_ topic: ReaderAbstractTopic) { - // no op - } - - - @objc open func enableLoggedInFeatures(_ enable: Bool) { - // no op - } - - - // MARK: - Actions - - - @IBAction func didTouchDown(_ sender: UIButton) { - borderedView.backgroundColor = .textInverted - } - - - @IBAction func didTouchUpInside(_ sender: UIButton) { - borderedView.backgroundColor = .listForeground - - delegate?.handleFollowActionForHeader(self) - } - - - @IBAction func didTouchUpOutside(_ sender: UIButton) { - borderedView.backgroundColor = .listForeground - } -} - - -// MARK: - Accessibility -extension ReaderFollowedSitesStreamHeader: Accessible { - func prepareForVoiceOver() { - isAccessibilityElement = true - accessibilityLabel = NSLocalizedString("Manage", comment: "Button title. Tapping lets the user manage the sites they follow.") - accessibilityHint = NSLocalizedString("Tapping lets you manage the sites you follow.", comment: "Accessibility hint") - accessibilityTraits = UIAccessibilityTraits.button - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderFollowedSitesStreamHeader.xib b/WordPress/Classes/ViewRelated/Reader/ReaderFollowedSitesStreamHeader.xib deleted file mode 100644 index 710198e9d1ea..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/ReaderFollowedSitesStreamHeader.xib +++ /dev/null @@ -1,95 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderFollowedSitesViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderFollowedSitesViewController.swift index d996a882f13b..2245ba57c792 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderFollowedSitesViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderFollowedSitesViewController.swift @@ -2,6 +2,7 @@ import Foundation import WordPressShared import CocoaLumberjack import WordPressFlux +import Gridicons /// Displays the list of sites a user follows in the Reader. Provides functionality /// for following new sites by URL, and unfollowing existing sites via a swipe @@ -19,15 +20,22 @@ class ReaderFollowedSitesViewController: UIViewController, UIViewControllerResto private var currentKeyboardHeight: CGFloat = 0 private var deviceIsRotating = false - private let noResultsViewController = NoResultsViewController.controller() + private lazy var noResultsViewController: NoResultsViewController = { + return NoResultsViewController.controller() + }() + + private var showsAccessoryFollowButtons: Bool = false + private var showsSectionTitle: Bool = true /// Convenience method for instantiating an instance of ReaderFollowedSitesViewController /// /// - Returns: An instance of the controller /// - @objc class func controller() -> ReaderFollowedSitesViewController { + @objc class func controller(showsAccessoryFollowButtons: Bool = false, showsSectionTitle: Bool = true) -> ReaderFollowedSitesViewController { let storyboard = UIStoryboard(name: "Reader", bundle: Bundle.main) let controller = storyboard.instantiateViewController(withIdentifier: "ReaderFollowedSitesViewController") as! ReaderFollowedSitesViewController + controller.showsAccessoryFollowButtons = showsAccessoryFollowButtons + controller.showsSectionTitle = showsSectionTitle return controller } @@ -58,12 +66,12 @@ class ReaderFollowedSitesViewController: UIViewController, UIViewControllerResto override func viewDidLoad() { super.viewDidLoad() + self.title = NSLocalizedString("Manage", comment: "Page title for the screen to manage your list of followed sites.") setupTableView() setupTableViewHandler() configureSearchBar() setupBackgroundTapGestureRecognizer() - noResultsViewController.delegate = self WPStyleGuide.configureColors(view: view, tableView: tableView) } @@ -117,21 +125,20 @@ class ReaderFollowedSitesViewController: UIViewController, UIViewControllerResto @objc func configureSearchBar() { let placeholderText = NSLocalizedString("Enter the URL of a site to follow", comment: "Placeholder text prompting the user to type the name of the URL they would like to follow.") - let attributes = WPStyleGuide.defaultSearchBarTextAttributesSwifted(.neutral(.shade30)) - let attributedPlaceholder = NSAttributedString(string: placeholderText, attributes: attributes) - UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self, ReaderFollowedSitesViewController.self]).attributedPlaceholder = attributedPlaceholder - let textAttributes = WPStyleGuide.defaultSearchBarTextAttributesSwifted(.neutral(.shade60)) - UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self, ReaderFollowedSitesViewController.self]).defaultTextAttributes = textAttributes + UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self, ReaderFollowedSitesViewController.self]).placeholder = placeholderText + WPStyleGuide.configureSearchBar(searchBar) + + let iconSizes = CGSize(width: 20, height: 20) + let clearImage = UIImage.gridicon(.crossCircle, size: iconSizes).withTintColor(.searchFieldIcons).withRenderingMode(.alwaysOriginal) + let addOutline = UIImage.gridicon(.addOutline, size: iconSizes).withTintColor(.searchFieldIcons).withRenderingMode(.alwaysOriginal) searchBar.autocapitalizationType = .none searchBar.keyboardType = .URL - searchBar.isTranslucent = false - searchBar.tintColor = .neutral(.shade30) - searchBar.barTintColor = .neutral(.shade5) - searchBar.backgroundImage = UIImage() - searchBar.returnKeyType = .done - searchBar.setImage(UIImage(named: "icon-clear-textfield"), for: .clear, state: UIControl.State()) - searchBar.setImage(UIImage(named: "icon-reader-search-plus"), for: .search, state: UIControl.State()) + searchBar.setImage(clearImage, for: .clear, state: UIControl.State()) + searchBar.setImage(addOutline, for: .search, state: UIControl.State()) + searchBar.searchTextField.accessibilityLabel = NSLocalizedString("Site URL", comment: "The accessibility label for the followed sites search field") + searchBar.searchTextField.accessibilityValue = nil + searchBar.searchTextField.accessibilityHint = placeholderText } func setupBackgroundTapGestureRecognizer() { @@ -155,12 +162,10 @@ class ReaderFollowedSitesViewController: UIViewController, UIViewControllerResto } currentKeyboardHeight = keyboardFrame.height - configureNoResultsView() } @objc func keyboardWillHide(_ notification: Foundation.Notification) { currentKeyboardHeight = 0 - configureNoResultsView() } @@ -172,7 +177,7 @@ class ReaderFollowedSitesViewController: UIViewController, UIViewControllerResto return } isSyncing = true - let service = ReaderTopicService(managedObjectContext: managedObjectContext()) + let service = ReaderTopicService(coreDataStack: ContextManager.shared) service.fetchFollowedSites(success: {[weak self] in self?.isSyncing = false self?.configureNoResultsView() @@ -193,7 +198,7 @@ class ReaderFollowedSitesViewController: UIViewController, UIViewControllerResto @objc func refreshFollowedPosts() { - let service = ReaderSiteService(managedObjectContext: managedObjectContext()) + let service = ReaderSiteService(coreDataStack: ContextManager.shared) service.syncPostsForFollowedSites() } @@ -203,8 +208,12 @@ class ReaderFollowedSitesViewController: UIViewController, UIViewControllerResto return } - let service = ReaderTopicService(managedObjectContext: managedObjectContext()) - service.toggleFollowing(forSite: site, success: { [weak self] in + NotificationCenter.default.post(name: .ReaderTopicUnfollowed, + object: nil, + userInfo: [ReaderNotificationKeys.topic: site]) + + let service = ReaderTopicService(coreDataStack: ContextManager.shared) + service.toggleFollowing(forSite: site, success: { [weak self] follow in let siteURL = URL(string: site.siteURL) let notice = Notice(title: NSLocalizedString("Unfollowed site", comment: "User unfollowed a site."), message: siteURL?.host, @@ -213,7 +222,7 @@ class ReaderFollowedSitesViewController: UIViewController, UIViewControllerResto self?.syncSites() self?.refreshFollowedPosts() - }, failure: { [weak self] (error) in + }, failure: { [weak self] (follow, error) in DDLogError("Could not unfollow site: \(String(describing: error))") let notice = Notice(title: NSLocalizedString("Could not unfollow site", comment: "Title of a prompt."), @@ -231,15 +240,15 @@ class ReaderFollowedSitesViewController: UIViewController, UIViewControllerResto return } - let service = ReaderSiteService(managedObjectContext: managedObjectContext()) + let service = ReaderSiteService(coreDataStack: ContextManager.shared) service.followSite(by: url, success: { [weak self] in let notice = Notice(title: NSLocalizedString("Followed site", comment: "User followed a site."), message: url.host, feedbackType: .success) self?.post(notice) - self?.syncSites() self?.refreshPostsForFollowedTopic() + self?.postFollowedNotification(siteUrl: url) }, failure: { [weak self] error in DDLogError("Could not follow site: \(String(describing: error))") @@ -258,9 +267,22 @@ class ReaderFollowedSitesViewController: UIViewController, UIViewControllerResto }) } + private func postFollowedNotification(siteUrl: URL) { + let service = ReaderSiteService(coreDataStack: ContextManager.shared) + service.topic(withSiteURL: siteUrl, success: { topic in + if let topic = topic { + NotificationCenter.default.post(name: .ReaderSiteFollowed, + object: nil, + userInfo: [ReaderNotificationKeys.topic: topic]) + } + }, failure: { error in + DDLogError("Unable to find topic by siteURL: \(String(describing: error?.localizedDescription))") + }) + + } @objc func refreshPostsForFollowedTopic() { - let service = ReaderPostService(managedObjectContext: managedObjectContext()) + let service = ReaderPostService(coreDataStack: ContextManager.shared) service.refreshPostsForFollowedTopic() } @@ -323,22 +345,12 @@ private extension ReaderFollowedSitesViewController { return } + noResultsViewController = NoResultsViewController.controller() + if isSyncing { noResultsViewController.configure(title: NoResultsText.loadingTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) } else { - noResultsViewController.configure(title: NoResultsText.noResultsTitle, - buttonTitle: NoResultsText.buttonTitle, - subtitle: NoResultsText.noResultsMessage) - - // Due to limited space when the keyboard is visible, - // hide the image on iPhone and iPad landscape. - var hideImageView = false - if currentKeyboardHeight > 0 { - hideImageView = WPDeviceIdentification.isiPhone() || - (WPDeviceIdentification.isiPad() && UIDevice.current.orientation.isLandscape) - } - - noResultsViewController.hideImageView(hideImageView) + noResultsViewController = NoResultsViewController.noFollowedSitesController(showActionButton: false) } showNoResultView() @@ -356,18 +368,7 @@ private extension ReaderFollowedSitesViewController { noResultsViewController.didMove(toParent: tableViewController) } - func showDiscoverSites() { - guard let readerMenuViewController = WPTabBarController.sharedInstance().readerMenuViewController else { - return - } - - readerMenuViewController.showSectionForDefaultMenuItem(withOrder: .discover, animated: true) - } - struct NoResultsText { - static let noResultsTitle = NSLocalizedString("No followed sites", comment: "Title of a message explaining that the user is not currently following any blogs in their reader.") - static let noResultsMessage = NSLocalizedString("You are not following any sites yet. Why not follow one now?", comment: "A suggestion to the user that they try following a site in their reader.") - static let buttonTitle = NSLocalizedString("Discover Sites", comment: "Button title. Tapping takes the user to the Discover sites list.") static let loadingTitle = NSLocalizedString("Fetching sites...", comment: "A short message to inform the user data for their followed sites is being fetched..") } @@ -408,22 +409,37 @@ extension ReaderFollowedSitesViewController: WPTableViewHandlerDelegate { return } - // Reset the site icon first to address: https://github.com/wordpress-mobile/WordPress-iOS/issues/8513 - cell.imageView?.image = .siteIconPlaceholder + var placeholderImage: UIImage = .siteIconPlaceholder + if site.isP2Type { + placeholderImage = UIImage.gridicon(.p2, size: CGSize(width: 40, height: 40)) + } - cell.accessoryType = .disclosureIndicator - cell.imageView?.backgroundColor = .neutral(.shade5) + // Reset the site icon first to address: https://github.com/wordpress-mobile/WordPress-iOS/issues/8513 + cell.imageView?.image = placeholderImage + cell.imageView?.tintColor = .listIcon + cell.imageView?.backgroundColor = UIColor.listForeground + + if showsAccessoryFollowButtons { + let button = UIButton(frame: CGRect(x: 0, y: 0, width: 40, height: 40)) + button.setImage(UIImage.gridicon(.readerFollowing), for: .normal) + button.imageView?.tintColor = UIColor.success + button.addTarget(self, action: #selector(tappedAccessory(_:)), for: .touchUpInside) + let unfollowSiteString = NSLocalizedString("Unfollow %@", comment: "Accessibility label for unfollowing a site") + button.accessibilityLabel = String(format: unfollowSiteString, site.title) + cell.accessoryView = button + cell.accessibilityElements = [button] + } else { + cell.accessoryType = .disclosureIndicator + } cell.textLabel?.text = site.title cell.detailTextLabel?.text = URL(string: site.siteURL)?.host - cell.imageView?.downloadSiteIcon(at: site.siteBlavatar) - + cell.imageView?.downloadSiteIcon(at: site.siteBlavatar, placeholderImage: placeholderImage) WPStyleGuide.configureTableViewSmallSubtitleCell(cell) cell.layoutSubviews() } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? WPTableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier) @@ -441,6 +457,11 @@ extension ReaderFollowedSitesViewController: WPTableViewHandlerDelegate { } func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + + guard showsSectionTitle else { + return nil + } + let count = tableViewHandler.resultsController.fetchedObjects?.count ?? 0 if count > 0 { return NSLocalizedString("Followed Sites", comment: "Section title for sites the user has followed.") @@ -481,6 +502,9 @@ extension ReaderFollowedSitesViewController: WPTableViewHandlerDelegate { return NSLocalizedString("Unfollow", comment: "Label of the table view cell's delete button, when unfollowing a site.") } + func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) { + unfollowSiteAtIndexPath(indexPath) + } func tableViewDidChangeContent(_ tableView: UITableView) { configureNoResultsView() @@ -492,6 +516,12 @@ extension ReaderFollowedSitesViewController: WPTableViewHandlerDelegate { } } + @objc func tappedAccessory(_ sender: UIButton) { + if let point = sender.superview?.convert(sender.center, to: tableView), + let indexPath = tableView.indexPathForRow(at: point) { + self.tableView(tableView, accessoryButtonTappedForRowWith: indexPath) + } + } } extension ReaderFollowedSitesViewController: UISearchBarDelegate { @@ -503,11 +533,3 @@ extension ReaderFollowedSitesViewController: UISearchBarDelegate { searchBar.resignFirstResponder() } } - -// MARK: - NoResultsViewControllerDelegate - -extension ReaderFollowedSitesViewController: NoResultsViewControllerDelegate { - func actionButtonPressed() { - showDiscoverSites() - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderHelpers.swift b/WordPress/Classes/ViewRelated/Reader/ReaderHelpers.swift index 147e0d4b4a41..54a6b16acd01 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderHelpers.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderHelpers.swift @@ -1,5 +1,78 @@ import Foundation import WordPressShared +import WordPressFlux +import AutomatticTracks + + +// MARK: - Reader Notifications + +extension NSNotification.Name { + // Sent when a site or a tag is unfollowed via Reader Manage screen. + static let ReaderTopicUnfollowed = NSNotification.Name(rawValue: "ReaderTopicUnfollowed") + // Sent when a site is followed via Reader Manage screen. + static let ReaderSiteFollowed = NSNotification.Name(rawValue: "ReaderSiteFollowed") + // Sent when a post's seen state has been toggled. + static let ReaderPostSeenToggled = NSNotification.Name(rawValue: "ReaderPostSeenToggled") + // Sent when a site is blocked. + static let ReaderSiteBlocked = NSNotification.Name(rawValue: "ReaderSiteBlocked") + // Sent when site blocking will begin. + static let ReaderSiteBlockingWillBegin = NSNotification.Name(rawValue: "ReaderSiteBlockingWillBegin") + // Sent when site blocking failed. + static let ReaderSiteBlockingFailed = NSNotification.Name(rawValue: "ReaderSiteBlockingFailed") + // Sent when the user blocking request is sent + static let ReaderUserBlockingWillBegin = NSNotification.Name(rawValue: "ReaderUserBlockingWillBegin") + // Sent when the user blocking request is complete + static let ReaderUserBlockingDidEnd = NSNotification.Name(rawValue: "ReaderUserBlockingDidEnd") +} + +struct ReaderNotificationKeys { + static let error = "error" + static let result = "result" + static let post = "post" + static let topic = "topic" +} + +// Used for event tracking properties +enum ReaderPostMenuSource { + case card + case details + + var description: String { + switch self { + case .card: + return "post_card" + case .details: + return "post_details" + } + } +} + +// Titles for post menu options +struct ReaderPostMenuButtonTitles { + static let cancel = NSLocalizedString("Cancel", comment: "The title of a cancel button.") + static let blockSite = NSLocalizedString("Block this site", comment: "The title of a button that triggers blocking a site from the user's reader.") + static let blockUser = NSLocalizedString( + "reader.post.menu.block.user", + value: "Block this user", + comment: "The title of a button that triggers blocking a user from the user's reader." + ) + static let reportPost = NSLocalizedString("Report this post", comment: "The title of a button that triggers reporting of a post from the user's reader.") + static let reportPostAuthor = NSLocalizedString( + "reader.post.menu.report.user", + value: "Report this user", + comment: "The title of a button that triggers the reporting of a post's author." + ) + static let share = NSLocalizedString("Share", comment: "Verb. Title of a button. Pressing lets the user share a post to others.") + static let visit = NSLocalizedString("Visit", comment: "An option to visit the site to which a specific post belongs") + static let unfollow = NSLocalizedString("Unfollow site", comment: "Verb. An option to unfollow a site.") + static let follow = NSLocalizedString("Follow site", comment: "Verb. An option to follow a site.") + static let subscribe = NSLocalizedString("Turn on site notifications", comment: "Verb. An option to switch on site notifications.") + static let unsubscribe = NSLocalizedString("Turn off site notifications", comment: "Verb. An option to switch off site notifications.") + static let markSeen = NSLocalizedString("Mark as seen", comment: "An option to mark a post as seen.") + static let markUnseen = NSLocalizedString("Mark as unseen", comment: "An option to mark a post as unseen.") + static let followConversation = NSLocalizedString("Follow conversation", comment: "Verb. Button title. Follow the comments on a post.") + static let unFollowConversation = NSLocalizedString("Unfollow conversation", comment: "Verb. Button title. The user is following the comments on a post.") +} /// A collection of helper methods used by the Reader. /// @@ -122,7 +195,7 @@ import WordPressShared /// - Parameters: /// - topic: A ReaderAbstractTopic /// - /// - Returns: True if the topic is for Discover + /// - Returns: True if the topic is for Saved For Later /// @objc open class func topicIsSavedForLater(_ topic: ReaderAbstractTopic) -> Bool { //TODO. Update this logic with the right one. I am not sure how this is going to be modeeled now. @@ -132,15 +205,24 @@ import WordPressShared // MARK: Analytics Helpers - @objc open class func trackLoadedTopic(_ topic: ReaderAbstractTopic, withProperties properties: [AnyHashable: Any]) { + class func trackLoadedTopic(_ topic: ReaderAbstractTopic, withProperties properties: [AnyHashable: Any]) { var stat: WPAnalyticsStat? if topicIsFreshlyPressed(topic) { stat = .readerFreshlyPressedLoaded + } else if topicIsFollowing(topic) { + WPAnalytics.trackReader(.readerFollowingShown, properties: properties) + + } else if topicIsLiked(topic) { + WPAnalytics.trackReader(.readerLikedShown, properties: properties) + + } else if isTopicSite(topic) { + WPAnalytics.trackReader(.readerBlogPreviewed, properties: properties) + } else if isTopicDefault(topic) && topicIsDiscover(topic) { // Tracks Discover only if it was one of the default menu items. - stat = .readerDiscoverViewed + WPAnalytics.trackReaderEvent(.readerDiscoverShown, properties: properties) } else if isTopicList(topic) { stat = .readerListLoaded @@ -148,7 +230,10 @@ import WordPressShared } else if isTopicTag(topic) { stat = .readerTagLoaded + } else if let teamTopic = topic as? ReaderTeamTopic { + WPAnalytics.trackReader(teamTopic.shownTrackEvent, properties: properties) } + if stat != nil { WPAnalytics.track(stat!, withProperties: properties) } @@ -172,6 +257,11 @@ import WordPressShared return properties } + @objc open class func statsPropertiesForPostAuthor(_ post: ReaderPost, andValue value: AnyObject? = nil, forKey key: String? = nil) -> [AnyHashable: Any] { + var properties = Self.statsPropertiesForPost(post, andValue: value, forKey: key) + properties[WPAppAnalyticsKeyPostAuthorID] = post.authorID + return properties + } @objc open class func bumpPageViewForPost(_ post: ReaderPost) { // Don't bump page views for feeds else the wrong blog/post get's bumped @@ -206,9 +296,12 @@ import WordPressShared let userAgent = WPUserAgent.wordPress() let path = NSString(format: "%@?%@", pixel, params.componentsJoined(by: "&")) as String - let url = URL(string: path) - let request = NSMutableURLRequest(url: url!) + guard let url = URL(string: path) else { + return + } + + let request = NSMutableURLRequest(url: url) request.setValue(userAgent, forHTTPHeaderField: "User-Agent") request.addValue(pixelStatReferrer, forHTTPHeaderField: "Referer") @@ -218,18 +311,326 @@ import WordPressShared } @objc open class func isUserAdminOnSiteWithID(_ siteID: NSNumber) -> Bool { - let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - if let blog = blogService.blog(byBlogId: siteID) { - return blog.isAdmin - } - return false + Blog.lookup(withID: siteID, in: ContextManager.sharedInstance().mainContext)?.isAdmin ?? false } + // convenience method that returns the topic type + class func topicType(_ topic: ReaderAbstractTopic?) -> ReaderTopicType { + guard let topic = topic else { + return .noTopic + } + if topicIsDiscover(topic) { + return .discover + } + if topicIsFollowing(topic) { + return .following + } + if topicIsLiked(topic) { + return .likes + } + if isTopicList(topic) { + return .list + } + if isTopicSearchTopic(topic) { + return .search + } + if isTopicSite(topic) { + return .site + } + if isTopicTag(topic) { + return .tag + } + if topic is ReaderTeamTopic { + return .organization + } + return .noTopic + } // MARK: Logged in helper @objc open class func isLoggedIn() -> Bool { return AccountHelper.isDotcomAvailable() } + + // MARK: ActionDispatcher Notification helper + + class func dispatchToggleSeenMessage(post: ReaderPost, success: Bool) { + var notice: Notice { + if success { + return Notice(title: post.isSeen ? NoticeMessages.seenSuccess : NoticeMessages.unseenSuccess) + } + return Notice(title: post.isSeen ? NoticeMessages.unseenFail : NoticeMessages.seenFail) + } + + dispatchNotice(notice) + } + + class func dispatchToggleFollowSiteMessage(post: ReaderPost, follow: Bool, success: Bool) { + let blogName = { + guard let blogNameForDisplay = post.blogNameForDisplay() else { + if let siteID = post.siteID, let postID = post.postID { + CrashLogging.main.logMessage("Expected blogNameForDisplay() to exist", + properties: ["siteID": siteID, "postID": postID], + level: .error) + } + return NoticeMessages.unknownSiteText + } + return blogNameForDisplay + }() + dispatchToggleFollowSiteMessage(siteTitle: blogName, siteID: post.siteID, follow: follow, success: success) + } + + class func dispatchToggleFollowSiteMessage(site: ReaderSiteTopic, follow: Bool, success: Bool) { + dispatchToggleFollowSiteMessage(siteTitle: site.title, siteID: site.siteID, follow: follow, success: success) + } + + class func dispatchToggleSubscribeCommentMessage(subscribing: Bool, success: Bool, actionHandler: ((Bool) -> Void)?) { + let title: String + let message: String? + let actionTitle: String? + if success { + title = subscribing ? NoticeMessages.commentFollowSuccess : NoticeMessages.commentUnfollowSuccess + message = subscribing ? NoticeMessages.commentFollowSuccessMessage : nil + actionTitle = subscribing ? NoticeMessages.commentFollowActionTitle : nil + } else { + title = subscribing ? NoticeMessages.commentFollowFail : NoticeMessages.commentUnfollowFail + message = nil + actionTitle = nil + } + dispatchNotice( + Notice( + title: title, + message: message, + actionTitle: actionTitle, + actionHandler: actionHandler + ) + ) + } + + class func dispatchToggleCommentNotificationMessage(subscribing: Bool, success: Bool) { + let action: ReaderHelpers.PostSubscriptionAction = subscribing ? .enableNotification : .disableNotification + dispatchNotice(Notice(title: noticeTitle(forAction: action, success: success))) + } + + class func dispatchToggleSubscribeCommentErrorMessage(subscribing: Bool) { + let title = subscribing ? NoticeMessages.commentFollowError : NoticeMessages.commentUnfollowError + dispatchNotice(Notice(title: title)) + } + + class func dispatchToggleFollowSiteMessage(siteTitle: String, siteID: NSNumber, follow: Bool, success: Bool) { + var notice: Notice + + if success { + notice = follow + ? followedSiteNotice(siteTitle: siteTitle, siteID: siteID) + : Notice(title: NoticeMessages.unfollowSuccess, message: siteTitle) + } else { + notice = Notice(title: follow ? NoticeMessages.followFail : NoticeMessages.unfollowFail) + } + + dispatchNotice(notice) + } + + class func dispatchToggleNotificationMessage(topic: ReaderSiteTopic, success: Bool) { + var notice: Notice { + if success { + return Notice(title: topic.isSubscribedForPostNotifications ? NoticeMessages.notificationOnSuccess : NoticeMessages.notificationOffSuccess) + } + return Notice(title: topic.isSubscribedForPostNotifications ? NoticeMessages.notificationOffFail : NoticeMessages.notificationOnFail) + } + + dispatchNotice(notice) + } + + class func dispatchSiteBlockedMessage(post: ReaderPost, success: Bool) { + var notice: Notice { + if success { + return Notice(title: NoticeMessages.blockSiteSuccess, message: post.blogNameForDisplay()) + } + return Notice(title: NoticeMessages.blockSiteFail, message: post.blogNameForDisplay()) + } + + dispatchNotice(notice) + } + + class func dispatchUserBlockedMessage(post: ReaderPost, success: Bool) { + var notice: Notice { + if success { + return Notice(title: NoticeMessages.blockUserSuccess, message: post.authorDisplayName ?? "") + } + return Notice(title: NoticeMessages.blockUserFail, message: post.authorDisplayName ?? "") + } + + dispatchNotice(notice) + } + + /// Enumerates the kind of actions available in relation to post subscriptions. + /// TODO: Add `followConversation` and `unfollowConversation` once the "Follow Conversation" feature flag is removed. + enum PostSubscriptionAction: Int { + case enableNotification + case disableNotification + } + + class func noticeTitle(forAction action: PostSubscriptionAction, success: Bool) -> String { + switch (action, success) { + case (.enableNotification, true): + return NSLocalizedString("In-app notifications enabled", comment: "The app successfully enabled notifications for the subscription") + case (.enableNotification, false): + return NSLocalizedString("Could not enable notifications", comment: "The app failed to enable notifications for the subscription") + case (.disableNotification, true): + return NSLocalizedString("In-app notifications disabled", comment: "The app successfully disabled notifications for the subscription") + case (.disableNotification, false): + return NSLocalizedString("Could not disable notifications", comment: "The app failed to disable notifications for the subscription") + } + } + + + + private class func dispatchNotice(_ notice: Notice) { + ActionDispatcher.dispatch(NoticeAction.post(notice)) + } + + private class func followedSiteNotice(siteTitle: String, siteID: NSNumber) -> Notice { + let notice = Notice(title: String(format: NoticeMessages.followSuccess, siteTitle), + message: NoticeMessages.enableNotifications, + actionTitle: NoticeMessages.enableButtonLabel) { _ in + let service = ReaderTopicService(coreDataStack: ContextManager.shared) + service.toggleSubscribingNotifications(for: siteID.intValue, subscribe: true, { + WPAnalytics.track(.readerListNotificationEnabled) + }) + } + + return notice + } + + private struct NoticeMessages { + static let seenFail = NSLocalizedString("Unable to mark post seen", comment: "Notice title when updating a post's seen status failed.") + static let unseenFail = NSLocalizedString("Unable to mark post unseen", comment: "Notice title when updating a post's unseen status failed.") + static let seenSuccess = NSLocalizedString("Marked post as seen", comment: "Notice title when updating a post's seen status succeeds.") + static let unseenSuccess = NSLocalizedString("Marked post as unseen", comment: "Notice title when updating a post's unseen status succeeds.") + static let followSuccess = NSLocalizedString("Following %1$@", comment: "Notice title when following a site succeeds. %1$@ is a placeholder for the site name.") + static let unfollowSuccess = NSLocalizedString("Unfollowed site", comment: "Notice title when unfollowing a site succeeds.") + static let followFail = NSLocalizedString("Unable to follow site", comment: "Notice title when following a site fails.") + static let unfollowFail = NSLocalizedString("Unable to unfollow site", comment: "Notice title when unfollowing a site fails.") + static let notificationOnFail = NSLocalizedString("Unable to turn on site notifications", comment: "Notice title when turning site notifications on fails.") + static let notificationOffFail = NSLocalizedString("Unable to turn off site notifications", comment: "Notice title when turning site notifications off fails.") + static let notificationOnSuccess = NSLocalizedString("Turned on site notifications", comment: "Notice title when turning site notifications on succeeds.") + static let notificationOffSuccess = NSLocalizedString("Turned off site notifications", comment: "Notice title when turning site notifications off succeeds.") + static let enableNotifications = NSLocalizedString("Enable site notifications?", comment: "Message prompting user to enable site notifications.") + static let enableButtonLabel = NSLocalizedString("Enable", comment: "Button title for the enable site notifications action.") + static let blockSiteSuccess = NSLocalizedString("Blocked site", comment: "Notice title when blocking a site succeeds.") + static let blockSiteFail = NSLocalizedString("Unable to block site", comment: "Notice title when blocking a site fails.") + static let blockUserSuccess = NSLocalizedString( + "Blocked user", + value: "Blocked user", + comment: "Notice title when blocking a user succeeds." + ) + static let blockUserFail = NSLocalizedString( + "reader.notice.user.blocked", + value: "reader.notice.user.block.failed", + comment: "Notice title when blocking a user fails." + ) + static let commentFollowSuccess = NSLocalizedString("Following this conversation", comment: "The app successfully subscribed to the comments for the post") + static let commentFollowSuccessMessage = NSLocalizedString("You'll get notifications in the app", comment: "The app successfully subscribed to the comments for the post") + static let commentFollowActionTitle = NSLocalizedString("Undo", comment: "Revert enabling notification after successfully subcribing to the comments for the post.") + static let commentUnfollowSuccess = NSLocalizedString("Successfully unfollowed conversation", comment: "The app successfully unsubscribed from the comments for the post") + static let commentFollowFail = NSLocalizedString("Unable to follow conversation", comment: "The app failed to subscribe to the comments for the post") + static let commentUnfollowFail = NSLocalizedString("Failed to unfollow conversation", comment: "The app failed to unsubscribe from the comments for the post") + static let commentFollowError = NSLocalizedString("Could not subscribe to comments", comment: "The app failed to subscribe to the comments for the post") + static let commentUnfollowError = NSLocalizedString("Could not unsubscribe from comments", comment: "The app failed to unsubscribe from the comments for the post") + static let unknownSiteText = NSLocalizedString( + "reader.notice.follow.site.unknown", + value: "this site", + comment: """ + A default value used to fill in the site name when the followed site somehow has missing site name or URL. + Example: given a notice format "Following %@" and empty site name, this will be "Following this site". + """ + ) + } +} + +/// Reader tab items +extension ReaderHelpers { + + static let defaultSavedItemPosition = 3 + + /// Sorts the default tabs according to the order [Following, Discover, Likes], and adds the Saved tab + class func rearrange(items: [ReaderTabItem]) -> [ReaderTabItem] { + + guard !items.isEmpty else { + return items + } + + var mutableItems = items + mutableItems.sort { + guard let leftTopic = $0.content.topic, let rightTopic = $1.content.topic else { + return true + } + + // first item: Following + if topicIsFollowing(leftTopic) { + return true + } + if topicIsFollowing(rightTopic) { + return false + } + + // second item: Discover + if topicIsDiscover(leftTopic) { + return true + } + if topicIsDiscover(rightTopic) { + return false + } + + // third item: Likes + if topicIsLiked(leftTopic) { + return true + } + if topicIsLiked(rightTopic) { + return false + } + + // any other items: sort them alphabetically, grouped by topic type + if leftTopic.type == rightTopic.type { + return leftTopic.title < rightTopic.title + } + + return true + } + + // fourth item: Saved. It's manually inserted after the sorting + let savedPosition = min(mutableItems.count, defaultSavedItemPosition) + mutableItems.insert(ReaderTabItem(ReaderContent(topic: nil, contentType: .saved)), at: savedPosition) + + // in case of log in with a self hosted site, prepend a 'dummy' Following tab + if !isLoggedIn() { + mutableItems.insert(ReaderTabItem(ReaderContent(topic: nil, contentType: .selfHostedFollowing)), at: 0) + } + + return mutableItems + } +} + + +/// Typed topic type +enum ReaderTopicType { + case discover + case following + case likes + case list + case search + case site + case tag + case organization + case noTopic +} + +@objc enum SiteOrganizationType: Int { + // site does not belong to an organization + case none + // site is an A8C P2 + case automattic + // site is a non-A8C P2 + case p2 } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderInterestsCoordinator.swift b/WordPress/Classes/ViewRelated/Reader/ReaderInterestsCoordinator.swift new file mode 100644 index 000000000000..2172efec1f4c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderInterestsCoordinator.swift @@ -0,0 +1,49 @@ +import Foundation + +class ReaderSelectInterestsCoordinator { + private let interestsService: ReaderFollowedInterestsService + private let userId: NSNumber? + + /// Creates a new instance of the coordinator + /// - Parameter service: An Optional `ReaderFollowedInterestsService` to use. If this is `nil` one will be created on the main context + /// - store: An optional backing store to keep track of if the user has seen the select interests view or not + /// - userId: The logged in user account, this makes sure the tracking is a per-user basis + init(service: ReaderFollowedInterestsService? = nil, + store: KeyValueDatabase = UserPersistentStoreFactory.instance(), + userId: NSNumber? = nil, + context: NSManagedObjectContext = ContextManager.sharedInstance().mainContext) { + + self.interestsService = service ?? ReaderTopicService(coreDataStack: ContextManager.shared) + self.userId = userId ?? { + return try? WPAccount.lookupDefaultWordPressComAccount(in: context)?.userID + }() + } + + // MARK: - Saving + public func saveInterests(interests: [RemoteReaderInterest], completion: @escaping (Bool) -> Void) { + let isLoggedIn = userId != nil + + interestsService.followInterests(interests, success: { _ in + completion(true) + + }, failure: { _ in + completion(false) + + }, isLoggedIn: isLoggedIn) + } + + // MARK: - Display Logic + + /// Determines whether or not the select interests view should be displayed + /// - Returns: true + public func isFollowingInterests(completion: @escaping (Bool) -> Void) { + interestsService.fetchFollowedInterestsLocally { followedInterests in + guard let interests = followedInterests else { + return + } + + let isFollowingInterests = interests.count > 0 + completion(isFollowingInterests) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderLikeAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderLikeAction.swift index f9d25f8080ea..a63602f792f0 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderLikeAction.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderLikeAction.swift @@ -8,11 +8,14 @@ final class ReaderLikeAction { ReaderHelpers.bumpPageViewForPost(post) UINotificationFeedbackGenerator().notificationOccurred(.success) } - let service = ReaderPostService(managedObjectContext: context) - service.toggleLiked(for: post, success: nil, failure: { (error: Error?) in + let service = ReaderPostService(coreDataStack: ContextManager.shared) + service.toggleLiked(for: post, success: { + completion?() + }, failure: { (error: Error?) in if let anError = error { DDLogError("Error (un)liking post: \(anError.localizedDescription)") } + completion?() }) } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderMenuAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderMenuAction.swift index 209b91b8fdf5..a53911cb109d 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderMenuAction.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderMenuAction.swift @@ -4,20 +4,37 @@ final class ReaderMenuAction { init(logged: Bool) { isLoggedIn = logged } - func execute(post: ReaderPost, context: NSManagedObjectContext, readerTopic: ReaderAbstractTopic?, anchor: UIView, vc: UIViewController) { - guard post.isFollowing else { - showMenuForPost(post, context: context, readerTopic: readerTopic, fromView: anchor, vc: vc) - return - } - let service = ReaderTopicService(managedObjectContext: context) - if let topic = service.findSiteTopic(withSiteID: post.siteID) { - showMenuForPost(post, context: context, topic: topic, readerTopic: readerTopic, fromView: anchor, vc: vc) - return - } + func execute(post: ReaderPost, + context: NSManagedObjectContext, + readerTopic: ReaderAbstractTopic? = nil, + anchor: UIView, + vc: UIViewController, + source: ReaderPostMenuSource, + followCommentsService: FollowCommentsService + ) { + self.execute(post: post, context: context, anchor: .view(anchor), vc: vc, source: source, followCommentsService: followCommentsService) } - fileprivate func showMenuForPost(_ post: ReaderPost, context: NSManagedObjectContext, topic: ReaderSiteTopic? = nil, readerTopic: ReaderAbstractTopic?, fromView anchorView: UIView, vc: UIViewController) { - ReaderShowMenuAction(loggedIn: isLoggedIn).execute(with: post, context: context, topic: topic, readerTopic: readerTopic, anchor: anchorView, vc: vc) + func execute(post: ReaderPost, + context: NSManagedObjectContext, + readerTopic: ReaderAbstractTopic? = nil, + anchor: ReaderShowMenuAction.PopoverAnchor, + vc: UIViewController, + source: ReaderPostMenuSource, + followCommentsService: FollowCommentsService + ) { + let siteTopic: ReaderSiteTopic? = post.isFollowing ? (try? ReaderSiteTopic.lookup(withSiteID: post.siteID, in: context)) : nil + + ReaderShowMenuAction(loggedIn: isLoggedIn).execute( + with: post, + context: context, + siteTopic: siteTopic, + readerTopic: readerTopic, + anchor: anchor, + vc: vc, + source: source, + followCommentsService: followCommentsService + ) } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderMenuItemCreator.swift b/WordPress/Classes/ViewRelated/Reader/ReaderMenuItemCreator.swift deleted file mode 100644 index 01c6a0f3e52c..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/ReaderMenuItemCreator.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// Interface abstracting the entities that create menu items for the different topics presented in Reader -protocol ReaderMenuItemCreator { - func supports(_ topic: ReaderAbstractTopic) -> Bool - func menuItem(with topic: ReaderAbstractTopic) -> ReaderMenuItem -} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderMenuViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderMenuViewController.swift deleted file mode 100644 index 13428afc4d83..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/ReaderMenuViewController.swift +++ /dev/null @@ -1,750 +0,0 @@ -import Foundation -import CocoaLumberjack -import Gridicons -import WordPressShared - - -/// The menu for the reader. -/// -@objc class ReaderMenuViewController: UITableViewController, UIViewControllerRestoration { - - @objc static let restorationIdentifier = "ReaderMenuViewController" - @objc static let selectedIndexPathRestorationIdentifier = "ReaderMenuSelectedIndexPathKey" - @objc static let currentReaderStreamIdentifier = "ReaderMenuCurrentStream" - - @objc let defaultCellIdentifier = "DefaultCellIdentifier" - @objc let actionCellIdentifier = "ActionCellIdentifier" - @objc let manageCellIdentifier = "ManageCellIdentifier" - - @objc var isSyncing = false - @objc var didSyncTopics = false - - @objc var currentReaderStream: ReaderStreamViewController? - - fileprivate var defaultIndexPath: IndexPath { - return viewModel.indexPathOfDefaultMenuItemWithOrder(order: .followed) - } - - fileprivate var restorableSelectedIndexPath: IndexPath? - - @objc lazy var viewModel: ReaderMenuViewModel = { - let sectionCreators: [ReaderMenuItemCreator] = [ - FollowingMenuItemCreator(), - DiscoverMenuItemCreator(), - LikedMenuItemCreator() - ] - - let vm = ReaderMenuViewModel(sectionCreators: sectionCreators) - vm.delegate = self - return vm - }() - - /// A convenience method for instantiating the controller. - /// - /// - Returns: An instance of the controller. - /// - @objc static func controller() -> ReaderMenuViewController { - return ReaderMenuViewController(style: .grouped) - } - - // MARK: - Restoration Methods - - - static func viewController(withRestorationIdentifierPath identifierComponents: [String], - coder: NSCoder) -> UIViewController? { - return WPTabBarController.sharedInstance().readerMenuViewController - } - - override func encodeRestorableState(with coder: NSCoder) { - coder.encode(restorableSelectedIndexPath, forKey: type(of: self).selectedIndexPathRestorationIdentifier) - coder.encode(currentReaderStream, forKey: type(of: self).currentReaderStreamIdentifier) - - super.encodeRestorableState(with: coder) - } - - override func decodeRestorableState(with coder: NSCoder) { - decodeRestorableSelectedIndexPathWithCoder(coder: coder) - decodeRestorableCurrentStreamWithCoder(coder: coder) - - super.decodeRestorableState(with: coder) - } - - fileprivate func decodeRestorableSelectedIndexPathWithCoder(coder: NSCoder) { - if let indexPath = coder.decodeObject(forKey: type(of: self).selectedIndexPathRestorationIdentifier) as? IndexPath { - restorableSelectedIndexPath = indexPath - } - } - - fileprivate func decodeRestorableCurrentStreamWithCoder(coder: NSCoder) { - if let currentStream = coder.decodeObject(forKey: type(of: self).currentReaderStreamIdentifier) as? ReaderStreamViewController { - currentReaderStream = currentStream - } - } - - // MARK: - Lifecycle Methods - - override init(style: UITableView.Style) { - super.init(style: style) - // Need to use `super` to work around a Swift compiler bug - // https://bugs.swift.org/browse/SR-3465 - super.restorationIdentifier = ReaderMenuViewController.restorationIdentifier - restorationClass = ReaderMenuViewController.self - - clearsSelectionOnViewWillAppear = false - - if restorableSelectedIndexPath == nil { - restorableSelectedIndexPath = defaultIndexPath - } - - setupRefreshControl() - setupAccountChangeNotificationObserver() - setupApplicationWillTerminateNotificationObserver() - } - - - required convenience init() { - self.init(style: .grouped) - } - - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - - override func viewDidLoad() { - super.viewDidLoad() - navigationItem.title = NSLocalizedString("Reader", comment: "Noun. Title of the Reader feature in the app.") - - configureTableView() - syncTopics() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - // We shouldn't show a selection if our split view is collapsed - if splitViewControllerIsHorizontallyCompact { - animateDeselectionInteractively() - - restorableSelectedIndexPath = defaultIndexPath - } - - reloadTableViewPreservingSelection() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - registerUserActivity() - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - reloadTableViewPreservingSelection() - } - - // MARK: - Configuration - - - @objc func setupRefreshControl() { - if refreshControl != nil { - return - } - - refreshControl = UIRefreshControl() - refreshControl?.addTarget(self, action: #selector(type(of: self).syncTopics), for: .valueChanged) - } - - - @objc func setupApplicationWillTerminateNotificationObserver() { - NotificationCenter.default.addObserver(self, - selector: #selector(handleApplicationWillTerminate), - name: UIApplication.willTerminateNotification, - object: nil) - } - - - @objc func setupAccountChangeNotificationObserver() { - NotificationCenter.default.addObserver(self, selector: #selector(handleAccountChanged), - name: .WPAccountDefaultWordPressComAccountChanged, - object: nil) - } - - - @objc func configureTableView() { - - tableView.register(WPTableViewCell.self, forCellReuseIdentifier: defaultCellIdentifier) - tableView.register(WPTableViewCell.self, forCellReuseIdentifier: actionCellIdentifier) - - WPStyleGuide.configureColors(view: view, tableView: tableView) - WPStyleGuide.configureAutomaticHeightRows(for: tableView) - } - - - // MARK: - Cleanup Methods - - - /// Clears the inUse flag from any topics or posts so marked. - /// - @objc func unflagInUseContent() { - let context = ContextManager.sharedInstance().mainContext - ReaderPostService(managedObjectContext: context).clearInUseFlags() - ReaderTopicService(managedObjectContext: context).clearInUseFlags() - } - - - /// Clean up topics that do not belong in the menu and posts that have no topic - /// This is merely a convenient place to perform this task. - /// - @objc func cleanupStaleContent(removeAllTopics removeAll: Bool) { - let context = ContextManager.sharedInstance().mainContext - ReaderPostService(managedObjectContext: context).deletePostsWithNoTopic() - - if removeAll { - ReaderTopicService(managedObjectContext: context).deleteAllTopics() - } else { - ReaderTopicService(managedObjectContext: context).deleteNonMenuTopics() - } - } - - /// Clears all saved posts, so they can be deleted by cleanup methods. - /// - func clearSavedPosts() { - let context = ContextManager.sharedInstance().mainContext - ReaderPostService(managedObjectContext: context).clearSavedPostFlags() - } - - // MARK: - Instance Methods - - - /// Handle the UIApplicationWillTerminate notification. - // - @objc func handleApplicationWillTerminate(_ notification: Foundation.Notification) { - // Its important to clean up stale content before unflagging, otherwise - // content we want to preserve for state restoration might also be - // deleted. - cleanupStaleContent(removeAllTopics: false) - unflagInUseContent() - } - - /// When logged out return the nav stack to the menu - /// - @objc func handleAccountChanged(_ notification: Foundation.Notification) { - // Reset the selected index path - restorableSelectedIndexPath = defaultIndexPath - - // Clean up obsolete content. - unflagInUseContent() - clearSavedPosts() - cleanupStaleContent(removeAllTopics: true) - - // Clean up stale search history - let context = ContextManager.sharedInstance().mainContext - ReaderSearchSuggestionService(managedObjectContext: context).deleteAllSuggestions() - - // Sync the menu fresh - syncTopics() - } - - /// Sync the Reader's menu and fetch followed site list - /// - @objc func syncTopics() { - if isSyncing { - return - } - - isSyncing = true - - let dispatchGroup = DispatchGroup() - let service = ReaderTopicService(managedObjectContext: ContextManager.sharedInstance().mainContext) - - dispatchGroup.enter() - service.fetchReaderMenu(success: { [weak self] in - self?.didSyncTopics = true - dispatchGroup.leave() - }, failure: { (error) in - dispatchGroup.leave() - DDLogError("Error syncing menu: \(String(describing: error))") - }) - - dispatchGroup.enter() - service.fetchFollowedSites(success: { - dispatchGroup.leave() - }, failure: { (error) in - dispatchGroup.leave() - DDLogError("Could not sync sites: \(String(describing: error))") - }) - - dispatchGroup.notify(queue: .main) { [weak self] in - self?.cleanupAfterSync() - } - } - - - /// Reset's state after a sync. - /// - @objc func cleanupAfterSync() { - refreshControl?.endRefreshing() - isSyncing = false - } - - - /// Presents the detail view controller for the specified post on the specified - /// blog. This is a convenience method for use with Notifications (for example). - /// - /// - Parameters: - /// - postID: The ID of the post on the specified blog. - /// - blogID: The ID of the blog. - /// - @objc func openPost(_ postID: NSNumber, onBlog blogID: NSNumber) { - showDetailViewController(viewControllerForPost(postID, siteID: blogID), sender: self) - } - - fileprivate func viewControllerForPost(_ postID: NSNumber, siteID: NSNumber) -> ReaderDetailViewController { - return ReaderDetailViewController.controllerWithPostID(postID, siteID: siteID) - } - - /// Presents the post list for the specified topic. - /// - /// - Parameters: - /// - topic: The topic to show. - /// - @objc func showPostsForTopic(_ topic: ReaderAbstractTopic) { - showDetailViewController(viewControllerForTopic(topic), sender: self) - } - - fileprivate func viewControllerForTopic(_ topic: ReaderAbstractTopic) -> ReaderStreamViewController { - return ReaderStreamViewController.controllerWithTopic(topic) - } - - /// Presents the reader's search view controller. - /// - fileprivate func viewControllerForSearch() -> ReaderSearchViewController { - return ReaderSearchViewController.controller() - } - - /// Present the Discover stream as a Site stream. - /// - private func viewControllerForDiscover() -> ReaderStreamViewController { - return ReaderStreamViewController.controllerWithSiteID(ReaderHelpers.discoverSiteID, isFeed: false) - } - - /// Presents the view controller for a default menu item - func showSectionForDefaultMenuItem(withOrder order: ReaderDefaultMenuItemOrder, - animated: Bool) { - let indexPath = viewModel.indexPathOfDefaultMenuItemWithOrder(order: order) - - showViewController(for: indexPath, animated: animated) - } - - /// Presents the saved for later view controller - @objc func showSavedForLater() { - guard let indexPath = viewModel.indexPathOfSavedForLater(), - let menuItem = viewModel.menuItemAtIndexPath(indexPath), - let viewController = viewControllerForMenuItem(menuItem) else { - return - } - - tableView.selectRow(at: indexPath, animated: false, scrollPosition: .middle) - restorableSelectedIndexPath = indexPath - - showDetailViewController(viewController, sender: self) - } - - fileprivate func viewControllerForSavedPosts() -> ReaderSavedPostsViewController { - return ReaderSavedPostsViewController() - } - - /// Presents a team view controller - func showSectionForTeam(withSlug slug: String, animated: Bool) { - guard let indexPath = viewModel.indexPathOfTeam(withSlug: slug) else { - return - } - - showViewController(for: indexPath, animated: animated) - } - - private func showViewController(for indexPath: IndexPath, - animated: Bool) { - guard let menuItem = viewModel.menuItemAtIndexPath(indexPath), - let viewController = viewControllerForMenuItem(menuItem) else { - return - } - - let actions = { - self.tableView.selectRow(at: indexPath, animated: false, scrollPosition: .middle) - self.restorableSelectedIndexPath = indexPath - - self.showDetailViewController(viewController, sender: self) - } - - if animated { - actions() - } else { - UIView.performWithoutAnimation(actions) - } - } - - /// Presents a new view controller for subscribing to a new tag. - /// - @objc func showAddTag() { - let placeholder = NSLocalizedString("Add any tag", comment: "Placeholder text. A call to action for the user to type any tag to which they would like to subscribe.") - let controller = SettingsTextViewController(text: nil, placeholder: placeholder, hint: nil) - controller.title = NSLocalizedString("Add a Tag", comment: "Title of a feature to add a new tag to the tags subscribed by the user.") - controller.onValueChanged = { value in - if value.trim().count > 0 { - self.followTagNamed(value.trim()) - } - } - controller.mode = .lowerCaseText - controller.displaysActionButton = true - controller.actionText = NSLocalizedString("Add Tag", comment: "Button Title. Tapping subscribes the user to a new tag.") - controller.onActionPress = { - self.dismissModal() - } - - let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(ReaderMenuViewController.dismissModal)) - controller.navigationItem.leftBarButtonItem = cancelButton - - let navController = UINavigationController(rootViewController: controller) - navController.modalPresentationStyle = .formSheet - - present(navController, animated: true) - } - - - /// Dismisses a presented view controller. - /// - @objc func dismissModal() { - dismiss(animated: true) - } - - func deselectSelectedRow(animated: Bool) { - tableView.deselectSelectedRowWithAnimation(animated) - restorableSelectedIndexPath = defaultIndexPath - } - - // MARK: - Tag Wrangling - - - /// Prompts the user to confirm unfolowing a tag. - /// - /// - Parameters: - /// - topic: The tag topic that is to be unfollowed. - /// - @objc func promptUnfollowTagTopic(_ topic: ReaderTagTopic) { - let title = NSLocalizedString("Remove", comment: "Title of a prompt asking the user to confirm they no longer wish to subscribe to a certain tag.") - let template = NSLocalizedString("Are you sure you wish to remove the tag '%@'?", comment: "A short message asking the user if they wish to unfollow the specified tag. The %@ is a placeholder for the name of the tag.") - let message = String(format: template, topic.title) - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addCancelActionWithTitle(NSLocalizedString("Cancel", comment: "Title of a cancel button.")) { (action) in - self.tableView.setEditing(false, animated: true) - } - alert.addDestructiveActionWithTitle(NSLocalizedString("Remove", comment: "Verb. Button title. Unfollows / unsubscribes the user from a topic in the reader.")) { (action) in - self.unfollowTagTopic(topic) - } - alert.presentFromRootViewController() - } - - - /// Tells the ReaderTopicService to unfollow the specified topic. - /// - /// - Parameters: - /// - topic: The tag topic that is to be unfollowed. - /// - @objc func unfollowTagTopic(_ topic: ReaderTagTopic) { - let service = ReaderTopicService(managedObjectContext: ContextManager.sharedInstance().mainContext) - service.unfollowTag(topic, withSuccess: nil) { (error) in - DDLogError("Could not unfollow topic \(topic), \(String(describing: error))") - - let title = NSLocalizedString("Could Not Remove Tag", comment: "Title of a prompt informing the user there was a probem unsubscribing from a tag in the reader.") - let message = error?.localizedDescription - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addCancelActionWithTitle(NSLocalizedString("OK", comment: "Button title. An acknowledgement of the message displayed in a prompt.")) - alert.presentFromRootViewController() - } - } - - - /// Follow a new tag with the specified tag name. - /// - /// - Parameters: - /// - tagName: The name of the tag to follow. - /// - @objc func followTagNamed(_ tagName: String) { - let service = ReaderTopicService(managedObjectContext: ContextManager.sharedInstance().mainContext) - - let generator = UINotificationFeedbackGenerator() - generator.prepare() - - service.followTagNamed(tagName, withSuccess: { [weak self] in - generator.notificationOccurred(.success) - - // A successful follow makes the new tag the currentTopic. - if let tag = service.currentTopic as? ReaderTagTopic { - self?.scrollToTag(tag) - } - - }, failure: { (error) in - DDLogError("Could not follow tag named \(tagName) : \(String(describing: error))") - - generator.notificationOccurred(.error) - - let title = NSLocalizedString("Could Not Follow Tag", comment: "Title of a prompt informing the user there was a probem unsubscribing from a tag in the reader.") - let message = error?.localizedDescription - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - alert.addCancelActionWithTitle(NSLocalizedString("OK", comment: "Button title. An acknowledgement of the message displayed in a prompt.")) - alert.presentFromRootViewController() - }) - } - - - /// Scrolls the tableView so the specified tag is in view. - /// - /// - Paramters: - /// - tag: The tag to scroll into view. - /// - @objc func scrollToTag(_ tag: ReaderTagTopic) { - guard let indexPath = viewModel.indexPathOfTag(tag) else { - return - } - - tableView.flashRowAtIndexPath(indexPath, scrollPosition: .middle, completion: { - if !self.splitViewControllerIsHorizontallyCompact { - self.tableView(self.tableView, didSelectRowAt: indexPath) - } - }) - } - - - // MARK: - TableView Delegate Methods - - - override func numberOfSections(in tableView: UITableView) -> Int { - return viewModel.numberOfSectionsInMenu() - } - - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.numberOfItemsInSection(section) - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - return viewModel.titleForSection(section) - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let menuItem = viewModel.menuItemAtIndexPath(indexPath) - if menuItem?.type == .addItem { - let cell = tableView.dequeueReusableCell(withIdentifier: actionCellIdentifier)! - configureActionCell(cell, atIndexPath: indexPath) - return cell - } - - let cell = tableView.dequeueReusableCell(withIdentifier: defaultCellIdentifier)! - configureCell(cell, atIndexPath: indexPath) - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let menuItem = viewModel.menuItemAtIndexPath(indexPath) else { - return - } - - if menuItem.type == .search { - QuickStartTourGuide.find()?.visited(.readerSearch) - } - - if menuItem.type == .addItem { - tableView.deselectSelectedRowWithAnimation(true) - showAddTag() - return - } - - if menuItem.type == .savedPosts { - trackSavedPostsNavigation() - } - - restorableSelectedIndexPath = indexPath - - if let viewController = viewControllerForMenuItem(menuItem) { - showDetailViewController(viewController, sender: self) - } - } - - fileprivate func viewControllerForMenuItem(_ menuItem: ReaderMenuItem) -> UIViewController? { - - if let topic = menuItem.topic, ReaderHelpers.topicIsDiscover(topic) { - return viewControllerForDiscover() - } - - if let topic = menuItem.topic { - currentReaderStream = viewControllerForTopic(topic) - return currentReaderStream - } - - if menuItem.type == .search { - currentReaderStream = nil - return viewControllerForSearch() - } - - if menuItem.type == .savedPosts { - currentReaderStream = nil - return viewControllerForSavedPosts() - } - - return nil - } - - - @objc func configureCell(_ cell: UITableViewCell, atIndexPath indexPath: IndexPath) { - guard let menuItem = viewModel.menuItemAtIndexPath(indexPath) else { - return - } - - WPStyleGuide.configureTableViewCell(cell) - cell.accessoryView = nil - cell.accessoryType = (splitViewControllerIsHorizontallyCompact) ? .disclosureIndicator : .none - if menuItem.type == .search && QuickStartTourGuide.find()?.isCurrentElement(.readerSearch) ?? false { - cell.accessoryView = QuickStartSpotlightView() - } - - cell.selectionStyle = .default - cell.textLabel?.text = menuItem.title - cell.imageView?.tintColor = .listIcon - cell.imageView?.image = menuItem.icon?.withRenderingMode(.alwaysTemplate) - } - - - @objc func configureActionCell(_ cell: UITableViewCell, atIndexPath indexPath: IndexPath) { - guard let menuItem = viewModel.menuItemAtIndexPath(indexPath) else { - return - } - - WPStyleGuide.configureTableViewActionCell(cell) - - if cell.accessoryView == nil { - let image = Gridicon.iconOfType(.plus) - let imageView = UIImageView(image: image) - imageView.tintColor = .primary - cell.accessoryView = imageView - } - - cell.selectionStyle = .default - cell.imageView?.image = menuItem.icon - cell.imageView?.tintColor = .primary - cell.textLabel?.text = menuItem.title - } - - - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - if !ReaderHelpers.isLoggedIn() { - return false - } - - guard let menuItem = viewModel.menuItemAtIndexPath(indexPath) else { - return false - } - - guard let topic = menuItem.topic else { - return false - } - - return ReaderHelpers.isTopicTag(topic) - } - - - override func tableView(_ tableView: UITableView, - commit editingStyle: UITableViewCell.EditingStyle, - forRowAt indexPath: IndexPath) { - guard let menuItem = viewModel.menuItemAtIndexPath(indexPath) else { - return - } - - guard let topic = menuItem.topic as? ReaderTagTopic else { - return - } - - promptUnfollowTagTopic(topic) - } - - - override func tableView(_ tableView: UITableView, - editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - return .delete - } - - - override func tableView(_ tableView: UITableView, titleForDeleteConfirmationButtonForRowAt indexPath: IndexPath) -> String? { - return NSLocalizedString("Remove", comment: "Label of the table view cell's delete button, when unfollowing tags.") - } -} - - -extension ReaderMenuViewController: ReaderMenuViewModelDelegate { - - @objc func menuDidReloadContent() { - reloadTableViewPreservingSelection() - } - - @objc func menuSectionDidChangeContent(_ index: Int) { - reloadTableViewPreservingSelection() - } - - @objc func reloadTableViewPreservingSelection() { - let selectedIndexPath = restorableSelectedIndexPath - - tableView.reloadData() - - // Show the current selection if our split view isn't collapsed - if !splitViewControllerIsHorizontallyCompact { - tableView.selectRow(at: selectedIndexPath, - animated: false, scrollPosition: .none) - } - } - -} - -extension ReaderMenuViewController: WPSplitViewControllerDetailProvider { - func initialDetailViewControllerForSplitView(_ splitView: WPSplitViewController) -> UIViewController? { - if restorableSelectedIndexPath == defaultIndexPath { - let service = ReaderTopicService(managedObjectContext: ContextManager.sharedInstance().mainContext) - if let topic = service.topicForFollowedSites() { - return viewControllerForTopic(topic) - } else { - restorableSelectedIndexPath = IndexPath(row: 0, section: 0) - if let item = viewModel.menuItemAtIndexPath(restorableSelectedIndexPath!) { - return viewControllerForMenuItem(item) - } - } - } - - return nil - } -} - -// MARK: - SearchableActivity Conformance - -extension ReaderMenuViewController: SearchableActivityConvertable { - var activityType: String { - return WPActivityType.reader.rawValue - } - - var activityTitle: String { - return NSLocalizedString("Reader", comment: "Title of the 'Reader' tab - used for spotlight indexing on iOS.") - } - - var activityKeywords: Set? { - let keyWordString = NSLocalizedString("wordpress, reader, articles, posts, blog post, followed, discover, likes, my likes, tags, topics", - comment: "This is a comma separated list of keywords used for spotlight indexing of the 'Reader' tab.") - let keywordArray = keyWordString.arrayOfTags() - - guard !keywordArray.isEmpty else { - return nil - } - - return Set(keywordArray) - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderMenuViewModel.swift b/WordPress/Classes/ViewRelated/Reader/ReaderMenuViewModel.swift deleted file mode 100644 index 41ae75c59550..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/ReaderMenuViewModel.swift +++ /dev/null @@ -1,784 +0,0 @@ -import Foundation -import CocoaLumberjack -import Gridicons - -// FIXME: comparison operators with optionals were removed from the Swift Standard Libary. -// Consider refactoring the code to use the non-optional operators. -fileprivate func < (lhs: T?, rhs: T?) -> Bool { - switch (lhs, rhs) { - case let (l?, r?): - return l < r - case (nil, _?): - return true - default: - return false - } -} - -// FIXME: comparison operators with optionals were removed from the Swift Standard Libary. -// Consider refactoring the code to use the non-optional operators. -fileprivate func > (lhs: T?, rhs: T?) -> Bool { - switch (lhs, rhs) { - case let (l?, r?): - return l > r - default: - return rhs < lhs - } -} - - - -/// Enum of the sections shown in the reader. -/// -enum ReaderMenuSectionType: Int { - case defaults - case teams - case lists - case tags -} - - -/// Enum of the types of row items shown in the reader. -/// -enum ReaderMenuItemType: Int { - case topic - case search - case recommended - case addItem - case savedPosts -} - - -/// Represents a section in the Reader's menu. -/// -struct ReaderMenuSection { - let title: String - let type: ReaderMenuSectionType -} - - -/// Represents an row, or menu item in a reader menu section. -/// -struct ReaderMenuItem { - let title: String - let type: ReaderMenuItemType - // A custom icon for the menu item. - var icon: UIImage? - // The corresponding topic for the item, if there is one (e.g. Search does not have a topic) - var topic: ReaderAbstractTopic? - // The order of the item if a custom order is used by the section. - var order: Int = 0 - - - init(title: String, type: ReaderMenuItemType, icon: UIImage?, topic: ReaderAbstractTopic?) { - self.title = title - self.type = type - self.icon = icon - self.topic = topic - } - - - init(title: String, type: ReaderMenuItemType) { - self.title = title - self.type = type - } -} - -extension ReaderMenuItem: Comparable { - static func == (lhs: ReaderMenuItem, rhs: ReaderMenuItem) -> Bool { - return lhs.order == rhs.order && lhs.title == rhs.title - } - - static func < (lhs: ReaderMenuItem, rhs: ReaderMenuItem) -> Bool { - if lhs.order < rhs.order { - return true - } - - if lhs.order > rhs.order { - return false - } - - if lhs.title < rhs.title { - return true - } - - return false - } -} - - -/// Protocol allowing a reader menu view model to notify content changes. -/// -protocol ReaderMenuViewModelDelegate: class { - - /// Notifies the delegate that the menu did reload its content. - /// - func menuDidReloadContent() - - - /// Notifies the delegate that the content of the specified section has changed. - /// - /// - Parameters: - /// - index: The index of the section. - /// - func menuSectionDidChangeContent(_ index: Int) -} - - -/// Defines the preferred order of items in the default section. -/// -enum ReaderDefaultMenuItemOrder: Int { - case followed - case discover - case search - case recommendations - case likes - case savedForLater - case other -} - - -/// The view model used by the reader. -/// -@objc class ReaderMenuViewModel: NSObject { - @objc var defaultsFetchedResultsController: NSFetchedResultsController! - @objc var teamsFetchedResultsController: NSFetchedResultsController! - @objc var listsFetchedResultsController: NSFetchedResultsController! - @objc var tagsFetchedResultsController: NSFetchedResultsController! - - var sections = [ReaderMenuSection]() - var defaultSectionItems = [ReaderMenuItem]() - weak var delegate: ReaderMenuViewModelDelegate? - - private let sectionCreators: [ReaderMenuItemCreator] - - private enum Strings { - static let savedForLaterMenuTitle = NSLocalizedString("Saved Posts", comment: "Section title for Saved Posts in Reader") - } - - // MARK: - Lifecycle Methods - - init(sectionCreators: [ReaderMenuItemCreator]) { - self.sectionCreators = sectionCreators - super.init() - listenForWordPressAccountChanged() - setupResultsControllers() - setupSections() - } - - - @objc func listenForWordPressAccountChanged() { - NotificationCenter.default.addObserver(self, selector: #selector(ReaderMenuViewModel.handleWordPressComAccountChanged(_:)), name: NSNotification.Name.WPAccountDefaultWordPressComAccountChanged, object: nil) - } - - - // MARK: - Setup - - - /// Sets up the results controllers. - /// - @objc func setupResultsControllers() { - setupDefaultResultsController() - setupTeamsResultsController() - setupListResultsController() - setupTagsResultsController() - } - - - /// Sets up the sections. This should be called only once during init, or when - /// the user signs in / out of wpcom. - /// Call the section setup methods in the order the sections should appear - /// in the menu. - /// - @objc func setupSections() { - // Clear anything that's cached. - sections.removeAll() - - // Rebuild! - setupDefaultsSection() - - if ReaderHelpers.isLoggedIn() && teamsFetchedResultsController.fetchedObjects?.count > 0 { - setupTeamsSection() - } - - if ReaderHelpers.isLoggedIn() && listsFetchedResultsController.fetchedObjects?.count > 0 { - setupListsSection() - } - - setupTagsSection() - } - - - // MARK: - Default Section - - - /// Sets up the default fetched results controller. - /// - @objc func setupDefaultResultsController() { - let fetchRequest = NSFetchRequest(entityName: "ReaderDefaultTopic") - let sortDescriptor = NSSortDescriptor(key: "title", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare)) - fetchRequest.sortDescriptors = [sortDescriptor] - - defaultsFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, - managedObjectContext: ContextManager.sharedInstance().mainContext, - sectionNameKeyPath: nil, - cacheName: nil) - defaultsFetchedResultsController.delegate = self - - do { - let _ = try defaultsFetchedResultsController.performFetch() - } catch { - DDLogError("There was a problem fetching default topics for the menu.") - assertionFailure("There was a problem fetching default topics.") - } - } - - - /// Sets up the defaults section and its corresponding NSFetchedResultsController. - /// - @objc func setupDefaultsSection() { - let section = ReaderMenuSection(title: NSLocalizedString("Streams", comment: "Section title of the default reader items."), type: .defaults) - sections.append(section) - - buildDefaultSectionItems() - } - - - /// Builds (or rebuilds) the items for the default menu section. - /// Since the default section shows items representing things other than topics, - /// we construct and cache menu items in an array with the desired item order. - /// - @objc func buildDefaultSectionItems() { - defaultSectionItems.removeAll() - - // Create menu items from the fetched results - if let fetchedObjects = defaultsFetchedResultsController.fetchedObjects { - for topic in fetchedObjects { - guard let abstractTopic = topic as? ReaderAbstractTopic else { - continue - } - - let item = sectionCreator(for: abstractTopic).menuItem(with: abstractTopic) - - defaultSectionItems.append(item) - } - } - - defaultSectionItems.append(searchMenuItem()) - - defaultSectionItems.append(savedPostsMenuItem()) - - // Sort the items ascending. - defaultSectionItems.sort(by: <) - } - - - /// Selects and returns the entity responsible for creating a menu item for a given topic - /// - private func sectionCreator(for topic: ReaderAbstractTopic) -> ReaderMenuItemCreator { - return sectionCreators.filter { - $0.supports(topic) - }.first ?? OtherMenuItemCreator() - } - - /// Returns the menu item to use for the reader search - /// - func searchMenuItem() -> ReaderMenuItem { - return SearchMenuItemCreator().menuItem() - } - - /// Returns the menu item to use for the reader search - /// - func savedPostsMenuItem() -> ReaderMenuItem { - return SavedForLaterMenuItemCreator().menuItem() - } - - - /// Returns the number of items for the default section. - /// - /// - Returns: The number of items in the section. - /// - @objc func itemCountForDefaultSection() -> Int { - return defaultSectionItems.count - } - - - /// Returns the menu item from the default section at the specified index. - /// - /// - Parameters: - /// - index: The index of the item. - /// - /// - Returns: The requested menu item or nil. - /// - func menuItemForDefaultAtIndex(_ index: Int) -> ReaderMenuItem? { - return defaultSectionItems[index] - } - - - // MARK: - Teams Section - - - /// Sets up the teams section. - /// - @objc func setupTeamsSection() { - let section = ReaderMenuSection(title: NSLocalizedString("Teams", comment: "Section title of the teams reader section."), type: .teams) - sections.append(section) - } - - - /// Sets up the teams fetched results controller. - /// - @objc func setupTeamsResultsController() { - let fetchRequest = NSFetchRequest(entityName: "ReaderTeamTopic") - let sortDescriptor = NSSortDescriptor(key: "title", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare)) - fetchRequest.sortDescriptors = [sortDescriptor] - - teamsFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, - managedObjectContext: ContextManager.sharedInstance().mainContext, - sectionNameKeyPath: nil, - cacheName: nil) - teamsFetchedResultsController.delegate = self - - do { - let _ = try teamsFetchedResultsController.performFetch() - } catch { - DDLogError("There was a problem fetching team topics for the menu.") - assertionFailure("There was a problem fetching team topics.") - } - } - - - /// Returns the number of items for the teams section. - /// - /// - Returns: The number of items in the section. - /// - @objc func itemCountForTeamsSection() -> Int { - return teamsFetchedResultsController.fetchedObjects?.count ?? 0 - } - - - /// Returns the menu item from the teams section at the specified index. - /// - /// - Parameters: - /// - index: The index of the item. - /// - /// - Returns: The requested menu item or nil. - /// - func menuItemForTeamsAtIndex(_ index: Int) -> ReaderMenuItem? { - guard let topic = teamsFetchedResultsController.object(at: IndexPath(row: index, section: 0)) as? ReaderTeamTopic else { - return nil - } - - return ReaderMenuItem(title: topic.title, type: .topic, icon: topic.icon, topic: topic) - } - - - // MARK: - List Section - - - /// Sets up the list fetched results controller - /// - @objc func setupListResultsController() { - let fetchRequest = NSFetchRequest(entityName: "ReaderListTopic") - let sortDescriptor = NSSortDescriptor(key: "title", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare)) - fetchRequest.sortDescriptors = [sortDescriptor] - - listsFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, - managedObjectContext: ContextManager.sharedInstance().mainContext, - sectionNameKeyPath: nil, - cacheName: nil) - listsFetchedResultsController.delegate = self - - updateAndPerformListsFetchRequest() - } - - - /// Updates the lists results controller's fetch request predicate and performs a new fetch - /// - @objc func updateAndPerformListsFetchRequest() { - listsFetchedResultsController.fetchRequest.predicate = predicateForRequests() - do { - let _ = try listsFetchedResultsController.performFetch() - } catch { - DDLogError("There was a problem fetching list topics for the menu.") - assertionFailure("There was a problem fetching list topics.") - } - } - - - /// Sets up the lists section and its corresponding NSFetchedResultsController. - /// - @objc func setupListsSection() { - let section = ReaderMenuSection(title: NSLocalizedString("Lists", comment: "Section title of the lists reader section."), type: .lists) - sections.append(section) - } - - - /// Returns the number of items for the lists section. - /// - /// - Returns: The number of items in the section. - /// - @objc func itemCountForListSection() -> Int { - return listsFetchedResultsController.fetchedObjects?.count ?? 0 - } - - - /// Returns the menu item from the lists section at the specified index. - /// - /// - Parameters: - /// - index: The index of the item. - /// - /// - Returns: The requested menu item or nil. - /// - func menuItemForListAtIndex(_ index: Int) -> ReaderMenuItem? { - guard let topic = listsFetchedResultsController.object(at: IndexPath(row: index, section: 0)) as? ReaderAbstractTopic else { - return nil - } - return ReaderMenuItem(title: topic.title, type: .topic, icon: nil, topic: topic) - } - - - // MARK: - Tags Section - - - /// Sets up the tags fetched results controller - /// - @objc func setupTagsResultsController() { - let fetchRequest = NSFetchRequest(entityName: "ReaderTagTopic") - fetchRequest.predicate = predicateForRequests() - let sortDescriptor = NSSortDescriptor(key: "title", ascending: true, selector: #selector(NSString.localizedCaseInsensitiveCompare)) - fetchRequest.sortDescriptors = [sortDescriptor] - - tagsFetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, - managedObjectContext: ContextManager.sharedInstance().mainContext, - sectionNameKeyPath: nil, - cacheName: nil) - tagsFetchedResultsController.delegate = self - - do { - let _ = try tagsFetchedResultsController.performFetch() - } catch { - DDLogError("There was a problem fetching tag topics for the menu.") - assertionFailure("There was a problem fetching tag topics.") - } - } - - - /// Updates the lists results controller's fetch request predicate and performs a new fetch - /// - @objc func updateAndPerformTagsFetchRequest() { - tagsFetchedResultsController.fetchRequest.predicate = predicateForRequests() - do { - let _ = try tagsFetchedResultsController.performFetch() - } catch { - DDLogError("There was a problem fetching tag topics for the menu.") - assertionFailure("There was a problem fetching tag topics.") - } - } - - - /// Sets up the tags section and its corresponding NSFetchedResultsController. - /// - @objc func setupTagsSection() { - let section = ReaderMenuSection(title: NSLocalizedString("Tags", comment: "Section title of the tags reader section."), type: .tags) - sections.append(section) - } - - - /// Returns the number of items for the tags section. - /// - /// - Returns: The number of items in the section. - /// - @objc func itemCountForTagSection() -> Int { - var count = tagsFetchedResultsController.fetchedObjects?.count ?? 0 - if ReaderHelpers.isLoggedIn() { - // The first time for a logged in user will be an "AddItem" type, so increase the count by 1. - count += 1 - } - return count - } - - - /// Returns the menu item from the tags section at the specified index. - /// - /// - Parameters: - /// - index: The index of the item. - /// - /// - Returns: The requested menu item or nil. - /// - func menuItemForTagAtIndex(_ index: Int) -> ReaderMenuItem? { - var fetchedIndex = index - if ReaderHelpers.isLoggedIn() { - if fetchedIndex == 0 { - let title = NSLocalizedString("Add a Tag", comment: "Title. Lets the user know that they can use this feature to subscribe to new tags.") - return ReaderMenuItem(title: title, type: .addItem) - } else { - // Adjust the index by one to account for AddItem - fetchedIndex -= 1 - } - } - - guard let topic = tagsFetchedResultsController.object(at: IndexPath(row: fetchedIndex, section: 0)) as? ReaderAbstractTopic else { - return nil - } - return ReaderMenuItem(title: topic.title, type: .topic, icon: nil, topic: topic) - } - - - // MARK: - Helper Methods - - - /// Returns the predicate for tag and list fetch requests. - /// - /// - Returns: An NSPredicate - /// - @objc func predicateForRequests() -> NSPredicate { - if ReaderHelpers.isLoggedIn() { - return NSPredicate(format: "following = YES AND showInMenu = YES") - } else { - return NSPredicate(format: "following = NO AND showInMenu = YES") - } - } - - - // MARK: - Notifications - - - /// Handles a notification that the signed in wpcom account was changed. - /// All the content in the view model is updated as a result. - /// - @objc func handleWordPressComAccountChanged(_ notification: Foundation.Notification) { - // Update predicates to correctly fetch following or not following. - updateAndPerformListsFetchRequest() - updateAndPerformTagsFetchRequest() - - setupSections() - delegate?.menuDidReloadContent() - } - - - // MARK: - View Model Interaction Methods - - - /// Returns the number of sections the menu should show. - /// - /// - Returns: The number of sections. - /// - @objc func numberOfSectionsInMenu() -> Int { - return sections.count - } - - - /// Retuns the index of the section with the specified type. - /// - /// - Paramters: - /// - type: The type of the section. - /// - /// - Return: The index of the section or nil if it was not found. - /// - func indexOfSectionWithType(_ type: ReaderMenuSectionType) -> Int? { - for (index, section) in sections.enumerated() { - if section.type == type { - return index - } - } - return nil - } - - - /// Returns the number of items in the specified section. - /// - /// - Parameters: - /// - index: The index of the section. - /// - /// - Returns: The number of rows in the section. - /// - @objc func numberOfItemsInSection(_ index: Int) -> Int { - let section = sections[index] - - switch section.type { - case .defaults: - return itemCountForDefaultSection() - - case .teams: - return itemCountForTeamsSection() - - case .lists: - return itemCountForListSection() - - case .tags: - return itemCountForTagSection() - } - } - - - /// Returns the title for the specified section - /// - /// - Parameters: - /// - index: The index of the section. - /// - /// - Returns: The title of the section. - /// - @objc func titleForSection(_ index: Int) -> String { - return sections[index].title - } - - - /// Returns the type for the specified section - /// - /// - Parameters: - /// - index: The index of the section. - /// - /// - Returns: The type of the section. - /// - func typeOfSection(_ index: Int) -> ReaderMenuSectionType { - return sections[index].type - } - - - /// Returns the section info for the specified section - /// - /// - Parameters: - /// - index: The index of the section. - /// - /// - Returns: The section info. - /// - func sectionInfoAtIndex(_ index: Int) -> ReaderMenuSection { - return sections[index] - } - - - /// Returns the menu item for the specified section and row - /// - /// - Parameters: - /// - index: The indexPath of the item. - /// - /// - Returns: The menu item for the specified index path. - /// - func menuItemAtIndexPath(_ indexPath: IndexPath) -> ReaderMenuItem? { - let section = sections[indexPath.section] - - switch section.type { - case .defaults: - return menuItemForDefaultAtIndex(indexPath.row) - - case .teams: - return menuItemForTeamsAtIndex(indexPath.row) - - case .lists: - return menuItemForListAtIndex(indexPath.row) - - case .tags: - return menuItemForTagAtIndex(indexPath.row) - } - } - - - /// Get the indexPath of a specific item in the default menu section - /// e.g. Discover. - /// - /// - Parameter order: The ReaderDefaultMenuItemOrder representing the item - /// to find. - /// - /// - Returns: An NSIndexPath representing the item. - /// - func indexPathOfDefaultMenuItemWithOrder(order: ReaderDefaultMenuItemOrder) -> IndexPath { - if let sectionIndex = indexOfSectionWithType(.defaults) { - for (index, item) in defaultSectionItems.enumerated() { - if item.order == order.rawValue { - return IndexPath(row: index, section: sectionIndex) - } - } - } - - return IndexPath(row: order.rawValue, section: ReaderMenuSectionType.defaults.rawValue) - } - - /// Get the indexPath of the specified tag - /// - /// - Parameters: - /// tag: The tag topic to find. - /// - /// - Returns: An NSIndexPath optional. - /// - @objc func indexPathOfTag(_ tag: ReaderTagTopic) -> IndexPath? { - if let indexPath = tagsFetchedResultsController.indexPath(forObject: tag) { - var row = indexPath.row - if ReaderHelpers.isLoggedIn() { - row += 1 - } - return IndexPath(row: row, section: indexOfSectionWithType(.tags)!) - } - return nil - } - - func indexPathOfTeam(withSlug slug: String) -> IndexPath? { - guard let teams = teamsFetchedResultsController.fetchedObjects as? [ReaderTeamTopic], - let section = indexOfSectionWithType(.teams) else { - return nil - } - - for (index, team) in teams.enumerated() { - if team.slug == slug { - return IndexPath(row: index, section: section) - } - } - - return nil - } - - func indexPathOfSavedForLater() -> IndexPath? { - if let sectionIndex = indexOfSectionWithType(.defaults) { - for (index, item) in defaultSectionItems.enumerated() { - if item.type == .savedPosts { - return IndexPath(row: index, section: sectionIndex) - } - } - } - - return nil - } -} - - -extension ReaderMenuViewModel: NSFetchedResultsControllerDelegate { - - func controllerDidChangeContent(_ controller: NSFetchedResultsController) { - var section: Int? - if controller == defaultsFetchedResultsController { - // Rebuild the defaults section since its source content changed. - buildDefaultSectionItems() - if let index = indexOfSectionWithType(.defaults) { - section = index - } - - } else if controller == teamsFetchedResultsController { - if let index = indexOfSectionWithType(.teams) { - section = index - } - - } else if controller == listsFetchedResultsController { - if let index = indexOfSectionWithType(.lists) { - section = index - } - - } else if controller == tagsFetchedResultsController { - if let index = indexOfSectionWithType(.tags) { - section = index - } - } - - if let section = section { - delegate?.menuSectionDidChangeContent(section) - - } else { - // One of the results controllers updated its content but that controller is not currently - // included in the list of sections. - // We need to update our sections then notify the delegate that content was reloaded. - setupSections() - delegate?.menuDidReloadContent() - } - - } - -} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderNewsCard.swift b/WordPress/Classes/ViewRelated/Reader/ReaderNewsCard.swift deleted file mode 100644 index 37d76b075a3c..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/ReaderNewsCard.swift +++ /dev/null @@ -1,54 +0,0 @@ -/// Bootstraps a news card specific for the reader. -final class ReaderNewsCard { - private let fileName = "News" - private let tracksOrigin = "reader" - - private lazy var database = { - return UserDefaults.standard - }() - - private lazy var stats = { - return TracksNewsStats(origin: tracksOrigin) - }() - - private lazy var newsManager: DefaultNewsManager = { - let localFilePath = Bundle.main.path(forResource: fileName, ofType: "strings") - return DefaultNewsManager(service: LocalNewsService(filePath: localFilePath), database: self.database, stats: self.stats) - }() - - func shouldPresentCard(containerIdentifier: Identifier) -> Bool { - return newsManager.shouldPresentCard(contextId: containerIdentifier) - } - - func newsCard(containerIdentifier: Identifier, header: ReaderStreamViewController.ReaderHeader?, container: UIViewController, delegate: NewsManagerDelegate) -> UIView? { - - //Set up the delegate, otherwise the actions associated to the buttons in the news card will not be triggered - newsManager.delegate = delegate - - // News card should not be presented: return configured stream header - guard shouldPresentCard(containerIdentifier: containerIdentifier) else { - return header - } - - let newsCard = NewsCard(manager: newsManager) - let news = News(manager: newsManager, ui: newsCard) - - // The news card is not available: return configured stream header - guard let cardUI = news.card(containerId: containerIdentifier)?.view else { - return header - } - - container.addChild(newsCard) - - // This stream does not have a header: return news card - guard let sectionHeader = header else { - return cardUI - } - - // Return NewsCard and header - let stackView = UIStackView(arrangedSubviews: [cardUI, sectionHeader]) - stackView.axis = .vertical - stackView.translatesAutoresizingMaskIntoConstraints = false - return stackView - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderPostBlockingController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderPostBlockingController.swift new file mode 100644 index 000000000000..27854c577de5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderPostBlockingController.swift @@ -0,0 +1,143 @@ +import Foundation + +protocol ReaderSiteBlockingControllerDelegate: AnyObject { + func readerSiteBlockingController(_ controller: ReaderPostBlockingController, willBeginBlockingSiteOfPost post: ReaderPost) + func readerSiteBlockingController(_ controller: ReaderPostBlockingController, didBlockSiteOfPost post: ReaderPost, result: Result) + func readerSiteBlockingController(_ controller: ReaderPostBlockingController, willBeginBlockingPostAuthor post: ReaderPost) + func readerSiteBlockingController(_ controller: ReaderPostBlockingController, didEndBlockingPostAuthor post: ReaderPost, result: Result) +} + +extension ReaderSiteBlockingControllerDelegate { + func readerSiteBlockingController(_ controller: ReaderPostBlockingController, willBeginBlockingSiteOfPost post: ReaderPost) {} + func readerSiteBlockingController(_ controller: ReaderPostBlockingController, didBlockSiteOfPost post: ReaderPost, result: Result) {} + func readerSiteBlockingController(_ controller: ReaderPostBlockingController, willBeginBlockingPostAuthor post: ReaderPost) {} + func readerSiteBlockingController(_ controller: ReaderPostBlockingController, didEndBlockingPostAuthor post: ReaderPost, result: Result) {} +} + +final class ReaderPostBlockingController { + + // MARK: - Properties + + /// The delegate receives updates about the site being blocked. + weak var delegate: ReaderSiteBlockingControllerDelegate? + + /// Flag indicating whether sites are currently being blocked. + var isBlockingPosts: Bool { + return !(ongoingSitesBlocking.isEmpty && ongoingUsersBlocking.isEmpty) + } + + /// Collection of site ids currently being blocked. + private var ongoingSitesBlocking = Set() + + /// Collection of user ids currently being blocked. + private var ongoingUsersBlocking = Set() + + // MARK: - Init + + init() { + self.observeSiteBlockingNotifications() + } + + // MARK: - Observing Notifications + + private func observeSiteBlockingNotifications() { + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(handleSiteBlockingWillBeginNotification(_:)), + name: .ReaderSiteBlockingWillBegin, + object: nil + ) + center.addObserver( + self, + selector: #selector(handleBlockSiteNotification(_:)), + name: .ReaderSiteBlocked, + object: nil + ) + center.addObserver( + self, + selector: #selector(handleSiteBlockingFailed(_:)), + name: .ReaderSiteBlockingFailed, + object: nil + ) + center.addObserver( + self, + selector: #selector(handleUserBlockingWillBegin(notification:)), + name: .ReaderUserBlockingWillBegin, + object: nil + ) + center.addObserver( + self, + selector: #selector(handleUserBlockingDidEnd(notification:)), + name: .ReaderUserBlockingDidEnd, + object: nil + ) + } + + // MARK: - + + private func removeBlockedPosts(authorID: NSNumber) { + let context = ContextManager.shared.mainContext + let request = NSFetchRequest(entityName: ReaderPost.entityName()) + request.predicate = .init(format: "\(#keyPath(ReaderPost.authorID)) = %@", authorID) + guard let result = try? context.fetch(request) else { + return + } + for object in result { + context.deleteObject(object) + } + try? context.save() + } + + // MARK: - Handling Notifications + + @objc private func handleUserBlockingWillBegin(notification: Foundation.Notification) { + guard let post = notification.userInfo?[ReaderNotificationKeys.post] as? ReaderPost, + let authorID = post.authorID + else { + return + } + self.ongoingUsersBlocking.insert(authorID) + self.delegate?.readerSiteBlockingController(self, willBeginBlockingPostAuthor: post) + } + + @objc private func handleUserBlockingDidEnd(notification: Foundation.Notification) { + guard let post = notification.userInfo?[ReaderNotificationKeys.post] as? ReaderPost, + let result = notification.userInfo?[ReaderNotificationKeys.result] as? Result, + let authorID = post.authorID + else { + return + } + self.ongoingUsersBlocking.remove(authorID) + self.removeBlockedPosts(authorID: authorID) + self.delegate?.readerSiteBlockingController(self, didEndBlockingPostAuthor: post, result: result) + } + + @objc private func handleSiteBlockingWillBeginNotification(_ notification: Foundation.Notification) { + guard let post = notification.userInfo?[ReaderNotificationKeys.post] as? ReaderPost else { + return + } + self.ongoingSitesBlocking.insert(post.siteID) + self.delegate?.readerSiteBlockingController(self, willBeginBlockingSiteOfPost: post) + } + + @objc private func handleBlockSiteNotification(_ notification: Foundation.Notification) { + guard let post = notification.userInfo?[ReaderNotificationKeys.post] as? ReaderPost else { + return + } + self.ongoingSitesBlocking.remove(post.siteID) + self.delegate?.readerSiteBlockingController(self, didBlockSiteOfPost: post, result: .success(())) + } + + @objc private func handleSiteBlockingFailed(_ notification: Foundation.Notification) { + guard let post = notification.userInfo?[ReaderNotificationKeys.post] as? ReaderPost else { + return + } + let error = (notification.userInfo?[ReaderNotificationKeys.error] as? Error) ?? UnknownError() + self.ongoingSitesBlocking.remove(post.siteID) + self.delegate?.readerSiteBlockingController(self, didBlockSiteOfPost: post, result: .failure(error)) + } + + private struct UnknownError: Error { + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderPostCardCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderPostCardCell.swift index f32ce3f64e6c..22cfd5af4224 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderPostCardCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderPostCardCell.swift @@ -1,7 +1,12 @@ +import AutomatticTracks import Foundation import WordPressShared import Gridicons +protocol ReaderTopicsChipsDelegate: AnyObject { + func didSelect(topic: String) + func heightDidChange() +} @objc public protocol ReaderPostCellDelegate: NSObjectProtocol { func readerCell(_ cell: ReaderPostCardCell, headerActionForProvider provider: ReaderPostContentProvider) @@ -9,7 +14,6 @@ import Gridicons func readerCell(_ cell: ReaderPostCardCell, followActionForProvider provider: ReaderPostContentProvider) func readerCell(_ cell: ReaderPostCardCell, saveActionForProvider provider: ReaderPostContentProvider) func readerCell(_ cell: ReaderPostCardCell, shareActionForProvider provider: ReaderPostContentProvider, fromView sender: UIView) - func readerCell(_ cell: ReaderPostCardCell, visitActionForProvider provider: ReaderPostContentProvider) func readerCell(_ cell: ReaderPostCardCell, likeActionForProvider provider: ReaderPostContentProvider) func readerCell(_ cell: ReaderPostCardCell, menuActionForProvider provider: ReaderPostContentProvider, fromView sender: UIView) func readerCell(_ cell: ReaderPostCardCell, attributionActionForProvider provider: ReaderPostContentProvider) @@ -18,76 +22,87 @@ import Gridicons } @objc open class ReaderPostCardCell: UITableViewCell { + // MARK: - Properties // Wrapper views - @IBOutlet fileprivate weak var contentStackView: UIStackView! - - // Header realated Views - @IBOutlet fileprivate weak var avatarImageView: UIImageView! - @IBOutlet fileprivate weak var headerBlogButton: UIButton! - @IBOutlet fileprivate weak var blogNameLabel: UILabel! - @IBOutlet fileprivate weak var bylineLabel: UILabel! - @IBOutlet fileprivate weak var followButton: UIButton! + @IBOutlet private weak var contentStackView: UIStackView! + @IBOutlet private weak var topicsCollectionView: TopicsCollectionView! + + // Header related Views + @IBOutlet private weak var headerStackView: UIStackView! + @IBOutlet private weak var avatarStackView: UIStackView! + @IBOutlet private weak var avatarImageView: UIImageView! + @IBOutlet private weak var authorAvatarImageView: UIImageView! + @IBOutlet private weak var headerBlogButton: UIButton! + @IBOutlet private weak var labelsStackView: UIStackView! + + @IBOutlet private weak var authorAndBlogNameStackView: UIStackView! + @IBOutlet private weak var authorNameLabel: UILabel! + @IBOutlet private weak var arrowImageView: UIImageView! + @IBOutlet private weak var blogNameLabel: UILabel! + + @IBOutlet private weak var hostAndTimeStackView: UIStackView! + @IBOutlet private weak var blogHostNameLabel: UILabel! + @IBOutlet private weak var bylineLabel: UILabel! + @IBOutlet private weak var bylineSeparatorLabel: UILabel! // Card views - @IBOutlet fileprivate weak var featuredImageView: CachedAnimatedImageView! - @IBOutlet fileprivate weak var titleLabel: ReaderPostCardContentLabel! - @IBOutlet fileprivate weak var summaryLabel: ReaderPostCardContentLabel! - @IBOutlet fileprivate weak var attributionView: ReaderCardDiscoverAttributionView! - @IBOutlet fileprivate weak var actionStackView: UIStackView! + @IBOutlet private weak var featuredImageView: CachedAnimatedImageView! + @IBOutlet private weak var titleLabel: ReaderPostCardContentLabel! + @IBOutlet private weak var summaryLabel: ReaderPostCardContentLabel! + @IBOutlet private weak var attributionView: ReaderCardDiscoverAttributionView! + @IBOutlet private weak var actionStackView: UIStackView! // Helper Views - @IBOutlet fileprivate weak var borderedView: UIView! - @IBOutlet fileprivate weak var interfaceVerticalSizingHelperView: UIView! + @IBOutlet private weak var borderedView: UIView! + @IBOutlet private weak var interfaceVerticalSizingHelperView: UIView! // Action buttons + @IBOutlet private var actionButtons: [UIButton]! + @IBOutlet private weak var saveForLaterButton: UIButton! + @IBOutlet private weak var likeActionButton: UIButton! + @IBOutlet private weak var commentActionButton: UIButton! + @IBOutlet private weak var menuButton: UIButton! + @IBOutlet private weak var reblogActionButton: UIButton! + + // Ghost cells placeholders + @IBOutlet private weak var ghostPlaceholderView: UIView! + + // Spotlight view + private let spotlightView: UIView = { + let spotlightView = QuickStartSpotlightView() + spotlightView.translatesAutoresizingMaskIntoConstraints = false + spotlightView.isHidden = true + return spotlightView + }() - @IBOutlet var actionButtons: [UIButton]! - @IBOutlet fileprivate weak var saveForLaterButton: UIButton! - @IBOutlet fileprivate weak var visitButton: UIButton! - @IBOutlet fileprivate weak var likeActionButton: UIButton! - @IBOutlet fileprivate weak var commentActionButton: UIButton! - @IBOutlet fileprivate weak var menuButton: UIButton! - @IBOutlet fileprivate weak var reblogActionButton: UIButton! - - // Layout Constraints - @IBOutlet fileprivate weak var featuredMediaHeightConstraint: NSLayoutConstraint! + /// Whether or not to show the spotlight animation to illustrate tapping the icon. + var spotlightIsShown: Bool = false { + didSet { + spotlightView.isHidden = !spotlightIsShown || !shouldShowCommentActionButton + } + } @objc open weak var delegate: ReaderPostCellDelegate? - @objc open weak var contentProvider: ReaderPostContentProvider? + private weak var contentProvider: ReaderPostContentProvider? - fileprivate let featuredMediaHeightConstraintConstant = WPDeviceIdentification.isiPad() ? CGFloat(226.0) : CGFloat(100.0) - fileprivate var featuredImageDesiredWidth = CGFloat() + private var featuredImageDesiredWidth = CGFloat() - fileprivate let summaryMaxNumberOfLines = 3 - fileprivate let avgWordsPerMinuteRead = 250 - fileprivate let minimumMinutesToRead = 2 - fileprivate var currentLoadedCardImageURL: String? - fileprivate var isSmallWidth: Bool { + private var currentLoadedCardImageURL: String? + private var isSmallWidth: Bool { let width = superview?.frame.width ?? 0 return width <= 320 } - // MARK: - Accessors - @objc open var hidesFollowButton = false - var loggedInActionVisibility: ReaderActionsVisibility = .visible(enabled: true) - + weak var topicChipsDelegate: ReaderTopicsChipsDelegate? - open override func setSelected(_ selected: Bool, animated: Bool) { - super.setSelected(selected, animated: animated) - setHighlighted(selected, animated: animated) - } + var displayTopics: Bool = false + var isP2Type: Bool = false - open override func setHighlighted(_ highlighted: Bool, animated: Bool) { - let previouslyHighlighted = self.isHighlighted - super.setHighlighted(highlighted, animated: animated) + // MARK: - Accessors - if previouslyHighlighted == highlighted { - return - } - applyHighlightedEffect(highlighted, animated: animated) - } + var loggedInActionVisibility: ReaderActionsVisibility = .visible(enabled: true) @objc open var headerBlogButtonIsEnabled: Bool { get { @@ -98,26 +113,30 @@ import Gridicons headerBlogButton.isEnabled = newValue if newValue { blogNameLabel.textColor = WPStyleGuide.readerCardBlogNameLabelTextColor() + authorNameLabel.textColor = WPStyleGuide.readerCardBlogNameLabelTextColor() + configureArrowImage() } else { blogNameLabel.textColor = WPStyleGuide.readerCardBlogNameLabelDisabledTextColor() + authorNameLabel.textColor = WPStyleGuide.readerCardBlogNameLabelDisabledTextColor() + configureArrowImage(withTint: WPStyleGuide.readerCardBlogNameLabelDisabledTextColor()) } } } } - fileprivate lazy var imageLoader: ImageLoader = { + private lazy var imageLoader: ImageLoader = { return ImageLoader(imageView: featuredImageView) }() - fileprivate lazy var readerCardTitleAttributes: [NSAttributedString.Key: Any] = { + private lazy var readerCardTitleAttributes: [NSAttributedString.Key: Any] = { return WPStyleGuide.readerCardTitleAttributes() }() - fileprivate lazy var readerCardSummaryAttributes: [NSAttributedString.Key: Any] = { + private lazy var readerCardSummaryAttributes: [NSAttributedString.Key: Any] = { return WPStyleGuide.readerCardSummaryAttributes() }() - fileprivate lazy var readerCardReadingTimeAttributes: [NSAttributedString.Key: Any] = { + private lazy var readerCardReadingTimeAttributes: [NSAttributedString.Key: Any] = { return WPStyleGuide.readerCardReadingTimeAttributes() }() @@ -137,92 +156,125 @@ import Gridicons interfaceVerticalSizingHelperView.isHidden = true setupMenuButton() - setupVisitButton() - setupCommentActionButton() - setupLikeActionButton() // Buttons must be set up before applying styles, // as this tints the images used in the buttons applyStyles() applyOpaqueBackgroundColors() - setupFeaturedImageView() + configureFeaturedImageView() setupSummaryLabel() setupAttributionView() + setupSpotlightView() adjustInsetsForTextDirection() - insetFollowButtonIcon() } open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) configureFeaturedImageIfNeeded() configureButtonTitles() + + // Update colors + applyStyles() + setupMenuButton() + configureFeaturedImageView() + configureAvatarImageView(avatarImageView) + configureAvatarImageView(authorAvatarImageView) } open override func prepareForReuse() { super.prepareForReuse() + imageLoader.prepareForReuse() - } + displayTopics = false + isP2Type = false + topicsCollectionView.collapse() + } - // MARK: - Configuration + @objc open func configureCell(_ contentProvider: ReaderPostContentProvider) { + self.contentProvider = contentProvider - fileprivate func setupAttributionView() { - attributionView.delegate = self + configureTopicsCollectionView() + configureHeader() + configureAvatarImageView(avatarImageView) + configureAvatarImageView(authorAvatarImageView) + configureFeaturedImageIfNeeded() + configureTitle() + configureSummary() + configureAttribution() + configureActionButtons() + configureButtonTitles() + prepareForVoiceOver() } - fileprivate func setupFeaturedImageView() { - featuredMediaHeightConstraint.constant = featuredMediaHeightConstraintConstant + func refreshLikeButton() { + configureLikeActionButton() + configureButtonTitles() } - fileprivate func setupSummaryLabel() { - summaryLabel.numberOfLines = summaryMaxNumberOfLines - summaryLabel.lineBreakMode = .byTruncatingTail - } +} - fileprivate func setupCommentActionButton() { - let image = UIImage(named: "icon-reader-comment")?.imageFlippedForRightToLeftLayoutDirection() - let highlightImage = UIImage(named: "icon-reader-comment-highlight")?.imageFlippedForRightToLeftLayoutDirection() - commentActionButton.setImage(image, for: UIControl.State()) - commentActionButton.setImage(highlightImage, for: .highlighted) - } +// MARK: - Configuration - fileprivate func setupLikeActionButton() { - let likeImage = UIImage(named: "icon-reader-like") - let likedImage = UIImage(named: "icon-reader-liked") +private extension ReaderPostCardCell { - likeActionButton.setImage(likeImage, for: .normal) - likeActionButton.setImage(likedImage, for: .highlighted) - likeActionButton.setImage(likedImage, for: .selected) - likeActionButton.setImage(likedImage, for: [.highlighted, .selected]) + struct Constants { + static let featuredMediaCornerRadius: CGFloat = 4 + static let imageBorderWidth: CGFloat = 1 + static let featuredMediaTopSpacing: CGFloat = 8 + static let headerBottomSpacing: CGFloat = 8 + static let summaryMaxNumberOfLines: NSInteger = 2 + static let avatarPlaceholderImage: UIImage? = UIImage(named: "post-blavatar-placeholder") + static let authorAvatarPlaceholderImage: UIImage? = UIImage(named: "gravatar") + static let rotate270Degrees: CGFloat = CGFloat.pi * 1.5 + static let rotate90Degrees: CGFloat = CGFloat.pi / 2 + static let spotlightXOffset: CGFloat = 10 + static let spotlightYOffset: CGFloat = 10 } - fileprivate func setupVisitButton() { - let size = CGSize(width: 20, height: 20) - let title = NSLocalizedString("Visit", comment: "Verb. Button title. Tap to visit a website.") - let icon = Gridicon.iconOfType(.external, withSize: size) - let tintedIcon = icon.imageFlippedForRightToLeftLayoutDirection() - let highlightIcon = icon.imageFlippedForRightToLeftLayoutDirection() + // MARK: - Configuration - visitButton.setTitle(title, for: UIControl.State()) - visitButton.setImage(tintedIcon, for: .normal) - visitButton.setImage(highlightIcon, for: .highlighted) + func setupAttributionView() { + attributionView.delegate = self } - fileprivate func setupMenuButton() { - let size = CGSize(width: 20, height: 20) - let icon = Gridicon.iconOfType(.ellipsis, withSize: size) - let tintedIcon = icon.imageWithTintColor(.neutral(.shade30)) - let highlightIcon = icon.imageWithTintColor(.neutral) + func setupSummaryLabel() { + summaryLabel.numberOfLines = Constants.summaryMaxNumberOfLines + summaryLabel.lineBreakMode = .byTruncatingTail + } + + func setupMenuButton() { + guard let icon = UIImage(named: "icon-menu-vertical-ellipsis") else { + return + } + + let tintColor = UIColor(light: .muriel(color: .gray, .shade50), + dark: .textSubtle) + + let highlightColor = UIColor(light: .muriel(color: .gray, .shade10), + dark: .textQuaternary) + + let tintedIcon = icon.imageWithTintColor(tintColor) + let highlightIcon = icon.imageWithTintColor(highlightColor) menuButton.setImage(tintedIcon, for: .normal) menuButton.setImage(highlightIcon, for: .highlighted) } - fileprivate func adjustInsetsForTextDirection() { + private func setupSpotlightView() { + addSubview(spotlightView) + bringSubviewToFront(spotlightView) + + NSLayoutConstraint.activate([ + commentActionButton.centerXAnchor.constraint(equalTo: spotlightView.centerXAnchor), + commentActionButton.centerYAnchor.constraint(equalTo: spotlightView.centerYAnchor, constant: Constants.spotlightYOffset) + ]) + } + + func adjustInsetsForTextDirection() { let buttonsToAdjust: [UIButton] = [ - visitButton, likeActionButton, commentActionButton, saveForLaterButton, @@ -232,100 +284,170 @@ import Gridicons } } - /** - Applies the default styles to the cell's subviews - */ - fileprivate func applyStyles() { + /// Applies the default styles to the cell's subviews + /// + func applyStyles() { backgroundColor = .clear contentView.backgroundColor = .listBackground borderedView.backgroundColor = .listForeground - borderedView.layer.borderColor = WPStyleGuide.readerCardCellBorderColor().cgColor - borderedView.layer.borderWidth = .hairlineBorderWidth - - WPStyleGuide.applyReaderSaveForLaterButtonStyle(saveForLaterButton) - - if FeatureFlag.postReblogging.enabled { - WPStyleGuide.applyReaderReblogActionButtonStyle(reblogActionButton) - } - WPStyleGuide.applyReaderFollowButtonStyle(followButton) WPStyleGuide.applyReaderCardBlogNameStyle(blogNameLabel) + WPStyleGuide.applyReaderCardBlogNameStyle(authorNameLabel) + + WPStyleGuide.applyReaderCardBylineLabelStyle(blogHostNameLabel) WPStyleGuide.applyReaderCardBylineLabelStyle(bylineLabel) + WPStyleGuide.applyReaderCardBylineLabelStyle(bylineSeparatorLabel) + WPStyleGuide.applyReaderCardTitleLabelStyle(titleLabel) WPStyleGuide.applyReaderCardSummaryLabelStyle(summaryLabel) - WPStyleGuide.applyReaderActionButtonStyle(commentActionButton) - WPStyleGuide.applyReaderActionButtonStyle(likeActionButton) - WPStyleGuide.applyReaderActionButtonStyle(visitButton) - } + // Action Buttons + WPStyleGuide.applyReaderCardSaveForLaterButtonStyle(saveForLaterButton) + WPStyleGuide.applyReaderCardReblogActionButtonStyle(reblogActionButton) + WPStyleGuide.applyReaderCardLikeButtonStyle(likeActionButton) + WPStyleGuide.applyReaderCardCommentButtonStyle(commentActionButton) + } - /** - Applies opaque backgroundColors to all subViews to avoid blending, for optimized drawing. - */ - fileprivate func applyOpaqueBackgroundColors() { + /// Applies opaque backgroundColors to all subViews to avoid blending, for optimized drawing. + /// + func applyOpaqueBackgroundColors() { blogNameLabel.backgroundColor = .listForeground + authorNameLabel.backgroundColor = .listForeground + blogHostNameLabel.backgroundColor = .listForeground bylineLabel.backgroundColor = .listForeground titleLabel.backgroundColor = .listForeground summaryLabel.backgroundColor = .listForeground commentActionButton.titleLabel?.backgroundColor = .listForeground likeActionButton.titleLabel?.backgroundColor = .listForeground + topicsCollectionView.backgroundColor = .listForeground } - @objc open func configureCell(_ contentProvider: ReaderPostContentProvider) { - self.contentProvider = contentProvider + func configureTopicsCollectionView() { + guard + displayTopics, + let contentProvider = contentProvider, + let tags = contentProvider.tagsForDisplay?(), + !tags.isEmpty + else { + topicsCollectionView.isHidden = true + return + } - configureHeader() - configureFollowButton() - configureFeaturedImageIfNeeded() - configureTitle() - configureSummary() - configureAttribution() - configureActionButtons() - configureButtonTitles() - prepareForVoiceOver() + topicsCollectionView.topicDelegate = self + topicsCollectionView.topics = tags + topicsCollectionView.isHidden = false } - fileprivate func configureHeader() { - guard let provider = contentProvider else { - return - } +} + +// MARK: - Header Configuration + +private extension ReaderPostCardCell { + + func configureHeader() { // Always reset - avatarImageView.image = nil + avatarImageView.image = Constants.avatarPlaceholderImage + authorAvatarImageView.image = Constants.authorAvatarPlaceholderImage + + setSiteIcon() + setAuthorAvatar() + setBlogLabels() + + avatarStackView.isHidden = avatarImageView.isHidden && authorAvatarImageView.isHidden + } + func setSiteIcon() { let size = avatarImageView.frame.size.width * UIScreen.main.scale - if let url = provider.siteIconForDisplay(ofSize: Int(size)) { - if provider.isPrivate() { - let request = PrivateSiteURLProtocol.requestForPrivateSite(from: url) - avatarImageView.downloadImage(usingRequest: request) - } else { - avatarImageView.downloadImage(from: url) - } - avatarImageView.isHidden = false - } else { + guard let contentProvider = contentProvider, + let url = contentProvider.siteIconForDisplay(ofSize: Int(size)) else { avatarImageView.isHidden = true + return + } + + let mediaRequestAuthenticator = MediaRequestAuthenticator() + let host = MediaHost(with: contentProvider, failure: { error in + // We'll log the error, so we know it's there, but we won't halt execution. + DDLogError("ReaderPostCardCell MediaHost error: \(error.localizedDescription)") + }) + + mediaRequestAuthenticator.authenticatedRequest( + for: url, + from: host, + onComplete: { request in + self.avatarImageView.downloadImage(usingRequest: request) + self.avatarImageView.isHidden = false + }, + onFailure: { error in + WordPressAppDelegate.crashLogging?.logError(error) + self.avatarImageView.isHidden = true + }) + } + + func setAuthorAvatar() { + guard isP2Type, + let contentProvider = contentProvider, + let url = contentProvider.avatarURLForDisplay() else { + authorAvatarImageView.isHidden = true + return } - var arr = [String]() - if let authorName = provider.authorForDisplay() { - arr.append(authorName) + authorAvatarImageView.isHidden = false + authorAvatarImageView.downloadImage(from: url, placeholderImage: Constants.authorAvatarPlaceholderImage) + } + + func setBlogLabels() { + guard let contentProvider = contentProvider else { + return } - if let blogName = provider.blogNameForDisplay() { - arr.append(blogName) + + authorNameLabel.isHidden = !isP2Type + arrowImageView.isHidden = !isP2Type + + if isP2Type { + authorNameLabel.text = contentProvider.authorForDisplay() + configureArrowImage() } - blogNameLabel.text = arr.joined(separator: ", ") - let byline = datePublished() - bylineLabel.text = byline + blogNameLabel.text = blogName() + blogHostNameLabel.text = contentProvider.siteHostNameForDisplay() + + let dateString: String = datePublished() + bylineSeparatorLabel.isHidden = dateString.isEmpty + bylineLabel.text = dateString } - fileprivate func configureFollowButton() { - followButton.isHidden = hidesFollowButton - followButton.isSelected = contentProvider?.isFollowing() ?? false + func configureArrowImage(withTint tint: UIColor = WPStyleGuide.readerCardBlogNameLabelTextColor()) { + arrowImageView.image = UIImage.gridicon(.dropdown).imageWithTintColor(tint) + + let imageRotationAngle = (userInterfaceLayoutDirection() == .rightToLeft) ? + Constants.rotate90Degrees : + Constants.rotate270Degrees + + arrowImageView.transform = CGAffineTransform(rotationAngle: imageRotationAngle) + } + + func configureAvatarImageView(_ imageView: UIImageView) { + imageView.layer.borderColor = WPStyleGuide.readerCardBlogIconBorderColor().cgColor + imageView.layer.borderWidth = Constants.imageBorderWidth + imageView.layer.masksToBounds = true + } + +} + +// MARK: - Card Configuration + +private extension ReaderPostCardCell { + + func configureFeaturedImageView() { + // Round the corners, and add a border + featuredImageView.layer.cornerRadius = Constants.featuredMediaCornerRadius + featuredImageView.layer.borderColor = WPStyleGuide.readerCardFeaturedMediaBorderColor().cgColor + featuredImageView.layer.borderWidth = Constants.imageBorderWidth } - fileprivate func configureFeaturedImageIfNeeded() { + func configureFeaturedImageIfNeeded() { guard let content = contentProvider else { return } @@ -333,9 +455,13 @@ import Gridicons imageLoader.prepareForReuse() currentLoadedCardImageURL = nil featuredImageView.isHidden = true + + contentStackView.setCustomSpacing(Constants.headerBottomSpacing, after: headerStackView) return } + contentStackView.setCustomSpacing(Constants.headerBottomSpacing + Constants.featuredMediaTopSpacing, after: headerStackView) + featuredImageView.layoutIfNeeded() if (!featuredImageURL.isGif && featuredImageView.image == nil) || (featuredImageURL.isGif && featuredImageView.animationImages == nil) || @@ -345,20 +471,26 @@ import Gridicons } } - fileprivate func configureFeaturedImage(_ featuredImageURL: URL) { - guard let content = contentProvider else { + func configureFeaturedImage(_ featuredImageURL: URL) { + guard let contentProvider = contentProvider else { return } featuredImageView.isHidden = false currentLoadedCardImageURL = featuredImageURL.absoluteString featuredImageDesiredWidth = featuredImageView.frame.width - let size = CGSize(width: featuredImageDesiredWidth, height: featuredMediaHeightConstraintConstant) - let postInfo = ReaderCardContent(provider: content) - imageLoader.loadImage(with: featuredImageURL, from: postInfo, preferredSize: size) + + let featuredImageHeight = featuredImageView.frame.height + + let size = CGSize(width: featuredImageDesiredWidth, height: featuredImageHeight) + let host = MediaHost(with: contentProvider, failure: { error in + // We'll log the error, so we know it's there, but we won't halt execution. + WordPressAppDelegate.crashLogging?.logError(error) + }) + imageLoader.loadImage(with: featuredImageURL, from: host, preferredSize: size) } - fileprivate func configureTitle() { + func configureTitle() { if let title = contentProvider?.titleForDisplay(), !title.isEmpty() { titleLabel.attributedText = NSAttributedString(string: title, attributes: readerCardTitleAttributes) titleLabel.isHidden = false @@ -368,7 +500,7 @@ import Gridicons } } - fileprivate func configureSummary() { + func configureSummary() { if let summary = contentProvider?.contentPreviewForDisplay(), !summary.isEmpty() { summaryLabel.attributedText = NSAttributedString(string: summary, attributes: readerCardSummaryAttributes) summaryLabel.isHidden = false @@ -378,7 +510,7 @@ import Gridicons } } - fileprivate func configureAttribution() { + func configureAttribution() { if contentProvider == nil || contentProvider?.sourceAttributionStyle() == SourceAttributionStyle.none { attributionView.configureView(nil) attributionView.isHidden = true @@ -388,7 +520,19 @@ import Gridicons } } - fileprivate func configureActionButtons() { +} + +// MARK: - Button Configuration + +private extension ReaderPostCardCell { + + enum CardAction: Int { + case comment = 1 + case like + case reblog + } + + func configureActionButtons() { if contentProvider == nil || contentProvider?.sourceAttributionStyle() != SourceAttributionStyle.none { resetActionButton(commentActionButton) resetActionButton(likeActionButton) @@ -405,13 +549,13 @@ import Gridicons configureActionButtonsInsets() } - fileprivate func resetActionButton(_ button: UIButton) { + func resetActionButton(_ button: UIButton) { button.setTitle(nil, for: UIControl.State()) button.isSelected = false - button.isHidden = true + button.isEnabled = false } - private func configureActionButtonsInsets() { + func configureActionButtonsInsets() { actionButtons.forEach { button in if isSmallWidth { button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 4) @@ -422,29 +566,19 @@ import Gridicons } } - fileprivate func configureLikeActionButton() { - // Show likes if logged in, or if likes exist, but not if external - guard shouldShowLikeActionButton else { - resetActionButton(likeActionButton) - return - } - - likeActionButton.tag = CardAction.like.rawValue - likeActionButton.isEnabled = loggedInActionVisibility.isEnabled - likeActionButton.isSelected = contentProvider!.isLiked() - likeActionButton.isHidden = false - } - - fileprivate var shouldShowLikeActionButton: Bool { + var shouldShowLikeActionButton: Bool { guard loggedInActionVisibility != .hidden else { return false } - guard let contentProvider = contentProvider else { + guard + let contentProvider = contentProvider, + let likeCount = contentProvider.likeCount() + else { return false } - let hasLikes = contentProvider.likeCount().intValue > 0 + let hasLikes = likeCount.intValue > 0 guard loggedInActionVisibility.isEnabled || hasLikes else { return false @@ -453,17 +587,19 @@ import Gridicons return !contentProvider.isExternal() } - fileprivate func configureCommentActionButton() { - guard shouldShowCommentActionButton else { - resetActionButton(commentActionButton) + func configureLikeActionButton() { + // Show likes if logged in, or if likes exist, but not if external + guard shouldShowLikeActionButton else { + resetActionButton(likeActionButton) return } - commentActionButton.tag = CardAction.comment.rawValue - commentActionButton.isHidden = false + likeActionButton.tag = CardAction.like.rawValue + likeActionButton.isEnabled = loggedInActionVisibility.isEnabled + likeActionButton.isSelected = contentProvider?.isLiked() ?? false } - fileprivate var shouldShowCommentActionButton: Bool { + var shouldShowCommentActionButton: Bool { guard loggedInActionVisibility != .hidden else { return false } @@ -482,22 +618,30 @@ import Gridicons return usesWPComAPI && (contentProvider.commentsOpen() || hasComments) } + func configureCommentActionButton() { + guard shouldShowCommentActionButton else { + resetActionButton(commentActionButton) + return + } + + commentActionButton.tag = CardAction.comment.rawValue + commentActionButton.isEnabled = true + } - fileprivate func configureSaveForLaterButton() { - saveForLaterButton.isHidden = false + func configureSaveForLaterButton() { + saveForLaterButton.isEnabled = true let postIsSavedForLater = contentProvider?.isSavedForLater() ?? false saveForLaterButton.isSelected = postIsSavedForLater } - fileprivate func configureReblogActionButton() { + func configureReblogActionButton() { reblogActionButton.tag = CardAction.reblog.rawValue - reblogActionButton.isHidden = !shouldShowReblogActionButton + reblogActionButton.isEnabled = shouldShowReblogActionButton } - fileprivate var shouldShowReblogActionButton: Bool { + var shouldShowReblogActionButton: Bool { // reblog button is hidden if there's no content - guard FeatureFlag.postReblogging.enabled, - let provider = contentProvider, + guard let provider = contentProvider, !provider.isPrivate(), loggedInActionVisibility.isEnabled else { return false @@ -505,7 +649,7 @@ import Gridicons return true } - fileprivate func configureButtonTitles() { + func configureButtonTitles() { guard let provider = contentProvider else { return } @@ -519,12 +663,9 @@ import Gridicons let commentTitle = commentCount > 0 ? String(commentCount) : "" likeActionButton.setTitle(likeTitle, for: .normal) commentActionButton.setTitle(commentTitle, for: .normal) - if FeatureFlag.postReblogging.enabled { - WPStyleGuide.applyReaderSaveForLaterButtonTitles(saveForLaterButton, showTitle: false) - WPStyleGuide.applyReaderReblogActionButtonTitle(reblogActionButton, showTitle: false) - } else { - saveForLaterButton.setTitle("", for: .normal) - } + WPStyleGuide.applyReaderSaveForLaterButtonTitles(saveForLaterButton, showTitle: false) + WPStyleGuide.applyReaderReblogActionButtonTitle(reblogActionButton, showTitle: false) + } else { let likeTitle = WPStyleGuide.likeCountForDisplay(likeCount) let commentTitle = WPStyleGuide.commentCountForDisplay(commentCount) @@ -533,80 +674,52 @@ import Gridicons commentActionButton.setTitle(commentTitle, for: .normal) WPStyleGuide.applyReaderSaveForLaterButtonTitles(saveForLaterButton) - if FeatureFlag.postReblogging.enabled { - WPStyleGuide.applyReaderReblogActionButtonTitle(reblogActionButton) - } + WPStyleGuide.applyReaderReblogActionButtonTitle(reblogActionButton) } } - /// Adds some space between the button and title. - /// Setting the titleEdgeInset.left seems to be ignored in IB for whatever reason, - /// so we'll add/remove it from the image as needed. - fileprivate func insetFollowButtonIcon() { - var insets = followButton.imageEdgeInsets - insets.right = 2.0 - followButton.imageEdgeInsets = insets - followButton.flipInsetsForRightToLeftLayoutDirection() - } +} - fileprivate func applyHighlightedEffect(_ highlighted: Bool, animated: Bool) { - func updateBorder() { - self.borderedView.layer.borderColor = highlighted ? WPStyleGuide.readerCardCellHighlightedBorderColor().cgColor : WPStyleGuide.readerCardCellBorderColor().cgColor - } - guard animated else { - updateBorder() - return - } - UIView.animate(withDuration: 0.25, - delay: 0, - options: UIView.AnimationOptions(), - animations: updateBorder) - } +// MARK: - Button Actions +extension ReaderPostCardCell { - // MARK: - + // MARK: - Header Tapped @objc func notifyDelegateHeaderWasTapped() { - if headerBlogButtonIsEnabled { - delegate?.readerCell(self, headerActionForProvider: contentProvider!) + guard headerBlogButtonIsEnabled, + let contentProvider = contentProvider else { + return } - } + delegate?.readerCell(self, headerActionForProvider: contentProvider) + } // MARK: - Actions - @IBAction func didTapFollowButton(_ sender: UIButton) { - guard let provider = contentProvider else { - return - } - delegate?.readerCell(self, followActionForProvider: provider) - } - @IBAction func didTapHeaderBlogButton(_ sender: UIButton) { notifyDelegateHeaderWasTapped() } @IBAction func didTapMenuButton(_ sender: UIButton) { - delegate?.readerCell(self, menuActionForProvider: contentProvider!, fromView: sender) - } - - @IBAction func didTapVisitButton(_ sender: UIButton) { - guard let provider = contentProvider else { + guard let contentProvider = contentProvider else { return } - delegate?.readerCell(self, visitActionForProvider: provider) + + delegate?.readerCell(self, menuActionForProvider: contentProvider, fromView: sender) } @IBAction func didTapSaveForLaterButton(_ sender: UIButton) { - guard let provider = contentProvider else { + guard let contentProvider = contentProvider else { return } - delegate?.readerCell(self, saveActionForProvider: provider) + + delegate?.readerCell(self, saveActionForProvider: contentProvider) configureSaveForLaterButton() } @IBAction func didTapActionButton(_ sender: UIButton) { - guard let contentProvider = self.contentProvider, + guard let contentProvider = contentProvider, let tag = CardAction(rawValue: sender.tag) else { return } @@ -621,33 +734,32 @@ import Gridicons } } - // MARK: - Custom UI Actions @IBAction func blogButtonTouchesDidHighlight(_ sender: UIButton) { blogNameLabel.isHighlighted = true + authorNameLabel.isHighlighted = true + configureArrowImage(withTint: .primaryLight) } @IBAction func blogButtonTouchesDidEnd(_ sender: UIButton) { blogNameLabel.isHighlighted = false + authorNameLabel.isHighlighted = false + configureArrowImage() } - - // MARK: - Private Types - - fileprivate enum CardAction: Int { - case comment = 1 - case like - case reblog - } } +// MARK: - ReaderCardDiscoverAttributionViewDelegate + extension ReaderPostCardCell: ReaderCardDiscoverAttributionViewDelegate { public func attributionActionSelectedForVisitingSite(_ view: ReaderCardDiscoverAttributionView) { delegate?.readerCell(self, attributionActionForProvider: contentProvider!) } } +// MARK: - Accessibility + extension ReaderPostCardCell: Accessible { func prepareForVoiceOver() { prepareCardForVoiceOver() @@ -656,31 +768,30 @@ extension ReaderPostCardCell: Accessible { prepareCommentsForVoiceOver() prepareLikeForVoiceOver() prepareMenuForVoiceOver() - prepareVisitForVoiceOver() - prepareFollowButtonForVoiceOver() - if FeatureFlag.postReblogging.enabled { - prepareReblogForVoiceOver() - } + prepareReblogForVoiceOver() } +} + +private extension ReaderPostCardCell { - private func prepareCardForVoiceOver() { + func prepareCardForVoiceOver() { accessibilityLabel = cardAccessibilityLabel() accessibilityHint = cardAccessibilityHint() accessibilityTraits = UIAccessibilityTraits.button } - private func cardAccessibilityLabel() -> String { + func cardAccessibilityLabel() -> String { let authorName = postAuthor() let blogTitle = blogName() return headerButtonAccessibilityLabel(name: authorName, title: blogTitle) + ", " + postTitle() + ", " + postContent() } - private func cardAccessibilityHint() -> String { + func cardAccessibilityHint() -> String { return NSLocalizedString("Shows the post content", comment: "Accessibility hint for the Reader Cell") } - private func prepareHeaderButtonForVoiceOver() { + func prepareHeaderButtonForVoiceOver() { guard headerBlogButtonIsEnabled else { /// When the headerbutton is disabled, hide it from VoiceOver as well. headerBlogButton.isAccessibilityElement = false @@ -697,52 +808,50 @@ extension ReaderPostCardCell: Accessible { headerBlogButton.accessibilityTraits = UIAccessibilityTraits.button } - private func headerButtonAccessibilityLabel(name: String, title: String) -> String { + func headerButtonAccessibilityLabel(name: String, title: String) -> String { return authorNameAndBlogTitle(name: name, title: title) + ", " + datePublished() } - private func authorNameAndBlogTitle(name: String, title: String) -> String { + func authorNameAndBlogTitle(name: String, title: String) -> String { let format = NSLocalizedString("Post by %@, from %@", comment: "Spoken accessibility label for blog author and name in Reader cell.") return String(format: format, name, title) } - private func headerButtonAccessibilityHint(title: String) -> String { + func headerButtonAccessibilityHint(title: String) -> String { let format = NSLocalizedString("Shows all posts from %@", comment: "Spoken accessibility hint for blog name in Reader cell.") return String(format: format, title) } - private func prepareSaveForLaterForVoiceOver() { + func prepareSaveForLaterForVoiceOver() { let isSavedForLater = contentProvider?.isSavedForLater() ?? false saveForLaterButton.accessibilityLabel = isSavedForLater ? NSLocalizedString("Saved Post", comment: "Accessibility label for the 'Save Post' button when a post has been saved.") : NSLocalizedString("Save post", comment: "Accessibility label for the 'Save Post' button.") saveForLaterButton.accessibilityHint = isSavedForLater ? NSLocalizedString("Remove this post from my saved posts.", comment: "Accessibility hint for the 'Save Post' button when a post is already saved.") : NSLocalizedString("Saves this post for later.", comment: "Accessibility hint for the 'Save Post' button.") saveForLaterButton.accessibilityTraits = UIAccessibilityTraits.button } - private func prepareCommentsForVoiceOver() { + func prepareCommentsForVoiceOver() { commentActionButton.accessibilityLabel = commentsLabel() commentActionButton.accessibilityHint = NSLocalizedString("Shows comments", comment: "Spoken accessibility hint for Comments buttons") commentActionButton.accessibilityTraits = UIAccessibilityTraits.button } - private func commentsLabel() -> String { + func commentsLabel() -> String { let commentCount = contentProvider?.commentCount()?.intValue ?? 0 - let format = commentCount > 1 ? pluralCommentFormat() : singularCommentFormat() - return String(format: format, "\(commentCount)") } - private func singularCommentFormat() -> String { + func singularCommentFormat() -> String { return NSLocalizedString("%@ comment", comment: "Accessibility label for comments button (singular)") } - private func pluralCommentFormat() -> String { + func pluralCommentFormat() -> String { return NSLocalizedString("%@ comments", comment: "Accessibility label for comments button (plural)") } - private func prepareLikeForVoiceOver() { - guard likeActionButton.isHidden == false else { + func prepareLikeForVoiceOver() { + guard likeActionButton.isEnabled == true else { return } @@ -751,27 +860,25 @@ extension ReaderPostCardCell: Accessible { likeActionButton.accessibilityTraits = UIAccessibilityTraits.button } - private func likeLabel() -> String { + func likeLabel() -> String { return isContentLiked() ? isLikedLabel(): isNotLikedLabel() } - private func isContentLiked() -> Bool { + func isContentLiked() -> Bool { return contentProvider?.isLiked() ?? false } - private func isLikedLabel() -> String { + func isLikedLabel() -> String { let postInMyLikes = NSLocalizedString("This post is in My Likes", comment: "Post is in my likes. Accessibility label") - return appendLikedCount(label: postInMyLikes) } - private func isNotLikedLabel() -> String { + func isNotLikedLabel() -> String { let postNotInMyLikes = NSLocalizedString("This post is not in My Likes", comment: "Post is not in my likes. Accessibility label") - return appendLikedCount(label: postNotInMyLikes) } - private func appendLikedCount(label: String) -> String { + func appendLikedCount(label: String) -> String { if let likeCount = contentProvider?.likeCountForDisplay() { return label + ", " + likeCount } else { @@ -779,93 +886,76 @@ extension ReaderPostCardCell: Accessible { } } - private func likeHint() -> String { + func likeHint() -> String { return isContentLiked() ? doubleTapToUnlike() : doubleTapToLike() } - private func doubleTapToUnlike() -> String { + func doubleTapToUnlike() -> String { return NSLocalizedString("Removes this post from My Likes", comment: "Removes a post from My Likes. Spoken Hint.") } - private func doubleTapToLike() -> String { + func doubleTapToLike() -> String { return NSLocalizedString("Adds this post to My Likes", comment: "Adds a post to My Likes. Spoken Hint.") } - private func prepareMenuForVoiceOver() { + func prepareMenuForVoiceOver() { menuButton.accessibilityLabel = NSLocalizedString("More", comment: "Accessibility label for the More button on Reader Cell") menuButton.accessibilityHint = NSLocalizedString("Shows more actions", comment: "Accessibility label for the More button on Reader Cell.") menuButton.accessibilityTraits = UIAccessibilityTraits.button } - private func prepareVisitForVoiceOver() { - visitButton.accessibilityLabel = NSLocalizedString("Visit", comment: "Verb. Button title. Accessibility label in Reader") - let hintFormat = NSLocalizedString("Visit %@ in a web view", comment: "A call to action to visit the specified blog via a web view. Accessibility hint in Reader") - visitButton.accessibilityHint = String(format: hintFormat, blogName()) - visitButton.accessibilityTraits = UIAccessibilityTraits.button - } - - private func prepareReblogForVoiceOver() { + func prepareReblogForVoiceOver() { reblogActionButton.accessibilityLabel = NSLocalizedString("Reblog post", comment: "Accessibility label for the reblog button.") reblogActionButton.accessibilityHint = NSLocalizedString("Reblog this post", comment: "Accessibility hint for the reblog button.") reblogActionButton.accessibilityTraits = UIAccessibilityTraits.button } - func prepareFollowButtonForVoiceOver() { - if hidesFollowButton { - return - } - - followButton.accessibilityLabel = followLabel() - followButton.accessibilityHint = followHint() - followButton.accessibilityTraits = UIAccessibilityTraits.button - } - - private func followLabel() -> String { + func followLabel() -> String { return followButtonIsSelected() ? followingLabel() : notFollowingLabel() } - private func followingLabel() -> String { + func followingLabel() -> String { return NSLocalizedString("Following", comment: "Accessibility label for following buttons.") } - private func notFollowingLabel() -> String { + func notFollowingLabel() -> String { return NSLocalizedString("Not following", comment: "Accessibility label for unselected following buttons.") } - private func followHint() -> String { + func followHint() -> String { return followButtonIsSelected() ? unfollow(): follow() } - private func unfollow() -> String { + func unfollow() -> String { return NSLocalizedString("Unfollows blog", comment: "Spoken hint describing action for selected following buttons.") } - private func follow() -> String { + func follow() -> String { return NSLocalizedString("Follows blog", comment: "Spoken hint describing action for unselected following buttons.") } - private func followButtonIsSelected() -> Bool { + func followButtonIsSelected() -> Bool { return contentProvider?.isFollowing() ?? false } - private func blogName() -> String { - return contentProvider?.blogNameForDisplay() ?? "" + func blogName() -> String { + return contentProvider?.blogNameForDisplay?() ?? "" } - private func postAuthor() -> String { + func postAuthor() -> String { return contentProvider?.authorForDisplay() ?? "" } - private func postTitle() -> String { + func postTitle() -> String { return contentProvider?.titleForDisplay() ?? "" } - private func postContent() -> String { + func postContent() -> String { return contentProvider?.contentPreviewForDisplay() ?? "" } - private func datePublished() -> String { - return (contentProvider?.dateForDisplay() as NSDate?)?.mediumString() ?? "" + func datePublished() -> String { + return contentProvider?.dateForDisplay()?.toMediumString() ?? "" } } @@ -893,11 +983,39 @@ extension ReaderPostCardCell { return menuButton } - func getVisitButtonForTesting() -> UIButton { - return visitButton - } - func getReblogButtonForTesting() -> UIButton { return reblogActionButton } } + +extension ReaderPostCardCell: GhostableView { + public func ghostAnimationWillStart() { + borderedView.isGhostableDisabled = true + attributionView.isHidden = true + menuButton.layer.opacity = 0 + commentActionButton.setTitle("", for: .normal) + likeActionButton.setTitle("", for: .normal) + authorAndBlogNameStackView.spacing = 0 + headerBlogButton.isHidden = true + labelsStackView.distribution = .fillEqually + labelsStackView.spacing = 8 + hostAndTimeStackView.alignment = .fill + bylineLabel.text = nil + bylineLabel.widthAnchor.constraint(equalTo: hostAndTimeStackView.widthAnchor, multiplier: 0.33).isActive = true + bylineLabel.isGhostableDisabled = true + featuredImageView.layer.borderWidth = 0 + ghostPlaceholderView.isHidden = false + } +} + +extension ReaderPostCardCell: ReaderTopicCollectionViewCoordinatorDelegate { + func coordinator(_ coordinator: ReaderTopicCollectionViewCoordinator, didChangeState: ReaderTopicCollectionViewState) { + layoutIfNeeded() + + topicChipsDelegate?.heightDidChange() + } + + func coordinator(_ coordinator: ReaderTopicCollectionViewCoordinator, didSelectTopic topic: String) { + topicChipsDelegate?.didSelect(topic: topic) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderPostCardCell.xib b/WordPress/Classes/ViewRelated/Reader/ReaderPostCardCell.xib index d5f28ad229be..031350172980 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderPostCardCell.xib +++ b/WordPress/Classes/ViewRelated/Reader/ReaderPostCardCell.xib @@ -1,9 +1,10 @@ - - + + - + + @@ -17,105 +18,186 @@ - + - - + + + + + + + - - - - - - - - - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + - + + + - - + + - + - - + - + - - + + - - - - - - - - - - - + - + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderReportPostAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderReportPostAction.swift new file mode 100644 index 000000000000..3fc8a45f827e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderReportPostAction.swift @@ -0,0 +1,67 @@ +/// Encapsulates a command to report a post +final class ReaderReportPostAction { + func execute(with post: ReaderPost, target: Target = .post, context: NSManagedObjectContext, origin: UIViewController) { + guard let url = Self.reportURL(with: post, target: target) else { + return + } + + let configuration = WebViewControllerConfiguration(url: url) + configuration.addsWPComReferrer = true + + if let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) { + configuration.authenticate(account: account) + } + + let controller = WebViewControllerFactory.controller(configuration: configuration, source: "reader_report") + let navController = UINavigationController(rootViewController: controller) + origin.present(navController, animated: true) + + // Track the report action + switch target { + case .post: + let properties = ReaderHelpers.statsPropertiesForPost(post, andValue: nil, forKey: nil) + WPAnalytics.trackReader(.readerPostReported, properties: properties) + case .author: + let properties = ReaderHelpers.statsPropertiesForPostAuthor(post) + WPAnalytics.trackReader(.readerPostAuthorReported, properties: properties) + } + } + + /// Safely generate the report URL + private static func reportURL(with post: ReaderPost, target: Target) -> URL? { + guard let postURLString = post.permaLink, + var components = URLComponents(string: Constants.reportURLString) + else { + return nil + } + + var queryItems = [URLQueryItem(name: Constants.reportKey, value: postURLString)] + + if target == .author { + guard let authorID = post.authorID?.stringValue else { + DDLogWarn("Author ID is required to report a post's author") + return nil + } + queryItems.append(.init(name: Constants.userKey, value: authorID)) + } + + components.queryItems = queryItems + return components.url + } + + // MARK: - Types + + enum Target { + /// Report the post itself. + case post + + /// Report the post's author. + case author + } + + private struct Constants { + static let reportURLString = "https://wordpress.com/abuse/" + static let reportKey = "report_url" + static let userKey = "report_user_id" + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLater+Analytics.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLater+Analytics.swift index e8839c0864b7..11b5d5890a2b 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLater+Analytics.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLater+Analytics.swift @@ -32,7 +32,8 @@ enum ReaderSaveForLaterOrigin { } } - fileprivate var viewAllPostsValue: String { + // TODO: - READERNAV - Refactor this and ReaderStreamViewController+Helper once the old reader is removed + var viewAllPostsValue: String { switch self { case .savedStream: return "post_list_saved_post_notice" @@ -65,24 +66,19 @@ extension ReaderSaveForLaterAction { func trackViewAllSavedPostsAction(origin: ReaderSaveForLaterOrigin) { let properties = [ readerSaveForLaterSourceKey: origin.viewAllPostsValue ] - WPAppAnalytics.track(.readerSavedListViewed, withProperties: properties) - } -} - -extension ReaderMenuViewController { - func trackSavedPostsNavigation() { - WPAppAnalytics.track(.readerSavedListViewed, withProperties: [ readerSaveForLaterSourceKey: ReaderSaveForLaterOrigin.readerMenu.viewAllPostsValue ]) - } -} - -extension ReaderSavedPostsViewController { - func trackSavedPostNavigation() { - WPAppAnalytics.track(.readerSavedPostOpened, withProperties: [ readerSaveForLaterSourceKey: ReaderSaveForLaterOrigin.savedStream.openPostValue ]) + WPAnalytics.trackReader(.readerSavedListShown, properties: properties) } } extension ReaderStreamViewController { func trackSavedPostNavigation() { - WPAppAnalytics.track(.readerSavedPostOpened, withProperties: [ readerSaveForLaterSourceKey: ReaderSaveForLaterOrigin.otherStream.openPostValue ]) + if contentType == .saved { + WPAppAnalytics.track(.readerSavedPostOpened, + withProperties: [readerSaveForLaterSourceKey: ReaderSaveForLaterOrigin.savedStream.openPostValue]) + } else { + // TODO: - READERNAV - See refactor note in ReaderSaveForLater+Analytics.viewAllPostsValue. + WPAppAnalytics.track(.readerSavedPostOpened, + withProperties: [readerSaveForLaterSourceKey: ReaderSaveForLaterOrigin.otherStream.openPostValue]) + } } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLaterAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLaterAction.swift index ea679a20596d..d834e1571c8f 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLaterAction.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSaveForLaterAction.swift @@ -11,26 +11,36 @@ final class ReaderSaveForLaterAction { static let removeFromSavedError = NSLocalizedString("Could not remove post from Saved for Later", comment: "Title of a prompt.") } - private let visibleConfirmation: Bool + var visibleConfirmation: Bool init(visibleConfirmation: Bool = false) { self.visibleConfirmation = visibleConfirmation } - func execute(with post: ReaderPost, context: NSManagedObjectContext, origin: ReaderSaveForLaterOrigin, completion: (() -> Void)? = nil) { + func execute(with post: ReaderPost, context: NSManagedObjectContext, origin: ReaderSaveForLaterOrigin, viewController: UIViewController?, completion: (() -> Void)? = nil) { + /// Preload the post + if let viewController = viewController, !post.isSavedForLater { + let offlineReaderWebView = OfflineReaderWebView() + offlineReaderWebView.saveForLater(post, viewController: viewController) + } + trackSaveAction(for: post, origin: origin) toggleSavedForLater(post, context: context, origin: origin, completion: completion) } private func toggleSavedForLater(_ post: ReaderPost, context: NSManagedObjectContext, origin: ReaderSaveForLaterOrigin, completion: (() -> Void)?) { - let readerPostService = ReaderPostService(managedObjectContext: context) + let readerPostService = ReaderPostService(coreDataStack: ContextManager.shared) readerPostService.toggleSavedForLater(for: post, success: { - self.presentSuccessNotice(for: post, context: context, origin: origin, completion: completion) + if origin == .otherStream { + self.presentSuccessNotice(for: post, context: context, origin: origin, completion: completion) + } completion?() - }, failure: { error in + }, failure: { error in + if origin == .otherStream { self.presentErrorNotice(error, activating: !post.isSavedForLater) - completion?() + } + completion?() }) } @@ -55,7 +65,7 @@ final class ReaderSaveForLaterAction { actionTitle: Strings.viewAll, actionHandler: { _ in self.trackViewAllSavedPostsAction(origin: origin) - self.showAll() + RootViewCoordinator.sharedPresenter.switchToSavedPosts() }) present(notice) @@ -93,7 +103,4 @@ final class ReaderSaveForLaterAction { ActionDispatcher.dispatch(NoticeAction.post(notice)) } - private func showAll() { - NotificationCenter.default.post(name: .showAllSavedForLaterPosts, object: self, userInfo: nil) - } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostCellActions.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostCellActions.swift index 8bfbbacc0a73..d90ded958b12 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostCellActions.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostCellActions.swift @@ -1,35 +1,15 @@ -protocol ReaderSavedPostCellActionsDelegate: class { +protocol ReaderSavedPostCellActionsDelegate: AnyObject { func willRemove(_ cell: ReaderPostCardCell) } /// Specialises ReaderPostCellActions to provide specific overrides for the ReaderSavedPostsViewController final class ReaderSavedPostCellActions: ReaderPostCellActions { - /// Posts that have been removed but not yet discarded - private var removedPosts = ReaderSaveForLaterRemovedPosts() - - weak var delegate: ReaderSavedPostCellActionsDelegate? override func readerCell(_ cell: ReaderPostCardCell, saveActionForProvider provider: ReaderPostContentProvider) { if let post = provider as? ReaderPost { removedPosts.add(post) } - delegate?.willRemove(cell) - } - - func postIsRemoved(_ post: ReaderPost) -> Bool { - return removedPosts.contains(post) - } - - func restoreUnsavedPost(_ post: ReaderPost) { - removedPosts.remove(post) - } - - func clearRemovedPosts() { - let allRemovedPosts = removedPosts.all() - for post in allRemovedPosts { - toggleSavedForLater(for: post) - } - removedPosts = ReaderSaveForLaterRemovedPosts() + savedPostsDelegate?.willRemove(cell) } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostUndoCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostUndoCell.swift index 454c4e445751..221f1668d0bd 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostUndoCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostUndoCell.swift @@ -41,13 +41,17 @@ final class ReaderSavedPostUndoCell: UITableViewCell { private func setupUndoButton() { undoButton.setTitle(Strings.undo, for: .normal) - let icon = Gridicon.iconOfType(.undo) + let icon = UIImage.gridicon(.undo) let tintedIcon = icon.imageWithTintColor(.primary) undoButton.setImage(tintedIcon, for: .normal) } private func applyStyles() { + backgroundColor = .clear + contentView.backgroundColor = .listBackground + borderedView.backgroundColor = .listForeground + borderedView.layer.borderColor = WPStyleGuide.readerCardCellBorderColor().cgColor borderedView.layer.borderWidth = .hairlineBorderWidth diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostUndoCell.xib b/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostUndoCell.xib index 9fdf50c21f34..2f9348d75aeb 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostUndoCell.xib +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostUndoCell.xib @@ -1,12 +1,9 @@ - - - - + + - - + @@ -14,66 +11,67 @@ - + - + - + - - - - + - + - + + - - - - - - - + + + + + + + + - + + diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostsViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostsViewController.swift deleted file mode 100644 index 684ad42854b7..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSavedPostsViewController.swift +++ /dev/null @@ -1,350 +0,0 @@ -import UIKit -import Gridicons -import WordPressShared -import WordPressUI - -final class ReaderSavedPostsViewController: UITableViewController { - private enum Strings { - static let title = NSLocalizedString("Saved Posts", comment: "Title for list of posts saved for later") - } - - private enum UndoCell { - static let nibName = "ReaderSavedPostUndoCell" - static let reuseIdentifier = "ReaderUndoCellReuseIdentifier" - static let height: CGFloat = 44 - } - - private var noResultsViewController = NoResultsViewController.controller() - fileprivate var footerView: PostListFooterView! - fileprivate let heightForFooterView = CGFloat(34.0) - fileprivate let estimatedHeightsCache = NSCache() - - /// Content management - private let content = ReaderTableContent() - /// Configuration of table view and registration of cells - private let tableConfiguration = ReaderTableConfiguration() - /// Configuration of cells - private let cellConfiguration = ReaderCellConfiguration() - /// Actions - private var postCellActions: ReaderSavedPostCellActions? - - fileprivate lazy var displayContext: NSManagedObjectContext = ContextManager.sharedInstance().newMainContextChildContext() - - override func viewDidLoad() { - super.viewDidLoad() - - title = Strings.title - - setupTableView() - setupFooterView() - setupContentHandler() - - WPStyleGuide.configureColors(view: view, tableView: tableView) - - updateAndPerformFetchRequest() - refreshNoResultsView() - } - - deinit { - postCellActions?.clearRemovedPosts() - } - - // MARK: - Setup - - fileprivate func setupTableView() { - tableConfiguration.setup(tableView) - setupUndoCell(tableView) - } - - private func setupUndoCell(_ tableView: UITableView) { - let nib = UINib(nibName: UndoCell.nibName, bundle: nil) - tableView.register(nib, forCellReuseIdentifier: UndoCell.reuseIdentifier) - } - - func undoCell(_ tableView: UITableView) -> ReaderSavedPostUndoCell { - return tableView.dequeueReusableCell(withIdentifier: UndoCell.reuseIdentifier) as! ReaderSavedPostUndoCell - } - - fileprivate func setupContentHandler() { - content.initializeContent(tableView: tableView, delegate: self) - } - - /// The fetch request can need a different predicate depending on how the content - /// being displayed has changed (blocking sites for instance). Call this method to - /// update the fetch request predicate and then perform a new fetch. - /// - fileprivate func updateAndPerformFetchRequest() { - content.updateAndPerformFetchRequest(predicate: predicateForFetchRequest()) - } - - fileprivate func setupFooterView() { - footerView = tableConfiguration.footer() - footerView.showSpinner(false) - var frame = footerView.frame - frame.size.height = heightForFooterView - footerView.frame = frame - tableView.tableFooterView = footerView - footerView.isHidden = true - } - - - // MARK: - Helpers for TableViewHandler - - - @objc func predicateForFetchRequest() -> NSPredicate { - return NSPredicate(format: "isSavedForLater == YES") - } - - - @objc func sortDescriptorsForFetchRequest() -> [NSSortDescriptor] { - let sortDescriptor = NSSortDescriptor(key: "sortRank", ascending: false) - return [sortDescriptor] - } - - - @objc public func configurePostCardCell(_ cell: UITableViewCell, post: ReaderPost) { - if postCellActions == nil { - postCellActions = ReaderSavedPostCellActions(context: managedObjectContext(), origin: self, topic: post.topic, visibleConfirmation: false) - postCellActions?.delegate = self - } - - cellConfiguration.configurePostCardCell(cell, - withPost: post, - topic: post.topic, - delegate: postCellActions, - loggedInActionVisibility: .hidden) - } -} - -// MARK: - WPTableViewHandlerDelegate - -extension ReaderSavedPostsViewController: WPTableViewHandlerDelegate { - - // MARK: - Fetched Results Related - - public func managedObjectContext() -> NSManagedObjectContext { - return displayContext - } - - - public func fetchRequest() -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: ReaderPost.classNameWithoutNamespaces()) - fetchRequest.predicate = predicateForFetchRequest() - fetchRequest.sortDescriptors = sortDescriptorsForFetchRequest() - return fetchRequest - } - - - public func tableViewDidChangeContent(_ tableView: UITableView) { - refreshNoResultsView() - } - - // MARK: - TableView Related - - public override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - // When using UITableViewAutomaticDimension for auto-sizing cells, UITableView - // likes to reload rows in a strange way. - // It uses the estimated height as a starting value for reloading animations. - // So this estimated value needs to be as accurate as possible to avoid any "jumping" in - // the cell heights during reload animations. - // Note: There may (and should) be a way to get around this, but there is currently no obvious solution. - // Brent C. August 8/2016 - if let height = estimatedHeightsCache.object(forKey: indexPath as AnyObject) as? CGFloat { - // Return the previously known height as it was cached via willDisplayCell. - return height - } - return tableConfiguration.estimatedRowHeight() - } - - - override public func tableView(_ aTableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UITableView.automaticDimension - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return 0 - } - - override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return 0 - } - - override public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let posts = content.content as? [ReaderPost] else { - DDLogError("[ReaderStreamViewController tableView:cellForRowAtIndexPath:] fetchedObjects was nil.") - return UITableViewCell() - } - let post = posts[indexPath.row] - - if post.isCross() { - let cell = tableConfiguration.crossPostCell(tableView) - cellConfiguration.configureCrossPostCell(cell, - withContent: content, - atIndexPath: indexPath) - return cell - } - - - if postCellActions?.postIsRemoved(post) == true { - let cell = undoCell(tableView) - configureUndoCell(cell, with: post) - return cell - } - - let cell = tableConfiguration.postCardCell(tableView) - configurePostCardCell(cell, post: post) - return cell - } - - private func configureUndoCell(_ cell: ReaderSavedPostUndoCell, with post: ReaderPost) { - cell.title.text = post.titleForDisplay() - cell.delegate = self - } - - override public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - // Cache the cell's layout height as the currently known height, for estimation. - // See estimatedHeightForRowAtIndexPath - estimatedHeightsCache.setObject(cell.frame.height as AnyObject, forKey: indexPath as AnyObject) - - guard cell.isKind(of: ReaderPostCardCell.self) || cell.isKind(of: ReaderCrossPostCell.self) else { - return - } - - guard let posts = content.content as? [ReaderPost] else { - DDLogError("[ReaderStreamViewController tableView:willDisplayCell:] fetchedObjects was nil.") - return - } - // Bump the render tracker if necessary. - let post = posts[indexPath.row] - if !post.rendered, let railcar = post.railcarDictionary() { - post.rendered = true - WPAppAnalytics.track(.trainTracksRender, withProperties: railcar) - } - } - - - /// Retrieve an instance of the specified post from the main NSManagedObjectContext. - /// - /// - Parameters: - /// - post: The post to retrieve. - /// - /// - Returns: The post fetched from the main context or nil if the post does not exist in the context. - /// - @objc func postInMainContext(_ post: ReaderPost) -> ReaderPost? { - guard let post = (try? ContextManager.sharedInstance().mainContext.existingObject(with: post.objectID)) as? ReaderPost else { - DDLogError("Error retrieving an existing post from the main context by its object ID.") - return nil - } - return post - } - - override public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let posts = content.content as? [ReaderPost] else { - DDLogError("[ReaderStreamViewController tableView:didSelectRowAtIndexPath:] fetchedObjects was nil.") - return - } - - let apost = posts[indexPath.row] - guard let post = postInMainContext(apost) else { - return - } - - if let topic = post.topic, ReaderHelpers.isTopicSearchTopic(topic) { - WPAppAnalytics.track(.readerSearchResultTapped) - - // We can use `if let` when `ReaderPost` adopts nullability. - let railcar = apost.railcarDictionary() - if railcar != nil { - WPAppAnalytics.trackTrainTracksInteraction(.readerSearchResultTapped, withProperties: railcar) - } - } - - var controller: ReaderDetailViewController - if post.sourceAttributionStyle() == .post && - post.sourceAttribution.postID != nil && - post.sourceAttribution.blogID != nil { - - controller = ReaderDetailViewController.controllerWithPostID(post.sourceAttribution.postID!, siteID: post.sourceAttribution.blogID!) - - } else if post.isCross() { - controller = ReaderDetailViewController.controllerWithPostID(post.crossPostMeta.postID, siteID: post.crossPostMeta.siteID) - - } else { - controller = ReaderDetailViewController.controllerWithPost(post) - - } - - trackSavedPostNavigation() - - navigationController?.pushFullscreenViewController(controller, animated: true) - - tableView.deselectRow(at: indexPath, animated: false) - } - - - public func configureCell(_ cell: UITableViewCell, at indexPath: IndexPath) { - // Do nothing - } -} - -// MARK: - No Results Handling - -private extension ReaderSavedPostsViewController { - - func refreshNoResultsView() { - noResultsViewController.removeFromView() - if content.isEmpty { - displayNoResultsView() - } - } - - func displayNoResultsView() { - configureNoResultsText() - addChild(noResultsViewController) - tableView.addSubview(withFadeAnimation: noResultsViewController.view) - noResultsViewController.view.frame = tableView.frame - - // The tableView doesn't start at y = 0, making the No Results View vertically off-center. - // So adjust the NRV accordingly. - noResultsViewController.view.frame.origin.y -= tableView.frame.origin.y - - noResultsViewController.didMove(toParent: self) - } - - func configureNoResultsText() { - var messageText = NSMutableAttributedString(string: NoResultsText.subtitleFormat) - - // Get attributed string styled for No Results so it gets the correct font attributes added to it. - // The font is used by the attributed string `replace(_:with:)` method below to correctly position the icon. - let styledText = noResultsViewController.applyMessageStyleTo(attributedString: messageText) - messageText = NSMutableAttributedString(attributedString: styledText) - - let icon = Gridicon.iconOfType(.bookmarkOutline, withSize: CGSize(width: 18, height: 18)) - messageText.replace("[bookmark-outline]", with: icon) - - noResultsViewController.configure(title: NoResultsText.noResultsTitle, attributedSubtitle: messageText) - } - - struct NoResultsText { - static let noResultsTitle = NSLocalizedString("No Saved Posts", comment: "Message displayed in Reader Saved Posts view if a user hasn't yet saved any posts.") - static let subtitleFormat = NSLocalizedString("Tap [bookmark-outline] to save a post to your list.", comment: "A hint displayed in the Saved Posts section of the Reader. The '[bookmark-outline]' placeholder will be replaced by an icon at runtime – please leave that string intact.") - } -} - -extension ReaderSavedPostsViewController: ReaderSavedPostCellActionsDelegate { - func willRemove(_ cell: ReaderPostCardCell) { - if let cellIndex = tableView.indexPath(for: cell) { - tableView.reloadRows(at: [cellIndex], with: .fade) - } - } -} - -extension ReaderSavedPostsViewController: ReaderPostUndoCellDelegate { - func readerCellWillUndo(_ cell: ReaderSavedPostUndoCell) { - if let cellIndex = tableView.indexPath(for: cell), - let post: ReaderPost = content.object(at: cellIndex) { - postCellActions?.restoreUnsavedPost(post) - tableView.reloadRows(at: [cellIndex], with: .fade) - } - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSearchSuggestionsViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSearchSuggestionsViewController.swift index 389a274f89d7..2e5b2c4c9a94 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSearchSuggestionsViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSearchSuggestionsViewController.swift @@ -31,17 +31,26 @@ class ReaderSearchSuggestionsViewController: UIViewController { var delegate: ReaderSearchSuggestionsDelegate? @objc let cellIdentifier = "CellIdentifier" @objc let rowAndButtonHeight = CGFloat(44.0) + + private var height: CGFloat { + UIApplication.shared.mainWindow?.frame.size.height ?? 0 + } + + private var isLargeAccessibilitySize: Bool { + [ + UIContentSizeCategory.accessibilityExtraLarge, + UIContentSizeCategory.accessibilityExtraExtraLarge, + UIContentSizeCategory.accessibilityExtraExtraExtraLarge + ].contains(traitCollection.preferredContentSizeCategory) + } + @objc var maxTableViewRows: Int { - let height = UIApplication.shared.keyWindow?.frame.size.height ?? 0 - if height == 320 { - // iPhone 4s, 5, 5s, in landscape orientation + if height <= 428 { + // Any iPhone landscape return 1 - } else if height <= 480 { - // iPhone 4s in portrait orientation - return 2 - } else if height <= 568 { - // iPhone 5, 5s in portrait orientation - return 4 + } else if height <= 834 { + // iPhone 6s/SE2nd/7/8 in portrait orientation, iPad landscape + return isLargeAccessibilitySize ? 3 : 4 } // Everything else return 5 @@ -86,6 +95,7 @@ class ReaderSearchSuggestionsViewController: UIViewController { override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) { + super.willTransition(to: newCollection, with: coordinator) coordinator.animate(alongsideTransition: { (_) in self.updateHeightConstraint() }) @@ -133,10 +143,12 @@ class ReaderSearchSuggestionsViewController: UIViewController { @objc func clearSearchHistory() { - let service = ReaderSearchSuggestionService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let service = ReaderSearchSuggestionService(coreDataStack: ContextManager.sharedInstance()) service.deleteAllSuggestions() tableView.reloadData() updateHeightConstraint() + + WPAnalytics.trackReader(.readerSearchHistoryCleared) } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSearchViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSearchViewController.swift index f2660f302938..150cada27c25 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSearchViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSearchViewController.swift @@ -20,22 +20,50 @@ import Gridicons case .sites: return NSLocalizedString("Sites", comment: "Title of a Reader tab showing Sites matching a user's search query") } } + + var trackingValue: String { + switch self { + case .posts: return "posts" + case .sites: return "sites" + } + } + } + + private enum SearchSource: String { + case userInput = "user_input" + case searchHistory = "search_history" } // MARK: - Properties @IBOutlet fileprivate weak var searchBar: UISearchBar! @IBOutlet fileprivate weak var filterBar: FilterTabBar! - @IBOutlet fileprivate weak var label: UILabel! fileprivate var backgroundTapRecognizer: UITapGestureRecognizer! fileprivate var streamController: ReaderStreamViewController? - fileprivate var siteSearchController = ReaderSiteSearchViewController() + fileprivate lazy var jpSiteSearchController = JetpackBannerWrapperViewController( + childVC: ReaderSiteSearchViewController(), + screen: .readerSearch + ) + fileprivate var siteSearchController: ReaderSiteSearchViewController? { + return jpSiteSearchController.childVC as? ReaderSiteSearchViewController + } fileprivate let searchBarSearchIconSize = CGFloat(13.0) fileprivate var suggestionsController: ReaderSearchSuggestionsViewController? fileprivate var restoredSearchTopic: ReaderSearchTopic? fileprivate var didBumpStats = false + private lazy var bannerView: JetpackBannerView = { + let textProvider = JetpackBrandingTextProvider(screen: JetpackBannerScreen.readerSearch) + let bannerView = JetpackBannerView() + bannerView.configure(title: textProvider.brandingText()) { [unowned self] in + JetpackBrandingCoordinator.presentOverlay(from: self) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBannerTapped(screen: .readerSearch) + } + bannerView.frame = CGRect(x: 0, y: 0, width: view.frame.width, height: JetpackBannerView.minimumHeight) + return bannerView + }() + fileprivate let sections: [Section] = [ .posts, .sites ] @@ -49,6 +77,14 @@ import Gridicons return controller } + @objc open class func controller(withSearchText searchText: String) -> ReaderSearchViewController { + let controller = controller() + controller.loadViewIfNeeded() + controller.searchBar.searchTextField.text = searchText + controller.performSearch() + return controller + } + // MARK: - State Restoration @@ -59,9 +95,7 @@ import Gridicons return ReaderSearchViewController.controller() } - let context = ContextManager.sharedInstance().mainContext - let service = ReaderTopicService(managedObjectContext: context) - guard let topic = service.find(withPath: path) as? ReaderSearchTopic else { + guard let topic = try? ReaderAbstractTopic.lookup(withPath: path, in: ContextManager.shared.mainContext) as? ReaderSearchTopic else { return ReaderSearchViewController.controller() } @@ -99,11 +133,11 @@ import Gridicons super.viewDidLoad() navigationItem.title = NSLocalizedString("Search", comment: "Title of the Reader's search feature") + navigationItem.largeTitleDisplayMode = .never WPStyleGuide.configureColors(view: view, tableView: nil) setupSearchBar() configureFilterBar() - configureLabel() configureBackgroundTapRecognizer() configureForRestoredTopic() configureSiteSearchViewController() @@ -117,8 +151,7 @@ import Gridicons } // When the parent is nil then we've been removed from the nav stack. // Clean up any search topics at this point. - let context = ContextManager.sharedInstance().mainContext - ReaderTopicService(managedObjectContext: context).deleteAllSearchTopics() + ReaderTopicService(coreDataStack: ContextManager.shared).deleteAllSearchTopics() } @@ -147,6 +180,7 @@ import Gridicons if didBumpStats { return } + WPAppAnalytics.track(.readerSearchLoaded) didBumpStats = true } @@ -155,16 +189,32 @@ import Gridicons // MARK: - Configuration - @objc func setupSearchBar() { + private func setupSearchBar() { // Appearance must be set before the search bar is added to the view hierarchy. let placeholderText = NSLocalizedString("Search WordPress", comment: "Placeholder text for the Reader search feature.") - let attributes = WPStyleGuide.defaultSearchBarTextAttributesSwifted(.neutral(.shade30)) - let attributedPlaceholder = NSAttributedString(string: placeholderText, attributes: attributes) - UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self, ReaderSearchViewController.self]).attributedPlaceholder = attributedPlaceholder - let textAttributes = WPStyleGuide.defaultSearchBarTextAttributesSwifted(.neutral(.shade60)) - UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self, ReaderSearchViewController.self]).defaultTextAttributes = textAttributes + UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self, ReaderSearchViewController.self]).placeholder = placeholderText + searchBar.becomeFirstResponder() WPStyleGuide.configureSearchBar(searchBar) + guard JetpackBrandingVisibility.all.enabled else { + return + } + searchBar.inputAccessoryView = bannerView + hideBannerViewIfNeeded() + } + + /// hides the Jetpack powered banner on iPhone landscape + private func hideBannerViewIfNeeded() { + guard JetpackBrandingVisibility.all.enabled else { + return + } + // hide the banner on iPhone landscape + bannerView.isHidden = traitCollection.verticalSizeClass == .compact + } + + open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + hideBannerViewIfNeeded() } func configureFilterBar() { @@ -176,15 +226,6 @@ import Gridicons filterBar.addTarget(self, action: #selector(selectedFilterDidChange(_:)), for: .valueChanged) } - @objc func configureLabel() { - let text = NSLocalizedString("Search WordPress\nfor a site or post", comment: "A short message that is a call to action for the Reader's Search feature.") - let rawAttributes = WPNUXUtility.titleAttributes(with: .neutral(.shade50)) as! [String: Any] - let swiftedAttributes = NSAttributedString.Key.convertFromRaw(attributes: rawAttributes) - label.numberOfLines = 2 - label.attributedText = NSAttributedString(string: text, attributes: swiftedAttributes) - } - - @objc func configureBackgroundTapRecognizer() { backgroundTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(ReaderSearchViewController.handleBackgroundTap(_:))) backgroundTapRecognizer.cancelsTouchesInView = true @@ -198,31 +239,30 @@ import Gridicons guard let topic = restoredSearchTopic else { return } - label.isHidden = true searchBar.text = topic.title streamController?.readerTopic = topic } private func configureSiteSearchViewController() { - siteSearchController.view.translatesAutoresizingMaskIntoConstraints = false + jpSiteSearchController.view.translatesAutoresizingMaskIntoConstraints = false - addChild(siteSearchController) + addChild(jpSiteSearchController) - view.addSubview(siteSearchController.view) + view.addSubview(jpSiteSearchController.view) NSLayoutConstraint.activate([ - view.leadingAnchor.constraint(equalTo: siteSearchController.view.leadingAnchor), - view.trailingAnchor.constraint(equalTo: siteSearchController.view.trailingAnchor), - filterBar.bottomAnchor.constraint(equalTo: siteSearchController.view.topAnchor), - view.bottomAnchor.constraint(equalTo: siteSearchController.view.bottomAnchor), + view.leadingAnchor.constraint(equalTo: jpSiteSearchController.view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: jpSiteSearchController.view.trailingAnchor), + filterBar.bottomAnchor.constraint(equalTo: jpSiteSearchController.view.topAnchor), + view.bottomAnchor.constraint(equalTo: jpSiteSearchController.view.bottomAnchor), ]) - siteSearchController.didMove(toParent: self) + jpSiteSearchController.didMove(toParent: self) if let topic = restoredSearchTopic { - siteSearchController.searchQuery = topic.title + siteSearchController?.searchQuery = topic.title } - siteSearchController.view.isHidden = true + jpSiteSearchController.view.isHidden = true } // MARK: - Actions @@ -236,13 +276,24 @@ import Gridicons /// Constructs a ReaderSearchTopic from the search phrase and sets the /// embedded stream to the topic. /// - @objc func performSearch() { + private func performSearch(source: SearchSource = .userInput) { guard let phrase = searchBar.text?.trim(), !phrase.isEmpty else { return } performPostsSearch(for: phrase) performSitesSearch(for: phrase) + trackSearchPerformed(source: source) + } + + private func trackSearchPerformed(source: SearchSource) { + let selectedTab: Section = Section(rawValue: filterBar.selectedIndex) ?? .posts + let properties: [AnyHashable: Any] = [ + "source": source.rawValue, + "type": selectedTab.trackingValue + ] + + WPAppAnalytics.track(.readerSearchPerformed, withProperties: properties) } private func performPostsSearch(for phrase: String) { @@ -252,24 +303,25 @@ import Gridicons let previousTopic = streamController.readerTopic - let context = ContextManager.sharedInstance().mainContext - let service = ReaderTopicService(managedObjectContext: context) - - let topic = service.searchTopic(forSearchPhrase: phrase) - streamController.readerTopic = topic - WPAppAnalytics.track(.readerSearchPerformed) + let service = ReaderTopicService(coreDataStack: ContextManager.shared) + service.createSearchTopic(forSearchPhrase: phrase) { topicID in + assert(Thread.isMainThread) + self.endSearch() - // Hide the starting label now that a topic has been set. - label.isHidden = true - endSearch() + guard let topicID, let topic = try? ContextManager.shared.mainContext.existingObject(with: topicID) as? ReaderAbstractTopic else { + DDLogError("Failed to create a search topic") + return + } + streamController.readerTopic = topic - if let previousTopic = previousTopic { - service.delete(previousTopic) + if let previousTopic, topic != previousTopic { + service.delete(previousTopic) + } } } private func performSitesSearch(for query: String) { - siteSearchController.searchQuery = query + siteSearchController?.searchQuery = query } @@ -283,10 +335,10 @@ import Gridicons switch section { case .posts: streamController?.view.isHidden = false - siteSearchController.view.isHidden = true + jpSiteSearchController.view.isHidden = true case .sites: streamController?.view.isHidden = true - siteSearchController.view.isHidden = false + jpSiteSearchController.view.isHidden = false } } @@ -411,7 +463,7 @@ extension ReaderSearchViewController: ReaderSearchSuggestionsDelegate { @objc func searchSuggestionsController(_ controller: ReaderSearchSuggestionsViewController, selectedItem: String) { searchBar.text = selectedItem - performSearch() + performSearch(source: .searchHistory) } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSeenAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSeenAction.swift new file mode 100644 index 000000000000..3bce60fab080 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSeenAction.swift @@ -0,0 +1,7 @@ +/// Encapsulates a command to toggle a post's seen status +final class ReaderSeenAction { + func execute(with post: ReaderPost, context: NSManagedObjectContext, completion: (() -> Void)? = nil, failure: ((Error?) -> Void)? = nil) { + let postService = ReaderPostService(coreDataStack: ContextManager.shared) + postService.toggleSeen(for: post, success: completion, failure: failure) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderShareAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderShareAction.swift index 9235f77133f8..fb311b0d45f2 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderShareAction.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderShareAction.swift @@ -1,11 +1,16 @@ /// Encapsulates a command share a post final class ReaderShareAction { func execute(with post: ReaderPost, context: NSManagedObjectContext, anchor: UIView, vc: UIViewController) { + self.execute(with: post, context: context, anchor: .view(anchor), vc: vc) + } + + func execute(with post: ReaderPost, context: NSManagedObjectContext, anchor: UIPopoverPresentationController.PopoverAnchor, vc: UIViewController) { let postID = post.objectID if let post: ReaderPost = ReaderActionHelpers.existingObject(for: postID, in: context) { let sharingController = PostSharingController() - sharingController.shareReaderPost(post, fromView: anchor, inViewController: vc) + sharingController.shareReaderPost(post, fromAnchor: anchor, inViewController: vc) + WPAnalytics.trackReader(.itemSharedReader, properties: ["blogId": post.siteID as Any]) } } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderShowAttributionAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderShowAttributionAction.swift index 6dfa83783772..11a80cfd82cb 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderShowAttributionAction.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderShowAttributionAction.swift @@ -22,7 +22,7 @@ final class ReaderShowAttributionAction { } let configuration = WebViewControllerConfiguration(url: linkURL) configuration.addsWPComReferrer = true - let controller = WebViewControllerFactory.controller(configuration: configuration) + let controller = WebViewControllerFactory.controller(configuration: configuration, source: "reader_attribution") let navController = UINavigationController(rootViewController: controller) origin.present(navController, animated: true) } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderShowMenuAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderShowMenuAction.swift index bebd6e47d132..5c7954c0f8b3 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderShowMenuAction.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderShowMenuAction.swift @@ -1,3 +1,4 @@ +import UIKit /// Encapsulates a command to create and handle the extended menu for each post in Reader final class ReaderShowMenuAction { private let isLoggedIn: Bool @@ -6,47 +7,160 @@ final class ReaderShowMenuAction { isLoggedIn = loggedIn } - func execute(with post: ReaderPost, context: NSManagedObjectContext, topic: ReaderSiteTopic? = nil, readerTopic: ReaderAbstractTopic?, anchor: UIView, vc: UIViewController) { + func execute(with post: ReaderPost, + context: NSManagedObjectContext, + siteTopic: ReaderSiteTopic? = nil, + readerTopic: ReaderAbstractTopic? = nil, + anchor: PopoverAnchor, + vc: UIViewController, + source: ReaderPostMenuSource, + followCommentsService: FollowCommentsService + ) { + // Create the action sheet let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) alertController.addCancelActionWithTitle(ReaderPostMenuButtonTitles.cancel, handler: nil) - // Block button - if shouldShowBlockSiteMenuItem(readerTopic: readerTopic) { + + // Block site button + if shouldShowBlockSiteMenuItem(readerTopic: readerTopic, post: post) { + let handler: (UIAlertAction) -> Void = { action in + guard let post: ReaderPost = ReaderActionHelpers.existingObject(for: post.objectID, in: context) else { + return + } + self.postSiteBlockingWillBeginNotification(post) + ReaderBlockSiteAction(asBlocked: true).execute(with: post, context: context, completion: { + ReaderHelpers.dispatchSiteBlockedMessage(post: post, success: true) + self.postSiteBlockingDidFinish(post) + }, + failure: { error in + ReaderHelpers.dispatchSiteBlockedMessage(post: post, success: false) + self.postSiteBlockingDidFail(post, error: error) + }) + } alertController.addActionWithTitle(ReaderPostMenuButtonTitles.blockSite, + style: .destructive, + handler: handler) + } + + // Block user button + if shouldShowBlockUserMenuItem(topic: readerTopic, post: post) { + let handler: (UIAlertAction) -> Void = { _ in + guard let post: ReaderPost = ReaderActionHelpers.existingObject(for: post.objectID, in: context) else { + return + } + self.postUserBlockingWillBeginNotification(post) + let action = ReaderBlockUserAction(context: context) + action.execute(with: post, blocked: true) { result in + switch result { + case .success: + ReaderHelpers.dispatchUserBlockedMessage(post: post, success: true) + case .failure: + ReaderHelpers.dispatchUserBlockedMessage(post: post, success: false) + } + self.postUserBlockingDidFinishNotification(post, result: result) + } + } + alertController.addActionWithTitle( + ReaderPostMenuButtonTitles.blockUser, + style: .destructive, + handler: handler + ) + } + + // Report post button + if shouldShowReportPostMenuItem(readerTopic: readerTopic, post: post) { + alertController.addActionWithTitle(ReaderPostMenuButtonTitles.reportPost, style: .destructive, handler: { (action: UIAlertAction) in if let post: ReaderPost = ReaderActionHelpers.existingObject(for: post.objectID, in: context) { - ReaderBlockSiteAction(asBlocked: true).execute(with: post, context: context, completion: {}) + ReaderReportPostAction().execute(with: post, context: context, origin: vc) } }) } + // Report user button + if shouldShowReportUserMenuItem(readerTopic: readerTopic, post: post) { + let handler: (UIAlertAction) -> Void = { _ in + guard let post: ReaderPost = ReaderActionHelpers.existingObject(for: post.objectID, in: context) else { + return + } + ReaderReportPostAction().execute(with: post, target: .author, context: context, origin: vc) + } + alertController.addActionWithTitle(ReaderPostMenuButtonTitles.reportPostAuthor, style: .destructive, handler: handler) + } + // Notification - if let topic = topic, isLoggedIn, post.isFollowing { - let isSubscribedForPostNotifications = topic.isSubscribedForPostNotifications + if let siteTopic = siteTopic, isLoggedIn, post.isFollowing { + let isSubscribedForPostNotifications = siteTopic.isSubscribedForPostNotifications let buttonTitle = isSubscribedForPostNotifications ? ReaderPostMenuButtonTitles.unsubscribe : ReaderPostMenuButtonTitles.subscribe alertController.addActionWithTitle(buttonTitle, style: .default, handler: { (action: UIAlertAction) in - if let topic: ReaderSiteTopic = ReaderActionHelpers.existingObject(for: topic.objectID, in: context) { - ReaderSubscribingNotificationAction().execute(for: topic.siteID, context: context, value: !topic.isSubscribedForPostNotifications) + if let topic: ReaderSiteTopic = ReaderActionHelpers.existingObject(for: siteTopic.objectID, in: context) { + let subscribe = !topic.isSubscribedForPostNotifications + + ReaderSubscribingNotificationAction().execute(for: topic.siteID, context: context, subscribe: subscribe, completion: { + ReaderHelpers.dispatchToggleNotificationMessage(topic: topic, success: true) + }, failure: { _ in + ReaderHelpers.dispatchToggleNotificationMessage(topic: topic, success: false) + }) } - }) + }) } // Following if isLoggedIn { let buttonTitle = post.isFollowing ? ReaderPostMenuButtonTitles.unfollow : ReaderPostMenuButtonTitles.follow + alertController.addActionWithTitle(buttonTitle, style: .default, handler: { (action: UIAlertAction) in if let post: ReaderPost = ReaderActionHelpers.existingObject(for: post.objectID, in: context) { - ReaderFollowAction().execute(with: post, context: context) + ReaderFollowAction().execute(with: post, + context: context, + completion: { follow in + ReaderHelpers.dispatchToggleFollowSiteMessage(post: post, follow: follow, success: true) + (vc as? ReaderStreamViewController)?.updateStreamHeaderIfNeeded() + }, failure: { follow, _ in + ReaderHelpers.dispatchToggleFollowSiteMessage(post: post, follow: follow, success: false) + }) } - }) + }) } + // Seen + if post.isSeenSupported { + alertController.addActionWithTitle(post.isSeen ? ReaderPostMenuButtonTitles.markUnseen : ReaderPostMenuButtonTitles.markSeen, + style: .default, + handler: { (action: UIAlertAction) in + + let event: WPAnalyticsEvent = post.isSeen ? .readerPostMarkUnseen : .readerPostMarkSeen + WPAnalytics.track(event, properties: ["source": source.description]) + + if let post: ReaderPost = ReaderActionHelpers.existingObject(for: post.objectID, in: context) { + ReaderSeenAction().execute(with: post, context: context, completion: { + ReaderHelpers.dispatchToggleSeenMessage(post: post, success: true) + + // Notify Reader Stream so the post card is updated. + NotificationCenter.default.post(name: .ReaderPostSeenToggled, + object: nil, + userInfo: [ReaderNotificationKeys.post: post]) + }, + failure: { _ in + ReaderHelpers.dispatchToggleSeenMessage(post: post, success: false) + }) + } + }) + } + + // Visit + alertController.addActionWithTitle(ReaderPostMenuButtonTitles.visit, + style: .default, + handler: { (action: UIAlertAction) in + ReaderVisitSiteAction().execute(with: post, context: context, origin: vc) + }) + // Share alertController.addActionWithTitle(ReaderPostMenuButtonTitles.share, style: .default, @@ -54,27 +168,128 @@ final class ReaderShowMenuAction { ReaderShareAction().execute(with: post, context: context, anchor: anchor, vc: vc) }) + // Comment Subscription (Follow Comments by Email & Notifications) + if post.canSubscribeComments { + let buttonTitle = post.isSubscribedComments ? ReaderPostMenuButtonTitles.unFollowConversation : ReaderPostMenuButtonTitles.followConversation + alertController.addActionWithTitle( + buttonTitle, + style: .default, + handler: { (action: UIAlertAction) in + if let post: ReaderPost = ReaderActionHelpers.existingObject(for: post.objectID, in: context) { + Self.trackToggleCommentSubscription(isSubscribed: post.isSubscribedComments, post: post, sourceViewController: vc) + + ReaderSubscribeCommentsAction().execute( + with: post, + context: context, + followCommentsService: followCommentsService, + sourceViewController: vc) { + (vc as? ReaderDetailViewController)?.updateFollowButtonState() + } + } + }) + } + if WPDeviceIdentification.isiPad() { alertController.modalPresentationStyle = .popover vc.present(alertController, animated: true) if let presentationController = alertController.popoverPresentationController { presentationController.permittedArrowDirections = .any - presentationController.sourceView = anchor - presentationController.sourceRect = anchor.bounds + switch anchor { + case .barButtonItem(let item): + presentationController.barButtonItem = item + case .view(let anchor): + presentationController.sourceView = anchor + presentationController.sourceRect = anchor.bounds + } } - } else { vc.present(alertController, animated: true) } } - fileprivate func shouldShowBlockSiteMenuItem(readerTopic: ReaderAbstractTopic?) -> Bool { - guard let topic = readerTopic else { + private func shouldShowBlockSiteMenuItem(readerTopic: ReaderAbstractTopic?, post: ReaderPost) -> Bool { + guard let topic = readerTopic, + isLoggedIn else { return false } - if isLoggedIn { - return ReaderHelpers.isTopicTag(topic) || ReaderHelpers.topicIsFreshlyPressed(topic) + + return ReaderHelpers.isTopicTag(topic) || + ReaderHelpers.topicIsDiscover(topic) || + ReaderHelpers.topicIsFreshlyPressed(topic) || + ReaderHelpers.topicIsFollowing(topic) + } + + private func shouldShowReportUserMenuItem(readerTopic: ReaderAbstractTopic?, post: ReaderPost) -> Bool { + return shouldShowReportPostMenuItem(readerTopic: readerTopic, post: post) + } + + private func shouldShowBlockUserMenuItem(topic: ReaderAbstractTopic?, post: ReaderPost) -> Bool { + return FeatureFlag.readerUserBlocking.enabled + && shouldShowReportUserMenuItem(readerTopic: topic, post: post) + && post.isWPCom + } + + private func shouldShowReportPostMenuItem(readerTopic: ReaderAbstractTopic?, post: ReaderPost) -> Bool { + return shouldShowBlockSiteMenuItem(readerTopic: readerTopic, post: post) + } + + private static func trackToggleCommentSubscription(isSubscribed: Bool, post: ReaderPost, sourceViewController: UIViewController) { + var properties = [String: Any]() + properties[WPAppAnalyticsKeyFollowAction] = isSubscribed ? "followed" : "unfollowed" + properties["notifications_enabled"] = isSubscribed + properties[WPAppAnalyticsKeyBlogID] = post.siteID + properties[WPAppAnalyticsKeySource] = Self.sourceForTrackingEvents(sourceViewController: sourceViewController) + WPAnalytics.trackReader(.readerMoreToggleFollowConversation, properties: properties) + } + + private static func sourceForTrackingEvents(sourceViewController: UIViewController) -> String { + if sourceViewController is ReaderDetailViewController { + return "reader_post_details_comments" + } else if sourceViewController is ReaderStreamViewController { + return "reader" } - return false + + return "unknown" + } + + // MARK: - Sending Notifications + + private func postSiteBlockingWillBeginNotification(_ post: ReaderPost) { + NotificationCenter.default.post(name: .ReaderSiteBlockingWillBegin, + object: nil, + userInfo: [ReaderNotificationKeys.post: post]) } + + /// Notify Reader Cards Stream so the post card is updated. + private func postSiteBlockingDidFinish(_ post: ReaderPost) { + NotificationCenter.default.post(name: .ReaderSiteBlocked, + object: nil, + userInfo: [ReaderNotificationKeys.post: post]) + } + + private func postSiteBlockingDidFail(_ post: ReaderPost, error: Error?) { + var userInfo: [String: Any] = [ReaderNotificationKeys.post: post] + if let error { + userInfo[ReaderNotificationKeys.error] = error + } + NotificationCenter.default.post(name: .ReaderSiteBlockingFailed, + object: nil, + userInfo: userInfo) + } + + private func postUserBlockingWillBeginNotification(_ post: ReaderPost) { + NotificationCenter.default.post(name: .ReaderUserBlockingWillBegin, + object: nil, + userInfo: [ReaderNotificationKeys.post: post]) + } + + private func postUserBlockingDidFinishNotification(_ post: ReaderPost, result: Result) { + let center = NotificationCenter.default + let userInfo: [String: Any] = [ReaderNotificationKeys.post: post, ReaderNotificationKeys.result: result] + center.post(name: .ReaderUserBlockingDidEnd, object: nil, userInfo: userInfo) + } + + // MARK: - Types + + typealias PopoverAnchor = UIPopoverPresentationController.PopoverAnchor } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSiteBlockingController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSiteBlockingController.swift new file mode 100644 index 000000000000..8591651bac34 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSiteBlockingController.swift @@ -0,0 +1,92 @@ +import Foundation + +protocol ReaderSiteBlockingControllerDelegate: AnyObject { + + func readerSiteBlockingController(_ controller: ReaderSiteBlockingController, willBeginBlockingSiteOfPost post: ReaderPost) + func readerSiteBlockingController(_ controller: ReaderSiteBlockingController, didBlockSiteOfPost post: ReaderPost, result: Result) +} + +extension ReaderSiteBlockingControllerDelegate { + + func readerSiteBlockingController(_ controller: ReaderSiteBlockingController, willBeginBlockingSiteOfPost post: ReaderPost) {} + func readerSiteBlockingController(_ controller: ReaderSiteBlockingController, didBlockSiteOfPost post: ReaderPost, result: Result) {} +} + +final class ReaderSiteBlockingController { + + // MARK: - Properties + + /// The delegate receives updates about the site being blocked. + weak var delegate: ReaderSiteBlockingControllerDelegate? + + /// Flag indicating whether sites are currently being blocked. + var isBlockingSites: Bool { + return !ongoingSitesBlocking.isEmpty + } + + /// Collection of site ids currently being blocked. + private var ongoingSitesBlocking = Set() + + // MARK: - Init + + init() { + self.observeSiteBlockingNotifications() + } + + // MARK: - Observing Notifications + + private func observeSiteBlockingNotifications() { + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(handleSiteBlockingWillBeginNotification(_:)), + name: .ReaderSiteBlockingWillBegin, + object: nil + ) + center.addObserver( + self, + selector: #selector(handleBlockSiteNotification(_:)), + name: .ReaderSiteBlocked, + object: nil + ) + center.addObserver( + self, + selector: #selector(handleSiteBlockingFailed(_:)), + name: .ReaderSiteBlockingFailed, + object: nil + ) + } + + // MARK: - Handling Notifications + + @objc private func handleSiteBlockingWillBeginNotification(_ notification: Foundation.Notification) { + guard let post = notification.userInfo?[ReaderNotificationKeys.post] as? ReaderPost else { + return + } + self.ongoingSitesBlocking.insert(post.siteID) + self.delegate?.readerSiteBlockingController(self, willBeginBlockingSiteOfPost: post) + } + + @objc private func handleBlockSiteNotification(_ notification: Foundation.Notification) { + guard let post = notification.userInfo?[ReaderNotificationKeys.post] as? ReaderPost else { + return + } + self.ongoingSitesBlocking.remove(post.siteID) + self.delegate?.readerSiteBlockingController(self, didBlockSiteOfPost: post, result: .success(())) + } + + @objc private func handleSiteBlockingFailed(_ notification: Foundation.Notification) { + guard let post = notification.userInfo?[ReaderNotificationKeys.post] as? ReaderPost else { + return + } + let error = (notification.userInfo?[ReaderNotificationKeys.error] as? Error) ?? BlockingError.unknown + self.ongoingSitesBlocking.remove(post.siteID) + self.delegate?.readerSiteBlockingController(self, didBlockSiteOfPost: post, result: .failure(error)) + } + + // MARK: - Types + + private enum BlockingError: Error { + case unknown + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSiteSearchViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSiteSearchViewController.swift index c8f81c218f6c..93fa9e6ad47c 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSiteSearchViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSiteSearchViewController.swift @@ -1,4 +1,5 @@ import UIKit +import Combine /// Displays search results from a reader site search. /// @@ -105,8 +106,7 @@ class ReaderSiteSearchViewController: UITableViewController, UIViewControllerRes showLoadingView() } - let context = ContextManager.sharedInstance().mainContext - let service = ReaderSiteSearchService(managedObjectContext: context) + let service = ReaderSiteSearchService(coreDataStack: ContextManager.shared) service.performSearch(with: query, page: page, success: { [weak self] (feeds, hasMore, totalFeeds) in @@ -350,13 +350,13 @@ class ReaderSiteSearchHeaderView: UIView { /// The delegate can then use this to re-set and resize the associated /// tableview's footer view property. /// -protocol ReaderSiteSearchFooterViewDelegate: class { +protocol ReaderSiteSearchFooterViewDelegate: AnyObject { func readerSiteSearchFooterViewDidChangeFrame(_ footerView: ReaderSiteSearchFooterView) } class ReaderSiteSearchFooterView: UIView { private let divider = UIView() - private let activityIndicator = UIActivityIndicatorView(style: .gray) + private let activityIndicator = UIActivityIndicatorView(style: .medium) weak var delegate: ReaderSiteSearchFooterViewDelegate? = nil private static let expandedHeight: CGFloat = 44.0 @@ -401,3 +401,13 @@ class ReaderSiteSearchFooterView: UIView { } } } + +// MARK: - JetpackBannerWrapperViewController + +extension ReaderSiteSearchViewController { + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + if let jetpackBannerWrapper = parent as? JetpackBannerWrapperViewController { + jetpackBannerWrapper.processJetpackBannerVisibility(scrollView) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSiteStreamHeader.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSiteStreamHeader.swift index 895aaa5b2be8..71cad564149b 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSiteStreamHeader.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSiteStreamHeader.swift @@ -1,5 +1,7 @@ import Foundation import WordPressShared +import Gridicons + // FIXME: comparison operators with optionals were removed from the Swift Standard Libary. // Consider refactoring the code to use the non-optional operators. fileprivate func < (lhs: T?, rhs: T?) -> Bool { @@ -26,13 +28,13 @@ fileprivate func > (lhs: T?, rhs: T?) -> Bool { @objc open class ReaderSiteStreamHeader: UIView, ReaderStreamHeader { - @IBOutlet fileprivate weak var borderedView: UIView! @IBOutlet fileprivate weak var avatarImageView: UIImageView! @IBOutlet fileprivate weak var titleLabel: UILabel! @IBOutlet fileprivate weak var detailLabel: UILabel! - @IBOutlet fileprivate weak var followButton: PostMetaButton! + @IBOutlet fileprivate weak var followButton: UIButton! @IBOutlet fileprivate weak var followCountLabel: UILabel! @IBOutlet fileprivate weak var descriptionLabel: UILabel! + @IBOutlet fileprivate weak var descriptionLabelTopConstraint: NSLayoutConstraint! open var delegate: ReaderStreamHeaderDelegate? fileprivate var defaultBlavatar = "blavatar-default" @@ -45,60 +47,68 @@ fileprivate func > (lhs: T?, rhs: T?) -> Bool { applyStyles() } - @objc func applyStyles() { - backgroundColor = .listBackground - borderedView.backgroundColor = .listForeground - borderedView.layer.borderColor = WPStyleGuide.readerCardCellBorderColor().cgColor - borderedView.layer.borderWidth = .hairlineBorderWidth + private func applyStyles() { WPStyleGuide.applyReaderStreamHeaderTitleStyle(titleLabel) WPStyleGuide.applyReaderStreamHeaderDetailStyle(detailLabel) WPStyleGuide.applyReaderSiteStreamDescriptionStyle(descriptionLabel) WPStyleGuide.applyReaderSiteStreamCountStyle(followCountLabel) } + open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) - // MARK: - Configuration + if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + preferredContentSizeDidChange() + } - @objc open func configureHeader(_ topic: ReaderAbstractTopic) { - assert(topic.isKind(of: ReaderSiteTopic.self), "Topic must be a site topic") + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + WPStyleGuide.applyReaderFollowButtonStyle(followButton) + } + } - let siteTopic = topic as! ReaderSiteTopic + // MARK: - Configuration - configureHeaderImage(siteTopic.siteBlavatar) + @objc open func configureHeader(_ topic: ReaderAbstractTopic) { + guard let siteTopic = topic as? ReaderSiteTopic else { + DDLogError("Topic must be a site topic") + return + } + followButton.isSelected = topic.following titleLabel.text = siteTopic.title + descriptionLabel.text = siteTopic.siteDescription + followCountLabel.text = formattedFollowerCountForTopic(siteTopic) detailLabel.text = URL(string: siteTopic.siteURL)?.host - WPStyleGuide.applyReaderFollowButtonStyle(followButton) - followButton.isSelected = topic.following + configureHeaderImage(siteTopic) - descriptionLabel.attributedText = attributedSiteDescriptionForTopic(siteTopic) - followCountLabel.text = formattedFollowerCountForTopic(siteTopic) + WPStyleGuide.applyReaderFollowButtonStyle(followButton) - if descriptionLabel.attributedText?.length > 0 { - descriptionLabel.isHidden = false - } else { - descriptionLabel.isHidden = true + if siteTopic.siteDescription.isEmpty { + descriptionLabelTopConstraint.constant = 0.0 } } - @objc func configureHeaderImage(_ siteBlavatar: String?) { - let placeholder = UIImage(named: defaultBlavatar) + private func configureHeaderImage(_ siteTopic: ReaderSiteTopic) { + let placeholder = UIImage.siteIconPlaceholder - var path = "" - if siteBlavatar != nil { - path = siteBlavatar! - } + guard let url = upscaledImageURL(urlString: siteTopic.siteBlavatar) else { + if siteTopic.isP2Type { + avatarImageView.tintColor = UIColor.listIcon + avatarImageView.layer.borderColor = UIColor.divider.cgColor + avatarImageView.layer.borderWidth = .hairlineBorderWidth + avatarImageView.image = UIImage.gridicon(.p2) + return + } - let url = URL(string: path) - if url != nil { - avatarImageView.downloadImage(from: url, placeholderImage: placeholder) - } else { avatarImageView.image = placeholder + return } + + avatarImageView.downloadImage(from: url, placeholderImage: placeholder) } - @objc func formattedFollowerCountForTopic(_ topic: ReaderSiteTopic) -> String { + private func formattedFollowerCountForTopic(_ topic: ReaderSiteTopic) -> String { let numberFormatter = NumberFormatter() numberFormatter.numberStyle = .decimal @@ -109,21 +119,10 @@ fileprivate func > (lhs: T?, rhs: T?) -> Bool { return str } - @objc func attributedSiteDescriptionForTopic(_ topic: ReaderSiteTopic) -> NSAttributedString { - return NSAttributedString(string: topic.siteDescription, attributes: WPStyleGuide.readerStreamHeaderDescriptionAttributes()) - } - @objc open func enableLoggedInFeatures(_ enable: Bool) { followButton.isHidden = !enable } - override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { - preferredContentSizeDidChange() - } - } - func preferredContentSizeDidChange() { applyStyles() } @@ -131,6 +130,38 @@ fileprivate func > (lhs: T?, rhs: T?) -> Bool { // MARK: - Actions @IBAction func didTapFollowButton(_ sender: UIButton) { - delegate?.handleFollowActionForHeader(self) + followButton.isUserInteractionEnabled = false + + delegate?.handleFollowActionForHeader(self) { [weak self] in + self?.followButton.isUserInteractionEnabled = true + } + } + + // MARK: - Private: Helpers + + /// Replaces the width query item (w) with an upscaled one for the image view + private func upscaledImageURL(urlString: String) -> URL? { + guard + let url = URL(string: urlString), + var components = URLComponents(url: url, resolvingAgainstBaseURL: true), + let host = components.host + else { + return nil + } + + // WP.com uses `w` and Gravatar uses `s` for the resizing query key + let widthKey = host.contains("gravatar") ? "s" : "w" + let width = Int(avatarImageView.bounds.width * UIScreen.main.scale) + let item = URLQueryItem(name: widthKey, value: "\(width)") + + var queryItems = components.queryItems ?? [] + + // Remove any existing size queries + queryItems.removeAll(where: { $0.name == widthKey}) + + queryItems.append(item) + components.queryItems = queryItems + + return components.url } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSiteStreamHeader.xib b/WordPress/Classes/ViewRelated/Reader/ReaderSiteStreamHeader.xib index e35a9d2eadbb..83dacb7a3242 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSiteStreamHeader.xib +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSiteStreamHeader.xib @@ -1,161 +1,101 @@ - - - - + + - + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - + + - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + + + - + - + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSitesCardCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSitesCardCell.swift new file mode 100644 index 000000000000..1191316ecb61 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSitesCardCell.swift @@ -0,0 +1,69 @@ +import UIKit + +/// A cell that displays topics the user might like +/// +class ReaderSitesCardCell: ReaderTopicsTableCardCell { + private let cellIdentifier = "SitesTopicCell" + + override func configure(_ data: [ReaderAbstractTopic]) { + super.configure(data) + + headerTitle = Constants.title + } + + override func setupTableView() { + super.setupTableView() + + let cell = UINib(nibName: "ReaderRecommendedSiteCardCell", bundle: Bundle.main) + tableView.register(cell, forCellReuseIdentifier: cellIdentifier) + } + + override func cell(forRowAt indexPath: IndexPath, tableView: UITableView, topic: ReaderAbstractTopic?) -> UITableViewCell { + guard + let siteTopic = topic as? ReaderSiteTopic, + let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath as IndexPath) as? ReaderRecommendedSiteCardCell + else { + return UITableViewCell() + } + + cell.configure(siteTopic) + cell.delegate = self + return cell + } + + func didToggleFollowing(_ topic: ReaderAbstractTopic, with success: Bool) { + guard let row = data.firstIndex(of: topic) else { + return + } + + tableView.reloadRows(at: [IndexPath(row: row, section: 0)], with: .none) + } + + private enum Constants { + static let title = NSLocalizedString("Sites to follow", comment: "A suggestion of topics the user might ") + } +} + +protocol ReaderSitesCardCellDelegate: ReaderTopicsTableCardCellDelegate { + func handleFollowActionForTopic(_ topic: ReaderAbstractTopic, for cell: ReaderSitesCardCell) +} + +extension ReaderSitesCardCell: ReaderRecommendedSitesCardCellDelegate { + func handleFollowActionForCell(_ cell: ReaderRecommendedSiteCardCell) { + guard + let indexPath = self.tableView.indexPath(for: cell), + let topic = data[indexPath.row] as? ReaderSiteTopic + else { + return + } + + (delegate as? ReaderSitesCardCellDelegate)?.handleFollowActionForTopic(topic, for: self) + + // Track Follow Action + var properties = [String: Any]() + properties[WPAppAnalyticsKeyFollowAction] = !topic.following + properties[WPAppAnalyticsKeyBlogID] = topic.siteID + + WPAnalytics.trackReader(.readerSuggestedSiteToggleFollow, properties: properties) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderStreamHeader.swift b/WordPress/Classes/ViewRelated/Reader/ReaderStreamHeader.swift index 36005dd9521e..d970a7c02cbf 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderStreamHeader.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderStreamHeader.swift @@ -1,7 +1,7 @@ import Foundation public protocol ReaderStreamHeaderDelegate: NSObjectProtocol { - func handleFollowActionForHeader(_ header: ReaderStreamHeader) + func handleFollowActionForHeader(_ header: ReaderStreamHeader, completion: @escaping () -> Void) } public protocol ReaderStreamHeader: NSObjectProtocol { diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Ghost.swift b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Ghost.swift new file mode 100644 index 000000000000..068f3ad15bcd --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Ghost.swift @@ -0,0 +1,40 @@ +import Foundation + +extension ReaderStreamViewController { + /// Show ghost card cells at the top of the tableView + func showGhost() { + guard ghostableTableView.superview == nil else { + return + } + + ghostableTableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(ghostableTableView) + NSLayoutConstraint.activate([ + ghostableTableView.widthAnchor.constraint(equalTo: tableView.widthAnchor, multiplier: 1), + ghostableTableView.heightAnchor.constraint(equalTo: tableView.heightAnchor, multiplier: 1), + ghostableTableView.centerXAnchor.constraint(equalTo: tableView.centerXAnchor), + ghostableTableView.centerYAnchor.constraint(equalTo: tableView.centerYAnchor) + ]) + + ghostableTableView.separatorStyle = .none + + let postCardTextCellNib = UINib(nibName: "ReaderPostCardCell", bundle: Bundle.main) + ghostableTableView.register(postCardTextCellNib, forCellReuseIdentifier: "ReaderPostCardCell") + + let ghostOptions = GhostOptions(displaysSectionHeader: false, reuseIdentifier: "ReaderPostCardCell", rowsPerSection: [10]) + let style = GhostStyle(beatDuration: GhostStyle.Defaults.beatDuration, + beatStartColor: .placeholderElement, + beatEndColor: .placeholderElementFaded) + ghostableTableView.estimatedRowHeight = 200 + ghostableTableView.removeGhostContent() + ghostableTableView.displayGhostContent(options: ghostOptions, style: style) + ghostableTableView.isScrollEnabled = false + ghostableTableView.isHidden = false + } + + /// Hide the ghost card cells + func hideGhost() { + ghostableTableView.removeGhostContent() + ghostableTableView.removeFromSuperview() + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Helper.swift b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Helper.swift index 04d4d724296c..6cf3af798d72 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Helper.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Helper.swift @@ -10,17 +10,7 @@ extension ReaderStreamViewController { var message: String } - func checkNewsCardAvailability(topic: ReaderAbstractTopic) { - let containerIdentifier = Identifier(value: topic.title) - let mustBadge = news.shouldPresentCard(containerIdentifier: containerIdentifier) - let notificationName: NSNotification.Name = mustBadge ? .NewsCardAvailable : .NewsCardNotAvailable - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01, execute: { - NotificationCenter.default.post(name: notificationName, object: nil) - }) - } - - /// Returns the ReaderStreamHeader appropriate for a particular ReaderTopic, including News Card, or nil if there is not one. + /// Returns the ReaderStreamHeader appropriate for a particular ReaderTopic. /// The header is returned already configured /// /// - Parameter topic: A ReaderTopic @@ -29,13 +19,11 @@ extension ReaderStreamViewController { /// /// - Returns: A configured instance of UIView. /// - func headerWithNewsCardForStream(_ topic: ReaderAbstractTopic, isLoggedIn: Bool, container: UITableViewController) -> UIView? { + func headerForStream(_ topic: ReaderAbstractTopic, isLoggedIn: Bool, container: UITableViewController) -> UIView? { let header = headerForStream(topic) configure(header, topic: topic, isLoggedIn: isLoggedIn, delegate: self) - let containerIdentifier = Identifier(value: topic.title) - - return news.newsCard(containerIdentifier: containerIdentifier, header: header, container: container, delegate: self) + return header } func configure(_ header: ReaderHeader?, topic: ReaderAbstractTopic, isLoggedIn: Bool, delegate: ReaderStreamHeaderDelegate) { @@ -45,34 +33,26 @@ extension ReaderStreamViewController { } func headerForStream(_ topic: ReaderAbstractTopic) -> ReaderHeader? { - if ReaderHelpers.topicIsFreshlyPressed(topic) || ReaderHelpers.topicIsLiked(topic) { - // no header for these special lists - return nil - } - - if ReaderHelpers.topicIsFollowing(topic) { - return Bundle.main.loadNibNamed("ReaderFollowedSitesStreamHeader", owner: nil, options: nil)!.first as! ReaderFollowedSitesStreamHeader - } - // if tag - if ReaderHelpers.isTopicTag(topic) { - return Bundle.main.loadNibNamed("ReaderTagStreamHeader", owner: nil, options: nil)!.first as! ReaderTagStreamHeader + if ReaderHelpers.isTopicTag(topic) && !isContentFiltered { + return Bundle.main.loadNibNamed("ReaderTagStreamHeader", owner: nil, options: nil)?.first as? ReaderTagStreamHeader } - // if list if ReaderHelpers.isTopicList(topic) { - return Bundle.main.loadNibNamed("ReaderListStreamHeader", owner: nil, options: nil)!.first as! ReaderListStreamHeader + return Bundle.main.loadNibNamed("ReaderListStreamHeader", owner: nil, options: nil)?.first as? ReaderListStreamHeader } - // if site - if ReaderHelpers.isTopicSite(topic) { - return Bundle.main.loadNibNamed("ReaderSiteStreamHeader", owner: nil, options: nil)!.first as! ReaderSiteStreamHeader + if ReaderHelpers.isTopicSite(topic) && !isContentFiltered { + return Bundle.main.loadNibNamed("ReaderSiteStreamHeader", owner: nil, options: nil)?.first as? ReaderSiteStreamHeader } - // if anything else return nil return nil } + static let defaultResponse = NoResultsResponse( + title: NSLocalizedString("No recent posts", comment: "A message title"), + message: NSLocalizedString("No posts have been made recently", comment: "A default message shown when the reader can find no post to display")) + /// Returns a NoResultsResponse instance appropriate for the specified ReaderTopic /// /// - Parameter topic: A ReaderTopic. @@ -130,10 +110,64 @@ extension ReaderStreamViewController { } // Default message - return NoResultsResponse( - title: NSLocalizedString("No recent posts", comment: "A message title"), - message: NSLocalizedString("No posts have been made recently", comment: "A default message shown whe the reader can find no post to display") - ) + return defaultResponse } +} + + +// MARK: - No Results for saved posts +extension ReaderStreamViewController { + + func configureNoResultsViewForSavedPosts() { + + let noResultsResponse = NoResultsResponse(title: NSLocalizedString("No saved posts", + comment: "Message displayed in Reader Saved Posts view if a user hasn't yet saved any posts."), + message: NSLocalizedString("Tap [bookmark-outline] to save a post to your list.", + comment: "A hint displayed in the Saved Posts section of the Reader. The '[bookmark-outline]' placeholder will be replaced by an icon at runtime – please leave that string intact.")) + + var messageText = NSMutableAttributedString(string: noResultsResponse.message) + + // Get attributed string styled for No Results so it gets the correct font attributes added to it. + // The font is used by the attributed string `replace(_:with:)` method below to correctly position the icon. + let styledText = resultsStatusView.applyMessageStyleTo(attributedString: messageText) + messageText = NSMutableAttributedString(attributedString: styledText) + let icon = UIImage.gridicon(.bookmarkOutline, size: CGSize(width: 18, height: 18)) + messageText.replace("[bookmark-outline]", with: icon) + + resultsStatusView.configureForLocalData(title: noResultsResponse.title, attributedSubtitle: messageText, image: "wp-illustration-reader-empty") + } +} + + +// MARK: - Undo cell for saved posts +extension ReaderStreamViewController { + + private enum UndoCell { + static let nibName = "ReaderSavedPostUndoCell" + static let reuseIdentifier = "ReaderUndoCellReuseIdentifier" + static let height: CGFloat = 44 + } + + func setupUndoCell(_ tableView: UITableView) { + let nib = UINib(nibName: UndoCell.nibName, bundle: nil) + tableView.register(nib, forCellReuseIdentifier: UndoCell.reuseIdentifier) + } + + func undoCell(_ tableView: UITableView) -> ReaderSavedPostUndoCell { + return tableView.dequeueReusableCell(withIdentifier: UndoCell.reuseIdentifier) as! ReaderSavedPostUndoCell + } + + func configureUndoCell(_ cell: ReaderSavedPostUndoCell, with post: ReaderPost) { + cell.title.text = post.titleForDisplay() + cell.delegate = self + } +} + + +// MARK: - Tracks +extension ReaderStreamViewController { + func trackSavedListAccessed() { + WPAnalytics.trackReader(.readerSavedListShown, properties: ["source": ReaderSaveForLaterOrigin.readerMenu.viewAllPostsValue]) + } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Sharing.swift b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Sharing.swift index 0db5b574a19c..7112fd616d7e 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Sharing.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController+Sharing.swift @@ -17,7 +17,7 @@ extension ReaderStreamViewController { return } - let image = Gridicon.iconOfType(.shareIOS).withRenderingMode(UIImage.RenderingMode.alwaysTemplate) + let image = UIImage.gridicon(.shareiOS).withRenderingMode(UIImage.RenderingMode.alwaysTemplate) let button = CustomHighlightButton(frame: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) button.setImage(image, for: .normal) button.addTarget(self, action: #selector(shareButtonTapped(_:)), for: .touchUpInside) diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift index c8d45fe92c39..36b552fc68ce 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderStreamViewController.swift @@ -4,6 +4,8 @@ import CocoaLumberjack import SVProgressHUD import WordPressShared import WordPressFlux +import UIKit +import Combine /// Displays a list of posts for a particular reader topic. /// - note: @@ -12,28 +14,80 @@ import WordPressFlux /// - This controller uses MULTIPLE NSManagedObjectContexts to manage syncing and state. /// - The topic exists in the main context /// - Syncing is performed on a derived (background) context. -/// - Content is fetched on a child context of the main context. This allows -/// new content to be synced without interrupting the UI until desired. /// - Row heights are auto-calculated via UITableViewAutomaticDimension and estimated heights /// are cached via willDisplayCell. /// -@objc class ReaderStreamViewController: UIViewController, UIViewControllerRestoration { +@objc class ReaderStreamViewController: UIViewController, UIViewControllerRestoration, ReaderSiteBlockingControllerDelegate { @objc static let restorationClassIdentifier = "ReaderStreamViewControllerRestorationIdentifier" @objc static let restorableTopicPathKey: String = "RestorableTopicPathKey" + // MARK: - Micro Controllers + + /// Object responsible for encapsulating and facililating the site blocking logic. + /// + /// Currently some of the site blocking logic is still performed by `ReaderStreamViewController` + /// but the goal is to move that logic to `ReaderSiteBlockingController`. + /// + /// There is nothing really wrong with keeping the blocking logic in `ReaderSiteBlockingController` but this + /// view controller is very large, with over 2000 lines of code! + private let siteBlockingController = ReaderPostBlockingController() + + /// Facilitates sharing of a blog via `ReaderStreamViewController+Sharing.swift`. + private let sharingController = PostSharingController() + + // MARK: - Services + + private lazy var readerPostService = ReaderPostService(coreDataStack: coreDataStack) + // MARK: - Properties /// Called if the stream or tag fails to load var streamLoadFailureBlock: (() -> Void)? = nil - private var tableView: UITableView! { + var shouldShowCommentSpotlight: Bool = false + + var tableView: UITableView! { return tableViewController.tableView } - private var syncHelper: WPContentSyncHelper! - private var resultsStatusView = NoResultsViewController.controller() + var jetpackBannerView: JetpackBannerView? - private lazy var footerView: PostListFooterView = { + private var syncHelpers: [ReaderAbstractTopic: WPContentSyncHelper] = [:] + + private var syncHelper: WPContentSyncHelper? { + guard let topic = readerTopic else { + return nil + } + let currentHelper = syncHelpers[topic] ?? WPContentSyncHelper() + syncHelpers[topic] = currentHelper + return currentHelper + } + + private var noResultsStatusViewController = NoResultsViewController.controller() + private var noFollowedSitesViewController: NoResultsViewController? + + private lazy var readerPostStreamService = ReaderPostStreamService(coreDataStack: coreDataStack) + + var resultsStatusView: NoResultsViewController { + get { + guard let noFollowedSitesVC = noFollowedSitesViewController else { + return noResultsStatusViewController + } + + return noFollowedSitesVC + } + } + + private var coreDataStack: CoreDataStack { + ContextManager.shared + } + + /// An alias for the apps's main context + private var viewContext: NSManagedObjectContext { + coreDataStack.mainContext + } + + private(set) lazy var footerView: PostListFooterView = { return tableConfiguration.footer() }() @@ -45,6 +99,8 @@ import WordPressFlux return refreshControl }() + private var noTopicController: UIViewController? + private let loadMoreThreashold = 4 private let refreshInterval = 300 @@ -58,11 +114,11 @@ import WordPressFlux private var syncIsFillingGap = false private var indexPathForGapMarker: IndexPath? private var didSetupView = false - private var listentingForBlockedSiteNotification = false private var didBumpStats = false + internal let scrollViewTranslationPublisher = PassthroughSubject() /// Content management - private let content = ReaderTableContent() + let content = ReaderTableContent() /// Configuration of table view and registration of cells private let tableConfiguration = ReaderTableConfiguration() /// Configuration of cells @@ -70,12 +126,6 @@ import WordPressFlux /// Actions private var postCellActions: ReaderPostCellActions? - let news = ReaderNewsCard() - - /// Used for fetching content. - private lazy var displayContext: NSManagedObjectContext = ContextManager.sharedInstance().newMainContextChildContext() - - private var siteID: NSNumber? { didSet { if siteID != nil { @@ -84,37 +134,48 @@ import WordPressFlux } } - private var tagSlug: String? { didSet { if tagSlug != nil { // Fixes https://github.com/wordpress-mobile/WordPress-iOS/issues/5223 - title = tagSlug + title = NSLocalizedString("Topic", comment: "Topic page title") fetchTagTopic() } } } - private var isShowingResultStatusView: Bool { return resultsStatusView.view?.superview != nil } + private var isLoadingDiscover: Bool { + return readerTopic == nil && + contentType == .topic && + siteID == ReaderHelpers.discoverSiteID + } /// The topic can be nil while a site or tag topic is being fetched, hence, optional. @objc var readerTopic: ReaderAbstractTopic? { didSet { - oldValue?.inUse = false + if let oldValue = oldValue { + oldValue.inUse = false + syncHelpers[oldValue]?.delegate = nil + } + syncHelper?.delegate = self - if let newTopic = readerTopic { + if let newTopic = readerTopic, + let context = newTopic.managedObjectContext { newTopic.inUse = true - ContextManager.sharedInstance().save(newTopic.managedObjectContext!) + ContextManager.sharedInstance().save(context) } if readerTopic != nil && readerTopic != oldValue { if didSetupView { - configureControllerForTopic() + updateContent() + if let syncHelper = syncHelper, syncHelper.isSyncing, !isShowingResultStatusView { + displayLoadingViewIfNeeded() + } } // Discard the siteID (if there was one) now that we have a good topic siteID = nil @@ -123,8 +184,31 @@ import WordPressFlux } } - /// Facilitates sharing of a blog via `ReaderStreamViewController+Sharing.swift`. - let sharingController = PostSharingController() + var isContentFiltered: Bool = false + + var contentType: ReaderContentType = .topic { + didSet { + if oldValue != .saved, contentType == .saved { + updateContent(synchronize: false) + trackSavedListAccessed() + } + postCellActions?.visibleConfirmation = contentType != .saved + } + } + + /// Used for the `source` property in Stats. + /// Indicates where the view was shown from. + enum StatSource: String { + case reader + case notif_like_list_user_profile + } + var statSource: StatSource = .reader + + let ghostableTableView = UITableView() + + private var cancellables = Set() + + // MARK: - Factory Methods /// Convenience method for instantiating an instance of ReaderStreamViewController /// for a existing topic. @@ -135,10 +219,13 @@ import WordPressFlux /// - Returns: An instance of the controller /// @objc class func controllerWithTopic(_ topic: ReaderAbstractTopic) -> ReaderStreamViewController { - let storyboard = UIStoryboard(name: "Reader", bundle: Bundle.main) - let controller = storyboard.instantiateViewController(withIdentifier: "ReaderStreamViewController") as! ReaderStreamViewController + // if a default discover topic is provided, treat it as a site to retrieve the header + if ReaderHelpers.topicIsDiscover(topic) { + return controllerWithSiteID(ReaderHelpers.discoverSiteID, isFeed: false) + } + + let controller = ReaderStreamViewController() controller.readerTopic = topic - controller.checkNewsCardAvailability(topic: topic) return controller } @@ -153,8 +240,7 @@ import WordPressFlux /// - Returns: An instance of the controller /// @objc class func controllerWithSiteID(_ siteID: NSNumber, isFeed: Bool) -> ReaderStreamViewController { - let storyboard = UIStoryboard(name: "Reader", bundle: Bundle.main) - let controller = storyboard.instantiateViewController(withIdentifier: "ReaderStreamViewController") as! ReaderStreamViewController + let controller = ReaderStreamViewController() controller.isFeed = isFeed controller.siteID = siteID @@ -177,6 +263,13 @@ import WordPressFlux return controller } + /// Convenience method to create an instance for saved posts + class func controllerForContentType(_ contentType: ReaderContentType) -> ReaderStreamViewController { + let controller = ReaderStreamViewController() + controller.contentType = contentType + return controller + } + // MARK: - State Restoration @@ -187,9 +280,7 @@ import WordPressFlux return nil } - let context = ContextManager.sharedInstance().mainContext - let service = ReaderTopicService(managedObjectContext: context) - guard let topic = service.find(withPath: path) else { + guard let topic = try? ReaderAbstractTopic.lookup(withPath: path, in: ContextManager.shared.mainContext) else { return nil } @@ -215,6 +306,8 @@ import WordPressFlux topic.inUse = false ContextManager.sharedInstance().save(topic.managedObjectContext!) } + + NotificationCenter.default.removeObserver(self) } @@ -228,17 +321,25 @@ import WordPressFlux override func viewDidLoad() { super.viewDidLoad() + // Setup Site Blocking Controller + self.siteBlockingController.delegate = self + // Disable the view until we have a topic. This prevents a premature // pull to refresh animation. view.isUserInteractionEnabled = readerTopic != nil + navigationItem.largeTitleDisplayMode = .never + NotificationCenter.default.addObserver(self, selector: #selector(defaultAccountDidChange(_:)), name: NSNotification.Name.WPAccountDefaultWordPressComAccountChanged, object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(postSeenToggled(_:)), name: .ReaderPostSeenToggled, object: nil) + refreshImageRequestAuthToken() - setupTableView() + configureCloseButtonIfNeeded() + setupStackView() setupFooterView() setupContentHandler() - setupSyncHelper() setupResultsStatusView() observeNetworkStatus() @@ -247,18 +348,35 @@ import WordPressFlux didSetupView = true - if readerTopic != nil { + NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + + guard !shouldDisplayNoTopicController else { + return + } + + if readerTopic != nil || contentType == .saved { // Do not perform a sync since a sync will be executed in viewWillAppear anyway. This // prevents a possible internet connection error being shown twice. - configureControllerForTopic(synchronize: false) + updateContent(synchronize: false) } else if (siteID != nil || tagSlug != nil) && isShowingResultStatusView == false { displayLoadingStream() } + + // Make sure the header is in-sync with the `readerTopic` object if it exists. + readerTopic? + .objectWillChange + .sink { [weak self] _ in + self?.updateStreamHeaderIfNeeded() + } + .store(in: &cancellables) } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + JetpackFeaturesRemovalCoordinator.presentOverlayIfNeeded(in: self, source: .reader) + syncIfAppropriate() } @@ -276,11 +394,17 @@ import WordPressFlux override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + if contentType == .saved { + postCellActions?.clearRemovedPosts() + } + + if shouldShowCommentSpotlight { + resetReaderDiscoverNudgeFlow() + } + dismissNoNetworkAlert() - // We want to listen for any changes (following, liked) in a post detail so we can refresh the child context. - let mainContext = ContextManager.sharedInstance().mainContext - NotificationCenter.default.addObserver(self, selector: #selector(ReaderStreamViewController.handleContextDidSaveNotification(_:)), name: NSNotification.Name.NSManagedObjectContextDidSave, object: mainContext) + ReaderTracker.shared.stop(.filteredList) } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -289,14 +413,12 @@ import WordPressFlux if self.isShowingResultStatusView { self.resultsStatusView.updateAccessoryViewsVisibility() } - }) - } - override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent + self.tableView.beginUpdates() + self.tableView.endUpdates() + }) } - override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() @@ -308,24 +430,41 @@ import WordPressFlux } } + @objc func willEnterForeground() { + guard isViewOnScreen() else { + return + } + + ReaderTracker.shared.start(.filteredList) + } + // MARK: - Topic acquisition /// Fetches a site topic for the value of the `siteID` property. /// private func fetchSiteTopic() { + guard let siteID = siteID else { + DDLogError("A siteID is required before fetching a site topic") + return + } + if isViewLoaded { displayLoadingStream() } - assert(siteID != nil, "A siteID is required before fetching a site topic") - let service = ReaderTopicService(managedObjectContext: ContextManager.sharedInstance().mainContext) - service.siteTopicForSite(withID: siteID!, + + let service = ReaderTopicService(coreDataStack: ContextManager.shared) + service.siteTopicForSite(withID: siteID, isFeed: isFeed, success: { [weak self] (objectID: NSManagedObjectID?, isFollowing: Bool) in let context = ContextManager.sharedInstance().mainContext - guard let objectID = objectID, let topic = (try? context.existingObject(with: objectID)) as? ReaderAbstractTopic else { + guard let objectID = objectID, + let topic = (try? context.existingObject(with: objectID)) as? ReaderAbstractTopic else { DDLogError("Reader: Error retriving an existing site topic by its objectID") + if self?.isLoadingDiscover ?? false { + self?.updateContent(synchronize: false) + } self?.displayLoadingStreamFailed() self?.reportStreamLoadFailure() return @@ -334,6 +473,9 @@ import WordPressFlux }, failure: { [weak self] (error: Error?) in + if self?.isLoadingDiscover ?? false { + self?.updateContent(synchronize: false) + } self?.displayLoadingStreamFailed() self?.reportStreamLoadFailure() }) @@ -342,12 +484,13 @@ import WordPressFlux /// Fetches a tag topic for the value of the `tagSlug` property /// + // TODO: - READERNAV - Remove this when the new reader is released private func fetchTagTopic() { if isViewLoaded { displayLoadingStream() } assert(tagSlug != nil, "A tag slug is requred before fetching a tag topic") - let service = ReaderTopicService(managedObjectContext: ContextManager.sharedInstance().mainContext) + let service = ReaderTopicService(coreDataStack: ContextManager.shared) service.tagTopicForTag(withSlug: tagSlug, success: { [weak self] (objectID: NSManagedObjectID?) in @@ -370,31 +513,50 @@ import WordPressFlux // MARK: - Setup - private func setupTableView() { - configureRefreshControl() - add(tableViewController, asChildOf: self) - layoutTableView() - tableConfiguration.setup(tableView) + private func setupStackView() { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + + setupTableView(stackView: stackView) + setupJetpackBanner(stackView: stackView) + + view.addSubview(stackView) + view.pinSubviewToAllEdges(stackView) } - @objc func configureRefreshControl() { - refreshControl.addTarget(self, action: #selector(ReaderStreamViewController.handleRefresh(_:)), for: .valueChanged) + private func setupJetpackBanner(stackView: UIStackView) { + /// If being presented in a modal, don't show a Jetpack banner + if let nav = navigationController, nav.isModal() { + return + } + + guard JetpackBrandingVisibility.all.enabled else { + return + } + let textProvider = JetpackBrandingTextProvider(screen: JetpackBannerScreen.reader) + let bannerView = JetpackBannerView() + bannerView.configure(title: textProvider.brandingText()) { [unowned self] in + JetpackBrandingCoordinator.presentOverlay(from: self) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBannerTapped(screen: .reader) + } + jetpackBannerView = bannerView + addTranslationObserver(bannerView) + stackView.addArrangedSubview(bannerView) } - private func add(_ childController: UIViewController, asChildOf controller: UIViewController) { - controller.addChild(childController) - controller.view.addSubview(childController.view) - childController.didMove(toParent: controller) + private func setupTableView(stackView: UIStackView) { + configureRefreshControl() + + stackView.addArrangedSubview(tableViewController.view) + tableViewController.didMove(toParent: self) + tableConfiguration.setup(tableView) + tableView.delegate = self + setupUndoCell(tableView) } - private func layoutTableView() { - tableView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) + @objc func configureRefreshControl() { + refreshControl.addTarget(self, action: #selector(ReaderStreamViewController.handleRefresh(_:)), for: .valueChanged) } private func setupContentHandler() { @@ -403,11 +565,6 @@ import WordPressFlux content.initializeContent(tableView: tableView, delegate: self) } - private func setupSyncHelper() { - syncHelper = WPContentSyncHelper() - syncHelper.delegate = self - } - private func setupResultsStatusView() { resultsStatusView.delegate = self } @@ -429,7 +586,7 @@ import WordPressFlux return } - guard let header = headerWithNewsCardForStream(topic, isLoggedIn: isLoggedIn, container: tableViewController) else { + guard let header = headerForStream(topic, isLoggedIn: isLoggedIn, container: tableViewController) else { tableView.tableHeaderView = nil return } @@ -441,10 +598,17 @@ import WordPressFlux tableView.tableHeaderView = header // This feels somewhat hacky, but it is the only way I found to insert a stack view into the header without breaking the autolayout constraints. - header.centerXAnchor.constraint(equalTo: tableView.centerXAnchor).isActive = true - header.widthAnchor.constraint(equalTo: tableView.widthAnchor).isActive = true - header.topAnchor.constraint(equalTo: tableView.topAnchor).isActive = true + let centerConstraint = header.centerXAnchor.constraint(equalTo: tableView.centerXAnchor) + let topConstraint = header.topAnchor.constraint(equalTo: tableView.topAnchor) + let headerWidthConstraint = header.widthAnchor.constraint(equalTo: tableView.widthAnchor) + headerWidthConstraint.priority = UILayoutPriority(999) + centerConstraint.priority = UILayoutPriority(999) + NSLayoutConstraint.activate([ + centerConstraint, + headerWidthConstraint, + topConstraint + ]) tableView.tableHeaderView?.layoutIfNeeded() tableView.tableHeaderView = tableView.tableHeaderView } @@ -460,12 +624,12 @@ import WordPressFlux } - /// Configures the controller for the `readerTopic`. This should only be called - /// once when the topic is set. - private func configureControllerForTopic(synchronize: Bool = true) { - assert(readerTopic != nil, "A reader topic is required") - assert(isViewLoaded, "The controller's view must be loaded before displaying the topic") - + /// Updates the content based on the values of `readerTopic` and `contentType` + private func updateContent(synchronize: Bool = true) { + // if the view has not been loaded yet, this will be called in viewDidLoad + guard isViewLoaded else { + return + } // Enable the view now that we have a topic. view.isUserInteractionEnabled = true @@ -476,18 +640,24 @@ import WordPressFlux tableViewController.refreshControl = nil } + // saved posts are local so do not need a pull to refresh + if contentType == .saved { + tableViewController.refreshControl = nil + } + // Rather than repeatedly creating a service to check if the user is logged in, cache it here. isLoggedIn = AccountHelper.isDotcomAvailable() - // Reset our display context to ensure its current. - managedObjectContext().reset() - configureTitleForTopic() configureShareButtonIfNeeded() hideResultsStatus() recentlyBlockedSitePostObjectIDs.removeAllObjects() updateAndPerformFetchRequest() - configureStreamHeader() + if readerTopic != nil { + configureStreamHeader() + } else { + tableView.tableHeaderView = nil + } tableView.setContentOffset(CGPoint.zero, animated: false) content.refresh() refreshTableViewHeaderLayout() @@ -498,19 +668,11 @@ import WordPressFlux bumpStats() - let count = content.contentCount - // Make sure we're showing the no results view if appropriate - if !syncHelper.isSyncing && count == 0 { + if let syncHelper = syncHelper, !syncHelper.isSyncing, content.isEmpty { + displayNoResultsView() + } else if contentType == .saved, content.isEmpty { displayNoResultsView() - } - - if !listentingForBlockedSiteNotification { - listentingForBlockedSiteNotification = true - NotificationCenter.default.addObserver(self, - selector: #selector(ReaderStreamViewController.handleBlockSiteNotification(_:)), - name: NSNotification.Name(rawValue: ReaderPostMenu.BlockSiteNotification), - object: nil) } } @@ -521,14 +683,30 @@ import WordPressFlux return } - title = topic.title + if ReaderHelpers.isTopicTag(topic) { + title = NSLocalizedString("Topic", comment: "Topic page title") + } else { + title = topic.title + } + } + + private func configureCloseButtonIfNeeded() { + if isModal() { + navigationItem.leftBarButtonItem = UIBarButtonItem(image: .gridicon(.cross), + style: .plain, + target: self, + action: #selector(closeButtonTapped)) + } } + @objc private func closeButtonTapped() { + dismiss(animated: true) + } /// Fetch and cache the current defaultAccount authtoken, if available. private func refreshImageRequestAuthToken() { - let acctServ = AccountService(managedObjectContext: ContextManager.sharedInstance().mainContext) - postCellActions?.imageRequestAuthToken = acctServ.defaultWordPressComAccount()?.authToken + let account = try? WPAccount.lookupDefaultWordPressComAccount(in: ContextManager.shared.mainContext) + postCellActions?.imageRequestAuthToken = account?.authToken } @@ -600,14 +778,17 @@ import WordPressFlux assertionFailure("A reader topic is required") return nil } + let title = topic.title var key: String = "list" + if ReaderHelpers.isTopicTag(topic) { key = "tag" } else if ReaderHelpers.isTopicSite(topic) { key = "site" } - return [key: title] + + return [key: title, "source": statSource.rawValue] } /// The fetch request can need a different predicate depending on how the content @@ -616,12 +797,40 @@ import WordPressFlux /// private func updateAndPerformFetchRequest() { assert(Thread.isMainThread, "ReaderStreamViewController Error: updating fetch request on a background thread.") - + removeBlockedPosts() content.updateAndPerformFetchRequest(predicate: predicateForFetchRequest()) } + private func removeBlockedPosts() { + // Fetch account + guard let account = try? WPAccount.lookupDefaultWordPressComAccount(in: viewContext) else { + return + } + + // Author Predicate + var predicates = [NSPredicate]() + let blockedAuthors = BlockedAuthor.find(.accountID(account.userID), context: viewContext).map { $0.authorID } + if !blockedAuthors.isEmpty { + predicates.append(NSPredicate(format: "\(#keyPath(ReaderPost.authorID)) IN %@", blockedAuthors)) + } + + // Site Predicate + if let topic = readerTopic as? ReaderSiteTopic, + let blocked = BlockedSite.findOne(accountID: account.userID, blogID: topic.siteID, context: viewContext) { + predicates.append(NSPredicate(format: "\(#keyPath(ReaderPost.siteID)) = %@", blocked.blogID)) + } + + // Execute + let request = NSFetchRequest(entityName: ReaderPost.entityName()) + request.predicate = NSCompoundPredicate(orPredicateWithSubpredicates: predicates) + let result = (try? viewContext.fetch(request)) ?? [] + for post in result { + viewContext.deleteObject(post) + } + try? viewContext.save() + } - private func updateStreamHeaderIfNeeded() { + func updateStreamHeaderIfNeeded() { guard let topic = readerTopic else { assertionFailure("A reader topic is required") return @@ -632,40 +841,43 @@ import WordPressFlux header.configureHeader(topic) } - func showManageSites(animated: Bool = true) { let controller = ReaderFollowedSitesViewController.controller() navigationController?.pushViewController(controller, animated: animated) } private func showFollowing() { - guard let readerMenuViewController = WPTabBarController.sharedInstance().readerMenuViewController else { - return - } - - readerMenuViewController.showSectionForDefaultMenuItem(withOrder: .followed, animated: true) + RootViewCoordinator.sharedPresenter.switchToFollowedSites() } // MARK: - Blocking - private func blockSiteForPost(_ post: ReaderPost) { - guard let indexPath = content.indexPath(forObject: post) else { + /// Update the post card when a site is blocked from post details. + /// + func readerSiteBlockingController(_ controller: ReaderPostBlockingController, didBlockSiteOfPost post: ReaderPost, result: Result) { + guard case .success = result, + let post = (try? viewContext.existingObject(with: post.objectID)) as? ReaderPost, + let indexPath = content.indexPath(forObject: post) + else { return } - - let objectID = post.objectID - recentlyBlockedSitePostObjectIDs.add(objectID) + recentlyBlockedSitePostObjectIDs.remove(post.objectID) updateAndPerformFetchRequest() - tableView.reloadRows(at: [indexPath], with: UITableView.RowAnimation.fade) + } - ReaderBlockSiteAction(asBlocked: true).execute(with: post, context: managedObjectContext()) { [weak self] in - self?.recentlyBlockedSitePostObjectIDs.remove(objectID) - self?.tableView.reloadRows(at: [indexPath], with: UITableView.RowAnimation.fade) + func readerSiteBlockingController(_ controller: ReaderPostBlockingController, didEndBlockingPostAuthor post: ReaderPost, result: Result) { + guard case .success = result, + let post = (try? viewContext.existingObject(with: post.objectID)) as? ReaderPost, + let indexPath = content.indexPath(forObject: post) + else { + return } + recentlyBlockedSitePostObjectIDs.remove(post.objectID) + updateAndPerformFetchRequest() + tableView.reloadRows(at: [indexPath], with: UITableView.RowAnimation.fade) } - private func unblockSiteForPost(_ post: ReaderPost) { guard let indexPath = content.indexPath(forObject: post) else { return @@ -676,33 +888,13 @@ import WordPressFlux tableView.reloadRows(at: [indexPath], with: UITableView.RowAnimation.fade) - ReaderBlockSiteAction(asBlocked: false).execute(with: post, context: managedObjectContext()) { [weak self] in + ReaderBlockSiteAction(asBlocked: false).execute(with: post, context: viewContext) { [weak self] in self?.recentlyBlockedSitePostObjectIDs.add(objectID) self?.tableView.reloadRows(at: [indexPath], with: UITableView.RowAnimation.fade) } } - /// A user can block a site from the detail screen. When this happens, we need - /// to update the list UI to properly reflect the change. Listen for the - /// notification and call blockSiteForPost as needed. - /// - @objc private func handleBlockSiteNotification(_ notification: Foundation.Notification) { - guard let userInfo = notification.userInfo, let aPost = userInfo["post"] as? ReaderPost else { - return - } - - guard let post = (try? managedObjectContext().existingObject(with: aPost.objectID)) as? ReaderPost else { - DDLogError("Error fetching existing post from context.") - return - } - - if let _ = content.indexPath(forObject: post) { - blockSiteForPost(post) - } - } - - // MARK: - Actions @@ -719,7 +911,12 @@ import WordPressFlux return } - syncHelper.syncContentWithUserInteraction(true) + if isLoadingDiscover { + fetchSiteTopic() + return + } + syncHelper?.syncContentWithUserInteraction(true) + WPAnalytics.trackReader(.readerPullToRefresh, properties: topicPropertyForStats() ?? [:]) } @@ -733,7 +930,9 @@ import WordPressFlux return } - guard let topic = readerTopic, let properties = topicPropertyForStats(), isViewLoaded && view.window != nil else { + guard let topic = readerTopic, + let properties = topicPropertyForStats(), + isViewLoaded && view.window != nil else { return } @@ -764,11 +963,11 @@ import WordPressFlux private func canSync() -> Bool { - return (readerTopic != nil) && connectionAvailable() + return (readerTopic != nil || isLoadingDiscover) && connectionAvailable() } @objc func connectionAvailable() -> Bool { - return WordPressAppDelegate.shared!.connectionAvailable + return WordPressAppDelegate.shared?.connectionAvailable ?? false } @@ -780,7 +979,7 @@ import WordPressFlux /// - The app must be running on the foreground. /// - The current time must be greater than the last sync interval. /// - private func syncIfAppropriate() { + func syncIfAppropriate(forceSync: Bool = false) { guard UIApplication.shared.isRunningTestSuite() == false else { return } @@ -793,7 +992,7 @@ import WordPressFlux return } - if ReaderHelpers.isTopicSearchTopic(topic) && topic.posts.count > 0 { + if ReaderHelpers.isTopicSearchTopic(topic) && topicPostsCount > 0 { // We only perform an initial sync if the topic has no results. // The rest of the time it should just support infinite scroll. // Normal the newly added topic will have no existing posts. The @@ -804,14 +1003,20 @@ import WordPressFlux let lastSynced = topic.lastSynced ?? Date(timeIntervalSince1970: 0) let interval = Int( Date().timeIntervalSince(lastSynced)) - if canSync() && (interval >= refreshInterval || topic.posts.count == 0) { - syncHelper.syncContentWithUserInteraction(false) + + if forceSync || (canSync() && (interval >= refreshInterval || topicPostsCount == 0)) { + syncHelper?.syncContentWithUserInteraction(false) } else { handleConnectionError() } } - /// Used to fetch new content in response to a background refresh event. + /// Returns the number of posts for the current topic + /// This allows the count to be overriden by subclasses + var topicPostsCount: Int { + return readerTopic?.posts.count ?? 0 + } + /// Used to fetch new content in response to a background refresh event. /// Not intended for use as part of a user interaction. See syncIfAppropriate instead. /// @objc func backgroundFetch(_ completionHandler: @escaping ((UIBackgroundFetchResult) -> Void)) { @@ -845,7 +1050,7 @@ import WordPressFlux return } - if syncHelper.isSyncing { + if let syncHelper = syncHelper, syncHelper.isSyncing { let alertTitle = NSLocalizedString("Busy", comment: "Title of a prompt letting the user know that they must wait until the current aciton completes.") let alertMessage = NSLocalizedString("Please wait til the current fetch completes.", comment: "Asks the usre to wait until the currently running fetch request completes.") let cancelTitle = NSLocalizedString("OK", comment: "Title of a button that dismisses a prompt") @@ -859,57 +1064,64 @@ import WordPressFlux } indexPathForGapMarker = indexPath syncIsFillingGap = true - syncHelper.syncContentWithUserInteraction(true) + syncHelper?.syncContentWithUserInteraction(true) } - private func syncItems(_ success: ((_ hasMore: Bool) -> Void)?, failure: ((_ error: NSError) -> Void)?) { guard let topic = readerTopic else { DDLogError("Error: Reader tried to sync items when the topic was nil.") return } - let syncContext = ContextManager.sharedInstance().newDerivedContext() - let service = ReaderPostService(managedObjectContext: syncContext) - - syncContext.perform { [weak self] in - guard let topicInContext = (try? syncContext.existingObject(with: topic.objectID)) as? ReaderAbstractTopic else { - DDLogError("Error: Could not retrieve an existing topic via its objectID") - return - } - - let objectID = topicInContext.objectID + let objectID = topic.objectID - let successBlock = { [weak self] (count: Int, hasMore: Bool) in - DispatchQueue.main.async { - if let strongSelf = self { - if strongSelf.recentlyBlockedSitePostObjectIDs.count > 0 { - strongSelf.recentlyBlockedSitePostObjectIDs.removeAllObjects() - strongSelf.updateAndPerformFetchRequest() - } - strongSelf.updateLastSyncedForTopic(objectID) + let successBlock = { [weak self] (count: Int, hasMore: Bool) in + DispatchQueue.main.async { + if let strongSelf = self { + if strongSelf.recentlyBlockedSitePostObjectIDs.count > 0 { + strongSelf.recentlyBlockedSitePostObjectIDs.removeAllObjects() + strongSelf.updateAndPerformFetchRequest() } - success?(hasMore) + strongSelf.updateLastSyncedForTopic(objectID) } + success?(hasMore) } + } - let failureBlock = { (error: Error?) in - DispatchQueue.main.async { - if let error = error { - failure?(error as NSError) - } + let failureBlock = { (error: Error?) in + DispatchQueue.main.async { + if let error = error { + failure?(error as NSError) } } + } - if ReaderHelpers.isTopicSearchTopic(topicInContext) { - service.fetchPosts(for: topicInContext, atOffset: 0, deletingEarlier: false, success: successBlock, failure: failureBlock) + self.fetch(for: topic, success: successBlock, failure: failureBlock) + } + + func fetch(for originalTopic: ReaderAbstractTopic, success: @escaping ((_ count: Int, _ hasMore: Bool) -> Void), failure: @escaping ((_ error: Error?) -> Void)) { + coreDataStack.performAndSave { context in + guard let topic = (try? context.existingObject(with: originalTopic.objectID)) as? ReaderAbstractTopic else { + DDLogError("Error: Could not retrieve an existing topic via its objectID") + return + } + + if ReaderHelpers.isTopicSearchTopic(topic) { + let service = ReaderPostService(coreDataStack: ContextManager.shared) + service.fetchPosts(for: topic, atOffset: 0, deletingEarlier: false, success: success, failure: failure) + } else if let topic = topic as? ReaderTagTopic { + self.readerPostStreamService.fetchPosts(for: topic, success: success, failure: failure) } else { - service.fetchPosts(for: topicInContext, earlierThan: Date(), success: successBlock, failure: failureBlock) + if FeatureFlag.readerUserBlocking.enabled { + self.readerPostService.fetchUnblockedPosts(topic: topic, earlierThan: Date(), forceRetry: true, success: success, failure: failure) + } else { + let service = ReaderPostService(coreDataStack: ContextManager.shared) + service.fetchPosts(for: topic, earlierThan: Date(), success: success, failure: failure) + } } } } - private func syncItemsForGap(_ success: ((_ hasMore: Bool) -> Void)?, failure: ((_ error: NSError) -> Void)?) { assert(syncIsFillingGap) guard let topic = readerTopic else { @@ -930,12 +1142,10 @@ import WordPressFlux // Reload the gap cell so it will start animating. tableView.reloadRows(at: [indexPath], with: .none) - let syncContext = ContextManager.sharedInstance().newDerivedContext() - let service = ReaderPostService(managedObjectContext: syncContext) let sortDate = post.sortDate - syncContext.perform { [weak self] in - guard let topicInContext = (try? syncContext.existingObject(with: topic.objectID)) as? ReaderAbstractTopic else { + coreDataStack.performAndSave { [weak self] context in + guard let topicInContext = (try? context.existingObject(with: topic.objectID)) as? ReaderAbstractTopic else { DDLogError("Error: Could not retrieve an existing topic via its objectID") return } @@ -963,6 +1173,7 @@ import WordPressFlux } } + let service = ReaderPostService(coreDataStack: ContextManager.shared) if ReaderHelpers.isTopicSearchTopic(topicInContext) { assertionFailure("Search topics should no have a gap to fill.") service.fetchPosts(for: topicInContext, atOffset: 0, deletingEarlier: true, success: successBlock, failure: failureBlock) @@ -973,12 +1184,38 @@ import WordPressFlux } - private func loadMoreItems(_ success: ((_ hasMore: Bool) -> Void)?, failure: ((_ error: NSError) -> Void)?) { + func loadMoreItems(_ success: ((_ hasMore: Bool) -> Void)?, failure: ((_ error: NSError) -> Void)?) { guard let topic = readerTopic else { assertionFailure("Tried to fill a gap when the topic was nil.") return } + footerView.showSpinner(true) + + let successBlock = { (count: Int, hasMore: Bool) in + DispatchQueue.main.async(execute: { + success?(hasMore) + }) + } + + let failureBlock = { (error: Error?) in + guard let error = error else { + return + } + + DispatchQueue.main.async(execute: { + failure?(error as NSError) + }) + } + + self.fetchMore(for: topic, success: successBlock, failure: failureBlock) + + if let properties = topicPropertyForStats() { + WPAppAnalytics.track(.readerInfiniteScroll, withProperties: properties) + } + } + + private func fetchMore(for originalTopic: ReaderAbstractTopic, success: @escaping ((Int, Bool) -> Void), failure: @escaping ((Error?) -> Void)) { guard let posts = content.content, let post = posts.last as? ReaderPost, @@ -988,47 +1225,29 @@ import WordPressFlux return } - footerView.showSpinner(true) - - let earlierThan = sortDate - let syncContext = ContextManager.sharedInstance().newDerivedContext() - let service = ReaderPostService(managedObjectContext: syncContext) - let offset = content.contentCount - syncContext.perform { - guard let topicInContext = (try? syncContext.existingObject(with: topic.objectID)) as? ReaderAbstractTopic else { + coreDataStack.performAndSave { context in + guard let topic = (try? context.existingObject(with: originalTopic.objectID)) as? ReaderAbstractTopic else { DDLogError("Error: Could not retrieve an existing topic via its objectID") return } - let successBlock = { (count: Int, hasMore: Bool) in - DispatchQueue.main.async(execute: { - success?(hasMore) - }) - } - - let failureBlock = { (error: Error?) in - guard let error = error else { - return - } - - DispatchQueue.main.async(execute: { - failure?(error as NSError) - }) - } - - if ReaderHelpers.isTopicSearchTopic(topicInContext) { - service.fetchPosts(for: topicInContext, atOffset: UInt(offset), deletingEarlier: false, success: successBlock, failure: failureBlock) + if ReaderHelpers.isTopicSearchTopic(topic) { + let service = ReaderPostService(coreDataStack: ContextManager.shared) + let offset = UInt(self.content.contentCount) + service.fetchPosts(for: topic, atOffset: UInt(offset), deletingEarlier: false, success: success, failure: failure) + } else if let topic = topic as? ReaderTagTopic { + self.readerPostStreamService.fetchPosts(for: topic, isFirstPage: false, success: success, failure: failure) } else { - service.fetchPosts(for: topicInContext, earlierThan: earlierThan, success: successBlock, failure: failureBlock) + if FeatureFlag.readerUserBlocking.enabled { + self.readerPostService.fetchUnblockedPosts(topic: topic, earlierThan: sortDate, success: success, failure: failure) + } else { + let service = ReaderPostService(coreDataStack: ContextManager.shared) + service.fetchPosts(for: topic, earlierThan: sortDate, success: success, failure: failure) + } } } - - if let properties = topicPropertyForStats() { - WPAppAnalytics.track(.readerInfiniteScroll, withProperties: properties) - } } - private func cleanupAfterSync(refresh: Bool = true) { syncIsFillingGap = false indexPathForGapMarker = nil @@ -1047,23 +1266,40 @@ import WordPressFlux refreshImageRequestAuthToken() } + @objc private func postSeenToggled(_ notification: Foundation.Notification) { - // MARK: - Helpers for TableViewHandler + // When a post's seen status is toggled outside the stream (ex: post details), + // refresh the post in the stream so the card options menu has the correct + // mark as seen/unseen option. + + guard let userInfo = notification.userInfo, + let post = userInfo[ReaderNotificationKeys.post] as? ReaderPost, + let indexPath = content.indexPath(forObject: post), + let cellPost: ReaderPost = content.object(at: indexPath) else { + return + } + + cellPost.isSeen = post.isSeen + tableView.reloadRows(at: [indexPath], with: .fade) + } + // MARK: - Helpers for TableViewHandler - private func predicateForFetchRequest() -> NSPredicate { + func predicateForFetchRequest() -> NSPredicate { // If readerTopic is nil return a predicate that is valid, but still // avoids returning readerPosts that do not belong to a topic (e.g. those // loaded from a notification). We can do this by specifying that self // has to exist within an empty set. - let predicateForNilTopic = NSPredicate(format: "topic = NULL AND SELF in %@", []) + let predicateForNilTopic = contentType == .saved ? + NSPredicate(format: "isSavedForLater == YES") : + NSPredicate(format: "topic = NULL AND SELF in %@", [String]()) guard let topic = readerTopic else { return predicateForNilTopic } - guard let topicInContext = (try? managedObjectContext().existingObject(with: topic.objectID)) as? ReaderAbstractTopic else { + guard let topicInContext = (try? viewContext.existingObject(with: topic.objectID)) as? ReaderAbstractTopic else { DDLogError("Error: Could not retrieve an existing topic via its objectID") return predicateForNilTopic } @@ -1076,38 +1312,55 @@ import WordPressFlux } - private func sortDescriptorsForFetchRequest() -> [NSSortDescriptor] { - let sortDescriptor = NSSortDescriptor(key: "sortRank", ascending: false) + func sortDescriptorsForFetchRequest(ascending: Bool = false) -> [NSSortDescriptor] { + let sortDescriptor = NSSortDescriptor(key: "sortRank", ascending: ascending) return [sortDescriptor] } private func configurePostCardCell(_ cell: UITableViewCell, post: ReaderPost) { - guard let topic = readerTopic else { - return - } - if postCellActions == nil { - postCellActions = ReaderPostCellActions(context: managedObjectContext(), origin: self, topic: readerTopic) + postCellActions = ReaderPostCellActions(context: viewContext, origin: self, topic: readerTopic) } postCellActions?.isLoggedIn = isLoggedIn + postCellActions?.savedPostsDelegate = self + + // Restrict the topics header to only display on the Discover, and tag detail views + var displayTopics = false + + if let topic = readerTopic { + let type = ReaderHelpers.topicType(topic) + + switch type { + case .discover, .tag: + displayTopics = true + default: + displayTopics = false + } + } cellConfiguration.configurePostCardCell(cell, withPost: post, - topic: topic, + topic: readerTopic ?? post.topic, delegate: postCellActions, - loggedInActionVisibility: .visible(enabled: isLoggedIn)) - } + loggedInActionVisibility: .visible(enabled: isLoggedIn), + topicChipsDelegate: self, + displayTopics: displayTopics) - @objc private func handleContextDidSaveNotification(_ notification: Foundation.Notification) { - ContextManager.sharedInstance().mergeChanges(displayContext, fromContextDidSave: notification) } - // MARK: - Helpers for ReaderStreamHeader + public func toggleFollowingForTopic(_ topic: ReaderAbstractTopic?, completion: ((Bool) -> Void)?) { + if let topic = topic as? ReaderTagTopic { + toggleFollowingForTag(topic, completion: completion) + } else if let topic = topic as? ReaderSiteTopic { + toggleFollowingForSite(topic, completion: completion) + } else if let topic = topic as? ReaderDefaultTopic, ReaderHelpers.topicIsFollowing(topic) { + showManageSites() + } + } - - private func toggleFollowingForTag(_ topic: ReaderTagTopic) { + private func toggleFollowingForTag(_ topic: ReaderTagTopic, completion: ((Bool) -> Void)?) { let generator = UINotificationFeedbackGenerator() generator.prepare() @@ -1115,46 +1368,28 @@ import WordPressFlux generator.notificationOccurred(.success) } - let service = ReaderTopicService(managedObjectContext: topic.managedObjectContext!) - service.toggleFollowing(forTag: topic, success: { [weak self] in - self?.syncHelper.syncContent() - }, failure: { [weak self] (error: Error?) in + let service = ReaderTopicService(coreDataStack: ContextManager.shared) + service.toggleFollowing(forTag: topic, success: { + completion?(true) + }, failure: { (error: Error?) in generator.notificationOccurred(.error) - self?.updateStreamHeaderIfNeeded() + completion?(false) }) - - updateStreamHeaderIfNeeded() } - - private func toggleFollowingForSite(_ topic: ReaderSiteTopic) { - let generator = UINotificationFeedbackGenerator() - generator.prepare() - - if !topic.following { - generator.notificationOccurred(.success) + private func toggleFollowingForSite(_ topic: ReaderSiteTopic, completion: ((Bool) -> Void)?) { + if topic.following { + ReaderSubscribingNotificationAction().execute(for: siteID, context: viewContext, subscribe: false) } - let toFollow = !topic.following - let siteID = topic.siteID - let siteTitle = topic.title - - if !toFollow { - ReaderSubscribingNotificationAction().execute(for: siteID, context: managedObjectContext(), value: !topic.isSubscribedForPostNotifications) - } - - let service = ReaderTopicService(managedObjectContext: topic.managedObjectContext!) - service.toggleFollowing(forSite: topic, success: { [weak self] in - self?.syncHelper.syncContent() - if toFollow { - self?.dispatchSubscribingNotificationNotice(with: siteTitle, siteID: siteID) - } - }, failure: { [weak self] (error: Error?) in - generator.notificationOccurred(.error) - self?.updateStreamHeaderIfNeeded() + let service = ReaderTopicService(coreDataStack: ContextManager.shared) + service.toggleFollowing(forSite: topic, success: { follow in + ReaderHelpers.dispatchToggleFollowSiteMessage(site: topic, follow: follow, success: true) + completion?(true) + }, failure: { (follow, error) in + ReaderHelpers.dispatchToggleFollowSiteMessage(site: topic, follow: follow, success: false) + completion?(false) }) - - updateStreamHeaderIfNeeded() } } @@ -1163,48 +1398,16 @@ import WordPressFlux extension ReaderStreamViewController: ReaderStreamHeaderDelegate { - func handleFollowActionForHeader(_ header: ReaderStreamHeader) { - if let topic = readerTopic as? ReaderTagTopic { - toggleFollowingForTag(topic) - - } else if let topic = readerTopic as? ReaderSiteTopic { - toggleFollowingForSite(topic) - - } else if let topic = readerTopic as? ReaderDefaultTopic, ReaderHelpers.topicIsFollowing(topic) { - showManageSites() - } - } -} - -extension ReaderStreamViewController: NewsManagerDelegate { - func didDismissNews() { - refreshTableHeaderIfNeeded() - } + func handleFollowActionForHeader(_ header: ReaderStreamHeader, completion: @escaping () -> Void) { + toggleFollowingForTopic(readerTopic) { [weak self] success in + if success { + self?.syncHelper?.syncContent() + } - func didSelectReadMore(_ url: URL) { - let readerLinkRouter = UniversalLinkRouter.shared - if readerLinkRouter.canHandle(url: url) { - readerLinkRouter.handle(url: url, shouldTrack: false, source: self) - } else if url.isWordPressDotComPost { - presentReaderDetailViewControllerWithURL(url) - } else { - presentWebViewControllerWithURL(url) + self?.updateStreamHeaderIfNeeded() + completion() } } - - private func presentWebViewControllerWithURL(_ url: URL) { - let configuration = WebViewControllerConfiguration(url: url) - configuration.authenticateWithDefaultAccount() - configuration.addsWPComReferrer = true - let controller = WebViewControllerFactory.controller(configuration: configuration) - let navController = UINavigationController(rootViewController: controller) - present(navController, animated: true) - } - - private func presentReaderDetailViewControllerWithURL(_ url: URL) { - let viewController = ReaderDetailViewController.controllerWithPostURL(url) - navigationController?.pushFullscreenViewController(viewController, animated: true) - } } // MARK: - WPContentSyncHelperDelegate @@ -1286,7 +1489,8 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { // MARK: - Fetched Results Related func managedObjectContext() -> NSManagedObjectContext { - return displayContext + assert(Thread.isMainThread, "WPTableViewHandler should use Core Data on the main thread") + return viewContext } @@ -1309,14 +1513,13 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { func tableViewHandlerWillRefreshTableViewPreservingOffset(_ tableViewHandler: WPTableViewHandler) { // Reload the table view to reflect new content. - managedObjectContext().reset() updateAndPerformFetchRequest() } func tableViewHandlerDidRefreshTableViewPreservingOffset(_ tableViewHandler: WPTableViewHandler) { hideResultsStatus() if tableViewHandler.resultsController.fetchedObjects?.count == 0 { - if syncHelper.isSyncing { + if let syncHelper = syncHelper, syncHelper.isSyncing { return } displayNoResultsView() @@ -1327,7 +1530,6 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { // MARK: - TableView Related - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { // When using UITableViewAutomaticDimension for auto-sizing cells, UITableView // likes to reload rows in a strange way. @@ -1343,7 +1545,6 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { return tableConfiguration.estimatedRowHeight() } - func tableView(_ aTableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } @@ -1363,6 +1564,10 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { let post = posts[indexPath.row] + return cell(for: post, at: indexPath) + } + + func cell(for post: ReaderPost, at indexPath: IndexPath) -> UITableViewCell { if post.isKind(of: ReaderGapMarker.self) { let cell = tableConfiguration.gapMarkerCell(tableView) cellConfiguration.configureGapMarker(cell, filling: syncIsFillingGap) @@ -1385,8 +1590,24 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { return cell } + if contentType == .saved, postCellActions?.postIsRemoved(post) == true { + let cell = undoCell(tableView) + configureUndoCell(cell, with: post) + return cell + } + let cell = tableConfiguration.postCardCell(tableView) configurePostCardCell(cell, post: post) + + if let topic = readerTopic, + ReaderHelpers.topicIsDiscover(topic), + indexPath.row == 0, + shouldShowCommentSpotlight { + cell.spotlightIsShown = true + } else { + cell.spotlightIsShown = false + } + return cell } @@ -1396,16 +1617,8 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { estimatedHeightsCache.setObject(cell.frame.height as AnyObject, forKey: indexPath as AnyObject) // Check to see if we need to load more. - let criticalRow = tableView.numberOfRows(inSection: indexPath.section) - loadMoreThreashold - if (indexPath.section == tableView.numberOfSections - 1) && (indexPath.row >= criticalRow) { - // We only what to load more when: - // - there is more content, - // - when we are not alrady syncing - // - when we are not waiting for scrolling to end to cleanup and refresh the list - if syncHelper.hasMoreContent && !syncHelper.isSyncing && !cleanupAndRefreshAfterScrolling { - syncHelper.syncMoreContent() - } - } + syncMoreContentIfNeeded(for: tableView, indexPathForVisibleRow: indexPath) + guard cell.isKind(of: ReaderPostCardCell.self) || cell.isKind(of: ReaderCrossPostCell.self) else { return } @@ -1413,14 +1626,50 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { guard let posts = content.content as? [ReaderPost] else { return } - // Bump the render tracker if necessary. + let post = posts[indexPath.row] + bumpRenderTracker(post) + } + + /// Loads more posts when certain conditions are fulfilled. + /// + /// More items loading is triggered when: + /// - There is more content to load. + /// - When we are not alrady syncing. + /// - When we are not waiting for scrolling to end to cleanup and refresh the list. + /// - When there are no ongoing blocking requests. + private func syncMoreContentIfNeeded(for tableView: UITableView, indexPathForVisibleRow indexPath: IndexPath) { + let criticalRow = tableView.numberOfRows(inSection: indexPath.section) - loadMoreThreashold + guard let syncHelper = syncHelper, (indexPath.section == tableView.numberOfSections - 1) && (indexPath.row >= criticalRow) else { + return + } + let shouldLoadMoreItems = syncHelper.hasMoreContent + && !syncHelper.isSyncing + && !cleanupAndRefreshAfterScrolling + && !siteBlockingController.isBlockingPosts + && !readerPostService.isSilentlyFetchingPosts + if shouldLoadMoreItems { + syncHelper.syncMoreContent() + } + } + + func bumpRenderTracker(_ post: ReaderPost) { + // Bump the render tracker if necessary. if !post.rendered, let railcar = post.railcarDictionary() { post.rendered = true WPAppAnalytics.track(.trainTracksRender, withProperties: railcar) } } + func reloadReaderDiscoverNudgeFlow(at indexPath: IndexPath) { + resetReaderDiscoverNudgeFlow() + tableView.reloadRows(at: [indexPath], with: UITableView.RowAnimation.fade) + } + + private func resetReaderDiscoverNudgeFlow() { + shouldShowCommentSpotlight = false + RootViewCoordinator.sharedPresenter.resetReaderDiscoverNudgeFlow() + } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let posts = content.content as? [ReaderPost] else { @@ -1429,6 +1678,10 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { } let apost = posts[indexPath.row] + didSelectPost(apost, at: indexPath) + } + + func didSelectPost(_ apost: ReaderPost, at indexPath: IndexPath) { guard let post = postInMainContext(apost) else { return } @@ -1453,23 +1706,13 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { } } - var controller: ReaderDetailViewController - if post.sourceAttributionStyle() == .post && - post.sourceAttribution.postID != nil && - post.sourceAttribution.blogID != nil { - - controller = ReaderDetailViewController.controllerWithPostID(post.sourceAttribution.postID!, siteID: post.sourceAttribution.blogID!) - - } else if post.isCross() { - controller = ReaderDetailViewController.controllerWithPostID(post.crossPostMeta.postID, siteID: post.crossPostMeta.siteID) + let controller = ReaderDetailViewController.controllerWithPost(post) + controller.coordinator?.readerTopic = readerTopic - } else { - controller = ReaderDetailViewController.controllerWithPost(post) - - } - - if post.isSavedForLater { + if post.isSavedForLater || contentType == .saved { trackSavedPostNavigation() + } else { + WPAnalytics.trackReader(.readerPostCardTapped, properties: topicPropertyForStats() ?? [:]) } navigationController?.pushFullscreenViewController(controller, animated: true) @@ -1510,16 +1753,18 @@ extension ReaderStreamViewController: SearchableActivityConvertable { // MARK: - Handling Loading and No Results -private extension ReaderStreamViewController { +extension ReaderStreamViewController { func displayLoadingStream() { configureResultsStatus(title: ResultsStatusText.loadingStreamTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) displayResultsStatus() + showGhost() } func displayLoadingStreamFailed() { configureResultsStatus(title: ResultsStatusText.loadingErrorTitle, subtitle: ResultsStatusText.loadingErrorMessage) displayResultsStatus() + hideGhost() } func displayLoadingViewIfNeeded() { @@ -1530,12 +1775,18 @@ private extension ReaderStreamViewController { tableView.tableHeaderView?.isHidden = true configureResultsStatus(title: ResultsStatusText.fetchingPostsTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) displayResultsStatus() + showGhost() } func displayNoResultsView() { // Its possible the topic was deleted before a sync could be completed, // so make certain its not nil. guard let topic = readerTopic else { + if contentType == .saved { + displayNoResultsForSavedPosts() + } else if contentType == .topic && siteID == ReaderHelpers.discoverSiteID { + displayNoResultsViewForDiscover() + } return } @@ -1546,18 +1797,39 @@ private extension ReaderStreamViewController { return } - let response: NoResultsResponse = ReaderStreamViewController.responseForNoResults(topic) + guard ReaderHelpers.topicIsFollowing(topic) else { + let response: NoResultsResponse = ReaderStreamViewController.responseForNoResults(topic) + + let buttonTitle = buttonTitleForTopic(topic) - let buttonTitle = buttonTitleForTopic(topic) - let imageName = ReaderHelpers.topicIsFollowing(topic) ? readerEmptyImageName : nil + configureResultsStatus(title: response.title, subtitle: response.message, buttonTitle: buttonTitle, imageName: readerEmptyImageName) + displayResultsStatus() + return + } + + view.isUserInteractionEnabled = true + + if noFollowedSitesViewController == nil { + let controller = NoResultsViewController.noFollowedSitesController(showActionButton: isLoggedIn) + controller.delegate = self + noFollowedSitesViewController = controller + } - configureResultsStatus(title: response.title, subtitle: response.message, buttonTitle: buttonTitle, imageName: imageName) displayResultsStatus() } func displayNoConnectionView() { configureResultsStatus(title: ResultsStatusText.noConnectionTitle, subtitle: noConnectionMessage()) displayResultsStatus() + hideGhost() + } + + /// Removes the no followed sites view controller if it exists + func resetNoFollowedSitesViewController() { + if let noFollowedSitesVC = noFollowedSitesViewController { + noFollowedSitesVC.removeFromView() + noFollowedSitesViewController = nil + } } func configureResultsStatus(title: String, @@ -1565,23 +1837,40 @@ private extension ReaderStreamViewController { buttonTitle: String? = nil, imageName: String? = nil, accessoryView: UIView? = nil) { + resetNoFollowedSitesViewController() resultsStatusView.configure(title: title, buttonTitle: buttonTitle, subtitle: subtitle, image: imageName, accessoryView: accessoryView) } + private func displayNoResultsForSavedPosts() { + resetNoFollowedSitesViewController() + configureNoResultsViewForSavedPosts() + displayResultsStatus() + } + + private func displayNoResultsViewForDiscover() { + configureResultsStatus(title: ReaderStreamViewController.defaultResponse.title, + subtitle: ReaderStreamViewController.defaultResponse.message, + imageName: readerEmptyImageName) + displayResultsStatus() + } + func displayResultsStatus() { resultsStatusView.removeFromView() + tableViewController.addChild(resultsStatusView) tableView.insertSubview(resultsStatusView.view, belowSubview: refreshControl) resultsStatusView.view.frame = tableView.frame resultsStatusView.didMove(toParent: tableViewController) resultsStatusView.updateView() footerView.isHidden = true + hideGhost() } func hideResultsStatus() { resultsStatusView.removeFromView() footerView.isHidden = false tableView.tableHeaderView?.isHidden = false + hideGhost() } func buttonTitleForTopic(_ topic: ReaderAbstractTopic) -> String? { @@ -1599,8 +1888,8 @@ private extension ReaderStreamViewController { struct ResultsStatusText { static let fetchingPostsTitle = NSLocalizedString("Fetching posts...", comment: "A brief prompt shown when the reader is empty, letting the user know the app is currently fetching new posts.") static let loadingStreamTitle = NSLocalizedString("Loading stream...", comment: "A short message to inform the user the requested stream is being loaded.") - static let loadingErrorTitle = NSLocalizedString("Problem loading stream", comment: "Error message title informing the user that a stream could not be loaded.") - static let loadingErrorMessage = NSLocalizedString("Sorry. The stream could not be loaded.", comment: "A short error message letting the user know the requested stream could not be loaded.") + static let loadingErrorTitle = NSLocalizedString("Problem loading content", comment: "Error message title informing the user that reader content could not be loaded.") + static let loadingErrorMessage = NSLocalizedString("Sorry. The content could not be loaded.", comment: "A short error message letting the user know the requested reader content could not be loaded.") static let manageSitesButtonTitle = NSLocalizedString("Manage Sites", comment: "Button title. Tapping lets the user manage the sites they follow.") static let followingButtonTitle = NSLocalizedString("Go to Following", comment: "Button title. Tapping lets the user view the sites they follow.") static let noConnectionTitle = NSLocalizedString("Unable to Sync", comment: "Title of error prompt shown when a sync the user initiated fails.") @@ -1648,7 +1937,11 @@ extension ReaderStreamViewController: NetworkAwareUI { extension ReaderStreamViewController: NetworkStatusDelegate { func networkStatusDidChange(active: Bool) { - syncIfAppropriate() + if isLoadingDiscover { + fetchSiteTopic() + } else { + syncIfAppropriate() + } } } @@ -1663,3 +1956,143 @@ extension ReaderStreamViewController: UIViewControllerTransitioningDelegate { return FancyAlertPresentationController(presentedViewController: presented, presenting: presenting) } } + + +// MARK: - ReaderContentViewController +extension ReaderStreamViewController: ReaderContentViewController { + func setContent(_ content: ReaderContent) { + isContentFiltered = content.topicType == .tag || content.topicType == .site + readerTopic = content.topicType == .discover ? nil : content.topic + contentType = content.type + + guard !shouldDisplayNoTopicController else { + return + } + + siteID = content.topicType == .discover ? ReaderHelpers.discoverSiteID : nil + trackFilterTime() + } + + func trackFilterTime() { + if isContentFiltered { + ReaderTracker.shared.start(.filteredList) + } else { + ReaderTracker.shared.stop(.filteredList) + } + } +} + +// MARK: - Saved Posts Delegate +extension ReaderStreamViewController: ReaderSavedPostCellActionsDelegate { + func willRemove(_ cell: ReaderPostCardCell) { + if let cellIndex = tableView.indexPath(for: cell) { + tableView.reloadRows(at: [cellIndex], with: .fade) + } + } +} + +// MARK: - Undo + +extension ReaderStreamViewController: ReaderPostUndoCellDelegate { + func readerCellWillUndo(_ cell: ReaderSavedPostUndoCell) { + if let cellIndex = tableView.indexPath(for: cell), + let post: ReaderPost = content.object(at: cellIndex) { + postCellActions?.restoreUnsavedPost(post) + tableView.reloadRows(at: [cellIndex], with: .fade) + } + } +} + +// MARK: - View content types without a topic +private extension ReaderStreamViewController { + + var shouldDisplayNoTopicController: Bool { + switch contentType { + case .selfHostedFollowing: + displaySelfHostedFollowingController() + return true + case .contentError: + displayContentErrorController() + return true + default: + removeNoTopicController() + return false + } + } + + func displaySelfHostedFollowingController() { + let controller = NoResultsViewController.noFollowedSitesController(showActionButton: isLoggedIn) + controller.delegate = self + + addNoTopicController(controller) + + view.isUserInteractionEnabled = true + } + + func displayContentErrorController() { + let controller = noTopicViewController(title: NoTopicConstants.contentErrorTitle, + subtitle: NoTopicConstants.contentErrorSubtitle, + image: NoTopicConstants.contentErrorImage) + addNoTopicController(controller) + + view.isUserInteractionEnabled = true + } + + + func noTopicViewController(title: String, + buttonTitle: String? = nil, + subtitle: String? = nil, + image: String? = nil) -> NoResultsViewController { + let controller = NoResultsViewController.controller() + controller.configure(title: title, + buttonTitle: buttonTitle, + subtitle: subtitle, + image: image) + + return controller + } + + func addNoTopicController(_ controller: NoResultsViewController) { + addChild(controller) + view.addSubview(controller.view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + view.pinSubviewToAllEdges(controller.view) + controller.didMove(toParent: self) + noTopicController = controller + } + + func removeNoTopicController() { + if let controller = noTopicController as? NoResultsViewController { + controller.removeFromView() + noTopicController = nil + } + } + + enum NoTopicConstants { + static let retryButtonTitle = NSLocalizedString("Retry", comment: "title for action that tries to connect to the reader after a loading error.") + static let contentErrorTitle = NSLocalizedString("Unable to load this content right now.", comment: "Default title shown for no-results when the device is offline.") + static let contentErrorSubtitle = NSLocalizedString("Check your network connection and try again.", comment: "Default subtitle for no-results when there is no connection") + static let contentErrorImage = "cloud" + } +} + +extension ReaderStreamViewController: ReaderTopicsChipsDelegate { + func heightDidChange() { + // Forces the table view to layout the cells and update their heights + tableView.beginUpdates() + tableView.endUpdates() + } + + func didSelect(topic: String) { + let topicStreamViewController = ReaderStreamViewController.controllerWithTagSlug(topic) + navigationController?.pushViewController(topicStreamViewController, animated: true) + } +} + +// MARK: - Jetpack banner delegate + +extension ReaderStreamViewController: UITableViewDelegate, JPScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + processJetpackBannerVisibility(scrollView) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSubscribeCommentsAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSubscribeCommentsAction.swift new file mode 100644 index 000000000000..7fd4486454b2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSubscribeCommentsAction.swift @@ -0,0 +1,57 @@ +/// Encapsulates a command to subscribe or unsubscribe to a posts comments. +final class ReaderSubscribeCommentsAction { + func execute(with post: ReaderPost, + context: NSManagedObjectContext, + followCommentsService: FollowCommentsService, + sourceViewController: UIViewController, + completion: (() -> Void)? = nil, + failure: ((Error?) -> Void)? = nil) { + + let subscribing = !post.isSubscribedComments + + followCommentsService.toggleSubscribed(post.isSubscribedComments, success: { subscribeSuccess in + followCommentsService.toggleNotificationSettings(subscribing, success: { + ReaderHelpers.dispatchToggleSubscribeCommentMessage(subscribing: subscribing, success: subscribeSuccess) { actionSuccess in + self.disableNotificationSettings(followCommentsService: followCommentsService) + Self.trackNotificationUndo(post: post, sourceViewController: sourceViewController) + } + completion?() + }, failure: { error in + DDLogError("Error toggling comment notification status: \(error.debugDescription)") + ReaderHelpers.dispatchToggleCommentNotificationMessage(subscribing: false, success: false) + failure?(error) + }) + }, failure: { error in + DDLogError("Error toggling comment subscription status: \(error.debugDescription)") + ReaderHelpers.dispatchToggleSubscribeCommentErrorMessage(subscribing: subscribing) + failure?(error) + }) + } + + private func disableNotificationSettings(followCommentsService: FollowCommentsService) { + followCommentsService.toggleNotificationSettings(false, success: { + ReaderHelpers.dispatchToggleCommentNotificationMessage(subscribing: false, success: true) + }, failure: { error in + DDLogError("Error toggling comment notification status: \(error.debugDescription)") + ReaderHelpers.dispatchToggleCommentNotificationMessage(subscribing: false, success: false) + }) + } + + private static func trackNotificationUndo(post: ReaderPost, sourceViewController: UIViewController) { + var properties = [String: Any]() + properties["notifications_enabled"] = false + properties[WPAppAnalyticsKeyBlogID] = post.siteID + properties[WPAppAnalyticsKeySource] = sourceForTrackingEvents(sourceViewController: sourceViewController) + WPAnalytics.trackReader(.readerToggleCommentNotifications, properties: properties) + } + + private static func sourceForTrackingEvents(sourceViewController: UIViewController) -> String { + if sourceViewController is ReaderDetailViewController { + return "reader_post_details_menu" + } else if sourceViewController is ReaderStreamViewController { + return "reader" + } + + return "unknown" + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderSubscribingNotificationAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderSubscribingNotificationAction.swift index 33af4f760e0b..610ee490b1f9 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderSubscribingNotificationAction.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderSubscribingNotificationAction.swift @@ -1,15 +1,11 @@ /// Encapsulates a command to toggle subscribing to notifications for a site final class ReaderSubscribingNotificationAction { - func execute(for siteID: NSNumber?, context: NSManagedObjectContext, value: Bool) { - toggleSubscribingNotifications(for: siteID, subscribe: value, context: context) - } - - fileprivate func toggleSubscribingNotifications(for siteID: NSNumber?, subscribe: Bool, context: NSManagedObjectContext) { + func execute(for siteID: NSNumber?, context: NSManagedObjectContext, subscribe: Bool, completion: (() -> Void)? = nil, failure: ((ReaderTopicServiceError?) -> Void)? = nil) { guard let siteID = siteID else { return } - let service = ReaderTopicService(managedObjectContext: context) - service.toggleSubscribingNotifications(for: siteID.intValue, subscribe: subscribe) + let service = ReaderTopicService(coreDataStack: ContextManager.shared) + service.toggleSubscribingNotifications(for: siteID.intValue, subscribe: subscribe, completion, failure) } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTableCardCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderTableCardCell.swift new file mode 100644 index 000000000000..2ba1e72f9a86 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTableCardCell.swift @@ -0,0 +1,171 @@ +import UIKit + +/// A cell that contains a table that displays a list of ReaderAbstractTopic's +/// +class ReaderTopicsTableCardCell: UITableViewCell { + private let containerView = UIView() + + let tableView: UITableView = ReaderTopicsTableView() + + private(set) var data: [ReaderAbstractTopic] = [] { + didSet { + guard oldValue != data else { + return + } + + tableView.reloadData() + } + } + + /// Constraints to be activated in compact horizontal size class + private var compactConstraints: [NSLayoutConstraint] = [] + + /// Constraints to be activated in regular horizontal size class + private var regularConstraints: [NSLayoutConstraint] = [] + + weak var delegate: ReaderTopicsTableCardCellDelegate? + + // Subclasses should configure these properties + var headerTitle: String? + var headerContentInsets: UIEdgeInsets = UIEdgeInsets(top: 10, left: 15, bottom: 10, right: 0) + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupTableView() + applyStyles() + + // Since iOS 14, the contentView is in the top of the view hierarchy. + // This conflicts with the tableView interaction, so we disable it. + contentView.isUserInteractionEnabled = false + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + refreshHorizontalConstraints() + } + + func configure(_ data: [ReaderAbstractTopic]) { + self.data = data + } + + func setupTableView() { + addSubview(containerView) + containerView.translatesAutoresizingMaskIntoConstraints = false + pinSubviewToSafeArea(containerView, insets: Constants.containerInsets) + containerView.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: containerView.topAnchor), + tableView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) + ]) + + // Constraints for regular horizontal size class + regularConstraints = [ + tableView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor) + ] + + // Constraints for compact horizontal size class + compactConstraints = [ + tableView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor) + ] + + tableView.isScrollEnabled = false + tableView.dataSource = self + tableView.delegate = self + } + + private func applyStyles() { + containerView.backgroundColor = .listForeground + tableView.backgroundColor = .listForeground + tableView.separatorColor = .placeholderElement + + backgroundColor = .clear + contentView.backgroundColor = .clear + + refreshHorizontalConstraints() + } + + func cell(forRowAt indexPath: IndexPath, tableView: UITableView, topic: ReaderAbstractTopic?) -> UITableViewCell { + return UITableViewCell() + } + + // Activate and deactivate constraints based on horizontal size class + private func refreshHorizontalConstraints() { + let isCompact = (traitCollection.horizontalSizeClass == .compact) + + compactConstraints.forEach { $0.isActive = isCompact } + regularConstraints.forEach { $0.isActive = !isCompact } + } + + private enum Constants { + static let containerInsets = UIEdgeInsets(top: 8, left: 0, bottom: 0, right: 0) + } +} + +extension ReaderTopicsTableCardCell: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return data.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let tableCell = cell(forRowAt: indexPath, tableView: tableView, topic: data[indexPath.row]) + + tableCell.backgroundColor = .clear + tableCell.contentView.backgroundColor = .clear + + return tableCell + } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return UIView(frame: .zero) + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 1 + } +} + +extension ReaderTopicsTableCardCell: UITableViewDelegate { + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let title = headerTitle else { + return nil + } + let header = UIView() + let headerTitle = UILabel() + headerTitle.text = title + header.addSubview(headerTitle) + headerTitle.translatesAutoresizingMaskIntoConstraints = false + header.pinSubviewToAllEdges(headerTitle, insets: headerContentInsets) + headerTitle.font = WPStyleGuide.serifFontForTextStyle(.title2) + return header + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let topic = data[indexPath.row] + delegate?.didSelect(topic: topic) + tableView.deselectSelectedRowWithAnimation(true) + } +} + +private class ReaderTopicsTableView: UITableView { + override var intrinsicContentSize: CGSize { + self.layoutIfNeeded() + return self.contentSize + } + + override var contentSize: CGSize { + didSet { + self.invalidateIntrinsicContentSize() + } + } +} + +protocol ReaderTopicsTableCardCellDelegate: AnyObject { + func didSelect(topic: ReaderAbstractTopic) +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTagStreamHeader.swift b/WordPress/Classes/ViewRelated/Reader/ReaderTagStreamHeader.swift index 8a65f91168d2..614da97a0d29 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderTagStreamHeader.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTagStreamHeader.swift @@ -2,15 +2,12 @@ import Foundation import WordPressShared @objc open class ReaderTagStreamHeader: UIView, ReaderStreamHeader { - @IBOutlet fileprivate weak var borderedView: UIView! @IBOutlet fileprivate weak var titleLabel: UILabel! - @IBOutlet fileprivate weak var followButton: PostMetaButton! + @IBOutlet fileprivate weak var followButton: UIButton! open var delegate: ReaderStreamHeaderDelegate? - // MARK: - Lifecycle Methods - open override func awakeFromNib() { super.awakeFromNib() @@ -19,24 +16,28 @@ import WordPressShared } @objc func applyStyles() { - backgroundColor = .listBackground - borderedView.backgroundColor = .listForeground - borderedView.layer.borderColor = WPStyleGuide.readerCardCellBorderColor().cgColor - borderedView.layer.borderWidth = .hairlineBorderWidth WPStyleGuide.applyReaderStreamHeaderTitleStyle(titleLabel) } + open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + WPStyleGuide.applyReaderFollowButtonStyle(followButton) + } + } + // MARK: - Configuration @objc open func configureHeader(_ topic: ReaderAbstractTopic) { titleLabel.text = topic.title - WPStyleGuide.applyReaderFollowButtonStyle(followButton) followButton.isSelected = topic.following + WPStyleGuide.applyReaderFollowButtonStyle(followButton) } @objc open func enableLoggedInFeatures(_ enable: Bool) { - followButton.isHidden = !enable + } fileprivate func adjustInsetsForTextDirection() { @@ -52,6 +53,10 @@ import WordPressShared // MARK: - Actions @IBAction func didTapFollowButton(_ sender: UIButton) { - delegate?.handleFollowActionForHeader(self) + followButton.isUserInteractionEnabled = false + + delegate?.handleFollowActionForHeader(self, completion: { [weak self] in + self?.followButton.isUserInteractionEnabled = true + }) } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTagStreamHeader.xib b/WordPress/Classes/ViewRelated/Reader/ReaderTagStreamHeader.xib index 839f332223f4..033af19e7e2c 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderTagStreamHeader.xib +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTagStreamHeader.xib @@ -1,85 +1,55 @@ - - - - - + + + - + + - + - - - - - - - - - - - - - - - - - - - - + + - + + - - - - - - - - + + + + + + + - - - + + + - - - diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTopicsCardCell.swift b/WordPress/Classes/ViewRelated/Reader/ReaderTopicsCardCell.swift new file mode 100644 index 000000000000..d83245cd979a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTopicsCardCell.swift @@ -0,0 +1,135 @@ +import UIKit + +/// A cell that displays topics the user might like +/// +class ReaderTopicsCardCell: UITableViewCell, NibLoadable { + // Views + @IBOutlet weak var containerView: UIView! + @IBOutlet weak var headerLabel: UILabel! + @IBOutlet weak var collectionView: UICollectionView! + + private(set) var data: [ReaderAbstractTopic] = [] { + didSet { + guard oldValue != data else { + return + } + + collectionView.reloadData() + } + } + + weak var delegate: ReaderTopicsTableCardCellDelegate? + + func configure(_ data: [ReaderAbstractTopic]) { + self.data = data + } + + override func awakeFromNib() { + super.awakeFromNib() + + applyStyles() + + // Configure header + headerLabel.text = Constants.title + + // Configure collection view + collectionView.showsHorizontalScrollIndicator = false + collectionView.register(ReaderInterestsCollectionViewCell.defaultNib, + forCellWithReuseIdentifier: ReaderInterestsCollectionViewCell.defaultReuseID) + } + + private func applyStyles() { + headerLabel.font = WPStyleGuide.serifFontForTextStyle(.title2) + + containerView.backgroundColor = .listForeground + headerLabel.backgroundColor = .listForeground + collectionView.backgroundColor = .listForeground + + backgroundColor = .clear + contentView.backgroundColor = .clear + } + + private struct Constants { + static let title = NSLocalizedString("You might like", comment: "A suggestion of topics the user might like") + + static let reuseIdentifier = ReaderInterestsCollectionViewCell.defaultReuseID + } +} + +// MARK: - Collection View: Datasource & Delegate +extension ReaderTopicsCardCell: UICollectionViewDelegate, UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Constants.reuseIdentifier, + for: indexPath) as? ReaderInterestsCollectionViewCell else { + fatalError("Expected a ReaderInterestsCollectionViewCell for identifier: \(Constants.reuseIdentifier)") + } + + let title = data[indexPath.row].title + + ReaderSuggestedTopicsStyleGuide.applySuggestedTopicStyle(label: cell.label, + with: indexPath.row) + + cell.label.text = title + cell.label.accessibilityTraits = .button + + // We need to use the calculated size for the height / corner radius because the cell size doesn't change until later + let size = sizeForCell(title: title) + cell.label.layer.cornerRadius = size.height * 0.5 + + return cell + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + collectionView.reloadData() + } + } + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return data.count + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let topic = data[indexPath.row] + + delegate?.didSelect(topic: topic) + } +} + +// MARK: - Collection View: Flow Layout Delegate +extension ReaderTopicsCardCell: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return sizeForCell(title: data[indexPath.row].title) + } + + // Calculates the dynamic size of the collection view cell based on the provided title + private func sizeForCell(title: String) -> CGSize { + let attributes: [NSAttributedString.Key: Any] = [ + .font: ReaderSuggestedTopicsStyleGuide.topicFont + ] + + let title: NSString = title as NSString + + var size = title.size(withAttributes: attributes) + size.height += (CellConstants.marginY * 2) + + // Prevent 1 token from being too long + let maxWidth = collectionView.bounds.width * CellConstants.maxWidthMultiplier + let width = min(size.width, maxWidth) + size.width = width + (CellConstants.marginX * 2) + + return size + } + + private struct CellConstants { + static let maxWidthMultiplier: CGFloat = 0.8 + static let marginX: CGFloat = 16 + static let marginY: CGFloat = 8 + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderTopicsCardCell.xib b/WordPress/Classes/ViewRelated/Reader/ReaderTopicsCardCell.xib new file mode 100644 index 000000000000..effda1875645 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderTopicsCardCell.xib @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderVisitSiteAction.swift b/WordPress/Classes/ViewRelated/Reader/ReaderVisitSiteAction.swift index fb03e7e636e7..0e2cb91c8c29 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderVisitSiteAction.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderVisitSiteAction.swift @@ -9,12 +9,13 @@ final class ReaderVisitSiteAction { let configuration = WebViewControllerConfiguration(url: siteURL) configuration.addsWPComReferrer = true - let service = AccountService(managedObjectContext: context) - if let account = service.defaultWordPressComAccount() { + + if let account = try? WPAccount.lookupDefaultWordPressComAccount(in: context) { configuration.authenticate(account: account) } - let controller = WebViewControllerFactory.controller(configuration: configuration) - let navController = UINavigationController(rootViewController: controller) + let controller = WebViewControllerFactory.controller(configuration: configuration, source: "reader_visit_site") + let navController = LightNavigationController(rootViewController: controller) origin.present(navController, animated: true) + WPAnalytics.trackReader(.readerArticleVisited) } } diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderWelcomeBanner.swift b/WordPress/Classes/ViewRelated/Reader/ReaderWelcomeBanner.swift new file mode 100644 index 000000000000..e07ec61aa7ad --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderWelcomeBanner.swift @@ -0,0 +1,46 @@ +import Foundation + +class ReaderWelcomeBanner: UIView, NibLoadable { + @IBOutlet weak var welcomeLabel: UILabel! + @IBOutlet weak var extraDotsView: UIView! + + static var bannerPresentedKey = "welcomeBannerPresented" + + override func awakeFromNib() { + super.awakeFromNib() + applyStyles() + configureWelcomeLabel() + showExtraDotsIfNeeded() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + showExtraDotsIfNeeded() + } + + /// Present the Welcome banner just one time + class func displayIfNeeded(in tableView: UITableView, + database: KeyValueDatabase = UserPersistentStoreFactory.instance()) { + guard !database.bool(forKey: ReaderWelcomeBanner.bannerPresentedKey) else { + return + } + + let view: ReaderWelcomeBanner = .loadFromNib() + tableView.tableHeaderView = view + database.set(true, forKey: ReaderWelcomeBanner.bannerPresentedKey) + } + + private func applyStyles() { + welcomeLabel.font = WPStyleGuide.serifFontForTextStyle(.title2) + backgroundColor = UIColor(light: .muriel(color: MurielColor(name: .blue, shade: .shade0)), dark: .listForeground) + welcomeLabel.textColor = UIColor(light: .muriel(color: MurielColor(name: .blue, shade: .shade80)), dark: .white) + } + + private func configureWelcomeLabel() { + welcomeLabel.text = NSLocalizedString("Welcome to Reader. Discover millions of blogs at your fingertips.", comment: "Welcome message shown under Discover in the Reader just the 1st time the user sees it") + } + + private func showExtraDotsIfNeeded() { + extraDotsView.isHidden = (traitCollection.horizontalSizeClass == .compact) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderWelcomeBanner.xib b/WordPress/Classes/ViewRelated/Reader/ReaderWelcomeBanner.xib new file mode 100644 index 000000000000..b3ceda7910d9 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/ReaderWelcomeBanner.xib @@ -0,0 +1,250 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Reader/SavedForLaterMenuItemCreator.swift b/WordPress/Classes/ViewRelated/Reader/SavedForLaterMenuItemCreator.swift deleted file mode 100644 index 39cd6b1f3bcb..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/SavedForLaterMenuItemCreator.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Gridicons - -/// Encapsulates creating of a ReaderMenuItem for Bookmarks / Saved for Later -final class SavedForLaterMenuItemCreator { - func menuItem() -> ReaderMenuItem { - let title = NSLocalizedString("Saved Posts", comment: "Title of the reader's Saved Posts menu item.") - var item = ReaderMenuItem(title: title, - type: .savedPosts) - item.icon = Gridicon.iconOfType(.bookmark) - item.order = ReaderDefaultMenuItemOrder.savedForLater.rawValue - - return item - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/SearchMenuItemCreator.swift b/WordPress/Classes/ViewRelated/Reader/SearchMenuItemCreator.swift deleted file mode 100644 index 7b6380d5edcb..000000000000 --- a/WordPress/Classes/ViewRelated/Reader/SearchMenuItemCreator.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Gridicons - -/// Encapsulates creating of a ReaderMenuItem for Search -final class SearchMenuItemCreator { - func menuItem() -> ReaderMenuItem { - let title = NSLocalizedString("Search", comment: "Title of the reader's Search menu item.") - var item = ReaderMenuItem(title: title, type: .search) - item.order = ReaderDefaultMenuItemOrder.search.rawValue - item.icon = Gridicon.iconOfType(.search) - - return item - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsCollectionViewCell.swift b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsCollectionViewCell.swift new file mode 100644 index 000000000000..febd18824b7a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsCollectionViewCell.swift @@ -0,0 +1,17 @@ +import UIKit + +class ReaderInterestsCollectionViewCell: UICollectionViewCell, NibReusable { + @IBOutlet weak var label: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + + guard let label = self.label else { + return + } + + label.isAccessibilityElement = true + accessibilityElements = [label] + } + +} diff --git a/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsCollectionViewCell.xib b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsCollectionViewCell.xib new file mode 100644 index 000000000000..421c13b03e2d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsCollectionViewCell.xib @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsCollectionViewFlowLayout.swift b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsCollectionViewFlowLayout.swift new file mode 100644 index 000000000000..958f9a79ae89 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsCollectionViewFlowLayout.swift @@ -0,0 +1,266 @@ +import UIKit + +protocol ReaderInterestsCollectionViewFlowLayoutDelegate: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout: ReaderInterestsCollectionViewFlowLayout, sizeForOverflowItem at: IndexPath, remainingItems: Int?) -> CGSize +} + +class ReaderInterestsCollectionViewFlowLayout: UICollectionViewFlowLayout { + weak var delegate: ReaderInterestsCollectionViewFlowLayoutDelegate? + + @IBInspectable var itemSpacing: CGFloat = 6 + @IBInspectable var cellHeight: CGFloat = 40 + @IBInspectable var allowsCentering: Bool = true + + /// Whether or not the layout should be force centered + @IBInspectable var isCentered: Bool = false + + // Collapsing/Expanding support + static let overflowItemKind = "InterestsOverflowItem" + var maxNumberOfDisplayedLines: Int? + var isExpanded: Bool = false + var remainingItems: Int? + + private var layoutAttributes: [UICollectionViewLayoutAttributes] = [] + + private var isRightToLeft: Bool { + let layoutDirection: UIUserInterfaceLayoutDirection = collectionView?.effectiveUserInterfaceLayoutDirection ?? .leftToRight + return layoutDirection == .rightToLeft + } + + // The content width minus the content insets used when calculating rows, and centering + private var maxContentWidth: CGFloat { + guard let collectionView = collectionView else { + return 0 + } + + let contentInsets: UIEdgeInsets = collectionView.contentInset + + return collectionView.bounds.width - (contentInsets.left + contentInsets.right) + } + + /// The calculated content size for the view + private var contentSize: CGSize = .zero + + override open var collectionViewContentSize: CGSize { + return contentSize + } + + func collectionView(_ collectionView: UICollectionView, layout: ReaderInterestsCollectionViewFlowLayout, sizeForOverflowItem at: IndexPath) -> CGSize { + return .zero + } + + override func prepare() { + guard let collectionView = collectionView else { + return + } + + let contentInsets = collectionView.contentInset + + // The current row used to calculate the y position and the total content height + var currentRow: CGFloat = 0 + + // Keeps track of the previous items frame so we can properly calculate the current item's x position + var previousFrame: CGRect = .zero + + let numberOfItems: Int = collectionView.numberOfItems(inSection: 0) + + // If we're expanded we will show the 'hide' option at the end + let count = isExpanded ? numberOfItems + 1 : numberOfItems + + for item in 0 ..< count { + let indexPath: IndexPath = IndexPath(row: item, section: 0) + let isCollapseItem = item == numberOfItems + let itemSize = isCollapseItem ? sizeForOverflowItem(at: indexPath) : sizeForItem(at: indexPath) + var frame: CGRect = CGRect(origin: .zero, size: itemSize) + + if item == 0 { + let minX: CGFloat = isRightToLeft ? maxContentWidth - frame.width : 0 + frame.origin = CGPoint(x: minX, y: contentInsets.top) + } else { + frame.origin.x = isRightToLeft ? previousFrame.minX - itemSpacing - frame.width : previousFrame.maxX + itemSpacing + + // If the new X position will go off screen move it to the next row + let needsNewRow = isRightToLeft ? frame.origin.x < 0 : frame.maxX > maxContentWidth + if needsNewRow { + // Cap the display to the maximum number of lines, and display the grouped item + // If we're in the expanded state display all the items + if let maxLines = maxNumberOfDisplayedLines, isExpanded == false, Int(currentRow) >= maxLines - 1 { + remainingItems = numberOfItems - item + 1 + + // Remove the last added item and replace it with the expand item + // If there's only 1 token left, don't remove it + if layoutAttributes.count > 1 { + layoutAttributes.removeLast() + } + + // Get the frame for the item that appears before the item we just removed + let lastFrame = layoutAttributes.last?.frame ?? previousFrame + var overflowFrame = previousFrame + + overflowFrame.size = sizeForOverflowItem(at: indexPath, remainingItems: remainingItems) + overflowFrame.origin.x = isRightToLeft ? lastFrame.minX - itemSpacing - overflowFrame.width : lastFrame.maxX + itemSpacing + + let overflowAttribute = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: Self.overflowItemKind, + with: indexPath) + overflowAttribute.frame = overflowFrame + + layoutAttributes.append(overflowAttribute) + + break + } + + frame.origin.x = isRightToLeft ? (maxContentWidth - frame.width) : 0 + currentRow += 1 + } + + frame.origin.y = currentRow * (cellHeight + itemSpacing) + contentInsets.top + remainingItems = nil + } + + let attribute: UICollectionViewLayoutAttributes + + if isCollapseItem { + attribute = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: Self.overflowItemKind, with: indexPath) + } else { + attribute = UICollectionViewLayoutAttributes(forCellWith: indexPath) + } + + attribute.frame = frame + layoutAttributes.append(attribute) + + previousFrame = frame + } + + // Update content size + contentSize.width = maxContentWidth + contentSize.height = (currentRow + 1) * cellHeight + contentInsets.top + contentInsets.bottom + (currentRow * itemSpacing) + } + + override func invalidateLayout() { + contentSize = .zero + layoutAttributes = [] + + super.invalidateLayout() + } + + /// Get the size for the given index path from the delegate, or the default item size + /// - Parameter indexPath: index path for the item + /// - Returns: The width for the cell either from the delegate or the itemSize property + private func sizeForItem(at indexPath: IndexPath) -> CGSize { + guard + let collectionView = collectionView, + let delegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout, + let size = delegate.collectionView?(collectionView, layout: self, sizeForItemAt: indexPath) + else { + return CGSize(width: itemSize.width, height: cellHeight) + } + + return CGSize(width: size.width, height: cellHeight) + } + + private func sizeForOverflowItem(at indexPath: IndexPath, remainingItems: Int? = nil) -> CGSize { + guard + let collectionView = collectionView, + let delegate = delegate + else { + return CGSize(width: itemSize.width, height: cellHeight) + } + + let size = delegate.collectionView(collectionView, layout: self, sizeForOverflowItem: indexPath, remainingItems: remainingItems) + return CGSize(width: size.width, height: cellHeight) + } + + + override open func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + if allowsCentering, isCentered || collectionView?.traitCollection.horizontalSizeClass == .regular { + return centeredLayoutAttributesForElements(in: rect) + } + + return self.layoutAttributes.filter { + return $0.frame.intersects(rect) + } + } + + private func centeredLayoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + var rows = [Row]() + var rowY: CGFloat = .greatestFiniteMagnitude + + // Create an array of "rows" based on the y positions + for attribute in layoutAttributes { + if attribute.frame.intersects(rect) == false { + continue + } + + let minY = attribute.frame.minY + + if rowY != minY { + rowY = minY + + rows.append(Row(itemSpacing: itemSpacing, isRightToLeft: isRightToLeft)) + } + + rows.last?.add(attribute: attribute) + } + + return rows.flatMap { (row: Row) -> [UICollectionViewLayoutAttributes] in + return row.centeredLayoutAttributesIn(width: maxContentWidth) + } + } + + override open func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + guard indexPath.row < layoutAttributes.count else { + return nil + } + return layoutAttributes[indexPath.row] + } +} + +// MARK: - Row Centering Helper Class +private class Row { + var layoutAttributes = [UICollectionViewLayoutAttributes]() + let itemSpacing: CGFloat + let isRightToLeft: Bool + + init(itemSpacing: CGFloat, isRightToLeft: Bool) { + self.itemSpacing = itemSpacing + self.isRightToLeft = isRightToLeft + } + + /// Add a new attribute to the row + /// - Parameter attribute: layout attribute to be added + public func add(attribute: UICollectionViewLayoutAttributes) { + layoutAttributes.append(attribute) + } + + /// Calculates a new X position for each item in this row based on a new x offset + /// - Parameter width: The total width of the container view to center in + /// - Returns: The new centered layout attributes + public func centeredLayoutAttributesIn(width: CGFloat) -> [UICollectionViewLayoutAttributes] { + let centerX = (width - rowWidth) * 0.5 + + var offset = isRightToLeft ? width - centerX : centerX + + layoutAttributes.forEach { attribute in + let itemWidth = attribute.frame.width + itemSpacing + + if isRightToLeft { + attribute.frame.origin.x = offset - attribute.frame.width + offset -= itemWidth + } else { + attribute.frame.origin.x = offset + offset += itemWidth + } + } + + return layoutAttributes + } + + /// Calculate the total row width including spacing + private var rowWidth: CGFloat { + let width = layoutAttributes.reduce(0, { width, attribute -> CGFloat in + return width + attribute.frame.width + }) + + return width + itemSpacing * CGFloat(layoutAttributes.count - 1) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsDataSource.swift b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsDataSource.swift new file mode 100644 index 000000000000..819bba76a0ff --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsDataSource.swift @@ -0,0 +1,109 @@ +import Foundation + +// MARK: - ReaderInterestViewModel +class ReaderInterestViewModel { + var isSelected: Bool = false + + var title: String { + return interest.title + } + + var slug: String { + return interest.slug + } + + let interest: RemoteReaderInterest + + init(interest: RemoteReaderInterest) { + self.interest = interest + } + + public func toggleSelected() { + self.isSelected = !isSelected + } +} + +// MARK: - ReaderInterestsDataDelegate +protocol ReaderInterestsDataDelegate: AnyObject { + func readerInterestsDidUpdate(_ dataSource: ReaderInterestsDataSource) +} + +// MARK: - ReaderInterestsDataSource +class ReaderInterestsDataSource { + weak var delegate: ReaderInterestsDataDelegate? + + private(set) var count: Int = 0 + + private var interests: [ReaderInterestViewModel] = [] { + didSet { + count = interests.count + + delegate?.readerInterestsDidUpdate(self) + } + } + + var selectedInterests: [ReaderInterestViewModel] { + return interests.filter { $0.isSelected } + } + + private var interestsService: ReaderInterestsService + private let topics: [ReaderTagTopic] + + /// Creates a new instance of the data source + /// - Parameter topicService: An Optional `ReaderTopicService` to use. If this is `nil` one will be created on the main context + init(topics: [ReaderTagTopic], service: ReaderInterestsService? = nil) { + self.topics = topics + + guard let service = service else { + self.interestsService = ReaderTopicService(coreDataStack: ContextManager.shared) + return + } + + self.interestsService = service + } + + /// Fetches the interests from the topic service + public func reload() { + interestsService.fetchInterests(success: { [weak self] interests in + guard let self = self else { + return + } + + self.interests = interests + .filter { interest in + // Filter out interests that are already being followed + !self.topics.contains(where: { followedTopic in + + // NOTE: Comparing the topic's title against the interest's slug is a workaround for + // topics that contain multiple words. + // + // We need this workaround because of an unfortunate bug: + // When we sync the remotely fetched topic with the locally stored topic, the + // "display_name" value is mapped to the local topic's title instead of the "title" value. + // + // See: ReaderTopicServiceRemote.m, normalizeTopicDictionary:subscribed:recommended: + + followedTopic.title.caseInsensitiveCompare(interest.slug) == .orderedSame + }) + } + .map { ReaderInterestViewModel(interest: $0) } + + }) { [weak self] (error: Error) in + DDLogError("Error: Could not retrieve reader interests: \(String(describing: error))") + + self?.interests = [] + } + } + + /// Reset all selected interests + public func reset() { + interests.forEach { $0.isSelected = false } + } + + /// Returns a reader interest for the specified row + /// - Parameter row: The index of the item you want to return + /// - Returns: A reader interest model + public func interest(for row: Int) -> ReaderInterestViewModel { + return interests[row] + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsStyleGuide.swift b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsStyleGuide.swift new file mode 100644 index 000000000000..1382017aa3a8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderInterestsStyleGuide.swift @@ -0,0 +1,149 @@ +import Foundation +import WordPressShared + +class ReaderInterestsStyleGuide { + // MARK: - View Styles + public class func applyTitleLabelStyles(label: UILabel) { + label.font = WPStyleGuide.serifFontForTextStyle(.largeTitle, fontWeight: .medium) + label.textColor = .text + } + + public class func applySubtitleLabelStyles(label: UILabel) { + label.font = WPStyleGuide.fontForTextStyle(.body) + label.textColor = .text + } + + // MARK: - Collection View Cell Styles + public class var cellLabelTitleFont: UIFont { + return WPStyleGuide.fontForTextStyle(.body) + } + + public class func applyCellLabelStyle(label: UILabel, isSelected: Bool) { + label.font = WPStyleGuide.fontForTextStyle(.body) + label.textColor = isSelected ? .white : .text + label.backgroundColor = isSelected ? .muriel(color: .primary, .shade40) : .quaternaryBackground + } + + // MARK: - Compact Collection View Cell Styles + public class var compactCellLabelTitleFont: UIFont { + return WPStyleGuide.fontForTextStyle(.footnote) + } + + public class func applyCompactCellLabelStyle(label: UILabel) { + label.font = Self.compactCellLabelTitleFont + label.textColor = .text + label.backgroundColor = .quaternaryBackground + } + + // MARK: - Next Button + public static let buttonContainerViewBackgroundColor: UIColor = .tertiarySystemBackground + + public class func applyNextButtonStyle(button: FancyButton) { + let disabledBackgroundColor: UIColor + let titleColor: UIColor + + disabledBackgroundColor = UIColor(light: .systemGray4, dark: .systemGray3) + titleColor = .textTertiary + + button.disabledTitleColor = titleColor + button.disabledBorderColor = disabledBackgroundColor + button.disabledBackgroundColor = disabledBackgroundColor + } + + // MARK: - Loading + public class func applyLoadingLabelStyles(label: UILabel) { + label.font = WPStyleGuide.fontForTextStyle(.body) + label.textColor = .textSubtle + } + + public class func applyActivityIndicatorStyles(indicator: UIActivityIndicatorView) { + indicator.color = UIColor(light: .black, dark: .white) + } +} + +class ReaderSuggestedTopicsStyleGuide { + /// The array of colors from the designs + /// Note: I am explictly using the MurielColor names instead of using the semantic ones + /// since these are explicit and not semantic colors. + static let colors: [TopicStyle] = [ + // Green + .init(textColor: .init(colorName: .green, section: .text), + backgroundColor: .init(colorName: .green, section: .background), + borderColor: .init(colorName: .green, section: .border)), + + // Purple + .init(textColor: .init(colorName: .purple, section: .text), + backgroundColor: .init(colorName: .purple, section: .background), + borderColor: .init(colorName: .purple, section: .border)), + + + // Yellow + .init(textColor: .init(colorName: .yellow, section: .text), + backgroundColor: .init(colorName: .yellow, section: .background), + borderColor: .init(colorName: .yellow, section: .border)), + + // Orange + .init(textColor: .init(colorName: .orange, section: .text), + backgroundColor: .init(colorName: .orange, section: .background), + borderColor: .init(colorName: .orange, section: .border)), + ] + + private class func topicStyle(for index: Int) -> TopicStyle { + let colorCount = Self.colors.count + + // Safety feature if for some reason the count of returned topics ever increases past 4 we will + // loop through the list colors again. + return Self.colors[index % colorCount] + } + + public static var topicFont: UIFont = WPStyleGuide.fontForTextStyle(.footnote) + + public class func applySuggestedTopicStyle(label: UILabel, with index: Int) { + let style = Self.topicStyle(for: index) + + label.font = Self.topicFont + label.textColor = style.textColor.color() + + label.layer.borderColor = style.borderColor.color().cgColor + label.layer.borderWidth = .hairlineBorderWidth + label.layer.backgroundColor = style.backgroundColor.color().cgColor + } + + // MARK: - Color Representation + struct TopicStyle { + let textColor: TopicColor + let backgroundColor: TopicColor + let borderColor: TopicColor + + struct TopicColor { + enum StyleSection { + case text, background, border + } + + let colorName: MurielColorName + let section: StyleSection + + func color() -> UIColor { + let lightShade: MurielColorShade + let darkShade: MurielColorShade + + switch section { + case .text: + lightShade = .shade50 + darkShade = .shade40 + + case .border: + lightShade = .shade5 + darkShade = .shade100 + + case .background: + lightShade = .shade0 + darkShade = .shade90 + } + + return UIColor(light: .muriel(color: MurielColor(name: colorName, shade: lightShade)), + dark: .muriel(color: MurielColor(name: colorName, shade: darkShade))) + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderSelectInterestsViewController.swift b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderSelectInterestsViewController.swift new file mode 100644 index 000000000000..a98ced56d553 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderSelectInterestsViewController.swift @@ -0,0 +1,422 @@ +import UIKit + +protocol ReaderDiscoverFlowDelegate: AnyObject { + func didCompleteReaderDiscoverFlow() +} + +struct ReaderSelectInterestsConfiguration { + let title: String + let subtitle: String? + let buttonTitle: (enabled: String, disabled: String)? + let loading: String +} + +class ReaderSelectInterestsViewController: UIViewController { + private struct Constants { + static let reuseIdentifier = ReaderInterestsCollectionViewCell.classNameWithoutNamespaces() + static let interestsLabelMargin: CGFloat = 10 + + static let cellCornerRadius: CGFloat = 8 + static let cellSpacing: CGFloat = 6 + static let cellHeight: CGFloat = 40 + static let animationDuration: TimeInterval = 0.2 + static let isCentered: Bool = true + } + + private struct Strings { + static let noSearchResultsTitle = NSLocalizedString("No new topics to follow", comment: "Message shown when there are no new topics to follow.") + static let tryAgainNoticeTitle = NSLocalizedString("Something went wrong. Please try again.", comment: "Error message shown when the app fails to save user selected interests") + static let tryAgainButtonTitle = NSLocalizedString("Try Again", comment: "Try to load the list of interests again.") + } + + // MARK: - IBOutlets + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var subTitleLabel: UILabel! + @IBOutlet weak var collectionView: UICollectionView! + @IBOutlet weak var buttonContainerView: UIView! + @IBOutlet weak var nextButton: FancyButton! + @IBOutlet weak var contentContainerView: UIStackView! + + @IBOutlet weak var activityIndicatorView: UIActivityIndicatorView! + @IBOutlet weak var loadingLabel: UILabel! + @IBOutlet weak var loadingView: UIStackView! + + @IBOutlet weak var bottomSpaceHeightConstraint: NSLayoutConstraint! + + // MARK: - Views + + private let spotlightView: UIView = { + let spotlightView = QuickStartSpotlightView() + spotlightView.translatesAutoresizingMaskIntoConstraints = false + spotlightView.isHidden = true + return spotlightView + }() + + /// Whether or not to show the spotlight animation to illustrate tapping the icon. + var spotlightIsShown: Bool = false { + didSet { + spotlightView.isHidden = !spotlightIsShown + } + } + + // MARK: - Data + private lazy var dataSource: ReaderInterestsDataSource = { + return ReaderInterestsDataSource(topics: topics) + }() + + private let coordinator: ReaderSelectInterestsCoordinator = ReaderSelectInterestsCoordinator() + + private let noResultsViewController = NoResultsViewController.controller() + + private let topics: [ReaderTagTopic] + + private let configuration: ReaderSelectInterestsConfiguration + + var didSaveInterests: (([RemoteReaderInterest]) -> Void)? = nil + + weak var readerDiscoverFlowDelegate: ReaderDiscoverFlowDelegate? + + // MARK: - Init + init(configuration: ReaderSelectInterestsConfiguration, topics: [ReaderTagTopic] = []) { + self.configuration = configuration + self.topics = topics + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + + dataSource.delegate = self + + configureNavigationBar() + configureI18N() + configureCollectionView() + configureNoResultsViewController() + applyStyles() + updateNextButtonState() + refreshData() + + // If the view is being presented overCurrentContext take into account tab bar height + if modalPresentationStyle == .overCurrentContext { + bottomSpaceHeightConstraint.constant = presentingViewController?.tabBarController?.tabBar.bounds.size.height ?? 0 + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + resetSelectedInterests() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + view.addSubview(spotlightView) + view.bringSubviewToFront(spotlightView) + + NSLayoutConstraint.activate([ + collectionView.centerXAnchor.constraint(equalTo: spotlightView.centerXAnchor), + collectionView.centerYAnchor.constraint(equalTo: spotlightView.centerYAnchor) + ]) + + WPAnalytics.trackReader(.selectInterestsShown) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + // If this view was presented over current context and it's disappearing + // it means that the user switched tabs. Keeping it in the view hierarchy cause + // weird black screens, so we dismiss it to avoid that. + if modalPresentationStyle == .overCurrentContext { + dismiss(animated: false) + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + guard let layout = collectionView.collectionViewLayout as? ReaderInterestsCollectionViewFlowLayout else { + return + } + + layout.invalidateLayout() + } + + // MARK: - IBAction's + @IBAction func nextButtonTapped(_ sender: Any) { + saveSelectedInterests() + } + + // MARK: - Display logic + func userIsFollowingTopics(completion: @escaping (Bool) -> Void) { + coordinator.isFollowingInterests(completion: completion) + } + + // MARK: - Private: Configuration + private func configureCollectionView() { + let nib = UINib(nibName: String(describing: ReaderInterestsCollectionViewCell.self), bundle: nil) + collectionView.register(nib, forCellWithReuseIdentifier: Constants.reuseIdentifier) + + guard let layout = collectionView.collectionViewLayout as? ReaderInterestsCollectionViewFlowLayout else { + return + } + + layout.itemSpacing = Constants.cellSpacing + layout.cellHeight = Constants.cellHeight + layout.isCentered = Constants.isCentered + } + + private func configureNoResultsViewController() { + noResultsViewController.delegate = self + } + + private func applyStyles() { + let styleGuide = ReaderInterestsStyleGuide.self + styleGuide.applyTitleLabelStyles(label: titleLabel) + styleGuide.applySubtitleLabelStyles(label: subTitleLabel) + styleGuide.applyNextButtonStyle(button: nextButton) + + buttonContainerView.backgroundColor = ReaderInterestsStyleGuide.buttonContainerViewBackgroundColor + + styleGuide.applyLoadingLabelStyles(label: loadingLabel) + styleGuide.applyActivityIndicatorStyles(indicator: activityIndicatorView) + + } + + private func configureNavigationBar() { + guard isModal() else { + return + } + + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, + target: self, + action: #selector(saveSelectedInterests)) + } + + private func configureI18N() { + + if isModal() { + title = configuration.title + titleLabel.isHidden = true + } else { + titleLabel.text = configuration.title + titleLabel.isHidden = false + } + + if let subtitle = configuration.subtitle { + subTitleLabel.text = subtitle + subTitleLabel.isHidden = false + } else { + subTitleLabel.isHidden = true + } + + if let buttonTitle = configuration.buttonTitle { + nextButton.setTitle(buttonTitle.enabled, for: .normal) + nextButton.setTitle(buttonTitle.disabled, for: .disabled) + buttonContainerView.isHidden = false + } else { + buttonContainerView.isHidden = true + } + + loadingLabel.text = configuration.loading + } + + // MARK: - Private: Data + private func refreshData() { + startLoading(hideLabel: true) + + dataSource.reload() + } + + private func resetSelectedInterests() { + dataSource.reset() + refreshData() + } + + private func reloadData() { + collectionView.reloadData() + stopLoading() + } + + @objc private func saveSelectedInterests() { + guard !dataSource.selectedInterests.isEmpty else { + self.didSaveInterests?([]) + return + } + + navigationItem.rightBarButtonItem?.isEnabled = false + startLoading() + announceLoadingTopics() + + let selectedInterests = dataSource.selectedInterests.map { $0.interest } + + coordinator.saveInterests(interests: selectedInterests) { [weak self] success in + guard success else { + self?.stopLoading() + self?.displayNotice(title: Strings.tryAgainNoticeTitle) + return + } + + self?.trackEvents(with: selectedInterests) + self?.stopLoading() + self?.didSaveInterests?(selectedInterests) + self?.readerDiscoverFlowDelegate?.didCompleteReaderDiscoverFlow() + } + } + + private func trackEvents(with selectedInterests: [RemoteReaderInterest]) { + selectedInterests.forEach { + WPAnalytics.track(.readerTagFollowed, withProperties: ["tag": $0.slug, "source": "discover"]) + } + + WPAnalytics.trackReader(.selectInterestsPicked, properties: ["quantity": selectedInterests.count]) + } + + // MARK: - Private: UI Helpers + private func updateNextButtonState() { + nextButton.isEnabled = dataSource.selectedInterests.count > 0 + } + + private func startLoading(hideLabel: Bool = false) { + loadingLabel.isHidden = hideLabel + + loadingView.alpha = 0 + loadingView.isHidden = false + + activityIndicatorView.startAnimating() + + contentContainerView.alpha = 0 + loadingView.alpha = 1 + } + + private func stopLoading() { + activityIndicatorView.stopAnimating() + + UIView.animate(withDuration: Constants.animationDuration, animations: { + self.contentContainerView.alpha = 1 + self.loadingView.alpha = 0 + }) { _ in + self.loadingView.isHidden = true + } + } + + private func announceLoadingTopics() { + UIAccessibility.post(notification: .screenChanged, argument: self.loadingLabel) + } +} + +// MARK: - UICollectionViewDataSource +extension ReaderSelectInterestsViewController: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return dataSource.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Constants.reuseIdentifier, + for: indexPath) as? ReaderInterestsCollectionViewCell else { + fatalError("Expected a ReaderInterestsCollectionViewCell for identifier: \(Constants.reuseIdentifier)") + } + + let interest: ReaderInterestViewModel = dataSource.interest(for: indexPath.row) + + ReaderInterestsStyleGuide.applyCellLabelStyle(label: cell.label, + isSelected: interest.isSelected) + + cell.layer.cornerRadius = Constants.cellCornerRadius + cell.label.text = interest.title + cell.label.accessibilityTraits = interest.isSelected ? [.selected, .button] : .button + + return cell + } +} + +// MARK: - UICollectionViewDelegate +extension ReaderSelectInterestsViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + + if spotlightIsShown { + spotlightIsShown = false + } + + // End reader quick start tour if user selects a topic. + QuickStartTourGuide.shared.visited(.readerDiscoverSettings) + + dataSource.interest(for: indexPath.row).toggleSelected() + updateNextButtonState() + + UIView.animate(withDuration: 0) { + collectionView.reloadItems(at: [indexPath]) + } + } +} + +// MARK: - UICollectionViewFlowLayout +extension ReaderSelectInterestsViewController: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let interest: ReaderInterestViewModel = dataSource.interest(for: indexPath.row) + + let attributes: [NSAttributedString.Key: Any] = [ + .font: ReaderInterestsStyleGuide.cellLabelTitleFont + ] + + let title: NSString = interest.title as NSString + + var size = title.size(withAttributes: attributes) + size.width += (Constants.interestsLabelMargin * 2) + + return size + } +} + +// MARK: - ReaderInterestsDataDelegate +extension ReaderSelectInterestsViewController: ReaderInterestsDataDelegate { + func readerInterestsDidUpdate(_ dataSource: ReaderInterestsDataSource) { + if dataSource.count > 0 { + hideLoadingView() + reloadData() + } else if !topics.isEmpty { + displayLoadingViewWithNoSearchResults(title: Strings.noSearchResultsTitle) + } else { + displayLoadingViewWithWebAction(title: "") + } + } +} + +// MARK: - NoResultsViewController +extension ReaderSelectInterestsViewController: NoResultsViewControllerDelegate { + func actionButtonPressed() { + refreshData() + } +} + +extension ReaderSelectInterestsViewController { + + func displayLoadingViewWithNoSearchResults(title: String) { + noResultsViewController.configureForNoSearchResults(title: title) + showLoadingView() + } + + func displayLoadingViewWithWebAction(title: String, accessoryView: UIView? = nil) { + noResultsViewController.configure(title: title, + buttonTitle: Strings.tryAgainButtonTitle, + accessoryView: accessoryView) + showLoadingView() + } + + func showLoadingView() { + hideLoadingView() + addChild(noResultsViewController) + view.addSubview(withFadeAnimation: noResultsViewController.view) + noResultsViewController.didMove(toParent: self) + } + + func hideLoadingView() { + noResultsViewController.removeFromView() + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderSelectInterestsViewController.xib b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderSelectInterestsViewController.xib new file mode 100644 index 000000000000..336f844d310e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Select Interests/ReaderSelectInterestsViewController.xib @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabItem.swift b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabItem.swift new file mode 100644 index 000000000000..b9191759fd2b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabItem.swift @@ -0,0 +1,83 @@ +struct ReaderTabItem: FilterTabBarItem { + + let shouldHideButtonsView: Bool + let shouldHideSettingsButton: Bool + let shouldHideTagFilter: Bool + + let content: ReaderContent + + var accessibilityIdentifier: String { + return title + } + + /// initialize with topic + init(_ content: ReaderContent) { + self.content = content + let filterableTopicTypes = [ReaderTopicType.following, .organization] + shouldHideButtonsView = !filterableTopicTypes.contains(content.topicType) && content.type != .selfHostedFollowing + shouldHideSettingsButton = content.type == .selfHostedFollowing + shouldHideTagFilter = content.topicType == .organization + } +} + + +// MARK: - Localized titles +extension ReaderTabItem { + + var title: String { + switch content.type { + case .topic: + switch content.topicType { + case .following: + return Titles.followingTitle + case .likes: + return Titles.likesTitle + default: + return content.topic?.title ?? Titles.emptyTitle + } + case .selfHostedFollowing: + return Titles.followingTitle + case .saved: + return Titles.savedTitle + default: + return Titles.emptyTitle + } + } + + private enum Titles { + static let followingTitle = NSLocalizedString("Following", comment: "Title of the Following Reader tab") + static let likesTitle = NSLocalizedString("Likes", comment: "Title of the Likes Reader tab") + static let savedTitle = NSLocalizedString("Saved", comment: "Title of the Saved Reader Tab") + static let emptyTitle = "" + } +} + + +// MARK: - Reader Content +enum ReaderContentType { + case selfHostedFollowing + case contentError + case saved + case topic +} + + +struct ReaderContent { + + private(set) var topic: ReaderAbstractTopic? + let type: ReaderContentType + let topicType: ReaderTopicType + + init(topic: ReaderAbstractTopic?, contentType: ReaderContentType = .topic) { + self.topicType = ReaderHelpers.topicType(topic) + + if let topic = topic { + self.topic = topic + // if topic is not nil, contentType must be .topic. + self.type = .topic + return + } + // if topic is nil, passing contentType: .topic is invalid -> content will be treated as invalid + self.type = (topic == nil && contentType == .topic) ? .contentError : contentType + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabItemsStore.swift b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabItemsStore.swift new file mode 100644 index 000000000000..27f580a59a48 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabItemsStore.swift @@ -0,0 +1,120 @@ +import WordPressFlux + +protocol ItemsStore: Observable { + var items: [ReaderTabItem] { get } + func getItems() +} + +class ReaderTabItemsStore: ItemsStore { + + let changeDispatcher = Dispatcher() + + let context: NSManagedObjectContext + let service: ReaderTopicService + + init(context: NSManagedObjectContext = ContextManager.sharedInstance().mainContext, + service: ReaderTopicService? = nil) { + self.context = context + self.service = service ?? ReaderTopicService(coreDataStack: ContextManager.shared) + } + + enum State { + case loading + case ready([ReaderTabItem]) + case error(Error) + + var isLoading: Bool { + switch self { + case .loading: + return true + case .error, .ready: + return false + } + } + } + + var state: State = .ready([]) { + didSet { + guard !state.isLoading else { + return + } + emitChange() + } + } + + var items: [ReaderTabItem] { + switch state { + case .loading, .error: + return [] + case .ready(let items): + return items + } + } +} + +// MARK: - Data fetching +extension ReaderTabItemsStore { + + /// Fetch request to extract reader menu topics from Core Data + private var topicsFetchRequest: NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: ReaderTopicsConstants.entityName) + fetchRequest.predicate = NSPredicate(format: ReaderTopicsConstants.predicateFormat, NSNumber(value: ReaderHelpers.isLoggedIn())) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: ReaderTopicsConstants.sortByKey, ascending: true)] + return fetchRequest + } + + /// Fetches items from the Core Data cache, if they exist, and updates the state accordingly + private func fetchTabBarItems() { + do { + let topics = try context.fetch(topicsFetchRequest) + let items = ReaderHelpers.rearrange(items: topics.map { ReaderTabItem(ReaderContent(topic: $0)) }) + self.state = .ready(items) + } catch { + DDLogError(ReaderTopicsConstants.fetchRequestError + error.localizedDescription) + self.state = .error(error) + } + } + + /// Updates the items from the underlying service + func getItems() { + guard !state.isLoading else { + return + } + state = .loading + + // Return the tab bar items right away to avoid waiting for the request to finish + fetchTabBarItems() + + // Sync the reader menu + service.fetchReaderMenu(success: { [weak self] in + self?.fetchTabBarItemsAndFollowedSites() + }, failure: { [weak self] (error) in + self?.fetchTabBarItemsAndFollowedSites() + let actualError = error ?? ReaderTopicsConstants.remoteServiceError + DDLogError("Error syncing menu: \(String(describing: actualError))") + }) + } + + private func fetchTabBarItemsAndFollowedSites() { + DispatchQueue.main.async { + self.fetchFollowedSites() + } + fetchTabBarItems() + } + + private func fetchFollowedSites() { + service.fetchFollowedSites(success: { + }, failure: { (error) in + let actualError = error ?? ReaderTopicsConstants.remoteServiceError + DDLogError("Could not sync sites: \(String(describing: actualError))") + }) + } + + private enum ReaderTopicsConstants { + static let predicateFormat = "type == 'default' OR type == 'organization' OR (type == 'list' AND following == %@ AND showInMenu == YES)" + static let entityName = "ReaderAbstractTopic" + static let sortByKey = "type" + static let fetchRequestError = "There was a problem fetching topics for the menu. " + static let remoteServiceError = NSError(domain: WordPressComRestApiErrorDomain, code: -1, userInfo: nil) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabView.swift b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabView.swift new file mode 100644 index 000000000000..6cc9f6ddc96c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabView.swift @@ -0,0 +1,373 @@ +import UIKit + + +class ReaderTabView: UIView { + + private let mainStackView: UIStackView + private let buttonsStackView: UIStackView + private let tabBar: FilterTabBar + private let filterButton: PostMetaButton + private let resetFilterButton: UIButton + private let horizontalDivider: UIView + private let containerView: UIView + private var loadingView: UIView? + + private let viewModel: ReaderTabViewModel + + private var filteredTabs: [(index: Int, topic: ReaderAbstractTopic)] = [] + private var previouslySelectedIndex: Int = 0 + + private var discoverIndex: Int? { + return tabBar.items.firstIndex(where: { ($0 as? ReaderTabItem)?.content.topicType == .discover }) + } + + private var p2Index: Int? { + return tabBar.items.firstIndex(where: { (($0 as? ReaderTabItem)?.content.topic as? ReaderTeamTopic)?.organizationID == SiteOrganizationType.p2.rawValue }) + } + + init(viewModel: ReaderTabViewModel) { + mainStackView = UIStackView() + buttonsStackView = UIStackView() + tabBar = FilterTabBar() + filterButton = PostMetaButton(type: .custom) + resetFilterButton = UIButton(type: .custom) + horizontalDivider = UIView() + containerView = UIView() + + self.viewModel = viewModel + + super.init(frame: .zero) + + viewModel.didSelectIndex = { [weak self] index in + self?.tabBar.setSelectedIndex(index) + self?.toggleButtonsView() + } + + viewModel.onTabBarItemsDidChange { [weak self] tabItems, index in + self?.tabBar.items = tabItems + self?.tabBar.setSelectedIndex(index) + self?.configureTabBarElements() + self?.hideGhost() + self?.addContentToContainerView() + } + + setupViewElements() + + NotificationCenter.default.addObserver(self, selector: #selector(topicUnfollowed(_:)), name: .ReaderTopicUnfollowed, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(siteFollowed(_:)), name: .ReaderSiteFollowed, object: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +// MARK: - UI setup + +extension ReaderTabView { + + /// Call this method to set the title of the filter button + private func setFilterButtonTitle(_ title: String) { + WPStyleGuide.applyReaderFilterButtonTitle(filterButton, title: title) + } + + private func setupViewElements() { + backgroundColor = .filterBarBackground + setupMainStackView() + setupTabBar() + setupButtonsView() + setupFilterButton() + setupResetFilterButton() + setupHorizontalDivider(horizontalDivider) + activateConstraints() + } + + private func setupMainStackView() { + mainStackView.translatesAutoresizingMaskIntoConstraints = false + mainStackView.axis = .vertical + addSubview(mainStackView) + mainStackView.addArrangedSubview(tabBar) + mainStackView.addArrangedSubview(buttonsStackView) + mainStackView.addArrangedSubview(horizontalDivider) + mainStackView.addArrangedSubview(containerView) + } + + private func setupTabBar() { + tabBar.tabBarHeight = Appearance.barHeight + WPStyleGuide.configureFilterTabBar(tabBar) + tabBar.addTarget(self, action: #selector(selectedTabDidChange(_:)), for: .valueChanged) + viewModel.fetchReaderMenu() + } + + private func configureTabBarElements() { + guard let tabItem = tabBar.currentlySelectedItem as? ReaderTabItem else { + return + } + + previouslySelectedIndex = tabBar.selectedIndex + buttonsStackView.isHidden = tabItem.shouldHideButtonsView + horizontalDivider.isHidden = tabItem.shouldHideButtonsView + } + + private func setupButtonsView() { + buttonsStackView.translatesAutoresizingMaskIntoConstraints = false + buttonsStackView.isLayoutMarginsRelativeArrangement = true + buttonsStackView.axis = .horizontal + buttonsStackView.alignment = .fill + buttonsStackView.addArrangedSubview(filterButton) + buttonsStackView.addArrangedSubview(resetFilterButton) + buttonsStackView.isHidden = true + } + + private func setupFilterButton() { + filterButton.translatesAutoresizingMaskIntoConstraints = false + filterButton.contentEdgeInsets = Appearance.filterButtonInsets + filterButton.imageEdgeInsets = Appearance.filterButtonimageInsets + filterButton.titleEdgeInsets = Appearance.filterButtonTitleInsets + filterButton.contentHorizontalAlignment = .leading + + filterButton.titleLabel?.font = Appearance.filterButtonFont + WPStyleGuide.applyReaderFilterButtonStyle(filterButton) + setFilterButtonTitle(Appearance.defaultFilterButtonTitle) + filterButton.addTarget(self, action: #selector(didTapFilterButton), for: .touchUpInside) + filterButton.accessibilityIdentifier = Accessibility.filterButtonIdentifier + } + + private func setupResetFilterButton() { + resetFilterButton.translatesAutoresizingMaskIntoConstraints = false + resetFilterButton.contentEdgeInsets = Appearance.resetButtonInsets + WPStyleGuide.applyReaderResetFilterButtonStyle(resetFilterButton) + resetFilterButton.addTarget(self, action: #selector(didTapResetFilterButton), for: .touchUpInside) + resetFilterButton.isHidden = true + resetFilterButton.accessibilityIdentifier = Accessibility.resetButtonIdentifier + resetFilterButton.accessibilityLabel = Accessibility.resetFilterButtonLabel + } + + private func setupHorizontalDivider(_ divider: UIView) { + divider.translatesAutoresizingMaskIntoConstraints = false + divider.backgroundColor = Appearance.dividerColor + } + + private func addContentToContainerView() { + guard let controller = self.next as? UIViewController, + let childController = viewModel.makeChildContentViewController(at: tabBar.selectedIndex) else { + return + } + + containerView.translatesAutoresizingMaskIntoConstraints = false + childController.view.translatesAutoresizingMaskIntoConstraints = false + + controller.children.forEach { + $0.remove() + } + controller.add(childController) + containerView.pinSubviewToAllEdges(childController.view) + + if viewModel.shouldShowCommentSpotlight { + let title = NSLocalizedString("Comment to start making connections.", comment: "Hint for users to grow their audience by commenting on other blogs.") + childController.displayNotice(title: title) + } + } + + private func activateConstraints() { + pinSubviewToAllEdges(mainStackView) + NSLayoutConstraint.activate([ + buttonsStackView.heightAnchor.constraint(equalToConstant: Appearance.barHeight), + resetFilterButton.widthAnchor.constraint(equalToConstant: Appearance.resetButtonWidth), + horizontalDivider.heightAnchor.constraint(equalToConstant: Appearance.dividerWidth), + horizontalDivider.widthAnchor.constraint(equalTo: mainStackView.widthAnchor) + ]) + } + + func applyFilter(for selectedTopic: ReaderAbstractTopic?) { + guard let selectedTopic = selectedTopic else { + return + } + + let selectedIndex = self.tabBar.selectedIndex + + // Remove any filters for selected index, then add new filter to array. + self.filteredTabs.removeAll(where: { $0.index == selectedIndex }) + self.filteredTabs.append((index: selectedIndex, topic: selectedTopic)) + + self.resetFilterButton.isHidden = false + self.setFilterButtonTitle(selectedTopic.title) + } +} + +// MARK: - Actions + +private extension ReaderTabView { + + /// Tab bar + @objc func selectedTabDidChange(_ tabBar: FilterTabBar) { + + // If the tab was previously filtered, refilter it. + // Otherwise reset the filter. + if let existingFilter = filteredTabs.first(where: { $0.index == tabBar.selectedIndex }) { + + if previouslySelectedIndex == discoverIndex { + // Reset the container view to show a feed's content. + addContentToContainerView() + } + + viewModel.setFilterContent(topic: existingFilter.topic) + + resetFilterButton.isHidden = false + setFilterButtonTitle(existingFilter.topic.title) + } else { + addContentToContainerView() + } + + previouslySelectedIndex = tabBar.selectedIndex + + viewModel.showTab(at: tabBar.selectedIndex) + toggleButtonsView() + } + + func toggleButtonsView() { + guard let tabItems = tabBar.items as? [ReaderTabItem] else { + return + } + // hide/show buttons depending on the selected tab. Do not execute the animation if not necessary. + guard buttonsStackView.isHidden != tabItems[tabBar.selectedIndex].shouldHideButtonsView else { + return + } + let shouldHideButtons = tabItems[self.tabBar.selectedIndex].shouldHideButtonsView + self.buttonsStackView.isHidden = shouldHideButtons + self.horizontalDivider.isHidden = shouldHideButtons + } + + /// Filter button + @objc func didTapFilterButton() { + /// Present from the image view to align to the left hand side + viewModel.presentFilter(from: filterButton.imageView ?? filterButton) { [weak self] selectedTopic in + guard let self = self else { + return + } + self.applyFilter(for: selectedTopic) + } + } + + /// Reset filter button + @objc func didTapResetFilterButton() { + filteredTabs.removeAll(where: { $0.index == tabBar.selectedIndex }) + setFilterButtonTitle(Appearance.defaultFilterButtonTitle) + resetFilterButton.isHidden = true + guard let tabItem = tabBar.currentlySelectedItem as? ReaderTabItem else { + return + } + viewModel.resetFilter(selectedItem: tabItem) + } + + @objc func topicUnfollowed(_ notification: Foundation.Notification) { + guard let userInfo = notification.userInfo, + let topic = userInfo[ReaderNotificationKeys.topic] as? ReaderAbstractTopic, + let existingFilter = filteredTabs.first(where: { $0.topic == topic }) else { + return + } + + filteredTabs.removeAll(where: { $0 == existingFilter }) + + if existingFilter.index == tabBar.selectedIndex { + didTapResetFilterButton() + } + + } + + @objc func siteFollowed(_ notification: Foundation.Notification) { + guard let userInfo = notification.userInfo, + let site = userInfo[ReaderNotificationKeys.topic] as? ReaderSiteTopic, + site.organizationType == .p2, + p2Index == nil else { + return + } + + // If a P2 is followed but the P2 tab is not in the Reader tab bar, + // refresh the Reader menu to display it. + viewModel.fetchReaderMenu() + } + +} + + +// MARK: - Ghost + +private extension ReaderTabView { + + /// Build the ghost tab bar + func makeGhostTabBar() -> FilterTabBar { + let ghostTabBar = FilterTabBar() + + ghostTabBar.items = Appearance.ghostTabItems + ghostTabBar.isUserInteractionEnabled = false + ghostTabBar.tabBarHeight = Appearance.barHeight + ghostTabBar.dividerColor = .clear + + return ghostTabBar + } + + /// Show the ghost tab bar + func showGhost() { + let ghostTabBar = makeGhostTabBar() + tabBar.addSubview(ghostTabBar) + tabBar.pinSubviewToAllEdges(ghostTabBar) + + loadingView = ghostTabBar + + ghostTabBar.startGhostAnimation(style: GhostStyle(beatDuration: GhostStyle.Defaults.beatDuration, + beatStartColor: .placeholderElement, + beatEndColor: .placeholderElementFaded)) + + } + + /// Hide the ghost tab bar + func hideGhost() { + loadingView?.stopGhostAnimation() + loadingView?.removeFromSuperview() + loadingView = nil + } + + struct GhostTabItem: FilterTabBarItem { + var title: String + let accessibilityIdentifier = "" + } +} + +// MARK: - Appearance + +private extension ReaderTabView { + + enum Appearance { + static let barHeight: CGFloat = 48 + + static let tabBarAnimationsDuration = 0.2 + + static let defaultFilterButtonTitle = NSLocalizedString("Filter", comment: "Title of the filter button in the Reader") + static let filterButtonMaxFontSize: CGFloat = 28.0 + static let filterButtonFont = WPStyleGuide.fontForTextStyle(.headline, fontWeight: .regular) + static let filterButtonInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0) + static let filterButtonimageInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0) + static let filterButtonTitleInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 0) + + static let resetButtonWidth: CGFloat = 32 + static let resetButtonInsets = UIEdgeInsets(top: 1, left: -4, bottom: -1, right: 4) + + static let dividerWidth: CGFloat = .hairlineBorderWidth + static let dividerColor: UIColor = .divider + // "ghost" titles are set to the default english titles, as they won't be visible anyway + static let ghostTabItems = [GhostTabItem(title: "Following"), GhostTabItem(title: "Discover"), GhostTabItem(title: "Likes"), GhostTabItem(title: "Saved")] + } +} + + +// MARK: - Accessibility + +extension ReaderTabView { + private enum Accessibility { + static let filterButtonIdentifier = "ReaderFilterButton" + static let resetButtonIdentifier = "ReaderResetButton" + static let resetFilterButtonLabel = NSLocalizedString("Reset filter", comment: "Accessibility label for the reset filter button in the reader.") + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabViewController.swift b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabViewController.swift new file mode 100644 index 000000000000..f5175b3ff6b3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabViewController.swift @@ -0,0 +1,213 @@ +import UIKit +import Gridicons + +class ReaderTabViewController: UIViewController { + + private let viewModel: ReaderTabViewModel + + private let makeReaderTabView: (ReaderTabViewModel) -> ReaderTabView + + private lazy var readerTabView: ReaderTabView = { + return makeReaderTabView(viewModel) + }() + + private let settingsButton: SpotlightableButton = SpotlightableButton(type: .custom) + + init(viewModel: ReaderTabViewModel, readerTabViewFactory: @escaping (ReaderTabViewModel) -> ReaderTabView) { + self.viewModel = viewModel + self.makeReaderTabView = readerTabViewFactory + super.init(nibName: nil, bundle: nil) + + title = ReaderTabConstants.title + setupNavigationButtons() + + ReaderTabViewController.configureRestoration(on: self) + + ReaderCardService().clean() + + viewModel.filterTapped = { [weak self] (fromView, completion) in + guard let self = self else { + return + } + + self.viewModel.presentFilter(from: self, sourceView: fromView, completion: { [weak self] topic in + self?.dismiss(animated: true, completion: nil) + completion(topic) + }) + } + + NotificationCenter.default.addObserver(self, selector: #selector(defaultAccountDidChange(_:)), name: NSNotification.Name.WPAccountDefaultWordPressComAccountChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + startObservingQuickStart() + + viewModel.fetchReaderMenu() + } + + required init?(coder: NSCoder) { + fatalError(ReaderTabConstants.storyBoardInitError) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + ReaderTracker.shared.start(.main) + + if AppConfiguration.showsWhatIsNew { + RootViewCoordinator.shared.presentWhatIsNew(on: self) + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if QuickStartTourGuide.shared.isCurrentElement(.readerDiscoverSettings) { + + if viewModel.selectedIndex != ReaderTabConstants.discoverIndex { + viewModel.showTab(at: ReaderTabConstants.discoverIndex) + } + + settingsButton.shouldShowSpotlight = true + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + ReaderTracker.shared.stop(.main) + + QuickStartTourGuide.shared.endCurrentTour() + } + + func setupNavigationButtons() { + // Search Button + let searchButton = UIBarButtonItem(image: UIImage.gridicon(.search), + style: .plain, + target: self, + action: #selector(didTapSearchButton)) + searchButton.accessibilityIdentifier = ReaderTabConstants.searchButtonAccessibilityIdentifier + + // Settings Button + settingsButton.spotlightOffset = ReaderTabConstants.spotlightOffset + settingsButton.contentEdgeInsets = ReaderTabConstants.settingsButtonContentEdgeInsets + settingsButton.setImage(.gridicon(.readerFollowing), for: .normal) + settingsButton.addTarget(self, action: #selector(didTapSettingsButton), for: .touchUpInside) + settingsButton.accessibilityIdentifier = ReaderTabConstants.settingsButtonIdentifier + let settingsButton = UIBarButtonItem(customView: settingsButton) + + navigationItem.rightBarButtonItems = [searchButton, settingsButton] + } + + override func loadView() { + view = readerTabView + + navigationItem.largeTitleDisplayMode = .never + navigationController?.navigationBar.prefersLargeTitles = false + } + + @objc func willEnterForeground() { + guard isViewOnScreen() else { + return + } + + ReaderTracker.shared.start(.main) + } + + func presentDiscoverTab() { + viewModel.shouldShowCommentSpotlight = true + viewModel.fetchReaderMenu() + viewModel.showTab(at: ReaderTabConstants.discoverIndex) + } +} + + +// MARK: - Navigation Buttons +extension ReaderTabViewController { + @objc private func didTapSettingsButton() { + viewModel.presentManage(from: self) + } + + @objc private func didTapSearchButton() { + viewModel.navigateToSearch() + } +} + +// MARK: Observing Quick Start +extension ReaderTabViewController { + private func startObservingQuickStart() { + NotificationCenter.default.addObserver(forName: .QuickStartTourElementChangedNotification, object: nil, queue: nil) { [weak self] notification in + if let info = notification.userInfo, + let element = info[QuickStartTourGuide.notificationElementKey] as? QuickStartTourElement { + self?.settingsButton.shouldShowSpotlight = element == .readerDiscoverSettings + } + } + } +} + + +// MARK: - State Restoration +extension ReaderTabViewController: UIViewControllerRestoration { + + static func configureRestoration(on instance: ReaderTabViewController) { + instance.restorationIdentifier = ReaderTabConstants.restorationIdentifier + instance.restorationClass = ReaderTabViewController.self + } + + static let encodedIndexKey = ReaderTabConstants.encodedIndexKey + + static func viewController(withRestorationIdentifierPath identifierComponents: [String], + coder: NSCoder) -> UIViewController? { + + let index = Int(coder.decodeInt32(forKey: ReaderTabViewController.encodedIndexKey)) + + let controller = RootViewCoordinator.sharedPresenter.readerTabViewController + controller?.setStartIndex(index) + + return controller + } + + override func encodeRestorableState(with coder: NSCoder) { + coder.encode(viewModel.selectedIndex, forKey: ReaderTabViewController.encodedIndexKey) + } + + func setStartIndex(_ index: Int) { + viewModel.selectedIndex = index + } +} + +// MARK: - Notifications +extension ReaderTabViewController { + // Ensure that topics and sites are synced when account changes + @objc private func defaultAccountDidChange(_ notification: Foundation.Notification) { + loadView() + } +} + +// MARK: - Constants +extension ReaderTabViewController { + private enum ReaderTabConstants { + static let title = NSLocalizedString("Reader", comment: "The default title of the Reader") + static let settingsButtonIdentifier = "ReaderSettingsButton" + static let searchButtonAccessibilityIdentifier = "ReaderSearchBarButton" + static let storyBoardInitError = "Storyboard instantiation not supported" + static let restorationIdentifier = "WPReaderTabControllerRestorationID" + static let encodedIndexKey = "WPReaderTabControllerIndexRestorationKey" + static let discoverIndex = 1 + static let spotlightOffset = UIOffset(horizontal: 20, vertical: -10) + static let settingsButtonContentEdgeInsets = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0) + } +} + +// MARK: - WPScrollableViewController conformance +extension ReaderTabViewController: WPScrollableViewController { + /// Scrolls the first child VC to the top if it's a `ReaderStreamViewController`. + func scrollViewToTop() { + guard let readerStreamVC = children.first as? ReaderStreamViewController else { + return + } + readerStreamVC.scrollViewToTop() + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabViewModel.swift b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabViewModel.swift new file mode 100644 index 000000000000..eb9e0ef4c1dd --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabViewModel.swift @@ -0,0 +1,254 @@ +import WordPressFlux + + +@objc class ReaderTabViewModel: NSObject { + + // MARK: - Properties + /// tab bar items + private let tabItemsStore: ItemsStore + private var subscription: Receipt? + private var onTabBarItemsDidChange: [(([ReaderTabItem], Int) -> Void)] = [] + + private var tabItems: [ReaderTabItem] { + tabItemsStore.items + } + /// completion handler for an external call that changes the tab index + var didSelectIndex: ((Int) -> Void)? + var selectedIndex = 0 + + /// completion handler for a tap on a tab on the toolbar + var setContent: ((ReaderContent) -> Void)? + + /// Creates an instance of ReaderContentViewController that gets installed in the ContentView + var makeReaderContentViewController: (ReaderContent) -> ReaderContentViewController + + /// Completion handler for selecting a filter from the available filter list + var filterTapped: ((UIView, @escaping (ReaderAbstractTopic?) -> Void) -> Void)? + + /// search + var navigateToSearch: () -> Void + + /// if items are loaded + var itemsLoaded: Bool { + return tabItems.count > 0 + } + + /// Spotlight + var shouldShowCommentSpotlight: Bool = false + + /// Settings + private let settingsPresenter: ScenePresenter + + init(readerContentFactory: @escaping (ReaderContent) -> ReaderContentViewController, + searchNavigationFactory: @escaping () -> Void, + tabItemsStore: ItemsStore, + settingsPresenter: ScenePresenter) { + self.makeReaderContentViewController = readerContentFactory + self.navigateToSearch = searchNavigationFactory + self.tabItemsStore = tabItemsStore + self.settingsPresenter = settingsPresenter + super.init() + + subscription = tabItemsStore.onChange { [weak self] in + guard let viewModel = self else { + return + } + viewModel.onTabBarItemsDidChange.forEach { $0(viewModel.tabItems, viewModel.selectedIndex) } + } + addNotificationsObservers() + observeNetworkStatus() + } +} + + +// MARK: - Tab bar items +extension ReaderTabViewModel { + + func onTabBarItemsDidChange(completion: @escaping ([ReaderTabItem], Int) -> Void) { + onTabBarItemsDidChange.append(completion) + } + + func fetchReaderMenu() { + tabItemsStore.getItems() + } +} + + +// MARK: - Tab selection +extension ReaderTabViewModel { + + func showTab(at index: Int) { + guard index < tabItems.count else { + return + } + selectedIndex = index + + if tabItems[index].content.type == .saved { + setContent?(tabItems[index].content) + } + } + + /// switch to the tab whose topic matches the given predicate + func switchToTab(where predicate: (ReaderAbstractTopic) -> Bool) { + guard let index = tabItems.firstIndex(where: { item in + guard let topic = item.content.topic else { + return false + } + return predicate(topic) + }) else { + return + } + showTab(at: index) + didSelectIndex?(index) + } + + /// switch to the tab whose title matches the given predicate + func switchToTab(where predicate: (String) -> Bool) { + guard let index = tabItems.firstIndex(where: { + predicate($0.title) + }) else { + return + } + showTab(at: index) + didSelectIndex?(index) + } +} + + +// MARK: - Filter +extension ReaderTabViewModel { + + func presentFilter(from: UIViewController, sourceView: UIView, completion: @escaping (ReaderAbstractTopic?) -> Void) { + let viewController = makeFilterSheetViewController(completion: completion) + let bottomSheet = BottomSheetViewController(childViewController: viewController) + bottomSheet.additionalSafeAreaInsetsRegular = UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0) + bottomSheet.show(from: from, sourceView: sourceView, arrowDirections: .up) + + WPAnalytics.track(.readerFilterSheetDisplayed) + } + + func presentManage(from: UIViewController) { + settingsPresenter.present(on: from, animated: true, completion: nil) + } + + func presentFilter(from: UIView, completion: @escaping (ReaderAbstractTopic?) -> Void) { + filterTapped?(from, { [weak self] topic in + if let topic = topic { + self?.setFilterContent(topic: topic) + } + completion(topic) + }) + } + + func resetFilter(selectedItem: FilterTabBarItem) { + WPAnalytics.track(.readerFilterSheetCleared) + if let content = (selectedItem as? ReaderTabItem)?.content { + setContent?(content) + } + } + + func setFilterContent(topic: ReaderAbstractTopic) { + let type = ((topic as? ReaderSiteTopic) != nil) ? "site" : "topic" + WPAnalytics.track(.readerFilterSheetItemSelected, properties: ["type": type]) + + setContent?(ReaderContent(topic: topic)) + } + +} + +// MARK: - Bottom Sheet +extension ReaderTabViewModel { + private func makeFilterSheetViewController(completion: @escaping (ReaderAbstractTopic) -> Void) -> FilterSheetViewController { + let selectedTab = tabItems[selectedIndex] + + let siteType: SiteOrganizationType = { + if let teamTopic = selectedTab.content.topic as? ReaderTeamTopic { + return teamTopic.organizationType + } + return .none + }() + + var filters = [ReaderSiteTopic.filterProvider(for: siteType)] + + if !selectedTab.shouldHideTagFilter { + filters.append(ReaderTagTopic.filterProvider()) + } + + return FilterSheetViewController(viewTitle: selectedTab.title, + filters: filters, + changedFilter: completion) + } +} + + +// MARK: - Reader Content +extension ReaderTabViewModel { + + func makeChildContentViewController(at index: Int) -> ReaderContentViewController? { + guard let tabItem = tabItems[safe: index] else { + return tabItems.isEmpty ? makeReaderContentViewController(ReaderContent(topic: nil, contentType: .contentError)) : nil + } + let controller = makeReaderContentViewController(tabItem.content) + + setContent = { [weak controller] configuration in + controller?.setContent(configuration) + } + return controller + } +} + + +extension ReaderTabViewModel: NetworkStatusReceiver, NetworkStatusDelegate { + func networkStatusDidChange(active: Bool) { + guard active, tabItems.isEmpty else { + return + } + fetchReaderMenu() + } +} + +// MARK: - Cleanup tasks +extension ReaderTabViewModel { + + private func addNotificationsObservers() { + NotificationCenter.default.addObserver(forName: UIApplication.willTerminateNotification, + object: nil, + queue: nil) { notification in + self.clearTopics(removeAllTopics: false) + self.clearFlags() + } + + NotificationCenter.default.addObserver(forName: .WPAccountDefaultWordPressComAccountChanged, + object: nil, + queue: nil) { notification in + self.clearFlags() + self.clearSavedPosts() + self.clearTopics(removeAllTopics: true) + self.clearSearchSuggestions() + self.selectedIndex = 0 + } + } + + private func clearFlags() { + ReaderPostService(coreDataStack: ContextManager.shared).clearInUseFlags() + ReaderTopicService(coreDataStack: ContextManager.shared).clearInUseFlags() + } + + private func clearTopics(removeAllTopics removeAll: Bool) { + ReaderPostService(coreDataStack: ContextManager.shared).deletePostsWithNoTopic() + + if removeAll { + ReaderTopicService(coreDataStack: ContextManager.shared).deleteAllTopics() + } else { + ReaderTopicService(coreDataStack: ContextManager.shared).deleteNonMenuTopics() + } + } + + private func clearSavedPosts() { + ReaderPostService(coreDataStack: ContextManager.shared).clearSavedPostFlags() + } + + private func clearSearchSuggestions() { + ReaderSearchSuggestionService(coreDataStack: ContextManager.sharedInstance()).deleteAllSuggestions() + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/WPTabBarController+ReaderTabNavigation.swift b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/WPTabBarController+ReaderTabNavigation.swift new file mode 100644 index 000000000000..f502fde27a23 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/WPTabBarController+ReaderTabNavigation.swift @@ -0,0 +1,136 @@ +/// Generic type for the UIViewController in the Reader Content View +protocol ReaderContentViewController: UIViewController { + func setContent(_ content: ReaderContent) +} + +// MARK: - DefinesVariableStatusBarStyle Support +extension WPTabBarController { + override open var preferredStatusBarStyle: UIStatusBarStyle { + .default + } + + override open var childForStatusBarStyle: UIViewController? { + guard + let topViewController = readerNavigationController?.topViewController, + ((topViewController as? DefinesVariableStatusBarStyle) != nil) + else { + return nil + } + return topViewController + } +} + +// MARK: - Reader Factory +extension WPTabBarController { + var readerTabViewController: ReaderTabViewController? { + readerNavigationController?.topViewController as? ReaderTabViewController + } + + @objc func makeReaderTabViewController() -> ReaderTabViewController { + return ReaderTabViewController(viewModel: self.readerTabViewModel, readerTabViewFactory: makeReaderTabView(_:)) + } + + @objc func makeReaderTabViewModel() -> ReaderTabViewModel { + let viewModel = ReaderTabViewModel(readerContentFactory: makeReaderContentViewController(with:), + searchNavigationFactory: navigateToReaderSearch, + tabItemsStore: ReaderTabItemsStore(), + settingsPresenter: ReaderManageScenePresenter()) + return viewModel + } + + private func makeReaderContentViewController(with content: ReaderContent) -> ReaderContentViewController { + + if content.topicType == .discover, let topic = content.topic { + let controller = ReaderCardsStreamViewController.controller(topic: topic) + controller.shouldShowCommentSpotlight = readerTabViewModel.shouldShowCommentSpotlight + return controller + } else if let topic = content.topic { + return ReaderStreamViewController.controllerWithTopic(topic) + } else { + return ReaderStreamViewController.controllerForContentType(content.type) + } + } + + private func makeReaderTabView(_ viewModel: ReaderTabViewModel) -> ReaderTabView { + return ReaderTabView(viewModel: self.readerTabViewModel) + } +} + + +// MARK: - Reader Navigation +extension WPTabBarController { + + /// reader navigation methods + func navigateToReaderSearch() { + let searchController = ReaderSearchViewController.controller() + navigateToReader(searchController) + } + + func navigateToReaderSearch(withSearchText searchText: String) { + let searchController = ReaderSearchViewController.controller(withSearchText: searchText) + navigateToReader(searchController) + } + + func navigateToReaderSite(_ topic: ReaderSiteTopic) { + let contentController = ReaderStreamViewController.controllerWithTopic(topic) + navigateToReader(contentController) + } + + func navigateToReaderTag( _ topic: ReaderTagTopic) { + let contentController = ReaderStreamViewController.controllerWithTopic(topic) + navigateToReader(contentController) + } + + func navigateToReader(_ pushControlller: UIViewController? = nil) { + showReaderTab() + readerNavigationController?.popToRootViewController(animated: false) + guard let controller = pushControlller else { + return + } + readerNavigationController?.pushViewController(controller, animated: true) + } + + func resetReaderDiscoverNudgeFlow() { + readerTabViewModel.shouldShowCommentSpotlight = false + } + + /// methods to select one of the default Reader tabs + @objc func switchToSavedPosts() { + let title = NSLocalizedString("Saved", comment: "Title of the Saved Reader Tab") + switchToTitle(title) + } + + func switchToFollowedSites() { + navigateToReader() + readerTabViewModel.switchToTab(where: { + ReaderHelpers.topicIsFollowing($0) + }) + } + + func switchToDiscover() { + navigateToReader() + readerTabViewModel.switchToTab(where: { + ReaderHelpers.topicIsDiscover($0) + }) + } + + func switchToMyLikes() { + navigateToReader() + readerTabViewModel.switchToTab(where: { + ReaderHelpers.topicIsLiked($0) + }) + } + + /// switches to a menu item topic that satisfies the given predicate with a topic value + func switchToTopic(where predicate: (ReaderAbstractTopic) -> Bool) { + navigateToReader() + readerTabViewModel.switchToTab(where: predicate) + } + /// switches to a menu item topic whose title matched the passed value + func switchToTitle(_ title: String) { + navigateToReader() + readerTabViewModel.switchToTab(where: { + $0 == title + }) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Tags View/DynamicHeightCollectionView.swift b/WordPress/Classes/ViewRelated/Reader/Tags View/DynamicHeightCollectionView.swift new file mode 100644 index 000000000000..aafe154c3734 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Tags View/DynamicHeightCollectionView.swift @@ -0,0 +1,17 @@ +import Foundation + +class DynamicHeightCollectionView: UICollectionView { + override func layoutSubviews() { + super.layoutSubviews() + + if bounds.size != intrinsicContentSize { + invalidateIntrinsicContentSize() + } + } + + override var intrinsicContentSize: CGSize { + var size = contentSize + size.width = superview?.bounds.size.width ?? 0 + return size + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Tags View/ReaderTopicCollectionViewCoordinator.swift b/WordPress/Classes/ViewRelated/Reader/Tags View/ReaderTopicCollectionViewCoordinator.swift new file mode 100644 index 000000000000..5cc04a108593 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Tags View/ReaderTopicCollectionViewCoordinator.swift @@ -0,0 +1,230 @@ +import UIKit + +enum ReaderTopicCollectionViewState { + case collapsed + case expanded +} + +protocol ReaderTopicCollectionViewCoordinatorDelegate: AnyObject { + func coordinator(_ coordinator: ReaderTopicCollectionViewCoordinator, didSelectTopic topic: String) + func coordinator(_ coordinator: ReaderTopicCollectionViewCoordinator, didChangeState: ReaderTopicCollectionViewState) +} + + +/// The topics coordinator manages the layout and configuration of a topics chip group collection view. +/// When created it will link to a collectionView and perform all the necessary configuration to +/// display the group with expanding/collapsing support. +/// +class ReaderTopicCollectionViewCoordinator: NSObject { + private struct Constants { + static let reuseIdentifier = ReaderInterestsCollectionViewCell.classNameWithoutNamespaces() + static let overflowReuseIdentifier = "OverflowItem" + + static let interestsLabelMargin: CGFloat = 8 + + static let cellCornerRadius: CGFloat = 4 + static let cellSpacing: CGFloat = 6 + static let cellHeight: CGFloat = 26 + static let maxCellWidthMultiplier: CGFloat = 0.8 + } + + private struct Strings { + static let collapseButtonTitle: String = NSLocalizedString("Hide", comment: "Title of a button used to collapse a group") + + static let expandButtonAccessbilityHint: String = NSLocalizedString("Tap to see all the tags for this post", comment: "VoiceOver Hint to inform the user what action the expand button performs") + + static let collapseButtonAccessbilityHint: String = NSLocalizedString("Tap to collapse the post tags", comment: "Accessibility hint to inform the user what action the hide button performs") + + static let accessbilityHint: String = NSLocalizedString("Tap to view posts for this tag", comment: "Accessibility hint to inform the user what action the post tag chip performs") + } + + weak var delegate: ReaderTopicCollectionViewCoordinatorDelegate? + + let collectionView: UICollectionView + var topics: [String] { + didSet { + reloadData() + } + } + + deinit { + guard let layout = collectionView.collectionViewLayout as? ReaderInterestsCollectionViewFlowLayout else { + return + } + + layout.isExpanded = false + layout.invalidateLayout() + } + + init(collectionView: UICollectionView, topics: [String]) { + self.collectionView = collectionView + self.topics = topics + + super.init() + + configureCollectionView() + } + + func reloadData() { + collectionView.reloadData() + collectionView.invalidateIntrinsicContentSize() + } + + func changeState(_ state: ReaderTopicCollectionViewState) { + guard let layout = collectionView.collectionViewLayout as? ReaderInterestsCollectionViewFlowLayout else { + return + } + + layout.isExpanded = state == .expanded + } + + private func configureCollectionView() { + collectionView.isAccessibilityElement = false + collectionView.delegate = self + collectionView.dataSource = self + + collectionView.contentInset = .zero + + let nib = UINib(nibName: String(describing: ReaderInterestsCollectionViewCell.self), bundle: nil) + + // Register the main cell + collectionView.register(nib, forCellWithReuseIdentifier: Constants.reuseIdentifier) + + // Register the overflow item type + collectionView.register(nib, forSupplementaryViewOfKind: ReaderInterestsCollectionViewFlowLayout.overflowItemKind, withReuseIdentifier: Constants.overflowReuseIdentifier) + + // Configure Layout + guard let layout = collectionView.collectionViewLayout as? ReaderInterestsCollectionViewFlowLayout else { + return + } + + layout.delegate = self + layout.maxNumberOfDisplayedLines = 1 + layout.itemSpacing = Constants.cellSpacing + layout.cellHeight = Constants.cellHeight + layout.allowsCentering = false + } + + private func sizeForCell(title: String) -> CGSize { + let attributes: [NSAttributedString.Key: Any] = [ + .font: ReaderInterestsStyleGuide.compactCellLabelTitleFont + ] + + + let title: NSString = title as NSString + + var size = title.size(withAttributes: attributes) + + // Prevent 1 token from being too long + let maxWidth = collectionView.bounds.width * Constants.maxCellWidthMultiplier + let width = min(size.width, maxWidth) + size.width = width + (Constants.interestsLabelMargin * 2) + + return size + } + + private func configure(cell: ReaderInterestsCollectionViewCell, with title: String) { + ReaderInterestsStyleGuide.applyCompactCellLabelStyle(label: cell.label) + + cell.layer.cornerRadius = Constants.cellCornerRadius + cell.label.text = title + cell.label.accessibilityHint = Strings.accessbilityHint + cell.label.accessibilityTraits = .button + } + + private func string(for remainingItems: Int?) -> String { + guard let items = remainingItems else { + return Strings.collapseButtonTitle + } + + return "\(items)+" + } +} + +extension ReaderTopicCollectionViewCoordinator: UICollectionViewDataSource { + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return topics.count + } +} + +extension ReaderTopicCollectionViewCoordinator: UICollectionViewDelegateFlowLayout { + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Constants.reuseIdentifier, + for: indexPath) as? ReaderInterestsCollectionViewCell else { + fatalError("Expected a ReaderInterestsCollectionViewCell for identifier: \(Constants.reuseIdentifier)") + } + + configure(cell: cell, with: topics[indexPath.row]) + + return cell + } + + func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + let overflowKind = ReaderInterestsCollectionViewFlowLayout.overflowItemKind + + guard + kind == overflowKind, + let cell = collectionView.dequeueReusableSupplementaryView(ofKind: overflowKind, withReuseIdentifier: Constants.overflowReuseIdentifier, for: indexPath) as? ReaderInterestsCollectionViewCell, + let layout = collectionView.collectionViewLayout as? ReaderInterestsCollectionViewFlowLayout + else { + fatalError("Expected a ReaderInterestsCollectionViewCell for identifier: \(Constants.overflowReuseIdentifier) with kind: \(overflowKind)") + } + + let remainingItems = layout.remainingItems + let title = string(for: remainingItems) + + configure(cell: cell, with: title) + + if layout.isExpanded { + cell.label.backgroundColor = .clear + } + + cell.label.accessibilityHint = layout.isExpanded ? Strings.collapseButtonAccessbilityHint : Strings.expandButtonAccessbilityHint + + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(toggleExpanded)) + cell.addGestureRecognizer(tapGestureRecognizer) + + return cell + } + + @objc func toggleExpanded(_ sender: ReaderInterestsCollectionViewCell) { + guard let layout = collectionView.collectionViewLayout as? ReaderInterestsCollectionViewFlowLayout else { + return + } + + layout.isExpanded = !layout.isExpanded + layout.invalidateLayout() + + WPAnalytics.trackReader(.readerChipsMoreToggled) + + delegate?.coordinator(self, didChangeState: layout.isExpanded ? .expanded: .collapsed) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return sizeForCell(title: topics[indexPath.row]) + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + // We create a remote service because we need to convert the topic to a slug an this contains the + // code to do that + let service = ReaderTopicServiceRemote(wordPressComRestApi: WordPressComRestApi.defaultApi()) + guard let topic = service.slug(forTopicName: topics[indexPath.row]) else { + return + } + + delegate?.coordinator(self, didSelectTopic: topic) + } +} + +extension ReaderTopicCollectionViewCoordinator: ReaderInterestsCollectionViewFlowLayoutDelegate { + func collectionView(_ collectionView: UICollectionView, layout: ReaderInterestsCollectionViewFlowLayout, sizeForOverflowItem at: IndexPath, remainingItems: Int?) -> CGSize { + + let title = string(for: remainingItems) + return sizeForCell(title: title) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Tags View/TopicsCollectionView.swift b/WordPress/Classes/ViewRelated/Reader/Tags View/TopicsCollectionView.swift new file mode 100644 index 000000000000..90d0e1484556 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Tags View/TopicsCollectionView.swift @@ -0,0 +1,58 @@ +import Foundation + +/// A drop in collection view that will configure the collection view to display the topics chip group: +/// - Overrides the layout to be `ReaderInterestsCollectionViewFlowLayout` +/// - Creates the ReaderTopicCollectionViewCoordinator +/// - Uses the dynamic height collection view class to automatically change its size to the content +/// +/// When implementing you can also use the `topicDelegate` to know when the group is expanded/collapsed, or if a topic chip was selected +class TopicsCollectionView: DynamicHeightCollectionView { + var coordinator: ReaderTopicCollectionViewCoordinator? + + weak var topicDelegate: ReaderTopicCollectionViewCoordinatorDelegate? + + var topics: [String] = [] { + didSet { + coordinator?.topics = topics + } + } + + override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { + super.init(frame: frame, collectionViewLayout: layout) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + func commonInit() { + collectionViewLayout = ReaderInterestsCollectionViewFlowLayout() + + coordinator = ReaderTopicCollectionViewCoordinator(collectionView: self, topics: topics) + coordinator?.delegate = self + } + + func collapse() { + coordinator?.changeState(.collapsed) + } + + override func accessibilityElementCount() -> Int { + guard let dataSource else { + return 0 + } + + return dataSource.collectionView(self, numberOfItemsInSection: 0) + } +} + +extension TopicsCollectionView: ReaderTopicCollectionViewCoordinatorDelegate { + func coordinator(_ coordinator: ReaderTopicCollectionViewCoordinator, didSelectTopic topic: String) { + topicDelegate?.coordinator(coordinator, didSelectTopic: topic) + } + + func coordinator(_ coordinator: ReaderTopicCollectionViewCoordinator, didChangeState: ReaderTopicCollectionViewState) { + topicDelegate?.coordinator(coordinator, didChangeState: didChangeState) + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/WPImageViewController.h b/WordPress/Classes/ViewRelated/Reader/WPImageViewController.h index 477e4dd55f7b..f7364c4c3162 100644 --- a/WordPress/Classes/ViewRelated/Reader/WPImageViewController.h +++ b/WordPress/Classes/ViewRelated/Reader/WPImageViewController.h @@ -5,6 +5,7 @@ @class Media; @class AbstractPost; +@class ReaderPost; NS_ASSUME_NONNULL_BEGIN @interface WPImageViewController : UIViewController @@ -12,6 +13,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly, nullable) id mediaAsset; @property (nonatomic, assign) BOOL shouldDismissWithGestures; @property (nonatomic, weak) AbstractPost* post; +@property (nonatomic, weak) ReaderPost* readerPost; - (instancetype)initWithImage:(UIImage *)image; - (instancetype)initWithURL:(NSURL *)url; diff --git a/WordPress/Classes/ViewRelated/Reader/WPImageViewController.m b/WordPress/Classes/ViewRelated/Reader/WPImageViewController.m index 7e5c58a93765..906dbcf95bf3 100644 --- a/WordPress/Classes/ViewRelated/Reader/WPImageViewController.m +++ b/WordPress/Classes/ViewRelated/Reader/WPImageViewController.m @@ -161,7 +161,7 @@ - (void)setupActivityIndicator { self.activityIndicatorView = [[CircularProgressView alloc] initWithStyle:CircularProgressViewStyleWhite]; AccessoryView *errorView = [[AccessoryView alloc] init]; - errorView.imageView.image = [Gridicon iconOfType:GridiconTypeNoticeOutline]; + errorView.imageView.image = [UIImage gridiconOfType:GridiconTypeNoticeOutline]; errorView.label.text = NSLocalizedString(@"Error", @"Generic error."); self.activityIndicatorView.errorView = errorView; } @@ -246,22 +246,34 @@ - (void)updateImageView [self.imageView sizeToFit]; self.scrollView.contentSize = self.imageView.image.size; [self centerImage]; + } - (void)loadImageFromURL { self.isLoadingImage = YES; __weak __typeof__(self) weakSelf = self; - [_imageView downloadImageUsingRequest:[NSURLRequest requestWithURL:self.url] - placeholderImage:self.image - success:^(UIImage *image) { - weakSelf.image = image; - [weakSelf updateImageView]; - weakSelf.isLoadingImage = NO; - } failure:^(NSError *error) { - DDLogError(@"Error loading image: %@", error); - [weakSelf.activityIndicatorView showError]; - }]; + if (self.readerPost != NULL) { + [self.imageLoader loadImageWithURL:self.url fromReaderPost:self.readerPost preferredSize:CGSizeZero placeholder:self.image success:^{ + weakSelf.isLoadingImage = NO; + weakSelf.image = weakSelf.imageView.image; + [weakSelf updateImageView]; + } error:^(NSError * _Nullable error) { + [weakSelf.activityIndicatorView showError]; + DDLogError(@"Error loading image: %@", error); + }]; + } else { + [_imageView downloadImageUsingRequest:[NSURLRequest requestWithURL:self.url] + placeholderImage:self.image + success:^(UIImage *image) { + weakSelf.image = image; + [weakSelf updateImageView]; + weakSelf.isLoadingImage = NO; + } failure:^(NSError *error) { + DDLogError(@"Error loading image: %@", error); + [weakSelf.activityIndicatorView showError]; + }]; + } } - (void)loadImageFromMedia @@ -269,7 +281,8 @@ - (void)loadImageFromMedia self.imageView.image = self.image; self.isLoadingImage = YES; __weak __typeof__(self) weakSelf = self; - [self.imageLoader loadImageFromMedia:self.media preferredSize:CGSizeZero placeholder:self.image success:^{ + BOOL isBlogAtomic = [self.media.blog isAtomic]; + [self.imageLoader loadImageFromMedia:self.media preferredSize:CGSizeZero placeholder:self.image isBlogAtomic:isBlogAtomic success:^{ weakSelf.isLoadingImage = NO; weakSelf.image = weakSelf.imageView.image; [weakSelf updateImageView]; @@ -354,7 +367,9 @@ - (void)viewWillDisappear:(BOOL)animated - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; - [self centerImage]; + [coordinator animateAlongsideTransition:^(id _Nonnull __unused context) { + [self centerImage]; + } completion:nil]; } - (BOOL)prefersHomeIndicatorAutoHidden @@ -555,6 +570,14 @@ - (void)setupAccessibility { self.imageView.isAccessibilityElement = YES; self.imageView.accessibilityTraits = UIAccessibilityTraitImage; + + if (self.media != nil && self.media.title != nil) { + self.imageView.accessibilityLabel = [NSString stringWithFormat:NSLocalizedString(@"Fullscreen view of image %@. Double tap to dismiss", @"Accessibility label for when image is shown to user in full screen, with instructions on how to dismiss the screen. Placeholder is the title of the image"), self.media.title]; + } + else { + self.imageView.accessibilityLabel = NSLocalizedString(@"Fullscreen view of image. Double tap to dismiss", @"Accessibility label for when image is shown to user in full screen, with instructions on how to dismiss the screen"); + } + } - (BOOL)accessibilityPerformEscape diff --git a/WordPress/Classes/ViewRelated/Reader/WPStyleGuide+Reader.swift b/WordPress/Classes/ViewRelated/Reader/WPStyleGuide+Reader.swift index 24438baa386b..ae96c4e4bee5 100644 --- a/WordPress/Classes/ViewRelated/Reader/WPStyleGuide+Reader.swift +++ b/WordPress/Classes/ViewRelated/Reader/WPStyleGuide+Reader.swift @@ -28,7 +28,8 @@ extension WPStyleGuide { // MARK: - Reader Card Styles @objc public class func readerCardBlogNameLabelTextColor() -> UIColor { - return .primary + return UIColor(light: .muriel(color: .gray, .shade90), + dark: .muriel(color: .gray, .shade0)) } @objc public class func readerCardBlogNameLabelDisabledTextColor() -> UIColor { @@ -44,10 +45,18 @@ extension WPStyleGuide { return .neutral(.shade10) } + public class func readerCardBlogIconBorderColor() -> UIColor { + return UIColor(light: .gray(.shade0), dark: .systemGray5) + } + + public class func readerCardFeaturedMediaBorderColor() -> UIColor { + return readerCardBlogIconBorderColor() + } + // MARK: - Card Attributed Text Attributes @objc public class func readerCrossPostTitleAttributes() -> [NSAttributedString.Key: Any] { - let font = WPStyleGuide.notoBoldFontForTextStyle(Cards.titleTextStyle) + let font = WPStyleGuide.serifFontForTextStyle(Cards.crossPostTitleTextStyle) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = Cards.crossPostLineSpacing @@ -63,12 +72,10 @@ extension WPStyleGuide { let font = WPStyleGuide.fontForTextStyle(Cards.crossPostSubtitleTextStyle, symbolicTraits: .traitBold) let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = Cards.crossPostLineSpacing - return [ .paragraphStyle: paragraphStyle, .font: font, - .foregroundColor: UIColor.textSubtle + .foregroundColor: UIColor(light: .gray(.shade40), dark: .systemGray) ] } @@ -81,24 +88,22 @@ extension WPStyleGuide { return [ .paragraphStyle: paragraphStyle, .font: font, - .foregroundColor: UIColor.textSubtle + .foregroundColor: UIColor(light: .gray(.shade40), dark: .systemGray) ] } @objc public class func readerCardTitleAttributes() -> [NSAttributedString.Key: Any] { - let font = WPStyleGuide.notoBoldFontForTextStyle(Cards.titleTextStyle) - let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = Cards.titleLineSpacing return [ .paragraphStyle: paragraphStyle, - .font: font + .font: WPStyleGuide.serifFontForTextStyle(Cards.titleTextStyle, fontWeight: .semibold) ] } @objc public class func readerCardSummaryAttributes() -> [NSAttributedString.Key: Any] { - let font = WPStyleGuide.notoFontForTextStyle(Cards.contentTextStyle) + let font = WPStyleGuide.fontForTextStyle(Cards.summaryTextStyle) let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.lineSpacing = Cards.contentLineSpacing @@ -119,36 +124,37 @@ extension WPStyleGuide { // MARK: - Detail styles @objc public class func readerDetailTitleAttributes() -> [NSAttributedString.Key: Any] { - let font = WPStyleGuide.notoBoldFontForTextStyle(Detail.titleTextStyle) - - let lineHeight = font.pointSize + 10.0 - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.minimumLineHeight = lineHeight - paragraphStyle.maximumLineHeight = lineHeight + let style: UIFont.TextStyle = UIDevice.isPad() ? .title1 : .title2 + let font = WPStyleGuide.serifFontForTextStyle(style, fontWeight: .semibold) return [ - .paragraphStyle: paragraphStyle, .font: font ] } + // MARK: - No Followed Sites Error Text Attributes + @objc public class func noFollowedSitesErrorTitleAttributes() -> [NSAttributedString.Key: Any] { + let paragraphStyle = NSMutableParagraphStyle() - // MARK: - Stream Header Attributed Text Attributes + return [ + .paragraphStyle: paragraphStyle, + .font: WPStyleGuide.serifFontForTextStyle(.title3), - @objc public class func readerStreamHeaderDescriptionAttributes() -> [NSAttributedString.Key: Any] { - let font = WPStyleGuide.notoFontForTextStyle(Cards.contentTextStyle) + ] + } + @objc public class func noFollowedSitesErrorSubtitleAttributes() -> [NSAttributedString.Key: Any] { let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = Cards.defaultLineSpacing paragraphStyle.alignment = .center return [ .paragraphStyle: paragraphStyle, - .font: font + .font: fontForTextStyle(.subheadline), + .foregroundColor: UIColor(light: .muriel(color: .gray, .shade40), + dark: .muriel(color: .gray, .shade20)) ] } - // MARK: - Apply Card Styles @objc public class func applyReaderCardSiteButtonStyle(_ button: UIButton) { @@ -162,30 +168,27 @@ extension WPStyleGuide { } @objc public class func applyReaderCardBlogNameStyle(_ label: UILabel) { - WPStyleGuide.configureLabel(label, textStyle: Cards.buttonTextStyle) + WPStyleGuide.configureLabel(label, textStyle: Cards.buttonTextStyle, fontWeight: .medium) label.textColor = readerCardBlogNameLabelTextColor() label.highlightedTextColor = .primaryLight } @objc public class func applyReaderCardBylineLabelStyle(_ label: UILabel) { WPStyleGuide.configureLabel(label, textStyle: Cards.subtextTextStyle) - label.textColor = UIColor.textSubtle + label.textColor = UIColor(light: .muriel(color: .gray, .shade40), + dark: .muriel(color: .gray, .shade20)) } @objc public class func applyReaderCardTitleLabelStyle(_ label: UILabel) { - label.textColor = .text + label.textColor = UIColor(light: .gray(.shade90), dark: .text) } @objc public class func applyReaderCardSummaryLabelStyle(_ label: UILabel) { - label.textColor = .text + label.textColor = UIColor(light: .gray(.shade80), dark: .muriel(color: .gray, .shade0)) } - @objc public class func applyReaderCardTagButtonStyle(_ button: UIButton) { - WPStyleGuide.configureLabel(button.titleLabel!, textStyle: Cards.subtextTextStyle) - button.setTitleColor(.primary, for: UIControl.State()) - button.setTitleColor(.primaryDark, for: .highlighted) - button.titleLabel?.allowsDefaultTighteningForTruncation = false - button.titleLabel?.lineBreakMode = .byTruncatingTail + public class func applyReaderCardAttributionLabelStyle(_ label: UILabel) { + label.textColor = UIColor(light: .gray(.shade80), dark: .textSubtle) } @objc public class func applyReaderCardActionButtonStyle(_ button: UIButton) { @@ -200,86 +203,156 @@ extension WPStyleGuide { // MARK: - Apply Stream Header Styles @objc public class func applyReaderStreamHeaderTitleStyle(_ label: UILabel) { - WPStyleGuide.configureLabel(label, textStyle: Cards.buttonTextStyle) + label.font = WPStyleGuide.serifFontForTextStyle(.title2, fontWeight: .bold) label.textColor = .text } @objc public class func applyReaderStreamHeaderDetailStyle(_ label: UILabel) { - WPStyleGuide.configureLabel(label, textStyle: Cards.subtextTextStyle) + label.font = fontForTextStyle(.subheadline, fontWeight: .regular) label.textColor = .textSubtle } @objc public class func applyReaderSiteStreamDescriptionStyle(_ label: UILabel) { - WPStyleGuide.configureLabelForNotoFont(label, textStyle: .subheadline) + label.font = fontForTextStyle(.body, fontWeight: .regular) label.textColor = .text } @objc public class func applyReaderSiteStreamCountStyle(_ label: UILabel) { - WPStyleGuide.configureLabel(label, textStyle: Cards.subtextTextStyle) + WPStyleGuide.configureLabel(label, textStyle: Cards.contentTextStyle) label.textColor = .textSubtle } // MARK: - Button Styles and Text + class func applyReaderStreamActionButtonStyle(_ button: UIButton) { + let tintColor = UIColor(light: .muriel(color: .gray, .shade50), + dark: .textSubtle) + + let disabledColor = UIColor(light: .muriel(color: .gray, .shade10), + dark: .textQuaternary) + + return applyReaderActionButtonStyle(button, + titleColor: tintColor, + imageColor: tintColor, + disabledColor: disabledColor) + } + - public class func applyReaderActionButtonStyle(_ button: UIButton) { - let defaultColor: UIColor = .listIcon + class func applyReaderActionButtonStyle(_ button: UIButton, + titleColor: UIColor = .listIcon, + imageColor: UIColor = .listIcon, + disabledColor: UIColor = .neutral(.shade10)) { + button.tintColor = imageColor let highlightedColor: UIColor = .neutral let selectedColor: UIColor = .primary(.shade40) let bothColor: UIColor = .primaryLight - let disabledColor: UIColor = .neutral(.shade10) - let normalImage = button.image(for: .normal) let highlightedImage = button.image(for: .highlighted) let selectedImage = button.image(for: .selected) let bothImage = button.image(for: [.highlighted, .selected]) let disabledImage = button.image(for: .disabled) - button.setImage(normalImage?.imageWithTintColor(defaultColor), for: .normal) button.setImage(highlightedImage?.imageWithTintColor(highlightedColor), for: .highlighted) button.setImage(selectedImage?.imageWithTintColor(selectedColor), for: .selected) button.setImage(bothImage?.imageWithTintColor(bothColor), for: [.selected, .highlighted]) button.setImage(disabledImage?.imageWithTintColor(disabledColor), for: .disabled) - button.setTitleColor(defaultColor, for: .normal) + button.setTitleColor(titleColor, for: .normal) button.setTitleColor(highlightedColor, for: .highlighted) button.setTitleColor(selectedColor, for: .selected) button.setTitleColor(bothColor, for: [.selected, .highlighted]) button.setTitleColor(disabledColor, for: .disabled) } + @objc public class func applyReaderFollowConversationButtonStyle(_ button: UIButton) { + // General + button.naturalContentHorizontalAlignment = .leading + button.backgroundColor = .clear + button.titleLabel?.font = fontForTextStyle(.footnote) + + // Color(s) + let normalColor = UIColor.primary + let highlightedColor = UIColor.neutral + let selectedColor = UIColor.success + + button.setTitleColor(normalColor, for: .normal) + button.setTitleColor(selectedColor, for: .selected) + button.setTitleColor(highlightedColor, for: .highlighted) + + // Image(s) + let side = WPStyleGuide.fontSizeForTextStyle(.headline) + let size = CGSize(width: side, height: side) + let followIcon = UIImage.gridicon(.readerFollowConversation, size: size) + let followingIcon = UIImage.gridicon(.readerFollowingConversation, size: size) + + button.setImage(followIcon.imageWithTintColor(normalColor), for: .normal) + button.setImage(followingIcon.imageWithTintColor(selectedColor), for: .selected) + button.setImage(followingIcon.imageWithTintColor(highlightedColor), for: .highlighted) + button.imageEdgeInsets = FollowConversationButton.Style.imageEdgeInsets + button.contentEdgeInsets = FollowConversationButton.Style.contentEdgeInsets + } + @objc public class func applyReaderFollowButtonStyle(_ button: UIButton) { - let side = WPStyleGuide.fontSizeForTextStyle(Cards.buttonTextStyle) + let side = WPStyleGuide.fontSizeForTextStyle(.callout) let size = CGSize(width: side, height: side) - let followIcon = Gridicon.iconOfType(.readerFollow, withSize: size) - let followingIcon = Gridicon.iconOfType(.readerFollowing, withSize: size) + let followIcon = UIImage.gridicon(.readerFollow, size: size) + let followingIcon = UIImage.gridicon(.readerFollowing, size: size) - let normalColor = UIColor.primary - let highlightedColor = UIColor.primaryDark - let selectedColor = UIColor.success + let followFont: UIFont = fontForTextStyle(.callout, fontWeight: .semibold) + let followingFont: UIFont = fontForTextStyle(.callout, fontWeight: .regular) + button.titleLabel?.font = button.isSelected ? followingFont : followFont + + button.layer.cornerRadius = 4.0 + button.layer.borderColor = UIColor.primaryButtonBorder.cgColor + + button.backgroundColor = button.isSelected ? FollowButton.Style.followingBackgroundColor : FollowButton.Style.followBackgroundColor + button.tintColor = button.isSelected ? FollowButton.Style.followingIconColor : FollowButton.Style.followTextColor - let tintedFollowIcon = followIcon.imageWithTintColor(normalColor) - let tintedFollowingIcon = followingIcon.imageWithTintColor(selectedColor) - let highlightIcon = followingIcon.imageWithTintColor(highlightedColor) + button.setTitleColor(FollowButton.Style.followTextColor, for: .normal) + button.setTitleColor(FollowButton.Style.followingTextColor, for: .selected) + + button.imageEdgeInsets = FollowButton.Style.imageEdgeInsets + button.titleEdgeInsets = FollowButton.Style.titleEdgeInsets + button.contentEdgeInsets = FollowButton.Style.contentEdgeInsets + + let tintedFollowIcon = followIcon.imageWithTintColor(FollowButton.Style.followTextColor) + let tintedFollowingIcon = followingIcon.imageWithTintColor(FollowButton.Style.followingTextColor) button.setImage(tintedFollowIcon, for: .normal) button.setImage(tintedFollowingIcon, for: .selected) - button.setImage(highlightIcon, for: .highlighted) - button.setTitle(followStringForDisplay, for: UIControl.State()) - button.setTitle(followingStringForDisplay, for: .selected) - button.setTitle(followingStringForDisplay, for: .highlighted) + button.setTitle(FollowButton.Text.followStringForDisplay, for: .normal) + button.setTitle(FollowButton.Text.followingStringForDisplay, for: .selected) - button.setTitleColor(normalColor, for: UIControl.State()) - button.setTitleColor(highlightedColor, for: .highlighted) - button.setTitleColor(selectedColor, for: .selected) + button.layer.borderWidth = button.isSelected ? 1.0 : 0.0 + + // Default accessibility label and hint. + button.accessibilityLabel = button.isSelected ? FollowButton.Text.followingStringForDisplay : FollowButton.Text.followStringForDisplay + button.accessibilityHint = FollowButton.Text.accessibilityHint + } + + @objc public class func applyReaderIconFollowButtonStyle(_ button: UIButton) { + let followIcon = UIImage.gridicon(.readerFollow) + let followingIcon = UIImage.gridicon(.readerFollowing) + + button.backgroundColor = .clear + + let tintedFollowIcon = followIcon.imageWithTintColor(.primary(.shade40)) + let tintedFollowingIcon = followingIcon.imageWithTintColor(.gray(.shade40)) + + button.setImage(tintedFollowIcon, for: .normal) + button.setImage(tintedFollowingIcon, for: .selected) + + // Default accessibility label and hint. + button.accessibilityLabel = button.isSelected ? FollowButton.Text.followingStringForDisplay : FollowButton.Text.followStringForDisplay + button.accessibilityHint = FollowButton.Text.accessibilityHint } @objc public class func applyReaderSaveForLaterButtonStyle(_ button: UIButton) { let size = Gridicon.defaultSize - let icon = Gridicon.iconOfType(.bookmarkOutline, withSize: size) - let selectedIcon = Gridicon.iconOfType(.bookmark, withSize: size) + let icon = UIImage.gridicon(.bookmarkOutline, size: size) + let selectedIcon = UIImage.gridicon(.bookmark, size: size) button.setImage(icon, for: .normal) button.setImage(selectedIcon, for: .selected) @@ -289,6 +362,55 @@ extension WPStyleGuide { applyReaderActionButtonStyle(button) } + @objc public class func applyReaderCardSaveForLaterButtonStyle(_ button: UIButton) { + let size = Cards.actionButtonSize + let icon = UIImage.gridicon(.bookmarkOutline, size: size) + let selectedIcon = UIImage.gridicon(.bookmark, size: size) + + button.setImage(icon, for: .normal) + button.setImage(selectedIcon, for: .selected) + button.setImage(selectedIcon, for: .highlighted) + button.setImage(selectedIcon, for: [.highlighted, .selected]) + button.setImage(icon, for: .disabled) + + applyReaderStreamActionButtonStyle(button) + } + + @objc public class func applyReaderCardCommentButtonStyle(_ button: UIButton, defaultSize: Bool = false) { + let size = defaultSize ? Gridicon.defaultSize : Cards.actionButtonSize + let icon = UIImage(named: "icon-reader-comment-outline")?.imageFlippedForRightToLeftLayoutDirection() + let selectedIcon = UIImage(named: "icon-reader-comment-outline-highlighted")?.imageFlippedForRightToLeftLayoutDirection() + + guard + let resizedIcon = icon?.resizedImage(size, interpolationQuality: .high)?.withRenderingMode(.alwaysTemplate), + let resizedSelectedIcon = selectedIcon?.resizedImage(size, interpolationQuality: .high).withRenderingMode(.alwaysTemplate) + else { + return + } + + button.setImage(resizedIcon, for: .normal) + button.setImage(resizedSelectedIcon, for: .selected) + button.setImage(resizedSelectedIcon, for: .highlighted) + button.setImage(resizedSelectedIcon, for: [.highlighted, .selected]) + button.setImage(resizedIcon, for: .disabled) + + applyReaderStreamActionButtonStyle(button) + } + + @objc public class func applyReaderCardLikeButtonStyle(_ button: UIButton) { + let size = Cards.actionButtonSize + let icon = UIImage.gridicon(.starOutline, size: size) + let selectedIcon = UIImage.gridicon(.star, size: size) + + button.setImage(icon, for: .normal) + button.setImage(selectedIcon, for: .selected) + button.setImage(selectedIcon, for: .highlighted) + button.setImage(selectedIcon, for: [.highlighted, .selected]) + button.setImage(icon, for: .disabled) + + applyReaderStreamActionButtonStyle(button) + } + /// Applies the save for later button style to the button passed as an argument /// - Parameter button: the button to apply the style to /// - Parameter showTitle: if set to true, will show the button label (default: true) @@ -301,12 +423,27 @@ extension WPStyleGuide { button.setTitle(savedTitle, for: [.highlighted, .selected]) } + /// Applies the reblog button style to the button passed as an argument + /// - Parameter button: the button to apply the style to + /// - Parameter showTitle: if set to true, will show the button label (default: true) + @objc public class func applyReaderCardReblogActionButtonStyle(_ button: UIButton, showTitle: Bool = true) { + let size = Cards.actionButtonSize + let icon = UIImage.gridicon(.reblog, size: size) + + button.setImage(icon, for: .normal) + button.setImage(icon, for: .selected) + button.setImage(icon, for: .highlighted) + button.setImage(icon, for: [.highlighted, .selected]) + button.setImage(icon, for: .disabled) + + applyReaderStreamActionButtonStyle(button) + } /// Applies the reblog button style to the button passed as an argument /// - Parameter button: the button to apply the style to /// - Parameter showTitle: if set to true, will show the button label (default: true) @objc public class func applyReaderReblogActionButtonStyle(_ button: UIButton, showTitle: Bool = true) { let size = Gridicon.defaultSize - let icon = Gridicon.iconOfType(.reblog, withSize: size) + let icon = UIImage.gridicon(.reblog, size: size) button.setImage(icon, for: .normal) @@ -349,14 +486,6 @@ extension WPStyleGuide { } } - @objc public static var followStringForDisplay: String { - return NSLocalizedString("Follow", comment: "Verb. Button title. Follow a new blog.") - } - - @objc public static var followingStringForDisplay: String { - return NSLocalizedString("Following", comment: "Verb. Button title. The user is following a blog.") - } - @objc public class func savePostStringForDisplay(_ isSaved: Bool) -> String { if isSaved { return NSLocalizedString("Saved", comment: "Title of action button for a Reader post that has been saved to read later.") @@ -365,6 +494,33 @@ extension WPStyleGuide { } } + /// Applies the filter button style to the button passed as an argument + class func applyReaderFilterButtonStyle(_ button: UIButton) { + let icon = UIImage.gridicon(.filter) + + button.setImage(icon, for: .normal) + applyReaderActionButtonStyle(button, titleColor: UIColor(light: .black, dark: .white)) + } + /// Applies the filter button title to the button passed as an argument + class func applyReaderFilterButtonTitle(_ button: UIButton, title: String) { + button.setTitle(title, for: .normal) + button.setTitle(title, for: .highlighted) + } + /// Applies the reset filter button style to the button passed as an argument + class func applyReaderResetFilterButtonStyle(_ button: UIButton) { + let icon = UIImage.gridicon(.crossSmall) + + button.setImage(icon, for: .normal) + applyReaderActionButtonStyle(button, imageColor: UIColor(light: .black, dark: .white)) + } + /// Applies the settings button style to the button passed as an argument + class func applyReaderSettingsButtonStyle(_ button: UIButton) { + let icon = UIImage.gridicon(.cog) + + button.setImage(icon, for: .normal) + applyReaderActionButtonStyle(button) + } + // MARK: - Gap Marker Styles @objc public class func applyGapMarkerButtonStyle(_ button: UIButton) { @@ -391,18 +547,50 @@ extension WPStyleGuide { public static let defaultLineSpacing: CGFloat = WPDeviceIdentification.isiPad() ? 6.0 : 3.0 public static let titleTextStyle: UIFont.TextStyle = WPDeviceIdentification.isiPad() ? .title2 : .title3 public static let titleLineSpacing: CGFloat = WPDeviceIdentification.isiPad() ? 0.0 : 0.0 - public static let contentTextStyle: UIFont.TextStyle = .subheadline + public static let summaryTextStyle: UIFont.TextStyle = .subheadline + public static let contentTextStyle: UIFont.TextStyle = .footnote public static let contentLineSpacing: CGFloat = 4 public static let buttonTextStyle: UIFont.TextStyle = .subheadline public static let subtextTextStyle: UIFont.TextStyle = .caption1 public static let loadMoreButtonTextStyle: UIFont.TextStyle = .subheadline - public static let crossPostSubtitleTextStyle: UIFont.TextStyle = .footnote + + public static let crossPostTitleTextStyle: UIFont.TextStyle = .body + public static let crossPostSubtitleTextStyle: UIFont.TextStyle = .caption1 public static let crossPostLineSpacing: CGFloat = 2.0 + + public static let actionButtonSize: CGSize = CGSize(width: 20, height: 20) } public struct Detail { - public static let titleTextStyle: UIFont.TextStyle = .title1 + public static let titleTextStyle: UIFont.TextStyle = .title2 public static let contentTextStyle: UIFont.TextStyle = .callout } + public struct FollowButton { + struct Style { + static let followBackgroundColor: UIColor = .primaryButtonBackground + static let followTextColor: UIColor = .white + static let followingBackgroundColor: UIColor = .clear + static let followingIconColor: UIColor = .buttonIcon + static let followingTextColor: UIColor = .textSubtle + + static let imageTitleSpace: CGFloat = 2.0 + static let imageEdgeInsets = UIEdgeInsets(top: 0, left: -imageTitleSpace, bottom: 0, right: imageTitleSpace) + static let titleEdgeInsets = UIEdgeInsets(top: 0, left: imageTitleSpace, bottom: 0, right: -imageTitleSpace) + static let contentEdgeInsets = UIEdgeInsets(top: 6.0, left: 12.0, bottom: 6.0, right: 12.0) + } + + struct Text { + static let accessibilityHint = NSLocalizedString("Follows the tag.", comment: "VoiceOver accessibility hint, informing the user the button can be used to follow a tag.") + static let followStringForDisplay = NSLocalizedString("Follow", comment: "Verb. Button title. Follow a new blog.") + static let followingStringForDisplay = NSLocalizedString("Following", comment: "Verb. Button title. The user is following a blog.") + } + } + + public struct FollowConversationButton { + struct Style { + static let imageEdgeInsets = UIEdgeInsets(top: 1.0, left: -4.0, bottom: 0.0, right: -4.0) + static let contentEdgeInsets = UIEdgeInsets(top: 0.0, left: 4.0, bottom: 0.0, right: 0.0) + } + } } diff --git a/WordPress/Classes/ViewRelated/Reader/WPStyleGuide+ReaderComments.swift b/WordPress/Classes/ViewRelated/Reader/WPStyleGuide+ReaderComments.swift index 5998878d2827..c013447d3a01 100644 --- a/WordPress/Classes/ViewRelated/Reader/WPStyleGuide+ReaderComments.swift +++ b/WordPress/Classes/ViewRelated/Reader/WPStyleGuide+ReaderComments.swift @@ -8,10 +8,27 @@ extension WPStyleGuide { return NSAttributedString.Key.convertToRaw(attributes: attributes) } - class func defaultSearchBarTextAttributesSwifted(_ color: UIColor) -> [NSAttributedString.Key: Any] { + class func defaultSearchBarTextAttributesSwifted() -> [NSAttributedString.Key: Any] { return [ - .foregroundColor: color, - .font: WPStyleGuide.fixedFont(for: .footnote) + .font: WPStyleGuide.fixedFont(for: .body) ] } + + class func defaultSearchBarTextAttributesSwifted(_ color: UIColor) -> [NSAttributedString.Key: Any] { + var attributes = defaultSearchBarTextAttributesSwifted() + + attributes[.foregroundColor] = color + + return attributes + } + + public struct ReaderCommentsNotificationSheet { + static let textColor = UIColor.text + static let descriptionLabelFont = fontForTextStyle(.subheadline) + static let switchLabelFont = fontForTextStyle(.body) + static let buttonTitleLabelFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + static let buttonBorderColor = UIColor.systemGray3 + static let switchOnTintColor = UIColor.systemGreen + static let switchInProgressTintColor = UIColor.brand + } } diff --git a/WordPress/Classes/ViewRelated/Reusable SwiftUI Views/ButtonStyles.swift b/WordPress/Classes/ViewRelated/Reusable SwiftUI Views/ButtonStyles.swift new file mode 100644 index 000000000000..c40beab68faa --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reusable SwiftUI Views/ButtonStyles.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct PrimaryButtonStyle: ViewModifier { + func body(content: Content) -> some View { + content + .font(.headline) + .padding() + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44.0, maxHeight: 44.0) + .background(Color(.primary)) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 8.0)) + } +} + +extension View { + func primaryButtonStyle() -> some View { + self.modifier(PrimaryButtonStyle()) + } +} diff --git a/WordPress/Classes/ViewRelated/Sharing/ShareAppContentPresenter+TableView.swift b/WordPress/Classes/ViewRelated/Sharing/ShareAppContentPresenter+TableView.swift new file mode 100644 index 000000000000..9ec22c1eb556 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Sharing/ShareAppContentPresenter+TableView.swift @@ -0,0 +1,9 @@ +/// Constants for share app content interoperability with table view. +/// +extension ShareAppContentPresenter { + struct RowConstants { + static let buttonTitle = AppConstants.Settings.shareButtonTitle + static let buttonIconImage: UIImage? = .init(systemName: "square.and.arrow.up") + static let buttonTintColor: UIColor = .primary + } +} diff --git a/WordPress/Classes/ViewRelated/Sharing/ShareAppContentPresenter.swift b/WordPress/Classes/ViewRelated/Sharing/ShareAppContentPresenter.swift new file mode 100644 index 000000000000..53f764142a57 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Sharing/ShareAppContentPresenter.swift @@ -0,0 +1,153 @@ +/// Encapsulates the logic required to fetch, prepare, and present the contents for sharing the app to others. +/// +/// The contents for sharing is first fetched from the API, so the share presentation logic is not synchronously executed. +/// Callers are recommended to listen to progress changes by implementing `didUpdateLoadingState(_ loading:)` as this class' delegate. +/// +class ShareAppContentPresenter { + + // MARK: Public Properties + + weak var delegate: ShareAppContentPresenterDelegate? + + /// Tracks content fetch state. + private(set) var isLoading: Bool = false { + didSet { + guard isLoading != oldValue else { + return + } + delegate?.didUpdateLoadingState(isLoading) + } + } + + // MARK: Private Properties + + /// The API used for fetching the share app link. Anonymous profile is allowed. + private let api: WordPressComRestApi + + private var remote: ShareAppContentServiceRemote + + /// In-memory cache. As long as the same presenter instance is used, there's no need to re-fetch the content everytime `shareContent` is called. + private var cachedContent: RemoteShareAppContent? = nil + + // MARK: Initialization + + /// Instantiates the presenter. When the provided account is nil, the presenter will default to anonymous API. + init(remote: ShareAppContentServiceRemote? = nil, account: WPAccount? = nil) { + self.api = account?.wordPressComRestV2Api ?? .anonymousApi(userAgent: WPUserAgent.wordPress(), localeKey: WordPressComRestApi.LocaleKeyV2) + self.remote = remote ?? ShareAppContentServiceRemote(wordPressComRestApi: api) + } + + // MARK: Public Methods + + /// Fetches the content needed for sharing, and presents the share sheet through the provided `sender` instance. + /// + /// - Parameters: + /// - appName: The name of the app to be shared. Fetched contents will differ depending on the provided value. + /// - sender: The view that will be presenting the share sheet. + /// - source: Provides tracking context on where the share app feature is engaged from. + /// - sourceView: The view to be the anchor for the popover view on iPad. + /// - completion: A closure that's invoked after the process completes. + func present(for appName: ShareAppName, in sender: UIViewController, source: ShareAppEventSource, sourceView: UIView? = nil, completion: (() -> Void)? = nil) { + let anchorView = sourceView ?? sender.view + if let content = cachedContent { + presentShareSheet(with: content, in: sender, sourceView: anchorView) + trackEngagement(source: source) + completion?() + return + } + + guard !isLoading else { + completion?() + return + } + + isLoading = true + + remote.getContent(for: appName) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let content): + self.cachedContent = content + self.presentShareSheet(with: content, in: sender, sourceView: anchorView) + self.trackEngagement(source: source) + + case .failure: + self.showFailureNotice(in: sender) + self.trackContentFetchFailed() + } + + self.isLoading = false + completion?() + } + } +} + +// MARK: - Tracking Source Definition + +enum ShareAppEventSource: String { + // Feature engaged from the Me page. + case me + + // Feature engaged from the About page. + case about +} + +// MARK: - Delegate Definition + +protocol ShareAppContentPresenterDelegate: AnyObject { + /// Delegate method called everytime the presenter updates its loading state. + /// + /// - Parameter loading: The presenter's latest loading state. + func didUpdateLoadingState(_ loading: Bool) +} + +// MARK: - Private Helpers + +private extension ShareAppContentPresenter { + /// Presents the share sheet by using `UIActivityViewController`. Contents to be shared will be constructed from the provided `content`. + /// + /// - Parameters: + /// - content: The model containing information metadata for the sharing activity. + /// - viewController: The view controller that will be presenting the activity. + /// - sourceView: The view set to be the anchor for the popover. + func presentShareSheet(with content: RemoteShareAppContent, in viewController: UIViewController, sourceView: UIView?) { + guard let linkURL = content.linkURL() else { + return + } + + let activityItems = [ + ShareAppTextActivityItemSource(message: content.message) as Any, + linkURL as Any + ] + + let activityViewController = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + activityViewController.popoverPresentationController?.sourceView = sourceView + viewController.present(activityViewController, animated: true, completion: nil) + } + + /// Shows a notice indicating that the share intent failed. + /// + func showFailureNotice(in viewController: UIViewController) { + viewController.displayNotice(title: .failureNoticeText, message: nil) + } + + // MARK: Tracking Helpers + + func trackEngagement(source: ShareAppEventSource) { + WPAnalytics.track(.recommendAppEngaged, properties: [String.sourceParameterKey: source.rawValue]) + } + + func trackContentFetchFailed() { + WPAnalytics.track(.recommendAppContentFetchFailed) + } +} + +// MARK: Localized Strings + +private extension String { + static let sourceParameterKey = "source" + static let failureNoticeText = NSLocalizedString("Something went wrong. Please try again.", + comment: "Error message shown when user tries to share the app with others, " + + "but failed due to unknown errors.") +} diff --git a/WordPress/Classes/ViewRelated/Sharing/ShareAppTextActivityItemSource.swift b/WordPress/Classes/ViewRelated/Sharing/ShareAppTextActivityItemSource.swift new file mode 100644 index 000000000000..0b84412b9756 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Sharing/ShareAppTextActivityItemSource.swift @@ -0,0 +1,36 @@ +/// A text-type UIActivityItemSource for the share app activity. +/// +/// Provides additional subject string so the subject line is filled when sharing the app via mail. +/// +final class ShareAppTextActivityItemSource: NSObject { + private let message: String + + init(message: String) { + self.message = message + } +} + +// MARK: - UIActivityItemSource + +extension ShareAppTextActivityItemSource: UIActivityItemSource { + func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { + // informs the activity controller that the activity type is text. + return String() + } + + func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { + return message + } + + func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String { + return .defaultSubjectText + } +} + +// MARK: - Localized Strings + +private extension String { + static let defaultSubjectText = NSLocalizedString("WordPress Apps - Apps for any screen", + comment: "Subject line for when sharing the app with others through mail or any other activity types " + + "that support contains a subject field.") +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Extensions/RemoteSiteDesigns.swift b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Extensions/RemoteSiteDesigns.swift new file mode 100644 index 000000000000..892feb82b344 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Extensions/RemoteSiteDesigns.swift @@ -0,0 +1,14 @@ +import Foundation +import WordPressKit + +extension RemoteSiteDesigns { + func designsForCategory(_ category: RemoteSiteDesignCategory) -> [RemoteSiteDesign] { + return designs.filter { + $0.categories.map { $0.slug }.contains(category.slug) + } + } + + func randomizedDesignsForCategory(_ category: RemoteSiteDesignCategory) -> [RemoteSiteDesign] { + return designsForCategory(category).shuffled() + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Preview/SiteDesignPreviewViewController.swift b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Preview/SiteDesignPreviewViewController.swift new file mode 100644 index 000000000000..ffe847f27c8a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Preview/SiteDesignPreviewViewController.swift @@ -0,0 +1,75 @@ +import UIKit + +class SiteDesignPreviewViewController: TemplatePreviewViewController { + private let createsSite: Bool + let completion: SiteDesignStep.SiteDesignSelection + let siteDesign: RemoteSiteDesign + let sectionType: SiteDesignSectionType + + init(siteDesign: RemoteSiteDesign, + selectedPreviewDevice: PreviewDevice?, + createsSite: Bool, + sectionType: SiteDesignSectionType, + onDismissWithDeviceSelected: ((PreviewDevice) -> ())?, + completion: @escaping SiteDesignStep.SiteDesignSelection) { + + self.completion = completion + self.siteDesign = siteDesign + self.createsSite = createsSite + self.sectionType = sectionType + super.init(demoURL: siteDesign.demoURL, selectedPreviewDevice: selectedPreviewDevice, onDismissWithDeviceSelected: onDismissWithDeviceSelected) + delegate = self + title = TextContent.previewTitle + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.leftBarButtonItem = CollapsableHeaderViewController.closeButton(target: self, action: #selector(closeButtonTapped)) + setPrimaryActionButtonTitle() + } + + private func setPrimaryActionButtonTitle() { + primaryActionButton.setTitle(createsSite ? TextContent.createSiteButton : TextContent.chooseButton, for: .normal) + } + + private enum TextContent { + static let previewTitle = NSLocalizedString("Preview", comment: "Title for screen to preview a selected homepage design.") + static let createSiteButton = NSLocalizedString("Create Site", comment: "Title for the button to progress with creating the site with the selected design.") + static let chooseButton = NSLocalizedString("Choose", comment: "Title for the button to progress with the selected site homepage design.") + } +} + +extension SiteDesignPreviewViewController: TemplatePreviewViewDelegate { + func deviceButtonTapped(_ device: PreviewDevice) { + SiteCreationAnalyticsHelper.trackSiteDesignPreviewModeButtonTapped(device) + } + + func deviceModeChanged(_ device: PreviewDevice) { + SiteCreationAnalyticsHelper.trackSiteDesignPreviewModeChanged(device) + } + + func previewError(_ error: Error) { + SiteCreationAnalyticsHelper.trackError(error) + } + + func previewViewed() { + SiteCreationAnalyticsHelper.trackSiteDesignPreviewViewed(siteDesign: siteDesign, previewMode: selectedPreviewDevice) + } + + func previewLoading() { + SiteCreationAnalyticsHelper.trackSiteDesignPreviewLoading(siteDesign: siteDesign, previewMode: selectedPreviewDevice) + } + + func previewLoaded() { + SiteCreationAnalyticsHelper.trackSiteDesignPreviewLoaded(siteDesign: siteDesign, previewMode: selectedPreviewDevice) + } + + func templatePicked() { + SiteCreationAnalyticsHelper.trackSiteDesignSelected(siteDesign, sectionType: sectionType) + completion(siteDesign) + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Preview/TemplatePreviewViewController.swift b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Preview/TemplatePreviewViewController.swift new file mode 100644 index 000000000000..ce751926e2f5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Preview/TemplatePreviewViewController.swift @@ -0,0 +1,206 @@ + +import Foundation + +protocol TemplatePreviewViewDelegate { + typealias PreviewDevice = PreviewDeviceSelectionViewController.PreviewDevice + func deviceButtonTapped(_ previewDevice: PreviewDevice) + func deviceModeChanged(_ previewDevice: PreviewDevice) + func previewError(_ error: Error) + func previewViewed() + func previewLoading() + func previewLoaded() + func templatePicked() +} + +class TemplatePreviewViewController: UIViewController, NoResultsViewHost, UIPopoverPresentationControllerDelegate { + typealias PreviewDevice = PreviewDeviceSelectionViewController.PreviewDevice + + @IBOutlet weak var primaryActionButton: UIButton! + @IBOutlet weak var webView: WKWebView! + @IBOutlet weak var footerView: UIView! + @IBOutlet weak var progressBar: UIProgressView! + + internal var delegate: TemplatePreviewViewDelegate? + private let demoURL: String + private var estimatedProgressObserver: NSKeyValueObservation? + internal var selectedPreviewDevice: PreviewDevice { + didSet { + if selectedPreviewDevice != oldValue { + UIView.animate(withDuration: 0.2, animations: { + self.webView.alpha = 0 + }, completion: { _ in + self.progressBar.animatableSetIsHidden(false) + self.webView.reload() + }) + } + } + } + private var onDismissWithDeviceSelected: ((PreviewDevice) -> ())? + + lazy var ghostView: GutenGhostView = { + let ghost = GutenGhostView() + ghost.hidesToolbar = true + ghost.translatesAutoresizingMaskIntoConstraints = false + return ghost + }() + + private var accentColor: UIColor { + return UIColor { (traitCollection: UITraitCollection) -> UIColor in + if traitCollection.userInterfaceStyle == .dark { + return UIColor.muriel(color: .primary, .shade40) + } else { + return UIColor.muriel(color: .primary, .shade50) + } + } + } + + init(demoURL: String, selectedPreviewDevice: PreviewDevice?, onDismissWithDeviceSelected: ((PreviewDevice) -> ())?) { + self.demoURL = demoURL + self.selectedPreviewDevice = selectedPreviewDevice ?? PreviewDevice.default + self.onDismissWithDeviceSelected = onDismissWithDeviceSelected + super.init(nibName: "\(TemplatePreviewViewController.self)", bundle: .main) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + removeProgressObserver() + } + + override func viewDidLoad() { + super.viewDidLoad() + configureWebView() + styleButtons() + webView.scrollView.contentInset.bottom = footerView.frame.height + webView.navigationDelegate = self + webView.backgroundColor = .basicBackground + delegate?.previewViewed() + observeProgressEstimations() + configurePreviewDeviceButton() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + onDismissWithDeviceSelected?(selectedPreviewDevice) + } + + @IBAction func actionButtonSelected(_ sender: Any) { + dismiss(animated: true) + delegate?.templatePicked() + } + + private func configureWebView() { + webView.alpha = 0 + guard let demoURL = URL(string: demoURL) else { return } + let request = URLRequest(url: demoURL) + webView.customUserAgent = WPUserAgent.wordPress() + webView.load(request) + } + + private func styleButtons() { + primaryActionButton.titleLabel?.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .medium) + primaryActionButton.backgroundColor = accentColor + primaryActionButton.layer.cornerRadius = 8 + primaryActionButton.setTitle(NSLocalizedString("Choose", comment: "Title for the button to progress with the selected site homepage design"), for: .normal) + } + + private func configurePreviewDeviceButton() { + let button = UIBarButtonItem(image: UIImage(named: "icon-devices"), style: .plain, target: self, action: #selector(previewDeviceButtonTapped)) + navigationItem.rightBarButtonItem = button + } + + private func observeProgressEstimations() { + estimatedProgressObserver = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] (webView, _) in + self?.progressBar.progress = Float(webView.estimatedProgress) + } + } + + @objc func closeButtonTapped() { + dismiss(animated: true) + } + + @objc private func previewDeviceButtonTapped() { + delegate?.deviceButtonTapped(selectedPreviewDevice) + let popoverContentController = PreviewDeviceSelectionViewController() + popoverContentController.selectedOption = selectedPreviewDevice + popoverContentController.onDeviceChange = { [weak self] device in + guard let self = self else { return } + self.delegate?.deviceModeChanged(device) + self.selectedPreviewDevice = device + } + + popoverContentController.modalPresentationStyle = .popover + popoverContentController.popoverPresentationController?.delegate = self + self.present(popoverContentController, animated: true, completion: nil) + } + + private func removeProgressObserver() { + estimatedProgressObserver?.invalidate() + estimatedProgressObserver = nil + } + + private func handleError(_ error: Error) { + delegate?.previewError(error) + configureAndDisplayNoResults(on: webView, + title: NSLocalizedString("Unable to load this content right now.", comment: "Informing the user that a network request failed because the device wasn't able to establish a network connection.")) + progressBar.animatableSetIsHidden(true) + } +} + +// MARK: WKNavigationDelegate +extension TemplatePreviewViewController: WKNavigationDelegate { + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + delegate?.previewLoading() + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + handleError(error) + } + + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { + webView.evaluateJavaScript(selectedPreviewDevice.viewportScript) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + handleError(error) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + UIView.animate(withDuration: 0.2) { + self.webView.alpha = 1 + } + + delegate?.previewLoaded() + progressBar.animatableSetIsHidden(true) + } +} + +// MARK: UIPopoverPresentationDelegate +extension TemplatePreviewViewController { + + func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) { + guard popoverPresentationController.presentedViewController is PreviewDeviceSelectionViewController else { + return + } + + popoverPresentationController.permittedArrowDirections = .up + popoverPresentationController.barButtonItem = navigationItem.rightBarButtonItem + } + + func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { + return .none + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + guard let popoverPresentationController = presentedViewController?.presentationController as? UIPopoverPresentationController else { + return + } + + prepareForPopoverPresentation(popoverPresentationController) + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Preview/TemplatePreviewViewController.xib b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Preview/TemplatePreviewViewController.xib new file mode 100644 index 000000000000..0ef0ff7feb5c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/Preview/TemplatePreviewViewController.xib @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Site Creation/Design Selection/RemoteSiteDesign+Thumbnail.swift b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/RemoteSiteDesign+Thumbnail.swift new file mode 100644 index 000000000000..97331ffadce4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/RemoteSiteDesign+Thumbnail.swift @@ -0,0 +1,7 @@ +import Foundation + +extension RemoteSiteDesign: Thumbnail { + var urlDesktop: String? { screenshot } + var urlTablet: String? { tabletScreenshot } + var urlMobile: String? { mobileScreenshot} +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignCategoryThumbnailSize.swift b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignCategoryThumbnailSize.swift new file mode 100644 index 000000000000..2ece9344f5cc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignCategoryThumbnailSize.swift @@ -0,0 +1,19 @@ +import Foundation + +enum SiteDesignCategoryThumbnailSize { + case category + case recommended + + var value: CGSize { + switch self { + case .category where UIDevice.isPad(): + return .init(width: 250, height: 325) + case .category: + return .init(width: 200, height: 260) + case .recommended where UIDevice.isPad(): + return .init(width: 327, height: 450) + case .recommended: + return .init(width: 350, height: 450) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignContentCollectionViewController.swift b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignContentCollectionViewController.swift new file mode 100644 index 000000000000..221bd5325b56 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignContentCollectionViewController.swift @@ -0,0 +1,365 @@ +import Gridicons +import UIKit +import WordPressKit + +class SiteDesignContentCollectionViewController: CollapsableHeaderViewController { + typealias PreviewDevice = PreviewDeviceSelectionViewController.PreviewDevice + + private let creator: SiteCreator + private let createsSite: Bool + private let tableView: UITableView + private let completion: SiteDesignStep.SiteDesignSelection + private var sectionAssembler: SiteDesignSectionLoader.Assembler? + + private var sections: [SiteDesignSection] = [] { + didSet { + tableView.scrollToTop(animated: false) + sectionHorizontalOffsets.removeAll() + contentSizeWillChange() + tableView.reloadData() + } + } + + /// Dictionary to store horizontal scroll position of sections, keyed by category slug + private var sectionHorizontalOffsets: [String: CGFloat] = [:] + + private var isLoading: Bool = true { + didSet { + if isLoading { + tableView.startGhostAnimation(style: GhostCellStyle.muriel) + } else { + tableView.stopGhostAnimation() + } + + tableView.reloadData() + } + } + + private var previewViewSelectedPreviewDevice = PreviewDevice.default + + private var ghostThumbnailSize: CGSize { + return SiteDesignCategoryThumbnailSize.recommended.value + } + + // MARK: Helper Footer View + override var allowCustomTableFooterView: Bool { + true + } + + override var alwaysResetHeaderOnRotation: Bool { + true + } + + private lazy var helperView: UIView = { + let view = UIView(frame: Metrics.helperFrame) + view.addSubview(helperStackView) + view.pinSubviewToAllEdges(helperStackView) + return view + }() + + private lazy var helperStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [helperSeparator, helperContentView]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + return stackView + }() + + private lazy var helperSeparator: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .separator + return view + }() + + private lazy var helperContentView: UIView = { + let view = UIView() + view.addSubview(helperContentStackView) + view.pinSubviewToAllEdges(helperContentStackView, insets: Metrics.helperPadding) + return view + }() + + private lazy var helperContentStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [helperImageStackView, helperLabelStackView]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = Metrics.helperSpacing + return stackView + }() + + private lazy var helperImageStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [helperImageView, UIView()]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + return stackView + }() + + private lazy var helperImageView: UIImageView = { + let imageView = UIImageView(image: .gridicon(.infoOutline)) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = .secondaryLabel + return imageView + }() + + private lazy var helperLabelStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [helperLabel, UIView()]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + return stackView + }() + + private lazy var helperLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = WPStyleGuide.fontForTextStyle(.body) + label.numberOfLines = Metrics.helperTextNumberOfLines + label.text = TextContent.helperText + label.textColor = .secondaryLabel + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = Metrics.helperTextMinimumScaleFactor + return label + }() + + private func activateHelperConstraints() { + NSLayoutConstraint.activate([ + helperImageView.heightAnchor.constraint(equalToConstant: Metrics.helperImageWidth), + helperImageView.widthAnchor.constraint(equalToConstant: Metrics.helperImageWidth), + helperSeparator.heightAnchor.constraint(equalToConstant: .hairlineBorderWidth) + ]) + } + + private func setupHelperView() { + tableView.tableFooterView = helperView + activateHelperConstraints() + + helperSeparator.isHidden = traitCollection.preferredContentSizeCategory.isAccessibilityCategory + helperContentView.isHidden = traitCollection.preferredContentSizeCategory.isAccessibilityCategory + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + helperSeparator.isHidden = traitCollection.preferredContentSizeCategory.isAccessibilityCategory + helperContentView.isHidden = traitCollection.preferredContentSizeCategory.isAccessibilityCategory + } + + let selectedPreviewDevice = PreviewDevice.mobile + + init(creator: SiteCreator, createsSite: Bool, completion: @escaping SiteDesignStep.SiteDesignSelection) { + self.creator = creator + self.createsSite = createsSite + self.completion = completion + tableView = UITableView(frame: .zero, style: .plain) + + super.init( + scrollableView: tableView, + mainTitle: TextContent.mainTitle, + // the primary action button is never shown + primaryActionTitle: "" + ) + + tableView.separatorStyle = .singleLine + tableView.separatorInset = .zero + tableView.showsVerticalScrollIndicator = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.register(CategorySectionTableViewCell.nib, forCellReuseIdentifier: CategorySectionTableViewCell.cellReuseIdentifier) + tableView.dataSource = self + navigationItem.backButtonTitle = TextContent.backButtonTitle + configureHeaderStyling() + configureCloseButton() + configureSkipButton() + SiteCreationAnalyticsHelper.trackSiteDesignViewed(previewMode: selectedPreviewDevice) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + fetchSiteDesigns() + } + + private func fetchSiteDesigns() { + if let sectionAssembler = sectionAssembler { + self.sections = sectionAssembler(creator.vertical) + return + } + + isLoading = true + + DispatchQueue.main.async { + SiteDesignSectionLoader.buildAssembler { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let assembler): + self.sectionAssembler = assembler + self.dismissNoResultsController() + self.sections = assembler(self.creator.vertical) + self.setupHelperView() + case .failure(let error): + self.handleError(error) + } + + self.isLoading = false + } + } + } + + private func configureHeaderStyling() { + headerView.backgroundColor = .systemBackground + hideHeaderVisualEffects() + } + + private func configureSkipButton() { + let skip = UIBarButtonItem(title: TextContent.skipButtonTitle, style: .done, target: self, action: #selector(skipButtonTapped)) + navigationItem.rightBarButtonItem = skip + } + + private func configureCloseButton() { + guard navigationController?.viewControllers.first == self else { + return + } + + navigationItem.leftBarButtonItem = UIBarButtonItem(title: TextContent.cancelButtonTitle, style: .done, target: self, action: #selector(closeButtonTapped)) + } + + @objc + private func closeButtonTapped(_ sender: Any) { + dismiss(animated: true) + } + + @objc + private func skipButtonTapped(_ sender: Any) { + presentedViewController?.dismiss(animated: true) + SiteCreationAnalyticsHelper.trackSiteDesignSkipped() + completion(nil) + } + + private func handleError(_ error: Error) { + SiteCreationAnalyticsHelper.trackError(error) + let titleText = TextContent.errorTitle + let subtitleText = TextContent.errorSubtitle + displayNoResultsController(title: titleText, subtitle: subtitleText, resultsDelegate: self) + } + + private enum TextContent { + static let mainTitle = NSLocalizedString("Choose a theme", + comment: "Title for the screen to pick a theme and homepage for a site.") + static let backButtonTitle = NSLocalizedString("Design", + comment: "Shortened version of the main title to be used in back navigation.") + static let skipButtonTitle = NSLocalizedString("Skip", + comment: "Continue without making a selection.") + static let cancelButtonTitle = NSLocalizedString("Cancel", + comment: "Cancel site creation.") + static let errorTitle = NSLocalizedString("Unable to load this content right now.", + comment: "Informing the user that a network request failed because the device wasn't able to establish a network connection.") + static let errorSubtitle = NSLocalizedString("Check your network connection and try again.", + comment: "Default subtitle for no-results when there is no connection.") + static let helperText = NSLocalizedString("Can’t decide? You can change the theme at any time.", + comment: "Helper text that appears at the bottom of the design screen.") + } + + private enum Metrics { + // Frame of the bottom helper: width will be automatically calucuated by assigning it to tableview.tableFooterView + static let helperFrame = CGRect(x: 0, y: 0, width: 0, height: 90) + static let helperImageWidth: CGFloat = 24.0 + static let helperPadding = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + static let helperSpacing: CGFloat = 16 + + static let helperTextNumberOfLines = 2 + static let helperTextMinimumScaleFactor: CGFloat = 0.6 + } +} + +// MARK: - NoResultsViewControllerDelegate + +extension SiteDesignContentCollectionViewController: NoResultsViewControllerDelegate { + + func actionButtonPressed() { + fetchSiteDesigns() + } +} + +// MARK: - UITableViewDataSource + +extension SiteDesignContentCollectionViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return isLoading ? 1 : (sections.count) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cellReuseIdentifier = CategorySectionTableViewCell.cellReuseIdentifier + guard let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseIdentifier, for: indexPath) as? CategorySectionTableViewCell else { + fatalError("Expected the cell with identifier \"\(cellReuseIdentifier)\" to be a \(CategorySectionTableViewCell.self). Please make sure the table view is registering the correct nib before loading the data") + } + cell.delegate = self + cell.selectionStyle = UITableViewCell.SelectionStyle.none + + if isLoading { + cell.section = nil + cell.isGhostCell = true + cell.ghostThumbnailSize = ghostThumbnailSize + cell.collectionView.allowsSelection = false + } else { + let section = sections[indexPath.row] + cell.section = section + cell.isGhostCell = false + cell.collectionView.allowsSelection = true + cell.horizontalScrollOffset = sectionHorizontalOffsets[section.categorySlug] ?? .zero + } + + cell.showsCheckMarkWhenSelected = false + cell.layer.masksToBounds = false + cell.clipsToBounds = false + cell.categoryTitleFont = WPStyleGuide.serifFontForTextStyle(.title2, fontWeight: .semibold) + return cell + } +} + +// MARK: - CategorySectionTableViewCellDelegate + +extension SiteDesignContentCollectionViewController: CategorySectionTableViewCellDelegate { + + func didSelectItemAt(_ position: Int, forCell cell: CategorySectionTableViewCell, slug: String) { + guard let sectionIndex = sections.firstIndex(where: { $0.categorySlug == slug }) else { return } + let section = sections[sectionIndex] + let design = section.designs[position] + let sectionType = section.sectionType + + let previewVC = SiteDesignPreviewViewController( + siteDesign: design, + selectedPreviewDevice: previewViewSelectedPreviewDevice, + createsSite: createsSite, + sectionType: sectionType, + onDismissWithDeviceSelected: { [weak self] device in + self?.previewViewSelectedPreviewDevice = device + }, + completion: completion + ) + + let navController = GutenbergLightNavigationController(rootViewController: previewVC) + navController.modalPresentationStyle = .pageSheet + navigationController?.present(navController, animated: true) { + // deselect so no border is shown on dismissal of the preview + cell.deselectItems() + } + } + + func didDeselectItem(forCell cell: CategorySectionTableViewCell) {} + + func accessibilityElementDidBecomeFocused(forCell cell: CategorySectionTableViewCell) { + guard UIAccessibility.isVoiceOverRunning, let cellIndexPath = tableView.indexPath(for: cell) else { return } + tableView.scrollToRow(at: cellIndexPath, at: .middle, animated: true) + } + + func saveHorizontalScrollPosition(forCell cell: CategorySectionTableViewCell, xPosition: CGFloat) { + guard let cellSection = cell.section else { + return + } + sectionHorizontalOffsets[cellSection.categorySlug] = xPosition + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignSection.swift b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignSection.swift new file mode 100644 index 000000000000..1f0ed9f7f680 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignSection.swift @@ -0,0 +1,42 @@ +import Foundation + +struct SiteDesignSection: CategorySection { + var designs: [RemoteSiteDesign] + var thumbnailSize: CGSize + + var caption: String? + var categorySlug: String + var title: String + var emoji: String? + var description: String? + var thumbnails: [Thumbnail] { designs } + + var sectionType: SiteDesignSectionType = .standard +} + +extension SiteDesignSection { + init(category: RemoteSiteDesignCategory, + designs: [RemoteSiteDesign], + thumbnailSize: CGSize, + sectionType: SiteDesignSectionType = .standard) { + + self.designs = designs + self.thumbnailSize = thumbnailSize + self.categorySlug = category.slug + self.title = category.title + self.emoji = category.emoji + self.description = category.description + self.sectionType = sectionType + } +} + +extension SiteDesignSection: Equatable { + static func == (lhs: SiteDesignSection, rhs: SiteDesignSection) -> Bool { + lhs.categorySlug == rhs.categorySlug + } +} + +enum SiteDesignSectionType { + case recommended + case standard +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignSectionLoader.swift b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignSectionLoader.swift new file mode 100644 index 000000000000..599f331234c1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignSectionLoader.swift @@ -0,0 +1,137 @@ +import Foundation +import WordPressKit + +struct SiteDesignSectionLoader { + typealias Assembler = ((SiteIntentVertical?) -> [SiteDesignSection]) + + /// Builds a site design section assembler based on data fetched from the API. + /// + /// - Parameter completion: A closure providing an assembler function or an error. + static func buildAssembler(completion: @escaping (Result) -> Void) { + fetchRemoteDesigns { result in + switch result { + case .success(let remoteDesigns): + let categorySections = getCategorySectionsForRemoteSiteDesigns(remoteDesigns) + + completion(.success({ vertical in + assembleSections( + categorySections: categorySections, + remoteDesigns: remoteDesigns, + vertical: vertical + ) + })) + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// Fetches remote designs from the API + /// + /// - Parameter completion: A closure providing remote site designs or an error. + static func fetchRemoteDesigns(completion: @escaping (Result) -> Void) { + typealias TemplateGroup = SiteDesignRequest.TemplateGroup + let templateGroups: [TemplateGroup] = FeatureFlag.betaSiteDesigns.enabled ? [.stable, .beta] : [] + + let restAPI = WordPressComRestApi.anonymousApi( + userAgent: WPUserAgent.wordPress(), + localeKey: WordPressComRestApi.LocaleKeyV2 + ) + + let request = SiteDesignRequest( + withThumbnailSize: SiteDesignCategoryThumbnailSize.recommended.value, + withGroups: templateGroups + ) + + SiteDesignServiceRemote.fetchSiteDesigns(restAPI, request: request) { result in + switch result { + case .success(let designs): + completion(.success(designs)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// Returns a single recommended section of designs whose `group` property contains a vertical's slug. + /// + /// - Parameters: + /// - vertical: A Site Intent vertical. + /// - remoteDesigns: Remote Site Designs. + /// - Returns: A `SiteDesignSection` if there was a match, otherwise `nil`. + static func getRecommendedSectionForVertical(_ vertical: SiteIntentVertical, remoteDesigns: RemoteSiteDesigns) -> SiteDesignSection? { + let designsForVertical = remoteDesigns.designs.filter({ + $0.group? + .map { $0.lowercased() } + .contains(vertical.slug.lowercased()) ?? false + }) + + guard !designsForVertical.isEmpty else { + return nil + } + + return SiteDesignSection( + designs: designsForVertical, + thumbnailSize: SiteDesignCategoryThumbnailSize.recommended.value, + caption: TextContent.recommendedCaption, + categorySlug: "recommended_" + vertical.slug, + title: String(format: TextContent.recommendedTitle, vertical.localizedTitle), + sectionType: .recommended + ) + } + + /// Gets `SiteDesignSection`s for the supplied `RemoteSiteDesigns` + /// + /// - If there are no designs for a category, it won't be included. + /// - Order of designs for each category are randomized, but the order of categories is not. + /// + /// - Parameter remoteDesigns: Remote Site Designs. + /// - Returns: Array of Site Design sections with the designs randomized. + static func getCategorySectionsForRemoteSiteDesigns(_ remoteDesigns: RemoteSiteDesigns) -> [SiteDesignSection] { + return remoteDesigns.categories.map { category in + SiteDesignSection( + category: category, + designs: remoteDesigns.randomizedDesignsForCategory(category), + thumbnailSize: SiteDesignCategoryThumbnailSize.category.value + ) + }.filter { !$0.designs.isEmpty } + } + + /// Assembles Site Design sections by placing a single larger recommended section above category sections. + /// + /// - If designs aren't found for a supplied vertical, it will attempt to find designs for a fallback category. + /// - If designs aren't found for the fallback category, the recommended section won't be included. + /// + /// - Parameters: + /// - remoteDesigns: Remote Site Designs. + /// - vertical: An optional Site Intent vertical. + /// - Returns: An array of Site Design sections. + static func assembleSections(categorySections: [SiteDesignSection], remoteDesigns: RemoteSiteDesigns, vertical: SiteIntentVertical?) -> [SiteDesignSection] { + if let vertical = vertical, let recommendedVertical = getRecommendedSectionForVertical(vertical, remoteDesigns: remoteDesigns) { + // Recommended designs for the vertical were found + return [recommendedVertical] + categorySections + } + + if var recommendedFallback = categorySections.first(where: { $0.categorySlug.lowercased() == "blog" }) { + // Recommended designs for the vertical weren't found, so we used the fallback category + recommendedFallback.title = String(format: TextContent.recommendedTitle, "Blogging") + recommendedFallback.thumbnailSize = SiteDesignCategoryThumbnailSize.recommended.value + recommendedFallback.sectionType = .recommended + recommendedFallback.caption = TextContent.recommendedCaption + return [recommendedFallback] + categorySections.filter { $0 != recommendedFallback } + } + + // No recommended designs were found + return categorySections + } +} + +private extension SiteDesignSectionLoader { + + enum TextContent { + static let recommendedTitle = NSLocalizedString("Best for %@", + comment: "Title for a section of recommended site designs. The %@ will be replaced with the related site intent topic, such as Food or Blogging.") + static let recommendedCaption = NSLocalizedString("PICKED FOR YOU", + comment: "Caption for the recommended sections in site designs.") + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignStep.swift b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignStep.swift new file mode 100644 index 000000000000..a4bcdc7df20d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Design Selection/SiteDesignStep.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Site Creation: Allows selection of the home page which translates to the initial theme as well. +final class SiteDesignStep: WizardStep { + typealias SiteDesignSelection = (_ design: RemoteSiteDesign?) -> Void + weak var delegate: WizardDelegate? + private let creator: SiteCreator + private let isLastStep: Bool + + private(set) lazy var content: UIViewController = { + return SiteDesignContentCollectionViewController(creator: creator, createsSite: isLastStep) { [weak self] (design) in + self?.didSelect(design) + } + }() + + init(creator: SiteCreator, isLastStep: Bool) { + self.creator = creator + self.isLastStep = isLastStep + } + + private func didSelect(_ design: RemoteSiteDesign?) { + creator.design = design + delegate?.nextStep() + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/AssembledSiteView.swift b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/AssembledSiteView.swift similarity index 95% rename from WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/AssembledSiteView.swift rename to WordPress/Classes/ViewRelated/Site Creation/Final Assembly/AssembledSiteView.swift index 14ec160b46f5..0e9fa3fd2f50 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/AssembledSiteView.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/AssembledSiteView.swift @@ -27,6 +27,8 @@ final class AssembledSiteView: UIView { /// This value displays in the address bar. private let siteName: String + private let siteCreator: SiteCreator + /// This value is what the web view loads. private let siteURLString: String @@ -74,9 +76,10 @@ final class AssembledSiteView: UIView { /// The designated initializer. /// /// - Parameter domainName: the domain associated with the site pending assembly. - init(domainName: String, siteURLString: String) { + init(domainName: String, siteURLString: String, siteCreator: SiteCreator) { self.siteName = domainName self.siteURLString = siteURLString + self.siteCreator = siteCreator textField = { let textField = UITextField(frame: .zero) @@ -98,7 +101,7 @@ final class AssembledSiteView: UIView { }() self.activityIndicator = { - let activityIndicator = UIActivityIndicatorView(style: .whiteLarge) + let activityIndicator = UIActivityIndicatorView(style: .large) activityIndicator.translatesAutoresizingMaskIntoConstraints = false activityIndicator.hidesWhenStopped = true @@ -140,7 +143,7 @@ final class AssembledSiteView: UIView { self.initialSiteRequest = siteRequest generator.prepare() - + webView.customUserAgent = WPUserAgent.wordPress() webView.load(siteRequest) } @@ -194,7 +197,7 @@ extension AssembledSiteView: UIGestureRecognizerDelegate { extension AssembledSiteView: WKNavigationDelegate { func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - WPAnalytics.track(.enhancedSiteCreationSuccessPreviewViewed) + SiteCreationAnalyticsHelper.trackSiteCreationSuccessPreviewViewed(siteCreator.design) } func webView(_ webView: WKWebView, decidePolicyFor: WKNavigationAction, decisionHandler: (WKNavigationActionPolicy) -> Void) { @@ -208,9 +211,8 @@ extension AssembledSiteView: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { webViewHasLoadedContent = true activityIndicator.stopAnimating() - webView.prepareWPComPreview() generator.notificationOccurred(.success) - WPAnalytics.track(.enhancedSiteCreationSuccessPreviewLoaded) + SiteCreationAnalyticsHelper.trackSiteCreationSuccessLoaded(siteCreator.design) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { diff --git a/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/LandInTheEditorHelper.swift b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/LandInTheEditorHelper.swift new file mode 100644 index 000000000000..1e11cdbcaa47 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/LandInTheEditorHelper.swift @@ -0,0 +1,39 @@ +typealias HomepageEditorCompletion = () -> Void + +class LandInTheEditorHelper { + /// Land in the editor, or continue as usual - Used to branch on the feature flag for landing in the editor from the site creation flow + /// - Parameter blog: Blog (which was just created) for which to show the home page editor + /// - Parameter navigationController: UINavigationController used to present the home page editor + /// - Parameter completion: HomepageEditorCompletion callback to be invoked after the user finishes editing the home page, or immediately iwhen the feature flag is disabled + static func landInTheEditorOrContinue(for blog: Blog, navigationController: UINavigationController, completion: @escaping HomepageEditorCompletion) { + // branch here for feature flag + if FeatureFlag.landInTheEditor.enabled { + landInTheEditor(for: blog, navigationController: navigationController, completion: completion) + } else { + completion() + } + } + + private static func landInTheEditor(for blog: Blog, navigationController: UINavigationController, completion: @escaping HomepageEditorCompletion) { + fetchAllPages(for: blog, success: { _ in + DispatchQueue.main.async { + if let homepage = blog.homepage { + let editorViewController = EditPageViewController(homepage: homepage, completion: completion) + navigationController.present(editorViewController, animated: false) + WPAnalytics.track(.landingEditorShown) + } + } + }, failure: { _ in + NSLog("Fetching all pages failed after site creation!") + }) + } + + // This seems to be necessary before casting `AbstractPost` to `Page`. + private static func fetchAllPages(for blog: Blog, success: @escaping PostServiceSyncSuccess, failure: @escaping PostServiceSyncFailure) { + let options = PostServiceSyncOptions() + options.number = 20 + let context = ContextManager.sharedInstance().mainContext + let postService = PostService(managedObjectContext: context) + postService.syncPosts(ofType: .page, with: options, for: blog, success: success, failure: failure) + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteAssemblyContentView.swift b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteAssemblyContentView.swift new file mode 100644 index 000000000000..a33543678f00 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteAssemblyContentView.swift @@ -0,0 +1,557 @@ +import Foundation +import UIKit +import Gridicons +import WordPressShared + +// MARK: SiteAssemblyContentView + +/// This view is intended for use as the root view of `SiteAssemblyWizardContent`. +/// It manages the state transitions that occur as a site is assembled via remote service dialogue. +final class SiteAssemblyContentView: UIView { + + // MARK: Properties + + /// A collection of parameters uses for animation & layout of the view. + private struct Parameters { + static let animationDuration = TimeInterval(0.5) + static let buttonContainerScaleFactor = CGFloat(2) + static let horizontalMargin = CGFloat(30) + static let verticalSpacing = CGFloat(30) + static let statusStackViewSpacing = CGFloat(16) + static let checkmarkImageSize = CGSize(width: 18, height: 18) + static let checkmarkImageColor = UIColor.muriel(color: .success, .shade20) + } + + /// This influences the top of the completion label as it animates into place. + private var completionLabelTopConstraint: NSLayoutConstraint? + + /// This advises the user that the site creation request completed successfully. + private(set) var completionLabel: UILabel + + private let completionDescription: UILabel = { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.numberOfLines = 0 + $0.font = WPStyleGuide.fontForTextStyle(.body) + $0.textColor = .text + return $0 + }(UILabel()) + + private lazy var completionLabelsStack: UIStackView = { + $0.addArrangedSubviews([completionLabel, completionDescription]) + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .vertical + $0.spacing = 24 + return $0 + }(UIStackView()) + + private let footnoteLabel: UILabel = { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.numberOfLines = 0 + $0.font = WPStyleGuide.fontForTextStyle(.footnote) + $0.textColor = .text + let footerText = NSLocalizedString( + "domain.purchase.preview.footer", + value: "It may take up to 30 minutes for your custom domain to start working.", + comment: "Domain Purchase Completion footer" + ) + $0.text = footerText + return $0 + }(UILabel()) + + /// This provides the user with some playful words while their site is being assembled + private let statusTitleLabel: UILabel + + /// This provides the user with some expectation while the site is being assembled + private let statusSubtitleLabel: UILabel + + /// This displays an image while the site is being assembled + private let statusImageView: UIImageView + + /// This advises the user that the site creation request is underway. + private let statusMessageRotatingView: SiteCreationRotatingMessageView + + /// The loading indicator provides an indeterminate view of progress as the site is being created. + private let activityIndicator: UIActivityIndicatorView + + /// The stack view manages the appearance of a status label and a loading indicator. + private(set) var statusStackView: UIStackView + + /// This influences the top of the assembled site, which varies by device & orientation. + private var assembledSiteTopConstraint: NSLayoutConstraint? + + /// This influences the width of the assembled site, which varies by device & orientation. + private var assembledSiteWidthConstraint: NSLayoutConstraint? + + /// This is a representation of the assembled site. + private(set) var assembledSiteView: AssembledSiteView? + + /// This constraint influences the presentation of the Done button as it animates into view. + private var buttonContainerBottomConstraint: NSLayoutConstraint? + + /// We adjust the button container view slightly to account for the Home indicator ("unsafe") region on the device. + private var buttonContainerContainer: UIView? + + /// The button container view is associated with the root view of a `NUXButtonViewController` + var buttonContainerView: UIView? { + didSet { + installButtonContainerView() + } + } + + /// The view apprising the user of an error encountering during the site assembly attempt. + var errorStateView: UIView? { + didSet { + installErrorStateView() + } + } + + /// The full address of the created site. + var siteURLString: String? + + /// The domain name is applied to the appearance of the created site. + var siteName: String? { + didSet { + installAssembledSiteView() + } + } + + /// The status of site assembly. As the state advances, the view updates in concert. + var status: SiteAssemblyStatus = .idle { + // Start and stop the message rotation + willSet { + switch newValue { + case .inProgress: + statusMessageRotatingView.startAnimating() + + default: + statusMessageRotatingView.stopAnimating() + } + } + + didSet { + setNeedsLayout() + } + } + + let siteCreator: SiteCreator + + var isFreeDomain: Bool? + private func shouldShowDomainPurchase() -> Bool { + if let isFreeDomain = isFreeDomain { + return !isFreeDomain + } + return siteCreator.shouldShowDomainCheckout + } + + // MARK: SiteAssemblyContentView + + /// The designated initializer. + init(siteCreator: SiteCreator) { + self.siteCreator = siteCreator + + self.completionLabel = { + let label = UILabel() + + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + + label.font = WPStyleGuide.fontForTextStyle(.title1, fontWeight: .bold) + label.textColor = .text + + if siteCreator.domainPurchasingEnabled { + label.textAlignment = .natural + } else { + label.textAlignment = .center + } + + label.text = Strings.Free.completionTitle + label.accessibilityLabel = Strings.Free.completionTitle + + return label + }() + + self.statusTitleLabel = { + let label = UILabel() + + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + + label.font = WPStyleGuide.fontForTextStyle(.largeTitle, fontWeight: .bold) + label.textColor = .text + label.textAlignment = .center + + let statusText = NSLocalizedString("Hooray!\nAlmost done", + comment: "User-facing string, presented to reflect that site assembly is underway.") + label.text = statusText + label.accessibilityLabel = statusText + + return label + }() + + self.statusSubtitleLabel = { + let label = UILabel() + + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + + label.font = WPStyleGuide.fontForTextStyle(.title2) + label.textColor = .textSubtle + label.textAlignment = .center + + let statusText = NSLocalizedString("Your site will be ready shortly", + comment: "User-facing string, presented to reflect that site assembly is underway.") + label.text = statusText + label.accessibilityLabel = statusText + + return label + }() + + self.statusImageView = { + let image = UIImage(named: "site-creation-loading") + let imageView = UIImageView(image: image) + + return imageView + }() + + self.statusMessageRotatingView = { + //The rotating message view will automatically use the localized string based + //on the message + + let statusMessages = [ + NSLocalizedString("Grabbing site URL", + comment: "User-facing string, presented to reflect that site assembly is underway."), + + NSLocalizedString("Adding site features", + comment: "User-facing string, presented to reflect that site assembly is underway."), + + NSLocalizedString("Setting up theme", + comment: "User-facing string, presented to reflect that site assembly is underway."), + + NSLocalizedString("Creating dashboard", + comment: "User-facing string, presented to reflect that site assembly is underway."), + ] + + let icon: UIImage = { + let iconSize = Parameters.checkmarkImageSize + let tintColor = Parameters.checkmarkImageColor + let icon = UIImage.gridicon(.checkmark, size: iconSize) + + guard let tintedIcon = icon.imageWithTintColor(tintColor) else { + return icon + } + + return tintedIcon + }() + + return SiteCreationRotatingMessageView(messages: statusMessages, iconImage: icon) + }() + + self.activityIndicator = { + let activityIndicator = UIActivityIndicatorView(style: .large) + + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.hidesWhenStopped = true + activityIndicator.color = .textSubtle + activityIndicator.startAnimating() + + return activityIndicator + }() + + self.statusStackView = { + let stackView = UIStackView() + + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.alignment = .center + stackView.axis = .vertical + stackView.spacing = Parameters.statusStackViewSpacing + + return stackView + }() + + super.init(frame: .zero) + + configure() + } + + /// This method is intended to be called by its owning view controller when constraints change. + func adjustConstraints() { + guard let assembledSitePreferredSize = assembledSiteView?.preferredSize, + let widthConstraint = assembledSiteWidthConstraint else { + + return + } + + widthConstraint.constant = assembledSitePreferredSize.width + layoutIfNeeded() + } + + // MARK: UIView + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + switch status { + case .idle: + layoutIdle() + case .inProgress: + layoutInProgress() + case .failed: + layoutFailed() + case .succeeded: + layoutSucceeded() + } + } + + // MARK: Private behavior + + private func configure() { + translatesAutoresizingMaskIntoConstraints = true + autoresizingMask = [ .flexibleWidth, .flexibleHeight ] + + backgroundColor = .listBackground + + statusStackView.addArrangedSubviews([ statusTitleLabel, statusSubtitleLabel, statusImageView, statusMessageRotatingView, activityIndicator ]) + addSubviews([completionLabelsStack, statusStackView, footnoteLabel]) + + // Increase the spacing around the illustration + statusStackView.setCustomSpacing(Parameters.verticalSpacing, after: statusSubtitleLabel) + statusStackView.setCustomSpacing(Parameters.verticalSpacing, after: statusImageView) + + let completionLabelTopInsetInitial = Parameters.verticalSpacing * 2 + let completionLabelInitialTopConstraint = completionLabelsStack.topAnchor.constraint(equalTo: prevailingLayoutGuide.topAnchor, constant: completionLabelTopInsetInitial) + self.completionLabelTopConstraint = completionLabelInitialTopConstraint + + NSLayoutConstraint.activate([ + completionLabelInitialTopConstraint, + completionLabelsStack.leadingAnchor.constraint(equalTo: prevailingLayoutGuide.leadingAnchor, constant: Parameters.horizontalMargin), + prevailingLayoutGuide.trailingAnchor.constraint(equalTo: completionLabelsStack.trailingAnchor, constant: Parameters.horizontalMargin), + completionLabelsStack.centerXAnchor.constraint(equalTo: centerXAnchor), + statusStackView.leadingAnchor.constraint(equalTo: prevailingLayoutGuide.leadingAnchor, constant: Parameters.horizontalMargin), + prevailingLayoutGuide.trailingAnchor.constraint(equalTo: statusStackView.trailingAnchor, constant: Parameters.horizontalMargin), + statusStackView.centerXAnchor.constraint(equalTo: centerXAnchor), + statusStackView.centerYAnchor.constraint(equalTo: centerYAnchor), + footnoteLabel.leadingAnchor.constraint(equalTo: completionLabelsStack.leadingAnchor), + completionLabelsStack.trailingAnchor.constraint(equalTo: footnoteLabel.trailingAnchor) + ]) + } + + private func installAssembledSiteView() { + guard let siteName = siteName, let siteURLString = siteURLString else { + return + } + + if let assembledSiteView { + assembledSiteView.removeFromSuperview() + } + + let assembledSiteView = AssembledSiteView(domainName: siteName, siteURLString: siteURLString, siteCreator: siteCreator) + addSubview(assembledSiteView) + + if let buttonContainer = buttonContainerContainer { + bringSubviewToFront(buttonContainer) + } + + let initialSiteTopConstraint = assembledSiteView.topAnchor.constraint(equalTo: bottomAnchor) + self.assembledSiteTopConstraint = initialSiteTopConstraint + + let assembledSiteTopInset = Parameters.verticalSpacing + + let preferredAssembledSiteSize = assembledSiteView.preferredSize + + let assembledSiteWidthConstraint = assembledSiteView.widthAnchor.constraint(equalToConstant: preferredAssembledSiteSize.width) + self.assembledSiteWidthConstraint = assembledSiteWidthConstraint + + let assembledSiteViewBottomConstraint: NSLayoutConstraint + if shouldShowDomainPurchase() { + assembledSiteView.layer.cornerRadius = 12 + assembledSiteView.layer.masksToBounds = true + assembledSiteViewBottomConstraint = footnoteLabel.topAnchor.constraint( + equalTo: assembledSiteView.bottomAnchor, + constant: 24 + ) + + completionLabel.text = Strings.Paid.completionTitle + completionLabel.accessibilityLabel = Strings.Paid.completionTitle + completionDescription.text = Strings.Paid.description + } else { + assembledSiteViewBottomConstraint = assembledSiteView.bottomAnchor.constraint( + equalTo: buttonContainerView?.topAnchor ?? bottomAnchor + ) + completionDescription.text = Strings.Free.description + } + + NSLayoutConstraint.activate([ + initialSiteTopConstraint, + assembledSiteView.topAnchor.constraint(greaterThanOrEqualTo: completionLabelsStack.bottomAnchor, constant: assembledSiteTopInset), + assembledSiteViewBottomConstraint, + assembledSiteView.centerXAnchor.constraint(equalTo: centerXAnchor), + assembledSiteWidthConstraint, + (buttonContainerView?.topAnchor ?? bottomAnchor).constraint(equalTo: footnoteLabel.bottomAnchor, constant: 15) + ]) + + self.assembledSiteView = assembledSiteView + } + + private func installButtonContainerView() { + guard let buttonContainerView = buttonContainerView else { + return + } + + buttonContainerView.backgroundColor = .basicBackground + + // This wrapper view provides underlap for Home indicator + let buttonContainerContainer = UIView(frame: .zero) + buttonContainerContainer.translatesAutoresizingMaskIntoConstraints = false + buttonContainerContainer.backgroundColor = .basicBackground + buttonContainerContainer.addSubview(buttonContainerView) + addSubview(buttonContainerContainer) + self.buttonContainerContainer = buttonContainerContainer + + let buttonContainerHeight = buttonContainerView.bounds.height + let safelyOffscreen = Parameters.buttonContainerScaleFactor * buttonContainerHeight + let bottomConstraint = buttonContainerView.bottomAnchor.constraint(equalTo: prevailingLayoutGuide.bottomAnchor, constant: safelyOffscreen) + self.buttonContainerBottomConstraint = bottomConstraint + + NSLayoutConstraint.activate([ + buttonContainerView.topAnchor.constraint(equalTo: buttonContainerContainer.topAnchor), + buttonContainerView.leadingAnchor.constraint(equalTo: buttonContainerContainer.leadingAnchor), + buttonContainerView.trailingAnchor.constraint(equalTo: buttonContainerContainer.trailingAnchor), + buttonContainerContainer.heightAnchor.constraint(equalTo: buttonContainerView.heightAnchor, multiplier: Parameters.buttonContainerScaleFactor), + buttonContainerContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + buttonContainerContainer.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomConstraint, + ]) + } + + private func installErrorStateView() { + guard let errorStateView = errorStateView else { + return + } + + errorStateView.alpha = 0 + addSubview(errorStateView) + + NSLayoutConstraint.activate([ + errorStateView.leadingAnchor.constraint(equalTo: leadingAnchor), + errorStateView.trailingAnchor.constraint(equalTo: trailingAnchor), + errorStateView.topAnchor.constraint(equalTo: topAnchor), + errorStateView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + private func layoutIdle() { + completionLabel.isHidden = true + completionDescription.isHidden = true + footnoteLabel.isHidden = true + statusStackView.alpha = 0 + errorStateView?.alpha = 0 + } + + private func layoutInProgress() { + UIView.animate(withDuration: Parameters.animationDuration, delay: 0, options: .curveEaseOut, animations: { [weak self] in + guard let self = self else { + return + } + self.errorStateView?.alpha = 0 + self.statusStackView.alpha = 1 + self.accessibilityElements = [ self.statusMessageRotatingView.statusLabel ] + }) + } + + private func layoutFailed() { + UIView.animate(withDuration: Parameters.animationDuration, delay: 0, options: .curveEaseOut, animations: { [weak self] in + guard let self = self else { + return + } + + self.statusStackView.alpha = 0 + + if let errorView = self.errorStateView { + errorView.alpha = 1 + self.accessibilityElements = [ errorView ] + } + }) + } + + private func layoutSucceeded() { + assembledSiteView?.loadSiteIfNeeded() + + UIView.animate(withDuration: Parameters.animationDuration, delay: 0, options: .curveEaseOut, animations: { [statusStackView] in + statusStackView.alpha = 0 + }, completion: { [weak self] completed in + guard completed, let self = self else { + return + } + + let completionLabelTopInsetFinal = Parameters.verticalSpacing + self.completionLabelTopConstraint?.constant = completionLabelTopInsetFinal + + self.assembledSiteTopConstraint?.isActive = false + let transitionConstraint = self.assembledSiteView?.topAnchor.constraint( + equalTo: self.completionLabelsStack.bottomAnchor, + constant: Parameters.verticalSpacing + ) + transitionConstraint?.isActive = true + self.assembledSiteTopConstraint = transitionConstraint + + self.buttonContainerBottomConstraint?.constant = 0 + + UIView.animate(withDuration: Parameters.animationDuration, + delay: 0, + options: .curveEaseOut, + animations: { [weak self] in + guard let self = self else { + return + } + + self.completionLabel.isHidden = false + self.completionDescription.isHidden = false + self.completionLabel.text = self.shouldShowDomainPurchase() ? Strings.Paid.completionTitle : Strings.Free.completionTitle + self.completionDescription.text = self.shouldShowDomainPurchase() ? Strings.Paid.description : Strings.Free.description + self.footnoteLabel.isHidden = !self.shouldShowDomainPurchase() + + + if let buttonView = self.buttonContainerView { + self.accessibilityElements = [ self.completionLabel, buttonView ] + } else { + self.accessibilityElements = [ self.completionLabel ] + } + + self.layoutIfNeeded() + }) + }) + } +} + +private enum Strings { + enum Paid { + static let completionTitle = NSLocalizedString( + "domain.purchase.preview.title", + value: "Kudos, your site is live!", + comment: "Reflects that site is live when domain purchase feature flag is ON." + ) + + static let description = NSLocalizedString( + "domain.purchase.preview.paid.description", + value: "We’ve emailed your receipt. Next, we'll help you get it ready for everyone.", + comment: "Domain Purchase Completion description (only for PAID domains)." + ) + } + + enum Free { + static let completionTitle = NSLocalizedString( + "Your site has been created!", + comment: "User-facing string, presented to reflect that site assembly completed successfully." + ) + static let description = NSLocalizedString( + "domain.purchase.preview.free.description", + value: "Next, we'll help you get it ready to be browsed.", + comment: "Domain Purchase Completion description (only for FREE domains)." + ) + + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteAssemblyStep.swift b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteAssemblyStep.swift new file mode 100644 index 000000000000..876a12e70113 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteAssemblyStep.swift @@ -0,0 +1,34 @@ + +import UIKit + +/// Site Creation: Site Assembly +final class SiteAssemblyStep: WizardStep { + + // MARK: Properties + + /// The creator collects user input as they advance through the wizard flow. + private let creator: SiteCreator + + /// The service with which the final assembly interacts to coordinate site creation. + private let service: SiteAssemblyService + + // MARK: WizardStep + + let content: UIViewController + + weak var delegate: WizardDelegate? = nil + + // MARK: SiteAssemblyStep + + /// The designated initializer. + /// + /// - Parameters: + /// - creator: the in-flight creation instance + /// - service: the service to use for initiating site creation + /// - onDismiss: the closure to be executed upon dismissal of the SiteAssemblyWizardContent + init(creator: SiteCreator, service: SiteAssemblyService, onDismiss: ((Blog, Bool) -> Void)? = nil) { + self.creator = creator + self.service = service + self.content = SiteAssemblyWizardContent(creator: creator, service: service, onDismiss: onDismiss) + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteAssemblyWizardContent.swift b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteAssemblyWizardContent.swift new file mode 100644 index 000000000000..dd40bb58336b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteAssemblyWizardContent.swift @@ -0,0 +1,337 @@ + +import UIKit + +import WordPressAuthenticator + +// MARK: - SiteAssemblyWizardContent + +/// This view controller manages the final step in the enhanced site creation sequence - invoking the service & +/// apprising the user of the outcome. +final class SiteAssemblyWizardContent: UIViewController { + + // MARK: Properties + + /// The creator collects user input as they advance through the wizard flow. + private let siteCreator: SiteCreator + + /// The service with which the final assembly interacts to coordinate site creation. + private let service: SiteAssemblyService + + /// Displays the domain checkout web view. + private lazy var domainPurchasingController = DomainPurchasingWebFlowController(viewController: self) + + /// The new `Blog`, if successfully created; `nil` otherwise. + private var createdBlog: Blog? + + /// The content view serves as the root view of this view controller. + private let contentView: SiteAssemblyContentView + + /// We reuse a `NUXButtonViewController` from `WordPressAuthenticator`. Ideally this might be in `WordPressUI`. + private let buttonViewController = NUXButtonViewController.instance() + + /// This view controller manages the interaction with error states that can arise during site assembly. + private var errorStateViewController: ErrorStateViewController? + + /// Locally tracks the network connection status via `NetworkStatusDelegate` + private var isNetworkActive = ReachabilityUtils.isInternetReachable() + + /// UseDefaults helper for quick start settings + private let quickStartSettings: QuickStartSettings + + /// Closure to be executed upon dismissal + private let onDismiss: ((Blog, Bool) -> Void)? + + // MARK: SiteAssemblyWizardContent + + /// The designated initializer. + /// + /// - Parameters: + /// - creator: the in-flight creation instance + /// - service: the service to use for initiating site creation + /// - quickStartSettings: the UserDefaults helper for quick start settings + /// - onDismiss: the closure to be executed upon dismissal + init(creator: SiteCreator, + service: SiteAssemblyService, + quickStartSettings: QuickStartSettings = QuickStartSettings(), + onDismiss: ((Blog, Bool) -> Void)? = nil) { + self.siteCreator = creator + self.service = service + self.quickStartSettings = quickStartSettings + self.onDismiss = onDismiss + self.contentView = SiteAssemblyContentView(siteCreator: siteCreator) + + super.init(nibName: nil, bundle: nil) + } + + // MARK: UIViewController + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + super.loadView() + view = contentView + } + + override func viewDidLoad() { + super.viewDidLoad() + + hidesBottomBarWhenPushed = true + installButtonViewController() + SiteCreationAnalyticsHelper.trackSiteCreationSuccessLoading(siteCreator.design) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + navigationController?.isNavigationBarHidden = true + setNeedsStatusBarAppearanceUpdate() + + observeNetworkStatus() + + if service.currentStatus == .idle { + attemptSiteCreation() + } + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + coordinator.animate(alongsideTransition: { [weak self] _ in + self?.contentView.adjustConstraints() + }) + } + + // MARK: Private behavior + + private func attemptSiteCreation() { + let creationRequest = siteCreator.build() + let shouldPerformDomainPurchasingStep = siteCreator.shouldShowDomainCheckout + service.createSite(creationRequest: creationRequest) { [weak self] status in + guard let self = self else { + return + } + + if status == .failed { + let errorType: ErrorStateViewType + if self.isNetworkActive == false { + errorType = .networkUnreachable + } else { + errorType = .siteLoading + } + self.installErrorStateViewController(with: errorType) + } else if status == .succeeded { + let blog = self.service.createdBlog + // Default all new blogs to use Gutenberg + if let createdBlog = blog { + let gutenbergSettings = GutenbergSettings() + gutenbergSettings.softSetGutenbergEnabled(true, for: createdBlog, source: .onSiteCreation) + gutenbergSettings.postSettingsToRemote(for: createdBlog) + } + + self.contentView.siteURLString = blog?.url as String? + self.contentView.siteName = blog?.displayURL as String? + self.createdBlog = blog + + // This stat is part of a funnel that provides critical information. Before + // making ANY modification to this stat please refer to: p4qSXL-35X-p2 + SiteCreationAnalyticsHelper.trackSiteCreationSuccess(self.siteCreator.design) + } + if status == .succeeded, + shouldPerformDomainPurchasingStep, + let domain = self.siteCreator.address, + let blog = self.createdBlog { + self.attemptDomainPurchasing(domain: domain, site: blog) + } else { + self.contentView.status = status + } + } + } + + /// The site must be created before attempting domain purchasing. + private func attemptDomainPurchasing(domain: DomainSuggestion, site: Blog) { + self.domainPurchasingController.purchase(domain: domain, site: site) { [weak self] result in + guard let self else { + return + } + switch result { + case .success(let domain): + self.contentView.siteName = domain + self.contentView.isFreeDomain = false + self.contentView.status = .succeeded + case .failure(let error): + self.contentView.isFreeDomain = true + switch error { + case .unsupportedRedirect, .internal, .invalidInput, .other: + self.installDomainCheckoutErrorStateViewController(domain: domain, site: site) + self.contentView.status = .failed + case .canceled: + self.contentView.status = .succeeded + } + } + } + } + + private func installButtonViewController() { + buttonViewController.delegate = self + + let primaryButtonText = NSLocalizedString("Done", + comment: "Tapping a button with this label allows the user to exit the Site Creation flow") + buttonViewController.setButtonTitles(primary: primaryButtonText) + + contentView.buttonContainerView = buttonViewController.view + + buttonViewController.willMove(toParent: self) + addChild(buttonViewController) + buttonViewController.didMove(toParent: self) + } + + private func installErrorStateViewController(with type: ErrorStateViewType) { + var configuration = ErrorStateViewConfiguration.configuration(type: type) + + configuration.retryActionHandler = { [weak self] in + guard let self = self else { + return + } + self.retryTapped() + } + + self.installErrorStateViewController(with: configuration) + } + + private func installDomainCheckoutErrorStateViewController(domain: DomainSuggestion, site: Blog) { + var configuration = ErrorStateViewConfiguration.configuration(type: .domainCheckoutFailed) + + configuration.retryActionHandler = { [weak self] in + guard let self else { + return + } + self.contentView.status = .inProgress + self.attemptDomainPurchasing(domain: domain, site: site) + } + + self.installErrorStateViewController(with: configuration) + } + + private func installErrorStateViewController(with configuration: ErrorStateViewConfiguration) { + var configuration = configuration + + if configuration.contactSupportActionHandler == nil { + configuration.contactSupportActionHandler = { [weak self] in + guard let self = self else { + return + } + self.contactSupportTapped() + } + } + + if configuration.dismissalActionHandler == nil { + configuration.dismissalActionHandler = { [weak self] in + guard let self = self else { + return + } + self.dismissTapped() + } + } + + // Remove previous error state view controller + if let errorStateViewController { + errorStateViewController.willMove(toParent: nil) + errorStateViewController.view?.removeFromSuperview() + errorStateViewController.removeFromParent() + errorStateViewController.didMove(toParent: nil) + } + + // Install new error state view controller + let errorStateViewController = ErrorStateViewController(with: configuration) + + self.contentView.errorStateView = errorStateViewController.view + + errorStateViewController.willMove(toParent: self) + addChild(errorStateViewController) + errorStateViewController.didMove(toParent: self) + + self.errorStateViewController = errorStateViewController + } +} + +// MARK: ErrorStateViewController support + +private extension SiteAssemblyWizardContent { + func contactSupportTapped() { + // TODO : capture analytics event via #10335 + let supportVC = SupportTableViewController() + supportVC.show(from: self) + } + + func dismissTapped(viaDone: Bool = false, completion: (() -> Void)? = nil) { + // TODO : using viaDone, capture analytics event via #10335 + navigationController?.dismiss(animated: true, completion: completion) + } + + func retryTapped(viaDone: Bool = false) { + // TODO : using viaDone, capture analytics event via #10335 + attemptSiteCreation() + } +} + +// MARK: - NetworkStatusDelegate + +extension SiteAssemblyWizardContent: NetworkStatusDelegate { + func networkStatusDidChange(active: Bool) { + isNetworkActive = active + } +} + +// MARK: - NUXButtonViewControllerDelegate + +extension SiteAssemblyWizardContent: NUXButtonViewControllerDelegate { + func primaryButtonPressed() { + SiteCreationAnalyticsHelper.trackSiteCreationSuccessPreviewOkButtonTapped() + + guard let blog = createdBlog, let navigationController = navigationController else { + return + } + + LandInTheEditorHelper.landInTheEditorOrContinue(for: blog, navigationController: navigationController) { [weak self] in + + guard let self = self else { + return + } + + if let onDismiss = self.onDismiss { + let quickstartPrompt = QuickStartPromptViewController(blog: blog) + quickstartPrompt.onDismiss = onDismiss + navigationController.pushViewController(quickstartPrompt, animated: true) + return + } + + self.dismissTapped(viaDone: true) { [blog, weak self] in + RootViewCoordinator.sharedPresenter.showBlogDetails(for: blog) + + // present quick start, and mark site title as complete if they already selected one + guard let self = self else { + return + } + let completedSteps: [QuickStartTour] = self.siteCreator.hasSiteTitle ? [QuickStartSiteTitleTour(blog: blog)] : [] + self.showQuickStartPrompt(for: blog, completedSteps: completedSteps) + } + } + } + + private func showQuickStartPrompt(for blog: Blog, completedSteps: [QuickStartTour] = []) { + guard !quickStartSettings.promptWasDismissed(for: blog) else { + return + } + + let rootViewController = RootViewCoordinator.sharedPresenter.rootViewController + let quickstartPrompt = QuickStartPromptViewController(blog: blog) + quickstartPrompt.onDismiss = { blog, showQuickStart in + if showQuickStart { + QuickStartTourGuide.shared.setupWithDelay(for: blog, type: .newSite, withCompletedSteps: completedSteps) + } + } + rootViewController.present(quickstartPrompt, animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteCreationRequest+Validation.swift b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteCreationRequest+Validation.swift new file mode 100644 index 000000000000..2b5654bda330 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteCreationRequest+Validation.swift @@ -0,0 +1,75 @@ + +import Foundation +import WordPressKit + +// MARK: SiteCreationRequest + +extension SiteCreationRequest { + + // MARK: Unvalidated + + /// Convenience initializer, which applies sensible defaults for language ID, client ID, client secret and timezone. + /// The request is marked as pending validation. + /// + /// - Parameters: + /// - segmentIdentifier: the segment ID for the pending site + /// - siteDesign: the starter site's slug for the pending site + /// - verticalIdentifier: the vertical ID for the pending site + /// - title: title of the pending site for the pending site + /// - tagline: tagline for the pending site + /// - siteURLString: the URL string for the pending site + /// - isPublic: whether or not the pending site should be public + /// - siteCreationFlow: string that identifies the site creation flow + /// - findAvailableURL: true if the site creation should find an available url from the provided `siteURLString` + /// + init(segmentIdentifier: Int64?, + siteDesign: String?, + verticalIdentifier: String?, + title: String, + tagline: String?, + siteURLString: String, + isPublic: Bool, + siteCreationFlow: String?, + findAvailableURL: Bool) { + + self.init(segmentIdentifier: segmentIdentifier, + siteDesign: siteDesign, + verticalIdentifier: verticalIdentifier, + title: title, + tagline: tagline, + siteURLString: siteURLString, + isPublic: true, + languageIdentifier: WordPressComLanguageDatabase().deviceLanguageIdNumber().stringValue, + shouldValidate: true, + clientIdentifier: ApiCredentials.client, + clientSecret: ApiCredentials.secret, + timezoneIdentifier: TimeZone.autoupdatingCurrent.identifier, + siteCreationFlow: siteCreationFlow, + findAvailableURL: findAvailableURL + ) + } + + // MARK: Validated + + /// Convenience initializer, intended to mark a pending site creation as previously validated. + /// + /// - Parameter request: the original request for the pending site + /// + init(request: SiteCreationRequest) { + self.init(segmentIdentifier: request.segmentIdentifier, + siteDesign: request.siteDesign, + verticalIdentifier: request.verticalIdentifier, + title: request.title, + tagline: request.tagline, + siteURLString: request.siteURLString, + isPublic: request.isPublic, + languageIdentifier: request.languageIdentifier, + shouldValidate: false, + clientIdentifier: request.clientIdentifier, + clientSecret: request.clientSecret, + timezoneIdentifier: request.timezoneIdentifier, + siteCreationFlow: request.siteCreationFlow, + findAvailableURL: request.findAvailableURL + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteCreationRotatingMessageView.swift b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteCreationRotatingMessageView.swift similarity index 97% rename from WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteCreationRotatingMessageView.swift rename to WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteCreationRotatingMessageView.swift index 37139683fc99..22f630a3527b 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteCreationRotatingMessageView.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Final Assembly/SiteCreationRotatingMessageView.swift @@ -138,8 +138,7 @@ class SiteCreationRotatingMessageView: UIView { /// Updates the status label/accessiblity label with the provided text /// - Parameter message: The text to be displayed internal func updateStatus(message: String) { - statusLabel.text = NSLocalizedString(message, - comment: "User-facing string, presented to reflect that site assembly is underway.") + statusLabel.text = message } // MARK: - Private diff --git a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteAssemblyContentView.swift b/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteAssemblyContentView.swift deleted file mode 100644 index 1f31640219d9..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteAssemblyContentView.swift +++ /dev/null @@ -1,447 +0,0 @@ - -import UIKit -import Gridicons -import WordPressShared - -// MARK: SiteAssemblyContentView - -/// This view is intended for use as the root view of `SiteAssemblyWizardContent`. -/// It manages the state transitions that occur as a site is assembled via remote service dialogue. -final class SiteAssemblyContentView: UIView { - - // MARK: Properties - - /// A collection of parameters uses for animation & layout of the view. - private struct Parameters { - static let animationDuration = TimeInterval(0.5) - static let buttonContainerScaleFactor = CGFloat(2) - static let horizontalMargin = CGFloat(30) - static let verticalSpacing = CGFloat(30) - static let statusStackViewSpacing = CGFloat(16) - static let checkmarkImageSize = CGSize(width: 18, height: 18) - static let checkmarkImageColor = UIColor.muriel(color: .success, .shade20) - } - - /// This influences the top of the completion label as it animates into place. - private var completionLabelTopConstraint: NSLayoutConstraint? - - /// This advises the user that the site creation request completed successfully. - private(set) var completionLabel: UILabel - - /// This provides the user with some playful words while their site is being assembled - private let statusTitleLabel: UILabel - - /// This provides the user with some expectation while the site is being assembled - private let statusSubtitleLabel: UILabel - - /// This displays an image while the site is being assembled - private let statusImageView: UIImageView - - /// This advises the user that the site creation request is underway. - private let statusMessageRotatingView: SiteCreationRotatingMessageView - - /// The loading indicator provides an indeterminate view of progress as the site is being created. - private let activityIndicator: UIActivityIndicatorView - - /// The stack view manages the appearance of a status label and a loading indicator. - private(set) var statusStackView: UIStackView - - /// This influences the top of the assembled site, which varies by device & orientation. - private var assembledSiteTopConstraint: NSLayoutConstraint? - - /// This influences the width of the assembled site, which varies by device & orientation. - private var assembledSiteWidthConstraint: NSLayoutConstraint? - - /// This is a representation of the assembled site. - private(set) var assembledSiteView: AssembledSiteView? - - /// This constraint influences the presentation of the Done button as it animates into view. - private var buttonContainerBottomConstraint: NSLayoutConstraint? - - /// We adjust the button container view slightly to account for the Home indicator ("unsafe") region on the device. - private var buttonContainerContainer: UIView? - - /// The button container view is associated with the root view of a `NUXButtonViewController` - var buttonContainerView: UIView? { - didSet { - installButtonContainerView() - } - } - - /// The view apprising the user of an error encountering during the site assembly attempt. - var errorStateView: UIView? { - didSet { - installErrorStateView() - } - } - - /// The full address of the created site. - var siteURLString: String? - - /// The domain name is applied to the appearance of the created site. - var siteName: String? { - didSet { - installAssembledSiteView() - } - } - - /// The status of site assembly. As the state advances, the view updates in concert. - var status: SiteAssemblyStatus = .idle { - // Start and stop the message rotation - willSet { - switch newValue { - case .inProgress: - statusMessageRotatingView.startAnimating() - - default: - statusMessageRotatingView.stopAnimating() - } - } - - didSet { - setNeedsLayout() - } - } - - // MARK: SiteAssemblyContentView - - /// The designated initializer. - init() { - self.completionLabel = { - let label = UILabel() - - label.translatesAutoresizingMaskIntoConstraints = false - label.numberOfLines = 0 - - label.font = WPStyleGuide.fontForTextStyle(.title1, fontWeight: .bold) - label.textColor = .text - label.textAlignment = .center - - let createdText = NSLocalizedString("Your site has been created!", - comment: "User-facing string, presented to reflect that site assembly completed successfully.") - label.text = createdText - label.accessibilityLabel = createdText - - return label - }() - - self.statusTitleLabel = { - let label = UILabel() - - label.translatesAutoresizingMaskIntoConstraints = false - label.numberOfLines = 0 - - label.font = WPStyleGuide.fontForTextStyle(.largeTitle, fontWeight: .bold) - label.textColor = .text - label.textAlignment = .center - - let statusText = NSLocalizedString("Hooray!\nAlmost done", - comment: "User-facing string, presented to reflect that site assembly is underway.") - label.text = statusText - label.accessibilityLabel = statusText - - return label - }() - - self.statusSubtitleLabel = { - let label = UILabel() - - label.translatesAutoresizingMaskIntoConstraints = false - label.numberOfLines = 0 - - label.font = WPStyleGuide.fontForTextStyle(.title2) - label.textColor = .textSubtle - label.textAlignment = .center - - let statusText = NSLocalizedString("Your site will be ready shortly", - comment: "User-facing string, presented to reflect that site assembly is underway.") - label.text = statusText - label.accessibilityLabel = statusText - - return label - }() - - self.statusImageView = { - let image = UIImage(named: "site-creation-loading") - let imageView = UIImageView(image: image) - - return imageView - }() - - self.statusMessageRotatingView = { - //The rotating message view will automatically use the localized string based - //on the message - - let statusMessages = [ - NSLocalizedString("Grabbing site URL", - comment: "User-facing string, presented to reflect that site assembly is underway."), - - NSLocalizedString("Adding site features", - comment: "User-facing string, presented to reflect that site assembly is underway."), - - NSLocalizedString("Setting up theme", - comment: "User-facing string, presented to reflect that site assembly is underway."), - - NSLocalizedString("Creating dashboard", - comment: "User-facing string, presented to reflect that site assembly is underway."), - ] - - let icon: UIImage = { - let iconSize = Parameters.checkmarkImageSize - let tintColor = Parameters.checkmarkImageColor - let icon = Gridicon.iconOfType(.checkmark, withSize: iconSize) - - guard let tintedIcon = icon.imageWithTintColor(tintColor) else { - return icon - } - - return tintedIcon - }() - - return SiteCreationRotatingMessageView(messages: statusMessages, iconImage: icon) - }() - - self.activityIndicator = { - let activityIndicator = UIActivityIndicatorView(style: .whiteLarge) - - activityIndicator.translatesAutoresizingMaskIntoConstraints = false - activityIndicator.hidesWhenStopped = true - activityIndicator.color = .textSubtle - activityIndicator.startAnimating() - - return activityIndicator - }() - - self.statusStackView = { - let stackView = UIStackView() - - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.alignment = .center - stackView.axis = .vertical - stackView.spacing = Parameters.statusStackViewSpacing - - return stackView - }() - - super.init(frame: .zero) - - configure() - } - - /// This method is intended to be called by its owning view controller when constraints change. - func adjustConstraints() { - guard let assembledSitePreferredSize = assembledSiteView?.preferredSize, - let widthConstraint = assembledSiteWidthConstraint else { - - return - } - - widthConstraint.constant = assembledSitePreferredSize.width - layoutIfNeeded() - } - - // MARK: UIView - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - - switch status { - case .idle: - layoutIdle() - case .inProgress: - layoutInProgress() - case .failed: - layoutFailed() - case .succeeded: - layoutSucceeded() - } - } - - // MARK: Private behavior - - private func configure() { - translatesAutoresizingMaskIntoConstraints = true - autoresizingMask = [ .flexibleWidth, .flexibleHeight ] - - backgroundColor = .listBackground - - statusStackView.addArrangedSubviews([ statusTitleLabel, statusSubtitleLabel, statusImageView, statusMessageRotatingView, activityIndicator ]) - addSubviews([ completionLabel, statusStackView ]) - - // Increase the spacing around the illustration - statusStackView.setCustomSpacing(Parameters.verticalSpacing, after: statusSubtitleLabel) - statusStackView.setCustomSpacing(Parameters.verticalSpacing, after: statusImageView) - - let completionLabelTopInsetInitial = Parameters.verticalSpacing * 2 - let completionLabelInitialTopConstraint = completionLabel.topAnchor.constraint(equalTo: prevailingLayoutGuide.topAnchor, constant: completionLabelTopInsetInitial) - self.completionLabelTopConstraint = completionLabelInitialTopConstraint - - NSLayoutConstraint.activate([ - completionLabelInitialTopConstraint, - completionLabel.leadingAnchor.constraint(equalTo: prevailingLayoutGuide.leadingAnchor, constant: Parameters.horizontalMargin), - completionLabel.trailingAnchor.constraint(equalTo: prevailingLayoutGuide.trailingAnchor, constant: -Parameters.horizontalMargin), - completionLabel.centerXAnchor.constraint(equalTo: centerXAnchor), - statusStackView.leadingAnchor.constraint(equalTo: prevailingLayoutGuide.leadingAnchor, constant: Parameters.horizontalMargin), - statusStackView.trailingAnchor.constraint(equalTo: prevailingLayoutGuide.trailingAnchor, constant: -Parameters.horizontalMargin), - statusStackView.centerXAnchor.constraint(equalTo: centerXAnchor), - statusStackView.centerYAnchor.constraint(equalTo: centerYAnchor) - ]) - } - - private func installAssembledSiteView() { - guard let siteName = siteName, let siteURLString = siteURLString else { - return - } - - let assembledSiteView = AssembledSiteView(domainName: siteName, siteURLString: siteURLString) - addSubview(assembledSiteView) - - if let buttonContainer = buttonContainerContainer { - bringSubviewToFront(buttonContainer) - } - - let initialSiteTopConstraint = assembledSiteView.topAnchor.constraint(equalTo: bottomAnchor) - self.assembledSiteTopConstraint = initialSiteTopConstraint - - let assembledSiteTopInset = Parameters.verticalSpacing - - let preferredAssembledSiteSize = assembledSiteView.preferredSize - - let assembledSiteWidthConstraint = assembledSiteView.widthAnchor.constraint(equalToConstant: preferredAssembledSiteSize.width) - self.assembledSiteWidthConstraint = assembledSiteWidthConstraint - - NSLayoutConstraint.activate([ - initialSiteTopConstraint, - assembledSiteView.topAnchor.constraint(greaterThanOrEqualTo: completionLabel.bottomAnchor, constant: assembledSiteTopInset), - assembledSiteView.bottomAnchor.constraint(equalTo: buttonContainerView?.topAnchor ?? bottomAnchor), - assembledSiteView.centerXAnchor.constraint(equalTo: centerXAnchor), - assembledSiteWidthConstraint, - ]) - - self.assembledSiteView = assembledSiteView - } - - private func installButtonContainerView() { - guard let buttonContainerView = buttonContainerView else { - return - } - - buttonContainerView.backgroundColor = .basicBackground - - // This wrapper view provides underlap for Home indicator - let buttonContainerContainer = UIView(frame: .zero) - buttonContainerContainer.translatesAutoresizingMaskIntoConstraints = false - buttonContainerContainer.backgroundColor = .basicBackground - buttonContainerContainer.addSubview(buttonContainerView) - addSubview(buttonContainerContainer) - self.buttonContainerContainer = buttonContainerContainer - - let buttonContainerHeight = buttonContainerView.bounds.height - let safelyOffscreen = Parameters.buttonContainerScaleFactor * buttonContainerHeight - let bottomConstraint = buttonContainerView.bottomAnchor.constraint(equalTo: prevailingLayoutGuide.bottomAnchor, constant: safelyOffscreen) - self.buttonContainerBottomConstraint = bottomConstraint - - NSLayoutConstraint.activate([ - buttonContainerView.topAnchor.constraint(equalTo: buttonContainerContainer.topAnchor), - buttonContainerView.leadingAnchor.constraint(equalTo: buttonContainerContainer.leadingAnchor), - buttonContainerView.trailingAnchor.constraint(equalTo: buttonContainerContainer.trailingAnchor), - buttonContainerContainer.heightAnchor.constraint(equalTo: buttonContainerView.heightAnchor, multiplier: Parameters.buttonContainerScaleFactor), - buttonContainerContainer.leadingAnchor.constraint(equalTo: leadingAnchor), - buttonContainerContainer.trailingAnchor.constraint(equalTo: trailingAnchor), - bottomConstraint, - ]) - } - - private func installErrorStateView() { - guard let errorStateView = errorStateView else { - return - } - - errorStateView.alpha = 0 - addSubview(errorStateView) - - NSLayoutConstraint.activate([ - errorStateView.leadingAnchor.constraint(equalTo: leadingAnchor), - errorStateView.trailingAnchor.constraint(equalTo: trailingAnchor), - errorStateView.topAnchor.constraint(equalTo: topAnchor), - errorStateView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - private func layoutIdle() { - completionLabel.alpha = 0 - statusStackView.alpha = 0 - errorStateView?.alpha = 0 - } - - private func layoutInProgress() { - UIView.animate(withDuration: Parameters.animationDuration, delay: 0, options: .curveEaseOut, animations: { [weak self] in - guard let self = self else { - return - } - self.errorStateView?.alpha = 0 - self.statusStackView.alpha = 1 - self.accessibilityElements = [ self.statusMessageRotatingView.statusLabel ] - }) - } - - private func layoutFailed() { - UIView.animate(withDuration: Parameters.animationDuration, delay: 0, options: .curveEaseOut, animations: { [weak self] in - guard let self = self else { - return - } - - self.statusStackView.alpha = 0 - - if let errorView = self.errorStateView { - errorView.alpha = 1 - self.accessibilityElements = [ errorView ] - } - }) - } - - private func layoutSucceeded() { - assembledSiteView?.loadSiteIfNeeded() - - UIView.animate(withDuration: Parameters.animationDuration, delay: 0, options: .curveEaseOut, animations: { [statusStackView] in - statusStackView.alpha = 0 - }, completion: { [weak self] completed in - guard completed, let self = self else { - return - } - - let completionLabelTopInsetFinal = Parameters.verticalSpacing - self.completionLabelTopConstraint?.constant = completionLabelTopInsetFinal - - self.assembledSiteTopConstraint?.isActive = false - let transitionConstraint = self.assembledSiteView?.topAnchor.constraint(equalTo: self.completionLabel.bottomAnchor, constant: Parameters.verticalSpacing) - transitionConstraint?.isActive = true - self.assembledSiteTopConstraint = transitionConstraint - - self.buttonContainerBottomConstraint?.constant = 0 - - UIView.animate(withDuration: Parameters.animationDuration, - delay: 0, - options: .curveEaseOut, - animations: { [weak self] in - guard let self = self else { - return - } - - self.completionLabel.alpha = 1 - - if let buttonView = self.buttonContainerView { - self.accessibilityElements = [ self.completionLabel, buttonView ] - } else { - self.accessibilityElements = [ self.completionLabel ] - } - - self.layoutIfNeeded() - }) - }) - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteAssemblyStep.swift b/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteAssemblyStep.swift deleted file mode 100644 index 608d7cf1901e..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteAssemblyStep.swift +++ /dev/null @@ -1,33 +0,0 @@ - -import UIKit - -/// Site Creation, Last screen: Site Assembly. -final class SiteAssemblyStep: WizardStep { - - // MARK: Properties - - /// The creator collects user input as they advance through the wizard flow. - private let creator: SiteCreator - - /// The service with which the final assembly interacts to coordinate site creation. - private let service: SiteAssemblyService - - // MARK: WizardStep - - let content: UIViewController - - var delegate: WizardDelegate? = nil - - // MARK: SiteAssemblyStep - - /// The designated initializer. - /// - /// - Parameters: - /// - creator: the in-flight creation instance - /// - service: the service to use for initiating site creation - init(creator: SiteCreator, service: SiteAssemblyService) { - self.creator = creator - self.service = service - self.content = SiteAssemblyWizardContent(creator: creator, service: service) - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteAssemblyWizardContent.swift b/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteAssemblyWizardContent.swift deleted file mode 100644 index 1e6a67dcf5ab..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteAssemblyWizardContent.swift +++ /dev/null @@ -1,246 +0,0 @@ - -import UIKit - -import WordPressAuthenticator - -// MARK: - SiteAssemblyWizardContent - -/// This view controller manages the final step in the enhanced site creation sequence - invoking the service & -/// apprising the user of the outcome. -final class SiteAssemblyWizardContent: UIViewController { - - // MARK: Properties - - /// The creator collects user input as they advance through the wizard flow. - private let siteCreator: SiteCreator - - /// The service with which the final assembly interacts to coordinate site creation. - private let service: SiteAssemblyService - - /// The new `Blog`, if successfully created; `nil` otherwise. - private var createdBlog: Blog? - - /// The content view serves as the root view of this view controller. - private let contentView = SiteAssemblyContentView() - - /// We reuse a `NUXButtonViewController` from `WordPressAuthenticator`. Ideally this might be in `WordPressUI`. - private let buttonViewController = NUXButtonViewController.instance() - - /// This view controller manages the interaction with error states that can arise during site assembly. - private var errorStateViewController: ErrorStateViewController? - - /// Locally tracks the network connection status via `NetworkStatusDelegate` - private var isNetworkActive = ReachabilityUtils.isInternetReachable() - - // MARK: SiteAssemblyWizardContent - - /// The designated initializer. - /// - /// - Parameters: - /// - creator: the in-flight creation instance - /// - service: the service to use for initiating site creation - init(creator: SiteCreator, service: SiteAssemblyService) { - self.siteCreator = creator - self.service = service - - super.init(nibName: nil, bundle: nil) - } - - // MARK: UIViewController - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var preferredStatusBarStyle: UIStatusBarStyle { - return .default - } - - override func loadView() { - super.loadView() - view = contentView - } - - override func viewDidLoad() { - super.viewDidLoad() - - hidesBottomBarWhenPushed = true - installButtonViewController() - WPAnalytics.track(.enhancedSiteCreationSuccessLoading) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - navigationController?.isNavigationBarHidden = true - setNeedsStatusBarAppearanceUpdate() - - observeNetworkStatus() - - if service.currentStatus == .idle { - attemptSiteCreation() - } - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - coordinator.animate(alongsideTransition: { [weak self] _ in - self?.contentView.adjustConstraints() - }) - } - - // MARK: Private behavior - - private func attemptSiteCreation() { - do { - let creationRequest = try siteCreator.build() - - service.createSite(creationRequest: creationRequest) { [weak self] status in - guard let self = self else { - return - } - - if status == .failed { - let errorType: ErrorStateViewType - if self.isNetworkActive == false { - errorType = .networkUnreachable - } else { - errorType = .siteLoading - } - self.installErrorStateViewController(with: errorType) - } else if status == .succeeded { - let blog = self.service.createdBlog - // Default all new blogs to use Gutenberg - if let createdBlog = blog { - let gutenbergSettings = GutenbergSettings() - gutenbergSettings.softSetGutenbergEnabled(true, for: createdBlog, source: .onSiteCreation) - gutenbergSettings.postSettingsToRemote(for: createdBlog) - } - - self.contentView.siteURLString = blog?.url as String? - self.contentView.siteName = blog?.displayURL as String? - self.createdBlog = blog - - // This stat is part of a funnel that provides critical information. Before - // making ANY modification to this stat please refer to: p4qSXL-35X-p2 - WPAnalytics.track(.createdSite) - } - - self.contentView.status = status - } - } catch { - DDLogError("Unable to proceed in Site Creation flow due to an unexpected error") - assertionFailure(error.localizedDescription) - } - } - - private func installButtonViewController() { - buttonViewController.delegate = self - - let primaryButtonText = NSLocalizedString("Done", - comment: "Tapping a button with this label allows the user to exit the Site Creation flow") - buttonViewController.setButtonTitles(primary: primaryButtonText) - - contentView.buttonContainerView = buttonViewController.view - - buttonViewController.willMove(toParent: self) - addChild(buttonViewController) - buttonViewController.didMove(toParent: self) - } - - private func installErrorStateViewController(with type: ErrorStateViewType) { - var configuration = ErrorStateViewConfiguration.configuration(type: type) - - configuration.contactSupportActionHandler = { [weak self] in - guard let self = self else { - return - } - self.contactSupportTapped() - } - - configuration.retryActionHandler = { [weak self] in - guard let self = self else { - return - } - self.retryTapped() - } - - configuration.dismissalActionHandler = { [weak self] in - guard let self = self else { - return - } - self.dismissTapped() - } - - let errorStateViewController = ErrorStateViewController(with: configuration) - - contentView.errorStateView = errorStateViewController.view - - errorStateViewController.willMove(toParent: self) - addChild(errorStateViewController) - errorStateViewController.didMove(toParent: self) - - self.errorStateViewController = errorStateViewController - } -} - -// MARK: ErrorStateViewController support - -private extension SiteAssemblyWizardContent { - func contactSupportTapped() { - // TODO : capture analytics event via #10335 - - let supportVC = SupportTableViewController() - supportVC.showFromTabBar() - } - - func dismissTapped(viaDone: Bool = false, completion: (() -> Void)? = nil) { - // TODO : using viaDone, capture analytics event via #10335 - navigationController?.dismiss(animated: true, completion: completion) - } - - func retryTapped(viaDone: Bool = false) { - // TODO : using viaDone, capture analytics event via #10335 - attemptSiteCreation() - } -} - -// MARK: - NetworkStatusDelegate - -extension SiteAssemblyWizardContent: NetworkStatusDelegate { - func networkStatusDidChange(active: Bool) { - isNetworkActive = active - } -} - -// MARK: - NUXButtonViewControllerDelegate - -extension SiteAssemblyWizardContent: NUXButtonViewControllerDelegate { - func primaryButtonPressed() { - dismissTapped(viaDone: true) { [createdBlog, weak self] in - guard let blog = createdBlog else { - return - } - WPAnalytics.track(.enhancedSiteCreationSuccessPreviewOkButtonTapped) - WPTabBarController.sharedInstance().switchMySitesTabToBlogDetails(for: blog) - - self?.showQuickStartAlert(for: blog) - } - } - - private func showQuickStartAlert(for blog: Blog) { - guard !UserDefaults.standard.quickStartWasDismissedPermanently else { - return - } - - guard let tabBar = WPTabBarController.sharedInstance() else { - return - } - - let fancyAlert = FancyAlertViewController.makeQuickStartAlertController(blog: blog) - fancyAlert.modalPresentationStyle = .custom - fancyAlert.transitioningDelegate = tabBar - tabBar.present(fancyAlert, animated: true) - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteCreationRequest+Validation.swift b/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteCreationRequest+Validation.swift deleted file mode 100644 index e8a8444e61bb..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/FinalAssembly/SiteCreationRequest+Validation.swift +++ /dev/null @@ -1,61 +0,0 @@ - -import Foundation -import WordPressKit - -// MARK: SiteCreationRequest - -extension SiteCreationRequest { - - // MARK: Unvalidated - - /// Convenience initializer, which applies sensible defaults for language ID, client ID & client secret. - /// The request is marked as pending validation. - /// - /// - Parameters: - /// - segmentIdentifier: the segment ID for the pending site - /// - verticalIdentifier: the vertical ID for the pending site - /// - title: title of the pending site for the pending site - /// - tagline: tagline for the pending site - /// - siteURLString: the URL string for the pending site - /// - isPublic: whether or not the pending site should be public - /// - init(segmentIdentifier: Int64, - verticalIdentifier: String?, - title: String, - tagline: String?, - siteURLString: String, - isPublic: Bool) { - - self.init(segmentIdentifier: segmentIdentifier, - verticalIdentifier: verticalIdentifier, - title: title, - tagline: tagline, - siteURLString: siteURLString, - isPublic: true, - languageIdentifier: WordPressComLanguageDatabase().deviceLanguageIdNumber().stringValue, - shouldValidate: true, - clientIdentifier: ApiCredentials.client(), - clientSecret: ApiCredentials.secret() - ) - } - - // MARK: Validated - - /// Convenience initializer, intended to mark a pending site creation as previously validated. - /// - /// - Parameter request: the original request for the pending site - /// - init(request: SiteCreationRequest) { - self.init(segmentIdentifier: request.segmentIdentifier, - verticalIdentifier: request.verticalIdentifier, - title: request.title, - tagline: request.tagline, - siteURLString: request.siteURLString, - isPublic: request.isPublic, - languageIdentifier: request.languageIdentifier, - shouldValidate: false, - clientIdentifier: request.clientIdentifier, - clientSecret: request.clientSecret - ) - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/ErrorStateView.swift b/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/ErrorStateView.swift index 12c92a6d089a..b51995cbbfeb 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/ErrorStateView.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/ErrorStateView.swift @@ -151,7 +151,7 @@ final class ErrorStateView: UIView { if let _ = configuration.dismissalActionHandler { self.dismissalImageView = { - let dismissImage = Gridicon.iconOfType(.cross) + let dismissImage = UIImage.gridicon(.cross) let imageView = UIImageView(image: dismissImage) imageView.translatesAutoresizingMaskIntoConstraints = false diff --git a/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/ErrorStateViewConfiguration.swift b/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/ErrorStateViewConfiguration.swift index 7dd9b61d5d03..143cc5f0c91b 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/ErrorStateViewConfiguration.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/ErrorStateViewConfiguration.swift @@ -13,6 +13,7 @@ enum ErrorStateViewType { case general case networkUnreachable case siteLoading + case domainCheckoutFailed } // MARK: ErrorViewConfiguration @@ -53,7 +54,7 @@ struct ErrorStateViewConfiguration { extension ErrorStateViewType { var localizedTitle: String { switch self { - case .general, .siteLoading: + case .general, .siteLoading, .domainCheckoutFailed: return NSLocalizedString("There was a problem", comment: "This primary message message is displayed if a user encounters a general error.") case .networkUnreachable: @@ -69,6 +70,12 @@ extension ErrorStateViewType { comment: "This secondary message is displayed if a user encounters a general error.") case .networkUnreachable: return nil + case .domainCheckoutFailed: + return NSLocalizedString( + "site.creation.assembly.step.domain.checkout.error.subtitle", + value: "Your website has been created successfully, but we encountered an issue while preparing your custom domain for checkout. Please try again or contact support for assistance.", + comment: "The error message to show in the 'Site Creation > Assembly Step' when the domain checkout fails for unknown reasons." + ) } } } diff --git a/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/InlineErrorRetryTableViewCell.swift b/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/InlineErrorRetryTableViewCell.swift index 459b6f6ff954..2b9a374eb535 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/InlineErrorRetryTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/InlineErrorRetryTableViewCell.swift @@ -29,7 +29,7 @@ private class InlineErrorRetryTableViewCellAccessoryView: UIStackView { init() { self.retryImageView = { - let dismissImage = Gridicon.iconOfType(.refresh).imageWithTintColor(.primary) + let dismissImage = UIImage.gridicon(.refresh).imageWithTintColor(.primary) let imageView = UIImageView(image: dismissImage) imageView.translatesAutoresizingMaskIntoConstraints = false @@ -113,7 +113,7 @@ final class InlineErrorRetryTableViewCell: UITableViewCell, ReusableCell { // MARK: Internal behavior - func setMessage(_ message: InlineErrorMessage) { + func setMessage(_ message: String) { textLabel?.text = message } @@ -125,7 +125,7 @@ final class InlineErrorRetryTableViewCell: UITableViewCell, ReusableCell { label.textColor = .neutral(.shade40) } - let borderColor = UIColor.neutral(.shade10) + let borderColor = UIColor.divider addTopBorder(withColor: borderColor) addBottomBorder(withColor: borderColor) diff --git a/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/InlineErrorTableViewProvider.swift b/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/InlineErrorTableViewProvider.swift deleted file mode 100644 index ad7c2672a376..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/Shared/ErrorStates/InlineErrorTableViewProvider.swift +++ /dev/null @@ -1,74 +0,0 @@ - -// MARK: - InlineErrorTableViewProvider - -typealias InlineErrorMessage = String - -struct InlineErrorMessages { - - static let noConnection: InlineErrorMessage = NSLocalizedString("No connection", - comment: "Displayed during Site Creation, when searching for Verticals and the network is unavailable.") - - static let serverError: InlineErrorMessage = NSLocalizedString("There was a problem", - comment: "Displayed during Site Creation, when searching for Verticals and the server returns an error.") -} - -/// This table view provider fulfills the data source & delegate responsibilities for inline error cases. -/// It consists of a single cell with an error message and an accessory view with which the user can retry a search. -/// -final class InlineErrorTableViewProvider: NSObject, TableViewProvider { - - // MARK: InlineErrorTableViewProvider - - /// The table view serviced by this provider - private weak var tableView: UITableView? - - /// The message displayed in the empty state table view cell - private let message: InlineErrorMessage - - /// The closure to invoke when a row in the underlying table view has been selected - private let selectionHandler: CellSelectionHandler? - - /// Creates an EmptyVerticalsTableViewProvider. - /// - /// - Parameters: - /// - tableView: the table view to be managed - /// - message: the message to display in the cell in question - /// - selectionHandler: the retry action to perform when a cell is selected, if any - /// - init(tableView: UITableView, message: InlineErrorMessage, selectionHandler: CellSelectionHandler? = nil) { - self.tableView = tableView - self.message = message - self.selectionHandler = selectionHandler - - super.init() - - tableView.dataSource = self - tableView.delegate = self - tableView.reloadData() - } - - // MARK: UITableViewDataSource - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return 1 - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: InlineErrorRetryTableViewCell.cellReuseIdentifier()) as? InlineErrorRetryTableViewCell else { - - assertionFailure("This is a programming error - InlineErrorRetryTableViewCell has not been properly registered!") - return UITableViewCell() - } - - cell.setMessage(message) - - return cell - } - - // MARK: UITableViewDelegate - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - selectionHandler?(indexPath) - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/SiteInfo/ShadowView.swift b/WordPress/Classes/ViewRelated/Site Creation/Shared/ShadowView.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Site Creation/SiteInfo/ShadowView.swift rename to WordPress/Classes/ViewRelated/Site Creation/Shared/ShadowView.swift diff --git a/WordPress/Classes/ViewRelated/Site Creation/Shared/SiteCreationAnalyticsEvent.swift b/WordPress/Classes/ViewRelated/Site Creation/Shared/SiteCreationAnalyticsEvent.swift new file mode 100644 index 000000000000..f40209048265 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Shared/SiteCreationAnalyticsEvent.swift @@ -0,0 +1,5 @@ +import Foundation + +enum SiteCreationAnalyticsEvent: String { + case domainPurchasingExperiment = "site_creation_domain_purchasing_experiment" +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Shared/SiteCreationAnalyticsHelper.swift b/WordPress/Classes/ViewRelated/Site Creation/Shared/SiteCreationAnalyticsHelper.swift new file mode 100644 index 000000000000..40370021915f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Shared/SiteCreationAnalyticsHelper.swift @@ -0,0 +1,178 @@ +import Foundation +import WordPressKit +import AutomatticTracks + +extension Variation { + var tracksProperty: String { + switch self { + case .treatment: + return "treatment" + case .customTreatment: + return "custom_treatment" + case .control: + return "control" + } + } +} + +class SiteCreationAnalyticsHelper { + typealias PreviewDevice = PreviewDeviceSelectionViewController.PreviewDevice + + private static let siteDesignKey = "template" + private static let previewModeKey = "preview_mode" + private static let verticalSlugKey = "vertical_slug" + private static let verticalSearchTerm = "search_term" + private static let variationKey = "variation" + private static let siteNameKey = "site_name" + private static let recommendedKey = "recommended" + private static let customTreatmentNameKey = "custom_treatment_variation_name" + + // MARK: - Lifecycle + static func trackSiteCreationAccessed(source: String) { + WPAnalytics.track(.enhancedSiteCreationAccessed, withProperties: ["source": source]) + + if FeatureFlag.siteCreationDomainPurchasing.enabled { + let domainPurchasingExperimentProperties: [String: String] = { + var dict: [String: String] = [Self.variationKey: ABTest.siteCreationDomainPurchasing.variation.tracksProperty] + + if case let .customTreatment(name) = ABTest.siteCreationDomainPurchasing.variation { + dict[Self.customTreatmentNameKey] = name + } + + return dict + }() + Self.track(.domainPurchasingExperiment, properties: domainPurchasingExperimentProperties) + } + } + + // MARK: - Site Intent + static func trackSiteIntentViewed() { + WPAnalytics.track(.enhancedSiteCreationIntentQuestionViewed) + } + + static func trackSiteIntentSelected(_ vertical: SiteIntentVertical) { + let properties = [verticalSlugKey: vertical.slug] + let event: WPAnalyticsEvent = vertical.isCustom ? + .enhancedSiteCreationIntentQuestionCustomVerticalSelected : + .enhancedSiteCreationIntentQuestionVerticalSelected + + WPAnalytics.track(event, properties: properties) + } + + static func trackSiteIntentSearchFocused() { + WPAnalytics.track(.enhancedSiteCreationIntentQuestionSearchFocused) + } + + static func trackSiteIntentSkipped() { + WPAnalytics.track(.enhancedSiteCreationIntentQuestionSkipped) + } + + static func trackSiteIntentCanceled() { + WPAnalytics.track(.enhancedSiteCreationIntentQuestionCanceled) + } + + // MARK: - Site Name + static func trackSiteNameViewed() { + WPAnalytics.track(.enhancedSiteCreationSiteNameViewed) + } + + static func trackSiteNameEntered(_ name: String) { + let properties = [siteNameKey: name] + WPAnalytics.track(.enhancedSiteCreationSiteNameEntered, properties: properties) + } + + static func trackSiteNameSkipped() { + WPAnalytics.track(.enhancedSiteCreationSiteNameSkipped) + } + + static func trackSiteNameCanceled() { + WPAnalytics.track(.enhancedSiteCreationSiteNameCanceled) + } + + // MARK: - Site Design + static func trackSiteDesignViewed(previewMode: PreviewDevice) { + WPAnalytics.track(.enhancedSiteCreationSiteDesignViewed, withProperties: commonProperties(previewMode)) + } + + static func trackSiteDesignSkipped() { + WPAnalytics.track(.enhancedSiteCreationSiteDesignSkipped) + } + + static func trackSiteDesignSelected(_ siteDesign: RemoteSiteDesign, sectionType: SiteDesignSectionType) { + var properties = commonProperties(siteDesign) + properties[recommendedKey] = sectionType == .recommended + WPAnalytics.track(.enhancedSiteCreationSiteDesignSelected, withProperties: properties) + } + + // MARK: - Site Design Preview + static func trackSiteDesignPreviewViewed(siteDesign: RemoteSiteDesign, previewMode: PreviewDevice) { + WPAnalytics.track(.enhancedSiteCreationSiteDesignPreviewViewed, withProperties: commonProperties(siteDesign, previewMode)) + } + + static func trackSiteDesignPreviewLoading(siteDesign: RemoteSiteDesign, previewMode: PreviewDevice) { + WPAnalytics.track(.enhancedSiteCreationSiteDesignPreviewLoading, withProperties: commonProperties(siteDesign, previewMode)) + } + + static func trackSiteDesignPreviewLoaded(siteDesign: RemoteSiteDesign, previewMode: PreviewDevice) { + WPAnalytics.track(.enhancedSiteCreationSiteDesignPreviewLoaded, withProperties: commonProperties(previewMode, siteDesign)) + } + + static func trackSiteDesignPreviewModeButtonTapped(_ previewMode: PreviewDevice) { + WPAnalytics.track(.enhancedSiteCreationSiteDesignPreviewModeButtonTapped, withProperties: commonProperties(previewMode)) + } + + static func trackSiteDesignPreviewModeChanged(_ previewMode: PreviewDevice) { + WPAnalytics.track(.enhancedSiteCreationSiteDesignPreviewModeChanged, withProperties: commonProperties(previewMode)) + } + + // MARK: - Final Assembly + static func trackSiteCreationSuccessLoading(_ siteDesign: RemoteSiteDesign?) { + WPAnalytics.track(.enhancedSiteCreationSuccessLoading, withProperties: commonProperties(siteDesign)) + } + + static func trackSiteCreationSuccess(_ siteDesign: RemoteSiteDesign?) { + WPAnalytics.track(.createdSite, withProperties: commonProperties(siteDesign)) + } + + static func trackSiteCreationSuccessPreviewViewed(_ siteDesign: RemoteSiteDesign?) { + WPAnalytics.track(.enhancedSiteCreationSuccessPreviewViewed, withProperties: commonProperties(siteDesign)) + } + + static func trackSiteCreationSuccessLoaded(_ siteDesign: RemoteSiteDesign?) { + WPAnalytics.track(.enhancedSiteCreationSuccessPreviewLoaded, withProperties: commonProperties(siteDesign)) + } + + static func trackSiteCreationSuccessPreviewOkButtonTapped() { + WPAnalytics.track(.enhancedSiteCreationSuccessPreviewOkButtonTapped) + } + + // MARK: - Error + static func trackError(_ error: Error) { + let errorProperties: [String: AnyObject] = [ + "error_info": error.localizedDescription as AnyObject + ] + + WPAnalytics.track(.enhancedSiteCreationErrorShown, withProperties: errorProperties) + } + + // MARK: - Common + private static func track(_ event: SiteCreationAnalyticsEvent, properties: [String: String] = [:]) { + let event = AnalyticsEvent(name: event.rawValue, properties: properties) + WPAnalytics.track(event) + } + + private static func commonProperties(_ properties: Any?...) -> [AnyHashable: Any] { + var result: [AnyHashable: Any] = [:] + + for property: Any? in properties { + if let siteDesign = property as? RemoteSiteDesign { + result.merge([siteDesignKey: siteDesign.slug]) { (_, new) in new } + } + if let previewMode = property as? PreviewDevice { + result.merge([previewModeKey: previewMode.rawValue]) { (_, new) in new } + } + } + + return result + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Shared/TitleSubtitleTextfieldHeader.swift b/WordPress/Classes/ViewRelated/Site Creation/Shared/TitleSubtitleTextfieldHeader.swift index d30dce7bc3e3..c55287847eb0 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/Shared/TitleSubtitleTextfieldHeader.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Shared/TitleSubtitleTextfieldHeader.swift @@ -17,14 +17,6 @@ final class SearchTextField: UITextField { static let textInset = CGFloat(56) } - // MARK: Becoming First Responder - - var allowFirstResponderStatus: Bool = true - - override var canBecomeFirstResponder: Bool { - return allowFirstResponderStatus - } - // MARK: UIView init() { @@ -55,12 +47,19 @@ final class SearchTextField: UITextField { override func rightViewRect(forBounds bounds: CGRect) -> CGRect { let iconX = bounds.width - Constants.iconInset - Constants.iconDimension let iconY = (bounds.height - Constants.iconDimension) / 2 - return CGRect(x: iconX, y: iconY, width: Constants.iconDimension, height: bounds.height) + return CGRect(x: iconX, y: iconY, width: Constants.iconDimension, height: Constants.iconDimension) } override func clearButtonRect(forBounds bounds: CGRect) -> CGRect { let originalClearButtonRect = super.clearButtonRect(forBounds: bounds) - return originalClearButtonRect.offsetBy(dx: Constants.clearButtonInset, dy: 0) + + var offsetX = Constants.clearButtonInset + + if effectiveUserInterfaceLayoutDirection == .rightToLeft { + offsetX = -offsetX + } + + return originalClearButtonRect.offsetBy(dx: offsetX, dy: 0) } // MARK: Private behavior @@ -77,36 +76,40 @@ final class SearchTextField: UITextField { autocorrectionType = .no adjustsFontForContentSizeCategory = true - setIconImage() + setIconImage(view: searchIconImageView) NSLayoutConstraint.activate([ heightAnchor.constraint(equalToConstant: Constants.searchHeight), ]) - - addTopBorder(withColor: .divider) - addBottomBorder(withColor: .divider) } - private func setIconImage() { + private lazy var searchIconImageView: UIImageView = { let iconSize = CGSize(width: Constants.iconDimension, height: Constants.iconDimension) - let loupeIcon = Gridicon.iconOfType(.search, withSize: iconSize).imageWithTintColor(.listIcon)?.imageFlippedForRightToLeftLayoutDirection() - let imageView = UIImageView(image: loupeIcon) + let loupeIcon = UIImage.gridicon(.search, size: iconSize).imageWithTintColor(.listIcon) + return UIImageView(image: loupeIcon) + }() - if traitCollection.layoutDirection == .rightToLeft { - rightView = imageView - rightViewMode = .always + private lazy var activityIndicator: UIActivityIndicatorView = { + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.backgroundColor = UIColor.clear + + return activityIndicator + }() + + func setIcon(isLoading: Bool) { + if isLoading { + setIconImage(view: activityIndicator) + activityIndicator.startAnimating() } else { - leftView = imageView - leftViewMode = .always + activityIndicator.stopAnimating() + setIconImage(view: searchIconImageView) } } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - - if #available(iOS 13, *) { - setIconImage() - } + private func setIconImage(view: UIView) { + // Since the RTL layout is already handled elsewhere updating leftView is enough here + leftView = view + leftViewMode = .always } } diff --git a/WordPress/Classes/ViewRelated/Site Creation/Shared/UITableView+Header.swift b/WordPress/Classes/ViewRelated/Site Creation/Shared/UITableView+Header.swift index 72b563c06e0e..9e4c28b3b6d5 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/Shared/UITableView+Header.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Shared/UITableView+Header.swift @@ -4,6 +4,10 @@ import UIKit extension UITableView { // via https://collindonnell.com/2015/09/29/dynamically-sized-table-view-header-or-footer-using-auto-layout/ + // + // This method didn't work as expected in `MigrationWelcomeViewController`. + // + // WIP: Remove this method and use `UITableView.sizeToFitHeaderView` instead. func layoutHeaderView() { guard let headerView = tableHeaderView else { return @@ -18,4 +22,30 @@ extension UITableView { tableHeaderView = headerView } } + + /// Resizes the `tableHeaderView` to fit its content. + /// + /// The `tableHeaderView` doesn't adjust its size automatically like a `UITableViewCell`, so this method + /// should be called whenever the `tableView`'s bounds changes or when the `tableHeaderView` content changes. + /// + /// This method should typically be called in `UIViewController.viewDidLayoutSubviews`. + /// + /// Source: https://gist.github.com/smileyborg/50de5da1c921b73bbccf7f76b3694f6a + /// + func sizeToFitHeaderView() { + guard let tableHeaderView else { + return + } + let fittingSize = CGSize(width: bounds.width - (safeAreaInsets.left + safeAreaInsets.right), height: 0) + let size = tableHeaderView.systemLayoutSizeFitting( + fittingSize, + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + let newFrame = CGRect(origin: .zero, size: size) + if tableHeaderView.frame.height != newFrame.height { + tableHeaderView.frame = newFrame + self.tableHeaderView = tableHeaderView + } + } } diff --git a/WordPress/Classes/ViewRelated/Site Creation/Site Intent/IntentCell.swift b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/IntentCell.swift new file mode 100644 index 000000000000..26281c7252c6 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/IntentCell.swift @@ -0,0 +1,48 @@ +import UIKit + +final class IntentCell: UITableViewCell, ModelSettableCell { + @IBOutlet weak var title: UILabel! + @IBOutlet weak var emojiContainer: UIView! + @IBOutlet weak var emoji: UILabel! + + static var estimatedSize: CGSize { + return CGSize(width: 320, height: 63) + } + + var model: SiteIntentVertical? { + didSet { + title.text = model?.localizedTitle + emoji.text = model?.emoji + } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + private func commonInit() { + selectedBackgroundView?.backgroundColor = .clear + + accessibilityTraits = .button + accessibilityHint = NSLocalizedString("Selects this topic as the intent for your site.", + comment: "Accessibility hint for a topic in the Site Creation intents view.") + } + + override func awakeFromNib() { + super.awakeFromNib() + + emojiContainer.layer.cornerRadius = emojiContainer.layer.frame.width / 2 + title.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .bold) + } + + override func prepareForReuse() { + title.text = nil + emoji.text = nil + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Site Intent/IntentCell.xib b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/IntentCell.xib new file mode 100644 index 000000000000..3e269e24242b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/IntentCell.xib @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentData.swift b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentData.swift new file mode 100644 index 000000000000..bb11b9b99325 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentData.swift @@ -0,0 +1,78 @@ +import Foundation + +struct SiteIntentData { + + static let allVerticals: [SiteIntentVertical] = [ + .init("food", NSLocalizedString("Food", comment: "Food site intent topic"), "🍔", isDefault: true), + .init("news", NSLocalizedString("News", comment: "News site intent topic"), "🗞️", isDefault: true), + .init("lifestyle", NSLocalizedString("Lifestyle", comment: "Lifestyle site intent topic"), "☕", isDefault: true), + .init("personal", NSLocalizedString("Personal", comment: "Personal site intent topic"), "✍️", isDefault: true), + .init("photography", NSLocalizedString("Photography", comment: "Photography site intent topic"), "📷", isDefault: true), + .init("travel", NSLocalizedString("Travel", comment: "Travel site intent topic"), "✈️", isDefault: true), + .init("art", NSLocalizedString("Art", comment: "Art site intent topic"), "🎨"), + .init("automotive", NSLocalizedString("Automotive", comment: "Automotive site intent topic"), "🚗"), + .init("beauty", NSLocalizedString("Beauty", comment: "Beauty site intent topic"), "💅"), + .init("books", NSLocalizedString("Books", comment: "Books site intent topic"), "📚"), + .init("business", NSLocalizedString("Business", comment: "Business site intent topic"), "💼"), + .init("community_nonprofit", NSLocalizedString("Community & Non-Profit", comment: "Community & Non-Profit site intent topic"), "🤝"), + .init("education", NSLocalizedString("Education", comment: "Education site intent topic"), "🏫"), + .init("diy", NSLocalizedString("DIY", comment: "DIY site intent topic"), "🔨"), + .init("fashion", NSLocalizedString("Fashion", comment: "Fashion site intent topic"), "👠"), + .init("finance", NSLocalizedString("Finance", comment: "Finance site intent topic"), "💰"), + .init("film_television", NSLocalizedString("Film & Television", comment: "Film & Television site intent topic"), "🎥"), + .init("fitness_exercise", NSLocalizedString("Fitness & Exercise", comment: "Fitness & Exercise site intent topic"), "💪"), + .init("gaming", NSLocalizedString("Gaming", comment: "Gaming site intent topic"), "🎮"), + .init("health", NSLocalizedString("Health", comment: "Health site intent topic"), "❤️"), + .init("interior_design", NSLocalizedString("Interior Design", comment: "Interior Design site intent topic"), "🛋️"), + .init("local_services", NSLocalizedString("Local Services", comment: "Local Services site intent topic"), "📍"), + .init("music", NSLocalizedString("Music", comment: "Music site intent topic"), "🎵"), + .init("parenting", NSLocalizedString("Parenting", comment: "Parenting site intent topic"), "👶"), + .init("people", NSLocalizedString("People", comment: "People site intent topic"), "🧑‍🤝‍🧑"), + .init("politics", NSLocalizedString("Politics", comment: "Politics site intent topic"), "🗳️"), + .init("real_estate", NSLocalizedString("Real Estate", comment: "Real Estate site intent topic"), "🏠"), + .init("sports", NSLocalizedString("Sports", comment: "Sports site intent topic"), "⚽"), + .init("technology", NSLocalizedString("Technology", comment: "Technology site intent topic"), "💻"), + .init("writing_poetry", NSLocalizedString("Writing & Poetry", comment: "Writing & Poetry site intent topic"), "📓") + ].sorted(by: { $0.localizedTitle < $1.localizedTitle }) + + static let defaultVerticals: [SiteIntentVertical] = { + allVerticals.filter { $0.isDefault } + }() + + // Filters verticals based on search term and prepends a custom vertical if there were no exact matches + static func filterVerticals(with term: String) -> [SiteIntentVertical] { + let trimmedAndLowercasedTerm = term.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + guard !trimmedAndLowercasedTerm.isEmpty else { + return allVerticals + } + + guard let exactMatch = allVerticals.first(where: { $0.localizedTitle.lowercased() == trimmedAndLowercasedTerm }) else { + let customVertical = SiteIntentVertical( + slug: trimmedAndLowercasedTerm, + localizedTitle: term, + emoji: "+", + isCustom: true + ) + + return [customVertical] + allVerticals.filter { $0.localizedTitle.lowercased().contains(trimmedAndLowercasedTerm) } + } + + return [exactMatch] + } +} + +fileprivate extension SiteIntentVertical { + init(_ slug: String, + _ localizedTitle: String, + _ emoji: String, + isDefault: Bool = false, + isCustom: Bool = false) { + + self.slug = slug + self.localizedTitle = localizedTitle + self.emoji = emoji + self.isDefault = isDefault + self.isCustom = isCustom + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentStep.swift b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentStep.swift new file mode 100644 index 000000000000..11be65e62580 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentStep.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Site Creation: Allows selection of the the site's vertical (a.k.a. intent or industry). +final class SiteIntentStep: WizardStep { + typealias SiteIntentSelection = (_ vertical: SiteIntentVertical?) -> Void + weak var delegate: WizardDelegate? + private let creator: SiteCreator + + private(set) lazy var content: UIViewController = { + return SiteIntentViewController { [weak self] vertical in + self?.didSelect(vertical) + } + }() + + init(creator: SiteCreator) { + self.creator = creator + } + + private func didSelect(_ vertical: SiteIntentVertical?) { + creator.vertical = vertical + delegate?.nextStep() + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentVertical.swift b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentVertical.swift new file mode 100644 index 000000000000..27a3f4ae2cd0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentVertical.swift @@ -0,0 +1,17 @@ +import Foundation + +struct SiteIntentVertical: Equatable { + let slug: String + let localizedTitle: String + let emoji: String + let isDefault: Bool + let isCustom: Bool + + init(slug: String, localizedTitle: String, emoji: String, isDefault: Bool = false, isCustom: Bool = false) { + self.slug = slug + self.localizedTitle = localizedTitle + self.emoji = emoji + self.isDefault = isDefault + self.isCustom = isCustom + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentViewController.swift b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentViewController.swift new file mode 100644 index 000000000000..8c367ee609fe --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Site Intent/SiteIntentViewController.swift @@ -0,0 +1,201 @@ +import UIKit + +class SiteIntentViewController: CollapsableHeaderViewController { + private let selection: SiteIntentStep.SiteIntentSelection + private let tableView: UITableView + + private var availableVerticals: [SiteIntentVertical] = SiteIntentData.defaultVerticals { + didSet { + contentSizeWillChange() + } + } + + private let searchBar: UISearchBar = { + let searchBar = UISearchBar() + searchBar.translatesAutoresizingMaskIntoConstraints = false + WPStyleGuide.configureSearchBar(searchBar, backgroundColor: .clear, returnKeyType: .search) + searchBar.setImage(UIImage(), for: .search, state: .normal) + return searchBar + }() + + override var separatorStyle: SeparatorStyle { + return .hidden + } + + override var alwaysResetHeaderOnRotation: Bool { + // the default behavior works on iPad, so let's not override it + WPDeviceIdentification.isiPhone() + } + + init(_ selection: @escaping SiteIntentStep.SiteIntentSelection) { + self.selection = selection + tableView = UITableView(frame: .zero, style: .plain) + + super.init( + scrollableView: tableView, + mainTitle: Strings.mainTitle, + navigationBarTitle: Strings.navigationBarTitle, + prompt: Strings.prompt, + primaryActionTitle: Strings.primaryAction, + accessoryView: searchBar + ) + + tableView.dataSource = self + searchBar.delegate = self + } + + // MARK: UIViewController + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + configureNavigationBar() + configureTable() + + largeTitleView.numberOfLines = Metrics.largeTitleLines + SiteCreationAnalyticsHelper.trackSiteIntentViewed() + } + + override func viewDidLayoutSubviews() { + searchBar.placeholder = Strings.searchTextFieldPlaceholder + } + + override func estimatedContentSize() -> CGSize { + + let visibleCells = CGFloat(availableVerticals.count) + let height = visibleCells * IntentCell.estimatedSize.height + return CGSize(width: view.frame.width, height: height) + } + + // MARK: UI Setup + + private func configureNavigationBar() { + // Title + navigationItem.backButtonTitle = Strings.backButtonTitle + // Skip button + navigationItem.rightBarButtonItem = UIBarButtonItem(title: Strings.skipButtonTitle, + style: .done, + target: self, + action: #selector(skipButtonTapped)) + // Cancel button + navigationItem.leftBarButtonItem = UIBarButtonItem(title: Strings.cancelButtonTitle, + style: .done, + target: self, + action: #selector(closeButtonTapped)) + navigationItem.leftBarButtonItem?.accessibilityIdentifier = "site-intent-cancel-button" + } + + private func configureTable() { + let cellName = IntentCell.cellReuseIdentifier() + let nib = UINib(nibName: cellName, bundle: nil) + tableView.register(nib, forCellReuseIdentifier: cellName) + tableView.register(InlineErrorRetryTableViewCell.self, forCellReuseIdentifier: InlineErrorRetryTableViewCell.cellReuseIdentifier()) + tableView.cellLayoutMarginsFollowReadableWidth = true + tableView.backgroundColor = .basicBackground + tableView.accessibilityIdentifier = "Site Intent Table" + } + + // MARK: Actions + + @objc + private func skipButtonTapped(_ sender: Any) { + SiteCreationAnalyticsHelper.trackSiteIntentSkipped() + selection(nil) + } + + @objc + private func closeButtonTapped(_ sender: Any) { + SiteCreationAnalyticsHelper.trackSiteIntentCanceled() + dismiss(animated: true) + } +} + +// MARK: Constants +extension SiteIntentViewController { + + private enum Strings { + static let mainTitle = NSLocalizedString("What's your website about?", + comment: "Select the site's intent. Title") + static let navigationBarTitle = NSLocalizedString("Site Topic", + comment: "Title of the navigation bar, shown when the large title is hidden.") + static let prompt = NSLocalizedString("Choose a topic from the list below or type your own.", + comment: "Select the site's intent. Subtitle") + static let primaryAction = NSLocalizedString("Continue", + comment: "Button to progress to the next step") + static let backButtonTitle = NSLocalizedString("Topic", + comment: "Shortened version of the main title to be used in back navigation") + static let skipButtonTitle = NSLocalizedString("Skip", + comment: "Continue without making a selection") + static let cancelButtonTitle = NSLocalizedString("Cancel", + comment: "Cancel site creation") + static let searchTextFieldPlaceholder = NSLocalizedString("E.g. Fashion, Poetry, Politics", comment: "Placeholder text for the search field int the Site Intent screen.") + static let continueButtonTitle = NSLocalizedString("Continue", comment: "Title of the continue button for the Site Intent screen.") + } + + private enum Metrics { + static let largeTitleLines = 2 + static let continueButtonPadding: CGFloat = 16 + static let continueButtonBottomOffset: CGFloat = 12 + static let continueButtonHeight: CGFloat = 44 + } +} + +// MARK: UITableViewDataSource + +extension SiteIntentViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return availableVerticals.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + return configureIntentCell(tableView, cellForRowAt: indexPath) + } + + func configureIntentCell(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: IntentCell.cellReuseIdentifier()) as? IntentCell else { + assertionFailure("This is a programming error - IntentCell has not been properly registered!") + return UITableViewCell() + } + + let vertical = availableVerticals[indexPath.row] + cell.model = vertical + return cell + } +} + +// MARK: UITableViewDelegate + +extension SiteIntentViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + let vertical = availableVerticals[indexPath.row] + + SiteCreationAnalyticsHelper.trackSiteIntentSelected(vertical) + selection(vertical) + } +} + +// MARK: Search Bar Delegate +extension SiteIntentViewController: UISearchBarDelegate { + + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + // do not unfilter already filtered content, when navigating back to this page + SiteCreationAnalyticsHelper.trackSiteIntentSearchFocused() + guard availableVerticals == SiteIntentData.defaultVerticals else { + return + } + + availableVerticals = SiteIntentData.allVerticals + tableView.reloadData() + tableView.scrollVerticallyToView(searchBar.searchTextField, animated: true) + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + availableVerticals = SiteIntentData.filterVerticals(with: searchText) + tableView.reloadData() + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Site Name/SiteNameStep.swift b/WordPress/Classes/ViewRelated/Site Creation/Site Name/SiteNameStep.swift new file mode 100644 index 000000000000..21a3cc92125e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Site Name/SiteNameStep.swift @@ -0,0 +1,40 @@ +import Foundation +import UIKit + +/// Site Creation: Allows creation of the site's name. +final class SiteNameStep: WizardStep { + weak var delegate: WizardDelegate? + private let creator: SiteCreator + + var content: UIViewController { + SiteNameViewController(siteNameViewFactory: makeSiteNameView) { [weak self] in + SiteCreationAnalyticsHelper.trackSiteNameSkipped() + self?.didSet(siteName: nil) + } + } + + init(creator: SiteCreator) { + self.creator = creator + } + + private func didSet(siteName: String?) { + if let siteName = siteName { + SiteCreationAnalyticsHelper.trackSiteNameEntered(siteName) + } + + // if users go back and then skip, the failable initializer of SiteInformation + // will reset the state, avoiding to retain the previous site name + creator.information = SiteInformation(title: siteName, tagLine: creator.information?.tagLine) + delegate?.nextStep() + } +} + +// Site Name View Factory +extension SiteNameStep { + /// Builds a the view to be used as main content + private func makeSiteNameView() -> UIView { + SiteNameView(siteVerticalName: creator.vertical?.localizedTitle ?? "") { [weak self] siteName in + self?.didSet(siteName: siteName?.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Site Name/SiteNameView.swift b/WordPress/Classes/ViewRelated/Site Creation/Site Name/SiteNameView.swift new file mode 100644 index 000000000000..0beef075296c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Site Name/SiteNameView.swift @@ -0,0 +1,310 @@ + +import UIKit +import WordPressShared + +/// content view for SiteNameViewController +class SiteNameView: UIView { + + private var siteVerticalName: String + private let onContinue: (String?) -> Void + + // Continue button constraints: will always be set in the initialzer, so it's fine to implicitly unwrap + private var continueButtonTopConstraint: NSLayoutConstraint! + private var continueButtonBottomConstraint: NSLayoutConstraint! + private var continueButtonLeadingConstraint: NSLayoutConstraint! + private var continueButtonTrailingConstraint: NSLayoutConstraint! + + // MARK: UI + + /// Title + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = WPStyleGuide.serifFontForTextStyle(.largeTitle, fontWeight: .semibold) + label.adjustsFontForContentSizeCategory = true + label.numberOfLines = Metrics.numberOfLinesInTitle + label.textAlignment = .center + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = Metrics.titleMinimumScaleFactor + return label + }() + + // used to add left and right padding to the title + private lazy var titleLabelView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(titleLabel) + return view + }() + + /// Subtitle + private lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = WPStyleGuide.fontForTextStyle(.body) + label.adjustsFontForContentSizeCategory = true + label.textColor = .secondaryLabel + label.setText(TextContent.subtitle) + label.numberOfLines = Metrics.numberOfLinesInSubtitle + label.textAlignment = .center + return label + }() + + // used to add left and right padding to the subtitle + private lazy var subtitleLabelView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(subtitleLabel) + return view + }() + + /// Search bar + private lazy var searchBar: UISearchBar = { + let searchBar = UISearchBar() + searchBar.translatesAutoresizingMaskIntoConstraints = false + WPStyleGuide.configureSearchBar(searchBar, backgroundColor: .clear, returnKeyType: .continue) + searchBar.setImage(UIImage(), for: .search, state: .normal) + searchBar.autocapitalizationType = .sentences + searchBar.accessibilityIdentifier = "Website Title" + searchBar.searchTextField.attributedPlaceholder = NSAttributedString() + return searchBar + }() + + /// Main stack view + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [titleLabelView, subtitleLabelView, searchBar]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = Metrics.mainStackViewVerticalSpacing + return stackView + }() + + /// Continue button + private lazy var continueButton: UIButton = { + let button = FancyButton() + button.isPrimary = true + button.translatesAutoresizingMaskIntoConstraints = false + button.titleLabel?.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + button.setTitle(TextContent.continueButtonTitle, for: .normal) + button.addTarget(self, action: #selector(navigateToNextScreen), for: .touchUpInside) + return button + }() + + @objc private func navigateToNextScreen() { + onContinue(searchBar.text) + } + + private lazy var continueButtonView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + view.addSubview(continueButton) + return view + }() + + override var canBecomeFirstResponder: Bool { + return true + } + + init(siteVerticalName: String, onContinue: @escaping (String?) -> Void) { + self.siteVerticalName = siteVerticalName + self.onContinue = onContinue + super.init(frame: .zero) + backgroundColor = .basicBackground + addSubview(mainStackView) + setupTitleColors() + setupContinueButton() + activateConstraints() + searchBar.delegate = self + hideTitlesIfNeeded() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + hideTitlesIfNeeded() + } + + override func layoutSubviews() { + super.layoutSubviews() + updateContinueButton() + } + + override func becomeFirstResponder() -> Bool { + super.becomeFirstResponder() + return searchBar.becomeFirstResponder() + } +} + +// MARK: setup +private extension SiteNameView { + + /// Highlghts the site name in blue + func setupTitleColors() { + // find the index where the vertical name goes, so that it won't be confused + // with any word in the title + let replacementIndex = NSString(string: TextContent.title).range(of: "%@") + + guard !siteVerticalName.isEmpty, replacementIndex.length > 0 else { + titleLabel.setText(TextContent.defaultTitle) + return + } + + let fullTitle = String(format: TextContent.title, siteVerticalName) + let attributedTitle = NSMutableAttributedString(string: fullTitle) + let replacementRange = NSRange(location: replacementIndex.location, length: siteVerticalName.utf16.count) + + attributedTitle.addAttributes([ + .foregroundColor: UIColor.primary, + ], range: replacementRange) + titleLabel.attributedText = attributedTitle + } + + /// sets up the continue button on top of the keyboard + func setupContinueButton() { + continueButton.isEnabled = false + searchBar.inputAccessoryView = continueButtonView + continueButtonView.frame = Metrics.continueButtonViewFrame(frame.width) + setContinueButtonConstraints() + } + + /// sets the default constraints for the continue button + func setContinueButtonConstraints() { + continueButtonTopConstraint = + continueButtonView + .safeAreaLayoutGuide + .topAnchor + .constraint(equalTo: continueButton.topAnchor, + constant: -Metrics.continueButtonStandardPadding) + + continueButtonBottomConstraint = + continueButtonView.safeAreaLayoutGuide + .bottomAnchor + .constraint(equalTo: continueButton.bottomAnchor, + constant: Metrics.continueButtonStandardPadding) + + continueButtonLeadingConstraint = + continueButtonView.safeAreaLayoutGuide + .leadingAnchor + .constraint(equalTo: continueButton.leadingAnchor, + constant: -Metrics.continueButtonStandardPadding) + + continueButtonTrailingConstraint = + continueButtonView + .safeAreaLayoutGuide + .trailingAnchor + .constraint(equalTo: continueButton.trailingAnchor, + constant: Metrics.continueButtonStandardPadding) + } + + /// Updates the constraints of the Continue button on iPad, so that the button and the search text field are at the same width + func updateContinueButton() { + guard UIDevice.isPad(), let windowWidth = UIApplication.shared.mainWindow?.frame.width else { + return + } + continueButtonLeadingConstraint.isActive = false + continueButtonTrailingConstraint.isActive = false + + let padding = (windowWidth - searchBar.frame.width) / 2 + Metrics.continueButtonAdditionaliPadPadding + + continueButtonLeadingConstraint = continueButtonView.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: continueButton.leadingAnchor, constant: -padding) + continueButtonTrailingConstraint = continueButtonView.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: continueButton.trailingAnchor, constant: padding) + NSLayoutConstraint.activate([continueButtonLeadingConstraint, continueButtonTrailingConstraint]) + } + + /// activates all constraints + func activateConstraints() { + titleLabelView.pinSubviewToSafeArea(titleLabel, insets: Metrics.titlesInsets) + subtitleLabelView.pinSubviewToSafeArea(subtitleLabel, insets: Metrics.titlesInsets) + + NSLayoutConstraint.activate([ + mainStackView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, + constant: Metrics.mainStackViewTopPadding), + mainStackView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, + constant: Metrics.mainStackViewSidePadding), + mainStackView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, + constant: -Metrics.mainStackViewSidePadding), + searchBar.heightAnchor.constraint(equalToConstant: Metrics.searchbarHeight), + continueButtonTopConstraint, + continueButtonBottomConstraint, + continueButtonLeadingConstraint, + continueButtonTrailingConstraint + ]) + } + + /// hides or shows titles based on the passed boolean parameter + func hideTitlesIfNeeded() { + let isAccessibility = traitCollection.verticalSizeClass == .compact || + traitCollection.preferredContentSizeCategory.isAccessibilityCategory + + let isVerylarge = [ + UIContentSizeCategory.extraExtraLarge, + UIContentSizeCategory.extraExtraExtraLarge + ].contains(traitCollection.preferredContentSizeCategory) + + titleLabelView.isHidden = isAccessibility + + subtitleLabelView.isHidden = isAccessibility || isVerylarge || isIphoneSEorSmaller + } + + var isIphoneSEorSmaller: Bool { + UIScreen.main.nativeBounds.height <= Metrics.smallerIphonesNativeBoundsHeight && + UIScreen.main.nativeScale <= Metrics.nativeScale + } +} + +// MARK: appearance +private extension SiteNameView { + + enum TextContent { + static let title = NSLocalizedString("Give your %@ website a name", + comment: "Title of the Site Name screen. Takes the vertical name as a parameter.") + static let defaultTitle = NSLocalizedString("Give your website a name", + comment: "Default title of the Site Name screen.") + static let subtitle = NSLocalizedString("A good name is short and memorable.\nYou can change it later.", + comment: "Subtitle of the Site Name screen.") + static let continueButtonTitle = NSLocalizedString("Continue", + comment: "Title of the Continue button in the Site Name screen.") + } + + enum Metrics { + // main stack view + static let mainStackViewVerticalSpacing: CGFloat = 18 + static let mainStackViewTopPadding: CGFloat = 10 + static let mainStackViewSidePadding: CGFloat = 8 + // search bar + static let searchbarHeight: CGFloat = 64 + // title and subtitle + static let numberOfLinesInTitle = 3 + static let numberOfLinesInSubtitle = 0 + static let titlesInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16) + static let verticalNameDisplayLimit = 32 + static let titleMinimumScaleFactor: CGFloat = 0.75 + //continue button + static let continueButtonStandardPadding: CGFloat = 16 + static let continueButtonAdditionaliPadPadding: CGFloat = 8 + static let continueButtonInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + static func continueButtonViewFrame(_ accessoryWidth: CGFloat) -> CGRect { + CGRect(x: 0, y: 0, width: accessoryWidth, height: 76) + } + // native bounds height and scale of iPhone SE 3rd gen and iPhone 8 + static let smallerIphonesNativeBoundsHeight: CGFloat = 1334 + static let nativeScale: CGFloat = 2 + } +} + +// MARK: search bar delegate +extension SiteNameView: UISearchBarDelegate { + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + // disable the continue button if the text is either empty or contains only spaces, newlines or tabs. + continueButton.isEnabled = searchText.first(where: { !$0.isWhitespace }) != nil + } + + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + updateContinueButton() + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Site Name/SiteNameViewController.swift b/WordPress/Classes/ViewRelated/Site Creation/Site Name/SiteNameViewController.swift new file mode 100644 index 000000000000..4410a950ab49 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Site Name/SiteNameViewController.swift @@ -0,0 +1,107 @@ + +import UIKit + +/// Site Name screen for the Site Creation flow +class SiteNameViewController: UIViewController { + + private let siteNameViewFactory: () -> UIView + private let onSkip: () -> Void + + init(siteNameViewFactory: @escaping () -> UIView, onSkip: @escaping () -> Void) { + self.siteNameViewFactory = siteNameViewFactory + self.onSkip = onSkip + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = siteNameViewFactory() + setTitleForTraitCollection() + configureNavigationBar() + + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + setTitleForTraitCollection() + } + + override func viewDidLoad() { + super.viewDidLoad() + SiteCreationAnalyticsHelper.trackSiteNameViewed() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + view.becomeFirstResponder() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if isMovingFromParent { + // Called when popping from a nav controller, e.g. hitting "Back" + SiteCreationAnalyticsHelper.trackSiteNameCanceled() + } + } +} + +// MARK: Navigation Bar +private extension SiteNameViewController { + + func configureNavigationBar() { + removeNavigationBarBorder() + // Title + navigationItem.backButtonTitle = TextContent.backButtonTitle + // Add skip button + navigationItem.rightBarButtonItem = UIBarButtonItem(title: TextContent.skipButtonTitle, + style: .done, + target: self, + action: #selector(skipButtonTapped)) + } + + @objc + private func skipButtonTapped() { + onSkip() + } + + /// Removes the separator line at the bottom of the navigation bar + func removeNavigationBarBorder() { + let navBarAppearance = UINavigationBarAppearance() + navBarAppearance.backgroundColor = .basicBackground + navBarAppearance.shadowColor = .clear + navBarAppearance.shadowImage = UIImage() + navigationItem.standardAppearance = navBarAppearance + navigationItem.scrollEdgeAppearance = navBarAppearance + navigationItem.compactAppearance = navBarAppearance + setNeedsStatusBarAppearanceUpdate() + } +} + +// MARK: Title +private extension SiteNameViewController { + + // hides or shows the title depending on the vertical size class ands accessibility category + func setTitleForTraitCollection() { + title = (traitCollection.verticalSizeClass == .compact || + traitCollection.preferredContentSizeCategory.isAccessibilityCategory) ? + TextContent.titleForVerticalCompactSizeClass : + "" + } +} + +// MARK: Constants +private extension SiteNameViewController { + + enum TextContent { + static let titleForVerticalCompactSizeClass = NSLocalizedString("Give your website a name", + comment: "Title for Site Name screen in iPhone landscape.") + static let skipButtonTitle = NSLocalizedString("Skip", + comment: "Title for the Skip button in the Site Name Screen.") + static let backButtonTitle = NSLocalizedString("Name", + comment: "Shortened version of the main title to be used in back navigation.") + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/SiteSegments/SiteSegmentsCell.swift b/WordPress/Classes/ViewRelated/Site Creation/Site Segments/SiteSegmentsCell.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Site Creation/SiteSegments/SiteSegmentsCell.swift rename to WordPress/Classes/ViewRelated/Site Creation/Site Segments/SiteSegmentsCell.swift diff --git a/WordPress/Classes/ViewRelated/Site Creation/SiteSegments/SiteSegmentsCell.xib b/WordPress/Classes/ViewRelated/Site Creation/Site Segments/SiteSegmentsCell.xib similarity index 100% rename from WordPress/Classes/ViewRelated/Site Creation/SiteSegments/SiteSegmentsCell.xib rename to WordPress/Classes/ViewRelated/Site Creation/Site Segments/SiteSegmentsCell.xib diff --git a/WordPress/Classes/ViewRelated/Site Creation/SiteSegments/SiteSegmentsStep.swift b/WordPress/Classes/ViewRelated/Site Creation/Site Segments/SiteSegmentsStep.swift similarity index 92% rename from WordPress/Classes/ViewRelated/Site Creation/SiteSegments/SiteSegmentsStep.swift rename to WordPress/Classes/ViewRelated/Site Creation/Site Segments/SiteSegmentsStep.swift index 9741c05cf289..de72b3c28418 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/SiteSegments/SiteSegmentsStep.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Site Segments/SiteSegmentsStep.swift @@ -1,5 +1,5 @@ -/// Site Creation. First screen: Site Segments +/// Site Creation: Site Segments final class SiteSegmentsStep: WizardStep { private let creator: SiteCreator private let service: SiteSegmentsService diff --git a/WordPress/Classes/ViewRelated/Site Creation/SiteSegments/SiteSegmentsWizardContent.swift b/WordPress/Classes/ViewRelated/Site Creation/Site Segments/SiteSegmentsWizardContent.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Site Creation/SiteSegments/SiteSegmentsWizardContent.swift rename to WordPress/Classes/ViewRelated/Site Creation/Site Segments/SiteSegmentsWizardContent.swift diff --git a/WordPress/Classes/ViewRelated/Site Creation/SiteSegments/SiteSegmentsWizardContent.xib b/WordPress/Classes/ViewRelated/Site Creation/Site Segments/SiteSegmentsWizardContent.xib similarity index 100% rename from WordPress/Classes/ViewRelated/Site Creation/SiteSegments/SiteSegmentsWizardContent.xib rename to WordPress/Classes/ViewRelated/Site Creation/Site Segments/SiteSegmentsWizardContent.xib diff --git a/WordPress/Classes/ViewRelated/Site Creation/SiteInfo/SiteInformationStep.swift b/WordPress/Classes/ViewRelated/Site Creation/SiteInfo/SiteInformationStep.swift deleted file mode 100644 index 90ab56a9f8ba..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/SiteInfo/SiteInformationStep.swift +++ /dev/null @@ -1,20 +0,0 @@ - -/// Site Creation. Fourth screen: Site Info -final class SiteInformationStep: WizardStep { - private let creator: SiteCreator - - private(set) lazy var content: UIViewController = { - return SiteInformationWizardContent(completion: didSelect) - }() - - var delegate: WizardDelegate? - - init(creator: SiteCreator) { - self.creator = creator - } - - private func didSelect(_ data: SiteInformation) { - creator.information = data - delegate?.nextStep() - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/SiteInfo/SiteInformationWizardContent.swift b/WordPress/Classes/ViewRelated/Site Creation/SiteInfo/SiteInformationWizardContent.swift deleted file mode 100644 index 3ea7ec51cd9e..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/SiteInfo/SiteInformationWizardContent.swift +++ /dev/null @@ -1,423 +0,0 @@ -import AutomatticTracks -import UIKit -import WordPressAuthenticator - -typealias SiteInformationCompletion = (SiteInformation) -> Void - -final class SiteInformationWizardContent: UIViewController { - private enum Row: Int, CaseIterable { - case title = 0 - case tagline = 1 - - func matches(_ row: Int) -> Bool { - return row == self.rawValue - } - } - - private struct Constants { - static let bottomMargin: CGFloat = 0.0 - static let footerHeight: CGFloat = 42.0 - static let footerVerticalMargin: CGFloat = 6.0 - static let footerHorizontalMargin: CGFloat = 16.0 - static let rowHeight: CGFloat = 44.0 - } - - private let completion: SiteInformationCompletion - - @IBOutlet weak var table: UITableView! - @IBOutlet weak var nextStep: NUXButton! - @IBOutlet weak var bottomConstraint: NSLayoutConstraint! - @IBOutlet weak var buttonWrapper: ShadowView! - - private lazy var headerData: SiteCreationHeaderData = { - let title = NSLocalizedString("Basic information", comment: "Create site, step 3. Select basic information. Title") - let subtitle = NSLocalizedString("Tell us more about the site you are creating.", comment: "Create site, step 3. Select basic information. Subtitle") - - return SiteCreationHeaderData(title: title, subtitle: subtitle) - }() - - init(completion: @escaping SiteInformationCompletion) { - self.completion = completion - super.init(nibName: String(describing: type(of: self)), bundle: nil) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - applyTitle() - setupBackground() - setupTable() - setupButtonWrapper() - setupNextButton() - WPAnalytics.track(.enhancedSiteCreationBasicInformationViewed) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - startListeningToKeyboardNotifications() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - postScreenChangedForVoiceOver() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - stopListeningToKeyboardNotifications() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - table.layoutHeaderView() - } - - private func applyTitle() { - title = NSLocalizedString("2 of 3", comment: "Site creation. Step 2. Screen title") - } - - private func setupBackground() { - view.backgroundColor = .listBackground - } - - private func setupTable() { - setupTableBackground() - setupTableSeparator() - registerCell() - setupCellHeight() - setupHeader() - setupFooter() - setupConstraints() - - table.dataSource = self - } - - private func setupTableBackground() { - table.backgroundColor = .listBackground - } - - private func setupTableSeparator() { - table.separatorColor = .divider - } - - private func registerCell() { - table.register( - InlineEditableNameValueCell.defaultNib, - forCellReuseIdentifier: InlineEditableNameValueCell.defaultReuseID - ) - } - - private func setupCellHeight() { - table.rowHeight = UITableView.automaticDimension - table.estimatedRowHeight = Constants.rowHeight - } - - private func setupButtonWrapper() { - buttonWrapper.backgroundColor = .listBackground - } - - private func setupNextButton() { - nextStep.addTarget(self, action: #selector(goToNextStep), for: .touchUpInside) - - setupButtonAsSkip() - } - - private func setupButtonAsSkip() { - let buttonTitle = NSLocalizedString("Skip", comment: "Button to progress to the next step") - nextStep.setTitle(buttonTitle, for: .normal) - nextStep.accessibilityLabel = buttonTitle - nextStep.accessibilityHint = NSLocalizedString("Navigates to the next step without making changes", comment: "Site creation. Navigates to the next step") - - nextStep.isPrimary = false - } - - private func setupButtonAsNext() { - let buttonTitle = NSLocalizedString("Next", comment: "Button to progress to the next step") - nextStep.setTitle(buttonTitle, for: .normal) - nextStep.accessibilityLabel = buttonTitle - nextStep.accessibilityHint = NSLocalizedString("Navigates to the next step saving changes", comment: "Site creation. Navigates to the next step") - - nextStep.isPrimary = true - } - - private func setupHeader() { - let initialHeaderFrame = CGRect(x: 0, y: 0, width: Int(table.frame.width), height: 0) - let header = TitleSubtitleHeader(frame: initialHeaderFrame) - header.setTitle(headerData.title) - header.setSubtitle(headerData.subtitle) - - table.tableHeaderView = header - - NSLayoutConstraint.activate([ - header.widthAnchor.constraint(equalTo: table.widthAnchor), - header.centerXAnchor.constraint(equalTo: table.centerXAnchor), - ]) - } - - private func setupFooter() { - let footer = UIView(frame: CGRect(x: 0.0, y: 0.0, width: table.frame.width, height: Constants.footerHeight)) - - let title = UILabel(frame: .zero) - title.translatesAutoresizingMaskIntoConstraints = false - title.textAlignment = .natural - title.numberOfLines = 0 - title.textColor = .neutral(.shade50) - title.font = WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .regular) - title.text = TableStrings.footer - title.adjustsFontForContentSizeCategory = true - - footer.addSubview(title) - - NSLayoutConstraint.activate([ - title.leadingAnchor.constraint(equalTo: footer.leadingAnchor, constant: Constants.footerHorizontalMargin), - title.trailingAnchor.constraint(equalTo: footer.trailingAnchor, constant: -1 * Constants.footerHorizontalMargin), - title.topAnchor.constraint(equalTo: footer.topAnchor, constant: Constants.footerVerticalMargin) - ]) - - table.tableFooterView = footer - } - - private func setupConstraints() { - table.cellLayoutMarginsFollowReadableWidth = true - - NSLayoutConstraint.activate([ - table.leadingAnchor.constraint(equalTo: view.prevailingLayoutGuide.leadingAnchor), - table.trailingAnchor.constraint(equalTo: view.prevailingLayoutGuide.trailingAnchor), - ]) - } - - @objc - private func goToNextStep() { - let collectedData = SiteInformation(title: titleString(), tagLine: taglineString()) - completion(collectedData) - trackBasicInformationNextStep() - } - - private func trackBasicInformationNextStep() { - if nextStep.isPrimary { - let basicInformationProperties: [String: AnyObject] = [ - "site_title": titleString() as AnyObject, - "tagline": taglineString() as AnyObject - ] - - WPAnalytics.track(.enhancedSiteCreationBasicInformationCompleted, withProperties: basicInformationProperties) - } else { - WPAnalytics.track(.enhancedSiteCreationBasicInformationSkipped) - } - } - - // MARK: - Cell Titles - - private func titleString() -> String { - return valueText(for: Row.title) - } - - private func taglineString() -> String { - return valueText(for: Row.tagline) - } - - // MARK: - Accessing Cells - - private func indexPath(for row: Row) -> IndexPath { - return IndexPath(row: row.rawValue, section: 0) - } - - private func cell(for row: Row) -> InlineEditableNameValueCell? { - return cell(at: indexPath(for: row)) - } - - private func cell(at indexPath: IndexPath) -> InlineEditableNameValueCell? { - return table.cellForRow(at: indexPath) as? InlineEditableNameValueCell - } - - // MARK: - Cell Value Text Fields - - private func valueTextField(at indexPath: IndexPath) -> UITextField? { - return cell(at: indexPath)?.valueTextField - } - - private func valueTextField(for row: Row) -> UITextField? { - return cell(for: row)?.valueTextField - } - - private func valueText(for row: Row) -> String { - return valueTextField(for: row)?.text ?? "" - } -} - -extension SiteInformationWizardContent: UITableViewDataSource { - private enum TableStrings { - static let site = NSLocalizedString("Site Title", comment: "Site info. Title") - static let tagline = NSLocalizedString("Tagline", comment: "Site info. Tagline") - static let footer = NSLocalizedString("The tagline is a short line of text shown right below the title.", comment: "Site info. Table footer.") - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return Row.allCases.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: InlineEditableNameValueCell.defaultReuseID, for: indexPath) as? InlineEditableNameValueCell else { - assertionFailure("SiteInformationWizardContent. Could not dequeue a cell") - return UITableViewCell() - } - - configure(cell, index: indexPath) - return cell - } - - private func configure(_ cell: InlineEditableNameValueCell, index: IndexPath) { - if Row.title.matches(index.row) { - cell.nameLabel.text = TableStrings.site - cell.valueTextField.attributedPlaceholder = attributedPlaceholder(text: TableStrings.site) - cell.valueTextField.delegate = self - cell.valueTextField.returnKeyType = .next - cell.addTopBorder(withColor: .neutral(.shade10)) - } - - if Row.tagline.matches(index.row) { - cell.nameLabel.text = TableStrings.tagline - cell.valueTextField.attributedPlaceholder = attributedPlaceholder(text: TableStrings.tagline) - cell.valueTextField.delegate = self - cell.valueTextField.returnKeyType = .done - cell.addBottomBorder(withColor: .neutral(.shade10)) - } - - cell.contentView.backgroundColor = .listForeground - - cell.nameLabel.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) - cell.nameLabel.textColor = .text - cell.nameLabel.backgroundColor = .listForeground - - cell.valueTextField.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) - cell.valueTextField.textColor = .text - cell.valueTextField.backgroundColor = .listForeground - - if cell.delegate == nil { - cell.delegate = self - } - } - - private func attributedPlaceholder(text: String) -> NSAttributedString { - let attributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: UIColor.textPlaceholder, - .font: WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) - ] - - return NSAttributedString(string: text, attributes: attributes) - } -} - -extension SiteInformationWizardContent: InlineEditableNameValueCellDelegate { - func inlineEditableNameValueCell(_ cell: InlineEditableNameValueCell, - valueTextFieldDidChange text: String) { - updateButton() - } - - private func updateButton() { - formIsFilled() ? setupButtonAsNext() : setupButtonAsSkip() - } - - private func formIsFilled() -> Bool { - return !titleString().isEmpty || !taglineString().isEmpty - } -} - -extension SiteInformationWizardContent: UITextFieldDelegate { - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - for (index, row) in Row.allCases.enumerated() { - guard let rowTextField = valueTextField(for: row) else { - let errorMessage = "We expect all rows to have `valueTextField` but row \(index) doesn't. Please review the logic." - CrashLogging.logMessage(errorMessage, properties: nil, level: .error) - assertionFailure(errorMessage) - continue - } - - guard rowTextField == textField else { - continue - } - - let indexPath = IndexPath(row: index + 1, section: 0) - - guard let nextTextField = valueTextField(at: indexPath) else { - goToNextStep() - return false - } - - nextTextField.becomeFirstResponder() - return false - } - - return true - } -} - -extension SiteInformationWizardContent { - private func startListeningToKeyboardNotifications() { - NotificationCenter.default.addObserver(self, - selector: #selector(keyboardWillShow), - name: UIResponder.keyboardWillShowNotification, - object: nil) - NotificationCenter.default.addObserver(self, - selector: #selector(keyboardWillHide), - name: UIResponder.keyboardWillHideNotification, - object: nil) - } - - private func stopListeningToKeyboardNotifications() { - NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) - NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) - } - - @objc - private func keyboardWillShow(_ notification: Foundation.Notification) { - guard let payload = KeyboardInfo(notification) else { return } - let keyboardScreenFrame = payload.frameEnd - - let convertedKeyboardFrame = view.convert(keyboardScreenFrame, from: nil) - - var constraintConstant = convertedKeyboardFrame.height - - let bottomInset = view.safeAreaInsets.bottom - constraintConstant -= bottomInset - - let animationDuration = payload.animationDuration - - bottomConstraint.constant = constraintConstant - view.setNeedsUpdateConstraints() - - if table.frame.contains(convertedKeyboardFrame.origin) { - let contentInsets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: constraintConstant, right: 0.0) - table.contentInset = contentInsets - table.scrollIndicatorInsets = contentInsets - - buttonWrapper.addShadow() - } - - UIView.animate(withDuration: animationDuration, - delay: 0, - options: .beginFromCurrentState, - animations: { [weak self] in - self?.view.layoutIfNeeded() - }, - completion: nil) - } - - @objc - private func keyboardWillHide(_ notification: Foundation.Notification) { - buttonWrapper.clearShadow() - bottomConstraint.constant = Constants.bottomMargin - } -} - -// MARK: - VoiceOver - -private extension SiteInformationWizardContent { - func postScreenChangedForVoiceOver() { - UIAccessibility.post(notification: .screenChanged, argument: table.tableHeaderView) - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/SiteInfo/SiteInformationWizardContent.xib b/WordPress/Classes/ViewRelated/Site Creation/SiteInfo/SiteInformationWizardContent.xib deleted file mode 100644 index b17c066d4893..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/SiteInfo/SiteInformationWizardContent.xib +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Site Creation/Verticals/NewVerticalCell.swift b/WordPress/Classes/ViewRelated/Site Creation/Verticals/NewVerticalCell.swift deleted file mode 100644 index 0fdedc83aef5..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/Verticals/NewVerticalCell.swift +++ /dev/null @@ -1,40 +0,0 @@ -import UIKit -import Gridicons - -/// This cell describes a user-specified vertical; that is to say, a search term without a server match. -/// -final class NewVerticalCell: UITableViewCell, SiteVerticalPresenter { - @IBOutlet weak var title: UILabel! - @IBOutlet weak var subtitle: UILabel! - - private struct Strings { - static let newVerticalSubtitle = NSLocalizedString("Custom category", comment: "Placeholder for new site types when creating a new site") - } - - var vertical: SiteVertical? { - didSet { - title.text = vertical?.title - subtitle.text = Strings.newVerticalSubtitle - } - } - - override func awakeFromNib() { - super.awakeFromNib() - styleTitle() - styleSubtitle() - } - - override func prepareForReuse() { - title.text = "" - } - - private func styleTitle() { - WPStyleGuide.configureLabel(title, textStyle: .body, symbolicTraits: .traitItalic) - title.textColor = .text - } - - private func styleSubtitle() { - subtitle.font = WPFontManager.systemRegularFont(ofSize: 15.0) - subtitle.textColor = .textSubtle - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Verticals/NewVerticalCell.xib b/WordPress/Classes/ViewRelated/Site Creation/Verticals/NewVerticalCell.xib deleted file mode 100644 index 6ea7c85577ca..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/Verticals/NewVerticalCell.xib +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Site Creation/Verticals/SiteVerticalPresenter.swift b/WordPress/Classes/ViewRelated/Site Creation/Verticals/SiteVerticalPresenter.swift deleted file mode 100644 index a88bf78b8fe5..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/Verticals/SiteVerticalPresenter.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Gridicons - -/// Abstracts cells for SiteVerticals -protocol SiteVerticalPresenter: ReusableCell { - var vertical: SiteVertical? { get set } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsCell.swift b/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsCell.swift deleted file mode 100644 index eb7150db66d5..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsCell.swift +++ /dev/null @@ -1,42 +0,0 @@ -import UIKit -import Gridicons - -/// This cell describes a server-vended `SiteVertical`. -/// -final class VerticalsCell: UITableViewCell, SiteVerticalPresenter { - @IBOutlet weak var title: UILabel! - - var vertical: SiteVertical? { - didSet { - title.text = vertical?.title - } - } - - override func awakeFromNib() { - super.awakeFromNib() - styleTitle() - } - - override func prepareForReuse() { - title.text = "" - } - - private func styleTitle() { - title.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) - title.textColor = .text - title.adjustsFontForContentSizeCategory = true - } -} - -extension VerticalsCell { - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { - preferredContentSizeDidChange() - } - } - - func preferredContentSizeDidChange() { - styleTitle() - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsCell.xib b/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsCell.xib deleted file mode 100644 index 8aa8d9eb111f..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsCell.xib +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsStep.swift b/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsStep.swift deleted file mode 100644 index 56bf56d6a22a..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsStep.swift +++ /dev/null @@ -1,24 +0,0 @@ - -/// Site Creation. Second screen: Site Verticals -final class VerticalsStep: WizardStep { - private let creator: SiteCreator - private let promptService: SiteVerticalsPromptService - private let verticalsService: SiteVerticalsService - - private(set) lazy var content: UIViewController = { - return VerticalsWizardContent(creator: self.creator, promptService: promptService, verticalsService: self.verticalsService, selection: self.didSelect) - }() - - var delegate: WizardDelegate? - - init(creator: SiteCreator, promptService: SiteVerticalsPromptService, verticalsService: SiteVerticalsService) { - self.creator = creator - self.promptService = promptService - self.verticalsService = verticalsService - } - - private func didSelect(_ vertical: SiteVertical?) { - creator.vertical = vertical - delegate?.nextStep() - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsTableViewProvider.swift b/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsTableViewProvider.swift deleted file mode 100644 index 5cfc48ad457f..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsTableViewProvider.swift +++ /dev/null @@ -1,96 +0,0 @@ - -import UIKit - -typealias CellSelectionHandler = ((IndexPath) -> ()) - -// MARK: - VerticalsTableViewProvider - -/// This table view provider fulfills the "happy path" role of data source & delegate for searching Site Verticals. -/// -final class VerticalsTableViewProvider: NSObject, TableViewProvider { - - // MARK: VerticalsTableViewProvider - - /// The table view serviced by this provider - private weak var tableView: UITableView? - - /// The underlying data represented by the provider - var data: [SiteVertical] { - didSet { - tableView?.reloadData() - } - } - - /// The closure to invoke when a row in the underlying table view has been selected - private let selectionHandler: CellSelectionHandler? - - /// Creates a VerticalsTableViewProvider. - /// - /// - Parameters: - /// - tableView: the table view to be managed - /// - data: initial data backing the table view - /// - selectionHandler: the action to perform when a cell is selected, if any - /// - init(tableView: UITableView, data: [SiteVertical] = [], selectionHandler: CellSelectionHandler? = nil) { - self.tableView = tableView - self.data = data - self.selectionHandler = selectionHandler - - super.init() - - tableView.dataSource = self - tableView.delegate = self - tableView.reloadData() - } - - // MARK: UITableViewDataSource - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return data.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let vertical = data[indexPath.row] - let cell = configureCell(vertical: vertical, indexPath: indexPath) - - addBorder(cell: cell, at: indexPath) - - return cell - } - - // MARK: UITableViewDelegate - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - selectionHandler?(indexPath) - } - - // MARK: Private behavior - - private func addBorder(cell: UITableViewCell, at: IndexPath) { - let row = at.row - if row == 0 { - cell.addTopBorder(withColor: .neutral(.shade10)) - } - - if row == data.count - 1 { - cell.addBottomBorder(withColor: .neutral(.shade10)) - } - } - - private func cellIdentifier(vertical: SiteVertical) -> String { - return vertical.isNew ? NewVerticalCell.cellReuseIdentifier() : VerticalsCell.cellReuseIdentifier() - } - - private func configureCell(vertical: SiteVertical, indexPath: IndexPath) -> UITableViewCell { - let identifier = cellIdentifier(vertical: vertical) - - if var cell = tableView?.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as? SiteVerticalPresenter { - cell.vertical = vertical - - return cell as! UITableViewCell - } - - return UITableViewCell() - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsWizardContent.swift b/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsWizardContent.swift deleted file mode 100644 index dd1525a844a8..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsWizardContent.swift +++ /dev/null @@ -1,527 +0,0 @@ -import UIKit -import WordPressKit -import WordPressAuthenticator - -/// Contains the UI corresponding to the list of verticals -/// -final class VerticalsWizardContent: UIViewController { - // MARK: Properties - - private static let defaultPrompt = SiteVerticalsPrompt( - title: NSLocalizedString("What's the focus of your business?", - comment: "Create site, step 2. Select focus of the business. Title"), - subtitle: NSLocalizedString("We'll use your answer to add sections to your website.", - comment: "Create site, step 2. Select focus of the business. Subtitle"), - hint: NSLocalizedString("e.g. Landscaping, Consulting... etc.", - comment: "Site creation. Select focus of your business, search field placeholder") - ) - - /// A collection of parameters uses for view layout - private struct Metrics { - static let rowHeight: CGFloat = 44.0 - static let separatorInset = UIEdgeInsets(top: 0, left: 16.0, bottom: 0, right: 0) - } - - /// The creator collects user input as they advance through the wizard flow. - private let siteCreator: SiteCreator - - /// The service which retrieves localized prompt verbiage specific to the chosen segment - private let promptService: SiteVerticalsPromptService - - /// The service which conducts searches for know verticals - private let verticalsService: SiteVerticalsService - - /// The action to perform once a Vertical is selected by the user - private let selection: (SiteVertical?) -> Void - - /// Makes sure we don't call the selection handler twice. - private var selectionHandled = false - - /// The localized prompt retrieved by remote service; `nil` otherwise - private var prompt: SiteVerticalsPrompt? - - /// We track the last prompt segment so that we can retry somewhat intelligently - private var lastSegmentIdentifer: Int64? = nil - - /// The throttle meters requests to the remote verticals service - private let throttle = Scheduler(seconds: 0.5) - - /// We track the last searched value so that we can retry - private var lastSearchQuery: String? = nil - - /// Locally tracks the network connection status via `NetworkStatusDelegate` - private var isNetworkActive = ReachabilityUtils.isInternetReachable() - - /// The table view renders our server content - @IBOutlet private weak var table: UITableView! - - /// The view wrapping the skip button - @IBOutlet weak var buttonWrapper: ShadowView! - - /// The skip button - @IBOutlet weak var nextStep: NUXButton! - - /// The constraint between the bottom of the buttonWrapper and this view controller's view - @IBOutlet weak var bottomConstraint: NSLayoutConstraint! - - /// Serves as both the data source & delegate of the table view - private(set) var tableViewProvider: TableViewProvider? - - /// Manages header visibility, keyboard management, and table view offset - private(set) var tableViewOffsetCoordinator: TableViewOffsetCoordinator? - - // MARK: VerticalsWizardContent - - /// The designated initializer. - /// - /// - Parameters: - /// - creator: accumulates user input as a user navigates through the site creation flow - /// - promptService: the service which retrieves localized prompt verbiage specific to the chosen segment - /// - verticalsService: the service which conducts searches for know verticals - /// - selection: the action to perform once a Vertical is selected by the user - /// - init(creator: SiteCreator, promptService: SiteVerticalsPromptService, verticalsService: SiteVerticalsService, selection: @escaping (SiteVertical?) -> Void) { - self.siteCreator = creator - self.promptService = promptService - self.verticalsService = verticalsService - self.selection = selection - - super.init(nibName: String(describing: type(of: self)), bundle: nil) - } - - // MARK: UIViewController - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - restoreSearchIfNeeded() - selectionHandled = false - postScreenChangedForVoiceOver() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - tableViewOffsetCoordinator?.stopListeningToKeyboardNotifications() - clearContent() - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - table.layoutHeaderView() - } - - override func viewDidLoad() { - super.viewDidLoad() - - self.tableViewOffsetCoordinator = TableViewOffsetCoordinator(coordinated: table, footerControlContainer: view, footerControl: buttonWrapper, toolbarBottomConstraint: bottomConstraint) - - applyTitle() - setupBackground() - setupButtonWrapper() - setupNextButton() - setupTable() - WPAnalytics.track(.enhancedSiteCreationVerticalsViewed) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - fetchPromptIfNeeded() - observeNetworkStatus() - tableViewOffsetCoordinator?.startListeningToKeyboardNotifications() - prepareViewIfNeeded() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - resignTextFieldResponderIfNeeded() - } - - // MARK: Private behavior - - private func applyTitle() { - title = NSLocalizedString("1 of 3", comment: "Site creation. Step 2. Screen title") - } - - private func clearContent() { - throttle.cancel() - - guard let validDataProvider = tableViewProvider as? VerticalsTableViewProvider else { - setupTableDataProvider() - return - } - validDataProvider.data = [] - tableViewOffsetCoordinator?.resetTableOffsetIfNeeded() - tableViewOffsetCoordinator?.showBottomToolbar() - } - - private func fetchPromptIfNeeded() { - // This should never apply, but we have a Segment? - guard let promptRequest = siteCreator.segment?.identifier else { - let defaultPrompt = VerticalsWizardContent.defaultPrompt - setupTableHeaderWithPrompt(defaultPrompt) - - return - } - - // We have already obtained this prompt - if prompt != nil, let lastRequestPromptIdentifier = lastSegmentIdentifer, lastRequestPromptIdentifier == promptRequest { - return - } - - // We are essentially resetting our search for a new segment ID - table.tableHeaderView = nil - prompt = nil - lastSearchQuery = nil - lastSegmentIdentifer = promptRequest - - promptService.retrieveVerticalsPrompt(request: promptRequest) { [weak self] serverPrompt in - guard let self = self else { - return - } - - let prompt: SiteVerticalsPrompt - if let serverPrompt = serverPrompt { - prompt = serverPrompt - } else { - prompt = VerticalsWizardContent.defaultPrompt - } - - self.setupTableHeaderWithPrompt(prompt) - } - } - - private func fetchVerticals(_ searchTerm: String) { - let request = SiteVerticalsRequest(search: searchTerm) - verticalsService.retrieveVerticals(request: request) { [weak self] result in - switch result { - case .success(let data): - self?.handleData(data) - case .failure(let error): - self?.handleError(error) - } - } - } - - private func handleData(_ data: [SiteVertical]) { - if let validDataProvider = tableViewProvider as? VerticalsTableViewProvider { - validDataProvider.data = data - } else { - setupTableDataProvider(data) - } - } - - private func handleError(_ error: Error? = nil) { - setupEmptyTableProvider() - } - - private func hideSeparators() { - table.tableFooterView = UIView(frame: .zero) - } - - private func performSearchIfNeeded(query: String) { - guard !query.isEmpty else { - return - } - - tableViewOffsetCoordinator?.hideBottomToolbar() - - lastSearchQuery = query - - guard isNetworkActive == true else { - setupEmptyTableProvider() - return - } - - throttle.throttle { [weak self] in - self?.fetchVerticals(query) - } - } - - private func registerCell(identifier: String) { - let nib = UINib(nibName: identifier, bundle: nil) - table.register(nib, forCellReuseIdentifier: identifier) - } - - private func registerCells() { - registerCell(identifier: VerticalsCell.cellReuseIdentifier()) - registerCell(identifier: NewVerticalCell.cellReuseIdentifier()) - - table.register(InlineErrorRetryTableViewCell.self, forCellReuseIdentifier: InlineErrorRetryTableViewCell.cellReuseIdentifier()) - } - - private func resignTextFieldResponderIfNeeded() { - guard WPDeviceIdentification.isiPhone(), let header = self.table.tableHeaderView as? TitleSubtitleTextfieldHeader else { - return - } - - let textField = header.textField - textField.resignFirstResponder() - } - - private func restoreSearchIfNeeded() { - guard let header = self.table.tableHeaderView as? TitleSubtitleTextfieldHeader, let currentSegmentID = siteCreator.segment?.identifier, let lastSegmentID = lastSegmentIdentifer, currentSegmentID == lastSegmentID else { - - return - } - - let textField = header.textField - guard let inputText = textField.text, !inputText.isEmpty else { - return - } - - tableViewOffsetCoordinator?.adjustTableOffsetIfNeeded() - performSearchIfNeeded(query: inputText) - } - - private func prepareViewIfNeeded() { - guard WPDeviceIdentification.isiPhone(), let header = self.table.tableHeaderView as? TitleSubtitleTextfieldHeader, let currentSegmentID = siteCreator.segment?.identifier, let lastSegmentID = lastSegmentIdentifer, currentSegmentID == lastSegmentID else { - - return - } - - let textField = header.textField - guard let inputText = textField.text, !inputText.isEmpty else { - return - } - textField.becomeFirstResponder() - } - - private func setupBackground() { - view.backgroundColor = .listBackground - } - - private func setupCellHeight() { - table.rowHeight = UITableView.automaticDimension - table.estimatedRowHeight = Metrics.rowHeight - table.separatorInset = Metrics.separatorInset - } - - private func setupCells() { - registerCells() - setupCellHeight() - } - - private func setupConstraints() { - table.cellLayoutMarginsFollowReadableWidth = true - - NSLayoutConstraint.activate([ - table.topAnchor.constraint(equalTo: view.prevailingLayoutGuide.topAnchor), - table.bottomAnchor.constraint(equalTo: view.prevailingLayoutGuide.bottomAnchor), - table.leadingAnchor.constraint(equalTo: view.prevailingLayoutGuide.leadingAnchor), - table.trailingAnchor.constraint(equalTo: view.prevailingLayoutGuide.trailingAnchor), - ]) - } - - private func setupEmptyTableProvider() { - let message: InlineErrorMessage - if isNetworkActive { - message = InlineErrorMessages.serverError - } else { - message = InlineErrorMessages.noConnection - } - - let handler: CellSelectionHandler = { [weak self] _ in - let retryQuery = self?.lastSearchQuery ?? "" - self?.performSearchIfNeeded(query: retryQuery) - } - - tableViewProvider = InlineErrorTableViewProvider(tableView: table, message: message, selectionHandler: handler) - } - - private func setupButtonWrapper() { - buttonWrapper.backgroundColor = .listBackground - } - - private func setupNextButton() { - nextStep.addTarget(self, action: #selector(skip), for: .touchUpInside) - - setupButtonAsSkip() - } - - private func setupButtonAsSkip() { - let buttonTitle = NSLocalizedString("Skip", comment: "Button to progress to the next step") - nextStep.setTitle(buttonTitle, for: .normal) - nextStep.accessibilityLabel = buttonTitle - nextStep.accessibilityHint = NSLocalizedString("Navigates to the next step without making changes", comment: "Site creation. Navigates to the next step") - - nextStep.isPrimary = false - } - - private func setupTable() { - setupTableBackground() - setupTableSeparator() - setupCells() - setupConstraints() - hideSeparators() - - setupTableDataProvider() - } - - private func setupTableBackground() { - table.backgroundColor = .listBackground - } - - private func setupTableHeaderWithPrompt(_ prompt: SiteVerticalsPrompt) { - self.prompt = prompt - - table.tableHeaderView = nil - - let header = TitleSubtitleTextfieldHeader(frame: .zero) - header.setTitle(prompt.title) - header.setSubtitle(prompt.subtitle) - - header.textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) - header.textField.delegate = self - - header.accessibilityTraits = .header - - let placeholderText = prompt.hint - let attributes = WPStyleGuide.defaultSearchBarTextAttributesSwifted(.textPlaceholder) - let attributedPlaceholder = NSAttributedString(string: placeholderText, attributes: attributes) - header.textField.attributedPlaceholder = attributedPlaceholder - header.textField.returnKeyType = .done - - table.tableHeaderView = header - - NSLayoutConstraint.activate([ - header.widthAnchor.constraint(equalTo: table.widthAnchor), - header.centerXAnchor.constraint(equalTo: table.centerXAnchor), - ]) - } - - private func setupTableDataProvider(_ data: [SiteVertical] = []) { - let handler: CellSelectionHandler = { [weak self] selectedIndexPath in - guard let self = self, let provider = self.tableViewProvider as? VerticalsTableViewProvider else { - return - } - - let vertical = provider.data[selectedIndexPath.row] - self.select(vertical) - self.trackVerticalSelection(vertical) - } - - self.tableViewProvider = VerticalsTableViewProvider(tableView: table, data: data, selectionHandler: handler) - } - - private func setupTableSeparator() { - table.separatorColor = .divider - } - - private func trackVerticalSelection(_ vertical: SiteVertical) { - let verticalProperties: [String: AnyObject] = [ - "vertical_name": vertical.title as AnyObject, - "vertical_id": vertical.identifier as AnyObject, - "vertical_is_user": vertical.isNew as AnyObject - ] - - WPAnalytics.track(.enhancedSiteCreationVerticalsSelected, withProperties: verticalProperties) - } - - @objc - private func textChanged(sender: UITextField) { - guard let searchTerm = sender.text, searchTerm.isEmpty == false else { - clearContent() - return - } - - performSearchIfNeeded(query: searchTerm) - tableViewOffsetCoordinator?.adjustTableOffsetIfNeeded() - } - - @objc - private func skip() { - select(nil) - WPAnalytics.track(.enhancedSiteCreationVerticalsSkipped) - } - - private func searchAndSelectVertical(_ textField: UITextField) { - guard let verticalName = textField.text, - verticalName.count > 0 else { - return - } - - verticalsService.retrieveVertical(named: verticalName) { [weak self] result in - guard let self = self else { - return - } - - switch result { - case .success(let vertical): - // If the user has changed the contents of the text field while the request was being executed - // we'll cancel the operation - guard verticalName == textField.text else { - return - } - - self.select(vertical) - case .failure: - // For now we're purposedly not taking any action here. - break - } - } - } - - /// Convenience method to make sure we don't execute the selection handler twice. - /// We should avoid calling the selection handler directly and user this method instead. - /// This method is also thread safe. - /// - private func select(_ vertical: SiteVertical?) { - DispatchQueue.main.async { [weak self] in - guard let self = self, - !self.selectionHandled else { - return - } - - self.selectionHandled = true - self.selection(vertical) - } - } -} - -// MARK: - NetworkStatusDelegate - -extension VerticalsWizardContent: NetworkStatusDelegate { - func networkStatusDidChange(active: Bool) { - isNetworkActive = active - } -} - -// MARK: - UITextFieldDelegate - -extension VerticalsWizardContent: UITextFieldDelegate { - func textFieldShouldClear(_ textField: UITextField) -> Bool { - tableViewOffsetCoordinator?.resetTableOffsetIfNeeded() - return true - } - - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - searchAndSelectVertical(textField) - - return true - } -} - -extension VerticalsWizardContent { - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { - preferredContentSizeDidChange() - } - } - - func preferredContentSizeDidChange() { - tableViewOffsetCoordinator?.adjustTableOffsetIfNeeded() - } -} - -// MARK: - VoiceOver - -private extension VerticalsWizardContent { - func postScreenChangedForVoiceOver() { - UIAccessibility.post(notification: .screenChanged, argument: table.tableHeaderView) - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsWizardContent.xib b/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsWizardContent.xib deleted file mode 100644 index 7e98f444f979..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/Verticals/VerticalsWizardContent.xib +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell+ViewModel.swift b/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell+ViewModel.swift new file mode 100644 index 000000000000..64a2793f8ad4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell+ViewModel.swift @@ -0,0 +1,139 @@ +import Foundation + +extension AddressTableViewCell { + + struct ViewModel { + let domain: String + let tags: [Tag] + let cost: Cost + + enum Cost { + case free + case regular(cost: String) + case onSale(cost: String, sale: String) + } + + enum Tag { + case recommended + case bestAlternative + case sale + + var localizedString: String { + switch self { + case .recommended: return Strings.recommended + case .bestAlternative: return Strings.bestAlternative + case .sale: return Strings.sale + } + } + } + } +} + +extension AddressTableViewCell.ViewModel { + + enum Strings { + static let free = NSLocalizedString( + "domain.suggestions.row.free", + value: "Free", + comment: "The text to display for free domains in 'Site Creation > Choose a domain' screen" + ) + static let yearly = NSLocalizedString( + "domain.suggestions.row.yearly", + value: "per year", + comment: "The text to display for paid domains in 'Site Creation > Choose a domain' screen" + ) + static let firstYear = NSLocalizedString( + "domain.suggestions.row.first-year", + value: "for the first year", + comment: "The text to display for paid domains on sale in 'Site Creation > Choose a domain' screen" + ) + static let recommended = NSLocalizedString( + "domain.suggestions.row.recommended", + value: "Recommended", + comment: "The 'Recommended' label under the domain name in 'Choose a domain' screen" + ) + static let bestAlternative = NSLocalizedString( + "domain.suggestions.row.best-alternative", + value: "Best Alternative", + comment: "The 'Best Alternative' label under the domain name in 'Choose a domain' screen" + ) + static let sale = NSLocalizedString( + "domain.suggestions.row.sale", + value: "Sale", + comment: "The 'Sale' label under the domain name in 'Choose a domain' screen" + ) + } +} + +extension AddressTableViewCell.ViewModel { + + init(model: DomainSuggestion, tags: [Tag] = []) { + // Declare variables + var tags = tags + let cost: Cost + + // Format cost and sale cost + if model.isFree { + cost = .free + } else if let formatter = Self.currencyFormatter(code: model.currencyCode), + let costValue = model.cost, + let formattedCost = formatter.string(from: .init(value: costValue)) { + if let saleCost = model.saleCost, let formattedSaleCost = formatter.string(from: .init(value: saleCost)) { + cost = .onSale(cost: formattedCost, sale: formattedSaleCost) + } else { + cost = .regular(cost: formattedCost) + } + } else { + cost = .regular(cost: model.costString) + } + + // Configure tags + if case .onSale = cost { + tags.append(.sale) + } + + // Initialize instance + self.init( + domain: model.domainName, + tags: tags, + cost: cost + ) + } + + /// Returns a list of tags depending on the row's position in the list. + /// - Parameter position: The position of the domin suggestion in the list. + /// - Returns: A list of tags. + static func tagsFromPosition(_ position: Int) -> [Tag] { + switch position { + case 0: return [.recommended] + case 1: return [.bestAlternative] + default: return [] + } + } + + private static func currencyFormatter(code: String?) -> NumberFormatter? { + guard let code else { + return nil + } + + let formatter = Self.Cache.currencyFormatter ?? { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.maximumFractionDigits = 2 + formatter.minimumFractionDigits = 0 + return formatter + }() + + if formatter.currencyCode != code { + formatter.currencyCode = code + } + + Self.Cache.currencyFormatter = formatter + + return formatter + } + + private enum Cache { + static var currencyFormatter: NumberFormatter? + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell.swift b/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell.swift new file mode 100644 index 000000000000..627b81c35fc1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/AddressTableViewCell.swift @@ -0,0 +1,319 @@ +import UIKit +import WordPressKit + +final class AddressTableViewCell: UITableViewCell { + + // MARK: - Dependencies + + private var domainPurchasingEnabled: Bool { + FeatureFlag.siteCreationDomainPurchasing.enabled && ABTest.siteCreationDomainPurchasing.isTreatmentVariation + } + + // MARK: - Views + + private var borders = [UIView]() + + private let domainLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.font = Appearance.domainFont + label.textColor = Appearance.domainTextColor + return label + }() + + private let tagsLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.font = Appearance.tagFont + return label + }() + + private let dotView: UIView = { + let length = Appearance.dotViewRadius * 2 + let view = UIView() + view.clipsToBounds = true + view.layer.cornerRadius = Appearance.dotViewRadius + view.translatesAutoresizingMaskIntoConstraints = true + view.frame.size = .init(width: length, height: length) + return view + }() + + private let checkmarkImageView: UIView = { + let configuration = UIImage.SymbolConfiguration(weight: .semibold) + let image = UIImage(systemName: "checkmark", withConfiguration: configuration) + let view = UIImageView() + view.isHidden = true + view.image = image + view.translatesAutoresizingMaskIntoConstraints = true + view.sizeToFit() + return view + }() + + private let costLabel: UILabel = { + let label = UILabel() + label.adjustsFontForContentSizeCategory = true + label.font = Appearance.domainFont + label.textColor = Appearance.domainTextColor + label.numberOfLines = 2 + return label + }() + + // MARK: - Init + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + commonInit() + if domainPurchasingEnabled { + setupSubviews() + } + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + private func commonInit() { + self.accessibilityTraits = .button + self.accessibilityHint = NSLocalizedString( + "Selects this domain to use for your site.", + comment: "Accessibility hint for a domain in the Site Creation domains list." + ) + if domainPurchasingEnabled { + self.accessibilityElements = [domainLabel, tagsLabel, costLabel] + } else { + self.selectedBackgroundView?.backgroundColor = .clear + } + } + + private func setupSubviews() { + let domainAndTag = Self.stackedViews([domainLabel, tagsLabel], axis: .vertical, alignment: .fill, distribution: .fill, spacing: 2) + let main = Self.stackedViews([domainAndTag, costLabel], axis: .horizontal, alignment: .center, distribution: .equalCentering, spacing: 0) + main.translatesAutoresizingMaskIntoConstraints = false + main.isLayoutMarginsRelativeArrangement = true + main.directionalLayoutMargins = Appearance.contentMargins + self.contentView.addSubview(main) + self.contentView.pinSubviewToAllEdges(main) + self.updatePriceLabelTextAlignment() + self.contentView.addSubview(dotView) + self.contentView.addSubview(checkmarkImageView) + self.selectedBackgroundView = { + let view = UIView() + view.backgroundColor = UIColor(light: .secondarySystemBackground, dark: .tertiarySystemBackground) + return view + }() + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + [dotView, checkmarkImageView].forEach { view in + view.center = CGPoint(x: Appearance.contentMargins.leading / 2, y: bounds.midY) + } + } + + // MARK: - React to Trait Collection Changes + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.layoutDirection != previousTraitCollection?.layoutDirection { + self.updatePriceLabelTextAlignment() + } + } + + // MARK: - Updating UI + + /// This is the new update method and it's called when `domainPurchasing` feature flag is enabled. + func update(with viewModel: ViewModel) { + self.domainLabel.text = viewModel.domain + self.tagsLabel.attributedText = Self.tagsAttributedString(tags: viewModel.tags) + self.tagsLabel.isHidden = viewModel.tags.isEmpty + self.costLabel.attributedText = Self.costAttributedString(cost: viewModel.cost) + self.dotView.backgroundColor = Appearance.dotColor(viewModel.tags.first) + } + + /// Updates the `costLabel` text alignment. + private func updatePriceLabelTextAlignment() { + switch traitCollection.layoutDirection { + case .rightToLeft: + // swiftlint:disable:next natural_text_alignment + self.costLabel.textAlignment = .left + default: + // swiftlint:disable:next inverse_text_alignment + self.costLabel.textAlignment = .right + } + } + + // MARK: - Helpers + + private static func tagsAttributedString(tags: [ViewModel.Tag]) -> NSAttributedString? { + guard !tags.isEmpty else { + return nil + } + let attributedString = NSMutableAttributedString() + for (index, tag) in tags.enumerated() { + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: Appearance.tagTextColor(tag) + ] + let string = index == 0 ? tag.localizedString : "\n\(tag.localizedString)" + attributedString.append(.init(string: string, attributes: attributes)) + } + return attributedString + } + + private static func costAttributedString(cost: ViewModel.Cost) -> NSAttributedString { + switch cost { + case .free: + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.label, + .font: Appearance.regularCostFont + ] + return NSAttributedString(string: ViewModel.Strings.free, attributes: attributes) + case .regular(let cost): + var attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.label, + .font: Appearance.regularCostFont + ] + let attributedString = NSMutableAttributedString(string: cost, attributes: attributes) + attributes[.font] = Appearance.smallCostFont + attributedString.append(.init(string: "\n\(ViewModel.Strings.yearly)", attributes: attributes)) + return attributedString + case .onSale(let cost, let sale): + let saleAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: Appearance.saleCostTextColor, + .font: Appearance.semiboldCostFont + ] + let costAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.secondaryLabel, + .font: Appearance.smallCostFont, + .strikethroughStyle: NSUnderlineStyle.single.rawValue + ] + let firstYearAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: Appearance.saleCostTextColor, + .font: Appearance.smallCostFont + ] + let attributedString = NSMutableAttributedString(string: cost, attributes: costAttributes) + attributedString.append(NSAttributedString(string: " \(sale)", attributes: saleAttributes)) + attributedString.append(.init(string: "\n\(ViewModel.Strings.firstYear)", attributes: firstYearAttributes)) + return attributedString + } + } + + private static func stackedViews( + _ subviews: [UIView], + axis: NSLayoutConstraint.Axis, + alignment: UIStackView.Alignment, + distribution: UIStackView.Distribution, + spacing: CGFloat) -> UIStackView { + let stackView = UIStackView(arrangedSubviews: subviews) + stackView.axis = axis + stackView.alignment = alignment + stackView.distribution = distribution + stackView.spacing = spacing + return stackView + } + + // MARK: - Constants + + enum Appearance { + + static let contentMargins = NSDirectionalEdgeInsets(top: 16, leading: 40, bottom: 16, trailing: 16) + + static let domainFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + static let domainTextColor = UIColor.text + + static let regularCostFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + static let semiboldCostFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + static let smallCostFont = WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .regular) + static let saleCostTextColor = UIColor(light: .muriel(name: .jetpackGreen, .shade50), dark: .muriel(name: .jetpackGreen, .shade30)) + + static let tagFont = WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .regular) + static let tagTextColor: (ViewModel.Tag?) -> UIColor = { tag in + guard let tag else { + return .clear + } + switch tag { + case .recommended: return UIColor(light: .muriel(name: .jetpackGreen, .shade50), dark: .muriel(name: .jetpackGreen, .shade30)) + case .bestAlternative: return UIColor(light: .muriel(name: .purple, .shade50), dark: .muriel(name: .purple, .shade30)) + case .sale: return UIColor(light: .muriel(name: .yellow, .shade50), dark: .muriel(name: .yellow, .shade30)) + } + } + + static let dotViewRadius: CGFloat = 4 + static let dotColor: (ViewModel.Tag?) -> UIColor = Appearance.tagTextColor + } +} + +// MARK: - Old Table View Cell Design + +extension AddressTableViewCell { + + override func awakeFromNib() { + super.awakeFromNib() + styleCheckmark() + } + + override func setSelected(_ selected: Bool, animated: Bool) { + if domainPurchasingEnabled { + super.setSelected(selected, animated: animated) + self.checkmarkImageView.isHidden = !selected + self.dotView.isHidden = !checkmarkImageView.isHidden + } else { + accessoryType = selected ? .checkmark : .none + } + } + + private func styleCheckmark() { + tintColor = .primary(.shade40) + } + + override func prepareForReuse() { + update(with: nil as DomainSuggestion?) + borders.forEach({ $0.removeFromSuperview() }) + borders = [] + } + + func update(with model: DomainSuggestion?) { + self.textLabel?.attributedText = AddressTableViewCell.processName(model?.domainName) + } + + public func addBorder(isFirstCell: Bool = false, isLastCell: Bool = false) { + if isFirstCell { + let border = addTopBorder(withColor: .divider) + borders.append(border) + } + + if isLastCell { + let border = addBottomBorder(withColor: .divider) + borders.append(border) + } else { + let border = addBottomBorder(withColor: .divider, leadingMargin: 20) + borders.append(border) + } + } + + public static func processName(_ domainName: String?) -> NSAttributedString? { + guard let name = domainName, + let customName = name.components(separatedBy: ".").first else { + return nil + } + + let completeDomainName = NSMutableAttributedString(string: name, attributes: TextStyleAttributes.defaults) + let rangeOfCustomName = NSRange(location: 0, length: customName.count) + completeDomainName.setAttributes(TextStyleAttributes.customName, range: rangeOfCustomName) + + return completeDomainName + } + + static var estimatedSize: CGSize { + return CGSize(width: 320, height: 45) + } + + private struct TextStyleAttributes { + static let defaults: [NSAttributedString.Key: Any] = [.font: WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular), + .foregroundColor: UIColor.textSubtle] + static let customName: [NSAttributedString.Key: Any] = [.font: WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular), + .foregroundColor: UIColor.text] + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Color+DesignSystem.swift b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Color+DesignSystem.swift new file mode 100644 index 000000000000..da80ef121052 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Color+DesignSystem.swift @@ -0,0 +1,62 @@ +import SwiftUI + +/// Design System Color extensions. Keep it in sync with its sibling file `UIColor+DesignSystem` +/// to support borth API's equally. +public extension Color { + enum DS { + public enum Foreground { + public static let primary = Color(DesignSystemColorNames.Foreground.primary) + public static let secondary = Color(DesignSystemColorNames.Foreground.secondary) + public static let tertiary = Color(DesignSystemColorNames.Foreground.tertiary) + public static let quaternary = Color(DesignSystemColorNames.Foreground.quaternary) + } + + public enum Background { + public static let primary = Color(DesignSystemColorNames.Background.primary) + public static let secondary = Color(DesignSystemColorNames.Background.secondary) + public static let tertiary = Color(DesignSystemColorNames.Background.tertiary) + public static let quaternary = Color(DesignSystemColorNames.Background.quaternary) + + public static var brand: Color { + if AppConfiguration.isJetpack { + return jetpack + } else { + return jetpack // FIXME: WordPress colors + } + } + + private static let jetpack = Color(DesignSystemColorNames.Background.jetpack) + } + + public enum Border { + public static let primary = Color(DesignSystemColorNames.Border.primary) + public static let secondary = Color(DesignSystemColorNames.Border.secondary) + public static let divider = Color(DesignSystemColorNames.Border.divider) + } + } +} + +/// Once we move Design System to its own module, we should keep this `internal` +/// as we don't need to expose it to the application module +internal enum DesignSystemColorNames { + internal enum Foreground { + internal static let primary = "foregroundPrimary" + internal static let secondary = "foregroundSecondary" + internal static let tertiary = "foregroundTertiary" + internal static let quaternary = "foregroundQuaternary" + } + + internal enum Background { + internal static let primary = "backgroundPrimary" + internal static let secondary = "backgroundSecondary" + internal static let tertiary = "backgroundTertiary" + internal static let quaternary = "backgroundQuaternary" + internal static let jetpack = "brandJetpack" + } + + internal enum Border { + internal static let primary = "borderPrimary" + internal static let secondary = "borderSecondary" + internal static let divider = "borderDivider" + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/ColorGallery.swift b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/ColorGallery.swift new file mode 100644 index 000000000000..3668a319f711 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/ColorGallery.swift @@ -0,0 +1,122 @@ +import SwiftUI + +struct ColorGallery: View { + @SwiftUI.Environment(\.colorScheme) var colorScheme + + var body: some View { + List { + foregroundSection + backgroundSection + borderSection + } + } + + private var foregroundSection: some View { + Section(header: sectionTitle("Foreground")) { + listItem( + with: "Primary", + hexString: hexString(for: .DS.Foreground.primary), + color: Color.DS.Foreground.primary + ) + listItem( + with: "Secondary", + hexString: hexString(for: .DS.Foreground.secondary), + color: Color.DS.Foreground.secondary + ) + listItem( + with: "Tertiary", + hexString: hexString(for: .DS.Foreground.tertiary), + color: Color.DS.Foreground.tertiary + ) + listItem( + with: "Quaternary", + hexString: hexString(for: .DS.Foreground.quaternary), + color: Color.DS.Foreground.quaternary + ) + } + } + + private var backgroundSection: some View { + Section(header: sectionTitle("Background")) { + listItem( + with: "Brand", + hexString: hexString(for: .DS.Background.brand), + color: Color.DS.Background.brand + ) + listItem( + with: "Primary", + hexString: hexString(for: .DS.Background.primary), + color: Color.DS.Background.primary + ) + listItem( + with: "Secondary", + hexString: hexString(for: .DS.Background.secondary), + color: Color.DS.Background.secondary + ) + listItem( + with: "Tertiary", + hexString: hexString(for: .DS.Background.tertiary), + color: Color.DS.Background.tertiary + ) + listItem( + with: "Quaternary", + hexString: hexString(for: .DS.Background.quaternary), + color: Color.DS.Background.quaternary + ) + } + } + + private var borderSection: some View { + Section(header: sectionTitle("Border")) { + listItem( + with: "Divider", + hexString: hexString(for: .DS.Border.divider), + color: Color.DS.Border.divider + ) + listItem( + with: "Primary", + hexString: hexString(for: .DS.Border.primary), + color: Color.DS.Border.primary + ) + listItem( + with: "Secondary", + hexString: hexString(for: .DS.Border.secondary), + color: Color.DS.Border.secondary + ) + } + } + + private func hexString(for color: UIColor?) -> String? { + colorScheme == .light ? color?.lightVariant().hexString() : color?.darkVariant().hexString() + } + + private func sectionTitle(_ title: String) -> some View { + Text(title) + .font(Font.headline) + .foregroundColor(.DS.Foreground.primary) + } + + private func colorSquare(_ color: Color) -> some View { + RoundedRectangle(cornerRadius: 8) + .fill(color) + .frame(width: 44, height: 44) + } + + private func listItem(with name: String, hexString: String?, color: Color) -> some View { + HStack(spacing: 16) { + colorSquare(color) + VStack(spacing: 8) { + HStack { + Text(name) + .foregroundColor(.DS.Foreground.primary) + Spacer() + } + HStack { + Text("#\(hexString ?? "")") + .foregroundColor(.DS.Foreground.secondary) + Spacer() + } + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteBackgroundPrimary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteBackgroundPrimary.colorset/Contents.json new file mode 100644 index 000000000000..ca11ee7596c6 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteBackgroundPrimary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.118", + "green" : "0.110", + "red" : "0.110" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteBackgroundSecondary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteBackgroundSecondary.colorset/Contents.json new file mode 100644 index 000000000000..89e027c1d029 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteBackgroundSecondary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.965", + "green" : "0.945", + "red" : "0.949" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.118", + "green" : "0.110", + "red" : "0.110" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteForegroundPrimary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteForegroundPrimary.colorset/Contents.json new file mode 100644 index 000000000000..04256378ab02 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteForegroundPrimary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteForegroundSecondary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteForegroundSecondary.colorset/Contents.json new file mode 100644 index 000000000000..374a5180bb86 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteForegroundSecondary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.557", + "green" : "0.541", + "red" : "0.541" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.624", + "green" : "0.596", + "red" : "0.596" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteGradientInitial.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteGradientInitial.colorset/Contents.json new file mode 100644 index 000000000000..136dd525396e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteGradientInitial.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "237", + "green" : "232", + "red" : "232" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "46", + "green" : "44", + "red" : "44" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteTooltipBackground.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteTooltipBackground.colorset/Contents.json new file mode 100644 index 000000000000..9c0e331e973d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteTooltipBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteTooltipBorder.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteTooltipBorder.colorset/Contents.json new file mode 100644 index 000000000000..09a39d7940c2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteTooltipBorder.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "204", + "green" : "204", + "red" : "204" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.000", + "blue" : "30", + "green" : "28", + "red" : "28" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteTooltipGradientInitial.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteTooltipGradientInitial.colorset/Contents.json new file mode 100644 index 000000000000..e2ba7ba8d2b5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Custom/SiteCreation/emptySiteTooltipGradientInitial.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "246", + "green" : "244", + "red" : "244" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "25", + "green" : "24", + "red" : "24" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Background/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Background/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Background/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/borderDivider.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/borderDivider.colorset/Contents.json new file mode 100644 index 000000000000..5a9111d90cd6 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/borderDivider.colorset/Contents.json @@ -0,0 +1,28 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "ios", + "reference" : "separatorColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "ios", + "reference" : "separatorColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/borderPrimary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/borderPrimary.colorset/Contents.json new file mode 100644 index 000000000000..274b3ba4edf5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/borderPrimary.colorset/Contents.json @@ -0,0 +1,28 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "ios", + "reference" : "opaqueSeparatorColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "ios", + "reference" : "opaqueSeparatorColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/borderSecondary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/borderSecondary.colorset/Contents.json new file mode 100644 index 000000000000..6078a53374fd --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Border/borderSecondary.colorset/Contents.json @@ -0,0 +1,28 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "ios", + "reference" : "systemGray3Color" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "ios", + "reference" : "systemGray3Color" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundPrimary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundPrimary.colorset/Contents.json new file mode 100644 index 000000000000..1effc4f12791 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundPrimary.colorset/Contents.json @@ -0,0 +1,28 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "universal", + "reference" : "labelColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "universal", + "reference" : "labelColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundQuaternary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundQuaternary.colorset/Contents.json new file mode 100644 index 000000000000..a2c537a2949c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundQuaternary.colorset/Contents.json @@ -0,0 +1,28 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "universal", + "reference" : "quaternaryLabelColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "universal", + "reference" : "quaternaryLabelColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundSecondary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundSecondary.colorset/Contents.json new file mode 100644 index 000000000000..92d12ef42b40 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundSecondary.colorset/Contents.json @@ -0,0 +1,28 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "universal", + "reference" : "secondaryLabelColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "universal", + "reference" : "secondaryLabelColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundTertiary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundTertiary.colorset/Contents.json new file mode 100644 index 000000000000..e04eff94d4f5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Foreground/foregroundTertiary.colorset/Contents.json @@ -0,0 +1,28 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "universal", + "reference" : "tertiaryLabelColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "universal", + "reference" : "tertiaryLabelColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Theme/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Theme/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Theme/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Theme/brandJetpack.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Theme/brandJetpack.colorset/Contents.json new file mode 100644 index 000000000000..c79f29f275e6 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/Theme/brandJetpack.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x10", + "green" : "0x87", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1F", + "green" : "0xB4", + "red" : "0x2F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundPrimary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundPrimary.colorset/Contents.json new file mode 100644 index 000000000000..7175aa037611 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundPrimary.colorset/Contents.json @@ -0,0 +1,28 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "ios", + "reference" : "systemBackgroundColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "ios", + "reference" : "systemBackgroundColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundQuaternary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundQuaternary.colorset/Contents.json new file mode 100644 index 000000000000..77ab6c6cdeff --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundQuaternary.colorset/Contents.json @@ -0,0 +1,28 @@ +{ + "colors" : [ + { + "color" : { + "platform" : "ios", + "reference" : "quaternarySystemFillColor" + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "ios", + "reference" : "quaternarySystemFillColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundSecondary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundSecondary.colorset/Contents.json new file mode 100644 index 000000000000..764d2e27f1d5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundSecondary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "246", + "green" : "241", + "red" : "242" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "30", + "green" : "28", + "red" : "28" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundTertiary.colorset/Contents.json b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundTertiary.colorset/Contents.json new file mode 100644 index 000000000000..5d761c058695 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/Colors.xcassets/Foundation/backgroundTertiary.colorset/Contents.json @@ -0,0 +1,31 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "extended-gray", + "components" : { + "alpha" : "1.000", + "white" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "platform" : "ios", + "reference" : "tertiarySystemBackgroundColor" + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/UIColor+DesignSystem.swift b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/UIColor+DesignSystem.swift new file mode 100644 index 000000000000..ab5da1ad789b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/DesignSystem/UIColor+DesignSystem.swift @@ -0,0 +1,42 @@ +import UIKit + +/// Replica of the `Color` structure +/// The reason for not using the `Color` intializer of `UIColor` is that +/// it has dubious effects. Also the doc advises against it. +/// Even though `UIColor(SwiftUI.Color)` keeps the adaptability for color theme, +/// accessing to light or dark variants specifically via trait collection does not return the right values +/// if the color is initialized as such. Probably one of the reasons why they advise against it. +/// To make these values non-optional, we use `Color` versions as fallback. +public extension UIColor { + enum DS { + public enum Foreground { + public static let primary = UIColor(named: DesignSystemColorNames.Foreground.primary) + public static let secondary = UIColor(named: DesignSystemColorNames.Foreground.secondary) + public static let tertiary = UIColor(named: DesignSystemColorNames.Foreground.tertiary) + public static let quaternary = UIColor(named: DesignSystemColorNames.Foreground.quaternary) + } + + public enum Background { + public static let primary = UIColor(named: DesignSystemColorNames.Background.primary) + public static let secondary = UIColor(named: DesignSystemColorNames.Background.secondary) + public static let tertiary = UIColor(named: DesignSystemColorNames.Background.tertiary) + public static let quaternary = UIColor(named: DesignSystemColorNames.Background.quaternary) + + public static var brand: UIColor? { + if AppConfiguration.isJetpack { + return jetpack + } else { + return jetpack // FIXME: WordPress colors + } + } + + private static let jetpack = UIColor(named: DesignSystemColorNames.Background.jetpack) + } + + public enum Border { + public static let primary = UIColor(named: DesignSystemColorNames.Border.primary) + public static let secondary = UIColor(named: DesignSystemColorNames.Border.secondary) + public static let divider = UIColor(named: DesignSystemColorNames.Border.divider) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/SiteCreationEmptySiteTemplate.swift b/WordPress/Classes/ViewRelated/Site Creation/Web Address/SiteCreationEmptySiteTemplate.swift new file mode 100644 index 000000000000..a79c483b5c17 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/SiteCreationEmptySiteTemplate.swift @@ -0,0 +1,146 @@ +import SwiftUI + +struct SiteCreationEmptySiteTemplate: View { + private enum Constants { + static let innerCornerRadius: CGFloat = 8 + static let containerCornerRadius: CGFloat = 16 + static let containerStackSpacing: CGFloat = 0 + static let siteBarHeight: CGFloat = 38 + static let siteBarStackSpacing: CGFloat = 8 + static let iconScaleFactor = 0.85 + } + + var body: some View { + VStack(spacing: Constants.containerStackSpacing) { + siteBarVStack + tooltip + } + .background( + LinearGradient( + gradient: Gradient( + colors: [Color.emptySiteGradientInitial, Color.emptySiteBackgroundPrimary] + ), + startPoint: .top, + endPoint: .center + ) + ) + .cornerRadius(Constants.containerCornerRadius) + } + + private var siteBarVStack: some View { + VStack(spacing: Constants.siteBarStackSpacing) { + siteBarHStack + Rectangle() + .fill( + LinearGradient( + gradient: Gradient( + colors: [Color.emptySiteTooltipGradientInitial, Color.emptySiteBackgroundPrimary] + ), + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(height: 100) + .cornerRadius(Constants.innerCornerRadius) + } + .padding(8) + } + + private var siteBarHStack: some View { + HStack(spacing: Constants.siteBarStackSpacing) { + siteSearchFieldHStack + plusView + } + .frame(height: Constants.siteBarHeight) + } + + private var siteSearchFieldHStack: some View { + ZStack { + HStack(spacing: Constants.siteBarStackSpacing) { + Image(systemName: "lock") + .scaleEffect(x: Constants.iconScaleFactor, y: Constants.iconScaleFactor) + .foregroundColor(Color.emptySiteForegroundSecondary) + Text(Strings.searchBarSiteAddress) + .font(.caption) + .accentColor(Color.emptySiteForegroundPrimary) + Spacer() + } + .padding([.leading, .trailing], Constants.siteBarStackSpacing) + } + .frame(height: Constants.siteBarHeight) + .background(Color.emptySiteBackgroundPrimary) + .cornerRadius(Constants.innerCornerRadius) + } + + private var plusView: some View { + ZStack { + RoundedRectangle(cornerRadius: Constants.innerCornerRadius) + .fill(Color.emptySiteBackgroundPrimary) + .frame(width: Constants.siteBarHeight, height: Constants.siteBarHeight) + Image(systemName: "plus") + .scaleEffect(x: Constants.iconScaleFactor, y: Constants.iconScaleFactor) + .foregroundColor(Color.emptySiteForegroundSecondary) + } + } + + private var tooltip: some View { + ZStack { + VStack(spacing: Constants.siteBarStackSpacing) { + HStack { + HStack { + Text(Strings.tooltipSiteName) + .font(.caption) + .padding(5) + } + .background(Color.emptySiteBackgroundSecondary) + .cornerRadius(5) + Spacer() + } + Text(Strings.tooltipDescription) + .font(.footnote) + .foregroundColor(Color.emptySiteForegroundSecondary) + } + .padding(.init(top: 16, leading: 16, bottom: 16, trailing: 16)) + } + .background(Color.emptySiteTooltipBackground) + .clipShape(RoundedRectangle(cornerRadius: 5)) + .overlay( + RoundedRectangle(cornerRadius: 5) + .strokeBorder(Color.emptySiteTooltipBorder, lineWidth: 0.8) + ) + .padding(.bottom, 8) + } +} + +private extension SiteCreationEmptySiteTemplate { + enum Strings { + static let tooltipSiteName = NSLocalizedString( + "site.creation.domain.tooltip.site.name", + value: "YourSiteName.com", + comment: "Site name that is placed in the tooltip view." + ) + + static let tooltipDescription = NSLocalizedString( + "site.creation.domain.tooltip.description", + value: "Like the example above, a domain allows people to find and visit your site from their web browser.", + comment: "Site name description that sits in the template website view." + ) + + static let searchBarSiteAddress = NSLocalizedString( + "site.cration.domain.site.address", + value: "https://yoursitename.com", + comment: "Template site address for the search bar." + ) + } +} + +private extension Color { + static let emptySiteBackgroundPrimary = Color("emptySiteBackgroundPrimary") + static let emptySiteBackgroundSecondary = Color("emptySiteBackgroundSecondary") + static let emptySiteForegroundPrimary = Color("emptySiteForegroundPrimary") + static let emptySiteForegroundSecondary = Color("emptySiteForegroundSecondary") + static let emptySiteGradientInitial = Color("emptySiteGradientInitial") + static let emptySiteTooltipGradientInitial = Color("emptySiteTooltipGradientInitial") + static let emptySiteTooltipBackground = Color("emptySiteTooltipBackground") + static let emptySiteTooltipBorder = Color("emptySiteTooltipBorder") +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/SitePromptView.swift b/WordPress/Classes/ViewRelated/Site Creation/Web Address/SitePromptView.swift new file mode 100644 index 000000000000..c244bcdd401f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/SitePromptView.swift @@ -0,0 +1,61 @@ +import UIKit +import Gridicons + +class SitePromptView: UIView { + + private struct Parameters { + static let cornerRadius = CGFloat(8) + static let borderWidth = CGFloat(1) + static let borderColor = UIColor.primaryButtonBorder + } + + @IBOutlet weak var sitePrompt: UILabel! { + didSet { + sitePrompt.text = NSLocalizedString("example.com", comment: "Provides a sample of what a domain name looks like.") + } + } + + @IBOutlet weak var lockIcon: UIImageView! { + didSet { + lockIcon.image = UIImage.gridicon(.lock) + } + } + var contentView: UIView! + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + contentView.layer.borderColor = Parameters.borderColor.cgColor + } + } + + private func commonInit() { + let bundle = Bundle(for: SitePromptView.self) + guard + let nibViews = bundle.loadNibNamed("SitePromptView", owner: self, options: nil), + let loadedView = nibViews.first as? UIView + else { + return + } + + contentView = loadedView + addSubview(contentView) + contentView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate( + contentView.constrainToSuperViewEdges() + ) + contentView.layer.cornerRadius = Parameters.cornerRadius + contentView.layer.borderColor = Parameters.borderColor.cgColor + contentView.layer.borderWidth = Parameters.borderWidth + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/SitePromptView.xib b/WordPress/Classes/ViewRelated/Site Creation/Web Address/SitePromptView.xib new file mode 100644 index 000000000000..58b072a131f5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/SitePromptView.xib @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressStep.swift b/WordPress/Classes/ViewRelated/Site Creation/Web Address/WebAddressStep.swift similarity index 76% rename from WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressStep.swift rename to WordPress/Classes/ViewRelated/Site Creation/Web Address/WebAddressStep.swift index 037b0f87a0b2..581b5f6d52d2 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressStep.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/WebAddressStep.swift @@ -1,14 +1,16 @@ -/// Site Creation. Third screen: Domains +/// Site Creation: Domains final class WebAddressStep: WizardStep { private let creator: SiteCreator private let service: SiteAddressService private(set) lazy var content: UIViewController = { - return WebAddressWizardContent(creator: creator, service: self.service, selection: didSelect) + return WebAddressWizardContent(creator: creator, service: self.service) { [weak self] (address) in + self?.didSelect(address) + } }() - var delegate: WizardDelegate? + weak var delegate: WizardDelegate? init(creator: SiteCreator, service: SiteAddressService) { self.creator = creator diff --git a/WordPress/Classes/ViewRelated/Site Creation/Web Address/WebAddressWizardContent.swift b/WordPress/Classes/ViewRelated/Site Creation/Web Address/WebAddressWizardContent.swift new file mode 100644 index 000000000000..7bf39b69a59d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Web Address/WebAddressWizardContent.swift @@ -0,0 +1,677 @@ +import UIKit +import WordPressAuthenticator +import SwiftUI + +/// Contains the UI corresponding to the list of Domain suggestions. +/// +final class WebAddressWizardContent: CollapsableHeaderViewController { + static let noMatchCellReuseIdentifier = "noMatchCellReuseIdentifier" + + // MARK: Properties + private struct Metrics { + static let maxLabelWidth = CGFloat(290) + static let noResultsTopInset = CGFloat(64) + static let sitePromptEdgeMargin = CGFloat(50) + static let sitePromptBottomMargin = CGFloat(10) + static let sitePromptTopMargin = CGFloat(25) + } + + override var separatorStyle: SeparatorStyle { + return .hidden + } + + /// Checks if the Domain Purchasing Feature Flag and AB Experiment are enabled + private var domainPurchasingEnabled: Bool { + return siteCreator.domainPurchasingEnabled + } + + /// The creator collects user input as they advance through the wizard flow. + private let siteCreator: SiteCreator + private let service: SiteAddressService + private let selection: (DomainSuggestion) -> Void + + /// Tracks the site address selected by users + private var selectedDomain: DomainSuggestion? { + didSet { + itemSelectionChanged(selectedDomain != nil) + } + } + + /// The table view renders our server content + private let table: UITableView + private let searchHeader: UIView + private let searchTextField: SearchTextField + private let searchBar = UISearchBar() + private var sitePromptView: SitePromptView! + private let siteCreationEmptyTemplate = SiteCreationEmptySiteTemplate() + private lazy var siteTemplateHostingController = UIHostingController(rootView: siteCreationEmptyTemplate) + + /// The underlying data represented by the provider + var data: [DomainSuggestion] { + didSet { + contentSizeWillChange() + table.reloadData() + } + } + private var _hasExactMatch: Bool = false + var hasExactMatch: Bool { + get { + guard (lastSearchQuery ?? "").count > 0 else { + // Forces the no match cell to hide when the results are empty. + return true + } + // Return true if there is no data to supress the no match cell + return data.count > 0 ? _hasExactMatch : true + } + set { + _hasExactMatch = newValue + } + } + + /// The throttle meters requests to the remote service + private let throttle = Scheduler(seconds: 0.5) + + /// We track the last searched value so that we can retry + private var lastSearchQuery: String? = nil + + /// Locally tracks the network connection status via `NetworkStatusDelegate` + private var isNetworkActive = ReachabilityUtils.isInternetReachable() + + /// This message is shown when there are no domain suggestions to list + private let noResultsLabel: UILabel + + private var noResultsLabelTopAnchor: NSLayoutConstraint? + private var isShowingError: Bool = false { + didSet { + if isShowingError { + contentSizeWillChange() + table.reloadData() + } + } + } + private var errorMessage: String { + if isNetworkActive { + return Strings.serverError + } else { + return Strings.noConnection + } + } + + // MARK: WebAddressWizardContent + + init(creator: SiteCreator, service: SiteAddressService, selection: @escaping (DomainSuggestion) -> Void) { + self.siteCreator = creator + self.service = service + self.selection = selection + self.data = [] + self.noResultsLabel = { + let label = UILabel() + + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + label.preferredMaxLayoutWidth = Metrics.maxLabelWidth + + label.font = WPStyleGuide.fontForTextStyle(.body) + label.textAlignment = .center + label.textColor = .text + label.text = Strings.noResults + + label.sizeToFit() + + label.isHidden = true + + return label + }() + searchTextField = SearchTextField() + searchHeader = UIView(frame: .zero) + table = UITableView(frame: .zero, style: .grouped) + super.init(scrollableView: table, + mainTitle: Strings.mainTitle, + prompt: Strings.prompt, + primaryActionTitle: Strings.createSite, + accessoryView: searchHeader) + } + + // MARK: UIViewController + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupTable() + WPAnalytics.track(.enhancedSiteCreationDomainsAccessed) + loadHeaderView() + addAddressHintView() + configureUIIfNeeded() + } + + private func configureUIIfNeeded() { + guard domainPurchasingEnabled else { + return + } + + NSLayoutConstraint.activate([ + largeTitleView.widthAnchor.constraint(equalTo: headerStackView.widthAnchor) + ]) + largeTitleView.textAlignment = .natural + promptView.textAlignment = .natural + promptView.font = .systemFont(ofSize: 17) + } + + private func loadHeaderView() { + + if domainPurchasingEnabled { + searchBar.searchBarStyle = UISearchBar.Style.default + searchBar.translatesAutoresizingMaskIntoConstraints = false + WPStyleGuide.configureSearchBar(searchBar, backgroundColor: .clear, returnKeyType: .search) + searchBar.layer.borderWidth = 0 + searchHeader.addSubview(searchBar) + searchBar.delegate = self + + NSLayoutConstraint.activate([ + searchBar.leadingAnchor.constraint(equalTo: searchHeader.leadingAnchor, constant: 8), + searchHeader.trailingAnchor.constraint(equalTo: searchBar.trailingAnchor, constant: 8), + searchBar.topAnchor.constraint(equalTo: searchHeader.topAnchor, constant: 1), + searchHeader.bottomAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 1) + ]) + } else { + searchHeader.addSubview(searchTextField) + searchHeader.backgroundColor = searchTextField.backgroundColor + let top = NSLayoutConstraint(item: searchTextField, attribute: .top, relatedBy: .equal, toItem: searchHeader, attribute: .top, multiplier: 1, constant: 0) + let bottom = NSLayoutConstraint(item: searchTextField, attribute: .bottom, relatedBy: .equal, toItem: searchHeader, attribute: .bottom, multiplier: 1, constant: 0) + let leading = NSLayoutConstraint(item: searchTextField, attribute: .leading, relatedBy: .equal, toItem: searchHeader, attribute: .leadingMargin, multiplier: 1, constant: 0) + let trailing = NSLayoutConstraint(item: searchTextField, attribute: .trailing, relatedBy: .equal, toItem: searchHeader, attribute: .trailingMargin, multiplier: 1, constant: 0) + searchHeader.addConstraints([top, bottom, leading, trailing]) + searchHeader.addTopBorder(withColor: .divider) + searchHeader.addBottomBorder(withColor: .divider) + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + observeNetworkStatus() + prepareViewIfNeeded() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + searchTextField.resignFirstResponder() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + restoreSearchIfNeeded() + postScreenChangedForVoiceOver() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + clearContent() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + updateNoResultsLabelTopInset() + + coordinator.animate(alongsideTransition: nil) { [weak self] (_) in + guard let self else { return } + + if self.domainPurchasingEnabled { + if !self.siteTemplateHostingController.view.isHidden { + self.updateTitleViewVisibility(true) + } + } else { + if !self.sitePromptView.isHidden { + self.updateTitleViewVisibility(true) + } + } + } + } + + override func estimatedContentSize() -> CGSize { + guard !isShowingError else { return CGSize(width: view.frame.width, height: 44) } + guard data.count > 0 else { return .zero } + let estimatedSectionHeaderHeight: CGFloat = 85 + let cellCount = hasExactMatch ? data.count : data.count + 1 + let height = estimatedSectionHeaderHeight + (CGFloat(cellCount) * AddressTableViewCell.estimatedSize.height) + return CGSize(width: view.frame.width, height: height) + } + + // MARK: Private behavior + private func clearContent() { + throttle.cancel() + itemSelectionChanged(false) + data = [] + lastSearchQuery = nil + setAddressHintVisibility(isHidden: false) + noResultsLabel.isHidden = true + expandHeader() + } + + private func fetchAddresses(_ searchTerm: String) { + isShowingError = false + updateIcon(isLoading: true) + service.addresses(for: searchTerm) { [weak self] results in + DispatchQueue.main.async { + self?.handleResult(results) + } + } + } + + private func handleResult(_ results: Result) { + updateIcon(isLoading: false) + switch results { + case .failure(let error): + handleError(error) + case .success(let data): + hasExactMatch = data.hasExactMatch + handleData(data.domainSuggestions, data.invalidQuery) + } + } + + private func handleData(_ data: [DomainSuggestion], _ invalidQuery: Bool) { + setAddressHintVisibility(isHidden: true) + let resultsHavePreviousSelection = data.contains { (suggestion) -> Bool in self.selectedDomain?.domainName == suggestion.domainName } + if !resultsHavePreviousSelection { + clearSelectionAndCreateSiteButton() + } + + self.data = data + if data.isEmpty { + if invalidQuery { + noResultsLabel.text = Strings.invalidQuery + } else { + noResultsLabel.text = Strings.noResults + } + noResultsLabel.isHidden = false + } else { + noResultsLabel.isHidden = true + } + postSuggestionsUpdateAnnouncementForVoiceOver(listIsEmpty: data.isEmpty, invalidQuery: invalidQuery) + } + + private func handleError(_ error: Error) { + SiteCreationAnalyticsHelper.trackError(error) + isShowingError = true + } + + private func performSearchIfNeeded(query: String) { + guard !query.isEmpty else { + clearContent() + return + } + + lastSearchQuery = query + + guard isNetworkActive == true else { + isShowingError = true + return + } + + throttle.debounce { [weak self] in + guard let self = self else { return } + self.fetchAddresses(query) + } + } + + override func primaryActionSelected(_ sender: Any) { + guard let selectedDomain = selectedDomain else { + return + } + + trackDomainsSelection(selectedDomain) + selection(selectedDomain) + } + + private func setupCells() { + let cellName = String(describing: AddressTableViewCell.self) + table.register(AddressTableViewCell.self, forCellReuseIdentifier: cellName) + table.register(InlineErrorRetryTableViewCell.self, forCellReuseIdentifier: InlineErrorRetryTableViewCell.cellReuseIdentifier()) + table.cellLayoutMarginsFollowReadableWidth = true + } + + private func restoreSearchIfNeeded() { + if domainPurchasingEnabled { + search(searchBar.text) + } else { + search(query(from: searchTextField)) + } + } + + private func prepareViewIfNeeded() { + searchTextField.becomeFirstResponder() + } + + private func setupHeaderAndNoResultsMessage() { + searchTextField.addTarget(self, action: #selector(textChanged), for: .editingChanged) + searchTextField.delegate = self + searchTextField.accessibilityTraits = .searchField + + let placeholderText = Strings.searchPlaceholder + let attributes = WPStyleGuide.defaultSearchBarTextAttributesSwifted(.textPlaceholder) + let attributedPlaceholder = NSAttributedString(string: placeholderText, attributes: attributes) + searchTextField.attributedPlaceholder = attributedPlaceholder + searchTextField.accessibilityHint = Strings.searchAccessibility + + view.addSubview(noResultsLabel) + + let noResultsLabelTopAnchor = noResultsLabel.topAnchor.constraint(equalTo: searchHeader.bottomAnchor) + self.noResultsLabelTopAnchor = noResultsLabelTopAnchor + + NSLayoutConstraint.activate([ + noResultsLabel.widthAnchor.constraint(equalTo: table.widthAnchor, constant: -50), + noResultsLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + noResultsLabelTopAnchor + ]) + + updateNoResultsLabelTopInset() + } + + /// Sets the top inset for the noResultsLabel based on layout orientation + private func updateNoResultsLabelTopInset() { + noResultsLabelTopAnchor?.constant = UIDevice.current.orientation.isPortrait ? Metrics.noResultsTopInset : 0 + } + + private func setupTable() { + if !domainPurchasingEnabled { + table.separatorStyle = .none + } + table.dataSource = self + table.estimatedRowHeight = AddressTableViewCell.estimatedSize.height + setupTableBackground() + setupTableSeparator() + setupCells() + setupHeaderAndNoResultsMessage() + table.showsVerticalScrollIndicator = false + table.isAccessibilityElement = false + } + + private func setupTableBackground() { + table.backgroundColor = .basicBackground + } + + private func setupTableSeparator() { + table.separatorColor = .divider + table.separatorInset.left = AddressTableViewCell.Appearance.contentMargins.leading + } + + private func query(from textField: UITextField?) -> String? { + guard let text = textField?.text, + !text.isEmpty else { + return siteCreator.information?.title + } + + return text + } + + @objc + private func textChanged(sender: UITextField) { + search(sender.text) + } + + private func clearSelectionAndCreateSiteButton() { + selectedDomain = nil + table.deselectSelectedRowWithAnimation(true) + itemSelectionChanged(false) + } + + private func trackDomainsSelection(_ domainSuggestion: DomainSuggestion) { + var domainSuggestionProperties: [String: Any] = [ + "chosen_domain": domainSuggestion.domainName as AnyObject, + "search_term": lastSearchQuery as AnyObject + ] + + if domainPurchasingEnabled { + domainSuggestionProperties["domain_cost"] = domainSuggestion.costString + } + + WPAnalytics.track(.enhancedSiteCreationDomainsSelected, withProperties: domainSuggestionProperties) + } + + // MARK: - Search logic + + func updateIcon(isLoading: Bool) { + searchTextField.setIcon(isLoading: isLoading) + } + + private func search(_ string: String?) { + guard let query = string, query.isEmpty == false else { + clearContent() + return + } + + performSearchIfNeeded(query: query) + } + + // MARK: - Search logic + + private func setAddressHintVisibility(isHidden: Bool) { + if domainPurchasingEnabled { + siteTemplateHostingController.view?.isHidden = isHidden + } else { + sitePromptView.isHidden = isHidden + } + } + + private func addAddressHintView() { + if domainPurchasingEnabled { + guard let siteCreationView = siteTemplateHostingController.view else { + return + } + siteCreationView.isUserInteractionEnabled = false + siteCreationView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(siteCreationView) + NSLayoutConstraint.activate([ + siteCreationView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), + containerView.trailingAnchor.constraint(equalTo: siteCreationView.trailingAnchor, constant: 16), + siteCreationView.topAnchor.constraint(equalTo: searchHeader.bottomAnchor, constant: Metrics.sitePromptTopMargin), + containerView.bottomAnchor.constraint(equalTo: siteCreationView.bottomAnchor, constant: 0) + ]) + } else { + sitePromptView = SitePromptView(frame: .zero) + sitePromptView.isUserInteractionEnabled = false + sitePromptView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(sitePromptView) + NSLayoutConstraint.activate([ + sitePromptView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: Metrics.sitePromptEdgeMargin), + sitePromptView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -Metrics.sitePromptEdgeMargin), + sitePromptView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: Metrics.sitePromptBottomMargin), + sitePromptView.topAnchor.constraint(equalTo: searchHeader.bottomAnchor, constant: Metrics.sitePromptTopMargin) + ]) + } + setAddressHintVisibility(isHidden: true) + } + + // MARK: - Others + + private enum Strings { + static let suggestionsUpdated = NSLocalizedString("Suggestions updated", + comment: "Announced by VoiceOver when new domains suggestions are shown in Site Creation.") + static let noResults = NSLocalizedString("No available addresses matching your search", + comment: "Advises the user that no Domain suggestions could be found for the search query.") + static let invalidQuery = NSLocalizedString("Your search includes characters not supported in WordPress.com domains. The following characters are allowed: A–Z, a–z, 0–9.", + comment: "This is shown to the user when their domain search query contains invalid characters.") + static let noConnection: String = NSLocalizedString("No connection", + comment: "Displayed during Site Creation, when searching for Verticals and the network is unavailable.") + static let serverError: String = NSLocalizedString("There was a problem", + comment: "Displayed during Site Creation, when searching for Verticals and the server returns an error.") + static let mainTitle: String = NSLocalizedString("Choose a domain", + comment: "Select domain name. Title") + static let prompt: String = NSLocalizedString("Search for a short and memorable keyword to help people find and visit your website.", + comment: "Select domain name. Subtitle") + static let createSite: String = NSLocalizedString("Create Site", + comment: "Button to progress to the next step") + static let searchPlaceholder: String = NSLocalizedString("Type a name for your site", + comment: "Site creation. Seelect a domain, search field placeholder") + static let searchAccessibility: String = NSLocalizedString("Searches for available domains to use for your site.", + comment: "Accessibility hint for the domains search field in Site Creation.") + static let suggestions: String = NSLocalizedString("Suggestions", + comment: "Suggested domains") + static let noMatch: String = NSLocalizedString("This domain is unavailable", + comment: "Notifies the user that the a domain matching the search term wasn't returned in the results") + } +} + +// MARK: - NetworkStatusDelegate + +extension WebAddressWizardContent: NetworkStatusDelegate { + func networkStatusDidChange(active: Bool) { + isNetworkActive = active + } +} + +// MARK: - UITextFieldDelegate + +extension WebAddressWizardContent: UITextFieldDelegate { + func textFieldShouldClear(_ textField: UITextField) -> Bool { + return true + } + + func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { + clearSelectionAndCreateSiteButton() + return true + } +} + +// MARK: - UISearchBarDelegate + +extension WebAddressWizardContent: UISearchBarDelegate { + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + clearSelectionAndCreateSiteButton() + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + search(searchText) + } +} + +// MARK: - VoiceOver + +private extension WebAddressWizardContent { + func postScreenChangedForVoiceOver() { + UIAccessibility.post(notification: .screenChanged, argument: table.tableHeaderView) + } + + func postSuggestionsUpdateAnnouncementForVoiceOver(listIsEmpty: Bool, invalidQuery: Bool) { + var message: String + if listIsEmpty { + message = invalidQuery ? Strings.invalidQuery : Strings.noResults + } else { + message = Strings.suggestionsUpdated + } + UIAccessibility.post(notification: .announcement, argument: message) + } +} + +// MARK: UITableViewDataSource +extension WebAddressWizardContent: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard !isShowingError else { return 1 } + return (!domainPurchasingEnabled && !hasExactMatch && section == 0) ? 1 : data.count + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + guard data.count > 0 else { return nil } + return (!domainPurchasingEnabled && !hasExactMatch && section == 0) ? nil : Strings.suggestions + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return (!domainPurchasingEnabled && !hasExactMatch && indexPath.section == 0) ? 60 : UITableView.automaticDimension + } + + func numberOfSections(in tableView: UITableView) -> Int { + return (domainPurchasingEnabled || hasExactMatch) ? 1 : 2 + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return (!domainPurchasingEnabled && !hasExactMatch && section == 0) ? UIView(frame: CGRect(x: 0, y: 0, width: tableView.frame.width, height: 3)) : nil + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if isShowingError { + return configureErrorCell(tableView, cellForRowAt: indexPath) + } else if !domainPurchasingEnabled && !hasExactMatch && indexPath.section == 0 { + return configureNoMatchCell(table, cellForRowAt: indexPath) + } else { + return configureAddressCell(tableView, cellForRowAt: indexPath) + } + } + + func configureNoMatchCell(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: WebAddressWizardContent.noMatchCellReuseIdentifier) ?? { + // Create and configure a new TableView cell if one hasn't been queued yet + let newCell = UITableViewCell(style: .subtitle, reuseIdentifier: WebAddressWizardContent.noMatchCellReuseIdentifier) + newCell.detailTextLabel?.text = Strings.noMatch + newCell.detailTextLabel?.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + newCell.detailTextLabel?.textColor = .textSubtle + newCell.addBottomBorder(withColor: .divider) + return newCell + }() + + cell.textLabel?.attributedText = AddressTableViewCell.processName("\(lastSearchQuery ?? "").wordpress.com") + return cell + } + + func configureAddressCell(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: AddressTableViewCell.self)) as? AddressTableViewCell else { + assertionFailure("This is a programming error - AddressCell has not been properly registered!") + return UITableViewCell() + } + + let domainSuggestion = data[indexPath.row] + if domainPurchasingEnabled { + let tags = AddressTableViewCell.ViewModel.tagsFromPosition(indexPath.row) + let viewModel = AddressTableViewCell.ViewModel(model: domainSuggestion, tags: tags) + cell.update(with: viewModel) + } else { + cell.update(with: domainSuggestion) + cell.addBorder(isFirstCell: (indexPath.row == 0), isLastCell: (indexPath.row == data.count - 1)) + cell.isSelected = domainSuggestion.domainName == selectedDomain?.domainName + } + + return cell + } + + func configureErrorCell(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: InlineErrorRetryTableViewCell.cellReuseIdentifier()) as? InlineErrorRetryTableViewCell else { + assertionFailure("This is a programming error - InlineErrorRetryTableViewCell has not been properly registered!") + return UITableViewCell() + } + + cell.setMessage(errorMessage) + return cell + } +} + +// MARK: UITableViewDelegate +extension WebAddressWizardContent: UITableViewDelegate { + + func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + // Prevent selection if it's the no matches cell + return (!domainPurchasingEnabled && !hasExactMatch && indexPath.section == 0) ? nil : indexPath + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard !isShowingError else { + retry() + return + } + + let domainSuggestion = data[indexPath.row] + self.selectedDomain = domainSuggestion + + if domainPurchasingEnabled { + searchBar.resignFirstResponder() + } else { + searchTextField.resignFirstResponder() + } + } + + func retry() { + let retryQuery = lastSearchQuery ?? "" + performSearchIfNeeded(query: retryQuery) + } +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/AddressCell.swift b/WordPress/Classes/ViewRelated/Site Creation/WebAddress/AddressCell.swift deleted file mode 100644 index 68779c1da024..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/AddressCell.swift +++ /dev/null @@ -1,89 +0,0 @@ -import UIKit -import WordPressKit - -final class AddressCell: UITableViewCell, ModelSettableCell { - private struct TextStyleAttributes { - static let defaults: [NSAttributedString.Key: Any] = [.font: WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular), - .foregroundColor: UIColor.textSubtle] - static let customName: [NSAttributedString.Key: Any] = [.font: WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular), - .foregroundColor: UIColor.text] - } - - @IBOutlet weak var title: UILabel! - - var model: DomainSuggestion? { - didSet { - title.attributedText = processName(model?.domainName) - } - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - commonInit() - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - commonInit() - } - - private func commonInit() { - selectedBackgroundView?.backgroundColor = .clear - - accessibilityTraits = .button - accessibilityHint = NSLocalizedString("Selects this domain to use for your site.", - comment: "Accessibility hint for a domain in the Site Creation domains list.") - } - - override func awakeFromNib() { - super.awakeFromNib() - styleCheckmark() - } - - override func setHighlighted(_ highlighted: Bool, animated: Bool) { - accessoryType = highlighted ? .checkmark : .none - } - - override func setSelected(_ selected: Bool, animated: Bool) { - accessoryType = selected ? .checkmark : .none - } - - private func styleCheckmark() { - tintColor = .primary(.shade40) - } - - override func prepareForReuse() { - title.attributedText = nil - } - - private func processName(_ domainName: String?) -> NSAttributedString? { - guard let name = domainName else { - return nil - } - - guard let customName = name.components(separatedBy: ".").first else { - return nil - } - - let completeDomainName = NSMutableAttributedString(string: name, attributes: TextStyleAttributes.defaults) - - let rangeOfCustomName = NSRange(location: 0, length: customName.count) - - completeDomainName.setAttributes(TextStyleAttributes.customName, range: rangeOfCustomName) - - return completeDomainName - } -} - -extension AddressCell { - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { - preferredContentSizeDidChange() - } - } - - func preferredContentSizeDidChange() { - title.attributedText = processName(model?.domainName) - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/AddressCell.xib b/WordPress/Classes/ViewRelated/Site Creation/WebAddress/AddressCell.xib deleted file mode 100644 index c8f845e227df..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/AddressCell.xib +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressTableViewProvider.swift b/WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressTableViewProvider.swift deleted file mode 100644 index d1076326a65f..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressTableViewProvider.swift +++ /dev/null @@ -1,101 +0,0 @@ - -import UIKit - -/// This table view provider fulfills the "happy path" role of data source & delegate for searching Site Domains. -/// -final class WebAddressTableViewProvider: NSObject, TableViewProvider { - - // MARK: WebAddressTableViewProvider - - /// The table view serviced by this provider - private weak var tableView: UITableView? - - /// Implicit suggestions are the base suggestions based on the site's name and info. - /// Whenever the user types something, this should be set to false. - var isShowingImplicitSuggestions = true { - didSet { - tableView?.reloadData() - } - } - - /// The underlying data represented by the provider - var data: [DomainSuggestion] { - didSet { - tableView?.reloadData() - } - } - - /// The closure to invoke when a row in the underlying table view has been selected - private let selectionHandler: CellSelectionHandler? - - /// Creates a WebAddressTableViewProvider. - /// - /// - Parameters: - /// - tableView: the table view to be managed - /// - data: initial data backing the table view - /// - selectionHandler: the action to perform when a cell is selected, if any - /// - init(tableView: UITableView, data: [DomainSuggestion] = [], selectionHandler: CellSelectionHandler? = nil) { - self.tableView = tableView - self.data = data - self.selectionHandler = selectionHandler - - super.init() - - tableView.dataSource = self - tableView.delegate = self - tableView.reloadData() - } - - // MARK: UITableViewDataSource - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return data.count - } - - func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - guard isShowingImplicitSuggestions else { - return nil - } - - return NSLocalizedString("Suggestions", comment: "Suggested domains") - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: AddressCell.cellReuseIdentifier()) as? AddressCell else { - - assertionFailure("This is a programming error - AddressCell has not been properly registered!") - return UITableViewCell() - } - - let domainSuggestion = data[indexPath.row] - cell.model = domainSuggestion - - addBorder(cell: cell, at: indexPath) - - return cell - } - - // MARK: UITableViewDelegate - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - selectionHandler?(indexPath) - } - - func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - return UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 1.0)) - } - - // MARK: Private behavior - - private func addBorder(cell: UITableViewCell, at: IndexPath) { - let row = at.row - if row == 0 { - cell.addTopBorder(withColor: .neutral(.shade10)) - } - - if row == data.count - 1 { - cell.addBottomBorder(withColor: .neutral(.shade10)) - } - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressWizardContent.swift b/WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressWizardContent.swift deleted file mode 100644 index 13825a573c34..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressWizardContent.swift +++ /dev/null @@ -1,535 +0,0 @@ -import UIKit -import WordPressAuthenticator - -/// Contains the UI corresponding to the list of Domain suggestions. -/// -final class WebAddressWizardContent: UIViewController { - - // MARK: Properties - - private struct Metrics { - static let maxLabelWidth = CGFloat(290) - static let noResultsTopInset = CGFloat(64) - } - - /// The creator collects user input as they advance through the wizard flow. - private let siteCreator: SiteCreator - - private let service: SiteAddressService - - private let selection: (DomainSuggestion) -> Void - - /// Tracks the site address selected by users - private var selectedDomain: DomainSuggestion? - - /// The table view renders our server content - @IBOutlet private weak var table: UITableView! - - /// The view wrapping the skip button - @IBOutlet private weak var buttonWrapper: ShadowView! - - /// The Create Site button - @IBOutlet private weak var createSite: NUXButton! - - /// The constraint between the bottom of the buttonWrapper and this view controller's view - @IBOutlet private weak var bottomConstraint: NSLayoutConstraint! - - /// Serves as both the data source & delegate of the table view - private(set) var tableViewProvider: TableViewProvider? - - /// Manages header visibility, keyboard management, and table view offset - private(set) var tableViewOffsetCoordinator: TableViewOffsetCoordinator? - - /// The throttle meters requests to the remote service - private let throttle = Scheduler(seconds: 0.5) - - /// We track the last searched value so that we can retry - private var lastSearchQuery: String? = nil - - /// Locally tracks the network connection status via `NetworkStatusDelegate` - private var isNetworkActive = ReachabilityUtils.isInternetReachable() - - private lazy var headerData: SiteCreationHeaderData = { - let title = NSLocalizedString("Choose a domain name for your site", - comment: "Create site, step 4. Select domain name. Title") - - let subtitle = NSLocalizedString("This is where people will find you on the internet", - comment: "Create site, step 4. Select domain name. Subtitle") - - return SiteCreationHeaderData(title: title, subtitle: subtitle) - }() - - /// This message advises the user that - private let noResultsLabel: UILabel - - private let isBottomToolbarAlwaysVisible = UIAccessibility.isVoiceOverRunning - - // MARK: WebAddressWizardContent - - init(creator: SiteCreator, service: SiteAddressService, selection: @escaping (DomainSuggestion) -> Void) { - self.siteCreator = creator - self.service = service - self.selection = selection - - self.noResultsLabel = { - let label = UILabel() - - label.translatesAutoresizingMaskIntoConstraints = false - label.numberOfLines = 0 - label.preferredMaxLayoutWidth = Metrics.maxLabelWidth - - label.font = WPStyleGuide.fontForTextStyle(.title2) - label.textAlignment = .center - label.textColor = .text - label.text = Strings.noResults - - label.sizeToFit() - - label.isHidden = true - - return label - }() - - super.init(nibName: String(describing: type(of: self)), bundle: nil) - } - - // MARK: UIViewController - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - - self.tableViewOffsetCoordinator = TableViewOffsetCoordinator(coordinated: table, footerControlContainer: view, footerControl: buttonWrapper, toolbarBottomConstraint: bottomConstraint) - - toggleBottomToolbar(enabled: false) - - applyTitle() - setupBackground() - setupButtonWrapper() - setupCreateSiteButton() - setupTable() - WPAnalytics.track(.enhancedSiteCreationDomainsAccessed) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - observeNetworkStatus() - tableViewOffsetCoordinator?.startListeningToKeyboardNotifications() - prepareViewIfNeeded() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - resignTextFieldResponderIfNeeded() - disallowTextFieldFirstResponder() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - restoreSearchIfNeeded() - allowTextFieldFirstResponder() - postScreenChangedForVoiceOver() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - tableViewOffsetCoordinator?.stopListeningToKeyboardNotifications() - clearContent() - } - - // MARK: Workaround: Text Field First Responder Issues - - /// This method is uses as a workaround for what appears to be an SDK bug. - /// - /// There's an issue that's causing `textField.resignFirstResponder()` to be ignored when called from - /// within `viewDidDisappear(animated:)`. This method makes it so that the text field just can't - /// have first responder whenever we don't want it to. - /// - /// Issue: https://github.com/wordpress-mobile/WordPress-iOS/issues/11702 - /// - private func allowTextFieldFirstResponder() { - guard let header = self.table.tableHeaderView as? TitleSubtitleTextfieldHeader else { - return - } - - header.textField.allowFirstResponderStatus = true - } - - /// This method makes it impossible for the text field to become first responder. - /// - /// Read the documentation of `allowTextFieldFirstResponder` for more details. - /// - private func disallowTextFieldFirstResponder() { - guard let header = self.table.tableHeaderView as? TitleSubtitleTextfieldHeader else { - return - } - - header.textField.allowFirstResponderStatus = false - } - - // MARK: Private behavior - - private func applyTitle() { - title = NSLocalizedString("3 of 3", comment: "Site creation. Step 3. Screen title") - } - - private func clearContent() { - throttle.cancel() - - toggleBottomToolbar(enabled: false) - - guard let validDataProvider = tableViewProvider as? WebAddressTableViewProvider else { - setupTableDataProvider(isShowingImplicitSuggestions: true) - return - } - - validDataProvider.data = [] - validDataProvider.isShowingImplicitSuggestions = true - tableViewOffsetCoordinator?.resetTableOffsetIfNeeded() - } - - private func fetchAddresses(_ searchTerm: String) { - // It's not ideal to let the segment ID be optional at this point, but in order to avoid overcomplicating my current - // task, I'll default to silencing this situation. Since the segment ID should exist, this silencing should not - // really be triggered for now. - guard let segmentID = siteCreator.segment?.identifier else { - return - } - - service.addresses(for: searchTerm, segmentID: segmentID) { [weak self] results in - switch results { - case .failure(let error): - self?.handleError(error) - case .success(let data): - self?.handleData(data) - } - } - } - - private func handleData(_ data: [DomainSuggestion]) { - let header = self.table.tableHeaderView as! TitleSubtitleTextfieldHeader - let isShowingImplicitSuggestions = header.textField.text!.isEmpty - - if let validDataProvider = tableViewProvider as? WebAddressTableViewProvider { - validDataProvider.data = data - validDataProvider.isShowingImplicitSuggestions = isShowingImplicitSuggestions - } else { - setupTableDataProvider(data, isShowingImplicitSuggestions: isShowingImplicitSuggestions) - } - - if data.isEmpty { - noResultsLabel.isHidden = false - } else { - noResultsLabel.isHidden = true - } - - if !isShowingImplicitSuggestions { - postSuggestionsUpdateAnnouncementForVoiceOver(listIsEmpty: data.isEmpty) - } - } - - private func handleError(_ error: Error) { - setupEmptyTableProvider() - } - - private func hideSeparators() { - table.tableFooterView = UIView(frame: .zero) - } - - private func performSearchIfNeeded(query: String) { - guard !query.isEmpty else { - return - } - - lastSearchQuery = query - - guard isNetworkActive == true else { - setupEmptyTableProvider() - return - } - - throttle.throttle { [weak self] in - self?.fetchAddresses(query) - } - } - - private func setupBackground() { - view.backgroundColor = .listBackground - } - - private func setupButtonWrapper() { - buttonWrapper.backgroundColor = .listBackground - } - - private func setupCreateSiteButton() { - createSite.addTarget(self, action: #selector(commitSelection), for: .touchUpInside) - - let buttonTitle = NSLocalizedString("Create Site", comment: "Button to progress to the next step") - createSite.setTitle(buttonTitle, for: .normal) - createSite.accessibilityLabel = buttonTitle - createSite.accessibilityHint = NSLocalizedString("Creates a new site with the given information.", - comment: "Accessibility hint for the Create Site button in Site Creation.") - - createSite.isPrimary = true - } - - @objc - private func commitSelection() { - guard let selectedDomain = selectedDomain else { - return - } - - selection(selectedDomain) - trackDomainsSelection(selectedDomain) - } - - private func setupCells() { - let cellName = AddressCell.cellReuseIdentifier() - let nib = UINib(nibName: cellName, bundle: nil) - table.register(nib, forCellReuseIdentifier: cellName) - - table.register(InlineErrorRetryTableViewCell.self, forCellReuseIdentifier: InlineErrorRetryTableViewCell.cellReuseIdentifier()) - } - - private func resignTextFieldResponderIfNeeded() { - guard let header = self.table.tableHeaderView as? TitleSubtitleTextfieldHeader else { - return - } - - let textField = header.textField - textField.resignFirstResponder() - textField.allowFirstResponderStatus = false - } - - private func restoreSearchIfNeeded() { - guard let header = self.table.tableHeaderView as? TitleSubtitleTextfieldHeader else { - return - } - - search(withInputFrom: header.textField) - } - - private func prepareViewIfNeeded() { - guard WPDeviceIdentification.isiPhone(), let header = self.table.tableHeaderView as? TitleSubtitleTextfieldHeader else { - return - } - - let textField = header.textField - guard let inputText = textField.text, !inputText.isEmpty else { - return - } - textField.becomeFirstResponder() - } - - private func setupEmptyTableProvider() { - let message: InlineErrorMessage - if isNetworkActive { - message = InlineErrorMessages.serverError - } else { - message = InlineErrorMessages.noConnection - } - - let handler: CellSelectionHandler = { [weak self] _ in - let retryQuery = self?.lastSearchQuery ?? "" - self?.performSearchIfNeeded(query: retryQuery) - } - - tableViewProvider = InlineErrorTableViewProvider(tableView: table, message: message, selectionHandler: handler) - } - - private func setupHeaderAndNoResultsMessage() { - let header = TitleSubtitleTextfieldHeader(frame: .zero) - header.setTitle(headerData.title) - header.setSubtitle(headerData.subtitle) - - header.textField.addTarget(self, action: #selector(textChanged), for: .editingChanged) - header.textField.delegate = self - - header.accessibilityTraits = .header - - let placeholderText = NSLocalizedString("Search Domains", comment: "Site creation. Seelect a domain, search field placeholder") - let attributes = WPStyleGuide.defaultSearchBarTextAttributesSwifted(.textPlaceholder) - let attributedPlaceholder = NSAttributedString(string: placeholderText, attributes: attributes) - header.textField.attributedPlaceholder = attributedPlaceholder - - header.textField.accessibilityHint = NSLocalizedString("Searches for available domains to use for your site.", comment: "Accessibility hint for the domains search field in Site Creation.") - - table.tableHeaderView = header - - view.addSubview(noResultsLabel) - - NSLayoutConstraint.activate([ - header.centerXAnchor.constraint(equalTo: table.centerXAnchor), - header.widthAnchor.constraint(equalTo: table.widthAnchor), - header.topAnchor.constraint(equalTo: table.topAnchor), - noResultsLabel.widthAnchor.constraint(equalTo: header.textField.widthAnchor), - noResultsLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - noResultsLabel.topAnchor.constraint(equalTo: header.textField.bottomAnchor, constant: Metrics.noResultsTopInset) - ]) - - table.tableHeaderView?.layoutIfNeeded() - table.tableHeaderView = table.tableHeaderView - } - - private func setupTable() { - setupTableBackground() - setupTableSeparator() - setupCells() - setupConstraints() - setupHeaderAndNoResultsMessage() - hideSeparators() - } - - private func setupTableBackground() { - table.backgroundColor = .listBackground - } - - private func setupTableSeparator() { - table.separatorColor = .divider - } - - private func setupConstraints() { - table.cellLayoutMarginsFollowReadableWidth = true - - NSLayoutConstraint.activate([ - table.topAnchor.constraint(equalTo: view.prevailingLayoutGuide.topAnchor), - table.bottomAnchor.constraint(equalTo: view.prevailingLayoutGuide.bottomAnchor), - table.leadingAnchor.constraint(equalTo: view.prevailingLayoutGuide.leadingAnchor), - table.trailingAnchor.constraint(equalTo: view.prevailingLayoutGuide.trailingAnchor), - ]) - } - - private func setupTableDataProvider(_ data: [DomainSuggestion] = [], isShowingImplicitSuggestions: Bool) { - let handler: CellSelectionHandler = { [weak self] selectedIndexPath in - guard let self = self, let provider = self.tableViewProvider as? WebAddressTableViewProvider else { - return - } - - let domainSuggestion = provider.data[selectedIndexPath.row] - self.selectedDomain = domainSuggestion - self.resignTextFieldResponderIfNeeded() - self.toggleBottomToolbar(enabled: true) - } - - let provider = WebAddressTableViewProvider(tableView: table, data: data, selectionHandler: handler) - provider.isShowingImplicitSuggestions = isShowingImplicitSuggestions - - self.tableViewProvider = provider - } - - private func query(from textField: UITextField?) -> String? { - guard let text = textField?.text, - !text.isEmpty else { - return siteCreator.information?.title - } - - return text - } - - @objc - private func textChanged(sender: UITextField) { - search(withInputFrom: sender) - } - - private func clearSelectionAndCreateSiteButton() { - selectedDomain = nil - table.deselectSelectedRowWithAnimation(true) - toggleBottomToolbar(enabled: false) - } - - private func trackDomainsSelection(_ domainSuggestion: DomainSuggestion) { - let domainSuggestionProperties: [String: AnyObject] = [ - "chosen_domain": domainSuggestion.domainName as AnyObject, - "search_term": lastSearchQuery as AnyObject - ] - - WPAnalytics.track(.enhancedSiteCreationDomainsSelected, withProperties: domainSuggestionProperties) - } - - // MARK: - Search logic - - private func search(withInputFrom textField: UITextField) { - guard let query = query(from: textField), query.isEmpty == false else { - clearContent() - return - } - - performSearchIfNeeded(query: query) - tableViewOffsetCoordinator?.adjustTableOffsetIfNeeded() - } - - // MARK: - Toolbar - - private func toggleBottomToolbar(enabled: Bool) { - createSite.isEnabled = enabled - - if enabled { - tableViewOffsetCoordinator?.showBottomToolbar() - } else { - if !isBottomToolbarAlwaysVisible { - tableViewOffsetCoordinator?.hideBottomToolbar() - } - } - } - - // MARK: - Others - - private enum Strings { - static let suggestionsUpdated = NSLocalizedString("Suggestions updated", - comment: "Announced by VoiceOver when new domains suggestions are shown in Site Creation.") - static let noResults = NSLocalizedString("No available addresses matching your search", - comment: "Advises the user that no Domain suggestions could be found for the search query.") - } -} - -// MARK: - NetworkStatusDelegate - -extension WebAddressWizardContent: NetworkStatusDelegate { - func networkStatusDidChange(active: Bool) { - isNetworkActive = active - } -} - -// MARK: - UITextFieldDelegate - -extension WebAddressWizardContent: UITextFieldDelegate { - func textFieldShouldClear(_ textField: UITextField) -> Bool { - tableViewOffsetCoordinator?.resetTableOffsetIfNeeded() - return true - } - - func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool { - clearSelectionAndCreateSiteButton() - return true - } -} - -extension WebAddressWizardContent { - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { - preferredContentSizeDidChange() - } - } - - func preferredContentSizeDidChange() { - tableViewOffsetCoordinator?.adjustTableOffsetIfNeeded() - } -} - -// MARK: - VoiceOver - -private extension WebAddressWizardContent { - func postScreenChangedForVoiceOver() { - UIAccessibility.post(notification: .screenChanged, argument: table.tableHeaderView) - } - - func postSuggestionsUpdateAnnouncementForVoiceOver(listIsEmpty: Bool) { - let message: String = listIsEmpty ? Strings.noResults : Strings.suggestionsUpdated - UIAccessibility.post(notification: .announcement, argument: message) - } -} diff --git a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressWizardContent.xib b/WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressWizardContent.xib deleted file mode 100644 index b83ea5df29e4..000000000000 --- a/WordPress/Classes/ViewRelated/Site Creation/WebAddress/WebAddressWizardContent.xib +++ /dev/null @@ -1,66 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreationStep.swift b/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreationStep.swift new file mode 100644 index 000000000000..80ab4fac6fe2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreationStep.swift @@ -0,0 +1,10 @@ +import Foundation + +enum SiteCreationStep { + case address + case design + case intent + case name + case segments + case siteAssembly +} diff --git a/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreationWizard.swift b/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreationWizard.swift index 472ee6977235..158b60e8a2b0 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreationWizard.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreationWizard.swift @@ -11,9 +11,9 @@ final class SiteCreationWizard: Wizard { return WizardNavigation(steps: self.steps) }() - lazy var content: UIViewController? = { - return navigation?.content - }() + var content: UIViewController? { + return navigation + } // The sequence of steps to complete the wizard. let steps: [WizardStep] diff --git a/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreationWizardLauncher.swift b/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreationWizardLauncher.swift index 2d3ee3416784..31b39ce5c783 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreationWizardLauncher.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreationWizardLauncher.swift @@ -1,47 +1,50 @@ +import AutomatticTracks + /// Puts together the Site creation wizard, assembling steps. final class SiteCreationWizardLauncher { private lazy var creator: SiteCreator = { return SiteCreator() }() - private lazy var segmentsStep: WizardStep = { - let segmentsService = SiteCreationSegmentsService(managedObjectContext: ContextManager.sharedInstance().mainContext) - return SiteSegmentsStep(creator: self.creator, service: segmentsService) - }() - - private lazy var verticalsStep: WizardStep = { - let promptService = SiteCreationVerticalsPromptService(managedObjectContext: ContextManager.sharedInstance().mainContext) - let verticalsService = SiteCreationVerticalsService(managedObjectContext: ContextManager.sharedInstance().mainContext) - - return VerticalsStep(creator: self.creator, promptService: promptService, verticalsService: verticalsService) - }() + private var shouldShowSiteIntent: Bool { + return FeatureFlag.siteIntentQuestion.enabled + } - private lazy var addressStep: WizardStep = { - let addressService = DomainsServiceAdapter(managedObjectContext: ContextManager.sharedInstance().mainContext) - return WebAddressStep(creator: self.creator, service: addressService) - }() + private var shouldShowSiteName: Bool { + return FeatureFlag.siteName.enabled + } - private lazy var siteInfoStep: WizardStep = { - return SiteInformationStep(creator: self.creator) - }() + lazy var steps: [SiteCreationStep] = { + // If Site Intent shouldn't be shown, fall back to the original steps. + guard shouldShowSiteIntent else { + return [ + .design, + .address, + .siteAssembly + ] + } - private lazy var siteAssemblyStep: WizardStep = { - let siteAssemblyService = EnhancedSiteCreationService(managedObjectContext: ContextManager.sharedInstance().mainContext) - return SiteAssemblyStep(creator: self.creator, service: siteAssemblyService) - }() + // If Site Intent should be shown but not the Site Name, only add Site Intent. + guard shouldShowSiteName else { + return [ + .intent, + .design, + .address, + .siteAssembly + ] + } - private lazy var steps: [WizardStep] = { + // If Site Name should be shown, swap out the Site Address step. return [ - self.segmentsStep, - self.verticalsStep, - self.siteInfoStep, - self.addressStep, - self.siteAssemblyStep + .intent, + .name, + .design, + .siteAssembly ] }() private lazy var wizard: SiteCreationWizard = { - return SiteCreationWizard(steps: self.steps) + return SiteCreationWizard(steps: steps.map { initStep($0) }) }() lazy var ui: UIViewController? = { @@ -49,7 +52,41 @@ final class SiteCreationWizardLauncher { return nil } - wizardContent.modalPresentationStyle = .fullScreen + wizardContent.modalPresentationStyle = .pageSheet + wizardContent.isModalInPresentation = true + return wizardContent }() + + /// Closure to be executed upon dismissal of the SiteAssemblyWizardContent. + /// + private let onDismiss: ((Blog, Bool) -> Void)? + + init( + onDismiss: ((Blog, Bool) -> Void)? = nil + ) { + self.onDismiss = onDismiss + } + + private func initStep(_ step: SiteCreationStep) -> WizardStep { + switch step { + case .address: + let addressService = DomainsServiceAdapter(coreDataStack: ContextManager.shared) + return WebAddressStep(creator: self.creator, service: addressService) + case .design: + // we call dropLast to remove .siteAssembly + let isLastStep = steps.dropLast().last == .design + return SiteDesignStep(creator: self.creator, isLastStep: isLastStep) + case .intent: + return SiteIntentStep(creator: self.creator) + case .name: + return SiteNameStep(creator: self.creator) + case .segments: + let segmentsService = SiteCreationSegmentsService(coreDataStack: ContextManager.sharedInstance()) + return SiteSegmentsStep(creator: self.creator, service: segmentsService) + case .siteAssembly: + let siteAssemblyService = EnhancedSiteCreationService(coreDataStack: ContextManager.sharedInstance()) + return SiteAssemblyStep(creator: self.creator, service: siteAssemblyService, onDismiss: onDismiss) + } + } } diff --git a/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreator.swift b/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreator.swift index 36b7e42d456a..f879b7211708 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreator.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Wizard/SiteCreator.swift @@ -1,5 +1,6 @@ import Foundation +import WordPressKit extension DomainSuggestion { var subdomain: String { @@ -16,8 +17,6 @@ extension DomainSuggestion { enum SiteCreationRequestAssemblyError: Error { case invalidSegmentIdentifier case invalidVerticalIdentifier - case invalidDomain - case invalidSiteInformation } // MARK: - SiteCreator @@ -26,45 +25,59 @@ enum SiteCreationRequestAssemblyError: Error { final class SiteCreator { // MARK: Properties - var segment: SiteSegment? - - var vertical: SiteVertical? - + var design: RemoteSiteDesign? + var vertical: SiteIntentVertical? var information: SiteInformation? - var address: DomainSuggestion? /// Generates the final object that will be posted to the backend /// /// - Returns: an Encodable object /// - func build() throws -> SiteCreationRequest { + func build() -> SiteCreationRequest { - guard let segmentIdentifier = segment?.identifier else { - throw SiteCreationRequestAssemblyError.invalidSegmentIdentifier - } + let request = SiteCreationRequest( + segmentIdentifier: segment?.identifier, + siteDesign: design?.slug ?? Strings.defaultDesignSlug, + verticalIdentifier: vertical?.slug, + title: information?.title ?? "", + tagline: information?.tagLine ?? "", + siteURLString: siteURLString, + isPublic: true, + siteCreationFlow: address == nil ? Strings.siteCreationFlowForNoAddress : nil, + findAvailableURL: !(address?.isFree ?? false) + ) + return request + } - let verticalIdentifier = vertical?.identifier.description + var hasSiteTitle: Bool { + information?.title != nil + } - guard let domainSuggestion = address else { - throw SiteCreationRequestAssemblyError.invalidDomain - } - let siteName = domainSuggestion.isWordPress ? domainSuggestion.subdomain : domainSuggestion.domainName + /// Checks if the Domain Purchasing Feature Flag and AB Experiment are enabled + var domainPurchasingEnabled: Bool { + FeatureFlag.siteCreationDomainPurchasing.enabled && ABTest.siteCreationDomainPurchasing.isTreatmentVariation + } - guard let siteInformation = information else { - throw SiteCreationRequestAssemblyError.invalidSiteInformation - } + /// Flag indicating whether the domain checkout flow should appear or not. + var shouldShowDomainCheckout: Bool { + domainPurchasingEnabled && !(address?.isFree ?? false) + } - let request = SiteCreationRequest( - segmentIdentifier: segmentIdentifier, - verticalIdentifier: verticalIdentifier, - title: siteInformation.title, - tagline: siteInformation.tagLine, - siteURLString: siteName, - isPublic: true - ) + /// Returns the domain suggestion if there's one, + /// - otherwise a site name if there's one, + /// - otherwise an empty string. + private var siteURLString: String { - return request + guard let domainSuggestion = address else { + return information?.title ?? "" + } + return domainSuggestion.isWordPress ? domainSuggestion.subdomain : domainSuggestion.domainName + } + + private enum Strings { + static let defaultDesignSlug = "default" + static let siteCreationFlowForNoAddress = "with-design-picker" } } diff --git a/WordPress/Classes/ViewRelated/Site Creation/Wizard/WizardNavigation.swift b/WordPress/Classes/ViewRelated/Site Creation/Wizard/WizardNavigation.swift index 704fcdc42d90..ab39f2898ccc 100644 --- a/WordPress/Classes/ViewRelated/Site Creation/Wizard/WizardNavigation.swift +++ b/WordPress/Classes/ViewRelated/Site Creation/Wizard/WizardNavigation.swift @@ -1,33 +1,10 @@ import UIKit -// MARK: - EnhancedSiteCreationNavigationController - -private final class EnhancedSiteCreationNavigationController: UINavigationController { - override var shouldAutorotate: Bool { - return WPDeviceIdentification.isiPad() ? true : false - } - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return WPDeviceIdentification.isiPad() ? .all : .portrait - } -} - // MARK: - WizardNavigation - -final class WizardNavigation { +final class WizardNavigation: UINavigationController { private let steps: [WizardStep] private let pointer: WizardNavigationPointer - private lazy var navigationController: UINavigationController? = { - guard let root = self.firstContentViewController else { - return nil - } - - let returnValue = EnhancedSiteCreationNavigationController(rootViewController: root) - returnValue.delegate = self.pointer - return returnValue - }() - private lazy var firstContentViewController: UIViewController? = { guard let firstStep = self.steps.first else { return nil @@ -35,31 +12,50 @@ final class WizardNavigation { return firstStep.content }() - init(steps: [WizardStep]) { self.steps = steps self.pointer = WizardNavigationPointer(capacity: steps.count) + guard let firstStep = self.steps.first else { + fatalError("Navigation Controller was initialized with no steps.") + } + + let root = firstStep.content + super.init(rootViewController: root) + + delegate = self.pointer configureSteps() } + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // Navigation Overrides + override var shouldAutorotate: Bool { + return WPDeviceIdentification.isiPad() ? true : false + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return WPDeviceIdentification.isiPad() ? .all : .portrait + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .default + } + private func configureSteps() { for var step in steps { step.delegate = self } } - - lazy var content: UIViewController? = { - return self.navigationController - }() } extension WizardNavigation: WizardDelegate { func nextStep() { - guard let navigationController = navigationController, let nextStepIndex = pointer.nextIndex else { + guard let nextStepIndex = pointer.nextIndex else { // If we find this statement in Fabric, it suggests 11388 might not have been resolved DDLogInfo("We've exceeded the max index of our wizard navigation steps (i.e., \(pointer.currentIndex)") - return } @@ -67,11 +63,11 @@ extension WizardNavigation: WizardDelegate { let nextViewController = nextStep.content // If we find this statement in Fabric, it's likely that we haven't resolved 11388 - if navigationController.viewControllers.contains(nextViewController) { + if viewControllers.contains(nextViewController) { DDLogInfo("Attempting to push \(String(describing: nextViewController.title)) when it's already on the navigation stack!") } - navigationController.pushViewController(nextViewController, animated: true) + pushViewController(nextViewController, animated: true) } } diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/Charts+AxisFormatters.swift b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+AxisFormatters.swift index 1436d3ffb080..d350205f1009 100644 --- a/WordPress/Classes/ViewRelated/Stats/Charts/Charts+AxisFormatters.swift +++ b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+AxisFormatters.swift @@ -18,7 +18,7 @@ import Charts /// The workaround employed here (recommended in 2410 above) relies on the time series data being ordered, and simply /// transforms the adjusted values by the time interval associated with the first date in the series. /// -class HorizontalAxisFormatter: IAxisValueFormatter { +class HorizontalAxisFormatter: AxisValueFormatter { // MARK: Properties @@ -35,7 +35,7 @@ class HorizontalAxisFormatter: IAxisValueFormatter { self.period = period } - // MARK: IAxisValueFormatter + // MARK: AxisValueFormatter func stringForValue(_ value: Double, axis: AxisBase?) -> String { updateFormatterTemplate() @@ -67,13 +67,13 @@ class HorizontalAxisFormatter: IAxisValueFormatter { // MARK: - VerticalAxisFormatter -class VerticalAxisFormatter: IAxisValueFormatter { +class VerticalAxisFormatter: AxisValueFormatter { // MARK: Properties private let largeValueFormatter = LargeValueFormatter() - // MARK: IAxisValueFormatter + // MARK: AxisValueFormatter func stringForValue(_ value: Double, axis: AxisBase?) -> String { if value <= 0.0 { diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/Charts+LargeValueFormatter.swift b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+LargeValueFormatter.swift index aa19344ea28d..50f4b9ee7f3d 100644 --- a/WordPress/Classes/ViewRelated/Stats/Charts/Charts+LargeValueFormatter.swift +++ b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+LargeValueFormatter.swift @@ -7,7 +7,7 @@ private let MAX_LENGTH = 5 @objc protocol Testing123 { } -public class LargeValueFormatter: NSObject, IValueFormatter, IAxisValueFormatter { +public class LargeValueFormatter: NSObject, ValueFormatter, AxisValueFormatter { /// Suffix to be appended after the values. /// diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift index 15da21fc10e1..d9803fd4d956 100644 --- a/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift +++ b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift @@ -4,15 +4,15 @@ import Charts // MARK: - Charts extensions extension BarChartData { - convenience init(entries: [BarChartDataEntry], valueFormatter: IValueFormatter? = nil) { - let dataSet = BarChartDataSet(values: entries, label: nil, valueFormatter: valueFormatter) + convenience init(entries: [BarChartDataEntry], valueFormatter: ValueFormatter) { + let dataSet = BarChartDataSet(entries: entries, valueFormatter: valueFormatter) self.init(dataSets: [dataSet]) } } extension BarChartDataSet { - convenience init(values: [BarChartDataEntry], label: String?, valueFormatter: IValueFormatter?) { - self.init(values: values, label: label) + convenience init(entries: [BarChartDataEntry], label: String = "", valueFormatter: ValueFormatter) { + self.init(entries: entries, label: label) self.valueFormatter = valueFormatter } } @@ -48,10 +48,34 @@ protocol BarChartStyling { var lineColor: UIColor { get } /// Formatter for x-axis values - var xAxisValueFormatter: IAxisValueFormatter { get } + var xAxisValueFormatter: AxisValueFormatter { get } /// Formatter for y-axis values - var yAxisValueFormatter: IAxisValueFormatter { get } + var yAxisValueFormatter: AxisValueFormatter { get } +} + +protocol LineChartStyling { + + /// This corresponds to the primary bar color. + var primaryLineColor: UIColor { get } + + /// This bar color is used if bars are overlayed. + var secondaryLineColor: UIColor? { get } + + /// This corresponds to the color of a single selected point + var primaryHighlightColor: UIColor? { get } + + /// This corresponds to the color of axis labels on the chart + var labelColor: UIColor { get } + + /// If specified, a legend will be presented with this value. It maps to the secondary bar color above. + var legendTitle: String? { get } + + /// This corresponds to the color of axis and grid lines on the chart + var lineColor: UIColor { get } + + /// Formatter for y-axis values + var yAxisValueFormatter: AxisValueFormatter { get } } /// Transforms a given data set for consumption by BarChartView in the Charts framework. @@ -65,6 +89,17 @@ protocol BarChartDataConvertible { var barChartData: BarChartData { get } } +/// Transforms a given data set for consumption by LineChartView in the Charts framework. +/// +protocol LineChartDataConvertible { + + /// Describe the chart for VoiceOver usage + var accessibilityDescription: String { get } + + /// Adapts the original data format for consumption by the Charts framework. + var lineChartData: LineChartData { get } +} + // MARK: - Charts & analytics /// Vends property values for analytics events that use granularity. @@ -73,6 +108,10 @@ enum BarChartAnalyticsPropertyGranularityValue: String, CaseIterable { case days, weeks, months, years } +enum LineChartAnalyticsPropertyGranularityValue: String, CaseIterable { + case days, weeks, months, years +} + extension StatsPeriodUnit { var analyticsGranularity: BarChartAnalyticsPropertyGranularityValue { switch self { @@ -86,4 +125,17 @@ extension StatsPeriodUnit { return .years } } + + var analyticsGranularityLine: LineChartAnalyticsPropertyGranularityValue { + switch self { + case .day: + return .days + case .week: + return .weeks + case .month: + return .months + case .year: + return .years + } + } } diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/DonutChartView.swift b/WordPress/Classes/ViewRelated/Stats/Charts/DonutChartView.swift new file mode 100644 index 000000000000..645540569865 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Charts/DonutChartView.swift @@ -0,0 +1,413 @@ +import UIKit + +class DonutChartView: UIView { + + // MARK: Views + + private var segmentLayers = [CAShapeLayer]() + + private var titleStackView: UIStackView! + private var titleLabel: UILabel! + private var totalCountLabel: UILabel! + private var chartContainer: UIView! + private var legendStackView: UIStackView! + + // MARK: Configuration + + struct Segment: Identifiable, Equatable { + // Identifier required to keep track of ordering + let id = UUID() + let title: String + let value: CGFloat + let color: UIColor + + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.id == rhs.id + } + + /// - Returns: A new Segment with the provided value + func withValue(_ newValue: CGFloat) -> Segment { + return Segment(title: title, value: newValue, color: color) + } + } + + var title: String? { + didSet { + titleLabel.text = title + } + } + + var totalCount: CGFloat = 0 { + didSet { + totalCountLabel.text = Float(totalCount).abbreviatedString() + } + } + + private var segments: [Segment] = [] { + didSet { + segmentOrder = segments.map({ $0.id }) + } + } + + // We keep track of segment IDs so we can keep the order consistent if we need to adjust segments later + private var segmentOrder: [UUID] = [] + + // MARK: Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .basicBackground + + configureChartContainer() + configureTitleViews() + configureLegend() + configureConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureChartContainer() { + chartContainer = UIView() + chartContainer.translatesAutoresizingMaskIntoConstraints = false + addSubview(chartContainer) + } + + private func configureTitleViews() { + titleLabel = UILabel() + titleLabel.textAlignment = .center + titleLabel.font = .preferredFont(forTextStyle: .subheadline) + titleLabel.adjustsFontForContentSizeCategory = true + + totalCountLabel = UILabel() + totalCountLabel.textAlignment = .center + totalCountLabel.font = .preferredFont(forTextStyle: .title1).bold() + totalCountLabel.adjustsFontForContentSizeCategory = true + + titleStackView = UIStackView(arrangedSubviews: [titleLabel, totalCountLabel]) + + titleStackView.translatesAutoresizingMaskIntoConstraints = false + titleStackView.axis = .vertical + titleStackView.spacing = Constants.titleStackViewSpacing + + addSubview(titleStackView) + } + + private func configureLegend() { + legendStackView = UIStackView() + legendStackView.translatesAutoresizingMaskIntoConstraints = false + legendStackView.spacing = Constants.legendStackViewSpacing + legendStackView.distribution = .fillEqually + legendStackView.alignment = .center + + addSubview(legendStackView) + } + + private func configureConstraints() { + NSLayoutConstraint.activate([ + chartContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + chartContainer.trailingAnchor.constraint(equalTo: trailingAnchor), + chartContainer.topAnchor.constraint(equalTo: topAnchor), + chartContainer.heightAnchor.constraint(equalToConstant: Constants.chartHeight), + + legendStackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + legendStackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + legendStackView.topAnchor.constraint(equalTo: chartContainer.bottomAnchor, constant: Constants.chartToLegendSpacing), + legendStackView.bottomAnchor.constraint(equalTo: bottomAnchor), + + titleStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.innerTextPadding), + titleStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.innerTextPadding), + titleStackView.centerYAnchor.constraint(equalTo: chartContainer.centerYAnchor), + titleStackView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: Constants.innerTextPadding), + titleStackView.bottomAnchor.constraint(lessThanOrEqualTo: chartContainer.bottomAnchor, constant: -Constants.innerTextPadding) + ]) + } + + /// Initializes the chart display with the provided data. + /// + /// - Parameters: + /// - title: Displayed in the center of the chart + /// - totalCount: Displayed in the center of the chart and used to calculate segment sizes + /// - segments: Used for color, legend titles, and segment size + func configure(title: String?, totalCount: CGFloat, segments: [Segment]) { + if segments.reduce(0.0, { $0 + $1.value }) > totalCount { + DDLogInfo("DonutChartView: Segment values should not total greater than 100%.") + } + + self.title = title + self.totalCount = totalCount + self.segments = normalizedSegments(segments) + + segments.forEach({ legendStackView.addArrangedSubview(LegendView(segment: $0)) }) + + layoutChart() + } + + // Converts all segment to percentage values between 0 and 1, otherwise + // extremely large values can throw things off when calculating segment sizes. + private func normalizedSegments(_ segments: [Segment]) -> [Segment] { + guard totalCount > 0 else { + return segments + } + + let filtered = segments.filter({ $0.value > 0 }) + return filtered.map({ $0.withValue($0.value / totalCount) }) + } + + private func layoutChart() { + guard !bounds.isEmpty else { + return + } + + CATransaction.begin() + CATransaction.setDisableActions(true) + + // Clear out any existing segments + segmentLayers.forEach({ $0.removeFromSuperlayer() }) + segmentLayers = [] + + guard totalCount > 0 else { + // We must have a total count greater than 0, as we use it to calculate percentages + DDLogInfo("DonutChartView: TotalCount must be greater than 0 for chart initialization.") + return + } + + // Due to the size of the endcaps on segments, if a segment is too small we can't display it. + // Here we'll increase the size of small segments if necessary. We loop through segments.count times + // to ensure that after each adjustment the remaining segments are still an acceptable size. + var displaySegments = adjustedSegmentsForDisplay(segments) + for _ in 0.. [Segment] { + // Ignore 0 sized segments and those that we've already marked as minimum size + let belowMinimumSegments: [Segment] = segments.filter({ $0.value > 0 && $0.value != Constants.minimumSizeSegment && $0.value < minimumSizePercentage }) + let otherSegments: [Segment] = segments.filter({ belowMinimumSegments.contains($0) == false }) + + // If a segment is too small to fit on the chart, we'll make a note of how much we + // need to adjust it to match the minimum, and add it to the array. + let totalAdjustment: CGFloat = belowMinimumSegments.reduce(0) { $0 + minimumSizePercentage - $1.value } + + guard belowMinimumSegments.count > 0 else { + return segments + } + + // Next we need to adjust the sizes of the other segments to account for the extra we added so that we end up back at 100% + let adjustmentPerSegment = totalAdjustment / CGFloat(otherSegments.count) + var allSegments: [Segment] = [] + + allSegments.append(contentsOf: otherSegments.map({ $0.withValue($0.value - adjustmentPerSegment) })) + allSegments.append(contentsOf: belowMinimumSegments.map({ $0.withValue(Constants.minimumSizeSegment) })) + + // Re-sort the new list based on the original ID order passed in when the chart was configured + return allSegments.sorted(by: { segmentOrder.firstIndex(of: $0.id) ?? 0 < segmentOrder.firstIndex(of: $1.id) ?? 0 }) + } + + override func layoutSubviews() { + super.layoutSubviews() + + if !segments.isEmpty { + layoutChart() + } + } + + // MARK: - Dynamic Type + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + legendStackView.axis = .vertical + legendStackView.alignment = .leading + legendStackView.subviews.forEach { ($0 as? LegendView)?.isCentered = false } + } else { + legendStackView.axis = .horizontal + legendStackView.alignment = .center + legendStackView.subviews.forEach { ($0 as? LegendView)?.isCentered = true } + } + } + + // MARK: Helpers + + private func makeSegmentLayer(_ segment: Segment) -> CAShapeLayer { + let segmentLayer = CAShapeLayer() + segmentLayer.frame = chartContainer.bounds + segmentLayer.lineWidth = Constants.lineWidth + segmentLayer.fillColor = UIColor.clear.cgColor + segmentLayer.strokeColor = segment.color.cgColor + segmentLayer.lineCap = .round + + return segmentLayer + } + + private var chartCenterPoint: CGPoint { + return CGPoint(x: chartContainer.bounds.midX, y: chartContainer.bounds.midY) + } + + private var chartRadius: CGFloat { + let smallestDimension = min(chartContainer.bounds.width, chartContainer.bounds.height) + return (smallestDimension / 2.0) - (Constants.lineWidth / 2.0) + } + + /// Offset used to adjust the endpoints of each chart segment so that the end caps + /// don't overlap, as they draw from their center not from the line edge + private var endCapOffset: CGFloat { + return asin(Constants.lineWidth * 0.5 / chartRadius) + } + + // How many % does a minimum size segment take up? (minimum size is 2 * endcap offset) + private var minimumSizePercentage: CGFloat { + return (endCapOffset * 2.0).percentFromRadians() + 0.01 // Just needs to be a fraction larger than the endcaps themselves + } + + // MARK: Constants + + enum Constants { + static let lineWidth: CGFloat = 16.0 + static let innerTextPadding: CGFloat = 24.0 + static let titleStackViewSpacing: CGFloat = 8.0 + static let legendStackViewSpacing: CGFloat = 8.0 + static let chartToLegendSpacing: CGFloat = 32.0 + static let chartHeight: CGFloat = 180.0 + + // We'll rotate the chart back by 90 degrees so it starts at the top rather than the right + static let chartRotationDegrees: CGFloat = -90.0 + + // Used to denote a segment that is below or at the minimum size we can display + static let minimumSizeSegment: CGFloat = -1 + } +} + +// MARK: - Legend View + +private class LegendView: UIView { + let segment: DonutChartView.Segment + + var isCentered: Bool { + get { + return leadingConstraint?.isActive ?? false + } + + set { + leadingConstraint?.isActive = !newValue + } + } + private var leadingConstraint: NSLayoutConstraint? + + init(segment: DonutChartView.Segment) { + self.segment = segment + + super.init(frame: .zero) + + configureSubviews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configureSubviews() { + let containerView = UIView() + containerView.translatesAutoresizingMaskIntoConstraints = false + + let indicator = UIView() + indicator.backgroundColor = segment.color + indicator.layer.cornerRadius = 6.0 + indicator.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.font = .preferredFont(forTextStyle: .footnote) + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.text = segment.title + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + containerView.addSubviews([titleLabel, indicator]) + addSubview(containerView) + + leadingConstraint = containerView.leadingAnchor.constraint(equalTo: leadingAnchor) + leadingConstraint?.priority = .required + + NSLayoutConstraint.activate([ + indicator.widthAnchor.constraint(equalToConstant: 12.0), + indicator.heightAnchor.constraint(equalToConstant: 12.0), + indicator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + indicator.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + + titleLabel.leadingAnchor.constraint(equalTo: indicator.trailingAnchor, constant: 8), + titleLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + titleLabel.topAnchor.constraint(greaterThanOrEqualTo: containerView.topAnchor), + titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: containerView.bottomAnchor), + + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor), + containerView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor), + containerView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), + containerView.centerXAnchor.constraint(equalTo: centerXAnchor) + ]) + } +} + +private extension CGFloat { + func radiansFromPercent() -> CGFloat { + return self * 2.0 * CGFloat.pi + } + + func percentFromRadians() -> CGFloat { + return self / (CGFloat.pi * 2.0) + } + + func radiansRotated(byDegrees rotationDegrees: CGFloat) -> CGFloat { + return self + rotationDegrees.degreesToRadians() + } + + func degreesToRadians() -> CGFloat { + return self * CGFloat.pi / 180.0 + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/InsightsLineChart.swift b/WordPress/Classes/ViewRelated/Stats/Charts/InsightsLineChart.swift new file mode 100644 index 000000000000..c0fbc3219a64 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Charts/InsightsLineChart.swift @@ -0,0 +1,183 @@ +import Foundation +import Charts +import Kanvas + +// MARK: - StatsInsightsFilterDimension + +enum StatsInsightsFilterDimension: Int, CaseIterable { + case views = 0, visitors +} + +extension StatsInsightsFilterDimension { + var accessibleDescription: String { + switch self { + case .views: + return NSLocalizedString("Line Chart depicting Views for insights.", comment: "This description is used to set the accessibility label for the Insights chart, with Views selected.") + case .visitors: + return NSLocalizedString("Line Chart depicting Visitors for insights.", comment: "This description is used to set the accessibility label for the Insights chart, with Visitors selected.") + } + } + + var analyticsProperty: String { + switch self { + case .views: + return "views" + case .visitors: + return "visitors" + } + } +} + +// MARK: - InsightsLineChart + +class InsightsLineChart { + + private let rawChartData: [StatsSummaryTimeIntervalDataAsAWeek] + private var filterDimension: StatsInsightsFilterDimension + + private(set) var lineChartData: [LineChartDataConvertible] = [] + private(set) var lineChartStyling: [LineChartStyling] = [] + + init(data: [StatsSummaryTimeIntervalDataAsAWeek], filterDimension: StatsInsightsFilterDimension = .views) { + rawChartData = data + self.filterDimension = filterDimension + + let (data, styling) = transform() + + lineChartData = data + lineChartStyling = styling + } + + private static let dataSetValueFormatter = DefaultValueFormatter(decimals: 0) + + /// Transforms the raw data into the line chart data and styling. + /// similar to PeriodChart transform + /// - Returns: A tuple containing the line chart data and styling. + func transform() -> (lineChartData: [LineChartDataConvertible], lineChartStyling: [LineChartStyling]) { + var thisWeekEntries = [ChartDataEntry]() + var prevWeekEntries = [ChartDataEntry]() + + switch filterDimension { + case .views: + (thisWeekEntries, prevWeekEntries) = filterData(path: \StatsSummaryData.viewsCount) + case .visitors: + (thisWeekEntries, prevWeekEntries) = filterData(path: \StatsSummaryData.visitorsCount) + } + + let chartData = createLineChartData(thisWeekEntries: thisWeekEntries, prevWeekEntries: prevWeekEntries) + let lineChartDataConvertibles = createLineChartDataConvertibles(chartData: chartData) + + let chartStyling: [LineChartStyling] = [ + ViewsInsightsLineChartStyling(primaryLineColor: Constants.primaryLineColorViews, + secondaryLineColor: Constants.secondaryLineColor, + primaryHighlightColor: Constants.primaryHighlightColor), + VisitorsInsightsLineChartStyling(primaryLineColor: Constants.primaryLineColorVisitors, + secondaryLineColor: Constants.secondaryLineColor, + primaryHighlightColor: Constants.primaryHighlightColor), + ] + + return (lineChartDataConvertibles, chartStyling) + } + + func createLineChartData(thisWeekEntries: [ChartDataEntry], prevWeekEntries: [ChartDataEntry]) -> [LineChartData] { + var chartData = [LineChartData]() + + let thisWeekDataSet = LineChartDataSet(entries: thisWeekEntries, + label: NSLocalizedString("stats.insights.accessibility.label.viewsVisitorsLastDays", value: "Last 7-days", comment: "Accessibility label used for distinguishing Views and Visitors in the Stats → Insights Views Visitors Line chart.")) + let prevWeekDataSet = LineChartDataSet(entries: prevWeekEntries, + label: NSLocalizedString("stats.insights.accessibility.label.viewsVisitorsPreviousDays", value: "Previous 7-days", comment: "Accessibility label used for distinguishing Views and Visitors in the Stats → Insights Views Visitors Line chart.")) + let viewsDataSets = [ thisWeekDataSet, prevWeekDataSet ] + let viewsChartData = LineChartData(dataSets: viewsDataSets) + chartData.append(viewsChartData) + + return chartData + } + + func createLineChartDataConvertibles(chartData: [LineChartData]) -> [LineChartDataConvertible] { + var lineChartDataConvertibles = [LineChartDataConvertible]() + + for filterDimension in StatsInsightsFilterDimension.allCases { + let filterIndex = filterDimension.rawValue + + let accessibleDescription = filterDimension.accessibleDescription + let data = chartData[filterIndex] + let insightsChartData = InsightsLineChartData(accessibilityDescription: accessibleDescription, lineChartData: data) + + lineChartDataConvertibles.append(insightsChartData) + break + } + + return lineChartDataConvertibles + } + + func filterData(path: KeyPath) -> (thisWeekEntries: [ChartDataEntry], prevWeekEntries: [ChartDataEntry]) { + var thisWeekEntries = [ChartDataEntry]() + var prevWeekEntries = [ChartDataEntry]() + + rawChartData.forEach { statsSummaryTimeIntervalDataAsAWeek in + switch statsSummaryTimeIntervalDataAsAWeek { + case .thisWeek(let data): + for (index, statsSummaryData) in data.summaryData.enumerated() { + thisWeekEntries.append(ChartDataEntry(x: Double(index), y: Double(statsSummaryData[keyPath: path]))) + } + case .prevWeek(let data): + for (index, statsSummaryData) in data.summaryData.enumerated() { + prevWeekEntries.append(ChartDataEntry(x: Double(index), y: Double(statsSummaryData[keyPath: path]))) + } + } + } + + return (thisWeekEntries: thisWeekEntries, prevWeekEntries: prevWeekEntries) + } + + func primaryLineColor(forFilterDimension filterDimension: StatsInsightsFilterDimension) -> UIColor { + switch filterDimension { + case .views: + return UIColor(light: .muriel(name: .blue, .shade50), dark: .muriel(name: .blue, .shade50)) + case .visitors: + return UIColor(light: .muriel(name: .purple, .shade50), dark: .muriel(name: .purple, .shade50)) + } + } +} + +private extension InsightsLineChart { + enum Constants { + static let primaryHighlightColor: UIColor = UIColor(red: 209.0/255.0, green: 209.0/255.0, blue: 214.0/255.0, alpha: 1.0) + static let secondaryLineColor: UIColor = UIColor(light: .textQuaternary, dark: .textTertiary) + static let primaryLineColorViews: UIColor = UIColor(light: .muriel(name: .blue, .shade50), dark: .muriel(name: .blue, .shade50)) + static let primaryLineColorVisitors: UIColor = UIColor(light: .muriel(name: .purple, .shade50), dark: .muriel(name: .purple, .shade50)) + } +} + +// MARK: - InsightsLineChartData + +private struct InsightsLineChartData: LineChartDataConvertible { + let accessibilityDescription: String + let lineChartData: LineChartData +} + +// MARK: - ViewsInsightsLineChartStyling + +private struct ViewsInsightsLineChartStyling: LineChartStyling { + let primaryLineColor: UIColor + let secondaryLineColor: UIColor? + let primaryHighlightColor: UIColor? + let labelColor: UIColor = UIColor(light: .secondaryLabel, dark: .tertiaryLabel) + let legendColor: UIColor? = .primary(.shade60) + let legendTitle: String? = NSLocalizedString("Views", comment: "Title for Views count in the legend of the Stats Insights views and visitors line chart") + let lineColor: UIColor = .neutral(.shade5) + let yAxisValueFormatter: AxisValueFormatter = VerticalAxisFormatter() +} + +// MARK: - VisitorsInsightsLineChartStyling + +private struct VisitorsInsightsLineChartStyling: LineChartStyling { + let primaryLineColor: UIColor + let secondaryLineColor: UIColor? + let primaryHighlightColor: UIColor? + let labelColor: UIColor = UIColor(light: .secondaryLabel, dark: .tertiaryLabel) + let legendColor: UIColor? = .primary(.shade60) + let legendTitle: String? = NSLocalizedString("Visitors", comment: "Title for Visitors count in the legend of the Stats Insights views and visitors line chart") + let lineColor: UIColor = .neutral(.shade5) + let yAxisValueFormatter: AxisValueFormatter = VerticalAxisFormatter() +} diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/PeriodChart.swift b/WordPress/Classes/ViewRelated/Stats/Charts/PeriodChart.swift index 5e6fcd7ad1ac..c81e33987c4f 100644 --- a/WordPress/Classes/ViewRelated/Stats/Charts/PeriodChart.swift +++ b/WordPress/Classes/ViewRelated/Stats/Charts/PeriodChart.swift @@ -114,10 +114,10 @@ private final class PeriodChartDataTransformer { var chartData = [BarChartData]() - let viewsDataSet = BarChartDataSet(values: viewEntries, + let viewsDataSet = BarChartDataSet(entries: viewEntries, label: NSLocalizedString("Views", comment: "Accessibility label used for distinguishing Views and Visitors in the Stats → Views bar chart."), valueFormatter: dataSetValueFormatter) - let visitorsDataSet = BarChartDataSet(values: visitorEntries, + let visitorsDataSet = BarChartDataSet(entries: visitorEntries, label: NSLocalizedString("Visitors", comment: "Accessibility label used for distinguishing Views and Visitors in the Stats → Views bar chart."), valueFormatter: dataSetValueFormatter) let viewsDataSets = [ viewsDataSet, visitorsDataSet ] @@ -178,11 +178,11 @@ private final class PeriodChartDataTransformer { } static func primaryHighlightColor(forCount count: Int) -> UIColor? { - return count > 0 ? UIColor(light: .accent(.shade30), dark: .accent(.shade60)) : nil + return count > 0 ? .statsPrimaryHighlight : nil } static func secondaryHighlightColor(forCount count: Int) -> UIColor? { - return count > 0 ? UIColor(light: .accent(.shade60), dark: .accent(.shade30)) : nil + return count > 0 ? .statsSecondaryHighlight : nil } } @@ -198,8 +198,8 @@ private struct ViewsPeriodChartStyling: BarChartStyling { let legendColor: UIColor? = .primary(.shade60) let legendTitle: String? = NSLocalizedString("Visitors", comment: "This appears in the legend of the period chart; Visitors are superimposed over Views in that case.") let lineColor: UIColor = .neutral(.shade5) - let xAxisValueFormatter: IAxisValueFormatter - let yAxisValueFormatter: IAxisValueFormatter = VerticalAxisFormatter() + let xAxisValueFormatter: AxisValueFormatter + let yAxisValueFormatter: AxisValueFormatter = VerticalAxisFormatter() } // MARK: - DefaultPeriodChartStyling @@ -213,6 +213,6 @@ private struct DefaultPeriodChartStyling: BarChartStyling { let legendColor: UIColor? = nil let legendTitle: String? = nil let lineColor: UIColor = .neutral(.shade5) - let xAxisValueFormatter: IAxisValueFormatter - let yAxisValueFormatter: IAxisValueFormatter = VerticalAxisFormatter() + let xAxisValueFormatter: AxisValueFormatter + let yAxisValueFormatter: AxisValueFormatter = VerticalAxisFormatter() } diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/PostChart.swift b/WordPress/Classes/ViewRelated/Stats/Charts/PostChart.swift index 53e22e0e37c9..711eebf8ea44 100644 --- a/WordPress/Classes/ViewRelated/Stats/Charts/PostChart.swift +++ b/WordPress/Classes/ViewRelated/Stats/Charts/PostChart.swift @@ -114,10 +114,10 @@ private final class PostChartDataTransformer { entries.append(entry) } - let chartData = BarChartData(entries: entries) + let chartData = BarChartData(dataSet: BarChartDataSet(entries: entries)) chartData.barWidth = effectiveWidth - let xAxisFormatter: IAxisValueFormatter = HorizontalAxisFormatter(initialDateInterval: firstDateInterval) + let xAxisFormatter: AxisValueFormatter = HorizontalAxisFormatter(initialDateInterval: firstDateInterval) let styling = PostChartStyling(primaryBarColor: primaryBarColor(forCount: totalViews), primaryHighlightColor: primaryHighlightColor(forType: type, withCount: totalViews), xAxisValueFormatter: xAxisFormatter) @@ -146,6 +146,6 @@ private struct PostChartStyling: BarChartStyling { let legendColor: UIColor? = nil let legendTitle: String? = nil let lineColor: UIColor = .neutral(.shade5) - let xAxisValueFormatter: IAxisValueFormatter - let yAxisValueFormatter: IAxisValueFormatter = VerticalAxisFormatter() + let xAxisValueFormatter: AxisValueFormatter + let yAxisValueFormatter: AxisValueFormatter = VerticalAxisFormatter() } diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/SparklineView.swift b/WordPress/Classes/ViewRelated/Stats/Charts/SparklineView.swift new file mode 100644 index 000000000000..846cef209e28 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Charts/SparklineView.swift @@ -0,0 +1,156 @@ +import UIKit +import simd + +class SparklineView: UIView { + private let lineLayer = CAShapeLayer() + private let maskLayer = CAShapeLayer() + private let gradientLayer = CAGradientLayer() + + private static let defaultChartColor = UIColor.muriel(name: .blue, .shade50) + var chartColor: UIColor! = SparklineView.defaultChartColor { + didSet { + if chartColor == nil { + chartColor = SparklineView.defaultChartColor + } + + updateChartColors() + } + } + + var data: [Int] = [] { + didSet { + let floatData: [CGFloat] = data.map({ CGFloat($0) }) + chartData = interpolateData(floatData) + + layoutChart() + } + } + + private var chartData: [CGFloat] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + + initializeChart() + } + + required init?(coder: NSCoder) { + fatalError() + } + + override func layoutSubviews() { + super.layoutSubviews() + + layoutChart() + } + + func initializeChart() { + layer.isGeometryFlipped = true + + lineLayer.lineWidth = Constants.lineWidth + lineLayer.fillColor = UIColor.clear.cgColor + + maskLayer.strokeColor = UIColor.clear.cgColor + maskLayer.fillColor = UIColor.black.cgColor + + gradientLayer.startPoint = Constants.gradientStart + gradientLayer.endPoint = Constants.gradientEnd + gradientLayer.mask = maskLayer + gradientLayer.opacity = Constants.gradientOpacity + + updateChartColors() + layer.addSublayer(gradientLayer) + layer.addSublayer(lineLayer) + } + + private func updateChartColors() { + lineLayer.strokeColor = chartColor.cgColor + gradientLayer.colors = [chartColor.cgColor, UIColor(white: 1.0, alpha: 0.0).cgColor] + } + + private func interpolateData(_ inputData: [CGFloat]) -> [CGFloat] { + guard inputData.count > 0, + let first = inputData.first else { + return [] + } + + var interpolatedData = [first] + + for (this, next) in zip(inputData, inputData.dropFirst()) { + let interpolationIncrement = (next - this) / Constants.interpolationCount + + for index in stride(from: 1, through: Constants.interpolationCount, by: 1) { + let normalized = simd_smoothstep(this, next, this + (index * interpolationIncrement)) + let actual = simd_mix(this, next, normalized) + + interpolatedData.append(actual) + } + } + + return interpolatedData + } + + private func layoutChart() { + CATransaction.begin() + CATransaction.setDisableActions(true) + + lineLayer.frame = bounds + maskLayer.frame = bounds + gradientLayer.frame = bounds + + guard bounds.width > 0, + bounds.height > 0, + chartData.count > 1 else { + lineLayer.path = nil + maskLayer.path = nil + CATransaction.commit() + return + } + + // Calculate points to fit along X axis, using existing interpolated Y values + let segmentWidth = bounds.width / CGFloat(chartData.count-1) + let points = chartData.enumerated().map({ CGPoint(x: CGFloat($0.offset) * segmentWidth, y: $0.element) }) + + // Scale Y values to fit within our bounds + let maxYValue = max(1.0, points.map(\.y).max() ?? 1.0) + let scaleFactor = bounds.height / maxYValue + var transform = CGAffineTransform(scaleX: 1.0, y: scaleFactor) + + // Scale the points slightly so that the line remains within bounds, based on the line width. + let xScaleFactor = (bounds.width - Constants.lineWidth) / bounds.width + let yScaleFactor = (bounds.height - Constants.lineWidth) / bounds.height + + transform = transform.scaledBy(x: xScaleFactor, y: yScaleFactor) + + let halfLineWidth = Constants.lineWidth / 2.0 + let lineTransform = CGAffineTransform(translationX: halfLineWidth, y: halfLineWidth) + transform = transform.concatenating(lineTransform) + + // Finally, create the paths – first the line... + let lineLayerPath = CGMutablePath() + lineLayerPath.addLines(between: points, transform: transform) + + lineLayer.path = lineLayerPath + + // ... then the bottom gradient + if let maskLayerPath = lineLayerPath.mutableCopy() { + maskLayerPath.addLine(to: CGPoint(x: bounds.width, y: 0)) + maskLayerPath.addLine(to: CGPoint(x: 0, y: 0)) + maskLayer.path = maskLayerPath + } + + CATransaction.commit() + } + + private enum Constants { + static let lineWidth: CGFloat = 2.0 + static let gradientOpacity: Float = 0.1 + + // This number of extra data points will be interpolated in between each pair of original data points. + // The higher the number, the smoother the chart line. + static let interpolationCount: Double = 20 + + static let gradientStart = CGPoint(x: 0.0, y: 0.5) + static let gradientEnd = CGPoint(x: 1.0, y: 0.5) + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/StatsBarChartView.swift b/WordPress/Classes/ViewRelated/Stats/Charts/StatsBarChartView.swift index e3cdf9af52d3..aa33583a3894 100644 --- a/WordPress/Classes/ViewRelated/Stats/Charts/StatsBarChartView.swift +++ b/WordPress/Classes/ViewRelated/Stats/Charts/StatsBarChartView.swift @@ -3,7 +3,7 @@ import Charts // MARK: - StatsBarChartViewDelegate -protocol StatsBarChartViewDelegate: class { +protocol StatsBarChartViewDelegate: AnyObject { func statsBarChartValueSelected(_ statsBarChartView: StatsBarChartView, entryIndex: Int, entryCount: Int) } @@ -81,7 +81,7 @@ class StatsBarChartView: BarChartView { return lastEntryIndex } - private var primaryDataSet: IChartDataSet? { + private var primaryDataSet: ChartDataSetProtocol? { return data?.dataSets.first } @@ -342,7 +342,7 @@ private extension StatsBarChartView { func configureYAxisMaximum() { let lowestMaxValue = Double(Constants.verticalAxisLabelCount - 1) - if let maxY = data?.getYMax(), + if let maxY = data?.getYMax(axis: .left), maxY >= lowestMaxValue { leftAxis.axisMaximum = VerticalAxisFormatter.roundUpAxisMaximum(maxY) } else { @@ -424,7 +424,7 @@ private extension StatsBarChartView { } func redrawChartMarkersIfNeeded() { - guard marker != nil, let highlight = lastHighlighted, let entry = barData?.entryForHighlight(highlight) else { + guard marker != nil, let highlight = lastHighlighted, let entry = barData?.entry(for: highlight) else { return } @@ -454,9 +454,6 @@ extension StatsBarChartView: ChartViewDelegate { extension StatsBarChartView: Accessible { func prepareForVoiceOver() { // ChartDataRendererBase creates a meaningful a11y description, relying on the chart description - guard let chartDescription = chartDescription else { - return - } chartDescription.text = barChartData.accessibilityDescription chartDescription.enabled = false // disabling the description hides a corresponding label } diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/StatsLineChartConfiguration.swift b/WordPress/Classes/ViewRelated/Stats/Charts/StatsLineChartConfiguration.swift new file mode 100644 index 000000000000..348909a8285f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Charts/StatsLineChartConfiguration.swift @@ -0,0 +1,8 @@ + +struct StatsLineChartConfiguration { + let data: LineChartDataConvertible + let styling: LineChartStyling + let analyticsGranularity: LineChartAnalyticsPropertyGranularityValue? + let indexToHighlight: Int? + let xAxisDates: [Date] +} diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/StatsLineChartView.swift b/WordPress/Classes/ViewRelated/Stats/Charts/StatsLineChartView.swift new file mode 100644 index 000000000000..d3273b84949b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Charts/StatsLineChartView.swift @@ -0,0 +1,343 @@ +import UIKit +import Charts + +// MARK: - StatsLineChartViewDelegate + +protocol StatsLineChartViewDelegate: AnyObject { + func statsLineChartValueSelected(_ statsLineChartView: StatsLineChartView, entryIndex: Int, entryCount: Int) +} + +// MARK: - StatsLineChartView + +private let LineChartAnalyticsPropertyKey = "property" +private let LineChartAnalyticsPropertyGranularityKey = "granularity" + +class StatsLineChartView: LineChartView { + + // MARK: Properties + + private struct Constants { + static let intrinsicHeight = CGFloat(190) + static let highlightAlpha = CGFloat(1) + static let highlightLineWidth = 1.0 + static let highlightLineDashLengths = 4.4 + static let horizontalAxisLabelCount = 3 + static let presentationDelay = TimeInterval(0.5) + static let primaryDataSetIndex = 0 + static let rotationDelay = TimeInterval(0.35) + static let secondaryDataSetIndex = 1 + static let topOffset = CGFloat(16) + static let trailingOffset = CGFloat(8) + static let verticalAxisLabelCount = 5 + static let xAxisWidth = 4.0 + static let xAxisTickWidth = 2.0 + static let lineWidth = 2.0 + static let numberDaysInWeek = 7 + } + + /// This adapts the data set for presentation by the Charts framework. + /// + private let lineChartData: LineChartDataConvertible + + /// This influences the visual appearance of the chart to be rendered. + /// + private let styling: LineChartStyling + + /// This informs the analytics event captured via user interaction. + /// + private let analyticsGranularity: LineChartAnalyticsPropertyGranularityValue? + + /// Dates to populate the x-axis + /// + private var xAxisDates: [Date] + + /// When set, the delegate is advised of user-initiated line selections + /// + private weak var statsLineChartViewDelegate: StatsLineChartViewDelegate? + + private var statsInsightsFilterDimension: StatsInsightsFilterDimension + + private var isHighlightNeeded: Bool { + guard let primaryDataSet = primaryDataSet, primaryDataSet.isHighlightEnabled else { + return false + } + return styling.primaryHighlightColor != nil + } + + private var primaryDataSet: ChartDataSetProtocol? { + return data?.dataSets.first + } + + + // MARK: StatsLineChartView + + override var bounds: CGRect { + didSet { + redrawChartMarkersIfNeeded() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + updateXAxisTicks() + } + + init(configuration: StatsLineChartConfiguration, delegate: StatsLineChartViewDelegate? = nil, statsInsightsFilterDimension: StatsInsightsFilterDimension = .views) { + self.lineChartData = configuration.data + self.styling = configuration.styling + self.analyticsGranularity = configuration.analyticsGranularity + self.statsLineChartViewDelegate = delegate + self.xAxisDates = configuration.xAxisDates + self.statsInsightsFilterDimension = statsInsightsFilterDimension + + super.init(frame: .zero) + + initialize() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + return CGSize(width: UIView.noIntrinsicMetric, height: Constants.intrinsicHeight) + } +} + +// MARK: - Private behavior + +private extension StatsLineChartView { + + func applyStyling() { + configureLineChartViewBaseProperties() + configureChartViewBaseProperties() + + configureXAxis() + configureYAxis() + } + + func captureAnalyticsEvent() { + var properties = [String: String]() + + if let specifiedAnalyticsGranularity = analyticsGranularity { + properties[LineChartAnalyticsPropertyGranularityKey] = specifiedAnalyticsGranularity.rawValue + } + + properties[LineChartAnalyticsPropertyKey] = statsInsightsFilterDimension.analyticsProperty + + WPAnalytics.track(.statsLineChartTapped, properties: properties) + } + + func configureAndPopulateData() { + let lineChartData = self.lineChartData.lineChartData + + guard let dataSets = lineChartData.dataSets as? [LineChartDataSet] else { + return + } + + configureChartForMultipleDataSets(dataSets) + + configureLegendIfNeeded() + + data = lineChartData + + configureYAxisMaximum() + } + + func configureLineChartViewBaseProperties() { + doubleTapToZoomEnabled = false + dragXEnabled = false + dragYEnabled = false + pinchZoomEnabled = false + + drawBordersEnabled = false + drawGridBackgroundEnabled = false + + minOffset = CGFloat(0) + + rightAxis.enabled = false + + scaleXEnabled = false + scaleYEnabled = false + } + + func configureChartForMultipleDataSets(_ dataSets: [LineChartDataSet]) { + // Primary + guard let primaryDataSet = dataSets.first else { + return + } + primaryDataSet.colors = [ styling.primaryLineColor ] + primaryDataSet.drawValuesEnabled = false + primaryDataSet.drawCirclesEnabled = false + primaryDataSet.lineWidth = Constants.lineWidth + primaryDataSet.mode = .horizontalBezier + + let gradientColors = [styling.primaryLineColor.withAlphaComponent(1).cgColor, + styling.primaryLineColor.withAlphaComponent(0).cgColor] + if let gradient = CGGradient(colorsSpace: nil, colors: gradientColors as CFArray, locations: nil) { + primaryDataSet.fillAlpha = 0.1 + primaryDataSet.fill = LinearGradientFill(gradient: gradient, angle: 0) + primaryDataSet.drawFilledEnabled = true + } + + if let initialHighlightColor = styling.primaryHighlightColor { + primaryDataSet.highlightColor = initialHighlightColor + primaryDataSet.highlightLineWidth = Constants.highlightLineWidth + primaryDataSet.highlightLineDashLengths = [Constants.highlightLineDashLengths] + primaryDataSet.drawHorizontalHighlightIndicatorEnabled = false + primaryDataSet.highlightEnabled = true + } else { + primaryDataSet.highlightEnabled = false + highlightPerTapEnabled = false + } + + // Secondary + guard dataSets.count > 1, let secondaryBarColor = styling.secondaryLineColor else { + return + } + let secondaryDataSet = dataSets[1] + secondaryDataSet.colors = [ secondaryBarColor ] + secondaryDataSet.drawValuesEnabled = false + secondaryDataSet.drawCirclesEnabled = false + secondaryDataSet.lineWidth = Constants.lineWidth + secondaryDataSet.mode = .horizontalBezier + secondaryDataSet.highlightEnabled = false + } + + func configureChartViewBaseProperties() { + dragDecelerationEnabled = false + extraRightOffset = Constants.trailingOffset + } + + func configureLegendIfNeeded() { + legend.enabled = false + } + + func configureXAxis() { + xAxis.axisLineColor = styling.lineColor + xAxis.drawAxisLineEnabled = true + xAxis.drawGridLinesEnabled = false + xAxis.drawLabelsEnabled = true + xAxis.labelPosition = .bottom + xAxis.labelTextColor = styling.labelColor + xAxis.labelFont = WPStyleGuide.fontForTextStyle(.footnote, symbolicTraits: [], maximumPointSize: WPStyleGuide.Stats.maximumChartAxisFontPointSize) + xAxis.setLabelCount(Constants.horizontalAxisLabelCount, force: true) + + let dateValueFormattter = DateValueFormatter() + dateValueFormattter.xAxisDates = xAxisDates + xAxis.valueFormatter = dateValueFormattter + xAxis.avoidFirstLastClippingEnabled = true + } + + func updateXAxisTicks() { + if contentRect.width > 0 { + xAxis.axisLineWidth = Constants.xAxisWidth + + let count = max(xAxisDates.count, Constants.numberDaysInWeek) + let contentWidthMinusTicks = contentRect.width - (Constants.xAxisTickWidth * CGFloat(count)) + xAxis.axisLineDashLengths = [Constants.xAxisTickWidth, (contentWidthMinusTicks / CGFloat(count - 1))] + } + } + + func configureYAxis() { + let yAxis = leftAxis + + yAxis.axisLineColor = styling.lineColor + yAxis.axisMinimum = 0.0 + yAxis.drawAxisLineEnabled = false + yAxis.drawLabelsEnabled = true + yAxis.drawZeroLineEnabled = true + yAxis.gridColor = styling.lineColor + yAxis.labelTextColor = styling.labelColor + yAxis.labelFont = WPStyleGuide.fontForTextStyle(.footnote, symbolicTraits: [], maximumPointSize: WPStyleGuide.Stats.maximumChartAxisFontPointSize) + yAxis.setLabelCount(Constants.verticalAxisLabelCount, force: true) + yAxis.valueFormatter = styling.yAxisValueFormatter + yAxis.zeroLineColor = styling.lineColor + + // This adjustment is intended to prevent clipping observed with some labels + // Potentially relevant : https://github.com/danielgindi/Charts/issues/992 + extraTopOffset = Constants.topOffset + } + + func configureYAxisMaximum() { + let lowestMaxValue = Double(Constants.verticalAxisLabelCount - 1) + + if let maxY = data?.getYMax(axis: .left), + maxY >= lowestMaxValue { + leftAxis.axisMaximum = VerticalAxisFormatter.roundUpAxisMaximum(maxY) + } else { + leftAxis.axisMaximum = lowestMaxValue + } + } + + func drawChartMarker(for entry: ChartDataEntry) { + marker = ViewsVisitorsChartMarker.init(dotColor: styling.primaryLineColor, name: styling.legendTitle ?? "") + if let customMarker = self.marker as? ViewsVisitorsChartMarker { + customMarker.chartView = self + } + } + + func highlightBar(for entry: ChartDataEntry, with highlight: Highlight) { + drawChartMarker(for: entry) + } + + func initialize() { + translatesAutoresizingMaskIntoConstraints = false + + delegate = self + + applyStyling() + prepareForVoiceOver() + configureAndPopulateData() + } + + func redrawChartMarkersIfNeeded() { + guard marker != nil, let highlight = lastHighlighted, let entry = lineData?.entry(for: highlight) else { + return + } + + notifyDataSetChanged() + + let postRotationDelay = DispatchTime.now() + Constants.rotationDelay + DispatchQueue.main.asyncAfter(deadline: postRotationDelay) { + self.highlightBar(for: entry, with: highlight) + } + } +} + +// MARK: - ChartViewDelegate + +private typealias StatsLineChartMarker = MarkerView + +extension StatsLineChartView: ChartViewDelegate { + func chartValueSelected(_ chartView: ChartViewBase, entry: ChartDataEntry, highlight: Highlight) { + captureAnalyticsEvent() + highlightBar(for: entry, with: highlight) + } +} + +// MARK: - Accessible + +extension StatsLineChartView: Accessible { + func prepareForVoiceOver() { + // ChartDataRendererBase creates a meaningful a11y description, relying on the chart description + chartDescription.text = lineChartData.accessibilityDescription + chartDescription.enabled = false // disabling the description hides a corresponding label + } +} + +private class DateValueFormatter: NSObject, AxisValueFormatter { + var dateFormatter: DateFormatter + var xAxisDates: [Date] = [] + + public override init() { + dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMM d" + } + + public func stringForValue(_ value: Double, axis: AxisBase?) -> String { + let date = xAxisDates[Int(value)] + return dateFormatter.string(from: date) + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Extensions/Double+Stats.swift b/WordPress/Classes/ViewRelated/Stats/Extensions/Double+Stats.swift index 0a2757c5aaca..5b1ea82ed58e 100644 --- a/WordPress/Classes/ViewRelated/Stats/Extensions/Double+Stats.swift +++ b/WordPress/Classes/ViewRelated/Stats/Extensions/Double+Stats.swift @@ -42,18 +42,39 @@ extension Double { struct Cache { static let units: [Unit] = { var units: [Unit] = [] - - units.append(Unit(abbreviationFormat: NSLocalizedString("%@K", comment: "Label displaying value in thousands. Ex: 66.6K."), accessibilityLabelFormat: NSLocalizedString("%@ thousand", comment: "Accessibility label for value in thousands. Ex: 66.6 thousand."))) - - units.append(Unit(abbreviationFormat: NSLocalizedString("%@M", comment: "Label displaying value in millions. Ex: 66.6M."), accessibilityLabelFormat: NSLocalizedString("%@ million", comment: "Accessibility label for value in millions. Ex: 66.6 million."))) - - units.append(Unit(abbreviationFormat: NSLocalizedString("%@B", comment: "Label displaying value in billions. Ex: 66.6B."), accessibilityLabelFormat: NSLocalizedString("%@ billion", comment: "Accessibility label for value in billions. Ex: 66.6 billion."))) - - units.append(Unit(abbreviationFormat: NSLocalizedString("%@T", comment: "Label displaying value in trillions. Ex: 66.6T."), accessibilityLabelFormat: NSLocalizedString("%@ trillion", comment: "Accessibility label for value in trillions. Ex: 66.6 trillion."))) - - units.append(Unit(abbreviationFormat: NSLocalizedString("%@P", comment: "Label displaying value in quadrillions. Ex: 66.6P."), accessibilityLabelFormat: NSLocalizedString("%@ quadrillion", comment: "Accessibility label for value in quadrillion. Ex: 66.6 quadrillion."))) - - units.append(Unit(abbreviationFormat: NSLocalizedString("%@E", comment: "Label displaying value in quintillions. Ex: 66.6E."), accessibilityLabelFormat: NSLocalizedString("%@ quintillion", comment: "Accessibility label for value in quintillions. Ex: 66.6 quintillion."))) + // Note: using `AppLocalizedString` here (instead of `NSLocalizedString`) to ensure that strings + // will be looked up from the app's _own_ `Localizable.strings` file, even when this file is used + // as part of an _App Extension_ (especially our various stats Widgets which also use this file) + + units.append(Unit( + abbreviationFormat: AppLocalizedString("%@K", comment: "Label displaying value in thousands. Ex: 66.6K."), + accessibilityLabelFormat: AppLocalizedString("%@ thousand", comment: "Accessibility label for value in thousands. Ex: 66.6 thousand.") + )) + + units.append(Unit( + abbreviationFormat: AppLocalizedString("%@M", comment: "Label displaying value in millions. Ex: 66.6M."), + accessibilityLabelFormat: AppLocalizedString("%@ million", comment: "Accessibility label for value in millions. Ex: 66.6 million.") + )) + + units.append(Unit( + abbreviationFormat: AppLocalizedString("%@B", comment: "Label displaying value in billions. Ex: 66.6B."), + accessibilityLabelFormat: AppLocalizedString("%@ billion", comment: "Accessibility label for value in billions. Ex: 66.6 billion.") + )) + + units.append(Unit( + abbreviationFormat: AppLocalizedString("%@T", comment: "Label displaying value in trillions. Ex: 66.6T."), + accessibilityLabelFormat: AppLocalizedString("%@ trillion", comment: "Accessibility label for value in trillions. Ex: 66.6 trillion.") + )) + + units.append(Unit( + abbreviationFormat: AppLocalizedString("%@P", comment: "Label displaying value in quadrillions. Ex: 66.6P."), + accessibilityLabelFormat: AppLocalizedString("%@ quadrillion", comment: "Accessibility label for value in quadrillion. Ex: 66.6 quadrillion.") + )) + + units.append(Unit( + abbreviationFormat: AppLocalizedString("%@E", comment: "Label displaying value in quintillions. Ex: 66.6E."), + accessibilityLabelFormat: AppLocalizedString("%@ quintillion", comment: "Accessibility label for value in quintillions. Ex: 66.6 quintillion.") + )) return units }() diff --git a/WordPress/Classes/ViewRelated/Stats/Extensions/NoResultsViewController+StatsModule.swift b/WordPress/Classes/ViewRelated/Stats/Extensions/NoResultsViewController+StatsModule.swift new file mode 100644 index 000000000000..a57e9649b4dc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Extensions/NoResultsViewController+StatsModule.swift @@ -0,0 +1,38 @@ +import Foundation + +// Empty states for Stats + +extension NoResultsViewController { + + @objc func configureForStatsModuleDisabled() { + configure(title: Strings.statsModuleDisabled.title, + buttonTitle: Strings.statsModuleDisabled.buttonTitle, + subtitle: Strings.statsModuleDisabled.subtitle, + image: Constants.statsImageName) + } + + @objc func configureForActivatingStatsModule() { + configure(title: Strings.activatingStatsModule.title, accessoryView: NoResultsViewController.loadingAccessoryView()) + view.layoutIfNeeded() + } + + private enum Constants { + static let statsImageName = "wp-illustration-stats" + } + + private enum Strings { + + enum statsModuleDisabled { + static let title = NSLocalizedString("Looking for stats?", comment: "Title for the error view when the stats module is disabled.") + static let subtitle = NSLocalizedString("Enable site stats to see detailed information about your traffic, likes, comments, and subscribers.", comment: + "Error message shown when trying to view Stats and the stats module is disabled.") + static let buttonTitle = NSLocalizedString("Enable Site Stats", comment: "Title for the button that will enable the site stats module.") + + } + + enum activatingStatsModule { + static let title = NSLocalizedString("Enabling Site Stats...", comment: "Text displayed while activating the site stats module.") + } + } + +} diff --git a/WordPress/Classes/ViewRelated/Stats/Extensions/StatsViewController+JetpackSettings.swift b/WordPress/Classes/ViewRelated/Stats/Extensions/StatsViewController+JetpackSettings.swift new file mode 100644 index 000000000000..7aa8f1611f4c --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Extensions/StatsViewController+JetpackSettings.swift @@ -0,0 +1,25 @@ +import Foundation +import UIKit + +extension StatsViewController { + + @objc func activateStatsModule(success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { + guard let blog else { + return + } + + let service = BlogJetpackSettingsService(coreDataStack: ContextManager.shared) + + service.updateJetpackModuleActiveSettingForBlog(blog, + module: Constants.statsModule, + active: true, + success: success, + failure: failure) + + } + + private enum Constants { + static let statsModule = "stats" + } + +} diff --git a/WordPress/Classes/ViewRelated/Stats/Extensions/UITableViewCell+Stats.swift b/WordPress/Classes/ViewRelated/Stats/Extensions/UITableViewCell+Stats.swift index 87ff5f1a513d..5403422edb55 100644 --- a/WordPress/Classes/ViewRelated/Stats/Extensions/UITableViewCell+Stats.swift +++ b/WordPress/Classes/ViewRelated/Stats/Extensions/UITableViewCell+Stats.swift @@ -14,6 +14,7 @@ extension UITableViewCell { forType statType: StatType, limitRowsDisplayed: Bool = true, rowDelegate: StatsTotalRowDelegate? = nil, + referrerDelegate: StatsTotalRowReferrerDelegate? = nil, viewMoreDelegate: ViewMoreRowDelegate? = nil) { let numberOfDataRows = dataRows.count @@ -38,7 +39,7 @@ extension UITableViewCell { for index in 0.. UIImage? { - return Gridicon.iconOfType(iconType, withSize: gridiconSize).imageWithTintColor(tintColor.styleGuideColor) + return UIImage.gridicon(iconType, size: gridiconSize).imageWithTintColor(tintColor.styleGuideColor) } static func gravatarPlaceholderImage() -> UIImage? { @@ -167,7 +176,8 @@ extension WPStyleGuide { static func configureFilterTabBar(_ filterTabBar: FilterTabBar, forTabbedCard: Bool = false, - forOverviewCard: Bool = false) { + forOverviewCard: Bool = false, + forNewInsightsCard: Bool = false) { WPStyleGuide.configureFilterTabBar(filterTabBar) // For FilterTabBar on TabbedTotalsCell @@ -183,8 +193,21 @@ extension WPStyleGuide { filterTabBar.tintColor = defaultFilterTintColor filterTabBar.selectedTitleColor = tabbedCardFilterSelectedTitleColor } + + // For FilterTabBar on StatsInsights + if forNewInsightsCard { + filterTabBar.tabSizingStyle = .fitting + filterTabBar.tintColor = UIColor.text + filterTabBar.selectedTitleColor = UIColor.text + filterTabBar.backgroundColor = .listForeground + filterTabBar.deselectedTabColor = UIColor(light: .neutral(.shade20), dark: .neutral(.shade50)) + } } + // MARK: - Font Size + + static let maximumChartAxisFontPointSize: CGFloat = 18 + // MARK: - Style Values static let defaultTextColor = UIColor.text @@ -199,6 +222,7 @@ extension WPStyleGuide { static let subTitleFont = WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .medium) static let summaryFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) static let substringHighlightFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + static let insightsCountFont = UIFont.preferredFont(forTextStyle: .title1).bold() static let tableBackgroundColor = UIColor.listBackground static let cellBackgroundColor = UIColor.listForeground @@ -225,6 +249,7 @@ extension WPStyleGuide { static let positiveColor = UIColor.success static let negativeColor = UIColor.error + static let neutralColor = UIColor.muriel(color: MurielColor(name: .blue)) static let gridiconSize = CGSize(width: 24, height: 24) @@ -237,12 +262,7 @@ extension WPStyleGuide { static let selectedDay = UIColor.accent } - static var mapBackground: UIColor { - if #available(iOS 13, *) { - return .systemGray4 - } - return .neutral(.shade10) - } + static let mapBackground: UIColor = .systemGray4 // MARK: - Posting Activity Collection View Styles @@ -261,6 +281,13 @@ extension WPStyleGuide { let columnWidth = trunc(width / numberOfColumns) return columnWidth } + + // MARK: - Referrer Details + struct ReferrerDetails { + static let standardCellSpacing: CGFloat = 16 + static let headerCellVerticalPadding: CGFloat = 7 + static let standardCellVerticalPadding: CGFloat = 11 + } } } diff --git a/WordPress/Classes/ViewRelated/Stats/Helpers/BottomScrollAnalyticsTracker.swift b/WordPress/Classes/ViewRelated/Stats/Helpers/BottomScrollAnalyticsTracker.swift index b0da82757276..34521f405aa1 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/BottomScrollAnalyticsTracker.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/BottomScrollAnalyticsTracker.swift @@ -1,10 +1,12 @@ - +import Combine import Foundation // MARK: - BottomScrollAnalyticsTracker final class BottomScrollAnalyticsTracker: NSObject { + let scrollViewTranslationPublisher = PassthroughSubject() + private func captureAnalyticsEvent(_ event: WPAnalyticsStat) { if let blogIdentifier = SiteStatsInformation.sharedInstance.siteID { WPAppAnalytics.track(event, withBlogID: blogIdentifier) @@ -18,10 +20,13 @@ final class BottomScrollAnalyticsTracker: NSObject { } } -// MARK: - UIScrollViewDelegate +// MARK: - JPScrollViewDelegate -extension BottomScrollAnalyticsTracker: UIScrollViewDelegate { - func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { +extension BottomScrollAnalyticsTracker: JPScrollViewDelegate { + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer) { let targetOffsetY = Int(targetContentOffset.pointee.y) @@ -33,4 +38,7 @@ extension BottomScrollAnalyticsTracker: UIScrollViewDelegate { trackScrollToBottomEvent() } } + func scrollViewDidScroll(_ scrollView: UIScrollView) { + processJetpackBannerVisibility(scrollView) + } } diff --git a/WordPress/Classes/ViewRelated/Stats/Helpers/SiteStatsImmuTableRows.swift b/WordPress/Classes/ViewRelated/Stats/Helpers/SiteStatsImmuTableRows.swift new file mode 100644 index 000000000000..bfb7c8295c5a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/SiteStatsImmuTableRows.swift @@ -0,0 +1,119 @@ +import Foundation + +/// Helper class to encapsulate ImmuTableRows creation +/// Stats Revamp results in the same ImmuTableRows created in different screens +/// +class SiteStatsImmuTableRows { + + /// Helper method to create the rows for the Views and Visitors section + /// + static func viewVisitorsImmuTableRows(_ statsSummaryTimeIntervalData: StatsSummaryTimeIntervalData?, + selectedSegment: StatsSegmentedControlData.Segment, + periodDate: Date, + periodEndDate: Date? = nil, + statsLineChartViewDelegate: StatsLineChartViewDelegate?, + siteStatsInsightsDelegate: SiteStatsInsightsDelegate?, + viewsAndVisitorsDelegate: StatsInsightsViewsAndVisitorsDelegate?) -> [ImmuTableRow] { + var tableRows = [ImmuTableRow]() + + let viewsData = SiteStatsInsightsViewModel.intervalData(statsSummaryTimeIntervalData, summaryType: .views, periodEndDate: periodEndDate) + let viewsSegmentData = StatsSegmentedControlData(segmentTitle: StatSection.periodOverviewViews.tabTitle, + segmentData: viewsData.count, + segmentPrevData: viewsData.prevCount, + difference: viewsData.difference, + differenceText: viewsDifferenceText(with: viewsData.count, difference: viewsData.difference), + date: periodDate, + period: StatsPeriodUnit.week, + analyticsStat: .statsOverviewTypeTappedViews, + accessibilityHint: StatSection.periodOverviewViews.tabAccessibilityHint, + differencePercent: viewsData.percentage) + + let visitorsData = SiteStatsInsightsViewModel.intervalData(statsSummaryTimeIntervalData, summaryType: .visitors, periodEndDate: periodEndDate) + let visitorsSegmentData = StatsSegmentedControlData(segmentTitle: StatSection.periodOverviewVisitors.tabTitle, + segmentData: visitorsData.count, + segmentPrevData: visitorsData.prevCount, + difference: visitorsData.difference, + differenceText: visitorsDifferenceText(with: visitorsData.count, difference: visitorsData.difference), + date: periodDate, + period: StatsPeriodUnit.week, + analyticsStat: .statsOverviewTypeTappedViews, + accessibilityHint: StatSection.periodOverviewViews.tabAccessibilityHint, + differencePercent: visitorsData.percentage) + + var lineChartData = [LineChartDataConvertible]() + var lineChartStyling = [LineChartStyling]() + + if let chartData = statsSummaryTimeIntervalData { + let splitSummaryTimeIntervalData = SiteStatsInsightsViewModel.splitStatsSummaryTimeIntervalData(chartData, periodEndDate: periodEndDate) + let viewsChart = InsightsLineChart(data: splitSummaryTimeIntervalData, filterDimension: .views) + lineChartData.append(contentsOf: viewsChart.lineChartData) + lineChartStyling.append(contentsOf: viewsChart.lineChartStyling) + + let visitorsChart = InsightsLineChart(data: splitSummaryTimeIntervalData, filterDimension: .visitors) + lineChartData.append(contentsOf: visitorsChart.lineChartData) + lineChartStyling.append(contentsOf: visitorsChart.lineChartStyling) + + var xAxisDates = [Date]() + splitSummaryTimeIntervalData.forEach { week in + switch week { + case .thisWeek(let data): + xAxisDates = data.summaryData.map { $0.periodStartDate } + default: + break + } + } + + let row = ViewsVisitorsRow( + segmentsData: [viewsSegmentData, visitorsSegmentData], + selectedSegment: selectedSegment, + chartData: lineChartData, + chartStyling: lineChartStyling, + period: StatsPeriodUnit.day, + statsLineChartViewDelegate: statsLineChartViewDelegate, + siteStatsInsightsDelegate: siteStatsInsightsDelegate, + viewsAndVisitorsDelegate: viewsAndVisitorsDelegate, + xAxisDates: xAxisDates + ) + tableRows.append(row) + } + + return tableRows + } + + private static func viewsDifferenceText(with count: Int, difference: Int) -> String { + if difference == 0 && count != 0 { + return Constants.viewsNoDifference + } + + return difference < 0 ? Constants.viewsLower : Constants.viewsHigher + } + + private static func visitorsDifferenceText(with count: Int, difference: Int) -> String { + if difference == 0 && count != 0 { + return Constants.visitorsNoDifference + } + + return difference < 0 ? Constants.visitorsLower : Constants.visitorsHigher + } + + enum Constants { + static let viewsHigher = NSLocalizedString("stats.insights.label.views.sevenDays.higher", + value: "Your views in the last 7-days are %@ higher than the previous 7-days.\n", + comment: "Stats insights views higher than previous 7 days") + static let viewsLower = NSLocalizedString("stats.insights.label.views.sevenDays.lower", + value: "Your views in the last 7-days are %@ lower than the previous 7-days.\n", + comment: "Stats insights views lower than previous 7 days") + static let visitorsHigher = NSLocalizedString("stats.insights.label.visitors.sevenDays.higher", + value: "Your visitors in the last 7-days are %@ higher than the previous 7-days.\n", + comment: "Stats insights visitors higher than previous 7 days") + static let visitorsLower = NSLocalizedString("stats.insights.label.visitors.sevenDays.lower", + value: "Your visitors in the last 7-days are %@ lower than the previous 7-days.\n", + comment: "Stats insights visitors lower than previous 7 days") + static let viewsNoDifference = NSLocalizedString("stats.insights.label.views.sevenDays.same", + value: "Your views in the last 7-days are the same as the previous 7-days.\n", + comment: "Stats insights label shown when the user's view count is the same as the previous 7 days.") + static let visitorsNoDifference = NSLocalizedString("stats.insights.label.visitors.sevenDays.same", + value: "Your visitors in the last 7-days are the same as the previous 7-days.\n", + comment: "Stats insights label shown when the user's visitor count is the same as the previous 7 days.") + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Helpers/SiteStatsInformation.swift b/WordPress/Classes/ViewRelated/Stats/Helpers/SiteStatsInformation.swift index 3c5e5ef2205a..2faecddb4704 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/SiteStatsInformation.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/SiteStatsInformation.swift @@ -5,28 +5,75 @@ import Foundation @objc class SiteStatsInformation: NSObject { // MARK: - Properties - + typealias SiteInsights = [String: [Int]] + private let userDefaultsInsightTypesKey = "StatsInsightTypes" @objc static var sharedInstance: SiteStatsInformation = SiteStatsInformation() private override init() {} @objc var siteID: NSNumber? @objc var siteTimeZone: TimeZone? @objc var oauth2Token: String? + @objc var supportsFileDownloads: Bool = true func updateTimeZone() { let context = ContextManager.shared.mainContext - let blogService = BlogService.init(managedObjectContext: context) - guard let siteID = siteID, - let blog = blogService.blog(byBlogId: siteID) else { + guard let siteID = siteID, let blog = Blog.lookup(withID: siteID, in: context) else { return } - siteTimeZone = blogService.timeZone(for: blog) + siteTimeZone = blog.timeZone } func timeZoneMatchesDevice() -> Bool { return siteTimeZone == TimeZone.current } +} + +extension SiteStatsInformation { + + func getCurrentSiteInsights(_ userDefaults: UserPersistentRepository = UserPersistentStoreFactory.instance()) -> [InsightType] { + + guard let siteID = siteID?.stringValue else { + return InsightType.defaultInsights + } + + // Get Insights from User Defaults, and extract those for the current site. + let allSitesInsights = userDefaults.object(forKey: userDefaultsInsightTypesKey) as? [SiteInsights] ?? [] + let values = allSitesInsights.first { $0.keys.first == siteID }?.values.first + return InsightType.typesForValues(values ?? InsightType.defaultInsightsValues) + } + + func saveCurrentSiteInsights(_ insightsCards: [InsightType], _ userDefaults: UserPersistentRepository = UserPersistentStoreFactory.instance()) { + + guard let siteID = siteID?.stringValue else { + return + } + + let insightTypesValues = InsightType.valuesForTypes(insightsCards) + let currentSiteInsights = [siteID: insightTypesValues] + + // Remove existing dictionary from array, and add the updated one. + let currentInsights = (userDefaults.object(forKey: userDefaultsInsightTypesKey) as? [SiteInsights] ?? []) + var updatedInsights = currentInsights.filter { $0.keys.first != siteID } + updatedInsights.append(currentSiteInsights) + + userDefaults.set(updatedInsights, forKey: userDefaultsInsightTypesKey) + } + + // Updates any insights that were written in UserDefaults, + // in order to update the default order and remove the .customize card from the list + func upgradeInsights() { + let savedInsights = getCurrentSiteInsights().filter { $0 != .growAudience && $0 != .customize } + guard savedInsights == InsightType.oldDefaultInsights else { + // remove the .customize card regardless the list is default or custom + let noCustomizeInsights = getCurrentSiteInsights().filter { $0 != .customize } + saveCurrentSiteInsights(noCustomizeInsights) + return + } + // re-add the pinned cards, but get rid of .customize + let newInsights = getCurrentSiteInsights().filter { $0 == .growAudience } + InsightType.defaultInsights + saveCurrentSiteInsights(newInsights) + } } diff --git a/WordPress/Classes/ViewRelated/Stats/Helpers/StatSection.swift b/WordPress/Classes/ViewRelated/Stats/Helpers/StatSection.swift index e1857b5df459..00b042eceb77 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/StatSection.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/StatSection.swift @@ -12,8 +12,11 @@ case periodPublished case periodVideos case periodFileDownloads + case insightsViewsVisitors case insightsLatestPostSummary case insightsAllTime + case insightsLikesTotals + case insightsCommentsTotals case insightsFollowerTotals case insightsMostPopularTime case insightsTagsAndCategories @@ -31,20 +34,39 @@ case postStatsAverageViews case postStatsRecentWeeks - static let allInsights = [StatSection.insightsLatestPostSummary, - .insightsAllTime, - .insightsFollowerTotals, - .insightsMostPopularTime, - .insightsTagsAndCategories, - .insightsAnnualSiteStats, - .insightsCommentsAuthors, - .insightsCommentsPosts, - .insightsFollowersWordPress, - .insightsFollowersEmail, - .insightsTodaysStats, - .insightsPostingActivity, - .insightsPublicize - ] + static var allInsights: [StatSection] { + if FeatureFlag.statsNewInsights.enabled { + return [.insightsViewsVisitors, + .insightsLikesTotals, + .insightsCommentsTotals, + .insightsFollowerTotals, + .insightsMostPopularTime, + .insightsLatestPostSummary, + .insightsAllTime, + .insightsAnnualSiteStats, + .insightsTodaysStats, + .insightsPostingActivity, + .insightsTagsAndCategories, + .insightsFollowersWordPress, + .insightsFollowersEmail, + .insightsPublicize] + } else { + return [.insightsLatestPostSummary, + .insightsAllTime, + .insightsFollowerTotals, + .insightsMostPopularTime, + .insightsTagsAndCategories, + .insightsAnnualSiteStats, + .insightsCommentsAuthors, + .insightsCommentsPosts, + .insightsFollowersWordPress, + .insightsFollowersEmail, + .insightsTodaysStats, + .insightsPostingActivity, + .insightsPublicize, + .insightsAddInsight] + } + } static let allPeriods = [StatSection.periodOverviewViews, .periodOverviewVisitors, @@ -71,10 +93,16 @@ var title: String { switch self { + case .insightsViewsVisitors: + return InsightsHeaders.viewsVisitors case .insightsLatestPostSummary: return InsightsHeaders.latestPostSummary case .insightsAllTime: return InsightsHeaders.allTimeStats + case .insightsLikesTotals: + return InsightsHeaders.likesTotals + case .insightsCommentsTotals: + return InsightsHeaders.commentsTotals case .insightsFollowerTotals: return InsightsHeaders.followerTotals case .insightsMostPopularTime: @@ -84,6 +112,17 @@ case .insightsAnnualSiteStats: return InsightsHeaders.annualSiteStats case .insightsCommentsAuthors, .insightsCommentsPosts: + if FeatureFlag.statsNewInsights.enabled { + switch self { + case .insightsCommentsAuthors: + return InsightsHeaders.topCommenters + case .insightsCommentsPosts: + return InsightsHeaders.posts + default: + return InsightsHeaders.comments + } + } + return InsightsHeaders.comments case .insightsFollowersWordPress, .insightsFollowersEmail: return InsightsHeaders.followers @@ -204,6 +243,8 @@ return TabTitles.overviewLikes case .periodOverviewComments: return TabTitles.overviewComments + case .insightsPublicize: + return TabTitles.publicize default: return "" } @@ -257,10 +298,16 @@ var insightType: InsightType? { switch self { + case .insightsViewsVisitors: + return .viewsVisitors case .insightsLatestPostSummary: return .latestPostSummary case .insightsAllTime: return .allTimeStats + case.insightsLikesTotals: + return .likesTotals + case .insightsCommentsTotals: + return .commentsTotals case .insightsFollowerTotals: return .followersTotals case .insightsMostPopularTime: @@ -284,6 +331,56 @@ } } + // MARK: - analyticsEvent on ViewMore tapped + + var analyticsViewMoreEvent: WPAnalyticsStat? { + switch self { + case .periodAuthors, .insightsCommentsAuthors: + return .statsViewMoreTappedAuthors + case .periodClicks: + return .statsViewMoreTappedClicks + case .periodOverviewComments: + return .statsViewMoreTappedComments + case .periodCountries: + return .statsViewMoreTappedCountries + case .insightsFollowerTotals, .insightsFollowersEmail, .insightsFollowersWordPress: + return .statsViewMoreTappedFollowers + case .periodPostsAndPages: + return .statsViewMoreTappedPostsAndPages + case .insightsPublicize: + return .statsViewMoreTappedPublicize + case .periodReferrers: + return .statsViewMoreTappedReferrers + case .periodSearchTerms: + return .statsViewMoreTappedSearchTerms + case .insightsTagsAndCategories: + return .statsViewMoreTappedTagsAndCategories + case .periodVideos: + return .statsViewMoreTappedVideoPlays + case .periodFileDownloads: + return .statsViewMoreTappedFileDownloads + case .insightsAnnualSiteStats: + return .statsViewMoreTappedThisYear + default: + return nil + } + } + + var analyticsProperty: String { + switch self { + case .insightsViewsVisitors: + return "views_and_visitors" + case .insightsFollowerTotals: + return "total_followers" + case .insightsLikesTotals: + return "total_likes" + case .insightsCommentsTotals: + return "total_comments" + default: + return "" + } + } + // MARK: - Image Size Accessor static let defaultImageSize = CGFloat(24) @@ -305,14 +402,25 @@ // MARK: String Structs struct InsightsHeaders { + static let viewsVisitors = NSLocalizedString("Views & Visitors", comment: "Insights views and visitors header") static let latestPostSummary = NSLocalizedString("Latest Post Summary", comment: "Insights latest post summary header") static let allTimeStats = NSLocalizedString("All-Time", comment: "Insights 'All-Time' header") - static let mostPopularTime = NSLocalizedString("Most Popular Time", comment: "Insights 'Most Popular Time' header") - static let followerTotals = NSLocalizedString("Follower Totals", comment: "Insights 'Follower Totals' header") - static let publicize = NSLocalizedString("Publicize", comment: "Insights 'Publicize' header") + static var mostPopularTime: String { + if FeatureFlag.statsNewAppearance.enabled { + return NSLocalizedString("stats.insights.mostPopularCard.title", value: "🔥 Most Popular Time", comment: "Insights 'Most Popular Time' header. Fire emoji should remain part of the string.") + } else { + return NSLocalizedString("Most Popular Time", comment: "Insights 'Most Popular Time' header") + } + } + static let likesTotals = NSLocalizedString("Total Likes", comment: "Insights 'Total Likes' header") + static let commentsTotals = NSLocalizedString("Total Comments", comment: "Insights 'Total Comments' header") + static let followerTotals = NSLocalizedString("Total Followers", comment: "Insights 'Total Followers' header") + static let publicize = NSLocalizedString("Jetpack Social Connections", comment: "Insights 'Jetpack Social Connections' header") static let todaysStats = NSLocalizedString("Today", comment: "Insights 'Today' header") static let postingActivity = NSLocalizedString("Posting Activity", comment: "Insights 'Posting Activity' header") + static let posts = NSLocalizedString("Posts", comment: "Insights 'Posts' header") static let comments = NSLocalizedString("Comments", comment: "Insights 'Comments' header") + static let topCommenters = NSLocalizedString("Top Commenters", comment: "Insights 'Top Commenters' header") static let followers = NSLocalizedString("Followers", comment: "Insights 'Followers' header") static let tagsAndCategories = NSLocalizedString("Tags and Categories", comment: "Insights 'Tags and Categories' header") static let annualSiteStats = NSLocalizedString("This Year", comment: "Insights 'This Year' header") @@ -372,6 +480,7 @@ static let commentsPosts = NSLocalizedString("Posts and Pages", comment: "Label for comments by posts and pages") static let followersWordPress = NSLocalizedString("WordPress.com", comment: "Label for WordPress.com followers") static let followersEmail = NSLocalizedString("Email", comment: "Label for email followers") + static let publicize = NSLocalizedString("Social", comment: "Label for social followers") static let overviewViews = NSLocalizedString("Views", comment: "Label for Period Overview views") static let overviewVisitors = NSLocalizedString("Visitors", comment: "Label for Period Overview visitors") static let overviewLikes = NSLocalizedString("Likes", comment: "Label for Period Overview likes") diff --git a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift index 9be6eb40dcf6..f741c158a2ec 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift @@ -135,6 +135,11 @@ import Foundation let siteTimeZone = SiteStatsInformation.sharedInstance.siteTimeZone ?? .autoupdatingCurrent return Date().convert(from: siteTimeZone) } + + class func yesterdayDateForSite() -> Date { + let components = DateComponents(day: -1) + return StatsDataHelper.calendar.date(byAdding: components, to: currentDateForSite()) ?? currentDateForSite() + } } fileprivate extension Date { @@ -188,8 +193,8 @@ extension Date { // This is basically a Swift rewrite of https://github.com/wordpress-mobile/WordPressCom-Stats-iOS/blob/develop/WordPressCom-Stats-iOS/Services/StatsDateUtilities.m#L97 // It could definitely use some love! - let calendar = StatsDataHelper.calendar - let now = StatsDataHelper.currentDateForSite() + let calendar = StatsDataHelper.calendarForSite + let now = Date() let components = calendar.dateComponents([.minute, .hour, .day], from: self, to: now) let days = components.day ?? 0 diff --git a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift index e77c7160bdc7..090a4590177b 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift @@ -92,9 +92,10 @@ class StatsPeriodHelper { } } - func calculateEndDate(from currentDate: Date, offsetBy count: Int = 1, unit: StatsPeriodUnit) -> Date? { - let calendar = Calendar.autoupdatingCurrent - + func calculateEndDate(from currentDate: Date, + offsetBy count: Int = 1, + unit: StatsPeriodUnit, + calendar: Calendar = Calendar.autoupdatingCurrent) -> Date? { guard let adjustedDate = calendar.date(byAdding: unit.calendarComponent, value: count, to: currentDate) else { DDLogError("[Stats] Couldn't do basic math on Calendars in Stats. Returning original value.") return currentDate @@ -105,21 +106,28 @@ class StatsPeriodHelper { return adjustedDate.normalizedDate() case .week: - - // The hours component here is because the `dateInterval` returned by Calendar is a closed range - // — so the "end" of a specific week is also simultenously a 'start' of the next one. - // This causes problem when calling this math on dates that _already are_ an end/start of a week. - // This doesn't work for our calculations, so we force it to rollover using this hack. - // (I *think* that's what's happening here. Doing Calendar math on this method has broken my brain. - // I spend like 10h on this ~50 LoC method. Beware.) - let components = DateComponents(day: 7 * count, hour: -12) - - guard let weekAdjusted = calendar.date(byAdding: components, to: currentDate.normalizedDate()) else { - DDLogError("[Stats] Couldn't add a multiple of 7 days and -12 hours to a date in Stats. Returning original value.") - return currentDate + if FeatureFlag.statsNewInsights.enabled { + guard let endDate = currentDate.lastDayOfTheWeek(in: calendar, with: count) else { + DDLogError("[Stats] Couldn't determine the last day of the week for a given date in Stats. Returning original value.") + return currentDate + } + + return endDate.normalizedDate() + } else { + // The hours component here is because the `dateInterval` returned by Calendar is a closed range + // — so the "end" of a specific week is also simultenously a 'start' of the next one. + // This causes problem when calling this math on dates that _already are_ an end/start of a week. + // This doesn't work for our calculations, so we force it to rollover using this hack. + // (I *think* that's what's happening here. Doing Calendar math on this method has broken my brain. + // I spend like 10h on this ~50 LoC method. Beware.) + let components = DateComponents(day: 7 * count, hour: -12) + + guard let weekAdjusted = calendar.date(byAdding: components, to: currentDate.normalizedDate()) else { + DDLogError("[Stats] Couldn't add a multiple of 7 days and -12 hours to a date in Stats. Returning original value.") + return currentDate + } + return calendar.dateInterval(of: .weekOfYear, for: weekAdjusted)?.end.normalizedDate() } - return calendar.dateInterval(of: .weekOfYear, for: weekAdjusted)?.end.normalizedDate() - case .month: guard let endDate = adjustedDate.lastDayOfTheMonth(in: calendar) else { DDLogError("[Stats] Couldn't determine number of days in a given month in Stats. Returning original value.") @@ -169,17 +177,11 @@ private extension Date { } func lastDayOfTheWeek(in calendar: Calendar, with offset: Int) -> Date? { - // The hours component here is because the `dateInterval` returnd by Calendar is a closed range - // — so the "end" of a specific week is also simultenously a 'start' of the next one. - // This causes problem when calling this math on dates that _already are_ an end/start of a week. - // This doesn't work for our calculations, so we force it to rollover using this hack. - // (I *think* that's what's happening here. Doing Calendar math on this method has broken my brain. - // I spend like 10h on this ~50 LoC method. Beware.) - let components = DateComponents(day: 7 * offset, hour: -12) + let components = DateComponents(day: 7 * offset) guard let weekAdjusted = calendar.date(byAdding: components, to: normalizedDate()), - let endOfAdjustedWeek = calendar.dateInterval(of: .weekOfYear, for: weekAdjusted)?.end else { - DDLogError("[Stats] Couldn't add a multiple of 7 days and -12 hours to a date in Stats. Returning original value.") + let endOfAdjustedWeek = StatsPeriodHelper().weekIncludingDate(weekAdjusted)?.weekEnd else { + DDLogError("[Stats] Couldn't add a multiple of 7 days to a date in Stats. Returning original value.") return nil } return endOfAdjustedWeek diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/InsightType.swift b/WordPress/Classes/ViewRelated/Stats/Insights/InsightType.swift new file mode 100644 index 000000000000..2a23dd93a2a4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Insights/InsightType.swift @@ -0,0 +1,149 @@ +import Foundation + +enum InsightType: Int, SiteStatsPinnable { + case growAudience + case customize + case latestPostSummary + case allTimeStats + case followersTotals + case mostPopularTime + case tagsAndCategories + case annualSiteStats + case comments + case followers + case todaysStats + case postingActivity + case publicize + case allDotComFollowers + case allEmailFollowers + case allComments + case allTagsAndCategories + case allAnnual + // New stats revamp cards – May 2022 + case viewsVisitors + case likesTotals + case commentsTotals + + // These Insights will be displayed in this order if a site's Insights have not been customized. + static var defaultInsights: [InsightType] { + if FeatureFlag.statsNewInsights.enabled { + return [.viewsVisitors, + .likesTotals, + .commentsTotals, + .followersTotals, + .mostPopularTime, + .latestPostSummary] + } else { + return [.mostPopularTime, + .allTimeStats, + .todaysStats, + .followers, + .comments] + } + } + + // This property is here to update the default list on existing installations. + // If the list saved on UserDefaults matches the old one, it will be updated to the new one above. + static var oldDefaultInsights: [InsightType] { + if FeatureFlag.statsNewInsights.enabled { + return [.mostPopularTime, + .allTimeStats, + .todaysStats, + .followers, + .comments] + } else { + return [.latestPostSummary, + .todaysStats, + .allTimeStats, + .followersTotals] + } + } + + static let defaultInsightsValues = InsightType.defaultInsights.map { $0.rawValue } + + static func typesForValues(_ values: [Int]) -> [InsightType] { + return values.compactMap { InsightType(rawValue: $0) } + } + + static func valuesForTypes(_ types: [InsightType]) -> [Int] { + return types.compactMap { $0.rawValue } + } + + var statSection: StatSection? { + switch self { + case .viewsVisitors: + return .insightsViewsVisitors + case .latestPostSummary: + return .insightsLatestPostSummary + case .allTimeStats: + return .insightsAllTime + case .likesTotals: + return .insightsLikesTotals + case .commentsTotals: + return .insightsCommentsTotals + case .followersTotals: + return .insightsFollowerTotals + case .mostPopularTime: + return .insightsMostPopularTime + case .tagsAndCategories: + return .insightsTagsAndCategories + case .annualSiteStats: + return .insightsAnnualSiteStats + case .comments: + return .insightsCommentsPosts + case .followers: + return .insightsFollowersEmail + case .todaysStats: + return .insightsTodaysStats + case .postingActivity: + return .insightsPostingActivity + case .publicize: + return .insightsPublicize + default: + return nil + } + } + /// returns the data to fetch for each card type. Some cards may require more than one type. + /// The same type might be needed for more than one card. + var insightsDataForSection: [InsightDataType] { + switch self { + case .mostPopularTime, .annualSiteStats: + return [.annualAndMostPopular] + case .followers: + return [.followers] + case .followersTotals: + return [.followers, .publicize] + case .publicize: + return [.publicize] + case .growAudience, .allTimeStats: + return [.allTime] + case .todaysStats: + return [.today] + case .comments: + return [.comments] + case .postingActivity: + return [.postingActivity] + case .latestPostSummary: + return [.latestPost] + case .tagsAndCategories: + return [.tagsAndCategories] + default: + return [] + } + } +} + +/// Represents the api to be called by one (or more) insight card(s) +/// It's used to support cases like two or more cards that need the same api call, +/// as well as cards that need more than one api call +enum InsightDataType: Int { + case latestPost + case allTime + case annualAndMostPopular + case followers + case publicize + case tagsAndCategories + case comments + case today + case postingActivity +} diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/Insights Management/AddInsightTableViewController.swift b/WordPress/Classes/ViewRelated/Stats/Insights/Insights Management/AddInsightTableViewController.swift deleted file mode 100644 index b112da9c5092..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/Insights/Insights Management/AddInsightTableViewController.swift +++ /dev/null @@ -1,134 +0,0 @@ -import UIKit -import Gridicons - -class AddInsightTableViewController: UITableViewController { - - // MARK: - Properties - - private weak var insightsDelegate: SiteStatsInsightsDelegate? - private var insightsShown = [StatSection]() - private var selectedStat: StatSection? - - private lazy var tableHandler: ImmuTableViewHandler = { - return ImmuTableViewHandler(takeOver: self) - }() - - // MARK: - Init - - override init(style: UITableView.Style) { - super.init(style: style) - navigationItem.title = NSLocalizedString("Add New Stats Card", comment: "Add New Stats Card view title") - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - convenience init(insightsDelegate: SiteStatsInsightsDelegate, insightsShown: [StatSection]) { - self.init(style: .grouped) - self.insightsDelegate = insightsDelegate - self.insightsShown = insightsShown - } - - // MARK: - View - - override func viewDidLoad() { - super.viewDidLoad() - - ImmuTable.registerRows([AddInsightStatRow.self], tableView: tableView) - reloadViewModel() - WPStyleGuide.configureColors(view: view, tableView: tableView) - WPStyleGuide.configureAutomaticHeightRows(for: tableView) - tableView.accessibilityIdentifier = "Add Insight Table" - - navigationItem.leftBarButtonItem = UIBarButtonItem(image: Gridicon.iconOfType(.cross), style: .plain, target: self, action: #selector(doneTapped)) - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - if selectedStat == nil { - insightsDelegate?.addInsightDismissed?() - } - } - - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return 38 - } - - override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return 0 - } - - @objc private func doneTapped() { - dismiss(animated: true, completion: nil) - } -} - -private extension AddInsightTableViewController { - - // MARK: - Table Model - - func reloadViewModel() { - tableHandler.viewModel = tableViewModel() - } - - func tableViewModel() -> ImmuTable { - return ImmuTable(sections: [ sectionForCategory(.general), - sectionForCategory(.postsAndPages), - sectionForCategory(.activity) ] - ) - } - - // MARK: - Table Sections - - func sectionForCategory(_ category: InsightsCategories) -> ImmuTableSection { - return ImmuTableSection(headerText: category.title, - rows: category.insights.map { - let enabled = !insightsShown.contains($0) - - return AddInsightStatRow(title: $0.insightManagementTitle, - enabled: enabled, - action: enabled ? rowActionFor($0) : nil) } - ) - } - - func rowActionFor(_ statSection: StatSection) -> ImmuTableAction { - return { [unowned self] row in - self.selectedStat = statSection - self.insightsDelegate?.addInsightSelected?(statSection) - self.dismiss(animated: true, completion: nil) - } - } - - // MARK: - Insights Categories - - enum InsightsCategories { - case general - case postsAndPages - case activity - - var title: String { - switch self { - case .general: - return NSLocalizedString("General", comment: "Add New Stats Card category title") - case .postsAndPages: - return NSLocalizedString("Posts and Pages", comment: "Add New Stats Card category title") - case .activity: - return NSLocalizedString("Activity", comment: "Add New Stats Card category title") - } - } - - var insights: [StatSection] { - switch self { - case .general: - return [.insightsAllTime, .insightsMostPopularTime, .insightsAnnualSiteStats, .insightsTodaysStats] - case .postsAndPages: - return [.insightsLatestPostSummary, .insightsPostingActivity, .insightsTagsAndCategories] - case .activity: - return [.insightsCommentsPosts, .insightsFollowersEmail, .insightsFollowerTotals, .insightsPublicize] - } - } - } - -} diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/Insights Management/CustomizeInsightsCell.swift b/WordPress/Classes/ViewRelated/Stats/Insights/Insights Management/CustomizeInsightsCell.swift index 970cb542f56f..93c81d1431d2 100644 --- a/WordPress/Classes/ViewRelated/Stats/Insights/Insights Management/CustomizeInsightsCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Insights/Insights Management/CustomizeInsightsCell.swift @@ -75,12 +75,36 @@ private extension CustomizeInsightsCell { // MARK: - Constants struct Labels { - static let title = NSLocalizedString("Customize your insights", comment: "Customize Insights title") - static let content = NSLocalizedString("Create your own customized dashboard and choose what reports to see. Focus on the data you care most about.", comment: "Customize Insights description") - static let tryIt = NSLocalizedString("Try it now", comment: "Customize Insights button title") - static let dismiss = NSLocalizedString("Dismiss", comment: "Customize Insights button title") - static let dismissHint = NSLocalizedString("Tap to dismiss this card", comment: "Accessibility hint") - static let tryItHint = NSLocalizedString("Tap to customize insights", comment: "Accessibility hint") + static let title = NSLocalizedString( + "customizeInsightsCell.title", + value: "Customize your insights", + comment: "Customize Insights title" + ) + static let content = NSLocalizedString( + "customizeInsightsCell.content", + value: "Create your own customized dashboard and choose what reports to see. Focus on the data you care most about.", + comment: "Customize Insights description" + ) + static let tryIt = NSLocalizedString( + "customizeInsightsCell.tryItButton.title", + value: "Try it now", + comment: "Customize Insights button title" + ) + static let dismiss = NSLocalizedString( + "customizeInsightsCell.dismissButton.title", + value: "Dismiss", + comment: "Customize Insights button title" + ) + static let dismissHint = NSLocalizedString( + "customizeInsightsCell.dismissButton.accessibilityHint", + value: "Tap to dismiss this card", + comment: "Accessibility hint" + ) + static let tryItHint = NSLocalizedString( + "customizeInsightsCell.tryItButton.accessibilityHint", + value: "Tap to customize insights", + comment: "Accessibility hint" + ) } } diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/Insights Management/InsightsManagementViewController.swift b/WordPress/Classes/ViewRelated/Stats/Insights/Insights Management/InsightsManagementViewController.swift new file mode 100644 index 000000000000..7afa35ce7a2e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Insights/Insights Management/InsightsManagementViewController.swift @@ -0,0 +1,437 @@ +import UIKit +import Gridicons + +// This exists in addition to `SiteStatsInsightsDelegate` because `[StatSection]` +// can't be represented in an Obj-C protocol. +protocol StatsInsightsManagementDelegate: AnyObject { + func userUpdatedActiveInsights(_ insights: [StatSection]) +} + +class InsightsManagementViewController: UITableViewController { + + // MARK: - Properties + private weak var insightsManagementDelegate: StatsInsightsManagementDelegate? + private weak var insightsDelegate: SiteStatsInsightsDelegate? + + /// Stored so that we can check if the user has made any changes. + private var originalInsightsShown = [StatSection]() + private var insightsShown = [StatSection]() { + didSet { + updateSaveButton() + } + } + + private var insightsInactive: [StatSection] { + StatSection.allInsights + .filter({ !self.insightsShown.contains($0) && !InsightsManagementViewController.insightsNotSupportedForManagement.contains($0) }) + } + + private var hasChanges: Bool { + return insightsShown != originalInsightsShown + } + + private var selectedStat: StatSection? + + private lazy var saveButton = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped)) + + private lazy var tableHandler: ImmuTableViewHandler = { + let handler = ImmuTableViewHandler(takeOver: self) + handler.automaticallyReloadTableView = false + return handler + }() + + // MARK: - Init + + override init(style: UITableView.Style) { + super.init(style: style) + + navigationItem.title = TextContent.title + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + convenience init(insightsDelegate: SiteStatsInsightsDelegate, insightsManagementDelegate: StatsInsightsManagementDelegate? = nil, insightsShown: [StatSection]) { + self.init(style: FeatureFlag.statsNewAppearance.enabled ? .insetGrouped : .grouped) + self.insightsDelegate = insightsDelegate + self.insightsManagementDelegate = insightsManagementDelegate + let insightsShownSupportedForManagement = insightsShown.filter { !InsightsManagementViewController.insightsNotSupportedForManagement.contains($0) } + self.insightsShown = insightsShownSupportedForManagement + self.originalInsightsShown = insightsShownSupportedForManagement + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + + ImmuTable.registerRows([AddInsightStatRow.self], tableView: tableView) + reloadViewModel() + WPStyleGuide.configureColors(view: view, tableView: tableView) + WPStyleGuide.configureAutomaticHeightRows(for: tableView) + tableView.estimatedSectionHeaderHeight = 38 + tableView.accessibilityIdentifier = TextContent.title + + if FeatureFlag.statsNewAppearance.enabled { + tableView.isEditing = true + tableView.allowsSelectionDuringEditing = true + } + + navigationItem.leftBarButtonItem = UIBarButtonItem(image: .gridicon(.cross), style: .plain, target: self, action: #selector(doneTapped)) + } + + func handleDismissViaGesture(from presenter: UIViewController) { + if FeatureFlag.statsNewAppearance.enabled && hasChanges { + promptToSave(from: presenter) + } else { + trackDismiss() + } + } + + // MARK: TableView Data Source / Delegate Overrides + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return UITableView.automaticDimension + } + + override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 0 + } + + override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { + guard FeatureFlag.statsNewAppearance.enabled else { + return false + } + + return isActiveCardsSection(indexPath.section) + } + + override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { + guard FeatureFlag.statsNewAppearance.enabled else { + return + } + + if isActiveCardsSection(sourceIndexPath.section) && isActiveCardsSection(destinationIndexPath.section) { + let item = insightsShown.remove(at: sourceIndexPath.row) + insightsShown.insert(item, at: destinationIndexPath.row) + reloadViewModel() + } + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + guard FeatureFlag.statsNewAppearance.enabled else { + return false + } + + return insightsShown.count > 0 && isActiveCardsSection(indexPath.section) + } + + override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath { + if isActiveCardsSection(proposedDestinationIndexPath.section) { + return proposedDestinationIndexPath + } + + return IndexPath(row: insightsShown.count - 1, section: 0) + } + + override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { + return false + } + + private func isActiveCardsSection(_ sectionIndex: Int) -> Bool { + return sectionIndex == 0 + } + + // MARK: - Actions + + private func updateSaveButton() { + guard FeatureFlag.statsNewAppearance.enabled else { + return + } + + if hasChanges { + navigationItem.rightBarButtonItem = saveButton + } else { + navigationItem.rightBarButtonItem = nil + } + } + + @objc private func doneTapped() { + if FeatureFlag.statsNewAppearance.enabled && hasChanges { + promptToSave(from: self) + } else { + dismiss() + } + } + + @objc func saveTapped() { + saveChanges() + + dismiss(animated: true, completion: nil) + } + + private func dismiss() { + trackDismiss() + + dismiss(animated: true, completion: nil) + } + + private func trackDismiss() { + WPAnalytics.trackEvent(.statsInsightsManagementDismissed) + insightsDelegate?.addInsightDismissed?() + } + + private func saveChanges() { + insightsManagementDelegate?.userUpdatedActiveInsights(insightsShown) + + WPAnalytics.track(.statsInsightsManagementSaved, properties: ["types": [insightsShown.map({$0.title})]]) + + // Update original to stop us detecting changes on dismiss + originalInsightsShown = insightsShown + } + + private func promptToSave(from viewController: UIViewController?) { + let alertStyle: UIAlertController.Style = UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet + let alert = UIAlertController(title: nil, message: TextContent.savePromptMessage, preferredStyle: alertStyle) + alert.addAction(UIAlertAction(title: TextContent.savePromptSaveButton, style: .default, handler: { _ in + self.saveTapped() + })) + alert.addAction(UIAlertAction(title: TextContent.savePromptDiscardButton, style: .destructive, handler: { _ in + self.dismiss() + })) + alert.addCancelActionWithTitle(TextContent.savePromptCancelButton, handler: nil) + viewController?.present(alert, animated: true, completion: nil) + } + + fileprivate enum TextContent { + static let title = NSLocalizedString("stats.insights.management.title", value: "Manage Stats Cards", comment: "Title of the Stats Insights Management screen") + static let activeCardsHeader = NSLocalizedString("stats.insights.management.activeCards", value: "Active Cards", comment: "Header title indicating which Stats Insights cards the user currently has set to active.") + static let inactiveCardsHeader = NSLocalizedString("stats.insights.management.inactiveCards", value: "Inactive Cards", comment: "Header title indicating which Stats Insights cards the user currently has disabled.") + static let placeholderRowTitle = NSLocalizedString("stats.insights.management.selectCardsPrompt", value: "Select cards from the list below", comment: "Prompt displayed on the Stats Insights management screen telling the user to tap a row to add it to their list of active cards.") + static let inactivePlaceholderRowTitle = NSLocalizedString("stats.insights.management.noCardsPrompt", value: "No inactive cards remaining", comment: "Prompt displayed on the Stats Insights management screen telling the user that all Stats cards are enabled.") + + static let savePromptMessage = NSLocalizedString("stats.insights.management.savePrompt.message", value: "You've made changes to your active Insights cards.", comment: "Title of alert in Stats Insights management, prompting the user to save changes to their list of active Stats cards.") + static let savePromptSaveButton = NSLocalizedString("stats.insights.management.savePrompt.saveButton", value: "Save Changes", comment: "Title of button in Stats Insights management, prompting the user to save changes to their list of active Stats cards.") + static let savePromptDiscardButton = NSLocalizedString("stats.insights.management.savePrompt.discardButton", value: "Discard Changes", comment: "Title of button in Stats Insights management, prompting the user to discard changes to their list of active Stats cards.") + static let savePromptCancelButton = NSLocalizedString("stats.insights.management.savePrompt.cancelButton", value: "Cancel", comment: "Title of button to cancel an alert and take no action.") + } +} + +private extension InsightsManagementViewController { + + // MARK: - Table Model + + func reloadViewModel() { + tableHandler.viewModel = tableViewModel() + tableView.reloadData() + } + + func tableViewModel() -> ImmuTable { + if FeatureFlag.statsNewAppearance.enabled { + return ImmuTable(sections: [ selectedStatsSection(), + inactiveCardsSection() ].compactMap({$0}) + ) + } else { + return ImmuTable(sections: [ selectedStatsSection(), + sectionForCategory(.general), + sectionForCategory(.postsAndPages), + sectionForCategory(.activity) ].compactMap({$0}) + ) + } + } + + // MARK: - Table Sections + + func selectedStatsSection() -> ImmuTableSection? { + guard FeatureFlag.statsNewAppearance.enabled else { + return nil + } + + guard insightsShown.count > 0 else { + return ImmuTableSection(headerText: TextContent.activeCardsHeader, rows: [placeholderRow]) + } + + return ImmuTableSection(headerText: TextContent.activeCardsHeader, + rows: insightsShown.map { + return AddInsightStatRow(title: $0.insightManagementTitle, + enabled: true, + action: rowActionFor($0)) } + ) + } + + func inactiveCardsSection() -> ImmuTableSection { + let rows = insightsInactive + + guard rows.count > 0 else { + return ImmuTableSection(headerText: TextContent.inactiveCardsHeader, rows: [inactivePlaceholderRow]) + } + + return ImmuTableSection(headerText: TextContent.inactiveCardsHeader, + rows: rows.map { + return AddInsightStatRow(title: $0.insightManagementTitle, + enabled: false, + action: rowActionFor($0)) } + ) + } + + func sectionForCategory(_ category: InsightsCategories) -> ImmuTableSection? { + guard FeatureFlag.statsNewAppearance.enabled else { + return ImmuTableSection(headerText: category.title, + rows: category.insights.map { + let enabled = !insightsShown.contains($0) + return AddInsightStatRow(title: $0.insightManagementTitle, + enabled: enabled, + action: enabled ? rowActionFor($0) : nil) } + ) + } + + let rows = category.insights.filter({ !self.insightsShown.contains($0) }) + guard rows.count > 0 else { + return nil + } + + return ImmuTableSection(headerText: category.title, + rows: rows.map { + return AddInsightStatRow(title: $0.insightManagementTitle, + enabled: false, + action: rowActionFor($0)) } + ) + } + + func rowActionFor(_ statSection: StatSection) -> ImmuTableAction { + return { [unowned self] row in + if FeatureFlag.statsNewAppearance.enabled { + toggleRow(for: statSection) + } else { + self.selectedStat = statSection + self.insightsDelegate?.addInsightSelected?(statSection) + + WPAnalytics.track(.statsInsightsManagementSaved, properties: ["types": [statSection.title]]) + self.dismiss(animated: true, completion: nil) + } + } + } + + func toggleRow(for statSection: StatSection) { + if let index = insightsShown.firstIndex(of: statSection) { + insightsShown.remove(at: index) + moveRowToInactive(at: index, statSection: statSection) + } else if let inactiveIndex = insightsInactive.firstIndex(of: statSection) { + insightsShown.append(statSection) + moveRowToActive(at: inactiveIndex, statSection: statSection) + } + } + + // Animates the movement of a row from the inactive to active section, supports accessibility + func moveRowToActive(at index: Int, statSection: StatSection) { + tableHandler.viewModel = tableViewModel() + + let origin = IndexPath(row: index, section: 1) + let row = insightsShown.firstIndex(of: statSection) ?? (insightsShown.count - 1) + let destination = IndexPath(row: row, section: 0) + + tableView.performBatchUpdates { + tableView.moveRow(at: origin, to: destination) + + /// Account for placeholder cell addition to inactive section + if insightsInactive.isEmpty { + tableView.insertRows(at: [.init(row: 0, section: 1)], with: .none) + } + + /// Account for placeholder cell removal from active section + if insightsShown.count == 1 { + tableView.deleteRows(at: [.init(row: 0, section: 0)], with: .automatic) + } + } + + /// Reload the data of the row to update the accessibility information + if let cell = tableView.cellForRow(at: destination), insightsShown.count > 0 { + tableHandler.viewModel.rowAtIndexPath(destination).configureCell(cell) + } + } + + // Animates the movement of a row from the active to inactive section, supports accessibility + func moveRowToInactive(at index: Int, statSection: StatSection) { + tableHandler.viewModel = tableViewModel() + + let origin = IndexPath(row: index, section: 0) + let row = insightsInactive.firstIndex(of: statSection) ?? 0 + let destination = IndexPath(row: row, section: 1) + + tableView.performBatchUpdates { + tableView.moveRow(at: origin, to: destination) + + /// Account for placeholder cell addition to active section + if insightsShown.isEmpty { + tableView.insertRows(at: [.init(row: 0, section: 0)], with: .none) + } + + /// Account for placeholder cell removal from inactive section + if insightsInactive.count == 1 { + tableView.deleteRows(at: [.init(row: 0, section: 1)], with: .automatic) + } + } + + /// Reload the data of the row to update the accessibility information + if let cell = tableView.cellForRow(at: destination), insightsInactive.count > 0 { + tableHandler.viewModel.rowAtIndexPath(destination).configureCell(cell) + } + } + + var placeholderRow: ImmuTableRow { + return AddInsightStatRow(title: TextContent.placeholderRowTitle, + enabled: false, + action: nil) + } + + var inactivePlaceholderRow: ImmuTableRow { + return AddInsightStatRow(title: TextContent.inactivePlaceholderRowTitle, + enabled: false, + action: nil) + } + + /// Insight StatSections who share the same insightType are represented by a single card + /// Only display a single one of them for Insight Management + /// insightsCommentsPosts and insightsCommentsAuthors have the same insightType + /// insightsFollowersEmail and insightsFollowersWordpress have the same insightType + private static let insightsNotSupportedForManagement: [StatSection] = [ + .insightsFollowersWordPress, + .insightsCommentsAuthors + ] + + // MARK: - Insights Categories + + enum InsightsCategories { + case general + case postsAndPages + case activity + + var title: String { + switch self { + case .general: + return NSLocalizedString("General", comment: "Add New Stats Card category title") + case .postsAndPages: + return NSLocalizedString("Posts and Pages", comment: "Add New Stats Card category title") + case .activity: + return NSLocalizedString("Activity", comment: "Add New Stats Card category title") + } + } + + var insights: [StatSection] { + switch self { + case .general: + if FeatureFlag.statsNewInsights.enabled { + return [.insightsViewsVisitors, .insightsAllTime, .insightsMostPopularTime, .insightsAnnualSiteStats, .insightsTodaysStats] + } + return [.insightsAllTime, .insightsMostPopularTime, .insightsAnnualSiteStats, .insightsTodaysStats] + + case .postsAndPages: + return [.insightsLatestPostSummary, .insightsPostingActivity, .insightsTagsAndCategories] + case .activity: + return [.insightsCommentsPosts, .insightsFollowersEmail, .insightsFollowerTotals, .insightsPublicize] + } + } + } + +} diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/Latest Post Summary/LatestPostSummaryCell.swift b/WordPress/Classes/ViewRelated/Stats/Insights/Latest Post Summary/LatestPostSummaryCell.swift index 6ea705409a39..0c6dd4e3ba8f 100644 --- a/WordPress/Classes/ViewRelated/Stats/Insights/Latest Post Summary/LatestPostSummaryCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Insights/Latest Post Summary/LatestPostSummaryCell.swift @@ -1,7 +1,11 @@ import UIKit import Gridicons -class LatestPostSummaryCell: UITableViewCell, NibLoadable, Accessible { +protocol LatestPostSummaryConfigurable { + func configure(withInsightData lastPostInsight: StatsLastPostInsight?, chartData: StatsPostDetails?, andDelegate delegate: SiteStatsInsightsDelegate?) +} + +class LatestPostSummaryCell: StatsBaseCell, LatestPostSummaryConfigurable, NibLoadable, Accessible { // MARK: - Properties @@ -42,6 +46,7 @@ class LatestPostSummaryCell: UITableViewCell, NibLoadable, Accessible { super.awakeFromNib() applyStyles() prepareForVoiceOver() + setupStackViewsMargins() } override func prepareForReuse() { @@ -52,6 +57,7 @@ class LatestPostSummaryCell: UITableViewCell, NibLoadable, Accessible { func configure(withInsightData lastPostInsight: StatsLastPostInsight?, chartData: StatsPostDetails?, andDelegate delegate: SiteStatsInsightsDelegate?) { siteStatsInsightsDelegate = delegate + statSection = .insightsLatestPostSummary // If there is no summary data, there is no post. Show Create Post option. guard let lastPostInsight = lastPostInsight else { @@ -99,6 +105,17 @@ private extension LatestPostSummaryCell { actionLabel.textColor = Style.actionTextColor } + func setupStackViewsMargins() { + actionStackView.isLayoutMarginsRelativeArrangement = true + actionStackView.directionalLayoutMargins = StackViewsMargins.horizontalPaddingMargins + + viewsStackView.isLayoutMarginsRelativeArrangement = true + viewsStackView.directionalLayoutMargins = StackViewsMargins.horizontalPaddingMargins + + chartStackView.isLayoutMarginsRelativeArrangement = true + chartStackView.directionalLayoutMargins = StackViewsMargins.horizontalPaddingMargins + } + func configureViewForAction() { guard let actionType = actionType else { return @@ -133,7 +150,7 @@ private extension LatestPostSummaryCell { } func setActionImageFor(action: ActionType) { - let iconType: GridiconType = action == .sharePost ? .shareIOS : .create + let iconType: GridiconType = action == .sharePost ? .shareiOS : .create actionImageView.image = Style.imageForGridiconType(iconType, withTint: .blue) } @@ -149,7 +166,7 @@ private extension LatestPostSummaryCell { let postAge = lastPostInsight?.publishedDate.relativeStringInPast() ?? "" - if let title = lastPostInsight?.title, !title.isEmpty { + if let title = lastPostInsight?.title.strippingHTML(), !title.isEmpty { postTitle = title } @@ -191,6 +208,10 @@ private extension LatestPostSummaryCell { static let dataHidden = CGFloat(16) } + struct StackViewsMargins { + static let horizontalPaddingMargins = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) + } + struct CellStrings { static let summaryPostInfo = NSLocalizedString("It's been %@ since %@ was published. ", comment: "Latest post summary text including placeholder for time and the post title.") static let summaryPerformance = NSLocalizedString("Here's how the post performed so far.", comment: "Appended to latest post summary text when the post has data.") @@ -262,12 +283,5 @@ private extension LatestPostSummaryCell { resetChartContainerView() chartStackView.addArrangedSubview(chartView) - - NSLayoutConstraint.activate([ - chartView.leadingAnchor.constraint(equalTo: chartStackView.leadingAnchor), - chartView.trailingAnchor.constraint(equalTo: chartStackView.trailingAnchor), - chartView.topAnchor.constraint(equalTo: chartStackView.topAnchor), - chartView.bottomAnchor.constraint(equalTo: chartStackView.bottomAnchor) - ]) } } diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/Latest Post Summary/LatestPostSummaryCell.xib b/WordPress/Classes/ViewRelated/Stats/Insights/Latest Post Summary/LatestPostSummaryCell.xib index 2f226ec68b6e..08b980691b5a 100644 --- a/WordPress/Classes/ViewRelated/Stats/Insights/Latest Post Summary/LatestPostSummaryCell.xib +++ b/WordPress/Classes/ViewRelated/Stats/Insights/Latest Post Summary/LatestPostSummaryCell.xib @@ -1,9 +1,9 @@ - + - + @@ -18,35 +18,35 @@ - - + - + - + - + - + @@ -76,7 +76,7 @@ - + @@ -86,13 +86,13 @@ - + @@ -104,24 +104,14 @@ - - - - - - - - - - - + + - - + + + + + + + + + + + - - + - - - - - - - - - - - - - - - + @@ -69,7 +72,7 @@ - + @@ -78,18 +81,15 @@ - - - - - + + - - + + @@ -97,10 +97,9 @@ - + - diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/ViewsVisitors/ViewsVisitorsChartMarker.swift b/WordPress/Classes/ViewRelated/Stats/Insights/ViewsVisitors/ViewsVisitorsChartMarker.swift new file mode 100644 index 000000000000..0878b63afbb5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Insights/ViewsVisitors/ViewsVisitorsChartMarker.swift @@ -0,0 +1,304 @@ +import Foundation +import Charts +import UIKit + +final class ViewsVisitorsChartMarker: MarkerView { + var dotColor: UIColor + var name: String + var minimumSize = CGSize() + + private var tooltipLabel: NSMutableAttributedString? + private var labelSize: CGSize = CGSize() + private var size: CGSize = CGSize() + private var paragraphStyle: NSMutableParagraphStyle = { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + paragraphStyle.lineSpacing = 4.0 + return paragraphStyle + }() + + public init(dotColor: UIColor, name: String) { + self.dotColor = dotColor + self.name = name + + super.init(frame: CGRect.zero) + } + + public required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func drawRect(context: CGContext, point: CGPoint) -> CGRect { + let chart = super.chartView + let width = size.width + + var rect = CGRect(origin: point, size: size) + + if point.y - size.height < 0 { + if point.x - size.width / 2.0 < 0 { + drawTopLeftRect(context: context, x: rect.origin.x, y: rect.origin.y, height: rect.height, width: rect.width) + } else if let chartWidth = chart?.bounds.width, point.x + width - size.width / 2.0 > chartWidth { + rect.origin.x -= size.width + drawTopRightRect(context: context, x: rect.origin.x, y: rect.origin.y, height: rect.height, width: rect.width) + } else { + rect.origin.x -= size.width / 2.0 + drawTopCenterRect(context: context, x: rect.origin.x, y: rect.origin.y, height: rect.height, width: rect.width) + } + + rect.origin.y += Constants.topInsets.top + rect.size.height -= Constants.topInsets.top + Constants.topInsets.bottom + } else { + rect.origin.y -= size.height + + if point.x - size.width / 2.0 < 0 { + drawLeftRect(context: context, x: rect.origin.x, y: rect.origin.y, height: rect.height, width: rect.width) + } else if let chartWidth = chart?.bounds.width, point.x + width - size.width / 2.0 > chartWidth { + rect.origin.x -= size.width + drawRightRect(context: context, x: rect.origin.x, y: rect.origin.y, height: rect.height, width: rect.width) + } else { + rect.origin.x -= size.width / 2.0 + drawCenterRect(context: context, x: rect.origin.x, y: rect.origin.y, height: rect.height, width: rect.width) + } + + rect.origin.y += Constants.insets.top + rect.size.height -= Constants.insets.top + Constants.insets.bottom + } + + return rect + } + + func drawDot(context: CGContext, xPosition: CGFloat, yPosition: CGFloat) { + context.setLineWidth(Constants.dotBorderWidth) + context.setStrokeColor(Constants.dotBorderColor) + context.setFillColor(dotColor.cgColor) + context.setShadow(offset: CGSize.zero, blur: Constants.shadowBlur, color: Constants.shadowColor) + + let square = CGRect(x: xPosition, y: yPosition, width: Constants.dotRadius * 2, height: Constants.dotRadius * 2) + context.addEllipse(in: square) + context.drawPath(using: .fillStroke) + } + + func drawCenterRect(context: CGContext, x: CGFloat, y: CGFloat, height: CGFloat, width: CGFloat) { + let arrowHeight = Constants.arrowSize.height + let arrowWidth = Constants.arrowSize.width + + drawDot(context: context, xPosition: x + width / 2.0 - Constants.dotRadius, yPosition: y + height - Constants.dotRadius) + + // Draw tooltip + context.setFillColor(Constants.tooltipColor.cgColor) + context.beginPath() + context.move(to: CGPoint(x: x + Constants.cornerRadius, y: y - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width - Constants.cornerRadius, y: y - Constants.dotRadius)) + // Top right corner + context.addQuadCurve(to: CGPoint(x: x + width, y: y + Constants.cornerRadius - Constants.dotRadius), control: CGPoint(x: x + width, y: y - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width, y: y + height - arrowHeight - Constants.cornerRadius - Constants.dotRadius)) + // Bottom right corner + context.addQuadCurve(to: CGPoint(x: x + width - Constants.cornerRadius, y: y + height - arrowHeight - Constants.dotRadius), control: CGPoint(x: x + width, y: y + height - arrowHeight - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + (width + arrowWidth) / 2.0, y: y + height - arrowHeight - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width / 2.0, y: y + height - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + (width - arrowWidth) / 2.0, y: y + height - arrowHeight - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + Constants.cornerRadius, y: y + height - arrowHeight - Constants.dotRadius)) + // Bottom left corner + context.addQuadCurve(to: CGPoint(x: x, y: y + height - arrowHeight - Constants.cornerRadius - Constants.dotRadius), control: CGPoint(x: x, y: y + height - arrowHeight - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x, y: y + Constants.cornerRadius - Constants.dotRadius)) + // Top left corner + context.addQuadCurve(to: CGPoint(x: x + Constants.cornerRadius, y: y - Constants.dotRadius), control: CGPoint(x: x, y: y - Constants.dotRadius)) + context.fillPath() + } + + func drawLeftRect(context: CGContext, x: CGFloat, y: CGFloat, height: CGFloat, width: CGFloat) { + let arrowHeight = Constants.arrowSize.height + let arrowWidth = Constants.arrowSize.width + + drawDot(context: context, xPosition: x - Constants.dotRadius, yPosition: y + height - Constants.dotRadius) + + // Draw tooltip + context.setFillColor(Constants.tooltipColor.cgColor) + context.beginPath() + context.move(to: CGPoint(x: x, y: y - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width - Constants.cornerRadius, y: y - Constants.dotRadius)) + // Top right corner + context.addQuadCurve(to: CGPoint(x: x + width, y: y + Constants.cornerRadius - Constants.dotRadius), control: CGPoint(x: x + width, y: y - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width, y: y + height - arrowHeight - Constants.cornerRadius - Constants.dotRadius)) + // Bottom right corner + context.addQuadCurve(to: CGPoint(x: x + width - Constants.cornerRadius, y: y + height - arrowHeight - Constants.dotRadius), control: CGPoint(x: x + width, y: y + height - arrowHeight - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + arrowWidth / 2.0, y: y + height - arrowHeight - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x, y: y + height - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x, y: y + Constants.cornerRadius - Constants.dotRadius)) + // Top left corner + context.addQuadCurve(to: CGPoint(x: x + Constants.cornerRadius, y: y - Constants.dotRadius), control: CGPoint(x: x, y: y - Constants.dotRadius)) + context.fillPath() + } + + func drawRightRect(context: CGContext, x: CGFloat, y: CGFloat, height: CGFloat, width: CGFloat) { + let arrowHeight = Constants.arrowSize.height + let arrowWidth = Constants.arrowSize.width + + drawDot(context: context, xPosition: x + width - Constants.dotRadius, yPosition: y + height - Constants.dotRadius) + + // Draw tooltip + context.setFillColor(Constants.tooltipColor.cgColor) + context.beginPath() + context.move(to: CGPoint(x: x + Constants.cornerRadius, y: y - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width - Constants.cornerRadius, y: y - Constants.dotRadius)) + // Top right corner + context.addQuadCurve(to: CGPoint(x: x + width, y: y + Constants.cornerRadius - Constants.dotRadius), control: CGPoint(x: x + width, y: y - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width, y: y + height - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width - arrowWidth / 2.0, y: y + height - arrowHeight - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + Constants.cornerRadius, y: y + height - arrowHeight - Constants.dotRadius)) + // Bottom left corner + context.addQuadCurve(to: CGPoint(x: x, y: y + height - arrowHeight - Constants.cornerRadius - Constants.dotRadius), control: CGPoint(x: x, y: y + height - arrowHeight - Constants.dotRadius)) + context.addLine(to: CGPoint(x: x, y: y + Constants.cornerRadius - Constants.dotRadius)) + // Top left corner + context.addQuadCurve(to: CGPoint(x: x + Constants.cornerRadius, y: y - Constants.dotRadius), control: CGPoint(x: x, y: y - Constants.dotRadius)) + context.fillPath() + } + + func drawTopCenterRect(context: CGContext, x: CGFloat, y: CGFloat, height: CGFloat, width: CGFloat) { + let arrowHeight = Constants.arrowSize.height + let arrowWidth = Constants.arrowSize.width + + drawDot(context: context, xPosition: x + width / 2.0 - Constants.dotRadius, yPosition: y - Constants.dotRadius) + // Draw tooltip + context.setFillColor(Constants.tooltipColor.cgColor) + context.beginPath() + context.move(to: CGPoint(x: x + width / 2.0, y: y + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + (width + arrowWidth) / 2.0, y: y + arrowHeight + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width - Constants.cornerRadius, y: y + arrowHeight + Constants.dotRadius)) + // Top right corner + context.addQuadCurve(to: CGPoint(x: x + width, y: y + arrowHeight + Constants.cornerRadius + Constants.dotRadius), control: CGPoint(x: x + width, y: y + arrowHeight + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width, y: y + height - Constants.cornerRadius + Constants.dotRadius)) + // Bottom right corner + context.addQuadCurve(to: CGPoint(x: x + width - Constants.cornerRadius, y: y + height + Constants.dotRadius), control: CGPoint(x: x + width, y: y + height + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + Constants.cornerRadius, y: y + height + Constants.dotRadius)) + // Bottom left corner + context.addQuadCurve(to: CGPoint(x: x, y: y + height - Constants.cornerRadius + Constants.dotRadius), control: CGPoint(x: x, y: y + height + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x, y: y + arrowHeight + Constants.cornerRadius + Constants.dotRadius)) + // Top left corner + context.addQuadCurve(to: CGPoint(x: x + Constants.cornerRadius, y: y + arrowHeight + Constants.dotRadius), control: CGPoint(x: x, y: y + arrowHeight + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + (width - arrowWidth) / 2.0, y: y + arrowHeight + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width / 2.0, y: y + Constants.dotRadius)) + context.fillPath() + } + + func drawTopLeftRect(context: CGContext, x: CGFloat, y: CGFloat, height: CGFloat, width: CGFloat) { + let arrowHeight = Constants.arrowSize.height + let arrowWidth = Constants.arrowSize.width + + drawDot(context: context, xPosition: x - Constants.dotRadius, yPosition: y - Constants.dotRadius) + + // Draw tooltip + context.setFillColor(Constants.tooltipColor.cgColor) + context.beginPath() + context.move(to: CGPoint(x: x, y: y + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + arrowWidth / 2.0, y: y + arrowHeight + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width - Constants.cornerRadius, y: y + arrowHeight + Constants.dotRadius)) + // Top right corner + context.addQuadCurve(to: CGPoint(x: x + width, y: y + arrowHeight + Constants.cornerRadius + Constants.dotRadius), control: CGPoint(x: x + width, y: y + arrowHeight + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width, y: y - Constants.cornerRadius + height + Constants.dotRadius)) + // Bottom right corner + context.addQuadCurve(to: CGPoint(x: x + width - Constants.cornerRadius, y: y + height + Constants.dotRadius), control: CGPoint(x: x + width, y: y + height + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + Constants.cornerRadius, y: y + height + Constants.dotRadius)) + // Bottom left corner + context.addQuadCurve(to: CGPoint(x: x, y: y + height - Constants.cornerRadius + Constants.dotRadius), control: CGPoint(x: x, y: y + height + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x, y: y + Constants.dotRadius)) + context.fillPath() + } + + func drawTopRightRect(context: CGContext, x: CGFloat, y: CGFloat, height: CGFloat, width: CGFloat) { + let arrowHeight = Constants.arrowSize.height + + drawDot(context: context, xPosition: x + width - Constants.dotRadius, yPosition: y - Constants.dotRadius) + + // Draw tooltip + context.setFillColor(Constants.tooltipColor.cgColor) + context.beginPath() + context.move(to: CGPoint(x: x + width, y: y + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width, y: y + height + Constants.dotRadius - Constants.cornerRadius)) + // Bottom right corner + context.addQuadCurve(to: CGPoint(x: x + width - Constants.cornerRadius, y: y + height + Constants.dotRadius), control: CGPoint(x: x + width, y: y + height + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + Constants.cornerRadius, y: y + height + Constants.dotRadius)) + // Bottom left corner + context.addQuadCurve(to: CGPoint(x: x, y: y + height - Constants.cornerRadius + Constants.dotRadius), control: CGPoint(x: x, y: y + height + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x, y: y + arrowHeight + Constants.cornerRadius + Constants.dotRadius)) + // Top left corner + context.addQuadCurve(to: CGPoint(x: x + Constants.cornerRadius, y: y + arrowHeight + Constants.dotRadius), control: CGPoint(x: x, y: y + arrowHeight + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width - arrowHeight / 2.0, y: y + arrowHeight + Constants.dotRadius)) + context.addLine(to: CGPoint(x: x + width, y: y + Constants.dotRadius)) + context.fillPath() + } + + override func draw(context: CGContext, point: CGPoint) { + guard let tooltipLabel = tooltipLabel else { + return + } + + context.saveGState() + let rect = drawRect(context: context, point: point) + UIGraphicsPushContext(context) + tooltipLabel.draw(in: rect) + UIGraphicsPopContext() + context.restoreGState() + } + + override func refreshContent(entry: ChartDataEntry, highlight: Highlight) { + let yValue = Int(entry.y).description + + guard let data = chartView?.data, data.dataSetCount > 1, let lineChartDataSetPrevWeek = data.dataSet(at: 1) as? LineChartDataSet else { + return + } + + let entryPrevWeek = lineChartDataSetPrevWeek.entries[Int(entry.x)] + let difference = Int(entry.y - entryPrevWeek.y) + let differenceStr = difference < 0 ? "\(difference)" : "+\(difference)" + + var roundedPercentage = 0 + if entryPrevWeek.y > 0 { + let percentage = (Float(difference) / Float(entryPrevWeek.y)) * 100 + roundedPercentage = Int(round(percentage)) + } + + let topRowAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.preferredFont(forTextStyle: .footnote), + .paragraphStyle: paragraphStyle, + .foregroundColor: UIColor.white.withAlphaComponent(0.8)] + let bottomRowAttributes: [NSAttributedString.Key: Any] = [.font: UIFont.preferredFont(forTextStyle: .headline), + .paragraphStyle: paragraphStyle, + .foregroundColor: UIColor.white] + + let topRowStr = NSMutableAttributedString(string: "\(differenceStr) (\(roundedPercentage)%)\n", attributes: topRowAttributes) + let bottomRowStr = NSAttributedString(string: "\(yValue) \(name)", attributes: bottomRowAttributes) + + topRowStr.append(bottomRowStr) + tooltipLabel = topRowStr + + labelSize = topRowStr.size() + size.width = labelSize.width + Constants.insets.left + Constants.insets.right + size.height = labelSize.height + Constants.insets.top + Constants.insets.bottom + size.width = max(minimumSize.width, size.width) + size.height = max(minimumSize.height, size.height) + } +} + +private extension ViewsVisitorsChartMarker { + enum Constants { + static var tooltipColor: UIColor { + return UIColor(color: .muriel(name: .blue, .shade100)) + } + + static var shadowColor: CGColor { + return UIColor(red: 50 / 255, green: 50 / 255, blue: 71 / 255, alpha: 0.06).cgColor + } + + static var dotBorderColor: CGColor { + return UIColor.white.cgColor + } + + static let arrowSize = CGSize(width: 12, height: 8) + static let insets = UIEdgeInsets(top: 2.0, left: 16.0, bottom: 26.0, right: 16.0) + static let topInsets = UIEdgeInsets(top: 26.0, left: 8.0, bottom: 2.0, right: 8.0) + static let dotRadius = 8.0 + static let dotBorderWidth = 4.0 + static let cornerRadius = 10.0 + static let shadowBlur = 5.0 + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/ViewsVisitors/ViewsVisitorsLineChartCell.swift b/WordPress/Classes/ViewRelated/Stats/Insights/ViewsVisitors/ViewsVisitorsLineChartCell.swift new file mode 100644 index 000000000000..13688526bf1f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Insights/ViewsVisitors/ViewsVisitorsLineChartCell.swift @@ -0,0 +1,320 @@ +import UIKit + +struct StatsSegmentedControlData { + var segmentTitle: String + var segmentData: Int + var segmentPrevData: Int + var segmentDataStub: String? + var difference: Int + var differenceText: String + var differencePercent: Int + var date: Date? + var period: StatsPeriodUnit? + var analyticsStat: WPAnalyticsStat? + + enum Segment: Int { + case views + case visitors + } + + private(set) var accessibilityHint: String? + + init(segmentTitle: String, segmentData: Int, segmentPrevData: Int, difference: Int, differenceText: String, segmentDataStub: String? = nil, date: Date? = nil, period: StatsPeriodUnit? = nil, analyticsStat: WPAnalyticsStat? = nil, accessibilityHint: String? = nil, differencePercent: Int) { + self.segmentTitle = segmentTitle + self.segmentData = segmentData + self.segmentPrevData = segmentPrevData + self.segmentDataStub = segmentDataStub + self.difference = difference + self.differenceText = differenceText + self.differencePercent = differencePercent + self.date = date + self.period = period + self.analyticsStat = analyticsStat + self.accessibilityHint = accessibilityHint + } + + var attributedDifferenceText: NSAttributedString? { + guard difference != 0 || segmentData > 0 else { + // No comparison shown if there's no change and 0 data + return nil + } + + let defaultAttributes = [NSAttributedString.Key.font: UIFont.preferredFont(forTextStyle: .subheadline), NSAttributedString.Key.foregroundColor: UIColor.textSubtle] + + if difference == 0 && segmentData != 0 { + return NSAttributedString(string: differenceText, attributes: defaultAttributes) + } + + let differenceText = String(format: differenceText, differenceLabel) + let attributedString = NSMutableAttributedString(string: differenceText, attributes: defaultAttributes) + + let str = attributedString.string as NSString + let range = str.range(of: differenceLabel) + + attributedString.addAttributes([.foregroundColor: differenceTextColor, + .font: UIFont.preferredFont(forTextStyle: .subheadline).bold()], + range: NSRange(location: range.location, length: differenceLabel.count)) + + return attributedString + } + + var differenceLabel: String { + // We want to show something like "+10.2K (+5%)" if we have a percentage difference and "1.2K" if we don't. + // + // Negative cases automatically appear with a negative sign "-10.2K (-5%)" by using `abbreviatedString()`. + // `abbreviatedString()` also handles formatting big numbers, i.e. 10,200 will become 10.2K. + let formatter = NumberFormatter() + formatter.locale = .current + let plusSign = difference <= 0 ? "" : "\(formatter.plusSign ?? "")" + + if differencePercent != 0 { + let stringFormat = NSLocalizedString( + "insights.visitorsLineChartCell.differenceLabelWithPercentage", + value: "%1$@%2$@ (%3$@%%)", + comment: "Text for the Insights Overview stat difference label. Shows the change from the previous period, including the percentage value. E.g.: +12.3K (5%). %1$@ is the placeholder for the change sign ('-', '+', or none). %2$@ is the placeholder for the change numerical value. %3$@ is the placeholder for the change percentage value, excluding the % sign." + ) + return String.localizedStringWithFormat( + stringFormat, + plusSign, + difference.abbreviatedString(), + differencePercent.abbreviatedString() + ) + } else { + let stringFormat = NSLocalizedString( + "insights.visitorsLineChartCell.differenceLabelWithoutPercentage", + value: "%1$@%2$@", + comment: "Text for the Insights Overview stat difference label. Shows the change from the previous period. E.g.: +12.3K. %1$@ is the placeholder for the change sign ('-', '+', or none). %2$@ is the placeholder for the change numerical value." + ) + return String.localizedStringWithFormat( + stringFormat, + plusSign, + difference.abbreviatedString() + ) + } + } + + var differenceTextColor: UIColor { + return difference < 0 ? WPStyleGuide.Stats.negativeColor : WPStyleGuide.Stats.positiveColor + } + + var title: String { + return self.segmentTitle + } + + var accessibilityIdentifier: String { + return self.segmentTitle.localizedLowercase + } + + var accessibilityLabel: String? { + segmentTitle + } + + var accessibilityValue: String? { + return segmentDataStub != nil ? "" : "\(segmentData)" + } +} + + +protocol StatsInsightsViewsAndVisitorsDelegate: AnyObject { + func viewsAndVisitorsSegmendChanged(to selectedSegmentIndex: Int) +} + +class ViewsVisitorsLineChartCell: StatsBaseCell, NibLoadable { + + @IBOutlet weak var labelsStackView: UIStackView! + @IBOutlet weak var legendLatestView: UIView! + @IBOutlet weak var legendLatestLabel: UILabel! + @IBOutlet weak var latestLabel: UILabel! + @IBOutlet weak var latestData: UILabel! + @IBOutlet weak var legendPreviousView: UIView! + @IBOutlet weak var legendPreviousLabel: UILabel! + @IBOutlet weak var previousLabel: UILabel! + @IBOutlet weak var previousData: UILabel! + @IBOutlet var differenceLabel: UILabel! + @IBOutlet weak var chartContainerView: UIView! + @IBOutlet weak var segmentedControl: UISegmentedControl! + @IBOutlet weak var bottomStackView: UIStackView! + + private weak var siteStatsInsightsDelegate: SiteStatsInsightsDelegate? + private weak var viewsAndVisitorsDelegate: StatsInsightsViewsAndVisitorsDelegate? + + private typealias Style = WPStyleGuide.Stats + private var segmentsData = [StatsSegmentedControlData]() + + private var chartData: [LineChartDataConvertible] = [] + private var chartStyling: [LineChartStyling] = [] + private weak var statsLineChartViewDelegate: StatsLineChartViewDelegate? + private var chartHighlightIndex: Int? + + private var period: StatsPeriodUnit? + private var xAxisDates: [Date] = [] + + fileprivate lazy var tipView: DashboardStatsNudgeView = { + let tipView = DashboardStatsNudgeView(title: Constants.topTipsText, hint: nil, insets: .zero) + tipView.onTap = { [weak self] in + if let url = URL(string: Constants.topTipsURLString) { + self?.siteStatsInsightsDelegate?.displayWebViewWithURL?(url) + } + } + return tipView + }() + + // MARK: - Configure + + override func awakeFromNib() { + super.awakeFromNib() + applyStyles() + } + + func configure(row: ViewsVisitorsRow) { + siteStatsInsightsDelegate = row.siteStatsInsightsDelegate + siteStatsInsightDetailsDelegate = row.siteStatsInsightsDelegate + statSection = .insightsViewsVisitors + + self.segmentsData = row.segmentsData + self.chartData = row.chartData + self.chartStyling = row.chartStyling + self.statsLineChartViewDelegate = row.statsLineChartViewDelegate + self.viewsAndVisitorsDelegate = row.viewsAndVisitorsDelegate + self.period = row.period + self.xAxisDates = row.xAxisDates + + setupSegmentedControl(selectedSegment: row.selectedSegment) + configureChartView() + updateLabels() + } + + @IBAction func selectedSegmentDidChange(_ sender: Any) { + let selectedSegmentIndex = segmentedControl.selectedSegmentIndex + captureAnalyticsEvent(selectedSegmentIndex) + + configureChartView() + updateLabels() + + viewsAndVisitorsDelegate?.viewsAndVisitorsSegmendChanged(to: selectedSegmentIndex) + } +} + + +// MARK: - Private Extension + +private extension ViewsVisitorsLineChartCell { + + func applyStyles() { + Style.configureCell(self) + styleLabels() + } + + func setupSegmentedControl(selectedSegment: StatsSegmentedControlData.Segment) { + segmentedControl.selectedSegmentTintColor = UIColor.white + segmentedControl.setTitleTextAttributes([.font: UIFont.preferredFont(forTextStyle: .subheadline).bold()], for: .normal) + segmentedControl.setTitleTextAttributes([.foregroundColor: UIColor.black], for: .selected) + segmentedControl.setTitle(segmentsData[0].segmentTitle, forSegmentAt: 0) + segmentedControl.setTitle(segmentsData[1].segmentTitle, forSegmentAt: 1) + segmentedControl.selectedSegmentIndex = selectedSegment.rawValue + } + + func styleLabels() { + latestData.font = UIFont.preferredFont(forTextStyle: .title2).bold() + latestData.adjustsFontSizeToFitWidth = true + latestLabel.adjustsFontSizeToFitWidth = true + + previousData.font = UIFont.preferredFont(forTextStyle: .title2).bold() + previousData.adjustsFontSizeToFitWidth = true + previousLabel.adjustsFontSizeToFitWidth = true + + legendLatestLabel.text = NSLocalizedString("stats.insights.label.viewsVisitorsLastDays", value: "Last 7-days", comment: "Last 7-days legend label") + legendLatestLabel.adjustsFontSizeToFitWidth = true + legendPreviousLabel.text = NSLocalizedString("stats.insights.label.viewsVisitorsPreviousDays", value: "Previous 7-days", comment: "Previous 7-days legend label") + legendPreviousLabel.adjustsFontSizeToFitWidth = true + } + + func updateLabels() { + let selectedSegmentIndex = segmentedControl.selectedSegmentIndex + + guard chartStyling.count > selectedSegmentIndex, segmentsData.count > selectedSegmentIndex else { + return + } + + let chartStyle = chartStyling[selectedSegmentIndex] + legendLatestView.backgroundColor = chartStyle.primaryLineColor + legendLatestLabel.textColor = chartStyle.primaryLineColor + latestData.textColor = chartStyle.primaryLineColor + latestLabel.textColor = chartStyle.primaryLineColor + + + let segmentData = segmentsData[selectedSegmentIndex] + latestLabel.text = segmentData.segmentTitle + previousLabel.text = segmentData.segmentTitle + + latestData.text = segmentData.segmentData.abbreviatedString(forHeroNumber: true) + previousData.text = segmentData.segmentPrevData.abbreviatedString(forHeroNumber: true) + + differenceLabel.attributedText = segmentData.attributedDifferenceText + + if segmentData.segmentData == 0 && segmentData.segmentPrevData == 0 { + differenceLabel.removeFromSuperview() + bottomStackView.addArrangedSubview(tipView) + } else { + tipView.removeFromSuperview() + bottomStackView.addArrangedSubview(differenceLabel) + } + } + + // MARK: Chart support + + func configureChartView() { + let selectedSegmentIndex = segmentedControl.selectedSegmentIndex + + guard chartData.count > selectedSegmentIndex, chartStyling.count > selectedSegmentIndex else { + return + } + + let configuration = StatsLineChartConfiguration(data: chartData[selectedSegmentIndex], + styling: chartStyling[selectedSegmentIndex], + analyticsGranularity: period?.analyticsGranularityLine, + indexToHighlight: 0, + xAxisDates: xAxisDates) + + let statsInsightsFilterDimension: StatsInsightsFilterDimension = selectedSegmentIndex == 0 ? .views : .visitors + + let chartView = StatsLineChartView(configuration: configuration, delegate: statsLineChartViewDelegate, statsInsightsFilterDimension: statsInsightsFilterDimension) + + resetChartContainerView() + chartContainerView.addSubview(chartView) + chartContainerView.accessibilityElements = [chartView] + + NSLayoutConstraint.activate([ + chartView.leadingAnchor.constraint(equalTo: chartContainerView.leadingAnchor), + chartView.trailingAnchor.constraint(equalTo: chartContainerView.trailingAnchor), + chartView.topAnchor.constraint(equalTo: chartContainerView.topAnchor), + chartView.bottomAnchor.constraint(equalTo: chartContainerView.bottomAnchor) + ]) + } + + func resetChartContainerView() { + for subview in chartContainerView.subviews { + subview.removeFromSuperview() + } + } + + // MARK: - Analytics support + + func captureAnalyticsEvent(_ selectedSegmentIndex: Int) { + let statsInsightsFilterDimension: StatsInsightsFilterDimension = selectedSegmentIndex == 0 ? .views : .visitors + + let properties: [String: String] = ["value": statsInsightsFilterDimension.analyticsProperty] + + if let blogId = SiteStatsInformation.sharedInstance.siteID, + let blog = Blog.lookup(withID: blogId, in: ContextManager.sharedInstance().mainContext) { + WPAnalytics.track(.statsInsightsViewsVisitorsToggled, properties: properties, blog: blog) + } else { + WPAnalytics.track(.statsInsightsViewsVisitorsToggled, properties: properties) + } + } + + enum Constants { + static let topTipsText = NSLocalizedString("Check out our top tips to increase your views and traffic", comment: "Title for a button that opens up the 'Getting More Views and Traffic' support page when tapped.") + static let topTipsURLString = "https://wordpress.com/support/getting-more-views-and-traffic/" + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Insights/ViewsVisitors/ViewsVisitorsLineChartCell.xib b/WordPress/Classes/ViewRelated/Stats/Insights/ViewsVisitors/ViewsVisitorsLineChartCell.xib new file mode 100644 index 000000000000..ead8f95e5788 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Insights/ViewsVisitors/ViewsVisitorsLineChartCell.xib @@ -0,0 +1,194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Stats/Operation/AsyncBlockOperation.swift b/WordPress/Classes/ViewRelated/Stats/Operation/AsyncBlockOperation.swift new file mode 100644 index 000000000000..28a931d9a212 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Operation/AsyncBlockOperation.swift @@ -0,0 +1,15 @@ +class AsyncBlockOperation: AsyncOperation { + + private let block: (@escaping () -> Void) -> Void + + init(block: @escaping (@escaping () -> Void) -> Void) { + self.block = block + } + + override func main() { + self.block { [weak self] in + self?.state = .isFinished + } + } + +} diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/CountriesCell.swift b/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/CountriesCell.swift index c04f51cc2526..e0324c0c65ce 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/CountriesCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/CountriesCell.swift @@ -1,6 +1,6 @@ import UIKit -class CountriesCell: UITableViewCell, NibLoadable { +class CountriesCell: StatsBaseCell, NibLoadable { // MARK: - Properties @@ -16,6 +16,7 @@ class CountriesCell: UITableViewCell, NibLoadable { @IBOutlet private var topSeparatorLineHeightConstraint: NSLayoutConstraint! private weak var siteStatsPeriodDelegate: SiteStatsPeriodDelegate? + private weak var siteStatsInsightsDetailsDelegate: SiteStatsInsightsDelegate? private var dataRows = [StatsTotalRowData]() private typealias Style = WPStyleGuide.Stats private var forDetails = false @@ -26,11 +27,13 @@ class CountriesCell: UITableViewCell, NibLoadable { dataSubtitle: String, dataRows: [StatsTotalRowData], siteStatsPeriodDelegate: SiteStatsPeriodDelegate? = nil, + siteStatsInsightsDetailsDelegate: SiteStatsInsightsDelegate? = nil, forDetails: Bool = false) { itemSubtitleLabel.text = itemSubtitle dataSubtitleLabel.text = dataSubtitle self.dataRows = dataRows self.siteStatsPeriodDelegate = siteStatsPeriodDelegate + self.siteStatsInsightsDetailsDelegate = siteStatsInsightsDetailsDelegate self.forDetails = forDetails bottomSeparatorLine.isHidden = forDetails @@ -63,6 +66,7 @@ private extension CountriesCell { } func setSubtitleVisibility() { + subtitleStackView.layoutIfNeeded() let subtitleHeight = subtitlesStackViewTopConstraint.constant * 2 + subtitleStackView.frame.height if forDetails { @@ -82,6 +86,7 @@ extension CountriesCell: ViewMoreRowDelegate { func viewMoreSelectedForStatSection(_ statSection: StatSection) { siteStatsPeriodDelegate?.viewMoreSelectedForStatSection?(statSection) + siteStatsInsightsDetailsDelegate?.viewMoreSelectedForStatSection?(statSection) } } diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/CountriesCell.xib b/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/CountriesCell.xib index 50b29b915bb3..9ff11d704bef 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/CountriesCell.xib +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/CountriesCell.xib @@ -1,11 +1,9 @@ - - - - + + - + @@ -16,20 +14,20 @@ - + - + - - + - + - + @@ -82,6 +80,7 @@ + diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapCell.swift b/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapCell.swift index b05e805fb567..be8aec631546 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapCell.swift @@ -1,6 +1,6 @@ import UIKit -class CountriesMapCell: UITableViewCell, NibLoadable, Accessible { +class CountriesMapCell: StatsBaseCell, NibLoadable, Accessible { private let countriesMapView = CountriesMapView.loadFromNib() private typealias Style = WPStyleGuide.Stats diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapCell.xib b/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapCell.xib index a7d62bd5835d..cda945c186f3 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapCell.xib +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapCell.xib @@ -1,11 +1,9 @@ - - - - + + - + @@ -16,11 +14,11 @@ - + - + @@ -44,6 +42,7 @@ + diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapView.swift b/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapView.swift index d46e8c1f3cf4..51a47556c6ef 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapView.swift +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/Countries/Map/CountriesMapView.swift @@ -67,12 +67,11 @@ private extension CountriesMapView { } func mapColors() -> [UIColor] { - if #available(iOS 13, *) { - if traitCollection.userInterfaceStyle == .dark { - return [.accent(.shade90), .accent] - } + if traitCollection.userInterfaceStyle == .dark { + return [.primary(.shade90), .primary] + } else { + return [.primary(.shade5), .primary] } - return [.accent(.shade5), .accent] } func setGradientColors() { diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift b/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift index 472a1048aab8..dd39f1edf5cd 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.swift @@ -95,6 +95,7 @@ class OverviewCell: UITableViewCell, NibLoadable { // MARK: - Properties @IBOutlet weak var topSeparatorLine: UIView! + @IBOutlet weak var labelsStackView: UIStackView! @IBOutlet weak var selectedLabel: UILabel! @IBOutlet weak var selectedData: UILabel! @IBOutlet weak var differenceLabel: UILabel! @@ -120,7 +121,12 @@ class OverviewCell: UITableViewCell, NibLoadable { applyStyles() } - func configure(tabsData: [OverviewTabData], barChartData: [BarChartDataConvertible] = [], barChartStyling: [BarChartStyling] = [], period: StatsPeriodUnit? = nil, statsBarChartViewDelegate: StatsBarChartViewDelegate? = nil, barChartHighlightIndex: Int? = nil) { + func configure(tabsData: [OverviewTabData], + barChartData: [BarChartDataConvertible] = [], + barChartStyling: [BarChartStyling] = [], + period: StatsPeriodUnit? = nil, + statsBarChartViewDelegate: StatsBarChartViewDelegate? = nil, + barChartHighlightIndex: Int? = nil) { self.tabsData = tabsData self.chartData = barChartData self.chartStyling = barChartStyling @@ -128,10 +134,12 @@ class OverviewCell: UITableViewCell, NibLoadable { self.chartHighlightIndex = barChartHighlightIndex self.period = period + configureLabelsStackView() configureChartView() setupFilterBar() updateLabels() } + } // MARK: - Private Extension @@ -144,20 +152,6 @@ private extension OverviewCell { Style.configureLabelForOverview(selectedData) Style.configureViewAsSeparator(topSeparatorLine) Style.configureViewAsSeparator(bottomSeparatorLine) - configureFonts() - } - - /// This method squelches two Xcode warnings that I encountered: - /// 1. Attribute Unavailable: Large Title font text style before iOS 11.0 - /// 2. Automatically Adjusts Font requires using a Dynamic Type text style - /// The second emerged as part of my attempt to resolve the first. - /// - func configureFonts() { - - let prevailingFont = WPStyleGuide.fontForTextStyle(UIFont.TextStyle.largeTitle) - selectedData.font = prevailingFont - - selectedData.adjustsFontForContentSizeCategory = true // iOS 10 } func setupFilterBar() { @@ -179,6 +173,10 @@ private extension OverviewCell { filterTabBar.equalWidthFill = .fillProportionally filterTabBar.equalWidthSpacing = 12 filterTabBar.addTarget(self, action: #selector(selectedFilterDidChange(_:)), for: .valueChanged) + + if FeatureFlag.statsNewAppearance.enabled { + filterTabBar.dividerColor = .clear + } } @objc func selectedFilterDidChange(_ filterBar: FilterTabBar) { @@ -198,6 +196,21 @@ private extension OverviewCell { differenceLabel.textColor = tabData.differenceTextColor } + func configureLabelsStackView() { + // If isAccessibilityCategory, display the labels vertically. + // This makes the differenceLabel "wrap" and appear under the selectedLabel. + + if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + labelsStackView.axis = .vertical + labelsStackView.alignment = .leading + labelsStackView.distribution = .fill + } else { + labelsStackView.axis = .horizontal + labelsStackView.alignment = .firstBaseline + labelsStackView.distribution = .equalSpacing + } + } + // MARK: Chart support func configureChartView() { diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.xib b/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.xib index 0d6bff056761..9eb0b1438e3d 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.xib +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/Overview/OverviewCell.xib @@ -1,22 +1,20 @@ - - - - + + - + - - + + - + @@ -26,42 +24,52 @@ - - - + + + + + + + + + + + + + - + - + - + - + @@ -76,19 +84,15 @@ + - - - + - - - + - - + @@ -98,11 +102,12 @@ + - + diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/SiteStatsPeriodTableViewController.swift b/WordPress/Classes/ViewRelated/Stats/Period Stats/SiteStatsPeriodTableViewController.swift index 258f621dce9d..7e06a01c33b2 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/SiteStatsPeriodTableViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/SiteStatsPeriodTableViewController.swift @@ -10,10 +10,15 @@ import WordPressFlux @objc optional func showPostStats(postID: Int, postTitle: String?, postURL: URL?) } +protocol SiteStatsReferrerDelegate: AnyObject { + func showReferrerDetails(_ data: StatsTotalRowData) +} class SiteStatsPeriodTableViewController: UITableViewController, StoryboardLoadable { static var defaultStoryboardName: String = "SiteStatsDashboard" + weak var bannerView: JetpackBannerView? + // MARK: - Properties private lazy var mainContext: NSManagedObjectContext = { @@ -24,10 +29,6 @@ class SiteStatsPeriodTableViewController: UITableViewController, StoryboardLoada return MediaService(managedObjectContext: mainContext) }() - private lazy var blogService: BlogService = { - return BlogService(managedObjectContext: mainContext) - }() - var selectedDate: Date? var selectedPeriod: StatsPeriodUnit? { didSet { @@ -72,13 +73,24 @@ class SiteStatsPeriodTableViewController: UITableViewController, StoryboardLoada WPStyleGuide.Stats.configureTable(tableView) refreshControl?.addTarget(self, action: #selector(userInitiatedRefresh), for: .valueChanged) ImmuTable.registerRows(tableRowTypes(), tableView: tableView) - tableView.register(SiteStatsTableHeaderView.defaultNib, - forHeaderFooterViewReuseIdentifier: SiteStatsTableHeaderView.defaultNibName) tableView.estimatedRowHeight = 500 + tableView.estimatedSectionHeaderHeight = SiteStatsTableHeaderView.estimatedHeight + sendScrollEventsToBanner() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if !isMovingToParent { + guard let date = selectedDate, let period = selectedPeriod else { + return + } + addViewModelListeners() + viewModel?.refreshPeriodOverviewData(withDate: date, forPeriod: period, resetOverviewCache: false) + } } override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: SiteStatsTableHeaderView.defaultNibName) as? SiteStatsTableHeaderView else { + guard let cell = Bundle.main.loadNibNamed("SiteStatsTableHeaderView", owner: nil, options: nil)?.first as? SiteStatsTableHeaderView else { return nil } @@ -88,9 +100,6 @@ class SiteStatsPeriodTableViewController: UITableViewController, StoryboardLoada return cell } - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return SiteStatsTableHeaderView.headerHeight() - } } extension SiteStatsPeriodTableViewController: StatsBarChartViewDelegate { @@ -117,7 +126,8 @@ private extension SiteStatsPeriodTableViewController { viewModel = SiteStatsPeriodViewModel(store: store, selectedDate: selectedDate, selectedPeriod: selectedPeriod, - periodDelegate: self) + periodDelegate: self, + referrerDelegate: self) viewModel?.statsBarChartViewDelegate = self addViewModelListeners() viewModel?.startFetchingOverview() @@ -196,7 +206,6 @@ private extension SiteStatsPeriodTableViewController { func viewIsVisible() -> Bool { return isViewLoaded && view.window != nil } - } // MARK: - NoResultsViewHost @@ -210,12 +219,12 @@ extension SiteStatsPeriodTableViewController: NoResultsViewHost { configureAndDisplayNoResults(on: tableView, title: NoResultConstants.errorTitle, subtitle: NoResultConstants.errorSubtitle, - buttonTitle: NoResultConstants.refreshButtonTitle) { [weak self] noResults in + buttonTitle: NoResultConstants.refreshButtonTitle, customizationBlock: { [weak self] noResults in noResults.delegate = self if !noResults.isReachable { noResults.resetButtonText() } - } + }) } private enum NoResultConstants { @@ -239,17 +248,16 @@ extension SiteStatsPeriodTableViewController: NoResultsViewControllerDelegate { extension SiteStatsPeriodTableViewController: SiteStatsPeriodDelegate { func displayWebViewWithURL(_ url: URL) { - let webViewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url) + let webViewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url, source: "site_stats_period") let navController = UINavigationController.init(rootViewController: webViewController) present(navController, animated: true) } func displayMediaWithID(_ mediaID: NSNumber) { - guard let siteID = SiteStatsInformation.sharedInstance.siteID, - let blog = blogService.blog(byBlogId: siteID) else { - DDLogInfo("Unable to get blog when trying to show media from Stats.") - return + guard let siteID = SiteStatsInformation.sharedInstance.siteID, let blog = Blog.lookup(withID: siteID, in: mainContext) else { + DDLogInfo("Unable to get blog when trying to show media from Stats.") + return } mediaService.getMediaWithID(mediaID, in: blog, success: { (media) in @@ -284,11 +292,19 @@ extension SiteStatsPeriodTableViewController: SiteStatsPeriodDelegate { func showPostStats(postID: Int, postTitle: String?, postURL: URL?) { removeViewModelListeners() - let postStatsTableViewController = PostStatsTableViewController.loadFromStoryboard() - postStatsTableViewController.configure(postID: postID, postTitle: postTitle, postURL: postURL) + let postStatsTableViewController = PostStatsTableViewController.withJPBannerForBlog(postID: postID, + postTitle: postTitle, + postURL: postURL) navigationController?.pushViewController(postStatsTableViewController, animated: true) } +} +// MARK: - SiteStatsReferrerDelegate + +extension SiteStatsPeriodTableViewController: SiteStatsReferrerDelegate { + func showReferrerDetails(_ data: StatsTotalRowData) { + show(ReferrerDetailsTableViewController(data: data), sender: nil) + } } // MARK: - SiteStatsTableHeaderDelegate Methods @@ -305,3 +321,14 @@ extension SiteStatsPeriodTableViewController: SiteStatsTableHeaderDateButtonDele } } } + +// MARK: Jetpack powered banner + +private extension SiteStatsPeriodTableViewController { + + func sendScrollEventsToBanner() { + if let bannerView = bannerView { + analyticsTracker.addTranslationObserver(bannerView) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Period Stats/SiteStatsPeriodViewModel.swift b/WordPress/Classes/ViewRelated/Stats/Period Stats/SiteStatsPeriodViewModel.swift index 478fc3552559..b1db0511d9fc 100644 --- a/WordPress/Classes/ViewRelated/Stats/Period Stats/SiteStatsPeriodViewModel.swift +++ b/WordPress/Classes/ViewRelated/Stats/Period Stats/SiteStatsPeriodViewModel.swift @@ -11,7 +11,9 @@ class SiteStatsPeriodViewModel: Observable { let changeDispatcher = Dispatcher() private weak var periodDelegate: SiteStatsPeriodDelegate? + private weak var referrerDelegate: SiteStatsReferrerDelegate? private let store: StatsPeriodStore + private var selectedDate: Date private var lastRequestedDate: Date private var lastRequestedPeriod: StatsPeriodUnit { didSet { @@ -29,22 +31,32 @@ class SiteStatsPeriodViewModel: Observable { private var mostRecentChartData: StatsSummaryTimeIntervalData? { didSet { if oldValue == nil { - currentEntryIndex = (mostRecentChartData?.summaryData.endIndex ?? 0) - 1 + guard let mostRecentChartData = mostRecentChartData else { + return + } + + currentEntryIndex = mostRecentChartData.summaryData.lastIndex(where: { $0.periodStartDate <= selectedDate }) + ?? max(mostRecentChartData.summaryData.count - 1, 0) } } } private var currentEntryIndex: Int = 0 + private let calendar: Calendar = .current + // MARK: - Constructor init(store: StatsPeriodStore = StoreContainer.shared.statsPeriod, selectedDate: Date, selectedPeriod: StatsPeriodUnit, - periodDelegate: SiteStatsPeriodDelegate) { + periodDelegate: SiteStatsPeriodDelegate, + referrerDelegate: SiteStatsReferrerDelegate) { self.periodDelegate = periodDelegate + self.referrerDelegate = referrerDelegate self.store = store - self.lastRequestedDate = selectedDate + self.selectedDate = selectedDate + self.lastRequestedDate = Date() self.lastRequestedPeriod = selectedPeriod changeReceipt = store.onChange { [weak self] in @@ -80,11 +92,11 @@ class SiteStatsPeriodViewModel: Observable { let errorBlock: (StatSection) -> [ImmuTableRow] = { section in return [CellHeaderRow(statSection: section), - StatsErrorRow(rowStatus: .error, statType: .period)] + StatsErrorRow(rowStatus: .error, statType: .period, statSection: nil)] } let summaryErrorBlock: AsyncBlock<[ImmuTableRow]> = { return [PeriodEmptyCellHeaderRow(), - StatsErrorRow(rowStatus: .error, statType: .period)] + StatsErrorRow(rowStatus: .error, statType: .period, statSection: nil)] } let loadingBlock: (StatSection) -> [ImmuTableRow] = { section in return [CellHeaderRow(statSection: section), @@ -183,16 +195,18 @@ class SiteStatsPeriodViewModel: Observable { }, error: { return errorBlock(.periodVideos) })) - tableRows.append(contentsOf: blocks(for: .topFileDownloads, - type: .period, - status: store.topFileDownloadsStatus, - block: { [weak self] in - return self?.fileDownloadsTableRows() ?? errorBlock(.periodFileDownloads) - }, loading: { - return loadingBlock(.periodFileDownloads) - }, error: { - return errorBlock(.periodFileDownloads) - })) + if SiteStatsInformation.sharedInstance.supportsFileDownloads { + tableRows.append(contentsOf: blocks(for: .topFileDownloads, + type: .period, + status: store.topFileDownloadsStatus, + block: { [weak self] in + return self?.fileDownloadsTableRows() ?? errorBlock(.periodFileDownloads) + }, loading: { + return loadingBlock(.periodFileDownloads) + }, error: { + return errorBlock(.periodFileDownloads) + })) + } tableRows.append(TableFooterRow()) @@ -209,7 +223,7 @@ class SiteStatsPeriodViewModel: Observable { mostRecentChartData = nil } - lastRequestedDate = date + selectedDate = date lastRequestedPeriod = period ActionDispatcher.dispatch(PeriodAction.refreshPeriodOverviewData(date: date, period: period, forceRefresh: true)) } @@ -239,7 +253,17 @@ class SiteStatsPeriodViewModel: Observable { } else { currentEntryIndex -= 1 } - return chartDate(for: currentEntryIndex) + + guard let nextDate = chartDate(for: currentEntryIndex) else { + // The date doesn't exist in the chart data... we need to manually calculate it and request + // a refresh. + let increment = forward ? 1 : -1 + let nextDate = calendar.date(byAdding: lastRequestedPeriod.calendarComponent, value: increment, to: selectedDate)! + refreshPeriodOverviewData(withDate: nextDate, forPeriod: lastRequestedPeriod) + return nextDate + } + + return nextDate } } @@ -262,11 +286,22 @@ private extension SiteStatsPeriodViewModel { let periodSummary = periodSummary, mostRecentChartData.periodEndDate == periodSummary.periodEndDate { self.mostRecentChartData = periodSummary - } else if let periodSummary = periodSummary, let chartData = mostRecentChartData, periodSummary.periodEndDate > chartData.periodEndDate { - mostRecentChartData = chartData + } else if let periodSummary = periodSummary, // when there is API data that has more recent API period date + let chartData = mostRecentChartData, // than our local chartData + periodSummary.periodEndDate > chartData.periodEndDate { + + // we validate if our periodDates match and if so we set the currentEntryIndex to the last index of the summaryData + // fixes issue #19688 + if let lastSummaryDataEntry = summaryData.last, + periodSummary.periodEndDate == lastSummaryDataEntry.periodStartDate { + mostRecentChartData = periodSummary + currentEntryIndex = summaryData.count - 1 + } else { + mostRecentChartData = chartData + } } - let periodDate = summaryData.last?.periodStartDate + let periodDate = summaryData.indices.contains(currentEntryIndex) ? summaryData[currentEntryIndex].periodStartDate : nil let period = periodSummary?.period let viewsData = intervalData(summaryType: .views) @@ -292,7 +327,7 @@ private extension SiteStatsPeriodViewModel { let likesData = intervalData(summaryType: .likes) // If Summary Likes is still loading, show dashes (instead of 0) // to indicate it's still loading. - let likesLoadingStub = likesData.count > 0 ? nil : (store.isFetchingSummaryLikes ? "----" : nil) + let likesLoadingStub = likesData.count > 0 ? nil : (store.isFetchingSummary ? "----" : nil) let likesTabData = OverviewTabData(tabTitle: StatSection.periodOverviewLikes.tabTitle, tabData: likesData.count, tabDataStub: likesLoadingStub, @@ -323,12 +358,17 @@ private extension SiteStatsPeriodViewModel { barChartStyling.append(contentsOf: chart.barChartStyling) indexToHighlight = chartData.summaryData.lastIndex(where: { - lastRequestedDate.normalizedDate() >= $0.periodStartDate.normalizedDate() + $0.periodStartDate.normalizedDate() <= selectedDate.normalizedDate() }) } - let row = OverviewRow(tabsData: [viewsTabData, visitorsTabData, likesTabData, commentsTabData], - chartData: barChartData, chartStyling: barChartStyling, period: lastRequestedPeriod, statsBarChartViewDelegate: statsBarChartViewDelegate, chartHighlightIndex: indexToHighlight) + let row = OverviewRow( + tabsData: [viewsTabData, visitorsTabData, likesTabData, commentsTabData], + chartData: barChartData, + chartStyling: barChartStyling, + period: lastRequestedPeriod, + statsBarChartViewDelegate: statsBarChartViewDelegate, + chartHighlightIndex: indexToHighlight) tableRows.append(row) return tableRows @@ -416,7 +456,8 @@ private extension SiteStatsPeriodViewModel { tableRows.append(TopTotalsPeriodStatsRow(itemSubtitle: StatSection.periodReferrers.itemSubtitle, dataSubtitle: StatSection.periodReferrers.dataSubtitle, dataRows: referrersDataRows(), - siteStatsPeriodDelegate: periodDelegate)) + siteStatsPeriodDelegate: periodDelegate, + siteStatsReferrerDelegate: referrerDelegate)) return tableRows } @@ -444,7 +485,8 @@ private extension SiteStatsPeriodViewModel { showDisclosure: true, disclosureURL: referrer.url, childRows: referrer.children.map { rowDataFromReferrer(referrer: $0) }, - statSection: .periodReferrers) + statSection: .periodReferrers, + isReferrerSpam: referrer.isSpam) } return referrers.map { rowDataFromReferrer(referrer: $0) } @@ -577,7 +619,7 @@ private extension SiteStatsPeriodViewModel { } func publishedDataRows() -> [StatsTotalRowData] { - return store.getTopPublished()?.publishedPosts.prefix(10).map { StatsTotalRowData.init(name: $0.title, + return store.getTopPublished()?.publishedPosts.prefix(10).map { StatsTotalRowData.init(name: $0.title.stringByDecodingXMLCharacters(), data: "", showDisclosure: true, disclosureURL: $0.postURL, diff --git a/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsCell.swift b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsCell.swift new file mode 100644 index 000000000000..befe8931b391 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsCell.swift @@ -0,0 +1,88 @@ +import UIKit + +final class ReferrerDetailsCell: UITableViewCell { + private let referrerLabel = UILabel() + private let viewsLabel = UILabel() + private let separatorView = UIView() + private typealias Style = WPStyleGuide.Stats + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Public Methods +extension ReferrerDetailsCell { + func configure(data: ReferrerDetailsRow.DetailsData, isLast: Bool) { + referrerLabel.text = data.name + viewsLabel.text = data.views + separatorView.isHidden = !isLast + prepareForVoiceOver() + } +} + +// MARK: - Accessible +extension ReferrerDetailsCell: Accessible { + func prepareForVoiceOver() { + isAccessibilityElement = true + if let referrer = referrerLabel.text, + let views = viewsLabel.text { + accessibilityLabel = "\(referrer), \(views)" + } + accessibilityTraits = [.staticText, .button] + accessibilityHint = NSLocalizedString("Tap to display referrer web page.", comment: "Accessibility hint for referrer details row.") + } +} + +// MARK: - Private methods +private extension ReferrerDetailsCell { + func setupViews() { + selectionStyle = .none + backgroundColor = Style.cellBackgroundColor + setupReferrerLabel() + setupViewsLabel() + setupSeparatorView() + } + + func setupReferrerLabel() { + Style.configureLabelAsLink(referrerLabel) + referrerLabel.font = WPStyleGuide.tableviewTextFont() + referrerLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(referrerLabel) + NSLayoutConstraint.activate([ + referrerLabel.leadingAnchor.constraint(equalTo: safeLeadingAnchor, constant: Style.ReferrerDetails.standardCellSpacing), + referrerLabel.topAnchor.constraint(equalTo: topAnchor, constant: Style.ReferrerDetails.standardCellVerticalPadding), + referrerLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Style.ReferrerDetails.standardCellVerticalPadding) + ]) + } + + func setupViewsLabel() { + Style.configureLabelAsChildRowTitle(viewsLabel) + viewsLabel.font = WPStyleGuide.tableviewTextFont() + viewsLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + viewsLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(viewsLabel) + NSLayoutConstraint.activate([ + viewsLabel.leadingAnchor.constraint(greaterThanOrEqualTo: referrerLabel.trailingAnchor, constant: Style.ReferrerDetails.standardCellSpacing), + viewsLabel.trailingAnchor.constraint(equalTo: safeTrailingAnchor, constant: -Style.ReferrerDetails.standardCellSpacing), + viewsLabel.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + func setupSeparatorView() { + separatorView.backgroundColor = Style.separatorColor + separatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(separatorView) + NSLayoutConstraint.activate([ + separatorView.leadingAnchor.constraint(equalTo: leadingAnchor), + separatorView.trailingAnchor.constraint(equalTo: trailingAnchor), + separatorView.bottomAnchor.constraint(equalTo: bottomAnchor), + separatorView.heightAnchor.constraint(equalToConstant: Style.separatorHeight) + ]) + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsHeaderCell.swift b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsHeaderCell.swift new file mode 100644 index 000000000000..d643aa932856 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsHeaderCell.swift @@ -0,0 +1,56 @@ +import UIKit + +final class ReferrerDetailsHeaderCell: UITableViewCell { + private let referrerLabel = UILabel() + private let viewsLabel = UILabel() + private typealias Style = WPStyleGuide.Stats + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Public Methods +extension ReferrerDetailsHeaderCell { + func configure(with section: StatSection) { + referrerLabel.text = section.itemSubtitle + viewsLabel.text = section.dataSubtitle + } +} + +// MARK: - Private methods +private extension ReferrerDetailsHeaderCell { + func setupViews() { + isUserInteractionEnabled = false + separatorInset = .zero + backgroundColor = Style.cellBackgroundColor + setupReferrerLabel() + setupViewsLabel() + } + + func setupReferrerLabel() { + Style.configureLabelAsSubtitle(referrerLabel) + referrerLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(referrerLabel) + NSLayoutConstraint.activate([ + referrerLabel.leadingAnchor.constraint(equalTo: safeLeadingAnchor, constant: Style.ReferrerDetails.standardCellSpacing), + referrerLabel.topAnchor.constraint(equalTo: topAnchor, constant: Style.ReferrerDetails.headerCellVerticalPadding), + referrerLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Style.ReferrerDetails.headerCellVerticalPadding) + ]) + } + + func setupViewsLabel() { + Style.configureLabelAsSubtitle(viewsLabel) + viewsLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(viewsLabel) + NSLayoutConstraint.activate([ + viewsLabel.trailingAnchor.constraint(equalTo: safeTrailingAnchor, constant: -Style.ReferrerDetails.standardCellSpacing), + viewsLabel.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsHeaderRow.swift b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsHeaderRow.swift new file mode 100644 index 000000000000..2e56bc1b1fa3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsHeaderRow.swift @@ -0,0 +1,22 @@ +import Foundation + +struct ReferrerDetailsHeaderRow: ImmuTableRow { + private typealias CellType = ReferrerDetailsHeaderCell + + static var cell = ImmuTableCell.class(CellType.self) + var action: ImmuTableAction? + + func configureCell(_ cell: UITableViewCell) { + guard let cell = cell as? CellType else { + return + } + cell.configure(with: section) + } +} + +// MARK: - Private Computed Properties +private extension ReferrerDetailsHeaderRow { + var section: StatSection { + StatSection.periodReferrers + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsRow.swift b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsRow.swift new file mode 100644 index 000000000000..2b400ff7a3d1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsRow.swift @@ -0,0 +1,26 @@ +import Foundation + +struct ReferrerDetailsRow: ImmuTableRow { + private typealias CellType = ReferrerDetailsCell + + static var cell = ImmuTableCell.class(CellType.self) + var action: ImmuTableAction? + let isLast: Bool + let data: DetailsData + + func configureCell(_ cell: UITableViewCell) { + guard let cell = cell as? CellType else { + return + } + cell.configure(data: data, isLast: isLast) + } +} + +// MARK: - Types +extension ReferrerDetailsRow { + struct DetailsData { + let name: String + let url: URL + let views: String + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsSpamActionCell.swift b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsSpamActionCell.swift new file mode 100644 index 000000000000..49ec0dab451f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsSpamActionCell.swift @@ -0,0 +1,98 @@ +import UIKit + +final class ReferrerDetailsSpamActionCell: UITableViewCell { + private let actionLabel = UILabel() + private let separatorView = UIView() + private let loader = UIActivityIndicatorView(style: .medium) + private typealias Style = WPStyleGuide.Stats + private var markAsSpam: Bool = false + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Public Methods +extension ReferrerDetailsSpamActionCell { + func configure(markAsSpam: Bool, isLoading: Bool) { + isLoading ? loader.startAnimating() : loader.stopAnimating() + actionLabel.isHidden = isLoading + + if markAsSpam { + actionLabel.text = NSLocalizedString("Mark as spam", comment: "Action title for marking referrer as spam") + actionLabel.textColor = Style.negativeColor + } else { + actionLabel.text = NSLocalizedString("Mark as not spam", comment: "Action title for unmarking referrer as spam") + actionLabel.textColor = Style.positiveColor + } + + self.markAsSpam = markAsSpam + prepareForVoiceOver() + } +} + +// MARK: - Accessible +extension ReferrerDetailsSpamActionCell: Accessible { + func prepareForVoiceOver() { + isAccessibilityElement = true + if let text = actionLabel.text { + accessibilityLabel = text + } + accessibilityTraits = [.button] + + let markHint = NSLocalizedString("Tap to mark referrer as spam.", comment: "Accessibility hint for referrer action row.") + let unmarkHint = NSLocalizedString("Tap to mark referrer as not spam.", comment: "Accessibility hint for referrer action row.") + accessibilityHint = markAsSpam ? markHint : unmarkHint + } +} + +// MARK: - Private methods +private extension ReferrerDetailsSpamActionCell { + func setupViews() { + separatorInset = .zero + selectionStyle = .none + backgroundColor = Style.cellBackgroundColor + setupActionLabel() + setupLoader() + setupSeparatorView() + } + + func setupActionLabel() { + actionLabel.textAlignment = .center + actionLabel.font = WPStyleGuide.tableviewTextFont() + actionLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(actionLabel) + NSLayoutConstraint.activate([ + actionLabel.leadingAnchor.constraint(equalTo: safeLeadingAnchor, constant: Style.ReferrerDetails.standardCellSpacing), + actionLabel.trailingAnchor.constraint(equalTo: safeTrailingAnchor, constant: -Style.ReferrerDetails.standardCellSpacing), + actionLabel.topAnchor.constraint(equalTo: topAnchor, constant: Style.ReferrerDetails.standardCellVerticalPadding), + actionLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Style.ReferrerDetails.standardCellVerticalPadding) + ]) + } + + func setupLoader() { + loader.translatesAutoresizingMaskIntoConstraints = false + addSubview(loader) + NSLayoutConstraint.activate([ + loader.centerXAnchor.constraint(equalTo: centerXAnchor), + loader.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + func setupSeparatorView() { + separatorView.backgroundColor = Style.separatorColor + separatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(separatorView) + NSLayoutConstraint.activate([ + separatorView.leadingAnchor.constraint(equalTo: leadingAnchor), + separatorView.trailingAnchor.constraint(equalTo: trailingAnchor), + separatorView.topAnchor.constraint(equalTo: topAnchor), + separatorView.heightAnchor.constraint(equalToConstant: Style.separatorHeight) + ]) + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsSpamActionRow.swift b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsSpamActionRow.swift new file mode 100644 index 000000000000..1eacf837cd05 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsSpamActionRow.swift @@ -0,0 +1,17 @@ +import Foundation + +struct ReferrerDetailsSpamActionRow: ImmuTableRow { + private typealias CellType = ReferrerDetailsSpamActionCell + + static var cell = ImmuTableCell.class(CellType.self) + var action: ImmuTableAction? + var isSpam: Bool + var isLoading: Bool + + func configureCell(_ cell: UITableViewCell) { + guard let cell = cell as? CellType else { + return + } + cell.configure(markAsSpam: !isSpam, isLoading: isLoading) + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsTableViewController.swift b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsTableViewController.swift new file mode 100644 index 000000000000..fe27af0d2463 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsTableViewController.swift @@ -0,0 +1,128 @@ +import UIKit + +final class ReferrerDetailsTableViewController: UITableViewController { + private var data: StatsTotalRowData + private lazy var tableHandler = ImmuTableViewHandler(takeOver: self) + private lazy var viewModel = ReferrerDetailsViewModel(data: data, delegate: self, referrersDelegate: self) + private let periodStore = StoreContainer.shared.statsPeriod + + init(data: StatsTotalRowData) { + self.data = data + super.init(style: .plain) + periodStore.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + buildViewModel() + } +} + +// MARK: - UITableViewDelegate +extension ReferrerDetailsTableViewController { + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + .zero + } + + override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + switch section { + case tableView.numberOfSections - 1: + return .zero + default: + return UITableView.automaticDimension + } + } + + override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + switch section { + case tableView.numberOfSections - 1: + return nil + default: + return UIView() + } + } +} + +// MARK: - ReferrerDetailsViewModelDelegate +extension ReferrerDetailsTableViewController: ReferrerDetailsViewModelDelegate { + func displayWebViewWithURL(_ url: URL) { + let webViewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url, source: "stats_referrer_details") + let navController = UINavigationController(rootViewController: webViewController) + present(navController, animated: true) + } + + func toggleSpamState(for referrerDomain: String, currentValue: Bool) { + setLoadingState(true) + periodStore.toggleSpamState(for: referrerDomain, currentValue: currentValue) + } +} + +// MARK: - StatsPeriodStoreDelegate +extension ReferrerDetailsTableViewController: StatsPeriodStoreDelegate { + func didChangeSpamState(for referrerDomain: String, isSpam: Bool) { + setLoadingState(false) + data.isReferrerSpam = isSpam + updateViewModel() + + let markedText = NSLocalizedString("marked as spam", comment: "Indicating that referrer was marked as spam") + let unmarkedText = NSLocalizedString("unmarked as spam", comment: "Indicating that referrer was unmarked as spam") + let text = isSpam ? markedText : unmarkedText + displayNotice(title: "\(referrerDomain) \(text)") + } + + func changingSpamStateForReferrerDomainFailed(oldValue: Bool) { + setLoadingState(false) + + let markText = NSLocalizedString("Couldn't mark as spam", comment: "Indicating that referrer couldn't be marked as spam") + let unmarkText = NSLocalizedString("Couldn't unmark as spam", comment: "Indicating that referrer couldn't be unmarked as spam") + let text = oldValue ? unmarkText : markText + displayNotice(title: text) + } +} + +// MARK: - SiteStatsReferrerDelegate +extension ReferrerDetailsTableViewController: SiteStatsReferrerDelegate { + func showReferrerDetails(_ data: StatsTotalRowData) { + let referrerViewController = ReferrerDetailsTableViewController(data: data) + navigationController?.pushViewController(referrerViewController, animated: true) + } +} + +// MARK: - Private Methods +private extension ReferrerDetailsTableViewController { + func setupViews() { + tableView.backgroundColor = WPStyleGuide.Stats.tableBackgroundColor + tableView.tableFooterView = UIView() + title = viewModel.title + ImmuTable.registerRows(rows, tableView: tableView) + } + + func buildViewModel() { + tableHandler.viewModel = viewModel.tableViewModel + } + + func updateViewModel() { + viewModel.update(with: data) + buildViewModel() + } + + func setLoadingState(_ value: Bool) { + viewModel.setLoadingState(value) + buildViewModel() + } +} + +// MARK: - Private Computed Properties +private extension ReferrerDetailsTableViewController { + var rows: [ImmuTableRow.Type] { + [ReferrerDetailsHeaderRow.self, + ReferrerDetailsRow.self, + ReferrerDetailsSpamActionRow.self, + DetailExpandableRow.self] + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsViewModel.swift b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsViewModel.swift new file mode 100644 index 000000000000..76e4101ce77e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Referrer Details/ReferrerDetailsViewModel.swift @@ -0,0 +1,113 @@ +import Foundation + +protocol ReferrerDetailsViewModelDelegate: AnyObject { + func displayWebViewWithURL(_ url: URL) + func toggleSpamState(for referrerDomain: String, currentValue: Bool) +} + +final class ReferrerDetailsViewModel { + private(set) var data: StatsTotalRowData + private weak var delegate: ReferrerDetailsViewModelDelegate? + private weak var referrersDelegate: SiteStatsReferrerDelegate? + private(set) var isLoading = false + + init(data: StatsTotalRowData, delegate: ReferrerDetailsViewModelDelegate, referrersDelegate: SiteStatsReferrerDelegate?) { + self.data = data + self.delegate = delegate + self.referrersDelegate = referrersDelegate + } +} + +// MARK: - Public Methods +extension ReferrerDetailsViewModel { + func update(with data: StatsTotalRowData) { + self.data = data + } + + func setLoadingState(_ value: Bool) { + isLoading = value + } +} + +// MARK: - Public Computed Properties +extension ReferrerDetailsViewModel { + var title: String { + data.name + } + + var tableViewModel: ImmuTable { + var firstSectionRows = [ImmuTableRow]() + firstSectionRows.append(ReferrerDetailsHeaderRow()) + firstSectionRows.append(contentsOf: buildDetailsRows(data: data)) + + var secondSectionRows = [ImmuTableRow]() + secondSectionRows.append(ReferrerDetailsSpamActionRow(action: action, isSpam: data.isReferrerSpam, isLoading: isLoading)) + + switch data.canMarkReferrerAsSpam { + case true: + return ImmuTable(sections: [ + ImmuTableSection(rows: firstSectionRows), + ImmuTableSection(rows: secondSectionRows) + ]) + case false: + return ImmuTable(sections: [ + ImmuTableSection(rows: firstSectionRows) + ]) + } + } +} + +// MARK: - Private Methods +private extension ReferrerDetailsViewModel { + func buildDetailsRows(data: StatsTotalRowData) -> [ImmuTableRow] { + var rows = [ImmuTableRow]() + + if let children = data.childRows, !children.isEmpty { + for (index, child) in children.enumerated() { + if let url = child.disclosureURL { + rows.append(ReferrerDetailsRow(action: action, + isLast: index == children.count - 1, + data: .init(name: child.name, + url: url, + views: child.data))) + } else if let childRows = child.childRows, !childRows.isEmpty { + rows.append(DetailExpandableRow(rowData: child, + referrerDelegate: referrersDelegate, + hideIndentedSeparator: true, + hideFullSeparator: index != children.count - 1, + expanded: false)) + } + } + } else { + guard let url = data.disclosureURL else { + return [] + } + rows.append(ReferrerDetailsRow(action: action, + isLast: true, + data: .init(name: data.name, + url: url, + views: data.data))) + } + + return rows + } +} + +// MARK: - Private Computed Properties +private extension ReferrerDetailsViewModel { + var action: ((ImmuTableRow) -> Void) { + return { [unowned self] row in + switch row { + case let row as ReferrerDetailsRow: + self.delegate?.displayWebViewWithURL(row.data.url) + case let row as ReferrerDetailsSpamActionRow: + guard let referrerDomain = self.data.disclosureURL?.host ?? self.data.childRows?.first?.disclosureURL?.host else { + return + } + self.delegate?.toggleSpamState(for: referrerDomain, currentValue: row.isSpam) + default: + break + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift index 22db4f7a49ce..23e22b01272d 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift @@ -1,6 +1,6 @@ import UIKit -protocol SiteStatsTableHeaderDelegate: class { +protocol SiteStatsTableHeaderDelegate: AnyObject { func dateChangedTo(_ newDate: Date?) } @@ -8,10 +8,12 @@ protocol SiteStatsTableHeaderDateButtonDelegate: SiteStatsTableHeaderDelegate { func didTouchHeaderButton(forward: Bool) } -class SiteStatsTableHeaderView: UITableViewHeaderFooterView, NibLoadable, Accessible { +class SiteStatsTableHeaderView: UIView, NibLoadable, Accessible { // MARK: - Properties + static let estimatedHeight: CGFloat = 60 + @IBOutlet weak var dateLabel: UILabel! @IBOutlet weak var timezoneLabel: UILabel! @IBOutlet weak var backArrow: UIImageView! @@ -55,11 +57,7 @@ class SiteStatsTableHeaderView: UITableViewHeaderFooterView, NibLoadable, Access return -(expectedPeriodCount - 1) } - // MARK: - Class Methods - - class func headerHeight() -> CGFloat { - return SiteStatsInformation.sharedInstance.timeZoneMatchesDevice() ? Heights.default : Heights.withTimezone - } + private var isRunningGhostAnimation: Bool = false // MARK: - View @@ -68,6 +66,12 @@ class SiteStatsTableHeaderView: UITableViewHeaderFooterView, NibLoadable, Access applyStyles() } + override func tintColorDidChange() { + super.tintColorDidChange() + // Restart animation when toggling light/dark mode so colors are updated. + restartGhostAnimation(style: GhostCellStyle.muriel) + } + func configure(date: Date?, period: StatsPeriodUnit?, delegate: SiteStatsTableHeaderDelegate, @@ -124,24 +128,38 @@ class SiteStatsTableHeaderView: UITableViewHeaderFooterView, NibLoadable, Access } func animateGhostLayers(_ animate: Bool) { - forwardButton.isEnabled = !animate - backButton.isEnabled = !animate - if animate { + isRunningGhostAnimation = true startGhostAnimation(style: GhostCellStyle.muriel) - return + } else { + isRunningGhostAnimation = false + stopGhostAnimation() } - stopGhostAnimation() + + updateButtonStates() } } private extension SiteStatsTableHeaderView { func applyStyles() { - contentView.backgroundColor = .listForeground + backgroundColor = .listForeground + Style.configureLabelAsCellRowTitle(dateLabel) + dateLabel.font = Metrics.dateLabelFont + dateLabel.adjustsFontForContentSizeCategory = true + dateLabel.minimumScaleFactor = Metrics.minimumScaleFactor + Style.configureLabelAsChildRowTitle(timezoneLabel) + timezoneLabel.font = Metrics.timezoneFont + timezoneLabel.adjustsFontForContentSizeCategory = true + timezoneLabel.minimumScaleFactor = Metrics.minimumScaleFactor + Style.configureViewAsSeparator(bottomSeparatorLine) + + // Required as the Style separator configure method clears all + // separators for the Stats Revamp in Insights. + bottomSeparatorLine.backgroundColor = .separator } func displayDate() -> String? { @@ -245,6 +263,12 @@ private extension SiteStatsTableHeaderView { } func updateButtonStates() { + guard !isRunningGhostAnimation else { + forwardButton.isEnabled = false + backButton.isEnabled = false + return + } + guard let date = date, let period = period else { forwardButton.isEnabled = false backButton.isEnabled = false @@ -280,12 +304,6 @@ private extension SiteStatsTableHeaderView { } } - // MARK: - Header Heights - - private struct Heights { - static let `default`: CGFloat = 44 - static let withTimezone: CGFloat = 60 - } } extension SiteStatsTableHeaderView: StatsBarChartViewDelegate { @@ -302,3 +320,25 @@ extension SiteStatsTableHeaderView: StatsBarChartViewDelegate { reloadView() } } + +private extension SiteStatsTableHeaderView { + enum Metrics { + static let dateLabelFontSize: CGFloat = 20 + static let maximumDateLabelFontSize: CGFloat = 32 + static let timezoneFontSize: CGFloat = 16 + static let maximumTimezoneFontSize: CGFloat = 20 + static let minimumScaleFactor: CGFloat = 0.8 + + static var dateLabelFont: UIFont { + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .headline) + let font = UIFont(descriptor: fontDescriptor, size: dateLabelFontSize) + return UIFontMetrics.default.scaledFont(for: font, maximumPointSize: maximumDateLabelFontSize) + } + + static var timezoneFont: UIFont { + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .callout) + let font = UIFont(descriptor: fontDescriptor, size: timezoneFontSize) + return UIFontMetrics.default.scaledFont(for: font, maximumPointSize: maximumTimezoneFontSize) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.xib b/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.xib index 5ed013fe6cac..d61c8fe00698 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.xib +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.xib @@ -1,9 +1,9 @@ - + - + @@ -18,18 +18,21 @@ + + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController+JetpackBannerViewController.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController+JetpackBannerViewController.swift new file mode 100644 index 000000000000..f07c9b0beb6d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController+JetpackBannerViewController.swift @@ -0,0 +1,16 @@ +import Foundation + +extension PostStatsTableViewController { + + static func withJPBannerForBlog(postID: Int, postTitle: String?, postURL: URL?) -> UIViewController { + let statsVC = PostStatsTableViewController.loadFromStoryboard() + statsVC.configure(postID: postID, postTitle: postTitle, postURL: postURL) + return JetpackBannerWrapperViewController(childVC: statsVC, screen: .stats) + } + + override func scrollViewDidScroll(_ scrollView: UIScrollView) { + if let jetpackBannerWrapper = parent as? JetpackBannerWrapperViewController { + jetpackBannerWrapper.processJetpackBannerVisibility(scrollView) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift index 77d7c9eb5023..119afb3369f2 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift @@ -26,7 +26,7 @@ class PostStatsTableViewController: UITableViewController, StoryboardLoadable { private var changeReceipt: Receipt? private lazy var tableHandler: ImmuTableViewHandler = { - return ImmuTableViewHandler(takeOver: self) + return ImmuTableViewHandler(takeOver: self, with: self) }() // MARK: - View @@ -35,15 +35,19 @@ class PostStatsTableViewController: UITableViewController, StoryboardLoadable { super.viewDidLoad() navigationItem.title = NSLocalizedString("Post Stats", comment: "Window title for Post Stats view.") refreshControl?.addTarget(self, action: #selector(userInitiatedRefresh), for: .valueChanged) + tableView.estimatedSectionHeaderHeight = SiteStatsTableHeaderView.estimatedHeight Style.configureTable(tableView) ImmuTable.registerRows(tableRowTypes(), tableView: tableView) - tableView.register(SiteStatsTableHeaderView.defaultNib, - forHeaderFooterViewReuseIdentifier: SiteStatsTableHeaderView.defaultNibName) initViewModel() trackAccessEvent() addWillEnterForegroundObserver() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + JetpackFeaturesRemovalCoordinator.presentOverlayIfNeeded(in: self, source: .stats) + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) removeWillEnterForegroundObserver() @@ -56,7 +60,7 @@ class PostStatsTableViewController: UITableViewController, StoryboardLoadable { } override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: SiteStatsTableHeaderView.defaultNibName) as? SiteStatsTableHeaderView else { + guard let cell = Bundle.main.loadNibNamed("SiteStatsTableHeaderView", owner: nil, options: nil)?.first as? SiteStatsTableHeaderView else { return nil } @@ -72,10 +76,6 @@ class PostStatsTableViewController: UITableViewController, StoryboardLoadable { return cell } - override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return SiteStatsTableHeaderView.headerHeight() - } - } extension PostStatsTableViewController: StatsForegroundObservable { @@ -187,7 +187,7 @@ private extension PostStatsTableViewController { extension PostStatsTableViewController: PostStatsDelegate { func displayWebViewWithURL(_ url: URL) { - let webViewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url) + let webViewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url, source: "stats_post_stats") let navController = UINavigationController.init(rootViewController: webViewController) present(navController, animated: true) } @@ -244,12 +244,12 @@ extension PostStatsTableViewController: NoResultsViewHost { configureAndDisplayNoResults(on: tableView, title: NoResultConstants.errorTitle, subtitle: NoResultConstants.errorSubtitle, - buttonTitle: NoResultConstants.refreshButtonTitle) { [weak self] noResults in + buttonTitle: NoResultConstants.refreshButtonTitle, customizationBlock: { [weak self] noResults in noResults.delegate = self if !noResults.isReachable { noResults.resetButtonText() } - } + }) } private enum NoResultConstants { diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsViewModel.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsViewModel.swift index 393eec6b1b73..d5cfafcc60ae 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsViewModel.swift @@ -127,7 +127,10 @@ private extension PostStatsViewModel { let overviewData = OverviewTabData(tabTitle: StatSection.periodOverviewViews.tabTitle, tabData: dayData.viewCount, difference: dayData.difference, - differencePercent: dayData.percentage) + differencePercent: dayData.percentage, + date: selectedDate, + period: .day + ) let chart = PostChart(postViews: lastTwoWeeks) diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/DetailDataCell.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/DetailDataCell.swift index 59e254955c40..ac35f7b86630 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/DetailDataCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/DetailDataCell.swift @@ -18,6 +18,7 @@ class DetailDataCell: UITableViewCell, NibLoadable { @IBOutlet weak var bottomExpandedSeparatorLine: UIView! private weak var detailsDelegate: SiteStatsDetailsDelegate? + private weak var referrerDelegate: SiteStatsReferrerDelegate? private var rowData: StatsTotalRowData? private typealias Style = WPStyleGuide.Stats private var row: StatsTotalRow? @@ -26,6 +27,7 @@ class DetailDataCell: UITableViewCell, NibLoadable { func configure(rowData: StatsTotalRowData, detailsDelegate: SiteStatsDetailsDelegate?, + referrerDelegate: SiteStatsReferrerDelegate? = nil, hideIndentedSeparator: Bool = false, hideFullSeparator: Bool = true, expanded: Bool = false, @@ -36,9 +38,10 @@ class DetailDataCell: UITableViewCell, NibLoadable { self.rowData = rowData self.detailsDelegate = detailsDelegate + self.referrerDelegate = referrerDelegate let row = StatsTotalRow.loadFromNib() - row.configure(rowData: rowData, delegate: self, forDetails: true) + row.configure(rowData: rowData, delegate: self, referrerDelegate: self, forDetails: true) bottomExpandedSeparatorLine.isHidden = hideFullSeparator @@ -95,5 +98,12 @@ extension DetailDataCell: StatsTotalRowDelegate { func toggleChildRows(for row: StatsTotalRow, didSelectRow: Bool) { detailsDelegate?.toggleChildRowsForRow?(row) } +} + +// MARK: - StatsTotalRowReferrerDelegate +extension DetailDataCell: StatsTotalRowReferrerDelegate { + func showReferrerDetails(_ data: StatsTotalRowData) { + referrerDelegate?.showReferrerDetails(data) + } } diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsDetailTableViewController.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsDetailTableViewController.swift index b872693204c5..66c1b1f78cf0 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsDetailTableViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsDetailTableViewController.swift @@ -9,6 +9,7 @@ import WordPressFlux @objc optional func displayMediaWithID(_ mediaID: NSNumber) } +//TODO - this should eventually be removed as part of Stats Revamp class SiteStatsDetailTableViewController: UITableViewController, StoryboardLoadable { // MARK: - StoryboardLoadable Protocol @@ -46,10 +47,6 @@ class SiteStatsDetailTableViewController: UITableViewController, StoryboardLoada return MediaService(managedObjectContext: mainContext) }() - private lazy var blogService: BlogService = { - return BlogService(managedObjectContext: mainContext) - }() - // MARK: - View override func viewDidLoad() { @@ -58,6 +55,7 @@ class SiteStatsDetailTableViewController: UITableViewController, StoryboardLoada clearExpandedRows() Style.configureTable(tableView) refreshControl?.addTarget(self, action: #selector(refreshData), for: .valueChanged) + tableView.estimatedSectionHeaderHeight = SiteStatsTableHeaderView.estimatedHeight ImmuTable.registerRows(tableRowTypes(), tableView: tableView) tableView.register(SiteStatsTableHeaderView.defaultNib, forHeaderFooterViewReuseIdentifier: SiteStatsTableHeaderView.defaultNibName) @@ -101,7 +99,7 @@ class SiteStatsDetailTableViewController: UITableViewController, StoryboardLoada return nil } - guard let cell = tableView.dequeueReusableHeaderFooterView(withIdentifier: SiteStatsTableHeaderView.defaultNibName) as? SiteStatsTableHeaderView else { + guard let cell = Bundle.main.loadNibNamed("SiteStatsTableHeaderView", owner: nil, options: nil)?.first as? SiteStatsTableHeaderView else { return nil } @@ -128,7 +126,7 @@ class SiteStatsDetailTableViewController: UITableViewController, StoryboardLoada return 0 } - return SiteStatsTableHeaderView.headerHeight() + return UITableView.automaticDimension } } @@ -145,7 +143,8 @@ extension SiteStatsDetailTableViewController: StatsForegroundObservable { private extension SiteStatsDetailTableViewController { func initViewModel() { - viewModel = SiteStatsDetailsViewModel(detailsDelegate: self) + viewModel = SiteStatsDetailsViewModel(detailsDelegate: self, + referrerDelegate: self) guard let statSection = statSection else { return @@ -276,7 +275,7 @@ extension SiteStatsDetailTableViewController: SiteStatsDetailsDelegate { } func displayWebViewWithURL(_ url: URL) { - let webViewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url) + let webViewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url, source: "site_stats_detail") let navController = UINavigationController.init(rootViewController: webViewController) present(navController, animated: true) } @@ -287,15 +286,16 @@ extension SiteStatsDetailTableViewController: SiteStatsDetailsDelegate { } func showPostStats(postID: Int, postTitle: String?, postURL: URL?) { - let postStatsTableViewController = PostStatsTableViewController.loadFromStoryboard() - postStatsTableViewController.configure(postID: postID, postTitle: postTitle, postURL: postURL) + let postStatsTableViewController = PostStatsTableViewController.withJPBannerForBlog(postID: postID, + postTitle: postTitle, + postURL: postURL) navigationController?.pushViewController(postStatsTableViewController, animated: true) } func displayMediaWithID(_ mediaID: NSNumber) { guard let siteID = SiteStatsInformation.sharedInstance.siteID, - let blog = blogService.blog(byBlogId: siteID) else { + let blog = Blog.lookup(withID: siteID, in: mainContext) else { DDLogInfo("Unable to get blog when trying to show media from Stats details.") return } @@ -307,7 +307,14 @@ extension SiteStatsDetailTableViewController: SiteStatsDetailsDelegate { DDLogInfo("Unable to get media when trying to show from Stats details: \(error.localizedDescription)") }) } +} +// MARK: - SiteStatsReferrerDelegate + +extension SiteStatsDetailTableViewController: SiteStatsReferrerDelegate { + func showReferrerDetails(_ data: StatsTotalRowData) { + show(ReferrerDetailsTableViewController(data: data), sender: nil) + } } // MARK: - NoResultsViewHost @@ -322,12 +329,12 @@ extension SiteStatsDetailTableViewController: NoResultsViewHost { configureAndDisplayNoResults(on: tableView, title: NoResultConstants.errorTitle, subtitle: NoResultConstants.errorSubtitle, - buttonTitle: NoResultConstants.refreshButtonTitle) { [weak self] noResults in + buttonTitle: NoResultConstants.refreshButtonTitle, customizationBlock: { [weak self] noResults in noResults.delegate = self if !noResults.isReachable { noResults.resetButtonText() } - } + }) } private enum NoResultConstants { diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsDetailsViewModel.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsDetailsViewModel.swift index 7dcc58a86a2e..a99d3568debf 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsDetailsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsDetailsViewModel.swift @@ -14,6 +14,7 @@ class SiteStatsDetailsViewModel: Observable { private var statSection: StatSection? private weak var detailsDelegate: SiteStatsDetailsDelegate? + private weak var referrerDelegate: SiteStatsReferrerDelegate? private let insightsStore = StoreContainer.shared.statsInsights private var insightsReceipt: Receipt? @@ -31,8 +32,10 @@ class SiteStatsDetailsViewModel: Observable { // MARK: - Init - init(detailsDelegate: SiteStatsDetailsDelegate) { + init(detailsDelegate: SiteStatsDetailsDelegate, + referrerDelegate: SiteStatsReferrerDelegate) { self.detailsDelegate = detailsDelegate + self.referrerDelegate = referrerDelegate } // MARK: - Data Fetching @@ -169,7 +172,7 @@ class SiteStatsDetailsViewModel: Observable { selectedIndex: selectedIndex)) let dataRows = statSection == .insightsFollowersWordPress ? wpTabData.dataRows : emailTabData.dataRows if dataRows.isEmpty { - rows.append(StatsErrorRow(rowStatus: .success, statType: .insights)) + rows.append(StatsErrorRow(rowStatus: .success, statType: .insights, statSection: .insightsFollowersWordPress)) } else { rows.append(contentsOf: tabbedRowsFrom(dataRows)) } @@ -187,7 +190,7 @@ class SiteStatsDetailsViewModel: Observable { selectedIndex: selectedIndex)) let dataRows = statSection == .insightsCommentsAuthors ? authorsTabData.dataRows : postsTabData.dataRows if dataRows.isEmpty { - rows.append(StatsErrorRow(rowStatus: .success, statType: .insights)) + rows.append(StatsErrorRow(rowStatus: .success, statType: .insights, statSection: .insightsCommentsAuthors)) } else { rows.append(contentsOf: tabbedRowsFrom(dataRows)) } @@ -399,7 +402,6 @@ class SiteStatsDetailsViewModel: Observable { ActionDispatcher.dispatch(PeriodAction.refreshPostStats(postID: postID)) } - } // MARK: - Private Extension @@ -576,15 +578,15 @@ private extension SiteStatsDetailsViewModel { StatsTotalRowData(name: AnnualSiteStats.totalComments, data: annualInsights.totalCommentsCount.abbreviatedString()), StatsTotalRowData(name: AnnualSiteStats.commentsPerPost, - data: Int(round(annualInsights.averageCommentsCount)).abbreviatedString()), + data: annualInsights.averageCommentsCount.abbreviatedString()), StatsTotalRowData(name: AnnualSiteStats.totalLikes, data: annualInsights.totalLikesCount.abbreviatedString()), StatsTotalRowData(name: AnnualSiteStats.likesPerPost, - data: Int(round(annualInsights.averageLikesCount)).abbreviatedString()), + data: annualInsights.averageLikesCount.abbreviatedString()), StatsTotalRowData(name: AnnualSiteStats.totalWords, data: annualInsights.totalWordsCount.abbreviatedString()), StatsTotalRowData(name: AnnualSiteStats.wordsPerPost, - data: Int(round(annualInsights.averageWordsCount)).abbreviatedString())] + data: annualInsights.averageWordsCount.abbreviatedString())] } // MARK: - Posts and Pages @@ -739,7 +741,8 @@ private extension SiteStatsDetailsViewModel { showDisclosure: true, disclosureURL: referrer.url, childRows: referrer.children.map { rowDataFromReferrer(referrer: $0) }, - statSection: .periodReferrers) + statSection: .periodReferrers, + isReferrerSpam: referrer.isSpam) } return referrers.map { rowDataFromReferrer(referrer: $0) } @@ -777,7 +780,7 @@ private extension SiteStatsDetailsViewModel { } func publishedRowData() -> [StatsTotalRowData] { - return periodStore.getTopPublished()?.publishedPosts.map { StatsTotalRowData(name: $0.title, + return periodStore.getTopPublished()?.publishedPosts.map { StatsTotalRowData(name: $0.title.stringByDecodingXMLCharacters(), data: "", showDisclosure: true, disclosureURL: $0.postURL, @@ -963,6 +966,7 @@ private extension SiteStatsDetailsViewModel { func parentRow(rowData: StatsTotalRowData, hideIndentedSeparator: Bool, hideFullSeparator: Bool, expanded: Bool) -> DetailExpandableRow { return DetailExpandableRow(rowData: rowData, detailsDelegate: detailsDelegate, + referrerDelegate: referrerDelegate, hideIndentedSeparator: hideIndentedSeparator, hideFullSeparator: hideFullSeparator, expanded: expanded) diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsInsightsDetailsTableViewController.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsInsightsDetailsTableViewController.swift new file mode 100644 index 000000000000..8125b1701cc2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsInsightsDetailsTableViewController.swift @@ -0,0 +1,406 @@ +import UIKit +import WordPressFlux + +class SiteStatsInsightsDetailsTableViewController: SiteStatsBaseTableViewController { + + // MARK: - Properties + + private typealias Style = WPStyleGuide.Stats + private var statSection: StatSection? + private var statType: StatType = .period + private var selectedDate = StatsDataHelper.currentDateForSite() + private var selectedPeriod: StatsPeriodUnit? + + private var viewModel: SiteStatsInsightsDetailsViewModel? + private var tableHeaderView: SiteStatsTableHeaderView? + + private var receipt: Receipt? + + private let insightsStore = StoreContainer.shared.statsInsights + private var insightsChangeReceipt: Receipt? + private let periodStore = StoreContainer.shared.statsPeriod + private var periodChangeReceipt: Receipt? + + private lazy var tableHandler: ImmuTableViewHandler = { + return ImmuTableViewHandler(takeOver: self) + }() + + private var postID: Int? + + private lazy var mainContext: NSManagedObjectContext = { + return ContextManager.sharedInstance().mainContext + }() + + private lazy var mediaService: MediaService = { + return MediaService(managedObjectContext: mainContext) + }() + + override func viewDidLoad() { + super.viewDidLoad() + + WPStyleGuide.Stats.configureTable(tableView) + refreshControl.addTarget(self, action: #selector(refreshData), for: .valueChanged) + tableView.estimatedSectionHeaderHeight = SiteStatsTableHeaderView.estimatedHeight + ImmuTable.registerRows(tableRowTypes(), tableView: tableView) + addWillEnterForegroundObserver() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + removeWillEnterForegroundObserver() + } + + func configure(statSection: StatSection, + selectedDate: Date? = nil, + selectedPeriod: StatsPeriodUnit? = nil, + postID: Int? = nil + ) { + self.statSection = statSection + self.selectedDate = selectedDate ?? StatsDataHelper.currentDateForSite() + self.selectedPeriod = selectedPeriod + self.postID = postID + tableStyle = .insetGrouped + statType = StatSection.allInsights.contains(statSection) ? .insights : .period + title = statSection.detailsTitle + initViewModel() + updateHeader() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + // This is primarily to resize the NoResultsView in a TabbedTotalsCell on rotation. + coordinator.animate(alongsideTransition: { _ in + self.tableView.reloadData() + }) + } +} + +extension SiteStatsInsightsDetailsTableViewController: StatsForegroundObservable { + func reloadStatsData() { + selectedDate = StatsDataHelper.currentDateForSite() + refreshData() + } +} + +private extension SiteStatsInsightsDetailsTableViewController { + private func updateHeader() { + guard let siteStatsTableHeaderView = Bundle.main.loadNibNamed("SiteStatsTableHeaderView", owner: nil, options: nil)?.first as? SiteStatsTableHeaderView else { + return + } + + guard let statSection = statSection else { + return + } + + if statSection == .insightsFollowerTotals || statSection == .insightsCommentsTotals { + return + } else if statSection == .insightsAnnualSiteStats, // When section header is this year, we configure the header so it shows only year + let allAnnualInsights = insightsStore.getAllAnnual()?.allAnnualInsights, + let mostRecentYear = allAnnualInsights.last?.year { + // Allow the date bar to only go up to the most recent year available. + var dateComponents = Calendar.current.dateComponents([.year, .month, .day], from: StatsDataHelper.currentDateForSite()) + dateComponents.year = mostRecentYear + let mostRecentDate = Calendar.current.date(from: dateComponents) + + siteStatsTableHeaderView.configure(date: selectedDate, + period: .year, + delegate: self, + expectedPeriodCount: allAnnualInsights.count, + mostRecentDate: mostRecentDate) + } + else { + siteStatsTableHeaderView.configure(date: selectedDate, period: StatsPeriodUnit.week, delegate: self) + } + + siteStatsTableHeaderView.animateGhostLayers(viewModel?.storeIsFetching(statSection: statSection) == true) + + tableView.tableHeaderView = siteStatsTableHeaderView + + guard let tableHeaderView = tableView.tableHeaderView else { + return + } + + tableHeaderView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + tableHeaderView.topAnchor.constraint(equalTo: tableView.topAnchor), + tableHeaderView.safeLeadingAnchor.constraint(equalTo: tableView.safeLeadingAnchor), + tableHeaderView.safeTrailingAnchor.constraint(equalTo: tableView.safeTrailingAnchor), + tableHeaderView.heightAnchor.constraint(equalToConstant: 60) + ]) + tableView.tableHeaderView?.layoutIfNeeded() + } + + func initViewModel() { + viewModel = SiteStatsInsightsDetailsViewModel(insightsDetailsDelegate: self, + detailsDelegate: self, + referrerDelegate: self, + viewsAndVisitorsDelegate: self) + + guard let statSection = statSection else { + return + } + + addViewModelListeners() + + viewModel?.fetchDataFor(statSection: statSection, + selectedDate: selectedDate, + selectedPeriod: selectedPeriod, + postID: postID) + } + + func addViewModelListeners() { + if receipt != nil { + return + } + + receipt = viewModel?.onChange { [weak self] in + self?.updateHeader() + self?.refreshTableView() + } + } + + func removeViewModelListeners() { + receipt = nil + } + + func tableRowTypes() -> [ImmuTableRow.Type] { + return [DetailDataRow.self, + DetailExpandableRow.self, + DetailExpandableChildRow.self, + DetailSubtitlesHeaderRow.self, + DetailSubtitlesTabbedHeaderRow.self, + DetailSubtitlesCountriesHeaderRow.self, + CountriesMapRow.self, + StatsErrorRow.self, + StatsGhostTopHeaderImmutableRow.self, + StatsGhostDetailRow.self, + ViewsVisitorsRow.self, + PeriodEmptyCellHeaderRow.self, + TotalInsightStatsRow.self] + } + + // MARK: - Table Refreshing + + func refreshTableView() { + guard let viewModel = viewModel else { + return + } + + tableHandler.viewModel = viewModel.tableViewModel() + refreshControl.endRefreshing() + + if viewModel.fetchDataHasFailed() { + displayFailureViewIfNecessary() + } else { + hideNoResults() + } + } + + @objc func refreshData() { + guard let statSection = statSection else { + return + } + + clearExpandedRows() + refreshControl.beginRefreshing() + + switch statSection { + case .insightsFollowersWordPress, .insightsFollowersEmail, .insightsFollowerTotals: + viewModel?.refreshFollowers() + case .insightsCommentsAuthors, .insightsCommentsPosts: + viewModel?.refreshComments() + case .insightsTagsAndCategories: + viewModel?.refreshTagsAndCategories() + case .insightsAnnualSiteStats: + viewModel?.refreshAnnual(selectedDate: selectedDate) + case .periodPostsAndPages: + viewModel?.refreshPostsAndPages() + case .periodSearchTerms: + viewModel?.refreshSearchTerms() + case .periodVideos: + viewModel?.refreshVideos() + case .periodClicks: + viewModel?.refreshClicks() + case .periodAuthors: + viewModel?.refreshAuthors() + case .periodReferrers: + viewModel?.refreshReferrers() + case .periodCountries: + viewModel?.refreshCountries() + case .periodPublished: + viewModel?.refreshPublished() + case .periodFileDownloads: + viewModel?.refreshFileDownloads() + case .postStatsMonthsYears, .postStatsAverageViews: + viewModel?.refreshPostStats() + case .insightsViewsVisitors: + viewModel?.refreshViewsAndVisitorsData(date: selectedDate) + default: + refreshControl.endRefreshing() + } + } + + func applyTableUpdates() { + tableView.performBatchUpdates({ + }) + } + + func clearExpandedRows() { + StatsDataHelper.clearExpandedDetails() + } + + func updateStatSectionForFilterChange() { + guard let oldStatSection = statSection else { + return + } + + switch oldStatSection { + case .insightsFollowersWordPress: + statSection = .insightsFollowersEmail + case .insightsFollowersEmail: + statSection = .insightsFollowersWordPress + case .insightsCommentsAuthors: + statSection = .insightsCommentsPosts + case .insightsCommentsPosts: + statSection = .insightsCommentsAuthors + default: + // Return here as `initViewModel` is only needed for filtered cards. + return + } + + initViewModel() + } +} + +// MARK: - SiteStatsDetailsDelegate Methods + +extension SiteStatsInsightsDetailsTableViewController: SiteStatsDetailsDelegate { + + func tabbedTotalsCellUpdated() { + applyTableUpdates() + } + + func displayWebViewWithURL(_ url: URL) { + let webViewController = WebViewControllerFactory.controllerAuthenticatedWithDefaultAccount(url: url, source: "site_stats_detail") + let navController = UINavigationController.init(rootViewController: webViewController) + present(navController, animated: true) + } + + func toggleChildRowsForRow(_ row: StatsTotalRow) { + StatsDataHelper.updatedExpandedState(forRow: row, inDetails: true) + refreshTableView() + } + + func showPostStats(postID: Int, postTitle: String?, postURL: URL?) { + removeViewModelListeners() + let postStatsTableViewController = PostStatsTableViewController.withJPBannerForBlog(postID: postID, + postTitle: postTitle, + postURL: postURL) + navigationController?.pushViewController(postStatsTableViewController, animated: true) + } + + func displayMediaWithID(_ mediaID: NSNumber) { + + guard let siteID = SiteStatsInformation.sharedInstance.siteID, + let blog = Blog.lookup(withID: siteID, in: mainContext) else { + DDLogInfo("Unable to get blog when trying to show media from Stats details.") + return + } + + mediaService.getMediaWithID(mediaID, in: blog, success: { (media) in + let viewController = MediaItemViewController(media: media) + self.navigationController?.pushViewController(viewController, animated: true) + }, failure: { (error) in + DDLogInfo("Unable to get media when trying to show from Stats details: \(error.localizedDescription)") + }) + } +} + +// MARK: - SiteStatsInsightsDelegate + +extension SiteStatsInsightsDetailsTableViewController: SiteStatsInsightsDelegate { + + func viewMoreSelectedForStatSection(_ statSection: StatSection) { + removeViewModelListeners() + + let detailTableViewController = SiteStatsDetailTableViewController.loadFromStoryboard() + detailTableViewController.configure(statSection: statSection, + selectedDate: selectedDate, + selectedPeriod: .week) + navigationController?.pushViewController(detailTableViewController, animated: true) + } +} + +// MARK: - SiteStatsReferrerDelegate + +extension SiteStatsInsightsDetailsTableViewController: SiteStatsReferrerDelegate { + func showReferrerDetails(_ data: StatsTotalRowData) { + show(ReferrerDetailsTableViewController(data: data), sender: nil) + } +} + +// MARK: - NoResultsViewHost + +extension SiteStatsInsightsDetailsTableViewController: NoResultsViewHost { + + private func displayFailureViewIfNecessary() { + guard tableHandler.viewModel.sections.isEmpty else { + return + } + + configureAndDisplayNoResults(on: tableView, + title: NoResultConstants.errorTitle, + subtitle: NoResultConstants.errorSubtitle, + buttonTitle: NoResultConstants.refreshButtonTitle, customizationBlock: { [weak self] noResults in + noResults.delegate = self + if !noResults.isReachable { + noResults.resetButtonText() + } + }) + } + + private enum NoResultConstants { + static let errorTitle = NSLocalizedString("Stats not loaded", comment: "The loading view title displayed when an error occurred") + static let errorSubtitle = NSLocalizedString("There was a problem loading your data, refresh your page to try again.", comment: "The loading view subtitle displayed when an error occurred") + static let refreshButtonTitle = NSLocalizedString("Refresh", comment: "The loading view button title displayed when an error occurred") + } +} + +// MARK: - NoResultsViewControllerDelegate methods + +extension SiteStatsInsightsDetailsTableViewController: NoResultsViewControllerDelegate { + func actionButtonPressed() { + hideNoResults() + refreshData() + } +} + +// MARK: - SiteStatsTableHeaderDelegate Methods + +extension SiteStatsInsightsDetailsTableViewController: SiteStatsTableHeaderDelegate { + + func dateChangedTo(_ newDate: Date?) { + guard let newDate = newDate else { + return + } + + // Since all Annual insights have already been fetched, don't refetch. + // Just update the date in the view model and refresh the table. + selectedDate = newDate + viewModel?.updateSelectedDate(newDate) + refreshTableView() + } +} + +// MARK: - ViewsVisitorsDelegate + +extension SiteStatsInsightsDetailsTableViewController: StatsInsightsViewsAndVisitorsDelegate { + func viewsAndVisitorsSegmendChanged(to selectedSegmentIndex: Int) { + if let selectedSegment = StatsSegmentedControlData.Segment(rawValue: selectedSegmentIndex) { + viewModel?.updateViewsAndVisitorsSegment(selectedSegment) + refreshTableView() + } + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsInsightsDetailsViewModel.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsInsightsDetailsViewModel.swift new file mode 100644 index 000000000000..38b6816955f8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/SiteStatsInsightsDetailsViewModel.swift @@ -0,0 +1,1372 @@ +import Foundation +import UIKit +import WordPressFlux + +/// The view model used by SiteStatsDetailTableViewController to show +/// all data for a selected stat. +/// +class SiteStatsInsightsDetailsViewModel: Observable { + + // MARK: - Properties + + let changeDispatcher = Dispatcher() + + private typealias Style = WPStyleGuide.Stats + + private var statSection: StatSection? + private weak var insightsDetailsDelegate: SiteStatsInsightsDelegate? + private weak var detailsDelegate: SiteStatsDetailsDelegate? + private weak var referrerDelegate: SiteStatsReferrerDelegate? + private weak var viewsAndVisitorsDelegate: StatsInsightsViewsAndVisitorsDelegate? + + private let insightsStore = StoreContainer.shared.statsInsights + private var insightsReceipt: Receipt? + private var insightsChangeReceipt: Receipt? + + private let periodStore = StoreContainer.shared.statsPeriod + private var periodReceipt: Receipt? + private var periodChangeReceipt: Receipt? + + private let revampStore = StoreContainer.shared.statsRevamp + private var revampChangeReceipt: Receipt? + + private(set) var selectedDate: Date? + private var selectedPeriod: StatsPeriodUnit? + private var postID: Int? + + private var allAnnualInsights = [StatsAnnualInsight]() + + private var selectedViewsVisitorsSegment: StatsSegmentedControlData.Segment = .views + + // MARK: - Init + + init(insightsDetailsDelegate: SiteStatsInsightsDelegate, + detailsDelegate: SiteStatsDetailsDelegate, + referrerDelegate: SiteStatsReferrerDelegate, + viewsAndVisitorsDelegate: StatsInsightsViewsAndVisitorsDelegate) { + self.insightsDetailsDelegate = insightsDetailsDelegate + self.detailsDelegate = detailsDelegate + self.referrerDelegate = referrerDelegate + self.viewsAndVisitorsDelegate = viewsAndVisitorsDelegate + } + + // MARK: - Data Fetching + + func fetchDataFor(statSection: StatSection, + selectedDate: Date? = nil, + selectedPeriod: StatsPeriodUnit? = nil, + postID: Int? = nil) { + self.statSection = statSection + self.selectedDate = selectedDate + self.selectedPeriod = selectedPeriod + self.postID = postID + + switch statSection { + case let statSection where StatSection.allInsights.contains(statSection): + switch statSection { + case .insightsViewsVisitors: + self.selectedPeriod = .week + + let date = selectedDate ?? StatsDataHelper.currentDateForSite() + + revampChangeReceipt = revampStore.onChange { [weak self] in + self?.emitChange() + } + + refreshViewsAndVisitorsData(date: date) + case .insightsFollowersWordPress, .insightsFollowersEmail, .insightsFollowerTotals: + guard let storeQuery = queryForInsightStatSection(statSection) else { + return + } + + insightsChangeReceipt = insightsStore.onChange { [weak self] in + self?.emitChange() + } + insightsReceipt = insightsStore.query(storeQuery) + + refreshFollowers() + case .insightsLikesTotals: + self.selectedPeriod = .week + + let date = selectedDate ?? StatsDataHelper.currentDateForSite() + + revampChangeReceipt = revampStore.onChange { [weak self] in + self?.emitChange() + } + + refreshTotalLikesData(date: date) + case .insightsCommentsTotals: + guard let storeQuery = queryForInsightStatSection(statSection) else { + return + } + + insightsChangeReceipt = insightsStore.onChange { [weak self] in + self?.emitChange() + } + insightsReceipt = insightsStore.query(storeQuery) + + refreshComments() + default: + guard let storeQuery = queryForInsightStatSection(statSection) else { + return + } + + insightsChangeReceipt = insightsStore.onChange { [weak self] in + self?.emitChange() + } + insightsReceipt = insightsStore.query(storeQuery) + } + case let statSection where StatSection.allPeriods.contains(statSection): + guard let storeQuery = queryForPeriodStatSection(statSection) else { + return + } + + periodChangeReceipt = periodStore.onChange { [weak self] in + self?.emitChange() + } + periodReceipt = periodStore.query(storeQuery) + case let statSection where StatSection.allPostStats.contains(statSection): + guard let postID = postID else { + return + } + + periodChangeReceipt = periodStore.onChange { [weak self] in + self?.emitChange() + } + periodReceipt = periodStore.query(.postStats(postID: postID)) + default: + break + } + } + + func fetchDataHasFailed() -> Bool { + guard let statSection = statSection else { + return true + } + + switch statSection { + case let statSection where StatSection.allInsights.contains(statSection): + switch statSection { + case .insightsViewsVisitors: + return revampStore.viewsAndVisitorsStatus == .error + case .insightsFollowersWordPress, .insightsFollowersEmail, .insightsFollowerTotals: + guard let storeQuery = queryForInsightStatSection(statSection) else { + return true + } + return insightsStore.fetchingFailed(for: storeQuery) + case .insightsLikesTotals: + return revampStore.likesTotalsStatus == .error + case .insightsCommentsTotals: + guard let storeQuery = queryForInsightStatSection(statSection) else { + return true + } + return periodStore.getSummary() == nil && insightsStore.fetchingFailed(for: storeQuery) + default: + guard let storeQuery = queryForInsightStatSection(statSection) else { + return true + } + return insightsStore.fetchingFailed(for: storeQuery) + } + case let statSection where StatSection.allPeriods.contains(statSection): + guard let storeQuery = queryForPeriodStatSection(statSection) else { + return true + } + return periodStore.fetchingFailed(for: storeQuery) + default: + guard let postID = postID else { + return true + } + return periodStore.fetchingFailed(for: .postStats(postID: postID)) + } + } + + func storeIsFetching(statSection: StatSection) -> Bool { + switch statSection { + case .insightsViewsVisitors: + return revampStore.viewsAndVisitorsStatus == .loading + case .insightsFollowersWordPress, .insightsFollowersEmail, .insightsFollowerTotals: + return insightsStore.isFetchingAllFollowers + case .insightsCommentsAuthors, .insightsCommentsPosts, .insightsCommentsTotals: + return insightsStore.isFetchingComments + case .insightsTagsAndCategories: + return insightsStore.isFetchingTagsAndCategories + case .insightsAnnualSiteStats: + return insightsStore.isFetchingAnnual + case .insightsLikesTotals: + return revampStore.likesTotalsStatus == .loading + case .periodPostsAndPages: + return periodStore.isFetchingPostsAndPages + case .periodSearchTerms: + return periodStore.isFetchingSearchTerms + case .periodVideos: + return periodStore.isFetchingVideos + case .periodClicks: + return periodStore.isFetchingClicks + case .periodAuthors: + return periodStore.isFetchingAuthors + case .periodReferrers: + return periodStore.isFetchingReferrers + case .periodCountries: + return periodStore.isFetchingCountries + case .periodPublished: + return periodStore.isFetchingPublished + case .periodFileDownloads: + return periodStore.isFetchingFileDownloads + case .postStatsMonthsYears, .postStatsAverageViews: + return periodStore.isFetchingPostStats(for: postID) + default: + return false + } + } + + func updateSelectedDate(_ selectedDate: Date) { + guard let statSection = statSection else { + return + } + + // the max selectedDate has to be currentDateForSite + // otherwise this can result in an API error + if selectedDate > StatsDataHelper.currentDateForSite() { + self.selectedDate = StatsDataHelper.currentDateForSite() + } else { + self.selectedDate = selectedDate + } + + fetchDataFor(statSection: statSection, + selectedDate: self.selectedDate, + selectedPeriod: selectedPeriod, + postID: postID) + } + + // MARK: - Table Model + + func tableViewModel() -> ImmuTable { + guard let statSection = statSection, + let _ = detailsDelegate else { + return ImmuTable.Empty + } + + if fetchDataHasFailed() { + return ImmuTable.Empty + } + + switch statSection { + case .insightsViewsVisitors: + return periodImmuTable(for: revampStore.viewsAndVisitorsStatus) { status in + var rows = [ImmuTableRow]() + + let viewsAndVisitorsData = revampStore.getViewsAndVisitorsData() + if let periodSummary = viewsAndVisitorsData.summary { + + // Views Visitors + let weekEnd = futureEndOfWeekDate(for: periodSummary) + rows.append(contentsOf: SiteStatsImmuTableRows.viewVisitorsImmuTableRows(periodSummary, + selectedSegment: selectedViewsVisitorsSegment, + periodDate: selectedDate!, + periodEndDate: weekEnd, + statsLineChartViewDelegate: nil, + siteStatsInsightsDelegate: nil, + viewsAndVisitorsDelegate: viewsAndVisitorsDelegate)) + + // Referrers + if let referrers = viewsAndVisitorsData.topReferrers { + let referrersData = referrersRowData(topReferrers: referrers) + let chartViewModel = StatsReferrersChartViewModel(referrers: referrers) + let chartView: UIView? = referrers.totalReferrerViewsCount > 0 ? chartViewModel.makeReferrersChartView() : nil + + var referrersRow = TopTotalsPeriodStatsRow(itemSubtitle: StatSection.periodReferrers.itemSubtitle, + dataSubtitle: StatSection.periodReferrers.dataSubtitle, + dataRows: referrersData, + statSection: StatSection.periodReferrers, + siteStatsPeriodDelegate: nil, //TODO - look at if I need to be not null + siteStatsReferrerDelegate: nil, + siteStatsInsightsDetailsDelegate: insightsDetailsDelegate) + referrersRow.topAccessoryView = chartView + rows.append(referrersRow) + } + + + // Countries + let map = countriesMap(topCountries: viewsAndVisitorsData.topCountries) + let isMapShown = !map.data.isEmpty + if isMapShown { + rows.append(CountriesMapRow(countriesMap: map, statSection: .periodCountries)) + } + rows.append(CountriesStatsRow(itemSubtitle: StatSection.periodCountries.itemSubtitle, + dataSubtitle: StatSection.periodCountries.dataSubtitle, + statSection: isMapShown ? nil : .periodCountries, + dataRows: countriesRowData(topCountries: viewsAndVisitorsData.topCountries), + siteStatsPeriodDelegate: nil, + siteStatsInsightsDetailsDelegate: insightsDetailsDelegate)) + return rows + } + + return rows + } + case .insightsFollowersWordPress, .insightsFollowersEmail, .insightsFollowerTotals: + let status = insightsStore.followersTotalsStatus + let type: InsightType = .followersTotals + return insightsImmuTable(for: (type, status)) { + var rows = [ImmuTableRow]() + rows.append(TotalInsightStatsRow(dataRow: createFollowerTotalInsightsRow(), statSection: .insightsFollowerTotals, siteStatsInsightsDelegate: nil)) + + let dotComFollowersCount = insightsStore.getDotComFollowers()?.dotComFollowersCount ?? 0 + let emailFollowersCount = insightsStore.getEmailFollowers()?.emailFollowersCount ?? 0 + let publicizeCount = insightsStore.getPublicizeCount() + + if dotComFollowersCount > 0 || emailFollowersCount > 0 || publicizeCount > 0 { + let chartViewModel = StatsFollowersChartViewModel(dotComFollowersCount: dotComFollowersCount, + emailFollowersCount: emailFollowersCount, + publicizeCount: publicizeCount) + + let chartView: UIView = chartViewModel.makeFollowersChartView() + + var chartRow = TopTotalsPeriodStatsRow(itemSubtitle: "", + dataSubtitle: "", + dataRows: followersRowData(dotComFollowersCount: dotComFollowersCount, + emailFollowersCount: emailFollowersCount, + othersCount: publicizeCount, + totalCount: dotComFollowersCount + emailFollowersCount + publicizeCount), + statSection: StatSection.insightsFollowersWordPress, + siteStatsPeriodDelegate: nil, //TODO - look at if I need to be not null + siteStatsReferrerDelegate: nil) + chartRow.topAccessoryView = chartView + rows.append(chartRow) + } + + rows.append(TabbedTotalsStatsRow(tabsData: [tabDataForFollowerType(.insightsFollowersWordPress), + tabDataForFollowerType(.insightsFollowersEmail)], + statSection: .insightsFollowersWordPress, + siteStatsInsightsDelegate: insightsDetailsDelegate, + siteStatsDetailsDelegate: detailsDelegate, + showTotalCount: false)) + return rows + } + case .insightsLikesTotals: + return periodImmuTable(for: revampStore.likesTotalsStatus) { status in + var rows = [ImmuTableRow]() + + let likesTotalsData = revampStore.getLikesTotalsData() + + if let summary = likesTotalsData.summary { + rows.append(TotalInsightStatsRow(dataRow: createLikesTotalInsightsRow(periodSummary: summary), + statSection: statSection, + siteStatsInsightsDelegate: nil) + ) + } + + if let topPostsAndPages = likesTotalsData.topPostsAndPages { + rows.append(TopTotalsPeriodStatsRow(itemSubtitle: StatSection.periodPostsAndPages.itemSubtitle, + dataSubtitle: StatSection.periodPostsAndPages.dataSubtitle, + dataRows: postsAndPagesRowData(topPostsAndPages: topPostsAndPages), + statSection: StatSection.periodPostsAndPages, + siteStatsPeriodDelegate: nil, + siteStatsReferrerDelegate: nil, + siteStatsInsightsDetailsDelegate: insightsDetailsDelegate, + siteStatsDetailsDelegate: detailsDelegate)) + } + + return rows + } + case .insightsCommentsAuthors, .insightsCommentsPosts, .insightsCommentsTotals: + /// Comments depend both on PeriodStore and InsightsStore states + let status: StoreFetchingStatus = { + if insightsStore.allCommentsInsightStatus == .loading { + return .loading + } else if periodStore.getSummary() != nil { + return .success + } else { + return insightsStore.allCommentsInsightStatus + } + }() + return insightsImmuTable(for: (.allComments, status)) { + var rows = [ImmuTableRow]() + rows.append(TotalInsightStatsRow(dataRow: createCommentsTotalInsightsRow(), statSection: .insightsCommentsTotals, siteStatsInsightsDelegate: nil)) + + let authorsTabData = tabDataForCommentType(.insightsCommentsAuthors) + rows.append(TopTotalsInsightStatsRow(itemSubtitle: "", + dataSubtitle: "", + dataRows: authorsTabData.dataRows, + statSection: .insightsCommentsAuthors, + siteStatsInsightsDelegate: insightsDetailsDelegate)) + + let postsTabData = tabDataForCommentType(.insightsCommentsPosts) + rows.append(TopTotalsInsightStatsRow(itemSubtitle: StatSection.InsightsHeaders.posts, + dataSubtitle: StatSection.InsightsHeaders.comments, + dataRows: postsTabData.dataRows, + statSection: .insightsCommentsPosts, + siteStatsInsightsDelegate: insightsDetailsDelegate)) + return rows + } + case .insightsTagsAndCategories: + return insightsImmuTable(for: (.allTagsAndCategories, insightsStore.allTagsAndCategoriesStatus)) { + var rows = [ImmuTableRow]() + rows.append(DetailSubtitlesHeaderRow(itemSubtitle: StatSection.insightsTagsAndCategories.itemSubtitle, + dataSubtitle: StatSection.insightsTagsAndCategories.dataSubtitle)) + rows.append(contentsOf: tagsAndCategoriesRows()) + return rows + } + case .insightsAnnualSiteStats: + return insightsImmuTable(for: (.allAnnual, insightsStore.allAnnualStatus)) { + return Array(annualRows()) + } + case .periodPostsAndPages: + return periodImmuTable(for: periodStore.topPostsAndPagesStatus) { status in + var rows = [ImmuTableRow]() + rows.append(DetailSubtitlesHeaderRow(itemSubtitle: StatSection.periodPostsAndPages.itemSubtitle, + dataSubtitle: StatSection.periodPostsAndPages.dataSubtitle)) + rows.append(contentsOf: postsAndPagesRows(for: status)) + return rows + } + case .periodSearchTerms: + return periodImmuTable(for: periodStore.topSearchTermsStatus) { status in + var rows = [ImmuTableRow]() + rows.append(DetailSubtitlesHeaderRow(itemSubtitle: StatSection.periodSearchTerms.itemSubtitle, + dataSubtitle: StatSection.periodSearchTerms.dataSubtitle)) + rows.append(contentsOf: searchTermsRows(for: status)) + return rows + } + case .periodVideos: + return periodImmuTable(for: periodStore.topVideosStatus) { status in + var rows = [ImmuTableRow]() + rows.append(DetailSubtitlesHeaderRow(itemSubtitle: StatSection.periodVideos.itemSubtitle, + dataSubtitle: StatSection.periodVideos.dataSubtitle)) + rows.append(contentsOf: videosRows(for: status)) + return rows + } + case .periodClicks: + return periodImmuTable(for: periodStore.topClicksStatus) { status in + var rows = [ImmuTableRow]() + rows.append(DetailSubtitlesHeaderRow(itemSubtitle: StatSection.periodClicks.itemSubtitle, + dataSubtitle: StatSection.periodClicks.dataSubtitle)) + rows.append(contentsOf: clicksRows(for: status)) + return rows + } + case .periodAuthors: + return periodImmuTable(for: periodStore.topAuthorsStatus) { status in + var rows = [ImmuTableRow]() + rows.append(DetailSubtitlesHeaderRow(itemSubtitle: StatSection.periodAuthors.itemSubtitle, + dataSubtitle: StatSection.periodAuthors.dataSubtitle)) + rows.append(contentsOf: authorsRows(for: status)) + return rows + } + case .periodReferrers: + return periodImmuTable(for: periodStore.topReferrersStatus) { status in + var rows = [ImmuTableRow]() + rows.append(DetailSubtitlesHeaderRow(itemSubtitle: StatSection.periodReferrers.itemSubtitle, + dataSubtitle: StatSection.periodReferrers.dataSubtitle)) + rows.append(contentsOf: referrersRows(for: status)) + return rows + } + case .periodCountries: + return periodImmuTable(for: periodStore.topCountriesStatus) { status in + var rows = [ImmuTableRow]() + let map = countriesMap(topCountries: periodStore.getTopCountries()) + if !map.data.isEmpty { + rows.append(CountriesMapRow(countriesMap: map)) + } + rows.append(DetailSubtitlesCountriesHeaderRow(itemSubtitle: StatSection.periodCountries.itemSubtitle, + dataSubtitle: StatSection.periodCountries.dataSubtitle)) + rows.append(contentsOf: countriesRows(for: status)) + return rows + } + case .periodPublished: + return periodImmuTable(for: periodStore.topPublishedStatus) { status in + var rows = [ImmuTableRow]() + rows.append(DetailSubtitlesHeaderRow(itemSubtitle: "", dataSubtitle: "")) + rows.append(contentsOf: publishedRows(for: status)) + return rows + } + case .periodFileDownloads: + return periodImmuTable(for: periodStore.topFileDownloadsStatus) { status in + var rows = [ImmuTableRow]() + rows.append(DetailSubtitlesHeaderRow(itemSubtitle: StatSection.periodFileDownloads.itemSubtitle, + dataSubtitle: StatSection.periodFileDownloads.dataSubtitle)) + rows.append(contentsOf: fileDownloadsRows(for: status)) + return rows + } + case .postStatsMonthsYears: + return periodImmuTable(for: periodStore.postStatsFetchingStatuses(for: postID)) { status in + var rows = [ImmuTableRow]() + rows.append(DetailSubtitlesCountriesHeaderRow(itemSubtitle: StatSection.postStatsMonthsYears.itemSubtitle, + dataSubtitle: StatSection.postStatsMonthsYears.dataSubtitle)) + rows.append(contentsOf: postStatsRows(status: status)) + return rows + } + case .postStatsAverageViews: + return periodImmuTable(for: periodStore.postStatsFetchingStatuses(for: postID)) { status in + var rows = [ImmuTableRow]() + rows.append(DetailSubtitlesCountriesHeaderRow(itemSubtitle: StatSection.postStatsAverageViews.itemSubtitle, + dataSubtitle: StatSection.postStatsAverageViews.dataSubtitle)) + rows.append(contentsOf: postStatsRows(forAverages: true, status: status)) + return rows + } + default: + return ImmuTable.Empty + } + } + + func createFollowerTotalInsightsRow() -> StatsTotalInsightsData { + return StatsTotalInsightsData.followersCount(insightsStore: insightsStore) + } + + func createLikesTotalInsightsRow(periodSummary: StatsSummaryTimeIntervalData?) -> StatsTotalInsightsData { + let weekEnd = futureEndOfWeekDate(for: periodSummary) + var data = StatsTotalInsightsData.createTotalInsightsData(periodSummary: periodSummary, + insightsStore: insightsStore, + statsSummaryType: .likes, + periodEndDate: weekEnd) + // We don't show guide text at the detail level + data.guideText = nil + return data + } + + func createCommentsTotalInsightsRow() -> StatsTotalInsightsData { + var data = StatsTotalInsightsData.createTotalInsightsData(periodSummary: periodStore.getSummary(), insightsStore: insightsStore, statsSummaryType: .comments) + // We don't show guide text at the detail level + data.guideText = nil + return data + } + + // MARK: - Refresh Data + + func refreshPeriodOverviewData(date: Date, period: StatsPeriodUnit = .week, forceRefresh: Bool = false) { + ActionDispatcher.dispatch(PeriodAction.refreshPeriodOverviewData(date: date, + period: period, + forceRefresh: forceRefresh)) + } + + func refreshFollowers(forceRefresh: Bool = true) { + ActionDispatcher.dispatch(InsightAction.refreshInsights(forceRefresh: forceRefresh)) + } + + func refreshViewsAndVisitorsData(date: Date) { + ActionDispatcher.dispatch(StatsRevampStoreAction.refreshViewsAndVisitors(date: date)) + } + + func refreshTotalLikesData(date: Date) { + ActionDispatcher.dispatch(StatsRevampStoreAction.refreshLikesTotals(date: date)) + } + + func refreshComments() { + ActionDispatcher.dispatch(InsightAction.refreshComments) + } + + func refreshTagsAndCategories() { + ActionDispatcher.dispatch(InsightAction.refreshTagsAndCategories) + } + + func refreshAnnual(selectedDate: Date) { + self.selectedDate = selectedDate + ActionDispatcher.dispatch(InsightAction.refreshAnnual) + } + + func refreshPostsAndPages() { + guard let selectedDate = selectedDate, + let selectedPeriod = selectedPeriod else { + return + } + ActionDispatcher.dispatch(PeriodAction.refreshPostsAndPages(date: selectedDate, period: selectedPeriod)) + } + + func refreshSearchTerms() { + guard let selectedDate = selectedDate, + let selectedPeriod = selectedPeriod else { + return + } + ActionDispatcher.dispatch(PeriodAction.refreshSearchTerms(date: selectedDate, period: selectedPeriod)) + } + + func refreshVideos() { + guard let selectedDate = selectedDate, + let selectedPeriod = selectedPeriod else { + return + } + ActionDispatcher.dispatch(PeriodAction.refreshVideos(date: selectedDate, period: selectedPeriod)) + } + + func refreshClicks() { + guard let selectedDate = selectedDate, + let selectedPeriod = selectedPeriod else { + return + } + ActionDispatcher.dispatch(PeriodAction.refreshClicks(date: selectedDate, period: selectedPeriod)) + } + + func refreshAuthors() { + guard let selectedDate = selectedDate, + let selectedPeriod = selectedPeriod else { + return + } + ActionDispatcher.dispatch(PeriodAction.refreshAuthors(date: selectedDate, period: selectedPeriod)) + } + + func refreshReferrers() { + guard let selectedDate = selectedDate, + let selectedPeriod = selectedPeriod else { + return + } + ActionDispatcher.dispatch(PeriodAction.refreshReferrers(date: selectedDate, period: selectedPeriod)) + } + + func refreshCountries() { + guard let selectedDate = selectedDate, + let selectedPeriod = selectedPeriod else { + return + } + ActionDispatcher.dispatch(PeriodAction.refreshCountries(date: selectedDate, period: selectedPeriod)) + } + + func refreshPublished() { + guard let selectedDate = selectedDate, + let selectedPeriod = selectedPeriod else { + return + } + ActionDispatcher.dispatch(PeriodAction.refreshPublished(date: selectedDate, period: selectedPeriod)) + } + + func refreshFileDownloads() { + guard let selectedDate = selectedDate, + let selectedPeriod = selectedPeriod else { + return + } + ActionDispatcher.dispatch(PeriodAction.refreshFileDownloads(date: selectedDate, period: selectedPeriod)) + } + + func refreshPostStats() { + guard let postID = postID else { + return + } + + ActionDispatcher.dispatch(PeriodAction.refreshPostStats(postID: postID)) + } + + // MARK: - Views & Visitors + + func updateViewsAndVisitorsSegment(_ selectedSegment: StatsSegmentedControlData.Segment) { + selectedViewsVisitorsSegment = selectedSegment + } +} + +// MARK: - Private Extension + +private extension SiteStatsInsightsDetailsViewModel { + + // MARK: - Store Queries + + func queryForInsightStatSection(_ statSection: StatSection) -> InsightQuery? { + switch statSection { + case .insightsFollowersWordPress, .insightsFollowersEmail, .insightsFollowerTotals: + return .insights // use .insights here which is same as top level insights screen + case .insightsCommentsAuthors, .insightsCommentsPosts, .insightsCommentsTotals: + return .allComments + case .insightsTagsAndCategories: + return .allTagsAndCategories + case .insightsAnnualSiteStats: + return .allAnnual + default: + return nil + } + } + + func queryForPeriodStatSection(_ statSection: StatSection) -> PeriodQuery? { + + guard let selectedDate = selectedDate, + let selectedPeriod = selectedPeriod else { + return nil + } + + switch statSection { + case .periodPostsAndPages: + return .allPostsAndPages(date: selectedDate, period: selectedPeriod) + case .periodSearchTerms: + return .allSearchTerms(date: selectedDate, period: selectedPeriod) + case .periodVideos: + return .allVideos(date: selectedDate, period: selectedPeriod) + case .periodClicks: + return .allClicks(date: selectedDate, period: selectedPeriod) + case .periodAuthors: + return .allAuthors(date: selectedDate, period: selectedPeriod) + case .periodReferrers: + return .allReferrers(date: selectedDate, period: selectedPeriod) + case .periodCountries: + return .allCountries(date: selectedDate, period: selectedPeriod) + case .periodPublished: + return .allPublished(date: selectedDate, period: selectedPeriod) + case .periodFileDownloads: + return .allFileDownloads(date: selectedDate, period: selectedPeriod) + case .insightsViewsVisitors, .insightsLikesTotals: + return .periods(date: selectedDate, period: selectedPeriod) + default: + return nil + } + } + + // MARK: - Tabbed Cards + + func tabbedRowsFrom(_ commentsRowData: [StatsTotalRowData]) -> [DetailDataRow] { + return dataRowsFor(commentsRowData) + } + + func tabDataForFollowerType(_ followerType: StatSection) -> TabData { + let tabTitle = followerType.tabTitle + var followers: [StatsFollower] = [] + var totalFollowers: Int? + + switch followerType { + case .insightsFollowersWordPress: + followers = insightsStore.getDotComFollowers()?.topDotComFollowers ?? [] + totalFollowers = insightsStore.getDotComFollowers()?.dotComFollowersCount + case .insightsFollowersEmail: + followers = insightsStore.getEmailFollowers()?.topEmailFollowers ?? [] + totalFollowers = insightsStore.getEmailFollowers()?.emailFollowersCount + default: + break + } + + let totalCount = String(format: followerType.totalFollowers, (totalFollowers ?? 0).abbreviatedString()) + + let followersData = followers.compactMap { + return StatsTotalRowData(name: $0.name, + data: $0.subscribedDate.relativeStringInPast(), + userIconURL: $0.avatarURL, + statSection: followerType) + } + + return TabData(tabTitle: tabTitle, + itemSubtitle: "", + dataSubtitle: "", + totalCount: totalCount, + dataRows: followersData) + } + + func tabDataForCommentType(_ commentType: StatSection) -> TabData { + let commentsInsight = insightsStore.getAllCommentsInsight() + + var rowItems: [StatsTotalRowData] = [] + + switch commentType { + case .insightsCommentsAuthors: + let authors = commentsInsight?.topAuthors ?? [] + rowItems = authors.map { + StatsTotalRowData(name: $0.name, + data: $0.commentCount.abbreviatedString(), + userIconURL: $0.iconURL, + showDisclosure: false, + statSection: .insightsCommentsAuthors) + } + case .insightsCommentsPosts: + let posts = commentsInsight?.topPosts ?? [] + rowItems = posts.map { + StatsTotalRowData(name: $0.name, + data: $0.commentCount.abbreviatedString(), + showDisclosure: true, + disclosureURL: $0.postURL, + statSection: .insightsCommentsPosts) + } + default: + break + } + + return TabData(tabTitle: commentType.tabTitle, + itemSubtitle: commentType.itemSubtitle, + dataSubtitle: commentType.dataSubtitle, + dataRows: rowItems) + } + + // MARK: - Tags and Categories + + func tagsAndCategoriesRows() -> [ImmuTableRow] { + return expandableDataRowsFor(tagsAndCategoriesRowData()) + } + + func tagsAndCategoriesRowData() -> [StatsTotalRowData] { + guard let tagsAndCategories = insightsStore.getAllTagsAndCategories()?.topTagsAndCategories else { + return [] + } + + return tagsAndCategories.map { + let viewsCount = $0.viewsCount ?? 0 + + return StatsTotalRowData(name: $0.name, + data: viewsCount.abbreviatedString(), + dataBarPercent: Float(viewsCount) / Float(tagsAndCategories.first?.viewsCount ?? 1), + icon: StatsDataHelper.tagsAndCategoriesIconForKind($0.kind), + showDisclosure: true, + disclosureURL: $0.url, + childRows: StatsDataHelper.childRowsForItems($0.children), + statSection: .insightsTagsAndCategories) + } + } + + // MARK: - Annual Site Stats + + func annualRows() -> [DetailDataRow] { + return dataRowsFor(annualRowData()) + } + + func annualRowData() -> [StatsTotalRowData] { + + guard let selectedDate = selectedDate else { + return [] + } + + allAnnualInsights = insightsStore.getAllAnnual()?.allAnnualInsights ?? [] + let selectedYear = Calendar.current.component(.year, from: selectedDate) + let selectedYearInsights = allAnnualInsights.first { $0.year == selectedYear } + + guard let annualInsights = selectedYearInsights else { + return [] + } + + return [StatsTotalRowData(name: AnnualSiteStats.totalPosts, + data: annualInsights.totalPostsCount.abbreviatedString()), + StatsTotalRowData(name: AnnualSiteStats.totalComments, + data: annualInsights.totalCommentsCount.abbreviatedString()), + StatsTotalRowData(name: AnnualSiteStats.commentsPerPost, + data: Int(round(annualInsights.averageCommentsCount)).abbreviatedString()), + StatsTotalRowData(name: AnnualSiteStats.totalLikes, + data: annualInsights.totalLikesCount.abbreviatedString()), + StatsTotalRowData(name: AnnualSiteStats.likesPerPost, + data: Int(round(annualInsights.averageLikesCount)).abbreviatedString()), + StatsTotalRowData(name: AnnualSiteStats.totalWords, + data: annualInsights.totalWordsCount.abbreviatedString()), + StatsTotalRowData(name: AnnualSiteStats.wordsPerPost, + data: Int(round(annualInsights.averageWordsCount)).abbreviatedString())] + } + + // MARK: - Posts and Pages + + func postsAndPagesRows(for status: StoreFetchingStatus) -> [DetailDataRow] { + return dataRowsFor(postsAndPagesRowData(topPostsAndPages: periodStore.getTopPostsAndPages()), status: status) + } + + func postsAndPagesRowData(topPostsAndPages: StatsTopPostsTimeIntervalData?) -> [StatsTotalRowData] { + let postsAndPages = topPostsAndPages?.topPosts ?? [] + + return postsAndPages.map { + let icon: UIImage? + + switch $0.kind { + case .homepage: + icon = Style.imageForGridiconType(.house, withTint: .icon) + case .page: + icon = Style.imageForGridiconType(.pages, withTint: .icon) + case .post: + icon = Style.imageForGridiconType(.posts, withTint: .icon) + case .unknown: + icon = Style.imageForGridiconType(.posts, withTint: .icon) + } + + return StatsTotalRowData(name: $0.title, + data: $0.viewsCount.abbreviatedString(), + postID: $0.postID, + dataBarPercent: Float($0.viewsCount) / Float(postsAndPages.first!.viewsCount), + icon: icon, + showDisclosure: true, + disclosureURL: $0.postURL, + statSection: .periodPostsAndPages) + } + } + + // MARK: - Search Terms + + func searchTermsRows(for status: StoreFetchingStatus) -> [DetailDataRow] { + return dataRowsFor(searchTermsRowData(), status: status) + } + + func searchTermsRowData() -> [StatsTotalRowData] { + guard let searchTerms = periodStore.getTopSearchTerms() else { + return [] + } + + + var mappedSearchTerms = searchTerms.searchTerms.map { StatsTotalRowData(name: $0.term, + data: $0.viewsCount.abbreviatedString(), + statSection: .periodSearchTerms) } + + if !mappedSearchTerms.isEmpty && searchTerms.hiddenSearchTermsCount > 0 { + // We want to insert the "Unknown search terms" item only if there's anything to show in the first place — if the + // section is empty, it doesn't make sense to insert it here. + + let unknownSearchTerm = StatsTotalRowData(name: NSLocalizedString("Unknown search terms", + comment: "Search Terms label for 'unknown search terms'."), + data: searchTerms.hiddenSearchTermsCount.abbreviatedString(), + statSection: .periodSearchTerms) + + mappedSearchTerms.insert(unknownSearchTerm, at: 0) + } + + return mappedSearchTerms + } + + // MARK: - Videos + + func videosRows(for status: StoreFetchingStatus) -> [DetailDataRow] { + return dataRowsFor(videosRowData(), status: status) + } + + func videosRowData() -> [StatsTotalRowData] { + return periodStore.getTopVideos()?.videos.map { StatsTotalRowData(name: $0.title, + data: $0.playsCount.abbreviatedString(), + mediaID: $0.postID as NSNumber, + icon: Style.imageForGridiconType(.video), + showDisclosure: true, + statSection: .periodVideos) } + ?? [] + } + + // MARK: - Clicks + + func clicksRows(for status: StoreFetchingStatus) -> [ImmuTableRow] { + return expandableDataRowsFor(clicksRowData(), status: status) + } + + func clicksRowData() -> [StatsTotalRowData] { + return periodStore.getTopClicks()?.clicks.map { + StatsTotalRowData(name: $0.title, + data: $0.clicksCount.abbreviatedString(), + showDisclosure: true, + disclosureURL: $0.iconURL, + childRows: $0.children.map { StatsTotalRowData(name: $0.title, + data: $0.clicksCount.abbreviatedString(), + showDisclosure: true, + disclosureURL: $0.clickedURL) }, + statSection: .periodClicks) + } ?? [] + } + + // MARK: - Authors + + func authorsRows(for status: StoreFetchingStatus) -> [ImmuTableRow] { + return expandableDataRowsFor(authorsRowData(), status: status) + } + + func authorsRowData() -> [StatsTotalRowData] { + let authors = periodStore.getTopAuthors()?.topAuthors ?? [] + + return authors.map { + StatsTotalRowData(name: $0.name, + data: $0.viewsCount.abbreviatedString(), + dataBarPercent: Float($0.viewsCount) / Float(authors.first!.viewsCount), + userIconURL: $0.iconURL, + showDisclosure: true, + childRows: $0.posts.map { StatsTotalRowData(name: $0.title, + data: $0.viewsCount.abbreviatedString(), + statSection: .periodAuthors) }, + statSection: .periodAuthors) + } + } + + // MARK: - Referrers + + func referrersRows(for status: StoreFetchingStatus) -> [ImmuTableRow] { + return expandableDataRowsFor(referrersRowData(topReferrers: periodStore.getTopReferrers()), status: status) + } + + func referrersRowData(topReferrers: StatsTopReferrersTimeIntervalData?) -> [StatsTotalRowData] { + let referrers = topReferrers?.referrers ?? [] + + func rowDataFromReferrer(referrer: StatsReferrer) -> StatsTotalRowData { + var icon: UIImage? = nil + var iconURL: URL? = nil + + switch referrer.iconURL?.lastPathComponent { + case "search-engine.png": + icon = Style.imageForGridiconType(.search) + case nil: + icon = Style.imageForGridiconType(.globe) + default: + iconURL = referrer.iconURL + } + + return StatsTotalRowData(name: referrer.title, + data: referrer.viewsCount.abbreviatedString(), + icon: icon, + socialIconURL: iconURL, + showDisclosure: true, + disclosureURL: referrer.url, + childRows: referrer.children.map { rowDataFromReferrer(referrer: $0) }, + statSection: .periodReferrers, + isReferrerSpam: referrer.isSpam) + } + + return referrers.map { rowDataFromReferrer(referrer: $0) } + } + + // MARK: - Followers + func followersRowData(dotComFollowersCount: Int, emailFollowersCount: Int, othersCount: Int, totalCount: Int) -> [StatsTotalRowData] { + var rowData = [StatsTotalRowData]() + + rowData.append( + StatsTotalRowData(name: StatSection.insightsFollowersWordPress.tabTitle, + data: "\(dotComFollowersCount.abbreviatedString()) (\(roundedPercentage(numerator: dotComFollowersCount, denominator: totalCount))%)", + statSection: .insightsFollowersWordPress) + ) + + rowData.append( + StatsTotalRowData(name: StatSection.insightsFollowersEmail.tabTitle, + data: "\(emailFollowersCount.abbreviatedString()) (\(roundedPercentage(numerator: emailFollowersCount, denominator: totalCount))%)", + statSection: .insightsFollowersEmail) + ) + + rowData.append( + StatsTotalRowData(name: StatSection.insightsPublicize.tabTitle, + data: "\(othersCount.abbreviatedString()) (\(roundedPercentage(numerator: othersCount, denominator: totalCount))%)", + statSection: .insightsFollowersWordPress) + ) + + return rowData + } + + // MARK: - Countries + + func countriesRows(for status: StoreFetchingStatus) -> [DetailDataRow] { + return dataRowsFor(countriesRowData(topCountries: periodStore.getTopCountries()), status: status) + } + + func countriesRowData(topCountries: StatsTopCountryTimeIntervalData?) -> [StatsTotalRowData] { + return topCountries?.countries.map { StatsTotalRowData(name: $0.name, + data: $0.viewsCount.abbreviatedString(), + icon: UIImage(named: $0.code), + statSection: .periodCountries) } + ?? [] + } + + func countriesMap(topCountries: StatsTopCountryTimeIntervalData?) -> CountriesMap { + let countries = topCountries?.countries ?? [] + return CountriesMap(minViewsCount: countries.last?.viewsCount ?? 0, + maxViewsCount: countries.first?.viewsCount ?? 0, + data: countries.reduce([String: NSNumber]()) { (dict, country) in + var nextDict = dict + nextDict.updateValue(NSNumber(value: country.viewsCount), forKey: country.code) + return nextDict + }) + } + + // MARK: - Published + + func publishedRows(for status: StoreFetchingStatus) -> [ DetailDataRow] { + return dataRowsFor(publishedRowData(), status: status) + } + + func publishedRowData() -> [StatsTotalRowData] { + return periodStore.getTopPublished()?.publishedPosts.map { StatsTotalRowData(name: $0.title.stringByDecodingXMLCharacters(), + data: "", + showDisclosure: true, + disclosureURL: $0.postURL, + statSection: .periodPublished) } + ?? [] + } + + // MARK: - File Downloads + + func fileDownloadsRows(for status: StoreFetchingStatus) -> [DetailDataRow] { + return dataRowsFor(fileDownloadsRowData(), status: status) + } + + func fileDownloadsRowData() -> [StatsTotalRowData] { + return periodStore.getTopFileDownloads()?.fileDownloads.map { StatsTotalRowData(name: $0.file, + data: $0.downloadCount.abbreviatedString(), + statSection: .periodFileDownloads) } + ?? [] + } + + // MARK: - Post Stats + + func postStatsRows(forAverages: Bool = false, status: StoreFetchingStatus) -> [ImmuTableRow] { + return expandableDataRowsFor(postStatsRowData(forAverages: forAverages), status: status) + } + + func postStatsRowData(forAverages: Bool) -> [StatsTotalRowData] { + let postStats = periodStore.getPostStats(for: postID) + + guard let yearsData = (forAverages ? postStats?.dailyAveragesPerMonth : postStats?.monthlyBreakdown), + let minYear = StatsDataHelper.minYearFrom(yearsData: yearsData), + let maxYear = StatsDataHelper.maxYearFrom(yearsData: yearsData) else { + return [] + } + + var yearRows = [StatsTotalRowData]() + + // Create Year rows in descending order + for year in (minYear...maxYear).reversed() { + let months = StatsDataHelper.monthsFrom(yearsData: yearsData, forYear: year) + let yearTotalViews = StatsDataHelper.totalViewsFrom(monthsData: months) + + let rowValue: Int = { + if forAverages { + return months.count > 0 ? (yearTotalViews / months.count) : 0 + } + return yearTotalViews + }() + + if rowValue > 0 { + yearRows.append(StatsTotalRowData(name: String(year), + data: rowValue.abbreviatedString(), + showDisclosure: true, + childRows: StatsDataHelper.childRowsForYear(months), + statSection: forAverages ? .postStatsAverageViews : .postStatsMonthsYears)) + } + } + + return yearRows + } + + // MARK: - Helpers + + func dataRowsFor(_ rowsData: [StatsTotalRowData], status: StoreFetchingStatus = .idle) -> [DetailDataRow] { + var detailDataRows = [DetailDataRow]() + + for (idx, rowData) in rowsData.enumerated() { + let isLastRow = idx == rowsData.endIndex-1 && status != .loading + detailDataRows.append(DetailDataRow(rowData: rowData, + detailsDelegate: detailsDelegate, + hideIndentedSeparator: isLastRow, + hideFullSeparator: !isLastRow)) + } + + return detailDataRows + } + + func expandableDataRowsFor(_ rowsData: [StatsTotalRowData], status: StoreFetchingStatus = .idle) -> [ImmuTableRow] { + var detailDataRows = [ImmuTableRow]() + + for (idx, rowData) in rowsData.enumerated() { + + // Expanded state of current row + let expanded = rowExpanded(rowData) + + // Expanded state of next row + let nextExpanded: Bool = { + let nextIndex = idx + 1 + if nextIndex < rowsData.count { + return rowExpanded(rowsData[nextIndex]) + } + return false + }() + + let isLastRow = idx == rowsData.endIndex-1 && status != .loading + + // Toggle the indented separator line based on expanded states. + // If the current row is expanded, hide the separator. + // If the current row is not expanded, hide the separator if the next row is. + let hideIndentedSeparator = expanded ? (expanded || isLastRow) : (nextExpanded || isLastRow) + + // Add top level parent row + detailDataRows.append(parentRow(rowData: rowData, + hideIndentedSeparator: hideIndentedSeparator, + hideFullSeparator: !isLastRow, + expanded: expanded)) + + // Continue to next parent if not expanded. + guard expanded, let childRowsData = rowData.childRows else { + continue + } + + // Add child rows + for (idx, childRowData) in childRowsData.enumerated() { + let isLastRow = idx == childRowsData.endIndex-1 + + // If this is the last child row, toggle the full separator based on + // next parent's expanded state to prevent duplicate lines. + let hideFullSeparator = isLastRow ? nextExpanded : true + + // If the parent row has an icon, show the image view for the child + // to make the child row appear "indented". + let showImage = rowData.hasIcon + + let grandChildRowsData = childRowData.childRows ?? [] + + // If this child has no children, add it as a child row. + guard !grandChildRowsData.isEmpty else { + detailDataRows.append(childRow(rowData: childRowData, + hideFullSeparator: hideFullSeparator, + showImage: showImage)) + continue + } + + let childExpanded = rowExpanded(childRowData) + + // If this child has children, add it as a parent row. + detailDataRows.append(parentRow(rowData: childRowData, + hideIndentedSeparator: true, + hideFullSeparator: !isLastRow, + expanded: childExpanded)) + + // If this child is not expanded, continue to next. + guard childExpanded else { + continue + } + + // Expanded state of next child row + let nextChildExpanded: Bool = { + let nextIndex = idx + 1 + if nextIndex < childRowsData.count { + return rowExpanded(childRowsData[nextIndex]) + } + return false + }() + + // Add grandchild rows + for (idx, grandChildRowData) in grandChildRowsData.enumerated() { + + // If this is the last grandchild row, toggle the full separator based on + // next child's expanded state to prevent duplicate lines. + let hideFullSeparator = (idx == grandChildRowsData.endIndex-1) ? nextChildExpanded : true + + detailDataRows.append(childRow(rowData: grandChildRowData, + hideFullSeparator: hideFullSeparator, + showImage: showImage)) + } + } + } + + return detailDataRows + } + + func childRow(rowData: StatsTotalRowData, hideFullSeparator: Bool, showImage: Bool) -> DetailExpandableChildRow { + return DetailExpandableChildRow(rowData: rowData, + detailsDelegate: detailsDelegate, + hideIndentedSeparator: true, + hideFullSeparator: hideFullSeparator, + showImage: showImage) + + } + + func parentRow(rowData: StatsTotalRowData, hideIndentedSeparator: Bool, hideFullSeparator: Bool, expanded: Bool) -> DetailExpandableRow { + return DetailExpandableRow(rowData: rowData, + detailsDelegate: detailsDelegate, + referrerDelegate: referrerDelegate, + hideIndentedSeparator: hideIndentedSeparator, + hideFullSeparator: hideFullSeparator, + expanded: expanded) + } + + func rowExpanded(_ rowData: StatsTotalRowData) -> Bool { + guard let statSection = rowData.statSection else { + return false + } + return StatsDataHelper.expandedRowLabelsDetails[statSection]?.contains(rowData.name) ?? false + } + + func insightsImmuTable(for row: (type: InsightType, status: StoreFetchingStatus), rowsBlock: () -> [ImmuTableRow]) -> ImmuTable { + if insightsStore.containsCachedData(for: row.type) { + let sections = rowsBlock().map({ ImmuTableSection(rows: [$0]) }) + return ImmuTable(sections: sections) + } + + var rows = [ImmuTableRow]() + + switch row.status { + case .loading, .idle: + rows.append(contentsOf: getGhostSequence()) + case .success: + rows.append(contentsOf: rowsBlock()) + case .error: + break + } + + var sections: [ImmuTableSection] = [] + var ghostRows: [ImmuTableRow] = [] + + rows.forEach({ row in + if row is StatsGhostTopHeaderImmutableRow || row is StatsGhostDetailRow { + ghostRows.append(row) + } else { + sections.append(ImmuTableSection(rows: [row])) + } + }) + + let ghostSection = ImmuTableSection(rows: ghostRows) + sections.append(ghostSection) + return ImmuTable(sections: sections) + } + + func periodImmuTable(for status: StoreFetchingStatus, + rowsBlock: (StoreFetchingStatus) -> [ImmuTableRow] + ) -> ImmuTable { + var rows = [ImmuTableRow]() + + switch status { + case .loading, .idle: + let content = rowsBlock(status) + + // Check if the content has more than 1 row + if content.count <= Constants.Sequence.minRowCount { + rows.append(contentsOf: getGhostSequence()) + } else { + rows.append(contentsOf: content) + rows.append(StatsGhostDetailRow(hideTopBorder: true, + isLastRow: true, + enableTopPadding: true)) + } + case .success: + rows.append(contentsOf: rowsBlock(status)) + case .error: + break + } + + var countriesRows: [ImmuTableRow] = [] + var sections: [ImmuTableSection] = [] + + rows.forEach({ row in + if row is CountriesMapRow || row is CountriesStatsRow { + countriesRows.append(row) + } else { + sections.append(ImmuTableSection(rows: [row])) + } + + }) + let countriesSection = ImmuTableSection(rows: countriesRows) + sections.append(countriesSection) + return ImmuTable(sections: sections) + } + + func getGhostSequence() -> [ImmuTableRow] { + var rows = [ImmuTableRow]() + rows.append(StatsGhostTopHeaderImmutableRow()) + rows.append(contentsOf: (Constants.Sequence.rows).map { index in + let isLastRow = index == Constants.Sequence.maxRowCount + return StatsGhostDetailRow(hideTopBorder: true, + isLastRow: isLastRow, + enableTopPadding: true) + }) + return rows + } + + func roundedPercentage(numerator: Int, denominator: Int) -> Int { + var roundedPercentage = 0 + + if denominator > 0 { + let percentage = (Float(numerator) / Float(denominator)) * 100 + roundedPercentage = Int(round(percentage)) + } + + return roundedPercentage + } + + enum Constants { + enum Sequence { + static let minRowCount = 1 + static let maxRowCount = 5 + static let rows = 0...maxRowCount + } + } + + // Return the future end of the week date if current period end date is not an end of the week + func futureEndOfWeekDate(for summary: StatsSummaryTimeIntervalData?) -> Date? { + guard let summary = summary else { + return nil + } + + /// When selectedDate is < end of the week we pad forward days to match the weeks view on WordPress.com + let week = StatsPeriodHelper().weekIncludingDate(summary.periodEndDate) + + if let weekEnd = week?.weekEnd, weekEnd > summary.periodEndDate { + return weekEnd + } else { + return nil + } + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/StatsFollowersChartViewModel.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/StatsFollowersChartViewModel.swift new file mode 100644 index 000000000000..eb7d42acf9c8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/StatsFollowersChartViewModel.swift @@ -0,0 +1,55 @@ +import Foundation + +struct StatsFollowersChartViewModel { + + public let dotComFollowersCount: Int + public let emailFollowersCount: Int + public let publicizeCount: Int + + func makeFollowersChartView() -> UIView { + // The followers chart currently shows 3 segments. If available, it will show: + // - WordPress.com followers + // - Email followers + // - Social + + let chartView = DonutChartView() + chartView.configure(title: "", totalCount: CGFloat(totalCount()), segments: segments()) + chartView.translatesAutoresizingMaskIntoConstraints = false + chartView.heightAnchor.constraint(greaterThanOrEqualToConstant: Constants.chartHeight).isActive = true + return chartView + } + + internal func totalCount() -> Int { + return dotComFollowersCount + emailFollowersCount + publicizeCount + } + + internal func segments() -> [DonutChartView.Segment] { + var segments: [DonutChartView.Segment] = [] + segments.append(segmentWith(title: Constants.wpComGroupTitle, count: dotComFollowersCount, color: Constants.wpComColor)) + segments.append(segmentWith(title: Constants.emailGroupTitle, count: emailFollowersCount, color: Constants.emailColor)) + segments.append(segmentWith(title: Constants.socialGroupTitle, count: publicizeCount, color: Constants.socialColor)) + return segments + } + + internal func segmentWith(title: String, count: Int, color: UIColor) -> DonutChartView.Segment { + return DonutChartView.Segment( + title: title, + value: CGFloat(count), + color: color + ) + } + + private enum Constants { + static let wpComGroupTitle = NSLocalizedString("WordPress", comment: "Title of Stats section that shows WordPress.com followers.") + static let emailGroupTitle = NSLocalizedString("Email", comment: "Title of Stats section that shows email followers.") + static let socialGroupTitle = NSLocalizedString("Social", comment: "Title of Stats section that shows social followers.") + + static let followersMaxGroupCount = 3 + + static let wpComColor: UIColor = .muriel(name: .blue, .shade50) + static let emailColor: UIColor = .muriel(name: .blue, .shade5) + static let socialColor: UIColor = .muriel(name: .orange, .shade30) + + static let chartHeight: CGFloat = 231.0 + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/StatsReferrersChartViewModel.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/StatsReferrersChartViewModel.swift new file mode 100644 index 000000000000..2242c1d36641 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Stats Detail/StatsReferrersChartViewModel.swift @@ -0,0 +1,82 @@ +import Foundation + +struct StatsReferrersChartViewModel { + let referrers: StatsTopReferrersTimeIntervalData + + func makeReferrersChartView() -> UIView { + // The referrers chart currently shows 3 segments. If available, it will show: + // - WordPress.com Reader + // - Search + // - Other + // Unfortunately due to the results returned by the API this just has to be checked + // based on the title of the group, so it's really only possible for English-speaking users. + // When we can't find a WordPress.com Reader or Search Engines group, we'll just use the top + // two groups with the highest referrers count. + + var topReferrers: [StatsReferrer] = [] + var allReferrers = referrers.referrers + + // First, find the WordPress.com and Search groups if we can + if let wpIndex = allReferrers.firstIndex(where: { $0.title.contains(Constants.wpComReferrerGroupTitle) }) { + topReferrers.append(allReferrers[wpIndex]) + allReferrers.remove(at: wpIndex) + } + + if let searchIndex = allReferrers.firstIndex(where: { $0.title.contains(Constants.searchEnginesReferrerGroupTitle) }) { + topReferrers.append(allReferrers[searchIndex]) + allReferrers.remove(at: searchIndex) + } + + // Then add groups from the top of the list to make up our target group count + while topReferrers.count < (Constants.referrersMaxGroupCount-1) && allReferrers.count > 0 { + topReferrers.append(allReferrers.removeFirst()) + } + + // Create segments for each referrer + var segments = topReferrers.enumerated().map({ index, item in + return DonutChartView.Segment( + title: Constants.referrersTitlesMap[item.title] ?? item.title, + value: CGFloat(item.viewsCount), + color: Constants.referrersSegmentColors[index] + ) + }) + + // Create a segment for all remaining referrers – "Other" + let otherCount = allReferrers.map({ $0.viewsCount }).reduce(0, +) + referrers.otherReferrerViewsCount + let otherSegment = DonutChartView.Segment( + title: Constants.otherReferrerGroupTitle, + value: CGFloat(otherCount), + color: Constants.referrersSegmentColors.last! + ) + segments.append(otherSegment) + + let chartView = DonutChartView() + chartView.configure(title: Constants.chartTitle, totalCount: CGFloat(referrers.totalReferrerViewsCount), segments: segments) + chartView.translatesAutoresizingMaskIntoConstraints = false + chartView.heightAnchor.constraint(greaterThanOrEqualToConstant: Constants.chartHeight).isActive = true + return chartView + } + + private enum Constants { + // Referrers + // These first two titles are not localized as they're used for string matching against the API response. + static let wpComReferrerGroupTitle = "WordPress.com Reader" + static let searchEnginesReferrerGroupTitle = "Search Engines" + static let otherReferrerGroupTitle = NSLocalizedString("Other", comment: "Title of Stats section that shows referrer traffic from other sources.") + static let chartTitle = NSLocalizedString("Views", comment: "Title for chart showing site views from various referrer sources.") + + static let referrersMaxGroupCount = 3 + static let referrersSegmentColors: [UIColor] = [ + .muriel(name: .blue, .shade80), + .muriel(name: .blue, .shade50), + .muriel(name: .blue, .shade5) + ] + + static let referrersTitlesMap = [ + "WordPress.com Reader": "WordPress", + "Search Engines": NSLocalizedString("Search", comment: "Title of Stats section that shows search engine referrer traffic.") + ] + + static let chartHeight: CGFloat = 231.0 + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsStackViewCell.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsStackViewCell.swift index 613ffb17ffa8..4e0ecea649ec 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsStackViewCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsStackViewCell.swift @@ -1,10 +1,12 @@ -class StatsStackViewCell: UITableViewCell, NibLoadable { +class StatsStackViewCell: StatsBaseCell, NibLoadable { private typealias Style = WPStyleGuide.Stats @IBOutlet private(set) var stackView: UIStackView! { didSet { - contentView.addTopBorder(withColor: Style.separatorColor) - contentView.addBottomBorder(withColor: Style.separatorColor) + if FeatureFlag.statsNewAppearance.disabled { + contentView.addTopBorder(withColor: Style.separatorColor) + contentView.addBottomBorder(withColor: Style.separatorColor) + } } } diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsStackViewCell.xib b/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsStackViewCell.xib index 4b61028bb99f..14b846f42806 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsStackViewCell.xib +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsStackViewCell.xib @@ -1,11 +1,9 @@ - - - - + + - + @@ -16,11 +14,11 @@ - + - + @@ -33,7 +31,9 @@ + + diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsTotalRow.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsTotalRow.swift index 8c43268507a3..6332a498df9f 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsTotalRow.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/StatsTotalRow.swift @@ -14,6 +14,7 @@ struct StatsTotalRowData { var disclosureURL: URL? var childRows: [StatsTotalRowData]? var statSection: StatSection? + var isReferrerSpam: Bool init(name: String, data: String, @@ -27,7 +28,8 @@ struct StatsTotalRowData { showDisclosure: Bool = false, disclosureURL: URL? = nil, childRows: [StatsTotalRowData]? = [StatsTotalRowData](), - statSection: StatSection? = nil) { + statSection: StatSection? = nil, + isReferrerSpam: Bool = false) { self.name = name self.data = data self.mediaID = mediaID @@ -41,11 +43,20 @@ struct StatsTotalRowData { self.disclosureURL = disclosureURL self.childRows = childRows self.statSection = statSection + self.isReferrerSpam = isReferrerSpam } var hasIcon: Bool { return self.icon != nil || self.socialIconURL != nil || self.userIconURL != nil } + + var canMarkReferrerAsSpam: Bool { + if let url = disclosureURL { + return url.absoluteString.contains(name) + } else { + return name.contains(".") + } + } } @objc protocol StatsTotalRowDelegate { @@ -56,6 +67,10 @@ struct StatsTotalRowData { @objc optional func showAddInsight() } +protocol StatsTotalRowReferrerDelegate: AnyObject { + func showReferrerDetails(_ data: StatsTotalRowData) +} + class StatsTotalRow: UIView, NibLoadable, Accessible { // MARK: - Properties @@ -92,6 +107,7 @@ class StatsTotalRow: UIView, NibLoadable, Accessible { private var dataBarMaxTrailing: Float = 0.0 private typealias Style = WPStyleGuide.Stats private weak var delegate: StatsTotalRowDelegate? + private weak var referrerDelegate: StatsTotalRowReferrerDelegate? private var forDetails = false private(set) weak var parentRow: StatsTotalRow? @@ -121,7 +137,7 @@ class StatsTotalRow: UIView, NibLoadable, Accessible { var expanded: Bool = false { didSet { - guard hasChildRows else { + guard hasChildRows, rowData?.statSection != .periodReferrers else { return } @@ -145,10 +161,12 @@ class StatsTotalRow: UIView, NibLoadable, Accessible { func configure(rowData: StatsTotalRowData, delegate: StatsTotalRowDelegate? = nil, + referrerDelegate: StatsTotalRowReferrerDelegate? = nil, forDetails: Bool = false, parentRow: StatsTotalRow? = nil) { self.rowData = rowData self.delegate = delegate + self.referrerDelegate = referrerDelegate self.forDetails = forDetails self.parentRow = parentRow @@ -249,7 +267,7 @@ private extension StatsTotalRow { } func configureDetailDisclosure() { - guard hasChildRows, forDetails else { + guard hasChildRows, forDetails, rowData?.statSection != .periodReferrers else { return } @@ -275,7 +293,6 @@ private extension StatsTotalRow { } func configureIcon() { - guard let rowData = rowData else { return } @@ -328,7 +345,6 @@ private extension StatsTotalRow { } func configureDataBar() { - guard let dataBarPercent = rowData?.dataBarPercent else { dataBarView.isHidden = true return @@ -360,8 +376,8 @@ private extension StatsTotalRow { } @IBAction func didTapDisclosureButton(_ sender: UIButton) { - - if let statSection = rowData?.statSection { + if let statSection = rowData?.statSection, + statSection != .insightsAddInsight { captureAnalyticsEventsFor(statSection) } @@ -370,16 +386,23 @@ private extension StatsTotalRow { return } + if rowData?.statSection == .periodReferrers, let data = rowData { + if !data.canMarkReferrerAsSpam, !hasChildRows, let url = data.disclosureURL { + delegate?.displayWebViewWithURL?(url) + } else { + referrerDelegate?.showReferrerDetails(data) + } + return + } + if hasChildRows { - expanded.toggle() - prepareForVoiceOver() - delegate?.toggleChildRows?(for: self, didSelectRow: true) + toggleExpandedState() return } if let disclosureURL = rowData?.disclosureURL { if let statSection = rowData?.statSection, - statSection == .periodPostsAndPages { + statSection == .periodPostsAndPages { guard let postID = rowData?.postID else { DDLogInfo("No postID available to show Post Stats.") return @@ -399,6 +422,12 @@ private extension StatsTotalRow { DDLogInfo("Stat row selection action not supported.") } + func toggleExpandedState() { + expanded.toggle() + prepareForVoiceOver() + delegate?.toggleChildRows?(for: self, didSelectRow: true) + } + func captureAnalyticsEventsFor(_ statSection: StatSection) { guard let event = statSection.analyticsItemTappedEvent else { return @@ -432,6 +461,10 @@ private extension StatSection { return .statsItemTappedVideoTapped case .insightsAddInsight: return .statsItemTappedInsightsAddStat + case .postStatsMonthsYears: + return .statsItemTappedPostStatsMonthsYears + case .postStatsRecentWeeks: + return .statsItemTappedPostStatsRecentWeeks default: return nil } diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/TopTotalsCell.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/TopTotalsCell.swift index b80001162d13..b2e3e24ca838 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/TopTotalsCell.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/TopTotalsCell.swift @@ -7,18 +7,28 @@ import UIKit /// If the row has child rows, those child rows are added to the stack view below the selected row. /// -class TopTotalsCell: UITableViewCell, NibLoadable { +class TopTotalsCell: StatsBaseCell, NibLoadable { // MARK: - Properties + @IBOutlet weak var outerStackView: UIStackView! @IBOutlet weak var subtitleStackView: UIStackView! @IBOutlet weak var rowsStackView: UIStackView! @IBOutlet weak var itemSubtitleLabel: UILabel! @IBOutlet weak var dataSubtitleLabel: UILabel! - @IBOutlet weak var subtitlesStackViewTopConstraint: NSLayoutConstraint! - @IBOutlet weak var rowsStackViewTopConstraint: NSLayoutConstraint! @IBOutlet weak var topSeparatorLine: UIView! @IBOutlet weak var bottomSeparatorLine: UIView! + private var topAccessoryView: UIView? = nil { + didSet { + oldValue?.removeFromSuperview() + + if let topAccessoryView = topAccessoryView { + outerStackView.insertArrangedSubview(topAccessoryView, at: 0) + topAccessoryView.layoutMargins = subtitleStackView.layoutMargins + outerStackView.setCustomSpacing(Metrics.topAccessoryViewSpacing, after: topAccessoryView) + } + } + } private var forDetails = false private var limitRowsDisplayed = true @@ -27,6 +37,7 @@ class TopTotalsCell: UITableViewCell, NibLoadable { private var subtitlesProvided = true private weak var siteStatsInsightsDelegate: SiteStatsInsightsDelegate? private weak var siteStatsPeriodDelegate: SiteStatsPeriodDelegate? + private weak var siteStatsReferrerDelegate: SiteStatsReferrerDelegate? private weak var siteStatsDetailsDelegate: SiteStatsDetailsDelegate? private weak var postStatsDelegate: PostStatsDelegate? private typealias Style = WPStyleGuide.Stats @@ -36,20 +47,26 @@ class TopTotalsCell: UITableViewCell, NibLoadable { func configure(itemSubtitle: String? = nil, dataSubtitle: String? = nil, dataRows: [StatsTotalRowData], + statSection: StatSection? = nil, siteStatsInsightsDelegate: SiteStatsInsightsDelegate? = nil, siteStatsPeriodDelegate: SiteStatsPeriodDelegate? = nil, + siteStatsReferrerDelegate: SiteStatsReferrerDelegate? = nil, siteStatsDetailsDelegate: SiteStatsDetailsDelegate? = nil, postStatsDelegate: PostStatsDelegate? = nil, + topAccessoryView: UIView? = nil, limitRowsDisplayed: Bool = true, forDetails: Bool = false) { itemSubtitleLabel.text = itemSubtitle dataSubtitleLabel.text = dataSubtitle subtitlesProvided = (itemSubtitle != nil && dataSubtitle != nil) self.dataRows = dataRows + self.statSection = statSection self.siteStatsInsightsDelegate = siteStatsInsightsDelegate self.siteStatsPeriodDelegate = siteStatsPeriodDelegate + self.siteStatsReferrerDelegate = siteStatsReferrerDelegate self.siteStatsDetailsDelegate = siteStatsDetailsDelegate self.postStatsDelegate = postStatsDelegate + self.topAccessoryView = topAccessoryView self.limitRowsDisplayed = limitRowsDisplayed self.forDetails = forDetails @@ -59,6 +76,7 @@ class TopTotalsCell: UITableViewCell, NibLoadable { forType: siteStatsPeriodDelegate != nil ? .period : .insights, limitRowsDisplayed: limitRowsDisplayed, rowDelegate: self, + referrerDelegate: self, viewMoreDelegate: self) initChildRows() @@ -87,6 +105,10 @@ class TopTotalsCell: UITableViewCell, NibLoadable { removeRowsFromStackView(rowsStackView) } + + private enum Metrics { + static let topAccessoryViewSpacing: CGFloat = 32.0 + } } // MARK: - Private Extension @@ -108,16 +130,23 @@ private extension TopTotalsCell { /// func setSubtitleVisibility() { subtitleStackView.layoutIfNeeded() - let subtitleHeight = subtitlesStackViewTopConstraint.constant * 2 + subtitleStackView.frame.height if forDetails { bottomSeparatorLine.isHidden = true - rowsStackViewTopConstraint.constant = subtitlesProvided ? subtitleHeight : 0 + + updateSubtitleConstraints(showSubtitles: subtitlesProvided) return } - let showSubtitles = !dataRows.isEmpty && subtitlesProvided - rowsStackViewTopConstraint.constant = showSubtitles ? subtitleHeight : 0 + updateSubtitleConstraints(showSubtitles: !dataRows.isEmpty && subtitlesProvided) + } + + private func updateSubtitleConstraints(showSubtitles: Bool) { + if showSubtitles { + subtitleStackView.isHidden = false + } else { + subtitleStackView.isHidden = true + } } // MARK: - Child Row Handling @@ -308,7 +337,14 @@ extension TopTotalsCell: StatsTotalRowDelegate { func showAddInsight() { siteStatsInsightsDelegate?.showAddInsight?() } +} + +// MARK: - StatsTotalRowReferrerDelegate +extension TopTotalsCell: StatsTotalRowReferrerDelegate { + func showReferrerDetails(_ data: StatsTotalRowData) { + siteStatsReferrerDelegate?.showReferrerDetails(data) + } } // MARK: - ViewMoreRowDelegate @@ -345,5 +381,9 @@ extension TopTotalsCell: Accessible { accessibilityLabel = String(format: descriptionFormat, title) } } + + if let sectionTitle = statSection?.title { + accessibilityLabel = "\(sectionTitle). \(accessibilityLabel ?? "")" + } } } diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/TopTotalsCell.xib b/WordPress/Classes/ViewRelated/Stats/Shared Views/TopTotalsCell.xib index cb9c3c0dab13..48b18dbf81f8 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/TopTotalsCell.xib +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/TopTotalsCell.xib @@ -1,11 +1,9 @@ - - - - + + - + @@ -16,32 +14,41 @@ - + - - + + - - + + + + + + + + + + + + + + - + - - - @@ -50,7 +57,7 @@ - + @@ -58,19 +65,16 @@ - - - - - + + - + - + @@ -78,10 +82,10 @@ + - - + diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/ViewMoreRow.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/ViewMoreRow.swift index 42a5ef7a43ae..d4814fd69e7f 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/ViewMoreRow.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/ViewMoreRow.swift @@ -1,6 +1,6 @@ import UIKit -protocol ViewMoreRowDelegate: class { +protocol ViewMoreRowDelegate: AnyObject { func viewMoreSelectedForStatSection(_ statSection: StatSection) } @@ -9,6 +9,7 @@ class ViewMoreRow: UIView, NibLoadable, Accessible { // MARK: - Properties @IBOutlet weak var viewMoreLabel: UILabel! + @IBOutlet weak var disclosureImageView: UIImageView! private var statSection: StatSection? private weak var delegate: ViewMoreRowDelegate? @@ -26,6 +27,7 @@ class ViewMoreRow: UIView, NibLoadable, Accessible { isAccessibilityElement = true accessibilityLabel = viewMoreLabel.text + accessibilityHint = NSLocalizedString("Tap to view more details.", comment: "Accessibility hint for a button that opens a new view with more details.") accessibilityTraits = .button } } @@ -38,6 +40,9 @@ private extension ViewMoreRow { backgroundColor = .listForeground viewMoreLabel.text = NSLocalizedString("View more", comment: "Label for viewing more stats.") viewMoreLabel.textColor = WPStyleGuide.Stats.actionTextColor + if FeatureFlag.statsNewAppearance.enabled && (statSection == .insightsFollowersWordPress || statSection == .insightsFollowersEmail) { + disclosureImageView.isHidden = true + } } @IBAction func didTapViewMoreButton(_ sender: UIButton) { @@ -66,38 +71,3 @@ private extension ViewMoreRow { } } } - -// MARK: - Analytics support - -private extension StatSection { - var analyticsViewMoreEvent: WPAnalyticsStat? { - switch self { - case .periodAuthors, .insightsCommentsAuthors: - return .statsViewMoreTappedAuthors - case .periodClicks: - return .statsViewMoreTappedClicks - case .periodOverviewComments: - return .statsViewMoreTappedComments - case .periodCountries: - return .statsViewMoreTappedCountries - case .insightsFollowerTotals, .insightsFollowersEmail, .insightsFollowersWordPress: - return .statsViewMoreTappedFollowers - case .periodPostsAndPages: - return .statsViewMoreTappedPostsAndPages - case .insightsPublicize: - return .statsViewMoreTappedPublicize - case .periodReferrers: - return .statsViewMoreTappedReferrers - case .periodSearchTerms: - return .statsViewMoreTappedSearchTerms - case .insightsTagsAndCategories: - return .statsViewMoreTappedTagsAndCategories - case .periodVideos: - return .statsViewMoreTappedVideoPlays - case .periodFileDownloads: - return .statsViewMoreTappedFileDownloads - default: - return nil - } - } -} diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/ViewMoreRow.xib b/WordPress/Classes/ViewRelated/Stats/Shared Views/ViewMoreRow.xib index ca8aac81ff2e..db2c457f3bab 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/ViewMoreRow.xib +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/ViewMoreRow.xib @@ -1,9 +1,9 @@ - + - + @@ -41,7 +41,7 @@ - + @@ -61,8 +62,8 @@ - + diff --git a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboard.storyboard b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboard.storyboard index 61fd2b8b768c..b9f2c1095de0 100644 --- a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboard.storyboard +++ b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboard.storyboard @@ -1,10 +1,11 @@ - + - + + @@ -32,63 +33,49 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + - + - - - - - - - - - - - - - - - - - - - - - - @@ -119,5 +106,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift index 7de69ec5ef97..73f08caa5460 100644 --- a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift @@ -17,6 +17,23 @@ enum StatsPeriodType: Int, FilterTabBarItem, CaseIterable { case .years: return NSLocalizedString("Years", comment: "Title of Years stats filter.") } } + + init?(from string: String) { + switch string { + case "day": + self = .days + case "week": + self = .weeks + case "month": + self = .months + case "year": + self = .years + case "insights": + self = .insights + default: + return nil + } + } } fileprivate extension StatsPeriodType { @@ -35,24 +52,82 @@ fileprivate extension StatsPeriodType { class SiteStatsDashboardViewController: UIViewController { + // MARK: - Keys + + static func lastSelectedStatsPeriodTypeKey(forSiteID siteID: Int) -> String { + return "LastSelectedStatsPeriodType-\(siteID)" + } + + static let lastSelectedStatsDateKey = "LastSelectedStatsDate" + // MARK: - Properties @IBOutlet weak var filterTabBar: FilterTabBar! + @IBOutlet weak var jetpackBannerView: JetpackBannerView! private var insightsTableViewController = SiteStatsInsightsTableViewController.loadFromStoryboard() private var periodTableViewController = SiteStatsPeriodTableViewController.loadFromStoryboard() private var pageViewController: UIPageViewController? + @objc lazy var manageInsightsButton: UIBarButtonItem = { + let button = UIBarButtonItem( + image: .gridicon(.cog), + style: .plain, + target: self, + action: #selector(manageInsightsButtonTapped)) + button.accessibilityHint = NSLocalizedString("Tap to customize insights", comment: "Accessibility hint to customize insights") + return button + }() + // MARK: - View override func viewDidLoad() { super.viewDidLoad() + configureJetpackBanner() + configureInsightsTableView() + configurePeriodTableViewController() setupFilterBar() + restoreSelectedDateFromUserDefaults() restoreSelectedPeriodFromUserDefaults() addWillEnterForegroundObserver() + configureNavBar() view.accessibilityIdentifier = "stats-dashboard" } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + JetpackFeaturesRemovalCoordinator.presentOverlayIfNeeded(in: self, source: .stats) + } + + func configureInsightsTableView() { + insightsTableViewController.tableStyle = FeatureFlag.statsNewAppearance.enabled ? .insetGrouped : .grouped + insightsTableViewController.bannerView = jetpackBannerView + } + + private func configurePeriodTableViewController() { + periodTableViewController.bannerView = jetpackBannerView + } + + func configureNavBar() { + parent?.navigationItem.rightBarButtonItem = currentSelectedPeriod == .insights ? manageInsightsButton : nil + } + + func configureJetpackBanner() { + guard JetpackBrandingVisibility.all.enabled else { + jetpackBannerView.removeFromSuperview() + return + } + let textProvider = JetpackBrandingTextProvider(screen: JetpackBannerScreen.stats) + jetpackBannerView.configure(title: textProvider.brandingText()) { [unowned self] in + JetpackBrandingCoordinator.presentOverlay(from: self) + JetpackBrandingAnalyticsHelper.trackJetpackPoweredBannerTapped(screen: .stats) + } + } + + @objc func manageInsightsButtonTapped() { + insightsTableViewController.showAddInsightView(source: "nav_bar") + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) removeWillEnterForegroundObserver() @@ -69,7 +144,7 @@ class SiteStatsDashboardViewController: UIViewController { override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { if traitCollection.verticalSizeClass == .regular, traitCollection.horizontalSizeClass == .compact { - updatePeriodView(oldSelectedPeriod: currentSelectedPeriod, withDate: periodTableViewController.selectedDate) + updatePeriodView(oldSelectedPeriod: currentSelectedPeriod) } } } @@ -85,7 +160,6 @@ extension SiteStatsDashboardViewController: StatsForegroundObservable { private extension SiteStatsDashboardViewController { struct Constants { - static let userDefaultsKey = "LastSelectedStatsPeriodType" static let progressViewInitialProgress = Float(0.03) static let progressViewHideDelay = 1 static let progressViewHideDuration = 0.15 @@ -119,6 +193,8 @@ private extension SiteStatsDashboardViewController { @objc func selectedFilterDidChange(_ filterBar: FilterTabBar) { currentSelectedPeriod = StatsPeriodType(rawValue: filterBar.selectedIndex) ?? StatsPeriodType.insights + + configureNavBar() } } @@ -128,25 +204,56 @@ private extension SiteStatsDashboardViewController { private extension SiteStatsDashboardViewController { func saveSelectedPeriodToUserDefaults() { - UserDefaults.standard.set(currentSelectedPeriod.rawValue, forKey: Constants.userDefaultsKey) + guard let siteID = SiteStatsInformation.sharedInstance.siteID?.intValue else { + return + } + + let key = Self.lastSelectedStatsPeriodTypeKey(forSiteID: siteID) + + guard !insightsTableViewController.isGrowAudienceShowing else { + UserPersistentStoreFactory.instance().set(StatsPeriodType.insights.rawValue, forKey: key) + return + } + + UserPersistentStoreFactory.instance().set(currentSelectedPeriod.rawValue, forKey: key) } func getSelectedPeriodFromUserDefaults() -> StatsPeriodType { - return StatsPeriodType(rawValue: UserDefaults.standard.integer(forKey: Constants.userDefaultsKey)) ?? .insights + + guard let siteID = SiteStatsInformation.sharedInstance.siteID?.intValue, + let periodType = StatsPeriodType(rawValue: UserPersistentStoreFactory.instance().integer(forKey: Self.lastSelectedStatsPeriodTypeKey(forSiteID: siteID))) else { + return .insights + } + + return periodType + } + + func getLastSelectedDateFromUserDefaults() -> Date? { + UserPersistentStoreFactory.instance().object(forKey: Self.lastSelectedStatsDateKey) as? Date + } + + func removeLastSelectedDateFromUserDefaults() { + UserPersistentStoreFactory.instance().removeObject(forKey: Self.lastSelectedStatsDateKey) + } + + func restoreSelectedDateFromUserDefaults() { + periodTableViewController.selectedDate = getLastSelectedDateFromUserDefaults() + removeLastSelectedDateFromUserDefaults() } func restoreSelectedPeriodFromUserDefaults() { currentSelectedPeriod = getSelectedPeriodFromUserDefaults() } - func updatePeriodView(oldSelectedPeriod: StatsPeriodType, withDate periodDate: Date? = nil) { + func updatePeriodView(oldSelectedPeriod: StatsPeriodType) { let selectedPeriodChanged = currentSelectedPeriod != oldSelectedPeriod let previousSelectedPeriodWasInsights = oldSelectedPeriod == .insights let pageViewControllerIsEmpty = pageViewController?.viewControllers?.isEmpty ?? true + let isGrowAudienceShowingOnInsights = insightsTableViewController.isGrowAudienceShowing switch currentSelectedPeriod { case .insights: - if selectedPeriodChanged || pageViewControllerIsEmpty { + if selectedPeriodChanged || pageViewControllerIsEmpty || isGrowAudienceShowingOnInsights { pageViewController?.setViewControllers([insightsTableViewController], direction: .forward, animated: false) @@ -159,7 +266,12 @@ private extension SiteStatsDashboardViewController { animated: false) } - periodTableViewController.selectedDate = periodDate ?? StatsDataHelper.currentDateForSite() + if periodTableViewController.selectedDate == nil + || selectedPeriodChanged { + + periodTableViewController.selectedDate = StatsDataHelper.currentDateForSite() + } + let selectedPeriod = StatsPeriodUnit(rawValue: currentSelectedPeriod.rawValue - 1) ?? .day periodTableViewController.selectedPeriod = selectedPeriod } diff --git a/WordPress/Classes/ViewRelated/Stats/SiteStatsTableViewCells.swift b/WordPress/Classes/ViewRelated/Stats/SiteStatsTableViewCells.swift index 40ebc7c3c721..7fd74d377883 100644 --- a/WordPress/Classes/ViewRelated/Stats/SiteStatsTableViewCells.swift +++ b/WordPress/Classes/ViewRelated/Stats/SiteStatsTableViewCells.swift @@ -1,4 +1,5 @@ import UIKit +import Gridicons // MARK: - Shared Rows @@ -28,6 +29,35 @@ struct OverviewRow: ImmuTableRow { } } +struct ViewsVisitorsRow: ImmuTableRow { + + typealias CellType = ViewsVisitorsLineChartCell + + static let cell: ImmuTableCell = { + return ImmuTableCell.nib(CellType.defaultNib, CellType.self) + }() + + let segmentsData: [StatsSegmentedControlData] + let selectedSegment: StatsSegmentedControlData.Segment + let action: ImmuTableAction? = nil + let chartData: [LineChartDataConvertible] + let chartStyling: [LineChartStyling] + let period: StatsPeriodUnit? + weak var statsLineChartViewDelegate: StatsLineChartViewDelegate? + weak var siteStatsInsightsDelegate: SiteStatsInsightsDelegate? + weak var viewsAndVisitorsDelegate: StatsInsightsViewsAndVisitorsDelegate? + let xAxisDates: [Date] + + func configureCell(_ cell: UITableViewCell) { + + guard let cell = cell as? CellType else { + return + } + + cell.configure(row: self) + } +} + struct CellHeaderRow: ImmuTableRow { typealias CellType = StatsCellHeader @@ -89,6 +119,33 @@ struct InsightCellHeaderRow: ImmuTableRow { } } +struct GrowAudienceRow: ImmuTableRow { + + typealias CellType = GrowAudienceCell + + static let cell: ImmuTableCell = { + return ImmuTableCell.nib(CellType.defaultNib, CellType.self) + }() + + let hintType: GrowAudienceCell.HintType + let allTimeViewsCount: Int + let isNudgeCompleted: Bool + weak var siteStatsInsightsDelegate: SiteStatsInsightsDelegate? + let action: ImmuTableAction? = nil + + func configureCell(_ cell: UITableViewCell) { + + guard let cell = cell as? CellType else { + return + } + + cell.configure(hintType: hintType, + allTimeViewsCount: allTimeViewsCount, + isNudgeCompleted: isNudgeCompleted, + insightsDelegate: siteStatsInsightsDelegate) + } +} + struct CustomizeInsightsRow: ImmuTableRow { typealias CellType = CustomizeInsightsCell @@ -113,11 +170,13 @@ struct CustomizeInsightsRow: ImmuTableRow { struct LatestPostSummaryRow: ImmuTableRow { - typealias CellType = LatestPostSummaryCell - - static let cell: ImmuTableCell = { - return ImmuTableCell.nib(CellType.defaultNib, CellType.self) - }() + static var cell: ImmuTableCell { + if FeatureFlag.statsNewInsights.enabled { + return ImmuTableCell.class(StatsLatestPostSummaryInsightsCell.self) + } else { + return ImmuTableCell.nib(LatestPostSummaryCell.defaultNib, LatestPostSummaryCell.self) + } + } let summaryData: StatsLastPostInsight? let chartData: StatsPostDetails? @@ -126,7 +185,7 @@ struct LatestPostSummaryRow: ImmuTableRow { func configureCell(_ cell: UITableViewCell) { - guard let cell = cell as? CellType else { + guard let cell = cell as? LatestPostSummaryConfigurable else { return } @@ -165,7 +224,9 @@ struct TabbedTotalsStatsRow: ImmuTableRow { }() let tabsData: [TabData] + let statSection: StatSection weak var siteStatsInsightsDelegate: SiteStatsInsightsDelegate? + weak var siteStatsDetailsDelegate: SiteStatsDetailsDelegate? let showTotalCount: Bool let action: ImmuTableAction? = nil @@ -176,7 +237,9 @@ struct TabbedTotalsStatsRow: ImmuTableRow { } cell.configure(tabsData: tabsData, + statSection: statSection, siteStatsInsightsDelegate: siteStatsInsightsDelegate, + siteStatsDetailsDelegate: siteStatsDetailsDelegate, showTotalCount: showTotalCount) } } @@ -192,6 +255,7 @@ struct TopTotalsInsightStatsRow: ImmuTableRow { let itemSubtitle: String let dataSubtitle: String let dataRows: [StatsTotalRowData] + let statSection: StatSection weak var siteStatsInsightsDelegate: SiteStatsInsightsDelegate? let action: ImmuTableAction? = nil @@ -206,6 +270,7 @@ struct TopTotalsInsightStatsRow: ImmuTableRow { cell.configure(itemSubtitle: itemSubtitle, dataSubtitle: dataSubtitle, dataRows: dataRows, + statSection: statSection, siteStatsInsightsDelegate: siteStatsInsightsDelegate, limitRowsDisplayed: limitRowsDisplayed) } @@ -234,27 +299,62 @@ struct TwoColumnStatsRow: ImmuTableRow { } } -// MARK: - Insights Management - -struct AddInsightRow: ImmuTableRow { +struct MostPopularTimeInsightStatsRow: ImmuTableRow { - typealias CellType = TopTotalsCell + typealias CellType = StatsMostPopularTimeInsightsCell static let cell: ImmuTableCell = { - return ImmuTableCell.nib(CellType.defaultNib, CellType.self) + return ImmuTableCell.class(CellType.self) }() - let dataRow: StatsTotalRowData + let data: StatsMostPopularTimeData? weak var siteStatsInsightsDelegate: SiteStatsInsightsDelegate? let action: ImmuTableAction? = nil func configureCell(_ cell: UITableViewCell) { + guard let cell = cell as? CellType else { + return + } + + cell.configure(data: data, siteStatsInsightsDelegate: siteStatsInsightsDelegate) + } +} + +struct TotalInsightStatsRow: ImmuTableRow { + + typealias CellType = StatsTotalInsightsCell + + static let cell: ImmuTableCell = { + return ImmuTableCell.class(CellType.self) + }() + + let dataRow: StatsTotalInsightsData + let statSection: StatSection + weak var siteStatsInsightsDelegate: SiteStatsInsightsDelegate? + let action: ImmuTableAction? = nil + func configureCell(_ cell: UITableViewCell) { guard let cell = cell as? CellType else { return } - cell.configure(dataRows: [dataRow], siteStatsInsightsDelegate: siteStatsInsightsDelegate) + cell.configure(dataRow: dataRow, statSection: statSection, siteStatsInsightsDelegate: siteStatsInsightsDelegate) + } +} + +// MARK: - Insights Management + +struct AddInsightRow: ImmuTableRow { + static let cell = ImmuTableCell.class(WPTableViewCellDefault.self) + + let action: ImmuTableAction? + + func configureCell(_ cell: UITableViewCell) { + cell.textLabel?.text = StatSection.insightsAddInsight.title + cell.accessoryView = UIImageView(image: WPStyleGuide.Stats.imageForGridiconType(.plus, withTint: .darkGrey)) + cell.accessibilityTraits = .button + cell.accessibilityLabel = StatSection.insightsAddInsight.title + cell.accessibilityHint = NSLocalizedString("Tap to add new stats cards.", comment: "Accessibility hint for a button that opens a view that allows to add new stats cards.") } } @@ -274,13 +374,28 @@ struct AddInsightStatRow: ImmuTableRow { cell.textLabel?.text = title cell.textLabel?.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) cell.textLabel?.adjustsFontForContentSizeCategory = true - cell.textLabel?.textColor = enabled ? .text : .textPlaceholder + cell.textLabel?.textColor = FeatureFlag.statsNewAppearance.enabled || enabled ? .text : .textPlaceholder cell.selectionStyle = .none cell.accessibilityLabel = title cell.isAccessibilityElement = true - cell.accessibilityTraits = enabled ? .button : .notEnabled - cell.accessibilityHint = enabled ? enabledHint : disabledHint + + let canTap = FeatureFlag.statsNewAppearance.enabled ? action != nil : enabled + cell.accessibilityTraits = canTap ? .button : .notEnabled + cell.accessibilityHint = canTap && enabled ? disabledHint : enabledHint + + if FeatureFlag.statsNewAppearance.enabled { + cell.accessoryView = canTap ? UIImageView(image: UIImage(systemName: Constants.plusIconName)) : nil + + let editingImageView = UIImageView(image: UIImage(systemName: Constants.minusIconName)) + editingImageView.tintColor = .textSubtle + cell.editingAccessoryView = editingImageView + } + } + + private enum Constants { + static let plusIconName = "plus.circle" + static let minusIconName = "minus.circle" } } @@ -317,7 +432,12 @@ struct TopTotalsPeriodStatsRow: ImmuTableRow { let itemSubtitle: String let dataSubtitle: String let dataRows: [StatsTotalRowData] + var statSection: StatSection? weak var siteStatsPeriodDelegate: SiteStatsPeriodDelegate? + weak var siteStatsReferrerDelegate: SiteStatsReferrerDelegate? + weak var siteStatsInsightsDetailsDelegate: SiteStatsInsightsDelegate? + weak var siteStatsDetailsDelegate: SiteStatsDetailsDelegate? + var topAccessoryView: UIView? = nil let action: ImmuTableAction? = nil func configureCell(_ cell: UITableViewCell) { @@ -329,7 +449,12 @@ struct TopTotalsPeriodStatsRow: ImmuTableRow { cell.configure(itemSubtitle: itemSubtitle, dataSubtitle: dataSubtitle, dataRows: dataRows, - siteStatsPeriodDelegate: siteStatsPeriodDelegate) + statSection: statSection, + siteStatsInsightsDelegate: siteStatsInsightsDetailsDelegate, + siteStatsPeriodDelegate: siteStatsPeriodDelegate, + siteStatsReferrerDelegate: siteStatsReferrerDelegate, + siteStatsDetailsDelegate: siteStatsDetailsDelegate, + topAccessoryView: topAccessoryView) } } @@ -365,8 +490,10 @@ struct CountriesStatsRow: ImmuTableRow { let itemSubtitle: String let dataSubtitle: String + var statSection: StatSection? let dataRows: [StatsTotalRowData] weak var siteStatsPeriodDelegate: SiteStatsPeriodDelegate? + weak var siteStatsInsightsDetailsDelegate: SiteStatsInsightsDelegate? let action: ImmuTableAction? = nil func configureCell(_ cell: UITableViewCell) { @@ -378,13 +505,16 @@ struct CountriesStatsRow: ImmuTableRow { cell.configure(itemSubtitle: itemSubtitle, dataSubtitle: dataSubtitle, dataRows: dataRows, - siteStatsPeriodDelegate: siteStatsPeriodDelegate) + siteStatsPeriodDelegate: siteStatsPeriodDelegate, + siteStatsInsightsDetailsDelegate: siteStatsInsightsDetailsDelegate) + cell.statSection = statSection } } struct CountriesMapRow: ImmuTableRow { let action: ImmuTableAction? = nil let countriesMap: CountriesMap + var statSection: StatSection? typealias CellType = CountriesMapCell @@ -397,6 +527,7 @@ struct CountriesMapRow: ImmuTableRow { return } cell.configure(with: countriesMap) + cell.statSection = statSection } } @@ -513,6 +644,7 @@ struct DetailExpandableRow: ImmuTableRow { let rowData: StatsTotalRowData weak var detailsDelegate: SiteStatsDetailsDelegate? + weak var referrerDelegate: SiteStatsReferrerDelegate? let hideIndentedSeparator: Bool let hideFullSeparator: Bool let expanded: Bool @@ -526,6 +658,7 @@ struct DetailExpandableRow: ImmuTableRow { cell.configure(rowData: rowData, detailsDelegate: detailsDelegate, + referrerDelegate: referrerDelegate, hideIndentedSeparator: hideIndentedSeparator, hideFullSeparator: hideFullSeparator, expanded: expanded) @@ -642,6 +775,7 @@ struct StatsErrorRow: ImmuTableRow { let action: ImmuTableAction? = nil let rowStatus: StoreFetchingStatus let statType: StatType + let statSection: StatSection? private let noDataRow = StatsNoDataRow.loadFromNib() @@ -652,5 +786,9 @@ struct StatsErrorRow: ImmuTableRow { noDataRow.configure(forType: statType, rowStatus: rowStatus) cell.insert(view: noDataRow) + + if let statSection = statSection { + cell.statSection = statSection + } } } diff --git a/WordPress/Classes/ViewRelated/Stats/StatsForegroundObservable.swift b/WordPress/Classes/ViewRelated/Stats/StatsForegroundObservable.swift index 131368c63e2f..447af1b75cf2 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsForegroundObservable.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsForegroundObservable.swift @@ -1,4 +1,4 @@ -protocol StatsForegroundObservable: class { +protocol StatsForegroundObservable: AnyObject { func addWillEnterForegroundObserver() func removeWillEnterForegroundObserver() func reloadStatsData() diff --git a/WordPress/Classes/ViewRelated/Stats/StatsViewController.h b/WordPress/Classes/ViewRelated/Stats/StatsViewController.h index 7c536a0f4650..2383ed23f32c 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsViewController.h +++ b/WordPress/Classes/ViewRelated/Stats/StatsViewController.h @@ -3,7 +3,9 @@ @interface StatsViewController : UIViewController -@property (nonatomic, weak) Blog *blog; -@property (nonatomic, copy) void (^dismissBlock)(void); +@property (nonatomic, weak, nullable) Blog *blog; +@property (nonatomic, copy, nullable) void (^dismissBlock)(void); + ++ (void)showForBlog:(nonnull Blog *)blog from:(nonnull UIViewController *)controller; @end diff --git a/WordPress/Classes/ViewRelated/Stats/StatsViewController.m b/WordPress/Classes/ViewRelated/Stats/StatsViewController.m index f00b4ef3c69d..ed77f43b59f1 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsViewController.m +++ b/WordPress/Classes/ViewRelated/Stats/StatsViewController.m @@ -4,18 +4,18 @@ #import "StatsViewController.h" #import "Blog.h" #import "WPAccount.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "BlogService.h" -#import "SFHFKeychainUtils.h" #import "TodayExtensionService.h" #import "WordPress-Swift.h" #import "WPAppAnalytics.h" static NSString *const StatsBlogObjectURLRestorationKey = @"StatsBlogObjectURL"; -@interface StatsViewController () +@interface StatsViewController () @property (nonatomic, assign) BOOL showingJetpackLogin; +@property (nonatomic, assign) BOOL isActivatingStatsModule; @property (nonatomic, strong) SiteStatsDashboardViewController *siteStatsDashboardVC; @property (nonatomic, weak) NoResultsViewController *noResultsViewController; @property (nonatomic, strong) UIActivityIndicatorView *loadingIndicator; @@ -34,17 +34,32 @@ - (instancetype)init return self; } ++ (void)showForBlog:(Blog *)blog from:(UIViewController *)controller +{ + StatsViewController *statsController = [StatsViewController new]; + statsController.blog = blog; + statsController.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; + [controller.navigationController pushViewController:statsController animated:YES]; + + [[QuickStartTourGuide shared] visited:QuickStartTourElementStats]; +} + - (void)viewDidLoad { [super viewDidLoad]; - self.view.backgroundColor = [WPStyleGuide itsEverywhereGrey]; + self.view.backgroundColor = [UIColor systemGroupedBackgroundColor]; self.navigationItem.title = NSLocalizedString(@"Stats", @"Stats window title"); + + self.extendedLayoutIncludesOpaqueBars = YES; UINavigationController *statsNavVC = [[UIStoryboard storyboardWithName:@"SiteStatsDashboard" bundle:nil] instantiateInitialViewController]; self.siteStatsDashboardVC = statsNavVC.viewControllers.firstObject; + + self.noResultsViewController = [NoResultsViewController controller]; + self.noResultsViewController.delegate = self; - self.loadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; + self.loadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; self.loadingIndicator.translatesAutoresizingMaskIntoConstraints = NO; [self.view addSubview:self.loadingIndicator]; [NSLayoutConstraint activateConstraints:@[ @@ -55,7 +70,7 @@ - (void)viewDidLoad // Being shown in a modal window if (self.presentingViewController != nil) { UIBarButtonItem *doneButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone target:self action:@selector(doneButtonTapped:)]; - self.navigationItem.rightBarButtonItem = doneButton; + self.navigationItem.leftBarButtonItem = doneButton; self.title = self.blog.settings.name; } @@ -65,6 +80,12 @@ - (void)viewDidLoad [self initStats]; } +- (void)viewWillDisappear:(BOOL)animated +{ + [super viewWillDisappear:animated]; + [self dismissQuickStartTaskCompleteNotice]; +} + - (void)setBlog:(Blog *)blog { _blog = blog; @@ -73,9 +94,13 @@ - (void)setBlog:(Blog *)blog - (void)addStatsViewControllerToView { - if (self.presentingViewController == nil) { - UIBarButtonItem *settingsButton = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Widgets", @"Nav bar button title to set the site used for Stats widgets.") style:UIBarButtonItemStylePlain target:self action:@selector(makeSiteTodayWidgetSite:)]; - self.navigationItem.rightBarButtonItem = settingsButton; + if (@available (iOS 14, *)) { + // do not install the widgets button on iOS 14 or later, if today widget feature flag is enabled + if (![Feature enabled:FeatureFlagTodayWidget]) { + [self installWidgetsButton]; + } + } else if (self.presentingViewController == nil) { + [self installWidgetsButton]; } [self addChildViewController:self.siteStatsDashboardVC]; @@ -83,20 +108,31 @@ - (void)addStatsViewControllerToView [self.siteStatsDashboardVC didMoveToParentViewController:self]; } +- (void) installWidgetsButton +{ + UIBarButtonItem *settingsButton = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Widgets", @"Nav bar button title to set the site used for Stats widgets.") style:UIBarButtonItemStylePlain target:self action:@selector(makeSiteTodayWidgetSite:)]; + self.navigationItem.rightBarButtonItem = settingsButton; +} - (void)initStats { - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; - SiteStatsInformation.sharedInstance.siteTimeZone = [blogService timeZoneForBlog:self.blog]; - + SiteStatsInformation.sharedInstance.siteTimeZone = [self.blog timeZone]; + // WordPress.com + Jetpack REST if (self.blog.account) { + + // Prompt user to enable site stats if stats module is disabled + if (!self.isActivatingStatsModule && ![self.blog isStatsActive]) { + [self showStatsModuleDisabled]; + return; + } + SiteStatsInformation.sharedInstance.oauth2Token = self.blog.account.authToken; SiteStatsInformation.sharedInstance.siteID = self.blog.dotComID; + SiteStatsInformation.sharedInstance.supportsFileDownloads = [self.blog supports:BlogFeatureFileDownloadsStats]; [self addStatsViewControllerToView]; - + [self initializeStatsWidgetsIfNeeded]; return; } @@ -106,8 +142,7 @@ - (void)initStats - (void)refreshStatus { [self.loadingIndicator startAnimating]; - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; + BlogService *blogService = [[BlogService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; __weak __typeof(self) weakSelf = self; [blogService syncBlog:self.blog success:^{ [self.loadingIndicator stopAnimating]; @@ -164,7 +199,7 @@ - (IBAction)makeSiteTodayWidgetSite:(id)sender handler:nil]; [alertController addActionWithTitle:NSLocalizedString(@"Use this site", @"") style:UIAlertActionStyleDefault - handler:^(UIAlertAction *alertAction) { + handler:^(UIAlertAction * __unused alertAction) { [self saveSiteDetailsForTodayWidget]; }]; alertController.popoverPresentationController.barButtonItem = sender; @@ -179,26 +214,33 @@ - (IBAction)doneButtonTapped:(id)sender } } - -- (void)showNoResults +- (void)showStatsModuleDisabled { - [self.noResultsViewController removeFromView]; + [self instantiateNoResultsViewControllerIfNeeded]; + [self.noResultsViewController configureForStatsModuleDisabled]; + [self displayNoResults]; +} - NSString *title = NSLocalizedString(@"No Connection", @"Title for the error view when there's no connection"); - NSString *subtitle = NSLocalizedString(@"An active internet connection is required to view stats", - @"Error message shown when trying to view Stats and there is no internet connection."); +- (void)showEnablingSiteStats +{ + [self instantiateNoResultsViewControllerIfNeeded]; + [self.noResultsViewController configureForActivatingStatsModule]; + [self displayNoResults]; +} - self.noResultsViewController = [NoResultsViewController controllerWithTitle:title - buttonTitle:nil - subtitle:subtitle - attributedSubtitle:nil - attributedSubtitleConfiguration:nil - image:nil - subtitleImage:nil - accessoryView:nil]; +- (void)instantiateNoResultsViewControllerIfNeeded +{ + if (!self.noResultsViewController) { + self.noResultsViewController = [NoResultsViewController controller]; + self.noResultsViewController.delegate = self; + } +} +- (void)displayNoResults +{ [self addChildViewController:self.noResultsViewController]; [self.view addSubviewWithFadeAnimation:self.noResultsViewController.view]; + self.noResultsViewController.view.frame = self.view.bounds; [self.noResultsViewController didMoveToParentViewController:self]; } @@ -210,6 +252,27 @@ - (void)reachabilityChanged:(NSNotification *)notification } } +#pragma mark - NoResultsViewControllerDelegate + +-(void)actionButtonPressed +{ + [self showEnablingSiteStats]; + + self.isActivatingStatsModule = YES; + + __weak __typeof(self) weakSelf = self; + + [self activateStatsModuleWithSuccess:^{ + [weakSelf.noResultsViewController removeFromView]; + [weakSelf initStats]; + weakSelf.isActivatingStatsModule = NO; + } failure:^(NSError *error) { + DDLogError(@"Error activating stats module: %@", error); + [weakSelf showStatsModuleDisabled]; + weakSelf.isActivatingStatsModule = NO; + }]; +} + #pragma mark - Restoration - (void)encodeRestorableStateWithCoder:(NSCoder *)coder diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/AllTimeWidgetStats.swift b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/AllTimeWidgetStats.swift index f006bd8e8e4a..ef233a10aad3 100644 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/AllTimeWidgetStats.swift +++ b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/AllTimeWidgetStats.swift @@ -2,7 +2,6 @@ import Foundation /// This struct contains data for the Insights All Time stats to be displayed in the corresponding widget. /// The data is stored in a plist for the widget to access. -/// This file is shared with WordPressAllTimeWidget, which accesses the data when it is viewed. /// struct AllTimeWidgetStats: Codable { @@ -67,7 +66,7 @@ extension AllTimeWidgetStats { } } - private static var dataFileName = "AllTimeData.plist" + private static var dataFileName = AppConfiguration.Widget.StatsToday.allTimeFilename private static var dataFileURL: URL? { guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: WPAppGroupName) else { diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/ThisWeekWidgetStats.swift b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/ThisWeekWidgetStats.swift index c9eb11480588..7e92a63cf8de 100644 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/ThisWeekWidgetStats.swift +++ b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/ThisWeekWidgetStats.swift @@ -3,7 +3,6 @@ import WordPressKit /// This struct contains data for 'Views This Week' stats to be displayed in the corresponding widget. /// The data is stored in a plist for the widget to access. -/// This file is shared with WordPressThisWeekWidget, which accesses the data when it is viewed. /// struct ThisWeekWidgetStats: Codable { @@ -14,7 +13,7 @@ struct ThisWeekWidgetStats: Codable { } } -struct ThisWeekWidgetDay: Codable { +struct ThisWeekWidgetDay: Codable, Hashable { let date: Date let viewsCount: Int let dailyChangePercent: Float @@ -107,7 +106,7 @@ extension ThisWeekWidgetStats { return days } - private static var dataFileName = "ThisWeekData.plist" + private static var dataFileName = AppConfiguration.Widget.StatsToday.thisWeekFilename private static var dataFileURL: URL? { guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: WPAppGroupName) else { diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/TodayWidgetStats.swift b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/TodayWidgetStats.swift index abffc0c5b6fa..2585ccc3d0de 100644 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/TodayWidgetStats.swift +++ b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Data/TodayWidgetStats.swift @@ -1,8 +1,8 @@ +import CocoaLumberjack import Foundation /// This struct contains data for the Insights Today stats to be displayed in the corresponding widget. /// The data is stored in a plist for the widget to access. -/// This file is shared with WordPressTodayWidget, which accesses the data when it is viewed. /// struct TodayWidgetStats: Codable { @@ -67,7 +67,7 @@ extension TodayWidgetStats { } } - private static var dataFileName = "TodayData.plist" + private static var dataFileName = AppConfiguration.Widget.StatsToday.todayFilename private static var dataFileURL: URL? { guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: WPAppGroupName) else { diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetDifferenceCell.swift b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetDifferenceCell.swift deleted file mode 100644 index b843c9a55749..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetDifferenceCell.swift +++ /dev/null @@ -1,87 +0,0 @@ -import UIKit - -class WidgetDifferenceCell: UITableViewCell { - - // MARK: - Properties - - static let reuseIdentifier = "WidgetDifferenceCell" - static let defaultHeight: CGFloat = 56.5 - - @IBOutlet private var dateLabel: UILabel! - @IBOutlet private var dataLabel: UILabel! - @IBOutlet private var differenceView: UIView! - @IBOutlet private var differenceLabel: UILabel! - - private var percentFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .percent - formatter.positivePrefix = "+" - return formatter - }() - - private var dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") - return formatter - }() - - // MARK: - View - - override func awakeFromNib() { - super.awakeFromNib() - configureColors() - initializeLabels() - } - - func configure(day: ThisWeekWidgetDay? = nil, isToday: Bool = false) { - configureLabels(day: day, isToday: isToday) - } - -} - -// MARK: - Private Extension - -private extension WidgetDifferenceCell { - - func configureColors() { - dateLabel.textColor = WidgetStyles.primaryTextColor - dataLabel.textColor = WidgetStyles.primaryTextColor - differenceLabel.textColor = Constants.differenceTextColor - differenceView.layer.cornerRadius = Constants.cornerRadius - } - - func initializeLabels() { - dateLabel.text = Constants.noDataLabel - dataLabel.text = Constants.noDataLabel - differenceLabel.text = Constants.noDataLabel - } - - func configureLabels(day: ThisWeekWidgetDay?, isToday: Bool) { - guard let day = day else { - return - } - - dataLabel.text = day.viewsCount.abbreviatedString() - differenceLabel.text = percentFormatter.string(for: day.dailyChangePercent) - - guard !isToday else { - dateLabel.text = Constants.today - differenceView.backgroundColor = Constants.neutralColor - return - } - - dateLabel.text = dateFormatter.string(from: day.date) - differenceView.backgroundColor = day.dailyChangePercent < 0 ? Constants.negativeColor : Constants.positiveColor - } - - enum Constants { - static let noDataLabel = "-" - static let cornerRadius: CGFloat = 4.0 - static let today = NSLocalizedString("Today", comment: "Label for most recent stat row.") - static let positiveColor: UIColor = .success - static let negativeColor: UIColor = .error - static let neutralColor: UIColor = .neutral(.shade40) - static let differenceTextColor: UIColor = .white - } - -} diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetDifferenceCell.xib b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetDifferenceCell.xib deleted file mode 100644 index eafd5a9627f1..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetDifferenceCell.xib +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetNoConnectionCell.swift b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetNoConnectionCell.swift deleted file mode 100644 index 57fc1d27e334..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetNoConnectionCell.swift +++ /dev/null @@ -1,35 +0,0 @@ -import UIKit - -class WidgetNoConnectionCell: UITableViewCell { - - // MARK: - Properties - - static let reuseIdentifier = "WidgetNoConnectionCell" - - @IBOutlet private var titleLabel: UILabel! - @IBOutlet private var messageLabel: UILabel! - - // MARK: - View - - override func awakeFromNib() { - super.awakeFromNib() - configureView() - } - -} - -private extension WidgetNoConnectionCell { - - func configureView() { - titleLabel.text = LocalizedText.title - messageLabel.text = LocalizedText.message - titleLabel.textColor = WidgetStyles.primaryTextColor - messageLabel.textColor = WidgetStyles.primaryTextColor - } - - enum LocalizedText { - static let title = NSLocalizedString("No network available", comment: "Displayed in the Stats widgets when there is no network") - static let message = NSLocalizedString("Stats will be updated next time you're online", comment: "Displayed in the Stats widgets when there is no network") - } - -} diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetNoConnectionCell.xib b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetNoConnectionCell.xib deleted file mode 100644 index 4b613661f181..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetNoConnectionCell.xib +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetTwoColumnCell.swift b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetTwoColumnCell.swift deleted file mode 100644 index a77a6685145d..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetTwoColumnCell.swift +++ /dev/null @@ -1,45 +0,0 @@ -import UIKit - -class WidgetTwoColumnCell: UITableViewCell { - - // MARK: - Properties - - static let reuseIdentifier = "WidgetTwoColumnCell" - static let defaultHeight: CGFloat = 78 - - @IBOutlet private var leftItemLabel: UILabel! - @IBOutlet private var leftDataLabel: UILabel! - @IBOutlet private var rightItemLabel: UILabel! - @IBOutlet private var rightDataLabel: UILabel! - - // MARK: - View - - override func awakeFromNib() { - super.awakeFromNib() - configureColors() - } - - // MARK: - Configure - - func configure(leftItemName: String, leftItemData: String, rightItemName: String, rightItemData: String) { - leftItemLabel.text = leftItemName - leftDataLabel.text = leftItemData - rightItemLabel.text = rightItemName - rightDataLabel.text = rightItemData - - leftDataLabel.accessibilityLabel = leftItemData.accessibilityLabel - rightDataLabel.accessibilityLabel = rightItemData.accessibilityLabel - } - -} - -// MARK: - Private Extension - -private extension WidgetTwoColumnCell { - func configureColors() { - leftItemLabel.textColor = WidgetStyles.primaryTextColor - leftDataLabel.textColor = WidgetStyles.primaryTextColor - rightItemLabel.textColor = WidgetStyles.primaryTextColor - rightDataLabel.textColor = WidgetStyles.primaryTextColor - } -} diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetTwoColumnCell.xib b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetTwoColumnCell.xib deleted file mode 100644 index 4157105e6e12..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetTwoColumnCell.xib +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUnconfiguredCell.swift b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUnconfiguredCell.swift deleted file mode 100644 index 6096fec24162..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUnconfiguredCell.swift +++ /dev/null @@ -1,80 +0,0 @@ -import UIKit - -enum WidgetType { - case today - case allTime - case thisWeek - case loadingFailed - - var configureLabelFont: UIFont { - switch self { - case .loadingFailed: - return WidgetStyles.headlineFont - default: - return WidgetStyles.footnoteNote - } - } -} - -class WidgetUnconfiguredCell: UITableViewCell { - - // MARK: - Properties - - static let reuseIdentifier = "WidgetUnconfiguredCell" - - @IBOutlet private var configureLabel: UILabel! - @IBOutlet private var separatorLine: UIView! - @IBOutlet private var separatorVisualEffectView: UIVisualEffectView! - @IBOutlet private var actionLabel: UILabel! - - private var widgetType: WidgetType? - - // MARK: - View - - func configure(for widgetType: WidgetType) { - self.widgetType = widgetType - configureView() - } - -} - -// MARK: - Private Extension - -private extension WidgetUnconfiguredCell { - - func configureView() { - guard let widgetType = widgetType else { - return - } - - configureLabel.text = { - switch widgetType { - case .today: - return LocalizedText.configureToday - case .allTime: - return LocalizedText.configureAllTime - case .thisWeek: - return LocalizedText.configureThisWeek - case .loadingFailed: - return LocalizedText.loadingFailed - } - }() - - configureLabel.font = widgetType.configureLabelFont - actionLabel.text = widgetType == .loadingFailed ? LocalizedText.retry : LocalizedText.openWordPress - configureLabel.textColor = WidgetStyles.primaryTextColor - actionLabel.textColor = WidgetStyles.primaryTextColor - WidgetStyles.configureSeparator(separatorLine) - separatorVisualEffectView.effect = WidgetStyles.separatorVibrancyEffect - } - - enum LocalizedText { - static let configureToday = NSLocalizedString("Display your site stats for today here. Configure in the WordPress app in your site stats.", comment: "Unconfigured stats today widget helper text") - static let configureAllTime = NSLocalizedString("Display your all-time site stats here. Configure in the WordPress app in your site stats.", comment: "Unconfigured stats all-time widget helper text") - static let configureThisWeek = NSLocalizedString("Display your site stats for this week here. Configure in the WordPress app in your site stats.", comment: "Unconfigured stats this week widget helper text") - static let openWordPress = NSLocalizedString("Open WordPress", comment: "Today widget label to launch WP app") - static let loadingFailed = NSLocalizedString("Couldn't load data", comment: "Message displayed when a Stats widget failed to load data.") - static let retry = NSLocalizedString("Retry", comment: "Stats widgets label to reload the widget.") - } - -} diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUnconfiguredCell.xib b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUnconfiguredCell.xib deleted file mode 100644 index 4e3211f6663f..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUnconfiguredCell.xib +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUrlCell.swift b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUrlCell.swift deleted file mode 100644 index c9be1c48d41d..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUrlCell.swift +++ /dev/null @@ -1,36 +0,0 @@ -import UIKit - -class WidgetUrlCell: UITableViewCell { - - // MARK: - Properties - - static let reuseIdentifier = "WidgetUrlCell" - static let height: CGFloat = 32 - - @IBOutlet private var separatorLine: UIView! - @IBOutlet private var separatorVisualEffectView: UIVisualEffectView! - @IBOutlet private var siteUrlLabel: UILabel! - - // MARK: - View - - override func awakeFromNib() { - super.awakeFromNib() - configureColors() - } - - func configure(siteUrl: String, hideSeparator: Bool = false) { - siteUrlLabel.text = siteUrl - separatorVisualEffectView.isHidden = hideSeparator - } - -} - -// MARK: - Private Extension - -private extension WidgetUrlCell { - func configureColors() { - siteUrlLabel.textColor = WidgetStyles.secondaryTextColor - WidgetStyles.configureSeparator(separatorLine) - separatorVisualEffectView.effect = WidgetStyles.separatorVibrancyEffect - } -} diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUrlCell.xib b/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUrlCell.xib deleted file mode 100644 index d9df3cc5e6dd..000000000000 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/Shared Views/WidgetUrlCell.xib +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/Classes/ViewRelated/Stats/Today Widgets/WidgetStyles.swift b/WordPress/Classes/ViewRelated/Stats/Today Widgets/WidgetStyles.swift index a6a175c84fda..4308eaefe90e 100644 --- a/WordPress/Classes/ViewRelated/Stats/Today Widgets/WidgetStyles.swift +++ b/WordPress/Classes/ViewRelated/Stats/Today Widgets/WidgetStyles.swift @@ -8,21 +8,7 @@ class WidgetStyles: NSObject { static let headlineFont = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .headline).pointSize) static let footnoteNote = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize) - static var separatorColor: UIColor = { - if #available(iOS 13, *) { - return .separator - } else { - return .divider - } - }() - - static var separatorVibrancyEffect: UIVibrancyEffect = { - if #available(iOS 13, *) { - return .widgetEffect(forVibrancyStyle: .separator) - } else { - return .widgetSecondary() - } - }() + static let separatorColor: UIColor = .separator static func configureSeparator(_ separator: UIView) { // Both colors are need for the vibrancy effect. diff --git a/WordPress/Classes/ViewRelated/SuggestionViewModel.swift b/WordPress/Classes/ViewRelated/SuggestionViewModel.swift new file mode 100644 index 000000000000..1c3c4aacd311 --- /dev/null +++ b/WordPress/Classes/ViewRelated/SuggestionViewModel.swift @@ -0,0 +1,25 @@ +import Foundation + +@objc final class SuggestionViewModel: NSObject { + + @objc private(set) var title: String? + + @objc let subtitle: String? + @objc let imageURL: URL? + + init(suggestion: UserSuggestion) { + if let username = suggestion.username { + self.title = "\(SuggestionType.mention.trigger)\(username)" + } + self.subtitle = suggestion.displayName + self.imageURL = suggestion.imageURL + } + + init(suggestion: SiteSuggestion) { + if let subdomain = suggestion.subdomain { + self.title = "\(SuggestionType.xpost.trigger)\(subdomain)" + } + self.subtitle = suggestion.title + self.imageURL = suggestion.blavatarURL + } +} diff --git a/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableView.h b/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableView.h index ee402f206b61..f7f77014a8a7 100644 --- a/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableView.h +++ b/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableView.h @@ -1,15 +1,28 @@ #import +typedef NS_CLOSED_ENUM(NSUInteger, SuggestionType) { + SuggestionTypeMention, + SuggestionTypeXpost +}; + +@protocol SuggestionsListViewModelType; @protocol SuggestionsTableViewDelegate; -@interface SuggestionsTableView : UIView +@interface SuggestionsTableView : UIView +@property (nonatomic, nonnull, strong, readonly) id viewModel; @property (nonatomic, nullable, weak) id suggestionsDelegate; -@property (nonatomic, nullable, strong) NSNumber *siteID; +@property (nonatomic, nullable, strong) NSArray *prominentSuggestionsIds; @property (nonatomic) BOOL useTransparentHeader; +@property (nonatomic) BOOL animateWithKeyboard; +@property (nonatomic) BOOL showLoading; -- (nonnull instancetype)init; +- (nonnull instancetype)initWithSiteID:(NSNumber *_Nullable)siteID + suggestionType:(SuggestionType)suggestionType + delegate:(id _Nonnull)suggestionsDelegate; +- (nonnull instancetype) initWithViewModel:(id _Nonnull)viewModel + delegate:(id _Nonnull)suggestionsDelegate; /** Enables or disables the SuggestionsTableView component. @@ -21,10 +34,15 @@ */ - (void)setUseTransparentHeader:(BOOL)useTransparentHeader; -/** - Show suggestions for the given word - returns YES if at least one suggestion is being shown -*/ -- (BOOL)showSuggestionsForWord:(nonnull NSString *)word; +- (void)hideSuggestions; + +/// Select the suggestion at a certain position and triggers the selection delegate +/// @param indexPath the index to select +- (void)selectSuggestionAtIndexPath:(NSIndexPath * _Nonnull)indexPath; + +/// Show suggestions for the given word. +/// @param word Used to find the suggestions that contain this word. +- (BOOL)showSuggestionsForWord:(nonnull NSString *)string; @end @@ -45,4 +63,21 @@ */ - (void)suggestionsTableView:(nonnull SuggestionsTableView *)suggestionsTableView didChangeTableBounds:(CGRect)bounds; + +/** + When the suggestionsTableView has completed subview layout, the SuggestionsTableView + will call this method to let the UIViewController know + */ +- (NSInteger)suggestionsTableViewMaxDisplayedRows:(nonnull SuggestionsTableView *)suggestionsTableView; + +/// This method is called every the header view above the suggestion is tapped. +/// @param suggestionsTableView the suggestion view. +- (void)suggestionsTableViewDidTapHeader:(nonnull SuggestionsTableView *)suggestionsTableView; + +/// This method returns the header view minimum height. +/// Can be used as a hit area for the user to dismiss the suggestions list. +/// @param suggestionsTableView the suggestion view. +- (CGFloat)suggestionsTableViewHeaderMinimumHeight:(nonnull SuggestionsTableView *)suggestionsTableView; + + @end diff --git a/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableView.m b/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableView.m index 1366c175a645..2185ae5e1634 100644 --- a/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableView.m +++ b/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableView.m @@ -1,21 +1,19 @@ #import "SuggestionsTableView.h" #import "WPStyleGuide+Suggestions.h" #import "SuggestionsTableViewCell.h" -#import "Suggestion.h" -#import "SuggestionService.h" +#import "WordPress-Swift.h" +CGFloat const STVDefaultMinHeaderHeight = 0.f; NSString * const CellIdentifier = @"SuggestionsTableViewCell"; CGFloat const STVRowHeight = 44.f; CGFloat const STVSeparatorHeight = 1.f; @interface SuggestionsTableView () +@property (nonatomic, readonly, nonnull, strong) UITableView *tableView; @property (nonatomic, strong) UIView *headerView; @property (nonatomic, strong) UIView *separatorView; -@property (nonatomic, strong) UITableView *tableView; -@property (nonatomic, strong) NSArray *suggestions; -@property (nonatomic, strong) NSString *searchText; -@property (nonatomic, strong) NSMutableArray *searchResults; +@property (nonatomic, strong) NSLayoutConstraint *headerMinimumHeightConstraint; @property (nonatomic, strong) NSLayoutConstraint *heightConstraint; @end @@ -24,14 +22,28 @@ @implementation SuggestionsTableView #pragma mark Public methods -- (instancetype)init -{ +- (instancetype)initWithSiteID:(NSNumber *)siteID + suggestionType:(SuggestionType)suggestionType + delegate:(id )suggestionsDelegate +{ + NSManagedObjectContext *contextManager = [ContextManager sharedInstance].mainContext; + SuggestionsListViewModel *viewModel = [[SuggestionsListViewModel alloc] initWithSiteID:siteID context:contextManager]; + viewModel.suggestionType = suggestionType; + return [self initWithViewModel:viewModel delegate:suggestionsDelegate]; +} + +- (nonnull instancetype) initWithViewModel:(id )viewModel + delegate:(id )suggestionsDelegate +{ self = [super initWithFrame:CGRectZero]; if (self) { - _searchText = @""; + _suggestionsDelegate = suggestionsDelegate; _enabled = YES; - _searchResults = [[NSMutableArray alloc] init]; _useTransparentHeader = NO; + _animateWithKeyboard = YES; + _showLoading = NO; + _viewModel = viewModel; + [self setupViewModel]; [self setupHeaderView]; [self setupTableView]; [self setupConstraints]; @@ -40,12 +52,28 @@ - (instancetype)init return self; } +#pragma mark - Custom Setters + +- (void)setProminentSuggestionsIds:(NSArray *)prominentSuggestionsIds +{ + _prominentSuggestionsIds = prominentSuggestionsIds; + self.viewModel.prominentSuggestionsIds = prominentSuggestionsIds; +} + - (void)setUseTransparentHeader:(BOOL)useTransparentHeader { _useTransparentHeader = useTransparentHeader; [self updateHeaderStyles]; } +#pragma mark - View Lifecycle + +- (void)didMoveToSuperview { + [super didMoveToSuperview]; + if (self.superview) { + [self.viewModel reloadData]; + } +} #pragma mark Private methods @@ -53,16 +81,30 @@ - (void)updateHeaderStyles { if (_useTransparentHeader) { [self.headerView setBackgroundColor: [UIColor clearColor]]; + [self.separatorView setBackgroundColor: [WPStyleGuide suggestionsSeparatorSmoke]]; } else { [self.headerView setBackgroundColor: [WPStyleGuide suggestionsHeaderSmoke]]; + [self.separatorView setBackgroundColor: [WPStyleGuide suggestionsHeaderSmoke]]; } - +} + +- (void)setupViewModel +{ + __weak __typeof(self) weakSelf = self; + self.viewModel.searchResultUpdated = ^(id __unused viewModel) { + if (!weakSelf) return; + [weakSelf.tableView reloadData]; + [weakSelf setNeedsUpdateConstraints]; + [weakSelf setNeedsLayout]; + [weakSelf layoutIfNeeded]; + }; } - (void)setupHeaderView { _headerView = [[UIView alloc] init]; [_headerView setTranslatesAutoresizingMaskIntoConstraints:NO]; + [_headerView addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didTapHeader)] ]; [self addSubview:_headerView]; _separatorView = [[UIView alloc] init]; @@ -75,14 +117,22 @@ - (void)setupHeaderView - (void)setupTableView { - _tableView = [[UITableView alloc] init]; + _tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleGrouped]; [_tableView registerClass:[SuggestionsTableViewCell class] forCellReuseIdentifier:CellIdentifier]; [_tableView setDataSource:self]; [_tableView setDelegate:self]; [_tableView setTranslatesAutoresizingMaskIntoConstraints:NO]; [_tableView setRowHeight:STVRowHeight]; + [_tableView setBackgroundColor:[UIColor systemBackgroundColor]]; + + // Removes a small padding at the bottom of the tableView + UIView *footerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 0, CGFLOAT_MIN)]; + footerView.backgroundColor = [UIColor clearColor]; + [_tableView setTableFooterView:footerView]; + // Table separator insets defined to match left edge of username in cell. [_tableView setSeparatorInset:UIEdgeInsetsMake(0.f, 47.f, 0.f, 0.f)]; + // iOS8 added and requires the following in order for that separator inset to be used if ([self.tableView respondsToSelector:@selector(setLayoutMargins:)]) { [_tableView setLayoutMargins:UIEdgeInsetsZero]; @@ -94,7 +144,7 @@ - (void)setupConstraints { // Pin the table view to the view's edges NSDictionary *views = @{@"headerview": self.headerView, - @"separatorview" : self.separatorView, + @"separatorview": self.separatorView, @"tableview": self.tableView }; NSDictionary *metrics = @{@"separatorheight" : @(STVSeparatorHeight)}; [self addConstraints:[NSLayoutConstraint constraintsWithVisualFormat:@"H:|[headerview]|" @@ -118,6 +168,16 @@ - (void)setupConstraints metrics:metrics views:views]]; + // Add a height constraint to the header view which we can later adjust via the delegate + self.headerMinimumHeightConstraint = [NSLayoutConstraint constraintWithItem:self.headerView + attribute:NSLayoutAttributeHeight + relatedBy:NSLayoutRelationGreaterThanOrEqual + toItem:nil + attribute:NSLayoutAttributeNotAnAttribute + multiplier:1 + constant:0.f]; + [self addConstraint:self.headerMinimumHeightConstraint]; + // Add a height constraint to the table view self.heightConstraint = [NSLayoutConstraint constraintWithItem:self.tableView attribute:NSLayoutAttributeHeight @@ -133,11 +193,6 @@ - (void)setupConstraints - (void)startObservingNotifications { - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(suggestionListUpdated:) - name:SuggestionListUpdatedNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardDidChangeFrame:) name:UIKeyboardDidChangeFrameNotification @@ -152,8 +207,11 @@ - (void)startObservingNotifications - (void)layoutSubviews { [super layoutSubviews]; - NSUInteger suggestionCount = self.searchResults.count; - [self setHidden:(0 == suggestionCount)]; + NSUInteger suggestionCount = self.viewModel.sections.count; + BOOL isSearchApplied = !self.viewModel.searchText.isEmpty; + BOOL isLoadingSuggestions = self.viewModel.isLoading; + BOOL showTable = (self.showLoading && isSearchApplied && isLoadingSuggestions) || suggestionCount > 0; + [self setHidden:!showTable]; if ([self.suggestionsDelegate respondsToSelector:@selector(suggestionsTableView:didChangeTableBounds:)]) { [self.suggestionsDelegate suggestionsTableView:self didChangeTableBounds:self.tableView.bounds]; } @@ -161,29 +219,44 @@ - (void)layoutSubviews - (void)updateConstraints { - // Take the height of the table frame and make it so only whole results are displayed - NSUInteger maxRows = floor(self.frame.size.height / STVRowHeight); - - if (maxRows < 1) { - maxRows = 1; - } - - if (self.searchResults.count > maxRows) { - self.heightConstraint.constant = maxRows * STVRowHeight; + // Ask the delegate for a minimum header height, otherwise use default value. + CGFloat minimumHeaderHeight = STVDefaultMinHeaderHeight; + if ([self.suggestionsDelegate respondsToSelector:@selector(suggestionsTableViewHeaderMinimumHeight:)]) { + minimumHeaderHeight = [self.suggestionsDelegate suggestionsTableViewHeaderMinimumHeight:self]; + } + self.headerMinimumHeightConstraint.constant = minimumHeaderHeight; + + // Get the number of max rows from the delegate. + NSNumber *maxRows = nil; + if([self.suggestionsDelegate respondsToSelector:@selector(suggestionsTableViewMaxDisplayedRows:)]){ + NSUInteger delegateMaxRows = [self.suggestionsDelegate suggestionsTableViewMaxDisplayedRows:self]; + maxRows = [NSNumber numberWithUnsignedInteger:delegateMaxRows]; + } + + // Set height constraint + [self.tableView setNeedsLayout]; + [self.tableView layoutIfNeeded]; + if (maxRows) { + self.heightConstraint.constant = [SuggestionsTableView maximumHeightForTableView:self.tableView + maxNumberOfRowsToDisplay:maxRows]; } else { - self.heightConstraint.constant = self.searchResults.count * STVRowHeight; + self.heightConstraint.constant = [SuggestionsTableView heightForTableView:self.tableView + maximumHeight:self.bounds.size.height - minimumHeaderHeight]; } - [super updateConstraints]; } - (void)keyboardDidChangeFrame:(NSNotification *)notification { + if (!self.animateWithKeyboard) { + return; + } + NSDictionary *info = [notification userInfo]; NSTimeInterval animationDuration = [[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; [self setNeedsUpdateConstraints]; - + [UIView animateWithDuration:animationDuration animations:^{ [self layoutIfNeeded]; }]; @@ -196,131 +269,28 @@ - (void)deviceOrientationDidChange:(NSNotification *)notification #pragma mark - Public methods -- (BOOL)showSuggestionsForWord:(NSString *)word -{ - if (!self.enabled) { - return NO; - } - - if ([word hasPrefix:@"@"]) { - self.searchText = [word substringFromIndex:1]; - if (self.searchText.length > 0) { - NSPredicate *resultPredicate = [NSPredicate predicateWithFormat:@"(displayName contains[c] %@) OR (userLogin contains[c] %@)", - self.searchText, self.searchText]; - self.searchResults = [[self.suggestions filteredArrayUsingPredicate:resultPredicate] mutableCopy]; - } else { - self.searchResults = [self.suggestions mutableCopy]; - } - } else { - self.searchText = @""; - [self.searchResults removeAllObjects]; - } - - [self.tableView reloadData]; - [self setNeedsUpdateConstraints]; - - return ([self.searchResults count] > 0); -} - -#pragma mark - UITableViewDataSource methods - -- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView -{ - return 1; -} - -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section -{ - if (!self.suggestions) { - return 1; - } - - return self.searchResults.count; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath -{ - SuggestionsTableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:CellIdentifier - forIndexPath:indexPath]; - - if (!self.suggestions) { - cell.usernameLabel.text = NSLocalizedString(@"Loading...", @"Suggestions loading message"); - cell.displayNameLabel.text = nil; - [cell.avatarImageView setImage:nil]; - cell.selectionStyle = UITableViewCellSelectionStyleNone; - return cell; - } - - Suggestion *suggestion = [self.searchResults objectAtIndex:indexPath.row]; - - cell.usernameLabel.text = [NSString stringWithFormat:@"@%@", suggestion.userLogin]; - cell.displayNameLabel.text = suggestion.displayName; - cell.selectionStyle = UITableViewCellSelectionStyleDefault; - cell.avatarImageView.image = [UIImage imageNamed:@"gravatar"]; - - [self loadAvatarForSuggestion:suggestion success:^(UIImage *image) { - if (indexPath.row >= self.searchResults.count) { - return; - } - - Suggestion *reloaded = [self.searchResults objectAtIndex:indexPath.row]; - if ([reloaded.imageURL isEqual:suggestion.imageURL] == false) { - return; - } - - cell.avatarImageView.image = image; - }]; - - return cell; -} - -#pragma mark - UITableViewDelegate - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath +- (BOOL)showSuggestionsForWord:(NSString *)string { - Suggestion *suggestion = [self.searchResults objectAtIndex:indexPath.row]; - [self.suggestionsDelegate suggestionsTableView:self - didSelectSuggestion:suggestion.userLogin - forSearchText:self.searchText]; + if (!self.enabled) { return false; } + return [self.viewModel searchSuggestionsWithWord: string]; } -#pragma mark - Suggestion list management - -- (void)suggestionListUpdated:(NSNotification *)notification +- (void)hideSuggestions { - // only reload if the suggestion list is updated for the current site - if (self.siteID && [notification.object isEqualToNumber:self.siteID]) { - self.suggestions = [[SuggestionService sharedInstance] suggestionsForSiteID:self.siteID]; - [self showSuggestionsForWord:self.searchText]; - } + [self showSuggestionsForWord:@""]; } -- (NSArray *)suggestions -{ - if (!_suggestions && _siteID != nil) { - _suggestions = [[SuggestionService sharedInstance] suggestionsForSiteID:self.siteID]; +- (void)selectSuggestionAtIndexPath:(NSIndexPath *)indexPath { + NSArray *sections = self.viewModel.sections; + if (indexPath.section < sections.count && indexPath.row < sections[indexPath.section].rows.count) { + [self tableView:self.tableView didSelectRowAtIndexPath:indexPath]; } - return _suggestions; } -#pragma mark - Avatar helper - -- (void)loadAvatarForSuggestion:(Suggestion *)suggestion success:(void (^)(UIImage *))success -{ - CGSize imageSize = CGSizeMake(SuggestionsTableViewCellAvatarSize, SuggestionsTableViewCellAvatarSize); - UIImage *image = [suggestion cachedAvatarWithSize:imageSize]; - if (image) { - success(image); - return; +- (void)didTapHeader { + if ([self.suggestionsDelegate respondsToSelector:@selector(suggestionsTableViewDidTapHeader:)]) { + [self.suggestionsDelegate suggestionsTableViewDidTapHeader:self]; } - - [suggestion fetchAvatarWithSize:imageSize success:^(UIImage *image) { - if (!image) { - return; - } - - success(image); - }]; } @end diff --git a/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableView.swift b/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableView.swift new file mode 100644 index 000000000000..587b7e9aa776 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableView.swift @@ -0,0 +1,207 @@ +import Foundation +import UIKit + +extension SuggestionType { + var trigger: String { + switch self { + case .mention: return "@" + case .xpost: return "+" + } + } +} + +@objc extension SuggestionsTableView: UITableViewDataSource, UITableViewDelegate { + + // MARK: - Static Properties + + private static let nonEmptyString = "_" + + // MARK: - UITableViewDataSource & UITableViewDelegate + + public func numberOfSections(in tableView: UITableView) -> Int { + return viewModel.isLoading ? 1 : viewModel.sections.count + } + + public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.isLoading ? 1 : viewModel.sections[section].rows.count + } + + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "SuggestionsTableViewCell", for: indexPath) as! SuggestionsTableViewCell + if viewModel.isLoading { + cell.titleLabel.text = NSLocalizedString("Loading...", comment: "Suggestions loading message") + cell.subtitleLabel.text = nil + cell.iconImageView.image = nil + cell.selectionStyle = .none + return cell + } + + cell.selectionStyle = .default + + let suggestion = viewModel.sections[indexPath.section].rows[indexPath.row] + cell.titleLabel.text = suggestion.title + cell.subtitleLabel.text = suggestion.subtitle + self.loadImage(for: suggestion, in: cell, at: indexPath, with: viewModel) + + return cell + } + + public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + // Ideally, we should be returning `nil` instead of `Self.nonEmptyString`. + // But when this method returns `nil` ( or empty string ), `tableView:heightForHeaderInSection:` method doesn't get called + // As a result, the section header is not hidden. Same behavior for section footers. + guard !viewModel.isLoading else { return Self.nonEmptyString } + return viewModel.sections[section].title ?? Self.nonEmptyString + } + + public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + // Show section header if there are more 2 sections or more. + return viewModel.sections.count > 1 ? tableView.sectionHeaderHeight : .leastNonzeroMagnitude + } + + public func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + return viewModel.sections.count > 1 ? nil : Self.nonEmptyString + } + + public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + // Show section footer if there are more 2 sections or more. + return viewModel.sections.count > 1 ? tableView.sectionFooterHeight : .leastNonzeroMagnitude + } + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let sliceIndex = viewModel.suggestionType.trigger.endIndex + let suggestion = viewModel.item(at: indexPath) + let searchText = String(viewModel.searchText[sliceIndex...]) + let suggestionTitle = suggestion.title == nil ? nil : String(suggestion.title![sliceIndex...]) + self.suggestionsDelegate?.suggestionsTableView?(self, didSelectSuggestion: suggestionTitle, forSearchText: searchText) + } + + // MARK: - Layout + + /// Returns the ideal height for the table view that displays all sections and rows as long as this height doesn't exceed the given `maximumHeight`. + /// + static func height(forTableView tableView: UITableView, maximumHeight height: CGFloat) -> CGFloat { + return min(height, tableView.contentSize.height) + } + + /// Returns the ideal height for the table view to display the given number of rows. + /// + static func maximumHeight(forTableView tableView: UITableView, maxNumberOfRowsToDisplay maxRows: NSNumber) -> CGFloat { + guard let maxRowIndexPath = indexPath(forRowAtPosition: maxRows.intValue, in: tableView) else { + return 0 + } + let maxTableViewHeight = heightFromBeginningOfTableView(tableView, toRowAtIndexPath: maxRowIndexPath) + return Self.height(forTableView: tableView, maximumHeight: maxTableViewHeight) + } + + /// Returns a height from the beinning of the table view to the given row index path. + /// + /// This method is used to make a portion of the table view visible ( beginning to row index path ) without the need of scrolling. + /// + private static func heightFromBeginningOfTableView(_ tableView: UITableView, toRowAtIndexPath indexPath: IndexPath) -> CGFloat { + var size = CGSize(width: tableView.bounds.width, height: 0) + let rowHeight = tableView.rowHeight + for section in 0...indexPath.section { + size.height += tableView.rectForHeader(inSection: section).height + if section == indexPath.section { + size.height += CGFloat(indexPath.row + 1) * rowHeight + if indexPath.row == tableView.numberOfRows(inSection: section) - 1 { + size.height += tableView.rectForFooter(inSection: section).height + } + } else { + size.height = size.height + + CGFloat(tableView.numberOfRows(inSection: section)) * rowHeight + + tableView.rectForFooter(inSection: section).height + } + } + return size.height + } + + /// Maps the row position to an index path. + /// + /// The position of a row doesn't take into account table view sections. For example: + /// + /// Section #1 + /// - Position 0 - IndexPath(row: 0, section: 0) + /// - Position 1 - IndexPath(row: 1, section: 0) + /// + /// Section #2 + /// - Position 2 - IndexPath(row: 0, section: 1) + /// - Position 3 - IndexPath(row: 1, section: 1) + /// + private static func indexPath(forRowAtPosition position: Int, in tableView: UITableView) -> IndexPath? { + var indexPath: IndexPath? + let numberOfSections = tableView.numberOfSections + var remainingRows = position + for section in 0.. 0 else { break } + let numberOfRows = tableView.numberOfRows(inSection: section) + let maxRow = min(remainingRows, numberOfRows) + indexPath = IndexPath(row: maxRow - 1, section: section) + remainingRows -= maxRow + } + return indexPath + } + + // MARK: - API + + /// Returns the a list of prominent suggestions excluding the current user. + static func prominentSuggestions(fromPostAuthorId postAuthorId: NSNumber?, commentAuthorId: NSNumber?, defaultAccountId: NSNumber?) -> [NSNumber] { + return [postAuthorId, commentAuthorId].compactMap { $0 != defaultAccountId ? $0 : nil } + } + + // MARK: - Private + + private func loadImage(for suggestion: SuggestionViewModel, in cell: SuggestionsTableViewCell, at indexPath: IndexPath, with viewModel: SuggestionsListViewModelType) { + cell.iconImageView.image = UIImage(named: "gravatar") + guard let imageURL = suggestion.imageURL else { return } + cell.imageDownloadHash = imageURL.hashValue + + retrieveIcon(for: imageURL) { image in + guard indexPath.section < viewModel.sections.count && indexPath.row < viewModel.sections[indexPath.section].rows.count else { return } + if let reloadedImageURL = viewModel.sections[indexPath.section].rows[indexPath.row].imageURL, reloadedImageURL.hashValue == cell.imageDownloadHash { + cell.iconImageView.image = image + } + } + } + + private func retrieveIcon(for imageURL: URL?, success: @escaping (UIImage?) -> Void) { + let imageSize = CGSize(width: SuggestionsTableViewCellIconSize, height: SuggestionsTableViewCellIconSize) + + if let image = cachedIcon(for: imageURL, with: imageSize) { + success(image) + } else { + fetchIcon(for: imageURL, with: imageSize, success: success) + } + } + + private func cachedIcon(for imageURL: URL?, with size: CGSize) -> UIImage? { + var hash: NSString? + let type = avatarSourceType(for: imageURL, with: &hash) + + if let hash = hash, let type = type { + return WPAvatarSource.shared()?.cachedImage(forAvatarHash: hash as String, of: type, with: size) + } + return nil + } + + private func fetchIcon(for imageURL: URL?, with size: CGSize, success: @escaping ((UIImage?) -> Void)) { + var hash: NSString? + let type = avatarSourceType(for: imageURL, with: &hash) + + if let hash = hash, let type = type { + WPAvatarSource.shared()?.fetchImage(forAvatarHash: hash as String, of: type, with: size, success: success) + } else { + success(nil) + } + } +} + +extension SuggestionsTableView { + func avatarSourceType(for imageURL: URL?, with hash: inout NSString?) -> WPAvatarSourceType? { + if let imageURL = imageURL { + return WPAvatarSource.shared()?.parseURL(imageURL, forAvatarHash: &hash) + } + return .unknown + } +} diff --git a/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableViewCell.h b/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableViewCell.h index a98da3db381b..f88b95801d39 100644 --- a/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableViewCell.h +++ b/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableViewCell.h @@ -1,11 +1,12 @@ #import -extern NSInteger const SuggestionsTableViewCellAvatarSize; +extern NSInteger const SuggestionsTableViewCellIconSize; @interface SuggestionsTableViewCell : UITableViewCell -@property (nonatomic, strong) UILabel *usernameLabel; -@property (nonatomic, strong) UILabel *displayNameLabel; -@property (nonatomic, strong) UIImageView *avatarImageView; +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *subtitleLabel; +@property (nonatomic, strong) UIImageView *iconImageView; +@property (nonatomic, assign) NSInteger imageDownloadHash; @end diff --git a/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableViewCell.m b/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableViewCell.m index d7712abf054a..eff54b4d0cf3 100644 --- a/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableViewCell.m +++ b/WordPress/Classes/ViewRelated/Suggestions/SuggestionsTableViewCell.m @@ -2,7 +2,7 @@ #import #import "WordPress-Swift.h" -NSInteger const SuggestionsTableViewCellAvatarSize = 23; +NSInteger const SuggestionsTableViewCellIconSize = 24; @implementation SuggestionsTableViewCell @@ -10,61 +10,66 @@ - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSStr { self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; if (self) { - [self setupUsernameLabel]; - [self setupDisplayNameLabel]; - [self setupAvatarImageView]; + [self setupTitleLabel]; + [self setupSubtitleLabel]; + [self setupIconImageView]; [self setupConstraints]; + self.backgroundColor = [UIColor murielListForeground]; } return self; } -- (void)setupUsernameLabel +- (void)prepareForReuse { + [super prepareForReuse]; + self.imageDownloadHash = 0; +} + +- (void)setupTitleLabel { - _usernameLabel = [[UILabel alloc] init]; - [_usernameLabel setTextColor:[UIColor murielPrimary]]; - [_usernameLabel setFont:[WPFontManager systemRegularFontOfSize:14.0]]; - [_usernameLabel setTranslatesAutoresizingMaskIntoConstraints:NO]; - [self.contentView addSubview:_usernameLabel]; + _titleLabel = [[UILabel alloc] init]; + [_titleLabel setTextColor:[UIColor murielPrimary]]; + [_titleLabel setFont:[WPFontManager systemRegularFontOfSize:17.0]]; + [_titleLabel setTranslatesAutoresizingMaskIntoConstraints:NO]; + [self.contentView addSubview:_titleLabel]; } -- (void)setupDisplayNameLabel +- (void)setupSubtitleLabel { - _displayNameLabel = [[UILabel alloc] init]; - [_displayNameLabel setTextColor:[WPStyleGuide allTAllShadeGrey]]; - [_displayNameLabel setFont:[WPFontManager systemRegularFontOfSize:14.0]]; - [_displayNameLabel setTranslatesAutoresizingMaskIntoConstraints:NO]; - _displayNameLabel.textAlignment = NSTextAlignmentRight; - [self.contentView addSubview:_displayNameLabel]; + _subtitleLabel = [[UILabel alloc] init]; + [_subtitleLabel setTextColor:[UIColor murielTextSubtle]]; + [_subtitleLabel setFont:[WPFontManager systemRegularFontOfSize:14.0]]; + [_subtitleLabel setTranslatesAutoresizingMaskIntoConstraints:NO]; + _subtitleLabel.textAlignment = NSTextAlignmentRight; + [self.contentView addSubview:_subtitleLabel]; } -- (void)setupAvatarImageView +- (void)setupIconImageView { - _avatarImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, SuggestionsTableViewCellAvatarSize, SuggestionsTableViewCellAvatarSize)]; - _avatarImageView.contentMode = UIViewContentModeScaleAspectFit; - _avatarImageView.clipsToBounds = YES; - _avatarImageView.image = [UIImage imageNamed:@"gravatar.png"]; - [_avatarImageView setTranslatesAutoresizingMaskIntoConstraints:NO]; - [self.contentView addSubview:_avatarImageView]; + _iconImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, SuggestionsTableViewCellIconSize, SuggestionsTableViewCellIconSize)]; + _iconImageView.contentMode = UIViewContentModeScaleAspectFit; + _iconImageView.clipsToBounds = YES; + [_iconImageView setTranslatesAutoresizingMaskIntoConstraints:NO]; + [self.contentView addSubview:_iconImageView]; } - (void)setupConstraints { NSDictionary *views = @{@"contentview": self.contentView, - @"username": _usernameLabel, - @"displayname": _displayNameLabel, - @"avatar": _avatarImageView }; + @"title": _titleLabel, + @"subtitle": _subtitleLabel, + @"icon": _iconImageView }; - NSDictionary *metrics = @{@"avatarsize": @(SuggestionsTableViewCellAvatarSize) }; + NSDictionary *metrics = @{@"iconsize": @(SuggestionsTableViewCellIconSize) }; // Horizontal spacing - NSArray *horizConstraints = [NSLayoutConstraint constraintsWithVisualFormat:@"H:|-[avatar(avatarsize)]-16-[username]-[displayname]-|" + NSArray *horizConstraints = [NSLayoutConstraint constraintsWithVisualFormat:@"H:|-[icon(iconsize)]-16-[title]-[subtitle]-|" options:0 metrics:metrics views:views]; [self.contentView addConstraints:horizConstraints]; // Vertically constrain centers of each element - [self.contentView addConstraint:[NSLayoutConstraint constraintWithItem:_usernameLabel + [self.contentView addConstraint:[NSLayoutConstraint constraintWithItem:_titleLabel attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:self.contentView @@ -72,7 +77,7 @@ - (void)setupConstraints multiplier:1.0 constant:0]]; - [self.contentView addConstraint:[NSLayoutConstraint constraintWithItem:_displayNameLabel + [self.contentView addConstraint:[NSLayoutConstraint constraintWithItem:_subtitleLabel attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:self.contentView @@ -80,7 +85,7 @@ - (void)setupConstraints multiplier:1.0 constant:0]]; - [self.contentView addConstraint:[NSLayoutConstraint constraintWithItem:_avatarImageView + [self.contentView addConstraint:[NSLayoutConstraint constraintWithItem:_iconImageView attribute:NSLayoutAttributeCenterY relatedBy:NSLayoutRelationEqual toItem:self.contentView diff --git a/WordPress/Classes/ViewRelated/SuggestionsListViewModel.swift b/WordPress/Classes/ViewRelated/SuggestionsListViewModel.swift new file mode 100644 index 000000000000..d2442e7849da --- /dev/null +++ b/WordPress/Classes/ViewRelated/SuggestionsListViewModel.swift @@ -0,0 +1,265 @@ +import Foundation +import CoreData + +@objc final class SuggestionsListSection: NSObject { + @objc var title: String? + @objc var rows: [SuggestionViewModel] = [] +} + +@objc final class SuggestionsListViewModel: NSObject, SuggestionsListViewModelType { + + // MARK: - Dependencies + + var context = ContextManager.shared.mainContext + var userSuggestionService = SuggestionService.shared + var siteSuggestionService = SiteSuggestionService.shared + + // MARK: - Configuration + + @objc var suggestionType: SuggestionType = .mention { + didSet { + self.searchSuggestions(withWord: searchText) + } + } + + @objc var prominentSuggestionsIds: [NSNumber]? { + didSet { + self.searchSuggestions(withWord: searchText) + } + } + + // MARK: - Input + + let blog: Blog? + + // MARK: - State + + @objc private(set) var isLoading = false + @objc private(set) var sections = [SuggestionsListSection]() + + private(set) var suggestions = Suggestions.users([]) + + private var searchResult: SearchResult? + + var suggestionTrigger: String { + return suggestionType.trigger + } + + @objc private(set) var searchText: String = "" + + // MARK: - Callback + + /// Called when the search result is updated. + @objc var searchResultUpdated: StateUpdatedHandler? + + // MARK: - Init + + init(blog: Blog?) { + self.blog = blog + } + + @objc convenience init(siteID: NSNumber, context: NSManagedObjectContext = ContextManager.shared.mainContext) { + let blog = Blog.lookup(withID: siteID, in: context) + self.init(blog: blog) + self.context = context + } + + // MARK: - Load Data + + @objc func reloadData() { + guard let blog = self.blog else { return } + self.isLoading = true + switch suggestionType { + case .mention: + self.userSuggestionService.suggestions(for: blog) { [weak self] suggestions in + guard let self = self else { return } + self.suggestions = Suggestions.users(suggestions ?? []) + self.isLoading = false + self.searchSuggestions(withWord: self.searchText) + } + case .xpost: + self.siteSuggestionService.suggestions(for: blog) { [weak self] suggestions in + guard let self = self else { return } + self.suggestions = Suggestions.sites(suggestions ?? []) + self.isLoading = false + self.searchSuggestions(withWord: self.searchText) + } + } + } + + // MARK: - Performing Search + + /// Searches suggestions for the given word. + /// - Parameter word: The suggestions that contain this word. + /// - Returns: True when there is at least one suggestion. + @discardableResult @objc func searchSuggestions(withWord word: String) -> Bool { + if word.hasPrefix(suggestionTrigger) { + let searchQuery = NSString(string: word).substring(from: suggestionTrigger.count) + self.searchText = word + self.searchResult = Self.searchResult( + searchQuery: searchQuery, + suggestions: suggestions, + suggestionType: suggestionType, + prominentSuggestionsIds: prominentSuggestionsIds ?? [] + ) + } else { + self.searchText = "" + self.searchResult = nil + } + + // Map searchResult to sections + self.sections = Self.sectionsFromSearchResult(searchResult) + + // Call callback and return result + self.searchResultUpdated?(self) + return sections.count > 0 + } + + private static func searchResult(searchQuery: String, suggestions: Suggestions, suggestionType: SuggestionType, prominentSuggestionsIds: [NSNumber]) -> SearchResult { + var suggestions = suggestions + if !searchQuery.isEmpty { + let predicate = Self.predicate(for: searchQuery, suggestionType: suggestionType) + suggestions = suggestions.filtered(using: predicate) + } + switch suggestions { + case .users(let userSuggestions): + let sortedUserSuggestions = Self.sort( + userSuggestions: userSuggestions, by: searchQuery + ) + return Self.searchResultByMovingProminentSuggestionsToTop( + userSuggestions: sortedUserSuggestions, + prominentSuggestionsIds: prominentSuggestionsIds + ) + case .sites(let siteSuggestions): + return .sites(siteSuggestions) + } + } + + /// Sort user suggestions by prefix first, then alphabetically. The collection is sorted first by checking if username or displayName begins with the provided prefix. The remaining items are sorted alphabetically by displayName. The prefix comparison is both case-insensitive and diacritic-insensitive. + /// - Parameter userSuggestions: The user suggestions collection to be sorted. + /// - Parameter prefix: The prefix to be used when checking the usernames and displayNames. + /// - Returns: The sorted user suggestion collection. + private static func sort(userSuggestions: [UserSuggestion], by prefix: String) -> [UserSuggestion] { + guard !userSuggestions.isEmpty, !prefix.isEmpty else { return userSuggestions } + let compareOptions: String.CompareOptions = [.anchored, .caseInsensitive, .diacriticInsensitive] + let triagedList = Dictionary(grouping: userSuggestions) { suggestion in + suggestion.username?.hasPrefix(prefix, with: compareOptions) == true || suggestion.displayName?.hasPrefix(prefix, with: compareOptions) == true + } + return (triagedList[true] ?? []).sorted() + (triagedList[false] ?? []).sorted() + } + + private static func searchResultByMovingProminentSuggestionsToTop(userSuggestions: [UserSuggestion], prominentSuggestionsIds ids: [NSNumber]) -> SearchResult { + // Do not proceed if `searchResults` or `prominentSuggestionsIds` is empty. + guard !(userSuggestions.isEmpty || ids.isEmpty) else { return .users(prominent: [], regular: userSuggestions) } + + // Loop through `searchResults` and find the following data: + // + // 1. suggestionIndexesToRemove: User Suggestions should be removed from their old position. + // + // 2. suggestionsToInsert: User Suggestions to insert at the beginning of `searchResults` + // while maintaining their order from `prominentSuggestionsIds`. + // + var suggestionIndexesToRemove = [Int]() + var prominentSuggestions: [UserSuggestion?] = Array(repeating: nil, count: ids.count) + for (index, suggestion) in userSuggestions.enumerated() { + guard let position = ids.firstIndex(where: { suggestion.userID == $0 }) else { continue } + suggestionIndexesToRemove.append(index) + prominentSuggestions[position] = suggestion + } + + // Move suggestions to the beginning of `searchResults` array. + var userSuggestions = userSuggestions + if !prominentSuggestions.isEmpty && suggestionIndexesToRemove.count > 0 { + let prominentSuggestions = prominentSuggestions.compactMap { $0 } + suggestionIndexesToRemove = suggestionIndexesToRemove.sorted(by: >) + suggestionIndexesToRemove.forEach { userSuggestions.remove(at: $0) } + return .users(prominent: prominentSuggestions, regular: userSuggestions) + } else { + return .users(prominent: [], regular: userSuggestions) + } + } + + private static func sectionsFromSearchResult(_ searchResult: SearchResult?) -> [Section] { + guard let searchResult = searchResult else { return [] } + switch searchResult { + case .users(let prominent, let regular): + let shouldShowSectionTitle = !prominent.isEmpty && !regular.isEmpty + var sections = [Section]() + if !prominent.isEmpty { + let section = Section() + if shouldShowSectionTitle { + section.title = NSLocalizedString("suggestions.section.prominent", value: "In this conversation", comment: "Section title for prominent suggestions") + } + section.rows = prominent.map { SuggestionViewModel(suggestion: $0) } + sections.append(section) + } + if !regular.isEmpty { + let section = Section() + if shouldShowSectionTitle { + section.title = NSLocalizedString("suggestions.section.regular", value: "Site members", comment: "Section title for regular suggestions") + } + section.rows = regular.map { SuggestionViewModel(suggestion: $0) } + sections.append(section) + } + return sections + case .sites(let sites): + let section = Section() + section.rows = sites.map { SuggestionViewModel(suggestion: $0) } + return [section] + } + } + + private static func predicate(for searchQuery: String, suggestionType: SuggestionType) -> NSPredicate { + switch suggestionType { + case .mention: + return NSPredicate(format: "(displayName contains[cd] %@) OR (username contains[cd] %@)", searchQuery, searchQuery) + case .xpost: + return NSPredicate(format: "(title contains[cd] %@) OR (siteURL.absoluteString contains[cd] %@)", searchQuery, searchQuery) + } + } + + // MARK: - Types + + typealias StateUpdatedHandler = (SuggestionsListViewModelType) -> Void + +} + +// MARK: - List Type + +extension SuggestionsListViewModel { + + typealias Section = SuggestionsListSection + + enum SearchResult { + case users(prominent: [UserSuggestion], regular: [UserSuggestion]) + case sites([SiteSuggestion]) + } + + enum Suggestions { + case users([UserSuggestion]) + case sites([SiteSuggestion]) + + var users: [UserSuggestion] { + switch self { + case .users(let suggestions): return suggestions + default: return [] + } + } + + var sites: [SiteSuggestion] { + switch self { + case .sites(let suggestions): return suggestions + default: return [] + } + } + + func filtered(using predicate: NSPredicate) -> Suggestions { + switch self { + case .users(let array): + return .users(NSMutableArray(array: array).filtered(using: predicate) as! [UserSuggestion]) + case .sites(let array): + return .sites(NSMutableArray(array: array).filtered(using: predicate) as! [SiteSuggestion]) + } + } + } +} diff --git a/WordPress/Classes/ViewRelated/SuggestionsViewModelType.swift b/WordPress/Classes/ViewRelated/SuggestionsViewModelType.swift new file mode 100644 index 000000000000..81e742c4afcd --- /dev/null +++ b/WordPress/Classes/ViewRelated/SuggestionsViewModelType.swift @@ -0,0 +1,35 @@ +import Foundation + +@objc protocol SuggestionsListViewModelType: AnyObject { + var suggestionType: SuggestionType { get set } + var prominentSuggestionsIds: [NSNumber]? { get set } + + var searchResultUpdated: ((SuggestionsListViewModelType) -> Void)? { get set } + + var isLoading: Bool { get } + var searchText: String { get } + + var sections: [SuggestionsListSection] { get } + + func reloadData() + func searchSuggestions(withWord word: String) -> Bool +} + +extension SuggestionsListViewModelType { + + var numberOfSections: Int { + return sections.count + } + + var numberOfItems: Int { + return sections.reduce(0) { $0 + $1.rows.count } + } + + func numberOfItems(inSection section: Int) -> Int { + return sections[section].rows.count + } + + func item(at indexPath: IndexPath) -> SuggestionViewModel { + return sections[indexPath.section].rows[indexPath.row] + } +} diff --git a/WordPress/Classes/ViewRelated/Support/LogOutActionHandler.swift b/WordPress/Classes/ViewRelated/Support/LogOutActionHandler.swift new file mode 100644 index 000000000000..05682bf88f11 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Support/LogOutActionHandler.swift @@ -0,0 +1,45 @@ +import UIKit + +struct LogOutActionHandler { + + private weak var windowManager: WindowManager? + + init(windowManager: WindowManager? = WordPressAppDelegate.shared?.windowManager) { + self.windowManager = windowManager + } + + func logOut(with viewController: UIViewController) { + let alert = UIAlertController(title: logOutAlertTitle, message: nil, preferredStyle: .alert) + alert.addActionWithTitle(Strings.alertCancelAction, style: .cancel) + alert.addActionWithTitle(Strings.alertLogoutAction, style: .destructive) { [weak viewController] _ in + viewController?.dismiss(animated: true) { + AccountHelper.logOutDefaultWordPressComAccount() + windowManager?.showSignInUI() + } + } + viewController.present(alert, animated: true) + } + + private var logOutAlertTitle: String { + let context = ContextManager.sharedInstance().mainContext + let count = AbstractPost.countLocalPosts(in: context) + + guard count > 0 else { + return Strings.alertDefaultTitle + } + + let format = count > 1 ? Strings.alertUnsavedTitlePlural : Strings.alertUnsavedTitleSingular + return String(format: format, count) + } + + + private struct Strings { + static let alertDefaultTitle = AppConstants.Logout.alertTitle + static let alertUnsavedTitleSingular = NSLocalizedString("You have changes to %d post that hasn't been uploaded to your site. Logging out now will delete those changes. Log out anyway?", + comment: "Warning displayed before logging out. The %d placeholder will contain the number of local posts (SINGULAR!)") + static let alertUnsavedTitlePlural = NSLocalizedString("You have changes to %d posts that haven’t been uploaded to your site. Logging out now will delete those changes. Log out anyway?", + comment: "Warning displayed before logging out. The %d placeholder will contain the number of local posts (PLURAL!)") + static let alertCancelAction = NSLocalizedString("Cancel", comment: "Verb. A button title. Tapping cancels an action.") + static let alertLogoutAction = NSLocalizedString("Log Out", comment: "Button for confirming logging out from WordPress.com account") + } +} diff --git a/WordPress/Classes/ViewRelated/Support/SupportConfiguration.swift b/WordPress/Classes/ViewRelated/Support/SupportConfiguration.swift new file mode 100644 index 000000000000..6acead817ac1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Support/SupportConfiguration.swift @@ -0,0 +1,34 @@ +import Foundation + +enum SupportConfiguration { + case zendesk + case forum + + static func current( + featureFlagStore: RemoteFeatureFlagStore = RemoteFeatureFlagStore(), + isWordPress: Bool = AppConfiguration.isWordPress, + zendeskEnabled: Bool = ZendeskUtils.zendeskEnabled) -> SupportConfiguration { + guard zendeskEnabled else { + return .forum + } + + if isWordPress && RemoteFeatureFlag.wordPressSupportForum.enabled(using: featureFlagStore) { + return .forum + } else { + return .zendesk + } + } + + static func isMigrationCardEnabled( + isJetpack: Bool = AppConfiguration.isJetpack, + migrationState: MigrationState = UserPersistentStoreFactory.instance().jetpackContentMigrationState + ) -> Bool { + return isJetpack && migrationState == .completed + } +} + +@objc class SupportConfigurationObjC: NSObject { + @objc static var isStartOverSupportEnabled: Bool { + return SupportConfiguration.current() == .zendesk + } +} diff --git a/WordPress/Classes/ViewRelated/Support/SupportTableViewController.swift b/WordPress/Classes/ViewRelated/Support/SupportTableViewController.swift index f641949dc3c1..e5fd018e0cab 100644 --- a/WordPress/Classes/ViewRelated/Support/SupportTableViewController.swift +++ b/WordPress/Classes/ViewRelated/Support/SupportTableViewController.swift @@ -5,6 +5,9 @@ class SupportTableViewController: UITableViewController { // MARK: - Properties + /// Configures the appearance of the support screen. + let configuration: Configuration + var sourceTag: WordPressSupportSourceTag? // If set, the Zendesk views will be shown from this view instead of in the navigation controller. @@ -12,11 +15,17 @@ class SupportTableViewController: UITableViewController { var showHelpFromViewController: UIViewController? private var tableHandler: ImmuTableViewHandler? - private let userDefaults = UserDefaults.standard + private let userDefaults = UserPersistentStoreFactory.instance() + + /// This closure is called when this VC is about to be dismissed due to the user + /// tapping the dismiss button. + /// + private var dismissTapped: (() -> ())? // MARK: - Init - override init(style: UITableView.Style) { + init(configuration: Configuration = .init(), style: UITableView.Style = .grouped) { + self.configuration = configuration super.init(style: style) } @@ -24,8 +33,9 @@ class SupportTableViewController: UITableViewController { fatalError("init(coder:) has not been implemented") } - required convenience init() { + required convenience init(dismissTapped: (() -> ())? = nil) { self.init(style: .grouped) + self.dismissTapped = dismissTapped } // MARK: - View @@ -35,8 +45,20 @@ class SupportTableViewController: UITableViewController { WPAnalytics.track(.openedSupport) setupNavBar() setupTable() - checkForAutomatticEmail() + if SupportConfiguration.current() == .zendesk { + checkForAutomatticEmail() + } ZendeskUtils.sharedInstance.cacheUnlocalizedSitePlans() + ZendeskUtils.fetchUserInformation() + + if SupportConfiguration.isMigrationCardEnabled() { + WPAnalytics.track(.supportMigrationFAQCardViewed) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + self.tableView.sizeToFitHeaderView() } override func viewWillAppear(_ animated: Bool) { @@ -49,6 +71,18 @@ class SupportTableViewController: UITableViewController { createUserActivity() } + func show(from presentingViewController: UIViewController) { + let navigationController = UINavigationController.init(rootViewController: self) + + if WPDeviceIdentification.isiPad() { + navigationController.modalTransitionStyle = .crossDissolve + navigationController.modalPresentationStyle = .formSheet + } + + presentingViewController.present(navigationController, animated: true) + } + + // TODO: Refactor this method to use the general `show(from:)` method @objc func showFromTabBar() { let navigationController = UINavigationController.init(rootViewController: self) @@ -57,20 +91,20 @@ class SupportTableViewController: UITableViewController { navigationController.modalPresentationStyle = .formSheet } - let tabBarController = WPTabBarController.sharedInstance() - if let presentedVC = tabBarController?.presentedViewController { + let rootViewController = RootViewCoordinator.sharedPresenter.rootViewController + if let presentedVC = rootViewController.presentedViewController { presentedVC.present(navigationController, animated: true) } else { - tabBarController?.present(navigationController, animated: true) + rootViewController.present(navigationController, animated: true) } } // MARK: - Button Actions @IBAction func dismissPressed(_ sender: AnyObject) { + dismissTapped?() dismiss(animated: true) } - } // MARK: - Private Extension @@ -86,10 +120,11 @@ private extension SupportTableViewController { title = LocalizedText.viewTitle if isModal() { - navigationItem.leftBarButtonItem = UIBarButtonItem(title: LocalizedText.closeButton, - style: WPStyleGuide.barButtonStyleForBordered(), + navigationItem.rightBarButtonItem = UIBarButtonItem(title: LocalizedText.closeButton, + style: WPStyleGuide.barButtonStyleForBordered(), target: self, action: #selector(SupportTableViewController.dismissPressed(_:))) + navigationItem.rightBarButtonItem?.accessibilityIdentifier = "close-button" } } @@ -98,55 +133,83 @@ private extension SupportTableViewController { NavigationItemRow.self, TextRow.self, HelpRow.self, - SupportEmailRow.self], + DestructiveButtonRow.self, + SupportEmailRow.self, + SupportForumRow.self, + ButtonRow.self, + MigrationRow.self], tableView: tableView) tableHandler = ImmuTableViewHandler(takeOver: self) reloadViewModel() WPStyleGuide.configureColors(view: view, tableView: tableView) - // remove empty cells - tableView.tableFooterView = UIView() - + tableView.tableFooterView = UIView() // remove empty cells + if let headerConfig = configuration.meHeaderConfiguration { + let headerView = MeHeaderView() + headerView.update(with: headerConfig) + tableView.tableHeaderView = headerView + } registerObservers() } // MARK: - Table Model - func tableViewModel() -> ImmuTable { - - // Help Section - var helpSectionRows = [ImmuTableRow]() - helpSectionRows.append(HelpRow(title: LocalizedText.wpHelpCenter, action: helpCenterSelected())) - - if ZendeskUtils.zendeskEnabled { - helpSectionRows.append(HelpRow(title: LocalizedText.contactUs, action: contactUsSelected())) - helpSectionRows.append(HelpRow(title: LocalizedText.myTickets, action: myTicketsSelected(), showIndicator: ZendeskUtils.showSupportNotificationIndicator)) - helpSectionRows.append(SupportEmailRow(title: LocalizedText.contactEmail, - value: ZendeskUtils.userSupportEmail() ?? LocalizedText.emailNotSet, - accessibilityHint: LocalizedText.contactEmailAccessibilityHint, - action: supportEmailSelected())) - } else { - helpSectionRows.append(HelpRow(title: LocalizedText.wpForums, action: contactUsSelected())) + func tableViewModel(supportConfiguration: SupportConfiguration) -> ImmuTable { + + // Support section + let helpSection: ImmuTableSection? + switch supportConfiguration { + case .zendesk: + var rows = [ImmuTableRow]() + rows.append(HelpRow(title: LocalizedText.wpHelpCenter, action: helpCenterSelected(), accessibilityIdentifier: "help-center-link-button")) + rows.append(HelpRow(title: LocalizedText.contactUs, action: contactUsSelected(), accessibilityIdentifier: "contact-support-button")) + rows.append(HelpRow(title: LocalizedText.tickets, action: myTicketsSelected(), showIndicator: ZendeskUtils.showSupportNotificationIndicator, accessibilityIdentifier: "my-tickets-button")) + rows.append(SupportEmailRow(title: LocalizedText.contactEmail, + value: ZendeskUtils.userSupportEmail() ?? LocalizedText.emailNotSet, + accessibilityHint: LocalizedText.contactEmailAccessibilityHint, + action: supportEmailSelected(), + accessibilityIdentifier: "set-contact-email-button")) + helpSection = ImmuTableSection(headerText: LocalizedText.prioritySupportSectionHeader, rows: rows, footerText: nil) + case .forum: + var rows = [ImmuTableRow]() + rows.append(SupportForumRow(title: LocalizedText.wpForumPrompt, + action: nil, + accessibilityIdentifier: "visit-wordpress-forums-prompt")) + rows.append(ButtonRow(title: LocalizedText.visitWpForumsButton, + accessibilityHint: LocalizedText.visitWpForumsButtonAccessibilityHint, + action: visitForumsSelected(), + accessibilityIdentifier: "visit-wordpress-forums-button")) + helpSection = ImmuTableSection(headerText: LocalizedText.wpForumsSectionHeader, rows: rows, footerText: nil) } - let helpSection = ImmuTableSection( - headerText: nil, - rows: helpSectionRows, - footerText: LocalizedText.helpFooter) - // Information Section - let versionRow = TextRow(title: LocalizedText.version, value: Bundle.main.shortVersionString()) - let switchRow = SwitchRow(title: LocalizedText.extraDebug, - value: userDefaults.bool(forKey: UserDefaultsKeys.extraDebug), - onChange: extraDebugToggled()) - let logsRow = NavigationItemRow(title: LocalizedText.activityLogs, action: activityLogsSelected()) + var informationSection: ImmuTableSection? + if configuration.showsLogsSection { + let versionRow = TextRow(title: LocalizedText.version, value: Bundle.main.shortVersionString()) + let switchRow = SwitchRow(title: LocalizedText.debug, + value: userDefaults.bool(forKey: UserDefaultsKeys.extraDebug), + onChange: extraDebugToggled()) + let logsRow = NavigationItemRow(title: LocalizedText.logs, action: activityLogsSelected(), accessibilityIdentifier: "activity-logs-button") + informationSection = ImmuTableSection( + headerText: LocalizedText.advancedSectionHeader, + rows: [versionRow, logsRow, switchRow], + footerText: LocalizedText.informationFooter + ) + } - let informationSection = ImmuTableSection( - headerText: nil, - rows: [versionRow, switchRow, logsRow], - footerText: LocalizedText.informationFooter) + // Log out Section + var logOutSections: ImmuTableSection? + if configuration.showsLogOutButton { + let logOutRow = DestructiveButtonRow( + title: LocalizedText.logOutButtonTitle, + action: logOutTapped(), + accessibilityIdentifier: "" + ) + logOutSections = .init(headerText: LocalizedText.wpAccount, optionalRows: [logOutRow]) + } // Create and return table - return ImmuTable(sections: [helpSection, informationSection]) + let sections = [createJetpackMigrationSection(), helpSection, informationSection, logOutSections].compactMap { $0 } + return ImmuTable(sections: sections) } @objc func refreshNotificationIndicator(_ notification: Foundation.Notification) { @@ -154,54 +217,51 @@ private extension SupportTableViewController { } func reloadViewModel() { - tableHandler?.viewModel = tableViewModel() + tableHandler?.viewModel = tableViewModel(supportConfiguration: SupportConfiguration.current()) } // MARK: - Row Handlers func helpCenterSelected() -> ImmuTableAction { - return { [unowned self] row in + return { [unowned self] _ in self.tableView.deselectSelectedRowWithAnimation(true) - if ZendeskUtils.zendeskEnabled { - guard let controllerToShowFrom = self.controllerToShowFrom() else { - return - } - ZendeskUtils.sharedInstance.showHelpCenterIfPossible(from: controllerToShowFrom, with: self.sourceTag) - } else { - guard let url = Constants.appSupportURL else { - return - } - UIApplication.shared.open(url) + guard let url = Constants.appSupportURL else { + return } + WPAnalytics.track(.supportHelpCenterViewed) + UIApplication.shared.open(url) } } func contactUsSelected() -> ImmuTableAction { - return { [unowned self] row in + return { [weak self] row in + guard let self = self else { return } self.tableView.deselectSelectedRowWithAnimation(true) - if ZendeskUtils.zendeskEnabled { - guard let controllerToShowFrom = self.controllerToShowFrom() else { - return - } - ZendeskUtils.sharedInstance.showNewRequestIfPossible(from: controllerToShowFrom, with: self.sourceTag) - } else { - guard let url = Constants.forumsURL else { - return + guard let controllerToShowFrom = self.controllerToShowFrom() else { + return + } + ZendeskUtils.sharedInstance.showNewRequestIfPossible(from: controllerToShowFrom, with: self.sourceTag) { [weak self] identityUpdated in + if identityUpdated { + self?.reloadViewModel() } - UIApplication.shared.open(url) } } } func myTicketsSelected() -> ImmuTableAction { - return { [unowned self] row in + return { [weak self] row in + guard let self = self else { return } ZendeskUtils.pushNotificationRead() self.tableView.deselectSelectedRowWithAnimation(true) guard let controllerToShowFrom = self.controllerToShowFrom() else { return } - ZendeskUtils.sharedInstance.showTicketListIfPossible(from: controllerToShowFrom, with: self.sourceTag) + ZendeskUtils.sharedInstance.showTicketListIfPossible(from: controllerToShowFrom, with: self.sourceTag) { [weak self] identityUpdated in + if identityUpdated { + self?.reloadViewModel() + } + } } } @@ -228,6 +288,22 @@ private extension SupportTableViewController { } } + func visitForumsSelected() -> ImmuTableAction { + return { [weak self] row in + guard let self = self else { return } + self.tableView.deselectSelectedRowWithAnimation(true) + self.launchForum(url: Constants.forumsURL) + } + } + + private func launchForum(url: URL?) { + guard let url = url else { + return + } + WPAnalytics.track(.supportOpenMobileForumTapped) + UIApplication.shared.open(url) + } + /// Zendesk does not allow agents to submit tickets, and displays a 'Message failed to send' error upon attempt. /// If the user email address is a8c, display a warning. /// @@ -260,6 +336,41 @@ private extension SupportTableViewController { } } + private func logOutTapped() -> ImmuTableAction { + return { [weak self] row in + guard let self else { + return + } + self.tableView.deselectSelectedRowWithAnimation(true) + let actionHandler = LogOutActionHandler() + actionHandler.logOut(with: self) + } + } + // MARK: - Jetpack migration section + + private func createJetpackMigrationSection() -> ImmuTableSection? { + guard SupportConfiguration.isMigrationCardEnabled() else { + return nil + } + + var rows = [ImmuTableRow]() + rows.append(MigrationRow(title: LocalizedText.jetpackMigrationTitle, + description: LocalizedText.jetpackMigrationDescription, + action: nil)) + rows.append(ButtonRow(title: LocalizedText.jetpackMigrationButton, + accessibilityHint: LocalizedText.jetpackMigrationButtonAccessibilityHint, + action: { _ in + guard let url = Constants.jetpackMigrationFAQURL else { + return + } + + WPAnalytics.track(.supportMigrationFAQButtonTapped) + UIApplication.shared.open(url) + }, + accessibilityIdentifier: nil)) + return ImmuTableSection(headerText: nil, rows: rows, footerText: nil) + } + // MARK: - ImmuTableRow Struct struct HelpRow: ImmuTableRow { @@ -268,11 +379,13 @@ private extension SupportTableViewController { let title: String let showIndicator: Bool let action: ImmuTableAction? + let accessibilityIdentifier: String? - init(title: String, action: @escaping ImmuTableAction, showIndicator: Bool = false) { + init(title: String, action: @escaping ImmuTableAction, showIndicator: Bool = false, accessibilityIdentifier: String? = nil) { self.title = title self.showIndicator = showIndicator self.action = action + self.accessibilityIdentifier = accessibilityIdentifier } func configureCell(_ cell: UITableViewCell) { @@ -282,6 +395,7 @@ private extension SupportTableViewController { cell.textLabel?.textColor = .primary cell.showIndicator = showIndicator cell.accessibilityTraits = .button + cell.accessibilityIdentifier = accessibilityIdentifier } } @@ -292,6 +406,7 @@ private extension SupportTableViewController { let value: String let accessibilityHint: String let action: ImmuTableAction? + let accessibilityIdentifier: String? func configureCell(_ cell: UITableViewCell) { cell.textLabel?.text = title @@ -300,6 +415,67 @@ private extension SupportTableViewController { cell.textLabel?.textColor = .primary cell.accessibilityTraits = .button cell.accessibilityHint = accessibilityHint + cell.accessibilityIdentifier = accessibilityIdentifier + } + } + + struct SupportForumRow: ImmuTableRow { + static let cell = ImmuTableCell.class(WPTableViewCellDefault.self) + + let title: String + let action: ImmuTableAction? + let accessibilityIdentifier: String? + + func configureCell(_ cell: UITableViewCell) { + cell.textLabel?.text = title + cell.selectionStyle = .none + cell.accessibilityIdentifier = accessibilityIdentifier + WPStyleGuide.configureTableViewCell(cell) + } + } + + struct ButtonRow: ImmuTableRow { + typealias CellType = ButtonCell + + static let cell = ImmuTableCell.class(CellType.self) + + let title: String + let accessibilityHint: String + let action: ImmuTableAction? + let accessibilityIdentifier: String? + + + func configureCell(_ cell: UITableViewCell) { + guard let cell = cell as? CellType else { + return + } + + cell.button.setTitle(title, for: .normal) + cell.button.accessibilityHint = accessibilityHint + cell.accessibilityIdentifier = accessibilityIdentifier + cell.button.addAction(UIAction { _ in + action?(self) + cell.setSelected(false, animated: true) + }, for: .touchUpInside) + } + + } + + struct MigrationRow: ImmuTableRow { + typealias CellType = MigrationCell + static let cell = ImmuTableCell.class(CellType.self) + + let title: String + let description: String + let action: ImmuTableAction? + + func configureCell(_ cell: UITableViewCell) { + guard let cell = cell as? CellType else { + return + } + + cell.titleLabel.text = title + cell.descriptionLabel.text = description } } @@ -312,20 +488,33 @@ private extension SupportTableViewController { // MARK: - Localized Text struct LocalizedText { - static let viewTitle = NSLocalizedString("Support", comment: "View title for Support page.") - static let closeButton = NSLocalizedString("Close", comment: "Dismiss the current view") - static let wpHelpCenter = NSLocalizedString("WordPress Help Center", comment: "Option in Support view to launch the Help Center.") - static let contactUs = NSLocalizedString("Contact Us", comment: "Option in Support view to contact the support team.") - static let wpForums = NSLocalizedString("WordPress Forums", comment: "Option in Support view to view the Forums.") - static let myTickets = NSLocalizedString("My Tickets", comment: "Option in Support view to access previous help tickets.") - static let helpFooter = NSLocalizedString("Visit the Help Center to get answers to common questions, or contact us for more help.", comment: "Support screen footer text displayed when Zendesk is enabled.") - static let version = NSLocalizedString("Version", comment: "Label in Support view displaying the app version.") - static let extraDebug = NSLocalizedString("Extra Debug", comment: "Option in Support view to enable/disable adding extra information to support ticket.") - static let activityLogs = NSLocalizedString("Activity Logs", comment: "Option in Support view to see activity logs.") - static let informationFooter = NSLocalizedString("The Extra Debug feature includes additional information in activity logs, and can help us troubleshoot issues with the app.", comment: "Support screen footer text explaining the Extra Debug feature.") - static let contactEmail = NSLocalizedString("Contact Email", comment: "Support email label.") - static let contactEmailAccessibilityHint = NSLocalizedString("Shows a dialog for changing the Contact Email.", comment: "Accessibility hint describing what happens if the Contact Email button is tapped.") - static let emailNotSet = NSLocalizedString("Not Set", comment: "Display value for Support email field if there is no user email address.") + static let viewTitle = NSLocalizedString("support.title", value: "Help", comment: "View title for Help & Support page.") + static let closeButton = NSLocalizedString("support.button.close.title", value: "Done", comment: "Dismiss the current view") + static let wpHelpCenter = NSLocalizedString("support.row.helpCenter.title", value: "WordPress Help Center", comment: "Option in Support view to launch the Help Center.") + static let contactUs = NSLocalizedString("support.row.contactUs.title", value: "Contact support", comment: "Option in Support view to contact the support team.") + static let wpForums = NSLocalizedString("support.row.forums.title", value: "WordPress Forums", comment: "Option in Support view to view the Forums.") + static let prioritySupportSectionHeader = NSLocalizedString("support.sectionHeader.prioritySupport.title", value: "Priority Support", comment: "Section header in Support view for priority support.") + static let wpForumsSectionHeader = NSLocalizedString("support.sectionHeader.forum.title", value: "Community Forums", comment: "Section header in Support view for the Forums.") + static let advancedSectionHeader = NSLocalizedString("support.sectionHeader.advanced.title", value: "Advanced", comment: "Section header in Support view for advanced information.") + static let wpForumPrompt = NSLocalizedString("support.row.communityForum.title", value: "Ask a question in the community forum and get help from our group of volunteers.", comment: "Suggestion in Support view to visit the Forums.") + static let visitWpForumsButton = NSLocalizedString("support.button.visitForum.title", value: "Visit WordPress.org", comment: "Option in Support view to visit the WordPress.org support forums.") + static let visitWpForumsButtonAccessibilityHint = NSLocalizedString("support.button.visitForum.accessibilityHint", value: "Tap to visit the community forum website in an external browser", comment: "Accessibility hint, informing user the button can be used to visit the support forums website.") + static let tickets = NSLocalizedString("support.row.tickets.title", value: "Tickets", comment: "Option in Support view to access previous help tickets.") + static let version = NSLocalizedString("support.row.version.title", value: "Version", comment: "Label in Support view displaying the app version.") + static let debug = NSLocalizedString("support.row.debug.title", value: "Debug", comment: "Option in Support view to enable/disable adding debug information to support ticket.") + static let logs = NSLocalizedString("support.row.logs.title", value: "Logs", comment: "Option in Support view to see activity logs.") + static let informationFooter = NSLocalizedString("support.sectionFooter.advanced.title", value: "Enable Debugging to include additional information in your logs that can help troubleshoot issues with the app.", comment: "Support screen footer text explaining the benefits of enabling the Debug feature.") + static let email = NSLocalizedString("support.row.email.title", value: "Email", comment: "Support email label.") + static let contactEmailAccessibilityHint = NSLocalizedString("support.row.contactEmail.accessibilityHint", value: "Shows a dialog for changing the Contact Email.", comment: "Accessibility hint describing what happens if the Contact Email button is tapped.") + static let emailNotSet = NSLocalizedString("support.row.contactEmail.emailNoteSet.detail", value: "Not Set", comment: "Display value for Support email field if there is no user email address.") + static let wpAccount = NSLocalizedString("support.sectionHeader.account.title", value: "WordPress.com Account", comment: "WordPress.com sign-out section header title") + static let logOutButtonTitle = NSLocalizedString("support.button.logOut.title", value: "Log Out", comment: "Button for confirming logging out from WordPress.com account") + static let contactEmail = NSLocalizedString("support.row.contactEmail.title", value: "Email", comment: "Support email label.") + + static let jetpackMigrationTitle = NSLocalizedString("support.row.jetpackMigration.title", value: "Thank you for switching to the Jetpack app!", comment: "An informational card title in Support view") + static let jetpackMigrationDescription = NSLocalizedString("support.row.jetpackMigration.description", value: "Our FAQ provides answers to common questions you may have.", comment: "An informational card description in Support view explaining what tapping the link on card does") + static let jetpackMigrationButton = NSLocalizedString("support.button.jetpackMigration.title", value: "Visit our FAQ", comment: "Option in Support view to visit the Jetpack migration FAQ website.") + static let jetpackMigrationButtonAccessibilityHint = NSLocalizedString("support.button.jetpackMigation.accessibilityHint", value: "Tap to visit the Jetpack app FAQ in an external browser", comment: "Accessibility hint, informing user the button can be used to visit the Jetpack migration FAQ website.") } // MARK: - User Defaults Keys @@ -337,9 +526,136 @@ private extension SupportTableViewController { // MARK: - Constants struct Constants { - static let appSupportURL = URL(string: "https://apps.wordpress.com/support") - static let forumsURL = URL(string: "https://ios.forums.wordpress.org") + static let appSupportURL = URL(string: "https://apps.wordpress.com/mobile-app-support/") + static let jetpackMigrationFAQURL = URL(string: "https://jetpack.com/support/switch-to-the-jetpack-app/") + + static let forumsURL = URL(string: "https://wordpress.org/support/forum/mobile/") static let automatticEmails = ["@automattic.com", "@a8c.com"] } +} + +private class ButtonCell: WPTableViewCellDefault { + + let button: SpotlightableButton = { + let button = SpotlightableButton(type: .custom) + + button.titleLabel?.font = WPStyleGuide.fontForTextStyle(.callout) + button.titleLabel?.adjustsFontForContentSizeCategory = true + button.titleLabel?.adjustsFontSizeToFitWidth = true + button.titleLabel?.lineBreakMode = .byTruncatingTail + button.contentHorizontalAlignment = .trailing + + button.setTitleColor(.primary, for: .normal) + + button.setImage(UIImage.gridicon(.external, + size: CGSize(width: LayoutSpacing.imageSize, height: LayoutSpacing.imageSize)), + for: .normal) + + // Align the image to the right + if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft { + button.semanticContentAttribute = .forceLeftToRight + button.imageEdgeInsets = LayoutSpacing.rtlButtonTitleImageInsets + } else { + button.semanticContentAttribute = .forceRightToLeft + button.imageEdgeInsets = LayoutSpacing.buttonTitleImageInsets + } + + button.translatesAutoresizingMaskIntoConstraints = false + + return button + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + contentView.addSubview(button) + + NSLayoutConstraint.activate([ + button.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: LayoutSpacing.padding), + button.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -LayoutSpacing.padding), + button.topAnchor.constraint(equalTo: contentView.topAnchor, constant: LayoutSpacing.padding), + button.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -LayoutSpacing.padding) + ]) + } + + enum LayoutSpacing { + static let imageSize: CGFloat = 17.0 + static let padding: CGFloat = 16.0 + static let buttonTitleImageInsets = UIEdgeInsets(top: 1, left: 4, bottom: 0, right: 0) + static let rtlButtonTitleImageInsets = UIEdgeInsets(top: 1, left: -4, bottom: 0, right: 4) + } +} + +private class MigrationCell: WPTableViewCell { + let titleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + return label + }() + + let descriptionLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.font = WPStyleGuide.fontForTextStyle(.body) + return label + }() + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.alignment = .leading + stackView.axis = .vertical + stackView.distribution = .equalSpacing + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = 10 + return stackView + }() + + private let logoImageView: UIImageView = { + let imageView = UIImageView(image: .init(named: "wp-migration-welcome")) + imageView.contentMode = .scaleAspectFit + return imageView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + selectionStyle = .none + + contentView.addSubview(stackView) + stackView.addArrangedSubview(logoImageView) + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(descriptionLabel) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: LayoutConstants.topBottomPadding), + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -LayoutConstants.topBottomPadding), + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: LayoutConstants.sidePadding), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -LayoutConstants.sidePadding), + logoImageView.heightAnchor.constraint(equalToConstant: LayoutConstants.imageSize.height), + logoImageView.widthAnchor.constraint(equalToConstant: LayoutConstants.imageSize.width) + ]) + } + enum LayoutConstants { + static let topBottomPadding: CGFloat = 16 + static let sidePadding: CGFloat = 20 + static let imageSize = CGSize(width: 56, height: 30) + } } diff --git a/WordPress/Classes/ViewRelated/Support/SupportTableViewControllerConfiguration.swift b/WordPress/Classes/ViewRelated/Support/SupportTableViewControllerConfiguration.swift new file mode 100644 index 000000000000..9b2c93160814 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Support/SupportTableViewControllerConfiguration.swift @@ -0,0 +1,37 @@ +import Foundation + +struct SupportTableViewControllerConfiguration { + + // MARK: Properties + + var meHeaderConfiguration: MeHeaderView.Configuration? + var showsLogOutButton: Bool = false + var showsLogsSection: Bool = true + + // MARK: Default Configurations + + static func currentAccountConfiguration() -> Self { + var config = Self.init() + if let account = Self.makeAccount() { + config.meHeaderConfiguration = .init(account: account) + config.showsLogOutButton = true + config.showsLogsSection = false + } + return config + } + + private static func makeAccount() -> WPAccount? { + let context = ContextManager.shared.mainContext + do { + return try WPAccount.lookupDefaultWordPressComAccount(in: context) + } catch { + DDLogError("Account lookup failed with error: \(error)") + return nil + } + } +} + +extension SupportTableViewController { + + typealias Configuration = SupportTableViewControllerConfiguration +} diff --git a/WordPress/Classes/ViewRelated/System/Action Sheet/ActionSheetViewController.swift b/WordPress/Classes/ViewRelated/System/Action Sheet/ActionSheetViewController.swift new file mode 100644 index 000000000000..246ce62f37ab --- /dev/null +++ b/WordPress/Classes/ViewRelated/System/Action Sheet/ActionSheetViewController.swift @@ -0,0 +1,234 @@ +struct ActionSheetButton { + let title: String + let image: UIImage + let identifier: String + let highlight: Bool + let badge: UIView? + let action: () -> Void + + init(title: String, image: UIImage, identifier: String, highlight: Bool = false, badge: UIView? = nil, action: @escaping () -> Void) { + self.title = title + self.image = image + self.identifier = identifier + self.highlight = highlight + self.badge = badge + self.action = action + } +} + +class ActionSheetViewController: UIViewController { + + enum Constants { + static let gripHeight: CGFloat = 5 + static let cornerRadius: CGFloat = 8 + static let buttonSpacing: CGFloat = 8 + static let additionalSafeAreaInsetsRegular: UIEdgeInsets = UIEdgeInsets(top: 20, left: 0, bottom: 20, right: 0) + static let minimumWidth: CGFloat = 300 + static let maximumWidth: CGFloat = 600 + + enum Header { + static let spacing: CGFloat = 16 + static let insets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 18, bottom: 0, right: 18) + } + + enum Button { + static let height: CGFloat = 54 + static let contentInsets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 18, bottom: 0, right: 35) + static let titleInsets: UIEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 0) + static let imageTintColor: UIColor = .neutral(.shade30) + static let font: UIFont = .preferredFont(forTextStyle: .callout) + static let textColor: UIColor = .text + static let badgeHorizontalPadding: CGFloat = 10 + } + + enum Stack { + static let insets: UIEdgeInsets = UIEdgeInsets(top: 5, left: 0, bottom: 0, right: 0) + } + } + + let headerView: UIView? + let buttons: [ActionSheetButton] + let headerTitle: String + private weak var scrollView: UIScrollView? + private var scrollViewHeightConstraint: NSLayoutConstraint? + private var scrollViewTopConstraint: NSLayoutConstraint? + + init(headerView: UIView? = nil, headerTitle: String, buttons: [ActionSheetButton]) { + self.headerView = headerView + self.headerTitle = headerTitle + self.buttons = buttons + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private lazy var gripButton: UIButton = { + let button = GripButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.addTarget(self, action: #selector(buttonPressed), for: .touchUpInside) + return button + }() + + @objc func buttonPressed() { + dismiss(animated: true, completion: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.clipsToBounds = true + view.layer.cornerRadius = Constants.cornerRadius + view.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner] + view.backgroundColor = .basicBackground + + let headerLabelView = UIView() + let headerLabel = UILabel() + headerLabelView.addSubview(headerLabel) + headerLabelView.pinSubviewToAllEdges(headerLabel, insets: Constants.Header.insets) + + headerLabel.font = WPStyleGuide.fontForTextStyle(.headline) + headerLabel.text = headerTitle + headerLabel.translatesAutoresizingMaskIntoConstraints = false + headerLabel.adjustsFontForContentSizeCategory = true + + let buttonViews = buttons.map({ (buttonInfo) -> UIButton in + return button(buttonInfo) + }) + + + let buttonConstraints = buttonViews.flatMap { button in + [ + button.heightAnchor.constraint(equalToConstant: Constants.Button.height), + button.widthAnchor.constraint(equalTo: view.widthAnchor), + ] + } + + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubviews([gripButton, scrollView]) + + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + scrollView.addSubview(stackView) + scrollView.pinSubviewToAllEdges(stackView) + + if let headerView = headerView { + stackView.addArrangedSubview(headerView) + } + + stackView.addArrangedSubviews([headerLabelView] + buttonViews) + stackView.setCustomSpacing(Constants.Header.spacing, after: headerLabelView) + + buttonViews.forEach { button in + stackView.setCustomSpacing(Constants.buttonSpacing, after: button) + } + + let topConstraint = scrollView.topAnchor.constraint(equalTo: gripButton.bottomAnchor, constant: Constants.Header.spacing) + scrollViewTopConstraint = topConstraint + let secondaryTopConstraint = scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor) + secondaryTopConstraint.priority = .defaultHigh + NSLayoutConstraint.activate([ + gripButton.heightAnchor.constraint(equalToConstant: Constants.gripHeight), + gripButton.widthAnchor.constraint(equalTo: view.widthAnchor), + gripButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + gripButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: Constants.Stack.insets.top), + topConstraint, + secondaryTopConstraint, + scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ] + buttonConstraints) + + self.scrollView = scrollView + refreshForTraits() + updateScrollViewHeight() + } + + private func createButton(_ handler: @escaping () -> Void) -> UIButton { + let button = UIButton(type: .custom, primaryAction: UIAction(handler: { _ in handler() })) + button.titleLabel?.font = Constants.Button.font + button.setTitleColor(Constants.Button.textColor, for: .normal) + button.imageView?.tintColor = Constants.Button.imageTintColor + button.setBackgroundImage(UIImage(color: .divider), for: .highlighted) + button.titleEdgeInsets = Constants.Button.titleInsets + button.naturalContentHorizontalAlignment = .leading + button.contentEdgeInsets = Constants.Button.contentInsets + button.translatesAutoresizingMaskIntoConstraints = false + button.flipInsetsForRightToLeftLayoutDirection() + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + } + + private func button(_ info: ActionSheetButton) -> UIButton { + let button = createButton(info.action) + + button.setTitle(info.title, for: .normal) + button.setImage(info.image, for: .normal) + button.accessibilityIdentifier = info.identifier + + if let badge = info.badge { + button.addSubview(badge) + button.addConstraints([ + badge.constrain(attribute: .left, toAttribute: .right, ofView: button.titleLabel!, relatedBy: .equal, constant: Constants.Button.badgeHorizontalPadding), + badge.constrainToSuperview(attribute: .centerY, relatedBy: .equal, constant: 0) + ]) + } + + if info.highlight { + addSpotlight(to: button) + } + return button + } + + private func addSpotlight(to button: UIButton) { + let spotlight = QuickStartSpotlightView() + spotlight.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(spotlight) + + NSLayoutConstraint.activate([ + spotlight.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -Constants.Header.insets.right), + spotlight.centerYAnchor.constraint(equalTo: button.centerYAnchor) + ]) + } + + open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + refreshForTraits() + } + + private func refreshForTraits() { + if presentingViewController?.traitCollection.horizontalSizeClass == .regular && presentingViewController?.traitCollection.verticalSizeClass != .compact { + gripButton.isHidden = true + additionalSafeAreaInsets = Constants.additionalSafeAreaInsetsRegular + scrollViewTopConstraint?.isActive = false + } else { + gripButton.isHidden = false + additionalSafeAreaInsets = .zero + scrollViewTopConstraint?.isActive = true + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + updateScrollViewHeight() + let compressedSize = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + let width = min(max(Constants.minimumWidth, compressedSize.width), Constants.maximumWidth) + preferredContentSize = CGSize(width: width, height: compressedSize.height) + } + + private func updateScrollViewHeight() { + guard let scrollView = scrollView else { + return + } + scrollView.layoutIfNeeded() + let scrollViewHeight = scrollView.contentSize.height + let heightConstraint = scrollViewHeightConstraint ?? scrollView.heightAnchor.constraint(greaterThanOrEqualToConstant: scrollViewHeight) + heightConstraint.constant = scrollViewHeight + heightConstraint.priority = .defaultHigh + heightConstraint.isActive = true + scrollViewHeightConstraint = heightConstraint + } +} diff --git a/WordPress/Classes/ViewRelated/System/Action Sheet/BloggingPromptsHeaderView.swift b/WordPress/Classes/ViewRelated/System/Action Sheet/BloggingPromptsHeaderView.swift new file mode 100644 index 000000000000..71dc149fbef0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/System/Action Sheet/BloggingPromptsHeaderView.swift @@ -0,0 +1,158 @@ +import UIKit + +class BloggingPromptsHeaderView: UIView, NibLoadable { + @IBOutlet private weak var containerStackView: UIStackView! + @IBOutlet private weak var titleStackView: UIStackView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var infoButton: UIButton! + @IBOutlet private weak var promptLabel: UILabel! + @IBOutlet private weak var attributionStackView: UIStackView! + @IBOutlet private weak var attributionImage: UIImageView! + @IBOutlet private weak var attributionLabel: UILabel! + @IBOutlet private weak var answerPromptButton: UIButton! + @IBOutlet private weak var answeredStackView: UIStackView! + @IBOutlet private weak var answeredLabel: UILabel! + @IBOutlet private weak var shareButton: UIButton! + @IBOutlet private weak var dividerView: UIView! + + var answerPromptHandler: (() -> Void)? + var infoButtonHandler: (() -> Void)? + + // This provides a quick way to toggle the shareButton. + // Since it probably will not be included in Blogging Prompts V1, + // it is disabled by default. + private let sharePromptEnabled = false + + override func awakeFromNib() { + super.awakeFromNib() + configureView() + } + + static func view(for prompt: BloggingPrompt?) -> BloggingPromptsHeaderView { + let promptsHeaderView = BloggingPromptsHeaderView.loadFromNib() + promptsHeaderView.configure(prompt) + WPAnalytics.track(.promptsBottomSheetViewed) + return promptsHeaderView + } +} + +// MARK: - Private methods + +private extension BloggingPromptsHeaderView { + + // MARK: - Configure View + + func configureView() { + configureSpacing() + configureStrings() + configureStyles() + configureConstraints() + configureInsets() + } + + func configureSpacing() { + containerStackView.setCustomSpacing(Constants.titleSpacing, after: titleStackView) + containerStackView.setCustomSpacing(Constants.answeredViewSpacing, after: answeredStackView) + containerStackView.setCustomSpacing(Constants.answerPromptButtonSpacing, after: answerPromptButton) + } + + func configureStrings() { + titleLabel.text = Strings.title + infoButton.accessibilityLabel = Strings.infoButtonAccessibilityLabel + answerPromptButton.setTitle(Strings.answerButtonTitle, for: .normal) + answeredLabel.text = Strings.answeredLabelTitle + shareButton.titleLabel?.text = Strings.shareButtonTitle + } + + func configureStyles() { + titleLabel.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + titleLabel.adjustsFontForContentSizeCategory = true + infoButton.setImage(.gridicon(.helpOutline), for: .normal) + infoButton.tintColor = .listSmallIcon + promptLabel.font = WPStyleGuide.BloggingPrompts.promptContentFont + promptLabel.adjustsFontForContentSizeCategory = true + answerPromptButton.titleLabel?.font = WPStyleGuide.BloggingPrompts.buttonTitleFont + answerPromptButton.titleLabel?.adjustsFontForContentSizeCategory = true + answerPromptButton.setTitleColor(WPStyleGuide.BloggingPrompts.buttonTitleColor, for: .normal) + answeredLabel.font = WPStyleGuide.BloggingPrompts.buttonTitleFont + answeredLabel.adjustsFontForContentSizeCategory = true + answeredLabel.textColor = WPStyleGuide.BloggingPrompts.answeredLabelColor + shareButton.titleLabel?.font = WPStyleGuide.BloggingPrompts.buttonTitleFont + shareButton.titleLabel?.adjustsFontForContentSizeCategory = true + shareButton.titleLabel?.adjustsFontSizeToFitWidth = true + shareButton.setTitleColor(WPStyleGuide.BloggingPrompts.buttonTitleColor, for: .normal) + attributionLabel.adjustsFontForContentSizeCategory = true + } + + func configureConstraints() { + NSLayoutConstraint.activate([ + dividerView.heightAnchor.constraint(equalToConstant: .hairlineBorderWidth), + ]) + } + + func configureInsets() { + if #available(iOS 15.0, *) { + var config: UIButton.Configuration = .plain() + config.contentInsets = Constants.buttonContentInsets + answerPromptButton.configuration = config + shareButton.configuration = config + } else { + answerPromptButton.contentEdgeInsets = Constants.buttonContentEdgeInsets + shareButton.contentEdgeInsets = Constants.buttonContentEdgeInsets + } + } + + func configure(_ prompt: BloggingPrompt?) { + promptLabel.text = prompt?.textForDisplay() + + let answered = prompt?.answered ?? false + answerPromptButton.isHidden = answered + answeredStackView.isHidden = !answered + shareButton.isHidden = !sharePromptEnabled + + if let promptAttribution = prompt?.attribution.lowercased(), + let attribution = BloggingPromptsAttribution(rawValue: promptAttribution) { + attributionStackView.isHidden = false + attributionImage.image = attribution.iconImage + attributionLabel.attributedText = attribution.attributedText + containerStackView.setCustomSpacing(Constants.promptSpacing, after: promptLabel) + } else { + attributionStackView.isHidden = true + containerStackView.setCustomSpacing(.zero, after: promptLabel) + } + } + + // MARK: - Button Actions + + @IBAction func answerPromptTapped(_ sender: Any) { + answerPromptHandler?() + } + + @IBAction func shareTapped(_ sender: Any) { + // TODO + } + + @IBAction func infoButtonTapped(_ sender: Any) { + infoButtonHandler?() + } + + // MARK: - Constants + + struct Constants { + static let titleSpacing: CGFloat = 8.0 + static let promptSpacing: CGFloat = 8.0 + static let answeredViewSpacing: CGFloat = 9.0 + static let answerPromptButtonSpacing: CGFloat = 9.0 + static let buttonContentEdgeInsets = UIEdgeInsets(top: 16.0, left: 0.0, bottom: 16.0, right: 0.0) + static let buttonContentInsets = NSDirectionalEdgeInsets(top: 16.0, leading: 0.0, bottom: 16.0, trailing: 0.0) + } + + struct Strings { + static let title = NSLocalizedString("Prompts", comment: "Title label for blogging prompts in the create new bottom action sheet.") + static let answerButtonTitle = NSLocalizedString("Answer Prompt", comment: "Title for a call-to-action button in the create new bottom action sheet.") + static let answeredLabelTitle = NSLocalizedString("✓ Answered", comment: "Title label that indicates the prompt has been answered.") + static let shareButtonTitle = NSLocalizedString("Share", comment: "Title for a button that allows the user to share their answer to the prompt.") + static let infoButtonAccessibilityLabel = NSLocalizedString("Learn more about prompts", comment: "Accessibility label for the blogging prompts info button on the prompts header view.") + } + +} diff --git a/WordPress/Classes/ViewRelated/System/Action Sheet/BloggingPromptsHeaderView.xib b/WordPress/Classes/ViewRelated/System/Action Sheet/BloggingPromptsHeaderView.xib new file mode 100644 index 000000000000..54cb81597a47 --- /dev/null +++ b/WordPress/Classes/ViewRelated/System/Action Sheet/BloggingPromptsHeaderView.xib @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/System/Action Sheet/BottomSheetPresentationController.swift b/WordPress/Classes/ViewRelated/System/Action Sheet/BottomSheetPresentationController.swift new file mode 100644 index 000000000000..2f5440d88949 --- /dev/null +++ b/WordPress/Classes/ViewRelated/System/Action Sheet/BottomSheetPresentationController.swift @@ -0,0 +1,125 @@ +/// A Presentation Controller which dims the background, allows the user to dismiss by tapping outside, and allows the user to swipit down +class BottomSheetPresentationController: FancyAlertPresentationController { + + private enum Constants { + static let maxWidthPercentage: CGFloat = 0.66 /// Used to constrain the width to a smaller size (instead of full width) when sheet is too wide + static let topSpacing: CGFloat = 16.0 + } + + private weak var tapGestureRecognizer: UITapGestureRecognizer? + private weak var panGestureRecognizer: UIPanGestureRecognizer? + + override var frameOfPresentedViewInContainerView: CGRect { + guard let containerView = containerView else { /// If we don't have a container view we're out of luck + return .zero + } + + let topSpacing = traitCollection.verticalSizeClass == .regular ? Constants.topSpacing : .zero + let maxHeight = containerView.bounds.height - containerView.safeAreaInsets.top - topSpacing + /// Height calculated by autolayout or a set maximum, Width equal to the container view minus insets + let height: CGFloat = min(presentedViewController.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).height, maxHeight) + var width: CGFloat = containerView.bounds.width - (containerView.safeAreaInsets.left + containerView.safeAreaInsets.right) + + /// If we're in a compact vertical size class, constrain the width a bit more so it doesn't get overly wide. + if traitCollection.verticalSizeClass == .compact { + width = width * Constants.maxWidthPercentage + } + + /// If we constrain the width, this centers the view by applying the appropriate insets based on width + let leftInset: CGFloat = ((containerView.bounds.width - width) / 2) + + return CGRect(x: leftInset, y: containerView.bounds.height - height, width: width, height: height) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + coordinator.animate(alongsideTransition: { _ in + self.presentedView?.frame = self.frameOfPresentedViewInContainerView + }, completion: nil) + super.viewWillTransition(to: size, with: coordinator) + } + + override func containerViewWillLayoutSubviews() { + presentedView?.frame = frameOfPresentedViewInContainerView + } + + override func containerViewDidLayoutSubviews() { + super.containerViewDidLayoutSubviews() + + if tapGestureRecognizer == nil { + addTapGestureRecognizer() + } + + if panGestureRecognizer == nil { + addPanGestureRecognizer() + } + } + + private func addTapGestureRecognizer() { + let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismiss)) + gestureRecognizer.cancelsTouchesInView = false + gestureRecognizer.delegate = self + containerView?.addGestureRecognizer(gestureRecognizer) + tapGestureRecognizer = gestureRecognizer + } + + private func addPanGestureRecognizer() { + let gestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(hide(_:))) + presentedViewController.view.addGestureRecognizer(gestureRecognizer) + panGestureRecognizer = gestureRecognizer + } + + var interactionController: UIPercentDrivenInteractiveTransition? + + @objc func hide(_ gesture: UIPanGestureRecognizer) { + guard let gestureView = gesture.view else { return } + + let translate = gesture.translation(in: gestureView) + let percent = translate.y / gestureView.bounds.size.height + + switch gesture.state { + case .began: + /// Begin the dismissal transition + interactionController = UIPercentDrivenInteractiveTransition() + dismiss() + case .changed: + /// Update the transition based on our calculated percent of completion + interactionController?.update(percent) + case .ended: + /// Calculate the velocity of the ended gesture. + /// - If the gesture has no downward velocity but is greater than half way down, complete the dismissal + /// - If there is downward velocity, dismiss + /// - If the gesture has no downward velocity and is less than half way down, cancel the dismissal + let velocity = gesture.velocity(in: gesture.view) + if (percent > 0.5 && velocity.y == 0) || velocity.y > 0 { + interactionController?.finish() + } else { + interactionController?.cancel() + } + interactionController = nil + case .cancelled: + interactionController?.cancel() + interactionController = nil + default: + break + } + } + + @objc func dismiss() { + presentedViewController.dismiss(animated: true, completion: nil) + } +} + +extension BottomSheetPresentationController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + /// Shouldn't happen; should always have container & presented view when tapped + guard let containerView = containerView, let presentedView = presentedView else { + return false + } + + let touchPoint = touch.location(in: containerView) + let isInPresentedView = presentedView.frame.contains(touchPoint) + + /// Do not accept the touch if inside of the presented view + return (gestureRecognizer == tapGestureRecognizer) && isInPresentedView == false + } +} diff --git a/WordPress/Classes/ViewRelated/System/BlurredEmptyViewController.swift b/WordPress/Classes/ViewRelated/System/BlurredEmptyViewController.swift new file mode 100644 index 000000000000..f0ae66649989 --- /dev/null +++ b/WordPress/Classes/ViewRelated/System/BlurredEmptyViewController.swift @@ -0,0 +1,27 @@ +import UIKit + +/// An empty view controller that has a blurred background +/// The blurred background can be removed using `removeBlurView()` +class BlurredEmptyViewController: UIViewController { + + // MARK: Lazy Private Variables + + private lazy var visualEffectView: UIVisualEffectView = { + let visualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterialDark)) + visualEffectView.translatesAutoresizingMaskIntoConstraints = false + return visualEffectView + }() + + // MARK: Public Functions + + func removeBlurView() { + visualEffectView.removeFromSuperview() + } + + // MARK: View Lifecycle + + override func viewDidLoad() { + view.addSubview(visualEffectView) + view.pinSubviewToAllEdges(visualEffectView) + } +} diff --git a/WordPress/Classes/ViewRelated/System/Coordinators/MySitesCoordinator.swift b/WordPress/Classes/ViewRelated/System/Coordinators/MySitesCoordinator.swift index 04871eded183..802dc3ab5a73 100644 --- a/WordPress/Classes/ViewRelated/System/Coordinators/MySitesCoordinator.swift +++ b/WordPress/Classes/ViewRelated/System/Coordinators/MySitesCoordinator.swift @@ -1,39 +1,125 @@ import UIKit +import WordPressAuthenticator @objc class MySitesCoordinator: NSObject { - let mySitesSplitViewController: WPSplitViewController - let mySitesNavigationController: UINavigationController - let blogListViewController: BlogListViewController + static let splitViewControllerRestorationID = "MySiteSplitViewControllerRestorationID" + static let navigationControllerRestorationID = "MySiteNavigationControllerRestorationID" + + let meScenePresenter: ScenePresenter + + let becomeActiveTab: () -> Void @objc - init(mySitesSplitViewController: WPSplitViewController, - mySitesNavigationController: UINavigationController, - blogListViewController: BlogListViewController) { - self.mySitesSplitViewController = mySitesSplitViewController - self.mySitesNavigationController = mySitesNavigationController - self.blogListViewController = blogListViewController + var currentBlog: Blog? { + mySiteViewController.blog + } + @objc + init(meScenePresenter: ScenePresenter, onBecomeActiveTab becomeActiveTab: @escaping () -> Void) { + self.meScenePresenter = meScenePresenter + self.becomeActiveTab = becomeActiveTab super.init() + + addSignInObserver() } - private func prepareToNavigate() { - WPTabBarController.sharedInstance().showMySitesTab() + // MARK: - Root View Controller - mySitesNavigationController.viewControllers = [blogListViewController] + private var rootContentViewController: UIViewController { + mySiteViewController } - func showMySites() { - prepareToNavigate() + // MARK: - VCs + + /// The view controller that should be presented by the tab bar controller. + /// + @objc + var rootViewController: UIViewController { + return splitViewController } - func showBlogDetails(for blog: Blog, then subsection: BlogDetailsSubsection? = nil) { - prepareToNavigate() + @objc + lazy var splitViewController: WPSplitViewController = { + let splitViewController = WPSplitViewController() + + splitViewController.restorationIdentifier = MySitesCoordinator.splitViewControllerRestorationID + splitViewController.presentsWithGesture = false + splitViewController.setInitialPrimaryViewController(navigationController) + splitViewController.tabBarItem = navigationController.tabBarItem + + return splitViewController + }() + + @objc + lazy var navigationController: UINavigationController = { + let navigationController = UINavigationController(rootViewController: rootContentViewController) + + navigationController.navigationBar.prefersLargeTitles = true + navigationController.restorationIdentifier = MySitesCoordinator.navigationControllerRestorationID + navigationController.navigationBar.isTranslucent = false + + let tabBarImage = AppStyleGuide.mySiteTabIcon + navigationController.tabBarItem.image = tabBarImage + navigationController.tabBarItem.selectedImage = tabBarImage + navigationController.tabBarItem.accessibilityLabel = NSLocalizedString("My Site", comment: "The accessibility value of the my site tab.") + navigationController.tabBarItem.accessibilityIdentifier = "mySitesTabButton" + navigationController.tabBarItem.title = NSLocalizedString("My Site", comment: "The accessibility value of the my site tab.") + + return navigationController + }() + + @objc + private(set) lazy var blogListViewController: BlogListViewController = { + BlogListViewController(meScenePresenter: self.meScenePresenter) + }() + + private lazy var mySiteViewController: MySiteViewController = { + makeMySiteViewController() + }() + + private func makeMySiteViewController() -> MySiteViewController { + MySiteViewController(meScenePresenter: self.meScenePresenter) + } + + // MARK: - Navigation + + func showRootViewController() { + becomeActiveTab() + + navigationController.viewControllers = [rootContentViewController] + } + + // MARK: - Sites List + + private func showSitesList() { + showRootViewController() + + let navigationController = UINavigationController(rootViewController: blogListViewController) + navigationController.modalPresentationStyle = .formSheet + mySiteViewController.present(navigationController, animated: true) + } + + // MARK: - Blog Details + + @objc + func showBlogDetails(for blog: Blog) { + showRootViewController() + + mySiteViewController.blog = blog + RecentSitesService().touch(blog: blog) + + if mySiteViewController.presentedViewController != nil { + mySiteViewController.dismiss(animated: true, completion: nil) + } + } - blogListViewController.setSelectedBlog(blog, animated: false) + func showBlogDetails(for blog: Blog, then subsection: BlogDetailsSubsection) { + showBlogDetails(for: blog) - if let subsection = subsection, - let blogDetailsViewController = mySitesNavigationController.topViewController as? BlogDetailsViewController { + if let mySiteViewController = navigationController.topViewController as? MySiteViewController { + mySiteViewController.showBlogDetailsSubsection(subsection) + } else if let blogDetailsViewController = navigationController.topViewController as? BlogDetailsViewController { blogDetailsViewController.showDetailView(for: subsection) } } @@ -41,30 +127,67 @@ class MySitesCoordinator: NSObject { // MARK: - Stats func showStats(for blog: Blog) { + guard JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures() else { + unsupportedFeatureFallback() + return + } + showBlogDetails(for: blog, then: .stats) } - func showStats(for blog: Blog, timePeriod: StatsPeriodType) { + func showStats(for blog: Blog, timePeriod: StatsPeriodType, date: Date? = nil) { + guard JetpackFeaturesRemovalCoordinator.shouldShowJetpackFeatures() else { + unsupportedFeatureFallback() + return + } + showBlogDetails(for: blog) - if let blogDetailsViewController = mySitesNavigationController.topViewController as? BlogDetailsViewController { - // Setting this user default is a bit of a hack, but it's by far the easiest way to - // get the stats view controller displaying the correct period. I spent some time - // trying to do it differently, but the existing stats view controller setup is - // quite complex and contains many nested child view controllers. As we're planning - // to revamp that section in the not too distant future, I opted for this simpler - // configuration for now. 2018-07-11 @frosty - UserDefaults.standard.set(timePeriod.rawValue, forKey: MySitesCoordinator.statsPeriodTypeDefaultsKey) + if let date = date { + UserPersistentStoreFactory.instance().set(date, forKey: SiteStatsDashboardViewController.lastSelectedStatsDateKey) + } - blogDetailsViewController.showDetailView(for: .stats) + if let siteID = blog.dotComID?.intValue { + let key = SiteStatsDashboardViewController.lastSelectedStatsPeriodTypeKey(forSiteID: siteID) + UserPersistentStoreFactory.instance().set(timePeriod.rawValue, forKey: key) } + + mySiteViewController.showBlogDetailsSubsection(.stats) } func showActivityLog(for blog: Blog) { showBlogDetails(for: blog, then: .activity) } - private static let statsPeriodTypeDefaultsKey = "LastSelectedStatsPeriodType" + // MARK: - Adding a new site + + func willDisplayPostSignupFlow() { + mySiteViewController.willDisplayPostSignupFlow = true + } + + func showSiteCreation() { + showRootViewController() + mySiteViewController.launchSiteCreation(source: "my_site") + } + + @objc + func showAddNewSite() { + showRootViewController() + mySiteViewController.presentInterfaceForAddingNewSite() + } + + // MARK: - Post creation + + func showCreateSheet(for blog: Blog?) { + let context = ContextManager.shared.mainContext + guard let targetBlog = blog ?? Blog.lastUsedOrFirst(in: context) else { + return + } + + showBlogDetails(for: targetBlog) + + mySiteViewController.presentCreateSheet() + } // MARK: - My Sites @@ -109,7 +232,7 @@ class MySitesCoordinator: NSObject { } guard let site = JetpackSiteRef(blog: blog), - let navigationController = mySitesSplitViewController.topDetailViewController?.navigationController else { + let navigationController = splitViewController.topDetailViewController?.navigationController else { return } @@ -118,4 +241,28 @@ class MySitesCoordinator: NSObject { navigationController.pushViewController(listViewController, animated: false) } + + // MARK: Notifications Handling + + private func addSignInObserver() { + let notificationName = NSNotification.Name(WordPressAuthenticator.WPSigninDidFinishNotification) + NotificationCenter.default.addObserver(self, + selector: #selector(signinDidFinish), + name: notificationName, + object: nil) + } + + @objc func signinDidFinish() { + mySiteViewController = makeMySiteViewController() + navigationController.viewControllers = [rootContentViewController] + } + + func displayJetpackOverlayForDisabledEntryPoint() { + let viewController = mySiteViewController + if viewController.isViewOnScreen() { + JetpackFeaturesRemovalCoordinator.presentOverlayIfNeeded(in: viewController, + source: .disabledEntryPoint, + blog: viewController.blog) + } + } } diff --git a/WordPress/Classes/ViewRelated/System/Coordinators/ReaderCoordinator.swift b/WordPress/Classes/ViewRelated/System/Coordinators/ReaderCoordinator.swift index 7b4e50ac3932..59bbebb7dd08 100644 --- a/WordPress/Classes/ViewRelated/System/Coordinators/ReaderCoordinator.swift +++ b/WordPress/Classes/ViewRelated/System/Coordinators/ReaderCoordinator.swift @@ -3,186 +3,136 @@ import UIKit @objc class ReaderCoordinator: NSObject { let readerNavigationController: UINavigationController - let readerSplitViewController: WPSplitViewController - let readerMenuViewController: ReaderMenuViewController var failureBlock: (() -> Void)? = nil - var source: UIViewController? = nil { - didSet { - let hasSource = source != nil - let sourceIsTopViewController = source == topNavigationController?.topViewController - - isNavigatingFromSource = hasSource && (sourceIsTopViewController || readerIsNotCurrentlySelected) - } - } - - private var isNavigatingFromSource = false - @objc - init(readerNavigationController: UINavigationController, - readerSplitViewController: WPSplitViewController, - readerMenuViewController: ReaderMenuViewController) { + init(readerNavigationController: UINavigationController) { self.readerNavigationController = readerNavigationController - self.readerSplitViewController = readerSplitViewController - self.readerMenuViewController = readerMenuViewController - super.init() } - private func prepareToNavigate() { - WPTabBarController.sharedInstance().showReaderTab() - - topNavigationController?.popToRootViewController(animated: isNavigatingFromSource) - } func showReaderTab() { - WPTabBarController.sharedInstance().showReaderTab() + RootViewCoordinator.sharedPresenter.showReaderTab() } func showDiscover() { - prepareToNavigate() - - readerMenuViewController.showSectionForDefaultMenuItem(withOrder: .discover, - animated: isNavigatingFromSource) + RootViewCoordinator.sharedPresenter.switchToDiscover() } func showSearch() { - prepareToNavigate() - - readerMenuViewController.showSectionForDefaultMenuItem(withOrder: .search, - animated: isNavigatingFromSource) + RootViewCoordinator.sharedPresenter.navigateToReaderSearch() } - func showA8CTeam() { - prepareToNavigate() + func showA8C() { + RootViewCoordinator.sharedPresenter.switchToTopic(where: { topic in + return (topic as? ReaderTeamTopic)?.slug == ReaderTeamTopic.a8cSlug + }) + } - readerMenuViewController.showSectionForTeam(withSlug: ReaderTeamTopic.a8cTeamSlug, animated: isNavigatingFromSource) + func showP2() { + RootViewCoordinator.sharedPresenter.switchToTopic(where: { topic in + return (topic as? ReaderTeamTopic)?.slug == ReaderTeamTopic.p2Slug + }) } func showMyLikes() { - prepareToNavigate() - - readerMenuViewController.showSectionForDefaultMenuItem(withOrder: .likes, - animated: isNavigatingFromSource) + RootViewCoordinator.sharedPresenter.switchToMyLikes() } func showManageFollowing() { - prepareToNavigate() - - readerMenuViewController.showSectionForDefaultMenuItem(withOrder: .followed, animated: false) - - if let followedViewController = topNavigationController?.topViewController as? ReaderStreamViewController { - followedViewController.showManageSites(animated: isNavigatingFromSource) - } + RootViewCoordinator.sharedPresenter.switchToFollowedSites() } func showList(named listName: String, forUser user: String) { let context = ContextManager.sharedInstance().mainContext - let service = ReaderTopicService(managedObjectContext: context) - - guard let topic = service.topicForList(named: listName, forUser: user) else { + guard let topic = ReaderListTopic.named(listName, forUser: user, in: context) else { failureBlock?() return } - if !isNavigatingFromSource { - prepareToNavigate() - } - - let streamViewController = ReaderStreamViewController.controllerWithTopic(topic) - - streamViewController.streamLoadFailureBlock = failureBlock - - readerSplitViewController.showDetailViewController(streamViewController, sender: nil) - readerMenuViewController.deselectSelectedRow(animated: false) + RootViewCoordinator.sharedPresenter.switchToTopic(where: { $0 == topic }) } func showTag(named tagName: String) { - if !isNavigatingFromSource { - prepareToNavigate() - } - let remote = ReaderTopicServiceRemote(wordPressComRestApi: WordPressComRestApi.anonymousApi(userAgent: WPUserAgent.wordPress())) let slug = remote.slug(forTopicName: tagName) ?? tagName.lowercased() - let controller = ReaderStreamViewController.controllerWithTagSlug(slug) - controller.streamLoadFailureBlock = failureBlock - - readerSplitViewController.showDetailViewController(controller, sender: nil) - readerMenuViewController.deselectSelectedRow(animated: false) - } - - func showStream(with siteID: Int, isFeed: Bool) { - let controller = ReaderStreamViewController.controllerWithSiteID(NSNumber(value: siteID), isFeed: isFeed) - controller.streamLoadFailureBlock = failureBlock - if isNavigatingFromSource { - topNavigationController?.pushViewController(controller, animated: true) - } else { - prepareToNavigate() - - readerSplitViewController.showDetailViewController(controller, sender: nil) - readerMenuViewController.deselectSelectedRow(animated: false) + getTagTopic(tagSlug: slug) { result in + guard let topic = try? result.get() else { return } + RootViewCoordinator.sharedPresenter.navigateToReaderTag(topic) } } - func showPost(with postID: Int, for feedID: Int, isFeed: Bool) { - let detailViewController = ReaderDetailViewController.controllerWithPostID(postID as NSNumber, - siteID: feedID as NSNumber, - isFeed: isFeed) + private func getTagTopic(tagSlug: String, completion: @escaping (Result) -> Void) { + let service = ReaderTopicService(coreDataStack: ContextManager.shared) + service.tagTopicForTag(withSlug: tagSlug, + success: { objectID in - detailViewController.postLoadFailureBlock = { [weak self, failureBlock] in - self?.topNavigationController?.popViewController(animated: false) - failureBlock?() - } + guard let objectID = objectID, + let topic = try? ContextManager.sharedInstance().mainContext.existingObject(with: objectID) as? ReaderTagTopic else { + DDLogError("Reader: Error retriving tag topic - invalid tag slug") + return + } + completion(.success(topic)) + }, + failure: { error in + let defaultError = NSError(domain: "readerTagTopicError", code: -1, userInfo: nil) + DDLogError("Reader: Error retriving tag topic - " + (error?.localizedDescription ?? "unknown failure reason")) + completion(.failure(error ?? defaultError)) + }) + } - if isNavigatingFromSource { - topNavigationController?.pushFullscreenViewController(detailViewController, animated: isNavigatingFromSource) - } else { - prepareToNavigate() + func showStream(with siteID: Int, isFeed: Bool) { + getSiteTopic(siteID: NSNumber(value: siteID), isFeed: isFeed) { result in + guard let topic = try? result.get() else { + return + } - topNavigationController?.pushFullscreenViewController(detailViewController, animated: isNavigatingFromSource) - readerMenuViewController.deselectSelectedRow(animated: false) + RootViewCoordinator.sharedPresenter.navigateToReaderSite(topic) } } - private var topNavigationController: UINavigationController? { - guard readerIsNotCurrentlySelected == false else { - return source?.navigationController - } + private func getSiteTopic(siteID: NSNumber, isFeed: Bool, completion: @escaping (Result) -> Void) { + let service = ReaderTopicService(coreDataStack: ContextManager.shared) + service.siteTopicForSite(withID: siteID, + isFeed: isFeed, + success: { objectID, isFollowing in - if readerMenuViewController.splitViewControllerIsHorizontallyCompact == false, - let navigationController = readerSplitViewController.topDetailViewController?.navigationController { - return navigationController - } + guard let objectID = objectID, + let topic = try? ContextManager.sharedInstance().mainContext.existingObject(with: objectID) as? ReaderSiteTopic else { + DDLogError("Reader: Error retriving site topic - invalid Site Id") + return + } + completion(.success(topic)) + }, + failure: { error in + let defaultError = NSError(domain: "readerSiteTopicError", code: -1, userInfo: nil) + DDLogError("Reader: Error retriving site topic - " + (error?.localizedDescription ?? "unknown failure reason")) + completion(.failure(error ?? defaultError)) + }) + } - return readerNavigationController + func showPost(with postID: Int, for feedID: Int, isFeed: Bool) { + showPost(in: ReaderDetailViewController + .controllerWithPostID(postID as NSNumber, + siteID: feedID as NSNumber, + isFeed: isFeed)) } - private var readerIsNotCurrentlySelected: Bool { - return WPTabBarController.sharedInstance().selectedViewController != readerSplitViewController + func showPost(with url: URL) { + showPost(in: ReaderDetailViewController.controllerWithPostURL(url)) } -} -extension ReaderTopicService { - /// Returns an existing topic for the specified list, or creates one if one - /// doesn't already exist. - /// - func topicForList(named listName: String, forUser user: String) -> ReaderListTopic? { - let remote = ReaderTopicServiceRemote(wordPressComRestApi: WordPressComRestApi.anonymousApi(userAgent: WPUserAgent.wordPress())) - let sanitizedListName = remote.slug(forTopicName: listName) ?? listName.lowercased() - let sanitizedUser = user.lowercased() - let path = remote.path(forEndpoint: "read/list/\(sanitizedUser)/\(sanitizedListName)/posts", withVersion: ._1_2) + private func showPost(in detailViewController: ReaderDetailViewController) { - if let existingTopic = findContainingPath(path) as? ReaderListTopic { - return existingTopic + let postLoadFailureBlock = { [weak self, failureBlock] in + self?.readerNavigationController.popToRootViewController(animated: false) + failureBlock?() } - let topic = ReaderListTopic(context: managedObjectContext) - topic.title = listName - topic.slug = sanitizedListName - topic.owner = user - topic.path = path - - return topic + detailViewController.postLoadFailureBlock = postLoadFailureBlock + RootViewCoordinator.sharedPresenter.navigateToReader(detailViewController) } + } diff --git a/WordPress/Classes/ViewRelated/System/FancyAlertViewController+AppIcons.swift b/WordPress/Classes/ViewRelated/System/FancyAlertViewController+AppIcons.swift new file mode 100644 index 000000000000..a5aa7325e69f --- /dev/null +++ b/WordPress/Classes/ViewRelated/System/FancyAlertViewController+AppIcons.swift @@ -0,0 +1,56 @@ +import UIKit + +extension FancyAlertViewController { + private struct Strings { + static let titleText = NSLocalizedString("New Custom App Icons", comment: "Title of alert informing users about the Reader Save for Later feature.") + static let bodyText = NSLocalizedString("We’ve updated our custom app icons with a fresh new look. There are 10 new styles to choose from, or you can simply keep your existing icon if you prefer.", comment: "Body text of alert informing users about the Reader Save for Later feature.") + static let newIconButtonTitle = NSLocalizedString("Choose a new app icon", comment: "OK Button title shown in alert informing users about the Reader Save for Later feature.") + static let dismissTitle = "Keep my current app icon" + } + + static func presentCustomAppIconUpgradeAlertIfNecessary(from origin: UIViewController & UIViewControllerTransitioningDelegate) { + guard AppConfiguration.allowsCustomAppIcons, + AppIcon.isUsingCustomIcon, + origin.presentedViewController == nil, + UserPersistentStoreFactory.instance().hasShownCustomAppIconUpgradeAlert == false else { + return + } + + UserPersistentStoreFactory.instance().hasShownCustomAppIconUpgradeAlert = true + + let controller = FancyAlertViewController.makeCustomAppIconUpgradeAlertController(with: origin) + controller.modalPresentationStyle = .custom + controller.transitioningDelegate = origin + + origin.present(controller, animated: true) + } + + static func makeCustomAppIconUpgradeAlertController(with presenter: UIViewController) -> FancyAlertViewController { + let newIconButton = ButtonConfig(Strings.newIconButtonTitle) { controller, _ in + controller.dismiss(animated: true) + + let appIconController = AppIconViewController() + let navigationController = UINavigationController(rootViewController: appIconController) + navigationController.modalPresentationStyle = .formSheet + presenter.present(navigationController, animated: true, completion: nil) + } + + let dismissButton = ButtonConfig(Strings.dismissTitle) { controller, _ in + controller.dismiss(animated: true) + } + + let image = UIImage(named: "custom-icons-alert") + + let config = FancyAlertViewController.Config(titleText: Strings.titleText, + bodyText: Strings.bodyText, + headerImage: image, + headerBackgroundColor: .basicBackground, + dividerPosition: .topWithPadding, + defaultButton: newIconButton, + cancelButton: dismissButton, + dismissAction: {}) + + let controller = FancyAlertViewController.controllerWithConfiguration(configuration: config) + return controller + } +} diff --git a/WordPress/Classes/ViewRelated/System/FancyAlertViewController+Async.swift b/WordPress/Classes/ViewRelated/System/FancyAlertViewController+Async.swift deleted file mode 100644 index b96817d4859e..000000000000 --- a/WordPress/Classes/ViewRelated/System/FancyAlertViewController+Async.swift +++ /dev/null @@ -1,111 +0,0 @@ -import UIKit -import SafariServices - -private protocol AlertStrings { - static var titleText: String { get } - static var bodyText: String { get } - static var confirmTitle: String { get } -} - -extension FancyAlertViewController { - - typealias ButtonConfig = FancyAlertViewController.Config.ButtonConfig - - private struct PublishPostStrings: AlertStrings { - static let titleText = NSLocalizedString("Publish with Confidence", comment: "Title of alert informing users about the async publishing feature.") - static let bodyText = NSLocalizedString("You can now leave the editor and your post will save and publish behind the scenes! Give it a try!", comment: "Body text of alert informing users about the async publishing feature.") - static let confirmTitle = NSLocalizedString("Publish Now", comment: "Publish button shown in alert informing users about the async publishing feature.") - } - - private struct SchedulePostStrings: AlertStrings { - static let titleText = NSLocalizedString("Schedule with Confidence", comment: "Title of alert informing users about the async publishing feature, when scheduling a post.") - static let bodyText = NSLocalizedString("You can now leave the editor and your post will save and schedule behind the scenes! Give it a try!", comment: "Body text of alert informing users about the async publishing feature, when scheduling a post.") - static let confirmTitle = NSLocalizedString("Schedule Now", comment: "Schedule button shown in alert informing users about the async publishing feature.") - } - - private struct PublishPageStrings: AlertStrings { - static let titleText = NSLocalizedString("Publish with Confidence", comment: "Title of alert informing users about the async publishing feature.") - static let bodyText = NSLocalizedString("You can now leave the editor and your page will save and publish behind the scenes! Give it a try!", comment: "Body text of alert informing users about the async publishing feature, when publishing a page.") - static let confirmTitle = NSLocalizedString("Publish Now", comment: "Publish button shown in alert informing users about the async publishing feature.") - } - - private struct SchedulePageStrings: AlertStrings { - static let titleText = NSLocalizedString("Schedule with Confidence", comment: "Title of alert informing users about the async publishing feature, when scheduling a page.") - static let bodyText = NSLocalizedString("You can now leave the editor and your page will save and schedule behind the scenes! Give it a try!", comment: "Body text of alert informing users about the async publishing feature, when scheduling a page.") - static let confirmTitle = NSLocalizedString("Schedule Now", comment: "Schedule button shown in alert informing users about the async publishing feature.") - } - - private struct GeneralStrings { - static let keepEditingTitle = NSLocalizedString("Keep Editing", comment: "Button title shown in alert informing users about the async publishing feature.") - static let moreHelpTitle = NSLocalizedString("How does it work?", comment: "Title of the more help button on alert helping users understand their site address") - } - - static func makeAsyncPostingAlertController(action: PostEditorAction, isPage: Bool, onConfirm: @escaping (() -> Void)) -> FancyAlertViewController { - let strings = self.strings(for: action, isPage: isPage) - - let confirmButton = ButtonConfig(strings.confirmTitle) { controller, _ in - controller.dismiss(animated: true, completion: { - onConfirm() - }) - } - - let dismissButton = ButtonConfig(GeneralStrings.keepEditingTitle) { controller, _ in - controller.dismiss(animated: true) - } - - let moreHelpButton = ButtonConfig(GeneralStrings.moreHelpTitle) { controller, _ in - guard let url = URL(string: "http://en.blog.wordpress.com/2018/04/23/ios-nine-point-eight/") else { - return - } - - let safariViewController = SFSafariViewController(url: url) - safariViewController.modalPresentationStyle = .pageSheet - controller.present(safariViewController, animated: true) - } - - let image = UIImage(named: "wp-illustration-easy-async") - - let config = FancyAlertViewController.Config(titleText: strings.titleText, - bodyText: strings.bodyText, - headerImage: image, - dividerPosition: .bottom, - defaultButton: confirmButton, - cancelButton: dismissButton, - moreInfoButton: moreHelpButton, - titleAccessoryButton: nil, - dismissAction: {}) - - let controller = FancyAlertViewController.controllerWithConfiguration(configuration: config) - return controller - } - - private static func strings(for action: PostEditorAction, isPage: Bool) -> AlertStrings.Type { - switch (action, isPage) { - case (.schedule, true): - return SchedulePageStrings.self - case (.schedule, false): - return SchedulePostStrings.self - case (_, true): - return PublishPageStrings.self - case (_, false): - return PublishPostStrings.self - } - } -} - -// MARK: - User Defaults - -extension UserDefaults { - private enum Keys: String { - case asyncPromoWasDisplayed = "AsyncPromoWasDisplayed" - } - - var asyncPromoWasDisplayed: Bool { - get { - return bool(forKey: Keys.asyncPromoWasDisplayed.rawValue) - } - set { - set(newValue, forKey: Keys.asyncPromoWasDisplayed.rawValue) - } - } -} diff --git a/WordPress/Classes/ViewRelated/System/FancyAlertViewController+NotificationPrimer.swift b/WordPress/Classes/ViewRelated/System/FancyAlertViewController+NotificationPrimer.swift index 558ccc0e986e..7e220dd66ba8 100644 --- a/WordPress/Classes/ViewRelated/System/FancyAlertViewController+NotificationPrimer.swift +++ b/WordPress/Classes/ViewRelated/System/FancyAlertViewController+NotificationPrimer.swift @@ -1,8 +1,11 @@ extension FancyAlertViewController { private struct Strings { - static let titleText = NSLocalizedString("Stay in the loop", comment: "Title of alert preparing users to grant permission for us to send them push notifications.") - static let bodyText = NSLocalizedString("We’ll notify you when you get followers, comments and likes. Would you like to allow push notifications?", comment: "Body text of alert preparing users to grant permission for us to send them push notifications.") - static let allowButtonText = NSLocalizedString("Allow notifications", comment: "Allow button title shown in alert preparing users to grant permission for us to send them push notifications.") + static let firstAlertTitleText = NSLocalizedString("Stay in the loop", comment: "Title of the first alert preparing users to grant permission for us to send them push notifications.") + static let firstAlertBodyText = NSLocalizedString("We'll notify you when you get new followers, comments, and likes. Would you like to allow push notifications?", comment: "Body text of the first alert preparing users to grant permission for us to send them push notifications.") + static let firstAllowButtonText = NSLocalizedString("Allow notifications", comment: "Allow button title shown in alert preparing users to grant permission for us to send them push notifications.") + static let secondAlertTitleText = NSLocalizedString("Get your notifications faster", comment: "Title of the second alert preparing users to grant permission for us to send them push notifications.") + static let secondAlertBodyText = NSLocalizedString("Learn about new comments, likes, and follows in seconds.", comment: "Body text of the first alert preparing users to grant permission for us to send them push notifications.") + static let secondAllowButtonText = NSLocalizedString("Allow push notifications", comment: "Allow button title shown in alert preparing users to grant permission for us to send them push notifications.") static let notNowText = NSLocalizedString("Not now", comment: "Not now button title shown in alert preparing users to grant permission for us to send them push notifications.") } @@ -15,52 +18,60 @@ extension FancyAlertViewController { /// /// - Parameter approveAction: block to call when approve is tapped /// - Returns: FancyAlertViewController of the primer - @objc static func makeNotificationPrimerAlertController(approveAction: @escaping ((_ controller: FancyAlertViewController) -> Void)) -> FancyAlertViewController { + static func makeNotificationAlertController(titleText: String?, + bodyText: String?, + allowButtonText: String, + seenEvent: WPAnalyticsEvent, + allowEvent: WPAnalyticsEvent, + noEvent: WPAnalyticsEvent, + approveAction: @escaping ((_ controller: FancyAlertViewController) -> Void)) -> FancyAlertViewController { - let publishButton = ButtonConfig(Strings.allowButtonText) { controller, _ in + let allowButton = ButtonConfig(allowButtonText) { controller, _ in approveAction(controller) - WPAnalytics.track(.pushNotificationPrimerAllowTapped, withProperties: [Analytics.locationKey: Analytics.alertKey]) + WPAnalytics.track(allowEvent, properties: [Analytics.locationKey: Analytics.alertKey]) } let dismissButton = ButtonConfig(Strings.notNowText) { controller, _ in defer { - WPAnalytics.track(.pushNotificationPrimerNoTapped, withProperties: [Analytics.locationKey: Analytics.alertKey]) + WPAnalytics.track(noEvent, properties: [Analytics.locationKey: Analytics.alertKey]) } controller.dismiss(animated: true) } let image = UIImage(named: "wp-illustration-stay-in-the-loop") - let config = FancyAlertViewController.Config(titleText: Strings.titleText, - bodyText: Strings.bodyText, + let config = FancyAlertViewController.Config(titleText: titleText, + bodyText: bodyText, headerImage: image, dividerPosition: .bottom, - defaultButton: publishButton, + defaultButton: allowButton, cancelButton: dismissButton, appearAction: { - WPAnalytics.track(.pushNotificationPrimerSeen, withProperties: [Analytics.locationKey: Analytics.alertKey]) + WPAnalytics.track(seenEvent, properties: [Analytics.locationKey: Analytics.alertKey]) }, dismissAction: {}) let controller = FancyAlertViewController.controllerWithConfiguration(configuration: config) return controller } -} - -// MARK: - User Defaults -@objc -extension UserDefaults { - private enum Keys: String { - case notificationPrimerAlertWasDisplayed = "NotificationPrimerAlertWasDisplayed" + static func makeNotificationPrimerAlertController(approveAction: @escaping ((_ controller: FancyAlertViewController) -> Void)) -> FancyAlertViewController { + return makeNotificationAlertController(titleText: Strings.firstAlertTitleText, + bodyText: Strings.firstAlertBodyText, + allowButtonText: Strings.firstAllowButtonText, + seenEvent: .pushNotificationsPrimerSeen, + allowEvent: .pushNotificationsPrimerAllowTapped, + noEvent: .pushNotificationsPrimerNoTapped, + approveAction: approveAction) } - var notificationPrimerAlertWasDisplayed: Bool { - get { - return bool(forKey: Keys.notificationPrimerAlertWasDisplayed.rawValue) - } - set { - set(newValue, forKey: Keys.notificationPrimerAlertWasDisplayed.rawValue) - } + static func makeNotificationSecondAlertController(approveAction: @escaping ((_ controller: FancyAlertViewController) -> Void)) -> FancyAlertViewController { + return makeNotificationAlertController(titleText: Strings.secondAlertTitleText, + bodyText: Strings.secondAlertBodyText, + allowButtonText: Strings.secondAllowButtonText, + seenEvent: .secondNotificationsAlertSeen, + allowEvent: .secondNotificationsAlertAllowTapped, + noEvent: .secondNotificationsAlertNoTapped, + approveAction: approveAction) } } diff --git a/WordPress/Classes/ViewRelated/System/FancyAlertViewController+QuickStart.swift b/WordPress/Classes/ViewRelated/System/FancyAlertViewController+QuickStart.swift deleted file mode 100644 index 8c0956de9cdb..000000000000 --- a/WordPress/Classes/ViewRelated/System/FancyAlertViewController+QuickStart.swift +++ /dev/null @@ -1,83 +0,0 @@ -extension FancyAlertViewController { - private struct Strings { - static let titleText = NSLocalizedString("Want a little help getting started?", comment: "Title of alert asking if users want to try out the quick start checklist.") - static let bodyText = NSLocalizedString("We’ll walk you through the basics of building and growing your site", comment: "Body text of alert asking if users want to try out the quick start checklist.") - static let allowButtonText = NSLocalizedString("Yes, Help Me", comment: "Allow button title shown in alert asking if users want to try out the quick start checklist.") - static let notNowText = NSLocalizedString("Not This Time", comment: "Not this time button title shown in alert asking if users want to try out the quick start checklist.") - } - - private struct UpgradeStrings { - static let titleText = NSLocalizedString("We’ve made some changes to your checklist", comment: "Title of alert letting users know we've upgraded the quick start checklist.") - static let bodyText = NSLocalizedString("We’ve added more tasks to help you grow your audience.", comment: "Body text of alert letting users know we've upgraded the quick start checklist.") - static let okButtonText = NSLocalizedString("OK", comment: "Dismiss button of alert letting users know we've upgraded the quick start checklist") - } - - private struct Analytics { - static let locationKey = "location" - static let alertKey = "alert" - } - - /// Create the fancy alert controller for the Quick Start request - /// - /// - Returns: FancyAlertViewController of the request - @objc static func makeQuickStartAlertController(blog: Blog) -> FancyAlertViewController { - WPAnalytics.track(.quickStartRequestAlertViewed) - - let allowButton = ButtonConfig(Strings.allowButtonText) { controller, _ in - controller.dismiss(animated: true) - - guard let tourGuide = QuickStartTourGuide.find() else { - return - } - - tourGuide.setup(for: blog) - - WPAnalytics.track(.quickStartRequestAlertButtonTapped, withProperties: ["type": "positive"]) - } - - let notNowButton = ButtonConfig(Strings.notNowText) { controller, _ in - controller.dismiss(animated: true) - PushNotificationsManager.shared.deletePendingLocalNotifications() - WPAnalytics.track(.quickStartRequestAlertButtonTapped, withProperties: ["type": "neutral"]) - } - - let image = UIImage(named: "wp-illustration-tasks-complete-audience") - - let config = FancyAlertViewController.Config(titleText: Strings.titleText, - bodyText: Strings.bodyText, - headerImage: image, - dividerPosition: .bottom, - defaultButton: allowButton, - cancelButton: notNowButton, - appearAction: {}, - dismissAction: {}) - - let controller = FancyAlertViewController.controllerWithConfiguration(configuration: config) - return controller - } - - /// Create the fancy alert controller for the Quick Start upgrade notification - /// - /// - Returns: FancyAlertViewController of the notice - @objc static func makeQuickStartUpgradeToV2AlertController(blog: Blog) -> FancyAlertViewController { - let okButton = ButtonConfig(UpgradeStrings.okButtonText) { controller, _ in - WPAnalytics.track(.quickStartMigrationDialogPositiveTapped) - - controller.dismiss(animated: true) - } - - let image = UIImage(named: "wp-illustration-big-checkmark") - - let config = FancyAlertViewController.Config(titleText: UpgradeStrings.titleText, - bodyText: UpgradeStrings.bodyText, - headerImage: image, - dividerPosition: .bottom, - defaultButton: okButton, - cancelButton: nil, - appearAction: {}, - dismissAction: {}) - - let controller = FancyAlertViewController.controllerWithConfiguration(configuration: config) - return controller - } -} diff --git a/WordPress/Classes/ViewRelated/System/FancyAlertViewController+SavedPosts.swift b/WordPress/Classes/ViewRelated/System/FancyAlertViewController+SavedPosts.swift index 334a314c32f2..ca410b4e7fce 100644 --- a/WordPress/Classes/ViewRelated/System/FancyAlertViewController+SavedPosts.swift +++ b/WordPress/Classes/ViewRelated/System/FancyAlertViewController+SavedPosts.swift @@ -2,6 +2,8 @@ import UIKit import Gridicons extension FancyAlertViewController { + typealias ButtonConfig = FancyAlertViewController.Config.ButtonConfig + private struct Strings { static let titleText = NSLocalizedString("Save Posts for Later", comment: "Title of alert informing users about the Reader Save for Later feature.") static let bodyText = NSLocalizedString("Save this post, and come back to read it whenever you'd like. It will only be available on this device — saved posts don't sync to other devices.", comment: "Body text of alert informing users about the Reader Save for Later feature.") @@ -9,8 +11,8 @@ extension FancyAlertViewController { } static func presentReaderSavedPostsAlertControllerIfNecessary(from origin: UIViewController & UIViewControllerTransitioningDelegate) { - if !UserDefaults.standard.savedPostsPromoWasDisplayed { - UserDefaults.standard.savedPostsPromoWasDisplayed = true + if !UserPersistentStoreFactory.instance().savedPostsPromoWasDisplayed { + UserPersistentStoreFactory.instance().savedPostsPromoWasDisplayed = true let controller = FancyAlertViewController.makeReaderSavedPostsAlertController() controller.modalPresentationStyle = .custom @@ -39,20 +41,3 @@ extension FancyAlertViewController { return controller } } - -// MARK: - User Defaults - -extension UserDefaults { - private enum Keys: String { - case savedPostsPromoWasDisplayed = "SavedPostsV1PromoWasDisplayed" - } - - var savedPostsPromoWasDisplayed: Bool { - get { - return bool(forKey: Keys.savedPostsPromoWasDisplayed.rawValue) - } - set { - set(newValue, forKey: Keys.savedPostsPromoWasDisplayed.rawValue) - } - } -} diff --git a/WordPress/Classes/ViewRelated/System/FancyAlerts+VerificationPrompt.swift b/WordPress/Classes/ViewRelated/System/FancyAlerts+VerificationPrompt.swift index b3190a6d8190..4a3a92bf8a30 100644 --- a/WordPress/Classes/ViewRelated/System/FancyAlerts+VerificationPrompt.swift +++ b/WordPress/Classes/ViewRelated/System/FancyAlerts+VerificationPrompt.swift @@ -12,8 +12,7 @@ extension FancyAlertViewController { @objc public static func verificationPromptController(completion: (() -> Void)?) -> FancyAlertViewController { let resendEmailButton = FancyAlertViewController.Config.ButtonConfig(Strings.resendEmail) { controller, button in - let managedObjectContext = ContextManager.sharedInstance().mainContext - let accountService = AccountService(managedObjectContext: managedObjectContext) + let accountService = AccountService(coreDataStack: ContextManager.sharedInstance()) let submitButton = button as? NUXButton diff --git a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift index 6eef4ff53529..7cd9dd8b8253 100644 --- a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift +++ b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift @@ -70,6 +70,7 @@ class FilterTabBar: UIControl { didSet { if let oldValue = oldValue { NSLayoutConstraint.deactivate([oldValue]) + tabBarHeightConstraint.isActive = true } } } @@ -80,6 +81,12 @@ class FilterTabBar: UIControl { } } + var tabBarHeightConstraintPriority: Float = UILayoutPriority.required.rawValue { + didSet { + tabBarHeightConstraint.priority = UILayoutPriority(tabBarHeightConstraintPriority) + } + } + var equalWidthFill: UIStackView.Distribution = .fillEqually { didSet { stackView.distribution = equalWidthFill @@ -202,21 +209,39 @@ class FilterTabBar: UIControl { commonInit() } + init() { + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + commonInit() + } + private func commonInit() { tabBarHeightConstraint = heightAnchor.constraint(equalToConstant: tabBarHeight) tabBarHeightConstraint?.isActive = true + divider.backgroundColor = dividerColor + addSubview(divider) + NSLayoutConstraint.activate([ + divider.leadingAnchor.constraint(equalTo: leadingAnchor), + divider.trailingAnchor.constraint(equalTo: trailingAnchor), + divider.bottomAnchor.constraint(equalTo: bottomAnchor), + divider.heightAnchor.constraint(equalToConstant: AppearanceMetrics.bottomDividerHeight) + ]) + addSubview(scrollView) NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -AppearanceMetrics.bottomDividerHeight), + scrollView.bottomAnchor.constraint(equalTo: divider.topAnchor), scrollView.topAnchor.constraint(equalTo: topAnchor) - ]) + ]) scrollView.addSubview(stackView) + stackView.isLayoutMarginsRelativeArrangement = true + // We will manually constrain the stack view to the content layout guide + scrollView.contentInsetAdjustmentBehavior = .never - stackViewWidthConstraint = stackView.widthAnchor.constraint(equalTo: widthAnchor) + stackViewWidthConstraint = stackView.widthAnchor.constraint(equalTo: scrollView.safeAreaLayoutGuide.widthAnchor) updateTabSizingConstraints() activateTabSizingConstraints() @@ -224,24 +249,15 @@ class FilterTabBar: UIControl { NSLayoutConstraint.activate([ stackView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), stackView.topAnchor.constraint(equalTo: scrollView.topAnchor), - stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -AppearanceMetrics.bottomDividerHeight), + stackView.bottomAnchor.constraint(equalTo: divider.topAnchor), stackView.topAnchor.constraint(equalTo: topAnchor) - ]) + ]) addSubview(selectionIndicator) NSLayoutConstraint.activate([ selectionIndicator.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), selectionIndicator.heightAnchor.constraint(equalToConstant: AppearanceMetrics.selectionIndicatorHeight) ]) - - divider.backgroundColor = dividerColor - addSubview(divider) - NSLayoutConstraint.activate([ - divider.leadingAnchor.constraint(equalTo: leadingAnchor), - divider.trailingAnchor.constraint(equalTo: trailingAnchor), - divider.bottomAnchor.constraint(equalTo: bottomAnchor), - divider.heightAnchor.constraint(equalToConstant: AppearanceMetrics.bottomDividerHeight) - ]) } // MARK: - Tabs @@ -304,7 +320,7 @@ class FilterTabBar: UIControl { let padding = (tabSizingStyle == .equalWidths) ? 0 : AppearanceMetrics.horizontalPadding stackViewEdgeConstraints = [ - stackView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: padding), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: padding), stackView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -padding) ] } @@ -346,7 +362,16 @@ class FilterTabBar: UIControl { } } + var currentlySelectedItem: FilterTabBarItem? { + return items[safe: selectedIndex] + } + func setSelectedIndex(_ index: Int, animated: Bool = true) { + guard items.indices.contains(index) else { + DDLogError("FilterTabBar - Tried to set an invalid index") + return + } + selectedIndex = index guard let tab = selectedTab else { @@ -405,7 +430,7 @@ class FilterTabBar: UIControl { /// private func scroll(to tab: UIButton, animated: Bool = true) { // Check the bar has enough content to scroll - guard scrollView.contentSize.width > scrollView.frame.width else { + guard scrollView.contentSize.width > scrollView.frame.width, bounds.width > 0 else { return } @@ -446,7 +471,7 @@ class FilterTabBar: UIControl { private enum AppearanceMetrics { static let height: CGFloat = 46.0 - static let bottomDividerHeight: CGFloat = 0.5 + static let bottomDividerHeight: CGFloat = .hairlineBorderWidth static let selectionIndicatorHeight: CGFloat = 2.0 static let horizontalPadding: CGFloat = 0.0 static let buttonInsets = UIEdgeInsets(top: 14.0, left: 12.0, bottom: 14.0, right: 12.0) diff --git a/WordPress/Classes/ViewRelated/System/Floating Create Button/CreateButtonActionSheet.swift b/WordPress/Classes/ViewRelated/System/Floating Create Button/CreateButtonActionSheet.swift new file mode 100644 index 000000000000..c19ab1bf44c2 --- /dev/null +++ b/WordPress/Classes/ViewRelated/System/Floating Create Button/CreateButtonActionSheet.swift @@ -0,0 +1,21 @@ +protocol ActionSheetItem { + var handler: () -> Void { get } + func makeButton() -> ActionSheetButton +} + +/// The Action Sheet containing action buttons to create new content to be displayed from the Create Button. +class CreateButtonActionSheet: ActionSheetViewController { + + enum Constants { + static let title = NSLocalizedString("Create New", comment: "Create New header text") + } + + init(headerView: UIView?, actions: [ActionSheetItem]) { + let buttons = actions.map { $0.makeButton() } + super.init(headerView: headerView, headerTitle: Constants.title, buttons: buttons) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WordPress/Classes/ViewRelated/System/Floating Create Button/CreateButtonCoordinator.swift b/WordPress/Classes/ViewRelated/System/Floating Create Button/CreateButtonCoordinator.swift index d60136337cd4..82c25506e064 100644 --- a/WordPress/Classes/ViewRelated/System/Floating Create Button/CreateButtonCoordinator.swift +++ b/WordPress/Classes/ViewRelated/System/Floating Create Button/CreateButtonCoordinator.swift @@ -1,75 +1,345 @@ import Gridicons +import WordPressFlux @objc class CreateButtonCoordinator: NSObject { private enum Constants { - static let padding: CGFloat = -16 - static let heightWidth: CGFloat = 56 + static let padding: CGFloat = -16 // Bottom and trailing padding to position the button along the bottom right corner + static let heightWidth: CGFloat = 56 // Height and width of the button + static let popoverOffset: CGFloat = -10 // The vertical offset of the iPad popover + static let maximumTooltipViews = 5 // Caps the number of times the user can see the announcement tooltip + static let skippedPromptsUDKey = "wp_skipped_blogging_prompts" } var button: FloatingActionButton = { - let button = FloatingActionButton(image: Gridicon.iconOfType(.create)) + let button = FloatingActionButton(image: .gridicon(.create)) button.accessibilityLabel = NSLocalizedString("Create", comment: "Accessibility label for create floating action button") button.accessibilityIdentifier = "floatingCreateButton" - button.accessibilityHint = NSLocalizedString("Creates new post or page", comment: " Accessibility hint for create floating action button") return button }() private weak var viewController: UIViewController? - let newPost: () -> Void - let newPage: () -> Void + private let noticeAnimator = NoticeAnimator(duration: 0.5, springDampening: 0.7, springVelocity: 0.0) - @objc init(_ viewController: UIViewController, newPost: @escaping () -> Void, newPage: @escaping () -> Void) { + private func notice(for blog: Blog) -> Notice { + let showsStories = blog.supports(.stories) + let title = showsStories ? NSLocalizedString("Create a post, page, or story", comment: "The tooltip title for the Floating Create Button") : NSLocalizedString("Creates new post, or page", comment: " Accessibility hint for create floating action button") + let notice = Notice(title: title, + message: "", + style: ToolTipNoticeStyle()) { [weak self] _ in + self?.didDismissTooltip = true + self?.hideNotice() + } + return notice + } + + // Once this reaches `maximumTooltipViews` we won't show the tooltip again + private var shownTooltipCount: Int { + set { + if newValue >= Constants.maximumTooltipViews { + didDismissTooltip = true + } else { + UserPersistentStoreFactory.instance().createButtonTooltipDisplayCount = newValue + } + } + get { + return UserPersistentStoreFactory.instance().createButtonTooltipDisplayCount + } + } + + private var didDismissTooltip: Bool { + set { + UserPersistentStoreFactory.instance().createButtonTooltipWasDisplayed = newValue + } + get { + return UserPersistentStoreFactory.instance().createButtonTooltipWasDisplayed + } + } + + // TODO: when prompt is used, get prompt from cache so it's using the latest. + private var prompt: BloggingPrompt? + + private lazy var bloggingPromptsService: BloggingPromptsService? = { + return BloggingPromptsService(blog: blog) + }() + + private weak var noticeContainerView: NoticeContainerView? + private let actions: [ActionSheetItem] + private let source: String + private let blog: Blog? + + /// Returns a newly initialized CreateButtonCoordinator + /// - Parameters: + /// - viewController: The UIViewController from which the menu should be shown. + /// - actions: A list of actions to display in the menu + /// - source: The source where the create button is being presented from + /// - blog: The current blog in context + init(_ viewController: UIViewController, actions: [ActionSheetItem], source: String, blog: Blog? = nil) { self.viewController = viewController - self.newPost = newPost - self.newPage = newPage + self.actions = actions + self.source = source + self.blog = blog + + super.init() + + listenForQuickStart() + + // Only fetch the prompt if it is actually needed, i.e. on the FAB that has multiple actions. + if actions.count > 1 { + fetchBloggingPrompt() + } } - /// Should be called any time the `viewController`'s trait collections change - /// - Parameter previousTraitCollect: The previous trait collection - @objc func presentingTraitCollectionDidChange(_ previousTraitCollection: UITraitCollection) { - //TODO: Dismiss + re-present presented view controller when Action Sheet is added + deinit { + quickStartObserver = nil } + /// Should be called any time the `viewController`'s trait collections will change. Dismisses when horizontal class changes to transition from .popover -> .custom + /// - Parameter previousTraitCollection: The previous trait collection + /// - Parameter newTraitCollection: The new trait collection + @objc func presentingTraitCollectionWillChange(_ previousTraitCollection: UITraitCollection, newTraitCollection: UITraitCollection) { + if let actionSheetController = viewController?.presentedViewController as? ActionSheetViewController { + if previousTraitCollection.horizontalSizeClass != newTraitCollection.horizontalSizeClass { + viewController?.dismiss(animated: false, completion: { [weak self] in + guard let self = self else { + return + } + self.setupPresentation(on: actionSheetController, for: newTraitCollection) + self.viewController?.present(actionSheetController, animated: false, completion: nil) + }) + } + } + } + + /// Button must be manually shown _after_ adding using `showCreateButton` @objc func add(to view: UIView, trailingAnchor: NSLayoutXAxisAnchor, bottomAnchor: NSLayoutYAxisAnchor) { button.translatesAutoresizingMaskIntoConstraints = false + button.isHidden = true view.addSubview(button) - /// A trailing constraint that is activated in `updateConstraints` at a later time when everything should be set up - let trailingConstraint = button.trailingAnchor.constraint(equalTo: trailingAnchor, constant: Constants.padding) - button.trailingConstraint = trailingConstraint - NSLayoutConstraint.activate([ button.bottomAnchor.constraint(equalTo: bottomAnchor, constant: Constants.padding), button.heightAnchor.constraint(equalToConstant: Constants.heightWidth), - button.widthAnchor.constraint(equalToConstant: Constants.heightWidth) + button.widthAnchor.constraint(equalToConstant: Constants.heightWidth), + button.trailingAnchor.constraint(equalTo: trailingAnchor, constant: Constants.padding) ]) button.addTarget(self, action: #selector(showCreateSheet), for: .touchUpInside) } - @objc private func showCreateSheet() { - //TODO: Add Action Sheet here - newPost() + private var currentTourElement: QuickStartTourElement? + + @objc func showCreateSheet() { + didDismissTooltip = true + hideNotice() + + guard let viewController = viewController else { + return + } + + if actions.count == 1 { + actions.first?.handler() + } else { + let actionSheetVC = actionSheetController(with: viewController.traitCollection) + viewController.present(actionSheetVC, animated: true, completion: { [weak self] in + WPAnalytics.track(.createSheetShown, properties: ["source": self?.source ?? ""]) + + if let element = self?.currentTourElement { + QuickStartTourGuide.shared.visited(element) + } + }) + } + } + + private func isShowingStoryOption() -> Bool { + actions.contains(where: { $0 is StoryAction }) + } + + private func actionSheetController(with traitCollection: UITraitCollection) -> UIViewController { + let actionSheetVC = CreateButtonActionSheet(headerView: createPromptHeaderView(), actions: actions) + setupPresentation(on: actionSheetVC, for: traitCollection) + return actionSheetVC + } + + private func setupPresentation(on viewController: UIViewController, for traitCollection: UITraitCollection) { + if traitCollection.horizontalSizeClass == .regular && traitCollection.verticalSizeClass == .regular { + viewController.modalPresentationStyle = .popover + } else { + viewController.modalPresentationStyle = .custom + } + + viewController.popoverPresentationController?.sourceView = self.button + viewController.popoverPresentationController?.sourceRect = self.button.bounds.offsetBy(dx: 0, dy: Constants.popoverOffset) + viewController.transitioningDelegate = self + } + + private func hideNotice() { + if let container = noticeContainerView { + NoticePresenter.dismiss(container: container) + } + } + + func hideCreateButtonTooltip() { + didDismissTooltip = true + hideNotice() } @objc func hideCreateButton() { - button.springAnimation(toShow: false) + hideNotice() + + if UIAccessibility.isReduceMotionEnabled { + button.isHidden = true + } else { + button.springAnimation(toShow: false) + } } - @objc func showCreateButton() { - button.setNeedsUpdateConstraints() // See `FloatingActionButton` implementation for more info on why this is needed. - button.springAnimation(toShow: true) + func removeCreateButton() { + button.removeFromSuperview() + noticeContainerView?.removeFromSuperview() } - /// These will be called by the action sheet - @objc func showNewPost() { - newPost() + @objc func showCreateButton(for blog: Blog) { + let showsStories = blog.supports(.stories) + button.accessibilityHint = showsStories ? NSLocalizedString("Creates new post, page, or story", comment: " Accessibility hint for create floating action button") : NSLocalizedString("Create a post or page", comment: " Accessibility hint for create floating action button") + showCreateButton(notice: notice(for: blog)) } - @objc func showNewPage() { - newPage() + private func showCreateButton(notice: Notice) { + if !didDismissTooltip { + noticeContainerView = noticeAnimator.present(notice: notice, in: viewController!.view, sourceView: button) + shownTooltipCount += 1 + } + + if UIAccessibility.isReduceMotionEnabled { + button.isHidden = false + } else { + button.springAnimation(toShow: true) + } } + + // MARK: - Quick Start + + private var quickStartObserver: Any? + + private func quickStartNotice(_ description: NSAttributedString) -> Notice { + let notice = Notice(title: "", + message: "", + style: ToolTipNoticeStyle(attributedMessage: description)) { [weak self] _ in + self?.didDismissTooltip = true + self?.hideNotice() + } + + return notice + } + + private func listenForQuickStart() { + quickStartObserver = NotificationCenter.default.addObserver(forName: .QuickStartTourElementChangedNotification, object: nil, queue: nil) { [weak self] (notification) in + guard let self = self, + let userInfo = notification.userInfo, + let element = userInfo[QuickStartTourGuide.notificationElementKey] as? QuickStartTourElement, + let description = userInfo[QuickStartTourGuide.notificationDescriptionKey] as? NSAttributedString, + element == .newpost || element == .newPage else { + return + } + + self.currentTourElement = element + self.hideNotice() + self.didDismissTooltip = false + self.showCreateButton(notice: self.quickStartNotice(description)) + } + } +} + +// MARK: Tranisitioning Delegate + +extension CreateButtonCoordinator: UIViewControllerTransitioningDelegate { + public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return BottomSheetAnimationController(transitionType: .presenting) + } + + public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? { + return BottomSheetAnimationController(transitionType: .dismissing) + } + + public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + let presentationController = BottomSheetPresentationController(presentedViewController: presented, presenting: presenting) + return presentationController + } + + public func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + return (viewController?.presentedViewController?.presentationController as? BottomSheetPresentationController)?.interactionController + } +} + +// MARK: - Blogging Prompts Methods + +private extension CreateButtonCoordinator { + + private func fetchBloggingPrompt() { + + // TODO: check for cached prompt first. + + guard let bloggingPromptsService = bloggingPromptsService else { + DDLogError("FAB: failed creating BloggingPromptsService instance.") + prompt = nil + return + } + + bloggingPromptsService.todaysPrompt(success: { [weak self] (prompt) in + self?.prompt = prompt + }, failure: { [weak self] (error) in + self?.prompt = nil + DDLogError("FAB: failed fetching blogging prompt: \(String(describing: error))") + }) + } + + private func createPromptHeaderView() -> BloggingPromptsHeaderView? { + guard FeatureFlag.bloggingPrompts.enabled, + let blog = blog, + blog.isAccessibleThroughWPCom(), + let prompt = prompt, + let siteID = blog.dotComID, + BlogDashboardPersonalizationService(siteID: siteID.intValue).isEnabled(.prompts), + !userSkippedPrompt(prompt, for: blog) else { + return nil + } + + let promptsHeaderView = BloggingPromptsHeaderView.view(for: prompt) + + promptsHeaderView.answerPromptHandler = { [weak self] in + WPAnalytics.track(.promptsBottomSheetAnswerPrompt) + self?.viewController?.dismiss(animated: true) { + let editor = EditPostViewController(blog: blog, prompt: prompt) + editor.modalPresentationStyle = .fullScreen + editor.entryPoint = .bloggingPromptsActionSheetHeader + self?.viewController?.present(editor, animated: true) + } + } + + promptsHeaderView.infoButtonHandler = { [weak self] in + WPAnalytics.track(.promptsBottomSheetHelp) + guard let presentedViewController = self?.viewController?.presentedViewController else { + return + } + BloggingPromptsIntroductionPresenter(interactionType: .actionable(blog: blog)).present(from: presentedViewController) + } + + return promptsHeaderView + } + + func userSkippedPrompt(_ prompt: BloggingPrompt, for blog: Blog) -> Bool { + guard FeatureFlag.bloggingPromptsEnhancements.enabled, + let siteID = blog.dotComID?.stringValue, + let allSkippedPrompts = UserPersistentStoreFactory.instance().array(forKey: Constants.skippedPromptsUDKey) as? [[String: Int32]] else { + return false + } + let siteSkippedPrompts = allSkippedPrompts.filter { $0.keys.first == siteID } + let matchingPrompts = siteSkippedPrompts.filter { $0.values.first == prompt.promptID } + + return !matchingPrompts.isEmpty + } + } diff --git a/WordPress/Classes/ViewRelated/System/Floating Create Button/FancyAlertViewController+CreateButtonAnnouncement.swift b/WordPress/Classes/ViewRelated/System/Floating Create Button/FancyAlertViewController+CreateButtonAnnouncement.swift new file mode 100644 index 000000000000..6c92684584cd --- /dev/null +++ b/WordPress/Classes/ViewRelated/System/Floating Create Button/FancyAlertViewController+CreateButtonAnnouncement.swift @@ -0,0 +1,60 @@ +extension FancyAlertViewController { + private struct Strings { + static let titleText = NSLocalizedString("Streamlined navigation", comment: "Title of alert announcing new Create Button feature.") + static let bodyText = NSLocalizedString("Now there are fewer and better-organized tabs, posting shortcuts, and more, so you can find what you need fast.", comment: "Body text of alert announcing new Create Button feature.") + static let okayButtonText = NSLocalizedString("Got it!", comment: "Okay button title shown in alert announcing new Create Button feature.") + static let readMoreButtonText = NSLocalizedString("Learn more", comment: "Read more button title shown in alert announcing new Create Button feature.") + } + + private struct Analytics { + static let locationKey = "location" + static let alertKey = "alert" + } + + /// Create the fancy alert controller for the notification primer + /// + /// - Parameter approveAction: block to call when approve is tapped + /// - Returns: FancyAlertViewController of the primer + @objc static func makeCreateButtonAnnouncementAlertController(readMoreAction: @escaping ((_ controller: FancyAlertViewController) -> Void)) -> FancyAlertViewController { + + let okayButton = ButtonConfig(Strings.okayButtonText) { controller, _ in + controller.dismiss(animated: true) + } + + let readMoreButton = ButtonConfig(Strings.readMoreButtonText) { controller, _ in + readMoreAction(controller) + } + + let image = UIImage(named: "wp-illustration-ia-announcement") + + let config = FancyAlertViewController.Config(titleText: Strings.titleText, + bodyText: Strings.bodyText, + headerImage: image, + dividerPosition: .bottom, + defaultButton: readMoreButton, + cancelButton: okayButton, + appearAction: { + WPAnalytics.track(WPAnalyticsEvent.createAnnouncementModalShown, properties: [Analytics.locationKey: Analytics.alertKey]) + }, + dismissAction: {}) + + let controller = FancyAlertViewController.controllerWithConfiguration(configuration: config) + return controller + } +} + +@objc +extension UserDefaults { + private enum Keys: String { + case createButtonAlertWasDisplayed = "CreateButtonAlertWasDisplayed" + } + + var createButtonAlertWasDisplayed: Bool { + get { + return bool(forKey: Keys.createButtonAlertWasDisplayed.rawValue) + } + set { + set(newValue, forKey: Keys.createButtonAlertWasDisplayed.rawValue) + } + } +} diff --git a/WordPress/Classes/ViewRelated/System/Floating Create Button/FloatingActionButton.swift b/WordPress/Classes/ViewRelated/System/Floating Create Button/FloatingActionButton.swift index 308225255215..f3e8af77c2e2 100644 --- a/WordPress/Classes/ViewRelated/System/Floating Create Button/FloatingActionButton.swift +++ b/WordPress/Classes/ViewRelated/System/Floating Create Button/FloatingActionButton.swift @@ -1,7 +1,12 @@ /// A rounded button with a shadow intended for use as a "Floating Action Button" class FloatingActionButton: UIButton { - var trailingConstraint: NSLayoutConstraint? + private var shadowLayer: CALayer? + + private enum Constants { + static let shadowColor: UIColor = UIColor.gray(.shade20) + static let shadowRadius: CGFloat = 3 + } convenience init(image: UIImage) { self.init(frame: .zero) @@ -12,10 +17,8 @@ class FloatingActionButton: UIButton { override init(frame: CGRect) { super.init(frame: frame) - backgroundColor = .accent + layer.backgroundColor = UIColor.primary.cgColor tintColor = .white - clipsToBounds = true - refreshShadow() } @@ -29,21 +32,11 @@ class FloatingActionButton: UIButton { layer.cornerRadius = rect.size.width / 2 } - override func updateConstraints() { - super.updateConstraints() - - trailingConstraint?.isActive = true - } - private func refreshShadow() { - layer.shadowColor = UIColor.gray(.shade20).cgColor - layer.shadowOffset = CGSize(width: 0, height: 0) - layer.shadowRadius = 3 - if #available(iOS 12.0, *) { - layer.shadowOpacity = traitCollection.userInterfaceStyle == .light ? 1 : 0 - } else { - layer.shadowOpacity = 1 - } + layer.shadowColor = Constants.shadowColor.cgColor + layer.shadowOffset = .zero + layer.shadowRadius = Constants.shadowRadius + layer.shadowOpacity = traitCollection.userInterfaceStyle == .light ? 1 : 0 } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { diff --git a/WordPress/Classes/ViewRelated/System/Floating Create Button/SheetActions.swift b/WordPress/Classes/ViewRelated/System/Floating Create Button/SheetActions.swift new file mode 100644 index 000000000000..6bbde69070af --- /dev/null +++ b/WordPress/Classes/ViewRelated/System/Floating Create Button/SheetActions.swift @@ -0,0 +1,78 @@ +/// Common Actions used by CreateButtonActionSheet + +struct PostAction: ActionSheetItem { + let handler: () -> Void + let source: String + + private let action = "create_new_post" + + func makeButton() -> ActionSheetButton { + let highlight: Bool = QuickStartTourGuide.shared.shouldSpotlight(.newpost) + return ActionSheetButton(title: NSLocalizedString("Blog post", comment: "Create new Blog Post button title"), + image: .gridicon(.posts), + identifier: "blogPostButton", + highlight: highlight, + action: { + WPAnalytics.track(.createSheetActionTapped, properties: ["source": source, "action": action]) + handler() + }) + } +} + +struct PageAction: ActionSheetItem { + let handler: () -> Void + let source: String + + private let action = "create_new_page" + + func makeButton() -> ActionSheetButton { + let highlight: Bool = QuickStartTourGuide.shared.shouldSpotlight(.newPage) + return ActionSheetButton(title: NSLocalizedString("Site page", comment: "Create new Site Page button title"), + image: .gridicon(.pages), + identifier: "sitePageButton", + highlight: highlight, + action: { + WPAnalytics.track(.createSheetActionTapped, properties: ["source": source, "action": action]) + handler() + }) + } +} + +struct StoryAction: ActionSheetItem { + + private enum Constants { + enum Badge { + static let font = UIFont.preferredFont(forTextStyle: .caption1) + static let insets = UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8) + static let cornerRadius: CGFloat = 2 + static let backgroundColor = UIColor.muriel(color: MurielColor(name: .red, shade: .shade50)) + } + } + + let handler: () -> Void + let source: String + + private let action = "create_new_story" + + func makeButton() -> ActionSheetButton { + return ActionSheetButton(title: NSLocalizedString("Story post", comment: "Create new Story button title"), + image: .gridicon(.story), + identifier: "storyButton", + action: { + WPAnalytics.track(.createSheetActionTapped, properties: ["source": source, "action": action]) + handler() + }) + } + + static func newBadge(title: String) -> UIButton { + let badge = UIButton(type: .custom) + badge.translatesAutoresizingMaskIntoConstraints = false + badge.setTitle(title, for: .normal) + badge.titleLabel?.font = Constants.Badge.font + badge.contentEdgeInsets = Constants.Badge.insets + badge.layer.cornerRadius = Constants.Badge.cornerRadius + badge.isUserInteractionEnabled = false + badge.backgroundColor = Constants.Badge.backgroundColor + return badge + } +} diff --git a/WordPress/Classes/ViewRelated/System/Floating Create Button/UIView+SpringAnimations.swift b/WordPress/Classes/ViewRelated/System/Floating Create Button/UIView+SpringAnimations.swift index d452838f4b07..402e7717436d 100644 --- a/WordPress/Classes/ViewRelated/System/Floating Create Button/UIView+SpringAnimations.swift +++ b/WordPress/Classes/ViewRelated/System/Floating Create Button/UIView+SpringAnimations.swift @@ -1,6 +1,6 @@ extension FloatingActionButton { - enum Constants { + private enum Constants { enum Maximize { static let damping: CGFloat = 0.7 static let duration: TimeInterval = 0.5 diff --git a/WordPress/Classes/ViewRelated/System/NetworkAware.swift b/WordPress/Classes/ViewRelated/System/NetworkAware.swift index 6f8f3cd2fa70..906fbcb6e1a2 100644 --- a/WordPress/Classes/ViewRelated/System/NetworkAware.swift +++ b/WordPress/Classes/ViewRelated/System/NetworkAware.swift @@ -45,7 +45,7 @@ extension NetworkAwareUI where Self: UIViewController { } /// Implementations of this protocol will be notified when the network connection status changes. Implementations of this protocol must call the observeNetworkStatus method. -protocol NetworkStatusDelegate: class { +protocol NetworkStatusDelegate: AnyObject { func observeNetworkStatus() /// This method will be called, on the main thread, when the network connection changes status. @@ -70,6 +70,25 @@ extension NetworkStatusDelegate where Self: UIViewController { } } +// TODO: - READERNAV - This is being used for the new Reader, currently under development. Once it's released, there should only be one extension +protocol NetworkStatusReceiver {} + +extension NetworkStatusDelegate where Self: NetworkStatusReceiver { + func observeNetworkStatus() { + receiver = ReachabilityNotificationObserver(delegate: self) + } + + fileprivate var receiver: ReachabilityNotificationObserver? { + get { + return objc_getAssociatedObject(self, &NetworkStatusAssociatedKeys.associatedObjectKey) as? ReachabilityNotificationObserver + } + + set { + objc_setAssociatedObject(self, &NetworkStatusAssociatedKeys.associatedObjectKey, newValue, .OBJC_ASSOCIATION_RETAIN) + } + } +} + fileprivate struct NetworkStatusAssociatedKeys { static var associatedObjectKey = "org.wordpress.networkstatus.notificationreceiver" } diff --git a/WordPress/Classes/ViewRelated/System/Notices/NoticeAnimator.swift b/WordPress/Classes/ViewRelated/System/Notices/NoticeAnimator.swift new file mode 100644 index 000000000000..ebe8e40c0913 --- /dev/null +++ b/WordPress/Classes/ViewRelated/System/Notices/NoticeAnimator.swift @@ -0,0 +1,99 @@ +import Foundation + +public struct NoticeAnimator { + + enum Transition { + case onscreen + case offscreen + } + + let duration: TimeInterval + let springDampening: CGFloat + let springVelocity: CGFloat + + /// Present the `Notice` inside of any view. If the notice includes a `sourceView` the constraints will be attached to that view (so the `sourceView` and `view` parameter **MUST** be in the same view hierarchy). + /// - Parameters: + /// - notice: The `Notice` to present. + /// - view: The `UIView` to add the `Notice` to. + /// - Returns: A `NoticeContainerView` instance containing the `NoticeView` which was added to `view` + func present(notice: Notice, in view: UIView, sourceView: UIView) -> NoticeContainerView { + let noticeView = NoticeView(notice: notice) + noticeView.configureArrow() + noticeView.translatesAutoresizingMaskIntoConstraints = false + + let noticeContainerView = NoticeContainerView(noticeView: noticeView) + view.addSubview(noticeContainerView) + + let bottomConstraint = noticeContainerView.bottomAnchor.constraint(equalTo: sourceView.topAnchor) + let leadingConstraint = noticeContainerView.constrain(attribute: .leading, toView: view, relatedBy: .greaterThanOrEqual, constant: 0) + let trailingConstraint = noticeContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + trailingConstraint.priority = .defaultHigh // During rotation this may need to break + + NSLayoutConstraint.activate([ + leadingConstraint, + trailingConstraint, + bottomConstraint + ]) + + animate(noticeContainer: noticeContainerView) + + return noticeContainerView + } + + func animate(noticeContainer: NoticeContainerView, completion: (() -> Void)? = nil) { + noticeContainer.noticeView.alpha = WPAlphaZero + + let fromState = state(for: noticeContainer, withTransition: .offscreen) + let toState = state(for: noticeContainer, withTransition: .onscreen) + animatePresentation(fromState: fromState, toState: toState, completion: completion) + } + + typealias AnimationBlock = () -> Void + + func state(for noticeContainer: NoticeContainerView, in view: UIView? = nil, withTransition transition: Transition, bottomOffset: CGFloat = 0) -> AnimationBlock { + return { + let presentation = noticeContainer.noticeView + + let alpha: CGFloat + switch transition { + case .onscreen: + alpha = WPAlphaFull + case .offscreen: + alpha = WPAlphaZero + } + + noticeContainer.noticeView.alpha = alpha + + switch presentation.notice.style.animationStyle { + case .moveIn: + noticeContainer.bottomConstraint?.constant = bottomOffset + noticeContainer.bottomConstraint?.isActive = transition == .onscreen + noticeContainer.topConstraint?.isActive = transition == .offscreen + case .fade: + // Fade just changes the alpha value which both animations need + break + } + + view?.layoutIfNeeded() + } + } + + func animatePresentation(fromState: AnimationBlock, + toState: @escaping AnimationBlock, + completion: AnimationBlock?) { + fromState() + + // this delay avoids affecting other transitions like navigation pushes + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .nanoseconds(1)) { + UIView.animate(withDuration: self.duration, + delay: 0, + usingSpringWithDamping: self.springDampening, + initialSpringVelocity: self.springVelocity, + options: [], + animations: toState, + completion: { _ in + completion?() + }) + } + } +} diff --git a/WordPress/Classes/ViewRelated/System/Notices/NoticePresenter.swift b/WordPress/Classes/ViewRelated/System/Notices/NoticePresenter.swift index 8d32e6bcfc1f..7546c7916a28 100644 --- a/WordPress/Classes/ViewRelated/System/Notices/NoticePresenter.swift +++ b/WordPress/Classes/ViewRelated/System/Notices/NoticePresenter.swift @@ -31,7 +31,7 @@ import WordPressFlux /// /// - SeeAlso: `NoticeStore` /// - SeeAlso: `NoticeAction` -class NoticePresenter: NSObject { +class NoticePresenter { /// Used for tracking the currently displayed Notice and its corresponding view. private struct NoticePresentation { let notice: Notice @@ -44,6 +44,7 @@ class NoticePresenter: NSObject { } private let store: NoticeStore + private let animator: NoticeAnimator private let window: UntouchableWindow private var view: UIView { guard let view = window.rootViewController?.view else { @@ -58,11 +59,13 @@ class NoticePresenter: NSObject { private var currentNoticePresentation: NoticePresentation? private var currentKeyboardPresentation: KeyboardPresentation = .notPresent - private init(store: NoticeStore) { + init(store: NoticeStore = StoreContainer.shared.notice, + animator: NoticeAnimator = NoticeAnimator(duration: Animations.appearanceDuration, springDampening: Animations.appearanceSpringDamping, springVelocity: NoticePresenter.Animations.appearanceSpringVelocity)) { self.store = store + self.animator = animator let windowFrame: CGRect - if let mainWindow = UIApplication.shared.keyWindow { + if let mainWindow = UIApplication.shared.mainWindow { windowFrame = mainWindow.offsetToAvoidStatusBar() } else { windowFrame = .zero @@ -74,8 +77,6 @@ class NoticePresenter: NSObject { // often a problem. window.windowLevel = .alert - super.init() - // Keep the window visible but hide it on the next run loop. If we hide it immediately, // the window is not automatically resized when the device is rotated. This issue // only happens on iPad simulators. @@ -93,10 +94,6 @@ class NoticePresenter: NSObject { listenToOrientationChangeEvents() } - override convenience init() { - self.init(store: StoreContainer.shared.notice) - } - // MARK: - Events private func listenToKeyboardEvents() { @@ -115,7 +112,7 @@ class NoticePresenter: NSObject { } UIView.animate(withDuration: durationValue.doubleValue, animations: { - currentContainer.bottomConstraint?.constant = self.onscreenNoticeContainerBottomConstraintConstant + currentContainer.bottomConstraint?.constant = self.onScreenNoticeContainerBottomConstraintConstant self.view.layoutIfNeeded() }) } @@ -130,7 +127,7 @@ class NoticePresenter: NSObject { } UIView.animate(withDuration: durationValue.doubleValue, animations: { - currentContainer.bottomConstraint?.constant = self.onscreenNoticeContainerBottomConstraintConstant + currentContainer.bottomConstraint?.constant = self.onScreenNoticeContainerBottomConstraintConstant self.view.layoutIfNeeded() }) } @@ -140,7 +137,7 @@ class NoticePresenter: NSObject { /// device is rotated. private func listenToOrientationChangeEvents() { let nc = NotificationCenter.default - nc.addObserver(forName: UIDevice.orientationDidChangeNotification, object: nil, queue: nil) { [weak self] _ in + nc.addObserver(forName: NSNotification.Name.WPTabBarHeightChanged, object: nil, queue: nil) { [weak self] _ in guard let self = self, let containerView = self.currentNoticePresentation?.containerView else { return @@ -218,6 +215,12 @@ class NoticePresenter: NSObject { let noticeContainerView = NoticeContainerView(noticeView: noticeView) addNoticeContainerToPresentingViewController(noticeContainerView) addBottomConstraintToNoticeContainer(noticeContainerView) + addTopConstraintToNoticeContainer(noticeContainerView) + + // At regular width, the notice shouldn't be any wider than 1/2 the app's width + noticeContainerView.noticeWidthConstraint = noticeView.widthAnchor.constraint(equalTo: noticeContainerView.widthAnchor, multiplier: 0.5) + let isRegularWidth = noticeContainerView.traitCollection.containsTraits(in: UITraitCollection(horizontalSizeClass: .regular)) + noticeContainerView.noticeWidthConstraint?.isActive = isRegularWidth NSLayoutConstraint.activate([ noticeContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -238,9 +241,12 @@ class NoticePresenter: NSObject { // Mask must be initialized after the window is shown or the view.frame will be zero view.mask = MaskView(parent: view, untouchableViewController: self.window.untouchableViewController) - let fromState = offscreenState(for: noticeContainerView) - let toState = onscreenState(for: noticeContainerView) - animatePresentation(fromState: fromState, toState: toState, completion: { + let offScreenBottomOffset = offScreenNoticeContainerBottomOffset(for: noticeContainerView) + let fromState = animator.state(for: noticeContainerView, in: view, withTransition: .offscreen, + bottomOffset: offScreenBottomOffset) + let toState = animator.state(for: noticeContainerView, in: view, withTransition: .onscreen, + bottomOffset: onScreenNoticeContainerBottomConstraintConstant) + animator.animatePresentation(fromState: fromState, toState: toState, completion: { // Quick Start notices don't get automatically dismissed guard notice.style.isDismissable else { return @@ -261,18 +267,42 @@ class NoticePresenter: NSObject { private func addBottomConstraintToNoticeContainer(_ container: NoticeContainerView) { let constraint = container.bottomAnchor.constraint(equalTo: view.bottomAnchor) container.bottomConstraint = constraint - constraint.isActive = true + constraint.isActive = false + } + + private func addTopConstraintToNoticeContainer(_ container: NoticeContainerView) { + let constraint = container.topAnchor.constraint(equalTo: view.bottomAnchor) + container.topConstraint = constraint + constraint.priority = UILayoutPriority.defaultHigh + constraint.isActive = false } // MARK: - Dismissal + public class func dismiss(container: NoticeContainerView) { + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .nanoseconds(1)) { + UIView.animate(withDuration: Animations.appearanceDuration, + delay: 0, + usingSpringWithDamping: Animations.appearanceSpringDamping, + initialSpringVelocity: Animations.appearanceSpringVelocity, + options: [], + animations: { + container.noticeView.alpha = WPAlphaZero + }, + completion: { _ in + container.removeFromSuperview() + }) + } + } + private func dismissForegroundNotice() { guard let container = currentNoticePresentation?.containerView, container.superview != nil else { return } - - animatePresentation(fromState: {}, toState: offscreenState(for: container), completion: { [weak self] in + let bottomOffset = offScreenNoticeContainerBottomOffset(for: container) + let toState = animator.state(for: container, in: view, withTransition: .offscreen, bottomOffset: bottomOffset) + animator.animatePresentation(fromState: {}, toState: toState, completion: { [weak self] in container.removeFromSuperview() // It is possible that when the dismiss animation finished, another Notice was already @@ -287,42 +317,7 @@ class NoticePresenter: NSObject { // MARK: - Animations - typealias AnimationBlock = () -> Void - - private func offscreenState(for noticeContainer: NoticeContainerView) -> AnimationBlock { - return { [weak self] in - guard let self = self else { - return - } - - noticeContainer.noticeView.alpha = WPAlphaZero - noticeContainer.bottomConstraint?.constant = { - switch self.currentKeyboardPresentation { - case .present(let keyboardHeight): - return -keyboardHeight + noticeContainer.bounds.height - case .notPresent: - return self.window.untouchableViewController.offsetOffscreen - } - }() - - self.view.layoutIfNeeded() - } - } - - private func onscreenState(for noticeContainer: NoticeContainerView) -> AnimationBlock { - return { [weak self] in - guard let self = self else { - return - } - - noticeContainer.noticeView.alpha = WPAlphaFull - noticeContainer.bottomConstraint?.constant = self.onscreenNoticeContainerBottomConstraintConstant - - self.view.layoutIfNeeded() - } - } - - private var onscreenNoticeContainerBottomConstraintConstant: CGFloat { + private var onScreenNoticeContainerBottomConstraintConstant: CGFloat { switch self.currentKeyboardPresentation { case .present(let keyboardHeight): return -keyboardHeight @@ -331,22 +326,12 @@ class NoticePresenter: NSObject { } } - private func animatePresentation(fromState: AnimationBlock, - toState: @escaping AnimationBlock, - completion: @escaping AnimationBlock) { - fromState() - - // this delay avoids affecting other transitions like navigation pushes - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .nanoseconds(1)) { - UIView.animate(withDuration: Animations.appearanceDuration, - delay: 0, - usingSpringWithDamping: Animations.appearanceSpringDamping, - initialSpringVelocity: Animations.appearanceSpringVelocity, - options: [], - animations: toState, - completion: { _ in - completion() - }) + private func offScreenNoticeContainerBottomOffset(for container: UIView) -> CGFloat { + switch self.currentKeyboardPresentation { + case .present(let keyboardHeight): + return -keyboardHeight + container.bounds.height + case .notPresent: + return window.untouchableViewController.offsetOffscreen } } @@ -376,14 +361,16 @@ private extension UIWindow { /// Small wrapper view that ensures a notice remains centered and at a maximum /// width when displayed in a regular size class. /// -private class NoticeContainerView: UIView { +class NoticeContainerView: UIView { /// The space between the Notice and its parent View private let containerMargin = UIEdgeInsets(top: 8.0, left: 8.0, bottom: 15.0, right: 8.0) var bottomConstraint: NSLayoutConstraint? + var topConstraint: NSLayoutConstraint? + var noticeWidthConstraint: NSLayoutConstraint? - let noticeView: UIView + let noticeView: NoticeView - init(noticeView: UIView) { + init(noticeView: NoticeView) { self.noticeView = noticeView super.init(frame: .zero) @@ -422,16 +409,11 @@ private class NoticeContainerView: UIView { fatalError("init(coder:) has not been implemented") } - lazy var noticeWidthConstraint: NSLayoutConstraint = { - // At regular width, the notice shouldn't be any wider than 1/2 the app's width - return noticeView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.5) - }() - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) let isRegularWidth = traitCollection.containsTraits(in: UITraitCollection(horizontalSizeClass: .regular)) - noticeWidthConstraint.isActive = isRegularWidth + noticeWidthConstraint?.isActive = isRegularWidth layoutIfNeeded() } diff --git a/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift b/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift index e9ee798e1195..a76d7bcf158f 100644 --- a/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift +++ b/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift @@ -1,4 +1,16 @@ +public enum NoticeAnimationStyle { + case moveIn + case fade +} + +/// A gesture which can be used to dismiss the notice. +/// See `NoticeView.configurGestureRecognizer()` for more details. +public enum NoticeDismissGesture { + case tap +} + public protocol NoticeStyle { + // Text var attributedMessage: NSAttributedString? { get } @@ -18,8 +30,26 @@ public protocol NoticeStyle { // Misc var isDismissable: Bool { get } + var animationStyle: NoticeAnimationStyle { get } + var dismissGesture: NoticeDismissGesture? { get } + + // Show an arrow (>) after the action button. + var showNextArrow: Bool { get } } +extension NoticeStyle { + public var backgroundColor: UIColor { + .invertedSystem5 + } + + public var titleColor: UIColor { + .invertedLabel + } + + public var messageColor: UIColor { + .invertedSecondaryLabel + } +} public struct NormalNoticeStyle: NoticeStyle { public let attributedMessage: NSAttributedString? = nil @@ -30,13 +60,14 @@ public struct NormalNoticeStyle: NoticeStyle { public var actionButtonFont: UIFont? { return UIFont.systemFont(ofSize: 14.0, weight: .medium) } public let cancelButtonFont: UIFont? = nil - public let titleColor: UIColor = .textInverted - public let messageColor: UIColor = .textInverted - public let backgroundColor: UIColor = .neutral(.shade80) - public let layoutMargins = UIEdgeInsets(top: 10.0, left: 16.0, bottom: 10.0, right: 16.0) public let isDismissable = true + public var showNextArrow = false + + public let animationStyle = NoticeAnimationStyle.moveIn + + public let dismissGesture: NoticeDismissGesture? = nil } public struct QuickStartNoticeStyle: NoticeStyle { @@ -48,11 +79,35 @@ public struct QuickStartNoticeStyle: NoticeStyle { public var actionButtonFont: UIFont? { return WPStyleGuide.fontForTextStyle(.headline) } public var cancelButtonFont: UIFont? { return WPStyleGuide.fontForTextStyle(.body) } - public let titleColor: UIColor = .white - public let messageColor: UIColor = .neutral(.shade10) - public let backgroundColor: UIColor = UIColor.neutral(.shade70).withAlphaComponent(0.88) + public let layoutMargins = UIEdgeInsets(top: 13.0, left: 16.0, bottom: 13.0, right: 16.0) + + public var isDismissable = false + public let showNextArrow = false + + public let animationStyle = NoticeAnimationStyle.moveIn + + public let dismissGesture: NoticeDismissGesture? = nil +} + +public struct ToolTipNoticeStyle: NoticeStyle { + public let attributedMessage: NSAttributedString? + + init(attributedMessage: NSAttributedString? = nil) { + self.attributedMessage = attributedMessage + } + + // Return new UIFont instance everytime in order to be responsive to accessibility font size changes + public var titleLabelFont: UIFont { return WPStyleGuide.fontForTextStyle(.body) } + public var messageLabelFont: UIFont { return WPStyleGuide.fontForTextStyle(.subheadline) } + public var actionButtonFont: UIFont? { return WPStyleGuide.fontForTextStyle(.headline) } + public var cancelButtonFont: UIFont? { return WPStyleGuide.fontForTextStyle(.body) } public let layoutMargins = UIEdgeInsets(top: 13.0, left: 16.0, bottom: 13.0, right: 16.0) public let isDismissable = false + public let showNextArrow = false + + public let animationStyle = NoticeAnimationStyle.fade + + public let dismissGesture: NoticeDismissGesture? = NoticeDismissGesture.tap } diff --git a/WordPress/Classes/ViewRelated/System/Notices/NoticeView.swift b/WordPress/Classes/ViewRelated/System/Notices/NoticeView.swift index 09a81fffa6d8..b1a5589e9f59 100644 --- a/WordPress/Classes/ViewRelated/System/Notices/NoticeView.swift +++ b/WordPress/Classes/ViewRelated/System/Notices/NoticeView.swift @@ -4,7 +4,7 @@ class NoticeView: UIView { internal let contentStackView = UIStackView() internal let backgroundContainerView = UIView() - internal let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .extraLight)) + internal let backgroundView = UIVisualEffectView(effect: Constants.visualEffect) internal let actionBackgroundView = UIView() private let shadowLayer = CAShapeLayer() private let shadowMaskLayer = CAShapeLayer() @@ -17,6 +17,10 @@ class NoticeView: UIView { private let actionButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system) + private lazy var nextArrowImageView: UIImageView = { + configureNextArrow() + }() + internal let notice: Notice internal var dualButtonsStackView: UIStackView? @@ -34,16 +38,29 @@ class NoticeView: UIView { fatalError("init(coder:) has not been implemented") } + internal func configureGestureRecognizer() { + switch notice.style.dismissGesture { + case .tap: + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(cancelButtonTapped)) + addGestureRecognizer(tapRecognizer) + case .none: + () + } + } + /// configure the NoticeView for display internal func configure() { configureBackgroundViews() configureShadow() configureContentStackView() + configureGestureRecognizer() configureLabels() configureForNotice() if notice.actionTitle != nil && notice.cancelTitle != nil { configureDualButtons() + } else if notice.actionTitle != nil && notice.style.showNextArrow { + configureActionButtonWithArrow() } else if notice.actionTitle != nil { configureActionButton() } @@ -77,6 +94,11 @@ class NoticeView: UIView { backgroundContainerView.layer.masksToBounds = true } + func configureArrow() { + let arrowView = addArrow(color: notice.style.backgroundColor, size: Metrics.arrowSize) + arrowView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: Metrics.arrowPosition).isActive = true + } + internal func configureShadow() { shadowLayer.shadowPath = UIBezierPath(roundedRect: layer.bounds, cornerRadius: Metrics.cornerRadius).cgPath shadowLayer.shadowColor = Appearance.shadowColor.cgColor @@ -98,7 +120,7 @@ class NoticeView: UIView { // Construct a mask path with the notice's roundrect cut out of a larger padding rect. // This, combined with the `kCAFillRuleEvenOdd` gives us an inverted mask, so // the shadow only appears _outside_ of the notice roundrect, and doesn't appear underneath - // and obscure the blur visual effect view. + // and obscure the blur visual effect view. let maskPath = CGMutablePath() let leftInset = notice.style.layoutMargins.left * 2 let topInset = notice.style.layoutMargins.top * 2 @@ -168,7 +190,7 @@ class NoticeView: UIView { actionBackgroundView.pinSubviewToAllEdgeMargins(actionButton) actionButton.titleLabel?.adjustsFontForContentSizeCategory = true - actionButton.setTitleColor(.primary(.shade40), for: .normal) + actionButton.setTitleColor(.invertedLink, for: .normal) actionButton.addTarget(self, action: #selector(actionButtonTapped), for: .touchUpInside) actionButton.setContentCompressionResistancePriority(.required, for: .horizontal) } @@ -223,7 +245,7 @@ class NoticeView: UIView { cancelBackgroundView.addTrailingBorder() actionButton.titleLabel?.adjustsFontForContentSizeCategory = true - actionButton.setTitleColor(.white, for: .normal) + actionButton.setTitleColor(notice.style.titleColor, for: .normal) actionButton.on(.touchUpInside) { [weak self] _ in self?.actionButtonTapped() } @@ -237,6 +259,60 @@ class NoticeView: UIView { cancelButton.setContentCompressionResistancePriority(.required, for: .vertical) } + private func configureActionButtonWithArrow() { + guard let actionTitle = notice.actionTitle, + notice.style.showNextArrow else { + actionBackgroundView.isHidden = true + return + } + + contentStackView.addArrangedSubview(actionBackgroundView) + actionBackgroundView.translatesAutoresizingMaskIntoConstraints = false + actionBackgroundView.layoutMargins = notice.style.layoutMargins + actionBackgroundView.backgroundColor = notice.style.backgroundColor + + NSLayoutConstraint.activate([ + actionBackgroundView.topAnchor.constraint(equalTo: backgroundView.contentView.topAnchor), + actionBackgroundView.bottomAnchor.constraint(equalTo: backgroundView.contentView.bottomAnchor) + ]) + + actionButton.setTitle(actionTitle, for: .normal) + actionButton.titleLabel?.adjustsFontForContentSizeCategory = true + actionButton.setTitleColor(.invertedLink, for: .normal) + actionButton.addTarget(self, action: #selector(actionButtonTapped), for: .touchUpInside) + + actionBackgroundView.addSubviews([actionButton, nextArrowImageView]) + + actionButton.translatesAutoresizingMaskIntoConstraints = false + nextArrowImageView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + actionButton.centerYAnchor.constraint(equalTo: actionBackgroundView.centerYAnchor), + actionButton.leadingAnchor.constraint(greaterThanOrEqualTo: actionBackgroundView.leadingAnchor) + ]) + + NSLayoutConstraint.activate([ + nextArrowImageView.centerYAnchor.constraint(equalTo: actionButton.centerYAnchor), + nextArrowImageView.leadingAnchor.constraint(equalTo: actionButton.trailingAnchor, constant: 5), + nextArrowImageView.trailingAnchor.constraint(equalTo: actionBackgroundView.trailingAnchor, constant: -16) + ]) + } + + private func configureNextArrow() -> UIImageView { + guard let image = UIImage(named: "disclosure-chevron")?.withTintColor(.invertedLink).imageFlippedForRightToLeftLayoutDirection() else { + return UIImageView() + } + + let arrowImageView = UIImageView(image: image) + arrowImageView.backgroundColor = notice.style.backgroundColor + + NSLayoutConstraint.activate([ + arrowImageView.heightAnchor.constraint(greaterThanOrEqualToConstant: 13.0) + ]) + + return arrowImageView + } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { @@ -293,7 +369,7 @@ class NoticeView: UIView { dismissHandler?() } - private func cancelButtonTapped() { + @objc private func cancelButtonTapped() { notice.actionHandler?(false) dismissHandler?() } @@ -302,9 +378,11 @@ class NoticeView: UIView { static let cornerRadius: CGFloat = 4.0 static let dualLayoutMargins = UIEdgeInsets(top: 6.0, left: 6.0, bottom: 6.0, right: 6.0) static let labelLineSpacing: CGFloat = 3.0 + static let arrowSize = CGSize(width: 20, height: 10) + static let arrowPosition: CGFloat = -24 /// Arrow is positioned along the right hand side by default. } - private enum Appearance { + fileprivate enum Appearance { static let shadowColor: UIColor = .black static let shadowOpacity: Float = 0.25 static let shadowRadius: CGFloat = 2.0 @@ -338,7 +416,7 @@ fileprivate extension UIView { func makeBorderView() -> UIView { let borderView = UIView() - borderView.backgroundColor = Constants.borderColor + borderView.backgroundColor = .invertedSeparator borderView.translatesAutoresizingMaskIntoConstraints = false self.addSubview(borderView) @@ -346,7 +424,72 @@ fileprivate extension UIView { } struct Constants { - static let borderColor = UIColor.white.withAlphaComponent(0.25) + static let visualEffect = UIBlurEffect(style: .extraLight) + } +} + +// MARK: - Arrow + +fileprivate extension UIView { + + func addArrow(color: UIColor, size: CGSize) -> UIView { + let arrowView = makeArrowView(color: color) + + NSLayoutConstraint.activate([ + arrowView.heightAnchor.constraint(equalToConstant: size.height), + arrowView.widthAnchor.constraint(equalToConstant: size.width), + arrowView.topAnchor.constraint(equalTo: bottomAnchor) + ]) + + return arrowView + } + + func makeArrowView(color: UIColor) -> UIView { + let arrowView = ArrowView() + arrowView.backgroundColor = color + arrowView.translatesAutoresizingMaskIntoConstraints = false + + let visualEffectView = ArrowEffectView(effect: Constants.visualEffect) + visualEffectView.backgroundColor = .clear + visualEffectView.translatesAutoresizingMaskIntoConstraints = false + visualEffectView.contentView.addSubview(arrowView) + visualEffectView.pinSubviewToAllEdges(arrowView) + + addSubview(visualEffectView) + return visualEffectView + } +} + +/// A Downward pointing triangle shaped view +private class ArrowView: UIView { + override func layoutSubviews() { + super.layoutSubviews() + + let trianglePath = UIBezierPath() + trianglePath.move(to: .zero) + trianglePath.addLine(to: CGPoint(x: bounds.size.width / 2, y: bounds.size.height)) + trianglePath.addLine(to: CGPoint(x: bounds.size.width, y: 0)) + trianglePath.close() + + let shapeLayer = CAShapeLayer() + shapeLayer.path = trianglePath.cgPath + layer.mask = shapeLayer + } +} + +private class ArrowEffectView: UIVisualEffectView { + override func layoutSubviews() { + super.layoutSubviews() + + let trianglePath = UIBezierPath() + trianglePath.move(to: .zero) + trianglePath.addLine(to: CGPoint(x: bounds.size.width / 2, y: bounds.size.height)) + trianglePath.addLine(to: CGPoint(x: bounds.size.width, y: 0)) + trianglePath.close() + + let shapeLayer = CAShapeLayer() + shapeLayer.path = trianglePath.cgPath + layer.mask = shapeLayer } } diff --git a/WordPress/Classes/ViewRelated/System/WPSplitViewController.swift b/WordPress/Classes/ViewRelated/System/WPSplitViewController.swift index cff01cc0c5b6..8ba24a4922c3 100644 --- a/WordPress/Classes/ViewRelated/System/WPSplitViewController.swift +++ b/WordPress/Classes/ViewRelated/System/WPSplitViewController.swift @@ -40,15 +40,13 @@ class WPSplitViewController: UISplitViewController { case portrait = 230 case landscape = 320 - static func widthForInterfaceOrientation(_ orientation: UIInterfaceOrientation) -> CGFloat { + static func width(for size: CGSize) -> CGFloat { // If the app is in multitasking (so isn't fullscreen), just use the narrow width - if let windowFrame = UIApplication.shared.keyWindow?.frame { - if windowFrame.width < UIScreen.main.bounds.width { - return self.portrait.rawValue - } + if size.width < UIScreen.main.bounds.width { + return self.portrait.rawValue } - if orientation.isPortrait || WPDeviceIdentification.isUnzoomediPhonePlus() { + if size.width < size.height || WPDeviceIdentification.isUnzoomediPhonePlus() { return self.portrait.rawValue } else { return self.landscape.rawValue @@ -56,22 +54,27 @@ class WPSplitViewController: UISplitViewController { } } - fileprivate func updateSplitViewForPrimaryColumnWidth() { + fileprivate func updateSplitViewForPrimaryColumnWidth(size: CGSize = UIScreen.main.bounds.size) { switch wpPrimaryColumnWidth { case .default: minimumPrimaryColumnWidth = UISplitViewController.automaticDimension maximumPrimaryColumnWidth = UISplitViewController.automaticDimension preferredPrimaryColumnWidthFraction = UISplitViewController.automaticDimension case .narrow: - let orientation = UIApplication.shared.statusBarOrientation - let columnWidth = WPSplitViewControllerNarrowPrimaryColumnWidth.widthForInterfaceOrientation(orientation) - + let columnWidth = WPSplitViewControllerNarrowPrimaryColumnWidth.width(for: size) minimumPrimaryColumnWidth = columnWidth maximumPrimaryColumnWidth = columnWidth - preferredPrimaryColumnWidthFraction = UIScreen.main.bounds.width / columnWidth + preferredPrimaryColumnWidthFraction = columnWidth / size.width case .full: - maximumPrimaryColumnWidth = UIScreen.main.bounds.width - preferredPrimaryColumnWidthFraction = 1.0 + break + + // Ref: https://github.com/wordpress-mobile/WordPress-iOS/issues/14547 + // Due to a bug where the column widths are not updating correctly when the primary column + // is set to full width, the empty views are not sized correctly on rotation. As a workaround, + // don't attempt to resize the columns for full width. These lines should be restored when + // the full width issue is resolved. + // maximumPrimaryColumnWidth = size.width + // preferredPrimaryColumnWidthFraction = 1.0 } } @@ -98,13 +101,13 @@ class WPSplitViewController: UISplitViewController { super.viewDidLoad() delegate = self - preferredDisplayMode = .allVisible + preferredDisplayMode = .oneBesideSecondary extendedLayoutIncludesOpaqueBars = true } override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent + return WPStyleGuide.preferredStatusBarStyle } override var childForStatusBarStyle: UIViewController? { @@ -143,9 +146,7 @@ class WPSplitViewController: UISplitViewController { // This is to work around an apparent bug in iOS 13 where the detail view is assuming the system is in dark // mode when switching out of the app and then back in. Here we ensure the overridden user interface style // traits are replaced with the correct current traits before we use them. - if #available(iOS 12.0, *) { - traits.append(UITraitCollection(userInterfaceStyle: traitCollection.userInterfaceStyle)) - } + traits.append(UITraitCollection(userInterfaceStyle: traitCollection.userInterfaceStyle)) return UITraitCollection(traitsFrom: traits) } @@ -159,8 +160,8 @@ class WPSplitViewController: UISplitViewController { override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) + updateSplitViewForPrimaryColumnWidth(size: size) coordinator.animate(alongsideTransition: { context in - self.updateSplitViewForPrimaryColumnWidth() self.updateDimmingViewFrame() }) @@ -203,7 +204,7 @@ class WPSplitViewController: UISplitViewController { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - if hasHorizontallyCompactView() && preferredDisplayMode == .primaryHidden { + if hasHorizontallyCompactView() && preferredDisplayMode == .secondaryOnly { setPrimaryViewControllerHidden(false, animated: false) } } @@ -227,10 +228,15 @@ class WPSplitViewController: UISplitViewController { } } + /// A flag that indicates whether the split view controller is showing the + /// initial (i.e. default) view controller or not. + /// + @objc var isShowingInitialDetail = false + fileprivate let dimmingViewAlpha: CGFloat = 0.5 fileprivate let dimmingViewAnimationDuration: TimeInterval = 0.3 - @objc func dimDetailViewController(_ dimmed: Bool) { + func dimDetailViewController(_ dimmed: Bool, withAlpha alpha: CGFloat? = nil) { if dimmed { if dimmingView.superview == nil { view.addSubview(dimmingView) @@ -240,7 +246,7 @@ class WPSplitViewController: UISplitViewController { // Dismiss the keyboard from the detail view controller if active topDetailViewController?.navigationController?.view.endEditing(true) UIView.animate(withDuration: dimmingViewAnimationDuration, animations: { - self.dimmingView.alpha = self.dimmingViewAlpha + self.dimmingView.alpha = alpha ?? self.dimmingViewAlpha }) } } else if dimmingView.superview != nil { @@ -322,24 +328,21 @@ class WPSplitViewController: UISplitViewController { * detail view controller. */ @objc func setInitialPrimaryViewController(_ viewController: UIViewController) { - var initialViewControllers = [viewController] - - if let navigationController = viewController as? UINavigationController, + guard let navigationController = viewController as? UINavigationController, let rootViewController = navigationController.viewControllers.last, - let detailViewController = initialDetailViewControllerForPrimaryViewController(rootViewController) { - - navigationController.delegate = self - - initialViewControllers.append(detailViewController) - viewControllers = initialViewControllers - } else { - viewControllers = [viewController, UIViewController()] + let detailViewController = initialDetailViewControllerForPrimaryViewController(rootViewController) else { + viewControllers = [viewController, UIViewController()] + return } + + navigationController.delegate = self + viewControllers = [viewController, detailViewController] } fileprivate func initialDetailViewControllerForPrimaryViewController(_ viewController: UIViewController) -> UIViewController? { + guard let detailProvider = viewController as? WPSplitViewControllerDetailProvider, - let detailViewController = detailProvider.initialDetailViewControllerForSplitView(self) else { + let detailViewController = detailProvider.initialDetailViewControllerForSplitView(self) else { return nil } @@ -369,10 +372,12 @@ class WPSplitViewController: UISplitViewController { /// /// - Parameter hidden: If `true`, hide the primary view controller. @objc func setPrimaryViewControllerHidden(_ hidden: Bool, animated: Bool = true) { - guard fullscreenDisplayEnabled else { return } + guard fullscreenDisplayEnabled else { + return + } let updateDisplayMode = { - self.preferredDisplayMode = (hidden) ? .primaryHidden : .allVisible + self.preferredDisplayMode = (hidden) ? .secondaryOnly : .oneBesideSecondary } if animated { @@ -428,6 +433,12 @@ extension WPSplitViewController: UISplitViewControllerDelegate { return nil } + // If the primary view is full width (i.e. when the No Results View is displayed), + // don't show a detail view as it will be rendered on top of (thus covering) the primary view. + if wpPrimaryColumnWidth == .full { + return primaryNavigationController + } + var viewControllers: [UIViewController] = [] // Splits the view controller list into primary and detail view controllers at the specified index @@ -499,7 +510,7 @@ extension WPSplitViewController: UISplitViewControllerDelegate { let forceKeepDetail = (collapseMode == .AlwaysKeepDetail && primaryViewController.viewControllers.last is WPSplitViewControllerDetailProvider) - if detailNavigationStackHasBeenModified || forceKeepDetail { + if (!isShowingInitialDetail && detailNavigationStackHasBeenModified) || forceKeepDetail { primaryViewController.viewControllers.append(contentsOf: secondaryViewController.viewControllers) } } @@ -553,7 +564,7 @@ extension WPSplitViewController: UINavigationControllerDelegate { } let hasFullscreenViewControllersInStack = navigationController.viewControllers.filter({$0 is PrefersFullscreenDisplay}).count > 0 - let isCurrentlyFullscreen = preferredDisplayMode != .allVisible + let isCurrentlyFullscreen = preferredDisplayMode != .oneBesideSecondary // Handle popping from fullscreen view controllers // @@ -692,11 +703,11 @@ extension UIViewController { /// in fullscreen until the `navigationController(_:willShowViewController:animated:)` /// delegate method detects that there are no fullscreen view controllers left /// in the stack. -protocol PrefersFullscreenDisplay: class {} +protocol PrefersFullscreenDisplay: AnyObject {} /// Used to indicate whether a view controller varies its preferred status bar style. /// -protocol DefinesVariableStatusBarStyle: class {} +protocol DefinesVariableStatusBarStyle: AnyObject {} // MARK: - WPSplitViewControllerDetailProvider Protocol diff --git a/WordPress/Classes/ViewRelated/System/WPTabBarController+MeNavigation.swift b/WordPress/Classes/ViewRelated/System/WPTabBarController+MeNavigation.swift deleted file mode 100644 index 9eddd3ea62e0..000000000000 --- a/WordPress/Classes/ViewRelated/System/WPTabBarController+MeNavigation.swift +++ /dev/null @@ -1,86 +0,0 @@ - -import UIKit - -/// Methods to access the Me Scene and sub levels -extension WPTabBarController { - /// removes all but the primary viewControllers from the stack - @objc func popMeTabToRoot() { - if FeatureFlag.meMove.enabled { - getNavigationController()?.popToRootViewController(animated: false) - } else { - self.meNavigationController.popToRootViewController(animated: false) - } - - } - /// presents the Me scene. If the feature flag is disabled, replaces the previously defined `showMeTab` - @objc func showMeScene(animated: Bool = true, completion: (() -> Void)? = nil) { - if FeatureFlag.meMove.enabled { - meScenePresenter.present(on: self, animated: animated, completion: completion) - } else { - showTab(for: Int(WPTabType.me.rawValue)) - } - } - /// access to sub levels - @objc func navigateToAccountSettings() { - if FeatureFlag.meMove.enabled { - showMeScene(animated: false) { - self.popMeTabToRoot() - self.getMeViewController()?.navigateToAccountSettings() - } - } else { - showMeScene() - popMeTabToRoot() - DispatchQueue.main.async { - self.meViewController.navigateToAccountSettings() - } - } - } - - @objc func navigateToAppSettings() { - if FeatureFlag.meMove.enabled { - showMeScene() { - self.popMeTabToRoot() - self.getMeViewController()?.navigateToAppSettings() - } - } else { - showMeScene() - popMeTabToRoot() - DispatchQueue.main.async { - self.meViewController.navigateToAppSettings() - } - } - } - - @objc func navigateToSupport() { - if FeatureFlag.meMove.enabled { - showMeScene() { - self.popMeTabToRoot() - self.getMeViewController()?.navigateToHelpAndSupport() - } - } else { - showMeScene() - popMeTabToRoot() - DispatchQueue.main.async { - self.meViewController.navigateToHelpAndSupport() - } - } - } - - /// obtains a reference to the navigation controller of the presented MeViewController - private func getNavigationController() -> UINavigationController? { - guard let splitController = meScenePresenter.presentedViewController as? WPSplitViewController, - let navigationController = splitController.viewControllers.first as? UINavigationController else { - return nil - } - return navigationController - } - - /// obtains a reference to the presented MeViewController - private func getMeViewController() -> MeViewController? { - guard let navigationController = getNavigationController(), - let meController = navigationController.viewControllers.first as? MeViewController else { - return nil - } - return meController - } -} diff --git a/WordPress/Classes/ViewRelated/System/WPTabBarController+QuickStart.swift b/WordPress/Classes/ViewRelated/System/WPTabBarController+QuickStart.swift index 65f057b33eba..5d38d163b4c4 100644 --- a/WordPress/Classes/ViewRelated/System/WPTabBarController+QuickStart.swift +++ b/WordPress/Classes/ViewRelated/System/WPTabBarController+QuickStart.swift @@ -1,5 +1,4 @@ private var spotlightView: QuickStartSpotlightView? -private let spotlightViewOffset: CGFloat = -5.0 private var quickStartObserver: NSObject? extension WPTabBarController { @@ -8,43 +7,80 @@ extension WPTabBarController { spotlightView?.removeFromSuperview() spotlightView = nil + let tabBarElements: [QuickStartTourElement] = [.readerTab, .notifications] + guard let userInfo = notification.userInfo, let element = userInfo[QuickStartTourGuide.notificationElementKey] as? QuickStartTourElement, - [.newpost, .readerTab].contains(element), - let tabBar = self?.tabBar else { + tabBarElements.contains(element) else { return } let newSpotlight = QuickStartSpotlightView() - tabBar.addSubview(newSpotlight) + self?.view.addSubview(newSpotlight) - let x: CGFloat - if element == .newpost { - x = tabBar.bounds.size.width / 2.0 - } else { - x = tabBar.bounds.size.width * 0.40 - newSpotlight.frame.size.width + guard let tabButton = self?.getTabButton(for: element) else { + return } - newSpotlight.frame = CGRect(x: x, y: spotlightViewOffset, width: newSpotlight.frame.width, height: newSpotlight.frame.height) + + newSpotlight.translatesAutoresizingMaskIntoConstraints = false + + let newSpotlightCenterX = newSpotlight.centerXAnchor.constraint(equalTo: tabButton.centerXAnchor, constant: Constants.spotlightXOffset) + let newSpotlightCenterY = newSpotlight.centerYAnchor.constraint(equalTo: tabButton.centerYAnchor, constant: Constants.spotlightYOffset) + let newSpotlightWidth = newSpotlight.widthAnchor.constraint(equalToConstant: Constants.spotlightDiameter) + let newSpotlightHeight = newSpotlight.heightAnchor.constraint(equalToConstant: Constants.spotlightDiameter) + + NSLayoutConstraint.activate([newSpotlightCenterX, newSpotlightCenterY, newSpotlightWidth, newSpotlightHeight]) + spotlightView = newSpotlight } quickStartObserver = observer as? NSObject } - @objc func alertQuickStartThatWriteWasTapped() { - tourGuide.visited(.newpost) + @objc func alertQuickStartThatReaderWasTapped() { + QuickStartTourGuide.shared.visited(.readerTab) } - @objc func alertQuickStartThatReaderWasTapped() { - tourGuide.visited(.readerTab) + @objc func alertQuickStartThatNotificationsWasTapped() { + QuickStartTourGuide.shared.visited(.notifications) } @objc func alertQuickStartThatOtherTabWasTapped() { - tourGuide.visited(.tabFlipped) + QuickStartTourGuide.shared.visited(.tabFlipped) } @objc func stopWatchingQuickTours() { NotificationCenter.default.removeObserver(quickStartObserver as Any) quickStartObserver = nil } + + private func getTabButton(for element: QuickStartTourElement) -> UIView? { + guard let index = tabIndex(for: element) else { + return nil + } + tabBar.layoutIfNeeded() + var tabs = tabBar.subviews.compactMap { return $0 is UIControl ? $0 : nil } + tabs.sort { $0.frame.origin.x < $1.frame.origin.x } + if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft { + tabs.reverse() + } + return tabs[safe: index] + } + + private func tabIndex(for element: QuickStartTourElement) -> Int? { + switch element { + case .readerTab: + return Int(WPTab.reader.rawValue) + case .notifications: + return Int(WPTab.notifications.rawValue) + default: + return nil + } + } + + private enum Constants { + static let spotlightDiameter: CGFloat = 40 + static let spotlightXOffset: CGFloat = 20 + static let spotlightYOffset: CGFloat = -10 + } } diff --git a/WordPress/Classes/ViewRelated/System/WPTabBarController+ShowTab.swift b/WordPress/Classes/ViewRelated/System/WPTabBarController+ShowTab.swift deleted file mode 100644 index ac635cf48c21..000000000000 --- a/WordPress/Classes/ViewRelated/System/WPTabBarController+ShowTab.swift +++ /dev/null @@ -1,37 +0,0 @@ -extension WPTabBarController { - - @objc func showPageTab(forBlog blog: Blog) { - let context = ContextManager.sharedInstance().mainContext - let postService = PostService(managedObjectContext: context) - let page = postService.createDraftPage(for: blog) - WPAppAnalytics.track(.editorCreatedPost, withProperties: ["tap_source": "tab_bar"], with: blog) - - let editorFactory = EditorFactory() - - let postViewController = editorFactory.instantiateEditor( - for: page, - replaceEditor: { [weak self] (editor, replacement) in - self?.replaceEditor(editor: editor, replacement: replacement) - }) - - show(postViewController) - } - - private func replaceEditor(editor: EditorViewController, replacement: EditorViewController) { - editor.dismiss(animated: true) { [weak self] in - self?.show(replacement) - } - } - - private func show(_ editorViewController: EditorViewController) { - editorViewController.onClose = { [weak editorViewController] _, _ in - editorViewController?.dismiss(animated: true) - } - - let navController = UINavigationController(rootViewController: editorViewController) - navController.restorationIdentifier = Restorer.Identifier.navigationController.rawValue - navController.modalPresentationStyle = .fullScreen - - present(navController, animated: true, completion: nil) - } -} diff --git a/WordPress/Classes/ViewRelated/System/WPTabBarController+Swift.swift b/WordPress/Classes/ViewRelated/System/WPTabBarController+Swift.swift index 8829343b1612..0ab38e7acbff 100644 --- a/WordPress/Classes/ViewRelated/System/WPTabBarController+Swift.swift +++ b/WordPress/Classes/ViewRelated/System/WPTabBarController+Swift.swift @@ -2,11 +2,7 @@ // MARK: - Tab Access Tracking extension WPTabBarController { - private static let tabIndexToStatMap: [WPTabType: WPAnalyticsStat] = [ - .mySites: .mySitesTabAccessed, - .reader: .readerAccessed, - .me: .meTabAccessed - ] + private static let tabIndexToStatMap: [WPTab: WPAnalyticsStat] = [.mySites: .mySitesTabAccessed, .reader: .readerAccessed] private struct AssociatedKeys { static var shouldTrackTabAccessOnViewDidAppear = 0 @@ -83,12 +79,13 @@ extension WPTabBarController { return false } - guard let tabType = WPTabType(rawValue: UInt(tabIndex)), + guard let tabType = WPTab(rawValue: Int(tabIndex)), let stat = WPTabBarController.tabIndexToStatMap[tabType] else { return false } WPAppAnalytics.track(stat) + return true } @@ -96,4 +93,17 @@ extension WPTabBarController { @objc func setupColors() { tabBar.isTranslucent = false } + + open override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + guard let selectedViewController else { + return super.supportedInterfaceOrientations + } + + if let splitViewController = selectedViewController as? WPSplitViewController, + let topDetailViewController = splitViewController.topDetailViewController { + return topDetailViewController.supportedInterfaceOrientations + } + + return selectedViewController.supportedInterfaceOrientations + } } diff --git a/WordPress/Classes/ViewRelated/System/WPTabBarController.h b/WordPress/Classes/ViewRelated/System/WPTabBarController.h index 66c964d81c5f..edb962f93ce8 100644 --- a/WordPress/Classes/ViewRelated/System/WPTabBarController.h +++ b/WordPress/Classes/ViewRelated/System/WPTabBarController.h @@ -1,79 +1,52 @@ #import +NS_ASSUME_NONNULL_BEGIN + extern NSString * const WPNewPostURLParamContentKey; extern NSString * const WPNewPostURLParamTagsKey; - -typedef NS_ENUM(NSUInteger, WPTabType) { - WPTabMySites, - WPTabReader, - WPTabNewPost, - WPTabMe, - WPTabNotifications -}; +extern NSString * const WPTabBarCurrentlySelectedScreenSites; +extern NSString * const WPTabBarCurrentlySelectedScreenReader; +extern NSString * const WPTabBarCurrentlySelectedScreenNotifications; +extern NSNotificationName const WPTabBarHeightChangedNotification; @class AbstractPost; @class Blog; +@class BloggingPromptCoordinator; @class BlogListViewController; @class MeViewController; @class MySitesCoordinator; @class NotificationsViewController; @class ReaderCoordinator; -@class ReaderMenuViewController; -@class CreateButtonCoordinator; +@class ReaderTabViewModel; @class WPSplitViewController; -@class QuickStartTourGuide; @protocol ScenePresenter; @interface WPTabBarController : UITabBarController -@property (nonatomic, strong, readonly) WPSplitViewController *blogListSplitViewController; -@property (nonatomic, strong, readonly) BlogListViewController *blogListViewController; -@property (nonatomic, strong, readonly) UINavigationController *blogListNavigationController; -@property (nonatomic, strong, readonly) ReaderMenuViewController *readerMenuViewController; -@property (nonatomic, strong, readonly) NotificationsViewController *notificationsViewController; -// will be removed when the new IA implementation completes -@property (nonatomic, strong, readonly) MeViewController *meViewController; -// will be removed when the new IA implementation completes -@property (nonatomic, strong, readonly) UINavigationController *meNavigationController; -@property (nonatomic, strong, readonly) QuickStartTourGuide *tourGuide; -@property (nonatomic, strong, readonly) MySitesCoordinator *mySitesCoordinator; -@property (nonatomic, strong, readonly) ReaderCoordinator *readerCoordinator; +@property (nonatomic, strong, readonly, nullable) NotificationsViewController *notificationsViewController; +@property (nonatomic, strong, readonly, nullable) UINavigationController *readerNavigationController; +@property (nonatomic, strong, readonly, nonnull) MySitesCoordinator *mySitesCoordinator; +@property (nonatomic, strong, readonly, nullable) ReaderCoordinator *readerCoordinator; @property (nonatomic, strong) id meScenePresenter; -@property (nonatomic, strong, readonly) CreateButtonCoordinator *createButtonCoordinator; +@property (nonatomic, strong, readonly) ReaderTabViewModel *readerTabViewModel; -+ (instancetype)sharedInstance; +- (instancetype)initWithStaticScreens:(BOOL)shouldUseStaticScreens; - (NSString *)currentlySelectedScreen; -- (BOOL)isNavigatingMySitesTab; - (void)showMySitesTab; - (void)showReaderTab; - (void)resetReaderTab; -- (void)showPostTab; -- (void)showPostTabWithCompletion:(void (^)(void))afterDismiss; -- (void)showPostTabForBlog:(Blog *)blog; -// will be removed when the new IA implementation completes -- (void)showMeTab; - (void)showNotificationsTab; -- (void)showPostTabAnimated:(BOOL)animated toMedia:(BOOL)openToMedia; - (void)showReaderTabForPost:(NSNumber *)postId onBlog:(NSNumber *)blogId; -- (void)switchMySitesTabToAddNewSite; -- (void)switchMySitesTabToStatsViewForBlog:(Blog *)blog; -- (void)switchMySitesTabToMediaForBlog:(Blog *)blog; -- (void)switchMySitesTabToCustomizeViewForBlog:(Blog *)blog; -- (void)switchMySitesTabToThemesViewForBlog:(Blog *)blog; -- (void)switchTabToPostsListForPost:(AbstractPost *)post; -- (void)switchTabToPagesListForPost:(AbstractPost *)post; -- (void)switchMySitesTabToBlogDetailsForBlog:(Blog *)blog; +- (void)reloadSplitViewControllers; - (void)popNotificationsTabToRoot; - (void)switchNotificationsTabToNotificationSettings; -- (void)switchReaderTabToSavedPosts; - - (void)showNotificationsTabForNoteWithID:(NSString *)notificationID; - (void)updateNotificationBadgeVisibility; -// will be removed when the new IA implementation completes -- (void)showTabForIndex:(NSInteger)tabIndex; @end + +NS_ASSUME_NONNULL_END diff --git a/WordPress/Classes/ViewRelated/System/WPTabBarController.m b/WordPress/Classes/ViewRelated/System/WPTabBarController.m index d2db725fd838..5ce3c463561f 100644 --- a/WordPress/Classes/ViewRelated/System/WPTabBarController.m +++ b/WordPress/Classes/ViewRelated/System/WPTabBarController.m @@ -2,7 +2,7 @@ #import #import "AccountService.h" -#import "ContextManager.h" +#import "CoreDataStack.h" #import "BlogService.h" #import "Blog.h" @@ -18,12 +18,9 @@ static NSString * const WPTabBarRestorationID = @"WPTabBarID"; -static NSString * const WPBlogListSplitViewRestorationID = @"WPBlogListSplitViewRestorationID"; static NSString * const WPReaderSplitViewRestorationID = @"WPReaderSplitViewRestorationID"; -static NSString * const WPMeSplitViewRestorationID = @"WPMeSplitViewRestorationID"; static NSString * const WPNotificationsSplitViewRestorationID = @"WPNotificationsSplitViewRestorationID"; -static NSString * const WPBlogListNavigationRestorationID = @"WPBlogListNavigationID"; static NSString * const WPReaderNavigationRestorationID = @"WPReaderNavigationID"; static NSString * const WPMeNavigationRestorationID = @"WPMeNavigationID"; static NSString * const WPNotificationsNavigationRestorationID = @"WPNotificationsNavigationID"; @@ -39,28 +36,29 @@ NSString * const WPNewPostURLParamTagsKey = @"tags"; NSString * const WPNewPostURLParamImageKey = @"image"; +NSString * const WPTabBarCurrentlySelectedScreenSites = @"Blog List"; +NSString * const WPTabBarCurrentlySelectedScreenReader = @"Reader"; +NSString * const WPTabBarCurrentlySelectedScreenNotifications = @"Notifications"; + +NSNotificationName const WPTabBarHeightChangedNotification = @"WPTabBarHeightChangedNotification"; +static NSString * const WPTabBarFrameKeyPath = @"frame"; + static NSInteger const WPTabBarIconOffsetiPad = 7; static NSInteger const WPTabBarIconOffsetiPhone = 5; -static CGFloat const WPTabBarIconSize = 32.0f; @interface WPTabBarController () -@property (nonatomic, strong) BlogListViewController *blogListViewController; +@property (nonatomic, assign) BOOL shouldUseStaticScreens; + @property (nonatomic, strong) NotificationsViewController *notificationsViewController; -@property (nonatomic, strong) ReaderMenuViewController *readerMenuViewController; -@property (nonatomic, strong) MeViewController *meViewController; -@property (nonatomic, strong) QuickStartTourGuide *tourGuide; -@property (nonatomic, strong) UIViewController *newPostViewController; -@property (nonatomic, strong) UINavigationController *blogListNavigationController; @property (nonatomic, strong) UINavigationController *readerNavigationController; @property (nonatomic, strong) UINavigationController *notificationsNavigationController; -@property (nonatomic, strong) UINavigationController *meNavigationController; -@property (nonatomic, strong) WPSplitViewController *blogListSplitViewController; -@property (nonatomic, strong) WPSplitViewController *readerSplitViewController; -@property (nonatomic, strong) WPSplitViewController *meSplitViewController; @property (nonatomic, strong) WPSplitViewController *notificationsSplitViewController; +@property (nonatomic, strong) ReaderTabViewModel *readerTabViewModel; + +@property (nonatomic, strong, nullable) MySitesCoordinator *mySitesCoordinator; @property (nonatomic, strong) UIImage *notificationsTabBarImage; @property (nonatomic, strong) UIImage *notificationsTabBarImageUnread; @@ -68,7 +66,7 @@ @interface WPTabBarController () screenWidth / 2; - - if (iPhoneLandscape || iPadPortraitFullscreen || iPadLandscapeGreaterThanHalfSplit) { - self.newPostViewController.tabBarItem.imageInsets = UIEdgeInsetsZero; - self.newPostViewController.tabBarItem.titlePositionAdjustment = UIOffsetZero; - self.newPostViewController.tabBarItem.image = [Gridicon iconOfType:GridiconTypeCreate withSize:CGSizeMake(WPTabBarIconSize, WPTabBarIconSize)]; - } else { - self.newPostViewController.tabBarItem.imageInsets = [self tabBarIconImageInsets]; - self.newPostViewController.tabBarItem.titlePositionAdjustment = UIOffsetMake(0, 99999.0); - - self.newPostViewController.tabBarItem.image = [UIImage imageNamed:@"icon-tab-newpost"]; - } -} - - (UIEdgeInsets)tabBarIconImageInsets { CGFloat offset = 0; @@ -341,60 +227,12 @@ - (UIEdgeInsets)tabBarIconImageInsets #pragma mark - Split View Controllers -- (UISplitViewController *)blogListSplitViewController +- (ReaderTabViewModel *)readerTabViewModel { - if (!_blogListSplitViewController) { - _blogListSplitViewController = [WPSplitViewController new]; - _blogListSplitViewController.restorationIdentifier = WPBlogListSplitViewRestorationID; - _blogListSplitViewController.presentsWithGesture = NO; - [_blogListSplitViewController setInitialPrimaryViewController:self.blogListNavigationController]; - _blogListSplitViewController.wpPrimaryColumnWidth = WPSplitViewControllerPrimaryColumnWidthNarrow; - - _blogListSplitViewController.dimsDetailViewControllerAutomatically = YES; - - _blogListSplitViewController.tabBarItem = self.blogListNavigationController.tabBarItem; + if (!_readerTabViewModel) { + _readerTabViewModel = [self makeReaderTabViewModel]; } - - return _blogListSplitViewController; -} - -- (UISplitViewController *)readerSplitViewController -{ - if (!_readerSplitViewController) { - _readerSplitViewController = [WPSplitViewController new]; - _readerSplitViewController.restorationIdentifier = WPReaderSplitViewRestorationID; - _readerSplitViewController.presentsWithGesture = NO; - [_readerSplitViewController setInitialPrimaryViewController:self.readerNavigationController]; - _readerSplitViewController.wpPrimaryColumnWidth = WPSplitViewControllerPrimaryColumnWidthNarrow; - _readerSplitViewController.collapseMode = WPSplitViewControllerCollapseModeAlwaysKeepDetail; - - // There's currently a bug on Plus sized phones where the detail navigation - // stack gets corrupted after restoring state: https://github.com/wordpress-mobile/WordPress-iOS/pull/6287#issuecomment-266877329 - // I've been unable to resolve it thus far, so for now we'll disable - // landscape split view on Plus devices for the Reader. - // James Frost 2017-01-09 - if ([WPDeviceIdentification isUnzoomediPhonePlus]) { - [_readerSplitViewController setOverrideTraitCollection:[UITraitCollection traitCollectionWithHorizontalSizeClass:UIUserInterfaceSizeClassCompact]]; - } - - _readerSplitViewController.tabBarItem = self.readerNavigationController.tabBarItem; - } - - return _readerSplitViewController; -} - -- (UISplitViewController *)meSplitViewController -{ - if (!_meSplitViewController) { - _meSplitViewController = [WPSplitViewController new]; - _meSplitViewController.restorationIdentifier = WPMeSplitViewRestorationID; - [_meSplitViewController setInitialPrimaryViewController:self.meNavigationController]; - _meSplitViewController.wpPrimaryColumnWidth = WPSplitViewControllerPrimaryColumnWidthNarrow; - - _meSplitViewController.tabBarItem = self.meNavigationController.tabBarItem; - } - - return _meSplitViewController; + return _readerTabViewModel; } - (UISplitViewController *)notificationsSplitViewController @@ -414,21 +252,12 @@ - (UISplitViewController *)notificationsSplitViewController - (void)reloadSplitViewControllers { - _blogListNavigationController = nil; - _blogListSplitViewController = nil; _readerNavigationController = nil; - _readerMenuViewController = nil; - _readerSplitViewController = nil; - _meSplitViewController = nil; _notificationsNavigationController = nil; _notificationsSplitViewController = nil; [self setViewControllers:[self tabViewControllers]]; - if ([Feature enabled:FeatureFlagFloatingCreateButton]) { - [self.createButtonCoordinator addTo:self.view trailingAnchor:self.blogListSplitViewController.viewControllers[0].view.trailingAnchor bottomAnchor:self.tabBar.topAnchor]; - } - // Reset the selectedIndex to the default MySites tab. self.selectedIndex = WPTabMySites; } @@ -436,9 +265,6 @@ - (void)reloadSplitViewControllers - (void)resetReaderTab { _readerNavigationController = nil; - _readerMenuViewController = nil; - _readerSplitViewController = nil; - [self setViewControllers:[self tabViewControllers]]; } @@ -446,180 +272,74 @@ - (void)resetReaderTab - (MySitesCoordinator *)mySitesCoordinator { - return [[MySitesCoordinator alloc] initWithMySitesSplitViewController:self.blogListSplitViewController - mySitesNavigationController:self.blogListNavigationController - blogListViewController:self.blogListViewController]; -} - -- (ReaderCoordinator *)readerCoordinator -{ - return [[ReaderCoordinator alloc] initWithReaderNavigationController:self.readerNavigationController - readerSplitViewController:self.readerSplitViewController - readerMenuViewController:self.readerMenuViewController]; -} - -- (CreateButtonCoordinator *)createButtonCoordinator -{ - if (!_createButtonCoordinator) { + if (!_mySitesCoordinator) { __weak __typeof(self) weakSelf = self; - _createButtonCoordinator = [[CreateButtonCoordinator alloc] init:self newPost:^{ - [weakSelf dismissViewControllerAnimated:true completion:nil]; - [weakSelf showPostTab]; - } newPage:^{ - [weakSelf dismissViewControllerAnimated:true completion:nil]; - Blog *blog = [weakSelf currentOrLastBlog]; - [weakSelf showPageTabForBlog:blog]; + + _mySitesCoordinator = [[MySitesCoordinator alloc] initWithMeScenePresenter: self.meScenePresenter + onBecomeActiveTab:^{ + [weakSelf showMySitesTab]; }]; } - return _createButtonCoordinator; + return _mySitesCoordinator; +} + +- (ReaderCoordinator *)readerCoordinator +{ + return [[ReaderCoordinator alloc] initWithReaderNavigationController:self.readerNavigationController]; } #pragma mark - Navigation Helpers - (NSArray *)tabViewControllers { - - NSMutableArray *allViewControllers = [NSMutableArray arrayWithArray:@[self.blogListSplitViewController, - self.readerSplitViewController, - self.newPostViewController, - self.meSplitViewController, - self.notificationsSplitViewController]]; - - if ([Feature enabled:FeatureFlagFloatingCreateButton]) { - [allViewControllers removeObject:self.newPostViewController]; - } - - if ([Feature enabled:FeatureFlagMeMove]) { - [allViewControllers removeObject:self.meSplitViewController]; - self.meSplitViewController = nil; - self.meNavigationController = nil; - self.meViewController = nil; + if (self.shouldUseStaticScreens) { + return @[ + self.mySitesCoordinator.rootViewController, + self.readerNavigationController, + self.notificationsNavigationController + ]; } - return allViewControllers; -} - -- (void)showTabForIndex:(NSInteger)tabIndex -{ - //TODO: only for FeatureFlagMeMove: this always receives a WPTabType, so we set toTabType = true - NSInteger newIndex = [self adjustedTabIndex:tabIndex toTabType:true]; - [self setSelectedIndex:newIndex]; -} - -/// Adjusts the passed tabIndex to a new value depending on the enabled feature flags -/// @param tabIndex The index that may need adjustment. -/// @param toTabType Whether the new index is being converted to the WPTabType index. If true, the index should come from the tab bar. -- (NSInteger)adjustedTabIndex:(NSInteger)tabIndex toTabType:(BOOL)toTabType { - //TODO: Remove this change once `floatingCreateButton` feature flag is enabled - NSInteger tabOffset = 0; - if ([Feature enabled:FeatureFlagFloatingCreateButton] && tabIndex > WPTabReader) { - tabOffset += 1; - } - if ([Feature enabled:FeatureFlagMeMove] && tabIndex > WPTabNewPost) { - tabOffset += 1; - } - return tabIndex + (toTabType ? -tabOffset : tabOffset); + return @[ + self.mySitesCoordinator.rootViewController, + self.readerNavigationController, + self.notificationsSplitViewController + ]; } - (void)showMySitesTab { - [self showTabForIndex:WPTabMySites]; + [self setSelectedIndex:WPTabMySites]; } - (void)showReaderTab { - [self showTabForIndex:WPTabReader]; -} - -- (void)showPostTab -{ - [self showPostTabWithCompletion:nil]; -} - -- (void)showPostTabWithCompletion:(void (^)(void))afterDismiss -{ - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; - // Ignore taps on the post tab and instead show the modal. - if ([blogService blogCountForAllAccounts] == 0) { - [self switchMySitesTabToAddNewSite]; - } else { - [self showPostTabAnimated:true toMedia:false blog:nil afterDismiss:afterDismiss]; - } - - [self alertQuickStartThatWriteWasTapped]; -} - -- (void)showPostTabForBlog:(Blog *)blog -{ - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; - if ([blogService blogCountForAllAccounts] == 0) { - [self switchMySitesTabToAddNewSite]; - } else { - [self showPostTabAnimated:YES toMedia:NO blog:blog]; - } -} -// will be removed when the new IA implementation completes -- (void)showMeTab -{ - [self showTabForIndex:WPTabMe]; + [self setSelectedIndex:WPTabReader]; } - (void)showNotificationsTab { - [self showTabForIndex:WPTabNotifications]; -} - -- (void)showPostTabAnimated:(BOOL)animated toMedia:(BOOL)openToMedia -{ - [self showPostTabAnimated:animated toMedia:openToMedia blog:nil]; -} - -- (void)showPostTabAnimated:(BOOL)animated toMedia:(BOOL)openToMedia blog:(Blog *)blog -{ - [self showPostTabAnimated:animated toMedia:openToMedia blog:blog afterDismiss:nil]; -} - -- (void)showPostTabAnimated:(BOOL)animated toMedia:(BOOL)openToMedia blog:(Blog *)blog afterDismiss:(void (^)(void))afterDismiss -{ - if (self.presentedViewController) { - [self dismissViewControllerAnimated:NO completion:nil]; - } - - if (!blog) { - blog = [self currentOrLastBlog]; - } - - EditPostViewController* editor = [[EditPostViewController alloc] initWithBlog:blog]; - editor.modalPresentationStyle = UIModalPresentationFullScreen; - editor.showImmediately = !animated; - editor.openWithMediaPicker = openToMedia; - editor.afterDismiss = afterDismiss; - [WPAppAnalytics track:WPAnalyticsStatEditorCreatedPost withProperties:@{ @"tap_source": @"tab_bar"} withBlog:blog]; - [self presentViewController:editor animated:NO completion:nil]; - return; + [self setSelectedIndex:WPTabNotifications]; } - (void)showReaderTabForPost:(NSNumber *)postId onBlog:(NSNumber *)blogId { [self showReaderTab]; + UIViewController *topDetailVC = (UIViewController *)self.readerNavigationController.topViewController; - UIViewController *topDetailVC = (ReaderDetailViewController *)self.readerSplitViewController.topDetailViewController; + // TODO: needed? if ([topDetailVC isKindOfClass:[ReaderDetailViewController class]]) { ReaderDetailViewController *readerDetailVC = (ReaderDetailViewController *)topDetailVC; ReaderPost *readerPost = readerDetailVC.post; if ([readerPost.postID isEqual:postId] && [readerPost.siteID isEqual: blogId]) { - // The desired reader detail VC is already the top VC for the tab. Move along. + // The desired reader detail VC is already the top VC for the tab. Move along. return; } } - - if (topDetailVC && topDetailVC.navigationController) { - ReaderDetailViewController *readerPostDetailVC = [ReaderDetailViewController controllerWithPostID:postId siteID:blogId isFeed:NO]; - [topDetailVC.navigationController pushFullscreenViewController:readerPostDetailVC animated:YES]; - } + + UIViewController *readerPostDetailVC = [ReaderDetailViewController controllerWithPostID:postId siteID:blogId isFeed:NO]; + [self.readerNavigationController pushFullscreenViewController:readerPostDetailVC animated:YES]; } - (void)popNotificationsTabToRoot @@ -627,113 +347,6 @@ - (void)popNotificationsTabToRoot [self.notificationsNavigationController popToRootViewControllerAnimated:NO]; } -- (void)switchTabToPostsListForPost:(AbstractPost *)post -{ - UIViewController *topVC = [self.blogListSplitViewController topDetailViewController]; - if ([topVC isKindOfClass:[PostListViewController class]]) { - Blog *blog = ((PostListViewController *)topVC).blog; - if ([post.blog.objectID isEqual:blog.objectID]) { - // The desired post view controller is already the top viewController for the tab. - // Nothing to see here. Move along. - return; - } - } - - [self switchMySitesTabToBlogDetailsForBlog:post.blog]; - - BlogDetailsViewController *blogDetailVC = (BlogDetailsViewController *)self.blogListNavigationController.topViewController; - if ([blogDetailVC isKindOfClass:[BlogDetailsViewController class]]) { - [blogDetailVC showDetailViewForSubsection:BlogDetailsSubsectionPosts]; - } -} - -- (void)switchTabToPagesListForPost:(AbstractPost *)post -{ - UIViewController *topVC = [self.blogListSplitViewController topDetailViewController]; - if ([topVC isKindOfClass:[PageListViewController class]]) { - Blog *blog = ((PageListViewController *)topVC).blog; - if ([post.blog.objectID isEqual:blog.objectID]) { - // The desired post view controller is already the top viewController for the tab. - // Nothing to see here. Move along. - return; - } - } - - [self switchMySitesTabToBlogDetailsForBlog:post.blog]; - - BlogDetailsViewController *blogDetailVC = (BlogDetailsViewController *)self.blogListNavigationController.topViewController; - if ([blogDetailVC isKindOfClass:[BlogDetailsViewController class]]) { - [blogDetailVC showDetailViewForSubsection:BlogDetailsSubsectionPages]; - } -} - -- (void)switchMySitesTabToAddNewSite -{ - [self showTabForIndex:WPTabMySites]; - [self.blogListViewController presentInterfaceForAddingNewSiteFrom:self.tabBar]; -} - -- (void)switchMySitesTabToStatsViewForBlog:(Blog *)blog -{ - [self switchMySitesTabToBlogDetailsForBlog:blog]; - - BlogDetailsViewController *blogDetailVC = (BlogDetailsViewController *)self.blogListNavigationController.topViewController; - if ([blogDetailVC isKindOfClass:[BlogDetailsViewController class]]) { - [blogDetailVC showDetailViewForSubsection:BlogDetailsSubsectionStats]; - } -} - -- (void)switchMySitesTabToMediaForBlog:(Blog *)blog -{ - if ([self adjustedTabIndex:self.selectedIndex toTabType:false] == WPTabMySites) { - UIViewController *topViewController = (BlogDetailsViewController *)self.blogListNavigationController.topViewController; - if ([topViewController isKindOfClass:[MediaLibraryViewController class]]) { - MediaLibraryViewController *mediaVC = (MediaLibraryViewController *)topViewController; - if (mediaVC.blog == blog) { - // If media is already selected for the specified blog, do nothing. - return; - } - } - } - - [self switchMySitesTabToBlogDetailsForBlog:blog]; - - BlogDetailsViewController *blogDetailVC = (BlogDetailsViewController *)self.blogListNavigationController.topViewController; - if ([blogDetailVC isKindOfClass:[BlogDetailsViewController class]]) { - [blogDetailVC showDetailViewForSubsection:BlogDetailsSubsectionMedia]; - } -} - -- (void)switchMySitesTabToCustomizeViewForBlog:(Blog *)blog -{ - [self switchMySitesTabToThemesViewForBlog:blog]; - - UIViewController *topVC = [self.blogListSplitViewController topDetailViewController]; - if ([topVC isKindOfClass:[ThemeBrowserViewController class]]) { - ThemeBrowserViewController *themeViewController = (ThemeBrowserViewController *)topVC; - [themeViewController presentCustomizeForTheme:[themeViewController currentTheme]]; - } -} - -- (void)switchMySitesTabToThemesViewForBlog:(Blog *)blog -{ - [self switchMySitesTabToBlogDetailsForBlog:blog]; - - BlogDetailsViewController *blogDetailVC = (BlogDetailsViewController *)self.blogListNavigationController.topViewController; - if ([blogDetailVC isKindOfClass:[BlogDetailsViewController class]]) { - [blogDetailVC showDetailViewForSubsection:BlogDetailsSubsectionThemes]; - } -} - -- (void)switchMySitesTabToBlogDetailsForBlog:(Blog *)blog -{ - [self showTabForIndex:WPTabMySites]; - - BlogListViewController *blogListVC = self.blogListViewController; - self.blogListNavigationController.viewControllers = @[blogListVC]; - [blogListVC setSelectedBlog:blog animated:NO]; -} - - (void)switchNotificationsTabToNotificationSettings { [self showNotificationsTab]; @@ -742,96 +355,50 @@ - (void)switchNotificationsTabToNotificationSettings [self.notificationsViewController showNotificationSettings]; } -- (void)switchReaderTabToSavedPosts -{ - [self showReaderTab]; - - // Unfortunately animations aren't disabled properly for this - // transition unless we dispatch_async. - dispatch_async(dispatch_get_main_queue(), ^{ - [self.readerNavigationController popToRootViewControllerAnimated:NO]; - - [UIView performWithoutAnimation:^{ - [self.readerMenuViewController showSavedForLater]; - }]; - }); -} - - (NSString *)currentlySelectedScreen { // Check which tab is currently selected NSString *currentlySelectedScreen = @""; - switch ([self adjustedTabIndex:self.selectedIndex toTabType:false]) { + switch (self.selectedIndex) { case WPTabMySites: - currentlySelectedScreen = @"Blog List"; + currentlySelectedScreen = WPTabBarCurrentlySelectedScreenSites; break; case WPTabReader: - currentlySelectedScreen = @"Reader"; + currentlySelectedScreen = WPTabBarCurrentlySelectedScreenReader; break; case WPTabNotifications: - currentlySelectedScreen = @"Notifications"; + currentlySelectedScreen = WPTabBarCurrentlySelectedScreenNotifications; break; - case WPTabMe: - currentlySelectedScreen = @"Me"; default: break; } return currentlySelectedScreen; } -- (Blog *)currentlyVisibleBlog -{ - if ([self adjustedTabIndex:self.selectedIndex toTabType:false] != WPTabMySites) { - return nil; - } - - BlogDetailsViewController *blogDetailsController = (BlogDetailsViewController *)[[self.blogListNavigationController.viewControllers wp_filter:^BOOL(id obj) { - return [obj isKindOfClass:[BlogDetailsViewController class]]; - }] firstObject]; - return blogDetailsController.blog; -} - -- (Blog *)currentOrLastBlog -{ - Blog *blog = [self currentlyVisibleBlog]; - - if (blog == nil) { - NSManagedObjectContext *context = [[ContextManager sharedInstance] mainContext]; - BlogService *blogService = [[BlogService alloc] initWithManagedObjectContext:context]; - blog = [blogService lastUsedOrFirstBlog]; - } - - return blog; -} - #pragma mark - UITabBarControllerDelegate methods - (BOOL)tabBarController:(UITabBarController *)tabBarController shouldSelectViewController:(UIViewController *)viewController { - NSUInteger newIndex = [tabBarController.viewControllers indexOfObject:viewController]; - - newIndex = [self adjustedTabIndex:newIndex toTabType:false]; - - if (newIndex == WPTabNewPost) { - [self showPostTab]; - return NO; - } + NSUInteger selectedIndex = [tabBarController.viewControllers indexOfObject:viewController]; // If we're selecting a new tab... - if (newIndex != tabBarController.selectedIndex) { - switch (newIndex) { + if (selectedIndex != tabBarController.selectedIndex) { + switch (selectedIndex) { case WPTabMySites: { - [self bypassBlogListViewControllerIfNecessary]; break; } case WPTabReader: { [self alertQuickStartThatReaderWasTapped]; break; } + case WPTabNotifications: { + [self alertQuickStartThatNotificationsWasTapped]; + break; + } default: break; } - [self trackTabAccessForTabIndex:newIndex]; + [self trackTabAccessForTabIndex:selectedIndex]; [self alertQuickStartThatOtherTabWasTapped]; } else { // If the current view controller is selected already and it's at its root then scroll to the top @@ -847,46 +414,16 @@ - (BOOL)tabBarController:(UITabBarController *)tabBarController shouldSelectView return YES; } -- (void)tabBarController:(UITabBarController *)tabBarController didSelectViewController:(UIViewController *)viewController -{ - if (![Feature enabled:FeatureFlagMeMove]) { - [self updateMeNotificationIcon]; - } -} - -- (void)bypassBlogListViewControllerIfNecessary -{ - // If the user has one blog then we don't want to present them with the main "My Sites" - // screen where they can see all their blogs. In the case of only one blog just show - // the main blog details screen - UINavigationController *navController = (UINavigationController *)[self.blogListSplitViewController.viewControllers firstObject]; - BlogListViewController *blogListViewController = (BlogListViewController *)[navController.viewControllers firstObject]; - - if ([blogListViewController shouldBypassBlogListViewControllerWhenSelectedFromTabBar]) { - if ([navController.visibleViewController isKindOfClass:[blogListViewController class]]) { - [blogListViewController bypassBlogListViewController]; - } - } -} - - (void)showNotificationsTabForNoteWithID:(NSString *)notificationID { - [self showTabForIndex:WPTabNotifications]; + [self setSelectedIndex:WPTabNotifications]; [self.notificationsViewController showDetailsForNotificationWithID:notificationID]; } -- (BOOL)isNavigatingMySitesTab -{ - return (self.selectedIndex == WPTabMySites && [self.blogListViewController.navigationController.viewControllers count] > 1); -} - #pragma mark - Zendesk Notifications - (void)updateIconIndicators:(NSNotification *)notification { - if (![Feature enabled:FeatureFlagMeMove]) { - [self updateMeNotificationIcon]; - } [self updateNotificationBadgeVisibility]; } @@ -896,9 +433,10 @@ - (void)defaultAccountDidChange:(NSNotification *)notification { if (notification.object == nil) { [self.readerNavigationController popToRootViewControllerAnimated:NO]; - [self.meNavigationController popToRootViewControllerAnimated:NO]; [self.notificationsNavigationController popToRootViewControllerAnimated:NO]; } + + self.readerNavigationController = nil; } - (void)signinDidFinish:(NSNotification *)notification @@ -910,10 +448,17 @@ - (void)signinDidFinish:(NSNotification *)notification - (void)updateNotificationBadgeVisibility { + UITabBarItem *notificationsTabBarItem = self.notificationsNavigationController.tabBarItem; + + if (self.shouldUseStaticScreens) { + notificationsTabBarItem.image = self.notificationsTabBarImage; + notificationsTabBarItem.accessibilityLabel = NSLocalizedString(@"Notifications", @"Notifications tab bar item accessibility label"); + return; + } + // Discount Zendesk unread notifications when determining if we need to show the notificationsTabBarImageUnread. NSInteger count = [[UIApplication sharedApplication] applicationIconBadgeNumber] - [ZendeskUtils unreadNotificationsCount]; - UITabBarItem *notificationsTabBarItem = self.notificationsNavigationController.tabBarItem; - if (count > 0) { + if (count > 0 || ![self welcomeNotificationSeen]) { notificationsTabBarItem.image = self.notificationsTabBarImageUnread; notificationsTabBarItem.accessibilityLabel = NSLocalizedString(@"Notifications Unread", @"Notifications tab bar item accessibility label, unread notifications state"); } else { @@ -921,52 +466,40 @@ - (void)updateNotificationBadgeVisibility notificationsTabBarItem.accessibilityLabel = NSLocalizedString(@"Notifications", @"Notifications tab bar item accessibility label"); } - if( UIApplication.sharedApplication.isCreatingScreenshots ) { + if (UIApplication.sharedApplication.isCreatingScreenshots) { notificationsTabBarItem.image = self.notificationsTabBarImage; notificationsTabBarItem.accessibilityLabel = NSLocalizedString(@"Notifications", @"Notifications tab bar item accessibility label"); } + } -- (void) showReaderBadge:(NSNotification *)notification +- (BOOL)welcomeNotificationSeen { - UIImage *readerTabBarImage = [[UIImage imageNamed:@"icon-tab-reader-unread"] imageWithRenderingMode:UIImageRenderingModeAlwaysOriginal]; - self.readerNavigationController.tabBarItem.image = readerTabBarImage; - - if( UIApplication.sharedApplication.isCreatingScreenshots ) { - [self hideReaderBadge:nil]; - } + NSUserDefaults *standardUserDefaults = [UserPersistentStoreFactory userDefaultsInstance]; + NSString *welcomeNotificationSeenKey = @"welcomeNotificationSeen"; + return [standardUserDefaults boolForKey: welcomeNotificationSeenKey]; } -- (void) hideReaderBadge:(NSNotification *)notification -{ - UIImage *readerTabBarImage = [UIImage imageNamed:@"icon-tab-reader"]; - self.readerNavigationController.tabBarItem.image = readerTabBarImage; -} +#pragma mark - NSObject(NSKeyValueObserving) Helpers -- (void)updateMeNotificationIcon +- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { - UITabBarItem *meTabBarItem = self.tabBar.items[[self adjustedTabIndex:WPTabMe toTabType:true]]; - - if ([ZendeskUtils showSupportNotificationIndicator]) { - meTabBarItem.image = self.meTabBarImageUnreadUnselected; - meTabBarItem.selectedImage = self.meTabBarImageUnreadSelected; - } else { - meTabBarItem.image = self.meTabBarImage; - meTabBarItem.selectedImage = self.meTabBarImage; + if (object == self.tabBar && [keyPath isEqualToString:WPTabBarFrameKeyPath]) { + [self notifyOfTabBarHeightChangedIfNeeded]; } - if( UIApplication.sharedApplication.isCreatingScreenshots ) { - meTabBarItem.image = self.meTabBarImage; - meTabBarItem.selectedImage = self.meTabBarImage; + if (object == [UIApplication sharedApplication] && [keyPath isEqualToString:WPApplicationIconBadgeNumberKeyPath]) { + [self updateNotificationBadgeVisibility]; } } -#pragma mark - NSObject(NSKeyValueObserving) Helpers - -- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context +- (void)notifyOfTabBarHeightChangedIfNeeded { - if ([keyPath isEqualToString:WPApplicationIconBadgeNumberKeyPath]) { - [self updateNotificationBadgeVisibility]; + CGFloat newTabBarHeight = self.tabBar.frame.size.height; + if (newTabBarHeight != self.tabBarHeight) { + self.tabBarHeight = newTabBarHeight; + [[NSNotificationCenter defaultCenter] postNotificationName:WPTabBarHeightChangedNotification + object:nil]; } } @@ -982,14 +515,17 @@ - (BOOL)canBecomeFirstResponder return nil; } - return @[ - [UIKeyCommand keyCommandWithInput:@"N" modifierFlags:UIKeyModifierCommand action:@selector(showPostTab) discoverabilityTitle:NSLocalizedString(@"New Post", @"The accessibility value of the post tab.")], - [UIKeyCommand keyCommandWithInput:@"1" modifierFlags:UIKeyModifierCommand action:@selector(showMySitesTab) discoverabilityTitle:NSLocalizedString(@"My Sites", @"The accessibility value of the my sites tab.")], - [UIKeyCommand keyCommandWithInput:@"2" modifierFlags:UIKeyModifierCommand action:@selector(showReaderTab) discoverabilityTitle:NSLocalizedString(@"Reader", @"The accessibility value of the reader tab.")], - // will be removed when the new IA implementation completes - [UIKeyCommand keyCommandWithInput:@"3" modifierFlags:UIKeyModifierCommand action:@selector(showMeTab) discoverabilityTitle:NSLocalizedString(@"Me", @"The accessibility value of the me tab.")], - [UIKeyCommand keyCommandWithInput:@"4" modifierFlags:UIKeyModifierCommand action:@selector(showNotificationsTab) discoverabilityTitle:NSLocalizedString(@"Notifications", @"Notifications tab bar item accessibility label")], - ]; + UIKeyCommand *showMySitesTabCommand = [UIKeyCommand keyCommandWithInput:@"1" modifierFlags:UIKeyModifierCommand action:@selector(showMySitesTab)]; + showMySitesTabCommand.discoverabilityTitle = NSLocalizedString(@"My Site", @"The accessibility value of the my site tab."); + + UIKeyCommand *showReaderTabCommand = [UIKeyCommand keyCommandWithInput:@"2" modifierFlags:UIKeyModifierCommand action:@selector(showReaderTab)]; + showMySitesTabCommand.discoverabilityTitle = NSLocalizedString(@"Reader", @"The accessibility value of the reader tab."); + + UIKeyCommand *showNotificationsTabCommand = [UIKeyCommand keyCommandWithInput:@"4" modifierFlags:UIKeyModifierCommand action:@selector(showNotificationsTab)]; + showMySitesTabCommand.discoverabilityTitle = NSLocalizedString(@"Notifications", @"Notifications tab bar item accessibility label"); + + + return @[showMySitesTabCommand, showReaderTabCommand, showNotificationsTabCommand]; } #pragma mark - Handling Layout @@ -1013,20 +549,6 @@ - (void)viewDidLayoutSubviews [super viewDidLayoutSubviews]; } -- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection -{ - [super traitCollectionDidChange:previousTraitCollection]; - - [self updateWriteButtonAppearance]; - - [self.createButtonCoordinator presentingTraitCollectionDidChange:previousTraitCollection]; -} - -- (void)willTransitionToTraitCollection:(UITraitCollection *)newCollection withTransitionCoordinator:(id)coordinator -{ - [super willTransitionToTraitCollection:newCollection withTransitionCoordinator:coordinator]; -} - #pragma mark - UIViewControllerTransitioningDelegate - (UIPresentationController *)presentationControllerForPresentedViewController:(UIViewController *)presented presentingViewController:(UIViewController *)presenting sourceViewController:(UIViewController *)source @@ -1038,4 +560,5 @@ - (UIPresentationController *)presentationControllerForPresentedViewController:( return nil; } + @end diff --git a/WordPress/Classes/ViewRelated/Themes/ThemeBrowserCell.swift b/WordPress/Classes/ViewRelated/Themes/ThemeBrowserCell.swift index 195f7e2583b7..90cd253a9fe8 100644 --- a/WordPress/Classes/ViewRelated/Themes/ThemeBrowserCell.swift +++ b/WordPress/Classes/ViewRelated/Themes/ThemeBrowserCell.swift @@ -6,6 +6,7 @@ import CocoaLumberjack /// public enum ThemeAction { case activate + case active case customize case details case support @@ -36,6 +37,8 @@ public enum ThemeAction { switch self { case .activate: return NSLocalizedString("Activate", comment: "Theme Activate action title") + case .active: + return NSLocalizedString("Active", comment: "Label for active Theme") case .customize: return NSLocalizedString("Customize", comment: "Theme Customize action title") case .details: @@ -53,7 +56,7 @@ public enum ThemeAction { switch self { case .activate: presenter.activateTheme(theme) - case .customize: + case .customize, .active: presenter.presentCustomizeForTheme(theme) case .details: presenter.presentDetailsForTheme(theme) @@ -190,11 +193,38 @@ open class ThemeBrowserCell: UICollectionViewCell { activityView.stopAnimating() } + private func imageUrlForWidth(imageUrl: String) -> String { + var screenshotUrl = imageUrl + // Themes not hosted on WP.com have an incorrect screenshotUrl + // it uses a // url (this is used on the web) when the scheme is not known. + if !screenshotUrl.hasPrefix("http") && screenshotUrl.hasPrefix("//") { + // Since not all sites support https + screenshotUrl = String(format: "http:%@", imageUrl) + } + + guard var components = URLComponents(string: screenshotUrl) else { + return screenshotUrl + } + + var queryItems: [URLQueryItem] = components.queryItems ?? [] + + if let screenshotWidth = presenter?.screenshotWidth { + queryItems.append(URLQueryItem(name: "w", value: "\(screenshotWidth)")) + } + + queryItems.append(URLQueryItem(name: "zoom", value: "\(UIScreen.main.scale)")) + components.queryItems = queryItems + + guard let urlString = components.url?.absoluteString else { + return screenshotUrl + } + + return urlString + } + fileprivate func refreshScreenshotImage(_ imageUrl: String) { - // Themes not hosted on WP.com have an incorrect screenshotUrl and do not correctly support the w param - let imageUrlForWidth = imageUrl.hasPrefix("http") ? imageUrl + "?w=\(presenter!.screenshotWidth)" : - String(format: "http:%@", imageUrl) - let screenshotUrl = URL(string: imageUrlForWidth) + let imageUrlWithWidth = imageUrlForWidth( imageUrl: imageUrl ) + let screenshotUrl = URL(string: imageUrlWithWidth) imageView.backgroundColor = Styles.placeholderColor activityView.startAnimating() diff --git a/WordPress/Classes/ViewRelated/Themes/ThemeBrowserHeaderView.swift b/WordPress/Classes/ViewRelated/Themes/ThemeBrowserHeaderView.swift index 79b5404b0932..6abccead1d9e 100644 --- a/WordPress/Classes/ViewRelated/Themes/ThemeBrowserHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Themes/ThemeBrowserHeaderView.swift @@ -107,11 +107,8 @@ open class ThemeBrowserHeaderView: UICollectionReusableView { } private func spotlightCustomizeButtonIfTourIsActive() { - guard let tourGuide = QuickStartTourGuide.find() else { - return - } - if tourGuide.isCurrentElement(.customize) { + if QuickStartTourGuide.shared.isCurrentElement(.customize) { customizeButton.addSubview(quickStartSpotlightView) quickStartSpotlightView.translatesAutoresizingMaskIntoConstraints = false addConstraints([ diff --git a/WordPress/Classes/ViewRelated/Themes/ThemeBrowserViewController+JetpackBannerViewController.swift b/WordPress/Classes/ViewRelated/Themes/ThemeBrowserViewController+JetpackBannerViewController.swift new file mode 100644 index 000000000000..38851639c76b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Themes/ThemeBrowserViewController+JetpackBannerViewController.swift @@ -0,0 +1,18 @@ +import Foundation + +extension ThemeBrowserViewController { + @objc + func withJPBanner() -> UIViewController { + navigationItem.largeTitleDisplayMode = .never + guard JetpackBrandingCoordinator.shouldShowBannerForJetpackDependentFeatures() else { + return self + } + return JetpackBannerWrapperViewController(childVC: self, screen: .themes) + } + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + if let jetpackBannerWrapper = parent as? JetpackBannerWrapperViewController { + jetpackBannerWrapper.processJetpackBannerVisibility(scrollView) + } + } +} diff --git a/WordPress/Classes/ViewRelated/Themes/ThemeBrowserViewController.swift b/WordPress/Classes/ViewRelated/Themes/ThemeBrowserViewController.swift index 270fcc6365d1..e5361c9f3e3b 100644 --- a/WordPress/Classes/ViewRelated/Themes/ThemeBrowserViewController.swift +++ b/WordPress/Classes/ViewRelated/Themes/ThemeBrowserViewController.swift @@ -43,7 +43,7 @@ public enum ThemeType { * @brief Publicly exposed theme interaction support * @details Held as weak reference by owned subviews */ -public protocol ThemePresenter: class { +public protocol ThemePresenter: AnyObject { var filterType: ThemeType { get set } var screenshotWidth: Int { get } @@ -84,6 +84,7 @@ public protocol ThemePresenter: class { @objc static let reuseIdentifierForThemesHeader = "ThemeBrowserSectionHeaderViewThemes" @objc static let reuseIdentifierForCustomThemesHeader = "ThemeBrowserSectionHeaderViewCustomThemes" + static let themesLoaderFrame = CGRect(x: 0.0, y: 0.0, width: 40.0, height: 20.0) // MARK: - Properties: must be set by parent @@ -117,8 +118,9 @@ public protocol ThemePresenter: class { fetchRequest.fetchBatchSize = 20 let sort = NSSortDescriptor(key: "order", ascending: true) fetchRequest.sortDescriptors = [sort] - let frc = NSFetchedResultsController(fetchRequest: fetchRequest, - managedObjectContext: self.themeService.managedObjectContext, + let frc = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: self.themeService.coreDataStack.mainContext, sectionNameKeyPath: nil, cacheName: nil) frc.delegate = self @@ -183,6 +185,15 @@ public protocol ThemePresenter: class { return !suspendedSearch.trim().isEmpty } + fileprivate var activityIndicator: UIActivityIndicatorView = { + let indicatorView = UIActivityIndicatorView(style: .medium) + indicatorView.frame = themesLoaderFrame + //TODO update color with white headers + indicatorView.color = .white + indicatorView.startAnimating() + return indicatorView + }() + open var filterType: ThemeType = ThemeType.mayPurchase ? .all : .free /** @@ -211,6 +222,18 @@ public protocol ThemePresenter: class { return nil } + fileprivate func updateActivateButton(isLoading: Bool) { + if isLoading { + activateButton?.customView = activityIndicator + activityIndicator.startAnimating() + } else { + activityIndicator.stopAnimating() + activateButton?.customView = nil + activateButton?.isEnabled = false + activateButton?.title = ThemeAction.active.title + } + } + fileprivate var presentingTheme: Theme? private var noResultsViewController: NoResultsViewController? @@ -228,7 +251,7 @@ public protocol ThemePresenter: class { * @brief Load theme screenshots at maximum displayed width */ @objc open var screenshotWidth: Int = { - let windowSize = UIApplication.shared.keyWindow!.bounds.size + let windowSize = UIApplication.shared.mainWindow!.bounds.size let vWidth = Styles.imageWidthForFrameWidth(windowSize.width) let hWidth = Styles.imageWidthForFrameWidth(windowSize.height) let maxWidth = Int(max(hWidth, vWidth)) @@ -238,11 +261,12 @@ public protocol ThemePresenter: class { /** * @brief The themes service we'll use in this VC and its helpers */ - fileprivate let themeService = ThemeService(managedObjectContext: ContextManager.sharedInstance().mainContext) + fileprivate let themeService = ThemeService(coreDataStack: ContextManager.sharedInstance()) fileprivate var themesSyncHelper: WPContentSyncHelper! fileprivate var themesSyncingPage = 0 fileprivate var customThemesSyncHelper: WPContentSyncHelper! fileprivate let syncPadding = 5 + fileprivate var activateButton: UIBarButtonItem? // MARK: - Private Aliases @@ -267,6 +291,7 @@ public protocol ThemePresenter: class { open override func viewDidLoad() { super.viewDidLoad() + collectionView.delegate = self title = NSLocalizedString("Themes", comment: "Title of Themes browser page") @@ -291,7 +316,7 @@ public protocol ThemePresenter: class { definesPresentationContext = true searchController = UISearchController(searchResultsController: nil) - searchController.dimsBackgroundDuringPresentation = false + searchController.obscuresBackgroundDuringPresentation = false searchController.delegate = self searchController.searchResultsUpdater = self @@ -352,10 +377,6 @@ public protocol ThemePresenter: class { unregisterForKeyboardNotifications() } - open override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent - } - fileprivate func registerForKeyboardNotifications() { NotificationCenter.default.addObserver(self, selector: #selector(ThemeBrowserViewController.keyboardDidShow(_:)), @@ -381,8 +402,8 @@ public protocol ThemePresenter: class { let keyboardHeight = collectionView.frame.maxY - keyboardFrame.origin.y collectionView.contentInset.bottom = keyboardHeight - collectionView.scrollIndicatorInsets.top = searchBarHeight - collectionView.scrollIndicatorInsets.bottom = keyboardHeight + collectionView.verticalScrollIndicatorInsets.top = searchBarHeight + collectionView.verticalScrollIndicatorInsets.bottom = keyboardHeight } @objc open func keyboardWillHide(_ notification: Foundation.Notification) { @@ -390,8 +411,8 @@ public protocol ThemePresenter: class { collectionView.contentInset.top = view.safeAreaInsets.top collectionView.contentInset.bottom = tabBarHeight - collectionView.scrollIndicatorInsets.top = searchBarHeight - collectionView.scrollIndicatorInsets.bottom = tabBarHeight + collectionView.verticalScrollIndicatorInsets.top = searchBarHeight + collectionView.verticalScrollIndicatorInsets.bottom = tabBarHeight } fileprivate func localKeyboardFrameFromNotification(_ notification: Foundation.Notification) -> CGRect { @@ -689,7 +710,7 @@ public protocol ThemePresenter: class { if sections[1] == .themes || sections[1] == .customThemes { setInfoSectionHidden(false) } - collectionView.scrollIndicatorInsets.top = view.safeAreaInsets.top + collectionView.verticalScrollIndicatorInsets.top = view.safeAreaInsets.top } fileprivate func setInfoSectionHidden(_ hidden: Bool) { @@ -764,12 +785,16 @@ public protocol ThemePresenter: class { } // MARK: - ThemePresenter + // optional closure that will be executed when the presented WebkitViewController closes + @objc var onWebkitViewControllerClose: (() -> Void)? @objc open func activateTheme(_ theme: Theme?) { guard let theme = theme, !theme.isCurrentTheme() else { return } + updateActivateButton(isLoading: true) + _ = themeService.activate(theme, for: blog, success: { [weak self] (theme: Theme?) in @@ -782,6 +807,9 @@ public protocol ThemePresenter: class { let successMessage = String(format: successFormat, theme?.name ?? "", theme?.author ?? "") let manageTitle = NSLocalizedString("Manage site", comment: "Return to blog screen action when theme activation succeeds") let okTitle = NSLocalizedString("OK", comment: "Alert dismissal title") + + self?.updateActivateButton(isLoading: false) + let alertController = UIAlertController(title: successTitle, message: successMessage, preferredStyle: .alert) @@ -793,11 +821,15 @@ public protocol ThemePresenter: class { alertController.addDefaultActionWithTitle(okTitle, handler: nil) alertController.presentFromRootViewController() }, - failure: { (error) in + failure: { [weak self] (error) in DDLogError("Error activating theme \(String(describing: theme.themeId)): \(String(describing: error?.localizedDescription))") let errorTitle = NSLocalizedString("Activation Error", comment: "Title of alert when theme activation fails") let okTitle = NSLocalizedString("OK", comment: "Alert dismissal title") + + self?.activityIndicator.stopAnimating() + self?.activateButton?.customView = nil + let alertController = UIAlertController(title: errorTitle, message: error?.localizedDescription, preferredStyle: .alert) @@ -810,13 +842,13 @@ public protocol ThemePresenter: class { _ = themeService.installTheme(theme, for: blog, success: { [weak self] in - self?.presentUrlForTheme(theme, url: theme.customizeUrl(), activeButton: false) + self?.presentUrlForTheme(theme, url: theme.customizeUrl(), activeButton: !theme.isCurrentTheme()) }, failure: nil) } @objc open func presentCustomizeForTheme(_ theme: Theme?) { WPAppAnalytics.track(.themesCustomizeAccessed, with: self.blog) - QuickStartTourGuide.find()?.visited(.customize) + QuickStartTourGuide.shared.visited(.customize) presentUrlForTheme(theme, url: theme?.customizeUrl(), activeButton: false, modalStyle: .fullScreen) } @@ -827,7 +859,7 @@ public protocol ThemePresenter: class { if let theme = theme, self.blog.supports(.customThemes) && !theme.custom { installThemeAndPresentCustomizer(theme) } else { - presentUrlForTheme(theme, url: theme?.customizeUrl(), activeButton: false) + presentUrlForTheme(theme, url: theme?.customizeUrl(), activeButton: !(theme?.isCurrentTheme() ?? true)) } } @@ -843,10 +875,10 @@ public protocol ThemePresenter: class { @objc open func presentViewForTheme(_ theme: Theme?) { WPAppAnalytics.track(.themesDemoAccessed, with: self.blog) - presentUrlForTheme(theme, url: theme?.viewUrl()) + presentUrlForTheme(theme, url: theme?.viewUrl(), onClose: onWebkitViewControllerClose) } - @objc open func presentUrlForTheme(_ theme: Theme?, url: String?, activeButton: Bool = true, modalStyle: UIModalPresentationStyle = .pageSheet) { + @objc open func presentUrlForTheme(_ theme: Theme?, url: String?, activeButton: Bool = true, modalStyle: UIModalPresentationStyle = .pageSheet, onClose: (() -> Void)? = nil) { guard let theme = theme, let url = url.flatMap(URL.init(string:)) else { return } @@ -857,15 +889,15 @@ public protocol ThemePresenter: class { configuration.authenticate(blog: theme.blog) configuration.secureInteraction = true configuration.customTitle = theme.name - configuration.addsHideMasterbarParameters = false configuration.navigationDelegate = customizerNavigationDelegate - let webViewController = WebViewControllerFactory.controller(configuration: configuration) - var buttons: [UIBarButtonItem]? - if activeButton && !theme.isCurrentTheme() { - let activate = UIBarButtonItem(title: ThemeAction.activate.title, style: .plain, target: self, action: #selector(ThemeBrowserViewController.activatePresentingTheme)) - buttons = [activate] - } - webViewController.navigationItem.rightBarButtonItems = buttons + configuration.onClose = onClose + + let title = activeButton ? ThemeAction.activate.title : ThemeAction.active.title + activateButton = UIBarButtonItem(title: title, style: .plain, target: self, action: #selector(ThemeBrowserViewController.activatePresentingTheme)) + activateButton?.isEnabled = !theme.isCurrentTheme() + + let webViewController = WebViewControllerFactory.controller(configuration: configuration, source: "theme_browser") + webViewController.navigationItem.rightBarButtonItem = activateButton let navigation = UINavigationController(rootViewController: webViewController) navigation.modalPresentationStyle = modalStyle @@ -880,7 +912,6 @@ public protocol ThemePresenter: class { @objc open func activatePresentingTheme() { suspendedSearch = "" - _ = navigationController?.popViewController(animated: true) activateTheme(presentingTheme) presentingTheme = nil } diff --git a/WordPress/Classes/ViewRelated/Tools/ScenePresenter.swift b/WordPress/Classes/ViewRelated/Tools/ScenePresenter.swift index ccbde3aaddc4..def38c14adca 100644 --- a/WordPress/Classes/ViewRelated/Tools/ScenePresenter.swift +++ b/WordPress/Classes/ViewRelated/Tools/ScenePresenter.swift @@ -10,3 +10,8 @@ protocol ScenePresenter { /// Presents the scene on the given UIViewController @objc func present(on viewController: UIViewController, animated: Bool, completion: (() -> Void)?) } + +@objc +protocol ScenePresenterDelegate { + @objc func didDismiss(presenter: ScenePresenter) +} diff --git a/WordPress/Classes/ViewRelated/Tools/SettingsCommon.swift b/WordPress/Classes/ViewRelated/Tools/SettingsCommon.swift index 773b1df955b0..a1dd4c44fb9c 100644 --- a/WordPress/Classes/ViewRelated/Tools/SettingsCommon.swift +++ b/WordPress/Classes/ViewRelated/Tools/SettingsCommon.swift @@ -2,7 +2,9 @@ import UIKit import WordPressKit import CocoaLumberjack -protocol SettingsController: ImmuTableController {} +protocol SettingsController: ImmuTableController { + var trackingKey: String { get } +} // MARK: - Actions extension SettingsController { @@ -55,6 +57,8 @@ extension SettingsController { let change = changeType(value) service.saveChange(change) DDLogDebug("\(title) changed: \(value)") + + trackChangeIfNeeded(row) } return controller @@ -76,8 +80,19 @@ extension SettingsController { let change = changeType(value) service.saveChange(change) DDLogDebug("\(title) changed: \(value)") + + trackChangeIfNeeded(row) } return controller } + + private func trackChangeIfNeeded(_ row: EditableTextRow) { + // Don't track if the field name isn't specified + guard let fieldName = row.fieldName else { + return + } + + WPAnalytics.trackSettingsChange(trackingKey, fieldName: fieldName) + } } diff --git a/WordPress/Classes/ViewRelated/Tools/SettingsListEditorViewController.swift b/WordPress/Classes/ViewRelated/Tools/SettingsListEditorViewController.swift index 7d10507b9be5..702edfab96d2 100644 --- a/WordPress/Classes/ViewRelated/Tools/SettingsListEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/Tools/SettingsListEditorViewController.swift @@ -17,7 +17,7 @@ open class SettingsListEditorViewController: UITableViewController { // MARK: - Initialiers @objc public convenience init(collection: Set?) { - self.init(style: .grouped) + self.init(style: .insetGrouped) emptyText = NSLocalizedString("No Items", comment: "List Editor Empty State Message") diff --git a/WordPress/Classes/ViewRelated/Tools/SettingsMultiTextViewController.h b/WordPress/Classes/ViewRelated/Tools/SettingsMultiTextViewController.h index 26c960f99efe..07bd588d5574 100644 --- a/WordPress/Classes/ViewRelated/Tools/SettingsMultiTextViewController.h +++ b/WordPress/Classes/ViewRelated/Tools/SettingsMultiTextViewController.h @@ -28,6 +28,12 @@ typedef void (^SettingsMultiTextChanged)(NSString * _Nonnull); /// Autocapitalization type used in the textfield, defaults to UITextAutocapitalizationTypeSentences @property (nonatomic, assign) UITextAutocapitalizationType autocapitalizationType; +/// The maximum characters to allow. +/// If > 0, characters will be limited to that value. +/// If <= 0, it will be ignored. +/// +@property (nonatomic, assign) NSInteger maxCharacterCount; + /// Block to be executed on dismiss, if the value was effectively updated. /// @property (nullable, nonatomic, copy) SettingsMultiTextChanged onValueChanged; diff --git a/WordPress/Classes/ViewRelated/Tools/SettingsMultiTextViewController.m b/WordPress/Classes/ViewRelated/Tools/SettingsMultiTextViewController.m index 7924bcc48f82..26b5b84389d3 100644 --- a/WordPress/Classes/ViewRelated/Tools/SettingsMultiTextViewController.m +++ b/WordPress/Classes/ViewRelated/Tools/SettingsMultiTextViewController.m @@ -20,7 +20,7 @@ - (instancetype)initWithText:(NSString *)text hint:(NSString *)hint isPassword:(BOOL)isPassword { - self = [super initWithStyle:UITableViewStyleGrouped]; + self = [super initWithStyle:UITableViewStyleInsetGrouped]; if (self) { _text = text; _placeholder = placeholder; @@ -160,4 +160,26 @@ - (void)adjustCellSize [self.tableView endUpdates]; } +#pragma mark - UITextViewDelegate + +- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text +{ + + // If character length is unrestricted, allow all text. + if (self.maxCharacterCount <= 0) { + return YES; + } + + NSString *newText = [textView.text stringByReplacingCharactersInRange: range withString: text]; + + // If the entire new text will fit, allow it. + if (newText.length <= self.maxCharacterCount) { + return YES; + } + + // Otherwise use only the number of characters from the new text that will fit. + textView.text = [newText substringToIndex: self.maxCharacterCount]; + return NO; +} + @end diff --git a/WordPress/Classes/ViewRelated/Tools/SettingsPickerViewController.swift b/WordPress/Classes/ViewRelated/Tools/SettingsPickerViewController.swift index 1a387a6300a1..da9ce4d73430 100644 --- a/WordPress/Classes/ViewRelated/Tools/SettingsPickerViewController.swift +++ b/WordPress/Classes/ViewRelated/Tools/SettingsPickerViewController.swift @@ -40,7 +40,7 @@ open class SettingsPickerViewController: UITableViewController { open var pickerMaximumValue: Int! /// Closure to be executed whenever the Switch / Picker is updated - @objc open var onChange : ((_ enabled: Bool, _ newValue: Int) -> ())? + @objc open var onChange: ((_ enabled: Bool, _ newValue: Int) -> ())? diff --git a/WordPress/Classes/ViewRelated/Tools/SettingsSelectionViewController.m b/WordPress/Classes/ViewRelated/Tools/SettingsSelectionViewController.m index 1073a31ab9f0..7b3e078a71e8 100644 --- a/WordPress/Classes/ViewRelated/Tools/SettingsSelectionViewController.m +++ b/WordPress/Classes/ViewRelated/Tools/SettingsSelectionViewController.m @@ -41,7 +41,7 @@ - (instancetype)initWithStyle:(UITableViewStyle)style { - (instancetype)initWithDictionary:(NSDictionary *)dictionary { - return [self initWithStyle:UITableViewStyleGrouped andDictionary:dictionary]; + return [self initWithStyle:UITableViewStyleInsetGrouped andDictionary:dictionary]; } - (instancetype)initWithStyle:(UITableViewStyle)style andDictionary:(NSDictionary *)dictionary diff --git a/WordPress/Classes/ViewRelated/Tools/SettingsTextViewController.h b/WordPress/Classes/ViewRelated/Tools/SettingsTextViewController.h index 03e10d4e056a..5f8f0e8dcc27 100644 --- a/WordPress/Classes/ViewRelated/Tools/SettingsTextViewController.h +++ b/WordPress/Classes/ViewRelated/Tools/SettingsTextViewController.h @@ -14,6 +14,7 @@ typedef NS_ENUM(NSInteger, SettingsTextModes) { typedef void (^SettingsTextAction)(void); typedef void (^SettingsTextChanged)(NSString * _Nonnull); typedef void (^SettingsAttributedTextChanged)(NSAttributedString * _Nonnull); +typedef void (^SettingsTextOnDismiss)(void); /// Reusable component that renders a UITextField + Hint onscreen. Useful for Text / Password / Email data entry. /// @@ -30,6 +31,9 @@ typedef void (^SettingsAttributedTextChanged)(NSAttributedString * _Nonnull); /// Block to be executed whenever the Action, if visible, is pressed. /// @property (nullable, nonatomic, copy) SettingsTextAction onActionPress; +/// Optional block to be executed when the viewController is dismissed. +/// +@property (nullable, nonatomic, copy) SettingsTextOnDismiss onDismiss; /// String to be displayed at the bottom. /// @@ -77,6 +81,10 @@ typedef void (^SettingsAttributedTextChanged)(NSAttributedString * _Nonnull); /// @property (nonatomic, assign) SettingsTextModes mode; +/// If YES, Cancel and Save buttons will be added to the navigation bar. +/// +@property (nonatomic, assign) BOOL displaysNavigationButtons; + /// Required initializer. /// /// Parameters: diff --git a/WordPress/Classes/ViewRelated/Tools/SettingsTextViewController.m b/WordPress/Classes/ViewRelated/Tools/SettingsTextViewController.m index a84d2b2be25c..b0193746184f 100644 --- a/WordPress/Classes/ViewRelated/Tools/SettingsTextViewController.m +++ b/WordPress/Classes/ViewRelated/Tools/SettingsTextViewController.m @@ -19,7 +19,7 @@ typedef NS_ENUM(NSInteger, SettingsTextSections) { #pragma mark - Private Properties @interface SettingsTextViewController() -@property (nonatomic, strong) NoticeAnimator *noticeAnimator; +@property (nonatomic, strong) MessageAnimator *messageAnimator; @property (nonatomic, strong) WPTableViewCell *textFieldCell; @property (nonatomic, strong) WPTableViewCell *actionCell; @property (nonatomic, strong) UITextField *textField; @@ -47,7 +47,7 @@ - (instancetype)initWithStyle:(UITableViewStyle)style - (instancetype)initWithText:(NSString *)text placeholder:(NSString *)placeholder hint:(NSString *)hint { - self = [super initWithStyle:UITableViewStyleGrouped]; + self = [super initWithStyle:UITableViewStyleInsetGrouped]; if (self) { [self commonInitWithPlaceholder:placeholder hint:hint]; @@ -60,7 +60,7 @@ - (instancetype)initWithText:(NSString *)text placeholder:(NSString *)placeholde - (instancetype)initWithAttributedText:(NSAttributedString *)text defaultAttributes:(NSDictionary *)defaultAttributes placeholder:(NSString *)placeholder hint:(NSString *)hint { - self = [super initWithStyle:UITableViewStyleGrouped]; + self = [super initWithStyle:UITableViewStyleInsetGrouped]; if (self) { [self commonInitWithPlaceholder:placeholder hint:hint]; @@ -116,7 +116,7 @@ - (void)viewDidLoad - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; - [self setupNoticeAnimatorIfNeeded]; + [self setupMessageAnimatorIfNeeded]; } - (void)viewDidAppear:(BOOL)animated @@ -138,12 +138,23 @@ - (void)viewWillDisappear:(BOOL)animated - (void)viewDidLayoutSubviews { [super viewDidLayoutSubviews]; - [self.noticeAnimator layout]; + [self.messageAnimator layout]; } #pragma mark - NavigationItem Buttons +- (void)setDisplaysNavigationButtons:(BOOL)displaysNavigationButtons +{ + if (displaysNavigationButtons) { + self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel target:self action:@selector(cancel)]; + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemSave target:self action:@selector(confirm)]; + } else { + self.navigationItem.leftBarButtonItem = nil; + self.navigationItem.rightBarButtonItem = nil; + } +} + - (void)cancel { self.shouldNotifyValue = NO; @@ -155,14 +166,14 @@ - (void)confirm [self dismissViewController]; } -- (void)setupNoticeAnimatorIfNeeded +- (void)setupMessageAnimatorIfNeeded { if (self.notice == nil) { return; } - self.noticeAnimator = [[NoticeAnimator alloc] initWithTarget:self.view]; - [self.noticeAnimator animateMessage:self.notice]; + self.messageAnimator = [[MessageAnimator alloc] initWithTarget:self.view]; + [self.messageAnimator animateMessage:self.notice]; } @@ -180,12 +191,13 @@ - (void)startListeningTextfieldChanges - (BOOL)textPassesValidation { BOOL isEmail = (self.mode == SettingsTextModesEmail); - return (self.validatesInput == false || isEmail == false || (isEmail && self.textField.text.isValidEmail)); + return ([self.textField hasText] && (self.validatesInput == false || isEmail == false || (isEmail && self.textField.text.isValidEmail))); } - (void)validateTextInput:(id)sender { self.doneButtonEnabled = [self textPassesValidation]; + [self setEnabledStateForCell:_actionCell value:self.doneButtonEnabled]; } @@ -229,7 +241,8 @@ - (WPTableViewCell *)actionCell _actionCell.textLabel.textAlignment = NSTextAlignmentCenter; [WPStyleGuide configureTableViewActionCell:_actionCell]; - + [self setEnabledStateForCell:_actionCell value:self.doneButtonEnabled]; + return _actionCell; } @@ -331,7 +344,7 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath - (void)dismissViewController { if (self.isModal) { - [self dismissViewControllerAnimated:YES completion:nil]; + [self dismissViewControllerAnimated:YES completion:self.onDismiss]; } else { [self.navigationController popViewControllerAnimated:YES]; } @@ -362,11 +375,9 @@ - (void)updateModeSettings:(SettingsTextModes)newMode requiresSecureTextEntry = YES; } else if (newMode == SettingsTextModesNewPassword) { requiresSecureTextEntry = YES; - if (@available(iOS 12.0, *)) { - NSString *passwordDescriptor = @"required: lower; required: upper; required: digit; required: [&)*]]; minlength: 6; maxlength: 24;"; - self.textField.passwordRules = [UITextInputPasswordRules passwordRulesWithDescriptor:passwordDescriptor]; - self.textField.textContentType = UITextContentTypeNewPassword; - } + NSString *passwordDescriptor = @"required: lower; required: upper; required: digit; required: [&)*]]; minlength: 6; maxlength: 24;"; + self.textField.passwordRules = [UITextInputPasswordRules passwordRulesWithDescriptor:passwordDescriptor]; + self.textField.textContentType = UITextContentTypeNewPassword; } else if (newMode == SettingsTextModesEmail) { keyboardType = UIKeyboardTypeEmailAddress; autocapitalizationType = UITextAutocapitalizationTypeNone; @@ -384,6 +395,14 @@ - (void)updateModeSettings:(SettingsTextModes)newMode self.textField.autocorrectionType = autocorrectionType; } +- (void)setEnabledStateForCell:(UITableViewCell *)ActionCell value:(BOOL)value +{ + if (value) { + [_actionCell enable]; + } else { + [_actionCell disable]; + } +} #pragma mark - UITextFieldDelegate Methods diff --git a/WordPress/Classes/ViewRelated/Tools/TableViewKeyboardObserver.swift b/WordPress/Classes/ViewRelated/Tools/TableViewKeyboardObserver.swift index ef6d6606505e..73dde44dfa5a 100644 --- a/WordPress/Classes/ViewRelated/Tools/TableViewKeyboardObserver.swift +++ b/WordPress/Classes/ViewRelated/Tools/TableViewKeyboardObserver.swift @@ -28,7 +28,7 @@ class TableViewKeyboardObserver: NSObject { } var inset = originalInset - if UIApplication.shared.statusBarOrientation.isPortrait { + if tableView?.window?.windowScene?.interfaceOrientation.isPortrait == true { inset.bottom += keyboardFrame.height } else { inset.bottom += keyboardFrame.width diff --git a/WordPress/Classes/ViewRelated/Tools/Time Zone/TimeZoneFormatter.swift b/WordPress/Classes/ViewRelated/Tools/Time Zone/TimeZoneFormatter.swift new file mode 100644 index 000000000000..3a037a5a730a --- /dev/null +++ b/WordPress/Classes/ViewRelated/Tools/Time Zone/TimeZoneFormatter.swift @@ -0,0 +1,55 @@ +import Foundation + +struct TimeZoneFormatter { + + private let timeZoneOffsetFormatter = DateFormatter() + + private let timeAtTimeZoneFormatter = DateFormatter() + + private let date: Date + + init(currentDate: Date) { + date = currentDate + configureDateFormatter(timeZoneOffsetFormatter, timeStyle: .none, dateFormat: Constants.timeZoneOffsetFormat) + configureDateFormatter(timeAtTimeZoneFormatter, timeStyle: .short, dateFormat: nil) + } + + private func configureDateFormatter(_ formatter: DateFormatter, timeStyle: DateFormatter.Style, dateFormat: String?) { + formatter.locale = Locale.autoupdatingCurrent + formatter.timeStyle = timeStyle + + if let dateFormat = dateFormat { + formatter.dateFormat = dateFormat + } + } + + func getZoneOffset(_ zone: WPTimeZone) -> String { + guard let namedTimeZone = zone as? NamedTimeZone, + let timeZone = TimeZone(identifier: namedTimeZone.value), + let timeZoneLocalized = timeZone.localizedName(for: .standard, locale: .current) else { + return "" + } + timeZoneOffsetFormatter.timeZone = timeZone + + let offset = timeZoneOffsetFormatter.string(from: date) + return "\(timeZoneLocalized) (\(offset))" + } + + func getTimeAtZone(_ zone: WPTimeZone) -> String { + guard let namedTimeZone = zone as? NamedTimeZone, + let timeZone = TimeZone(identifier: namedTimeZone.value) else { + return "" + } + timeAtTimeZoneFormatter.timeZone = timeZone + + return timeAtTimeZoneFormatter.string(from: date) + } +} + +private extension TimeZoneFormatter { + + enum Constants { + static let timeZoneOffsetFormat = "ZZZZ" + } + +} diff --git a/WordPress/Classes/ViewRelated/Tools/Time Zone/TimeZoneSelectorViewController.swift b/WordPress/Classes/ViewRelated/Tools/Time Zone/TimeZoneSelectorViewController.swift new file mode 100644 index 000000000000..5e17d33760cc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Tools/Time Zone/TimeZoneSelectorViewController.swift @@ -0,0 +1,173 @@ +import UIKit +import WordPressFlux + +class TimeZoneSelectorViewController: UITableViewController, UISearchResultsUpdating { + var storeReceipt: Receipt? + var queryReceipt: Receipt? + + var onSelectionChanged: ((WPTimeZone) -> Void) + var viewModel: TimeZoneSelectorViewModel { + didSet { + handler.viewModel = viewModel.tableViewModel(selectionHandler: { [weak self] (selectedTimezone) in + self?.viewModel.selectedValue = selectedTimezone.value + self?.onSelectionChanged(selectedTimezone) + }) + tableView.reloadData() + } + } + + fileprivate lazy var handler: ImmuTableViewHandler = { + return ImmuTableViewHandler(takeOver: self) + }() + + private var noResultsViewController: NoResultsViewController? + + private let searchController: UISearchController = { + let controller = UISearchController(searchResultsController: nil) + controller.obscuresBackgroundDuringPresentation = false + return controller + }() + + init(selectedValue: String?, onSelectionChanged: @escaping (WPTimeZone) -> Void) { + self.onSelectionChanged = onSelectionChanged + self.viewModel = TimeZoneSelectorViewModel(state: .loading, selectedValue: selectedValue, filter: nil) + super.init(style: .grouped) + searchController.searchResultsUpdater = self + title = NSLocalizedString("Time Zone", comment: "Title for the time zone selector") + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + if FeatureFlag.timeZoneSuggester.enabled { + ImmuTable.registerRows([TimeZoneRow.self], tableView: tableView) + } else { + ImmuTable.registerRows([CheckmarkRow.self], tableView: tableView) + } + + WPStyleGuide.configureColors(view: view, tableView: tableView) + WPStyleGuide.configureSearchBar(searchController.searchBar) + + configureTableHeaderView() + + tableView.backgroundView = UIView() + + let store = StoreContainer.shared.timezone + storeReceipt = store.onChange { [weak self] in + self?.updateViewModel() + } + queryReceipt = store.query(TimeZoneQuery()) + updateViewModel() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + if FeatureFlag.timeZoneSuggester.enabled { + // re-using this existing functionality for xib + autolayout in TableView Header + tableView.layoutHeaderView() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + searchController.isActive = false + } + + private func configureTableHeaderView() { + if FeatureFlag.timeZoneSuggester.enabled { + let timeZoneIdentifier = TimeZone.current.identifier + guard let headerView = TimeZoneSearchHeaderView.makeFromNib(searchBar: searchController.searchBar, + timezone: timeZoneIdentifier) else { + // fallback to default SearchBar if TimeZoneSearchHeaderView cannot be created + tableView.tableHeaderView = searchController.searchBar + return + } + + headerView.tapped = { [weak self] in + // check if currentTimeZoneIdentifier has a WPTimeZone instance + if let selectedTimezone = self?.viewModel.getTimeZoneForIdentifier(timeZoneIdentifier) { + self?.viewModel.selectedValue = timeZoneIdentifier + self?.onSelectionChanged(selectedTimezone) + } + } + + tableView.tableHeaderView = headerView + } else { + tableView.tableHeaderView = searchController.searchBar + } + } + + func updateSearchResults(for searchController: UISearchController) { + updateViewModel() + } + + func updateViewModel() { + let store = StoreContainer.shared.timezone + viewModel = TimeZoneSelectorViewModel( + state: TimeZoneSelectorViewModel.State.with(storeState: store.state), + selectedValue: viewModel.selectedValue, + filter: searchFilter + ) + updateNoResults() + } + + var searchFilter: String? { + guard searchController.isActive else { + return nil + } + return searchController.searchBar.text?.nonEmptyString() + } + +} + +// MARK: - No Results Handling + +private extension TimeZoneSelectorViewController { + + func updateNoResults() { + noResultsViewController?.removeFromView() + if let noResultsViewModel = viewModel.noResultsViewModel { + showNoResults(noResultsViewModel) + } + } + + private func showNoResults(_ viewModel: NoResultsViewController.Model) { + + if noResultsViewController == nil { + noResultsViewController = NoResultsViewController.controller() + noResultsViewController?.delegate = self + } + + guard let noResultsViewController = noResultsViewController else { + return + } + + noResultsViewController.bindViewModel(viewModel) + + tableView.addSubview(withFadeAnimation: noResultsViewController.view) + addChild(noResultsViewController) + noResultsViewController.didMove(toParent: self) + } + +} + +// MARK: - NoResultsViewControllerDelegate + +extension TimeZoneSelectorViewController: NoResultsViewControllerDelegate { + func actionButtonPressed() { + let supportVC = SupportTableViewController() + supportVC.showFromTabBar() + } +} + +// MARK: - UITableViewDelegate + +extension TimeZoneSelectorViewController { + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } +} diff --git a/WordPress/Classes/ViewRelated/Tools/Time Zone/TimeZoneSelectorViewModel.swift b/WordPress/Classes/ViewRelated/Tools/Time Zone/TimeZoneSelectorViewModel.swift new file mode 100644 index 000000000000..4c8a3b93b87d --- /dev/null +++ b/WordPress/Classes/ViewRelated/Tools/Time Zone/TimeZoneSelectorViewModel.swift @@ -0,0 +1,129 @@ +import WordPressFlux + +struct TimeZoneSelectorViewModel: Observable { + enum State { + case loading + case ready([TimeZoneGroup]) + case error(Error) + + static func with(storeState: TimeZoneStoreState) -> State { + switch storeState { + case .empty, .loading: + return .loading + case .loaded(let groups): + return .ready(groups) + case .error(let error): + return .error(error) + } + } + } + + var state: State = .loading { + didSet { + emitChange() + } + } + + var selectedValue: String? { + didSet { + emitChange() + } + } + + var filter: String? { + didSet { + emitChange() + } + } + + let changeDispatcher = Dispatcher() + + var groups: [TimeZoneGroup] { + guard case .ready(let groups) = state else { + return [] + } + return groups + } + + var filteredGroups: [TimeZoneGroup] { + guard let filter = filter else { + return groups + } + + return groups.compactMap({ (group) in + if group.name.localizedCaseInsensitiveContains(filter) { + return group + } else { + let timezones = group.timezones.filter({ $0.label.localizedCaseInsensitiveContains(filter) }) + if timezones.isEmpty { + return nil + } else { + return TimeZoneGroup(name: group.name, timezones: timezones) + } + } + }) + } + + private let timeZoneFormatter = TimeZoneFormatter(currentDate: Date()) + + func getTimeZoneForIdentifier(_ timeZoneIdentifier: String) -> WPTimeZone? { + return groups + .flatMap({ $0.timezones }) + .filter({ $0.value.lowercased() == timeZoneIdentifier.lowercased() }) + .first + } + + func tableViewModel(selectionHandler: @escaping (WPTimeZone) -> Void) -> ImmuTable { + return ImmuTable( + sections: filteredGroups.map({ (group) -> ImmuTableSection in + return ImmuTableSection( + headerText: group.name, + rows: group.timezones.map({ (timezone) -> ImmuTableRow in + if FeatureFlag.timeZoneSuggester.enabled { + return TimeZoneRow(title: timezone.label, + leftSubtitle: timeZoneFormatter.getZoneOffset(timezone), + rightSubtitle: timeZoneFormatter.getTimeAtZone(timezone), + action: { _ in + selectionHandler(timezone) + }) + } + else { + return CheckmarkRow(title: timezone.label, checked: timezone.value == selectedValue, action: { _ in + selectionHandler(timezone) + }) + } + })) + }) + ) + } + + var noResultsViewModel: NoResultsViewController.Model? { + switch state { + case .loading: + return NoResultsViewController.Model(title: LocalizedText.loadingTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) + case .ready: + return nil + case .error: + let appDelegate = WordPressAppDelegate.shared + + guard let connectionAvailable = appDelegate?.connectionAvailable, connectionAvailable == true else { + return NoResultsViewController.Model(title: LocalizedText.noConnectionTitle, + subtitle: LocalizedText.noConnectionSubtitle) + } + + return NoResultsViewController.Model(title: LocalizedText.errorTitle, + subtitle: LocalizedText.errorSubtitle, + buttonText: LocalizedText.buttonText) + } + } + + struct LocalizedText { + static let loadingTitle = NSLocalizedString("Loading...", comment: "Text displayed while loading time zones") + static let errorTitle = NSLocalizedString("Oops", comment: "Title for the view when there's an error loading time zones") + static let errorSubtitle = NSLocalizedString("There was an error loading time zones", comment: "Error message when time zones can't be loaded") + static let buttonText = NSLocalizedString("Contact support", comment: "Title of a button. A call to action to contact support for assistance.") + static let noConnectionTitle = NSLocalizedString("No connection", comment: "Title for the error view when there's no connection") + static let noConnectionSubtitle = NSLocalizedString("An active internet connection is required", comment: "Error message when loading failed because there's no connection") + } + +} diff --git a/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneRow.swift b/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneRow.swift new file mode 100644 index 000000000000..ae83130b07b1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneRow.swift @@ -0,0 +1,18 @@ +import UIKit + +struct TimeZoneRow: ImmuTableRow { + static let cell = ImmuTableCell.class(TimeZoneTableViewCell.self) + + let title: String + let leftSubtitle: String + let rightSubtitle: String + let action: ImmuTableAction? + + func configureCell(_ cell: UITableViewCell) { + guard let cell = cell as? TimeZoneTableViewCell else { return } + + cell.titleLabel.text = title + cell.leftSubtitle.text = leftSubtitle + cell.rightSubtitle.text = rightSubtitle + } +} diff --git a/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneSearchHeaderView.swift b/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneSearchHeaderView.swift new file mode 100644 index 000000000000..ab682311311f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneSearchHeaderView.swift @@ -0,0 +1,57 @@ +import UIKit + +final class TimeZoneSearchHeaderView: UIView { + + @IBOutlet private weak var searchWrapperView: SearchWrapperView! + + @IBOutlet private weak var searchWrapperViewHeightConstraint: NSLayoutConstraint! + + @IBOutlet private weak var suggestionLabel: UILabel! + + @IBOutlet private weak var suggestionButton: UIButton! + + /// Callback called when the button is tapped + var tapped: (() -> Void)? + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + class func makeFromNib(searchBar: UISearchBar, timezone: String) -> TimeZoneSearchHeaderView? { + guard let view = Bundle.main.loadNibNamed(Constants.nibIdentifier, + owner: self, + options: nil)?.first as? TimeZoneSearchHeaderView else { + assertionFailure("Failed to load view from nib named \(Constants.nibIdentifier)") + return nil + } + + view.searchWrapperView.addSubview(searchBar) + view.searchWrapperViewHeightConstraint.constant = searchBar.frame.height + + view.suggestionLabel.text = Localization.suggestion + + view.suggestionButton.setTitle(timezone, for: .normal) + view.suggestionButton.addTarget(view, action: #selector(buttonTapped), for: .touchUpInside) + + return view + } + + @objc private func buttonTapped() { + tapped?() + } +} + + +// MARK: - Constants + +private extension TimeZoneSearchHeaderView { + + enum Constants { + static let nibIdentifier = "TimeZoneSearchHeaderView" + } + + enum Localization { + static let suggestion = NSLocalizedString("Suggestion:", + comment: "Label displayed to the user left of the time zone suggestion button") + } +} diff --git a/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneSearchHeaderView.xib b/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneSearchHeaderView.xib new file mode 100644 index 000000000000..bdb872332536 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneSearchHeaderView.xib @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneTableViewCell.swift b/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneTableViewCell.swift new file mode 100644 index 000000000000..711ca25cd00b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Tools/Time Zone/Views/TimeZoneTableViewCell.swift @@ -0,0 +1,100 @@ +import UIKit + +class TimeZoneTableViewCell: WPTableViewCell { + + lazy var titleLabel: UILabel = { + let label = UILabel() + setupTimeZoneLabel(label) + label.font = .preferredFont(forTextStyle: .body) + label.textColor = .label + + return label + }() + + lazy var leftSubtitle: UILabel = { + let label = UILabel() + setupTimeZoneLabel(label) + label.font = .preferredFont(forTextStyle: .caption1) + label.textColor = .secondaryLabel + + return label + }() + + lazy var rightSubtitle: UILabel = { + let label = UILabel() + setupTimeZoneLabel(label) + label.font = .preferredFont(forTextStyle: .caption1) + label.textColor = .secondaryLabel + + if label.effectiveUserInterfaceLayoutDirection == .leftToRight { + // swiftlint:disable:next inverse_text_alignment + label.textAlignment = .right + } else { + // swiftlint:disable:next natural_text_alignment + label.textAlignment = .left + } + + return label + }() + + // MARK: - Initializers + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setupSubviews() + } + + public required override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupSubviews() + } + + func setupTimeZoneLabel(_ label: UILabel) { + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 1 + label.adjustsFontForContentSizeCategory = true + } + + private func setupSubviews() { + // Not every WPTimeZone has a time zone offset so wrapping content in UIStackView + // to allow for dynamic resizing for these cases + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .fill + stackView.spacing = Constants.verticalSpacing + + let subtitleContainerView = UIView() + subtitleContainerView.translatesAutoresizingMaskIntoConstraints = false + subtitleContainerView.addSubview(leftSubtitle) + subtitleContainerView.addSubview(rightSubtitle) + + let substack = UIStackView() + substack.axis = .horizontal + substack.alignment = .fill + substack.spacing = Constants.subtitleHorizontalSpacing + + substack.addArrangedSubviews([leftSubtitle, rightSubtitle]) + stackView.addArrangedSubviews([titleLabel, substack]) + + contentView.addSubview(stackView) + contentView.pinSubviewToAllEdges(stackView, insets: UIEdgeInsets( + top: Constants.verticalPadding, + left: Constants.horizontalPadding, + bottom: Constants.verticalPadding, + right: Constants.horizontalPadding) + ) + } +} + + +// MARK: - Constants + +private extension TimeZoneTableViewCell { + enum Constants { + static let horizontalPadding: CGFloat = 16 + static let verticalPadding: CGFloat = 10 + static let verticalSpacing: CGFloat = 3 + static let subtitleHorizontalSpacing: CGFloat = 8 + } +} diff --git a/WordPress/Classes/ViewRelated/Tools/TimeZoneSelectorViewController.swift b/WordPress/Classes/ViewRelated/Tools/TimeZoneSelectorViewController.swift deleted file mode 100644 index 187b7ecd1aa4..000000000000 --- a/WordPress/Classes/ViewRelated/Tools/TimeZoneSelectorViewController.swift +++ /dev/null @@ -1,233 +0,0 @@ -import UIKit -import WordPressFlux - -struct TimeZoneSelectorViewModel: Observable { - enum State { - case loading - case ready([TimeZoneGroup]) - case error(Error) - - static func with(storeState: TimeZoneStoreState) -> State { - switch storeState { - case .empty, .loading: - return .loading - case .loaded(let groups): - return .ready(groups) - case .error(let error): - return .error(error) - } - } - } - - var state: State = .loading { - didSet { - emitChange() - } - } - - var selectedValue: String? { - didSet { - emitChange() - } - } - - var filter: String? { - didSet { - emitChange() - } - } - - let changeDispatcher = Dispatcher() - - var groups: [TimeZoneGroup] { - guard case .ready(let groups) = state else { - return [] - } - return groups - } - - var filteredGroups: [TimeZoneGroup] { - guard let filter = filter else { - return groups - } - - return groups.compactMap({ (group) in - if group.name.localizedCaseInsensitiveContains(filter) { - return group - } else { - let timezones = group.timezones.filter({ $0.label.localizedCaseInsensitiveContains(filter) }) - if timezones.isEmpty { - return nil - } else { - return TimeZoneGroup(name: group.name, timezones: timezones) - } - } - }) - } - - func tableViewModel(selectionHandler: @escaping (WPTimeZone) -> Void) -> ImmuTable { - return ImmuTable( - sections: filteredGroups.map({ (group) -> ImmuTableSection in - return ImmuTableSection( - headerText: group.name, - rows: group.timezones.map({ (timezone) -> ImmuTableRow in - return CheckmarkRow(title: timezone.label, checked: timezone.value == selectedValue, action: { _ in - selectionHandler(timezone) - }) - })) - }) - ) - } - - var noResultsViewModel: NoResultsViewController.Model? { - switch state { - case .loading: - return NoResultsViewController.Model(title: LocalizedText.loadingTitle, accessoryView: NoResultsViewController.loadingAccessoryView()) - case .ready: - return nil - case .error: - let appDelegate = WordPressAppDelegate.shared - if (appDelegate?.connectionAvailable)! { - return NoResultsViewController.Model(title: LocalizedText.errorTitle, - subtitle: LocalizedText.errorSubtitle, - buttonText: LocalizedText.buttonText) - } else { - return NoResultsViewController.Model(title: LocalizedText.noConnectionTitle, - subtitle: LocalizedText.noConnectionSubtitle) - } - } - } - - struct LocalizedText { - static let loadingTitle = NSLocalizedString("Loading...", comment: "Text displayed while loading time zones") - static let errorTitle = NSLocalizedString("Oops", comment: "Title for the view when there's an error loading time zones") - static let errorSubtitle = NSLocalizedString("There was an error loading time zones", comment: "Error message when time zones can't be loaded") - static let buttonText = NSLocalizedString("Contact support", comment: "Title of a button. A call to action to contact support for assistance.") - static let noConnectionTitle = NSLocalizedString("No connection", comment: "Title for the error view when there's no connection") - static let noConnectionSubtitle = NSLocalizedString("An active internet connection is required", comment: "Error message when loading failed because there's no connection") - } - -} - -class TimeZoneSelectorViewController: UITableViewController, UISearchResultsUpdating { - var storeReceipt: Receipt? - var queryReceipt: Receipt? - - var onSelectionChanged: ((WPTimeZone) -> Void) - var viewModel: TimeZoneSelectorViewModel { - didSet { - handler.viewModel = viewModel.tableViewModel(selectionHandler: { [weak self] (selectedTimezone) in - self?.viewModel.selectedValue = selectedTimezone.value - self?.onSelectionChanged(selectedTimezone) - }) - tableView.reloadData() - } - } - - fileprivate lazy var handler: ImmuTableViewHandler = { - return ImmuTableViewHandler(takeOver: self) - }() - - private var noResultsViewController: NoResultsViewController? - - private let searchController: UISearchController = { - let controller = UISearchController(searchResultsController: nil) - controller.obscuresBackgroundDuringPresentation = false - return controller - }() - - init(selectedValue: String?, onSelectionChanged: @escaping (WPTimeZone) -> Void) { - self.onSelectionChanged = onSelectionChanged - self.viewModel = TimeZoneSelectorViewModel(state: .loading, selectedValue: selectedValue, filter: nil) - super.init(style: .grouped) - searchController.searchResultsUpdater = self - title = NSLocalizedString("Time Zone", comment: "Title for the time zone selector") - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - ImmuTable.registerRows([CheckmarkRow.self], tableView: tableView) - WPStyleGuide.configureColors(view: view, tableView: tableView) - WPStyleGuide.configureSearchBar(searchController.searchBar) - tableView.tableHeaderView = searchController.searchBar - tableView.backgroundView = UIView() - - let store = StoreContainer.shared.timezone - storeReceipt = store.onChange { [weak self] in - self?.updateViewModel() - } - queryReceipt = store.query(TimeZoneQuery()) - updateViewModel() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - searchController.isActive = false - } - - func updateSearchResults(for searchController: UISearchController) { - updateViewModel() - } - - func updateViewModel() { - let store = StoreContainer.shared.timezone - viewModel = TimeZoneSelectorViewModel( - state: TimeZoneSelectorViewModel.State.with(storeState: store.state), - selectedValue: viewModel.selectedValue, - filter: searchFilter - ) - updateNoResults() - } - - var searchFilter: String? { - guard searchController.isActive else { - return nil - } - return searchController.searchBar.text?.nonEmptyString() - } - -} - -// MARK: - No Results Handling - -private extension TimeZoneSelectorViewController { - - func updateNoResults() { - noResultsViewController?.removeFromView() - if let noResultsViewModel = viewModel.noResultsViewModel { - showNoResults(noResultsViewModel) - } - } - - private func showNoResults(_ viewModel: NoResultsViewController.Model) { - - if noResultsViewController == nil { - noResultsViewController = NoResultsViewController.controller() - noResultsViewController?.delegate = self - } - - guard let noResultsViewController = noResultsViewController else { - return - } - - noResultsViewController.bindViewModel(viewModel) - - tableView.addSubview(withFadeAnimation: noResultsViewController.view) - addChild(noResultsViewController) - noResultsViewController.didMove(toParent: self) - } - -} - -// MARK: - NoResultsViewControllerDelegate - -extension TimeZoneSelectorViewController: NoResultsViewControllerDelegate { - func actionButtonPressed() { - let supportVC = SupportTableViewController() - supportVC.showFromTabBar() - } -} diff --git a/WordPress/Classes/ViewRelated/Tools/UIView+SwiftUI.swift b/WordPress/Classes/ViewRelated/Tools/UIView+SwiftUI.swift new file mode 100644 index 000000000000..19d60d4f1ad5 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Tools/UIView+SwiftUI.swift @@ -0,0 +1,12 @@ +import SwiftUI +import UIKit + + +extension UIView { + class func embedSwiftUIView(_ view: Content) -> UIView { + let controller = UIHostingController(rootView: view) + controller.view.translatesAutoresizingMaskIntoConstraints = false + controller.view.backgroundColor = .clear + return controller.view + } +} diff --git a/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSectionHeader.swift b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSectionHeader.swift new file mode 100644 index 000000000000..59fe1c13b529 --- /dev/null +++ b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSectionHeader.swift @@ -0,0 +1,13 @@ +import UIKit + +class UserProfileSectionHeader: UITableViewHeaderFooterView, NibReusable { + + @IBOutlet weak var titleLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + titleLabel.textColor = .textSubtle + contentView.backgroundColor = .basicBackground + } + +} diff --git a/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSectionHeader.xib b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSectionHeader.xib new file mode 100644 index 000000000000..affb3ac85971 --- /dev/null +++ b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSectionHeader.xib @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSheetViewController.swift b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSheetViewController.swift new file mode 100644 index 000000000000..d34175dba76c --- /dev/null +++ b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSheetViewController.swift @@ -0,0 +1,217 @@ +class UserProfileSheetViewController: UITableViewController { + + // MARK: - Properties + + private let user: LikeUser + + // Used for the `source` property in Stats when a Blog is previewed by URL (that is, in a WebView). + var blogUrlPreviewedSource: String? + + private lazy var mainContext = { + return ContextManager.sharedInstance().mainContext + }() + + private lazy var contentCoordinator: ContentCoordinator = { + return DefaultContentCoordinator(controller: self, context: mainContext) + }() + + // MARK: - Init + + init(user: LikeUser) { + self.user = user + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + configureTable() + registerTableCells() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + var size = tableView.contentSize + + // Apply a slight padding to the bottom of the view to give it some space to breathe + // when being presented in a popover or bottom sheet + let bottomPadding = WPDeviceIdentification.isiPad() ? Constants.iPadBottomPadding : Constants.iPhoneBottomPadding + size.height += bottomPadding + + preferredContentSize = size + } +} + +// MARK: - DrawerPresentable Extension + +extension UserProfileSheetViewController: DrawerPresentable { + + var collapsedHeight: DrawerHeight { + if traitCollection.verticalSizeClass == .compact { + return .maxHeight + } + + // Force the table layout to update so the Bottom Sheet gets the right height. + tableView.layoutIfNeeded() + return .intrinsicHeight + } + + var scrollableView: UIScrollView? { + return tableView + } + + var allowsUserTransition: Bool { + false + } + +} + +// MARK: - UITableViewDataSource methods + +extension UserProfileSheetViewController { + + override func numberOfSections(in tableView: UITableView) -> Int { + return user.preferredBlog != nil ? 2 : 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch indexPath.section { + case Constants.userInfoSection: + return userInfoCell() + default: + return siteCell() + } + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + + // Don't show section header for User Info + guard section != Constants.userInfoSection, + let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: UserProfileSectionHeader.defaultReuseID) as? UserProfileSectionHeader else { + return nil + } + + header.titleLabel.text = Constants.siteSectionTitle + return header + } + + override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return indexPath.section == Constants.userInfoSection ? UserProfileUserInfoCell.estimatedRowHeight : + UserProfileSiteCell.estimatedRowHeight + } + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return section == Constants.userInfoSection ? 0 : UITableView.automaticDimension + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + return false + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.section != Constants.userInfoSection else { + return + } + + showSite() + tableView.deselectRow(at: indexPath, animated: true) + } +} + +// MARK: - Private Extension + +private extension UserProfileSheetViewController { + + func showSite() { + WPAnalytics.track(.userProfileSheetSiteShown) + + guard let blog = user.preferredBlog else { + return + } + + guard blog.blogID > 0 else { + showSiteWebView(withUrl: blog.blogUrl) + return + } + + showSiteTopicWithID(NSNumber(value: blog.blogID)) + + } + + func showSiteTopicWithID(_ siteID: NSNumber) { + let controller = ReaderStreamViewController.controllerWithSiteID(siteID, isFeed: false) + controller.statSource = ReaderStreamViewController.StatSource.notif_like_list_user_profile + let navController = UINavigationController(rootViewController: controller) + present(navController, animated: true) + } + + func showSiteWebView(withUrl url: String?) { + guard let urlString = url, + !urlString.isEmpty, + let siteURL = URL(string: urlString) else { + DDLogError("User Profile: Error creating URL from site string.") + return + } + + WPAnalytics.track(.blogUrlPreviewed, properties: ["source": blogUrlPreviewedSource as Any]) + contentCoordinator.displayWebViewWithURL(siteURL, source: blogUrlPreviewedSource ?? "user_profile_sheet") + } + + func configureTable() { + tableView.backgroundColor = .basicBackground + tableView.separatorStyle = .none + tableView.isScrollEnabled = false + } + + func registerTableCells() { + tableView.register(UserProfileUserInfoCell.defaultNib, + forCellReuseIdentifier: UserProfileUserInfoCell.defaultReuseID) + + tableView.register(UserProfileSiteCell.defaultNib, + forCellReuseIdentifier: UserProfileSiteCell.defaultReuseID) + + tableView.register(UserProfileSectionHeader.defaultNib, + forHeaderFooterViewReuseIdentifier: UserProfileSectionHeader.defaultReuseID) + } + + func userInfoCell() -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: UserProfileUserInfoCell.defaultReuseID) as? UserProfileUserInfoCell else { + return UITableViewCell() + } + + cell.configure(withUser: user) + return cell + } + + func siteCell() -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: UserProfileSiteCell.defaultReuseID) as? UserProfileSiteCell, + let blog = user.preferredBlog else { + return UITableViewCell() + } + + cell.configure(withBlog: blog) + return cell + } + + enum Constants { + static let userInfoSection = 0 + static let siteSectionTitle = NSLocalizedString("Site", comment: "Header for a single site, shown in Notification user profile.").localizedUppercase + static let iPadBottomPadding: CGFloat = 10 + static let iPhoneBottomPadding: CGFloat = 40 + } + +} diff --git a/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSiteCell.swift b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSiteCell.swift new file mode 100644 index 000000000000..4aa72e92b215 --- /dev/null +++ b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSiteCell.swift @@ -0,0 +1,51 @@ +import Foundation + +class UserProfileSiteCell: UITableViewCell, NibReusable { + + // MARK: - Properties + + @IBOutlet weak var siteIconImageView: UIImageView! + @IBOutlet weak var siteNameLabel: UILabel! + @IBOutlet weak var siteUrlLabel: UILabel! + + static let estimatedRowHeight: CGFloat = 50 + + // MARK: - View + + override func awakeFromNib() { + super.awakeFromNib() + configureCell() + } + + // MARK: - Public Methods + + func configure(withBlog blog: LikeUserPreferredBlog) { + siteNameLabel.text = blog.blogName + siteUrlLabel.text = blog.blogUrl + downloadIconWithURL(blog.iconUrl) + } +} + +// MARK: - Private Extension + +private extension UserProfileSiteCell { + + func configureCell() { + siteNameLabel.textColor = .text + siteUrlLabel.textColor = .textSubtle + } + + func downloadIconWithURL(_ url: String?) { + // Always reset icon + siteIconImageView.cancelImageDownload() + siteIconImageView.image = .siteIconPlaceholderImage + + guard let url = url, + let iconURL = URL(string: url) else { + return + } + + siteIconImageView.downloadImage(from: iconURL, placeholderImage: .siteIconPlaceholderImage) + } + +} diff --git a/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSiteCell.xib b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSiteCell.xib new file mode 100644 index 000000000000..1fe76d25999c --- /dev/null +++ b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileSiteCell.xib @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileUserInfoCell.swift b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileUserInfoCell.swift new file mode 100644 index 000000000000..3cf119647c2b --- /dev/null +++ b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileUserInfoCell.swift @@ -0,0 +1,61 @@ +class UserProfileUserInfoCell: UITableViewCell, NibReusable { + + // MARK: - Properties + + @IBOutlet weak var gravatarImageView: CircularImageView! + @IBOutlet weak var nameLabel: UILabel! + @IBOutlet weak var usernameLabel: UILabel! + @IBOutlet weak var userBioLabel: UILabel! + + static let estimatedRowHeight: CGFloat = 200 + + // MARK: - View + + override func awakeFromNib() { + super.awakeFromNib() + configureCell() + } + + // MARK: - Public Methods + + func configure(withUser user: LikeUser) { + nameLabel.text = user.displayName + usernameLabel.text = String(format: Constants.usernameFormat, user.username) + + userBioLabel.text = user.bio + userBioLabel.isHidden = user.bio.isEmpty + + downloadGravatarWithURL(user.avatarUrl) + } + +} + +// MARK: - Private Extension + +private extension UserProfileUserInfoCell { + + func configureCell() { + nameLabel.textColor = .text + nameLabel.font = WPStyleGuide.serifFontForTextStyle(.title3, fontWeight: .semibold) + usernameLabel.textColor = .textSubtle + userBioLabel.textColor = .text + } + + func downloadGravatarWithURL(_ url: String?) { + // Always reset gravatar + gravatarImageView.cancelImageDownload() + gravatarImageView.image = .gravatarPlaceholderImage + + guard let url = url, + let gravatarURL = URL(string: url) else { + return + } + + gravatarImageView.downloadImage(from: gravatarURL, placeholderImage: .gravatarPlaceholderImage) + } + + struct Constants { + static let usernameFormat = NSLocalizedString("@%1$@", comment: "Label displaying the user's username preceeded by an '@' symbol. %1$@ is a placeholder for the username.") + } + +} diff --git a/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileUserInfoCell.xib b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileUserInfoCell.xib new file mode 100644 index 000000000000..15d94f53d467 --- /dev/null +++ b/WordPress/Classes/ViewRelated/User Profile Sheet/UserProfileUserInfoCell.xib @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Views/AlertView/AlertInternalView.swift b/WordPress/Classes/ViewRelated/Views/AlertView/AlertInternalView.swift index bea10ab8a740..9fda9de8cef9 100644 --- a/WordPress/Classes/ViewRelated/Views/AlertView/AlertInternalView.swift +++ b/WordPress/Classes/ViewRelated/Views/AlertView/AlertInternalView.swift @@ -6,7 +6,7 @@ import WordPressShared /// open class AlertInternalView: UIView { // MARK: - Public Properties - @objc open var onClick : (() -> ())? + @objc open var onClick: (() -> ())? diff --git a/WordPress/Classes/ViewRelated/Views/AlertView/AlertView.swift b/WordPress/Classes/ViewRelated/Views/AlertView/AlertView.swift index 18438a870b9a..d5a0bc2ebfb8 100644 --- a/WordPress/Classes/ViewRelated/Views/AlertView/AlertView.swift +++ b/WordPress/Classes/ViewRelated/Views/AlertView/AlertView.swift @@ -60,7 +60,7 @@ open class AlertView: NSObject { /// - Returns: The Key View. /// fileprivate func keyView() -> UIView { - return (UIApplication.shared.keyWindow?.subviews.first)! + return (UIApplication.shared.mainWindow?.subviews.first)! } diff --git a/WordPress/Classes/ViewRelated/Views/BadgeLabel.swift b/WordPress/Classes/ViewRelated/Views/BadgeLabel.swift index 65a709686216..bec7c78b2211 100644 --- a/WordPress/Classes/ViewRelated/Views/BadgeLabel.swift +++ b/WordPress/Classes/ViewRelated/Views/BadgeLabel.swift @@ -8,6 +8,13 @@ class BadgeLabel: UILabel { } } + @IBInspectable var verticalPadding: CGFloat = 0 { + didSet { + invalidateIntrinsicContentSize() + setNeedsLayout() + } + } + // MARK: Initialization override init(frame: CGRect) { @@ -30,13 +37,14 @@ class BadgeLabel: UILabel { // MARK: Padding override func drawText(in rect: CGRect) { - let insets = UIEdgeInsets.init(top: 0, left: horizontalPadding, bottom: 0, right: horizontalPadding) + let insets = UIEdgeInsets.init(top: verticalPadding, left: horizontalPadding, bottom: verticalPadding, right: horizontalPadding) super.drawText(in: rect.inset(by: insets)) } override var intrinsicContentSize: CGSize { var paddedSize = super.intrinsicContentSize paddedSize.width += 2 * horizontalPadding + paddedSize.height += 2 * verticalPadding return paddedSize } diff --git a/WordPress/Classes/ViewRelated/Views/ButtonScrollView.swift b/WordPress/Classes/ViewRelated/Views/ButtonScrollView.swift new file mode 100644 index 000000000000..ddca2ff130e1 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/ButtonScrollView.swift @@ -0,0 +1,16 @@ +import UIKit + +/// This UIScrollView subclass enables scrolling when the initial tap is inside a UIButton. +/// By default touches inside a UIButton cancels the scrolling action. This subclass overrides this behavior. +/// It's recommended to use this subclass when a scroll view is mainly populated by UIButtons. +class ButtonScrollView: UIScrollView { + + override func touchesShouldCancel(in view: UIView) -> Bool { + if view.isKind(of: UIButton.self) { + return true + } + + return super.touchesShouldCancel(in: view) + } + +} diff --git a/WordPress/Classes/ViewRelated/Views/CircularImageView.swift b/WordPress/Classes/ViewRelated/Views/CircularImageView.swift index c894ba2fff4b..1b72d6b93126 100644 --- a/WordPress/Classes/ViewRelated/Views/CircularImageView.swift +++ b/WordPress/Classes/ViewRelated/Views/CircularImageView.swift @@ -2,9 +2,6 @@ import UIKit // Makes a UIImageView circular. Handy for gravatars class CircularImageView: UIImageView { - - var animatesTouch = false - @objc var shouldRoundCorners: Bool = true { didSet { let rect = frame @@ -38,56 +35,3 @@ class CircularImageView: UIImageView { } } } - - -/// Touch animation -extension CircularImageView { - - private struct AnimationConfiguration { - static let startAlpha: CGFloat = 0.5 - static let endAlpha: CGFloat = 1.0 - static let aimationDuration: TimeInterval = 0.3 - } - /// animates the change of opacity from the current value to AnimationConfiguration.endAlpha - private func restoreAlpha() { - UIView.animate(withDuration: AnimationConfiguration.aimationDuration) { - self.alpha = AnimationConfiguration.endAlpha - } - } - /// Custom touch animation, executed if animatesTouch is set to true. - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - super.touchesBegan(touches, with: event) - if animatesTouch { - alpha = AnimationConfiguration.startAlpha - } - } - - override func touchesEnded(_ touches: Set, with event: UIEvent?) { - super.touchesEnded(touches, with: event) - if animatesTouch { - restoreAlpha() - } - } - - override func touchesCancelled(_ touches: Set, with event: UIEvent?) { - super.touchesCancelled(touches, with: event) - if animatesTouch { - restoreAlpha() - } - } -} - -/// Border options -extension CircularImageView { - - private struct StandardBorder { - static let color = UIColor.white - static let width = CGFloat(2) - } - /// sets border color and width to the circular image view. Defaults to StandardBorder values - func setBorder(color: UIColor = StandardBorder.color, width: CGFloat = StandardBorder.width) { - self.layer.borderColor = color.cgColor - self.layer.borderWidth = width - } - -} diff --git a/WordPress/Classes/ViewRelated/Views/FixedSizeImageView.swift b/WordPress/Classes/ViewRelated/Views/FixedSizeImageView.swift new file mode 100644 index 000000000000..98ada81e80ad --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/FixedSizeImageView.swift @@ -0,0 +1,14 @@ +import Foundation + +/// `UIImageView`s without height and width constraints are automatically resized to their intrinsic content size when +/// an image is set. This subclass exists to allow the creation of image views that don't have size constraints and that +/// are NOT resized when their image is set. +/// +/// This is useful to let the dimensions of the `UIImageView` be defined by neighbor views. A good example of this is when you want +/// the image view to have the same height as a neighbor text field. +/// +class FixedSizeImageView: UIImageView { + override var intrinsicContentSize: CGSize { + return .zero + } +} diff --git a/WordPress/Classes/ViewRelated/Views/LinearGradientView.swift b/WordPress/Classes/ViewRelated/Views/LinearGradientView.swift new file mode 100644 index 000000000000..975c5da3e3fb --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/LinearGradientView.swift @@ -0,0 +1,60 @@ +import UIKit + +class LinearGradientView: UIView { + @IBInspectable var startColor: UIColor? = nil + @IBInspectable var endColor: UIColor? = nil + + @IBInspectable var startPoint: CGPoint = CGPoint(x: 0.5, y: 0.0) + @IBInspectable var endPoint: CGPoint = CGPoint(x: 0.5, y: 1.0) + + override init(frame: CGRect) { + super.init(frame: frame) + + contentMode = .redraw + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + contentMode = .redraw + } + + private func configure() { + contentMode = .redraw + } + + override func draw(_ rect: CGRect) { + guard + let context = UIGraphicsGetCurrentContext(), + let startColor = startColor?.cgColor, + let endColor = endColor?.cgColor + else { + return + } + + context.saveGState() + + defer { context.restoreGState() } + + let path = UIBezierPath(rect: bounds) + path.addClip() + + let width = bounds.width + let height = bounds.height + + let start = CGPoint(x: startPoint.x * width, y: startPoint.y * height) + let end = CGPoint(x: endPoint.x * width, y: endPoint.y * height) + + let colors = [startColor, endColor] as CFArray + guard + let gradient = CGGradient(colorsSpace: nil, colors: colors, locations: nil) + else { + return + } + + context.drawLinearGradient(gradient, + start: start, + end: end, + options: []) + } +} diff --git a/WordPress/Classes/ViewRelated/Views/List/ListSimpleOverlayView.swift b/WordPress/Classes/ViewRelated/Views/List/ListSimpleOverlayView.swift new file mode 100644 index 000000000000..c6c4b9aff286 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/List/ListSimpleOverlayView.swift @@ -0,0 +1,7 @@ +/// A view with side-by-side label and button, intended to be displayed as an overlay on top of `ListTableViewCell`. +/// +class ListSimpleOverlayView: UIView, NibLoadable { + // MARK: IBOutlets + @IBOutlet weak var textLabel: UILabel! + @IBOutlet weak var actionButton: UIButton! +} diff --git a/WordPress/Classes/ViewRelated/Views/List/ListSimpleOverlayView.xib b/WordPress/Classes/ViewRelated/Views/List/ListSimpleOverlayView.xib new file mode 100644 index 000000000000..277c83ca1c3f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/List/ListSimpleOverlayView.xib @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Views/List/ListTableHeaderView.swift b/WordPress/Classes/ViewRelated/Views/List/ListTableHeaderView.swift new file mode 100644 index 000000000000..7539fc29ac4f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/List/ListTableHeaderView.swift @@ -0,0 +1,61 @@ +/// Renders a table header view with bottom separator, and meant to be used +/// alongside `ListTableViewCell`. +/// +/// This is used in Comments and Notifications as part of the Comments +/// Unification project. +/// +class ListTableHeaderView: UITableViewHeaderFooterView, NibReusable { + // MARK: IBOutlets + + @IBOutlet private weak var separatorsView: SeparatorsView! + @IBOutlet private weak var titleLabel: UILabel! + + // MARK: Properties + + /// Added to provide objc support, since NibReusable protocol methods aren't accessible from objc. + /// This should be removed when the caller is rewritten in Swift. + @objc static let reuseIdentifier = defaultReuseID + + @objc static let estimatedRowHeight = 26 + + @objc var title: String? { + get { + titleLabel.text + } + set { + titleLabel.text = newValue?.localizedUppercase ?? String() + accessibilityLabel = newValue + } + } + + // MARK: Initialization + + override func awakeFromNib() { + super.awakeFromNib() + + // Hide text label to prevent values being shown due to interaction with + // NSFetchedResultsController. By default, the results controller assigns the + // value of sectionNameKeyPath to UITableHeaderFooterView's textLabel. + textLabel?.isHidden = true + + // Set background color. + // Note that we need to set it through `backgroundView`, or Xcode will lash out a warning. + backgroundView = { + let view = UIView(frame: self.bounds) + view.backgroundColor = Style.sectionHeaderBackgroundColor + return view + }() + + // configure title label + titleLabel.font = Style.sectionHeaderFont + titleLabel.textColor = Style.sectionHeaderTitleColor + + // configure separators view + separatorsView.bottomColor = Style.separatorColor + separatorsView.bottomVisible = true + } + + // MARK: Convenience + + private typealias Style = WPStyleGuide.List +} diff --git a/WordPress/Classes/ViewRelated/Views/List/ListTableHeaderView.xib b/WordPress/Classes/ViewRelated/Views/List/ListTableHeaderView.xib new file mode 100644 index 000000000000..1a64196ca7e8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/List/ListTableHeaderView.xib @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Views/List/ListTableViewCell.swift b/WordPress/Classes/ViewRelated/Views/List/ListTableViewCell.swift new file mode 100644 index 000000000000..e56fd10e7e3f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/List/ListTableViewCell.swift @@ -0,0 +1,166 @@ +/// Table view cell for the List component. +/// +/// This is used in Comments and Notifications as part of the Comments +/// Unification project. +/// +class ListTableViewCell: UITableViewCell, NibReusable { + // MARK: IBOutlets + + @IBOutlet private weak var indicatorView: UIView! + @IBOutlet private weak var avatarView: CircularImageView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var snippetLabel: UILabel! + @IBOutlet private weak var indicatorWidthConstraint: NSLayoutConstraint! + + /// Convenience property to retain the overlay view when shown on top of the cell. + /// The overlay can be shown or dismissed through `showOverlay` and `dismissOverlay` respectively. + private var overlayView: UIView? + + // MARK: Properties + + /// Added to provide objc support, since NibReusable protocol methods aren't accessible from objc. + /// This should be removed when the caller is rewritten in Swift. + @objc static let reuseIdentifier = defaultReuseID + + @objc static let estimatedRowHeight = 68 + + /// The color of the indicator circle. + @objc var indicatorColor: UIColor = .clear { + didSet { + updateIndicatorColor() + } + } + + /// Toggle variable to determine whether the indicator circle should be shown. + @objc var showsIndicator: Bool = false { + didSet { + updateIndicatorColor() + } + } + + /// The default placeholder image. + @objc var placeholderImage: UIImage = Style.placeholderImage + + /// The attributed string to be displayed in titleLabel. + /// To keep the styles uniform between List components, refer to regular and bold styles in `WPStyleGuide+List`. + @objc var attributedTitleText: NSAttributedString? { + get { + titleLabel.attributedText + } + set { + titleLabel.attributedText = newValue ?? NSAttributedString() + } + } + + /// The snippet text, displayed in snippetLabel. + /// Note that new values are trimmed of whitespaces and newlines. + @objc var snippetText: String? { + get { + snippetLabel.text + } + set { + snippetLabel.text = newValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? String() + updateTitleTextLines() + } + } + + /// Convenience computed property to check whether the cell has a snippet text or not. + private var hasSnippet: Bool { + !(snippetText ?? "").isEmpty + } + + // MARK: Initialization + + override func awakeFromNib() { + super.awakeFromNib() + configureSubviews() + } + + // MARK: Public Methods + + /// Configures the avatar image view with the provided URL. + /// If the URL does not contain any image, the default placeholder image will be displayed. + /// - Parameter url: The URL containing the image. + func configureImage(with url: URL?) { + if let someURL = url, let gravatar = Gravatar(someURL) { + avatarView.downloadGravatar(gravatar, placeholder: placeholderImage, animate: true) + return + } + + // handle non-gravatar images + avatarView.downloadImage(from: url, placeholderImage: placeholderImage) + } + + /// Configures the avatar image view from Gravatar based on provided email. + /// If the Gravatar image for the provided email doesn't exist, the default placeholder image will be displayed. + /// - Parameter gravatarEmail: The email to be used for querying the Gravatar image. + func configureImageWithGravatarEmail(_ email: String?) { + guard let someEmail = email else { + return + } + + avatarView.downloadGravatarWithEmail(someEmail, placeholderImage: placeholderImage) + } + + // MARK: Overlay View Support + + /// Shows an overlay view on top of the cell. + /// - Parameter view: The view to be shown as an overlay. + func showOverlay(with view: UIView) { + // If an existing overlay is present, let's dismiss it to prevent stacked overlays. + if let _ = overlayView { + dismissOverlay() + } + + contentView.addSubview(view) + contentView.pinSubviewToAllEdges(view) + overlayView = view + } + + /// Removes the overlay that's covering the cell. + func dismissOverlay() { + overlayView?.removeFromSuperview() + overlayView = nil + } +} + +// MARK: Private Helpers + +private extension ListTableViewCell { + /// Apply styles for the subviews. + func configureSubviews() { + // indicator view + indicatorView.layer.cornerRadius = indicatorWidthConstraint.constant / 2 + + // title label + titleLabel.font = Style.plainTitleFont + titleLabel.textColor = Style.titleTextColor + titleLabel.numberOfLines = Constants.titleNumberOfLinesWithSnippet + + // snippet label + snippetLabel.font = Style.snippetFont + snippetLabel.textColor = Style.snippetTextColor + snippetLabel.numberOfLines = Constants.snippetNumberOfLines + } + + /// Show more lines in titleLabel when there's no snippet. + func updateTitleTextLines() { + titleLabel.numberOfLines = hasSnippet ? Constants.titleNumberOfLinesWithSnippet : Constants.titleNumberOfLinesWithoutSnippet + } + + func updateIndicatorColor() { + indicatorView.backgroundColor = showsIndicator ? indicatorColor : .clear + } +} + +// MARK: Private Constants + +private extension ListTableViewCell { + typealias Style = WPStyleGuide.List + + struct Constants { + static let titleNumberOfLinesWithoutSnippet = 3 + static let titleNumberOfLinesWithSnippet = 2 + static let snippetNumberOfLines = 2 + } +} diff --git a/WordPress/Classes/ViewRelated/Views/List/ListTableViewCell.xib b/WordPress/Classes/ViewRelated/Views/List/ListTableViewCell.xib new file mode 100644 index 000000000000..2caba2185843 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/List/ListTableViewCell.xib @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Classes/ViewRelated/Views/List/WPStyleGuide+List.swift b/WordPress/Classes/ViewRelated/Views/List/WPStyleGuide+List.swift new file mode 100644 index 000000000000..0d5d4b5ce4bc --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/List/WPStyleGuide+List.swift @@ -0,0 +1,34 @@ +/// Convenience constants catalog related for styling List components. +/// +extension WPStyleGuide { + public enum List { + // MARK: Section Headers + public static let sectionHeaderFont = WPStyleGuide.fontForTextStyle(.caption1, fontWeight: .medium) + public static let sectionHeaderTitleColor = UIColor.textSubtle + public static let sectionHeaderBackgroundColor = UIColor.basicBackground + + // MARK: Separators + public static let separatorColor = UIColor.divider + + // MARK: Cells + public static let placeholderImage = UIImage.gravatarPlaceholderImage + public static let snippetFont = regularTextFont + public static let snippetTextColor = UIColor.textSubtle + public static let plainTitleFont = regularTextFont + public static let titleTextColor = UIColor.text + + public static let titleRegularAttributes: [NSAttributedString.Key: Any] = [ + .font: regularTextFont, + .foregroundColor: titleTextColor + ] + + public static let titleBoldAttributes: [NSAttributedString.Key: Any] = [ + .font: boldTextFont, + .foregroundColor: titleTextColor + ] + + // MARK: Private Styles + private static let regularTextFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) + private static let boldTextFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .semibold) + } +} diff --git a/WordPress/Classes/ViewRelated/Views/LoadingStatusView.swift b/WordPress/Classes/ViewRelated/Views/LoadingStatusView.swift index 05be06c8f9c0..b966c23deb5e 100644 --- a/WordPress/Classes/ViewRelated/Views/LoadingStatusView.swift +++ b/WordPress/Classes/ViewRelated/Views/LoadingStatusView.swift @@ -6,17 +6,15 @@ class LoadingStatusView: UIView { translatesAutoresizingMaskIntoConstraints = false backgroundColor = .clear autoresizingMask = .flexibleWidth - accessibilityHint = NSLocalizedString("Tap to cancel uploading.", comment: "This is a status indicator on the editor") - let localizedString = NSLocalizedString("%@", comment: "\"Uploading\" Status text") - titleLabel.text = String.localizedStringWithFormat(localizedString, title) + titleLabel.text = title activityIndicator.startAnimating() configureLayout() } private lazy var titleLabel: UILabel = { let label = UILabel() - label.textColor = .white - label.font = WPFontManager.systemBoldFont(ofSize: 14.0) + label.textColor = .appBarText + label.font = WPFontManager.systemRegularFont(ofSize: 14.0) label.translatesAutoresizingMaskIntoConstraints = false label.sizeToFit() label.numberOfLines = 1 @@ -28,7 +26,7 @@ class LoadingStatusView: UIView { }() private lazy var activityIndicator: UIActivityIndicatorView = { - let indicator = UIActivityIndicatorView(style: .white) + let indicator = UIActivityIndicatorView(style: .medium) indicator.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ indicator.widthAnchor.constraint(equalToConstant: 20.0), diff --git a/WordPress/Classes/ViewRelated/Views/MenuSheetViewController.swift b/WordPress/Classes/ViewRelated/Views/MenuSheetViewController.swift new file mode 100644 index 000000000000..a8b09c0c3f8f --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/MenuSheetViewController.swift @@ -0,0 +1,171 @@ +import UIKit +import WordPressUI + +/// Provides a fallback implementation for showing `UIMenu` in iOS 13. To "mimic" the `UIContextMenu` appearance, this +/// view controller should be presented modally with a `.popover` presentation style. Note that to simplify things, +/// nested elements will be displayed as if `UIMenuOptions.displayInline` is applied. +/// +/// In iOS 13, `UIMenu` can only appear through long press gesture. There is no way to make it appear programmatically +/// or through different gestures. However, in iOS 14 menus can be configured to appear on tap events. Refer to +/// `showsMenuAsPrimaryAction` for more details. +/// +/// TODO: Remove this component (and its usage) in favor of `UIMenu` when the minimum version is bumped to iOS 14. +/// +class MenuSheetViewController: UITableViewController { + + struct MenuItem { + let title: String + let image: UIImage? + let handler: () -> Void + let destructive: Bool + + init(title: String, image: UIImage? = nil, destructive: Bool = false, handler: @escaping () -> Void) { + self.title = title + self.image = image + self.handler = handler + self.destructive = destructive + } + + var foregroundColor: UIColor { + return destructive ? .error : .text + } + } + + private let itemSource: [[MenuItem]] + private let orientation: UIDeviceOrientation // used to track if orientation changes. + + // MARK: Lifecycle + + required init(items: [[MenuItem]]) { + self.itemSource = items + self.orientation = UIDevice.current.orientation + + super.init(style: .plain) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureTable() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + preferredContentSize = CGSize(width: min(tableView.contentSize.width, Constants.maxWidth), height: tableView.contentSize.height) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + // Dismiss the menu when the orientation changes. This mimics the behavior of UIContextMenu/UIMenu. + if UIDevice.current.orientation != orientation { + dismissMenu() + } + } + +} + +// MARK: - Table View + +extension MenuSheetViewController { + + override func numberOfSections(in tableView: UITableView) -> Int { + return itemSource.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard let items = itemSource[safe: section] else { + return 0 + } + return items.count + } + + /// Override separator color in dark mode so it kinda matches the separator color in `UIContextMenu`. + /// With system colors, somehow dark colors won't go darker below the cell's background color. + /// Note that returning nil means falling back to the default behavior. + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard traitCollection.userInterfaceStyle == .dark else { + return nil + } + + let headerView = UIView() + headerView.backgroundColor = Constants.darkSeparatorColor + return headerView + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return section == 0 ? tableView.sectionHeaderHeight : Constants.tableSectionHeight + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let items = itemSource[safe: indexPath.section], + let item = items[safe: indexPath.row] else { + return .init() + } + + let cell = tableView.dequeueReusableCell(withIdentifier: Constants.cellIdentifier, for: indexPath) + cell.tintColor = item.foregroundColor + cell.textLabel?.textColor = item.foregroundColor + cell.textLabel?.setText(item.title) + cell.textLabel?.numberOfLines = 0 + cell.accessoryView = UIImageView(image: item.image?.withTintColor(.text)) + + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + dismissMenu { + guard let items = self.itemSource[safe: indexPath.section], + let item = items[safe: indexPath.row] else { + return + } + + item.handler() + } + } +} + +// MARK: - Private Helpers + +private extension MenuSheetViewController { + struct Constants { + // maximum width follows the approximate width of `UIContextMenu`. + static let maxWidth: CGFloat = 250 + static let tableSectionHeight: CGFloat = 8 + static let darkSeparatorColor = UIColor(fromRGBColorWithRed: 11, green: 11, blue: 11) + static let cellIdentifier = "cell" + } + + func configureTable() { + tableView.register(UITableViewCell.self, forCellReuseIdentifier: Constants.cellIdentifier) + tableView.sectionHeaderHeight = 0 + tableView.bounces = false + + // draw the separators from edge to edge. + tableView.separatorInset = .zero + + // hide separators for the last row. + tableView.tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.frame.width, height: 0)) + } + + func dismissMenu(completion: (() -> Void)? = nil) { + if let controller = popoverPresentationController { + controller.delegate?.presentationControllerWillDismiss?(controller) + } + + dismiss(animated: true) { + defer { + if let controller = self.popoverPresentationController { + controller.delegate?.presentationControllerDidDismiss?(controller) + } + } + completion?() + } + } +} diff --git a/WordPress/Classes/ViewRelated/Views/MultilineButton.swift b/WordPress/Classes/ViewRelated/Views/MultilineButton.swift new file mode 100644 index 000000000000..eb5d67eac0ee --- /dev/null +++ b/WordPress/Classes/ViewRelated/Views/MultilineButton.swift @@ -0,0 +1,20 @@ +import UIKit + +/// A `UIButton` with a multiline title label doesn't update it's height based on the number of lines. +/// +/// The `MultilineButton` custom button calculates it's intrinsic content height based on the title label's height. +/// +class MultilineButton: UIButton { + + override var intrinsicContentSize: CGSize { + + guard let labelSize = titleLabel?.sizeThatFits(CGSize(width: frame.size.width, height: CGFloat.greatestFiniteMagnitude)), + labelSize.height > frame.size.height else { + return super.intrinsicContentSize + } + + let desiredHeight = labelSize.height + contentEdgeInsets.top + contentEdgeInsets.bottom + + return CGSize(width: frame.size.width, height: desiredHeight) + } +} diff --git a/WordPress/Classes/ViewRelated/Views/NoResults.storyboard b/WordPress/Classes/ViewRelated/Views/NoResults.storyboard index d9bb79faa6d4..24cdd18badfd 100644 --- a/WordPress/Classes/ViewRelated/Views/NoResults.storyboard +++ b/WordPress/Classes/ViewRelated/Views/NoResults.storyboard @@ -1,9 +1,9 @@ - + - + @@ -20,10 +20,10 @@ - + - + @@ -34,24 +34,24 @@ - + - + - + - + @@ -59,13 +59,13 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackErrorViewModel.swift b/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackErrorViewModel.swift new file mode 100644 index 000000000000..0fc48005c4dc --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackErrorViewModel.swift @@ -0,0 +1,46 @@ +import Foundation + +protocol JetpackErrorViewModel { + /// The primary icon image + /// If this is nil the image will be hidden + var image: UIImage? { get } + + /// The title for the error description + /// If nil, title will be hidden + var title: String? { get } + + /// The error description text + var description: FormattedStringProvider { get } + + /// The title for the first button + /// If this is nil the button will be hidden + var primaryButtonTitle: String? { get } + + /// The title for the second button + /// If this is nil the button will be hidden + var secondaryButtonTitle: String? { get } + + /// Executes action associated to a tap in the view controller primary button + /// - Parameter viewController: usually the view controller sending the tap + func didTapPrimaryButton(in viewController: UIViewController?) + + /// Executes action associated to a tap in the view controller secondary button + /// - Parameter viewController: usually the view controller sending the tap + func didTapSecondaryButton(in viewController: UIViewController?) +} + +/// Helper struct to define a type as both a regular string and an attributed one +struct FormattedStringProvider { + let stringValue: String + let attributedStringValue: NSAttributedString? + + init(string: String) { + stringValue = string + attributedStringValue = nil + } + + init(attributedString: NSAttributedString) { + attributedStringValue = attributedString + stringValue = attributedString.string + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackNoSitesErrorViewModel.swift b/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackNoSitesErrorViewModel.swift new file mode 100644 index 000000000000..da3485dd61be --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackNoSitesErrorViewModel.swift @@ -0,0 +1,43 @@ +import Foundation + +struct JetpackNoSitesErrorViewModel: JetpackErrorViewModel { + let image: UIImage? = UIImage(named: "jetpack-empty-state-illustration") + var title: String? = Constants.title + var description: FormattedStringProvider = FormattedStringProvider(string: Constants.description) + var primaryButtonTitle: String? = Constants.primaryButtonTitle + var secondaryButtonTitle: String? = Constants.secondaryButtonTitle + + func didTapPrimaryButton(in viewController: UIViewController?) { + guard let url = URL(string: Constants.helpURLString) else { + return + } + + let controller = WebViewControllerFactory.controller(url: url, source: "jetpack_no_sites") + let navController = UINavigationController(rootViewController: controller) + + viewController?.present(navController, animated: true) + } + + func didTapSecondaryButton(in viewController: UIViewController?) { + AccountHelper.logOutDefaultWordPressComAccount() + } + + private struct Constants { + static let title = NSLocalizedString("No Jetpack sites found", + comment: "Title when users have no Jetpack sites.") + + static let description = NSLocalizedString("If you already have a site, you’ll need to install the free Jetpack plugin and connect it to your WordPress.com account.", + comment: "Message explaining that they will need to install Jetpack on one of their sites.") + + + static let primaryButtonTitle = NSLocalizedString("See Instructions", + comment: "Action button linking to instructions for installing Jetpack." + + "Presented when logging in with a site address that does not have a valid Jetpack installation") + + static let secondaryButtonTitle = NSLocalizedString("Try With Another Account", + comment: "Action button that will restart the login flow." + + "Presented when logging in with a site address that does not have a valid Jetpack installation") + + static let helpURLString = "https://jetpack.com/support/getting-started-with-jetpack/" + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackNotFoundErrorViewModel.swift b/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackNotFoundErrorViewModel.swift new file mode 100644 index 000000000000..5aac59c8d6c4 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackNotFoundErrorViewModel.swift @@ -0,0 +1,73 @@ +import UIKit + +struct JetpackNotFoundErrorViewModel: JetpackErrorViewModel { + let title: String? = nil + let image: UIImage? = UIImage(named: "jetpack-empty-state-illustration") + var description: FormattedStringProvider { + let siteName = siteURL + let description = String(format: Constants.description, siteName) + let font: UIFont = JetpackLoginErrorViewController.descriptionFont.semibold() + + let attributedString = NSMutableAttributedString(string: description) + attributedString.applyStylesToMatchesWithPattern(siteName, styles: [.font: font]) + + return FormattedStringProvider(attributedString: attributedString) + } + + var primaryButtonTitle: String? = Constants.primaryButtonTitle + var secondaryButtonTitle: String? = Constants.secondaryButtonTitle + + private let siteURL: String + + init(with siteURL: String?) { + self.siteURL = siteURL?.trimURLScheme() ?? Constants.yourSite + } + + func didTapPrimaryButton(in viewController: UIViewController?) { + guard let url = URL(string: Constants.helpURLString) else { + return + } + + let controller = WebViewControllerFactory.controller(url: url, source: "jetpack_not_found") + let navController = UINavigationController(rootViewController: controller) + + viewController?.present(navController, animated: true) + } + + func didTapSecondaryButton(in viewController: UIViewController?) { + viewController?.navigationController?.popToRootViewController(animated: true) + } + + private struct Constants { + static let yourSite = NSLocalizedString("your site", + comment: "Placeholder for site url, if the url is unknown." + + "Presented when logging in with a site address that does not have a valid Jetpack installation." + + "The error would read: to use this app for your site you'll need...") + + static let description = NSLocalizedString("To use this app for %@ you'll need to have the Jetpack plugin installed and activated.", + comment: "Message explaining that Jetpack needs to be installed for a particular site. " + + "Reads like 'To use this app for example.com you'll need to have...") + + static let primaryButtonTitle = NSLocalizedString("See Instructions", + comment: "Action button linking to instructions for installing Jetpack." + + "Presented when logging in with a site address that does not have a valid Jetpack installation") + + static let secondaryButtonTitle = NSLocalizedString("Try With Another Account", + comment: "Action button that will restart the login flow." + + "Presented when logging in with a site address that does not have a valid Jetpack installation") + + static let helpURLString = "https://jetpack.com/support/getting-started-with-jetpack/" + } +} + +private extension String { + // Removes http:// or https:// + func trimURLScheme() -> String? { + guard let urlComponents = URLComponents(string: self), + let host = urlComponents.host else { + return self + } + + return host + urlComponents.path + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackNotWPErrorViewModel.swift b/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackNotWPErrorViewModel.swift new file mode 100644 index 000000000000..9f1128a788c6 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/Login Error/View Models/JetpackNotWPErrorViewModel.swift @@ -0,0 +1,27 @@ +import Foundation + +struct JetpackNotWPErrorViewModel: JetpackErrorViewModel { + let title: String? = nil + let image: UIImage? = UIImage(named: "jetpack-empty-state-illustration") + var description: FormattedStringProvider = FormattedStringProvider(string: Constants.description) + var primaryButtonTitle: String? = Constants.primaryButtonTitle + var secondaryButtonTitle: String? = nil + + func didTapPrimaryButton(in viewController: UIViewController?) { + viewController?.navigationController?.popToRootViewController(animated: true) + } + + func didTapSecondaryButton(in viewController: UIViewController?) { } + + private struct Constants { + static let description = NSLocalizedString("We were not able to detect a WordPress site at the address you entered." + + " Please make sure WordPress is installed and that you are running" + + " the latest available version.", + comment: "Message explaining that WordPress was not detected.") + + + static let primaryButtonTitle = NSLocalizedString("Try With Another Account", + comment: "Action button that will restart the login flow." + + "Presented when logging in with a site address that does not have a valid Jetpack installation") + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/All done/MigrationDoneViewController.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/All done/MigrationDoneViewController.swift new file mode 100644 index 000000000000..ce01339f36a4 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/All done/MigrationDoneViewController.swift @@ -0,0 +1,41 @@ +import UIKit + +class MigrationDoneViewController: UIViewController { + + private let viewModel: MigrationDoneViewModel + + private let tracker: MigrationAnalyticsTracker + + init(viewModel: MigrationDoneViewModel, tracker: MigrationAnalyticsTracker = .init()) { + self.viewModel = viewModel + self.tracker = tracker + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + self.view = MigrationStepView( + headerView: MigrationHeaderView(configuration: viewModel.configuration.headerConfiguration), + actionsView: MigrationActionsView(configuration: viewModel.configuration.actionsConfiguration), + centerView: MigrationCenterView.deleteWordPress(with: viewModel.configuration.centerViewConfiguration) + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.navigationController?.setNavigationBarHidden(true, animated: animated) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.navigationController?.setNavigationBarHidden(false, animated: animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.tracker.track(.thanksScreenShown) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/All done/MigrationDoneViewModel.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/All done/MigrationDoneViewModel.swift new file mode 100644 index 000000000000..91c792d3993f --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/All done/MigrationDoneViewModel.swift @@ -0,0 +1,19 @@ +class MigrationDoneViewModel { + + let configuration: MigrationStepConfiguration + + init(coordinator: MigrationFlowCoordinator, tracker: MigrationAnalyticsTracker = .init()) { + + let headerConfiguration = MigrationHeaderConfiguration(step: .done) + + let centerViewConfigurartion = MigrationCenterViewConfiguration(step: .done) + + let actionsConfiguration = MigrationActionsViewConfiguration(step: .done, primaryHandler: { [weak coordinator] in + tracker.track(.thanksScreenFinishTapped) + coordinator?.transitionToNextStep() + }) + configuration = MigrationStepConfiguration(headerConfiguration: headerConfiguration, + centerViewConfiguration: centerViewConfigurartion, + actionsConfiguration: actionsConfiguration) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Analytics/MigrationAnalyticsTracker.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Analytics/MigrationAnalyticsTracker.swift new file mode 100644 index 000000000000..1188f0d3cd95 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Analytics/MigrationAnalyticsTracker.swift @@ -0,0 +1,81 @@ +import Foundation + +struct MigrationAnalyticsTracker { + + // MARK: - Track Method + + func track(_ event: MigrationEvent, properties: Properties = [:]) { + let event = AnalyticsEvent(name: event.rawValue, properties: properties) + WPAnalytics.track(event) + } + + // MARK: - Content Export + + func trackContentExportEligibility(eligible: Bool) { + let properties = ["eligible": String(eligible)] + self.track(.contentExportEligibility, properties: properties) + } + + func trackContentExportSucceeded() { + self.track(.contentExportSucceeded) + } + + func trackContentExportFailed(reason: String) { + let properties = ["error_type": reason] + self.track(.contentExportFailed, properties: properties) + } + + // MARK: - Content Import + + func trackContentImportEligibility(params: ContentImportEventParams) { + let properties = [ + "eligible": String(params.eligible), + "featureFlagEnabled": String(params.featureFlagEnabled), + "compatibleWordPressInstalled": String(params.compatibleWordPressInstalled), + "migrationState": String(params.migrationState.rawValue), + "loggedIn": String(params.loggedIn) + ] + self.track(.contentImportEligibility, properties: properties) + } + + func trackContentImportSucceeded() { + /// Refresh the account metadata so subsequent analytics calls are linked to the user. + WPAnalytics.refreshMetadata() + self.track(.contentImportSucceeded) + } + + func trackContentImportFailed(reason: String) { + let properties = ["error_type": reason] + self.track(.contentImportFailed, properties: properties) + } + + struct ContentImportEventParams { + let eligible: Bool + let featureFlagEnabled: Bool + let compatibleWordPressInstalled: Bool + let migrationState: MigrationState + let loggedIn: Bool + } + + // MARK: - WordPress Migration Eligibility + + /// Tracks an event representing the WordPress migratable state. + /// If WordPress is not installed, nothing is tracked. + func trackWordPressMigrationEligibility() { + let state = MigrationAppDetection.getWordPressInstallationState() + switch state { + case .wordPressInstalledAndMigratable: + let properties = ["compatible": "true"] + self.track(.wordPressDetected, properties: properties) + case .wordPressInstalledNotMigratable: + let properties = ["compatible": "false"] + self.track(.wordPressDetected, properties: properties) + default: + break + } + } + + // MARK: - Types + + typealias Properties = [String: String] +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Analytics/MigrationEvent.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Analytics/MigrationEvent.swift new file mode 100644 index 000000000000..4d5124fa1862 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Analytics/MigrationEvent.swift @@ -0,0 +1,53 @@ +import Foundation + +enum MigrationEvent: String { + // Content Export + case contentExportEligibility = "migration_content_export_eligibility" + case contentExportSucceeded = "migration_content_export_succeeded" + case contentExportFailed = "migration_content_export_failed" + + // Content Import + case contentImportEligibility = "migration_content_import_eligibility" + case contentImportSucceeded = "migration_content_import_succeeded" + case contentImportFailed = "migration_content_import_failed" + + // Email + case emailTriggered = "migration_email_triggered" + case emailSent = "migration_email_sent" + case emailFailed = "migration_email_failed" + + // Welcome Screen + case welcomeScreenShown = "migration_welcome_screen_shown" + case welcomeScreenContinueTapped = "migration_welcome_screen_continue_button_tapped" + case welcomeScreenHelpButtonTapped = "migration_welcome_screen_help_button_tapped" + case welcomeScreenAvatarTapped = "migration_welcome_screen_avatar_tapped" + + // Notifications Screen + case notificationsScreenShown = "migration_notifications_screen_shown" + case notificationsScreenContinueTapped = "migration_notifications_screen_continue_button_tapped" + case notificationsScreenDecideLaterButtonTapped = "migration_notifications_screen_decide_later_button_tapped" + case notificationsScreenPermissionGranted = "migration_notifications_screen_permission_granted" + case notificationsScreenPermissionDenied = "migration_notifications_screen_permission_denied" + case notificationsScreenPermissionNotDetermined = "migration_notifications_screen_permission_notDetermined" + + // Thanks Screen + case thanksScreenShown = "migration_thanks_screen_shown" + case thanksScreenFinishTapped = "migration_thanks_screen_finish_button_tapped" + + // Please Delete WordPress Card & Screen + case pleaseDeleteWordPressCardShown = "migration_please_delete_wordpress_card_shown" + case pleaseDeleteWordPressCardHidden = "migration_please_delete_wordpress_card_hidden" + case pleaseDeleteWordPressCardTapped = "migration_please_delete_wordpress_card_tapped" + case pleaseDeleteWordPressScreenShown = "migration_please_delete_wordpress_screen_shown" + case pleaseDeleteWordPressScreenGotItTapped = "migration_please_delete_wordpress_screen_gotit_tapped" + case pleaseDeleteWordPressScreenHelpTapped = "migration_please_delete_wordpress_screen_help_tapped" + case pleaseDeleteWordPressScreenCloseTapped = "migration_please_delete_wordpress_screen_close_tapped" + + // Load WordPress + case loadWordPressScreenShown = "migration_load_wordpress_screen_shown" + case loadWordPressScreenOpenTapped = "migration_load_wordpress_screen_open_tapped" + case loadWordPressScreenNoThanksTapped = "migration_load_wordpress_screen_no_thanks_tapped" + + // WordPress Migratable State + case wordPressDetected = "migration_wordpressapp_detected" +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationAppDetection.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationAppDetection.swift new file mode 100644 index 000000000000..0d244b9dcd99 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationAppDetection.swift @@ -0,0 +1,31 @@ +import Foundation + +enum WordPressInstallationState { + case wordPressNotInstalled + case wordPressInstalledNotMigratable + case wordPressInstalledAndMigratable + + var isWordPressInstalled: Bool { + return self != .wordPressNotInstalled + } +} + +struct MigrationAppDetection { + + static func getWordPressInstallationState() -> WordPressInstallationState { + if UIApplication.shared.canOpen(app: .wordpressMigrationV1) { + return .wordPressInstalledAndMigratable + } + + if UIApplication.shared.canOpen(app: .wordpress) { + return .wordPressInstalledNotMigratable + } + + return .wordPressNotInstalled + } + + static var isCounterpartAppInstalled: Bool { + let scheme: AppScheme = AppConfiguration.isJetpack ? .wordpress : .jetpack + return UIApplication.shared.canOpen(app: scheme) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationAppearance.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationAppearance.swift new file mode 100644 index 000000000000..81598017e483 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationAppearance.swift @@ -0,0 +1,6 @@ +import Foundation + +enum MigrationAppearance { + + static let backgroundColor = UIColor(light: .white, dark: .muriel(color: .jetpackGreen, .shade100)) +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationDependencyContainer.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationDependencyContainer.swift new file mode 100644 index 000000000000..e5715b52ce2a --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationDependencyContainer.swift @@ -0,0 +1,104 @@ +struct MigrationDependencyContainer { + + let migrationCoordinator = MigrationFlowCoordinator() + + func makeInitialViewController() -> UIViewController { + MigrationNavigationController(coordinator: migrationCoordinator, + factory: MigrationViewControllerFactory(coordinator: migrationCoordinator)) + } +} + +struct MigrationViewControllerFactory { + + let coordinator: MigrationFlowCoordinator + + init(coordinator: MigrationFlowCoordinator) { + self.coordinator = coordinator + } + + func viewController(for step: MigrationStep) -> UIViewController? { + switch step { + case .welcome: + return makeWelcomeViewController() + case .notifications: + return makeNotificationsViewController() + case .done: + return makeDoneViewController() + case .dismiss: + return nil + } + } + + func initialViewController() -> UIViewController? { + viewController(for: coordinator.currentStep) + } + + private func makeAccount() -> WPAccount? { + + let context = ContextManager.shared.mainContext + do { + return try WPAccount.lookupDefaultWordPressComAccount(in: context) + } catch { + DDLogError("Account lookup failed with error: \(error)") + return nil + } + } + + // MARK: - View Controllers + + private func makeWelcomeViewModel(handlers: ActionHandlers) -> MigrationWelcomeViewModel { + let primaryHandler = { () -> Void in handlers.primary?() } + let secondaryHandler = { () -> Void in handlers.secondary?() } + + let actions = MigrationActionsViewConfiguration( + step: .welcome, + primaryHandler: primaryHandler, + secondaryHandler: secondaryHandler + ) + + return .init(account: makeAccount(), actions: actions) + } + + private func makeWelcomeViewController() -> UIViewController { + let handlers = ActionHandlers() + let viewModel = makeWelcomeViewModel(handlers: handlers) + + let viewController = MigrationWelcomeViewController(viewModel: viewModel) + handlers.primary = { [weak coordinator] in coordinator?.transitionToNextStep() } + handlers.secondary = makeSupportViewControllerRouter(with: viewController) + + return viewController + } + + private func makeNotificationsViewModel() -> MigrationNotificationsViewModel { + MigrationNotificationsViewModel(coordinator: coordinator) + } + + private func makeNotificationsViewController() -> UIViewController { + MigrationNotificationsViewController(viewModel: makeNotificationsViewModel()) + } + + private func makeDoneViewModel() -> MigrationDoneViewModel { + MigrationDoneViewModel(coordinator: coordinator) + } + + private func makeDoneViewController() -> UIViewController { + MigrationDoneViewController(viewModel: makeDoneViewModel()) + } + + // MARK: - Routers + + private func makeSupportViewControllerRouter(with presenter: UIViewController) -> () -> Void { + return { [weak presenter] in + let destination = SupportTableViewController(configuration: .currentAccountConfiguration(), style: .insetGrouped) + presenter?.present(UINavigationController(rootViewController: destination), animated: true) + } + } + + // MARK: - Types + + private class ActionHandlers { + var primary: (() -> Void)? + var secondary: (() -> Void)? + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationEmailService.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationEmailService.swift new file mode 100644 index 000000000000..b391166a10e5 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationEmailService.swift @@ -0,0 +1,93 @@ +import Foundation + +final class MigrationEmailService { + + // MARK: - Dependencies + + private let api: WordPressComRestApi + private let tracker: MigrationAnalyticsTracker + + // MARK: - Init + + init(api: WordPressComRestApi, tracker: MigrationAnalyticsTracker = .init()) { + self.api = api + self.tracker = tracker + } + + convenience init(account: WPAccount) { + self.init(api: account.wordPressComRestV2Api) + } + + /// Convenience initializer that tries to set the `WordPressComRestApi` property, or fails. + convenience init() throws { + do { + let context = ContextManager.shared.mainContext + guard let account = try WPAccount.lookupDefaultWordPressComAccount(in: context) else { + throw MigrationError.accountNotFound + } + self.init(api: account.wordPressComRestV2Api) + } catch let error { + DDLogError("[\(MigrationError.domain)] Object instantiation failed: \(error.localizedDescription)") + throw error + } + } + + // MARK: - Methods + + func sendMigrationEmail() async throws { + do { + tracker.track(.emailTriggered) + let response = try await sendMigrationEmail(path: Endpoint.migrationEmail) + if !response.success { + throw MigrationError.unsuccessfulResponse + } + tracker.track(.emailSent) + } catch let error { + let properties = ["error_type": error.localizedDescription] + tracker.track(.emailFailed, properties: properties) + DDLogError("[\(MigrationError.domain)] Migration email sending failed: \(error.localizedDescription)") + throw error + } + } + + private func sendMigrationEmail(path: String) async throws -> SendMigrationEmailResponse { + return try await withCheckedThrowingContinuation { continuation in + api.POST(path, parameters: nil) { responseObject, httpResponse in + do { + let decoder = JSONDecoder() + let data = try JSONSerialization.data(withJSONObject: responseObject) + let response = try decoder.decode(SendMigrationEmailResponse.self, from: data) + continuation.resume(returning: response) + } catch let error { + continuation.resume(throwing: error) + } + } failure: { error, httpResponse in + continuation.resume(throwing: error) + } + } + } + + // MARK: - Types + + enum MigrationError: LocalizedError { + case accountNotFound + case unsuccessfulResponse + + var errorDescription: String? { + switch self { + case .accountNotFound: return "Account not found." + case .unsuccessfulResponse: return "Backend returned an unsuccessful response. ( success: false )" + } + } + + static let domain = "MigrationEmailService" + } + + private enum Endpoint { + static let migrationEmail = "/wpcom/v2/mobile/migration" + } + + private struct SendMigrationEmailResponse: Decodable { + let success: Bool + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationState.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationState.swift new file mode 100644 index 000000000000..fbfb4c41d086 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/MigrationState.swift @@ -0,0 +1,7 @@ +import Foundation + +enum MigrationState: String { + case notStarted + case inProgress + case completed +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Navigation/MigrationFlowCoordinator.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Navigation/MigrationFlowCoordinator.swift new file mode 100644 index 000000000000..9a9749c5e9b5 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Navigation/MigrationFlowCoordinator.swift @@ -0,0 +1,83 @@ +import Combine +import UserNotifications + +/// Coordinator for the migration to jetpack flow +final class MigrationFlowCoordinator: ObservableObject { + + // MARK: - Dependencies + + private let migrationEmailService: MigrationEmailService? + private let userPersistentRepository: UserPersistentRepository + + // MARK: - Properties + + // Beware that changes won't be published on the main thread, + // so always make sure to return to the main thread for UI updates + // related to this property. + @Published private(set) var currentStep = MigrationStep.welcome + + // MARK: - Init + + init(migrationEmailService: MigrationEmailService? = try? .init(), + userPersistentRepository: UserPersistentRepository = UserPersistentStoreFactory.instance()) { + self.migrationEmailService = migrationEmailService + self.userPersistentRepository = userPersistentRepository + self.userPersistentRepository.jetpackContentMigrationState = .inProgress + } + + deinit { + if userPersistentRepository.jetpackContentMigrationState != .completed { + self.userPersistentRepository.jetpackContentMigrationState = .notStarted + } + } + + // MARK: - Transitioning Steps + + func transitionToNextStep() { + Task { [weak self] in + guard let self = self, let nextStep = await Self.nextStep(from: currentStep) else { + return + } + self.currentStep = nextStep + self.didTransitionToStep(nextStep) + } + } + + private func didTransitionToStep(_ step: MigrationStep) { + switch step { + case .done: + self.userPersistentRepository.jetpackContentMigrationState = .completed + self.sendMigrationEmail() + default: + break + } + } + + // MARK: - Helpers + + private func sendMigrationEmail() { + Task { [weak self] in + try? await self?.migrationEmailService?.sendMigrationEmail() + } + } + + private static func shouldSkipNotificationsScreen() async -> Bool { + let settings = await UNUserNotificationCenter.current().notificationSettings() + let authStatus = settings.authorizationStatus + return authStatus == .authorized || authStatus == .denied + } + + private static func nextStep(from step: MigrationStep) async -> MigrationStep? { + switch step { + case .welcome: + let shouldSkipNotifications = await shouldSkipNotificationsScreen() + return shouldSkipNotifications ? .done : .notifications + case .notifications: + return .done + case .done: + return .dismiss + case .dismiss: + return nil + } + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Navigation/MigrationNavigationController.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Navigation/MigrationNavigationController.swift new file mode 100644 index 000000000000..a0567d625138 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Navigation/MigrationNavigationController.swift @@ -0,0 +1,80 @@ +import Combine +import UIKit + +class MigrationNavigationController: UINavigationController { + /// Navigation coordinator + private let coordinator: MigrationFlowCoordinator + /// The view controller factory used to push view controllers on the stack + private let factory: MigrationViewControllerFactory + /// Receives state changes to set the navigation stack accordingly + private var cancellable: AnyCancellable? + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if let presentedViewController { + return presentedViewController.supportedInterfaceOrientations + } + if WPDeviceIdentification.isiPhone() { + return .portrait + } else { + return .allButUpsideDown + } + } + + // Force portrait orientation for migration view controllers only + override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { + if let presentedViewController { + return presentedViewController.preferredInterfaceOrientationForPresentation + } + return .portrait + } + + init(coordinator: MigrationFlowCoordinator, factory: MigrationViewControllerFactory) { + self.coordinator = coordinator + self.factory = factory + if let initialViewController = factory.initialViewController() { + super.init(rootViewController: initialViewController) + } else { + super.init(nibName: nil, bundle: nil) + } + configure() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configure() { + let navigationBar = self.navigationBar + let standardAppearance = UINavigationBarAppearance() + standardAppearance.configureWithDefaultBackground() + let scrollEdgeAppearance = UINavigationBarAppearance() + scrollEdgeAppearance.configureWithTransparentBackground() + navigationBar.standardAppearance = standardAppearance + navigationBar.scrollEdgeAppearance = scrollEdgeAppearance + navigationBar.compactAppearance = standardAppearance + if #available(iOS 15.0, *) { + navigationBar.compactScrollEdgeAppearance = scrollEdgeAppearance + } + navigationBar.isTranslucent = true + listenForStateChanges() + } + + private func listenForStateChanges() { + cancellable = coordinator.$currentStep + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] step in + self?.updateStack(for: step) + } + } + + private func updateStack(for step: MigrationStep) { + // sets the stack for the next navigation step, if there's one + guard let viewController = factory.viewController(for: step) else { + return + } + // if we want to support backwards navigation, we need to set + // also the previous steps in the stack + setViewControllers([viewController], animated: true) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Navigation/MigrationStep.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Navigation/MigrationStep.swift new file mode 100644 index 000000000000..3edc828bb24b --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Navigation/MigrationStep.swift @@ -0,0 +1,8 @@ +import Foundation + +enum MigrationStep: String { + case welcome + case notifications + case done + case dismiss +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationActionsConfiguration.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationActionsConfiguration.swift new file mode 100644 index 000000000000..3eea6a401bac --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationActionsConfiguration.swift @@ -0,0 +1,60 @@ +struct MigrationActionsViewConfiguration { + + let primaryTitle: String? + let secondaryTitle: String? + let primaryHandler: (() -> Void)? + let secondaryHandler: (() -> Void)? +} + +extension MigrationActionsViewConfiguration { + + init(step: MigrationStep, primaryHandler: (() -> Void)? = nil, secondaryHandler: (() -> Void)? = nil) { + self.primaryHandler = primaryHandler + self.secondaryHandler = secondaryHandler + self.primaryTitle = Appearance.primaryTitle(for: step) + self.secondaryTitle = Appearance.secondaryTitle(for: step) + } +} + +private extension MigrationActionsViewConfiguration { + + enum Appearance { + + static func primaryTitle(for step: MigrationStep) -> String? { + switch step { + case .welcome, .notifications: + return Appearance.defaultPrimaryTitle + case .done: + return Appearance.donePrimaryTitle + case.dismiss: + return nil + } + } + + static func secondaryTitle(for step: MigrationStep) -> String? { + switch step { + case .welcome: + return Appearance.welcomeSecondaryTitle + case .notifications: + return Appearance.notificationsSecondaryTitle + default: + return nil + } + } + + static let defaultPrimaryTitle = NSLocalizedString("Continue", + value: "Continue", + comment: "The primary button title in the migration welcome and notifications screens.") + + static let donePrimaryTitle = NSLocalizedString("migration.done.actions.primary.title", + value: "Finish", + comment: "Primary button title in the migration done screen.") + + static let welcomeSecondaryTitle = NSLocalizedString("Need help?", + comment: "The secondary button title in the migration welcome screen") + + static let notificationsSecondaryTitle = NSLocalizedString("migration.notifications.actions.secondary.title", + value: "Decide later", + comment: "Secondary button title in the migration notifications screen.") + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationCenterViewConfiguration.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationCenterViewConfiguration.swift new file mode 100644 index 000000000000..2e93be0764a7 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationCenterViewConfiguration.swift @@ -0,0 +1,65 @@ +struct MigrationCenterViewConfiguration { + + let attributedText: NSAttributedString +} + +extension MigrationCenterViewConfiguration { + + init(step: MigrationStep) { + self.attributedText = Appearance.highlightText(Appearance.highlightedText(for: step), inString: Appearance.text(for: step)) + } +} + +private extension MigrationCenterViewConfiguration { + + enum Appearance { + + static let notificationsText = NSLocalizedString("migration.notifications.footer", + value: "When the alert appears tap Allow to continue receiving all your WordPress notifications.", + comment: "Footer for the migration notifications screen.") + static let notificationsHighlightedText = NSLocalizedString("migration.notifications.footer.highlighted", + value: "Allow", + comment: "Highlighted text in the footer of the migration notifications screen.") + + static let doneText = NSLocalizedString("migration.done.footer", + value: "We recommend uninstalling the WordPress app on your device to avoid data conflicts.", + comment: "Footer for the migration done screen.") + static let doneHighlightedText = NSLocalizedString("migration.done.footer.highlighted", + value: "uninstalling the WordPress app", + comment: "Highlighted text in the footer of the migration done screen.") + + static func text(for step: MigrationStep) -> String { + switch step { + case .notifications: + return notificationsText + case .done: + return doneText + default: + return "" + } + } + + static func highlightedText(for step: MigrationStep) -> String { + switch step { + case .notifications: + return notificationsHighlightedText + case .done: + return doneHighlightedText + default: + return "" + } + } + + static func highlightText(_ subString: String, inString: String) -> NSAttributedString { + let attributedString = NSMutableAttributedString(string: inString) + + guard let subStringRange = inString.nsRange(of: subString) else { + return attributedString + } + + attributedString.addAttributes([.font: WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .bold)], + range: subStringRange) + return attributedString + } + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationHeaderConfiguration.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationHeaderConfiguration.swift new file mode 100644 index 000000000000..380e9c84972b --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationHeaderConfiguration.swift @@ -0,0 +1,118 @@ +struct MigrationHeaderConfiguration { + + let title: String? + let image: UIImage? + let primaryDescription: String? + let secondaryDescription: String? +} + +extension MigrationHeaderConfiguration { + + init(step: MigrationStep, multiSite: Bool = false) { + image = Appearance.image(for: step) + title = Appearance.title(for: step) + primaryDescription = Appearance.primaryDescription(for: step) + secondaryDescription = Appearance.secondaryDescription(for: step, multiSite: multiSite) + } +} + +private extension MigrationHeaderConfiguration { + + enum Appearance { + static func image(for step: MigrationStep) -> UIImage? { + switch step { + case .welcome: + return UIImage(named: "wp-migration-welcome") + case .notifications: + return UIImage(named: "wp-migration-notifications") + case .done: + return UIImage(named: "wp-migration-done") + case .dismiss: + return nil + } + } + + static func title(for step: MigrationStep) -> String? { + switch step { + case .welcome: + return welcomeTitle + case .notifications: + return notificationsTitle + case .done: + return doneTitle + case .dismiss: + return nil + } + } + + static func primaryDescription(for step: MigrationStep) -> String? { + switch step { + case .welcome: + return welcomePrimaryDescription + case .notifications: + return notificationsPrimaryDescription + case .done: + return donePrimaryDescription + case .dismiss: + return nil + } + } + + static func secondaryDescription(for step: MigrationStep, multiSite: Bool = false) -> String? { + switch step { + case .welcome: + return welcomeSecondaryDescription(plural: multiSite) + case .notifications: + return JetpackNotificationMigrationService.shared.isMigrationSupported ? notificationsSecondaryDescription : nil + case .done: + return nil + case .dismiss: + return nil + } + } + + static let welcomeTitle = NSLocalizedString("migration.welcome.title", + value: "Welcome to Jetpack!", + comment: "The title in the migration welcome screen") + + static let notificationsTitle = NSLocalizedString("migration.notifications.title", + value: "Allow notifications to keep up with your site", + comment: "Title of the migration notifications screen.") + + static let doneTitle = NSLocalizedString("migration.done.title", + value: "Thanks for switching to Jetpack!", + comment: "Title of the migration done screen.") + + static let welcomePrimaryDescription = NSLocalizedString("migration.welcome.primaryDescription", + value: "It looks like you’re switching from the WordPress app.", + comment: "The primary description in the migration welcome screen") + + static let notificationsPrimaryDescription = NSLocalizedString("migration.notifications.primaryDescription", + value: "You’ll get all the same notifications but now they’ll come from the Jetpack app.", + comment: "Primary description in the migration notifications screen.") + + static let donePrimaryDescription = NSLocalizedString("migration.done.primaryDescription", + value: "We’ve transferred all your data and settings. Everything is right where you left it.", + comment: "Primary description in the migration done screen.") + + static let notificationsSecondaryDescription = NSLocalizedString("migration.notifications.secondaryDescription", + value: "We’ll disable notifications for the WordPress app.", + comment: "Secondary description in the migration notifications screen") + + static func welcomeSecondaryDescription(plural: Bool) -> String { + if plural { + return NSLocalizedString( + "migration.welcome.secondaryDescription.plural", + value: "We found your sites. Continue to transfer all your data and sign in to Jetpack automatically.", + comment: "The plural form of the secondary description in the migration welcome screen" + ) + } else { + return NSLocalizedString( + "migration.welcome.secondaryDescription.singular", + value: "We found your site. Continue to transfer all your data and sign in to Jetpack automatically.", + comment: "The singular form of the secondary description in the migration welcome screen" + ) + } + } + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationStepConfiguration.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationStepConfiguration.swift new file mode 100644 index 000000000000..8727001e7592 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/Configuration/MigrationStepConfiguration.swift @@ -0,0 +1,5 @@ +struct MigrationStepConfiguration { + let headerConfiguration: MigrationHeaderConfiguration + let centerViewConfiguration: MigrationCenterViewConfiguration? + let actionsConfiguration: MigrationActionsViewConfiguration +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationActionsView.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationActionsView.swift new file mode 100644 index 000000000000..94e9e1c52bc3 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationActionsView.swift @@ -0,0 +1,142 @@ +import UIKit +import WordPressUI + +final class MigrationActionsView: UIView { + + // MARK: - Views + + private let configuration: MigrationActionsViewConfiguration + + let primaryButton: UIButton = MigrationActionsView.primaryButton() + + let secondaryButton: UIButton = MigrationActionsView.secondaryButton() + + private let visualEffectView: UIVisualEffectView = { + let effect = UIBlurEffect(style: .regular) + let view = UIVisualEffectView(effect: effect) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let separatorView: UIView = { + let view = UIView() + view.backgroundColor = UIColor.separator + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = Constants.spacing + return stackView + }() + + // MARK: - Tap Handlers + + lazy var primaryHandler: (MigrationActionsViewConfiguration) -> Void = { + return { configuration in + configuration.primaryHandler?() + } + }() + + lazy var secondaryHandler: (MigrationActionsViewConfiguration) -> Void = { + return { configuration in + configuration.secondaryHandler?() + } + }() + + // MARK: - Init + + init(configuration: MigrationActionsViewConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + self.setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + // Set properties + self.directionalLayoutMargins = Constants.insets + + // Layout visual effect view + self.addSubview(visualEffectView) + self.pinSubviewToAllEdges(visualEffectView) + + // Layout separator view + self.addSubview(separatorView) + NSLayoutConstraint.activate([ + separatorView.topAnchor.constraint(equalTo: topAnchor), + separatorView.leadingAnchor.constraint(equalTo: leadingAnchor), + separatorView.trailingAnchor.constraint(equalTo: trailingAnchor), + separatorView.heightAnchor.constraint(equalToConstant: Constants.separatorHeight) + ]) + + // Layout buttons + self.stackView.addArrangedSubviews([primaryButton, secondaryButton]) + self.addSubview(stackView) + configureButtons() + NSLayoutConstraint.activate([ + primaryButton.leadingAnchor.constraint(equalTo: stackView.leadingAnchor), + primaryButton.trailingAnchor.constraint(equalTo: stackView.trailingAnchor), + stackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + stackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) + ]) + } + + private func configureButtons() { + primaryButton.setTitle(configuration.primaryTitle, for: .normal) + primaryButton.addTarget(self, action: #selector(didTapPrimaryButton), for: .touchUpInside) + secondaryButton.setTitle(configuration.secondaryTitle, for: .normal) + secondaryButton.addTarget(self, action: #selector(didTapSecondaryButton), for: .touchUpInside) + } + + @objc private func didTapPrimaryButton() { + primaryHandler(configuration) + } + + @objc private func didTapSecondaryButton() { + secondaryHandler(configuration) + } + + // MARK: - Button Factory + + private static func primaryButton() -> UIButton { + let button = FancyButton() + button.isPrimary = true + return button + } + + private static func secondaryButton() -> UIButton { + let button = UIButton() + let font = Constants.secondaryButtonFont + let color = Constants.secondaryButtonColor + button.setTitleColor(color, for: .normal) + button.titleLabel?.font = font + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + } + + // MARK: - Configuring Intrinsic Size + + override var intrinsicContentSize: CGSize { + return .init(width: UIView.noIntrinsicMetric, height: stackView.intrinsicContentSize.height) + } + + // MARK: - Constants + + private struct Constants { + static let separatorHeight = CGFloat(0.5) + static let insets = NSDirectionalEdgeInsets(top: 20, leading: 30, bottom: 20, trailing: 30) + static let spacing = CGFloat(10) + static let secondaryButtonFont = WPStyleGuide.fontForTextStyle(.headline, fontWeight: .regular) + static let secondaryButtonColor = UIColor.muriel(color: .jetpackGreen, .shade50) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationCenterView.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationCenterView.swift new file mode 100644 index 000000000000..644e05ae5743 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationCenterView.swift @@ -0,0 +1,60 @@ +import UIKit + +/// A view with an injected content and a description withl highlighted words +class MigrationCenterView: UIView { + + private let configuration: MigrationCenterViewConfiguration? + + // MARK: - Views + + private let contentView: UIView + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.font = WPStyleGuide.fontForTextStyle(.footnote, fontWeight: .regular) + if let configuration { + label.attributedText = configuration.attributedText + } + label.textAlignment = .center + label.textColor = Appearance.descriptionTextColor + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + return label + }() + + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [contentView, descriptionLabel]) + stackView.axis = .vertical + stackView.alignment = .center + stackView.setCustomSpacing(Appearance.fakeAlertToDescriptionSpacing, after: contentView) + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + // MARK: - Init + + init(contentView: UIView, configuration: MigrationCenterViewConfiguration?) { + self.contentView = contentView + self.configuration = configuration + super.init(frame: .zero) + addSubview(mainStackView) + pinSubviewToAllEdges(mainStackView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + static func deleteWordPress(with configuration: MigrationCenterViewConfiguration?) -> MigrationCenterView { + let imageView = UIImageView(image: UIImage(named: "wp-migration-icon-with-badge")) + imageView.contentMode = .scaleAspectFit + return .init(contentView: imageView, configuration: configuration) + } + + // MARK: - Types + + private enum Appearance { + static let fakeAlertToDescriptionSpacing: CGFloat = 20 + static let descriptionTextColor = UIColor(light: .muriel(color: .gray, .shade50), dark: .muriel(color: .gray, .shade10)) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationHeaderView.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationHeaderView.swift new file mode 100644 index 000000000000..a29b37be4d6b --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationHeaderView.swift @@ -0,0 +1,104 @@ +import UIKit + +final class MigrationHeaderView: UIView { + + // MARK: - Views + + let imageView = UIImageView() + + private let configuration: MigrationHeaderConfiguration + + let titleLabel: UILabel = { + let label = UILabel() + label.font = Constants.titleFont + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + return label + }() + + let primaryDescriptionLabel: UILabel = { + let label = UILabel() + label.font = Constants.primaryDescriptionFont + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + return label + }() + + let secondaryDescriptionLabel: UILabel = { + let label = UILabel() + label.font = Constants.secondaryDescriptionFont + label.numberOfLines = 0 + label.textColor = Constants.secondaryTextColor + label.adjustsFontForContentSizeCategory = true + return label + }() + + // MARK: - Init + + init(configuration: MigrationHeaderConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + self.setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + // Set subviews + let labelsStackView = verticalStackView(arrangedSubviews: [titleLabel, primaryDescriptionLabel, secondaryDescriptionLabel]) + labelsStackView.translatesAutoresizingMaskIntoConstraints = false + labelsStackView.spacing = Constants.labelsSpacing + let mainStackView = verticalStackView(arrangedSubviews: [imageView, labelsStackView]) + mainStackView.setCustomSpacing(Constants.spacing, after: imageView) + mainStackView.alignment = .leading + mainStackView.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(mainStackView) + NSLayoutConstraint.activate([ + labelsStackView.widthAnchor.constraint(equalTo: mainStackView.widthAnchor), + mainStackView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor), + mainStackView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + mainStackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + mainStackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) + ]) + configureAppearance() + } + + // MARK: - Views Factory + + private func verticalStackView(arrangedSubviews: [UIView]) -> UIStackView { + let stackView = UIStackView(arrangedSubviews: arrangedSubviews) + stackView.axis = .vertical + return stackView + } + + private func configureAppearance() { + // Set image and labels + imageView.image = configuration.image + titleLabel.text = configuration.title + primaryDescriptionLabel.text = configuration.primaryDescription + secondaryDescriptionLabel.text = configuration.secondaryDescription + + // Hide image and labels if they're empty + imageView.isHidden = imageView.image == nil + [titleLabel, primaryDescriptionLabel, secondaryDescriptionLabel].forEach { label in + label.isHidden = label.text?.isEmpty ?? true + } + } + + // MARK: - Types + + private struct Constants { + /// Spacing of the top most stack view + static let spacing: CGFloat = 30 + + /// Spacing of the labels stack view + static let labelsSpacing: CGFloat = 20 + + static let titleFont: UIFont = WPStyleGuide.fontForTextStyle(.largeTitle, fontWeight: .bold) + static let primaryDescriptionFont: UIFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + static let secondaryDescriptionFont: UIFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .regular) + static let secondaryTextColor = UIColor(light: .muriel(color: .gray, .shade50), dark: .muriel(color: .gray, .shade10)) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationStepView.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationStepView.swift new file mode 100644 index 000000000000..fb5fc4876dab --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Common/Views/MigrationStepView.swift @@ -0,0 +1,146 @@ +import UIKit + +class MigrationStepView: UIView { + + // MARK: - Configuration + + var additionalContentInset = UIEdgeInsets( + top: Constants.topContentInset, + left: 0, + bottom: Constants.additionalBottomContentInset, + right: 0 + ) + + // MARK: - Views + + private let headerView: MigrationHeaderView + private let centerView: UIView + private let actionsView: MigrationActionsView + + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [headerView, centerView, UIView()]) + stackView.axis = .vertical + stackView.distribution = .equalSpacing + stackView.directionalLayoutMargins = Constants.mainStackViewMargins + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.isLayoutMarginsRelativeArrangement = true + stackView.setCustomSpacing(Constants.stackViewSpacing, after: headerView) + return stackView + }() + + private lazy var contentView: UIView = { + let contentView = UIView() + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(mainStackView) + return contentView + }() + + private lazy var mainScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentView) + return scrollView + }() + + private lazy var minContentHeightConstraint: NSLayoutConstraint = { + return contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 0) + }() + + // MARK: - Init + + init(headerView: MigrationHeaderView, + actionsView: MigrationActionsView, + centerView: UIView) { + self.headerView = headerView + self.centerView = centerView + centerView.translatesAutoresizingMaskIntoConstraints = false + self.actionsView = actionsView + headerView.directionalLayoutMargins = .zero + actionsView.translatesAutoresizingMaskIntoConstraints = false + super.init(frame: .zero) + backgroundColor = MigrationAppearance.backgroundColor + addSubview(mainScrollView) + addSubview(actionsView) + activateConstraints() + } + + private func activateConstraints() { + contentView.pinSubviewToAllEdges(mainStackView) + minContentHeightConstraint.isActive = true + + NSLayoutConstraint.activate([ + mainScrollView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + mainScrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + mainScrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + mainScrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + mainScrollView.pinSubviewToAllEdges(contentView) + + NSLayoutConstraint.activate([ + contentView.widthAnchor.constraint(equalTo: widthAnchor), + actionsView.leadingAnchor.constraint(equalTo: leadingAnchor), + actionsView.trailingAnchor.constraint(equalTo: trailingAnchor), + actionsView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Layout Subviews + + override func layoutSubviews() { + super.layoutSubviews() + self.layoutMainScrollView() + self.layoutContentView() + } + + private func layoutMainScrollView() { + let bottomInset = actionsView.frame.size.height - safeAreaInsets.bottom + self.mainScrollView.contentInset = .init( + top: additionalContentInset.top, + left: additionalContentInset.left, + bottom: bottomInset + additionalContentInset.bottom, + right: additionalContentInset.right + ) + self.mainScrollView.verticalScrollIndicatorInsets.bottom = bottomInset + self.mainScrollView.setNeedsLayout() + self.mainScrollView.layoutIfNeeded() + } + + private func layoutContentView() { + self.minContentHeightConstraint.constant = 0 + let contentViewHeight = contentView.systemLayoutSizeFitting( + .init(width: bounds.width, height: UIView.noIntrinsicMetric), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ).height + let scrollViewHeight = mainScrollView.frame.height + let scrollViewInsets = mainScrollView.adjustedContentInset + let visibleHeight = scrollViewHeight - (scrollViewInsets.top + scrollViewInsets.bottom) + if contentViewHeight < visibleHeight { + self.minContentHeightConstraint.constant = visibleHeight + } + } + + // MARK: - Types + + private enum Constants { + /// Adds space between the content bottom edge and actions sheet top edge. + /// + /// Bottom inset is added to the `scrollView` so the content is not covered by the Actions Sheet view. + /// The value of the bottom inset is computed in `layoutSubviews`. + static let additionalBottomContentInset: CGFloat = 10 + + /// Adds top padding to the `scrollView`. + static let topContentInset: CGFloat = UINavigationBar().intrinsicContentSize.height + + // Main stack view spacing. + static let stackViewSpacing: CGFloat = 20 + + // Adds margins to the main sack view. + static let mainStackViewMargins = NSDirectionalEdgeInsets(top: 0, leading: 30, bottom: 0, trailing: 30) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Delete WordPress/MigrationDeleteWordPressViewController.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Delete WordPress/MigrationDeleteWordPressViewController.swift new file mode 100644 index 000000000000..82a7472e99fe --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Delete WordPress/MigrationDeleteWordPressViewController.swift @@ -0,0 +1,95 @@ +import UIKit + +final class MigrationDeleteWordPressViewController: UIViewController { + + // MARK: - Dependencies + + private let viewModel: MigrationDeleteWordPressViewModel + + private let tracker: MigrationAnalyticsTracker + + // MARK: - Init + + init(viewModel: MigrationDeleteWordPressViewModel, tracker: MigrationAnalyticsTracker = .init()) { + self.viewModel = viewModel + self.tracker = tracker + super.init(nibName: nil, bundle: nil) + } + + convenience init() { + let actions = MigrationDeleteWordPressViewModel.Actions() + self.init(viewModel: MigrationDeleteWordPressViewModel(actions: actions)) + actions.primary = { [weak self] in + self?.primaryButtonTapped() + } + actions.secondary = { [weak self] in + self?.secondaryButtonTapped() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func loadView() { + let migrationView = MigrationStepView( + headerView: MigrationHeaderView(configuration: viewModel.header), + actionsView: MigrationActionsView(configuration: viewModel.actions), + centerView: MigrationCenterView.deleteWordPress(with: viewModel.content) + ) + migrationView.additionalContentInset.top = 0 + self.view = migrationView + } + + override func viewDidLoad() { + super.viewDidLoad() + self.setupNavigationBar() + self.setupDismissButton() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.tracker.track(.pleaseDeleteWordPressScreenShown) + } + + // MARK: - Setup + + private func setupNavigationBar() { + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundColor = MigrationAppearance.backgroundColor + navigationItem.standardAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + navigationItem.compactAppearance = appearance + if #available(iOS 15.0, *) { + navigationItem.compactScrollEdgeAppearance = appearance + } + } + + private func setupDismissButton() { + let closeButton = UIButton.makeCloseButton() + closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) + let item = UIBarButtonItem(customView: closeButton) + self.navigationItem.rightBarButtonItem = item + } + + // MARK: - User Interaction + + @objc private func closeButtonTapped() { + self.tracker.track(.pleaseDeleteWordPressScreenCloseTapped) + self.dismiss(animated: true) + } + + private func primaryButtonTapped() { + self.tracker.track(.pleaseDeleteWordPressScreenGotItTapped) + self.dismiss(animated: true) + } + + private func secondaryButtonTapped() { + self.tracker.track(.pleaseDeleteWordPressScreenHelpTapped) + let destination = SupportTableViewController() + self.present(UINavigationController(rootViewController: destination), animated: true) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Delete WordPress/MigrationDeleteWordPressViewModel.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Delete WordPress/MigrationDeleteWordPressViewModel.swift new file mode 100644 index 000000000000..26a2612fe6b0 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Delete WordPress/MigrationDeleteWordPressViewModel.swift @@ -0,0 +1,58 @@ +import Foundation + +final class MigrationDeleteWordPressViewModel { + + // MARK: - Configuration + + let header: MigrationHeaderConfiguration + let content: MigrationCenterViewConfiguration + let actions: MigrationActionsViewConfiguration + + // MARK: - Init + + init(actions: Actions) { + self.header = .init( + title: Strings.title, + image: UIImage(named: "wp-migration-welcome"), + primaryDescription: Strings.description, + secondaryDescription: nil + ) + self.content = .init(step: .done) + self.actions = .init( + primaryTitle: Strings.primaryAction, + secondaryTitle: Strings.secondaryAction, + primaryHandler: { actions.primary?() }, + secondaryHandler: { actions.secondary?() } + ) + } + + // MARK: - Types + + enum Strings { + static let title = NSLocalizedString( + "migration.deleteWordpress.title", + value: "You no longer need the WordPress app on your device", + comment: "The title in the Delete WordPress screen" + ) + static let description = NSLocalizedString( + "migration.deleteWordpress.description", + value: "It looks like you still have the WordPress app installed.", + comment: "The description in the Delete WordPress screen" + ) + static let primaryAction = NSLocalizedString( + "migration.deleteWordpress.primaryButton", + value: "Got it", + comment: "The primary button title in the Delete WordPress screen" + ) + static let secondaryAction = NSLocalizedString( + "migration.deleteWordpress.secondaryButton", + value: "Need help?", + comment: "The secondary button title in the Delete WordPress screen" + ) + } + + class Actions { + var primary: (() -> Void)? + var secondary: (() -> Void)? + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Delete WordPress/UIButton+Dismiss.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Delete WordPress/UIButton+Dismiss.swift new file mode 100644 index 000000000000..2cc0a37f7fdd --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Delete WordPress/UIButton+Dismiss.swift @@ -0,0 +1,43 @@ +import UIKit + +extension UIButton { + + private static var closeButtonImage: UIImage { + let fontForSystemImage = UIFont.systemFont(ofSize: Metrics.closeButtonRadius) + let configuration = UIImage.SymbolConfiguration(font: fontForSystemImage) + + // fallback to the gridicon if for any reason the system image fails to render + return UIImage(systemName: Constants.closeButtonSystemName, withConfiguration: configuration) ?? + UIImage.gridicon(.crossCircle, size: CGSize(width: Metrics.closeButtonRadius, height: Metrics.closeButtonRadius)) + } + + static func makeCloseButton() -> UIButton { + let closeButton = CircularImageButton() + + closeButton.setImage(closeButtonImage, for: .normal) + closeButton.tintColor = Colors.closeButtonTintColor + closeButton.setImageBackgroundColor(UIColor(light: .black, dark: .white)) + + NSLayoutConstraint.activate([ + closeButton.widthAnchor.constraint(equalToConstant: Metrics.closeButtonRadius), + closeButton.heightAnchor.constraint(equalTo: closeButton.widthAnchor) + ]) + + return closeButton + } + + private enum Constants { + static let closeButtonSystemName = "xmark.circle.fill" + } + + private enum Metrics { + static let closeButtonRadius: CGFloat = 30 + } + + private enum Colors { + static let closeButtonTintColor = UIColor( + light: .muriel(color: .gray, .shade5), + dark: .muriel(color: .jetpackGreen, .shade90) + ) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Load WordPress/MigrationLoadWordPressViewController.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Load WordPress/MigrationLoadWordPressViewController.swift new file mode 100644 index 000000000000..0fa9c7de23e2 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Load WordPress/MigrationLoadWordPressViewController.swift @@ -0,0 +1,42 @@ +import UIKit + +class MigrationLoadWordPressViewController: UIViewController { + + // MARK: - Dependencies + + private let viewModel: MigrationLoadWordPressViewModel + private let tracker: MigrationAnalyticsTracker + + // MARK: - Init + + init(viewModel: MigrationLoadWordPressViewModel, tracker: MigrationAnalyticsTracker = .init()) { + self.viewModel = viewModel + self.tracker = tracker + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func loadView() { + let migrationView = MigrationStepView( + headerView: MigrationHeaderView(configuration: viewModel.header), + actionsView: MigrationActionsView(configuration: viewModel.actions), + centerView: UIView() + ) + self.view = migrationView + } + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = MigrationAppearance.backgroundColor + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.tracker.track(.loadWordPressScreenShown) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Load WordPress/MigrationLoadWordPressViewModel.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Load WordPress/MigrationLoadWordPressViewModel.swift new file mode 100644 index 000000000000..f537e2e6e573 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Load WordPress/MigrationLoadWordPressViewModel.swift @@ -0,0 +1,63 @@ +import Foundation + +final class MigrationLoadWordPressViewModel { + + // MARK: - Configuration + + let header: MigrationHeaderConfiguration + let content: MigrationCenterViewConfiguration + let actions: MigrationActionsViewConfiguration + + // MARK: - Init + + init(actions: Actions) { + self.header = .init( + title: Strings.title, + image: UIImage(named: "wp-migration-welcome"), + primaryDescription: Strings.description, + secondaryDescription: Strings.secondaryDescription + ) + self.content = .init(step: .done) + self.actions = .init( + primaryTitle: Strings.primaryAction, + secondaryTitle: Strings.secondaryAction, + primaryHandler: { actions.primary?() }, + secondaryHandler: { actions.secondary?() } + ) + } + + // MARK: - Types + + enum Strings { + static let title = NSLocalizedString( + "migration.loadWordpress.title", + value: "Welcome to Jetpack!", + comment: "The title in the Load WordPress screen" + ) + static let description = NSLocalizedString( + "migration.loadWordpress.description", + value: "It looks like you have the WordPress app installed.", + comment: "The description in the Load WordPress screen" + ) + static let secondaryDescription = NSLocalizedString( + "migration.loadWordpress.secondaryDescription", + value: "Would you like to transfer your data from the WordPress app and sign in automatically?", + comment: "The secondary description in the Load WordPress screen" + ) + static let primaryAction = NSLocalizedString( + "migration.loadWordpress.primaryButton", + value: "Open WordPress", + comment: "The primary button title in the Load WordPress screen" + ) + static let secondaryAction = NSLocalizedString( + "migration.loadWordpress.secondaryButton", + value: "No thanks", + comment: "The secondary button title in the Load WordPress screen" + ) + } + + class Actions { + var primary: (() -> Void)? + var secondary: (() -> Void)? + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Notifications Permission/MigrationNotificationsCenterView.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Notifications Permission/MigrationNotificationsCenterView.swift new file mode 100644 index 000000000000..7b363b3c8089 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Notifications Permission/MigrationNotificationsCenterView.swift @@ -0,0 +1,39 @@ +import UIKit + +class MigrationNotificationsCenterView: UIView { + + private lazy var explainerImageView: UIImageView = { + let imageView = UIImageView(image: Appearance.explainerImage(for: traitCollection.layoutDirection)) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return imageView + }() + + init() { + super.init(frame: .zero) + addSubview(explainerImageView) + pinSubviewToAllEdges(explainerImageView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + guard traitCollection.layoutDirection != previousTraitCollection?.layoutDirection else { + return + } + // probably an edge case, but if users change language direction, then update the fake alert + explainerImageView.image = Appearance.explainerImage(for: traitCollection.layoutDirection) + } + + private enum Appearance { + + static func explainerImage(for textDirection: UITraitEnvironmentLayoutDirection) -> UIImage? { + let imageName = textDirection == .rightToLeft ? "wp-migration-notifications-explainer-rtl" : "wp-migration-notifications-explainer-ltr" + return UIImage(named: imageName) + } + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Notifications Permission/MigrationNotificationsViewController.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Notifications Permission/MigrationNotificationsViewController.swift new file mode 100644 index 000000000000..089356f107b9 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Notifications Permission/MigrationNotificationsViewController.swift @@ -0,0 +1,44 @@ +import UIKit + +class MigrationNotificationsViewController: UIViewController { + + private let viewModel: MigrationNotificationsViewModel + + private let tracker: MigrationAnalyticsTracker + + init(viewModel: MigrationNotificationsViewModel, tracker: MigrationAnalyticsTracker = .init()) { + self.viewModel = viewModel + self.tracker = tracker + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let centerContentView = MigrationNotificationsCenterView() + let centerView = MigrationCenterView(contentView: centerContentView, + configuration: viewModel.configuration.centerViewConfiguration) + + view = MigrationStepView(headerView: MigrationHeaderView(configuration: viewModel.configuration.headerConfiguration), + actionsView: MigrationActionsView(configuration: viewModel.configuration.actionsConfiguration), + centerView: centerView) + + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.navigationController?.setNavigationBarHidden(true, animated: animated) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.navigationController?.setNavigationBarHidden(false, animated: animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.tracker.track(.notificationsScreenShown) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Notifications Permission/MigrationNotificationsViewModel.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Notifications Permission/MigrationNotificationsViewModel.swift new file mode 100644 index 000000000000..8a0689f71f72 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Notifications Permission/MigrationNotificationsViewModel.swift @@ -0,0 +1,42 @@ +import Foundation + +class MigrationNotificationsViewModel { + + let configuration: MigrationStepConfiguration + + init(coordinator: MigrationFlowCoordinator, tracker: MigrationAnalyticsTracker = .init()) { + let headerConfiguration = MigrationHeaderConfiguration(step: .notifications) + let centerViewConfigurartion = MigrationCenterViewConfiguration(step: .notifications) + + let primaryHandler = { [weak coordinator] in + tracker.track(.notificationsScreenContinueTapped) + InteractiveNotificationsManager.shared.requestAuthorization { [weak coordinator] authorized in + UNUserNotificationCenter.current().getNotificationSettings { settings in + guard settings.authorizationStatus != .notDetermined else { + tracker.track(.notificationsScreenPermissionNotDetermined) + return + } + + coordinator?.transitionToNextStep() + let event: MigrationEvent = authorized ? .notificationsScreenPermissionGranted : .notificationsScreenPermissionDenied + tracker.track(event) + + if authorized { + JetpackNotificationMigrationService.shared.rescheduleLocalNotifications() + } + } + } + } + let secondaryHandler = { [weak coordinator] in + tracker.track(.notificationsScreenDecideLaterButtonTapped) + coordinator?.transitionToNextStep() + } + let actionsConfiguration = MigrationActionsViewConfiguration(step: .notifications, + primaryHandler: primaryHandler, + secondaryHandler: secondaryHandler) + + configuration = MigrationStepConfiguration(headerConfiguration: headerConfiguration, + centerViewConfiguration: centerViewConfigurartion, + actionsConfiguration: actionsConfiguration) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Collection View/DashboardMigrationSuccessCell+Jetpack.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Collection View/DashboardMigrationSuccessCell+Jetpack.swift new file mode 100644 index 000000000000..25d0b4c6e2f8 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Collection View/DashboardMigrationSuccessCell+Jetpack.swift @@ -0,0 +1,14 @@ +import UIKit + +extension DashboardMigrationSuccessCell { + + func configure(with viewController: UIViewController) { + self.onTap = { [weak viewController] in + guard let viewController else { + return + } + let handler = MigrationSuccessActionHandler() + handler.showDeleteWordPressOverlay(with: viewController) + } + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Collection View/DashboardMigrationSuccessCell+WordPress.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Collection View/DashboardMigrationSuccessCell+WordPress.swift new file mode 100644 index 000000000000..07a9d24f0058 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Collection View/DashboardMigrationSuccessCell+WordPress.swift @@ -0,0 +1,9 @@ +import Foundation + +extension DashboardMigrationSuccessCell { + + func configure(with viewController: UIViewController) { + // Adding an empty implementation of this method so Xcode doesn't complain. + // The whole `DashboardMigrationSuccessCell` shouldn't exist in WordPress. It's only needed for Jetpack. + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Collection View/DashboardMigrationSuccessCell.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Collection View/DashboardMigrationSuccessCell.swift new file mode 100644 index 000000000000..b67803692315 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Collection View/DashboardMigrationSuccessCell.swift @@ -0,0 +1,30 @@ +import UIKit + +class DashboardMigrationSuccessCell: UICollectionViewCell, Reusable { + + var onTap: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + let view = MigrationSuccessCardView() { + self.onTap?() + } + view.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(view) + contentView.pinSubviewToAllEdges(view) + } +} + +extension DashboardMigrationSuccessCell: BlogDashboardCardConfigurable { + func configure(blog: Blog, viewController: BlogDashboardViewController?, apiResponse: BlogDashboardRemoteEntity?) { + + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/MigrationSuccessActionHandler.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/MigrationSuccessActionHandler.swift new file mode 100644 index 000000000000..fa6b41a386df --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/MigrationSuccessActionHandler.swift @@ -0,0 +1,16 @@ +import UIKit + +struct MigrationSuccessActionHandler { + + private let tracker: MigrationAnalyticsTracker + + init(tracker: MigrationAnalyticsTracker = .init()) { + self.tracker = tracker + } + + func showDeleteWordPressOverlay(with viewController: UIViewController) { + tracker.track(.pleaseDeleteWordPressCardTapped) + let destination = MigrationDeleteWordPressViewController() + viewController.present(UINavigationController(rootViewController: destination), animated: true) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/MigrationSuccessCardView.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/MigrationSuccessCardView.swift new file mode 100644 index 000000000000..2357268fca71 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/MigrationSuccessCardView.swift @@ -0,0 +1,123 @@ +import UIKit + +@objc +class MigrationSuccessCardView: UIView { + + private var onTap: (() -> Void)? + + private var iconImage: UIImage? { + traitCollection.layoutDirection == .leftToRight + ? UIImage(named: Appearance.iconImageNameLtr) + : UIImage(named: Appearance.iconImageNameRtl) + } + + private lazy var iconImageView: UIImageView = { + let imageView = UIImageView(image: iconImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.text = Appearance.descriptionText + label.font = Appearance.descriptionFont + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + return label + }() + + private lazy var learnMoreLabel: UILabel = { + let label = UILabel() + label.text = Appearance.learnMoreText + label.font = Appearance.learnMoreFont + label.textColor = Appearance.learnMoreTextColor + label.numberOfLines = 0 + label.adjustsFontForContentSizeCategory = true + return label + }() + + private lazy var labelStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [descriptionLabel, learnMoreLabel]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .leading + stackView.spacing = 8 + return stackView + }() + + private lazy var mainStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [iconImageView, labelStackView]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.alignment = .top + stackView.spacing = 16 + return stackView + }() + + @objc + private func viewTapped() { + onTap?() + } + + init(onTap: (() -> Void)? = nil) { + self.onTap = onTap + super.init(frame: .zero) + addSubview(mainStackView) + pinSubviewToAllEdges(mainStackView, insets: UIEdgeInsets(allEdges: 16)) + NSLayoutConstraint.activate([ + iconImageView.heightAnchor.constraint(equalToConstant: 32), + iconImageView.widthAnchor.constraint(equalToConstant: 56) + ]) + backgroundColor = .listForeground + layer.cornerRadius = 10 + clipsToBounds = true + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(viewTapped))) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private enum Appearance { + static var iconImageNameLtr = "wp-migration-success-card-icon-ltr" + static var iconImageNameRtl = "wp-migration-success-card-icon-rtl" + static let descriptionText = NSLocalizedString("wp.migration.successCard.description", + value: "Welcome to the Jetpack app. You can uninstall the WordPress app.", + comment: "Description of the jetpack migration success card, used in My site.") + static let descriptionFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) + static let learnMoreText = NSLocalizedString("wp.migration.successCard.learnMore", + value: "Learn more", + comment: "Title of a button that displays a blog post in a web view.") + static let learnMoreFont = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: .regular) + static let learnMoreTextColor = UIColor.muriel(color: .jetpackGreen, .shade40) + } +} + +// Ideally, this logic should be handled elsewhere. But since the whole migration feature is temporary +// Perhaps that's not worth the trouble. +// +// TODO: Remove `shouldShowMigrationSuccessCard` when the migration feature is no longer needed +extension MigrationSuccessCardView { + + private static var cachedShouldShowMigrationSuccessCard = false + + @objc static var shouldShowMigrationSuccessCard: Bool { + guard AppConfiguration.isJetpack else { + return false + } + + let isFeatureFlagEnabled = FeatureFlag.contentMigration.enabled + let isWordPressInstalled = MigrationAppDetection.getWordPressInstallationState().isWordPressInstalled + let isMigrationCompleted = UserPersistentStoreFactory.instance().jetpackContentMigrationState == .completed + let newValue = isFeatureFlagEnabled && isWordPressInstalled && isMigrationCompleted + + if newValue != Self.cachedShouldShowMigrationSuccessCard { + let tracker = MigrationAnalyticsTracker() + let event: MigrationEvent = newValue ? .pleaseDeleteWordPressCardShown : .pleaseDeleteWordPressCardHidden + tracker.track(event) + Self.cachedShouldShowMigrationSuccessCard = newValue + } + + return newValue + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Table View/MigrationSuccessCell+Jetpack.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Table View/MigrationSuccessCell+Jetpack.swift new file mode 100644 index 000000000000..abcf10d0768b --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Table View/MigrationSuccessCell+Jetpack.swift @@ -0,0 +1,15 @@ +import Foundation + +extension MigrationSuccessCell { + + @objc(configureWithViewController:) + func configure(with viewController: UIViewController) { + self.onTap = { [weak viewController] in + guard let viewController else { + return + } + let handler = MigrationSuccessActionHandler() + handler.showDeleteWordPressOverlay(with: viewController) + } + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Table View/MigrationSuccessCell+WordPress.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Table View/MigrationSuccessCell+WordPress.swift new file mode 100644 index 000000000000..e2dabe4d12f1 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Table View/MigrationSuccessCell+WordPress.swift @@ -0,0 +1,10 @@ +import Foundation + +extension MigrationSuccessCell { + + @objc(configureWithViewController:) + func configure(with viewController: UIViewController) { + // Adding an empty implementation of this method so Xcode doesn't complain. + // The whole `MigrationSuccessCell` shouldn't exist in WordPress. It's only needed for Jetpack. + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Table View/MigrationSuccessCell.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Table View/MigrationSuccessCell.swift new file mode 100644 index 000000000000..40a357594279 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Success card/Table View/MigrationSuccessCell.swift @@ -0,0 +1,39 @@ +import UIKit + +@objc +class MigrationSuccessCell: UITableViewCell { + + var onTap: (() -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setup() { + let view = MigrationSuccessCardView() { + self.onTap?() + } + view.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(view) + contentView.pinSubviewToAllEdges(view) + } +} + +extension BlogDetailsViewController { + + @objc func migrationSuccessSectionViewModel() -> BlogDetailsSection { + let row = BlogDetailsRow() + row.callback = {} + + let section = BlogDetailsSection(title: nil, + rows: [row], + footerTitle: nil, + category: .migrationSuccess) + return section + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Welcome/MigrationWelcomeBlogTableViewCell.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Welcome/MigrationWelcomeBlogTableViewCell.swift new file mode 100644 index 000000000000..bef73865e665 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Welcome/MigrationWelcomeBlogTableViewCell.swift @@ -0,0 +1,104 @@ +import UIKit + +final class MigrationWelcomeBlogTableViewCell: UITableViewCell, Reusable { + + // MARK: - Views + + let siteImageView: UIImageView = { + let imageView = UIImageView() + imageView.clipsToBounds = true + imageView.layer.cornerRadius = Constants.imageCornerRadius + imageView.layer.cornerCurve = .continuous + imageView.contentMode = .scaleAspectFill + return imageView + }() + + let siteNameLabel: UILabel = { + let label = UILabel() + label.font = Constants.siteNameFont + label.textColor = Constants.siteNameTextColor + label.adjustsFontForContentSizeCategory = true + return label + }() + + let siteAddressLabel: UILabel = { + let label = UILabel() + label.font = Constants.siteAddressFont + label.textColor = Constants.siteAddressTextColor + label.adjustsFontForContentSizeCategory = true + return label + }() + + // MARK: - Init + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + self.setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setup() { + // Set cell properties + self.backgroundColor = .clear + self.contentView.backgroundColor = .clear + self.selectionStyle = .none + self.accessibilityElements = [siteNameLabel, siteAddressLabel] + + // Set subviews + let labelsStackView = UIStackView(arrangedSubviews: [siteNameLabel, siteAddressLabel]) + labelsStackView.axis = .vertical + labelsStackView.alignment = .leading + labelsStackView.spacing = Constants.verticalSpacing + let mainStackView = UIStackView(arrangedSubviews: [siteImageView, labelsStackView]) + mainStackView.axis = .horizontal + mainStackView.spacing = Constants.horizontalSpacing + mainStackView.alignment = .center + mainStackView.translatesAutoresizingMaskIntoConstraints = false + self.contentView.addSubview(mainStackView) + + // Set constraints + let imageSize = Constants.imageSize + NSLayoutConstraint.activate([ + self.siteImageView.widthAnchor.constraint(equalToConstant: imageSize.width), + self.siteImageView.heightAnchor.constraint(equalToConstant: imageSize.height) + ]) + self.contentView.pinSubviewToAllEdgeMargins(mainStackView) + } + + // MARK: - Updating UI + + func update(with blog: Blog) { + let displayURL = blog.displayURL as String? ?? "" + if let name = blog.settings?.name?.nonEmptyString() { + self.siteNameLabel.text = name + self.siteAddressLabel.text = displayURL + } else { + self.siteNameLabel.text = displayURL + self.siteAddressLabel.text = nil + } + self.siteImageView.downloadSiteIcon(for: blog, imageSize: Constants.imageSize) + } + + // MARK: - Types + + private struct Constants { + /// Spacing between the image view and the labels stack view. + static let horizontalSpacing = CGFloat(20) + + /// Spacing between the labels. + static let verticalSpacing = CGFloat(3) + + static let siteNameFont = WPStyleGuide.fontForTextStyle(.body, fontWeight: .semibold) + static let siteNameTextColor = UIColor.text + static let siteAddressFont = WPStyleGuide.fontForTextStyle(.callout, fontWeight: .regular) + static let siteAddressTextColor = UIColor.textSubtle + + static let imageCornerRadius = CGFloat(3) + static let imageSize = CGSize(width: 60, height: 60) + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Welcome/MigrationWelcomeViewController.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Welcome/MigrationWelcomeViewController.swift new file mode 100644 index 000000000000..383b6cb2bf73 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Welcome/MigrationWelcomeViewController.swift @@ -0,0 +1,147 @@ +import UIKit + +final class MigrationWelcomeViewController: UIViewController { + + // MARK: - Dependencies + + private let tracker: MigrationAnalyticsTracker + + private let viewModel: MigrationWelcomeViewModel + + // MARK: - Views + + private let tableView = UITableView(frame: .zero, style: .plain) + + private lazy var headerView: MigrationHeaderView = { + let view = MigrationHeaderView(configuration: viewModel.configuration.headerConfiguration) + view.translatesAutoresizingMaskIntoConstraints = true + view.directionalLayoutMargins = Constants.tableHeaderViewMargins + return view + }() + + private lazy var bottomSheet: MigrationActionsView = { + let actionsView = MigrationActionsView(configuration: viewModel.configuration.actionsConfiguration) + actionsView.translatesAutoresizingMaskIntoConstraints = false + actionsView.primaryHandler = { [weak self] configuration in + self?.tracker.track(.welcomeScreenContinueTapped) + configuration.primaryHandler?() + } + actionsView.secondaryHandler = { [weak self] configuration in + self?.tracker.track(.welcomeScreenHelpButtonTapped) + configuration.secondaryHandler?() + } + return actionsView + }() + + // MARK: - Lifecycle + + init(viewModel: MigrationWelcomeViewModel, tracker: MigrationAnalyticsTracker = .init()) { + self.viewModel = viewModel + self.tracker = tracker + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = MigrationAppearance.backgroundColor + self.setupTableView() + self.setupBottomSheet() + self.setupNavigationBar() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.tracker.track(.welcomeScreenShown) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + self.tableView.sizeToFitHeaderView() + self.updateTableViewContentInset() + } + + // MARK: - Setup and Updates + + private func setupTableView() { + self.tableView.backgroundColor = .clear + self.tableView.directionalLayoutMargins.leading = Constants.tableViewLeadingMargin + self.tableView.register(MigrationWelcomeBlogTableViewCell.self, forCellReuseIdentifier: MigrationWelcomeBlogTableViewCell.defaultReuseID) + self.tableView.translatesAutoresizingMaskIntoConstraints = false + self.tableView.dataSource = self + self.tableView.tableHeaderView = headerView + self.tableView.cellLayoutMarginsFollowReadableWidth = true + self.view.addSubview(tableView) + self.view.pinSubviewToAllEdges(tableView) + } + + private func setupNavigationBar() { + self.navigationItem.rightBarButtonItem = UIBarButtonItem(email: viewModel.gravatarEmail) { [weak self] () -> Void in + guard let self else { + return + } + self.tracker.track(.welcomeScreenAvatarTapped) + self.viewModel.configuration.actionsConfiguration.secondaryHandler?() + } + } + + private func setupBottomSheet() { + self.view.addSubview(bottomSheet) + NSLayoutConstraint.activate([ + bottomSheet.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bottomSheet.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bottomSheet.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + /// Increases the tableView's bottom inset so it doesn't get covered by the bottom actions sheet. + private func updateTableViewContentInset() { + let bottomInset = -view.safeAreaInsets.bottom + bottomSheet.bounds.height + self.tableView.contentInset.bottom = bottomInset + Constants.tableViewBottomInsetMargin + self.tableView.verticalScrollIndicatorInsets.bottom = bottomInset + } + + // MARK: - Constants + + private struct Constants { + /// Used to add a gap between the `tableView` last row and the bottom sheet top edge. + static let tableViewBottomInsetMargin = CGFloat(20) + + /// Used to align the `tableView`'s leading edge with the tableView header's leading edge. + static let tableViewLeadingMargin = CGFloat(30) + + /// Used for the `tableHeaderView` layout guide margins. + static let tableHeaderViewMargins: NSDirectionalEdgeInsets = { + var insets = NSDirectionalEdgeInsets(top: 20, leading: 30, bottom: 30, trailing: 30) + if #available(iOS 15, *) { + insets.top = 0 + } + return insets + }() + } +} + +// MARK: - UITableViewDataSource + +extension MigrationWelcomeViewController: UITableViewDataSource { + + func numberOfSections(in tableView: UITableView) -> Int { + return viewModel.blogListDataSource.numberOfSections(in: tableView) + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.blogListDataSource.tableView(tableView, numberOfRowsInSection: section) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let blog = viewModel.blogListDataSource.blog(at: indexPath) + let cell = tableView.dequeueReusableCell(withIdentifier: MigrationWelcomeBlogTableViewCell.defaultReuseID, for: indexPath) as! MigrationWelcomeBlogTableViewCell + cell.update(with: blog) + return cell + } +} diff --git a/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Welcome/MigrationWelcomeViewModel.swift b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Welcome/MigrationWelcomeViewModel.swift new file mode 100644 index 000000000000..9d63a7dad152 --- /dev/null +++ b/WordPress/Jetpack/Classes/ViewRelated/WordPress-to-Jetpack Migration/Welcome/MigrationWelcomeViewModel.swift @@ -0,0 +1,38 @@ +import UIKit + +final class MigrationWelcomeViewModel { + + // MARK: - Properties + + let gravatarEmail: String? + let configuration: MigrationStepConfiguration + let blogListDataSource: BlogListDataSource + + // MARK: - Init + + init(gravatarEmail: String?, blogListDataSource: BlogListDataSource, configuration: MigrationStepConfiguration) { + self.gravatarEmail = gravatarEmail + self.configuration = configuration + self.blogListDataSource = blogListDataSource + } + + convenience init(account: WPAccount?, actions: MigrationActionsViewConfiguration) { + let blogsDataSource = BlogListDataSource() + blogsDataSource.loggedIn = true + blogsDataSource.account = account + let header = MigrationHeaderConfiguration( + step: .welcome, + multiSite: blogsDataSource.visibleBlogsCount > 1 + ) + let configuration = MigrationStepConfiguration( + headerConfiguration: header, + centerViewConfiguration: nil, + actionsConfiguration: actions + ) + self.init( + gravatarEmail: account?.email, + blogListDataSource: blogsDataSource, + configuration: configuration + ) + } +} diff --git a/WordPress/Jetpack/ExtensionConfiguration.swift b/WordPress/Jetpack/ExtensionConfiguration.swift new file mode 100644 index 000000000000..c833d415af81 --- /dev/null +++ b/WordPress/Jetpack/ExtensionConfiguration.swift @@ -0,0 +1,33 @@ +// Jetpack Extension configuration + +import Foundation + +/// - Warning: +/// This configuration extension has a **WordPress** counterpart in the WordPress bundle. +/// Make sure to keep them in sync to avoid build errors when building the WordPress target. +@objc extension AppConfiguration { + + @objc(AppConfigurationExtension) + class Extension: NSObject { + @objc(AppConfigurationExtensionShare) + class Share: NSObject { + @objc static let keychainUsernameKey = "JPUsername" + @objc static let keychainTokenKey = "JPOAuth2Token" + @objc static let keychainServiceName = "JPShareExtension" + @objc static let userDefaultsPrimarySiteName = "JPShareUserDefaultsPrimarySiteName" + @objc static let userDefaultsPrimarySiteID = "JPShareUserDefaultsPrimarySiteID" + @objc static let userDefaultsLastUsedSiteName = "JPShareUserDefaultsLastUsedSiteName" + @objc static let userDefaultsLastUsedSiteID = "JPShareUserDefaultsLastUsedSiteID" + @objc static let maximumMediaDimensionKey = "JPShareExtensionMaximumMediaDimensionKey" + @objc static let recentSitesKey = "JPShareExtensionRecentSitesKey" + } + + @objc(AppConfigurationExtensionNotificationsService) + class NotificationsService: NSObject { + @objc static let keychainServiceName = "JPNotificationServiceExtension" + @objc static let keychainTokenKey = "JPOAuth2Token" + @objc static let keychainUsernameKey = "JPUsername" + @objc static let keychainUserIDKey = "JPUserID" + } + } +} diff --git a/WordPress/Jetpack/Icons/3D/3d-icon-app-60@2x.png b/WordPress/Jetpack/Icons/3D/3d-icon-app-60@2x.png new file mode 100644 index 000000000000..70a98b2a8ded Binary files /dev/null and b/WordPress/Jetpack/Icons/3D/3d-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/3D/3d-icon-app-60@3x.png b/WordPress/Jetpack/Icons/3D/3d-icon-app-60@3x.png new file mode 100644 index 000000000000..9cd80fc4d496 Binary files /dev/null and b/WordPress/Jetpack/Icons/3D/3d-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/3D/3d-icon-app-76.png b/WordPress/Jetpack/Icons/3D/3d-icon-app-76.png new file mode 100644 index 000000000000..0e3c37833029 Binary files /dev/null and b/WordPress/Jetpack/Icons/3D/3d-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/3D/3d-icon-app-76@2x.png b/WordPress/Jetpack/Icons/3D/3d-icon-app-76@2x.png new file mode 100644 index 000000000000..4be56574ec77 Binary files /dev/null and b/WordPress/Jetpack/Icons/3D/3d-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/3D/3d-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/3D/3d-icon-app-83.5@2x.png new file mode 100644 index 000000000000..4f50da372841 Binary files /dev/null and b/WordPress/Jetpack/Icons/3D/3d-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-60@2x.png b/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-60@2x.png new file mode 100644 index 000000000000..fd29bebe82be Binary files /dev/null and b/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-60@3x.png b/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-60@3x.png new file mode 100644 index 000000000000..5eeff66f7d40 Binary files /dev/null and b/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-76.png b/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-76.png new file mode 100644 index 000000000000..568cc92bdef1 Binary files /dev/null and b/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-76@2x.png b/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-76@2x.png new file mode 100644 index 000000000000..a4e859188d7f Binary files /dev/null and b/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-83.5@2x.png new file mode 100644 index 000000000000..32baa4f0b8f6 Binary files /dev/null and b/WordPress/Jetpack/Icons/black-on-white/black-on-white-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-60@2x.png b/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-60@2x.png new file mode 100644 index 000000000000..1f8a11692531 Binary files /dev/null and b/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-60@3x.png b/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-60@3x.png new file mode 100644 index 000000000000..3f32ba0bf5a9 Binary files /dev/null and b/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-76.png b/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-76.png new file mode 100644 index 000000000000..b9bb03dcd4eb Binary files /dev/null and b/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-76@2x.png b/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-76@2x.png new file mode 100644 index 000000000000..60a49f5a8f5d Binary files /dev/null and b/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-83.5@2x.png new file mode 100644 index 000000000000..256739833204 Binary files /dev/null and b/WordPress/Jetpack/Icons/blue-on-white/blue-on-white-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-60@2x.png b/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-60@2x.png new file mode 100644 index 000000000000..ad536cd66e3a Binary files /dev/null and b/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-60@3x.png b/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-60@3x.png new file mode 100644 index 000000000000..714b81f3d021 Binary files /dev/null and b/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-76.png b/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-76.png new file mode 100644 index 000000000000..6f32f6fdff12 Binary files /dev/null and b/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-76@2x.png b/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-76@2x.png new file mode 100644 index 000000000000..9d1113f85fbf Binary files /dev/null and b/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-83.5@2x.png new file mode 100644 index 000000000000..6cface58d493 Binary files /dev/null and b/WordPress/Jetpack/Icons/celadon-on-white/celadon-on-white-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-60@2x.png b/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-60@2x.png new file mode 100644 index 000000000000..2f4420838aa2 Binary files /dev/null and b/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-60@3x.png b/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-60@3x.png new file mode 100644 index 000000000000..415d6bb8c491 Binary files /dev/null and b/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-76.png b/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-76.png new file mode 100644 index 000000000000..cfc2a0ccf33a Binary files /dev/null and b/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-76@2x.png b/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-76@2x.png new file mode 100644 index 000000000000..250e2bcd0d3c Binary files /dev/null and b/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-83.5@2x.png new file mode 100644 index 000000000000..5aaed03241c7 Binary files /dev/null and b/WordPress/Jetpack/Icons/cool-green/cool-green-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-60@2x.png b/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-60@2x.png new file mode 100644 index 000000000000..98e080c82a2b Binary files /dev/null and b/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-60@3x.png b/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-60@3x.png new file mode 100644 index 000000000000..fe2700ba71e1 Binary files /dev/null and b/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-76.png b/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-76.png new file mode 100644 index 000000000000..14f4ed542701 Binary files /dev/null and b/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-76@2x.png b/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-76@2x.png new file mode 100644 index 000000000000..cb1646945219 Binary files /dev/null and b/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-83.5@2x.png new file mode 100644 index 000000000000..71ab13a3151c Binary files /dev/null and b/WordPress/Jetpack/Icons/dark-glow/dark-glow-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-60@2x.png b/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-60@2x.png new file mode 100644 index 000000000000..2352ae5bd11f Binary files /dev/null and b/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-60@3x.png b/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-60@3x.png new file mode 100644 index 000000000000..14b56c8c1ab2 Binary files /dev/null and b/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-76.png b/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-76.png new file mode 100644 index 000000000000..d476f1b39984 Binary files /dev/null and b/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-76@2x.png b/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-76@2x.png new file mode 100644 index 000000000000..47b3b62ca32d Binary files /dev/null and b/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-83.5@2x.png new file mode 100644 index 000000000000..2ca13d6169dc Binary files /dev/null and b/WordPress/Jetpack/Icons/dark-green/dark-green-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-60@2x.png b/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-60@2x.png new file mode 100644 index 000000000000..4defc25b5c77 Binary files /dev/null and b/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-60@3x.png b/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-60@3x.png new file mode 100644 index 000000000000..bae966e5abff Binary files /dev/null and b/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-76.png b/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-76.png new file mode 100644 index 000000000000..c5f61ba6d2e1 Binary files /dev/null and b/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-76@2x.png b/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-76@2x.png new file mode 100644 index 000000000000..9ef71a3eb529 Binary files /dev/null and b/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-83.5@2x.png new file mode 100644 index 000000000000..a1ec37fcebd2 Binary files /dev/null and b/WordPress/Jetpack/Icons/green-on-white/green-on-white-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-60@2x.png b/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-60@2x.png new file mode 100644 index 000000000000..373429ddceee Binary files /dev/null and b/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-60@3x.png b/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-60@3x.png new file mode 100644 index 000000000000..75444aa7c5f1 Binary files /dev/null and b/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-76.png b/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-76.png new file mode 100644 index 000000000000..536a2da029f0 Binary files /dev/null and b/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-76@2x.png b/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-76@2x.png new file mode 100644 index 000000000000..717d6bd4db40 Binary files /dev/null and b/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-83.5@2x.png new file mode 100644 index 000000000000..99adc8590db0 Binary files /dev/null and b/WordPress/Jetpack/Icons/jetpack-light/jetpack-light-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-60@2x.png b/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-60@2x.png new file mode 100644 index 000000000000..69e411dea4d7 Binary files /dev/null and b/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-60@3x.png b/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-60@3x.png new file mode 100644 index 000000000000..99d44eff67fd Binary files /dev/null and b/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-76.png b/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-76.png new file mode 100644 index 000000000000..9bbe4ed0f9dc Binary files /dev/null and b/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-76@2x.png b/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-76@2x.png new file mode 100644 index 000000000000..2a9c76a88570 Binary files /dev/null and b/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-83.5@2x.png new file mode 100644 index 000000000000..7b2a71f76797 Binary files /dev/null and b/WordPress/Jetpack/Icons/neu-green/neu-green-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-60@2x.png b/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-60@2x.png new file mode 100644 index 000000000000..cfffc617a58b Binary files /dev/null and b/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-60@3x.png b/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-60@3x.png new file mode 100644 index 000000000000..9d73789f7cf0 Binary files /dev/null and b/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-76.png b/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-76.png new file mode 100644 index 000000000000..352aa43082bc Binary files /dev/null and b/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-76@2x.png b/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-76@2x.png new file mode 100644 index 000000000000..cd89ebe64b83 Binary files /dev/null and b/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-83.5@2x.png new file mode 100644 index 000000000000..c06a103b6ab2 Binary files /dev/null and b/WordPress/Jetpack/Icons/neumorphic-dark/neumorphic-dark-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-60@2x.png b/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-60@2x.png new file mode 100644 index 000000000000..d388616d9eb6 Binary files /dev/null and b/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-60@3x.png b/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-60@3x.png new file mode 100644 index 000000000000..b8c72d1c8969 Binary files /dev/null and b/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-76.png b/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-76.png new file mode 100644 index 000000000000..267df0287097 Binary files /dev/null and b/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-76@2x.png b/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-76@2x.png new file mode 100644 index 000000000000..7dbb986c63df Binary files /dev/null and b/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-83.5@2x.png new file mode 100644 index 000000000000..cbfa3fc17321 Binary files /dev/null and b/WordPress/Jetpack/Icons/neumorphic-light/neumorphic-light-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-60@2x.png b/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-60@2x.png new file mode 100644 index 000000000000..6c24bfde4d66 Binary files /dev/null and b/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-60@3x.png b/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-60@3x.png new file mode 100644 index 000000000000..50621932190c Binary files /dev/null and b/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-76.png b/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-76.png new file mode 100644 index 000000000000..490e129a8377 Binary files /dev/null and b/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-76@2x.png b/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-76@2x.png new file mode 100644 index 000000000000..b507bca2f7ba Binary files /dev/null and b/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-83.5@2x.png new file mode 100644 index 000000000000..98a11c2fc2fe Binary files /dev/null and b/WordPress/Jetpack/Icons/pink-on-white/pink-on-white-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-60@2x.png b/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-60@2x.png new file mode 100644 index 000000000000..3ee41c29b5d7 Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-60@3x.png b/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-60@3x.png new file mode 100644 index 000000000000..dd6ab43926ea Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-76.png b/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-76.png new file mode 100644 index 000000000000..5083269b83ed Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-76@2x.png b/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-76@2x.png new file mode 100644 index 000000000000..843aea4076e1 Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-83.5@2x.png new file mode 100644 index 000000000000..c75441a7bdc0 Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum-on-black/spectrum-on-black-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-60@2x.png b/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-60@2x.png new file mode 100644 index 000000000000..2f3583801374 Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-60@3x.png b/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-60@3x.png new file mode 100644 index 000000000000..36fea1198e93 Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-76.png b/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-76.png new file mode 100644 index 000000000000..ce474aa650e7 Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-76@2x.png b/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-76@2x.png new file mode 100644 index 000000000000..82dfce7ecc29 Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-83.5@2x.png new file mode 100644 index 000000000000..5f693bf869be Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum-on-white/spectrum-on-white-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-60@2x.png b/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-60@2x.png new file mode 100644 index 000000000000..d171d27ff98d Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-60@3x.png b/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-60@3x.png new file mode 100644 index 000000000000..2cb81d6007f1 Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-76.png b/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-76.png new file mode 100644 index 000000000000..294ba9798e46 Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-76@2x.png b/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-76@2x.png new file mode 100644 index 000000000000..f3704655221b Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-83.5@2x.png new file mode 100644 index 000000000000..c2c2243fd95c Binary files /dev/null and b/WordPress/Jetpack/Icons/spectrum/spectrum-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-60@2x.png b/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-60@2x.png new file mode 100644 index 000000000000..4f0ca1cae017 Binary files /dev/null and b/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-60@3x.png b/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-60@3x.png new file mode 100644 index 000000000000..8537f4f77772 Binary files /dev/null and b/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-76.png b/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-76.png new file mode 100644 index 000000000000..1ca60873ce91 Binary files /dev/null and b/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-76@2x.png b/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-76@2x.png new file mode 100644 index 000000000000..9daa26db2c46 Binary files /dev/null and b/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-83.5@2x.png new file mode 100644 index 000000000000..cf8972f4e38e Binary files /dev/null and b/WordPress/Jetpack/Icons/stroke-dark/stroke-dark-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-60@2x.png b/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-60@2x.png new file mode 100644 index 000000000000..209447ccde87 Binary files /dev/null and b/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-60@3x.png b/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-60@3x.png new file mode 100644 index 000000000000..41a91feb4346 Binary files /dev/null and b/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-76.png b/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-76.png new file mode 100644 index 000000000000..87500d76ed5a Binary files /dev/null and b/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-76@2x.png b/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-76@2x.png new file mode 100644 index 000000000000..b1936e61ecca Binary files /dev/null and b/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-83.5@2x.png new file mode 100644 index 000000000000..41cf8a3bf286 Binary files /dev/null and b/WordPress/Jetpack/Icons/stroke-light/stroke-light-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-60@2x.png b/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-60@2x.png new file mode 100644 index 000000000000..1e36e4e3c0c7 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-60@3x.png b/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-60@3x.png new file mode 100644 index 000000000000..72ba542ed51a Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-76.png b/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-76.png new file mode 100644 index 000000000000..35d796065fc8 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-76@2x.png b/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-76@2x.png new file mode 100644 index 000000000000..cc2afee0ad44 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-83.5@2x.png new file mode 100644 index 000000000000..0fc517fa7c44 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-black/white-on-black-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-60@2x.png b/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-60@2x.png new file mode 100644 index 000000000000..c086891c0383 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-60@3x.png b/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-60@3x.png new file mode 100644 index 000000000000..9d3b686bbcb8 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-76.png b/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-76.png new file mode 100644 index 000000000000..07dca1dd579c Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-76@2x.png b/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-76@2x.png new file mode 100644 index 000000000000..adea49c2d731 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-83.5@2x.png new file mode 100644 index 000000000000..a3796411c41a Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-blue/white-on-blue-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-60@2x.png b/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-60@2x.png new file mode 100644 index 000000000000..94206dc35f09 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-60@3x.png b/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-60@3x.png new file mode 100644 index 000000000000..22c113e1126d Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-76.png b/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-76.png new file mode 100644 index 000000000000..94dafdc51b88 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-76@2x.png b/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-76@2x.png new file mode 100644 index 000000000000..16f7c20e29b3 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-83.5@2x.png new file mode 100644 index 000000000000..c99ff3c9b863 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-celadon/white-on-celadon-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-60@2x.png b/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-60@2x.png new file mode 100644 index 000000000000..a55741e956ae Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-60@3x.png b/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-60@3x.png new file mode 100644 index 000000000000..d31eee14fb57 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-76.png b/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-76.png new file mode 100644 index 000000000000..2264d9e269ff Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-76@2x.png b/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-76@2x.png new file mode 100644 index 000000000000..670c9b131de7 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-83.5@2x.png new file mode 100644 index 000000000000..bfab8433b80f Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-green/white-on-green-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-60@2x.png b/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-60@2x.png new file mode 100644 index 000000000000..8995d3ef3a76 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-60@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-60@3x.png b/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-60@3x.png new file mode 100644 index 000000000000..39e5f8130fb4 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-60@3x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-76.png b/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-76.png new file mode 100644 index 000000000000..e2c0c458b08a Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-76.png differ diff --git a/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-76@2x.png b/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-76@2x.png new file mode 100644 index 000000000000..f7f8e91e93b3 Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-76@2x.png differ diff --git a/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-83.5@2x.png b/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-83.5@2x.png new file mode 100644 index 000000000000..d2d05eba93ed Binary files /dev/null and b/WordPress/Jetpack/Icons/white-on-pink/white-on-pink-icon-app-83.5@2x.png differ diff --git a/WordPress/Jetpack/Info.plist b/WordPress/Jetpack/Info.plist new file mode 100644 index 000000000000..cf0c1f81f737 --- /dev/null +++ b/WordPress/Jetpack/Info.plist @@ -0,0 +1,657 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + org.wordpress.bgtask.weeklyroundup + org.wordpress.bgtask.weeklyroundup.processing + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${PRODUCT_NAME} + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleIcons + + CFBundleAlternateIcons + + 3D + + CFBundleIconFiles + + 3d-icon-app-60 + 3d-icon-app-76 + 3d-icon-app-83.5 + + UIPrerenderedIcon + + + Stroke Light + + WPRequiresBorder + + CFBundleIconFiles + + stroke-light-icon-app-60 + stroke-light-icon-app-76 + stroke-light-icon-app-83.5 + + UIPrerenderedIcon + + + Stroke Dark + + CFBundleIconFiles + + stroke-dark-icon-app-60 + stroke-dark-icon-app-76 + stroke-dark-icon-app-83.5 + + UIPrerenderedIcon + + + Black on White + + WPRequiresBorder + + CFBundleIconFiles + + black-on-white-icon-app-60 + black-on-white-icon-app-76 + black-on-white-icon-app-83.5 + + UIPrerenderedIcon + + + Blue on White + + CFBundleIconFiles + + blue-on-white-icon-app-60 + blue-on-white-icon-app-76 + blue-on-white-icon-app-83.5 + + UIPrerenderedIcon + + WPRequiresBorder + + + Celadon on White + + WPRequiresBorder + + CFBundleIconFiles + + celadon-on-white-icon-app-60 + celadon-on-white-icon-app-76 + celadon-on-white-icon-app-83.5 + + UIPrerenderedIcon + + + Green on White + + WPRequiresBorder + + CFBundleIconFiles + + green-on-white-icon-app-60 + green-on-white-icon-app-76 + green-on-white-icon-app-83.5 + + UIPrerenderedIcon + + + Jetpack Light + + CFBundleIconFiles + + jetpack-light-icon-app-60 + jetpack-light-icon-app-76 + jetpack-light-icon-app-83.5 + + UIPrerenderedIcon + + + Dark Green + + CFBundleIconFiles + + dark-green-icon-app-60 + dark-green-icon-app-76 + dark-green-icon-app-83.5 + + UIPrerenderedIcon + + + Dark Glow + + CFBundleIconFiles + + dark-glow-icon-app-60 + dark-glow-icon-app-76 + dark-glow-icon-app-83.5 + + UIPrerenderedIcon + + + Neu Green + + CFBundleIconFiles + + neu-green-icon-app-60 + neu-green-icon-app-76 + neu-green-icon-app-83.5 + + UIPrerenderedIcon + + + Neumorphic Dark + + CFBundleIconFiles + + neumorphic-dark-icon-app-60 + neumorphic-dark-icon-app-76 + neumorphic-dark-icon-app-83.5 + + UIPrerenderedIcon + + + Neumorphic Light + + CFBundleIconFiles + + neumorphic-light-icon-app-60 + neumorphic-light-icon-app-76 + neumorphic-light-icon-app-83.5 + + UIPrerenderedIcon + + + Spectrum + + CFBundleIconFiles + + spectrum-icon-app-60 + spectrum-icon-app-76 + spectrum-icon-app-83.5 + + UIPrerenderedIcon + + + Spectrum on White + + WPRequiresBorder + + CFBundleIconFiles + + spectrum-on-white-icon-app-60 + spectrum-on-white-icon-app-76 + spectrum-on-white-icon-app-83.5 + + UIPrerenderedIcon + + + Spectrum on Black + + CFBundleIconFiles + + spectrum-on-black-icon-app-60 + spectrum-on-black-icon-app-76 + spectrum-on-black-icon-app-83.5 + + UIPrerenderedIcon + + + White on Black + + CFBundleIconFiles + + white-on-black-icon-app-60 + white-on-black-icon-app-76 + white-on-black-icon-app-83.5 + + UIPrerenderedIcon + + + White on Blue + + CFBundleIconFiles + + white-on-blue-icon-app-60 + white-on-blue-icon-app-76 + white-on-blue-icon-app-83.5 + + UIPrerenderedIcon + + + White on Celadon + + CFBundleIconFiles + + white-on-celadon-icon-app-60 + white-on-celadon-icon-app-76 + white-on-celadon-icon-app-83.5 + + UIPrerenderedIcon + + + White on Pink + + CFBundleIconFiles + + white-on-pink-icon-app-60 + white-on-pink-icon-app-76 + white-on-pink-icon-app-83.5 + + UIPrerenderedIcon + + + White on Green + + CFBundleIconFiles + + white-on-green-icon-app-60 + white-on-green-icon-app-76 + white-on-green-icon-app-83.5 + + UIPrerenderedIcon + + + + CFBundlePrimaryIcon + + CFBundleIconFiles + + AppIcon + + UIPrerenderedIcon + + + + CFBundleIcons~ipad + + CFBundleAlternateIcons + + 3D + + CFBundleIconFiles + + 3d-icon-app-60 + 3d-icon-app-76 + 3d-icon-app-83.5 + + UIPrerenderedIcon + + + Stroke Light + + WPRequiresBorder + + CFBundleIconFiles + + stroke-light-icon-app-60 + stroke-light-icon-app-76 + stroke-light-icon-app-83.5 + + UIPrerenderedIcon + + + Stroke Dark + + CFBundleIconFiles + + stroke-dark-icon-app-60 + stroke-dark-icon-app-76 + stroke-dark-icon-app-83.5 + + UIPrerenderedIcon + + + Black on White + + WPRequiresBorder + + CFBundleIconFiles + + black-on-white-icon-app-60 + black-on-white-icon-app-76 + black-on-white-icon-app-83.5 + + UIPrerenderedIcon + + + Blue on White + + CFBundleIconFiles + + blue-on-white-icon-app-60 + blue-on-white-icon-app-76 + blue-on-white-icon-app-83.5 + + UIPrerenderedIcon + + WPRequiresBorder + + + Celadon on White + + WPRequiresBorder + + CFBundleIconFiles + + celadon-on-white-icon-app-60 + celadon-on-white-icon-app-76 + celadon-on-white-icon-app-83.5 + + UIPrerenderedIcon + + + Green on White + + WPRequiresBorder + + CFBundleIconFiles + + green-on-white-icon-app-60 + green-on-white-icon-app-76 + green-on-white-icon-app-83.5 + + UIPrerenderedIcon + + + Jetpack Light + + CFBundleIconFiles + + jetpack-light-icon-app-60 + jetpack-light-icon-app-76 + jetpack-light-icon-app-83.5 + + UIPrerenderedIcon + + + Dark Green + + CFBundleIconFiles + + dark-green-icon-app-60 + dark-green-icon-app-76 + dark-green-icon-app-83.5 + + UIPrerenderedIcon + + + Dark Glow + + CFBundleIconFiles + + dark-glow-icon-app-60 + dark-glow-icon-app-76 + dark-glow-icon-app-83.5 + + UIPrerenderedIcon + + + Neu Green + + CFBundleIconFiles + + neu-green-icon-app-60 + neu-green-icon-app-76 + neu-green-icon-app-83.5 + + UIPrerenderedIcon + + + Neumorphic Dark + + CFBundleIconFiles + + neumorphic-dark-icon-app-60 + neumorphic-dark-icon-app-76 + neumorphic-dark-icon-app-83.5 + + UIPrerenderedIcon + + + Neumorphic Light + + CFBundleIconFiles + + neumorphic-light-icon-app-60 + neumorphic-light-icon-app-76 + neumorphic-light-icon-app-83.5 + + UIPrerenderedIcon + + + Spectrum + + CFBundleIconFiles + + spectrum-icon-app-60 + spectrum-icon-app-76 + spectrum-icon-app-83.5 + + UIPrerenderedIcon + + + Spectrum on White + + WPRequiresBorder + + CFBundleIconFiles + + spectrum-on-white-icon-app-60 + spectrum-on-white-icon-app-76 + spectrum-on-white-icon-app-83.5 + + UIPrerenderedIcon + + + Spectrum on Black + + CFBundleIconFiles + + spectrum-on-black-icon-app-60 + spectrum-on-black-icon-app-76 + spectrum-on-black-icon-app-83.5 + + UIPrerenderedIcon + + + White on Black + + CFBundleIconFiles + + white-on-black-icon-app-60 + white-on-black-icon-app-76 + white-on-black-icon-app-83.5 + + UIPrerenderedIcon + + + White on Blue + + CFBundleIconFiles + + white-on-blue-icon-app-60 + white-on-blue-icon-app-76 + white-on-blue-icon-app-83.5 + + UIPrerenderedIcon + + + White on Celadon + + CFBundleIconFiles + + white-on-celadon-icon-app-60 + white-on-celadon-icon-app-76 + white-on-celadon-icon-app-83.5 + + UIPrerenderedIcon + + + White on Pink + + CFBundleIconFiles + + white-on-pink-icon-app-60 + white-on-pink-icon-app-76 + white-on-pink-icon-app-83.5 + + UIPrerenderedIcon + + + White on Green + + CFBundleIconFiles + + white-on-green-icon-app-60 + white-on-green-icon-app-76 + white-on-green-icon-app-83.5 + + UIPrerenderedIcon + + + + CFBundlePrimaryIcon + + CFBundleIconFiles + + AppIcon + + UIPrerenderedIcon + + + + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + org.wordpress + CFBundleURLSchemes + + wordpress-oauth-v2 + ${WPCOM_SCHEME} + jetpacknotificationmigration + + + + CFBundleTypeRole + Editor + CFBundleURLName + GoogleSignIn + CFBundleURLSchemes + + com.googleusercontent.apps.108380595987-qmh1rvuqi418cs6otokppnemo48288c9 + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + LSApplicationQueriesSchemes + + wordpress + wordpressmigration+v1 + wordpressnotificationmigration + org-appextension-feature-password-management + twitter + whatsapp + tg + googlegmail + airmail + ms-outlook + readdle-spark + ymail + fastmail + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSCameraUsageDescription + To take photos or videos to use in your posts. + NSLocationUsageDescription + WordPress would like to add your location to posts on sites where you have enabled geotagging. + NSLocationWhenInUseUsageDescription + WordPress would like to add your location to posts on sites where you have enabled geotagging. + NSMicrophoneUsageDescription + Enable microphone access to record sound in your videos. + NSPhotoLibraryAddUsageDescription + To add photos or videos to your posts. + NSPhotoLibraryUsageDescription + To add photos or videos to your posts. + NSUserActivityTypes + + org.wordpress.me + org.wordpress.me.appsettings + org.wordpress.me.notificationsettings + org.wordpress.me.support + org.wordpress.mysites + org.wordpress.mysites.details + org.wordpress.notifications + org.wordpress.reader + + PHPhotoLibraryPreventAutomaticLimitedAccessAlert + + UIAppFonts + + Noticons.ttf + oswald_upper.ttf + Shrikhand-Regular.ttf + LibreBaskerville-Regular.ttf + SpaceMono-Bold.ttf + Pacifico-Regular.ttf + Nunito-Bold.ttf + + UIBackgroundModes + + fetch + processing + remote-notification + + UILaunchStoryboardName + Launch Screen + UIPrerenderedIcon + + UIRequiresFullScreen + + UIStatusBarHidden + + UIStatusBarStyle + UIStatusBarStyleBlackOpaque + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/WordPress/Jetpack/JetpackDebug.entitlements b/WordPress/Jetpack/JetpackDebug.entitlements new file mode 100644 index 000000000000..5253760134d2 --- /dev/null +++ b/WordPress/Jetpack/JetpackDebug.entitlements @@ -0,0 +1,30 @@ + + + + + aps-environment + development + com.apple.developer.applesignin + + Default + + com.apple.developer.associated-domains + + applinks:jetpack.com + webcredentials:wordpress.com + applinks:apps.wordpress.com + applinks:wordpress.com + applinks:public-api.wordpress.com + applinks:links.wp.a8cmail.com + applinks:*.wordpress.com + + com.apple.security.application-groups + + group.org.wordpress + + keychain-access-groups + + 3TMU3BH3NK.org.wordpress + + + diff --git a/WordPress/Jetpack/JetpackRelease-Alpha.entitlements b/WordPress/Jetpack/JetpackRelease-Alpha.entitlements new file mode 100644 index 000000000000..56b32f593884 --- /dev/null +++ b/WordPress/Jetpack/JetpackRelease-Alpha.entitlements @@ -0,0 +1,26 @@ + + + + + aps-environment + development + com.apple.developer.associated-domains + + applinks:jetpack.com + webcredentials:wordpress.com + applinks:apps.wordpress.com + applinks:wordpress.com + applinks:public-api.wordpress.com + applinks:links.wp.a8cmail.com + applinks:*.wordpress.com + + com.apple.security.application-groups + + group.org.wordpress.alpha + + keychain-access-groups + + 99KV9Z6BKV.org.wordpress.alpha + + + diff --git a/WordPress/Jetpack/JetpackRelease-Internal.entitlements b/WordPress/Jetpack/JetpackRelease-Internal.entitlements new file mode 100644 index 000000000000..87e951fee79e --- /dev/null +++ b/WordPress/Jetpack/JetpackRelease-Internal.entitlements @@ -0,0 +1,26 @@ + + + + + aps-environment + development + com.apple.developer.associated-domains + + applinks:jetpack.com + webcredentials:wordpress.com + applinks:apps.wordpress.com + applinks:wordpress.com + applinks:public-api.wordpress.com + applinks:links.wp.a8cmail.com + applinks:*.wordpress.com + + com.apple.security.application-groups + + group.org.wordpress.internal + + keychain-access-groups + + 99KV9Z6BKV.org.wordpress.internal + + + diff --git a/WordPress/Jetpack/JetpackRelease.entitlements b/WordPress/Jetpack/JetpackRelease.entitlements new file mode 100644 index 000000000000..5253760134d2 --- /dev/null +++ b/WordPress/Jetpack/JetpackRelease.entitlements @@ -0,0 +1,30 @@ + + + + + aps-environment + development + com.apple.developer.applesignin + + Default + + com.apple.developer.associated-domains + + applinks:jetpack.com + webcredentials:wordpress.com + applinks:apps.wordpress.com + applinks:wordpress.com + applinks:public-api.wordpress.com + applinks:links.wp.a8cmail.com + applinks:*.wordpress.com + + com.apple.security.application-groups + + group.org.wordpress + + keychain-access-groups + + 3TMU3BH3NK.org.wordpress + + + diff --git a/WordPress/Jetpack/Launch Screen.storyboard b/WordPress/Jetpack/Launch Screen.storyboard new file mode 100644 index 000000000000..0b15765afd02 --- /dev/null +++ b/WordPress/Jetpack/Launch Screen.storyboard @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Jetpack/Resources/AppStoreStrings.po b/WordPress/Jetpack/Resources/AppStoreStrings.po new file mode 100644 index 000000000000..d0bbf0ff8feb --- /dev/null +++ b/WordPress/Jetpack/Resources/AppStoreStrings.po @@ -0,0 +1,127 @@ +# Translation of Release Notes & Apple Store Description in English (US) +# This file is distributed under the same license as the Release Notes & Apple Store Description package. +msgid "" +msgstr "" +"PO-Revision-Date: 2018-01-29 17:30-0000\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Poedit 2.0.1\n" +"Project-Id-Version: Release Notes & Apple Store Description\n" +"POT-Creation-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" + +#. translators: The application name in the Apple App Store. Please keep the brand names ('Jetpack' and WordPress') verbatim. Limit to 30 characters including spaces and punctuation! +msgctxt "app_store_name" +msgid "Jetpack – Website Builder" +msgstr "" + +#. translators: Subtitle to be displayed below the application name in the Apple App Store. Limit to 30 characters including spaces and commas! +msgctxt "app_store_subtitle" +msgid "Supercharge your WordPress" +msgstr "" + +#. translators: Multi-paragraph text used to display in the Apple App Store. +msgctxt "app_store_desc" +msgid "" +"Jetpack for WordPress\n" +"\n" +"Put the power of web publishing in your pocket. Jetpack is a website creator and so much more!\n" +"\n" +"CREATE\n" +"\n" +"Give your big ideas a home on the web. Jetpack for iOS is a website builder and a blog maker powered by WordPress. Use it to create your website.\n" +"Pick the right look and feel from a wide selection of WordPress themes, then customize with photos, colors, and fonts so it’s uniquely you.\n" +"Built-in Quick Start tips guide you through the setup basics to set your new website up for success. (We’re not just a website creator — we’re your partner and cheering squad!)\n" +"\n" +"ANALYTICS & INSIGHTS\n" +"\n" +"Check your website’s stats in real time to keep track of the activity on your site.\n" +"Track which posts and pages get the most traffic over time by exploring daily, weekly, monthly, and yearly insights.\n" +"Use the traffic map to see which countries your visitors come from.\n" +"\n" +"NOTIFICATIONS\n" +"\n" +"Get notifications about comments, likes, and new followers so you can see people reacting to your website as it happens.\n" +"Reply to new comments as they show up to keep the conversation flowing and acknowledge your readers.\n" +"\n" +"PUBLISH\n" +"\n" +"Create updates, stories, photo essays announcements — anything! — with the editor.\n" +"Bring your posts and pages to life with photos and video from your camera and albums, or find the perfect image with the in-app collection of free-to-use pro photography.\n" +"Save ideas as drafts and come back to them when your muse returns, or schedule new posts for the future so your site is always fresh and engaging.\n" +"Add tags and categories to help new readers discover your posts, and watch your audience grow.\n" +"\n" +"SECURITY & PERFORMANCE TOOLS\n" +"\n" +"Restore your site from anywhere if something goes wrong.\n" +"Scan for threats and resolve them with a tap.\n" +"Keep tabs on site activity to see who changed what and when.\n" +"\n" +"READER\n" +"\n" +"Jetpack is more than a blog maker — use it to connect with a community of writers in the WordPress.com Reader. Explore thousands of topics by tag, discover new authors and organizations, and follow the ones who pique your interest.\n" +"Hang on to the posts that fascinate you with the Save for later feature.\n" +"\n" +"SHARE\n" +"\n" +"Set up automated sharing to tell your followers on social media when you publish a new post. Automatically cross-post to Facebook, Twitter, and more.\n" +"Add social sharing buttons to your posts so your visitors can share them with their network, and let your fans become your ambassadors.\n" +"\n" +"Learn more at https://jetpack.com/mobile\n" +"\n" +"California users privacy notice: https://automattic.com/privacy/#california-consumer-privacy-act-ccpa\n" +msgstr "" + +#. translators: Keywords used in the App Store search engine to find the app. +#. Delimit with a comma between each keyword. Limit to 100 characters including spaces and commas. +msgctxt "app_store_keywords" +msgid "social,notes,jetpack,writing,geotagging,media,blog,website,blogging,journal" +msgstr "" + +msgctxt "v22.3-whats-new" +msgid "" +"Breaking news—VideoPress blocks are now enabled for Simple WordPress.com websites, so you can host and embed high-quality videos to your heart’s content. We hope those videos include cats.\n" +msgstr "" + +#. translators: This is a promo message that will be attached on top of the first screenshot in the App Store. +#. No specified characters limit here, but try to keep as short as the source one. +msgctxt "screenshot-text-1" +msgid "" +"Create and\n" +"manage your site\n" +"on the go\n" +msgstr "" + +#. translators: This is a promo message that will be attached on top of the second screenshot in the App Store. +#. No specified characters limit here, but try to keep as short as the source one. +msgctxt "screenshot-text-2" +msgid "Create any type of site in a few taps." +msgstr "" + +#. translators: This is a promo message that will be attached on top of the third screenshot in the App Store. +#. No specified characters limit here, but try to keep as short as the source one. +msgctxt "screenshot-text-3" +msgid "Professional designs to help you build." +msgstr "" + +#. translators: This is a promo message that will be attached on top of the fourth screenshot in the App Store. +#. No specified characters limit here, but try to keep as short as the source one. +msgctxt "screenshot-text-4" +msgid "Professional designs to help you build." +msgstr "" + +#. translators: This is a promo message that will be attached on top of the fifth screenshot in the App Store. +#. No specified characters limit here, but try to keep as short as the source one. +msgctxt "screenshot-text-5" +msgid "Keep up with your stats from anywhere." +msgstr "" + +#. translators: This is a promo message that will be attached on top of the fifth screenshot in the App Store. +#. No specified characters limit here, but try to keep as short as the source one. +msgctxt "screenshot-text-6" +msgid "Stay up to date with instant notifications." +msgstr "" + diff --git a/WordPress/Jetpack/Resources/release_notes.txt b/WordPress/Jetpack/Resources/release_notes.txt new file mode 100644 index 000000000000..5a57a8c457e8 --- /dev/null +++ b/WordPress/Jetpack/Resources/release_notes.txt @@ -0,0 +1 @@ +Breaking news—VideoPress blocks are now enabled for Simple WordPress.com websites, so you can host and embed high-quality videos to your heart’s content. We hope those videos include cats. diff --git a/WordPress/Jetpack/TracksConfiguration.swift b/WordPress/Jetpack/TracksConfiguration.swift new file mode 100644 index 000000000000..ff385d66fb7f --- /dev/null +++ b/WordPress/Jetpack/TracksConfiguration.swift @@ -0,0 +1,3 @@ +struct TracksConfiguration { + static let eventNamePrefix = "jpios" +} diff --git a/WordPress/Jetpack/UIColor+JetpackColors.swift b/WordPress/Jetpack/UIColor+JetpackColors.swift new file mode 100644 index 000000000000..861a012f9ae6 --- /dev/null +++ b/WordPress/Jetpack/UIColor+JetpackColors.swift @@ -0,0 +1,51 @@ +import UIKit + +// MARK: - UI elements +extension UIColor { + + /// Muriel/iOS navigation color + static var appBarBackground: UIColor { + .secondarySystemGroupedBackground + } + + static var appBarTint: UIColor { + .text + } + + static var lightAppBarTint: UIColor { + return .text + } + + static var appBarText: UIColor { + .text + } + + static var filterBarBackground: UIColor { + return .secondarySystemGroupedBackground + } + + static var filterBarSelected: UIColor { + return .primary + } + + static var filterBarSelectedText: UIColor { + return .text + } + + static var tabSelected: UIColor { + return .text + } + + /// Note: these values are intended to match the iOS defaults + static var tabUnselected: UIColor = UIColor(light: UIColor(hexString: "999999"), dark: UIColor(hexString: "757575")) + + static var statsPrimaryHighlight: UIColor { + return UIColor(light: muriel(color: MurielColor(name: .pink, shade: .shade30)), + dark: muriel(color: MurielColor(name: .pink, shade: .shade60))) + } + + static var statsSecondaryHighlight: UIColor { + return UIColor(light: muriel(color: MurielColor(name: .pink, shade: .shade60)), + dark: muriel(color: MurielColor(name: .pink, shade: .shade30))) + } +} diff --git a/WordPress/Jetpack/WidgetConfiguration.swift b/WordPress/Jetpack/WidgetConfiguration.swift new file mode 100644 index 000000000000..d3ec31711525 --- /dev/null +++ b/WordPress/Jetpack/WidgetConfiguration.swift @@ -0,0 +1,43 @@ +// Jetpack Widget configuration + +import Foundation + +/// - Warning: +/// This configuration extension has a **WordPress** counterpart in the WordPress bundle. +/// Make sure to keep them in sync to avoid build errors when building the WordPress target. +@objc extension AppConfiguration { + + @objc(AppConfigurationWidget) + class Widget: NSObject { + @objc(AppConfigurationWidgetStats) + class Stats: NSObject { + @objc static let keychainTokenKey = "OAuth2Token" + @objc static let keychainServiceName = "JetpackTodayWidget" + @objc static let userDefaultsSiteIdKey = "JetpackHomeWidgetsSiteId" + @objc static let userDefaultsLoggedInKey = "JetpackHomeWidgetsLoggedIn" + @objc static let userDefaultsJetpackFeaturesDisabledKey = "JetpackJPFeaturesDisabledKey" + @objc static let todayKind = "JetpackHomeWidgetToday" + @objc static let allTimeKind = "JetpackHomeWidgetAllTime" + @objc static let thisWeekKind = "JetpackHomeWidgetThisWeek" + @objc static let todayProperties = "JetpackHomeWidgetTodayProperties" + @objc static let allTimeProperties = "JetpackHomeWidgetAllTimeProperties" + @objc static let thisWeekProperties = "JetpackHomeWidgetThisWeekProperties" + @objc static let todayFilename = "JetpackHomeWidgetTodayData.plist" + @objc static let allTimeFilename = "JetpackHomeWidgetAllTimeData.plist" + @objc static let thisWeekFilename = "JetpackHomeWidgetThisWeekData.plist" + } + + + // iOS13 Stats Today Widgets + @objc(AppConfigurationWidgetStatsToday) + class StatsToday: NSObject { + @objc static let userDefaultsSiteIdKey = "JetpackTodayWidgetSiteId" + @objc static let userDefaultsSiteNameKey = "JetpackTodayWidgetSiteName" + @objc static let userDefaultsSiteUrlKey = "JetpackTodayWidgetSiteUrl" + @objc static let userDefaultsSiteTimeZoneKey = "JetpackTodayWidgetTimeZone" + @objc static let todayFilename = "JetpackTodayData.plist" + @objc static let thisWeekFilename = "JetpackThisWeekData.plist" + @objc static let allTimeFilename = "JetpackAllTimeData.plist" + } + } +} diff --git a/WordPress/JetpackAllFeaturesLogosAnimation_ltr.json b/WordPress/JetpackAllFeaturesLogosAnimation_ltr.json new file mode 100644 index 000000000000..f628bf65715b --- /dev/null +++ b/WordPress/JetpackAllFeaturesLogosAnimation_ltr.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":60,"ip":0,"op":148,"w":259,"h":71,"nm":"Logo animation-4-up-left lottie","ddd":0,"assets":[{"id":"comp_0","nm":"Logo animation-4-up-left","fr":60,"layers":[{"ddd":0,"ind":2,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.185],"y":[1]},"o":{"x":[0.561],"y":[0]},"t":74,"s":[90.001]},{"t":121,"s":[465.242]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":1,"nm":"White Solid 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[89.9,81,0],"ix":2,"l":2},"a":{"a":0,"k":[70,70,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":20,"s":[0,0,100]},{"t":44,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[38.66,0],[0,-38.66],[-38.66,0],[0,38.66]],"o":[[-38.66,0],[0,38.66],[38.66,0],[0,-38.66]],"v":[[70,0],[0,70],[70,140],[140,70]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":140,"sh":140,"sc":"#ffffff","ip":0,"op":61,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"WP 2","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[89.93,81,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":61,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":1,"nm":"Pale Green Solid 1","parent":2,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[17.137,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"WP 3","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":89.93,"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":61,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"WP","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":89.93,"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":61,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":10,"ty":1,"nm":"Pale Green Solid 1","parent":11,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[124.44,65,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[93.284,93.284,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":0,"nm":"Notifications","tt":1,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.186],"y":[0.995]},"o":{"x":[0.567],"y":[0]},"t":74,"s":[90.28]},{"t":121,"s":[340.28]}],"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":58,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":1,"nm":"Pale Green Solid 1","parent":14,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[123.974,65,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[93.284,93.284,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":0,"nm":"Reader","tt":1,"refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.095],"y":[1]},"o":{"x":[0.498],"y":[0]},"t":74,"s":[90.28]},{"t":113.201171875,"s":[215.28]}],"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":58,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":1,"nm":"Pale Green Solid 1","parent":17,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[123.974,65,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[93.284,93.284,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":17,"ty":0,"nm":"Stats","tt":1,"refId":"comp_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":90.28,"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":58,"op":1200,"st":0,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"Notifications","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Gridicon / gridicons-bell","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.523,0.523],[0,0],[0.001,-0.797],[-1.596,0]],"o":[[0,0],[-0.523,0.523],[0,1.596],[0.797,0]],"v":[[-4.38,8.373],[-8.464,4.288],[-9.311,6.331],[-6.422,9.22]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-0.56,0.56],[0,0],[3.38,3.38],[3.38,-3.38],[0,0],[1.089,0.156],[0,0],[0,0]],"o":[[0,0],[0,0],[-0.156,-1.089],[0,0],[3.38,-3.38],[-3.38,-3.38],[0,0],[-0.56,0.56],[0,0],[0,0],[0,0]],"v":[[3.324,13],[4.343,11.98],[4.117,10.39],[4.853,7.389],[10.465,1.777],[10.465,-10.465],[-1.777,-10.465],[-7.388,-4.854],[-10.388,-4.118],[-11.98,-4.344],[-13,-3.325]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gridicon / gridicons-bell","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_3","nm":"Reader","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Gridicon / gridicons-reader","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[64.35,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,1.788],[0,0],[0,0],[0,0],[1.788,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,1.788],[0,0],[-1.788,0]],"v":[[-14.625,9.75],[-14.625,-13],[14.625,-13],[14.625,9.75],[11.375,13],[-11.375,13]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,4.875],[-3.25,4.875],[-3.25,3.25],[-11.375,3.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,1.625],[0,1.625],[0,0],[-11.375,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,-1.625],[0,-1.625],[0,-3.25],[-11.375,-3.25]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[3.25,4.875],[11.375,4.875],[11.375,-3.25],[3.25,-3.25]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,-6.5],[11.375,-6.5],[11.375,-9.75],[-11.375,-9.75]],"c":true},"ix":2},"nm":"Path 6","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gridicon / gridicons-reader","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_4","nm":"Stats","fr":30,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Frame 562","refId":"comp_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[64,62,0],"ix":2,"l":2},"a":{"a":0,"k":[30,30,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":60,"h":60,"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_5","nm":"Frame 562","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,56.613,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15,1.694],[15,1.694],[15,-1.694],[-15,-1.694]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[47.142,28.305,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,7.621],[2.143,7.621],[2.143,-7.621],[-2.143,-7.621]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,21.774,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,10.887],[2.143,10.887],[2.143,-10.887],[-2.143,-10.887]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[12.857,32.662,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,5.444],[2.143,5.444],[2.143,-5.444],[-2.143,-5.444]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Frame 562","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,30,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Frame 562","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Logo animation-4-up-left","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[128,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[275,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":550,"h":162,"ip":0,"op":148,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/WordPress/JetpackAllFeaturesLogosAnimation_rtl.json b/WordPress/JetpackAllFeaturesLogosAnimation_rtl.json new file mode 100644 index 000000000000..3038caa04483 --- /dev/null +++ b/WordPress/JetpackAllFeaturesLogosAnimation_rtl.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":60,"ip":0,"op":148,"w":259,"h":71,"nm":"Logo animation-4-up-right lottie","ddd":0,"assets":[{"id":"comp_0","nm":"Logo animation-4-up-right","fr":60,"layers":[{"ddd":0,"ind":2,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.109],"y":[1]},"o":{"x":[0.58],"y":[0]},"t":74,"s":[465.242]},{"t":121,"s":[90.001]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":1,"nm":"White Solid 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[464.9,81,0],"ix":2,"l":2},"a":{"a":0,"k":[70,70,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":20,"s":[0,0,100]},{"t":44,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[38.66,0],[0,-38.66],[-38.66,0],[0,38.66]],"o":[[-38.66,0],[0,38.66],[38.66,0],[0,-38.66]],"v":[[70,0],[0,70],[70,140],[140,70]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":140,"sh":140,"sc":"#ffffff","ip":0,"op":61,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"WP 2","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[464.93,81,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":61,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":6,"ty":1,"nm":"Pale Green Solid 1","parent":2,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-93.083,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"WP 3","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":464.93,"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":61,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"WP","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":464.93,"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":61,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":10,"ty":1,"nm":"Pale Green Solid 1","parent":11,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[6.903,65,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[-93.284,-93.284,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":0,"nm":"Notifications","tt":1,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.186],"y":[1.005]},"o":{"x":[0.567],"y":[0]},"t":74,"s":[465.08]},{"t":121,"s":[214.28]}],"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":58,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":1,"nm":"Pale Green Solid 1","parent":14,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[5.504,65,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[-93.284,-93.284,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":0,"nm":"Reader","tt":1,"refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.095],"y":[1]},"o":{"x":[0.498],"y":[0]},"t":74,"s":[465.08]},{"t":113,"s":[339.28]}],"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":58,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":16,"ty":1,"nm":"Pale Green Solid 1","parent":17,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[6.437,65,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[-93.284,-93.284,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":17,"ty":0,"nm":"Stats","tt":1,"refId":"comp_4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":465.08,"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":58,"op":1200,"st":0,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"Notifications","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Gridicon / gridicons-bell","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.523,0.523],[0,0],[0.001,-0.797],[-1.596,0]],"o":[[0,0],[-0.523,0.523],[0,1.596],[0.797,0]],"v":[[-4.38,8.373],[-8.464,4.288],[-9.311,6.331],[-6.422,9.22]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-0.56,0.56],[0,0],[3.38,3.38],[3.38,-3.38],[0,0],[1.089,0.156],[0,0],[0,0]],"o":[[0,0],[0,0],[-0.156,-1.089],[0,0],[3.38,-3.38],[-3.38,-3.38],[0,0],[-0.56,0.56],[0,0],[0,0],[0,0]],"v":[[3.324,13],[4.343,11.98],[4.117,10.39],[4.853,7.389],[10.465,1.777],[10.465,-10.465],[-1.777,-10.465],[-7.388,-4.854],[-10.388,-4.118],[-11.98,-4.344],[-13,-3.325]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gridicon / gridicons-bell","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_3","nm":"Reader","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Gridicon / gridicons-reader","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[64.35,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,1.788],[0,0],[0,0],[0,0],[1.788,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,1.788],[0,0],[-1.788,0]],"v":[[-14.625,9.75],[-14.625,-13],[14.625,-13],[14.625,9.75],[11.375,13],[-11.375,13]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,4.875],[-3.25,4.875],[-3.25,3.25],[-11.375,3.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,1.625],[0,1.625],[0,0],[-11.375,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,-1.625],[0,-1.625],[0,-3.25],[-11.375,-3.25]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[3.25,4.875],[11.375,4.875],[11.375,-3.25],[3.25,-3.25]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,-6.5],[11.375,-6.5],[11.375,-9.75],[-11.375,-9.75]],"c":true},"ix":2},"nm":"Path 6","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gridicon / gridicons-reader","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_4","nm":"Stats","fr":30,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Frame 562","refId":"comp_5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[64,62,0],"ix":2,"l":2},"a":{"a":0,"k":[30,30,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":60,"h":60,"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_5","nm":"Frame 562","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,56.613,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15,1.694],[15,1.694],[15,-1.694],[-15,-1.694]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[47.142,28.305,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,7.621],[2.143,7.621],[2.143,-7.621],[-2.143,-7.621]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,21.774,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,10.887],[2.143,10.887],[2.143,-10.887],[-2.143,-10.887]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[12.857,32.662,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,5.444],[2.143,5.444],[2.143,-5.444],[-2.143,-5.444]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Frame 562","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,30,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Frame 562","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Logo animation-4-up-right","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[128.1,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[275,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":550,"h":162,"ip":0,"op":148,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/WordPress/JetpackDraftActionExtension/Info-Alpha.plist b/WordPress/JetpackDraftActionExtension/Info-Alpha.plist new file mode 100644 index 000000000000..8cb818fa56f1 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/Info-Alpha.plist @@ -0,0 +1,81 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Save as Draft (JP Alpha) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIcons + + CFBundleIcons~ipad + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleSignature + ???? + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + SUBQUERY( + extensionItems, + $extensionItem, + (SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.png" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg-2000" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.tiff" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.compuserve.gif" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.microsoft.bmp" + ).@count >= 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" + ).@count == 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" + ).@count == 1) + AND + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.find-login-action" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.save-login-action" + ).@count == 0 + ).@count >= 1 + + NSExtensionJavaScriptPreprocessingFile + WordPressShare + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.ui-services + + + diff --git a/WordPress/JetpackDraftActionExtension/Info-Internal.plist b/WordPress/JetpackDraftActionExtension/Info-Internal.plist new file mode 100644 index 000000000000..174168dac9c6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/Info-Internal.plist @@ -0,0 +1,81 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Save as Draft (JP Internal) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIcons + + CFBundleIcons~ipad + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleSignature + ???? + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + SUBQUERY( + extensionItems, + $extensionItem, + (SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.png" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg-2000" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.tiff" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.compuserve.gif" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.microsoft.bmp" + ).@count >= 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" + ).@count == 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" + ).@count == 1) + AND + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.find-login-action" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.save-login-action" + ).@count == 0 + ).@count >= 1 + + NSExtensionJavaScriptPreprocessingFile + WordPressShare + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.ui-services + + + diff --git a/WordPress/JetpackDraftActionExtension/Info.plist b/WordPress/JetpackDraftActionExtension/Info.plist new file mode 100644 index 000000000000..0f71bf7733c9 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/Info.plist @@ -0,0 +1,81 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Save as Draft + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIcons + + CFBundleIcons~ipad + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleSignature + ???? + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + SUBQUERY( + extensionItems, + $extensionItem, + (SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.png" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg-2000" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.tiff" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.compuserve.gif" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.microsoft.bmp" + ).@count >= 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" + ).@count == 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" + ).@count == 1) + AND + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.find-login-action" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.save-login-action" + ).@count == 0 + ).@count >= 1 + + NSExtensionJavaScriptPreprocessingFile + WordPressShare + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.ui-services + + + diff --git a/WordPress/JetpackDraftActionExtension/ar.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/ar.lproj/InfoPlist.strings new file mode 100644 index 000000000000..607e08b6f873 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/ar.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + حفظ كمسودة + CFBundleName + حفظ كمسودة + + diff --git a/WordPress/JetpackDraftActionExtension/bg.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/bg.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/bg.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/JetpackDraftActionExtension/cs.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/cs.lproj/InfoPlist.strings new file mode 100644 index 000000000000..17e52fd7b9a3 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/cs.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Uložit jako koncept + CFBundleName + Uložit jako koncept + + diff --git a/WordPress/JetpackDraftActionExtension/cy.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/cy.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/cy.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/JetpackDraftActionExtension/da.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/da.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/da.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/JetpackDraftActionExtension/de.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/de.lproj/InfoPlist.strings new file mode 100644 index 000000000000..1c84fcd0b709 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/de.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Als Entwurf speichern + CFBundleName + Als Entwurf speichern + + diff --git a/WordPress/JetpackDraftActionExtension/en-AU.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/en-AU.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/en-AU.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/JetpackDraftActionExtension/en-CA.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/en-CA.lproj/InfoPlist.strings new file mode 100644 index 000000000000..80bc9e224780 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/en-CA.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Save as Draft + CFBundleName + Save as Draft + + diff --git a/WordPress/JetpackDraftActionExtension/en-GB.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/en-GB.lproj/InfoPlist.strings new file mode 100644 index 000000000000..80bc9e224780 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/en-GB.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Save as Draft + CFBundleName + Save as Draft + + diff --git a/WordPress/JetpackDraftActionExtension/en.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/en.lproj/InfoPlist.strings new file mode 100644 index 000000000000..7f4205fb725d --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/en.lproj/InfoPlist.strings @@ -0,0 +1,5 @@ +/* Name of the "Save as Draft" action as it should appear in the iOS Share Sheet when sharing content from other apps to Jetpack */ +CFBundleDisplayName = "Save as Draft"; + +/* Name of the "Save as Draft" action as it should appear in the iOS Share Sheet when sharing content from other apps to Jetpack. Must be less than 16 characters long. Typically the same text as CFBundleDisplayName, but could be shorter if needed. */ +CFBundleName = "Save as Draft"; diff --git a/WordPress/JetpackDraftActionExtension/es.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/es.lproj/InfoPlist.strings new file mode 100644 index 000000000000..0484d274a926 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/es.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Guardar como borrador + CFBundleName + Guardar como borrador + + diff --git a/WordPress/JetpackDraftActionExtension/fr.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/fr.lproj/InfoPlist.strings new file mode 100644 index 000000000000..c88e6c9c10a8 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/fr.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Enregistrer comme brouillon + CFBundleName + Enregistrer comme brouillon + + diff --git a/WordPress/JetpackDraftActionExtension/he.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/he.lproj/InfoPlist.strings new file mode 100644 index 000000000000..48a2463ce85b --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/he.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + לשמור כטיוטה + CFBundleName + לשמור כטיוטה + + diff --git a/WordPress/JetpackDraftActionExtension/hr.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/hr.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/hr.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/JetpackDraftActionExtension/hu.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/hu.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/hu.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/JetpackDraftActionExtension/id.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/id.lproj/InfoPlist.strings new file mode 100644 index 000000000000..0320de01cc58 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/id.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Simpan sebagai Konsep + CFBundleName + Simpan sebagai Konsep + + diff --git a/WordPress/JetpackDraftActionExtension/is.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/is.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/is.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/JetpackDraftActionExtension/it.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/it.lproj/InfoPlist.strings new file mode 100644 index 000000000000..2dcfc1da56c4 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/it.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Salva come Bozza + CFBundleName + Salva come Bozza + + diff --git a/WordPress/JetpackDraftActionExtension/ja.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/ja.lproj/InfoPlist.strings new file mode 100644 index 000000000000..aaac4de8a3b3 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/ja.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + 下書きとして保存 + CFBundleName + 下書きとして保存 + + diff --git a/WordPress/JetpackDraftActionExtension/ko.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/ko.lproj/InfoPlist.strings new file mode 100644 index 000000000000..87ab0a91082f --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/ko.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + 임시글로 저장 + CFBundleName + 임시글로 저장 + + diff --git a/WordPress/JetpackDraftActionExtension/nb.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/nb.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/nb.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/JetpackDraftActionExtension/nl.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/nl.lproj/InfoPlist.strings new file mode 100644 index 000000000000..4d72c12702ad --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/nl.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Opslaan als concept + CFBundleName + Opslaan als concept + + diff --git a/WordPress/JetpackDraftActionExtension/pl.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/pl.lproj/InfoPlist.strings new file mode 100644 index 000000000000..fcc6b3e31bf0 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/pl.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Zapisz jako szkic + CFBundleName + Zapisz jako szkic + + diff --git a/WordPress/JetpackDraftActionExtension/pt-BR.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/pt-BR.lproj/InfoPlist.strings new file mode 100644 index 000000000000..3134bedcab34 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/pt-BR.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Salvar como rascunho + CFBundleName + Salvar como rascunho + + diff --git a/WordPress/JetpackDraftActionExtension/pt.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/pt.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/pt.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/JetpackDraftActionExtension/ro.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/ro.lproj/InfoPlist.strings new file mode 100644 index 000000000000..fb538aea8f51 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/ro.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Salvează ca ciornă + CFBundleName + Salvează ca ciornă + + diff --git a/WordPress/JetpackDraftActionExtension/ru.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/ru.lproj/InfoPlist.strings new file mode 100644 index 000000000000..4b705eaf6d62 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/ru.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Сохранить как черновик + CFBundleName + Сохранить как черновик + + diff --git a/WordPress/JetpackDraftActionExtension/sk.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/sk.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/sk.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/JetpackDraftActionExtension/sq.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/sq.lproj/InfoPlist.strings new file mode 100644 index 000000000000..a23d77dede7e --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/sq.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Ruaje si Skicë + CFBundleName + Ruaje si Skicë + + diff --git a/WordPress/JetpackDraftActionExtension/sv.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/sv.lproj/InfoPlist.strings new file mode 100644 index 000000000000..8d9c71b6edaa --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/sv.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Spara som utkast + CFBundleName + Spara som utkast + + diff --git a/WordPress/JetpackDraftActionExtension/th.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/th.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/th.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/JetpackDraftActionExtension/tr.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/tr.lproj/InfoPlist.strings new file mode 100644 index 000000000000..f079af2dea90 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/tr.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + Taslak olarak kaydet + CFBundleName + Taslak olarak kaydet + + diff --git a/WordPress/JetpackDraftActionExtension/zh-Hans.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 000000000000..eaefa69961ba --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/zh-Hans.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + 保存为草稿 + CFBundleName + 保存为草稿 + + diff --git a/WordPress/JetpackDraftActionExtension/zh-Hant.lproj/InfoPlist.strings b/WordPress/JetpackDraftActionExtension/zh-Hant.lproj/InfoPlist.strings new file mode 100644 index 000000000000..9bb61b62cc30 --- /dev/null +++ b/WordPress/JetpackDraftActionExtension/zh-Hant.lproj/InfoPlist.strings @@ -0,0 +1,11 @@ + + + + + + CFBundleDisplayName + 儲存為草稿 + CFBundleName + 儲存為草稿 + + diff --git a/WordPress/JetpackInstallPluginLogoAnimation_ltr.json b/WordPress/JetpackInstallPluginLogoAnimation_ltr.json new file mode 100644 index 000000000000..b8ce68a236e8 --- /dev/null +++ b/WordPress/JetpackInstallPluginLogoAnimation_ltr.json @@ -0,0 +1 @@ +{"v":"5.10.2","fr":60,"ip":0,"op":145,"w":131,"h":71,"nm":"jp-error-left lottie","ddd":0,"assets":[{"id":"comp_0","nm":"jp-error-left","fr":60,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.186],"y":[1]},"o":{"x":[0.561],"y":[0]},"t":74,"s":[90.001]},{"t":106,"s":[209.863]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":1,"nm":"Pale Green Solid 1","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[17.137,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Warning icon","tt":1,"tp":2,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[90.28,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Warning icon","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[90.28,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":58,"st":0,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"tp":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"tp":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"Warning icon","fr":30,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"warning","refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[64,64,0],"ix":2,"l":2},"a":{"a":0,"k":[40,40,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":80,"h":80,"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Warning icon","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":100,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.839215695858,0.211764708161,0.219607844949,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Warning icon","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_3","nm":"warning","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[40,40,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,7.367],[7.367,0],[0,-7.367],[-7.367,0]],"o":[[0,-7.367],[-7.367,0],[0,7.367],[7.367,0]],"v":[[13.333,0],[0,-13.333],[-13.333,0],[0,13.333]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[2.467,-8.467],[1.883,2.3],[-1.883,2.3],[-2.467,-8.467]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,-0.7],[0.4,-0.383],[0,0],[0.75,0],[0.433,0.383],[0,0.683],[-0.417,0.383],[-0.783,0],[-0.4,-0.383]],"o":[[0,0.683],[0,0],[-0.417,0.383],[-0.75,0],[-0.417,-0.383],[0,-0.7],[0.417,-0.383],[0.783,0],[0.4,0.383]],"v":[[2.35,6.3],[1.733,7.9],[1.733,7.9],[-0.017,8.467],[-1.783,7.9],[-2.417,6.3],[-1.8,4.683],[-0.017,4.1],[1.75,4.683]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"warning","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[40,40,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[40,40],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":2,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"warning","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"jp-error-left","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.5,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":300,"h":162,"ip":0,"op":145,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"","dr":145}]} \ No newline at end of file diff --git a/WordPress/JetpackInstallPluginLogoAnimation_rtl.json b/WordPress/JetpackInstallPluginLogoAnimation_rtl.json new file mode 100644 index 000000000000..bafe45668868 --- /dev/null +++ b/WordPress/JetpackInstallPluginLogoAnimation_rtl.json @@ -0,0 +1 @@ +{"v":"5.10.2","fr":60,"ip":0,"op":145,"w":131,"h":71,"nm":"jp-error-right lottie","ddd":0,"assets":[{"id":"comp_0","nm":"jp-error-right","fr":60,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.228],"y":[1]},"o":{"x":[0.699],"y":[0]},"t":74,"s":[210.263]},{"t":106,"s":[90.001]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":1,"nm":"Pale Green Solid 1","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[137.277,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[-100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Warning icon","tt":1,"tp":2,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[210.32,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Warning icon","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[210.32,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":58,"st":0,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"tp":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"tp":3,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"Warning icon","fr":30,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"warning","refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[64,64,0],"ix":2,"l":2},"a":{"a":0,"k":[40,40,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":80,"h":80,"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Warning icon","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":100,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.839215695858,0.211764708161,0.219607844949,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Warning icon","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_3","nm":"warning","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[40,40,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,7.367],[7.367,0],[0,-7.367],[-7.367,0]],"o":[[0,-7.367],[-7.367,0],[0,7.367],[7.367,0]],"v":[[13.333,0],[0,-13.333],[-13.333,0],[0,13.333]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[2.467,-8.467],[1.883,2.3],[-1.883,2.3],[-2.467,-8.467]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,-0.7],[0.4,-0.383],[0,0],[0.75,0],[0.433,0.383],[0,0.683],[-0.417,0.383],[-0.783,0],[-0.4,-0.383]],"o":[[0,0.683],[0,0],[-0.417,0.383],[-0.75,0],[-0.417,-0.383],[0,-0.7],[0.417,-0.383],[0.783,0],[0.4,0.383]],"v":[[2.35,6.3],[1.733,7.9],[1.733,7.9],[-0.017,8.467],[-1.783,7.9],[-2.417,6.3],[-1.8,4.683],[-0.017,4.1],[1.75,4.683]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"warning","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[40,40,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[40,40],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":2,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"warning","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"jp-error-right","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.5,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":300,"h":162,"ip":0,"op":145,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"","dr":145}]} \ No newline at end of file diff --git a/WordPress/JetpackIntents/Info.plist b/WordPress/JetpackIntents/Info.plist new file mode 100644 index 000000000000..6037556bfebc --- /dev/null +++ b/WordPress/JetpackIntents/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + JetpackIntents + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleVersion + ${VERSION_LONG} + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSExtension + + NSExtensionAttributes + + IntentsRestrictedWhileLocked + + IntentsRestrictedWhileProtectedDataUnavailable + + IntentsSupported + + SelectSiteIntent + + + NSExtensionPointIdentifier + com.apple.intents-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).IntentHandler + + + diff --git a/WordPress/JetpackNotificationServiceExtension/Info-Alpha.plist b/WordPress/JetpackNotificationServiceExtension/Info-Alpha.plist new file mode 100644 index 000000000000..0348f0b11021 --- /dev/null +++ b/WordPress/JetpackNotificationServiceExtension/Info-Alpha.plist @@ -0,0 +1,35 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + WordPressNotificationServiceExtension α + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + UIAppFonts + + Noticons.ttf + + + diff --git a/WordPress/JetpackNotificationServiceExtension/Info-Internal.plist b/WordPress/JetpackNotificationServiceExtension/Info-Internal.plist new file mode 100644 index 000000000000..13538157f5a4 --- /dev/null +++ b/WordPress/JetpackNotificationServiceExtension/Info-Internal.plist @@ -0,0 +1,35 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + WordPressNotificationServiceExtension Internal + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + UIAppFonts + + Noticons.ttf + + + diff --git a/WordPress/JetpackNotificationServiceExtension/Info.plist b/WordPress/JetpackNotificationServiceExtension/Info.plist new file mode 100644 index 000000000000..cff78411ddf8 --- /dev/null +++ b/WordPress/JetpackNotificationServiceExtension/Info.plist @@ -0,0 +1,35 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + WordPressNotificationServiceExtension + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + UIAppFonts + + Noticons.ttf + + + diff --git a/WordPress/JetpackNotificationsLogoAnimation_ltr.json b/WordPress/JetpackNotificationsLogoAnimation_ltr.json new file mode 100644 index 000000000000..bbef939d476e --- /dev/null +++ b/WordPress/JetpackNotificationsLogoAnimation_ltr.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":60,"ip":0,"op":145,"w":131,"h":71,"nm":"jp-notifications-left lottie","ddd":0,"assets":[{"id":"comp_0","nm":"jp-notifications-left","fr":60,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.186],"y":[1]},"o":{"x":[0.561],"y":[0]},"t":74,"s":[90.001]},{"t":106,"s":[209.863]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":1,"nm":"Pale Green Solid 1","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[17.137,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Notifications","tt":1,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[90.28,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Notifications","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[90.28,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":58,"st":0,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"Notifications","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Gridicon / gridicons-bell","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.523,0.523],[0,0],[0.001,-0.797],[-1.596,0]],"o":[[0,0],[-0.523,0.523],[0,1.596],[0.797,0]],"v":[[-4.38,8.373],[-8.464,4.288],[-9.311,6.331],[-6.422,9.22]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-0.56,0.56],[0,0],[3.38,3.38],[3.38,-3.38],[0,0],[1.089,0.156],[0,0],[0,0]],"o":[[0,0],[0,0],[-0.156,-1.089],[0,0],[3.38,-3.38],[-3.38,-3.38],[0,0],[-0.56,0.56],[0,0],[0,0],[0,0]],"v":[[3.324,13],[4.343,11.98],[4.117,10.39],[4.853,7.389],[10.465,1.777],[10.465,-10.465],[-1.777,-10.465],[-7.388,-4.854],[-10.388,-4.118],[-11.98,-4.344],[-13,-3.325]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gridicon / gridicons-bell","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"jp-notifications-left","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.5,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":300,"h":162,"ip":0,"op":145,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"","dr":145}]} \ No newline at end of file diff --git a/WordPress/JetpackNotificationsLogoAnimation_rtl.json b/WordPress/JetpackNotificationsLogoAnimation_rtl.json new file mode 100644 index 000000000000..20a871af81ec --- /dev/null +++ b/WordPress/JetpackNotificationsLogoAnimation_rtl.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":60,"ip":0,"op":145,"w":131,"h":71,"nm":"jp-notifications-right lottie","ddd":0,"assets":[{"id":"comp_0","nm":"jp-notifications-right","fr":60,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.228],"y":[1]},"o":{"x":[0.699],"y":[0]},"t":74,"s":[210.263]},{"t":106,"s":[90.001]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":1,"nm":"Pale Green Solid 1","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[137.277,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[-100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Notifications","tt":1,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[210.32,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Notifications","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[210.32,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":58,"st":0,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"Notifications","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Gridicon / gridicons-bell","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.523,0.523],[0,0],[0.001,-0.797],[-1.596,0]],"o":[[0,0],[-0.523,0.523],[0,1.596],[0.797,0]],"v":[[-4.38,8.373],[-8.464,4.288],[-9.311,6.331],[-6.422,9.22]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[-0.56,0.56],[0,0],[3.38,3.38],[3.38,-3.38],[0,0],[1.089,0.156],[0,0],[0,0]],"o":[[0,0],[0,0],[-0.156,-1.089],[0,0],[3.38,-3.38],[-3.38,-3.38],[0,0],[-0.56,0.56],[0,0],[0,0],[0,0]],"v":[[3.324,13],[4.343,11.98],[4.117,10.39],[4.853,7.389],[10.465,1.777],[10.465,-10.465],[-1.777,-10.465],[-7.388,-4.854],[-10.388,-4.118],[-11.98,-4.344],[-13,-3.325]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gridicon / gridicons-bell","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"jp-notifications-right","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.5,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":300,"h":162,"ip":0,"op":145,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"","dr":145}]} \ No newline at end of file diff --git a/WordPress/JetpackReaderLogoAnimation_ltr.json b/WordPress/JetpackReaderLogoAnimation_ltr.json new file mode 100644 index 000000000000..fcee2b07decb --- /dev/null +++ b/WordPress/JetpackReaderLogoAnimation_ltr.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":60,"ip":0,"op":145,"w":131,"h":71,"nm":"jp-reader-left lottie","ddd":0,"assets":[{"id":"comp_0","nm":"jp-reader-left","fr":60,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.186],"y":[1]},"o":{"x":[0.561],"y":[0]},"t":74,"s":[90.001]},{"t":106,"s":[209.863]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":1,"nm":"Pale Green Solid 1","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[17.137,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Reader","tt":1,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[90.28,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Reader","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[90.28,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":58,"st":0,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"Reader","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Gridicon / gridicons-reader","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[64.35,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,1.788],[0,0],[0,0],[0,0],[1.788,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,1.788],[0,0],[-1.788,0]],"v":[[-14.625,9.75],[-14.625,-13],[14.625,-13],[14.625,9.75],[11.375,13],[-11.375,13]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,4.875],[-3.25,4.875],[-3.25,3.25],[-11.375,3.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,1.625],[0,1.625],[0,0],[-11.375,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,-1.625],[0,-1.625],[0,-3.25],[-11.375,-3.25]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[3.25,4.875],[11.375,4.875],[11.375,-3.25],[3.25,-3.25]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,-6.5],[11.375,-6.5],[11.375,-9.75],[-11.375,-9.75]],"c":true},"ix":2},"nm":"Path 6","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gridicon / gridicons-reader","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"jp-reader-left","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.5,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":300,"h":162,"ip":0,"op":145,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"","dr":145}]} \ No newline at end of file diff --git a/WordPress/JetpackReaderLogoAnimation_rtl.json b/WordPress/JetpackReaderLogoAnimation_rtl.json new file mode 100644 index 000000000000..0976a7a078fd --- /dev/null +++ b/WordPress/JetpackReaderLogoAnimation_rtl.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":60,"ip":0,"op":145,"w":131,"h":71,"nm":"jp-reader-right lottie","ddd":0,"assets":[{"id":"comp_0","nm":"jp-reader-right","fr":60,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.228],"y":[1]},"o":{"x":[0.699],"y":[0]},"t":74,"s":[210.263]},{"t":106,"s":[90.001]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":1,"nm":"Pale Green Solid 1","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[137.277,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[-100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Reader","tt":1,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[210.32,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Reader","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[210.32,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":58,"st":0,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"Reader","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Gridicon / gridicons-reader","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[64.35,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,1.788],[0,0],[0,0],[0,0],[1.788,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,1.788],[0,0],[-1.788,0]],"v":[[-14.625,9.75],[-14.625,-13],[14.625,-13],[14.625,9.75],[11.375,13],[-11.375,13]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,4.875],[-3.25,4.875],[-3.25,3.25],[-11.375,3.25]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,1.625],[0,1.625],[0,0],[-11.375,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,-1.625],[0,-1.625],[0,-3.25],[-11.375,-3.25]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[3.25,4.875],[11.375,4.875],[11.375,-3.25],[3.25,-3.25]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-11.375,-6.5],[11.375,-6.5],[11.375,-9.75],[-11.375,-9.75]],"c":true},"ix":2},"nm":"Path 6","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Gridicon / gridicons-reader","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"jp-reader-right","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.5,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":300,"h":162,"ip":0,"op":145,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"","dr":145}]} \ No newline at end of file diff --git a/WordPress/JetpackScreenshotGeneration/Info.plist b/WordPress/JetpackScreenshotGeneration/Info.plist new file mode 100644 index 000000000000..ba72822e8728 --- /dev/null +++ b/WordPress/JetpackScreenshotGeneration/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + + diff --git a/WordPress/JetpackScreenshotGeneration/JetpackScreenshotGeneration.swift b/WordPress/JetpackScreenshotGeneration/JetpackScreenshotGeneration.swift new file mode 100644 index 000000000000..6b0c6cbc9e71 --- /dev/null +++ b/WordPress/JetpackScreenshotGeneration/JetpackScreenshotGeneration.swift @@ -0,0 +1,103 @@ +import ScreenObject +import UIKit +import UITestsFoundation +import XCTest + +class JetpackScreenshotGeneration: XCTestCase { + let scanWaitTime: UInt32 = 5 + + override func setUpWithError() throws { + super.setUp() + + let app = XCUIApplication.jetpack + + // This does the shared setup including injecting mocks and launching the app + setUpTestSuite(for: app, removeBeforeLaunching: true) + + // The app is already launched so we can set it up for screenshots here + setupSnapshot(app) + + if XCUIDevice.isPad { + XCUIDevice.shared.orientation = UIDeviceOrientation.landscapeLeft + } else { + XCUIDevice.shared.orientation = UIDeviceOrientation.portrait + } + + try LoginFlow.login(email: WPUITestCredentials.testWPcomUserEmail, + password: WPUITestCredentials.testWPcomPassword, + selectedSiteTitle: "yourjetpack.blog") + } + + override func tearDown() { + removeApp(.jetpack) + super.tearDown() + } + + func testGenerateScreenshots() throws { + + let mySite = try MySiteScreen() + + // Open Home + if XCUIDevice.isPad { + mySite.goToHomeScreen() + } + + // Get Site Creation screenshot + let mySitesScreen = try mySite.showSiteSwitcher() + let siteIntentScreen = try mySitesScreen + .tapPlusButton() + .thenTakeScreenshot(1, named: "SiteCreation") + + try siteIntentScreen.closeModal() + try mySitesScreen.closeModal() + + // Get Create New screenshot + let createSheet = try mySite.goToCreateSheet() + .thenTakeScreenshot(2, named: "CreateNew") + + // Get Page Builder screenshot + let chooseLayout = try createSheet.goToSitePage() + .thenTakeScreenshot(3, named: "PageBuilder") + + try chooseLayout.closeModal() + + // Open Menu to be able to access stats + if XCUIDevice.isPhone { + mySite.goToMenu() + } + + // Get Stats screenshot + let statsScreen = try mySite.goToStatsScreen() + statsScreen + .dismissCustomizeInsightsNotice() + .thenTakeScreenshot(4, named: "Stats") + + // Get Notifications screenshot + let notificationList = try TabNavComponent() + .goToNotificationsScreen() + .dismissNotificationAlertIfNeeded() + if XCUIDevice.isPad { + notificationList + .openNotification(withText: "Reyansh Pawar commented on My Top 10 Pastry Recipes") + } + notificationList.thenTakeScreenshot(5, named: "Notifications") + } +} + +extension ScreenObject { + + @discardableResult + func thenTakeScreenshot(_ index: Int, named title: String) -> Self { + let mode = XCUIDevice.inDarkMode ? "dark" : "light" + let filename = "\(index)-\(mode)-\(title)" + + snapshot(filename) + + return self + } +} + +extension XCUIApplication { + + static let jetpack = XCUIApplication(bundleIdentifier: "com.automattic.jetpack") +} diff --git a/WordPress/JetpackShareExtension/Info-Alpha.plist b/WordPress/JetpackShareExtension/Info-Alpha.plist new file mode 100644 index 000000000000..5f6860c4b062 --- /dev/null +++ b/WordPress/JetpackShareExtension/Info-Alpha.plist @@ -0,0 +1,81 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + JP Alpha + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIcons + + CFBundleIcons~ipad + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleSignature + ???? + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionAttributes + + NSExtensionJavaScriptPreprocessingFile + WordPressShare + NSExtensionActivationRule + + SUBQUERY( + extensionItems, + $extensionItem, + (SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.png" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg-2000" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.tiff" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.compuserve.gif" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.microsoft.bmp" + ).@count >= 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" + ).@count == 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" + ).@count == 1) + AND + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.find-login-action" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.save-login-action" + ).@count == 0 + ).@count >= 1 + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + diff --git a/WordPress/JetpackShareExtension/Info-Internal.plist b/WordPress/JetpackShareExtension/Info-Internal.plist new file mode 100644 index 000000000000..f68ab99c931b --- /dev/null +++ b/WordPress/JetpackShareExtension/Info-Internal.plist @@ -0,0 +1,81 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + JP Internal + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIcons + + CFBundleIcons~ipad + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleSignature + ???? + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionAttributes + + NSExtensionJavaScriptPreprocessingFile + WordPressShare + NSExtensionActivationRule + + SUBQUERY( + extensionItems, + $extensionItem, + (SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.png" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg-2000" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.tiff" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.compuserve.gif" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.microsoft.bmp" + ).@count >= 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" + ).@count == 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" + ).@count == 1) + AND + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.find-login-action" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.save-login-action" + ).@count == 0 + ).@count >= 1 + + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + diff --git a/WordPress/JetpackShareExtension/Info.plist b/WordPress/JetpackShareExtension/Info.plist new file mode 100644 index 000000000000..7eb4311db3dc --- /dev/null +++ b/WordPress/JetpackShareExtension/Info.plist @@ -0,0 +1,81 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Jetpack + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIcons + + CFBundleIcons~ipad + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleSignature + ???? + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + SUBQUERY( + extensionItems, + $extensionItem, + (SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.png" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.jpeg-2000" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.tiff" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.compuserve.gif" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.microsoft.bmp" + ).@count >= 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" + ).@count == 1 + OR + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" + ).@count == 1) + AND + SUBQUERY( + $extensionItem.attachments, + $attachment, + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.find-login-action" + || ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.appextension.save-login-action" + ).@count == 0 + ).@count >= 1 + + NSExtensionJavaScriptPreprocessingFile + WordPressShare + + NSExtensionMainStoryboard + MainInterface + NSExtensionPointIdentifier + com.apple.share-services + + + diff --git a/WordPress/JetpackStatsLogoAnimation_ltr.json b/WordPress/JetpackStatsLogoAnimation_ltr.json new file mode 100644 index 000000000000..13c9da05f58e --- /dev/null +++ b/WordPress/JetpackStatsLogoAnimation_ltr.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":60,"ip":0,"op":145,"w":131,"h":71,"nm":"jp-stats-left lottie","ddd":0,"assets":[{"id":"comp_0","nm":"jp-stats-left","fr":60,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.186],"y":[1]},"o":{"x":[0.561],"y":[0]},"t":74,"s":[90.001]},{"t":106,"s":[209.863]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":1,"nm":"Pale Green Solid 1","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[17.137,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Stats","tt":1,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[90.28,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Stats","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[90.28,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":58,"st":0,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"Stats","fr":30,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Frame 562","refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[64,62,0],"ix":2,"l":2},"a":{"a":0,"k":[30,30,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":60,"h":60,"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_3","nm":"Frame 562","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,56.613,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15,1.694],[15,1.694],[15,-1.694],[-15,-1.694]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[47.142,28.305,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,7.621],[2.143,7.621],[2.143,-7.621],[-2.143,-7.621]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,21.774,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,10.887],[2.143,10.887],[2.143,-10.887],[-2.143,-10.887]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[12.857,32.662,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,5.444],[2.143,5.444],[2.143,-5.444],[-2.143,-5.444]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Frame 562","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,30,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Frame 562","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"jp-stats-left","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.5,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":300,"h":162,"ip":0,"op":145,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"","dr":145}]} \ No newline at end of file diff --git a/WordPress/JetpackStatsLogoAnimation_rtl.json b/WordPress/JetpackStatsLogoAnimation_rtl.json new file mode 100644 index 000000000000..f542459e7cd2 --- /dev/null +++ b/WordPress/JetpackStatsLogoAnimation_rtl.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":60,"ip":0,"op":145,"w":131,"h":71,"nm":"jp-stats-right lottie","ddd":0,"assets":[{"id":"comp_0","nm":"jp-stats-right","fr":60,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.228],"y":[1]},"o":{"x":[0.699],"y":[0]},"t":74,"s":[210.263]},{"t":106,"s":[90.001]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":1,"nm":"Pale Green Solid 1","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[137.277,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[-100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"Stats","tt":1,"refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[210.32,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"Stats","refId":"comp_2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[210.32,81,0],"ix":2,"l":2},"a":{"a":0,"k":[65,65,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"w":130,"h":130,"ip":0,"op":58,"st":0,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_2","nm":"Stats","fr":30,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Frame 562","refId":"comp_3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[64,62,0],"ix":2,"l":2},"a":{"a":0,"k":[30,30,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"w":60,"h":60,"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Ellipse 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65,65,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[65,65],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]},{"id":"comp_3","nm":"Frame 562","fr":30,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,56.613,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15,1.694],[15,1.694],[15,-1.694],[-15,-1.694]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[47.142,28.305,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,7.621],[2.143,7.621],[2.143,-7.621],[-2.143,-7.621]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,21.774,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,10.887],[2.143,10.887],[2.143,-10.887],[-2.143,-10.887]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Vector","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[12.857,32.662,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-2.143,5.444],[2.143,5.444],[2.143,-5.444],[-2.143,-5.444]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Frame 562","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[30,30,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[30,30],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Frame 562","np":1,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"jp-stats-right","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.5,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":300,"h":162,"ip":0,"op":145,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"","dr":145}]} \ No newline at end of file diff --git a/WordPress/JetpackStatsWidgets/Info.plist b/WordPress/JetpackStatsWidgets/Info.plist new file mode 100644 index 000000000000..afa7961a7cd2 --- /dev/null +++ b/WordPress/JetpackStatsWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Jetpack Home Today + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/WordPress/JetpackStatsWidgets/LocalizationConfiguration.swift b/WordPress/JetpackStatsWidgets/LocalizationConfiguration.swift new file mode 100644 index 000000000000..afdcb84cbaf7 --- /dev/null +++ b/WordPress/JetpackStatsWidgets/LocalizationConfiguration.swift @@ -0,0 +1,7 @@ +extension AppConfiguration.Widget { + struct Localization { + static let unconfiguredViewTodayTitle = LocalizableStrings.unconfiguredViewJetpackTodayTitle + static let unconfiguredViewThisWeekTitle = LocalizableStrings.unconfiguredViewJetpackThisWeekTitle + static let unconfiguredViewAllTimeTitle = LocalizableStrings.unconfiguredViewJetpackAllTimeTitle + } +} diff --git a/WordPress/JetpackWordPressLogoAnimation_ltr.json b/WordPress/JetpackWordPressLogoAnimation_ltr.json new file mode 100644 index 000000000000..0a9628603ed5 --- /dev/null +++ b/WordPress/JetpackWordPressLogoAnimation_ltr.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":60,"ip":0,"op":145,"w":131,"h":71,"nm":"Logo animation 03-left lottie","ddd":0,"assets":[{"id":"comp_0","nm":"Logo animation 03-left","fr":60,"layers":[{"ddd":0,"ind":3,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.186],"y":[1]},"o":{"x":[0.561],"y":[0]},"t":74,"s":[90.001]},{"t":106,"s":[209.863]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":1,"nm":"White Solid 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[89.9,81,0],"ix":2,"l":2},"a":{"a":0,"k":[70,70,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":20,"s":[0,0,100]},{"t":44,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[38.66,0],[0,-38.66],[-38.66,0],[0,38.66]],"o":[[-38.66,0],[0,38.66],[38.66,0],[0,-38.66]],"v":[[70,0],[0,70],[70,140],[140,70]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":140,"sh":140,"sc":"#ffffff","ip":0,"op":61,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"WP 2","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[89.93,81,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":61,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":8,"ty":1,"nm":"Pale Green Solid 1","parent":3,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[17.137,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"WP 3","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":89.93,"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":211,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"WP","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":89.93,"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":61,"st":0,"ct":1,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Logo animation 03-left","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.5,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":300,"h":162,"ip":0,"op":145,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"","dr":145}]} \ No newline at end of file diff --git a/WordPress/JetpackWordPressLogoAnimation_rtl.json b/WordPress/JetpackWordPressLogoAnimation_rtl.json new file mode 100644 index 000000000000..67726941b2aa --- /dev/null +++ b/WordPress/JetpackWordPressLogoAnimation_rtl.json @@ -0,0 +1 @@ +{"v":"5.9.6","fr":60,"ip":0,"op":145,"w":131,"h":71,"nm":"Logo animation 03-right lottie","ddd":0,"assets":[{"id":"comp_0","nm":"Logo animation 03-right","fr":60,"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Jetpack","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.228],"y":[1]},"o":{"x":[0.699],"y":[0]},"t":74,"s":[210.263]},{"t":106,"s":[90.001]}],"ix":3},"y":{"a":0,"k":81.001,"ix":4}},"a":{"a":0,"k":[77.5,77.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":42,"s":[0,0,100]},{"t":58,"s":[99.8,99.8,100]}],"ix":6,"l":2}},"ao":0,"w":155,"h":155,"ip":32,"op":1212,"st":12,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":1,"nm":"White Solid 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[210.04,81,0],"ix":2,"l":2},"a":{"a":0,"k":[70,70,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":20,"s":[0,0,100]},{"t":44,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"a","pt":{"a":0,"k":{"i":[[38.66,0],[0,-38.66],[-38.66,0],[0,38.66]],"o":[[-38.66,0],[0,38.66],[38.66,0],[0,-38.66]],"v":[[70,0],[0,70],[70,140],[140,70]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":140,"sh":140,"sc":"#ffffff","ip":0,"op":61,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"WP 2","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[210.07,81,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":61,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":1,"nm":"Pale Green Solid 1","parent":1,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[137.277,76.999,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[-100,100,100],"ix":6,"l":2}},"ao":0,"hasMask":true,"masksProperties":[{"inv":false,"mode":"s","pt":{"a":0,"k":{"i":[[38.936,0],[0,-38.936],[-38.936,0],[0,38.936]],"o":[[-38.936,0],[0,38.936],[38.936,0],[0,-38.936]],"v":[[206,11],[135.5,81.5],[206,152],[276.5,81.5]],"c":true},"ix":1},"o":{"a":0,"k":100,"ix":3},"x":{"a":0,"k":0,"ix":4},"nm":"Mask 1"}],"sw":300,"sh":162,"sc":"#bdf0bc","ip":0,"op":1200,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"WP 3","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":210.07,"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":211,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"WP","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":210.07,"ix":3},"y":{"a":0,"k":81,"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[107.2,107.2,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-17.94,0],[0,-17.908],[17.908,0],[0,17.94]],"o":[[17.908,0],[0,17.94],[-17.94,0],[0,-17.908]],"v":[[0,-32.5],[32.5,0],[0,32.5],[-32.5,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.788,-0.065],[0,0],[0,0],[0,0],[0,0],[-1.625,-0.098],[-2.827,0],[0,0],[1.625,-0.163],[1.755,-0.065],[0,0],[0,4.842],[1.137,1.982],[0,2.405],[-2.21,-0.357],[7.572,0],[5.167,-7.93],[-0.585,0],[0,0],[1.625,-0.195]],"o":[[0,0],[0,0],[0,0],[0,0],[-1.625,-0.065],[0,0],[3.055,0],[1.625,-0.098],[0,0],[0,0],[0,0],[0,-3.575],[-1.462,-2.405],[0,-3.867],[-5.2,-4.777],[-10.173,0],[0.682,0.033],[2.99,0],[1.625,-0.065],[0,0]],"v":[[-18.298,-12.285],[-7.215,17.452],[-1.333,-1.495],[-5.428,-12.577],[-8.417,-12.837],[-8.223,-16.803],[-0.65,-16.413],[7.085,-16.803],[7.28,-12.837],[4.615,-12.285],[14.462,14.917],[19.012,-1.333],[17.225,-9.327],[14.3,-16.152],[19.663,-21.288],[0,-28.893],[-24.277,-15.827],[-22.393,-15.795],[-14.69,-16.152],[-14.495,-12.545]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[2.275,4.193],[0.78,-2.08],[3.152,-8.417],[0,10.693]],"o":[[1.008,7.735],[-3.185,8.385],[8.678,-5.005],[0,-5.005]],"v":[[25.48,-13.812],[24.083,0],[14.592,25.285],[28.893,0]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0,-4.225],[-9.685,-4.68],[4.615,12.643]],"o":[[0,11.473],[-4.648,-12.643],[-1.592,3.607]],"v":[[-28.893,0],[-12.675,26.292],[-26.553,-11.667]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[-2.795,0.943],[0,0],[2.633,-7.735],[-2.568,0]],"o":[[0,0],[-2.6,7.67],[2.34,0.715],[3.087,0]],"v":[[8.807,27.43],[0.423,4.745],[-7.443,27.82],[0,28.893]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.458823531866,0.768627464771,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"WP","np":7,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":61,"st":0,"ct":1,"bm":0}]},{"id":"comp_1","nm":"jetpack logo","fr":60,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Circle 3","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Top Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[219.499]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-19.239,5.797],[-1.804,5.797],[-1.804,-28.128]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Circle 2","td":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Bottom Triangle","tt":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"s":true,"x":{"a":0,"k":77.499,"ix":3},"y":{"a":1,"k":[{"i":{"x":[0.104],"y":[1]},"o":{"x":[0.452],"y":[0]},"t":42,"s":[-57.501]},{"t":74,"s":[77.499]}],"ix":4}},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[1.761,-5.883],[1.761,28.043],[19.153,-5.883]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1242,"st":42,"ct":1,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Circle","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[77.499,77.499,0],"ix":2,"l":2},"a":{"a":0,"k":[0,0,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,19.325],[-19.282,0],[0,-19.325],[19.325,0]],"o":[[0,-19.325],[19.325,0],[0,19.325],[-19.325,0]],"v":[[-35,0],[0,-35],[35,0],[0,35]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.023529412225,0.61960786581,0.031372550875,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[200,200],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Vector","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":1200,"st":0,"ct":1,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":0,"nm":"Logo animation 03-right","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.5,35.5,0],"ix":2,"l":2},"a":{"a":0,"k":[150,81,0],"ix":1,"l":2},"s":{"a":0,"k":[50,50,100],"ix":6,"l":2}},"ao":0,"w":300,"h":162,"ip":0,"op":145,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"","dr":145}]} \ No newline at end of file diff --git a/WordPress/Launch Screen.storyboard b/WordPress/Launch Screen.storyboard index 30fc6bb9b699..6f68ed53314e 100644 --- a/WordPress/Launch Screen.storyboard +++ b/WordPress/Launch Screen.storyboard @@ -1,12 +1,10 @@ - - - - + + - - + + @@ -19,15 +17,15 @@ - + - - - + + + - + @@ -40,9 +38,9 @@ - - - - + + + + diff --git a/WordPress/News.strings b/WordPress/News.strings deleted file mode 100644 index 004accdd23c4..000000000000 --- a/WordPress/News.strings +++ /dev/null @@ -1,11 +0,0 @@ -/* News Card title. */ -"Title" = "Offline Support"; - -/* News Card content. */ -"Content" = "Our mobile apps now support offline publishing and editing."; - -/* News Card link. */ -"URL" = "https://en.blog.wordpress.com/2020/01/30/improved-offline-publishing/"; - -/* Build version this card applies to. */ -"version" = "14.1"; diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Contents.json b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Contents.json index 759ede1df070..6863677eb37a 100644 --- a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Contents.json +++ b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Contents.json @@ -1,148 +1,152 @@ { "images" : [ { + "filename" : "Icon-App-20x20@2x.png", "idiom" : "iphone", - "size" : "20x20", - "scale" : "2x" + "scale" : "2x", + "size" : "20x20" }, { + "filename" : "Icon-App-20x20@3x.png", "idiom" : "iphone", - "size" : "20x20", - "scale" : "3x" + "scale" : "3x", + "size" : "20x20" }, { - "size" : "29x29", + "filename" : "Icon-App-29x29.png", "idiom" : "iphone", - "filename" : "app-icon-29pt.png", - "scale" : "1x" + "scale" : "1x", + "size" : "29x29" }, { - "size" : "29x29", + "filename" : "Icon-App-29x29@2x.png", "idiom" : "iphone", - "filename" : "app-icon-29pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "29x29" }, { - "size" : "29x29", + "filename" : "Icon-App-29x29@3x.png", "idiom" : "iphone", - "filename" : "app-icon-29pt@3x.png", - "scale" : "3x" + "scale" : "3x", + "size" : "29x29" }, { - "size" : "40x40", + "filename" : "Icon-App-40x40@2x.png", "idiom" : "iphone", - "filename" : "app-icon-40pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "40x40" }, { - "size" : "40x40", + "filename" : "Icon-App-40x40@3x.png", "idiom" : "iphone", - "filename" : "app-icon-40pt@3x.png", - "scale" : "3x" + "scale" : "3x", + "size" : "40x40" }, { "idiom" : "iphone", - "size" : "57x57", - "scale" : "1x" + "scale" : "1x", + "size" : "57x57" }, { "idiom" : "iphone", - "size" : "57x57", - "scale" : "2x" + "scale" : "2x", + "size" : "57x57" }, { - "size" : "60x60", + "filename" : "Icon-App-60x60@2x.png", "idiom" : "iphone", - "filename" : "app-icon-60pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "60x60" }, { - "size" : "60x60", + "filename" : "Icon-App-60x60@3x.png", "idiom" : "iphone", - "filename" : "app-icon-60pt@3x.png", - "scale" : "3x" + "scale" : "3x", + "size" : "60x60" }, { + "filename" : "Icon-App-20x20.png", "idiom" : "ipad", - "size" : "20x20", - "scale" : "1x" + "scale" : "1x", + "size" : "20x20" }, { + "filename" : "Icon-App-20x20@2x-1.png", "idiom" : "ipad", - "size" : "20x20", - "scale" : "2x" + "scale" : "2x", + "size" : "20x20" }, { - "size" : "29x29", + "filename" : "Icon-App-29x29-1.png", "idiom" : "ipad", - "filename" : "ipad-app-icon-29pt.png", - "scale" : "1x" + "scale" : "1x", + "size" : "29x29" }, { - "size" : "29x29", + "filename" : "Icon-App-29x29@2x-1.png", "idiom" : "ipad", - "filename" : "ipad-app-icon-29pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "29x29" }, { - "size" : "40x40", + "filename" : "Icon-App-40x40.png", "idiom" : "ipad", - "filename" : "app-icon-40pt.png", - "scale" : "1x" + "scale" : "1x", + "size" : "40x40" }, { - "size" : "40x40", + "filename" : "Icon-App-40x40@2x-1.png", "idiom" : "ipad", - "filename" : "ipad-app-icon-40pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "40x40" }, { "idiom" : "ipad", - "size" : "50x50", - "scale" : "1x" + "scale" : "1x", + "size" : "50x50" }, { "idiom" : "ipad", - "size" : "50x50", - "scale" : "2x" + "scale" : "2x", + "size" : "50x50" }, { "idiom" : "ipad", - "size" : "72x72", - "scale" : "1x" + "scale" : "1x", + "size" : "72x72" }, { "idiom" : "ipad", - "size" : "72x72", - "scale" : "2x" + "scale" : "2x", + "size" : "72x72" }, { - "size" : "76x76", + "filename" : "Icon-App-76x76.png", "idiom" : "ipad", - "filename" : "app-icon-76pt.png", - "scale" : "1x" + "scale" : "1x", + "size" : "76x76" }, { - "size" : "76x76", + "filename" : "Icon-App-76x76@2x.png", "idiom" : "ipad", - "filename" : "app-icon-76pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "76x76" }, { - "size" : "83.5x83.5", + "filename" : "Icon-App-83.5x83.5@2x.png", "idiom" : "ipad", - "filename" : "app-icon-167.png", - "scale" : "2x" + "scale" : "2x", + "size" : "83.5x83.5" }, { - "size" : "1024x1024", + "filename" : "Icon-App-iTunes.png", "idiom" : "ios-marketing", - "filename" : "app-icon-1024.png", - "scale" : "1x" + "scale" : "1x", + "size" : "1024x1024" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20.png new file mode 100644 index 000000000000..d75355a946fc Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20@2x-1.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20@2x-1.png new file mode 100644 index 000000000000..af5087df6d6d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20@2x-1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 000000000000..af5087df6d6d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20@3x.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 000000000000..b230dc28e1c1 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-20x20@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29-1.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29-1.png new file mode 100644 index 000000000000..42c2b1165d31 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29-1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29.png new file mode 100644 index 000000000000..42c2b1165d31 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29@2x-1.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29@2x-1.png new file mode 100644 index 000000000000..421511686bbf Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29@2x-1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 000000000000..421511686bbf Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29@3x.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 000000000000..c71ba747fd65 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-29x29@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40.png new file mode 100644 index 000000000000..9a99713b8869 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40@2x-1.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40@2x-1.png new file mode 100644 index 000000000000..aac276a84b1d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40@2x-1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 000000000000..aac276a84b1d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40@3x.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 000000000000..cef2aacbdbb7 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-40x40@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-60x60@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 000000000000..cef2aacbdbb7 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-60x60@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-60x60@3x.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 000000000000..2fb4a3ed7cdc Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-60x60@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-76x76.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-76x76.png new file mode 100644 index 000000000000..f86621858e31 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-76x76.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-76x76@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 000000000000..d4ec75c66351 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-76x76@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-83.5x83.5@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 000000000000..b88d9729d326 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-iTunes.png b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-iTunes.png new file mode 100644 index 000000000000..0fdbb719df2e Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon-Internal.appiconset/Icon-App-iTunes.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Contents.json b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Contents.json index 759ede1df070..df1fdffcc936 100644 --- a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Contents.json +++ b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Contents.json @@ -1,148 +1,152 @@ { "images" : [ { + "filename" : "Icon-App-20x20@2x-1.png", "idiom" : "iphone", - "size" : "20x20", - "scale" : "2x" + "scale" : "2x", + "size" : "20x20" }, { + "filename" : "Icon-App-20x20@3x.png", "idiom" : "iphone", - "size" : "20x20", - "scale" : "3x" + "scale" : "3x", + "size" : "20x20" }, { - "size" : "29x29", + "filename" : "Icon-App-29x29.png", "idiom" : "iphone", - "filename" : "app-icon-29pt.png", - "scale" : "1x" + "scale" : "1x", + "size" : "29x29" }, { - "size" : "29x29", + "filename" : "Icon-App-29x29@2x.png", "idiom" : "iphone", - "filename" : "app-icon-29pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "29x29" }, { - "size" : "29x29", + "filename" : "Icon-App-29x29@3x.png", "idiom" : "iphone", - "filename" : "app-icon-29pt@3x.png", - "scale" : "3x" + "scale" : "3x", + "size" : "29x29" }, { - "size" : "40x40", + "filename" : "Icon-App-40x40@2x.png", "idiom" : "iphone", - "filename" : "app-icon-40pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "40x40" }, { - "size" : "40x40", + "filename" : "Icon-App-40x40@3x.png", "idiom" : "iphone", - "filename" : "app-icon-40pt@3x.png", - "scale" : "3x" + "scale" : "3x", + "size" : "40x40" }, { "idiom" : "iphone", - "size" : "57x57", - "scale" : "1x" + "scale" : "1x", + "size" : "57x57" }, { "idiom" : "iphone", - "size" : "57x57", - "scale" : "2x" + "scale" : "2x", + "size" : "57x57" }, { - "size" : "60x60", + "filename" : "Icon-App-60x60@2x.png", "idiom" : "iphone", - "filename" : "app-icon-60pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "60x60" }, { - "size" : "60x60", + "filename" : "Icon-App-60x60@3x.png", "idiom" : "iphone", - "filename" : "app-icon-60pt@3x.png", - "scale" : "3x" + "scale" : "3x", + "size" : "60x60" }, { + "filename" : "Icon-App-20x20.png", "idiom" : "ipad", - "size" : "20x20", - "scale" : "1x" + "scale" : "1x", + "size" : "20x20" }, { + "filename" : "Icon-App-20x20@2x.png", "idiom" : "ipad", - "size" : "20x20", - "scale" : "2x" + "scale" : "2x", + "size" : "20x20" }, { - "size" : "29x29", + "filename" : "Icon-App-29x29-1.png", "idiom" : "ipad", - "filename" : "ipad-app-icon-29pt.png", - "scale" : "1x" + "scale" : "1x", + "size" : "29x29" }, { - "size" : "29x29", + "filename" : "Icon-App-29x29@2x-1.png", "idiom" : "ipad", - "filename" : "ipad-app-icon-29pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "29x29" }, { - "size" : "40x40", + "filename" : "Icon-App-40x40.png", "idiom" : "ipad", - "filename" : "app-icon-40pt.png", - "scale" : "1x" + "scale" : "1x", + "size" : "40x40" }, { - "size" : "40x40", + "filename" : "Icon-App-40x40@2x-1.png", "idiom" : "ipad", - "filename" : "ipad-app-icon-40pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "40x40" }, { "idiom" : "ipad", - "size" : "50x50", - "scale" : "1x" + "scale" : "1x", + "size" : "50x50" }, { "idiom" : "ipad", - "size" : "50x50", - "scale" : "2x" + "scale" : "2x", + "size" : "50x50" }, { "idiom" : "ipad", - "size" : "72x72", - "scale" : "1x" + "scale" : "1x", + "size" : "72x72" }, { "idiom" : "ipad", - "size" : "72x72", - "scale" : "2x" + "scale" : "2x", + "size" : "72x72" }, { - "size" : "76x76", + "filename" : "Icon-App-76x76.png", "idiom" : "ipad", - "filename" : "app-icon-76pt.png", - "scale" : "1x" + "scale" : "1x", + "size" : "76x76" }, { - "size" : "76x76", + "filename" : "Icon-App-76x76@2x.png", "idiom" : "ipad", - "filename" : "app-icon-76pt@2x.png", - "scale" : "2x" + "scale" : "2x", + "size" : "76x76" }, { - "size" : "83.5x83.5", + "filename" : "Icon-App-83.5x83.5@2x.png", "idiom" : "ipad", - "filename" : "app-icon-167.png", - "scale" : "2x" + "scale" : "2x", + "size" : "83.5x83.5" }, { - "size" : "1024x1024", + "filename" : "Icon-App-iTunes.png", "idiom" : "ios-marketing", - "filename" : "app-icon-1024.png", - "scale" : "1x" + "scale" : "1x", + "size" : "1024x1024" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20.png new file mode 100644 index 000000000000..d75355a946fc Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png new file mode 100644 index 000000000000..af5087df6d6d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20@2x-1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 000000000000..af5087df6d6d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 000000000000..b230dc28e1c1 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29-1.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29-1.png new file mode 100644 index 000000000000..42c2b1165d31 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29-1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29.png new file mode 100644 index 000000000000..42c2b1165d31 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png new file mode 100644 index 000000000000..421511686bbf Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29@2x-1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 000000000000..421511686bbf Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 000000000000..c71ba747fd65 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40.png new file mode 100644 index 000000000000..9a99713b8869 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png new file mode 100644 index 000000000000..aac276a84b1d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40@2x-1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 000000000000..aac276a84b1d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 000000000000..cef2aacbdbb7 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 000000000000..cef2aacbdbb7 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 000000000000..2fb4a3ed7cdc Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-76x76.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-76x76.png new file mode 100644 index 000000000000..f86621858e31 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-76x76.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 000000000000..d4ec75c66351 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 000000000000..b88d9729d326 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-iTunes.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-iTunes.png new file mode 100644 index 000000000000..30f124f79ef1 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/Icon-App-iTunes.png differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-1024.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-1024.png deleted file mode 100644 index 63b623165ee4..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-1024.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-167.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-167.png deleted file mode 100644 index 237b58ef38a9..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-167.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-29pt.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-29pt.png deleted file mode 100644 index 3f542997a146..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-29pt.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-29pt@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-29pt@2x.png deleted file mode 100644 index a2f668b64c86..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-29pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-29pt@3x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-29pt@3x.png deleted file mode 100644 index f7d32027fa41..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-29pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-40pt.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-40pt.png deleted file mode 100644 index 5865c2baea48..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-40pt.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-40pt@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-40pt@2x.png deleted file mode 100644 index e284d01439ba..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-40pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-40pt@3x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-40pt@3x.png deleted file mode 100644 index acb6e5423921..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-40pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-60pt@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-60pt@2x.png deleted file mode 100644 index e31add273736..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-60pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-60pt@3x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-60pt@3x.png deleted file mode 100644 index 2ad64d716b6c..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-60pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-76pt.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-76pt.png deleted file mode 100644 index 108376908b33..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-76pt.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-76pt@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-76pt@2x.png deleted file mode 100644 index 8cd47e12c0f0..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/app-icon-76pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/ipad-app-icon-29pt.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/ipad-app-icon-29pt.png deleted file mode 100644 index 3f542997a146..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/ipad-app-icon-29pt.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/ipad-app-icon-29pt@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/ipad-app-icon-29pt@2x.png deleted file mode 100644 index a2f668b64c86..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/ipad-app-icon-29pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/ipad-app-icon-40pt@2x.png b/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/ipad-app-icon-40pt@2x.png deleted file mode 100644 index 94d67fffd000..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/AppIcon.appiconset/ipad-app-icon-40pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/Blogging Reminders/Contents.json b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-bell.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-bell.imageset/Contents.json new file mode 100644 index 000000000000..706bd74bbe37 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-bell.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "bell.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-bell.imageset/bell.pdf b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-bell.imageset/bell.pdf new file mode 100644 index 000000000000..1e5e02c0ca68 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-bell.imageset/bell.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-calendar.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-calendar.imageset/Contents.json new file mode 100644 index 000000000000..4fbd1364911e --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-calendar.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "calendar.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-calendar.imageset/calendar.pdf b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-calendar.imageset/calendar.pdf new file mode 100644 index 000000000000..f5b63f673218 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-calendar.imageset/calendar.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-celebration.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-celebration.imageset/Contents.json new file mode 100644 index 000000000000..6e4c49ecb0ac --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-celebration.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "celebration.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-celebration.imageset/celebration.pdf b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-celebration.imageset/celebration.pdf new file mode 100644 index 000000000000..198a856f7326 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Blogging Reminders/reminders-celebration.imageset/celebration.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Contents.json b/WordPress/Resources/AppImages.xcassets/Contents.json index da4a164c9186..73c00596a7fc 100644 --- a/WordPress/Resources/AppImages.xcassets/Contents.json +++ b/WordPress/Resources/AppImages.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WordPress/Resources/AppImages.xcassets/Custom Symbols/Contents.json b/WordPress/Resources/AppImages.xcassets/Custom Symbols/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Custom Symbols/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Custom Symbols/fork.knife.symbolset/Contents.json b/WordPress/Resources/AppImages.xcassets/Custom Symbols/fork.knife.symbolset/Contents.json new file mode 100644 index 000000000000..d2e490721f27 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Custom Symbols/fork.knife.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "fork.knife.svg", + "idiom" : "universal" + } + ] +} diff --git a/WordPress/Resources/AppImages.xcassets/Custom Symbols/fork.knife.symbolset/fork.knife.svg b/WordPress/Resources/AppImages.xcassets/Custom Symbols/fork.knife.symbolset/fork.knife.svg new file mode 100644 index 000000000000..6437dcbf66c5 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Custom Symbols/fork.knife.symbolset/fork.knife.svg @@ -0,0 +1,161 @@ + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.2.0 + Requires Xcode 13 or greater + Generated from fork.knife + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Resources/AppImages.xcassets/Custom Symbols/pawprint.symbolset/Contents.json b/WordPress/Resources/AppImages.xcassets/Custom Symbols/pawprint.symbolset/Contents.json new file mode 100644 index 000000000000..799a3bbebb1e --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Custom Symbols/pawprint.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "pawprint.svg", + "idiom" : "universal" + } + ] +} diff --git a/WordPress/Resources/AppImages.xcassets/Custom Symbols/pawprint.symbolset/pawprint.svg b/WordPress/Resources/AppImages.xcassets/Custom Symbols/pawprint.symbolset/pawprint.svg new file mode 100644 index 000000000000..fd3877635dcc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Custom Symbols/pawprint.symbolset/pawprint.svg @@ -0,0 +1,161 @@ + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.2.0 + Requires Xcode 13 or greater + Generated from pawprint + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Resources/AppImages.xcassets/Domains/Contents.json b/WordPress/Resources/AppImages.xcassets/Domains/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Domains/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Domains/domains-success.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Domains/domains-success.imageset/Contents.json new file mode 100644 index 000000000000..5047319ee92c --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Domains/domains-success.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "domains-success.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Domains/domains-success.imageset/domains-success.pdf b/WordPress/Resources/AppImages.xcassets/Domains/domains-success.imageset/domains-success.pdf new file mode 100644 index 000000000000..f250550a147c Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Domains/domains-success.imageset/domains-success.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/Contents.json b/WordPress/Resources/AppImages.xcassets/Grow Audience/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Grow Audience/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/Contents.json new file mode 100644 index 000000000000..6b499b5e5205 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "grow-audience-illustration-blogging-reminders.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "grow-audience-illustration-blogging-reminders@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "grow-audience-illustration-blogging-reminders@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/grow-audience-illustration-blogging-reminders.png b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/grow-audience-illustration-blogging-reminders.png new file mode 100644 index 000000000000..5ee6a10b61e7 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/grow-audience-illustration-blogging-reminders.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/grow-audience-illustration-blogging-reminders@2x.png b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/grow-audience-illustration-blogging-reminders@2x.png new file mode 100644 index 000000000000..3f730c38d47c Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/grow-audience-illustration-blogging-reminders@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/grow-audience-illustration-blogging-reminders@3x.png b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/grow-audience-illustration-blogging-reminders@3x.png new file mode 100644 index 000000000000..e3a5451f8730 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-blogging-reminders.imageset/grow-audience-illustration-blogging-reminders@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/Contents.json new file mode 100644 index 000000000000..10346f3b1369 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "grow-audience-illustration-reader.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "grow-audience-illustration-reader@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "grow-audience-illustration-reader@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/grow-audience-illustration-reader.png b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/grow-audience-illustration-reader.png new file mode 100644 index 000000000000..03b5b05b943b Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/grow-audience-illustration-reader.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/grow-audience-illustration-reader@2x.png b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/grow-audience-illustration-reader@2x.png new file mode 100644 index 000000000000..31ee20cf5e53 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/grow-audience-illustration-reader@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/grow-audience-illustration-reader@3x.png b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/grow-audience-illustration-reader@3x.png new file mode 100644 index 000000000000..32af1da8062d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-reader.imageset/grow-audience-illustration-reader@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/Contents.json new file mode 100644 index 000000000000..f18d6da95981 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "grow-audience-illustration-social.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "grow-audience-illustration-social@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "grow-audience-illustration-social@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/grow-audience-illustration-social.png b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/grow-audience-illustration-social.png new file mode 100644 index 000000000000..3c584ab3374e Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/grow-audience-illustration-social.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/grow-audience-illustration-social@2x.png b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/grow-audience-illustration-social@2x.png new file mode 100644 index 000000000000..afe58730eb0e Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/grow-audience-illustration-social@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/grow-audience-illustration-social@3x.png b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/grow-audience-illustration-social@3x.png new file mode 100644 index 000000000000..02459776c8ac Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Grow Audience/grow-audience-illustration-social.imageset/grow-audience-illustration-social@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Hands-Calendar.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Hands-Calendar.imageset/Contents.json new file mode 100644 index 000000000000..525ac7799898 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Hands-Calendar.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Hands Calendar.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Hands-Calendar.imageset/Hands Calendar.png b/WordPress/Resources/AppImages.xcassets/Hands-Calendar.imageset/Hands Calendar.png new file mode 100644 index 000000000000..e3ffc0b786ef Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Hands-Calendar.imageset/Hands Calendar.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Illustrations/Contents.json b/WordPress/Resources/AppImages.xcassets/Illustrations/Contents.json index da4a164c9186..73c00596a7fc 100644 --- a/WordPress/Resources/AppImages.xcassets/Illustrations/Contents.json +++ b/WordPress/Resources/AppImages.xcassets/Illustrations/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-first-post.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-first-post.imageset/Contents.json new file mode 100644 index 000000000000..ed57f88e512a --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-first-post.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "firstpost.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-first-post.imageset/firstpost.pdf b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-first-post.imageset/firstpost.pdf new file mode 100644 index 000000000000..763a06426f3d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-first-post.imageset/firstpost.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-following-empty-results.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-following-empty-results.imageset/Contents.json new file mode 100644 index 000000000000..b2d280df935d --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-following-empty-results.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "wp-illustration-following-empty-results.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "wp-illustration-following-empty-results-dark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-following-empty-results.imageset/wp-illustration-following-empty-results-dark.pdf b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-following-empty-results.imageset/wp-illustration-following-empty-results-dark.pdf new file mode 100644 index 000000000000..6165bcc9b5bb Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-following-empty-results.imageset/wp-illustration-following-empty-results-dark.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-following-empty-results.imageset/wp-illustration-following-empty-results.pdf b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-following-empty-results.imageset/wp-illustration-following-empty-results.pdf new file mode 100644 index 000000000000..3aab3ceb1689 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-following-empty-results.imageset/wp-illustration-following-empty-results.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-ia-announcement.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-ia-announcement.imageset/Contents.json new file mode 100644 index 000000000000..45756b8638b6 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-ia-announcement.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "wp-illustration-ia-announcement.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-ia-announcement.imageset/wp-illustration-ia-announcement.pdf b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-ia-announcement.imageset/wp-illustration-ia-announcement.pdf new file mode 100644 index 000000000000..24964d3bdb3d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-ia-announcement.imageset/wp-illustration-ia-announcement.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-quickstart-existing-site.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-quickstart-existing-site.imageset/Contents.json new file mode 100644 index 000000000000..29841b55103d --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-quickstart-existing-site.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "wp-illustration-quickstart-existing-site.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-quickstart-existing-site.imageset/wp-illustration-quickstart-existing-site.pdf b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-quickstart-existing-site.imageset/wp-illustration-quickstart-existing-site.pdf new file mode 100644 index 000000000000..6d11ff00e996 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Illustrations/wp-illustration-quickstart-existing-site.imageset/wp-illustration-quickstart-existing-site.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Install/Contents.json b/WordPress/Resources/AppImages.xcassets/Jetpack Install/Contents.json index da4a164c9186..73c00596a7fc 100644 --- a/WordPress/Resources/AppImages.xcassets/Jetpack Install/Contents.json +++ b/WordPress/Resources/AppImages.xcassets/Jetpack Install/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-error.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-error.imageset/Contents.json index bd8edfe65a3e..e4de628dd68c 100644 --- a/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-error.imageset/Contents.json +++ b/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-error.imageset/Contents.json @@ -1,12 +1,12 @@ { "images" : [ { - "idiom" : "universal", - "filename" : "jetpack-install-error.pdf" + "filename" : "jetpack-install-error.pdf", + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-error.imageset/jetpack-install-error.pdf b/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-error.imageset/jetpack-install-error.pdf index a1b3fdcef0c8..2bdbdb51bd13 100644 Binary files a/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-error.imageset/jetpack-install-error.pdf and b/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-error.imageset/jetpack-install-error.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-logo.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-logo.imageset/Contents.json index 5d0786b24ea7..5b633cc5b09e 100644 --- a/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-logo.imageset/Contents.json +++ b/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-logo.imageset/Contents.json @@ -1,12 +1,12 @@ { "images" : [ { - "idiom" : "universal", - "filename" : "jetpack-install-logo.pdf" + "filename" : "jetpack-install-logo.pdf", + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-logo.imageset/jetpack-install-logo.pdf b/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-logo.imageset/jetpack-install-logo.pdf index 2891b932b1e5..10ec2cdb8c90 100644 Binary files a/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-logo.imageset/jetpack-install-logo.pdf and b/WordPress/Resources/AppImages.xcassets/Jetpack Install/jetpack-install-logo.imageset/jetpack-install-logo.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Scan/Contents.json b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-menu-icon.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-menu-icon.imageset/Contents.json new file mode 100644 index 000000000000..5481aa399889 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-menu-icon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "jetpack-scan-menu-icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-menu-icon.imageset/jetpack-scan-menu-icon.pdf b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-menu-icon.imageset/jetpack-scan-menu-icon.pdf new file mode 100644 index 000000000000..a80dc0d895f4 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-menu-icon.imageset/jetpack-scan-menu-icon.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-error.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-error.imageset/Contents.json new file mode 100644 index 000000000000..d26f6a2f486c --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-error.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "jetpack-scan-state-error.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-error.imageset/jetpack-scan-state-error.pdf b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-error.imageset/jetpack-scan-state-error.pdf new file mode 100644 index 000000000000..181ad52a9b44 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-error.imageset/jetpack-scan-state-error.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-okay.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-okay.imageset/Contents.json new file mode 100644 index 000000000000..f4f25c541b89 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-okay.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "jetpack-scan-state-okay.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-okay.imageset/jetpack-scan-state-okay.pdf b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-okay.imageset/jetpack-scan-state-okay.pdf new file mode 100644 index 000000000000..ae4ae325d107 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-okay.imageset/jetpack-scan-state-okay.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-progress.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-progress.imageset/Contents.json new file mode 100644 index 000000000000..b0cb395584fa --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-progress.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "jetpack-scan-state-progress.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-progress.imageset/jetpack-scan-state-progress.pdf b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-progress.imageset/jetpack-scan-state-progress.pdf new file mode 100644 index 000000000000..75468d3e69b6 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-state-progress.imageset/jetpack-scan-state-progress.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-threat-fixed.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-threat-fixed.imageset/Contents.json new file mode 100644 index 000000000000..baa4187e7fd9 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-threat-fixed.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "jetpack-scan-threat-fixed.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-threat-fixed.imageset/jetpack-scan-threat-fixed.pdf b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-threat-fixed.imageset/jetpack-scan-threat-fixed.pdf new file mode 100644 index 000000000000..90a9a52556dd Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Jetpack Scan/jetpack-scan-threat-fixed.imageset/jetpack-scan-threat-fixed.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/New Jetpack prologue/Contents.json b/WordPress/Resources/AppImages.xcassets/New Jetpack prologue/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/New Jetpack prologue/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/New Jetpack prologue/JPBackground.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/New Jetpack prologue/JPBackground.imageset/Contents.json new file mode 100644 index 000000000000..0ce038741595 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/New Jetpack prologue/JPBackground.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "JPBackground.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/New Jetpack prologue/JPBackground.imageset/JPBackground.pdf b/WordPress/Resources/AppImages.xcassets/New Jetpack prologue/JPBackground.imageset/JPBackground.pdf new file mode 100644 index 000000000000..1538f6f7c14c Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/New Jetpack prologue/JPBackground.imageset/JPBackground.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Notification Prompt/Contents.json b/WordPress/Resources/AppImages.xcassets/Notification Prompt/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Notification Prompt/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/Contents.json new file mode 100644 index 000000000000..01b1925c2ee3 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "traffic-surge-icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "traffic-surge-icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "traffic-surge-icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/traffic-surge-icon.png b/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/traffic-surge-icon.png new file mode 100644 index 000000000000..cd121815efe0 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/traffic-surge-icon.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/traffic-surge-icon@2x.png b/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/traffic-surge-icon@2x.png new file mode 100644 index 000000000000..c6df465ce9a0 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/traffic-surge-icon@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/traffic-surge-icon@3x.png b/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/traffic-surge-icon@3x.png new file mode 100644 index 000000000000..c9414d0f69fe Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Notification Prompt/traffic-surge-icon.imageset/traffic-surge-icon@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/Contents.json new file mode 100644 index 000000000000..7f1ed27f52ac --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "view-milestone-1k.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "view-milestone-1k@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "view-milestone-1k@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/view-milestone-1k.png b/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/view-milestone-1k.png new file mode 100644 index 000000000000..de481e2fcd62 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/view-milestone-1k.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/view-milestone-1k@2x.png b/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/view-milestone-1k@2x.png new file mode 100644 index 000000000000..f310ce160211 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/view-milestone-1k@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/view-milestone-1k@3x.png b/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/view-milestone-1k@3x.png new file mode 100644 index 000000000000..c45c3f6e1cdb Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Notification Prompt/view-milestone-1k.imageset/view-milestone-1k@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Notifications/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Notifications/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/notifications-approve.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-approve.imageset/Contents.json similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-approve.imageset/Contents.json rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-approve.imageset/Contents.json diff --git a/WordPress/Resources/AppImages.xcassets/notifications-approve.imageset/notifications-approve.pdf b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-approve.imageset/notifications-approve.pdf similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-approve.imageset/notifications-approve.pdf rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-approve.imageset/notifications-approve.pdf diff --git a/WordPress/Resources/AppImages.xcassets/notifications-approved.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-approved.imageset/Contents.json similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-approved.imageset/Contents.json rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-approved.imageset/Contents.json diff --git a/WordPress/Resources/AppImages.xcassets/notifications-approved.imageset/notifications-approved.pdf b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-approved.imageset/notifications-approved.pdf similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-approved.imageset/notifications-approved.pdf rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-approved.imageset/notifications-approved.pdf diff --git a/WordPress/Resources/AppImages.xcassets/notifications-bell.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-bell.imageset/Contents.json similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-bell.imageset/Contents.json rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-bell.imageset/Contents.json diff --git a/WordPress/Resources/AppImages.xcassets/notifications-bell.imageset/notifications-bell.pdf b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-bell.imageset/notifications-bell.pdf similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-bell.imageset/notifications-bell.pdf rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-bell.imageset/notifications-bell.pdf diff --git a/WordPress/Resources/AppImages.xcassets/Notifications/notifications-confetti-background.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-confetti-background.imageset/Contents.json new file mode 100644 index 000000000000..d987b0f97f1e --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-confetti-background.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "note-confetti-background.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Notifications/notifications-confetti-background.imageset/note-confetti-background.pdf b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-confetti-background.imageset/note-confetti-background.pdf new file mode 100644 index 000000000000..399d027f244f Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-confetti-background.imageset/note-confetti-background.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/notifications-email.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-email.imageset/Contents.json similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-email.imageset/Contents.json rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-email.imageset/Contents.json diff --git a/WordPress/Resources/AppImages.xcassets/notifications-email.imageset/notifications-email.pdf b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-email.imageset/notifications-email.pdf similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-email.imageset/notifications-email.pdf rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-email.imageset/notifications-email.pdf diff --git a/WordPress/Resources/AppImages.xcassets/notifications-like.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-like.imageset/Contents.json similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-like.imageset/Contents.json rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-like.imageset/Contents.json diff --git a/WordPress/Resources/AppImages.xcassets/notifications-like.imageset/notifications-like.pdf b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-like.imageset/notifications-like.pdf similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-like.imageset/notifications-like.pdf rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-like.imageset/notifications-like.pdf diff --git a/WordPress/Resources/AppImages.xcassets/notifications-liked.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-liked.imageset/Contents.json similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-liked.imageset/Contents.json rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-liked.imageset/Contents.json diff --git a/WordPress/Resources/AppImages.xcassets/notifications-liked.imageset/notifications-liked.pdf b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-liked.imageset/notifications-liked.pdf similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-liked.imageset/notifications-liked.pdf rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-liked.imageset/notifications-liked.pdf diff --git a/WordPress/Resources/AppImages.xcassets/notifications-phone.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-phone.imageset/Contents.json similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-phone.imageset/Contents.json rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-phone.imageset/Contents.json diff --git a/WordPress/Resources/AppImages.xcassets/notifications-phone.imageset/notifications-phone.pdf b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-phone.imageset/notifications-phone.pdf similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-phone.imageset/notifications-phone.pdf rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-phone.imageset/notifications-phone.pdf diff --git a/WordPress/Resources/AppImages.xcassets/notifications-reply.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-reply.imageset/Contents.json similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-reply.imageset/Contents.json rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-reply.imageset/Contents.json diff --git a/WordPress/Resources/AppImages.xcassets/notifications-reply.imageset/notifications-reply.pdf b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-reply.imageset/notifications-reply.pdf similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-reply.imageset/notifications-reply.pdf rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-reply.imageset/notifications-reply.pdf diff --git a/WordPress/Resources/AppImages.xcassets/notifications-spam.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-spam.imageset/Contents.json similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-spam.imageset/Contents.json rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-spam.imageset/Contents.json diff --git a/WordPress/Resources/AppImages.xcassets/notifications-spam.imageset/notifications-spam.pdf b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-spam.imageset/notifications-spam.pdf similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-spam.imageset/notifications-spam.pdf rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-spam.imageset/notifications-spam.pdf diff --git a/WordPress/Resources/AppImages.xcassets/notifications-trash.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-trash.imageset/Contents.json similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-trash.imageset/Contents.json rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-trash.imageset/Contents.json diff --git a/WordPress/Resources/AppImages.xcassets/notifications-trash.imageset/notifications-trash.pdf b/WordPress/Resources/AppImages.xcassets/Notifications/notifications-trash.imageset/notifications-trash.pdf similarity index 100% rename from WordPress/Resources/AppImages.xcassets/notifications-trash.imageset/notifications-trash.pdf rename to WordPress/Resources/AppImages.xcassets/Notifications/notifications-trash.imageset/notifications-trash.pdf diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashBrushStroke.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashBrushStroke.imageset/Contents.json new file mode 100644 index 000000000000..9dc3d2337ad1 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashBrushStroke.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "splashBrushStroke.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashBrushStroke.imageset/splashBrushStroke.pdf b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashBrushStroke.imageset/splashBrushStroke.pdf new file mode 100644 index 000000000000..594e323ff3c6 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashBrushStroke.imageset/splashBrushStroke.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/Contents.json new file mode 100644 index 000000000000..3f9ad875ed6d --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "splashLogoLight.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "splashLogoDark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "splashLogoLight@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "splashLogoDark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "splashLogoLight@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "splashLogoDark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoDark.png b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoDark.png new file mode 100644 index 000000000000..133d9d17d7c8 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoDark.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoDark@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoDark@2x.png new file mode 100644 index 000000000000..e346e17eecf2 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoDark@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoDark@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoDark@3x.png new file mode 100644 index 000000000000..5cb9c4ece85f Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoDark@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoLight.png b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoLight.png new file mode 100644 index 000000000000..0294e2d2f08c Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoLight.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoLight@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoLight@2x.png new file mode 100644 index 000000000000..408f782ce8ba Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoLight@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoLight@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoLight@3x.png new file mode 100644 index 000000000000..7f14ed972d11 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/SplashPrologue/splashLogo.imageset/splashLogoLight@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/Contents.json new file mode 100644 index 000000000000..7bb713fbc6db --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "barGraphLight.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "barGraphDark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "barGraphLight@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "barGraphDark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "barGraphLight@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "barGraphDark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphDark.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphDark.png new file mode 100644 index 000000000000..ec4e4952ba57 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphDark.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphDark@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphDark@2x.png new file mode 100644 index 000000000000..751f775e8988 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphDark@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphDark@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphDark@3x.png new file mode 100644 index 000000000000..8d10370a8c48 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphDark@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphLight.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphLight.png new file mode 100644 index 000000000000..f4f8ef4193ac Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphLight.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphLight@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphLight@2x.png new file mode 100644 index 000000000000..015b9fc0674d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphLight@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphLight@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphLight@3x.png new file mode 100644 index 000000000000..0635c9d8a1de Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/barGraph.imageset/barGraphLight@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/Contents.json new file mode 100644 index 000000000000..fc6af984724b --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page1Website1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page1Website1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page1Website1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/page1Website1.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/page1Website1.png new file mode 100644 index 000000000000..799f17e92f03 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/page1Website1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/page1Website1@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/page1Website1@2x.png new file mode 100644 index 000000000000..c5b37590dcb6 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/page1Website1@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/page1Website1@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/page1Website1@3x.png new file mode 100644 index 000000000000..b0bee1521bf2 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite1.imageset/page1Website1@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/Contents.json new file mode 100644 index 000000000000..0624ef9fe872 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page1Website2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page1Website2@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page1Website2@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/page1Website2.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/page1Website2.png new file mode 100644 index 000000000000..7bb1d3539c9d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/page1Website2.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/page1Website2@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/page1Website2@2x.png new file mode 100644 index 000000000000..c4f3982bca1c Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/page1Website2@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/page1Website2@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/page1Website2@3x.png new file mode 100644 index 000000000000..939feb92fab2 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite2.imageset/page1Website2@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/Contents.json new file mode 100644 index 000000000000..1230939fcb3d --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page1Website3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page1Website3@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page1Website3@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/page1Website3.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/page1Website3.png new file mode 100644 index 000000000000..9578f306a4cb Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/page1Website3.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/page1Website3@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/page1Website3@2x.png new file mode 100644 index 000000000000..d7270f119746 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/page1Website3@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/page1Website3@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/page1Website3@3x.png new file mode 100644 index 000000000000..38378e09ed79 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite3.imageset/page1Website3@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/Contents.json new file mode 100644 index 000000000000..b57b3690fd47 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page1Website4.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page1Website4@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page1Website4@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/page1Website4.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/page1Website4.png new file mode 100644 index 000000000000..87116f57752b Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/page1Website4.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/page1Website4@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/page1Website4@2x.png new file mode 100644 index 000000000000..8840ca9ded06 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/page1Website4@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/page1Website4@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/page1Website4@3x.png new file mode 100644 index 000000000000..cbeeb1f7d339 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite4.imageset/page1Website4@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/Contents.json new file mode 100644 index 000000000000..6e55f7aea183 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page1Website5.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page1Website5@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page1Website5@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/page1Website5.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/page1Website5.png new file mode 100644 index 000000000000..47440243420f Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/page1Website5.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/page1Website5@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/page1Website5@2x.png new file mode 100644 index 000000000000..0754c9a5321b Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/page1Website5@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/page1Website5@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/page1Website5@3x.png new file mode 100644 index 000000000000..359d7519474e Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite5.imageset/page1Website5@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/Contents.json new file mode 100644 index 000000000000..8c94f4406ac7 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page1Website6.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page1Website6@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page1Website6@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/page1Website6.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/page1Website6.png new file mode 100644 index 000000000000..2e3a963f67fc Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/page1Website6.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/page1Website6@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/page1Website6@2x.png new file mode 100644 index 000000000000..9c60eed24224 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/page1Website6@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/page1Website6@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/page1Website6@3x.png new file mode 100644 index 000000000000..0430212a0d91 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite6.imageset/page1Website6@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/Contents.json new file mode 100644 index 000000000000..61beec0a1cf1 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page1Website7.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page1Website7@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page1Website7@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/page1Website7.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/page1Website7.png new file mode 100644 index 000000000000..edbd6fb9903a Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/page1Website7.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/page1Website7@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/page1Website7@2x.png new file mode 100644 index 000000000000..8bb370b31f91 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/page1Website7@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/page1Website7@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/page1Website7@3x.png new file mode 100644 index 000000000000..141a16e3f420 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/introWebsite7.imageset/page1Website7@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/Contents.json new file mode 100644 index 000000000000..cb6b683486de --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page2Img1Sea.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page2Img1Sea@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page2Img1Sea@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/page2Img1Sea.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/page2Img1Sea.png new file mode 100644 index 000000000000..d1effea4be85 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/page2Img1Sea.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/page2Img1Sea@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/page2Img1Sea@2x.png new file mode 100644 index 000000000000..96441b8e1c0e Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/page2Img1Sea@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/page2Img1Sea@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/page2Img1Sea@3x.png new file mode 100644 index 000000000000..07f152aa91e3 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img1Sea.imageset/page2Img1Sea@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/Contents.json new file mode 100644 index 000000000000..c1035823e030 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page2Img2Trees.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page2Img2Trees@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page2Img2Trees@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/page2Img2Trees.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/page2Img2Trees.png new file mode 100644 index 000000000000..efe735909b46 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/page2Img2Trees.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/page2Img2Trees@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/page2Img2Trees@2x.png new file mode 100644 index 000000000000..76591bf118bc Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/page2Img2Trees@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/page2Img2Trees@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/page2Img2Trees@3x.png new file mode 100644 index 000000000000..76182003cacb Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img2Trees.imageset/page2Img2Trees@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/Contents.json new file mode 100644 index 000000000000..78f567018fb5 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page2Img3Food.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page2Img3Food@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page2Img3Food@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/page2Img3Food.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/page2Img3Food.png new file mode 100644 index 000000000000..3942ff44ccfc Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/page2Img3Food.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/page2Img3Food@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/page2Img3Food@2x.png new file mode 100644 index 000000000000..a5790afe35a1 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/page2Img3Food@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/page2Img3Food@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/page2Img3Food@3x.png new file mode 100644 index 000000000000..2cb233583add Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page2Img3Food.imageset/page2Img3Food@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/Contents.json new file mode 100644 index 000000000000..f2d834926e4c --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page3Avatar1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page3Avatar1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page3Avatar1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/page3Avatar1.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/page3Avatar1.png new file mode 100644 index 000000000000..f490890475da Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/page3Avatar1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/page3Avatar1@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/page3Avatar1@2x.png new file mode 100644 index 000000000000..e006ea2e9576 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/page3Avatar1@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/page3Avatar1@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/page3Avatar1@3x.png new file mode 100644 index 000000000000..6f8734bb3b65 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar1.imageset/page3Avatar1@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/Contents.json new file mode 100644 index 000000000000..b265f0c37ba5 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page3Avatar2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page3Avatar2@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page3Avatar2@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/page3Avatar2.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/page3Avatar2.png new file mode 100644 index 000000000000..e01a91acba64 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/page3Avatar2.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/page3Avatar2@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/page3Avatar2@2x.png new file mode 100644 index 000000000000..e4930ed1c1ed Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/page3Avatar2@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/page3Avatar2@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/page3Avatar2@3x.png new file mode 100644 index 000000000000..1ad613c1dfcb Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar2.imageset/page3Avatar2@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/Contents.json new file mode 100644 index 000000000000..cae3dca8be9c --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page3Avatar3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page3Avatar3@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page3Avatar3@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/page3Avatar3.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/page3Avatar3.png new file mode 100644 index 000000000000..91c7ba4899e2 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/page3Avatar3.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/page3Avatar3@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/page3Avatar3@2x.png new file mode 100644 index 000000000000..4668ffcb8635 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/page3Avatar3@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/page3Avatar3@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/page3Avatar3@3x.png new file mode 100644 index 000000000000..3c69158b5bdb Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page3Avatar3.imageset/page3Avatar3@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page4Map.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page4Map.imageset/Contents.json new file mode 100644 index 000000000000..1509fcf39d14 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page4Map.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images" : [ + { + "filename" : "page4MapLight.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "page4MapDark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page4Map.imageset/page4MapDark.pdf b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page4Map.imageset/page4MapDark.pdf new file mode 100644 index 000000000000..d776d26c1ae0 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page4Map.imageset/page4MapDark.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page4Map.imageset/page4MapLight.pdf b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page4Map.imageset/page4MapLight.pdf new file mode 100644 index 000000000000..4a02cce419e1 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page4Map.imageset/page4MapLight.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/Contents.json new file mode 100644 index 000000000000..f26db29a4761 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page5Avatar1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page5Avatar1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page5Avatar1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/page5Avatar1.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/page5Avatar1.png new file mode 100644 index 000000000000..c2567f3dd056 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/page5Avatar1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/page5Avatar1@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/page5Avatar1@2x.png new file mode 100644 index 000000000000..5984dc439d6d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/page5Avatar1@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/page5Avatar1@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/page5Avatar1@3x.png new file mode 100644 index 000000000000..62bdfba468ba Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar1.imageset/page5Avatar1@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/Contents.json new file mode 100644 index 000000000000..172a3fabb6d2 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page5Avatar2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page5Avatar2@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page5Avatar2@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/page5Avatar2.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/page5Avatar2.png new file mode 100644 index 000000000000..dd37a291f01c Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/page5Avatar2.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/page5Avatar2@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/page5Avatar2@2x.png new file mode 100644 index 000000000000..d0284d1fcad6 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/page5Avatar2@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/page5Avatar2@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/page5Avatar2@3x.png new file mode 100644 index 000000000000..3e2d162453e1 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar2.imageset/page5Avatar2@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/Contents.json new file mode 100644 index 000000000000..835daf91092c --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page5Avatar3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page5Avatar3@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page5Avatar3@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/page5Avatar3.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/page5Avatar3.png new file mode 100644 index 000000000000..2d8119ceed01 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/page5Avatar3.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/page5Avatar3@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/page5Avatar3@2x.png new file mode 100644 index 000000000000..1c523db52028 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/page5Avatar3@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/page5Avatar3@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/page5Avatar3@3x.png new file mode 100644 index 000000000000..02d5303f0e03 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Avatar3.imageset/page5Avatar3@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/Contents.json new file mode 100644 index 000000000000..a97a79e5f6a4 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page5Img1Coffee.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page5Img1Coffee@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page5Img1Coffee@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/page5Img1Coffee.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/page5Img1Coffee.png new file mode 100644 index 000000000000..fd43f72603ed Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/page5Img1Coffee.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/page5Img1Coffee@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/page5Img1Coffee@2x.png new file mode 100644 index 000000000000..dcb256b7ea9e Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/page5Img1Coffee@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/page5Img1Coffee@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/page5Img1Coffee@3x.png new file mode 100644 index 000000000000..fd33c63f75da Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img1Coffee.imageset/page5Img1Coffee@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/Contents.json new file mode 100644 index 000000000000..d69b5cf938ed --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page5Img2Stadium.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page5Img2Stadium@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page5Img2Stadium@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/page5Img2Stadium.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/page5Img2Stadium.png new file mode 100644 index 000000000000..db5a9b63d11f Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/page5Img2Stadium.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/page5Img2Stadium@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/page5Img2Stadium@2x.png new file mode 100644 index 000000000000..3975229b9a10 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/page5Img2Stadium@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/page5Img2Stadium@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/page5Img2Stadium@3x.png new file mode 100644 index 000000000000..b6dfad71523c Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img2Stadium.imageset/page5Img2Stadium@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/Contents.json new file mode 100644 index 000000000000..835f65bb08bc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "page5Img3Museum.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "page5Img3Museum@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "page5Img3Museum@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/page5Img3Museum.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/page5Img3Museum.png new file mode 100644 index 000000000000..bea1b1c5a8f3 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/page5Img3Museum.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/page5Img3Museum@2x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/page5Img3Museum@2x.png new file mode 100644 index 000000000000..470a52f63e2b Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/page5Img3Museum@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/page5Img3Museum@3x.png b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/page5Img3Museum@3x.png new file mode 100644 index 000000000000..fe27cbb43aa1 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Prologue/UnifiedPrologue/page5Img3Museum.imageset/page5Img3Museum@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/QR Login/Contents.json b/WordPress/Resources/AppImages.xcassets/QR Login/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/QR Login/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/QR Login/qr-login-close-icon.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/QR Login/qr-login-close-icon.imageset/Contents.json new file mode 100644 index 000000000000..5075f9b788a3 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/QR Login/qr-login-close-icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "qr-login-close-icon.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/QR Login/qr-login-close-icon.imageset/qr-login-close-icon.pdf b/WordPress/Resources/AppImages.xcassets/QR Login/qr-login-close-icon.imageset/qr-login-close-icon.pdf new file mode 100644 index 000000000000..b498ecd02cf7 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/QR Login/qr-login-close-icon.imageset/qr-login-close-icon.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/QR Login/qr-scan-focus.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/QR Login/qr-scan-focus.imageset/Contents.json new file mode 100644 index 000000000000..335fbd22bb21 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/QR Login/qr-scan-focus.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "qr-scan-focus.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/QR Login/qr-scan-focus.imageset/qr-scan-focus.pdf b/WordPress/Resources/AppImages.xcassets/QR Login/qr-scan-focus.imageset/qr-scan-focus.pdf new file mode 100644 index 000000000000..33f74ec50976 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/QR Login/qr-scan-focus.imageset/qr-scan-focus.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/Contents.json b/WordPress/Resources/AppImages.xcassets/Stories/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Stories/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/Contents.json new file mode 100644 index 000000000000..6d2de54cd1fc --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stories-confirm-button.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stories-confirm-button@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stories-confirm-button@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/stories-confirm-button.png b/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/stories-confirm-button.png new file mode 100644 index 000000000000..c4104fae6220 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/stories-confirm-button.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/stories-confirm-button@2x.png b/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/stories-confirm-button@2x.png new file mode 100644 index 000000000000..70cc4769a931 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/stories-confirm-button@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/stories-confirm-button@3x.png b/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/stories-confirm-button@3x.png new file mode 100644 index 000000000000..7f019a0769cf Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories-confirm-button.imageset/stories-confirm-button@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/Contents.json new file mode 100644 index 000000000000..874bc1d6f669 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stories-next-button.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stories-next-button@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stories-next-button@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/stories-next-button.png b/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/stories-next-button.png new file mode 100644 index 000000000000..d670ec017e44 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/stories-next-button.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/stories-next-button@2x.png b/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/stories-next-button@2x.png new file mode 100644 index 000000000000..383795eece5e Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/stories-next-button@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/stories-next-button@3x.png b/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/stories-next-button@3x.png new file mode 100644 index 000000000000..f2a1fa26e1d4 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories-next-button.imageset/stories-next-button@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/Contents.json new file mode 100644 index 000000000000..c548e7a93e63 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stories_intro_cover_1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stories_intro_cover_1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stories_intro_cover_1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/stories_intro_cover_1.png b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/stories_intro_cover_1.png new file mode 100644 index 000000000000..9a8f74d1815a Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/stories_intro_cover_1.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/stories_intro_cover_1@2x.png b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/stories_intro_cover_1@2x.png new file mode 100644 index 000000000000..12260bf4b1a7 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/stories_intro_cover_1@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/stories_intro_cover_1@3x.png b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/stories_intro_cover_1@3x.png new file mode 100644 index 000000000000..99aff3404d37 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_1.imageset/stories_intro_cover_1@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/Contents.json new file mode 100644 index 000000000000..22d92aa51490 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "stories_intro_cover_2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "stories_intro_cover_2@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "stories_intro_cover_2@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/stories_intro_cover_2.png b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/stories_intro_cover_2.png new file mode 100644 index 000000000000..a6c71d049118 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/stories_intro_cover_2.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/stories_intro_cover_2@2x.png b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/stories_intro_cover_2@2x.png new file mode 100644 index 000000000000..c76abd7dc35d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/stories_intro_cover_2@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/stories_intro_cover_2@3x.png b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/stories_intro_cover_2@3x.png new file mode 100644 index 000000000000..b2807468aa20 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/Stories/stories_intro_cover_2.imageset/stories_intro_cover_2@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/arrowshape.turn.up.backward.symbolset/Contents.json b/WordPress/Resources/AppImages.xcassets/arrowshape.turn.up.backward.symbolset/Contents.json new file mode 100644 index 000000000000..3f189dca8974 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/arrowshape.turn.up.backward.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "arrowshape.turn.up.backward.svg", + "idiom" : "universal" + } + ] +} diff --git a/WordPress/Resources/AppImages.xcassets/arrowshape.turn.up.backward.symbolset/arrowshape.turn.up.backward.svg b/WordPress/Resources/AppImages.xcassets/arrowshape.turn.up.backward.symbolset/arrowshape.turn.up.backward.svg new file mode 100644 index 000000000000..9354f96b00d5 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/arrowshape.turn.up.backward.symbolset/arrowshape.turn.up.backward.svg @@ -0,0 +1,161 @@ + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.3.0 + Requires Xcode 13 or greater + Generated from arrowshape.turn.up.backward + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/Resources/AppImages.xcassets/confetti-circle.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/confetti-circle.imageset/Contents.json new file mode 100644 index 000000000000..4d45a4cd652e --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/confetti-circle.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "confetti-circle.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/confetti-circle.imageset/confetti-circle.pdf b/WordPress/Resources/AppImages.xcassets/confetti-circle.imageset/confetti-circle.pdf new file mode 100644 index 000000000000..4067cca34a7b Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/confetti-circle.imageset/confetti-circle.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/confetti-hotdog.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/confetti-hotdog.imageset/Contents.json new file mode 100644 index 000000000000..8b35ded9b76f --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/confetti-hotdog.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "confetti-hotdog.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/confetti-hotdog.imageset/confetti-hotdog.pdf b/WordPress/Resources/AppImages.xcassets/confetti-hotdog.imageset/confetti-hotdog.pdf new file mode 100644 index 000000000000..1b139622e98f Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/confetti-hotdog.imageset/confetti-hotdog.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/confetti-star.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/confetti-star.imageset/Contents.json new file mode 100644 index 000000000000..015f8506e679 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/confetti-star.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "confetti-star.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/confetti-star.imageset/confetti-star.pdf b/WordPress/Resources/AppImages.xcassets/confetti-star.imageset/confetti-star.pdf new file mode 100644 index 000000000000..8aa5b670d0fa Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/confetti-star.imageset/confetti-star.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/custom-icons-alert.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/custom-icons-alert.imageset/Contents.json new file mode 100644 index 000000000000..1c01d481b6a7 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/custom-icons-alert.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "custom-icons-alert.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/WordPress/Resources/AppImages.xcassets/custom-icons-alert.imageset/custom-icons-alert.pdf b/WordPress/Resources/AppImages.xcassets/custom-icons-alert.imageset/custom-icons-alert.pdf new file mode 100644 index 000000000000..43600aefdfc2 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/custom-icons-alert.imageset/custom-icons-alert.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/Contents.json deleted file mode 100644 index 74617fcec35b..000000000000 --- a/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/Contents.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "giphy-attribution.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "giphy-attribution@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "giphy-attribution@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - }, - "properties" : { - "template-rendering-intent" : "template" - } -} \ No newline at end of file diff --git a/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/giphy-attribution.png b/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/giphy-attribution.png deleted file mode 100644 index 543c63a78f65..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/giphy-attribution.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/giphy-attribution@2x.png b/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/giphy-attribution@2x.png deleted file mode 100644 index 6040148d17b4..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/giphy-attribution@2x.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/giphy-attribution@3x.png b/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/giphy-attribution@3x.png deleted file mode 100644 index 511624e60de1..000000000000 Binary files a/WordPress/Resources/AppImages.xcassets/giphy-attribution.imageset/giphy-attribution@3x.png and /dev/null differ diff --git a/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/Contents.json new file mode 100644 index 000000000000..7299a18f5799 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon-jetpack.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon-jetpack@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon-jetpack@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/icon-jetpack.png b/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/icon-jetpack.png new file mode 100644 index 000000000000..43e302148456 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/icon-jetpack.png differ diff --git a/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/icon-jetpack@2x.png b/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/icon-jetpack@2x.png new file mode 100644 index 000000000000..7431052b24b9 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/icon-jetpack@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/icon-jetpack@3x.png b/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/icon-jetpack@3x.png new file mode 100644 index 000000000000..2975678a8d59 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon-jetpack.imageset/icon-jetpack@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/icon-lightbulb-outline.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/icon-lightbulb-outline.imageset/Contents.json new file mode 100644 index 000000000000..96fca665d081 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/icon-lightbulb-outline.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "icon-lightbulb-outline.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/icon-lightbulb-outline.imageset/icon-lightbulb-outline.pdf b/WordPress/Resources/AppImages.xcassets/icon-lightbulb-outline.imageset/icon-lightbulb-outline.pdf new file mode 100644 index 000000000000..40b2bd25b4d9 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon-lightbulb-outline.imageset/icon-lightbulb-outline.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/icon-menu-vertical-ellipsis.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/icon-menu-vertical-ellipsis.imageset/Contents.json new file mode 100644 index 000000000000..1c4534e95db1 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/icon-menu-vertical-ellipsis.imageset/Contents.json @@ -0,0 +1,24 @@ +{ + "images" : [ + { + "filename" : "icon-menu-vertical-ellipsis.pdf", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/icon-menu-vertical-ellipsis.imageset/icon-menu-vertical-ellipsis.pdf b/WordPress/Resources/AppImages.xcassets/icon-menu-vertical-ellipsis.imageset/icon-menu-vertical-ellipsis.pdf new file mode 100644 index 000000000000..dccb0e9cda28 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon-menu-vertical-ellipsis.imageset/icon-menu-vertical-ellipsis.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/icon-reader-comment-highlight.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/icon-reader-comment-highlight.imageset/Contents.json index 44affe540443..39917abcbb69 100644 --- a/WordPress/Resources/AppImages.xcassets/icon-reader-comment-highlight.imageset/Contents.json +++ b/WordPress/Resources/AppImages.xcassets/icon-reader-comment-highlight.imageset/Contents.json @@ -1,12 +1,15 @@ { "images" : [ { - "idiom" : "universal", - "filename" : "icon-reader-comment-highlight.pdf" + "filename" : "icon-reader-comment-highlight.pdf", + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } -} \ No newline at end of file +} diff --git a/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline-highlighted.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline-highlighted.imageset/Contents.json new file mode 100644 index 000000000000..5982d548ccab --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline-highlighted.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "icon-reader-comment-outline-highlighted.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline-highlighted.imageset/icon-reader-comment-outline-highlighted.pdf b/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline-highlighted.imageset/icon-reader-comment-outline-highlighted.pdf new file mode 100644 index 000000000000..9ad5f01ce9f4 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline-highlighted.imageset/icon-reader-comment-outline-highlighted.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline.imageset/Contents.json new file mode 100644 index 000000000000..4d12354db629 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "icon-reader-comment-outline.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline.imageset/icon-reader-comment-outline.pdf b/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline.imageset/icon-reader-comment-outline.pdf new file mode 100644 index 000000000000..4378ce5630ab Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon-reader-comment-outline.imageset/icon-reader-comment-outline.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/icon-reader-comment.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/icon-reader-comment.imageset/Contents.json index 3c51ecc5bfe1..ecd8dbd173e6 100644 --- a/WordPress/Resources/AppImages.xcassets/icon-reader-comment.imageset/Contents.json +++ b/WordPress/Resources/AppImages.xcassets/icon-reader-comment.imageset/Contents.json @@ -1,12 +1,15 @@ { "images" : [ { - "idiom" : "universal", - "filename" : "icon-reader-comment.pdf" + "filename" : "icon-reader-comment.pdf", + "idiom" : "universal" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } -} \ No newline at end of file +} diff --git a/WordPress/Resources/AppImages.xcassets/icon-wp-filled.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/icon-wp-filled.imageset/Contents.json new file mode 100644 index 000000000000..67c8b6d74c08 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/icon-wp-filled.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "icon-wp-filled.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "icon-wp-filled-dark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "compression-type" : "lossless", + "template-rendering-intent" : "original" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/icon-wp-filled.imageset/icon-wp-filled-dark.pdf b/WordPress/Resources/AppImages.xcassets/icon-wp-filled.imageset/icon-wp-filled-dark.pdf new file mode 100644 index 000000000000..9e1af703a467 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon-wp-filled.imageset/icon-wp-filled-dark.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/icon-wp-filled.imageset/icon-wp-filled.pdf b/WordPress/Resources/AppImages.xcassets/icon-wp-filled.imageset/icon-wp-filled.pdf new file mode 100644 index 000000000000..af4099a9e736 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon-wp-filled.imageset/icon-wp-filled.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/Contents.json new file mode 100644 index 000000000000..4fbb46fa88ae --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon.calendar.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon.calendar@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon.calendar@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/icon.calendar.png b/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/icon.calendar.png new file mode 100644 index 000000000000..ddf07929da12 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/icon.calendar.png differ diff --git a/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/icon.calendar@2x.png b/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/icon.calendar@2x.png new file mode 100644 index 000000000000..de65582049b0 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/icon.calendar@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/icon.calendar@3x.png b/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/icon.calendar@3x.png new file mode 100644 index 000000000000..5bcc7ad4d9b7 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon.calendar.imageset/icon.calendar@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/Contents.json new file mode 100644 index 000000000000..af13b6786872 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon.globe.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon.globe@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon.globe@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/icon.globe.png b/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/icon.globe.png new file mode 100644 index 000000000000..4730d864136b Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/icon.globe.png differ diff --git a/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/icon.globe@2x.png b/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/icon.globe@2x.png new file mode 100644 index 000000000000..18340091922c Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/icon.globe@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/icon.globe@3x.png b/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/icon.globe@3x.png new file mode 100644 index 000000000000..2706dd533dc1 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon.globe.imageset/icon.globe@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/Contents.json new file mode 100644 index 000000000000..69ed2002e97e --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon.verse.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon.verse@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon.verse@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/icon.verse.png b/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/icon.verse.png new file mode 100644 index 000000000000..b6e9715e47bf Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/icon.verse.png differ diff --git a/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/icon.verse@2x.png b/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/icon.verse@2x.png new file mode 100644 index 000000000000..abce5dc9e4a1 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/icon.verse@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/icon.verse@3x.png b/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/icon.verse@3x.png new file mode 100644 index 000000000000..9e0fa46ae67f Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/icon.verse.imageset/icon.verse@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/Contents.json new file mode 100644 index 000000000000..eca540026e22 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "jp-notif-icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "jp-notif-icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "jp-notif-icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/jp-notif-icon.png b/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/jp-notif-icon.png new file mode 100644 index 000000000000..24bcfffa3436 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/jp-notif-icon.png differ diff --git a/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/jp-notif-icon@2x.png b/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/jp-notif-icon@2x.png new file mode 100644 index 000000000000..f393c820636d Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/jp-notif-icon@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/jp-notif-icon@3x.png b/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/jp-notif-icon@3x.png new file mode 100644 index 000000000000..8cf457e63254 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/jp-notif-icon.imageset/jp-notif-icon@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/Contents.json new file mode 100644 index 000000000000..86c424d7098d --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "jp-reader-icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "jp-reader-icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "jp-reader-icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/jp-reader-icon.png b/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/jp-reader-icon.png new file mode 100644 index 000000000000..70597d166b82 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/jp-reader-icon.png differ diff --git a/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/jp-reader-icon@2x.png b/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/jp-reader-icon@2x.png new file mode 100644 index 000000000000..43d931544d07 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/jp-reader-icon@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/jp-reader-icon@3x.png b/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/jp-reader-icon@3x.png new file mode 100644 index 000000000000..2ec14b025661 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/jp-reader-icon.imageset/jp-reader-icon@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/Contents.json new file mode 100644 index 000000000000..61c63c2fd799 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "jp-stats-icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "jp-stats-icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "jp-stats-icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/jp-stats-icon.png b/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/jp-stats-icon.png new file mode 100644 index 000000000000..5cea227a9015 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/jp-stats-icon.png differ diff --git a/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/jp-stats-icon@2x.png b/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/jp-stats-icon@2x.png new file mode 100644 index 000000000000..8eb6b5992fed Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/jp-stats-icon@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/jp-stats-icon@3x.png b/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/jp-stats-icon@3x.png new file mode 100644 index 000000000000..d1411d40ee3c Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/jp-stats-icon.imageset/jp-stats-icon@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/logo-dayone.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/logo-dayone.imageset/Contents.json new file mode 100644 index 000000000000..3a54956c1260 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/logo-dayone.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "logo-dayone.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/WordPress/Resources/AppImages.xcassets/logo-dayone.imageset/logo-dayone.pdf b/WordPress/Resources/AppImages.xcassets/logo-dayone.imageset/logo-dayone.pdf new file mode 100644 index 000000000000..3fdf14f91603 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/logo-dayone.imageset/logo-dayone.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/more-horizontal-mobile.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/more-horizontal-mobile.imageset/Contents.json new file mode 100644 index 000000000000..08a009949956 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/more-horizontal-mobile.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "more-horizontal-mobile.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/WordPress/Resources/AppImages.xcassets/more-horizontal-mobile.imageset/more-horizontal-mobile.svg b/WordPress/Resources/AppImages.xcassets/more-horizontal-mobile.imageset/more-horizontal-mobile.svg new file mode 100644 index 000000000000..f8af22821782 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/more-horizontal-mobile.imageset/more-horizontal-mobile.svg @@ -0,0 +1,3 @@ + + + diff --git a/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/Contents.json new file mode 100644 index 000000000000..1b59ea2b2641 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pagesCardPromoImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pagesCardPromoImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pagesCardPromoImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/pagesCardPromoImage.png b/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/pagesCardPromoImage.png new file mode 100644 index 000000000000..083bc9328454 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/pagesCardPromoImage.png differ diff --git a/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/pagesCardPromoImage@2x.png b/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/pagesCardPromoImage@2x.png new file mode 100644 index 000000000000..8b08ec6539ef Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/pagesCardPromoImage@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/pagesCardPromoImage@3x.png b/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/pagesCardPromoImage@3x.png new file mode 100644 index 000000000000..010ba6032736 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/pagesCardPromoImage.imageset/pagesCardPromoImage@3x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/personalize.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/personalize.imageset/Contents.json new file mode 100644 index 000000000000..7937f37d66b4 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/personalize.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "personalize.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/personalize.imageset/personalize.pdf b/WordPress/Resources/AppImages.xcassets/personalize.imageset/personalize.pdf new file mode 100644 index 000000000000..e7c6eb47bbd7 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/personalize.imageset/personalize.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/stats-revamp-v2-feature-intro-preview.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/stats-revamp-v2-feature-intro-preview.imageset/Contents.json new file mode 100644 index 000000000000..b959dbef9403 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/stats-revamp-v2-feature-intro-preview.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "stats-revamp-v2-feature-intro-preview.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/stats-revamp-v2-feature-intro-preview.imageset/stats-revamp-v2-feature-intro-preview.pdf b/WordPress/Resources/AppImages.xcassets/stats-revamp-v2-feature-intro-preview.imageset/stats-revamp-v2-feature-intro-preview.pdf new file mode 100644 index 000000000000..3b43ff70afb1 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/stats-revamp-v2-feature-intro-preview.imageset/stats-revamp-v2-feature-intro-preview.pdf differ diff --git a/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/Contents.json b/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/Contents.json new file mode 100644 index 000000000000..64b843cde047 --- /dev/null +++ b/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "tenor-attribution.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "tenor-attribution@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "tenor-attribution@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/tenor-attribution.png b/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/tenor-attribution.png new file mode 100644 index 000000000000..0d5f7703acce Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/tenor-attribution.png differ diff --git a/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/tenor-attribution@2x.png b/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/tenor-attribution@2x.png new file mode 100644 index 000000000000..29e5008fe83a Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/tenor-attribution@2x.png differ diff --git a/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/tenor-attribution@3x.png b/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/tenor-attribution@3x.png new file mode 100644 index 000000000000..18fafbf70200 Binary files /dev/null and b/WordPress/Resources/AppImages.xcassets/tenor-attribution.imageset/tenor-attribution@3x.png differ diff --git a/WordPress/Resources/AppStoreStrings.po b/WordPress/Resources/AppStoreStrings.po index 1db53d489e4c..b007d71086d1 100644 --- a/WordPress/Resources/AppStoreStrings.po +++ b/WordPress/Resources/AppStoreStrings.po @@ -13,9 +13,14 @@ msgstr "" "Last-Translator: \n" "Language-Team: \n" +#. translators: The application name in the Apple App Store. Please keep the brand name ('WordPress') verbatim. Limit to 30 characters including spaces and punctuation! +msgctxt "app_store_name" +msgid "WordPress – Website Builder" +msgstr "" + #. translators: Subtitle to be displayed below the application name in the Apple App Store. Limit to 30 characters including spaces and commas! msgctxt "app_store_subtitle" -msgid "Manage your website anywhere" +msgid "Design a site, build a blog" msgstr "" #. translators: Multi-paragraph text used to display in the Apple App Store. @@ -29,22 +34,20 @@ msgid "" "\n" "WordPress for iOS supports WordPress.com and self-hosted WordPress.org sites running WordPress 4.0 or higher.\n" "\n" -"Need help with the app? Visit the forums at https://ios.forums.wordpress.org/ or tweet us @WordPressiOS.\n\n" +"Need help with the app? Visit the forums at https://wordpress.org/support/forum/mobile/ or tweet us @WordPressiOS.\n" +"\n" +"View the Privacy Notice for California Users at https://automattic.com/privacy/#california-consumer-privacy-act-ccpa.\n" msgstr "" #. translators: Keywords used in the App Store search engine to find the app. #. Delimit with a comma between each keyword. Limit to 100 characters including spaces and commas. msgctxt "app_store_keywords" -msgid "social,network,notes,jetpack,photos,writing,geotagging,media,blog,wordpress,website,blogging,design\n" +msgid "blogger,writing,blogging,web,maker,online,store,business,make,create,write,blogs" msgstr "" -msgctxt "v14.3-whats-new" +msgctxt "v22.3-whats-new" msgid "" -"- There are two new blocks available! Welcome, Button and Group blocks.\n" -"- A few other blocks have improvements: You can change image sizes in image blocks and use upload options in Gallery blocks. We also took care of a text-wrapping issue in Shortcode blocks that would cut off part of your content.\n" -"- We also made some enhancements to the general block experience. You’ll see a new toolbar floating above the block you’re editing to make navigating blocks, especially complex ones, easier. There’s also scroll support in the block picker and in block settings.\n" -"- Finally, we fixed a few bugs. Adding emoji to a post title no longer adds strong HTML elements to the post title, and the alignment of paragraph blocks is now respected when you split the paragraph or read the post’s HTML content.\n" -"- We also adjusted the weekday symbols in the calendar depending on your regional settings, and added Quick Action buttons to the Site Details to take you straight to the most frequently used parts of a site.\n" +"Breaking news—VideoPress blocks are now enabled for Simple WordPress.com websites, so you can host and embed high-quality videos to your heart’s content. We hope those videos include cats.\n" msgstr "" #. translators: This is a standard chunk of text used to tell a user what's new with a release when nothing major has changed. @@ -75,40 +78,63 @@ msgstr "" #. No specified characters limit here, but try to keep as short as the source one. msgctxt "app_store_screenshot-1" msgid "" -"Enjoy your\n" -"favorite sites\n" +"The world’s most\n" +"popular website\n" +"builder\n" msgstr "" #. translators: This is a promo message that will be attached on top of the second screenshot in the App Store. #. No specified characters limit here, but try to keep as short as the source one. msgctxt "app_store_screenshot-2" msgid "" -"Get notified\n" -"in real-time\n" +"Create a site or\n" +"start a blog\n" msgstr "" #. translators: This is a promo message that will be attached on top of the third screenshot in the App Store. #. No specified characters limit here, but try to keep as short as the source one. msgctxt "app_store_screenshot-3" msgid "" -"Manage your site\n" -"everywhere you go\n" +"Discover\n" +"new reads\n" msgstr "" #. translators: This is a promo message that will be attached on top of the fourth screenshot in the App Store. #. No specified characters limit here, but try to keep as short as the source one. msgctxt "app_store_screenshot-4" msgid "" -"Share your ideas\n" -"with the world\n" +"Build an\n" +"audience\n" msgstr "" #. translators: This is a promo message that will be attached on top of the fifth screenshot in the App Store. #. No specified characters limit here, but try to keep as short as the source one. msgctxt "app_store_screenshot-5" msgid "" -"All the stats\n" -"in your hand\n" +"Keep tabs on\n" +"your site\n" +msgstr "" + +#. translators: This is a promo message that will be attached on top of the fifth screenshot in the App Store. +#. No specified characters limit here, but try to keep as short as the source one. +msgctxt "app_store_screenshot-6" +msgid "" +"Reply in\n" +"real time\n" +msgstr "" + +#. translators: This is a promo message that will be attached on top of the fifth screenshot in the App Store. +#. No specified characters limit here, but try to keep as short as the source one. +msgctxt "app_store_screenshot-7" +msgid "" +"Upload\n" +"on the go\n" +msgstr "" + +#. translators: This is a promo message that will be attached on top of the fifth screenshot in the App Store. +#. No specified characters limit here, but try to keep as short as the source one. +msgctxt "app_store_screenshot-8" +msgid "Write without compromises" msgstr "" #. translators: This is a promo message that will be attached on top of an enhanced screenshot in the App Store. diff --git a/WordPress/Resources/HTML/richCommentStyle.css b/WordPress/Resources/HTML/richCommentStyle.css new file mode 100644 index 000000000000..52d79ef009b9 --- /dev/null +++ b/WordPress/Resources/HTML/richCommentStyle.css @@ -0,0 +1,222 @@ +:root { + /* informs WebKit that this template supports both appearance modes. */ + color-scheme: light dark; + + /* custom variables. */ + --primary-color: rgba(6, 117, 196, 1); + --mention-background-color: rgba(2, 103, 255, .1); + --monospace-font: ui-monospace, monospace; +} + +/* overrides for dark color scheme. */ +@media(prefers-color-scheme: dark) { + :root { + --primary-color: rgba(57, 156, 227, 1); + --mention-background-color: rgba(2, 103, 255, .2); + } +} + +/* disable WebKit text inflation algorithm to prevent text size increase on orientation change. */ +html { + -webkit-text-size-adjust: none; +} + +/* use Apple's standard style to match the native look. */ +body { + font: -apple-system-body; + color: -apple-system-label; + background-color: -apple-system-background; +} + +/* get rid of the container default margins and paddings. */ +html, body { + margin: 0; + padding: 0; +} + +/* remove the bottom margin of ANY element that's placed last within the tag. */ +body :last-child { + margin-bottom: 0; +} + +/* remove the top margin of ANY element that's placed first within the tag. */ +body :first-child { + margin-top: 0; +} + +/* + prevent ALL elements from exceeding viewport width. + individual elements can override this style as needed since it _should_ have higher specificity. +*/ +* { + max-width: 100vw; +} + +p { + word-wrap: break-word; +} + +div { + margin-bottom: 5pt; +} + +/* remove the default left/right margin for figures, as some elements (tables, images) are often embedded in
. */ +figure { + margin: 0 +} + +/** LINKS **/ + +a { + color: var(--primary-color); +} + +a.mention { + text-decoration: none; + background-color: var(--mention-background-color); + padding: 0 3px; + -webkit-border-radius: 3pt; +} + +/** IMAGES **/ + +.wp-block-image img, +p > img:not(.emoji) { + display: block; + margin: 0 auto; /* align center */ + width: auto; + height: auto; +} + +/* apply border radius to all image elements. */ +.wp-block-image img, +p > img:not(.emoji), +.tiled-gallery__item > img { + -webkit-border-radius: 3pt; +} + +figcaption { + font: -apple-system-caption1; + color: -apple-system-secondary-label; + text-align: center; +} + +/* set custom emoji images to be match current font size, and align it to the text baseline. */ +img.emoji { + vertical-align: baseline; + max-width: 1rem; +} + +ul.blocks-gallery-grid { + list-style-type: none; + margin: auto 0; + padding: 0; + display: flex; + -webkit-flex-wrap: wrap; + align-items: center; + justify-content: space-between; +} + +.blocks-gallery-item { + max-width: 50vw; +} + +.blocks-gallery-item img { + max-width: 100%; + height: auto; + object-fit: contain; +} + +/** BLOCKQUOTES **/ + +blockquote { + font-size: 1rem; + margin: 15pt 0; + padding: 10pt; + background-color: -apple-system-secondary-background; + -webkit-border-radius: 3pt; +} + +blockquote > p:first-child { + margin-top: 0; + padding-top: 0; +} + +blockquote cite { + display: block; + font: -apple-system-footnote; + text-align: right; +} + +blockquote cite::before { + content: "— "; +} + +/** CODE SNIPPETS **/ + +code, +pre { + font-family: var(--monospace-font); + font-size: 0.85rem; + -webkit-border-radius: 3pt; + background-color: -apple-system-secondary-background; +} + +code { + padding: 0 3pt; +} + +pre { + padding: 10pt; + padding-right: 5pt; + white-space: pre-wrap; + word-wrap: break-word; +} + +/** TABLES **/ + +table { + /* allow tables to span outside the viewport, since it is scrollable. */ + max-width: initial; + overflow-x: scroll; + min-width: 100vw; + margin: auto 0; + padding: 0; + border-collapse: collapse; +} + +table, tr, td { + border: 1px solid -apple-system-separator; +} + +td { + padding: 6pt; + vertical-align: top; +} + +/** VIDEOS **/ + +video { + width: 100vw; + height: auto; +} + +/** BLOCK OVERRIDES **/ + +/* override hardcoded background color to system default. */ +.has-background { + background-color: -apple-system-background !important; +} + +/* forcefully remove gaps from custom vertical spacers. */ +.wp-block-spacer { + height: 0px !important; +} + +/* + forcefully assign default text color. + some contents apply inline text color styling which may lead to low contrast. + */ +p, em, strong, b { + color: -apple-system-label !important; +} diff --git a/WordPress/Resources/HTML/richCommentTemplate.html b/WordPress/Resources/HTML/richCommentTemplate.html new file mode 100644 index 000000000000..ee66fe8062ce --- /dev/null +++ b/WordPress/Resources/HTML/richCommentTemplate.html @@ -0,0 +1,24 @@ + + + + + + + + %5$@ + + diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_20pt.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_20pt.png deleted file mode 100644 index 8906a30c7800..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_20pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_20pt@2x.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_20pt@2x.png deleted file mode 100644 index 021e6c8ef4a5..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_20pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_20pt@3x.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_20pt@3x.png deleted file mode 100644 index 82ef16f46652..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_20pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_29pt.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_29pt.png deleted file mode 100644 index 09de7475e3b8..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_29pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_29pt@2x.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_29pt@2x.png deleted file mode 100644 index db2008cb5d15..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_29pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_29pt@3x.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_29pt@3x.png deleted file mode 100644 index 06df69def195..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_29pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_40pt.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_40pt.png deleted file mode 100644 index 021e6c8ef4a5..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_40pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_40pt@2x.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_40pt@2x.png deleted file mode 100644 index 2a76276ce30f..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_40pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_40pt@3x.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_40pt@3x.png deleted file mode 100644 index 6401cee182bd..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_40pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_60pt@2x.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_60pt@2x.png deleted file mode 100644 index 6401cee182bd..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_60pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_60pt@3x.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_60pt@3x.png deleted file mode 100644 index 8e91be2d1d90..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_60pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_76pt.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_76pt.png deleted file mode 100644 index cd229a612c25..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_76pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_76pt@2x.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_76pt@2x.png deleted file mode 100644 index db4ad1c1f045..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_76pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_83.5@2x.png b/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_83.5@2x.png deleted file mode 100644 index 786c2090eb45..000000000000 Binary files a/WordPress/Resources/Icons/Hot Pink/hot_pink_icon_83.5@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_20pt.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_20pt.png deleted file mode 100644 index 1f8e393a9f55..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_20pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_20pt@2x.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_20pt@2x.png deleted file mode 100644 index 2d61a4e58158..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_20pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_20pt@3x.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_20pt@3x.png deleted file mode 100644 index 977c343f01ed..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_20pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_29pt.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_29pt.png deleted file mode 100644 index e293003744d5..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_29pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_29pt@2x.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_29pt@2x.png deleted file mode 100644 index 332619255ea8..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_29pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_29pt@3x.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_29pt@3x.png deleted file mode 100644 index 4dd1d0ae8576..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_29pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_40pt.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_40pt.png deleted file mode 100644 index 2d61a4e58158..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_40pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_40pt@2x.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_40pt@2x.png deleted file mode 100644 index 4653da504038..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_40pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_40pt@3x.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_40pt@3x.png deleted file mode 100644 index f2828ad6658c..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_40pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_60pt@2x.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_60pt@2x.png deleted file mode 100644 index f2828ad6658c..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_60pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_60pt@3x.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_60pt@3x.png deleted file mode 100644 index 28627e047ac8..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_60pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_76pt.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_76pt.png deleted file mode 100644 index 39caf45d38a2..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_76pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_76pt@2x.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_76pt@2x.png deleted file mode 100644 index c23665d696f8..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_76pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_83.5@2x.png b/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_83.5@2x.png deleted file mode 100644 index 5325c7235b09..000000000000 Binary files a/WordPress/Resources/Icons/Jetpack Green/jetpack_green_icon_83.5@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_20pt.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_20pt.png deleted file mode 100644 index 86d609a3038c..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_20pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_20pt@2x.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_20pt@2x.png deleted file mode 100644 index 9951bd1aa240..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_20pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_20pt@3x.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_20pt@3x.png deleted file mode 100644 index 26960ef4338a..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_20pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_29pt.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_29pt.png deleted file mode 100644 index 75b42451273b..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_29pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_29pt@2x.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_29pt@2x.png deleted file mode 100644 index 68ad567b87af..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_29pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_29pt@3x.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_29pt@3x.png deleted file mode 100644 index 324b397d4c10..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_29pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_40pt.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_40pt.png deleted file mode 100644 index 9951bd1aa240..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_40pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_40pt@2x.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_40pt@2x.png deleted file mode 100644 index 395aeca1b5ab..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_40pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_40pt@3x.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_40pt@3x.png deleted file mode 100644 index 509db33cd956..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_40pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_60pt@2x.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_60pt@2x.png deleted file mode 100644 index 509db33cd956..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_60pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_60pt@3x.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_60pt@3x.png deleted file mode 100644 index dc23ac1fc803..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_60pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_76pt.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_76pt.png deleted file mode 100644 index be493dca3375..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_76pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_76pt@2x.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_76pt@2x.png deleted file mode 100644 index 2941fd5d641e..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_76pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_83.5@2x.png b/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_83.5@2x.png deleted file mode 100644 index c10d0f467dd7..000000000000 Binary files a/WordPress/Resources/Icons/Open Source Dark/open_source_dark_icon_83.5@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_20pt.png b/WordPress/Resources/Icons/Open Source/open_source_icon_20pt.png deleted file mode 100644 index 9bcfd6038b44..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_20pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_20pt@2x.png b/WordPress/Resources/Icons/Open Source/open_source_icon_20pt@2x.png deleted file mode 100644 index 0890ba28369e..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_20pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_20pt@3x.png b/WordPress/Resources/Icons/Open Source/open_source_icon_20pt@3x.png deleted file mode 100644 index 91b9ae1675c8..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_20pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_29pt.png b/WordPress/Resources/Icons/Open Source/open_source_icon_29pt.png deleted file mode 100644 index f331ba8e4b01..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_29pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_29pt@2x.png b/WordPress/Resources/Icons/Open Source/open_source_icon_29pt@2x.png deleted file mode 100644 index 6abefafb265e..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_29pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_29pt@3x.png b/WordPress/Resources/Icons/Open Source/open_source_icon_29pt@3x.png deleted file mode 100644 index 42496e4d8056..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_29pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_40pt.png b/WordPress/Resources/Icons/Open Source/open_source_icon_40pt.png deleted file mode 100644 index 0890ba28369e..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_40pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_40pt@2x.png b/WordPress/Resources/Icons/Open Source/open_source_icon_40pt@2x.png deleted file mode 100644 index 619a0baa0988..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_40pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_40pt@3x.png b/WordPress/Resources/Icons/Open Source/open_source_icon_40pt@3x.png deleted file mode 100644 index 596e4b87a042..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_40pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_60pt@2x.png b/WordPress/Resources/Icons/Open Source/open_source_icon_60pt@2x.png deleted file mode 100644 index 596e4b87a042..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_60pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_60pt@3x.png b/WordPress/Resources/Icons/Open Source/open_source_icon_60pt@3x.png deleted file mode 100644 index ce2438e8bf95..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_60pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_76pt.png b/WordPress/Resources/Icons/Open Source/open_source_icon_76pt.png deleted file mode 100644 index a9a21e3fb10d..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_76pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_76pt@2x.png b/WordPress/Resources/Icons/Open Source/open_source_icon_76pt@2x.png deleted file mode 100644 index 051f4d299b2c..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_76pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Open Source/open_source_icon_83.5@2x.png b/WordPress/Resources/Icons/Open Source/open_source_icon_83.5@2x.png deleted file mode 100644 index 8365a37a56b1..000000000000 Binary files a/WordPress/Resources/Icons/Open Source/open_source_icon_83.5@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_20pt.png b/WordPress/Resources/Icons/Pride/pride_icon_20pt.png deleted file mode 100644 index 9504e2175d05..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_20pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_20pt@2x.png b/WordPress/Resources/Icons/Pride/pride_icon_20pt@2x.png deleted file mode 100644 index fc7489bf483e..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_20pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_20pt@3x.png b/WordPress/Resources/Icons/Pride/pride_icon_20pt@3x.png deleted file mode 100644 index 0f0d4e498ee9..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_20pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_29pt.png b/WordPress/Resources/Icons/Pride/pride_icon_29pt.png deleted file mode 100644 index 04a45e3797e2..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_29pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_29pt@2x.png b/WordPress/Resources/Icons/Pride/pride_icon_29pt@2x.png deleted file mode 100644 index c0fc71dfa0d1..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_29pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_29pt@3x.png b/WordPress/Resources/Icons/Pride/pride_icon_29pt@3x.png deleted file mode 100644 index 5c401e36b677..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_29pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_40pt.png b/WordPress/Resources/Icons/Pride/pride_icon_40pt.png deleted file mode 100644 index fc7489bf483e..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_40pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_40pt@2x.png b/WordPress/Resources/Icons/Pride/pride_icon_40pt@2x.png deleted file mode 100644 index 12a61ca30c5f..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_40pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_40pt@3x.png b/WordPress/Resources/Icons/Pride/pride_icon_40pt@3x.png deleted file mode 100644 index bbaa44f0da39..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_40pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_60pt.png b/WordPress/Resources/Icons/Pride/pride_icon_60pt.png deleted file mode 100644 index 0f0d4e498ee9..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_60pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_60pt@2x.png b/WordPress/Resources/Icons/Pride/pride_icon_60pt@2x.png deleted file mode 100644 index bbaa44f0da39..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_60pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_60pt@3x.png b/WordPress/Resources/Icons/Pride/pride_icon_60pt@3x.png deleted file mode 100644 index 3aba37863da0..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_60pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_76pt.png b/WordPress/Resources/Icons/Pride/pride_icon_76pt.png deleted file mode 100644 index f8fecc5be831..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_76pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_76pt@2x.png b/WordPress/Resources/Icons/Pride/pride_icon_76pt@2x.png deleted file mode 100644 index 3e76aaa9419d..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_76pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/Pride/pride_icon_83.5@2x.png b/WordPress/Resources/Icons/Pride/pride_icon_83.5@2x.png deleted file mode 100644 index bd8be5aa7a11..000000000000 Binary files a/WordPress/Resources/Icons/Pride/pride_icon_83.5@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_20pt.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_20pt.png deleted file mode 100644 index 82db7daff088..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_20pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_20pt@2x.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_20pt@2x.png deleted file mode 100644 index a2c887c7288c..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_20pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_20pt@3x.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_20pt@3x.png deleted file mode 100644 index 0f1074a15172..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_20pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_29pt.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_29pt.png deleted file mode 100644 index 54eefc362e68..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_29pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_29pt@2x.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_29pt@2x.png deleted file mode 100644 index 27c6fe4c9569..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_29pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_29pt@3x.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_29pt@3x.png deleted file mode 100644 index 916010f488b0..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_29pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_40pt.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_40pt.png deleted file mode 100644 index a2c887c7288c..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_40pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_40pt@2x.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_40pt@2x.png deleted file mode 100644 index 74a24d162a75..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_40pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_40pt@3x.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_40pt@3x.png deleted file mode 100644 index 2fbc5eeda187..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_40pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_60pt@2x.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_60pt@2x.png deleted file mode 100644 index 2fbc5eeda187..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_60pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_60pt@3x.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_60pt@3x.png deleted file mode 100644 index 7fd6deaa1e2f..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_60pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_76pt.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_76pt.png deleted file mode 100644 index ef9dfafdf926..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_76pt.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_76pt@2x.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_76pt@2x.png deleted file mode 100644 index 73e1fd57fe2f..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_76pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_83.5@2x.png b/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_83.5@2x.png deleted file mode 100644 index f39e822dd69d..000000000000 Binary files a/WordPress/Resources/Icons/WordPress Dark/wordpress_dark_icon_83.5@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress/wordpress_icon_40pt@2x.png b/WordPress/Resources/Icons/WordPress/wordpress_icon_40pt@2x.png deleted file mode 100644 index 94d67fffd000..000000000000 Binary files a/WordPress/Resources/Icons/WordPress/wordpress_icon_40pt@2x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/WordPress/wordpress_icon_40pt@3x.png b/WordPress/Resources/Icons/WordPress/wordpress_icon_40pt@3x.png deleted file mode 100644 index 92758a2c67c2..000000000000 Binary files a/WordPress/Resources/Icons/WordPress/wordpress_icon_40pt@3x.png and /dev/null differ diff --git a/WordPress/Resources/Icons/black-classic/black-classic-icon-app-60x60@2x.png b/WordPress/Resources/Icons/black-classic/black-classic-icon-app-60x60@2x.png new file mode 100644 index 000000000000..ec5e6c62cf67 Binary files /dev/null and b/WordPress/Resources/Icons/black-classic/black-classic-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/black-classic/black-classic-icon-app-60x60@3x.png b/WordPress/Resources/Icons/black-classic/black-classic-icon-app-60x60@3x.png new file mode 100644 index 000000000000..11c7a7352382 Binary files /dev/null and b/WordPress/Resources/Icons/black-classic/black-classic-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/black-classic/black-classic-icon-app-76x76.png b/WordPress/Resources/Icons/black-classic/black-classic-icon-app-76x76.png new file mode 100644 index 000000000000..73145d90cab1 Binary files /dev/null and b/WordPress/Resources/Icons/black-classic/black-classic-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/black-classic/black-classic-icon-app-76x76@2x.png b/WordPress/Resources/Icons/black-classic/black-classic-icon-app-76x76@2x.png new file mode 100644 index 000000000000..95c194d53ad7 Binary files /dev/null and b/WordPress/Resources/Icons/black-classic/black-classic-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/black-classic/black-classic-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/black-classic/black-classic-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..a93e0d5d71b5 Binary files /dev/null and b/WordPress/Resources/Icons/black-classic/black-classic-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/black/black-icon-app-60x60@2x.png b/WordPress/Resources/Icons/black/black-icon-app-60x60@2x.png new file mode 100644 index 000000000000..3fec12836c18 Binary files /dev/null and b/WordPress/Resources/Icons/black/black-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/black/black-icon-app-60x60@3x.png b/WordPress/Resources/Icons/black/black-icon-app-60x60@3x.png new file mode 100644 index 000000000000..0e8b1fb207c7 Binary files /dev/null and b/WordPress/Resources/Icons/black/black-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/black/black-icon-app-76x76.png b/WordPress/Resources/Icons/black/black-icon-app-76x76.png new file mode 100644 index 000000000000..b6b5e4c820f9 Binary files /dev/null and b/WordPress/Resources/Icons/black/black-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/black/black-icon-app-76x76@2x.png b/WordPress/Resources/Icons/black/black-icon-app-76x76@2x.png new file mode 100644 index 000000000000..d3ee0f43c4b7 Binary files /dev/null and b/WordPress/Resources/Icons/black/black-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/black/black-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/black/black-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..ee4ec5ec6e17 Binary files /dev/null and b/WordPress/Resources/Icons/black/black-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-60x60@2x.png b/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-60x60@2x.png new file mode 100644 index 000000000000..4d306cdbb8b0 Binary files /dev/null and b/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-60x60@3x.png b/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-60x60@3x.png new file mode 100644 index 000000000000..43eedbe58d5f Binary files /dev/null and b/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-76x76.png b/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-76x76.png new file mode 100644 index 000000000000..c699ddf6afc2 Binary files /dev/null and b/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-76x76@2x.png b/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-76x76@2x.png new file mode 100644 index 000000000000..67128f048840 Binary files /dev/null and b/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..2f549bbcc121 Binary files /dev/null and b/WordPress/Resources/Icons/blue-classic/blue-classic-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/blue/blue-icon-app-60x60@2x.png b/WordPress/Resources/Icons/blue/blue-icon-app-60x60@2x.png new file mode 100644 index 000000000000..3af50dba7e44 Binary files /dev/null and b/WordPress/Resources/Icons/blue/blue-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/blue/blue-icon-app-60x60@3x.png b/WordPress/Resources/Icons/blue/blue-icon-app-60x60@3x.png new file mode 100644 index 000000000000..d574a6c95df7 Binary files /dev/null and b/WordPress/Resources/Icons/blue/blue-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/blue/blue-icon-app-76x76.png b/WordPress/Resources/Icons/blue/blue-icon-app-76x76.png new file mode 100644 index 000000000000..80e36d486f76 Binary files /dev/null and b/WordPress/Resources/Icons/blue/blue-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/blue/blue-icon-app-76x76@2x.png b/WordPress/Resources/Icons/blue/blue-icon-app-76x76@2x.png new file mode 100644 index 000000000000..71b59b0872d7 Binary files /dev/null and b/WordPress/Resources/Icons/blue/blue-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/blue/blue-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/blue/blue-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..737ef93d50de Binary files /dev/null and b/WordPress/Resources/Icons/blue/blue-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-60x60@2x.png b/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-60x60@2x.png new file mode 100644 index 000000000000..18115d5ac600 Binary files /dev/null and b/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-60x60@3x.png b/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-60x60@3x.png new file mode 100644 index 000000000000..5269b89c363a Binary files /dev/null and b/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-76x76.png b/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-76x76.png new file mode 100644 index 000000000000..64a9664eacd8 Binary files /dev/null and b/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-76x76@2x.png b/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-76x76@2x.png new file mode 100644 index 000000000000..e40282f7fcda Binary files /dev/null and b/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..4e5fbf06531e Binary files /dev/null and b/WordPress/Resources/Icons/celadon-classic/celadon-classic-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/celadon/celadon-icon-app-60x60@2x.png b/WordPress/Resources/Icons/celadon/celadon-icon-app-60x60@2x.png new file mode 100644 index 000000000000..679322977c9a Binary files /dev/null and b/WordPress/Resources/Icons/celadon/celadon-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/celadon/celadon-icon-app-60x60@3x.png b/WordPress/Resources/Icons/celadon/celadon-icon-app-60x60@3x.png new file mode 100644 index 000000000000..96bdd1f411c1 Binary files /dev/null and b/WordPress/Resources/Icons/celadon/celadon-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/celadon/celadon-icon-app-76x76.png b/WordPress/Resources/Icons/celadon/celadon-icon-app-76x76.png new file mode 100644 index 000000000000..9883d26604c6 Binary files /dev/null and b/WordPress/Resources/Icons/celadon/celadon-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/celadon/celadon-icon-app-76x76@2x.png b/WordPress/Resources/Icons/celadon/celadon-icon-app-76x76@2x.png new file mode 100644 index 000000000000..e0876fb8ec7b Binary files /dev/null and b/WordPress/Resources/Icons/celadon/celadon-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/celadon/celadon-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/celadon/celadon-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..6c88385d88fd Binary files /dev/null and b/WordPress/Resources/Icons/celadon/celadon-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-60x60@2x.png b/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-60x60@2x.png new file mode 100644 index 000000000000..cef2aacbdbb7 Binary files /dev/null and b/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-60x60@3x.png b/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-60x60@3x.png new file mode 100644 index 000000000000..2fb4a3ed7cdc Binary files /dev/null and b/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-76x76.png b/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-76x76.png new file mode 100644 index 000000000000..f86621858e31 Binary files /dev/null and b/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-76x76@2x.png b/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-76x76@2x.png new file mode 100644 index 000000000000..d4ec75c66351 Binary files /dev/null and b/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..b88d9729d326 Binary files /dev/null and b/WordPress/Resources/Icons/cool-blue/cool-blue-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-60x60@2x.png b/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-60x60@2x.png new file mode 100644 index 000000000000..cdb9ceb76148 Binary files /dev/null and b/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-60x60@3x.png b/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-60x60@3x.png new file mode 100644 index 000000000000..ac736bb0dba7 Binary files /dev/null and b/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-76x76.png b/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-76x76.png new file mode 100644 index 000000000000..f3e4af1cd2bb Binary files /dev/null and b/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-76x76@2x.png b/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-76x76@2x.png new file mode 100644 index 000000000000..6db390437a2f Binary files /dev/null and b/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..9ae32da2dd0b Binary files /dev/null and b/WordPress/Resources/Icons/hot-pink/hot-pink-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-60x60@2x.png b/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-60x60@2x.png new file mode 100644 index 000000000000..aaaa13072c31 Binary files /dev/null and b/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-60x60@3x.png b/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-60x60@3x.png new file mode 100644 index 000000000000..32c280c6b834 Binary files /dev/null and b/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-76x76.png b/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-76x76.png new file mode 100644 index 000000000000..64f58892253e Binary files /dev/null and b/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-76x76@2x.png b/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-76x76@2x.png new file mode 100644 index 000000000000..5daa65d17947 Binary files /dev/null and b/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..f69f933f0a23 Binary files /dev/null and b/WordPress/Resources/Icons/jetpack-green/jetpack-green-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-60x60@2x.png b/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-60x60@2x.png new file mode 100644 index 000000000000..052083a582f5 Binary files /dev/null and b/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-60x60@3x.png b/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-60x60@3x.png new file mode 100644 index 000000000000..87afee88df3f Binary files /dev/null and b/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-76x76.png b/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-76x76.png new file mode 100644 index 000000000000..8ed376e7fc96 Binary files /dev/null and b/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-76x76@2x.png b/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-76x76@2x.png new file mode 100644 index 000000000000..c05f7405a6da Binary files /dev/null and b/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..6d38cbebfee4 Binary files /dev/null and b/WordPress/Resources/Icons/open-source-dark/open-source-dark-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/open-source/open-source-icon-app-60x60@2x.png b/WordPress/Resources/Icons/open-source/open-source-icon-app-60x60@2x.png new file mode 100644 index 000000000000..4e193d8b336f Binary files /dev/null and b/WordPress/Resources/Icons/open-source/open-source-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/open-source/open-source-icon-app-60x60@3x.png b/WordPress/Resources/Icons/open-source/open-source-icon-app-60x60@3x.png new file mode 100644 index 000000000000..d5b7e5402fdf Binary files /dev/null and b/WordPress/Resources/Icons/open-source/open-source-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/open-source/open-source-icon-app-76x76.png b/WordPress/Resources/Icons/open-source/open-source-icon-app-76x76.png new file mode 100644 index 000000000000..4156819801dc Binary files /dev/null and b/WordPress/Resources/Icons/open-source/open-source-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/open-source/open-source-icon-app-76x76@2x.png b/WordPress/Resources/Icons/open-source/open-source-icon-app-76x76@2x.png new file mode 100644 index 000000000000..c60896aed8e6 Binary files /dev/null and b/WordPress/Resources/Icons/open-source/open-source-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/open-source/open-source-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/open-source/open-source-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..c6e0537f8412 Binary files /dev/null and b/WordPress/Resources/Icons/open-source/open-source-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-60x60@2x.png b/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-60x60@2x.png new file mode 100644 index 000000000000..17dc567ae7e1 Binary files /dev/null and b/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-60x60@3x.png b/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-60x60@3x.png new file mode 100644 index 000000000000..b3c2878de452 Binary files /dev/null and b/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-76x76.png b/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-76x76.png new file mode 100644 index 000000000000..8a6ce5a282ee Binary files /dev/null and b/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-76x76@2x.png b/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-76x76@2x.png new file mode 100644 index 000000000000..aff25c725476 Binary files /dev/null and b/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..662cac50a532 Binary files /dev/null and b/WordPress/Resources/Icons/pink-classic/pink-classic-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/pink/pink-icon-app-60x60@2x.png b/WordPress/Resources/Icons/pink/pink-icon-app-60x60@2x.png new file mode 100644 index 000000000000..463ca9b992fc Binary files /dev/null and b/WordPress/Resources/Icons/pink/pink-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/pink/pink-icon-app-60x60@3x.png b/WordPress/Resources/Icons/pink/pink-icon-app-60x60@3x.png new file mode 100644 index 000000000000..c6d4eef2b248 Binary files /dev/null and b/WordPress/Resources/Icons/pink/pink-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/pink/pink-icon-app-76x76.png b/WordPress/Resources/Icons/pink/pink-icon-app-76x76.png new file mode 100644 index 000000000000..e277a958caa9 Binary files /dev/null and b/WordPress/Resources/Icons/pink/pink-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/pink/pink-icon-app-76x76@2x.png b/WordPress/Resources/Icons/pink/pink-icon-app-76x76@2x.png new file mode 100644 index 000000000000..af3543258b77 Binary files /dev/null and b/WordPress/Resources/Icons/pink/pink-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/pink/pink-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/pink/pink-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..f5ad9a0de8cd Binary files /dev/null and b/WordPress/Resources/Icons/pink/pink-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/pride/pride-icon-app-60x60@2x.png b/WordPress/Resources/Icons/pride/pride-icon-app-60x60@2x.png new file mode 100644 index 000000000000..cf8b0ea7371e Binary files /dev/null and b/WordPress/Resources/Icons/pride/pride-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/pride/pride-icon-app-60x60@3x.png b/WordPress/Resources/Icons/pride/pride-icon-app-60x60@3x.png new file mode 100644 index 000000000000..4952e6296bad Binary files /dev/null and b/WordPress/Resources/Icons/pride/pride-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/pride/pride-icon-app-76x76.png b/WordPress/Resources/Icons/pride/pride-icon-app-76x76.png new file mode 100644 index 000000000000..69c7ebddca5c Binary files /dev/null and b/WordPress/Resources/Icons/pride/pride-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/pride/pride-icon-app-76x76@2x.png b/WordPress/Resources/Icons/pride/pride-icon-app-76x76@2x.png new file mode 100644 index 000000000000..46919e822f78 Binary files /dev/null and b/WordPress/Resources/Icons/pride/pride-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/pride/pride-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/pride/pride-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..602b6fe99698 Binary files /dev/null and b/WordPress/Resources/Icons/pride/pride-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-60x60@2x.png b/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-60x60@2x.png new file mode 100644 index 000000000000..885ff5d69810 Binary files /dev/null and b/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-60x60@3x.png b/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-60x60@3x.png new file mode 100644 index 000000000000..5553a3192dea Binary files /dev/null and b/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-76x76.png b/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-76x76.png new file mode 100644 index 000000000000..d8ed422942af Binary files /dev/null and b/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-76x76@2x.png b/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-76x76@2x.png new file mode 100644 index 000000000000..15e91cf2ac3b Binary files /dev/null and b/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..987b5ce05c6e Binary files /dev/null and b/WordPress/Resources/Icons/spectrum-2022/spectrum-'22-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-60x60@2x.png b/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-60x60@2x.png new file mode 100644 index 000000000000..3e9445e25e02 Binary files /dev/null and b/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-60x60@3x.png b/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-60x60@3x.png new file mode 100644 index 000000000000..9ff0681090f7 Binary files /dev/null and b/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-76x76.png b/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-76x76.png new file mode 100644 index 000000000000..219719a1d5fb Binary files /dev/null and b/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-76x76@2x.png b/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-76x76@2x.png new file mode 100644 index 000000000000..4e7cb39007fe Binary files /dev/null and b/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..d0edcba05f09 Binary files /dev/null and b/WordPress/Resources/Icons/spectrum-classic/spectrum-classic-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/spectrum/spectrum-icon-app-60x60@2x.png b/WordPress/Resources/Icons/spectrum/spectrum-icon-app-60x60@2x.png new file mode 100644 index 000000000000..bf8b36ccd7d5 Binary files /dev/null and b/WordPress/Resources/Icons/spectrum/spectrum-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/spectrum/spectrum-icon-app-60x60@3x.png b/WordPress/Resources/Icons/spectrum/spectrum-icon-app-60x60@3x.png new file mode 100644 index 000000000000..51f4b45e8385 Binary files /dev/null and b/WordPress/Resources/Icons/spectrum/spectrum-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/spectrum/spectrum-icon-app-76x76.png b/WordPress/Resources/Icons/spectrum/spectrum-icon-app-76x76.png new file mode 100644 index 000000000000..3bf1729047f3 Binary files /dev/null and b/WordPress/Resources/Icons/spectrum/spectrum-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/spectrum/spectrum-icon-app-76x76@2x.png b/WordPress/Resources/Icons/spectrum/spectrum-icon-app-76x76@2x.png new file mode 100644 index 000000000000..b34884bbbe30 Binary files /dev/null and b/WordPress/Resources/Icons/spectrum/spectrum-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/spectrum/spectrum-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/spectrum/spectrum-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..74ec53188f13 Binary files /dev/null and b/WordPress/Resources/Icons/spectrum/spectrum-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-60x60@2x.png b/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-60x60@2x.png new file mode 100644 index 000000000000..2717a326eeea Binary files /dev/null and b/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-60x60@2x.png differ diff --git a/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-60x60@3x.png b/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-60x60@3x.png new file mode 100644 index 000000000000..dd48285433a5 Binary files /dev/null and b/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-60x60@3x.png differ diff --git a/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-76x76.png b/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-76x76.png new file mode 100644 index 000000000000..9b1dde3f160c Binary files /dev/null and b/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-76x76.png differ diff --git a/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-76x76@2x.png b/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-76x76@2x.png new file mode 100644 index 000000000000..a991d549fdca Binary files /dev/null and b/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-76x76@2x.png differ diff --git a/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-83.5x83.5@2x.png b/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-83.5x83.5@2x.png new file mode 100644 index 000000000000..b11f572952e7 Binary files /dev/null and b/WordPress/Resources/Icons/wordpress-dark/wordpress-dark-icon-app-83.5x83.5@2x.png differ diff --git a/WordPress/Resources/ar.lproj/InfoPlist.strings b/WordPress/Resources/ar.lproj/InfoPlist.strings index effa96753841..734a38112735 100644 --- a/WordPress/Resources/ar.lproj/InfoPlist.strings +++ b/WordPress/Resources/ar.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSLocationWhenInUseUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSCameraUsageDescription = "لالتقاط صور أو مقاطع الفيديو لاستخدامها في مقالاتك."; -NSPhotoLibraryUsageDescription = "لإضافة صور أو مقاطع فيديو إلى مقالاتك."; -NSMicrophoneUsageDescription = "بالنسبة إلى مقاطع الفيديو المراد إضافة صوت إليها."; + + + + + + NSCameraUsageDescription + لالتقاط صور أو مقاطع فيديو لاستخدامها في مقالاتك. + NSLocationUsageDescription + ترغب وردبريس في إضافة مكانك إلى مقالاتك على الموقع حيث قمت بتمكين تحديد الموقع الجغرافي. + NSLocationWhenInUseUsageDescription + ترغب وردبريس في إضافة مكانك إلى مقالاتك على الموقع حيث قمت بتمكين تحديد الموقع الجغرافي. + NSMicrophoneUsageDescription + قم بتمكين الوصول إلى الميكروفون لتسجيل الصوت في فيديوهاتك. + NSPhotoLibraryUsageDescription + لإضافة صور أو مقاطع فيديو إلى مقالاتك. + + diff --git a/WordPress/Resources/ar.lproj/Localizable.strings b/WordPress/Resources/ar.lproj/Localizable.strings index 3897c00ab9cb..b17bdd50f55c 100644 Binary files a/WordPress/Resources/ar.lproj/Localizable.strings and b/WordPress/Resources/ar.lproj/Localizable.strings differ diff --git a/WordPress/Resources/bg.lproj/InfoPlist.strings b/WordPress/Resources/bg.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/Resources/bg.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/Resources/bg.lproj/Localizable.strings b/WordPress/Resources/bg.lproj/Localizable.strings index 6b824c5faacf..b2e4e9fc62c8 100644 Binary files a/WordPress/Resources/bg.lproj/Localizable.strings and b/WordPress/Resources/bg.lproj/Localizable.strings differ diff --git a/WordPress/Resources/cs.lproj/InfoPlist.strings b/WordPress/Resources/cs.lproj/InfoPlist.strings new file mode 100644 index 000000000000..dd9708eddf71 --- /dev/null +++ b/WordPress/Resources/cs.lproj/InfoPlist.strings @@ -0,0 +1,17 @@ + + + + + + NSCameraUsageDescription + Pořizovat fotky a videa a umožnit použití ve vašich příspěvcích. + NSLocationUsageDescription + WordPress chce přidat vaší polohu k příspěvkům, můžete povolit určování polohy. + NSLocationWhenInUseUsageDescription + WordPress chce přidat vaší polohu k příspěvkům, můžete povolit určování polohy. + NSMicrophoneUsageDescription + Povolte přístup k mikrofonům pro záznam zvuku ve videích. + NSPhotoLibraryUsageDescription + Chcete přidat fotky a videa do vašeho příspěvku. + + diff --git a/WordPress/Resources/cs.lproj/Localizable.strings b/WordPress/Resources/cs.lproj/Localizable.strings index cda6e48494c3..97e82b968b32 100644 Binary files a/WordPress/Resources/cs.lproj/Localizable.strings and b/WordPress/Resources/cs.lproj/Localizable.strings differ diff --git a/WordPress/Resources/cy.lproj/InfoPlist.strings b/WordPress/Resources/cy.lproj/InfoPlist.strings index fce347e649b9..6b6139952ec6 100644 --- a/WordPress/Resources/cy.lproj/InfoPlist.strings +++ b/WordPress/Resources/cy.lproj/InfoPlist.strings @@ -1,5 +1,6 @@ -NSLocationUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSLocationWhenInUseUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSCameraUsageDescription = "To take photos or videos to use in your posts."; -NSPhotoLibraryUsageDescription = "To add photos or videos to your posts."; -NSMicrophoneUsageDescription = "Enable microphone access to record sound in your videos."; + + + + + + diff --git a/WordPress/Resources/cy.lproj/Localizable.strings b/WordPress/Resources/cy.lproj/Localizable.strings index 82cb63741d4c..b85ab841d5c3 100644 Binary files a/WordPress/Resources/cy.lproj/Localizable.strings and b/WordPress/Resources/cy.lproj/Localizable.strings differ diff --git a/WordPress/Resources/da.lproj/InfoPlist.strings b/WordPress/Resources/da.lproj/InfoPlist.strings index fce347e649b9..6b6139952ec6 100644 --- a/WordPress/Resources/da.lproj/InfoPlist.strings +++ b/WordPress/Resources/da.lproj/InfoPlist.strings @@ -1,5 +1,6 @@ -NSLocationUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSLocationWhenInUseUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSCameraUsageDescription = "To take photos or videos to use in your posts."; -NSPhotoLibraryUsageDescription = "To add photos or videos to your posts."; -NSMicrophoneUsageDescription = "Enable microphone access to record sound in your videos."; + + + + + + diff --git a/WordPress/Resources/da.lproj/Localizable.strings b/WordPress/Resources/da.lproj/Localizable.strings index 8d52add1f961..95cb28aacb9e 100644 Binary files a/WordPress/Resources/da.lproj/Localizable.strings and b/WordPress/Resources/da.lproj/Localizable.strings differ diff --git a/WordPress/Resources/de.lproj/InfoPlist.strings b/WordPress/Resources/de.lproj/InfoPlist.strings index 13ee3b58568a..a6e787e366e1 100644 --- a/WordPress/Resources/de.lproj/InfoPlist.strings +++ b/WordPress/Resources/de.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress möchte auf Webseiten, bei denen Du das Geotagging aktiviert hast, deinen Standort zu den Beiträgen hinzufügen."; -NSLocationWhenInUseUsageDescription = "WordPress möchte auf Webseiten, bei denen Du das Geotagging aktiviert hast, deinen Standort zu den Beiträgen hinzufügen."; -NSCameraUsageDescription = "Um Fotos oder Videos für deine Beiträge aufzunehmen."; -NSPhotoLibraryUsageDescription = "Um deinen Beiträgen Fotos oder Videos hinzuzufügen."; -NSMicrophoneUsageDescription = "Damit du Sound für deine Videos aufnehmen kannst."; + + + + + + NSCameraUsageDescription + Um Fotos oder Videos für deine Beiträge aufzunehmen. + NSLocationUsageDescription + Darf WordPress zu Beiträgen auf Websites, auf denen du Geotagging aktiviert hast, deinen Standort hinzufügen? + NSLocationWhenInUseUsageDescription + Darf WordPress zu Beiträgen auf Websites, auf denen du Geotagging aktiviert hast, deinen Standort hinzufügen? + NSMicrophoneUsageDescription + Aktiviere den Mikrofonzugriff, um in deinen Videos Ton aufzeichnen zu können. + NSPhotoLibraryUsageDescription + Um deinen Beiträgen Fotos oder Videos hinzuzufügen. + + diff --git a/WordPress/Resources/de.lproj/Localizable.strings b/WordPress/Resources/de.lproj/Localizable.strings index aebcdaf453be..196a247e2fc5 100644 Binary files a/WordPress/Resources/de.lproj/Localizable.strings and b/WordPress/Resources/de.lproj/Localizable.strings differ diff --git a/WordPress/Resources/en-AU.lproj/InfoPlist.strings b/WordPress/Resources/en-AU.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/Resources/en-AU.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/Resources/en-AU.lproj/Localizable.strings b/WordPress/Resources/en-AU.lproj/Localizable.strings index 41f50d743792..859520da4b0f 100644 Binary files a/WordPress/Resources/en-AU.lproj/Localizable.strings and b/WordPress/Resources/en-AU.lproj/Localizable.strings differ diff --git a/WordPress/Resources/en-CA.lproj/InfoPlist.strings b/WordPress/Resources/en-CA.lproj/InfoPlist.strings index fce347e649b9..25b76878db6c 100644 --- a/WordPress/Resources/en-CA.lproj/InfoPlist.strings +++ b/WordPress/Resources/en-CA.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSLocationWhenInUseUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSCameraUsageDescription = "To take photos or videos to use in your posts."; -NSPhotoLibraryUsageDescription = "To add photos or videos to your posts."; -NSMicrophoneUsageDescription = "Enable microphone access to record sound in your videos."; + + + + + + NSCameraUsageDescription + To take photos or videos to use in your posts. + NSLocationUsageDescription + WordPress would like to add your location to posts on sites where you have enabled geotagging. + NSLocationWhenInUseUsageDescription + WordPress would like to add your location to posts on sites where you have enabled geotagging. + NSMicrophoneUsageDescription + Enable microphone access to record sound in your videos. + NSPhotoLibraryUsageDescription + To add photos or videos to your posts. + + diff --git a/WordPress/Resources/en-CA.lproj/Localizable.strings b/WordPress/Resources/en-CA.lproj/Localizable.strings index 1e79475f23aa..3ef2a68dfa24 100644 Binary files a/WordPress/Resources/en-CA.lproj/Localizable.strings and b/WordPress/Resources/en-CA.lproj/Localizable.strings differ diff --git a/WordPress/Resources/en-GB.lproj/InfoPlist.strings b/WordPress/Resources/en-GB.lproj/InfoPlist.strings index fce347e649b9..25b76878db6c 100644 --- a/WordPress/Resources/en-GB.lproj/InfoPlist.strings +++ b/WordPress/Resources/en-GB.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSLocationWhenInUseUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSCameraUsageDescription = "To take photos or videos to use in your posts."; -NSPhotoLibraryUsageDescription = "To add photos or videos to your posts."; -NSMicrophoneUsageDescription = "Enable microphone access to record sound in your videos."; + + + + + + NSCameraUsageDescription + To take photos or videos to use in your posts. + NSLocationUsageDescription + WordPress would like to add your location to posts on sites where you have enabled geotagging. + NSLocationWhenInUseUsageDescription + WordPress would like to add your location to posts on sites where you have enabled geotagging. + NSMicrophoneUsageDescription + Enable microphone access to record sound in your videos. + NSPhotoLibraryUsageDescription + To add photos or videos to your posts. + + diff --git a/WordPress/Resources/en-GB.lproj/Localizable.strings b/WordPress/Resources/en-GB.lproj/Localizable.strings index dc44b15d7aab..20cad8196a15 100644 Binary files a/WordPress/Resources/en-GB.lproj/Localizable.strings and b/WordPress/Resources/en-GB.lproj/Localizable.strings differ diff --git a/WordPress/Resources/en.lproj/InfoPlist.strings b/WordPress/Resources/en.lproj/InfoPlist.strings index fce347e649b9..54bcec0d0cc6 100644 --- a/WordPress/Resources/en.lproj/InfoPlist.strings +++ b/WordPress/Resources/en.lproj/InfoPlist.strings @@ -1,5 +1,14 @@ +/* This sentence is shown when the app asks permission from the user to use their location. */ NSLocationUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; + +/* This sentence is shown when the app asks permission from the user to use their location. */ NSLocationWhenInUseUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; + +/* Sentence to justify why the app is asking permission from the user to use their camera. */ NSCameraUsageDescription = "To take photos or videos to use in your posts."; + +/* Sentence to justify why the app asks permission from the user to access their Media Library. */ NSPhotoLibraryUsageDescription = "To add photos or videos to your posts."; + +/* Sentence to justify why the app asks permission from the user to access the device microphone. */ NSMicrophoneUsageDescription = "Enable microphone access to record sound in your videos."; diff --git a/WordPress/Resources/en.lproj/Localizable.strings b/WordPress/Resources/en.lproj/Localizable.strings index 15bf286400c0..d1af82ded36a 100644 Binary files a/WordPress/Resources/en.lproj/Localizable.strings and b/WordPress/Resources/en.lproj/Localizable.strings differ diff --git a/WordPress/Resources/en.lproj/WordPressApi.strings b/WordPress/Resources/en.lproj/WordPressApi.strings deleted file mode 100644 index 258a95a63958..000000000000 Binary files a/WordPress/Resources/en.lproj/WordPressApi.strings and /dev/null differ diff --git a/WordPress/Resources/es.lproj/InfoPlist.strings b/WordPress/Resources/es.lproj/InfoPlist.strings index db9afc671120..2f5ff7d54b7b 100644 --- a/WordPress/Resources/es.lproj/InfoPlist.strings +++ b/WordPress/Resources/es.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress desea añadir tu ubicación a las entradas en los sitios en los que has activado el geoetiquetado."; -NSLocationWhenInUseUsageDescription = "WordPress desea añadir tu ubicación a las entradas en los sitios en los que has activado el geoetiquetado."; -NSCameraUsageDescription = "Para hacer las fotos o vídeos que deseas usar en tus entradas."; -NSPhotoLibraryUsageDescription = "Para añadir fotos o vídeos en tus entradas."; -NSMicrophoneUsageDescription = "Para que tus vídeos tengan sonido."; + + + + + + NSCameraUsageDescription + Para hacer las fotos o vídeos que deseas usar en tus entradas. + NSLocationUsageDescription + WordPress desea añadir tu ubicación a las entradas en los sitios en los que has activado el geoetiquetado. + NSLocationWhenInUseUsageDescription + WordPress desea añadir tu ubicación a las entradas en los sitios en los que has activado el geoetiquetado. + NSMicrophoneUsageDescription + Activa el acceso al micrófono para poder grabar sonido en tus vídeos. + NSPhotoLibraryUsageDescription + Para añadir fotos o vídeos en tus entradas. + + diff --git a/WordPress/Resources/es.lproj/Localizable.strings b/WordPress/Resources/es.lproj/Localizable.strings index 242bd5ee5c74..1b9e66500e73 100644 Binary files a/WordPress/Resources/es.lproj/Localizable.strings and b/WordPress/Resources/es.lproj/Localizable.strings differ diff --git a/WordPress/Resources/fr.lproj/InfoPlist.strings b/WordPress/Resources/fr.lproj/InfoPlist.strings index c914caeac9c6..02ed607aa6de 100644 --- a/WordPress/Resources/fr.lproj/InfoPlist.strings +++ b/WordPress/Resources/fr.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress voudrai ajouter votre position aux articles des sites pour lesquels vous avez activé la géolocalisation."; -NSLocationWhenInUseUsageDescription = "WordPress voudrai ajouter votre position aux articles des sites pour lesquels vous avez activé la géolocalisation."; -NSCameraUsageDescription = "Pour prendre des photos ou réaliser des vidéos à utiliser dans vos articles."; -NSPhotoLibraryUsageDescription = "Pour ajouter des photos ou des vidéos à vos articles."; -NSMicrophoneUsageDescription = "Permet à vos vidéos d'intégrer du son."; + + + + + + NSCameraUsageDescription + Pour prendre des photos ou réaliser des vidéos à utiliser dans vos articles. + NSLocationUsageDescription + WordPress souhaiterait ajouter votre emplacement aux articles sur les sites pour lesquels vous avez activé la géolocalisation. + NSLocationWhenInUseUsageDescription + WordPress souhaiterait ajouter votre emplacement aux articles sur les sites pour lesquels vous avez activé la géolocalisation. + NSMicrophoneUsageDescription + Autoriser l’accès au micro pour enregistrer le son dans vos vidéos. + NSPhotoLibraryUsageDescription + Pour ajouter des photos ou des vidéos à vos articles. + + diff --git a/WordPress/Resources/fr.lproj/Localizable.strings b/WordPress/Resources/fr.lproj/Localizable.strings index 070a437ccd8e..6bf2b4d140c1 100644 Binary files a/WordPress/Resources/fr.lproj/Localizable.strings and b/WordPress/Resources/fr.lproj/Localizable.strings differ diff --git a/WordPress/Resources/he.lproj/InfoPlist.strings b/WordPress/Resources/he.lproj/InfoPlist.strings index 6010d1ada47f..c99439f6e4cb 100644 --- a/WordPress/Resources/he.lproj/InfoPlist.strings +++ b/WordPress/Resources/he.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress רוצה להוסיף את המיקום שלך לפוסטים באתרים שבהם אפשרת תיוג מיקום."; -NSLocationWhenInUseUsageDescription = "WordPress רוצה להוסיף את המיקום שלך לפוסטים באתרים שבהם אפשרת תיוג מיקום."; -NSCameraUsageDescription = "לצלם תמונות או סרטוני וידאו לשימוש בפוסטים שלך."; -NSPhotoLibraryUsageDescription = "להוסיף תמונות או סרטוני וידאו לפוסטים."; -NSMicrophoneUsageDescription = "כדי שלסרטוני הווידאו שלך יהיה קול."; + + + + + + NSCameraUsageDescription + לצלם תמונות או סרטוני וידאו לשימוש בפוסטים שלך. + NSLocationUsageDescription + WordPress רוצה להוסיף את המיקום שלך לפוסטים באתרים שבהם אפשרת תיוג מיקום. + NSLocationWhenInUseUsageDescription + WordPress רוצה להוסיף את המיקום שלך לפוסטים באתרים שבהם אפשרת תיוג מיקום. + NSMicrophoneUsageDescription + עליך לאפשר גישה למיקרופון כדי להקליט סאונד בקובצי ווידאו. + NSPhotoLibraryUsageDescription + להוסיף תמונות או סרטוני וידאו לפוסטים. + + diff --git a/WordPress/Resources/he.lproj/Localizable.strings b/WordPress/Resources/he.lproj/Localizable.strings index 5e1a223fd654..56d54184e60b 100644 Binary files a/WordPress/Resources/he.lproj/Localizable.strings and b/WordPress/Resources/he.lproj/Localizable.strings differ diff --git a/WordPress/Resources/hr.lproj/InfoPlist.strings b/WordPress/Resources/hr.lproj/InfoPlist.strings index fce347e649b9..6b6139952ec6 100644 --- a/WordPress/Resources/hr.lproj/InfoPlist.strings +++ b/WordPress/Resources/hr.lproj/InfoPlist.strings @@ -1,5 +1,6 @@ -NSLocationUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSLocationWhenInUseUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSCameraUsageDescription = "To take photos or videos to use in your posts."; -NSPhotoLibraryUsageDescription = "To add photos or videos to your posts."; -NSMicrophoneUsageDescription = "Enable microphone access to record sound in your videos."; + + + + + + diff --git a/WordPress/Resources/hr.lproj/Localizable.strings b/WordPress/Resources/hr.lproj/Localizable.strings index 56d3cdeabb95..6e260beb8d34 100644 Binary files a/WordPress/Resources/hr.lproj/Localizable.strings and b/WordPress/Resources/hr.lproj/Localizable.strings differ diff --git a/WordPress/Resources/hu.lproj/InfoPlist.strings b/WordPress/Resources/hu.lproj/InfoPlist.strings index fce347e649b9..6b6139952ec6 100644 --- a/WordPress/Resources/hu.lproj/InfoPlist.strings +++ b/WordPress/Resources/hu.lproj/InfoPlist.strings @@ -1,5 +1,6 @@ -NSLocationUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSLocationWhenInUseUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSCameraUsageDescription = "To take photos or videos to use in your posts."; -NSPhotoLibraryUsageDescription = "To add photos or videos to your posts."; -NSMicrophoneUsageDescription = "Enable microphone access to record sound in your videos."; + + + + + + diff --git a/WordPress/Resources/hu.lproj/Localizable.strings b/WordPress/Resources/hu.lproj/Localizable.strings index 487923ab068f..39247b7936df 100644 Binary files a/WordPress/Resources/hu.lproj/Localizable.strings and b/WordPress/Resources/hu.lproj/Localizable.strings differ diff --git a/WordPress/Resources/id.lproj/InfoPlist.strings b/WordPress/Resources/id.lproj/InfoPlist.strings index b4f9dd25b15e..16adea377cbe 100644 --- a/WordPress/Resources/id.lproj/InfoPlist.strings +++ b/WordPress/Resources/id.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress akan menambahkan lokasi Anda pada setiap tulisan di dalam situs dengan fitur geotagging yang Anda aktifkan."; -NSLocationWhenInUseUsageDescription = "WordPress akan menambahkan lokasi Anda pada setiap tulisan di dalam situs dengan fitur geotagging yang Anda aktifkan."; -NSCameraUsageDescription = "Untuk mengambil foto atau video agar dapat digunakan di pos Anda."; -NSPhotoLibraryUsageDescription = "Untuk menambahkan foto atau video ke pos Anda."; -NSMicrophoneUsageDescription = "Agar video Anda dapat bersuara."; + + + + + + NSCameraUsageDescription + Untuk mengambil foto atau video agar dapat digunakan di artikel Anda. + NSLocationUsageDescription + WordPress ingin menambahkan lokasi Anda di artikel pada situs dengan geotagging yang telah Anda aktifkan. + NSLocationWhenInUseUsageDescription + WordPress ingin menambahkan lokasi Anda di artikel pada situs dengan geotagging yang telah Anda aktifkan. + NSMicrophoneUsageDescription + Izinkan akses mikrofon untuk merekam suara di video Anda. + NSPhotoLibraryUsageDescription + Untuk menambahkan foto atau video ke artikel Anda. + + diff --git a/WordPress/Resources/id.lproj/Localizable.strings b/WordPress/Resources/id.lproj/Localizable.strings index a485d837c4f8..a1d79e713051 100644 Binary files a/WordPress/Resources/id.lproj/Localizable.strings and b/WordPress/Resources/id.lproj/Localizable.strings differ diff --git a/WordPress/Resources/is.lproj/InfoPlist.strings b/WordPress/Resources/is.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/Resources/is.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/Resources/is.lproj/Localizable.strings b/WordPress/Resources/is.lproj/Localizable.strings index 77f5512d7bbd..50fbbc2f9a38 100644 Binary files a/WordPress/Resources/is.lproj/Localizable.strings and b/WordPress/Resources/is.lproj/Localizable.strings differ diff --git a/WordPress/Resources/it.lproj/InfoPlist.strings b/WordPress/Resources/it.lproj/InfoPlist.strings index 62233ddd4867..14e0053d173c 100644 --- a/WordPress/Resources/it.lproj/InfoPlist.strings +++ b/WordPress/Resources/it.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress vorrebbe aggiungere la tua posizione agli articoli che pubblichi sui siti in cui hai attivato il geotagging."; -NSLocationWhenInUseUsageDescription = "WordPress vorrebbe aggiungere la tua posizione agli articoli che pubblichi sui siti in cui hai attivato il geotagging."; -NSCameraUsageDescription = "Per acquisire foto o video da usare nei tuoi articoli."; -NSPhotoLibraryUsageDescription = "Per aggiungere foto o video ai tuoi articoli."; -NSMicrophoneUsageDescription = "Per far sì che i video dispongano di audio."; + + + + + + NSCameraUsageDescription + Per acquisire foto o video da usare nei tuoi articoli. + NSLocationUsageDescription + WordPress desidera aggiungere la tua posizione agli articoli in cui hai attivato il geotagging. + NSLocationWhenInUseUsageDescription + WordPress desidera aggiungere la tua posizione agli articoli in cui hai attivato il geotagging. + NSMicrophoneUsageDescription + Abilita l'accesso al microfono per registrare l'audio nei tuoi video. + NSPhotoLibraryUsageDescription + Per aggiungere foto o video ai tuoi articoli. + + diff --git a/WordPress/Resources/it.lproj/Localizable.strings b/WordPress/Resources/it.lproj/Localizable.strings index 4478ccdc9a10..8dda48087621 100644 Binary files a/WordPress/Resources/it.lproj/Localizable.strings and b/WordPress/Resources/it.lproj/Localizable.strings differ diff --git a/WordPress/Resources/ja.lproj/InfoPlist.strings b/WordPress/Resources/ja.lproj/InfoPlist.strings index 7c690723bd5f..1b43b9ba51ff 100644 --- a/WordPress/Resources/ja.lproj/InfoPlist.strings +++ b/WordPress/Resources/ja.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress は、ユーザーが有効にしたジオタギングの場所をサイトの投稿に追加したいと考えています。"; -NSLocationWhenInUseUsageDescription = "WordPress は、ユーザーが有効にしたジオタギングの場所をサイトの投稿に追加したいと考えています。"; -NSCameraUsageDescription = "投稿に使用する写真または動画を撮る方法。"; -NSPhotoLibraryUsageDescription = "投稿に写真または動画を追加する方法。"; -NSMicrophoneUsageDescription = "動画に音声を含める方法。"; + + + + + + NSCameraUsageDescription + 投稿に使用する写真または動画を撮る。 + NSLocationUsageDescription + WordPress が、ジオタグを有効にしたサイトの投稿に位置情報を追加しようとしています。 + NSLocationWhenInUseUsageDescription + WordPress が、ジオタグを有効にしたサイトの投稿に位置情報を追加しようとしています。 + NSMicrophoneUsageDescription + 動画の音を録音するためにマイクを有効にしてください。 + NSPhotoLibraryUsageDescription + 投稿に写真または動画を追加する。 + + diff --git a/WordPress/Resources/ja.lproj/Localizable.strings b/WordPress/Resources/ja.lproj/Localizable.strings index 0c5e8f057c16..b1896c3fb30e 100644 Binary files a/WordPress/Resources/ja.lproj/Localizable.strings and b/WordPress/Resources/ja.lproj/Localizable.strings differ diff --git a/WordPress/Resources/ko.lproj/InfoPlist.strings b/WordPress/Resources/ko.lproj/InfoPlist.strings index 99c68c913e17..09e851f3727e 100644 --- a/WordPress/Resources/ko.lproj/InfoPlist.strings +++ b/WordPress/Resources/ko.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "위치 태그를 활성화한 사이트의 글에 위치를 추가하려고 합니다."; -NSLocationWhenInUseUsageDescription = "위치 태그를 활성화한 사이트의 글에 위치를 추가하려고 합니다."; -NSCameraUsageDescription = "글에 사용할 사진이나 비디오를 촬영"; -NSPhotoLibraryUsageDescription = "글에 사진이나 비디오를 추가"; -NSMicrophoneUsageDescription = "비디오에서 사운드 재생"; + + + + + + NSCameraUsageDescription + 글에 사용할 사진이나 비디오를 촬영 + NSLocationUsageDescription + 워드프레스에서 위치 태그를 활성화한 사이트의 글에 위치를 추가하려고 합니다. + NSLocationWhenInUseUsageDescription + 워드프레스에서 위치 태그를 활성화한 사이트의 글에 위치를 추가하려고 합니다. + NSMicrophoneUsageDescription + 비디오에 소리를 녹음하려면 마이크 접근을 활성화하세요. + NSPhotoLibraryUsageDescription + 글에 사진이나 비디오를 추가 + + diff --git a/WordPress/Resources/ko.lproj/Localizable.strings b/WordPress/Resources/ko.lproj/Localizable.strings index 9175ed0fd489..353206395edd 100644 Binary files a/WordPress/Resources/ko.lproj/Localizable.strings and b/WordPress/Resources/ko.lproj/Localizable.strings differ diff --git a/WordPress/Resources/nb.lproj/InfoPlist.strings b/WordPress/Resources/nb.lproj/InfoPlist.strings index fce347e649b9..6b6139952ec6 100644 --- a/WordPress/Resources/nb.lproj/InfoPlist.strings +++ b/WordPress/Resources/nb.lproj/InfoPlist.strings @@ -1,5 +1,6 @@ -NSLocationUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSLocationWhenInUseUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSCameraUsageDescription = "To take photos or videos to use in your posts."; -NSPhotoLibraryUsageDescription = "To add photos or videos to your posts."; -NSMicrophoneUsageDescription = "Enable microphone access to record sound in your videos."; + + + + + + diff --git a/WordPress/Resources/nb.lproj/Localizable.strings b/WordPress/Resources/nb.lproj/Localizable.strings index 62832bcb6007..e58e608ee696 100644 Binary files a/WordPress/Resources/nb.lproj/Localizable.strings and b/WordPress/Resources/nb.lproj/Localizable.strings differ diff --git a/WordPress/Resources/nl.lproj/InfoPlist.strings b/WordPress/Resources/nl.lproj/InfoPlist.strings index 5d8b402467d6..645a74ed4074 100644 --- a/WordPress/Resources/nl.lproj/InfoPlist.strings +++ b/WordPress/Resources/nl.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress wil je huidige locatie toevoegen aan berichten op sites waar je geotagging geactiveerd hebt."; -NSLocationWhenInUseUsageDescription = "WordPress wil je huidige locatie toevoegen aan berichten op sites waar je geotagging geactiveerd hebt."; -NSCameraUsageDescription = "Voor het maken van foto's of video's die gebruikt kunnen worden in je berichten."; -NSPhotoLibraryUsageDescription = "Voor het toevoegen van foto's of video's aan je berichten."; -NSMicrophoneUsageDescription = "Om je video's te voorzien van geluid."; + + + + + + NSCameraUsageDescription + Voor het maken van foto's of video's die gebruikt kunnen worden in je berichten. + NSLocationUsageDescription + WordPress wil je locatie toevoegen aan berichten op sites waarvoor je geotagging hebt ingeschakeld. + NSLocationWhenInUseUsageDescription + WordPress wil je locatie toevoegen aan berichten op sites waarvoor je geotagging hebt ingeschakeld. + NSMicrophoneUsageDescription + Toegang tot microfoon inschakelen om geluid op te nemen in je video's. + NSPhotoLibraryUsageDescription + Voor het toevoegen van foto's of video's aan je berichten. + + diff --git a/WordPress/Resources/nl.lproj/Localizable.strings b/WordPress/Resources/nl.lproj/Localizable.strings index d1046eb995d6..ff57660a955e 100644 Binary files a/WordPress/Resources/nl.lproj/Localizable.strings and b/WordPress/Resources/nl.lproj/Localizable.strings differ diff --git a/WordPress/Resources/pl.lproj/InfoPlist.strings b/WordPress/Resources/pl.lproj/InfoPlist.strings index fce347e649b9..6b6139952ec6 100644 --- a/WordPress/Resources/pl.lproj/InfoPlist.strings +++ b/WordPress/Resources/pl.lproj/InfoPlist.strings @@ -1,5 +1,6 @@ -NSLocationUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSLocationWhenInUseUsageDescription = "WordPress would like to add your location to posts on sites where you have enabled geotagging."; -NSCameraUsageDescription = "To take photos or videos to use in your posts."; -NSPhotoLibraryUsageDescription = "To add photos or videos to your posts."; -NSMicrophoneUsageDescription = "Enable microphone access to record sound in your videos."; + + + + + + diff --git a/WordPress/Resources/pl.lproj/Localizable.strings b/WordPress/Resources/pl.lproj/Localizable.strings index a797798a0a4d..c06d0dd23f9e 100644 Binary files a/WordPress/Resources/pl.lproj/Localizable.strings and b/WordPress/Resources/pl.lproj/Localizable.strings differ diff --git a/WordPress/Resources/pt-BR.lproj/InfoPlist.strings b/WordPress/Resources/pt-BR.lproj/InfoPlist.strings index 1b73da0e7529..df762ce078c4 100644 --- a/WordPress/Resources/pt-BR.lproj/InfoPlist.strings +++ b/WordPress/Resources/pt-BR.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "O WordPress gostaria de adicionar sua localização aos posts em sites aonde você ativou o geotagging."; -NSLocationWhenInUseUsageDescription = "O WordPress gostaria de adicionar sua localização aos posts em sites aonde você ativou o geotagging."; -NSCameraUsageDescription = "Tirar fotos ou vídeos para usar nos seus posts."; -NSPhotoLibraryUsageDescription = "Adicionar fotos ou vídeos aos seus posts."; -NSMicrophoneUsageDescription = "Para que seus vídeos possuam som."; + + + + + + NSCameraUsageDescription + Para tirar fotos ou gravar vídeos para usar nos seus posts. + NSLocationUsageDescription + O WordPress gostaria de adicionar sua localização em posts de sites nos quais você ativou o geotagging. + NSLocationWhenInUseUsageDescription + O WordPress gostaria de adicionar sua localização em posts de sites nos quais você ativou o geotagging. + NSMicrophoneUsageDescription + Ativar acesso ao microfone para gravar áudio em seus vídeos. + NSPhotoLibraryUsageDescription + Para adicionar fotos ou vídeos aos seus posts. + + diff --git a/WordPress/Resources/pt-BR.lproj/Localizable.strings b/WordPress/Resources/pt-BR.lproj/Localizable.strings index f45a3d86335a..ed0945849b4b 100644 Binary files a/WordPress/Resources/pt-BR.lproj/Localizable.strings and b/WordPress/Resources/pt-BR.lproj/Localizable.strings differ diff --git a/WordPress/Resources/pt.lproj/InfoPlist.strings b/WordPress/Resources/pt.lproj/InfoPlist.strings index 363e0265a64e..6b6139952ec6 100644 --- a/WordPress/Resources/pt.lproj/InfoPlist.strings +++ b/WordPress/Resources/pt.lproj/InfoPlist.strings @@ -1,5 +1,6 @@ -NSLocationUsageDescription = "O WordPress gostaria de adicionar a sua localização a artigos dos seus sites que tenham a geo-localização activada."; -NSLocationWhenInUseUsageDescription = "O WordPress gostaria de adicionar a sua localização a artigos dos seus sites que tenham a geo-localização activada."; -NSCameraUsageDescription = "To take photos or videos to use in your posts."; -NSPhotoLibraryUsageDescription = "To add photos or videos to your posts."; -NSMicrophoneUsageDescription = "Enable microphone access to record sound in your videos."; + + + + + + diff --git a/WordPress/Resources/pt.lproj/Localizable.strings b/WordPress/Resources/pt.lproj/Localizable.strings index 1b8eabeeb8cb..26863bbe7307 100644 Binary files a/WordPress/Resources/pt.lproj/Localizable.strings and b/WordPress/Resources/pt.lproj/Localizable.strings differ diff --git a/WordPress/Resources/release_notes.txt b/WordPress/Resources/release_notes.txt index 33d1cd88a8d0..5a57a8c457e8 100644 --- a/WordPress/Resources/release_notes.txt +++ b/WordPress/Resources/release_notes.txt @@ -1,5 +1 @@ -- There are two new blocks available! Welcome, Button and Group blocks. -- A few other blocks have improvements: You can change image sizes in image blocks and use upload options in Gallery blocks. We also took care of a text-wrapping issue in Shortcode blocks that would cut off part of your content. -- We also made some enhancements to the general block experience. You’ll see a new toolbar floating above the block you’re editing to make navigating blocks, especially complex ones, easier. There’s also scroll support in the block picker and in block settings. -- Finally, we fixed a few bugs. Adding emoji to a post title no longer adds strong HTML elements to the post title, and the alignment of paragraph blocks is now respected when you split the paragraph or read the post’s HTML content. -- We also adjusted the weekday symbols in the calendar depending on your regional settings, and added Quick Action buttons to the Site Details to take you straight to the most frequently used parts of a site. \ No newline at end of file +Breaking news—VideoPress blocks are now enabled for Simple WordPress.com websites, so you can host and embed high-quality videos to your heart’s content. We hope those videos include cats. diff --git a/WordPress/Resources/ro.lproj/InfoPlist.strings b/WordPress/Resources/ro.lproj/InfoPlist.strings new file mode 100644 index 000000000000..e4e58b79a18b --- /dev/null +++ b/WordPress/Resources/ro.lproj/InfoPlist.strings @@ -0,0 +1,17 @@ + + + + + + NSCameraUsageDescription + Să faci poze sau videouri pe care să le folosești în articolele tale. + NSLocationUsageDescription + WordPress preferă să-ți adaugi locația în articolele din site-urile în care ai activat etichetarea geografică. + NSLocationWhenInUseUsageDescription + WordPress preferă să-ți adaugi locația în articolele din site-urile în care ai activat etichetarea geografică. + NSMicrophoneUsageDescription + Activează accesul la microfon pentru a înregistra sunetul în videourile tale. + NSPhotoLibraryUsageDescription + Să adaugi poze sau videouri în articolele tale. + + diff --git a/WordPress/Resources/ro.lproj/Localizable.strings b/WordPress/Resources/ro.lproj/Localizable.strings index a412c7c66970..c6fbb7e76bc3 100644 Binary files a/WordPress/Resources/ro.lproj/Localizable.strings and b/WordPress/Resources/ro.lproj/Localizable.strings differ diff --git a/WordPress/Resources/ru.lproj/InfoPlist.strings b/WordPress/Resources/ru.lproj/InfoPlist.strings index 35b171ab109c..0778bd031fce 100644 --- a/WordPress/Resources/ru.lproj/InfoPlist.strings +++ b/WordPress/Resources/ru.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "Платформе WordPress требуется разрешение для добавления сведений о местоположении к записям на ваших сайтах, для которых включена функция привязки к местоположению."; -NSLocationWhenInUseUsageDescription = "Платформе WordPress требуется разрешение для добавления сведений о местоположении к записям на ваших сайтах, для которых включена функция привязки к местоположению."; -NSCameraUsageDescription = "Для создания фотографий и видео, которые будут размещаться в ваших записях."; -NSPhotoLibraryUsageDescription = "Для размещения фотографий и видео в ваших записях."; -NSMicrophoneUsageDescription = "Включение звука при воспроизведении видео."; + + + + + + NSCameraUsageDescription + Для создания фотографий и видео, которые будут размещаться в ваших записях. + NSLocationUsageDescription + Платформе WordPress требуется разрешение для добавления сведений о местоположении к записям на ваших сайтах, для которых включена функция привязки к местоположению. + NSLocationWhenInUseUsageDescription + Платформе WordPress требуется разрешение для добавления сведений о местоположении к записям на ваших сайтах, для которых включена функция привязки к местоположению. + NSMicrophoneUsageDescription + Включите доступ к микрофону для записи звука к вашим видео. + NSPhotoLibraryUsageDescription + Для размещения фотографий и видео в ваших записях. + + diff --git a/WordPress/Resources/ru.lproj/Localizable.strings b/WordPress/Resources/ru.lproj/Localizable.strings index 5ea6847aa950..989992cba595 100644 Binary files a/WordPress/Resources/ru.lproj/Localizable.strings and b/WordPress/Resources/ru.lproj/Localizable.strings differ diff --git a/WordPress/Resources/sk.lproj/InfoPlist.strings b/WordPress/Resources/sk.lproj/InfoPlist.strings new file mode 100644 index 000000000000..6b6139952ec6 --- /dev/null +++ b/WordPress/Resources/sk.lproj/InfoPlist.strings @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/Resources/sk.lproj/Localizable.strings b/WordPress/Resources/sk.lproj/Localizable.strings index 2e1b88199a3b..51ec84947c88 100644 Binary files a/WordPress/Resources/sk.lproj/Localizable.strings and b/WordPress/Resources/sk.lproj/Localizable.strings differ diff --git a/WordPress/Resources/sq.lproj/InfoPlist.strings b/WordPress/Resources/sq.lproj/InfoPlist.strings new file mode 100644 index 000000000000..37bea204a037 --- /dev/null +++ b/WordPress/Resources/sq.lproj/InfoPlist.strings @@ -0,0 +1,17 @@ + + + + + + NSCameraUsageDescription + Që të bëjë foto ose video për t’u përdorur te postimet tuaja. + NSLocationUsageDescription + WordPress-i do të donte të shtonte vendndodhjen tuaj në postime te sajte ku keni aktivizuar gjeoetiketimet. + NSLocationWhenInUseUsageDescription + WordPress-i do të donte të shtonte vendndodhjen tuaj në postime te sajte ku keni aktivizuar gjeoetiketimet. + NSMicrophoneUsageDescription + Aktivizoni përdorim mikrofoni për të incizuar zë në videot tuaja. + NSPhotoLibraryUsageDescription + Që të shtohen foto ose video te postimet tuaja. + + diff --git a/WordPress/Resources/sq.lproj/Localizable.strings b/WordPress/Resources/sq.lproj/Localizable.strings index 40a40490316d..36345f7079b4 100644 Binary files a/WordPress/Resources/sq.lproj/Localizable.strings and b/WordPress/Resources/sq.lproj/Localizable.strings differ diff --git a/WordPress/Resources/sv.lproj/InfoPlist.strings b/WordPress/Resources/sv.lproj/InfoPlist.strings index a4ff534fd857..304c9b133e82 100644 --- a/WordPress/Resources/sv.lproj/InfoPlist.strings +++ b/WordPress/Resources/sv.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress skulle vilja lägga till din plats i inlägg på webbplatser där du har aktiverat geografisk plats."; -NSLocationWhenInUseUsageDescription = "WordPress skulle vilja lägga till din plats i inlägg på webbplatser där du har aktiverat geografisk plats."; -NSCameraUsageDescription = "För att ta bilder eller videor som kan användas i dina inlägg."; -NSPhotoLibraryUsageDescription = "För att lägga till bilder eller videor i dina inlägg."; -NSMicrophoneUsageDescription = "För att dina videor ska ha ljud."; + + + + + + NSCameraUsageDescription + För att ta bilder eller spela in videoklipp som kan användas i dina inlägg. + NSLocationUsageDescription + WordPress skulle vilja lägga till din plats i inlägg på webbplatser där du har aktiverat geografisk plats. + NSLocationWhenInUseUsageDescription + WordPress skulle vilja lägga till din plats i inlägg på webbplatser där du har aktiverat geografisk plats. + NSMicrophoneUsageDescription + Aktivera mikrofonåtkomst för att spela in ljud i dina videoklipp. + NSPhotoLibraryUsageDescription + För att lägga till bilder eller videoklipp i dina inlägg. + + diff --git a/WordPress/Resources/sv.lproj/Localizable.strings b/WordPress/Resources/sv.lproj/Localizable.strings index b193b67a712a..7b22c5dbbfa1 100644 Binary files a/WordPress/Resources/sv.lproj/Localizable.strings and b/WordPress/Resources/sv.lproj/Localizable.strings differ diff --git a/WordPress/Resources/th.lproj/InfoPlist.strings b/WordPress/Resources/th.lproj/InfoPlist.strings index 83a780b267b0..6b6139952ec6 100644 --- a/WordPress/Resources/th.lproj/InfoPlist.strings +++ b/WordPress/Resources/th.lproj/InfoPlist.strings @@ -1,5 +1,6 @@ -NSLocationUsageDescription = "เวิร์ดเพรสต้องการเพิ่มที่อยู่ของคุณเพื่อเขียนบนเว็บไซต์ที่คุณเปิดใช้งาน geotagging"; -NSLocationWhenInUseUsageDescription = "เวิร์ดเพรสต้องการเพิ่มที่อยู่ของคุณเพื่อเขียนบนเว็บไซต์ที่คุณเปิดใช้งาน geotagging"; -NSCameraUsageDescription = "To take photos or videos to use in your posts."; -NSPhotoLibraryUsageDescription = "To add photos or videos to your posts."; -NSMicrophoneUsageDescription = "Enable microphone access to record sound in your videos."; + + + + + + diff --git a/WordPress/Resources/th.lproj/Localizable.strings b/WordPress/Resources/th.lproj/Localizable.strings index 4c8c27b719ee..e6b7ee0b64b3 100644 Binary files a/WordPress/Resources/th.lproj/Localizable.strings and b/WordPress/Resources/th.lproj/Localizable.strings differ diff --git a/WordPress/Resources/tr.lproj/InfoPlist.strings b/WordPress/Resources/tr.lproj/InfoPlist.strings index 8d85e1df3421..f3bf9df79a98 100644 --- a/WordPress/Resources/tr.lproj/InfoPlist.strings +++ b/WordPress/Resources/tr.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress coğrafi etiketlemeyi etkinleştirdiğiniz sitelerdeki gönderilere konumunuzu eklemek istiyor."; -NSLocationWhenInUseUsageDescription = "WordPress coğrafi etiketlemeyi etkinleştirdiğiniz sitelerdeki gönderilere konumunuzu eklemek istiyor."; -NSCameraUsageDescription = "Gönderilerinizde kullanacağınız fotoğraf veya videoları çekmek için."; -NSPhotoLibraryUsageDescription = "Gönderilerinize fotoğraf veya video eklemek için."; -NSMicrophoneUsageDescription = "Videolarınıza ses eklemek için."; + + + + + + NSCameraUsageDescription + Yazılarınızda kullanacağınız fotoğraf veya videoları çekmek için. + NSLocationUsageDescription + WordPress coğrafi etiketlemeyi etkinleştirdiğiniz sitelerdeki yazılara konumunuzu eklemek istiyor. + NSLocationWhenInUseUsageDescription + WordPress coğrafi etiketlemeyi etkinleştirdiğiniz sitelerdeki yazılara konumunuzu eklemek istiyor. + NSMicrophoneUsageDescription + Videolarınızdaki sesleri kaydetmek için mikrofon erişimini etkinleştir. + NSPhotoLibraryUsageDescription + Yazılarınızda fotoğraf veya video eklemek için. + + diff --git a/WordPress/Resources/tr.lproj/Localizable.strings b/WordPress/Resources/tr.lproj/Localizable.strings index eb46fa694d7a..6d9caf656d59 100644 Binary files a/WordPress/Resources/tr.lproj/Localizable.strings and b/WordPress/Resources/tr.lproj/Localizable.strings differ diff --git a/WordPress/Resources/zh-Hans.lproj/InfoPlist.strings b/WordPress/Resources/zh-Hans.lproj/InfoPlist.strings index ad16858ee109..e62faf40dbf1 100644 --- a/WordPress/Resources/zh-Hans.lproj/InfoPlist.strings +++ b/WordPress/Resources/zh-Hans.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "对于已启用地理标记的站点,WordPress 会向其中的文章内添加您的位置。"; -NSLocationWhenInUseUsageDescription = "对于已启用地理标记的站点,WordPress 会向其中的文章内添加您的位置。"; -NSCameraUsageDescription = "拍摄要在文章内使用的照片或视频。"; -NSPhotoLibraryUsageDescription = "向您的文章内添加照片或视频。"; -NSMicrophoneUsageDescription = "录制有声视频。"; + + + + + + NSCameraUsageDescription + 拍摄要在您的文章内使用的照片或视频。 + NSLocationUsageDescription + 对于已启用地理标记的站点,WordPress 会在其中的文章内添加您的位置。 + NSLocationWhenInUseUsageDescription + 对于已启用地理标记的站点,WordPress 会在其中的文章内添加您的位置。 + NSMicrophoneUsageDescription + 启用麦克风来录制视频中的声音。 + NSPhotoLibraryUsageDescription + 在您的文章内添加照片或视频。 + + diff --git a/WordPress/Resources/zh-Hans.lproj/Localizable.strings b/WordPress/Resources/zh-Hans.lproj/Localizable.strings index 33a08c0fd2e9..751d31236421 100644 Binary files a/WordPress/Resources/zh-Hans.lproj/Localizable.strings and b/WordPress/Resources/zh-Hans.lproj/Localizable.strings differ diff --git a/WordPress/Resources/zh-Hant.lproj/InfoPlist.strings b/WordPress/Resources/zh-Hant.lproj/InfoPlist.strings index 70928ea3cc7f..0178668f431a 100644 --- a/WordPress/Resources/zh-Hant.lproj/InfoPlist.strings +++ b/WordPress/Resources/zh-Hant.lproj/InfoPlist.strings @@ -1,5 +1,17 @@ -NSLocationUsageDescription = "WordPress 想要在你已啟用地理位置標誌功能的網站上,將你的位置新增網站上的文章。"; -NSLocationWhenInUseUsageDescription = "WordPress 想要在你已啟用地理位置標誌功能的網站上,將你的位置新增網站上的文章。"; -NSCameraUsageDescription = "拍些相片或影片供文章使用。"; -NSPhotoLibraryUsageDescription = "在你的文章中新增相片或影片。"; -NSMicrophoneUsageDescription = "在你的文章中新增相片或影片。"; + + + + + + NSCameraUsageDescription + 拍些相片或影片供文章使用。 + NSLocationUsageDescription + WordPress 想要在你已啟用地理位置標誌功能的網站上,將你的位置新增至網站上的文章。 + NSLocationWhenInUseUsageDescription + WordPress 想要在你已啟用地理位置標誌功能的網站上,將你的位置新增至網站上的文章。 + NSMicrophoneUsageDescription + 請啟用麥克風權限,以錄製影片音效。 + NSPhotoLibraryUsageDescription + 在你的文章中新增相片或影片。 + + diff --git a/WordPress/Resources/zh-Hant.lproj/Localizable.strings b/WordPress/Resources/zh-Hant.lproj/Localizable.strings index 0235eea42d7d..d0b24603983b 100644 Binary files a/WordPress/Resources/zh-Hant.lproj/Localizable.strings and b/WordPress/Resources/zh-Hant.lproj/Localizable.strings differ diff --git a/WordPress/Shared/SharePost.swift b/WordPress/Shared/SharePost.swift index 78b789162c45..89453a6ff554 100644 --- a/WordPress/Shared/SharePost.swift +++ b/WordPress/Shared/SharePost.swift @@ -38,8 +38,12 @@ import MobileCoreServices } @objc convenience init?(data: Data) { - let decoder = NSKeyedUnarchiver(forReadingWith: data) - self.init(coder: decoder) + do { + let decoder = try NSKeyedUnarchiver(forReadingFrom: data) + self.init(coder: decoder) + } catch { + return nil + } } func encode(with aCoder: NSCoder) { @@ -64,11 +68,10 @@ import MobileCoreServices } @objc var data: Data { - let data = NSMutableData() - let encoder = NSKeyedArchiver(forWritingWith: data) + let encoder = NSKeyedArchiver(requiringSecureCoding: false) encode(with: encoder) encoder.finishEncoding() - return data as Data + return encoder.encodedData } } diff --git a/WordPress/Tracks+ShareExtension.swift b/WordPress/Tracks+ShareExtension.swift index 564ec7b33e51..7db9e87b953f 100644 --- a/WordPress/Tracks+ShareExtension.swift +++ b/WordPress/Tracks+ShareExtension.swift @@ -8,17 +8,17 @@ extension Tracks { public func trackExtensionLaunched(_ wpcomAvailable: Bool) { let properties = ["is_configured_dotcom": wpcomAvailable] - trackExtensionEvent(.launched, properties: properties as [String: AnyObject]?) + trackExtensionEvent(.launched, properties: properties as [String: AnyObject]) } public func trackExtensionPosted(_ status: String) { let properties = ["post_status": status] - trackExtensionEvent(.posted, properties: properties as [String: AnyObject]?) + trackExtensionEvent(.posted, properties: properties as [String: AnyObject]) } public func trackExtensionError(_ error: NSError) { let properties = ["error_code": String(error.code), "error_domain": error.domain, "error_description": error.description] - trackExtensionEvent(.error, properties: properties as [String: AnyObject]?) + trackExtensionEvent(.error, properties: properties as [String: AnyObject]) } public func trackExtensionCancelled() { @@ -31,7 +31,7 @@ extension Tracks { public func trackExtensionTagsSelected(_ tags: String) { let properties = ["selected_tags": tags] - trackExtensionEvent(.tagsSelected, properties: properties as [String: AnyObject]?) + trackExtensionEvent(.tagsSelected, properties: properties as [String: AnyObject]) } public func trackExtensionCategoriesOpened() { @@ -40,7 +40,16 @@ extension Tracks { public func trackExtensionCategoriesSelected(_ categories: String) { let properties = ["categories_tags": categories] - trackExtensionEvent(.categoriesSelected, properties: properties as [String: AnyObject]?) + trackExtensionEvent(.categoriesSelected, properties: properties as [String: AnyObject]) + } + + public func trackExtensionPostTypeOpened() { + trackExtensionEvent(.postTypeOpened) + } + + public func trackExtensionPostTypeSelected(_ postType: String) { + let properties = ["post_type": postType] + trackExtensionEvent(.postTypeSelected, properties: properties as [String: AnyObject]) } // MARK: - Private Helpers @@ -52,13 +61,15 @@ extension Tracks { // MARK: - Private Enums fileprivate enum ExtensionEvents: String { - case launched = "wpios_share_extension_launched" - case posted = "wpios_share_extension_posted" - case tagsOpened = "wpios_share_extension_tags_opened" - case tagsSelected = "wpios_share_extension_tags_selected" - case canceled = "wpios_share_extension_canceled" - case error = "wpios_share_extension_error" - case categoriesOpened = "wpios_share_extension_categories_opened" - case categoriesSelected = "wpios_share_extension_categories_selected" + case launched = "share_extension_launched" + case posted = "share_extension_posted" + case tagsOpened = "share_extension_tags_opened" + case tagsSelected = "share_extension_tags_selected" + case canceled = "share_extension_canceled" + case error = "share_extension_error" + case categoriesOpened = "share_extension_categories_opened" + case categoriesSelected = "share_extension_categories_selected" + case postTypeOpened = "share_extension_post_type_opened" + case postTypeSelected = "share_extension_post_type_selected" } } diff --git a/WordPress/UITests/Flows/EditorFlow.swift b/WordPress/UITests/Flows/EditorFlow.swift new file mode 100644 index 000000000000..0e184259580a --- /dev/null +++ b/WordPress/UITests/Flows/EditorFlow.swift @@ -0,0 +1,23 @@ +import UITestsFoundation +import XCTest + +class EditorFlow { + static func returnToMainEditorScreen() { + while EditorPostSettings.isLoaded() || CategoriesComponent.isLoaded() || TagsComponent.isLoaded() || MediaPickerAlbumListScreen.isLoaded() || MediaPickerAlbumScreen.isLoaded() { + navigateBack() + } + } + + static func goToMySiteScreen() throws -> MySiteScreen { + return try TabNavComponent().goToMySiteScreen() + } + + static func toggleBlockEditor(to state: SiteSettingsScreen.Toggle) throws -> SiteSettingsScreen { + if !SiteSettingsScreen.isLoaded() { + _ = try TabNavComponent() + .goToMySiteScreen() + .goToSettingsScreen() + } + return try SiteSettingsScreen().toggleBlockEditor(to: state) + } +} diff --git a/WordPress/UITests/Flows/LoginFlow.swift b/WordPress/UITests/Flows/LoginFlow.swift new file mode 100644 index 000000000000..ef631504e534 --- /dev/null +++ b/WordPress/UITests/Flows/LoginFlow.swift @@ -0,0 +1,52 @@ +import UITestsFoundation +import XCTest + +class LoginFlow { + + @discardableResult + static func login(email: String, password: String, selectedSiteTitle: String? = nil) throws -> MySiteScreen { + return try PrologueScreen().selectContinue() + .proceedWith(email: email) + .proceedWith(password: password) + .continueWithSelectedSite(title: selectedSiteTitle) + .dismissNotificationAlertIfNeeded() + } + + // Login with self-hosted site via Site Address. + @discardableResult + static func login(siteUrl: String, username: String, password: String) throws -> MySiteScreen { + return try PrologueScreen().selectSiteAddress() + .proceedWith(siteUrl: siteUrl) + .proceedWith(username: username, password: password) + .continueWithSelectedSite() + .dismissNotificationAlertIfNeeded() + + // TODO: remove when unifiedAuth is permanent. + // Leaving here for now in case unifiedAuth is disabled. +// return WelcomeScreen().selectLogin() +// .goToSiteAddressLogin() +// .proceedWith(siteUrl: siteUrl) +// .proceedWith(username: username, password: password) +// .continueWithSelectedSite() +// .dismissNotificationAlertIfNeeded() + } + + // Login with WP site via Site Address. + @discardableResult + static func login(siteUrl: String, email: String, password: String) throws -> MySiteScreen { + return try PrologueScreen().selectSiteAddress() + .proceedWithWP(siteUrl: siteUrl) + .proceedWith(email: email) + .proceedWith(password: password) + .continueWithSelectedSite() + .dismissNotificationAlertIfNeeded() + } + + // Login with self-hosted site via Site Address. + static func loginIfNeeded(siteUrl: String, username: String, password: String) throws -> TabNavComponent { + guard TabNavComponent.isLoaded() else { + return try login(siteUrl: siteUrl, username: username, password: password).tabBar + } + return try TabNavComponent() + } +} diff --git a/WordPress/UITests/JetpackUITests-Info.plist b/WordPress/UITests/JetpackUITests-Info.plist new file mode 100644 index 000000000000..ba72822e8728 --- /dev/null +++ b/WordPress/UITests/JetpackUITests-Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + + diff --git a/WordPress/UITests/JetpackUITests.xctestplan b/WordPress/UITests/JetpackUITests.xctestplan new file mode 100644 index 000000000000..ac25c7e857fe --- /dev/null +++ b/WordPress/UITests/JetpackUITests.xctestplan @@ -0,0 +1,38 @@ +{ + "configurations" : [ + { + "id" : "6250F91E-8E09-40AF-96F5-C7474EE19AB4", + "name" : "Foundation-EN-US", + "options" : { + "language" : "en", + "region" : "US" + } + } + ], + "defaultOptions" : { + "codeCoverage" : false, + "targetForVariableExpansion" : { + "containerPath" : "container:WordPress.xcodeproj", + "identifier" : "FABB1F8F2602FC2C00C8785C", + "name" : "Jetpack" + }, + "testRepetitionMode" : "retryOnFailure" + }, + "testTargets" : [ + { + "skippedTests" : [ + "EditorAztecTests", + "LoginTests\/testEmailMagicLinkLogin()", + "SignupTests", + "SignupTests\/testEmailSignup()", + "SupportScreenTests\/testSupportForumsCanBeLoadedDuringLogin()" + ], + "target" : { + "containerPath" : "container:WordPress.xcodeproj", + "identifier" : "EA14532229AD874C001F3143", + "name" : "JetpackUITests" + } + } + ], + "version" : 1 +} diff --git a/WordPress/UITests/README.md b/WordPress/UITests/README.md new file mode 100644 index 000000000000..e538a2c40d42 --- /dev/null +++ b/WordPress/UITests/README.md @@ -0,0 +1,31 @@ +# UI Tests + +WordPress for iOS has UI acceptance tests for critical user flows through the app, such as login, signup, and publishing a post. The tests use mocked network requests with [WireMock](http://wiremock.org/), defined in the `API-Mocks` folder in the project's root. + +## Running tests + +Note that due to the mock server setup, tests cannot be run on physical devices right now. + +1. Follow the [build instructions](https://github.com/wordpress-mobile/WordPress-iOS#build-instructions) (steps 1-5) to clone the project, install the dependencies, and open the project in Xcode. +2. `rake mocks` to start a local mock server. +3. With the `WordPress` scheme selected in Xcode, open the Test Navigator and select the `WordPressUITests` test plan. +4. Run the tests on a simulator. + +## Adding tests + +When adding a new UI test, consider: + +* Whether you need to test a user flow (to accomplish a task or goal) or a specific feature (e.g. boundary testing). +* What screens are being tested (defined as page objects in `Screens/`). +* Whether there are repeated flows across tests (defined in `Flows/`). +* What network requests are made during the test (defined in `API-Mocks/`). + +It's preferred to focus UI tests on entire user flows, and group tests with related flows or goals in the same test suite. + +When you add a new test, you may need to add new screens, methods, and flows. We use page objects and method chaining for clarity in our tests. Wherever possible, use an existing `accessibilityIdentifier` (or add one to the app) instead of a string to select a UI element on the screen. This ensures tests can be run regardless of the device language. + +## Adding or updating network mocks + +When you add a test (or when the app changes), the request definitions for WireMock need to be updated in `API-Mocks/`. You can read WireMock’s documentation [here](http://wiremock.org/docs/). + +If you are unsure what network requests need to be mocked for a test, an easy way to find out is to run the app through [Charles Proxy](https://www.charlesproxy.com/) and observe the required requests. diff --git a/WordPress/UITests/Screens/BaseScreen.swift b/WordPress/UITests/Screens/BaseScreen.swift new file mode 100644 index 000000000000..4b00ff8b20aa --- /dev/null +++ b/WordPress/UITests/Screens/BaseScreen.swift @@ -0,0 +1,63 @@ +import UITestsFoundation +import XCTest + +private extension XCUIElementAttributes { + var isNetworkLoadingIndicator: Bool { + if hasAllowListedIdentifier { return false } + + let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20) + let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3) + + return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize + } + + var hasAllowListedIdentifier: Bool { + let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"] + + return allowListedIdentifiers.contains(identifier) + } + + func isStatusBar(_ deviceWidth: CGFloat) -> Bool { + if elementType == .statusBar { return true } + guard frame.origin == .zero else { return false } + + let oldStatusBarSize = CGSize(width: deviceWidth, height: 20) + let newStatusBarSize = CGSize(width: deviceWidth, height: 44) + + return [oldStatusBarSize, newStatusBarSize].contains(frame.size) + } +} + +private extension XCUIElementQuery { + var networkLoadingIndicators: XCUIElementQuery { + let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isNetworkLoadingIndicator + } + + return self.containing(isNetworkLoadingIndicator) + } + + var deviceStatusBars: XCUIElementQuery { + let deviceWidth = XCUIApplication().frame.width + + let isStatusBar = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isStatusBar(deviceWidth) + } + + return self.containing(isStatusBar) + } + + func first(where predicate: (XCUIElement) throws -> Bool) rethrows -> XCUIElement? { + return try self.allElementsBoundByIndex.first(where: predicate) + } +} + +private extension CGFloat { + func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool { + return numberA...numberB ~= self + } +} diff --git a/WordPress/UITests/Tests/DashboardTests.swift b/WordPress/UITests/Tests/DashboardTests.swift new file mode 100644 index 000000000000..976997f85459 --- /dev/null +++ b/WordPress/UITests/Tests/DashboardTests.swift @@ -0,0 +1,29 @@ +import UITestsFoundation +import XCTest + +class DashboardTests: XCTestCase { + + override func setUpWithError() throws { + setUpTestSuite() + + try LoginFlow.login( + siteUrl: WPUITestCredentials.testWPcomSiteAddress, + email: WPUITestCredentials.testWPcomUserEmail, + password: WPUITestCredentials.testWPcomPassword + ) + } + + override func tearDownWithError() throws { + takeScreenshotOfFailedTest() + removeApp() + } + + // This test is JP only. + func testDomainsCardNavigation() throws { + try MySiteScreen() + .scrollToDomainsCard() + .verifyDomainsCard() + .tapDomainsCard() + .verifyDomainsScreenLoaded() + } +} diff --git a/WordPress/UITests/Tests/EditorAztecTests.swift b/WordPress/UITests/Tests/EditorAztecTests.swift new file mode 100644 index 000000000000..6592a1f784a8 --- /dev/null +++ b/WordPress/UITests/Tests/EditorAztecTests.swift @@ -0,0 +1,78 @@ +import UITestsFoundation +import XCTest + +class EditorAztecTests: XCTestCase { + private var editorScreen: AztecEditorScreen! + + override func setUpWithError() throws { + setUpTestSuite() + + _ = try LoginFlow.login(siteUrl: WPUITestCredentials.testWPcomSiteAddress, email: WPUITestCredentials.testWPcomUserEmail, password: WPUITestCredentials.testWPcomPassword) + editorScreen = try EditorFlow + .toggleBlockEditor(to: .off) + .goBackToMySite() + .tabBar.goToAztecEditorScreen() + .dismissNotificationAlertIfNeeded(.accept) + } + + override func tearDownWithError() throws { + takeScreenshotOfFailedTest() + removeApp() + } + + // TODO: Re-enable Aztec tests but for editing an existing Aztec post. + // For more information, see Issue: https://github.com/wordpress-mobile/WordPress-iOS/issues/16218 +// func testTextPostPublish() { +// let title = getRandomPhrase() +// let content = getRandomContent() +// editorScreen +// .enterTextInTitle(text: title) +// .enterText(text: content) +// .publish() +// .viewPublishedPost(withTitle: title) +// .verifyEpilogueDisplays(postTitle: title, siteAddress: WPUITestCredentials.testWPcomSitePrimaryAddress) +// .done() +// } +// +// func testBasicPostPublish() { +// let title = getRandomPhrase() +// let content = getRandomContent() +// let category = getCategory() +// let tag = getTag() +// editorScreen +// .enterTextInTitle(text: title) +// .enterText(text: content) +// .addImageByOrder(id: 0) +// .openPostSettings() +// .selectCategory(name: category) +// .addTag(name: tag) +// .setFeaturedImage() +// .verifyPostSettings(withCategory: category, withTag: tag, hasImage: true) +// .removeFeatureImage() +// .verifyPostSettings(withCategory: category, withTag: tag, hasImage: false) +// .setFeaturedImage() +// .verifyPostSettings(withCategory: category, withTag: tag, hasImage: true) +// .closePostSettings() +// AztecEditorScreen(mode: .rich).publish() +// .viewPublishedPost(withTitle: title) +// .verifyEpilogueDisplays(postTitle: title, siteAddress: WPUITestCredentials.testWPcomSitePrimaryAddress) +// .done() +// } +// +// // Github issue https://github.com/wordpress-mobile/AztecEditor-iOS/issues/385 +// func testLongTitle() { +// let longTitle = "long title in a galaxy not so far away" +// // Title heigh contains of actual textfield height + bottom line. +// // 16.5px - is the height of that bottom line. Its not changing with different font sizes +// let titleTextView = editorScreen.titleView +// let titleLineHeight = titleTextView.frame.height - 16.5 +// let oneLineTitleHeight = titleTextView.frame.height +// +// let repeatTimes = isIPhone ? 6 : 20 +// _ = editorScreen.enterTextInTitle(text: String(repeating: "very ", count: repeatTimes) + longTitle) +// +// let twoLineTitleHeight = titleTextView.frame.height +// +// XCTAssert(twoLineTitleHeight - oneLineTitleHeight >= titleLineHeight ) +// } +} diff --git a/WordPress/UITests/Tests/EditorGutenbergTests.swift b/WordPress/UITests/Tests/EditorGutenbergTests.swift new file mode 100644 index 000000000000..5718ce87b30d --- /dev/null +++ b/WordPress/UITests/Tests/EditorGutenbergTests.swift @@ -0,0 +1,79 @@ +import UITestsFoundation +import XCTest + +class EditorGutenbergTests: XCTestCase { + private var editorScreen: BlockEditorScreen! + + override func setUpWithError() throws { + setUpTestSuite() + + _ = try LoginFlow.login(siteUrl: WPUITestCredentials.testWPcomSiteAddress, email: WPUITestCredentials.testWPcomUserEmail, password: WPUITestCredentials.testWPcomPassword) + editorScreen = try EditorFlow + .goToMySiteScreen() + .tabBar.gotoBlockEditorScreen() + .dismissNotificationAlertIfNeeded(.accept) + } + + override func tearDownWithError() throws { + takeScreenshotOfFailedTest() + removeApp() + } + + let title = "Rich post title" + let content = "Some text, and more text" + + func testTextPostPublish() throws { + + try editorScreen + .enterTextInTitle(text: title) + .addParagraphBlock(withText: content) + .verifyContentStructure(blocks: 1, words: content.components(separatedBy: " ").count, characters: content.count) + .publish() + .viewPublishedPost(withTitle: title) + .verifyEpilogueDisplays(postTitle: title, siteAddress: WPUITestCredentials.testWPcomSitePrimaryAddress) + .done() + } + + func testBasicPostPublishWithCategoryAndTag() throws { + + let category = getCategory() + let tag = getTag() + try editorScreen + .enterTextInTitle(text: title) + .addParagraphBlock(withText: content) + .addImage() + .verifyContentStructure(blocks: 2, words: content.components(separatedBy: " ").count, characters: content.count) + .openPostSettings() + .selectCategory(name: category) + .addTag(name: tag) + .closePostSettings() + try BlockEditorScreen().publish() + .viewPublishedPost(withTitle: title) + .verifyEpilogueDisplays(postTitle: title, siteAddress: WPUITestCredentials.testWPcomSitePrimaryAddress) + .done() + } + + func testAddRemoveFeaturedImage() throws { + + try editorScreen + .enterTextInTitle(text: title) + .addParagraphBlock(withText: content) + .verifyContentStructure(blocks: 1, words: content.components(separatedBy: " ").count, characters: content.count) + .openPostSettings() + .setFeaturedImage() + .verifyPostSettings(hasImage: true) + .removeFeatureImage() + .verifyPostSettings(hasImage: false) + .setFeaturedImage() + .verifyPostSettings(hasImage: true) + .closePostSettings() + } + + func testAddGalleryBlock() throws { + try editorScreen + .enterTextInTitle(text: title) + .addParagraphBlock(withText: content) + .addImageGallery() + .verifyContentStructure(blocks: 2, words: content.components(separatedBy: " ").count, characters: content.count) + } +} diff --git a/WordPress/UITests/Tests/LoginTests.swift b/WordPress/UITests/Tests/LoginTests.swift new file mode 100644 index 000000000000..684d20e59e80 --- /dev/null +++ b/WordPress/UITests/Tests/LoginTests.swift @@ -0,0 +1,90 @@ +import UITestsFoundation +import XCTest + +class LoginTests: XCTestCase { + + override func setUpWithError() throws { + try super.setUpWithError() + setUpTestSuite() + } + + override func tearDownWithError() throws { + takeScreenshotOfFailedTest() + removeApp() + } + + // Unified email login/out + func testWPcomLoginLogout() throws { + let prologueScreen = try PrologueScreen().selectContinue() + .proceedWith(email: WPUITestCredentials.testWPcomUserEmail) + .proceedWith(password: WPUITestCredentials.testWPcomPassword) + .verifyEpilogueDisplays(username: WPUITestCredentials.testWPcomUsername, siteUrl: WPUITestCredentials.testWPcomSitePrimaryAddress) + .continueWithSelectedSite() + .dismissNotificationAlertIfNeeded() + .tabBar.goToMeScreen() + .logoutToPrologue() + + XCTAssert(prologueScreen.isLoaded) + } + + /** + This test opens safari to trigger the mocked magic link redirect + */ + func testEmailMagicLinkLogin() throws { + let welcomeScreen = try WelcomeScreen().selectLogin() + .selectEmailLogin() + .proceedWith(email: WPUITestCredentials.testWPcomUserEmail) + .proceedWithLink() + .openMagicLoginLink() + .continueWithSelectedSite() + .dismissNotificationAlertIfNeeded() + .tabBar.goToMeScreen() + .logout() + + XCTAssert(welcomeScreen.isLoaded) + } + + // Unified self hosted login/out + func testSelfHostedLoginLogout() throws { + let prologueScreen = try PrologueScreen() + + try prologueScreen + .selectSiteAddress() + .proceedWith(siteUrl: WPUITestCredentials.selfHostedSiteAddress) + .proceedWithSelfHosted(username: WPUITestCredentials.selfHostedUsername, password: WPUITestCredentials.selfHostedPassword) + .removeSelfHostedSite() + + XCTAssert(prologueScreen.isLoaded) + } + + // Unified WordPress.com email login failure due to incorrect password + func testWPcomInvalidPassword() throws { + _ = try PrologueScreen().selectContinue() + .proceedWith(email: WPUITestCredentials.testWPcomUserEmail) + .tryProceed(password: "invalidPswd") + .verifyLoginError() + } + + // Self-Hosted after WordPress.com login. + // Login to a WordPress.com account, open site switcher, then add a self-hosted site. + func testAddSelfHostedSiteAfterWPcomLogin() throws { + try PrologueScreen().selectContinue() + .proceedWith(email: WPUITestCredentials.testWPcomUserEmail) + .proceedWith(password: WPUITestCredentials.testWPcomPassword) + .verifyEpilogueDisplays(username: WPUITestCredentials.testWPcomUsername, siteUrl: WPUITestCredentials.testWPcomSitePrimaryAddress) + .continueWithSelectedSite() //returns MySite screen + + // From here, bring up the sites list and choose to add a new self-hosted site. + .showSiteSwitcher() + .addSelfHostedSite() + + // Then, go through the self-hosted login flow: + .proceedWith(siteUrl: WPUITestCredentials.selfHostedSiteAddress) + .proceedWithSelfHostedSiteAddedFromSitesList(username: WPUITestCredentials.selfHostedUsername, password: WPUITestCredentials.selfHostedPassword) + + // Login flow returns MySites modal, which needs to be closed. + .closeModal() + + XCTAssert(MySiteScreen.isLoaded()) + } +} diff --git a/WordPress/UITests/Tests/MainNavigationTests.swift b/WordPress/UITests/Tests/MainNavigationTests.swift new file mode 100644 index 000000000000..86267e652973 --- /dev/null +++ b/WordPress/UITests/Tests/MainNavigationTests.swift @@ -0,0 +1,56 @@ +import UITestsFoundation +import XCTest + +class MainNavigationTests: XCTestCase { + private var mySiteScreen: MySiteScreen! + + override func setUpWithError() throws { + setUpTestSuite() + + try LoginFlow.login(siteUrl: WPUITestCredentials.testWPcomSiteAddress, email: WPUITestCredentials.testWPcomUserEmail, password: WPUITestCredentials.testWPcomPassword) + mySiteScreen = try TabNavComponent() + .goToMySiteScreen() + .goToMenu() + } + + override func tearDownWithError() throws { + takeScreenshotOfFailedTest() + removeApp() + } + + // We run into an issue where the People screen would crash short after loading. + // See https://github.com/wordpress-mobile/WordPress-iOS/issues/20112. + // + // It would be wise to add similar tests for each item in the menu (then remove this comment). + func testLoadsPeopleScreen() throws { + XCTAssert(MySiteScreen.isLoaded(), "MySitesScreen screen isn't loaded.") + + try mySiteScreen + .goToPeople() + + XCTAssertTrue(PeopleScreen.isLoaded(), "PeopleScreen screen isn't loaded.") + } + + func testTabBarNavigation() throws { + XCTAssert(MySiteScreen.isLoaded(), "MySitesScreen screen isn't loaded.") + + _ = try mySiteScreen + .tabBar.goToReaderScreen() + + XCTAssert(ReaderScreen.isLoaded(), "Reader screen isn't loaded.") + + // We may get a notifications fancy alert when loading the reader for the first time + if let alert = try? FancyAlertComponent() { + alert.cancelAlert() + } + + _ = try mySiteScreen + .tabBar.goToNotificationsScreen() + .dismissNotificationAlertIfNeeded() + + XCTContext.runActivity(named: "Confirm Notifications screen and main navigation bar are loaded.") { (activity) in + XCTAssert(NotificationsScreen.isLoaded(), "Notifications screen isn't loaded.") + XCTAssert(TabNavComponent.isVisible(), "Main navigation bar isn't visible.") + } + } +} diff --git a/WordPress/UITests/Tests/ReaderTests.swift b/WordPress/UITests/Tests/ReaderTests.swift new file mode 100644 index 000000000000..bf8cfd00de5d --- /dev/null +++ b/WordPress/UITests/Tests/ReaderTests.swift @@ -0,0 +1,42 @@ +import UITestsFoundation +import XCTest + +class ReaderTests: XCTestCase { + private var readerScreen: ReaderScreen! + + override func setUpWithError() throws { + setUpTestSuite() + + _ = try LoginFlow.login(siteUrl: WPUITestCredentials.testWPcomSiteAddress, email: WPUITestCredentials.testWPcomUserEmail, password: WPUITestCredentials.testWPcomPassword) + readerScreen = try EditorFlow + .goToMySiteScreen() + .tabBar.goToReaderScreen() + } + + override func tearDownWithError() throws { + takeScreenshotOfFailedTest() + removeApp() + } + + let expectedPostContent = "Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis. Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis. Proin dictum non ligula aliquam varius. Nam ornare accumsan ante, sollicitudin bibendum erat bibendum nec. Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis." + + let commentContent = "Test comment." + + func testViewPost() { + readerScreen.openLastPost() + XCTAssert(readerScreen.postContentEquals(expectedPostContent)) + } + + func testViewPostInSafari() { + readerScreen.openLastPostInSafari() + XCTAssert(readerScreen.postContentEquals(expectedPostContent)) + } + + func testAddCommentToPost() throws { + try readerScreen + .openLastPostComments() + .verifyCommentsListEmpty() + .replyToPost(commentContent) + .verifyCommentSent(commentContent) + } +} diff --git a/WordPress/UITests/Tests/SignupTests.swift b/WordPress/UITests/Tests/SignupTests.swift new file mode 100644 index 000000000000..c11eeab8c28c --- /dev/null +++ b/WordPress/UITests/Tests/SignupTests.swift @@ -0,0 +1,28 @@ +import UITestsFoundation +import XCTest + +class SignupTests: XCTestCase { + + override func setUpWithError() throws { + try super.setUpWithError() + setUpTestSuite() + } + + override func tearDownWithError() throws { + takeScreenshotOfFailedTest() + removeApp() + } + + func testEmailSignup() throws { + let mySiteScreen = try WelcomeScreen().selectSignup() + .selectEmailSignup() + .proceedWith(email: WPUITestCredentials.signupEmail) + .openMagicSignupLink() + .verifyEpilogueContains(username: WPUITestCredentials.signupUsername, displayName: WPUITestCredentials.signupDisplayName) + .setPassword(WPUITestCredentials.signupPassword) + .continueWithSignup() + .dismissNotificationAlertIfNeeded() + + XCTAssert(mySiteScreen.isLoaded) + } +} diff --git a/WordPress/UITests/Tests/StatsTests.swift b/WordPress/UITests/Tests/StatsTests.swift new file mode 100644 index 000000000000..0be16b5a0d42 --- /dev/null +++ b/WordPress/UITests/Tests/StatsTests.swift @@ -0,0 +1,64 @@ +import UITestsFoundation +import XCTest + +class StatsTests: XCTestCase { + private var statsScreen: StatsScreen! + + override func setUpWithError() throws { + setUpTestSuite() + _ = try LoginFlow.login(siteUrl: WPUITestCredentials.testWPcomSiteAddress, email: WPUITestCredentials.testWPcomUserEmail, password: WPUITestCredentials.testWPcomPassword) + statsScreen = try MySiteScreen() + .goToMenu() + .goToStatsScreen() + .switchTo(mode: .insights) + .refreshStatsIfNeeded() + .dismissCustomizeInsightsNotice() + } + + override func tearDownWithError() throws { + takeScreenshotOfFailedTest() + removeApp() + } + + let insightsStats: [String] = [ + "Your views in the last 7-days are -9 (-82%) lower than the previous 7-days. ", + "Thursday", + "34% of views", + "Best Hour", + "4 AM", + "25% of views" + ] + + let yearsStats: [String] = [ + "9,148", + "+7,933 (653%)", + "United States, 60", + "Canada, 44", + "Germany, 15", + "France, 14", + "United Kingdom, 12", + "India, 121" + ] + + let yearsChartBars: [String] = [ + "Views, 2019: 9148", + "Visitors, 2019: 4216", + "Views, 2018: 1215", + "Visitors, 2018: 632", + "Views, 2017: 788", + "Visitors, 2017: 465" + ] + + func testInsightsStatsLoadProperly() { + statsScreen + .switchTo(mode: .insights) + .assertStatsAreLoaded(insightsStats) + } + + func testYearsStatsLoadProperly() { + statsScreen + .switchTo(mode: .years) + .assertStatsAreLoaded(yearsStats) + .assertChartIsLoaded(yearsChartBars) + } +} diff --git a/WordPress/UITests/Tests/SupportScreenTests.swift b/WordPress/UITests/Tests/SupportScreenTests.swift new file mode 100644 index 000000000000..2285cabae69b --- /dev/null +++ b/WordPress/UITests/Tests/SupportScreenTests.swift @@ -0,0 +1,32 @@ +import UITestsFoundation +import XCTest + +class SupportScreenTests: XCTestCase { + override func setUpWithError() throws { + setUpTestSuite() + } + + override func tearDownWithError() throws { + takeScreenshotOfFailedTest() + removeApp() + } + + func testSupportForumsCanBeLoadedDuringLogin() throws { + try PrologueScreen() + .selectContinue() + .selectHelp() + .assertVisitForumButtonEnabled() + .visitForums() + .assertForumsLoaded() + } + + func testContactUsCanBeLoadedDuringLogin() throws { + try PrologueScreen() + .selectContinue() + .selectHelp() + .contactSupport(userEmail: WPUITestCredentials.contactSupportUserEmail) + .assertCanNotSendEmptyMessage() + .enterText("A") + .assertCanSendMessage() + } +} diff --git a/WordPress/UITests/Utils/XCTest+Extensions.swift b/WordPress/UITests/Utils/XCTest+Extensions.swift new file mode 100644 index 000000000000..394f05d28498 --- /dev/null +++ b/WordPress/UITests/Utils/XCTest+Extensions.swift @@ -0,0 +1,86 @@ +import UITestsFoundation +import XCTest + +extension XCTestCase { + + public func setUpTestSuite( + for app: XCUIApplication = XCUIApplication(), + removeBeforeLaunching: Bool = false, + crashOnCoreDataConcurrencyIssues: Bool = true + ) { + super.setUp() + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + app.launchArguments = ["-wpcom-api-base-url", WireMock.URL().absoluteString, "-no-animations", "-ui-testing"] + + if crashOnCoreDataConcurrencyIssues { + app.launchArguments.append(contentsOf: ["-com.apple.CoreData.ConcurrencyDebug", "1"]) + } + + if removeBeforeLaunching { + removeApp(app) + } + + app.activate() + + // Media permissions alert handler + let alertButtonTitle = "Allow Access to All Photos" + systemAlertHandler(alertTitle: "“WordPress” Would Like to Access Your Photos", alertButton: alertButtonTitle) + } + + public func takeScreenshotOfFailedTest() { + guard let failuresCount = testRun?.failureCount, failuresCount > 0 else { return } + + XCTContext.runActivity(named: "Take a screenshot at the end of a failed test") { activity in + add(XCTAttachment(screenshot: XCUIApplication().windows.firstMatch.screenshot())) + } + } + + public func systemAlertHandler(alertTitle: String, alertButton: String) { + addUIInterruptionMonitor(withDescription: alertTitle) { (alert) -> Bool in + let alertButtonElement = alert.buttons[alertButton] + XCTAssert(alertButtonElement.waitForExistence(timeout: 5)) + alertButtonElement.tap() + return true + } + } + + public func getRandomPhrase() -> String { + var wordArray: [String] = [] + let phraseLength = Int.random(in: 3...6) + for _ in 1...phraseLength { + wordArray.append(DataHelper.words.randomElement()!) + } + let phrase = wordArray.joined(separator: " ") + + return phrase + } + + public func getRandomContent() -> String { + var sentenceArray: [String] = [] + let paraLength = Int.random(in: 1...DataHelper.sentences.count) + for _ in 1...paraLength { + sentenceArray.append(DataHelper.sentences.randomElement()!) + } + let paragraph = sentenceArray.joined(separator: " ") + + return paragraph + } + + public func getCategory() -> String { + return "Wedding" + } + + public func getTag() -> String { + return "tag \(Date().toString())" + } + + public struct DataHelper { + static let words = ["Lorem", "Ipsum", "Dolor", "Sit", "Amet", "Consectetur", "Adipiscing", "Elit"] + static let sentences = ["Lorem ipsum dolor sit amet, consectetur adipiscing elit.", "Nam ornare accumsan ante, sollicitudin bibendum erat bibendum nec.", "Nam congue efficitur leo eget porta.", "Proin dictum non ligula aliquam varius.", "Aenean vehicula nunc in sapien rutrum, nec vehicula enim iaculis."] + static let category = "iOS Test" + static let tag = "tag \(Date().toString())" + } +} diff --git a/WordPress/UITests/WPUITestCredentials.swift b/WordPress/UITests/WPUITestCredentials.swift new file mode 100644 index 000000000000..bb64302b01e7 --- /dev/null +++ b/WordPress/UITests/WPUITestCredentials.swift @@ -0,0 +1,18 @@ +import UITestsFoundation + +// These are fake credentials used for the mocked UI tests +struct WPUITestCredentials { + static let testWPcomUserEmail: String = "t@wp.com" + static let testWPcomUsername: String = "e2eflowtestingmobile" + static let testWPcomPassword: String = "pw" + static let testWPcomSiteAddress: String = "tricountyrealestate.wordpress.com" + static let testWPcomSitePrimaryAddress: String = "tricountyrealestate.wordpress.com" + static let selfHostedUsername: String = "e2eflowtestingmobile" + static let selfHostedPassword: String = "mocked_password" + static let selfHostedSiteAddress: String = "\(WireMock.URL().absoluteString)" + static let signupEmail: String = "e2eflowsignuptestingmobile@example.com" + static let signupUsername: String = "e2eflowsignuptestingmobile" + static let signupDisplayName: String = "Eeflowsignuptestingmobile" + static let signupPassword: String = "mocked_password" + static let contactSupportUserEmail: String = "user@test.zzz" +} diff --git a/WordPress/UITests/WordPressUITests-Info.plist b/WordPress/UITests/WordPressUITests-Info.plist new file mode 100644 index 000000000000..ba72822e8728 --- /dev/null +++ b/WordPress/UITests/WordPressUITests-Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + + diff --git a/WordPress/UITests/WordPressUITests.xctestplan b/WordPress/UITests/WordPressUITests.xctestplan new file mode 100644 index 000000000000..c46d842a07ba --- /dev/null +++ b/WordPress/UITests/WordPressUITests.xctestplan @@ -0,0 +1,46 @@ +{ + "configurations" : [ + { + "id" : "8E888E84-0D6D-4516-A355-99461ADC0F47", + "name" : "Foundation-EN-US", + "options" : { + "language" : "en", + "region" : "US" + } + } + ], + "defaultOptions" : { + "codeCoverage" : false, + "targetForVariableExpansion" : { + "containerPath" : "container:WordPress.xcodeproj", + "identifier" : "1D6058900D05DD3D006BFB54", + "name" : "WordPress" + }, + "testRepetitionMode" : "retryOnFailure" + }, + "testTargets" : [ + { + "skippedTests" : [ + "DashboardTests", + "EditorAztecTests", + "EditorTests\/testPlayground()", + "LoginTests\/testEmailMagicLinkLogin()", + "LoginTests\/testEmailPasswordLoginLogout()", + "LoginTests\/testSelfHostedUsernamePasswordLoginLogout()", + "LoginTests\/testUnsuccessfulLogin()", + "LoginTests\/testWpcomUsernamePasswordLogin()", + "MainNavigationTests", + "SignupTests\/testEmailSignup()", + "StatsTests", + "SupportScreenTests\/testContactUsCanBeLoadedDuringLogin()", + "SupportScreenTests\/testSupportScreenLoads()" + ], + "target" : { + "containerPath" : "container:WordPress.xcodeproj", + "identifier" : "FF27168E1CAAC87A0006E2D4", + "name" : "WordPressUITests" + } + } + ], + "version" : 1 +} diff --git a/WordPress/UITestsFoundation/FancyAlertComponent.swift b/WordPress/UITestsFoundation/FancyAlertComponent.swift new file mode 100644 index 000000000000..288762f41f76 --- /dev/null +++ b/WordPress/UITestsFoundation/FancyAlertComponent.swift @@ -0,0 +1,46 @@ +import ScreenObject +import XCTest +import XCUITestHelpers + +public class FancyAlertComponent: ScreenObject { + + private let defaultAlertButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["fancy-alert-view-default-button"] + } + + private let cancelAlertButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["fancy-alert-view-cancel-button"] + } + + var defaultAlertButton: XCUIElement { defaultAlertButtonGetter(app) } + var cancelAlertButton: XCUIElement { cancelAlertButtonGetter(app) } + + public enum Action { + case accept + case cancel + } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [defaultAlertButtonGetter, cancelAlertButtonGetter], + app: app, + waitTimeout: 3 + ) + } + + public func acceptAlert() { + XCTAssert(defaultAlertButton.waitForExistence(timeout: 3)) + XCTAssert(defaultAlertButton.waitForIsHittable(timeout: 3)) + + XCTAssert(defaultAlertButton.isHittable) + defaultAlertButton.tap() + } + + public func cancelAlert() { + cancelAlertButton.tap() + } + + public static func isLoaded() -> Bool { + (try? FancyAlertComponent().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Globals.swift b/WordPress/UITestsFoundation/Globals.swift new file mode 100644 index 000000000000..a6a1e5de7b9d --- /dev/null +++ b/WordPress/UITestsFoundation/Globals.swift @@ -0,0 +1,110 @@ +import UIKit +import XCTest +import ScreenObject + +// TODO: This should maybe go in an `XCUIApplication` extension? +public var navBackButton: XCUIElement { XCUIApplication().navigationBars.element(boundBy: 0).buttons.element(boundBy: 0) } + +// This list has all the navBarButton labels currently covered by UI tests and must be updated when adding new ones. +public let navBackButtonLabels = ["Post Settings", "Back", "Get Started"] + +// Sometimes the Back Button in Navigation Bar is not recognized by XCUITest as an element. +// This method identifies when it happens and uses a swipe back gesture instead of tapping the button. +public func navigateBack() { + let app = XCUIApplication() + let isBackButonAvailableInNavigationBar = navBackButtonLabels.contains(navBackButton.label) + + if isBackButonAvailableInNavigationBar { + navBackButton.tap() + } else { + let leftEdge = app.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0.5)) + let center = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + + leftEdge.press(forDuration: 0.01, thenDragTo: center) + } +} + +public func pullToRefresh(app: XCUIApplication = XCUIApplication()) { + let top = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)) + let bottom = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + + top.press(forDuration: 0.01, thenDragTo: bottom) +} + +public func waitAndTap( _ element: XCUIElement, maxRetries: Int = 10, timeout: TimeInterval = 10) { + guard element.waitForIsHittable(timeout: timeout) else { + XCTFail("Expected element (\(element)) was not hittable after \(timeout) seconds.") + return + } + + var retries = 0 + while element.isHittable && retries < maxRetries { + element.tap() + retries += 1 + } +} + +extension ScreenObject { + + // TODO: This was implemented on the original `BaseScreen` and is here just as a copy-paste for the transition. + /// Pops the navigation stack, returning to the item above the current one. + public func pop() { + navBackButton.tap() + } + + public func openMagicLink() { + XCTContext.runActivity(named: "Open magic link in Safari") { activity in + let safari = Apps.safari + safari.launch() + + // Select the URL bar when Safari opens + let urlBar = safari.textFields["URL"] + if !urlBar.waitForExistence(timeout: 5) { + safari.buttons["URL"].tap() + } + + // Follow the magic link + var magicLinkComponents = URLComponents(url: WireMock.URL(), resolvingAgainstBaseURL: false)! + magicLinkComponents.path = "/magic-link" + magicLinkComponents.queryItems = [URLQueryItem(name: "scheme", value: "wpdebug")] + + urlBar.typeText("\(magicLinkComponents.url!.absoluteString)\n") + + // Accept the prompt to open the deep link + safari.scrollViews.element(boundBy: 0).buttons.element(boundBy: 1).tap() + } + } + + public func findSafariAddressBar(hasBeenTapped: Bool) -> XCUIElement { + let safari = Apps.safari + + // when the device is iPad and addressBar has not been tapped the element is a button + if UIDevice.current.userInterfaceIdiom == .pad && !hasBeenTapped { + return safari.buttons["Address"] + } + + return safari.textFields["Address"] + } + + @discardableResult + public func dismissNotificationAlertIfNeeded( + _ action: FancyAlertComponent.Action = .cancel + ) throws -> Self { + guard FancyAlertComponent.isLoaded() else { return self } + + switch action { + case .accept: + try FancyAlertComponent().acceptAlert() + case .cancel: + try FancyAlertComponent().cancelAlert() + } + + return self + } +} + +public enum Apps { + + public static let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + public static let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") +} diff --git a/WordPress/UITestsFoundation/Info.plist b/WordPress/UITestsFoundation/Info.plist new file mode 100644 index 000000000000..9bcb244429ec --- /dev/null +++ b/WordPress/UITestsFoundation/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/WordPress/WordPressUITests/Utils/Logger.swift b/WordPress/UITestsFoundation/Logger.swift similarity index 91% rename from WordPress/WordPressUITests/Utils/Logger.swift rename to WordPress/UITestsFoundation/Logger.swift index d583e41caf40..ae1c547e9bb6 100644 --- a/WordPress/WordPressUITests/Utils/Logger.swift +++ b/WordPress/UITestsFoundation/Logger.swift @@ -1,6 +1,4 @@ -import Foundation - -enum LogEvent: String { +public enum LogEvent: String { case e = "[‼️]" // error case i = "[ℹ️]" // info case d = "[💬]" // debug @@ -14,7 +12,7 @@ enum LogEvent: String { // Logger.log(message: "Hey ho, lets go!", event: .v) // Output example: // 2017-11-23 03:16:32025 [ℹ️][BasePage.swift]:18 19 waitForPage() -> Page AztecUITests.BlogsPage is loaded -class Logger { +public class Logger { // 1. The date formatter static var dateFormat = "yyyy-MM-dd hh:mm:ssSSS" // Use your own static var dateFormatter: DateFormatter { @@ -30,7 +28,7 @@ class Logger { return components.isEmpty ? "" : components.last! } - class func log(message: String, event: LogEvent, + public class func log(message: String, event: LogEvent, fileName: String = #file, line: Int = #line, column: Int = #column, funcName: String = #function) { #if DEBUG // 7. @@ -39,9 +37,8 @@ class Logger { } } - // 2. The Date to String extension -extension Date { +public extension Date { func toString() -> String { return Logger.dateFormatter.string(from: self as Date) } diff --git a/WordPress/UITestsFoundation/Screens/ActionSheetComponent.swift b/WordPress/UITestsFoundation/Screens/ActionSheetComponent.swift new file mode 100644 index 000000000000..3f2bb2ce082a --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/ActionSheetComponent.swift @@ -0,0 +1,43 @@ +import ScreenObject +import XCTest +import XCUITestHelpers + +public class ActionSheetComponent: ScreenObject { + + private static let getBlogPostButton: (XCUIApplication) -> XCUIElement = { + $0.buttons["blogPostButton"] + } + + private static let getSitePageButton: (XCUIApplication) -> XCUIElement = { + $0.buttons["sitePageButton"] + } + + var blogPostButton: XCUIElement { Self.getBlogPostButton(app) } + var sitePageButton: XCUIElement { Self.getSitePageButton(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [Self.getBlogPostButton, Self.getSitePageButton], + app: app, + waitTimeout: 7 + ) + } + + public func goToBlogPost() { + XCTAssert(blogPostButton.waitForExistence(timeout: 3)) + XCTAssert(blogPostButton.waitForIsHittable(timeout: 3)) + + XCTAssert(blogPostButton.isHittable) + blogPostButton.tap() + } + + public func goToSitePage() throws -> ChooseLayoutScreen { + XCTAssert(sitePageButton.waitForExistence(timeout: 3)) + XCTAssert(sitePageButton.waitForIsHittable(timeout: 3)) + + XCTAssert(sitePageButton.isHittable) + sitePageButton.tap() + + return try ChooseLayoutScreen() + } +} diff --git a/WordPress/UITestsFoundation/Screens/ActivityLogScreen.swift b/WordPress/UITestsFoundation/Screens/ActivityLogScreen.swift new file mode 100644 index 000000000000..adbe7b397e3c --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/ActivityLogScreen.swift @@ -0,0 +1,13 @@ +import ScreenObject +import XCTest + +public class ActivityLogScreen: ScreenObject { + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ { $0.otherElements.firstMatch } ], + app: app, + waitTimeout: 7 + ) + } +} diff --git a/WordPress/UITestsFoundation/Screens/CommentsScreen.swift b/WordPress/UITestsFoundation/Screens/CommentsScreen.swift new file mode 100644 index 000000000000..a56aa97cda52 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/CommentsScreen.swift @@ -0,0 +1,66 @@ +import ScreenObject +import XCTest + +public class CommentsScreen: ScreenObject { + + private let navigationBarTitleGetter: (XCUIApplication) -> XCUIElement = { + $0.navigationBars["Comments"] + } + + private let replyFieldGetter: (XCUIApplication) -> XCUIElement = { + $0.otherElements["reply-to-post-text-field"] + } + + private let backButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.navigationBars.buttons["Reader"] + } + + private let replyButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Reply"] + } + + var replyField: XCUIElement { replyFieldGetter(app) } + var backButton: XCUIElement { backButtonGetter(app) } + var replyButton: XCUIElement { replyButtonGetter(app) } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ + navigationBarTitleGetter, + replyFieldGetter + ], + app: app, + waitTimeout: 7 + ) + } + + public static func isLoaded() -> Bool { + (try? ReaderScreen().isLoaded) ?? false + } + + public func navigateBack() throws -> ReaderScreen { + backButton.tap() + return try ReaderScreen() + } + + @discardableResult + public func replyToPost(_ comment: String) -> CommentsScreen { + replyField.tap() + replyField.typeText(comment) + replyButton.tap() + return self + } + + public func verifyCommentsListEmpty() -> CommentsScreen { + XCTAssertTrue(app.tables.firstMatch.label == "Empty list") + XCTAssertTrue(app.staticTexts["Be the first to leave a comment."].isHittable) + XCTAssertTrue(app.cells.count == 0) + return self + } + + public func verifyCommentSent(_ content: String) { + let replySentMessage = app.otherElements["notice_title_and_message"] + XCTAssertTrue(replySentMessage.waitForIsHittable(), "'Reply Sent' message was not displayed.") + XCTAssertTrue(app.cells.containing(.textView, identifier: content).count == 1, "Comment was not visible") + } +} diff --git a/WordPress/UITestsFoundation/Screens/DomainsScreen.swift b/WordPress/UITestsFoundation/Screens/DomainsScreen.swift new file mode 100644 index 000000000000..ba5ac1f937c8 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/DomainsScreen.swift @@ -0,0 +1,32 @@ +import ScreenObject +import XCTest + +public class DomainsScreen: ScreenObject { + public let tabBar: TabNavComponent + + let siteDomainsNavbarHeaderGetter: (XCUIApplication) -> XCUIElement = { + $0.staticTexts["Site Domains"] + } + + var siteDomainsNavbarHeader: XCUIElement { siteDomainsNavbarHeaderGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + tabBar = try TabNavComponent() + + try super.init( + expectedElementGetters: [ siteDomainsNavbarHeaderGetter ], + app: app, + waitTimeout: 7 + ) + } + + public static func isLoaded() -> Bool { + (try? DomainsScreen().isLoaded) ?? false + } + + @discardableResult + public func verifyDomainsScreenLoaded() -> Self { + XCTAssertTrue(DomainsScreen.isLoaded(), "\"Domains\" screen isn't loaded.") + return self + } +} diff --git a/WordPress/UITestsFoundation/Screens/Editor/AztecEditorScreen.swift b/WordPress/UITestsFoundation/Screens/Editor/AztecEditorScreen.swift new file mode 100644 index 000000000000..ea9281f7b287 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Editor/AztecEditorScreen.swift @@ -0,0 +1,293 @@ +import ScreenObject +import XCTest + +public class AztecEditorScreen: ScreenObject { + + enum Mode { + case rich + case html + + func toggle() -> Mode { + return self == .rich ? .html : .rich + } + } + + let mode: Mode + private(set) var textView: XCUIElement + + private let richTextField = "aztec-rich-text-view" + private let htmlTextField = "aztec-html-text-view" + + var mediaButton: XCUIElement { app.buttons["format_toolbar_insert_media"] } + var sourcecodeButton: XCUIElement { app.buttons["format_toolbar_toggle_html_view"] } + + private let textViewGetter: (String) -> (XCUIApplication) -> XCUIElement = { identifier in + return { app in + var textView = app.textViews[identifier] + + if textView.exists == false { + if app.otherElements[identifier].exists { + textView = app.otherElements[identifier] + } + } + + return textView + } + } + + init(mode: Mode, app: XCUIApplication = XCUIApplication()) throws { + self.mode = mode + let textField: String + switch mode { + case .rich: + textField = richTextField + case .html: + textField = htmlTextField + } + + textView = app.textViews[textField] + + try super.init( + expectedElementGetters: [ textViewGetter(textField) ], + app: app, + waitTimeout: 7 + ) + + showOptionsStrip() + } + + func showOptionsStrip() { + textView.coordinate(withNormalizedOffset: .zero).tap() + expandOptionsStrip() + } + + func expandOptionsStrip() { + let expandButton = app.children(matching: .window).element(boundBy: 1).children(matching: .other).element.children(matching: .other).element.children(matching: .other).element.children(matching: .button).element + + if expandButton.exists && expandButton.isHittable && !sourcecodeButton.exists { + expandButton.tap() + } + } + + @discardableResult + func addList(type: String) -> AztecEditorScreen { + let listButton = app.buttons["format_toolbar_toggle_list_unordered"] + tapToolbarButton(button: listButton) + if type == "ul" { + app.buttons["Unordered List"].tap() + } else if type == "ol" { + app.buttons["Ordered List"].tap() + } + + return self + } + + func addListWithLines(type: String, lines: Array) -> AztecEditorScreen { + addList(type: type) + + for (index, line) in lines.enumerated() { + enterText(text: line) + if index != (lines.count - 1) { + app.buttons["Return"].tap() + } + } + return self + } + + /** + Tapping on toolbar button. And swipes if needed. + */ + @discardableResult + func tapToolbarButton(button: XCUIElement) -> AztecEditorScreen { + let linkButton = app.buttons["format_toolbar_insert_link"] + let swipeElement = mediaButton.isHittable ? mediaButton : linkButton + + if !button.exists || !button.isHittable { + swipeElement.swipeLeft() + } + Logger.log(message: "Tapping on Toolbar button: \(button)", event: .d) + button.tap() + + return self + } + + /** + Tapping in to textView by specific coordinate. Its always tricky to know what cooridnates to click. + Here is a list of "known" coordinates: + 30:32 - first word in 2d indented line (list) + 30:72 - first word in 3d intended line (blockquote) + */ + func tapByCordinates(x: Int, y: Int) -> AztecEditorScreen { + // textView frames on different devices: + // iPhone X (0.0, 88.0, 375.0, 391.0) + // iPhone SE (0.0, 64.0, 320.0, 504.0) + let frame = textView.frame + var vector = CGVector(dx: frame.minX + CGFloat(x), dy: frame.minY + CGFloat(y)) + if frame.minY == 88 { + let yDiff = frame.minY - 64 // 64 - is minY for "normal" devices + vector = CGVector(dx: frame.minX + CGFloat(x), dy: frame.minY - yDiff + CGFloat(y)) + } + + textView.coordinate(withNormalizedOffset: CGVector.zero).withOffset(vector).tap() + sleep(1) // to make sure that "paste" manu wont show up. + return self + } + + /** + Common method to type in different text fields + */ + @discardableResult + public func enterText(text: String) -> AztecEditorScreen { + app.staticTexts["aztec-content-placeholder"].tap() + textView.typeText(text) + return self + } + + /** + Enters text into title field. + - Parameter text: the test to enter into the title + */ + public func enterTextInTitle(text: String) -> AztecEditorScreen { + let titleView = app.textViews["Title"] + titleView.tap() + titleView.typeText(text) + + return self + } + + @discardableResult + func deleteText(chars: Int) -> AztecEditorScreen { + for _ in 1...chars { + app.keys["delete"].tap() + } + + return self + } + + func getViewContent() -> String { + if mode == .rich { + return getTextContent() + } + + return getHTMLContent() + } + + /** + Selects all entered text in provided textView element + */ + func selectAllText() -> AztecEditorScreen { + textView.coordinate(withNormalizedOffset: CGVector.zero).press(forDuration: 1) + app.menuItems["Select All"].tap() + + return self + } + + /* + Select Image from Camera Roll by its ID. Starts with 0 + Simulator range: 0..4 + */ + func addImageByOrder(id: Int) throws -> AztecEditorScreen { + tapToolbarButton(button: mediaButton) + + // Allow access to device media + app.tap() // trigger the media permissions alert handler + + // Make sure media picker is open + if mediaButton.exists { + tapToolbarButton(button: mediaButton) + } + + // Inject the first picture + try MediaPickerAlbumScreen().selectImage(atIndex: 0) + app.buttons["insert_media_button"].tap() + + // Wait for upload to finish + let uploadProgressBar = app.progressIndicators["Progress"] + XCTAssertEqual( + uploadProgressBar.waitFor(predicateString: "exists == false", timeout: 10), + .completed + ) + + return self + } + + // returns void since return screen depends on from which screen it loaded + public func closeEditor() { + XCTContext.runActivity(named: "Close the Aztec editor") { (activity) in + XCTContext.runActivity(named: "Close the More menu if needed") { (activity) in + let actionSheet = app.sheets.element(boundBy: 0) + if actionSheet.exists { + if XCUIDevice.isPad { + app.otherElements["PopoverDismissRegion"].tap() + } else { + app.sheets.buttons["Keep Editing"].tap() + } + } + } + + let editorCloseButton = app.navigationBars["Azctec Editor Navigation Bar"].buttons["Close"] + + editorCloseButton.tap() + + XCTContext.runActivity(named: "Discard any local changes") { (activity) in + + let postHasChangesSheet = app.sheets["post-has-changes-alert"] + let discardButton = XCUIDevice.isPad ? postHasChangesSheet.buttons.lastMatch : postHasChangesSheet.buttons.element(boundBy: 1) + + if postHasChangesSheet.exists && (discardButton?.exists ?? false) { + Logger.log(message: "Discarding unsaved changes", event: .v) + discardButton?.tap() + } + } + + XCTAssertEqual( + editorCloseButton.waitFor(predicateString: "isEnabled == false"), + .completed, + "Aztec editor should be closed but is still loaded." + ) + } + } + + public func publish() throws -> EditorNoticeComponent { + app.buttons["Publish"].tap() + + try confirmPublish() + + return try EditorNoticeComponent(withNotice: "Post published", andAction: "View") + } + + private func confirmPublish() throws { + if FancyAlertComponent.isLoaded() { + try FancyAlertComponent().acceptAlert() + } else { + app.buttons["Publish Now"].tap() + } + } + + public func openPostSettings() throws -> EditorPostSettings { + app.buttons["more_post_options"].tap() + + app.sheets.buttons["Post Settings"].tap() + + return try EditorPostSettings() + } + + private func getHTMLContent() -> String { + let text = textView.value as! String + + // Remove spaces between HTML tags. + let regex = try! NSRegularExpression(pattern: ">\\s+?<", options: .caseInsensitive) + let range = NSMakeRange(0, text.count) + let strippedText = regex.stringByReplacingMatches(in: text, options: .reportCompletion, range: range, withTemplate: "><") + + return strippedText + } + + private func getTextContent() -> String { + return textView.value as! String + } + + static func isLoaded(mode: Mode = .rich) -> Bool { + (try? AztecEditorScreen(mode: mode).isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Editor/BlockEditorScreen.swift b/WordPress/UITestsFoundation/Screens/Editor/BlockEditorScreen.swift new file mode 100644 index 000000000000..e8b37d253bb5 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Editor/BlockEditorScreen.swift @@ -0,0 +1,270 @@ +import ScreenObject +import XCTest +import XCUITestHelpers + +public class BlockEditorScreen: ScreenObject { + + let editorCloseButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.navigationBars["Gutenberg Editor Navigation Bar"].buttons["Close"] + } + + var editorCloseButton: XCUIElement { editorCloseButtonGetter(app) } + + let addBlockButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["add-block-button"] // Uses a testID + } + + var addBlockButton: XCUIElement { addBlockButtonGetter(app) } + + let moreButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["more_post_options"] + } + + var moreButton: XCUIElement { moreButtonGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + // The block editor has _many_ elements but most are loaded on-demand. To verify the screen + // is loaded, we rely only on the button to add a new block and on the navigation bar we + // expect to encase the screen. + try super.init( + expectedElementGetters: [ editorCloseButtonGetter, addBlockButtonGetter ], + app: app, + waitTimeout: 10 + ) + } + + /** + Enters text into title field. + - Parameter text: the test to enter into the title + */ + public func enterTextInTitle(text: String) -> BlockEditorScreen { + let titleView = app.otherElements["Post title. Empty"].firstMatch // Uses a localized string + XCTAssert(titleView.waitForExistence(timeout: 3), "Title View does not exist!") + + titleView.tap() + titleView.typeText(text) + + return self + } + + /** + Adds a paragraph block with text. + - Parameter withText: the text to enter in the paragraph block + */ + public func addParagraphBlock(withText text: String) -> BlockEditorScreen { + addBlock("Paragraph block") + + let paragraphView = app.otherElements["Paragraph Block. Row 1. Empty"].textViews.element(boundBy: 0) + paragraphView.typeText(text) + + return self + } + + /** + Adds an image block with latest image from device. + */ + public func addImage() throws -> BlockEditorScreen { + addBlock("Image block") + try addImageByOrder(id: 0) + + return self + } + + /** + Adds a gallery block with multiple images from device. + */ + public func addImageGallery() throws -> BlockEditorScreen { + addBlock("Gallery block") + try addMultipleImages(numberOfImages: 3) + + return self + } + + /** + Selects a block based on part of the block label (e.g. partial text in a paragraph block) + */ + @discardableResult + public func selectBlock(containingText text: String) -> BlockEditorScreen { + let predicate = NSPredicate(format: "label CONTAINS[c] '\(text)'") + XCUIApplication().buttons.containing(predicate).firstMatch.tap() + return self + } + + // returns void since return screen depends on from which screen it loaded + public func closeEditor() { + XCTContext.runActivity(named: "Close the block editor") { (activity) in + XCTContext.runActivity(named: "Close the More menu if needed") { (activity) in + let actionSheet = app.sheets.element(boundBy: 0) + if actionSheet.exists { + dismissBlockEditorPopovers() + } + } + + // Wait for close button to be hittable (i.e. React "Loading from pre-bundled file" message is gone) + editorCloseButton.waitForIsHittable(timeout: 3) + editorCloseButton.tap() + + XCTContext.runActivity(named: "Discard any local changes") { (activity) in + guard app.staticTexts["You have unsaved changes."].waitForIsHittable(timeout: 3) else { return } + + Logger.log(message: "Discarding unsaved changes", event: .v) + let discardButton = app.buttons["Discard"] // Uses a localized string + discardButton.tap() + } + + let editorNavBar = app.navigationBars["Gutenberg Editor Navigation Bar"] + let waitForEditorToClose = editorNavBar.waitFor(predicateString: "isEnabled == false") + XCTAssertEqual(waitForEditorToClose, .completed, "Block editor should be closed but is still loaded.") + } + } + + // Sometimes ScreenObject times out due to long loading time making the Editor Screen evaluate to `nil`. + // This function adds the ability to still close the Editor Screen when that happens. + public static func closeEditorDiscardingChanges(app: XCUIApplication = XCUIApplication()) { + let closeButton = app.buttons["Close"] + if closeButton.waitForIsHittable() { closeButton.tap() } + + let discardChangesButton = app.buttons["Discard"] + if discardChangesButton.waitForIsHittable() { discardChangesButton.tap() } + } + + public func publish() throws -> EditorNoticeComponent { + let publishButton = app.buttons["Publish"] + let publishNowButton = app.buttons["Publish Now"] + var tries = 0 + // This loop to check for Publish Now Button is an attempt to confirm that the publishButton.tap() call took effect. + // The tests would fail sometimes in the pipeline with no apparent reason. + repeat { + publishButton.tap() + tries += 1 + } while !publishNowButton.waitForIsHittable(timeout: 3) && tries <= 3 + try confirmPublish() + + return try EditorNoticeComponent(withNotice: "Post published", andAction: "View") + } + + public func openPostSettings() throws -> EditorPostSettings { + moreButton.tap() + let postSettingsButton = app.buttons["Post Settings"] // Uses a localized string + postSettingsButton.tap() + + return try EditorPostSettings() + } + + private func getContentStructure() -> String { + moreButton.tap() + let contentStructure = app.staticTexts.element(matching: NSPredicate(format: "label CONTAINS 'Content Structure'")).label + dismissBlockEditorPopovers() + + return contentStructure + } + + private func dismissBlockEditorPopovers() { + if XCUIDevice.isPad { + app.otherElements["PopoverDismissRegion"].tap() + dismissImageViewIfNeeded() + } else { + app.buttons["Keep Editing"].tap() + } + } + + private func dismissImageViewIfNeeded() { + let fullScreenImage = app.images["Fullscreen view of image. Double tap to dismiss"] + if fullScreenImage.exists { fullScreenImage.tap() } + } + + @discardableResult + public func verifyContentStructure(blocks: Int, words: Int, characters: Int) throws -> BlockEditorScreen { + let expectedStructure = "Content Structure Blocks: \(blocks), Words: \(words), Characters: \(characters)" + let actualStructure = getContentStructure() + + XCTAssertEqual(actualStructure, expectedStructure, "Unexpected post structure.") + + return try BlockEditorScreen() + } + + private func addBlock(_ blockLabel: String) { + addBlockButton.tap() + let blockButton = app.buttons[blockLabel] + XCTAssertTrue(blockButton.waitForIsHittable(timeout: 3)) + blockButton.tap() + } + + /// Some tests might fail during the block picking flow. In such cases, we need to dismiss the + /// block picker itself before being able to interact with the rest of the app again. + public func dismissBlocksPickerIfNeeded() { + // Determine whether the block picker is on screen using the visibility of the add block + // button as a proxy + guard addBlockButton.isFullyVisibleOnScreen() == false else { return } + + // Dismiss the block picker by swiping down + app.swipeDown() + + XCTAssertTrue(addBlockButton.waitForIsHittable(timeout: 3)) + } + + /* + Select Image from Camera Roll by its ID. Starts with 0 + */ + private func addImageByOrder(id: Int) throws { + try chooseFromDevice() + .selectImage(atIndex: 0) + } + + /* + Select Sequencial Images from Camera Roll by its ID. Starts with 0 + */ + private func addMultipleImages(numberOfImages: Int) throws { + try chooseFromDevice() + .selectMultipleImages(numberOfImages) + } + + private func chooseFromDevice() throws -> MediaPickerAlbumScreen { + let imageDeviceButton = app.buttons["Choose from device"] // Uses a localized string + + imageDeviceButton.tap() + + // Allow access to device media + app.tap() // trigger the media permissions alert handler + + return try MediaPickerAlbumListScreen() + .selectAlbum(atIndex: 0) + } + + private func confirmPublish() throws { + if FancyAlertComponent.isLoaded() { + try FancyAlertComponent().acceptAlert() + } else { + let publishNowButton = app.buttons["Publish Now"] + publishNowButton.tap() + dismissBloggingRemindersAlertIfNeeded() + } + } + + public func dismissBloggingRemindersAlertIfNeeded() { + guard app.buttons["Set reminders"].waitForExistence(timeout: 3) else { return } + + if XCUIDevice.isPad { + app.swipeDown(velocity: .fast) + } else { + let dismissBloggingRemindersAlertButton = app.buttons.element(boundBy: 0) + dismissBloggingRemindersAlertButton.tap() + } + } + + static func isLoaded() -> Bool { + (try? BlockEditorScreen().isLoaded) ?? false + } + + @discardableResult + public func openBlockPicker() throws -> BlockEditorScreen { + addBlockButton.tap() + return try BlockEditorScreen() + } + + @discardableResult + public func closeBlockPicker() throws -> BlockEditorScreen { + editorCloseButton.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0)).tap() + return try BlockEditorScreen() + } +} diff --git a/WordPress/UITestsFoundation/Screens/Editor/ChooseLayoutScreen.swift b/WordPress/UITestsFoundation/Screens/Editor/ChooseLayoutScreen.swift new file mode 100644 index 000000000000..82efcabbe5f7 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Editor/ChooseLayoutScreen.swift @@ -0,0 +1,27 @@ +import ScreenObject +import XCTest + +public class ChooseLayoutScreen: ScreenObject { + + let closeButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Close"] + } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [closeButtonGetter], + app: app, + waitTimeout: 7 + ) + } + + @discardableResult + public func closeModal() throws -> MySiteScreen { + closeButtonGetter(app).tap() + return try MySiteScreen() + } + + public static func isLoaded() -> Bool { + (try? ChooseLayoutScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Editor/EditorNoticeComponent.swift b/WordPress/UITestsFoundation/Screens/Editor/EditorNoticeComponent.swift new file mode 100644 index 000000000000..fa697fbc05bb --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Editor/EditorNoticeComponent.swift @@ -0,0 +1,41 @@ +import ScreenObject +import XCTest + +public class EditorNoticeComponent: ScreenObject { + + private let noticeTitleGetter: (XCUIApplication) -> XCUIElement + private let noticeActionGetter: (XCUIApplication) -> XCUIElement + + private let expectedNoticeTitle: String + + init( + withNotice noticeTitle: String, + andAction buttonText: String, + app: XCUIApplication = XCUIApplication() + ) throws { + noticeTitleGetter = { app in app.otherElements["notice_title_and_message"] } + noticeActionGetter = { app in app.buttons[buttonText] } + expectedNoticeTitle = noticeTitle + + try super.init( + expectedElementGetters: [ noticeTitleGetter, noticeActionGetter ], + app: app, + waitTimeout: 7 + ) + } + + public func viewPublishedPost(withTitle postTitle: String) throws -> EditorPublishEpilogueScreen { + // The publish notice has a joined accessibility label equal to: title + message + // (the postTitle). It does not seem possible to target the specific postTitle label + // only because of this. + XCTAssertEqual( + noticeTitleGetter(app).label, + String(format: "%@. %@", expectedNoticeTitle, postTitle), + "Post title not visible on published post notice" + ) + + noticeActionGetter(app).tap() + + return try EditorPublishEpilogueScreen() + } +} diff --git a/WordPress/UITestsFoundation/Screens/Editor/EditorPostSettings.swift b/WordPress/UITestsFoundation/Screens/Editor/EditorPostSettings.swift new file mode 100644 index 000000000000..6e76341fda2a --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Editor/EditorPostSettings.swift @@ -0,0 +1,92 @@ +import Nimble +import ScreenObject +import XCTest + +public class EditorPostSettings: ScreenObject { + + // expectedElement comes from the superclass and gets the first expectedElementGetters result + var settingsTable: XCUIElement { expectedElement } + + var categoriesSection: XCUIElement { settingsTable.cells["Categories"] } + var tagsSection: XCUIElement { settingsTable.cells["Tags"] } + var featuredImageButton: XCUIElement { settingsTable.cells["SetFeaturedImage"] } + var changeFeaturedImageButton: XCUIElement { settingsTable.cells["CurrentFeaturedImage"] } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ { $0.tables["SettingsTable"] } ], + app: app, + waitTimeout: 7 + ) + } + + public func selectCategory(name: String) throws -> EditorPostSettings { + return try openCategories() + .selectCategory(name: name) + .goBackToSettings() + } + + public func addTag(name: String) throws -> EditorPostSettings { + return try openTags() + .addTag(name: name) + .goBackToSettings() + } + + func openCategories() throws -> CategoriesComponent { + categoriesSection.tap() + + return try CategoriesComponent() + } + + func openTags() throws -> TagsComponent { + tagsSection.tap() + + return try TagsComponent() + } + + public func removeFeatureImage() throws -> EditorPostSettings { + changeFeaturedImageButton.tap() + + try FeaturedImageScreen() + .tapRemoveFeaturedImageButton() + + return try EditorPostSettings() + } + + public func setFeaturedImage() throws -> EditorPostSettings { + featuredImageButton.tap() + try MediaPickerAlbumListScreen() + .selectAlbum(atIndex: 0) // Select media library + .selectImage(atIndex: 0) // Select latest uploaded image + + return try EditorPostSettings() + } + + public func verifyPostSettings(withCategory category: String? = nil, withTag tag: String? = nil, hasImage: Bool) throws -> EditorPostSettings { + if let postCategory = category { + XCTAssertTrue(categoriesSection.staticTexts[postCategory].exists, "Category \(postCategory) not set") + } + if let postTag = tag { + XCTAssertTrue(tagsSection.staticTexts[postTag].exists, "Tag \(postTag) not set") + } + let imageCount = settingsTable.images.count + if hasImage { + XCTAssertTrue(imageCount == 1, "Featured image not set") + expect(self.changeFeaturedImageButton.images.descendants(matching: .other).element.exists) + .toEventually(beFalse(), timeout: .seconds(30), description: "Featured image is not displayed") + } else { + XCTAssertTrue(imageCount == 0, "Featured image is set but should not be") + } + + return try EditorPostSettings() + } + + /// - Note: Returns `Void` because the return screen depends on which editor the user is in. + public func closePostSettings() { + navigateBack() + } + + public static func isLoaded() -> Bool { + return (try? EditorPostSettings().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Editor/EditorPublishEpilogueScreen.swift b/WordPress/UITestsFoundation/Screens/Editor/EditorPublishEpilogueScreen.swift new file mode 100644 index 000000000000..498889a66639 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Editor/EditorPublishEpilogueScreen.swift @@ -0,0 +1,44 @@ +import ScreenObject +import XCTest + +public class EditorPublishEpilogueScreen: ScreenObject { + + private let getDoneButton: (XCUIApplication) -> XCUIElement = { + $0.navigationBars.buttons["doneButton"] + } + + private let getViewButton: (XCUIApplication) -> XCUIElement = { + $0.buttons["viewPostButton"] + } + + var doneButton: XCUIElement { getDoneButton(app) } + var viewButton: XCUIElement { getViewButton(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + // Defining this via a `let` rather than inline only to avoid a SwiftLint style violation + let publishedPostStatusGetter: (XCUIApplication) -> XCUIElement = { + $0.staticTexts["publishedPostStatusLabel"] + } + + try super.init( + expectedElementGetters: [ getDoneButton, getViewButton, publishedPostStatusGetter ], + app: app, + waitTimeout: 7 + ) + } + + /// - Note: Returns `Void` since the return screen depends on which screen we started from. + public func done() { + doneButton.tap() + } + + public func verifyEpilogueDisplays(postTitle expectedPostTitle: String, siteAddress expectedSiteAddress: String) -> EditorPublishEpilogueScreen { + let actualPostTitle = app.staticTexts["postTitle"].label + let actualSiteAddress = app.staticTexts["siteUrl"].label + + XCTAssertEqual(expectedPostTitle, actualPostTitle, "Post title doesn't match expected title") + XCTAssertEqual(expectedSiteAddress, actualSiteAddress, "Site address doesn't match expected address") + + return self + } +} diff --git a/WordPress/UITestsFoundation/Screens/Editor/EditorSettingsComponents/CategoriesComponent.swift b/WordPress/UITestsFoundation/Screens/Editor/EditorSettingsComponents/CategoriesComponent.swift new file mode 100644 index 000000000000..e85876fe2a2f --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Editor/EditorSettingsComponents/CategoriesComponent.swift @@ -0,0 +1,30 @@ +import ScreenObject +import XCTest + +public class CategoriesComponent: ScreenObject { + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ { $0.tables["CategoriesList"] } ], + app: app, + waitTimeout: 7 + ) + } + + public func selectCategory(name: String) -> CategoriesComponent { + let category = app.cells.staticTexts[name] + category.tap() + + return self + } + + func goBackToSettings() throws -> EditorPostSettings { + navigateBack() + + return try EditorPostSettings() + } + + public static func isLoaded() -> Bool { + (try? CategoriesComponent().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Editor/EditorSettingsComponents/TagsComponent.swift b/WordPress/UITestsFoundation/Screens/Editor/EditorSettingsComponents/TagsComponent.swift new file mode 100644 index 000000000000..a1971d7c459a --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Editor/EditorSettingsComponents/TagsComponent.swift @@ -0,0 +1,32 @@ +import ScreenObject +import XCTest + +public class TagsComponent: ScreenObject { + + // expectedElement comes from the superclass and gets the first expectedElementGetters result + var tagsField: XCUIElement { expectedElement } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ { $0.textViews["add-tags"] } ], + app: app, + waitTimeout: 7 + ) + } + + func addTag(name: String) -> TagsComponent { + tagsField.typeText(name) + + return self + } + + func goBackToSettings() throws -> EditorPostSettings { + navigateBack() + + return try EditorPostSettings() + } + + public static func isLoaded() -> Bool { + (try? TagsComponent().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Editor/FeaturedImageScreen.swift b/WordPress/UITestsFoundation/Screens/Editor/FeaturedImageScreen.swift new file mode 100644 index 000000000000..85fa28be4af4 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Editor/FeaturedImageScreen.swift @@ -0,0 +1,22 @@ +import ScreenObject +import XCTest + +public class FeaturedImageScreen: ScreenObject { + + // expectedElement comes from the superclass and gets the first expectedElementGetters result + var removeButton: XCUIElement { expectedElement } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ { $0.navigationBars.buttons["Remove Featured Image"] } ], + app: app, + waitTimeout: 7 + ) + } + + public func tapRemoveFeaturedImageButton() { + removeButton.tap() + app.buttons["Remove"].tap() + } + +} diff --git a/WordPress/UITestsFoundation/Screens/Jetpack/JetpackBackupOptionsScreen.swift b/WordPress/UITestsFoundation/Screens/Jetpack/JetpackBackupOptionsScreen.swift new file mode 100644 index 000000000000..8f70944b0ad5 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Jetpack/JetpackBackupOptionsScreen.swift @@ -0,0 +1,13 @@ +import ScreenObject +import XCTest + +public class JetpackBackupOptionsScreen: ScreenObject { + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ { $0.otherElements.firstMatch } ], + app: app, + waitTimeout: 7 + ) + } +} diff --git a/WordPress/UITestsFoundation/Screens/Jetpack/JetpackBackupScreen.swift b/WordPress/UITestsFoundation/Screens/Jetpack/JetpackBackupScreen.swift new file mode 100644 index 000000000000..9d76d50092a4 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Jetpack/JetpackBackupScreen.swift @@ -0,0 +1,33 @@ +import ScreenObject +import XCTest + +public class JetpackBackupScreen: ScreenObject { + + private let ellipsisButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.tables["jetpack-backup-table"].cells.element(boundBy: 0).buttons["activity-cell-action-button"] + } + + var ellipsisButton: XCUIElement { ellipsisButtonGetter(app) } + var downloadBackupButton: XCUIElement { app.sheets.buttons["jetpack-download-backup-button"] } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ellipsisButtonGetter], + app: app, + waitTimeout: 7 + ) + } + + public func goToBackupOptions() throws -> JetpackBackupOptionsScreen { + ellipsisButton.tap() + + XCTAssert(downloadBackupButton.waitForExistence(timeout: 3)) + XCTAssert(downloadBackupButton.waitForIsHittable(timeout: 3)) + + XCTAssert(downloadBackupButton.isHittable) + + downloadBackupButton.tap() + + return try JetpackBackupOptionsScreen() + } +} diff --git a/WordPress/UITestsFoundation/Screens/Jetpack/JetpackScanScreen.swift b/WordPress/UITestsFoundation/Screens/Jetpack/JetpackScanScreen.swift new file mode 100644 index 000000000000..c7f4ffc71168 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Jetpack/JetpackScanScreen.swift @@ -0,0 +1,13 @@ +import ScreenObject +import XCTest + +public class JetpackScanScreen: ScreenObject { + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ { $0.otherElements.firstMatch } ], + app: app, + waitTimeout: 7 + ) + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/FeatureIntroductionScreen.swift b/WordPress/UITestsFoundation/Screens/Login/FeatureIntroductionScreen.swift new file mode 100644 index 000000000000..286aec0b8ac7 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/FeatureIntroductionScreen.swift @@ -0,0 +1,28 @@ +import ScreenObject +import XCTest + +public class FeatureIntroductionScreen: ScreenObject { + private let closeButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["close-button"] + } + + var closeButton: XCUIElement { closeButtonGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [closeButtonGetter], + app: app, + waitTimeout: 7 + ) + } + + public func dismiss() throws -> MySiteScreen { + closeButton.tap() + + return try MySiteScreen() + } + + static func isLoaded() -> Bool { + (try? FeatureIntroductionScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/LinkOrPasswordScreen.swift b/WordPress/UITestsFoundation/Screens/Login/LinkOrPasswordScreen.swift new file mode 100644 index 000000000000..112c4874552b --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/LinkOrPasswordScreen.swift @@ -0,0 +1,38 @@ +import ScreenObject +import XCTest + +// TODO: remove when unifiedAuth is permanent. + +public class LinkOrPasswordScreen: ScreenObject { + + let passwordOptionGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Use Password"] + } + let linkButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Send Link Button"] + } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [passwordOptionGetter, linkButtonGetter], + app: app, + waitTimeout: 7 + ) + } + + func proceedWithPassword() throws -> LoginPasswordScreen { + passwordOptionGetter(app).tap() + + return try LoginPasswordScreen() + } + + public func proceedWithLink() throws -> LoginCheckMagicLinkScreen { + linkButtonGetter(app).tap() + + return try LoginCheckMagicLinkScreen() + } + + public static func isLoaded() -> Bool { + (try? LinkOrPasswordScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/LoginCheckMagicLinkScreen.swift b/WordPress/UITestsFoundation/Screens/Login/LoginCheckMagicLinkScreen.swift new file mode 100644 index 000000000000..bef86bbdd037 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/LoginCheckMagicLinkScreen.swift @@ -0,0 +1,37 @@ +import ScreenObject +import XCTest + +public class LoginCheckMagicLinkScreen: ScreenObject { + + let passwordOptionGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Use Password"] + } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ + passwordOptionGetter, + // swiftlint:disable:next opening_brace + { $0.buttons["Open Mail Button"] } + ], + app: app, + waitTimeout: 7 + ) + } + + func proceedWithPassword() throws -> LoginPasswordScreen { + passwordOptionGetter(app).tap() + + return try LoginPasswordScreen() + } + + public func openMagicLoginLink() throws -> LoginEpilogueScreen { + openMagicLink() + + return try LoginEpilogueScreen() + } + + public static func isLoaded() -> Bool { + (try? LoginCheckMagicLinkScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/LoginEmailScreen.swift b/WordPress/UITestsFoundation/Screens/Login/LoginEmailScreen.swift new file mode 100644 index 000000000000..6cda9eb87a1b --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/LoginEmailScreen.swift @@ -0,0 +1,48 @@ +import ScreenObject +import XCTest + +// TODO: remove when unifiedAuth is permanent. + +public class LoginEmailScreen: ScreenObject { + + let emailTextFieldGetter: (XCUIApplication) -> XCUIElement = { + $0.textFields["Login Email Address"] + } + + let nextButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Login Email Next Button"] + } + + var emailTextField: XCUIElement { emailTextFieldGetter(app) } + var nextButton: XCUIElement { nextButtonGetter(app) } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [emailTextFieldGetter, nextButtonGetter], + app: app, + waitTimeout: 7 + ) + } + + public func proceedWith(email: String) throws -> LinkOrPasswordScreen { + emailTextField.tap() + emailTextField.typeText(email) + nextButton.tap() + + return try LinkOrPasswordScreen() + } + + func goToSiteAddressLogin() throws -> LoginSiteAddressScreen { + app.buttons["Self Hosted Login Button"].tap() + + return try LoginSiteAddressScreen() + } + + static func isLoaded() -> Bool { + (try? LoginEmailScreen().isLoaded) ?? false + } + + static func isEmailEntered() -> Bool { + (try? LoginEmailScreen().emailTextField.value != nil) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/LoginEpilogueScreen.swift b/WordPress/UITestsFoundation/Screens/Login/LoginEpilogueScreen.swift new file mode 100644 index 000000000000..62ed2110ff0d --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/LoginEpilogueScreen.swift @@ -0,0 +1,95 @@ +import ScreenObject +import XCTest + +public class LoginEpilogueScreen: ScreenObject { + + private let loginEpilogueTableGetter: (XCUIApplication) -> XCUIElement = { + $0.tables["login-epilogue-table"] + } + + var loginEpilogueTable: XCUIElement { loginEpilogueTableGetter(app) } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [loginEpilogueTableGetter], + app: app, + waitTimeout: 7 + ) + } + + public func continueWithSelectedSite(title: String? = nil) throws -> MySiteScreen { + if let title = title { + let selectedSite = loginEpilogueTable.cells[title] + selectedSite.tap() + } else { + let firstSite = loginEpilogueTable.cells.element(boundBy: 2) + firstSite.tap() + } + + try dismissQuickStartPromptIfNeeded() + try dismissOnboardingQuestionsPromptIfNeeded() + try dismissFeatureIntroductionIfNeeded() + return try MySiteScreen() + } + + // Used by "Self-Hosted after WordPress.com login" test. When a site is added from the Sites List, the Sites List modal (MySitesScreen) + // remains active after the epilogue "done" button is tapped. + public func continueWithSelfHostedSiteAddedFromSitesList() throws -> MySitesScreen { + let firstSite = loginEpilogueTable.cells.element(boundBy: 2) + firstSite.tap() + + try dismissQuickStartPromptIfNeeded() + try dismissOnboardingQuestionsPromptIfNeeded() + return try MySitesScreen() + } + + public func verifyEpilogueDisplays(username: String? = nil, siteUrl: String) -> LoginEpilogueScreen { + if var expectedUsername = username { + expectedUsername = "@\(expectedUsername)" + let actualUsername = app.staticTexts["login-epilogue-username-label"].label + XCTAssertEqual(expectedUsername, actualUsername, "Username displayed is \(actualUsername) but should be \(expectedUsername)") + } + + let expectedSiteUrl = getDisplayUrl(for: siteUrl) + let actualSiteUrl = app.staticTexts["siteUrl"].firstMatch.label + XCTAssertEqual(expectedSiteUrl, actualSiteUrl, "Site URL displayed is \(actualSiteUrl) but should be \(expectedSiteUrl)") + + return self + } + + private func getDisplayUrl(for siteUrl: String) -> String { + var displayUrl = siteUrl.replacingOccurrences(of: "http(s?)://", with: "", options: .regularExpression) + if displayUrl.hasSuffix("/") { + displayUrl = String(displayUrl.dropLast()) + } + + return displayUrl + } + + private func dismissQuickStartPromptIfNeeded() throws { + try XCTContext.runActivity(named: "Dismiss quick start prompt if needed.") { _ in + guard QuickStartPromptScreen.isLoaded() else { return } + + Logger.log(message: "Dismising quick start prompt...", event: .i) + _ = try QuickStartPromptScreen().selectNoThanks() + } + } + + private func dismissOnboardingQuestionsPromptIfNeeded() throws { + try XCTContext.runActivity(named: "Dismiss onboarding questions prompt if needed.") { _ in + guard OnboardingQuestionsPromptScreen.isLoaded() else { return } + + Logger.log(message: "Dismissing onboarding questions prompt...", event: .i) + _ = try OnboardingQuestionsPromptScreen().selectSkip() + } + } + + private func dismissFeatureIntroductionIfNeeded() throws { + try XCTContext.runActivity(named: "Dismiss feature introduction screen if needed.") { _ in + guard FeatureIntroductionScreen.isLoaded() else { return } + + Logger.log(message: "Dismissing feature introduction screen...", event: .i) + _ = try FeatureIntroductionScreen().dismiss() + } + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/LoginPasswordScreen.swift b/WordPress/UITestsFoundation/Screens/Login/LoginPasswordScreen.swift new file mode 100644 index 000000000000..6efe5deee507 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/LoginPasswordScreen.swift @@ -0,0 +1,49 @@ +import ScreenObject +import XCTest + +// TODO: remove when unifiedAuth is permanent. + +class LoginPasswordScreen: ScreenObject { + + let passwordTextFieldGetter: (XCUIApplication) -> XCUIElement = { + $0.secureTextFields["Password"] + } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [passwordTextFieldGetter], + app: app, + waitTimeout: 7 + ) + } + + func proceedWith(password: String) throws -> LoginEpilogueScreen { + _ = tryProceed(password: password) + + return try LoginEpilogueScreen() + } + + func tryProceed(password: String) -> LoginPasswordScreen { + let passwordTextField = passwordTextFieldGetter(app) + passwordTextField.tap() + passwordTextField.typeText(password) + let loginButton = app.buttons["Password next Button"] + loginButton.tap() + if loginButton.exists && !loginButton.isHittable { + XCTAssertEqual(loginButton.waitFor(predicateString: "isEnabled == true"), .completed) + } + return self + } + + func verifyLoginError() -> LoginPasswordScreen { + let errorLabel = app.staticTexts["pswdErrorLabel"] + _ = errorLabel.waitForExistence(timeout: 2) + + XCTAssertTrue(errorLabel.exists) + return self + } + + static func isLoaded() -> Bool { + (try? LoginPasswordScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/LoginSiteAddressScreen.swift b/WordPress/UITestsFoundation/Screens/Login/LoginSiteAddressScreen.swift new file mode 100644 index 000000000000..badf06ea22bb --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/LoginSiteAddressScreen.swift @@ -0,0 +1,53 @@ +import ScreenObject +import XCTest + +private struct ElementStringIDs { + static let nextButton = "Site Address Next Button" + + // TODO: clean up comments when unifiedSiteAddress is permanently enabled. + + // For original Site Address. This matches accessibilityIdentifier in Login.storyboard. + // Leaving here for now in case unifiedSiteAddress is disabled. + // static let siteAddressTextField = "usernameField" + + // For unified Site Address. This matches TextFieldTableViewCell.accessibilityIdentifier. + static let siteAddressTextField = "Site address" +} + +public class LoginSiteAddressScreen: ScreenObject { + + let siteAddressTextFieldGetter: (XCUIApplication) -> XCUIElement = { + $0.textFields["Site address"] + } + + var siteAddressTextField: XCUIElement { siteAddressTextFieldGetter(app) } + var nextButton: XCUIElement { app.buttons[ElementStringIDs.nextButton] } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [siteAddressTextFieldGetter], + app: app, + waitTimeout: 7 + ) + } + + public func proceedWith(siteUrl: String) throws -> LoginUsernamePasswordScreen { + siteAddressTextField.tap() + siteAddressTextField.typeText(siteUrl) + nextButton.tap() + + return try LoginUsernamePasswordScreen() + } + + public func proceedWithWP(siteUrl: String) throws -> GetStartedScreen { + siteAddressTextField.tap() + siteAddressTextField.typeText(siteUrl) + nextButton.tap() + + return try GetStartedScreen() + } + + public static func isLoaded() -> Bool { + (try? LoginSiteAddressScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/LoginUsernamePasswordScreen.swift b/WordPress/UITestsFoundation/Screens/Login/LoginUsernamePasswordScreen.swift new file mode 100644 index 000000000000..76155c39e87d --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/LoginUsernamePasswordScreen.swift @@ -0,0 +1,111 @@ +import ScreenObject +import XCTest +import XCUITestHelpers + +private struct ElementStringIDs { + // TODO: clean up comments when unifiedSiteAddress is permanently enabled. + + // For original Site Address. These match accessibilityIdentifier in Login.storyboard. + // Leaving here for now in case unifiedSiteAddress is disabled. + // static let usernameTextField = "usernameField" + // static let passwordTextField = "passwordField" + // static let nextButton = "submitButton" + + // For unified Site Address. This matches TextFieldTableViewCell.accessibilityIdentifier. + static let usernameTextField = "Username" + static let passwordTextField = "Password" + static let nextButton = "Continue Button" +} + +public class LoginUsernamePasswordScreen: ScreenObject { + + let usernameTextFieldGetter: (XCUIApplication) -> XCUIElement = { + $0.textFields[ElementStringIDs.usernameTextField] + } + + let passwordTextFieldGetter: (XCUIApplication) -> XCUIElement = { + $0.secureTextFields[ElementStringIDs.passwordTextField] + } + + let nextButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons[ElementStringIDs.nextButton] + } + + var usernameTextField: XCUIElement { usernameTextFieldGetter(app) } + var passwordTextField: XCUIElement { passwordTextFieldGetter(app) } + var nextButton: XCUIElement { nextButtonGetter(app) } + + init(app: XCUIApplication = XCUIApplication()) throws { + // Notice that we don't use the "next button" getter because, at the time the screen loads, + // that element is disabled. `ScreenObject` uses `isEnabled == true` on the elements we + // pass at `init`. + try super.init( + expectedElementGetters: [ + usernameTextFieldGetter, + passwordTextFieldGetter + ], + app: app, + waitTimeout: 7 + ) + } + + public func proceedWith(username: String, password: String) throws -> LoginEpilogueScreen { + fill(username: username, password: password) + + return try LoginEpilogueScreen() + } + + public func proceedWithSelfHostedSiteAddedFromSitesList(username: String, password: String) throws -> MySitesScreen { + fill(username: username, password: password) + try dismissQuickStartPromptIfNeeded() + try dismissOnboardingQuestionsPromptIfNeeded() + + return try MySitesScreen() + } + + public func proceedWithSelfHosted(username: String, password: String) throws -> MySiteScreen { + fill(username: username, password: password) + try dismissQuickStartPromptIfNeeded() + try dismissOnboardingQuestionsPromptIfNeeded() + + return try MySiteScreen() + } + + public static func isLoaded() -> Bool { + (try? LoginUsernamePasswordScreen().isLoaded) ?? false + } + + private func fill(username: String, password: String) { + usernameTextField.tap() + usernameTextField.typeText(username) + passwordTextField.tap() + // Workaround to enter password in languages where typing doesn't work + // Pasting is not reliable enough to use all the time so we only use it where it's necessary + if ["ru", "th"].contains(Locale.current.languageCode) { + passwordTextField.paste(text: password) + } else { + passwordTextField.typeText(password) + } + nextButton.tap() + } + + private func dismissQuickStartPromptIfNeeded() throws { + try XCTContext.runActivity(named: "Dismiss quick start prompt if needed.") { (activity) in + if QuickStartPromptScreen.isLoaded() { + Logger.log(message: "Dismising quick start prompt...", event: .i) + _ = try QuickStartPromptScreen().selectNoThanks() + return + } + } + } + + private func dismissOnboardingQuestionsPromptIfNeeded() throws { + try XCTContext.runActivity(named: "Dismiss onboarding questions prompt if needed.") { (activity) in + if OnboardingQuestionsPromptScreen.isLoaded() { + Logger.log(message: "Dismissing onboarding questions prompt...", event: .i) + _ = try OnboardingQuestionsPromptScreen().selectSkip() + return + } + } + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/OnboardingQuestionsPromptScreen.swift b/WordPress/UITestsFoundation/Screens/Login/OnboardingQuestionsPromptScreen.swift new file mode 100644 index 000000000000..2040762b4965 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/OnboardingQuestionsPromptScreen.swift @@ -0,0 +1,28 @@ +import ScreenObject +import XCTest + +public class OnboardingQuestionsPromptScreen: ScreenObject { + private let skipButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Skip"] + } + + var skipButton: XCUIElement { skipButtonGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [skipButtonGetter], + app: app, + waitTimeout: 7 + ) + } + + public func selectSkip() throws -> MySiteScreen { + skipButton.tap() + + return try MySiteScreen() + } + + static func isLoaded() -> Bool { + (try? OnboardingQuestionsPromptScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/QuickStartPromptScreen.swift b/WordPress/UITestsFoundation/Screens/Login/QuickStartPromptScreen.swift new file mode 100644 index 000000000000..df95937f0914 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/QuickStartPromptScreen.swift @@ -0,0 +1,29 @@ +import ScreenObject +import XCTest + +public class QuickStartPromptScreen: ScreenObject { + + private let noThanksButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["No thanks"] + } + + var noThanksButton: XCUIElement { noThanksButtonGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [noThanksButtonGetter], + app: app, + waitTimeout: 7 + ) + } + + public func selectNoThanks() throws -> MySiteScreen { + noThanksButton.tap() + + return try MySiteScreen() + } + + static func isLoaded() -> Bool { + (try? QuickStartPromptScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/Unified/GetStartedScreen.swift b/WordPress/UITestsFoundation/Screens/Login/Unified/GetStartedScreen.swift new file mode 100644 index 000000000000..871bc6ef286f --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/Unified/GetStartedScreen.swift @@ -0,0 +1,71 @@ +import ScreenObject +import XCTest + +public class GetStartedScreen: ScreenObject { + + private let navBarGetter: (XCUIApplication) -> XCUIElement = { + $0.navigationBars["Get Started"] + } + + private let emailTextFieldGetter: (XCUIApplication) -> XCUIElement = { + $0.textFields["Email address"] + } + + private let continueButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Get Started Email Continue Button"] + } + + private let helpButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["authenticator-help-button"] + } + + private let backButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Back"] + } + + var navBar: XCUIElement { navBarGetter(app) } + public var emailTextField: XCUIElement { emailTextFieldGetter(app) } + var continueButton: XCUIElement { continueButtonGetter(app) } + var helpButton: XCUIElement { helpButtonGetter(app) } + var backButton: XCUIElement { backButtonGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + // Notice we are not checking for the continue button, because that's visible but not + // enabled, and `ScreenObject` checks for enabled elements. + try super.init( + expectedElementGetters: [ + navBarGetter, + emailTextFieldGetter, + helpButtonGetter + ], + app: app, + waitTimeout: 7 + ) + } + + public func proceedWith(email: String) throws -> PasswordScreen { + emailTextField.tap() + emailTextField.typeText(email) + continueButton.tap() + + return try PasswordScreen() + } + + public func goBackToPrologue() { + backButton.tap() + } + + public func selectHelp() throws -> SupportScreen { + helpButton.tap() + + return try SupportScreen() + } + + public static func isLoaded() -> Bool { + (try? GetStartedScreen().isLoaded) ?? false + } + + public static func isEmailEntered() -> Bool { + (try? GetStartedScreen().emailTextField.value != nil) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/Unified/PasswordScreen.swift b/WordPress/UITestsFoundation/Screens/Login/Unified/PasswordScreen.swift new file mode 100644 index 000000000000..8d1814c2a6ff --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/Unified/PasswordScreen.swift @@ -0,0 +1,62 @@ +import ScreenObject +import XCTest + +public class PasswordScreen: ScreenObject { + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + // swiftlint:disable:next opening_brace + expectedElementGetters: [ { $0.secureTextFields["Password"] } ], + app: app, + waitTimeout: 7 + ) + } + + public func proceedWith(password: String) throws -> LoginEpilogueScreen { + _ = tryProceed(password: password) + + return try LoginEpilogueScreen() + } + + public func tryProceed(password: String) -> PasswordScreen { + // A hack to make tests pass for RtL languages. + // + // An unintended side effect of calling passwordTextField.tap() while testing a RtL language is that the + // text field's secureTextEntry property gets set to 'false'. I suspect this happens because for RtL langagues, + // the secure text entry toggle button is displayed in the tap area of the passwordTextField. + // + // As a result, tests fail with the following error: + // + // "No matches found for Descendants matching type SecureTextField from input" + // + // Calling passwordTextField.doubleTap() prevents tests from failing by ensuring that the text field's + // secureTextEntry property remains 'true'. + let passwordTextField = expectedElement + passwordTextField.doubleTap() + + passwordTextField.typeText(password) + let continueButton = app.buttons["Continue Button"] + continueButton.tap() + + // The Simulator might ask to save the password which, of course, we don't want to do + if app.buttons["Save Password"].waitForExistence(timeout: 5) { + // There should be no need to wait for this button to exist since it's part of the same + // alert where "Save Password" is. + app.buttons["Not Now"].tap() + } + + return self + } + + public func verifyLoginError() -> PasswordScreen { + let errorLabel = app.cells["Password Error"] + _ = errorLabel.waitForExistence(timeout: 2) + + XCTAssertTrue(errorLabel.exists) + return self + } + + public static func isLoaded() -> Bool { + (try? PasswordScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/Unified/PrologueScreen.swift b/WordPress/UITestsFoundation/Screens/Login/Unified/PrologueScreen.swift new file mode 100644 index 000000000000..81bfe6f284f4 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/Unified/PrologueScreen.swift @@ -0,0 +1,40 @@ +import ScreenObject +import XCTest + +public class PrologueScreen: ScreenObject { + + private let continueButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Prologue Continue Button"] + } + + private let siteAddressButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Prologue Self Hosted Button"] + } + + var continueButton: XCUIElement { continueButtonGetter(app) } + var siteAddressButton: XCUIElement { siteAddressButtonGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [continueButtonGetter, siteAddressButtonGetter], + app: app, + waitTimeout: 3 + ) + } + + public func selectContinue() throws -> GetStartedScreen { + continueButton.tap() + + return try GetStartedScreen() + } + + public func selectSiteAddress() throws -> LoginSiteAddressScreen { + siteAddressButton.tap() + + return try LoginSiteAddressScreen() + } + + public static func isLoaded(app: XCUIApplication = XCUIApplication()) -> Bool { + (try? PrologueScreen(app: app).isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/WelcomeScreen.swift b/WordPress/UITestsFoundation/Screens/Login/WelcomeScreen.swift new file mode 100644 index 000000000000..ef5359088ae4 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/WelcomeScreen.swift @@ -0,0 +1,42 @@ +import ScreenObject +import XCTest + +// TODO: remove when unifiedAuth is permanent. + +public class WelcomeScreen: ScreenObject { + + private let logInButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Prologue Log In Button"] + } + + private let signupButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Prologue Signup Button"] + } + + var signupButton: XCUIElement { signupButtonGetter(app) } + var logInButton: XCUIElement { logInButtonGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [logInButtonGetter, signupButtonGetter], + app: app, + waitTimeout: 7 + ) + } + + public func selectSignup() throws -> WelcomeScreenSignupComponent { + signupButton.tap() + + return try WelcomeScreenSignupComponent() + } + + public func selectLogin() throws -> WelcomeScreenLoginComponent { + logInButton.tap() + + return try WelcomeScreenLoginComponent() + } + + static func isLoaded() -> Bool { + (try? WelcomeScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Login/WelcomeScreenLoginComponent.swift b/WordPress/UITestsFoundation/Screens/Login/WelcomeScreenLoginComponent.swift new file mode 100644 index 000000000000..90b8adc111e4 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Login/WelcomeScreenLoginComponent.swift @@ -0,0 +1,34 @@ +import ScreenObject +import XCTest + +// TODO: remove when unifiedAuth is permanent. + +public class WelcomeScreenLoginComponent: ScreenObject { + + let emailLoginButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Log in with Email Button"] + } + let siteAddressButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Self Hosted Login Button"] + } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [emailLoginButtonGetter, siteAddressButtonGetter], + app: app, + waitTimeout: 7 + ) + } + + public func selectEmailLogin() throws -> LoginEmailScreen { + emailLoginButtonGetter(app).tap() + + return try LoginEmailScreen() + } + + func goToSiteAddressLogin() throws -> LoginSiteAddressScreen { + siteAddressButtonGetter(app).tap() + + return try LoginSiteAddressScreen() + } +} diff --git a/WordPress/UITestsFoundation/Screens/Me/ContactUsScreen.swift b/WordPress/UITestsFoundation/Screens/Me/ContactUsScreen.swift new file mode 100644 index 000000000000..8c6532cd9789 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Me/ContactUsScreen.swift @@ -0,0 +1,74 @@ +import ScreenObject +import XCTest + +/// This screen object is for the Support section. In the app, it's a modal we can get to from Me +/// > Help & Support > Contact Support, or, when logged out, from Prologue > tap either continue button > Help > Contact Support. +public class ContactUsScreen: ScreenObject { + + private let closeButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["ZDKbackButton"] + } + + private let sendButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["ZDKsendButton"] + } + + private let attachButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["ZDKattachButton"] + } + + private let deleteMessageButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Delete"] + } + + var closeButton: XCUIElement { closeButtonGetter(app) } + var sendButton: XCUIElement { sendButtonGetter(app) } + var attachButton: XCUIElement { attachButtonGetter(app) } + var deleteMessageButton: XCUIElement { deleteMessageButtonGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + // Notice we are not checking for the send button because it's visible but not enabled, + // and `ScreenObject` checks for enabled elements. + try super.init( + expectedElementGetters: [ + closeButtonGetter, + attachButtonGetter, + ], + app: app, + waitTimeout: 7 + ) + } + + @discardableResult + public func assertCanNotSendEmptyMessage() -> ContactUsScreen { + XCTAssert(!sendButton.isEnabled) + return self + } + + @discardableResult + public func assertCanSendMessage() -> ContactUsScreen { + XCTAssert(sendButton.isEnabled) + return self + } + + public func enterText(_ text: String) -> ContactUsScreen { + app.typeText(text) + return self + } + + public func dismiss() throws -> SupportScreen { + closeButton.tap() + discardMessageIfNeeded() + return try SupportScreen() + } + + private func discardMessageIfNeeded() { + if deleteMessageButton.isHittable { + deleteMessageButton.tap() + } + } + + static func isLoaded() -> Bool { + (try? SupportScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Me/SupportScreen.swift b/WordPress/UITestsFoundation/Screens/Me/SupportScreen.swift new file mode 100644 index 000000000000..96c415b73623 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Me/SupportScreen.swift @@ -0,0 +1,101 @@ +import ScreenObject +import XCTest + +/// This screen object is for the Support section. In the app, it's a modal we can get to from Me +/// > Help & Support, or, when logged out, from Prologue > tap either continue button > Help. +public class SupportScreen: ScreenObject { + + private let closeButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["close-button"] + } + + private let contactSupportButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.cells["contact-support-button"] + } + + private let contactEmailFieldGetter: (XCUIApplication) -> XCUIElement = { + $0.textFields["Email"] + } + + private let visitForumsButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.cells["visit-wordpress-forums-button"] + } + + private let okButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["OK"] + } + + var closeButton: XCUIElement { closeButtonGetter(app) } + var visitForumsButton: XCUIElement { visitForumsButtonGetter(app) } + var okButton: XCUIElement { okButtonGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ + closeButtonGetter, + visitForumsButtonGetter, + // swiftlint:disable opening_brace + { $0.cells["visit-wordpress-forums-prompt"] }, + { $0.cells["activity-logs-button"] } + // swiftlint:enable opening_brace + ], + app: app, + waitTimeout: 7 + ) + } + + public func contactSupport(userEmail: String) throws -> ContactUsScreen { + let emailTextField = app.textFields["Email"] + + app.cells["contact-support-button"].tap() + emailTextField.tap() + emailTextField.typeText(userEmail) + app.buttons["OK"].tap() + + return try ContactUsScreen() + } + + public func assertVisitForumButtonEnabled() -> SupportScreen { + XCTAssert(visitForumsButton.isEnabled) + return self + } + + public func visitForums() -> SupportScreen { + visitForumsButton.tap() + + // Select the Address bar when Safari opens + let addressBar = findSafariAddressBar(hasBeenTapped: false) + + guard addressBar.waitForExistence(timeout: 5) else { + XCTFail("Address bar not found") + return self + } + addressBar.tap() + + return self + } + + public func assertForumsLoaded() { + let safari = Apps.safari + guard safari.wait(for: .runningForeground, timeout: 4) else { + XCTFail("Safari wait failed") + return + } + + let addressBar = findSafariAddressBar(hasBeenTapped: true) + let predicate = NSPredicate(format: "value == 'https://wordpress.org/support/forum/mobile/'") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: addressBar) + let result = XCTWaiter.wait(for: [expectation], timeout: 5) + XCTAssertEqual(result, .completed) + + app.activate() //Back to app + } + + public func dismiss() { + closeButton.tap() + } + + static func isLoaded() -> Bool { + (try? SupportScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/MeTabScreen.swift b/WordPress/UITestsFoundation/Screens/MeTabScreen.swift new file mode 100644 index 000000000000..5e1d0a5e6309 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/MeTabScreen.swift @@ -0,0 +1,73 @@ +import ScreenObject +import XCTest + +public class MeTabScreen: ScreenObject { + let logOutButton: XCUIElement + let logOutAlert: XCUIElement + var appSettingsButton: XCUIElement { expectedElement } + let myProfileButton: XCUIElement + let accountSettingsButton: XCUIElement + let notificationSettingsButton: XCUIElement + let doneButton: XCUIElement + + init(app: XCUIApplication = XCUIApplication()) throws { + logOutButton = app.cells["logOutFromWPcomButton"] + logOutAlert = app.alerts.element(boundBy: 0) + myProfileButton = app.cells["myProfile"] + accountSettingsButton = app.cells["accountSettings"] + notificationSettingsButton = app.cells["notificationSettings"] + doneButton = app.navigationBars.buttons["doneBarButton"] + + try super.init( + expectedElementGetter: { $0.cells["appSettings"] }, + app: app, + waitTimeout: 7 + ) + } + + public func isLoggedInToWpcom() -> Bool { + return logOutButton.exists + } + + public func logout() throws -> WelcomeScreen { + app.cells["logOutFromWPcomButton"].tap() + + // Some localizations have very long "log out" text, which causes the UIAlertView + // to stack. We need to detect these cases in order to reliably tap the correct button + if logOutAlert.buttons.allElementsShareCommonAxisX { + logOutAlert.buttons.element(boundBy: 0).tap() + } + else { + logOutAlert.buttons.element(boundBy: 1).tap() + } + + return try WelcomeScreen() + } + + public func logoutToPrologue() throws -> PrologueScreen { + app.cells["logOutFromWPcomButton"].tap() + + // Some localizations have very long "log out" text, which causes the UIAlertView + // to stack. We need to detect these cases in order to reliably tap the correct button + if logOutAlert.buttons.allElementsShareCommonAxisX { + logOutAlert.buttons.element(boundBy: 0).tap() + } + else { + logOutAlert.buttons.element(boundBy: 1).tap() + } + + return try PrologueScreen() + } + + func goToLoginFlow() throws -> PrologueScreen { + app.cells["Log In"].tap() + + return try PrologueScreen() + } + + public func dismiss() throws -> MySiteScreen { + app.buttons["Done"].tap() + + return try MySiteScreen() + } +} diff --git a/WordPress/UITestsFoundation/Screens/Media/MediaPickerAlbumListScreen.swift b/WordPress/UITestsFoundation/Screens/Media/MediaPickerAlbumListScreen.swift new file mode 100644 index 000000000000..4b9ed8d1b3bf --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Media/MediaPickerAlbumListScreen.swift @@ -0,0 +1,29 @@ +import ScreenObject +import XCTest + +public class MediaPickerAlbumListScreen: ScreenObject { + + private let albumListGetter: (XCUIApplication) -> XCUIElement = { + $0.tables["AlbumTable"] + } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetter: albumListGetter, + app: app, + waitTimeout: 7 + ) + } + + public func selectAlbum(atIndex index: Int) throws -> MediaPickerAlbumScreen { + let selectedAlbum = albumListGetter(app).cells.element(boundBy: index) + XCTAssertTrue(selectedAlbum.waitForExistence(timeout: 5), "Selected album did not load") + waitAndTap(selectedAlbum) + + return try MediaPickerAlbumScreen() + } + + public static func isLoaded() -> Bool { + (try? MediaPickerAlbumListScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/Media/MediaPickerAlbumScreen.swift b/WordPress/UITestsFoundation/Screens/Media/MediaPickerAlbumScreen.swift new file mode 100644 index 000000000000..8f4f73d97de6 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Media/MediaPickerAlbumScreen.swift @@ -0,0 +1,46 @@ +import ScreenObject +import XCTest + +public class MediaPickerAlbumScreen: ScreenObject { + let mediaCollectionGetter: (XCUIApplication) -> XCUIElement = { + $0.collectionViews["MediaCollection"] + } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [mediaCollectionGetter], + app: app, + waitTimeout: 7 + ) + } + + public func selectImage(atIndex index: Int) { + let selectedImage = mediaCollectionGetter(app).cells.element(boundBy: index) + XCTAssertTrue(selectedImage.waitForExistence(timeout: 5), "Selected image did not load") + selectedImage.tap() + } + + public func selectMultipleImages(_ numberOfImages: Int) { + var index = 0 + while index < numberOfImages { + selectImage(atIndex: index) + index += 1 + } + + app.buttons["SelectedActionButton"].tap() + } + + func insertSelectedImage() { + app.buttons["SelectedActionButton"].tap() + } + + public static func isLoaded(app: XCUIApplication = XCUIApplication()) -> Bool { + // Check if the media picker is loaded as a component within the editor + // and only return true if the media picker is a full screen + if app.navigationBars["Azctec Editor Navigation Bar"].exists { + return false + } + + return (try? MediaPickerAlbumScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/MediaScreen.swift b/WordPress/UITestsFoundation/Screens/MediaScreen.swift new file mode 100644 index 000000000000..7e6bc5a7e9a6 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/MediaScreen.swift @@ -0,0 +1,17 @@ +import ScreenObject +import XCTest + +public class MediaScreen: ScreenObject { + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ { $0.collectionViews["MediaCollection"] } ], + app: app, + waitTimeout: 7 + ) + } + + static func isLoaded() -> Bool { + (try? MediaScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/MySiteScreen.swift b/WordPress/UITestsFoundation/Screens/MySiteScreen.swift new file mode 100644 index 000000000000..ad318a810b80 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/MySiteScreen.swift @@ -0,0 +1,203 @@ +import ScreenObject +import XCTest + +private struct ElementStringIDs { + static let navBarTitle = "my-site-navigation-bar" + static let blogTable = "Blog Details Table" + static let removeSiteButton = "BlogDetailsRemoveSiteCell" + static let activityLogButton = "Activity Log Row" + static let jetpackScanButton = "Scan Row" + static let jetpackBackupButton = "Backup Row" + static let postsButton = "Blog Post Row" + static let mediaButton = "Media Row" + static let statsButton = "Stats Row" + static let peopleButton = "People Row" + static let settingsButton = "Settings Row" + static let createButton = "floatingCreateButton" + static let ReaderButton = "Reader" + static let switchSiteButton = "SwitchSiteButton" + static let dashboardButton = "Home" + static let segmentedControlMenuButton = "Menu" + static let domainsCardHeaderButton = "Find a custom domain" +} + +/// The home-base screen for an individual site. Used in many of our UI tests. +public class MySiteScreen: ScreenObject { + public let tabBar: TabNavComponent + + let activityLogButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.cells[ElementStringIDs.activityLogButton] + } + + let postsButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.cells[ElementStringIDs.postsButton] + } + + let mediaButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.cells[ElementStringIDs.mediaButton] + } + + var mediaButton: XCUIElement { mediaButtonGetter(app) } + + let statsButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.cells[ElementStringIDs.statsButton] + } + + let createButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons[ElementStringIDs.createButton] + } + let readerButton: XCUIElement + + let switchSiteButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons[ElementStringIDs.switchSiteButton] + } + + let homeButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons[ElementStringIDs.dashboardButton] + } + + let segmentedControlMenuButton: (XCUIApplication) -> XCUIElement = { + $0.buttons[ElementStringIDs.segmentedControlMenuButton] + } + + let domainsCardButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons[ElementStringIDs.domainsCardHeaderButton] + } + + var domainsCardButton: XCUIElement { domainsCardButtonGetter(app) } + + static var isVisible: Bool { + let app = XCUIApplication() + let blogTable = app.tables[ElementStringIDs.blogTable] + return blogTable.exists && blogTable.isHittable + } + + public init(app: XCUIApplication = XCUIApplication()) throws { + tabBar = try TabNavComponent() + readerButton = app.buttons[ElementStringIDs.ReaderButton] + + try super.init( + expectedElementGetters: [ + switchSiteButtonGetter, + statsButtonGetter, + postsButtonGetter, + mediaButtonGetter, + createButtonGetter + ], + app: app, + waitTimeout: 7 + ) + } + + public func showSiteSwitcher() throws -> MySitesScreen { + switchSiteButtonGetter(app).tap() + return try MySitesScreen() + } + + public func removeSelfHostedSite() { + app.tables[ElementStringIDs.blogTable].swipeUp(velocity: .fast) + app.cells[ElementStringIDs.removeSiteButton].doubleTap() + + let removeButton: XCUIElement + if XCUIDevice.isPad { + removeButton = app.alerts.buttons.element(boundBy: 1) + } else { + removeButton = app.buttons["Remove Site"] + } + + removeButton.tap() + } + + public func goToActivityLog() throws -> ActivityLogScreen { + app.cells[ElementStringIDs.activityLogButton].tap() + return try ActivityLogScreen() + } + + public func goToJetpackScan() throws -> JetpackScanScreen { + app.cells[ElementStringIDs.jetpackScanButton].tap() + return try JetpackScanScreen() + } + + public func goToJetpackBackup() throws -> JetpackBackupScreen { + app.cells[ElementStringIDs.jetpackBackupButton].tap() + return try JetpackBackupScreen() + } + + public func gotoPostsScreen() throws -> PostsScreen { + // A hack for iPad, because sometimes tapping "posts" doesn't load it the first time + if XCUIDevice.isPad { + mediaButton.tap() + } + + postsButtonGetter(app).tap() + return try PostsScreen() + } + + public func goToMediaScreen() throws -> MediaScreen { + mediaButton.tap() + return try MediaScreen() + } + + public func goToStatsScreen() throws -> StatsScreen { + statsButtonGetter(app).tap() + return try StatsScreen() + } + + public func goToSettingsScreen() throws -> SiteSettingsScreen { + app.cells[ElementStringIDs.settingsButton].tap() + return try SiteSettingsScreen() + } + + public func goToCreateSheet() throws -> ActionSheetComponent { + createButtonGetter(app).tap() + return try ActionSheetComponent() + } + + public func goToHomeScreen() -> Self { + homeButtonGetter(app).tap() + return self + } + + @discardableResult + public func goToMenu() -> Self { + // On iPad, the menu items are already listed on screen, so we don't need to tap the menu button + guard XCUIDevice.isPhone else { + return self + } + + segmentedControlMenuButton(app).tap() + return self + } + + @discardableResult + public func goToPeople() throws -> PeopleScreen { + app.cells[ElementStringIDs.peopleButton].tap() + return try PeopleScreen() + } + + public static func isLoaded() -> Bool { + (try? MySiteScreen().isLoaded) ?? false + } + + @discardableResult + public func verifyDomainsCard() -> Self { + let cardText = app.staticTexts["Stake your claim on your corner of the web with a site address that’s easy to find, share and follow."] + XCTAssertTrue(domainsCardButton.waitForIsHittable(), "Domains card header was not displayed.") + XCTAssertTrue(cardText.waitForIsHittable(), "Domains card text was not displayed.") + return self + } + + @discardableResult + public func tapDomainsCard() throws -> DomainsScreen { + domainsCardButton.tap() + return try DomainsScreen() + } + + @discardableResult + public func scrollToDomainsCard() throws -> Self { + let collectionView = app.collectionViews.firstMatch + let cardCell = collectionView.cells.containing(.other, identifier: "dashboard-domains-card-contentview").firstMatch + cardCell.scrollIntoView(within: collectionView) + return self + } +} diff --git a/WordPress/UITestsFoundation/Screens/MySitesScreen.swift b/WordPress/UITestsFoundation/Screens/MySitesScreen.swift new file mode 100644 index 000000000000..3400d18d604d --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/MySitesScreen.swift @@ -0,0 +1,62 @@ +import ScreenObject +import XCTest + +/// The site switcher AKA blog list. Currently presented as a modal we can get to from My Site by +/// tapping the down arrow next to the site title. +public class MySitesScreen: ScreenObject { + let cancelButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["my-sites-cancel-button"] + } + + let plusButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["add-site-button"] + } + + let addSelfHostedSiteButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Add self-hosted site"] + } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ + // swiftlint:disable:next opening_brace + { $0.staticTexts["My Sites"] }, + cancelButtonGetter, + plusButtonGetter + ], + app: app, + waitTimeout: 7 + ) + } + + public func addSelfHostedSite() throws -> LoginSiteAddressScreen { + plusButtonGetter(app).tap() + addSelfHostedSiteButtonGetter(app).tap() + return try LoginSiteAddressScreen() + } + + @discardableResult + public func tapPlusButton() throws -> SiteIntentScreen { + plusButtonGetter(app).tap() + return try SiteIntentScreen() + } + + @discardableResult + public func closeModal() throws -> MySiteScreen { + cancelButtonGetter(app).tap() + return try MySiteScreen() + } + + @discardableResult + public func switchToSite(withTitle title: String) throws -> MySiteScreen { + app.cells[title].tap() + return try MySiteScreen() + } + + public func closeModalIfNeeded() { + if addSelfHostedSiteButtonGetter(app).isHittable { + app.children(matching: .window).element(boundBy: 0).tap() + } + if cancelButtonGetter(app).isHittable { cancelButtonGetter(app).tap() } + } +} diff --git a/WordPress/UITestsFoundation/Screens/NotificationsScreen.swift b/WordPress/UITestsFoundation/Screens/NotificationsScreen.swift new file mode 100644 index 000000000000..d32f47c3926c --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/NotificationsScreen.swift @@ -0,0 +1,30 @@ +import ScreenObject +import XCTest + +public class NotificationsScreen: ScreenObject { + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ { $0.tables["Notifications Table"] } ], + app: app, + waitTimeout: 7 + ) + } + + @discardableResult + public func openNotification(withText notificationText: String) -> NotificationsScreen { + app.staticTexts[notificationText].tap() + return self + } + + @discardableResult + public func replyToNotification() -> NotificationsScreen { + let replyButton = app.buttons["reply-button"] + replyButton.tap() + return self + } + + public static func isLoaded() -> Bool { + (try? NotificationsScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/PeopleScreen.swift b/WordPress/UITestsFoundation/Screens/PeopleScreen.swift new file mode 100644 index 000000000000..2c4c3feec618 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/PeopleScreen.swift @@ -0,0 +1,28 @@ +import ScreenObject +import XCTest + +public class PeopleScreen: ScreenObject { + + public init(app: XCUIApplication = XCUIApplication()) throws { + let filterButtonGetter: (String) -> (XCUIApplication) -> XCUIElement = { identifier in + return { app in + app.buttons[identifier] + } + } + + try super.init( + expectedElementGetters: [ + // See the PeopleViewController.Filter rawValues + filterButtonGetter("users"), + filterButtonGetter("followers"), + filterButtonGetter("email") + ], + app: app, + waitTimeout: 7 + ) + } + + public static func isLoaded() -> Bool { + (try? PeopleScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/PostsScreen.swift b/WordPress/UITestsFoundation/Screens/PostsScreen.swift new file mode 100644 index 000000000000..91e4aa7f60ec --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/PostsScreen.swift @@ -0,0 +1,87 @@ +import ScreenObject +import XCTest + +public class PostsScreen: ScreenObject { + + public enum PostStatus { + case published + case drafts + } + + private var currentlyFilteredPostStatus: PostStatus = .published + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ { $0.tables["PostsTable"] } ], + app: app, + waitTimeout: 7 + ) + showOnly(.published) + } + + @discardableResult + public func showOnly(_ status: PostStatus) -> PostsScreen { + switch status { + case .published: + app.buttons["published"].tap() + case .drafts: + app.buttons["drafts"].tap() + } + + currentlyFilteredPostStatus = status + + return self + } + + public func selectPost(withSlug slug: String) throws -> EditorScreen { + + // Tap the current tab item to scroll the table to the top + showOnly(currentlyFilteredPostStatus) + + let cell = expectedElement.cells[slug] + XCTAssert(cell.waitForExistence(timeout: 5)) + + cell.scrollIntoView(within: expectedElement) + cell.tap() + + dismissAutosaveDialogIfNeeded() + + let editorScreen = EditorScreen() + try editorScreen.dismissDialogsIfNeeded() + + return EditorScreen() + } + + /// If there are two versions of a local post, the app will ask which version we want to use when editing. + /// We always want to use the local version (which is currently the first option) + private func dismissAutosaveDialogIfNeeded() { + let autosaveDialog = app.alerts["autosave-options-alert"] + if autosaveDialog.exists { + autosaveDialog.buttons.firstMatch.tap() + } + } +} + +public struct EditorScreen { + + var isAztecEditor: Bool { + let aztecEditorElement = "Azctec Editor Navigation Bar" + return XCUIApplication().navigationBars[aztecEditorElement].exists + } + + func dismissDialogsIfNeeded() throws { + guard let blockEditor = try? BlockEditorScreen() else { return } + + try blockEditor.dismissNotificationAlertIfNeeded(.accept) + } + + public func close() { + if let blockEditor = try? BlockEditorScreen() { + blockEditor.closeEditor() + } + + if let aztecEditor = try? AztecEditorScreen(mode: .rich) { + aztecEditor.closeEditor() + } + } +} diff --git a/WordPress/UITestsFoundation/Screens/ReaderScreen.swift b/WordPress/UITestsFoundation/Screens/ReaderScreen.swift new file mode 100644 index 000000000000..6662c304c4c7 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/ReaderScreen.swift @@ -0,0 +1,80 @@ +import ScreenObject +import XCTest + +public class ReaderScreen: ScreenObject { + + private let discoverButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Discover"] + } + + var discoverButton: XCUIElement { discoverButtonGetter(app) } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ + // swiftlint:skip:next opening_brace + { $0.tables["Reader"] }, + discoverButtonGetter + ], + app: app, + waitTimeout: 7 + ) + } + + public func openLastPost() { + getLastPost().tap() + } + + public func openLastPostInSafari() { + getLastPost().buttons["More"].tap() + app.buttons["Visit"].tap() + } + + public func openLastPostComments() throws -> CommentsScreen { + let commentButton = getLastPost().buttons["0 comment"] + guard commentButton.waitForIsHittable() else { fatalError("ReaderScreen.Post: Comments button not loaded") } + commentButton.tap() + return try CommentsScreen() + } + + public func getLastPost() -> XCUIElement { + guard let post = app.cells.lastMatch else { fatalError("ReaderScreen: No posts loaded") } + scrollDownUntilElementIsFullyVisible(element: post) + return post + } + + private func scrollDownUntilElementIsFullyVisible(element: XCUIElement) { + var loopCount = 0 + // Using isFullyVisibleOnScreen instead of waitForIsHittable to solve a problem on iPad where the desired post + // was already hittable but the comments button was still not visible. + while !element.isFullyVisibleOnScreen() && loopCount < 10 { + loopCount += 1 + app.swipeUp(velocity: .fast) + } + } + + public func postContentEquals(_ expected: String) -> Bool { + let equalsPostContent = NSPredicate(format: "label == %@", expected) + let isPostContentEqual = app.staticTexts.element(matching: equalsPostContent).waitForIsHittable(timeout: 3) + + return isPostContentEqual + } + + public func dismissPost() { + let backButton = app.buttons["Back"] + let dismissButton = app.buttons["Dismiss"] + + if dismissButton.isHittable { dismissButton.tap() } + if backButton.isHittable { backButton.tap() } + } + + public static func isLoaded() -> Bool { + (try? ReaderScreen().isLoaded) ?? false + } + + public func openDiscover() -> ReaderScreen { + discoverButton.tap() + + return self + } +} diff --git a/WordPress/UITestsFoundation/Screens/Signup/SignupCheckMagicLinkScreen.swift b/WordPress/UITestsFoundation/Screens/Signup/SignupCheckMagicLinkScreen.swift new file mode 100644 index 000000000000..5f1c5e354e7b --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Signup/SignupCheckMagicLinkScreen.swift @@ -0,0 +1,20 @@ +import ScreenObject +import XCTest + +public class SignupCheckMagicLinkScreen: ScreenObject { + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + // swiftlint:disable:next opening_brace + expectedElementGetters: [{ $0.buttons["Open Mail Button"] }], + app: app, + waitTimeout: 7 + ) + } + + public func openMagicSignupLink() throws -> SignupEpilogueScreen { + openMagicLink() + + return try SignupEpilogueScreen() + } +} diff --git a/WordPress/UITestsFoundation/Screens/Signup/SignupEmailScreen.swift b/WordPress/UITestsFoundation/Screens/Signup/SignupEmailScreen.swift new file mode 100644 index 000000000000..96300e13f8a5 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Signup/SignupEmailScreen.swift @@ -0,0 +1,34 @@ +import ScreenObject +import XCTest + +// TODO: remove when unifiedAuth is permanent. + +public class SignupEmailScreen: ScreenObject { + + private let emailTextFieldGetter: (XCUIApplication) -> XCUIElement = { + $0.textFields["Signup Email Address"] + } + + private let nextButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Signup Email Next Button"] + } + + var emailTextField: XCUIElement { emailTextFieldGetter(app) } + var nextButton: XCUIElement { nextButtonGetter(app) } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [emailTextFieldGetter, nextButtonGetter], + app: app, + waitTimeout: 7 + ) + } + + public func proceedWith(email: String) throws -> SignupCheckMagicLinkScreen { + emailTextField.tap() + emailTextField.typeText(email) + nextButton.tap() + + return try SignupCheckMagicLinkScreen() + } +} diff --git a/WordPress/UITestsFoundation/Screens/Signup/SignupEpilogueScreen.swift b/WordPress/UITestsFoundation/Screens/Signup/SignupEpilogueScreen.swift new file mode 100644 index 000000000000..8da53c217c21 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Signup/SignupEpilogueScreen.swift @@ -0,0 +1,38 @@ +import ScreenObject +import XCTest + +public class SignupEpilogueScreen: ScreenObject { + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ { $0.staticTexts["New Account Header"] } ], + app: app, + waitTimeout: 7 + ) + } + + public func verifyEpilogueContains(username: String, displayName: String) -> SignupEpilogueScreen { + let actualUsername = app.textFields["Username Field"].value as! String + let actualDisplayName = app.textFields["Display Name Field"].value as! String + + XCTAssertEqual(username, actualUsername, "Username is set to \(actualUsername) but should be \(username)") + XCTAssertEqual(displayName, actualDisplayName, "Display name is set to \(actualDisplayName) but should be \(displayName)") + + return self + } + + public func setPassword(_ password: String) -> SignupEpilogueScreen { + let passwordField = app.secureTextFields["Password Field"] + passwordField.tap() + passwordField.typeText(password) + app.buttons["Done"].tap() + + return self + } + + public func continueWithSignup() throws -> MySiteScreen { + app.buttons["Done Button"].tap() + + return try MySiteScreen() + } +} diff --git a/WordPress/UITestsFoundation/Screens/Signup/WelcomeScreenSignupComponent.swift b/WordPress/UITestsFoundation/Screens/Signup/WelcomeScreenSignupComponent.swift new file mode 100644 index 000000000000..ff23b0cd0dc9 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/Signup/WelcomeScreenSignupComponent.swift @@ -0,0 +1,27 @@ +import ScreenObject +import XCTest + +// TODO: remove when unifiedAuth is permanent. + +public class WelcomeScreenSignupComponent: ScreenObject { + + private let emailSignupButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["Sign up with Email Button"] + } + + var emailSignupButton: XCUIElement { emailSignupButtonGetter(app) } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [emailSignupButtonGetter], + app: app, + waitTimeout: 7 + ) + } + + public func selectEmailSignup() throws -> SignupEmailScreen { + emailSignupButton.tap() + + return try SignupEmailScreen() + } +} diff --git a/WordPress/UITestsFoundation/Screens/SiteIntentScreen.swift b/WordPress/UITestsFoundation/Screens/SiteIntentScreen.swift new file mode 100644 index 000000000000..36036dd0aad0 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/SiteIntentScreen.swift @@ -0,0 +1,31 @@ +import ScreenObject +import XCTest + +public class SiteIntentScreen: ScreenObject { + + let cancelButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.buttons["site-intent-cancel-button"] + } + + init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ + // swiftlint:disable:next opening_brace + { $0.tables["Site Intent Table"] }, + cancelButtonGetter + ], + app: app, + waitTimeout: 7 + ) + } + + @discardableResult + public func closeModal() throws -> MySitesScreen { + cancelButtonGetter(app).tap() + return try MySitesScreen() + } + + public static func isLoaded() -> Bool { + (try? SiteIntentScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/SiteSettingsScreen.swift b/WordPress/UITestsFoundation/Screens/SiteSettingsScreen.swift new file mode 100644 index 000000000000..04f2fa1752c2 --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/SiteSettingsScreen.swift @@ -0,0 +1,53 @@ +import ScreenObject +import XCTest + +public class SiteSettingsScreen: ScreenObject { + + public enum Toggle { + case on + case off + } + + private let blockEditorToggleGetter: (XCUIApplication) -> XCUIElement = { + $0.tables["siteSettingsTable"].switches["useBlockEditorSwitch"] + } + var blockEditorToggle: XCUIElement { blockEditorToggleGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [blockEditorToggleGetter], + app: app, + waitTimeout: 7 + ) + } + + @discardableResult + public func toggleBlockEditor(to state: Toggle) -> SiteSettingsScreen { + switch state { + case .on: + if !isBlockEditorEnabled() { + blockEditorToggle.tap() + } + case .off: + if isBlockEditorEnabled() { + blockEditorToggle.tap() + } + } + return self + } + + public func goBackToMySite() throws -> MySiteScreen { + if XCUIDevice.isPhone { + navigateBack() + } + return try MySiteScreen() + } + + private func isBlockEditorEnabled() -> Bool { + return blockEditorToggle.value as! String == "1" + } + + public static func isLoaded() -> Bool { + (try? SiteSettingsScreen().isLoaded) ?? false + } +} diff --git a/WordPress/UITestsFoundation/Screens/StatsScreen.swift b/WordPress/UITestsFoundation/Screens/StatsScreen.swift new file mode 100644 index 000000000000..48b6aa3b6c7c --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/StatsScreen.swift @@ -0,0 +1,86 @@ +import ScreenObject +import XCTest + +public class StatsScreen: ScreenObject { + + public enum Mode: String { + case insights + case months + case years + } + + private enum Timeouts { + static let short: TimeInterval = 1 + static let `default`: TimeInterval = 3 + static let long: TimeInterval = 10 + } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + // swiftlint:disable:next opening_brace + expectedElementGetters: [{ $0.otherElements.firstMatch }], + app: app, + waitTimeout: 7 + ) + } + + public func verifyStatsLoaded(_ stats: [String]) -> Bool { + for stat in stats { + guard app.staticTexts[stat].waitForExistence(timeout: Timeouts.default) else { + Logger.log(message: "Element not found: \(stat)", event: LogEvent.e) + return false + } + } + return true + } + + public func verifyChartLoaded(_ chartElements: [String]) -> Bool { + for chartElement in chartElements { + guard app.otherElements[chartElement].waitForExistence(timeout: Timeouts.default) else { + Logger.log(message: "Element not found: \(chartElement)", event: LogEvent.e) + return false + } + } + return true + } + + @discardableResult + public func assertStatsAreLoaded(_ elements: [String]) -> StatsScreen { + XCTAssert(verifyStatsLoaded(elements)) + return self + } + + @discardableResult + public func assertChartIsLoaded(_ elements: [String]) -> StatsScreen { + XCTAssert(verifyChartLoaded(elements)) + return self + } + + @discardableResult + public func dismissCustomizeInsightsNotice() -> StatsScreen { + let button = app.buttons["dismiss-customize-insights-cell"] + + if button.exists { + button.tap() + } + + return self + } + + @discardableResult + public func switchTo(mode: Mode) -> StatsScreen { + app.buttons[mode.rawValue].tap() + return self + } + + public func refreshStatsIfNeeded() -> StatsScreen { + let errorMessage = NSPredicate(format: "label == 'An error occurred.'") + let isErrorMessagePresent = app.staticTexts.element(matching: errorMessage).exists + let expectedCardsWithoutData = 3 + let isDataLoaded = app.staticTexts.matching(identifier: "No data yet").count <= expectedCardsWithoutData + + if isErrorMessagePresent == true || isDataLoaded == false { pullToRefresh() } + + return self + } +} diff --git a/WordPress/UITestsFoundation/Screens/TabNavComponent.swift b/WordPress/UITestsFoundation/Screens/TabNavComponent.swift new file mode 100644 index 000000000000..1e51f989591c --- /dev/null +++ b/WordPress/UITestsFoundation/Screens/TabNavComponent.swift @@ -0,0 +1,86 @@ +import ScreenObject +import XCTest + +public class TabNavComponent: ScreenObject { + + private static let tabBarGetter: (XCUIApplication) -> XCUIElement = { + $0.tabBars["Main Navigation"] + } + + private let mySitesTabButtonGetter: (XCUIApplication) -> XCUIElement = { + TabNavComponent.tabBarGetter($0).buttons["mySitesTabButton"] + } + + private let readerTabButtonGetter: (XCUIApplication) -> XCUIElement = { + TabNavComponent.tabBarGetter($0).buttons["readerTabButton"] + } + + private let notificationsTabButtonGetter: (XCUIApplication) -> XCUIElement = { + TabNavComponent.tabBarGetter($0).buttons["notificationsTabButton"] + } + + var mySitesTabButton: XCUIElement { mySitesTabButtonGetter(app) } + var readerTabButton: XCUIElement { readerTabButtonGetter(app) } + var notificationsTabButton: XCUIElement { notificationsTabButtonGetter(app) } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [ + mySitesTabButtonGetter, + readerTabButtonGetter, + notificationsTabButtonGetter + ], + app: app, + waitTimeout: 3 + ) + } + + public func goToMeScreen() throws -> MeTabScreen { + try goToMySiteScreen() + let meButton = app.navigationBars.buttons["meBarButton"] + meButton.tap() + return try MeTabScreen() + } + + @discardableResult + public func goToMySiteScreen() throws -> MySiteScreen { + mySitesTabButton.tap() + return try MySiteScreen() + } + + public func goToAztecEditorScreen() throws -> AztecEditorScreen { + let mySiteScreen = try goToMySiteScreen() + let actionSheet = try mySiteScreen.goToCreateSheet() + actionSheet.goToBlogPost() + + return try AztecEditorScreen(mode: .rich) + } + + public func gotoBlockEditorScreen() throws -> BlockEditorScreen { + let mySite = try goToMySiteScreen() + let actionSheet = try mySite.goToCreateSheet() + actionSheet.goToBlogPost() + + return try BlockEditorScreen() + } + + public func goToReaderScreen() throws -> ReaderScreen { + readerTabButton.tap() + return try ReaderScreen() + } + + public func goToNotificationsScreen() throws -> NotificationsScreen { + notificationsTabButton.tap() + try dismissNotificationAlertIfNeeded() + return try NotificationsScreen() + } + + public static func isLoaded() -> Bool { + (try? TabNavComponent().isLoaded) ?? false + } + + public static func isVisible() -> Bool { + guard let screen = try? TabNavComponent() else { return false } + return screen.mySitesTabButton.isHittable + } +} diff --git a/WordPress/UITestsFoundation/UITestsFoundation.h b/WordPress/UITestsFoundation/UITestsFoundation.h new file mode 100644 index 000000000000..5a8a50d14cf7 --- /dev/null +++ b/WordPress/UITestsFoundation/UITestsFoundation.h @@ -0,0 +1,7 @@ +#import + +//! Project version number for UITestsFoundation. +FOUNDATION_EXPORT double UITestsFoundationVersionNumber; + +//! Project version string for UITestsFoundation. +FOUNDATION_EXPORT const unsigned char UITestsFoundationVersionString[]; diff --git a/WordPress/UITestsFoundation/WireMock.swift b/WordPress/UITestsFoundation/WireMock.swift new file mode 100644 index 000000000000..7724146e87c5 --- /dev/null +++ b/WordPress/UITestsFoundation/WireMock.swift @@ -0,0 +1,10 @@ +import Foundation + +public class WireMock { + + public static func URL() -> Foundation.URL { + let host = ProcessInfo().environment["WIREMOCK_HOST"] ?? "localhost" + let port = ProcessInfo().environment["WIREMOCK_PORT"] ?? "8282" + return Foundation.URL(string: "http://\(host):\(port)/")! + } +} diff --git a/WordPress/UITestsFoundation/XCTestCase+Utils.swift b/WordPress/UITestsFoundation/XCTestCase+Utils.swift new file mode 100644 index 000000000000..f4ca321069d4 --- /dev/null +++ b/WordPress/UITestsFoundation/XCTestCase+Utils.swift @@ -0,0 +1,34 @@ +import XCTest + +public extension XCTestCase { + + func removeApp(_ app: XCUIApplication = XCUIApplication()) { + // We need to store the app name before calling `terminate()` if we want to delete it. + // Otherwise, we won't be able to access it to read its name as the test will fail with: + // + // > Failed to get matching snapshot: Application org.wordpress is not running + // + // Launch the app to access its name so we can deleted it from Springboard + switch app.state { + case .unknown, .notRunning: + app.launch() + default: + break + } + + let appName = app.label + app.terminate() + + let appToRemove = Apps.springboard.icons[appName] + + guard appToRemove.exists else { + return + } + + appToRemove.firstMatch.press(forDuration: 1) + waitAndTap(Apps.springboard.buttons["Remove App"]) + waitAndTap(Apps.springboard.alerts.buttons["Delete App"]) + waitAndTap(Apps.springboard.alerts.buttons["Delete"]) + } + +} diff --git a/WordPress/UITestsFoundation/XCUIElement+Scroll.swift b/WordPress/UITestsFoundation/XCUIElement+Scroll.swift new file mode 100644 index 000000000000..af0de8231e5f --- /dev/null +++ b/WordPress/UITestsFoundation/XCUIElement+Scroll.swift @@ -0,0 +1,24 @@ +import XCTest +import XCUITestHelpers + +extension XCUIElement { + + /// Scroll an element into view within another element. + /// scrollView can be a UIScrollView, or anything that subclasses it like UITableView + /// + /// TODO: The implementation of this could use work: + /// - What happens if the element is above the current scroll view position? + /// - What happens if it's a really long scroll view? + public func scrollIntoView(within scrollView: XCUIElement, threshold: Int = 1000) { + var iteration = 0 + + while isFullyVisibleOnScreen() == false && iteration < threshold { + scrollView.scroll(byDeltaX: 0, deltaY: 100) + iteration += 1 + } + + if isFullyVisibleOnScreen() == false { + XCTFail("Unable to scroll element into view") + } + } +} diff --git a/WordPress/UITestsFoundation/XCUIElement+Utils.swift b/WordPress/UITestsFoundation/XCUIElement+Utils.swift new file mode 100644 index 000000000000..ad20380bdb19 --- /dev/null +++ b/WordPress/UITestsFoundation/XCUIElement+Utils.swift @@ -0,0 +1,12 @@ +import XCTest + +// TODO: This should go XCUITestHelpers if not there already +public extension XCUIElement { + + func scroll(byDeltaX deltaX: CGFloat, deltaY: CGFloat) { + let startCoordinate = self.coordinate(withNormalizedOffset: CGVector(dx: 0, dy: 0)) + let destination = startCoordinate.withOffset(CGVector(dx: deltaX, dy: deltaY * -1)) + + startCoordinate.press(forDuration: 0.01, thenDragTo: destination) + } +} diff --git a/WordPress/UITestsFoundation/XCUIElementQuery+Utils.swift b/WordPress/UITestsFoundation/XCUIElementQuery+Utils.swift new file mode 100644 index 000000000000..0eb1c7fee6be --- /dev/null +++ b/WordPress/UITestsFoundation/XCUIElementQuery+Utils.swift @@ -0,0 +1,9 @@ +import XCTest + +// TODO: This might be better suited in the XCUITestHelpers frameworks. +public extension XCUIElementQuery { + + var lastMatch: XCUIElement? { + return self.allElementsBoundByIndex.last + } +} diff --git a/WordPress/Vendor/ALIterativeMigrator.h b/WordPress/Vendor/ALIterativeMigrator.h deleted file mode 100644 index 9e05cb128ed2..000000000000 --- a/WordPress/Vendor/ALIterativeMigrator.h +++ /dev/null @@ -1,97 +0,0 @@ -/* - Copyright (c) 2013, Art & Logic - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - 2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND - ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - The views and conclusions contained in the software and documentation are those - of the authors and should not be interpreted as representing official policies, - either expressed or implied, of the FreeBSD Project. -*/ - -#import - -/** - * Iteratively migrates a persistent Core Data store along a series of ordered - * managed object models. Allows for easily mixing inferred migrations - * with mapping models and custom migrations. - */ -@interface ALIterativeMigrator : NSObject - -/** - * Iteratively migrates the store at the given URL from its current model - * to the given finalModel in order through the list of modelNames. - * - * Does nothing (returns YES) if the persistent store does not yet exist - * at the given sourceStoreURL. - * - * @param sourceStoreURL The file URL to the persistent store file. - * @param sourceStoreType The type of store at sourceStoreURL - * (see NSPersistentStoreCoordinator for possible values). - * @param finalModel The final target managed object model for the migration manager. - * At the end of the migration, the persistent store should be migrated - * to this model. - * @param modelNames *.mom file names for each of the managed object models - * through which the persistent store might need to be migrated. - * The model names should be ordered in such a way that migration - * from one to the next can occur either using a custom mapping model - * or an inferred mapping model. - * The *.mom files should be stored in the top level of the main bundle. - * @param error If an error occurs during the migration, upon return contains - * an NSError object that describes the problem. - * - * @return YES if the migration proceeds without errors, otherwise NO. - */ -+ (BOOL)iterativeMigrateURL:(NSURL*)sourceStoreURL - ofType:(NSString*)sourceStoreType - toModel:(NSManagedObjectModel*)finalModel - orderedModelNames:(NSArray*)modelNames - error:(NSError**)error; - -/** - * Migrates the store at the given URL from one object model to another - * using the given mapping model. Writes the store to a temporary file - * during migration so that if migration fails, the original store is left intact. - * - * @param sourceStoreURL The file URL to the persistent store file. - * @param sourceStoreType The type of store at sourceStoreURL - * (see NSPersistentStoreCoordinator for possible values). - * @param sourceModel The source managed object model for the migration manager. - * @param targetModel The target managed object model for the migration manager. - * @param mappingModel The mapping model to use to effect the migration. - * @param error If an error occurs during the migration, upon return contains - * an NSError object that describes the problem. - * - * @return YES if the migration proceeds without errors, otherwise NO. - */ -+ (BOOL)migrateURL:(NSURL*)sourceStoreURL - ofType:(NSString*)sourceStoreType - fromModel:(NSManagedObjectModel*)sourceModel - toModel:(NSManagedObjectModel*)targetModel - mappingModel:(NSMappingModel*)mappingModel - error:(NSError**)error; - -/** - * Returns the error domain used in NSErrors created by this class. - */ -+ (NSString*)errorDomain; - -@end diff --git a/WordPress/Vendor/ALIterativeMigrator.m b/WordPress/Vendor/ALIterativeMigrator.m deleted file mode 100644 index 1c3fd69c8c81..000000000000 --- a/WordPress/Vendor/ALIterativeMigrator.m +++ /dev/null @@ -1,379 +0,0 @@ -/* - Copyright (c) 2013, Art & Logic - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - 2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND - ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - The views and conclusions contained in the software and documentation are those - of the authors and should not be interpreted as representing official policies, - either expressed or implied, of the FreeBSD Project. -*/ - -#import "ALIterativeMigrator.h" - -@implementation ALIterativeMigrator - -+ (BOOL)iterativeMigrateURL:(NSURL*)sourceStoreURL - ofType:(NSString*)sourceStoreType - toModel:(NSManagedObjectModel*)finalModel - orderedModelNames:(NSArray*)modelNames - error:(NSError**)error -{ - // If the persistent store does not exist at the given URL, - // assume that it hasn't yet been created and return success immediately. - if (NO == [[NSFileManager defaultManager] fileExistsAtPath:[sourceStoreURL path]]) - { - return YES; - } - - // Get the persistent store's metadata. The metadata is used to - // get information about the store's managed object model. - NSDictionary* sourceMetadata = - [self metadataForPersistentStoreOfType:sourceStoreType - URL:sourceStoreURL - error:error]; - if (nil == sourceMetadata) - { - return NO; - } - - // Check whether the final model is already compatible with the store. - // If it is, no migration is necessary. - if ([finalModel isConfiguration:nil compatibleWithStoreMetadata:sourceMetadata]) - { - return YES; - } - - // Find the current model used by the store. - NSManagedObjectModel* sourceModel = - [self modelForStoreMetadata:sourceMetadata error:error]; - if (nil == sourceModel) - { - return NO; - } - - // Get NSManagedObjectModels for each of the model names given. - NSArray* models = [self modelsNamed:modelNames error:error]; - if (nil == models) - { - return NO; - } - - // Build an inclusive list of models between the source and final models. - NSMutableArray* relevantModels = [NSMutableArray array]; - BOOL firstFound = NO; - BOOL lastFound = NO; - BOOL reverse = NO; - for (NSManagedObjectModel* model in models) - { - if ([model isEqual:sourceModel] || [model isEqual:finalModel]) - { - if (firstFound) - { - lastFound = YES; - // In case a reverse migration is being performed (descending through the - // ordered array of models), check whether the source model is found - // after the final model. - reverse = [model isEqual:sourceModel]; - } - else - { - firstFound = YES; - } - } - - if (firstFound) - { - [relevantModels addObject:model]; - } - - if (lastFound) - { - break; - } - } - - // Ensure that the source model is at the start of the list. - if (reverse) - { - relevantModels = - [[[relevantModels reverseObjectEnumerator] allObjects] mutableCopy]; - } - - // Migrate through the list - for (int i = 0; i < ([relevantModels count] - 1); i++) - { - NSManagedObjectModel* modelA = [relevantModels objectAtIndex:i]; - NSManagedObjectModel* modelB = [relevantModels objectAtIndex:(i + 1)]; - - // Check whether a custom mapping model exists. - NSMappingModel* mappingModel = [NSMappingModel mappingModelFromBundles:nil - forSourceModel:modelA - destinationModel:modelB]; - - // If there is no custom mapping model, try to infer one. - if (nil == mappingModel) - { - mappingModel = [NSMappingModel inferredMappingModelForSourceModel:modelA - destinationModel:modelB - error:error]; - if (nil == mappingModel) - { - return NO; - } - } - - if (![self migrateURL:sourceStoreURL - ofType:sourceStoreType - fromModel:modelA - toModel:modelB - mappingModel:mappingModel - error:error]) - { - return NO; - } - } - - return YES; -} - -+ (BOOL)migrateURL:(NSURL*)sourceStoreURL - ofType:(NSString*)sourceStoreType - fromModel:(NSManagedObjectModel*)sourceModel - toModel:(NSManagedObjectModel*)targetModel - mappingModel:(NSMappingModel*)mappingModel - error:(NSError**)error -{ - // Build a temporary path to write the migrated store. - NSFileManager* fileManager = [NSFileManager defaultManager]; - NSURL *tempDestinationStoreURL = [[sourceStoreURL.URLByDeletingLastPathComponent URLByAppendingPathComponent:@"migration"] URLByAppendingPathComponent:sourceStoreURL.lastPathComponent]; - [fileManager createDirectoryAtURL:tempDestinationStoreURL.URLByDeletingLastPathComponent withIntermediateDirectories:NO attributes:nil error:nil]; - - // Migrate from the source model to the target model using the mapping, - // and store the resulting data at the temporary path. - NSMigrationManager* migrator = [[NSMigrationManager alloc] - initWithSourceModel:sourceModel - destinationModel:targetModel]; - - if (![migrator migrateStoreFromURL:sourceStoreURL - type:sourceStoreType - options:nil - withMappingModel:mappingModel - toDestinationURL:tempDestinationStoreURL - destinationType:sourceStoreType - destinationOptions:nil - error:error]) - { - return NO; - } - - // Move the original source store to a backup location. - NSString* backupPath = [sourceStoreURL.URLByDeletingLastPathComponent URLByAppendingPathComponent:@"backup"].path; - [fileManager createDirectoryAtPath:backupPath withIntermediateDirectories:NO attributes:nil error:nil]; - NSArray *files = [fileManager contentsOfDirectoryAtPath:sourceStoreURL.URLByDeletingLastPathComponent.path error:error]; - for (NSString *file in files) { - if ([file hasPrefix:sourceStoreURL.lastPathComponent]) { - NSString *fullPath = [sourceStoreURL.URLByDeletingLastPathComponent URLByAppendingPathComponent:file].path; - NSString *toPath = [[NSURL URLWithString:backupPath] URLByAppendingPathComponent:file].path; - [fileManager moveItemAtPath:fullPath toPath:toPath error:error]; - - if (*error) { - DDLogError(@"Error while moving %@ to %@: %@", fullPath, toPath, *error); - return NO; - } - } - } - - // Copy migrated over the original files - files = [fileManager contentsOfDirectoryAtPath:tempDestinationStoreURL.URLByDeletingLastPathComponent.path error:error]; - for (NSString *file in files) { - if ([file hasPrefix:tempDestinationStoreURL.lastPathComponent]) { - NSString *fullPath = [tempDestinationStoreURL.URLByDeletingLastPathComponent URLByAppendingPathComponent:file].path; - NSString *toPath = [sourceStoreURL.URLByDeletingLastPathComponent URLByAppendingPathComponent:file].path; - [fileManager removeItemAtPath:toPath error:nil]; - [fileManager moveItemAtPath:fullPath toPath:toPath error:error]; - - if (*error) { - DDLogError(@"Error while moving %@ to %@: %@", fullPath, toPath, *error); - return NO; - } - } - } - - // Delete backup copies of the original file before migration - files = [fileManager contentsOfDirectoryAtPath:backupPath error:error]; - for (NSString *file in files) { - NSString *fullPath = [[NSURL URLWithString:backupPath] URLByAppendingPathComponent:file].path; - [fileManager removeItemAtPath:fullPath error:error]; - - if (*error) { - DDLogError(@"Error while deleting backup file %@: %@", fullPath, *error); - return NO; - } - } - - return YES; -} - -+ (NSString*)errorDomain -{ - return @"com.artlogic.IterativeMigrator"; -} - -#pragma mark - Private methods - -// Returns an NSError with the give code and localized description, -// and this class' error domain. -+ (NSError*)errorWithCode:(NSInteger)code description:(NSString*)description -{ - NSDictionary* userInfo = @{ - NSLocalizedDescriptionKey: description - }; - - return [NSError errorWithDomain:[ALIterativeMigrator errorDomain] - code:code - userInfo:userInfo]; -} - -// Gets the metadata for the given persistent store. -+ (NSDictionary*)metadataForPersistentStoreOfType:(NSString*)storeType - URL:(NSURL*)url - error:(NSError **)error -{ - NSDictionary* sourceMetadata = - [NSPersistentStoreCoordinator metadataForPersistentStoreOfType:storeType - URL:url - options:nil - error:error]; - if (nil == sourceMetadata && NULL != error) - { - NSString* errorDesc = [NSString stringWithFormat: - @"Failed to find source metadata for store: %@", - url]; - *error = [self errorWithCode:102 description:errorDesc]; - } - - return sourceMetadata; -} - -// Finds the source model for the store described by the given metadata. -+ (NSManagedObjectModel*)modelForStoreMetadata:(NSDictionary*)metadata - error:(NSError**)error -{ - NSManagedObjectModel* sourceModel = [NSManagedObjectModel - mergedModelFromBundles:nil - forStoreMetadata:metadata]; - if (nil == sourceModel && NULL != error) - { - NSString* errorDesc = [NSString stringWithFormat: - @"Failed to find source model for metadata: %@", - metadata]; - *error = [self errorWithCode:100 description:errorDesc]; - } - - return sourceModel; -} - -// Returns an array of NSManagedObjectModels loaded from mom files with the given names. -// Returns nil if any model files could not be found. -+ (NSArray*)modelsNamed:(NSArray*)modelNames - error:(NSError**)error -{ - NSMutableArray* models = [NSMutableArray array]; - for (NSString* modelName in modelNames) - { - NSURL* modelUrl = [self urlForModelName:modelName inDirectory:nil]; - NSManagedObjectModel* model = - [[NSManagedObjectModel alloc] initWithContentsOfURL:modelUrl]; - - if (nil == model) - { - if (NULL != error) - { - NSString* errorDesc = - [NSString stringWithFormat:@"No model found for %@ at URL %@", modelName, modelUrl]; - *error = [self errorWithCode:110 description:errorDesc]; - } - return nil; - } - - [models addObject:model]; - } - return models; -} - -// Returns an array of paths to .mom model files in the given directory. -// Recurses into .momd directories to look for .mom files. -// @param directory The name of the bundle directory to search. If nil, -// searches default paths. -+ (NSArray*)modelPathsInDirectory:(NSString*)directory -{ - NSMutableArray* modelPaths = [NSMutableArray array]; - - // Get top level mom file paths. - [modelPaths addObjectsFromArray: - [[NSBundle mainBundle] pathsForResourcesOfType:@"mom" - inDirectory:directory]]; - - // Get mom file paths from momd directories. - NSArray* momdPaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"momd" - inDirectory:directory]; - for (NSString* momdPath in momdPaths) - { - NSString* resourceSubpath = [momdPath lastPathComponent]; - - [modelPaths addObjectsFromArray: - [[NSBundle mainBundle] - pathsForResourcesOfType:@"mom" - inDirectory:resourceSubpath]]; - } - - return modelPaths; -} - -// Returns the URL for a model file with the given name in the given directory. -// @param directory The name of the bundle directory to search. If nil, -// searches default paths. -+ (NSURL*)urlForModelName:(NSString*)modelName - inDirectory:(NSString*)directory -{ - NSBundle* bundle = [NSBundle mainBundle]; - NSURL* url = [bundle URLForResource:modelName - withExtension:@"mom" - subdirectory:directory]; - if (nil == url) - { - // Get mom file paths from momd directories. - NSArray* momdPaths = [[NSBundle mainBundle] pathsForResourcesOfType:@"momd" - inDirectory:directory]; - for (NSString* momdPath in momdPaths) - { - if (url) { continue; } - url = [bundle URLForResource:modelName - withExtension:@"mom" - subdirectory:[momdPath lastPathComponent]]; - } - } - - return url; -} - -@end diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/AnimatedGIFImageSerialization.podspec b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/AnimatedGIFImageSerialization.podspec deleted file mode 100755 index 189a3399b3ff..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/AnimatedGIFImageSerialization.podspec +++ /dev/null @@ -1,15 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'AnimatedGIFImageSerialization' - s.version = '0.1.0' - s.license = 'MIT' - s.summary = 'Decodes UIImage sequences from Animated GIFs.' - s.homepage = 'https://github.com/mattt/AnimatedGIFImageSerialization' - s.social_media_url = 'https://twitter.com/mattt' - s.authors = { 'Mattt Thompson' => 'm@mattt.me' } - s.source = { :git => 'https://github.com/mattt/AnimatedGIFImageSerialization.git', :tag => '0.1.0' } - s.source_files = 'AnimatedGIFImageSerialization' - s.requires_arc = true - - s.ios.frameworks = "MobileCoreServices", "ImageIO", "CoreGraphics" - s.ios.deployment_target = '5.0' -end diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/AnimatedGIFImageSerialization.xcworkspace/contents.xcworkspacedata b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/AnimatedGIFImageSerialization.xcworkspace/contents.xcworkspacedata deleted file mode 100755 index ec612867793b..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/AnimatedGIFImageSerialization.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/AnimatedGIFImageSerialization.xcworkspace/xcshareddata/AnimatedGIFImageSerialization.xccheckout b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/AnimatedGIFImageSerialization.xcworkspace/xcshareddata/AnimatedGIFImageSerialization.xccheckout deleted file mode 100644 index d9c7f4eacd83..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/AnimatedGIFImageSerialization.xcworkspace/xcshareddata/AnimatedGIFImageSerialization.xccheckout +++ /dev/null @@ -1,41 +0,0 @@ - - - - - IDESourceControlProjectFavoriteDictionaryKey - - IDESourceControlProjectIdentifier - 97716021-2777-4EB3-ADBC-C7E5DA9BB05A - IDESourceControlProjectName - AnimatedGIFImageSerialization - IDESourceControlProjectOriginsDictionary - - B7298D54-03E8-42E6-B043-EDFC6B8797C0 - ssh://github.com/wordpress-mobile/WordPress-iOS.git - - IDESourceControlProjectPath - WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/AnimatedGIFImageSerialization.xcworkspace - IDESourceControlProjectRelativeInstallPathDictionary - - B7298D54-03E8-42E6-B043-EDFC6B8797C0 - ../../../.. - - IDESourceControlProjectURL - ssh://github.com/wordpress-mobile/WordPress-iOS.git - IDESourceControlProjectVersion - 110 - IDESourceControlProjectWCCIdentifier - B7298D54-03E8-42E6-B043-EDFC6B8797C0 - IDESourceControlProjectWCConfigurations - - - IDESourceControlRepositoryExtensionIdentifierKey - public.vcs.git - IDESourceControlWCCIdentifierKey - B7298D54-03E8-42E6-B043-EDFC6B8797C0 - IDESourceControlWCCName - WordPress-iOS - - - - diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example.xcodeproj/project.pbxproj b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example.xcodeproj/project.pbxproj deleted file mode 100755 index 5a9d92d826df..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example.xcodeproj/project.pbxproj +++ /dev/null @@ -1,338 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 46; - objects = { - -/* Begin PBXBuildFile section */ - F82E75AF18ABDC4E002D596E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F82E75AE18ABDC4E002D596E /* Foundation.framework */; }; - F82E75B118ABDC4E002D596E /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F82E75B018ABDC4E002D596E /* CoreGraphics.framework */; }; - F82E75B318ABDC4E002D596E /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F82E75B218ABDC4E002D596E /* UIKit.framework */; }; - F82E75B918ABDC4E002D596E /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = F82E75B718ABDC4E002D596E /* InfoPlist.strings */; }; - F82E75BB18ABDC4E002D596E /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = F82E75BA18ABDC4E002D596E /* main.m */; }; - F82E75BF18ABDC4E002D596E /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = F82E75BE18ABDC4E002D596E /* AppDelegate.m */; }; - F82E75C118ABDC4E002D596E /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F82E75C018ABDC4E002D596E /* Images.xcassets */; }; - F83E7E5F18E1FCA900309F89 /* AnimatedGIFImageSerialization.m in Sources */ = {isa = PBXBuildFile; fileRef = F83E7E5E18E1FCA900309F89 /* AnimatedGIFImageSerialization.m */; }; - F862CE2518E1EFF000DC750D /* ImageIO.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F862CE2418E1EFF000DC750D /* ImageIO.framework */; }; - F862CE2718E1F53800DC750D /* animated.gif in Resources */ = {isa = PBXBuildFile; fileRef = F862CE2618E1F53800DC750D /* animated.gif */; }; - F862CE2918E1F7AB00DC750D /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F862CE2818E1F7AB00DC750D /* MobileCoreServices.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - F82E75AB18ABDC4E002D596E /* Animated GIF.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Animated GIF.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - F82E75AE18ABDC4E002D596E /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; - F82E75B018ABDC4E002D596E /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; - F82E75B218ABDC4E002D596E /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; - F82E75B618ABDC4E002D596E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - F82E75B818ABDC4E002D596E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; - F82E75BA18ABDC4E002D596E /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; - F82E75BC18ABDC4E002D596E /* Prefix.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Prefix.pch; sourceTree = ""; }; - F82E75BD18ABDC4E002D596E /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; - F82E75BE18ABDC4E002D596E /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; - F82E75C018ABDC4E002D596E /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; - F83E7E5D18E1FCA900309F89 /* AnimatedGIFImageSerialization.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AnimatedGIFImageSerialization.h; sourceTree = ""; }; - F83E7E5E18E1FCA900309F89 /* AnimatedGIFImageSerialization.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AnimatedGIFImageSerialization.m; sourceTree = ""; }; - F862CE2418E1EFF000DC750D /* ImageIO.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImageIO.framework; path = System/Library/Frameworks/ImageIO.framework; sourceTree = SDKROOT; }; - F862CE2618E1F53800DC750D /* animated.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = animated.gif; sourceTree = ""; }; - F862CE2818E1F7AB00DC750D /* MobileCoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = System/Library/Frameworks/MobileCoreServices.framework; sourceTree = SDKROOT; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - F82E75A818ABDC4E002D596E /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - F862CE2918E1F7AB00DC750D /* MobileCoreServices.framework in Frameworks */, - F862CE2518E1EFF000DC750D /* ImageIO.framework in Frameworks */, - F82E75B118ABDC4E002D596E /* CoreGraphics.framework in Frameworks */, - F82E75B318ABDC4E002D596E /* UIKit.framework in Frameworks */, - F82E75AF18ABDC4E002D596E /* Foundation.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - F82E75A218ABDC4E002D596E = { - isa = PBXGroup; - children = ( - F82E75B418ABDC4E002D596E /* Animated GIF Example */, - F82E75AD18ABDC4E002D596E /* Frameworks */, - F82E75AC18ABDC4E002D596E /* Products */, - F82E75DD18ABDC57002D596E /* Vendor */, - ); - sourceTree = ""; - }; - F82E75AC18ABDC4E002D596E /* Products */ = { - isa = PBXGroup; - children = ( - F82E75AB18ABDC4E002D596E /* Animated GIF.app */, - ); - name = Products; - sourceTree = ""; - }; - F82E75AD18ABDC4E002D596E /* Frameworks */ = { - isa = PBXGroup; - children = ( - F862CE2818E1F7AB00DC750D /* MobileCoreServices.framework */, - F862CE2418E1EFF000DC750D /* ImageIO.framework */, - F82E75AE18ABDC4E002D596E /* Foundation.framework */, - F82E75B018ABDC4E002D596E /* CoreGraphics.framework */, - F82E75B218ABDC4E002D596E /* UIKit.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; - F82E75B418ABDC4E002D596E /* Animated GIF Example */ = { - isa = PBXGroup; - children = ( - F82E75BD18ABDC4E002D596E /* AppDelegate.h */, - F82E75BE18ABDC4E002D596E /* AppDelegate.m */, - F82E75C018ABDC4E002D596E /* Images.xcassets */, - F82E75B518ABDC4E002D596E /* Supporting Files */, - ); - path = "Animated GIF Example"; - sourceTree = ""; - }; - F82E75B518ABDC4E002D596E /* Supporting Files */ = { - isa = PBXGroup; - children = ( - F82E75B618ABDC4E002D596E /* Info.plist */, - F82E75B718ABDC4E002D596E /* InfoPlist.strings */, - F82E75BA18ABDC4E002D596E /* main.m */, - F82E75BC18ABDC4E002D596E /* Prefix.pch */, - F862CE2618E1F53800DC750D /* animated.gif */, - ); - name = "Supporting Files"; - sourceTree = ""; - }; - F82E75DD18ABDC57002D596E /* Vendor */ = { - isa = PBXGroup; - children = ( - F83E7E5C18E1FCA900309F89 /* AnimatedGIFImageSerialization */, - ); - name = Vendor; - path = ../WebPImageSerialization; - sourceTree = ""; - }; - F83E7E5C18E1FCA900309F89 /* AnimatedGIFImageSerialization */ = { - isa = PBXGroup; - children = ( - F83E7E5D18E1FCA900309F89 /* AnimatedGIFImageSerialization.h */, - F83E7E5E18E1FCA900309F89 /* AnimatedGIFImageSerialization.m */, - ); - name = AnimatedGIFImageSerialization; - path = ../AnimatedGIFImageSerialization; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - F82E75AA18ABDC4E002D596E /* Animated GIF Example */ = { - isa = PBXNativeTarget; - buildConfigurationList = F82E75D718ABDC4E002D596E /* Build configuration list for PBXNativeTarget "Animated GIF Example" */; - buildPhases = ( - F82E75A718ABDC4E002D596E /* Sources */, - F82E75A818ABDC4E002D596E /* Frameworks */, - F82E75A918ABDC4E002D596E /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "Animated GIF Example"; - productName = "WebPImage Example"; - productReference = F82E75AB18ABDC4E002D596E /* Animated GIF.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - F82E75A318ABDC4E002D596E /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 0510; - ORGANIZATIONNAME = "Mattt Thompson"; - }; - buildConfigurationList = F82E75A618ABDC4E002D596E /* Build configuration list for PBXProject "Animated GIF Example" */; - compatibilityVersion = "Xcode 3.2"; - developmentRegion = English; - hasScannedForEncodings = 0; - knownRegions = ( - en, - ); - mainGroup = F82E75A218ABDC4E002D596E; - productRefGroup = F82E75AC18ABDC4E002D596E /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - F82E75AA18ABDC4E002D596E /* Animated GIF Example */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - F82E75A918ABDC4E002D596E /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F82E75B918ABDC4E002D596E /* InfoPlist.strings in Resources */, - F82E75C118ABDC4E002D596E /* Images.xcassets in Resources */, - F862CE2718E1F53800DC750D /* animated.gif in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - F82E75A718ABDC4E002D596E /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F83E7E5F18E1FCA900309F89 /* AnimatedGIFImageSerialization.m in Sources */, - F82E75BF18ABDC4E002D596E /* AppDelegate.m in Sources */, - F82E75BB18ABDC4E002D596E /* main.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - F82E75B718ABDC4E002D596E /* InfoPlist.strings */ = { - isa = PBXVariantGroup; - children = ( - F82E75B818ABDC4E002D596E /* en */, - ); - name = InfoPlist.strings; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - F82E75D518ABDC4E002D596E /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_SYMBOLS_PRIVATE_EXTERN = NO; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 5.0; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - }; - name = Debug; - }; - F82E75D618ABDC4E002D596E /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = YES; - ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 5.0; - SDKROOT = iphoneos; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - F82E75D818ABDC4E002D596E /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; - FRAMEWORK_SEARCH_PATHS = "$(inherited)"; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = "Animated GIF Example/Prefix.pch"; - INFOPLIST_FILE = "$(SRCROOT)/Animated GIF Example/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 6.0; - PRODUCT_NAME = "Animated GIF"; - WRAPPER_EXTENSION = app; - }; - name = Debug; - }; - F82E75D918ABDC4E002D596E /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; - FRAMEWORK_SEARCH_PATHS = "$(inherited)"; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = "Animated GIF Example/Prefix.pch"; - INFOPLIST_FILE = "$(SRCROOT)/Animated GIF Example/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 6.0; - PRODUCT_NAME = "Animated GIF"; - WRAPPER_EXTENSION = app; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - F82E75A618ABDC4E002D596E /* Build configuration list for PBXProject "Animated GIF Example" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - F82E75D518ABDC4E002D596E /* Debug */, - F82E75D618ABDC4E002D596E /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - F82E75D718ABDC4E002D596E /* Build configuration list for PBXNativeTarget "Animated GIF Example" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - F82E75D818ABDC4E002D596E /* Debug */, - F82E75D918ABDC4E002D596E /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = F82E75A318ABDC4E002D596E /* Project object */; -} diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100755 index 709f15542e13..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/AppDelegate.h b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/AppDelegate.h deleted file mode 100755 index a1145f022eee..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/AppDelegate.h +++ /dev/null @@ -1,29 +0,0 @@ -// AppDelegate.h -// -// Copyright (c) 2014 Mattt Thompson (http://mattt.me/) -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -#import - -@interface AppDelegate : UIResponder - -@property (strong, nonatomic) UIWindow *window; - -@end diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/AppDelegate.m b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/AppDelegate.m deleted file mode 100755 index f91e8aa3b08c..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/AppDelegate.m +++ /dev/null @@ -1,49 +0,0 @@ -// AppDelegate.m -// -// Copyright (c) 2014 Mattt Thompson (http://mattt.me/) -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -#import "AppDelegate.h" - -#import "AnimatedGIFImageSerialization.h" - -@implementation AppDelegate - -- (BOOL)application:(__unused UIApplication *)application -didFinishLaunchingWithOptions:(__unused NSDictionary *)launchOptions -{ - self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; - - UIViewController *viewController = [[UIViewController alloc] init]; - self.window.rootViewController = viewController; - - UIImageView *imageView = [[UIImageView alloc] initWithFrame:viewController.view.bounds]; - imageView.contentMode = UIViewContentModeCenter; - imageView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - imageView.image = [UIImage imageNamed:@"animated.gif"]; - [viewController.view addSubview:imageView]; - - self.window.backgroundColor = [UIColor whiteColor]; - [self.window makeKeyAndVisible]; - - return YES; -} - -@end diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Images.xcassets/AppIcon.appiconset/Contents.json b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Images.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100755 index a396706db4ec..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Images.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "iphone", - "size" : "29x29", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "40x40", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "60x60", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Images.xcassets/LaunchImage.launchimage/Contents.json b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Images.xcassets/LaunchImage.launchimage/Contents.json deleted file mode 100755 index c79ebd3ada13..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Images.xcassets/LaunchImage.launchimage/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "orientation" : "portrait", - "idiom" : "iphone", - "extent" : "full-screen", - "minimum-system-version" : "7.0", - "scale" : "2x" - }, - { - "orientation" : "portrait", - "idiom" : "iphone", - "subtype" : "retina4", - "extent" : "full-screen", - "minimum-system-version" : "7.0", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Info.plist b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Info.plist deleted file mode 100755 index 25b6a0d0fcfd..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Info.plist +++ /dev/null @@ -1,37 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - ${PRODUCT_NAME} - CFBundleExecutable - ${EXECUTABLE_NAME} - CFBundleIdentifier - com.mattt.${PRODUCT_NAME:rfc1034identifier} - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - ${PRODUCT_NAME} - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - LSRequiresIPhoneOS - - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Prefix.pch b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Prefix.pch deleted file mode 100755 index 743435c9bee5..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/Prefix.pch +++ /dev/null @@ -1,16 +0,0 @@ -// -// Prefix header -// -// The contents of this file are implicitly included at the beginning of every source file. -// - -#import - -#ifndef __IPHONE_3_0 -#warning "This project uses features only available in iOS SDK 3.0 and later." -#endif - -#ifdef __OBJC__ - #import - #import -#endif diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/animated.gif b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/animated.gif deleted file mode 100755 index f94e759ff075..000000000000 Binary files a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/animated.gif and /dev/null differ diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/en.lproj/InfoPlist.strings b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/en.lproj/InfoPlist.strings deleted file mode 100755 index 477b28ff8f86..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/en.lproj/InfoPlist.strings +++ /dev/null @@ -1,2 +0,0 @@ -/* Localized versions of Info.plist keys */ - diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/main.m b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/main.m deleted file mode 100755 index a3cd004f5ef6..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/Example/Animated GIF Example/main.m +++ /dev/null @@ -1,30 +0,0 @@ -// main.m -// -// Copyright (c) 2014 Mattt Thompson (http://mattt.me/) -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -#import - -#import "AppDelegate.h" - -int main(int argc, char * argv[]) { - @autoreleasepool { - return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); - } -} diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/LICENSE b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/LICENSE deleted file mode 100755 index e0e8aebaff2e..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2014 Mattt Thompson (http://mattt.me/) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/README.md b/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/README.md deleted file mode 100755 index c39bd055a403..000000000000 --- a/WordPress/Vendor/AnimatedGIFImageSerialization-0.1.0/README.md +++ /dev/null @@ -1,41 +0,0 @@ -AnimatedGIFImageSerialization -============================= - -`AnimatedGIFImageSerialization` decodes an `UIImage` from [Animated GIFs](http://en.wikipedia.org/wiki/Graphics_Interchange_Format) image data, following the API conventions of Foundation's `NSJSONSerialization` class. - -As it ships with iOS, `UIImage` does not support decoding animated gifs into an animated `UIImage`. But so long as `ANIMATED_GIF_NO_UIIMAGE_INITIALIZER_SWIZZLING` is not `#define`'d, the this library will swizzle the `UIImage` initializers to automatically support animated GIFs. - -## Usage - -### Decoding - -```objective-c -UIImageView *imageView = ...; -imageView.image = [UIImage imageNamed:@"animated.gif"]; -``` - -![Animated GIF](https://raw.githubusercontent.com/mattt/AnimatedGIFImageSerialization/master/Example/Animated%20GIF%20Example/animated.gif) - -### Encoding - -```objective-c -UIImage *image = ...; -NSData *data = [AnimatedGIFImageSerialization animatedGIFDataWithImage:image - duration:1.0 - loopCount:1 - error:nil]; -``` - ---- - -## Contact - -Mattt Thompson - -- http://github.com/mattt -- http://twitter.com/mattt -- m@mattt.me - -## License - -AnimatedGIFImageSerialization is available under the MIT license. See the LICENSE file for more info. diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ar.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ar.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index fc0b9485c396..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ar.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "لا تستطيع العثور على ما تبحث عنه؟"; -"Rate App" = "تقييم التطبيق"; -"We\'re happy to help you!" = "نحن سعداء بمساعدتك!"; -"What's on your mind?" = "ما الذي يدور ببالك؟"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "يؤسفنا سماع ذلك. يُرجى إطلاعنا على المزيد من المعلومات بشأن المشكلة التي تواجهها؟"; -"Remind Later" = "تذكير في وقت لاحق"; -"Your message has been received." = "تم تلقي رسالتك."; -"Message send failure." = "فشل إرسال الرسالة."; -"Hated it" = "لم تعجبني"; -"What\'s your feedback about our customer support?" = "ما تعليقك على دعم العملاء لدينا؟"; -"Take a screenshot on your iPhone" = "خذ لقطة شاشة من جهاز iPhone الخاص بك"; -"Learn how" = "معرفة الطريقة"; -"Take a screenshot on your iPad" = "خذ لقطة شاشة من جهاز iPad الخاص بك"; -"Your email(optional)" = "بريدك الإلكتروني (اختياري)"; -"Name invalid" = "اسم غير صحيح"; -"View Now" = "عرض الآن"; -"SEND ANYWAY" = "إرسال على أية حال"; -"OK" = "حسنًا"; -"Send message" = "إرسال الرسالة"; -"REVIEW" = "مراجعة"; -"Share" = "مشاركة"; -"Close Help" = "إغلاق المساعدة"; -"Loved it" = "أعجبتني"; -"Learn how to" = "تعرّف على كيفية"; -"No FAQs found in this section" = "لم يتم العثور على أسئلة شائعة في هذا القسم"; -"Thanks for contacting us." = "نشكرك على اتصالك بنا."; -"Chat Now" = "تحدث الآن"; -"Buy Now" = "شراء الآن"; -"New Conversation" = "محادثة جديدة"; -"Please check your network connection and try again." = "يُرجى فحص اتصالك بشبكة الإنترنت والمحاولة مرة أخرى."; -"New message from Support" = "رسالة جديدة من الدعم"; -"Question" = "سؤال"; -"Type in a new message" = "ادخل رسالة جديدة"; -"Email (optional)" = "البريد الإلكتروني (اختياري)"; -"Reply" = "رد"; -"CONTACT US" = "اتصل بنا"; -"Email" = "البريد الإلكتروني"; -"Like" = "إعجاب"; -"Sending your message..." = "جارِ إرسال رسالتك..."; -"Tap here if this FAQ was not helpful to you" = "انقر هنا إذا لم تجد هذا السؤال المتداول مفيد لك"; -"Any other feedback? (optional)" = "هل لديك تعليق آخر؟ (اختياري)"; -"You found this helpful." = "ساعدك ذلك."; -"No working Internet connection is found." = "لا يوجد اتصال فعّال بالإنترنت."; -"No messages found." = "لم يتم العثور على رسائل."; -"Please enter a brief description of the issue you are facing." = "يُرجى إدخال وصف مختصر للمشكلة التي تواجهها."; -"Shop Now" = "تسوق الآن"; -"Email invalid" = "البريد الإلكتروني غير صحيح"; -"Did we answer all your questions?" = "هل أجبنا على جميع أسئلتك؟"; -"Close Section" = "إغلاق القسم"; -"Close FAQ" = "إغلاق الأسئلة المتداولة"; -"Close" = "إغلاق"; -"This conversation has ended." = "لقد انتهت المحادثة."; -"Send it anyway" = "إرسال على أية حال"; -"You accepted review request." = "لقد قبلت طلب التقييم."; -"Delete" = "حذف"; -"What else can we help you with?" = "ما الذي نستطيع فعله لمساعدتك أيضًا؟"; -"Tap here if the answer was not helpful to you" = "انقر هنا إذا لم تكن الإجابة مفيدة لك"; -"Service Rating" = "تقييم الخدمة"; -"Your email" = "بريدك الإلكتروني"; -"Could not fetch FAQs" = "لم نتمكن من استعراض الأسئلة الشائعة"; -"Thanks for rating us." = "نشكرك على تقييمك لنا."; -"Download" = "تنزيل"; -"Please enter a valid email" = "يُرجى إدخال عنوان بريد إلكتروني صحيح"; -"Message" = "رسالة"; -"or" = "أو"; -"Decline" = "رفض"; -"No" = "لا"; -"Screenshot could not be sent. Image is too large, try again with another image" = "لم يتم إرسال لقطة الشاشة. الصورة كبيرة للغاية، لذا حاول مرة أخرى مع صورة غيرها"; -"Stars" = "نجوم"; -"Your feedback has been received." = "تم تلقي تعليقك."; -"Dislike" = "عدم إعجاب"; -"Preview" = "معاينة"; -"Book Now" = "حجز الآن"; -"START A NEW CONVERSATION" = "بدء محادثة جديدة"; -"Your Rating" = "تقييمك"; -"No Internet!" = "لا يوجد اتصال بالإنترنت!"; -"You didn't find this helpful." = "لم يساعدك ذلك."; -"Review on the App Store" = "تقديم تقييم على App Store"; -"Open Help" = "فتح المساعدة"; -"Search" = "بحث"; -"Tap here if you found this FAQ helpful" = "انقر هنا إذا وجدت هذا السؤال المتداول مفيدًا"; -"Invalid Entry" = "إدخال غير صحيح"; -"Star" = "نجمة"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "انقر هنا إذا وجدت هذه الإجابة مفيدة"; -"Report a problem" = "إبلاغ عن مشكلة"; -"YES, THANKS!" = "نعم، شكرًا!"; -"Help" = "مساعدة"; -"Was this helpful?" = "هل ساعدك ذلك؟"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "لم يتم إرسال رسالتك. هل تود النقر على \"إعادة المحاولة\"لإرسال هذه الرسالة؟"; -"Suggestions" = "مقترحات"; -"No FAQs found" = "لم يتم العثور على أسئلة شائعة"; -"Done" = "تم"; -"Opening Gallery..." = "جارِ فتح معرض الصور..."; -"You rated the service with" = "لقد قيَّمت الخدمة بـ"; -"Cancel" = "إلغاء"; -"Could not fetch message" = "لم نتمكن من استعراض الرسالة"; -"Loading..." = "جارٍ التحميل..."; -"Read FAQs" = "قراءة الأسئلة المتداولة"; -"Thanks for messaging us!" = "نشكرك على مراسلتنا!"; -"Try Again" = "حاول مرة أخرى"; -"Your Name" = "اسمك"; -"Please provide a name." = "يُرجى تقديم اسم."; -"FAQ" = "الأسئلة الشائعة"; -"Describe your problem" = "صف مشكلتك"; -"How can we help?" = "كيف يمكننا المساعدة؟"; -"Help about" = "مساعدة حول"; -"Send Feedback" = "إرسال تعليق"; -"We could not fetch the required data" = "لم نتمكن من الحصول على البيانات المطلوبة"; -"Name" = "الاسم"; -"Sending failed!" = "فشل الإرسال!"; -"Attach a screenshot of your problem" = "ارفق لقطة شاشة لمشكلتك"; -"Mark as read" = "وضع علامة كمقروء"; -"Yes" = "نعم"; -"Send a new message" = "إرسال رسالة جديدة"; -"Conversation" = "محادثة"; -"Questions that may already have your answer" = "أسئلة قد تتضمن بالفعل الإجابة التي تريدها"; -"Attach a photo" = "إرفاق صورة"; -"Accept" = "قبول"; -"Your reply" = "ردك"; -"Inbox" = "صندوق الوارد"; -"Remove attachment" = "إزالة المرفق"; -"Read FAQ" = "قراءة السؤال المتداول"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "عفوًا! تم إغلاق هذه المحادثة نظرًا لعدم وجود نشاط بها. يُرجى بدء محادثة جديدة مع مندوبينا."; -"%d new messages from Support" = "%d رسالة جديدة من الدعم"; -"Ok, Attach" = "حسنًا، قم بالإرفاق"; -"Send" = "إرسال"; -"Screenshot size should not exceed %.2f MB" = "لا ينبغي للقطة الشاشة أن تزيد عن مساحة %.2f ميجابايت"; -"Information" = "معلومات"; -"Issue ID" = "معرّف المشكلة"; -"Tap to copy" = "النقر لإجراء النسخ"; -"Copied!" = "تم النسخ!"; -"We couldn't find an FAQ with matching ID" = "لم نستطع العثور على سؤال متداول يتطابق مع معرّف."; -"Failed to load screenshot" = "فشل تحميل لقطة الشاشة"; -"Failed to load video" = "فشل تحميل مقطع الفيديو"; -"Failed to load image" = "فشل تحميل الصورة"; -"Hold down your device's power and home buttons at the same time." = "اضغط مع الاستمرار على زر التشغيل وزر الشاشة الرئيسية في الوقت نفسه."; -"Please note that a few devices may have the power button on the top." = "يُرجى ملاحظة أن أزرار التشغيل في بعض الأجهزة توجد في أعلى الجهاز."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "بمجرد الانتهاء، عد مرة أخرى لهذا الحوار وانقر حسنًا، قم بالإرفاق لإرفاق الصورة."; -"Okay" = "موافق"; -"We couldn't find an FAQ section with matching ID" = "لم نستطع العثور على قسم أسئلة متداولة يتطابق مع معرّف"; - -"GIFs are not supported" = "ملفات جيف غير معتمدة"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/bg.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/bg.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 40dd555d75cd..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/bg.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Не можете да намерите това, което търсите?"; -"Rate App" = "Оценете приложението"; -"We\'re happy to help you!" = "За нас е удоволствие да Ви помагаме!"; -"Did we answer all your questions?" = "Отговорихме ли на всички ваши въпроси?"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Съжаляваме да го чуем. Бихте ли ни казали малко повече за проблема ви?"; -"Remind Later" = "Напомняне по-късно"; -"Your message has been received." = "Вашето съобщение беше получено."; -"Message send failure." = "Неуспешно изпращане на съобщение."; -"What\'s your feedback about our customer support?" = "Какво е мнението Ви за нашата клиентска поддръжка?"; -"Take a screenshot on your iPhone" = "Направете скрийншот на своя iPhone"; -"Learn how" = "Научете как"; -"Take a screenshot on your iPad" = "Направете скрийншот на своя iPad"; -"Your email(optional)" = "Вашият имейл адрес (по желание)"; -"Conversation" = "Разговор"; -"View Now" = "Прегледайте сега"; -"SEND ANYWAY" = "ИЗПРАТИ ВЪПРЕКИ ТОВА"; -"OK" = "ОК"; -"Help" = "Помощ"; -"Send message" = "Изпратете съобщението"; -"REVIEW" = "РЕЦЕНЗИЯ"; -"Share" = "Споделяне"; -"Close Help" = "Затворете помощта"; -"Sending your message..." = "Съобщението ви се изпраща..."; -"Learn how to" = "Научете как се прави"; -"No FAQs found in this section" = "В този раздел не са открити ЧЗВ"; -"Thanks for contacting us." = "Благодарим Ви, че се свързахте с нас."; -"Chat Now" = "Разговаряйте в чата сега"; -"Buy Now" = "Купете сега"; -"New Conversation" = "Нов разговор"; -"Please check your network connection and try again." = "Моля, проверете мрежовата връзка и опитайте отново."; -"New message from Support" = "Ново съобщение от Поддръжка"; -"Question" = "Въпрос"; -"Type in a new message" = "Въведете ново съобщение"; -"Email (optional)" = "Имейл (по желание)"; -"Reply" = "Отговор"; -"CONTACT US" = "СВЪРЖЕТЕ СЕ С НАС"; -"Email" = "Имейл"; -"Like" = "Харесва ми"; -"Tap here if this FAQ was not helpful to you" = "Натиснете тук, ако този ЧЗВ не Ви е бил от полза"; -"Any other feedback? (optional)" = "Някакви други коментари? (по желание)"; -"You found this helpful." = "Това ви помогна."; -"No working Internet connection is found." = "Не е намерена работеща интернет връзка."; -"No messages found." = "Не са намерени съобщения."; -"Please enter a brief description of the issue you are facing." = "Моля, въведете кратко описание на проблема, на който сте се натъкнали."; -"Shop Now" = "Пазарувайте сега"; -"Close Section" = "Затворете раздела"; -"Close FAQ" = "Затворете ЧЗВ"; -"Close" = "Затвори"; -"This conversation has ended." = "Този разговор е приключил."; -"Send it anyway" = "Изпратете въпреки това"; -"You accepted review request." = "Вие приехте заявка за предоставяне на мнение."; -"Delete" = "Изтриване"; -"What else can we help you with?" = "С какво друго можем да ви помогнем?"; -"Tap here if the answer was not helpful to you" = "Натиснете тук, ако отговорът не Ви е бил от полза"; -"Service Rating" = "Оценка на услугата"; -"Your email" = "Вашият имейл адрес"; -"Email invalid" = "Невалиден имейл"; -"Could not fetch FAQs" = "ЧЗВ не могат да бъдат извлечени"; -"Thanks for rating us." = "Благодарим ви, че ни дадохте оценка."; -"Download" = "Изтегляне"; -"Please enter a valid email" = "Въведете валиден имейл адрес"; -"Message" = "Съобщение"; -"or" = "или"; -"Decline" = "Отказ"; -"No" = "Не"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Скрийншотът не можа да се изпрати. Изображението е прекалено голямо, опитайте отново с друго изображение"; -"Hated it" = "Ужасна е"; -"Stars" = "Звезди"; -"Your feedback has been received." = "Вашата обратна връзка беше получена."; -"Dislike" = "Не ми харесва"; -"Preview" = "Преглед"; -"Book Now" = "Запазете сега"; -"START A NEW CONVERSATION" = "ЗАПОЧНЕТЕ НОВ РАЗГОВОР"; -"Your Rating" = "Вашата оценка"; -"No Internet!" = "Няма интернет!"; -"Invalid Entry" = "Невалидни данни"; -"Loved it" = "Чудесна е"; -"Review on the App Store" = "Мнение в App Store"; -"Open Help" = "Отворете помощта"; -"Search" = "Търсене"; -"Tap here if you found this FAQ helpful" = "Натиснете тук, ако този ЧЗВ Ви е бил от полза"; -"Star" = "Звезда"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Натиснете тук, ако този отговор Ви е бил от полза"; -"Report a problem" = "Съобщаване за проблем"; -"YES, THANKS!" = "ДА, БЛАГОДАРЯ!"; -"Was this helpful?" = "Това помогна ли ви?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Съобщението ви не беше изпратено. Докоснете \"Повторен опит\", за да опитате отново."; -"Suggestions" = "Предложения"; -"No FAQs found" = "Не са намерени ЧЗВ"; -"Done" = "Готово"; -"Opening Gallery..." = "Галерията се отваря..."; -"You rated the service with" = "Оценихте услугата с"; -"Cancel" = "Отказ"; -"Loading..." = "Зареждане..."; -"Read FAQs" = "Прочетете ЧЗВ"; -"Thanks for messaging us!" = "Благодарим Ви за съобщението!"; -"Try Again" = "Опитайте отново"; -"Send Feedback" = "Изпратете обратна връзка"; -"Your Name" = "Вашето име"; -"Please provide a name." = "Моля, въведете име."; -"FAQ" = "ЧЗВ"; -"Describe your problem" = "Опишете проблема си"; -"How can we help?" = "Как можем да ви помогнем?"; -"Help about" = "Помощ за"; -"We could not fetch the required data" = "Не можахме да извлечем необходимите данни"; -"Name" = "Име"; -"Sending failed!" = "Неуспешно изпращане!"; -"You didn't find this helpful." = "Това не Ви е било от полза."; -"Attach a screenshot of your problem" = "Прикачете екранна снимка на проблема си"; -"Mark as read" = "Отбелязване като прочетено"; -"Name invalid" = "Невалидно име"; -"Yes" = "Да"; -"What's on your mind?" = "Какво мислите?"; -"Send a new message" = "Изпращане на ново съобщение"; -"Questions that may already have your answer" = "Въпроси, които може да са получили вашия отговор"; -"Attach a photo" = "Прикачване на снимка"; -"Accept" = "Приемане"; -"Your reply" = "Вашият отговор"; -"Inbox" = "Входяща кутия"; -"Remove attachment" = "Премахване на прикачен файл"; -"Could not fetch message" = "Съобщението не можа да се извлече"; -"Read FAQ" = "Прочетете ЧЗВ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Съжаляваме! Този разговор беше затворен поради липса на активност. Моля, започнете нов разговор с нашите агенти."; -"%d new messages from Support" = "%d нови съобщения от отдела по поддръжка"; -"Ok, Attach" = "ОК, прикрепи"; -"Send" = "Изпращане"; -"Screenshot size should not exceed %.2f MB" = "Размерът на екранната снимка не може да превишава %.2f MB"; -"Information" = "Информация"; -"Issue ID" = "Идентификатор на проблем"; -"Tap to copy" = "Докоснете, за да копирате"; -"Copied!" = "Копирано!"; -"We couldn't find an FAQ with matching ID" = "Не можахме да открием ЧЗВ със съответното ИД"; -"Failed to load screenshot" = "Неуспешно зареждане на екранна снимка"; -"Failed to load video" = "Неуспешно зареждане на видеоклип"; -"Failed to load image" = "Неуспешно зареждане на изображение"; -"Hold down your device's power and home buttons at the same time." = "Натиснете и задръжте едновременно бутона за включване и бутона за начало на вашето устройство."; -"Please note that a few devices may have the power button on the top." = "Моля, имайте предвид, че при някои устройства бутонът за включване може да не е разположен най-горе."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "След като го направите, върнете се към този разговор и докоснете „ОК, прикачи“, за да прикачите."; -"Okay" = "Добре"; -"We couldn't find an FAQ section with matching ID" = "Не можахме да открием секция от ЧЗВ със съответното ИД"; - -"GIFs are not supported" = "GIF не се поддържат"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/bn.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/bn.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index dbec3cfceb3f..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/bn.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "আপনি যা খুঁজছিলেন তা পাচ্ছেন না?"; -"Rate App" = "অ্যাপ্লিকেশনটিকে রেট দিন"; -"We\'re happy to help you!" = "আমরা আপনাকে সাহায্য করতে পেরে খুশি হলাম!"; -"Did we answer all your questions?" = "আমরা কি আপনার সব প্রশ্নের উত্তর দিয়েছি?"; -"Remind Later" = "পরে মনে করাবেন"; -"Your message has been received." = "আপনার মেসেজ গৃহীত হয়নি।"; -"Message send failure." = "মেসেজ পাঠাতে ব্যর্থ।"; -"What\'s your feedback about our customer support?" = "আমাদের গ্রাহক সহায়তা সম্পর্কে আপনার প্রতিক্রিয়া কী?"; -"Take a screenshot on your iPhone" = "আপনার আইফোনে একটি স্ক্রীনশট নিন"; -"Learn how" = "কিভাবে তা শিখুন"; -"Take a screenshot on your iPad" = "আপনার আইপ্যাডে একটি স্ক্রীনশট নিন"; -"Your email(optional)" = "আপনার ইমেইল (ঐচ্ছিক)"; -"Conversation" = "সংলাপ"; -"View Now" = "এখন দেখুন"; -"SEND ANYWAY" = "যেভাবেই হোক পাঠান"; -"OK" = "ঠিক আছে"; -"Help" = "সাহায্য"; -"Send message" = "বার্তা পাঠান"; -"REVIEW" = "পর্যালোচনা করুন"; -"Share" = "শেয়ার"; -"Close Help" = "Help বন্ধ করুন"; -"Sending your message..." = "আপনার মেসেজ পাঠানো হচ্ছে..."; -"Learn how to" = "শিখুন কিভাবে"; -"No FAQs found in this section" = "কোন প্রায়শই জিজ্ঞাসিত প্রশ্নাবলী এই বিভাগে পাওয়া যায়নি"; -"Thanks for contacting us." = "আমাদের সাথে যোগাযোগ করার জন্য ধন্যবাদ।"; -"Chat Now" = "এখন চ্যাট করুন"; -"Buy Now" = "এখন কিনুন"; -"New Conversation" = "নতুন সংলাপ"; -"Please check your network connection and try again." = "অনুগ্রহ করে আপনার নেটওয়ার্কের সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।"; -"New message from Support" = "সহায়তা থেকে নতুন মেসেজ"; -"Question" = "প্রশ্ন"; -"Type in a new message" = "একটি নতুন বার্তা টাইপ করুন"; -"Email (optional)" = "ইমেল (ঐচ্ছিক)"; -"Reply" = "উত্তর"; -"CONTACT US" = "আমাদের সাথে যোগাযোগ করুন"; -"Email" = "ইমেল"; -"Like" = "পছন্দ"; -"Tap here if this FAQ was not helpful to you" = "এখানে ট্যাপ যদি এই FAQ আপনাকে সহায়ক ছিল না"; -"Any other feedback? (optional)" = "অন্য কোন মতামত? (ঐচ্ছিক)"; -"You found this helpful." = "আপনার কাছে এটি সহায়ক ছিল।"; -"No working Internet connection is found." = "কোন কার্যকর ইন্টারনেট সংযোগ পাওয়া যাচ্ছে না।"; -"No messages found." = "কোনো বার্তা পাওয়া যায়নি।"; -"Please enter a brief description of the issue you are facing." = "অনুগ্রহ করে আপনি যে সমস্যাটির সম্মুখীন হচ্ছেন সেটির একটি ছোট বিবরণ দিন।"; -"Shop Now" = "এখন কিনুন"; -"Close Section" = "সেকশন বন্ধ করুন"; -"Close FAQ" = "FAQ বন্ধ করুন"; -"Close" = "বন্ধ করুন"; -"This conversation has ended." = "এই সংলাপটি শেষ করা হয়েছে।"; -"Send it anyway" = "এটিকে এইভাবেই পাঠান"; -"You accepted review request." = "আপনি রিভিউ’র অনুরোধ গ্রহণ করেছেন।"; -"Delete" = "মুছুন"; -"What else can we help you with?" = "এছাড়া আমরা আপনাকে আর কি নিয়ে সাহায্য করতে পারি?"; -"Tap here if the answer was not helpful to you" = "উত্তরটি সহায়ককারী না হলে এখানে ট্যাপ করুন"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "সেটা জেনে দুঃখিত। অনুগ্রহ করে আপনি যে সমস্যাটির সম্মুখীন হচ্ছেন সেটি সম্পর্কে আমাদেরকে আরেকটু বলবেন?"; -"Service Rating" = "পরিসেবা গুণমান বিচার"; -"Your email" = "আপনার ইমেইল"; -"Email invalid" = "ইমেল অবৈধ"; -"Could not fetch FAQs" = "ফ্যাকস আনা গেল না"; -"Thanks for rating us." = "আমাদের গুণমান বিচার করার জন্য ধন্যবাদ।"; -"Download" = "ডাউনলোড"; -"Please enter a valid email" = "একটি বৈধ ইমেইল এন্টার করুন"; -"Message" = "বার্তা"; -"or" = "অথবা"; -"Decline" = "প্রত্যাখ্যান"; -"No" = "না"; -"Screenshot could not be sent. Image is too large, try again with another image" = "স্ক্রীনশট পাঠানো যায়নি। ছবিটি অত্যন্ত বড়, আরেকটি ছবি দিয়ে চেষ্টা করে দেখুন"; -"Hated it" = "এটি ঘৃণা করি"; -"Stars" = "স্টার"; -"Your feedback has been received." = "আপনার মতামত গৃহীত হয়েছে।"; -"Dislike" = "অপছন্দ"; -"Preview" = "পূর্বদৃশ্য"; -"Book Now" = "এখন বুক করুন"; -"START A NEW CONVERSATION" = "একটি নতুন সংলাপ শুরু করুন"; -"Your Rating" = "আপনার গুণমান বিচার"; -"No Internet!" = "কোন ইন্টারনেট নেই!"; -"Invalid Entry" = "অবৈধ লিখন"; -"Loved it" = "এটি ভালো লেগেছে"; -"Review on the App Store" = "App Store-এ রিভিউ দিন"; -"Open Help" = "Help ওপেন করুন"; -"Search" = "সার্চ"; -"Tap here if you found this FAQ helpful" = "FAQ সহায়ককারী হলে এখানে ট্যাপ করুন"; -"Star" = "স্টার"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "উত্তরটি সহায়ককারী হলে এখানে ট্যাপ করুন"; -"Report a problem" = "একটি সমস্যার প্রতিবেদন লিখুন"; -"YES, THANKS!" = "হ্যাঁ, ধন্যবাদ!"; -"Was this helpful?" = "এটি কি সহায়ক ছিল?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "আপনার মেসেজ পাঠানো হয়নি।এই মেসেজটি পাঠাতে \"আবার চেষ্টা করুন\" ট্যাপ করবেন?"; -"Suggestions" = "প্রস্তাব"; -"No FAQs found" = "কোন ফ্যাক খুঁজে পাওয়া যায়নি"; -"Done" = "করা হয়েছে"; -"Opening Gallery..." = "গ্যালারী খুলছে..."; -"You rated the service with" = "আপনি পরিষেবাটিকে রেট দিয়েছেন"; -"Cancel" = "বাতিল"; -"Loading..." = "লোড হচ্ছে..."; -"Read FAQs" = "FAQ গুলি পড়ুন"; -"Thanks for messaging us!" = "আমাদেরকে বার্তা পাঠানোর জন্য ধন্যবাদ!"; -"Try Again" = "পুনরায় চেষ্টা করুন"; -"Send Feedback" = "মতামত পাঠান"; -"Your Name" = "তোমার নাম"; -"Please provide a name." = "অনুগ্রহ করে একটি নাম দিন"; -"FAQ" = "ফ্যাক(বারংবার জিজ্ঞাসিত প্রশ্ন)"; -"Describe your problem" = "আপনার সমস্যাটি বর্ণনা করে বলুন"; -"How can we help?" = "আমরা কিভাবে সাহায্য করতে পারি?"; -"Help about" = "Help সম্পর্কিত"; -"We could not fetch the required data" = "আমরা প্রয়োজনীয় তথ্য আনতে পারিনি"; -"Name" = "নাম"; -"Sending failed!" = "পাঠাতে ব্যর্থ!"; -"You didn't find this helpful." = "আপনার এটিকে সহায়ককারী বলে মনে হয়নি"; -"Attach a screenshot of your problem" = "আপনার সমস্যার একটি স্ক্রিনশট সংযুক্ত করুন"; -"Mark as read" = "পঠিত হিসেবে চিহ্নিত করুন"; -"Name invalid" = "নাম অবৈধ"; -"Yes" = "হাঁ"; -"What's on your mind?" = "আপনার মনে কি চলছে?"; -"Send a new message" = "একটি নতুন বার্তা পাঠান"; -"Questions that may already have your answer" = "প্রশ্ন যাতে ইতিমধ্যেই আপনার উত্তর লুকিয়ে রয়েছে"; -"Attach a photo" = "একটি ফটো সংযুক্ত করুন"; -"Accept" = "গ্রহন"; -"Your reply" = "আপনার উত্তর"; -"Inbox" = "ইনবক্স"; -"Remove attachment" = "অ্যাটাচমেন্ট অপসারণ"; -"Could not fetch message" = "বার্তাকে আনা যায়নি"; -"Read FAQ" = "FAQ পড়ুন"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "দুঃখিত! নিষ্ক্রিয়তার কারণে এই কথোপকথনটি বন্ধ করে দেওয়া হয়েছে। অনুগ্রহ করে আমাদের এজেন্টদের সঙ্গে একটি নতুন কথোপকথন শুরু করুন।"; -"%d new messages from Support" = "%d সাপোর্ট থেকে আসা নতুন মেসেজগুলি"; -"Ok, Attach" = "ঠিক আছে, সংযুক্ত করুন"; -"Send" = "পাঠান"; -"Screenshot size should not exceed %.2f MB" = "স্ক্রিনশটের সাইজ %.2f MB-এর বেশী রাখা যাবে না"; -"Information" = "তথ্য"; -"Issue ID" = "সমস্যা আইডি"; -"Tap to copy" = "কপি করতে ট্যাপ করুন"; -"Copied!" = "কপি করা হয়েছে"; -"We couldn't find an FAQ with matching ID" = "আমরা এই আইডি সংশ্লিষ্ট কোনো FAQ খুঁজে পাইনি"; -"Failed to load screenshot" = "স্ক্রিনশট লোড ব্যর্থ হযেছে"; -"Failed to load video" = "ভিডিও লোড করতে ব্যর্থ হযেছে"; -"Failed to load image" = "ছবি লোড করতে ব্যর্থ হযেছে"; -"Hold down your device's power and home buttons at the same time." = "একই সময়ে আপনার ডিভাইসের পাওয়ার এবং হোম বাটন চেপে ধরে রাখুন।"; -"Please note that a few devices may have the power button on the top." = "অনুগ্রহ করে মনে রাখবেন পাওয়ার বাটন কয়েকটি ডিভাইসের উপরের দিকে থাকতে পারে।"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "একবার সম্পন্ন হয়ে গেলে, এই কথোপকথনে ফিরে আসুন এবং এটিকে সংযুক্ত করতে \"ক আছে, সংযুক্ত করুন\"-এ ট্যাপ করুন।"; -"Okay" = "ঠিক আছে"; -"We couldn't find an FAQ section with matching ID" = "আমরা এই আইডি সংশ্লিষ্ট কোনো FAQ বিভাগ খুঁজে পাইনি"; - -"GIFs are not supported" = "GIF সমর্থিত হয় না"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ca.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ca.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 2d3eb28771d2..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ca.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "No trobes el que busques?"; -"Rate App" = "Avalua l'aplicació"; -"We\'re happy to help you!" = "És un plaer ajudar-te."; -"Did we answer all your questions?" = "Hem respost totes les teves preguntes?"; -"Remind Later" = "Recorda-m'ho més tard"; -"Your message has been received." = "Hem rebut el teu missatge."; -"Message send failure." = "Error d'enviament de missatge."; -"What\'s your feedback about our customer support?" = "Quins suggeriments tens sobre el Servei d'Atenció?"; -"Take a screenshot on your iPhone" = "Fes una foto de la pantalla amb el teu iPhone"; -"Learn how" = "Com fer-ho"; -"Take a screenshot on your iPad" = "Fes una foto de la pantalla amb el teu iPad"; -"Your email(optional)" = "La teva adreça electrònica (opcional)"; -"Conversation" = "Conversa"; -"View Now" = "Mostra ara"; -"SEND ANYWAY" = "ENVIAR DE TOTES MANERES"; -"OK" = "D'acord"; -"Help" = "Ajuda"; -"Send message" = "Envia el missatge"; -"REVIEW" = "REVISAR"; -"Share" = "Comparteix"; -"Close Help" = "Tanca l'ajuda"; -"Sending your message..." = "Enviant el teu missatge..."; -"Learn how to" = "Més informació sobre com"; -"No FAQs found in this section" = "No s'han trobat preguntes freqüents en aquesta secció."; -"Thanks for contacting us." = "Gràcies per posar-te en contacte amb nosaltres."; -"Chat Now" = "Xateja ara"; -"Buy Now" = "Compra ara"; -"New Conversation" = "Conversa"; -"Please check your network connection and try again." = "Si us plau, revisa la teva connexió a Internet i tornar-ho a intentar."; -"New message from Support" = "Nou missatge del Servei d'Atenció"; -"Question" = "Pregunta"; -"Type in a new message" = "Escriu en un missatge nou"; -"Email (optional)" = "Adreça electrònica (opcional)"; -"Reply" = "Respon"; -"CONTACT US" = "POSA'T EN CONTACTE AMB NOSALTRES"; -"Email" = "Adreça electrònica"; -"Like" = "M'agrada"; -"Tap here if this FAQ was not helpful to you" = "Toca aquí si la pregunta no t'ha servit d'ajuda"; -"Any other feedback? (optional)" = "Vols fer cap altre comentari? (opcional)"; -"You found this helpful." = "T'ha ajudat."; -"No working Internet connection is found." = "No s'ha trobat cap connexió Internet activa."; -"No messages found." = "No s'ha trobat cap missatge."; -"Please enter a brief description of the issue you are facing." = "Si us plau, introdueix una breu descripció del problema que estàs tenint."; -"Shop Now" = "Compra ara"; -"Close Section" = "Tanca la secció"; -"Close FAQ" = "Tanca les preguntes freqüents"; -"Close" = "Tancar"; -"This conversation has ended." = "Aquesta conversa ha finalitzat."; -"Send it anyway" = "Envia de totes maneres"; -"You accepted review request." = "Has acceptat la sol·licitud per escriure una ressenya."; -"Delete" = "Suprimeix"; -"What else can we help you with?" = "Et podem ajudar amb alguna cosa més?"; -"Tap here if the answer was not helpful to you" = "Toca aquí si la resposta no t'ha servit d'ajuda"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Ens sap greu. Ens pots explicar breument quin problema estàs tenint?"; -"Service Rating" = "Avaluació del Servei"; -"Your email" = "La teva adreça electrònica"; -"Email invalid" = "Correu electrònic invàlid"; -"Could not fetch FAQs" = "No s'han trobat Preguntes freqüents"; -"Thanks for rating us." = "Gràcies per enviar-nos la teva valoració."; -"Download" = "Baixa"; -"Please enter a valid email" = "Introdueix una adreça electrònica vàlida"; -"Message" = "Missatge"; -"or" = "o"; -"Decline" = "Rebutja"; -"No" = "No"; -"Screenshot could not be sent. Image is too large, try again with another image" = "La fotografia de pantalla no s'ha enviat. La imatge pesa massa, torna a intentar-ho amb una altra imatge"; -"Hated it" = "Gens satisfet"; -"Stars" = "Estrelles"; -"Your feedback has been received." = "Hem rebut el teu comentari."; -"Dislike" = "No m'agrada"; -"Preview" = "Vista prèvia"; -"Book Now" = "Reserva ara"; -"START A NEW CONVERSATION" = "COMENÇAR UNA NOVA CONVERSA"; -"Your Rating" = "La teva Valoració"; -"No Internet!" = "Sense Internet!"; -"Invalid Entry" = "Entrada invàlida"; -"Loved it" = "Molt satisfet"; -"Review on the App Store" = "Escriu una ressenya a l'App Store"; -"Open Help" = "Obre l'ajuda"; -"Search" = "Cerca"; -"Tap here if you found this FAQ helpful" = "Toca aquí si la pregunta t'ha resultat útil"; -"Star" = "Estrella"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Toca aquí si la resposta t'ha resultat útil"; -"Report a problem" = "Informa sobre un problema"; -"YES, THANKS!" = "SÍ, GRÀCIES!"; -"Was this helpful?" = "Ha estat d'ajuda?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "El teu missatge no s'ha enviat. Fes clic a \"Intentar de nou\" per enviar aquest missatge?"; -"Suggestions" = "Suggeriments"; -"No FAQs found" = "No s'han trobat Preguntes Freqüents"; -"Done" = "Fet"; -"Opening Gallery..." = "Obrint Fotos..."; -"You rated the service with" = "Has puntuat el servei amb"; -"Cancel" = "Cancel·lar"; -"Loading..." = "S'està carregant…"; -"Read FAQs" = "Consulta les preguntes freqüents"; -"Thanks for messaging us!" = "Gràcies per enviar-nos aquest missatge."; -"Try Again" = "Torna-ho a provar."; -"Send Feedback" = "Enviar Comentari"; -"Your Name" = "Nom"; -"Please provide a name." = "Si us plau, introdueix un nom."; -"FAQ" = "Preguntes freqüents"; -"Describe your problem" = "Descriu el teu problema"; -"How can we help?" = "Com et podem ajudar?"; -"Help about" = "Ajuda sobre"; -"We could not fetch the required data" = "No hem trobat les dades necessàries"; -"Name" = "Nom"; -"Sending failed!" = "Error d'enviament!"; -"You didn't find this helpful." = "No t'ha servit d'ajuda."; -"Attach a screenshot of your problem" = "Adjunta una captura de pantalla del problema"; -"Mark as read" = "Marca'l com a llegit"; -"Name invalid" = "Nom invàlid"; -"Yes" = "Sí"; -"What's on your mind?" = "Què estàs pensant?"; -"Send a new message" = "Envia un missatge nou"; -"Questions that may already have your answer" = "Preguntes que ja et poden donar la teva resposta"; -"Attach a photo" = "Adjunta una foto"; -"Accept" = "Accepta"; -"Your reply" = "La teva resposta"; -"Inbox" = "Safata d'entrada"; -"Remove attachment" = "Suprimeix el fitxer adjunt"; -"Could not fetch message" = "No hem trobat el missatge"; -"Read FAQ" = "Consulta les preguntes freqüents"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Aquesta conversa s'ha tancat perquè estava inactiva. Comença una nova conversa amb els nostres agents."; -"%d new messages from Support" = "%d missatges nous del Servei d'Atenció"; -"Ok, Attach" = "D'acord, adjunta"; -"Send" = "Envia"; -"Screenshot size should not exceed %.2f MB" = "La mida de la captura de la pantalla no pot tenir més de %.2f MB"; -"Information" = "Informació"; -"Issue ID" = "Identificador del problema"; -"Tap to copy" = "Toca per copiar-ho"; -"Copied!" = "S'ha copiat correctament."; -"We couldn't find an FAQ with matching ID" = "No hem trobat cap pregunta amb aquest identificador."; -"Failed to load screenshot" = "No s'ha pogut carregar la captura de pantalla."; -"Failed to load video" = "No s'ha pogut carregar el vídeo."; -"Failed to load image" = "No s'ha pogut carregar la imatge."; -"Hold down your device's power and home buttons at the same time." = "Mantén premuts alhora el botó d'engegada i el botó d'inici del teu dispositiu."; -"Please note that a few devices may have the power button on the top." = "És possible que el botó d'engegada d'alguns dispositius sigui a la part superior."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Un cop fet, torna a aquesta conversa i toca \"D'acord, adjunta\" per adjuntar-la."; -"Okay" = "D'acord"; -"We couldn't find an FAQ section with matching ID" = "No hem trobat cap secció de preguntes freqüents amb aquest identificador."; - -"GIFs are not supported" = "Els GIF no són compatibles"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/cs.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/cs.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index aeabbbd8050a..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/cs.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Nemůžete najít, co hledáte?"; -"Rate App" = "Ohodnotit aplikaci"; -"We\'re happy to help you!" = "Jsme rádi, že jsme vám mohli pomoci!"; -"Did we answer all your questions?" = "Zodpověděli jsme všechny vaše dotazy?"; -"Remind Later" = "Připomenout později"; -"Your message has been received." = "Vaše zpráva byla přijata."; -"Message send failure." = "Odeslání zprávy se nezdařilo."; -"What\'s your feedback about our customer support?" = "Jakou zpětnou vazbu byste poskytli naší zákaznické podpoře?"; -"Take a screenshot on your iPhone" = "Pořiďte na svém iPhonu screenshot"; -"Learn how" = "Zjistit jak"; -"Take a screenshot on your iPad" = "Pořiďte na svém iPadu screenshot"; -"Your email(optional)" = "Váš e-mail (volitelný)"; -"Conversation" = "Konverzace"; -"View Now" = "Zobrazit hned"; -"SEND ANYWAY" = "PŘESTO ODESLAT"; -"OK" = "OK"; -"Help" = "Nápověda"; -"Send message" = "Odeslat zprávu"; -"REVIEW" = "HODNOCENÍ"; -"Share" = "Sdílet"; -"Close Help" = "Zavřít nápovědu"; -"Sending your message..." = "Vaše zpráva se odesílá..."; -"Learn how to" = "Zjistěte jak"; -"No FAQs found in this section" = "V této části nebyly nalezeny žádné časté otázky"; -"Thanks for contacting us." = "Děkujeme, že jste se na nás obrátili."; -"Chat Now" = "Zahájit chat hned"; -"Buy Now" = "Koupit hned"; -"New Conversation" = "Nová konverzace"; -"Please check your network connection and try again." = "Ověřte připojení k síti a zkuste to znovu."; -"New message from Support" = "Nová zpráva z podpory"; -"Question" = "Otázka"; -"Type in a new message" = "Napište novou zprávu"; -"Email (optional)" = "E-mail (volitelné)"; -"Reply" = "Odpovědět"; -"CONTACT US" = "KONTAKTUJE NÁS"; -"Email" = "E-mail"; -"Like" = "To se mi líbí"; -"Tap here if this FAQ was not helpful to you" = "Pokud tyto často kladené dotazy nebyly užitečné, klepněte sem"; -"Any other feedback? (optional)" = "Nějaká další zpětná vazba? (volitelné)"; -"You found this helpful." = "Informace jste vyhodnotili jako užitečné."; -"No working Internet connection is found." = "Nenalezeno funkční připojení k internetu."; -"No messages found." = "Žádné zprávy nenalezeny."; -"Please enter a brief description of the issue you are facing." = "Zadejte stručný popis problému, který řešíte."; -"Shop Now" = "Nakupovat hned"; -"Close Section" = "Zavřít sekci"; -"Close FAQ" = "Zavřít často kladené dotazy"; -"Close" = "Zavřít"; -"This conversation has ended." = "Tato konverzace skončila."; -"Send it anyway" = "Přesto odeslat"; -"You accepted review request." = "Přijali jste žádost o hodnocení."; -"Delete" = "Odstranit"; -"What else can we help you with?" = "S čím vám ještě můžeme pomoci?"; -"Tap here if the answer was not helpful to you" = "Pokud odpověď nebyla užitečná, klepněte sem"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "To je nám líto. Můžete nám o aktuálním problému říci více?"; -"Service Rating" = "Hodnocení služby"; -"Your email" = "Váš e-mail"; -"Email invalid" = "E-mail není platný"; -"Could not fetch FAQs" = "Nebylo možné vyvolat často kladené dotazy"; -"Thanks for rating us." = "Děkujeme za vaše hodnocení."; -"Download" = "Stáhnout"; -"Please enter a valid email" = "Prosím, zadejte platný e-mail"; -"Message" = "Zpráva"; -"or" = "nebo"; -"Decline" = "Odmítnout"; -"No" = "Ne"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Screenshot se nepodařilo odeslat. Obrázek je příliš velký, zkuste opakovat s jiným"; -"Hated it" = "Byla příšerná"; -"Stars" = "Hvězdičky"; -"Your feedback has been received." = "Vaše zpětná vazba byla přijata."; -"Dislike" = "To se mi nelíbí"; -"Preview" = "Náhled"; -"Book Now" = "Rezervovat hned"; -"START A NEW CONVERSATION" = "ZAHÁJIT NOVOU KONVERZACI"; -"Your Rating" = "Vaše hodnocení"; -"No Internet!" = "Internet není k dispozici!"; -"Invalid Entry" = "Neplatné zadání"; -"Loved it" = "Byla skvělá"; -"Review on the App Store" = "Ohodnotit v App Store"; -"Open Help" = "Otevřít nápovědu"; -"Search" = "Vyhledat"; -"Tap here if you found this FAQ helpful" = "Pokud tyto často kladené dotazy byly užitečné, klepněte sem"; -"Star" = "Hvězdička"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Pokud odpověď byla užitečná, klepněte sem"; -"Report a problem" = "Nahlásit problém"; -"YES, THANKS!" = "ANO, DÍKY!"; -"Was this helpful?" = "Byly tyto informace užitečné?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Vaše zpráva nebyla odeslána. Klepněte na \"Zkusit znovu\" odeslat tuto zprávu?"; -"Suggestions" = "Návrhy"; -"No FAQs found" = "Často kladené dotazy nenalezeny"; -"Done" = "Hotovo"; -"Opening Gallery..." = "Otevírání galerie..."; -"You rated the service with" = "Službu jste hodnotili takto"; -"Cancel" = "Zrušit"; -"Loading..." = "Nahrávání..."; -"Read FAQs" = "Přečíst často kladené dotazy"; -"Thanks for messaging us!" = "Děkujeme za vaši zprávu!"; -"Try Again" = "Zkusit znovu"; -"Send Feedback" = "Odeslat zpětnou vazbu"; -"Your Name" = "Vaše jméno"; -"Please provide a name." = "Zadejte jméno."; -"FAQ" = "Často kladené dotazy"; -"Describe your problem" = "Popište váš problém"; -"How can we help?" = "Jak můžeme pomoci?"; -"Help about" = "Nápověda k tématu"; -"We could not fetch the required data" = "Nepodařilo se vyvolat požadované údaje"; -"Name" = "Jméno"; -"Sending failed!" = "Odeslání se nezdařilo!"; -"You didn't find this helpful." = "Informace jste vyhodnotili jako neužitečné."; -"Attach a screenshot of your problem" = "Připojte screenshot vašeho problému"; -"Mark as read" = "Označit jako přečtené"; -"Name invalid" = "Neplatné jméno"; -"Yes" = "Ano"; -"What's on your mind?" = "Co máte na mysli?"; -"Send a new message" = "Odeslat novou zprávu"; -"Questions that may already have your answer" = "Otázky, na které již možná existuje odpověď"; -"Attach a photo" = "Připojit fotografii"; -"Accept" = "Přijmout"; -"Your reply" = "Vaše odpověď"; -"Inbox" = "Doručené"; -"Remove attachment" = "Odebrat přílohu"; -"Could not fetch message" = "Nebylo možné vyvolat zprávu"; -"Read FAQ" = "Přečíst často kladené dotazy"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Omlouváme se! Tato konverzace byla uzavřena z důvodu nečinnosti. Prosím, zahajte novou konverzaci s našimi agenty."; -"%d new messages from Support" = "Počet nových zpráv z podpory: %d"; -"Ok, Attach" = "OK, připojit"; -"Send" = "Odeslat"; -"Screenshot size should not exceed %.2f MB" = "Velikost screenshotu by neměla přesáhnout %.2f MB"; -"Information" = "Informace"; -"Issue ID" = "ID problému"; -"Tap to copy" = "Klepněte pro kopírování"; -"Copied!" = "Zkopírováno!"; -"We couldn't find an FAQ with matching ID" = "Nepodařilo se nám najít často kladené otázky s tímto ID"; -"Failed to load screenshot" = "Načítání screenshotu se nezdařilo"; -"Failed to load video" = "Načítání videa nezdařilo"; -"Failed to load image" = "Načítání obrázku nezdařilo"; -"Hold down your device's power and home buttons at the same time." = "Přidržte na svém zařízení zároveň tlačítko vypnout/zapnout a domů."; -"Please note that a few devices may have the power button on the top." = "Upozorňujeme, že některá zařízení mohou mít tlačítko vypnout/zapnout v horní části."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Jakmile tak učiníte, vraťte se k této konverzaci a pro připojení klepněte na možnost „OK, připojit“."; -"Okay" = "OK"; -"We couldn't find an FAQ section with matching ID" = "Nepodařilo se nám najít oddíl s často kladenými otázkami s tímto ID"; - -"GIFs are not supported" = "GIF nejsou podporovány"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/da.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/da.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 0a012186aac2..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/da.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Kan du ikke finde det, du leder efter?"; -"Rate App" = "Bedøm app"; -"We\'re happy to help you!" = "Vi vil meget gerne hjælpe dig!"; -"Did we answer all your questions?" = "Har vi svaret på alle dine spørgsmål?"; -"Remind Later" = "Påmind mig senere"; -"Your message has been received." = "Din besked er blevet modtaget."; -"Message send failure." = "Beskeden blev ikke sendt."; -"What\'s your feedback about our customer support?" = "Hvad synes du om vores kundesupport?"; -"Take a screenshot on your iPhone" = "Tag et skærmbillede på din iPhone"; -"Learn how" = "Se hvordan"; -"Take a screenshot on your iPad" = "Tag et skræmbillede på din iPad"; -"Your email(optional)" = "Din e-mailadresse (valgfrit)"; -"Conversation" = "Besked"; -"View Now" = "Vis nu"; -"SEND ANYWAY" = "SEND ALLIGEVEL"; -"OK" = "OK"; -"Help" = "Hjælp"; -"Send message" = "Send besked"; -"REVIEW" = "BEDØM"; -"Share" = "Del"; -"Close Help" = "Luk Hjælp"; -"Sending your message..." = "Sender din besked ..."; -"Learn how to" = "Se hvordan"; -"No FAQs found in this section" = "Ingen OSS fundet i denne sektion"; -"Thanks for contacting us." = "Tak fordi du kontaktede os."; -"Chat Now" = "Chat nu"; -"Buy Now" = "Køb nu"; -"New Conversation" = "Ny besked"; -"Please check your network connection and try again." = "Tjek din internetforbindelse, og prøv igen."; -"New message from Support" = "Ny besked fra support"; -"Question" = "Spørgsmål"; -"Type in a new message" = "Indtast en ny besked"; -"Email (optional)" = "E-mail (valgfrit)"; -"Reply" = "Svar"; -"CONTACT US" = "KONTAKT OS"; -"Email" = "E-mail"; -"Like" = "Synes om"; -"Tap here if this FAQ was not helpful to you" = "Tryk her, hvis dette OSS var unyttigt"; -"Any other feedback? (optional)" = "Er der andet, du gerne vil fortælle os? (valgfrit)"; -"You found this helpful." = "Du kunne bruge denne information."; -"No working Internet connection is found." = "En internetforbindelse blev ikke fundet."; -"No messages found." = "Ingen beskeder fundet."; -"Please enter a brief description of the issue you are facing." = "Indtast en kort beskrivelse af dit problem."; -"Shop Now" = "Shop nu"; -"Close Section" = "Luk sektion"; -"Close FAQ" = "Luk OSS"; -"Close" = "Luk"; -"This conversation has ended." = "Denne besked er slut."; -"Send it anyway" = "Send alligevel"; -"You accepted review request." = "Anmodning om bedømmelse accepteret"; -"Delete" = "Slet"; -"What else can we help you with?" = "Hvad kan vi ellers hjælpe dig med?"; -"Tap here if the answer was not helpful to you" = "Tryk her, hvis svaret var unyttigt"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Det er vi kede af at høre. Kunne du tænke dig at fortælle os lidt mere om dit problem?"; -"Service Rating" = "Servicebedømmelse"; -"Your email" = "Din e-mailadresse"; -"Email invalid" = "Ugyldig e-mailadresse"; -"Could not fetch FAQs" = "OSS blev ikke indlæst"; -"Thanks for rating us." = "Tak for din bedømmelse."; -"Download" = "Download"; -"Please enter a valid email" = "Indtast en gyldig e-mailadresse"; -"Message" = "Besked"; -"or" = "eller"; -"Decline" = "Afslå"; -"No" = "Nej"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Skærmbilledet blev ikke sendt. Billedet er for stort. Prøv igen med et andet billede"; -"Hated it" = "Hader den"; -"Stars" = "stjerner"; -"Your feedback has been received." = "Vi har modtaget din feedback."; -"Dislike" = "Synes ikke om"; -"Preview" = "Forhåndsvisning"; -"Book Now" = "Bestil nu"; -"START A NEW CONVERSATION" = "START EN NY BESKED"; -"Your Rating" = "Din bedømmelse"; -"No Internet!" = "Ingen internetforbindelse!"; -"Invalid Entry" = "Ugyldig tekst"; -"Loved it" = "Elsker den"; -"Review on the App Store" = "Bedøm i App Store"; -"Open Help" = "Åbn Hjælp"; -"Search" = "Søg"; -"Tap here if you found this FAQ helpful" = "Tryk her, hvis dette OSS var nyttigt"; -"Star" = "stjerne"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Tryk her, hvis svaret var nyttigt"; -"Report a problem" = "Rapportér et problem"; -"YES, THANKS!" = "JA TAK!"; -"Was this helpful?" = "Kunne du bruge denne information?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Din besked blev ikke sendt. Tryk på \"Prøv igen\" for at sende denne besked."; -"Suggestions" = "Forslag"; -"No FAQs found" = "Ingen OSS fundet"; -"Done" = "Udført"; -"Opening Gallery..." = "Åbn galleriet ..."; -"You rated the service with" = "Du bedømte tjenesten med"; -"Cancel" = "Annuller"; -"Loading..." = "Indlæser ..."; -"Read FAQs" = "Læs OSS"; -"Thanks for messaging us!" = "Tak for din besked!"; -"Try Again" = "Prøv igen"; -"Send Feedback" = "Send feedback"; -"Your Name" = "Dit navn"; -"Please provide a name." = "Vælg et andet navn."; -"FAQ" = "OSS"; -"Describe your problem" = "Beskriv dit problem"; -"How can we help?" = "Hvordan kan vi hjælpe dig?"; -"Help about" = "Hjælp om"; -"We could not fetch the required data" = "De nødvendige oplysninger blev ikke indlæst"; -"Name" = "Navn"; -"Sending failed!" = "Der opstod en fejl!"; -"You didn't find this helpful." = "Du kunne ikke bruge denne information."; -"Attach a screenshot of your problem" = "Vedhæft et skærmbillede af dit problem"; -"Mark as read" = "Markér som læst"; -"Name invalid" = "Ugyldigt navn"; -"Yes" = "Ja"; -"What's on your mind?" = "Hvad bekymrer dig?"; -"Send a new message" = "Send en ny besked"; -"Questions that may already have your answer" = "Spørgsmål, der måske allerede er blevet besvaret"; -"Attach a photo" = "Vedhæft et foto"; -"Accept" = "Acceptér"; -"Your reply" = "Dit svar"; -"Inbox" = "Indbakke"; -"Remove attachment" = "Fjern vedhæftet fil"; -"Could not fetch message" = "Beskeden kunne ikke hentes"; -"Read FAQ" = "Læs OSS"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Vi beklager! Denne samtale blev lukket pga. inaktivitet. Start en ny samtale med vores agenter."; -"%d new messages from Support" = "%d nye beskeder fra support"; -"Ok, Attach" = "Ok, vedhæft"; -"Send" = "Send"; -"Screenshot size should not exceed %.2f MB" = "Skærmbilledets størrelse må ikke overskride %.2f MB"; -"Information" = "Oplysninger"; -"Issue ID" = "Problem-id"; -"Tap to copy" = "Tryk for at kopiere"; -"Copied!" = "Kopieret!"; -"We couldn't find an FAQ with matching ID" = "Der blev ikke fundet et OSS med et matchende ID"; -"Failed to load screenshot" = "Skærmbilledet blev ikke indlæst"; -"Failed to load video" = "Videoen blev ikke indlæst"; -"Failed to load image" = "Beskeden blev ikke indlæst"; -"Hold down your device's power and home buttons at the same time." = "Hold tænd/sluk- og hjem-knappen nede samtidig."; -"Please note that a few devices may have the power button on the top." = "Vær opmærksom på, at på nogle få enheder sidder tænd/sluk-knappen øverst"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Når dette er gjort, skal du vende tilbage til denne skærm og trykke på \"OK, vedhæft\" for at vedhæfte det."; -"Okay" = "Okay"; -"We couldn't find an FAQ section with matching ID" = "Der blev ikke fundet en OSS-sektion med et matchende ID"; - -"GIFs are not supported" = "GIF'er understøttes ikke"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/de.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/de.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 434d6602e88e..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/de.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Nicht gefunden, wonach Sie gesucht haben?"; -"Rate App" = "App bewerten"; -"We\'re happy to help you!" = "Wir helfen gern!"; -"Did we answer all your questions?" = "Haben wir all ihre Fragen beantwortet?"; -"Remind Later" = "Später erinnern"; -"Your message has been received." = "Wir haben Ihre Nachricht erhalten."; -"Message send failure." = "Fehler beim Senden von Nachricht."; -"What\'s your feedback about our customer support?" = "Wie gefällt Ihnen unser Kundensupport?"; -"Take a screenshot on your iPhone" = "Screenshot auf iPhone erstellen"; -"Learn how" = "Mehr erfahren"; -"Take a screenshot on your iPad" = "Screenshot auf iPad erstellen"; -"Your email(optional)" = "Ihre E-Mail (optional)"; -"Conversation" = "Konversation"; -"View Now" = "Jetzt ansehen"; -"SEND ANYWAY" = "TROTZDEM SENDEN"; -"OK" = "OK"; -"Help" = "Hilfe"; -"Send message" = "Nachricht senden"; -"REVIEW" = "REZENSION"; -"Share" = "Teilen"; -"Close Help" = "Hilfe schließen"; -"Sending your message..." = "Nachricht wird gesendet ..."; -"Learn how to" = "Mehr erfahren zu:"; -"No FAQs found in this section" = "Keine FAQs in diesem Abschnitt gefunden"; -"Thanks for contacting us." = "Danke, dass Sie uns kontaktieren."; -"Chat Now" = "Jetzt chatten"; -"Buy Now" = "Jetzt kaufen"; -"New Conversation" = "Neue Konversation"; -"Please check your network connection and try again." = "Bitte überprüfen Sie Ihre Netzwerkverbindung und versuchen Sie es erneut."; -"New message from Support" = "Neue Support-Nachricht"; -"Question" = "Frage"; -"Type in a new message" = "Geben Sie eine neue Nachricht ein."; -"Email (optional)" = "E-Mail-Adresse (optional)"; -"Reply" = "Antworten"; -"CONTACT US" = "KONTAKT"; -"Email" = "E-Mail-Adresse"; -"Like" = "Gefällt mir"; -"Tap here if this FAQ was not helpful to you" = "Tippen Sie hier, falls die FAQ nicht hilfreich war."; -"Any other feedback? (optional)" = "Möchten Sie uns noch mehr mitteilen? (optional)"; -"You found this helpful." = "Es hat mir geholfen."; -"No working Internet connection is found." = "Keine aktive Internetverbindung gefunden."; -"No messages found." = "Keine Nachrichten gefunden."; -"Please enter a brief description of the issue you are facing." = "Bitte geben Sie eine kurze Beschreibung Ihres Problems ein."; -"Shop Now" = "Jetzt einkaufen"; -"Close Section" = "Bereich schließen"; -"Close FAQ" = "FAQ schließen"; -"Close" = "Schließen"; -"This conversation has ended." = "Diese Konversation ist beendet."; -"Send it anyway" = "Trotzdem senden"; -"You accepted review request." = "Sie haben die Rezensionsanfrage akzeptiert."; -"Delete" = "Löschen"; -"What else can we help you with?" = "Womit können wir Ihnen noch helfen?"; -"Tap here if the answer was not helpful to you" = "Tippen Sie hier, falls die Antwort nicht hilfreich war."; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Würden Sie uns bitte mehr über das Problem erzählen, das bei Ihnen aufgetreten ist?"; -"Service Rating" = "Servicebewertung"; -"Your email" = "Ihre E-Mail"; -"Email invalid" = "Ungültige E-Mail-Adresse"; -"Could not fetch FAQs" = "Abrufen von FAQs gescheitert"; -"Thanks for rating us." = "Vielen Dank für Ihre Bewertung."; -"Download" = "Download"; -"Please enter a valid email" = "Bitte geben Sie eine gültige E-Mail-Adresse ein."; -"Message" = "Nachricht"; -"or" = "oder"; -"Decline" = "Ablehnen"; -"No" = "Nein"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Senden von Screenshot fehlgeschlagen: Bild ist zu groß. Bitte noch einmal mit einem anderen Bild versuchen."; -"Hated it" = "Gefiel mir nicht"; -"Stars" = "Sterne"; -"Your feedback has been received." = "Wir haben Ihr Feedback erhalten."; -"Dislike" = "Gefällt mir nicht"; -"Preview" = "Vorschau"; -"Book Now" = "Jetzt buchen"; -"START A NEW CONVERSATION" = "NEUE KONVERSATION STARTEN"; -"Your Rating" = "Ihre Bewertung"; -"No Internet!" = "Kein Internet!"; -"Invalid Entry" = "Ungültiger Eintrag"; -"Loved it" = "Gefiel mir sehr"; -"Review on the App Store" = "Im App Store rezensieren"; -"Open Help" = "Hilfe öffnen"; -"Search" = "Suche"; -"Tap here if you found this FAQ helpful" = "Tippen Sie hier, falls diese FAQ hilfreich war."; -"Star" = "Stern"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Tippen Sie hier, falls die Antwort hilfreich war."; -"Report a problem" = "Problem melden"; -"YES, THANKS!" = "JA, DANKE!"; -"Was this helpful?" = "War das hilfreich?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Ihre Nachricht wurde nicht gesendet. Tippen Sie auf \"Wiederholen\", um diese Nachricht erneut zu senden."; -"Suggestions" = "Vorschläge"; -"No FAQs found" = "Keine FAQs gefunden"; -"Done" = "Fertig"; -"Opening Gallery..." = "Galerie öffnen ..."; -"You rated the service with" = "Sie haben den Service wie folgt bewertet:"; -"Cancel" = "Abbrechen"; -"Loading..." = "Laden ..."; -"Read FAQs" = "FAQs lesen"; -"Thanks for messaging us!" = "Danke, das Sie uns geschrieben haben!"; -"Try Again" = "Wiederholen"; -"Send Feedback" = "Feedback senden"; -"Your Name" = "Ihr Name"; -"Please provide a name." = "Bitte geben Sie einen Namen ein."; -"FAQ" = "FAQ"; -"Describe your problem" = "Beschreiben Sie Ihr Problem"; -"How can we help?" = "Wie können wir helfen?"; -"Help about" = "Hilfe zu"; -"We could not fetch the required data" = "Wir konnten die erforderlichen Daten nicht abrufen."; -"Name" = "Name"; -"Sending failed!" = "Senden fehlgeschlagen!"; -"You didn't find this helpful." = "Sie fanden dies nicht hilfreich."; -"Attach a screenshot of your problem" = "Hängen Sie einen Screenshot Ihres Problems an."; -"Mark as read" = "Als gelesen markieren"; -"Name invalid" = "Ungültiger Name"; -"Yes" = "Ja"; -"What's on your mind?" = "Worüber möchten Sie reden?"; -"Send a new message" = "Neue Nachricht senden"; -"Questions that may already have your answer" = "Fragen, die bereits Antworten für Sie enthalten könnten"; -"Attach a photo" = "Foto anhängen"; -"Accept" = "Annehmen"; -"Your reply" = "Ihre Antwort"; -"Inbox" = "Posteingang"; -"Remove attachment" = "Anhang entfernen"; -"Could not fetch message" = "Nachricht konnte nicht abgerufen werden."; -"Read FAQ" = "FAQ lesen"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Tut uns leid! Diese Konversation wurde aufgrund von Inaktivität geschlossen. Bitte beginnen Sie eine neue Konversation mit unseren Agenten."; -"%d new messages from Support" = "%d neue Support-Nachrichten"; -"Ok, Attach" = "OK, anhängen"; -"Send" = "Senden"; -"Screenshot size should not exceed %.2f MB" = "Die Größe des Screenshots sollte %.2f MB nicht überschreiten."; -"Information" = "Details"; -"Issue ID" = "Problem-ID"; -"Tap to copy" = "Zum Kopieren tippen"; -"Copied!" = "Kopiert!"; -"We couldn't find an FAQ with matching ID" = "Wir konnten keine FAQ mit passender ID finden."; -"Failed to load screenshot" = "Bildschirmfoto konnte nicht geladen werden"; -"Failed to load video" = "Video konnte nicht geladen werden"; -"Failed to load image" = "Bild konnte nicht geladen werden"; -"Hold down your device's power and home buttons at the same time." = "Halten Sie gleichzeitig Standby- und Home-Taste Ihres Geräts gedrückt."; -"Please note that a few devices may have the power button on the top." = "Bitte beachten Sie, dass die Standby-Taste bei einigen Geräten an der Oberseite angebracht ist."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Kehren Sie danach zu dieser Konversation zurück und tippen Sie auf \"OK, anhängen\", um den Screenshot anzuhängen."; -"Okay" = "Okay"; -"We couldn't find an FAQ section with matching ID" = "Wir konnten keinen FAQ-Abschnitt mit passender ID finden."; - -"GIFs are not supported" = "GIFs werden nicht unterstützt"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/el.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/el.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index e292219a32c1..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/el.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Δεν μπορείτε να βρείτε αυτό που ψάχνετε;"; -"Rate App" = "Αξιολόγηση εφαρμογής"; -"We\'re happy to help you!" = "Θα σας βοηθήσουμε με μεγάλη χαρά!"; -"Did we answer all your questions?" = "Απαντήσαμε σε όλες τις ερωτήσεις σας;"; -"Remind Later" = "Υπενθύμιση αργότερα"; -"Your message has been received." = "Το μήνυμά σας παραλήφθηκε."; -"Message send failure." = "Αποτυχία αποστολής μηνύματος."; -"What\'s your feedback about our customer support?" = "Ποιο είναι το σχόλιό σας για την εξυπηρέτηση πελατών μας;"; -"Take a screenshot on your iPhone" = "Λήψη στιγμιότυπου στο iPhone σας"; -"Learn how" = "Μάθετε τον τρόπο"; -"Take a screenshot on your iPad" = "Λήψη στιγμιότυπου στο iPad σας"; -"Your email(optional)" = "Το email σας(προαιρετικό)"; -"Conversation" = "Συνομιλία"; -"View Now" = "Προβολή τώρα"; -"SEND ANYWAY" = "ΑΠΟΣΤΟΛΗ ΟΠΩΣΔΗΠΟΤΕ"; -"OK" = "ΟΚ"; -"Help" = "Βοήθεια"; -"Send message" = "Αποστολή μηνύματος"; -"REVIEW" = "ΚΡΙΤΙΚΗ"; -"Share" = "Κοινή χρήση"; -"Close Help" = "Κλείσιμο Βοήθειας"; -"Sending your message..." = "Αποστολή του μηνύματός σας..."; -"Learn how to" = "Μάθετε περισσότερα σχετικά με"; -"No FAQs found in this section" = "Δεν βρέθηκαν Συνήθεις ερωτήσεις σε αυτή την ενότητα"; -"Thanks for contacting us." = "Ευχαριστούμε που επικοινωνήσατε μαζί μας."; -"Chat Now" = "Συνομιλία τώρα"; -"Buy Now" = "Αγορά τώρα"; -"New Conversation" = "Νέα συνομιλία"; -"Please check your network connection and try again." = "Ελέγξτε τη σύνδεση του δικτύου και δοκιμάστε πάλι."; -"New message from Support" = "Νέο μήνυμα από την Υποστήριξη"; -"Question" = "Ερώτηση"; -"Type in a new message" = "Πληκτρολογήστε νέο μήνυμα"; -"Email (optional)" = "Email (προαιρετικά)"; -"Reply" = "Απάντηση"; -"CONTACT US" = "ΕΠΙΚΟΙΝΩΝΗΣΤΕ ΜΑΖΙ ΜΑΣ"; -"Email" = "Email"; -"Like" = "Μου αρέσει"; -"Tap here if this FAQ was not helpful to you" = "Πατήστε εδώ αν αυτή η Συνήθης ερώτηση δεν ήταν χρήσιμη"; -"Any other feedback? (optional)" = "Κάποιο άλλο σχόλιο; (προαιρετικά)"; -"You found this helpful." = "Το βρήκατε χρήσιμο."; -"No working Internet connection is found." = "Δεν βρέθηκε ενεργή σύνδεση Internet."; -"No messages found." = "Δεν βρέθηκαν μηνύματα."; -"Please enter a brief description of the issue you are facing." = "Εισαγάγετε σύντομη περιγραφή του προβλήματος που αντιμετωπίζετε."; -"Shop Now" = "Αγορά τώρα"; -"Close Section" = "Κλείσιμο ενότητας"; -"Close FAQ" = "Κλείσιμο Συνήθων ερωτήσεων"; -"Close" = "Κλείσιμο"; -"This conversation has ended." = "Η συνομιλία έληξε."; -"Send it anyway" = "Αποστολή οπωσδήποτε"; -"You accepted review request." = "Αποδεχθήκατε το αίτημα για κριτική."; -"Delete" = "Διαγραφή"; -"What else can we help you with?" = "Πώς αλλιώς μπορούμε να σας βοηθήσουμε;"; -"Tap here if the answer was not helpful to you" = "Πατήστε εδώ αν δεν βρήκατε χρήσιμη την απάντηση"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Λυπούμαστε γι' αυτό. Θέλετε να μας δώσετε περισσότερες πληροφορίες για το πρόβλημα που αντιμετωπίζετε;"; -"Service Rating" = "Αξιολόγηση υπηρεσίας"; -"Your email" = "Το email σας"; -"Email invalid" = "Μη έγκυρο email"; -"Could not fetch FAQs" = "Δεν ανακτήθηκαν οι συνήθεις ερωτήσεις"; -"Thanks for rating us." = "Ευχαριστούμε για την αξιολόγησή σας."; -"Download" = "Λήψη"; -"Please enter a valid email" = "Εισαγάγετε ένα έγκυρο email"; -"Message" = "Μήνυμα"; -"or" = "ή"; -"Decline" = "Απόρριψη"; -"No" = "Όχι"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Δεν ήταν δυνατή η αποστολή του στιγμιότυπου. Η εικόνα είναι πολύ μεγάλη, δοκιμάστε πάλι με άλλη εικόνα"; -"Hated it" = "Δεν μου άρεσε"; -"Stars" = "Αστέρια"; -"Your feedback has been received." = "Το σχόλιό σας παραλήφθηκε."; -"Dislike" = "Δεν μου αρέσει"; -"Preview" = "Προεπισκόπηση"; -"Book Now" = "Κράτηση τώρα"; -"START A NEW CONVERSATION" = "ΕΝΑΡΞΗ ΝΕΑΣ ΣΥΝΟΜΙΛΙΑΣ"; -"Your Rating" = "Η αξιολόγησή σας"; -"No Internet!" = "Χωρίς Internet!"; -"Invalid Entry" = "Μη έγκυρη καταχώρηση"; -"Loved it" = "Μου άρεσε"; -"Review on the App Store" = "Κριτική στο App Store"; -"Open Help" = "Άνοιγμα Βοήθειας"; -"Search" = "Αναζήτηση"; -"Tap here if you found this FAQ helpful" = "Πατήστε εδώ αν βρήκατε χρήσιμη αυτή την Συνήθη ερώτηση"; -"Star" = "Αστέρι"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Πατήστε εδώ αν βρήκατε χρήσιμη αυτή την απάντηση"; -"Report a problem" = "Αναφορά προβλήματος"; -"YES, THANKS!" = "ΝΑΙ, ΕΥΧΑΡΙΣΤΩ!"; -"Was this helpful?" = "Ήταν χρήσιμο;"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Το μήνυμά σας δεν στάλθηκε. Θέλετε να πατήστε \"Δοκιμάστε πάλι\" για να στείλετε αυτό το μήνυμα"; -"Suggestions" = "Προτάσεις"; -"No FAQs found" = "Δεν βρέθηκαν Συνήθεις ερωτήσεις"; -"Done" = "Τέλος"; -"Opening Gallery..." = "Άνοιγμα Συλλογής..."; -"You rated the service with" = "Αξιολογήσατε την υπηρεσία με"; -"Cancel" = "Άκυρο"; -"Loading..." = "Φόρτωση..."; -"Read FAQs" = "Ανάγνωση Συνήθων ερωτήσεων"; -"Thanks for messaging us!" = "Ευχαριστούμε για την αποστολή του μηνύματος!"; -"Try Again" = "Δοκιμάστε πάλι"; -"Send Feedback" = "Αποστολή σχολίων"; -"Your Name" = "Το Όνομά σας"; -"Please provide a name." = "Δώστε ένα όνομα."; -"FAQ" = "Συνήθεις ερωτήσεις"; -"Describe your problem" = "Περιγράψτε το πρόβλημά σας"; -"How can we help?" = "Πώς μπορούμε να βοηθήσουμε;"; -"Help about" = "Βοήθεια σχετικά με"; -"We could not fetch the required data" = "Δεν ήταν δυνατή η ανάκτηση των απαιτούμενων δεδομένων"; -"Name" = "Όνομα"; -"Sending failed!" = "Η αποστολή απέτυχε!"; -"You didn't find this helpful." = "Δεν το βρήκατε χρήσιμο."; -"Attach a screenshot of your problem" = "Επισυνάψτε ένα στιγμιότυπο οθόνης με το πρόβλημα"; -"Mark as read" = "Σήμανση ως αναγνωσμένο"; -"Name invalid" = "Μη έγκυρο όνομα"; -"Yes" = "Ναι"; -"What's on your mind?" = "Τι σκέπτεστε;"; -"Send a new message" = "Αποστολή νέου μηνύματος"; -"Questions that may already have your answer" = "Ερωτήσεις που ενδέχεται να έχετε ήδη απαντήσει"; -"Attach a photo" = "Επισύναψη φωτογραφίας"; -"Accept" = "Αποδοχή"; -"Your reply" = "Η απάντησή σας"; -"Inbox" = "Εισερχόμενα"; -"Remove attachment" = "Αφαίρεση συνημμένου"; -"Could not fetch message" = "Δεν ήταν δυνατή η ανάκτηση του μηνύματος"; -"Read FAQ" = "Ανάγνωση Συνήθων ερωτήσεων"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Λυπούμαστε! Αυτή η συνομιλία έκλεισε εξαιτίας αδράνειας. Αρχίστε νέα συνομιλία με τους εκπροσώπους μας."; -"%d new messages from Support" = "%d νέα μηνύματα από την Υποστήριξη"; -"Ok, Attach" = "ΟΚ, επισύναψη"; -"Send" = "Αποστολή"; -"Screenshot size should not exceed %.2f MB" = "Το μέγεθος στιγμιότυπου οθόνης δεν πρέπει να υπερβαίνει τα %.2f MB"; -"Information" = "Πληροφορίες"; -"Issue ID" = "Αναγνωριστικό θέματος"; -"Tap to copy" = "Πατήστε για αντιγραφή"; -"Copied!" = "Αντιγράφηκε!"; -"We couldn't find an FAQ with matching ID" = "Δεν βρέθηκε Συνήθης ερώτηση με αντίστοιχο αναγνωριστικό"; -"Failed to load screenshot" = "Η φόρτωση του στιγμιότυπου απέτυχε"; -"Failed to load video" = "Η φόρτωση του βίντεο απέτυχε"; -"Failed to load image" = "Η φόρτωση της εικόνας απέτυχε"; -"Hold down your device's power and home buttons at the same time." = "Κρατήστε πατημένο το πλήκτρο λειτουργίας και το πλήκτρο κεντρικής σελίδας της συσκευής σας, ταυτόχρονα."; -"Please note that a few devices may have the power button on the top." = "Σημειώστε ότι, σε ορισμένες συσκευές, το πλήκτρο λειτουργίας ενδέχεται να βρίσκεται στο επάνω μέρος."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Μόλις τελειώσετε, επιστρέψτε σε αυτή τη συνομιλία και πατήστε το \"ΟΚ, επισύναψη\" για επισύναψη."; -"Okay" = "ΟΚ"; -"We couldn't find an FAQ section with matching ID" = "Δεν βρέθηκε ενότητα \"Συνήθεις ερωτήσεις\" με αντίστοιχο αναγνωριστικό"; - -"GIFs are not supported" = "Τα GIF δεν υποστηρίζονται"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/en.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/en.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 2c9b810bf924..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/en.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,148 +0,0 @@ -/* - HelpshiftLocalizable.strings - Helpshift - Copyright (c) 2014 Helpshift,Inc., All rights reserved. - */ -"Can't find what you were looking for?" = "Can't find what you were looking for?"; -"Rate App" = "Rate App"; -"We\'re happy to help you!" = "We\'re happy to help you!"; -"What's on your mind?" = "What's on your mind?"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?"; -"Thanks for contacting us." = "Thanks for contacting us."; -"Remind Later" = "Remind Later"; -"Your message has been received." = "Your message has been received."; -"Message send failure." = "Message send failure."; -"Hated it" = "Hated it"; -"What\'s your feedback about our customer support?" = "What\'s your feedback about our customer support?"; -"Take a screenshot on your iPhone" = "Take a screenshot on your iPhone"; -"Learn how" = "Learn how"; -"Take a screenshot on your iPad" = "Take a screenshot on your iPad"; -"Name invalid" = "Name invalid"; -"View Now" = "View Now"; -"SEND ANYWAY" = "SEND ANYWAY"; -"Help" = "Help"; -"Send message" = "Send message"; -"REVIEW" = "REVIEW"; -"Share" = "Share"; -"Close Help" = "Close Help"; -"Loved it" = "Loved it"; -"Learn how to" = "Learn how to"; -"Chat Now" = "Chat Now"; -"Buy Now" = "Buy Now"; -"New Conversation" = "New Conversation"; -"Please check your network connection and try again." = "Please check your network connection and try again."; -"New message from Support" = "New message from Support"; -"Question" = "Question"; -"No FAQs found in this section" = "No FAQs found in this section"; -"Type in a new message" = "Type in a new message"; -"Email (optional)" = "Email (optional)"; -"Reply" = "Reply"; -"CONTACT US" = "CONTACT US"; -"Email" = "Email"; -"Like" = "Like"; -"Sending your message..." = "Sending your message..."; -"Tap here if this FAQ was not helpful to you" = "Tap here if this FAQ was not helpful to you"; -"Any other feedback? (optional)" = "Any other feedback? (optional)"; -"You found this helpful." = "You found this helpful."; -"No working Internet connection is found." = "No working Internet connection is found."; -"No messages found." = "No messages found."; -"Please enter a brief description of the issue you are facing." = "Please enter a brief description of the issue you are facing."; -"Shop Now" = "Shop Now"; -"Email invalid" = "Email invalid"; -"Did we answer all your questions?" = "Did we answer all your questions?"; -"Close Section" = "Close Section"; -"Close FAQ" = "Close FAQ"; -"Close" = "Close"; -"This conversation has ended." = "This conversation has ended."; -"Send it anyway" = "Send it anyway"; -"You accepted review request." = "You accepted review request."; -"Delete" = "Delete"; -"Invalid Entry" = "Invalid Entry"; -"Tap here if the answer was not helpful to you" = "Tap here if the answer was not helpful to you"; -"Service Rating" = "Service Rating"; -"Thanks for messaging us!" = "Thanks for messaging us!"; -"Could not fetch FAQs" = "Could not fetch FAQs"; -"Thanks for rating us." = "Thanks for rating us."; -"Download" = "Download"; -"Please enter a valid email" = "Please enter a valid email"; -"Message" = "Message"; -"or" = "or"; -"Your email" = "Your email"; -"Decline" = "Decline"; -"No" = "No"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Screenshot could not be sent. Image is too large, try again with another image"; -"Stars" = "Stars"; -"Your feedback has been received." = "Your feedback has been received."; -"Dislike" = "Dislike"; -"Preview" = "Preview"; -"Book Now" = "Book Now"; -"START A NEW CONVERSATION" = "START A NEW CONVERSATION"; -"Your Rating" = "Your Rating"; -"No Internet!" = "No Internet!"; -"You didn't find this helpful." = "You didn't find this helpful."; -"Review on the App Store" = "Review on the App Store"; -"Open Help" = "Open Help"; -"Search" = "Search"; -"Tap here if you found this FAQ helpful" = "Tap here if you found this FAQ helpful"; -"Star" = "Star"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Tap here if you found this answer helpful"; -"Report a problem" = "Report a problem"; -"YES, THANKS!" = "YES, THANKS!"; -"Was this helpful?" = "Was this helpful?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Your message was not sent.Tap \"Try Again\" to send this message?"; -"OK" = "OK"; -"Suggestions" = "Suggestions"; -"No FAQs found" = "No FAQs found"; -"Done" = "Done"; -"Opening Gallery..." = "Opening Gallery..."; -"Cancel" = "Cancel"; -"Could not fetch message" = "Could not fetch message"; -"Read FAQs" = "Read FAQs"; -"Try Again" = "Try Again"; -"%d new messages from Support" = "%d new messages from Support"; -"Your Name" = "Your Name"; -"Please provide a name." = "Please provide a name."; -"You rated the service with" = "You rated the service with"; -"What else can we help you with?" = "What else can we help you with?"; -"FAQ" = "FAQ"; -"Describe your problem" = "Describe your problem"; -"How can we help?" = "How can we help?"; -"Help about" = "Help about"; -"Send Feedback" = "Send Feedback"; -"We could not fetch the required data" = "We could not fetch the required data"; -"Name" = "Name"; -"Sending failed!" = "Sending failed!"; -"Attach a screenshot of your problem" = "Attach a screenshot of your problem"; -"Mark as read" = "Mark as read"; -"Loading..." = "Loading..."; -"Yes" = "Yes"; -"Send a new message" = "Send a new message"; -"Your email(optional)" = "Your email(optional)"; -"Conversation" = "Conversation"; -"Questions that may already have your answer" = "Questions that may already have your answer"; -"Attach a photo" = "Attach a photo"; -"Accept" = "Accept"; -"Your reply" = "Your reply"; -"Inbox" = "Inbox"; -"Remove attachment" = "Remove attachment"; -"Read FAQ" = "Read FAQ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents."; -"Ok, Attach" = "Ok, Attach"; -"Send" = "Send"; -"Screenshot size should not exceed %.2f MB" = "Screenshot size should not exceed %.2f MB"; -"Information" = "Information"; -"Issue ID" = "Issue ID"; -"Tap to copy" = "Tap to copy"; -"Copied!" = "Copied!"; -"We couldn't find an FAQ with matching ID" = "We couldn't find an FAQ with matching ID"; -"Failed to load screenshot" = "Failed to load screenshot"; -"Failed to load video" = "Failed to load video"; -"Failed to load image" = "Failed to load image"; -"Hold down your device's power and home buttons at the same time." = "Hold down your device's power and home buttons at the same time."; -"Please note that a few devices may have the power button on the top." = "Please note that a few devices may have the power button on the top."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Once done, come back to this conversation and tap on \"Ok, attach\" to attach it."; -"Okay" = "Okay"; -"We couldn't find an FAQ section with matching ID" = "We couldn't find an FAQ section with matching ID"; - -"GIFs are not supported" = "GIFs are not supported"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/es.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/es.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 724ede3bf6b2..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/es.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "¿No ha encontrado lo que buscaba?"; -"Rate App" = "Valorar aplicación"; -"We\'re happy to help you!" = "¡Estamos encantados de ayudarle!"; -"Did we answer all your questions?" = "¿Hemos resuelto todas sus dudas?"; -"Remind Later" = "Recordármelo más tarde"; -"Your message has been received." = "Hemos recibido su mensaje."; -"Message send failure." = "El envío de mensaje ha fallado."; -"What\'s your feedback about our customer support?" = "¿Qué le ha parecido nuestro servicio de soporte al cliente?"; -"Take a screenshot on your iPhone" = "Tomar una captura de pantalla con su iPhone"; -"Learn how" = "Aprender cómo"; -"Take a screenshot on your iPad" = "Tomar una captura de pantalla con su iPad"; -"Your email(optional)" = "Su correo (opcional)"; -"Conversation" = "Conversación"; -"View Now" = "Ver ahora"; -"SEND ANYWAY" = "ENVIAR IGUALMENTE"; -"OK" = "Aceptar"; -"Help" = "Ayuda"; -"Send message" = "Enviar mensaje"; -"REVIEW" = "RESEÑAR"; -"Share" = "Compartir"; -"Close Help" = "Cerrar Ayuda"; -"Sending your message..." = "Enviando su mensaje..."; -"Learn how to" = "Aprenda a"; -"No FAQs found in this section" = "No hay P+F en la sección."; -"Thanks for contacting us." = "Gracias por ponerse en contacto con nosotros."; -"Chat Now" = "Charlar ahora"; -"Buy Now" = "Comprar ahora"; -"New Conversation" = "Nueva conversación"; -"Please check your network connection and try again." = "Compruebe la conexión de red e inténtelo de nuevo."; -"New message from Support" = "Nuevo mensaje de soporte"; -"Question" = "Pregunta"; -"Type in a new message" = "Introduzca un nuevo mensaje"; -"Email (optional)" = "Correo (opcional)"; -"Reply" = "Responder"; -"CONTACT US" = "CONTACTE CON NOSOTROS"; -"Email" = "Correo"; -"Like" = "Me gusta"; -"Tap here if this FAQ was not helpful to you" = "Toque aquí si esta P+F no le resultó útil"; -"Any other feedback? (optional)" = "¿Tiene más comentarios? (Opcional)"; -"You found this helpful." = "Me ha resultado útil."; -"No working Internet connection is found." = "No se ha detectado una conexión activa a Internet."; -"No messages found." = "No se han encontrado mensajes."; -"Please enter a brief description of the issue you are facing." = "Introduzca una breve descripción del problema que está experimentando."; -"Shop Now" = "Adquirir ahora"; -"Close Section" = "Cerrar sección"; -"Close FAQ" = "Cerrar P+F"; -"Close" = "Cerrar"; -"This conversation has ended." = "La conversación ha finalizado."; -"Send it anyway" = "Enviar igualmente"; -"You accepted review request." = "Ha aceptado la solicitud de reseña."; -"Delete" = "Eliminar"; -"What else can we help you with?" = "¿Necesita más ayuda?"; -"Tap here if the answer was not helpful to you" = "Toque aquí si esta respuesta no le resultó útil"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Lo sentimos. ¿Puede describir el problema que está experimentando?"; -"Service Rating" = "Valoración del servicio"; -"Your email" = "Su correo"; -"Email invalid" = "Correo no válido"; -"Could not fetch FAQs" = "No se han encontrado P+F."; -"Thanks for rating us." = "Gracias por su valoración."; -"Download" = "Descargar"; -"Please enter a valid email" = "Escriba un correo válido"; -"Message" = "Mensaje"; -"or" = "o"; -"Decline" = "Rechazar"; -"No" = "No"; -"Screenshot could not be sent. Image is too large, try again with another image" = "No se ha podido enviar la captura. La imagen es demasiado grande. Inténtelo de nuevo con otra imagen."; -"Hated it" = "Muy malo"; -"Stars" = "estrellas"; -"Your feedback has been received." = "Hemos recibido sus comentarios."; -"Dislike" = "No me gusta"; -"Preview" = "Vista previa"; -"Book Now" = "Reservar ahora"; -"START A NEW CONVERSATION" = "INICIAR UNA NUEVA CONVERSACIÓN"; -"Your Rating" = "Su valoración"; -"No Internet!" = "Sin conexión"; -"Invalid Entry" = "Entrada no válida"; -"Loved it" = "Muy bueno"; -"Review on the App Store" = "Reseñar en la App Store"; -"Open Help" = "Abrir Ayuda"; -"Search" = "Buscar"; -"Tap here if you found this FAQ helpful" = "Toque aquí si esta P+F le resultó útil"; -"Star" = "estrella"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Toque aquí si esta respuesta le resultó útil"; -"Report a problem" = "Comunicar un problema"; -"YES, THANKS!" = "SÍ"; -"Was this helpful?" = "¿Le resultó útil?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Su mensaje no se ha enviado. Toque Volver a intentarlo para enviar el mensaje."; -"Suggestions" = "Sugerencias"; -"No FAQs found" = "No se han encontrado P+F"; -"Done" = "Listo"; -"Opening Gallery..." = "Accediendo a la galería..."; -"You rated the service with" = "Ha valorado el servicio con"; -"Cancel" = "Cancelar"; -"Loading..." = "Cargando..."; -"Read FAQs" = "Leer P+F"; -"Thanks for messaging us!" = "Gracias por su mensaje"; -"Try Again" = "Volver a intentarlo"; -"Send Feedback" = "Enviar comentarios"; -"Your Name" = "Su nombre"; -"Please provide a name." = "Proporcione un nombre."; -"FAQ" = "P+F"; -"Describe your problem" = "Describa su problema"; -"How can we help?" = "¿En qué podemos ayudarle?"; -"Help about" = "Ayuda sobre"; -"We could not fetch the required data" = "No se ha encontrado la información deseada."; -"Name" = "Nombre"; -"Sending failed!" = "Ha fallado el envío"; -"You didn't find this helpful." = "No le ha resultado útil."; -"Attach a screenshot of your problem" = "Adjunte una captura de pantalla del problema"; -"Mark as read" = "Marcar como leído"; -"Name invalid" = "Nombre no válido"; -"Yes" = "Sí"; -"What's on your mind?" = "Introduzca su comentario"; -"Send a new message" = "Enviar un nuevo mensaje"; -"Questions that may already have your answer" = "Preguntas que podrían contener la información que busca"; -"Attach a photo" = "Adjuntar una foto"; -"Accept" = "Aceptar"; -"Your reply" = "Su respuesta"; -"Inbox" = "Bandeja de entrada"; -"Remove attachment" = "Quitar archivo adjunto"; -"Could not fetch message" = "No se ha encontrado el mensaje"; -"Read FAQ" = "Leer P+F"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Lo sentimos. La conversación se ha cerrado porque estaba inactiva. Inicie otra con sus agentes."; -"%d new messages from Support" = "%d nuevos mensajes de soporte"; -"Ok, Attach" = "Adjuntar"; -"Send" = "Enviar"; -"Screenshot size should not exceed %.2f MB" = "La captura de pantalla no debe superar %.2f MB"; -"Information" = "Información"; -"Issue ID" = "Id. de problema"; -"Tap to copy" = "Toque para copiar"; -"Copied!" = "¡Copiado!"; -"We couldn't find an FAQ with matching ID" = "No se ha encontrado una P+F con el ID correspondiente."; -"Failed to load screenshot" = "Error al cargar la captura de pantalla"; -"Failed to load video" = "Error al cargar el vídeo"; -"Failed to load image" = "Error al cargar la imagen"; -"Hold down your device's power and home buttons at the same time." = "Mantenga presionados los botones de inicio y de encendido de su dispositivo a la vez."; -"Please note that a few devices may have the power button on the top." = "Tenga en cuenta que algunos dispositivos tienen el botón de encendido en la parte superior."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Vuelva a esta conversación y toque Adjuntar para agregar la imagen."; -"Okay" = "Continuar"; -"We couldn't find an FAQ section with matching ID" = "No se ha encontrado una sección de P+F con el id. correspondiente."; - -"GIFs are not supported" = "Los GIF no se admiten"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/fa.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/fa.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index a767179086ba..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/fa.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "قادر به یافتن آنچه در پی آن بودید نیستید؟"; -"Rate App" = "امتیازدهی به برنامه"; -"We\'re happy to help you!" = "کمک به شما مایه خرسندی ماست!"; -"Did we answer all your questions?" = "آیا به همه پرسش‌های شما پاسخ دادیم؟"; -"Remind Later" = "بعداً یادآوری کن"; -"Your message has been received." = "پیام شما دریافت شده است."; -"Message send failure." = "خطا در ارسال پیام."; -"What\'s your feedback about our customer support?" = "دیدگاه شما درباره پشتیبانی مشتری ما چیست؟"; -"Take a screenshot on your iPhone" = "از صفحه آیفون خود عکس بگیرید"; -"Learn how" = "یادگیری نحوه انجام"; -"Take a screenshot on your iPad" = "از صفحه آی‌پد خود عکس بگیرید"; -"Your email(optional)" = "ایمیل شما (اختیاری)"; -"Conversation" = "مکالمه"; -"View Now" = "اکنون مشاهده کنید"; -"SEND ANYWAY" = "به هر حال ارسال کن"; -"OK" = "تأیید"; -"Help" = "کمک"; -"Send message" = "ارسال پیام"; -"REVIEW" = "نظردهی"; -"Share" = "به‌اشتراک‌گذاری"; -"Close Help" = "بستن کمک"; -"Sending your message..." = "در حال ارسال پیام شما..."; -"Learn how to" = "نحوه انجام آن را بیاموزید"; -"No FAQs found in this section" = "هیچ سؤال متداولی در این بخش یافت نشد"; -"Thanks for contacting us." = "از اینکه با ما تماس گرفته‌اید متشکریم."; -"Chat Now" = "اکنون گفتگو کنید"; -"Buy Now" = "اکنون خرید کنید"; -"New Conversation" = "مکالمه جدید"; -"Please check your network connection and try again." = "لطفاً اتصال شبکه خود را بررسی و سپس دوباره سعی کنید."; -"New message from Support" = "پیام جدید از طرف پشتیبانی"; -"Question" = "سؤال"; -"Type in a new message" = "پیام جدیدی وارد کنید"; -"Email (optional)" = "ایمیل (اختیاری)"; -"Reply" = "پاسخ‌دهی"; -"CONTACT US" = "تماس با ما"; -"Email" = "ایمیل"; -"Like" = "مورد پسند"; -"Tap here if this FAQ was not helpful to you" = "چنانچه به نظرتان این سؤال متداول غیرمفید بود، اینجا ضربه بزنید"; -"Any other feedback? (optional)" = "آیا بازخورد دیگری دارید؟ (اختیاری)"; -"You found this helpful." = "به نظر شما مفید بود."; -"No working Internet connection is found." = "اتصال اینترنتی فعال یافت نشد."; -"No messages found." = "هیچ پیامی یافت نشد."; -"Please enter a brief description of the issue you are facing." = "لطفاً در مورد مشکل خود کمی توضیح دهید."; -"Shop Now" = "اکنون خرید کنید"; -"Close Section" = "بستن بخش"; -"Close FAQ" = "بستن سؤالات متداول"; -"Close" = "بستن"; -"This conversation has ended." = "این مکالمه به پایان رسیده است."; -"Send it anyway" = "به هر حال ارسال کن"; -"You accepted review request." = "درخواست نظردهی را پذیرفتید."; -"Delete" = "حذف"; -"What else can we help you with?" = "چه کمک دیگری می‌توانیم به شما بکنیم؟"; -"Tap here if the answer was not helpful to you" = "چنانچه به نظرتان این پاسخ غیرمفید بود، اینجا ضربه بزنید"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "از شنیدن این موضوع متاسفیم. لطفاً در صورت امکان اطلاعات بیشتری در مورد مشکلی که با آن مواجه هستید به ما ارائه دهید."; -"Service Rating" = "امتیاز سرویس"; -"Your email" = "ایمیل شما"; -"Email invalid" = "ایمیل نامعتبر"; -"Could not fetch FAQs" = "فراخوانی سؤالات متداول مقدور نبود"; -"Thanks for rating us." = "از اینکه به ما امتیاز داده‌اید متشکریم."; -"Download" = "دانلود"; -"Please enter a valid email" = "لطفاً ایمیل معتبری وارد کنید"; -"Message" = "پیام"; -"or" = "یا"; -"Decline" = "رد کردن"; -"No" = "خیر"; -"Screenshot could not be sent. Image is too large, try again with another image" = "ارسال عکس صفحه مقدور نبود. تصویر بیش از اندازه بزرگ است؛ لطفاً ارسال تصویر دیگری را امتحان کنید"; -"Hated it" = "خوشم نیامد"; -"Stars" = "ستاره"; -"Your feedback has been received." = "بازخورد شما دریافت شده است."; -"Dislike" = "غیر مورد پسند"; -"Preview" = "پیش‌نمایش"; -"Book Now" = "همین حالا رزرو کنید"; -"START A NEW CONVERSATION" = "شروع مکالمه جدید"; -"Your Rating" = "امتیاز شما"; -"No Internet!" = "بدون اینترنت!"; -"Invalid Entry" = "ورود نامعتبر"; -"Loved it" = "دوستش داشتم"; -"Review on the App Store" = "در App Store نظر بدهید"; -"Open Help" = "باز کردن کمک"; -"Search" = "جستجو"; -"Tap here if you found this FAQ helpful" = "چنانچه به نظرتان این سؤال متداول مفید بود، اینجا ضربه بزنید"; -"Star" = "ستاره"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "چنانچه به نظرتان این پاسخ مفید بود، اینجا ضربه بزنید"; -"Report a problem" = "گزارش مشکل"; -"YES, THANKS!" = "بله، متشکرم!"; -"Was this helpful?" = "آیا این پاسخ مفید بود؟"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "پیام شما ارسال نشد. برای ارسال پیام، روی «دوباره تلاش کنید» ضربه بزنید."; -"Suggestions" = "پیشنهادات"; -"No FAQs found" = "هیچ سؤال متداولی یافت نشد"; -"Done" = "انجام شد"; -"Opening Gallery..." = "در حال باز کردن گالری..."; -"You rated the service with" = "امتیاز شما به این سرویس:"; -"Cancel" = "لغو"; -"Loading..." = "در حال بارگذاری..."; -"Read FAQs" = "خواندن سؤالات متداول"; -"Thanks for messaging us!" = "بابت ارسال پیام از شما متشکریم!"; -"Try Again" = "دوباره تلاش کنید"; -"Send Feedback" = "ارسال بازخورد"; -"Your Name" = "نام شما"; -"Please provide a name." = "لطفاً یک نام ارائه دهید."; -"FAQ" = "سؤالات متداول"; -"Describe your problem" = "مشکل خود را شرح دهید"; -"How can we help?" = "چگونه می‌توانیم به شما کمک کنیم؟"; -"Help about" = "کمک درباره"; -"We could not fetch the required data" = "قادر به فراخوانی اطلاعات مورد نیاز نبودیم"; -"Name" = "نام"; -"Sending failed!" = "ارسال ناموفق بود!"; -"You didn't find this helpful." = "به نظر شما مفید نبود."; -"Attach a screenshot of your problem" = "نماگرفتی از مشکل خود ضمیمه کنید"; -"Mark as read" = "علامت‌گذاری به عنوان خوانده‌شده"; -"Name invalid" = "نام نامعتبر"; -"Yes" = "بله"; -"What's on your mind?" = "به چه چیزی فکر می‌کنید؟"; -"Send a new message" = "پیام جدیدی ارسال کنید"; -"Questions that may already have your answer" = "سؤالاتی که ممکن است دربردارنده پاسخ شما باشند"; -"Attach a photo" = "ضمیمه کردن عکس"; -"Accept" = "پذیرش"; -"Your reply" = "پاسخ شما"; -"Inbox" = "صندوق ورودی"; -"Remove attachment" = "حذف ضمیمه"; -"Could not fetch message" = "فراخوانی پیام مقدور نبود"; -"Read FAQ" = "سؤالات متداول را بخوانید"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "متاسفیم! این مکالمه به دلیل عدم فعالیت بسته شده است. لطفاً مکالمه جدیدی را با نمایندگان ما شروع کنید."; -"%d new messages from Support" = "%dپیام‌های جدید از طرف پشتیبانی"; -"Ok, Attach" = "بسیار خب، ضمیمه کن"; -"Send" = "ارسال"; -"Screenshot size should not exceed %.2f MB" = "اندازه نماگرفت نباید بیشتر از ‎%.2f MB‎ باشد"; -"Information" = "اطلاعات"; -"Issue ID" = "شناسه نسخه"; -"Tap to copy" = "برای کپی ضربه بزنید"; -"Copied!" = "کپی شد!"; -"We couldn't find an FAQ with matching ID" = "قادر به یافتن سؤالات رایج با این شناسه نبودیم."; -"Failed to load screenshot" = "بارگذاری نماگرفت ناموفق بود"; -"Failed to load video" = "بارگذاری ویدئو ناموفق بود"; -"Failed to load image" = "بارگذاری تصویر ناموفق بود"; -"Hold down your device's power and home buttons at the same time." = "دکمه‌های روشن/خاموش و خانه دستگاه خود را همزمان فشار دهید."; -"Please note that a few devices may have the power button on the top." = "لطفاً توجه داشته باشید که دکمه روشن/خاموش برخی از دستگاه‌ها ممکن است در بالای آن باشد."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "پس از انجام این کار، به این پنجره گفتگو بازگردید و روی «تایید، ضمیمه کن» ضربه بزنید."; -"Okay" = "تایید"; -"We couldn't find an FAQ section with matching ID" = "متن مبدا - قادر به یافتن بخش سوالات رایج با شناسه مربوطه نبودیم."; - -"GIFs are not supported" = "GIF ها پشتیبانی نمی شوند"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/fi.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/fi.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index e483cbdf5bfe..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/fi.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Etkö löydä etsimääsi?"; -"Rate App" = "Arvostele sovellus"; -"We\'re happy to help you!" = "Autamme mielellämme!"; -"Did we answer all your questions?" = "Vastasimmeko kaikkiin kysymyksiisi?"; -"Remind Later" = "Muistuta myöhemmin"; -"Your message has been received." = "Viestisi on vastaanotettu."; -"Message send failure." = "Viestin lähetys ei onnistunut."; -"What\'s your feedback about our customer support?" = "Mikä on palautteesi asiakastuestamme?"; -"Take a screenshot on your iPhone" = "Ota kuvakaappaus iPhonella"; -"Learn how" = "Ohjeet"; -"Take a screenshot on your iPad" = "Ota kuvakaappaus iPadilla"; -"Your email(optional)" = "Sähköpostisi (valinnainen)"; -"Conversation" = "Keskustelu"; -"View Now" = "Katsele nyt"; -"SEND ANYWAY" = "LÄHETÄ SILTI"; -"OK" = "OK"; -"Help" = "Ohjeet"; -"Send message" = "Lähetä viesti"; -"REVIEW" = "ARVOSTELE"; -"Share" = "Jaa"; -"Close Help" = "Sulje ohjeet"; -"Sending your message..." = "Viestiäsi lähetetään..."; -"Learn how to" = "Ohjeet aiheesta"; -"No FAQs found in this section" = "Tähän osioon ei löytynyt UKK:ta"; -"Thanks for contacting us." = "Kiitos, että otit meihin yhteyttä."; -"Chat Now" = "Juttele nyt"; -"Buy Now" = "Osta nyt"; -"New Conversation" = "Uusi keskustelu"; -"Please check your network connection and try again." = "Tarkista verkkoyhteys ja yritä uudelleen."; -"New message from Support" = "Uusi viesti asiakastuelta"; -"Question" = "Kysymys"; -"Type in a new message" = "Kirjoita uusi viesti"; -"Email (optional)" = "Sähköposti (vaihtoehtoinen)"; -"Reply" = "Vastaa"; -"CONTACT US" = "OTA YHTEYTTÄ"; -"Email" = "Sähköposti"; -"Like" = "Tykkää"; -"Tap here if this FAQ was not helpful to you" = "Napauta tästä, jos tämä UKK ei auttanut sinua"; -"Any other feedback? (optional)" = "Haluatko antaa muuta palautetta? (ei pakollinen)"; -"You found this helpful." = "Tästä oli apua."; -"No working Internet connection is found." = "Toimivaa verkkoyhteyttä ei löydy."; -"No messages found." = "Yhtään viestiä ei löytynyt."; -"Please enter a brief description of the issue you are facing." = "Kirjoita lyhyt kuvaus kohtaamastasi ongelmasta."; -"Shop Now" = "Osta nyt"; -"Close Section" = "Sulje osio"; -"Close FAQ" = "Sulje UKK"; -"Close" = "Sulje"; -"This conversation has ended." = "Tämä keskustelu on päättynyt."; -"Send it anyway" = "Lähetä silti"; -"You accepted review request." = "Hyväksyit arvostelupyynnön."; -"Delete" = "Poista"; -"What else can we help you with?" = "Miten muuten voisimme auttaa sinua?"; -"Tap here if the answer was not helpful to you" = "Napauta tästä, jos vastaus ei auttanut sinua"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Ikävä kuulla. Voisitko hieman kertoa meille ongelmastasi?"; -"Service Rating" = "Palvelun arvio"; -"Your email" = "Sähköpostisi"; -"Email invalid" = "Kelpaamaton sähköposti"; -"Could not fetch FAQs" = "UKK:ta ei löytynyt"; -"Thanks for rating us." = "Kiitos arvostelusta."; -"Download" = "Lataa"; -"Please enter a valid email" = "Kirjoita oikea sähköposti"; -"Message" = "Viesti"; -"or" = "tai"; -"Decline" = "Hylkää"; -"No" = "Ei"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Kuvakaappausta ei voitu lähettää. Kuva on liian iso. Yritä uudelleen toisella kuvalla"; -"Hated it" = "En pitänyt siitä"; -"Stars" = "Tähteä"; -"Your feedback has been received." = "Palautteesi on vastaanotettu."; -"Dislike" = "Älä tykkää"; -"Preview" = "Esikatselu"; -"Book Now" = "Varaa nyt"; -"START A NEW CONVERSATION" = "ALOITA UUSI KESKUSTELU"; -"Your Rating" = "Sinun arviosi"; -"No Internet!" = "Ei yhteyttä!"; -"Invalid Entry" = "Kelpaamaton syöte"; -"Loved it" = "Pidin siitä"; -"Review on the App Store" = "Katso App Storessa"; -"Open Help" = "Avaa ohjeet"; -"Search" = "Hae"; -"Tap here if you found this FAQ helpful" = "Napauta tästä, jos tämä UKK auttoi sinua"; -"Star" = "Tähti"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Napauta tästä, jos vastaus auttoi sinua"; -"Report a problem" = "Ilmoita ongelmasta"; -"YES, THANKS!" = "KYLLÄ, KIITOS!"; -"Was this helpful?" = "Oliko tästä apua?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Viestiäsi ei lähetetty. Lähetä viesti napauttamalla kohtaa \"Yritä uudelleen\"?"; -"Suggestions" = "Ehdotuksia"; -"No FAQs found" = "Yhtään UKK:ta ei löytynyt"; -"Done" = "Valmis"; -"Opening Gallery..." = "Avataan galleriaa..."; -"You rated the service with" = "Arvostelit palvelun:"; -"Cancel" = "Peru"; -"Loading..." = "Ladataan..."; -"Read FAQs" = "Lue UKK"; -"Thanks for messaging us!" = "Kiitos viestistäsi!"; -"Try Again" = "Yritä uudelleen"; -"Send Feedback" = "Lähetä palautetta"; -"Your Name" = "Nimesi"; -"Please provide a name." = "Kirjoita nimi."; -"FAQ" = "UKK"; -"Describe your problem" = "Kuvaile ongelmasi"; -"How can we help?" = "Miten voimme auttaa?"; -"Help about" = "Ohjeet aiheesta"; -"We could not fetch the required data" = "Emme saaneet haettua pyydettyjä tietoja"; -"Name" = "Nimi"; -"Sending failed!" = "Lähetys ei onnistunut!"; -"You didn't find this helpful." = "Tästä ei ollut apua."; -"Attach a screenshot of your problem" = "Liitä kuvakaappaus ongelmastasi"; -"Mark as read" = "Merkitse luetuksi"; -"Name invalid" = "Kelpaamaton nimi"; -"Yes" = "Kyllä"; -"What's on your mind?" = "Mistä haluat puhua?"; -"Send a new message" = "Kirjoita uusi viesti"; -"Questions that may already have your answer" = "Kysymyksiä, joista saatat löytää vastauksesi"; -"Attach a photo" = "Liitä valokuva"; -"Accept" = "Hyväksy"; -"Your reply" = "Vastauksesi"; -"Inbox" = "Postilaatikko"; -"Remove attachment" = "Poista liite"; -"Could not fetch message" = "Viestiä ei löytynyt"; -"Read FAQ" = "Lue UKK"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Pahoittelut! Tämä keskustelu on suljettu, koska se ei ole ollut aktiivinen. Aloita uusi keskustelu agenttiemme kanssa."; -"%d new messages from Support" = "%d uutta viestiä asiakastuelta"; -"Ok, Attach" = "Ok, liitä"; -"Send" = "Lähetä"; -"Screenshot size should not exceed %.2f MB" = "Kuvakaappauksen koko saa olla enintään %.2f Mt"; -"Information" = "Tiedot"; -"Issue ID" = "Ongelman tunnus"; -"Tap to copy" = "Kopioi napauttamalla"; -"Copied!" = "Kopioitu!"; -"We couldn't find an FAQ with matching ID" = "UKK:sta ei löytynyt mitään tuolla tunnuksella"; -"Failed to load screenshot" = "Kuvakaappauksen lataaminen ei onnistunut"; -"Failed to load video" = "Videon lataaminen ei onnistunut"; -"Failed to load image" = "Kuvan lataaminen ei onnistunut"; -"Hold down your device's power and home buttons at the same time." = "Paina yhtä aikaa laitteesi virtanäppäintä ja kotinäppäintä."; -"Please note that a few devices may have the power button on the top." = "Huomaa, että joissakin laitteissa virtanäppäin voi olla ylälaidassa."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Kun olet valmis, palaa tähän keskusteluun ja liitä kuvakaappaus napauttamalla kohtaa \"Ok, liitä”."; -"Okay" = "Okei"; -"We couldn't find an FAQ section with matching ID" = "UKK:sta ei löytynyt yhtään osiota tuolla tunnuksella"; - -"GIFs are not supported" = "GIF-tiedostoja ei tueta"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/fr.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/fr.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index b62fe603355a..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/fr.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Vous ne trouvez pas ce que vous cherchez ?"; -"Rate App" = "Évaluer l'app"; -"We\'re happy to help you!" = "Nous serons ravis de vous aider !"; -"Did we answer all your questions?" = "Avons-nous répondu à toutes vos questions ?"; -"Remind Later" = "Me rappeler plus tard"; -"Your message has been received." = "Votre message a été reçu."; -"Message send failure." = "Échec de l'envoi du message."; -"What\'s your feedback about our customer support?" = "Que pensez-vous de notre assistance client ?"; -"Take a screenshot on your iPhone" = "Faites une capture d'écran sur votre iPhone"; -"Learn how" = "Apprendre comment"; -"Take a screenshot on your iPad" = "Faites une capture d'écran sur votre iPad"; -"Your email(optional)" = "Votre e-mail (facultatif)"; -"Conversation" = "Conversation"; -"View Now" = "Afficher maintenant"; -"SEND ANYWAY" = "ENVOYER QUAND MÊME"; -"OK" = "OK"; -"Help" = "Aide"; -"Send message" = "Envoyer un message"; -"REVIEW" = "CRITIQUE"; -"Share" = "Partager"; -"Close Help" = "Fermer l'aide"; -"Sending your message..." = "Envoi de votre message..."; -"Learn how to" = "Apprendre comment"; -"No FAQs found in this section" = "Aucune FAQ trouvée dans cette section"; -"Thanks for contacting us." = "Merci de nous avoir contactés."; -"Chat Now" = "Discuter maintenant"; -"Buy Now" = "Acheter maintenant"; -"New Conversation" = "Nouvelle conversation"; -"Please check your network connection and try again." = "Veuillez vérifier votre connexion réseau et réessayer."; -"New message from Support" = "Nouveau message de l'assistance"; -"Question" = "Question"; -"Type in a new message" = "Saisir un nouveau message"; -"Email (optional)" = "E-mail (optionnel)"; -"Reply" = "Répondre"; -"CONTACT US" = "NOUS CONTACTER"; -"Email" = "E-mail"; -"Like" = "Aimer"; -"Tap here if this FAQ was not helpful to you" = "Touchez ici si vous n'avez pas trouvé la FAQ utile."; -"Any other feedback? (optional)" = "D'autres commentaires ? (optionnel)"; -"You found this helpful." = "Vous avez trouvé cela utile."; -"No working Internet connection is found." = "Aucune connexion internet active trouvée."; -"No messages found." = "Aucun message trouvé."; -"Please enter a brief description of the issue you are facing." = "Veuillez saisir une brève description de votre problème."; -"Shop Now" = "Parcourir maintenant"; -"Close Section" = "Fermer la section"; -"Close FAQ" = "Fermer la FAQ"; -"Close" = "Fermer"; -"This conversation has ended." = "Cette conversation est terminée."; -"Send it anyway" = "Envoyer quand même"; -"You accepted review request." = "Vous avez accepté une demande de critique."; -"Delete" = "Supprimer"; -"What else can we help you with?" = "Que pouvons-nous faire d'autre pour vous ?"; -"Tap here if the answer was not helpful to you" = "Touchez ici si la réponse reçue ne vous a pas aidé."; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Désolé. Dites-nous en un peu plus sur le problème que vous rencontrez ?"; -"Service Rating" = "Évaluation du service"; -"Your email" = "Votre e-mail"; -"Email invalid" = "E-mail invalide"; -"Could not fetch FAQs" = "FAQ irrécupérable"; -"Thanks for rating us." = "Merci de nous avoir notés."; -"Download" = "Télécharger"; -"Please enter a valid email" = "Entrez une adresse e-mail valide."; -"Message" = "Message"; -"or" = "ou"; -"Decline" = "Refuser"; -"No" = "Non"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Échec d'envoi de la capture d'écran. Image trop lourde, réessayez avec une autre image."; -"Hated it" = "J'ai détesté"; -"Stars" = "Étoiles"; -"Your feedback has been received." = "Votre commentaire a été reçu."; -"Dislike" = "Ne pas aimer"; -"Preview" = "Aperçu"; -"Book Now" = "Commander maintenant"; -"START A NEW CONVERSATION" = "LANCER UNE NOUVELLE CONVERSATION"; -"Your Rating" = "Votre évaluation"; -"No Internet!" = "Pas d'internet !"; -"Invalid Entry" = "Saisie invalide"; -"Loved it" = "J'ai adoré"; -"Review on the App Store" = "Publier une critique sur l'App Store"; -"Open Help" = "Ouvrir l'aide"; -"Search" = "Rechercher"; -"Tap here if you found this FAQ helpful" = "Touchez ici si vous avez trouvé la FAQ utile."; -"Star" = "Étoile"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Touchez ici si la réponse reçue vous a aidé."; -"Report a problem" = "Signaler un problème"; -"YES, THANKS!" = "OUI, MERCI !"; -"Was this helpful?" = "Cela a-t-il été utile ?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Votre message n'a pas été envoyé.Toucher \"Réessayer\" pour l'envoyer ?"; -"Suggestions" = "Suggestions"; -"No FAQs found" = "Aucune FAQ trouvée"; -"Done" = "Fait"; -"Opening Gallery..." = "Ouverture photothèque..."; -"You rated the service with" = "Vous avez évalué ce service avec"; -"Cancel" = "Annuler"; -"Loading..." = "Chargement..."; -"Read FAQs" = "Consulter les FAQ"; -"Thanks for messaging us!" = "Merci pour votre message !"; -"Try Again" = "Réessayer"; -"Send Feedback" = "Envoyer commentaire"; -"Your Name" = "Votre nom"; -"Please provide a name." = "Veuillez fournir un nom."; -"FAQ" = "FAQ"; -"Describe your problem" = "Décrivez votre problème"; -"How can we help?" = "Comment pouvons-nous vous aider ?"; -"Help about" = "Aide concernant"; -"We could not fetch the required data" = "Données requises irrécupérables"; -"Name" = "Nom"; -"Sending failed!" = "Échec de l'envoi !"; -"You didn't find this helpful." = "Vous n'avez pas trouvé cela utile."; -"Attach a screenshot of your problem" = "Joindre une capture d'écran de votre problème"; -"Mark as read" = "Marquer comme lu"; -"Name invalid" = "Nom invalide"; -"Yes" = "Oui"; -"What's on your mind?" = "Qu'avez-vous en tête ?"; -"Send a new message" = "Envoyer un nouveau message"; -"Questions that may already have your answer" = "Questions contenant peut-être déjà votre réponse"; -"Attach a photo" = "Joindre une photo"; -"Accept" = "Accepter"; -"Your reply" = "Votre réponse"; -"Inbox" = "Boîte de réception"; -"Remove attachment" = "Supprimer la pièce jointe"; -"Could not fetch message" = "Impossible de récupérer le message"; -"Read FAQ" = "Consulter la FAQ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Nous sommes désolés ! Cette conversation a été fermée pour inactivité. Veuillez commencer une nouvelle conversation avec nos agents."; -"%d new messages from Support" = "%d nouveaux messages de l'assistance"; -"Ok, Attach" = "Ok, joindre"; -"Send" = "Envoyer"; -"Screenshot size should not exceed %.2f MB" = "La taille d'une capture d'écran ne doit pas dépasser %.2f Mo."; -"Information" = "Informations"; -"Issue ID" = "Identifiant du problème"; -"Tap to copy" = "Touchez pour copier"; -"Copied!" = "Copié !"; -"We couldn't find an FAQ with matching ID" = "Impossible de trouver une FAQ avec cet identifiant"; -"Failed to load screenshot" = "Échec du chargement de la capture d'écran"; -"Failed to load video" = "Échec du chargement de la vidéo"; -"Failed to load image" = "Échec du chargement de l'image"; -"Hold down your device's power and home buttons at the same time." = "Maintenez en même temps les boutons Marche et Home de votre appareil."; -"Please note that a few devices may have the power button on the top." = "Veuillez noter que pour certains modèles, le bouton Marche est situé sur le dessus."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Ensuite, revenez à cette conversation et touchez « Ok, joindre » pour joindre le fichier."; -"Okay" = "Ok"; -"We couldn't find an FAQ section with matching ID" = "Impossible de trouver une section de la FAQ avec cet identifiant"; - -"GIFs are not supported" = "Les GIF ne sont pas pris en charge"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/gu.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/gu.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 2e020f323ec7..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/gu.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,150 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "તમે જે શોધી રહ્યા હતા તે મેળવી શકાતું નથી?"; -"Rate App" = "ઍપ્લિકેશન રેટ કરો"; -"We\'re happy to help you!" = "અમે તમને મદદ કરવામાં અમને આનંદ છે!"; -"Did we answer all your questions?" = "શું અમે તમારા બધા પ્રશ્નોના જવાબ આપ્યા?"; -"Remind Later" = "પછી યાદ કરાવો"; -"Your message has been received." = "તમારો મેસેજ પ્રાપ્ત થયો છે."; -"Message send failure." = "મેસેજ મોકલવામાં નિષ્ફળતા."; -"What\'s your feedback about our customer support?" = "અમારા કસ્ટમર સપોર્ટ વિષે તમારો શું પ્રતિભાવ છે?"; -"Take a screenshot on your iPhone" = "તમારા આઇફોન (iPhone) પર એક સ્ક્રીનશૉટ લો"; -"Learn how" = "જાણો કેવી રીતે"; -"Take a screenshot on your iPad" = "તમારા આઈપેડ (iPad) પર એક સ્ક્રીનશૉટ લો"; -"Your email(optional)" = "તમારો ઈમેઈલ (વૈકલ્પિક)"; -"Conversation" = "વાતચીત"; -"View Now" = "હમણાં જુઓ"; -"SEND ANYWAY" = "તો પણ મોકલો"; -"OK" = "ઓકે"; -"Help" = "મદદ"; -"Send message" = "મેસેજ મોકલો"; -"REVIEW" = "સમીક્ષા"; -"Share" = "શેઅર કરો"; -"Close Help" = "હેલ્પ બંધ કરો"; -"Sending your message..." = "તમારો મેસેજ મોકલાઈ રહ્યો છે..."; -"Learn how to" = "શીખો કેવી રીતે"; -"No FAQs found in this section" = "આ વિભાગમાં વારંવાર પૂછતા પ્રશ્નો મળ્યા નથી"; -"Thanks for contacting us." = "અમારો સંપર્ક કરવા બદ્દલ તમારો આભાર."; -"Chat Now" = "હમણાં ચેટ કરો"; -"Buy Now" = "હમણાં ખરીદો"; -"New Conversation" = "નવી વાતચીત"; -"Please check your network connection and try again." = "કૃપા કરીને તમારું નેટવર્ક કનેક્શન તપાસો અને ફરીથી પ્રયાસ કરો."; -"New message from Support" = "સપોર્ટ માંથી નવો મેસેજ"; -"Question" = "પ્રશ્ન"; -"Type in a new message" = "એક નવા મેસેજમાં ટાઈપ કરો"; -"Email (optional)" = "ઇમેઇલ (વૈકલ્પિક)"; -"Reply" = "જવાબ આપો"; -"CONTACT US" = "અમારો સંપર્ક કરો"; -"Email" = "ઇમેઇલ"; -"Like" = "લાઈક કરો"; -"Tap here if this FAQ was not helpful to you" = "જો જવાબ તમને મદદરૂપ હતો નથી તો અહીં ટૅપ કરો"; -"Any other feedback? (optional)" = "કોઈપણ અન્ય ફીડબૅક? (વૈકલ્પિક)"; -"You found this helpful." = "તમને આ મદદરૂપ લાગ્યું."; -"No working Internet connection is found." = "કોઈ સક્રિય ઈન્ટરનેટ કનેક્શન મળ્યું નથી."; -"No messages found." = "કોઈ મેસેજ ગોતી શકાયા નથી."; -"Please enter a brief description of the issue you are facing." = "તમે જે સમસ્યાનો સામનો કરી રહ્યા છો તેનું સંક્ષિપ્ત વર્ણન દાખલ કરો."; -"Shop Now" = "હમણાં ખરીદો"; -"Close Section" = "વિભાગ બંધ કરો"; -"Close FAQ" = "વારંવાર પૂછાતા પ્રશ્નો (એફએક્યુ) બંધ કરો"; -"Close" = "બંધ કરો"; -"This conversation has ended." = "આ વાતચીત સમાપ્ત થઈ છે."; -"Send it anyway" = "છતાં પણ મોકલો"; -"You accepted review request." = "તમે સમીક્ષાની વિનંતી સ્વીકારી છે."; -"Delete" = "ડીલીટ કરો"; -"What else can we help you with?" = "અમે તમારી બીજી શું મદદ કરી શકીએ?"; -"Tap here if the answer was not helpful to you" = "જો જવાબ તમને મદદરૂપ હતો નથી તો અહીં ટૅપ કરો"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "તે સાંભળીને દિલગીર છીએ. તમે જેનો સામનો કરી રહ્યા છો તે સમસ્યા વિષે કૃપા કરીને અમને વધુ કહેશો?"; -"Service Rating" = "સેવા સંબંધિત રેટિંગ"; -"Your email" = "તમારો ઇમેઇલ"; -"Email invalid" = "ઇમેઇલ અમાન્ય છે"; -"Could not fetch FAQs" = "વારંવાર પૂછાતા પ્રશ્નો લાવી શકાયા નથી"; -"Thanks for rating us." = "અમને રેટ કરવા બદ્દલ આભાર."; -"Download" = "ડાઉનલોડ કરો"; -"Please enter a valid email" = "કૃપા કરીને માન્ય ઇમેઇલ દાખલ કરો"; -"Message" = "મેસેજ"; -"or" = "અથવા"; -"Decline" = "નકારો"; -"No" = "ના"; -"Screenshot could not be sent. Image is too large, try again with another image" = "સ્ક્રીનશૉટ મોકલી શકાયો નથી. ઈમેજ ખૂબ જ મોટી છે, બીજી ઈમેજ સાથે ફરીથી પ્રયાસ કરો"; -"Hated it" = "અત્યંત ખરાબ"; -"Stars" = "સ્ટાર્સ"; -"Your feedback has been received." = "તમારો ફીડબૅક પ્રાપ્ત કરવામાં આવ્યો છે."; -"Dislike" = "ડિસલાઈક કરો"; -"Preview" = "પ્રીવ્યૂ"; -"Book Now" = "હમણાં બુક કરો"; -"START A NEW CONVERSATION" = "નવી વાતચીત શરૂ કરો"; -"Your Rating" = "તમારી રેટિંગ"; -"No Internet!" = "ઈન્ટરનેટ નથી!"; -"Invalid Entry" = "અમાન્ય એન્ટ્રી"; -"Loved it" = "ખૂબ જ સરસ"; -"Review on the App Store" = "ઍપ સ્ટોરમાં સમીક્ષા કરો"; -"Open Help" = "હેલ્પ ઓપન કરો"; -"Search" = "સર્ચ કરો"; -"Tap here if you found this FAQ helpful" = "અહીં ટૅપ કરો જો તમને આ વારંવાર પૂછાતા પ્રશ્નો (એફએક્યુ) મદદરૂપ લાગ્યો હોય"; -"Star" = "સ્ટાર"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "અહીં ટૅપ કરો જો તમને જવાબ મદદરૂપ લાગ્યો હોય"; -"Report a problem" = "એક સમસ્યા અહેવાલિત કરો"; -"YES, THANKS!" = "હા આભાર!"; -"Was this helpful?" = "શું આ મદદરૂપ હતું?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "તમારો મેસેજ મોકલાયો ન હતો. ટૅપ કરો \"આ મેસેજ મોકલવા માટે \"ફરીથી પ્રયત્ન કરો\"?"; -"Suggestions" = "સૂચનો"; -"No FAQs found" = "કોઈ વારંવાર પૂછાતા પ્રશ્નો મળ્યા નથી"; -"Done" = "થઈ ગયું"; -"Opening Gallery..." = "ગૅલરી ખુલી રહી છે..."; -"You rated the service with" = "તમે આ સાથે સેવાને રેટ કરી"; -"Cancel" = "રદ કરો"; -"Loading..." = "લોડ થઈ રહ્યું છે..."; -"Read FAQs" = "વારંવાર પૂછાતા પ્રશ્નો (એફએક્યુ) વાંચો"; -"Thanks for messaging us!" = "અમને મેસેજ કરવા બદ્દલ આભાર"; -"Try Again" = "ફરી પ્રયત્ન કરો"; -"Send Feedback" = "ફીડબૅક મોકલો"; -"Your Name" = "તમારું નામ"; -"Please provide a name." = "કૃપા કરીને એક નામ આપો."; -"FAQ" = "એફએક્યૂ (વારંવાર પૂછાતા પ્રશ્નો)"; -"Describe your problem" = "તમારી સમસ્યાનું વર્ણન કરો"; -"How can we help?" = "અમે કેવી રીતે મદદ કરી શકીએ?"; -"Help about" = "વિષે હેલ્પ"; -"We could not fetch the required data" = "અમે જરૂરી ડૅટા લાવી શક્યા નથી"; -"Name" = "નામ"; -"Sending failed!" = "મોકલવાનું નિષ્ફળ ગયું!"; -"You didn't find this helpful." = "તમને આ મદદરૂપ લાગ્યું નહીં."; -"Attach a screenshot of your problem" = "તમારી સમસ્યાનો એક સ્ક્રીનશૉટ જોડો"; -"Mark as read" = "વાંચેલ તરીકે ચિહ્નિત કરો"; -"Name invalid" = "નામ અમાન્ય છે"; -"Yes" = "હા"; -"What's on your mind?" = "તમારા મનમાં શું ચાલી રહ્યું છે?"; -"Send a new message" = "એક નવો મેસેજ મોકલો"; -"Questions that may already have your answer" = "પ્રશ્નો કે જેમના તમારા જવાબ પહેલેથી હોય"; -"Attach a photo" = "એક ફોટો જોડો"; -"Accept" = "સ્વીકારો"; -"Your reply" = "તમારો જવાબ"; -"Inbox" = "ઈનબૉક્સ"; -"Remove attachment" = "જોડાણ કાઢી નાખો"; -"Could not fetch message" = "મેસેજ લાવી શકાયો નથી"; -"Read FAQ" = "વારંવાર પૂછાતા પ્રશ્નો (એફએક્યુ) વાંચો"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "દિલગીર છીએ! આ વાતચીત નિષ્ક્રિયતા કારણે બંધ કરવામાં આવી હતી. કૃપા કરીને અમારા એજન્ટો સાથે નવી વાતચીત શરૂ કરો."; -"%d new messages from Support" = "%d સપોર્ટ તરફથી નવા મેસેજ"; -"Ok, Attach" = "ઠીક છે, જોડો"; -"Send" = "મોકલો"; -"Screenshot size should not exceed %.2f MB" = "સ્ક્રીનશૉટ સાઈઝ %.2f એમબી થી વધવી જોઈએ નહીં"; -"Information" = "માહિતી"; -"Issue ID" = "ઇસ્યુ આઇડી"; -"Tap to copy" = "ક્લિપબોર્ડમાં કોપી કરો"; -"Copied!" = "કોપી થયું!"; -"We couldn't find an FAQ with matching ID" = "અમે મેળ ખાતી આઈડી સાથે એફએક્યુ શોધી શક્યા નથી"; -"Failed to load screenshot" = "સ્ક્રીનશૉટ લોડ કરવામાં નિષ્ફળ"; -"Failed to load video" = "વિડિઓ લોડ કરવામાં નિષ્ફળ"; -"Failed to load image" = "ઇમેજ લોડ કરવામાં નિષ્ફળ"; -"Hold down your device's power and home buttons at the same time." = "તમારા ઉપકરણના પાવર અને હોમ બટનો એક જ સમયે દબાવી રાખો."; -"Please note that a few devices may have the power button on the top." = "કૃપા કરીને નોંધ લો કે કેટલાંક ઉપકરણોમાં પાવર બટન ઉપરની બાજુએ હોઈ શકે છે."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "એકવાર તે થઈ ગયા પછી, આ વાતચીત પાર પાછા આવો અને જોડવા માટે \"ઓકે, જોડે\" પર ટૅપ કરો."; -"Okay" = "ઓકે"; -"We couldn't find an FAQ section with matching ID" = "અમે મેળ ખાતી આઈડી સાથે FAQ વિભાગ શોધી શક્યા નથી"; - -"GIFs are not supported" = "GIF સપોર્ટેડ નથી"; - diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/he.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/he.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 352873e7beb9..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/he.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* - HelpshiftLocalizable.strings - Helpshift - Copyright (c) 2014 Helpshift,Inc., All rights reserved. - */ - -"Can't find what you were looking for?" = "לא הצלחת למצוא את מה שאת/ה מחפש/ת?"; -"Rate App" = "דרג/י את האפליקציה"; -"We\'re happy to help you!" = "נשמח לעזור לך!"; -"Did we answer all your questions?" = "האם ענינו על כל השאלות שלך?"; -"Remind Later" = "הזכר/הזכירי לי מאוחר יותר"; -"Your message has been received." = "הודעתך התקבלה."; -"Message send failure." = "נכשלה שליחת ההודעה."; -"What\'s your feedback about our customer support?" = "מהו המשוב שלך על התמיכה בלקוחות?"; -"Take a screenshot on your iPhone" = "בצע/י צילום מסך מה-iPhone"; -"Learn how" = "למידע נוסף"; -"Take a screenshot on your iPad" = "בצע/י צילום מסך מה-iPad"; -"Your email(optional)" = "הדוא\"ל שלך (אופציונלי)"; -"Conversation" = "שיחה"; -"View Now" = "הצג/הציגי עכשיו"; -"SEND ANYWAY" = "שלח בכל זאת"; -"OK" = "אישור"; -"Help" = "עזרה"; -"Send message" = "שלח/י הודעה"; -"REVIEW" = "סקירה"; -"Share" = "שתף/שתפי"; -"Close Help" = "סגור/סגרי את העזרה"; -"Sending your message..." = "שולח את ההודעה..."; -"Learn how to" = "למידע נוסף"; -"No FAQs found in this section" = "אין שאלות נפוצות במקטע זה"; -"Thanks for contacting us." = "תודה שפנית אלינו."; -"Chat Now" = "שוחח/י בצ'אט עכשיו"; -"Buy Now" = "קנה/קני עכשיו"; -"New Conversation" = "שיחה חדשה"; -"Please check your network connection and try again." = "נא לבדוק את החיבור לרשת ולנסות שוב."; -"New message from Support" = "הודעה חדשה מתמיכה"; -"Question" = "שאלה"; -"Type in a new message" = "הקלד/הקלידי הודעה חדשה"; -"Email (optional)" = "דוא\"ל (אופציונלי)"; -"Reply" = "השב/השיבי"; -"CONTACT US" = "צרו קשר"; -"Email" = "דוא\"ל"; -"Like" = "אהבתי"; -"Tap here if this FAQ was not helpful to you" = "הקש/הקישי כאן אם שאלות נפוצות אלה לא הועילו לך"; -"Any other feedback? (optional)" = "האם יש לך משוב נוסף? (אופציונלי)"; -"You found this helpful." = "זה עזר לך."; -"No working Internet connection is found." = "לא נמצא חיבור פעיל לאינטרנט."; -"No messages found." = "לא נמצאו הודעות."; -"Please enter a brief description of the issue you are facing." = "נא להזין תיאור קצר של הבעיה שבה נתקלת."; -"Shop Now" = "קנה/קני עכשיו"; -"Close Section" = "סגור/סגרי מקטע"; -"Close FAQ" = "סגור/סגרי שאלות נפוצות"; -"Close" = "סגור"; -"This conversation has ended." = "שיחה זו הסתיימה."; -"Send it anyway" = "שלח/י בכל זאת"; -"You accepted review request." = "קיבלת את בקשת הסקירה."; -"Delete" = "מחק/י"; -"What else can we help you with?" = "מה עוד נוכל לעשות למענך?"; -"Tap here if the answer was not helpful to you" = "הקש/הקישי כאן אם התשובה לא הועילה לך"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "מצטערים על כך. האם אפשר לספר לנו קצת יותר על הבעיה שבה נתקלת?"; -"Service Rating" = "דירוג שירות"; -"Your email" = "הדוא\"ל שלך"; -"Email invalid" = "דוא\"ל לא תקף"; -"Could not fetch FAQs" = "לא ניתן להביא שאלות נפוצות"; -"Thanks for rating us." = "תודה שדירגת אותנו."; -"Download" = "הורד/הורידי"; -"Please enter a valid email" = "הזן/הזיני כתובת דוא\"ל תקפה"; -"Message" = "הודעה"; -"or" = "או"; -"Decline" = "דחה/דחי"; -"No" = "לא"; -"Screenshot could not be sent. Image is too large, try again with another image" = "נכשלה שליחת צילום המסך. התמונה גדולה מדי, יש לנסות שוב עם תמונה אחרת"; -"Hated it" = "שנאתי את זה"; -"Stars" = "כוכבים"; -"Your feedback has been received." = "משובך התקבל."; -"Dislike" = "לא אהבתי"; -"Preview" = "צפייה מקדימה"; -"Book Now" = "הזמן/הזמיני עכשיו"; -"START A NEW CONVERSATION" = "התחל שיחה חדשה"; -"Your Rating" = "הדירוג שלך"; -"No Internet!" = "אין אינטרנט!"; -"Invalid Entry" = "ערך לא תקף"; -"Loved it" = "אהבתי את זה"; -"Review on the App Store" = "סקירה ב-App Store"; -"Open Help" = "פתח/י את העזרה"; -"Search" = "חיפוש"; -"Tap here if you found this FAQ helpful" = "הקש/הקישי כאן אם שאלות נפוצות אלה הועילו לך"; -"Star" = "כוכב"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "הקש/הקישי כאן אם תשובה זו הועילה לך"; -"Report a problem" = "דווח/י על בעיה"; -"YES, THANKS!" = "כן, תודה!"; -"Was this helpful?" = "האם זה עזר?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "הודעתך לא נשלחה. נא להקיש \"לנסות שוב\" לשלוח הודעה זו?"; -"Suggestions" = "הצעות"; -"No FAQs found" = "לא נמצאו שאלות נפוצות"; -"Done" = "סיום"; -"Opening Gallery..." = "פותח גלריה..."; -"You rated the service with" = "דירגת את השירות עם"; -"Cancel" = "ביטול"; -"Loading..." = "טוען..."; -"Read FAQs" = "קרא/י שאלות נפוצות"; -"Thanks for messaging us!" = "תודה על הודעתך!"; -"Try Again" = "נסה שוב"; -"Send Feedback" = "שלח/י משוב"; -"Your Name" = "שמך"; -"Please provide a name." = "נא לספק שם."; -"FAQ" = "שאלות נפוצות"; -"Describe your problem" = "תאר/י את הבעיה"; -"How can we help?" = "כיצד נוכל לעזור?"; -"Help about" = "עזרה בנושא"; -"We could not fetch the required data" = "לא הצלחנו להביא את הנתונים הדרושים"; -"Name" = "שם"; -"Sending failed!" = "נכשלה השליחה!"; -"You didn't find this helpful." = "זה לא היה מועיל."; -"Attach a screenshot of your problem" = "צרף/צרפי צילום מסך של הבעיה"; -"Mark as read" = "סמן/סמני כהודעה שנקראה"; -"Name invalid" = "שם לא תקף"; -"Yes" = "כן"; -"What's on your mind?" = "מה קורה?"; -"Send a new message" = "שלח/י הודעה חדשה"; -"Questions that may already have your answer" = "שאלות שיתכן שכוללות כבר תשובה בשבילך"; -"Attach a photo" = "צרף/צרפי תמונה"; -"Accept" = "קבל/י"; -"Your reply" = "המענה שלך"; -"Inbox" = "תיבת דואר נכנס"; -"Remove attachment" = "הסר/הסירי קובץ מצורף"; -"Could not fetch message" = "לא ניתן להביא הודעה"; -"Read FAQ" = "קרא/י שאלות נפוצות"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "מצטערים! שיחה זו נסגרה עקב העדר פעילות. התחל שיחה חדשה עם סוכנים."; -"%d new messages from Support" = "%d הודעות חדשות מהתמיכה"; -"Ok, Attach" = "בסדר, צרף קובץ"; -"Send" = "שלח"; -"Screenshot size should not exceed %.2f MB" = "גודל צילום המסך לא יכול לחרוג מ-%.2f MB"; -"Information" = "מידע"; -"Issue ID" = "זיהוי הנפקה"; -"Tap to copy" = "הקש כדי להעתיק"; -"Copied!" = "הועתק!"; -"We couldn't find an FAQ with matching ID" = "לא הצלחנו למצוא שאלה נפוצה עם קוד זיהוי תואם"; -"Failed to load screenshot" = "טעינת צילום המסך נכשלה"; -"Failed to load video" = "טעינת הסרטון נכשלה"; -"Failed to load image" = "טעינת התמונה נכשלה"; -"Hold down your device's power and home buttons at the same time." = "יש להחזיק את לחצן ההפעלה ואת לחצן הבית לחוצים בו-זמנית במכשיר."; -"Please note that a few devices may have the power button on the top." = "לתשומת לבך, יתכן שלחצן ההפעלה יהיה בראש המכשיר במכשירים מסוימים."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "לאחר ביצוע הפעולה, יש לחזור לשיחה זו ולהקיש על \"בסדר, צרף קובץ\" כדי לצרף אותו."; -"Okay" = "אישור"; -"We couldn't find an FAQ section with matching ID" = "לא הצלחנו למצוא מקטע של שאלות נפוצות עם מזהה תואם"; - -"GIFs are not supported" = "קבצי GIF אינם נתמכים"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/hi.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/hi.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 56ae17a4e725..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/hi.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "आप जो खोज रहे हैं, वह नहीं मिल रहा है?"; -"Rate App" = "ऐप का मूल्यांकन करें"; -"We\'re happy to help you!" = "हम आपकी सहायता करके प्रसन्न हैं!"; -"Did we answer all your questions?" = "क्या हमने आपके सभी प्रश्नों के उत्तर दिए?"; -"Remind Later" = "बाद में याद दिलाएं"; -"Your message has been received." = "आपका संदेश प्राप्त हो गया है।"; -"Message send failure." = "संदेश प्रेषण विफल रहा।"; -"What\'s your feedback about our customer support?" = "हमारी ग्राहक सेवा के संबंध में आपकी प्रतिक्रिया क्या है?"; -"Take a screenshot on your iPhone" = "अपने iPhone पर एक स्क्रीनशॉट लें"; -"Learn how" = "तरीका सीखें"; -"Take a screenshot on your iPad" = "अपने iPad पर एक स्क्रीनशॉट लें"; -"Your email(optional)" = "आपका ईमेल (वैकल्पिक)"; -"Conversation" = "वार्तालाप"; -"View Now" = "अभी देखें"; -"SEND ANYWAY" = "किसी भी तरह् से भेजें"; -"OK" = "ठीक है"; -"Help" = "मदद"; -"Send message" = "संदेश भेजें"; -"REVIEW" = "समीक्षा करें"; -"Share" = "साझा करें"; -"Close Help" = "सहायता बंद करें"; -"Sending your message..." = "आपका संदेश भेज रहा है..."; -"Learn how to" = "तरीका सीखें"; -"No FAQs found in this section" = "इस अनुभाग में कोई अक्सर पूछे गए प्रश्न नहीं मिलेे"; -"Thanks for contacting us." = "हमसे संपर्क करने के लिए धन्यवाद।"; -"Chat Now" = "अभी चैट करें"; -"Buy Now" = "अभी खरीदें"; -"New Conversation" = "नया वार्तालाप"; -"Please check your network connection and try again." = "कृपया अपना नेटवर्क कनेक्शन जांचें तथा पुन: प्रयास करेंं।"; -"New message from Support" = "समर्थन के नए संदेश"; -"Question" = "प्रश्न"; -"Type in a new message" = "एक नया संदेश टाइप करें"; -"Email (optional)" = "ईमेल (वैकल्पिक)"; -"Reply" = "जवाब दीजिए"; -"CONTACT US" = "हमसे संपर्क करें"; -"Email" = "ईमेल"; -"Like" = "पसंद"; -"Tap here if this FAQ was not helpful to you" = "यदि यह FAQ आपके लिए उपयोगी नहीं था तो यहाँ टैप करें"; -"Any other feedback? (optional)" = "कोई अन्य प्रतिक्रिया? (वैकल्पिक)"; -"You found this helpful." = "आपको यह उपयोगी लगा।"; -"No working Internet connection is found." = "कोई कार्यशील इंटरनेट कनेक्शन नहीं मिला।"; -"No messages found." = "कोई संदेश नहीं मिला."; -"Please enter a brief description of the issue you are facing." = "आप जिस समस्या का सामना कर रहे हैं, कृपया उसका संक्षिप्त विवरण दर्ज करें।"; -"Shop Now" = "अब खरीददारी करें"; -"Close Section" = "सेक्शन बंद करें"; -"Close FAQ" = "अक्सर पूछे गए सवाल (FAQ) बंद करें"; -"Close" = "बंद करें"; -"This conversation has ended." = "यह वार्तालाप समाप्त हो चुका है"; -"Send it anyway" = "इसे किसी भी तरह् से भेजें"; -"You accepted review request." = "आपने समीक्षा अनुरोध स्वीकार किया है."; -"Delete" = "हटाएं"; -"What else can we help you with?" = "हम आपकी और किस तरह से मदद कर सकते हैं?"; -"Tap here if the answer was not helpful to you" = "यदि जवाब आपके लिए उपयोगी नहीं था तो यहाँ टैप करें"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "यह जानकर दुख हुआ। क्या आप जिस समस्या का सामना कर रहे हैं, उसके बारे में थोड़ा विस्तार से बताएंगे?"; -"Service Rating" = "सेवा का मूल्यांकन"; -"Your email" = "आपका ईमेल"; -"Email invalid" = "अमान्य ईमेल"; -"Could not fetch FAQs" = "सामान्य प्रश्न फ़ेच नहीं कर सके"; -"Thanks for rating us." = "हमारा मूल्यांकन करने के लिए धन्यवाद।"; -"Download" = "डाउनलोड करें"; -"Please enter a valid email" = "कृपया एक मान्य ईमेल दर्ज करें"; -"Message" = "संदेश"; -"or" = "या"; -"Decline" = "रद्द करें"; -"No" = "नहीं"; -"Screenshot could not be sent. Image is too large, try again with another image" = "स्क्रीनशॉट नहीं भेजा जा सका। छवि बहुत बड़ी है, दूसरी छवि के साथ पुन: प्रयास करें"; -"Hated it" = "इससे घृणा है"; -"Stars" = "स्टार"; -"Your feedback has been received." = "आपकी प्रतिक्रिया प्राप्त हो चुकी है।"; -"Dislike" = "नापसंद"; -"Preview" = "पूर्वावलोकन"; -"Book Now" = "अभी बुक करें"; -"START A NEW CONVERSATION" = "नया वार्तालाप शुरू करें"; -"Your Rating" = "आपका मूल्यांकन"; -"No Internet!" = "कोई इंटरनेट नहीं!"; -"Invalid Entry" = "अमान्य प्रविष्टि"; -"Loved it" = "यह पसंद है"; -"Review on the App Store" = "ऐप स्टोर में समीक्षा करें"; -"Open Help" = "सहायता खोलें"; -"Search" = "खोजें"; -"Tap here if you found this FAQ helpful" = "यदि आपको य‍ह FAQ उपयोगी लगा तो यहाँ टैप करें"; -"Star" = "स्टार"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "यदि आपको य‍ह उपयोगी लगा तो यहाँ टैप करें"; -"Report a problem" = "एक समस्या की रिपोर्ट करें"; -"YES, THANKS!" = "हां, धन्यवाद!"; -"Was this helpful?" = "क्या यह उपयोगी था?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "आपका संदेश प्रेषित नहीं किया गया।इस संदेश को भेजने के लिए \"पुन: प्रयास करें\" पर टैप करें?"; -"Suggestions" = "सुझाव"; -"No FAQs found" = "कोई FAQ नहीं मिला"; -"Done" = "पूर्ण हो गया"; -"Opening Gallery..." = "गैलरी खुल रही है..."; -"You rated the service with" = "आपने सेवा का मूल्यांकन इसके साथ किया है"; -"Cancel" = "रद्द करें"; -"Loading..." = "लोड हो रहा है..."; -"Read FAQs" = "अक्सर पूछे गए सवाल (FAQ) पढ़ें"; -"Thanks for messaging us!" = "हमे संदेश प्रेषित करने के लिए धन्यवाद!"; -"Try Again" = "पुन: कोशिश करें"; -"Send Feedback" = "प्रतिक्रिया भेजें"; -"Your Name" = "आपका नाम"; -"Please provide a name." = "कृपया कोई नाम प्रदान करें।"; -"FAQ" = "FAQ"; -"Describe your problem" = "अपनी समस्या का विवरण दें"; -"How can we help?" = "हम आपकी कैसे मदद कर सकते हैं?"; -"Help about" = "इस विषय में सहायता"; -"We could not fetch the required data" = "हम आवश्यक डेट फ़ेच नहीं कर सके"; -"Name" = "नाम"; -"Sending failed!" = "प्रेषण विफल रहा!"; -"You didn't find this helpful." = "आपके लिए यह उपयोगी नहीं रहा।"; -"Attach a screenshot of your problem" = "अपनी समस्या का एक स्क्रीनशॉट संलग्न करें"; -"Mark as read" = "पढ़ेे गए चिह्नित करें"; -"Name invalid" = "नाम अमान्य"; -"Yes" = "हाँ"; -"What's on your mind?" = "आपके मन में क्या चल रहा है?"; -"Send a new message" = "एक नया संदेश भेजें"; -"Questions that may already have your answer" = "ऐसे प्रश्न जो आपके उत्तर में पहले से ही हो सकते हैं"; -"Attach a photo" = "एक फोटो संलग्न करें"; -"Accept" = "स्वीकार करें"; -"Your reply" = "आपका जवाब"; -"Inbox" = "इनबॉक्स"; -"Remove attachment" = "संलग्नक हटाएं"; -"Could not fetch message" = "संदेश हासिल नहीं कर सके"; -"Read FAQ" = "अक्सर पूछे गए सवाल (FAQ) देखें"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "माफ करें! निष्क्रियता के कारण यह वार्तालाप बंद कर दिया गया था. कृपया हमारे एजेंटों के साथ नया वार्तालाप आरंभ करें."; -"%d new messages from Support" = "%d समर्थन के नए संदेश"; -"Ok, Attach" = "ठीक है, संलग्न करें"; -"Send" = "भेजिए"; -"Screenshot size should not exceed %.2f MB" = "स्क्रीनशॉट आकार %.2f MB से ज्यादा नहीं होना चाहिए"; -"Information" = "जानकारी"; -"Issue ID" = "समस्या ID"; -"Tap to copy" = "कॉपी करने के लिए टैप करें"; -"Copied!" = "कॉपी हुआ!"; -"We couldn't find an FAQ with matching ID" = "इस ID से अक्सर किये जाने वाले सवाल नहीं मिल सके"; -"Failed to load screenshot" = "स्क्रीनशॉट लोड करने में विफल"; -"Failed to load video" = "विडियो लोड करने में विफल"; -"Failed to load image" = "इमेज लोड करने में विफल"; -"Hold down your device's power and home buttons at the same time." = "एक ही समय में अपने डिवाइस के पावर और होम बटन दबाए रखें।"; -"Please note that a few devices may have the power button on the top." = "कृपया ध्यान दें की कुछ उपकरणों में पावर बटन ऊपरी हिस्से में हो सकता है।"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "यह करने के बाद, वापस इस बातचीत पर आएं और \"ठीक है, संलग्न करें\" पर टैप करके संलग्नक करें।"; -"Okay" = "ठीक है"; -"We couldn't find an FAQ section with matching ID" = "इस ID से अक्सर किये जाने वाले सवालों का अनुभाग नहीं मिल सका"; - -"GIFs are not supported" = "जीआईएफ समर्थित नहीं हैं"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/hr.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/hr.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 9f56f69fce41..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/hr.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Ne možete pronaći što tražite?"; -"Rate App" = "Ocijeni aplikaciju"; -"We\'re happy to help you!" = "Sretni smo što vam možemo pomoći"; -"Did we answer all your questions?" = "Jesmo li odgovorili na sva vaša pitanja?"; -"Remind Later" = "Podsjeti kasnije"; -"Your message has been received." = "Vaša je poruka primljena."; -"Message send failure." = "Neuspješno slanje poruke."; -"What\'s your feedback about our customer support?" = "Jeste li zadovoljni s našom službom za korisnike?"; -"Take a screenshot on your iPhone" = "Snimite snimku zaslona na svojem uređaju iPhone"; -"Learn how" = "Saznajte kako"; -"Take a screenshot on your iPad" = "Snimite snimku zaslona na svojem uređaju iPad"; -"Your email(optional)" = "Vaša adresa elektroničke pošte (dodatno)"; -"Conversation" = "Razgovor"; -"View Now" = "Pregledaj sada"; -"SEND ANYWAY" = "SVEJEDNO POŠALJI"; -"OK" = "U REDU"; -"Help" = "Pomoć"; -"Send message" = "Pošalji poruku"; -"REVIEW" = "OSVRT"; -"Share" = "Podijeli"; -"Close Help" = "Zatvori Pomoć"; -"Sending your message..." = "Slanje vaše poruke..."; -"Learn how to" = "Saznajte kako"; -"No FAQs found in this section" = "Nijedno često postavljano pitanje nije pronađeno u ovom odjeljku"; -"Thanks for contacting us." = "Hvala vam na poruci."; -"Chat Now" = "Čavrlja sada"; -"Buy Now" = "Kupi sada"; -"New Conversation" = "Novi razgovor"; -"Please check your network connection and try again." = "Provjerite svoju internetsku vezu i pokušajte ponovo."; -"New message from Support" = "Nova poruka od Službe za podršku"; -"Question" = "Pitanje"; -"Type in a new message" = "Napiši u novoj poruci"; -"Email (optional)" = "Adresa elektroničke pošte (neobvezno)"; -"Reply" = "Odgovori"; -"CONTACT US" = "OBRATITE NAM SE"; -"Email" = "Adresa elektroničke pošte"; -"Like" = "Sviđa mi se"; -"Tap here if this FAQ was not helpful to you" = "Ovdje dodirnite ako vam ovo Često postavljeno pitanje nije bilo korisno"; -"Any other feedback? (optional)" = "Dodatne povratne informacije? (neobvezno)"; -"You found this helpful." = "Ovo mi je objašnjenje bilo korisno."; -"No working Internet connection is found." = "Nije pronađena nijedna aktivna internetska veza."; -"No messages found." = "Nijedna poruka nije pronađena."; -"Please enter a brief description of the issue you are facing." = "Navedite kratak opis problema na koji ste naišli."; -"Shop Now" = "Kupuj sada"; -"Close Section" = "Zatvori Odjeljak"; -"Close FAQ" = "Zatvori Često postavljana pitanja"; -"Close" = "Zatvori"; -"This conversation has ended." = "Ovaj je razgovor završen."; -"Send it anyway" = "Svejedno pošalji"; -"You accepted review request." = "Prihvatili ste zahtjev za osvrtom."; -"Delete" = "Izbriši"; -"What else can we help you with?" = "Trebate li dodatnu pomoć?"; -"Tap here if the answer was not helpful to you" = "Ovdje dodirnite ako odgovor nije bio koristan"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Ispričavamo se zbog toga. Možete li nam reći nešto više o problemu na koji ste naišli?"; -"Service Rating" = "Ocjena pružene usluge"; -"Your email" = "Vaša adresa elektroničke pošte"; -"Email invalid" = "Neispravna adresa elektroničke pošte"; -"Could not fetch FAQs" = "Nije bilo moguće dohvatiti često postavljana pitanja"; -"Thanks for rating us." = "Hvala vam na ocjeni."; -"Download" = "Preuzmi"; -"Please enter a valid email" = "Upišite ispravnu adresu elektroničke pošte"; -"Message" = "Poruka"; -"or" = "ili"; -"Decline" = "Odbij"; -"No" = "Ne"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Snimka zaslona nije poslana. Slika je prevelika. Pokušajte poslati drugu sliku"; -"Hated it" = "Ne sviđa mi se"; -"Stars" = "Zvjezdice"; -"Your feedback has been received." = "Vaše su povratne informacije primljene."; -"Dislike" = "Ne sviđa mi se"; -"Preview" = "Pregled"; -"Book Now" = "Zakaži sada"; -"START A NEW CONVERSATION" = "ZAPOČNI NOVI RAZGOVOR"; -"Your Rating" = "Vaša ocjena"; -"No Internet!" = "Nema internetske veze!"; -"Invalid Entry" = "Neispravan unos"; -"Loved it" = "Sviđa mi se"; -"Review on the App Store" = "Ocijenite u trgovini App Store"; -"Open Help" = "Otvori pomoć"; -"Search" = "Traži"; -"Tap here if you found this FAQ helpful" = "Ovdje dodirnite ako vam je ovo Često postavljeno pitanje bilo korisno"; -"Star" = "Zvjezdica"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Ovdje dodirnite ako vam je ovaj odgovor bio koristan"; -"Report a problem" = "Prijavi problem"; -"YES, THANKS!" = "DA, HVALA"; -"Was this helpful?" = "Je li vam ovo objašnjenje bilo korisno?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Vaša poruka nije poslana. Dodirnite \"Pokušajte ponovno\" kako biste poslali poruku?"; -"Suggestions" = "Prijedlozi"; -"No FAQs found" = "Nijedno često postavljano pitanje nije pronađeno"; -"Done" = "Završeno"; -"Opening Gallery..." = "Otvaranje galerije..."; -"You rated the service with" = "Ocijenili ste uslugu s"; -"Cancel" = "Odustani"; -"Loading..." = "Učitavanje..."; -"Read FAQs" = "Pročitaj Često postavljana pitanja"; -"Thanks for messaging us!" = "Hvala vam na slanju poruke!"; -"Try Again" = "Pokušajte ponovno"; -"Send Feedback" = "Pošalji povratne informacije"; -"Your Name" = "Vaše ime"; -"Please provide a name." = "Upišite drugo ime."; -"FAQ" = "Često postavljana pitanja"; -"Describe your problem" = "Opišite svoj problem"; -"How can we help?" = "Kako vam možemo pomoći?"; -"Help about" = "Pomoć za"; -"We could not fetch the required data" = "Nismo mogli dohvatiti tražene podatke"; -"Name" = "Ime"; -"Sending failed!" = "Neuspješno slanje!"; -"You didn't find this helpful." = "Ovo objašnjenje nije mi bilo korisno."; -"Attach a screenshot of your problem" = "Priložite snimku zaslona problema"; -"Mark as read" = "Označi kao pročitano"; -"Name invalid" = "Neispravno ime"; -"Yes" = "Da"; -"What's on your mind?" = "O čemu razmišljate?"; -"Send a new message" = "Pošalji novu poruku"; -"Questions that may already have your answer" = "Vaša pitanja koja su možda već odgovorena"; -"Attach a photo" = "Priloži fotografiju"; -"Accept" = "Prihvati"; -"Your reply" = "Vaš odgovor"; -"Inbox" = "Ulazni pretinac"; -"Remove attachment" = "Ukloni prilog"; -"Could not fetch message" = "Nije bilo moguće dohvatiti poruku"; -"Read FAQ" = "Pročitaj Često postavljeno pitanje"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Ispričavamo se! Ovaj je razgovor završen zbog neaktivnosti. Započnite novi razgovor s našim agentima."; -"%d new messages from Support" = "Broj novih poruka od službe za podršku: %d"; -"Ok, Attach" = "U redu, priloži"; -"Send" = "Pošalji"; -"Screenshot size should not exceed %.2f MB" = "Veličina snimke zaslona ne smije biti veća od %.2f MB"; -"Information" = "Informacije"; -"Issue ID" = "Izdaj identifikacijsku oznaku"; -"Tap to copy" = "Dodirni za kopiranje"; -"Copied!" = "Kopiraj"; -"We couldn't find an FAQ with matching ID" = "Nismo pronašli Često postavljano pitanje s pripadajućom identifikacijskom oznakom"; -"Failed to load screenshot" = "Neuspješno učitavanje snimke zaslona"; -"Failed to load video" = "Neuspješno učitavanje videozapisa"; -"Failed to load image" = "Neuspješno učitavanje slike"; -"Hold down your device's power and home buttons at the same time." = "Istodobno pritisnite gumb za uključivanje / isključivanje te gumb HOME na svojem uređaju."; -"Please note that a few devices may have the power button on the top." = "Napominjemo vam da neki uređaji imaju gumb za uključivanje/isključivanje na gornjem dijelu uređaja."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Nakon što obavite navedenu radnju, vratite se na razgovor i dodirnite „U redu, priloži” kako biste je priložili."; -"Okay" = "U redu"; -"We couldn't find an FAQ section with matching ID" = "Nismo pronašli odjeljak Često postavljano pitanje s pripadajućom identifikacijskom oznakom"; - -"GIFs are not supported" = "GIF-ovi nisu podržani"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/hu.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/hu.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index d8c43d6594cb..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/hu.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Nem találja, amit keresett?"; -"Rate App" = "Alkalmazás értékelése"; -"We\'re happy to help you!" = "Nagyon szívesen segítünk!"; -"Did we answer all your questions?" = "Minden kérdését megválaszoltuk?"; -"Remind Later" = "Emlékeztessen később"; -"Your message has been received." = "Az üzenetét megkaptuk."; -"Message send failure." = "Üzenetküldési hiba."; -"What\'s your feedback about our customer support?" = "Mi a véleménye az ügyfélszolgálatunkról?"; -"Take a screenshot on your iPhone" = "Készítsen képernyőfotót az iPhone-ján"; -"Learn how" = "Ismerje meg, hogyan"; -"Take a screenshot on your iPad" = "Készítsen képernyőfotót az iPad-jén"; -"Your email(optional)" = "Az Ön e-mail címe (opcionális)"; -"Conversation" = "Beszélgetés"; -"View Now" = "Megtekintés"; -"SEND ANYWAY" = "KÜLDÉS MINDENKÉPPEN"; -"OK" = "OK"; -"Help" = "Súgó"; -"Send message" = "Üzenet küldése"; -"REVIEW" = "VÉLEMÉNY"; -"Share" = "Megosztás"; -"Close Help" = "Súgó bezárása"; -"Sending your message..." = "Üzenet elküldése..."; -"Learn how to" = "Ismerje meg, hogyan"; -"No FAQs found in this section" = "Nem található GYIK ebben a részben"; -"Thanks for contacting us." = "Köszönjük, hogy hozzánk fordult."; -"Chat Now" = "Csevegés"; -"Buy Now" = "Vásárlás"; -"New Conversation" = "Új beszélgetés"; -"Please check your network connection and try again." = "Ellenőrizze a hálózati kapcsolatát, és próbálja újra."; -"New message from Support" = "Új üzenet az Ügyfélszolgálattól"; -"Question" = "Kérdés"; -"Type in a new message" = "Írjon be új üzenetet"; -"Email (optional)" = "E-mail cím (opcionális)"; -"Reply" = "Válasz"; -"CONTACT US" = "KAPCSOLAT"; -"Email" = "E-mail cím"; -"Like" = "Tetszik"; -"Tap here if this FAQ was not helpful to you" = "Itt érintse meg a kijelzőt, ha nem találta hasznosnak ezt a gyakori kérdést és választ"; -"Any other feedback? (optional)" = "Van további visszajelzése? (opcionális)"; -"You found this helpful." = "Hasznosnak találta."; -"No working Internet connection is found." = "Nincs működő internetkapcsolat."; -"No messages found." = "Nem található üzenet."; -"Please enter a brief description of the issue you are facing." = "Kérjük, adjon rövid leírást a felmerült problémáról."; -"Shop Now" = "Vásárlás"; -"Close Section" = "Rész bezárása"; -"Close FAQ" = "GYIK bezárása"; -"Close" = "Bezárás"; -"This conversation has ended." = "A beszélgetés befejeződött."; -"Send it anyway" = "Küldés mindenképpen"; -"You accepted review request." = "Ön elfogadta a véleményezési kérést."; -"Delete" = "Törlés"; -"What else can we help you with?" = "Segíthetünk valami másban is?"; -"Tap here if the answer was not helpful to you" = "Itt érintse meg a kijelzőt, ha nem találta hasznosnak a választ"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Sajnálattal halljuk. Tudna esetleg több információt adni a felmerült problémáról?"; -"Service Rating" = "Szolgáltatás értékelése"; -"Your email" = "Az Ön e-mail címe"; -"Email invalid" = "Érvénytelen e-mail cím"; -"Could not fetch FAQs" = "GYIK-ek lekérése sikertelen"; -"Thanks for rating us." = "Köszönjük, hogy értékelte alkalmazásunkat."; -"Download" = "Letöltés"; -"Please enter a valid email" = "Kérjük, érvényes e-mail címet adjon meg"; -"Message" = "Üzenet"; -"or" = "vagy"; -"Decline" = "Elutasítás"; -"No" = "Nem"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Képernyőfotó küldése sikertelen. A kép túl nagy, próbálja újra egy másik képpel"; -"Hated it" = "Nem tetszett"; -"Stars" = "Csillag"; -"Your feedback has been received." = "A visszajelzését megkaptuk."; -"Dislike" = "Nem tetszik"; -"Preview" = "Előnézet"; -"Book Now" = "Foglalás"; -"START A NEW CONVERSATION" = "ÚJ BESZÉLGETÉS INDÍTÁSA"; -"Your Rating" = "Az Ön értékelése"; -"No Internet!" = "Nincs internet!"; -"Invalid Entry" = "Érvénytelen megadott adat"; -"Loved it" = "Tetszett"; -"Review on the App Store" = "Véleményezhet az App Store oldalon"; -"Open Help" = "Súgó megnyitása"; -"Search" = "Keresés"; -"Tap here if you found this FAQ helpful" = "Itt érintse meg a kijelzőt, ha hasznosnak találta ezt a gyakori kérdést és választ"; -"Star" = "Csillag"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Itt érintse meg a kijelzőt, ha hasznosnak találta a választ"; -"Report a problem" = "Probléma jelentése"; -"YES, THANKS!" = "IGEN, KÖSZÖNÖM!"; -"Was this helpful?" = "Hasznos volt a válasz?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Üzenetét nem sikerült elküldeni. Koppintson az \"Újrapróbálkozás\" gombra, hogy elküldje az üzenetet?"; -"Suggestions" = "Javaslatok"; -"No FAQs found" = "Nem található GYIK"; -"Done" = "Kész"; -"Opening Gallery..." = "Fotók megnyitása..."; -"You rated the service with" = "A szolgáltatást az alábbi módon értékelte:"; -"Cancel" = "Mégsem"; -"Loading..." = "Betöltés..."; -"Read FAQs" = "GYIK olvasása"; -"Thanks for messaging us!" = "Köszönjük az üzenetét!"; -"Try Again" = "Újrapróbálkozás"; -"Send Feedback" = "Visszajelzés küldése"; -"Your Name" = "Az Ön neve"; -"Please provide a name." = "Kérjük, adjon meg egy nevet."; -"FAQ" = "GYIK"; -"Describe your problem" = "Írja le a problémát"; -"How can we help?" = "Miben segíthetünk?"; -"Help about" = "Súgó erről"; -"We could not fetch the required data" = "Nem tudtuk lekérni a szükséges adatokat"; -"Name" = "Név"; -"Sending failed!" = "Küldés sikertelen!"; -"You didn't find this helpful." = "Ön nem találta hasznosnak ezt."; -"Attach a screenshot of your problem" = "Képernyőfotó csatolása a problémáról"; -"Mark as read" = "Megjelölés olvasottként"; -"Name invalid" = "Érvénytelen név"; -"Yes" = "Igen"; -"What's on your mind?" = "Mi jár a fejében?"; -"Send a new message" = "Új üzenet küldése"; -"Questions that may already have your answer" = "Kérdések, amelyekben megtalálhatja a keresett választ"; -"Attach a photo" = "Fotó csatolása"; -"Accept" = "Elfogadás"; -"Your reply" = "Az Ön válasza"; -"Inbox" = "Beérkezett üzenetek"; -"Remove attachment" = "Melléklet eltávolítása"; -"Could not fetch message" = "Üzenet lekérése sikertelen"; -"Read FAQ" = "GYIK olvasása"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Sajnáljuk, de ezt a beszélgetést tevékenység hiánya miatt lezártuk. Kérjük, kezdjen új beszélgetést ügynökeinkkel."; -"%d new messages from Support" = "%d új üzenet az Ügyfélszolgálattól"; -"Ok, Attach" = "OK, csatolás"; -"Send" = "Küldés"; -"Screenshot size should not exceed %.2f MB" = "A képernyőfotó maximális mérete %.2f MB"; -"Information" = "Információ"; -"Issue ID" = "Probléma azonosítója"; -"Tap to copy" = "Koppintson a másoláshoz"; -"Copied!" = "Másolva"; -"We couldn't find an FAQ with matching ID" = "Nem találtunk egyező azonosítójú GYIK-et."; -"Failed to load screenshot" = "Képernyőfotó betöltése sikertelen"; -"Failed to load video" = "Videó betöltése sikertelen"; -"Failed to load image" = "Kép betöltése sikertelen"; -"Hold down your device's power and home buttons at the same time." = "Képernyőfotó készítéséhez nyomja le hosszan egyszerre a be-/kikapcsoló gombot és a főgombot az eszközén."; -"Please note that a few devices may have the power button on the top." = "Egyes eszközök esetén a be-/kikapcsoló gomb az eszköz tetején található."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Ezt követően lépjen vissza ebbe a beszélgetésbe, és érintse meg az „OK, csatolás” lehetőséget a csatoláshoz."; -"Okay" = "OK"; -"We couldn't find an FAQ section with matching ID" = "Nem találtunk egyező azonosítójú GYIK-részt."; - -"GIFs are not supported" = "A GIF-ek nem támogatottak"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/id.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/id.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 3ae1c812f610..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/id.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Tidak menemukan hal yang Anda cari?"; -"Rate App" = "Beri Nilai Apli"; -"We\'re happy to help you!" = "Kami senang membantu Anda!"; -"Did we answer all your questions?" = "Apakah kami menjawab semua pertanyaan Anda?"; -"Remind Later" = "Ingatkan Nanti"; -"Your message has been received." = "Pesan Anda telah diterima."; -"Message send failure." = "Pengiriman pesan gagal."; -"What\'s your feedback about our customer support?" = "Apa tanggapan Anda mengenai dukungan pelanggan kami?"; -"Take a screenshot on your iPhone" = "Ambil tangkapan layar di iPhone Anda"; -"Learn how" = "Pelajari caranya"; -"Take a screenshot on your iPad" = "Ambil tangkapan layar di iPad Anda"; -"Your email(optional)" = "Email Anda (opsional)"; -"Conversation" = "Percakapan"; -"View Now" = "Lihat Sekarang"; -"SEND ANYWAY" = "KIRIM SAJA"; -"OK" = "Oke"; -"Help" = "Bantuan"; -"Send message" = "Kirim pesan"; -"REVIEW" = "ULAS"; -"Share" = "Bagikan"; -"Close Help" = "Tutup Bantuan"; -"Sending your message..." = "Mengirim pesan Anda..."; -"Learn how to" = "Pelajari caranya"; -"No FAQs found in this section" = "Tidak ada FAQ di bagian ini"; -"Thanks for contacting us." = "Terima kasih telah menghubungi kami."; -"Chat Now" = "Mengobrol Sekarang"; -"Buy Now" = "Beli Sekarang"; -"New Conversation" = "Percakapan Baru"; -"Please check your network connection and try again." = "Periksa koneksi jaringan Anda, lalu coba lagi"; -"New message from Support" = "Pesan baru dari Dukungan"; -"Question" = "Pertanyaan"; -"Type in a new message" = "Ketikkan pesan baru"; -"Email (optional)" = "Email (opsional)"; -"Reply" = "Balas"; -"CONTACT US" = "HUBUNGI KAMI"; -"Email" = "Email"; -"Like" = "Suka"; -"Tap here if this FAQ was not helpful to you" = "Ketuk di sini jika FAQ ini tidak membantu bagi Anda"; -"Any other feedback? (optional)" = "Ada tanggapan lain? (opsional)"; -"You found this helpful." = "Menurut Anda ini membantu."; -"No working Internet connection is found." = "Tidak ditemukan koneksi internet yang berfungsi."; -"No messages found." = "Pesan tidak ditemukan."; -"Please enter a brief description of the issue you are facing." = "Ketikkan keterangan singkat tentang masalah yang Anda hadapi."; -"Shop Now" = "Belanja Sekarang"; -"Close Section" = "Tutup Bagian"; -"Close FAQ" = "Tutup FAQ"; -"Close" = "Tutup"; -"This conversation has ended." = "Percakapan ini telah berakhir."; -"Send it anyway" = "Kirim saja"; -"You accepted review request." = "Anda menerima permintaan ulasan."; -"Delete" = "Hapus"; -"What else can we help you with?" = "Ada lagi yang bisa kami bantu?"; -"Tap here if the answer was not helpful to you" = "Ketuk di sini jika jawabannya tidak membantu bagi Anda"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Kami turut menyesali hal tersebut. Berkenankah Anda menguraikan lebih jauh mengenai masalah yang Anda hadapi?"; -"Service Rating" = "Nilai Layanan"; -"Your email" = "Email Anda"; -"Email invalid" = "Email tidak sah"; -"Could not fetch FAQs" = "Tidak dapat mengambil FAQ"; -"Thanks for rating us." = "Terima kasih telah memberi nilai."; -"Download" = "Unduh"; -"Please enter a valid email" = "Masukkan alamat email yang sah"; -"Message" = "Pesan"; -"or" = "atau"; -"Decline" = "Tolak"; -"No" = "Tidak"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Tangkapan layar tidak dapat dikirim. Gambar terlalu besar, coba lagi dengan gambar lain"; -"Hated it" = "Tidak suka"; -"Stars" = "Bintang"; -"Your feedback has been received." = "Tanggapan Anda telah diterima."; -"Dislike" = "Tidak Suka"; -"Preview" = "Pratinjau"; -"Book Now" = "Pesan Sekarang"; -"START A NEW CONVERSATION" = "MULAI PERCAKAPAN BARU"; -"Your Rating" = "Nilai Anda"; -"No Internet!" = "Tidak ada internet!"; -"Invalid Entry" = "Entri Tidak Sah"; -"Loved it" = "Suka"; -"Review on the App Store" = "Ulas di App Store"; -"Open Help" = "Buka Bantuan"; -"Search" = "Cari"; -"Tap here if you found this FAQ helpful" = "Ketuk di sini jika menurut Anda FAQ ini membantu"; -"Star" = "Bintang"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Ketuk di sini jika menurut Anda jawaban ini membantu"; -"Report a problem" = "Laporkan masalah"; -"YES, THANKS!" = "YA, TERIMA KASIH!"; -"Was this helpful?" = "Apakah ini membantu?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Pesan Anda tidak terkirim.Ketuk \"Coba Lagi\" untuk mengirim pesan ini?"; -"Suggestions" = "Saran"; -"No FAQs found" = "FAQ tidak ditemukan"; -"Done" = "Selesai"; -"Opening Gallery..." = "Membuka Galeri..."; -"You rated the service with" = "Anda memberi nilai layanan ini dengan"; -"Cancel" = "Batal"; -"Loading..." = "Memuat..."; -"Read FAQs" = "Baca FAQ"; -"Thanks for messaging us!" = "Terima kasih telah mengirim pesan kepada kami!"; -"Try Again" = "Coba Lagi"; -"Send Feedback" = "Kirim Masukan"; -"Your Name" = "Nama Anda"; -"Please provide a name." = "Mohon berikan nama."; -"FAQ" = "FAQ"; -"Describe your problem" = "Jelaskan masalah Anda"; -"How can we help?" = "Ada yang bisa kami bantu?"; -"Help about" = "Bantuan tentang"; -"We could not fetch the required data" = "Kami tidak dapat mengambil data yang diperlukan"; -"Name" = "Nama"; -"Sending failed!" = "Gagal mengirim!"; -"You didn't find this helpful." = "Menurut Anda, ini tidak membantu."; -"Attach a screenshot of your problem" = "Lampirkan tangkapan layar dari masalah Anda"; -"Mark as read" = "Tandai sudah dibaca"; -"Name invalid" = "Nama tidak sah"; -"Yes" = "Ya"; -"What's on your mind?" = "Apa yang ingin Anda sampaikan?"; -"Send a new message" = "Kirim pesan baru"; -"Questions that may already have your answer" = "Pertanyaan yang mungkin mengandung jawaban yang Anda perlukan"; -"Attach a photo" = "Lampirkan foto"; -"Accept" = "Terima"; -"Your reply" = "Balasan Anda"; -"Inbox" = "Kotak Masuk"; -"Remove attachment" = "Hapus lampiran"; -"Could not fetch message" = "Tidak dapat mengambil pesan"; -"Read FAQ" = "Baca FAQ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Maaf, percakapan ini ditutup karena tidak aktif. Mulailah percakapan baru dengan agen kami."; -"%d new messages from Support" = "%d pesan baru dari Dukungan"; -"Ok, Attach" = "Oke, Lampirkan"; -"Send" = "Kirim"; -"Screenshot size should not exceed %.2f MB" = "Ukuran cuplikan layar tidak boleh melebihi %.2f MB"; -"Information" = "Informasi"; -"Issue ID" = "ID Masalah"; -"Tap to copy" = "Ketuk untuk menyalin"; -"Copied!" = "Disalin!"; -"We couldn't find an FAQ with matching ID" = "Kami tidak bisa menemukan Pertanyaan Umum dengan ID yang sesuai"; -"Failed to load screenshot" = "Gagal memuat gambar layar"; -"Failed to load video" = "Gagal memuat video"; -"Failed to load image" = "Gagal memuat gambar"; -"Hold down your device's power and home buttons at the same time." = "Tahan serentak tombol daya dan tombol home di perangkat Anda."; -"Please note that a few devices may have the power button on the top." = "Perhatikan bahwa pada segelintir perangkat, tombol daya mungkin ada di atas."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Setelah rampung, kembalilah ke percakapan ini dan ketuk \"Oke, lampirkan\" untuk melampirkannya."; -"Okay" = "Oke"; -"We couldn't find an FAQ section with matching ID" = "Kami tidak dapat menemukan bagian Tanya-Jawab dengan ID yang sesuai"; - -"GIFs are not supported" = "GIF tidak didukung"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/it.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/it.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index ca201a1e9568..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/it.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,150 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Non hai trovato quello che cercavi?"; -"Rate App" = "Valuta l\'app"; -"We\'re happy to help you!" = "Siamo felici di aiutarti!"; -"Did we answer all your questions?" = "Abbiamo risposto a tutte le tue domande?"; -"Remind Later" = "Ricordamelo più tardi"; -"Your message has been received." = "Il tuo messaggio è stato ricevuto."; -"Message send failure." = "Invio del messaggio non riuscito."; -"What\'s your feedback about our customer support?" = "Qual è la tua opinione sull'assistenza ai clienti?"; -"Take a screenshot on your iPhone" = "Scatta un'istantanea dello schermo sul tuo iPhone."; -"Learn how" = "Impara ora"; -"Take a screenshot on your iPad" = "Scatta un'istantanea dello schermo sul tuo iPad."; -"Your email(optional)" = "Il tuo indirizzo e-mail (facoltativo)"; -"Conversation" = "Conversazione"; -"View Now" = "Visualizza ora"; -"SEND ANYWAY" = "INVIA COMUNQUE"; -"OK" = "OK"; -"Help" = "Aiuto"; -"Send message" = "Invia messaggio"; -"REVIEW" = "RECENSISCI"; -"Share" = "Condividi"; -"Close Help" = "Chiudi Aiuto"; -"Sending your message..." = "Invio del messaggio..."; -"Learn how to" = "Impara a"; -"No FAQs found in this section" = "Nessuna domanda frequente trovata in questa sezione."; -"Thanks for contacting us." = "Grazie per averci contattato."; -"Chat Now" = "Entra in chat"; -"Buy Now" = "Compra ora"; -"New Conversation" = "Nuova conversazione"; -"Please check your network connection and try again." = "Controlla la tua connessione di rete e riprova."; -"New message from Support" = "Nuovo messaggio dall'assistenza"; -"Question" = "Domanda"; -"Type in a new message" = "Digita un nuovo messaggio"; -"Email (optional)" = "E-mail (optional)"; -"Reply" = "Rispondi"; -"CONTACT US" = "CONTATTACI"; -"Email" = "E-mail"; -"Like" = "Mi piace"; -"Tap here if this FAQ was not helpful to you" = "Tocca qui se questa domanda frequente non ti è stata utile"; -"Any other feedback? (optional)" = "Altri commenti? (optional)"; -"You found this helpful." = "Ti è stato utile."; -"No working Internet connection is found." = "Non è stata rilevata una connessione Internet."; -"No messages found." = "Nessun messaggio trovato."; -"Please enter a brief description of the issue you are facing." = "Inserisci una breve descrizione del problema che hai incontrato."; -"Shop Now" = "Vai al negozio"; -"Close Section" = "Chiudi sezione"; -"Close FAQ" = "Chiudi Domande frequenti"; -"Close" = "Chiudi"; -"This conversation has ended." = "Questa conversazione è terminata."; -"Send it anyway" = "Invia comunque"; -"You accepted review request." = "Richiesta di recensione accettata."; -"Delete" = "Elimina"; -"What else can we help you with?" = "Cos'altro possiamo fare per te?"; -"Tap here if the answer was not helpful to you" = "Tocca qui se la risposta non ti è stata utile"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Ci dispiace. Potresti darci qualche altra informazione sul problema che hai riscontrato?"; -"Service Rating" = "Valutazione del servizio"; -"Your email" = "Il tuo indirizzo e-mail"; -"Email invalid" = "E-mail non valida."; -"Could not fetch FAQs" = "Impossibile trovare domande frequenti."; -"Thanks for rating us." = "Grazie per averci valutato."; -"Download" = "Scarica"; -"Please enter a valid email" = "Inserisci un indirizzo e-mail valido"; -"Message" = "Messaggio"; -"or" = "o"; -"Decline" = "Rifiuta"; -"No" = "No"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Impossibile inviare l'istantanea dello schermo. L'immagine è troppo grande; riprova con un'altra immagine."; -"Hated it" = "Non mi è piaciuto"; -"Stars" = "Stelle"; -"Your feedback has been received." = "La tua opinione è stata ricevuta."; -"Dislike" = "Non mi piace"; -"Preview" = "Anteprima"; -"Book Now" = "Prenota ora"; -"START A NEW CONVERSATION" = "INIZIA UNA NUOVA CONVERSAZIONE"; -"Your Rating" = "La tua valutazione"; -"No Internet!" = "Niente Internet!"; -"Invalid Entry" = "Voce non valida."; -"Loved it" = "Mi è piaciuto"; -"Review on the App Store" = "Valuta nell\'App Store"; -"Open Help" = "Apri Aiuto"; -"Search" = "Cerca"; -"Tap here if you found this FAQ helpful" = "Tocca qui se questa domanda frequente ti è stata utile"; -"Star" = "Stella"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Tocca qui se la risposta ti è stata utile"; -"Report a problem" = "Segnala un problema"; -"YES, THANKS!" = "SÌ GRAZIE!"; -"Was this helpful?" = "Ti è stato utile?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Il tuo messaggio non è stato inviato. Tocca \"Riprova\" per inviare questo messaggio?"; -"Suggestions" = "Suggerimenti"; -"No FAQs found" = "Nessuna domanda frequente trovata"; -"Done" = "Fatto"; -"Opening Gallery..." = "Apri Foto..."; -"You rated the service with" = "Hai valutato il servizio con"; -"Cancel" = "Cancella"; -"Loading..." = "Caricamento..."; -"Read FAQs" = "Leggi le Domande frequenti"; -"Thanks for messaging us!" = "Grazie per il tuo messaggio!"; -"Try Again" = "Riprova"; -"Send Feedback" = "Invia opinione"; -"Your Name" = "Il tuo nome"; -"Please provide a name." = "Inserisci un nome."; -"FAQ" = "Domande frequenti"; -"Describe your problem" = "Descrivi il tuo problema."; -"How can we help?" = "Come possiamo aiutarti?"; -"Help about" = "Aiuto su"; -"We could not fetch the required data" = "Non abbiamo trovato i dati richiesti."; -"Name" = "Nome"; -"Sending failed!" = "Invio non riuscito!"; -"You didn't find this helpful." = "Non ti è stato utile."; -"Attach a screenshot of your problem" = "Allega uno screenshot del tuo problema"; -"Mark as read" = "Segna come già letto"; -"Name invalid" = "Nome non valido"; -"Yes" = "Sì"; -"What's on your mind?" = "A cosa stai pensando?"; -"Send a new message" = "Invia un nuovo messaggio"; -"Questions that may already have your answer" = "Domande che potrebbero contenere la risposta che ti serve."; -"Attach a photo" = "Allega una foto"; -"Accept" = "Accetta"; -"Your reply" = "La tua risposta"; -"Inbox" = "In arrivo"; -"Remove attachment" = "Rimuovi allegato"; -"Could not fetch message" = "Impossibile recuperare il messaggio"; -"Read FAQ" = "Leggi la Domanda frequente"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Siamo spiacenti. Questa conversazione è stata chiusa per inattività. Avvia una nuova conversazione con i nostri agenti."; -"%d new messages from Support" = "%d nuovi messaggi dal servizio di assistenza"; -"Ok, Attach" = "OK, allega"; -"Send" = "Invia"; -"Screenshot size should not exceed %.2f MB" = "La dimensione degli screenshot non può superare i %.2f MB"; -"Information" = "Informazioni"; -"Issue ID" = "ID problema"; -"Tap to copy" = "Tocca per copiare"; -"Copied!" = "Copiato!"; -"We couldn't find an FAQ with matching ID" = "Impossibile trovare una domanda frequente con l'ID corrispondente."; -"Failed to load screenshot" = "Impossibile caricare l'istantanea."; -"Failed to load video" = "Impossibile caricare il video."; -"Failed to load image" = "Impossibile caricare l'immagine."; -"Hold down your device's power and home buttons at the same time." = "Tieni premuti contemporaneamente il tasto Standby/Riattiva e il tasto Home del tuo dispositivo."; -"Please note that a few devices may have the power button on the top." = "Nota: su alcuni dispositivi, il tasto Standby/Riattiva potrebbe trovarsi nella parte superiore."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Una volta fatto, torna a questa conversazione e tocca \"OK, allega\" per allegare l'istantanea."; -"Okay" = "OK"; - -"We couldn't find an FAQ section with matching ID" = "Impossibile trovare una sezione Domande frequenti con l'ID corrispondente."; - -"GIFs are not supported" = "I GIF non sono supportati"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ja.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ja.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 4a2f371f7080..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ja.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "問題の解決方法が見つかりませんか?"; -"Rate App" = "アプリを評価"; -"We\'re happy to help you!" = "真摯に受け止め対応させていただきます。"; -"Did we answer all your questions?" = "カスタマーサポートは全ての質問に回答しましたか?"; -"Remind Later" = "後で知らせる"; -"Your message has been received." = "メッセージを受領いたしました。"; -"Message send failure." = "メッセージを送信できませんでした。"; -"What\'s your feedback about our customer support?" = "カスタマーサポートへのご意見・ご要望をお聞かせください。"; -"Take a screenshot on your iPhone" = "iPhone でスクリーンショットを撮影してください"; -"Learn how" = "方法を確認"; -"Take a screenshot on your iPad" = "iPad でスクリーンショットを撮影してください"; -"Your email(optional)" = "Eメールアドレス(任意)"; -"Conversation" = "メッセージ"; -"View Now" = "今すぐ見る"; -"SEND ANYWAY" = "送信する"; -"OK" = "OK"; -"Help" = "ヘルプ"; -"Send message" = "メッセージを送信"; -"REVIEW" = "レビュー"; -"Share" = "シェア"; -"Close Help" = "ヘルプを閉じる"; -"Sending your message..." = "メッセージを送信しています…"; -"Learn how to" = "方法を確認:"; -"No FAQs found in this section" = "このカテゴリのFAQはありません"; -"Thanks for contacting us." = "ご連絡いただき誠にありがとうございます。"; -"Chat Now" = "チャットする"; -"Buy Now" = "今すぐ購入"; -"New Conversation" = "新規メッセージ"; -"Please check your network connection and try again." = "ネットワーク接続状況をご確認の上、再度お試しください。"; -"New message from Support" = "サポートからの新規メッセージ"; -"Question" = "質問"; -"Type in a new message" = "新しいメッセージを入力"; -"Email (optional)" = "Eメールアドレス(任意)"; -"Reply" = "返信"; -"CONTACT US" = "お問い合わせ"; -"Email" = "Eメールアドレス"; -"Like" = "いいね"; -"Tap here if this FAQ was not helpful to you" = "このFAQがお役に立たなかった場合は、こちらをタップしてください"; -"Any other feedback? (optional)" = "ご意見・ご要望をお聞かせください。(任意)"; -"You found this helpful." = "役に立った"; -"No working Internet connection is found." = "利用可能なネットワークが見つかりません。"; -"No messages found." = "メッセージが見つかりません。"; -"Please enter a brief description of the issue you are facing." = "発生中の問題の内容をご入力ください。"; -"Shop Now" = "今すぐ購入"; -"Close Section" = "カテゴリを閉じる"; -"Close FAQ" = "FAQを閉じる"; -"Close" = "閉じる"; -"This conversation has ended." = "このメッセージは終了しました。"; -"Send it anyway" = "送信する"; -"You accepted review request." = "レビュー依頼に応じました。"; -"Delete" = "削除"; -"What else can we help you with?" = "他にご要望がございますか?"; -"Tap here if the answer was not helpful to you" = "この回答がお役に立たなかった場合は、こちらをタップしてください"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "ご不便をおかけし申し訳ございません。お手数ですが発生している問題についてもう少し詳しくお聞かせください。"; -"Service Rating" = "サービス内容の評価"; -"Your email" = "Eメールアドレス"; -"Email invalid" = "Eメールアドレスが無効です"; -"Could not fetch FAQs" = "FAQが見つかりませんでした"; -"Thanks for rating us." = "評価いただき。ありがとうございます。"; -"Download" = "ダウンロード"; -"Please enter a valid email" = "有効なEメールアドレスをご入力ください"; -"Message" = "メッセージ"; -"or" = "または"; -"Decline" = "拒否"; -"No" = "いいえ"; -"Screenshot could not be sent. Image is too large, try again with another image" = "スクリーンショットを送信できませんでした。ファイルサイズが大きすぎます。他の画像をご利用ください。"; -"Hated it" = "悪い"; -"Stars" = "つ星"; -"Your feedback has been received." = "フィードバックを受領いたしました。"; -"Dislike" = "いいね を取り消す"; -"Preview" = "プレビュー"; -"Book Now" = "今すぐ予約"; -"START A NEW CONVERSATION" = "新規メッセージを作成"; -"Your Rating" = "あなたの評価"; -"No Internet!" = "インターネット接続なし"; -"Invalid Entry" = "記載内容が不足しています"; -"Loved it" = "良い"; -"Review on the App Store" = "App Store でレビュー"; -"Open Help" = "ヘルプを開く"; -"Search" = "検索"; -"Tap here if you found this FAQ helpful" = "このFAQがお役に立った場合は、こちらをタップしてください"; -"Star" = "つ星"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "この回答がお役に立った場合は、こちらをタップしてください"; -"Report a problem" = "問題を報告"; -"YES, THANKS!" = "はい"; -"Was this helpful?" = "お役に立ちましたか?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "メッセージを送信できませんでした。\"再試行\" をタップするとメッセージを再送します。"; -"Suggestions" = "候補"; -"No FAQs found" = "FAQが見つかりません"; -"Done" = "終了"; -"Opening Gallery..." = "カメラロールを開いています…"; -"You rated the service with" = "としてサービスを評価いただきました"; -"Cancel" = "キャンセル"; -"Loading..." = "ロード中…"; -"Read FAQs" = "FAQを読む"; -"Thanks for messaging us!" = "メッセージをお送りいただき、ありがとうございました。"; -"Try Again" = "再試行"; -"Send Feedback" = "フィードバックを送信"; -"Your Name" = "お名前"; -"Please provide a name." = "お名前をご入力ください"; -"FAQ" = "FAQ"; -"Describe your problem" = "問題の詳細をご説明ください"; -"How can we help?" = "お困りですか?"; -"Help about" = "ヘルプ:"; -"We could not fetch the required data" = "必要なデータを取得できませんでした"; -"Name" = "お名前"; -"Sending failed!" = "送信できませんでした"; -"You didn't find this helpful." = "役に立たなかった"; -"Attach a screenshot of your problem" = "問題の様子を撮影したスクリーンショットを添付"; -"Mark as read" = "既読にする"; -"Name invalid" = "お名前が無効です"; -"Yes" = "はい"; -"What's on your mind?" = "懸念事項をお知らせください。"; -"Send a new message" = "新しいメッセージを送信"; -"Questions that may already have your answer" = "既に回答済みの可能性がございます"; -"Attach a photo" = "写真を添付"; -"Accept" = "承諾"; -"Your reply" = "返信"; -"Inbox" = "受信箱"; -"Remove attachment" = "添付ファイルを削除"; -"Could not fetch message" = "メッセージが見つかりませんでした"; -"Read FAQ" = "FAQを読む"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "メッセージのやり取りがなかったため、この話題は終了した扱いになっています。新規メッセージを作成してください。"; -"%d new messages from Support" = "サポートからの新しいメッセージが%d件あります"; -"Ok, Attach" = "添付する"; -"Send" = "送信"; -"Screenshot size should not exceed %.2f MB" = "スクリーンショットのファイルサイズは%.2fMB以下にしてください"; -"Information" = "情報"; -"Issue ID" = "問題のID"; -"Tap to copy" = "タップしてコピー"; -"Copied!" = "コピー完了"; -"We couldn't find an FAQ with matching ID" = "IDが一致するFAQが見つかりませんでした"; -"Failed to load screenshot" = "スクリーンショットのロードに失敗しました"; -"Failed to load video" = "ビデオのロードに失敗しました"; -"Failed to load image" = "画像のロードに失敗しました"; -"Hold down your device's power and home buttons at the same time." = "デバイス の電源ボタンとホームボタンを同時に押してください。"; -"Please note that a few devices may have the power button on the top." = "一部のデバイスでは、電源ボタンがデバイス上部に設置されている場合があります。"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "スクリーンショットが撮影できたら、このメッセージに戻り「添付する」をタップして添付してください。"; -"Okay" = "OK"; -"We couldn't find an FAQ section with matching ID" = "IDが一致するFAQのカテゴリが見つかりませんでした"; - -"GIFs are not supported" = "GIFはサポートされていません"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/kn.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/kn.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index e6b35e2b3770..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/kn.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "ನೀವು ಹುಡುಕುತ್ತಿರುವುದು ಸಿಗಲಿಲ್ಲವೇ?"; -"Rate App" = "ಅಪ್ಲಿಕೇಶನ್ ಕುರಿತು ರೇಟ್ ನೀಡಿ"; -"We\'re happy to help you!" = "ನಿಮಗೆ ಸಹಾಯ ಮಾಡಲು ಸಂತೋಷವಾಗುತ್ತಿದೆ!"; -"Did we answer all your questions?" = "ನಾವು ನಿಮ್ಮ ಎಲ್ಲಾ ಪ್ರಶ್ನೆಗಳಿಗೆ ಉತ್ತರಿಸಿದ್ದೇವೆಯೇ?"; -"Remind Later" = "ನಂತರ ನೆನಪಿಸು"; -"Your message has been received." = "ನಿಮ್ಮ ಸಂದೇಶವನ್ನು ಸ್ವೀಕರಿಸಲಾಗಿದೆ."; -"Message send failure." = "ಸಂದೇಶ ಕಳುಹಿಸುವಲ್ಲಿ ವಿಫಲವಾಗಿದೆ."; -"What\'s your feedback about our customer support?" = "ನಮ್ಮ ಗ್ರಾಹಕ ಬೆಂಬಲದ ಕುರಿತು ನಿಮ್ಮ ಪ್ರತಿಕ್ರಿಯೆ ಏನು?"; -"Take a screenshot on your iPhone" = "ನಿಮ್ಮ iPhone ನಲ್ಲಿ ಸ್ಕ್ರೀನ್‌ಶಾಟ್ ತೆಗೆದುಕೊಳ್ಳಿ"; -"Learn how" = "ಇನ್ನಷ್ಟು ತಿಳಿಯಿರಿ"; -"Take a screenshot on your iPad" = "ನಿಮ್ಮ iPad ನಲ್ಲಿ ಸ್ಕ್ರೀನ್‌ಶಾಟ್ ತೆಗೆದುಕೊಳ್ಳಿ"; -"Your email(optional)" = "ನಿಮ್ಮ ಇಮೇಲ್ (ಐಚ್ಛಿಕ)"; -"Conversation" = "ಸಂವಾದ"; -"View Now" = "ಈಗಲೇ ವೀಕ್ಷಿಸು"; -"SEND ANYWAY" = "ಹೇಗಾದರೂ ಕಳುಹಿಸು"; -"OK" = "ಸರಿ"; -"Help" = "ಸಹಾಯ"; -"Send message" = "ಸಂದೇಶ ಕಳುಹಿಸಿ"; -"REVIEW" = "ಪರಿಶೀಲಿಸು"; -"Share" = "ಹಂಚು"; -"Close Help" = "ಸಹಾಯ ಮುಚ್ಚು"; -"Sending your message..." = "ನಿಮ್ಮ ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲಾಗುತ್ತಿದೆ..."; -"Learn how to" = "ಈ ಕುರಿತು ಇನ್ನಷ್ಟು ತಿಳಿಯಿರಿ"; -"No FAQs found in this section" = "ಈ ವಿಭಾಗದಲ್ಲಿ FAQ ಪ್ರಶ್ನೆಗಳು ಕಂಡುಬರಲಿಲ್ಲ"; -"Thanks for contacting us." = "ನಮ್ಮನ್ನು ಸಂಪರ್ಕಿಸಿದ್ದಕ್ಕೆ ಧನ್ಯವಾದಗಳು."; -"Chat Now" = "ಈಗಲೇ ಚಾಟ್ ಮಾಡು"; -"Buy Now" = "ಈಗಲೇ ಖರೀದಿಸು"; -"New Conversation" = "ಹೊಸ ಸಂವಾದ"; -"Please check your network connection and try again." = "ದಯವಿಟ್ಟು ನಿಮ್ಮ ಇಂಟರ್ನೆಟ್ ಸಂಪರ್ಕವನ್ನು ಪರಿಶೀಲಿಸಿ ಹಾಗೂ ಮತ್ತೊಮ್ಮೆ ಪ್ರಯತ್ನಿಸಿ."; -"New message from Support" = "ಬೆಂಬಲ ತಂಡದಿಂದ ಹೊಸ ಸಂದೇಶ"; -"Question" = "ಪ್ರಶ್ನೆ"; -"Type in a new message" = "ಹೊಸ ಸಂದೇಶ ಟೈಪ್ ಮಾಡಿ"; -"Email (optional)" = "ಇಮೇಲ್ (ಐಚ್ಛಿಕ)"; -"Reply" = "ಪ್ರತ್ಯುತ್ತರಿಸು"; -"CONTACT US" = "ನಮ್ಮನ್ನು ಸಂಪರ್ಕಿಸಿ"; -"Email" = "ಇಮೇಲ್"; -"Like" = "ಮೆಚ್ಚು"; -"Tap here if this FAQ was not helpful to you" = "ಈ FAQ ನಿಮಗೆ ಉಪಯುಕ್ತವೆನಿಸದಿದ್ದಲ್ಲಿ ಇಲ್ಲಿ ಟ್ಯಾಪ್ ಮಾಡಿ"; -"Any other feedback? (optional)" = "ಬೇರೆ ಯಾವುದಾದರೂ ಪ್ರತಿಕ್ರಿಯೆ ಇದೆಯೇ? (ಐಚ್ಛಿಕ)"; -"You found this helpful." = "ಇದು ನಿಮಗೆ ಉಪಯುಕ್ತವಾಗಿರುವಂತಿದೆ."; -"No working Internet connection is found." = "ಸಕ್ರಿಯ ಇಂಟರ್ನೆಟ್ ಸಂಪರ್ಕ ಕಂಡುಬಂದಿಲ್ಲ."; -"No messages found." = "ಸಂದೇಶಗಳು ಕಂಡುಬಂದಿಲ್ಲ."; -"Please enter a brief description of the issue you are facing." = "ನೀವು ಎದುರಿಸುತ್ತಿರುವ ಸಮಸ್ಯೆಯ ಕುರಿತು ಸಂಕ್ಷಿಪ್ತ ವಿವರಣೆಯನ್ನು ನೀಡಿ."; -"Shop Now" = "ಈಗಲೇ ಶಾಪ್ ಮಾಡಿ"; -"Close Section" = "ವಿಭಾಗ ಮುಚ್ಚು"; -"Close FAQ" = "FAQ ಮುಚ್ಚು"; -"Close" = "ಮುಚ್ಚು"; -"This conversation has ended." = "ಈ ಸಂವಾದ ಅಂತ್ಯಗೊಂಡಿದೆ."; -"Send it anyway" = "ಹೇಗಾದರೂ ಕಳುಹಿಸು"; -"You accepted review request." = "ನೀವು ಅಭಿಪ್ರಾಯ ತಿಳಿಸುವ ವಿನಂತಿಗೆ ಸಮ್ಮತಿಸಿರುವಿರಿ."; -"Delete" = "ಅಳಿಸು"; -"What else can we help you with?" = "ನಿಮಗೆ ಬೇರೇನಾದರೂ ಸಹಾಯ ಬೇಕೇ?"; -"Tap here if the answer was not helpful to you" = "ಉತ್ತರವು ನಿಮಗೆ ಉಪಯುಕ್ತವೆನಿಸದಿದ್ದಲ್ಲಿ ಇಲ್ಲಿ ಟ್ಯಾಪ್ ಮಾಡಿ"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "ಕೇಳಲು ಬೇಸರವಾಗುತ್ತಿದೆ. ನೀವು ಎದುರಿಸುತ್ತಿರುವ ಸಮಸ್ಯೆಯ ಕುರಿತು ನಮಗೆ ಇನ್ನೂ ಸ್ವಲ್ಪ ಮಾಹಿತಿಯನ್ನು ನೀಡುವಿರಾ?"; -"Service Rating" = "ಸೇವೆ ರೇಟಿಂಗ್"; -"Your email" = "ನಿಮ್ಮ ಇಮೇಲ್"; -"Email invalid" = "ಇಮೇಲ್ ಅಮಾನ್ಯವಾಗಿದೆ"; -"Could not fetch FAQs" = "ಪದೇ ಪದೇ ಕೇಳಿರುವ ಪ್ರಶ್ನೆಗಳನ್ನು ಹಿಂಪಡೆಯಲಾಗಲಿಲ್ಲ"; -"Thanks for rating us." = "ನಮಗೆ ರೇಟಿಂಗ್ ನೀಡಿದ್ದಕ್ಕೆ ಧನ್ಯವಾದಗಳು."; -"Download" = "ಡೌನ್‌ಲೋಡ್"; -"Please enter a valid email" = "ಮಾನ್ಯ ಇಮೇಲ್ ಐಡಿ ನಮೂದಿಸಿ"; -"Message" = "ಸಂದೇಶ"; -"or" = "ಅಥವಾ"; -"Decline" = "ನಿರಾಕರಿಸು"; -"No" = "ಇಲ್ಲ"; -"Screenshot could not be sent. Image is too large, try again with another image" = "ಸ್ಕ್ರೀನ್‌ಶಾಟ್ ಕಳುಹಿಸಲಾಗುತ್ತಿಲ್ಲ. ಚಿತ್ರ ತುಂಬಾ ದೊಡ್ಡದಾಗಿದೆ, ಬೇರೊಂದು ಚಿತ್ರವನ್ನು ಕಳುಹಿಸಲು ಪ್ರಯತ್ನಿಸಿ"; -"Hated it" = "ಇಷ್ಟವಾಗಲಿಲ್ಲ"; -"Stars" = "ಸ್ಟಾರ್‌ಗಳು"; -"Your feedback has been received." = "ನಿಮ್ಮ ಪ್ರತಿಕ್ರಿಯೆಯನ್ನು ಸ್ವೀಕರಿಸಲಾಗಿದೆ."; -"Dislike" = "ಮೆಚ್ಚದಿರು"; -"Preview" = "ಪೂರ್ವವೀಕ್ಷಿಸಿ"; -"Book Now" = "ಈಗಲೇ ಬುಕ್ ಮಾಡು"; -"START A NEW CONVERSATION" = "ಹೊಸ ಸಂವಾದವನ್ನು ಪ್ರಾರಂಭಿಸಿ"; -"Your Rating" = "ನಿಮ್ಮ ರೇಟಿಂಗ್"; -"No Internet!" = "ಇಂಟರ್ನೆಟ್ ಸಂಪರ್ಕವಿಲ್ಲ!"; -"Invalid Entry" = "ಅಮಾನ್ಯ ನಮೂದು"; -"Loved it" = "ಇಷ್ಟವಾಯಿತು"; -"Review on the App Store" = "ಅಪ್ಲಿಕೇಶನ್ ಸ್ಟೋರ್ ಕುರಿತು ಅಭಿಪ್ರಾಯ ತಿಳಿಸಿ"; -"Open Help" = "ಸಹಾಯ ತೆರೆಯಿರಿ"; -"Search" = "ಹುಡುಕು"; -"Tap here if you found this FAQ helpful" = "ಈ FAQ ನಿಮಗೆ ಉಪಯುಕ್ತವೆನಿಸಿದಲ್ಲಿ ಇಲ್ಲಿ ಟ್ಯಾಪ್ ಮಾಡಿ"; -"Star" = "ಸ್ಟಾರ್"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "ಈ ಉತ್ತರವು ನಿಮಗೆ ಉಪಯುಕ್ತವೆನಿಸಿದಲ್ಲಿ ಇಲ್ಲಿ ಟ್ಯಾಪ್ ಮಾಡಿ"; -"Report a problem" = "ಸಮಸ್ಯೆಯ ಕುರಿತು ವರದಿ ಮಾಡಿ"; -"YES, THANKS!" = "ಹೌದು, ಧನ್ಯವಾದಗಳು!"; -"Was this helpful?" = "ಇದು ಉಪಯುಕ್ತವೇ?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "ನಿಮ್ಮ ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲಾಗಿಲ್ಲ. ಈ ಸಂದೇಶವನ್ನು ಕಳುಹಿಸಲು \"ಮತ್ತೊಮ್ಮೆ ಪ್ರಯತ್ನಿಸು\" ಟ್ಯಾಪ್ ಮಾಡುವುದೇ?"; -"Suggestions" = "ಸಲಹೆಗಳು"; -"No FAQs found" = "FAQಗಳು ಕಂಡುಬರಲಿಲ್ಲ"; -"Done" = "ಮುಗಿದಿದೆ"; -"Opening Gallery..." = "ಗ್ಯಾಲರಿ ತೆರೆಯಲಾಗುತ್ತಿದೆ..."; -"You rated the service with" = "ನಿಮ್ಮ ಇದರ ಜೊತೆಗಿನ ರೇಟ್ ಮಾಡಿರುವ ಸೇವೆ"; -"Cancel" = "ರದ್ದುಮಾಡು"; -"Loading..." = "ಲೋಡ್ ಆಗುತ್ತಿದೆ..."; -"Read FAQs" = "FAQ ಪ್ರಶ್ನೆಗಳನ್ನು ಓದಿ"; -"Thanks for messaging us!" = "ನಮಗೆ ಸಂದೇಶ ಕಳುಹಿಸುತ್ತಿರುವುದಕ್ಕೆ ಧನ್ಯವಾದಗಳು!"; -"Try Again" = "ಮತ್ತೊಮ್ಮೆ ಪ್ರಯತ್ನಿಸಿ"; -"Send Feedback" = "ಪ್ರತಿಕ್ರಿಯೆ ಕಳುಹಿಸಿ"; -"Your Name" = "ನಿಮ್ಮ ಹೆಸರು"; -"Please provide a name." = "ಹೆಸರೊಂದನ್ನು ನೀಡಿ."; -"FAQ" = "FAQ"; -"Describe your problem" = "ನಿಮ್ಮ ಸಮಸ್ಯೆಯ ಕುರಿತು ತಿಳಿಸಿ"; -"How can we help?" = "ನಾವು ನಿಮಗೆ ಹೇಗೆ ಸಹಾಯ ಮಾಡಬಹುದು?"; -"Help about" = "ಕುರಿತು ಸಹಾಯ"; -"We could not fetch the required data" = "ನಾವು ಅಗತ್ಯ ಮಾಹಿತಿಯನ್ನು ಹಿಂಪಡೆಯಲಾಗಲಿಲ್ಲ"; -"Name" = "ಹೆಸರು"; -"Sending failed!" = "ಕಳುಹಿಸಲು ವಿಫಲವಾಗಿದೆ!"; -"You didn't find this helpful." = "ಇದು ನಿಮಗೆ ಉಪಯುಕ್ತವಾಗಿಲ್ಲದಿರುವಂತಿದೆ."; -"Attach a screenshot of your problem" = "ನಿಮ್ಮ ಸಮಸ್ಯೆಯ ಕುರಿತಾದ ಸ್ಕ್ರೀನ್‌ಶಾಟ್ ಲಗತ್ತಿಸಿ"; -"Mark as read" = "ಓದಿದೆ ಎಂದು ಗುರುತಿಸು"; -"Name invalid" = "ಹೆಸರು ಅಮಾನ್ಯವಾಗಿದೆ"; -"Yes" = "ಹೌದು"; -"What's on your mind?" = "ನಿಮ್ಮ ಮನದಲ್ಲೇನಿದೆ?"; -"Send a new message" = "ಹೊಸ ಸಂದೇಶ ಕಳುಹಿಸಿ"; -"Questions that may already have your answer" = "ಈಗಾಗಲೇ ನಿಮ್ಮ ಉತ್ತರವನ್ನೊಳಗೊಂಡ ಪ್ರಶ್ನೆಗಳು"; -"Attach a photo" = "ಫೋಟೋ ಲಗತ್ತಿಸು"; -"Accept" = "ಸಮ್ಮತಿಸು"; -"Your reply" = "ನಿಮ್ಮ ಪ್ರತ್ಯುತ್ತರ"; -"Inbox" = "ಇನ್‌ಬಾಕ್ಸ್"; -"Remove attachment" = "ಲಗತ್ತು ತೆಗೆ"; -"Could not fetch message" = "ಸಂದೇಶವನ್ನು ಹಿಂಪಡೆಯಲಾಗುತ್ತಿಲ್ಲ"; -"Read FAQ" = "FAQ ಪ್ರಶ್ನೆಯನ್ನು ಓದಿ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "ಕ್ಷಮಿಸಿ! ನಿಷ್ಕ್ರಿಯತೆಯ ಕಾರಣ ಈ ಸಂವಾದವನ್ನು ಮುಚ್ಚಲಾಗಿದೆ. ನಮ್ಮ ಏಜೆಂಟ್‌ಗಳ ಜೊತೆಗೆ ಹೊಸ ಸಂವಾದವನ್ನು ಪ್ರಾರಂಭಿಸಿ."; -"%d new messages from Support" = "%d ಬೆಂಬಲ ತಂಡದಿಂದ ಹೊಸ ಸಂದೇಶಗಳು"; -"Ok, Attach" = "ಸರಿ, ಲಗತ್ತಿಸು"; -"Send" = "ಕಳುಹಿಸು"; -"Screenshot size should not exceed %.2f MB" = "ಸ್ಕ್ರೀನ್‌ಶಾಟ್ ಗಾತ್ರ %.2f MB ಮೀರುವಂತಿಲ್ಲ"; -"Information" = "ಮಾಹಿತಿ"; -"Issue ID" = "ಸಮಸ್ಯೆ ID"; -"Tap to copy" = "ನಕಲಿಸಲು ತಟ್ಟಿ"; -"Copied!" = "ನಕಲಿಸಲಾಗಿದೆ!"; -"We couldn't find an FAQ with matching ID" = "ಹೊಂದಾಣಿಕೆ ಐಡಿಯ FAQ ಪ್ರಶ್ನೆಯನ್ನು ಹುಡುಕಲು ನಮಗೆ ಸಾಧ್ಯವಾಗುತ್ತಿಲ್ಲ"; -"Failed to load screenshot" = "ಸ್ಕ್ರೀನ್‌ಶಾಟ್ ಲೋಡ್ ಮಾಡಲು ವಿಫಲವಾಗಿದೆ"; -"Failed to load video" = "ವೀಡಿಯೊ ಲೋಡ್ ಮಾಡಲು ವಿಫಲವಾಗಿದೆ"; -"Failed to load image" = "ಚಿತ್ರವನ್ನು ಲೋಡ್ ಮಾಡಲು ವಿಫಲವಾಗಿದೆ"; -"Hold down your device's power and home buttons at the same time." = "ಒಂದೇ ಸಮಯದಲ್ಲಿ ನಿಮ್ಮ ಸಾಧನದ ಪವರ್ ಮತ್ತು ಹೋಮ್ ಬಟನ್ ಒತ್ತಿ ಹಿಡಿಯಿರಿ."; -"Please note that a few devices may have the power button on the top." = "ಕೆಲವು ಸಾಧನಗಳಲ್ಲಿ ಪವರ್ ಬಟನ್ ಮೇಲ್ಭಾಗದಲ್ಲಿರುತ್ತದೆ ಎಂಬುದು ನಿಮ್ಮ ಗಮನಕ್ಕಿರಲಿ."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "ಮುಗಿದ ಬಳಿಕ, ಈ ಸಂವಾದಕ್ಕೆ ಹಿಂತಿರುಗಿ ಮತ್ತು ಲಗತ್ತಿಸಲು \"ಸರಿ ಲಗತ್ತಿಸು\" ಟ್ಯಾಪ್ ಮಾಡಿ."; -"Okay" = "ಸರಿ"; -"We couldn't find an FAQ section with matching ID" = "ಹೊಂದಾಣಿಕೆ ಐಡಿಯ FAQ ವಿಭಾಗ ಪ್ರಶ್ನೆಯನ್ನು ಹುಡುಕಲು ನಮಗೆ ಸಾಧ್ಯವಾಗುತ್ತಿಲ್ಲ"; - -"GIFs are not supported" = "GIF ಗಳನ್ನು ಬೆಂಬಲಿಸುವುದಿಲ್ಲ"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ko.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ko.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index e0e136a5f6b3..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ko.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "원하는 항목을 찾지 못하셨나요?"; -"Rate App" = "앱 평가하기"; -"We\'re happy to help you!" = "기꺼이 도와 드리겠습니다!"; -"Did we answer all your questions?" = "모든 질문이 해결되셨나요?"; -"Remind Later" = "나중에 알림"; -"Your message has been received." = "귀하의 메시지를 수신했습니다."; -"Message send failure." = "메시지 전송에 실패했습니다."; -"What\'s your feedback about our customer support?" = "저희 고객 지원에 대해 어떻게 평가하십니까?"; -"Take a screenshot on your iPhone" = "iPhone에서 스크린샷 촬영하기"; -"Learn how" = "방법 배우기"; -"Take a screenshot on your iPad" = "iPad에서 스크린샷 촬영하기"; -"Your email(optional)" = "귀하의 이메일(선택)"; -"Conversation" = "대화"; -"View Now" = "지금 보기"; -"SEND ANYWAY" = "그냥 전송하기"; -"OK" = "확인"; -"Help" = "도움말"; -"Send message" = "메시지 전송하기"; -"REVIEW" = "리뷰"; -"Share" = "공유"; -"Close Help" = "도움말 닫기"; -"Sending your message..." = "메시지 전송 중..."; -"Learn how to" = "방법 안내:"; -"No FAQs found in this section" = "이 섹션에는 FAQ가 없음"; -"Thanks for contacting us." = "문의해 주셔서 감사합니다."; -"Chat Now" = "지금 대화하기"; -"Buy Now" = "지금 구입하기"; -"New Conversation" = "새 대화"; -"Please check your network connection and try again." = "네트워크 연결을 확인하고 다시 시도해 주세요."; -"New message from Support" = "지원에서 보낸 새 메시지"; -"Question" = "질문"; -"Type in a new message" = "새 메시지 입력하기"; -"Email (optional)" = "이메일 (보조)"; -"Reply" = "답장"; -"CONTACT US" = "문의하기"; -"Email" = "이메일"; -"Like" = "좋아요"; -"Tap here if this FAQ was not helpful to you" = "이 FAQ가 도움이 되지 않았다면 여기를 누르세요."; -"Any other feedback? (optional)" = "다른 의견이 있으신가요? (선택)"; -"You found this helpful." = "도움이 되었습니다."; -"No working Internet connection is found." = "작동 중인 인터넷 연결이 없습니다."; -"No messages found." = "메시지가 없습니다."; -"Please enter a brief description of the issue you are facing." = "현재 겪고 있는 문제에 대해 짤막한 설명을 입력해 주세요."; -"Shop Now" = "지금 쇼핑하기"; -"Close Section" = "섹션 닫기"; -"Close FAQ" = "FAQ 닫기"; -"Close" = "닫기"; -"This conversation has ended." = "이 대화는 종료되었습니다."; -"Send it anyway" = "그냥 전송하기"; -"You accepted review request." = "리뷰 요청을 승인하셨습니다."; -"Delete" = "삭제"; -"What else can we help you with?" = "다른 도움이 필요하십니까?"; -"Tap here if the answer was not helpful to you" = "답변이 도움이 되지 않았다면 여기를 누르세요."; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "죄송합니다. 저희에게 현재 겪고 있는 문제에 대해 좀 더 알려 주시겠습니까?"; -"Service Rating" = "서비스 평가"; -"Your email" = "귀하의 이메일"; -"Email invalid" = "잘못된 이메일"; -"Could not fetch FAQs" = "FAQ를 불러올 수 없음"; -"Thanks for rating us." = "평가해 주셔서 감사합니다."; -"Download" = "다운로드"; -"Please enter a valid email" = "유효한 이메일을 입력해 주세요."; -"Message" = "메시지"; -"or" = "혹은"; -"Decline" = "거부"; -"No" = "아니요"; -"Screenshot could not be sent. Image is too large, try again with another image" = "스크린샷 전송에 실패했습니다. 이미지가 너무 크니, 다른 이미지로 다시 시도하세요"; -"Hated it" = "나쁨"; -"Stars" = "점"; -"Your feedback has been received." = "귀하의 의견을 수신했습니다."; -"Dislike" = "싫어요"; -"Preview" = "미리보기"; -"Book Now" = "지금 예약하기"; -"START A NEW CONVERSATION" = "새 대화 시작하기"; -"Your Rating" = "나의 평가"; -"No Internet!" = "인터넷 없음!"; -"Invalid Entry" = "잘못된 입력"; -"Loved it" = "좋음"; -"Review on the App Store" = "App Store에서 평가하기"; -"Open Help" = "도움말 열기"; -"Search" = "검색"; -"Tap here if you found this FAQ helpful" = "이 FAQ가 도움이 되었다면 여기를 누르세요."; -"Star" = "점"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "답변이 도움이 되었다면 여기를 누르세요."; -"Report a problem" = "문제 보고"; -"YES, THANKS!" = "네, 감사합니다!"; -"Was this helpful?" = "내용이 도움이 되었나요?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "메시지가 전송되지 않았습니다.\"다시 시도\"를 탭해서 이 메시지를 전송할까요?"; -"Suggestions" = "제안"; -"No FAQs found" = "FAQ가 없음"; -"Done" = "완료"; -"Opening Gallery..." = "갤러리 여는 중..."; -"You rated the service with" = "귀하의 서비스 평가 점수"; -"Cancel" = "취소"; -"Loading..." = "불러오는 중..."; -"Read FAQs" = "FAQ 읽기"; -"Thanks for messaging us!" = "보내 주셔서 감사합니다!"; -"Try Again" = "다시 시도"; -"Send Feedback" = "의견 보내기"; -"Your Name" = "이름"; -"Please provide a name." = "이름을 입력해 주세요."; -"FAQ" = "FAQ"; -"Describe your problem" = "문제에 대해 설명하기"; -"How can we help?" = "무엇을 도와 드릴까요?"; -"Help about" = "도움말 주제:"; -"We could not fetch the required data" = "필요한 데이터를 불러오지 못했습니다"; -"Name" = "이름"; -"Sending failed!" = "전송 실패!"; -"You didn't find this helpful." = "도움이 되지 않았습니다."; -"Attach a screenshot of your problem" = "문제에 관련된 스크린샷 첨부하기"; -"Mark as read" = "읽은 상태로 표시"; -"Name invalid" = "잘못된 이름"; -"Yes" = "네"; -"What's on your mind?" = "무슨 일로 오셨나요?"; -"Send a new message" = "새 메시지 전송"; -"Questions that may already have your answer" = "이미 답변된 질문일 수도 있습니다"; -"Attach a photo" = "사진 첨부"; -"Accept" = "수락"; -"Your reply" = "귀하의 답장"; -"Inbox" = "수신함"; -"Remove attachment" = "첨부 파일 제거"; -"Could not fetch message" = "메시지를 불러올 수 없음"; -"Read FAQ" = "FAQ 읽기"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "죄송합니다! 이 대화는 활동이 없어서 닫혔습니다. 에이전트로 새 대화를 시작해 주세요."; -"%d new messages from Support" = "고객 지원에서 보낸 새 메시지 %d개"; -"Ok, Attach" = "확인 및 첨부"; -"Send" = "전송"; -"Screenshot size should not exceed %.2f MB" = "스크린샷 크기가 %.2f MB를 초과함"; -"Information" = "정보"; -"Issue ID" = "문제 ID"; -"Tap to copy" = "눌러서 복사하기"; -"Copied!" = "복사 완료!"; -"We couldn't find an FAQ with matching ID" = "일치하는 ID로 FAQ를 찾을 수 없습니다."; -"Failed to load screenshot" = "스크린샷 불러오기 실패"; -"Failed to load video" = "비디오 불러오기 실패"; -"Failed to load image" = "이미지 불러오기 실패"; -"Hold down your device's power and home buttons at the same time." = "단말기의 전원 버튼과 홈 버튼을 동시에 누르십시오."; -"Please note that a few devices may have the power button on the top." = "일부 단말기는 기기 상단에 전원 버튼이 있습니다."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "완료되면, 이 대화창으로 돌아와서 \"확인, 첨부\"를 눌러 첨부합니다."; -"Okay" = "창 닫기"; -"We couldn't find an FAQ section with matching ID" = "일치하는 ID로 FAQ 섹션을 찾을 수 없습니다."; - -"GIFs are not supported" = "GIF는 지원되지 않습니다."; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/lv.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/lv.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index cb740be87455..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/lv.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Nevarat atrast to, ko meklējāt?"; -"Rate App" = "Novērtēt lietotni"; -"We\'re happy to help you!" = "Priecāsimies Jums palīdzēt!"; -"Did we answer all your questions?" = "Vai mēs atbildējām uz visiem Jūsu jautājumiem?"; -"Remind Later" = "Atgādināt vēlāk"; -"Your message has been received." = "Jūsu ziņa tika saņemta."; -"Message send failure." = "Ziņas nosūtīšana neizdevās."; -"What\'s your feedback about our customer support?" = "Kā Jūs vērtējat mūsu klientu atbalstu?"; -"Take a screenshot on your iPhone" = "Uzņemiet ekrānuzņēmumu ar savu iPhone"; -"Learn how" = "Uzziniet, kādā veidā"; -"Take a screenshot on your iPad" = "Uzņemiet ekrānuzņēmumu ar savu iPad"; -"Your email(optional)" = "Jūsu e-pasts (izvēles)"; -"Conversation" = "Saruna"; -"View Now" = "Skatīt tagad"; -"SEND ANYWAY" = "VIENALGA NOSŪTĪT"; -"OK" = "LABI"; -"Help" = "Palīdzība"; -"Send message" = "Nosūtīt ziņojumu"; -"REVIEW" = "NOVĒRTĒT"; -"Share" = "Kopīgot"; -"Close Help" = "Aizvērt sadaļu Palīdzība"; -"Sending your message..." = "Notiek Jūsu ziņas nosūtīšana..."; -"Learn how to" = "Uzziniet, kā"; -"No FAQs found in this section" = "Šajā sadaļā netika atrasti BUJ"; -"Thanks for contacting us." = "Paldies, ka sazinājāties ar mums."; -"Chat Now" = "Tērzēt tagad"; -"Buy Now" = "Pirkt tagad"; -"New Conversation" = "Jauna saruna"; -"Please check your network connection and try again." = "Lūdzu, pārbaudiet tīkla pieslēgumu un mēģiniet vēlreiz."; -"New message from Support" = "Jauna ziņa no Klientu atbalsta dienesta"; -"Question" = "Jautājums"; -"Type in a new message" = "Ierakstiet jaunu ziņojumu"; -"Reply" = "Atbildēt"; -"Email (optional)" = "E-pasts (izvēles papildiespēja)"; -"CONTACT US" = "SAZINĀTIES AR MUMS"; -"Email" = "E-pasts"; -"Like" = "Patīk"; -"Tap here if this FAQ was not helpful to you" = "Piesitiet šeit, ja šis BUJ nebija noderīgs"; -"Any other feedback? (optional)" = "Cits vērtējums? (izvēles papildiespēja)"; -"You found this helpful." = "Palīdzēja."; -"No working Internet connection is found." = "Piekļuve funkcionējošam interneta pieslēgumam netika atrasta."; -"No messages found." = "Ziņojumi nav atrasti."; -"Please enter a brief description of the issue you are facing." = "Lūdzu, ievadiet īsu pieredzētās problēmas aprakstu."; -"Shop Now" = "Iepirkties tagad"; -"Close Section" = "Aizvērt sadaļu"; -"Close FAQ" = "Aizvērt BUJ"; -"Close" = "Aizvērt"; -"This conversation has ended." = "Šī saruna ir beigusies."; -"Send it anyway" = "Tik un tā sūtīt"; -"You accepted review request." = "Jūs pieņēmāt novērtējuma pieprasījumu."; -"Delete" = "Dzēst"; -"What else can we help you with?" = "Kā vēl mēs Jums varam palīdzēt?"; -"Tap here if the answer was not helpful to you" = "Piesitiet šeit, ja atbilde nebija noderīga"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Žēl to dzirdēt. Vai Jūs, lūdzu, varētu plašāk izklāstīt pieredzētās problēmas būtību?"; -"Service Rating" = "Pakalpojuma vērtējums"; -"Your email" = "Jūsu e-pasts"; -"Email invalid" = "E-pasts nav pareizs"; -"Could not fetch FAQs" = "BUJ nebija iespējams izgūt"; -"Thanks for rating us." = "Paldies, ka sniedzāt vērtējumu par mums."; -"Download" = "Lejupielādēt"; -"Please enter a valid email" = "Lūdzu, ievadiet derīgu e-pastu"; -"Message" = "Ziņojums"; -"or" = "vai"; -"Decline" = "Noraidīt"; -"No" = "Nē"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Nevar nosūtīt ekrānuzņēmumu. Attēls ir pārāk liels, mēģiniet vēlreiz ar citu attēlu"; -"Hated it" = "Nepatika"; -"Stars" = "Zvaigznes"; -"Your feedback has been received." = "Jūsu vērtējums tika saņemts."; -"Dislike" = "Nepatīk"; -"Preview" = "Priekšskatījums"; -"Book Now" = "Rezervēt tagad"; -"START A NEW CONVERSATION" = "UZSĀKT JAUNU SARUNU"; -"Your Rating" = "Jūsu vērtējums"; -"No Internet!" = "Nav piekļuves internetam!"; -"Invalid Entry" = "Ieraksts nav pareizs"; -"Loved it" = "Patika"; -"Review on the App Store" = "Novērtējums App Store"; -"Open Help" = "Atvērt Palīdzību"; -"Search" = "Meklēt"; -"Tap here if you found this FAQ helpful" = "Piesitiet šeit, ja šis BUJ bija noderīgs"; -"Star" = "Zvaigzne"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Piesitiet šeit, ja atbilde bija noderīga"; -"Report a problem" = "Ziņot par problēmu"; -"YES, THANKS!" = "JĀ, PALDIES!"; -"Was this helpful?" = "Vai tas palīdzēja?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Jūsu ziņa netika nosūtīta. \"Mēģināt vēlreiz\", lai sūtītu šo ziņu?"; -"Suggestions" = "Ieteikumi"; -"No FAQs found" = "BUJ netika atrasti"; -"Done" = "Darīts"; -"Opening Gallery..." = "Notiek attēlu galerijas atvēršana..."; -"You rated the service with" = "Jūs novērtējāt pakalpojumu ar"; -"Cancel" = "Atcelt"; -"Loading..." = "Ielādē..."; -"Read FAQs" = "Lasīt BUJ"; -"Thanks for messaging us!" = "Paldies par nosūtīto ziņojumu!"; -"Try Again" = "Mēģināt vēlreiz"; -"Send Feedback" = "Nosūtiet atsauksmi"; -"Your Name" = "Jūsu vārds"; -"Please provide a name." = "Lūdzu, ievadiet vārdu."; -"FAQ" = "BUJ"; -"Describe your problem" = "Aprakstiet problēmu"; -"How can we help?" = "Kā mēs varam palīdzēt?"; -"Help about" = "Palīdzība par"; -"We could not fetch the required data" = "Mēs nevarējām izgūt pieprasīto informāciju"; -"Name" = "Vārds"; -"Sending failed!" = "Nosūtīšana neizdevās!"; -"You didn't find this helpful." = "Šī sadaļa Jums nebija noderīga."; -"Attach a screenshot of your problem" = "Pievienot savas problēmas ekrānuzņēmumu"; -"Mark as read" = "Atzīmēt kā izlasītu"; -"Name invalid" = "Vārds nav pareizs"; -"Yes" = "Jā"; -"What's on your mind?" = "Kas Jums ir padomā?"; -"Send a new message" = "Nosūtīt jaunu ziņojumu"; -"Questions that may already have your answer" = "Atbildes, kas varētu sniegt risinājumu Jūsu jautājumiem"; -"Attach a photo" = "Pievienot attēlu"; -"Accept" = "Piekrist"; -"Your reply" = "Jūsu atbilde"; -"Inbox" = "Iesūtne"; -"Remove attachment" = "Noņemt pielikumu"; -"Could not fetch message" = "Ziņojumu nevarēja iegūt"; -"Read FAQ" = "Lasīt BUJ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Atvainojiet! Šī saruna tika aizvērta neaktivitātes dēļ. Lūdzu, uzsāciet jaunu sarunu ar mūsu aģentiem."; -"%d new messages from Support" = "%d jauni ziņojumi no Atbalsta dienesta"; -"Ok, Attach" = "Labi, pievienot"; -"Send" = "Nosūtīt"; -"Screenshot size should not exceed %.2f MB" = "Ekrānuzņēmuma izmērs nedrīkst pārsniegt %.2f MB"; -"Information" = "Informācija"; -"Issue ID" = "Problēmas ID"; -"Tap to copy" = "Piespiediet, lai kopētu"; -"Copied!" = "Nokopēts!"; -"We couldn't find an FAQ with matching ID" = "Neatradām BUJ ar atbilstošu ID"; -"Failed to load screenshot" = "Ekrānuzņēmuma ielāde neizdevās"; -"Failed to load video" = "Video ielāde neizdevās"; -"Failed to load image" = "Attēla ielāde neizdevās"; -"Hold down your device's power and home buttons at the same time." = "Vienlaicīgi turiet piespiestu savas ierīces ieslēgšanas/izslēgšanas pogu un sākuma ekrāna atainošanas pogu."; -"Please note that a few devices may have the power button on the top." = "Ņemiet vērā, ka dažām ierīcēm ieslēgšanas/izslēgšanas poga var atrasties augšpusē."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Pēc tam atgriezieties pie šīs sarunas un uzklikšķiniet uz \"Labi, pievienot\", lai to pievienotu."; -"Okay" = "Labi"; -"We couldn't find an FAQ section with matching ID" = "Nebija iespējams atrast BUJ sadaļu ar atbilstošu ID"; - -"GIFs are not supported" = "GIF faili netiek atbalstīti"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ml.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ml.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index def553bd5ea7..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ml.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "നിങ്ങൾ അന്വേഷിച്ചത് ‌കണ്ടെത്താനായില്ലേ?"; -"Rate App" = "ആപ്പ് ‌റേറ്റുചെയ്യുക"; -"We\'re happy to help you!" = "നിങ്ങളെ സഹായിക്കാൻ ‌ഞങ്ങൾക്ക് സന്തോഷമേയുള്ളു!"; -"Did we answer all your questions?" = "നിങ്ങളുടെ ചോദ്യങ്ങൾക്കെല്ലാം ഞങ്ങൾ മറുപടി നൽകിയോ?"; -"Remind Later" = "അൽപ്പസമയം കഴിഞ്ഞ് ഓർമ്മിപ്പിക്കുക"; -"Your message has been received." = "നിങ്ങളുടെ സന്ദേശം സ്വീകരിച്ചു."; -"Message send failure." = "സന്ദേശമയയ്ക്കൽ പരാജയപ്പെട്ടു."; -"What\'s your feedback about our customer support?" = "ഞങ്ങളുടെ ഉപഭോക്തൃ പിന്തുണയെ കിറിച്ച് നിങ്ങളുടെ പ്രതികരണമെന്താണ്?"; -"Take a screenshot on your iPhone" = "നിങ്ങളുടെ iPhone-ൽ ഒരു സ്ക്രീൻഷോട്ട് എടുക്കൂ"; -"Learn how" = "എങ്ങനെയെന്ന് അറിയുക"; -"Take a screenshot on your iPad" = "നിങ്ങളുടെ iPad-ൽ ഒരു സ്ക്രീൻഷോട്ട് എടുക്കൂ"; -"Your email(optional)" = "നിങ്ങളുടെ ഇമെയിൽ(ഓപ്ഷണൽ)"; -"Conversation" = "ചർച്ച"; -"View Now" = "ഇപ്പോൾ കാണുക"; -"SEND ANYWAY" = "എന്തായാലും അയയ്ക്കുക"; -"OK" = "ശരി"; -"Help" = "സഹായം"; -"Send message" = "സന്ദേശമയയ്ക്കുക"; -"REVIEW" = "അവലോകനം ചെയ്യുക"; -"Share" = "പങ്കിടുക"; -"Close Help" = "സഹായം അടയ്ക്കുക"; -"Sending your message..." = "നിങ്ങളുടെ സന്ദേശം അയയ്ക്കുന്നു..."; -"Learn how to" = "ഇനിപ്പറയുന്നത് എങ്ങനെയെന്ന് അറിയുക"; -"No FAQs found in this section" = "ഈ വിഭാഗത്തിൽ പതിവ് ചോദ്യങ്ങളൊന്നും കണ്ടെത്തിയില്ല"; -"Thanks for contacting us." = "ഞങ്ങളെ ബന്ധപ്പെട്ടതിന് നന്ദി."; -"Chat Now" = "ഇപ്പോൾ ചാറ്റുചെയ്യുക"; -"Buy Now" = "ഇപ്പോൾ വാങ്ങുക"; -"New Conversation" = "പുതിയ ചർച്ച"; -"Please check your network connection and try again." = "നിങ്ങളുടെ നെറ്റ്‌വർക്ക് ‌കണക്ഷൻ പരിശോധിച്ചശേഷം വീണ്ടും ശ്രമിക്കുക."; -"New message from Support" = "പിന്തുണയിൽ നിന്നുള്ള പുതിയ സന്ദേശം"; -"Question" = "ചോദ്യം"; -"Thanks for rating us." = "ഞങ്ങളെ റേറ്റ് ചെയ്തതിന് നന്ദി."; -"Type in a new message" = "ഒരു പുതിയ സന്ദേശം ടൈപ്പുചെയ്യുക"; -"Email (optional)" = "ഇമെയിൽ (ഐച്ഛികം)"; -"Reply" = "മറുപടി"; -"CONTACT US" = "ഞങ്ങളെ ബന്ധപ്പെടുക"; -"Email" = "ഇലെയിൽ"; -"Like" = "ഇഷ്ടപ്പെടുന്നു"; -"Tap here if this FAQ was not helpful to you" = "പതിവ് ‌ചോദ്യങ്ങൾ നിങ്ങൾക്ക് സഹായകരമല്ലായെങ്കിൽ, ഇവിടെ ടാപ്പുചെയ്യുക"; -"Any other feedback? (optional)" = "മറ്റെന്തെങ്കിലും പ്രതികരണമുണ്ടോ? (ഐച്ഛികം)"; -"You found this helpful." = "നിങ്ങൾക്കിത് സഹായകരമായിരുന്നു."; -"No working Internet connection is found." = "പ്രവർത്തനനിരതമായ ഇന്റർനെറ്റ് ‌കണക്ഷനുകളൊന്നും കണ്ടെത്തിയില്ല."; -"No messages found." = "സന്ദേശങ്ങളൊന്നും കാണ്മാനില്ല."; -"Please enter a brief description of the issue you are facing." = "നിങ്ങൾ നേരിടുന്ന പ്രശ്‌നത്തെ കുറിച്ച് ‌ലഘുവായ വിവരണം ദയവായി നൽകുക."; -"Shop Now" = "ഇപ്പോൾ ഷോപ്പുചെയ്യുക"; -"Close Section" = "വിഭാഗം അടയ്ക്കുക"; -"Close FAQ" = "പതിവ് ചോദ്യങ്ങൾ അടയ്ക്കുക"; -"Close" = "അടയ്ക്കുക"; -"This conversation has ended." = "ഈ ചർച്ച അവസാനിച്ചു."; -"Send it anyway" = "അത് എന്തായാലും അയയ്ക്കുക"; -"You accepted review request." = "അവലോകന അഭ്യർത്ഥന നിങ്ങൾ സ്വീകരിച്ചു."; -"Delete" = "ഇല്ലാതാക്കുക"; -"What else can we help you with?" = "മറ്റെന്ത് സഹായമാണ് ഞങ്ങൾ നിങ്ങൾക്ക് നൽകേണ്ടത്?"; -"Tap here if the answer was not helpful to you" = "ഉത്തരം നിങ്ങൾക്ക് സഹായകരമല്ലായെങ്കിൽ, ഇവിടെ ടാപ്പുചെയ്യുക"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "ഞങ്ങൾക്കതിൽ അതിയായ വിഷമമുണ്ട്. നിങ്ങൾ നേരിടുന്ന പ്രശ്‌നത്തെ കുറിച്ച് ‌കുറച്ചുകൂടി വിവരം ഞങ്ങൾക്ക് ‌നൽകാമോ?"; -"Service Rating" = "സേവന റേറ്റിംഗ്"; -"Your email" = "നിങ്ങളുടെ ഇമെയിൽ"; -"Email invalid" = "അസാധുവായ ഇമെയിൽ"; -"Could not fetch FAQs" = "പതിവ് ചോദ്യങ്ങളൊന്നും കണ്ടെത്താനായില്ല"; -"Download" = "ഡൗൺലോഡുചെയ്യുക"; -"Please enter a valid email" = "സാധുവായ ഇമെയിൽ നൽകുക"; -"Message" = "സന്ദേശം"; -"or" = "അല്ലെങ്കിൽ"; -"Decline" = "നിരസിക്കുക"; -"No" = "അല്ല"; -"Screenshot could not be sent. Image is too large, try again with another image" = "സ്ക്രീൻഷോട്ട് അയയ്ക്കാൻ കഴിഞ്ഞില്ല. ചിത്രം വളരെ വലുതാണ്, മറ്റൊരു ചിത്രമുപയോഗിച്ച് ‌വീണ്ടും ശ്രമിക്കുക"; -"Hated it" = "വെറുക്കുന്നു"; -"Stars" = "നക്ഷത്രങ്ങൾ"; -"Your feedback has been received." = "നിങ്ങളുടെ പ്രതികരണം ലഭിച്ചു."; -"Dislike" = "ഇഷ്ടപ്പെടുന്നില്ല"; -"Preview" = "പ്രിവ്യൂ"; -"Book Now" = "ഇപ്പോൾ ബുക്കുചെയ്യുക"; -"START A NEW CONVERSATION" = "ഒരു പുതിയ ചർച്ച ആരംഭിക്കുക"; -"Your Rating" = "നിങ്ങളുടെ റേറ്റിംഗ്"; -"No Internet!" = "ഇന്റർനെറ്റ് ഇല്ല!"; -"Invalid Entry" = "അസാധുവായ എൻട്രി"; -"Loved it" = "ഇഷ്ടപ്പെടുന്നു"; -"Review on the App Store" = "ആപ്പ് സ്റ്റോറിലെ അവലോകനം"; -"Open Help" = "സഹായം തിറക്കുക"; -"Search" = "തിരയുക"; -"Tap here if you found this FAQ helpful" = "പതിവ് ചോദ്യങ്ങൾ നിങ്ങൾക്ക് സഹായകരമായെങ്കിൽ, ഇവിടെ ടാപ്പുചെയ്യുക"; -"Star" = "നക്ഷത്രം"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "ഉത്തരം നിങ്ങൾക്ക് സഹായകരമായെങ്കിൽ, ഇവിടെ ടാപ്പുചെയ്യുക"; -"Report a problem" = "ഒരു പ്രശ്‌നം റിപ്പോർട്ടുചെയ്യുക"; -"YES, THANKS!" = "അതെ, നന്ദി!"; -"Was this helpful?" = "ഇത് സഹായകരമായിരുന്നോ?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "നിങ്ങളുടെ സന്ദേശമയച്ചിട്ടില്ല. ഈ സന്ദേശമയയ്ക്കാൻ \"വീണ്ടും ശ്രമിക്കുക\" എന്നതിൽ ടാപ്പുചെയ്യണോ?"; -"Suggestions" = "അഭിപ്രായങ്ങൾ"; -"No FAQs found" = "പതിവ് ചോദ്യങ്ങളൊന്നും കണ്ടെത്തിയില്ല"; -"Done" = "കഴിഞ്ഞു"; -"Opening Gallery..." = "ഗാലറി തുറക്കുന്നു..."; -"You rated the service with" = "നിങ്ങൾ സേവനത്തെ ഇനിപ്പറയുന്ന പ്രകാരം റേറ്റുചെയ്‌തു"; -"Cancel" = "റദ്ദാക്കുക"; -"Loading..." = "ലോഡുചെയ്യുന്നു..."; -"Read FAQs" = "പതിവ് ചോദ്യങ്ങൾ വായിക്കുക"; -"Thanks for messaging us!" = "ഞങ്ങൾക്ക് സന്ദേശമയച്ചതിന് നിങ്ങൾക്ക് നന്ദി!"; -"Try Again" = "വീണ്ടും ശ്രമിക്കുക"; -"Send Feedback" = "പ്രതികരണം അയയ്ക്കുക"; -"Your Name" = "നിങ്ങളുടെ പേര്"; -"Please provide a name." = "ദയവായി ഒരു പേര് നൽകുക."; -"FAQ" = "പതിവ് ചോദ്യങ്ങൾ"; -"Describe your problem" = "നിങ്ങൾ നേരിടുന്ന പ്രശ്‌നം വിവരിക്കുക"; -"How can we help?" = "നിങ്ങൾക്ക് എന്ത് സഹായമാണ് വേണ്ടത്?"; -"Help about" = "ഇനിപ്പറയുന്നതിനെ കുറിച്ചുള്ള സഹായം"; -"We could not fetch the required data" = "ആവശ്യമായ ഡാറ്റ കണ്ടെത്താനായില്ല"; -"Name" = "പേര്"; -"Sending failed!" = "അയയ്ക്കൽ പരാജയപ്പെട്ടു!"; -"You didn't find this helpful." = "നിങ്ങൾക്കിത് ‌സഹായകരമായിരുന്നില്ല."; -"Attach a screenshot of your problem" = "നിങ്ങളുടെ പ്രശ്‌നം സംബന്ധിച്ച ഒരു സ്‌ക്രീൻഷോട്ട് അറ്റാച്ചുചെയ്യുക"; -"Mark as read" = "വായിച്ചതായി അടയാളപ്പെടുത്തുക"; -"Name invalid" = "പേര് തെറ്റാണ്"; -"Yes" = "അതെ"; -"What's on your mind?" = "നിങ്ങ‌ളുടെ മനസ്സിലെന്താണ്?"; -"Send a new message" = "ഒരു പുതിയ സന്ദേശമയയ്ക്കുക"; -"Questions that may already have your answer" = "നിങ്ങൾക്കുള്ള ഉത്തരം ഇതിനകം തന്നെ അടങ്ങിയിട്ടുള്ള ചോദ്യങ്ങൾ"; -"Attach a photo" = "ഒരു ഫോട്ടോ അറ്റാച്ചുചെയ്യുക"; -"Accept" = "സ്വീകരിക്കുക"; -"Your reply" = "നിങ്ങളുടെ മറുപടി"; -"Inbox" = "ഇൻബോക്‌സ്"; -"Remove attachment" = "അറ്റാച്ചുമെന്റ് നീക്കംചെയ്യുക"; -"Could not fetch message" = "സന്ദേശം കൊണ്ടുവരാൻ കഴിഞ്ഞില്ല"; -"Read FAQ" = "പതിവ് ചോദ്യങ്ങൾ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "ക്ഷമിക്കണം! സജീവമല്ലാത്തത് മൂലം ഈ ചർച്ച അവസാനിപ്പിച്ചു. ഞങ്ങളുടെ ഏജന്റുമാരുമായി ഒരു പുതിയ ചർച്ച ആരംഭിക്കുക."; -"%d new messages from Support" = "%d പിന്തുണയിൽ നിന്നുള്ള പുതിയ സന്ദേശം"; -"Ok, Attach" = "ശരി, അറ്റാച്ചുചെയ്യൂ"; -"Send" = "അയയ്‌ക്കൂ"; -"Screenshot size should not exceed %.2f MB" = "സ്‌ക്രീൻഷോട്ട് ‌വലിപ്പം %.2f MB-യിൽ കൂടാൻ പാടില്ല"; -"Information" = "വിവരം"; -"Issue ID" = "ഇഷ്യൂ ഐഡി"; -"Tap to copy" = "പകർത്താൻ ടാപ്പുചെയ്യുക"; -"Copied!" = "പകർത്തി!"; -"We couldn't find an FAQ with matching ID" = "സമാന ഐഡിയുള്ള പതിവായി ചോദിക്കുന്ന ഒരു ചോദ്യം കണ്ടെത്താൻ ഞങ്ങൾക്ക് ‌കഴിഞ്ഞില്ല."; -"Failed to load screenshot" = "സ്രീൻഷോട്ട് ലോഡ് ചെയ്യുന്നത് പരാജയപ്പെട്ടു"; -"Failed to load video" = "വീഡിയോ ‌ലോഡ് ചെയ്യുന്നത് പരാജയപ്പെട്ടു"; -"Failed to load image" = "ചിത്രം ലോഡ് ചെയ്യുന്നത് പരാജയപ്പെട്ടു"; -"Hold down your device's power and home buttons at the same time." = "നിങ്ങളുടെ ഉപകരണത്തിലെ ‌പവർ ബട്ടണും ഹോം ബട്ടണും ഒരേ സമയം അമർത്തിപ്പിടിക്കുക."; -"Please note that a few devices may have the power button on the top." = "ചില ഉപകരണങ്ങളിൽ പവർ ബട്ടൺ മുകളിലായിരിക്കം എന്ന കാര്യം ശ്രദ്ധിക്കുക."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "സ്‌ക്രീൻഷോട്ട് എടുത്ത ശേഷം അത് അറ്റാച്ചുചെയ്യാൻ, ഈ ചർച്ചയിലേക്ക് മടങ്ങി വന്ന് \"ശരി, അറ്റാച്ചുചെയ്യുക\" എന്നതിൽ ടാപ്പുചെയ്യുക."; -"Okay" = "ശരി"; -"We couldn't find an FAQ section with matching ID" = "സമാന ഐഡിയുള്ള ഒരു FAQ വിഭാഗം കണ്ടെത്താൻ കഴിഞ്ഞില്ല"; - -"GIFs are not supported" = "GIF- കൾ പിന്തുണയ്ക്കുന്നില്ല"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/mr.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/mr.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 127a97966d62..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/mr.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "आपण\ शोधत आहात ते सापडत नाही आहे का?"; -"Rate App" = "अनुप्रयोग रेट करा"; -"We\'re happy to help you!" = "आम्ही\ आपली मदत करण्यास आनंदी आहोत!"; -"Did we answer all your questions?" = "आम्ही आपल्या सर्व प्रश्नांची उत्तरे दिली का?"; -"Remind Later" = "नंतर आठवण करा"; -"Your message has been received." = "आपला संदेश प्राप्त झाला आहे."; -"Message send failure." = "संदेश पाठवणी अपयशी"; -"What\'s your feedback about our customer support?" = "आमच्या\ ग्राहक सेवेबद्दल आपली अभिप्राय काय आहे?"; -"Take a screenshot on your iPhone" = "आपल्या iPhoneवर एक स्क्रीन शॉट घ्या"; -"Learn how" = "कसे करायचे ते शिका"; -"Take a screenshot on your iPad" = "आपल्या iPadवर एक स्क्रीन शॉट घ्या"; -"Your email(optional)" = "आपला इमेल (वैकल्पिक)"; -"Conversation" = "संभाषण"; -"View Now" = "आता पाहा"; -"SEND ANYWAY" = "असेच पाठवा"; -"OK" = "ठीक आहे"; -"Help" = "मदत"; -"Send message" = "संदेश पाठवा"; -"REVIEW" = "पुनरावलोकन"; -"Share" = "सामायिक करा"; -"Close Help" = "मदत बंद करा"; -"Sending your message..." = "आपला संदेश पाठवत आहे..."; -"Learn how to" = "कसे करायचे ते शिका"; -"No FAQs found in this section" = "ह्या भागात कोणतेही FAQ सापडले नाहीत"; -"Thanks for contacting us." = "आमच्याशी संपर्क साधल्या बद्दल धन्यवाद!"; -"Chat Now" = "आता चॅट करा"; -"Buy Now" = "आता खरेदी करा"; -"New Conversation" = "नवीन संभाषण"; -"Please check your network connection and try again." = "कृपया आपली नेटवर्क जोडणी तपासा आणि पुन्हा प्रयत्न करा"; -"New message from Support" = "समर्थनाकडून नवीन संदेश"; -"Question" = "प्रश्न"; -"Type in a new message" = "नवीन संदेश टाइप करा"; -"Email (optional)" = "इमेल (वैकल्पिक)"; -"Reply" = "उत्तर द्या"; -"CONTACT US" = "आमच्याशी संपर्क करा"; -"Email" = "इमेल"; -"Like" = "लाईक करा"; -"Tap here if this FAQ was not helpful to you" = "FAQ आपल्याला उपयुक्त नसल्यास इथे टॅप करा"; -"Any other feedback? (optional)" = "इतर कोणताही अभिप्राय? (वैकल्पिक)"; -"You found this helpful." = "आपल्याला हे उपयुक्त वाटले."; -"No working Internet connection is found." = "कोणतीही चालू इंटरनेट जोडणी संपली नाही"; -"No messages found." = "कोणतेही संदेश सापडले नाहीत."; -"Please enter a brief description of the issue you are facing." = "कृपया आपण तोंड देत असलेल्या समस्येचे संक्षिप्त वर्णन लिहा."; -"Shop Now" = "आता खरेदी करा"; -"Close Section" = "भाग बंद करा"; -"Close FAQ" = "FAQ बंद करा"; -"This conversation has ended." = "हे संभाषण संपले आहे"; -"Send it anyway" = "असेच पाठवा"; -"You accepted review request." = "आपली पुनरावलोकन विनंती स्वीकारली गेली आहे."; -"Delete" = "हटवा"; -"What else can we help you with?" = "आम्ही आपल्याला अजून काय मदत करू शकतो?"; -"Tap here if the answer was not helpful to you" = "उत्तर आपल्याला उपयुक्त नसल्यास इथे टॅप करा"; -"Service Rating" = "सेवा रेटिंग"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "ऐकून वाईट वाटले. कृपया आपण सामोरे जात असलेल्या समस्येबद्दल थोडे अधिक सांगू शकाल का?"; -"Your email" = "आपला इमेल"; -"Email invalid" = "इमेल अवैध आहे"; -"Could not fetch FAQs" = "FAQ प्राप्त करता आले नाहीत"; -"Thanks for rating us." = "आम्हाला रेट केल्याबद्दल धन्यवाद"; -"Download" = "डाउनलोड करा"; -"Please enter a valid email" = "कृपया एक वैध इमेल प्रविष्ठ करा"; -"Message" = "संदेश"; -"or" = "किंवा"; -"Decline" = "नाकारा"; -"No" = "नाही"; -"Screenshot could not be sent. Image is too large, try again with another image" = "स्क्रीन शॉट पाठवता आला नाही. प्रतिमा खूप मोठी आहे, दुसर्या प्रतिमेसह पुन्हा प्रयत्न करा"; -"Hated it" = "अजिबात आवडले नाही"; -"Stars" = "तारे"; -"Your feedback has been received." = "आपला अभिप्राय प्राप्त झाला आहे."; -"Dislike" = "डिस्लाईक करा"; -"Preview" = "प्रीव्ह्यू"; -"Book Now" = "आता बुक करा"; -"START A NEW CONVERSATION" = "नवीन संभाषण सुरु करा"; -"Your Rating" = "आपले रेटिंग"; -"No Internet!" = "इंटरनेट नाही आहे!"; -"Invalid Entry" = "अवैध एन्ट्री"; -"Loved it" = "अतिशय आवडला"; -"Review on the App Store" = "App Store मध्ये पुनरावलोकन करा"; -"Open Help" = "मदत उघडा"; -"Search" = "शोधा"; -"Tap here if you found this FAQ helpful" = "जर आपल्याला FAQ उपयुक्त वाटले असतील तर इथे टॅप करा"; -"Star" = "तारा"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "जर आपल्याला उत्तर उपयुक्त वाटले असेल तर इथे टॅप करा"; -"Report a problem" = "समस्या नोंदवा"; -"YES, THANKS!" = "होय, धन्यवाद!"; -"Was this helpful?" = "हे उपयुक्त होते का?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "आपला संदेश पाठवला गेला नाही.हा संदेश पाठवण्यासाठी\"Try Again\" वर टॅप करा?"; -"Suggestions" = "सूचना"; -"No FAQs found" = "कोणतेही FAQ सापडले नाहीत"; -"Done" = "झाले"; -"Opening Gallery..." = "गॅलेरी उघडत आहे..."; -"You rated the service with" = "आपण ह्या सेवेला ___ श्रेणीबद्ध केलेत."; -"Cancel" = "रद्द करा"; -"Close" = "बंद करा"; -"Loading..." = "लोड करत आहे..."; -"Read FAQs" = "FAQ वाचा"; -"Thanks for messaging us!" = "आम्हाला संदेश पाठवल्याबद्दल धन्यवाद!"; -"Try Again" = "पुन्हा प्रयत्न करा"; -"Send Feedback" = "अभिप्राय पाठवा"; -"Your Name" = "आपले नाव"; -"Please provide a name." = "कृपया एक नाव पुरवा"; -"FAQ" = "FAQ"; -"Describe your problem" = "आपल्या समस्येचे वर्णन करा"; -"How can we help?" = "आम्ही आपली काय मदत करू शकतो?"; -"Help about" = "विषयी मदत"; -"Name" = "नाव"; -"Sending failed!" = "पाठवणे अपयशी!"; -"We could not fetch the required data" = "आम्ही आवश्यक डेटा प्राप्त करू शकलो नाही"; -"You didn't find this helpful." = "आपल्याला हे उपयुक्त वाटले नाही."; -"Attach a screenshot of your problem" = "आपल्या समस्येचा स्क्रीन शॉट पाठवा"; -"Mark as read" = "वाचले आहे असे अंकित करा"; -"Name invalid" = "नाव अवैध आहे"; -"Yes" = "होय"; -"What's on your mind?" = "आपल्या मनात काय आहे?"; -"Send a new message" = "नवीन संदेश पाठवा"; -"Questions that may already have your answer" = "आपले उत्तर ज्यात आधीच असू शकेल असे प्रश्न"; -"Attach a photo" = "फोटो संलग्न करा"; -"Accept" = "स्वीकारा"; -"Your reply" = "आपले उत्तर"; -"Inbox" = "इनबॉक्स"; -"Remove attachment" = "संलग्न काढा"; -"Could not fetch message" = "संदेश प्राप्त करता आला नाही"; -"Read FAQ" = "FAQ वाचा"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "क्षमस्व! निष्क्रियतेमुळे हे संभाषण बंद करण्यात आले आहे. आमच्या एजंट्सशी नवीन संभाषण सुरु करा."; -"%d new messages from Support" = "%d समर्थनाकडून नवीन संदेश"; -"Ok, Attach" = "ठीक आहे,संलग्न करा"; -"Send" = "पाठवा"; -"Screenshot size should not exceed %.2f MB" = "स्क्रीन शॉट %.2f MB पेक्षा जास्त नसावा"; -"Information" = "माहिती"; -"Issue ID" = "समस्या आयडी"; -"Tap to copy" = "कॉपी करण्यासाठी टॅप करा"; -"Copied!" = "कॉपी केले आहे"; -"We couldn't find an FAQ with matching ID" = "आम्हाला जुळणाऱ्या ID चा FAQ सापडला नाही"; -"Failed to load screenshot" = "स्क्रीन शॉट उपलोड करण्यात अपयशी"; -"Failed to load video" = "व्हिडीओ उपलोड करण्यात अपयशी"; -"Failed to load image" = "प्रतिमा उपलोड करण्यात अपयशी"; -"Hold down your device's power and home buttons at the same time." = "आपल्या उपकरणाची पावर आणि होम बटणे एकत्र दाबून ठेवा"; -"Please note that a few devices may have the power button on the top." = "कृपया नोंद घ्या की काही उपकरणांमध्ये पावर बटण वर असू शकते"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "एकदा झाले, कि या संभाषणात परत या आणि संलग्न करण्यासाठी \"ठीक आहे, संलग्न करा\" वर टॅप करा."; -"Okay" = "ठीक आहे"; -"We couldn't find an FAQ section with matching ID" = "या ID शी जुळणारा FAQ आम्ही शोधू शकलो नाही"; - -"GIFs are not supported" = "GIF समर्थित नाहीत"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ms.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ms.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index a3a7fd70bc2a..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ms.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Tidak temui apa yang anda cari?"; -"Rate App" = "Nilaikan Apl"; -"We\'re happy to help you!" = "Kami gembira dapat membantu anda!"; -"Did we answer all your questions?" = "Adakah kami menjawab semua soalan anda?"; -"Remind Later" = "Ingatkan Kemudian"; -"Your message has been received." = "Mesej anda telah diterima."; -"Message send failure." = "Kegagalan menghantar mesej."; -"What\'s your feedback about our customer support?" = "Apakah maklum balas anda tentang sokongan pelanggan kami?"; -"Take a screenshot on your iPhone" = "Ambil syot layar pada iPhone anda"; -"Learn how" = "Ketahui caranya"; -"Take a screenshot on your iPad" = "Ambil syot layar pada iPad anda"; -"Your email(optional)" = "E-mel anda(pilihan)"; -"Conversation" = "Perbincangan"; -"View Now" = "Lihat Sekarang"; -"SEND ANYWAY" = "HANTAR JUGA"; -"OK" = "OK"; -"Help" = "Bantuan"; -"Send message" = "Hantar mesej"; -"REVIEW" = "SEMAK SEMULA"; -"Share" = "Kongsi"; -"Close Help" = "Tutup Bantuan"; -"Sending your message..." = "Menghantar mesej anda..."; -"Learn how to" = "Ketahui caranya"; -"No FAQs found in this section" = "Tiada Soalan Lazim ditemui dalam seksyen ini"; -"Thanks for contacting us." = "Terima kasih kerana menghubungi kami."; -"Chat Now" = "Sembang Sekarang"; -"Buy Now" = "Beli Sekarang"; -"New Conversation" = "Perbincangan Baharu"; -"Please check your network connection and try again." = "Sila semak sambungan rangkaian anda dan cuba lagi."; -"New message from Support" = "Mesej baharu daripada Sokongan"; -"Question" = "Soalan"; -"Type in a new message" = "Taip di dalam mesej baharu"; -"Email (optional)" = "E-mel (pilihan)"; -"Reply" = "Balas"; -"CONTACT US" = "HUBUNGI KAMI"; -"Email" = "E-mel"; -"Like" = "Suka"; -"Tap here if this FAQ was not helpful to you" = "Ketik di sini jika Soalan Lazim ini tidak membantu anda"; -"Any other feedback? (optional)" = "Sebarang maklum balas lain? (pilihan)"; -"You found this helpful." = "Anda mendapati ia membantu."; -"No working Internet connection is found." = "Tiada sambungan Internet yang berfungsi ditemui."; -"No messages found." = "Tiada mesej ditemui."; -"Please enter a brief description of the issue you are facing." = "Sila masukkan huraian ringkas mengenai isu yang anda hadapi."; -"Shop Now" = "Beli-belah Sekarang"; -"Close Section" = "Tutup Seksyen"; -"Close FAQ" = "Tutup Soalan Lazim"; -"Close" = "Tutup"; -"This conversation has ended." = "Perbincangan ini sudah tamat."; -"Send it anyway" = "Hantarkan juga"; -"You accepted review request." = "Anda menerima permintaan untuk mengulas."; -"Delete" = "Padam"; -"What else can we help you with?" = "Apa lagi yang boleh kami bantu anda?"; -"Tap here if the answer was not helpful to you" = "Ketik di sini jika jawapan itu tidak membantu anda"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Kami kesal mendengarnya. Boleh beritahu kami sedikit lagi tentang masalah yang anda hadapi?"; -"Service Rating" = "Penarafan Perkhidmatan"; -"Your email" = "E-mel anda"; -"Email invalid" = "E-mel tidak sah"; -"Could not fetch FAQs" = "Tidak dapat mengambil soalan lazim"; -"Thanks for rating us." = "Terima kasih kerana menilai kami."; -"Download" = "Muat turun"; -"Please enter a valid email" = "Sila masukkan e-mel yang sah"; -"Message" = "Mesej"; -"or" = "atau"; -"Decline" = "Tolak"; -"No" = "Tidak"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Syot layar tidak dapat dihantar. Imej terlalu besar, cuba lagi dengan imej lain"; -"Hated it" = "Membencinya"; -"Stars" = "Bintang"; -"Your feedback has been received." = "Maklum balas anda telah diterima."; -"Dislike" = "Tidak suka"; -"Preview" = "Pratonton"; -"Book Now" = "Tempah Sekarang"; -"START A NEW CONVERSATION" = "MULAKAN PERBINCANGAN BAHARU"; -"Your Rating" = "Penarafan Anda"; -"No Internet!" = "Tiada Internet!"; -"Invalid Entry" = "Entri Tidak Sah"; -"Loved it" = "Menyukainya"; -"Review on the App Store" = "Beri ulasan di Gedung Apl"; -"Open Help" = "Buka Bantuan"; -"Search" = "Cari"; -"Tap here if you found this FAQ helpful" = "Ketik di sini jika anda mendapati Soalan Lazim ini membantu"; -"Star" = "Bintang"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Ketik di sini jika anda mendapati jawapan ini membantu"; -"Report a problem" = "Laporkan masalah"; -"YES, THANKS!" = "YA, TERIMA KASIH!"; -"Was this helpful?" = "Adakah ini membantu?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Mesej anda belum dihantar. Ketik \"Cuba Lagi\" untuk menghantar mesej ini?"; -"Suggestions" = "Cadangan"; -"No FAQs found" = "Tiada Soalan Lazim ditemui"; -"Done" = "Selesai"; -"Opening Gallery..." = "Membuka Galeri..."; -"You rated the service with" = "Anda nilaikan perkhidmatan ini dengan"; -"Cancel" = "Batal"; -"Loading..." = "Memuatkan..."; -"Read FAQs" = "Baca Soalan-soalan Lazim"; -"Thanks for messaging us!" = "Terima kasih kerana menghantar mesej kepada kami!"; -"Try Again" = "Cuba Lagi"; -"Send Feedback" = "Hantar Maklum Balas"; -"Your Name" = "Nama Anda"; -"Please provide a name." = "Sila berikan nama."; -"FAQ" = "Soalan Lazim"; -"Describe your problem" = "Huraikan masalah anda"; -"How can we help?" = "Bagaimana kami boleh membantu?"; -"Help about" = "Bantuan tentang"; -"We could not fetch the required data" = "Kami tidak dapat mengambil data yang diperlukan"; -"Name" = "Nama"; -"Sending failed!" = "Penghantaran gagal!"; -"You didn't find this helpful." = "Anda tidak mendapatinya membantu."; -"Attach a screenshot of your problem" = "Lampirkan syot layar tentang masalah anda"; -"Mark as read" = "Tanda sudah baca"; -"Name invalid" = "Nama tidak sah"; -"Yes" = "Ya"; -"What's on your mind?" = "Apa yang anda fikirkan?"; -"Send a new message" = "Hantar mesej baharu"; -"Questions that may already have your answer" = "Soalan yang mungkin sudah ada jawapan anda"; -"Attach a photo" = "Lampirkan foto"; -"Accept" = "Terima"; -"Your reply" = "Jawapan anda"; -"Inbox" = "Peti masuk"; -"Remove attachment" = "Buang lampiran"; -"Could not fetch message" = "Tidak dapat mengambil mesej"; -"Read FAQ" = "Baca Soalan Lazim"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Maaf! Perbincangan ini telah ditutup kerana tidak aktif. Sila mulakan perbincangan baharu dengan ejen kami."; -"%d new messages from Support" = "%d mesej baharu daripada Sokongan"; -"Ok, Attach" = "Ok, Lampirkan"; -"Send" = "Hantar"; -"Screenshot size should not exceed %.2f MB" = "Saiz syot layar tidak sepatutnya melebihi %.2f MB"; -"Information" = "Maklumat"; -"Issue ID" = "ID Isu"; -"Tap to copy" = "Ketik untuk salin"; -"Copied!" = "Disalin!"; -"We couldn't find an FAQ with matching ID" = "Kami tidak menemui Soalan Lazim dengan ID yang sepadan"; -"Failed to load screenshot" = "Gagal memuatkan syot layar"; -"Failed to load video" = "Gagal memuatkan video"; -"Failed to load image" = "Gagal memuatkan imej"; -"Hold down your device's power and home buttons at the same time." = "Tekan terus butang kuasa dan butang utama peranti anda secara serentak."; -"Please note that a few devices may have the power button on the top." = "Sila ambil perhatian bahawa sesetengah peranti mungkin mempunyai butang kuasa di bahagian atas."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Setelah selesai, kembali ke perbincangan ini dan ketik \"Ok, lampirkan\" untuk melampirkannya."; -"Okay" = "Okey"; -"We couldn't find an FAQ section with matching ID" = "Kami tidak menemui seksyen Soalan Lazim dengan ID yang sepadan"; - -"GIFs are not supported" = "GIF tidak disokong"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/nb-NO.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/nb-NO.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index c5e05f2d2294..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/nb-NO.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* - HelpshiftLocalizable.strings - Helpshift - Copyright (c) 2014 Helpshift,Inc., All rights reserved. - */ - -"Can't find what you were looking for?" = "Can't find what you were looking for?"; -"Rate App" = "Rate App"; -"We\'re happy to help you!" = "We\'re happy to help you!"; -"What's on your mind?" = "What's on your mind?"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?"; -"Thanks for contacting us." = "Thanks for contacting us."; -"Remind Later" = "Remind Later"; -"Your message has been received." = "Your message has been received."; -"Message send failure." = "Message send failure."; -"Hated it" = "Hated it"; -"What\'s your feedback about our customer support?" = "What\'s your feedback about our customer support?"; -"Take a screenshot on your iPhone" = "Take a screenshot on your iPhone"; -"Learn how" = "Learn how"; -"Take a screenshot on your iPad" = "Take a screenshot on your iPad"; -"Name invalid" = "Name invalid"; -"View Now" = "View Now"; -"SEND ANYWAY" = "SEND ANYWAY"; -"Help" = "Help"; -"Send message" = "Send message"; -"REVIEW" = "REVIEW"; -"Share" = "Share"; -"Close Help" = "Close Help"; -"Loved it" = "Loved it"; -"Learn how to" = "Learn how to"; -"Chat Now" = "Chat Now"; -"Buy Now" = "Buy Now"; -"New Conversation" = "New Conversation"; -"Please check your network connection and try again." = "Please check your network connection and try again."; -"New message from Support" = "New message from Support"; -"Question" = "Question"; -"No FAQs found in this section" = "No FAQs found in this section"; -"Type in a new message" = "Type in a new message"; -"Email (optional)" = "Email (optional)"; -"Reply" = "Reply"; -"CONTACT US" = "CONTACT US"; -"Email" = "Email"; -"Like" = "Like"; -"Sending your message..." = "Sending your message..."; -"Tap here if this FAQ was not helpful to you" = "Tap here if this FAQ was not helpful to you"; -"Any other feedback? (optional)" = "Any other feedback? (optional)"; -"You found this helpful." = "You found this helpful."; -"No working Internet connection is found." = "No working Internet connection is found."; -"No messages found." = "No messages found."; -"Please enter a brief description of the issue you are facing." = "Please enter a brief description of the issue you are facing."; -"Shop Now" = "Shop Now"; -"Email invalid" = "Email invalid"; -"Did we answer all your questions?" = "Did we answer all your questions?"; -"Close Section" = "Close Section"; -"Close FAQ" = "Close FAQ"; -"Close" = "Close"; -"This conversation has ended." = "This conversation has ended."; -"Send it anyway" = "Send it anyway"; -"You accepted review request." = "You accepted review request."; -"Delete" = "Delete"; -"Invalid Entry" = "Invalid Entry"; -"Tap here if the answer was not helpful to you" = "Tap here if the answer was not helpful to you"; -"Service Rating" = "Service Rating"; -"Thanks for messaging us!" = "Thanks for messaging us!"; -"Could not fetch FAQs" = "Could not fetch FAQs"; -"Thanks for rating us." = "Thanks for rating us."; -"Download" = "Download"; -"Please enter a valid email" = "Please enter a valid email"; -"Message" = "Message"; -"or" = "or"; -"Your email" = "Your email"; -"Decline" = "Decline"; -"No" = "No"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Screenshot could not be sent. Image is too large, try again with another image"; -"Stars" = "Stars"; -"Your feedback has been received." = "Your feedback has been received."; -"Dislike" = "Dislike"; -"Preview" = "Preview"; -"Book Now" = "Book Now"; -"START A NEW CONVERSATION" = "START A NEW CONVERSATION"; -"Your Rating" = "Your Rating"; -"No Internet!" = "No Internet!"; -"You didn't find this helpful." = "You didn't find this helpful."; -"Review on the App Store" = "Review on the App Store"; -"Open Help" = "Open Help"; -"Search" = "Search"; -"Tap here if you found this FAQ helpful" = "Tap here if you found this FAQ helpful"; -"Star" = "Star"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Tap here if you found this answer helpful"; -"Report a problem" = "Report a problem"; -"YES, THANKS!" = "YES, THANKS!"; -"Was this helpful?" = "Was this helpful?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Your message was not sent.Tap \"Try Again\" to send this message?"; -"OK" = "OK"; -"Suggestions" = "Suggestions"; -"No FAQs found" = "No FAQs found"; -"Done" = "Done"; -"Opening Gallery..." = "Opening Gallery..."; -"Cancel" = "Cancel"; -"Could not fetch message" = "Could not fetch message"; -"Read FAQs" = "Read FAQs"; -"Try Again" = "Try Again"; -"%d new messages from Support" = "%d new messages from Support"; -"Your Name" = "Your Name"; -"Please provide a name." = "Please provide a name."; -"You rated the service with" = "You rated the service with"; -"What else can we help you with?" = "What else can we help you with?"; -"FAQ" = "FAQ"; -"Describe your problem" = "Describe your problem"; -"How can we help?" = "How can we help?"; -"Help about" = "Help about"; -"Send Feedback" = "Send Feedback"; -"We could not fetch the required data" = "We could not fetch the required data"; -"Name" = "Name"; -"Sending failed!" = "Sending failed!"; -"Attach a screenshot of your problem" = "Attach a screenshot of your problem"; -"Mark as read" = "Mark as read"; -"Loading..." = "Loading..."; -"Yes" = "Yes"; -"Send a new message" = "Send a new message"; -"Your email(optional)" = "Your email(optional)"; -"Conversation" = "Conversation"; -"Questions that may already have your answer" = "Questions that may already have your answer"; -"Attach a photo" = "Attach a photo"; -"Accept" = "Accept"; -"Your reply" = "Your reply"; -"Inbox" = "Inbox"; -"Remove attachment" = "Remove attachment"; -"Read FAQ" = "Read FAQ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents."; -"Ok, Attach" = "Ok, Attach"; -"Send" = "Send"; -"Screenshot size should not exceed %.2f MB" = "Screenshot size should not exceed %.2f MB"; -"Information" = "Information"; -"Issue ID" = "Issue ID"; -"Tap to copy" = "Tap to copy"; -"Copied!" = "Copied!"; -"We couldn't find an FAQ with matching ID" = "We couldn't find an FAQ with matching ID"; -"Failed to load screenshot" = "Failed to load screenshot"; -"Failed to load video" = "Failed to load video"; -"Failed to load image" = "Failed to load image"; -"Hold down your device's power and home buttons at the same time." = "Hold down your device's power and home buttons at the same time."; -"Please note that a few devices may have the power button on the top." = "Please note that a few devices may have the power button on the top."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Once done, come back to this conversation and tap on \"Ok, attach\" to attach it."; -"Okay" = "Okay"; -"We couldn't find an FAQ section with matching ID" = "We couldn't find an FAQ section with matching ID"; - -"GIFs are not supported" = "GIFs are not supported"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/nb.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/nb.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index bf7a2099b7f5..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/nb.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Finner du ikke det du leter etter?"; -"Rate App" = "Vurder appen"; -"We\'re happy to help you!" = "Vi hjelper deg gjerne!"; -"Did we answer all your questions?" = "Ble alle spørsmålene dine besvart?"; -"Remind Later" = "Minn meg på det senere"; -"Your message has been received." = "Meldingen din er mottatt."; -"Message send failure." = "Sending av melding mislyktes."; -"What\'s your feedback about our customer support?" = "Hva er din tilbakemelding om kundestøtten?"; -"Take a screenshot on your iPhone" = "Ta skjermbilde på din iPhone"; -"Learn how" = "Lær hvordan"; -"Take a screenshot on your iPad" = "Ta skjermbilde på din iPad"; -"Your email(optional)" = "E-postadressen din (valgfritt)"; -"Conversation" = "Samtale"; -"View Now" = "Vis nå"; -"SEND ANYWAY" = "SEND ALLIKEVEL"; -"OK" = "OK"; -"Help" = "Hjelp"; -"Send message" = "Send melding"; -"REVIEW" = "VURDER"; -"Share" = "Del"; -"Close Help" = "Lukk hjelp"; -"Sending your message..." = "Sender meldingen din …"; -"Learn how to" = "Lær om"; -"No FAQs found in this section" = "Ingen vanlige spørsmål i denne delen"; -"Thanks for contacting us." = "Takk for at du tok kontakt."; -"Chat Now" = "Chat nå"; -"Buy Now" = "Kjøp nå"; -"New Conversation" = "Ny samtale"; -"Please check your network connection and try again." = "Sjekk nettverkstilkoblingen din, og prøv igjen."; -"New message from Support" = "Ny melding fra kundestøtte"; -"Question" = "Spørsmål"; -"Type in a new message" = "Skriv en ny melding"; -"Email (optional)" = "E-post (valgfritt)"; -"Reply" = "Svar"; -"CONTACT US" = "TA KONTAKT"; -"Email" = "E-post"; -"Like" = "Liker"; -"Tap here if this FAQ was not helpful to you" = "Trykk her hvis dette vanlige svaret ikke var nyttig"; -"Any other feedback? (optional)" = "Har du andre tilbakemeldinger? (Valgfritt.)"; -"You found this helpful." = "Du syntes dette var hjelpsomt."; -"No working Internet connection is found." = "Ingen Internett-forbindelse ble funnet."; -"No messages found." = "Ingen meldinger ble funnet."; -"Please enter a brief description of the issue you are facing." = "Vennligst gi en kort beskrivelse av problemet."; -"Shop Now" = "Handle nå"; -"Close Section" = "Lukk del"; -"Close FAQ" = "Lukk vanlige spørsmål"; -"Close" = "Lukk"; -"This conversation has ended." = "Samtalen er avsluttet."; -"Send it anyway" = "Send allikevel"; -"You accepted review request." = "Du godtok forespørselen om en anmeldelse."; -"Delete" = "Slett"; -"What else can we help you with?" = "Er det noe annet vi kan hjelpe deg med?"; -"Tap here if the answer was not helpful to you" = "Trykk her hvis svaret ikke var nyttig"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Beklager! Kan du fortelle oss litt mer om problemet?"; -"Service Rating" = "Vurder kundeservice"; -"Your email" = "E-postadressen din"; -"Email invalid" = "Ugyldig e-post"; -"Could not fetch FAQs" = "Kunne ikke hente vanlige spørsmål"; -"Thanks for rating us." = "Takk for at du ga oss en vurdering."; -"Download" = "Last ned"; -"Please enter a valid email" = "Oppgi en gyldig e-postadresse"; -"Message" = "Melding"; -"or" = "eller"; -"Decline" = "Avslå"; -"No" = "Nei"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Skjermbildet kunne ikke sendes, da filen er for stor. Prøv med et annet bilde."; -"Hated it" = "Misliker"; -"Stars" = "Stjerner"; -"Your feedback has been received." = "Tilbakemeldingen din er mottatt."; -"Dislike" = "Misliker"; -"Preview" = "Forhåndsvisning"; -"Book Now" = "Bestill nå"; -"START A NEW CONVERSATION" = "START NY SAMTALE"; -"Your Rating" = "Din vurdering"; -"No Internet!" = "Ingen Internett-forbindelse."; -"Invalid Entry" = "Ugyldig tekst"; -"Loved it" = "Liker"; -"Review on the App Store" = "Anmeld på App Store"; -"Open Help" = "Åpne hjelp"; -"Search" = "Søk"; -"Tap here if you found this FAQ helpful" = "Trykk her hvis dette vanlige svaret var nyttig"; -"Star" = "Stjerne"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Trykk her hvis svaret var nyttig"; -"Report a problem" = "Rapporter et problem"; -"YES, THANKS!" = "JA, TAKK!"; -"Was this helpful?" = "Var dette hjelpsomt?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Meldingen din ble ikke sendt.Trykk \"Prøv igjen\" for å sende denne meldingen?"; -"Suggestions" = "Forslag"; -"No FAQs found" = "Ingen vanlige spørsmål"; -"Done" = "Ferdig"; -"Opening Gallery..." = "Åpner bildegalleri …"; -"You rated the service with" = "Du vurderte tjenesten"; -"Cancel" = "Avbryt"; -"Loading..." = "Laster inn …"; -"Read FAQs" = "Les vanlige spørsmål"; -"Thanks for messaging us!" = "Takk for at du skrev til oss!"; -"Try Again" = "Prøv igjen"; -"Send Feedback" = "Send tilbakemelding"; -"Your Name" = "Navnet ditt"; -"Please provide a name." = "Vennligst oppgi et navn."; -"FAQ" = "Vanlige spørsmål"; -"Describe your problem" = "Beskriv problemet"; -"How can we help?" = "Hva kan vi hjelpe deg med?"; -"Help about" = "Hjelp med"; -"We could not fetch the required data" = "Vi kunne ikke hente nødvendige data"; -"Name" = "Navn"; -"Sending failed!" = "Sending mislyktes!"; -"You didn't find this helpful." = "Du syntes ikke dette var nyttig."; -"Attach a screenshot of your problem" = "Legg ved et skjermbilde av problemet"; -"Mark as read" = "Merk som lest"; -"Name invalid" = "Ugyldig navn"; -"Yes" = "Ja"; -"What's on your mind?" = "Hva har du på hjertet?"; -"Send a new message" = "Send en ny melding"; -"Questions that may already have your answer" = "Spørsmål som allerede kan ha blitt besvart"; -"Attach a photo" = "Legg ved et bilde"; -"Accept" = "Godta"; -"Your reply" = "Svaret ditt"; -"Inbox" = "Innboks"; -"Remove attachment" = "Fjern vedlegg"; -"Could not fetch message" = "Kunne ikke hente melding"; -"Read FAQ" = "Les vanlige spørsmål"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Beklager, denne samtalen ble lukket på grunn av manglende aktivitet. Vennligst start en ny samtale med våre agenter."; -"%d new messages from Support" = "%d nye meldinger fra kundestøtte."; -"Ok, Attach" = "OK, legg ved"; -"Send" = "Send"; -"Screenshot size should not exceed %.2f MB" = "Skjermbildet kan ikke ta mer enn %.2f MB"; -"Information" = "Informasjon"; -"Issue ID" = "Emne-ID"; -"Tap to copy" = "Trykk for å kopiere"; -"Copied!" = "Kopiert!"; -"We couldn't find an FAQ with matching ID" = "Kunne ikke finne vanlige spørsmål med matchende ID"; -"Failed to load screenshot" = "Kunne ikke laste inn skjermbilde"; -"Failed to load video" = "Kunne ikke laste inn video"; -"Failed to load image" = "Kunne ikke laste inn bilde"; -"Hold down your device's power and home buttons at the same time." = "Trykk samtidig inn Dvale/våkne-knappen og Hjem-knappen på enheten."; -"Please note that a few devices may have the power button on the top." = "På enkelte enheter kan Dvale/våkne-knappen være øverst."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Kom så tilbake til denne samtalen og trykk på «OK, legg ved» for å legge ved skjermbildet."; -"Okay" = "OK"; -"We couldn't find an FAQ section with matching ID" = "Kunne ikke finne Vanlige spørsmål-del med matchende ID"; - -"GIFs are not supported" = "GIFer støttes ikke"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/nl.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/nl.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index acd3fc499618..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/nl.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Kun je niet vinden wat je zoekt?"; -"Rate App" = "Beoordeel onze app"; -"We\'re happy to help you!" = "We helpen u graag!"; -"Did we answer all your questions?" = "Hebben we al je vragen beantwoord?"; -"Remind Later" = "Herinner mij later"; -"Your message has been received." = "We hebben je bericht ontvangen."; -"Message send failure." = "Bericht niet verstuurd."; -"What\'s your feedback about our customer support?" = "Wat vindt u van de klantenondersteuning?"; -"Take a screenshot on your iPhone" = "Maak een screenshot op je iPhone"; -"Learn how" = "Ontdek hoe"; -"Take a screenshot on your iPad" = "Maak een screenshot op je iPad"; -"Your email(optional)" = "Uw e-mail (optioneel)"; -"Conversation" = "Gesprek"; -"View Now" = "Nu bekijken"; -"SEND ANYWAY" = "TOCH VERSTUREN"; -"OK" = "OK"; -"Help" = "Help"; -"Send message" = "Bericht versturen"; -"REVIEW" = "RECENSIE"; -"Share" = "Delen"; -"Close Help" = "Help sluiten"; -"Sending your message..." = "Je bericht is verstuurd..."; -"Learn how to" = "Ontdek hoe"; -"No FAQs found in this section" = "Geen FAQ's gevonden in dit gedeelte"; -"Thanks for contacting us." = "Bedankt dat u contact met ons heeft opgenomen."; -"Chat Now" = "Nu chatten"; -"Buy Now" = "Nu kopen"; -"New Conversation" = "Nieuw bericht"; -"Please check your network connection and try again." = "Controleer je internetverbinding en probeer het opnieuw."; -"New message from Support" = "Nieuw bericht van Support"; -"Question" = "Vraag"; -"Type in a new message" = "Nieuw bericht schrijven"; -"Email (optional)" = "E-mail (optioneel)"; -"Reply" = "Beantwoorden"; -"CONTACT US" = "CONTACT"; -"Email" = "E-mail"; -"Like" = "Leuk"; -"Tap here if this FAQ was not helpful to you" = "Tik hier als deze FAQ u niet heeft geholpen"; -"Any other feedback? (optional)" = "Heb je nog andere feedback? (optioneel)"; -"You found this helpful." = "U vond dit nuttig."; -"No working Internet connection is found." = "We hebben geen werkende internetverbinding kunnen vinden."; -"No messages found." = "Geen berichten gevonden."; -"Please enter a brief description of the issue you are facing." = "Geef een korte beschrijving van het probleem."; -"Shop Now" = "Nu winkelen"; -"Close Section" = "Onderdeel sluiten"; -"Close FAQ" = "FAQ sluiten"; -"Close" = "Sluiten"; -"This conversation has ended." = "Dit gesprek is afgelopen."; -"Send it anyway" = "Toch versturen"; -"You accepted review request." = "U heeft het recensieverzoek geaccepteerd."; -"Delete" = "Verwijderen"; -"What else can we help you with?" = "Kunnen we je nog ergens anders mee helpen?"; -"Tap here if the answer was not helpful to you" = "Tik hier als het antwoord u niet heeft geholpen"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Jammer dat te horen. Kun je ons iets meer over het probleem vertellen?"; -"Service Rating" = "Servicewaardering"; -"Your email" = "Uw e-mail"; -"Email invalid" = "Ongeldig e-mailadres"; -"Could not fetch FAQs" = "De FAQ's konden niet op worden gehaald"; -"Thanks for rating us." = "Bedankt voor je beoordeling."; -"Download" = "Downloaden"; -"Please enter a valid email" = "Geef een geldig e-mailadres op"; -"Message" = "Bericht"; -"or" = "of"; -"Decline" = "Weigeren"; -"No" = "Nee"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Het screenshot kon niet worden verstuurd. De afbeelding is te groot, probeer het met een andere afbeelding."; -"Hated it" = "Waardeloos"; -"Stars" = "Sterren"; -"Your feedback has been received." = "We hebben je feedback ontvangen."; -"Dislike" = "Niet leuk"; -"Preview" = "Preview"; -"Book Now" = "Nu boeken"; -"START A NEW CONVERSATION" = "START EEN NIEUW BERICHT"; -"Your Rating" = "Jouw beoordeling"; -"No Internet!" = "Geen internet!"; -"Invalid Entry" = "Ongeldige invoer"; -"Loved it" = "Geweldig"; -"Review on the App Store" = "Beoordelen in de App Store"; -"Open Help" = "Help openen"; -"Search" = "Zoeken"; -"Tap here if you found this FAQ helpful" = "Tik hier als deze FAQ u heeft geholpen"; -"Star" = "Ster"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Tik hier als het antwoord u heeft geholpen"; -"Report a problem" = "Een probleem melden"; -"YES, THANKS!" = "JA, BEDANKT!"; -"Was this helpful?" = "Had je hier wat aan?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Je bericht is niet verstuurd. Tik op \"Opnieuw proberen\" om het bericht te versturen."; -"Suggestions" = "Suggesties"; -"No FAQs found" = "Geen FAQ's gevonden"; -"Done" = "Klaar"; -"Opening Gallery..." = "Foto's openen..."; -"You rated the service with" = "U hebt de dienst beoordeeld met"; -"Cancel" = "Annuleren"; -"Loading..." = "Laden..."; -"Read FAQs" = "FAQ's lezen"; -"Thanks for messaging us!" = "Bedankt dat u ons een bericht heeft gestuurd!"; -"Try Again" = "Opnieuw proberen"; -"Send Feedback" = "Feedback vesturen"; -"Your Name" = "Uw naam"; -"Please provide a name." = "Geef een geldige naam op."; -"FAQ" = "FAQ"; -"Describe your problem" = "Omschrijf het probleem"; -"How can we help?" = "Hoe kunnen we je helpen?"; -"Help about" = "Hulp voor"; -"We could not fetch the required data" = "We hebben de vereiste gegevens niet op kunnen halen"; -"Name" = "Naam"; -"Sending failed!" = "Versturen mislukt!"; -"You didn't find this helpful." = "U vond dit niet nuttig."; -"Attach a screenshot of your problem" = "Een screenshot van het probleem toevoegen"; -"Mark as read" = "Markeren als gelezen"; -"Name invalid" = "Ongeldige naam"; -"Yes" = "Ja"; -"What's on your mind?" = "Waar zit je mee?"; -"Send a new message" = "Nieuw bericht versturen"; -"Questions that may already have your answer" = "Vragen die jouw antwoord misschien al bevatten"; -"Attach a photo" = "Foto toevoegen"; -"Accept" = "Accepteren"; -"Your reply" = "Uw reactie"; -"Inbox" = "Postvak IN"; -"Remove attachment" = "Bijlage verwijderen"; -"Could not fetch message" = "Bericht kon niet worden opgehaald"; -"Read FAQ" = "FAQ lezen"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Dit gesprek is vanwege inactiviteit afgesloten. Start een nieuw gesprek met een van onze agenten."; -"%d new messages from Support" = "%d nieuwe berichten van Ondersteuning"; -"Ok, Attach" = "Ok, bijvoegen"; -"Send" = "Versturen"; -"Screenshot size should not exceed %.2f MB" = "Screenshot mag niet groter zijn dan %.2f MB"; -"Information" = "Informatie"; -"Issue ID" = "Probleem-ID"; -"Tap to copy" = "Tik om te kopiëren"; -"Copied!" = "Gekopieerd!"; -"We couldn't find an FAQ with matching ID" = "We konden geen FAQ vinden met overeenkomstige ID"; -"Failed to load screenshot" = "Laden schermafbeelding mislukt"; -"Failed to load video" = "Laden video mislukt"; -"Failed to load image" = "Laden afbeelding mislukt"; -"Hold down your device's power and home buttons at the same time." = "Houd de sluimerknop en de thuisknop van je apparaat tegelijkertijd ingedrukt."; -"Please note that a few devices may have the power button on the top." = "Let op: de sluimerknop kan bij sommige apparaten bovenaan zitten."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Ga na het maken van je schermafbeelding terug naar dit gesprek en tik op 'Oké, toevoegen' om deze toe te voegen."; -"Okay" = "Oké"; -"We couldn't find an FAQ section with matching ID" = "We konden geen FAQ-gedeelte vinden met overeenkomstige ID"; - -"GIFs are not supported" = "GIF's worden niet ondersteund"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/pa.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/pa.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 9ba050188892..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/pa.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "ਇਹ ਪਤਾ ਨਹੀਂ ਲਗਾ ਸਕੇ ਕਿ ਤੁਸੀਂ ਕਿਸ ਚੀਜ਼ ਦੀ ਖੋਜ ਕਰ ਰਹੇ ਹੋ?"; -"Rate App" = "ਐਪ ਨੂੰ ਰੇਟ ਕਰੋ"; -"We\'re happy to help you!" = "ਅਸੀਂ ਤੁਹਾਡੀ ਮਦਦ ਕਰਕੇ ਖੁਸ਼ ਹਾਂ!"; -"Did we answer all your questions?" = "ਕੀ ਅਸੀਂ ਤੁਹਾਡਾ ਸਾਰੇ ਸਵਾਲਾਂ ਦਾ ਜਵਾਬ ਦਿੱਤਾ?"; -"Remind Later" = "ਬਾਅਦ ਵਿੱਚ ਯਾਦ ਕਰਾਓ"; -"Your message has been received." = "ਤੁਹਾਡਾ ਸੁਨੇਹਾ ਪ੍ਰਾਪਤ ਕਰ ਲਿਆ ਗਿਆ ਹੈ।"; -"Message send failure." = "ਸੁਨੇਹਾ ਭੇਜਣ ਦੀ ਅਸਫਲਤਾ।"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "ਤੁਹਾਡਾ ਸੁਨੇਹਾ ਭੇਜਿਆ ਨਹੀਂ ਗਿਆ ਸੀ। ਇਸ ਸੁਨੇਹੇ ਨੂੰ ਭੇਜਣ ਲਈ \"ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ\"ਤੇ ਟੈਪ ਕਰਨਾ ਹੈ?"; -"What\'s your feedback about our customer support?" = "ਸਾਡੇ ਗ੍ਰਾਹਕ ਸਮਰਥਨ ਬਾਰੇ ਤੁਹਾਡੀ ਫੀਡਬੈਕ ਕੀ ਹੈ?"; -"Take a screenshot on your iPhone" = "ਆਪਣੇ iPhone ਉੱਤੇ ਇੱਕ ਸਕਰੀਨਸ਼ੌਟ ਲਓ"; -"Learn how" = "ਸਿੱਖੇ ਕਿ ਕਿਵੇਂ"; -"Take a screenshot on your iPad" = "ਆਪਣੇ iPad ਉੱਤੇ ਇੱਕ ਸਕਰੀਨਸ਼ੌਟ ਲਓ"; -"Your email(optional)" = "ਤੁਹਾਡੀ ਈਮੇਲ (ਚੋਣਵੀਂ)"; -"Conversation" = "ਵਾਰਤਾਲਾਪ"; -"View Now" = "ਹੁਣੇ ਦੇਖੋ"; -"SEND ANYWAY" = "ਫੇਰ ਵੀ ਭੇਜੋ"; -"OK" = "ਠੀਕ ਹੈ"; -"Help" = "ਮਦਦ"; -"Send message" = "ਸੰਦੇਸ਼ ਭੇਜੋ"; -"REVIEW" = "ਸਮੀਖਿਆ"; -"Share" = "ਸਾਂਝਾ ਕਰੋ"; -"Close Help" = "ਮਦਦ ਨੂੰ ਬੰਦ ਕਰੋ"; -"Sending your message..." = "ਤੁਹਾਡਾ ਸੁਨੇਹਾ ਭੇਜਿਆ ਜਾ ਰਿਹਾ..."; -"Learn how to" = "ਸਿੱਖੇ ਕਿ ਕਿਵੇਂ"; -"No FAQs found in this section" = "ਇਸ ਸੈਕਸ਼ਨ ਵਿੱਚ ਕੋਈ FAQ ਨਹੀਂ ਮਿਲੇ"; -"Thanks for contacting us." = "ਸਾਡੇ ਨਾਲ ਸੰਪਰਕ ਕਰਨ ਲਈ ਧੰਨਵਾਦ!"; -"Chat Now" = "ਹੁਣੇ ਚੈਟ ਕਰੋ"; -"Buy Now" = "ਹੁਣੇ ਖਰੀਦੋ"; -"New Conversation" = "ਨਵਾਂ ਵਾਰਤਾਲਾਪ"; -"Please check your network connection and try again." = "ਕਿਰਪਾ ਕਰਕੇ ਆਪਣੇ ਇੰਟਰਨੈਟ ਕਨੈਕਸ਼ਨ ਦੀ ਜਾਂਚ ਕਰੋ ਅਤੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।"; -"New message from Support" = "ਸਹਾਇਤਾ ਵਲੋਂ ਨਵਾਂ ਸੁਨੇਹਾ"; -"Question" = "ਪ੍ਰਸ਼ਨ"; -"Type in a new message" = "ਇੱਕ ਨਵਾਂ ਸੁਨੇਹਾ ਟਾਈਪ ਕਰੋ"; -"Email (optional)" = "ਈਮੇਲ (ਚੋਣਵਾਂ)"; -"Reply" = "ਜਵਾਬ ਦਿਓ"; -"CONTACT US" = "ਸਾਡੇ ਨਾਲ ਸੰਪਰਕ ਕਰੋ"; -"Email" = "ਈਮੇਲ"; -"Like" = "ਪਸੰਦ ਕਰੋ"; -"Tap here if this FAQ was not helpful to you" = "ਇੱਥੇ ਟੈਪ ਕਰੋ ਜੇ ਇਹ FAQ ਤੁਹਾਡੇ ਲਈ ਸਹਾਇਕ ਨਹੀਂ ਸੀ"; -"Any other feedback? (optional)" = "ਕੋਈ ਹੋਰ ਫੀਡਬੈਕ ਹੈ?(ਵਿਕਲਪਕ)"; -"You found this helpful." = "ਤੁਹਾਨੂੰ ਇਹ ਸਹਾਇਕ ਲੱਗਿਆ।"; -"No working Internet connection is found." = "ਕੋਈ ਕਾਰਜਸ਼ੀਲ ਇੰਟਰਨੈਟ ਕਨੈਕਸ਼ਨ ਨਹੀਂ ਮਿਲਿਆ ਹੈ।"; -"No messages found." = "ਕੋਈ ਸੁਨੇਹੇ ਨਹੀਂ ਮਿਲੇ।"; -"Please enter a brief description of the issue you are facing." = "ਕਿਰਪਾ ਕਰਕੇ ਆਪਣੇ ਵਲੋਂ ਸਾਮ੍ਹਣਾ ਕੀਤੇ ਜਾ ਰਹੇ ਮੁੱਦੇ ਦਾ ਸੰਖਿਪਤ ਵਰਣਨ ਦਾਖ਼ਲ ਕਰੋ।"; -"Shop Now" = "ਹੁਣੇ ਖਰੀਦਾਰੀ ਕਰੋ"; -"Close Section" = "ਵਿਭਾਗ ਨੂੰ ਬੰਦ ਕਰੋ"; -"Close FAQ" = "FAQ ਨੂੰ ਬੰਦ ਕਰੋ"; -"This conversation has ended." = "ਇਹ ਵਾਰਤਾਲਾਪ ਖਤਮ ਹੋ ਗਿਆ ਹੈ"; -"Send it anyway" = "ਫੇਰ ਵੀ ਇਸਨੂੰ ਭੇਜੋ"; -"You accepted review request." = "ਤੁਸੀਂ ਸਮੀਖਿਆ ਬੇਨਤੀ ਸਵੀਕਾਰ ਕੀਤੀ।"; -"Delete" = "ਮਿਟਾਓ"; -"What else can we help you with?" = "ਅਸੀਂ ਤੁਹਾਡੀ ਹੋਰ ਕਿਵੇਂ ਮਦਦ ਕਰ ਸਕਦੇ ਹਾਂ?"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "ਐਹ ਸੁਣਨ ਲਈ ਅਫਸੋਸ ਹੈ. ਕੀ ਤੁਸੀ ਸਾਹਣੁ ਕਿਰਪਾ ਕਰਕੇ ਐਸ ਪਰੇਸ਼ਾਨੀ ਦੇ ਬਾਰੇ ਵਿਚ ਥੋੜਾ ਹੋਰ ਦੱਸ ਸਕਦੇ ਹੋ?"; -"Tap here if the answer was not helpful to you" = "ਇੱਥੇ ਟੈਪ ਕਰੋ ਜੇ ਜਵਾਬ ਤੁਹਾਡੇ ਲਈ ਸਹਾਇਕ ਨਹੀਂ ਸੀ"; -"Service Rating" = "ਸੇਵਾ ਰੇਟਿੰਗ"; -"Your email" = "ਤੁਹਾਡੀ ਈਮੇਲ"; -"Email invalid" = "ਅਯੋਗ ਈਮੇਲ"; -"Could not fetch FAQs" = "FAQ ਨਹੀਂ ਲਿਆ ਸਕੇ"; -"Thanks for rating us." = "ਸਾਨੂੰ ਰੇਟ ਕਰਨ ਲਈ ਧੰਨਵਾਦ।"; -"Download" = "ਡਾਉਨਲੋਡ ਕਰੋ"; -"Please enter a valid email" = "ਕਿਰਪਾ ਕਰਕੇ ਇੱਕ ਵੈਧ ਈਮੇਲ ਦਾਖ਼ਲ ਕਰੋ"; -"Message" = "ਸੁਨੇਹਾ"; -"or" = "ਜਾਂ"; -"Decline" = "ਅਸਵੀਕਾਰ ਕਰੋ"; -"No" = "ਨਹੀਂ"; -"Screenshot could not be sent. Image is too large, try again with another image" = "ਸਕਰੀਨਸ਼ੌਟ ਭੇਜਿਆ ਨਹੀਂ ਜਾ ਸਕਿਆ। ਪ੍ਰਤੀਬਿੰਬ ਬੇਹੱਦ ਵੱਡਾ ਹੈ, ਕਿਸੇ ਹੋਰ ਪ੍ਰਤੀਬਿੰਬ ਨੂੰ ਅਜ਼ਮਾਓ"; -"Hated it" = "ਇਸ ਨੂੰ ਨਾਪਸੰਦ ਕੀਤਾ"; -"Stars" = "ਸਿਤਾਰੇ"; -"Your feedback has been received." = "ਤੁਹਾਡੀ ਫੀਡਬੈਕ ਪ੍ਰਾਪਤ ਕਰ ਲਈ ਗਈ ਹੈ।"; -"Dislike" = "ਨਾਪਸੰਦ ਕਰੋ"; -"Preview" = "ਪੂਰਵਦਰਸ਼ਨ"; -"Book Now" = "ਹੁਣੇ ਬੁੱਕ ਕਰੋ"; -"START A NEW CONVERSATION" = "ਨਵਾਂ ਵਾਰਤਾਲਾਪ ਸ਼ੁਰੂ ਕਰੋ"; -"Your Rating" = "ਤੁਹਾਡੀ ਰੇਟਿੰਗ"; -"No Internet!" = "ਬਿਲਕੁੱਲ ਵੀ ਇੰਟਰਨੈਟ ਨਹੀਂ!"; -"Invalid Entry" = "ਅਯੋਗ ਏਂਟ੍ਰੀ"; -"Loved it" = "ਇਸ ਨੂੰ ਪਸੰਦ ਕੀਤਾ"; -"Review on the App Store" = "ਐਪ ਸਟੋਰ ਉੱਤੇ ਸਮੀਖਿਆ ਕਰੋ"; -"Open Help" = "ਮਦਦ ਖੋਲ੍ਹੋ"; -"Search" = "ਖੋਜ ਕਰੋ"; -"Tap here if you found this FAQ helpful" = "ਇੱਥੇ ਟੈਪ ਕਰੋ ਜੇ ਤੁਹਾਨੂੰ ਇਹ FAQ ਸਹਾਇਕ ਲੱਗਿਆ"; -"Star" = "ਸਿਤਾਰਾ"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "ਇੱਥੇ ਟੈਪ ਕਰੋ ਜੇ ਤੁਹਾਨੂੰ ਇਹ ਜਵਾਬ ਸਹਾਇਕ ਲੱਗਿਆ"; -"Report a problem" = "ਕਿਸੇ ਸਮੱਸਿਆ ਦੀ ਰੀਪੋਰਟ ਕਰੋ"; -"YES, THANKS!" = "ਹਾਂ, ਧੰਨਵਾਦ!"; -"Was this helpful?" = "ਕੀ ਇਹ ਸਹਾਇਕ ਸੀ?"; -"Suggestions" = "ਸੁਝਾਅ"; -"No FAQs found" = "ਕੋਈ FAQ ਨਹੀਂ ਮਿਲੇ"; -"Done" = "ਹੋ ਗਿਆ"; -"Opening Gallery..." = "ਗੈਲਰੀ ਖੋਲ੍ਹੀ ਜਾ ਰਹੀ..."; -"You rated the service with" = "ਤੁਸੀਂ ਇਸ ਦੇ ਨਾਲ ਸੇਵਾ ਨੂੰ ਰੇਟ ਕੀਤਾ"; -"Cancel" = "ਰੱਦ ਕਰੋ"; -"Close" = "ਬੰਦ ਕਰੋ"; -"Loading..." = "ਲੋਡ ਕੀਤਾ ਜਾ ਰਿਹਾ..."; -"Read FAQs" = "FAQs ਪੜ੍ਹੋ"; -"Thanks for messaging us!" = "ਸਾਨੂੰ ਸੁਨੇਹਾ ਭੇਜਣ ਲਈ ਧੰਨਵਾਦ!"; -"Try Again" = "ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ"; -"Send Feedback" = "ਫੀਡਬੈਕ ਭੇਜੋ"; -"Your Name" = "ਤੁਹਾਡਾ ਨਾਮ"; -"Please provide a name." = "ਕਿਰਪਾ ਕਰਕੇ ਇੱਕ ਨਾਮ ਪ੍ਰਦਾਨ ਕਰੋ।"; -"FAQ" = "FAQ"; -"Describe your problem" = "ਆਪਣੀ ਸਮੱਸਿਆ ਦਾ ਵਰਣਨ ਕਰੋ"; -"How can we help?" = "ਅਸੀਂ ਕਿਵੇਂ ਮਦਦ ਕਰ ਸਕਦੇ ਹਾਂ?"; -"Help about" = "ਇਸ ਬਾਰੇ ਮਦਦ"; -"Name" = "ਨਾਮ"; -"Sending failed!" = "ਭੇਜਣਾ ਅਸਫਲ ਹੋਇਆ!"; -"We could not fetch the required data" = "ਅੱਸੀ ਲੋੜ ਦੇ ਡਾਟਾ ਨੂੰ ਪ੍ਰਾਪਤ ਨਾ ਕਰ ਸਕਦੇ ਹੈ"; -"You didn't find this helpful." = "ਤੁਹਾਨੂੰ ਇਹ ਸਹਾਇਕ ਨਹੀਂ ਲੱਗਿਆ।"; -"Attach a screenshot of your problem" = "ਆਪਣੀ ਸਮੱਸਿਆ ਦਾ ਇੱਕ ਸਕ੍ਰੀਨਸ਼ੌਟ ਨੱਥੀ ਕਰੋ"; -"Mark as read" = "ਪੜ੍ਹਿਆ ਗਿਆ ਵਜੋਂ ਨਿਸ਼ਾਨ ਲਗਾਓ"; -"Name invalid" = "ਅਯੋਗ ਨਾਮ"; -"Yes" = "ਹਾਂ"; -"What's on your mind?" = "ਤੁਹਾਡੇ ਦਿਮਾਗ਼ ਵਿੱਚ ਕੀ ਹੈ?"; -"Send a new message" = "ਇੱਕ ਨਵਾਂ ਸੰਦੇਸ਼ ਭੇਜੋ"; -"Questions that may already have your answer" = "ਸਵਾਲ ਜਿਹਨਾਂ ਕੋਲ ਪਹਿਲਾਂ ਤੋਂ ਤੁਹਾਡਾ ਜਵਾਬ ਹੋ ਸਕਦਾ ਹੈ"; -"Attach a photo" = "ਇੱਕ ਫੋਟੋ ਨੱਥੀ ਕਰੋ"; -"Accept" = "ਸਵੀਕਾਰ ਕਰੋ"; -"Your reply" = "ਤੁਹਾਡਾ ਜਵਾਬ"; -"Inbox" = "ਇਨਬਾਕਸ"; -"Remove attachment" = "ਅਟੈਚਮੈਂਟ ਨੂੰ ਹਟਾਓ"; -"Could not fetch message" = "ਸੁਨੇਹਾ ਨਹੀਂ ਲਿਆ ਸਕੇ"; -"Read FAQ" = "FAQ ਪੜ੍ਹੋ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "ਮੁਆਫ਼ ਕਰੋ! ਇਹ ਵਾਰਤਾਲਾਪ ਅਕ੍ਰਿਆਸ਼ੀਲਤਾ ਕਾਰਨ ਬੰਦ ਕਰ ਦਿੱਤਾ ਗਿਆ ਸੀ। ਕਿਰਪਾ ਕਰਕੇ ਸਾਡੇ ਏਜੰਟਾਂ ਦੇ ਨਾਲ ਇੱਕ ਨਵਾਂ ਵਾਰਤਾਲਾਪ ਸ਼ੁਰੂ ਕਰੋ।"; -"%d new messages from Support" = "%d ਸਹਾਇਤਾ ਵਲੋਂ ਨਵਾਂ ਸੁਨੇਹਾ"; -"Ok, Attach" = "ਠੀਕ ਹੈ, ਨੱਥੀ ਕਰੋ"; -"Send" = "ਭੇਜੋ"; -"Screenshot size should not exceed %.2f MB" = "ਸਕ੍ਰੀਨਸ਼ੌਟ ਦਾ ਆਕਾਰ %.2f MB ਤੋਂ ਵੱਧ ਨਹੀਂ ਹੋਵੇਗਾ"; -"Information" = "ਜਾਣਕਾਰੀ"; -"Issue ID" = "ID ਜਾਰੀ ਕੀਤੀ"; -"Tap to copy" = "ਕਾਪੀ ਕਰਨ ਲਈ ਟੈਪ ਕਰੋ"; -"Copied!" = "ਕਾਪੀ ਕੀਤਾ!"; -"We couldn't find an FAQ with matching ID" = "ਸਾਨੂੰ ਏਸ ID ਦੇ ਨਾਲ ਮਿਲਦਾ FAQ ਨਾ ਲਭਿਆ"; -"Failed to load screenshot" = "ਸਕਰੀਨਸ਼ੋਤ ਨੂੰ ਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ"; -"Failed to load video" = "ਵੀਡੀਓ ਨੂੰ ਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ"; -"Failed to load image" = "ਇਮੇਜ ਨੂੰ ਲੋਡ ਕਰਨ ਵਿੱਚ ਅਸਫਲ"; -"Hold down your device's power and home buttons at the same time." = "ਆਪਣੇ ਡਿਵਾਈਸ ਉੱਤੇ ਇੱਕੋ ਸਮੇਂ ਤੇ ਪਾਵਰ ਅਤੇ ਹੋਮ ਬਟਨਾਂ ਨੂੰ ਦਬਾ ਕੇ ਰੱਖੋ।"; -"Please note that a few devices may have the power button on the top." = "ਕਿਰਪਾ ਕਰਕੇ ਨੋਟ ਕਰੋ ਕੁਝ ਜੰਤਰ ਦੇ ਪਾਵਰ ਬਟਨ ਚੋਟੀ ਤੇ ਹੋ ਸਕਦਾ ਹੈ।"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "ਇਸ ਨੂੰ ਕਰਨ ਦੇ ਬਾਅਦ, ਵਾਪਸ ਇਸ ਗੱਲਬਾਤ ਨੂੰ ਖੋਲੋ ਤੇ \"ਠੀਕ ਹੈ, ਨਾਲ ਨੱਥੀ\" ਦੇ ਉਤੇ ਟੈਪ ਕਰਕੇ ਸਕਰੀਨ ਸ਼ੋਟ ਨੂੰ ਨੱਥੀ ਕਰੋ।"; -"Okay" = "ਠੀਕ ਹੈ"; -"We couldn't find an FAQ section with matching ID" = "ਸਾਨੂੰ ਏਸ ID ਦੇ ਨਾਲ ਮਿਲਦਾ FAQ ਅਨੁਭਾਗ ਨਾ ਲਭਿਆ"; - -"GIFs are not supported" = "GIFs ਸਮਰਥਿਤ ਨਹੀਂ ਹਨ"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/pl.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/pl.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index c579f9c73d50..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/pl.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Nie możesz znaleźć interesującego cię tematu?"; -"Rate App" = "Oceń naszą aplikację"; -"We\'re happy to help you!" = "Z przyjemnością pomożemy!"; -"Did we answer all your questions?" = "Czy odpowiedzieliśmy na wszystkie twoje pytania?"; -"Remind Later" = "Przypomnij mi później"; -"Your message has been received." = "Twoja wiadomość została odebrana."; -"Message send failure." = "Nie udało się wysłać wiadomości."; -"What\'s your feedback about our customer support?" = "Co sądzisz o naszym dziale pomocy?"; -"Take a screenshot on your iPhone" = "Zrób zdjęcie ekranu na twoim telefonie iPhone"; -"Learn how" = "Dowiedz się więcej"; -"Take a screenshot on your iPad" = "Zrób zdjęcie ekranu na twoim tablecie iPad"; -"Your email(optional)" = "Twój e-mail (opcjonalne)"; -"Conversation" = "Rozmowa"; -"View Now" = "Obejrzyj teraz"; -"SEND ANYWAY" = "WYŚLIJ MIMO WSZYSTKO"; -"OK" = "OK"; -"Help" = "Pomoc"; -"Send message" = "Wyślij wiadomość"; -"REVIEW" = "RECENZJA"; -"Share" = "Udostępnij"; -"Close Help" = "Zamknij Pomoc"; -"Sending your message..." = "Trwa wysyłanie wiadomości..."; -"Learn how to" = "Dowiedz się jak..."; -"No FAQs found in this section" = "Brak popularnych pytań dla niniejszej sekcji"; -"Thanks for contacting us." = "Dziękujemy za kontakt z nami."; -"Chat Now" = "Przejdź do czatu teraz"; -"Buy Now" = "Kup teraz"; -"New Conversation" = "Nowa rozmowa"; -"Please check your network connection and try again." = "Prosimy sprawdzić połączenie z siecią i spróbować ponownie."; -"New message from Support" = "Nowa wiadomość od Działu Pomocy"; -"Question" = "Pytanie"; -"Type in a new message" = "Wprowadź tekst nowej wiadomości"; -"Email (optional)" = "E-mail (opcjonalne)"; -"Reply" = "Odpowiedź"; -"CONTACT US" = "SKONTAKTUJ SIĘ Z NAMI"; -"Email" = "E-mail"; -"Like" = "Lubię to"; -"Tap here if this FAQ was not helpful to you" = "Stuknij tutaj, jeśli Najczęściej Zadawane Pytania nie były pomocne"; -"Any other feedback? (optional)" = "Czy chcesz coś dodać? (opcjonalne)"; -"You found this helpful." = "Pomoc była skuteczna."; -"No working Internet connection is found." = "Nie wykryto połączenia z internetem."; -"No messages found." = "Nie odnaleziono wiadomości."; -"Please enter a brief description of the issue you are facing." = "Prosimy o krótki opis napotkanego problemu."; -"Shop Now" = "Kupuj teraz"; -"Close Section" = "Zamknij sekcję"; -"Close FAQ" = "Zamknij Najczęściej Zadawane Pytania"; -"Close" = "Zamknij"; -"This conversation has ended." = "Rozmowa zakończona."; -"Send it anyway" = "Wyślij mimo wszystko"; -"You accepted review request." = "Użytkownik przyjął prośbę o recenzję"; -"Delete" = "Usuń"; -"What else can we help you with?" = "W czym jeszcze możemy pomóc?"; -"Tap here if the answer was not helpful to you" = "Stuknij tutaj, jeśli odpowiedź była nieskuteczna"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Przykro nam to słyszeć. Czy możesz opowiedzieć nam o problemie, który napotykasz?"; -"Service Rating" = "Ocena usługi"; -"Your email" = "Twój e-mail"; -"Email invalid" = "Niewłaściwy adres"; -"Could not fetch FAQs" = "Nie udało się pobrać listy najczęściej zadawanych pytań"; -"Thanks for rating us." = "Dziękujemy za wystawienie nam oceny."; -"Download" = "Pobierz"; -"Please enter a valid email" = "Prosimy podać stosowny adres e-mail"; -"Message" = "Wiadomość"; -"or" = "lub"; -"Decline" = "Odmowa"; -"No" = "Nie"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Nie udało się wysłać zdjęcia ekranu. Obraz jest zbyt duży. Spróbuj ponownie, używając innego obrazu."; -"Hated it" = "Nie podobała mi się"; -"Stars" = "Gwiazdki"; -"Your feedback has been received." = "Twoja opinia została odebrana."; -"Dislike" = "Nie lubię tego"; -"Preview" = "Zapowiedź"; -"Book Now" = "Zamów teraz"; -"START A NEW CONVERSATION" = "ROZPOCZNIJ NOWĄ ROZMOWĘ"; -"Your Rating" = "Twoja ocena"; -"No Internet!" = "Brak połączenia!"; -"Invalid Entry" = "Niewłaściwy wpis"; -"Loved it" = "Podobała mi się"; -"Review on the App Store" = "Zrecenzuj w sklepie App Store"; -"Open Help" = "Przejdź do pomocy"; -"Search" = "Szukaj"; -"Tap here if you found this FAQ helpful" = "Stuknij tutaj, jeśli Najczęściej Zadawane Pytania były pomocne"; -"Star" = "Gwiazdka"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Stuknij tutaj, jeśli odpowiedź była skuteczna"; -"Report a problem" = "Zgłoś problem"; -"YES, THANKS!" = "TAK, DZIĘKI!"; -"Was this helpful?" = "Czy pomoc była skuteczna?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Wiadomość nie została wysłana.Stuknij \"Spróbuj ponownie\" aby wysłać tę wiadomość."; -"Suggestions" = "Sugestie"; -"No FAQs found" = "Nie odnaleziono najczęściej zadawanych pytań"; -"Done" = "Gotowe"; -"Opening Gallery..." = "Otwieranie galerii..."; -"You rated the service with" = "Usługa została oceniona:"; -"Cancel" = "Anuluj"; -"Loading..." = "Wczytywanie..."; -"Read FAQs" = "Zapoznaj się z Najczęściej Zadawanymi Pytaniami"; -"Thanks for messaging us!" = "Dziękujemy za wysłanie nam wiadomości!"; -"Try Again" = "Spróbuj ponownie"; -"Send Feedback" = "Prześlij opinię"; -"Your Name" = "Twoje imię"; -"Please provide a name." = "Prosimy o podanie imienia."; -"FAQ" = "Najczęściej zadawane pytania"; -"Describe your problem" = "Prosimy opisać problem"; -"How can we help?" = "W czym możemy pomóc?"; -"Help about" = "Pomoc dotycząca:"; -"We could not fetch the required data" = "Nie udało się pobrać wymaganych danych"; -"Name" = "Imię"; -"Sending failed!" = "Wysyłka nieudana!"; -"You didn't find this helpful." = "Pomoc była nieskuteczna."; -"Attach a screenshot of your problem" = "Załącz zrzut ekranu przedstawiający problem"; -"Mark as read" = "Zaznacz jako przeczytane"; -"Name invalid" = "Niewłaściwe imię"; -"Yes" = "Tak"; -"What's on your mind?" = "O czym myślisz?"; -"Send a new message" = "Wyślij nową wiadomość"; -"Questions that may already have your answer" = "Pytania, które być może dotyczą tego, czego szukasz"; -"Attach a photo" = "Załącz zdjęcie"; -"Accept" = "Zatwierdź"; -"Your reply" = "Twoja odpowiedź"; -"Inbox" = "Skrzynka odbiorcza"; -"Remove attachment" = "Usuń załącznik"; -"Could not fetch message" = "Nie udało się pobrać wiadomości"; -"Read FAQ" = "Przejrzyj Najczęściej Zadawane Pytania"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Przepraszamy! Niniejsza rozmowa została zakończona z powodu braku aktywności. Prosimy o rozpoczęcie nowej rozmowy z jednym z naszych agentów."; -"%d new messages from Support" = "%d nowe wiadomości od działu pomocy"; -"Ok, Attach" = "Ok, załącz"; -"Send" = "Wyślij"; -"Screenshot size should not exceed %.2f MB" = "Rozmiar zrzutu ekranu nie powinien przekraczać %.2f MB"; -"Information" = "Informacje"; -"Issue ID" = "Identyfikator problemu"; -"Tap to copy" = "Stuknij, aby skopiować"; -"Copied!" = "Skopiowano!"; -"We couldn't find an FAQ with matching ID" = "Nie udało się odnaleźć pasujących Pytań"; -"Failed to load screenshot" = "Nie udało się wczytać zdjęcia ekranu"; -"Failed to load video" = "Nie udało się wczytać klipu"; -"Failed to load image" = "Nie udało się wczytać obrazka"; -"Hold down your device's power and home buttons at the same time." = "Naciśnij i przytrzymaj jednocześnie przycisk zasilania oraz przycisk Home na twoim urządzeniu."; -"Please note that a few devices may have the power button on the top." = "W przypadku niektórych urządzeń przycisk zasilania znajduje się na górze."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Gdy skończysz, wróć do tej rozmowy i dotknij przycisk „Ok, załącz”, aby dodać załącznik."; -"Okay" = "OK"; -"We couldn't find an FAQ section with matching ID" = "Nie udało się odnaleźć pasującej sekcji Pytań"; - -"GIFs are not supported" = "GIF nie są obsługiwane"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/pt.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/pt.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index ad28c6005009..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/pt.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Não encontras o que procuras?"; -"Rate App" = "Avaliar a App"; -"We\'re happy to help you!" = "Temos todo o prazer em ajudar-te!"; -"Did we answer all your questions?" = "Respondemos a todas as tuas questões?"; -"Remind Later" = "Lembrar mais tarde"; -"Your message has been received." = "A tua mensagem foi recebida."; -"Message send failure." = "Falha no envio da mensagem."; -"What\'s your feedback about our customer support?" = "Qual é o teu feedback relativamente ao apoio ao cliente?"; -"Take a screenshot on your iPhone" = "Captura o ecrã do teu iPhone"; -"Learn how" = "Aprender agora"; -"Take a screenshot on your iPad" = "Captura o ecrã do teu iPad"; -"Your email(optional)" = "O teu email (opcional)"; -"Conversation" = "Conversa"; -"View Now" = "Ver agora"; -"SEND ANYWAY" = "ENVIAR NA MESMA"; -"OK" = "OK"; -"Help" = "Ajuda"; -"Send message" = "Enviar mensagem"; -"REVIEW" = "REVER"; -"Share" = "Partilhar"; -"Close Help" = "Fechar Ajuda"; -"Sending your message..." = "A enviar a tua mensagem..."; -"Learn how to" = "Aprende a"; -"No FAQs found in this section" = "Sem FAQs nesta secção"; -"Thanks for contacting us." = "Obrigado por nos contactares."; -"Chat Now" = "Conversar agora"; -"Buy Now" = "Comprar agora"; -"New Conversation" = "Nova conversa"; -"Please check your network connection and try again." = "Verifica a ligação à rede e tenta novamente."; -"New message from Support" = "Nova mensagem de Suporte"; -"Question" = "Pergunta"; -"Type in a new message" = "Escreve uma nova mensagem"; -"Email (optional)" = "Email (opcional)"; -"Reply" = "Responder"; -"CONTACT US" = "CONTACTA-NOS"; -"Email" = "Email"; -"Like" = "Gostar"; -"Tap here if this FAQ was not helpful to you" = "Toca aqui se esta FAQ não foi útil para ti"; -"Any other feedback? (optional)" = "Mais feedback? (opcional)"; -"You found this helpful." = "Achaste isto útil."; -"No working Internet connection is found." = "Não foi encontrada nenhuma ligação à Internet."; -"No messages found." = "Nenhuma mensagem encontrada."; -"Please enter a brief description of the issue you are facing." = "Introduz uma breve descrição do problema que estás a enfrentar."; -"Shop Now" = "Comprar agora"; -"Close Section" = "Fechar secção"; -"Close FAQ" = "Fechar FAQ"; -"Close" = "Fechar"; -"This conversation has ended." = "Esta conversa terminou."; -"Send it anyway" = "Enviar na mesma"; -"You accepted review request." = "Aceitaste o pedido de avaliação."; -"Delete" = "Eliminar"; -"What else can we help you with?" = "Com que mais te podemos ajudar?"; -"Tap here if the answer was not helpful to you" = "Toca aqui se a resposta não foi útil para ti"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Lamentamos. Podes dizer-nos mais algumas coisas sobre o problema que estás a enfrentar?"; -"Service Rating" = "Avaliação do serviço"; -"Your email" = "O teu email"; -"Email invalid" = "Email inválido"; -"Could not fetch FAQs" = "Impossível obter FAQ"; -"Thanks for rating us." = "Obrigado por nos avaliares."; -"Download" = "Transferir"; -"Please enter a valid email" = "Introduz um endereço de email válido"; -"Message" = "Mensagem"; -"or" = "ou"; -"Decline" = "Recusar"; -"No" = "Não"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Captura de ecrã não enviada. Imagem muito grande, tenta enviar com outra imagem"; -"Hated it" = "Detestei"; -"Stars" = "Estrelas"; -"Your feedback has been received." = "O teu feedback foi recebido."; -"Dislike" = "Não gostar"; -"Preview" = "Pré-visualizar"; -"Book Now" = "Reservar agora"; -"START A NEW CONVERSATION" = "INICIAR NOVA CONVERSA"; -"Your Rating" = "A tua avaliação"; -"No Internet!" = "Sem Internet!"; -"Invalid Entry" = "Introdução inválida"; -"Loved it" = "Adorei"; -"Review on the App Store" = "Avaliar na App Store"; -"Open Help" = "Abrir Ajuda"; -"Search" = "Procurar"; -"Tap here if you found this FAQ helpful" = "Toca aqui se achaste esta FAQ útil"; -"Star" = "Estrela"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Toca aqui se achaste esta resposta útil"; -"Report a problem" = "Comunicar problema"; -"YES, THANKS!" = "SIM, OBRIGADO!"; -"Was this helpful?" = "Foi útil?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "A mensagem não foi enviada. Toca \"Tentar novamente\" para enviar esta mensagem."; -"Suggestions" = "Sugestões"; -"No FAQs found" = "Sem FAQ encontradas"; -"Done" = "Feito"; -"Opening Gallery..." = "A abrir Fotografias..."; -"You rated the service with" = "Classificaste o serviço com"; -"Cancel" = "Cancelar"; -"Loading..." = "A carregar..."; -"Read FAQs" = "Ler FAQs"; -"Thanks for messaging us!" = "Obrigado pela tua mensagem!"; -"Try Again" = "Tentar novamente"; -"Send Feedback" = "Enviar feedback"; -"Your Name" = "Nome"; -"Please provide a name." = "Indica um nome."; -"FAQ" = "FAQ"; -"Describe your problem" = "Descreve o teu problema"; -"How can we help?" = "Como podemos ajudar?"; -"Help about" = "Ajuda sobre"; -"We could not fetch the required data" = "Não conseguimos obter os dados necessários"; -"Name" = "Nome"; -"Sending failed!" = "Falha no envio!"; -"You didn't find this helpful." = "Não achaste isto útil."; -"Attach a screenshot of your problem" = "Anexa captura de ecrã do teu problema"; -"Mark as read" = "Marcar como lida"; -"Name invalid" = "Nome inválido"; -"Yes" = "Sim"; -"What's on your mind?" = "O que estás a pensar?"; -"Send a new message" = "Enviar nova mensagem"; -"Questions that may already have your answer" = "Questões que possam ainda ter resposta"; -"Attach a photo" = "Anexar foto"; -"Accept" = "Aceitar"; -"Your reply" = "A tua resposta"; -"Inbox" = "Caixa de entrada"; -"Remove attachment" = "Remover anexo"; -"Could not fetch message" = "Impossível obter mensagem"; -"Read FAQ" = "Ler FAQ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Lamentamos! Esta conversa foi encerrada por inatividade. Inicia uma nova conversa com os nossos agentes."; -"%d new messages from Support" = "%d novas mensagens do apoio ao cliente"; -"Ok, Attach" = "Ok, anexar"; -"Send" = "Enviar"; -"Screenshot size should not exceed %.2f MB" = "O tamanho da captura do ecrã não deve exceder %.2f MB"; -"Information" = "Informações"; -"Issue ID" = "ID do problema"; -"Tap to copy" = "Tocar para copiar"; -"Copied!" = "Copiado!"; -"We couldn't find an FAQ with matching ID" = "Não conseguimos encontrar nenhuma FAQ com ID correspondente"; -"Failed to load screenshot" = "Falha ao carregar captura de ecrã"; -"Failed to load video" = "Falha ao carregar vídeo"; -"Failed to load image" = "Falha ao carregar imagem"; -"Hold down your device's power and home buttons at the same time." = "Pressiona simultaneamente o botão Home e o botão de alimentação do teu dispositivo."; -"Please note that a few devices may have the power button on the top." = "Tem em atenção que alguns dispositivos podem ter o botão de alimentação no topo."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Depois de o fazeres, regressa a esta conversa e toca em \"Ok, anexar\" para a anexares."; -"Okay" = "Ok"; -"We couldn't find an FAQ section with matching ID" = "Não conseguimos encontrar nenhuma secção de FAQ com ID correspondente"; - -"GIFs are not supported" = "GIFs não são suportados"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ro.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ro.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index bfcdabd1e6ff..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ro.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,150 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Nu găsești ceea ce căutai?"; -"Rate App" = "Evaluați aplicația"; -"We\'re happy to help you!" = "Suntem încântați să vă ajutăm!"; -"Did we answer all your questions?" = "Ți-am răspuns la toate întrebările?"; -"Remind Later" = "Amintește-mi mai târziu"; -"Your message has been received." = "Mesajul tău a fost primit."; -"Message send failure." = "Eșec trimitere mesaj."; -"What\'s your feedback about our customer support?" = "Care este feedbackul dvs. despre serviciu nostru de asistență clienți?"; -"Take a screenshot on your iPhone" = "Realizează o captură de ecran pe iPhone"; -"Learn how" = "Află cum"; -"Take a screenshot on your iPad" = "Realizează o captură de ecran pe iPad"; -"Your email(optional)" = "Adresa ta de e-mail (opțional)"; -"Conversation" = "Conversație"; -"View Now" = "Vizualizați acum"; -"SEND ANYWAY" = "TRIMITE ORICUM"; -"OK" = "OK"; -"Help" = "Ajutor"; -"Send message" = "Trimitere mesaj"; -"REVIEW" = "EVALUEAZĂ"; -"Share" = "Partajați"; -"Close Help" = "Închidere Ajutor"; -"Sending your message..." = "Se trimite mesajul tău..."; -"Learn how to" = "Aflați cum să"; -"No FAQs found in this section" = "Nu s-au găsit întrebări frecvente în această secțiune"; -"Thanks for contacting us." = "Vă mulțumim că ne-ați contactat."; -"Chat Now" = "Discutați prin chat acum"; -"Buy Now" = "Cumpărați acum"; -"New Conversation" = "Conversație nouă"; -"Please check your network connection and try again." = "Verifică conexiunea la rețea și reîncearcă."; -"New message from Support" = "Nou mesaj de la Asistență"; -"Question" = "Întrebare"; -"Type in a new message" = "Introduceți un nou mesaj"; -"Email (optional)" = "E-mail (opțional)"; -"Reply" = "Răspundeți"; -"CONTACT US" = "CONTACTEAZĂ-NE"; -"Email" = "E-mail"; -"Like" = "Apreciați"; -"Tap here if this FAQ was not helpful to you" = "Apăsați aici dacă Întrebările frecvente nu v-au fost utile"; -"Any other feedback? (optional)" = "Alt feedback? (opțional)"; -"You found this helpful." = "Consideri că ți-a fost util."; -"No working Internet connection is found." = "Nu s-a găsit nicio conexiune la Internet."; -"No messages found." = "Nu s-a găsit niciun mesaj."; -"Please enter a brief description of the issue you are facing." = "Introdu o scurtă descriere a problemei cu care te confrunți."; -"Shop Now" = "Cumpărați acum"; -"Close Section" = "Închidere secțiune"; -"Close FAQ" = "Închidere Întrebări frecvente"; -"Close" = "Închide"; -"This conversation has ended." = "Această conversație s-a încheiat."; -"Send it anyway" = "Trimiteți oricum"; -"You accepted review request." = "Ați acceptat cererea de evaluare."; -"Delete" = "Ștergere"; -"What else can we help you with?" = "Cu ce te mai putem ajuta?"; -"Tap here if the answer was not helpful to you" = "Apăsați aici dacă răspunsul nu v-a fost util"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Regretăm. Ne-ai putea spune mai multe despre problema ta?"; -"Service Rating" = "Evaluare serviciu"; -"Your email" = "Adresa ta de e-mail"; -"Email invalid" = "E-mail nevalid"; -"Could not fetch FAQs" = "Nu s-au putut obține întrebări frecvente"; -"Thanks for rating us." = "Îți mulțumim pentru notare."; -"Download" = "Descărcați"; -"Please enter a valid email" = "Introduceți o adresă de e-mail validă"; -"Message" = "Mesaj"; -"or" = "sau"; -"Decline" = "Refuz"; -"No" = "Nu"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Captura de ecran nu a putut fi trimisă. Imaginea este prea mare, încearcă din nou cu altă imagine"; -"Hated it" = "Mi-a displăcut"; -"Stars" = "Stele"; -"Your feedback has been received." = "Feedbackul tău a fost primit."; -"Dislike" = "Nu apreciați"; -"Preview" = "Previzualizare"; -"Book Now" = "Rezervați acum"; -"START A NEW CONVERSATION" = "ÎNCEPE O NOUĂ CONVERSAȚIE"; -"Your Rating" = "Notarea ta"; -"No Internet!" = "Lipsă Internet!"; -"Invalid Entry" = "Intrare nevalidă"; -"Loved it" = "Mi-a plăcut"; -"Review on the App Store" = "Evaluați pe App Store"; -"Open Help" = "Deschideți Ajutor"; -"Search" = "Căutare"; -"Tap here if you found this FAQ helpful" = "Apăsați aici dacă Întrebările frecvente v-au fost utile"; -"Star" = "Stea"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Apăsați aici dacă acest răspuns v-a fost util"; -"Report a problem" = "Raportați o problemă"; -"YES, THANKS!" = "DA, MULȚUMESC!"; -"Was this helpful?" = "Ți-a fost util?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Mesajul tău nu a fost trimis. Atingi \"Reîncearcă \" pentru a trimite acest mesaj?"; -"Suggestions" = "Sugestii"; -"No FAQs found" = "Nu s-au găsit întrebări frecvente"; -"Done" = "Rezolvat"; -"Opening Gallery..." = "Deschide galeria..."; -"You rated the service with" = "Ai evaluat serviciul cu"; -"Cancel" = "Anulează"; -"Loading..." = "Se încarcă..."; -"Read FAQs" = "Citiți întrebările frecvente"; -"Thanks for messaging us!" = "Mulțumim pentru mesajul dvs!"; -"Try Again" = "Reîncearcă"; -"Send Feedback" = "Trimite feedback"; -"Your Name" = "Numele dvs."; -"Please provide a name." = "Furnizează un nume."; -"FAQ" = "Întrebări frecvente"; -"Describe your problem" = "Descrie problema ta"; -"How can we help?" = "Cum te putem ajuta?"; -"Help about" = "Ajutor despre"; -"We could not fetch the required data" = "Nu am putut obține datele cerute"; -"Name" = "Nume"; -"Sending failed!" = "Trimitere eșuată!"; -"You didn't find this helpful." = "Nu ați considerat acesta ca util."; -"Attach a screenshot of your problem" = "Atașați o captură de ecran a problemei dvs."; -"Mark as read" = "Marcați ca citit"; -"Name invalid" = "Nume nevalid"; -"Yes" = "Da"; -"What's on your mind?" = "La ce te gândești?"; -"Send a new message" = "Trimiteți un nou mesaj"; -"Questions that may already have your answer" = "Întrebări care pot conține deja răspunsul tău"; -"Attach a photo" = "Atașați o poză"; -"Accept" = "Acceptare"; -"Your reply" = "Răspunsul dvs."; -"Inbox" = "Mesaje primite"; -"Remove attachment" = "Eliminați atașamentul"; -"Could not fetch message" = "Nu s-a găsit mesajul"; -"Read FAQ" = "Citiți întrebările frecvente"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Ne cerem scuze! Această conversație a fost încheiată din cauza inactivității. Începeți o nouă conversație cu agenții noștri."; -"%d new messages from Support" = "%d noi mesaje de la Serviciul de asistență"; -"Ok, Attach" = "OK, atașează"; -"Send" = "Trimite"; -"Screenshot size should not exceed %.2f MB" = "Dimensiunea capturii de ecran nu trebuie să depășească %.2f MB"; -"Information" = "Informații"; -"Issue ID" = "ID problemă"; -"Tap to copy" = "Atingeți pentru copiere"; -"Copied!" = "Copiate!"; -"We couldn't find an FAQ with matching ID" = "Nu am putut găsi o Întrebare frecventă cu același ID"; -"Failed to load screenshot" = "Încărcare captură de ecran eșuată"; -"Failed to load video" = "Încărcare videoclip eșuată"; -"Failed to load image" = "Încărcare imagine eșuată"; -"Hold down your device's power and home buttons at the same time." = "Ține apăsat butonul de pornire și butonul home de pe dispozitiv în același timp."; -"Please note that a few devices may have the power button on the top." = "Unele dispozitive pot avea butonul de pornire deasupra."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "După ce faci acest lucru, revino la această conversație și atinge „Ok, atașează” pentru a-l atașa."; -"Okay" = "OK"; - -"We couldn't find an FAQ section with matching ID" = "Nu am putut găsi o secțiune Întrebări frecvente cu același ID"; - -"GIFs are not supported" = "GIF-urile nu sunt acceptate"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ru.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ru.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 8bdf633ae53b..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ru.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Не можете найти то, что искали?"; -"Rate App" = "Оценить приложение"; -"We\'re happy to help you!" = "Мы рады помочь вам!"; -"Did we answer all your questions?" = "Вы получили ответы на все свои вопросы?"; -"Remind Later" = "Напомнить позже"; -"Your message has been received." = "Ваше сообщение получено."; -"Message send failure." = "Не удалось отправить сообщение."; -"What\'s your feedback about our customer support?" = "Как бы вы оценили работу нашей службы поддержки?"; -"Take a screenshot on your iPhone" = "Сделайте снимок экрана на своем iPhone"; -"Learn how" = "Узнать как"; -"Take a screenshot on your iPad" = "Сделайте снимок экрана на своем iPad"; -"Your email(optional)" = "Ваш адрес e-mail (по желанию)"; -"Conversation" = "Разговор"; -"View Now" = "Просмотреть сейчас"; -"SEND ANYWAY" = "ОТПРАВИТЬ ВСЕ РАВНО"; -"OK" = "OK"; -"Help" = "Помощь"; -"Send message" = "Отправить сообщение"; -"REVIEW" = "ОТЗЫВ"; -"Share" = "Поделиться"; -"Close Help" = "Закрыть помощь"; -"Sending your message..." = "Отправка сообщения..."; -"Learn how to" = "Узнать как"; -"No FAQs found in this section" = "В этом разделе вопросов/ответов (ЧаВо) не найдено"; -"Thanks for contacting us." = "Спасибо, что обратились к нам."; -"Chat Now" = "Перейти в чат"; -"Buy Now" = "Купить сейчас"; -"New Conversation" = "Новый разговор"; -"Please check your network connection and try again." = "Проверьте сетевое подключение и повторите попытку."; -"New message from Support" = "Новое сообщение службы поддержки"; -"Question" = "Вопрос"; -"Type in a new message" = "Введите новое сообщение"; -"Email (optional)" = "E-mail (по желанию)"; -"Reply" = "Ответить"; -"CONTACT US" = "СВЯЗАТЬСЯ С НАМИ"; -"Email" = "E-mail"; -"Like" = "Нравится"; -"Tap here if this FAQ was not helpful to you" = "Нажмите здесь, если этот ЧаВо вам не помог"; -"Any other feedback? (optional)" = "Хотите добавить отзыв (по желанию)?"; -"You found this helpful." = "Вы считаете эту информацию полезной."; -"No working Internet connection is found." = "Не найдено действующего подключения к Интернету."; -"No messages found." = "Сообщений не найдено."; -"Please enter a brief description of the issue you are facing." = "Введите краткое описание проблемы, с которой вы столкнулись."; -"Shop Now" = "Перейти в магазин"; -"Close Section" = "Закрыть раздел"; -"Close FAQ" = "Закрыть ЧаВо"; -"Close" = "Закрыть"; -"This conversation has ended." = "Этот разговор завершен."; -"Send it anyway" = "Отправить все равно"; -"You accepted review request." = "Вы приняли запрос обзора."; -"Delete" = "Удалить"; -"What else can we help you with?" = "Чем еще мы можем вам помочь?"; -"Tap here if the answer was not helpful to you" = "Нажмите здесь, если данный ответ вам не помог"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Очень жаль. Может, расскажете поподробнее о проблеме, с которой вы столкнулись?"; -"Service Rating" = "Оценка услуг"; -"Your email" = "Ваш адрес e-mail"; -"Email invalid" = "E-mail недействителен"; -"Could not fetch FAQs" = "Не удалось найти вопросы/ответы (ЧаВо)"; -"Thanks for rating us." = "Спасибо за оценку!"; -"Download" = "Загрузка"; -"Please enter a valid email" = "Введите действительный адрес e-mail"; -"Message" = "Сообщение"; -"or" = "или"; -"Decline" = "Отклонить"; -"No" = "Нет"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Невозможно отправить снимок экрана. Файл слишком велик; выберите другое изображение и повторите попытку."; -"Hated it" = "Отвратительно"; -"Stars" = "Звезды"; -"Your feedback has been received." = "Ваш отзыв получен."; -"Dislike" = "Не нравится"; -"Preview" = "Предпросмотр"; -"Book Now" = "Заказать сейчас"; -"START A NEW CONVERSATION" = "НАЧАТЬ НОВЫЙ РАЗГОВОР"; -"Your Rating" = "Ваша оценка"; -"No Internet!" = "Интернет недоступен!"; -"Invalid Entry" = "Недопустимая запись"; -"Loved it" = "Великолепно"; -"Review on the App Store" = "Отзыв на App Store"; -"Open Help" = "Открыть помощь"; -"Search" = "Поиск"; -"Tap here if you found this FAQ helpful" = "Нажмите здесь, если этот ЧаВо показался вам полезным"; -"Star" = "Звезда"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Нажмите здесь, если этот ответ показался вам полезным"; -"Report a problem" = "Сообщить о проблеме"; -"YES, THANKS!" = "ДА, СПАСИБО!"; -"Was this helpful?" = "Помогла ли вам эта информация?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Сообщение не отправлено. Нажмите \"Еще раз\", чтобы отправить это сообщение."; -"Suggestions" = "Предложения"; -"No FAQs found" = "ЧаВо не найдено"; -"Done" = "Готово"; -"Opening Gallery..." = "Открываем альбом..."; -"You rated the service with" = "Вы поставили этой услуге оценку"; -"Cancel" = "Отмена"; -"Loading..." = "Загрузка..."; -"Read FAQs" = "Читать сборники ЧаВо"; -"Thanks for messaging us!" = "Спасибо, что отправили нам сообщение!"; -"Try Again" = "Еще раз"; -"Send Feedback" = "Отправить отзыв"; -"Your Name" = "Ваше имя"; -"Please provide a name." = "Укажите имя."; -"FAQ" = "ЧаВо"; -"Describe your problem" = "Опишите свою проблему"; -"How can we help?" = "Чем мы можем помочь?"; -"Help about" = "Помощь по теме"; -"We could not fetch the required data" = "Не удалось найти нужные данные"; -"Name" = "Имя"; -"Sending failed!" = "Сбой отправки!"; -"You didn't find this helpful." = "Вы считаете эту информацию бесполезной."; -"Attach a screenshot of your problem" = "Приложите снимок экрана, отражающий суть вашей проблемы"; -"Mark as read" = "Пометить как прочитанное"; -"Name invalid" = "Недопустимое имя"; -"Yes" = "Да"; -"What's on your mind?" = "О чем вы думаете?"; -"Send a new message" = "Отправить новое сообщение"; -"Questions that may already have your answer" = "Вопросы, на которые, возможно, уже дан ответ"; -"Attach a photo" = "Приложить снимок"; -"Accept" = "Принять"; -"Your reply" = "Ваш ответ"; -"Inbox" = "Входящие"; -"Remove attachment" = "Удалить вложение"; -"Could not fetch message" = "Не удается получить сообщение"; -"Read FAQ" = "Читать ЧаВо"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Этот разговор был завершен по причине отсутствия активности участников. Пожалуйста, начните новый разговор с нашими представителями."; -"%d new messages from Support" = "%d новых сообщений службы поддержки"; -"Ok, Attach" = "Да, приложить"; -"Send" = "Отправить"; -"Screenshot size should not exceed %.2f MB" = "Размер снимка экрана не должен превышать %.2f Мб"; -"Information" = "Информация"; -"Issue ID" = "Идентификатор проблемы"; -"Tap to copy" = "Коснитесь, чтобы скопировать"; -"Copied!" = "Скопировано!"; -"We couldn't find an FAQ with matching ID" = "Не удалось найти сборник ЧаВо с таким идентификатором"; -"Failed to load screenshot" = "Не удалось загрузить снимок экрана"; -"Failed to load video" = "Не удалось загрузить видео"; -"Failed to load image" = "Не удалось загрузить изображение"; -"Hold down your device's power and home buttons at the same time." = "Удерживайте нажатыми одновременно кнопку «Домой» и кнопку сна/пробуждения на своем устройстве."; -"Please note that a few devices may have the power button on the top." = "Учтите, что на некоторых устройствах кнопка сна/пробуждения может находиться вверху."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Затем вернитесь к этому диалоговому окну и нажмите «Да, приложить», чтобы приложить снимок."; -"Okay" = "OK"; -"We couldn't find an FAQ section with matching ID" = "Не удалось найти раздел ЧаВо с таким идентификатором"; - -"GIFs are not supported" = "GIF не поддерживаются"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/sk.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/sk.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 770855346a8e..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/sk.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Nenašli ste to, čo hľadáte?"; -"Rate App" = "Ohodnotiť aplikáciu"; -"We\'re happy to help you!" = "S radosťou vám pomôžeme!"; -"Did we answer all your questions?" = "Zodpovedali sme všetky vaše otázky?"; -"Remind Later" = "Pripomenúť neskôr"; -"Your message has been received." = "Vašu správu sme dostali."; -"Message send failure." = "Správu sa nepodarilo odoslať."; -"What\'s your feedback about our customer support?" = "Aký je váš názor na našu zákaznícku podporu?"; -"Take a screenshot on your iPhone" = "Odfoťte obrazovku na iPhone"; -"Learn how" = "Zistiť ako"; -"Take a screenshot on your iPad" = "Odfoťte obrazovku na iPade"; -"Your email(optional)" = "Váš e-mail (voliteľné)"; -"Conversation" = "Konverzácia"; -"View Now" = "Zobraziť"; -"SEND ANYWAY" = "NAPRIEK TOMU POSLAŤ"; -"OK" = "OK"; -"Help" = "Pomoc"; -"Send message" = "Odošlite správu"; -"REVIEW" = "OHODNOTIŤ"; -"Share" = "Zdieľať"; -"Close Help" = "Zatvoriť pomoc"; -"Sending your message..." = "Posiela sa správa..."; -"Learn how to" = "Zistiť ako"; -"No FAQs found in this section" = "V tejto časti nie sú žiadne často kladené otázky"; -"Thanks for contacting us." = "Ďakujeme, že ste nás kontaktovali."; -"Chat Now" = "Chat"; -"Buy Now" = "Kúpiť"; -"New Conversation" = "Nová konverzácia"; -"Please check your network connection and try again." = "Skontrolujte svoje pripojenie na sieť a skúste to znova."; -"New message from Support" = "Nová správa od tímu podpory"; -"Question" = "Otázka"; -"Type in a new message" = "Napíšte novú správu"; -"Email (optional)" = "E-mail (voliteľné)"; -"Reply" = "Odpovedať"; -"CONTACT US" = "KONTAKTUJTE NÁS"; -"Email" = "E-mail"; -"Like" = "Páči sa mi to"; -"Tap here if this FAQ was not helpful to you" = "Ak bola táto často kladená otázka neužitočná, ťuknite sem"; -"Any other feedback? (optional)" = "Iný názor? (voliteľné)"; -"You found this helpful." = "Táto rada vám pomohla."; -"No working Internet connection is found." = "Nenašlo sa žiadne pripojenie na internet."; -"No messages found." = "Nenašli sa žiadne správy."; -"Please enter a brief description of the issue you are facing." = "Uveďte krátky popis svojho problému."; -"Shop Now" = "Nakupovať"; -"Close Section" = "Zatvoriť časť"; -"Close FAQ" = "Zatvoriť často kladené otázky"; -"Close" = "Zatvoriť"; -"This conversation has ended." = "Táto konverzácia sa skončila."; -"Send it anyway" = "Napriek tomu odoslať"; -"You accepted review request." = "Prijali ste žiadosť o hodnotenie."; -"Delete" = "Odstrániť"; -"What else can we help you with?" = "Ako vám ešte môžeme pomôcť?"; -"Tap here if the answer was not helpful to you" = "Ak bola odpoveď neužitočná, ťuknite sem"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "To je nám nesmierne ľúto. Mohli by ste nám povedať o vašom probléme trochu viac?"; -"Service Rating" = "Hodnotenie služby"; -"Your email" = "Váš e-mail"; -"Email invalid" = "E-mail nie je platný"; -"Could not fetch FAQs" = "Často kladené otázky sa nepodarilo získať"; -"Thanks for rating us." = "Ďakujeme za vaše hodnotenie."; -"Download" = "Stiahnuť"; -"Please enter a valid email" = "Zadajte platný e-mail"; -"Message" = "Správa"; -"or" = "alebo"; -"Decline" = "Odmietnuť"; -"No" = "Nie"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Fotografiu obrazovky sa nepodarilo odoslať. Obrázok je príliš veľký, skúste to znova s iným obrázkom"; -"Hated it" = "Nepáčila sa mi"; -"Stars" = "Hviezdičky"; -"Your feedback has been received." = "Vašu spätnú väzbu sme dostali."; -"Dislike" = "Nepáči sa mi to"; -"Preview" = "Náhľad"; -"Book Now" = "Rezervovať"; -"START A NEW CONVERSATION" = "ZAČAŤ NOVÚ KONVERZÁCIU"; -"Your Rating" = "Vaše hodnotenie"; -"No Internet!" = "Nie je pripojenie na internet!"; -"Invalid Entry" = "Neplatný vstup"; -"Loved it" = "Páčila sa mi"; -"Review on the App Store" = "Hodnotenie v App Store"; -"Open Help" = "Otvoriť pomoc"; -"Search" = "Vyhľadávať"; -"Tap here if you found this FAQ helpful" = "Ak bola táto často kladená otázka užitočná, ťuknite sem"; -"Star" = "Hviezdička"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Ak bola odpoveď užitočná, ťuknite sem"; -"Report a problem" = "Nahlásiť problém"; -"YES, THANKS!" = "ÁNO, ĎAKUJEM!"; -"Was this helpful?" = "Pomohlo vám to?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Vaša správa sa neodoslala. Na odoslanie správy ťuknite na možnosť \"Skúsiť znova\"."; -"Suggestions" = "Návrhy"; -"No FAQs found" = "Nenašli sa žiadne často kladené otázky"; -"Done" = "Hotovo"; -"Opening Gallery..." = "Otváranie galérie..."; -"You rated the service with" = "Službu ste ohodnotili"; -"Cancel" = "Zrušiť"; -"Loading..." = "Načítava sa..."; -"Read FAQs" = "Prečítať často kladené otázky"; -"Thanks for messaging us!" = "Ďakujeme, že ste nám napísali!"; -"Try Again" = "Skúsiť znova"; -"Send Feedback" = "Poslať spätnú väzbu"; -"Your Name" = "Vaše meno"; -"Please provide a name." = "Uveďte meno."; -"FAQ" = "Často kladené otázky"; -"Describe your problem" = "Popíšte svoj problém"; -"How can we help?" = "Ako vám môžeme pomôcť?"; -"Help about" = "Pomoc ohľadom"; -"We could not fetch the required data" = "Požadované údaje sa nepodarilo získať"; -"Name" = "Meno"; -"Sending failed!" = "Odosielanie zlyhalo!"; -"You didn't find this helpful." = "Táto rada vám nepomohla."; -"Attach a screenshot of your problem" = "Priložte snímku obrazovky so zachyteným problémom"; -"Mark as read" = "Označiť ako prečítanú"; -"Name invalid" = "Neplatné meno"; -"Yes" = "Áno"; -"What's on your mind?" = "Čo vás trápi?"; -"Send a new message" = "Poslať novú správu"; -"Questions that may already have your answer" = "Otázky, ktoré by mohli obsahovať odpovede, ktoré hľadáte"; -"Attach a photo" = "Priložiť snímku"; -"Accept" = "Prijať"; -"Your reply" = "Vaša odpoveď"; -"Inbox" = "Doručená pošta"; -"Remove attachment" = "Odobrať prílohu"; -"Could not fetch message" = "Správu sa nepodarilo získať"; -"Read FAQ" = "Prečítať často kladenú otázku"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Ospravedlňujeme sa! Táto konverzácia bola zatvorená z dôvodu neaktivity. Začnite novú konverzáciu s našimi agentmi."; -"%d new messages from Support" = "%d nových správ od tímu podpory"; -"Ok, Attach" = "Ok, priložiť"; -"Send" = "Poslať"; -"Screenshot size should not exceed %.2f MB" = "Veľkosť snímky obrazovky by nemala presiahnuť %.2f MB"; -"Information" = "Informácie"; -"Issue ID" = "ID problému"; -"Tap to copy" = "Ťuknite na kopírovanie"; -"Copied!" = "Skopírované!"; -"We couldn't find an FAQ with matching ID" = "Nepodarilo sa nájsť často kladené otázky s týmto ID"; -"Failed to load screenshot" = "Načítanie snímky obrazovky zlyhalo"; -"Failed to load video" = "Načítanie videa zlyhalo"; -"Failed to load image" = "Načítanie obrázka zlyhalo"; -"Hold down your device's power and home buttons at the same time." = "Súčasne podržte tlačidlo napájania a tlačidlo Domov na zariadení."; -"Please note that a few devices may have the power button on the top." = "Upozorňujeme, že niektoré zariadenia môžu mať tlačidlo napájania na vrchu"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Po skončení znova prejdite na túto konverzáciu a ťuknutím na možnosť „Ok, priložiť“ ju priložíte."; -"Okay" = "V poriadku"; -"We couldn't find an FAQ section with matching ID" = "Nepodarilo sa nájsť časť s často kladenými otázkami s týmto ID"; - -"GIFs are not supported" = "GIF nie sú podporované"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/sl.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/sl.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 47bbb5733eec..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/sl.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Ne najdete tega, kar ste iskali?"; -"Rate App" = "Oceni aplikacijo"; -"We\'re happy to help you!" = "Z veseljem vam pomagamo."; -"Did we answer all your questions?" = "Ali smo odgovorili na vsa vaša vprašanja?"; -"Remind Later" = "Spomni me pozneje"; -"Your message has been received." = "Vaše sporočilo smo prejeli."; -"Message send failure." = "Napaka pri pošiljanju sporočila"; -"What\'s your feedback about our customer support?" = "Kakšno je vaše mnenje o naši podpori za stranke?"; -"Take a screenshot on your iPhone" = "Naredite posnetek zaslona na svojem iPhonu"; -"Learn how" = "Ugotovite, kako"; -"Take a screenshot on your iPad" = "Naredite posnetek zaslona na svojem iPadu"; -"Your email(optional)" = "Vaša e-pošta (izbirno)"; -"Conversation" = "Pogovor"; -"View Now" = "Oglej si zdaj"; -"SEND ANYWAY" = "VSEENO POŠLJI"; -"OK" = "V REDU"; -"Help" = "Pomoč"; -"Send message" = "Pošlji sporočilo"; -"REVIEW" = "OCENI"; -"Share" = "Daj v skupno rabo"; -"Close Help" = "Zapri pomoč"; -"Sending your message..." = "Pošiljanje sporočila ..."; -"Learn how to" = "Ugotovite, kako"; -"No FAQs found in this section" = "V tem razdelku ni najdenih vprašanj in odgovorov"; -"Thanks for contacting us." = "Hvala, ker ste se obrnili na nas."; -"Chat Now" = "Začni klepet"; -"Buy Now" = "Kupi zdaj"; -"New Conversation" = "Nov pogovor"; -"Please check your network connection and try again." = "Preverite omrežno povezavo in poskusite znova."; -"New message from Support" = "Novo sporočilo iz Podpore"; -"Question" = "Vprašanje"; -"Type in a new message" = "Vnesite novo sporočilo"; -"Email (optional)" = "E-pošta (dodatna možnost)"; -"Reply" = "Odgovori"; -"CONTACT US" = "STIK Z NAMI"; -"Email" = "E-pošta"; -"Like" = "Všeč mi je"; -"Tap here if this FAQ was not helpful to you" = "Tapnite tukaj, če menite, da to pogosto vprašanje ni bilo koristno"; -"Any other feedback? (optional)" = "Imate še kakšne druge povratne informacije? (Dodatna možnost)"; -"You found this helpful." = "To se vam zdi koristno."; -"No working Internet connection is found." = "Najti ni bilo mogoče nobene delujoče internetne povezave."; -"No messages found." = "Nobenega sporočila ni bilo mogoče najti."; -"Please enter a brief description of the issue you are facing." = "Prosimo, vnesite kratek opis težave, s katero se soočate."; -"Shop Now" = "Nakupuj"; -"Close Section" = "Zapri razdelek"; -"Close FAQ" = "Zapri razdelek s pogostimi vprašanji"; -"Close" = "Zapri"; -"This conversation has ended." = "Ta pogovor je zaključen."; -"Send it anyway" = "Vseeno pošlji"; -"You accepted review request." = "Sprejeli ste zahtevo za oceno."; -"Delete" = "Izbriši"; -"What else can we help you with?" = "S čim vam lahko še pomagamo?"; -"Tap here if the answer was not helpful to you" = "Tapnite tukaj, če menite, da odgovor ni bil koristen"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Žal nam je. Nam lahko poveste več o težavi, s katero se soočate?"; -"Service Rating" = "Ocena storitve"; -"Your email" = "Vaša e-pošta"; -"Email invalid" = "E-poštni naslov ni veljaven"; -"Could not fetch FAQs" = "Pogostih vprašanj ni bilo mogoče pridobiti"; -"Thanks for rating us." = "Hvala, ker ste nas ocenili."; -"Download" = "Prenesi"; -"Please enter a valid email" = "Vnesite veljavno e-pošto"; -"Message" = "Sporočilo"; -"or" = "ali"; -"Decline" = "Zavrni"; -"No" = "Ne"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Posnetka zaslona ni bilo mogoče poslati. Slika je prevelika. Poskusite z drugo sliko."; -"Hated it" = "Bilo je grozno"; -"Stars" = "Zvezdice"; -"Your feedback has been received." = "Vaše povratne informacije smo prejeli."; -"Dislike" = "Ni mi všeč"; -"Preview" = "Predogled"; -"Book Now" = "Rezerviraj"; -"START A NEW CONVERSATION" = "ZAČNI NOV POGOVOR"; -"Your Rating" = "Vaša ocena"; -"No Internet!" = "Ni internetne povezave."; -"Invalid Entry" = "Neveljaven vnos"; -"Loved it" = "Bilo je super"; -"Review on the App Store" = "Ocenite v trgovini App Store"; -"Open Help" = "Odpri pomoč"; -"Search" = "Išči"; -"Tap here if you found this FAQ helpful" = "Tapnite tukaj, če menite, da je bilo to pogosto vprašanje koristno"; -"Star" = "Zvezdica"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Tapnite tukaj, če menite, da je bil odgovor koristen"; -"Report a problem" = "Prijavite težavo"; -"YES, THANKS!" = "DA, HVALA!"; -"Was this helpful?" = "Ali je bilo to v pomoč?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Sporočilo ni poslano. Tapnite \"Poskusi znova\", da ponovno pošljete sporočilo."; -"Suggestions" = "Predlogi"; -"No FAQs found" = "Najdeno ni bilo nobeno pogosto vprašanje"; -"Done" = "Končano"; -"Opening Gallery..." = "Odpiranje galerije ..."; -"You rated the service with" = "Storitev ste ocenili takole:"; -"Cancel" = "Prekliči"; -"Loading..." = "Nalaganje ..."; -"Read FAQs" = "Preberi razdelek s pogostimi vprašanji"; -"Thanks for messaging us!" = "Hvala, ker ste nam poslali sporočilo."; -"Try Again" = "Poskusite znova"; -"Send Feedback" = "Pošlji povratne informacije"; -"Your Name" = "Vaše ime"; -"Please provide a name." = "Prosimo, vnesite ime."; -"FAQ" = "Pogosta vprašanja"; -"Describe your problem" = "Opišite svojo težavo"; -"How can we help?" = "Kako vam lahko pomagamo?"; -"Help about" = "Več informacij o podpori"; -"We could not fetch the required data" = "Zahtevanih podatkov ni bilo mogoče pridobiti"; -"Name" = "Ime"; -"Sending failed!" = "Pošiljanje ni uspelo!"; -"You didn't find this helpful." = "To se vam ne zdi koristno."; -"Attach a screenshot of your problem" = "Priložite posnetek zaslona o težavi"; -"Mark as read" = "Označi kot prebrano"; -"Name invalid" = "Ime ni veljavno"; -"Yes" = "Da"; -"What's on your mind?" = "O čem razmišljate?"; -"Send a new message" = "Pošlji novo sporočilo"; -"Questions that may already have your answer" = "Vprašanja, ki morda že imajo vaš odgovor"; -"Attach a photo" = "Priloži sliko"; -"Accept" = "Sprejmi"; -"Your reply" = "Vaš odgovor"; -"Inbox" = "Mapa »Prejeto«"; -"Remove attachment" = "Odstrani prilogo"; -"Could not fetch message" = "Sporočila ni bilo mogoče pridobiti"; -"Read FAQ" = "Preberi pogosta vprašanja"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Pogovor se je končal zaradi neaktivnosti. Začnite nov pogovor z našimi posredniki."; -"%d new messages from Support" = "Št. novih sporočil od podpore: %d"; -"Ok, Attach" = "V redu, priloži"; -"Send" = "Pošlji"; -"Screenshot size should not exceed %.2f MB" = "Posnetek zaslona ne sme presegati %.2f MB"; -"Information" = "Informacije"; -"Issue ID" = "ID zadeve"; -"Tap to copy" = "Tapni za kopiranje"; -"Copied!" = "Kopirano!"; -"We couldn't find an FAQ with matching ID" = "Pogostega vprašanja s tem ID-jem ni bilo mogoče najti"; -"Failed to load screenshot" = "Posnetka zaslona ni bilo mogoče naložiti"; -"Failed to load video" = "Videoposnetka ni bilo mogoče naložiti"; -"Failed to load image" = "Slike ni bilo mogoče naložiti"; -"Hold down your device's power and home buttons at the same time." = "Hkrati pritisnite in pridržite gumb za vklop/izklop in gumb za začetni zaslon na napravi."; -"Please note that a few devices may have the power button on the top." = "Upoštevajte, da je lahko gumb za napajanje pri nekaterih napravah na vrhu."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Ko končate, se vrnite v ta pogovor in tapnite »Ok, attach« (V redu, priloži), da priložite posnetek zaslona."; -"Okay" = "V redu"; -"We couldn't find an FAQ section with matching ID" = "Razdelka s pogostimi vprašanji in odgovori, ki bi imel enak ID, ni bilo mogoče najti"; - -"GIFs are not supported" = "GIF-ji niso podprti"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/sv.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/sv.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 88eaa8c57977..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/sv.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,150 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Kan vi inte hitta vad du söker?"; -"Rate App" = "Betygsätt appen"; -"We\'re happy to help you!" = "Vi hjälper dig gärna!"; -"Did we answer all your questions?" = "Svarade vi på alla dina frågor?"; -"Remind Later" = "Påminn mig senare"; -"Your message has been received." = "Vi har tagit emot ditt meddelande."; -"Message send failure." = "Meddelandet skickades inte."; -"What\'s your feedback about our customer support?" = "Vad tycker du om vår kundsupport?"; -"Take a screenshot on your iPhone" = "Ta en skärmbild med din iPhone"; -"Learn how" = "Lär dig hur"; -"Take a screenshot on your iPad" = "Ta en skärmbild med din iPad"; -"Your email(optional)" = "Din e-post (valfritt)"; -"Conversation" = "Konversation"; -"View Now" = "Visa nu"; -"SEND ANYWAY" = "SKICKA ÄNDÅ"; -"OK" = "OK"; -"Help" = "Hjälp"; -"Send message" = "Skicka meddelande"; -"REVIEW" = "RECENSION"; -"Share" = "Dela"; -"Close Help" = "Stäng hjälp"; -"Sending your message..." = "Skickar ditt meddelande"; -"Learn how to" = "Så gör du"; -"No FAQs found in this section" = "Det gick inte att hitta Vanliga frågor i den här sektionen"; -"Thanks for contacting us." = "Tack för att du kontaktar oss."; -"Chat Now" = "Chatta nu"; -"Buy Now" = "Köp nu"; -"New Conversation" = "Ny konversation"; -"Please check your network connection and try again." = "Kontrollera din nätverksanslutning och försök igen."; -"New message from Support" = "Nytt meddelande från Supporten"; -"Question" = "Fråga"; -"Type in a new message" = "Skriv in ett nytt meddelande"; -"Email (optional)" = "E-post (valfritt)"; -"Reply" = "Svara"; -"CONTACT US" = "KONTAKTA OSS"; -"Email" = "E-post"; -"Like" = "Gilla"; -"Tap here if this FAQ was not helpful to you" = "Tryck här om de vanliga frågorna inte var till hjälp"; -"Any other feedback? (optional)" = "Annan feedback? (valfri)"; -"You found this helpful." = "Du tyckte att det var användbart."; -"No working Internet connection is found." = "Det gick inte att hitta en fungerande internetanslutning!"; -"No messages found." = "Inga meddelanden hittades."; -"Please enter a brief description of the issue you are facing." = "Skriv in en kort beskrivning av ditt problem."; -"Shop Now" = "Handla nu"; -"Close Section" = "Stäng sektionen"; -"Close FAQ" = "Stäng vanliga frågor"; -"Close" = "Stäng"; -"This conversation has ended." = "Konversationen har avslutats."; -"Send it anyway" = "Skicka ändå"; -"You accepted review request." = "Du accepterade recensionsförfrågan."; -"Delete" = "Radera"; -"What else can we help you with?" = "Vad mer kan vi hjälpa dig med?"; -"Tap here if the answer was not helpful to you" = "Tryck här om svaret inte var till hjälp"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Det var tråkigt att höra. Kan du berätta lite mer om ditt problem?"; -"Service Rating" = "Servicerecension"; -"Your email" = "Din e-post"; -"Email invalid" = "Ogiltig e-post"; -"Could not fetch FAQs" = "Kunde inte hämta Vanliga frågor"; -"Thanks for rating us." = "Tack för att du betygsätter oss."; -"Download" = "Ladda ned"; -"Please enter a valid email" = "Skriv in en giltig e-post"; -"Message" = "Meddelande"; -"or" = "eller"; -"Decline" = "Avböj"; -"No" = "Nej"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Skärmbilden kunde inte skickas. Bilden är för stor, försök igen med en annan bild"; -"Hated it" = "Hatade den"; -"Stars" = "Stjärnor"; -"Your feedback has been received." = "Vi har tagit emot din feedback."; -"Dislike" = "Ogilla"; -"Preview" = "Granskning"; -"Book Now" = "Boka nu"; -"START A NEW CONVERSATION" = "STARTA EN NY KONVERSATION"; -"Your Rating" = "Din betygsättning"; -"No Internet!" = "Ingen internet!"; -"Invalid Entry" = "Ogiltig text"; -"Loved it" = "Älskade den"; -"Review on the App Store" = "Recensera i App Store"; -"Open Help" = "Öppna hjälp"; -"Search" = "Sök"; -"Tap here if you found this FAQ helpful" = "Tryck här om de vanliga frågorna var till hjälp"; -"Star" = "Stjärna"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Tryck här om svaret var till hjälp"; -"Report a problem" = "Rapportera ett fel"; -"YES, THANKS!" = "JA, TACK!"; -"Was this helpful?" = "Var det här användbart?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Ditt meddelande skickades inte. Peka på \"Försök igen\" för att skicka det här meddelandet?"; -"Suggestions" = "Förslag"; -"No FAQs found" = "Inga Vanliga frågor funna"; -"Done" = "Klart"; -"Opening Gallery..." = "Öppnar galleri ..."; -"You rated the service with" = "Du gav tjänsten"; -"Cancel" = "Avbryt"; -"Loading..." = "Laddar ..."; -"Read FAQs" = "Läs vanliga frågor"; -"Thanks for messaging us!" = "Tack för att du skrev till oss!"; -"Try Again" = "Försök igen"; -"Send Feedback" = "Skicka feedback"; -"Your Name" = "Ditt namn"; -"Please provide a name." = "Ange ett namn"; -"FAQ" = "Vanliga frågor"; -"Describe your problem" = "Beskriv ditt problem"; -"How can we help?" = "Hur kan vi hjälpa till?"; -"Help about" = "Hjälp med"; -"We could not fetch the required data" = "Vi kunde inte hämta de data som krävs"; -"Name" = "Namn"; -"Sending failed!" = "Det gick inte att skicka!"; -"You didn't find this helpful." = "Du tyckte inte att det var användbart."; -"Attach a screenshot of your problem" = "Bifoga en skärmbild av ditt problem"; -"Mark as read" = "Markera som läst"; -"Name invalid" = "Ogiltigt namn"; -"Yes" = "Ja"; -"What's on your mind?" = "Vad har du på hjärtat?"; -"Send a new message" = "Skicka ett nytt meddelande"; -"Questions that may already have your answer" = "Frågor som kanske redan har svar"; -"Attach a photo" = "Bifoga ett foto"; -"Accept" = "Acceptera"; -"Your reply" = "Ditt svar"; -"Inbox" = "Inkorg"; -"Remove attachment" = "Ta bort bilaga"; -"Could not fetch message" = "Kunde inte hämta meddelande"; -"Read FAQ" = "Läs vanliga frågor"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Tyvärr har den här konversationen stängts på grund av inaktivitet. Starta gärna en ny konversation med oss."; -"%d new messages from Support" = "%d nya meddelanden från Supporten"; -"Ok, Attach" = "Ok, bifoga"; -"Send" = "Skicka"; -"Screenshot size should not exceed %.2f MB" = "Skärmbildens storlek får inte överskrida %.2f MB"; -"Information" = "Information"; -"Issue ID" = "Ärende-id"; -"Tap to copy" = "Tryck för att kopiera"; -"Copied!" = "Kopierat!"; -"We couldn't find an FAQ with matching ID" = "Vi kunde inte hitta någon vanlig fråga med detta id"; -"Failed to load screenshot" = "Kunde inte ladda skärmbild"; -"Failed to load video" = "Kunde inte ladda video"; -"Failed to load image" = "Kunde inte ladda bild"; -"Hold down your device's power and home buttons at the same time." = "Håll in din enhets ström- och hemknapp samtidigt."; -"Please note that a few devices may have the power button on the top." = "Obs! En del enheter kan ha strömknappen på ovansidan."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Återvänd till det här meddelandet när det är gjort och tryck på \"Ok, bifoga\" för att bifoga bilden."; -"Okay" = "OK"; -"We couldn't find an FAQ section with matching ID" = "Vi kunde inte hitta någon Vanliga frågor-sektion med detta id"; - -"GIFs are not supported" = "GIF stöds inte"; - diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ta.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ta.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 17f043b16f02..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/ta.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "நீங்கள் தேடுவது கிடைக்கவில்லையா?"; -"Rate App" = "பயன்பாட்டை மதிப்பிடுக"; -"We\'re happy to help you!" = "உங்களுக்கு உதவக் காத்திருக்கிறோம்!"; -"Did we answer all your questions?" = "எல்லா கேள்விகளுக்கும் பதிலளித்துள்ளோமா?"; -"Remind Later" = "பின்னர் நினைவூட்டு"; -"Your message has been received." = "உங்கள் செய்தி வந்து சேர்ந்தது."; -"Message send failure." = "செய்தியை அனுப்ப முடியவில்லை."; -"What\'s your feedback about our customer support?" = "எங்கள் வாடிக்கையாளர் ஆதரவைப் பற்றி உங்கள் கருத்து?"; -"Take a screenshot on your iPhone" = "உங்கள் iPhone இல் ஸ்கிரீன்ஷாட் எடுக்கவும்"; -"Learn how" = "எப்படி என்று அறிந்து கொள்க"; -"Take a screenshot on your iPad" = "உங்கள் iPad இல் ஸ்கிரீன்ஷாட் எடுக்கவும்"; -"Your email(optional)" = "உங்கள் மின்னஞ்சல் (விரும்பினால்)"; -"Conversation" = "உரையாடல்"; -"View Now" = "இப்போது காட்டு"; -"SEND ANYWAY" = "எப்படியிருந்தாலும் அனுப்பு"; -"OK" = "சரி"; -"Help" = "உதவி"; -"Send message" = "செய்தியை அனுப்பு"; -"REVIEW" = "மதிப்புரை"; -"Share" = "பகிர்"; -"Close Help" = "உதவியை மூடு"; -"Sending your message..." = "உங்கள் செய்தியை அனுப்புகிறது..."; -"Learn how to" = "எப்படி என்று அறிந்து கொள்க"; -"No FAQs found in this section" = "இந்தப் பிரிவில் கேள்வி பதில்கள் இல்லை"; -"Thanks for contacting us." = "எங்களைத் தொடர்பு கொண்டதற்கு நன்றி."; -"Chat Now" = "இப்போது அரட்டையடி"; -"Buy Now" = "இப்போது வாங்கு"; -"New Conversation" = "புதிய உரையாடல்"; -"Please check your network connection and try again." = "உங்கள் இணைய இணைப்பைச் சரிபார்த்து மீண்டும் முயற்சி செய்யவும்."; -"New message from Support" = "ஆதரவு மையத்தில் இருந்து புதிய செய்தி"; -"Question" = "கேள்வி"; -"Type in a new message" = "புதிய செய்தியைத் தட்டச்சு செய்க"; -"Email (optional)" = "மின்னஞ்சல் (விரும்பினால்)"; -"Reply" = "பதிலளி"; -"CONTACT US" = "எங்களைத் தொடர்பு கொள்க"; -"Email" = "மின்னஞ்சல்"; -"Like" = "விரும்புகிறேன்"; -"Tap here if this FAQ was not helpful to you" = "இந்தக் கேள்வி பதில் உங்களுக்கு உதவியாக இல்லை என்றால் இங்கே தட்டவும்"; -"Any other feedback? (optional)" = "வேறு ஏதேனும் கருத்து உள்ளதா? (விரும்பினால்)"; -"You found this helpful." = "இது உதவியாக இருந்தது."; -"No working Internet connection is found." = "இணைய இணைப்பு எதுவும் பயன்பாட்டில் இல்லை."; -"No messages found." = "செய்திகள் இல்லை."; -"Please enter a brief description of the issue you are facing." = "நீங்கள் சந்திக்கும் பிரச்சனை குறித்து சுருக்கமான விளக்கத்தை உள்ளிடவும்.."; -"Shop Now" = "இப்போது ஷாப்பிங் செய்"; -"Close Section" = "பிரிவை மூடு"; -"Close FAQ" = "கேள்வி பதிலை மூடு"; -"Close" = "மூடு"; -"This conversation has ended." = "இந்த உரையாடல் முடிந்தது."; -"Send it anyway" = "எப்படியிருந்தாலும் அனுப்பு"; -"You accepted review request." = "மதிப்பாய்வுக் கோரிக்கையை ஏற்றுக் கொண்டீர்கள்."; -"Delete" = "நீக்கு"; -"What else can we help you with?" = "வேறு ஏதாவது உதவி தேவையா?"; -"Tap here if the answer was not helpful to you" = "பதில் உங்களுக்கு உதவியாக இல்லை என்றால் இங்கே தட்டவும்"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "அதற்காக வருந்துகிறோம். நீங்கள் சந்திக்கும் பிரச்சனை குறித்து மேலும் விரிவாகக் கூற முடியுமா?"; -"Service Rating" = "சேவை மதிப்பீடு"; -"Your email" = "உங்கள் மின்னஞ்சல்"; -"Email invalid" = "தவறான மின்னஞ்சல்"; -"Could not fetch FAQs" = "கேள்வி பதில்களைப் பெற முடியவில்லை"; -"Thanks for rating us." = "மதிப்பீடு வழங்கியதற்கு நன்றி."; -"Download" = "பதிவிறக்கு"; -"Please enter a valid email" = "சரியான மின்னஞ்சல் முகவரியை உள்ளிடவும்"; -"Message" = "செய்தி"; -"or" = "அல்லது"; -"Decline" = "நிராகரி"; -"No" = "இல்லை"; -"Screenshot could not be sent. Image is too large, try again with another image" = "ஸ்கிரீன்ஷாட்டை அனுப்ப முடியவில்லை. படம் மிகவும் சிறியதாக உள்ளது. மற்று படத்தைக் கொண்டு மீண்டும் முயற்சி செய்யவும்"; -"Hated it" = "பிடிக்கவில்லை"; -"Stars" = "நட்சத்திரங்கள்"; -"Your feedback has been received." = "உங்கள் கருத்து வந்து சேர்ந்தது."; -"Dislike" = "விரும்பவில்லை"; -"Preview" = "மாதிரிக்காட்சி"; -"Book Now" = "இப்போது முன்பதிவு செய்"; -"START A NEW CONVERSATION" = "புதிய உரையாடலைத் தொடங்கு"; -"Your Rating" = "உங்கள் மதிப்பீடு"; -"No Internet!" = "இணைப்பு இல்லை"; -"Invalid Entry" = "தவறான உள்ளீடு"; -"Loved it" = "பிடித்திருக்கிறது"; -"Review on the App Store" = "App Store இல் மதிப்புரை வழங்கவும்"; -"Open Help" = "உதவியைத் திற"; -"Search" = "தேடு"; -"Tap here if you found this FAQ helpful" = "இந்தக் கேள்வி பதில் உங்களுக்கு உதவியாக இருந்தால் இங்கே தட்டவும்"; -"Star" = "நட்சத்திரம்"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "இந்தப் பதில் உங்களுக்கு உதவியாக இருந்தால் இங்கே தட்டவும்"; -"Report a problem" = "சிக்கல் குறித்துப் புகாரளிக்கவும்"; -"YES, THANKS!" = "ஆம், நன்றி!"; -"Was this helpful?" = "இது உதவியாக இருந்ததா?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "உங்கள் செய்தி அனுப்பப்படவில்லை.இந்தச் செய்தியை அனுப்ப, \"மீண்டும் முயற்சி செய்\" என்பதைத் தட்டவும்?"; -"Suggestions" = "ஆலோசனைகள்"; -"No FAQs found" = "கேள்வி பதில்கள் இல்லை"; -"Done" = "முடிந்தது"; -"Opening Gallery..." = "கேலரியைத் திறக்கிறது..."; -"You rated the service with" = "சேவைக்கு நீங்கள் வழங்கிய மதிப்பீடு:"; -"Cancel" = "ரத்துசெய்"; -"Loading..." = "ஏற்றுகிறது..."; -"Read FAQs" = "கேள்வி பதிலைப் படிக்கவும்"; -"Thanks for messaging us!" = "செய்தி அனுப்பியதற்கு நன்றி!"; -"Try Again" = "மீண்டும் முயற்சி செய்யவும்"; -"Send Feedback" = "கருத்து அனுப்பு"; -"Your Name" = "உங்கள் பெயர்"; -"Please provide a name." = "பெயரை உள்ளிடவும்."; -"FAQ" = "கேள்வி பதில்"; -"Describe your problem" = "உங்கள் பிரச்சனையை விவரிக்கவும்"; -"How can we help?" = "எப்படி உதவ வேண்டும்?"; -"Help about" = "இதைப் பற்றிய உதவி"; -"We could not fetch the required data" = "தேவையான தரவைப் பெற முடியவில்லை"; -"Name" = "பெயர்"; -"Sending failed!" = "அனுப்ப முடியவில்லை!"; -"You didn't find this helpful." = "இது உதவியாக இல்லை."; -"Attach a screenshot of your problem" = "உங்கள் பிரச்சனையைக் காட்டும் ஸ்கிரீன்ஷாட்டை இணைக்கவும்"; -"Mark as read" = "படித்ததாகக் குறி"; -"Name invalid" = "தவறான பெயர்"; -"Yes" = "ஆம்"; -"What's on your mind?" = "என்ன நினைக்கிறீர்கள்?"; -"Send a new message" = "புதிய செய்தியை அனுப்பவும்"; -"Questions that may already have your answer" = "ஏற்கனவே பதிலளிக்கப்பட்டுள்ள கேள்விகள்"; -"Attach a photo" = "படத்தை இணை"; -"Accept" = "ஏற்கவும்"; -"Your reply" = "உங்கள் பதில்"; -"Inbox" = "இன்பாக்ஸ்"; -"Remove attachment" = "இணைப்பை அகற்று"; -"Could not fetch message" = "செய்தியைப் பெற முடியவில்லை"; -"Read FAQ" = "கேள்வி பதிலைப் படிக்கவும்"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "மன்னிக்கவும் இந்த உரையாடல் செயல்படாமல் இருந்ததால் மூடப்பட்டது. எங்கள் முகவர்களுடன் புதிய உரையாடலைத் தொடங்கவும்."; -"%d new messages from Support" = "%d ஆதரவு மையத்தில் இருந்து புதிய செய்திகள்"; -"Ok, Attach" = "சரி, இணை"; -"Send" = "அனுப்பு"; -"Screenshot size should not exceed %.2f MB" = "ஸ்கிரீன்ஷாட்டின் அளவு %.2f MB க்கு மிகாமல் இருக்க வேண்டும்"; -"Information" = "தகவல்"; -"Issue ID" = "சிக்கல் ஐடி"; -"Tap to copy" = "நகலெடுக்க தட்டவும்"; -"Copied!" = "நகலெடுக்கப்பட்டது!"; -"We couldn't find an FAQ with matching ID" = "பொருந்தும் ஐடியுடன் FAQஐ கண்டறிய முடியவில்லை"; -"Failed to load screenshot" = "ஸ்கிரீன்ஷாட்டை ஏற்றுதல் தோல்வி"; -"Failed to load video" = "வீடியோவை ஏற்றுதல் தோல்வி"; -"Failed to load image" = "படத்தை ஏற்றுதல் தோல்வி"; -"Hold down your device's power and home buttons at the same time." = "உங்கள் சாதனத்தின் பவர் மற்றும் முகப்பு பொத்தான்களை ஒரே நேரத்தில் அழுத்திப் பிடிக்கவும்."; -"Please note that a few devices may have the power button on the top." = "சில சாதனங்களில் பவர் பொத்தான் மேலே இருக்கும் என்பதை நினைவில் கொள்ளவும்."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "முடித்தவுடன், இந்த உரையாடலுக்குத் திரும்பி, அதை இணைப்பதற்கு “சரி, இணைக்கவும்” என்பதை தட்டவும்."; -"Okay" = "சரி"; -"We couldn't find an FAQ section with matching ID" = "பொருந்தும் ஐடியுடன் FAQ பிரிவைக் கண்டறிய முடியவில்லை"; - -"GIFs are not supported" = "GIF கள் ஆதரிக்கப்படவில்லை"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/te.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/te.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 1889f69a31a3..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/te.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "మీరు ఎదురుచూస్తున్న దానిని కనుక్కోలేము?"; -"Rate App" = "ప్రోగ్రాంను రేట్‌ చేయండి"; -"We\'re happy to help you!" = "మీకు సహాయపడేందుకు మేము సంతోషిస్తున్నాము!"; -"Did we answer all your questions?" = "మేము మీ ప్రశ్నలకన్నింటికీ సమాధానమిచ్చామా?"; -"Remind Later" = "తరువాత గుర్తు చేయండి"; -"Your message has been received." = "మీ సందేశం అందుకోబడినది."; -"Message send failure." = "సందేశాన్ని పంపడంలో వైఫల్యం."; -"What\'s your feedback about our customer support?" = "మా కస్టమర్‌ సపోర్డు గురించి మీ అభిప్రాయం ఏమిటి?"; -"Take a screenshot on your iPhone" = "మీ ఐఫోన్‌లో ఒక స్క్రీన్‌షాట్‌ను తీసుకోండి"; -"Learn how" = "ఎలానో తెలుసుకోండి"; -"Take a screenshot on your iPad" = "మీ ఐప్యాడ్‌లో ఒక స్క్రీన్‌షాట్‌ను తీసుకోండి"; -"Your email(optional)" = "మీ ఇమెయిల్(ఐచ్ఛికం)"; -"Conversation" = "సంభాషణ"; -"View Now" = "ఇప్పుడు వీక్షించండి"; -"SEND ANYWAY" = "అయినా సరే పంపండి"; -"OK" = "సరే"; -"Help" = "సహాయం"; -"Send message" = "సందేశాన్ని పంపండి"; -"REVIEW" = "సమీక్షించండి"; -"Share" = "పంచుకోండి"; -"Close Help" = "సహాయంను మూసేయండి"; -"Sending your message..." = "మీ సందేశాన్ని పంపుతున్నాము..."; -"Learn how to" = "ఎలా చేయాలో తెలుసుకోండి"; -"No FAQs found in this section" = "ఈ విభాగంలో ఎఫ్‌ఎక్యూలు ఏవీ లేవు"; -"Thanks for contacting us." = "మమ్మల్ని సంప్రదించినందుకు ధన్యవాదములు."; -"Chat Now" = "ఇప్పుడు చాట్ చేయండి"; -"Buy Now" = "ఇప్పుడు కొనుగోలు చేయండి"; -"New Conversation" = "కొత్త సంభాషణ"; -"Please check your network connection and try again." = "దయచేసి మీ నెట్‌వర్క్‌ కనెక్షన్‌ను చెక్ చేసుకుని, మళ్ళీ ప్రయత్నించండి."; -"New message from Support" = "సపోర్ట్‌ నుండి కొత్త సందేశం"; -"Question" = "ప్రశ్న"; -"Type in a new message" = "ఒక కొత్త సందేశాన్ని టైప్ చేయండి"; -"Email (optional)" = "ఇమెయిల్‌ (ఐచ్ఛికం)"; -"Reply" = "ప్రత్యుత్తరం"; -"CONTACT US" = "మమ్మల్ని సంప్రదించండి"; -"Email" = "ఇమెయిల్‌"; -"Like" = "ఇష్టపడండి"; -"Tap here if this FAQ was not helpful to you" = "ఈ FAQ గనక మీకు సహాయకరంగా లేకుంటే ఇక్కడ ట్యాప్ చేయండి"; -"Any other feedback? (optional)" = "ఇంకేదైనా అభిప్రాయం? (ఐచ్ఛికం)"; -"You found this helpful." = "మీకు ఇది సహాయకరంగా అనిపించింది."; -"No working Internet connection is found." = "పని చేస్తున్న ఇంటర్నెట్ కనెక్షన్‌ ఏదీ అందలేదు."; -"No messages found." = "ఏ సందేశాలు లేవు."; -"Please enter a brief description of the issue you are facing." = "మీరు ఎదుర్కొంటున్న సమస్యను గురించి క్లుప్తమైన వివరణను ప్రవేశపెట్టండి."; -"Shop Now" = "ఇప్పుడు షాప్ చేయండి"; -"Close Section" = "విభాగాన్ని మూసేయండి"; -"Close FAQ" = "FAQని మూసేయండి"; -"Close" = "మూసివేయండి"; -"This conversation has ended." = "ఈ సంభాషణ ముగిసినది."; -"Send it anyway" = "అయినా సరే దానిని పంపండి"; -"You accepted review request." = "సమీక్ష అభ్యర్ధనను మీరు ఆమోదించారు."; -"Delete" = "తొలగించండి"; -"What else can we help you with?" = "మీకు మేము మరింకే విధంగా సహాయపడగలము?"; -"Tap here if the answer was not helpful to you" = "సమాధానం గనక మీకు సహాయకరంగా లేకుంటే ఇక్కడ ట్యాప్ చేయండి"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "మీరు ఎదుర్కొంటున్న సమస్యను విని చింతిస్తున్నాము. దాని గురించి మీరు మాకు మరి కొంత సమాచారాన్ని అందించగలరా?"; -"Service Rating" = "సేవకు రేటింగు"; -"Your email" = "మీ ఇమెయిల్"; -"Email invalid" = "ఇమెయిల్‌ చెల్లనిది"; -"Could not fetch FAQs" = "ఎఫ్‌ఎక్యూలను తీసుకురాలేకపోయాము"; -"Thanks for rating us." = "మమ్మల్ని రేట్ చేసినందుకు ధన్యవాదాలు."; -"Download" = "డౌన్‌లోడ్‌ చేయండి"; -"Please enter a valid email" = "దయచేసి ఒక సరైన ఇమెయిల్‌ను ప్రవేశపెట్టండి"; -"Message" = "సందేశం"; -"or" = "లేదా"; -"Decline" = "నిరాకరించండి"; -"No" = "కాదు"; -"Screenshot could not be sent. Image is too large, try again with another image" = "స్క్రీన్‌షాట్‌ను పంపించలేము. చిత్రం చాలా పెద్దగా ఉన్నది, మరొక చిత్రంతో మళ్ళీ ప్రయత్నించండి."; -"Hated it" = "అసహ్యించుకుంటున్నాము"; -"Stars" = "స్టార్‌లు"; -"Your feedback has been received." = "మీ అభిప్రాయం అందుకోబడినది."; -"Dislike" = "అయిష్టతను చూపండి"; -"Preview" = "ప్రివ్యూ"; -"Book Now" = "ఇప్పుడు బుక్‌ చేయండి"; -"START A NEW CONVERSATION" = "ఒక కొత్త సంభాషణను ఆరంభించండి"; -"Your Rating" = "మీ రేటింగు"; -"No Internet!" = "ఇంటర్నెట్‌ లేదు!"; -"Invalid Entry" = "చెల్లని నమోదు"; -"Loved it" = "ఇష్టపడుతున్నాము"; -"Review on the App Store" = "యాప్‌ స్టోర్‌ మీద సమీక్షించండి"; -"Open Help" = "సహాయాన్ని తెరవండి"; -"Search" = "వెతకండి"; -"Tap here if you found this FAQ helpful" = "ఈ FAQ గనక మీకు సహాయకరంగా అనిపిస్తే ఇక్కడ ట్యాప్ చేయండి"; -"Star" = "స్టార్‌"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "సమాధానం గనక మీకు సహాయకరంగా అనిపిస్తే ఇక్కడ ట్యాప్ చేయండి"; -"Report a problem" = "ఒక సమస్యను నివేదించండి"; -"YES, THANKS!" = "అవును, ధన్యవాదాలు!"; -"Was this helpful?" = "ఇది సహాయకరంగా ఉందా?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "మీ సందేశం పంపబడలేదు. ఈ సందేశాన్ని పంపేందుకు \"మళ్ళీ ప్రయత్నించండి\"ని ట్యాప్ చేయాలా?"; -"Suggestions" = "సలహాలు"; -"No FAQs found" = "ఎఫ్‌ఎక్యూలు ఏవీ దొరకలేదు"; -"Done" = "అయ్యింది"; -"Opening Gallery..." = "గ్యాలరీని తెరుస్తున్నాము..."; -"You rated the service with" = "మీరు సర్వీసును దీనితో రేట్ చేశారు"; -"Cancel" = "రద్దుచెయ్యండి"; -"Loading..." = "లోడ్‌ అవుతున్నది..."; -"Read FAQs" = "FAQలను చదవండి"; -"Thanks for messaging us!" = "మాకు సందేశాన్ని పంపినందుకు ధన్యవాదాలు!"; -"Try Again" = "మళ్ళీ ప్రయత్నించండి"; -"Send Feedback" = "అభిప్రాయాన్ని పంపండి"; -"Your Name" = "మీ పేరు"; -"Please provide a name." = "ఒక పేరును దయచేసి ప్రవేశపెట్టండి."; -"FAQ" = "ఎఫ్‌ఎక్యూ"; -"Describe your problem" = "మీ సమస్యను విశదపరచండి"; -"How can we help?" = "మేమెలా సహాయపడగలము?"; -"Help about" = "సహాయం గురించి"; -"We could not fetch the required data" = "కావలసిన సమాచారాన్ని మేము తీసుకురాలేకపోయాము"; -"Name" = "పేరు"; -"Sending failed!" = "పంపడం విఫలమైంది!"; -"You didn't find this helpful." = "మీకు ఇది సహాయకరంగా అనిపించలేదు."; -"Attach a screenshot of your problem" = "మీ సమస్యకు సంబంధించిన ఒక స్క్రీన్‌షాట్‌ను జోడించండి"; -"Mark as read" = "చదివినట్లుగా గుర్తు పెట్టండి"; -"Name invalid" = "పేరు చెల్లనిది"; -"Yes" = "అవును"; -"What's on your mind?" = "మీరు ఏమనుకుంటున్నారు?"; -"Send a new message" = "ఒక కొత్త సందేశాన్ని పంపండి"; -"Questions that may already have your answer" = "మీ సమాధానాలను ఇప్పటికే కలిగి ఉన్న ప్రశ్నలు"; -"Attach a photo" = "ఒక ఫోటోను జతచేయండి"; -"Accept" = "సమ్మతించండి"; -"Your reply" = "మీ ప్రత్యుత్తరం"; -"Inbox" = "ఇన్‌బాక్స్‌"; -"Remove attachment" = "అటాచ్‌మెంట్‌ను తొలగించండి"; -"Could not fetch message" = "సందేశాన్ని పొందలేకపోయాము"; -"Read FAQ" = "FAQ ను చదవండి"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "క్షమించాలి! స్తబ్దత కారణంగా ఈ సంభాషణ ముగించబడింది. మా ఏజెంట్లతో దయచేసి ఒక కొత్త సంభాషణను ఆరంభించండి."; -"%d new messages from Support" = "%d సపోర్ట్‌ నుండి కొత్త సందేశాలు"; -"Ok, Attach" = "సరే, జత చేయండి"; -"Send" = "పంపండి"; -"Screenshot size should not exceed %.2f MB" = "స్క్రీన్‌షాట్‌ పరిమాణం %.2f MB కి మించరాదు"; -"Information" = "సమాచారం"; -"Issue ID" = "సమస్య ఐడి"; -"Tap to copy" = "కాపీ చేసేందుకు ట్యాప్‌ చేయండి"; -"Copied!" = "కాపీ చేేయబడింది!"; -"We couldn't find an FAQ with matching ID" = "IDకి సరిపోయే FAQ ను మేము కనుగొనలేకపోయాము"; -"Failed to load screenshot" = "స్క్రీన్‌షాట్‌ లోడ్ చేయడంలో విఫలమైంది"; -"Failed to load video" = "వీడియ లోడ్ చేయడంలో విఫలమైంది"; -"Failed to load image" = "ఇమేజ్‌ని లోడ్ చేయడంలో విఫలమైంది"; -"Hold down your device's power and home buttons at the same time." = "మీ పరికరపు పవర్ మరియు హోం బటన్‌లను ఒకేసారి నొక్కి పట్టుకోండి."; -"Please note that a few devices may have the power button on the top." = "కొన్ని పరికరాలకు పవర్ బటన్‌ పై భాగంలో ఉంటుందని దయచేసి గమనించండి."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "అది అయ్యాక, ఈ సంభాషణకు తిరిగి వచ్చి. \"సరే, జతచేయండి\" మీద ట్యాప్ చేసి దానిని జతచేయండి."; -"Okay" = "సరే"; -"We couldn't find an FAQ section with matching ID" = "సరేిపోలే ఐడితో FAQ విభాగాన్ని మేము కనుగొనలేకపోయాము"; - -"GIFs are not supported" = "GIF లకి మద్దతు లేదు"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/th.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/th.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 87a2372b575b..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/th.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "ไม่พบสิ่งที่คุณกำลังค้นหาใช่หรือไม่"; -"Rate App" = "ให้คะแนนแอป"; -"We\'re happy to help you!" = "เรายินดีให้ความช่วยเหลือคุณ!"; -"Did we answer all your questions?" = "เราได้ตอบคำถามทั้งหมดของคุณแล้วหรือไม่"; -"Remind Later" = "เตือนภายหลัง"; -"Your message has been received." = "ได้รับข้อความของคุณแล้ว"; -"Message send failure." = "การส่งข้อความล้มเหลว"; -"What\'s your feedback about our customer support?" = "คุณมีคำติชมใดๆ เกี่ยวกับฝ่ายสนับสนุนลูกค้าของเราหรือไม่"; -"Take a screenshot on your iPhone" = "ถ่ายภาพหน้าจอ iPhone ของคุณ"; -"Learn how" = "เรียนรู้วิธี"; -"Name invalid" = "ชื่อไม่ถูกต้อง"; -"Take a screenshot on your iPad" = "ถ่ายภาพหน้าจอ iPad ของคุณ"; -"Your email(optional)" = "อีเมลของคุณ(ทางเลือก)"; -"Conversation" = "การสนทนา"; -"View Now" = "ดูตอนนี้"; -"SEND ANYWAY" = "ยืนยันที่จะส่ง"; -"OK" = "ตกลง"; -"Help" = "วิธีใช้"; -"Send message" = "ส่งข้อวาม"; -"REVIEW" = "ให้คำวิจารณ์"; -"Share" = "แชร์"; -"Close Help" = "ปิดความช่วยเหลือ"; -"Sending your message..." = "กำลังส่งข้อความของคุณ..."; -"Learn how to" = "เรียนรู้วิธี"; -"No FAQs found in this section" = "ไม่เจอคำถามที่พบบ่อยในหัวข้อนี้"; -"Thanks for contacting us." = "ขอบคุณสำหรับการติดต่อหาเรา"; -"Chat Now" = "แชทตอนนี้"; -"Buy Now" = "ซื้อตอนนี้"; -"New Conversation" = "การสนทนาใหม่"; -"Please check your network connection and try again." = "โปรดตรวจสอบการเชื่อมต่อเครือข่ายของคุณ และลองอีกครั้ง"; -"New message from Support" = "ข้อความใหม่จากฝ่ายช่วยเหลือลูกค้า"; -"Question" = "คำถาม"; -"Type in a new message" = "พิมพ์ข้อความใหม่ของคุณ"; -"Email (optional)" = "อีเมล (ทางเลือก)"; -"Reply" = "ตอบกลับ"; -"CONTACT US" = "ติดต่อเรา"; -"Email" = "อีเมล"; -"Like" = "ถูกใจ"; -"Tap here if this FAQ was not helpful to you" = "แตะที่นี่หากคำถามที่พบบ่อยนี้ไม่มีประโยชน์กับคุณ"; -"Any other feedback? (optional)" = "มีคำติชมอื่นใดหรือไม่ (ทางเลือก)"; -"You found this helpful." = "คุณเห็นว่า นี่เป็นเรื่องที่เป็นประโยชน์"; -"No working Internet connection is found." = "ไม่พบการเชื่อมต่อกับอินเทอร์เน็ต"; -"No messages found." = "ไม่พบข้อความ"; -"Please enter a brief description of the issue you are facing." = "โปรดป้อนคำอธิบายถึงปัญหาที่คุณประสบอยู่โดยย่อ"; -"Shop Now" = "เลือกซื้อตอนนี้"; -"Close Section" = "ปิดส่วน"; -"Close FAQ" = "ปิดคำถามที่พบบ่อย"; -"Close" = "ปิด"; -"This conversation has ended." = "การสนทนานี้จบลงแล้ว"; -"Send it anyway" = "ยืนยันที่จะส่ง"; -"You accepted review request." = "คุณยอมรับคำขอการให้คำวิจารณ์แล้ว"; -"Delete" = "ลบ"; -"What else can we help you with?" = "มีเรื่องอื่นใดที่เราจะช่วยคุณได้อีกหรือไม่"; -"Tap here if the answer was not helpful to you" = "แตะที่นี่หากคำตอบไม่มีประโยชน์กับคุณ"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "ขออภัยสำหรับเรื่องดังกล่าว โปรดแจ้งเราเพิ่มเติมเกี่ยวกับปัญหาที่คุณกำลังประสบอยู่ได้หรือไม่"; -"Service Rating" = "การให้คะแนนการบริการ"; -"Your email" = "อีเมลของคุณ"; -"Email invalid" = "อีเมลไม่ถูกต้อง"; -"Could not fetch FAQs" = "ไม่สามารถดึงข้อมูลคำถามที่ถามบ่อยได้"; -"Thanks for rating us." = "ขอบคุณสำหรับการให้คะแนนแก่เรา"; -"Download" = "ดาวน์โหลด"; -"Please enter a valid email" = "โปรดใส่อีเมลที่ถูกต้อง"; -"Message" = "ข้อความ"; -"or" = "หรือ"; -"Decline" = "ปฏิเสธ"; -"No" = "ไม่ใช่"; -"Screenshot could not be sent. Image is too large, try again with another image" = "ไม่สามารถส่งภาพถ่ายหน้าจอได้ ภาพถ่ายมีขนาดใหญ่เกินไป ลองอีกครั้งด้วยภาพถ่ายอื่น"; -"Hated it" = "ฉันไม่พอใจ"; -"Stars" = "ดาว"; -"Your feedback has been received." = "ได้รับข้อคิดเห็นของคุณแล้ว"; -"Dislike" = "เลิกถูกใจ"; -"Preview" = "เรียกดูตัวอย่าง"; -"Book Now" = "จองตอนนี้"; -"START A NEW CONVERSATION" = "เริ่มต้นการสนทนาใหม่"; -"Your Rating" = "การให้คะแนนของคุณ"; -"No Internet!" = "ไม่มีการเชื่อมต่ออินเทอร์เน็ต!"; -"Invalid Entry" = "ป้อนข้อมูลไม่ถูกต้อง"; -"Loved it" = "ฉันพอใจ"; -"Review on the App Store" = "คำวิจารณ์บน App Store"; -"Open Help" = "เปิดความช่วยเหลือ"; -"Search" = "ค้นหา"; -"Tap here if you found this FAQ helpful" = "แตะที่นี่หากคำถามที่พบบ่อยนี้มีประโยชน์กับคุณ"; -"Star" = "ดาว"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "แตะที่นี่หากคำตอบนี้มีประโยชน์กับคุณ"; -"Report a problem" = "รายงานปัญหา"; -"YES, THANKS!" = "ใช่ ขอบคุณ!"; -"Was this helpful?" = "สิ่งนี้มีประโยชน์หรือไม่"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "ข้อความของคุณยังไม่ถูกส่งออกไป แตะ \"ลองอีกครั้ง\" เพื่อส่งข้อความนี้หรือไม่"; -"Suggestions" = "ข้อแนะนำ"; -"No FAQs found" = "ไม่พบคำถามที่ถามบ่อย"; -"Done" = "เสร็จแล้ว"; -"Opening Gallery..." = "กำลังเปิดแกลลอรี..."; -"You rated the service with" = "คุณได้ให้คะแนนบริการนี้"; -"Cancel" = "ยกเลิก"; -"Loading..." = "กำลังโหลด..."; -"Read FAQs" = "อ่านคำถามที่พบบ่อย"; -"Thanks for messaging us!" = "ขอบคุณสำหรับการส่งข้อความถึงเรา!"; -"Try Again" = "ลองอีกครั้ง"; -"Send Feedback" = "ส่งคำติชม"; -"Your Name" = "ชื่อของคุณ"; -"Please provide a name." = "โปรดป้อนชื่อ"; -"FAQ" = "คำถามที่ถามบ่อย"; -"Describe your problem" = "อธิบายปัญหาของคุณ"; -"How can we help?" = "เราจะช่วยเหลือคุณได้อย่างไรบ้าง"; -"Help about" = "ความช่วยเหลือเกี่ยวกับ"; -"We could not fetch the required data" = "เราไม่สามารถดึงข้อมูลที่จำเป็นได้"; -"Name" = "ชื่อ"; -"Sending failed!" = "การส่งล้มเหลว!"; -"You didn't find this helpful." = "คุณไม่เห็นว่า นี่เป็นเรื่องที่เป็นประโยชน์"; -"Attach a screenshot of your problem" = "แนบภาพหน้าจอที่เกี่ยวข้องกับปัญหาของคุณ"; -"Mark as read" = "ทำเครื่องหมายว่าอ่านแล้ว"; -"Yes" = "ใช่"; -"What's on your mind?" = "คุณกำลังคิดถึงเรื่องใด"; -"Send a new message" = "ส่งข้อความใหม่"; -"Questions that may already have your answer" = "คำถามที่อาจมีคำตอบให้กับคุณแล้ว"; -"Attach a photo" = "แนบรูปภาพ"; -"Accept" = "ยอมรับ"; -"Your reply" = "การตอบกลับของคุณ"; -"Inbox" = "กล่องข้อความ"; -"Remove attachment" = "ลบไฟล์แนบ"; -"Could not fetch message" = "ไม่สามารถเรียกข้อความ"; -"Read FAQ" = "อ่านคำถามที่พบบ่อย"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "ขออภัย! การสนทนานี้จะปิดลงเนื่องจากไม่มีการใช้งาน โปรดเริ่มต้นการสนทนาใหม่กับตัวแทนของเรา"; -"%d new messages from Support" = "%d ข้อความใหม่จากฝ่ายช่วยเหลือลูกค้า"; -"Ok, Attach" = "ตกลง แนบ"; -"Send" = "ส่ง"; -"Screenshot size should not exceed %.2f MB" = "ขนาดของภาพหน้าจอไม่ควรเกิน %.2f MB"; -"Information" = "ข้อมูล"; -"Issue ID" = "ออกไอดี"; -"Tap to copy" = "แตะเพื่อคัดลอก"; -"Copied!" = "คัดลอกแล้ว"; -"We couldn't find an FAQ with matching ID" = "เราค้นหาคำถามที่พบบ่อยที่มี ID ตรงกันไม่พบ"; -"Failed to load screenshot" = "ล้มเหลวในการโหลดภาพสกรีนช็อต"; -"Failed to load video" = "ล้มเหลวในการโหลดวิดีโอ"; -"Failed to load image" = "ล้มเหลวในการโหลดภาพ"; -"Hold down your device's power and home buttons at the same time." = "กดปุ่มเปิด/ปิดและโฮมค้างไว้ในเวลาเดียวกัน"; -"Please note that a few devices may have the power button on the top." = "โปรดทราบว่าอุปกรณ์บางเครื่องอาจมีปุ่มเปิด/ปิดอยู่ด้านบน"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "เมื่อเสร็จสิ้นแล้ว ให้กลับมาที่การสนทนานี้แล้วแตะที่ \"ตกลง แนบ\" เพื่อแนบไฟล์"; -"Okay" = "ตกลง"; -"We couldn't find an FAQ section with matching ID" = "เราค้นหาส่วนคำถามที่พบบ่อยที่มี ID ตรงกันไม่พบ"; - -"GIFs are not supported" = "GIF ไม่ได้รับการสนับสนุน"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/tr.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/tr.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 789160d75b0d..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/tr.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Aradığını bulamıyoruz?"; -"Rate App" = "Uygulamayı Derecelendir"; -"We\'re happy to help you!" = "Yardımcı olmaktan memnuniyet duyarız!"; -"Did we answer all your questions?" = "Tüm sorularına yanıt verdik mi?"; -"Remind Later" = "Daha Sonra Hatırlat"; -"Your message has been received." = "Mesajın alındı."; -"Message send failure." = "Mesaj gönderme hatası."; -"What\'s your feedback about our customer support?" = "Müşteri desteğimiz ile ilgili geri bildirimin nedir?"; -"Take a screenshot on your iPhone" = "iPhone'nundan bir ekran görüntüsü al"; -"Learn how" = "Şimdi öğren"; -"Name invalid" = "İsim geçersiz"; -"Take a screenshot on your iPad" = "iPad'inden bir ekran görüntüsü al"; -"Your email(optional)" = "E-posta adresiniz (isteğe bağlı)"; -"Conversation" = "Konuşma"; -"View Now" = "Şimdi Görüntüle"; -"SEND ANYWAY" = "GENE DE GÖNDER"; -"OK" = "Tamam"; -"Help" = "Yardım"; -"Send message" = "Mesajı Gönder"; -"REVIEW" = "GÖZDEN GEÇİR"; -"Share" = "Paylaş"; -"Close Help" = "Yardımı Kapat"; -"Sending your message..." = "Mesajın gönderiliyor..."; -"Learn how to" = "Nasıl yapılacağını şimdi öğren"; -"No FAQs found in this section" = "Bu bölümde SSS bulunamadı"; -"Thanks for contacting us." = "Bizimle irtibata geçtiğin için teşekkür ederiz."; -"Chat Now" = "Sohbet Et"; -"Buy Now" = "Şimdi Satın Al"; -"New Conversation" = "Yeni Konuşma"; -"Please check your network connection and try again." = "Lütfen ağ bağlantını kontrol et ve yeniden dene."; -"New message from Support" = "Destek bölümünden yeni mesaj"; -"Question" = "Soru"; -"Type in a new message" = "Yeni bir mesaj yazın"; -"Email (optional)" = "E-posta (isteğe bağlı)"; -"Reply" = "Yanıtla"; -"CONTACT US" = "BİZE ULAŞIN"; -"Email" = "E-posta"; -"Like" = "Beğen"; -"Tap here if this FAQ was not helpful to you" = "Bu SSS yanıtını yararlı bulmadıysanız buraya dokunun"; -"Any other feedback? (optional)" = "Başka bir geri bildirim var mı? (optional)"; -"You found this helpful." = "Bunu yararlı buldun."; -"No working Internet connection is found." = "Çalışan bir İnternet bağlantısı bulunamadı."; -"No messages found." = "Mesaj bulunamadı."; -"Please enter a brief description of the issue you are facing." = "Lütfen karşılaştığın sorunun kısa bir tanımlamasını gir."; -"Shop Now" = "Şimdi Alışveriş Yap"; -"Close Section" = "Bölümü Kapat"; -"Close FAQ" = "SSS'ı Kapat"; -"Close" = "Kapat"; -"This conversation has ended." = "Bu konuşma son erdi."; -"Send it anyway" = "Her şekilde gönder"; -"You accepted review request." = "Gözden geçirme isteğini kabul ettin."; -"Delete" = "Sil"; -"What else can we help you with?" = "Başka nasıl yardımcı olabiliriz?"; -"Tap here if the answer was not helpful to you" = "Yanıt yardımcı olmadıysa buraya dokunun"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Bunu duyduğumuza üzüldük. Sorunla ilgili bize biraz daha fazla bilgi verebilir misin?"; -"Service Rating" = "Hizmet Değerlendirmesi"; -"Your email" = "E-posta adresiniz"; -"Email invalid" = "E-posta geçersiz"; -"Could not fetch FAQs" = "SSS getirilemedi"; -"Thanks for rating us." = "Bizi derecelendirdiğin için teşekkür ederiz."; -"Download" = "İndir"; -"Please enter a valid email" = "Lütfen geçerli bir e-posta adresi gir"; -"Message" = "Mesaj"; -"or" = "veya"; -"Decline" = "Reddet"; -"No" = "Hayır"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Ekran görüntüsü gönderilemedi. Görüntü çok büyük, başka bir görüntü ile yeniden dene"; -"Hated it" = "Nefret ettim"; -"Stars" = "Yıldız"; -"Your feedback has been received." = "Geri bildirimin alındı."; -"Dislike" = "Beğenme"; -"Preview" = "Ön izleme"; -"Book Now" = "Şimdi Rezerve Et"; -"START A NEW CONVERSATION" = "YENİ BİR KONUŞMAYA BAŞLA"; -"Your Rating" = "Derecelendirmen"; -"No Internet!" = "İnternet yok!"; -"Invalid Entry" = "Geçersiz giriş"; -"Loved it" = "Sevdim"; -"Review on the App Store" = "App Store'da gözden geçir"; -"Open Help" = "Yardımı Aç"; -"Search" = "Ara"; -"Tap here if you found this FAQ helpful" = "Bu SSS yanıtını yararlı bulduysanız buraya dokunun"; -"Star" = "Yıldız"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Yanıtı yararlı bulduysanız buraya dokunun"; -"Report a problem" = "Problem bildir"; -"YES, THANKS!" = "EVET, TEŞEKKÜRLER!"; -"Was this helpful?" = "Bu yardımcı oldu mu?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Mesajın gönderilmedi. \"Yeniden Dene\" düğmesine dokunarak mesajı yeniden göndermek ister misin?"; -"Suggestions" = "Öneriler"; -"No FAQs found" = "SSS bulunamadı"; -"Done" = "Bitti"; -"Opening Gallery..." = "Galerinin açılması..."; -"You rated the service with" = "Bu hizmete verdiğiniz puan:"; -"Cancel" = "İptal"; -"Loading..." = "Yükleniyor..."; -"Read FAQs" = "SSS Oku"; -"Thanks for messaging us!" = "Mesaj gönderdiğin için teşekkürler!"; -"Try Again" = "Yeniden Dene"; -"Send Feedback" = "Geri Bildirim Gönder"; -"Your Name" = "İsim"; -"Please provide a name." = "Lütfen bir isim ver"; -"FAQ" = "SSS"; -"Describe your problem" = "Problemini tanımla"; -"How can we help?" = "Nasıl yardımcı olabiliriz?"; -"Help about" = "Şununla ilgili yardım:"; -"We could not fetch the required data" = "İstenen verileri getiremedik"; -"Name" = "Adı"; -"Sending failed!" = "Gönderme başarısız!"; -"You didn't find this helpful." = "Bunu yararlı bulmadın."; -"Attach a screenshot of your problem" = "Sorununuz ile ilgili bir ekran görüntüsü ekleyin"; -"Mark as read" = "Okundu olarak işaretle"; -"Yes" = "Evet"; -"What's on your mind?" = "Aklında ne var?"; -"Send a new message" = "Yeni bir mesaj gönder"; -"Questions that may already have your answer" = "Aradığın cevabı halihazırda içeren sorular"; -"Attach a photo" = "Bir fotoğraf ekleyin"; -"Accept" = "Kabul et"; -"Your reply" = "Yanıtınız"; -"Inbox" = "Gelen Kutusu"; -"Remove attachment" = "Eki Kaldır"; -"Could not fetch message" = "Mesaj alınamadı"; -"Read FAQ" = "SSS Oku"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Üzgünüz! Konuşma herhangi bir etkinlik olmadığından dolayı kapatıldı. Lütfen temsilcimiz ile yeni bir konuşma başlat."; -"%d new messages from Support" = "Destek bölümünden %d yeni mesaj"; -"Ok, Attach" = "Tamam, Ekle"; -"Send" = "Gönder"; -"Screenshot size should not exceed %.2f MB" = "Ekran görüntüsü boyutu %.2f MB'ı aşmamalı"; -"Information" = "Bilgi"; -"Issue ID" = "Sorun Kimliği"; -"Tap to copy" = "Kopyalamak için dokun"; -"Copied!" = "Kopyalandı!"; -"We couldn't find an FAQ with matching ID" = "Eşleşen kimliğe sahip bir SSS sayfası bulamadık"; -"Failed to load screenshot" = "Ekran görüntüsü yüklenemedi"; -"Failed to load video" = "Video yüklenemedi"; -"Failed to load image" = "Resim yüklenemedi"; -"Hold down your device's power and home buttons at the same time." = "Cihazının uyut/uyandır ve ana ekran düğmelerine aynı anda basılı tut."; -"Please note that a few devices may have the power button on the top." = "Bazı cihazların uyut/uyandır düğmesinin üstte olabileceğini unutma."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "İşlem tamamlandıktan sonra, bu konuşmaya dön ve eklemek için \"Tamam, ekle\"ye dokun."; -"Okay" = "Tamam"; -"We couldn't find an FAQ section with matching ID" = "Eşleşen kimliğe sahip bir SSS bölümü bulamadık"; - -"GIFs are not supported" = "GIF'ler desteklenmiyor"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/uk.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/uk.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 86d1dba9dc5b..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/uk.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "Не можете знайти те, що шукали?"; -"Rate App" = "Оцінити програму"; -"We\'re happy to help you!" = "Ми з радістю допоможемо вам!"; -"Did we answer all your questions?" = "Ви отримали відповіді на всі свої питання?"; -"Remind Later" = "Нагадати пізніше"; -"Your message has been received." = "Ваше повідомлення отримано."; -"Message send failure." = "Не вдалося відправити повідомлення."; -"What\'s your feedback about our customer support?" = "Як ви оцінюєте роботу нашої служби підтримки?"; -"Take a screenshot on your iPhone" = "Зробіть знімок екрана на своєму iPhone"; -"Learn how" = "Дізнатися як"; -"Name invalid" = "Неприйнятне ім'я"; -"Take a screenshot on your iPad" = "Зробіть знімок екрана на своєму iPad"; -"Your email(optional)" = "Ваша адреса e-mail (за бажанням)"; -"Conversation" = "Розмова"; -"View Now" = "Переглянути зараз"; -"SEND ANYWAY" = "ВСЕ ОДНО ВІДПРАВИТИ"; -"OK" = "OK"; -"Help" = "Допомога"; -"Send message" = "Відправити повідомлення"; -"REVIEW" = "ВІДГУК"; -"Share" = "Поділитися"; -"Close Help" = "Закрити довідку"; -"Sending your message..." = "Відправка повідомлення..."; -"Learn how to" = "Дізнатися як"; -"No FAQs found in this section" = "У цьому розділі відповідей на поширені питання (FAQ) не знайдено"; -"Thanks for contacting us." = "Дякуємо, що звернулися до нас."; -"Chat Now" = "Перейти до чату"; -"Buy Now" = "Купити зараз"; -"New Conversation" = "Нова розмова"; -"Please check your network connection and try again." = "Перевірте підключення до мережі і спробуйте ще раз."; -"New message from Support" = "Нове повідомлення служби підтримки"; -"Question" = "Питання"; -"Type in a new message" = "Введіть нове повідомлення"; -"Email (optional)" = "E-mail (за бажанням)"; -"Reply" = "Відповісти"; -"CONTACT US" = "ЗВ'ЯЗАТИСЯ З НАМИ"; -"Email" = "E-mail"; -"Like" = "Подобається"; -"Tap here if this FAQ was not helpful to you" = "Торкніться тут, якщо цей FAQ вам не допоміг"; -"Any other feedback? (optional)" = "Хочете додати відгук (за бажанням)?"; -"You found this helpful." = "Ви вважаєте цю інформацію корисною."; -"No working Internet connection is found." = "Не знайдено діючих підключень до Інтернету."; -"No messages found." = "Повідомлень не знайдено."; -"Please enter a brief description of the issue you are facing." = "Введіть стислий опис проблеми, яка у вас виникла."; -"Shop Now" = "Перейти до магазину"; -"Close Section" = "Закрити розділ"; -"Close FAQ" = "Закрити FAQ"; -"Close" = "Закрити"; -"This conversation has ended." = "Цю розмову завершено."; -"Send it anyway" = "Все одно відправити"; -"You accepted review request." = "Ви прийняли запит відгуку."; -"Delete" = "Видалити"; -"What else can we help you with?" = "Чим ще ми можемо вам допомогти?"; -"Tap here if the answer was not helpful to you" = "Торкніться тут, якщо ця відповідь вам не допомогла"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Прикро таке чути. Може, розповісте трохи детальніше про проблему, яка у вас виникла?"; -"Service Rating" = "Оцінка послуг"; -"Your email" = "Ваша адреса e-mail"; -"Email invalid" = "Адреса e-mail недійсна"; -"Could not fetch FAQs" = "Не вдалося знайти відповіді на поширені питання (FAQ)"; -"Thanks for rating us." = "Дякуємо за оцінку!"; -"Download" = "Завантажити"; -"Please enter a valid email" = "Введіть дійсну адресу e-mail"; -"Message" = "Повідомлення"; -"or" = "або"; -"Decline" = "Відхилити"; -"No" = "Ні"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Не вдалося відправити знімок. Файл є надто великим; виберіть інше зображення і повторіть спробу."; -"Hated it" = "Просто жах"; -"Stars" = "Зірки"; -"Your feedback has been received." = "Ваш відгук отримано."; -"Dislike" = "Не подобається"; -"Preview" = "Попередній перегляд"; -"Book Now" = "Замовити зараз"; -"START A NEW CONVERSATION" = "ПОЧАТИ НОВУ РОЗМОВУ"; -"Your Rating" = "Ваша оцінка"; -"No Internet!" = "Інтернет недоступний!"; -"Invalid Entry" = "Неприпустимий запис"; -"Loved it" = "Чудово"; -"Review on the App Store" = "Відгук в App Store"; -"Open Help" = "Відкрити довідку"; -"Search" = "Пошук"; -"Tap here if you found this FAQ helpful" = "Торкніться тут, якщо цей FAQ здається вам корисним"; -"Star" = "Зірка"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Торкніться тут, якщо ця відповідь здалася вам корисною"; -"Report a problem" = "Повідомити про проблему"; -"YES, THANKS!" = "ТАК, ДЯКУЮ!"; -"Was this helpful?" = "Чи допомогла вам ця інформація?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Повідомлення не відправлено. Натисніть \"Ще раз\", щоб відправити це повідомлення."; -"Suggestions" = "Пропозиції"; -"No FAQs found" = "FAQ не знайдено"; -"Done" = "Готово"; -"Opening Gallery..." = "Відкриваємо альбом..."; -"You rated the service with" = "Ви поставили цій послузі оцінку"; -"Cancel" = "Скасувати"; -"Loading..." = "Завантаження..."; -"Read FAQs" = "Переглянути збірники FAQ"; -"Thanks for messaging us!" = "Дякуємо, що надіслали нам повідомлення!"; -"Try Again" = "Ще раз"; -"Send Feedback" = "Відправити відгук"; -"Your Name" = "Ваше ім'я"; -"Please provide a name." = "Вкажіть ім'я."; -"FAQ" = "FAQ"; -"Describe your problem" = "Опишіть свою проблему"; -"How can we help?" = "Чим ми можемо допомогти?"; -"Help about" = "Довідка за темою"; -"We could not fetch the required data" = "Не вдається знайти потрібні дані"; -"Name" = "Ім'я"; -"Sending failed!" = "Збій відправлення!"; -"You didn't find this helpful." = "Ви не вважаєте цю інформацію корисною."; -"Attach a screenshot of your problem" = "Додайте знімок екрана, що стосується вашої проблеми"; -"Mark as read" = "Позначити як прочитане"; -"Yes" = "Так"; -"What's on your mind?" = "Про що ви думаєте?"; -"Send a new message" = "Надіслати нове повідомлення"; -"Questions that may already have your answer" = "Питання, на які, можливо, вже отримано відповіді"; -"Attach a photo" = "Вкласти знімок"; -"Accept" = "Прийняти"; -"Your reply" = "Ваша відповідь"; -"Inbox" = "Вхідні"; -"Remove attachment" = "Видалити вкладення"; -"Could not fetch message" = "Не вдалося отримати повідомлення"; -"Read FAQ" = "Переглянути FAQ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "На жаль, цю розмову було закрито через неактивність учасників. Почніть, будь ласка, нову розмову з нашими представниками."; -"%d new messages from Support" = "%d нових повідомлень служби підтримки"; -"Ok, Attach" = "Так, вкласти"; -"Send" = "Відправити"; -"Screenshot size should not exceed %.2f MB" = "Розмір знімка екрана не повинен перевищувати %.2f Мб"; -"Information" = "Інформація"; -"Issue ID" = "Ідентифікатор проблеми"; -"Tap to copy" = "Торкніться, щоб скопіювати"; -"Copied!" = "Скопійовано!"; -"We couldn't find an FAQ with matching ID" = "Збірника FAQ з таким ідентифікатором не знайдено"; -"Failed to load screenshot" = "Не вдалося завантажити знімок екрана"; -"Failed to load video" = "Не вдалося завантажити відео"; -"Failed to load image" = "Не вдалося завантажити зображення"; -"Hold down your device's power and home buttons at the same time." = "Одночасно натисніть та утримуйте кнопку увімкнення/вимкнення і кнопку «Додому» свого пристрою."; -"Please note that a few devices may have the power button on the top." = "Зауважте, що на деяких моделях кнопка увімкнення/вимкнення розташована зверху."; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Після цього поверніться до цього діалогового вікна і натисніть «Так, вкласти», щоб вкласти знімок."; -"Okay" = "OK"; -"We couldn't find an FAQ section with matching ID" = "Розділ FAQ з таким ідентифікатором не знайдено"; - -"GIFs are not supported" = "GIF-файли не підтримуються"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/vi.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/vi.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 3178b6ab9629..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/vi.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* - HelpshiftLocalizable.strings - Helpshift - Copyright (c) 2014 Helpshift,Inc., All rights reserved. - */ - -"Can't find what you were looking for?" = "Bạn không tìm thấy điều bạn muốn tìm?"; -"Rate App" = "Xếp hạng Ứng dụng"; -"We\'re happy to help you!" = "Chúng tôi sẵn lòng giúp bạn!"; -"Did we answer all your questions?" = "Chúng tôi đã trả lời hết tất cả các câu hỏi của bạn chưa?"; -"Remind Later" = "Nhắc lại Sau"; -"Your message has been received." = "Chúng tôi đã nhận được tin nhắn của bạn."; -"Message send failure." = "Gửi tin nhắn thất bạn."; -"What\'s your feedback about our customer support?" = "Bạn có phản hồi gì về việc hỗ trợ khách hàng của chúng tôi không?"; -"Take a screenshot on your iPhone" = "Chụp ảnh màn hìn trên iPhone"; -"Learn how" = "Tìm hiểu cách thức"; -"Name invalid" = "Tên không hợp lệ"; -"Take a screenshot on your iPad" = "Chụp ảnh màn hìn trên iPad"; -"Your email(optional)" = "Email của bạn (tùy chọn)"; -"Conversation" = "Hội thoại"; -"View Now" = "Xem Ngay"; -"SEND ANYWAY" = "VẪN CỨ GỬI"; -"OK" = "OK"; -"Help" = "Trợ giúp"; -"Send message" = "Gửi tin nhắn"; -"REVIEW" = "ĐÁNH GIÁ"; -"Share" = "Chia sẻ"; -"Close Help" = "Đóng Trợ giúp"; -"Sending your message..." = "Đang gửi tin nhắn..."; -"Learn how to" = "Tìm hiểu cách thức"; -"No FAQs found in this section" = "Không tìm thấy FAQ trong phần này"; -"Thanks for contacting us." = "Cảm ơn bạn đã liên hệ với chúng tôi."; -"Chat Now" = "Trò chuyện Ngay"; -"Buy Now" = "Mua Ngay"; -"New Conversation" = "Cuộc hội thoại mới"; -"Please check your network connection and try again." = "Vui lòng kiểm tra kết nối mạng và thử lại."; -"New message from Support" = "Tin nhắn mới từ Hỗ trợ"; -"Question" = "Câu hỏi"; -"Type in a new message" = "Nhập tin nhắn mới"; -"Email (optional)" = "Email (tùy chọn)"; -"Reply" = "Trả lời"; -"CONTACT US" = "LIÊN HỆ"; -"Email" = "Email"; -"Like" = "Thích"; -"Tap here if this FAQ was not helpful to you" = "Nhấn vào đây nếu FAQ này không hữu ích với bạn"; -"Any other feedback? (optional)" = "Bạn có phản hồi nào khác không? (tùy chọn)"; -"You found this helpful." = "Bạn thấy điều này hữu ích."; -"No working Internet connection is found." = "Không tìm thấy kết nối mạng Internet nào đang hoạt động."; -"No messages found." = "Không tìm thấy tin nhắn."; -"Please enter a brief description of the issue you are facing." = "Vui lòng nhập mô tả ngắn gọn về vấn đề mà bạn đang gặp phải."; -"Shop Now" = "Đến cửa hàng Ngay"; -"Close Section" = "Đóng Phần"; -"Close FAQ" = "Đóng FAQ"; -"Close" = "Đóng"; -"This conversation has ended." = "Cuộc hội thoại này đã chấm dứt."; -"Send it anyway" = "Vẫn cứ gửi"; -"You accepted review request." = "Bạn đã chấp nhận yêu cầu nhận xét."; -"Delete" = "Xóa"; -"What else can we help you with?" = "Chúng tôi còn có thể giúp gì khác cho bạn không?"; -"Tap here if the answer was not helpful to you" = "Nhấn vào đây nếu câu trả lời không hữu ích với bạn"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "Rất tiếc vì điều đó. Bạn có thể vui lòng cho chúng tôi biết thêm một chút về vấn đề mà bạn đang gặp phải không?"; -"Service Rating" = "Xếp hạng dịch vụ"; -"Your email" = "Email của bạn"; -"Email invalid" = "Email không hợp lệ"; -"Could not fetch FAQs" = "Không thể tải câu hỏi thường gặp"; -"Thanks for rating us." = "Cảm ơn bạn đã xếp hạng chúng tôi."; -"Download" = "Tải về"; -"Please enter a valid email" = "Vui lòng nhập email hợp lệ"; -"Message" = "Tin nhắn"; -"or" = "hoặc"; -"Decline" = "Từ chối"; -"No" = "Không"; -"Screenshot could not be sent. Image is too large, try again with another image" = "Không thể gửi ảnh chụp màn hình. Kích thước tệp hình ảnh quá lớn, hãy thử lại với hình ảnh khác"; -"Hated it" = "Không thích"; -"Stars" = "Sao"; -"Your feedback has been received." = "Chúng tôi đã nhận được phản hồi của bạn."; -"Dislike" = "Không thích"; -"Preview" = "Xem trước"; -"Book Now" = "Đặt Ngay"; -"START A NEW CONVERSATION" = "BẮT ĐẦU CUỘC HỘI THOẠI MỚI"; -"Your Rating" = "Xếp hạng của bạn"; -"No Internet!" = "Không có mạng Internet!"; -"Invalid Entry" = "Mục nhập không hợp lệ"; -"Loved it" = "Thích"; -"Review on the App Store" = "Nhận xét trên App Store"; -"Open Help" = "Mở Trợ giúp"; -"Search" = "Tìm kiếm"; -"Tap here if you found this FAQ helpful" = "Nhấn vào đây nếu bạn thấy FAQ này hữu ích"; -"Star" = "Sao"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "Nhấn vào đây nếu bạn thấy câu trả lời hữu ích"; -"Report a problem" = "Báo cáo vấn đề"; -"YES, THANKS!" = "RỒI, XIN CẢM ƠN!"; -"Was this helpful?" = "Điều này có hữu ích không?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "Tin nhắn của bạn chưa được gửi. Nhấn \"Thử lại\" để gửi tin nhắn này?"; -"Suggestions" = "Gợi ý"; -"No FAQs found" = "Không tìm thấy Câu hỏi thường gặp"; -"Done" = "Hoàn thành"; -"Opening Gallery..." = "Đang mở Thư viện..."; -"You rated the service with" = "Bạn đã đánh giá dịch vụ này là"; -"Cancel" = "Hủy"; -"Loading..." = "Đang tải..."; -"Read FAQs" = "Đọc FAQ"; -"Thanks for messaging us!" = "Cảm ơn bạn đã gửi tin nhắn cho chúng tôi!"; -"Try Again" = "Thử Lại"; -"Send Feedback" = "Gửi Phản hồi"; -"Your Name" = "Tên của Bạn"; -"Please provide a name." = "Vui lòng cung cấp tên."; -"FAQ" = "Câu hỏi thường gặp"; -"Describe your problem" = "Mô tả vấn đề của bạn"; -"How can we help?" = "Chúng tôi có thể giúp gì cho bạn?"; -"Help about" = "Trợ giúp về"; -"We could not fetch the required data" = "Chúng tôi không thể tải dữ liệu yêu cầu"; -"Name" = "Tên"; -"Sending failed!" = "Gửi thất bạn!"; -"You didn't find this helpful." = "Bạn thấy điều này không hữu ích."; -"Attach a screenshot of your problem" = "Đính kèm ảnh chụp màn hình vấn đề của bạn"; -"Mark as read" = "Đánh dấu đã đọc"; -"Yes" = "Có"; -"What's on your mind?" = "Bạn đang nghĩ gì?"; -"Send a new message" = "Gửi tin nhắn mới"; -"Questions that may already have your answer" = "Câu hỏi có thể đã có câu trả lời của bạn"; -"Attach a photo" = "Đính kèm ảnh"; -"Accept" = "Chấp nhậ̣n"; -"Your reply" = "Trả lời của bạn"; -"Inbox" = "Hộp thư"; -"Remove attachment" = "Xóa tập tin đính kèm"; -"Could not fetch message" = "Không thể tải tin nhắn"; -"Read FAQ" = "Đọc FAQ"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "Rất tiếc! Tin nhắn này đã bị đóng do không có hoạt động. Vui lòng bắt đầu một tin nhắn mới với các nhân viên của chúng tôi."; -"%d new messages from Support" = "%d tin nhắn mới từ Hỗ trợ"; -"Ok, Attach" = "Ok, đính kèm"; -"Send" = "Gửi"; -"Screenshot size should not exceed %.2f MB" = "Kích thước ảnh chụp màn hình không được lớn hơn %.2f MB"; -"Information" = "Thông tin"; -"Issue ID" = "Cấp ID"; -"Tap to copy" = "Chạm vào để sao chép"; -"Copied!" = "Đã sao chép"; -"We couldn't find an FAQ with matching ID" = "Chúng tôi không thể tìm thấy câu hỏi trong FAQ có ID này"; -"Failed to load screenshot" = "Không thể tải ảnh chụp màn hình"; -"Failed to load video" = "Không thể tải video"; -"Failed to load image" = "Không thể tài hình ảnh"; -"Hold down your device's power and home buttons at the same time." = "Giữ chặt nút nguồn và nút màn hình chính cùng lúc"; -"Please note that a few devices may have the power button on the top." = "Xin hãy lưu ý rằng một số thiết bị có thể đặt nút nguồn ở phía trên"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "Sau khi hoàn thành, quay trở lại cuộc hội thoại này và nhấn vào \"OK, đính kèm\" để đính kèm."; -"Okay" = "OK"; -"We couldn't find an FAQ section with matching ID" = "Chúng tôi không thể tìm thấy mục FAQ bằng ID này"; - -"GIFs are not supported" = "GIF không được hỗ trợ"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hans-SG.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hans-SG.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 9f22cdecd08a..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hans-SG.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "找不到所需的答案吗?"; -"Rate App" = "评价应用"; -"We\'re happy to help you!" = "我们很乐意为您提供帮助!"; -"Did we answer all your questions?" = "我们有没有回答您的所有问题?"; -"Remind Later" = "稍后提醒"; -"Your message has been received." = "我们已收到您的消息。"; -"Message send failure." = "消息发送失败。"; -"What\'s your feedback about our customer support?" = "您对我们的客服有什么看法?"; -"Take a screenshot on your iPhone" = "在iPhone上屏幕截图"; -"Learn how" = "了解使用方法"; -"Take a screenshot on your iPad" = "在iPad上屏幕截图"; -"Your email(optional)" = "您的电子邮件(可选)"; -"Conversation" = "对话"; -"View Now" = "立刻查看"; -"SEND ANYWAY" = "仍然发送"; -"OK" = "确定"; -"Help" = "帮助"; -"Send message" = "发送消息"; -"REVIEW" = "评论"; -"Share" = "分享"; -"Close Help" = "关闭帮助"; -"Sending your message..." = "正在发送消息……"; -"Learn how to" = "了解使用方法"; -"No FAQs found in this section" = "未在本节内找到常见问题解答"; -"Thanks for contacting us." = "感谢您联系我们。"; -"Chat Now" = "立刻开始聊天"; -"Buy Now" = "立刻购买"; -"New Conversation" = "新对话"; -"Please check your network connection and try again." = "请检查网络连接然后重试。"; -"New message from Support" = "客服团队新消息"; -"Question" = "问题"; -"Type in a new message" = "输入一条新消息"; -"Email (optional)" = "电子邮件(可选)"; -"Reply" = "回复"; -"CONTACT US" = "联系我们"; -"Email" = "电子邮件Email"; -"Like" = "赞"; -"Tap here if this FAQ was not helpful to you" = "如果这个FAQ对您没有帮助,请按这里"; -"Any other feedback? (optional)" = "还有其他意见或建议吗?(可选)"; -"You found this helpful." = "有帮助。"; -"No working Internet connection is found." = "未找到可用的互联网连接。"; -"No messages found." = "未找到消息。"; -"Please enter a brief description of the issue you are facing." = "请简要说明您遇到的问题。"; -"Shop Now" = "立刻前往商店"; -"Close Section" = "关闭分区"; -"Close FAQ" = "关闭常见问题解答"; -"Close" = "关闭"; -"This conversation has ended." = "该对话已结束。"; -"Send it anyway" = "仍然发送"; -"You accepted review request." = "您接受了评论申请。"; -"Delete" = "删除"; -"What else can we help you with?" = "我们还能为您做些什么?"; -"Tap here if the answer was not helpful to you" = "如果回答对您没有帮助,请按这里"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "很遗憾。您能向我们提供更多问题说明吗?"; -"Service Rating" = "服务评价"; -"Your email" = "您的电子邮件"; -"Email invalid" = "电子邮件无效"; -"Could not fetch FAQs" = "无法获取常见问题解答"; -"Thanks for rating us." = "感谢您对我们的评价。"; -"Download" = "下载"; -"Please enter a valid email" = "请输入有效的电子邮件"; -"Message" = "消息"; -"or" = "或"; -"Decline" = "拒绝"; -"No" = "否"; -"Screenshot could not be sent. Image is too large, try again with another image" = "屏幕截图未能发送。图像尺寸过大,请使用其他图像重试"; -"Hated it" = "不喜欢"; -"Stars" = "星"; -"Your feedback has been received." = "我们已收到您的反馈信息。"; -"Dislike" = "不喜欢"; -"Preview" = "预览"; -"Book Now" = "立刻订购"; -"START A NEW CONVERSATION" = "开始新对话"; -"Your Rating" = "您的评价"; -"No Internet!" = "无互联网连接!"; -"Invalid Entry" = "无效输入"; -"Loved it" = "喜欢"; -"Review on the App Store" = "去App Store上评论"; -"Open Help" = "打开帮助"; -"Search" = "搜索"; -"Tap here if you found this FAQ helpful" = "如果这个FAQ有帮助,请按这里"; -"Star" = "星"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "如果这个回答有帮助,请按这里"; -"Report a problem" = "报告问题"; -"YES, THANKS!" = "好,谢谢!"; -"Was this helpful?" = "该话题有帮助吗?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "你的消息未发送。请按“重试”再次发送该消息。"; -"Suggestions" = "建议"; -"No FAQs found" = "未找到常见问题解答"; -"Done" = "完成"; -"Opening Gallery..." = "打开相册……"; -"You rated the service with" = "您对服务的评价为"; -"Cancel" = "取消"; -"Loading..." = "正在加载..."; -"Read FAQs" = "查看常见问题解答"; -"Thanks for messaging us!" = "感谢您的消息!"; -"Try Again" = "再试一次"; -"Send Feedback" = "发送反馈"; -"Your Name" = "您的姓名"; -"Please provide a name." = "请输入名称。"; -"FAQ" = "常见问题解答"; -"Describe your problem" = "说明您遇到的问题"; -"How can we help?" = "我们能为您做些什么?"; -"Help about" = "帮助主题:"; -"We could not fetch the required data" = "我们无法获取所需数据"; -"Name" = "名称"; -"Sending failed!" = "发送失败!"; -"You didn't find this helpful." = "你认为这没有帮助。"; -"Attach a screenshot of your problem" = "添加问题截图"; -"Mark as read" = "标记为已读"; -"Name invalid" = "名称无效"; -"Yes" = "是"; -"What's on your mind?" = "您在想什么?"; -"Send a new message" = "发送新消息"; -"Questions that may already have your answer" = "已经有了答案的问题"; -"Attach a photo" = "添加照片"; -"Accept" = "接受"; -"Your reply" = "您的回复"; -"Inbox" = "收件箱"; -"Remove attachment" = "移除附件"; -"Could not fetch message" = "无法获取消息"; -"Read FAQ" = "查看常见问题解答"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "抱歉!该对话因为不活跃而关闭。请与我们的代理开始新对话。"; -"%d new messages from Support" = "%d条客服团队新消息"; -"Ok, Attach" = "好,添加"; -"Send" = "发送"; -"Screenshot size should not exceed %.2f MB" = "屏幕截图不应超过%.2fMB"; -"Information" = "信息"; -"Issue ID" = "问题 ID"; -"Tap to copy" = "点击以复制"; -"Copied!" = "已复制!"; -"We couldn't find an FAQ with matching ID" = "我们未能找到与ID匹配的常见问题解答"; -"Failed to load screenshot" = "屏幕快照加载失败"; -"Failed to load video" = "视频加载失败"; -"Failed to load image" = "图像加载失败"; -"Hold down your device's power and home buttons at the same time." = "請同時按住裝置上的睡眠/喚醒按鈕和主畫面按鈕。"; -"Please note that a few devices may have the power button on the top." = "請注意,部分裝置的睡眠/喚醒按鈕位置於裝置的上方。"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "完成後,再次回到此對話,並按下「好的,附加」以附加。"; -"Okay" = "完成"; -"We couldn't find an FAQ section with matching ID" = "我们未能找到与ID匹配的常见问题解答章节"; - -"GIFs are not supported" = "不支持GIF"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hans.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hans.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index ff662329d39b..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hans.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "找不到所需的答案吗?"; -"Rate App" = "评价应用"; -"We\'re happy to help you!" = "我们很乐意为您提供帮助!"; -"Did we answer all your questions?" = "我们有没有回答您的所有问题?"; -"Remind Later" = "稍后提醒"; -"Your message has been received." = "我们已收到您的消息。"; -"Message send failure." = "消息发送失败。"; -"What\'s your feedback about our customer support?" = "您对我们的客服有什么看法?"; -"Take a screenshot on your iPhone" = "在iPhone上屏幕截图"; -"Learn how" = "了解使用方法"; -"Take a screenshot on your iPad" = "在iPad上屏幕截图"; -"Your email(optional)" = "您的电子邮件(可选)"; -"Conversation" = "对话"; -"View Now" = "立刻查看"; -"SEND ANYWAY" = "仍然发送"; -"OK" = "确定"; -"Help" = "帮助"; -"Send message" = "发送消息"; -"REVIEW" = "评论"; -"Share" = "分享"; -"Close Help" = "关闭帮助"; -"Sending your message..." = "正在发送消息……"; -"Learn how to" = "了解使用方法"; -"No FAQs found in this section" = "未在本节内找到常见问题解答"; -"Thanks for contacting us." = "感谢您联系我们。"; -"Chat Now" = "立刻开始聊天"; -"Buy Now" = "立刻购买"; -"New Conversation" = "新对话"; -"Please check your network connection and try again." = "请检查网络连接然后重试。"; -"New message from Support" = "客服团队新消息"; -"Question" = "问题"; -"Type in a new message" = "输入一条新消息"; -"Email (optional)" = "电子邮件(可选)"; -"Reply" = "回复"; -"CONTACT US" = "联系我们"; -"Email" = "电子邮件Email"; -"Like" = "赞"; -"Tap here if this FAQ was not helpful to you" = "如果这个FAQ对您没有帮助,请按这里"; -"Any other feedback? (optional)" = "还有其他意见或建议吗?(可选)"; -"You found this helpful." = "有帮助。"; -"No working Internet connection is found." = "未找到可用的互联网连接。"; -"No messages found." = "未找到消息。"; -"Please enter a brief description of the issue you are facing." = "请简要说明您遇到的问题。"; -"Shop Now" = "立刻前往商店"; -"Close Section" = "关闭分区"; -"Close FAQ" = "关闭常见问题解答"; -"Close" = "关闭"; -"This conversation has ended." = "该对话已结束。"; -"Send it anyway" = "仍然发送"; -"You accepted review request." = "您接受了评论申请。"; -"Delete" = "删除"; -"What else can we help you with?" = "我们还能为您做些什么?"; -"Tap here if the answer was not helpful to you" = "如果回答对您没有帮助,请按这里"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "很遗憾。您能向我们提供更多问题说明吗?"; -"Service Rating" = "服务评价"; -"Your email" = "您的电子邮件"; -"Email invalid" = "电子邮件无效"; -"Could not fetch FAQs" = "无法获取常见问题解答"; -"Thanks for rating us." = "感谢您对我们的评价。"; -"Download" = "下载"; -"Please enter a valid email" = "请输入有效的电子邮件"; -"Message" = "消息"; -"or" = "或"; -"Decline" = "拒绝"; -"No" = "否"; -"Screenshot could not be sent. Image is too large, try again with another image" = "屏幕截图未能发送。图像尺寸过大,请使用其他图像重试"; -"Hated it" = "不喜欢"; -"Stars" = "星"; -"Your feedback has been received." = "我们已收到您的反馈信息。"; -"Dislike" = "不喜欢"; -"Preview" = "预览"; -"Book Now" = "立刻订购"; -"START A NEW CONVERSATION" = "开始新对话"; -"Your Rating" = "您的评价"; -"No Internet!" = "无互联网连接!"; -"Invalid Entry" = "无效输入"; -"Loved it" = "喜欢"; -"Review on the App Store" = "去App Store上评论"; -"Open Help" = "打开帮助"; -"Search" = "搜索"; -"Tap here if you found this FAQ helpful" = "如果这个FAQ有帮助,请按这里"; -"Star" = "星"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "如果这个回答有帮助,请按这里"; -"Report a problem" = "报告问题"; -"YES, THANKS!" = "好,谢谢!"; -"Was this helpful?" = "该话题有帮助吗?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "你的消息未发送。请按“重试”再次发送该消息。"; -"Suggestions" = "建议"; -"No FAQs found" = "未找到常见问题解答"; -"Done" = "完成"; -"Opening Gallery..." = "打开相册……"; -"You rated the service with" = "您对服务的评价为"; -"Cancel" = "取消"; -"Loading..." = "正在加载..."; -"Read FAQs" = "查看常见问题解答"; -"Thanks for messaging us!" = "感谢您的消息!"; -"Try Again" = "再试一次"; -"Send Feedback" = "发送反馈"; -"Your Name" = "您的姓名"; -"Please provide a name." = "请输入名称。"; -"FAQ" = "常见问题解答"; -"Describe your problem" = "说明您遇到的问题"; -"How can we help?" = "我们能为您做些什么?"; -"Help about" = "帮助主题:"; -"We could not fetch the required data" = "我们无法获取所需数据"; -"Name" = "名称"; -"Sending failed!" = "发送失败!"; -"You didn't find this helpful." = "你认为这没有帮助。"; -"Attach a screenshot of your problem" = "添加问题截图"; -"Mark as read" = "标记为已读"; -"Name invalid" = "名称无效"; -"Yes" = "是"; -"What's on your mind?" = "您在想什么?"; -"Send a new message" = "发送新消息"; -"Questions that may already have your answer" = "已经有了答案的问题"; -"Attach a photo" = "添加照片"; -"Accept" = "接受"; -"Your reply" = "您的回复"; -"Inbox" = "收件箱"; -"Remove attachment" = "移除附件"; -"Could not fetch message" = "无法获取消息"; -"Read FAQ" = "查看常见问题解答"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "抱歉!该对话因为不活跃而关闭。请与我们的代理开始新对话。"; -"%d new messages from Support" = "%d条客服团队新消息"; -"Ok, Attach" = "好,添加"; -"Send" = "发送"; -"Screenshot size should not exceed %.2f MB" = "屏幕截图不应超过%.2fMB"; -"Information" = "信息"; -"Issue ID" = "问题 ID"; -"Tap to copy" = "点击以复制"; -"Copied!" = "已复制!"; -"We couldn't find an FAQ with matching ID" = "我们未能找到与ID匹配的常见问题解答"; -"Failed to load screenshot" = "屏幕快照加载失败"; -"Failed to load video" = "视频加载失败"; -"Failed to load image" = "图像加载失败"; -"Hold down your device's power and home buttons at the same time." = "請同時按住裝置上的睡眠/喚醒按鈕和主畫面按鈕。"; -"Please note that a few devices may have the power button on the top." = "請注意,部分裝置的睡眠/喚醒按鈕位置於裝置的上方。"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "完成後,再次回到此對話,並按下「好的,附加」以附加。"; -"Okay" = "完成"; -"We couldn't find an FAQ section with matching ID" = "我们未能找到与ID匹配的常见问题解答章节"; - -"GIFs are not supported" = "不支持GIF"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hant-HK.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hant-HK.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 875665c218d5..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hant-HK.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "找不到您需要的資訊嗎?"; -"Rate App" = "評分 App"; -"We\'re happy to help you!" = "我們很樂意協助您!"; -"Did we answer all your questions?" = "我們是否已回答您全部問題?"; -"Remind Later" = "稍後提醒"; -"Your message has been received." = "您的訊息已收悉。"; -"Message send failure." = "訊息傳送失敗"; -"What\'s your feedback about our customer support?" = "您對我們的客戶支援團隊有何意見嗎?"; -"Take a screenshot on your iPhone" = "在 iPhone 上擷取螢幕快照"; -"Learn how" = "了解方法"; -"Take a screenshot on your iPad" = "在 iPad 上擷取螢幕快照"; -"Your email(optional)" = "您的電子郵件(選填)"; -"Conversation" = "對話"; -"View Now" = "立刻檢視"; -"SEND ANYWAY" = "直接傳送"; -"OK" = "好"; -"Help" = "說明"; -"Send message" = "傳送訊息"; -"REVIEW" = "評論"; -"Share" = "分享"; -"Close Help" = "關閉說明"; -"Sending your message..." = "正在傳送您的訊息..."; -"Learn how to" = "了解方法"; -"No FAQs found in this section" = "此章節中沒有常見問題集"; -"Thanks for contacting us." = "感謝您與我們聯絡。"; -"Chat Now" = "立刻聊天"; -"Buy Now" = "立刻買"; -"New Conversation" = "新對話"; -"Please check your network connection and try again." = "請檢查您的網際網路連線,然後再試一次。"; -"New message from Support" = "支援團隊送來新訊息"; -"Question" = "問題"; -"Type in a new message" = "輸入新訊息"; -"Email (optional)" = "電子郵件(選填)"; -"Reply" = "回覆"; -"CONTACT US" = "聯絡我們"; -"Email" = "電子郵件"; -"Like" = "讚"; -"Tap here if this FAQ was not helpful to you" = "若此常見問題對您沒有幫助,請點擊此處"; -"Read FAQ" = "閱讀常見問題"; -"Any other feedback? (optional)" = "有其他意見嗎?(選填)"; -"You found this helpful." = "您覺得這很有幫助。"; -"No working Internet connection is found." = "找不到可用的網際網路連線。"; -"No messages found." = "找不到訊息"; -"Please enter a brief description of the issue you are facing." = "請輸入您所遭遇問題的簡略描述。"; -"Shop Now" = "立刻購買"; -"Close Section" = "關閉此章節"; -"Close FAQ" = "關閉常見問題"; -"Close" = "關閉"; -"This conversation has ended." = "此對話已結束。"; -"Send it anyway" = "直接傳送"; -"You accepted review request." = "您已接受評論請求。"; -"Delete" = "刪除"; -"What else can we help you with?" = "還有其他需要我們幫助之處嗎?"; -"Tap here if the answer was not helpful to you" = "若答案對您沒有幫助,請點擊此處"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "很遺憾。能否請您向我們更詳細你遭遇的問題?"; -"Service Rating" = "服務評價"; -"Your email" = "您的電子郵件"; -"Email invalid" = "無效的電子郵件"; -"Could not fetch FAQs" = "無法取得常見問題集"; -"Thanks for rating us." = "感謝您對我們的評價。"; -"Download" = "下載"; -"Please enter a valid email" = "請輸入有效的電子郵件"; -"Message" = "訊息"; -"or" = "或"; -"Decline" = "拒絕"; -"No" = "否"; -"Screenshot could not be sent. Image is too large, try again with another image" = "無法傳送螢幕快照。影像太大,請用其他影像再試一次"; -"Hated it" = "不喜歡"; -"Stars" = "星星"; -"Your feedback has been received." = "您的意見已收悉。"; -"Dislike" = "噓"; -"Preview" = "預覽"; -"Book Now" = "立刻預訂"; -"START A NEW CONVERSATION" = "開始新對話"; -"Your Rating" = "您的評價"; -"No Internet!" = "沒有網路!"; -"Invalid Entry" = "無效的內容"; -"Loved it" = "很喜歡"; -"Review on the App Store" = "在 App Store 上評論"; -"Open Help" = "打開說明"; -"Search" = "搜尋"; -"Tap here if you found this FAQ helpful" = "若您認為此常見問題有幫助,請點擊此處"; -"Star" = "星星"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "若您認為答案有幫助,請點擊此處"; -"Report a problem" = "回報問題"; -"YES, THANKS!" = "是的,謝謝!"; -"Was this helpful?" = "這是否有幫助?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "您的訊息未送出。要點擊「再試一次」來傳送此訊息嗎?"; -"Suggestions" = "建議事項"; -"No FAQs found" = "找不到常見問題集"; -"Done" = "完成"; -"Opening Gallery..." = "打開相簿..."; -"You rated the service with" = "您已為此服務評分:"; -"Cancel" = "取消"; -"Loading..." = "載入中..."; -"Read FAQs" = "閱讀常見問題集"; -"Thanks for messaging us!" = "感謝您給我們訊息!"; -"Try Again" = "再試一次"; -"Send Feedback" = "傳送意見反饋"; -"Your Name" = "您的姓名"; -"Please provide a name." = "請提供名稱。"; -"FAQ" = "常見問題集"; -"Describe your problem" = "請描述您的問題"; -"How can we help?" = "我們能如何為您效勞?"; -"Help about" = "說明主題"; -"We could not fetch the required data" = "我們無法取得所需的資料"; -"Name" = "名稱"; -"Sending failed!" = "傳送失敗!"; -"You didn't find this helpful." = "您覺得這沒有幫助。"; -"Attach a screenshot of your problem" = "附加您的問題螢幕快照"; -"Mark as read" = "標記為已讀"; -"Name invalid" = "無效的名稱"; -"Yes" = "有"; -"What's on your mind?" = "您有什麼想法嗎?"; -"Send a new message" = "傳送新訊息"; -"Questions that may already have your answer" = "您的問題可能已經有答案"; -"Attach a photo" = "附加照片"; -"Accept" = "接受"; -"Your reply" = "您的回覆"; -"Inbox" = "收件匣"; -"Remove attachment" = "移除附件"; -"Could not fetch message" = "無法取得訊息"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "很抱歉,本對話因沒有活動,現已關閉。請開始新對話來和我們的服務人員聯繫。"; -"%d new messages from Support" = "支援團隊送來 %d 則新訊息"; -"Ok, Attach" = "好的,附加"; -"Send" = "傳送"; -"Screenshot size should not exceed %.2f MB" = "螢幕快照大小不得超過 %.2f MB"; -"Information" = "資訊"; -"Issue ID" = "問題 ID"; -"Tap to copy" = "點擊以複製"; -"Copied!" = "已複製!"; -"We couldn't find an FAQ with matching ID" = "我們無法找到具有匹配 ID 的常見問題"; -"Failed to load screenshot" = "載入螢幕快照失敗"; -"Failed to load video" = "載入影片失敗"; -"Failed to load image" = "載入圖像失敗"; -"Hold down your device's power and home buttons at the same time." = "同时按住设备的睡眠/唤醒按钮与主屏幕按钮。"; -"Please note that a few devices may have the power button on the top." = "注意:某些设备的睡眠/唤醒按钮可能在顶部。"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "完成后,请返回此对话,并轻点“好,添加”来添加附件。"; -"Okay" = "确定"; -"We couldn't find an FAQ section with matching ID" = "我們無法找到具有匹配 ID 的常見問題章節"; - -"GIFs are not supported" = "不支持GIF"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hant.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hant.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 0e85dde5e693..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hant.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "找不到您需要的資訊嗎?"; -"Rate App" = "評分 App"; -"We\'re happy to help you!" = "我們很樂意協助您!"; -"Did we answer all your questions?" = "我們是否已回答您全部問題?"; -"Remind Later" = "稍後提醒"; -"Your message has been received." = "您的訊息已收悉。"; -"Message send failure." = "訊息傳送失敗"; -"What\'s your feedback about our customer support?" = "您對我們的客戶支援團隊有何意見嗎?"; -"Take a screenshot on your iPhone" = "在 iPhone 上擷取螢幕快照"; -"Learn how" = "了解方法"; -"Take a screenshot on your iPad" = "在 iPad 上擷取螢幕快照"; -"Your email(optional)" = "您的電子郵件(選填)"; -"Conversation" = "對話"; -"View Now" = "立刻檢視"; -"SEND ANYWAY" = "直接傳送"; -"OK" = "好"; -"Help" = "說明"; -"Send message" = "傳送訊息"; -"REVIEW" = "評論"; -"Share" = "分享"; -"Close Help" = "關閉說明"; -"Sending your message..." = "正在傳送您的訊息..."; -"Learn how to" = "了解方法"; -"No FAQs found in this section" = "此章節中沒有常見問題集"; -"Thanks for contacting us." = "感謝您與我們聯絡。"; -"Chat Now" = "立刻聊天"; -"Buy Now" = "立刻買"; -"New Conversation" = "新對話"; -"Please check your network connection and try again." = "請檢查您的網際網路連線,然後再試一次。"; -"New message from Support" = "支援團隊送來新訊息"; -"Question" = "問題"; -"Type in a new message" = "輸入新訊息"; -"Email (optional)" = "電子郵件(選填)"; -"Reply" = "回覆"; -"CONTACT US" = "聯絡我們"; -"Email" = "電子郵件"; -"Like" = "讚"; -"Tap here if this FAQ was not helpful to you" = "若此常見問題對您沒有幫助,請點擊此處"; -"Any other feedback? (optional)" = "有其他意見嗎?(選填)"; -"You found this helpful." = "您覺得這很有幫助。"; -"No working Internet connection is found." = "找不到可用的網際網路連線。"; -"No messages found." = "找不到訊息"; -"Please enter a brief description of the issue you are facing." = "請輸入您所遭遇問題的簡略描述。"; -"Shop Now" = "立刻購買"; -"Close Section" = "關閉此章節"; -"Close FAQ" = "關閉常見問題"; -"Close" = "關閉"; -"This conversation has ended." = "此對話已結束。"; -"Send it anyway" = "直接傳送"; -"You accepted review request." = "您已接受評論請求。"; -"Delete" = "刪除"; -"What else can we help you with?" = "還有其他需要我們幫助之處嗎?"; -"Tap here if the answer was not helpful to you" = "若答案對您沒有幫助,請點擊此處"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "很遺憾。能否請您向我們更詳細你遭遇的問題?"; -"Service Rating" = "服務評價"; -"Your email" = "您的電子郵件"; -"Email invalid" = "無效的電子郵件"; -"Could not fetch FAQs" = "無法取得常見問題集"; -"Thanks for rating us." = "感謝您對我們的評價。"; -"Download" = "下載"; -"Please enter a valid email" = "請輸入有效的電子郵件"; -"Message" = "訊息"; -"or" = "或"; -"Decline" = "拒絕"; -"No" = "否"; -"Screenshot could not be sent. Image is too large, try again with another image" = "無法傳送螢幕快照。影像太大,請用其他影像再試一次"; -"Hated it" = "不喜歡"; -"Stars" = "星星"; -"Your feedback has been received." = "您的意見已收悉。"; -"Dislike" = "噓"; -"Preview" = "預覽"; -"Book Now" = "立刻預訂"; -"START A NEW CONVERSATION" = "開始新對話"; -"Your Rating" = "您的評價"; -"No Internet!" = "沒有網路!"; -"Invalid Entry" = "無效的內容"; -"Loved it" = "很喜歡"; -"Review on the App Store" = "在 App Store 上評論"; -"Open Help" = "打開說明"; -"Search" = "搜尋"; -"Tap here if you found this FAQ helpful" = "若您認為此常見問題有幫助,請點擊此處"; -"Star" = "星星"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "若您認為答案有幫助,請點擊此處"; -"Report a problem" = "回報問題"; -"YES, THANKS!" = "是的,謝謝!"; -"Was this helpful?" = "這是否有幫助?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "您的訊息未送出。要點擊「再試一次」來傳送此訊息嗎?"; -"Suggestions" = "建議事項"; -"No FAQs found" = "找不到常見問題集"; -"Done" = "完成"; -"Opening Gallery..." = "打開相簿..."; -"You rated the service with" = "您已為此服務評分:"; -"Cancel" = "取消"; -"Loading..." = "載入中..."; -"Read FAQs" = "閱讀常見問題集"; -"Thanks for messaging us!" = "感謝您給我們訊息!"; -"Try Again" = "再試一次"; -"Send Feedback" = "傳送意見反饋"; -"Your Name" = "您的姓名"; -"Please provide a name." = "請提供名稱。"; -"FAQ" = "常見問題集"; -"Describe your problem" = "請描述您的問題"; -"How can we help?" = "我們能如何為您效勞?"; -"Help about" = "說明主題"; -"We could not fetch the required data" = "我們無法取得所需的資料"; -"Name" = "名稱"; -"Sending failed!" = "傳送失敗!"; -"You didn't find this helpful." = "您覺得這沒有幫助。"; -"Attach a screenshot of your problem" = "附加您的問題螢幕快照"; -"Mark as read" = "標記為已讀"; -"Name invalid" = "無效的名稱"; -"Yes" = "有"; -"What's on your mind?" = "您有什麼想法嗎?"; -"Send a new message" = "傳送新訊息"; -"Questions that may already have your answer" = "您的問題可能已經有答案"; -"Attach a photo" = "附加照片"; -"Accept" = "接受"; -"Your reply" = "您的回覆"; -"Inbox" = "收件匣"; -"Remove attachment" = "移除附件"; -"Could not fetch message" = "無法取得訊息"; -"Read FAQ" = "閱讀常見問題"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "很抱歉,本對話因沒有活動,現已關閉。請開始新對話來和我們的服務人員聯繫。"; -"%d new messages from Support" = "支援團隊送來 %d 則新訊息"; -"Ok, Attach" = "好的,附加"; -"Send" = "傳送"; -"Screenshot size should not exceed %.2f MB" = "螢幕快照大小不得超過 %.2f MB"; -"Information" = "資訊"; -"Issue ID" = "問題 ID"; -"Tap to copy" = "點擊以複製"; -"Copied!" = "已複製!"; -"We couldn't find an FAQ with matching ID" = "我們無法找到具有匹配 ID 的常見問題"; -"Failed to load screenshot" = "載入螢幕快照失敗"; -"Failed to load video" = "載入影片失敗"; -"Failed to load image" = "載入圖像失敗"; -"Hold down your device's power and home buttons at the same time." = "同时按住设备的睡眠/唤醒按钮与主屏幕按钮。"; -"Please note that a few devices may have the power button on the top." = "注意:某些设备的睡眠/唤醒按钮可能在顶部。"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "完成后,请返回此对话,并轻点“好,添加”来添加附件。"; -"Okay" = "确定"; -"We couldn't find an FAQ section with matching ID" = "我們無法找到具有匹配 ID 的常見問題章節"; - -"GIFs are not supported" = "不支持GIF"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hk.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hk.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 875665c218d5..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Hk.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "找不到您需要的資訊嗎?"; -"Rate App" = "評分 App"; -"We\'re happy to help you!" = "我們很樂意協助您!"; -"Did we answer all your questions?" = "我們是否已回答您全部問題?"; -"Remind Later" = "稍後提醒"; -"Your message has been received." = "您的訊息已收悉。"; -"Message send failure." = "訊息傳送失敗"; -"What\'s your feedback about our customer support?" = "您對我們的客戶支援團隊有何意見嗎?"; -"Take a screenshot on your iPhone" = "在 iPhone 上擷取螢幕快照"; -"Learn how" = "了解方法"; -"Take a screenshot on your iPad" = "在 iPad 上擷取螢幕快照"; -"Your email(optional)" = "您的電子郵件(選填)"; -"Conversation" = "對話"; -"View Now" = "立刻檢視"; -"SEND ANYWAY" = "直接傳送"; -"OK" = "好"; -"Help" = "說明"; -"Send message" = "傳送訊息"; -"REVIEW" = "評論"; -"Share" = "分享"; -"Close Help" = "關閉說明"; -"Sending your message..." = "正在傳送您的訊息..."; -"Learn how to" = "了解方法"; -"No FAQs found in this section" = "此章節中沒有常見問題集"; -"Thanks for contacting us." = "感謝您與我們聯絡。"; -"Chat Now" = "立刻聊天"; -"Buy Now" = "立刻買"; -"New Conversation" = "新對話"; -"Please check your network connection and try again." = "請檢查您的網際網路連線,然後再試一次。"; -"New message from Support" = "支援團隊送來新訊息"; -"Question" = "問題"; -"Type in a new message" = "輸入新訊息"; -"Email (optional)" = "電子郵件(選填)"; -"Reply" = "回覆"; -"CONTACT US" = "聯絡我們"; -"Email" = "電子郵件"; -"Like" = "讚"; -"Tap here if this FAQ was not helpful to you" = "若此常見問題對您沒有幫助,請點擊此處"; -"Read FAQ" = "閱讀常見問題"; -"Any other feedback? (optional)" = "有其他意見嗎?(選填)"; -"You found this helpful." = "您覺得這很有幫助。"; -"No working Internet connection is found." = "找不到可用的網際網路連線。"; -"No messages found." = "找不到訊息"; -"Please enter a brief description of the issue you are facing." = "請輸入您所遭遇問題的簡略描述。"; -"Shop Now" = "立刻購買"; -"Close Section" = "關閉此章節"; -"Close FAQ" = "關閉常見問題"; -"Close" = "關閉"; -"This conversation has ended." = "此對話已結束。"; -"Send it anyway" = "直接傳送"; -"You accepted review request." = "您已接受評論請求。"; -"Delete" = "刪除"; -"What else can we help you with?" = "還有其他需要我們幫助之處嗎?"; -"Tap here if the answer was not helpful to you" = "若答案對您沒有幫助,請點擊此處"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "很遺憾。能否請您向我們更詳細你遭遇的問題?"; -"Service Rating" = "服務評價"; -"Your email" = "您的電子郵件"; -"Email invalid" = "無效的電子郵件"; -"Could not fetch FAQs" = "無法取得常見問題集"; -"Thanks for rating us." = "感謝您對我們的評價。"; -"Download" = "下載"; -"Please enter a valid email" = "請輸入有效的電子郵件"; -"Message" = "訊息"; -"or" = "或"; -"Decline" = "拒絕"; -"No" = "否"; -"Screenshot could not be sent. Image is too large, try again with another image" = "無法傳送螢幕快照。影像太大,請用其他影像再試一次"; -"Hated it" = "不喜歡"; -"Stars" = "星星"; -"Your feedback has been received." = "您的意見已收悉。"; -"Dislike" = "噓"; -"Preview" = "預覽"; -"Book Now" = "立刻預訂"; -"START A NEW CONVERSATION" = "開始新對話"; -"Your Rating" = "您的評價"; -"No Internet!" = "沒有網路!"; -"Invalid Entry" = "無效的內容"; -"Loved it" = "很喜歡"; -"Review on the App Store" = "在 App Store 上評論"; -"Open Help" = "打開說明"; -"Search" = "搜尋"; -"Tap here if you found this FAQ helpful" = "若您認為此常見問題有幫助,請點擊此處"; -"Star" = "星星"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "若您認為答案有幫助,請點擊此處"; -"Report a problem" = "回報問題"; -"YES, THANKS!" = "是的,謝謝!"; -"Was this helpful?" = "這是否有幫助?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "您的訊息未送出。要點擊「再試一次」來傳送此訊息嗎?"; -"Suggestions" = "建議事項"; -"No FAQs found" = "找不到常見問題集"; -"Done" = "完成"; -"Opening Gallery..." = "打開相簿..."; -"You rated the service with" = "您已為此服務評分:"; -"Cancel" = "取消"; -"Loading..." = "載入中..."; -"Read FAQs" = "閱讀常見問題集"; -"Thanks for messaging us!" = "感謝您給我們訊息!"; -"Try Again" = "再試一次"; -"Send Feedback" = "傳送意見反饋"; -"Your Name" = "您的姓名"; -"Please provide a name." = "請提供名稱。"; -"FAQ" = "常見問題集"; -"Describe your problem" = "請描述您的問題"; -"How can we help?" = "我們能如何為您效勞?"; -"Help about" = "說明主題"; -"We could not fetch the required data" = "我們無法取得所需的資料"; -"Name" = "名稱"; -"Sending failed!" = "傳送失敗!"; -"You didn't find this helpful." = "您覺得這沒有幫助。"; -"Attach a screenshot of your problem" = "附加您的問題螢幕快照"; -"Mark as read" = "標記為已讀"; -"Name invalid" = "無效的名稱"; -"Yes" = "有"; -"What's on your mind?" = "您有什麼想法嗎?"; -"Send a new message" = "傳送新訊息"; -"Questions that may already have your answer" = "您的問題可能已經有答案"; -"Attach a photo" = "附加照片"; -"Accept" = "接受"; -"Your reply" = "您的回覆"; -"Inbox" = "收件匣"; -"Remove attachment" = "移除附件"; -"Could not fetch message" = "無法取得訊息"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "很抱歉,本對話因沒有活動,現已關閉。請開始新對話來和我們的服務人員聯繫。"; -"%d new messages from Support" = "支援團隊送來 %d 則新訊息"; -"Ok, Attach" = "好的,附加"; -"Send" = "傳送"; -"Screenshot size should not exceed %.2f MB" = "螢幕快照大小不得超過 %.2f MB"; -"Information" = "資訊"; -"Issue ID" = "問題 ID"; -"Tap to copy" = "點擊以複製"; -"Copied!" = "已複製!"; -"We couldn't find an FAQ with matching ID" = "我們無法找到具有匹配 ID 的常見問題"; -"Failed to load screenshot" = "載入螢幕快照失敗"; -"Failed to load video" = "載入影片失敗"; -"Failed to load image" = "載入圖像失敗"; -"Hold down your device's power and home buttons at the same time." = "同时按住设备的睡眠/唤醒按钮与主屏幕按钮。"; -"Please note that a few devices may have the power button on the top." = "注意:某些设备的睡眠/唤醒按钮可能在顶部。"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "完成后,请返回此对话,并轻点“好,添加”来添加附件。"; -"Okay" = "确定"; -"We couldn't find an FAQ section with matching ID" = "我們無法找到具有匹配 ID 的常見問題章節"; - -"GIFs are not supported" = "不支持GIF"; diff --git a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Sg.lproj/HelpshiftLocalizable.strings b/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Sg.lproj/HelpshiftLocalizable.strings deleted file mode 100644 index 9f22cdecd08a..000000000000 --- a/WordPress/Vendor/Helpshift/HelpshiftDefaultLocalizations/zh-Sg.lproj/HelpshiftLocalizable.strings +++ /dev/null @@ -1,149 +0,0 @@ -/* -HelpshiftLocalizable.strings -Helpshift -Copyright (c) 2014 Helpshift,Inc., All rights reserved. -*/ - -"Can't find what you were looking for?" = "找不到所需的答案吗?"; -"Rate App" = "评价应用"; -"We\'re happy to help you!" = "我们很乐意为您提供帮助!"; -"Did we answer all your questions?" = "我们有没有回答您的所有问题?"; -"Remind Later" = "稍后提醒"; -"Your message has been received." = "我们已收到您的消息。"; -"Message send failure." = "消息发送失败。"; -"What\'s your feedback about our customer support?" = "您对我们的客服有什么看法?"; -"Take a screenshot on your iPhone" = "在iPhone上屏幕截图"; -"Learn how" = "了解使用方法"; -"Take a screenshot on your iPad" = "在iPad上屏幕截图"; -"Your email(optional)" = "您的电子邮件(可选)"; -"Conversation" = "对话"; -"View Now" = "立刻查看"; -"SEND ANYWAY" = "仍然发送"; -"OK" = "确定"; -"Help" = "帮助"; -"Send message" = "发送消息"; -"REVIEW" = "评论"; -"Share" = "分享"; -"Close Help" = "关闭帮助"; -"Sending your message..." = "正在发送消息……"; -"Learn how to" = "了解使用方法"; -"No FAQs found in this section" = "未在本节内找到常见问题解答"; -"Thanks for contacting us." = "感谢您联系我们。"; -"Chat Now" = "立刻开始聊天"; -"Buy Now" = "立刻购买"; -"New Conversation" = "新对话"; -"Please check your network connection and try again." = "请检查网络连接然后重试。"; -"New message from Support" = "客服团队新消息"; -"Question" = "问题"; -"Type in a new message" = "输入一条新消息"; -"Email (optional)" = "电子邮件(可选)"; -"Reply" = "回复"; -"CONTACT US" = "联系我们"; -"Email" = "电子邮件Email"; -"Like" = "赞"; -"Tap here if this FAQ was not helpful to you" = "如果这个FAQ对您没有帮助,请按这里"; -"Any other feedback? (optional)" = "还有其他意见或建议吗?(可选)"; -"You found this helpful." = "有帮助。"; -"No working Internet connection is found." = "未找到可用的互联网连接。"; -"No messages found." = "未找到消息。"; -"Please enter a brief description of the issue you are facing." = "请简要说明您遇到的问题。"; -"Shop Now" = "立刻前往商店"; -"Close Section" = "关闭分区"; -"Close FAQ" = "关闭常见问题解答"; -"Close" = "关闭"; -"This conversation has ended." = "该对话已结束。"; -"Send it anyway" = "仍然发送"; -"You accepted review request." = "您接受了评论申请。"; -"Delete" = "删除"; -"What else can we help you with?" = "我们还能为您做些什么?"; -"Tap here if the answer was not helpful to you" = "如果回答对您没有帮助,请按这里"; -"Sorry to hear that. Could you please tell us a little bit more about the problem you are facing?" = "很遗憾。您能向我们提供更多问题说明吗?"; -"Service Rating" = "服务评价"; -"Your email" = "您的电子邮件"; -"Email invalid" = "电子邮件无效"; -"Could not fetch FAQs" = "无法获取常见问题解答"; -"Thanks for rating us." = "感谢您对我们的评价。"; -"Download" = "下载"; -"Please enter a valid email" = "请输入有效的电子邮件"; -"Message" = "消息"; -"or" = "或"; -"Decline" = "拒绝"; -"No" = "否"; -"Screenshot could not be sent. Image is too large, try again with another image" = "屏幕截图未能发送。图像尺寸过大,请使用其他图像重试"; -"Hated it" = "不喜欢"; -"Stars" = "星"; -"Your feedback has been received." = "我们已收到您的反馈信息。"; -"Dislike" = "不喜欢"; -"Preview" = "预览"; -"Book Now" = "立刻订购"; -"START A NEW CONVERSATION" = "开始新对话"; -"Your Rating" = "您的评价"; -"No Internet!" = "无互联网连接!"; -"Invalid Entry" = "无效输入"; -"Loved it" = "喜欢"; -"Review on the App Store" = "去App Store上评论"; -"Open Help" = "打开帮助"; -"Search" = "搜索"; -"Tap here if you found this FAQ helpful" = "如果这个FAQ有帮助,请按这里"; -"Star" = "星"; -"IssueDescriptionMinimumCharacterLength" = 1; -"Tap here if you found this answer helpful" = "如果这个回答有帮助,请按这里"; -"Report a problem" = "报告问题"; -"YES, THANKS!" = "好,谢谢!"; -"Was this helpful?" = "该话题有帮助吗?"; -"Your message was not sent.Tap \"Try Again\" to send this message?" = "你的消息未发送。请按“重试”再次发送该消息。"; -"Suggestions" = "建议"; -"No FAQs found" = "未找到常见问题解答"; -"Done" = "完成"; -"Opening Gallery..." = "打开相册……"; -"You rated the service with" = "您对服务的评价为"; -"Cancel" = "取消"; -"Loading..." = "正在加载..."; -"Read FAQs" = "查看常见问题解答"; -"Thanks for messaging us!" = "感谢您的消息!"; -"Try Again" = "再试一次"; -"Send Feedback" = "发送反馈"; -"Your Name" = "您的姓名"; -"Please provide a name." = "请输入名称。"; -"FAQ" = "常见问题解答"; -"Describe your problem" = "说明您遇到的问题"; -"How can we help?" = "我们能为您做些什么?"; -"Help about" = "帮助主题:"; -"We could not fetch the required data" = "我们无法获取所需数据"; -"Name" = "名称"; -"Sending failed!" = "发送失败!"; -"You didn't find this helpful." = "你认为这没有帮助。"; -"Attach a screenshot of your problem" = "添加问题截图"; -"Mark as read" = "标记为已读"; -"Name invalid" = "名称无效"; -"Yes" = "是"; -"What's on your mind?" = "您在想什么?"; -"Send a new message" = "发送新消息"; -"Questions that may already have your answer" = "已经有了答案的问题"; -"Attach a photo" = "添加照片"; -"Accept" = "接受"; -"Your reply" = "您的回复"; -"Inbox" = "收件箱"; -"Remove attachment" = "移除附件"; -"Could not fetch message" = "无法获取消息"; -"Read FAQ" = "查看常见问题解答"; -"Sorry! This conversation was closed due to inactivity. Please start a new conversation with our agents." = "抱歉!该对话因为不活跃而关闭。请与我们的代理开始新对话。"; -"%d new messages from Support" = "%d条客服团队新消息"; -"Ok, Attach" = "好,添加"; -"Send" = "发送"; -"Screenshot size should not exceed %.2f MB" = "屏幕截图不应超过%.2fMB"; -"Information" = "信息"; -"Issue ID" = "问题 ID"; -"Tap to copy" = "点击以复制"; -"Copied!" = "已复制!"; -"We couldn't find an FAQ with matching ID" = "我们未能找到与ID匹配的常见问题解答"; -"Failed to load screenshot" = "屏幕快照加载失败"; -"Failed to load video" = "视频加载失败"; -"Failed to load image" = "图像加载失败"; -"Hold down your device's power and home buttons at the same time." = "請同時按住裝置上的睡眠/喚醒按鈕和主畫面按鈕。"; -"Please note that a few devices may have the power button on the top." = "請注意,部分裝置的睡眠/喚醒按鈕位置於裝置的上方。"; -"Once done, come back to this conversation and tap on \"Ok, attach\" to attach it." = "完成後,再次回到此對話,並按下「好的,附加」以附加。"; -"Okay" = "完成"; -"We couldn't find an FAQ section with matching ID" = "我们未能找到与ID匹配的常见问题解答章节"; - -"GIFs are not supported" = "不支持GIF"; diff --git a/WordPress/WordPress-Alpha.entitlements b/WordPress/WordPress-Alpha.entitlements index 1118dd0c0f98..0e3350cf3abe 100644 --- a/WordPress/WordPress-Alpha.entitlements +++ b/WordPress/WordPress-Alpha.entitlements @@ -7,6 +7,8 @@ webcredentials:wordpress.com applinks:wordpress.com applinks:apps.wordpress.com + applinks:links.wp.a8cmail.com + applinks:*.wordpress.com com.apple.security.application-groups diff --git a/WordPress/WordPress-Internal-Info.plist b/WordPress/WordPress-Internal-Info.plist index 4008a12f1a51..af06a92cc6cd 100644 --- a/WordPress/WordPress-Internal-Info.plist +++ b/WordPress/WordPress-Internal-Info.plist @@ -6,8 +6,202 @@ en CFBundleDisplayName WP Internal + CFBundleExecutable + ${EXECUTABLE_NAME} CFBundleIcons + CFBundleAlternateIcons + + Black + + CFBundleIconFiles + + black-icon-app-60x60 + black-icon-app-76x76 + black-icon-app-83.5x83.5 + + UIPrerenderedIcon + + + Black Classic + + CFBundleIconFiles + + black-classic-icon-app-60x60 + black-classic-icon-app-76x76 + black-classic-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPRequiresBorder + + + Blue Classic + + CFBundleIconFiles + + blue-classic-icon-app-60x60 + blue-classic-icon-app-76x76 + blue-classic-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPRequiresBorder + + + Celadon + + CFBundleIconFiles + + celadon-icon-app-60x60 + celadon-icon-app-76x76 + celadon-icon-app-83.5x83.5 + + UIPrerenderedIcon + + + Celadon Classic + + CFBundleIconFiles + + celadon-classic-icon-app-60x60 + celadon-classic-icon-app-76x76 + celadon-classic-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPRequiresBorder + + + Hot Pink + + CFBundleIconFiles + + hot-pink-icon-app-60x60 + hot-pink-icon-app-76x76 + hot-pink-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPLegacyIcon + + + Jetpack Green + + CFBundleIconFiles + + jetpack-green-icon-app-60x60 + jetpack-green-icon-app-76x76 + jetpack-green-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPLegacyIcon + + + Open Source + + CFBundleIconFiles + + open-source-icon-app-60x60 + open-source-icon-app-76x76 + open-source-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPLegacyIcon + + WPRequiresBorder + + + Open Source Dark + + CFBundleIconFiles + + open-source-dark-icon-app-60x60 + open-source-dark-icon-app-76x76 + open-source-dark-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPLegacyIcon + + + Pink + + CFBundleIconFiles + + pink-icon-app-60x60 + pink-icon-app-76x76 + pink-icon-app-83.5x83.5 + + UIPrerenderedIcon + + + Pink Classic + + CFBundleIconFiles + + pink-classic-icon-app-60x60 + pink-classic-icon-app-76x76 + pink-classic-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPRequiresBorder + + + Pride + + CFBundleIconFiles + + pride-icon-app-60x60 + pride-icon-app-76x76 + pride-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPLegacyIcon + + + Spectrum + + CFBundleIconFiles + + spectrum-icon-app-60x60 + spectrum-icon-app-76x76 + spectrum-icon-app-83.5x83.5 + + UIPrerenderedIcon + + + Spectrum Classic + + CFBundleIconFiles + + spectrum-classic-icon-app-60x60 + spectrum-classic-icon-app-76x76 + spectrum-classic-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPRequiresBorder + + + WordPress Dark + + CFBundleIconFiles + + wordpress-dark-icon-app-60x60 + wordpress-dark-icon-app-76x76 + wordpress-dark-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPLegacyIcon + + + CFBundlePrimaryIcon CFBundleIconFiles @@ -17,98 +211,211 @@ UIPrerenderedIcon + + CFBundleIcons~ipad + CFBundleAlternateIcons - WordPress Dark + Black CFBundleIconFiles - wordpress_dark_icon_20pt - wordpress_dark_icon_29pt - wordpress_dark_icon_40pt - wordpress_dark_icon_60pt - wordpress_dark_icon_76pt - wordpress_dark_icon_83.5 + black-icon-app-60x60 + black-icon-app-76x76 + black-icon-app-83.5x83.5 UIPrerenderedIcon - Open Source + Black Classic CFBundleIconFiles - open_source_icon_20pt - open_source_icon_29pt - open_source_icon_40pt - open_source_icon_60pt - open_source_icon_76pt - open_source_icon_83.5 + black-classic-icon-app-60x60 + black-classic-icon-app-76x76 + black-classic-icon-app-83.5x83.5 UIPrerenderedIcon WPRequiresBorder - Open Source Dark + Blue Classic + + CFBundleIconFiles + + blue-classic-icon-app-60x60 + blue-classic-icon-app-76x76 + blue-classic-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPRequiresBorder + + + Celadon CFBundleIconFiles - open_source_dark_icon_20pt - open_source_dark_icon_29pt - open_source_dark_icon_40pt - open_source_dark_icon_60pt - open_source_dark_icon_76pt - open_source_dark_icon_83.5 + celadon-icon-app-60x60 + celadon-icon-app-76x76 + celadon-icon-app-83.5x83.5 UIPrerenderedIcon + Celadon Classic + + CFBundleIconFiles + + celadon-classic-icon-app-60x60 + celadon-classic-icon-app-76x76 + celadon-classic-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPRequiresBorder + + Hot Pink CFBundleIconFiles - hot_pink_icon_20pt - hot_pink_icon_29pt - hot_pink_icon_40pt - hot_pink_icon_60pt - hot_pink_icon_76pt - hot_pink_icon_83.5 + hot-pink-icon-app-60x60 + hot-pink-icon-app-76x76 + hot-pink-icon-app-83.5x83.5 UIPrerenderedIcon + WPLegacyIcon + Jetpack Green CFBundleIconFiles - jetpack_green_icon_20pt - jetpack_green_icon_29pt - jetpack_green_icon_40pt - jetpack_green_icon_60pt - jetpack_green_icon_76pt - jetpack_green_icon_83.5 + jetpack-green-icon-app-60x60 + jetpack-green-icon-app-76x76 + jetpack-green-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPLegacyIcon + + + Open Source + + CFBundleIconFiles + + open-source-icon-app-60x60 + open-source-icon-app-76x76 + open-source-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPLegacyIcon + + WPRequiresBorder + + + Open Source Dark + + CFBundleIconFiles + + open-source-dark-icon-app-60x60 + open-source-dark-icon-app-76x76 + open-source-dark-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPLegacyIcon + + + Pink + + CFBundleIconFiles + + pink-icon-app-60x60 + pink-icon-app-76x76 + pink-icon-app-83.5x83.5 + + UIPrerenderedIcon + + + Pink Classic + + CFBundleIconFiles + + pink-classic-icon-app-60x60 + pink-classic-icon-app-76x76 + pink-classic-icon-app-83.5x83.5 UIPrerenderedIcon + WPRequiresBorder + Pride CFBundleIconFiles - pride_icon_20pt - pride_icon_29pt - pride_icon_40pt - pride_icon_60pt - pride_icon_76pt - pride_icon_83.5 + pride-icon-app-60x60 + pride-icon-app-76x76 + pride-icon-app-83.5x83.5 UIPrerenderedIcon + WPLegacyIcon + + + Spectrum + + CFBundleIconFiles + + spectrum-icon-app-60x60 + spectrum-icon-app-76x76 + spectrum-icon-app-83.5x83.5 + + UIPrerenderedIcon + + + Spectrum Classic + + CFBundleIconFiles + + spectrum-classic-icon-app-60x60 + spectrum-classic-icon-app-76x76 + spectrum-classic-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPRequiresBorder + + + WordPress Dark + + CFBundleIconFiles + + wordpress-dark-icon-app-60x60 + wordpress-dark-icon-app-76x76 + wordpress-dark-icon-app-83.5x83.5 + + UIPrerenderedIcon + + WPLegacyIcon + + CFBundlePrimaryIcon + + CFBundleIconFiles + + AppIcon + + UIPrerenderedIcon + + - CFBundleExecutable - ${EXECUTABLE_NAME} CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion @@ -136,6 +443,8 @@ org.wordpress CFBundleURLSchemes + wordpressmigration+v1 + wordpressnotificationmigration wordpress-oauth-v2 ${WPCOM_SCHEME} @@ -147,7 +456,7 @@ GoogleSignIn CFBundleURLSchemes - GOOGLE_LOGIN_SCHEME_ID + com.googleusercontent.apps.108380595987-ujhrhknecrqli756i72gkcs4aaia6nhb @@ -157,6 +466,8 @@ LSApplicationQueriesSchemes + $(JP_SCHEME) + jetpacknotificationmigration org-appextension-feature-password-management twitter @@ -177,19 +488,18 @@ WordPress would like to add your location to posts on sites where you have enabled geotagging. NSMicrophoneUsageDescription Enable microphone access to record sound in your videos. - NSPhotoLibraryUsageDescription - To add photos or videos to your posts. NSPhotoLibraryAddUsageDescription To add photos or videos to your posts. + NSPhotoLibraryUsageDescription + To add photos or videos to your posts. UIAppFonts Noticons.ttf - UIApplicationShortcutWidget - org.wordpress.internal.WordPressTodayWidget UIBackgroundModes fetch + processing remote-notification UILaunchStoryboardName diff --git a/WordPress/WordPress-Internal.entitlements b/WordPress/WordPress-Internal.entitlements index cad33173cba7..e40c5eddc1a6 100644 --- a/WordPress/WordPress-Internal.entitlements +++ b/WordPress/WordPress-Internal.entitlements @@ -9,6 +9,8 @@ webcredentials:wordpress.com applinks:wordpress.com applinks:apps.wordpress.com + applinks:links.wp.a8cmail.com + applinks:*.wordpress.com com.apple.security.application-groups diff --git a/WordPress/WordPress-Swift.h b/WordPress/WordPress-Swift.h new file mode 100644 index 000000000000..0e81e0d3e74d --- /dev/null +++ b/WordPress/WordPress-Swift.h @@ -0,0 +1,7 @@ +/// Due to a known issue, the compiler produces warnings on the Swift code that an intent definition file generates. +/// Ref: https://developer.apple.com/forums/thread/686448 +/// As a workaround, we ignore all multiple method declarations warnings when importing WordPress-Swift.h +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wduplicate-method-match" +#import "WordPress-Swift-XcodeGenerated.h" +#pragma clang diagnostic pop diff --git a/WordPress/WordPress.entitlements b/WordPress/WordPress.entitlements index dd0f617c2f50..88743e39627f 100644 --- a/WordPress/WordPress.entitlements +++ b/WordPress/WordPress.entitlements @@ -14,6 +14,8 @@ applinks:wordpress.com applinks:apps.wordpress.com applinks:public-api.wordpress.com + applinks:links.wp.a8cmail.com + applinks:*.wordpress.com com.apple.developer.icloud-container-identifiers diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index f0f9f5932b84..e66d539178a2 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -3,10 +3,21 @@ archiveVersion = 1; classes = { }; - objectVersion = 48; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ + 096A92F526E29FFF00448C68 /* GenerateCredentials */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 096A92FA26E2A00000448C68 /* Build configuration list for PBXAggregateTarget "GenerateCredentials" */; + buildPhases = ( + 096A92FB26E2A05400448C68 /* Generate Secrets / Credentials */, + ); + dependencies = ( + ); + name = GenerateCredentials; + productName = GenerateCredentials; + }; A2795807198819DE0031C6A3 /* OCLint */ = { isa = PBXAggregateTarget; buildConfigurationList = A279580C198819DE0031C6A3 /* Build configuration list for PBXAggregateTarget "OCLint" */; @@ -29,23 +40,136 @@ name = SwiftLint; productName = SwiftLint; }; - FFC3F6F41B0DBF0900EFC359 /* UpdatePlistPreprocessor */ = { - isa = PBXAggregateTarget; - buildConfigurationList = FFC3F6F51B0DBF1000EFC359 /* Build configuration list for PBXAggregateTarget "UpdatePlistPreprocessor" */; - buildPhases = ( - FFC3F6FA1B0DBF1E00EFC359 /* Update Plist Preprocessor file */, - ); - dependencies = ( - ); - name = UpdatePlistPreprocessor; - productName = UpdatePlistPreprocessor; - }; /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ - 00F2E3F8166EEF9800D0527C /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 834CE7371256D0F60046A4A3 /* CoreGraphics.framework */; }; - 00F2E3FA166EEFBE00D0527C /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E10B3653158F2D4500419A93 /* UIKit.framework */; }; - 00F2E3FB166EEFE100D0527C /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E10B3651158F2D3F00419A93 /* QuartzCore.framework */; }; + 010459E629153FFF000C7778 /* JetpackNotificationMigrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010459E529153FFF000C7778 /* JetpackNotificationMigrationService.swift */; }; + 010459E729153FFF000C7778 /* JetpackNotificationMigrationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010459E529153FFF000C7778 /* JetpackNotificationMigrationService.swift */; }; + 010459ED2915519C000C7778 /* JetpackNotificationMigrationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010459EC2915519C000C7778 /* JetpackNotificationMigrationServiceTests.swift */; }; + 0107E0B428F97D5000DE87DB /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; + 0107E0B528F97D5000DE87DB /* StatsWidgetEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F63B93B258179D100F581BE /* StatsWidgetEntry.swift */; }; + 0107E0B628F97D5000DE87DB /* HomeWidgetCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA53E9B256571D800F4D9A2 /* HomeWidgetCache.swift */; }; + 0107E0B728F97D5000DE87DB /* ListStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FCF66E825CAF8C50047F337 /* ListStatsView.swift */; }; + 0107E0B828F97D5000DE87DB /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64028AAC82600EABE26 /* KeychainUtils.swift */; }; + 0107E0B928F97D5000DE87DB /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690141F828FF000200E30 /* BuildConfiguration.swift */; }; + 0107E0BA28F97D5000DE87DB /* TodayWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E58A2E2360D23400E5534B /* TodayWidgetStats.swift */; }; + 0107E0BB28F97D5000DE87DB /* StatsWidgetsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2F0C15256C6B2C003351C7 /* StatsWidgetsService.swift */; }; + 0107E0BC28F97D5000DE87DB /* StatsWidgetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F526D562539FAC60069706C /* StatsWidgetsView.swift */; }; + 0107E0BD28F97D5000DE87DB /* AppLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */; }; + 0107E0BE28F97D5000DE87DB /* MultiStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5689FF25420DE80048A9E4 /* MultiStatsView.swift */; }; + 0107E0BF28F97D5000DE87DB /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; + 0107E0C028F97D5000DE87DB /* WordPressHomeWidgetToday.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F526C522538CF2A0069706C /* WordPressHomeWidgetToday.swift */; }; + 0107E0C128F97D5000DE87DB /* FlexibleCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F568A2E254216550048A9E4 /* FlexibleCard.swift */; }; + 0107E0C228F97D5000DE87DB /* VerticalCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F568A1E254213B60048A9E4 /* VerticalCard.swift */; }; + 0107E0C328F97D5000DE87DB /* ThisWeekWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F93181239AF64800E4E96E /* ThisWeekWidgetStats.swift */; }; + 0107E0C428F97D5000DE87DB /* HomeWidgetAllTimeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5C861925C9EA2500BABE64 /* HomeWidgetAllTimeData.swift */; }; + 0107E0C528F97D5000DE87DB /* GroupedViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE20C1425CF165700A15525 /* GroupedViewData.swift */; }; + 0107E0C628F97D5000DE87DB /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690151F828FF000200E30 /* FeatureFlag.swift */; }; + 0107E0C728F97D5000DE87DB /* StatsWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8EEC4D25B4817000EC9DAE /* StatsWidgets.swift */; }; + 0107E0C828F97D5000DE87DB /* StatsValueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA59B99258289E30073772F /* StatsValueView.swift */; }; + 0107E0C928F97D5000DE87DB /* SiteListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8EEC6F25B4849A00EC9DAE /* SiteListProvider.swift */; }; + 0107E0CA28F97D5000DE87DB /* HomeWidgetThisWeekData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B136C25D08F34004FAC0A /* HomeWidgetThisWeekData.swift */; }; + 0107E0CB28F97D5000DE87DB /* WordPressHomeWidgetAllTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5C86BF25CA197500BABE64 /* WordPressHomeWidgetAllTime.swift */; }; + 0107E0CC28F97D5000DE87DB /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; + 0107E0CD28F97D5000DE87DB /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; + 0107E0CE28F97D5000DE87DB /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FCF66FA25CAF8E00047F337 /* ListRow.swift */; }; + 0107E0D028F97D5000DE87DB /* WordPressHomeWidgetThisWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B138E25D09AA5004FAC0A /* WordPressHomeWidgetThisWeek.swift */; }; + 0107E0D128F97D5000DE87DB /* SingleStatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5689EF254209790048A9E4 /* SingleStatView.swift */; }; + 0107E0D228F97D5000DE87DB /* UnconfiguredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAA18CB25797B85002B1911 /* UnconfiguredView.swift */; }; + 0107E0D328F97D5000DE87DB /* Tracks+StatsWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98390AC2254C984700868F0A /* Tracks+StatsWidgets.swift */; }; + 0107E0D428F97D5000DE87DB /* HomeWidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6DA04025646F96002AB88F /* HomeWidgetData.swift */; }; + 0107E0D528F97D5000DE87DB /* HomeWidgetTodayData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB34ACA25672A90001A74A6 /* HomeWidgetTodayData.swift */; }; + 0107E0D628F97D5000DE87DB /* AllTimeWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFF57D23984344008A1DCB /* AllTimeWidgetStats.swift */; }; + 0107E0D728F97D5000DE87DB /* Sites.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 3F46AB0225BF5D6300CE2E98 /* Sites.intentdefinition */; }; + 0107E0D828F97D5000DE87DB /* LocalizableStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE77C8225B0CA89007DE9E5 /* LocalizableStrings.swift */; }; + 0107E0D928F97D5000DE87DB /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; + 0107E0DA28F97D5000DE87DB /* ListViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE20C3625CF211F00A15525 /* ListViewData.swift */; }; + 0107E0DB28F97D5000DE87DB /* Double+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C82B52193A7B900A06E84 /* Double+Stats.swift */; }; + 0107E0DC28F97D5000DE87DB /* Tracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA22821C99F6180016CA7C /* Tracks.swift */; }; + 0107E0DE28F97D5000DE87DB /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F526C4F2538CF2A0069706C /* SwiftUI.framework */; }; + 0107E0DF28F97D5000DE87DB /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F526C4D2538CF2A0069706C /* WidgetKit.framework */; }; + 0107E0E228F97D5000DE87DB /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3F526C552538CF2B0069706C /* Assets.xcassets */; }; + 0107E0E328F97D5000DE87DB /* ColorPalette.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 435B76292297484200511813 /* ColorPalette.xcassets */; }; + 0107E0EE28F97E6900DE87DB /* JetpackStatsWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0107E0EA28F97D5000DE87DB /* JetpackStatsWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 0107E11228FD7FE200DE87DB /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25FA332609AAAA0005E08F /* AppConfiguration.swift */; }; + 0107E11328FD7FE300DE87DB /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25FA332609AAAA0005E08F /* AppConfiguration.swift */; }; + 0107E11428FD7FE300DE87DB /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25FA332609AAAA0005E08F /* AppConfiguration.swift */; }; + 0107E11528FD7FE500DE87DB /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25FA332609AAAA0005E08F /* AppConfiguration.swift */; }; + 0107E11628FD7FE800DE87DB /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25FA332609AAAA0005E08F /* AppConfiguration.swift */; }; + 0107E13B28FE9DB200DE87DB /* Sites.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 3F46AB0225BF5D6300CE2E98 /* Sites.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; }; + 0107E13C28FE9DB200DE87DB /* ThisWeekWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F93181239AF64800E4E96E /* ThisWeekWidgetStats.swift */; }; + 0107E13D28FE9DB200DE87DB /* HomeWidgetAllTimeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5C861925C9EA2500BABE64 /* HomeWidgetAllTimeData.swift */; }; + 0107E13E28FE9DB200DE87DB /* SitesDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1482CDF2575BDA4007E4DD6 /* SitesDataProvider.swift */; }; + 0107E13F28FE9DB200DE87DB /* HomeWidgetTodayData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB34ACA25672A90001A74A6 /* HomeWidgetTodayData.swift */; }; + 0107E14028FE9DB200DE87DB /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; + 0107E14128FE9DB200DE87DB /* HomeWidgetCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA53E9B256571D800F4D9A2 /* HomeWidgetCache.swift */; }; + 0107E14228FE9DB200DE87DB /* AllTimeWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFF57D23984344008A1DCB /* AllTimeWidgetStats.swift */; }; + 0107E14328FE9DB200DE87DB /* TodayWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E58A2E2360D23400E5534B /* TodayWidgetStats.swift */; }; + 0107E14428FE9DB200DE87DB /* HomeWidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6DA04025646F96002AB88F /* HomeWidgetData.swift */; }; + 0107E14528FE9DB200DE87DB /* HomeWidgetThisWeekData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B136C25D08F34004FAC0A /* HomeWidgetThisWeekData.swift */; }; + 0107E14728FE9DB200DE87DB /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; + 0107E14828FE9DB200DE87DB /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; + 0107E14928FE9DB200DE87DB /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F163C025658B4D003DC13B /* IntentHandler.swift */; }; + 0107E14D28FE9DB200DE87DB /* AppImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 433840C622C2BA5B00CB13F8 /* AppImages.xcassets */; }; + 0107E15928FEB10E00DE87DB /* JetpackIntents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0107E15428FE9DB200DE87DB /* JetpackIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 0107E15D28FFE99300DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E15C28FFE99300DE87DB /* WidgetConfiguration.swift */; }; + 0107E15E28FFE99300DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E15C28FFE99300DE87DB /* WidgetConfiguration.swift */; }; + 0107E15F28FFE99300DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E15C28FFE99300DE87DB /* WidgetConfiguration.swift */; }; + 0107E16028FFE99300DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E15C28FFE99300DE87DB /* WidgetConfiguration.swift */; }; + 0107E16128FFE99300DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E15C28FFE99300DE87DB /* WidgetConfiguration.swift */; }; + 0107E16228FFE99300DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E15C28FFE99300DE87DB /* WidgetConfiguration.swift */; }; + 0107E16428FFED1800DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E16328FFED1800DE87DB /* WidgetConfiguration.swift */; }; + 0107E16528FFED1800DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E16328FFED1800DE87DB /* WidgetConfiguration.swift */; }; + 0107E16628FFED1800DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E16328FFED1800DE87DB /* WidgetConfiguration.swift */; }; + 0107E16A28FFED1800DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E16328FFED1800DE87DB /* WidgetConfiguration.swift */; }; + 0107E16B28FFED1800DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E16328FFED1800DE87DB /* WidgetConfiguration.swift */; }; + 0107E16C28FFED1800DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E16328FFED1800DE87DB /* WidgetConfiguration.swift */; }; + 0107E16D28FFED1800DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E16328FFED1800DE87DB /* WidgetConfiguration.swift */; }; + 0107E16E28FFEF3700DE87DB /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25F9FD2609AA830005E08F /* AppConfiguration.swift */; }; + 0107E16F28FFEF4500DE87DB /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25FA332609AAAA0005E08F /* AppConfiguration.swift */; }; + 0107E17028FFEF4F00DE87DB /* WidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E15C28FFE99300DE87DB /* WidgetConfiguration.swift */; }; + 0107E1852900059300DE87DB /* LocalizationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E1842900059300DE87DB /* LocalizationConfiguration.swift */; }; + 0107E1872900065500DE87DB /* LocalizationConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0107E1862900065400DE87DB /* LocalizationConfiguration.swift */; }; + 0107E18A29000E1500DE87DB /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; + 0107E18B29000E1700DE87DB /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; + 0107E18C29000E2A00DE87DB /* AppStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD2544126116CEA00EDAF88 /* AppStyleGuide.swift */; }; + 0107E18D29000E3300DE87DB /* AppStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD2538E26116A1600EDAF88 /* AppStyleGuide.swift */; }; + 0107E18E29000EA100DE87DB /* UIColor+MurielColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435B762122973D0600511813 /* UIColor+MurielColors.swift */; }; + 0107E18F29000EA200DE87DB /* UIColor+MurielColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435B762122973D0600511813 /* UIColor+MurielColors.swift */; }; + 0118968F29D1EB5E00D34BA9 /* DomainsDashboardCardHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0118968E29D1EB5E00D34BA9 /* DomainsDashboardCardHelper.swift */; }; + 0118969129D1F2FE00D34BA9 /* DomainsDashboardCardHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0118969029D1F2FE00D34BA9 /* DomainsDashboardCardHelperTests.swift */; }; + 0118969229D2CA6F00D34BA9 /* DomainsDashboardCardHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0118968E29D1EB5E00D34BA9 /* DomainsDashboardCardHelper.swift */; }; + 0118969F29D30CAD00D34BA9 /* DomainsDashboardCardTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0118969E29D30CAD00D34BA9 /* DomainsDashboardCardTracker.swift */; }; + 011896A029D30CAD00D34BA9 /* DomainsDashboardCardTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0118969E29D30CAD00D34BA9 /* DomainsDashboardCardTracker.swift */; }; + 011896A229D5AF0700D34BA9 /* BlogDashboardCardConfigurable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011896A129D5AF0700D34BA9 /* BlogDashboardCardConfigurable.swift */; }; + 011896A329D5AF0700D34BA9 /* BlogDashboardCardConfigurable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011896A129D5AF0700D34BA9 /* BlogDashboardCardConfigurable.swift */; }; + 011896A529D5B72500D34BA9 /* DomainsDashboardCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011896A429D5B72500D34BA9 /* DomainsDashboardCoordinator.swift */; }; + 011896A629D5B72500D34BA9 /* DomainsDashboardCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011896A429D5B72500D34BA9 /* DomainsDashboardCoordinator.swift */; }; + 011896A829D5BBB400D34BA9 /* DomainsDashboardFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011896A729D5BBB400D34BA9 /* DomainsDashboardFactory.swift */; }; + 011896A929D5BBB400D34BA9 /* DomainsDashboardFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011896A729D5BBB400D34BA9 /* DomainsDashboardFactory.swift */; }; + 0141929C2983F0A300CAEDB0 /* SupportConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0141929B2983F0A300CAEDB0 /* SupportConfiguration.swift */; }; + 0141929D2983F0A300CAEDB0 /* SupportConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0141929B2983F0A300CAEDB0 /* SupportConfiguration.swift */; }; + 014192A02983F5E800CAEDB0 /* SupportConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0141929F2983F5E800CAEDB0 /* SupportConfigurationTests.swift */; }; + 0147D64E294B1E1600AA6410 /* StatsRevampStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0147D64D294B1E1600AA6410 /* StatsRevampStore.swift */; }; + 0147D64F294B1E1600AA6410 /* StatsRevampStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0147D64D294B1E1600AA6410 /* StatsRevampStore.swift */; }; + 0147D651294B6EA600AA6410 /* StatsRevampStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0147D650294B6EA600AA6410 /* StatsRevampStoreTests.swift */; }; + 0148CC292859127F00CF5D96 /* StatsWidgetsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0148CC282859127F00CF5D96 /* StatsWidgetsStoreTests.swift */; }; + 0148CC2B2859C87000CF5D96 /* BlogServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0148CC2A2859C87000CF5D96 /* BlogServiceMock.swift */; }; + 015BA4EB29A788A300920F4B /* StatsTotalInsightsCellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 015BA4EA29A788A300920F4B /* StatsTotalInsightsCellTests.swift */; }; + 01CE5007290A889F00A9C2E0 /* TracksConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CE5006290A889F00A9C2E0 /* TracksConfiguration.swift */; }; + 01CE5008290A88BD00A9C2E0 /* TracksConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CE5006290A889F00A9C2E0 /* TracksConfiguration.swift */; }; + 01CE500C290A88BF00A9C2E0 /* TracksConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CE5006290A889F00A9C2E0 /* TracksConfiguration.swift */; }; + 01CE500E290A88C100A9C2E0 /* TracksConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CE5006290A889F00A9C2E0 /* TracksConfiguration.swift */; }; + 01CE500F290A88C100A9C2E0 /* TracksConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CE5006290A889F00A9C2E0 /* TracksConfiguration.swift */; }; + 01CE5012290A890B00A9C2E0 /* TracksConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CE5010290A890300A9C2E0 /* TracksConfiguration.swift */; }; + 01CE5013290A890E00A9C2E0 /* TracksConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CE5010290A890300A9C2E0 /* TracksConfiguration.swift */; }; + 01CE5014290A890E00A9C2E0 /* TracksConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CE5010290A890300A9C2E0 /* TracksConfiguration.swift */; }; + 01CE5015290A890F00A9C2E0 /* TracksConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01CE5010290A890300A9C2E0 /* TracksConfiguration.swift */; }; + 01DBFD8729BDCBF200F3720F /* JetpackNativeConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01DBFD8629BDCBF200F3720F /* JetpackNativeConnectionService.swift */; }; + 01DBFD8829BDCBF200F3720F /* JetpackNativeConnectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01DBFD8629BDCBF200F3720F /* JetpackNativeConnectionService.swift */; }; + 01E61E5A29F03DEC002E544E /* DashboardDomainsCardSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E61E5929F03DEC002E544E /* DashboardDomainsCardSearchView.swift */; }; + 01E61E5B29F03DEC002E544E /* DashboardDomainsCardSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E61E5929F03DEC002E544E /* DashboardDomainsCardSearchView.swift */; }; + 01E78D1D296EA54F00FB6863 /* StatsPeriodHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01E78D1C296EA54F00FB6863 /* StatsPeriodHelperTests.swift */; }; 02761EC02270072F009BAF0F /* BlogDetailsViewController+SectionHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02761EBF2270072F009BAF0F /* BlogDetailsViewController+SectionHelpers.swift */; }; 02761EC222700A9C009BAF0F /* BlogDetailsSubsectionToSectionCategoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02761EC122700A9C009BAF0F /* BlogDetailsSubsectionToSectionCategoryTests.swift */; }; 02761EC4227010BC009BAF0F /* BlogDetailsSectionIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02761EC3227010BC009BAF0F /* BlogDetailsSectionIndexTests.swift */; }; @@ -55,10 +179,16 @@ 02BE5CC02281B53F00E351BA /* RegisterDomainDetailsViewModelLoadingStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BE5CBF2281B53F00E351BA /* RegisterDomainDetailsViewModelLoadingStateTests.swift */; }; 02BF30532271D7F000616558 /* DomainCreditRedemptionSuccessViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BF30522271D7F000616558 /* DomainCreditRedemptionSuccessViewController.swift */; }; 02D75D9922793EA2003FF09A /* BlogDetailsSectionFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D75D9822793EA2003FF09A /* BlogDetailsSectionFooterView.swift */; }; - 04936A8B3D936CD1FC4AB1EF /* Pods_WordPressNotificationContentExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6CC2CF274BA2F245C464D562 /* Pods_WordPressNotificationContentExtension.framework */; }; + 03216EC6279946CA00D444CA /* SchedulingDatePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03216EC5279946CA00D444CA /* SchedulingDatePickerViewController.swift */; }; + 03216EC7279946CA00D444CA /* SchedulingDatePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03216EC5279946CA00D444CA /* SchedulingDatePickerViewController.swift */; }; + 03216ECC27995F3500D444CA /* SchedulingViewControllerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03216ECB27995F3500D444CA /* SchedulingViewControllerPresenter.swift */; }; + 03216ECD27995F3500D444CA /* SchedulingViewControllerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03216ECB27995F3500D444CA /* SchedulingViewControllerPresenter.swift */; }; + 069A4AA62664448F00413FA9 /* GutenbergFeaturedImageHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 069A4AA52664448F00413FA9 /* GutenbergFeaturedImageHelper.swift */; }; + 069A4AA72664448F00413FA9 /* GutenbergFeaturedImageHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 069A4AA52664448F00413FA9 /* GutenbergFeaturedImageHelper.swift */; }; 0807CB721CE670A800CDBDAC /* WPContentSearchHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0807CB711CE670A800CDBDAC /* WPContentSearchHelper.swift */; }; 080C44A91CE14A9F00B3A02F /* MenuDetailsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 080C449E1CE14A9F00B3A02F /* MenuDetailsViewController.m */; }; 0815CF461E96F22600069916 /* MediaImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0815CF451E96F22600069916 /* MediaImportService.swift */; }; + 081E4B4C281C019A0085E89C /* TooltipAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081E4B4B281C019A0085E89C /* TooltipAnchor.swift */; }; 08216FAA1CDBF95100304BA7 /* MenuItemEditing.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 08216FA71CDBF95100304BA7 /* MenuItemEditing.storyboard */; }; 08216FAB1CDBF95100304BA7 /* MenuItemEditingViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FA91CDBF95100304BA7 /* MenuItemEditingViewController.m */; }; 08216FC81CDBF96000304BA7 /* MenuItemAbstractPostsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FAD1CDBF96000304BA7 /* MenuItemAbstractPostsViewController.m */; }; @@ -77,11 +207,21 @@ 08216FD51CDBF96000304BA7 /* MenuItemTypeViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FC71CDBF96000304BA7 /* MenuItemTypeViewController.m */; }; 082635BB1CEA69280088030C /* MenuItemsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 082635BA1CEA69280088030C /* MenuItemsViewController.m */; }; 0828D7FA1E6E09AE00C7C7D4 /* WPAppAnalytics+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0828D7F91E6E09AE00C7C7D4 /* WPAppAnalytics+Media.swift */; }; + 082A645B291C2DD700668D2C /* Routes+Jetpack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 082A645A291C2DD700668D2C /* Routes+Jetpack.swift */; }; + 082A645C291C2DD700668D2C /* Routes+Jetpack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 082A645A291C2DD700668D2C /* Routes+Jetpack.swift */; }; 082AB9D91C4EEEF4000CA523 /* PostTagService.m in Sources */ = {isa = PBXBuildFile; fileRef = 082AB9D81C4EEEF4000CA523 /* PostTagService.m */; }; 082AB9DD1C4F035E000CA523 /* PostTag.m in Sources */ = {isa = PBXBuildFile; fileRef = 082AB9DC1C4F035E000CA523 /* PostTag.m */; }; + 0839F88B2993C0C000415038 /* JetpackDefaultOverlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0857BB3F299275760011CBD1 /* JetpackDefaultOverlayCoordinator.swift */; }; + 0839F88C2993C1B500415038 /* JetpackPluginOverlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084FC3BA29914C7F00A17BCF /* JetpackPluginOverlayCoordinator.swift */; }; + 0839F88D2993C1B600415038 /* JetpackPluginOverlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084FC3BA29914C7F00A17BCF /* JetpackPluginOverlayCoordinator.swift */; }; 0845B8C61E833C56001BA771 /* URL+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0845B8C51E833C56001BA771 /* URL+Helpers.swift */; }; 08472A201C727E020040769D /* PostServiceOptions.m in Sources */ = {isa = PBXBuildFile; fileRef = 08472A1F1C727E020040769D /* PostServiceOptions.m */; }; + 084A07062848E1820054508A /* FeatureHighlightStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084A07052848E1820054508A /* FeatureHighlightStore.swift */; }; 084D94AF1EDF842F00C385A6 /* test-video-device-gps.m4v in Resources */ = {isa = PBXBuildFile; fileRef = 084D94AE1EDF842600C385A6 /* test-video-device-gps.m4v */; }; + 084FC3B729913B1B00A17BCF /* JetpackPluginOverlayViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084FC3B629913B1B00A17BCF /* JetpackPluginOverlayViewModelTests.swift */; }; + 084FC3BC299155C900A17BCF /* JetpackOverlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084FC3B829914BD700A17BCF /* JetpackOverlayCoordinator.swift */; }; + 084FC3BD299155CA00A17BCF /* JetpackOverlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084FC3B829914BD700A17BCF /* JetpackOverlayCoordinator.swift */; }; + 0857BB40299275760011CBD1 /* JetpackDefaultOverlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0857BB3F299275760011CBD1 /* JetpackDefaultOverlayCoordinator.swift */; }; 0857C2771CE5375F0014AE99 /* MenuItemAbstractView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0857C2701CE5375F0014AE99 /* MenuItemAbstractView.m */; }; 0857C2781CE5375F0014AE99 /* MenuItemInsertionView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0857C2721CE5375F0014AE99 /* MenuItemInsertionView.m */; }; 0857C2791CE5375F0014AE99 /* MenuItemsVisualOrderingView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0857C2741CE5375F0014AE99 /* MenuItemsVisualOrderingView.m */; }; @@ -89,20 +229,50 @@ 086103961EE09C91004D7C01 /* MediaVideoExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086103951EE09C91004D7C01 /* MediaVideoExporter.swift */; }; 086C4D101E81F9240011D960 /* Media+Blog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086C4D0F1E81F9240011D960 /* Media+Blog.swift */; }; 086E1FE01BBB35D2002D86CA /* MenusViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 086E1FDF1BBB35D2002D86CA /* MenusViewController.m */; }; + 086F2481284F52DB00032F39 /* TooltipPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088CC593282BEC41007B9421 /* TooltipPresenter.swift */; }; + 086F2482284F52DD00032F39 /* TooltipAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081E4B4B281C019A0085E89C /* TooltipAnchor.swift */; }; + 086F2483284F52DF00032F39 /* Tooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D553652821286300AA1E8D /* Tooltip.swift */; }; + 086F2484284F52E100032F39 /* FeatureHighlightStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 084A07052848E1820054508A /* FeatureHighlightStore.swift */; }; + 0878580328B4CF950069F96C /* UserPersistentRepositoryUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0878580228B4CF950069F96C /* UserPersistentRepositoryUtility.swift */; }; + 0878580428B4CF950069F96C /* UserPersistentRepositoryUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0878580228B4CF950069F96C /* UserPersistentRepositoryUtility.swift */; }; 0879FC161E9301DD00E1EFC8 /* MediaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0879FC151E9301DD00E1EFC8 /* MediaTests.swift */; }; 087EBFA81F02313E001F7ACE /* MediaThumbnailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 087EBFA71F02313E001F7ACE /* MediaThumbnailService.swift */; }; + 0880BADC29ED6FF3002D3AB0 /* UIColor+DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0880BADB29ED6FF3002D3AB0 /* UIColor+DesignSystem.swift */; }; + 0880BADD29ED6FF3002D3AB0 /* UIColor+DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0880BADB29ED6FF3002D3AB0 /* UIColor+DesignSystem.swift */; }; 0885A3671E837AFE00619B4D /* URLIncrementalFilenameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0885A3661E837AFE00619B4D /* URLIncrementalFilenameTests.swift */; }; 088B89891DA6F93B000E8DEF /* ReaderPostCardContentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088B89881DA6F93B000E8DEF /* ReaderPostCardContentLabel.swift */; }; + 088CC594282BEC41007B9421 /* TooltipPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088CC593282BEC41007B9421 /* TooltipPresenter.swift */; }; + 088D58A529E724F300E6C0F4 /* ColorGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088D58A429E724F300E6C0F4 /* ColorGallery.swift */; }; + 088D58A629E724F300E6C0F4 /* ColorGallery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088D58A429E724F300E6C0F4 /* ColorGallery.swift */; }; + 08A250F828D9E87600F50420 /* CommentDetailInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A250F728D9E87600F50420 /* CommentDetailInfoViewController.swift */; }; + 08A250F928D9E87600F50420 /* CommentDetailInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A250F728D9E87600F50420 /* CommentDetailInfoViewController.swift */; }; + 08A250FC28D9F0E200F50420 /* CommentDetailInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A250FB28D9F0E200F50420 /* CommentDetailInfoViewModel.swift */; }; + 08A250FD28D9F0E200F50420 /* CommentDetailInfoViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A250FB28D9F0E200F50420 /* CommentDetailInfoViewModel.swift */; }; 08A2AD791CCED2A800E84454 /* PostTagServiceTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 08A2AD781CCED2A800E84454 /* PostTagServiceTests.m */; }; 08A2AD7B1CCED8E500E84454 /* PostCategoryServiceTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 08A2AD7A1CCED8E500E84454 /* PostCategoryServiceTests.m */; }; + 08A4E129289D202F001D9EC7 /* UserPersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A4E128289D202F001D9EC7 /* UserPersistentStore.swift */; }; + 08A4E12A289D202F001D9EC7 /* UserPersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A4E128289D202F001D9EC7 /* UserPersistentStore.swift */; }; + 08A4E12C289D2337001D9EC7 /* UserPersistentRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A4E12B289D2337001D9EC7 /* UserPersistentRepository.swift */; }; + 08A4E12D289D2337001D9EC7 /* UserPersistentRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A4E12B289D2337001D9EC7 /* UserPersistentRepository.swift */; }; + 08A4E12F289D2795001D9EC7 /* UserPersistentStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A4E12E289D2795001D9EC7 /* UserPersistentStoreTests.swift */; }; + 08A7343F298AB68000F925C7 /* JetpackPluginOverlayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A7343E298AB68000F925C7 /* JetpackPluginOverlayViewModel.swift */; }; + 08A73440298AB68000F925C7 /* JetpackPluginOverlayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A7343E298AB68000F925C7 /* JetpackPluginOverlayViewModel.swift */; }; 08AAD69F1CBEA47D002B2418 /* MenusService.m in Sources */ = {isa = PBXBuildFile; fileRef = 08AAD69E1CBEA47D002B2418 /* MenusService.m */; }; 08AAD6A11CBEA610002B2418 /* MenusServiceTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 08AAD6A01CBEA610002B2418 /* MenusServiceTests.m */; }; 08B6D6F31C8F7DCE0052C52B /* PostType.m in Sources */ = {isa = PBXBuildFile; fileRef = 08B6D6F11C8F7DCE0052C52B /* PostType.m */; }; 08B6E51A1F036CAD00268F57 /* MediaFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08B6E5191F036CAD00268F57 /* MediaFileManager.swift */; }; 08B6E51C1F037ADD00268F57 /* MediaFileManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08B6E51B1F037ADD00268F57 /* MediaFileManagerTests.swift */; }; 08B832421EC130D60079808D /* test-gif.gif in Resources */ = {isa = PBXBuildFile; fileRef = 08B832411EC130D60079808D /* test-gif.gif */; }; + 08B954F328535EE800B07185 /* FeatureHighlightStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08B954F228535EE800B07185 /* FeatureHighlightStoreTests.swift */; }; + 08BA4BC7298A9AD500015BD2 /* JetpackInstallPluginLogoAnimation_rtl.json in Resources */ = {isa = PBXBuildFile; fileRef = 08BA4BC5298A9AD400015BD2 /* JetpackInstallPluginLogoAnimation_rtl.json */; }; + 08BA4BC8298A9AD500015BD2 /* JetpackInstallPluginLogoAnimation_rtl.json in Resources */ = {isa = PBXBuildFile; fileRef = 08BA4BC5298A9AD400015BD2 /* JetpackInstallPluginLogoAnimation_rtl.json */; }; + 08BA4BC9298A9AD500015BD2 /* JetpackInstallPluginLogoAnimation_ltr.json in Resources */ = {isa = PBXBuildFile; fileRef = 08BA4BC6298A9AD400015BD2 /* JetpackInstallPluginLogoAnimation_ltr.json */; }; + 08BA4BCA298A9AD500015BD2 /* JetpackInstallPluginLogoAnimation_ltr.json in Resources */ = {isa = PBXBuildFile; fileRef = 08BA4BC6298A9AD400015BD2 /* JetpackInstallPluginLogoAnimation_ltr.json */; }; 08C388661ED7705E0057BE49 /* MediaAssetExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C388651ED7705E0057BE49 /* MediaAssetExporter.swift */; }; 08C3886A1ED78EE70057BE49 /* Media+WPMediaAsset.m in Sources */ = {isa = PBXBuildFile; fileRef = 08C388691ED78EE70057BE49 /* Media+WPMediaAsset.m */; }; + 08C42C31281807880034720B /* ReaderSubscribeCommentsActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C42C30281807880034720B /* ReaderSubscribeCommentsActionTests.swift */; }; + 08CBC77929AE6CC4000026E7 /* SiteCreationEmptySiteTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBC77829AE6CC4000026E7 /* SiteCreationEmptySiteTemplate.swift */; }; + 08CBC77A29AE6CC4000026E7 /* SiteCreationEmptySiteTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CBC77829AE6CC4000026E7 /* SiteCreationEmptySiteTemplate.swift */; }; 08CC677E1C49B65A00153AD7 /* MenuItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 08CC67791C49B65A00153AD7 /* MenuItem.m */; }; 08CC677F1C49B65A00153AD7 /* Menu.m in Sources */ = {isa = PBXBuildFile; fileRef = 08CC677A1C49B65A00153AD7 /* Menu.m */; }; 08CC67801C49B65A00153AD7 /* MenuLocation.m in Sources */ = {isa = PBXBuildFile; fileRef = 08CC677D1C49B65A00153AD7 /* MenuLocation.m */; }; @@ -112,14 +282,21 @@ 08D345531CD7F50900358E8C /* MenusSelectionView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D3454F1CD7F50900358E8C /* MenusSelectionView.m */; }; 08D345561CD7FBA900358E8C /* MenuHeaderViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D345551CD7FBA900358E8C /* MenuHeaderViewController.m */; }; 08D499671CDD20450004809A /* Menus.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 08D499661CDD20450004809A /* Menus.storyboard */; }; + 08D553662821286300AA1E8D /* Tooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D553652821286300AA1E8D /* Tooltip.swift */; }; 08D978551CD2AF7D0054F19A /* Menu+ViewDesign.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D9784C1CD2AF7D0054F19A /* Menu+ViewDesign.m */; }; 08D978561CD2AF7D0054F19A /* MenuItem+ViewDesign.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D9784E1CD2AF7D0054F19A /* MenuItem+ViewDesign.m */; }; 08D978571CD2AF7D0054F19A /* MenuItemCheckButtonView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D978501CD2AF7D0054F19A /* MenuItemCheckButtonView.m */; }; 08D978581CD2AF7D0054F19A /* MenuItemSourceHeaderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D978521CD2AF7D0054F19A /* MenuItemSourceHeaderView.m */; }; 08D978591CD2AF7D0054F19A /* MenuItemSourceTextBar.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D978541CD2AF7D0054F19A /* MenuItemSourceTextBar.m */; }; 08DF9C441E8475530058678C /* test-image-portrait.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 08DF9C431E8475530058678C /* test-image-portrait.jpg */; }; + 08E39B4528A3DEB200874CB8 /* UserPersistentStoreFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E39B4428A3DEB200874CB8 /* UserPersistentStoreFactory.swift */; }; + 08E39B4628A3DEB200874CB8 /* UserPersistentStoreFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E39B4428A3DEB200874CB8 /* UserPersistentStoreFactory.swift */; }; 08E77F451EE87FCF006F9515 /* MediaThumbnailExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E77F441EE87FCF006F9515 /* MediaThumbnailExporter.swift */; }; 08E77F471EE9D72F006F9515 /* MediaThumbnailExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E77F461EE9D72F006F9515 /* MediaThumbnailExporterTests.swift */; }; + 08EA036729C9B51200B72A87 /* Color+DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08EA036629C9B51200B72A87 /* Color+DesignSystem.swift */; }; + 08EA036929C9B53000B72A87 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 08EA036829C9B53000B72A87 /* Colors.xcassets */; }; + 08EA036A29C9C39A00B72A87 /* Color+DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08EA036629C9B51200B72A87 /* Color+DesignSystem.swift */; }; + 08EA036B29C9C3A000B72A87 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 08EA036829C9B53000B72A87 /* Colors.xcassets */; }; 08F8CD2A1EBD22EF0049D0C0 /* MediaExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F8CD291EBD22EF0049D0C0 /* MediaExporter.swift */; }; 08F8CD2D1EBD24600049D0C0 /* MediaExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F8CD2C1EBD245F0049D0C0 /* MediaExporterTests.swift */; }; 08F8CD2F1EBD29440049D0C0 /* MediaImageExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F8CD2E1EBD29440049D0C0 /* MediaImageExporter.swift */; }; @@ -128,72 +305,230 @@ 08F8CD371EBD2AA80049D0C0 /* test-image-device-photo-gps.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 08F8CD341EBD2AA80049D0C0 /* test-image-device-photo-gps.jpg */; }; 08F8CD391EBD2C970049D0C0 /* MediaURLExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F8CD381EBD2C970049D0C0 /* MediaURLExporter.swift */; }; 08F8CD3B1EBD2D020049D0C0 /* MediaURLExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F8CD3A1EBD2D020049D0C0 /* MediaURLExporterTests.swift */; }; + 098B8576275E76FE004D299F /* AppLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */; }; + 098B8577275E9765004D299F /* AppLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */; }; + 098B8578275FF975004D299F /* AppLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */; }; + 098B8579275FFB21004D299F /* AppLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */; }; + 099D768327D14B8E00F77EDE /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 099D768127D14B8E00F77EDE /* InfoPlist.strings */; }; + 09DBEA55281336E10019724E /* AppLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */; }; + 0A3FCA1D28B71CBD00499A15 /* FullScreenCommentReplyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3FCA1C28B71CBC00499A15 /* FullScreenCommentReplyViewModel.swift */; }; + 0A3FCA1E28B71CBD00499A15 /* FullScreenCommentReplyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3FCA1C28B71CBC00499A15 /* FullScreenCommentReplyViewModel.swift */; }; + 0A69300B28B5AA5E00E98DE1 /* FullScreenCommentReplyViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A69300A28B5AA5E00E98DE1 /* FullScreenCommentReplyViewModelTests.swift */; }; + 0A9610F928B2E56300076EBA /* UserSuggestion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9610F828B2E56300076EBA /* UserSuggestion+Comparable.swift */; }; + 0A9610FA28B2E56300076EBA /* UserSuggestion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9610F828B2E56300076EBA /* UserSuggestion+Comparable.swift */; }; + 0A9687BC28B40771009DCD2F /* FullScreenCommentReplyViewModelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A9687BB28B40771009DCD2F /* FullScreenCommentReplyViewModelMock.swift */; }; + 0C35FFF129CB81F700D224EB /* BlogDashboardHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C35FFF029CB81F700D224EB /* BlogDashboardHelpers.swift */; }; + 0C35FFF229CB81F700D224EB /* BlogDashboardHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C35FFF029CB81F700D224EB /* BlogDashboardHelpers.swift */; }; + 0C35FFF429CBA6DA00D224EB /* BlogDashboardPersonalizationViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C35FFF329CBA6DA00D224EB /* BlogDashboardPersonalizationViewModelTests.swift */; }; + 0C35FFF629CBB5DE00D224EB /* BlogDashboardEmptyStateCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C35FFF529CBB5DE00D224EB /* BlogDashboardEmptyStateCell.swift */; }; + 0C35FFF729CBB5DE00D224EB /* BlogDashboardEmptyStateCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C35FFF529CBB5DE00D224EB /* BlogDashboardEmptyStateCell.swift */; }; + 0CB4056B29C78F06008EED0A /* BlogDashboardPersonalizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */; }; + 0CB4056C29C78F06008EED0A /* BlogDashboardPersonalizationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */; }; + 0CB4056E29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4056D29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift */; }; + 0CB4057629C8DDE5008EED0A /* BlogDashboardPersonalizationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4057229C8DD01008EED0A /* BlogDashboardPersonalizationView.swift */; }; + 0CB4057729C8DDE8008EED0A /* BlogDashboardPersonalizationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4057229C8DD01008EED0A /* BlogDashboardPersonalizationView.swift */; }; + 0CB4057929C8DDEC008EED0A /* BlogDashboardPersonalizationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4057029C8DCF4008EED0A /* BlogDashboardPersonalizationViewModel.swift */; }; + 0CB4057A29C8DDEE008EED0A /* BlogDashboardPersonalizationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4057029C8DCF4008EED0A /* BlogDashboardPersonalizationViewModel.swift */; }; + 0CB4057D29C8DF83008EED0A /* BlogDashboardPersonalizeCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4057B29C8DEE1008EED0A /* BlogDashboardPersonalizeCardCell.swift */; }; + 0CB4057E29C8DF84008EED0A /* BlogDashboardPersonalizeCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CB4057B29C8DEE1008EED0A /* BlogDashboardPersonalizeCardCell.swift */; }; 1702BBDC1CEDEA6B00766A33 /* BadgeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1702BBDB1CEDEA6B00766A33 /* BadgeLabel.swift */; }; 1702BBE01CF3034E00766A33 /* DomainsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1702BBDF1CF3034E00766A33 /* DomainsService.swift */; }; + 17039225282E6D2800F602E9 /* ViewsVisitorsLineChartCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772B0728201F5300664C02 /* ViewsVisitorsLineChartCell.swift */; }; + 17039226282E6D2F00F602E9 /* ViewsVisitorsLineChartCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DC772B0528201F5200664C02 /* ViewsVisitorsLineChartCell.xib */; }; + 17039227282E6DF500F602E9 /* ViewsVisitorsChartMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772B0628201F5200664C02 /* ViewsVisitorsChartMarker.swift */; }; 1703D04C20ECD93800D292E9 /* Routes+Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1703D04B20ECD93800D292E9 /* Routes+Post.swift */; }; - 1705E55020A5DA5700EF1C9D /* ReaderSavedPostsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1705E54F20A5DA5700EF1C9D /* ReaderSavedPostsViewController.swift */; }; 1707CE421F3121750020B7FE /* UICollectionViewCell+Tint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1707CE411F3121750020B7FE /* UICollectionViewCell+Tint.swift */; }; 170BEC872391530D0017AEC1 /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; 170BEC88239153110017AEC1 /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; - 170BEC89239153120017AEC1 /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; 170BEC8A239153160017AEC1 /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; 170BEC8B239153160017AEC1 /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; 170BEC8C2391533D0017AEC1 /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; 170CE7402064478600A48191 /* PostNoticeNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 170CE73F2064478600A48191 /* PostNoticeNavigationCoordinator.swift */; }; + 171096CB270F01EA001BCDD6 /* DomainSuggestionsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171096CA270F01EA001BCDD6 /* DomainSuggestionsTableViewController.swift */; }; + 171096CC270F01EA001BCDD6 /* DomainSuggestionsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171096CA270F01EA001BCDD6 /* DomainSuggestionsTableViewController.swift */; }; 1714F8D020E6DA8900226DCB /* RouteMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1714F8CF20E6DA8900226DCB /* RouteMatcher.swift */; }; 1715179220F4B2EB002C4A38 /* Routes+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1715179120F4B2EB002C4A38 /* Routes+Stats.swift */; }; 1715179420F4B5CD002C4A38 /* MySitesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1715179320F4B5CD002C4A38 /* MySitesCoordinator.swift */; }; - 171909E4206CFFCD0054DF0B /* FancyAlertViewController+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171909E3206CFFCD0054DF0B /* FancyAlertViewController+Async.swift */; }; + 1716AEFC25F2927600CF49EC /* MySiteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1716AEFB25F2927600CF49EC /* MySiteViewController.swift */; }; + 17171374265FAA8A00F3A022 /* BloggingRemindersNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17171373265FAA8A00F3A022 /* BloggingRemindersNavigationController.swift */; }; + 17171375265FAA8A00F3A022 /* BloggingRemindersNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17171373265FAA8A00F3A022 /* BloggingRemindersNavigationController.swift */; }; + 1717139F265FE59700F3A022 /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1717139E265FE59700F3A022 /* ButtonStyles.swift */; }; + 171713A0265FE59700F3A022 /* ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1717139E265FE59700F3A022 /* ButtonStyles.swift */; }; 171963401D378D5100898E8B /* SearchWrapperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1719633F1D378D5100898E8B /* SearchWrapperView.swift */; }; + 171CC15824FCEBF7008B7180 /* UINavigationBar+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171CC15724FCEBF7008B7180 /* UINavigationBar+Appearance.swift */; }; + 17222D80261DDDF90047B163 /* celadon-classic-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D45261DDDF10047B163 /* celadon-classic-icon-app-76x76.png */; }; + 17222D81261DDDF90047B163 /* celadon-classic-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D46261DDDF10047B163 /* celadon-classic-icon-app-76x76@2x.png */; }; + 17222D82261DDDF90047B163 /* celadon-classic-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D47261DDDF10047B163 /* celadon-classic-icon-app-83.5x83.5@2x.png */; }; + 17222D83261DDDF90047B163 /* celadon-classic-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D48261DDDF10047B163 /* celadon-classic-icon-app-60x60@2x.png */; }; + 17222D84261DDDF90047B163 /* celadon-classic-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D49261DDDF10047B163 /* celadon-classic-icon-app-60x60@3x.png */; }; + 17222D85261DDDF90047B163 /* celadon-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D4B261DDDF30047B163 /* celadon-icon-app-83.5x83.5@2x.png */; }; + 17222D86261DDDF90047B163 /* celadon-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D4C261DDDF30047B163 /* celadon-icon-app-76x76.png */; }; + 17222D87261DDDF90047B163 /* celadon-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D4D261DDDF30047B163 /* celadon-icon-app-76x76@2x.png */; }; + 17222D88261DDDF90047B163 /* celadon-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D4E261DDDF30047B163 /* celadon-icon-app-60x60@2x.png */; }; + 17222D89261DDDF90047B163 /* celadon-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D4F261DDDF30047B163 /* celadon-icon-app-60x60@3x.png */; }; + 17222D8A261DDDF90047B163 /* black-classic-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D51261DDDF40047B163 /* black-classic-icon-app-76x76@2x.png */; }; + 17222D8B261DDDF90047B163 /* black-classic-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D52261DDDF40047B163 /* black-classic-icon-app-60x60@3x.png */; }; + 17222D8C261DDDF90047B163 /* black-classic-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D53261DDDF40047B163 /* black-classic-icon-app-83.5x83.5@2x.png */; }; + 17222D8D261DDDF90047B163 /* black-classic-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D54261DDDF40047B163 /* black-classic-icon-app-60x60@2x.png */; }; + 17222D8E261DDDF90047B163 /* black-classic-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D55261DDDF40047B163 /* black-classic-icon-app-76x76.png */; }; + 17222D8F261DDDF90047B163 /* blue-classic-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D57261DDDF40047B163 /* blue-classic-icon-app-60x60@3x.png */; }; + 17222D90261DDDF90047B163 /* blue-classic-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D58261DDDF40047B163 /* blue-classic-icon-app-60x60@2x.png */; }; + 17222D91261DDDF90047B163 /* blue-classic-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D59261DDDF40047B163 /* blue-classic-icon-app-76x76@2x.png */; }; + 17222D92261DDDF90047B163 /* blue-classic-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D5A261DDDF40047B163 /* blue-classic-icon-app-83.5x83.5@2x.png */; }; + 17222D93261DDDF90047B163 /* blue-classic-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D5B261DDDF40047B163 /* blue-classic-icon-app-76x76.png */; }; + 17222D94261DDDF90047B163 /* pink-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D5D261DDDF50047B163 /* pink-icon-app-76x76@2x.png */; }; + 17222D95261DDDF90047B163 /* pink-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D5E261DDDF50047B163 /* pink-icon-app-76x76.png */; }; + 17222D96261DDDF90047B163 /* pink-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D5F261DDDF50047B163 /* pink-icon-app-60x60@3x.png */; }; + 17222D97261DDDF90047B163 /* pink-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D60261DDDF50047B163 /* pink-icon-app-60x60@2x.png */; }; + 17222D98261DDDF90047B163 /* pink-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D61261DDDF50047B163 /* pink-icon-app-83.5x83.5@2x.png */; }; + 17222D99261DDDF90047B163 /* black-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D63261DDDF50047B163 /* black-icon-app-60x60@2x.png */; }; + 17222D9A261DDDF90047B163 /* black-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D64261DDDF50047B163 /* black-icon-app-60x60@3x.png */; }; + 17222D9B261DDDF90047B163 /* black-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D65261DDDF50047B163 /* black-icon-app-76x76.png */; }; + 17222D9C261DDDF90047B163 /* black-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D66261DDDF60047B163 /* black-icon-app-76x76@2x.png */; }; + 17222D9D261DDDF90047B163 /* black-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D67261DDDF60047B163 /* black-icon-app-83.5x83.5@2x.png */; }; + 17222D9E261DDDF90047B163 /* pink-classic-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D69261DDDF60047B163 /* pink-classic-icon-app-83.5x83.5@2x.png */; }; + 17222D9F261DDDF90047B163 /* pink-classic-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D6A261DDDF60047B163 /* pink-classic-icon-app-76x76@2x.png */; }; + 17222DA0261DDDF90047B163 /* pink-classic-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D6B261DDDF60047B163 /* pink-classic-icon-app-60x60@2x.png */; }; + 17222DA1261DDDF90047B163 /* pink-classic-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D6C261DDDF60047B163 /* pink-classic-icon-app-76x76.png */; }; + 17222DA2261DDDF90047B163 /* pink-classic-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D6D261DDDF60047B163 /* pink-classic-icon-app-60x60@3x.png */; }; + 17222DA3261DDDF90047B163 /* spectrum-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D6F261DDDF70047B163 /* spectrum-icon-app-76x76@2x.png */; }; + 17222DA4261DDDF90047B163 /* spectrum-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D70261DDDF70047B163 /* spectrum-icon-app-83.5x83.5@2x.png */; }; + 17222DA5261DDDF90047B163 /* spectrum-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D71261DDDF70047B163 /* spectrum-icon-app-60x60@3x.png */; }; + 17222DA6261DDDF90047B163 /* spectrum-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D72261DDDF70047B163 /* spectrum-icon-app-76x76.png */; }; + 17222DA7261DDDF90047B163 /* spectrum-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D73261DDDF70047B163 /* spectrum-icon-app-60x60@2x.png */; }; + 17222DAB261DDDF90047B163 /* blue-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D78261DDDF70047B163 /* blue-icon-app-60x60@3x.png */; }; + 17222DAC261DDDF90047B163 /* blue-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D79261DDDF70047B163 /* blue-icon-app-60x60@2x.png */; }; + 17222DAD261DDDF90047B163 /* spectrum-classic-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D7B261DDDF80047B163 /* spectrum-classic-icon-app-76x76@2x.png */; }; + 17222DAE261DDDF90047B163 /* spectrum-classic-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D7C261DDDF80047B163 /* spectrum-classic-icon-app-76x76.png */; }; + 17222DAF261DDDF90047B163 /* spectrum-classic-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D7D261DDDF80047B163 /* spectrum-classic-icon-app-60x60@3x.png */; }; + 17222DB0261DDDF90047B163 /* spectrum-classic-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D7E261DDDF80047B163 /* spectrum-classic-icon-app-83.5x83.5@2x.png */; }; + 17222DB1261DDDF90047B163 /* spectrum-classic-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17222D7F261DDDF80047B163 /* spectrum-classic-icon-app-60x60@2x.png */; }; 1724DDC81C60F1200099D273 /* PlanDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1724DDC71C60F1200099D273 /* PlanDetailViewController.swift */; }; 1724DDCC1C6121D00099D273 /* Plans.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1724DDCB1C6121D00099D273 /* Plans.storyboard */; }; 172797D91CE5D0CD00CB8057 /* PlansLoadingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172797D81CE5D0CD00CB8057 /* PlansLoadingIndicatorView.swift */; }; 172E27D31FD98135003EA321 /* NoticePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172E27D21FD98135003EA321 /* NoticePresenter.swift */; }; + 172F06B92865C04F00C78FD4 /* spectrum-'22-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 172F06B42865C04E00C78FD4 /* spectrum-'22-icon-app-76x76.png */; }; + 172F06BA2865C04F00C78FD4 /* spectrum-'22-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 172F06B52865C04E00C78FD4 /* spectrum-'22-icon-app-60x60@3x.png */; }; + 172F06BB2865C04F00C78FD4 /* spectrum-'22-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 172F06B62865C04E00C78FD4 /* spectrum-'22-icon-app-76x76@2x.png */; }; + 172F06BC2865C04F00C78FD4 /* spectrum-'22-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 172F06B72865C04F00C78FD4 /* spectrum-'22-icon-app-83.5x83.5@2x.png */; }; + 172F06BD2865C04F00C78FD4 /* spectrum-'22-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 172F06B82865C04F00C78FD4 /* spectrum-'22-icon-app-60x60@2x.png */; }; 1730D4A31E97E3E400326B7C /* MediaItemTableViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1730D4A21E97E3E400326B7C /* MediaItemTableViewCells.swift */; }; - 173BCE731CEB368A00AE8817 /* DomainsListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173BCE721CEB368A00AE8817 /* DomainsListViewController.swift */; }; - 173BCE751CEB369900AE8817 /* Domains.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 173BCE741CEB369900AE8817 /* Domains.storyboard */; }; + 173B215527875E0600D4DD6B /* DeviceMediaPermissionsHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173B215427875E0600D4DD6B /* DeviceMediaPermissionsHeader.swift */; }; + 173B215627875E0600D4DD6B /* DeviceMediaPermissionsHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173B215427875E0600D4DD6B /* DeviceMediaPermissionsHeader.swift */; }; 173BCE791CEB780800AE8817 /* Domain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173BCE781CEB780800AE8817 /* Domain.swift */; }; 173D82E7238EE2A7008432DA /* FeatureFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173D82E6238EE2A7008432DA /* FeatureFlagTests.swift */; }; - 173F6DFC21232F2A00A4C8E2 /* NoResultsGiphyConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173F6DFB21232F2A00A4C8E2 /* NoResultsGiphyConfiguration.swift */; }; - 173F6DFE212352D000A4C8E2 /* GiphyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173F6DFD212352D000A4C8E2 /* GiphyService.swift */; }; + 173DF291274522A1007C64B5 /* AppAboutScreenConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173DF290274522A1007C64B5 /* AppAboutScreenConfiguration.swift */; }; + 173DF292274522A1007C64B5 /* AppAboutScreenConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173DF290274522A1007C64B5 /* AppAboutScreenConfiguration.swift */; }; 1746D7771D2165AE00B11D77 /* ForcePopoverPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1746D7761D2165AE00B11D77 /* ForcePopoverPresenter.swift */; }; 1749965F2271BF08007021BD /* WordPressAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1749965E2271BF08007021BD /* WordPressAppDelegate.swift */; }; + 174C116F2624603400346EC6 /* MBarRouteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174C116E2624603400346EC6 /* MBarRouteTests.swift */; }; + 174C11932624C78900346EC6 /* Routes+Start.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174C11922624C78900346EC6 /* Routes+Start.swift */; }; + 174C11942624C78900346EC6 /* Routes+Start.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174C11922624C78900346EC6 /* Routes+Start.swift */; }; 174C9697205A846E00CEEF6E /* PostNoticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174C9696205A846E00CEEF6E /* PostNoticeViewModel.swift */; }; 1750BD6D201144DB0050F13A /* MediaNoticeNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1750BD6C201144DB0050F13A /* MediaNoticeNavigationCoordinator.swift */; }; 1751E5911CE0E552000CA08D /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; 1751E5931CE23801000CA08D /* NSAttributedString+StyledHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5921CE23801000CA08D /* NSAttributedString+StyledHTML.swift */; }; + 17523381246C4F9200870B4A /* HomepageSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17523380246C4F9200870B4A /* HomepageSettingsViewController.swift */; }; 1752D4F9238D702D002B79E7 /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; 1752D4FA238D702E002B79E7 /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; - 1752D4FB238D702F002B79E7 /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; 1752D4FC238D703A002B79E7 /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; + 175507B327A062980038ED28 /* PublicizeConnectionURLMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175507B227A062980038ED28 /* PublicizeConnectionURLMatcher.swift */; }; + 175507B427A062980038ED28 /* PublicizeConnectionURLMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175507B227A062980038ED28 /* PublicizeConnectionURLMatcher.swift */; }; + 1756DBDF28328B76006E6DB9 /* DonutChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1756DBDE28328B76006E6DB9 /* DonutChartView.swift */; }; + 1756DBE028328B76006E6DB9 /* DonutChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1756DBDE28328B76006E6DB9 /* DonutChartView.swift */; }; + 1756F1DF2822BB6F00CD0915 /* SparklineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1756F1DE2822BB6F00CD0915 /* SparklineView.swift */; }; + 1756F1E02822BB6F00CD0915 /* SparklineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1756F1DE2822BB6F00CD0915 /* SparklineView.swift */; }; + 175721162754D31F00DE38BC /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175721152754D31F00DE38BC /* AppIcon.swift */; }; + 175721172754D31F00DE38BC /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175721152754D31F00DE38BC /* AppIcon.swift */; }; 1759F1701FE017BF0003EC81 /* Queue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1759F16F1FE017BF0003EC81 /* Queue.swift */; }; 1759F1721FE017F20003EC81 /* QueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1759F1711FE017F20003EC81 /* QueueTests.swift */; }; 1759F1801FE1460C0003EC81 /* NoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1759F17F1FE1460C0003EC81 /* NoticeView.swift */; }; 175A650C20B6F7280023E71B /* ReaderSaveForLater+Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175A650B20B6F7280023E71B /* ReaderSaveForLater+Analytics.swift */; }; + 175CC1702720548700622FB4 /* DomainExpiryDateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175CC16F2720548700622FB4 /* DomainExpiryDateFormatter.swift */; }; + 175CC1712720548700622FB4 /* DomainExpiryDateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175CC16F2720548700622FB4 /* DomainExpiryDateFormatter.swift */; }; + 175CC17527205BFB00622FB4 /* DomainExpiryDateFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175CC17427205BFB00622FB4 /* DomainExpiryDateFormatterTests.swift */; }; + 175CC1772721814C00622FB4 /* domain-service-updated-domains.json in Resources */ = {isa = PBXBuildFile; fileRef = 175CC1762721814B00622FB4 /* domain-service-updated-domains.json */; }; + 175CC17927230DC900622FB4 /* Bool+StringRepresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175CC17827230DC900622FB4 /* Bool+StringRepresentation.swift */; }; + 175CC17A27230DC900622FB4 /* Bool+StringRepresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175CC17827230DC900622FB4 /* Bool+StringRepresentation.swift */; }; + 175CC17C2723103000622FB4 /* WPAnalytics+Domains.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175CC17B2723103000622FB4 /* WPAnalytics+Domains.swift */; }; + 175CC17D2723103000622FB4 /* WPAnalytics+Domains.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175CC17B2723103000622FB4 /* WPAnalytics+Domains.swift */; }; + 175F99B52625FDE100F2687E /* FancyAlertViewController+AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175F99B42625FDE100F2687E /* FancyAlertViewController+AppIcons.swift */; }; + 175F99B62625FDE100F2687E /* FancyAlertViewController+AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175F99B42625FDE100F2687E /* FancyAlertViewController+AppIcons.swift */; }; + 1761F17126209AEE000815EF /* open-source-dark-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F14E26209AEC000815EF /* open-source-dark-icon-app-83.5x83.5@2x.png */; }; + 1761F17226209AEE000815EF /* open-source-dark-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F14F26209AEC000815EF /* open-source-dark-icon-app-60x60@3x.png */; }; + 1761F17326209AEE000815EF /* open-source-dark-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15026209AEC000815EF /* open-source-dark-icon-app-60x60@2x.png */; }; + 1761F17426209AEE000815EF /* open-source-dark-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15126209AEC000815EF /* open-source-dark-icon-app-76x76.png */; }; + 1761F17526209AEE000815EF /* open-source-dark-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15226209AEC000815EF /* open-source-dark-icon-app-76x76@2x.png */; }; + 1761F17626209AEE000815EF /* wordpress-dark-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15426209AEC000815EF /* wordpress-dark-icon-app-76x76@2x.png */; }; + 1761F17726209AEE000815EF /* wordpress-dark-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15526209AEC000815EF /* wordpress-dark-icon-app-76x76.png */; }; + 1761F17826209AEE000815EF /* wordpress-dark-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15626209AEC000815EF /* wordpress-dark-icon-app-83.5x83.5@2x.png */; }; + 1761F17926209AEE000815EF /* wordpress-dark-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15726209AEC000815EF /* wordpress-dark-icon-app-60x60@2x.png */; }; + 1761F17A26209AEE000815EF /* wordpress-dark-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15826209AEC000815EF /* wordpress-dark-icon-app-60x60@3x.png */; }; + 1761F17B26209AEE000815EF /* open-source-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15A26209AED000815EF /* open-source-icon-app-60x60@2x.png */; }; + 1761F17C26209AEE000815EF /* open-source-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15B26209AED000815EF /* open-source-icon-app-60x60@3x.png */; }; + 1761F17D26209AEE000815EF /* open-source-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15C26209AED000815EF /* open-source-icon-app-83.5x83.5@2x.png */; }; + 1761F17E26209AEE000815EF /* open-source-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15D26209AED000815EF /* open-source-icon-app-76x76.png */; }; + 1761F17F26209AEE000815EF /* open-source-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F15E26209AED000815EF /* open-source-icon-app-76x76@2x.png */; }; + 1761F18026209AEE000815EF /* jetpack-green-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16026209AED000815EF /* jetpack-green-icon-app-76x76.png */; }; + 1761F18126209AEE000815EF /* jetpack-green-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16126209AED000815EF /* jetpack-green-icon-app-60x60@2x.png */; }; + 1761F18226209AEE000815EF /* jetpack-green-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16226209AED000815EF /* jetpack-green-icon-app-60x60@3x.png */; }; + 1761F18326209AEE000815EF /* jetpack-green-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16326209AED000815EF /* jetpack-green-icon-app-83.5x83.5@2x.png */; }; + 1761F18426209AEE000815EF /* jetpack-green-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16426209AED000815EF /* jetpack-green-icon-app-76x76@2x.png */; }; + 1761F18526209AEE000815EF /* pride-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16626209AED000815EF /* pride-icon-app-60x60@3x.png */; }; + 1761F18626209AEE000815EF /* pride-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16726209AED000815EF /* pride-icon-app-60x60@2x.png */; }; + 1761F18726209AEE000815EF /* pride-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16826209AED000815EF /* pride-icon-app-83.5x83.5@2x.png */; }; + 1761F18826209AEE000815EF /* pride-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16926209AED000815EF /* pride-icon-app-76x76.png */; }; + 1761F18926209AEE000815EF /* pride-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16A26209AED000815EF /* pride-icon-app-76x76@2x.png */; }; + 1761F18A26209AEE000815EF /* hot-pink-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16C26209AEE000815EF /* hot-pink-icon-app-60x60@2x.png */; }; + 1761F18B26209AEE000815EF /* hot-pink-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16D26209AEE000815EF /* hot-pink-icon-app-83.5x83.5@2x.png */; }; + 1761F18C26209AEE000815EF /* hot-pink-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16E26209AEE000815EF /* hot-pink-icon-app-60x60@3x.png */; }; + 1761F18D26209AEE000815EF /* hot-pink-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F16F26209AEE000815EF /* hot-pink-icon-app-76x76.png */; }; + 1761F18E26209AEE000815EF /* hot-pink-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 1761F17026209AEE000815EF /* hot-pink-icon-app-76x76@2x.png */; }; + 1762B6DC2845510400F270A5 /* StatsReferrersChartViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1762B6DB2845510400F270A5 /* StatsReferrersChartViewModel.swift */; }; + 1762B6DD2845510400F270A5 /* StatsReferrersChartViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1762B6DB2845510400F270A5 /* StatsReferrersChartViewModel.swift */; }; 1767494E1D3633A000B8D1D1 /* ThemeBrowserSearchHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1767494D1D3633A000B8D1D1 /* ThemeBrowserSearchHeaderView.swift */; }; + 176BA53B268266E70025E4A3 /* BlogService+Reminders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176BA53A268266DE0025E4A3 /* BlogService+Reminders.swift */; }; + 176BA53C268266E70025E4A3 /* BlogService+Reminders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176BA53A268266DE0025E4A3 /* BlogService+Reminders.swift */; }; 176BB87F20D0068500751DCE /* FancyAlertViewController+SavedPosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176BB87E20D0068500751DCE /* FancyAlertViewController+SavedPosts.swift */; }; + 176CE91627FB44C100F1E32B /* StatsBaseCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176CE91527FB44C100F1E32B /* StatsBaseCell.swift */; }; + 176CE91727FB44C100F1E32B /* StatsBaseCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176CE91527FB44C100F1E32B /* StatsBaseCell.swift */; }; + 176CF39A25E0005F00E1E598 /* NoteBlockButtonTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176CF39925E0005F00E1E598 /* NoteBlockButtonTableViewCell.swift */; }; + 176CF3AC25E0079600E1E598 /* NoteBlockButtonTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 176CF3AB25E0079600E1E598 /* NoteBlockButtonTableViewCell.xib */; }; 176DEEE91D4615FE00331F30 /* WPSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176DEEE81D4615FE00331F30 /* WPSplitViewController.swift */; }; + 176E194725C465F70058F1C5 /* UnifiedPrologueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176E194625C465F70058F1C5 /* UnifiedPrologueViewController.swift */; }; 177074851FB209F100951A4A /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177074841FB209F100951A4A /* CircularProgressView.swift */; }; 177076211EA206C000705A4A /* PlayIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177076201EA206C000705A4A /* PlayIconView.swift */; }; - 177B4C252123161900CF8084 /* GiphyPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B4C242123161900CF8084 /* GiphyPicker.swift */; }; - 177B4C27212316DC00CF8084 /* GiphyStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B4C26212316DC00CF8084 /* GiphyStrings.swift */; }; - 177B4C292123181400CF8084 /* GiphyDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B4C282123181400CF8084 /* GiphyDataSource.swift */; }; - 177B4C2B2123185300CF8084 /* GiphyMediaGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B4C2A2123185300CF8084 /* GiphyMediaGroup.swift */; }; - 177B4C2D2123192200CF8084 /* GiphyMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B4C2C2123192200CF8084 /* GiphyMedia.swift */; }; - 177B4C3121236F0F00CF8084 /* GiphyDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B4C3021236F0F00CF8084 /* GiphyDataLoader.swift */; }; - 177B4C3321236F5C00CF8084 /* GiphyPageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B4C3221236F5C00CF8084 /* GiphyPageable.swift */; }; - 177B4C352123706400CF8084 /* GiphyResultsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177B4C342123706400CF8084 /* GiphyResultsPage.swift */; }; - 177CBE501DA3A3AC009F951E /* CollectionType+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177CBE4F1DA3A3AC009F951E /* CollectionType+Helpers.swift */; }; + 1770BD0D267A368100D5F8C0 /* BloggingRemindersPushPromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1770BD0C267A368100D5F8C0 /* BloggingRemindersPushPromptViewController.swift */; }; + 1770BD0E267A368100D5F8C0 /* BloggingRemindersPushPromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1770BD0C267A368100D5F8C0 /* BloggingRemindersPushPromptViewController.swift */; }; 177E7DAD1DD0D1E600890467 /* UINavigationController+SplitViewFullscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177E7DAC1DD0D1E600890467 /* UINavigationController+SplitViewFullscreen.swift */; }; 1782BE841E70063100A91E7D /* MediaItemViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1782BE831E70063100A91E7D /* MediaItemViewController.swift */; }; + 17870A702816F2A000D1C627 /* StatsLatestPostSummaryInsightsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17870A6F2816F2A000D1C627 /* StatsLatestPostSummaryInsightsCell.swift */; }; + 17870A712816F2A000D1C627 /* StatsLatestPostSummaryInsightsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17870A6F2816F2A000D1C627 /* StatsLatestPostSummaryInsightsCell.swift */; }; + 17870A74281FBEC100D1C627 /* StatsTotalInsightsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17870A73281FBEC000D1C627 /* StatsTotalInsightsCell.swift */; }; + 17870A75281FBEC100D1C627 /* StatsTotalInsightsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17870A73281FBEC000D1C627 /* StatsTotalInsightsCell.swift */; }; + 1788106F260E488B00A98BD8 /* UnifiedPrologueNotificationsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1788106E260E488B00A98BD8 /* UnifiedPrologueNotificationsContentView.swift */; }; + 178810B52611D25600A98BD8 /* Text+BoldSubString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178810B42611D25600A98BD8 /* Text+BoldSubString.swift */; }; + 178810D92612037900A98BD8 /* UnifiedPrologueReaderContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178810D82612037800A98BD8 /* UnifiedPrologueReaderContentView.swift */; }; + 178DDD06266D68A3006C68C4 /* BloggingRemindersFlowIntroViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178DDD05266D68A3006C68C4 /* BloggingRemindersFlowIntroViewController.swift */; }; + 178DDD1B266D7523006C68C4 /* BloggingRemindersFlowSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178DDD1A266D7523006C68C4 /* BloggingRemindersFlowSettingsViewController.swift */; }; + 178DDD30266D7576006C68C4 /* BloggingRemindersFlowCompletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178DDD2F266D7576006C68C4 /* BloggingRemindersFlowCompletionViewController.swift */; }; + 178DDD31266D7576006C68C4 /* BloggingRemindersFlowCompletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178DDD2F266D7576006C68C4 /* BloggingRemindersFlowCompletionViewController.swift */; }; + 178DDD57266E4165006C68C4 /* CalendarDayToggleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178DDD56266E4165006C68C4 /* CalendarDayToggleButton.swift */; }; 1790A4531E28F0ED00AE54C2 /* UINavigationController+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1790A4521E28F0ED00AE54C2 /* UINavigationController+Helpers.swift */; }; + 179501CD27A01D4100882787 /* PublicizeAuthorizationURLComponentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179501CC27A01D4100882787 /* PublicizeAuthorizationURLComponentsTests.swift */; }; 1797373720EBAA4100377B4E /* RouteMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1797373620EBAA4100377B4E /* RouteMatcherTests.swift */; }; + 179A70F02729834B006DAC0A /* Binding+OnChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179A70EF2729834B006DAC0A /* Binding+OnChange.swift */; }; + 179A70F12729834B006DAC0A /* Binding+OnChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179A70EF2729834B006DAC0A /* Binding+OnChange.swift */; }; 17A09B99238FE13B0022AE0D /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; 17A28DC3205001A900EA6D9E /* FilterTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A28DC2205001A900EA6D9E /* FilterTabBar.swift */; }; 17A28DC52050404C00EA6D9E /* AuthorFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A28DC42050404C00EA6D9E /* AuthorFilterButton.swift */; }; 17A28DCB2052FB5D00EA6D9E /* AuthorFilterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A28DCA2052FB5D00EA6D9E /* AuthorFilterViewController.swift */; }; 17A4A36920EE51870071C2CA /* Routes+Reader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A4A36820EE51870071C2CA /* Routes+Reader.swift */; }; 17A4A36C20EE55320071C2CA /* ReaderCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A4A36B20EE55320071C2CA /* ReaderCoordinator.swift */; }; + 17A8858D2757B97F0071FCA3 /* AutomatticAbout in Frameworks */ = {isa = PBXBuildFile; productRef = 17A8858C2757B97F0071FCA3 /* AutomatticAbout */; }; + 17A8858F2757BEC00071FCA3 /* AutomatticAbout in Frameworks */ = {isa = PBXBuildFile; productRef = 17A8858E2757BEC00071FCA3 /* AutomatticAbout */; }; + 17ABD3522811A48900B1E9CB /* StatsMostPopularTimeInsightsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17ABD3512811A48900B1E9CB /* StatsMostPopularTimeInsightsCell.swift */; }; + 17ABD3532811A48900B1E9CB /* StatsMostPopularTimeInsightsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17ABD3512811A48900B1E9CB /* StatsMostPopularTimeInsightsCell.swift */; }; 17AD36D51D36C1A60044B10D /* WPStyleGuide+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17AD36D41D36C1A60044B10D /* WPStyleGuide+Search.swift */; }; 17AF92251C46634000A99CFB /* BlogSiteVisibilityHelperTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 17AF92241C46634000A99CFB /* BlogSiteVisibilityHelperTest.m */; }; 17B7C89E20EC1D0D0042E260 /* UniversalLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B7C89D20EC1D0D0042E260 /* UniversalLinkRouter.swift */; }; @@ -202,166 +537,392 @@ 17BB26AE1E6D8321008CD031 /* MediaLibraryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BB26AD1E6D8321008CD031 /* MediaLibraryViewController.swift */; }; 17BD4A0820F76A4700975AC3 /* Routes+Banners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BD4A0720F76A4700975AC3 /* Routes+Banners.swift */; }; 17BD4A192101D31B00975AC3 /* NavigationActionHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BD4A182101D31B00975AC3 /* NavigationActionHelpers.swift */; }; + 17C1D67C2670E3DC006C8970 /* SiteIconPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C1D67B2670E3DC006C8970 /* SiteIconPickerView.swift */; }; + 17C1D67D2670E3DC006C8970 /* SiteIconPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C1D67B2670E3DC006C8970 /* SiteIconPickerView.swift */; }; + 17C1D6912670E4A2006C8970 /* UIFont+Fitting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C1D6902670E4A2006C8970 /* UIFont+Fitting.swift */; }; + 17C1D6922670E4A2006C8970 /* UIFont+Fitting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C1D6902670E4A2006C8970 /* UIFont+Fitting.swift */; }; + 17C1D6F526711ED0006C8970 /* Emoji.txt in Resources */ = {isa = PBXBuildFile; fileRef = 17C1D6F426711ED0006C8970 /* Emoji.txt */; }; + 17C1D6F626711ED0006C8970 /* Emoji.txt in Resources */ = {isa = PBXBuildFile; fileRef = 17C1D6F426711ED0006C8970 /* Emoji.txt */; }; + 17C1D7DC26735631006C8970 /* EmojiRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C1D7DB26735631006C8970 /* EmojiRenderer.swift */; }; + 17C1D7DD26735631006C8970 /* EmojiRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C1D7DB26735631006C8970 /* EmojiRenderer.swift */; }; + 17C2FF0925D4852400CDB712 /* UnifiedProloguePages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C2FF0825D4852400CDB712 /* UnifiedProloguePages.swift */; }; + 17C3F8BF25E4438200EFFE12 /* notifications-button-text-content.json in Resources */ = {isa = PBXBuildFile; fileRef = 17C3F8BE25E4438100EFFE12 /* notifications-button-text-content.json */; }; + 17C3FA6F25E591D200EFFE12 /* UIFont+Weight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C3FA6E25E591D200EFFE12 /* UIFont+Weight.swift */; }; + 17C64BD2248E26A200AF09D7 /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C64BD1248E26A200AF09D7 /* AppAppearance.swift */; }; 17CE77ED20C6C2F3001DEA5A /* ReaderSiteSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CE77EC20C6C2F3001DEA5A /* ReaderSiteSearchService.swift */; }; 17CE77EF20C6CDAA001DEA5A /* ReaderSiteSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CE77EE20C6CDAA001DEA5A /* ReaderSiteSearchViewController.swift */; }; 17D2FDC21C6A468A00944265 /* PlanComparisonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D2FDC11C6A468A00944265 /* PlanComparisonViewController.swift */; }; 17D4153C22C2308D006378EF /* StatsPeriodHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D4153B22C2308D006378EF /* StatsPeriodHelper.swift */; }; 17D5C3F71FFCF2D800EB70FF /* MediaProgressCoordinatorNoticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D5C3F61FFCF2D800EB70FF /* MediaProgressCoordinatorNoticeViewModel.swift */; }; + 17D9362324729FB6008B2205 /* Blog+HomepageSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D9362224729FB6008B2205 /* Blog+HomepageSettings.swift */; }; + 17D9362724769579008B2205 /* HomepageSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D9362624769579008B2205 /* HomepageSettingsService.swift */; }; 17D975AF1EF7F6F100303D63 /* WPStyleGuide+Aztec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D975AE1EF7F6F100303D63 /* WPStyleGuide+Aztec.swift */; }; - 17DC4C2E22C5E6910059CA11 /* open_source_dark_icon_20pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C1F22C5E6910059CA11 /* open_source_dark_icon_20pt@2x.png */; }; - 17DC4C2F22C5E6910059CA11 /* open_source_dark_icon_40pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2022C5E6910059CA11 /* open_source_dark_icon_40pt@3x.png */; }; - 17DC4C3022C5E6910059CA11 /* open_source_dark_icon_20pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2122C5E6910059CA11 /* open_source_dark_icon_20pt.png */; }; - 17DC4C3122C5E6910059CA11 /* open_source_dark_icon_40pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2222C5E6910059CA11 /* open_source_dark_icon_40pt@2x.png */; }; - 17DC4C3222C5E6910059CA11 /* open_source_dark_icon_20pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2322C5E6910059CA11 /* open_source_dark_icon_20pt@3x.png */; }; - 17DC4C3322C5E6910059CA11 /* open_source_dark_icon_40pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2422C5E6910059CA11 /* open_source_dark_icon_40pt.png */; }; - 17DC4C3422C5E6910059CA11 /* open_source_dark_icon_76pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2522C5E6910059CA11 /* open_source_dark_icon_76pt@2x.png */; }; - 17DC4C3522C5E6910059CA11 /* open_source_dark_icon_29pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2622C5E6910059CA11 /* open_source_dark_icon_29pt.png */; }; - 17DC4C3622C5E6910059CA11 /* open_source_dark_icon_60pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2722C5E6910059CA11 /* open_source_dark_icon_60pt@3x.png */; }; - 17DC4C3722C5E6910059CA11 /* open_source_dark_icon_29pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2822C5E6910059CA11 /* open_source_dark_icon_29pt@3x.png */; }; - 17DC4C3822C5E6910059CA11 /* open_source_dark_icon_83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2922C5E6910059CA11 /* open_source_dark_icon_83.5@2x.png */; }; - 17DC4C3922C5E6910059CA11 /* open_source_dark_icon_29pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2A22C5E6910059CA11 /* open_source_dark_icon_29pt@2x.png */; }; - 17DC4C3A22C5E6910059CA11 /* open_source_dark_icon_60pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2B22C5E6910059CA11 /* open_source_dark_icon_60pt@2x.png */; }; - 17DC4C3B22C5E6910059CA11 /* open_source_dark_icon_76pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C2C22C5E6910059CA11 /* open_source_dark_icon_76pt.png */; }; - 17DC4C4D22C5E6D60059CA11 /* open_source_icon_76pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C3E22C5E6D30059CA11 /* open_source_icon_76pt@2x.png */; }; - 17DC4C4E22C5E6D60059CA11 /* open_source_icon_20pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C3F22C5E6D30059CA11 /* open_source_icon_20pt.png */; }; - 17DC4C4F22C5E6D60059CA11 /* open_source_icon_20pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4022C5E6D30059CA11 /* open_source_icon_20pt@2x.png */; }; - 17DC4C5022C5E6D60059CA11 /* open_source_icon_40pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4122C5E6D40059CA11 /* open_source_icon_40pt.png */; }; - 17DC4C5222C5E6D60059CA11 /* open_source_icon_29pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4322C5E6D40059CA11 /* open_source_icon_29pt@2x.png */; }; - 17DC4C5322C5E6D60059CA11 /* open_source_icon_60pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4422C5E6D40059CA11 /* open_source_icon_60pt@3x.png */; }; - 17DC4C5422C5E6D60059CA11 /* open_source_icon_83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4522C5E6D50059CA11 /* open_source_icon_83.5@2x.png */; }; - 17DC4C5522C5E6D60059CA11 /* open_source_icon_40pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4622C5E6D50059CA11 /* open_source_icon_40pt@2x.png */; }; - 17DC4C5622C5E6D60059CA11 /* open_source_icon_29pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4722C5E6D50059CA11 /* open_source_icon_29pt@3x.png */; }; - 17DC4C5722C5E6D60059CA11 /* open_source_icon_60pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4822C5E6D50059CA11 /* open_source_icon_60pt@2x.png */; }; - 17DC4C5822C5E6D60059CA11 /* open_source_icon_20pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4922C5E6D50059CA11 /* open_source_icon_20pt@3x.png */; }; - 17DC4C5922C5E6D60059CA11 /* open_source_icon_40pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4A22C5E6D60059CA11 /* open_source_icon_40pt@3x.png */; }; - 17DC4C5A22C5E6D60059CA11 /* open_source_icon_76pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4B22C5E6D60059CA11 /* open_source_icon_76pt.png */; }; - 17DC4C5B22C5E6D60059CA11 /* open_source_icon_29pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17DC4C4C22C5E6D60059CA11 /* open_source_icon_29pt.png */; }; 17E24F5420FCF1D900BD70A3 /* Routes+MySites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E24F5320FCF1D900BD70A3 /* Routes+MySites.swift */; }; 17E362EC22C40BE8000E0C79 /* AppIconViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E362EB22C40BE8000E0C79 /* AppIconViewController.swift */; }; - 17E362EF22C41275000E0C79 /* wordpress_icon_40pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362ED22C41275000E0C79 /* wordpress_icon_40pt@2x.png */; }; - 17E362F022C41275000E0C79 /* wordpress_icon_40pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362EE22C41275000E0C79 /* wordpress_icon_40pt@3x.png */; }; - 17E3630022C41725000E0C79 /* wordpress_dark_icon_20pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362F122C41721000E0C79 /* wordpress_dark_icon_20pt@2x.png */; }; - 17E3630122C41725000E0C79 /* wordpress_dark_icon_40pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362F222C41722000E0C79 /* wordpress_dark_icon_40pt@3x.png */; }; - 17E3630222C41725000E0C79 /* wordpress_dark_icon_20pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362F322C41722000E0C79 /* wordpress_dark_icon_20pt.png */; }; - 17E3630322C41725000E0C79 /* wordpress_dark_icon_83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362F422C41722000E0C79 /* wordpress_dark_icon_83.5@2x.png */; }; - 17E3630422C41725000E0C79 /* wordpress_dark_icon_29pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362F522C41723000E0C79 /* wordpress_dark_icon_29pt@2x.png */; }; - 17E3630522C41725000E0C79 /* wordpress_dark_icon_60pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362F622C41723000E0C79 /* wordpress_dark_icon_60pt@3x.png */; }; - 17E3630622C41725000E0C79 /* wordpress_dark_icon_60pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362F722C41723000E0C79 /* wordpress_dark_icon_60pt@2x.png */; }; - 17E3630822C41725000E0C79 /* wordpress_dark_icon_29pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362F922C41724000E0C79 /* wordpress_dark_icon_29pt@3x.png */; }; - 17E3630922C41725000E0C79 /* wordpress_dark_icon_29pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362FA22C41724000E0C79 /* wordpress_dark_icon_29pt.png */; }; - 17E3630A22C41725000E0C79 /* wordpress_dark_icon_40pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362FB22C41724000E0C79 /* wordpress_dark_icon_40pt@2x.png */; }; - 17E3630B22C41725000E0C79 /* wordpress_dark_icon_40pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362FC22C41724000E0C79 /* wordpress_dark_icon_40pt.png */; }; - 17E3630C22C41725000E0C79 /* wordpress_dark_icon_20pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362FD22C41725000E0C79 /* wordpress_dark_icon_20pt@3x.png */; }; - 17E3630D22C41725000E0C79 /* wordpress_dark_icon_76pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362FE22C41725000E0C79 /* wordpress_dark_icon_76pt.png */; }; - 17E3630E22C41725000E0C79 /* wordpress_dark_icon_76pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E362FF22C41725000E0C79 /* wordpress_dark_icon_76pt@2x.png */; }; - 17E3633C22C417F0000E0C79 /* jetpack_green_icon_40pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3632D22C417E7000E0C79 /* jetpack_green_icon_40pt.png */; }; - 17E3633D22C417F0000E0C79 /* jetpack_green_icon_20pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3632E22C417E7000E0C79 /* jetpack_green_icon_20pt@3x.png */; }; - 17E3633E22C417F0000E0C79 /* jetpack_green_icon_40pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3632F22C417E7000E0C79 /* jetpack_green_icon_40pt@2x.png */; }; - 17E3633F22C417F0000E0C79 /* jetpack_green_icon_29pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3633022C417E8000E0C79 /* jetpack_green_icon_29pt@3x.png */; }; - 17E3634022C417F0000E0C79 /* jetpack_green_icon_29pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3633122C417E8000E0C79 /* jetpack_green_icon_29pt.png */; }; - 17E3634222C417F0000E0C79 /* jetpack_green_icon_83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3633322C417E9000E0C79 /* jetpack_green_icon_83.5@2x.png */; }; - 17E3634322C417F0000E0C79 /* jetpack_green_icon_29pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3633422C417EA000E0C79 /* jetpack_green_icon_29pt@2x.png */; }; - 17E3634422C417F0000E0C79 /* jetpack_green_icon_60pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3633522C417EB000E0C79 /* jetpack_green_icon_60pt@3x.png */; }; - 17E3634522C417F0000E0C79 /* jetpack_green_icon_20pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3633622C417EB000E0C79 /* jetpack_green_icon_20pt@2x.png */; }; - 17E3634622C417F0000E0C79 /* jetpack_green_icon_76pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3633722C417EC000E0C79 /* jetpack_green_icon_76pt@2x.png */; }; - 17E3634722C417F0000E0C79 /* jetpack_green_icon_76pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3633822C417EC000E0C79 /* jetpack_green_icon_76pt.png */; }; - 17E3634822C417F0000E0C79 /* jetpack_green_icon_20pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3633922C417ED000E0C79 /* jetpack_green_icon_20pt.png */; }; - 17E3634922C417F0000E0C79 /* jetpack_green_icon_40pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3633A22C417EE000E0C79 /* jetpack_green_icon_40pt@3x.png */; }; - 17E3634A22C417F0000E0C79 /* jetpack_green_icon_60pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3633B22C417EF000E0C79 /* jetpack_green_icon_60pt@2x.png */; }; - 17E3639722C41DBA000E0C79 /* hot_pink_icon_29pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3638822C41DB0000E0C79 /* hot_pink_icon_29pt@2x.png */; }; - 17E3639822C41DBA000E0C79 /* hot_pink_icon_60pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3638922C41DB0000E0C79 /* hot_pink_icon_60pt@3x.png */; }; - 17E3639922C41DBA000E0C79 /* hot_pink_icon_76pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3638A22C41DB1000E0C79 /* hot_pink_icon_76pt.png */; }; - 17E3639A22C41DBA000E0C79 /* hot_pink_icon_20pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3638B22C41DB1000E0C79 /* hot_pink_icon_20pt@3x.png */; }; - 17E3639B22C41DBA000E0C79 /* hot_pink_icon_20pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3638C22C41DB1000E0C79 /* hot_pink_icon_20pt@2x.png */; }; - 17E3639C22C41DBA000E0C79 /* hot_pink_icon_40pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3638D22C41DB2000E0C79 /* hot_pink_icon_40pt.png */; }; - 17E3639D22C41DBA000E0C79 /* hot_pink_icon_40pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3638E22C41DB3000E0C79 /* hot_pink_icon_40pt@3x.png */; }; - 17E3639E22C41DBA000E0C79 /* hot_pink_icon_76pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3638F22C41DB3000E0C79 /* hot_pink_icon_76pt@2x.png */; }; - 17E3639F22C41DBA000E0C79 /* hot_pink_icon_20pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3639022C41DB4000E0C79 /* hot_pink_icon_20pt.png */; }; - 17E363A022C41DBA000E0C79 /* hot_pink_icon_60pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3639122C41DB5000E0C79 /* hot_pink_icon_60pt@2x.png */; }; - 17E363A122C41DBA000E0C79 /* hot_pink_icon_29pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3639222C41DB6000E0C79 /* hot_pink_icon_29pt@3x.png */; }; - 17E363A222C41DBA000E0C79 /* hot_pink_icon_29pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3639322C41DB7000E0C79 /* hot_pink_icon_29pt.png */; }; - 17E363A322C41DBA000E0C79 /* hot_pink_icon_83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3639422C41DB8000E0C79 /* hot_pink_icon_83.5@2x.png */; }; - 17E363A422C41DBA000E0C79 /* hot_pink_icon_40pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E3639522C41DB9000E0C79 /* hot_pink_icon_40pt@2x.png */; }; - 17E363DB22C4280A000E0C79 /* pride_icon_40pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363CB22C42807000E0C79 /* pride_icon_40pt@3x.png */; }; - 17E363DC22C4280A000E0C79 /* pride_icon_60pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363CC22C42807000E0C79 /* pride_icon_60pt@2x.png */; }; - 17E363DD22C4280A000E0C79 /* pride_icon_20pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363CD22C42808000E0C79 /* pride_icon_20pt@2x.png */; }; - 17E363DE22C4280A000E0C79 /* pride_icon_20pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363CE22C42808000E0C79 /* pride_icon_20pt.png */; }; - 17E363DF22C4280A000E0C79 /* pride_icon_29pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363CF22C42808000E0C79 /* pride_icon_29pt@2x.png */; }; - 17E363E022C4280A000E0C79 /* pride_icon_60pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363D022C42808000E0C79 /* pride_icon_60pt.png */; }; - 17E363E122C4280A000E0C79 /* pride_icon_76pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363D122C42808000E0C79 /* pride_icon_76pt@2x.png */; }; - 17E363E222C4280A000E0C79 /* pride_icon_76pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363D222C42808000E0C79 /* pride_icon_76pt.png */; }; - 17E363E322C4280A000E0C79 /* pride_icon_83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363D322C42808000E0C79 /* pride_icon_83.5@2x.png */; }; - 17E363E422C4280A000E0C79 /* pride_icon_29pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363D422C42809000E0C79 /* pride_icon_29pt@3x.png */; }; - 17E363E522C4280A000E0C79 /* pride_icon_40pt@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363D522C42809000E0C79 /* pride_icon_40pt@2x.png */; }; - 17E363E622C4280A000E0C79 /* pride_icon_40pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363D622C42809000E0C79 /* pride_icon_40pt.png */; }; - 17E363E722C4280A000E0C79 /* pride_icon_20pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363D722C42809000E0C79 /* pride_icon_20pt@3x.png */; }; - 17E363E822C4280A000E0C79 /* pride_icon_29pt.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363D822C42809000E0C79 /* pride_icon_29pt.png */; }; - 17E363E922C4280A000E0C79 /* pride_icon_60pt@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = 17E363D922C4280A000E0C79 /* pride_icon_60pt@3x.png */; }; 17E4CD0C238C33F300C56916 /* DebugMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E4CD0B238C33F300C56916 /* DebugMenuViewController.swift */; }; - 17E60E09220DBD6E00848F89 /* WKWebView+Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E60E08220DBD6E00848F89 /* WKWebView+Preview.swift */; }; + 17EFD3742578201100AB753C /* ValueTransformers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17EFD3732578201100AB753C /* ValueTransformers.swift */; }; 17F0E1DA20EBDC0A001E9514 /* Routes+Me.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F0E1D920EBDC0A001E9514 /* Routes+Me.swift */; }; + 17F11EDB268623BA00D1BBA7 /* BloggingRemindersScheduleFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F11EDA268623BA00D1BBA7 /* BloggingRemindersScheduleFormatter.swift */; }; + 17F11EDC268623BA00D1BBA7 /* BloggingRemindersScheduleFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F11EDA268623BA00D1BBA7 /* BloggingRemindersScheduleFormatter.swift */; }; 17F52DB72315233300164966 /* WPStyleGuide+FilterTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F52DB62315233300164966 /* WPStyleGuide+FilterTabBar.swift */; }; 17F67C56203D81430072001E /* PostCardStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F67C55203D81430072001E /* PostCardStatusViewModel.swift */; }; 17F7C24922770B68002E5C2E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F7C24822770B68002E5C2E /* main.swift */; }; + 17FC0032264D728E00FCBD37 /* SharingServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17FC0031264D728E00FCBD37 /* SharingServiceTests.swift */; }; 17FCA6811FD84B4600DBA9C8 /* NoticeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17FCA6801FD84B4600DBA9C8 /* NoticeStore.swift */; }; 1A433B1D2254CBEE00AE7910 /* WordPressComRestApi+Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A433B1C2254CBEE00AE7910 /* WordPressComRestApi+Defaults.swift */; }; - 1A82EC9F229EBAFB000F141E /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4EA11FD6654A007AE3E4 /* Logger.swift */; }; 1ABA150822AE5F870039311A /* WordPressUIBundleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABA150722AE5F870039311A /* WordPressUIBundleTests.swift */; }; - 1AE0F2B12297F7E9000BDD7F /* WireMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AE0F2B02297F7E9000BDD7F /* WireMock.swift */; }; - 1AED7239229D2E260036C5B8 /* WireMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AE0F2B02297F7E9000BDD7F /* WireMock.swift */; }; - 1D36FCB53C05724865D41F7A /* Pods_WordPress.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 556E3A9C1600564F6A3CADF6 /* Pods_WordPress.framework */; }; + 1D19C56329C9D9A700FB0087 /* GutenbergVideoPressUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D19C56229C9D9A700FB0087 /* GutenbergVideoPressUploadProcessor.swift */; }; + 1D19C56429C9D9A700FB0087 /* GutenbergVideoPressUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D19C56229C9D9A700FB0087 /* GutenbergVideoPressUploadProcessor.swift */; }; + 1D19C56629C9DB0A00FB0087 /* GutenbergVideoPressUploadProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D19C56529C9DB0A00FB0087 /* GutenbergVideoPressUploadProcessorTests.swift */; }; 1D60589F0D05DD5A006BFB54 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D30AB110D05D00D00671497 /* Foundation.framework */; }; + 1D91080729F847A2003F9A5E /* MediaServiceUpdateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 1D91080629F847A2003F9A5E /* MediaServiceUpdateTests.m */; }; + 1E0462162566938300EB98EF /* GutenbergFileUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0462152566938300EB98EF /* GutenbergFileUploadProcessor.swift */; }; + 1E0FF01E242BC572008DA898 /* GutenbergWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0FF01D242BC572008DA898 /* GutenbergWebViewController.swift */; }; + 1E485A90249B61440000A253 /* GutenbergRequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E485A8F249B61440000A253 /* GutenbergRequestAuthenticator.swift */; }; + 1E4F2E712458AF8500EB73E7 /* GutenbergWebNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4F2E702458AF8500EB73E7 /* GutenbergWebNavigationViewController.swift */; }; + 1E5D00102493CE240004B708 /* GutenGhostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E5D000F2493CE240004B708 /* GutenGhostView.swift */; }; + 1E5D00142493E8C90004B708 /* GutenGhostView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 1E5D00132493E8C90004B708 /* GutenGhostView.xib */; }; + 1E672D95257663CE00421F13 /* GutenbergAudioUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E672D94257663CE00421F13 /* GutenbergAudioUploadProcessor.swift */; }; 1E9D544D23C4C56300F6A9E0 /* GutenbergRollout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9D544C23C4C56300F6A9E0 /* GutenbergRollout.swift */; }; + 223EA61E212A7C26A456C32C /* Pods_JetpackDraftActionExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 430F7B409FE22699ADB1A724 /* Pods_JetpackDraftActionExtension.framework */; }; + 241E60B325CA0D2900912CEB /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 241E60B225CA0D2900912CEB /* UserSettings.swift */; }; + 2420BEF125D8DAB300966129 /* Blog+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2420BEF025D8DAB300966129 /* Blog+Lookup.swift */; }; + 24351254264DCA08009BB2B6 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24351253264DCA08009BB2B6 /* Secrets.swift */; }; + 24351255264DCA08009BB2B6 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24351253264DCA08009BB2B6 /* Secrets.swift */; }; + 246D0A0325E97D5D0028B83F /* Blog+ObjcTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 246D0A0225E97D5D0028B83F /* Blog+ObjcTests.m */; }; + 2481B17F260D4D4E00AE59DB /* WPAccount+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2481B17E260D4D4E00AE59DB /* WPAccount+Lookup.swift */; }; + 2481B180260D4D4E00AE59DB /* WPAccount+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2481B17E260D4D4E00AE59DB /* WPAccount+Lookup.swift */; }; + 2481B1D5260D4E8B00AE59DB /* AccountBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2481B1D4260D4E8B00AE59DB /* AccountBuilder.swift */; }; + 2481B1E8260D4EAC00AE59DB /* WPAccount+LookupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2481B1E7260D4EAC00AE59DB /* WPAccount+LookupTests.swift */; }; + 2481B20C260D8FED00AE59DB /* WPAccount+ObjCLookupTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 2481B20B260D8FED00AE59DB /* WPAccount+ObjCLookupTests.m */; }; + 24A2948325D602710000A51E /* BlogTimeZoneTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 24A2948225D602710000A51E /* BlogTimeZoneTests.m */; }; + 24ADA24C24F9A4CB001B5DAE /* RemoteFeatureFlagStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24ADA24B24F9A4CB001B5DAE /* RemoteFeatureFlagStore.swift */; }; + 24B1AE3124FEC79900B9F334 /* RemoteFeatureFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B1AE3024FEC79900B9F334 /* RemoteFeatureFlagTests.swift */; }; + 24C69A8B2612421900312D9A /* UserSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C69A8A2612421900312D9A /* UserSettingsTests.swift */; }; + 24C69AC22612467C00312D9A /* UserSettingsTestsObjc.m in Sources */ = {isa = PBXBuildFile; fileRef = 24C69AC12612467C00312D9A /* UserSettingsTestsObjc.m */; }; + 24CE2EB1258D687A0000C297 /* WordPressFlux in Frameworks */ = {isa = PBXBuildFile; productRef = 24CE2EB0258D687A0000C297 /* WordPressFlux */; }; + 24F3789825E6E62100A27BB7 /* NSManagedObject+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F3789725E6E62100A27BB7 /* NSManagedObject+Lookup.swift */; }; 2611CC62A62F9E6BC25350FE /* Pods_WordPressScreenshotGeneration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AB390AA9C94F16E78184E9D1 /* Pods_WordPressScreenshotGeneration.framework */; }; - 26D66DEC36ACF7442186B07D /* Pods_WordPressThisWeekWidget.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 979B445A45E13F3289F2E99E /* Pods_WordPressThisWeekWidget.framework */; }; 2906F812110CDA8900169D56 /* EditCommentViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2906F810110CDA8900169D56 /* EditCommentViewController.m */; }; 296890780FE971DC00770264 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 296890770FE971DC00770264 /* Security.framework */; }; 2F08ECFC2283A4FB000F8E11 /* PostService+UnattachedMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F08ECFB2283A4FB000F8E11 /* PostService+UnattachedMedia.swift */; }; + 2F09D134245223D300956257 /* HeaderDetailsContentStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F09D133245223D300956257 /* HeaderDetailsContentStyles.swift */; }; 2F161B0622CC2DC70066A5C5 /* LoadingStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F161B0522CC2DC70066A5C5 /* LoadingStatusView.swift */; }; + 2F605FA8251430C200F99544 /* PostCategoriesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F605FA7251430C200F99544 /* PostCategoriesViewController.swift */; }; + 2F605FAA25145F7200F99544 /* WPCategoryTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F605FA925145F7200F99544 /* WPCategoryTree.swift */; }; + 2F668B61255DD11400D0038A /* JetpackSpeedUpSiteSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F668B5E255DD11400D0038A /* JetpackSpeedUpSiteSettingsViewController.swift */; }; + 2F668B62255DD11400D0038A /* JetpackSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F668B5F255DD11400D0038A /* JetpackSettingsViewController.swift */; }; + 2F668B63255DD11400D0038A /* JetpackConnectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F668B60255DD11400D0038A /* JetpackConnectionViewController.swift */; }; 2FA37B1A215724E900C80377 /* LongPressGestureLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA37B19215724E900C80377 /* LongPressGestureLabel.swift */; }; - 2FA6511321F26949009AA935 /* SiteVerticalsPromptService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA6511221F26949009AA935 /* SiteVerticalsPromptService.swift */; }; - 2FA6511521F269A6009AA935 /* VerticalsTableViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA6511421F269A6009AA935 /* VerticalsTableViewProvider.swift */; }; 2FA6511721F26A24009AA935 /* ChangePasswordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA6511621F26A24009AA935 /* ChangePasswordViewController.swift */; }; - 2FA6511A21F26A57009AA935 /* InlineErrorTableViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA6511821F26A57009AA935 /* InlineErrorTableViewProvider.swift */; }; 2FA6511B21F26A57009AA935 /* InlineErrorRetryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA6511921F26A57009AA935 /* InlineErrorRetryTableViewCell.swift */; }; - 2FA6511D21F26A7C009AA935 /* WebAddressTableViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA6511C21F26A7C009AA935 /* WebAddressTableViewProvider.swift */; }; 2FAE97090E33B21600CA8540 /* defaultPostTemplate_old.html in Resources */ = {isa = PBXBuildFile; fileRef = 2FAE97040E33B21600CA8540 /* defaultPostTemplate_old.html */; }; 2FAE970C0E33B21600CA8540 /* xhtml1-transitional.dtd in Resources */ = {isa = PBXBuildFile; fileRef = 2FAE97070E33B21600CA8540 /* xhtml1-transitional.dtd */; }; 2FAE970D0E33B21600CA8540 /* xhtmlValidatorTemplate.xhtml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAE97080E33B21600CA8540 /* xhtmlValidatorTemplate.xhtml */; }; 30EABE0918A5903400B73A9C /* WPBlogTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 30EABE0818A5903400B73A9C /* WPBlogTableViewCell.m */; }; 3101866B1A373B01008F7DF6 /* WPTabBarController.m in Sources */ = {isa = PBXBuildFile; fileRef = 3101866A1A373B01008F7DF6 /* WPTabBarController.m */; }; - 313AE4A019E3F20400AAFABE /* CommentViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 313AE49C19E3F20400AAFABE /* CommentViewController.m */; }; 315FC2C51A2CB29300E7CDA2 /* MeHeaderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 315FC2C41A2CB29300E7CDA2 /* MeHeaderView.m */; }; - 319D6E7B19E447500013871C /* Suggestion.m in Sources */ = {isa = PBXBuildFile; fileRef = 319D6E7A19E447500013871C /* Suggestion.m */; }; - 319D6E7E19E447C80013871C /* SuggestionService.m in Sources */ = {isa = PBXBuildFile; fileRef = 319D6E7D19E447C80013871C /* SuggestionService.m */; }; 319D6E8119E44C680013871C /* SuggestionsTableView.m in Sources */ = {isa = PBXBuildFile; fileRef = 319D6E8019E44C680013871C /* SuggestionsTableView.m */; }; 319D6E8519E44F7F0013871C /* SuggestionsTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 319D6E8419E44F7F0013871C /* SuggestionsTableViewCell.m */; }; - 31C9F82E1A2368A2008BB945 /* BlogDetailHeaderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 31C9F82D1A2368A2008BB945 /* BlogDetailHeaderView.m */; }; 31EC15081A5B6675009FC8B3 /* WPStyleGuide+Suggestions.m in Sources */ = {isa = PBXBuildFile; fileRef = 31EC15071A5B6675009FC8B3 /* WPStyleGuide+Suggestions.m */; }; + 32110547250BFC3E0048446F /* ImageDimensionParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32110546250BFC3E0048446F /* ImageDimensionParserTests.swift */; }; + 3211055A250C027D0048446F /* invalid-jpeg-header.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 32110552250C027B0048446F /* invalid-jpeg-header.jpg */; }; + 3211055B250C027D0048446F /* valid-jpeg-header.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 32110553250C027B0048446F /* valid-jpeg-header.jpg */; }; + 3211055C250C027D0048446F /* valid-gif-header.gif in Resources */ = {isa = PBXBuildFile; fileRef = 32110554250C027B0048446F /* valid-gif-header.gif */; }; + 3211055D250C027D0048446F /* 100x100.gif in Resources */ = {isa = PBXBuildFile; fileRef = 32110555250C027C0048446F /* 100x100.gif */; }; + 32110560250C027D0048446F /* invalid-gif.gif in Resources */ = {isa = PBXBuildFile; fileRef = 32110558250C027C0048446F /* invalid-gif.gif */; }; + 32110561250C027D0048446F /* 100x100.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 32110559250C027C0048446F /* 100x100.jpg */; }; + 32110569250C0E960048446F /* 100x100-png in Resources */ = {isa = PBXBuildFile; fileRef = 32110568250C0E960048446F /* 100x100-png */; }; + 3211056B250C0F750048446F /* valid-png-header in Resources */ = {isa = PBXBuildFile; fileRef = 3211056A250C0F750048446F /* valid-png-header */; }; + 321955BF24BE234C00E3F316 /* ReaderInterestsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 321955BE24BE234C00E3F316 /* ReaderInterestsCoordinator.swift */; }; + 321955C124BE4EBF00E3F316 /* ReaderSelectInterestsCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 321955C024BE4EBF00E3F316 /* ReaderSelectInterestsCoordinatorTests.swift */; }; + 321955C324BF77E400E3F316 /* ReaderTopicService+FollowedInterests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 321955C224BF77E400E3F316 /* ReaderTopicService+FollowedInterests.swift */; }; 321E292623A5F10900588610 /* FullScreenCommentReplyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328CEC5D23A532BA00A6899E /* FullScreenCommentReplyViewController.swift */; }; + 3223393C24FEC18100BDD4BF /* ReaderDetailFeaturedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3223393B24FEC18000BDD4BF /* ReaderDetailFeaturedImageView.swift */; }; + 3223393E24FEC2A700BDD4BF /* ReaderDetailFeaturedImageView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3223393D24FEC2A700BDD4BF /* ReaderDetailFeaturedImageView.xib */; }; + 3234B8E7252FA0930068DA40 /* ReaderSitesCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3234B8E6252FA0930068DA40 /* ReaderSitesCardCell.swift */; }; + 3234BB082530D7DC0068DA40 /* ReaderTopicService+SiteInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3234BB072530D7DC0068DA40 /* ReaderTopicService+SiteInfo.swift */; }; + 3234BB172530DFCA0068DA40 /* ReaderTableCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3234BB162530DFCA0068DA40 /* ReaderTableCardCell.swift */; }; + 3234BB342530EA980068DA40 /* ReaderRecommendedSiteCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3234BB322530EA980068DA40 /* ReaderRecommendedSiteCardCell.swift */; }; + 3234BB352530EA980068DA40 /* ReaderRecommendedSiteCardCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3234BB332530EA980068DA40 /* ReaderRecommendedSiteCardCell.xib */; }; + 3236F77224ABB6C90088E8F3 /* ReaderInterestsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3236F77124ABB6C90088E8F3 /* ReaderInterestsDataSource.swift */; }; + 3236F77524ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3236F77324ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.swift */; }; + 3236F77624ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3236F77424ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.xib */; }; + 3236F79E24AE75790088E8F3 /* ReaderTopicService+Interests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3236F79D24AE75790088E8F3 /* ReaderTopicService+Interests.swift */; }; + 3236F7A124B61B950088E8F3 /* ReaderInterestsDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3236F7A024B61B950088E8F3 /* ReaderInterestsDataSourceTests.swift */; }; 323F8F3023A22C4C000BA49C /* SiteCreationRotatingMessageViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C6CDDA23A1FF0D002556FF /* SiteCreationRotatingMessageViewTests.swift */; }; 323F8F3123A22C8F000BA49C /* SiteCreationRotatingMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3221278523A0BD27002CA183 /* SiteCreationRotatingMessageView.swift */; }; + 324780E1247F2E2A00987525 /* NoResultsViewController+FollowedSites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324780E0247F2E2A00987525 /* NoResultsViewController+FollowedSites.swift */; }; 3249615223F20111004C7733 /* PostSignUpInterstitialViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3249615023F20111004C7733 /* PostSignUpInterstitialViewController.swift */; }; 3249615323F20111004C7733 /* PostSignUpInterstitialViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3249615123F20111004C7733 /* PostSignUpInterstitialViewController.xib */; }; + 3250490724F988220036B47F /* Interpolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3250490624F988220036B47F /* Interpolation.swift */; }; + 3254366C24ABA82100B2C5F5 /* ReaderInterestsStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3254366B24ABA82100B2C5F5 /* ReaderInterestsStyleGuide.swift */; }; 325D3B3D23A8376400766DF6 /* FullScreenCommentReplyViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 325D3B3C23A8376400766DF6 /* FullScreenCommentReplyViewControllerTests.swift */; }; - 32C765BA23F715E4000A7F11 /* PostSignUpInterstitialCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F1A6CE23F7083500AB8CA9 /* PostSignUpInterstitialCoordinator.swift */; }; - 32C765BB23F7170C000A7F11 /* PostSignUpInterstitialCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F1A6D323F7111800AB8CA9 /* PostSignUpInterstitialCoordinatorTests.swift */; }; + 326E281B250AC4A50029EBF0 /* ImageDimensionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326E2819250AC4A50029EBF0 /* ImageDimensionFetcher.swift */; }; + 326E281C250AC4A50029EBF0 /* ImageDimensionParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326E281A250AC4A50029EBF0 /* ImageDimensionParser.swift */; }; + 329F8E5624DDAC61002A5311 /* DynamicHeightCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 329F8E5524DDAC61002A5311 /* DynamicHeightCollectionView.swift */; }; + 329F8E5824DDBD11002A5311 /* ReaderTopicCollectionViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 329F8E5724DDBD11002A5311 /* ReaderTopicCollectionViewCoordinator.swift */; }; + 32A218D8251109DB00D1AE6C /* ReaderReportPostAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32A218D7251109DB00D1AE6C /* ReaderReportPostAction.swift */; }; 32CA6EC02390C61F00B51347 /* PostListEditorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32CA6EBF2390C61F00B51347 /* PostListEditorPresenter.swift */; }; + 32E1BFDA24A66F2A007A08F0 /* ReaderInterestsCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32E1BFD924A66F2A007A08F0 /* ReaderInterestsCollectionViewFlowLayout.swift */; }; + 32E1BFFD24AB9D28007A08F0 /* ReaderSelectInterestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32E1BFFB24AB9D28007A08F0 /* ReaderSelectInterestsViewController.swift */; }; + 32E1BFFE24AB9D28007A08F0 /* ReaderSelectInterestsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 32E1BFFC24AB9D28007A08F0 /* ReaderSelectInterestsViewController.xib */; }; + 32F2566025012D3F006B8BC4 /* LinearGradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F2565F25012D3F006B8BC4 /* LinearGradientView.swift */; }; + 35BBACD2917117A95B6F3046 /* Pods_JetpackStatsWidgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26AC7B7EB4454FA8E268624D /* Pods_JetpackStatsWidgets.framework */; }; + 365FDEB78647AB79DDCC4533 /* Pods_WordPressUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8DCE7542239FBC709B90EA85 /* Pods_WordPressUITests.framework */; }; 37022D931981C19000F322B7 /* VerticallyStackedButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 37022D901981BF9200F322B7 /* VerticallyStackedButton.m */; }; 374CB16215B93C0800DD0EBC /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 374CB16115B93C0800DD0EBC /* AudioToolbox.framework */; }; 37EAAF4D1A11799A006D6306 /* CircularImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAAF4C1A11799A006D6306 /* CircularImageView.swift */; }; + 3F09CCA82428FF3300D00A8C /* ReaderTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F09CCA72428FF3300D00A8C /* ReaderTabViewController.swift */; }; + 3F09CCAA2428FF8300D00A8C /* ReaderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F09CCA92428FF8300D00A8C /* ReaderTabView.swift */; }; + 3F09CCAE24292EFD00D00A8C /* ReaderTabItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F09CCAD24292EFD00D00A8C /* ReaderTabItem.swift */; }; + 3F107B1929B6F7E0009B3658 /* XCTestCase+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F107B1829B6F7E0009B3658 /* XCTestCase+Utils.swift */; }; + 3F170E242655917400F6F670 /* UIView+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F170E232655917400F6F670 /* UIView+SwiftUI.swift */; }; + 3F170E252655917400F6F670 /* UIView+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F170E232655917400F6F670 /* UIView+SwiftUI.swift */; }; 3F1AD48123FC87A400BB1375 /* BlogDetailsViewController+MeButtonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1AD48023FC87A400BB1375 /* BlogDetailsViewController+MeButtonTests.swift */; }; 3F1B66A323A2F54B0075F09E /* ReaderReblogActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F1B66A223A2F54B0075F09E /* ReaderReblogActionTests.swift */; }; + 3F1FD2502548AD8B0060C53A /* TodayWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E58A2E2360D23400E5534B /* TodayWidgetStats.swift */; }; + 3F1FD27B2548AE900060C53A /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; + 3F1FD30D2548B0A80060C53A /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; + 3F2656A125AF4DFA0073A832 /* AppLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */; }; 3F29EB7224042276005313DE /* MeViewController+UIViewControllerRestoration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F29EB7124042276005313DE /* MeViewController+UIViewControllerRestoration.swift */; }; - 3F30E50923FB362700225013 /* WPTabBarController+MeNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F30E50823FB362700225013 /* WPTabBarController+MeNavigation.swift */; }; + 3F2ABE16277037A9005D8916 /* VideoLimitsAlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2ABE15277037A9005D8916 /* VideoLimitsAlertPresenter.swift */; }; + 3F2ABE1827704EE2005D8916 /* WPMediaAsset+VideoLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2ABE1727704EE2005D8916 /* WPMediaAsset+VideoLimits.swift */; }; + 3F2ABE1A2770EF3E005D8916 /* Blog+VideoLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2ABE192770EF3E005D8916 /* Blog+VideoLimits.swift */; }; + 3F2ABE1B277118C4005D8916 /* Blog+VideoLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2ABE192770EF3E005D8916 /* Blog+VideoLimits.swift */; }; + 3F2ABE1C277118C9005D8916 /* VideoLimitsAlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2ABE15277037A9005D8916 /* VideoLimitsAlertPresenter.swift */; }; + 3F2ABE1D277118CC005D8916 /* WPMediaAsset+VideoLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2ABE1727704EE2005D8916 /* WPMediaAsset+VideoLimits.swift */; }; + 3F2B62DC284F4E0B0008CD59 /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 3F2B62DB284F4E0B0008CD59 /* Charts */; }; + 3F2B62DE284F4E310008CD59 /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 3F2B62DD284F4E310008CD59 /* Charts */; }; + 3F2F0C16256C6B2C003351C7 /* StatsWidgetsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2F0C15256C6B2C003351C7 /* StatsWidgetsService.swift */; }; + 3F2F854026FAE9DC000FCDA5 /* BlockEditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2BB0C92289CC3B0034F9AB /* BlockEditorScreen.swift */; }; + 3F2F854226FAEA50000FCDA5 /* JetpackScanScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4104732639393700E90EBF /* JetpackScanScreen.swift */; }; + 3F2F854326FAEA50000FCDA5 /* JetpackBackupScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4104BE26393F1A00E90EBF /* JetpackBackupScreen.swift */; }; + 3F2F854426FAEA82000FCDA5 /* JetpackBackupOptionsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA41070D263957C000E90EBF /* JetpackBackupOptionsScreen.swift */; }; + 3F2F854526FAEB86000FCDA5 /* MediaPickerAlbumScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC52189B2279D295008998CE /* MediaPickerAlbumScreen.swift */; }; + 3F2F854726FAED51000FCDA5 /* MediaPickerAlbumListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC5218992279CF3B008998CE /* MediaPickerAlbumListScreen.swift */; }; + 3F2F854826FAEEEC000FCDA5 /* EditorPublishEpilogueScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D83A1FF13B8A00A11345 /* EditorPublishEpilogueScreen.swift */; }; + 3F2F854A26FAF132000FCDA5 /* FeaturedImageScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD47BDE2474228C00F00660 /* FeaturedImageScreen.swift */; }; + 3F2F854F26FAF227000FCDA5 /* WelcomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4E9A1FD66423007AE3E4 /* WelcomeScreen.swift */; }; + 3F2F855026FAF227000FCDA5 /* SignupEmailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB97522B15A2900642EE9 /* SignupEmailScreen.swift */; }; + 3F2F855126FAF227000FCDA5 /* WelcomeScreenLoginComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985F06B42303866200949733 /* WelcomeScreenLoginComponent.swift */; }; + 3F2F855226FAF227000FCDA5 /* SignupCheckMagicLinkScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB97722B15B2C00642EE9 /* SignupCheckMagicLinkScreen.swift */; }; + 3F2F855326FAF227000FCDA5 /* EditorPostSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC19BE05223FECAC00CAB3E1 /* EditorPostSettings.swift */; }; + 3F2F855426FAF227000FCDA5 /* AztecEditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D8341FF1208400A11345 /* AztecEditorScreen.swift */; }; + 3F2F855526FAF227000FCDA5 /* TagsComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC8498D22241477F00DB490A /* TagsComponent.swift */; }; + 3F2F855626FAF227000FCDA5 /* LoginEmailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4EA31FD6659B007AE3E4 /* LoginEmailScreen.swift */; }; + 3F2F855726FAF227000FCDA5 /* WelcomeScreenSignupComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB98622B28F4600642EE9 /* WelcomeScreenSignupComponent.swift */; }; + 3F2F855826FAF227000FCDA5 /* SignupEpilogueScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB97922B15C1000642EE9 /* SignupEpilogueScreen.swift */; }; + 3F2F855926FAF227000FCDA5 /* CategoriesComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE55E982242715C002A9634 /* CategoriesComponent.swift */; }; + 3F2F855A26FAF227000FCDA5 /* EditorNoticeComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC94FC67221452A4002E5825 /* EditorNoticeComponent.swift */; }; + 3F2F855B26FAF227000FCDA5 /* LoginPasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD3271FD6705200E55192 /* LoginPasswordScreen.swift */; }; + 3F2F855C26FAF227000FCDA5 /* LinkOrPasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD3291FD6708900E55192 /* LinkOrPasswordScreen.swift */; }; + 3F2F855D26FAF227000FCDA5 /* LoginCheckMagicLinkScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF6ACE6221ED73900D0C5BE /* LoginCheckMagicLinkScreen.swift */; }; + 3F2F855E26FAF227000FCDA5 /* PrologueScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AA22BC25082A86005CCC13 /* PrologueScreen.swift */; }; + 3F2F855F26FAF235000FCDA5 /* MeTabScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD3311FD6803700E55192 /* MeTabScreen.swift */; }; + 3F2F856026FAF235000FCDA5 /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE87071A2006E65C004FB5A4 /* NotificationsScreen.swift */; }; + 3F2F856126FAF235000FCDA5 /* ReaderScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8707182006E48E004FB5A4 /* ReaderScreen.swift */; }; + 3F2F856326FAF612000FCDA5 /* EditorGutenbergTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2BB0CF228ACF710034F9AB /* EditorGutenbergTests.swift */; }; + 3F3087C424EDB7040087B548 /* AnnouncementCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3087C324EDB7040087B548 /* AnnouncementCell.swift */; }; + 3F30A6B0299B412E0004452F /* PeopleScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F30A6AF299B412E0004452F /* PeopleScreen.swift */; }; + 3F338B71289BD3040014ADC5 /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = 3F338B70289BD3040014ADC5 /* Nimble */; }; + 3F338B73289BD5970014ADC5 /* Nimble in Frameworks */ = {isa = PBXBuildFile; productRef = 3F338B72289BD5970014ADC5 /* Nimble */; }; + 3F39C93527A09927001EC300 /* WordPressLibraryLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F39C93427A09927001EC300 /* WordPressLibraryLogger.swift */; }; + 3F39C93627A09927001EC300 /* WordPressLibraryLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F39C93427A09927001EC300 /* WordPressLibraryLogger.swift */; }; + 3F3B23C22858A1B300CACE60 /* BuildkiteTestCollector in Frameworks */ = {isa = PBXBuildFile; productRef = 3F3B23C12858A1B300CACE60 /* BuildkiteTestCollector */; }; + 3F3B23C42858A1D800CACE60 /* BuildkiteTestCollector in Frameworks */ = {isa = PBXBuildFile; productRef = 3F3B23C32858A1D800CACE60 /* BuildkiteTestCollector */; }; + 3F3CA65025D3003C00642A89 /* StatsWidgetsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3CA64F25D3003C00642A89 /* StatsWidgetsStore.swift */; }; + 3F3D854B251E6418001CA4D2 /* AnnouncementsDataStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3D854A251E6418001CA4D2 /* AnnouncementsDataStoreTests.swift */; }; + 3F3DD0AF26FCDA3100F5F121 /* PresentationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3DD0AE26FCDA3100F5F121 /* PresentationButton.swift */; }; + 3F3DD0B026FCDA3100F5F121 /* PresentationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3DD0AE26FCDA3100F5F121 /* PresentationButton.swift */; }; + 3F3DD0B226FD176800F5F121 /* PresentationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3DD0B126FD176800F5F121 /* PresentationCard.swift */; }; + 3F3DD0B326FD176800F5F121 /* PresentationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3DD0B126FD176800F5F121 /* PresentationCard.swift */; }; + 3F3DD0B626FD18EB00F5F121 /* Blog+DomainsDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3DD0B526FD18EB00F5F121 /* Blog+DomainsDashboardView.swift */; }; + 3F3DD0B726FD18EB00F5F121 /* Blog+DomainsDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3DD0B526FD18EB00F5F121 /* Blog+DomainsDashboardView.swift */; }; + 3F411B6F28987E3F002513AE /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 3F411B6E28987E3F002513AE /* Lottie */; }; + 3F421DF524A3EC2B00CA9B9E /* Spotlightable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F421DF424A3EC2B00CA9B9E /* Spotlightable.swift */; }; + 3F435220289B2B2B00CE19ED /* JetpackBrandingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43704328932F0100475B6E /* JetpackBrandingCoordinator.swift */; }; + 3F435221289B2B5100CE19ED /* JetpackOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43703E2893201400475B6E /* JetpackOverlayViewController.swift */; }; + 3F435222289B2B5A00CE19ED /* JetpackOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F4370402893207C00475B6E /* JetpackOverlayView.swift */; }; 3F43602F23F31D48001DEE70 /* ScenePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43602E23F31D48001DEE70 /* ScenePresenter.swift */; }; 3F43603123F31E09001DEE70 /* MeScenePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43603023F31E09001DEE70 /* MeScenePresenter.swift */; }; 3F43603323F36515001DEE70 /* BlogListViewController+BlogDetailsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43603223F36515001DEE70 /* BlogListViewController+BlogDetailsFactory.swift */; }; + 3F43703F2893201400475B6E /* JetpackOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43703E2893201400475B6E /* JetpackOverlayViewController.swift */; }; + 3F4370412893207C00475B6E /* JetpackOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F4370402893207C00475B6E /* JetpackOverlayView.swift */; }; + 3F43704428932F0100475B6E /* JetpackBrandingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43704328932F0100475B6E /* JetpackBrandingCoordinator.swift */; }; + 3F44DD58289C379C006334CD /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 3F44DD57289C379C006334CD /* Lottie */; }; + 3F46AAFE25BF5D6300CE2E98 /* Sites.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 3F46AB0225BF5D6300CE2E98 /* Sites.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; }; + 3F46AB0025BF5D6300CE2E98 /* Sites.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 3F46AB0225BF5D6300CE2E98 /* Sites.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; }; + 3F46EEC728BC4935004F02B2 /* JetpackPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F46EEC628BC4935004F02B2 /* JetpackPrompt.swift */; }; + 3F46EECE28BC4962004F02B2 /* JetpackLandingScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F46EECB28BC4962004F02B2 /* JetpackLandingScreenView.swift */; }; + 3F46EED128BFF339004F02B2 /* JetpackPromptsConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F46EED028BFF339004F02B2 /* JetpackPromptsConfiguration.swift */; }; + 3F4D035028A56F9B00F0A4FD /* CircularImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F4D034F28A56F9B00F0A4FD /* CircularImageButton.swift */; }; + 3F4D035128A56F9B00F0A4FD /* CircularImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F4D034F28A56F9B00F0A4FD /* CircularImageButton.swift */; }; + 3F4D035328A5BFCE00F0A4FD /* JetpackWordPressLogoAnimation_ltr.json in Resources */ = {isa = PBXBuildFile; fileRef = 3F4D035228A5BFCE00F0A4FD /* JetpackWordPressLogoAnimation_ltr.json */; }; + 3F4EB39228AC561600B8DD86 /* JetpackWordPressLogoAnimation_rtl.json in Resources */ = {isa = PBXBuildFile; fileRef = 3F4EB39128AC561600B8DD86 /* JetpackWordPressLogoAnimation_rtl.json */; }; + 3F50945B2454ECA000C4470B /* ReaderTabItemsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50945A2454ECA000C4470B /* ReaderTabItemsStoreTests.swift */; }; + 3F50945F245537A700C4470B /* ReaderTabViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F50945E245537A700C4470B /* ReaderTabViewModelTests.swift */; }; + 3F526C4E2538CF2A0069706C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F526C4D2538CF2A0069706C /* WidgetKit.framework */; }; + 3F526C502538CF2A0069706C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3F526C4F2538CF2A0069706C /* SwiftUI.framework */; }; + 3F526C562538CF2B0069706C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3F526C552538CF2B0069706C /* Assets.xcassets */; }; + 3F526C5C2538CF2B0069706C /* WordPressStatsWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3F526C4C2538CF2A0069706C /* WordPressStatsWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 3F526D572539FAC60069706C /* StatsWidgetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F526D562539FAC60069706C /* StatsWidgetsView.swift */; }; + 3F5689F0254209790048A9E4 /* SingleStatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5689EF254209790048A9E4 /* SingleStatView.swift */; }; + 3F568A0025420DE80048A9E4 /* MultiStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5689FF25420DE80048A9E4 /* MultiStatsView.swift */; }; + 3F568A1F254213B60048A9E4 /* VerticalCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F568A1E254213B60048A9E4 /* VerticalCard.swift */; }; + 3F568A2F254216550048A9E4 /* FlexibleCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F568A2E254216550048A9E4 /* FlexibleCard.swift */; }; + 3F5AAC242877791900AEF5DD /* JetpackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFA5ED12876152E00830E28 /* JetpackButton.swift */; }; 3F5B3EAF23A851330060FF1F /* ReaderReblogPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5B3EAE23A851330060FF1F /* ReaderReblogPresenter.swift */; }; 3F5B3EB123A851480060FF1F /* ReaderReblogFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5B3EB023A851480060FF1F /* ReaderReblogFormatter.swift */; }; - 3F8CB104239E025F007627BF /* ReaderDetailViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8CB103239E025F007627BF /* ReaderDetailViewControllerTests.swift */; }; + 3F5B9B43288AFE4B001D17E9 /* DashboardBadgeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5B9B42288AFE4B001D17E9 /* DashboardBadgeCell.swift */; }; + 3F5B9B45288B0761001D17E9 /* DashboardBadgeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5B9B42288AFE4B001D17E9 /* DashboardBadgeCell.swift */; }; + 3F5C861A25C9EA2500BABE64 /* HomeWidgetAllTimeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5C861925C9EA2500BABE64 /* HomeWidgetAllTimeData.swift */; }; + 3F5C863B25C9EA8200BABE64 /* AllTimeWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFF57D23984344008A1DCB /* AllTimeWidgetStats.swift */; }; + 3F5C864C25C9EA8400BABE64 /* AllTimeWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFF57D23984344008A1DCB /* AllTimeWidgetStats.swift */; }; + 3F5C865D25C9EBEF00BABE64 /* HomeWidgetAllTimeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5C861925C9EA2500BABE64 /* HomeWidgetAllTimeData.swift */; }; + 3F5C866E25C9EBF200BABE64 /* HomeWidgetAllTimeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5C861925C9EA2500BABE64 /* HomeWidgetAllTimeData.swift */; }; + 3F5C86C025CA197500BABE64 /* WordPressHomeWidgetAllTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5C86BF25CA197500BABE64 /* WordPressHomeWidgetAllTime.swift */; }; + 3F63B93C258179D100F581BE /* StatsWidgetEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F63B93B258179D100F581BE /* StatsWidgetEntry.swift */; }; + 3F662C4A24DC9FAC00CAEA95 /* WhatIsNewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F662C4924DC9FAC00CAEA95 /* WhatIsNewViewController.swift */; }; + 3F685B6A26D431FA001C6808 /* DomainSuggestionViewControllerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAF9CC426D03C7400268EA2 /* DomainSuggestionViewControllerWrapper.swift */; }; + 3F6975FF242D941E001F1807 /* ReaderTabViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6975FE242D941E001F1807 /* ReaderTabViewModel.swift */; }; + 3F6A7E92251BC1DC005B6A61 /* RootViewCoordinator+WhatIsNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6A7E91251BC1DC005B6A61 /* RootViewCoordinator+WhatIsNew.swift */; }; + 3F6AD0562502A91400080F3B /* AnnouncementsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6AD0552502A91400080F3B /* AnnouncementsCache.swift */; }; + 3F6BC04B25B2474C007369D3 /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690151F828FF000200E30 /* FeatureFlag.swift */; }; + 3F6BC05C25B24773007369D3 /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; + 3F6BC06D25B24787007369D3 /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690141F828FF000200E30 /* BuildConfiguration.swift */; }; + 3F6BC07E25B247A4007369D3 /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; + 3F6DA04125646F96002AB88F /* HomeWidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6DA04025646F96002AB88F /* HomeWidgetData.swift */; }; + 3F71D5302548C2B200A4BA93 /* Double+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C82B52193A7B900A06E84 /* Double+Stats.swift */; }; + 3F720C2128899DD900519938 /* JetpackBrandingVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F720C2028899DD900519938 /* JetpackBrandingVisibility.swift */; }; + 3F720C222889B65B00519938 /* JetpackBrandingVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F720C2028899DD900519938 /* JetpackBrandingVisibility.swift */; }; + 3F73388226C1CE9B0075D1DD /* TimeSelectionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F73388126C1CE9B0075D1DD /* TimeSelectionButton.swift */; }; + 3F73BE5D24EB38E200BE99FF /* WhatIsNewScenePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F73BE5C24EB38E200BE99FF /* WhatIsNewScenePresenter.swift */; }; + 3F73BE5F24EB3B4400BE99FF /* WhatIsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F73BE5E24EB3B4400BE99FF /* WhatIsNewView.swift */; }; + 3F751D462491A93D0008A2B1 /* ZendeskUtilsTests+Plans.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F751D452491A93D0008A2B1 /* ZendeskUtilsTests+Plans.swift */; }; + 3F758FD524F6FB4900BBA2FC /* AnnouncementsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F758FD424F6FB4900BBA2FC /* AnnouncementsStore.swift */; }; + 3F762E9326784A950088CD45 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F762E9226784A950088CD45 /* Logger.swift */; }; + 3F762E9526784B540088CD45 /* WireMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F762E9426784B540088CD45 /* WireMock.swift */; }; + 3F762E9726784BED0088CD45 /* FancyAlertComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F762E9626784BED0088CD45 /* FancyAlertComponent.swift */; }; + 3F762E9926784CC90088CD45 /* XCUIElementQuery+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F762E9826784CC90088CD45 /* XCUIElementQuery+Utils.swift */; }; + 3F762E9B26784D2A0088CD45 /* XCUIElement+Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F762E9A26784D2A0088CD45 /* XCUIElement+Utils.swift */; }; + 3F762E9D26784DB40088CD45 /* Globals.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F762E9C26784DB40088CD45 /* Globals.swift */; }; + 3F810A5A2616870C00ADDCC2 /* UnifiedPrologueIntroContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F810A592616870C00ADDCC2 /* UnifiedPrologueIntroContentView.swift */; }; + 3F810A5B2616870C00ADDCC2 /* UnifiedPrologueIntroContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F810A592616870C00ADDCC2 /* UnifiedPrologueIntroContentView.swift */; }; + 3F82310F24564A870086E9B8 /* ReaderTabViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F82310E24564A870086E9B8 /* ReaderTabViewTests.swift */; }; + 3F8513DF260D091500A4B938 /* RoundRectangleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8513DE260D091500A4B938 /* RoundRectangleView.swift */; }; + 3F851415260D0A3300A4B938 /* UnifiedPrologueEditorContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F851414260D0A3300A4B938 /* UnifiedPrologueEditorContentView.swift */; }; + 3F851428260D1EA300A4B938 /* CircledIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F851427260D1EA300A4B938 /* CircledIcon.swift */; }; + 3F86A83729D19C15005D20C0 /* SignupEpilogueTableViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F86A83629D19C15005D20C0 /* SignupEpilogueTableViewControllerTests.swift */; }; + 3F88065B26C30F2A0074DD21 /* TimeSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F88065A26C30F2A0074DD21 /* TimeSelectionViewController.swift */; }; + 3F8A087D253E4337000F35ED /* ColorPalette.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 435B76292297484200511813 /* ColorPalette.xcassets */; }; + 3F8B136D25D08F34004FAC0A /* HomeWidgetThisWeekData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B136C25D08F34004FAC0A /* HomeWidgetThisWeekData.swift */; }; + 3F8B138F25D09AA5004FAC0A /* WordPressHomeWidgetThisWeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B138E25D09AA5004FAC0A /* WordPressHomeWidgetThisWeek.swift */; }; + 3F8B306825D1D4B8005A2903 /* ThisWeekWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F93181239AF64800E4E96E /* ThisWeekWidgetStats.swift */; }; + 3F8B310E25D1D60C005A2903 /* ThisWeekWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F93181239AF64800E4E96E /* ThisWeekWidgetStats.swift */; }; + 3F8B311F25D1D610005A2903 /* HomeWidgetThisWeekData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B136C25D08F34004FAC0A /* HomeWidgetThisWeekData.swift */; }; + 3F8B313025D1D652005A2903 /* HomeWidgetThisWeekData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B136C25D08F34004FAC0A /* HomeWidgetThisWeekData.swift */; }; + 3F8B45A029283D6C00730FA4 /* DashboardMigrationSuccessCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B459F29283D6C00730FA4 /* DashboardMigrationSuccessCell.swift */; }; + 3F8B45A7292C1A2300730FA4 /* MigrationSuccessCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B45A6292C1A2300730FA4 /* MigrationSuccessCardView.swift */; }; + 3F8B45A8292C1F2500730FA4 /* MigrationSuccessCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B45A6292C1A2300730FA4 /* MigrationSuccessCardView.swift */; }; + 3F8B45A9292C1F2C00730FA4 /* DashboardMigrationSuccessCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B459F29283D6C00730FA4 /* DashboardMigrationSuccessCell.swift */; }; + 3F8B45AB292C42CC00730FA4 /* MigrationSuccessCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B45AA292C42CC00730FA4 /* MigrationSuccessCell.swift */; }; + 3F8B45AC292C455800730FA4 /* MigrationSuccessCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B45AA292C42CC00730FA4 /* MigrationSuccessCell.swift */; }; 3F8CB10623A07B17007627BF /* ReaderReblogAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8CB10523A07B17007627BF /* ReaderReblogAction.swift */; }; + 3F8CBE0B24EEB0EA00F71234 /* AnnouncementsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8CBE0A24EEB0EA00F71234 /* AnnouncementsDataSource.swift */; }; + 3F8CBE0D24EED2CB00F71234 /* FindOutMoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8CBE0C24EED2CB00F71234 /* FindOutMoreCell.swift */; }; + 3F8D988926153484003619E5 /* UnifiedPrologueBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8D988826153484003619E5 /* UnifiedPrologueBackgroundView.swift */; }; + 3F8D988A26153484003619E5 /* UnifiedPrologueBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8D988826153484003619E5 /* UnifiedPrologueBackgroundView.swift */; }; + 3F8EEC4E25B4817000EC9DAE /* StatsWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8EEC4D25B4817000EC9DAE /* StatsWidgets.swift */; }; + 3F8EEC7025B4849A00EC9DAE /* SiteListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8EEC6F25B4849A00EC9DAE /* SiteListProvider.swift */; }; + 3F946C592684DD8E00B946F6 /* BloggingRemindersActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F946C582684DD8E00B946F6 /* BloggingRemindersActions.swift */; }; + 3F946C5A2684DD8E00B946F6 /* BloggingRemindersActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F946C582684DD8E00B946F6 /* BloggingRemindersActions.swift */; }; + 3F95FF4026C4F385007731D3 /* ScreenObject in Frameworks */ = {isa = PBXBuildFile; productRef = 3FC2C34226C4E8B700C6D98F /* ScreenObject */; }; + 3FA53E9C256571D800F4D9A2 /* HomeWidgetCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA53E9B256571D800F4D9A2 /* HomeWidgetCache.swift */; }; + 3FA53E9D256571D800F4D9A2 /* HomeWidgetCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA53E9B256571D800F4D9A2 /* HomeWidgetCache.swift */; }; + 3FA53ED62565860900F4D9A2 /* HomeWidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6DA04025646F96002AB88F /* HomeWidgetData.swift */; }; + 3FA59B9A258289E30073772F /* StatsValueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA59B99258289E30073772F /* StatsValueView.swift */; }; + 3FA62FD326FE2E4B0020793A /* ShapeWithTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA62FD226FE2E4B0020793A /* ShapeWithTextView.swift */; }; + 3FA62FD426FE2E4B0020793A /* ShapeWithTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA62FD226FE2E4B0020793A /* ShapeWithTextView.swift */; }; + 3FA6405B2670CCD40064401E /* UITestsFoundation.h in Headers */ = {isa = PBXBuildFile; fileRef = 3FA640592670CCD40064401E /* UITestsFoundation.h */; settings = {ATTRIBUTES = (Public, ); }; }; + 3FA640612670CE210064401E /* UITestsFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3FA640572670CCD40064401E /* UITestsFoundation.framework */; }; + 3FA640622670CE260064401E /* UITestsFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3FA640572670CCD40064401E /* UITestsFoundation.framework */; }; + 3FA640662670CEFF0064401E /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3FA640652670CEFE0064401E /* XCTest.framework */; }; + 3FA640672670D1290064401E /* UITestsFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3FA640572670CCD40064401E /* UITestsFoundation.framework */; }; + 3FAA18CC25797B85002B1911 /* UnconfiguredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAA18CB25797B85002B1911 /* UnconfiguredView.swift */; }; + 3FAE0652287C8FC500F46508 /* JPScrollViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAE0651287C8FC500F46508 /* JPScrollViewDelegate.swift */; }; + 3FAE0653287C8FC500F46508 /* JPScrollViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAE0651287C8FC500F46508 /* JPScrollViewDelegate.swift */; }; + 3FAF9CC226D01CFE00268EA2 /* DomainsDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAF9CC126D01CFE00268EA2 /* DomainsDashboardView.swift */; }; + 3FAF9CC326D02FC500268EA2 /* DomainsDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAF9CC126D01CFE00268EA2 /* DomainsDashboardView.swift */; }; + 3FAF9CC526D03C7400268EA2 /* DomainSuggestionViewControllerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAF9CC426D03C7400268EA2 /* DomainSuggestionViewControllerWrapper.swift */; }; + 3FB1929026C6109F000F5AA3 /* TimeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB1928F26C6109F000F5AA3 /* TimeSelectionView.swift */; }; + 3FB1929126C6C56E000F5AA3 /* TimeSelectionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F73388126C1CE9B0075D1DD /* TimeSelectionButton.swift */; }; + 3FB1929226C6C575000F5AA3 /* TimeSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F88065A26C30F2A0074DD21 /* TimeSelectionViewController.swift */; }; + 3FB1929326C6C57A000F5AA3 /* TimeSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB1928F26C6109F000F5AA3 /* TimeSelectionView.swift */; }; + 3FB1929526C79EC6000F5AA3 /* Date+TimeStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB1929426C79EC6000F5AA3 /* Date+TimeStrings.swift */; }; + 3FB1929626C79EC6000F5AA3 /* Date+TimeStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB1929426C79EC6000F5AA3 /* Date+TimeStrings.swift */; }; + 3FB34ACB25672A90001A74A6 /* HomeWidgetTodayData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB34ACA25672A90001A74A6 /* HomeWidgetTodayData.swift */; }; + 3FB34ADA25672AA5001A74A6 /* HomeWidgetTodayData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB34ACA25672A90001A74A6 /* HomeWidgetTodayData.swift */; }; + 3FB5C2B327059AC8007D0ECE /* XCUIElement+Scroll.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB5C2B227059AC8007D0ECE /* XCUIElement+Scroll.swift */; }; + 3FBB2D2B27FB6CB200C57BBF /* SiteNameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FBB2D2A27FB6CB200C57BBF /* SiteNameViewController.swift */; }; + 3FBB2D2C27FB6CB200C57BBF /* SiteNameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FBB2D2A27FB6CB200C57BBF /* SiteNameViewController.swift */; }; + 3FBB2D2E27FB715900C57BBF /* SiteNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FBB2D2D27FB715900C57BBF /* SiteNameView.swift */; }; + 3FBB2D2F27FB715900C57BBF /* SiteNameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FBB2D2D27FB715900C57BBF /* SiteNameView.swift */; }; + 3FBF21B7267AA17A0098335F /* BloggingRemindersAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FBF21B6267AA17A0098335F /* BloggingRemindersAnimator.swift */; }; + 3FBF21B8267AA17A0098335F /* BloggingRemindersAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FBF21B6267AA17A0098335F /* BloggingRemindersAnimator.swift */; }; + 3FC2C33D26C4CF0A00C6D98F /* XCUITestHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 3FC2C33C26C4CF0A00C6D98F /* XCUITestHelpers */; }; + 3FC7F89E2612341900FD8728 /* UnifiedPrologueStatsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC7F89D2612341900FD8728 /* UnifiedPrologueStatsContentView.swift */; }; + 3FC8D19B244F43B500495820 /* ReaderTabItemsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC8D19A244F43B500495820 /* ReaderTabItemsStore.swift */; }; + 3FCC8FD9256C911300810295 /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; 3FCCAA1523F4A1A3004064C0 /* UIBarButtonItem+MeBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FCCAA1423F4A1A3004064C0 /* UIBarButtonItem+MeBarButton.swift */; }; + 3FCF66E925CAF8C50047F337 /* ListStatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FCF66E825CAF8C50047F337 /* ListStatsView.swift */; }; + 3FCF66FB25CAF8E00047F337 /* ListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FCF66FA25CAF8E00047F337 /* ListRow.swift */; }; + 3FD0316F24201E08005C0993 /* GravatarButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FD0316E24201E08005C0993 /* GravatarButtonView.swift */; }; + 3FD272E024CF8F270021F0C8 /* UIColor+Notice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FD272DF24CF8F270021F0C8 /* UIColor+Notice.swift */; }; + 3FD675D925C87A15009AB3C1 /* Sites.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 3F46AB0225BF5D6300CE2E98 /* Sites.intentdefinition */; }; + 3FD675EA25C87A25009AB3C1 /* WordPressHomeWidgetToday.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F526C522538CF2A0069706C /* WordPressHomeWidgetToday.swift */; }; + 3FD83CBF246C751800381999 /* CoreDataIterativeMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FD83CBE246C751800381999 /* CoreDataIterativeMigrator.swift */; }; + 3FD9CB7E28998ADB00CF76DE /* JetpackOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F4370402893207C00475B6E /* JetpackOverlayView.swift */; }; + 3FDDFE9627C8178C00606933 /* SiteStatsInformationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FDDFE9527C8178C00606933 /* SiteStatsInformationTests.swift */; }; + 3FE20C1525CF165700A15525 /* GroupedViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE20C1425CF165700A15525 /* GroupedViewData.swift */; }; + 3FE20C3725CF211F00A15525 /* ListViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE20C3625CF211F00A15525 /* ListViewData.swift */; }; + 3FE39A3126F836A5006E2B3A /* LoginSiteAddressScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE911BB221D8497007E1D4E /* LoginSiteAddressScreen.swift */; }; + 3FE39A3226F836C7006E2B3A /* LoginUsernamePasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE911BD221D85E4007E1D4E /* LoginUsernamePasswordScreen.swift */; }; + 3FE39A3326F836D7006E2B3A /* GetStartedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AA22BE25082B1E005CCC13 /* GetStartedScreen.swift */; }; + 3FE39A3426F836E9006E2B3A /* PasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98ADDDD925083CA9008FF6EE /* PasswordScreen.swift */; }; + 3FE39A3526F83701006E2B3A /* LoginEpilogueScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD32B1FD6782A00E55192 /* LoginEpilogueScreen.swift */; }; + 3FE39A3626F8370D006E2B3A /* MySitesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD32D1FD67EDA00E55192 /* MySitesScreen.swift */; }; + 3FE39A3726F83748006E2B3A /* SupportScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC71EC22689A67400ACC0A0 /* SupportScreen.swift */; }; + 3FE39A3826F837CB006E2B3A /* MySiteScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8707162006B774004FB5A4 /* MySiteScreen.swift */; }; + 3FE39A3926F837E1006E2B3A /* ActivityLogScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA41044C263932AC00E90EBF /* ActivityLogScreen.swift */; }; + 3FE39A3B26F837FF006E2B3A /* PostsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C47A8B238C801600AAD9ED /* PostsScreen.swift */; }; + 3FE39A3E26F8383E006E2B3A /* MediaScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97DA41F23D67B820050E791 /* MediaScreen.swift */; }; + 3FE39A3F26F8384E006E2B3A /* StatsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C47A8E238C9D6400AAD9ED /* StatsScreen.swift */; }; + 3FE39A4026F8386A006E2B3A /* SiteSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF9F65622F03C9200F79BBF /* SiteSettingsScreen.swift */; }; + 3FE39A4126F8388E006E2B3A /* TabNavComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD32F1FD67F3B00E55192 /* TabNavComponent.swift */; }; + 3FE39A4226F838A0006E2B3A /* ActionSheetComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E032EB240D49FF003AF350 /* ActionSheetComponent.swift */; }; + 3FE3D1FC26A6F2AC00F3CD10 /* ListTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE39C134269C37C900EFB827 /* ListTableViewCell.swift */; }; + 3FE3D1FD26A6F34900F3CD10 /* WPStyleGuide+List.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA088042696F7AA00193358 /* WPStyleGuide+List.swift */; }; + 3FE3D1FE26A6F4AC00F3CD10 /* ListTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA088002696E7F600193358 /* ListTableHeaderView.swift */; }; + 3FE3D1FF26A6F56700F3CD10 /* Comment+Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE02F95E269DC14A00752A44 /* Comment+Interface.swift */; }; + 3FE77C8325B0CA89007DE9E5 /* LocalizableStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FE77C8225B0CA89007DE9E5 /* LocalizableStrings.swift */; }; + 3FEC241525D73E8B007AFE63 /* ConfettiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEC241425D73E8B007AFE63 /* ConfettiView.swift */; }; + 3FF15A56291B4EEA00E1B4E5 /* MigrationCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF15A55291B4EEA00E1B4E5 /* MigrationCenterView.swift */; }; + 3FF15A5C291ED21100E1B4E5 /* MigrationNotificationsCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF15A5B291ED21100E1B4E5 /* MigrationNotificationsCenterView.swift */; }; + 3FF1A853242D5FCB00373F5D /* WPTabBarController+ReaderTabNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF1A852242D5FCB00373F5D /* WPTabBarController+ReaderTabNavigation.swift */; }; + 3FF717FF291F07AB00323614 /* MigrationCenterViewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF717FE291F07AB00323614 /* MigrationCenterViewConfiguration.swift */; }; + 3FFA5ED22876152E00830E28 /* JetpackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFA5ED12876152E00830E28 /* JetpackButton.swift */; }; + 3FFDEF7829177D7500B625CE /* MigrationNotificationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFDEF7729177D7500B625CE /* MigrationNotificationsViewModel.swift */; }; + 3FFDEF7A29177D8C00B625CE /* MigrationNotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFDEF7929177D8C00B625CE /* MigrationNotificationsViewController.swift */; }; + 3FFDEF7F29177FB100B625CE /* MigrationStepConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFDEF7E29177FB100B625CE /* MigrationStepConfiguration.swift */; }; + 3FFDEF812917882800B625CE /* MigrationNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFDEF802917882800B625CE /* MigrationNavigationController.swift */; }; + 3FFDEF8329179CD000B625CE /* MigrationDependencyContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFDEF8229179CD000B625CE /* MigrationDependencyContainer.swift */; }; + 3FFDEF852918215700B625CE /* MigrationStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFDEF842918215700B625CE /* MigrationStepView.swift */; }; + 3FFDEF882918596B00B625CE /* MigrationDoneViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFDEF872918596B00B625CE /* MigrationDoneViewModel.swift */; }; + 3FFDEF8A2918597700B625CE /* MigrationDoneViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFDEF892918597700B625CE /* MigrationDoneViewController.swift */; }; + 3FFDEF8F29187F1200B625CE /* MigrationHeaderConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFDEF8E29187F1200B625CE /* MigrationHeaderConfiguration.swift */; }; + 3FFDEF9129187F2100B625CE /* MigrationActionsConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFDEF9029187F2100B625CE /* MigrationActionsConfiguration.swift */; }; + 3FFE3C0828FE00D10021BB96 /* StatsSegmentedControlDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFE3C0728FE00D10021BB96 /* StatsSegmentedControlDataTests.swift */; }; 400199AB222590E100EB0906 /* AllTimeStatsRecordValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400199AA222590E100EB0906 /* AllTimeStatsRecordValueTests.swift */; }; 400199AD22259FF300EB0906 /* AnnualAndMostPopularTimeStatsRecordValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400199AC22259FF300EB0906 /* AnnualAndMostPopularTimeStatsRecordValueTests.swift */; }; 400A2C772217A8A0000A8A59 /* VisitsSummaryStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C752217A8A0000A8A59 /* VisitsSummaryStatsRecordValue+CoreDataClass.swift */; }; @@ -380,7 +941,7 @@ 400A2C952217B68D000A8A59 /* TopViewedVideoStatsRecordValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C942217B68D000A8A59 /* TopViewedVideoStatsRecordValueTests.swift */; }; 400A2C972217B883000A8A59 /* VisitsSummaryStatsRecordValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C962217B883000A8A59 /* VisitsSummaryStatsRecordValueTests.swift */; }; 400F4625201E74EE000CFD9E /* CollectionViewContainerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400F4624201E74EE000CFD9E /* CollectionViewContainerRow.swift */; }; - 4019B27120885AB900A0C7EB /* Activity.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4019B27020885AB900A0C7EB /* Activity.storyboard */; }; + 4019B27120885AB900A0C7EB /* ActivityDetailViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4019B27020885AB900A0C7EB /* ActivityDetailViewController.storyboard */; }; 401A3D022027DBD80099A127 /* PluginListCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 401A3D012027DBD80099A127 /* PluginListCell.xib */; }; 401AC82722DD2387006D78D4 /* Blog+Plans.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401AC82622DD2387006D78D4 /* Blog+Plans.swift */; }; 4020B2BD2007AC850002C963 /* WPStyleGuide+Gridicon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4020B2BC2007AC850002C963 /* WPStyleGuide+Gridicon.swift */; }; @@ -460,21 +1021,15 @@ 40FC6B7F2072E3EC00B9A1CD /* ActivityDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40FC6B7E2072E3EB00B9A1CD /* ActivityDetailViewController.swift */; }; 430693741DD25F31009398A2 /* PostPost.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 430693731DD25F31009398A2 /* PostPost.storyboard */; }; 430D50BE212B7AAE008F15F4 /* NoticeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430D50BD212B7AAE008F15F4 /* NoticeStyle.swift */; }; - 43134379217954F100DA2176 /* QuickStartSkipAllCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 43134378217954F100DA2176 /* QuickStartSkipAllCell.xib */; }; - 4313437B217956DB00DA2176 /* QuickStartSkipAllCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4313437A217956DB00DA2176 /* QuickStartSkipAllCell.swift */; }; 4319AADE2090F00C0025D68E /* FancyAlertViewController+NotificationPrimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4319AADD2090F00C0025D68E /* FancyAlertViewController+NotificationPrimer.swift */; }; - 431EF35A21F7D4000017BE16 /* QuickStartListTitleCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 431EF35921F7D4000017BE16 /* QuickStartListTitleCell.xib */; }; 4322A20D203E1885004EA740 /* SignupUsernameTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4322A20C203E1885004EA740 /* SignupUsernameTableViewController.swift */; }; 4326191522FCB9DC003C7642 /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; 4326191622FCB9F8003C7642 /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; - 4326191722FCB9F9003C7642 /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; 4326191822FCB9F9003C7642 /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; 4326191922FCB9FA003C7642 /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; 4328FED12314788C000EC32A /* ColorPalette.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 435B76292297484200511813 /* ColorPalette.xcassets */; }; - 43290CF4214F755400F6B398 /* FancyAlertViewController+QuickStart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43290CF3214F755400F6B398 /* FancyAlertViewController+QuickStart.swift */; }; 43290D04215C28D800F6B398 /* Blog+QuickStart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43290D03215C28D800F6B398 /* Blog+QuickStart.swift */; }; 43290D0A215E8B1200F6B398 /* QuickStartSpotlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43290D09215E8B1200F6B398 /* QuickStartSpotlightView.swift */; }; - 432A5AE021F9222A00603959 /* QuickStartListTitleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432A5ADF21F9222A00603959 /* QuickStartListTitleCell.swift */; }; 433432521E9ED18900915988 /* LoginEpilogueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433432511E9ED18900915988 /* LoginEpilogueViewController.swift */; }; 433840C722C2BA5B00CB13F8 /* AppImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 433840C622C2BA5B00CB13F8 /* AppImages.xcassets */; }; 433840C822C2BA6300CB13F8 /* AppImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 433840C622C2BA5B00CB13F8 /* AppImages.xcassets */; }; @@ -486,7 +1041,6 @@ 433ADC19223B2A0200ED9DE1 /* TextBundleWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */; }; 433ADC1A223B2A7D00ED9DE1 /* TextBundleWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */; }; 433ADC1B223B2A7E00ED9DE1 /* TextBundleWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */; }; - 433ADC1C223B2A7E00ED9DE1 /* TextBundleWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */; }; 433ADC1D223B2A7F00ED9DE1 /* TextBundleWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */; }; 4348C88321002FBD00735DC0 /* QuickStartTourGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4348C88221002FBD00735DC0 /* QuickStartTourGuide.swift */; }; 4349B0AC218A45270034118A /* RevisionsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4349B0AB218A45270034118A /* RevisionsTableViewController.swift */; }; @@ -505,9 +1059,6 @@ 435B762B2297484200511813 /* ColorPalette.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 435B76292297484200511813 /* ColorPalette.xcassets */; }; 435B762C2297484200511813 /* ColorPalette.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 435B76292297484200511813 /* ColorPalette.xcassets */; }; 435D101A2130C2AB00BB2AA8 /* BlogDetailsViewController+FancyAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435D10192130C2AB00BB2AA8 /* BlogDetailsViewController+FancyAlerts.swift */; }; - 436110D922C3ED18000773AD /* UIColor+MurielColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435B762122973D0600511813 /* UIColor+MurielColors.swift */; }; - 436110DA22C3ED44000773AD /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690151F828FF000200E30 /* FeatureFlag.swift */; }; - 436110DB22C3ED4C000773AD /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690141F828FF000200E30 /* BuildConfiguration.swift */; }; 436110DC22C41ADB000773AD /* UIColor+MurielColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435B762122973D0600511813 /* UIColor+MurielColors.swift */; }; 436110DD22C41AFD000773AD /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690151F828FF000200E30 /* FeatureFlag.swift */; }; 436110DE22C41B02000773AD /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690141F828FF000200E30 /* BuildConfiguration.swift */; }; @@ -518,7 +1069,6 @@ 436D55F02115CB6800CEAA33 /* RegisterDomainDetailsSectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D55EF2115CB6800CEAA33 /* RegisterDomainDetailsSectionTests.swift */; }; 436D55F5211632B700CEAA33 /* RegisterDomainDetailsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D55F4211632B700CEAA33 /* RegisterDomainDetailsViewModelTests.swift */; }; 436D561F2117312700CEAA33 /* RegisterDomainSuggestionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D560C2117312600CEAA33 /* RegisterDomainSuggestionsViewController.swift */; }; - 436D56202117312700CEAA33 /* RegisterDomainSuggestionsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D560D2117312600CEAA33 /* RegisterDomainSuggestionsTableViewController.swift */; }; 436D56212117312700CEAA33 /* RegisterDomainDetailsViewModel+SectionDefinitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D56102117312700CEAA33 /* RegisterDomainDetailsViewModel+SectionDefinitions.swift */; }; 436D56222117312700CEAA33 /* RegisterDomainDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D56112117312700CEAA33 /* RegisterDomainDetailsViewModel.swift */; }; 436D56242117312700CEAA33 /* RegisterDomainDetailsViewModel+RowList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D56132117312700CEAA33 /* RegisterDomainDetailsViewModel+RowList.swift */; }; @@ -548,22 +1098,125 @@ 43B0BA962229927F00328C69 /* WordPressAppDelegate+openURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B0BA952229927F00328C69 /* WordPressAppDelegate+openURL.swift */; }; 43C9908E21067E22009EFFEB /* QuickStartChecklistViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C9908D21067E22009EFFEB /* QuickStartChecklistViewController.swift */; }; 43D54D131DCAA070007F575F /* PostPostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D54D121DCAA070007F575F /* PostPostViewController.swift */; }; - 43D74AC820F8D17A004AD934 /* DomainSuggestionsButtonViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D74AC720F8D17A004AD934 /* DomainSuggestionsButtonViewPresenter.swift */; }; 43D74ACE20F906DD004AD934 /* InlineEditableNameValueCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 43D74ACD20F906DD004AD934 /* InlineEditableNameValueCell.xib */; }; 43D74AD020F906EE004AD934 /* InlineEditableNameValueCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D74ACF20F906EE004AD934 /* InlineEditableNameValueCell.swift */; }; 43D74AD420FB5ABA004AD934 /* RegisterDomainSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 43D74AD320FB5ABA004AD934 /* RegisterDomainSectionHeaderView.xib */; }; 43D74AD620FB5AD5004AD934 /* RegisterDomainSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D74AD520FB5AD5004AD934 /* RegisterDomainSectionHeaderView.swift */; }; 43DC0EF12040B23200896C9C /* SignupUsernameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC0EF02040B23200896C9C /* SignupUsernameViewController.swift */; }; 43DDFE8C21715ADD008BE72F /* WPTabBarController+QuickStart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DDFE8B21715ADD008BE72F /* WPTabBarController+QuickStart.swift */; }; - 43DDFE9021785EAC008BE72F /* QuickStartCongratulationsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 43DDFE8F21785EAC008BE72F /* QuickStartCongratulationsCell.xib */; }; - 43DDFE922178635D008BE72F /* QuickStartCongratulationsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DDFE912178635D008BE72F /* QuickStartCongratulationsCell.swift */; }; 43EE90EF223B1029006A33E9 /* TextBundleWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */; }; 43FB3F411EBD215C00FC8A62 /* LoginEpilogueBlogCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FB3F401EBD215C00FC8A62 /* LoginEpilogueBlogCell.swift */; }; 43FB3F471EC10F1E00FC8A62 /* LoginEpilogueTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FB3F461EC10F1E00FC8A62 /* LoginEpilogueTableViewController.swift */; }; 43FF64EF20DAA0840060A69A /* GravatarUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FF64EE20DAA0840060A69A /* GravatarUploader.swift */; }; + 46183CF4251BD658004F9AFD /* PageTemplateLayout+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46183CF2251BD658004F9AFD /* PageTemplateLayout+CoreDataClass.swift */; }; + 46183CF5251BD658004F9AFD /* PageTemplateLayout+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46183CF3251BD658004F9AFD /* PageTemplateLayout+CoreDataProperties.swift */; }; + 46183D1F251BD6A0004F9AFD /* PageTemplateCategory+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46183D1D251BD6A0004F9AFD /* PageTemplateCategory+CoreDataClass.swift */; }; + 46183D20251BD6A0004F9AFD /* PageTemplateCategory+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46183D1E251BD6A0004F9AFD /* PageTemplateCategory+CoreDataProperties.swift */; }; + 46241C0F2540BD01002B8A12 /* SiteDesignStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46241C0E2540BD01002B8A12 /* SiteDesignStep.swift */; }; + 46241C3C2540D483002B8A12 /* SiteDesignContentCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46241C3A2540D483002B8A12 /* SiteDesignContentCollectionViewController.swift */; }; + 4625B556253789C000C04AAD /* CollapsableHeaderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4625B555253789C000C04AAD /* CollapsableHeaderViewController.swift */; }; + 4625B6342538B53700C04AAD /* CollapsableHeaderViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4625B6332538B53700C04AAD /* CollapsableHeaderViewController.xib */; }; + 4629E4212440C5B20002E15C /* GutenbergCoverUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4629E4202440C5B20002E15C /* GutenbergCoverUploadProcessor.swift */; }; + 4629E4232440C8160002E15C /* GutenbergCoverUploadProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4629E4222440C8160002E15C /* GutenbergCoverUploadProcessorTests.swift */; }; 462F4E0A18369F0B0028D2F8 /* BlogDetailsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 462F4E0718369F0B0028D2F8 /* BlogDetailsViewController.m */; }; + 4631359124AD013F0017E65C /* PageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4631359024AD013F0017E65C /* PageCoordinator.swift */; }; + 4631359624AD068B0017E65C /* GutenbergLayoutPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4631359524AD068B0017E65C /* GutenbergLayoutPickerViewController.swift */; }; + 464688D8255C71D200ECA61C /* SiteDesignPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 464688D6255C71D200ECA61C /* SiteDesignPreviewViewController.swift */; }; + 464688D9255C71D200ECA61C /* TemplatePreviewViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 464688D7255C71D200ECA61C /* TemplatePreviewViewController.xib */; }; + 465B097A24C877E500336B6C /* GutenbergLightNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465B097924C877E500336B6C /* GutenbergLightNavigationController.swift */; }; + 465F89F7263B690C00F4C950 /* wp-block-editor-v1-settings-success-NotThemeJSON.json in Resources */ = {isa = PBXBuildFile; fileRef = 465F89F6263B690C00F4C950 /* wp-block-editor-v1-settings-success-NotThemeJSON.json */; }; + 465F8A0A263B692600F4C950 /* wp-block-editor-v1-settings-success-ThemeJSON.json in Resources */ = {isa = PBXBuildFile; fileRef = 465F8A09263B692600F4C950 /* wp-block-editor-v1-settings-success-ThemeJSON.json */; }; + 46638DF6244904A3006E8439 /* GutenbergBlockProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46638DF5244904A3006E8439 /* GutenbergBlockProcessor.swift */; }; + 4666534A2501552A00165DD4 /* LayoutPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 466653492501552A00165DD4 /* LayoutPreviewViewController.swift */; }; + 467D3DFA25E4436000EB9CB0 /* SitePromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 467D3DF925E4436000EB9CB0 /* SitePromptView.swift */; }; + 467D3E0C25E4436D00EB9CB0 /* SitePromptView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 467D3E0B25E4436D00EB9CB0 /* SitePromptView.xib */; }; + 4688E6CC26AB571D00A5D894 /* RequestAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4688E6CB26AB571D00A5D894 /* RequestAuthenticatorTests.swift */; }; + 469CE06D24BCED75003BDC8B /* CategorySectionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469CE06B24BCED75003BDC8B /* CategorySectionTableViewCell.swift */; }; + 469CE06E24BCED75003BDC8B /* CategorySectionTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 469CE06C24BCED75003BDC8B /* CategorySectionTableViewCell.xib */; }; + 469CE07124BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469CE06F24BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.swift */; }; + 469CE07224BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 469CE07024BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.xib */; }; + 469EB16524D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469EB16324D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.swift */; }; + 469EB16624D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 469EB16424D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.xib */; }; + 469EB16824D9AD8B00C764CB /* CollapsableHeaderFilterBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469EB16724D9AD8B00C764CB /* CollapsableHeaderFilterBar.swift */; }; + 46B1A16B26A774E500F058AE /* CollapsableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B1A16A26A774E500F058AE /* CollapsableHeaderView.swift */; }; + 46B1A16C26A774E500F058AE /* CollapsableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B1A16A26A774E500F058AE /* CollapsableHeaderView.swift */; }; + 46B30B782582C7DD00A25E66 /* SiteAddressServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B30B772582C7DD00A25E66 /* SiteAddressServiceTests.swift */; }; + 46B30B872582CA2200A25E66 /* domain-suggestions.json in Resources */ = {isa = PBXBuildFile; fileRef = 46B30B862582CA2200A25E66 /* domain-suggestions.json */; }; + 46C984682527863E00988BB9 /* LayoutPickerAnalyticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C984672527863E00988BB9 /* LayoutPickerAnalyticsEvent.swift */; }; + 46CFA7BF262745F70077BAD9 /* get_wp_v2_themes_twentytwentyone.json in Resources */ = {isa = PBXBuildFile; fileRef = 46CFA7BE262745F70077BAD9 /* get_wp_v2_themes_twentytwentyone.json */; }; + 46CFA7E3262746940077BAD9 /* get_wp_v2_themes_twentytwenty.json in Resources */ = {isa = PBXBuildFile; fileRef = 46CFA7E2262746940077BAD9 /* get_wp_v2_themes_twentytwenty.json */; }; + 46D6114F2555DAED00B0B7BB /* SiteCreationAnalyticsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D6114E2555DAED00B0B7BB /* SiteCreationAnalyticsHelper.swift */; }; + 46E327D124E705C7000944B3 /* PageLayoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46E327D024E705C7000944B3 /* PageLayoutService.swift */; }; + 46F583A92624CE790010A723 /* BlockEditorSettings+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F583A52624CE790010A723 /* BlockEditorSettings+CoreDataClass.swift */; }; + 46F583AA2624CE790010A723 /* BlockEditorSettings+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F583A52624CE790010A723 /* BlockEditorSettings+CoreDataClass.swift */; }; + 46F583AB2624CE790010A723 /* BlockEditorSettings+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F583A62624CE790010A723 /* BlockEditorSettings+CoreDataProperties.swift */; }; + 46F583AC2624CE790010A723 /* BlockEditorSettings+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F583A62624CE790010A723 /* BlockEditorSettings+CoreDataProperties.swift */; }; + 46F583AD2624CE790010A723 /* BlockEditorSettingElement+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F583A72624CE790010A723 /* BlockEditorSettingElement+CoreDataClass.swift */; }; + 46F583AE2624CE790010A723 /* BlockEditorSettingElement+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F583A72624CE790010A723 /* BlockEditorSettingElement+CoreDataClass.swift */; }; + 46F583AF2624CE790010A723 /* BlockEditorSettingElement+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F583A82624CE790010A723 /* BlockEditorSettingElement+CoreDataProperties.swift */; }; + 46F583B02624CE790010A723 /* BlockEditorSettingElement+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F583A82624CE790010A723 /* BlockEditorSettingElement+CoreDataProperties.swift */; }; + 46F583D42624D0BC0010A723 /* Blog+BlockEditorSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F583D32624D0BC0010A723 /* Blog+BlockEditorSettings.swift */; }; + 46F583D52624D0BC0010A723 /* Blog+BlockEditorSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F583D32624D0BC0010A723 /* Blog+BlockEditorSettings.swift */; }; + 46F584822624DCC80010A723 /* BlockEditorSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F584812624DCC80010A723 /* BlockEditorSettingsService.swift */; }; + 46F584832624DCC80010A723 /* BlockEditorSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F584812624DCC80010A723 /* BlockEditorSettingsService.swift */; }; + 46F584B82624E6380010A723 /* BlockEditorSettings+GutenbergEditorSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F584B72624E6380010A723 /* BlockEditorSettings+GutenbergEditorSettings.swift */; }; + 46F584B92624E6380010A723 /* BlockEditorSettings+GutenbergEditorSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F584B72624E6380010A723 /* BlockEditorSettings+GutenbergEditorSettings.swift */; }; + 46F58501262605930010A723 /* BlockEditorSettingsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46F58500262605930010A723 /* BlockEditorSettingsServiceTests.swift */; }; + 4A072CD229093704006235BE /* AsyncBlockOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A072CD129093704006235BE /* AsyncBlockOperation.swift */; }; + 4A072CD329093704006235BE /* AsyncBlockOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A072CD129093704006235BE /* AsyncBlockOperation.swift */; }; + 4A17C1A4281A823E0001FFE5 /* NSManagedObject+Fixture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A17C1A3281A823E0001FFE5 /* NSManagedObject+Fixture.swift */; }; + 4A1E77C6298897F6006281CC /* SharingSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E77C5298897F6006281CC /* SharingSyncService.swift */; }; + 4A1E77C7298897F6006281CC /* SharingSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E77C5298897F6006281CC /* SharingSyncService.swift */; }; + 4A1E77C92988997C006281CC /* PublicizeConnection+Creation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E77C82988997C006281CC /* PublicizeConnection+Creation.swift */; }; + 4A1E77CA2988997C006281CC /* PublicizeConnection+Creation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E77C82988997C006281CC /* PublicizeConnection+Creation.swift */; }; + 4A1E77CC2989F2F7006281CC /* WPAccount+DeduplicateBlogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E77CB2989F2F7006281CC /* WPAccount+DeduplicateBlogs.swift */; }; + 4A1E77CD2989F2F7006281CC /* WPAccount+DeduplicateBlogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1E77CB2989F2F7006281CC /* WPAccount+DeduplicateBlogs.swift */; }; + 4A2172F828EAACFF0006F4F1 /* BlogQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2172F728EAACFF0006F4F1 /* BlogQuery.swift */; }; + 4A2172F928EAACFF0006F4F1 /* BlogQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2172F728EAACFF0006F4F1 /* BlogQuery.swift */; }; + 4A2172FE28F688890006F4F1 /* Blog+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2172FD28F688890006F4F1 /* Blog+Media.swift */; }; + 4A2172FF28F688890006F4F1 /* Blog+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A2172FD28F688890006F4F1 /* Blog+Media.swift */; }; + 4A266B8F282B05210089CF3D /* JSONObjectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A266B8E282B05210089CF3D /* JSONObjectTests.swift */; }; + 4A266B91282B13A70089CF3D /* CoreDataTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A266B90282B13A70089CF3D /* CoreDataTestCase.swift */; }; + 4A358DE629B5EB8D00BFCEBE /* PublicizeService+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A358DE529B5EB8D00BFCEBE /* PublicizeService+Lookup.swift */; }; + 4A358DE729B5EB8D00BFCEBE /* PublicizeService+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A358DE529B5EB8D00BFCEBE /* PublicizeService+Lookup.swift */; }; + 4A358DE929B5F14C00BFCEBE /* SharingButton+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A358DE829B5F14C00BFCEBE /* SharingButton+Lookup.swift */; }; + 4A358DEA29B5F14C00BFCEBE /* SharingButton+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A358DE829B5F14C00BFCEBE /* SharingButton+Lookup.swift */; }; + 4A526BDF296BE9A50007B5BA /* CoreDataService.m in Sources */ = {isa = PBXBuildFile; fileRef = 4A526BDD296BE9A50007B5BA /* CoreDataService.m */; }; + 4A526BE0296BE9A50007B5BA /* CoreDataService.m in Sources */ = {isa = PBXBuildFile; fileRef = 4A526BDD296BE9A50007B5BA /* CoreDataService.m */; }; + 4A76A4BB29D4381100AABF4B /* CommentService+LikesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A76A4BA29D4381000AABF4B /* CommentService+LikesTests.swift */; }; + 4A76A4BD29D43BFD00AABF4B /* CommentService+MorderationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A76A4BC29D43BFD00AABF4B /* CommentService+MorderationTests.swift */; }; + 4A76A4BF29D4F0A500AABF4B /* reader-post-comments-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 4A76A4BE29D4F0A500AABF4B /* reader-post-comments-success.json */; }; + 4A82C43128D321A300486CFF /* Blog+Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A82C43028D321A300486CFF /* Blog+Post.swift */; }; + 4A82C43228D321A300486CFF /* Blog+Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A82C43028D321A300486CFF /* Blog+Post.swift */; }; + 4A878550290F2C7D0083AB78 /* Media+Sync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A87854F290F2C7D0083AB78 /* Media+Sync.swift */; }; + 4A878551290F2C7D0083AB78 /* Media+Sync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A87854F290F2C7D0083AB78 /* Media+Sync.swift */; }; + 4A9314DC297790C300360232 /* PeopleServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9314DB297790C300360232 /* PeopleServiceTests.swift */; }; + 4A9314E42979FA4700360232 /* PostCategory+Creation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9314E32979FA4700360232 /* PostCategory+Creation.swift */; }; + 4A9314E52979FA4700360232 /* PostCategory+Creation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9314E32979FA4700360232 /* PostCategory+Creation.swift */; }; + 4A9314E7297A0C5000360232 /* PostCategory+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9314E6297A0C5000360232 /* PostCategory+Lookup.swift */; }; + 4A9314E8297A0C5000360232 /* PostCategory+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9314E6297A0C5000360232 /* PostCategory+Lookup.swift */; }; + 4A9948E229714EF1006282A9 /* AccountSettingsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9948E129714EF1006282A9 /* AccountSettingsServiceTests.swift */; }; + 4A9948E4297624EF006282A9 /* Blog+Creation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9948E3297624EF006282A9 /* Blog+Creation.swift */; }; + 4A9948E5297624EF006282A9 /* Blog+Creation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9948E3297624EF006282A9 /* Blog+Creation.swift */; }; + 4A9B81E32921AE03007A05D1 /* ContextManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9B81E22921AE02007A05D1 /* ContextManager.swift */; }; + 4A9B81E42921AE03007A05D1 /* ContextManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A9B81E22921AE02007A05D1 /* ContextManager.swift */; }; + 4AA33EF829963ABE005B6E23 /* ReaderAbstractTopic+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA33EF729963ABE005B6E23 /* ReaderAbstractTopic+Lookup.swift */; }; + 4AA33EF929963ABE005B6E23 /* ReaderAbstractTopic+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA33EF729963ABE005B6E23 /* ReaderAbstractTopic+Lookup.swift */; }; + 4AA33EFB2999AE3B005B6E23 /* ReaderListTopic+Creation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA33EFA2999AE3B005B6E23 /* ReaderListTopic+Creation.swift */; }; + 4AA33EFC2999AE3B005B6E23 /* ReaderListTopic+Creation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA33EFA2999AE3B005B6E23 /* ReaderListTopic+Creation.swift */; }; + 4AA33F012999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA33F002999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift */; }; + 4AA33F022999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA33F002999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift */; }; + 4AA33F04299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA33F03299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift */; }; + 4AA33F05299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA33F03299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift */; }; + 4AD5656C28E3D0670054C676 /* ReaderPost+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD5656B28E3D0670054C676 /* ReaderPost+Helper.swift */; }; + 4AD5656D28E3D0670054C676 /* ReaderPost+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD5656B28E3D0670054C676 /* ReaderPost+Helper.swift */; }; + 4AD5656F28E413160054C676 /* Blog+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD5656E28E413160054C676 /* Blog+History.swift */; }; + 4AD5657028E413160054C676 /* Blog+History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD5656E28E413160054C676 /* Blog+History.swift */; }; + 4AD5657228E543A30054C676 /* BlogQueryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD5657128E543A30054C676 /* BlogQueryTests.swift */; }; + 4AEF2DD929A84B2C00345734 /* ReaderSiteServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AEF2DD829A84B2C00345734 /* ReaderSiteServiceTests.swift */; }; + 4AFB8FBF2824999500A2F4B2 /* ContextManager+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFB8FBE2824999400A2F4B2 /* ContextManager+Helpers.swift */; }; 4B2DD0F29CD6AC353C056D41 /* Pods_WordPressUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8DCE7542239FBC709B90EA85 /* Pods_WordPressUITests.framework */; }; - 4C8A715EBCE7E73AEE216293 /* Pods_WordPressShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F47DB4A8EC2E6844E213A3FA /* Pods_WordPressShareExtension.framework */; }; + 4BB2296498BE66D515E3D610 /* Pods_JetpackShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 23052F0F1F9B2503E33D0A26 /* Pods_JetpackShareExtension.framework */; }; 4D520D4F22972BC9002F5924 /* acknowledgements.html in Resources */ = {isa = PBXBuildFile; fileRef = 4D520D4E22972BC9002F5924 /* acknowledgements.html */; }; 570265152298921800F2214C /* PostListTableViewHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 570265142298921800F2214C /* PostListTableViewHandler.swift */; }; 570265172298960B00F2214C /* PostListTableViewHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 570265162298960B00F2214C /* PostListTableViewHandlerTests.swift */; }; @@ -620,7 +1273,7 @@ 598DD1711B97985700146967 /* ThemeBrowserCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 598DD1701B97985700146967 /* ThemeBrowserCell.swift */; }; 59A3CADD1CD2FF0C009BFA1B /* BasePageListCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 59A3CADC1CD2FF0C009BFA1B /* BasePageListCell.m */; }; 59A9AB351B4C33A500A433DC /* ThemeService.m in Sources */ = {isa = PBXBuildFile; fileRef = 59A9AB341B4C33A500A433DC /* ThemeService.m */; }; - 59B48B621B99E132008EBB84 /* JSONLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59B48B611B99E132008EBB84 /* JSONLoader.swift */; }; + 59B48B621B99E132008EBB84 /* JSONObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59B48B611B99E132008EBB84 /* JSONObject.swift */; }; 59DCA5211CC68AF3000F245F /* PageListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59DCA5201CC68AF3000F245F /* PageListViewController.swift */; }; 59DD94341AC479ED0032DD6B /* WPLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 59DD94331AC479ED0032DD6B /* WPLogger.m */; }; 59E1D46E1CEF77B500126697 /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E1D46C1CEF77B500126697 /* Page.swift */; }; @@ -630,7 +1283,6 @@ 5D1181E71B4D6DEB003F3084 /* WPStyleGuide+Reader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1181E61B4D6DEB003F3084 /* WPStyleGuide+Reader.swift */; }; 5D13FA571AF99C2100F06492 /* PageListSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D13FA561AF99C2100F06492 /* PageListSectionHeaderView.xib */; }; 5D146EBB189857ED0068FDC6 /* FeaturedImageViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D146EBA189857ED0068FDC6 /* FeaturedImageViewController.m */; }; - 5D17F0BE1A1D4C5F0087CCB8 /* PrivateSiteURLProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D17F0BD1A1D4C5F0087CCB8 /* PrivateSiteURLProtocol.m */; }; 5D18FE9F1AFBB17400EFEED0 /* RestorePageTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D18FE9D1AFBB17400EFEED0 /* RestorePageTableViewCell.m */; }; 5D18FEA01AFBB17400EFEED0 /* RestorePageTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D18FE9E1AFBB17400EFEED0 /* RestorePageTableViewCell.xib */; }; 5D1D04751B7A50B100CDE646 /* Reader.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5D1D04731B7A50B100CDE646 /* Reader.storyboard */; }; @@ -644,14 +1296,10 @@ 5D42A3E0175E7452005CFF05 /* BasePost.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D42A3D9175E7452005CFF05 /* BasePost.m */; }; 5D42A3E2175E7452005CFF05 /* ReaderPost.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D42A3DD175E7452005CFF05 /* ReaderPost.m */; }; 5D42A405175E76A7005CFF05 /* WPImageViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D42A402175E76A2005CFF05 /* WPImageViewController.m */; }; - 5D44EB381986D8BA008B7175 /* ReaderSiteService.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D44EB371986D8BA008B7175 /* ReaderSiteService.m */; }; 5D4E30D11AA4B41A000D9904 /* WPStyleGuide+Pages.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D4E30D01AA4B41A000D9904 /* WPStyleGuide+Pages.m */; }; 5D51ADAF19A832AF00539C0B /* WordPress-20-21.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 5D51ADAE19A832AF00539C0B /* WordPress-20-21.xcmappingmodel */; }; - 5D577D33189127BE00B964C3 /* PostGeolocationViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D577D32189127BE00B964C3 /* PostGeolocationViewController.m */; }; - 5D577D361891360900B964C3 /* PostGeolocationView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D577D351891360900B964C3 /* PostGeolocationView.m */; }; 5D5A6E931B613CA400DAF819 /* ReaderPostCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D5A6E911B613CA400DAF819 /* ReaderPostCardCell.swift */; }; 5D5A6E941B613CA400DAF819 /* ReaderPostCardCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D5A6E921B613CA400DAF819 /* ReaderPostCardCell.xib */; }; - 5D5D0027187DA9D30027CEF6 /* PostCategoriesViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D5D0026187DA9D30027CEF6 /* PostCategoriesViewController.m */; }; 5D62BAD718AA88210044E5F7 /* PageSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D62BAD618AA88210044E5F7 /* PageSettingsViewController.m */; }; 5D69DBC4165428CA00A2D1F7 /* n.caf in Resources */ = {isa = PBXBuildFile; fileRef = 5D69DBC3165428CA00A2D1F7 /* n.caf */; }; 5D6C4AF61B603CA3005E3C43 /* WPTableViewActivityCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D6C4AF51B603CA3005E3C43 /* WPTableViewActivityCell.xib */; }; @@ -666,12 +1314,10 @@ 5D732F991AE84E5400CD89E7 /* PostListFooterView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D732F981AE84E5400CD89E7 /* PostListFooterView.xib */; }; 5D7DEA2919D488DD0032EE77 /* WPStyleGuide+ReaderComments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7DEA2819D488DD0032EE77 /* WPStyleGuide+ReaderComments.swift */; }; 5D839AA8187F0D6B00811F4A /* PostFeaturedImageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D839AA7187F0D6B00811F4A /* PostFeaturedImageCell.m */; }; - 5D839AAB187F0D8000811F4A /* PostGeolocationCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D839AAA187F0D8000811F4A /* PostGeolocationCell.m */; }; 5D8D53F119250412003C8859 /* BlogSelectorViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D8D53EE19250412003C8859 /* BlogSelectorViewController.m */; }; 5D97C2F315CAF8D8009B44DD /* UINavigationController+KeyboardFix.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D97C2F215CAF8D8009B44DD /* UINavigationController+KeyboardFix.m */; }; 5DA3EE161925090A00294E0B /* MediaService.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DA3EE151925090A00294E0B /* MediaService.m */; }; 5DA5BF4418E32DCF005F11F9 /* Theme.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DA5BF3418E32DCF005F11F9 /* Theme.m */; }; - 5DAE40AD19EC70930011A0AE /* ReaderPostHeaderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DAE40AC19EC70930011A0AE /* ReaderPostHeaderView.m */; }; 5DAFEAB81AF2CA6E00B3E1D7 /* PostMetaButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DAFEAB71AF2CA6E00B3E1D7 /* PostMetaButton.m */; }; 5DB3BA0518D0E7B600F3F3E9 /* WPPickerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DB3BA0418D0E7B600F3F3E9 /* WPPickerView.m */; }; 5DB4683B18A2E718004A89A9 /* LocationService.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DB4683A18A2E718004A89A9 /* LocationService.m */; }; @@ -685,10 +1331,7 @@ 5DFA7EC31AF7CB910072023B /* Pages.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5DFA7EC21AF7CB910072023B /* Pages.storyboard */; }; 5DFA7EC71AF814E40072023B /* PageListTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DFA7EC51AF814E40072023B /* PageListTableViewCell.m */; }; 5DFA7EC81AF814E40072023B /* PageListTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5DFA7EC61AF814E40072023B /* PageListTableViewCell.xib */; }; - 638E2F3D4DFE9E93AF660CED /* Pods_WordPressAllTimeWidget.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0075A07B739C4EDB5EC613B8 /* Pods_WordPressAllTimeWidget.framework */; }; - 660A036E6EE9D353F2712081 /* Pods_WordPressNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E2A297CCB53FCF6851D79331 /* Pods_WordPressNotificationServiceExtension.framework */; }; - 6867C0633E597372235CB5E0 /* Pods_WordPressDraftActionExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F7E3CC306AECBBCB71D2E19C /* Pods_WordPressDraftActionExtension.framework */; }; - 7059CD210F332B6500A0660B /* WPCategoryTree.m in Sources */ = {isa = PBXBuildFile; fileRef = 7059CD200F332B6500A0660B /* WPCategoryTree.m */; }; + 6E5BA46926A59D620043A6F2 /* SupportScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5BA46826A59D620043A6F2 /* SupportScreenTests.swift */; }; 730354BA21C867E500CD18C2 /* SiteCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 730354B921C867E500CD18C2 /* SiteCreatorTests.swift */; }; 7305138321C031FC006BD0A1 /* AssembledSiteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7305138221C031FC006BD0A1 /* AssembledSiteView.swift */; }; 730D290F22976F1A0004BB1E /* BottomScrollAnalyticsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 730D290E22976F1A0004BB1E /* BottomScrollAnalyticsTracker.swift */; }; @@ -702,7 +1345,6 @@ 731E88CA21C9A10B0055C014 /* ErrorStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731E88C721C9A10A0055C014 /* ErrorStateView.swift */; }; 731E88CB21C9A10B0055C014 /* ErrorStateViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731E88C821C9A10A0055C014 /* ErrorStateViewController.swift */; }; 7320C8BD2190C9FC0082FED5 /* UITextView+SummaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7320C8BC2190C9FC0082FED5 /* UITextView+SummaryTests.swift */; }; - 7326718B210F75D2001FA866 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 7326718D210F75D2001FA866 /* Localizable.strings */; }; 7326A4A8221C8F4100B4EB8C /* UIStackView+Subviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326A4A7221C8F4100B4EB8C /* UIStackView+Subviews.swift */; }; 732A473D218787500015DA74 /* WPRichTextFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 732A473C218787500015DA74 /* WPRichTextFormatterTests.swift */; }; 732A473F21878EB10015DA74 /* WPRichContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 732A473E21878EB10015DA74 /* WPRichContentViewTests.swift */; }; @@ -730,9 +1372,8 @@ 7335AC6D21220F0F0012EF2D /* FormattableUserContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFF208520EAD918009C4699 /* FormattableUserContent.swift */; }; 733F36042126197800988727 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E185474D1DED8D8800D875D7 /* UserNotifications.framework */; }; 733F36062126197800988727 /* UserNotificationsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 733F36052126197800988727 /* UserNotificationsUI.framework */; }; - 733F36102126197800988727 /* WordPressNotificationContentExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 733F36032126197800988727 /* WordPressNotificationContentExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7345EAC4212DD49400607EC9 /* CircularImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAAF4C1A11799A006D6306 /* CircularImageView.swift */; }; - 7358E6BF210BD318002323EB /* WordPressNotificationServiceExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7358E6B8210BD318002323EB /* WordPressNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 7358E6BF210BD318002323EB /* WordPressNotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7358E6B8210BD318002323EB /* WordPressNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 735A9681228E421F00461135 /* StatsBarChartConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 735A9680228E421F00461135 /* StatsBarChartConfiguration.swift */; }; 7360018F20A265C7001E5E31 /* String+Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7360018E20A265C7001E5E31 /* String+Files.swift */; }; 736584E6213752730029C9A4 /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; @@ -762,7 +1403,6 @@ 73B05D2921374F1E0073ECAA /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; 73B6693A21CAD960008456C3 /* ErrorStateViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73B6693921CAD960008456C3 /* ErrorStateViewTests.swift */; }; 73BFDA8A211D054800907245 /* Notifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73BFDA89211D054800907245 /* Notifiable.swift */; }; - 73C31907212F214200769485 /* Noticons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E6B42CBE1D9DA6270043E228 /* Noticons.ttf */; }; 73C8F06021BEED9100DDDF7E /* SiteAssemblyStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73C8F05F21BEED9100DDDF7E /* SiteAssemblyStep.swift */; }; 73C8F06221BEEEDE00DDDF7E /* SiteAssemblyWizardContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73C8F06121BEEEDE00DDDF7E /* SiteAssemblyWizardContent.swift */; }; 73C8F06321BEEF2F00DDDF7E /* SiteAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73178C3021BEE45300E37C9A /* SiteAssembly.swift */; }; @@ -772,10 +1412,7 @@ 73CB13972289BEFB00265F49 /* Charts+LargeValueFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73CB13962289BEFB00265F49 /* Charts+LargeValueFormatter.swift */; }; 73CE3E0E21F7F9D3007C9C85 /* TableViewOffsetCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73CE3E0D21F7F9D3007C9C85 /* TableViewOffsetCoordinator.swift */; }; 73D5AC63212622B200ADDDD2 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D5AC5C212622B200ADDDD2 /* NotificationViewController.swift */; }; - 73D5AC64212622B200ADDDD2 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 73D5AC5E212622B200ADDDD2 /* Localizable.strings */; }; 73D86969223AF4040064920F /* StatsChartLegendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D86968223AF4040064920F /* StatsChartLegendView.swift */; }; - 73E32389212CD8B5001B735C /* NSAttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54866C91A0D7042004AC79D /* NSAttributedString+Helpers.swift */; }; - 73E3238B212CE0D7001B735C /* Noticons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E6B42CBE1D9DA6270043E228 /* Noticons.ttf */; }; 73E40D8921238BF50012ABA6 /* Tracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA22821C99F6180016CA7C /* Tracks.swift */; }; 73E40D8C21238C520012ABA6 /* Tracks+ServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73E40D8B21238C520012ABA6 /* Tracks+ServiceExtension.swift */; }; 73E4E376227A033A0007D752 /* PostChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73E4E375227A033A0007D752 /* PostChart.swift */; }; @@ -842,7 +1479,7 @@ 74448F552044BC7600BD4CDA /* CategoryTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74448F532044BC7600BD4CDA /* CategoryTree.swift */; }; 74558369201A1FD3007809BB /* WPReusableTableViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74558368201A1FD3007809BB /* WPReusableTableViewCells.swift */; }; 7455836B201A216C007809BB /* WPReusableTableViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74558368201A1FD3007809BB /* WPReusableTableViewCells.swift */; }; - 7457667C202B558C00F42E40 /* WordPressDraftActionExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 74576672202B558C00F42E40 /* WordPressDraftActionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 7457667C202B558C00F42E40 /* WordPressDraftActionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 74576672202B558C00F42E40 /* WordPressDraftActionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 74585B991F0D58F300E7E667 /* DomainsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74585B981F0D58F300E7E667 /* DomainsServiceTests.swift */; }; 74585B9C1F0D591D00E7E667 /* domain-service-valid-domains.json in Resources */ = {isa = PBXBuildFile; fileRef = 74585B9A1F0D591D00E7E667 /* domain-service-valid-domains.json */; }; 74585B9D1F0D591D00E7E667 /* domain-service-all-domain-types.json in Resources */ = {isa = PBXBuildFile; fileRef = 74585B9B1F0D591D00E7E667 /* domain-service-all-domain-types.json */; }; @@ -870,7 +1507,6 @@ 748437F11F1D4ECC00E8DDAF /* notifications-last-seen.json in Resources */ = {isa = PBXBuildFile; fileRef = 748BD8881F1923D500813F9A /* notifications-last-seen.json */; }; 7484D94D20320DFE006E94B4 /* WordPressShare.js in Resources */ = {isa = PBXBuildFile; fileRef = E1AFA8C21E8E34230004A323 /* WordPressShare.js */; }; 748BD8851F19234300813F9A /* notifications-mark-as-read.json in Resources */ = {isa = PBXBuildFile; fileRef = 748BD8841F19234300813F9A /* notifications-mark-as-read.json */; }; - 749197EE209B9A2E006F5E66 /* ReaderCardContent+PostInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749197ED209B9A2E006F5E66 /* ReaderCardContent+PostInformation.swift */; }; 7492F78E1F9BD94500B5A04A /* ShareMediaFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7430C4481F97F23600E2673E /* ShareMediaFileManager.swift */; }; 74989B8C2088E3650054290B /* BlogDetailsViewController+Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74989B8B2088E3650054290B /* BlogDetailsViewController+Activity.swift */; }; 74AC1DA1200D0CC300973CAD /* UINavigationController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AC1DA0200D0CC300973CAD /* UINavigationController+Extensions.swift */; }; @@ -892,7 +1528,6 @@ 74E44AD52031E83C00556205 /* ExtensionNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74F89406202A1965008610FA /* ExtensionNotificationManager.swift */; }; 74E44AD62031E85A00556205 /* ShareNoticeConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74EA3B87202A0462004F802D /* ShareNoticeConstants.swift */; }; 74E44AD92031ED2300556205 /* WordPressDraft-Lumberjack.m in Sources */ = {isa = PBXBuildFile; fileRef = 74E44AD72031ED2300556205 /* WordPressDraft-Lumberjack.m */; }; - 74E44ADC2031EFD600556205 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 74E44ADE2031EFD600556205 /* Localizable.strings */; }; 74EA3B88202A0462004F802D /* ShareNoticeConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74EA3B87202A0462004F802D /* ShareNoticeConstants.swift */; }; 74EA3B89202A0462004F802D /* ShareNoticeConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74EA3B87202A0462004F802D /* ShareNoticeConstants.swift */; }; 74EFB5C8208674250070BD4E /* BlogListViewController+Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74EFB5C7208674250070BD4E /* BlogListViewController+Activity.swift */; }; @@ -903,6 +1538,7 @@ 74FA4BE51FBFA0660031EAAD /* Extensions.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 74FA4BE31FBFA0660031EAAD /* Extensions.xcdatamodeld */; }; 74FA4BE61FBFA0660031EAAD /* Extensions.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 74FA4BE31FBFA0660031EAAD /* Extensions.xcdatamodeld */; }; 74FA4BED1FBFA2350031EAAD /* SharedCoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 746D6B241FBF701F003C45BE /* SharedCoreDataStack.swift */; }; + 7D21280D251CF0850086DD2C /* EditPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D21280C251CF0850086DD2C /* EditPageViewController.swift */; }; 7E14635720B3BEAB00B95F41 /* WPStyleGuide+Loader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E14635620B3BEAB00B95F41 /* WPStyleGuide+Loader.swift */; }; 7E21C761202BBC8E00837CF5 /* iAd.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E21C760202BBC8D00837CF5 /* iAd.framework */; }; 7E21C765202BBF4400837CF5 /* SearchAdsAttribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E21C764202BBF4400837CF5 /* SearchAdsAttribution.swift */; }; @@ -961,7 +1597,6 @@ 7E58879A20FE8D9300DB6F80 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E58879920FE8D9300DB6F80 /* Environment.swift */; }; 7E5887A020FE956100DB6F80 /* AppRatingUtilityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E58879F20FE956100DB6F80 /* AppRatingUtilityType.swift */; }; 7E729C28209A087300F76599 /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E729C27209A087200F76599 /* ImageLoader.swift */; }; - 7E729C2A209A241100F76599 /* AbstractPost+PostInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E729C29209A241100F76599 /* AbstractPost+PostInformation.swift */; }; 7E7947A9210BAC1D005BB851 /* NotificationContentRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7947A8210BAC1D005BB851 /* NotificationContentRange.swift */; }; 7E7947AB210BAC5E005BB851 /* NotificationCommentRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7947AA210BAC5E005BB851 /* NotificationCommentRange.swift */; }; 7E7947AD210BAC7B005BB851 /* FormattableNoticonRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7947AC210BAC7B005BB851 /* FormattableNoticonRange.swift */; }; @@ -978,7 +1613,6 @@ 7E987F562108017B00CAFB88 /* NotificationContentRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E987F552108017B00CAFB88 /* NotificationContentRouter.swift */; }; 7E987F58210811CC00CAFB88 /* NotificationContentRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E987F57210811CC00CAFB88 /* NotificationContentRouterTests.swift */; }; 7E987F5A2108122A00CAFB88 /* NotificationUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E987F592108122A00CAFB88 /* NotificationUtility.swift */; }; - 7E9B90F821127CA400AF83E6 /* ContextManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E9B90F721127CA400AF83E6 /* ContextManagerType.swift */; }; 7EA30DB521ADA20F0092F894 /* EditorMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA30DB321ADA20F0092F894 /* EditorMediaUtility.swift */; }; 7EA30DB621ADA20F0092F894 /* AztecAttachmentDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA30DB421ADA20F0092F894 /* AztecAttachmentDelegate.swift */; }; 7EAA66EF22CB36FD00869038 /* TestAnalyticsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EAA66EE22CB36FD00869038 /* TestAnalyticsTracker.swift */; }; @@ -987,26 +1621,348 @@ 7EBB4126206C388100012D98 /* StockPhotosService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EBB4125206C388100012D98 /* StockPhotosService.swift */; }; 7EC9FE0B22C627DB00C5A888 /* PostEditorAnalyticsSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EC9FE0A22C627DB00C5A888 /* PostEditorAnalyticsSessionTests.swift */; }; 7ECD5B8120C4D823001AEBC5 /* MediaPreviewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ECD5B8020C4D823001AEBC5 /* MediaPreviewHelper.swift */; }; - 7ED3695520A9F091007B0D56 /* Blog+ImageSourceInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ED3695420A9F091007B0D56 /* Blog+ImageSourceInformation.swift */; }; 7EDAB3F420B046FE002D1A76 /* CircularProgressView+ActivityIndicatorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EDAB3F320B046FE002D1A76 /* CircularProgressView+ActivityIndicatorType.swift */; }; 7EF2EEA0210A67B60007A76B /* notifications-unapproved-comment.json in Resources */ = {isa = PBXBuildFile; fileRef = 7EF2EE9F210A67B60007A76B /* notifications-unapproved-comment.json */; }; - 7EF9F65722F03C9200F79BBF /* SiteSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF9F65622F03C9200F79BBF /* SiteSettingsScreen.swift */; }; 7EFF208620EAD918009C4699 /* FormattableUserContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFF208520EAD918009C4699 /* FormattableUserContent.swift */; }; 7EFF208A20EADCB6009C4699 /* NotificationTextContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFF208920EADCB6009C4699 /* NotificationTextContent.swift */; }; 7EFF208C20EADF68009C4699 /* FormattableCommentContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFF208B20EADF68009C4699 /* FormattableCommentContent.swift */; }; + 800035BD291DD0D7007D2D26 /* JetpackFullscreenOverlayGeneralViewModel+Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035BC291DD0D7007D2D26 /* JetpackFullscreenOverlayGeneralViewModel+Analytics.swift */; }; + 800035BE291DD0D7007D2D26 /* JetpackFullscreenOverlayGeneralViewModel+Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035BC291DD0D7007D2D26 /* JetpackFullscreenOverlayGeneralViewModel+Analytics.swift */; }; + 800035C1292307E8007D2D26 /* ExtensionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035C0292307E8007D2D26 /* ExtensionConfiguration.swift */; }; + 800035C329230A0B007D2D26 /* ExtensionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035C229230A0B007D2D26 /* ExtensionConfiguration.swift */; }; + 8000361D292468D4007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8000361C292468D4007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel.swift */; }; + 8000361E292468D4007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8000361C292468D4007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel.swift */; }; + 8000362029246956007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8000361F29246956007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift */; }; + 8000362129246956007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8000361F29246956007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift */; }; + 801D94EF2919E7D70051993E /* JetpackFullscreenOverlayGeneralViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D94EE2919E7D70051993E /* JetpackFullscreenOverlayGeneralViewModel.swift */; }; + 801D94F02919E7D70051993E /* JetpackFullscreenOverlayGeneralViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D94EE2919E7D70051993E /* JetpackFullscreenOverlayGeneralViewModel.swift */; }; + 801D950D291AB3CF0051993E /* JetpackReaderLogoAnimation_ltr.json in Resources */ = {isa = PBXBuildFile; fileRef = 801D9507291AB3CD0051993E /* JetpackReaderLogoAnimation_ltr.json */; }; + 801D950F291AB3CF0051993E /* JetpackReaderLogoAnimation_rtl.json in Resources */ = {isa = PBXBuildFile; fileRef = 801D9508291AB3CD0051993E /* JetpackReaderLogoAnimation_rtl.json */; }; + 801D9511291AB3CF0051993E /* JetpackStatsLogoAnimation_ltr.json in Resources */ = {isa = PBXBuildFile; fileRef = 801D9509291AB3CE0051993E /* JetpackStatsLogoAnimation_ltr.json */; }; + 801D9513291AB3CF0051993E /* JetpackStatsLogoAnimation_rtl.json in Resources */ = {isa = PBXBuildFile; fileRef = 801D950A291AB3CE0051993E /* JetpackStatsLogoAnimation_rtl.json */; }; + 801D9515291AB3CF0051993E /* JetpackNotificationsLogoAnimation_rtl.json in Resources */ = {isa = PBXBuildFile; fileRef = 801D950B291AB3CE0051993E /* JetpackNotificationsLogoAnimation_rtl.json */; }; + 801D9517291AB3CF0051993E /* JetpackNotificationsLogoAnimation_ltr.json in Resources */ = {isa = PBXBuildFile; fileRef = 801D950C291AB3CF0051993E /* JetpackNotificationsLogoAnimation_ltr.json */; }; + 801D951A291AC0B00051993E /* OverlayFrequencyTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D9519291AC0B00051993E /* OverlayFrequencyTracker.swift */; }; + 801D951B291AC0B00051993E /* OverlayFrequencyTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D9519291AC0B00051993E /* OverlayFrequencyTracker.swift */; }; + 801D951D291ADB7E0051993E /* OverlayFrequencyTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D951C291ADB7E0051993E /* OverlayFrequencyTrackerTests.swift */; }; + 8031F346292FF46100E8F95E /* ExtensionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035C229230A0B007D2D26 /* ExtensionConfiguration.swift */; }; + 8031F347292FF46200E8F95E /* ExtensionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035C229230A0B007D2D26 /* ExtensionConfiguration.swift */; }; + 8031F348292FF46400E8F95E /* ExtensionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035C229230A0B007D2D26 /* ExtensionConfiguration.swift */; }; + 8031F349292FF46A00E8F95E /* ExtensionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035C0292307E8007D2D26 /* ExtensionConfiguration.swift */; }; + 8031F34A292FF46B00E8F95E /* ExtensionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035C0292307E8007D2D26 /* ExtensionConfiguration.swift */; }; + 8031F34B292FF46E00E8F95E /* ExtensionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035C0292307E8007D2D26 /* ExtensionConfiguration.swift */; }; + 8031F34C29302A2500E8F95E /* ExtensionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035C229230A0B007D2D26 /* ExtensionConfiguration.swift */; }; + 8031F34D29302C8100E8F95E /* ExtensionConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800035C0292307E8007D2D26 /* ExtensionConfiguration.swift */; }; + 803BB9792959543D00B3F6D6 /* RootViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB9782959543D00B3F6D6 /* RootViewCoordinator.swift */; }; + 803BB97A2959543D00B3F6D6 /* RootViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB9782959543D00B3F6D6 /* RootViewCoordinator.swift */; }; + 803BB97C2959559500B3F6D6 /* RootViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB97B2959559500B3F6D6 /* RootViewPresenter.swift */; }; + 803BB97D2959559500B3F6D6 /* RootViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB97B2959559500B3F6D6 /* RootViewPresenter.swift */; }; + 803BB980295957CF00B3F6D6 /* WPTabBarController+RootViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB97F295957CF00B3F6D6 /* WPTabBarController+RootViewPresenter.swift */; }; + 803BB981295957CF00B3F6D6 /* WPTabBarController+RootViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB97F295957CF00B3F6D6 /* WPTabBarController+RootViewPresenter.swift */; }; + 803BB983295957F600B3F6D6 /* MySitesCoordinator+RootViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB982295957F600B3F6D6 /* MySitesCoordinator+RootViewPresenter.swift */; }; + 803BB984295957F600B3F6D6 /* MySitesCoordinator+RootViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB982295957F600B3F6D6 /* MySitesCoordinator+RootViewPresenter.swift */; }; + 803BB986295A873800B3F6D6 /* RootViewPresenter+MeNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB985295A873800B3F6D6 /* RootViewPresenter+MeNavigation.swift */; }; + 803BB987295A873800B3F6D6 /* RootViewPresenter+MeNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB985295A873800B3F6D6 /* RootViewPresenter+MeNavigation.swift */; }; + 803BB989295B80D300B3F6D6 /* RootViewPresenter+EditorNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB988295B80D300B3F6D6 /* RootViewPresenter+EditorNavigation.swift */; }; + 803BB98A295B80D300B3F6D6 /* RootViewPresenter+EditorNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB988295B80D300B3F6D6 /* RootViewPresenter+EditorNavigation.swift */; }; + 803BB98C29637AFC00B3F6D6 /* BlurredEmptyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB98B29637AFC00B3F6D6 /* BlurredEmptyViewController.swift */; }; + 803BB98D29637AFC00B3F6D6 /* BlurredEmptyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB98B29637AFC00B3F6D6 /* BlurredEmptyViewController.swift */; }; + 803BB98F29667BAF00B3F6D6 /* JetpackBrandingTextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB98E29667BAF00B3F6D6 /* JetpackBrandingTextProvider.swift */; }; + 803BB99029667BAF00B3F6D6 /* JetpackBrandingTextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB98E29667BAF00B3F6D6 /* JetpackBrandingTextProvider.swift */; }; + 803BB99429667CF700B3F6D6 /* JetpackBrandingTextProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803BB99329667CF700B3F6D6 /* JetpackBrandingTextProviderTests.swift */; }; + 803C493B283A7C0C00003E9B /* QuickStartChecklistHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803C493A283A7C0C00003E9B /* QuickStartChecklistHeader.swift */; }; + 803C493C283A7C0C00003E9B /* QuickStartChecklistHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803C493A283A7C0C00003E9B /* QuickStartChecklistHeader.swift */; }; + 803C493E283A7C2200003E9B /* QuickStartChecklistHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = 803C493D283A7C2200003E9B /* QuickStartChecklistHeader.xib */; }; + 803C493F283A7C2200003E9B /* QuickStartChecklistHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = 803C493D283A7C2200003E9B /* QuickStartChecklistHeader.xib */; }; + 803D90F7292F0188007CC0D0 /* JetpackRedirector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803D90F6292F0188007CC0D0 /* JetpackRedirector.swift */; }; + 803D90F8292F0188007CC0D0 /* JetpackRedirector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803D90F6292F0188007CC0D0 /* JetpackRedirector.swift */; }; + 803DE81328FFAE36007D4E9C /* RemoteConfigStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803DE81228FFAE36007D4E9C /* RemoteConfigStore.swift */; }; + 803DE81428FFAE36007D4E9C /* RemoteConfigStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803DE81228FFAE36007D4E9C /* RemoteConfigStore.swift */; }; + 803DE81628FFAEF2007D4E9C /* RemoteConfigParameter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803DE81528FFAEF2007D4E9C /* RemoteConfigParameter.swift */; }; + 803DE81728FFAEF2007D4E9C /* RemoteConfigParameter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803DE81528FFAEF2007D4E9C /* RemoteConfigParameter.swift */; }; + 803DE81928FFB7B5007D4E9C /* RemoteParameterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803DE81828FFB7B5007D4E9C /* RemoteParameterTests.swift */; }; + 803DE81F290636A4007D4E9C /* JetpackFeaturesRemovalCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803DE81E290636A4007D4E9C /* JetpackFeaturesRemovalCoordinatorTests.swift */; }; + 803DE821290642B4007D4E9C /* JetpackFeaturesRemovalCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803DE820290642B4007D4E9C /* JetpackFeaturesRemovalCoordinator.swift */; }; + 803DE822290642B4007D4E9C /* JetpackFeaturesRemovalCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 803DE820290642B4007D4E9C /* JetpackFeaturesRemovalCoordinator.swift */; }; + 80535DB82946C79700873161 /* JetpackBrandingMenuCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80535DB72946C79700873161 /* JetpackBrandingMenuCardCell.swift */; }; + 80535DBB294ABBF000873161 /* JetpackAllFeaturesLogosAnimation_rtl.json in Resources */ = {isa = PBXBuildFile; fileRef = 80535DB9294ABBEF00873161 /* JetpackAllFeaturesLogosAnimation_rtl.json */; }; + 80535DBC294ABBF000873161 /* JetpackAllFeaturesLogosAnimation_ltr.json in Resources */ = {isa = PBXBuildFile; fileRef = 80535DBA294ABBEF00873161 /* JetpackAllFeaturesLogosAnimation_ltr.json */; }; + 80535DBE294AC89200873161 /* JetpackBrandingMenuCardPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80535DBD294AC89200873161 /* JetpackBrandingMenuCardPresenter.swift */; }; + 80535DC0294B7D3200873161 /* BlogDetailsViewController+JetpackBrandingMenuCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80535DBF294B7D3200873161 /* BlogDetailsViewController+JetpackBrandingMenuCard.swift */; }; + 80535DC1294BDE1900873161 /* JetpackBrandingMenuCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80535DB72946C79700873161 /* JetpackBrandingMenuCardCell.swift */; }; + 80535DC2294BDE2500873161 /* JetpackBrandingMenuCardPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80535DBD294AC89200873161 /* JetpackBrandingMenuCardPresenter.swift */; }; + 80535DC3294BDE2B00873161 /* BlogDetailsViewController+JetpackBrandingMenuCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80535DBF294B7D3200873161 /* BlogDetailsViewController+JetpackBrandingMenuCard.swift */; }; + 80535DC5294BF4BE00873161 /* JetpackBrandingMenuCardPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80535DC4294BF4BE00873161 /* JetpackBrandingMenuCardPresenterTests.swift */; }; + 8058730B28F7B70B00340C11 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8058730D28F7B70B00340C11 /* InfoPlist.strings */; }; + 805CC0B7296680CF002941DC /* RemoteFeatureFlagStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805CC0B6296680CF002941DC /* RemoteFeatureFlagStoreMock.swift */; }; + 805CC0B9296680F7002941DC /* RemoteConfigStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805CC0B8296680F7002941DC /* RemoteConfigStoreMock.swift */; }; + 805CC0BC29668986002941DC /* JetpackBrandedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805CC0BA29668918002941DC /* JetpackBrandedScreen.swift */; }; + 805CC0BD29668987002941DC /* JetpackBrandedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805CC0BA29668918002941DC /* JetpackBrandedScreen.swift */; }; + 805CC0BF29668A97002941DC /* MockCurrentDateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805CC0BE29668A97002941DC /* MockCurrentDateProvider.swift */; }; + 805CC0C1296CF3B3002941DC /* JetpackNewUsersOverlaySecondaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805CC0C0296CF3B3002941DC /* JetpackNewUsersOverlaySecondaryView.swift */; }; + 805CC0C2296CF3B3002941DC /* JetpackNewUsersOverlaySecondaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 805CC0C0296CF3B3002941DC /* JetpackNewUsersOverlaySecondaryView.swift */; }; + 8067340A27E3A50900ABC95E /* UIViewController+RemoveQuickStart.m in Sources */ = {isa = PBXBuildFile; fileRef = 8067340927E3A50900ABC95E /* UIViewController+RemoveQuickStart.m */; }; + 8067340B27E3A50900ABC95E /* UIViewController+RemoveQuickStart.m in Sources */ = {isa = PBXBuildFile; fileRef = 8067340927E3A50900ABC95E /* UIViewController+RemoveQuickStart.m */; }; + 806E53E127E01C7F0064315E /* DashboardStatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806E53E027E01C7F0064315E /* DashboardStatsViewModel.swift */; }; + 806E53E227E01C7F0064315E /* DashboardStatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806E53E027E01C7F0064315E /* DashboardStatsViewModel.swift */; }; + 806E53E427E01CFE0064315E /* DashboardStatsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806E53E327E01CFE0064315E /* DashboardStatsViewModelTests.swift */; }; + 8070EB3E28D807CB005C6513 /* InMemoryUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8070EB3D28D807CB005C6513 /* InMemoryUserDefaults.swift */; }; + 8071390727D039E70012DB21 /* DashboardSingleStatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8071390627D039E70012DB21 /* DashboardSingleStatView.swift */; }; + 8071390827D039E70012DB21 /* DashboardSingleStatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8071390627D039E70012DB21 /* DashboardSingleStatView.swift */; }; + 808C578F27C7FB1A0099A92C /* ButtonScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808C578E27C7FB1A0099A92C /* ButtonScrollView.swift */; }; + 808C579027C7FB1A0099A92C /* ButtonScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 808C578E27C7FB1A0099A92C /* ButtonScrollView.swift */; }; + 8091019329078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8091019129078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.swift */; }; + 8091019429078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8091019129078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.swift */; }; + 8091019529078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8091019229078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.xib */; }; + 8091019629078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8091019229078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.xib */; }; + 809101982908DE8500FCB4EA /* JetpackFullscreenOverlayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809101972908DE8500FCB4EA /* JetpackFullscreenOverlayViewModel.swift */; }; + 809101992908DE8500FCB4EA /* JetpackFullscreenOverlayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809101972908DE8500FCB4EA /* JetpackFullscreenOverlayViewModel.swift */; }; + 809620D228E540D700940A5D /* UploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741E22441FC0CC55007967AB /* UploadOperation.swift */; }; + 809620D328E540D700940A5D /* TextBundleWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */; }; + 809620D428E540D700940A5D /* ShareNoticeConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74EA3B87202A0462004F802D /* ShareNoticeConstants.swift */; }; + 809620D528E540D700940A5D /* WPStyleGuide+Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50248AE1C96FF6200AFBDED /* WPStyleGuide+Share.swift */; }; + 809620D628E540D700940A5D /* ShareExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CE41641E8D101A000CF5A4 /* ShareExtractor.swift */; }; + 809620D728E540D700940A5D /* ShareExtensionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930F09161C7D110E00995926 /* ShareExtensionService.swift */; }; + 809620D928E540D700940A5D /* ShareTagsPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747F88C0203778E000523C7C /* ShareTagsPickerViewController.swift */; }; + 809620DA28E540D700940A5D /* ShareCategoriesPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7414A140203CBADF005A7D9B /* ShareCategoriesPickerViewController.swift */; }; + 809620DB28E540D700940A5D /* WPStyleGuide+ApplicationStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = E678FC141C76241000F55F55 /* WPStyleGuide+ApplicationStyles.swift */; }; + 809620DC28E540D700940A5D /* ShareExtensionEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74F5CD391FE0653500764E7C /* ShareExtensionEditorViewController.swift */; }; + 809620DD28E540D700940A5D /* UIImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA868A1D10A41600AB5F7E /* UIImage+Extensions.swift */; }; + 809620DE28E540D700940A5D /* WordPressShare-Lumberjack.m in Sources */ = {isa = PBXBuildFile; fileRef = B50248BC1C96FFCC00AFBDED /* WordPressShare-Lumberjack.m */; }; + 809620DF28E540D700940A5D /* Extensions.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 74FA4BE31FBFA0660031EAAD /* Extensions.xcdatamodeld */; }; + 809620E028E540D700940A5D /* UIColor+WordPressColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD256922611B01700EDAF88 /* UIColor+WordPressColors.swift */; }; + 809620E128E540D700940A5D /* TextList+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C611EF42AF200372C65 /* TextList+WordPress.swift */; }; + 809620E228E540D700940A5D /* AppExtensionsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FA2EE3200E8A6C001DDC13 /* AppExtensionsService.swift */; }; + 809620E328E540D700940A5D /* ExtensionNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74F89406202A1965008610FA /* ExtensionNotificationManager.swift */; }; + 809620E428E540D700940A5D /* NoResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982A4C3420227D6700B5518E /* NoResultsViewController.swift */; }; + 809620E528E540D700940A5D /* NSAttributedStringKey+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56F25871FBDE502005C33E4 /* NSAttributedStringKey+Conversion.swift */; }; + 809620E628E540D700940A5D /* SharePost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E125F1E31E8E595E00320B67 /* SharePost.swift */; }; + 809620E728E540D700940A5D /* ShareData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 745EAF4920040B220066F415 /* ShareData.swift */; }; + 809620E828E540D700940A5D /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690141F828FF000200E30 /* BuildConfiguration.swift */; }; + 809620E928E540D700940A5D /* TableViewKeyboardObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15632CB1EB9ECF40035A099 /* TableViewKeyboardObserver.swift */; }; + 809620EA28E540D700940A5D /* ExtensionTransitioningManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74402F2B2005337D00A1D4A2 /* ExtensionTransitioningManager.swift */; }; + 809620EB28E540D700940A5D /* MainShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74337EDC20054D5500777997 /* MainShareViewController.swift */; }; + 809620EC28E540D700940A5D /* SharePostTypePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB48172926E0D93D008C2D9B /* SharePostTypePickerViewController.swift */; }; + 809620ED28E540D700940A5D /* AppLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */; }; + 809620EE28E540D700940A5D /* WPStyleGuide+Gridicon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4020B2BC2007AC850002C963 /* WPStyleGuide+Gridicon.swift */; }; + 809620EF28E540D700940A5D /* ExtensionPresentationAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74402F2D2005344700A1D4A2 /* ExtensionPresentationAnimator.swift */; }; + 809620F028E540D700940A5D /* String+RegEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54C02231F38F50100574572 /* String+RegEx.swift */; }; + 809620F128E540D700940A5D /* AppStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD2538E26116A1600EDAF88 /* AppStyleGuide.swift */; }; + 809620F228E540D700940A5D /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; + 809620F328E540D700940A5D /* SharedCoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 746D6B241FBF701F003C45BE /* SharedCoreDataStack.swift */; }; + 809620F428E540D700940A5D /* ShareExtensionAbstractViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 745EAF462003FDAA0066F415 /* ShareExtensionAbstractViewController.swift */; }; + 809620F528E540D700940A5D /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; + 809620F628E540D700940A5D /* RemotePost+ShareData.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF6201226E8FB520061A1F8 /* RemotePost+ShareData.swift */; }; + 809620F728E540D700940A5D /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64028AAC82600EABE26 /* KeychainUtils.swift */; }; + 809620F828E540D700940A5D /* CategoryTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74448F532044BC7600BD4CDA /* CategoryTree.swift */; }; + 809620F928E540D700940A5D /* ExtensionPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74402F29200528F200A1D4A2 /* ExtensionPresentationController.swift */; }; + 809620FA28E540D700940A5D /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; + 809620FB28E540D700940A5D /* RemoteBlog+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE39E17120CB117B00CABA05 /* RemoteBlog+Capabilities.swift */; }; + 809620FC28E540D700940A5D /* ShareModularViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6787F41FFF2886005D9F01 /* ShareModularViewController.swift */; }; + 809620FD28E540D700940A5D /* NSExtensionContext+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5552D7D1CD101A600B26DF6 /* NSExtensionContext+Extensions.swift */; }; + 809620FE28E540D700940A5D /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5552D7F1CD1028C00B26DF6 /* String+Extensions.swift */; }; + 809620FF28E540D700940A5D /* LightNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59AAC15235EA46D00385EE6 /* LightNavigationController.swift */; }; + 8096210028E540D700940A5D /* UINavigationController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AC1DA0200D0CC300973CAD /* UINavigationController+Extensions.swift */; }; + 8096210128E540D700940A5D /* PostUploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AF4D711FE417D200E3EBFE /* PostUploadOperation.swift */; }; + 8096210228E540D700940A5D /* Header+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C631EF42B3A00372C65 /* Header+WordPress.swift */; }; + 8096210328E540D700940A5D /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; + 8096210428E540D700940A5D /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; + 8096210528E540D700940A5D /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690151F828FF000200E30 /* FeatureFlag.swift */; }; + 8096210628E540D700940A5D /* Tracks+ShareExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B504F5F41C9C2BD000F8B1C6 /* Tracks+ShareExtension.swift */; }; + 8096210728E540D700940A5D /* FormatBarItemProviders.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C651EF42B6400372C65 /* FormatBarItemProviders.swift */; }; + 8096210828E540D700940A5D /* ShareSegueHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 745EAF442003FD050066F415 /* ShareSegueHandler.swift */; }; + 8096210928E540D700940A5D /* RemotePostCategory+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BC35B720499EEB00AC1525 /* RemotePostCategory+Extensions.swift */; }; + 8096210A28E540D700940A5D /* WPReusableTableViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74558368201A1FD3007809BB /* WPReusableTableViewCells.swift */; }; + 8096210B28E540D700940A5D /* WPAnimatedBox.m in Sources */ = {isa = PBXBuildFile; fileRef = E240859B183D82AE002EB0EF /* WPAnimatedBox.m */; }; + 8096210C28E540D700940A5D /* String+Ranges.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55FFCF91F034F1A0070812C /* String+Ranges.swift */; }; + 8096210D28E540D700940A5D /* ImgUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126FDFE20A33BDB0010EB6E /* ImgUploadProcessor.swift */; }; + 8096210E28E540D700940A5D /* UIApplication+mainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3626F825A665E500D7CCE3 /* UIApplication+mainWindow.swift */; }; + 8096210F28E540D700940A5D /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; + 8096211028E540D700940A5D /* MediaUploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AF4D6D1FE417D200E3EBFE /* MediaUploadOperation.swift */; }; + 8096211128E540D700940A5D /* UIColor+MurielColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435B762122973D0600511813 /* UIColor+MurielColors.swift */; }; + 8096211228E540D700940A5D /* ShareMediaFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7430C4481F97F23600E2673E /* ShareMediaFileManager.swift */; }; + 8096211328E540D700940A5D /* Tracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA22821C99F6180016CA7C /* Tracks.swift */; }; + 8096211728E540D700940A5D /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 740C7C4E202F4CD6001C31B0 /* MainInterface.storyboard */; }; + 8096211828E540D700940A5D /* WordPressShare.js in Resources */ = {isa = PBXBuildFile; fileRef = E1AFA8C21E8E34230004A323 /* WordPressShare.js */; }; + 8096211928E540D700940A5D /* ShareExtension.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 74F5CD371FE0646F00764E7C /* ShareExtension.storyboard */; }; + 8096211A28E540D700940A5D /* NoResults.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 98B33C87202283860071E1E2 /* NoResults.storyboard */; }; + 8096212528E5411400940A5D /* JetpackShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8096212328E540D700940A5D /* JetpackShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 8096212F28E555F100940A5D /* ColorPalette.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 435B76292297484200511813 /* ColorPalette.xcassets */; }; + 8096213328E55C9400940A5D /* TextList+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C611EF42AF200372C65 /* TextList+WordPress.swift */; }; + 8096213428E55C9400940A5D /* TextBundleWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */; }; + 8096213528E55C9400940A5D /* UINavigationController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AC1DA0200D0CC300973CAD /* UINavigationController+Extensions.swift */; }; + 8096213628E55C9400940A5D /* ShareExtensionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930F09161C7D110E00995926 /* ShareExtensionService.swift */; }; + 8096213728E55C9400940A5D /* ShareTagsPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 747F88C0203778E000523C7C /* ShareTagsPickerViewController.swift */; }; + 8096213828E55C9400940A5D /* ShareCategoriesPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7414A140203CBADF005A7D9B /* ShareCategoriesPickerViewController.swift */; }; + 8096213A28E55C9400940A5D /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; + 8096213B28E55C9400940A5D /* UploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741E22441FC0CC55007967AB /* UploadOperation.swift */; }; + 8096213C28E55C9400940A5D /* WPStyleGuide+ApplicationStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = E678FC141C76241000F55F55 /* WPStyleGuide+ApplicationStyles.swift */; }; + 8096213D28E55C9400940A5D /* NSAttributedStringKey+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56F25871FBDE502005C33E4 /* NSAttributedStringKey+Conversion.swift */; }; + 8096213E28E55C9400940A5D /* NSExtensionContext+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5552D7D1CD101A600B26DF6 /* NSExtensionContext+Extensions.swift */; }; + 8096213F28E55C9400940A5D /* Header+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C631EF42B3A00372C65 /* Header+WordPress.swift */; }; + 8096214028E55C9400940A5D /* Extensions.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 74FA4BE31FBFA0660031EAAD /* Extensions.xcdatamodeld */; }; + 8096214128E55C9400940A5D /* UIColor+WordPressColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD256922611B01700EDAF88 /* UIColor+WordPressColors.swift */; }; + 8096214228E55C9400940A5D /* PostUploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AF4D711FE417D200E3EBFE /* PostUploadOperation.swift */; }; + 8096214328E55C9400940A5D /* NoResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982A4C3420227D6700B5518E /* NoResultsViewController.swift */; }; + 8096214428E55C9400940A5D /* WPStyleGuide+Gridicon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4020B2BC2007AC850002C963 /* WPStyleGuide+Gridicon.swift */; }; + 8096214528E55C9400940A5D /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5552D7F1CD1028C00B26DF6 /* String+Extensions.swift */; }; + 8096214628E55C9400940A5D /* TableViewKeyboardObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15632CB1EB9ECF40035A099 /* TableViewKeyboardObserver.swift */; }; + 8096214728E55C9400940A5D /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690141F828FF000200E30 /* BuildConfiguration.swift */; }; + 8096214828E55C9400940A5D /* ShareExtensionAbstractViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 745EAF462003FDAA0066F415 /* ShareExtensionAbstractViewController.swift */; }; + 8096214928E55C9400940A5D /* ExtensionPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74402F29200528F200A1D4A2 /* ExtensionPresentationController.swift */; }; + 8096214A28E55C9400940A5D /* SharePost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E125F1E31E8E595E00320B67 /* SharePost.swift */; }; + 8096214B28E55C9400940A5D /* UIImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA868A1D10A41600AB5F7E /* UIImage+Extensions.swift */; }; + 8096214C28E55C9400940A5D /* ShareMediaFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7430C4481F97F23600E2673E /* ShareMediaFileManager.swift */; }; + 8096214D28E55C9400940A5D /* SharePostTypePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB48172926E0D93D008C2D9B /* SharePostTypePickerViewController.swift */; }; + 8096214E28E55C9400940A5D /* AppLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */; }; + 8096214F28E55C9400940A5D /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; + 8096215028E55C9400940A5D /* ExtensionNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74F89406202A1965008610FA /* ExtensionNotificationManager.swift */; }; + 8096215128E55C9400940A5D /* CategoryTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74448F532044BC7600BD4CDA /* CategoryTree.swift */; }; + 8096215228E55C9400940A5D /* AppStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD2538E26116A1600EDAF88 /* AppStyleGuide.swift */; }; + 8096215328E55C9400940A5D /* ShareSegueHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 745EAF442003FD050066F415 /* ShareSegueHandler.swift */; }; + 8096215428E55C9400940A5D /* SharedCoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 746D6B241FBF701F003C45BE /* SharedCoreDataStack.swift */; }; + 8096215528E55C9400940A5D /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; + 8096215628E55C9400940A5D /* ShareExtensionEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74F5CD391FE0653500764E7C /* ShareExtensionEditorViewController.swift */; }; + 8096215728E55C9400940A5D /* RemotePost+ShareData.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF6201226E8FB520061A1F8 /* RemotePost+ShareData.swift */; }; + 8096215828E55C9400940A5D /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64028AAC82600EABE26 /* KeychainUtils.swift */; }; + 8096215928E55C9400940A5D /* RemoteBlog+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE39E17120CB117B00CABA05 /* RemoteBlog+Capabilities.swift */; }; + 8096215A28E55C9400940A5D /* ExtensionTransitioningManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74402F2B2005337D00A1D4A2 /* ExtensionTransitioningManager.swift */; }; + 8096215B28E55C9400940A5D /* WordPressDraft-Lumberjack.m in Sources */ = {isa = PBXBuildFile; fileRef = 74E44AD72031ED2300556205 /* WordPressDraft-Lumberjack.m */; }; + 8096215C28E55C9400940A5D /* Tracks+DraftAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741AF3A4202F3E2A00C771A5 /* Tracks+DraftAction.swift */; }; + 8096215D28E55C9400940A5D /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; + 8096215E28E55C9400940A5D /* WPStyleGuide+Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50248AE1C96FF6200AFBDED /* WPStyleGuide+Share.swift */; }; + 8096215F28E55C9400940A5D /* LightNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59AAC15235EA46D00385EE6 /* LightNavigationController.swift */; }; + 8096216028E55C9400940A5D /* String+RegEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54C02231F38F50100574572 /* String+RegEx.swift */; }; + 8096216128E55C9400940A5D /* ShareData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 745EAF4920040B220066F415 /* ShareData.swift */; }; + 8096216228E55C9400940A5D /* WPReusableTableViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74558368201A1FD3007809BB /* WPReusableTableViewCells.swift */; }; + 8096216328E55C9400940A5D /* MainShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74337EDC20054D5500777997 /* MainShareViewController.swift */; }; + 8096216428E55C9400940A5D /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; + 8096216528E55C9400940A5D /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690151F828FF000200E30 /* FeatureFlag.swift */; }; + 8096216628E55C9400940A5D /* ShareModularViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6787F41FFF2886005D9F01 /* ShareModularViewController.swift */; }; + 8096216728E55C9400940A5D /* MediaUploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AF4D6D1FE417D200E3EBFE /* MediaUploadOperation.swift */; }; + 8096216828E55C9400940A5D /* Tracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA22821C99F6180016CA7C /* Tracks.swift */; }; + 8096216928E55C9400940A5D /* RemotePostCategory+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BC35B720499EEB00AC1525 /* RemotePostCategory+Extensions.swift */; }; + 8096216A28E55C9400940A5D /* ShareNoticeConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74EA3B87202A0462004F802D /* ShareNoticeConstants.swift */; }; + 8096216B28E55C9400940A5D /* WPAnimatedBox.m in Sources */ = {isa = PBXBuildFile; fileRef = E240859B183D82AE002EB0EF /* WPAnimatedBox.m */; }; + 8096216C28E55C9400940A5D /* String+Ranges.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55FFCF91F034F1A0070812C /* String+Ranges.swift */; }; + 8096216D28E55C9400940A5D /* ImgUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126FDFE20A33BDB0010EB6E /* ImgUploadProcessor.swift */; }; + 8096216E28E55C9400940A5D /* ShareExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CE41641E8D101A000CF5A4 /* ShareExtractor.swift */; }; + 8096216F28E55C9400940A5D /* UIApplication+mainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3626F825A665E500D7CCE3 /* UIApplication+mainWindow.swift */; }; + 8096217028E55C9400940A5D /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; + 8096217128E55C9400940A5D /* FormatBarItemProviders.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C651EF42B6400372C65 /* FormatBarItemProviders.swift */; }; + 8096217228E55C9400940A5D /* UIColor+MurielColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435B762122973D0600511813 /* UIColor+MurielColors.swift */; }; + 8096217328E55C9400940A5D /* AppExtensionsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FA2EE3200E8A6C001DDC13 /* AppExtensionsService.swift */; }; + 8096217428E55C9400940A5D /* ExtensionPresentationAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74402F2D2005344700A1D4A2 /* ExtensionPresentationAnimator.swift */; }; + 8096217828E55C9400940A5D /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 740C7C4E202F4CD6001C31B0 /* MainInterface.storyboard */; }; + 8096217A28E55C9400940A5D /* WordPressShare.js in Resources */ = {isa = PBXBuildFile; fileRef = E1AFA8C21E8E34230004A323 /* WordPressShare.js */; }; + 8096217B28E55C9400940A5D /* ShareExtension.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 74F5CD371FE0646F00764E7C /* ShareExtension.storyboard */; }; + 8096217C28E55C9400940A5D /* NoResults.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 98B33C87202283860071E1E2 /* NoResults.storyboard */; }; + 8096217D28E55C9400940A5D /* AppImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 433840C622C2BA5B00CB13F8 /* AppImages.xcassets */; }; + 8096217E28E55C9400940A5D /* ColorPalette.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 435B76292297484200511813 /* ColorPalette.xcassets */; }; + 8096218E28E55F8600940A5D /* JetpackDraftActionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8096218528E55C9400940A5D /* JetpackDraftActionExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 8096219328E5613700940A5D /* AppImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 433840C622C2BA5B00CB13F8 /* AppImages.xcassets */; }; + 8096219428E561A800940A5D /* AppImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FABB286B2603086900C8785C /* AppImages.xcassets */; }; + 80A2153D29C35197002FE8EB /* StaticScreensTabBarWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A2153C29C35197002FE8EB /* StaticScreensTabBarWrapper.swift */; }; + 80A2153E29C35197002FE8EB /* StaticScreensTabBarWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A2153C29C35197002FE8EB /* StaticScreensTabBarWrapper.swift */; }; + 80A2154029CA68D5002FE8EB /* RemoteFeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A2153F29CA68D5002FE8EB /* RemoteFeatureFlag.swift */; }; + 80A2154129CA68D5002FE8EB /* RemoteFeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A2153F29CA68D5002FE8EB /* RemoteFeatureFlag.swift */; }; + 80A2154329D1177A002FE8EB /* RemoteConfigDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A2154229D1177A002FE8EB /* RemoteConfigDebugViewController.swift */; }; + 80A2154429D1177A002FE8EB /* RemoteConfigDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A2154229D1177A002FE8EB /* RemoteConfigDebugViewController.swift */; }; + 80A2154629D15B88002FE8EB /* RemoteConfigOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A2154529D15B88002FE8EB /* RemoteConfigOverrideStore.swift */; }; + 80A2154729D15B88002FE8EB /* RemoteConfigOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A2154529D15B88002FE8EB /* RemoteConfigOverrideStore.swift */; }; + 80B016CF27FEBDC900D15566 /* DashboardCardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B016CE27FEBDC900D15566 /* DashboardCardTests.swift */; }; + 80B016D12803AB9F00D15566 /* DashboardPostsListCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B016D02803AB9F00D15566 /* DashboardPostsListCardCell.swift */; }; + 80B016D22803AB9F00D15566 /* DashboardPostsListCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B016D02803AB9F00D15566 /* DashboardPostsListCardCell.swift */; }; + 80C523A429959DE000B1C14B /* BlazeWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80C523A329959DE000B1C14B /* BlazeWebViewController.swift */; }; + 80C523A529959DE000B1C14B /* BlazeWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80C523A329959DE000B1C14B /* BlazeWebViewController.swift */; }; + 80C523A72995D73C00B1C14B /* BlazeWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80C523A62995D73C00B1C14B /* BlazeWebViewModel.swift */; }; + 80C523A82995D73C00B1C14B /* BlazeWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80C523A62995D73C00B1C14B /* BlazeWebViewModel.swift */; }; + 80C523AB29AE6C2200B1C14B /* BlazeWebViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80C523AA29AE6C2200B1C14B /* BlazeWebViewModelTests.swift */; }; + 80C740FB2989FC4600199027 /* PostStatsTableViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80C740FA2989FC4600199027 /* PostStatsTableViewController+JetpackBannerViewController.swift */; }; + 80C740FC2989FC4600199027 /* PostStatsTableViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80C740FA2989FC4600199027 /* PostStatsTableViewController+JetpackBannerViewController.swift */; }; + 80D9CFF429DCA53E00FE3400 /* DashboardPagesListCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9CFF329DCA53E00FE3400 /* DashboardPagesListCardCell.swift */; }; + 80D9CFF529DD314600FE3400 /* DashboardPagesListCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9CFF329DCA53E00FE3400 /* DashboardPagesListCardCell.swift */; }; + 80D9CFF729E5010300FE3400 /* PagesCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9CFF629E5010300FE3400 /* PagesCardViewModel.swift */; }; + 80D9CFF829E5010300FE3400 /* PagesCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9CFF629E5010300FE3400 /* PagesCardViewModel.swift */; }; + 80D9CFFA29E5E6FE00FE3400 /* DashboardCardTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9CFF929E5E6FE00FE3400 /* DashboardCardTableView.swift */; }; + 80D9CFFB29E5E6FE00FE3400 /* DashboardCardTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9CFF929E5E6FE00FE3400 /* DashboardCardTableView.swift */; }; + 80D9CFFD29E711E200FE3400 /* DashboardPageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9CFFC29E711E200FE3400 /* DashboardPageCell.swift */; }; + 80D9CFFE29E711E200FE3400 /* DashboardPageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9CFFC29E711E200FE3400 /* DashboardPageCell.swift */; }; + 80D9D00029E85EBF00FE3400 /* PageEditorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9CFFF29E85EBF00FE3400 /* PageEditorPresenter.swift */; }; + 80D9D00129E85EBF00FE3400 /* PageEditorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9CFFF29E85EBF00FE3400 /* PageEditorPresenter.swift */; }; + 80D9D00329EF4C7F00FE3400 /* DashboardPageCreationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9D00229EF4C7F00FE3400 /* DashboardPageCreationCell.swift */; }; + 80D9D00429EF4C7F00FE3400 /* DashboardPageCreationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9D00229EF4C7F00FE3400 /* DashboardPageCreationCell.swift */; }; + 80D9D04629F760C400FE3400 /* FailableDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9D04529F760C400FE3400 /* FailableDecodable.swift */; }; + 80D9D04729F765C900FE3400 /* FailableDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D9D04529F760C400FE3400 /* FailableDecodable.swift */; }; + 80EF671F27F135EB0063B138 /* WhatIsNewViewAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF671E27F135EB0063B138 /* WhatIsNewViewAppearance.swift */; }; + 80EF672027F135EB0063B138 /* WhatIsNewViewAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF671E27F135EB0063B138 /* WhatIsNewViewAppearance.swift */; }; + 80EF672227F160720063B138 /* DashboardCustomAnnouncementCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF672127F160720063B138 /* DashboardCustomAnnouncementCell.swift */; }; + 80EF672327F160720063B138 /* DashboardCustomAnnouncementCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF672127F160720063B138 /* DashboardCustomAnnouncementCell.swift */; }; + 80EF672527F3D63B0063B138 /* DashboardStatsStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF672427F3D63B0063B138 /* DashboardStatsStackView.swift */; }; + 80EF672627F3D63B0063B138 /* DashboardStatsStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF672427F3D63B0063B138 /* DashboardStatsStackView.swift */; }; + 80EF9284280CFEB60064A971 /* DashboardPostsSyncManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF9283280CFEB60064A971 /* DashboardPostsSyncManagerTests.swift */; }; + 80EF9286280D272E0064A971 /* DashboardPostsSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF9285280D272E0064A971 /* DashboardPostsSyncManager.swift */; }; + 80EF9287280D272E0064A971 /* DashboardPostsSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF9285280D272E0064A971 /* DashboardPostsSyncManager.swift */; }; + 80EF928A280D28140064A971 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF9289280D28140064A971 /* Atomic.swift */; }; + 80EF928B280D28140064A971 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF9289280D28140064A971 /* Atomic.swift */; }; + 80EF928D280E83110064A971 /* QuickStartToursCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF928C280E83110064A971 /* QuickStartToursCollection.swift */; }; + 80EF928E280E83110064A971 /* QuickStartToursCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF928C280E83110064A971 /* QuickStartToursCollection.swift */; }; + 80EF929028105CFA0064A971 /* QuickStartFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF928F28105CFA0064A971 /* QuickStartFactory.swift */; }; + 80EF929128105CFA0064A971 /* QuickStartFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF928F28105CFA0064A971 /* QuickStartFactory.swift */; }; + 80EF92932810FA5A0064A971 /* QuickStartFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80EF92922810FA5A0064A971 /* QuickStartFactoryTests.swift */; }; + 80F6D02228EE866A00953C1A /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; + 80F6D02328EE866A00953C1A /* RemoteNotificationActionParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7335AC5F21220D550012EF2D /* RemoteNotificationActionParser.swift */; }; + 80F6D02428EE866A00953C1A /* NotificationContentRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7947A8210BAC1D005BB851 /* NotificationContentRange.swift */; }; + 80F6D02628EE866A00953C1A /* FormattableContentStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B620F4097B00DF8486 /* FormattableContentStyles.swift */; }; + 80F6D02728EE866A00953C1A /* FormattableRangesFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E929CD02110D4F200BCAD88 /* FormattableRangesFactory.swift */; }; + 80F6D02828EE866A00953C1A /* FormattableContentAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B820F4097B00DF8486 /* FormattableContentAction.swift */; }; + 80F6D02928EE866A00953C1A /* DefaultFormattableContentAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123AF20F4097A00DF8486 /* DefaultFormattableContentAction.swift */; }; + 80F6D02A28EE866A00953C1A /* FormattableMediaContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B020F4097A00DF8486 /* FormattableMediaContent.swift */; }; + 80F6D02B28EE866A00953C1A /* FormattableTextContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B320F4097A00DF8486 /* FormattableTextContent.swift */; }; + 80F6D02C28EE866A00953C1A /* FormattableContentActionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B720F4097B00DF8486 /* FormattableContentActionCommand.swift */; }; + 80F6D02D28EE866A00953C1A /* FormattableContentRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B420F4097A00DF8486 /* FormattableContentRange.swift */; }; + 80F6D02E28EE866A00953C1A /* FormattableCommentContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFF208B20EADF68009C4699 /* FormattableCommentContent.swift */; }; + 80F6D02F28EE866A00953C1A /* NotificationCommentRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7947AA210BAC5E005BB851 /* NotificationCommentRange.swift */; }; + 80F6D03028EE866A00953C1A /* NotificationContentFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB5824620EC41B200002702 /* NotificationContentFactory.swift */; }; + 80F6D03128EE866A00953C1A /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; + 80F6D03228EE866A00953C1A /* String+CondenseWhitespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = C737553D27C80DD500C6E9A1 /* String+CondenseWhitespace.swift */; }; + 80F6D03328EE866A00953C1A /* FormattableUserContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFF208520EAD918009C4699 /* FormattableUserContent.swift */; }; + 80F6D03428EE866A00953C1A /* FormattableContentFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123AC20F4097900DF8486 /* FormattableContentFactory.swift */; }; + 80F6D03528EE866A00953C1A /* AppLocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */; }; + 80F6D03628EE866A00953C1A /* TextBundleWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */; }; + 80F6D03728EE866A00953C1A /* FormattableNoticonRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7947AC210BAC7B005BB851 /* FormattableNoticonRange.swift */; }; + 80F6D03828EE866A00953C1A /* RichNotificationContentFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F6DD41212BA54700CE447D /* RichNotificationContentFormatter.swift */; }; + 80F6D03928EE866A00953C1A /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; + 80F6D03A28EE866A00953C1A /* FormattableContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B220F4097A00DF8486 /* FormattableContent.swift */; }; + 80F6D03B28EE866A00953C1A /* NotificationContentRangeFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B120F4097A00DF8486 /* NotificationContentRangeFactory.swift */; }; + 80F6D03C28EE866A00953C1A /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7396FE65210F730600496D0D /* NotificationService.swift */; }; + 80F6D03D28EE866A00953C1A /* Tracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA22821C99F6180016CA7C /* Tracks.swift */; }; + 80F6D03E28EE866A00953C1A /* FormattableContentGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123AD20F4097900DF8486 /* FormattableContentGroup.swift */; }; + 80F6D03F28EE866A00953C1A /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64028AAC82600EABE26 /* KeychainUtils.swift */; }; + 80F6D04028EE866A00953C1A /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; + 80F6D04128EE866A00953C1A /* Notifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73BFDA89211D054800907245 /* Notifiable.swift */; }; + 80F6D04228EE866A00953C1A /* NotificationTextContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFF208920EADCB6009C4699 /* NotificationTextContent.swift */; }; + 80F6D04328EE866A00953C1A /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690151F828FF000200E30 /* FeatureFlag.swift */; }; + 80F6D04428EE866A00953C1A /* UIImage+Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58C4EC9207C5E1900E32E4D /* UIImage+Assets.swift */; }; + 80F6D04528EE866A00953C1A /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690141F828FF000200E30 /* BuildConfiguration.swift */; }; + 80F6D04628EE866A00953C1A /* Tracks+ServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73E40D8B21238C520012ABA6 /* Tracks+ServiceExtension.swift */; }; + 80F6D04728EE866A00953C1A /* UNNotificationContent+RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73768B6A212B4E4F005136A1 /* UNNotificationContent+RemoteNotification.swift */; }; + 80F6D04828EE866A00953C1A /* RichNotificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F6DD43212C714F00CE447D /* RichNotificationViewModel.swift */; }; + 80F6D04928EE866A00953C1A /* RemoteNotificationStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73EDC709212E5D6700E5E3ED /* RemoteNotificationStyles.swift */; }; + 80F6D04D28EE866A00953C1A /* Noticons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0C25DF2F7700C9654B /* Noticons.ttf */; }; + 80F6D05D28EE88FC00953C1A /* JetpackNotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 80F6D05428EE866A00953C1A /* JetpackNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 80F8DAC1282B6546007434A0 /* WPAnalytics+QuickStart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8DAC0282B6546007434A0 /* WPAnalytics+QuickStart.swift */; }; + 80F8DAC2282B6546007434A0 /* WPAnalytics+QuickStart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80F8DAC0282B6546007434A0 /* WPAnalytics+QuickStart.swift */; }; 820ADD701F3A1F88002D7F93 /* ThemeBrowserSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 820ADD6F1F3A1F88002D7F93 /* ThemeBrowserSectionHeaderView.xib */; }; 820ADD721F3A226E002D7F93 /* ThemeBrowserSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820ADD711F3A226E002D7F93 /* ThemeBrowserSectionHeaderView.swift */; }; 821738091FE04A9E00BEC94C /* DateAndTimeFormatSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 821738081FE04A9E00BEC94C /* DateAndTimeFormatSettingsViewController.swift */; }; 8217380B1FE05EE600BEC94C /* BlogSettings+DateAndTimeFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8217380A1FE05EE600BEC94C /* BlogSettings+DateAndTimeFormat.swift */; }; 822876F11E929CFD00696BF7 /* ReachabilityUtils+OnlineActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822876F01E929CFD00696BF7 /* ReachabilityUtils+OnlineActions.swift */; }; - 822D60B11F4C747E0016C46D /* JetpackSecuritySettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822D60B01F4C747E0016C46D /* JetpackSecuritySettingsViewController.swift */; }; 822D60B91F4CCC7A0016C46D /* BlogJetpackSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822D60B81F4CCC7A0016C46D /* BlogJetpackSettingsService.swift */; }; 82301B8F1E787420009C9C4E /* AppRatingUtilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82301B8E1E787420009C9C4E /* AppRatingUtilityTests.swift */; }; - 8236EB4B20248FF1007C7CF9 /* JetpackSpeedUpSiteSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8236EB4A20248FF0007C7CF9 /* JetpackSpeedUpSiteSettingsViewController.swift */; }; 8236EB502024ED8C007C7CF9 /* NotificationsViewController+JetpackPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8236EB4F2024ED8C007C7CF9 /* NotificationsViewController+JetpackPrompt.swift */; }; 825327581FBF7CD600B8B7D2 /* ActivityUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 825327571FBF7CD600B8B7D2 /* ActivityUtils.swift */; }; 8261B4CC1EA8E13700668298 /* SVProgressHUD+Dismiss.m in Sources */ = {isa = PBXBuildFile; fileRef = 8261B4CA1EA8E13700668298 /* SVProgressHUD+Dismiss.m */; }; - 827704F11F607C0E002E8A03 /* JetpackConnectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 827704F01F607C0E002E8A03 /* JetpackConnectionViewController.swift */; }; 8298F38F1EEF2B15008EB7F0 /* AppFeedbackPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8298F38E1EEF2B15008EB7F0 /* AppFeedbackPromptView.swift */; }; 8298F3921EEF3BA7008EB7F0 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8298F3911EEF3BA7008EB7F0 /* StoreKit.framework */; }; 82A062DC2017BC220084CE7C /* ActivityListSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 82A062DB2017BC220084CE7C /* ActivityListSectionHeaderView.xib */; }; @@ -1015,20 +1971,71 @@ 82B85DF91EDDB807004FD510 /* SiteIconPickerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82B85DF81EDDB807004FD510 /* SiteIconPickerPresenter.swift */; }; 82C420761FE44BD900CFB15B /* SiteSettingsViewController+Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82C420751FE44BD900CFB15B /* SiteSettingsViewController+Swift.swift */; }; 82FC61241FA8ADAD00A1757E /* ActivityTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FC611C1FA8ADAC00A1757E /* ActivityTableViewCell.swift */; }; - 82FC61251FA8ADAD00A1757E /* ActivityListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FC611D1FA8ADAC00A1757E /* ActivityListViewController.swift */; }; + 82FC61251FA8ADAD00A1757E /* BaseActivityListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FC611D1FA8ADAC00A1757E /* BaseActivityListViewController.swift */; }; 82FC61261FA8ADAD00A1757E /* ActivityTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 82FC611E1FA8ADAC00A1757E /* ActivityTableViewCell.xib */; }; 82FC61271FA8ADAD00A1757E /* WPStyleGuide+Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FC611F1FA8ADAC00A1757E /* WPStyleGuide+Activity.swift */; }; 82FC612A1FA8B6F000A1757E /* ActivityListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FC61291FA8B6F000A1757E /* ActivityListViewModel.swift */; }; 82FC612C1FA8B7FC00A1757E /* ActivityListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FC612B1FA8B7FC00A1757E /* ActivityListRow.swift */; }; 82FFBF4D1F434BDA00F4573F /* ThemeIdHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FFBF4C1F434BDA00F4573F /* ThemeIdHelper.swift */; }; 83043E55126FA31400EC9953 /* MessageUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83043E54126FA31400EC9953 /* MessageUI.framework */; }; - 83418AAA11C9FA6E00ACF00C /* Comment.m in Sources */ = {isa = PBXBuildFile; fileRef = 83418AA911C9FA6E00ACF00C /* Comment.m */; }; + 830A58D82793AB4500CDE94F /* LoginEpilogueAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830A58D72793AB4400CDE94F /* LoginEpilogueAnimator.swift */; }; + 830A58D92793AB4500CDE94F /* LoginEpilogueAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 830A58D72793AB4400CDE94F /* LoginEpilogueAnimator.swift */; }; + 8313B9EE298B1ACD000AF26E /* SiteSettingsViewController+Blogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8313B9ED298B1ACD000AF26E /* SiteSettingsViewController+Blogging.swift */; }; + 8313B9EF298B1ACD000AF26E /* SiteSettingsViewController+Blogging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8313B9ED298B1ACD000AF26E /* SiteSettingsViewController+Blogging.swift */; }; + 8313B9FA2995A03C000AF26E /* JetpackRemoteInstallCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8313B9F92995A03C000AF26E /* JetpackRemoteInstallCardView.swift */; }; + 8313B9FB2995A03C000AF26E /* JetpackRemoteInstallCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8313B9F92995A03C000AF26E /* JetpackRemoteInstallCardView.swift */; }; + 8320BDE5283D9359009DF2DE /* BlogService+BloggingPrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8320BDE4283D9359009DF2DE /* BlogService+BloggingPrompts.swift */; }; + 8320BDE6283D9359009DF2DE /* BlogService+BloggingPrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8320BDE4283D9359009DF2DE /* BlogService+BloggingPrompts.swift */; }; + 8323789828526E6D003F4443 /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25F9FD2609AA830005E08F /* AppConfiguration.swift */; }; + 8323789928526E6E003F4443 /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25F9FD2609AA830005E08F /* AppConfiguration.swift */; }; + 8332DD2429259AE300802F7D /* DataMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8332DD2329259AE300802F7D /* DataMigrator.swift */; }; + 8332DD2529259AE300802F7D /* DataMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8332DD2329259AE300802F7D /* DataMigrator.swift */; }; + 8332DD2829259BEB00802F7D /* DataMigratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8332DD2729259BEB00802F7D /* DataMigratorTests.swift */; }; 834CE7341256D0DE0046A4A3 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 834CE7331256D0DE0046A4A3 /* CFNetwork.framework */; }; 8350E49611D2C71E00A7B073 /* Media.m in Sources */ = {isa = PBXBuildFile; fileRef = 8350E49511D2C71E00A7B073 /* Media.m */; }; - 8355D67E11D13EAD00A61362 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8355D67D11D13EAD00A61362 /* MobileCoreServices.framework */; }; 8355D7D911D260AA00A61362 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8355D7D811D260AA00A61362 /* CoreData.framework */; }; - 835E2403126E66E50085940B /* AssetsLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 835E2402126E66E50085940B /* AssetsLibrary.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + 836498C828172C5900A2C170 /* WPStyleGuide+BloggingPrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836498C728172C5900A2C170 /* WPStyleGuide+BloggingPrompts.swift */; }; + 836498C928172C5900A2C170 /* WPStyleGuide+BloggingPrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836498C728172C5900A2C170 /* WPStyleGuide+BloggingPrompts.swift */; }; + 836498CB2817301800A2C170 /* BloggingPromptsHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 836498CA2817301800A2C170 /* BloggingPromptsHeaderView.xib */; }; + 836498CC2817301800A2C170 /* BloggingPromptsHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 836498CA2817301800A2C170 /* BloggingPromptsHeaderView.xib */; }; + 836498CE281735CC00A2C170 /* BloggingPromptsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836498CD281735CC00A2C170 /* BloggingPromptsHeaderView.swift */; }; + 836498CF281735CC00A2C170 /* BloggingPromptsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836498CD281735CC00A2C170 /* BloggingPromptsHeaderView.swift */; }; 8370D10A11FA499A009D650F /* WPTableViewActivityCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 8370D10911FA499A009D650F /* WPTableViewActivityCell.m */; }; + 83796699299C048E004A92B9 /* DashboardJetpackInstallCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83796698299C048E004A92B9 /* DashboardJetpackInstallCardCell.swift */; }; + 8379669A299C048E004A92B9 /* DashboardJetpackInstallCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83796698299C048E004A92B9 /* DashboardJetpackInstallCardCell.swift */; }; + 8379669C299C0C44004A92B9 /* JetpackRemoteInstallTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8379669B299C0C44004A92B9 /* JetpackRemoteInstallTableViewCell.swift */; }; + 8379669D299C0C44004A92B9 /* JetpackRemoteInstallTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8379669B299C0C44004A92B9 /* JetpackRemoteInstallTableViewCell.swift */; }; + 8379669F299D51EC004A92B9 /* JetpackPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8379669E299D51EC004A92B9 /* JetpackPlugin.swift */; }; + 837966A0299D51EC004A92B9 /* JetpackPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8379669E299D51EC004A92B9 /* JetpackPlugin.swift */; }; + 837966A2299E9C85004A92B9 /* JetpackInstallPluginHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837966A1299E9C85004A92B9 /* JetpackInstallPluginHelper.swift */; }; + 837966A3299E9C85004A92B9 /* JetpackInstallPluginHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837966A1299E9C85004A92B9 /* JetpackInstallPluginHelper.swift */; }; + 837B49D7283C2AE80061A657 /* BloggingPromptSettings+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837B49D3283C2AE80061A657 /* BloggingPromptSettings+CoreDataClass.swift */; }; + 837B49D8283C2AE80061A657 /* BloggingPromptSettings+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837B49D3283C2AE80061A657 /* BloggingPromptSettings+CoreDataClass.swift */; }; + 837B49D9283C2AE80061A657 /* BloggingPromptSettings+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837B49D4283C2AE80061A657 /* BloggingPromptSettings+CoreDataProperties.swift */; }; + 837B49DA283C2AE80061A657 /* BloggingPromptSettings+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837B49D4283C2AE80061A657 /* BloggingPromptSettings+CoreDataProperties.swift */; }; + 837B49DB283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837B49D5283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataClass.swift */; }; + 837B49DC283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837B49D5283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataClass.swift */; }; + 837B49DD283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837B49D6283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataProperties.swift */; }; + 837B49DE283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837B49D6283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataProperties.swift */; }; + 8384C64128AAC82600EABE26 /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64028AAC82600EABE26 /* KeychainUtils.swift */; }; + 8384C64228AAC82600EABE26 /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64028AAC82600EABE26 /* KeychainUtils.swift */; }; + 8384C64428AAC85F00EABE26 /* KeychainUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64328AAC85F00EABE26 /* KeychainUtilsTests.swift */; }; + 839B150B2795DEE0009F5E77 /* UIView+Margins.swift in Sources */ = {isa = PBXBuildFile; fileRef = 839B150A2795DEE0009F5E77 /* UIView+Margins.swift */; }; + 839B150C2795DEE0009F5E77 /* UIView+Margins.swift in Sources */ = {isa = PBXBuildFile; fileRef = 839B150A2795DEE0009F5E77 /* UIView+Margins.swift */; }; + 83A1B19528AFE47900E737AC /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64028AAC82600EABE26 /* KeychainUtils.swift */; }; + 83A1B19628AFE47A00E737AC /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64028AAC82600EABE26 /* KeychainUtils.swift */; }; + 83A1B19A28AFE47C00E737AC /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64028AAC82600EABE26 /* KeychainUtils.swift */; }; + 83A1B19B28AFE47D00E737AC /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64028AAC82600EABE26 /* KeychainUtils.swift */; }; + 83A1B19C28AFE47D00E737AC /* KeychainUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C64028AAC82600EABE26 /* KeychainUtils.swift */; }; + 83A1B19E28AFE86A00E737AC /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690151F828FF000200E30 /* FeatureFlag.swift */; }; + 83A1B1A028AFE89700E737AC /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25F9FD2609AA830005E08F /* AppConfiguration.swift */; }; + 83A1B1A328AFE89F00E737AC /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690141F828FF000200E30 /* BuildConfiguration.swift */; }; + 83B1D037282C62620061D911 /* BloggingPromptsAttribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B1D036282C62620061D911 /* BloggingPromptsAttribution.swift */; }; + 83B1D038282C62620061D911 /* BloggingPromptsAttribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83B1D036282C62620061D911 /* BloggingPromptsAttribution.swift */; }; + 83C972E0281C45AB0049E1FE /* Post+BloggingPrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83C972DF281C45AB0049E1FE /* Post+BloggingPrompts.swift */; }; + 83C972E1281C45AB0049E1FE /* Post+BloggingPrompts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83C972DF281C45AB0049E1FE /* Post+BloggingPrompts.swift */; }; + 83EF3D7B2937D703000AF9BF /* SharedDataIssueSolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED65D78293511E4008071BF /* SharedDataIssueSolver.swift */; }; + 83EF3D7F2937F08C000AF9BF /* SharedDataIssueSolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EF3D7C2937E969000AF9BF /* SharedDataIssueSolverTests.swift */; }; 83F3E26011275E07004CD686 /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83F3E25F11275E07004CD686 /* MapKit.framework */; }; 83F3E2D311276371004CD686 /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83F3E2D211276371004CD686 /* CoreLocation.framework */; }; 83FEFC7611FF6C5A0078B462 /* SiteSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 83FEFC7411FF6C5A0078B462 /* SiteSettingsViewController.m */; }; @@ -1044,64 +2051,205 @@ 85F8E19D1B018698000859BB /* PushAuthenticationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F8E19C1B018698000859BB /* PushAuthenticationServiceTests.swift */; }; 8B05D29123A9417E0063B9AA /* WPMediaEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B05D29023A9417E0063B9AA /* WPMediaEditor.swift */; }; 8B05D29323AA572A0063B9AA /* GutenbergMediaEditorImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B05D29223AA572A0063B9AA /* GutenbergMediaEditorImage.swift */; }; + 8B065CC627BD5452005BA7AB /* DashboardEmptyPostsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B065CC527BD5452005BA7AB /* DashboardEmptyPostsCardCell.swift */; }; + 8B065CC727BD5452005BA7AB /* DashboardEmptyPostsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B065CC527BD5452005BA7AB /* DashboardEmptyPostsCardCell.swift */; }; + 8B0732E7242B9C5200E7FBD3 /* PrepublishingHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8B0732E6242B9C5200E7FBD3 /* PrepublishingHeaderView.xib */; }; + 8B0732E9242BA1F000E7FBD3 /* PrepublishingHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B0732E8242BA1F000E7FBD3 /* PrepublishingHeaderView.swift */; }; + 8B0732F0242BF7E800E7FBD3 /* Blog+Title.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B0732EE242BF6EA00E7FBD3 /* Blog+Title.swift */; }; + 8B0732F3242BF99B00E7FBD3 /* PrepublishingNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B0732F1242BF97B00E7FBD3 /* PrepublishingNavigationController.swift */; }; + 8B074A5027AC3A64003A2EB8 /* BlogDashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B074A4F27AC3A64003A2EB8 /* BlogDashboardViewModel.swift */; }; + 8B074A5127AC3A64003A2EB8 /* BlogDashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B074A4F27AC3A64003A2EB8 /* BlogDashboardViewModel.swift */; }; + 8B0CE7D12481CFE8004C4799 /* ReaderDetailHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B0CE7D02481CFE8004C4799 /* ReaderDetailHeaderView.swift */; }; + 8B0CE7D32481CFF8004C4799 /* ReaderDetailHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8B0CE7D22481CFF8004C4799 /* ReaderDetailHeaderView.xib */; }; + 8B15CDAB27EB89AD00A75749 /* BlogDashboardPostsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B15CDAA27EB89AC00A75749 /* BlogDashboardPostsParser.swift */; }; + 8B15CDAC27EB89AD00A75749 /* BlogDashboardPostsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B15CDAA27EB89AC00A75749 /* BlogDashboardPostsParser.swift */; }; + 8B15D27428009EBF0076628A /* BlogDashboardAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B15D27328009EBF0076628A /* BlogDashboardAnalytics.swift */; }; + 8B15D27528009EBF0076628A /* BlogDashboardAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B15D27328009EBF0076628A /* BlogDashboardAnalytics.swift */; }; + 8B16CE9A25251C89007BE5A9 /* ReaderPostStreamService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B16CE9925251C89007BE5A9 /* ReaderPostStreamService.swift */; }; + 8B1CF00F2433902700578582 /* PasswordAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1CF00E2433902700578582 /* PasswordAlertController.swift */; }; + 8B1CF0112433E61C00578582 /* AbstractPost+TitleForVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1CF0102433E61C00578582 /* AbstractPost+TitleForVisibility.swift */; }; + 8B1E62D625758AAF009A0F80 /* ActivityTypeSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1E62D525758AAF009A0F80 /* ActivityTypeSelectorViewController.swift */; }; + 8B24C4E3249A4C3E0005E8A5 /* OfflineReaderWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B24C4E2249A4C3E0005E8A5 /* OfflineReaderWebView.swift */; }; + 8B25F8DA24B7683A009DD4C9 /* ReaderCSSTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B25F8D924B7683A009DD4C9 /* ReaderCSSTests.swift */; }; + 8B260D7E2444FC9D0010F756 /* PostVisibilitySelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B260D7D2444FC9D0010F756 /* PostVisibilitySelectorViewController.swift */; }; + 8B2D4F5327ECE089009B085C /* dashboard-200-without-posts.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B2D4F5227ECE089009B085C /* dashboard-200-without-posts.json */; }; + 8B2D4F5527ECE376009B085C /* BlogDashboardPostsParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B2D4F5427ECE376009B085C /* BlogDashboardPostsParserTests.swift */; }; + 8B33BC9527A0C14C00DB5985 /* BlogDetailsViewController+QuickActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B33BC9427A0C14C00DB5985 /* BlogDetailsViewController+QuickActions.swift */; }; + 8B33BC9627A0C14C00DB5985 /* BlogDetailsViewController+QuickActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B33BC9427A0C14C00DB5985 /* BlogDetailsViewController+QuickActions.swift */; }; + 8B36256625A60CCA00D7CCE3 /* BackupListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B36256525A60CCA00D7CCE3 /* BackupListViewController.swift */; }; + 8B3626F925A665E500D7CCE3 /* UIApplication+mainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3626F825A665E500D7CCE3 /* UIApplication+mainWindow.swift */; }; 8B3DECAB2388506400A459C2 /* SentryStartupEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3DECAA2388506400A459C2 /* SentryStartupEvent.swift */; }; + 8B45C12627B2A27400EA3257 /* dashboard-200-with-drafts-only.json in Resources */ = {isa = PBXBuildFile; fileRef = 8B45C12527B2A27400EA3257 /* dashboard-200-with-drafts-only.json */; }; + 8B45C12727B2B08900EA3257 /* DashboardStatsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6214DF27B1AD9D001DF7B6 /* DashboardStatsCardCell.swift */; }; + 8B45C12827B2B0D500EA3257 /* BlogDashboardService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6214E227B1B2F3001DF7B6 /* BlogDashboardService.swift */; }; + 8B4DDF25278F44CC0022494D /* BlogDashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA73D7D5278D9E5D00DF24B3 /* BlogDashboardViewController.swift */; }; + 8B4EDADD27DF9D5E004073B6 /* Blog+MySite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4EDADC27DF9D5E004073B6 /* Blog+MySite.swift */; }; + 8B4EDADE27DF9D5E004073B6 /* Blog+MySite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4EDADC27DF9D5E004073B6 /* Blog+MySite.swift */; }; + 8B51844525893F140085488D /* FilterBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B51844425893F140085488D /* FilterBarView.swift */; }; + 8B55F9B82614D819007D618E /* UnifiedPrologueEditorContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F851414260D0A3300A4B938 /* UnifiedPrologueEditorContentView.swift */; }; + 8B55F9CA2614D8BC007D618E /* RoundRectangleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8513DE260D091500A4B938 /* RoundRectangleView.swift */; }; + 8B55F9DC2614D902007D618E /* CircledIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F851427260D1EA300A4B938 /* CircledIcon.swift */; }; + 8B55F9EE2614D977007D618E /* UnifiedPrologueStatsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC7F89D2612341900FD8728 /* UnifiedPrologueStatsContentView.swift */; }; + 8B55FA002614D980007D618E /* UnifiedPrologueNotificationsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1788106E260E488B00A98BD8 /* UnifiedPrologueNotificationsContentView.swift */; }; + 8B55FA122614D989007D618E /* UnifiedPrologueReaderContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178810D82612037800A98BD8 /* UnifiedPrologueReaderContentView.swift */; }; + 8B55FAAD2614FC87007D618E /* Text+BoldSubString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178810B42611D25600A98BD8 /* Text+BoldSubString.swift */; }; + 8B5E1DD827EA5929002EBEE3 /* PostCoordinator+Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5E1DD727EA5929002EBEE3 /* PostCoordinator+Dashboard.swift */; }; + 8B5E1DD927EA5929002EBEE3 /* PostCoordinator+Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B5E1DD727EA5929002EBEE3 /* PostCoordinator+Dashboard.swift */; }; + 8B5FEAF125A746CB000CBFF7 /* UIApplication+mainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3626F825A665E500D7CCE3 /* UIApplication+mainWindow.swift */; }; + 8B5FEC0225A750CB000CBFF7 /* UIApplication+mainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3626F825A665E500D7CCE3 /* UIApplication+mainWindow.swift */; }; + 8B6214E027B1AD9D001DF7B6 /* DashboardStatsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6214DF27B1AD9D001DF7B6 /* DashboardStatsCardCell.swift */; }; + 8B6214E327B1B2F3001DF7B6 /* BlogDashboardService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6214E227B1B2F3001DF7B6 /* BlogDashboardService.swift */; }; + 8B6214E627B1B446001DF7B6 /* BlogDashboardServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6214E527B1B446001DF7B6 /* BlogDashboardServiceTests.swift */; }; + 8B64B4B2247EC3A2009A1229 /* reader.css in Resources */ = {isa = PBXBuildFile; fileRef = 8B64B4B1247EC3A2009A1229 /* reader.css */; }; + 8B69F0E4255C2C3F006B1CEF /* ActivityListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B69F0E3255C2C3F006B1CEF /* ActivityListViewModelTests.swift */; }; + 8B69F100255C4870006B1CEF /* ActivityStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B69F0FF255C4870006B1CEF /* ActivityStoreTests.swift */; }; + 8B69F19F255D67E7006B1CEF /* CalendarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B69F19E255D67E7006B1CEF /* CalendarViewController.swift */; }; + 8B6BD55024293FBE00DB8F28 /* PrepublishingNudgesViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6BD54F24293FBE00DB8F28 /* PrepublishingNudgesViewControllerTests.swift */; }; 8B6EA62323FDE50B004BA312 /* PostServiceUploadingList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6EA62223FDE50B004BA312 /* PostServiceUploadingList.swift */; }; + 8B749E7225AF522900023F03 /* JetpackCapabilitiesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B749E7125AF522900023F03 /* JetpackCapabilitiesService.swift */; }; + 8B749E9025AF8D2E00023F03 /* JetpackCapabilitiesServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B749E8F25AF8D2E00023F03 /* JetpackCapabilitiesServiceTests.swift */; }; + 8B74A9A8268E3C68003511CE /* RewindStatus+multiSite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B74A9A7268E3C68003511CE /* RewindStatus+multiSite.swift */; }; + 8B74A9A9268E3C68003511CE /* RewindStatus+multiSite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B74A9A7268E3C68003511CE /* RewindStatus+multiSite.swift */; }; 8B7623382384373E00AB3EE7 /* PageListViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7623372384373E00AB3EE7 /* PageListViewControllerTests.swift */; }; + 8B7C97E325A8BFA2004A3373 /* JetpackActivityLogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7C97E225A8BFA2004A3373 /* JetpackActivityLogViewController.swift */; }; + 8B7F25A724E6EDB4007D82CC /* TopicsCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7F25A624E6EDB4007D82CC /* TopicsCollectionView.swift */; }; + 8B7F51C924EED804008CF5B5 /* ReaderTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7F51C824EED804008CF5B5 /* ReaderTracker.swift */; }; + 8B7F51CB24EED8A8008CF5B5 /* ReaderTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7F51CA24EED8A8008CF5B5 /* ReaderTrackerTests.swift */; }; 8B821F3C240020E2006B697E /* PostServiceUploadingListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B821F3B240020E2006B697E /* PostServiceUploadingListTests.swift */; }; + 8B85AEDA259230FC00ADBEC9 /* ABTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B85AED9259230FC00ADBEC9 /* ABTest.swift */; }; 8B8C814D2318073300A0E620 /* BasePostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8C814C2318073300A0E620 /* BasePostTests.swift */; }; + 8B8E50B627A4692000C89979 /* DashboardPostListErrorCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8E50B527A4692000C89979 /* DashboardPostListErrorCell.swift */; }; + 8B8E50B727A4692000C89979 /* DashboardPostListErrorCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8E50B527A4692000C89979 /* DashboardPostListErrorCell.swift */; }; 8B8FE8582343955500F9AD2E /* PostAutoUploadMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8FE8562343952B00F9AD2E /* PostAutoUploadMessages.swift */; }; + 8B92D69627CD51FA001F5371 /* DashboardGhostCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B92D69527CD51FA001F5371 /* DashboardGhostCardCell.swift */; }; + 8B92D69727CD51FA001F5371 /* DashboardGhostCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B92D69527CD51FA001F5371 /* DashboardGhostCardCell.swift */; }; + 8B93412F257029F60097D0AC /* FilterChipButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B93412E257029F50097D0AC /* FilterChipButton.swift */; }; 8B93856E22DC08060010BF02 /* PageListSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B93856D22DC08060010BF02 /* PageListSectionHeaderView.swift */; }; 8B939F4323832E5D00ACCB0F /* PostListViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B939F4223832E5D00ACCB0F /* PostListViewControllerTests.swift */; }; + 8BA125EB27D8F5E4008B779F /* UIView+PinSubviewPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA125EA27D8F5E4008B779F /* UIView+PinSubviewPriority.swift */; }; + 8BA125EC27D8F5E4008B779F /* UIView+PinSubviewPriority.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA125EA27D8F5E4008B779F /* UIView+PinSubviewPriority.swift */; }; + 8BA77BCB2482C52A00E1EBBF /* ReaderCardDiscoverAttributionView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8BA77BCA2482C52A00E1EBBF /* ReaderCardDiscoverAttributionView.xib */; }; + 8BA77BCD248340CE00E1EBBF /* ReaderDetailToolbar.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8BA77BCC248340CE00E1EBBF /* ReaderDetailToolbar.xib */; }; + 8BA77BCF2483415400E1EBBF /* ReaderDetailToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA77BCE2483415400E1EBBF /* ReaderDetailToolbar.swift */; }; + 8BAC9D9E27BAB97E008EA44C /* BlogDashboardRemoteEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BAC9D9D27BAB97E008EA44C /* BlogDashboardRemoteEntity.swift */; }; + 8BAC9D9F27BAB97E008EA44C /* BlogDashboardRemoteEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BAC9D9D27BAB97E008EA44C /* BlogDashboardRemoteEntity.swift */; }; + 8BAD272C241FEF3300E9D105 /* PrepublishingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BAD272B241FEF3300E9D105 /* PrepublishingViewController.swift */; }; + 8BAD53D6241922B900230F4B /* WPAnalyticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BAD53D5241922B900230F4B /* WPAnalyticsEvent.swift */; }; + 8BADF16524801BCE005AD038 /* ReaderWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BADF16424801BCE005AD038 /* ReaderWebView.swift */; }; + 8BB185C624B5FB8500A4CCE8 /* ReaderCardService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185C524B5FB8500A4CCE8 /* ReaderCardService.swift */; }; + 8BB185CC24B6058600A4CCE8 /* reader-cards-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 8BB185CB24B6058600A4CCE8 /* reader-cards-success.json */; }; + 8BB185CE24B62CE100A4CCE8 /* ReaderCardServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185CD24B62CE100A4CCE8 /* ReaderCardServiceTests.swift */; }; + 8BB185CF24B62D7600A4CCE8 /* reader-cards-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 8BB185CB24B6058600A4CCE8 /* reader-cards-success.json */; }; + 8BB185D224B63D5F00A4CCE8 /* ReaderCardsStreamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D024B63D1600A4CCE8 /* ReaderCardsStreamViewController.swift */; }; + 8BB185D524B66FE600A4CCE8 /* ReaderCard+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D324B66FE500A4CCE8 /* ReaderCard+CoreDataProperties.swift */; }; + 8BB185D624B66FE600A4CCE8 /* ReaderCard+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D424B66FE600A4CCE8 /* ReaderCard+CoreDataClass.swift */; }; + 8BBBCE702717651200B277AC /* JetpackModuleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBBCE6F2717651200B277AC /* JetpackModuleHelper.swift */; }; + 8BBBCE712717651200B277AC /* JetpackModuleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBBCE6F2717651200B277AC /* JetpackModuleHelper.swift */; }; + 8BBBEBB224B8F8C0005E358E /* ReaderCardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBBEBB124B8F8C0005E358E /* ReaderCardTests.swift */; }; + 8BBC778B27B5531700DBA087 /* BlogDashboardPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBC778A27B5531700DBA087 /* BlogDashboardPersistence.swift */; }; + 8BBC778C27B5531700DBA087 /* BlogDashboardPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBC778A27B5531700DBA087 /* BlogDashboardPersistence.swift */; }; 8BC12F72231FEBA1004DDA72 /* PostCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC12F71231FEBA1004DDA72 /* PostCoordinatorTests.swift */; }; - 8BC12F7523201917004DDA72 /* PostService+MarkAsFailedAndDraftIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC12F732320181E004DDA72 /* PostService+MarkAsFailedAndDraftIfNeeded.swift */; }; + 8BC12F7523201917004DDA72 /* AbstractPost+MarkAsFailedAndDraftIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC12F732320181E004DDA72 /* AbstractPost+MarkAsFailedAndDraftIfNeeded.swift */; }; 8BC12F7723201B86004DDA72 /* PostService+MarkAsFailedAndDraftIfNeededTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC12F7623201B86004DDA72 /* PostService+MarkAsFailedAndDraftIfNeededTests.swift */; }; 8BC6020923900D8400EFE3D0 /* NullBlogPropertySanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC6020823900D8400EFE3D0 /* NullBlogPropertySanitizer.swift */; }; 8BC6020D2390412000EFE3D0 /* NullBlogPropertySanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC6020C2390412000EFE3D0 /* NullBlogPropertySanitizerTests.swift */; }; + 8BC81D6527CFC0DA0057F790 /* BlogDashboardState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC81D6427CFC0DA0057F790 /* BlogDashboardState.swift */; }; + 8BC81D6627CFFC310057F790 /* BlogDashboardState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC81D6427CFC0DA0057F790 /* BlogDashboardState.swift */; }; + 8BCB83D124C21063001581BD /* ReaderStreamViewController+Ghost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCB83D024C21063001581BD /* ReaderStreamViewController+Ghost.swift */; }; + 8BCF957A24C6044000712056 /* ReaderTopicsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCF957924C6044000712056 /* ReaderTopicsCardCell.swift */; }; + 8BD34F0927D144FF005E931C /* BlogDashboardStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD34F0827D144FF005E931C /* BlogDashboardStateTests.swift */; }; + 8BD34F0B27D14B3C005E931C /* Blog+DashboardState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD34F0A27D14B3C005E931C /* Blog+DashboardState.swift */; }; + 8BD34F0C27D14B3C005E931C /* Blog+DashboardState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD34F0A27D14B3C005E931C /* Blog+DashboardState.swift */; }; 8BD36E022395CAEA00EFFF1C /* MediaEditorOperation+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD36E012395CAEA00EFFF1C /* MediaEditorOperation+Description.swift */; }; 8BD36E062395CC4400EFFF1C /* MediaEditorOperation+DescriptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD36E052395CC4400EFFF1C /* MediaEditorOperation+DescriptionTests.swift */; }; + 8BD66ED42787530C00CCD95A /* PostsCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD66ED32787530C00CCD95A /* PostsCardViewModel.swift */; }; + 8BD66ED52787530C00CCD95A /* PostsCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD66ED32787530C00CCD95A /* PostsCardViewModel.swift */; }; + 8BD8201924BCCE8600FF25FD /* ReaderWelcomeBanner.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8BD8201824BCCE8600FF25FD /* ReaderWelcomeBanner.xib */; }; + 8BD8201B24BCDBFF00FF25FD /* ReaderWelcomeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD8201A24BCDBFF00FF25FD /* ReaderWelcomeBanner.swift */; }; + 8BD8201D24BF9E5200FF25FD /* ReaderWelcomeBannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD8201C24BF9E5200FF25FD /* ReaderWelcomeBannerTests.swift */; }; + 8BDA5A6B247C2EAF00AB124C /* ReaderDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA5A6A247C2EAF00AB124C /* ReaderDetailViewController.swift */; }; + 8BDA5A6D247C2F8400AB124C /* ReaderDetailViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA5A6C247C2F8400AB124C /* ReaderDetailViewControllerTests.swift */; }; + 8BDA5A70247C36C100AB124C /* ReaderDetailViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8BDA5A6E247C308300AB124C /* ReaderDetailViewController.storyboard */; }; + 8BDA5A74247C5EAA00AB124C /* ReaderDetailCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA5A73247C5EAA00AB124C /* ReaderDetailCoordinatorTests.swift */; }; + 8BDA5A75247C63F300AB124C /* ReaderDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA5A71247C5E5800AB124C /* ReaderDetailCoordinator.swift */; }; + 8BDC4C39249BA5CA00DE0A2D /* ReaderCSS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDC4C38249BA5CA00DE0A2D /* ReaderCSS.swift */; }; + 8BE69512243E674300FF492F /* PrepublishingHeaderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE69511243E674300FF492F /* PrepublishingHeaderViewTests.swift */; }; + 8BE6F92A27EE26D30008BDC7 /* BlogDashboardPostCardGhostCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8BE6F92927EE26D30008BDC7 /* BlogDashboardPostCardGhostCell.xib */; }; + 8BE6F92C27EE27DB0008BDC7 /* BlogDashboardPostCardGhostCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE6F92B27EE27DB0008BDC7 /* BlogDashboardPostCardGhostCell.swift */; }; + 8BE6F92D27EE27DB0008BDC7 /* BlogDashboardPostCardGhostCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE6F92B27EE27DB0008BDC7 /* BlogDashboardPostCardGhostCell.swift */; }; + 8BE6F92E27EE27E10008BDC7 /* BlogDashboardPostCardGhostCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8BE6F92927EE26D30008BDC7 /* BlogDashboardPostCardGhostCell.xib */; }; 8BE7C84123466927006EDE70 /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE7C84023466927006EDE70 /* I18n.swift */; }; + 8BE9AB8827B6B5A300708E45 /* BlogDashboardPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BE9AB8727B6B5A300708E45 /* BlogDashboardPersistenceTests.swift */; }; + 8BEE845A27B1DC9D0001A93C /* dashboard-200-with-drafts-and-scheduled.json in Resources */ = {isa = PBXBuildFile; fileRef = 8BEE845927B1DC9D0001A93C /* dashboard-200-with-drafts-and-scheduled.json */; }; + 8BEE846427B1E05B0001A93C /* DashboardCardModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BEE846027B1DE0E0001A93C /* DashboardCardModel.swift */; }; + 8BEE846527B1E05C0001A93C /* DashboardCardModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BEE846027B1DE0E0001A93C /* DashboardCardModel.swift */; }; + 8BF0B607247D88EB009A7457 /* UITableViewCell+enableDisable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF0B606247D88EB009A7457 /* UITableViewCell+enableDisable.swift */; }; + 8BF1C81A27BC00AF00F1C203 /* BlogDashboardCardFrameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF1C81927BC00AF00F1C203 /* BlogDashboardCardFrameView.swift */; }; + 8BF1C81B27BC00AF00F1C203 /* BlogDashboardCardFrameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF1C81927BC00AF00F1C203 /* BlogDashboardCardFrameView.swift */; }; + 8BF281F927CE8E4100AF8CF3 /* DashboardGhostCardContent.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8BF281F827CE8E4100AF8CF3 /* DashboardGhostCardContent.xib */; }; + 8BF281FA27CE8E4100AF8CF3 /* DashboardGhostCardContent.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8BF281F827CE8E4100AF8CF3 /* DashboardGhostCardContent.xib */; }; + 8BF281FC27CEB69C00AF8CF3 /* DashboardFailureCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF281FB27CEB69C00AF8CF3 /* DashboardFailureCardCell.swift */; }; + 8BF281FD27CEB69C00AF8CF3 /* DashboardFailureCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF281FB27CEB69C00AF8CF3 /* DashboardFailureCardCell.swift */; }; + 8BF9E03327B1A8A800915B27 /* DashboardCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9E03227B1A8A800915B27 /* DashboardCard.swift */; }; + 8BF9E03427B1A8A800915B27 /* DashboardCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9E03227B1A8A800915B27 /* DashboardCard.swift */; }; 8BFE36FD230F16580061EBA8 /* AbstractPost+fixLocalMediaURLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BFE36FC230F16580061EBA8 /* AbstractPost+fixLocalMediaURLs.swift */; }; 8BFE36FF230F1C850061EBA8 /* AbstractPost+fixLocalMediaURLsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BFE36FE230F1C850061EBA8 /* AbstractPost+fixLocalMediaURLsTests.swift */; }; + 8C6A22E425783D2000A79950 /* JetpackScanService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6A22E325783D2000A79950 /* JetpackScanService.swift */; }; + 8F22804451E5812433733348 /* TimeZoneSearchHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F228848D5DEACE6798CE7E2 /* TimeZoneSearchHeaderView.swift */; }; + 8F2281EE1C11E49A2B1FE337 /* TimeZoneSearchHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8F228AE62B771552F0F971BE /* TimeZoneSearchHeaderView.xib */; }; + 8F228614F588222E57F2E8DE /* TimeZoneSearchHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8F228AE62B771552F0F971BE /* TimeZoneSearchHeaderView.xib */; }; + 8F2289EDA1886BF77687D72D /* TimeZoneSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2283367263B37B0681F988 /* TimeZoneSelectorViewController.swift */; }; + 8F228B22E190FF92D05E53DB /* TimeZoneSearchHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F228848D5DEACE6798CE7E2 /* TimeZoneSearchHeaderView.swift */; }; + 8F228F2923045666AE456D2C /* TimeZoneSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F2283367263B37B0681F988 /* TimeZoneSelectorViewController.swift */; }; 91138455228373EB00FB02B7 /* GutenbergVideoUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91138454228373EB00FB02B7 /* GutenbergVideoUploadProcessor.swift */; }; 912347192213484300BD9F97 /* GutenbergViewController+InformativeDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912347182213484300BD9F97 /* GutenbergViewController+InformativeDialog.swift */; }; 9123471B221449E200BD9F97 /* GutenbergInformativeDialogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9123471A221449E200BD9F97 /* GutenbergInformativeDialogTests.swift */; }; 912347762216E27200BD9F97 /* GutenbergViewController+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912347752216E27200BD9F97 /* GutenbergViewController+Localization.swift */; }; 91D8364121946EFB008340B2 /* GutenbergMediaPickerHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91D8364021946EFB008340B2 /* GutenbergMediaPickerHelper.swift */; }; - 91DCE84421A6A7840062F134 /* PostEditor+BlogPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91DCE84321A6A7840062F134 /* PostEditor+BlogPicker.swift */; }; 91DCE84621A6A7F50062F134 /* PostEditor+MoreOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91DCE84521A6A7F50062F134 /* PostEditor+MoreOptions.swift */; }; 91DCE84821A6C58C0062F134 /* PostEditor+Publish.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91DCE84721A6C58C0062F134 /* PostEditor+Publish.swift */; }; 930F09171C7D110E00995926 /* ShareExtensionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930F09161C7D110E00995926 /* ShareExtensionService.swift */; }; 930F09191C7E1C1E00995926 /* ShareExtensionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930F09161C7D110E00995926 /* ShareExtensionService.swift */; }; - 931430201E68B9C50014B6C6 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E1C5B2131E54C28C00052319 /* Localizable.strings */; }; - 931D26F519ED7E6D00114F17 /* BlogJetpackTest.m in Sources */ = {isa = PBXBuildFile; fileRef = E150520B16CAC5C400D3DDDC /* BlogJetpackTest.m */; }; + 931215E1267DE1C0008C3B69 /* StatsTotalRowDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215E0267DE1C0008C3B69 /* StatsTotalRowDataTests.swift */; }; + 931215E4267F5003008C3B69 /* ReferrerDetailsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215E3267F5003008C3B69 /* ReferrerDetailsTableViewController.swift */; }; + 931215E6267F5192008C3B69 /* ReferrerDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215E5267F5192008C3B69 /* ReferrerDetailsViewModel.swift */; }; + 931215E8267F52A6008C3B69 /* ReferrerDetailsHeaderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215E7267F52A6008C3B69 /* ReferrerDetailsHeaderRow.swift */; }; + 931215EA267F59CB008C3B69 /* ReferrerDetailsHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215E9267F59CB008C3B69 /* ReferrerDetailsHeaderCell.swift */; }; + 931215EC267F5F45008C3B69 /* ReferrerDetailsRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215EB267F5F45008C3B69 /* ReferrerDetailsRow.swift */; }; + 931215EE267F6799008C3B69 /* ReferrerDetailsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215ED267F6799008C3B69 /* ReferrerDetailsCell.swift */; }; + 931215F2267FE162008C3B69 /* ReferrerDetailsSpamActionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215F1267FE162008C3B69 /* ReferrerDetailsSpamActionRow.swift */; }; + 931215F4267FE177008C3B69 /* ReferrerDetailsSpamActionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215F3267FE177008C3B69 /* ReferrerDetailsSpamActionCell.swift */; }; 931D26F619ED7F7000114F17 /* BlogServiceTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 930FD0A519882742000CC81D /* BlogServiceTest.m */; }; 931D26F719ED7F7500114F17 /* ReaderPostServiceTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DE8A0401912D95B00B2FF59 /* ReaderPostServiceTest.m */; }; - 931D26FE19EDA10D00114F17 /* ALIterativeMigrator.m in Sources */ = {isa = PBXBuildFile; fileRef = 931D26FD19EDA10D00114F17 /* ALIterativeMigrator.m */; }; 931D270019EDAE8600114F17 /* CoreDataMigrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 931D26FF19EDAE8600114F17 /* CoreDataMigrationTests.m */; }; 931DF4D618D09A2F00540BDD /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 931DF4D818D09A2F00540BDD /* InfoPlist.strings */; }; - 932225B11C7CE50300443B02 /* WordPressShareExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 932225A71C7CE50300443B02 /* WordPressShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 931F312C2714302A0075433B /* PublicizeServicesState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931F312B2714302A0075433B /* PublicizeServicesState.swift */; }; + 931F312D2714302A0075433B /* PublicizeServicesState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931F312B2714302A0075433B /* PublicizeServicesState.swift */; }; + 932225B11C7CE50300443B02 /* WordPressShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 932225A71C7CE50300443B02 /* WordPressShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 932645A41E7C206600134988 /* GutenbergSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 932645A31E7C206600134988 /* GutenbergSettingsTests.swift */; }; 933D1F471EA64108009FB462 /* TestingAppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 933D1F461EA64108009FB462 /* TestingAppDelegate.m */; }; 933D1F6C1EA7A3AB009FB462 /* TestingMode.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 933D1F6B1EA7A3AB009FB462 /* TestingMode.storyboard */; }; 933D1F6E1EA7A402009FB462 /* TestAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 933D1F6D1EA7A402009FB462 /* TestAssets.xcassets */; }; + 934098C02719577D00B3E77E /* InsightType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934098BF2719577D00B3E77E /* InsightType.swift */; }; + 934098C12719577D00B3E77E /* InsightType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934098BF2719577D00B3E77E /* InsightType.swift */; }; + 934098C3271957A600B3E77E /* SiteStatsInsightsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934098C2271957A600B3E77E /* SiteStatsInsightsDelegate.swift */; }; + 934098C4271957A600B3E77E /* SiteStatsInsightsDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934098C2271957A600B3E77E /* SiteStatsInsightsDelegate.swift */; }; 93414DE51E2D25AE003143A3 /* PostEditorState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93414DE41E2D25AE003143A3 /* PostEditorState.swift */; }; - 934884AB19B73BA6004028D8 /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; 93594BD5191D2F5A0079E6B2 /* stats-batch.json in Resources */ = {isa = PBXBuildFile; fileRef = 93594BD4191D2F5A0079E6B2 /* stats-batch.json */; }; 9363113F19FA996700B0C739 /* AccountServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9363113E19FA996700B0C739 /* AccountServiceTests.swift */; }; + 937250EE267A492D0086075F /* StatsPeriodStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937250ED267A492D0086075F /* StatsPeriodStoreTests.swift */; }; 937D9A0F19F83812007B9D5F /* WordPress-22-23.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 937D9A0E19F83812007B9D5F /* WordPress-22-23.xcmappingmodel */; }; 937D9A1119F838C2007B9D5F /* AccountToAccount22to23.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937D9A1019F838C2007B9D5F /* AccountToAccount22to23.swift */; }; 937E3AB61E3EBE1600CDA01A /* PostEditorStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937E3AB51E3EBE1600CDA01A /* PostEditorStateTests.swift */; }; 937F3E321AD6FDA7006BA498 /* WPAnalyticsTrackerAutomatticTracks.m in Sources */ = {isa = PBXBuildFile; fileRef = 937F3E311AD6FDA7006BA498 /* WPAnalyticsTrackerAutomatticTracks.m */; }; + 938466B92683CA0E00A538DC /* ReferrerDetailsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938466B82683CA0E00A538DC /* ReferrerDetailsViewModelTests.swift */; }; 938CF3DC1EF1BE6800AF838E /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; 938CF3DD1EF1BE7F00AF838E /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; - 938CF3DE1EF1BE8000AF838E /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; 93A379EC19FFBF7900415023 /* KeychainTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 93A379EB19FFBF7900415023 /* KeychainTest.m */; }; 93A3F7DE1843F6F00082FEEA /* CoreTelephony.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93A3F7DD1843F6F00082FEEA /* CoreTelephony.framework */; }; 93B853231B4416A30064FE72 /* WPAnalyticsTrackerAutomatticTracksTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 93B853221B4416A30064FE72 /* WPAnalyticsTrackerAutomatticTracksTests.m */; }; 93C1147F18EC5DD500DAC95C /* AccountService.m in Sources */ = {isa = PBXBuildFile; fileRef = 93C1147E18EC5DD500DAC95C /* AccountService.m */; }; 93C1148518EDF6E100DAC95C /* BlogService.m in Sources */ = {isa = PBXBuildFile; fileRef = 93C1148418EDF6E100DAC95C /* BlogService.m */; }; - 93C2075A1CC7FF9C00C94D04 /* Tracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA22821C99F6180016CA7C /* Tracks.swift */; }; - 93C2075D1CC7FFC800C94D04 /* Tracks+TodayWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C2075C1CC7FFC800C94D04 /* Tracks+TodayWidget.swift */; }; 93C4864F181043D700A24725 /* ActivityLogDetailViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 93069F581762410B000C966D /* ActivityLogDetailViewController.m */; }; 93C486511810445D00A24725 /* ActivityLogViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 93069F55176237A4000C966D /* ActivityLogViewController.m */; }; 93C882A11EEB18D700227A59 /* html_page_with_link_to_rsd_non_standard.html in Resources */ = {isa = PBXBuildFile; fileRef = 93C882981EEB18D700227A59 /* html_page_with_link_to_rsd_non_standard.html */; }; @@ -1109,26 +2257,37 @@ 93C882A31EEB18D700227A59 /* plugin_redirect.html in Resources */ = {isa = PBXBuildFile; fileRef = 93C8829A1EEB18D700227A59 /* plugin_redirect.html */; }; 93C882A41EEB18D700227A59 /* rsd.xml in Resources */ = {isa = PBXBuildFile; fileRef = 93C8829B1EEB18D700227A59 /* rsd.xml */; }; 93CD939319099BE70049096E /* authtoken.json in Resources */ = {isa = PBXBuildFile; fileRef = 93CD939219099BE70049096E /* authtoken.json */; }; + 93CDC72126CD342900C8A3A8 /* DestructiveAlertHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CDC72026CD342900C8A3A8 /* DestructiveAlertHelper.swift */; }; + 93CDC72226CD342900C8A3A8 /* DestructiveAlertHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CDC72026CD342900C8A3A8 /* DestructiveAlertHelper.swift */; }; 93D86B981C691E71003D8E3E /* LocalCoreDataServiceTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 59A9AB391B4C3ECD00A433DC /* LocalCoreDataServiceTests.m */; }; 93DEB88219E5BF7100F9546D /* TodayExtensionService.m in Sources */ = {isa = PBXBuildFile; fileRef = 93DEB88119E5BF7100F9546D /* TodayExtensionService.m */; }; - 93E3D3C819ACE8E300B1C509 /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; - 93E5283C19A7741A003A1A9C /* NotificationCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93E5283B19A7741A003A1A9C /* NotificationCenter.framework */; }; - 93E5284119A7741A003A1A9C /* TodayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E5284019A7741A003A1A9C /* TodayViewController.swift */; }; - 93E5284319A7741A003A1A9C /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 93E5284219A7741A003A1A9C /* MainInterface.storyboard */; }; - 93E5284619A7741A003A1A9C /* WordPressTodayWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 93E5283A19A7741A003A1A9C /* WordPressTodayWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 93E5285619A77BAC003A1A9C /* NotificationCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93E5283B19A7741A003A1A9C /* NotificationCenter.framework */; }; - 93E9050719E6F3D8005513C9 /* TestContextManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 93E9050619E6F3D8005513C9 /* TestContextManager.m */; }; + 93E63369272AC532009DACF8 /* LoginEpilogueChooseSiteTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E63368272AC532009DACF8 /* LoginEpilogueChooseSiteTableViewCell.swift */; }; + 93E6336A272AC532009DACF8 /* LoginEpilogueChooseSiteTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E63368272AC532009DACF8 /* LoginEpilogueChooseSiteTableViewCell.swift */; }; + 93E6336C272AF504009DACF8 /* LoginEpilogueDividerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E6336B272AF504009DACF8 /* LoginEpilogueDividerView.swift */; }; + 93E6336D272AF504009DACF8 /* LoginEpilogueDividerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E6336B272AF504009DACF8 /* LoginEpilogueDividerView.swift */; }; + 93E6336F272C1074009DACF8 /* LoginEpilogueCreateNewSiteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E6336E272C1074009DACF8 /* LoginEpilogueCreateNewSiteCell.swift */; }; + 93E63370272C1074009DACF8 /* LoginEpilogueCreateNewSiteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E6336E272C1074009DACF8 /* LoginEpilogueCreateNewSiteCell.swift */; }; 93EF094C19ED533500C89770 /* ContextManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93E9050319E6F242005513C9 /* ContextManagerTests.swift */; }; 93F2E53E1E9E5A010050D489 /* CoreText.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E131CB5116CACA6B004B0314 /* CoreText.framework */; }; 93F2E5401E9E5A180050D489 /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F2E53F1E9E5A180050D489 /* libsqlite3.tbd */; }; 93F2E5421E9E5A350050D489 /* QuickLook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F2E5411E9E5A350050D489 /* QuickLook.framework */; }; 93F2E5441E9E5A570050D489 /* CoreSpotlight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F2E5431E9E5A570050D489 /* CoreSpotlight.framework */; }; + 93F7214F271831820021A09F /* SiteStatsPinnedItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93F7214E271831820021A09F /* SiteStatsPinnedItemStore.swift */; }; + 93F72150271831820021A09F /* SiteStatsPinnedItemStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93F7214E271831820021A09F /* SiteStatsPinnedItemStore.swift */; }; 93FA59DD18D88C1C001446BC /* PostCategoryService.m in Sources */ = {isa = PBXBuildFile; fileRef = 93FA59DC18D88C1C001446BC /* PostCategoryService.m */; }; + 9801E682274EEBC4002FDDB6 /* ReaderDetailCommentsHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9801E681274EEBC4002FDDB6 /* ReaderDetailCommentsHeader.swift */; }; + 9801E683274EEBC4002FDDB6 /* ReaderDetailCommentsHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9801E681274EEBC4002FDDB6 /* ReaderDetailCommentsHeader.swift */; }; + 9801E685274EEC19002FDDB6 /* ReaderDetailCommentsHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9801E684274EEC19002FDDB6 /* ReaderDetailCommentsHeader.xib */; }; + 9801E686274EEC19002FDDB6 /* ReaderDetailCommentsHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9801E684274EEC19002FDDB6 /* ReaderDetailCommentsHeader.xib */; }; + 9804A097263780B500354097 /* LikeUserHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9804A096263780B400354097 /* LikeUserHelpers.swift */; }; + 9804A098263780B500354097 /* LikeUserHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9804A096263780B400354097 /* LikeUserHelpers.swift */; }; 98077B5A2075561800109F95 /* SupportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98077B592075561800109F95 /* SupportTableViewController.swift */; }; 9808655A203D075E00D58786 /* EpilogueUserInfoCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98086559203D075D00D58786 /* EpilogueUserInfoCell.xib */; }; 9808655C203D079B00D58786 /* EpilogueUserInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9808655B203D079A00D58786 /* EpilogueUserInfoCell.swift */; }; - 980D3B1B23C925F40060A890 /* WidgetStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985ED0E323C6950600B8D06A /* WidgetStyles.swift */; }; 9813512E22F0FC2700F7425D /* FileDownloadsStatsRecordValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9813512D22F0FC2700F7425D /* FileDownloadsStatsRecordValueTests.swift */; }; + 9815D0B326B49A0600DF7226 /* Comment+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9815D0B226B49A0600DF7226 /* Comment+CoreDataProperties.swift */; }; + 9815D0B426B49A0600DF7226 /* Comment+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9815D0B226B49A0600DF7226 /* Comment+CoreDataProperties.swift */; }; 981676D6221B7A4300B81C3F /* CountriesCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981676D4221B7A4300B81C3F /* CountriesCell.swift */; }; 981676D7221B7A4300B81C3F /* CountriesCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 981676D5221B7A4300B81C3F /* CountriesCell.xib */; }; 981C34912183871200FC2683 /* SiteStatsDashboard.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 981C348F2183871100FC2683 /* SiteStatsDashboard.storyboard */; }; @@ -1138,6 +2297,11 @@ 981C986B21B9BF6000A7C0C8 /* PostingActivityViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 981C986A21B9BF6000A7C0C8 /* PostingActivityViewController.storyboard */; }; 981C986E21B9D71400A7C0C8 /* PostingActivityCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C986C21B9D71400A7C0C8 /* PostingActivityCollectionViewCell.swift */; }; 981D092A211259840014ECAF /* NoResultsViewController+MediaLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981D0929211259840014ECAF /* NoResultsViewController+MediaLibrary.swift */; }; + 981D464825B0D4E7000AA65C /* ReaderSeenAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981D464725B0D4E7000AA65C /* ReaderSeenAction.swift */; }; + 9822A8412624CFB900FD8A03 /* UserProfileSiteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9822A8402624CFB900FD8A03 /* UserProfileSiteCell.swift */; }; + 9822A8422624CFB900FD8A03 /* UserProfileSiteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9822A8402624CFB900FD8A03 /* UserProfileSiteCell.swift */; }; + 9822A8552624D01800FD8A03 /* UserProfileSiteCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9822A8542624D01800FD8A03 /* UserProfileSiteCell.xib */; }; + 9822A8562624D01800FD8A03 /* UserProfileSiteCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9822A8542624D01800FD8A03 /* UserProfileSiteCell.xib */; }; 9822D8DD214194EB0092CBD1 /* NoResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982A4C3420227D6700B5518E /* NoResultsViewController.swift */; }; 9822D8DE214194EB0092CBD1 /* NoResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982A4C3420227D6700B5518E /* NoResultsViewController.swift */; }; 9826AE8221B5C6A700C851FA /* LatestPostSummaryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9826AE8021B5C6A700C851FA /* LatestPostSummaryCell.swift */; }; @@ -1150,10 +2314,28 @@ 9826AE9121B5D3CD00C851FA /* PostingActivityCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9826AE8F21B5D3CD00C851FA /* PostingActivityCell.xib */; }; 9829162F2224BC1C008736C0 /* SiteStatsDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9829162E2224BC1C008736C0 /* SiteStatsDetailsViewModel.swift */; }; 982A4C3520227D6700B5518E /* NoResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982A4C3420227D6700B5518E /* NoResultsViewController.swift */; }; - 983002A822FA05D600F03DBB /* AddInsightTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983002A722FA05D600F03DBB /* AddInsightTableViewController.swift */; }; - 983AE84C2399AC5B00E5B7F6 /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; - 983AE84D2399AC6B00E5B7F6 /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; - 983AE8512399B19200E5B7F6 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 983AE84F2399B19200E5B7F6 /* Localizable.strings */; }; + 982D261F2788DDF200A41286 /* ReaderCommentsFollowPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982D261E2788DDF200A41286 /* ReaderCommentsFollowPresenter.swift */; }; + 982D26202788DDF200A41286 /* ReaderCommentsFollowPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982D261E2788DDF200A41286 /* ReaderCommentsFollowPresenter.swift */; }; + 982D99FE26F922C100AA794C /* InlineEditableMultiLineCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982D99FC26F922C100AA794C /* InlineEditableMultiLineCell.swift */; }; + 982D99FF26F922C100AA794C /* InlineEditableMultiLineCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982D99FC26F922C100AA794C /* InlineEditableMultiLineCell.swift */; }; + 982D9A0026F922C100AA794C /* InlineEditableMultiLineCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 982D99FD26F922C100AA794C /* InlineEditableMultiLineCell.xib */; }; + 982D9A0126F922C100AA794C /* InlineEditableMultiLineCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 982D99FD26F922C100AA794C /* InlineEditableMultiLineCell.xib */; }; + 982DA9A7263B1E2F00E5743B /* CommentService+Likes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982DA9A6263B1E2F00E5743B /* CommentService+Likes.swift */; }; + 982DA9A8263B1E2F00E5743B /* CommentService+Likes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982DA9A6263B1E2F00E5743B /* CommentService+Likes.swift */; }; + 982DDF90263238A6002B3904 /* LikeUser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982DDF8C263238A6002B3904 /* LikeUser+CoreDataClass.swift */; }; + 982DDF91263238A6002B3904 /* LikeUser+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982DDF8C263238A6002B3904 /* LikeUser+CoreDataClass.swift */; }; + 982DDF92263238A6002B3904 /* LikeUser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982DDF8D263238A6002B3904 /* LikeUser+CoreDataProperties.swift */; }; + 982DDF93263238A6002B3904 /* LikeUser+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982DDF8D263238A6002B3904 /* LikeUser+CoreDataProperties.swift */; }; + 982DDF94263238A6002B3904 /* LikeUserPreferredBlog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982DDF8E263238A6002B3904 /* LikeUserPreferredBlog+CoreDataClass.swift */; }; + 982DDF95263238A6002B3904 /* LikeUserPreferredBlog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982DDF8E263238A6002B3904 /* LikeUserPreferredBlog+CoreDataClass.swift */; }; + 982DDF96263238A6002B3904 /* LikeUserPreferredBlog+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982DDF8F263238A6002B3904 /* LikeUserPreferredBlog+CoreDataProperties.swift */; }; + 982DDF97263238A6002B3904 /* LikeUserPreferredBlog+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982DDF8F263238A6002B3904 /* LikeUserPreferredBlog+CoreDataProperties.swift */; }; + 983002A822FA05D600F03DBB /* InsightsManagementViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983002A722FA05D600F03DBB /* InsightsManagementViewController.swift */; }; + 9835F16E25E492EE002EFF23 /* CommentsList.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9835F16D25E492EE002EFF23 /* CommentsList.storyboard */; }; + 98390AC3254C984700868F0A /* Tracks+StatsWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98390AC2254C984700868F0A /* Tracks+StatsWidgets.swift */; }; + 98390AD2254C985F00868F0A /* Tracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA22821C99F6180016CA7C /* Tracks.swift */; }; + 9839CEBB26FAA0530097406E /* CommentModerationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9839CEB926FAA0510097406E /* CommentModerationBar.swift */; }; + 9839CEBC26FAA0530097406E /* CommentModerationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9839CEB926FAA0510097406E /* CommentModerationBar.swift */; }; 983DBBAA22125DD500753988 /* StatsTableFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = 983DBBA822125DD300753988 /* StatsTableFooter.xib */; }; 983DBBAB22125DD500753988 /* StatsTableFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983DBBA922125DD300753988 /* StatsTableFooter.swift */; }; 98458CB821A39D350025D232 /* StatsNoDataRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98458CB721A39D350025D232 /* StatsNoDataRow.swift */; }; @@ -1167,19 +2349,26 @@ 984B139221F66AC60004B6A2 /* SiteStatsPeriodViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984B139121F66AC50004B6A2 /* SiteStatsPeriodViewModel.swift */; }; 984B139421F66B2D0004B6A2 /* StatsPeriodStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984B139321F66B2D0004B6A2 /* StatsPeriodStore.swift */; }; 984B4EF320742FCC00F87888 /* ZendeskUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984B4EF220742FCC00F87888 /* ZendeskUtils.swift */; }; - 984BE91523CE72D600B37D90 /* Double+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C82B52193A7B900A06E84 /* Double+Stats.swift */; }; 984F86FB21DEDB070070E0E3 /* TopTotalsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984F86FA21DEDB060070E0E3 /* TopTotalsCell.swift */; }; + 98517E5928220411001FFD45 /* BloggingPromptTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98517E5828220411001FFD45 /* BloggingPromptTableViewCell.swift */; }; + 98517E5A28220411001FFD45 /* BloggingPromptTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98517E5828220411001FFD45 /* BloggingPromptTableViewCell.swift */; }; + 98517E5C28220475001FFD45 /* BloggingPromptTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98517E5B28220474001FFD45 /* BloggingPromptTableViewCell.xib */; }; + 98517E5D28220475001FFD45 /* BloggingPromptTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98517E5B28220474001FFD45 /* BloggingPromptTableViewCell.xib */; }; 98563DDD21BF30C40006F5E9 /* TabbedTotalsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98563DDB21BF30C40006F5E9 /* TabbedTotalsCell.swift */; }; 98563DDE21BF30C40006F5E9 /* TabbedTotalsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98563DDC21BF30C40006F5E9 /* TabbedTotalsCell.xib */; }; + 9856A389261FC206008D6354 /* UserProfileUserInfoCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9856A388261FC206008D6354 /* UserProfileUserInfoCell.xib */; }; + 9856A38A261FC206008D6354 /* UserProfileUserInfoCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9856A388261FC206008D6354 /* UserProfileUserInfoCell.xib */; }; + 9856A39D261FC21E008D6354 /* UserProfileUserInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9856A39C261FC21E008D6354 /* UserProfileUserInfoCell.swift */; }; + 9856A39E261FC21E008D6354 /* UserProfileUserInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9856A39C261FC21E008D6354 /* UserProfileUserInfoCell.swift */; }; + 9856A3E4261FD27A008D6354 /* UserProfileSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9856A3E3261FD27A008D6354 /* UserProfileSectionHeader.swift */; }; + 9856A3E5261FD27A008D6354 /* UserProfileSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9856A3E3261FD27A008D6354 /* UserProfileSectionHeader.swift */; }; 985793C822F23D7000643DBF /* CustomizeInsightsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985793C622F23D7000643DBF /* CustomizeInsightsCell.swift */; }; 985793C922F23D7000643DBF /* CustomizeInsightsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 985793C722F23D7000643DBF /* CustomizeInsightsCell.xib */; }; 98579BC7203DD86F004086E4 /* EpilogueSectionHeaderFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98579BC5203DD86D004086E4 /* EpilogueSectionHeaderFooter.swift */; }; 98579BC8203DD86F004086E4 /* EpilogueSectionHeaderFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98579BC6203DD86E004086E4 /* EpilogueSectionHeaderFooter.xib */; }; - 985ED0E223C686CA00B8D06A /* ColorPalette.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 435B76292297484200511813 /* ColorPalette.xcassets */; }; 985ED0E423C6950600B8D06A /* WidgetStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985ED0E323C6950600B8D06A /* WidgetStyles.swift */; }; - 985ED0E723C6964500B8D06A /* WidgetStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985ED0E323C6950600B8D06A /* WidgetStyles.swift */; }; - 985ED0E823C6964600B8D06A /* WidgetStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985ED0E323C6950600B8D06A /* WidgetStyles.swift */; }; - 985F06B52303866200949733 /* WelcomeScreenLoginComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985F06B42303866200949733 /* WelcomeScreenLoginComponent.swift */; }; + 98622E9F274C59A400061A5F /* ReaderDetailCommentsTableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98622E9E274C59A400061A5F /* ReaderDetailCommentsTableViewDelegate.swift */; }; + 98622EA0274C59A400061A5F /* ReaderDetailCommentsTableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98622E9E274C59A400061A5F /* ReaderDetailCommentsTableViewDelegate.swift */; }; 9865257D2194D77F0078B916 /* SiteStatsInsightsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9865257C2194D77E0078B916 /* SiteStatsInsightsViewModel.swift */; }; 98656BD82037A1770079DE67 /* SignupEpilogueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98656BD72037A1770079DE67 /* SignupEpilogueViewController.swift */; }; 986C908422319EFF00FC31E1 /* PostStatsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 986C908322319EFF00FC31E1 /* PostStatsTableViewController.swift */; }; @@ -1193,14 +2382,6 @@ 986FF29E2141971D005B28EC /* WPAnimatedBox.m in Sources */ = {isa = PBXBuildFile; fileRef = E240859B183D82AE002EB0EF /* WPAnimatedBox.m */; }; 986FF29F214198D9005B28EC /* String+Ranges.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55FFCF91F034F1A0070812C /* String+Ranges.swift */; }; 986FF2A0214198D9005B28EC /* String+Ranges.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55FFCF91F034F1A0070812C /* String+Ranges.swift */; }; - 98712D1B23DA1C7E00555316 /* WidgetNoConnectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98712D1923DA1C7E00555316 /* WidgetNoConnectionCell.swift */; }; - 98712D1C23DA1C7E00555316 /* WidgetNoConnectionCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98712D1A23DA1C7E00555316 /* WidgetNoConnectionCell.xib */; }; - 98712D1D23DA1D0800555316 /* WidgetNoConnectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98712D1923DA1C7E00555316 /* WidgetNoConnectionCell.swift */; }; - 98712D1E23DA1D0900555316 /* WidgetNoConnectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98712D1923DA1C7E00555316 /* WidgetNoConnectionCell.swift */; }; - 98712D1F23DA1D0A00555316 /* WidgetNoConnectionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98712D1923DA1C7E00555316 /* WidgetNoConnectionCell.swift */; }; - 98712D2023DA1D1000555316 /* WidgetNoConnectionCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98712D1A23DA1C7E00555316 /* WidgetNoConnectionCell.xib */; }; - 98712D2123DA1D1100555316 /* WidgetNoConnectionCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98712D1A23DA1C7E00555316 /* WidgetNoConnectionCell.xib */; }; - 98712D2223DA1D1200555316 /* WidgetNoConnectionCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98712D1A23DA1C7E00555316 /* WidgetNoConnectionCell.xib */; }; 9872CB30203B8A730066A293 /* SignupEpilogueTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9872CB2F203B8A730066A293 /* SignupEpilogueTableViewController.swift */; }; 9874766F219630240080967F /* SiteStatsTableViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9874766E219630240080967F /* SiteStatsTableViewCells.swift */; }; 9874767321963D330080967F /* SiteStatsInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9874767221963D320080967F /* SiteStatsInformation.swift */; }; @@ -1208,57 +2389,38 @@ 987535642282682D001661B4 /* DetailDataCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 987535622282682D001661B4 /* DetailDataCell.xib */; }; 98797DBC222F434500128C21 /* OverviewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98797DBA222F434500128C21 /* OverviewCell.swift */; }; 98797DBD222F434500128C21 /* OverviewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98797DBB222F434500128C21 /* OverviewCell.xib */; }; - 9880150623A840200003BD11 /* ColorPalette.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 435B76292297484200511813 /* ColorPalette.xcassets */; }; + 987C40C625E590BE002A0955 /* CommentsViewController+Filters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987C40C525E590BE002A0955 /* CommentsViewController+Filters.swift */; }; + 987E79CB261F8858000192B7 /* UserProfileSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987E79CA261F8857000192B7 /* UserProfileSheetViewController.swift */; }; + 987E79CC261F8858000192B7 /* UserProfileSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987E79CA261F8857000192B7 /* UserProfileSheetViewController.swift */; }; 988056032183CCE50083B643 /* SiteStatsInsightsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988056022183CCE50083B643 /* SiteStatsInsightsTableViewController.swift */; }; 98812966219CE42A0075FF33 /* StatsTotalRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98812964219CE42A0075FF33 /* StatsTotalRow.swift */; }; 98812967219CE42A0075FF33 /* StatsTotalRow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98812965219CE42A0075FF33 /* StatsTotalRow.xib */; }; 9881296E219CF1310075FF33 /* StatsCellHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9881296C219CF1300075FF33 /* StatsCellHeader.swift */; }; 9881296F219CF1310075FF33 /* StatsCellHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9881296D219CF1310075FF33 /* StatsCellHeader.xib */; }; + 98830A922747043B0061A87C /* BorderedButtonTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98830A912747043B0061A87C /* BorderedButtonTableViewCell.swift */; }; + 98830A932747043B0061A87C /* BorderedButtonTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98830A912747043B0061A87C /* BorderedButtonTableViewCell.swift */; }; + 9887560C2810BA7A00AD7589 /* BloggingPromptsIntroductionPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9887560B2810BA7A00AD7589 /* BloggingPromptsIntroductionPresenter.swift */; }; + 9887560D2810BA7A00AD7589 /* BloggingPromptsIntroductionPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9887560B2810BA7A00AD7589 /* BloggingPromptsIntroductionPresenter.swift */; }; 98880A4A22B2E5E400464538 /* TwoColumnCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98880A4822B2E5E400464538 /* TwoColumnCell.swift */; }; 98880A4B22B2E5E400464538 /* TwoColumnCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98880A4922B2E5E400464538 /* TwoColumnCell.xib */; }; 988AC37522F10DD900BC1433 /* FileDownloadsStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988AC37422F10DD900BC1433 /* FileDownloadsStatsRecordValue+CoreDataProperties.swift */; }; 988AC37922F10E2C00BC1433 /* FileDownloadsStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988AC37822F10E2C00BC1433 /* FileDownloadsStatsRecordValue+CoreDataClass.swift */; }; - 988F073523D0CE8800AC67A6 /* WidgetUrlCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988F073323D0CE8800AC67A6 /* WidgetUrlCell.swift */; }; - 988F073623D0CE8800AC67A6 /* WidgetUrlCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 988F073423D0CE8800AC67A6 /* WidgetUrlCell.xib */; }; - 988F073723D0D15D00AC67A6 /* WidgetUrlCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 988F073423D0CE8800AC67A6 /* WidgetUrlCell.xib */; }; - 988F073823D0D15D00AC67A6 /* WidgetUrlCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 988F073423D0CE8800AC67A6 /* WidgetUrlCell.xib */; }; - 988F073923D0D15E00AC67A6 /* WidgetUrlCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 988F073423D0CE8800AC67A6 /* WidgetUrlCell.xib */; }; - 988F073A23D0D16700AC67A6 /* WidgetUrlCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988F073323D0CE8800AC67A6 /* WidgetUrlCell.swift */; }; - 988F073B23D0D16800AC67A6 /* WidgetUrlCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988F073323D0CE8800AC67A6 /* WidgetUrlCell.swift */; }; - 988F073C23D0D16800AC67A6 /* WidgetUrlCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988F073323D0CE8800AC67A6 /* WidgetUrlCell.swift */; }; - 98906502237CC1DF00218CD2 /* WidgetUnconfiguredCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989064FB237CC1DE00218CD2 /* WidgetUnconfiguredCell.swift */; }; - 98906503237CC1DF00218CD2 /* WidgetUnconfiguredCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989064FB237CC1DE00218CD2 /* WidgetUnconfiguredCell.swift */; }; - 98906504237CC1DF00218CD2 /* WidgetUnconfiguredCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 989064FC237CC1DE00218CD2 /* WidgetUnconfiguredCell.xib */; }; - 98906505237CC1DF00218CD2 /* WidgetUnconfiguredCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 989064FC237CC1DE00218CD2 /* WidgetUnconfiguredCell.xib */; }; - 98906506237CC1DF00218CD2 /* WidgetTwoColumnCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989064FD237CC1DE00218CD2 /* WidgetTwoColumnCell.swift */; }; - 98906507237CC1DF00218CD2 /* WidgetTwoColumnCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989064FD237CC1DE00218CD2 /* WidgetTwoColumnCell.swift */; }; - 98906508237CC1DF00218CD2 /* WidgetTwoColumnCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 989064FE237CC1DE00218CD2 /* WidgetTwoColumnCell.xib */; }; - 98906509237CC1DF00218CD2 /* WidgetTwoColumnCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 989064FE237CC1DE00218CD2 /* WidgetTwoColumnCell.xib */; }; + 988FD74A279B75A400C7E814 /* NotificationCommentDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988FD749279B75A400C7E814 /* NotificationCommentDetailCoordinator.swift */; }; + 988FD74B279B75A400C7E814 /* NotificationCommentDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988FD749279B75A400C7E814 /* NotificationCommentDetailCoordinator.swift */; }; 98921EF721372E12004949AA /* MediaCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98921EF621372E12004949AA /* MediaCoordinator.swift */; }; - 98921EF921372E30004949AA /* LocalNewsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98921EF821372E30004949AA /* LocalNewsService.swift */; }; - 98921EFB21372E57004949AA /* NewsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98921EFA21372E57004949AA /* NewsService.swift */; }; + 9895401126C1F39300EDEB5A /* EditCommentTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9895401026C1F39300EDEB5A /* EditCommentTableViewController.swift */; }; + 9895401226C1F39300EDEB5A /* EditCommentTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9895401026C1F39300EDEB5A /* EditCommentTableViewController.swift */; }; 9895B6E021ED49160053D370 /* TopTotalsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9895B6DF21ED49160053D370 /* TopTotalsCell.xib */; }; - 989643E223A02F080070720A /* WidgetUnconfiguredCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989064FB237CC1DE00218CD2 /* WidgetUnconfiguredCell.swift */; }; - 989643E423A02F4E0070720A /* UIColor+MurielColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435B762122973D0600511813 /* UIColor+MurielColors.swift */; }; - 989643E523A02F640070720A /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; - 989643E823A031CD0070720A /* WidgetUnconfiguredCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 989064FC237CC1DE00218CD2 /* WidgetUnconfiguredCell.xib */; }; - 989643EC23A0437B0070720A /* WidgetDifferenceCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989643EA23A0437B0070720A /* WidgetDifferenceCell.swift */; }; - 989643ED23A0437B0070720A /* WidgetDifferenceCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989643EA23A0437B0070720A /* WidgetDifferenceCell.swift */; }; - 989643EE23A0437B0070720A /* WidgetDifferenceCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 989643EB23A0437B0070720A /* WidgetDifferenceCell.xib */; }; - 989643EF23A0437B0070720A /* WidgetDifferenceCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 989643EB23A0437B0070720A /* WidgetDifferenceCell.xib */; }; + 98A047722821CEBF001B4E2D /* BloggingPromptsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98A047712821CEBF001B4E2D /* BloggingPromptsViewController.swift */; }; + 98A047732821CEBF001B4E2D /* BloggingPromptsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98A047712821CEBF001B4E2D /* BloggingPromptsViewController.swift */; }; + 98A047752821D069001B4E2D /* BloggingPromptsViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 98A047742821D069001B4E2D /* BloggingPromptsViewController.storyboard */; }; + 98A047762821D069001B4E2D /* BloggingPromptsViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 98A047742821D069001B4E2D /* BloggingPromptsViewController.storyboard */; }; 98A25BD1203CB25F006A5807 /* SignupEpilogueCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98A25BD0203CB25F006A5807 /* SignupEpilogueCell.swift */; }; 98A25BD3203CB278006A5807 /* SignupEpilogueCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98A25BD2203CB278006A5807 /* SignupEpilogueCell.xib */; }; - 98A3C2F0239997DA0048D38D /* NotificationCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93E5283B19A7741A003A1A9C /* NotificationCenter.framework */; }; - 98A3C2FA239997DA0048D38D /* WordPressThisWeekWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 98A3C2EF239997DA0048D38D /* WordPressThisWeekWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 98A3C3022399A19F0048D38D /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 98A3C3002399A1850048D38D /* MainInterface.storyboard */; }; - 98A3C3052399A2370048D38D /* ThisWeekViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98A3C3042399A2370048D38D /* ThisWeekViewController.swift */; }; - 98A437AE20069098004A8A57 /* DomainSuggestionsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98A437AD20069097004A8A57 /* DomainSuggestionsTableViewController.swift */; }; - 98A6B98F2398807F0031AEBD /* WidgetTwoColumnCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989064FD237CC1DE00218CD2 /* WidgetTwoColumnCell.swift */; }; - 98A6B9902398808B0031AEBD /* WidgetUnconfiguredCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989064FB237CC1DE00218CD2 /* WidgetUnconfiguredCell.swift */; }; - 98A6B992239881630031AEBD /* UIColor+MurielColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435B762122973D0600511813 /* UIColor+MurielColors.swift */; }; - 98A6B993239881860031AEBD /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; - 98A6B9942398821B0031AEBD /* WidgetTwoColumnCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 989064FE237CC1DE00218CD2 /* WidgetTwoColumnCell.xib */; }; - 98A6B996239882350031AEBD /* WidgetUnconfiguredCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 989064FC237CC1DE00218CD2 /* WidgetUnconfiguredCell.xib */; }; + 98AA6D1126B8CE7200920C8B /* Comment+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AA6D1026B8CE7200920C8B /* Comment+CoreDataClass.swift */; }; + 98AA6D1226B8CE7200920C8B /* Comment+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AA6D1026B8CE7200920C8B /* Comment+CoreDataClass.swift */; }; + 98AA9F2127EA890800B3A98C /* FeatureIntroductionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AA9F2027EA890800B3A98C /* FeatureIntroductionViewController.swift */; }; + 98AA9F2227EA890800B3A98C /* FeatureIntroductionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AA9F2027EA890800B3A98C /* FeatureIntroductionViewController.swift */; }; 98AE3DF5219A1789003C0E24 /* StatsInsightsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AE3DF4219A1788003C0E24 /* StatsInsightsStore.swift */; }; 98B11B892216535100B7F2D7 /* StatsChildRowsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B11B882216535100B7F2D7 /* StatsChildRowsView.swift */; }; 98B11B8B2216536C00B7F2D7 /* StatsChildRowsView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98B11B8A2216536C00B7F2D7 /* StatsChildRowsView.xib */; }; @@ -1266,36 +2428,49 @@ 98B3FA8421C05BDC00148DD4 /* ViewMoreRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B3FA8321C05BDC00148DD4 /* ViewMoreRow.swift */; }; 98B3FA8621C05BF000148DD4 /* ViewMoreRow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98B3FA8521C05BF000148DD4 /* ViewMoreRow.xib */; }; 98B52AE121F7AF4A006FF6B4 /* StatsDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B52AE021F7AF4A006FF6B4 /* StatsDataHelper.swift */; }; + 98B88452261E4E09007ED7F8 /* LikeUserTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B88451261E4E09007ED7F8 /* LikeUserTableViewCell.swift */; }; + 98B88453261E4E09007ED7F8 /* LikeUserTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B88451261E4E09007ED7F8 /* LikeUserTableViewCell.swift */; }; + 98B88466261E4E4E007ED7F8 /* LikeUserTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98B88465261E4E4E007ED7F8 /* LikeUserTableViewCell.xib */; }; + 98B88467261E4E4E007ED7F8 /* LikeUserTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98B88465261E4E4E007ED7F8 /* LikeUserTableViewCell.xib */; }; + 98BAA7C126F925F70073A2F9 /* InlineEditableSingleLineCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98BAA7BF26F925F60073A2F9 /* InlineEditableSingleLineCell.xib */; }; + 98BAA7C226F925F70073A2F9 /* InlineEditableSingleLineCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98BAA7BF26F925F60073A2F9 /* InlineEditableSingleLineCell.xib */; }; + 98BAA7C326F925F70073A2F9 /* InlineEditableSingleLineCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BAA7C026F925F70073A2F9 /* InlineEditableSingleLineCell.swift */; }; + 98BAA7C426F925F70073A2F9 /* InlineEditableSingleLineCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BAA7C026F925F70073A2F9 /* InlineEditableSingleLineCell.swift */; }; + 98BC522427F6245700D6E8C2 /* BloggingPromptsFeatureIntroduction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BC522327F6245700D6E8C2 /* BloggingPromptsFeatureIntroduction.swift */; }; + 98BC522527F6245700D6E8C2 /* BloggingPromptsFeatureIntroduction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BC522327F6245700D6E8C2 /* BloggingPromptsFeatureIntroduction.swift */; }; + 98BC522A27F6259700D6E8C2 /* BloggingPromptsFeatureDescriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BC522927F6259700D6E8C2 /* BloggingPromptsFeatureDescriptionView.swift */; }; + 98BC522B27F6259700D6E8C2 /* BloggingPromptsFeatureDescriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BC522927F6259700D6E8C2 /* BloggingPromptsFeatureDescriptionView.swift */; }; + 98BC522D27F62B6300D6E8C2 /* BloggingPromptsFeatureDescriptionView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98BC522C27F62B6300D6E8C2 /* BloggingPromptsFeatureDescriptionView.xib */; }; + 98BC522E27F62B6300D6E8C2 /* BloggingPromptsFeatureDescriptionView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98BC522C27F62B6300D6E8C2 /* BloggingPromptsFeatureDescriptionView.xib */; }; 98BDFF6B20D0732900C72C58 /* SupportTableViewController+Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BDFF6A20D0732900C72C58 /* SupportTableViewController+Activity.swift */; }; - 98BFF57C2398406A008A1DCB /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; 98BFF57E23984345008A1DCB /* AllTimeWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFF57D23984344008A1DCB /* AllTimeWidgetStats.swift */; }; - 98BFF57F23984345008A1DCB /* AllTimeWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFF57D23984344008A1DCB /* AllTimeWidgetStats.swift */; }; - 98C0CE9B23C559C800D0F27C /* Double+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C82B52193A7B900A06E84 /* Double+Stats.swift */; }; - 98C0CE9C23C559C800D0F27C /* Double+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C82B52193A7B900A06E84 /* Double+Stats.swift */; }; 98CAD296221B4ED2003E8F45 /* StatSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98CAD295221B4ED1003E8F45 /* StatSection.swift */; }; - 98D31B8F2396ED7F009CFF43 /* NotificationCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93E5283B19A7741A003A1A9C /* NotificationCenter.framework */; }; - 98D31B992396ED7F009CFF43 /* WordPressAllTimeWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 98D31B8E2396ED7E009CFF43 /* WordPressAllTimeWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 98D31BA72396F7E2009CFF43 /* AllTimeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D31BA52396F7BF009CFF43 /* AllTimeViewController.swift */; }; - 98D31BAC23970078009CFF43 /* Tracks+AllTimeWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D31BAB23970078009CFF43 /* Tracks+AllTimeWidget.swift */; }; - 98D31BAD2397015C009CFF43 /* Tracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA22821C99F6180016CA7C /* Tracks.swift */; }; - 98D31BAE239708FB009CFF43 /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; - 98D31BBD23971F4B009CFF43 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 98D31BBB23971F4B009CFF43 /* Localizable.strings */; }; - 98D31BC223972A79009CFF43 /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; 98D52C3222B1CFEC00831529 /* StatsTwoColumnRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D52C3022B1CFEB00831529 /* StatsTwoColumnRow.swift */; }; 98D52C3322B1CFEC00831529 /* StatsTwoColumnRow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98D52C3122B1CFEC00831529 /* StatsTwoColumnRow.xib */; }; - 98D7ECCE23983D0800B87710 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 98DE9A9E239835C800A88D01 /* MainInterface.storyboard */; }; - 98E419DE2399B5A700D8C822 /* Tracks+ThisWeekWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E419DD2399B5A700D8C822 /* Tracks+ThisWeekWidget.swift */; }; - 98E419DF2399B62A00D8C822 /* Tracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA22821C99F6180016CA7C /* Tracks.swift */; }; + 98DCF4A5275945E00008630F /* ReaderDetailNoCommentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DCF4A3275945DF0008630F /* ReaderDetailNoCommentCell.swift */; }; + 98DCF4A6275945E00008630F /* ReaderDetailNoCommentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98DCF4A3275945DF0008630F /* ReaderDetailNoCommentCell.swift */; }; + 98DCF4A7275945E00008630F /* ReaderDetailNoCommentCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98DCF4A4275945DF0008630F /* ReaderDetailNoCommentCell.xib */; }; + 98DCF4A8275945E00008630F /* ReaderDetailNoCommentCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98DCF4A4275945DF0008630F /* ReaderDetailNoCommentCell.xib */; }; + 98E0829F2637545C00537BF1 /* PostService+Likes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E0829E2637545C00537BF1 /* PostService+Likes.swift */; }; + 98E082A02637545C00537BF1 /* PostService+Likes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E0829E2637545C00537BF1 /* PostService+Likes.swift */; }; + 98E14A3C27C9712D007B0896 /* NotificationCommentDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E14A3B27C9712D007B0896 /* NotificationCommentDetailViewController.swift */; }; + 98E14A3D27C9712D007B0896 /* NotificationCommentDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E14A3B27C9712D007B0896 /* NotificationCommentDetailViewController.swift */; }; + 98E54FF2265C972900B4BE9A /* ReaderDetailLikesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E54FF1265C972900B4BE9A /* ReaderDetailLikesView.swift */; }; + 98E54FF3265C972900B4BE9A /* ReaderDetailLikesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E54FF1265C972900B4BE9A /* ReaderDetailLikesView.swift */; }; + 98E55019265C977E00B4BE9A /* ReaderDetailLikesView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98E55018265C977E00B4BE9A /* ReaderDetailLikesView.xib */; }; + 98E5501A265C977E00B4BE9A /* ReaderDetailLikesView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98E55018265C977E00B4BE9A /* ReaderDetailLikesView.xib */; }; 98E58A2F2360D23400E5534B /* TodayWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E58A2E2360D23400E5534B /* TodayWidgetStats.swift */; }; - 98E58A302360D23400E5534B /* TodayWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E58A2E2360D23400E5534B /* TodayWidgetStats.swift */; }; - 98E58A442361019300E5534B /* Pods_WordPressTodayWidget.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 98E58A432361019300E5534B /* Pods_WordPressTodayWidget.framework */; }; + 98E5D4922620C2B40074A56A /* UserProfileSectionHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98E5D4912620C2B40074A56A /* UserProfileSectionHeader.xib */; }; + 98E5D4932620C2B40074A56A /* UserProfileSectionHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98E5D4912620C2B40074A56A /* UserProfileSectionHeader.xib */; }; 98EB126A20D2DC2500D2D5B5 /* NoResultsViewController+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98EB126920D2DC2500D2D5B5 /* NoResultsViewController+Model.swift */; }; + 98ED5963265EBD0000A0B33E /* ReaderDetailLikesListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98ED5962265EBD0000A0B33E /* ReaderDetailLikesListController.swift */; }; + 98ED5964265EBD0000A0B33E /* ReaderDetailLikesListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98ED5962265EBD0000A0B33E /* ReaderDetailLikesListController.swift */; }; 98F1B12A2111017A00139493 /* NoResultsStockPhotosConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F1B1292111017900139493 /* NoResultsStockPhotosConfiguration.swift */; }; 98F537A722496CF300B334F9 /* SiteStatsTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F537A622496CF300B334F9 /* SiteStatsTableHeaderView.swift */; }; 98F537A922496D0D00B334F9 /* SiteStatsTableHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98F537A822496D0D00B334F9 /* SiteStatsTableHeaderView.xib */; }; 98F93182239AF64800E4E96E /* ThisWeekWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F93181239AF64800E4E96E /* ThisWeekWidgetStats.swift */; }; - 98F93183239AF64800E4E96E /* ThisWeekWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F93181239AF64800E4E96E /* ThisWeekWidgetStats.swift */; }; - 98F93184239AF76900E4E96E /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; + 98F9FB2E270282C200ADF552 /* CommentModerationBar.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98F9FB2D270282C100ADF552 /* CommentModerationBar.xib */; }; + 98F9FB2F270282C200ADF552 /* CommentModerationBar.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98F9FB2D270282C100ADF552 /* CommentModerationBar.xib */; }; 98FCFC232231DF43006ECDD4 /* PostStatsTitleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98FCFC212231DF43006ECDD4 /* PostStatsTitleCell.swift */; }; 98FCFC242231DF43006ECDD4 /* PostStatsTitleCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98FCFC222231DF43006ECDD4 /* PostStatsTitleCell.xib */; }; 98FF6A3E23A30A250025FD72 /* QuickStartNavigationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98FF6A3D23A30A240025FD72 /* QuickStartNavigationSettings.swift */; }; @@ -1303,7 +2478,6 @@ 9A09F915230C3E9700F42AB7 /* StoreFetchingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A09F914230C3E9700F42AB7 /* StoreFetchingStatus.swift */; }; 9A09F91C230C49FD00F42AB7 /* StatsStackViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A09F91A230C49FD00F42AB7 /* StatsStackViewCell.xib */; }; 9A09F91E230C4C0200F42AB7 /* StatsGhostTableViewRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A09F91D230C4C0200F42AB7 /* StatsGhostTableViewRows.swift */; }; - 9A144AC822FD6A650069DD71 /* ColorPalette.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 435B76292297484200511813 /* ColorPalette.xcassets */; }; 9A162F2321C26D7500FDC035 /* RevisionPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A162F2221C26D7500FDC035 /* RevisionPreviewViewController.swift */; }; 9A162F2521C26F5F00FDC035 /* UIViewController+ChildViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A162F2421C26F5F00FDC035 /* UIViewController+ChildViewController.swift */; }; 9A162F2921C271D300FDC035 /* RevisionBrowserState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A162F2821C271D300FDC035 /* RevisionBrowserState.swift */; }; @@ -1333,8 +2507,6 @@ 9A4A8F4B235758EF00088CE4 /* StatsStore+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4A8F4A235758EF00088CE4 /* StatsStore+Cache.swift */; }; 9A4E215A21F7565A00EFF212 /* QuickStartChecklistCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A4E215921F7565A00EFF212 /* QuickStartChecklistCell.xib */; }; 9A4E215C21F75BBE00EFF212 /* QuickStartChecklistManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4E215B21F75BBE00EFF212 /* QuickStartChecklistManager.swift */; }; - 9A4E216021F87AE200EFF212 /* QuickStartChecklistHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4E215F21F87AE200EFF212 /* QuickStartChecklistHeader.swift */; }; - 9A4E216221F87AF300EFF212 /* QuickStartChecklistHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A4E216121F87AF300EFF212 /* QuickStartChecklistHeader.xib */; }; 9A4E271A22EF0C78001F6A6B /* ChangeUsernameViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4E271922EF0C78001F6A6B /* ChangeUsernameViewModel.swift */; }; 9A4E271D22EF33F5001F6A6B /* AccountSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4E271C22EF33F5001F6A6B /* AccountSettingsStore.swift */; }; 9A4E61F821A2C3BC0017A925 /* RevisionDiff+CoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4E61F721A2C3BC0017A925 /* RevisionDiff+CoreData.swift */; }; @@ -1369,6 +2541,7 @@ 9AC3C69B231543C2007933CD /* StatsGhostChartCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9AC3C69A231543C2007933CD /* StatsGhostChartCell.xib */; }; 9AF724EF2146813C00F63A61 /* ParentPageSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF724EE2146813C00F63A61 /* ParentPageSettingsViewController.swift */; }; 9AF9551821A1D7970057827C /* DiffAbstractValue+Attributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF9551721A1D7970057827C /* DiffAbstractValue+Attributes.swift */; }; + 9C86CF3E1EAC13181A593D00 /* Pods_Apps_Jetpack.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 57E15BC2269B6B7419464B6F /* Pods_Apps_Jetpack.framework */; }; 9F3EFC9E208E2E8A00268758 /* ReaderSiteInfoSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3EFC9D208E2E8A00268758 /* ReaderSiteInfoSubscriptions.swift */; }; 9F3EFCA1208E305E00268758 /* ReaderTopicService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3EFCA0208E305D00268758 /* ReaderTopicService+Subscriptions.swift */; }; 9F3EFCA3208E308A00268758 /* UIViewController+Notice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3EFCA2208E308900268758 /* UIViewController+Notice.swift */; }; @@ -1377,13 +2550,42 @@ A01C542E0E24E88400D411F2 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A01C542D0E24E88400D411F2 /* SystemConfiguration.framework */; }; A01C55480E25E0D000D411F2 /* defaultPostTemplate.html in Resources */ = {isa = PBXBuildFile; fileRef = A01C55470E25E0D000D411F2 /* defaultPostTemplate.html */; }; A0E293F10E21027E00C6919C /* WPAddPostCategoryViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = A0E293F00E21027E00C6919C /* WPAddPostCategoryViewController.m */; }; + A1C54EBE8C34FFD5015F8FC9 /* Pods_Apps_WordPress.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1E826CD5B4B116AF78FF391C /* Pods_Apps_WordPress.framework */; }; + A2C95CCF203760D9372C5857 /* Pods_WordPressDraftActionExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92B40A77F0765C1E93B11727 /* Pods_WordPressDraftActionExtension.framework */; }; + AB2211D225ED68E300BF72FC /* CommentServiceRemoteFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2211D125ED68E300BF72FC /* CommentServiceRemoteFactory.swift */; }; + AB2211F425ED6E7A00BF72FC /* CommentServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2211F325ED6E7A00BF72FC /* CommentServiceTests.swift */; }; + AB758D9E25EFDF9C00961C0B /* LikesListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB758D9D25EFDF9C00961C0B /* LikesListController.swift */; }; + AC68C9CA28E5DF14009030A9 /* NotificationsViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC68C9C928E5DF14009030A9 /* NotificationsViewControllerTests.swift */; }; + ACACE3AE28D729FA000992F9 /* NoResultsViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACACE3AD28D729FA000992F9 /* NoResultsViewControllerTests.swift */; }; ACBAB5FE0E121C7300F38795 /* PostSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = ACBAB5FD0E121C7300F38795 /* PostSettingsViewController.m */; }; ADF544C2195A0F620092213D /* CustomHighlightButton.m in Sources */ = {isa = PBXBuildFile; fileRef = ADF544C1195A0F620092213D /* CustomHighlightButton.m */; }; + AE2F3125270B6DA000B2A9C2 /* Scanner+QuotedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2F3124270B6DA000B2A9C2 /* Scanner+QuotedText.swift */; }; + AE2F3126270B6DA000B2A9C2 /* Scanner+QuotedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2F3124270B6DA000B2A9C2 /* Scanner+QuotedText.swift */; }; + AE2F3128270B6DE200B2A9C2 /* NSMutableAttributedString+ApplyAttributesToQuotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2F3127270B6DE200B2A9C2 /* NSMutableAttributedString+ApplyAttributesToQuotes.swift */; }; + AE2F3129270B6DE200B2A9C2 /* NSMutableAttributedString+ApplyAttributesToQuotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2F3127270B6DE200B2A9C2 /* NSMutableAttributedString+ApplyAttributesToQuotes.swift */; }; + AE3047AA270B66D300FE9266 /* Scanner+QuotedTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3047A9270B66D300FE9266 /* Scanner+QuotedTextTests.swift */; }; + AEE0828A2681C23C00DCF54B /* GutenbergRefactoredGalleryUploadProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE082892681C23C00DCF54B /* GutenbergRefactoredGalleryUploadProcessorTests.swift */; }; + B030FE0A27EBF0BC000F6F5E /* SiteCreationIntentTracksEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B030FE0927EBF0BC000F6F5E /* SiteCreationIntentTracksEventTests.swift */; }; + B03B9234250BC593000A40AF /* SuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B03B9233250BC593000A40AF /* SuggestionService.swift */; }; + B03B9236250BC5FD000A40AF /* Suggestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B03B9235250BC5FD000A40AF /* Suggestion.swift */; }; + B0637527253E7CEC00FD45D2 /* SuggestionsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0637526253E7CEB00FD45D2 /* SuggestionsTableView.swift */; }; + B0637543253E7E7A00FD45D2 /* GutenbergSuggestionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0637542253E7E7A00FD45D2 /* GutenbergSuggestionsViewController.swift */; }; + B06378C0253F639D00FD45D2 /* SiteSuggestion+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = B06378BE253F639D00FD45D2 /* SiteSuggestion+CoreDataClass.swift */; }; + B06378C1253F639D00FD45D2 /* SiteSuggestion+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = B06378BF253F639D00FD45D2 /* SiteSuggestion+CoreDataProperties.swift */; }; + B084E61F27E3B79F007BF7A8 /* SiteIntentStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0960C8627D14BD400BC9717 /* SiteIntentStep.swift */; }; + B084E62027E3B7A4007BF7A8 /* SiteIntentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B089140C27E1255D00CF468B /* SiteIntentViewController.swift */; }; + B089140D27E1255D00CF468B /* SiteIntentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B089140C27E1255D00CF468B /* SiteIntentViewController.swift */; }; + B0960C8727D14BD400BC9717 /* SiteIntentStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0960C8627D14BD400BC9717 /* SiteIntentStep.swift */; }; + B0A6DEBF2626335F00B5B8EF /* AztecPostViewController+MenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A6DEBE2626335F00B5B8EF /* AztecPostViewController+MenuTests.swift */; }; + B0AC50DD251E96270039E022 /* ReaderCommentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0AC50DC251E96270039E022 /* ReaderCommentsViewController.swift */; }; + B0B68A9C252FA91E0001B28C /* UserSuggestion+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0B68A9A252FA91E0001B28C /* UserSuggestion+CoreDataClass.swift */; }; + B0B68A9D252FA91E0001B28C /* UserSuggestion+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0B68A9B252FA91E0001B28C /* UserSuggestion+CoreDataProperties.swift */; }; + B0CD27CF286F8858009500BF /* JetpackBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CD27CE286F8858009500BF /* JetpackBannerView.swift */; }; + B0CD27D0286F8858009500BF /* JetpackBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0CD27CE286F8858009500BF /* JetpackBannerView.swift */; }; + B0F2EFBF259378E600C7EB6D /* SiteSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0F2EFBE259378E600C7EB6D /* SiteSuggestionService.swift */; }; B5015C581D4FDBB300C9449E /* NotificationActionsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5015C571D4FDBB300C9449E /* NotificationActionsService.swift */; }; B50248AF1C96FF6200AFBDED /* WPStyleGuide+Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50248AE1C96FF6200AFBDED /* WPStyleGuide+Share.swift */; }; B50248C21C96FFCC00AFBDED /* WordPressShare-Lumberjack.m in Sources */ = {isa = PBXBuildFile; fileRef = B50248BC1C96FFCC00AFBDED /* WordPressShare-Lumberjack.m */; }; - B50421E71B680839008EEA82 /* NoteUndoOverlayView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B50421E61B680839008EEA82 /* NoteUndoOverlayView.xib */; }; - B50421E91B68170F008EEA82 /* NoteUndoOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50421E81B68170F008EEA82 /* NoteUndoOverlayView.swift */; }; B504F5F51C9C2BD000F8B1C6 /* Tracks+ShareExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B504F5F41C9C2BD000F8B1C6 /* Tracks+ShareExtension.swift */; }; B50C0C5A1EF42A2600372C65 /* MediaProgressCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C591EF42A2600372C65 /* MediaProgressCoordinator.swift */; }; B50C0C5E1EF42A4A00372C65 /* AztecAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C5C1EF42A4A00372C65 /* AztecAttachmentViewController.swift */; }; @@ -1401,7 +2603,6 @@ B522C4F81B3DA79B00E47B59 /* NotificationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B522C4F71B3DA79B00E47B59 /* NotificationSettingsViewController.swift */; }; B526DC291B1E47FC002A8C5F /* WPStyleGuide+WebView.m in Sources */ = {isa = PBXBuildFile; fileRef = B526DC281B1E47FC002A8C5F /* WPStyleGuide+WebView.m */; }; B52C4C7D199D4CD3009FD823 /* NoteBlockUserTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52C4C7C199D4CD3009FD823 /* NoteBlockUserTableViewCell.swift */; }; - B52C4C7F199D74AE009FD823 /* NoteTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52C4C7E199D74AE009FD823 /* NoteTableViewCell.swift */; }; B52F8CD81B43260C00D36025 /* NotificationSettingStreamsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52F8CD71B43260C00D36025 /* NotificationSettingStreamsViewController.swift */; }; B5326E6F203F554D007392C3 /* WordPressSupportSourceTag+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5326E6E203F554C007392C3 /* WordPressSupportSourceTag+Helpers.swift */; }; B532ACCF1DC3AB8E00FFFA57 /* NotificationSyncMediatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B532ACCE1DC3AB8E00FFFA57 /* NotificationSyncMediatorTests.swift */; }; @@ -1427,7 +2628,6 @@ B54866CA1A0D7042004AC79D /* NSAttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54866C91A0D7042004AC79D /* NSAttributedString+Helpers.swift */; }; B549BA681CF7447E0086C608 /* InvitePersonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B549BA671CF7447E0086C608 /* InvitePersonViewController.swift */; }; B54C02241F38F50100574572 /* String+RegEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54C02231F38F50100574572 /* String+RegEx.swift */; }; - B54E1DF01A0A7BAA00807537 /* ReplyBezierView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54E1DED1A0A7BAA00807537 /* ReplyBezierView.swift */; }; B54E1DF11A0A7BAA00807537 /* ReplyTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54E1DEE1A0A7BAA00807537 /* ReplyTextView.swift */; }; B54E1DF21A0A7BAA00807537 /* ReplyTextView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B54E1DEF1A0A7BAA00807537 /* ReplyTextView.xib */; }; B54E1DF41A0A7BBF00807537 /* NotificationMediaDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54E1DF31A0A7BBF00807537 /* NotificationMediaDownloader.swift */; }; @@ -1438,8 +2638,6 @@ B5552D831CD1062400B26DF6 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5552D7F1CD1028C00B26DF6 /* String+Extensions.swift */; }; B555E1151C04A68D00CEC81B /* WordPress-41-42.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = B555E1141C04A68D00CEC81B /* WordPress-41-42.xcmappingmodel */; }; B556EFCB1DCA374200728F93 /* DictionaryHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B556EFCA1DCA374200728F93 /* DictionaryHelpersTests.swift */; }; - B55853F31962337500FAF6C3 /* NSScanner+Helpers.m in Sources */ = {isa = PBXBuildFile; fileRef = B55853F21962337500FAF6C3 /* NSScanner+Helpers.m */; }; - B55853F719630D5400FAF6C3 /* NSAttributedString+Util.m in Sources */ = {isa = PBXBuildFile; fileRef = B55853F619630D5400FAF6C3 /* NSAttributedString+Util.m */; }; B558541419631A1000FAF6C3 /* Notifications.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B558541019631A1000FAF6C3 /* Notifications.storyboard */; }; B55F1AA21C107CE200FD04D4 /* BlogSettingsDiscussionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55F1AA11C107CE200FD04D4 /* BlogSettingsDiscussionTests.swift */; }; B55F1AA81C10936600FD04D4 /* BlogSettings+Discussion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55F1AA71C10936600FD04D4 /* BlogSettings+Discussion.swift */; }; @@ -1447,7 +2645,6 @@ B560914C208A671F00399AE4 /* WPStyleGuide+SiteCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B560914B208A671E00399AE4 /* WPStyleGuide+SiteCreation.swift */; }; B56695B01D411EEB007E342F /* KeyboardDismissHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56695AF1D411EEB007E342F /* KeyboardDismissHelper.swift */; }; B566EC751B83867800278395 /* NSMutableAttributedStringTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B566EC741B83867800278395 /* NSMutableAttributedStringTests.swift */; }; - B5683DB81B6C03810043447C /* NoteTableHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5683DB71B6C03810043447C /* NoteTableHeaderView.xib */; }; B56994451B7A7EF200FF26FA /* WPStyleGuide+Comments.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56994441B7A7EF200FF26FA /* WPStyleGuide+Comments.swift */; }; B56A70181B5040B9001D5815 /* SwitchTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56A70171B5040B9001D5815 /* SwitchTableViewCell.swift */; }; B56F25881FBDE502005C33E4 /* NSAttributedStringKey+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56F25871FBDE502005C33E4 /* NSAttributedStringKey+Conversion.swift */; }; @@ -1462,7 +2659,6 @@ B5772AC61C9C84900031F97E /* GravatarServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5772AC51C9C84900031F97E /* GravatarServiceTests.swift */; }; B57AF5FA1ACDC73D0075A7D2 /* NoteBlockActionsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57AF5F91ACDC73D0075A7D2 /* NoteBlockActionsTableViewCell.swift */; }; B57B92BD1B73B08100DFF00B /* SeparatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57B92BC1B73B08100DFF00B /* SeparatorsView.swift */; }; - B57B99D519A2C20200506504 /* NoteTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57B99D419A2C20200506504 /* NoteTableHeaderView.swift */; }; B57B99DE19A2DBF200506504 /* NSObject+Helpers.m in Sources */ = {isa = PBXBuildFile; fileRef = B57B99DD19A2DBF200506504 /* NSObject+Helpers.m */; }; B587798619B799EB00E57C5A /* Notification+Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = B587798419B799EB00E57C5A /* Notification+Interface.swift */; }; B5882C471D5297D1008E0EAA /* NotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5882C461D5297D1008E0EAA /* NotificationTests.swift */; }; @@ -1500,15 +2696,12 @@ B5C9401A1DB900DC0079D4FF /* AccountHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C940191DB900DC0079D4FF /* AccountHelper.swift */; }; B5CABB171C0E382C0050AB9F /* PickerTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CABB161C0E382C0050AB9F /* PickerTableViewCell.swift */; }; B5CC05F61962150600975CAC /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; - B5CEEB8E1B7920BE00E7B7B0 /* CommentsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CEEB8D1B7920BE00E7B7B0 /* CommentsTableViewCell.swift */; }; - B5CEEB901B79244D00E7B7B0 /* CommentsTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5CEEB8F1B79244D00E7B7B0 /* CommentsTableViewCell.xib */; }; B5D889411BEBE30A007C4684 /* BlogSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D889401BEBE30A007C4684 /* BlogSettings.swift */; }; B5DA8A5F20ADAA1D00D5BDE1 /* plugin-directory-jetpack.json in Resources */ = {isa = PBXBuildFile; fileRef = B5DA8A5E20ADAA1C00D5BDE1 /* plugin-directory-jetpack.json */; }; B5DB8AF41C949DC20059196A /* WPImmuTableRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DB8AF31C949DC20059196A /* WPImmuTableRows.swift */; }; B5DBE4FE1D21A700002E81D3 /* NotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DBE4FD1D21A700002E81D3 /* NotificationsViewController.swift */; }; B5DD04741CD3DAB00003DF89 /* NSFetchedResultsController+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DD04731CD3DAB00003DF89 /* NSFetchedResultsController+Helpers.swift */; }; B5E167F419C08D18009535AA /* NSCalendar+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E167F319C08D18009535AA /* NSCalendar+Helpers.swift */; }; - B5E23BDF19AD0D00000D6879 /* NoteTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5E23BDE19AD0D00000D6879 /* NoteTableViewCell.xib */; }; B5E51B7B203477DF00151ECD /* WordPressAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E51B7A203477DF00151ECD /* WordPressAuthenticationManager.swift */; }; B5E94D151FE04815000E7C20 /* UIImageView+SiteIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E94D141FE04815000E7C20 /* UIImageView+SiteIcon.swift */; }; B5EB19EC20C6DACC008372B9 /* ImageDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EB19EB20C6DACC008372B9 /* ImageDownloader.swift */; }; @@ -1521,7 +2714,6 @@ B5EFB1D11B33630C007608A3 /* notifications-settings.json in Resources */ = {isa = PBXBuildFile; fileRef = B5EFB1D01B33630C007608A3 /* notifications-settings.json */; }; B5F641B31E37C36700B7819F /* AdaptiveNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F641B21E37C36700B7819F /* AdaptiveNavigationController.swift */; }; B5F67AC71DB7D81300482C62 /* NotificationSyncMediator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F67AC61DB7D81300482C62 /* NotificationSyncMediator.swift */; }; - B5F995A01B59708C00AB0B3E /* NotificationSettingsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5F9959F1B59708C00AB0B3E /* NotificationSettingsViewController.xib */; }; B5FA22831C99F6180016CA7C /* Tracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA22821C99F6180016CA7C /* Tracks.swift */; }; B5FA868C1D10A4C400AB5F7E /* UIImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FA868A1D10A41600AB5F7E /* UIImage+Extensions.swift */; }; B5FDF9F320D842D2006D14E3 /* AztecNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FDF9F220D842D2006D14E3 /* AztecNavigationController.swift */; }; @@ -1529,67 +2721,233 @@ BE1071FC1BC75E7400906AFF /* WPStyleGuide+Blog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE1071FB1BC75E7400906AFF /* WPStyleGuide+Blog.swift */; }; BE1071FF1BC75FFA00906AFF /* WPStyleGuide+BlogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE1071FE1BC75FFA00906AFF /* WPStyleGuide+BlogTests.swift */; }; BE13B3E71B2B58D800A4211D /* BlogListViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = BE13B3E51B2B58D800A4211D /* BlogListViewController.m */; }; - BE2B4E9B1FD66423007AE3E4 /* WelcomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4E9A1FD66423007AE3E4 /* WelcomeScreen.swift */; }; BE2B4E9F1FD664F5007AE3E4 /* BaseScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4E9E1FD664F5007AE3E4 /* BaseScreen.swift */; }; - BE2B4EA21FD6654A007AE3E4 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4EA11FD6654A007AE3E4 /* Logger.swift */; }; - BE2B4EA41FD6659B007AE3E4 /* LoginEmailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4EA31FD6659B007AE3E4 /* LoginEmailScreen.swift */; }; BE6787F51FFF2886005D9F01 /* ShareModularViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6787F41FFF2886005D9F01 /* ShareModularViewController.swift */; }; - BE6DD3281FD6705200E55192 /* LoginPasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD3271FD6705200E55192 /* LoginPasswordScreen.swift */; }; - BE6DD32A1FD6708900E55192 /* LinkOrPasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD3291FD6708900E55192 /* LinkOrPasswordScreen.swift */; }; - BE6DD32C1FD6782A00E55192 /* LoginEpilogueScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD32B1FD6782A00E55192 /* LoginEpilogueScreen.swift */; }; - BE6DD32E1FD67EDA00E55192 /* MySitesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD32D1FD67EDA00E55192 /* MySitesScreen.swift */; }; - BE6DD3301FD67F3B00E55192 /* TabNavComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD32F1FD67F3B00E55192 /* TabNavComponent.swift */; }; - BE6DD3321FD6803700E55192 /* MeTabScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD3311FD6803700E55192 /* MeTabScreen.swift */; }; - BE8707172006B774004FB5A4 /* MySiteScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8707162006B774004FB5A4 /* MySiteScreen.swift */; }; - BE8707192006E48E004FB5A4 /* ReaderScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8707182006E48E004FB5A4 /* ReaderScreen.swift */; }; - BE87071B2006E65C004FB5A4 /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE87071A2006E65C004FB5A4 /* NotificationsScreen.swift */; }; BE87E1A01BD4054F0075D45B /* WP3DTouchShortcutCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE87E19F1BD4054F0075D45B /* WP3DTouchShortcutCreator.swift */; }; BE87E1A21BD405790075D45B /* WP3DTouchShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE87E1A11BD405790075D45B /* WP3DTouchShortcutHandler.swift */; }; BEA0E4851BD83565000AEE81 /* WP3DTouchShortcutCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA0E4841BD83565000AEE81 /* WP3DTouchShortcutCreatorTests.swift */; }; BED4D8301FF11DEF00A11345 /* EditorAztecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D82F1FF11DEF00A11345 /* EditorAztecTests.swift */; }; BED4D8331FF11E3800A11345 /* LoginFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D8321FF11E3800A11345 /* LoginFlow.swift */; }; - BED4D8351FF1208400A11345 /* AztecEditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D8341FF1208400A11345 /* AztecEditorScreen.swift */; }; - BED4D83B1FF13B8A00A11345 /* EditorPublishEpilogueScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D83A1FF13B8A00A11345 /* EditorPublishEpilogueScreen.swift */; }; + C314543B262770BE005B216B /* BlogServiceAuthorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C314543A262770BE005B216B /* BlogServiceAuthorTests.swift */; }; + C31466CC2939950900D62FC7 /* MigrationLoadWordPressViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31466CB2939950900D62FC7 /* MigrationLoadWordPressViewController.swift */; }; + C31852A129670F8100A78BE9 /* JetpackScanViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31852A029670F8100A78BE9 /* JetpackScanViewController+JetpackBannerViewController.swift */; }; + C31852A229670F8100A78BE9 /* JetpackScanViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31852A029670F8100A78BE9 /* JetpackScanViewController+JetpackBannerViewController.swift */; }; + C31852A329673BFC00A78BE9 /* MenusViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B554502965C32A00A04753 /* MenusViewController+JetpackBannerViewController.swift */; }; + C31B6D5B28BFB6F300E64FEB /* SplashPrologueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31B6D5A28BFB6F300E64FEB /* SplashPrologueView.swift */; }; + C31B6D7828BFE8B100E64FEB /* EBGaramond-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C31B6D7728BFE8B100E64FEB /* EBGaramond-Regular.ttf */; }; + C3234F4C27EB96A3004ADB29 /* IntentCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C3302CC427EB67D0004229D3 /* IntentCell.xib */; }; + C3234F4D27EB96A5004ADB29 /* IntentCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C3302CC427EB67D0004229D3 /* IntentCell.xib */; }; + C3234F4E27EB96A9004ADB29 /* IntentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3302CC527EB67D0004229D3 /* IntentCell.swift */; }; + C3234F4F27EB96AB004ADB29 /* IntentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3302CC527EB67D0004229D3 /* IntentCell.swift */; }; + C3234F5427EBBACA004ADB29 /* SiteIntentVertical.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3234F5327EBBACA004ADB29 /* SiteIntentVertical.swift */; }; + C3234F5527EBBACA004ADB29 /* SiteIntentVertical.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3234F5327EBBACA004ADB29 /* SiteIntentVertical.swift */; }; + C324D7A928C26F3F00310DEF /* SplashPrologueStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = C324D7A828C26F3F00310DEF /* SplashPrologueStyleGuide.swift */; }; + C324D7AB28C2F73100310DEF /* SplashPrologueStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = C324D7A828C26F3F00310DEF /* SplashPrologueStyleGuide.swift */; }; + C324D7AC28C2F73F00310DEF /* SplashPrologueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DD4DCD28BE5D4D0046C68E /* SplashPrologueViewController.swift */; }; + C32A6A2C2832BF02002E9394 /* SiteDesignCategoryThumbnailSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32A6A2B2832BF02002E9394 /* SiteDesignCategoryThumbnailSize.swift */; }; + C32A6A2D2832BF02002E9394 /* SiteDesignCategoryThumbnailSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = C32A6A2B2832BF02002E9394 /* SiteDesignCategoryThumbnailSize.swift */; }; + C33A5ADC2935848F00961E3A /* MigrationAppDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33A5ADB2935848F00961E3A /* MigrationAppDetection.swift */; }; + C3439B5F27FE3A3C0058DA55 /* SiteCreationWizardLauncherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3439B5E27FE3A3C0058DA55 /* SiteCreationWizardLauncherTests.swift */; }; + C34E94BA28EDF7D900D27A16 /* InfiniteScrollerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34E94B928EDF7D900D27A16 /* InfiniteScrollerView.swift */; }; + C34E94BC28EDF80700D27A16 /* InfiniteScrollerViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34E94BB28EDF80700D27A16 /* InfiniteScrollerViewDelegate.swift */; }; + C352870527FDD35C004E2E51 /* SiteNameStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352870427FDD35C004E2E51 /* SiteNameStep.swift */; }; + C352870627FDD35C004E2E51 /* SiteNameStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352870427FDD35C004E2E51 /* SiteNameStep.swift */; }; + C35D4FF1280077F100DB90B5 /* SiteCreationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D4FF0280077F100DB90B5 /* SiteCreationStep.swift */; }; + C35D4FF2280077F100DB90B5 /* SiteCreationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D4FF0280077F100DB90B5 /* SiteCreationStep.swift */; }; + C3643ACF28AC049D00FC5FD3 /* SharingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3643ACE28AC049D00FC5FD3 /* SharingViewController.swift */; }; + C3643AD028AC049D00FC5FD3 /* SharingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3643ACE28AC049D00FC5FD3 /* SharingViewController.swift */; }; + C373D6E728045281008F8C26 /* SiteIntentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C373D6E628045281008F8C26 /* SiteIntentData.swift */; }; + C373D6E828045281008F8C26 /* SiteIntentData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C373D6E628045281008F8C26 /* SiteIntentData.swift */; }; + C373D6EA280452F6008F8C26 /* SiteIntentDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C373D6E9280452F6008F8C26 /* SiteIntentDataTests.swift */; }; + C3835559288B02B00062E402 /* JetpackBannerWrapperViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3835558288B02B00062E402 /* JetpackBannerWrapperViewController.swift */; }; + C383555A288B02B00062E402 /* JetpackBannerWrapperViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3835558288B02B00062E402 /* JetpackBannerWrapperViewController.swift */; }; + C387B7A22638D66F00BDEF86 /* PostAuthorSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E2462826277B7700B99EA6 /* PostAuthorSelectorViewController.swift */; }; + C3895DDD28C65435004E7C9B /* SplashPrologueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31B6D5A28BFB6F300E64FEB /* SplashPrologueView.swift */; }; + C38C5D8127F61D2C002F517E /* MenuItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38C5D8027F61D2C002F517E /* MenuItemTests.swift */; }; + C395FB232821FE4400AE7C11 /* SiteDesignSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C395FB222821FE4400AE7C11 /* SiteDesignSection.swift */; }; + C395FB242821FE4B00AE7C11 /* SiteDesignSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C395FB222821FE4400AE7C11 /* SiteDesignSection.swift */; }; + C395FB262821FE7B00AE7C11 /* RemoteSiteDesign+Thumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = C395FB252821FE7B00AE7C11 /* RemoteSiteDesign+Thumbnail.swift */; }; + C395FB272822148400AE7C11 /* RemoteSiteDesign+Thumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = C395FB252821FE7B00AE7C11 /* RemoteSiteDesign+Thumbnail.swift */; }; + C396C80B280F2401006FE7AC /* SiteDesignTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C396C80A280F2401006FE7AC /* SiteDesignTests.swift */; }; + C39ABBAE294BE84000F6F278 /* BackupListViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C39ABBAD294BE84000F6F278 /* BackupListViewController+JetpackBannerViewController.swift */; }; + C39ABBAF294BE84000F6F278 /* BackupListViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C39ABBAD294BE84000F6F278 /* BackupListViewController+JetpackBannerViewController.swift */; }; + C3A1166929807E3F00B0CB6E /* ReaderBlockUserAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A1166829807E3F00B0CB6E /* ReaderBlockUserAction.swift */; }; + C3A1166A29807E3F00B0CB6E /* ReaderBlockUserAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A1166829807E3F00B0CB6E /* ReaderBlockUserAction.swift */; }; + C3AB4879292F114A001F7AF8 /* UIApplication+AppAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AB4878292F114A001F7AF8 /* UIApplication+AppAvailability.swift */; }; + C3B554512965C32A00A04753 /* MenusViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B554502965C32A00A04753 /* MenusViewController+JetpackBannerViewController.swift */; }; + C3B5545329661F2C00A04753 /* ThemeBrowserViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B5545229661F2C00A04753 /* ThemeBrowserViewController+JetpackBannerViewController.swift */; }; + C3B5545429661F2C00A04753 /* ThemeBrowserViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B5545229661F2C00A04753 /* ThemeBrowserViewController+JetpackBannerViewController.swift */; }; + C3BC86F629528997005D1A01 /* PeopleViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BC86F529528997005D1A01 /* PeopleViewController+JetpackBannerViewController.swift */; }; + C3BC86F729528997005D1A01 /* PeopleViewController+JetpackBannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3BC86F529528997005D1A01 /* PeopleViewController+JetpackBannerViewController.swift */; }; + C3C21EB928385EC8002296E2 /* RemoteSiteDesigns.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C21EB828385EC8002296E2 /* RemoteSiteDesigns.swift */; }; + C3C21EBA28385EC8002296E2 /* RemoteSiteDesigns.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C21EB828385EC8002296E2 /* RemoteSiteDesigns.swift */; }; + C3C2F84628AC8BC700937E45 /* JetpackBannerScrollVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2F84528AC8BC700937E45 /* JetpackBannerScrollVisibilityTests.swift */; }; + C3C2F84828AC8EBF00937E45 /* JetpackBannerScrollVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2F84728AC8EBF00937E45 /* JetpackBannerScrollVisibility.swift */; }; + C3C2F84928AC8EBF00937E45 /* JetpackBannerScrollVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2F84728AC8EBF00937E45 /* JetpackBannerScrollVisibility.swift */; }; + C3C39B0726F50D3900B1238D /* WordPressSupportSourceTag+Editor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C39B0626F50D3900B1238D /* WordPressSupportSourceTag+Editor.swift */; }; + C3C39B0826F50D3900B1238D /* WordPressSupportSourceTag+Editor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C39B0626F50D3900B1238D /* WordPressSupportSourceTag+Editor.swift */; }; + C3C70C562835C5BB00DD2546 /* SiteDesignSectionLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C70C552835C5BB00DD2546 /* SiteDesignSectionLoaderTests.swift */; }; + C3DA0EE02807062600DA3250 /* SiteCreationNameTracksEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DA0EDF2807062600DA3250 /* SiteCreationNameTracksEventTests.swift */; }; + C3DD4DCE28BE5D4D0046C68E /* SplashPrologueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3DD4DCD28BE5D4D0046C68E /* SplashPrologueViewController.swift */; }; + C3E2462926277B7700B99EA6 /* PostAuthorSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E2462826277B7700B99EA6 /* PostAuthorSelectorViewController.swift */; }; + C3E42AB027F4D30E00546706 /* MenuItemsViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E42AAF27F4D30E00546706 /* MenuItemsViewControllerTests.swift */; }; + C3E77F89293A4EA10034AE5A /* MigrationLoadWordPressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E77F88293A4EA10034AE5A /* MigrationLoadWordPressViewModel.swift */; }; + C3FBF4E828AFEDF8003797DF /* JetpackBrandingAnalyticsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FBF4E728AFEDF8003797DF /* JetpackBrandingAnalyticsHelper.swift */; }; + C3FBF4E928AFEDF8003797DF /* JetpackBrandingAnalyticsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FBF4E728AFEDF8003797DF /* JetpackBrandingAnalyticsHelper.swift */; }; + C3FF78E828354A91008FA600 /* SiteDesignSectionLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FF78E728354A91008FA600 /* SiteDesignSectionLoader.swift */; }; + C3FF78E928354A91008FA600 /* SiteDesignSectionLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3FF78E728354A91008FA600 /* SiteDesignSectionLoader.swift */; }; C533CF350E6D3ADA000C3DE8 /* CommentsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C533CF340E6D3ADA000C3DE8 /* CommentsViewController.m */; }; - C545E0A21811B9880020844C /* ContextManager.m in Sources */ = {isa = PBXBuildFile; fileRef = C545E0A11811B9880020844C /* ContextManager.m */; }; C56636E91868D0CE00226AAB /* StatsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C56636E71868D0CE00226AAB /* StatsViewController.m */; }; - CC19BE06223FECAC00CAB3E1 /* EditorPostSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC19BE05223FECAC00CAB3E1 /* EditorPostSettings.swift */; }; - CC2BB0CA2289CC3B0034F9AB /* BlockEditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2BB0C92289CC3B0034F9AB /* BlockEditorScreen.swift */; }; - CC2BB0D0228ACF710034F9AB /* EditorGutenbergTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2BB0CF228ACF710034F9AB /* EditorGutenbergTests.swift */; }; + C649C66318E8B5EF92B8F196 /* Pods_JetpackIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D42A30853435E728881904E8 /* Pods_JetpackIntents.framework */; }; + C700F9D2257FD63A0090938E /* JetpackScanViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C700F9D0257FD63A0090938E /* JetpackScanViewController.xib */; }; + C700F9EE257FD64E0090938E /* JetpackScanViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C700F9ED257FD64E0090938E /* JetpackScanViewController.swift */; }; + C700FAB2258020DB0090938E /* JetpackScanThreatCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C700FAB0258020DB0090938E /* JetpackScanThreatCell.swift */; }; + C700FAB3258020DB0090938E /* JetpackScanThreatCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C700FAB1258020DB0090938E /* JetpackScanThreatCell.xib */; }; + C7124E4E2638528F00929318 /* JetpackPrologueViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C7124E4C2638528F00929318 /* JetpackPrologueViewController.xib */; }; + C7124E4F2638528F00929318 /* JetpackPrologueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7124E4D2638528F00929318 /* JetpackPrologueViewController.swift */; }; + C7124E922638905B00929318 /* StarFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7124E912638905B00929318 /* StarFieldView.swift */; }; + C7192ECF25E8432D00C3020D /* ReaderTopicsCardCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C7192ECE25E8432D00C3020D /* ReaderTopicsCardCell.xib */; }; + C71AF533281064DE00F9E99E /* OnboardingQuestionsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71AF532281064DE00F9E99E /* OnboardingQuestionsCoordinator.swift */; }; + C71AF534281064DE00F9E99E /* OnboardingQuestionsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71AF532281064DE00F9E99E /* OnboardingQuestionsCoordinator.swift */; }; + C71BC73F25A652410023D789 /* JetpackScanStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71BC73E25A652410023D789 /* JetpackScanStatusViewModel.swift */; }; + C7234A3A2832BA240045C63F /* QRLoginCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7234A392832BA240045C63F /* QRLoginCoordinator.swift */; }; + C7234A3B2832BA240045C63F /* QRLoginCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7234A392832BA240045C63F /* QRLoginCoordinator.swift */; }; + C7234A422832C2BA0045C63F /* QRLoginScanningViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7234A402832C2BA0045C63F /* QRLoginScanningViewController.swift */; }; + C7234A432832C2BA0045C63F /* QRLoginScanningViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7234A402832C2BA0045C63F /* QRLoginScanningViewController.swift */; }; + C7234A442832C2BA0045C63F /* QRLoginScanningViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C7234A412832C2BA0045C63F /* QRLoginScanningViewController.xib */; }; + C7234A452832C2BA0045C63F /* QRLoginScanningViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C7234A412832C2BA0045C63F /* QRLoginScanningViewController.xib */; }; + C7234A4E2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7234A4C2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.swift */; }; + C7234A4F2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7234A4C2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.swift */; }; + C7234A502832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C7234A4D2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.xib */; }; + C7234A512832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C7234A4D2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.xib */; }; + C72A4F68264088E4009CA633 /* JetpackNotFoundErrorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72A4F67264088E4009CA633 /* JetpackNotFoundErrorViewModel.swift */; }; + C72A4F7B26408943009CA633 /* JetpackNotWPErrorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72A4F7A26408943009CA633 /* JetpackNotWPErrorViewModel.swift */; }; + C72A4F8E26408C73009CA633 /* JetpackNoSitesErrorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72A4F8D26408C73009CA633 /* JetpackNoSitesErrorViewModel.swift */; }; + C72A52CF2649B158009CA633 /* JetpackWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72A52CE2649B157009CA633 /* JetpackWindowManager.swift */; }; + C737553E27C80DD500C6E9A1 /* String+CondenseWhitespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = C737553D27C80DD500C6E9A1 /* String+CondenseWhitespace.swift */; }; + C737553F27C80DD500C6E9A1 /* String+CondenseWhitespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = C737553D27C80DD500C6E9A1 /* String+CondenseWhitespace.swift */; }; + C737554027C80F1300C6E9A1 /* String+CondenseWhitespace.swift in Sources */ = {isa = PBXBuildFile; fileRef = C737553D27C80DD500C6E9A1 /* String+CondenseWhitespace.swift */; }; + C73868C525C9F9820072532C /* JetpackScanThreatSectionGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = C73868C425C9F9820072532C /* JetpackScanThreatSectionGrouping.swift */; }; + C738CB0B28623CED001BE107 /* QRLoginCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C738CB0A28623CED001BE107 /* QRLoginCoordinatorTests.swift */; }; + C738CB0D28623F07001BE107 /* QRLoginURLParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C738CB0C28623F07001BE107 /* QRLoginURLParserTests.swift */; }; + C738CB0F28626466001BE107 /* QRLoginScanningCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C738CB0E28626466001BE107 /* QRLoginScanningCoordinatorTests.swift */; }; + C738CB1128626606001BE107 /* QRLoginVerifyCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C738CB1028626606001BE107 /* QRLoginVerifyCoordinatorTests.swift */; }; + C743535627BD7144008C2644 /* AnimatedGifAttachmentViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C743535527BD7144008C2644 /* AnimatedGifAttachmentViewProvider.swift */; }; + C743535727BD7144008C2644 /* AnimatedGifAttachmentViewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C743535527BD7144008C2644 /* AnimatedGifAttachmentViewProvider.swift */; }; + C768B5B425828A5D00556E75 /* JetpackScanStatusCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C768B5B225828A5D00556E75 /* JetpackScanStatusCell.swift */; }; + C768B5B525828A5D00556E75 /* JetpackScanStatusCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C768B5B325828A5D00556E75 /* JetpackScanStatusCell.xib */; }; + C76F48DC25BA202600BFEC87 /* JetpackScanHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C76F48DB25BA202600BFEC87 /* JetpackScanHistoryViewController.swift */; }; + C76F48EE25BA20EF00BFEC87 /* JetpackScanHistoryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C76F48ED25BA20EF00BFEC87 /* JetpackScanHistoryCoordinator.swift */; }; + C76F490025BA23B000BFEC87 /* JetpackScanHistoryViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C76F48FF25BA23B000BFEC87 /* JetpackScanHistoryViewController.xib */; }; + C77FC90928009C7000726F00 /* OnboardingQuestionsPromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C77FC90728009C7000726F00 /* OnboardingQuestionsPromptViewController.swift */; }; + C77FC90A28009C7000726F00 /* OnboardingQuestionsPromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C77FC90728009C7000726F00 /* OnboardingQuestionsPromptViewController.swift */; }; + C77FC90B28009C7000726F00 /* OnboardingQuestionsPromptViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C77FC90828009C7000726F00 /* OnboardingQuestionsPromptViewController.xib */; }; + C77FC90C28009C7000726F00 /* OnboardingQuestionsPromptViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C77FC90828009C7000726F00 /* OnboardingQuestionsPromptViewController.xib */; }; + C77FC90F2800CAC100726F00 /* OnboardingEnableNotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C77FC90D2800CAC100726F00 /* OnboardingEnableNotificationsViewController.swift */; }; + C77FC9102800CAC100726F00 /* OnboardingEnableNotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C77FC90D2800CAC100726F00 /* OnboardingEnableNotificationsViewController.swift */; }; + C77FC9112800CAC100726F00 /* OnboardingEnableNotificationsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C77FC90E2800CAC100726F00 /* OnboardingEnableNotificationsViewController.xib */; }; + C77FC9122800CAC100726F00 /* OnboardingEnableNotificationsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C77FC90E2800CAC100726F00 /* OnboardingEnableNotificationsViewController.xib */; }; + C78543D225B889CC006CEAFB /* JetpackScanThreatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C78543D125B889CC006CEAFB /* JetpackScanThreatViewModel.swift */; }; + C789952525816F96001B7B43 /* JetpackScanCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C789952425816F96001B7B43 /* JetpackScanCoordinator.swift */; }; + C79C307C26EA915100E88514 /* ReferrerDetailsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215E3267F5003008C3B69 /* ReferrerDetailsTableViewController.swift */; }; + C79C307D26EA919F00E88514 /* ReferrerDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215E5267F5192008C3B69 /* ReferrerDetailsViewModel.swift */; }; + C79C307E26EA970200E88514 /* ReferrerDetailsHeaderRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215E7267F52A6008C3B69 /* ReferrerDetailsHeaderRow.swift */; }; + C79C307F26EA970800E88514 /* ReferrerDetailsSpamActionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215F1267FE162008C3B69 /* ReferrerDetailsSpamActionRow.swift */; }; + C79C308026EA975000E88514 /* ReferrerDetailsRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215EB267F5F45008C3B69 /* ReferrerDetailsRow.swift */; }; + C79C308126EA975900E88514 /* ReferrerDetailsHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215E9267F59CB008C3B69 /* ReferrerDetailsHeaderCell.swift */; }; + C79C308226EA99D500E88514 /* ReferrerDetailsSpamActionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215F3267FE177008C3B69 /* ReferrerDetailsSpamActionCell.swift */; }; + C79C308326EA9A2300E88514 /* ReferrerDetailsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 931215ED267F6799008C3B69 /* ReferrerDetailsCell.swift */; }; + C7A09A4A28401B7B003096ED /* QRLoginService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A09A4928401B7B003096ED /* QRLoginService.swift */; }; + C7A09A4B28401B7B003096ED /* QRLoginService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A09A4928401B7B003096ED /* QRLoginService.swift */; }; + C7A09A4D28403A34003096ED /* QRLoginURLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A09A4C28403A34003096ED /* QRLoginURLParser.swift */; }; + C7A09A4E28403A34003096ED /* QRLoginURLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A09A4C28403A34003096ED /* QRLoginURLParser.swift */; }; + C7AFF874283C0ADC000E01DF /* UIApplication+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AFF873283C0ADC000E01DF /* UIApplication+Helpers.swift */; }; + C7AFF875283C0ADC000E01DF /* UIApplication+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AFF873283C0ADC000E01DF /* UIApplication+Helpers.swift */; }; + C7AFF877283C2623000E01DF /* QRLoginScanningCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AFF876283C2623000E01DF /* QRLoginScanningCoordinator.swift */; }; + C7AFF878283C2623000E01DF /* QRLoginScanningCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AFF876283C2623000E01DF /* QRLoginScanningCoordinator.swift */; }; + C7AFF87C283D5CF4000E01DF /* QRLoginVerifyCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AFF87B283D5CF4000E01DF /* QRLoginVerifyCoordinator.swift */; }; + C7AFF87D283D5CF4000E01DF /* QRLoginVerifyCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AFF87B283D5CF4000E01DF /* QRLoginVerifyCoordinator.swift */; }; + C7B7CC702812FDCE007B9807 /* MySiteViewController+OnboardingPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B7CC6F2812FDCE007B9807 /* MySiteViewController+OnboardingPrompt.swift */; }; + C7B7CC712812FDCE007B9807 /* MySiteViewController+OnboardingPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B7CC6F2812FDCE007B9807 /* MySiteViewController+OnboardingPrompt.swift */; }; + C7B7CC7328134347007B9807 /* OnboardingQuestionsPromptScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B7CC7228134347007B9807 /* OnboardingQuestionsPromptScreen.swift */; }; + C7BB60162863609C00748FD9 /* QRLoginInternetConnectionChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7BB60152863609C00748FD9 /* QRLoginInternetConnectionChecker.swift */; }; + C7BB60172863609C00748FD9 /* QRLoginInternetConnectionChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7BB60152863609C00748FD9 /* QRLoginInternetConnectionChecker.swift */; }; + C7BB60192863AF9700748FD9 /* QRLoginProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7BB60182863AF9700748FD9 /* QRLoginProtocols.swift */; }; + C7BB601A2863AF9700748FD9 /* QRLoginProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7BB60182863AF9700748FD9 /* QRLoginProtocols.swift */; }; + C7BB601C2863B3D600748FD9 /* QRLoginCameraPermissionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7BB601B2863B3D600748FD9 /* QRLoginCameraPermissionsHandler.swift */; }; + C7BB601D2863B3D600748FD9 /* QRLoginCameraPermissionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7BB601B2863B3D600748FD9 /* QRLoginCameraPermissionsHandler.swift */; }; + C7BB601F2863B9E800748FD9 /* QRLoginCameraSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7BB601E2863B9E800748FD9 /* QRLoginCameraSession.swift */; }; + C7BB60202863B9E800748FD9 /* QRLoginCameraSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7BB601E2863B9E800748FD9 /* QRLoginCameraSession.swift */; }; + C7D30C652638B07A00A1695B /* JetpackPrologueStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D30C642638B07A00A1695B /* JetpackPrologueStyleGuide.swift */; }; + C7E5F2522799BD54009BC263 /* cool-blue-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = C7E5F24D2799BD52009BC263 /* cool-blue-icon-app-76x76.png */; }; + C7E5F2532799BD54009BC263 /* cool-blue-icon-app-60x60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = C7E5F24E2799BD52009BC263 /* cool-blue-icon-app-60x60@3x.png */; }; + C7E5F2542799BD54009BC263 /* cool-blue-icon-app-60x60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = C7E5F24F2799BD52009BC263 /* cool-blue-icon-app-60x60@2x.png */; }; + C7E5F2552799BD54009BC263 /* cool-blue-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = C7E5F2502799BD52009BC263 /* cool-blue-icon-app-83.5x83.5@2x.png */; }; + C7E5F2562799BD54009BC263 /* cool-blue-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = C7E5F2512799BD52009BC263 /* cool-blue-icon-app-76x76@2x.png */; }; + C7E5F25A2799C2B0009BC263 /* blue-icon-app-76x76.png in Resources */ = {isa = PBXBuildFile; fileRef = C7E5F2572799C2B0009BC263 /* blue-icon-app-76x76.png */; }; + C7E5F25B2799C2B0009BC263 /* blue-icon-app-76x76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = C7E5F2582799C2B0009BC263 /* blue-icon-app-76x76@2x.png */; }; + C7E5F25C2799C2B0009BC263 /* blue-icon-app-83.5x83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = C7E5F2592799C2B0009BC263 /* blue-icon-app-83.5x83.5@2x.png */; }; + C7F79369260D14C100CE547F /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25F9FD2609AA830005E08F /* AppConfiguration.swift */; }; + C7F7936A260D14C200CE547F /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25F9FD2609AA830005E08F /* AppConfiguration.swift */; }; + C7F7ABD6261CED7A00CE547F /* JetpackAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F7ABD5261CED7A00CE547F /* JetpackAuthenticationManager.swift */; }; + C7F7AC75261CF1F300CE547F /* JetpackLoginErrorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F7AC73261CF1F300CE547F /* JetpackLoginErrorViewController.swift */; }; + C7F7AC76261CF1F300CE547F /* JetpackLoginErrorViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C7F7AC74261CF1F300CE547F /* JetpackLoginErrorViewController.xib */; }; + C7F7ACBE261E4F0600CE547F /* JetpackErrorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F7ACBD261E4F0600CE547F /* JetpackErrorViewModel.swift */; }; + C7F7BDBD26262A1B00CE547F /* AppDependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F7BDBC26262A1B00CE547F /* AppDependency.swift */; }; + C7F7BDD026262A4C00CE547F /* AppDependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F7BDCF26262A4C00CE547F /* AppDependency.swift */; }; + C7F7BE0726262B9A00CE547F /* AuthenticationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F7BE0626262B9900CE547F /* AuthenticationHandler.swift */; }; + C7F7BE4C2626301500CE547F /* AuthenticationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7F7BE0626262B9900CE547F /* AuthenticationHandler.swift */; }; + C80512FE243FFD4B00B6B04D /* TenorDataSouceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C856749B243F462F001A995E /* TenorDataSouceTests.swift */; }; + C81CCD63243AECA100A83E27 /* TenorClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD5E243AECA000A83E27 /* TenorClient.swift */; }; + C81CCD64243AECA100A83E27 /* TenorMediaObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD5F243AECA000A83E27 /* TenorMediaObject.swift */; }; + C81CCD65243AECA200A83E27 /* TenorGIF.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD60243AECA100A83E27 /* TenorGIF.swift */; }; + C81CCD67243AECA200A83E27 /* TenorGIFCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD62243AECA100A83E27 /* TenorGIFCollection.swift */; }; + C81CCD6A243AEE1100A83E27 /* TenorAPIResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD69243AEE1100A83E27 /* TenorAPIResponseTests.swift */; }; + C81CCD6C243AEFBF00A83E27 /* TenorReponseData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD6B243AEFBF00A83E27 /* TenorReponseData.swift */; }; + C81CCD6F243AF7D700A83E27 /* TenorReponseParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD6D243AF09900A83E27 /* TenorReponseParser.swift */; }; + C81CCD70243AFAE600A83E27 /* TenorResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD61243AECA100A83E27 /* TenorResponse.swift */; }; + C81CCD7B243BF7A600A83E27 /* TenorStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD71243BF7A500A83E27 /* TenorStrings.swift */; }; + C81CCD7C243BF7A600A83E27 /* TenorPageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD72243BF7A500A83E27 /* TenorPageable.swift */; }; + C81CCD7D243BF7A600A83E27 /* TenorDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD73243BF7A500A83E27 /* TenorDataSource.swift */; }; + C81CCD7E243BF7A600A83E27 /* TenorMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD74243BF7A500A83E27 /* TenorMedia.swift */; }; + C81CCD7F243BF7A600A83E27 /* TenorMediaGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD75243BF7A500A83E27 /* TenorMediaGroup.swift */; }; + C81CCD80243BF7A600A83E27 /* TenorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD76243BF7A600A83E27 /* TenorPicker.swift */; }; + C81CCD81243BF7A600A83E27 /* TenorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD77243BF7A600A83E27 /* TenorService.swift */; }; + C81CCD82243BF7A600A83E27 /* TenorResultsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD78243BF7A600A83E27 /* TenorResultsPage.swift */; }; + C81CCD83243BF7A600A83E27 /* TenorDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD79243BF7A600A83E27 /* TenorDataLoader.swift */; }; + C81CCD84243BF7A600A83E27 /* NoResultsTenorConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD7A243BF7A600A83E27 /* NoResultsTenorConfiguration.swift */; }; + C81CCD86243C00E000A83E27 /* TenorPageableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD85243C00E000A83E27 /* TenorPageableTests.swift */; }; + C856748F243EF177001A995E /* GutenbergTenorMediaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C856748E243EF177001A995E /* GutenbergTenorMediaPicker.swift */; }; + C8567492243F3751001A995E /* tenor-search-response.json in Resources */ = {isa = PBXBuildFile; fileRef = C8567491243F3751001A995E /* tenor-search-response.json */; }; + C8567494243F388F001A995E /* tenor-invalid-search-reponse.json in Resources */ = {isa = PBXBuildFile; fileRef = C8567493243F388F001A995E /* tenor-invalid-search-reponse.json */; }; + C8567496243F3D37001A995E /* TenorResultsPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8567495243F3D37001A995E /* TenorResultsPageTests.swift */; }; + C8567498243F41CA001A995E /* MockTenorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8567497243F41CA001A995E /* MockTenorService.swift */; }; + C856749A243F4292001A995E /* TenorMockDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8567499243F4292001A995E /* TenorMockDataHelper.swift */; }; + C94C0B1B25DCFA0100F2F69B /* FilterableCategoriesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94C0B1A25DCFA0100F2F69B /* FilterableCategoriesViewController.swift */; }; + C957C20626DCC1770037628F /* LandInTheEditorHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C957C20526DCC1770037628F /* LandInTheEditorHelper.swift */; }; + C957C20726DCC1770037628F /* LandInTheEditorHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C957C20526DCC1770037628F /* LandInTheEditorHelper.swift */; }; + C99B08FC26081AD600CA71EB /* TemplatePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99B08FB26081AD600CA71EB /* TemplatePreviewViewController.swift */; }; + C9F1D4B72706ED7C00BDF917 /* EditHomepageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F1D4B62706ED7C00BDF917 /* EditHomepageViewController.swift */; }; + C9F1D4B82706ED7C00BDF917 /* EditHomepageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F1D4B62706ED7C00BDF917 /* EditHomepageViewController.swift */; }; + C9F1D4BA2706EEEB00BDF917 /* HomepageEditorNavigationBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F1D4B92706EEEB00BDF917 /* HomepageEditorNavigationBarManager.swift */; }; + C9F1D4BB2706EEEB00BDF917 /* HomepageEditorNavigationBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9F1D4B92706EEEB00BDF917 /* HomepageEditorNavigationBarManager.swift */; }; + CB1FD8D826E4BBAA00EDAF06 /* SharePostTypePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB48172926E0D93D008C2D9B /* SharePostTypePickerViewController.swift */; }; + CB48172A26E0D93D008C2D9B /* SharePostTypePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB48172926E0D93D008C2D9B /* SharePostTypePickerViewController.swift */; }; + CBF6201326E8FB520061A1F8 /* RemotePost+ShareData.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF6201226E8FB520061A1F8 /* RemotePost+ShareData.swift */; }; + CBF6201426E8FB8A0061A1F8 /* RemotePost+ShareData.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF6201226E8FB520061A1F8 /* RemotePost+ShareData.swift */; }; CC52188C2278C622008998CE /* EditorFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC52188B2278C622008998CE /* EditorFlow.swift */; }; - CC52189A2279CF3B008998CE /* MediaPickerAlbumListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC5218992279CF3B008998CE /* MediaPickerAlbumListScreen.swift */; }; - CC52189C2279D295008998CE /* MediaPickerAlbumScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC52189B2279D295008998CE /* MediaPickerAlbumScreen.swift */; }; CC7CB97322B1510900642EE9 /* SignupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB97222B1510900642EE9 /* SignupTests.swift */; }; - CC7CB97622B15A2900642EE9 /* SignupEmailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB97522B15A2900642EE9 /* SignupEmailScreen.swift */; }; - CC7CB97822B15B2C00642EE9 /* SignupCheckMagicLinkScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB97722B15B2C00642EE9 /* SignupCheckMagicLinkScreen.swift */; }; - CC7CB97A22B15C1000642EE9 /* SignupEpilogueScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB97922B15C1000642EE9 /* SignupEpilogueScreen.swift */; }; - CC7CB98722B28F4600642EE9 /* WelcomeScreenSignupComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB98622B28F4600642EE9 /* WelcomeScreenSignupComponent.swift */; }; - CC8498D32241477F00DB490A /* TagsComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC8498D22241477F00DB490A /* TagsComponent.swift */; }; CC8A5EAB22159FA6001B7874 /* WPUITestCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC8A5EAA22159FA6001B7874 /* WPUITestCredentials.swift */; }; - CC94FC68221452A4002E5825 /* EditorNoticeComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC94FC67221452A4002E5825 /* EditorNoticeComponent.swift */; }; - CC94FC6A2214532D002E5825 /* FancyAlertComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC94FC692214532D002E5825 /* FancyAlertComponent.swift */; }; - CCE55E992242715C002A9634 /* CategoriesComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE55E982242715C002A9634 /* CategoriesComponent.swift */; }; - CCE911BC221D8497007E1D4E /* LoginSiteAddressScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE911BB221D8497007E1D4E /* LoginSiteAddressScreen.swift */; }; - CCE911BE221D85E4007E1D4E /* LoginUsernamePasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE911BD221D85E4007E1D4E /* LoginUsernamePasswordScreen.swift */; }; - CCF6ACE7221ED73900D0C5BE /* LoginCheckMagicLinkScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF6ACE6221ED73900D0C5BE /* LoginCheckMagicLinkScreen.swift */; }; + CCBC9EB4251258FB008E1D5F /* WPUITestCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC8A5EAA22159FA6001B7874 /* WPUITestCredentials.swift */; }; CE1CCB2D204DDD18000EE3AC /* MyProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1CCB2C204DDD18000EE3AC /* MyProfileHeaderView.swift */; }; CE1CCB2F2050502B000EE3AC /* MyProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = CE1CCB2E2050502B000EE3AC /* MyProfileHeaderView.xib */; }; CE39E17220CB117B00CABA05 /* RemoteBlog+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE39E17120CB117B00CABA05 /* RemoteBlog+Capabilities.swift */; }; CE39E17320CB117B00CABA05 /* RemoteBlog+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE39E17120CB117B00CABA05 /* RemoteBlog+Capabilities.swift */; }; CE46018B21139E8300F242B6 /* FooterTextContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE46018A21139E8300F242B6 /* FooterTextContent.swift */; }; CEBD3EAB0FF1BA3B00C1396E /* Blog.m in Sources */ = {isa = PBXBuildFile; fileRef = CEBD3EAA0FF1BA3B00C1396E /* Blog.m */; }; - D800D86420997BA100E7C7E5 /* ReaderMenuItemCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D86320997BA100E7C7E5 /* ReaderMenuItemCreator.swift */; }; - D800D86620997C6E00E7C7E5 /* FollowingMenuItemCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D86520997C6E00E7C7E5 /* FollowingMenuItemCreator.swift */; }; - D800D86820997D5000E7C7E5 /* DiscoverMenuItemCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D86720997D5000E7C7E5 /* DiscoverMenuItemCreator.swift */; }; - D800D86A20997E0C00E7C7E5 /* LikedMenuItemCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D86920997E0C00E7C7E5 /* LikedMenuItemCreator.swift */; }; - D800D86C20997EB400E7C7E5 /* OtherMenuItemCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D86B20997EB400E7C7E5 /* OtherMenuItemCreator.swift */; }; - D800D86E2099857000E7C7E5 /* SearchMenuItemCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D86D2099857000E7C7E5 /* SearchMenuItemCreator.swift */; }; - D800D87020998A7300E7C7E5 /* SavedForLaterMenuItemCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D86F20998A7300E7C7E5 /* SavedForLaterMenuItemCreator.swift */; }; - D800D874209997DB00E7C7E5 /* FollowingMenuItemCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D873209997DB00E7C7E5 /* FollowingMenuItemCreatorTests.swift */; }; - D800D87620999AE700E7C7E5 /* DiscoverMenuItemCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D87520999AE700E7C7E5 /* DiscoverMenuItemCreatorTests.swift */; }; - D800D87820999B6D00E7C7E5 /* LikedMenuItemCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D87720999B6D00E7C7E5 /* LikedMenuItemCreatorTests.swift */; }; - D800D87A20999C0500E7C7E5 /* OtherMenuItemCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D87920999C0500E7C7E5 /* OtherMenuItemCreatorTests.swift */; }; - D800D87C20999CA200E7C7E5 /* SearchMenuItemCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D800D87B20999CA200E7C7E5 /* SearchMenuItemCreatorTests.swift */; }; + CECEEB552823164800A28ADE /* MediaCacheSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECEEB542823164800A28ADE /* MediaCacheSettingsViewController.swift */; }; + CECEEB562823164800A28ADE /* MediaCacheSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CECEEB542823164800A28ADE /* MediaCacheSettingsViewController.swift */; }; + D0E2AA7C4D4CB1679173958E /* Pods_WordPressShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 213A62FF811EBDB969FA7669 /* Pods_WordPressShareExtension.framework */; }; D8071631203DA23700B32FD9 /* Accessible.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8071630203DA23700B32FD9 /* Accessible.swift */; }; D809E686203F0215001AA0DE /* ReaderPostCardCellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D809E685203F0215001AA0DE /* ReaderPostCardCellTests.swift */; }; D80BC79C207464D200614A59 /* MediaLibraryMediaPickingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80BC79B207464D200614A59 /* MediaLibraryMediaPickingCoordinator.swift */; }; @@ -1597,20 +2955,9 @@ D80BC7A02074722000614A59 /* CameraCaptureCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80BC79F2074722000614A59 /* CameraCaptureCoordinator.swift */; }; D80BC7A22074739400614A59 /* MediaLibraryStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80BC7A12074739300614A59 /* MediaLibraryStrings.swift */; }; D80BC7A4207487F200614A59 /* MediaLibraryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80BC7A3207487F200614A59 /* MediaLibraryPicker.swift */; }; - D80EE63A203DEE1B0094C34C /* ReaderFollowedSitesStreamHeaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80EE639203DEE1B0094C34C /* ReaderFollowedSitesStreamHeaderTests.swift */; }; D81322B32050F9110067714D /* NotificationName+Names.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81322B22050F9110067714D /* NotificationName+Names.swift */; }; D813D67F21AA8BBF0055CCA1 /* ShadowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D813D67E21AA8BBF0055CCA1 /* ShadowView.swift */; }; D8160442209C1B0F00ABAFFA /* ReaderSaveForLaterAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8160441209C1B0F00ABAFFA /* ReaderSaveForLaterAction.swift */; }; - D816B8BE2112CC000052CE4D /* NewsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816B8BD2112CC000052CE4D /* NewsItem.swift */; }; - D816B8C02112CC930052CE4D /* NewsItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816B8BF2112CC930052CE4D /* NewsItemTests.swift */; }; - D816B8CA2112D2FD0052CE4D /* LocalNewsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816B8C92112D2FD0052CE4D /* LocalNewsServiceTests.swift */; }; - D816B8CD2112D4AA0052CE4D /* NewsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816B8CC2112D4AA0052CE4D /* NewsManager.swift */; }; - D816B8CF2112D4F90052CE4D /* DefaultNewsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816B8CE2112D4F90052CE4D /* DefaultNewsManager.swift */; }; - D816B8D12112D5960052CE4D /* DefaultNewsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816B8D02112D5960052CE4D /* DefaultNewsManagerTests.swift */; }; - D816B8D42112D6E70052CE4D /* NewsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816B8D22112D6E70052CE4D /* NewsCard.swift */; }; - D816B8D52112D6E70052CE4D /* NewsCard.xib in Resources */ = {isa = PBXBuildFile; fileRef = D816B8D32112D6E70052CE4D /* NewsCard.xib */; }; - D816B8D72112D75C0052CE4D /* NewsCardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816B8D62112D75C0052CE4D /* NewsCardTests.swift */; }; - D816B8D92112D85F0052CE4D /* News.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816B8D82112D85F0052CE4D /* News.swift */; }; D816C1E920E0880400C4D82F /* NotificationAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816C1E820E0880400C4D82F /* NotificationAction.swift */; }; D816C1EC20E0887C00C4D82F /* ApproveComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816C1EB20E0887C00C4D82F /* ApproveComment.swift */; }; D816C1EE20E0892200C4D82F /* Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816C1ED20E0892200C4D82F /* Follow.swift */; }; @@ -1621,9 +2968,6 @@ D817799020ABF26800330998 /* ReaderCellConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D817798F20ABF26800330998 /* ReaderCellConfiguration.swift */; }; D817799420ABFDB300330998 /* ReaderPostCellActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D817799320ABFDB300330998 /* ReaderPostCellActions.swift */; }; D81879D920ABC647000CFA95 /* ReaderTableConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81879D820ABC647000CFA95 /* ReaderTableConfiguration.swift */; }; - D818FFD421915586000E5FEE /* VerticalsStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = D818FFD321915586000E5FEE /* VerticalsStep.swift */; }; - D818FFD72191566B000E5FEE /* VerticalsWizardContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D818FFD52191566B000E5FEE /* VerticalsWizardContent.swift */; }; - D818FFD82191566B000E5FEE /* VerticalsWizardContent.xib in Resources */ = {isa = PBXBuildFile; fileRef = D818FFD62191566B000E5FEE /* VerticalsWizardContent.xib */; }; D81C2F5420F85DB1002AE1F1 /* ApproveCommentActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81C2F5320F85DB1002AE1F1 /* ApproveCommentActionTests.swift */; }; D81C2F5820F86CEA002AE1F1 /* NetworkStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81C2F5720F86CEA002AE1F1 /* NetworkStatus.swift */; }; D81C2F5A20F86E94002AE1F1 /* LikeCommentActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81C2F5920F86E94002AE1F1 /* LikeCommentActionTests.swift */; }; @@ -1648,23 +2992,15 @@ D821C817210036D9002ED995 /* ActivityContentFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D821C816210036D9002ED995 /* ActivityContentFactoryTests.swift */; }; D821C819210037F8002ED995 /* activity-log-activity-content.json in Resources */ = {isa = PBXBuildFile; fileRef = D821C818210037F8002ED995 /* activity-log-activity-content.json */; }; D821C81B21003AE9002ED995 /* FormattableContentGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D821C81A21003AE9002ED995 /* FormattableContentGroupTests.swift */; }; - D82247F92113EF5C00918CEB /* News.strings in Resources */ = {isa = PBXBuildFile; fileRef = D82247F82113EF5C00918CEB /* News.strings */; }; - D82247FB2113F50600918CEB /* News.strings in Resources */ = {isa = PBXBuildFile; fileRef = D82247FA2113F50600918CEB /* News.strings */; }; D82253DC2199411F0014D0E2 /* SiteAddressService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82253DB2199411F0014D0E2 /* SiteAddressService.swift */; }; D82253DF2199418B0014D0E2 /* WebAddressWizardContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82253DD2199418B0014D0E2 /* WebAddressWizardContent.swift */; }; - D82253E02199418B0014D0E2 /* WebAddressWizardContent.xib in Resources */ = {isa = PBXBuildFile; fileRef = D82253DE2199418B0014D0E2 /* WebAddressWizardContent.xib */; }; - D82253E5219956540014D0E2 /* AddressCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82253E3219956540014D0E2 /* AddressCell.swift */; }; - D82253E6219956540014D0E2 /* AddressCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D82253E4219956540014D0E2 /* AddressCell.xib */; }; - D82253EA219A8A720014D0E2 /* SiteInformationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82253E9219A8A720014D0E2 /* SiteInformationStep.swift */; }; - D82253ED219A8A960014D0E2 /* SiteInformationWizardContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82253EB219A8A960014D0E2 /* SiteInformationWizardContent.swift */; }; - D82253EE219A8A960014D0E2 /* SiteInformationWizardContent.xib in Resources */ = {isa = PBXBuildFile; fileRef = D82253EC219A8A960014D0E2 /* SiteInformationWizardContent.xib */; }; + D82253E5219956540014D0E2 /* AddressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82253E3219956540014D0E2 /* AddressTableViewCell.swift */; }; D8225409219AB2030014D0E2 /* SiteInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8225407219AB0520014D0E2 /* SiteInformation.swift */; }; D826D67F211D21C700A5D8FE /* NullMockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = D826D67E211D21C700A5D8FE /* NullMockUserDefaults.swift */; }; - D826D682211D51E300A5D8FE /* ReaderNewsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D826D681211D51E300A5D8FE /* ReaderNewsCard.swift */; }; - D8281CF1212AB34C00D09098 /* NewsStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8281CF0212AB34C00D09098 /* NewsStats.swift */; }; D829C33B21B12EFE00B09F12 /* UIView+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = D829C33A21B12EFE00B09F12 /* UIView+Borders.swift */; }; - D8380CA42192E77F00250609 /* VerticalsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8380CA22192E77F00250609 /* VerticalsCell.swift */; }; - D8380CA52192E77F00250609 /* VerticalsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D8380CA32192E77F00250609 /* VerticalsCell.xib */; }; + D82E087529EEB0B00098F500 /* DashboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82E087429EEB0B00098F500 /* DashboardTests.swift */; }; + D82E087629EEB0B00098F500 /* DashboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82E087429EEB0B00098F500 /* DashboardTests.swift */; }; + D82E087829EEB7AF0098F500 /* DomainsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82E087729EEB7AF0098F500 /* DomainsScreen.swift */; }; D83CA3A520842CAF0060E310 /* Pageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83CA3A420842CAF0060E310 /* Pageable.swift */; }; D83CA3A720842CD90060E310 /* ResultsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83CA3A620842CD90060E310 /* ResultsPage.swift */; }; D83CA3A920842D190060E310 /* StockPhotosPageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83CA3A820842D190060E310 /* StockPhotosPageable.swift */; }; @@ -1699,12 +3035,10 @@ D86572172186C3600023A99C /* WizardDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86572162186C3600023A99C /* WizardDelegate.swift */; }; D865722E2186F96D0023A99C /* SiteSegmentsWizardContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D865722C2186F96B0023A99C /* SiteSegmentsWizardContent.swift */; }; D865722F2186F96D0023A99C /* SiteSegmentsWizardContent.xib in Resources */ = {isa = PBXBuildFile; fileRef = D865722D2186F96C0023A99C /* SiteSegmentsWizardContent.xib */; }; - D871F98C214235C9002849B0 /* NewsBadFormed.strings in Resources */ = {isa = PBXBuildFile; fileRef = D871F98B214235C9002849B0 /* NewsBadFormed.strings */; }; D87A329620ABD60700F4726F /* ReaderTableContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87A329520ABD60700F4726F /* ReaderTableContent.swift */; }; D88106F720C0C9A8001D2F00 /* ReaderSavedPostUndoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88106F520C0C9A8001D2F00 /* ReaderSavedPostUndoCell.swift */; }; D88106F820C0C9A8001D2F00 /* ReaderSavedPostUndoCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D88106F620C0C9A8001D2F00 /* ReaderSavedPostUndoCell.xib */; }; D88106FA20C0CFEE001D2F00 /* ReaderSaveForLaterRemovedPosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88106F920C0CFEE001D2F00 /* ReaderSaveForLaterRemovedPosts.swift */; }; - D88106FC20C0D4A4001D2F00 /* ReaderSavedPostCellActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88106FB20C0D4A4001D2F00 /* ReaderSavedPostCellActions.swift */; }; D88A6492208D7A0A008AE9BC /* MockStockPhotosService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88A6491208D7A0A008AE9BC /* MockStockPhotosService.swift */; }; D88A6494208D7AD0008AE9BC /* DefaultStockPhotosService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88A6493208D7AD0008AE9BC /* DefaultStockPhotosService.swift */; }; D88A6496208D7B0B008AE9BC /* NullStockPhotosService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88A6495208D7B0B008AE9BC /* NullStockPhotosService.swift */; }; @@ -1727,18 +3061,59 @@ D8A3A5B5206A4C7800992576 /* StockPhotosPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A3A5B4206A4C7800992576 /* StockPhotosPicker.swift */; }; D8A468E02181C6450094B82F /* site-segment.json in Resources */ = {isa = PBXBuildFile; fileRef = D8A468DF2181C6450094B82F /* site-segment.json */; }; D8A468E521828D940094B82F /* SiteVerticalsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A468E421828D940094B82F /* SiteVerticalsService.swift */; }; - D8AEA54A21C21BEC00AB4DCB /* NewVerticalCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AEA54821C21BEB00AB4DCB /* NewVerticalCell.swift */; }; - D8AEA54B21C21BEC00AB4DCB /* NewVerticalCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D8AEA54921C21BEC00AB4DCB /* NewVerticalCell.xib */; }; - D8AEA54D21C2216300AB4DCB /* SiteVerticalPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AEA54C21C2216300AB4DCB /* SiteVerticalPresenter.swift */; }; D8B6BEB7203E11F2007C8A19 /* Bundle+LoadFromNib.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B6BEB6203E11F2007C8A19 /* Bundle+LoadFromNib.swift */; }; D8B9B58F204F4EA1003C6042 /* NetworkAware.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B9B58E204F4EA1003C6042 /* NetworkAware.swift */; }; - D8B9B591204F658A003C6042 /* CommentsViewController+NetworkAware.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B9B590204F658A003C6042 /* CommentsViewController+NetworkAware.swift */; }; D8BA274D20FDEA2E007A5C77 /* NotificationTextContentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BA274C20FDEA2E007A5C77 /* NotificationTextContentTests.swift */; }; D8C31CC62188490000A33B35 /* SiteSegmentsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C31CC42188490000A33B35 /* SiteSegmentsCell.swift */; }; D8C31CC72188490000A33B35 /* SiteSegmentsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D8C31CC52188490000A33B35 /* SiteSegmentsCell.xib */; }; D8CB56202181A8CE00554EAE /* SiteSegmentsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8CB561F2181A8CE00554EAE /* SiteSegmentsService.swift */; }; D8D7DF5A20AD18A400B40A2D /* ImgUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126FDFE20A33BDB0010EB6E /* ImgUploadProcessor.swift */; }; D8EB1FD121900810002AE1C4 /* BlogListViewController+SiteCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8EB1FD021900810002AE1C4 /* BlogListViewController+SiteCreation.swift */; }; + DC06DFF927BD52BE00969974 /* WeeklyRoundupBackgroundTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC06DFF827BD52BE00969974 /* WeeklyRoundupBackgroundTaskTests.swift */; }; + DC06DFFC27BD679700969974 /* BlogTitleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC06DFFB27BD679700969974 /* BlogTitleTests.swift */; }; + DC13DB7E293FD09F00E33561 /* StatsInsightsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC13DB7D293FD09F00E33561 /* StatsInsightsStoreTests.swift */; }; + DC2CA0852837B9080037E17E /* SiteStatsInsightsDetailsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2CA0842837B9070037E17E /* SiteStatsInsightsDetailsViewModelTests.swift */; }; + DC3B9B2C27739760003F7249 /* TimeZoneSelectorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3B9B2B27739760003F7249 /* TimeZoneSelectorViewModel.swift */; }; + DC3B9B2D27739760003F7249 /* TimeZoneSelectorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3B9B2B27739760003F7249 /* TimeZoneSelectorViewModel.swift */; }; + DC3B9B2F27739887003F7249 /* TimeZoneSelectorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3B9B2E27739887003F7249 /* TimeZoneSelectorViewModelTests.swift */; }; + DC76668326FD9AC9009254DD /* TimeZoneRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC76668126FD9AC8009254DD /* TimeZoneRow.swift */; }; + DC76668426FD9AC9009254DD /* TimeZoneRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC76668126FD9AC8009254DD /* TimeZoneRow.swift */; }; + DC76668526FD9AC9009254DD /* TimeZoneTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC76668226FD9AC9009254DD /* TimeZoneTableViewCell.swift */; }; + DC76668626FD9AC9009254DD /* TimeZoneTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC76668226FD9AC9009254DD /* TimeZoneTableViewCell.swift */; }; + DC772AF1282009BA00664C02 /* InsightsLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772AEE282009B900664C02 /* InsightsLineChart.swift */; }; + DC772AF2282009BA00664C02 /* InsightsLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772AEE282009B900664C02 /* InsightsLineChart.swift */; }; + DC772AF3282009BA00664C02 /* StatsLineChartConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772AEF282009BA00664C02 /* StatsLineChartConfiguration.swift */; }; + DC772AF4282009BA00664C02 /* StatsLineChartConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772AEF282009BA00664C02 /* StatsLineChartConfiguration.swift */; }; + DC772AF5282009BA00664C02 /* StatsLineChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772AF0282009BA00664C02 /* StatsLineChartView.swift */; }; + DC772AF6282009BA00664C02 /* StatsLineChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772AF0282009BA00664C02 /* StatsLineChartView.swift */; }; + DC772B0128200A3700664C02 /* stats-visits-day-4.json in Resources */ = {isa = PBXBuildFile; fileRef = DC772AFD28200A3600664C02 /* stats-visits-day-4.json */; }; + DC772B0228200A3700664C02 /* stats-visits-day-14.json in Resources */ = {isa = PBXBuildFile; fileRef = DC772AFE28200A3600664C02 /* stats-visits-day-14.json */; }; + DC772B0328200A3700664C02 /* SiteStatsInsightViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772AFF28200A3600664C02 /* SiteStatsInsightViewModelTests.swift */; }; + DC772B0428200A3700664C02 /* stats-visits-day-11.json in Resources */ = {isa = PBXBuildFile; fileRef = DC772B0028200A3600664C02 /* stats-visits-day-11.json */; }; + DC772B0828201F5300664C02 /* ViewsVisitorsLineChartCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = DC772B0528201F5200664C02 /* ViewsVisitorsLineChartCell.xib */; }; + DC772B0928201F5300664C02 /* ViewsVisitorsChartMarker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772B0628201F5200664C02 /* ViewsVisitorsChartMarker.swift */; }; + DC772B0A28201F5300664C02 /* ViewsVisitorsLineChartCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC772B0728201F5300664C02 /* ViewsVisitorsLineChartCell.swift */; }; + DC8F61F727032B3F0087AC5D /* TimeZoneFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8F61F627032B3F0087AC5D /* TimeZoneFormatter.swift */; }; + DC8F61F827032B3F0087AC5D /* TimeZoneFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8F61F627032B3F0087AC5D /* TimeZoneFormatter.swift */; }; + DC8F61FC2703321F0087AC5D /* TimeZoneFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC8F61FB2703321F0087AC5D /* TimeZoneFormatterTests.swift */; }; + DC9AF769285DF8A300EA2A0D /* StatsFollowersChartViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9AF768285DF8A300EA2A0D /* StatsFollowersChartViewModel.swift */; }; + DC9AF76A285DF8A300EA2A0D /* StatsFollowersChartViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9AF768285DF8A300EA2A0D /* StatsFollowersChartViewModel.swift */; }; + DCAD9FCC94B311DCE8988D91 /* Pods_JetpackNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 23F18781EEBE5551D6B4992C /* Pods_JetpackNotificationServiceExtension.framework */; }; + DCC662512810915D00962D0C /* BlogVideoLimitsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC662502810915D00962D0C /* BlogVideoLimitsTests.swift */; }; + DCCDF75B283BEFEA00AA347E /* SiteStatsInsightsDetailsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCCDF75A283BEFEA00AA347E /* SiteStatsInsightsDetailsTableViewController.swift */; }; + DCCDF75C283BEFEA00AA347E /* SiteStatsInsightsDetailsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCCDF75A283BEFEA00AA347E /* SiteStatsInsightsDetailsTableViewController.swift */; }; + DCCDF75E283BF02D00AA347E /* SiteStatsInsightsDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCCDF75D283BF02D00AA347E /* SiteStatsInsightsDetailsViewModel.swift */; }; + DCCDF75F283BF02D00AA347E /* SiteStatsInsightsDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCCDF75D283BF02D00AA347E /* SiteStatsInsightsDetailsViewModel.swift */; }; + DCF892C9282FA37100BB71E1 /* SiteStatsBaseTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF892C8282FA37100BB71E1 /* SiteStatsBaseTableViewController.swift */; }; + DCF892CA282FA37100BB71E1 /* SiteStatsBaseTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF892C8282FA37100BB71E1 /* SiteStatsBaseTableViewController.swift */; }; + DCF892CC282FA3BB00BB71E1 /* SiteStatsImmuTableRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF892CB282FA3BB00BB71E1 /* SiteStatsImmuTableRows.swift */; }; + DCF892CD282FA3BB00BB71E1 /* SiteStatsImmuTableRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF892CB282FA3BB00BB71E1 /* SiteStatsImmuTableRows.swift */; }; + DCF892D0282FA42A00BB71E1 /* SiteStatsImmuTableRowsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF892CF282FA42A00BB71E1 /* SiteStatsImmuTableRowsTests.swift */; }; + DCF892D2282FA45500BB71E1 /* StatsMockDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF892D1282FA45500BB71E1 /* StatsMockDataLoader.swift */; }; + DCFC097329D3549C00277ECB /* DashboardDomainsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFC097229D3549C00277ECB /* DashboardDomainsCardCell.swift */; }; + DCFC097429D3549C00277ECB /* DashboardDomainsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFC097229D3549C00277ECB /* DashboardDomainsCardCell.swift */; }; + DCFC6A29292523D20062D65B /* SiteStatsPinnedItemStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFC6A28292523D20062D65B /* SiteStatsPinnedItemStoreTests.swift */; }; + DF6D9E10C4CEE05331B4DAE5 /* Pods_WordPressNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D3B8D9C4DCD93C57C2B98CDC /* Pods_WordPressNotificationServiceExtension.framework */; }; E100C6BB1741473000AE48D8 /* WordPress-11-12.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = E100C6BA1741472F00AE48D8 /* WordPress-11-12.xcmappingmodel */; }; E10290741F30615A00DAC588 /* Role.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10290731F30615A00DAC588 /* Role.swift */; }; E102B7901E714F24007928E8 /* RecentSitesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102B78F1E714F24007928E8 /* RecentSitesService.swift */; }; @@ -1751,7 +3126,7 @@ E10F3DA11E5C2CE0008FAADA /* PostListFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10F3DA01E5C2CE0008FAADA /* PostListFilterTests.swift */; }; E11000991CDB5F1E00E33887 /* KeychainTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11000981CDB5F1E00E33887 /* KeychainTools.swift */; }; E11330511A13BAA300D36D84 /* me-sites-with-jetpack.json in Resources */ = {isa = PBXBuildFile; fileRef = E11330501A13BAA300D36D84 /* me-sites-with-jetpack.json */; }; - E11450DF1C4E47E600A6BD0F /* NoticeAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11450DE1C4E47E600A6BD0F /* NoticeAnimator.swift */; }; + E11450DF1C4E47E600A6BD0F /* MessageAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11450DE1C4E47E600A6BD0F /* MessageAnimator.swift */; }; E114D79A153D85A800984182 /* WPError.m in Sources */ = {isa = PBXBuildFile; fileRef = E114D799153D85A800984182 /* WPError.m */; }; E11C4B702010930B00A6619C /* Blog+Jetpack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11C4B6F2010930B00A6619C /* Blog+Jetpack.swift */; }; E11C4B72201096EF00A6619C /* JetpackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11C4B71201096EF00A6619C /* JetpackState.swift */; }; @@ -1770,15 +3145,12 @@ E12BE5EE1C5235DB000FD5CA /* get-me-settings-v1.1.json in Resources */ = {isa = PBXBuildFile; fileRef = E12BE5ED1C5235DB000FD5CA /* get-me-settings-v1.1.json */; }; E12DB07B1C48D1C200A6C1D4 /* WPAccount+AccountSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12DB07A1C48D1C200A6C1D4 /* WPAccount+AccountSettings.swift */; }; E12FE0741FA0CEE000F28710 /* ImmuTable+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12FE0731FA0CEE000F28710 /* ImmuTable+Optional.swift */; }; - E131CB5216CACA6B004B0314 /* CoreText.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E131CB5116CACA6B004B0314 /* CoreText.framework */; }; - E131CB5416CACB05004B0314 /* libxml2.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = E131CB5316CACB05004B0314 /* libxml2.dylib */; }; E131CB5616CACF1E004B0314 /* get-user-blogs_has-blog.json in Resources */ = {isa = PBXBuildFile; fileRef = E131CB5516CACF1E004B0314 /* get-user-blogs_has-blog.json */; }; E131CB5816CACFB4004B0314 /* get-user-blogs_doesnt-have-blog.json in Resources */ = {isa = PBXBuildFile; fileRef = E131CB5716CACFB4004B0314 /* get-user-blogs_doesnt-have-blog.json */; }; E135965D1E7152D1006C6606 /* RecentSitesServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E135965C1E7152D1006C6606 /* RecentSitesServiceTests.swift */; }; E137B1661F8B77D4006AC7FC /* WebNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E137B1651F8B77D4006AC7FC /* WebNavigationDelegate.swift */; }; E1389ADB1C59F7C200FB2466 /* PlanListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1389ADA1C59F7C200FB2466 /* PlanListViewController.swift */; }; E13A8C9B1C3E6EF2005BB1C1 /* ImmuTable+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13A8C9A1C3E6EF2005BB1C1 /* ImmuTable+WordPress.swift */; }; - E13ACCD41EE5672100CCE985 /* PostEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13ACCD31EE5672100CCE985 /* PostEditor.swift */; }; E14200781C117A2E00B3B115 /* ManagedAccountSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14200771C117A2E00B3B115 /* ManagedAccountSettings.swift */; }; E142007A1C117A3B00B3B115 /* ManagedAccountSettings+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14200791C117A3B00B3B115 /* ManagedAccountSettings+CoreDataProperties.swift */; }; E1468DE71E794A4D0044D80F /* LanguageSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1468DE61E794A4D0044D80F /* LanguageSelectorViewController.swift */; }; @@ -1813,7 +3185,6 @@ E166FA1B1BB0656B00374B5B /* PeopleCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E166FA1A1BB0656B00374B5B /* PeopleCellViewModel.swift */; }; E16A76F11FC4758300A661E3 /* JetpackSiteRef.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16A76F01FC4758300A661E3 /* JetpackSiteRef.swift */; }; E16A76F31FC4766900A661E3 /* CredentialsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16A76F21FC4766900A661E3 /* CredentialsService.swift */; }; - E16AB92E14D978240047A2E5 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D30AB110D05D00D00671497 /* Foundation.framework */; }; E16FB7E11F8B5D7D0004DD9F /* WebViewControllerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16FB7E01F8B5D7D0004DD9F /* WebViewControllerConfiguration.swift */; }; E16FB7E31F8B61040004DD9F /* WebKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16FB7E21F8B61030004DD9F /* WebKitViewController.swift */; }; E174F6E6172A73960004F23A /* WPAccount.m in Sources */ = {isa = PBXBuildFile; fileRef = E105E9CE1726955600C0D9E7 /* WPAccount.m */; }; @@ -1825,36 +3196,14 @@ E180BD4E1FB4681E00D0D781 /* MockCookieJar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E180BD4D1FB4681E00D0D781 /* MockCookieJar.swift */; }; E18165FD14E4428B006CE885 /* loader.html in Resources */ = {isa = PBXBuildFile; fileRef = E18165FC14E4428B006CE885 /* loader.html */; }; E1823E6C1E42231C00C19F53 /* UIEdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1823E6B1E42231C00C19F53 /* UIEdgeInsets.swift */; }; - E183EC9C16B215FE00C2EB11 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A01C542D0E24E88400D411F2 /* SystemConfiguration.framework */; }; - E183EC9D16B2160200C2EB11 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8355D67D11D13EAD00A61362 /* MobileCoreServices.framework */; }; - E183ECA216B2179B00C2EB11 /* Accounts.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC24E5F41577E16B00A6D5B5 /* Accounts.framework */; }; - E183ECA316B2179B00C2EB11 /* AddressBook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC24E5F01577DBC300A6D5B5 /* AddressBook.framework */; }; - E183ECA416B2179B00C2EB11 /* AssetsLibrary.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 835E2402126E66E50085940B /* AssetsLibrary.framework */; }; - E183ECA516B2179B00C2EB11 /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 374CB16115B93C0800DD0EBC /* AudioToolbox.framework */; }; - E183ECA616B2179B00C2EB11 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1A386C714DB05C300954CF8 /* AVFoundation.framework */; }; - E183ECA716B2179B00C2EB11 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 834CE7331256D0DE0046A4A3 /* CFNetwork.framework */; }; - E183ECA816B2179B00C2EB11 /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8355D7D811D260AA00A61362 /* CoreData.framework */; }; - E183ECA916B2179B00C2EB11 /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83F3E2D211276371004CD686 /* CoreLocation.framework */; }; - E183ECAA16B2179B00C2EB11 /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1A386C914DB05F700954CF8 /* CoreMedia.framework */; }; - E183ECAB16B2179B00C2EB11 /* ImageIO.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD3D6D2B1349F5D30061136A /* ImageIO.framework */; }; - E183ECAC16B2179B00C2EB11 /* libiconv.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = FD21397E13128C5300099582 /* libiconv.dylib */; }; - E183ECAD16B2179B00C2EB11 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = E19DF740141F7BDD000002F3 /* libz.dylib */; }; - E183ECAE16B2179B00C2EB11 /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83F3E25F11275E07004CD686 /* MapKit.framework */; }; - E183ECAF16B2179B00C2EB11 /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83FB4D3E122C38F700DB9506 /* MediaPlayer.framework */; }; - E183ECB016B2179B00C2EB11 /* MessageUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83043E54126FA31400EC9953 /* MessageUI.framework */; }; - E183ECB116B2179B00C2EB11 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 296890770FE971DC00770264 /* Security.framework */; }; - E183ECB216B2179B00C2EB11 /* Twitter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CC24E5F21577DFF400A6D5B5 /* Twitter.framework */; }; E185042F1EE6ABD9005C234C /* Restorer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E185042E1EE6ABD9005C234C /* Restorer.swift */; }; E185474E1DED8D8800D875D7 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E185474D1DED8D8800D875D7 /* UserNotifications.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; - E18549D9230EED73003C620E /* BlogService+Deduplicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18549D8230EED73003C620E /* BlogService+Deduplicate.swift */; }; E18549DB230FBFEF003C620E /* BlogServiceDeduplicationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18549DA230FBFEF003C620E /* BlogServiceDeduplicationTests.swift */; }; - E1928B2E1F8369F100E076C8 /* WebViewAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1928B2D1F8369F100E076C8 /* WebViewAuthenticatorTests.swift */; }; E192E78C22EF453C008D725D /* WordPress-87-88.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = E192E78B22EF453C008D725D /* WordPress-87-88.xcmappingmodel */; }; E19B17AE1E5C6944007517C6 /* BasePost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19B17AD1E5C6944007517C6 /* BasePost.swift */; }; E19B17B01E5C69A5007517C6 /* NSManagedObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19B17AF1E5C69A5007517C6 /* NSManagedObject.swift */; }; E19B17B21E5C8F36007517C6 /* AbstractPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19B17B11E5C8F36007517C6 /* AbstractPost.swift */; }; E19DF741141F7BDD000002F3 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = E19DF740141F7BDD000002F3 /* libz.dylib */; }; - E19FED0A1FBD85F300D77FAB /* WordPressFlux.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E19FED0B1FBD85F300D77FAB /* WordPressFlux.framework */; }; E1A03EE217422DCF0085D192 /* BlogToAccount.m in Sources */ = {isa = PBXBuildFile; fileRef = E1A03EE117422DCE0085D192 /* BlogToAccount.m */; }; E1A03F48174283E10085D192 /* BlogToJetpackAccount.m in Sources */ = {isa = PBXBuildFile; fileRef = E1A03F47174283E00085D192 /* BlogToJetpackAccount.m */; }; E1A386C814DB05C300954CF8 /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1A386C714DB05C300954CF8 /* AVFoundation.framework */; }; @@ -1871,7 +3220,6 @@ E1B23B081BFB3B370006559B /* MyProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B23B071BFB3B370006559B /* MyProfileViewController.swift */; }; E1B62A7B13AA61A100A6FCA4 /* WPWebViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = E1B62A7A13AA61A100A6FCA4 /* WPWebViewController.m */; }; E1B642131EFA5113001DC6D7 /* ModelTestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B642121EFA5113001DC6D7 /* ModelTestHelper.swift */; }; - E1B6A9CC1E54B6B2008FD47E /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E1B6A9CE1E54B6B2008FD47E /* Localizable.strings */; }; E1B84F001E02E94D00BF6434 /* PingHubManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B84EFF1E02E94D00BF6434 /* PingHubManager.swift */; }; E1B912811BB00EFD003C25B9 /* People.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E1B912801BB00EFD003C25B9 /* People.storyboard */; }; E1B912831BB01047003C25B9 /* PeopleRoleBadgeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B912821BB01047003C25B9 /* PeopleRoleBadgeLabel.swift */; }; @@ -1879,19 +3227,17 @@ E1B9128B1BB0129C003C25B9 /* WPStyleGuide+People.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B9128A1BB0129C003C25B9 /* WPStyleGuide+People.swift */; }; E1B9128F1BB05B1D003C25B9 /* PeopleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B912841BB01266003C25B9 /* PeopleCell.swift */; }; E1B921BC1C0ED5A3003EA3CB /* MediaSizeSliderCellTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B921BB1C0ED5A3003EA3CB /* MediaSizeSliderCellTest.swift */; }; - E1BB85981F82459800797050 /* WebViewAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BB85971F82459800797050 /* WebViewAuthenticator.swift */; }; E1BB92321FDAAFFA00F2D817 /* TextWithAccessoryButtonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BB92311FDAAFFA00F2D817 /* TextWithAccessoryButtonCell.swift */; }; E1BEEC631C4E35A8000B4FA0 /* Animator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BEEC621C4E35A8000B4FA0 /* Animator.swift */; }; E1BEEC651C4E3978000B4FA0 /* PaddedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BEEC641C4E3978000B4FA0 /* PaddedLabel.swift */; }; + E1C2260723901AAD0021D03C /* WordPressOrgRestApi+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C2260623901AAD0021D03C /* WordPressOrgRestApi+WordPress.swift */; }; E1C5457E1C6B962D001CEB0E /* MediaSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C5457D1C6B962D001CEB0E /* MediaSettings.swift */; }; E1C545801C6C79BB001CEB0E /* MediaSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C5457F1C6C79BB001CEB0E /* MediaSettingsTests.swift */; }; E1C9AA511C10419200732665 /* Math.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C9AA501C10419200732665 /* Math.swift */; }; E1C9AA561C10427100732665 /* MathTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C9AA551C10427100732665 /* MathTest.swift */; }; E1CA0A6C1FA73053004C4BBE /* PluginStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CA0A6B1FA73053004C4BBE /* PluginStore.swift */; }; E1CB6DA3200F376400945457 /* TimeZoneStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB6DA2200F376400945457 /* TimeZoneStore.swift */; }; - E1CB6DA7200F661900945457 /* TimeZoneSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB6DA6200F661900945457 /* TimeZoneSelectorViewController.swift */; }; E1CE41661E8D1026000CF5A4 /* ShareExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CE41641E8D101A000CF5A4 /* ShareExtractor.swift */; }; - E1CECE051E6F01CE009C6695 /* PostPreviewGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CECE041E6F01CE009C6695 /* PostPreviewGenerator.swift */; }; E1CFC1571E0AC8FF001DF9E9 /* Pattern.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CFC1561E0AC8FF001DF9E9 /* Pattern.swift */; }; E1D0D81616D3B86800E33F4C /* SafariActivity.m in Sources */ = {isa = PBXBuildFile; fileRef = E1D0D81516D3B86800E33F4C /* SafariActivity.m */; }; E1D28E931F2F6EB500A5DAFD /* RoleService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D28E921F2F6EB500A5DAFD /* RoleService.swift */; }; @@ -1922,8 +3268,6 @@ E2E7EB46185FB140004F5E72 /* WPBlogSelectorButton.m in Sources */ = {isa = PBXBuildFile; fileRef = E2E7EB45185FB140004F5E72 /* WPBlogSelectorButton.m */; }; E603C7701BC94AED00AD49D7 /* WordPress-37-38.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = E603C76F1BC94AED00AD49D7 /* WordPress-37-38.xcmappingmodel */; }; E60BD231230A3DD400727E82 /* KeyringAccountHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60BD230230A3DD400727E82 /* KeyringAccountHelper.swift */; }; - E60C2ED51DE5048200488630 /* ReaderCommentCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = E60C2ED41DE5048200488630 /* ReaderCommentCell.xib */; }; - E60C2ED71DE5075100488630 /* ReaderCommentCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60C2ED61DE5075100488630 /* ReaderCommentCell.swift */; }; E61084BE1B9B47BA008050C5 /* ReaderAbstractTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E61084B91B9B47BA008050C5 /* ReaderAbstractTopic.swift */; }; E61084BF1B9B47BA008050C5 /* ReaderDefaultTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E61084BA1B9B47BA008050C5 /* ReaderDefaultTopic.swift */; }; E61084C01B9B47BA008050C5 /* ReaderListTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E61084BB1B9B47BA008050C5 /* ReaderListTopic.swift */; }; @@ -1940,6 +3284,8 @@ E62AFB6C1DC8E593007484FC /* WPRichTextFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62AFB671DC8E593007484FC /* WPRichTextFormatter.swift */; }; E62AFB6D1DC8E593007484FC /* WPTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62AFB681DC8E593007484FC /* WPTextAttachment.swift */; }; E62AFB6E1DC8E593007484FC /* WPTextAttachmentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62AFB691DC8E593007484FC /* WPTextAttachmentManager.swift */; }; + E62CE58E26B1D14200C9D147 /* AccountService+Cookies.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62CE58D26B1D14200C9D147 /* AccountService+Cookies.swift */; }; + E62CE58F26B1D14200C9D147 /* AccountService+Cookies.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62CE58D26B1D14200C9D147 /* AccountService+Cookies.swift */; }; E6311C411EC9FF4A00122529 /* UsersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6311C401EC9FF4A00122529 /* UsersService.swift */; }; E6311C431ECA017E00122529 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6311C421ECA017E00122529 /* UserProfile.swift */; }; E6374DC01C444D8B00F24720 /* PublicizeConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6374DBD1C444D8B00F24720 /* PublicizeConnection.swift */; }; @@ -1950,6 +3296,7 @@ E6431DE61C4E892900FD8D90 /* SharingDetailViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = E6431DE21C4E892900FD8D90 /* SharingDetailViewController.m */; }; E6431DE71C4E892900FD8D90 /* SharingViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = E6431DE41C4E892900FD8D90 /* SharingViewController.m */; }; E64384831C628FCC0052ADB5 /* WPStyleGuide+Sharing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64384821C628FCC0052ADB5 /* WPStyleGuide+Sharing.swift */; }; + E64595F0256B5D7800F7F90C /* CommentAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64595EF256B5D7800F7F90C /* CommentAnalytics.swift */; }; E64ECA4D1CE62041000188A0 /* ReaderSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64ECA4C1CE62041000188A0 /* ReaderSearchViewController.swift */; }; E65219F91B8D10C2000B1217 /* ReaderBlockedSiteCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = E65219F81B8D10C2000B1217 /* ReaderBlockedSiteCell.xib */; }; E65219FB1B8D10DA000B1217 /* ReaderBlockedSiteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65219FA1B8D10DA000B1217 /* ReaderBlockedSiteCell.swift */; }; @@ -1973,15 +3320,16 @@ E684383E221F535900752258 /* LoadMoreCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E684383D221F535900752258 /* LoadMoreCounter.swift */; }; E6843840221F5A2200752258 /* PostListExcessiveLoadMoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E684383F221F5A2200752258 /* PostListExcessiveLoadMoreTests.swift */; }; E68580F61E0D91470090EE63 /* WPHorizontalRuleAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E68580F51E0D91470090EE63 /* WPHorizontalRuleAttachment.swift */; }; + E690F6EF25E05D180015A777 /* InviteLinks+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = E690F6ED25E05D170015A777 /* InviteLinks+CoreDataClass.swift */; }; + E690F6F025E05D180015A777 /* InviteLinks+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = E690F6EE25E05D180015A777 /* InviteLinks+CoreDataProperties.swift */; }; E69551F61B8B6AE200CB8E4F /* ReaderStreamViewController+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E69551F51B8B6AE200CB8E4F /* ReaderStreamViewController+Helper.swift */; }; + E696541F25A8ED7C000E2A52 /* UIApplication+mainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3626F825A665E500D7CCE3 /* UIApplication+mainWindow.swift */; }; + E696542025A8ED7C000E2A52 /* UIApplication+mainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3626F825A665E500D7CCE3 /* UIApplication+mainWindow.swift */; }; E69BA1981BB5D7D300078740 /* WPStyleGuide+ReadableMargins.m in Sources */ = {isa = PBXBuildFile; fileRef = E69BA1971BB5D7D300078740 /* WPStyleGuide+ReadableMargins.m */; }; - E69EF9D51BFA539F00ED0554 /* ReaderDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E69EF9D21BFA539F00ED0554 /* ReaderDetailViewController.swift */; }; - E6A2158E1D10627500DE5270 /* ReaderMenuViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A2158D1D10627500DE5270 /* ReaderMenuViewModel.swift */; }; E6A215901D1065F200DE5270 /* AbstractPostTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A2158F1D1065F200DE5270 /* AbstractPostTest.swift */; }; E6A3384C1BB08E3F00371587 /* ReaderGapMarker.m in Sources */ = {isa = PBXBuildFile; fileRef = E6A3384B1BB08E3F00371587 /* ReaderGapMarker.m */; }; E6A3384E1BB0A50900371587 /* ReaderGapMarkerCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = E6A3384D1BB0A50900371587 /* ReaderGapMarkerCell.xib */; }; E6A338501BB0A70F00371587 /* ReaderGapMarkerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A3384F1BB0A70F00371587 /* ReaderGapMarkerCell.swift */; }; - E6B42CBF1D9DA6270043E228 /* Noticons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E6B42CBE1D9DA6270043E228 /* Noticons.ttf */; }; E6B9B8AA1B94E1FE0001B92F /* ReaderPostTest.m in Sources */ = {isa = PBXBuildFile; fileRef = E6B9B8A91B94E1FE0001B92F /* ReaderPostTest.m */; }; E6B9B8AF1B94FA1C0001B92F /* ReaderStreamViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B9B8AE1B94FA1C0001B92F /* ReaderStreamViewControllerTests.swift */; }; E6BDEA731CE4824300682885 /* ReaderSearchTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6BDEA721CE4824300682885 /* ReaderSearchTopic.swift */; }; @@ -1999,128 +3347,2192 @@ E6D3B1431D1C702600008D4B /* ReaderFollowedSitesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D3B1421D1C702600008D4B /* ReaderFollowedSitesViewController.swift */; }; E6D3E8491BEBD871002692E8 /* ReaderCrossPostCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D3E8481BEBD871002692E8 /* ReaderCrossPostCell.swift */; }; E6D3E84B1BEBD888002692E8 /* ReaderCrossPostCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = E6D3E84A1BEBD888002692E8 /* ReaderCrossPostCell.xib */; }; + E6D6A1302683ABE6004C24A7 /* ReaderSubscribeCommentsAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D6A12F2683ABE6004C24A7 /* ReaderSubscribeCommentsAction.swift */; }; + E6D6A1312683ABE6004C24A7 /* ReaderSubscribeCommentsAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D6A12F2683ABE6004C24A7 /* ReaderSubscribeCommentsAction.swift */; }; E6DAABDD1CF632EC0069D933 /* ReaderSearchSuggestionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DAABDC1CF632EC0069D933 /* ReaderSearchSuggestionsViewController.swift */; }; E6DE44671B90D251000FA7EF /* ReaderHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DE44661B90D251000FA7EF /* ReaderHelpers.swift */; }; E6E27D621C6144DB0063F821 /* SharingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E27D611C6144DB0063F821 /* SharingButton.swift */; }; - E6E57CD51D0F08B200C22E3E /* ReaderMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E57CD41D0F08B200C22E3E /* ReaderMenuViewController.swift */; }; - E6ED09091D46AD29003283C4 /* ReaderFollowedSitesStreamHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6ED09081D46AD29003283C4 /* ReaderFollowedSitesStreamHeader.swift */; }; - E6ED090B1D46AFAF003283C4 /* ReaderFollowedSitesStreamHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = E6ED090A1D46AFAF003283C4 /* ReaderFollowedSitesStreamHeader.xib */; }; - E6F058021C1A122B008000F9 /* ReaderPostMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F058011C1A122B008000F9 /* ReaderPostMenu.swift */; }; E6F2788021BC1A4A008B4DB5 /* Plan.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F2787A21BC1A48008B4DB5 /* Plan.swift */; }; E6F2788121BC1A4A008B4DB5 /* PlanGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F2787B21BC1A48008B4DB5 /* PlanGroup.swift */; }; E6F2788421BC1A4A008B4DB5 /* PlanFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F2787E21BC1A49008B4DB5 /* PlanFeature.swift */; }; E6FACB1E1EC675E300284AC7 /* GravatarProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FACB1D1EC675E300284AC7 /* GravatarProfile.swift */; }; E8DEE110E4BC3FA1974AB1BB /* Pods_WordPressTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B921F5DD9A1F257C792EC225 /* Pods_WordPressTest.framework */; }; + EA14532929AD874C001F3143 /* MainNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA0B7D61CAC1F9F00533B9D /* MainNavigationTests.swift */; }; + EA14532A29AD874C001F3143 /* ReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB10E3F27487F5D000DA4C1 /* ReaderTests.swift */; }; + EA14532B29AD874C001F3143 /* EditorAztecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D82F1FF11DEF00A11345 /* EditorAztecTests.swift */; }; + EA14532C29AD874C001F3143 /* EditorGutenbergTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2BB0CF228ACF710034F9AB /* EditorGutenbergTests.swift */; }; + EA14532D29AD874C001F3143 /* LoginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2716911CAAC87B0006E2D4 /* LoginTests.swift */; }; + EA14532E29AD874C001F3143 /* BaseScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4E9E1FD664F5007AE3E4 /* BaseScreen.swift */; }; + EA14532F29AD874C001F3143 /* SupportScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5BA46826A59D620043A6F2 /* SupportScreenTests.swift */; }; + EA14533029AD874C001F3143 /* StatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD2BF4127594DAB00A847BB /* StatsTests.swift */; }; + EA14533129AD874C001F3143 /* WPUITestCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC8A5EAA22159FA6001B7874 /* WPUITestCredentials.swift */; }; + EA14533229AD874C001F3143 /* SignupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB97222B1510900642EE9 /* SignupTests.swift */; }; + EA14533329AD874C001F3143 /* EditorFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC52188B2278C622008998CE /* EditorFlow.swift */; }; + EA14533429AD874C001F3143 /* UIApplication+mainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3626F825A665E500D7CCE3 /* UIApplication+mainWindow.swift */; }; + EA14533529AD874C001F3143 /* LoginFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D8321FF11E3800A11345 /* LoginFlow.swift */; }; + EA14533629AD874C001F3143 /* XCTest+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2716A01CABC7D40006E2D4 /* XCTest+Extensions.swift */; }; + EA14533829AD874C001F3143 /* UITestsFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3FA640572670CCD40064401E /* UITestsFoundation.framework */; }; + EA14533929AD874C001F3143 /* BuildkiteTestCollector in Frameworks */ = {isa = PBXBuildFile; productRef = EA14532529AD874C001F3143 /* BuildkiteTestCollector */; }; + EA78189427596B2F00554DFA /* ContactUsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA78189327596B2F00554DFA /* ContactUsScreen.swift */; }; + EAB10E4027487F5D000DA4C1 /* ReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAB10E3F27487F5D000DA4C1 /* ReaderTests.swift */; }; + EAD08D0E29D45E23001A72F9 /* CommentsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD08D0D29D45E23001A72F9 /* CommentsScreen.swift */; }; + EAD2BF4227594DAB00A847BB /* StatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD2BF4127594DAB00A847BB /* StatsTests.swift */; }; + EB6DF027AF96D801F280E805 /* Pods_WordPressStatsWidgets.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4D670B9448DF9991366CF42D /* Pods_WordPressStatsWidgets.framework */; }; + F10465142554260600655194 /* BindableTapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10465132554260600655194 /* BindableTapGestureRecognizer.swift */; }; + F10D634F26F0B78E00E46CC7 /* Blog+Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10D634E26F0B78E00E46CC7 /* Blog+Organization.swift */; }; + F10D635026F0B78E00E46CC7 /* Blog+Organization.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10D634E26F0B78E00E46CC7 /* Blog+Organization.swift */; }; F10E655021B0613A007AB2EE /* GutenbergViewController+MoreActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10E654F21B06139007AB2EE /* GutenbergViewController+MoreActions.swift */; }; F110239B2318479000C4E84A /* Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = F110239A2318479000C4E84A /* Media.swift */; }; F11023A1231863CE00C4E84A /* MediaServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11023A0231863CE00C4E84A /* MediaServiceTests.swift */; }; F11023A323186BCA00C4E84A /* MediaBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11023A223186BCA00C4E84A /* MediaBuilder.swift */; }; + F1112AB2255C2D4600F1F746 /* BlogDetailHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1112AB1255C2D4600F1F746 /* BlogDetailHeaderView.swift */; }; + F111B87826580FCE00057942 /* BloggingRemindersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F111B87726580FCE00057942 /* BloggingRemindersStore.swift */; }; + F111B88D2658103C00057942 /* Combine.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F111B88B2658102700057942 /* Combine.framework */; }; F115308121B17E66002F1D65 /* EditorFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F115308021B17E65002F1D65 /* EditorFactory.swift */; }; + F117B120265C53AB00D2CAA9 /* BloggingRemindersScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F117B11F265C53AB00D2CAA9 /* BloggingRemindersScheduler.swift */; }; + F11C9F74243B3C3E00921DDC /* MediaHost+Blog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11C9F73243B3C3E00921DDC /* MediaHost+Blog.swift */; }; + F11C9F76243B3C5E00921DDC /* MediaHost+AbstractPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11C9F75243B3C5E00921DDC /* MediaHost+AbstractPost.swift */; }; + F11C9F78243B3C9600921DDC /* MediaHost+ReaderPostContentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11C9F77243B3C9600921DDC /* MediaHost+ReaderPostContentProvider.swift */; }; F126FE0020A33BDB0010EB6E /* VideoUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126FDFD20A33BDB0010EB6E /* VideoUploadProcessor.swift */; }; F126FE0220A33BDB0010EB6E /* DocumentUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126FDFF20A33BDB0010EB6E /* DocumentUploadProcessor.swift */; }; + F127FFD824213B5600B9D41A /* atomic-get-authentication-cookie-success.json in Resources */ = {isa = PBXBuildFile; fileRef = F127FFD724213B5600B9D41A /* atomic-get-authentication-cookie-success.json */; }; F12E500323C7C5330068CB5E /* WKWebView+UserAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12E500223C7C5330068CB5E /* WKWebView+UserAgent.swift */; }; + F12FA5D92428FA8F0054DA21 /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12FA5D82428FA8F0054DA21 /* AuthenticationService.swift */; }; + F1450CF32437DA3E00A28BFE /* MediaRequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1450CF22437DA3E00A28BFE /* MediaRequestAuthenticator.swift */; }; + F1450CF52437E1A600A28BFE /* MediaHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1450CF42437E1A600A28BFE /* MediaHost.swift */; }; + F1450CF72437E8F800A28BFE /* MediaHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1450CF62437E8F800A28BFE /* MediaHostTests.swift */; }; + F1450CF92437EEBB00A28BFE /* MediaRequestAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1450CF82437EEBB00A28BFE /* MediaRequestAuthenticatorTests.swift */; }; + F1482CE02575BDA4007E4DD6 /* SitesDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1482CDF2575BDA4007E4DD6 /* SitesDataProvider.swift */; }; + F151EC832665271200AEA89E /* BloggingRemindersSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F151EC822665271200AEA89E /* BloggingRemindersSchedulerTests.swift */; }; + F15272FD243B27BC00C8DC7A /* AbstractPost+Local.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15272FC243B27BC00C8DC7A /* AbstractPost+Local.swift */; }; + F15272FF243B28B700C8DC7A /* RequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15272FE243B28B600C8DC7A /* RequestAuthenticator.swift */; }; + F1527301243B290E00C8DC7A /* AbstractPost+Autosave.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1527300243B290D00C8DC7A /* AbstractPost+Autosave.swift */; }; + F1585405267D3B5000A2E966 /* BloggingRemindersFlowIntroViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178DDD05266D68A3006C68C4 /* BloggingRemindersFlowIntroViewController.swift */; }; + F1585419267D3B5700A2E966 /* BloggingRemindersScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F117B11F265C53AB00D2CAA9 /* BloggingRemindersScheduler.swift */; }; + F158541A267D3B6000A2E966 /* BloggingRemindersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F111B87726580FCE00057942 /* BloggingRemindersStore.swift */; }; + F158542E267D3B8A00A2E966 /* BloggingRemindersFlowSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178DDD1A266D7523006C68C4 /* BloggingRemindersFlowSettingsViewController.swift */; }; + F1585442267D3BF900A2E966 /* CalendarDayToggleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178DDD56266E4165006C68C4 /* CalendarDayToggleButton.swift */; }; F15A230420A3EBE300625EA2 /* ImgUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126FDFE20A33BDB0010EB6E /* ImgUploadProcessor.swift */; }; F15A230520A3ECC500625EA2 /* ImgUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126FDFE20A33BDB0010EB6E /* ImgUploadProcessor.swift */; }; + F15D1FBA265C41A900854EE5 /* BloggingRemindersStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15D1FB9265C41A900854EE5 /* BloggingRemindersStoreTests.swift */; }; + F163541626DE2ECE008B625B /* NotificationEventTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F163541526DE2ECE008B625B /* NotificationEventTracker.swift */; }; + F163541726DE2ECE008B625B /* NotificationEventTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F163541526DE2ECE008B625B /* NotificationEventTracker.swift */; }; F1655B4822A6C2FA00227BFB /* Routes+Mbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1655B4722A6C2FA00227BFB /* Routes+Mbar.swift */; }; F16601C423E9E783007950AE /* SharingAuthorizationWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16601C323E9E783007950AE /* SharingAuthorizationWebViewController.swift */; }; F16C35D623F33DE400C81331 /* PageAutoUploadMessageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16C35D523F33DE400C81331 /* PageAutoUploadMessageProvider.swift */; }; F16C35DA23F3F76C00C81331 /* PostAutoUploadMessageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16C35D923F3F76C00C81331 /* PostAutoUploadMessageProvider.swift */; }; F16C35DC23F3F78E00C81331 /* AutoUploadMessageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16C35DB23F3F78E00C81331 /* AutoUploadMessageProvider.swift */; }; + F17196FC257556020051AA98 /* HomeWidgetTodayData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB34ACA25672A90001A74A6 /* HomeWidgetTodayData.swift */; }; + F177986725755F2200AD3836 /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; F17A2A1E23BFBD72001E96AC /* UIView+ExistingConstraints.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A2A1D23BFBD72001E96AC /* UIView+ExistingConstraints.swift */; }; F17A2A2023BFBD84001E96AC /* UIView+ExistingConstraints.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A2A1F23BFBD84001E96AC /* UIView+ExistingConstraints.swift */; }; + F181EDE526B2AC7200C61241 /* BackgroundTasksCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F181EDE426B2AC7200C61241 /* BackgroundTasksCoordinator.swift */; }; + F1863716253E49B8003D4BEF /* AddSiteAlertFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1863715253E49B8003D4BEF /* AddSiteAlertFactory.swift */; }; F18B43781F849F580089B817 /* PostAttachmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18B43771F849F580089B817 /* PostAttachmentTests.swift */; }; + F18CB8962642E58700B90794 /* FixedSizeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18CB8952642E58700B90794 /* FixedSizeImageView.swift */; }; + F18CB8972642E58700B90794 /* FixedSizeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F18CB8952642E58700B90794 /* FixedSizeImageView.swift */; }; + F19153BD2549ED0800629EC4 /* UITextField+WorkaroundContinueIssue.swift in Sources */ = {isa = PBXBuildFile; fileRef = F19153BC2549ED0800629EC4 /* UITextField+WorkaroundContinueIssue.swift */; }; + F195C42B26DFBDC2000EC884 /* BackgroundTasksCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F181EDE426B2AC7200C61241 /* BackgroundTasksCoordinator.swift */; }; + F195C42C26DFBE21000EC884 /* WeeklyRoundupBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D8C6EF26C17A6C002E3323 /* WeeklyRoundupBackgroundTask.swift */; }; + F195C42D26DFBE3A000EC884 /* WordPressBackgroundTaskEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D8C6E826BA94DF002E3323 /* WordPressBackgroundTaskEventHandler.swift */; }; + F198FF3B256D47AB001266EB /* HomeWidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6DA04025646F96002AB88F /* HomeWidgetData.swift */; }; + F198FF4C256D483D001266EB /* TodayWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E58A2E2360D23400E5534B /* TodayWidgetStats.swift */; }; + F198FF5D256D4877001266EB /* HomeWidgetCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA53E9B256571D800F4D9A2 /* HomeWidgetCache.swift */; }; + F1A38F212678C4DA00849843 /* BloggingRemindersFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A38F202678C4DA00849843 /* BloggingRemindersFlow.swift */; }; + F1A38F222678C4DA00849843 /* BloggingRemindersFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A38F202678C4DA00849843 /* BloggingRemindersFlow.swift */; }; + F1A75B9B2732EF3700784A70 /* AboutScreenTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A75B9A2732EF3700784A70 /* AboutScreenTracker.swift */; }; + F1A75B9C2732EF3700784A70 /* AboutScreenTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A75B9A2732EF3700784A70 /* AboutScreenTracker.swift */; }; + F1ACDF6B256D6C120005AE9B /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; + F1ACDF7C256D6C290005AE9B /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; + F1ADCAF7241FEF0C00F150D2 /* AtomicAuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ADCAF6241FEF0C00F150D2 /* AtomicAuthenticationService.swift */; }; F1B1E7A324098FA100549E2A /* BlogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1B1E7A224098FA100549E2A /* BlogTests.swift */; }; + F1BB660C274E704D00A319BE /* LikeUserHelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BB660B274E704D00A319BE /* LikeUserHelperTests.swift */; }; + F1BC842E27035A1800C39993 /* BlogService+Domains.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BC842D27035A1800C39993 /* BlogService+Domains.swift */; }; + F1BC842F27035A1800C39993 /* BlogService+Domains.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1BC842D27035A1800C39993 /* BlogService+Domains.swift */; }; + F1C197A62670DDB100DE1FF7 /* BloggingRemindersTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C197A52670DDB100DE1FF7 /* BloggingRemindersTracker.swift */; }; + F1C197A72670DDB100DE1FF7 /* BloggingRemindersTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C197A52670DDB100DE1FF7 /* BloggingRemindersTracker.swift */; }; + F1C740BF26B18E42005D0809 /* StoreSandboxSecretScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C740BE26B18E42005D0809 /* StoreSandboxSecretScreen.swift */; }; + F1C740C026B1D4D2005D0809 /* StoreSandboxSecretScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C740BE26B18E42005D0809 /* StoreSandboxSecretScreen.swift */; }; F1D690161F82913F00200E30 /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690151F828FF000200E30 /* FeatureFlag.swift */; }; F1D690171F82914200200E30 /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690141F828FF000200E30 /* BuildConfiguration.swift */; }; + F1D8C6E926BA94DF002E3323 /* WordPressBackgroundTaskEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D8C6E826BA94DF002E3323 /* WordPressBackgroundTaskEventHandler.swift */; }; + F1D8C6EB26BABE3E002E3323 /* WeeklyRoundupDebugScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D8C6EA26BABE3E002E3323 /* WeeklyRoundupDebugScreen.swift */; }; + F1D8C6EC26BABE3E002E3323 /* WeeklyRoundupDebugScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D8C6EA26BABE3E002E3323 /* WeeklyRoundupDebugScreen.swift */; }; + F1D8C6F026C17A6C002E3323 /* WeeklyRoundupBackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D8C6EF26C17A6C002E3323 /* WeeklyRoundupBackgroundTask.swift */; }; F1DB8D292288C14400906E2F /* Uploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DB8D282288C14400906E2F /* Uploader.swift */; }; F1DB8D2B2288C24500906E2F /* UploadsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DB8D2A2288C24500906E2F /* UploadsManager.swift */; }; + F1E3536B25B9F74C00992E3A /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E3536A25B9F74C00992E3A /* WindowManager.swift */; }; + F1E72EBA267790110066FF91 /* UIViewController+Dismissal.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E72EB9267790100066FF91 /* UIViewController+Dismissal.swift */; }; + F1E72EBB267790110066FF91 /* UIViewController+Dismissal.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E72EB9267790100066FF91 /* UIViewController+Dismissal.swift */; }; + F1F083F6241FFE930056D3B1 /* AtomicAuthenticationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F083F5241FFE930056D3B1 /* AtomicAuthenticationServiceTests.swift */; }; + F1F163C125658B4D003DC13B /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1F163C025658B4D003DC13B /* IntentHandler.swift */; }; + F1F163D625658B4D003DC13B /* WordPressIntents.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F1F163BE25658B4D003DC13B /* WordPressIntents.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F41BDD73290BBDCA00B7F2B0 /* MigrationActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41BDD72290BBDCA00B7F2B0 /* MigrationActionsView.swift */; }; + F41BDD792910AFCA00B7F2B0 /* MigrationFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41BDD782910AFCA00B7F2B0 /* MigrationFlowCoordinator.swift */; }; + F41BDD7B29114E2400B7F2B0 /* MigrationStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41BDD7A29114E2400B7F2B0 /* MigrationStep.swift */; }; + F41E32FE287B47A500F89082 /* SuggestionsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41E32FD287B47A500F89082 /* SuggestionsListViewModel.swift */; }; + F41E32FF287B47A500F89082 /* SuggestionsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41E32FD287B47A500F89082 /* SuggestionsListViewModel.swift */; }; + F41E3301287B5FE500F89082 /* SuggestionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41E3300287B5FE500F89082 /* SuggestionViewModel.swift */; }; + F41E3302287B5FE500F89082 /* SuggestionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41E3300287B5FE500F89082 /* SuggestionViewModel.swift */; }; + F41E4E8C28F18B7B001880C6 /* AppIconListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F41E4E8B28F18B7B001880C6 /* AppIconListViewModelTests.swift */; }; + F41E4E9528F20802001880C6 /* white-on-pink-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4E9028F20801001880C6 /* white-on-pink-icon-app-76.png */; }; + F41E4E9628F20802001880C6 /* white-on-pink-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4E9128F20801001880C6 /* white-on-pink-icon-app-83.5@2x.png */; }; + F41E4E9728F20802001880C6 /* white-on-pink-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4E9228F20801001880C6 /* white-on-pink-icon-app-76@2x.png */; }; + F41E4E9828F20802001880C6 /* white-on-pink-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4E9328F20802001880C6 /* white-on-pink-icon-app-60@3x.png */; }; + F41E4E9928F20802001880C6 /* white-on-pink-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4E9428F20802001880C6 /* white-on-pink-icon-app-60@2x.png */; }; + F41E4E9F28F20AB8001880C6 /* white-on-celadon-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4E9A28F20AB7001880C6 /* white-on-celadon-icon-app-76@2x.png */; }; + F41E4EA028F20AB8001880C6 /* white-on-celadon-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4E9B28F20AB7001880C6 /* white-on-celadon-icon-app-60@3x.png */; }; + F41E4EA128F20AB8001880C6 /* white-on-celadon-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4E9C28F20AB7001880C6 /* white-on-celadon-icon-app-83.5@2x.png */; }; + F41E4EA228F20AB8001880C6 /* white-on-celadon-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4E9D28F20AB7001880C6 /* white-on-celadon-icon-app-76.png */; }; + F41E4EA328F20AB8001880C6 /* white-on-celadon-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4E9E28F20AB8001880C6 /* white-on-celadon-icon-app-60@2x.png */; }; + F41E4EAA28F20DF9001880C6 /* stroke-light-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EA528F20DF9001880C6 /* stroke-light-icon-app-76.png */; }; + F41E4EAB28F20DF9001880C6 /* stroke-light-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EA628F20DF9001880C6 /* stroke-light-icon-app-60@2x.png */; }; + F41E4EAC28F20DF9001880C6 /* stroke-light-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EA728F20DF9001880C6 /* stroke-light-icon-app-60@3x.png */; }; + F41E4EAD28F20DF9001880C6 /* stroke-light-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EA828F20DF9001880C6 /* stroke-light-icon-app-83.5@2x.png */; }; + F41E4EAE28F20DF9001880C6 /* stroke-light-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EA928F20DF9001880C6 /* stroke-light-icon-app-76@2x.png */; }; + F41E4EB528F225DB001880C6 /* stroke-dark-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EB028F225DB001880C6 /* stroke-dark-icon-app-76.png */; }; + F41E4EB628F225DB001880C6 /* stroke-dark-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EB128F225DB001880C6 /* stroke-dark-icon-app-83.5@2x.png */; }; + F41E4EB728F225DB001880C6 /* stroke-dark-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EB228F225DB001880C6 /* stroke-dark-icon-app-60@2x.png */; }; + F41E4EB828F225DB001880C6 /* stroke-dark-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EB328F225DB001880C6 /* stroke-dark-icon-app-60@3x.png */; }; + F41E4EB928F225DB001880C6 /* stroke-dark-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EB428F225DB001880C6 /* stroke-dark-icon-app-76@2x.png */; }; + F41E4EC028F22932001880C6 /* spectrum-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EBB28F22931001880C6 /* spectrum-icon-app-83.5@2x.png */; }; + F41E4EC128F22932001880C6 /* spectrum-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EBC28F22931001880C6 /* spectrum-icon-app-76.png */; }; + F41E4EC228F22932001880C6 /* spectrum-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EBD28F22931001880C6 /* spectrum-icon-app-76@2x.png */; }; + F41E4EC328F22932001880C6 /* spectrum-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EBE28F22931001880C6 /* spectrum-icon-app-60@3x.png */; }; + F41E4EC428F22932001880C6 /* spectrum-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EBF28F22931001880C6 /* spectrum-icon-app-60@2x.png */; }; + F41E4ECB28F23E00001880C6 /* green-on-white-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EC628F23E00001880C6 /* green-on-white-icon-app-60@3x.png */; }; + F41E4ECC28F23E00001880C6 /* green-on-white-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EC728F23E00001880C6 /* green-on-white-icon-app-60@2x.png */; }; + F41E4ECD28F23E00001880C6 /* green-on-white-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EC828F23E00001880C6 /* green-on-white-icon-app-76.png */; }; + F41E4ECE28F23E00001880C6 /* green-on-white-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EC928F23E00001880C6 /* green-on-white-icon-app-76@2x.png */; }; + F41E4ECF28F23E00001880C6 /* green-on-white-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4ECA28F23E00001880C6 /* green-on-white-icon-app-83.5@2x.png */; }; + F41E4ED628F2424B001880C6 /* dark-glow-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4ED128F2424B001880C6 /* dark-glow-icon-app-83.5@2x.png */; }; + F41E4ED728F2424B001880C6 /* dark-glow-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4ED228F2424B001880C6 /* dark-glow-icon-app-76.png */; }; + F41E4ED828F2424B001880C6 /* dark-glow-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4ED328F2424B001880C6 /* dark-glow-icon-app-60@3x.png */; }; + F41E4ED928F2424B001880C6 /* dark-glow-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4ED428F2424B001880C6 /* dark-glow-icon-app-60@2x.png */; }; + F41E4EDA28F2424B001880C6 /* dark-glow-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4ED528F2424B001880C6 /* dark-glow-icon-app-76@2x.png */; }; + F41E4EE128F24623001880C6 /* 3d-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EDC28F24623001880C6 /* 3d-icon-app-76@2x.png */; }; + F41E4EE228F24623001880C6 /* 3d-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EDD28F24623001880C6 /* 3d-icon-app-83.5@2x.png */; }; + F41E4EE328F24623001880C6 /* 3d-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EDE28F24623001880C6 /* 3d-icon-app-60@3x.png */; }; + F41E4EE428F24623001880C6 /* 3d-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EDF28F24623001880C6 /* 3d-icon-app-60@2x.png */; }; + F41E4EE528F24623001880C6 /* 3d-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EE028F24623001880C6 /* 3d-icon-app-76.png */; }; + F41E4EEC28F247D3001880C6 /* white-on-green-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EE728F247D2001880C6 /* white-on-green-icon-app-83.5@2x.png */; }; + F41E4EED28F247D3001880C6 /* white-on-green-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EE828F247D3001880C6 /* white-on-green-icon-app-60@3x.png */; }; + F41E4EEE28F247D3001880C6 /* white-on-green-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EE928F247D3001880C6 /* white-on-green-icon-app-76.png */; }; + F41E4EEF28F247D3001880C6 /* white-on-green-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EEA28F247D3001880C6 /* white-on-green-icon-app-60@2x.png */; }; + F41E4EF028F247D3001880C6 /* white-on-green-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F41E4EEB28F247D3001880C6 /* white-on-green-icon-app-76@2x.png */; }; + F42A1D9729928B360059CC70 /* BlockedAuthor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42A1D9629928B360059CC70 /* BlockedAuthor.swift */; }; + F4426FD3287E08C300218003 /* SuggestionServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4426FD2287E08C300218003 /* SuggestionServiceMock.swift */; }; + F4426FD9287F02FD00218003 /* SiteSuggestionsServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4426FD8287F02FD00218003 /* SiteSuggestionsServiceMock.swift */; }; + F4426FDB287F066400218003 /* site-suggestions.json in Resources */ = {isa = PBXBuildFile; fileRef = F4426FDA287F066400218003 /* site-suggestions.json */; }; + F44293D228E3B18E00D340AF /* AppIconListViewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F44293D128E3B18E00D340AF /* AppIconListViewModelType.swift */; }; + F44293D328E3B18E00D340AF /* AppIconListViewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F44293D128E3B18E00D340AF /* AppIconListViewModelType.swift */; }; + F44293D628E3BA1700D340AF /* AppIconListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F44293D528E3BA1700D340AF /* AppIconListViewModel.swift */; }; + F44293DD28E45DBA00D340AF /* AppIconListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F44293D528E3BA1700D340AF /* AppIconListViewModel.swift */; }; + F446B843296F2DED008B94B7 /* MigrationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E79300296EEE320025E8E0 /* MigrationState.swift */; }; + F44FB6CB287895AF0001E3CE /* SuggestionsListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F44FB6CA287895AF0001E3CE /* SuggestionsListViewModelTests.swift */; }; + F44FB6CD287897F90001E3CE /* SuggestionsTableViewMockDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F44FB6CC287897F90001E3CE /* SuggestionsTableViewMockDelegate.swift */; }; + F44FB6D12878A1020001E3CE /* user-suggestions.json in Resources */ = {isa = PBXBuildFile; fileRef = F44FB6D02878A1020001E3CE /* user-suggestions.json */; }; + F45326D829F6B8A6005F9F31 /* SiteCreationAnalyticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45326D729F6B8A6005F9F31 /* SiteCreationAnalyticsEvent.swift */; }; + F45326D929F6B8A6005F9F31 /* SiteCreationAnalyticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F45326D729F6B8A6005F9F31 /* SiteCreationAnalyticsEvent.swift */; }; + F4552086299D147B00D9F6A8 /* BlockedSite.swift in Sources */ = {isa = PBXBuildFile; fileRef = F48D44B5298992C30051EAA6 /* BlockedSite.swift */; }; + F465976E28E4669200D5F49A /* cool-green-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465976928E4669200D5F49A /* cool-green-icon-app-76@2x.png */; }; + F465976F28E4669200D5F49A /* cool-green-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F465976A28E4669200D5F49A /* cool-green-icon-app-76.png */; }; + F465977028E4669200D5F49A /* cool-green-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465976B28E4669200D5F49A /* cool-green-icon-app-60@3x.png */; }; + F465977128E4669200D5F49A /* cool-green-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465976C28E4669200D5F49A /* cool-green-icon-app-60@2x.png */; }; + F465977228E4669200D5F49A /* cool-green-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465976D28E4669200D5F49A /* cool-green-icon-app-83.5@2x.png */; }; + F465977928E6598900D5F49A /* black-on-white-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F465977428E6598800D5F49A /* black-on-white-icon-app-76.png */; }; + F465977A28E6598900D5F49A /* black-on-white-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465977528E6598800D5F49A /* black-on-white-icon-app-60@3x.png */; }; + F465977B28E6598900D5F49A /* black-on-white-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465977628E6598900D5F49A /* black-on-white-icon-app-76@2x.png */; }; + F465977C28E6598900D5F49A /* black-on-white-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465977728E6598900D5F49A /* black-on-white-icon-app-60@2x.png */; }; + F465977D28E6598900D5F49A /* black-on-white-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465977828E6598900D5F49A /* black-on-white-icon-app-83.5@2x.png */; }; + F465978428E65E1800D5F49A /* blue-on-white-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465977F28E65E1600D5F49A /* blue-on-white-icon-app-76@2x.png */; }; + F465978528E65E1800D5F49A /* blue-on-white-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465978028E65E1700D5F49A /* blue-on-white-icon-app-60@2x.png */; }; + F465978628E65E1800D5F49A /* blue-on-white-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465978128E65E1700D5F49A /* blue-on-white-icon-app-83.5@2x.png */; }; + F465978728E65E1800D5F49A /* blue-on-white-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F465978228E65E1700D5F49A /* blue-on-white-icon-app-76.png */; }; + F465978828E65E1800D5F49A /* blue-on-white-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465978328E65E1700D5F49A /* blue-on-white-icon-app-60@3x.png */; }; + F465978F28E65F8A00D5F49A /* celadon-on-white-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465978A28E65F8900D5F49A /* celadon-on-white-icon-app-60@2x.png */; }; + F465979028E65F8A00D5F49A /* celadon-on-white-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465978B28E65F8900D5F49A /* celadon-on-white-icon-app-76@2x.png */; }; + F465979128E65F8A00D5F49A /* celadon-on-white-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F465978C28E65F8900D5F49A /* celadon-on-white-icon-app-76.png */; }; + F465979228E65F8A00D5F49A /* celadon-on-white-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465978D28E65F8900D5F49A /* celadon-on-white-icon-app-60@3x.png */; }; + F465979328E65F8A00D5F49A /* celadon-on-white-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465978E28E65F8A00D5F49A /* celadon-on-white-icon-app-83.5@2x.png */; }; + F465979A28E65FC800D5F49A /* dark-green-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465979528E65FC700D5F49A /* dark-green-icon-app-60@3x.png */; }; + F465979B28E65FC800D5F49A /* dark-green-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465979628E65FC700D5F49A /* dark-green-icon-app-76@2x.png */; }; + F465979C28E65FC800D5F49A /* dark-green-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465979728E65FC800D5F49A /* dark-green-icon-app-60@2x.png */; }; + F465979D28E65FC800D5F49A /* dark-green-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465979828E65FC800D5F49A /* dark-green-icon-app-83.5@2x.png */; }; + F465979E28E65FC800D5F49A /* dark-green-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F465979928E65FC800D5F49A /* dark-green-icon-app-76.png */; }; + F46597A528E6600800D5F49A /* jetpack-light-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597A028E6600700D5F49A /* jetpack-light-icon-app-60@3x.png */; }; + F46597A628E6600800D5F49A /* jetpack-light-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597A128E6600700D5F49A /* jetpack-light-icon-app-83.5@2x.png */; }; + F46597A728E6600800D5F49A /* jetpack-light-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597A228E6600700D5F49A /* jetpack-light-icon-app-76.png */; }; + F46597A828E6600800D5F49A /* jetpack-light-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597A328E6600800D5F49A /* jetpack-light-icon-app-60@2x.png */; }; + F46597A928E6600800D5F49A /* jetpack-light-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597A428E6600800D5F49A /* jetpack-light-icon-app-76@2x.png */; }; + F46597B028E6605E00D5F49A /* neu-green-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597AB28E6605C00D5F49A /* neu-green-icon-app-76.png */; }; + F46597B128E6605E00D5F49A /* neu-green-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597AC28E6605D00D5F49A /* neu-green-icon-app-76@2x.png */; }; + F46597B228E6605E00D5F49A /* neu-green-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597AD28E6605D00D5F49A /* neu-green-icon-app-83.5@2x.png */; }; + F46597B328E6605E00D5F49A /* neu-green-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597AE28E6605D00D5F49A /* neu-green-icon-app-60@2x.png */; }; + F46597B428E6605E00D5F49A /* neu-green-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597AF28E6605D00D5F49A /* neu-green-icon-app-60@3x.png */; }; + F46597BB28E6687800D5F49A /* neumorphic-dark-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597B628E6687700D5F49A /* neumorphic-dark-icon-app-76@2x.png */; }; + F46597BC28E6687800D5F49A /* neumorphic-dark-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597B728E6687700D5F49A /* neumorphic-dark-icon-app-60@2x.png */; }; + F46597BD28E6687800D5F49A /* neumorphic-dark-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597B828E6687700D5F49A /* neumorphic-dark-icon-app-76.png */; }; + F46597BE28E6687800D5F49A /* neumorphic-dark-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597B928E6687700D5F49A /* neumorphic-dark-icon-app-60@3x.png */; }; + F46597BF28E6687800D5F49A /* neumorphic-dark-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597BA28E6687700D5F49A /* neumorphic-dark-icon-app-83.5@2x.png */; }; + F46597C628E668B900D5F49A /* neumorphic-light-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597C128E668B800D5F49A /* neumorphic-light-icon-app-83.5@2x.png */; }; + F46597C728E668B900D5F49A /* neumorphic-light-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597C228E668B800D5F49A /* neumorphic-light-icon-app-76@2x.png */; }; + F46597C828E668B900D5F49A /* neumorphic-light-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597C328E668B900D5F49A /* neumorphic-light-icon-app-60@3x.png */; }; + F46597C928E668B900D5F49A /* neumorphic-light-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597C428E668B900D5F49A /* neumorphic-light-icon-app-76.png */; }; + F46597CA28E668B900D5F49A /* neumorphic-light-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597C528E668B900D5F49A /* neumorphic-light-icon-app-60@2x.png */; }; + F46597DC28E6694200D5F49A /* pink-on-white-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597D728E6694100D5F49A /* pink-on-white-icon-app-60@3x.png */; }; + F46597DD28E6694200D5F49A /* pink-on-white-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597D828E6694100D5F49A /* pink-on-white-icon-app-60@2x.png */; }; + F46597DE28E6694200D5F49A /* pink-on-white-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597D928E6694100D5F49A /* pink-on-white-icon-app-76@2x.png */; }; + F46597DF28E6694200D5F49A /* pink-on-white-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597DA28E6694100D5F49A /* pink-on-white-icon-app-83.5@2x.png */; }; + F46597E028E6694200D5F49A /* pink-on-white-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597DB28E6694200D5F49A /* pink-on-white-icon-app-76.png */; }; + F46597E728E6698D00D5F49A /* spectrum-on-black-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597E228E6698C00D5F49A /* spectrum-on-black-icon-app-76@2x.png */; }; + F46597E828E6698D00D5F49A /* spectrum-on-black-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597E328E6698C00D5F49A /* spectrum-on-black-icon-app-60@2x.png */; }; + F46597E928E6698D00D5F49A /* spectrum-on-black-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597E428E6698C00D5F49A /* spectrum-on-black-icon-app-83.5@2x.png */; }; + F46597EA28E6698D00D5F49A /* spectrum-on-black-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597E528E6698C00D5F49A /* spectrum-on-black-icon-app-76.png */; }; + F46597EB28E6698D00D5F49A /* spectrum-on-black-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597E628E6698D00D5F49A /* spectrum-on-black-icon-app-60@3x.png */; }; + F46597F228E669D400D5F49A /* spectrum-on-white-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597ED28E669D300D5F49A /* spectrum-on-white-icon-app-76.png */; }; + F46597F328E669D400D5F49A /* spectrum-on-white-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597EE28E669D300D5F49A /* spectrum-on-white-icon-app-83.5@2x.png */; }; + F46597F428E669D400D5F49A /* spectrum-on-white-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597EF28E669D400D5F49A /* spectrum-on-white-icon-app-60@3x.png */; }; + F46597F528E669D400D5F49A /* spectrum-on-white-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597F028E669D400D5F49A /* spectrum-on-white-icon-app-76@2x.png */; }; + F46597F628E669D400D5F49A /* spectrum-on-white-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597F128E669D400D5F49A /* spectrum-on-white-icon-app-60@2x.png */; }; + F46597FD28E66A1100D5F49A /* white-on-black-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597F828E66A1000D5F49A /* white-on-black-icon-app-60@2x.png */; }; + F46597FE28E66A1100D5F49A /* white-on-black-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597F928E66A1000D5F49A /* white-on-black-icon-app-76.png */; }; + F46597FF28E66A1100D5F49A /* white-on-black-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597FA28E66A1100D5F49A /* white-on-black-icon-app-76@2x.png */; }; + F465980028E66A1100D5F49A /* white-on-black-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597FB28E66A1100D5F49A /* white-on-black-icon-app-83.5@2x.png */; }; + F465980128E66A1100D5F49A /* white-on-black-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F46597FC28E66A1100D5F49A /* white-on-black-icon-app-60@3x.png */; }; + F465980828E66A5B00D5F49A /* white-on-blue-icon-app-76@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465980328E66A5A00D5F49A /* white-on-blue-icon-app-76@2x.png */; }; + F465980928E66A5B00D5F49A /* white-on-blue-icon-app-76.png in Resources */ = {isa = PBXBuildFile; fileRef = F465980428E66A5A00D5F49A /* white-on-blue-icon-app-76.png */; }; + F465980A28E66A5B00D5F49A /* white-on-blue-icon-app-60@3x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465980528E66A5A00D5F49A /* white-on-blue-icon-app-60@3x.png */; }; + F465980B28E66A5B00D5F49A /* white-on-blue-icon-app-60@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465980628E66A5A00D5F49A /* white-on-blue-icon-app-60@2x.png */; }; + F465980C28E66A5B00D5F49A /* white-on-blue-icon-app-83.5@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = F465980728E66A5B00D5F49A /* white-on-blue-icon-app-83.5@2x.png */; }; + F478B152292FC1BC00AA8645 /* MigrationAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F478B151292FC1BC00AA8645 /* MigrationAppearance.swift */; }; + F47E154A29E84A9300B6E426 /* DomainPurchasingWebFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F47E154929E84A9300B6E426 /* DomainPurchasingWebFlowController.swift */; }; + F47E154B29E84A9300B6E426 /* DomainPurchasingWebFlowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F47E154929E84A9300B6E426 /* DomainPurchasingWebFlowController.swift */; }; + F48D44B6298992C30051EAA6 /* BlockedSite.swift in Sources */ = {isa = PBXBuildFile; fileRef = F48D44B5298992C30051EAA6 /* BlockedSite.swift */; }; + F48D44BA2989A58C0051EAA6 /* ReaderSiteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F48D44B92989A58C0051EAA6 /* ReaderSiteService.swift */; }; + F48D44BB2989A9070051EAA6 /* ReaderSiteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F48D44B92989A58C0051EAA6 /* ReaderSiteService.swift */; }; + F48D44BC2989AA8A0051EAA6 /* ReaderSiteService.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D44EB371986D8BA008B7175 /* ReaderSiteService.m */; }; + F48D44BD2989AA8C0051EAA6 /* ReaderSiteService.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D44EB371986D8BA008B7175 /* ReaderSiteService.m */; }; + F49B99FF2937C9B4000CEFCE /* MigrationEmailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49B99FE2937C9B4000CEFCE /* MigrationEmailService.swift */; }; + F49B9A0029393049000CEFCE /* MigrationAppDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33A5ADB2935848F00961E3A /* MigrationAppDetection.swift */; }; + F49B9A06293A21BF000CEFCE /* MigrationAnalyticsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49B9A05293A21BF000CEFCE /* MigrationAnalyticsTracker.swift */; }; + F49B9A08293A21F4000CEFCE /* MigrationEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49B9A07293A21F4000CEFCE /* MigrationEvent.swift */; }; + F49B9A09293A3243000CEFCE /* MigrationEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49B9A07293A21F4000CEFCE /* MigrationEvent.swift */; }; + F49B9A0A293A3249000CEFCE /* MigrationAnalyticsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49B9A05293A21BF000CEFCE /* MigrationAnalyticsTracker.swift */; }; + F49D7BEB29DF329500CB93A5 /* UIPopoverPresentationController+PopoverAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49D7BEA29DF329500CB93A5 /* UIPopoverPresentationController+PopoverAnchor.swift */; }; + F49D7BEC29DF329500CB93A5 /* UIPopoverPresentationController+PopoverAnchor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F49D7BEA29DF329500CB93A5 /* UIPopoverPresentationController+PopoverAnchor.swift */; }; + F4BECD1B288EE5220078391A /* SuggestionsViewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BECD1A288EE5220078391A /* SuggestionsViewModelType.swift */; }; + F4BECD1C288EE5220078391A /* SuggestionsViewModelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4BECD1A288EE5220078391A /* SuggestionsViewModelType.swift */; }; + F4CBE3D429258AE1004FFBB6 /* MeHeaderViewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FB0ACC292587D500F651F9 /* MeHeaderViewConfiguration.swift */; }; + F4CBE3D6292597E3004FFBB6 /* SupportTableViewControllerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4CBE3D5292597E3004FFBB6 /* SupportTableViewControllerConfiguration.swift */; }; + F4CBE3D7292597E3004FFBB6 /* SupportTableViewControllerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4CBE3D5292597E3004FFBB6 /* SupportTableViewControllerConfiguration.swift */; }; + F4CBE3D929265BC8004FFBB6 /* LogOutActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4CBE3D829265BC8004FFBB6 /* LogOutActionHandler.swift */; }; + F4CBE3DA29265BC8004FFBB6 /* LogOutActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4CBE3D829265BC8004FFBB6 /* LogOutActionHandler.swift */; }; + F4D36AD5298498E600E6B84C /* ReaderPostBlockingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D36AD4298498E600E6B84C /* ReaderPostBlockingController.swift */; }; + F4D36AD6298498E600E6B84C /* ReaderPostBlockingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D36AD4298498E600E6B84C /* ReaderPostBlockingController.swift */; }; + F4D829622930E9F300038726 /* MigrationDeleteWordPressViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D829612930E9F300038726 /* MigrationDeleteWordPressViewController.swift */; }; + F4D829642930EA4C00038726 /* MigrationDeleteWordPressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D829632930EA4C00038726 /* MigrationDeleteWordPressViewModel.swift */; }; + F4D829662931046F00038726 /* UIButton+Dismiss.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D829652931046F00038726 /* UIButton+Dismiss.swift */; }; + F4D829682931059000038726 /* MigrationSuccessActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D829672931059000038726 /* MigrationSuccessActionHandler.swift */; }; + F4D8296A2931083000038726 /* MigrationSuccessCell+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D829692931083000038726 /* MigrationSuccessCell+WordPress.swift */; }; + F4D8296C2931087100038726 /* MigrationSuccessCell+Jetpack.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D8296B2931087100038726 /* MigrationSuccessCell+Jetpack.swift */; }; + F4D829702931097900038726 /* DashboardMigrationSuccessCell+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D8296F2931097900038726 /* DashboardMigrationSuccessCell+WordPress.swift */; }; + F4D82972293109A600038726 /* DashboardMigrationSuccessCell+Jetpack.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D82971293109A600038726 /* DashboardMigrationSuccessCell+Jetpack.swift */; }; + F4D9188629D78C9100974A71 /* BlogDetailsViewController+Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D9188529D78C9100974A71 /* BlogDetailsViewController+Strings.swift */; }; + F4D9188729D78C9100974A71 /* BlogDetailsViewController+Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D9188529D78C9100974A71 /* BlogDetailsViewController+Strings.swift */; }; + F4D9AF4F288AD2E300803D40 /* SuggestionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D9AF4E288AD2E300803D40 /* SuggestionViewModelTests.swift */; }; + F4D9AF51288AE23500803D40 /* SuggestionTableViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D9AF50288AE23500803D40 /* SuggestionTableViewTests.swift */; }; + F4D9AF53288AE2BA00803D40 /* SuggestionsTableViewDelegateMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D9AF52288AE2BA00803D40 /* SuggestionsTableViewDelegateMock.swift */; }; + F4DDE2C229C92F0D00C02A76 /* CrashLogging+Singleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4DDE2C129C92F0D00C02A76 /* CrashLogging+Singleton.swift */; }; + F4DDE2C329C92F0D00C02A76 /* CrashLogging+Singleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4DDE2C129C92F0D00C02A76 /* CrashLogging+Singleton.swift */; }; + F4E79301296EEE320025E8E0 /* MigrationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E79300296EEE320025E8E0 /* MigrationState.swift */; }; + F4EDAA4C29A516EA00622D3D /* ReaderPostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4EDAA4B29A516E900622D3D /* ReaderPostService.swift */; }; + F4EDAA4D29A516EA00622D3D /* ReaderPostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4EDAA4B29A516E900622D3D /* ReaderPostService.swift */; }; + F4EDAA5129A795C600622D3D /* BlockedAuthor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42A1D9629928B360059CC70 /* BlockedAuthor.swift */; }; + F4EF4BAB291D3D4700147B61 /* SiteIconTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4EF4BAA291D3D4700147B61 /* SiteIconTests.swift */; }; + F4F9D5EA2909622E00502576 /* MigrationWelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F9D5E92909622E00502576 /* MigrationWelcomeViewController.swift */; }; + F4F9D5EC29096CF500502576 /* MigrationHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F9D5EB29096CF500502576 /* MigrationHeaderView.swift */; }; + F4F9D5F2290993D400502576 /* MigrationWelcomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F9D5F1290993D400502576 /* MigrationWelcomeViewModel.swift */; }; + F4F9D5F42909B7C100502576 /* MigrationWelcomeBlogTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F9D5F32909B7C100502576 /* MigrationWelcomeBlogTableViewCell.swift */; }; + F4FB0ACD292587D500F651F9 /* MeHeaderViewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FB0ACC292587D500F651F9 /* MeHeaderViewConfiguration.swift */; }; + F4FE743429C3767300AC2729 /* AddressTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FE743329C3767300AC2729 /* AddressTableViewCell+ViewModel.swift */; }; + F4FE743529C3767300AC2729 /* AddressTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4FE743329C3767300AC2729 /* AddressTableViewCell+ViewModel.swift */; }; + F504D2B025D60C5900A2764C /* StoryPoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = F504D2AA25D60C5900A2764C /* StoryPoster.swift */; }; + F504D2B125D60C5900A2764C /* StoryMediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F504D2AB25D60C5900A2764C /* StoryMediaLoader.swift */; }; + F504D43725D717EF00A2764C /* PostEditor+BlogPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91DCE84321A6A7840062F134 /* PostEditor+BlogPicker.swift */; }; + F504D44825D717F600A2764C /* PostEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13ACCD31EE5672100CCE985 /* PostEditor.swift */; }; + F50B0E7B246212B8006601DD /* NoticeAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F50B0E7A246212B8006601DD /* NoticeAnimator.swift */; }; F511F8A42356A4F400895E73 /* PublishSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F511F8A32356A4F400895E73 /* PublishSettingsViewController.swift */; }; - F53FF3A123E2377E001AD596 /* NewBlogDetailHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53FF3A023E2377E001AD596 /* NewBlogDetailHeaderView.swift */; }; - F53FF3A323EA3E45001AD596 /* BlogDetailsViewController+Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53FF3A223EA3E45001AD596 /* BlogDetailsViewController+Header.swift */; }; + F515E9662654312200848251 /* Noticons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0C25DF2F7700C9654B /* Noticons.ttf */; }; + F515E9672654312200848251 /* Noticons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0C25DF2F7700C9654B /* Noticons.ttf */; }; + F52CACCA244FA7AA00661380 /* ReaderManageScenePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F52CACC9244FA7AA00661380 /* ReaderManageScenePresenter.swift */; }; + F52CACCC24512EA700661380 /* EmptyActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F52CACCB24512EA700661380 /* EmptyActionView.swift */; }; + F532AD61253B81320013B42E /* StoriesIntroDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F532AD60253B81320013B42E /* StoriesIntroDataSource.swift */; }; + F532AE1C253E55D40013B42E /* CreateButtonActionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F532AE1B253E55D40013B42E /* CreateButtonActionSheet.swift */; }; F53FF3A823EA723D001AD596 /* ActionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53FF3A723EA723D001AD596 /* ActionRow.swift */; }; F53FF3AA23EA725C001AD596 /* SiteIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53FF3A923EA725C001AD596 /* SiteIconView.swift */; }; F543AF5723A84E4D0022F595 /* PublishSettingsControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F543AF5623A84E4D0022F595 /* PublishSettingsControllerTests.swift */; }; - F543AF5923A84F200022F595 /* SchedulingCalendarViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F543AF5823A84F200022F595 /* SchedulingCalendarViewControllerTests.swift */; }; F551E7F523F6EA3100751212 /* FloatingActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F551E7F423F6EA3100751212 /* FloatingActionButton.swift */; }; F551E7F723FC9A5C00751212 /* Collection+RotateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F551E7F623FC9A5C00751212 /* Collection+RotateTests.swift */; }; F565190323CF6D1D003FACAF /* WKCookieJarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F565190223CF6D1D003FACAF /* WKCookieJarTests.swift */; }; - F5660D03235CF73800020B1E /* SchedulingCalendarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5660CFF235CE82100020B1E /* SchedulingCalendarViewController.swift */; }; F5660D07235D114500020B1E /* CalendarCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5660D06235D114500020B1E /* CalendarCollectionView.swift */; }; F5660D09235D1CDD00020B1E /* CalendarMonthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5660D08235D1CDD00020B1E /* CalendarMonthView.swift */; }; + F56A33332538C0ED00E2AEF3 /* MySiteViewController+FAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = F56A33322538C0ED00E2AEF3 /* MySiteViewController+FAB.swift */; }; F57402A7235FF9C300374346 /* SchedulingDate+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F57402A6235FF9C300374346 /* SchedulingDate+Helpers.swift */; }; + F574416E242569CA00E150A8 /* Route+Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = F574416C2425697D00E150A8 /* Route+Page.swift */; }; F580C3C123D22E2D0038E243 /* PreviewDeviceLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F580C3C023D22E2D0038E243 /* PreviewDeviceLabel.swift */; }; F580C3CB23D8F9B40038E243 /* AbstractPost+Dates.swift in Sources */ = {isa = PBXBuildFile; fileRef = F580C3CA23D8F9B40038E243 /* AbstractPost+Dates.swift */; }; F582060223A85495005159A9 /* SiteDateFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = F582060123A85495005159A9 /* SiteDateFormatters.swift */; }; - F582060423A88379005159A9 /* TimePickerViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F582060323A88379005159A9 /* TimePickerViewControllerTests.swift */; }; - F5844B6B235EAF3D007C6557 /* HalfScreenPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5844B6A235EAF3D007C6557 /* HalfScreenPresentationController.swift */; }; + F5844B6B235EAF3D007C6557 /* PartScreenPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5844B6A235EAF3D007C6557 /* PartScreenPresentationController.swift */; }; F59AAC10235E430F00385EE6 /* ChosenValueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59AAC0F235E430E00385EE6 /* ChosenValueRow.swift */; }; F59AAC16235EA46D00385EE6 /* LightNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59AAC15235EA46D00385EE6 /* LightNavigationController.swift */; }; + F5A34A9925DEF47D00C9654B /* WPMediaPicker+MediaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A34A9825DEF47D00C9654B /* WPMediaPicker+MediaPicker.swift */; }; + F5A34BCB25DF244F00C9654B /* KanvasCameraAnalyticsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A34BC925DF244F00C9654B /* KanvasCameraAnalyticsHandler.swift */; }; + F5A34BCC25DF244F00C9654B /* KanvasCameraCustomUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A34BCA25DF244F00C9654B /* KanvasCameraCustomUI.swift */; }; + F5A34D0D25DF2F7F00C9654B /* Nunito-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0725DF2F7700C9654B /* Nunito-Bold.ttf */; }; + F5A34D0E25DF2F7F00C9654B /* SpaceMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0825DF2F7700C9654B /* SpaceMono-Bold.ttf */; }; + F5A34D0F25DF2F7F00C9654B /* oswald_upper.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0625DF2F7700C9654B /* oswald_upper.ttf */; }; + F5A34D1025DF2F7F00C9654B /* Shrikhand-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0A25DF2F7700C9654B /* Shrikhand-Regular.ttf */; }; + F5A34D1125DF2F7F00C9654B /* Pacifico-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0B25DF2F7700C9654B /* Pacifico-Regular.ttf */; }; + F5A34D1225DF2F7F00C9654B /* Noticons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0C25DF2F7700C9654B /* Noticons.ttf */; }; + F5A34D1325DF2F7F00C9654B /* LibreBaskerville-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0925DF2F7700C9654B /* LibreBaskerville-Regular.ttf */; }; + F5A738BD244DF75400EDE065 /* OffsetTableViewHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A738BC244DF75400EDE065 /* OffsetTableViewHandler.swift */; }; + F5A738BF244DF7E400EDE065 /* ReaderTagsTableViewController+Cells.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A738BE244DF7E400EDE065 /* ReaderTagsTableViewController+Cells.swift */; }; + F5A738C3244E7A6F00EDE065 /* ReaderTagsTableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A738C2244E7A6F00EDE065 /* ReaderTagsTableViewModel.swift */; }; + F5AE43E425DD02C1003675F4 /* StoryEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AE43E325DD02C0003675F4 /* StoryEditor.swift */; }; + F5AE440625DD0345003675F4 /* CameraHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AE440525DD0345003675F4 /* CameraHandler.swift */; }; + F5B390EA2537E30B0097049E /* GridCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B390E92537E30B0097049E /* GridCell.swift */; }; F5B8A60F23CE56A1001B7359 /* PreviewDeviceSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B8A60E23CE56A1001B7359 /* PreviewDeviceSelectionViewController.swift */; }; + F5B9151F244653C100179876 /* TabbedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B9151E244653C100179876 /* TabbedViewController.swift */; }; + F5B9152124465FB400179876 /* ReaderTagsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B9152024465FB400179876 /* ReaderTagsTableViewController.swift */; }; + F5B9D7F0245BA938002BB2C7 /* FancyAlertViewController+CreateButtonAnnouncement.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B9D7EF245BA938002BB2C7 /* FancyAlertViewController+CreateButtonAnnouncement.swift */; }; + F5C00EAE242179780047846F /* WeekdaysHeaderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5C00EAD242179780047846F /* WeekdaysHeaderViewTests.swift */; }; + F5CFB8F524216DFC00E58B69 /* CalendarHeaderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5CFB8F424216DFC00E58B69 /* CalendarHeaderViewTests.swift */; }; F5D03DFF2370E28D0043F287 /* LightNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59AAC15235EA46D00385EE6 /* LightNavigationController.swift */; }; F5D03E002370E28E0043F287 /* LightNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59AAC15235EA46D00385EE6 /* LightNavigationController.swift */; }; F5D0A64923C8FA1500B20D27 /* LinkBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D0A64823C8FA1500B20D27 /* LinkBehavior.swift */; }; F5D0A64E23CC159400B20D27 /* PreviewWebKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D0A64D23CC159400B20D27 /* PreviewWebKitViewController.swift */; }; F5D0A65023CC15A800B20D27 /* PreviewNonceHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D0A64F23CC15A800B20D27 /* PreviewNonceHandler.swift */; }; F5D0A65223CCD3B600B20D27 /* PreviewWebKitViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D0A65123CCD3B600B20D27 /* PreviewWebKitViewControllerTests.swift */; }; + F5D399302541F25B0058D0AB /* SheetActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D3992F2541F25B0058D0AB /* SheetActions.swift */; }; F5E032D6240889EB003AF350 /* CreateButtonCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E032D5240889EB003AF350 /* CreateButtonCoordinator.swift */; }; F5E032DB24088F44003AF350 /* UIView+SpringAnimations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E032DA24088F44003AF350 /* UIView+SpringAnimations.swift */; }; - F5E032DF2408D1F1003AF350 /* WPTabBarController+ShowTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E032DE2408D1F1003AF350 /* WPTabBarController+ShowTab.swift */; }; + F5E032E62408D537003AF350 /* ActionSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E032E32408D537003AF350 /* ActionSheetViewController.swift */; }; + F5E032E82408D537003AF350 /* BottomSheetPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E032E52408D537003AF350 /* BottomSheetPresentationController.swift */; }; + F5E1577F25DE04E200EEEDFB /* GutenbergMediaFilesUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E1577E25DE04E200EEEDFB /* GutenbergMediaFilesUploadProcessor.swift */; }; + F5E1BA9B253A0A5E0091E9A6 /* StoriesIntroViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E1BA9A253A0A5E0091E9A6 /* StoriesIntroViewController.swift */; }; + F5E1BBE0253B74240091E9A6 /* URLQueryItem+Parameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E1BBDF253B74240091E9A6 /* URLQueryItem+Parameters.swift */; }; + F5E29036243E4F5F00C19CA5 /* FilterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E29035243E4F5F00C19CA5 /* FilterProvider.swift */; }; + F5E29038243FAB0300C19CA5 /* FilterTableData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E29037243FAB0300C19CA5 /* FilterTableData.swift */; }; + F5E63129243BC8190088229D /* FilterSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E63128243BC8190088229D /* FilterSheetView.swift */; }; + F5E6312B243BC83E0088229D /* FilterSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E6312A243BC83E0088229D /* FilterSheetViewController.swift */; }; F5EF481723ABCAD8004C3532 /* MainShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74337EDC20054D5500777997 /* MainShareViewController.swift */; }; F5EF481823ABCAE0004C3532 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 740C7C4E202F4CD6001C31B0 /* MainInterface.storyboard */; }; + F913BB0E24B3C58B00C19032 /* EventLoggingDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F913BB0D24B3C58B00C19032 /* EventLoggingDelegate.swift */; }; + F913BB1024B3C5CE00C19032 /* EventLoggingDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F913BB0F24B3C5CE00C19032 /* EventLoggingDataProvider.swift */; }; F928EDA3226140620030D451 /* WPCrashLoggingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F928EDA2226140620030D451 /* WPCrashLoggingProvider.swift */; }; F93735F122D534FE00A3C312 /* LoggingURLRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93735F022D534FE00A3C312 /* LoggingURLRedactor.swift */; }; F93735F822D53C3B00A3C312 /* LoggingURLRedactorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93735F722D53C3B00A3C312 /* LoggingURLRedactorTests.swift */; }; F9463A7321C05EE90081F11E /* ScreenshotCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9463A7221C05EE90081F11E /* ScreenshotCredentials.swift */; }; - F97DA42023D67B820050E791 /* MediaScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97DA41F23D67B820050E791 /* MediaScreen.swift */; }; - F97DA42123D67BBB0050E791 /* MediaScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97DA41F23D67B820050E791 /* MediaScreen.swift */; }; F98C58192228849E0073D752 /* XCTest+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2716A01CABC7D40006E2D4 /* XCTest+Extensions.swift */; }; F9941D1822A805F600788F33 /* UIImage+XCAssetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9941D1722A805F600788F33 /* UIImage+XCAssetTests.swift */; }; + F99B8B0DFEA7B43FAB6DEC03 /* Pods_WordPressIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1DD7BB9C25967442493CC19 /* Pods_WordPressIntents.framework */; }; + F9B862C92478170A008B093C /* EncryptedLogTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B862C82478170A008B093C /* EncryptedLogTableViewController.swift */; }; F9C47A6B238C7CFD00AAD9ED /* LoginFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D8321FF11E3800A11345 /* LoginFlow.swift */; }; - F9C47A6C238C7D6A00AAD9ED /* MySitesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD32D1FD67EDA00E55192 /* MySitesScreen.swift */; }; - F9C47A6D238C7D7500AAD9ED /* MySiteScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8707162006B774004FB5A4 /* MySiteScreen.swift */; }; - F9C47A6E238C7D7D00AAD9ED /* TabNavComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD32F1FD67F3B00E55192 /* TabNavComponent.swift */; }; - F9C47A6F238C7D8800AAD9ED /* WelcomeScreenLoginComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985F06B42303866200949733 /* WelcomeScreenLoginComponent.swift */; }; - F9C47A70238C7D8800AAD9ED /* WelcomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4E9A1FD66423007AE3E4 /* WelcomeScreen.swift */; }; - F9C47A71238C7D8800AAD9ED /* LoginEmailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4EA31FD6659B007AE3E4 /* LoginEmailScreen.swift */; }; - F9C47A72238C7D8800AAD9ED /* LoginPasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD3271FD6705200E55192 /* LoginPasswordScreen.swift */; }; - F9C47A73238C7D8800AAD9ED /* LinkOrPasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD3291FD6708900E55192 /* LinkOrPasswordScreen.swift */; }; - F9C47A74238C7D8800AAD9ED /* LoginCheckMagicLinkScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF6ACE6221ED73900D0C5BE /* LoginCheckMagicLinkScreen.swift */; }; - F9C47A75238C7D8800AAD9ED /* LoginSiteAddressScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE911BB221D8497007E1D4E /* LoginSiteAddressScreen.swift */; }; - F9C47A76238C7D8800AAD9ED /* LoginUsernamePasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE911BD221D85E4007E1D4E /* LoginUsernamePasswordScreen.swift */; }; - F9C47A77238C7D8800AAD9ED /* LoginEpilogueScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD32B1FD6782A00E55192 /* LoginEpilogueScreen.swift */; }; F9C47A78238C7DAC00AAD9ED /* BaseScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4E9E1FD664F5007AE3E4 /* BaseScreen.swift */; }; - F9C47A79238C7DAC00AAD9ED /* MeTabScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6DD3311FD6803700E55192 /* MeTabScreen.swift */; }; - F9C47A7A238C7DAC00AAD9ED /* ReaderScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8707182006E48E004FB5A4 /* ReaderScreen.swift */; }; - F9C47A7B238C7DAC00AAD9ED /* SiteSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF9F65622F03C9200F79BBF /* SiteSettingsScreen.swift */; }; - F9C47A7C238C7DAC00AAD9ED /* NotificationsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE87071A2006E65C004FB5A4 /* NotificationsScreen.swift */; }; - F9C47A7D238C7DAC00AAD9ED /* FancyAlertComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC94FC692214532D002E5825 /* FancyAlertComponent.swift */; }; - F9C47A7E238C7DC100AAD9ED /* EditorPublishEpilogueScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D83A1FF13B8A00A11345 /* EditorPublishEpilogueScreen.swift */; }; - F9C47A7F238C7DC100AAD9ED /* AztecEditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D8341FF1208400A11345 /* AztecEditorScreen.swift */; }; - F9C47A80238C7DC100AAD9ED /* BlockEditorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2BB0C92289CC3B0034F9AB /* BlockEditorScreen.swift */; }; - F9C47A81238C7DC100AAD9ED /* EditorNoticeComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC94FC67221452A4002E5825 /* EditorNoticeComponent.swift */; }; - F9C47A82238C7DC100AAD9ED /* EditorPostSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC19BE05223FECAC00CAB3E1 /* EditorPostSettings.swift */; }; - F9C47A83238C7DC500AAD9ED /* WelcomeScreenSignupComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB98622B28F4600642EE9 /* WelcomeScreenSignupComponent.swift */; }; - F9C47A84238C7DC500AAD9ED /* SignupEmailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB97522B15A2900642EE9 /* SignupEmailScreen.swift */; }; - F9C47A85238C7DC500AAD9ED /* SignupCheckMagicLinkScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB97722B15B2C00642EE9 /* SignupCheckMagicLinkScreen.swift */; }; - F9C47A86238C7DC500AAD9ED /* SignupEpilogueScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7CB97922B15C1000642EE9 /* SignupEpilogueScreen.swift */; }; - F9C47A87238C7DF100AAD9ED /* MediaPickerAlbumListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC5218992279CF3B008998CE /* MediaPickerAlbumListScreen.swift */; }; - F9C47A88238C7DF100AAD9ED /* MediaPickerAlbumScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC52189B2279D295008998CE /* MediaPickerAlbumScreen.swift */; }; - F9C47A89238C7E2600AAD9ED /* CategoriesComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE55E982242715C002A9634 /* CategoriesComponent.swift */; }; - F9C47A8A238C7E2600AAD9ED /* TagsComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC8498D22241477F00DB490A /* TagsComponent.swift */; }; - F9C47A8C238C801600AAD9ED /* PostsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C47A8B238C801600AAD9ED /* PostsScreen.swift */; }; - F9C47A8D238C809700AAD9ED /* PostsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C47A8B238C801600AAD9ED /* PostsScreen.swift */; }; - F9C47A8F238C9D6400AAD9ED /* StatsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C47A8E238C9D6400AAD9ED /* StatsScreen.swift */; }; - F9C47A90238C9D6700AAD9ED /* StatsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C47A8E238C9D6400AAD9ED /* StatsScreen.swift */; }; + FA00863D24EB68B100C863F2 /* FollowCommentsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00863C24EB68B100C863F2 /* FollowCommentsService.swift */; }; + FA1A543E25A6E2F60033967D /* RestoreWarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1A543D25A6E2F60033967D /* RestoreWarningView.swift */; }; + FA1A544025A6E3080033967D /* RestoreWarningView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA1A543F25A6E3080033967D /* RestoreWarningView.xib */; }; + FA1A55EF25A6F0740033967D /* RestoreStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1A55EE25A6F0740033967D /* RestoreStatusView.swift */; }; + FA1A55FF25A6F07F0033967D /* RestoreStatusView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA1A55FE25A6F07F0033967D /* RestoreStatusView.xib */; }; FA1ACAA21BC6E45D00DDDCE2 /* WPStyleGuide+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1ACAA11BC6E45D00DDDCE2 /* WPStyleGuide+Themes.swift */; }; + FA1CEAC225CA9C2A005E7038 /* RestoreStatusFailedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1CEAC125CA9C2A005E7038 /* RestoreStatusFailedView.swift */; }; + FA1CEAD425CA9C40005E7038 /* RestoreStatusFailedView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA1CEAD325CA9C40005E7038 /* RestoreStatusFailedView.xib */; }; + FA20751427A86B73001A644D /* UIScrollView+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA20751327A86B73001A644D /* UIScrollView+Helpers.swift */; }; + FA20751527A86B73001A644D /* UIScrollView+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA20751327A86B73001A644D /* UIScrollView+Helpers.swift */; }; + FA25FA212609AA9C0005E08F /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25F9FD2609AA830005E08F /* AppConfiguration.swift */; }; + FA25FA342609AAAA0005E08F /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA25FA332609AAAA0005E08F /* AppConfiguration.swift */; }; + FA332AD029C1F97A00182FBB /* MovedToJetpackViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA332ACF29C1F97A00182FBB /* MovedToJetpackViewController.swift */; }; + FA332AD129C1F97A00182FBB /* MovedToJetpackViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA332ACF29C1F97A00182FBB /* MovedToJetpackViewController.swift */; }; + FA332AD429C1FC7A00182FBB /* MovedToJetpackViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA332AD329C1FC7A00182FBB /* MovedToJetpackViewModel.swift */; }; + FA332AD529C1FC7A00182FBB /* MovedToJetpackViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA332AD329C1FC7A00182FBB /* MovedToJetpackViewModel.swift */; }; + FA347AED26EB6E300096604B /* GrowAudienceCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA347AEB26EB6E300096604B /* GrowAudienceCell.swift */; }; + FA347AEE26EB6E300096604B /* GrowAudienceCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA347AEB26EB6E300096604B /* GrowAudienceCell.swift */; }; + FA347AEF26EB6E300096604B /* GrowAudienceCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA347AEC26EB6E300096604B /* GrowAudienceCell.xib */; }; + FA347AF026EB6E300096604B /* GrowAudienceCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA347AEC26EB6E300096604B /* GrowAudienceCell.xib */; }; + FA347AF226EB7A420096604B /* StatsGhostGrowAudienceCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA347AF126EB7A420096604B /* StatsGhostGrowAudienceCell.xib */; }; + FA347AF326EB7A420096604B /* StatsGhostGrowAudienceCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA347AF126EB7A420096604B /* StatsGhostGrowAudienceCell.xib */; }; + FA3536F525B01A2C0005A3A0 /* JetpackRestoreCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3536F425B01A2C0005A3A0 /* JetpackRestoreCompleteViewController.swift */; }; FA4ADAD81C50687400F858D7 /* SiteManagementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4ADAD71C50687400F858D7 /* SiteManagementService.swift */; }; FA4ADADA1C509FE400F858D7 /* SiteManagementServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4ADAD91C509FE400F858D7 /* SiteManagementServiceTests.swift */; }; + FA4B202F29A619130089FE68 /* BlazeFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B202E29A619130089FE68 /* BlazeFlowCoordinator.swift */; }; + FA4B203029A619130089FE68 /* BlazeFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B202E29A619130089FE68 /* BlazeFlowCoordinator.swift */; }; + FA4B203529A786460089FE68 /* BlazeEventsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B203429A786460089FE68 /* BlazeEventsTracker.swift */; }; + FA4B203629A786460089FE68 /* BlazeEventsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B203429A786460089FE68 /* BlazeEventsTracker.swift */; }; + FA4B203829A8C48F0089FE68 /* AbstractPost+Blaze.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B203729A8C48F0089FE68 /* AbstractPost+Blaze.swift */; }; + FA4B203929A8C48F0089FE68 /* AbstractPost+Blaze.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B203729A8C48F0089FE68 /* AbstractPost+Blaze.swift */; }; + FA4B203B29AE62C00089FE68 /* BlazeOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B203A29AE62C00089FE68 /* BlazeOverlayViewController.swift */; }; + FA4B203C29AE62C00089FE68 /* BlazeOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4B203A29AE62C00089FE68 /* BlazeOverlayViewController.swift */; }; + FA4BC0D02996A589005EB077 /* BlazeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4BC0CF2996A589005EB077 /* BlazeService.swift */; }; + FA4BC0D12996A589005EB077 /* BlazeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4BC0CF2996A589005EB077 /* BlazeService.swift */; }; + FA4F383627D766020068AAF5 /* MySiteSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4F383527D766020068AAF5 /* MySiteSettings.swift */; }; + FA4F383727D766020068AAF5 /* MySiteSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4F383527D766020068AAF5 /* MySiteSettings.swift */; }; + FA4F65A72594337300EAA9F5 /* JetpackRestoreOptionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4F65A62594337300EAA9F5 /* JetpackRestoreOptionsViewController.swift */; }; + FA4F660525946B5F00EAA9F5 /* JetpackRestoreHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4F660425946B5F00EAA9F5 /* JetpackRestoreHeaderView.swift */; }; + FA4F661425946B8500EAA9F5 /* JetpackRestoreHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA4F661325946B8500EAA9F5 /* JetpackRestoreHeaderView.xib */; }; FA5C740F1C599BA7000B528C /* TableViewHeaderDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5C740E1C599BA7000B528C /* TableViewHeaderDetailView.swift */; }; + FA612DDF274E9F730002B03A /* QuickStartPromptScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA612DDE274E9F730002B03A /* QuickStartPromptScreen.swift */; }; + FA6402D129C325C1007A235C /* MovedToJetpackEventsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA6402D029C325C1007A235C /* MovedToJetpackEventsTracker.swift */; }; + FA6402D229C325C1007A235C /* MovedToJetpackEventsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA6402D029C325C1007A235C /* MovedToJetpackEventsTracker.swift */; }; + FA681F8A25CA946B00DAA544 /* BaseRestoreStatusFailedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA681F8825CA946B00DAA544 /* BaseRestoreStatusFailedViewController.swift */; }; + FA6FAB3525EF7C5700666CED /* ReaderRelatedPostsSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA6FAB3425EF7C5700666CED /* ReaderRelatedPostsSectionHeaderView.swift */; }; + FA6FAB4725EF7C6A00666CED /* ReaderRelatedPostsSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA6FAB4625EF7C6A00666CED /* ReaderRelatedPostsSectionHeaderView.xib */; }; + FA70024C29DC3B5500E874FD /* DashboardActivityLogCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA70024B29DC3B5500E874FD /* DashboardActivityLogCardCell.swift */; }; + FA70024D29DC3B5500E874FD /* DashboardActivityLogCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA70024B29DC3B5500E874FD /* DashboardActivityLogCardCell.swift */; }; + FA73D7D6278D9E5D00DF24B3 /* BlogDashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA73D7D5278D9E5D00DF24B3 /* BlogDashboardViewController.swift */; }; + FA73D7E52798765B00DF24B3 /* SitePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA73D7E42798765B00DF24B3 /* SitePickerViewController.swift */; }; + FA73D7E62798765B00DF24B3 /* SitePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA73D7E42798765B00DF24B3 /* SitePickerViewController.swift */; }; + FA73D7E927987BA500DF24B3 /* SitePickerViewController+SiteIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA73D7E827987BA500DF24B3 /* SitePickerViewController+SiteIcon.swift */; }; + FA73D7EA27987BA500DF24B3 /* SitePickerViewController+SiteIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA73D7E827987BA500DF24B3 /* SitePickerViewController+SiteIcon.swift */; }; + FA73D7EC27987E4500DF24B3 /* SitePickerViewController+QuickStart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA73D7EB27987E4500DF24B3 /* SitePickerViewController+QuickStart.swift */; }; + FA73D7ED27987E4500DF24B3 /* SitePickerViewController+QuickStart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA73D7EB27987E4500DF24B3 /* SitePickerViewController+QuickStart.swift */; }; FA77E02A1BE17CFC006D45E0 /* ThemeBrowserHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA77E0291BE17CFC006D45E0 /* ThemeBrowserHeaderView.swift */; }; + FA7AA45325BFD9BC005E7200 /* JetpackScanThreatDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7AA45225BFD9BC005E7200 /* JetpackScanThreatDetailsViewController.swift */; }; + FA7AA4A725BFE0A9005E7200 /* JetpackScanThreatDetailsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA7AA4A625BFE0A9005E7200 /* JetpackScanThreatDetailsViewController.xib */; }; + FA7F92B825E61C7E00502D2A /* ReaderTagsFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7F92B725E61C7E00502D2A /* ReaderTagsFooter.swift */; }; + FA7F92CA25E61C9300502D2A /* ReaderTagsFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA7F92C925E61C9300502D2A /* ReaderTagsFooter.xib */; }; + FA88EAD6260AE69C001D232B /* TemplatePreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99B08FB26081AD600CA71EB /* TemplatePreviewViewController.swift */; }; + FA8E1F7725EEFA7300063673 /* ReaderPostService+RelatedPosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA8E1F7625EEFA7300063673 /* ReaderPostService+RelatedPosts.swift */; }; + FA8E2FE027C6377000DA0982 /* DashboardQuickStartCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA8E2FDF27C6377000DA0982 /* DashboardQuickStartCardCell.swift */; }; + FA8E2FE127C6377000DA0982 /* DashboardQuickStartCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA8E2FDF27C6377000DA0982 /* DashboardQuickStartCardCell.swift */; }; + FA8E2FE527C6AE4500DA0982 /* QuickStartChecklistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA8E2FE427C6AE4500DA0982 /* QuickStartChecklistView.swift */; }; + FA8E2FE627C6AE4500DA0982 /* QuickStartChecklistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA8E2FE427C6AE4500DA0982 /* QuickStartChecklistView.swift */; }; + FA90EFEF262E74210055AB22 /* JetpackWebViewControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA90EFEE262E74210055AB22 /* JetpackWebViewControllerFactory.swift */; }; + FA90EFF0262E74210055AB22 /* JetpackWebViewControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA90EFEE262E74210055AB22 /* JetpackWebViewControllerFactory.swift */; }; + FA9276AD286C951200C323BB /* FeatureIntroductionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9276AC286C951200C323BB /* FeatureIntroductionScreen.swift */; }; + FA9276AF2888557500C323BB /* SiteIntentScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9276AE2888557500C323BB /* SiteIntentScreen.swift */; }; + FA9276B12889550E00C323BB /* ChooseLayoutScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA9276B02889550E00C323BB /* ChooseLayoutScreen.swift */; }; + FA98A24D2832A5E9003B9233 /* NewQuickStartChecklistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA98A24C2832A5E9003B9233 /* NewQuickStartChecklistView.swift */; }; + FA98A24E2832A5E9003B9233 /* NewQuickStartChecklistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA98A24C2832A5E9003B9233 /* NewQuickStartChecklistView.swift */; }; + FA98A2502833F1DC003B9233 /* QuickStartChecklistConfigurable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA98A24F2833F1DC003B9233 /* QuickStartChecklistConfigurable.swift */; }; + FA98A2512833F1DC003B9233 /* QuickStartChecklistConfigurable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA98A24F2833F1DC003B9233 /* QuickStartChecklistConfigurable.swift */; }; + FA98B61629A3B76A0071AAE8 /* DashboardBlazeCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA98B61529A3B76A0071AAE8 /* DashboardBlazeCardCell.swift */; }; + FA98B61729A3B76A0071AAE8 /* DashboardBlazeCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA98B61529A3B76A0071AAE8 /* DashboardBlazeCardCell.swift */; }; + FA98B61929A3BF050071AAE8 /* BlazeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA98B61829A3BF050071AAE8 /* BlazeCardView.swift */; }; + FA98B61A29A3BF050071AAE8 /* BlazeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA98B61829A3BF050071AAE8 /* BlazeCardView.swift */; }; + FA98B61C29A3DB840071AAE8 /* BlazeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA98B61B29A3DB840071AAE8 /* BlazeHelper.swift */; }; + FA98B61D29A3DB840071AAE8 /* BlazeHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA98B61B29A3DB840071AAE8 /* BlazeHelper.swift */; }; + FAA4012D27B405DB009E1137 /* DashboardQuickActionsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA4012C27B405DB009E1137 /* DashboardQuickActionsCardCell.swift */; }; + FAA4012E27B405DB009E1137 /* DashboardQuickActionsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA4012C27B405DB009E1137 /* DashboardQuickActionsCardCell.swift */; }; + FAA4013427B52455009E1137 /* QuickActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA4013327B52455009E1137 /* QuickActionButton.swift */; }; + FAA4013527B52455009E1137 /* QuickActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA4013327B52455009E1137 /* QuickActionButton.swift */; }; + FAA9084C27BD60710093FFA8 /* MySiteViewController+QuickStart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA9084B27BD60710093FFA8 /* MySiteViewController+QuickStart.swift */; }; + FAA9084D27BD60710093FFA8 /* MySiteViewController+QuickStart.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA9084B27BD60710093FFA8 /* MySiteViewController+QuickStart.swift */; }; + FAADE42626159AFE00BF29FE /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAADE3F02615996E00BF29FE /* AppConstants.swift */; }; + FAADE43A26159B2800BF29FE /* AppConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAADE42726159B1300BF29FE /* AppConstants.swift */; }; + FAB37D4627ED84BC00CA993C /* DashboardStatsNudgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB37D4527ED84BC00CA993C /* DashboardStatsNudgeView.swift */; }; + FAB37D4727ED84BC00CA993C /* DashboardStatsNudgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB37D4527ED84BC00CA993C /* DashboardStatsNudgeView.swift */; }; + FAB4F32724EDE12A00F259BA /* FollowCommentsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB4F32624EDE12A00F259BA /* FollowCommentsServiceTests.swift */; }; + FAB8004925AEDC2300D5D54A /* JetpackBackupCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8004825AEDC2300D5D54A /* JetpackBackupCompleteViewController.swift */; }; + FAB800B225AEE3C600D5D54A /* RestoreCompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB800B125AEE3C600D5D54A /* RestoreCompleteView.swift */; }; + FAB800C225AEE3D200D5D54A /* RestoreCompleteView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FAB800C125AEE3D200D5D54A /* RestoreCompleteView.xib */; }; + FAB8AA2225AF031200F9F8A0 /* BaseRestoreCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8AA2125AF031200F9F8A0 /* BaseRestoreCompleteViewController.swift */; }; + FAB8AB5F25AFFD0600F9F8A0 /* JetpackRestoreStatusCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8AB5E25AFFD0600F9F8A0 /* JetpackRestoreStatusCoordinator.swift */; }; + FAB8AB8B25AFFE7500F9F8A0 /* JetpackRestoreService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8AB8A25AFFE7500F9F8A0 /* JetpackRestoreService.swift */; }; + FAB8F75025AD72CE00D5D54A /* BaseRestoreOptionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8F74F25AD72CE00D5D54A /* BaseRestoreOptionsViewController.swift */; }; + FAB8F76E25AD73C000D5D54A /* JetpackBackupOptionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8F76D25AD73C000D5D54A /* JetpackBackupOptionsViewController.swift */; }; + FAB8F78C25AD785400D5D54A /* BaseRestoreStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8F78B25AD785400D5D54A /* BaseRestoreStatusViewController.swift */; }; + FAB8F7AA25AD792500D5D54A /* JetpackBackupStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8F7A925AD792500D5D54A /* JetpackBackupStatusViewController.swift */; }; + FAB8FD5025AEB0F500D5D54A /* JetpackBackupStatusCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8FD4F25AEB0F500D5D54A /* JetpackBackupStatusCoordinator.swift */; }; + FAB8FD6E25AEB23600D5D54A /* JetpackBackupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8FD6D25AEB23600D5D54A /* JetpackBackupService.swift */; }; + FAB9826E2697038700B172A3 /* StatsViewController+JetpackSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB9826D2697038700B172A3 /* StatsViewController+JetpackSettings.swift */; }; + FAB9826F2697038700B172A3 /* StatsViewController+JetpackSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB9826D2697038700B172A3 /* StatsViewController+JetpackSettings.swift */; }; + FAB985C12697550C00B172A3 /* NoResultsViewController+StatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB985C02697550C00B172A3 /* NoResultsViewController+StatsModule.swift */; }; + FAB985C22697550C00B172A3 /* NoResultsViewController+StatsModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB985C02697550C00B172A3 /* NoResultsViewController+StatsModule.swift */; }; + FABB1FAB2602FC2C00C8785C /* ReaderCardDiscoverAttributionView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8BA77BCA2482C52A00E1EBBF /* ReaderCardDiscoverAttributionView.xib */; }; + FABB1FAC2602FC2C00C8785C /* PostingActivityCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9826AE8F21B5D3CD00C851FA /* PostingActivityCell.xib */; }; + FABB1FB02602FC2C00C8785C /* SignupEpilogueCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98A25BD2203CB278006A5807 /* SignupEpilogueCell.xib */; }; + FABB1FB12602FC2C00C8785C /* JetpackScanViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C700F9D0257FD63A0090938E /* JetpackScanViewController.xib */; }; + FABB1FB32602FC2C00C8785C /* NoteBlockActionsTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5C66B731ACF071F00F68370 /* NoteBlockActionsTableViewCell.xib */; }; + FABB1FB42602FC2C00C8785C /* Pacifico-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0B25DF2F7700C9654B /* Pacifico-Regular.ttf */; }; + FABB1FB92602FC2C00C8785C /* RestoreStatusFailedView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA1CEAD325CA9C40005E7038 /* RestoreStatusFailedView.xib */; }; + FABB1FBA2602FC2C00C8785C /* NoteBlockHeaderTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5C66B6F1ACF06CA00F68370 /* NoteBlockHeaderTableViewCell.xib */; }; + FABB1FBB2602FC2C00C8785C /* CollapsableHeaderCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 469CE07024BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.xib */; }; + FABB1FBC2602FC2C00C8785C /* PrepublishingHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8B0732E6242B9C5200E7FBD3 /* PrepublishingHeaderView.xib */; }; + FABB1FBF2602FC2C00C8785C /* WPTableViewActivityCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D6C4AF51B603CA3005E3C43 /* WPTableViewActivityCell.xib */; }; + FABB1FC12602FC2C00C8785C /* defaultPostTemplate.html in Resources */ = {isa = PBXBuildFile; fileRef = A01C55470E25E0D000D411F2 /* defaultPostTemplate.html */; }; + FABB1FC32602FC2C00C8785C /* defaultPostTemplate_old.html in Resources */ = {isa = PBXBuildFile; fileRef = 2FAE97040E33B21600CA8540 /* defaultPostTemplate_old.html */; }; + FABB1FC42602FC2C00C8785C /* ReaderInterestsCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3236F77424ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.xib */; }; + FABB1FC52602FC2C00C8785C /* StatsGhostTopHeaderCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A19D440236C7C7500D393E5 /* StatsGhostTopHeaderCell.xib */; }; + FABB1FC72602FC2C00C8785C /* RegisterDomainDetailsFooterView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 436D561B2117312700CEAA33 /* RegisterDomainDetailsFooterView.xib */; }; + FABB1FC82602FC2C00C8785C /* EpilogueSectionHeaderFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98579BC6203DD86E004086E4 /* EpilogueSectionHeaderFooter.xib */; }; + FABB1FC92602FC2C00C8785C /* reader.css in Resources */ = {isa = PBXBuildFile; fileRef = 8B64B4B1247EC3A2009A1229 /* reader.css */; }; + FABB1FCB2602FC2C00C8785C /* AlertView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B572735F1B66CCEF000D1C4F /* AlertView.xib */; }; + FABB1FCC2602FC2C00C8785C /* SiteStatsTableHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98F537A822496D0D00B334F9 /* SiteStatsTableHeaderView.xib */; }; + FABB1FCD2602FC2C00C8785C /* Posts.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5DBFC8A81A9BE07B00E00DE4 /* Posts.storyboard */; }; + FABB1FCE2602FC2C00C8785C /* StatsChildRowsView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98B11B8A2216536C00B7F2D7 /* StatsChildRowsView.xib */; }; + FABB1FD02602FC2C00C8785C /* xhtml1-transitional.dtd in Resources */ = {isa = PBXBuildFile; fileRef = 2FAE97070E33B21600CA8540 /* xhtml1-transitional.dtd */; }; + FABB1FD22602FC2C00C8785C /* RestoreStatusView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA1A55FE25A6F07F0033967D /* RestoreStatusView.xib */; }; + FABB1FD32602FC2C00C8785C /* PluginDetailViewHeaderCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4058F4191FF40EE1000D5559 /* PluginDetailViewHeaderCell.xib */; }; + FABB1FD42602FC2C00C8785C /* Flags.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 406A0EEF224D39C50016AD6A /* Flags.xcassets */; }; + FABB1FD52602FC2C00C8785C /* StatsGhostTopCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A9E3FAF230EA7A300909BC4 /* StatsGhostTopCell.xib */; }; + FABB1FD62602FC2C00C8785C /* CustomizeInsightsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 985793C722F23D7000643DBF /* CustomizeInsightsCell.xib */; }; + FABB1FD92602FC2C00C8785C /* oswald_upper.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0625DF2F7700C9654B /* oswald_upper.ttf */; }; + FABB1FDB2602FC2C00C8785C /* xhtmlValidatorTemplate.xhtml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAE97080E33B21600CA8540 /* xhtmlValidatorTemplate.xhtml */; }; + FABB1FDC2602FC2C00C8785C /* PostingActivityDay.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9826AE8721B5C73400C851FA /* PostingActivityDay.xib */; }; + FABB1FDD2602FC2C00C8785C /* reader-cards-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 8BB185CB24B6058600A4CCE8 /* reader-cards-success.json */; }; + FABB1FDF2602FC2C00C8785C /* ReaderRelatedPostsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FAC086D625EDFB1E00B94F2A /* ReaderRelatedPostsCell.xib */; }; + FABB1FE52602FC2C00C8785C /* RevisionOperation.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A2B28F62192121F00458F2A /* RevisionOperation.xib */; }; + FABB1FE82602FC2C00C8785C /* TitleBadgeDisclosureCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = E66E2A681FE432BB00788F22 /* TitleBadgeDisclosureCell.xib */; }; + FABB1FE92602FC2C00C8785C /* TextWithAccessoryButtonCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 40640045200ED30300106789 /* TextWithAccessoryButtonCell.xib */; }; + FABB1FEA2602FC2C00C8785C /* SiteStatsDetailTableViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 98467A42221CD74D00DF51AE /* SiteStatsDetailTableViewController.storyboard */; }; + FABB1FED2602FC2C00C8785C /* InlineEditableNameValueCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 43D74ACD20F906DD004AD934 /* InlineEditableNameValueCell.xib */; }; + FABB1FEE2602FC2C00C8785C /* AppImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 433840C622C2BA5B00CB13F8 /* AppImages.xcassets */; }; + FABB1FEF2602FC2C00C8785C /* MediaQuotaCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FF00889C204DFF77007CCE66 /* MediaQuotaCell.xib */; }; + FABB1FF02602FC2C00C8785C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 931DF4D818D09A2F00540BDD /* InfoPlist.strings */; }; + FABB1FF22602FC2C00C8785C /* ReaderSavedPostUndoCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D88106F620C0C9A8001D2F00 /* ReaderSavedPostUndoCell.xib */; }; + FABB1FF32602FC2C00C8785C /* ReaderPostCardCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D5A6E921B613CA400DAF819 /* ReaderPostCardCell.xib */; }; + FABB1FF52602FC2C00C8785C /* RevisionsTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4349B0AE218A477F0034118A /* RevisionsTableViewCell.xib */; }; + FABB1FF72602FC2C00C8785C /* Revisions.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 439F4F37219B636500F8D0C7 /* Revisions.storyboard */; }; + FABB1FF82602FC2C00C8785C /* ActivityDetailViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4019B27020885AB900A0C7EB /* ActivityDetailViewController.storyboard */; }; + FABB1FF92602FC2C00C8785C /* ReaderDetailHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8B0CE7D22481CFF8004C4799 /* ReaderDetailHeaderView.xib */; }; + FABB1FFA2602FC2C00C8785C /* ReaderTagsFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA7F92C925E61C9300502D2A /* ReaderTagsFooter.xib */; }; + FABB1FFB2602FC2C00C8785C /* PostingActivityLegend.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9848DF8221B8BB6900B99DA4 /* PostingActivityLegend.xib */; }; + FABB1FFC2602FC2C00C8785C /* PostingActivityViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 981C986A21B9BF6000A7C0C8 /* PostingActivityViewController.storyboard */; }; + FABB1FFD2602FC2C00C8785C /* TwoColumnCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98880A4922B2E5E400464538 /* TwoColumnCell.xib */; }; + FABB1FFE2602FC2C00C8785C /* Notifications.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B558541019631A1000FAF6C3 /* Notifications.storyboard */; }; + FABB20022602FC2C00C8785C /* StatsTableFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = 983DBBA822125DD300753988 /* StatsTableFooter.xib */; }; + FABB20052602FC2C00C8785C /* ThemeBrowserSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 820ADD6F1F3A1F88002D7F93 /* ThemeBrowserSectionHeaderView.xib */; }; + FABB20072602FC2C00C8785C /* SitePromptView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 467D3E0B25E4436D00EB9CB0 /* SitePromptView.xib */; }; + FABB20082602FC2C00C8785C /* DeleteSite.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 746A6F561E71C691003B67E3 /* DeleteSite.storyboard */; }; + FABB200A2602FC2C00C8785C /* Noticons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0C25DF2F7700C9654B /* Noticons.ttf */; }; + FABB200B2602FC2C00C8785C /* LoginEpilogue.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B51AD77A2056C31100A6C545 /* LoginEpilogue.storyboard */; }; + FABB200F2602FC2C00C8785C /* TopTotalsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9895B6DF21ED49160053D370 /* TopTotalsCell.xib */; }; + FABB20102602FC2C00C8785C /* PostPost.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 430693731DD25F31009398A2 /* PostPost.storyboard */; }; + FABB20112602FC2C00C8785C /* SpaceMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0825DF2F7700C9654B /* SpaceMono-Bold.ttf */; }; + FABB20142602FC2C00C8785C /* CommentsList.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9835F16D25E492EE002EFF23 /* CommentsList.storyboard */; }; + FABB20162602FC2C00C8785C /* TabbedTotalsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98563DDC21BF30C40006F5E9 /* TabbedTotalsCell.xib */; }; + FABB20172602FC2C00C8785C /* PageListTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5DFA7EC61AF814E40072023B /* PageListTableViewCell.xib */; }; + FABB20182602FC2C00C8785C /* NoteBlockImageTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5C66B771ACF073900F68370 /* NoteBlockImageTableViewCell.xib */; }; + FABB201B2602FC2C00C8785C /* SignupEpilogue.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B59F34A0207678480069992D /* SignupEpilogue.storyboard */; }; + FABB201D2602FC2C00C8785C /* PostStatsTitleCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98FCFC222231DF43006ECDD4 /* PostStatsTitleCell.xib */; }; + FABB20212602FC2C00C8785C /* ReaderDetailToolbar.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8BA77BCC248340CE00E1EBBF /* ReaderDetailToolbar.xib */; }; + FABB20222602FC2C00C8785C /* RewindStatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4070D75B20E5F55A007CEBDA /* RewindStatusTableViewCell.xib */; }; + FABB20232602FC2C00C8785C /* PageListSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D13FA561AF99C2100F06492 /* PageListSectionHeaderView.xib */; }; + FABB20242602FC2C00C8785C /* JetpackScanThreatDetailsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA7AA4A625BFE0A9005E7200 /* JetpackScanThreatDetailsViewController.xib */; }; + FABB20252602FC2C00C8785C /* CollabsableHeaderFilterCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 469EB16424D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.xib */; }; + FABB20262602FC2C00C8785C /* ReplyTextView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B54E1DEF1A0A7BAA00807537 /* ReplyTextView.xib */; }; + FABB20272602FC2C00C8785C /* LibreBaskerville-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0925DF2F7700C9654B /* LibreBaskerville-Regular.ttf */; }; + FABB20282602FC2C00C8785C /* StatsCellHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9881296D219CF1310075FF33 /* StatsCellHeader.xib */; }; + FABB20292602FC2C00C8785C /* JetpackScanHistoryViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = C76F48FF25BA23B000BFEC87 /* JetpackScanHistoryViewController.xib */; }; + FABB202A2602FC2C00C8785C /* PostSignUpInterstitialViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3249615123F20111004C7733 /* PostSignUpInterstitialViewController.xib */; }; + FABB202B2602FC2C00C8785C /* CollapsableHeaderViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4625B6332538B53700C04AAD /* CollapsableHeaderViewController.xib */; }; + FABB202C2602FC2C00C8785C /* ReaderRelatedPostsSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA6FAB4625EF7C6A00666CED /* ReaderRelatedPostsSectionHeaderView.xib */; }; + FABB202D2602FC2C00C8785C /* PostingActivityMonth.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9826AE8B21B5CC8D00C851FA /* PostingActivityMonth.xib */; }; + FABB202E2602FC2C00C8785C /* Menus.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 08D499661CDD20450004809A /* Menus.storyboard */; }; + FABB202F2602FC2C00C8785C /* CountriesMapView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A3BDA0F22944F4D00FBF510 /* CountriesMapView.xib */; }; + FABB20302602FC2C00C8785C /* OverviewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98797DBB222F434500128C21 /* OverviewCell.xib */; }; + FABB20322602FC2C00C8785C /* JetpackRemoteInstallStateView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A8ECE1C2254AE4E0043C8DA /* JetpackRemoteInstallStateView.xib */; }; + FABB20352602FC2C00C8785C /* TemplatePreviewViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 464688D7255C71D200ECA61C /* TemplatePreviewViewController.xib */; }; + FABB20372602FC2C00C8785C /* NoResults.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 98B33C87202283860071E1E2 /* NoResults.storyboard */; }; + FABB20392602FC2C00C8785C /* ActivityListSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 82A062DB2017BC220084CE7C /* ActivityListSectionHeaderView.xib */; }; + FABB203D2602FC2C00C8785C /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E1D91454134A853D0089019C /* Localizable.strings */; }; + FABB203F2602FC2C00C8785C /* ReaderRecommendedSiteCardCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3234BB332530EA980068DA40 /* ReaderRecommendedSiteCardCell.xib */; }; + FABB20402602FC2C00C8785C /* Nunito-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0725DF2F7700C9654B /* Nunito-Bold.ttf */; }; + FABB20412602FC2C00C8785C /* ImageCropViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5EEB19E1CA96D19004B6540 /* ImageCropViewController.xib */; }; + FABB20422602FC2C00C8785C /* Reader.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5D1D04731B7A50B100CDE646 /* Reader.storyboard */; }; + FABB20442602FC2C00C8785C /* RestoreWarningView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA1A543F25A6E3080033967D /* RestoreWarningView.xib */; }; + FABB20452602FC2C00C8785C /* JetpackLoginViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A8ECE022254A3250043C8DA /* JetpackLoginViewController.xib */; }; + FABB20472602FC2C00C8785C /* StatsNoDataRow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98458CB921A39D7A0025D232 /* StatsNoDataRow.xib */; }; + FABB20482602FC2C00C8785C /* JetpackScanThreatCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C700FAB1258020DB0090938E /* JetpackScanThreatCell.xib */; }; + FABB20492602FC2C00C8785C /* JetpackScanStatusCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C768B5B325828A5D00556E75 /* JetpackScanStatusCell.xib */; }; + FABB204A2602FC2C00C8785C /* ActivityTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 82FC611E1FA8ADAC00A1757E /* ActivityTableViewCell.xib */; }; + FABB204C2602FC2C00C8785C /* StatsGhostTitleCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9AB36B83236B25D900FAD72A /* StatsGhostTitleCell.xib */; }; + FABB204D2602FC2C00C8785C /* NoteBlockButtonTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 176CF3AB25E0079600E1E598 /* NoteBlockButtonTableViewCell.xib */; }; + FABB204F2602FC2C00C8785C /* NoteBlockCommentTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5C66B751ACF072C00F68370 /* NoteBlockCommentTableViewCell.xib */; }; + FABB20502602FC2C00C8785C /* PostCardCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 57AA8490228715E700D3C2A2 /* PostCardCell.xib */; }; + FABB20552602FC2C00C8785C /* ReaderDetailFeaturedImageView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3223393D24FEC2A700BDD4BF /* ReaderDetailFeaturedImageView.xib */; }; + FABB20562602FC2C00C8785C /* loader.html in Resources */ = {isa = PBXBuildFile; fileRef = E18165FC14E4428B006CE885 /* loader.html */; }; + FABB20582602FC2C00C8785C /* JetpackRestoreHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FA4F661325946B8500EAA9F5 /* JetpackRestoreHeaderView.xib */; }; + FABB205A2602FC2C00C8785C /* PagedViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E1DD4CCC1CAE41C800C3863E /* PagedViewController.xib */; }; + FABB205C2602FC2C00C8785C /* StatsGhostChartCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9AC3C69A231543C2007933CD /* StatsGhostChartCell.xib */; }; + FABB205D2602FC2C00C8785C /* ReaderGapMarkerCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = E6A3384D1BB0A50900371587 /* ReaderGapMarkerCell.xib */; }; + FABB205E2602FC2C00C8785C /* postPreview.html in Resources */ = {isa = PBXBuildFile; fileRef = 5DB767401588F64D00EBE36C /* postPreview.html */; }; + FABB20602602FC2C00C8785C /* MediaSizeSliderCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = E14977191C0DCB6F0057CD60 /* MediaSizeSliderCell.xib */; }; + FABB20622602FC2C00C8785C /* ReaderBlockedSiteCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = E65219F81B8D10C2000B1217 /* ReaderBlockedSiteCell.xib */; }; + FABB20632602FC2C00C8785C /* QuickStartChecklistCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A4E215921F7565A00EFF212 /* QuickStartChecklistCell.xib */; }; + FABB20652602FC2C00C8785C /* SiteSegmentsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D8C31CC52188490000A33B35 /* SiteSegmentsCell.xib */; }; + FABB20662602FC2C00C8785C /* ReaderSiteStreamHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = E6D2E15E1B8A9C830000ED14 /* ReaderSiteStreamHeader.xib */; }; + FABB20692602FC2C00C8785C /* ReaderTopicsCardCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = C7192ECE25E8432D00C3020D /* ReaderTopicsCardCell.xib */; }; + FABB206B2602FC2C00C8785C /* richEmbedTemplate.html in Resources */ = {isa = PBXBuildFile; fileRef = E61507E12220A0FE00213D33 /* richEmbedTemplate.html */; }; + FABB206D2602FC2C00C8785C /* NoteBlockUserTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5C66B791ACF074600F68370 /* NoteBlockUserTableViewCell.xib */; }; + FABB20702602FC2C00C8785C /* ReaderListStreamHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = E6D2E1621B8AAA340000ED14 /* ReaderListStreamHeader.xib */; }; + FABB20722602FC2C00C8785C /* MyProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = CE1CCB2E2050502B000EE3AC /* MyProfileHeaderView.xib */; }; + FABB20742602FC2C00C8785C /* RestorePageTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D18FE9E1AFBB17400EFEED0 /* RestorePageTableViewCell.xib */; }; + FABB20762602FC2C00C8785C /* WordPressShare.js in Resources */ = {isa = PBXBuildFile; fileRef = E1AFA8C21E8E34230004A323 /* WordPressShare.js */; }; + FABB20772602FC2C00C8785C /* world-map.svg in Resources */ = {isa = PBXBuildFile; fileRef = 9A76C32E22AFDA2100F5D819 /* world-map.svg */; }; + FABB20782602FC2C00C8785C /* NoteBlockTextTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5C66B711ACF071000F68370 /* NoteBlockTextTableViewCell.xib */; }; + FABB207D2602FC2C00C8785C /* RELEASE-NOTES.txt in Resources */ = {isa = PBXBuildFile; fileRef = FF37F90822385C9F00AFA3DB /* RELEASE-NOTES.txt */; }; + FABB207E2602FC2C00C8785C /* EpilogueUserInfoCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98086559203D075D00D58786 /* EpilogueUserInfoCell.xib */; }; + FABB207F2602FC2C00C8785C /* ReaderTagStreamHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = E6D2E1601B8AA4410000ED14 /* ReaderTagStreamHeader.xib */; }; + FABB20812602FC2C00C8785C /* PostCompactCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 577C2AB52294401800AD1F03 /* PostCompactCell.xib */; }; + FABB20862602FC2C00C8785C /* n.caf in Resources */ = {isa = PBXBuildFile; fileRef = 5D69DBC3165428CA00A2D1F7 /* n.caf */; }; + FABB20882602FC2C00C8785C /* People.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E1B912801BB00EFD003C25B9 /* People.storyboard */; }; + FABB208B2602FC2C00C8785C /* LatestPostSummaryCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9826AE8121B5C6A700C851FA /* LatestPostSummaryCell.xib */; }; + FABB208D2602FC2C00C8785C /* RestorePostTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D2FB2821AE98C4600F1D4ED /* RestorePostTableViewCell.xib */; }; + FABB208E2602FC2C00C8785C /* Plans.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1724DDCB1C6121D00099D273 /* Plans.storyboard */; }; + FABB208F2602FC2C00C8785C /* PluginDirectoryCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 40A2778020191B5E00D078D5 /* PluginDirectoryCollectionViewCell.xib */; }; + FABB20902602FC2C00C8785C /* ReaderWelcomeBanner.xib in Resources */ = {isa = PBXBuildFile; fileRef = 8BD8201824BCCE8600FF25FD /* ReaderWelcomeBanner.xib */; }; + FABB20952602FC2C00C8785C /* StatsGhostPostingActivityCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A9E3FB3230EC4F700909BC4 /* StatsGhostPostingActivityCell.xib */; }; + FABB20962602FC2C00C8785C /* ColorPalette.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 435B76292297484200511813 /* ColorPalette.xcassets */; }; + FABB20972602FC2C00C8785C /* RestoreCompleteView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FAB800C125AEE3D200D5D54A /* RestoreCompleteView.xib */; }; + FABB20992602FC2C00C8785C /* RegisterDomain.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 402FFB1B218C27C100FF4A0B /* RegisterDomain.storyboard */; }; + FABB209A2602FC2C00C8785C /* PostListFooterView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D732F981AE84E5400CD89E7 /* PostListFooterView.xib */; }; + FABB209B2602FC2C00C8785C /* acknowledgements.html in Resources */ = {isa = PBXBuildFile; fileRef = 4D520D4E22972BC9002F5924 /* acknowledgements.html */; }; + FABB209C2602FC2C00C8785C /* ReaderDetailViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8BDA5A6E247C308300AB124C /* ReaderDetailViewController.storyboard */; }; + FABB209D2602FC2C00C8785C /* EditCommentViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D6C4AFD1B603CE9005E3C43 /* EditCommentViewController.xib */; }; + FABB209E2602FC2C00C8785C /* GutenGhostView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 1E5D00132493E8C90004B708 /* GutenGhostView.xib */; }; + FABB20A12602FC2C00C8785C /* MenuItemEditing.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 08216FA71CDBF95100304BA7 /* MenuItemEditing.storyboard */; }; + FABB20A22602FC2C00C8785C /* CountriesCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 981676D5221B7A4300B81C3F /* CountriesCell.xib */; }; + FABB20A32602FC2C00C8785C /* RegisterDomainSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 43D74AD320FB5ABA004AD934 /* RegisterDomainSectionHeaderView.xib */; }; + FABB20A42602FC2C00C8785C /* BlogDetailsSectionHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4349BFF4221205540084F200 /* BlogDetailsSectionHeaderView.xib */; }; + FABB20A52602FC2C00C8785C /* CountriesMapCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A5C854722B3E42800BEE7A3 /* CountriesMapCell.xib */; }; + FABB20A62602FC2C00C8785C /* WPWebViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5D6C4B011B603D1F005E3C43 /* WPWebViewController.xib */; }; + FABB20A72602FC2C00C8785C /* ReaderCrossPostCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = E6D3E84A1BEBD888002692E8 /* ReaderCrossPostCell.xib */; }; + FABB20A82602FC2C00C8785C /* StatsStackViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A09F91A230C49FD00F42AB7 /* StatsStackViewCell.xib */; }; + FABB20AA2602FC2C00C8785C /* RegisterDomainDetailsErrorSectionFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = 436D561E2117312700CEAA33 /* RegisterDomainDetailsErrorSectionFooter.xib */; }; + FABB20AB2602FC2C00C8785C /* Pages.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5DFA7EC21AF7CB910072023B /* Pages.storyboard */; }; + FABB20AC2602FC2C00C8785C /* Shrikhand-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F5A34D0A25DF2F7700C9654B /* Shrikhand-Regular.ttf */; }; + FABB20AD2602FC2C00C8785C /* StatsGhostSingleRowCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A73CB072350DE4C002EF20C /* StatsGhostSingleRowCell.xib */; }; + FABB20AE2602FC2C00C8785C /* ThemeBrowser.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 596C035F1B84F24000899EEB /* ThemeBrowser.storyboard */; }; + FABB20AF2602FC2C00C8785C /* StatsGhostTabbedCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A9E3FB1230EB74300909BC4 /* StatsGhostTabbedCell.xib */; }; + FABB20B02602FC2C00C8785C /* CategorySectionTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 469CE06C24BCED75003BDC8B /* CategorySectionTableViewCell.xib */; }; + FABB20B22602FC2C00C8785C /* ExpandableCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4034FDED2007D4F700153B87 /* ExpandableCell.xib */; }; + FABB20B52602FC2C00C8785C /* richEmbedScript.js in Resources */ = {isa = PBXBuildFile; fileRef = E61507E32220A13B00213D33 /* richEmbedScript.js */; }; + FABB20B72602FC2C00C8785C /* PluginListCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 401A3D012027DBD80099A127 /* PluginListCell.xib */; }; + FABB20B82602FC2C00C8785C /* SiteStatsDashboard.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 981C348F2183871100FC2683 /* SiteStatsDashboard.storyboard */; }; + FABB20B92602FC2C00C8785C /* PostStatsTableViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 986C908522319F2600FC31E1 /* PostStatsTableViewController.storyboard */; }; + FABB20BA2602FC2C00C8785C /* ReaderSelectInterestsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 32E1BFFC24AB9D28007A08F0 /* ReaderSelectInterestsViewController.xib */; }; + FABB20BB2602FC2C00C8785C /* StatsTwoColumnRow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98D52C3122B1CFEC00831529 /* StatsTwoColumnRow.xib */; }; + FABB20BC2602FC2C00C8785C /* StatsTotalRow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98812965219CE42A0075FF33 /* StatsTotalRow.xib */; }; + FABB20BD2602FC2C00C8785C /* ViewMoreRow.xib in Resources */ = {isa = PBXBuildFile; fileRef = 98B3FA8521C05BF000148DD4 /* ViewMoreRow.xib */; }; + FABB20BE2602FC2C00C8785C /* SiteSegmentsWizardContent.xib in Resources */ = {isa = PBXBuildFile; fileRef = D865722D2186F96C0023A99C /* SiteSegmentsWizardContent.xib */; }; + FABB20BF2602FC2C00C8785C /* DetailDataCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 987535622282682D001661B4 /* DetailDataCell.xib */; }; + FABB20C02602FC2C00C8785C /* StatsGhostTwoColumnCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 9A9E3FAC230E9DD000909BC4 /* StatsGhostTwoColumnCell.xib */; }; + FABB20C32602FC2C00C8785C /* FloatingActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F551E7F423F6EA3100751212 /* FloatingActionButton.swift */; }; + FABB20C42602FC2C00C8785C /* TextList+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C611EF42AF200372C65 /* TextList+WordPress.swift */; }; + FABB20C52602FC2C00C8785C /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5722E401D51A28100F40C5E /* Notification.swift */; }; + FABB20C62602FC2C00C8785C /* NoticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1759F17F1FE1460C0003EC81 /* NoticeView.swift */; }; + FABB20C72602FC2C00C8785C /* TopViewedAuthorStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C403F02215D66A00E8C894 /* TopViewedAuthorStatsRecordValue+CoreDataProperties.swift */; }; + FABB20C82602FC2C00C8785C /* ActivityPluginRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4A773320F800ED001C706D /* ActivityPluginRange.swift */; }; + FABB20C92602FC2C00C8785C /* ShareNoticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7462BFCF2028C49800B552D8 /* ShareNoticeViewModel.swift */; }; + FABB20CA2602FC2C00C8785C /* Routes+Reader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A4A36820EE51870071C2CA /* Routes+Reader.swift */; }; + FABB20CB2602FC2C00C8785C /* WPContentSearchHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0807CB711CE670A800CDBDAC /* WPContentSearchHelper.swift */; }; + FABB20CC2602FC2C00C8785C /* SiteStatsViewModel+AsyncBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A73B7142362FBAE004624A8 /* SiteStatsViewModel+AsyncBlock.swift */; }; + FABB20CD2602FC2C00C8785C /* PostUploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AF4D711FE417D200E3EBFE /* PostUploadOperation.swift */; }; + FABB20CE2602FC2C00C8785C /* VerticallyStackedButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 37022D901981BF9200F322B7 /* VerticallyStackedButton.m */; }; + FABB20CF2602FC2C00C8785C /* PageListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59DCA5201CC68AF3000F245F /* PageListViewController.swift */; }; + FABB20D02602FC2C00C8785C /* CoreDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5ECA6C91DBAA0020062D7E0 /* CoreDataHelper.swift */; }; + FABB20D12602FC2C00C8785C /* LocalCoreDataService.m in Sources */ = {isa = PBXBuildFile; fileRef = FFC6ADD91B56F366002F3C84 /* LocalCoreDataService.m */; }; + FABB20D22602FC2C00C8785C /* AztecNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FDF9F220D842D2006D14E3 /* AztecNavigationController.swift */; }; + FABB20D32602FC2C00C8785C /* Notification+Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = B587798419B799EB00E57C5A /* Notification+Interface.swift */; }; + FABB20D42602FC2C00C8785C /* ReaderSaveForLater+Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175A650B20B6F7280023E71B /* ReaderSaveForLater+Analytics.swift */; }; + FABB20D52602FC2C00C8785C /* TagsCategoriesStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054F4552214E3C800D261AB /* TagsCategoriesStatsRecordValue+CoreDataProperties.swift */; }; + FABB20D62602FC2C00C8785C /* RevisionOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2B28F42192121400458F2A /* RevisionOperation.swift */; }; + FABB20D72602FC2C00C8785C /* ZendeskUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984B4EF220742FCC00F87888 /* ZendeskUtils.swift */; }; + FABB20D82602FC2C00C8785C /* AbstractPost+Dates.swift in Sources */ = {isa = PBXBuildFile; fileRef = F580C3CA23D8F9B40038E243 /* AbstractPost+Dates.swift */; }; + FABB20D92602FC2C00C8785C /* Blog+HomepageSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D9362224729FB6008B2205 /* Blog+HomepageSettings.swift */; }; + FABB20DA2602FC2C00C8785C /* GutenbergRollout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9D544C23C4C56300F6A9E0 /* GutenbergRollout.swift */; }; + FABB20DB2602FC2C00C8785C /* Routes+MySites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E24F5320FCF1D900BD70A3 /* Routes+MySites.swift */; }; + FABB20DC2602FC2C00C8785C /* WPAppAnalytics+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0828D7F91E6E09AE00C7C7D4 /* WPAppAnalytics+Media.swift */; }; + FABB20DD2602FC2C00C8785C /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591AA4FF1CEF9BF20074934F /* Post.swift */; }; + FABB20DE2602FC2C00C8785C /* GutenbergWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0FF01D242BC572008DA898 /* GutenbergWebViewController.swift */; }; + FABB20E02602FC2C00C8785C /* CookieJar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E161B7EB1F839345000FDF0B /* CookieJar.swift */; }; + FABB20E12602FC2C00C8785C /* WebAddressStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = D853723921952DAF0076F461 /* WebAddressStep.swift */; }; + FABB20E22602FC2C00C8785C /* NotificationSyncMediator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F67AC61DB7D81300482C62 /* NotificationSyncMediator.swift */; }; + FABB20E42602FC2C00C8785C /* ThemeBrowserHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA77E0291BE17CFC006D45E0 /* ThemeBrowserHeaderView.swift */; }; + FABB20E52602FC2C00C8785C /* SiteStatsInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9874767221963D320080967F /* SiteStatsInformation.swift */; }; + FABB20E62602FC2C00C8785C /* TitleSubtitleHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A4B21B85CF20005062B /* TitleSubtitleHeader.swift */; }; + FABB20E82602FC2C00C8785C /* AppAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C64BD1248E26A200AF09D7 /* AppAppearance.swift */; }; + FABB20E92602FC2C00C8785C /* ReaderTabViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F09CCA72428FF3300D00A8C /* ReaderTabViewController.swift */; }; + FABB20EA2602FC2C00C8785C /* ActivityTypeSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1E62D525758AAF009A0F80 /* ActivityTypeSelectorViewController.swift */; }; + FABB20EB2602FC2C00C8785C /* ActivityActionsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123CB20F418A500DF8486 /* ActivityActionsParser.swift */; }; + FABB20EC2602FC2C00C8785C /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40F46B6922121BA800A2143B /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataProperties.swift */; }; + FABB20ED2602FC2C00C8785C /* Routes+Me.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F0E1D920EBDC0A001E9514 /* Routes+Me.swift */; }; + FABB20EE2602FC2C00C8785C /* StockPhotosResultsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83CA3AA20842E5F0060E310 /* StockPhotosResultsPage.swift */; }; + FABB20EF2602FC2C00C8785C /* QuickStartTourGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4348C88221002FBD00735DC0 /* QuickStartTourGuide.swift */; }; + FABB20F02602FC2C00C8785C /* ReaderDetailToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BA77BCE2483415400E1EBBF /* ReaderDetailToolbar.swift */; }; + FABB20F12602FC2C00C8785C /* RecentSitesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E102B78F1E714F24007928E8 /* RecentSitesService.swift */; }; + FABB20F22602FC2C00C8785C /* PlanService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D7FF371C74EB0E00E7E5E5 /* PlanService.swift */; }; + FABB20F32602FC2C00C8785C /* Routes+Mbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1655B4722A6C2FA00227BFB /* Routes+Mbar.swift */; }; + FABB20F42602FC2C00C8785C /* PostListEditorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32CA6EBF2390C61F00B51347 /* PostListEditorPresenter.swift */; }; + FABB20F52602FC2C00C8785C /* PreviewDeviceLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F580C3C023D22E2D0038E243 /* PreviewDeviceLabel.swift */; }; + FABB20F62602FC2C00C8785C /* WPStyleGuide+ReaderComments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7DEA2819D488DD0032EE77 /* WPStyleGuide+ReaderComments.swift */; }; + FABB20F72602FC2C00C8785C /* TopViewedPostStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C403F22215D66A00E8C894 /* TopViewedPostStatsRecordValue+CoreDataProperties.swift */; }; + FABB20F82602FC2C00C8785C /* BaseActivityListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FC611D1FA8ADAC00A1757E /* BaseActivityListViewController.swift */; }; + FABB20F92602FC2C00C8785C /* RequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15272FE243B28B600C8DC7A /* RequestAuthenticator.swift */; }; + FABB20FA2602FC2C00C8785C /* SettingsTitleSubtitleController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66E2A631FE4311200788F22 /* SettingsTitleSubtitleController.swift */; }; + FABB20FB2602FC2C00C8785C /* WebViewControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1222B661F878E4700D23173 /* WebViewControllerFactory.swift */; }; + FABB20FC2602FC2C00C8785C /* WordPress-32-33.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = E1D86E681B2B414300DD2192 /* WordPress-32-33.xcmappingmodel */; }; + FABB20FD2602FC2C00C8785C /* ShowRevisionsListManger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2B28E7219046ED00458F2A /* ShowRevisionsListManger.swift */; }; + FABB20FF2602FC2C00C8785C /* WidgetStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985ED0E323C6950600B8D06A /* WidgetStyles.swift */; }; + FABB21002602FC2C00C8785C /* WordPress-91-92.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 57CCB3802358ED07003ECD0C /* WordPress-91-92.xcmappingmodel */; }; + FABB21012602FC2C00C8785C /* ReaderCardDiscoverAttributionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D2B30B81B7411C700DA15F3 /* ReaderCardDiscoverAttributionView.swift */; }; + FABB21022602FC2C00C8785C /* Plugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E89C6B1FD80E74006E7A33 /* Plugin.swift */; }; + FABB21032602FC2C00C8785C /* JetpackActivityLogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7C97E225A8BFA2004A3373 /* JetpackActivityLogViewController.swift */; }; + FABB21042602FC2C00C8785C /* FormattableMediaContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B020F4097A00DF8486 /* FormattableMediaContent.swift */; }; + FABB21052602FC2C00C8785C /* CollabsableHeaderFilterCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469EB16324D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.swift */; }; + FABB21062602FC2C00C8785C /* SearchResultsStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C403E92215CD1300E8C894 /* SearchResultsStatsRecordValue+CoreDataClass.swift */; }; + FABB21072602FC2C00C8785C /* ReaderTableContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87A329520ABD60700F4726F /* ReaderTableContent.swift */; }; + FABB21082602FC2C00C8785C /* SiteCreationHeaderData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A4D21B85CF20005062B /* SiteCreationHeaderData.swift */; }; + FABB21092602FC2C00C8785C /* WPAuthTokenIssueSolver.m in Sources */ = {isa = PBXBuildFile; fileRef = 594399921B45091000539E21 /* WPAuthTokenIssueSolver.m */; }; + FABB210A2602FC2C00C8785C /* PageAutoUploadMessageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16C35D523F33DE400C81331 /* PageAutoUploadMessageProvider.swift */; }; + FABB210B2602FC2C00C8785C /* ReaderDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA5A6A247C2EAF00AB124C /* ReaderDetailViewController.swift */; }; + FABB210C2602FC2C00C8785C /* HomeWidgetThisWeekData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8B136C25D08F34004FAC0A /* HomeWidgetThisWeekData.swift */; }; + FABB210D2602FC2C00C8785C /* PersonHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7B4CF720459E21001463D6 /* PersonHeaderCell.swift */; }; + FABB210E2602FC2C00C8785C /* SuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B03B9233250BC593000A40AF /* SuggestionService.swift */; }; + FABB210F2602FC2C00C8785C /* WordPress-41-42.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = B555E1141C04A68D00CEC81B /* WordPress-41-42.xcmappingmodel */; }; + FABB21102602FC2C00C8785C /* MySiteViewController+FAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = F56A33322538C0ED00E2AEF3 /* MySiteViewController+FAB.swift */; }; + FABB21112602FC2C00C8785C /* BaseRestoreOptionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8F74F25AD72CE00D5D54A /* BaseRestoreOptionsViewController.swift */; }; + FABB21122602FC2C00C8785C /* AddSiteAlertFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1863715253E49B8003D4BEF /* AddSiteAlertFactory.swift */; }; + FABB21132602FC2C00C8785C /* AuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12FA5D82428FA8F0054DA21 /* AuthenticationService.swift */; }; + FABB21142602FC2C00C8785C /* JetpackSiteRef.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16A76F01FC4758300A661E3 /* JetpackSiteRef.swift */; }; + FABB21152602FC2C00C8785C /* WPStyleGuide+Reply.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B56D3019AFB68800B4E29B /* WPStyleGuide+Reply.swift */; }; + FABB21162602FC2C00C8785C /* WPBlogTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 30EABE0818A5903400B73A9C /* WPBlogTableViewCell.m */; }; + FABB21172602FC2C00C8785C /* JetpackBackupOptionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8F76D25AD73C000D5D54A /* JetpackBackupOptionsViewController.swift */; }; + FABB21182602FC2C00C8785C /* TopViewedVideoStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C7A2217A985000A8A59 /* TopViewedVideoStatsRecordValue+CoreDataProperties.swift */; }; + FABB21192602FC2C00C8785C /* NotificationContentRangeFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B120F4097A00DF8486 /* NotificationContentRangeFactory.swift */; }; + FABB211A2602FC2C00C8785C /* SiteInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D170361EF9D8D10046D433 /* SiteInfo.swift */; }; + FABB211B2602FC2C00C8785C /* CredentialsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16A76F21FC4766900A661E3 /* CredentialsService.swift */; }; + FABB211C2602FC2C00C8785C /* EncryptedLogTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9B862C82478170A008B093C /* EncryptedLogTableViewController.swift */; }; + FABB211D2602FC2C00C8785C /* ActivityDateFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4070D75D20E6B4E4007CEBDA /* ActivityDateFormatting.swift */; }; + FABB211E2602FC2C00C8785C /* UIView+Subviews.m in Sources */ = {isa = PBXBuildFile; fileRef = E2AA87A418523E5300886693 /* UIView+Subviews.m */; }; + FABB211F2602FC2C00C8785C /* WordPress-20-21.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 5D51ADAE19A832AF00539C0B /* WordPress-20-21.xcmappingmodel */; }; + FABB21202602FC2C00C8785C /* TenorClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD5E243AECA000A83E27 /* TenorClient.swift */; }; + FABB21212602FC2C00C8785C /* LoginEpilogueTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FB3F461EC10F1E00FC8A62 /* LoginEpilogueTableViewController.swift */; }; + FABB21222602FC2C00C8785C /* JetpackRemoteInstallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8ECE092254A3260043C8DA /* JetpackRemoteInstallViewModel.swift */; }; + FABB21232602FC2C00C8785C /* InteractivePostViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5727EAF72284F5AC00822104 /* InteractivePostViewDelegate.swift */; }; + FABB21242602FC2C00C8785C /* PostEditor+BlogPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91DCE84321A6A7840062F134 /* PostEditor+BlogPicker.swift */; }; + FABB21252602FC2C00C8785C /* ActivityLogViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 93069F55176237A4000C966D /* ActivityLogViewController.m */; }; + FABB21262602FC2C00C8785C /* UIViewController+ChildViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A162F2421C26F5F00FDC035 /* UIViewController+ChildViewController.swift */; }; + FABB21272602FC2C00C8785C /* Media+Blog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086C4D0F1E81F9240011D960 /* Media+Blog.swift */; }; + FABB21282602FC2C00C8785C /* MenuItemEditingHeaderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FB31CDBF96000304BA7 /* MenuItemEditingHeaderView.m */; }; + FABB21292602FC2C00C8785C /* Routes+Banners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BD4A0720F76A4700975AC3 /* Routes+Banners.swift */; }; + FABB212A2602FC2C00C8785C /* BadgeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1702BBDB1CEDEA6B00766A33 /* BadgeLabel.swift */; }; + FABB212B2602FC2C00C8785C /* ReaderTagsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B9152024465FB400179876 /* ReaderTagsTableViewController.swift */; }; + FABB212C2602FC2C00C8785C /* NotificationsViewController+AppRatings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4388FEFD20A4E0B900783948 /* NotificationsViewController+AppRatings.swift */; }; + FABB212D2602FC2C00C8785C /* BlogService.m in Sources */ = {isa = PBXBuildFile; fileRef = 93C1148418EDF6E100DAC95C /* BlogService.m */; }; + FABB212E2602FC2C00C8785C /* JetpackSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F668B5F255DD11400D0038A /* JetpackSettingsViewController.swift */; }; + FABB212F2602FC2C00C8785C /* PostingActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C986621B9BDF300A7C0C8 /* PostingActivityViewController.swift */; }; + FABB21302602FC2C00C8785C /* PostCardStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F67C55203D81430072001E /* PostCardStatusViewModel.swift */; }; + FABB21312602FC2C00C8785C /* SharingAuthorizationHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = E63BBC951C5168BE00598BE8 /* SharingAuthorizationHelper.m */; }; + FABB21332602FC2C00C8785C /* TwoColumnCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98880A4822B2E5E400464538 /* TwoColumnCell.swift */; }; + FABB21342602FC2C00C8785C /* PostSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = ACBAB5FD0E121C7300F38795 /* PostSettingsViewController.m */; }; + FABB21352602FC2C00C8785C /* MenuItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 08CC67791C49B65A00153AD7 /* MenuItem.m */; }; + FABB21362602FC2C00C8785C /* WPRichTextFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62AFB671DC8E593007484FC /* WPRichTextFormatter.swift */; }; + FABB21372602FC2C00C8785C /* LastPostStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A71C61220E1950002E3D25 /* LastPostStatsRecordValue+CoreDataProperties.swift */; }; + FABB21382602FC2C00C8785C /* NoticeAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F50B0E7A246212B8006601DD /* NoticeAnimator.swift */; }; + FABB21392602FC2C00C8785C /* ShareExtensionSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74CEF0761F9AA0F700B729CA /* ShareExtensionSessionManager.swift */; }; + FABB213A2602FC2C00C8785C /* ReaderWelcomeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD8201A24BCDBFF00FF25FD /* ReaderWelcomeBanner.swift */; }; + FABB213B2602FC2C00C8785C /* Extensions.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 74FA4BE31FBFA0660031EAAD /* Extensions.xcdatamodeld */; }; + FABB213C2602FC2C00C8785C /* CustomLogFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 986CC4D120E1B2F6004F300E /* CustomLogFormatter.swift */; }; + FABB213D2602FC2C00C8785C /* ReaderInterestsCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3236F77324ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.swift */; }; + FABB213E2602FC2C00C8785C /* StatsRecord+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A71C64220E1951002E3D25 /* StatsRecord+CoreDataClass.swift */; }; + FABB213F2602FC2C00C8785C /* LatestPostSummaryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9826AE8021B5C6A700C851FA /* LatestPostSummaryCell.swift */; }; + FABB21402602FC2C00C8785C /* LoginEpilogueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433432511E9ED18900915988 /* LoginEpilogueViewController.swift */; }; + FABB21412602FC2C00C8785C /* QuickStartTours.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395A1582106389800844E8E /* QuickStartTours.swift */; }; + FABB21422602FC2C00C8785C /* RevisionsTableViewFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2B28ED2191B50500458F2A /* RevisionsTableViewFooter.swift */; }; + FABB21432602FC2C00C8785C /* KanvasCameraAnalyticsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A34BC925DF244F00C9654B /* KanvasCameraAnalyticsHandler.swift */; }; + FABB21442602FC2C00C8785C /* MediaEditorOperation+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BD36E012395CAEA00EFFF1C /* MediaEditorOperation+Description.swift */; }; + FABB21452602FC2C00C8785C /* WPStyleGuide+FilterTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F52DB62315233300164966 /* WPStyleGuide+FilterTabBar.swift */; }; + FABB21462602FC2C00C8785C /* PagedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DD4CCA1CAE41B800C3863E /* PagedViewController.swift */; }; + FABB21472602FC2C00C8785C /* InvitePersonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B549BA671CF7447E0086C608 /* InvitePersonViewController.swift */; }; + FABB21482602FC2C00C8785C /* PushAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B535209A1AF7BBB800B33BA8 /* PushAuthenticationManager.swift */; }; + FABB21492602FC2C00C8785C /* CommentServiceRemoteFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2211D125ED68E300BF72FC /* CommentServiceRemoteFactory.swift */; }; + FABB214A2602FC2C00C8785C /* SuggestionsTableView.m in Sources */ = {isa = PBXBuildFile; fileRef = 319D6E8019E44C680013871C /* SuggestionsTableView.m */; }; + FABB214B2602FC2C00C8785C /* PluginDetailViewHeaderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E728841FF3D9070010E7C9 /* PluginDetailViewHeaderCell.swift */; }; + FABB214C2602FC2C00C8785C /* PageTemplateCategory+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46183D1D251BD6A0004F9AFD /* PageTemplateCategory+CoreDataClass.swift */; }; + FABB214D2602FC2C00C8785C /* AllTimeStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E7FECC2211FFA70032834E /* AllTimeStatsRecordValue+CoreDataProperties.swift */; }; + FABB214E2602FC2C00C8785C /* StreakInsightStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054F45B2214F50300D261AB /* StreakInsightStatsRecordValue+CoreDataClass.swift */; }; + FABB214F2602FC2C00C8785C /* PostNoticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 174C9696205A846E00CEEF6E /* PostNoticeViewModel.swift */; }; + FABB21502602FC2C00C8785C /* JetpackInstallStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2D0B24225CB97F009E585F /* JetpackInstallStore.swift */; }; + FABB21512602FC2C00C8785C /* WizardNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A4621B85CF20005062B /* WizardNavigation.swift */; }; + FABB21522602FC2C00C8785C /* BuildConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690141F828FF000200E30 /* BuildConfiguration.swift */; }; + FABB21532602FC2C00C8785C /* PaddedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BEEC641C4E3978000B4FA0 /* PaddedLabel.swift */; }; + FABB21542602FC2C00C8785C /* PostService.m in Sources */ = {isa = PBXBuildFile; fileRef = E1A6DBE419DC7D230071AC1E /* PostService.m */; }; + FABB21552602FC2C00C8785C /* WPTextAttachmentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62AFB691DC8E593007484FC /* WPTextAttachmentManager.swift */; }; + FABB21562602FC2C00C8785C /* ReaderSiteInfoSubscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3EFC9D208E2E8A00268758 /* ReaderSiteInfoSubscriptions.swift */; }; + FABB21572602FC2C00C8785C /* ReaderSaveForLaterAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8160441209C1B0F00ABAFFA /* ReaderSaveForLaterAction.swift */; }; + FABB21582602FC2C00C8785C /* WPHorizontalRuleAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E68580F51E0D91470090EE63 /* WPHorizontalRuleAttachment.swift */; }; + FABB21592602FC2C00C8785C /* ImmuTable+Optional.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12FE0731FA0CEE000F28710 /* ImmuTable+Optional.swift */; }; + FABB215A2602FC2C00C8785C /* WordPressComRestApi+Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A433B1C2254CBEE00AE7910 /* WordPressComRestApi+Defaults.swift */; }; + FABB215B2602FC2C00C8785C /* WhatIsNewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F662C4924DC9FAC00CAEA95 /* WhatIsNewViewController.swift */; }; + FABB215C2602FC2C00C8785C /* FormattableCommentContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFF208B20EADF68009C4699 /* FormattableCommentContent.swift */; }; + FABB215D2602FC2C00C8785C /* ValueTransformers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17EFD3732578201100AB753C /* ValueTransformers.swift */; }; + FABB215E2602FC2C00C8785C /* ABTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B85AED9259230FC00ADBEC9 /* ABTest.swift */; }; + FABB215F2602FC2C00C8785C /* ContextManager+ErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E5EE36231E47A80018E9E3 /* ContextManager+ErrorHandling.swift */; }; + FABB21602602FC2C00C8785C /* EmptyActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F52CACCB24512EA700661380 /* EmptyActionView.swift */; }; + FABB21612602FC2C00C8785C /* ReaderShowAttributionAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CBE20AA7B7F008E8AE8 /* ReaderShowAttributionAction.swift */; }; + FABB21622602FC2C00C8785C /* LinkSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFABD7FF213423F1003C65B6 /* LinkSettingsViewController.swift */; }; + FABB21632602FC2C00C8785C /* FeatureItemCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15644F01CE0E56600D96E64 /* FeatureItemCell.swift */; }; + FABB21642602FC2C00C8785C /* WPRichContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62AFB661DC8E593007484FC /* WPRichContentView.swift */; }; + FABB21652602FC2C00C8785C /* MediaFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08B6E5191F036CAD00268F57 /* MediaFileManager.swift */; }; + FABB21662602FC2C00C8785C /* WPUserAgent.m in Sources */ = {isa = PBXBuildFile; fileRef = 594DB2941AB891A200E2E456 /* WPUserAgent.m */; }; + FABB21672602FC2C00C8785C /* AztecVerificationPromptHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 402FFB23218C36CF00FF4A0B /* AztecVerificationPromptHelper.swift */; }; + FABB21682602FC2C00C8785C /* NullBlogPropertySanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC6020823900D8400EFE3D0 /* NullBlogPropertySanitizer.swift */; }; + FABB21692602FC2C00C8785C /* StockPhotosStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A3A5AB2069FE5B00992576 /* StockPhotosStrings.swift */; }; + FABB216A2602FC2C00C8785C /* StatsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C56636E71868D0CE00226AAB /* StatsViewController.m */; }; + FABB216B2602FC2C00C8785C /* WPRichTextEmbed.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6805D2D1DCD399600168E4F /* WPRichTextEmbed.swift */; }; + FABB216C2602FC2C00C8785C /* PostSignUpInterstitialViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3249615023F20111004C7733 /* PostSignUpInterstitialViewController.swift */; }; + FABB216D2602FC2C00C8785C /* StatsGhostTableViewRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A09F91D230C4C0200F42AB7 /* StatsGhostTableViewRows.swift */; }; + FABB216E2602FC2C00C8785C /* ActivityContentRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E53AAFF20FE55A9005796FE /* ActivityContentRouter.swift */; }; + FABB216F2602FC2C00C8785C /* UIView+ContentLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A5B21B85EB00005062B /* UIView+ContentLayout.swift */; }; + FABB21702602FC2C00C8785C /* PeopleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B912841BB01266003C25B9 /* PeopleCell.swift */; }; + FABB21712602FC2C00C8785C /* AbstractPost+Local.swift in Sources */ = {isa = PBXBuildFile; fileRef = F15272FC243B27BC00C8DC7A /* AbstractPost+Local.swift */; }; + FABB21722602FC2C00C8785C /* MeScenePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43603023F31E09001DEE70 /* MeScenePresenter.swift */; }; + FABB21732602FC2C00C8785C /* JetpackScanStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71BC73E25A652410023D789 /* JetpackScanStatusViewModel.swift */; }; + FABB21752602FC2C00C8785C /* HeaderContentGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3E7A6520E44F200075D159 /* HeaderContentGroup.swift */; }; + FABB21762602FC2C00C8785C /* CameraHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AE440525DD0345003675F4 /* CameraHandler.swift */; }; + FABB21772602FC2C00C8785C /* JetpackConnectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F668B60255DD11400D0038A /* JetpackConnectionViewController.swift */; }; + FABB21782602FC2C00C8785C /* NotificationActionsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5015C571D4FDBB300C9449E /* NotificationActionsService.swift */; }; + FABB21792602FC2C00C8785C /* JetpackScanService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6A22E325783D2000A79950 /* JetpackScanService.swift */; }; + FABB217A2602FC2C00C8785C /* FilterSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E63128243BC8190088229D /* FilterSheetView.swift */; }; + FABB217B2602FC2C00C8785C /* WPAddPostCategoryViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = A0E293F00E21027E00C6919C /* WPAddPostCategoryViewController.m */; }; + FABB217C2602FC2C00C8785C /* ReaderSearchTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6BDEA721CE4824300682885 /* ReaderSearchTopic.swift */; }; + FABB217D2602FC2C00C8785C /* WPMediaPicker+MediaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A34A9825DEF47D00C9654B /* WPMediaPicker+MediaPicker.swift */; }; + FABB217E2602FC2C00C8785C /* JetpackInstallError+Blocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8ECE0B2254A3260043C8DA /* JetpackInstallError+Blocking.swift */; }; + FABB217F2602FC2C00C8785C /* QuickStartTourState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AF2F962107D3800069C012 /* QuickStartTourState.swift */; }; + FABB21802602FC2C00C8785C /* ReaderPostCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D5A6E911B613CA400DAF819 /* ReaderPostCardCell.swift */; }; + FABB21812602FC2C00C8785C /* DiffContentValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A38DC66218899FB006A409B /* DiffContentValue.swift */; }; + FABB21822602FC2C00C8785C /* FormattableTextContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B320F4097A00DF8486 /* FormattableTextContent.swift */; }; + FABB21832602FC2C00C8785C /* WordPress-61-62.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = E10520571F2B1CC900A948F6 /* WordPress-61-62.xcmappingmodel */; }; + FABB21842602FC2C00C8785C /* WhatIsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F73BE5E24EB3B4400BE99FF /* WhatIsNewView.swift */; }; + FABB21852602FC2C00C8785C /* GravatarService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5772AC31C9C7A070031F97E /* GravatarService.swift */; }; + FABB21862602FC2C00C8785C /* CountriesCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981676D4221B7A4300B81C3F /* CountriesCell.swift */; }; + FABB21872602FC2C00C8785C /* NavBarTitleDropdownButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D2C05551AD2F56200A753FE /* NavBarTitleDropdownButton.m */; }; + FABB21882602FC2C00C8785C /* ReaderTopicsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCF957924C6044000712056 /* ReaderTopicsCardCell.swift */; }; + FABB21892602FC2C00C8785C /* Post+RefreshStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0F722B206E5345000DAAB5 /* Post+RefreshStatus.swift */; }; + FABB218A2602FC2C00C8785C /* ImageDimensionParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326E281A250AC4A50029EBF0 /* ImageDimensionParser.swift */; }; + FABB218B2602FC2C00C8785C /* FollowersCountStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405BFB1E223B37A000CD5BEA /* FollowersCountStatsRecordValue+CoreDataClass.swift */; }; + FABB218C2602FC2C00C8785C /* DocumentUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126FDFF20A33BDB0010EB6E /* DocumentUploadProcessor.swift */; }; + FABB218D2602FC2C00C8785C /* ReaderTopicService+Interests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3236F79D24AE75790088E8F3 /* ReaderTopicService+Interests.swift */; }; + FABB218E2602FC2C00C8785C /* JetpackService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2D0B35225E2511009E585F /* JetpackService.swift */; }; + FABB218F2602FC2C00C8785C /* FollowersStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40F50B7C22130E6C00CBBB73 /* FollowersStatsRecordValue+CoreDataProperties.swift */; }; + FABB21902602FC2C00C8785C /* JetpackRestoreStatusFailedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD95D2625B91BCF00F011B5 /* JetpackRestoreStatusFailedViewController.swift */; }; + FABB21912602FC2C00C8785C /* ReaderTopicToReaderListTopic37to38.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66969DB1B9E55C300EC9C00 /* ReaderTopicToReaderListTopic37to38.swift */; }; + FABB21922602FC2C00C8785C /* BlogService+JetpackConvenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2D0B22225CB92B009E585F /* BlogService+JetpackConvenience.swift */; }; + FABB21932602FC2C00C8785C /* GutenbergTenorMediaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C856748E243EF177001A995E /* GutenbergTenorMediaPicker.swift */; }; + FABB21942602FC2C00C8785C /* AztecPostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C5D1EF42A4A00372C65 /* AztecPostViewController.swift */; }; + FABB21972602FC2C00C8785C /* MenusViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 086E1FDF1BBB35D2002D86CA /* MenusViewController.m */; }; + FABB21982602FC2C00C8785C /* UIViewController+Notice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3EFCA2208E308900268758 /* UIViewController+Notice.swift */; }; + FABB21992602FC2C00C8785C /* GutenbergFileUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0462152566938300EB98EF /* GutenbergFileUploadProcessor.swift */; }; + FABB219A2602FC2C00C8785C /* EventLoggingDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F913BB0F24B3C5CE00C19032 /* EventLoggingDataProvider.swift */; }; + FABB219B2602FC2C00C8785C /* PageListSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B93856D22DC08060010BF02 /* PageListSectionHeaderView.swift */; }; + FABB219C2602FC2C00C8785C /* InviteLinks+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = E690F6ED25E05D170015A777 /* InviteLinks+CoreDataClass.swift */; }; + FABB219D2602FC2C00C8785C /* Page+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E1D46D1CEF77B500126697 /* Page+CoreDataProperties.swift */; }; + FABB219E2602FC2C00C8785C /* FollowersStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40F50B7B22130E6C00CBBB73 /* FollowersStatsRecordValue+CoreDataClass.swift */; }; + FABB219F2602FC2C00C8785C /* RegisterDomainSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D74AD520FB5AD5004AD934 /* RegisterDomainSectionHeaderView.swift */; }; + FABB21A02602FC2C00C8785C /* WPStyleGuide+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 986DD19B218D002500D28061 /* WPStyleGuide+Stats.swift */; }; + FABB21A12602FC2C00C8785C /* TabbedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B9151E244653C100179876 /* TabbedViewController.swift */; }; + FABB21A22602FC2C00C8785C /* Blog+Quota.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF00889A204DF3ED007CCE66 /* Blog+Quota.swift */; }; + FABB21A32602FC2C00C8785C /* CommentsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C533CF340E6D3ADA000C3DE8 /* CommentsViewController.m */; }; + FABB21A42602FC2C00C8785C /* SiteSettingsViewController+SiteManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFF153C1C98962E007D1C90 /* SiteSettingsViewController+SiteManagement.swift */; }; + FABB21A52602FC2C00C8785C /* Follow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816C1ED20E0892200C4D82F /* Follow.swift */; }; + FABB21A62602FC2C00C8785C /* BlogJetpackSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822D60B81F4CCC7A0016C46D /* BlogJetpackSettingsService.swift */; }; + FABB21A72602FC2C00C8785C /* MenuItemTypeViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FC71CDBF96000304BA7 /* MenuItemTypeViewController.m */; }; + FABB21A82602FC2C00C8785C /* WPStyleGuide+WebView.m in Sources */ = {isa = PBXBuildFile; fileRef = B526DC281B1E47FC002A8C5F /* WPStyleGuide+WebView.m */; }; + FABB21A92602FC2C00C8785C /* HomeWidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6DA04025646F96002AB88F /* HomeWidgetData.swift */; }; + FABB21AA2602FC2C00C8785C /* WPStyleGuide+Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FC611F1FA8ADAC00A1757E /* WPStyleGuide+Activity.swift */; }; + FABB21AB2602FC2C00C8785C /* AllTimeWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFF57D23984344008A1DCB /* AllTimeWidgetStats.swift */; }; + FABB21AC2602FC2C00C8785C /* GutenbergFilesAppMediaSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4071392372AD54003627FA /* GutenbergFilesAppMediaSource.swift */; }; + FABB21AD2602FC2C00C8785C /* NoteBlockTextTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B532D4E8199D4357006E4DF6 /* NoteBlockTextTableViewCell.swift */; }; + FABB21AE2602FC2C00C8785C /* NotificationSettingStreamsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52F8CD71B43260C00D36025 /* NotificationSettingStreamsViewController.swift */; }; + FABB21AF2602FC2C00C8785C /* SiteDesignPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 464688D6255C71D200ECA61C /* SiteDesignPreviewViewController.swift */; }; + FABB21B02602FC2C00C8785C /* ReaderReblogFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5B3EB023A851480060FF1F /* ReaderReblogFormatter.swift */; }; + FABB21B12602FC2C00C8785C /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AB5A061E0BF17500574B4E /* Array.swift */; }; + FABB21B22602FC2C00C8785C /* NotificationActionParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = D858F2FC20E1F09F007E8A1C /* NotificationActionParser.swift */; }; + FABB21B32602FC2C00C8785C /* SiteTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66E2A641FE4311200788F22 /* SiteTagsViewController.swift */; }; + FABB21B42602FC2C00C8785C /* GutenbergImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC02B82222687BF00E64FDE /* GutenbergImageLoader.swift */; }; + FABB21B52602FC2C00C8785C /* ReaderCrossPostMeta.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C09B3D1BF0FDEB003074CB /* ReaderCrossPostMeta.swift */; }; + FABB21B62602FC2C00C8785C /* SnippetsContentStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3E7A5420E44B4B0075D159 /* SnippetsContentStyles.swift */; }; + FABB21B72602FC2C00C8785C /* TopicsCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7F25A624E6EDB4007D82CC /* TopicsCollectionView.swift */; }; + FABB21B82602FC2C00C8785C /* SiteSegmentsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8CB561F2181A8CE00554EAE /* SiteSegmentsService.swift */; }; + FABB21B92602FC2C00C8785C /* BlogListDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11E77591E72932F0072AD40 /* BlogListDataSource.swift */; }; + FABB21BA2602FC2C00C8785C /* MediaItemTableViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1730D4A21E97E3E400326B7C /* MediaItemTableViewCells.swift */; }; + FABB21BB2602FC2C00C8785C /* AbstractPost+fixLocalMediaURLs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BFE36FC230F16580061EBA8 /* AbstractPost+fixLocalMediaURLs.swift */; }; + FABB21BC2602FC2C00C8785C /* MediaNoticeNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1750BD6C201144DB0050F13A /* MediaNoticeNavigationCoordinator.swift */; }; + FABB21BD2602FC2C00C8785C /* ReaderPostToReaderPost37to38.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66969E31B9E68B200EC9C00 /* ReaderPostToReaderPost37to38.swift */; }; + FABB21BE2602FC2C00C8785C /* ReaderRelatedPostsSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA6FAB3425EF7C5700666CED /* ReaderRelatedPostsSectionHeaderView.swift */; }; + FABB21C02602FC2C00C8785C /* AppRatingUtilityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E58879F20FE956100DB6F80 /* AppRatingUtilityType.swift */; }; + FABB21C12602FC2C00C8785C /* MenuItemSourceFooterView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FBD1CDBF96000304BA7 /* MenuItemSourceFooterView.m */; }; + FABB21C22602FC2C00C8785C /* JetpackRestoreFailedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD951A325B6CB3600F011B5 /* JetpackRestoreFailedViewController.swift */; }; + FABB21C32602FC2C00C8785C /* ActionSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E032E32408D537003AF350 /* ActionSheetViewController.swift */; }; + FABB21C42602FC2C00C8785C /* PostService+Revisions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4F8F55218B88E000EEDCCC /* PostService+Revisions.swift */; }; + FABB21C52602FC2C00C8785C /* SourcePostAttribution.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DED0E171B432E0400431FCD /* SourcePostAttribution.m */; }; + FABB21C62602FC2C00C8785C /* MySitesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1715179320F4B5CD002C4A38 /* MySitesCoordinator.swift */; }; + FABB21C72602FC2C00C8785C /* MenuItem+ViewDesign.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D9784E1CD2AF7D0054F19A /* MenuItem+ViewDesign.m */; }; + FABB21C92602FC2C00C8785C /* ReaderInterestsStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3254366B24ABA82100B2C5F5 /* ReaderInterestsStyleGuide.swift */; }; + FABB21CA2602FC2C00C8785C /* SiteStatsInsightsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988056022183CCE50083B643 /* SiteStatsInsightsTableViewController.swift */; }; + FABB21CB2602FC2C00C8785C /* PostCompactCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 577C2AB322943FEC00AD1F03 /* PostCompactCell.swift */; }; + FABB21CC2602FC2C00C8785C /* MeHeaderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 315FC2C41A2CB29300E7CDA2 /* MeHeaderView.m */; }; + FABB21CD2602FC2C00C8785C /* WebProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1222B621F877FD700D23173 /* WebProgressView.swift */; }; + FABB21CE2602FC2C00C8785C /* Restorer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E185042E1EE6ABD9005C234C /* Restorer.swift */; }; + FABB21CF2602FC2C00C8785C /* BlogDetailsViewController+SectionHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02761EBF2270072F009BAF0F /* BlogDetailsViewController+SectionHelpers.swift */; }; + FABB21D02602FC2C00C8785C /* SiteStatsPeriodTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984B138D21F65F860004B6A2 /* SiteStatsPeriodTableViewController.swift */; }; + FABB21D12602FC2C00C8785C /* TextBundleWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */; }; + FABB21D22602FC2C00C8785C /* WPCategoryTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F605FA925145F7200F99544 /* WPCategoryTree.swift */; }; + FABB21D32602FC2C00C8785C /* PageListTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DFA7EC51AF814E40072023B /* PageListTableViewCell.m */; }; + FABB21D42602FC2C00C8785C /* PageSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D62BAD618AA88210044E5F7 /* PageSettingsViewController.m */; }; + FABB21D52602FC2C00C8785C /* SiteDesignContentCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46241C3A2540D483002B8A12 /* SiteDesignContentCollectionViewController.swift */; }; + FABB21D62602FC2C00C8785C /* PlanComparisonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D2FDC11C6A468A00944265 /* PlanComparisonViewController.swift */; }; + FABB21D72602FC2C00C8785C /* Routes+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1715179120F4B2EB002C4A38 /* Routes+Stats.swift */; }; + FABB21D82602FC2C00C8785C /* MeViewController+UIViewControllerRestoration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F29EB7124042276005313DE /* MeViewController+UIViewControllerRestoration.swift */; }; + FABB21D92602FC2C00C8785C /* SearchIdentifierGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74729CA92057100200D1394D /* SearchIdentifierGenerator.swift */; }; + FABB21DA2602FC2C00C8785C /* StatsTwoColumnRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D52C3022B1CFEB00831529 /* StatsTwoColumnRow.swift */; }; + FABB21DB2602FC2C00C8785C /* AlertInternalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B572735D1B66CCEF000D1C4F /* AlertInternalView.swift */; }; + FABB21DC2602FC2C00C8785C /* ReaderSaveForLaterRemovedPosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88106F920C0CFEE001D2F00 /* ReaderSaveForLaterRemovedPosts.swift */; }; + FABB21DD2602FC2C00C8785C /* NSAttributedString+StyledHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5921CE23801000CA08D /* NSAttributedString+StyledHTML.swift */; }; + FABB21DE2602FC2C00C8785C /* Accessible.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8071630203DA23700B32FD9 /* Accessible.swift */; }; + FABB21DF2602FC2C00C8785C /* PostingActivityCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9826AE8E21B5D3CD00C851FA /* PostingActivityCell.swift */; }; + FABB21E02602FC2C00C8785C /* SupportTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98077B592075561800109F95 /* SupportTableViewController.swift */; }; + FABB21E12602FC2C00C8785C /* DetailDataCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987535612282682D001661B4 /* DetailDataCell.swift */; }; + FABB21E22602FC2C00C8785C /* PluginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17E67021FA22C93009BDC9A /* PluginViewModel.swift */; }; + FABB21E32602FC2C00C8785C /* NSCalendar+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E167F319C08D18009535AA /* NSCalendar+Helpers.swift */; }; + FABB21E42602FC2C00C8785C /* GutenbergCoverUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4629E4202440C5B20002E15C /* GutenbergCoverUploadProcessor.swift */; }; + FABB21E52602FC2C00C8785C /* MediaQuotaCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF00889E204E01AE007CCE66 /* MediaQuotaCell.swift */; }; + FABB21E62602FC2C00C8785C /* PartScreenPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5844B6A235EAF3D007C6557 /* PartScreenPresentationController.swift */; }; + FABB21E72602FC2C00C8785C /* WPRichTextImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6805D2E1DCD399600168E4F /* WPRichTextImage.swift */; }; + FABB21E82602FC2C00C8785C /* FormattableContentRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B420F4097A00DF8486 /* FormattableContentRange.swift */; }; + FABB21E92602FC2C00C8785C /* MenuItemSourceHeaderView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D978521CD2AF7D0054F19A /* MenuItemSourceHeaderView.m */; }; + FABB21EA2602FC2C00C8785C /* RestoreWarningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1A543D25A6E2F60033967D /* RestoreWarningView.swift */; }; + FABB21EB2602FC2C00C8785C /* GutenbergWebNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E4F2E702458AF8500EB73E7 /* GutenbergWebNavigationViewController.swift */; }; + FABB21ED2602FC2C00C8785C /* WPTabBarController+ReaderTabNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FF1A852242D5FCB00373F5D /* WPTabBarController+ReaderTabNavigation.swift */; }; + FABB21EE2602FC2C00C8785C /* WPStyleGuide+Blog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE1071FB1BC75E7400906AFF /* WPStyleGuide+Blog.swift */; }; + FABB21EF2602FC2C00C8785C /* KeyboardDismissHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56695AF1D411EEB007E342F /* KeyboardDismissHelper.swift */; }; + FABB21F02602FC2C00C8785C /* FancyAlertViewController+CreateButtonAnnouncement.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B9D7EF245BA938002BB2C7 /* FancyAlertViewController+CreateButtonAnnouncement.swift */; }; + FABB21F12602FC2C00C8785C /* GutenbergSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF54D4631D6F3FA900A0DC4D /* GutenbergSettings.swift */; }; + FABB21F22602FC2C00C8785C /* MediaPickingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80BC79D20746B4100614A59 /* MediaPickingContext.swift */; }; + FABB21F32602FC2C00C8785C /* ReaderStreamHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2E16B1B8B423B0000ED14 /* ReaderStreamHeader.swift */; }; + FABB21F42602FC2C00C8785C /* Progress+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD12D5D1FE1998D00F20A00 /* Progress+Helpers.swift */; }; + FABB21F52602FC2C00C8785C /* PostNoticeNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 170CE73F2064478600A48191 /* PostNoticeNavigationCoordinator.swift */; }; + FABB21F62602FC2C00C8785C /* SearchableActivityConvertable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 740516882087B73400252FD0 /* SearchableActivityConvertable.swift */; }; + FABB21F72602FC2C00C8785C /* GridCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B390E92537E30B0097049E /* GridCell.swift */; }; + FABB21F82602FC2C00C8785C /* AdaptiveNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F641B21E37C36700B7819F /* AdaptiveNavigationController.swift */; }; + FABB21F92602FC2C00C8785C /* RemotePostCategory+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BC35B720499EEB00AC1525 /* RemotePostCategory+Extensions.swift */; }; + FABB21FA2602FC2C00C8785C /* PostAutoUploadMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B8FE8562343952B00F9AD2E /* PostAutoUploadMessages.swift */; }; + FABB21FB2602FC2C00C8785C /* WPError+Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57D5812C2228526C002BAAD7 /* WPError+Swift.swift */; }; + FABB21FC2602FC2C00C8785C /* FileDownloadsStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988AC37822F10E2C00BC1433 /* FileDownloadsStatsRecordValue+CoreDataClass.swift */; }; + FABB21FD2602FC2C00C8785C /* ActivityUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 825327571FBF7CD600B8B7D2 /* ActivityUtils.swift */; }; + FABB21FE2602FC2C00C8785C /* WPLogger.m in Sources */ = {isa = PBXBuildFile; fileRef = 59DD94331AC479ED0032DD6B /* WPLogger.m */; }; + FABB21FF2602FC2C00C8785C /* JetpackBackupStatusCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8FD4F25AEB0F500D5D54A /* JetpackBackupStatusCoordinator.swift */; }; + FABB22012602FC2C00C8785C /* RevisionDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A38DC67218899FB006A409B /* RevisionDiff.swift */; }; + FABB22022602FC2C00C8785C /* MediaThumbnailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4C069E206560E500E0B2BC /* MediaThumbnailCoordinator.swift */; }; + FABB22032602FC2C00C8785C /* RevisionDiffsPageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4697B121B002AD00468B64 /* RevisionDiffsPageManager.swift */; }; + FABB22042602FC2C00C8785C /* CalendarCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5660D06235D114500020B1E /* CalendarCollectionView.swift */; }; + FABB22052602FC2C00C8785C /* SubjectContentGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3E7A6320E44ED60075D159 /* SubjectContentGroup.swift */; }; + FABB22062602FC2C00C8785C /* PluginListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E151C0C51F3889DF00710A83 /* PluginListRow.swift */; }; + FABB22072602FC2C00C8785C /* BlogSettings+DateAndTimeFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8217380A1FE05EE600BEC94C /* BlogSettings+DateAndTimeFormat.swift */; }; + FABB22082602FC2C00C8785C /* PostSearchHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57047A4E22A961BC00B461DF /* PostSearchHeader.swift */; }; + FABB22092602FC2C00C8785C /* ReaderPostCardContentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 088B89881DA6F93B000E8DEF /* ReaderPostCardContentLabel.swift */; }; + FABB220A2602FC2C00C8785C /* FilterChipButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B93412E257029F50097D0AC /* FilterChipButton.swift */; }; + FABB220B2602FC2C00C8785C /* StatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A71C62220E1950002E3D25 /* StatsRecordValue+CoreDataClass.swift */; }; + FABB220C2602FC2C00C8785C /* Blog.m in Sources */ = {isa = PBXBuildFile; fileRef = CEBD3EAA0FF1BA3B00C1396E /* Blog.m */; }; + FABB220D2602FC2C00C8785C /* JetpackRemoteInstallStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8ECE1B2254AE4E0043C8DA /* JetpackRemoteInstallStateView.swift */; }; + FABB220E2602FC2C00C8785C /* PublishSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F511F8A32356A4F400895E73 /* PublishSettingsViewController.swift */; }; + FABB220F2602FC2C00C8785C /* MediaHost+AbstractPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11C9F75243B3C5E00921DDC /* MediaHost+AbstractPost.swift */; }; + FABB22102602FC2C00C8785C /* PageTemplateCategory+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46183D1E251BD6A0004F9AFD /* PageTemplateCategory+CoreDataProperties.swift */; }; + FABB22112602FC2C00C8785C /* MyProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1CCB2C204DDD18000EE3AC /* MyProfileHeaderView.swift */; }; + FABB22132602FC2C00C8785C /* BlogSiteVisibilityHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = FFDA7E4F1B8DF6E500B83C56 /* BlogSiteVisibilityHelper.m */; }; + FABB22142602FC2C00C8785C /* TitleBadgeDisclosureCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66E2A671FE432B900788F22 /* TitleBadgeDisclosureCell.swift */; }; + FABB22152602FC2C00C8785C /* FormattableContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B220F4097A00DF8486 /* FormattableContent.swift */; }; + FABB22162602FC2C00C8785C /* WebAddressWizardContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82253DD2199418B0014D0E2 /* WebAddressWizardContent.swift */; }; + FABB22172602FC2C00C8785C /* VisitsSummaryStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C762217A8A0000A8A59 /* VisitsSummaryStatsRecordValue+CoreDataProperties.swift */; }; + FABB22182602FC2C00C8785C /* SiteSegmentsStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = D853723B21952DC90076F461 /* SiteSegmentsStep.swift */; }; + FABB22192602FC2C00C8785C /* ReaderInterestsCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32E1BFD924A66F2A007A08F0 /* ReaderInterestsCollectionViewFlowLayout.swift */; }; + FABB221A2602FC2C00C8785C /* PHAsset+Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF70A3201FD5840500BC270D /* PHAsset+Metadata.swift */; }; + FABB221B2602FC2C00C8785C /* Theme.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DA5BF3418E32DCF005F11F9 /* Theme.m */; }; + FABB221C2602FC2C00C8785C /* StockPhotosMediaGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A3A5B0206A49A100992576 /* StockPhotosMediaGroup.swift */; }; + FABB221D2602FC2C00C8785C /* DefaultFormattableContentAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123AF20F4097A00DF8486 /* DefaultFormattableContentAction.swift */; }; + FABB221E2602FC2C00C8785C /* PostEditorNavigationBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E504D4921A5B8D400E341A8 /* PostEditorNavigationBarManager.swift */; }; + FABB221F2602FC2C00C8785C /* Charts+LargeValueFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73CB13962289BEFB00265F49 /* Charts+LargeValueFormatter.swift */; }; + FABB22202602FC2C00C8785C /* CustomHighlightButton.m in Sources */ = {isa = PBXBuildFile; fileRef = ADF544C1195A0F620092213D /* CustomHighlightButton.m */; }; + FABB22212602FC2C00C8785C /* UniversalLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B7C89D20EC1D0D0042E260 /* UniversalLinkRouter.swift */; }; + FABB22222602FC2C00C8785C /* LightNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59AAC15235EA46D00385EE6 /* LightNavigationController.swift */; }; + FABB22232602FC2C00C8785C /* PluginDirectoryCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A2777E20191AA500D078D5 /* PluginDirectoryCollectionViewCell.swift */; }; + FABB22242602FC2C00C8785C /* GutenbergNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E40716123741375003627FA /* GutenbergNetworking.swift */; }; + FABB22252602FC2C00C8785C /* NavigationActionHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BD4A182101D31B00975AC3 /* NavigationActionHelpers.swift */; }; + FABB22262602FC2C00C8785C /* String+Ranges.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55FFCF91F034F1A0070812C /* String+Ranges.swift */; }; + FABB22272602FC2C00C8785C /* ActivityCommentRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E846FF220FD37BD00881F5A /* ActivityCommentRange.swift */; }; + FABB22282602FC2C00C8785C /* GutenbergAudioUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E672D94257663CE00421F13 /* GutenbergAudioUploadProcessor.swift */; }; + FABB22292602FC2C00C8785C /* AsyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA0ADB0235F116F0027AB5D /* AsyncOperation.swift */; }; + FABB222A2602FC2C00C8785C /* JetpackScanThreatSectionGrouping.swift in Sources */ = {isa = PBXBuildFile; fileRef = C73868C425C9F9820072532C /* JetpackScanThreatSectionGrouping.swift */; }; + FABB222B2602FC2C00C8785C /* MediaService+Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8791BA1FBAF4B400AD86E6 /* MediaService+Swift.swift */; }; + FABB222C2602FC2C00C8785C /* ReaderActionHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CB820AA77AD008E8AE8 /* ReaderActionHelpers.swift */; }; + FABB222D2602FC2C00C8785C /* NoteBlockUserTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B52C4C7C199D4CD3009FD823 /* NoteBlockUserTableViewCell.swift */; }; + FABB222E2602FC2C00C8785C /* WPStyleGuide+Sharing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64384821C628FCC0052ADB5 /* WPStyleGuide+Sharing.swift */; }; + FABB222F2602FC2C00C8785C /* StoryPoster.swift in Sources */ = {isa = PBXBuildFile; fileRef = F504D2AA25D60C5900A2764C /* StoryPoster.swift */; }; + FABB22302602FC2C00C8785C /* Double+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C82B52193A7B900A06E84 /* Double+Stats.swift */; }; + FABB22312602FC2C00C8785C /* CircularProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177074841FB209F100951A4A /* CircularProgressView.swift */; }; + FABB22322602FC2C00C8785C /* StatsNoDataRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98458CB721A39D350025D232 /* StatsNoDataRow.swift */; }; + FABB22332602FC2C00C8785C /* ReaderTableCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3234BB162530DFCA0068DA40 /* ReaderTableCardCell.swift */; }; + FABB22342602FC2C00C8785C /* ShareNoticeNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7462BFD32028CD4400B552D8 /* ShareNoticeNavigationCoordinator.swift */; }; + FABB22352602FC2C00C8785C /* CameraCaptureCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80BC79F2074722000614A59 /* CameraCaptureCoordinator.swift */; }; + FABB22362602FC2C00C8785C /* HomeWidgetCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA53E9B256571D800F4D9A2 /* HomeWidgetCache.swift */; }; + FABB22372602FC2C00C8785C /* JetpackRestoreStatusCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8AB5E25AFFD0600F9F8A0 /* JetpackRestoreStatusCoordinator.swift */; }; + FABB22382602FC2C00C8785C /* EventLoggingDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F913BB0D24B3C58B00C19032 /* EventLoggingDelegate.swift */; }; + FABB22392602FC2C00C8785C /* TodayStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4089C50E22371B120031CE78 /* TodayStatsRecordValue+CoreDataClass.swift */; }; + FABB223B2602FC2C00C8785C /* AbstractPost+Searchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74729CAD205722E300D1394D /* AbstractPost+Searchable.swift */; }; + FABB223C2602FC2C00C8785C /* EditCommentViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 2906F810110CDA8900169D56 /* EditCommentViewController.m */; }; + FABB223D2602FC2C00C8785C /* ThisWeekWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F93181239AF64800E4E96E /* ThisWeekWidgetStats.swift */; }; + FABB223E2602FC2C00C8785C /* PostAutoUploadMessageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16C35D923F3F76C00C81331 /* PostAutoUploadMessageProvider.swift */; }; + FABB223F2602FC2C00C8785C /* GutenbergMediaPickerHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91D8364021946EFB008340B2 /* GutenbergMediaPickerHelper.swift */; }; + FABB22402602FC2C00C8785C /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1D690151F828FF000200E30 /* FeatureFlag.swift */; }; + FABB22422602FC2C00C8785C /* BlogDetailsViewController+Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74989B8B2088E3650054290B /* BlogDetailsViewController+Activity.swift */; }; + FABB22432602FC2C00C8785C /* NoteBlockTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B532D4E7199D4357006E4DF6 /* NoteBlockTableViewCell.swift */; }; + FABB22442602FC2C00C8785C /* WPTabBarController+QuickStart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DDFE8B21715ADD008BE72F /* WPTabBarController+QuickStart.swift */; }; + FABB22452602FC2C00C8785C /* SharedCoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 746D6B241FBF701F003C45BE /* SharedCoreDataStack.swift */; }; + FABB22462602FC2C00C8785C /* TagsCategoriesStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054F4542214E3C800D261AB /* TagsCategoriesStatsRecordValue+CoreDataClass.swift */; }; + FABB22472602FC2C00C8785C /* KeychainTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11000981CDB5F1E00E33887 /* KeychainTools.swift */; }; + FABB22482602FC2C00C8785C /* ReaderTagStreamHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2E1661B8AAD8C0000ED14 /* ReaderTagStreamHeader.swift */; }; + FABB22492602FC2C00C8785C /* FullScreenCommentReplyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328CEC5D23A532BA00A6899E /* FullScreenCommentReplyViewController.swift */; }; + FABB224A2602FC2C00C8785C /* ReaderFollowedSitesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D3B1421D1C702600008D4B /* ReaderFollowedSitesViewController.swift */; }; + FABB224B2602FC2C00C8785C /* AnnouncementsCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6AD0552502A91400080F3B /* AnnouncementsCache.swift */; }; + FABB224C2602FC2C00C8785C /* GravatarUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FF64EE20DAA0840060A69A /* GravatarUploader.swift */; }; + FABB224D2602FC2C00C8785C /* NotificationSettingDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5899ADD1B419C560075A3D6 /* NotificationSettingDetailsViewController.swift */; }; + FABB224E2602FC2C00C8785C /* AztecMediaPickingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A3A5A92069E53900992576 /* AztecMediaPickingCoordinator.swift */; }; + FABB224F2602FC2C00C8785C /* SiteSuggestion+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = B06378BE253F639D00FD45D2 /* SiteSuggestion+CoreDataClass.swift */; }; + FABB22502602FC2C00C8785C /* NibReusable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D55DA210F862A00CEAA33 /* NibReusable.swift */; }; + FABB22512602FC2C00C8785C /* TabbedTotalsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98563DDB21BF30C40006F5E9 /* TabbedTotalsCell.swift */; }; + FABB22522602FC2C00C8785C /* ActivityTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FC611C1FA8ADAC00A1757E /* ActivityTableViewCell.swift */; }; + FABB22532602FC2C00C8785C /* BlogDetailsViewController+FancyAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435D10192130C2AB00BB2AA8 /* BlogDetailsViewController+FancyAlerts.swift */; }; + FABB22542602FC2C00C8785C /* StoriesIntroDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F532AD60253B81320013B42E /* StoriesIntroDataSource.swift */; }; + FABB22552602FC2C00C8785C /* StoreContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ECE34E1FA88DA2007FA37A /* StoreContainer.swift */; }; + FABB22562602FC2C00C8785C /* AbstractPost+HashHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 40232A9D230A6A740036B0B6 /* AbstractPost+HashHelpers.m */; }; + FABB22572602FC2C00C8785C /* StartOverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE420191C5AEFE100C1D036 /* StartOverViewController.swift */; }; + FABB22592602FC2C00C8785C /* UINavigationController+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1790A4521E28F0ED00AE54C2 /* UINavigationController+Helpers.swift */; }; + FABB225A2602FC2C00C8785C /* UnifiedProloguePages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C2FF0825D4852400CDB712 /* UnifiedProloguePages.swift */; }; + FABB225B2602FC2C00C8785C /* CountryStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C7E2217A985000A8A59 /* CountryStatsRecordValue+CoreDataClass.swift */; }; + FABB225C2602FC2C00C8785C /* MenuHeaderViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D345551CD7FBA900358E8C /* MenuHeaderViewController.m */; }; + FABB225D2602FC2C00C8785C /* NotificationDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5120B371D47CC6C0059361A /* NotificationDetailsViewController.swift */; }; + FABB225F2602FC2C00C8785C /* MediaURLExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F8CD381EBD2C970049D0C0 /* MediaURLExporter.swift */; }; + FABB22602602FC2C00C8785C /* WordPress.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = E125443B12BF5A7200D87A0A /* WordPress.xcdatamodeld */; }; + FABB22612602FC2C00C8785C /* WPStyleGuide+AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B574CE101B5D8F8600A84FFD /* WPStyleGuide+AlertView.swift */; }; + FABB22622602FC2C00C8785C /* ErrorStateViewConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731E88C621C9A10A0055C014 /* ErrorStateViewConfiguration.swift */; }; + FABB22632602FC2C00C8785C /* UIFont+Weight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17C3FA6E25E591D200EFFE12 /* UIFont+Weight.swift */; }; + FABB22642602FC2C00C8785C /* PostChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73E4E375227A033A0007D752 /* PostChart.swift */; }; + FABB22652602FC2C00C8785C /* PageCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4631359024AD013F0017E65C /* PageCoordinator.swift */; }; + FABB22662602FC2C00C8785C /* Blog+QuickStart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43290D03215C28D800F6B398 /* Blog+QuickStart.swift */; }; + FABB22672602FC2C00C8785C /* CommentsViewController+Filters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 987C40C525E590BE002A0955 /* CommentsViewController+Filters.swift */; }; + FABB22682602FC2C00C8785C /* AddressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82253E3219956540014D0E2 /* AddressTableViewCell.swift */; }; + FABB22692602FC2C00C8785C /* URL+LinkNormalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1FD0232091268900186384 /* URL+LinkNormalization.swift */; }; + FABB226A2602FC2C00C8785C /* ReaderTopicService+SiteInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3234BB072530D7DC0068DA40 /* ReaderTopicService+SiteInfo.swift */; }; + FABB226B2602FC2C00C8785C /* WordPressSupportSourceTag+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5326E6E203F554C007392C3 /* WordPressSupportSourceTag+Helpers.swift */; }; + FABB226C2602FC2C00C8785C /* ParentPageSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF724EE2146813C00F63A61 /* ParentPageSettingsViewController.swift */; }; + FABB226D2602FC2C00C8785C /* Queue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1759F16F1FE017BF0003EC81 /* Queue.swift */; }; + FABB226E2602FC2C00C8785C /* SwitchTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56A70171B5040B9001D5815 /* SwitchTableViewCell.swift */; }; + FABB226F2602FC2C00C8785C /* ReaderTopicToReaderSiteTopic37to38.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66969E11B9E67A000EC9C00 /* ReaderTopicToReaderSiteTopic37to38.swift */; }; + FABB22702602FC2C00C8785C /* WordPressComSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B543D2B420570B5A00D3D4CC /* WordPressComSyncService.swift */; }; + FABB22712602FC2C00C8785C /* AppRatingsUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14A52361E39F43E00EE203E /* AppRatingsUtility.swift */; }; + FABB22722602FC2C00C8785C /* GutenbergBlockProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46638DF5244904A3006E8439 /* GutenbergBlockProcessor.swift */; }; + FABB22732602FC2C00C8785C /* SiteDesignStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46241C0E2540BD01002B8A12 /* SiteDesignStep.swift */; }; + FABB22742602FC2C00C8785C /* Media.m in Sources */ = {isa = PBXBuildFile; fileRef = 8350E49511D2C71E00A7B073 /* Media.m */; }; + FABB22752602FC2C00C8785C /* NetworkAware.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B9B58E204F4EA1003C6042 /* NetworkAware.swift */; }; + FABB22762602FC2C00C8785C /* LanguageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54346951C6A707D0010B3AD /* LanguageViewController.swift */; }; + FABB22772602FC2C00C8785C /* InlineEditableNameValueCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D74ACF20F906EE004AD934 /* InlineEditableNameValueCell.swift */; }; + FABB22782602FC2C00C8785C /* TodayStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4089C50F22371B120031CE78 /* TodayStatsRecordValue+CoreDataProperties.swift */; }; + FABB22792602FC2C00C8785C /* WPCrashLoggingProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F928EDA2226140620030D451 /* WPCrashLoggingProvider.swift */; }; + FABB227A2602FC2C00C8785C /* StockPhotosMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A3A5B2206A49BF00992576 /* StockPhotosMedia.swift */; }; + FABB227B2602FC2C00C8785C /* FancyAlertViewController+SavedPosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176BB87E20D0068500751DCE /* FancyAlertViewController+SavedPosts.swift */; }; + FABB227C2602FC2C00C8785C /* WPRichTextMediaAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6805D2F1DCD399600168E4F /* WPRichTextMediaAttachment.swift */; }; + FABB227D2602FC2C00C8785C /* TenorMediaObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD5F243AECA000A83E27 /* TenorMediaObject.swift */; }; + FABB227E2602FC2C00C8785C /* NotificationReplyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C0CF3C204DA41000DB0362 /* NotificationReplyStore.swift */; }; + FABB227F2602FC2C00C8785C /* SettingsTextViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = B53AD9B91BE95687009AB87E /* SettingsTextViewController.m */; }; + FABB22802602FC2C00C8785C /* PlanListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1389ADA1C59F7C200FB2466 /* PlanListViewController.swift */; }; + FABB22812602FC2C00C8785C /* GutenbergRequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E485A8F249B61440000A253 /* GutenbergRequestAuthenticator.swift */; }; + FABB22822602FC2C00C8785C /* WPTableViewActivityCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 8370D10911FA499A009D650F /* WPTableViewActivityCell.m */; }; + FABB22842602FC2C00C8785C /* PeopleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B912881BB01288003C25B9 /* PeopleViewController.swift */; }; + FABB22852602FC2C00C8785C /* ConfettiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FEC241425D73E8B007AFE63 /* ConfettiView.swift */; }; + FABB22862602FC2C00C8785C /* RegisterDomainSuggestionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D560C2117312600CEAA33 /* RegisterDomainSuggestionsViewController.swift */; }; + FABB22872602FC2C00C8785C /* AbstractPost+MarkAsFailedAndDraftIfNeeded.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BC12F732320181E004DDA72 /* AbstractPost+MarkAsFailedAndDraftIfNeeded.swift */; }; + FABB22882602FC2C00C8785C /* UserSuggestion+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0B68A9B252FA91E0001B28C /* UserSuggestion+CoreDataProperties.swift */; }; + FABB22892602FC2C00C8785C /* FollowersCountStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405BFB1F223B37A000CD5BEA /* FollowersCountStatsRecordValue+CoreDataProperties.swift */; }; + FABB228A2602FC2C00C8785C /* ReaderHeaderAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CC620AA85C1008E8AE8 /* ReaderHeaderAction.swift */; }; + FABB228B2602FC2C00C8785C /* FancyAlerts+VerificationPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 405B53FA1F83C369002E19BF /* FancyAlerts+VerificationPrompt.swift */; }; + FABB228C2602FC2C00C8785C /* ReaderSiteStreamHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2E1641B8AAD7E0000ED14 /* ReaderSiteStreamHeader.swift */; }; + FABB228D2602FC2C00C8785C /* VideoUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126FDFD20A33BDB0010EB6E /* VideoUploadProcessor.swift */; }; + FABB228E2602FC2C00C8785C /* PeopleCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E166FA1A1BB0656B00374B5B /* PeopleCellViewModel.swift */; }; + FABB228F2602FC2C00C8785C /* TableViewOffsetCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73CE3E0D21F7F9D3007C9C85 /* TableViewOffsetCoordinator.swift */; }; + FABB22902602FC2C00C8785C /* RegisterDomainDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D56192117312700CEAA33 /* RegisterDomainDetailsViewController.swift */; }; + FABB22912602FC2C00C8785C /* FilterProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E29035243E4F5F00C19CA5 /* FilterProvider.swift */; }; + FABB22922602FC2C00C8785C /* DomainCreditEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027AC51C227896540033E56E /* DomainCreditEligibilityChecker.swift */; }; + FABB22932602FC2C00C8785C /* SiteSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 83FEFC7411FF6C5A0078B462 /* SiteSettingsViewController.m */; }; + FABB22942602FC2C00C8785C /* WPStyleGuide+Comments.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56994441B7A7EF200FF26FA /* WPStyleGuide+Comments.swift */; }; + FABB22952602FC2C00C8785C /* WPMediaEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B05D29023A9417E0063B9AA /* WPMediaEditor.swift */; }; + FABB22962602FC2C00C8785C /* ApproveComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816C1EB20E0887C00C4D82F /* ApproveComment.swift */; }; + FABB22972602FC2C00C8785C /* MediaUploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74AF4D6D1FE417D200E3EBFE /* MediaUploadOperation.swift */; }; + FABB22982602FC2C00C8785C /* PostingActivityCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C986C21B9D71400A7C0C8 /* PostingActivityCollectionViewCell.swift */; }; + FABB22992602FC2C00C8785C /* NibLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D55D8210F85DD00CEAA33 /* NibLoadable.swift */; }; + FABB229A2602FC2C00C8785C /* BackupListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B36256525A60CCA00D7CCE3 /* BackupListViewController.swift */; }; + FABB229B2602FC2C00C8785C /* ReaderReportPostAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32A218D7251109DB00D1AE6C /* ReaderReportPostAction.swift */; }; + FABB229C2602FC2C00C8785C /* BaseRestoreStatusFailedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA681F8825CA946B00DAA544 /* BaseRestoreStatusFailedViewController.swift */; }; + FABB229D2602FC2C00C8785C /* ImgUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F126FDFE20A33BDB0010EB6E /* ImgUploadProcessor.swift */; }; + FABB229E2602FC2C00C8785C /* RegisterDomainDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D56112117312700CEAA33 /* RegisterDomainDetailsViewModel.swift */; }; + FABB229F2602FC2C00C8785C /* JetpackScanHistoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C76F48DB25BA202600BFEC87 /* JetpackScanHistoryViewController.swift */; }; + FABB22A02602FC2C00C8785C /* GutenbergStockPhotos.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E407120237163B8003627FA /* GutenbergStockPhotos.swift */; }; + FABB22A12602FC2C00C8785C /* MediaHost+Blog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11C9F73243B3C3E00921DDC /* MediaHost+Blog.swift */; }; + FABB22A22602FC2C00C8785C /* PlanListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15644EC1CE0E4FE00D96E64 /* PlanListRow.swift */; }; + FABB22A32602FC2C00C8785C /* SiteCreationWizard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A4021B85CF20005062B /* SiteCreationWizard.swift */; }; + FABB22A42602FC2C00C8785C /* TopCommentsAuthorStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054F4412213635000D261AB /* TopCommentsAuthorStatsRecordValue+CoreDataProperties.swift */; }; + FABB22A52602FC2C00C8785C /* SignupEpilogueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98656BD72037A1770079DE67 /* SignupEpilogueViewController.swift */; }; + FABB22A62602FC2C00C8785C /* TenorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD76243BF7A600A83E27 /* TenorPicker.swift */; }; + FABB22A72602FC2C00C8785C /* BlogListViewController+Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74EFB5C7208674250070BD4E /* BlogListViewController+Activity.swift */; }; + FABB22A82602FC2C00C8785C /* TableDataCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A4A21B85CF20005062B /* TableDataCoordinator.swift */; }; + FABB22A92602FC2C00C8785C /* SettingsMultiTextViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = B541276A1C0F7D610015CA80 /* SettingsMultiTextViewController.m */; }; + FABB22AA2602FC2C00C8785C /* FilterSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E6312A243BC83E0088229D /* FilterSheetViewController.swift */; }; + FABB22AB2602FC2C00C8785C /* Plan.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F2787A21BC1A48008B4DB5 /* Plan.swift */; }; + FABB22AC2602FC2C00C8785C /* DiscussionSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AC00671BE3C4E100F8E7C3 /* DiscussionSettingsViewController.swift */; }; + FABB22AD2602FC2C00C8785C /* GutenbergSuggestionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0637542253E7E7A00FD45D2 /* GutenbergSuggestionsViewController.swift */; }; + FABB22AE2602FC2C00C8785C /* TrashComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816C1F520E0896F00C4D82F /* TrashComment.swift */; }; + FABB22AF2602FC2C00C8785C /* MenusService.m in Sources */ = {isa = PBXBuildFile; fileRef = 08AAD69E1CBEA47D002B2418 /* MenusService.m */; }; + FABB22B12602FC2C00C8785C /* ReaderDetailFeaturedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3223393B24FEC18000BDD4BF /* ReaderDetailFeaturedImageView.swift */; }; + FABB22B22602FC2C00C8785C /* StatsDataHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B52AE021F7AF4A006FF6B4 /* StatsDataHelper.swift */; }; + FABB22B32602FC2C00C8785C /* PrepublishingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BAD272B241FEF3300E9D105 /* PrepublishingViewController.swift */; }; + FABB22B42602FC2C00C8785C /* PageListTableViewHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22D9BF214A6BCA00BAEAF2 /* PageListTableViewHandler.swift */; }; + FABB22B52602FC2C00C8785C /* SearchableItemConvertable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74729CA52056FE6000D1394D /* SearchableItemConvertable.swift */; }; + FABB22B62602FC2C00C8785C /* WPAndDeviceMediaLibraryDataSource.m in Sources */ = {isa = PBXBuildFile; fileRef = FFE3B2C61B2E651400E9F1E0 /* WPAndDeviceMediaLibraryDataSource.m */; }; + FABB22B72602FC2C00C8785C /* SettingsListEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59D994E1C0790CC0003D795 /* SettingsListEditorViewController.swift */; }; + FABB22B82602FC2C00C8785C /* QuickStartChecklistManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4E215B21F75BBE00EFF212 /* QuickStartChecklistManager.swift */; }; + FABB22B92602FC2C00C8785C /* NoResultsTenorConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD7A243BF7A600A83E27 /* NoResultsTenorConfiguration.swift */; }; + FABB22BA2602FC2C00C8785C /* JetpackRemoteInstallViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8ECE062254A3260043C8DA /* JetpackRemoteInstallViewController.swift */; }; + FABB22BB2602FC2C00C8785C /* PlanDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1724DDC71C60F1200099D273 /* PlanDetailViewController.swift */; }; + FABB22BC2602FC2C00C8785C /* Array+Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A2CD5362146B8C700AE5055 /* Array+Page.swift */; }; + FABB22BD2602FC2C00C8785C /* WPTableViewHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D6C4B071B603E03005E3C43 /* WPTableViewHandler.m */; }; + FABB22BE2602FC2C00C8785C /* JetpackScanThreatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C78543D125B889CC006CEAFB /* JetpackScanThreatViewModel.swift */; }; + FABB22BF2602FC2C00C8785C /* PublicizeConnectionStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40EE947D2213213F00CD264F /* PublicizeConnectionStatsRecordValue+CoreDataClass.swift */; }; + FABB22C12602FC2C00C8785C /* DomainsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1702BBDF1CF3034E00766A33 /* DomainsService.swift */; }; + FABB22C22602FC2C00C8785C /* PasswordAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1CF00E2433902700578582 /* PasswordAlertController.swift */; }; + FABB22C32602FC2C00C8785C /* ReaderTagsFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7F92B725E61C7E00502D2A /* ReaderTagsFooter.swift */; }; + FABB22C42602FC2C00C8785C /* ReaderPostCellActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D817799320ABFDB300330998 /* ReaderPostCellActions.swift */; }; + FABB22C52602FC2C00C8785C /* ActivityStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 402B2A7820ACD7690027C1DC /* ActivityStore.swift */; }; + FABB22C62602FC2C00C8785C /* NSAttributedString+WPRichText.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62AFB651DC8E593007484FC /* NSAttributedString+WPRichText.swift */; }; + FABB22C72602FC2C00C8785C /* StatsTotalRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98812964219CE42A0075FF33 /* StatsTotalRow.swift */; }; + FABB22C82602FC2C00C8785C /* SiteCreationAnalyticsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D6114E2555DAED00B0B7BB /* SiteCreationAnalyticsHelper.swift */; }; + FABB22C92602FC2C00C8785C /* NSAttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54866C91A0D7042004AC79D /* NSAttributedString+Helpers.swift */; }; + FABB22CA2602FC2C00C8785C /* TenorService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD77243BF7A600A83E27 /* TenorService.swift */; }; + FABB22CB2602FC2C00C8785C /* FilterBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B51844425893F140085488D /* FilterBarView.swift */; }; + FABB22CC2602FC2C00C8785C /* BlogToBlogMigration_61_62.swift in Sources */ = {isa = PBXBuildFile; fileRef = E105205A1F2B1CF400A948F6 /* BlogToBlogMigration_61_62.swift */; }; + FABB22CD2602FC2C00C8785C /* WebKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16FB7E21F8B61030004DD9F /* WebKitViewController.swift */; }; + FABB22CE2602FC2C00C8785C /* AnnouncementsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8CBE0A24EEB0EA00F71234 /* AnnouncementsDataSource.swift */; }; + FABB22CF2602FC2C00C8785C /* AssembledSiteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7305138221C031FC006BD0A1 /* AssembledSiteView.swift */; }; + FABB22D02602FC2C00C8785C /* ReaderTopicService+FollowedInterests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 321955C224BF77E400E3F316 /* ReaderTopicService+FollowedInterests.swift */; }; + FABB22D12602FC2C00C8785C /* ReaderRecommendedSiteCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3234BB322530EA980068DA40 /* ReaderRecommendedSiteCardCell.swift */; }; + FABB22D22602FC2C00C8785C /* PostEditorState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93414DE41E2D25AE003143A3 /* PostEditorState.swift */; }; + FABB22D32602FC2C00C8785C /* UICollectionViewCell+Tint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1707CE411F3121750020B7FE /* UICollectionViewCell+Tint.swift */; }; + FABB22D42602FC2C00C8785C /* ManagedPerson+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5176CC21CDCE1C30083CF2D /* ManagedPerson+CoreDataProperties.swift */; }; + FABB22D52602FC2C00C8785C /* TenorStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD71243BF7A500A83E27 /* TenorStrings.swift */; }; + FABB22D62602FC2C00C8785C /* PostEditor+MoreOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91DCE84521A6A7F50062F134 /* PostEditor+MoreOptions.swift */; }; + FABB22D72602FC2C00C8785C /* NotificationSupportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73ACDF982114FE4500233AD4 /* NotificationSupportService.swift */; }; + FABB22D82602FC2C00C8785C /* MenusSelectionView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D3454F1CD7F50900358E8C /* MenusSelectionView.m */; }; + FABB22D92602FC2C00C8785C /* UITextView+RichTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6C4B101B604190005E3C43 /* UITextView+RichTextView.swift */; }; + FABB22DA2602FC2C00C8785C /* RoundedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15644E81CE0E47C00D96E64 /* RoundedButton.swift */; }; + FABB22DC2602FC2C00C8785C /* MenuLocation.m in Sources */ = {isa = PBXBuildFile; fileRef = 08CC677D1C49B65A00153AD7 /* MenuLocation.m */; }; + FABB22DD2602FC2C00C8785C /* RevisionsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4349B0AD218A477F0034118A /* RevisionsTableViewCell.swift */; }; + FABB22DE2602FC2C00C8785C /* KeyboardInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A4C21B85CF20005062B /* KeyboardInfo.swift */; }; + FABB22DF2602FC2C00C8785C /* PlanDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15644F21CE0E5A500D96E64 /* PlanDetailViewModel.swift */; }; + FABB22E02602FC2C00C8785C /* DebugMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E4CD0B238C33F300C56916 /* DebugMenuViewController.swift */; }; + FABB22E12602FC2C00C8785C /* MenuItemCheckButtonView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D978501CD2AF7D0054F19A /* MenuItemCheckButtonView.m */; }; + FABB22E22602FC2C00C8785C /* MenuItemLinkViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FB51CDBF96000304BA7 /* MenuItemLinkViewController.m */; }; + FABB22E32602FC2C00C8785C /* PreviewWebKitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D0A64D23CC159400B20D27 /* PreviewWebKitViewController.swift */; }; + FABB22E42602FC2C00C8785C /* PeriodChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733195822284FE9F0007D904 /* PeriodChart.swift */; }; + FABB22E52602FC2C00C8785C /* MediaHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1450CF42437E1A600A28BFE /* MediaHost.swift */; }; + FABB22E62602FC2C00C8785C /* MenuItemAbstractPostsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FAD1CDBF96000304BA7 /* MenuItemAbstractPostsViewController.m */; }; + FABB22E72602FC2C00C8785C /* AtomicAuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ADCAF6241FEF0C00F150D2 /* AtomicAuthenticationService.swift */; }; + FABB22E82602FC2C00C8785C /* WPStyleGuide+Posts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5703A4C522C003DC0028A343 /* WPStyleGuide+Posts.swift */; }; + FABB22E92602FC2C00C8785C /* GutenbergVideoUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91138454228373EB00FB02B7 /* GutenbergVideoUploadProcessor.swift */; }; + FABB22EA2602FC2C00C8785C /* PostCategory.m in Sources */ = {isa = PBXBuildFile; fileRef = E125445512BF5B3900D87A0A /* PostCategory.m */; }; + FABB22EB2602FC2C00C8785C /* MediaAssetExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C388651ED7705E0057BE49 /* MediaAssetExporter.swift */; }; + FABB22EC2602FC2C00C8785C /* SharingButtonsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C892D51C601D55007AD612 /* SharingButtonsViewController.swift */; }; + FABB22ED2602FC2C00C8785C /* EditorFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F115308021B17E65002F1D65 /* EditorFactory.swift */; }; + FABB22EE2602FC2C00C8785C /* NotificationSiteSubscriptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8E38BD209C6DE200454E3C /* NotificationSiteSubscriptionViewController.swift */; }; + FABB22EF2602FC2C00C8785C /* PostingActivityDay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9826AE8521B5C72300C851FA /* PostingActivityDay.swift */; }; + FABB22F02602FC2C00C8785C /* ReaderTeamTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DB326B1D9ACD4A00C8FEBC /* ReaderTeamTopic.swift */; }; + FABB22F12602FC2C00C8785C /* WPAccount+AccountSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12DB07A1C48D1C200A6C1D4 /* WPAccount+AccountSettings.swift */; }; + FABB22F22602FC2C00C8785C /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B7C89F20EC1D6A0042E260 /* Route.swift */; }; + FABB22F32602FC2C00C8785C /* RegisterDomainDetailsViewController+Cells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D56152117312700CEAA33 /* RegisterDomainDetailsViewController+Cells.swift */; }; + FABB22F42602FC2C00C8785C /* ReaderStreamViewController+Sharing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7371E6D121FA730700596C0A /* ReaderStreamViewController+Sharing.swift */; }; + FABB22F52602FC2C00C8785C /* FilterTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A28DC2205001A900EA6D9E /* FilterTabBar.swift */; }; + FABB22F62602FC2C00C8785C /* ClicksStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C822217A985000A8A59 /* ClicksStatsRecordValue+CoreDataProperties.swift */; }; + FABB22F72602FC2C00C8785C /* EditComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D858F30220E201F4007E8A1C /* EditComment.swift */; }; + FABB22F82602FC2C00C8785C /* UnknownEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B538F3881EF46EC8001003D5 /* UnknownEditorViewController.swift */; }; + FABB22F92602FC2C00C8785C /* AccountToAccount22to23.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937D9A1019F838C2007B9D5F /* AccountToAccount22to23.swift */; }; + FABB22FA2602FC2C00C8785C /* VisitsSummaryStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C752217A8A0000A8A59 /* VisitsSummaryStatsRecordValue+CoreDataClass.swift */; }; + FABB22FB2602FC2C00C8785C /* ReaderFollowAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CB220AA6861008E8AE8 /* ReaderFollowAction.swift */; }; + FABB22FC2602FC2C00C8785C /* SheetActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D3992F2541F25B0058D0AB /* SheetActions.swift */; }; + FABB22FD2602FC2C00C8785C /* ReaderTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B7F51C824EED804008CF5B5 /* ReaderTracker.swift */; }; + FABB22FE2602FC2C00C8785C /* PlanFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F2787E21BC1A49008B4DB5 /* PlanFeature.swift */; }; + FABB22FF2602FC2C00C8785C /* Spotlightable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F421DF424A3EC2B00CA9B9E /* Spotlightable.swift */; }; + FABB23002602FC2C00C8785C /* StockPhotosDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A3A5AE206A442800992576 /* StockPhotosDataSource.swift */; }; + FABB23022602FC2C00C8785C /* OfflineReaderWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B24C4E2249A4C3E0005E8A5 /* OfflineReaderWebView.swift */; }; + FABB23032602FC2C00C8785C /* CountriesMapCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A5C854622B3E42800BEE7A3 /* CountriesMapCell.swift */; }; + FABB23042602FC2C00C8785C /* NavigationTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B410B51B1772B000CFCF8D /* NavigationTitleView.swift */; }; + FABB23052602FC2C00C8785C /* WPAnalyticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BAD53D5241922B900230F4B /* WPAnalyticsEvent.swift */; }; + FABB23062602FC2C00C8785C /* Coordinate.m in Sources */ = {isa = PBXBuildFile; fileRef = E14932B5130427B300154804 /* Coordinate.m */; }; + FABB23072602FC2C00C8785C /* SiteCreationRotatingMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3221278523A0BD27002CA183 /* SiteCreationRotatingMessageView.swift */; }; + FABB23082602FC2C00C8785C /* SignupUsernameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43DC0EF02040B23200896C9C /* SignupUsernameViewController.swift */; }; + FABB23092602FC2C00C8785C /* MediaItemViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1782BE831E70063100A91E7D /* MediaItemViewController.swift */; }; + FABB230A2602FC2C00C8785C /* PostAnnotation.m in Sources */ = {isa = PBXBuildFile; fileRef = 833AF25A114575A50016DE8F /* PostAnnotation.m */; }; + FABB230B2602FC2C00C8785C /* GutenbergViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3E9B6F2177C9DC00FD5797 /* GutenbergViewController.swift */; }; + FABB230C2602FC2C00C8785C /* EditPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D21280C251CF0850086DD2C /* EditPageViewController.swift */; }; + FABB230D2602FC2C00C8785C /* SiteCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A4121B85CF20005062B /* SiteCreator.swift */; }; + FABB230E2602FC2C00C8785C /* CalendarMonthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5660D08235D1CDD00020B1E /* CalendarMonthView.swift */; }; + FABB230F2602FC2C00C8785C /* RestoreStatusFailedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1CEAC125CA9C2A005E7038 /* RestoreStatusFailedView.swift */; }; + FABB23112602FC2C00C8785C /* PostingActivityLegend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9848DF8021B8BB5600B99DA4 /* PostingActivityLegend.swift */; }; + FABB23122602FC2C00C8785C /* WPImmuTableRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DB8AF31C949DC20059196A /* WPImmuTableRows.swift */; }; + FABB23132602FC2C00C8785C /* WordPress-87-88.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = E192E78B22EF453C008D725D /* WordPress-87-88.xcmappingmodel */; }; + FABB23142602FC2C00C8785C /* UsersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6311C401EC9FF4A00122529 /* UsersService.swift */; }; + FABB23152602FC2C00C8785C /* SharePost+UIActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E125F1E61E8E59C800320B67 /* SharePost+UIActivityItemSource.swift */; }; + FABB23162602FC2C00C8785C /* WPWebViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = E1B62A7A13AA61A100A6FCA4 /* WPWebViewController.m */; }; + FABB23172602FC2C00C8785C /* ShadowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D813D67E21AA8BBF0055CCA1 /* ShadowView.swift */; }; + FABB23182602FC2C00C8785C /* Wizard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D865721121869C590023A99C /* Wizard.swift */; }; + FABB23192602FC2C00C8785C /* Media+WPMediaAsset.m in Sources */ = {isa = PBXBuildFile; fileRef = 08C388691ED78EE70057BE49 /* Media+WPMediaAsset.m */; }; + FABB231A2602FC2C00C8785C /* GutenbergImgUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EC3BF2209A144006176E1 /* GutenbergImgUploadProcessor.swift */; }; + FABB231B2602FC2C00C8785C /* PluginListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E151C0C71F388A2000710A83 /* PluginListViewModel.swift */; }; + FABB231C2602FC2C00C8785C /* WPStyleGuide+People.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B9128A1BB0129C003C25B9 /* WPStyleGuide+People.swift */; }; + FABB231D2602FC2C00C8785C /* PlanGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6F2787B21BC1A48008B4DB5 /* PlanGroup.swift */; }; + FABB231E2602FC2C00C8785C /* MenuItemPostsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FB91CDBF96000304BA7 /* MenuItemPostsViewController.m */; }; + FABB231F2602FC2C00C8785C /* SignupUsernameTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4322A20C203E1885004EA740 /* SignupUsernameTableViewController.swift */; }; + FABB23202602FC2C00C8785C /* Blog+Editor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7BEF6F22E1AED8009A880D /* Blog+Editor.swift */; }; + FABB23212602FC2C00C8785C /* ReaderCardService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185C524B5FB8500A4CCE8 /* ReaderCardService.swift */; }; + FABB23222602FC2C00C8785C /* UITableViewCell+Stats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98487E3921EE8FB500352B4E /* UITableViewCell+Stats.swift */; }; + FABB23232602FC2C00C8785C /* Delay.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14BCABA1E0BC817002E0603 /* Delay.swift */; }; + FABB23242602FC2C00C8785C /* Pageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83CA3A420842CAF0060E310 /* Pageable.swift */; }; + FABB23252602FC2C00C8785C /* AppIconViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17E362EB22C40BE8000E0C79 /* AppIconViewController.swift */; }; + FABB23262602FC2C00C8785C /* ThemeBrowserCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 598DD1701B97985700146967 /* ThemeBrowserCell.swift */; }; + FABB23272602FC2C00C8785C /* ImageCropViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A05AD81CA48601002EC787 /* ImageCropViewController.swift */; }; + FABB23282602FC2C00C8785C /* LoggingURLRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F93735F022D534FE00A3C312 /* LoggingURLRedactor.swift */; }; + FABB23292602FC2C00C8785C /* SiteStatsTableViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9874766E219630240080967F /* SiteStatsTableViewCells.swift */; }; + FABB232A2602FC2C00C8785C /* SharingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E27D611C6144DB0063F821 /* SharingButton.swift */; }; + FABB232B2602FC2C00C8785C /* PostActionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 570BFD8A22823D7B007859A8 /* PostActionSheet.swift */; }; + FABB232C2602FC2C00C8785C /* PublicizeConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6374DBD1C444D8B00F24720 /* PublicizeConnection.swift */; }; + FABB232D2602FC2C00C8785C /* TenorPageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD72243BF7A500A83E27 /* TenorPageable.swift */; }; + FABB232E2602FC2C00C8785C /* AccountService+MergeDuplicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6C0ED3A231DA23400A08B57 /* AccountService+MergeDuplicates.swift */; }; + FABB232F2602FC2C00C8785C /* URL+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0845B8C51E833C56001BA771 /* URL+Helpers.swift */; }; + FABB23302602FC2C00C8785C /* WPStyleGuide+Aztec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D975AE1EF7F6F100303D63 /* WPStyleGuide+Aztec.swift */; }; + FABB23312602FC2C00C8785C /* SettingsCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14B40FE1C58B93F005046F6 /* SettingsCommon.swift */; }; + FABB23322602FC2C00C8785C /* FormatBarItemProviders.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C651EF42B6400372C65 /* FormatBarItemProviders.swift */; }; + FABB23332602FC2C00C8785C /* RevisionDiffViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439F4F332196537500F8D0C7 /* RevisionDiffViewController.swift */; }; + FABB23342602FC2C00C8785C /* BlogDetailsSectionFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D75D9822793EA2003FF09A /* BlogDetailsSectionFooterView.swift */; }; + FABB23362602FC2C00C8785C /* TenorGIFCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD62243AECA100A83E27 /* TenorGIFCollection.swift */; }; + FABB23372602FC2C00C8785C /* WebNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E137B1651F8B77D4006AC7FC /* WebNavigationDelegate.swift */; }; + FABB23382602FC2C00C8785C /* WordPress-22-23.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 937D9A0E19F83812007B9D5F /* WordPress-22-23.xcmappingmodel */; }; + FABB23392602FC2C00C8785C /* CountriesMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1A67A522B2AD4E00FF8422 /* CountriesMap.swift */; }; + FABB233A2602FC2C00C8785C /* RevisionsNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439F4F39219B715300F8D0C7 /* RevisionsNavigationController.swift */; }; + FABB233B2602FC2C00C8785C /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40F46B6822121BA800A2143B /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataClass.swift */; }; + FABB233C2602FC2C00C8785C /* MediaProgressCoordinatorNoticeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D5C3F61FFCF2D800EB70FF /* MediaProgressCoordinatorNoticeViewModel.swift */; }; + FABB233D2602FC2C00C8785C /* SiteStatsPeriodViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984B139121F66AC50004B6A2 /* SiteStatsPeriodViewModel.swift */; }; + FABB233F2602FC2C00C8785C /* AnnouncementCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3087C324EDB7040087B548 /* AnnouncementCell.swift */; }; + FABB23402602FC2C00C8785C /* ReaderStreamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1D04741B7A50B100CDE646 /* ReaderStreamViewController.swift */; }; + FABB23412602FC2C00C8785C /* SeparatorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57B92BC1B73B08100DFF00B /* SeparatorsView.swift */; }; + FABB23422602FC2C00C8785C /* JetpackScanCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C789952425816F96001B7B43 /* JetpackScanCoordinator.swift */; }; + FABB23432602FC2C00C8785C /* WPStyleGuide+ReadableMargins.m in Sources */ = {isa = PBXBuildFile; fileRef = E69BA1971BB5D7D300078740 /* WPStyleGuide+ReadableMargins.m */; }; + FABB23442602FC2C00C8785C /* FormattableContentStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B620F4097B00DF8486 /* FormattableContentStyles.swift */; }; + FABB23452602FC2C00C8785C /* StatsBarChartConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 735A9680228E421F00461135 /* StatsBarChartConfiguration.swift */; }; + FABB23462602FC2C00C8785C /* LoadingStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F161B0522CC2DC70066A5C5 /* LoadingStatusView.swift */; }; + FABB23472602FC2C00C8785C /* MenuDetailsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 080C449E1CE14A9F00B3A02F /* MenuDetailsViewController.m */; }; + FABB23482602FC2C00C8785C /* InlineErrorRetryTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA6511921F26A57009AA935 /* InlineErrorRetryTableViewCell.swift */; }; + FABB23492602FC2C00C8785C /* MenuItemSourceCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FBB1CDBF96000304BA7 /* MenuItemSourceCell.m */; }; + FABB234A2602FC2C00C8785C /* BlogDetailsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 462F4E0718369F0B0028D2F8 /* BlogDetailsViewController.m */; }; + FABB234B2602FC2C00C8785C /* UIBarButtonItem+MeBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FCCAA1423F4A1A3004064C0 /* UIBarButtonItem+MeBarButton.swift */; }; + FABB234C2602FC2C00C8785C /* PostSettingsViewController+FeaturedImageUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFEECFFB2084DE2B009B8CDB /* PostSettingsViewController+FeaturedImageUpload.swift */; }; + FABB234D2602FC2C00C8785C /* ReaderLikeAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CB020AA64E1008E8AE8 /* ReaderLikeAction.swift */; }; + FABB234E2602FC2C00C8785C /* ReaderMenuAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CC820AA87E5008E8AE8 /* ReaderMenuAction.swift */; }; + FABB234F2602FC2C00C8785C /* PostSharingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 593F26601CAB00CA00F14073 /* PostSharingController.swift */; }; + FABB23502602FC2C00C8785C /* LongPressGestureLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA37B19215724E900C80377 /* LongPressGestureLabel.swift */; }; + FABB23512602FC2C00C8785C /* NoResultsStockPhotosConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F1B1292111017900139493 /* NoResultsStockPhotosConfiguration.swift */; }; + FABB23522602FC2C00C8785C /* String+Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7360018E20A265C7001E5E31 /* String+Files.swift */; }; + FABB23532602FC2C00C8785C /* UIImage+Export.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF70A3211FD5840500BC270D /* UIImage+Export.swift */; }; + FABB23542602FC2C00C8785C /* NotificationName+Names.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81322B22050F9110067714D /* NotificationName+Names.swift */; }; + FABB23552602FC2C00C8785C /* NSFetchedResultsController+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DD04731CD3DAB00003DF89 /* NSFetchedResultsController+Helpers.swift */; }; + FABB23562602FC2C00C8785C /* BlogSettings+Discussion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55F1AA71C10936600FD04D4 /* BlogSettings+Discussion.swift */; }; + FABB23572602FC2C00C8785C /* PostTagPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E155EC711E9B7DCE009D7F63 /* PostTagPickerViewController.swift */; }; + FABB23582602FC2C00C8785C /* SignupEpilogueCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98A25BD0203CB25F006A5807 /* SignupEpilogueCell.swift */; }; + FABB23592602FC2C00C8785C /* NoteBlockActionsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57AF5F91ACDC73D0075A7D2 /* NoteBlockActionsTableViewCell.swift */; }; + FABB235A2602FC2C00C8785C /* ReaderCardsStreamViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D024B63D1600A4CCE8 /* ReaderCardsStreamViewController.swift */; }; + FABB235B2602FC2C00C8785C /* DeleteSiteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742C79971E5F511C00DB1608 /* DeleteSiteViewController.swift */; }; + FABB235C2602FC2C00C8785C /* WPAnalyticsTrackerWPCom.m in Sources */ = {isa = PBXBuildFile; fileRef = 85DA8C4318F3F29A0074C8A4 /* WPAnalyticsTrackerWPCom.m */; }; + FABB235D2602FC2C00C8785C /* AztecAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C5C1EF42A4A00372C65 /* AztecAttachmentViewController.swift */; }; + FABB235F2602FC2C00C8785C /* LoginEpilogueBlogCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43FB3F401EBD215C00FC8A62 /* LoginEpilogueBlogCell.swift */; }; + FABB23602602FC2C00C8785C /* PostServiceRemoteFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57D66B99234BB206005A2D74 /* PostServiceRemoteFactory.swift */; }; + FABB23612602FC2C00C8785C /* ReaderTabItemsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FC8D19A244F43B500495820 /* ReaderTabItemsStore.swift */; }; + FABB23622602FC2C00C8785C /* NoResultsViewController+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98EB126920D2DC2500D2D5B5 /* NoResultsViewController+Model.swift */; }; + FABB23632602FC2C00C8785C /* PostListTableViewHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 570265142298921800F2214C /* PostListTableViewHandler.swift */; }; + FABB23642602FC2C00C8785C /* UIAlertController+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5969E2120A49E86005E9DF1 /* UIAlertController+Helpers.swift */; }; + FABB23652602FC2C00C8785C /* RevisionDiff+CoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4E61F721A2C3BC0017A925 /* RevisionDiff+CoreData.swift */; }; + FABB23662602FC2C00C8785C /* NotificationCommentRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7947AA210BAC5E005BB851 /* NotificationCommentRange.swift */; }; + FABB23672602FC2C00C8785C /* NotificationAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816C1E820E0880400C4D82F /* NotificationAction.swift */; }; + FABB23682602FC2C00C8785C /* NSManagedObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19B17AF1E5C69A5007517C6 /* NSManagedObject.swift */; }; + FABB23692602FC2C00C8785C /* ExportableAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF355D971FB492DD00244E6D /* ExportableAsset.swift */; }; + FABB236A2602FC2C00C8785C /* PlanListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15644EE1CE0E53B00D96E64 /* PlanListViewModel.swift */; }; + FABB236B2602FC2C00C8785C /* InsightsManagementViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983002A722FA05D600F03DBB /* InsightsManagementViewController.swift */; }; + FABB236C2602FC2C00C8785C /* StatSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98CAD295221B4ED1003E8F45 /* StatSection.swift */; }; + FABB236D2602FC2C00C8785C /* ReaderDetailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDA5A71247C5E5800AB124C /* ReaderDetailCoordinator.swift */; }; + FABB236E2602FC2C00C8785C /* AccountService.m in Sources */ = {isa = PBXBuildFile; fileRef = 93C1147E18EC5DD500DAC95C /* AccountService.m */; }; + FABB236F2602FC2C00C8785C /* MediaLibraryPickerDataSource.m in Sources */ = {isa = PBXBuildFile; fileRef = FF945F6F1B28242300FB8AC4 /* MediaLibraryPickerDataSource.m */; }; + FABB23702602FC2C00C8785C /* PostStatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 986C90872231AD6200FC31E1 /* PostStatsViewModel.swift */; }; + FABB23712602FC2C00C8785C /* StatsStore+Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4A8F4A235758EF00088CE4 /* StatsStore+Cache.swift */; }; + FABB23722602FC2C00C8785C /* WP3DTouchShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE87E1A11BD405790075D45B /* WP3DTouchShortcutHandler.swift */; }; + FABB23732602FC2C00C8785C /* BadgeContentStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3E7A5C20E44DB00075D159 /* BadgeContentStyles.swift */; }; + FABB23742602FC2C00C8785C /* WPStyleGuide+Pages.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D4E30D01AA4B41A000D9904 /* WPStyleGuide+Pages.m */; }; + FABB23752602FC2C00C8785C /* ReaderInterestsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3236F77124ABB6C90088E8F3 /* ReaderInterestsDataSource.swift */; }; + FABB23762602FC2C00C8785C /* ActivityContentGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123C920F4184200DF8486 /* ActivityContentGroup.swift */; }; + FABB23772602FC2C00C8785C /* MediaImageExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F8CD2E1EBD29440049D0C0 /* MediaImageExporter.swift */; }; + FABB23782602FC2C00C8785C /* GutenbergViewController+InformativeDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912347182213484300BD9F97 /* GutenbergViewController+InformativeDialog.swift */; }; + FABB23792602FC2C00C8785C /* ChangePasswordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA6511621F26A24009AA935 /* ChangePasswordViewController.swift */; }; + FABB237A2602FC2C00C8785C /* LikeComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816C1EF20E0893A00C4D82F /* LikeComment.swift */; }; + FABB237B2602FC2C00C8785C /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E1D46C1CEF77B500126697 /* Page.swift */; }; + FABB237C2602FC2C00C8785C /* ReaderInterestsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 321955BE24BE234C00E3F316 /* ReaderInterestsCoordinator.swift */; }; + FABB237D2602FC2C00C8785C /* ReaderSavedPostCellActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE4327325874D140039EB8C /* ReaderSavedPostCellActions.swift */; }; + FABB237E2602FC2C00C8785C /* FormattableContentFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B520F4097B00DF8486 /* FormattableContentFormatter.swift */; }; + FABB237F2602FC2C00C8785C /* SearchAdsAttribution.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E21C764202BBF4400837CF5 /* SearchAdsAttribution.swift */; }; + FABB23802602FC2C00C8785C /* ReaderCommentsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DF8D26019E82B1000A2CD95 /* ReaderCommentsViewController.m */; }; + FABB23812602FC2C00C8785C /* MySiteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1716AEFB25F2927600CF49EC /* MySiteViewController.swift */; }; + FABB23822602FC2C00C8785C /* FooterContentGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3E7A5F20E44E490075D159 /* FooterContentGroup.swift */; }; + FABB23832602FC2C00C8785C /* BasePageListCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 59A3CADC1CD2FF0C009BFA1B /* BasePageListCell.m */; }; + FABB23852602FC2C00C8785C /* WPError.m in Sources */ = {isa = PBXBuildFile; fileRef = E114D799153D85A800984182 /* WPError.m */; }; + FABB23862602FC2C00C8785C /* ContentRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E53AB0120FE5EAE005796FE /* ContentRouter.swift */; }; + FABB23872602FC2C00C8785C /* BlogToBlog32to33.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16273E01B2ACEB600088AF7 /* BlogToBlog32to33.swift */; }; + FABB23882602FC2C00C8785C /* WPStyleGuide+ApplicationStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = E678FC141C76241000F55F55 /* WPStyleGuide+ApplicationStyles.swift */; }; + FABB23892602FC2C00C8785C /* FormattableUserContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFF208520EAD918009C4699 /* FormattableUserContent.swift */; }; + FABB238A2602FC2C00C8785C /* RegisterDomainDetailsViewController+HeaderFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D56162117312700CEAA33 /* RegisterDomainDetailsViewController+HeaderFooter.swift */; }; + FABB238B2602FC2C00C8785C /* ReaderSpacerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66EB6F81C1B7A76003DABC5 /* ReaderSpacerView.swift */; }; + FABB238D2602FC2C00C8785C /* WPStyleGuide+SiteCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B560914B208A671E00399AE4 /* WPStyleGuide+SiteCreation.swift */; }; + FABB238E2602FC2C00C8785C /* NotificationSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EFB1C11B31B98E007608A3 /* NotificationSettingsService.swift */; }; + FABB238F2602FC2C00C8785C /* WPButtonForNavigationBar.m in Sources */ = {isa = PBXBuildFile; fileRef = 5903AE1A19B60A98009D5354 /* WPButtonForNavigationBar.m */; }; + FABB23922602FC2C00C8785C /* UIImage+Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58C4EC9207C5E1900E32E4D /* UIImage+Assets.swift */; }; + FABB23932602FC2C00C8785C /* LoadMoreCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E684383D221F535900752258 /* LoadMoreCounter.swift */; }; + FABB23942602FC2C00C8785C /* LoginEpilogueUserInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6158AC91ECDF518005FA441 /* LoginEpilogueUserInfo.swift */; }; + FABB23952602FC2C00C8785C /* WPAvatarSource.m in Sources */ = {isa = PBXBuildFile; fileRef = E1E4CE0A1773C59B00430844 /* WPAvatarSource.m */; }; + FABB23962602FC2C00C8785C /* StatsTableFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983DBBA922125DD300753988 /* StatsTableFooter.swift */; }; + FABB23972602FC2C00C8785C /* BlogSyncFacade.m in Sources */ = {isa = PBXBuildFile; fileRef = 85D239A21AE5A5FC0074768D /* BlogSyncFacade.m */; }; + FABB23982602FC2C00C8785C /* PrepublishingNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B0732F1242BF97B00E7FBD3 /* PrepublishingNavigationController.swift */; }; + FABB23992602FC2C00C8785C /* UINavigationController+KeyboardFix.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D97C2F215CAF8D8009B44DD /* UINavigationController+KeyboardFix.m */; }; + FABB239B2602FC2C00C8785C /* ExpandableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4034FDE92007C42400153B87 /* ExpandableCell.swift */; }; + FABB239C2602FC2C00C8785C /* PreviewNonceHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D0A64F23CC15A800B20D27 /* PreviewNonceHandler.swift */; }; + FABB239D2602FC2C00C8785C /* JetpackSpeedUpSiteSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F668B5E255DD11400D0038A /* JetpackSpeedUpSiteSettingsViewController.swift */; }; + FABB239E2602FC2C00C8785C /* TopCommentedPostStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054F43A221354E000D261AB /* TopCommentedPostStatsRecordValue+CoreDataProperties.swift */; }; + FABB239F2602FC2C00C8785C /* FormattableContentFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123AC20F4097900DF8486 /* FormattableContentFactory.swift */; }; + FABB23A02602FC2C00C8785C /* PluginDirectoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E469922017F3D20030DB5F /* PluginDirectoryViewController.swift */; }; + FABB23A12602FC2C00C8785C /* RoleService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D28E921F2F6EB500A5DAFD /* RoleService.swift */; }; + FABB23A22602FC2C00C8785C /* AccountHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C940191DB900DC0079D4FF /* AccountHelper.swift */; }; + FABB23A32602FC2C00C8785C /* Sites.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 3F46AB0225BF5D6300CE2E98 /* Sites.intentdefinition */; settings = {ATTRIBUTES = (codegen, ); }; }; + FABB23A42602FC2C00C8785C /* MenuItemSourceTextBar.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D978541CD2AF7D0054F19A /* MenuItemSourceTextBar.m */; }; + FABB23A52602FC2C00C8785C /* PluginDirectoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E469942017FB1F0030DB5F /* PluginDirectoryViewModel.swift */; }; + FABB23A62602FC2C00C8785C /* AbstractPost+Autosave.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1527300243B290D00C8DC7A /* AbstractPost+Autosave.swift */; }; + FABB23A72602FC2C00C8785C /* ReaderPost+Searchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 745A41AF2065405600299D75 /* ReaderPost+Searchable.swift */; }; + FABB23A82602FC2C00C8785C /* MeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A8CACA1C22FF7C0038689E /* MeViewController.swift */; }; + FABB23A92602FC2C00C8785C /* BlogService+BlogAuthors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A341E5221997A1E0036662E /* BlogService+BlogAuthors.swift */; }; + FABB23AA2602FC2C00C8785C /* AuthorFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A28DC42050404C00EA6D9E /* AuthorFilterButton.swift */; }; + FABB23AB2602FC2C00C8785C /* MenuItemTagsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FC31CDBF96000304BA7 /* MenuItemTagsViewController.m */; }; + FABB23AC2602FC2C00C8785C /* ReachabilityUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D3E334D15EEBB6B005FC6F2 /* ReachabilityUtils.m */; }; + FABB23AD2602FC2C00C8785C /* TenorDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD73243BF7A500A83E27 /* TenorDataSource.swift */; }; + FABB23AE2602FC2C00C8785C /* ReaderTopicService+Subscriptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F3EFCA0208E305D00268758 /* ReaderTopicService+Subscriptions.swift */; }; + FABB23AF2602FC2C00C8785C /* TopCommentedPostStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054F439221354E000D261AB /* TopCommentedPostStatsRecordValue+CoreDataClass.swift */; }; + FABB23B02602FC2C00C8785C /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14DFAFA1E07E7C400494688 /* Data.swift */; }; + FABB23B12602FC2C00C8785C /* AccountSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14B40FC1C58B806005046F6 /* AccountSettingsViewController.swift */; }; + FABB23B22602FC2C00C8785C /* StatsChildRowsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B11B882216535100B7F2D7 /* StatsChildRowsView.swift */; }; + FABB23B32602FC2C00C8785C /* Menu.m in Sources */ = {isa = PBXBuildFile; fileRef = 08CC677A1C49B65A00153AD7 /* Menu.m */; }; + FABB23B42602FC2C00C8785C /* BlogListViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = BE13B3E51B2B58D800A4211D /* BlogListViewController.m */; }; + FABB23B52602FC2C00C8785C /* JetpackScanStatusCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C768B5B225828A5D00556E75 /* JetpackScanStatusCell.swift */; }; + FABB23B62602FC2C00C8785C /* NoteBlockCommentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B532D4E5199D4357006E4DF6 /* NoteBlockCommentTableViewCell.swift */; }; + FABB23B72602FC2C00C8785C /* Notifiable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73BFDA89211D054800907245 /* Notifiable.swift */; }; + FABB23B82602FC2C00C8785C /* RegisterDomainDetailsViewModel+CountryDialCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 404B35D222E9BA0800AD0B37 /* RegisterDomainDetailsViewModel+CountryDialCodes.swift */; }; + FABB23B92602FC2C00C8785C /* BlogListViewController+SiteCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8EB1FD021900810002AE1C4 /* BlogListViewController+SiteCreation.swift */; }; + FABB23BA2602FC2C00C8785C /* CommentAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64595EF256B5D7800F7F90C /* CommentAnalytics.swift */; }; + FABB23BB2602FC2C00C8785C /* TableViewHeaderDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5C740E1C599BA7000B528C /* TableViewHeaderDetailView.swift */; }; + FABB23BC2602FC2C00C8785C /* MenuItemAbstractView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0857C2701CE5375F0014AE99 /* MenuItemAbstractView.m */; }; + FABB23BD2602FC2C00C8785C /* LocationService.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DB4683A18A2E718004A89A9 /* LocationService.m */; }; + FABB23BE2602FC2C00C8785C /* Domain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173BCE781CEB780800AE8817 /* Domain.swift */; }; + FABB23BF2602FC2C00C8785C /* PostListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 590E873A1CB8205700D1B734 /* PostListViewController.swift */; }; + FABB23C02602FC2C00C8785C /* FeatureItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15644EA1CE0E4C500D96E64 /* FeatureItemRow.swift */; }; + FABB23C12602FC2C00C8785C /* ReaderTagsTableViewController+Cells.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A738BE244DF7E400EDE065 /* ReaderTagsTableViewController+Cells.swift */; }; + FABB23C22602FC2C00C8785C /* PrepublishingHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B0732E8242BA1F000E7FBD3 /* PrepublishingHeaderView.swift */; }; + FABB23C32602FC2C00C8785C /* StatsWidgetsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3CA64F25D3003C00642A89 /* StatsWidgetsStore.swift */; }; + FABB23C42602FC2C00C8785C /* MenuItemsVisualOrderingView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0857C2741CE5375F0014AE99 /* MenuItemsVisualOrderingView.m */; }; + FABB23C52602FC2C00C8785C /* JetpackScanViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C700F9ED257FD64E0090938E /* JetpackScanViewController.swift */; }; + FABB23C62602FC2C00C8785C /* ReaderSubscribingNotificationAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CB420AA68D5008E8AE8 /* ReaderSubscribingNotificationAction.swift */; }; + FABB23C72602FC2C00C8785C /* GutenbergMediaInserterHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8C54AC21F677260003ABCF /* GutenbergMediaInserterHelper.swift */; }; + FABB23C82602FC2C00C8785C /* TopViewedAuthorStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C403EF2215D66A00E8C894 /* TopViewedAuthorStatsRecordValue+CoreDataClass.swift */; }; + FABB23C92602FC2C00C8785C /* JetpackState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11C4B71201096EF00A6619C /* JetpackState.swift */; }; + FABB23CA2602FC2C00C8785C /* EditPostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437542E21DD4E19E00D6B727 /* EditPostViewController.swift */; }; + FABB23CB2602FC2C00C8785C /* PostListFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 595CB3751D2317D50082C7E9 /* PostListFilter.swift */; }; + FABB23CC2602FC2C00C8785C /* MediaThumbnailExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08E77F441EE87FCF006F9515 /* MediaThumbnailExporter.swift */; }; + FABB23CD2602FC2C00C8785C /* ReferrerStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C7C2217A985000A8A59 /* ReferrerStatsRecordValue+CoreDataClass.swift */; }; + FABB23CE2602FC2C00C8785C /* BlogDetailHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1112AB1255C2D4600F1F746 /* BlogDetailHeaderView.swift */; }; + FABB23CF2602FC2C00C8785C /* ImageDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EB19EB20C6DACC008372B9 /* ImageDownloader.swift */; }; + FABB23D02602FC2C00C8785C /* ReaderDetailHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B0CE7D02481CFE8004C4799 /* ReaderDetailHeaderView.swift */; }; + FABB23D12602FC2C00C8785C /* WPAnimatedBox.m in Sources */ = {isa = PBXBuildFile; fileRef = E240859B183D82AE002EB0EF /* WPAnimatedBox.m */; }; + FABB23D22602FC2C00C8785C /* PostServiceOptions.m in Sources */ = {isa = PBXBuildFile; fileRef = 08472A1F1C727E020040769D /* PostServiceOptions.m */; }; + FABB23D32602FC2C00C8785C /* DiffTitleValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A38DC65218899FA006A409B /* DiffTitleValue.swift */; }; + FABB23D42602FC2C00C8785C /* ReaderCommentsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0AC50DC251E96270039E022 /* ReaderCommentsViewController.swift */; }; + FABB23D52602FC2C00C8785C /* SiteCreationRequest+Validation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73856E5A21E1602400773CD9 /* SiteCreationRequest+Validation.swift */; }; + FABB23D62602FC2C00C8785C /* FormattableContentGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123AD20F4097900DF8486 /* FormattableContentGroup.swift */; }; + FABB23D72602FC2C00C8785C /* MenuItemSourceViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FC11CDBF96000304BA7 /* MenuItemSourceViewController.m */; }; + FABB23D82602FC2C00C8785C /* WPReusableTableViewCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74558368201A1FD3007809BB /* WPReusableTableViewCells.swift */; }; + FABB23D92602FC2C00C8785C /* HomepageSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D9362624769579008B2205 /* HomepageSettingsService.swift */; }; + FABB23DA2602FC2C00C8785C /* ReaderSearchSuggestionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DAABDC1CF632EC0069D933 /* ReaderSearchSuggestionsViewController.swift */; }; + FABB23DB2602FC2C00C8785C /* SentryStartupEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3DECAA2388506400A459C2 /* SentryStartupEvent.swift */; }; + FABB23DC2602FC2C00C8785C /* JetpackScanThreatCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C700FAB0258020DB0090938E /* JetpackScanThreatCell.swift */; }; + FABB23DD2602FC2C00C8785C /* SiteSegmentsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C31CC42188490000A33B35 /* SiteSegmentsCell.swift */; }; + FABB23DE2602FC2C00C8785C /* SharingConnectionsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = E6431DE01C4E892900FD8D90 /* SharingConnectionsViewController.m */; }; + FABB23DF2602FC2C00C8785C /* BlogSelectorViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D8D53EE19250412003C8859 /* BlogSelectorViewController.m */; }; + FABB23E02602FC2C00C8785C /* HomepageSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17523380246C4F9200870B4A /* HomepageSettingsViewController.swift */; }; + FABB23E12602FC2C00C8785C /* TenorMediaGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD75243BF7A500A83E27 /* TenorMediaGroup.swift */; }; + FABB23E22602FC2C00C8785C /* SiteSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0F2EFBE259378E600C7EB6D /* SiteSuggestionService.swift */; }; + FABB23E32602FC2C00C8785C /* NotificationsViewController+PushPrimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4388FEFF20A4E19C00783948 /* NotificationsViewController+PushPrimer.swift */; }; + FABB23E42602FC2C00C8785C /* ReaderPostService.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D3D559618F88C3500782892 /* ReaderPostService.m */; }; + FABB23E52602FC2C00C8785C /* EditorMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA30DB321ADA20F0092F894 /* EditorMediaUtility.swift */; }; + FABB23E62602FC2C00C8785C /* ShareMediaFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7430C4481F97F23600E2673E /* ShareMediaFileManager.swift */; }; + FABB23E82602FC2C00C8785C /* ReaderCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A4A36B20EE55320071C2CA /* ReaderCoordinator.swift */; }; + FABB23E92602FC2C00C8785C /* RestoreCompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB800B125AEE3C600D5D54A /* RestoreCompleteView.swift */; }; + FABB23EA2602FC2C00C8785C /* NoteBlockImageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B532D4ED199D4418006E4DF6 /* NoteBlockImageTableViewCell.swift */; }; + FABB23EB2602FC2C00C8785C /* PostCategoryService.m in Sources */ = {isa = PBXBuildFile; fileRef = 93FA59DC18D88C1C001446BC /* PostCategoryService.m */; }; + FABB23EC2602FC2C00C8785C /* RegisterDomainDetailsServiceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D564E211E122D00CEAA33 /* RegisterDomainDetailsServiceProxy.swift */; }; + FABB23ED2602FC2C00C8785C /* PreviewDeviceSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5B8A60E23CE56A1001B7359 /* PreviewDeviceSelectionViewController.swift */; }; + FABB23EE2602FC2C00C8785C /* MenusSelectionItem.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D3454B1CD7F50900358E8C /* MenusSelectionItem.m */; }; + FABB23EF2602FC2C00C8785C /* WPTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62AFB681DC8E593007484FC /* WPTextAttachment.swift */; }; + FABB23F02602FC2C00C8785C /* TitleSubtitleTextfieldHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A4821B85CF20005062B /* TitleSubtitleTextfieldHeader.swift */; }; + FABB23F12602FC2C00C8785C /* DefaultStockPhotosService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88A6493208D7AD0008AE9BC /* DefaultStockPhotosService.swift */; }; + FABB23F22602FC2C00C8785C /* Animator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BEEC621C4E35A8000B4FA0 /* Animator.swift */; }; + FABB23F32602FC2C00C8785C /* SiteStatsDashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981C3493218388CA00FC2683 /* SiteStatsDashboardViewController.swift */; }; + FABB23F42602FC2C00C8785C /* MediaSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C5457D1C6B962D001CEB0E /* MediaSettings.swift */; }; + FABB23F52602FC2C00C8785C /* DomainCreditRedemptionSuccessViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BF30522271D7F000616558 /* DomainCreditRedemptionSuccessViewController.swift */; }; + FABB23F62602FC2C00C8785C /* ActivityLogDetailViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 93069F581762410B000C966D /* ActivityLogDetailViewController.m */; }; + FABB23F72602FC2C00C8785C /* NSObject+Helpers.m in Sources */ = {isa = PBXBuildFile; fileRef = B57B99DD19A2DBF200506504 /* NSObject+Helpers.m */; }; + FABB23F82602FC2C00C8785C /* ImmuTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E49CE31C4902EE002393A4 /* ImmuTableViewController.swift */; }; + FABB23F92602FC2C00C8785C /* NullStockPhotosService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88A6495208D7B0B008AE9BC /* NullStockPhotosService.swift */; }; + FABB23FA2602FC2C00C8785C /* RevisionPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A162F2221C26D7500FDC035 /* RevisionPreviewViewController.swift */; }; + FABB23FC2602FC2C00C8785C /* SitePromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 467D3DF925E4436000EB9CB0 /* SitePromptView.swift */; }; + FABB23FD2602FC2C00C8785C /* BaseRestoreCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8AA2125AF031200F9F8A0 /* BaseRestoreCompleteViewController.swift */; }; + FABB23FE2602FC2C00C8785C /* SharingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E616E4B21C480896002C024E /* SharingService.swift */; }; + FABB24002602FC2C00C8785C /* BottomSheetPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E032E52408D537003AF350 /* BottomSheetPresentationController.swift */; }; + FABB24012602FC2C00C8785C /* LikesListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB758D9D25EFDF9C00961C0B /* LikesListController.swift */; }; + FABB24022602FC2C00C8785C /* AccountSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1FD45DF1C030B3800750F4C /* AccountSettingsService.swift */; }; + FABB24032602FC2C00C8785C /* ReaderCSS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BDC4C38249BA5CA00DE0A2D /* ReaderCSS.swift */; }; + FABB24042602FC2C00C8785C /* SafariActivity.m in Sources */ = {isa = PBXBuildFile; fileRef = E1D0D81516D3B86800E33F4C /* SafariActivity.m */; }; + FABB24052602FC2C00C8785C /* WordPress-37-38.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = E603C76F1BC94AED00AD49D7 /* WordPress-37-38.xcmappingmodel */; }; + FABB24062602FC2C00C8785C /* UploadOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741E22441FC0CC55007967AB /* UploadOperation.swift */; }; + FABB24072602FC2C00C8785C /* LayoutPickerAnalyticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C984672527863E00988BB9 /* LayoutPickerAnalyticsEvent.swift */; }; + FABB24082602FC2C00C8785C /* ActivityRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E442FCB20F6AACB00DEACA5 /* ActivityRange.swift */; }; + FABB24092602FC2C00C8785C /* DiffAbstractValue+Attributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AF9551721A1D7970057827C /* DiffAbstractValue+Attributes.swift */; }; + FABB240A2602FC2C00C8785C /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E58879920FE8D9300DB6F80 /* Environment.swift */; }; + FABB240B2602FC2C00C8785C /* AbstractPostListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591232681CCEAA5100B86207 /* AbstractPostListViewController.swift */; }; + FABB240C2602FC2C00C8785C /* ManagedAccountSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14200771C117A2E00B3B115 /* ManagedAccountSettings.swift */; }; + FABB240E2602FC2C00C8785C /* Blog+Plans.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401AC82622DD2387006D78D4 /* Blog+Plans.swift */; }; + FABB240F2602FC2C00C8785C /* PostType.m in Sources */ = {isa = PBXBuildFile; fileRef = 08B6D6F11C8F7DCE0052C52B /* PostType.m */; }; + FABB24102602FC2C00C8785C /* ThemeService.m in Sources */ = {isa = PBXBuildFile; fileRef = 59A9AB341B4C33A500A433DC /* ThemeService.m */; }; + FABB24112602FC2C00C8785C /* NSAttributedString+RichTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6C4B0E1B604190005E3C43 /* NSAttributedString+RichTextView.swift */; }; + FABB24122602FC2C00C8785C /* NoteBlockButtonTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176CF39925E0005F00E1E598 /* NoteBlockButtonTableViewCell.swift */; }; + FABB24142602FC2C00C8785C /* ThemeBrowserSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820ADD711F3A226E002D7F93 /* ThemeBrowserSectionHeaderView.swift */; }; + FABB24152602FC2C00C8785C /* SiteIconPickerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82B85DF81EDDB807004FD510 /* SiteIconPickerPresenter.swift */; }; + FABB24172602FC2C00C8785C /* BlogSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D889401BEBE30A007C4684 /* BlogSettings.swift */; }; + FABB24182602FC2C00C8785C /* WKWebView+UserAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12E500223C7C5330068CB5E /* WKWebView+UserAgent.swift */; }; + FABB24192602FC2C00C8785C /* JetpackCapabilitiesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B749E7125AF522900023F03 /* JetpackCapabilitiesService.swift */; }; + FABB241A2602FC2C00C8785C /* PostToPost30To31.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DF7F7771B223916003A05C8 /* PostToPost30To31.m */; }; + FABB241B2602FC2C00C8785C /* GutenGhostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E5D000F2493CE240004B708 /* GutenGhostView.swift */; }; + FABB241C2602FC2C00C8785C /* ModelSettableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A4921B85CF20005062B /* ModelSettableCell.swift */; }; + FABB241D2602FC2C00C8785C /* FollowCommentsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA00863C24EB68B100C863F2 /* FollowCommentsService.swift */; }; + FABB241E2602FC2C00C8785C /* ForcePopoverPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1746D7761D2165AE00B11D77 /* ForcePopoverPresenter.swift */; }; + FABB241F2602FC2C00C8785C /* LikePost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D858F30020E20106007E8A1C /* LikePost.swift */; }; + FABB24202602FC2C00C8785C /* Routes+Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1703D04B20ECD93800D292E9 /* Routes+Post.swift */; }; + FABB24212602FC2C00C8785C /* Blog+Jetpack.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11C4B6F2010930B00A6619C /* Blog+Jetpack.swift */; }; + FABB24222602FC2C00C8785C /* Uploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DB8D282288C14400906E2F /* Uploader.swift */; }; + FABB24232602FC2C00C8785C /* JetpackRestoreService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8AB8A25AFFE7500F9F8A0 /* JetpackRestoreService.swift */; }; + FABB24242602FC2C00C8785C /* DateAndTimeFormatSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 821738081FE04A9E00BEC94C /* DateAndTimeFormatSettingsViewController.swift */; }; + FABB24252602FC2C00C8785C /* WPContentSyncHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6C4B051B603E03005E3C43 /* WPContentSyncHelper.swift */; }; + FABB24262602FC2C00C8785C /* SiteAssemblyStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73C8F05F21BEED9100DDDF7E /* SiteAssemblyStep.swift */; }; + FABB24272602FC2C00C8785C /* SiteSettingsViewController+Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82C420751FE44BD900CFB15B /* SiteSettingsViewController+Swift.swift */; }; + FABB24282602FC2C00C8785C /* FilterTableData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E29037243FAB0300C19CA5 /* FilterTableData.swift */; }; + FABB24292602FC2C00C8785C /* ReaderGapMarker.m in Sources */ = {isa = PBXBuildFile; fileRef = E6A3384B1BB08E3F00371587 /* ReaderGapMarker.m */; }; + FABB242A2602FC2C00C8785C /* Pattern.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CFC1561E0AC8FF001DF9E9 /* Pattern.swift */; }; + FABB242B2602FC2C00C8785C /* ShareExtensionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 930F09161C7D110E00995926 /* ShareExtensionService.swift */; }; + FABB242C2602FC2C00C8785C /* KanvasCameraCustomUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A34BCA25DF244F00C9654B /* KanvasCameraCustomUI.swift */; }; + FABB242D2602FC2C00C8785C /* MediaPreviewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ECD5B8020C4D823001AEBC5 /* MediaPreviewHelper.swift */; }; + FABB242E2602FC2C00C8785C /* OffsetTableViewHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A738BC244DF75400EDE065 /* OffsetTableViewHandler.swift */; }; + FABB242F2602FC2C00C8785C /* UINavigationController+SplitViewFullscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177E7DAC1DD0D1E600890467 /* UINavigationController+SplitViewFullscreen.swift */; }; + FABB24302602FC2C00C8785C /* ReaderReblogPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5B3EAE23A851330060FF1F /* ReaderReblogPresenter.swift */; }; + FABB24312602FC2C00C8785C /* BodyContentGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3E7A6120E44E6A0075D159 /* BodyContentGroup.swift */; }; + FABB24322602FC2C00C8785C /* WPStyleGuide+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17AD36D41D36C1A60044B10D /* WPStyleGuide+Search.swift */; }; + FABB24332602FC2C00C8785C /* WindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E3536A25B9F74C00992E3A /* WindowManager.swift */; }; + FABB24342602FC2C00C8785C /* NSFileManager+FolderSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0148E41DFABBC9001AD265 /* NSFileManager+FolderSize.swift */; }; + FABB24352602FC2C00C8785C /* ErrorStateViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731E88C821C9A10A0055C014 /* ErrorStateViewController.swift */; }; + FABB24362602FC2C00C8785C /* WP3DTouchShortcutCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE87E19F1BD4054F0075D45B /* WP3DTouchShortcutCreator.swift */; }; + FABB24372602FC2C00C8785C /* GIFPlaybackStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742B7F39209CB2B6002E3CC9 /* GIFPlaybackStrategy.swift */; }; + FABB24382602FC2C00C8785C /* ReaderCommentAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CC420AA83F9008E8AE8 /* ReaderCommentAction.swift */; }; + FABB24392602FC2C00C8785C /* JetpackBackupStatusFailedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD954B725B7A99900F011B5 /* JetpackBackupStatusFailedViewController.swift */; }; + FABB243A2602FC2C00C8785C /* CircularImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37EAAF4C1A11799A006D6306 /* CircularImageView.swift */; }; + FABB243B2602FC2C00C8785C /* GravatarButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FD0316E24201E08005C0993 /* GravatarButtonView.swift */; }; + FABB243C2602FC2C00C8785C /* StockPhotosService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EBB4125206C388100012D98 /* StockPhotosService.swift */; }; + FABB243D2602FC2C00C8785C /* Blog+Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FEAD9221494B2006E1D2D /* Blog+Analytics.swift */; }; + FABB243E2602FC2C00C8785C /* ReaderStreamViewController+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E69551F51B8B6AE200CB8E4F /* ReaderStreamViewController+Helper.swift */; }; + FABB243F2602FC2C00C8785C /* ReaderAbstractTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E61084B91B9B47BA008050C5 /* ReaderAbstractTopic.swift */; }; + FABB24402602FC2C00C8785C /* PostService+UnattachedMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F08ECFB2283A4FB000F8E11 /* PostService+UnattachedMedia.swift */; }; + FABB24412602FC2C00C8785C /* ReaderDefaultTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E61084BA1B9B47BA008050C5 /* ReaderDefaultTopic.swift */; }; + FABB24422602FC2C00C8785C /* ReaderCard+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D424B66FE600A4CCE8 /* ReaderCard+CoreDataClass.swift */; }; + FABB24432602FC2C00C8785C /* ReplyToComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816C1F120E0894D00C4D82F /* ReplyToComment.swift */; }; + FABB24442602FC2C00C8785C /* TextWithAccessoryButtonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BB92311FDAAFFA00F2D817 /* TextWithAccessoryButtonCell.swift */; }; + FABB24452602FC2C00C8785C /* TenorResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD61243AECA100A83E27 /* TenorResponse.swift */; }; + FABB24462602FC2C00C8785C /* BaseRestoreStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8F78B25AD785400D5D54A /* BaseRestoreStatusViewController.swift */; }; + FABB24472602FC2C00C8785C /* SignupEpilogueTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9872CB2F203B8A730066A293 /* SignupEpilogueTableViewController.swift */; }; + FABB24482602FC2C00C8785C /* MyProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B23B071BFB3B370006559B /* MyProfileViewController.swift */; }; + FABB24492602FC2C00C8785C /* CreateButtonActionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F532AE1B253E55D40013B42E /* CreateButtonActionSheet.swift */; }; + FABB244A2602FC2C00C8785C /* ReaderStreamViewController+Ghost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCB83D024C21063001581BD /* ReaderStreamViewController+Ghost.swift */; }; + FABB244B2602FC2C00C8785C /* Constants.m in Sources */ = {isa = PBXBuildFile; fileRef = B5CC05F51962150600975CAC /* Constants.m */; }; + FABB244C2602FC2C00C8785C /* MurielColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4326191422FCB9DC003C7642 /* MurielColor.swift */; }; + FABB244D2602FC2C00C8785C /* RegisterDomainDetailsErrorSectionFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D561C2117312700CEAA33 /* RegisterDomainDetailsErrorSectionFooter.swift */; }; + FABB244E2602FC2C00C8785C /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E729C27209A087200F76599 /* ImageLoader.swift */; }; + FABB244F2602FC2C00C8785C /* SiteAssemblyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73C8F06721BF1A5E00DDDF7E /* SiteAssemblyContentView.swift */; }; + FABB24502602FC2C00C8785C /* FeaturedImageViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D146EBA189857ED0068FDC6 /* FeaturedImageViewController.m */; }; + FABB24512602FC2C00C8785C /* NotificationCenter+ObserveMultiple.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57276E70239BDFD200515BE2 /* NotificationCenter+ObserveMultiple.swift */; }; + FABB24522602FC2C00C8785C /* ReaderCellConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D817798F20ABF26800330998 /* ReaderCellConfiguration.swift */; }; + FABB24532602FC2C00C8785C /* GutenbergGalleryUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1B11E4238FDFE70038B93E /* GutenbergGalleryUploadProcessor.swift */; }; + FABB24542602FC2C00C8785C /* StoriesIntroViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E1BA9A253A0A5E0091E9A6 /* StoriesIntroViewController.swift */; }; + FABB24552602FC2C00C8785C /* WPStyleGuide+Suggestions.m in Sources */ = {isa = PBXBuildFile; fileRef = 31EC15071A5B6675009FC8B3 /* WPStyleGuide+Suggestions.m */; }; + FABB24562602FC2C00C8785C /* SiteStatsInsightsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9865257C2194D77E0078B916 /* SiteStatsInsightsViewModel.swift */; }; + FABB24572602FC2C00C8785C /* CheckmarkTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F74696A209EFD0C0074D52B /* CheckmarkTableViewCell.swift */; }; + FABB24582602FC2C00C8785C /* ReaderManageScenePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F52CACC9244FA7AA00661380 /* ReaderManageScenePresenter.swift */; }; + FABB24592602FC2C00C8785C /* MediaService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5371621FDFF64F00619A3F /* MediaService.swift */; }; + FABB245A2602FC2C00C8785C /* TopViewedPostStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C403F12215D66A00E8C894 /* TopViewedPostStatsRecordValue+CoreDataClass.swift */; }; + FABB245B2602FC2C00C8785C /* PostMetaButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DAFEAB71AF2CA6E00B3E1D7 /* PostMetaButton.m */; }; + FABB245C2602FC2C00C8785C /* PluginStore+Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40ADB15420686870009A9161 /* PluginStore+Persistence.swift */; }; + FABB245D2602FC2C00C8785C /* ResultsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83CA3A620842CD90060E310 /* ResultsPage.swift */; }; + FABB245E2602FC2C00C8785C /* FormattableRangesFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E929CD02110D4F200BCAD88 /* FormattableRangesFactory.swift */; }; + FABB245F2602FC2C00C8785C /* Menu+ViewDesign.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D9784C1CD2AF7D0054F19A /* Menu+ViewDesign.m */; }; + FABB24602602FC2C00C8785C /* BlogDetailsViewController+DomainCredit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AC3091226FFFAA0018D23B /* BlogDetailsViewController+DomainCredit.swift */; }; + FABB24612602FC2C00C8785C /* KeyringAccountHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60BD230230A3DD400727E82 /* KeyringAccountHelper.swift */; }; + FABB24622602FC2C00C8785C /* ManagedAccountSettings+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14200791C117A3B00B3B115 /* ManagedAccountSettings+CoreDataProperties.swift */; }; + FABB24632602FC2C00C8785C /* PushAuthenticationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B535209C1AF7EB9F00B33BA8 /* PushAuthenticationService.swift */; }; + FABB24642602FC2C00C8785C /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B572735E1B66CCEF000D1C4F /* AlertView.swift */; }; + FABB24652602FC2C00C8785C /* WPTabBarController.m in Sources */ = {isa = PBXBuildFile; fileRef = 3101866A1A373B01008F7DF6 /* WPTabBarController.m */; }; + FABB24662602FC2C00C8785C /* UIAlertControllerProxy.m in Sources */ = {isa = PBXBuildFile; fileRef = 85B125441B02937E008A3D95 /* UIAlertControllerProxy.m */; }; + FABB24672602FC2C00C8785C /* StatsBarChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FF702F221F43CD00541798 /* StatsBarChartView.swift */; }; + FABB24682602FC2C00C8785C /* URLQueryItem+Parameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E1BBDF253B74240091E9A6 /* URLQueryItem+Parameters.swift */; }; + FABB24692602FC2C00C8785C /* MenuItemView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0857C2761CE5375F0014AE99 /* MenuItemView.m */; }; + FABB246A2602FC2C00C8785C /* WordPressAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E51B7A203477DF00151ECD /* WordPressAuthenticationManager.swift */; }; + FABB246B2602FC2C00C8785C /* SiteManagementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4ADAD71C50687400F858D7 /* SiteManagementService.swift */; }; + FABB246C2602FC2C00C8785C /* ActivityPostRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4A773120F800CB001C706D /* ActivityPostRange.swift */; }; + FABB246D2602FC2C00C8785C /* ReaderSelectInterestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32E1BFFB24AB9D28007A08F0 /* ReaderSelectInterestsViewController.swift */; }; + FABB246E2602FC2C00C8785C /* ReaderShowMenuAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CC020AA7C58008E8AE8 /* ReaderShowMenuAction.swift */; }; + FABB246F2602FC2C00C8785C /* NSURLCache+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BE31C31CB825A100BDF770 /* NSURLCache+Helpers.swift */; }; + FABB24702602FC2C00C8785C /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5552D7F1CD1028C00B26DF6 /* String+Extensions.swift */; }; + FABB24712602FC2C00C8785C /* CollapsableHeaderFilterBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469EB16724D9AD8B00C764CB /* CollapsableHeaderFilterBar.swift */; }; + FABB24722602FC2C00C8785C /* AutomatedTransferHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40247E012120FE3600AE1C3C /* AutomatedTransferHelper.swift */; }; + FABB24732602FC2C00C8785C /* PostTagService.m in Sources */ = {isa = PBXBuildFile; fileRef = 082AB9D81C4EEEF4000CA523 /* PostTagService.m */; }; + FABB24742602FC2C00C8785C /* ReaderTabViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6975FE242D941E001F1807 /* ReaderTabViewModel.swift */; }; + FABB24752602FC2C00C8785C /* ReaderSearchSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62079E01CF7A61200F5CD46 /* ReaderSearchSuggestionService.swift */; }; + FABB24762602FC2C00C8785C /* RootViewCoordinator+WhatIsNew.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6A7E91251BC1DC005B6A61 /* RootViewCoordinator+WhatIsNew.swift */; }; + FABB24772602FC2C00C8785C /* CachedAnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8A04DF1D9BFE7400523BC4 /* CachedAnimatedImageView.swift */; }; + FABB24782602FC2C00C8785C /* AccountToAccount20to21.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A6CEA519FA800E009F07DE /* AccountToAccount20to21.swift */; }; + FABB24792602FC2C00C8785C /* AutoUploadMessageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16C35DB23F3F78E00C81331 /* AutoUploadMessageProvider.swift */; }; + FABB247A2602FC2C00C8785C /* MenuItemEditingViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FA91CDBF95100304BA7 /* MenuItemEditingViewController.m */; }; + FABB247B2602FC2C00C8785C /* TopTotalsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984F86FA21DEDB060070E0E3 /* TopTotalsCell.swift */; }; + FABB247C2602FC2C00C8785C /* RegisterDomainDetailsViewController+LocalizedStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D56182117312700CEAA33 /* RegisterDomainDetailsViewController+LocalizedStrings.swift */; }; + FABB247D2602FC2C00C8785C /* GutenbergLayoutPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4631359524AD068B0017E65C /* GutenbergLayoutPickerViewController.swift */; }; + FABB247E2602FC2C00C8785C /* SiteInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8225407219AB0520014D0E2 /* SiteInformation.swift */; }; + FABB247F2602FC2C00C8785C /* StockPhotosPageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83CA3A820842D190060E310 /* StockPhotosPageable.swift */; }; + FABB24802602FC2C00C8785C /* JetpackRestoreStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF13E2F25A59240003EE470 /* JetpackRestoreStatusViewController.swift */; }; + FABB24812602FC2C00C8785C /* BindableTapGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10465132554260600655194 /* BindableTapGestureRecognizer.swift */; }; + FABB24822602FC2C00C8785C /* ReaderSearchSuggestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62079DE1CF79FC200F5CD46 /* ReaderSearchSuggestion.swift */; }; + FABB24832602FC2C00C8785C /* ReaderSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E64ECA4C1CE62041000188A0 /* ReaderSearchViewController.swift */; }; + FABB24842602FC2C00C8785C /* SiteStatsTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F537A622496CF300B334F9 /* SiteStatsTableHeaderView.swift */; }; + FABB24852602FC2C00C8785C /* MediaSizeSliderCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14977171C0DC0770057CD60 /* MediaSizeSliderCell.swift */; }; + FABB24862602FC2C00C8785C /* NotificationContentRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E987F552108017B00CAFB88 /* NotificationContentRouter.swift */; }; + FABB24872602FC2C00C8785C /* PageLayoutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46E327D024E705C7000944B3 /* PageLayoutService.swift */; }; + FABB24882602FC2C00C8785C /* Route+Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = F574416C2425697D00E150A8 /* Route+Page.swift */; }; + FABB24892602FC2C00C8785C /* PlayIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 177076201EA206C000705A4A /* PlayIconView.swift */; }; + FABB248A2602FC2C00C8785C /* WPTabBarController+Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57BAD50B225CCE1A006139EC /* WPTabBarController+Swift.swift */; }; + FABB248B2602FC2C00C8785C /* LanguageSelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1468DE61E794A4D0044D80F /* LanguageSelectorViewController.swift */; }; + FABB248C2602FC2C00C8785C /* PluginListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14694061F3459E2004052C8 /* PluginListViewController.swift */; }; + FABB248D2602FC2C00C8785C /* PostCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0D8145205809C8000EE505 /* PostCoordinator.swift */; }; + FABB248E2602FC2C00C8785C /* AztecAttachmentDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA30DB421ADA20F0092F894 /* AztecAttachmentDelegate.swift */; }; + FABB248F2602FC2C00C8785C /* Memoize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82B67B351FC726CD006FB593 /* Memoize.swift */; }; + FABB24902602FC2C00C8785C /* StatsPeriodAsyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA0ADB2235F11DC0027AB5D /* StatsPeriodAsyncOperation.swift */; }; + FABB24912602FC2C00C8785C /* NotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DBE4FD1D21A700002E81D3 /* NotificationsViewController.swift */; }; + FABB24922602FC2C00C8785C /* PingHubManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B84EFF1E02E94D00BF6434 /* PingHubManager.swift */; }; + FABB24932602FC2C00C8785C /* MediaVideoExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 086103951EE09C91004D7C01 /* MediaVideoExporter.swift */; }; + FABB24942602FC2C00C8785C /* PluginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E126C81E1F95FC1B00A5F464 /* PluginViewController.swift */; }; + FABB24952602FC2C00C8785C /* ReaderSiteTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E61084BC1B9B47BA008050C5 /* ReaderSiteTopic.swift */; }; + FABB24962602FC2C00C8785C /* JetpackBackupCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8004825AEDC2300D5D54A /* JetpackBackupCompleteViewController.swift */; }; + FABB24972602FC2C00C8785C /* JetpackLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8ECE012254A3250043C8DA /* JetpackLoginViewController.swift */; }; + FABB24992602FC2C00C8785C /* StreakStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054F45E2214F50300D261AB /* StreakStatsRecordValue+CoreDataProperties.swift */; }; + FABB249A2602FC2C00C8785C /* Header+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C631EF42B3A00372C65 /* Header+WordPress.swift */; }; + FABB249B2602FC2C00C8785C /* StoryMediaLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F504D2AB25D60C5900A2764C /* StoryMediaLoader.swift */; }; + FABB249C2602FC2C00C8785C /* EditorSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7BEF7222E1DD27009A880D /* EditorSettingsService.swift */; }; + FABB249D2602FC2C00C8785C /* JetpackScanHistoryCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C76F48ED25BA20EF00BFEC87 /* JetpackScanHistoryCoordinator.swift */; }; + FABB249E2602FC2C00C8785C /* NoticeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17FCA6801FD84B4600DBA9C8 /* NoticeStore.swift */; }; + FABB249F2602FC2C00C8785C /* RevisionBrowserState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A162F2821C271D300FDC035 /* RevisionBrowserState.swift */; }; + FABB24A02602FC2C00C8785C /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; }; + FABB24A12602FC2C00C8785C /* UITableViewCell+enableDisable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF0B606247D88EB009A7457 /* UITableViewCell+enableDisable.swift */; }; + FABB24A22602FC2C00C8785C /* WPAnalyticsTrackerAutomatticTracks.m in Sources */ = {isa = PBXBuildFile; fileRef = 937F3E311AD6FDA7006BA498 /* WPAnalyticsTrackerAutomatticTracks.m */; }; + FABB24A32602FC2C00C8785C /* CollapsableHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469CE06F24BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.swift */; }; + FABB24A42602FC2C00C8785C /* NotificationContentFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EB5824620EC41B200002702 /* NotificationContentFactory.swift */; }; + FABB24A52602FC2C00C8785C /* SiteIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53FF3A923EA725C001AD596 /* SiteIconView.swift */; }; + FABB24A62602FC2C00C8785C /* JetpackRestoreCompleteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3536F425B01A2C0005A3A0 /* JetpackRestoreCompleteViewController.swift */; }; + FABB24A72602FC2C00C8785C /* FormattableNoticonRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7947AC210BAC7B005BB851 /* FormattableNoticonRange.swift */; }; + FABB24A82602FC2C00C8785C /* PromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55086201CC15CCB004EADB4 /* PromptViewController.swift */; }; + FABB24A92602FC2C00C8785C /* SearchResultsStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C403EA2215CD1300E8C894 /* SearchResultsStatsRecordValue+CoreDataProperties.swift */; }; + FABB24AA2602FC2C00C8785C /* ActivityListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FC612B1FA8B7FC00A1757E /* ActivityListRow.swift */; }; + FABB24AB2602FC2C00C8785C /* UIColor+MurielColorsObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436110DF22C4241A000773AD /* UIColor+MurielColorsObjC.swift */; }; + FABB24AC2602FC2C00C8785C /* InteractiveNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B68BD31C19AAED00EB59E0 /* InteractiveNotificationsManager.swift */; }; + FABB24AD2602FC2C00C8785C /* PageTemplateLayout+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46183CF3251BD658004F9AFD /* PageTemplateLayout+CoreDataProperties.swift */; }; + FABB24AF2602FC2C00C8785C /* ReaderSeenAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981D464725B0D4E7000AA65C /* ReaderSeenAction.swift */; }; + FABB24B02602FC2C00C8785C /* SchedulingDate+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F57402A6235FF9C300374346 /* SchedulingDate+Helpers.swift */; }; + FABB24B12602FC2C00C8785C /* QuickStartChecklistCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4395A15C2106718900844E8E /* QuickStartChecklistCell.swift */; }; + FABB24B22602FC2C00C8785C /* PostAutoUploadInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C2331722FE0EC900A3863B /* PostAutoUploadInteractor.swift */; }; + FABB24B32602FC2C00C8785C /* SiteStatsDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9829162E2224BC1C008736C0 /* SiteStatsDetailsViewModel.swift */; }; + FABB24B42602FC2C00C8785C /* MediaHost+ReaderPostContentProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11C9F77243B3C9600921DDC /* MediaHost+ReaderPostContentProvider.swift */; }; + FABB24B52602FC2C00C8785C /* RestoreStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1A55EE25A6F0740033967D /* RestoreStatusView.swift */; }; + FABB24B62602FC2C00C8785C /* CustomizeInsightsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 985793C622F23D7000643DBF /* CustomizeInsightsCell.swift */; }; + FABB24B72602FC2C00C8785C /* SVProgressHUD+Dismiss.m in Sources */ = {isa = PBXBuildFile; fileRef = 8261B4CA1EA8E13700668298 /* SVProgressHUD+Dismiss.m */; }; + FABB24B82602FC2C00C8785C /* DynamicHeightCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 329F8E5524DDAC61002A5311 /* DynamicHeightCollectionView.swift */; }; + FABB24B92602FC2C00C8785C /* RegisterDomainDetailsViewModel+RowList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D56132117312700CEAA33 /* RegisterDomainDetailsViewModel+RowList.swift */; }; + FABB24BA2602FC2C00C8785C /* TenorGIF.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD60243AECA100A83E27 /* TenorGIF.swift */; }; + FABB24BB2602FC2C00C8785C /* ThemeBrowserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596C035D1B84F21D00899EEB /* ThemeBrowserViewController.swift */; }; + FABB24BC2602FC2C00C8785C /* RevisionOperationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4353BFA8219E0E820009CED3 /* RevisionOperationViewController.swift */; }; + FABB24BD2602FC2C00C8785C /* WPBlogSelectorButton.m in Sources */ = {isa = PBXBuildFile; fileRef = E2E7EB45185FB140004F5E72 /* WPBlogSelectorButton.m */; }; + FABB24BE2602FC2C00C8785C /* ReaderTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F09CCA92428FF8300D00A8C /* ReaderTabView.swift */; }; + FABB24BF2602FC2C00C8785C /* EpilogueUserInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9808655B203D079A00D58786 /* EpilogueUserInfoCell.swift */; }; + FABB24C02602FC2C00C8785C /* MenuItemTypeSelectionView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FC51CDBF96000304BA7 /* MenuItemTypeSelectionView.m */; }; + FABB24C12602FC2C00C8785C /* WordPressAppDelegate+openURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B0BA952229927F00328C69 /* WordPressAppDelegate+openURL.swift */; }; + FABB24C22602FC2C00C8785C /* WPSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176DEEE81D4615FE00331F30 /* WPSplitViewController.swift */; }; + FABB24C32602FC2C00C8785C /* CollectionViewContainerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400F4624201E74EE000CFD9E /* CollectionViewContainerRow.swift */; }; + FABB24C42602FC2C00C8785C /* JetpackConnectionWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8ECE052254A3260043C8DA /* JetpackConnectionWebViewController.swift */; }; + FABB24C52602FC2C00C8785C /* SiteSuggestion+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = B06378BF253F639D00FD45D2 /* SiteSuggestion+CoreDataProperties.swift */; }; + FABB24C62602FC2C00C8785C /* UntouchableWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4353BFB121A376BF0009CED3 /* UntouchableWindow.swift */; }; + FABB24C72602FC2C00C8785C /* WPProgressTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = FF0AAE091A150A560089841D /* WPProgressTableViewCell.m */; }; + FABB24C82602FC2C00C8785C /* StockPhotosPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A3A5B4206A4C7800992576 /* StockPhotosPicker.swift */; }; + FABB24CA2602FC2C00C8785C /* UnifiedPrologueViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 176E194625C465F70058F1C5 /* UnifiedPrologueViewController.swift */; }; + FABB24CC2602FC2C00C8785C /* CollapsableHeaderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4625B555253789C000C04AAD /* CollapsableHeaderViewController.swift */; }; + FABB24CD2602FC2C00C8785C /* Blog+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2420BEF025D8DAB300966129 /* Blog+Lookup.swift */; }; + FABB24CE2602FC2C00C8785C /* ReaderTopicService.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DBCD9D418F35D7500B32229 /* ReaderTopicService.m */; }; + FABB24CF2602FC2C00C8785C /* HeaderDetailsContentStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F09D133245223D300956257 /* HeaderDetailsContentStyles.swift */; }; + FABB24D02602FC2C00C8785C /* RewindStatusRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403F57BB20E5CA6A004E889A /* RewindStatusRow.swift */; }; + FABB24D12602FC2C00C8785C /* LinkBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5D0A64823C8FA1500B20D27 /* LinkBehavior.swift */; }; + FABB24D22602FC2C00C8785C /* ReaderHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6DE44661B90D251000FA7EF /* ReaderHelpers.swift */; }; + FABB24D32602FC2C00C8785C /* SubjectContentStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3E7A5220E44B260075D159 /* SubjectContentStyles.swift */; }; + FABB24D42602FC2C00C8785C /* AbstractPost.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D42A3D7175E7452005CFF05 /* AbstractPost.m */; }; + FABB24D52602FC2C00C8785C /* PostStatsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 986C908322319EFF00FC31E1 /* PostStatsTableViewController.swift */; }; + FABB24D72602FC2C00C8785C /* TenorResultsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD78243BF7A600A83E27 /* TenorResultsPage.swift */; }; + FABB24D82602FC2C00C8785C /* ThemeWebNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACB36F01C5C2BF800C6DF4E /* ThemeWebNavigationDelegate.swift */; }; + FABB24D92602FC2C00C8785C /* PluginListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F47D4C1FE0290C00C1D44E /* PluginListCell.swift */; }; + FABB24DA2602FC2C00C8785C /* HomeWidgetAllTimeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5C861925C9EA2500BABE64 /* HomeWidgetAllTimeData.swift */; }; + FABB24DB2602FC2C00C8785C /* UIView+Borders.swift in Sources */ = {isa = PBXBuildFile; fileRef = D829C33A21B12EFE00B09F12 /* UIView+Borders.swift */; }; + FABB24DC2602FC2C00C8785C /* BasePost.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D42A3D9175E7452005CFF05 /* BasePost.m */; }; + FABB24DD2602FC2C00C8785C /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F7C24822770B68002E5C2E /* main.swift */; }; + FABB24DE2602FC2C00C8785C /* UIViewController+NoResults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4E939E2268D9B400E14823 /* UIViewController+NoResults.swift */; }; + FABB24DF2602FC2C00C8785C /* TimeZoneStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CB6DA2200F376400945457 /* TimeZoneStore.swift */; }; + FABB24E02602FC2C00C8785C /* UserSuggestion+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0B68A9A252FA91E0001B28C /* UserSuggestion+CoreDataClass.swift */; }; + FABB24E12602FC2C00C8785C /* MediaLibraryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80BC7A3207487F200614A59 /* MediaLibraryPicker.swift */; }; + FABB24E22602FC2C00C8785C /* SharingAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E663D18F1C65383E0017F109 /* SharingAccountViewController.swift */; }; + FABB24E32602FC2C00C8785C /* TodayExtensionService.m in Sources */ = {isa = PBXBuildFile; fileRef = 93DEB88119E5BF7100F9546D /* TodayExtensionService.m */; }; + FABB24E42602FC2C00C8785C /* UIApplication+mainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B3626F825A665E500D7CCE3 /* UIApplication+mainWindow.swift */; }; + FABB24E52602FC2C00C8785C /* CoreDataIterativeMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FD83CBE246C751800381999 /* CoreDataIterativeMigrator.swift */; }; + FABB24E62602FC2C00C8785C /* ReaderSiteSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CE77EE20C6CDAA001DEA5A /* ReaderSiteSearchViewController.swift */; }; + FABB24E72602FC2C00C8785C /* TopViewedVideoStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C792217A985000A8A59 /* TopViewedVideoStatsRecordValue+CoreDataClass.swift */; }; + FABB24E82602FC2C00C8785C /* ReaderPost.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D42A3DD175E7452005CFF05 /* ReaderPost.m */; }; + FABB24E92602FC2C00C8785C /* MediaLibraryMediaPickingCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80BC79B207464D200614A59 /* MediaLibraryMediaPickingCoordinator.swift */; }; + FABB24EA2602FC2C00C8785C /* PageTemplateLayout+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46183CF2251BD658004F9AFD /* PageTemplateLayout+CoreDataClass.swift */; }; + FABB24EB2602FC2C00C8785C /* NoResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982A4C3420227D6700B5518E /* NoResultsViewController.swift */; }; + FABB24EC2602FC2C00C8785C /* ActionDispatcherFacade.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57C3D392235DFD8E00FE9CE6 /* ActionDispatcherFacade.swift */; }; + FABB24ED2602FC2C00C8785C /* ReaderSavedPostUndoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D88106F520C0C9A8001D2F00 /* ReaderSavedPostUndoCell.swift */; }; + FABB24EE2602FC2C00C8785C /* Suggestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B03B9235250BC5FD000A40AF /* Suggestion.swift */; }; + FABB24EF2602FC2C00C8785C /* ShareNoticeConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74EA3B87202A0462004F802D /* ShareNoticeConstants.swift */; }; + FABB24F02602FC2C00C8785C /* FeatureFlagOverrideStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */; }; + FABB24F12602FC2C00C8785C /* RelatedPostsPreviewTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = FF1933FE1BB17DA3006825B8 /* RelatedPostsPreviewTableViewCell.m */; }; + FABB24F22602FC2C00C8785C /* IntrinsicTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54075D31D3D7D5B0095C318 /* IntrinsicTableView.swift */; }; + FABB24F32602FC2C00C8785C /* HomeWidgetTodayData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB34ACA25672A90001A74A6 /* HomeWidgetTodayData.swift */; }; + FABB24F42602FC2C00C8785C /* NoticePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172E27D21FD98135003EA321 /* NoticePresenter.swift */; }; + FABB24F52602FC2C00C8785C /* ReaderTagTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E61084BD1B9B47BA008050C5 /* ReaderTagTopic.swift */; }; + FABB24F62602FC2C00C8785C /* Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = F110239A2318479000C4E84A /* Media.swift */; }; + FABB24F72602FC2C00C8785C /* WordPressOrgRestApi+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C2260623901AAD0021D03C /* WordPressOrgRestApi+WordPress.swift */; }; + FABB24F82602FC2C00C8785C /* GravatarPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B53B02B21CAC3AAC003190A0 /* GravatarPickerViewController.swift */; }; + FABB24F92602FC2C00C8785C /* ReaderTableConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81879D820ABC647000CFA95 /* ReaderTableConfiguration.swift */; }; + FABB24FA2602FC2C00C8785C /* AnnouncementsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F758FD424F6FB4900BBA2FC /* AnnouncementsStore.swift */; }; + FABB24FB2602FC2C00C8785C /* ReaderListStreamHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2E1681B8AAD9B0000ED14 /* ReaderListStreamHeader.swift */; }; + FABB24FC2602FC2C00C8785C /* NoResultsViewController+MediaLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 981D0929211259840014ECAF /* NoResultsViewController+MediaLibrary.swift */; }; + FABB24FD2602FC2C00C8785C /* StockPhotosDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83CA3AF2084CAAF0060E310 /* StockPhotosDataLoader.swift */; }; + FABB24FE2602FC2C00C8785C /* ReaderTopicToReaderTagTopic37to38.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66969D91B9E55AB00EC9C00 /* ReaderTopicToReaderTagTopic37to38.swift */; }; + FABB24FF2602FC2C00C8785C /* ChangeUsernameViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4E271922EF0C78001F6A6B /* ChangeUsernameViewModel.swift */; }; + FABB25002602FC2C00C8785C /* PlansLoadingIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172797D81CE5D0CD00CB8057 /* PlansLoadingIndicatorView.swift */; }; + FABB25012602FC2C00C8785C /* JetpackScanThreatDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7AA45225BFD9BC005E7200 /* JetpackScanThreatDetailsViewController.swift */; }; + FABB25022602FC2C00C8785C /* NotificationTextContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EFF208920EADCB6009C4699 /* NotificationTextContent.swift */; }; + FABB25032602FC2C00C8785C /* PostEditor+Publish.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91DCE84721A6C58C0062F134 /* PostEditor+Publish.swift */; }; + FABB25042602FC2C00C8785C /* MediaCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98921EF621372E12004949AA /* MediaCoordinator.swift */; }; + FABB25052602FC2C00C8785C /* StatsStackViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A9E3FA2230D5F0A00909BC4 /* StatsStackViewCell.swift */; }; + FABB25062602FC2C00C8785C /* ReaderTagsTableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5A738C2244E7A6F00EDE065 /* ReaderTagsTableViewModel.swift */; }; + FABB25072602FC2C00C8785C /* SiteAssemblyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73178C2E21BEE1F500E37C9A /* SiteAssemblyService.swift */; }; + FABB25082602FC2C00C8785C /* ManagedPerson.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5176CC01CDCE1B90083CF2D /* ManagedPerson.swift */; }; + FABB25092602FC2C00C8785C /* ChosenValueRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59AAC0F235E430E00385EE6 /* ChosenValueRow.swift */; }; + FABB250A2602FC2C00C8785C /* ReplyTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54E1DEE1A0A7BAA00807537 /* ReplyTextView.swift */; }; + FABB250B2602FC2C00C8785C /* PublicizeConnectionStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40EE947E2213213F00CD264F /* PublicizeConnectionStatsRecordValue+CoreDataProperties.swift */; }; + FABB250C2602FC2C00C8785C /* MenuItemEditingFooterView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FB11CDBF96000304BA7 /* MenuItemEditingFooterView.m */; }; + FABB250D2602FC2C00C8785C /* ReaderCrossPostCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D3E8481BEBD871002692E8 /* ReaderCrossPostCell.swift */; }; + FABB250E2602FC2C00C8785C /* Role.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10290731F30615A00DAC588 /* Role.swift */; }; + FABB250F2602FC2C00C8785C /* SuggestionsTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0637526253E7CEB00FD45D2 /* SuggestionsTableView.swift */; }; + FABB25102602FC2C00C8785C /* MenuItemSourceResultsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FBF1CDBF96000304BA7 /* MenuItemSourceResultsViewController.m */; }; + FABB25112602FC2C00C8785C /* UIColor+MurielColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 435B762122973D0600511813 /* UIColor+MurielColors.swift */; }; + FABB25122602FC2C00C8785C /* LayoutPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 466653492501552A00165DD4 /* LayoutPreviewViewController.swift */; }; + FABB25132602FC2C00C8785C /* UIStackView+Subviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326A4A7221C8F4100B4EB8C /* UIStackView+Subviews.swift */; }; + FABB25142602FC2C00C8785C /* RevisionsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4349B0AB218A45270034118A /* RevisionsTableViewController.swift */; }; + FABB25152602FC2C00C8785C /* PeopleService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1209FA31BB4978B00D69778 /* PeopleService.swift */; }; + FABB25162602FC2C00C8785C /* StatsPeriodStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984B139321F66B2D0004B6A2 /* StatsPeriodStore.swift */; }; + FABB25172602FC2C00C8785C /* ImmuTable+WordPress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13A8C9A1C3E6EF2005BB1C1 /* ImmuTable+WordPress.swift */; }; + FABB25182602FC2C00C8785C /* NoticeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430D50BD212B7AAE008F15F4 /* NoticeStyle.swift */; }; + FABB25192602FC2C00C8785C /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5899AE31B422D990075A3D6 /* NotificationSettings.swift */; }; + FABB251A2602FC2C00C8785C /* HeaderContentStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3E7A5620E44D130075D159 /* HeaderContentStyles.swift */; }; + FABB251B2602FC2C00C8785C /* Revision.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A38DC64218899FA006A409B /* Revision.swift */; }; + FABB251C2602FC2C00C8785C /* PluginDirectoryAccessoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 403269912027719C00608441 /* PluginDirectoryAccessoryItem.swift */; }; + FABB251D2602FC2C00C8785C /* PHAsset+Exporters.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB1FA9F1BF0EC4E0090C761 /* PHAsset+Exporters.swift */; }; + FABB251E2602FC2C00C8785C /* GutenbergLightNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465B097924C877E500336B6C /* GutenbergLightNavigationController.swift */; }; + FABB251F2602FC2C00C8785C /* AppFeedbackPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8298F38E1EEF2B15008EB7F0 /* AppFeedbackPromptView.swift */; }; + FABB25202602FC2C00C8785C /* UIView+ExistingConstraints.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A2A1D23BFBD72001E96AC /* UIView+ExistingConstraints.swift */; }; + FABB25212602FC2C00C8785C /* CountryStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C7F2217A985000A8A59 /* CountryStatsRecordValue+CoreDataProperties.swift */; }; + FABB25222602FC2C00C8785C /* MediaRequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1450CF22437DA3E00A28BFE /* MediaRequestAuthenticator.swift */; }; + FABB25232602FC2C00C8785C /* StatsCellHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9881296C219CF1300075FF33 /* StatsCellHeader.swift */; }; + FABB25242602FC2C00C8785C /* UIEdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1823E6B1E42231C00C19F53 /* UIEdgeInsets.swift */; }; + FABB25252602FC2C00C8785C /* RestorePageTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D18FE9D1AFBB17400EFEED0 /* RestorePageTableViewCell.m */; }; + FABB25262602FC2C00C8785C /* String+RegEx.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54C02231F38F50100574572 /* String+RegEx.swift */; }; + FABB25272602FC2C00C8785C /* UIImage+Exporters.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB1FA9D1BF0EB840090C761 /* UIImage+Exporters.swift */; }; + FABB25282602FC2C00C8785C /* PostStatsTitleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98FCFC212231DF43006ECDD4 /* PostStatsTitleCell.swift */; }; + FABB25292602FC2C00C8785C /* CommentService.m in Sources */ = {isa = PBXBuildFile; fileRef = E1556CF1193F6FE900FC52EA /* CommentService.m */; }; + FABB252A2602FC2C00C8785C /* MediaLibraryStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80BC7A12074739300614A59 /* MediaLibraryStrings.swift */; }; + FABB252B2602FC2C00C8785C /* StoreFetchingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A09F914230C3E9700F42AB7 /* StoreFetchingStatus.swift */; }; + FABB252C2602FC2C00C8785C /* SiteDateFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = F582060123A85495005159A9 /* SiteDateFormatters.swift */; }; + FABB252D2602FC2C00C8785C /* PostEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13ACCD31EE5672100CCE985 /* PostEditor.swift */; }; + FABB252E2602FC2C00C8785C /* ClicksStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C812217A985000A8A59 /* ClicksStatsRecordValue+CoreDataClass.swift */; }; + FABB252F2602FC2C00C8785C /* JetpackRestoreWarningCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD9458D25B5678700F011B5 /* JetpackRestoreWarningCoordinator.swift */; }; + FABB25302602FC2C00C8785C /* WPPickerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DB3BA0418D0E7B600F3F3E9 /* WPPickerView.m */; }; + FABB25312602FC2C00C8785C /* UIImageView+SiteIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E94D141FE04815000E7C20 /* UIImageView+SiteIcon.swift */; }; + FABB25322602FC2C00C8785C /* ReaderSiteSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17CE77EC20C6C2F3001DEA5A /* ReaderSiteSearchService.swift */; }; + FABB25332602FC2C00C8785C /* QuickStartNavigationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98FF6A3D23A30A240025FD72 /* QuickStartNavigationSettings.swift */; }; + FABB25342602FC2C00C8785C /* WPImageViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D42A402175E76A2005CFF05 /* WPImageViewController.m */; }; + FABB25352602FC2C00C8785C /* ReaderListTopic.swift in Sources */ = {isa = PBXBuildFile; fileRef = E61084BB1B9B47BA008050C5 /* ReaderListTopic.swift */; }; + FABB25362602FC2C00C8785C /* PublicizeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6374DBE1C444D8B00F24720 /* PublicizeService.swift */; }; + FABB25372602FC2C00C8785C /* ActivityContentFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4A773A20F8058F001C706D /* ActivityContentFactory.swift */; }; + FABB25382602FC2C00C8785C /* TenorDataLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD79243BF7A600A83E27 /* TenorDataLoader.swift */; }; + FABB25392602FC2C00C8785C /* OverviewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98797DBA222F434500128C21 /* OverviewCell.swift */; }; + FABB253A2602FC2C00C8785C /* WordPressAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1749965E2271BF08007021BD /* WordPressAppDelegate.swift */; }; + FABB253B2602FC2C00C8785C /* MediaService.m in Sources */ = {isa = PBXBuildFile; fileRef = 5DA3EE151925090A00294E0B /* MediaService.m */; }; + FABB253C2602FC2C00C8785C /* FormattableContentAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B820F4097B00DF8486 /* FormattableContentAction.swift */; }; + FABB253D2602FC2C00C8785C /* SiteVerticalsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A468E421828D940094B82F /* SiteVerticalsService.swift */; }; + FABB253E2602FC2C00C8785C /* ReaderBlockedSiteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65219FA1B8D10DA000B1217 /* ReaderBlockedSiteCell.swift */; }; + FABB253F2602FC2C00C8785C /* JetpackRestoreWarningViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF13C5225A57ABD003EE470 /* JetpackRestoreWarningViewController.swift */; }; + FABB25402602FC2C00C8785C /* SiteAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73178C3021BEE45300E37C9A /* SiteAssembly.swift */; }; + FABB25412602FC2C00C8785C /* ImageDimensionFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326E2819250AC4A50029EBF0 /* ImageDimensionFetcher.swift */; }; + FABB25422602FC2C00C8785C /* AnimatedImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7435CE7220A4B9170075A1B9 /* AnimatedImageCache.swift */; }; + FABB25432602FC2C00C8785C /* PostListFilterSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AB7C5D1D3E70510066CB6A /* PostListFilterSettings.swift */; }; + FABB25442602FC2C00C8785C /* FooterTextContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE46018A21139E8300F242B6 /* FooterTextContent.swift */; }; + FABB25452602FC2C00C8785C /* Blog+Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EEDB961C91F10400676B2B /* Blog+Interface.swift */; }; + FABB25462602FC2C00C8785C /* PostFeaturedImageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D839AA7187F0D6B00811F4A /* PostFeaturedImageCell.m */; }; + FABB25472602FC2C00C8785C /* WPException.m in Sources */ = {isa = PBXBuildFile; fileRef = FFCB9F4A22A125BD0080A45F /* WPException.m */; }; + FABB25482602FC2C00C8785C /* StatsGhostCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A9E3FAB230E9DD000909BC4 /* StatsGhostCells.swift */; }; + FABB25492602FC2C00C8785C /* NotificationCenter+ObserveOnce.swift in Sources */ = {isa = PBXBuildFile; fileRef = E14DF70520C922F200959BA9 /* NotificationCenter+ObserveOnce.swift */; }; + FABB254A2602FC2C00C8785C /* SharePost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E125F1E31E8E595E00320B67 /* SharePost.swift */; }; + FABB254B2602FC2C00C8785C /* RoleViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56FEB781CD8E13C00E621F9 /* RoleViewController.swift */; }; + FABB254C2602FC2C00C8785C /* SiteAddressService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82253DB2199411F0014D0E2 /* SiteAddressService.swift */; }; + FABB254D2602FC2C00C8785C /* WPStyleGuide+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B56D3119AFB68800B4E29B /* WPStyleGuide+Notifications.swift */; }; + FABB254E2602FC2C00C8785C /* SettingTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = FF8DDCDE1B5DB1C10098826F /* SettingTableViewCell.m */; }; + FABB254F2602FC2C00C8785C /* CountriesMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A3BDA0D22944F3500FBF510 /* CountriesMapView.swift */; }; + FABB25502602FC2C00C8785C /* MediaLibraryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BB26AD1E6D8321008CD031 /* MediaLibraryViewController.swift */; }; + FABB25512602FC2C00C8785C /* TodayWidgetStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98E58A2E2360D23400E5534B /* TodayWidgetStats.swift */; }; + FABB25522602FC2C00C8785C /* GutenbergMediaFilesUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E1577E25DE04E200EEEDFB /* GutenbergMediaFilesUploadProcessor.swift */; }; + FABB25532602FC2C00C8785C /* WordPress-30-31.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 5DF7F7731B22337C003A05C8 /* WordPress-30-31.xcmappingmodel */; }; + FABB25542602FC2C00C8785C /* RegisterDomainDetailsFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D561D2117312700CEAA33 /* RegisterDomainDetailsFooterView.swift */; }; + FABB25552602FC2C00C8785C /* Charts+AxisFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73F76E1D222851E300FDDAD2 /* Charts+AxisFormatters.swift */; }; + FABB25562602FC2C00C8785C /* OtherAndTotalViewsCount+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40EC1F0D2249CA8400F6785E /* OtherAndTotalViewsCount+CoreDataClass.swift */; }; + FABB25572602FC2C00C8785C /* EpilogueSectionHeaderFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98579BC5203DD86D004086E4 /* EpilogueSectionHeaderFooter.swift */; }; + FABB25582602FC2C00C8785C /* PostCategoriesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F605FA7251430C200F99544 /* PostCategoriesViewController.swift */; }; + FABB25592602FC2C00C8785C /* NSManagedObject+Lookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F3789725E6E62100A27BB7 /* NSManagedObject+Lookup.swift */; }; + FABB255A2602FC2C00C8785C /* StatsPeriodHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17D4153B22C2308D006378EF /* StatsPeriodHelper.swift */; }; + FABB255B2602FC2C00C8785C /* FooterContentStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3E7A5820E44D2F0075D159 /* FooterContentStyles.swift */; }; + FABB255C2602FC2C00C8785C /* TenorMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD74243BF7A500A83E27 /* TenorMedia.swift */; }; + FABB255D2602FC2C00C8785C /* AbstractPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19B17B11E5C8F36007517C6 /* AbstractPost.swift */; }; + FABB255E2602FC2C00C8785C /* WhatIsNewScenePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F73BE5C24EB38E200BE99FF /* WhatIsNewScenePresenter.swift */; }; + FABB255F2602FC2C00C8785C /* CalendarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B69F19E255D67E7006B1CEF /* CalendarViewController.swift */; }; + FABB25602602FC2C00C8785C /* GutenbergViewController+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 912347752216E27200BD9F97 /* GutenbergViewController+Localization.swift */; }; + FABB25612602FC2C00C8785C /* MarkAsSpam.swift in Sources */ = {isa = PBXBuildFile; fileRef = D816C1F320E0895E00C4D82F /* MarkAsSpam.swift */; }; + FABB25622602FC2C00C8785C /* BlogDetailsSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4349BFF6221205740084F200 /* BlogDetailsSectionHeaderView.swift */; }; + FABB25642602FC2C00C8785C /* Math.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C9AA501C10419200732665 /* Math.swift */; }; + FABB25652602FC2C00C8785C /* MediaProgressCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50C0C591EF42A2600372C65 /* MediaProgressCoordinator.swift */; }; + FABB25662602FC2C00C8785C /* NSMutableAttributedString+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B574CE141B5E8EA800A84FFD /* NSMutableAttributedString+Helpers.swift */; }; + FABB25672602FC2C00C8785C /* PushNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5416CF41C171D7100006DD8 /* PushNotificationsManager.swift */; }; + FABB25682602FC2C00C8785C /* StatsInsightsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AE3DF4219A1788003C0E24 /* StatsInsightsStore.swift */; }; + FABB25692602FC2C00C8785C /* MenuItemPagesViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FB71CDBF96000304BA7 /* MenuItemPagesViewController.m */; }; + FABB256A2602FC2C00C8785C /* StatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A71C65220E1951002E3D25 /* StatsRecordValue+CoreDataProperties.swift */; }; + FABB256B2602FC2C00C8785C /* InteractivePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57D6C83F229498C4003DDC7E /* InteractivePostView.swift */; }; + FABB256C2602FC2C00C8785C /* LinearGradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F2565F25012D3F006B8BC4 /* LinearGradientView.swift */; }; + FABB256D2602FC2C00C8785C /* WizardStep.swift in Sources */ = {isa = PBXBuildFile; fileRef = D865721021869C590023A99C /* WizardStep.swift */; }; + FABB256E2602FC2C00C8785C /* PostPostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D54D121DCAA070007F575F /* PostPostViewController.swift */; }; + FABB256F2602FC2C00C8785C /* QuickStartSpotlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43290D09215E8B1200F6B398 /* QuickStartSpotlightView.swift */; }; + FABB25702602FC2C00C8785C /* ReaderReblogAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8CB10523A07B17007627BF /* ReaderReblogAction.swift */; }; + FABB25712602FC2C00C8785C /* SiteAssemblyWizardContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73C8F06121BEEEDE00DDDF7E /* SiteAssemblyWizardContent.swift */; }; + FABB25722602FC2C00C8785C /* UITableView+Header.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A5D21B8632E0005062B /* UITableView+Header.swift */; }; + FABB25732602FC2C00C8785C /* RichTextContentStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3E7A5A20E44D950075D159 /* RichTextContentStyles.swift */; }; + FABB25742602FC2C00C8785C /* StatsRecord+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A71C60220E1950002E3D25 /* StatsRecord+CoreDataProperties.swift */; }; + FABB25752602FC2C00C8785C /* ContentCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E846FF020FD0A0500881F5A /* ContentCoordinator.swift */; }; + FABB25762602FC2C00C8785C /* InviteLinks+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = E690F6EE25E05D180015A777 /* InviteLinks+CoreDataProperties.swift */; }; + FABB25772602FC2C00C8785C /* NotificationMediaDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B54E1DF31A0A7BBF00807537 /* NotificationMediaDownloader.swift */; }; + FABB25782602FC2C00C8785C /* WizardDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86572162186C3600023A99C /* WizardDelegate.swift */; }; + FABB25792602FC2C00C8785C /* Blog+Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73713582208EA4B900CCDFC8 /* Blog+Files.swift */; }; + FABB257A2602FC2C00C8785C /* RevisionDiffsBrowserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439F4F3B219B78B500F8D0C7 /* RevisionDiffsBrowserViewController.swift */; }; + FABB257B2602FC2C00C8785C /* WPAccount.m in Sources */ = {isa = PBXBuildFile; fileRef = E105E9CE1726955600C0D9E7 /* WPAccount.m */; }; + FABB257C2602FC2C00C8785C /* CLPlacemark+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF619DD41C75246900903B65 /* CLPlacemark+Formatting.swift */; }; + FABB257D2602FC2C00C8785C /* SiteStatsDetailTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98467A3E221CD48500DF51AE /* SiteStatsDetailTableViewController.swift */; }; + FABB257E2602FC2C00C8785C /* MessageAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11450DE1C4E47E600A6BD0F /* MessageAnimator.swift */; }; + FABB257F2602FC2C00C8785C /* JetpackRestoreOptionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4F65A62594337300EAA9F5 /* JetpackRestoreOptionsViewController.swift */; }; + FABB25802602FC2C00C8785C /* WPStyleGuide+Loader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E14635620B3BEAB00B95F41 /* WPStyleGuide+Loader.swift */; }; + FABB25812602FC2C00C8785C /* MediaThumbnailService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 087EBFA71F02313E001F7ACE /* MediaThumbnailService.swift */; }; + FABB25822602FC2C00C8785C /* MediaExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08F8CD291EBD22EF0049D0C0 /* MediaExporter.swift */; }; + FABB25832602FC2C00C8785C /* ReaderRelatedPostsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC086D525EDFB1E00B94F2A /* ReaderRelatedPostsCell.swift */; }; + FABB25842602FC2C00C8785C /* NoResultsViewController+FollowedSites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324780E0247F2E2A00987525 /* NoResultsViewController+FollowedSites.swift */; }; + FABB25862602FC2C00C8785C /* WordPress-11-12.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = E100C6BA1741472F00AE48D8 /* WordPress-11-12.xcmappingmodel */; }; + FABB25872602FC2C00C8785C /* DiffAbstractValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A38DC68218899FB006A409B /* DiffAbstractValue.swift */; }; + FABB25882602FC2C00C8785C /* Routes+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17B7C8C020EE2A870042E260 /* Routes+Notifications.swift */; }; + FABB25892602FC2C00C8785C /* Autosaver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E92A1FA233CB1B7006D281B /* Autosaver.swift */; }; + FABB258A2602FC2C00C8785C /* BlogAuthor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A341E5421997A330036662E /* BlogAuthor.swift */; }; + FABB258B2602FC2C00C8785C /* Blog+BlogAuthors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A341E5521997A330036662E /* Blog+BlogAuthors.swift */; }; + FABB258C2602FC2C00C8785C /* ActivityContentStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E3AB3DA20F52654001F33B6 /* ActivityContentStyles.swift */; }; + FABB258D2602FC2C00C8785C /* CategorySectionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 469CE06B24BCED75003BDC8B /* CategorySectionTableViewCell.swift */; }; + FABB258E2602FC2C00C8785C /* StatsForegroundObservable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A034CEA237DB8660047B41B /* StatsForegroundObservable.swift */; }; + FABB258F2602FC2C00C8785C /* AbstractPost+TitleForVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B1CF0102433E61C00578582 /* AbstractPost+TitleForVisibility.swift */; }; + FABB25902602FC2C00C8785C /* SharingViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = E6431DE41C4E892900FD8D90 /* SharingViewController.m */; }; + FABB25912602FC2C00C8785C /* FormattableActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123C720F417EF00DF8486 /* FormattableActivity.swift */; }; + FABB25922602FC2C00C8785C /* RevisionPreviewTextViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A162F2A21C2A21A00FDC035 /* RevisionPreviewTextViewManager.swift */; }; + FABB25932602FC2C00C8785C /* StatsChartLegendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D86968223AF4040064920F /* StatsChartLegendView.swift */; }; + FABB25942602FC2C00C8785C /* FindOutMoreCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F8CBE0C24EED2CB00F71234 /* FindOutMoreCell.swift */; }; + FABB25952602FC2C00C8785C /* NSURL+Exporters.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF286C751DE70A4500383A62 /* NSURL+Exporters.swift */; }; + FABB25962602FC2C00C8785C /* FileDownloadsStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 988AC37422F10DD900BC1433 /* FileDownloadsStatsRecordValue+CoreDataProperties.swift */; }; + FABB25972602FC2C00C8785C /* BlogToAccount.m in Sources */ = {isa = PBXBuildFile; fileRef = E1A03EE117422DCE0085D192 /* BlogToAccount.m */; }; + FABB25982602FC2C00C8785C /* JetpackRestoreHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4F660425946B5F00EAA9F5 /* JetpackRestoreHeaderView.swift */; }; + FABB25992602FC2C00C8785C /* StoryboardLoadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D55DE210F866900CEAA33 /* StoryboardLoadable.swift */; }; + FABB259A2602FC2C00C8785C /* ReaderBlockSiteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CC220AA7F57008E8AE8 /* ReaderBlockSiteAction.swift */; }; + FABB259C2602FC2C00C8785C /* ReaderPostService+RelatedPosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA8E1F7625EEFA7300063673 /* ReaderPostService+RelatedPosts.swift */; }; + FABB259D2602FC2C00C8785C /* Blog+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B518E1641CCAA19200ADFE75 /* Blog+Capabilities.swift */; }; + FABB259E2602FC2C00C8785C /* RestorePostTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 575E126E229779E70041B3EB /* RestorePostTableViewCell.swift */; }; + FABB259F2602FC2C00C8785C /* MenuItemCategoriesViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08216FAF1CDBF96000304BA7 /* MenuItemCategoriesViewController.m */; }; + FABB25A02602FC2C00C8785C /* UINavigationBar+Appearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171CC15724FCEBF7008B7180 /* UINavigationBar+Appearance.swift */; }; + FABB25A12602FC2C00C8785C /* QuickStartChecklistViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C9908D21067E22009EFFEB /* QuickStartChecklistViewController.swift */; }; + FABB25A22602FC2C00C8785C /* MenuItemsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 082635BA1CEA69280088030C /* MenuItemsViewController.m */; }; + FABB25A32602FC2C00C8785C /* ReachabilityUtils+OnlineActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822876F01E929CFD00696BF7 /* ReachabilityUtils+OnlineActions.swift */; }; + FABB25A42602FC2C00C8785C /* TableViewKeyboardObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = E15632CB1EB9ECF40035A099 /* TableViewKeyboardObserver.swift */; }; + FABB25A52602FC2C00C8785C /* BlogToJetpackAccount.m in Sources */ = {isa = PBXBuildFile; fileRef = E1A03F47174283E00085D192 /* BlogToJetpackAccount.m */; }; + FABB25A62602FC2C00C8785C /* NotificationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B522C4F71B3DA79B00E47B59 /* NotificationSettingsViewController.swift */; }; + FABB25A72602FC2C00C8785C /* ChangeUsernameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A51DA1322E9E8C7005CC335 /* ChangeUsernameViewController.swift */; }; + FABB25A82602FC2C00C8785C /* RichTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D6C4B0F1B604190005E3C43 /* RichTextView.swift */; }; + FABB25A92602FC2C00C8785C /* ScenePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43602E23F31D48001DEE70 /* ScenePresenter.swift */; }; + FABB25AA2602FC2C00C8785C /* AccountSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A4E271C22EF33F5001F6A6B /* AccountSettingsStore.swift */; }; + FABB25AB2602FC2C00C8785C /* FancyAlertViewController+NotificationPrimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4319AADD2090F00C0025D68E /* FancyAlertViewController+NotificationPrimer.swift */; }; + FABB25AC2602FC2C00C8785C /* Charts+Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FF7031221F469100541798 /* Charts+Support.swift */; }; + FABB25AD2602FC2C00C8785C /* SharingAuthorizationWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F16601C323E9E783007950AE /* SharingAuthorizationWebViewController.swift */; }; + FABB25AE2602FC2C00C8785C /* SearchWrapperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1719633F1D378D5100898E8B /* SearchWrapperView.swift */; }; + FABB25AF2602FC2C00C8785C /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 241E60B225CA0D2900912CEB /* UserSettings.swift */; }; + FABB25B02602FC2C00C8785C /* MediaImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0815CF451E96F22600069916 /* MediaImportService.swift */; }; + FABB25B12602FC2C00C8785C /* NSAttributedStringKey+Conversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56F25871FBDE502005C33E4 /* NSAttributedStringKey+Conversion.swift */; }; + FABB25B22602FC2C00C8785C /* SelectPostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFABD80721370496003C65B6 /* SelectPostViewController.swift */; }; + FABB25B32602FC2C00C8785C /* ReaderVisitSiteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CBC20AA7A7A008E8AE8 /* ReaderVisitSiteAction.swift */; }; + FABB25B42602FC2C00C8785C /* ReaderCard+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB185D324B66FE500A4CCE8 /* ReaderCard+CoreDataProperties.swift */; }; + FABB25B52602FC2C00C8785C /* GutenbergViewController+MoreActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10E654F21B06139007AB2EE /* GutenbergViewController+MoreActions.swift */; }; + FABB25B62602FC2C00C8785C /* Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = E148362E1C6DF7D8005ACF53 /* Product.swift */; }; + FABB25B72602FC2C00C8785C /* PostTag.m in Sources */ = {isa = PBXBuildFile; fileRef = 082AB9DC1C4F035E000CA523 /* PostTag.m */; }; + FABB25B82602FC2C00C8785C /* ActivityListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FC61291FA8B6F000A1757E /* ActivityListViewModel.swift */; }; + FABB25B92602FC2C00C8785C /* UITextField+WorkaroundContinueIssue.swift in Sources */ = {isa = PBXBuildFile; fileRef = F19153BC2549ED0800629EC4 /* UITextField+WorkaroundContinueIssue.swift */; }; + FABB25BA2602FC2C00C8785C /* StoreKit+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = E177807F1C97FA9500FA7E14 /* StoreKit+Debug.swift */; }; + FABB25BB2602FC2C00C8785C /* PrivacySettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1ADE0EA20A9EF6200D6AADC /* PrivacySettingsViewController.swift */; }; + FABB25BC2602FC2C00C8785C /* SharingDetailViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = E6431DE21C4E892900FD8D90 /* SharingDetailViewController.m */; }; + FABB25BD2602FC2C00C8785C /* OtherAndTotalViewsCount+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40EC1F0E2249CA8400F6785E /* OtherAndTotalViewsCount+CoreDataProperties.swift */; }; + FABB25BE2602FC2C00C8785C /* SettingsPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50EED781C0E5B2400D278CA /* SettingsPickerViewController.swift */; }; + FABB25BF2602FC2C00C8785C /* WPTableImageSource.m in Sources */ = {isa = PBXBuildFile; fileRef = E1F5A1BB1771C90A00E0495F /* WPTableImageSource.m */; }; + FABB25C02602FC2C00C8785C /* ReaderTopicCollectionViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 329F8E5724DDBD11002A5311 /* ReaderTopicCollectionViewCoordinator.swift */; }; + FABB25C12602FC2C00C8785C /* WPAppAnalytics.m in Sources */ = {isa = PBXBuildFile; fileRef = 5948AD0D1AB734F2006E8882 /* WPAppAnalytics.m */; }; + FABB25C22602FC2C00C8785C /* SettingsSelectionViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = B53AD9BE1BE9584A009AB87E /* SettingsSelectionViewController.m */; }; + FABB25C32602FC2C00C8785C /* FormattableContentActionCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4123B720F4097B00DF8486 /* FormattableContentActionCommand.swift */; }; + FABB25C42602FC2C00C8785C /* AuthorFilterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A28DCA2052FB5D00EA6D9E /* AuthorFilterViewController.swift */; }; + FABB25C52602FC2C00C8785C /* MenusSelectionItemView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D3454D1CD7F50900358E8C /* MenusSelectionItemView.m */; }; + FABB25C62602FC2C00C8785C /* TenorReponseParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C81CCD6D243AF09900A83E27 /* TenorReponseParser.swift */; }; + FABB25C72602FC2C00C8785C /* WPStyleGuide+Gridicon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4020B2BC2007AC850002C963 /* WPStyleGuide+Gridicon.swift */; }; + FABB25C82602FC2C00C8785C /* RelatedPostsSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FF42584F1BA092EE00580C68 /* RelatedPostsSettingsViewController.m */; }; + FABB25C92602FC2C00C8785C /* CreateButtonCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E032D5240889EB003AF350 /* CreateButtonCoordinator.swift */; }; + FABB25CA2602FC2C00C8785C /* SearchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74729CA22056FA0900D1394D /* SearchManager.swift */; }; + FABB25CB2602FC2C00C8785C /* BlogToBlogMigration87to88.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E8980C922E8C7A600C567B0 /* BlogToBlogMigration87to88.swift */; }; + FABB25CC2602FC2C00C8785C /* WPStyleGuide+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1ACAA11BC6E45D00DDDCE2 /* WPStyleGuide+Themes.swift */; }; + FABB25CD2602FC2C00C8785C /* WPStyleGuide+Reader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1181E61B4D6DEB003F3084 /* WPStyleGuide+Reader.swift */; }; + FABB25CE2602FC2C00C8785C /* Interpolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3250490624F988220036B47F /* Interpolation.swift */; }; + FABB25CF2602FC2C00C8785C /* PostEditorAnalyticsSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17FEAD7221490F7006E1D2D /* PostEditorAnalyticsSession.swift */; }; + FABB25D02602FC2C00C8785C /* SiteCreationWizardLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 738B9A4421B85CF20005062B /* SiteCreationWizardLauncher.swift */; }; + FABB25D12602FC2C00C8785C /* ReaderSitesCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3234B8E6252FA0930068DA40 /* ReaderSitesCardCell.swift */; }; + FABB25D22602FC2C00C8785C /* SiteSegmentsWizardContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = D865722C2186F96B0023A99C /* SiteSegmentsWizardContent.swift */; }; + FABB25D32602FC2C00C8785C /* ReaderTopicToReaderDefaultTopic37to38.swift in Sources */ = {isa = PBXBuildFile; fileRef = E66969DF1B9E648100EC9C00 /* ReaderTopicToReaderDefaultTopic37to38.swift */; }; + FABB25D42602FC2C00C8785C /* UploadsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DB8D2A2288C24500906E2F /* UploadsManager.swift */; }; + FABB25D62602FC2C00C8785C /* ActivityListSectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82A062DD2017BCBA0084CE7C /* ActivityListSectionHeaderView.swift */; }; + FABB25D72602FC2C00C8785C /* WPAccount+RestApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73FEC870220B358500CEF791 /* WPAccount+RestApi.swift */; }; + FABB25D82602FC2C00C8785C /* ImageCropOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FF3BE61CAD881100C1D597 /* ImageCropOverlayView.swift */; }; + FABB25D92602FC2C00C8785C /* UIView+SpringAnimations.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5E032DA24088F44003AF350 /* UIView+SpringAnimations.swift */; }; + FABB25DA2602FC2C00C8785C /* ReaderGapMarkerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A3384F1BB0A70F00371587 /* ReaderGapMarkerCell.swift */; }; + FABB25DB2602FC2C00C8785C /* SupportTableViewController+Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BDFF6A20D0732900C72C58 /* SupportTableViewController+Activity.swift */; }; + FABB25DC2602FC2C00C8785C /* ThemeIdHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82FFBF4C1F434BDA00F4573F /* ThemeIdHelper.swift */; }; + FABB25DD2602FC2C00C8785C /* BottomScrollAnalyticsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 730D290E22976F1A0004BB1E /* BottomScrollAnalyticsTracker.swift */; }; + FABB25DE2602FC2C00C8785C /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6311C421ECA017E00122529 /* UserProfile.swift */; }; + FABB25DF2602FC2C00C8785C /* PickerTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5CABB161C0E382C0050AB9F /* PickerTableViewCell.swift */; }; + FABB25E02602FC2C00C8785C /* StoryEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5AE43E325DD02C0003675F4 /* StoryEditor.swift */; }; + FABB25E12602FC2C00C8785C /* WPActivityDefaults.m in Sources */ = {isa = PBXBuildFile; fileRef = E1D95EB717A28F5E00A3E9F3 /* WPActivityDefaults.m */; }; + FABB25E22602FC2C00C8785C /* RegisterDomainDetailsViewModel+CellIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D562F2117410C00CEAA33 /* RegisterDomainDetailsViewModel+CellIndex.swift */; }; + FABB25E32602FC2C00C8785C /* RouteMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1714F8CF20E6DA8900226DCB /* RouteMatcher.swift */; }; + FABB25E42602FC2C00C8785C /* WPGUIConstants.m in Sources */ = {isa = PBXBuildFile; fileRef = 591A428E1A6DC6F2003807A6 /* WPGUIConstants.m */; }; + FABB25E52602FC2C00C8785C /* UIColor+Notice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FD272DF24CF8F270021F0C8 /* UIColor+Notice.swift */; }; + FABB25E62602FC2C00C8785C /* JetpackBackupOptionsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD9457D25B5647B00F011B5 /* JetpackBackupOptionsCoordinator.swift */; }; + FABB25E72602FC2C00C8785C /* PluginStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1CA0A6B1FA73053004C4BBE /* PluginStore.swift */; }; + FABB25E82602FC2C00C8785C /* JetpackBackupStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8F7A925AD792500D5D54A /* JetpackBackupStatusViewController.swift */; }; + FABB25E92602FC2C00C8785C /* Pinghub.swift in Sources */ = {isa = PBXBuildFile; fileRef = E11DA4921E03E03F00CF07A8 /* Pinghub.swift */; }; + FABB25EA2602FC2C00C8785C /* PeopleRoleBadgeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B912821BB01047003C25B9 /* PeopleRoleBadgeLabel.swift */; }; + FABB25EB2602FC2C00C8785C /* ActivityDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40FC6B7E2072E3EB00B9A1CD /* ActivityDetailViewController.swift */; }; + FABB25EC2602FC2C00C8785C /* BlogListViewController+BlogDetailsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43603223F36515001DEE70 /* BlogListViewController+BlogDetailsFactory.swift */; }; + FABB25ED2602FC2C00C8785C /* WebViewControllerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16FB7E01F8B5D7D0004DD9F /* WebViewControllerConfiguration.swift */; }; + FABB25EE2602FC2C00C8785C /* ViewMoreRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B3FA8321C05BDC00148DD4 /* ViewMoreRow.swift */; }; + FABB25EF2602FC2C00C8785C /* ReaderWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BADF16424801BCE005AD038 /* ReaderWebView.swift */; }; + FABB25F02602FC2C00C8785C /* PostVisibilitySelectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B260D7D2444FC9D0010F756 /* PostVisibilitySelectorViewController.swift */; }; + FABB25F12602FC2C00C8785C /* UIApplication+Helpers.m in Sources */ = {isa = PBXBuildFile; fileRef = B5416D001C17693B00006DD8 /* UIApplication+Helpers.m */; }; + FABB25F22602FC2C00C8785C /* ErrorStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 731E88C721C9A10A0055C014 /* ErrorStateView.swift */; }; + FABB25F32602FC2C00C8785C /* MenusSelectionDetailView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D345491CD7F50900358E8C /* MenusSelectionDetailView.m */; }; + FABB25F42602FC2C00C8785C /* Blog+Title.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B0732EE242BF6EA00E7FBD3 /* Blog+Title.swift */; }; + FABB25F52602FC2C00C8785C /* FilterableCategoriesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94C0B1A25DCFA0100F2F69B /* FilterableCategoriesViewController.swift */; }; + FABB25F62602FC2C00C8785C /* Post+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591AA5001CEF9BF20074934F /* Post+CoreDataProperties.swift */; }; + FABB25F72602FC2C00C8785C /* BasePost.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19B17AD1E5C6944007517C6 /* BasePost.swift */; }; + FABB25F82602FC2C00C8785C /* NotificationContentRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E7947A8210BAC1D005BB851 /* NotificationContentRange.swift */; }; + FABB25F92602FC2C00C8785C /* PostServiceUploadingList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B6EA62223FDE50B004BA312 /* PostServiceUploadingList.swift */; }; + FABB25FA2602FC2C00C8785C /* ImmuTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1EBC36E1C118EA500F638E0 /* ImmuTable.swift */; }; + FABB25FB2602FC2C00C8785C /* PersonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B59B18741CC7FB8D0055EB7C /* PersonViewController.swift */; }; + FABB25FC2602FC2C00C8785C /* StreakInsightStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054F45C2214F50300D261AB /* StreakInsightStatsRecordValue+CoreDataProperties.swift */; }; + FABB25FD2602FC2C00C8785C /* JetpackRemoteInstallState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8ECE0A2254A3260043C8DA /* JetpackRemoteInstallState.swift */; }; + FABB25FE2602FC2C00C8785C /* Scheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D78238206ABD970015A3A1 /* Scheduler.swift */; }; + FABB25FF2602FC2C00C8785C /* RegisterDomainDetailsViewModel+RowDefinitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D56122117312700CEAA33 /* RegisterDomainDetailsViewModel+RowDefinitions.swift */; }; + FABB26002602FC2C00C8785C /* GravatarProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FACB1D1EC675E300284AC7 /* GravatarProfile.swift */; }; + FABB26012602FC2C00C8785C /* ReaderShareAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8212CB620AA7703008E8AE8 /* ReaderShareAction.swift */; }; + FABB26022602FC2C00C8785C /* PostingActivityMonth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9826AE8921B5CC7300C851FA /* PostingActivityMonth.swift */; }; + FABB26032602FC2C00C8785C /* StreakStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054F45D2214F50300D261AB /* StreakStatsRecordValue+CoreDataClass.swift */; }; + FABB26042602FC2C00C8785C /* ReaderTabItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F09CCAD24292EFD00D00A8C /* ReaderTabItem.swift */; }; + FABB26062602FC2C00C8785C /* TopCommentsAuthorStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4054F4402213635000D261AB /* TopCommentsAuthorStatsRecordValue+CoreDataClass.swift */; }; + FABB26072602FC2C00C8785C /* JetpackBackupService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAB8FD6D25AEB23600D5D54A /* JetpackBackupService.swift */; }; + FABB26082602FC2C00C8785C /* NotificationsViewController+JetpackPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8236EB4F2024ED8C007C7CF9 /* NotificationsViewController+JetpackPrompt.swift */; }; + FABB26092602FC2C00C8785C /* PostListFooterView.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D732F961AE84E3C00CD89E7 /* PostListFooterView.m */; }; + FABB260A2602FC2C00C8785C /* ThemeBrowserSearchHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1767494D1D3633A000B8D1D1 /* ThemeBrowserSearchHeaderView.swift */; }; + FABB260B2602FC2C00C8785C /* MenuItemInsertionView.m in Sources */ = {isa = PBXBuildFile; fileRef = 0857C2721CE5375F0014AE99 /* MenuItemInsertionView.m */; }; + FABB260C2602FC2C00C8785C /* AllTimeStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40E7FECB2211FFA60032834E /* AllTimeStatsRecordValue+CoreDataClass.swift */; }; + FABB260D2602FC2C00C8785C /* GutenbergMediaEditorImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B05D29223AA572A0063B9AA /* GutenbergMediaEditorImage.swift */; }; + FABB260E2602FC2C00C8785C /* NoteBlockHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B532D4E6199D4357006E4DF6 /* NoteBlockHeaderTableViewCell.swift */; }; + FABB260F2602FC2C00C8785C /* RemoteFeatureFlagStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24ADA24B24F9A4CB001B5DAE /* RemoteFeatureFlagStore.swift */; }; + FABB26102602FC2C00C8785C /* SuggestionsTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 319D6E8419E44F7F0013871C /* SuggestionsTableViewCell.m */; }; + FABB26112602FC2C00C8785C /* LastPostStatsRecordValue+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A71C63220E1951002E3D25 /* LastPostStatsRecordValue+CoreDataClass.swift */; }; + FABB26122602FC2C00C8785C /* ActivityRangesFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4A773520F80146001C706D /* ActivityRangesFactory.swift */; }; + FABB26132602FC2C00C8785C /* SFHFKeychainUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; + FABB26142602FC2C00C8785C /* CircularProgressView+ActivityIndicatorType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EDAB3F320B046FE002D1A76 /* CircularProgressView+ActivityIndicatorType.swift */; }; + FABB26152602FC2C00C8785C /* CocoaLumberjack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */; }; + FABB26162602FC2C00C8785C /* WPUploadStatusButton.m in Sources */ = {isa = PBXBuildFile; fileRef = 740BD8341A0D4C3600F04D18 /* WPUploadStatusButton.m */; }; + FABB26172602FC2C00C8785C /* MediaExternalExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EAD7CCF206D761200BEDCFD /* MediaExternalExporter.swift */; }; + FABB26182602FC2C00C8785C /* RegisterDomainDetailsViewModel+SectionDefinitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436D56102117312700CEAA33 /* RegisterDomainDetailsViewModel+SectionDefinitions.swift */; }; + FABB26192602FC2C00C8785C /* ActionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53FF3A723EA723D001AD596 /* ActionRow.swift */; }; + FABB261A2602FC2C00C8785C /* AppSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA162301CB7031A00E2E110 /* AppSettingsViewController.swift */; }; + FABB261B2602FC2C00C8785C /* ReaderPostStreamService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B16CE9925251C89007BE5A9 /* ReaderPostStreamService.swift */; }; + FABB261C2602FC2C00C8785C /* ReferrerStatsRecordValue+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400A2C7D2217A985000A8A59 /* ReferrerStatsRecordValue+CoreDataProperties.swift */; }; + FABB261E2602FC2C00C8785C /* PostCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57AA848E228715DA00D3C2A2 /* PostCardCell.swift */; }; + FABB26202602FC2C00C8785C /* iAd.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E21C760202BBC8D00837CF5 /* iAd.framework */; }; + FABB26212602FC2C00C8785C /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8298F3911EEF3BA7008EB7F0 /* StoreKit.framework */; }; + FABB26222602FC2C00C8785C /* CoreSpotlight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F2E5431E9E5A570050D489 /* CoreSpotlight.framework */; }; + FABB26232602FC2C00C8785C /* QuickLook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F2E5411E9E5A350050D489 /* QuickLook.framework */; }; + FABB26242602FC2C00C8785C /* CoreText.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E131CB5116CACA6B004B0314 /* CoreText.framework */; }; + FABB26252602FC2C00C8785C /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E185474D1DED8D8800D875D7 /* UserNotifications.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; + FABB26262602FC2C00C8785C /* Photos.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FF75933A1BE2423800814D3B /* Photos.framework */; }; + FABB26272602FC2C00C8785C /* NotificationCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93E5283B19A7741A003A1A9C /* NotificationCenter.framework */; }; + FABB26282602FC2C00C8785C /* CoreTelephony.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93A3F7DD1843F6F00082FEEA /* CoreTelephony.framework */; }; + FABB26292602FC2C00C8785C /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A01C542D0E24E88400D411F2 /* SystemConfiguration.framework */; }; + FABB262A2602FC2C00C8785C /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 374CB16115B93C0800DD0EBC /* AudioToolbox.framework */; }; + FABB262B2602FC2C00C8785C /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 834CE7371256D0F60046A4A3 /* CoreGraphics.framework */; }; + FABB262C2602FC2C00C8785C /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E10B3653158F2D4500419A93 /* UIKit.framework */; }; + FABB262D2602FC2C00C8785C /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E10B3651158F2D3F00419A93 /* QuartzCore.framework */; }; + FABB262E2602FC2C00C8785C /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83FB4D3E122C38F700DB9506 /* MediaPlayer.framework */; }; + FABB262F2602FC2C00C8785C /* CoreMedia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1A386C914DB05F700954CF8 /* CoreMedia.framework */; }; + FABB26302602FC2C00C8785C /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1A386C714DB05C300954CF8 /* AVFoundation.framework */; }; + FABB26312602FC2C00C8785C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D30AB110D05D00D00671497 /* Foundation.framework */; }; + FABB26322602FC2C00C8785C /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 296890770FE971DC00770264 /* Security.framework */; }; + FABB26332602FC2C00C8785C /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83F3E25F11275E07004CD686 /* MapKit.framework */; }; + FABB26342602FC2C00C8785C /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83F3E2D211276371004CD686 /* CoreLocation.framework */; }; + FABB26352602FC2C00C8785C /* WordPressFlux in Frameworks */ = {isa = PBXBuildFile; productRef = FABB1FA62602FC2C00C8785C /* WordPressFlux */; }; + FABB26362602FC2C00C8785C /* CoreData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8355D7D811D260AA00A61362 /* CoreData.framework */; }; + FABB26372602FC2C00C8785C /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 834CE7331256D0DE0046A4A3 /* CFNetwork.framework */; }; + FABB26382602FC2C00C8785C /* MessageUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83043E54126FA31400EC9953 /* MessageUI.framework */; }; + FABB26392602FC2C00C8785C /* ImageIO.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD3D6D2B1349F5D30061136A /* ImageIO.framework */; }; + FABB263A2602FC2C00C8785C /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AA54D41A8E7510003BDD12 /* WebKit.framework */; }; + FABB263B2602FC2C00C8785C /* libsqlite3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 93F2E53F1E9E5A180050D489 /* libsqlite3.tbd */; }; + FABB263C2602FC2C00C8785C /* libiconv.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = FD21397E13128C5300099582 /* libiconv.dylib */; }; + FABB263D2602FC2C00C8785C /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = E19DF740141F7BDD000002F3 /* libz.dylib */; }; + FABB263F2602FC2C00C8785C /* CoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FF4DEAD7244B56E200ACA032 /* CoreServices.framework */; }; + FABB28472603067C00C8785C /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FABB28462603067C00C8785C /* Launch Screen.storyboard */; }; + FABB286C2603086900C8785C /* AppImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = FABB286B2603086900C8785C /* AppImages.xcassets */; }; + FAC086D725EDFB1E00B94F2A /* ReaderRelatedPostsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC086D525EDFB1E00B94F2A /* ReaderRelatedPostsCell.swift */; }; + FAC086D825EDFB1E00B94F2A /* ReaderRelatedPostsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FAC086D625EDFB1E00B94F2A /* ReaderRelatedPostsCell.xib */; }; + FAC1B81E29B0C2AC00E0C542 /* BlazeOverlayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC1B81D29B0C2AC00E0C542 /* BlazeOverlayViewModel.swift */; }; + FAC1B81F29B0C2AC00E0C542 /* BlazeOverlayViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC1B81D29B0C2AC00E0C542 /* BlazeOverlayViewModel.swift */; }; + FAC1B82729B1F1EE00E0C542 /* BlazePostPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC1B82629B1F1EE00E0C542 /* BlazePostPreviewView.swift */; }; + FAC1B82829B1F1EE00E0C542 /* BlazePostPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC1B82629B1F1EE00E0C542 /* BlazePostPreviewView.swift */; }; FACB36F11C5C2BF800C6DF4E /* ThemeWebNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACB36F01C5C2BF800C6DF4E /* ThemeWebNavigationDelegate.swift */; }; + FAD2538F26116A1600EDAF88 /* AppStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD2538E26116A1600EDAF88 /* AppStyleGuide.swift */; }; + FAD2539026116A1600EDAF88 /* AppStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD2538E26116A1600EDAF88 /* AppStyleGuide.swift */; }; + FAD2539126116A1600EDAF88 /* AppStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD2538E26116A1600EDAF88 /* AppStyleGuide.swift */; }; + FAD2544226116CEA00EDAF88 /* AppStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD2544126116CEA00EDAF88 /* AppStyleGuide.swift */; }; + FAD2566F2611AEC700EDAF88 /* AppStyleGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD2538E26116A1600EDAF88 /* AppStyleGuide.swift */; }; + FAD256932611B01700EDAF88 /* UIColor+WordPressColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD256922611B01700EDAF88 /* UIColor+WordPressColors.swift */; }; + FAD256A62611B01A00EDAF88 /* UIColor+WordPressColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD256922611B01700EDAF88 /* UIColor+WordPressColors.swift */; }; + FAD256B82611B01B00EDAF88 /* UIColor+WordPressColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD256922611B01700EDAF88 /* UIColor+WordPressColors.swift */; }; + FAD257132611B04D00EDAF88 /* UIColor+JetpackColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD257112611B04D00EDAF88 /* UIColor+JetpackColors.swift */; }; + FAD257F52611B54200EDAF88 /* UIColor+WordPressColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD256922611B01700EDAF88 /* UIColor+WordPressColors.swift */; }; + FAD7625B29ED780B00C09583 /* JSONDecoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD7625A29ED780B00C09583 /* JSONDecoderExtension.swift */; }; + FAD7625C29ED780B00C09583 /* JSONDecoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD7625A29ED780B00C09583 /* JSONDecoderExtension.swift */; }; + FAD7626429F1480E00C09583 /* DashboardActivityLogCardCell+ActivityPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD7626329F1480E00C09583 /* DashboardActivityLogCardCell+ActivityPresenter.swift */; }; + FAD7626529F1480E00C09583 /* DashboardActivityLogCardCell+ActivityPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD7626329F1480E00C09583 /* DashboardActivityLogCardCell+ActivityPresenter.swift */; }; + FAD9457E25B5647B00F011B5 /* JetpackBackupOptionsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD9457D25B5647B00F011B5 /* JetpackBackupOptionsCoordinator.swift */; }; + FAD9458E25B5678700F011B5 /* JetpackRestoreWarningCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD9458D25B5678700F011B5 /* JetpackRestoreWarningCoordinator.swift */; }; + FAD951A425B6CB3600F011B5 /* JetpackRestoreFailedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD951A325B6CB3600F011B5 /* JetpackRestoreFailedViewController.swift */; }; + FAD954B825B7A99900F011B5 /* JetpackBackupStatusFailedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD954B725B7A99900F011B5 /* JetpackBackupStatusFailedViewController.swift */; }; + FAD95D2725B91BCF00F011B5 /* JetpackRestoreStatusFailedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAD95D2625B91BCF00F011B5 /* JetpackRestoreStatusFailedViewController.swift */; }; + FADFBD26265F580500039C41 /* MultilineButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADFBD25265F580500039C41 /* MultilineButton.swift */; }; + FADFBD27265F580500039C41 /* MultilineButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADFBD25265F580500039C41 /* MultilineButton.swift */; }; + FADFBD3B265F5B2E00039C41 /* WPStyleGuide+Jetpack.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADFBD3A265F5B2E00039C41 /* WPStyleGuide+Jetpack.swift */; }; + FADFBD3C265F5B2E00039C41 /* WPStyleGuide+Jetpack.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADFBD3A265F5B2E00039C41 /* WPStyleGuide+Jetpack.swift */; }; FAE4201A1C5AEFE100C1D036 /* StartOverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE420191C5AEFE100C1D036 /* StartOverViewController.swift */; }; + FAE4327425874D140039EB8C /* ReaderSavedPostCellActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE4327325874D140039EB8C /* ReaderSavedPostCellActions.swift */; }; + FAE4CA682732C094003BFDFE /* QuickStartPromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE4CA662732C094003BFDFE /* QuickStartPromptViewController.swift */; }; + FAE4CA692732C094003BFDFE /* QuickStartPromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE4CA662732C094003BFDFE /* QuickStartPromptViewController.swift */; }; + FAE4CA6A2732C094003BFDFE /* QuickStartPromptViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = FAE4CA672732C094003BFDFE /* QuickStartPromptViewController.xib */; }; + FAE4CA6B2732C094003BFDFE /* QuickStartPromptViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = FAE4CA672732C094003BFDFE /* QuickStartPromptViewController.xib */; }; + FAE8EE99273AC06F00A65307 /* QuickStartSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE8EE98273AC06F00A65307 /* QuickStartSettings.swift */; }; + FAE8EE9A273AC06F00A65307 /* QuickStartSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE8EE98273AC06F00A65307 /* QuickStartSettings.swift */; }; + FAE8EE9C273AD0A800A65307 /* QuickStartSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE8EE9B273AD0A800A65307 /* QuickStartSettingsTests.swift */; }; + FAF13C5325A57ABD003EE470 /* JetpackRestoreWarningViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF13C5225A57ABD003EE470 /* JetpackRestoreWarningViewController.swift */; }; + FAF13E3025A59240003EE470 /* JetpackRestoreStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF13E2F25A59240003EE470 /* JetpackRestoreStatusViewController.swift */; }; + FAF64B6F2637DEEC00E8A1DF /* BaseScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2B4E9E1FD664F5007AE3E4 /* BaseScreen.swift */; }; + FAF64B722637DEEC00E8A1DF /* XCTest+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2716A01CABC7D40006E2D4 /* XCTest+Extensions.swift */; }; + FAF64B742637DEEC00E8A1DF /* WPUITestCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC8A5EAA22159FA6001B7874 /* WPUITestCredentials.swift */; }; + FAF64B7B2637DEEC00E8A1DF /* LoginFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BED4D8321FF11E3800A11345 /* LoginFlow.swift */; }; + FAF64B922637DEEC00E8A1DF /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8511CFC41C60884400B7CEED /* SnapshotHelper.swift */; }; + FAF64B982637DEEC00E8A1DF /* ScreenshotCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9463A7221C05EE90081F11E /* ScreenshotCredentials.swift */; }; + FAF64E4F2637E85800E8A1DF /* JetpackScreenshotGeneration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAF64E4E2637E85800E8A1DF /* JetpackScreenshotGeneration.swift */; }; + FAFC064B27D22E4C002F0483 /* QuickStartTourStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFC064A27D22E4C002F0483 /* QuickStartTourStateView.swift */; }; + FAFC064C27D22E4C002F0483 /* QuickStartTourStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFC064A27D22E4C002F0483 /* QuickStartTourStateView.swift */; }; + FAFC064E27D2360B002F0483 /* QuickStartCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFC064D27D2360B002F0483 /* QuickStartCell.swift */; }; + FAFC064F27D2360B002F0483 /* QuickStartCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFC064D27D2360B002F0483 /* QuickStartCell.swift */; }; + FAFC065127D27241002F0483 /* BlogDetailsViewController+Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFC065027D27241002F0483 /* BlogDetailsViewController+Dashboard.swift */; }; + FAFC065227D27241002F0483 /* BlogDetailsViewController+Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFC065027D27241002F0483 /* BlogDetailsViewController+Dashboard.swift */; }; FAFF153D1C98962E007D1C90 /* SiteSettingsViewController+SiteManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAFF153C1C98962E007D1C90 /* SiteSettingsViewController+SiteManagement.swift */; }; FD21397F13128C5300099582 /* libiconv.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = FD21397E13128C5300099582 /* libiconv.dylib */; }; FD3D6D2C1349F5D30061136A /* ImageIO.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD3D6D2B1349F5D30061136A /* ImageIO.framework */; }; + FE003F5D282D61BA006F8D1D /* BloggingPrompt+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003F5B282D61B9006F8D1D /* BloggingPrompt+CoreDataClass.swift */; }; + FE003F5E282D61BA006F8D1D /* BloggingPrompt+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003F5B282D61B9006F8D1D /* BloggingPrompt+CoreDataClass.swift */; }; + FE003F5F282D61BA006F8D1D /* BloggingPrompt+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003F5C282D61B9006F8D1D /* BloggingPrompt+CoreDataProperties.swift */; }; + FE003F60282D61BA006F8D1D /* BloggingPrompt+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE003F5C282D61B9006F8D1D /* BloggingPrompt+CoreDataProperties.swift */; }; + FE003F62282E73E6006F8D1D /* blogging-prompts-fetch-success.json in Resources */ = {isa = PBXBuildFile; fileRef = FE003F61282E73E6006F8D1D /* blogging-prompts-fetch-success.json */; }; + FE02F95F269DC14A00752A44 /* Comment+Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE02F95E269DC14A00752A44 /* Comment+Interface.swift */; }; + FE06AC8326C3BD0900B69DE4 /* ShareAppContentPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE06AC8226C3BD0900B69DE4 /* ShareAppContentPresenter.swift */; }; + FE06AC8526C3C2F800B69DE4 /* ShareAppTextActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE06AC8426C3C2F800B69DE4 /* ShareAppTextActivityItemSource.swift */; }; + FE18495827F5ACBA00D26879 /* DashboardPromptsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE18495727F5ACBA00D26879 /* DashboardPromptsCardCell.swift */; }; + FE23EB4926E7C91F005A1698 /* richCommentTemplate.html in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */; }; + FE23EB4A26E7C91F005A1698 /* richCommentTemplate.html in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */; }; + FE23EB4B26E7C91F005A1698 /* richCommentStyle.css in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4826E7C91F005A1698 /* richCommentStyle.css */; }; + FE23EB4C26E7C91F005A1698 /* richCommentStyle.css in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4826E7C91F005A1698 /* richCommentStyle.css */; }; + FE25C235271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE25C234271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift */; }; + FE25C236271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE25C234271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift */; }; + FE29EFCD29A91160007CE034 /* WPAdminConvertibleRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE29EFCC29A91160007CE034 /* WPAdminConvertibleRouter.swift */; }; + FE29EFCE29A91160007CE034 /* WPAdminConvertibleRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE29EFCC29A91160007CE034 /* WPAdminConvertibleRouter.swift */; }; + FE2E3729281C839C00A1E82A /* BloggingPromptsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2E3728281C839C00A1E82A /* BloggingPromptsServiceTests.swift */; }; + FE320CC5294705990046899B /* ReaderPostBackupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE320CC4294705990046899B /* ReaderPostBackupTests.swift */; }; + FE32E7F12844971000744D80 /* ReminderScheduleCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE32E7F02844971000744D80 /* ReminderScheduleCoordinatorTests.swift */; }; + FE32EFFF275914390040BE67 /* MenuSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE32EFFE275914390040BE67 /* MenuSheetViewController.swift */; }; + FE32F000275914390040BE67 /* MenuSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE32EFFE275914390040BE67 /* MenuSheetViewController.swift */; }; + FE32F002275F602E0040BE67 /* CommentContentRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE32F001275F602E0040BE67 /* CommentContentRenderer.swift */; }; + FE32F003275F602E0040BE67 /* CommentContentRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE32F001275F602E0040BE67 /* CommentContentRenderer.swift */; }; + FE32F006275F62620040BE67 /* WebCommentContentRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE32F005275F62620040BE67 /* WebCommentContentRenderer.swift */; }; + FE32F007275F62620040BE67 /* WebCommentContentRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE32F005275F62620040BE67 /* WebCommentContentRenderer.swift */; }; + FE341705275FA157005D5CA7 /* RichCommentContentRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE341704275FA157005D5CA7 /* RichCommentContentRenderer.swift */; }; + FE341706275FA157005D5CA7 /* RichCommentContentRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE341704275FA157005D5CA7 /* RichCommentContentRenderer.swift */; }; + FE39C135269C37C900EFB827 /* ListTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FE39C133269C37C900EFB827 /* ListTableViewCell.xib */; }; + FE39C136269C37C900EFB827 /* ListTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE39C134269C37C900EFB827 /* ListTableViewCell.swift */; }; + FE3D057E26C3D5C1002A51B0 /* ShareAppContentPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3D057D26C3D5C1002A51B0 /* ShareAppContentPresenterTests.swift */; }; + FE3D058026C3E0F2002A51B0 /* share-app-link-success.json in Resources */ = {isa = PBXBuildFile; fileRef = FE3D057F26C3E0F2002A51B0 /* share-app-link-success.json */; }; + FE3D058326C419C4002A51B0 /* ShareAppContentPresenter+TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3D058126C40E66002A51B0 /* ShareAppContentPresenter+TableView.swift */; }; + FE3D058426C419C7002A51B0 /* ShareAppContentPresenter+TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3D058126C40E66002A51B0 /* ShareAppContentPresenter+TableView.swift */; }; + FE3E427826A868E300C596CE /* ListTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FE39C133269C37C900EFB827 /* ListTableViewCell.xib */; }; + FE3E427926A868EC00C596CE /* ListTableHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FEA088022696E81F00193358 /* ListTableHeaderView.xib */; }; + FE3E427A26A8690100C596CE /* ListSimpleOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E83E326A58646008CE851 /* ListSimpleOverlayView.swift */; }; + FE3E427B26A8690A00C596CE /* ListSimpleOverlayView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FE3E83E426A58646008CE851 /* ListSimpleOverlayView.xib */; }; + FE3E83E526A58646008CE851 /* ListSimpleOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE3E83E326A58646008CE851 /* ListSimpleOverlayView.swift */; }; + FE3E83E626A58646008CE851 /* ListSimpleOverlayView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FE3E83E426A58646008CE851 /* ListSimpleOverlayView.xib */; }; + FE43DAAF26DFAD1C00CFF595 /* CommentContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE43DAAD26DFAD1C00CFF595 /* CommentContentTableViewCell.swift */; }; + FE43DAB026DFAD1C00CFF595 /* CommentContentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE43DAAD26DFAD1C00CFF595 /* CommentContentTableViewCell.swift */; }; + FE43DAB126DFAD1C00CFF595 /* CommentContentTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FE43DAAE26DFAD1C00CFF595 /* CommentContentTableViewCell.xib */; }; + FE43DAB226DFAD1C00CFF595 /* CommentContentTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FE43DAAE26DFAD1C00CFF595 /* CommentContentTableViewCell.xib */; }; + FE4C46FF27FAE61700285F35 /* DashboardPromptsCardCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE18495727F5ACBA00D26879 /* DashboardPromptsCardCell.swift */; }; + FE4DC5A3293A75FC008F322F /* MigrationDeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4DC5A2293A75FC008F322F /* MigrationDeepLinkRouter.swift */; }; + FE4DC5A7293A79F1008F322F /* WordPressExportRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4DC5A6293A79F1008F322F /* WordPressExportRoute.swift */; }; + FE4DC5A8293A84E6008F322F /* MigrationDeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4DC5A2293A75FC008F322F /* MigrationDeepLinkRouter.swift */; }; + FE4DC5AA293A84F8008F322F /* WordPressExportRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE4DC5A6293A79F1008F322F /* WordPressExportRoute.swift */; }; + FE6BB143293227AC001E5F7A /* ContentMigrationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE6BB142293227AC001E5F7A /* ContentMigrationCoordinator.swift */; }; + FE6BB144293227AC001E5F7A /* ContentMigrationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE6BB142293227AC001E5F7A /* ContentMigrationCoordinator.swift */; }; + FE6BB1462932289B001E5F7A /* ContentMigrationCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE6BB1452932289B001E5F7A /* ContentMigrationCoordinatorTests.swift */; }; + FE76C5E0293A63A800573C92 /* UIApplication+AppAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AB4878292F114A001F7AF8 /* UIApplication+AppAvailability.swift */; }; + FE7FAABB299A36570032A6F2 /* WPComJetpackRemoteInstallViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7FAABA299A36570032A6F2 /* WPComJetpackRemoteInstallViewModelTests.swift */; }; + FE7FAABE299A998E0032A6F2 /* EventTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7FAABC299A98B90032A6F2 /* EventTracker.swift */; }; + FE7FAABF299A998F0032A6F2 /* EventTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7FAABC299A98B90032A6F2 /* EventTracker.swift */; }; + FE9CC71A26D7A2A40026AEF3 /* CommentDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9CC71926D7A2A40026AEF3 /* CommentDetailViewController.swift */; }; + FE9CC71B26D7A2A40026AEF3 /* CommentDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE9CC71926D7A2A40026AEF3 /* CommentDetailViewController.swift */; }; + FEA088012696E7F600193358 /* ListTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA088002696E7F600193358 /* ListTableHeaderView.swift */; }; + FEA088032696E81F00193358 /* ListTableHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = FEA088022696E81F00193358 /* ListTableHeaderView.xib */; }; + FEA088052696F7AA00193358 /* WPStyleGuide+List.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA088042696F7AA00193358 /* WPStyleGuide+List.swift */; }; + FEA1123C29944896008097B0 /* SelfHostedJetpackRemoteInstallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA1123B29944896008097B0 /* SelfHostedJetpackRemoteInstallViewModel.swift */; }; + FEA1123D29944896008097B0 /* SelfHostedJetpackRemoteInstallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA1123B29944896008097B0 /* SelfHostedJetpackRemoteInstallViewModel.swift */; }; + FEA1123F29964BCA008097B0 /* WPComJetpackRemoteInstallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA1123E29964BCA008097B0 /* WPComJetpackRemoteInstallViewModel.swift */; }; + FEA1124029964BCA008097B0 /* WPComJetpackRemoteInstallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA1123E29964BCA008097B0 /* WPComJetpackRemoteInstallViewModel.swift */; }; + FEA312842987FB0100FFD405 /* BlogJetpackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA312832987FB0100FFD405 /* BlogJetpackTests.swift */; }; + FEA5CE7F2701DC8000B41F2A /* AppImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 433840C622C2BA5B00CB13F8 /* AppImages.xcassets */; }; + FEA6517B281C491C002EA086 /* BloggingPromptsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA6517A281C491C002EA086 /* BloggingPromptsService.swift */; }; + FEA6517C281C491C002EA086 /* BloggingPromptsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA6517A281C491C002EA086 /* BloggingPromptsService.swift */; }; + FEA7948D26DD136700CEC520 /* CommentHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA7948C26DD136700CEC520 /* CommentHeaderTableViewCell.swift */; }; + FEA7948E26DD136700CEC520 /* CommentHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA7948C26DD136700CEC520 /* CommentHeaderTableViewCell.swift */; }; + FEA7949026DF7F3700CEC520 /* WPStyleGuide+CommentDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA7948F26DF7F3700CEC520 /* WPStyleGuide+CommentDetail.swift */; }; + FEA7949126DF7F3700CEC520 /* WPStyleGuide+CommentDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEA7948F26DF7F3700CEC520 /* WPStyleGuide+CommentDetail.swift */; }; + FEAA6F79298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAA6F78298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift */; }; + FEAC916E28001FC4005026E7 /* AvatarTrainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAC916D28001FC4005026E7 /* AvatarTrainView.swift */; }; + FEAC916F28001FC4005026E7 /* AvatarTrainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAC916D28001FC4005026E7 /* AvatarTrainView.swift */; }; + FEC26030283FBA1A003D886A /* BloggingPromptCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC2602F283FBA1A003D886A /* BloggingPromptCoordinator.swift */; }; + FEC26031283FBA1A003D886A /* BloggingPromptCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC2602F283FBA1A003D886A /* BloggingPromptCoordinator.swift */; }; + FEC26033283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC26032283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift */; }; + FEC26034283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC26032283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift */; }; + FECA442F28350B7800D01F15 /* PromptRemindersScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECA442E28350B7800D01F15 /* PromptRemindersScheduler.swift */; }; + FECA443028350B7800D01F15 /* PromptRemindersScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECA442E28350B7800D01F15 /* PromptRemindersScheduler.swift */; }; + FECA44322836647100D01F15 /* PromptRemindersSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECA44312836647100D01F15 /* PromptRemindersSchedulerTests.swift */; }; + FED65D79293511E4008071BF /* SharedDataIssueSolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED65D78293511E4008071BF /* SharedDataIssueSolver.swift */; }; + FED77258298BC5B300C2346E /* PluginJetpackProxyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED77257298BC5B300C2346E /* PluginJetpackProxyService.swift */; }; + FED77259298BC5B300C2346E /* PluginJetpackProxyService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED77257298BC5B300C2346E /* PluginJetpackProxyService.swift */; }; + FEDA1AD8269D475D0038EC98 /* ListTableViewCell+Comments.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDA1AD7269D475D0038EC98 /* ListTableViewCell+Comments.swift */; }; + FEDA1AD9269D475D0038EC98 /* ListTableViewCell+Comments.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDA1AD7269D475D0038EC98 /* ListTableViewCell+Comments.swift */; }; + FEDDD46F26A03DE900F8942B /* ListTableViewCell+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDDD46E26A03DE900F8942B /* ListTableViewCell+Notifications.swift */; }; + FEDDD47026A03DE900F8942B /* ListTableViewCell+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDDD46E26A03DE900F8942B /* ListTableViewCell+Notifications.swift */; }; + FEF4DC5528439357003806BE /* ReminderScheduleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF4DC5428439357003806BE /* ReminderScheduleCoordinator.swift */; }; + FEF4DC5628439357003806BE /* ReminderScheduleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEF4DC5428439357003806BE /* ReminderScheduleCoordinator.swift */; }; + FEFA263E26C58427009CCB7E /* ShareAppTextActivityItemSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFA263D26C58427009CCB7E /* ShareAppTextActivityItemSourceTests.swift */; }; + FEFA263F26C5AE9A009CCB7E /* ShareAppContentPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE06AC8226C3BD0900B69DE4 /* ShareAppContentPresenter.swift */; }; + FEFA264026C5AE9E009CCB7E /* ShareAppTextActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE06AC8426C3C2F800B69DE4 /* ShareAppTextActivityItemSource.swift */; }; + FEFC0F892731182C001F7F1D /* CommentService+Replies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFC0F882731182C001F7F1D /* CommentService+Replies.swift */; }; + FEFC0F8A2731182C001F7F1D /* CommentService+Replies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFC0F882731182C001F7F1D /* CommentService+Replies.swift */; }; + FEFC0F8C273131A6001F7F1D /* CommentService+RepliesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFC0F8B273131A6001F7F1D /* CommentService+RepliesTests.swift */; }; + FEFC0F8E27313DD0001F7F1D /* comments-v2-success.json in Resources */ = {isa = PBXBuildFile; fileRef = FEFC0F8D27313DCF001F7F1D /* comments-v2-success.json */; }; + FEFC0F9027315634001F7F1D /* empty-array.json in Resources */ = {isa = PBXBuildFile; fileRef = FEFC0F8F27315634001F7F1D /* empty-array.json */; }; FF00889B204DF3ED007CCE66 /* Blog+Quota.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF00889A204DF3ED007CCE66 /* Blog+Quota.swift */; }; FF00889D204DFF77007CCE66 /* MediaQuotaCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = FF00889C204DFF77007CCE66 /* MediaQuotaCell.xib */; }; FF00889F204E01AE007CCE66 /* MediaQuotaCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF00889E204E01AE007CCE66 /* MediaQuotaCell.swift */; }; @@ -2128,7 +5540,7 @@ FF0AAE0A1A150A560089841D /* WPProgressTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = FF0AAE091A150A560089841D /* WPProgressTableViewCell.m */; }; FF0B2567237A023C004E255F /* GutenbergVideoUploadProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0B2566237A023C004E255F /* GutenbergVideoUploadProcessorTests.swift */; }; FF0D8146205809C8000EE505 /* PostCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0D8145205809C8000EE505 /* PostCoordinator.swift */; }; - FF0F722C206E5345000DAAB5 /* PostService+RefreshStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0F722B206E5345000DAAB5 /* PostService+RefreshStatus.swift */; }; + FF0F722C206E5345000DAAB5 /* Post+RefreshStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF0F722B206E5345000DAAB5 /* Post+RefreshStatus.swift */; }; FF1933FF1BB17DA3006825B8 /* RelatedPostsPreviewTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = FF1933FE1BB17DA3006825B8 /* RelatedPostsPreviewTableViewCell.m */; }; FF1B11E5238FDFE70038B93E /* GutenbergGalleryUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1B11E4238FDFE70038B93E /* GutenbergGalleryUploadProcessor.swift */; }; FF1B11E7238FE27A0038B93E /* GutenbergGalleryUploadProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1B11E6238FE27A0038B93E /* GutenbergGalleryUploadProcessorTests.swift */; }; @@ -2137,13 +5549,13 @@ FF2716921CAAC87B0006E2D4 /* LoginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2716911CAAC87B0006E2D4 /* LoginTests.swift */; }; FF2716A11CABC7D40006E2D4 /* XCTest+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2716A01CABC7D40006E2D4 /* XCTest+Extensions.swift */; }; FF286C761DE70A4500383A62 /* NSURL+Exporters.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF286C751DE70A4500383A62 /* NSURL+Exporters.swift */; }; - FF28B3F11AEB251200E11AAE /* InfoPListTranslator.m in Sources */ = {isa = PBXBuildFile; fileRef = FF28B3F01AEB251200E11AAE /* InfoPListTranslator.m */; }; FF2EC3C02209A144006176E1 /* GutenbergImgUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EC3BF2209A144006176E1 /* GutenbergImgUploadProcessor.swift */; }; FF2EC3C22209AC19006176E1 /* GutenbergImgUploadProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2EC3C12209AC19006176E1 /* GutenbergImgUploadProcessorTests.swift */; }; FF355D981FB492DD00244E6D /* ExportableAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF355D971FB492DD00244E6D /* ExportableAsset.swift */; }; FF37F90922385CA000AFA3DB /* RELEASE-NOTES.txt in Resources */ = {isa = PBXBuildFile; fileRef = FF37F90822385C9F00AFA3DB /* RELEASE-NOTES.txt */; }; FF4258501BA092EE00580C68 /* RelatedPostsSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = FF42584F1BA092EE00580C68 /* RelatedPostsSettingsViewController.m */; }; FF4C069F206560E500E0B2BC /* MediaThumbnailCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4C069E206560E500E0B2BC /* MediaThumbnailCoordinator.swift */; }; + FF4DEAD8244B56E300ACA032 /* CoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FF4DEAD7244B56E200ACA032 /* CoreServices.framework */; }; FF5371631FDFF64F00619A3F /* MediaService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5371621FDFF64F00619A3F /* MediaService.swift */; }; FF54D4641D6F3FA900A0DC4D /* GutenbergSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF54D4631D6F3FA900A0DC4D /* GutenbergSettings.swift */; }; FF619DD51C75246900903B65 /* CLPlacemark+Formatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF619DD41C75246900903B65 /* CLPlacemark+Formatting.swift */; }; @@ -2152,7 +5564,6 @@ FF75933B1BE2423800814D3B /* Photos.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FF75933A1BE2423800814D3B /* Photos.framework */; }; FF7C89A31E3A1029000472A8 /* MediaLibraryPickerDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7C89A21E3A1029000472A8 /* MediaLibraryPickerDataSourceTests.swift */; }; FF8032661EE9E22200861F28 /* MediaProgressCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8032651EE9E22200861F28 /* MediaProgressCoordinatorTests.swift */; }; - FF854B8D1FD16DA20043BB2B /* WordPressFlux.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = E19FED0B1FBD85F300D77FAB /* WordPressFlux.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; FF8791BB1FBAF4B500AD86E6 /* MediaService+Swift.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8791BA1FBAF4B400AD86E6 /* MediaService+Swift.swift */; }; FF8A04E01D9BFE7400523BC4 /* CachedAnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8A04DF1D9BFE7400523BC4 /* CachedAnimatedImageView.swift */; }; FF8C54AD21F677260003ABCF /* GutenbergMediaInserterHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF8C54AC21F677260003ABCF /* GutenbergMediaInserterHelper.swift */; }; @@ -2160,17 +5571,13 @@ FF8DDCDF1B5DB1C10098826F /* SettingTableViewCell.m in Sources */ = {isa = PBXBuildFile; fileRef = FF8DDCDE1B5DB1C10098826F /* SettingTableViewCell.m */; }; FF945F701B28242300FB8AC4 /* MediaLibraryPickerDataSource.m in Sources */ = {isa = PBXBuildFile; fileRef = FF945F6F1B28242300FB8AC4 /* MediaLibraryPickerDataSource.m */; }; FF9A6E7121F9361700D36D14 /* MediaUploadHashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9A6E7021F9361700D36D14 /* MediaUploadHashTests.swift */; }; - FF9C81C42375BA8100DC4B2F /* GutenbergBlockProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF9C81C32375BA8100DC4B2F /* GutenbergBlockProcessor.swift */; }; FFA0B7D71CAC1F9F00533B9D /* MainNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA0B7D61CAC1F9F00533B9D /* MainNavigationTests.swift */; }; FFA162311CB7031A00E2E110 /* AppSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA162301CB7031A00E2E110 /* AppSettingsViewController.swift */; }; FFABD800213423F1003C65B6 /* LinkSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFABD7FF213423F1003C65B6 /* LinkSettingsViewController.swift */; }; FFABD80821370496003C65B6 /* SelectPostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFABD80721370496003C65B6 /* SelectPostViewController.swift */; }; FFB1FA9E1BF0EB840090C761 /* UIImage+Exporters.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB1FA9D1BF0EB840090C761 /* UIImage+Exporters.swift */; }; FFB1FAA01BF0EC4E0090C761 /* PHAsset+Exporters.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB1FA9F1BF0EC4E0090C761 /* PHAsset+Exporters.swift */; }; - FFB7B8201A0012E80032E723 /* ApiCredentials.m in Sources */ = {isa = PBXBuildFile; fileRef = FFB7B81D1A0012E80032E723 /* ApiCredentials.m */; }; FFC02B83222687BF00E64FDE /* GutenbergImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC02B82222687BF00E64FDE /* GutenbergImageLoader.swift */; }; - FFC6ADD41B56F295002F3C84 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC6ADD21B56F295002F3C84 /* AboutViewController.swift */; }; - FFC6ADD51B56F295002F3C84 /* AboutViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = FFC6ADD31B56F295002F3C84 /* AboutViewController.xib */; }; FFC6ADDA1B56F366002F3C84 /* LocalCoreDataService.m in Sources */ = {isa = PBXBuildFile; fileRef = FFC6ADD91B56F366002F3C84 /* LocalCoreDataService.m */; }; FFCB9F4B22A125BD0080A45F /* WPException.m in Sources */ = {isa = PBXBuildFile; fileRef = FFCB9F4A22A125BD0080A45F /* WPException.m */; }; FFD12D5E1FE1998D00F20A00 /* Progress+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFD12D5D1FE1998D00F20A00 /* Progress+Helpers.swift */; }; @@ -2180,12 +5587,47 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 733F360E2126197800988727 /* PBXContainerItemProxy */ = { + 0107E0EC28F97E6100DE87DB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0107E0B128F97D5000DE87DB; + remoteInfo = JetpackStatsWidgets; + }; + 0107E15628FEA03800DE87DB /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 0107E13828FE9DB200DE87DB; + remoteInfo = JetpackIntents; + }; + 096A92FC26E2A0AE00448C68 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 096A92F526E29FFF00448C68; + remoteInfo = GenerateCredentials; + }; + 3F526C5A2538CF2B0069706C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3F526C4B2538CF2A0069706C; + remoteInfo = WordPressHomeWidgetTodayExtension; + }; + 3FCFFAFD2994A949002840C9 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = FFA8E22A1F94E3DE0002170F; + remoteInfo = SwiftLint; + }; + 3FCFFAFF2994AB25002840C9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; proxyType = 1; - remoteGlobalIDString = 733F36022126197800988727; - remoteInfo = WordPressNotificationContentExtension; + remoteGlobalIDString = FFA8E22A1F94E3DE0002170F; + remoteInfo = SwiftLint; }; 7358E6BD210BD318002323EB /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -2201,6 +5643,27 @@ remoteGlobalIDString = 74576671202B558C00F42E40; remoteInfo = WordPressDraftActionExtension; }; + 8096212628E5411400940A5D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 809620CF28E540D700940A5D; + remoteInfo = JetpackShareExtension; + }; + 8096218F28E55F8600940A5D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 8096213028E55C9400940A5D; + remoteInfo = JetpackDraftActionExtension; + }; + 80F6D05E28EE88FC00953C1A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 80F6D01F28EE866A00953C1A; + remoteInfo = JetpackNotificationServiceExtension; + }; 8511CFBB1C607A7000B7CEED /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; @@ -2215,40 +5678,33 @@ remoteGlobalIDString = 932225A61C7CE50300443B02; remoteInfo = WordPressShare; }; - 93E5284419A7741A003A1A9C /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; - proxyType = 1; - remoteGlobalIDString = 93E5283919A7741A003A1A9C; - remoteInfo = WordPressTodayWidget; - }; - 93E5284719A7741A003A1A9C /* PBXContainerItemProxy */ = { + E16AB93E14D978520047A2E5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; proxyType = 1; - remoteGlobalIDString = 93E5283919A7741A003A1A9C; - remoteInfo = WordPressTodayWidget; + remoteGlobalIDString = 1D6058900D05DD3D006BFB54; + remoteInfo = WordPress; }; - 98A3C2F8239997DA0048D38D /* PBXContainerItemProxy */ = { + EA14534429AD877C001F3143 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; proxyType = 1; - remoteGlobalIDString = 98A3C2EE239997DA0048D38D; - remoteInfo = WordPressThisWeekWidget; + remoteGlobalIDString = FABB1F8F2602FC2C00C8785C; + remoteInfo = Jetpack; }; - 98D31B972396ED7F009CFF43 /* PBXContainerItemProxy */ = { + F1F163D425658B4D003DC13B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; proxyType = 1; - remoteGlobalIDString = 98D31B8D2396ED7E009CFF43; - remoteInfo = WordPressAllTimeWidget; + remoteGlobalIDString = F1F163BD25658B4D003DC13B; + remoteInfo = WordPressIntents; }; - E16AB93E14D978520047A2E5 /* PBXContainerItemProxy */ = { + FAF64C392637E02700E8A1DF /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; proxyType = 1; - remoteGlobalIDString = 1D6058900D05DD3D006BFB54; - remoteInfo = WordPress; + remoteGlobalIDString = FABB1F8F2602FC2C00C8785C; + remoteInfo = Jetpack; }; FF2716941CAAC87B0006E2D4 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -2257,47 +5713,71 @@ remoteGlobalIDString = 1D6058900D05DD3D006BFB54; remoteInfo = WordPress; }; - FFC3F6FB1B0DBF7200EFC359 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 29B97313FDCFA39411CA2CEA /* Project object */; - proxyType = 1; - remoteGlobalIDString = FFC3F6F41B0DBF0900EFC359; - remoteInfo = UpdatePlistPreprocessor; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 832D4F01120A6F7C001708D4 /* CopyFiles */ = { + 93E5284E19A7741A003A1A9C /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; - dstSubfolderSpec = 10; + dstSubfolderSpec = 13; files = ( - FF854B8D1FD16DA20043BB2B /* WordPressFlux.framework in CopyFiles */, + 7457667C202B558C00F42E40 /* WordPressDraftActionExtension.appex in Embed Foundation Extensions */, + F1F163D625658B4D003DC13B /* WordPressIntents.appex in Embed Foundation Extensions */, + 7358E6BF210BD318002323EB /* WordPressNotificationServiceExtension.appex in Embed Foundation Extensions */, + 932225B11C7CE50300443B02 /* WordPressShareExtension.appex in Embed Foundation Extensions */, + 3F526C5C2538CF2B0069706C /* WordPressStatsWidgets.appex in Embed Foundation Extensions */, ); + name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; - 93E5284E19A7741A003A1A9C /* Embed App Extensions */ = { + FABB26402602FC2C00C8785C /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( - 98A3C2FA239997DA0048D38D /* WordPressThisWeekWidget.appex in Embed App Extensions */, - 7457667C202B558C00F42E40 /* WordPressDraftActionExtension.appex in Embed App Extensions */, - 7358E6BF210BD318002323EB /* WordPressNotificationServiceExtension.appex in Embed App Extensions */, - 733F36102126197800988727 /* WordPressNotificationContentExtension.appex in Embed App Extensions */, - 98D31B992396ED7F009CFF43 /* WordPressAllTimeWidget.appex in Embed App Extensions */, - 932225B11C7CE50300443B02 /* WordPressShareExtension.appex in Embed App Extensions */, - 93E5284619A7741A003A1A9C /* WordPressTodayWidget.appex in Embed App Extensions */, - ); - name = "Embed App Extensions"; + 8096212528E5411400940A5D /* JetpackShareExtension.appex in Embed Foundation Extensions */, + 0107E0EE28F97E6900DE87DB /* JetpackStatsWidgets.appex in Embed Foundation Extensions */, + 80F6D05D28EE88FC00953C1A /* JetpackNotificationServiceExtension.appex in Embed Foundation Extensions */, + 0107E15928FEB10E00DE87DB /* JetpackIntents.appex in Embed Foundation Extensions */, + 8096218E28E55F8600940A5D /* JetpackDraftActionExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0075A07B739C4EDB5EC613B8 /* Pods_WordPressAllTimeWidget.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressAllTimeWidget.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 010459E529153FFF000C7778 /* JetpackNotificationMigrationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackNotificationMigrationService.swift; sourceTree = ""; }; + 010459EC2915519C000C7778 /* JetpackNotificationMigrationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackNotificationMigrationServiceTests.swift; sourceTree = ""; }; + 0107E0EA28F97D5000DE87DB /* JetpackStatsWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = JetpackStatsWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0107E15428FE9DB200DE87DB /* JetpackIntents.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = JetpackIntents.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0107E15C28FFE99300DE87DB /* WidgetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetConfiguration.swift; sourceTree = ""; }; + 0107E16328FFED1800DE87DB /* WidgetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetConfiguration.swift; sourceTree = ""; }; + 0107E1812900043200DE87DB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0107E1832900043200DE87DB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0107E1842900059300DE87DB /* LocalizationConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationConfiguration.swift; sourceTree = ""; }; + 0107E1862900065400DE87DB /* LocalizationConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalizationConfiguration.swift; sourceTree = ""; }; + 0118968E29D1EB5E00D34BA9 /* DomainsDashboardCardHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainsDashboardCardHelper.swift; sourceTree = ""; }; + 0118969029D1F2FE00D34BA9 /* DomainsDashboardCardHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainsDashboardCardHelperTests.swift; sourceTree = ""; }; + 0118969E29D30CAD00D34BA9 /* DomainsDashboardCardTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DomainsDashboardCardTracker.swift; sourceTree = ""; }; + 011896A129D5AF0700D34BA9 /* BlogDashboardCardConfigurable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardCardConfigurable.swift; sourceTree = ""; }; + 011896A429D5B72500D34BA9 /* DomainsDashboardCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainsDashboardCoordinator.swift; sourceTree = ""; }; + 011896A729D5BBB400D34BA9 /* DomainsDashboardFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainsDashboardFactory.swift; sourceTree = ""; }; + 011A2815DB0DE7E3973CBC0E /* Pods-Apps-Jetpack.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Apps-Jetpack.release.xcconfig"; path = "../Pods/Target Support Files/Pods-Apps-Jetpack/Pods-Apps-Jetpack.release.xcconfig"; sourceTree = ""; }; + 0141929B2983F0A300CAEDB0 /* SupportConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportConfiguration.swift; sourceTree = ""; }; + 0141929F2983F5E800CAEDB0 /* SupportConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportConfigurationTests.swift; sourceTree = ""; }; + 0147D64D294B1E1600AA6410 /* StatsRevampStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsRevampStore.swift; sourceTree = ""; }; + 0147D650294B6EA600AA6410 /* StatsRevampStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsRevampStoreTests.swift; sourceTree = ""; }; + 0148CC282859127F00CF5D96 /* StatsWidgetsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsWidgetsStoreTests.swift; sourceTree = ""; }; + 0148CC2A2859C87000CF5D96 /* BlogServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogServiceMock.swift; sourceTree = ""; }; + 015BA4EA29A788A300920F4B /* StatsTotalInsightsCellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsTotalInsightsCellTests.swift; sourceTree = ""; }; + 01CE5006290A889F00A9C2E0 /* TracksConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracksConfiguration.swift; sourceTree = ""; }; + 01CE5010290A890300A9C2E0 /* TracksConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracksConfiguration.swift; sourceTree = ""; }; + 01DBFD8629BDCBF200F3720F /* JetpackNativeConnectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackNativeConnectionService.swift; sourceTree = ""; }; + 01E61E5929F03DEC002E544E /* DashboardDomainsCardSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardDomainsCardSearchView.swift; sourceTree = ""; }; + 01E78D1C296EA54F00FB6863 /* StatsPeriodHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsPeriodHelperTests.swift; sourceTree = ""; }; 02761EBF2270072F009BAF0F /* BlogDetailsViewController+SectionHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogDetailsViewController+SectionHelpers.swift"; sourceTree = ""; }; 02761EC122700A9C009BAF0F /* BlogDetailsSubsectionToSectionCategoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDetailsSubsectionToSectionCategoryTests.swift; sourceTree = ""; }; 02761EC3227010BC009BAF0F /* BlogDetailsSectionIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDetailsSectionIndexTests.swift; sourceTree = ""; }; @@ -2306,11 +5786,16 @@ 02AC3091226FFFAA0018D23B /* BlogDetailsViewController+DomainCredit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogDetailsViewController+DomainCredit.swift"; sourceTree = ""; }; 02BE5CBF2281B53F00E351BA /* RegisterDomainDetailsViewModelLoadingStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterDomainDetailsViewModelLoadingStateTests.swift; sourceTree = ""; }; 02BF30522271D7F000616558 /* DomainCreditRedemptionSuccessViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainCreditRedemptionSuccessViewController.swift; sourceTree = ""; }; + 02BF978AFC1EFE50CFD558C2 /* Pods-WordPressStatsWidgets.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressStatsWidgets.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressStatsWidgets/Pods-WordPressStatsWidgets.release.xcconfig"; sourceTree = ""; }; 02D75D9822793EA2003FF09A /* BlogDetailsSectionFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDetailsSectionFooterView.swift; sourceTree = ""; }; + 03216EC5279946CA00D444CA /* SchedulingDatePickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SchedulingDatePickerViewController.swift; sourceTree = ""; }; + 03216ECB27995F3500D444CA /* SchedulingViewControllerPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchedulingViewControllerPresenter.swift; sourceTree = ""; }; + 069A4AA52664448F00413FA9 /* GutenbergFeaturedImageHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergFeaturedImageHelper.swift; sourceTree = ""; }; 0807CB711CE670A800CDBDAC /* WPContentSearchHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WPContentSearchHelper.swift; sourceTree = ""; }; 080C449D1CE14A9F00B3A02F /* MenuDetailsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MenuDetailsViewController.h; sourceTree = ""; }; 080C449E1CE14A9F00B3A02F /* MenuDetailsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenuDetailsViewController.m; sourceTree = ""; }; 0815CF451E96F22600069916 /* MediaImportService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaImportService.swift; sourceTree = ""; }; + 081E4B4B281C019A0085E89C /* TooltipAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TooltipAnchor.swift; sourceTree = ""; }; 08216FA71CDBF95100304BA7 /* MenuItemEditing.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = MenuItemEditing.storyboard; sourceTree = ""; }; 08216FA81CDBF95100304BA7 /* MenuItemEditingViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MenuItemEditingViewController.h; sourceTree = ""; }; 08216FA91CDBF95100304BA7 /* MenuItemEditingViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenuItemEditingViewController.m; sourceTree = ""; }; @@ -2345,6 +5830,7 @@ 082635B91CEA69280088030C /* MenuItemsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MenuItemsViewController.h; sourceTree = ""; }; 082635BA1CEA69280088030C /* MenuItemsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenuItemsViewController.m; sourceTree = ""; }; 0828D7F91E6E09AE00C7C7D4 /* WPAppAnalytics+Media.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPAppAnalytics+Media.swift"; sourceTree = ""; }; + 082A645A291C2DD700668D2C /* Routes+Jetpack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Routes+Jetpack.swift"; sourceTree = ""; }; 082AB9D71C4EEEF4000CA523 /* PostTagService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PostTagService.h; sourceTree = ""; }; 082AB9D81C4EEEF4000CA523 /* PostTagService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostTagService.m; sourceTree = ""; }; 082AB9DB1C4F035E000CA523 /* PostTag.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PostTag.h; sourceTree = ""; }; @@ -2353,8 +5839,13 @@ 0845B8C51E833C56001BA771 /* URL+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+Helpers.swift"; sourceTree = ""; }; 08472A1E1C7273FA0040769D /* PostServiceOptions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PostServiceOptions.h; sourceTree = ""; }; 08472A1F1C727E020040769D /* PostServiceOptions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostServiceOptions.m; sourceTree = ""; }; + 084A07052848E1820054508A /* FeatureHighlightStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureHighlightStore.swift; sourceTree = ""; }; 084D94AE1EDF842600C385A6 /* test-video-device-gps.m4v */ = {isa = PBXFileReference; lastKnownFileType = file; path = "test-video-device-gps.m4v"; sourceTree = ""; }; + 084FC3B629913B1B00A17BCF /* JetpackPluginOverlayViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackPluginOverlayViewModelTests.swift; sourceTree = ""; }; + 084FC3B829914BD700A17BCF /* JetpackOverlayCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackOverlayCoordinator.swift; sourceTree = ""; }; + 084FC3BA29914C7F00A17BCF /* JetpackPluginOverlayCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackPluginOverlayCoordinator.swift; sourceTree = ""; }; 084FF460C7742309671B3A86 /* Pods-WordPressTest.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressTest.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressTest/Pods-WordPressTest.debug.xcconfig"; sourceTree = ""; }; + 0857BB3F299275760011CBD1 /* JetpackDefaultOverlayCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackDefaultOverlayCoordinator.swift; sourceTree = ""; }; 0857C26F1CE5375F0014AE99 /* MenuItemAbstractView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MenuItemAbstractView.h; sourceTree = ""; }; 0857C2701CE5375F0014AE99 /* MenuItemAbstractView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenuItemAbstractView.m; sourceTree = ""; }; 0857C2711CE5375F0014AE99 /* MenuItemInsertionView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MenuItemInsertionView.h; sourceTree = ""; }; @@ -2367,12 +5858,22 @@ 086C4D0F1E81F9240011D960 /* Media+Blog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Media+Blog.swift"; sourceTree = ""; }; 086E1FDE1BBB35D2002D86CA /* MenusViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MenusViewController.h; sourceTree = ""; }; 086E1FDF1BBB35D2002D86CA /* MenusViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenusViewController.m; sourceTree = ""; }; + 0878580228B4CF950069F96C /* UserPersistentRepositoryUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPersistentRepositoryUtility.swift; sourceTree = ""; }; 0879FC151E9301DD00E1EFC8 /* MediaTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaTests.swift; sourceTree = ""; }; 087EBFA71F02313E001F7ACE /* MediaThumbnailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaThumbnailService.swift; sourceTree = ""; }; + 0880BADB29ED6FF3002D3AB0 /* UIColor+DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+DesignSystem.swift"; sourceTree = ""; }; 0885A3661E837AFE00619B4D /* URLIncrementalFilenameTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLIncrementalFilenameTests.swift; sourceTree = ""; }; 088B89881DA6F93B000E8DEF /* ReaderPostCardContentLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderPostCardContentLabel.swift; sourceTree = ""; }; + 088CC593282BEC41007B9421 /* TooltipPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TooltipPresenter.swift; sourceTree = ""; }; + 088D58A429E724F300E6C0F4 /* ColorGallery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorGallery.swift; sourceTree = ""; }; + 08A250F728D9E87600F50420 /* CommentDetailInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentDetailInfoViewController.swift; sourceTree = ""; }; + 08A250FB28D9F0E200F50420 /* CommentDetailInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentDetailInfoViewModel.swift; sourceTree = ""; }; 08A2AD781CCED2A800E84454 /* PostTagServiceTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostTagServiceTests.m; sourceTree = ""; }; 08A2AD7A1CCED8E500E84454 /* PostCategoryServiceTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostCategoryServiceTests.m; sourceTree = ""; }; + 08A4E128289D202F001D9EC7 /* UserPersistentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPersistentStore.swift; sourceTree = ""; }; + 08A4E12B289D2337001D9EC7 /* UserPersistentRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPersistentRepository.swift; sourceTree = ""; }; + 08A4E12E289D2795001D9EC7 /* UserPersistentStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPersistentStoreTests.swift; sourceTree = ""; }; + 08A7343E298AB68000F925C7 /* JetpackPluginOverlayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackPluginOverlayViewModel.swift; sourceTree = ""; }; 08AAD69D1CBEA47D002B2418 /* MenusService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MenusService.h; sourceTree = ""; }; 08AAD69E1CBEA47D002B2418 /* MenusService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenusService.m; sourceTree = ""; }; 08AAD6A01CBEA610002B2418 /* MenusServiceTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenusServiceTests.m; sourceTree = ""; }; @@ -2381,9 +5882,14 @@ 08B6E5191F036CAD00268F57 /* MediaFileManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaFileManager.swift; sourceTree = ""; }; 08B6E51B1F037ADD00268F57 /* MediaFileManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaFileManagerTests.swift; sourceTree = ""; }; 08B832411EC130D60079808D /* test-gif.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = "test-gif.gif"; sourceTree = ""; }; + 08B954F228535EE800B07185 /* FeatureHighlightStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureHighlightStoreTests.swift; sourceTree = ""; }; + 08BA4BC5298A9AD400015BD2 /* JetpackInstallPluginLogoAnimation_rtl.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackInstallPluginLogoAnimation_rtl.json; sourceTree = ""; }; + 08BA4BC6298A9AD400015BD2 /* JetpackInstallPluginLogoAnimation_ltr.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackInstallPluginLogoAnimation_ltr.json; sourceTree = ""; }; 08C388651ED7705E0057BE49 /* MediaAssetExporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaAssetExporter.swift; sourceTree = ""; }; 08C388681ED78EE70057BE49 /* Media+WPMediaAsset.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "Media+WPMediaAsset.h"; sourceTree = ""; }; 08C388691ED78EE70057BE49 /* Media+WPMediaAsset.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "Media+WPMediaAsset.m"; sourceTree = ""; }; + 08C42C30281807880034720B /* ReaderSubscribeCommentsActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSubscribeCommentsActionTests.swift; sourceTree = ""; }; + 08CBC77829AE6CC4000026E7 /* SiteCreationEmptySiteTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteCreationEmptySiteTemplate.swift; sourceTree = ""; }; 08CC67771C49B52E00153AD7 /* WordPress 45.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 45.xcdatamodel"; sourceTree = ""; }; 08CC67781C49B65A00153AD7 /* MenuItem.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MenuItem.h; sourceTree = ""; }; 08CC67791C49B65A00153AD7 /* MenuItem.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenuItem.m; sourceTree = ""; }; @@ -2403,6 +5909,7 @@ 08D345551CD7FBA900358E8C /* MenuHeaderViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenuHeaderViewController.m; sourceTree = ""; }; 08D499661CDD20450004809A /* Menus.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Menus.storyboard; sourceTree = ""; }; 08D4C0E61C76F14E002E5BF6 /* WordPress 47.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 47.xcdatamodel"; sourceTree = ""; }; + 08D553652821286300AA1E8D /* Tooltip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tooltip.swift; sourceTree = ""; }; 08D9784B1CD2AF7D0054F19A /* Menu+ViewDesign.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "Menu+ViewDesign.h"; sourceTree = ""; }; 08D9784C1CD2AF7D0054F19A /* Menu+ViewDesign.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "Menu+ViewDesign.m"; sourceTree = ""; }; 08D9784D1CD2AF7D0054F19A /* MenuItem+ViewDesign.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "MenuItem+ViewDesign.h"; sourceTree = ""; }; @@ -2414,9 +5921,12 @@ 08D978531CD2AF7D0054F19A /* MenuItemSourceTextBar.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MenuItemSourceTextBar.h; sourceTree = ""; }; 08D978541CD2AF7D0054F19A /* MenuItemSourceTextBar.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenuItemSourceTextBar.m; sourceTree = ""; }; 08DF9C431E8475530058678C /* test-image-portrait.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "test-image-portrait.jpg"; sourceTree = ""; }; + 08E39B4428A3DEB200874CB8 /* UserPersistentStoreFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPersistentStoreFactory.swift; sourceTree = ""; }; 08E5CAD31E7B3A4500FAC71B /* WordPress 57.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 57.xcdatamodel"; sourceTree = ""; }; 08E77F441EE87FCF006F9515 /* MediaThumbnailExporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaThumbnailExporter.swift; sourceTree = ""; }; 08E77F461EE9D72F006F9515 /* MediaThumbnailExporterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaThumbnailExporterTests.swift; sourceTree = ""; }; + 08EA036629C9B51200B72A87 /* Color+DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+DesignSystem.swift"; sourceTree = ""; }; + 08EA036829C9B53000B72A87 /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Colors.xcassets; sourceTree = ""; }; 08F8CD291EBD22EF0049D0C0 /* MediaExporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaExporter.swift; sourceTree = ""; }; 08F8CD2C1EBD245F0049D0C0 /* MediaExporterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaExporterTests.swift; sourceTree = ""; }; 08F8CD2E1EBD29440049D0C0 /* MediaImageExporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaImageExporter.swift; sourceTree = ""; }; @@ -2425,66 +5935,226 @@ 08F8CD341EBD2AA80049D0C0 /* test-image-device-photo-gps.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "test-image-device-photo-gps.jpg"; sourceTree = ""; }; 08F8CD381EBD2C970049D0C0 /* MediaURLExporter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaURLExporter.swift; sourceTree = ""; }; 08F8CD3A1EBD2D020049D0C0 /* MediaURLExporterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaURLExporterTests.swift; sourceTree = ""; }; - 0EAD25F1E69B6B91783C4963 /* Pods-WordPressAllTimeWidget.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressAllTimeWidget.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressAllTimeWidget/Pods-WordPressAllTimeWidget.debug.xcconfig"; sourceTree = ""; }; - 0F01F606071B0B9BD9DB34DE /* Pods-WordPressTodayWidget.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressTodayWidget.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressTodayWidget/Pods-WordPressTodayWidget.release-alpha.xcconfig"; sourceTree = ""; }; - 10BBFF34EBDA2F9B8CB48DF6 /* Pods-WordPressUITests.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressUITests.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressUITests/Pods-WordPressUITests.release-internal.xcconfig"; sourceTree = ""; }; + 099D768227D14B8E00F77EDE /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D768427D14BAE00F77EDE /* sq */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sq; path = sq.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D768527D14BB300F77EDE /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D768627D14BB700F77EDE /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D768727D14BBA00F77EDE /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; + 099D768827D14BBE00F77EDE /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; + 099D768927D14BC000F77EDE /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D768A27D14BC200F77EDE /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D768B27D14BC400F77EDE /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D768C27D14BC600F77EDE /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D768D27D14BC800F77EDE /* en-AU */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-AU"; path = "en-AU.lproj/InfoPlist.strings"; sourceTree = ""; }; + 099D768E27D14BCA00F77EDE /* en-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-CA"; path = "en-CA.lproj/InfoPlist.strings"; sourceTree = ""; }; + 099D768F27D14BCC00F77EDE /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/InfoPlist.strings"; sourceTree = ""; }; + 099D769027D14BCE00F77EDE /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769127D14BD000F77EDE /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769227D14BD500F77EDE /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769327D14BD700F77EDE /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769427D14BD900F77EDE /* is */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = is; path = is.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769527D14BDB00F77EDE /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769627D14BDC00F77EDE /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769727D14BDE00F77EDE /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769827D14BE000F77EDE /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769927D14BE200F77EDE /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769A27D14BE500F77EDE /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769B27D14BE800F77EDE /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769C27D14BEA00F77EDE /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = ""; }; + 099D769D27D14BEC00F77EDE /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769E27D14BED00F77EDE /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D769F27D14BEF00F77EDE /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D76A027D14BF100F77EDE /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D76A127D14BF200F77EDE /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D76A227D14BF400F77EDE /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D76A327D14BF600F77EDE /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; + 099D76A427D14BF800F77EDE /* cy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cy; path = cy.lproj/InfoPlist.strings; sourceTree = ""; }; + 09C8BB7E27DFF9BE00974175 /* sq */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sq; path = sq.lproj/InfoPlist.strings; sourceTree = ""; }; + 09C8BB7F27DFF9C000974175 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/InfoPlist.strings; sourceTree = ""; }; + 09C8BB8027DFF9C600974175 /* en-AU */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-AU"; path = "en-AU.lproj/InfoPlist.strings"; sourceTree = ""; }; + 09C8BB8127DFF9C800974175 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/InfoPlist.strings; sourceTree = ""; }; + 09C8BB8227DFF9C900974175 /* is */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = is; path = is.lproj/InfoPlist.strings; sourceTree = ""; }; + 09C8BB8327DFF9CB00974175 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; + 09C8BB8427DFF9CC00974175 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/InfoPlist.strings; sourceTree = ""; }; + 09F367D2BE684EDE2E4A40E3 /* Pods-WordPressDraftActionExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressDraftActionExtension.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension.debug.xcconfig"; sourceTree = ""; }; + 0A3FCA1C28B71CBC00499A15 /* FullScreenCommentReplyViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullScreenCommentReplyViewModel.swift; sourceTree = ""; }; + 0A69300A28B5AA5E00E98DE1 /* FullScreenCommentReplyViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCommentReplyViewModelTests.swift; sourceTree = ""; }; + 0A9610F828B2E56300076EBA /* UserSuggestion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserSuggestion+Comparable.swift"; sourceTree = ""; }; + 0A9687BB28B40771009DCD2F /* FullScreenCommentReplyViewModelMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCommentReplyViewModelMock.swift; sourceTree = ""; }; + 0C35FFF029CB81F700D224EB /* BlogDashboardHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardHelpers.swift; sourceTree = ""; }; + 0C35FFF329CBA6DA00D224EB /* BlogDashboardPersonalizationViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationViewModelTests.swift; sourceTree = ""; }; + 0C35FFF529CBB5DE00D224EB /* BlogDashboardEmptyStateCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardEmptyStateCell.swift; sourceTree = ""; }; + 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationService.swift; sourceTree = ""; }; + 0CB4056D29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationServiceTests.swift; sourceTree = ""; }; + 0CB4057029C8DCF4008EED0A /* BlogDashboardPersonalizationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationViewModel.swift; sourceTree = ""; }; + 0CB4057229C8DD01008EED0A /* BlogDashboardPersonalizationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizationView.swift; sourceTree = ""; }; + 0CB4057B29C8DEE1008EED0A /* BlogDashboardPersonalizeCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersonalizeCardCell.swift; sourceTree = ""; }; 131D0EE49695795ECEDAA446 /* Pods-WordPressTest.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressTest.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressTest/Pods-WordPressTest.release-alpha.xcconfig"; sourceTree = ""; }; + 150B6590614A28DF9AD25491 /* Pods-Apps-Jetpack.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Apps-Jetpack.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-Apps-Jetpack/Pods-Apps-Jetpack.release-alpha.xcconfig"; sourceTree = ""; }; + 152F25D5C232985E30F56CAC /* Pods-Apps-Jetpack.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Apps-Jetpack.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-Apps-Jetpack/Pods-Apps-Jetpack.debug.xcconfig"; sourceTree = ""; }; 1702BBDB1CEDEA6B00766A33 /* BadgeLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BadgeLabel.swift; sourceTree = ""; }; 1702BBDF1CF3034E00766A33 /* DomainsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DomainsService.swift; sourceTree = ""; }; 1703D04B20ECD93800D292E9 /* Routes+Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Routes+Post.swift"; sourceTree = ""; }; - 1705E54F20A5DA5700EF1C9D /* ReaderSavedPostsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSavedPostsViewController.swift; sourceTree = ""; }; 1707CE411F3121750020B7FE /* UICollectionViewCell+Tint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UICollectionViewCell+Tint.swift"; sourceTree = ""; }; 170CE73F2064478600A48191 /* PostNoticeNavigationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostNoticeNavigationCoordinator.swift; sourceTree = ""; }; + 171096CA270F01EA001BCDD6 /* DomainSuggestionsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DomainSuggestionsTableViewController.swift; sourceTree = ""; }; 1714F8CF20E6DA8900226DCB /* RouteMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteMatcher.swift; sourceTree = ""; }; 1715179120F4B2EB002C4A38 /* Routes+Stats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Routes+Stats.swift"; sourceTree = ""; }; 1715179320F4B5CD002C4A38 /* MySitesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MySitesCoordinator.swift; sourceTree = ""; }; - 171909E3206CFFCD0054DF0B /* FancyAlertViewController+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FancyAlertViewController+Async.swift"; sourceTree = ""; }; + 1716AEFB25F2927600CF49EC /* MySiteViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MySiteViewController.swift; sourceTree = ""; }; + 17171373265FAA8A00F3A022 /* BloggingRemindersNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersNavigationController.swift; sourceTree = ""; }; + 1717139E265FE59700F3A022 /* ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonStyles.swift; sourceTree = ""; }; 1719633F1D378D5100898E8B /* SearchWrapperView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchWrapperView.swift; sourceTree = ""; }; + 171CC15724FCEBF7008B7180 /* UINavigationBar+Appearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Appearance.swift"; sourceTree = ""; }; + 17222D45261DDDF10047B163 /* celadon-classic-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-classic-icon-app-76x76.png"; sourceTree = ""; }; + 17222D46261DDDF10047B163 /* celadon-classic-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-classic-icon-app-76x76@2x.png"; sourceTree = ""; }; + 17222D47261DDDF10047B163 /* celadon-classic-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-classic-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 17222D48261DDDF10047B163 /* celadon-classic-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-classic-icon-app-60x60@2x.png"; sourceTree = ""; }; + 17222D49261DDDF10047B163 /* celadon-classic-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-classic-icon-app-60x60@3x.png"; sourceTree = ""; }; + 17222D4B261DDDF30047B163 /* celadon-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 17222D4C261DDDF30047B163 /* celadon-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-icon-app-76x76.png"; sourceTree = ""; }; + 17222D4D261DDDF30047B163 /* celadon-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-icon-app-76x76@2x.png"; sourceTree = ""; }; + 17222D4E261DDDF30047B163 /* celadon-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-icon-app-60x60@2x.png"; sourceTree = ""; }; + 17222D4F261DDDF30047B163 /* celadon-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-icon-app-60x60@3x.png"; sourceTree = ""; }; + 17222D51261DDDF40047B163 /* black-classic-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-classic-icon-app-76x76@2x.png"; sourceTree = ""; }; + 17222D52261DDDF40047B163 /* black-classic-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-classic-icon-app-60x60@3x.png"; sourceTree = ""; }; + 17222D53261DDDF40047B163 /* black-classic-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-classic-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 17222D54261DDDF40047B163 /* black-classic-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-classic-icon-app-60x60@2x.png"; sourceTree = ""; }; + 17222D55261DDDF40047B163 /* black-classic-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-classic-icon-app-76x76.png"; sourceTree = ""; }; + 17222D57261DDDF40047B163 /* blue-classic-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-classic-icon-app-60x60@3x.png"; sourceTree = ""; }; + 17222D58261DDDF40047B163 /* blue-classic-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-classic-icon-app-60x60@2x.png"; sourceTree = ""; }; + 17222D59261DDDF40047B163 /* blue-classic-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-classic-icon-app-76x76@2x.png"; sourceTree = ""; }; + 17222D5A261DDDF40047B163 /* blue-classic-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-classic-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 17222D5B261DDDF40047B163 /* blue-classic-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-classic-icon-app-76x76.png"; sourceTree = ""; }; + 17222D5D261DDDF50047B163 /* pink-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-icon-app-76x76@2x.png"; sourceTree = ""; }; + 17222D5E261DDDF50047B163 /* pink-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-icon-app-76x76.png"; sourceTree = ""; }; + 17222D5F261DDDF50047B163 /* pink-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-icon-app-60x60@3x.png"; sourceTree = ""; }; + 17222D60261DDDF50047B163 /* pink-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-icon-app-60x60@2x.png"; sourceTree = ""; }; + 17222D61261DDDF50047B163 /* pink-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 17222D63261DDDF50047B163 /* black-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-icon-app-60x60@2x.png"; sourceTree = ""; }; + 17222D64261DDDF50047B163 /* black-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-icon-app-60x60@3x.png"; sourceTree = ""; }; + 17222D65261DDDF50047B163 /* black-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-icon-app-76x76.png"; sourceTree = ""; }; + 17222D66261DDDF60047B163 /* black-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-icon-app-76x76@2x.png"; sourceTree = ""; }; + 17222D67261DDDF60047B163 /* black-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 17222D69261DDDF60047B163 /* pink-classic-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-classic-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 17222D6A261DDDF60047B163 /* pink-classic-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-classic-icon-app-76x76@2x.png"; sourceTree = ""; }; + 17222D6B261DDDF60047B163 /* pink-classic-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-classic-icon-app-60x60@2x.png"; sourceTree = ""; }; + 17222D6C261DDDF60047B163 /* pink-classic-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-classic-icon-app-76x76.png"; sourceTree = ""; }; + 17222D6D261DDDF60047B163 /* pink-classic-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-classic-icon-app-60x60@3x.png"; sourceTree = ""; }; + 17222D6F261DDDF70047B163 /* spectrum-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-icon-app-76x76@2x.png"; sourceTree = ""; }; + 17222D70261DDDF70047B163 /* spectrum-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 17222D71261DDDF70047B163 /* spectrum-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-icon-app-60x60@3x.png"; sourceTree = ""; }; + 17222D72261DDDF70047B163 /* spectrum-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-icon-app-76x76.png"; sourceTree = ""; }; + 17222D73261DDDF70047B163 /* spectrum-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-icon-app-60x60@2x.png"; sourceTree = ""; }; + 17222D78261DDDF70047B163 /* blue-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-icon-app-60x60@3x.png"; sourceTree = ""; }; + 17222D79261DDDF70047B163 /* blue-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-icon-app-60x60@2x.png"; sourceTree = ""; }; + 17222D7B261DDDF80047B163 /* spectrum-classic-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-classic-icon-app-76x76@2x.png"; sourceTree = ""; }; + 17222D7C261DDDF80047B163 /* spectrum-classic-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-classic-icon-app-76x76.png"; sourceTree = ""; }; + 17222D7D261DDDF80047B163 /* spectrum-classic-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-classic-icon-app-60x60@3x.png"; sourceTree = ""; }; + 17222D7E261DDDF80047B163 /* spectrum-classic-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-classic-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 17222D7F261DDDF80047B163 /* spectrum-classic-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-classic-icon-app-60x60@2x.png"; sourceTree = ""; }; 1724DDC71C60F1200099D273 /* PlanDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlanDetailViewController.swift; sourceTree = ""; }; 1724DDCB1C6121D00099D273 /* Plans.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Plans.storyboard; sourceTree = ""; }; 172797D81CE5D0CD00CB8057 /* PlansLoadingIndicatorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlansLoadingIndicatorView.swift; sourceTree = ""; }; 172E27D21FD98135003EA321 /* NoticePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticePresenter.swift; sourceTree = ""; }; + 172F06B42865C04E00C78FD4 /* spectrum-'22-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-'22-icon-app-76x76.png"; sourceTree = ""; }; + 172F06B52865C04E00C78FD4 /* spectrum-'22-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-'22-icon-app-60x60@3x.png"; sourceTree = ""; }; + 172F06B62865C04E00C78FD4 /* spectrum-'22-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-'22-icon-app-76x76@2x.png"; sourceTree = ""; }; + 172F06B72865C04F00C78FD4 /* spectrum-'22-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-'22-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 172F06B82865C04F00C78FD4 /* spectrum-'22-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-'22-icon-app-60x60@2x.png"; sourceTree = ""; }; 1730D4A21E97E3E400326B7C /* MediaItemTableViewCells.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaItemTableViewCells.swift; sourceTree = ""; }; - 173BCE721CEB368A00AE8817 /* DomainsListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DomainsListViewController.swift; sourceTree = ""; }; - 173BCE741CEB369900AE8817 /* Domains.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Domains.storyboard; sourceTree = ""; }; + 173B215427875E0600D4DD6B /* DeviceMediaPermissionsHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceMediaPermissionsHeader.swift; sourceTree = ""; }; 173BCE781CEB780800AE8817 /* Domain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; 173D82E6238EE2A7008432DA /* FeatureFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagTests.swift; sourceTree = ""; }; - 173F6DFB21232F2A00A4C8E2 /* NoResultsGiphyConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoResultsGiphyConfiguration.swift; sourceTree = ""; }; - 173F6DFD212352D000A4C8E2 /* GiphyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyService.swift; sourceTree = ""; }; + 173DF290274522A1007C64B5 /* AppAboutScreenConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAboutScreenConfiguration.swift; sourceTree = ""; }; 1746D7761D2165AE00B11D77 /* ForcePopoverPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForcePopoverPresenter.swift; sourceTree = ""; }; 1749965E2271BF08007021BD /* WordPressAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressAppDelegate.swift; sourceTree = ""; }; + 174C116E2624603400346EC6 /* MBarRouteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBarRouteTests.swift; sourceTree = ""; }; + 174C11922624C78900346EC6 /* Routes+Start.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Routes+Start.swift"; sourceTree = ""; }; 174C9696205A846E00CEEF6E /* PostNoticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostNoticeViewModel.swift; sourceTree = ""; }; 1750BD6C201144DB0050F13A /* MediaNoticeNavigationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaNoticeNavigationCoordinator.swift; sourceTree = ""; }; 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyValueDatabase.swift; sourceTree = ""; }; 1751E5921CE23801000CA08D /* NSAttributedString+StyledHTML.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+StyledHTML.swift"; sourceTree = ""; }; + 17523380246C4F9200870B4A /* HomepageSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomepageSettingsViewController.swift; sourceTree = ""; }; + 175507B227A062980038ED28 /* PublicizeConnectionURLMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicizeConnectionURLMatcher.swift; sourceTree = ""; }; + 1756DBDE28328B76006E6DB9 /* DonutChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonutChartView.swift; sourceTree = ""; }; + 1756F1DE2822BB6F00CD0915 /* SparklineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparklineView.swift; sourceTree = ""; }; + 175721152754D31F00DE38BC /* AppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = ""; }; 1759F16F1FE017BF0003EC81 /* Queue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Queue.swift; sourceTree = ""; }; 1759F1711FE017F20003EC81 /* QueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueTests.swift; sourceTree = ""; }; 1759F17F1FE1460C0003EC81 /* NoticeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeView.swift; sourceTree = ""; }; 175A650B20B6F7280023E71B /* ReaderSaveForLater+Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderSaveForLater+Analytics.swift"; sourceTree = ""; }; + 175CC16F2720548700622FB4 /* DomainExpiryDateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainExpiryDateFormatter.swift; sourceTree = ""; }; + 175CC17427205BFB00622FB4 /* DomainExpiryDateFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainExpiryDateFormatterTests.swift; sourceTree = ""; }; + 175CC1762721814B00622FB4 /* domain-service-updated-domains.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "domain-service-updated-domains.json"; sourceTree = ""; }; + 175CC17827230DC900622FB4 /* Bool+StringRepresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bool+StringRepresentation.swift"; sourceTree = ""; }; + 175CC17B2723103000622FB4 /* WPAnalytics+Domains.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPAnalytics+Domains.swift"; sourceTree = ""; }; + 175F99B42625FDE100F2687E /* FancyAlertViewController+AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FancyAlertViewController+AppIcons.swift"; sourceTree = ""; }; + 1761F14E26209AEC000815EF /* open-source-dark-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open-source-dark-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 1761F14F26209AEC000815EF /* open-source-dark-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open-source-dark-icon-app-60x60@3x.png"; sourceTree = ""; }; + 1761F15026209AEC000815EF /* open-source-dark-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open-source-dark-icon-app-60x60@2x.png"; sourceTree = ""; }; + 1761F15126209AEC000815EF /* open-source-dark-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open-source-dark-icon-app-76x76.png"; sourceTree = ""; }; + 1761F15226209AEC000815EF /* open-source-dark-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open-source-dark-icon-app-76x76@2x.png"; sourceTree = ""; }; + 1761F15426209AEC000815EF /* wordpress-dark-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress-dark-icon-app-76x76@2x.png"; sourceTree = ""; }; + 1761F15526209AEC000815EF /* wordpress-dark-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress-dark-icon-app-76x76.png"; sourceTree = ""; }; + 1761F15626209AEC000815EF /* wordpress-dark-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress-dark-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 1761F15726209AEC000815EF /* wordpress-dark-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress-dark-icon-app-60x60@2x.png"; sourceTree = ""; }; + 1761F15826209AEC000815EF /* wordpress-dark-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress-dark-icon-app-60x60@3x.png"; sourceTree = ""; }; + 1761F15A26209AED000815EF /* open-source-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open-source-icon-app-60x60@2x.png"; sourceTree = ""; }; + 1761F15B26209AED000815EF /* open-source-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open-source-icon-app-60x60@3x.png"; sourceTree = ""; }; + 1761F15C26209AED000815EF /* open-source-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open-source-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 1761F15D26209AED000815EF /* open-source-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open-source-icon-app-76x76.png"; sourceTree = ""; }; + 1761F15E26209AED000815EF /* open-source-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open-source-icon-app-76x76@2x.png"; sourceTree = ""; }; + 1761F16026209AED000815EF /* jetpack-green-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack-green-icon-app-76x76.png"; sourceTree = ""; }; + 1761F16126209AED000815EF /* jetpack-green-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack-green-icon-app-60x60@2x.png"; sourceTree = ""; }; + 1761F16226209AED000815EF /* jetpack-green-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack-green-icon-app-60x60@3x.png"; sourceTree = ""; }; + 1761F16326209AED000815EF /* jetpack-green-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack-green-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 1761F16426209AED000815EF /* jetpack-green-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack-green-icon-app-76x76@2x.png"; sourceTree = ""; }; + 1761F16626209AED000815EF /* pride-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride-icon-app-60x60@3x.png"; sourceTree = ""; }; + 1761F16726209AED000815EF /* pride-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride-icon-app-60x60@2x.png"; sourceTree = ""; }; + 1761F16826209AED000815EF /* pride-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 1761F16926209AED000815EF /* pride-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride-icon-app-76x76.png"; sourceTree = ""; }; + 1761F16A26209AED000815EF /* pride-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride-icon-app-76x76@2x.png"; sourceTree = ""; }; + 1761F16C26209AEE000815EF /* hot-pink-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot-pink-icon-app-60x60@2x.png"; sourceTree = ""; }; + 1761F16D26209AEE000815EF /* hot-pink-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot-pink-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + 1761F16E26209AEE000815EF /* hot-pink-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot-pink-icon-app-60x60@3x.png"; sourceTree = ""; }; + 1761F16F26209AEE000815EF /* hot-pink-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot-pink-icon-app-76x76.png"; sourceTree = ""; }; + 1761F17026209AEE000815EF /* hot-pink-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot-pink-icon-app-76x76@2x.png"; sourceTree = ""; }; + 1762B6DB2845510400F270A5 /* StatsReferrersChartViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsReferrersChartViewModel.swift; sourceTree = ""; }; 1767494D1D3633A000B8D1D1 /* ThemeBrowserSearchHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeBrowserSearchHeaderView.swift; sourceTree = ""; }; + 176BA53A268266DE0025E4A3 /* BlogService+Reminders.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BlogService+Reminders.swift"; sourceTree = ""; }; 176BB87E20D0068500751DCE /* FancyAlertViewController+SavedPosts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FancyAlertViewController+SavedPosts.swift"; sourceTree = ""; }; + 176CE91527FB44C100F1E32B /* StatsBaseCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsBaseCell.swift; sourceTree = ""; }; + 176CF39925E0005F00E1E598 /* NoteBlockButtonTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteBlockButtonTableViewCell.swift; sourceTree = ""; }; + 176CF3AB25E0079600E1E598 /* NoteBlockButtonTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NoteBlockButtonTableViewCell.xib; sourceTree = ""; }; 176DEEE81D4615FE00331F30 /* WPSplitViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WPSplitViewController.swift; sourceTree = ""; }; + 176E194625C465F70058F1C5 /* UnifiedPrologueViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedPrologueViewController.swift; sourceTree = ""; }; 177074841FB209F100951A4A /* CircularProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressView.swift; sourceTree = ""; }; 177076201EA206C000705A4A /* PlayIconView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayIconView.swift; sourceTree = ""; }; - 177B4C242123161900CF8084 /* GiphyPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyPicker.swift; sourceTree = ""; }; - 177B4C26212316DC00CF8084 /* GiphyStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyStrings.swift; sourceTree = ""; }; - 177B4C282123181400CF8084 /* GiphyDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyDataSource.swift; sourceTree = ""; }; - 177B4C2A2123185300CF8084 /* GiphyMediaGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyMediaGroup.swift; sourceTree = ""; }; - 177B4C2C2123192200CF8084 /* GiphyMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyMedia.swift; sourceTree = ""; }; - 177B4C3021236F0F00CF8084 /* GiphyDataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyDataLoader.swift; sourceTree = ""; }; - 177B4C3221236F5C00CF8084 /* GiphyPageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyPageable.swift; sourceTree = ""; }; - 177B4C342123706400CF8084 /* GiphyResultsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiphyResultsPage.swift; sourceTree = ""; }; - 177CBE4F1DA3A3AC009F951E /* CollectionType+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CollectionType+Helpers.swift"; sourceTree = ""; }; + 1770BD0C267A368100D5F8C0 /* BloggingRemindersPushPromptViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersPushPromptViewController.swift; sourceTree = ""; }; 177E7DAC1DD0D1E600890467 /* UINavigationController+SplitViewFullscreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UINavigationController+SplitViewFullscreen.swift"; sourceTree = ""; }; 1782BE831E70063100A91E7D /* MediaItemViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaItemViewController.swift; sourceTree = ""; }; + 17870A6F2816F2A000D1C627 /* StatsLatestPostSummaryInsightsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsLatestPostSummaryInsightsCell.swift; sourceTree = ""; }; + 17870A72281847D500D1C627 /* WordPress 139.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 139.xcdatamodel"; sourceTree = ""; }; + 17870A73281FBEC000D1C627 /* StatsTotalInsightsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsTotalInsightsCell.swift; sourceTree = ""; }; + 1788106E260E488B00A98BD8 /* UnifiedPrologueNotificationsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedPrologueNotificationsContentView.swift; sourceTree = ""; }; + 178810B42611D25600A98BD8 /* Text+BoldSubString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+BoldSubString.swift"; sourceTree = ""; }; + 178810D82612037800A98BD8 /* UnifiedPrologueReaderContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnifiedPrologueReaderContentView.swift; sourceTree = ""; }; + 178DDD05266D68A3006C68C4 /* BloggingRemindersFlowIntroViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersFlowIntroViewController.swift; sourceTree = ""; }; + 178DDD1A266D7523006C68C4 /* BloggingRemindersFlowSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersFlowSettingsViewController.swift; sourceTree = ""; }; + 178DDD2F266D7576006C68C4 /* BloggingRemindersFlowCompletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersFlowCompletionViewController.swift; sourceTree = ""; }; + 178DDD56266E4165006C68C4 /* CalendarDayToggleButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDayToggleButton.swift; sourceTree = ""; }; 1790A4521E28F0ED00AE54C2 /* UINavigationController+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UINavigationController+Helpers.swift"; sourceTree = ""; }; + 179501CC27A01D4100882787 /* PublicizeAuthorizationURLComponentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicizeAuthorizationURLComponentsTests.swift; sourceTree = ""; }; 1797373620EBAA4100377B4E /* RouteMatcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteMatcherTests.swift; sourceTree = ""; }; + 179A70EF2729834B006DAC0A /* Binding+OnChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Binding+OnChange.swift"; sourceTree = ""; }; 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagOverrideStore.swift; sourceTree = ""; }; 17A28DC2205001A900EA6D9E /* FilterTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterTabBar.swift; sourceTree = ""; }; 17A28DC42050404C00EA6D9E /* AuthorFilterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorFilterButton.swift; sourceTree = ""; }; 17A28DCA2052FB5D00EA6D9E /* AuthorFilterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorFilterViewController.swift; sourceTree = ""; }; 17A4A36820EE51870071C2CA /* Routes+Reader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Routes+Reader.swift"; sourceTree = ""; }; 17A4A36B20EE55320071C2CA /* ReaderCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCoordinator.swift; sourceTree = ""; }; + 17ABD3512811A48900B1E9CB /* StatsMostPopularTimeInsightsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsMostPopularTimeInsightsCell.swift; sourceTree = ""; }; 17AD36D41D36C1A60044B10D /* WPStyleGuide+Search.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Search.swift"; sourceTree = ""; }; 17AF92241C46634000A99CFB /* BlogSiteVisibilityHelperTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogSiteVisibilityHelperTest.m; sourceTree = ""; }; 17B7C89D20EC1D0D0042E260 /* UniversalLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UniversalLinkRouter.swift; sourceTree = ""; }; @@ -2494,135 +6164,137 @@ 17BB26AD1E6D8321008CD031 /* MediaLibraryViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaLibraryViewController.swift; sourceTree = ""; }; 17BD4A0720F76A4700975AC3 /* Routes+Banners.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Routes+Banners.swift"; sourceTree = ""; }; 17BD4A182101D31B00975AC3 /* NavigationActionHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationActionHelpers.swift; sourceTree = ""; }; + 17C1D67B2670E3DC006C8970 /* SiteIconPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteIconPickerView.swift; sourceTree = ""; }; + 17C1D6902670E4A2006C8970 /* UIFont+Fitting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Fitting.swift"; sourceTree = ""; }; + 17C1D6F426711ED0006C8970 /* Emoji.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = Emoji.txt; sourceTree = ""; }; + 17C1D7DB26735631006C8970 /* EmojiRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiRenderer.swift; sourceTree = ""; }; + 17C2FF0825D4852400CDB712 /* UnifiedProloguePages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedProloguePages.swift; sourceTree = ""; }; + 17C3F8BE25E4438100EFFE12 /* notifications-button-text-content.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "notifications-button-text-content.json"; sourceTree = ""; }; + 17C3FA6E25E591D200EFFE12 /* UIFont+Weight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Weight.swift"; sourceTree = ""; }; + 17C64BD1248E26A200AF09D7 /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = ""; }; 17CE77EC20C6C2F3001DEA5A /* ReaderSiteSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSiteSearchService.swift; sourceTree = ""; }; 17CE77EE20C6CDAA001DEA5A /* ReaderSiteSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSiteSearchViewController.swift; sourceTree = ""; }; 17D2FDC11C6A468A00944265 /* PlanComparisonViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlanComparisonViewController.swift; sourceTree = ""; }; 17D4153B22C2308D006378EF /* StatsPeriodHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsPeriodHelper.swift; sourceTree = ""; }; 17D5C3F61FFCF2D800EB70FF /* MediaProgressCoordinatorNoticeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProgressCoordinatorNoticeViewModel.swift; sourceTree = ""; }; + 17D9362224729FB6008B2205 /* Blog+HomepageSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+HomepageSettings.swift"; sourceTree = ""; }; + 17D9362624769579008B2205 /* HomepageSettingsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomepageSettingsService.swift; sourceTree = ""; }; 17D975AE1EF7F6F100303D63 /* WPStyleGuide+Aztec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Aztec.swift"; sourceTree = ""; }; - 17DC4C1F22C5E6910059CA11 /* open_source_dark_icon_20pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_dark_icon_20pt@2x.png"; sourceTree = ""; }; - 17DC4C2022C5E6910059CA11 /* open_source_dark_icon_40pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_dark_icon_40pt@3x.png"; sourceTree = ""; }; - 17DC4C2122C5E6910059CA11 /* open_source_dark_icon_20pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = open_source_dark_icon_20pt.png; sourceTree = ""; }; - 17DC4C2222C5E6910059CA11 /* open_source_dark_icon_40pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_dark_icon_40pt@2x.png"; sourceTree = ""; }; - 17DC4C2322C5E6910059CA11 /* open_source_dark_icon_20pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_dark_icon_20pt@3x.png"; sourceTree = ""; }; - 17DC4C2422C5E6910059CA11 /* open_source_dark_icon_40pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = open_source_dark_icon_40pt.png; sourceTree = ""; }; - 17DC4C2522C5E6910059CA11 /* open_source_dark_icon_76pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_dark_icon_76pt@2x.png"; sourceTree = ""; }; - 17DC4C2622C5E6910059CA11 /* open_source_dark_icon_29pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = open_source_dark_icon_29pt.png; sourceTree = ""; }; - 17DC4C2722C5E6910059CA11 /* open_source_dark_icon_60pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_dark_icon_60pt@3x.png"; sourceTree = ""; }; - 17DC4C2822C5E6910059CA11 /* open_source_dark_icon_29pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_dark_icon_29pt@3x.png"; sourceTree = ""; }; - 17DC4C2922C5E6910059CA11 /* open_source_dark_icon_83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_dark_icon_83.5@2x.png"; sourceTree = ""; }; - 17DC4C2A22C5E6910059CA11 /* open_source_dark_icon_29pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_dark_icon_29pt@2x.png"; sourceTree = ""; }; - 17DC4C2B22C5E6910059CA11 /* open_source_dark_icon_60pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_dark_icon_60pt@2x.png"; sourceTree = ""; }; - 17DC4C2C22C5E6910059CA11 /* open_source_dark_icon_76pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = open_source_dark_icon_76pt.png; sourceTree = ""; }; - 17DC4C3E22C5E6D30059CA11 /* open_source_icon_76pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_icon_76pt@2x.png"; sourceTree = ""; }; - 17DC4C3F22C5E6D30059CA11 /* open_source_icon_20pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = open_source_icon_20pt.png; sourceTree = ""; }; - 17DC4C4022C5E6D30059CA11 /* open_source_icon_20pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_icon_20pt@2x.png"; sourceTree = ""; }; - 17DC4C4122C5E6D40059CA11 /* open_source_icon_40pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = open_source_icon_40pt.png; sourceTree = ""; }; - 17DC4C4322C5E6D40059CA11 /* open_source_icon_29pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_icon_29pt@2x.png"; sourceTree = ""; }; - 17DC4C4422C5E6D40059CA11 /* open_source_icon_60pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_icon_60pt@3x.png"; sourceTree = ""; }; - 17DC4C4522C5E6D50059CA11 /* open_source_icon_83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_icon_83.5@2x.png"; sourceTree = ""; }; - 17DC4C4622C5E6D50059CA11 /* open_source_icon_40pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_icon_40pt@2x.png"; sourceTree = ""; }; - 17DC4C4722C5E6D50059CA11 /* open_source_icon_29pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_icon_29pt@3x.png"; sourceTree = ""; }; - 17DC4C4822C5E6D50059CA11 /* open_source_icon_60pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_icon_60pt@2x.png"; sourceTree = ""; }; - 17DC4C4922C5E6D50059CA11 /* open_source_icon_20pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_icon_20pt@3x.png"; sourceTree = ""; }; - 17DC4C4A22C5E6D60059CA11 /* open_source_icon_40pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "open_source_icon_40pt@3x.png"; sourceTree = ""; }; - 17DC4C4B22C5E6D60059CA11 /* open_source_icon_76pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = open_source_icon_76pt.png; sourceTree = ""; }; - 17DC4C4C22C5E6D60059CA11 /* open_source_icon_29pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = open_source_icon_29pt.png; sourceTree = ""; }; + 17E204C5271DB7620038BC90 /* WordPress 135.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 135.xcdatamodel"; sourceTree = ""; }; 17E24F5320FCF1D900BD70A3 /* Routes+MySites.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Routes+MySites.swift"; sourceTree = ""; }; 17E362EB22C40BE8000E0C79 /* AppIconViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconViewController.swift; sourceTree = ""; }; - 17E362ED22C41275000E0C79 /* wordpress_icon_40pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_icon_40pt@2x.png"; sourceTree = ""; }; - 17E362EE22C41275000E0C79 /* wordpress_icon_40pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_icon_40pt@3x.png"; sourceTree = ""; }; - 17E362F122C41721000E0C79 /* wordpress_dark_icon_20pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_dark_icon_20pt@2x.png"; sourceTree = ""; }; - 17E362F222C41722000E0C79 /* wordpress_dark_icon_40pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_dark_icon_40pt@3x.png"; sourceTree = ""; }; - 17E362F322C41722000E0C79 /* wordpress_dark_icon_20pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = wordpress_dark_icon_20pt.png; sourceTree = ""; }; - 17E362F422C41722000E0C79 /* wordpress_dark_icon_83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_dark_icon_83.5@2x.png"; sourceTree = ""; }; - 17E362F522C41723000E0C79 /* wordpress_dark_icon_29pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_dark_icon_29pt@2x.png"; sourceTree = ""; }; - 17E362F622C41723000E0C79 /* wordpress_dark_icon_60pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_dark_icon_60pt@3x.png"; sourceTree = ""; }; - 17E362F722C41723000E0C79 /* wordpress_dark_icon_60pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_dark_icon_60pt@2x.png"; sourceTree = ""; }; - 17E362F922C41724000E0C79 /* wordpress_dark_icon_29pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_dark_icon_29pt@3x.png"; sourceTree = ""; }; - 17E362FA22C41724000E0C79 /* wordpress_dark_icon_29pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = wordpress_dark_icon_29pt.png; sourceTree = ""; }; - 17E362FB22C41724000E0C79 /* wordpress_dark_icon_40pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_dark_icon_40pt@2x.png"; sourceTree = ""; }; - 17E362FC22C41724000E0C79 /* wordpress_dark_icon_40pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = wordpress_dark_icon_40pt.png; sourceTree = ""; }; - 17E362FD22C41725000E0C79 /* wordpress_dark_icon_20pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_dark_icon_20pt@3x.png"; sourceTree = ""; }; - 17E362FE22C41725000E0C79 /* wordpress_dark_icon_76pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = wordpress_dark_icon_76pt.png; sourceTree = ""; }; - 17E362FF22C41725000E0C79 /* wordpress_dark_icon_76pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wordpress_dark_icon_76pt@2x.png"; sourceTree = ""; }; - 17E3632D22C417E7000E0C79 /* jetpack_green_icon_40pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = jetpack_green_icon_40pt.png; sourceTree = ""; }; - 17E3632E22C417E7000E0C79 /* jetpack_green_icon_20pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack_green_icon_20pt@3x.png"; sourceTree = ""; }; - 17E3632F22C417E7000E0C79 /* jetpack_green_icon_40pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack_green_icon_40pt@2x.png"; sourceTree = ""; }; - 17E3633022C417E8000E0C79 /* jetpack_green_icon_29pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack_green_icon_29pt@3x.png"; sourceTree = ""; }; - 17E3633122C417E8000E0C79 /* jetpack_green_icon_29pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = jetpack_green_icon_29pt.png; sourceTree = ""; }; - 17E3633322C417E9000E0C79 /* jetpack_green_icon_83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack_green_icon_83.5@2x.png"; sourceTree = ""; }; - 17E3633422C417EA000E0C79 /* jetpack_green_icon_29pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack_green_icon_29pt@2x.png"; sourceTree = ""; }; - 17E3633522C417EB000E0C79 /* jetpack_green_icon_60pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack_green_icon_60pt@3x.png"; sourceTree = ""; }; - 17E3633622C417EB000E0C79 /* jetpack_green_icon_20pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack_green_icon_20pt@2x.png"; sourceTree = ""; }; - 17E3633722C417EC000E0C79 /* jetpack_green_icon_76pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack_green_icon_76pt@2x.png"; sourceTree = ""; }; - 17E3633822C417EC000E0C79 /* jetpack_green_icon_76pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = jetpack_green_icon_76pt.png; sourceTree = ""; }; - 17E3633922C417ED000E0C79 /* jetpack_green_icon_20pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = jetpack_green_icon_20pt.png; sourceTree = ""; }; - 17E3633A22C417EE000E0C79 /* jetpack_green_icon_40pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack_green_icon_40pt@3x.png"; sourceTree = ""; }; - 17E3633B22C417EF000E0C79 /* jetpack_green_icon_60pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack_green_icon_60pt@2x.png"; sourceTree = ""; }; - 17E3638822C41DB0000E0C79 /* hot_pink_icon_29pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot_pink_icon_29pt@2x.png"; sourceTree = ""; }; - 17E3638922C41DB0000E0C79 /* hot_pink_icon_60pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot_pink_icon_60pt@3x.png"; sourceTree = ""; }; - 17E3638A22C41DB1000E0C79 /* hot_pink_icon_76pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = hot_pink_icon_76pt.png; sourceTree = ""; }; - 17E3638B22C41DB1000E0C79 /* hot_pink_icon_20pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot_pink_icon_20pt@3x.png"; sourceTree = ""; }; - 17E3638C22C41DB1000E0C79 /* hot_pink_icon_20pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot_pink_icon_20pt@2x.png"; sourceTree = ""; }; - 17E3638D22C41DB2000E0C79 /* hot_pink_icon_40pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = hot_pink_icon_40pt.png; sourceTree = ""; }; - 17E3638E22C41DB3000E0C79 /* hot_pink_icon_40pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot_pink_icon_40pt@3x.png"; sourceTree = ""; }; - 17E3638F22C41DB3000E0C79 /* hot_pink_icon_76pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot_pink_icon_76pt@2x.png"; sourceTree = ""; }; - 17E3639022C41DB4000E0C79 /* hot_pink_icon_20pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = hot_pink_icon_20pt.png; sourceTree = ""; }; - 17E3639122C41DB5000E0C79 /* hot_pink_icon_60pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot_pink_icon_60pt@2x.png"; sourceTree = ""; }; - 17E3639222C41DB6000E0C79 /* hot_pink_icon_29pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot_pink_icon_29pt@3x.png"; sourceTree = ""; }; - 17E3639322C41DB7000E0C79 /* hot_pink_icon_29pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = hot_pink_icon_29pt.png; sourceTree = ""; }; - 17E3639422C41DB8000E0C79 /* hot_pink_icon_83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot_pink_icon_83.5@2x.png"; sourceTree = ""; }; - 17E3639522C41DB9000E0C79 /* hot_pink_icon_40pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "hot_pink_icon_40pt@2x.png"; sourceTree = ""; }; - 17E363CB22C42807000E0C79 /* pride_icon_40pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride_icon_40pt@3x.png"; sourceTree = ""; }; - 17E363CC22C42807000E0C79 /* pride_icon_60pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride_icon_60pt@2x.png"; sourceTree = ""; }; - 17E363CD22C42808000E0C79 /* pride_icon_20pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride_icon_20pt@2x.png"; sourceTree = ""; }; - 17E363CE22C42808000E0C79 /* pride_icon_20pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = pride_icon_20pt.png; sourceTree = ""; }; - 17E363CF22C42808000E0C79 /* pride_icon_29pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride_icon_29pt@2x.png"; sourceTree = ""; }; - 17E363D022C42808000E0C79 /* pride_icon_60pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = pride_icon_60pt.png; sourceTree = ""; }; - 17E363D122C42808000E0C79 /* pride_icon_76pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride_icon_76pt@2x.png"; sourceTree = ""; }; - 17E363D222C42808000E0C79 /* pride_icon_76pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = pride_icon_76pt.png; sourceTree = ""; }; - 17E363D322C42808000E0C79 /* pride_icon_83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride_icon_83.5@2x.png"; sourceTree = ""; }; - 17E363D422C42809000E0C79 /* pride_icon_29pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride_icon_29pt@3x.png"; sourceTree = ""; }; - 17E363D522C42809000E0C79 /* pride_icon_40pt@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride_icon_40pt@2x.png"; sourceTree = ""; }; - 17E363D622C42809000E0C79 /* pride_icon_40pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = pride_icon_40pt.png; sourceTree = ""; }; - 17E363D722C42809000E0C79 /* pride_icon_20pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride_icon_20pt@3x.png"; sourceTree = ""; }; - 17E363D822C42809000E0C79 /* pride_icon_29pt.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = pride_icon_29pt.png; sourceTree = ""; }; - 17E363D922C4280A000E0C79 /* pride_icon_60pt@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pride_icon_60pt@3x.png"; sourceTree = ""; }; 17E4CD0B238C33F300C56916 /* DebugMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugMenuViewController.swift; sourceTree = ""; }; 17E553B520910791000D3005 /* WordPress 75.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 75.xcdatamodel"; sourceTree = ""; }; - 17E60E08220DBD6E00848F89 /* WKWebView+Preview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKWebView+Preview.swift"; sourceTree = ""; }; + 17EFD2D82577B61900AB753C /* WordPress 104.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 104.xcdatamodel"; sourceTree = ""; }; + 17EFD3732578201100AB753C /* ValueTransformers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValueTransformers.swift; sourceTree = ""; }; 17F0E1D920EBDC0A001E9514 /* Routes+Me.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Routes+Me.swift"; sourceTree = ""; }; + 17F11EDA268623BA00D1BBA7 /* BloggingRemindersScheduleFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersScheduleFormatter.swift; sourceTree = ""; }; 17F2A5D020ACC70D00F0BE10 /* WordPress 76.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 76.xcdatamodel"; sourceTree = ""; }; 17F52DB62315233300164966 /* WPStyleGuide+FilterTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+FilterTabBar.swift"; sourceTree = ""; }; 17F67C55203D81430072001E /* PostCardStatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCardStatusViewModel.swift; sourceTree = ""; }; 17F7C24822770B68002E5C2E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 17FC0031264D728E00FCBD37 /* SharingServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingServiceTests.swift; sourceTree = ""; }; 17FCA6801FD84B4600DBA9C8 /* NoticeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeStore.swift; sourceTree = ""; }; + 18B1A53E374E22C490A08F23 /* Pods-JetpackStatsWidgets.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackStatsWidgets.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackStatsWidgets/Pods-JetpackStatsWidgets.debug.xcconfig"; sourceTree = ""; }; 1A433B1C2254CBEE00AE7910 /* WordPressComRestApi+Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WordPressComRestApi+Defaults.swift"; sourceTree = ""; }; 1ABA150722AE5F870039311A /* WordPressUIBundleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressUIBundleTests.swift; sourceTree = ""; }; - 1AE0F2B02297F7E9000BDD7F /* WireMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireMock.swift; sourceTree = ""; }; - 1B77149F6C65D343E7E3AD09 /* Pods-WordPressUITests.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressUITests.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressUITests/Pods-WordPressUITests.release-alpha.xcconfig"; sourceTree = ""; }; + 1BC96E982E9B1A6DD86AF491 /* Pods-WordPressShareExtension.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressShareExtension.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension.release-alpha.xcconfig"; sourceTree = ""; }; + 1D19C56229C9D9A700FB0087 /* GutenbergVideoPressUploadProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GutenbergVideoPressUploadProcessor.swift; sourceTree = ""; }; + 1D19C56529C9DB0A00FB0087 /* GutenbergVideoPressUploadProcessorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GutenbergVideoPressUploadProcessorTests.swift; sourceTree = ""; }; 1D30AB110D05D00D00671497 /* Foundation.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; 1D6058910D05DD3D006BFB54 /* WordPress.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WordPress.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1D91080629F847A2003F9A5E /* MediaServiceUpdateTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MediaServiceUpdateTests.m; sourceTree = ""; }; + 1E0462152566938300EB98EF /* GutenbergFileUploadProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergFileUploadProcessor.swift; sourceTree = ""; }; + 1E0FF01D242BC572008DA898 /* GutenbergWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergWebViewController.swift; sourceTree = ""; }; + 1E485A8F249B61440000A253 /* GutenbergRequestAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergRequestAuthenticator.swift; sourceTree = ""; }; + 1E4F2E702458AF8500EB73E7 /* GutenbergWebNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergWebNavigationViewController.swift; sourceTree = ""; }; + 1E5D000F2493CE240004B708 /* GutenGhostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenGhostView.swift; sourceTree = ""; }; + 1E5D00132493E8C90004B708 /* GutenGhostView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = GutenGhostView.xib; sourceTree = ""; }; + 1E672D94257663CE00421F13 /* GutenbergAudioUploadProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergAudioUploadProcessor.swift; sourceTree = ""; }; + 1E826CD5B4B116AF78FF391C /* Pods_Apps_WordPress.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Apps_WordPress.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 1E9D544C23C4C56300F6A9E0 /* GutenbergRollout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergRollout.swift; sourceTree = ""; }; - 2262D835FA89938EBF63EADF /* Pods-WordPressShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressShareExtension.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension.debug.xcconfig"; sourceTree = ""; }; - 27AE66536E0C5378203F9312 /* Pods-WordPressUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressUITests.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressUITests/Pods-WordPressUITests.debug.xcconfig"; sourceTree = ""; }; + 213A62FF811EBDB969FA7669 /* Pods_WordPressShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 23052F0F1F9B2503E33D0A26 /* Pods_JetpackShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JetpackShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 23F18781EEBE5551D6B4992C /* Pods_JetpackNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JetpackNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 241E60B225CA0D2900912CEB /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = ""; }; + 2420BEF025D8DAB300966129 /* Blog+Lookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+Lookup.swift"; sourceTree = ""; }; + 24350E7C264DB76E009BB2B6 /* Jetpack.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Jetpack.debug.xcconfig; sourceTree = ""; }; + 24351059264DC1E2009BB2B6 /* Jetpack.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Jetpack.release.xcconfig; sourceTree = ""; }; + 243511A4264DC2D9009BB2B6 /* Jetpack.internal.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Jetpack.internal.xcconfig; sourceTree = ""; }; + 24351253264DCA08009BB2B6 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../Secrets/Secrets.swift; sourceTree = BUILT_PRODUCTS_DIR; }; + 2439B1DB264ECBDF00239130 /* Jetpack.alpha.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Jetpack.alpha.xcconfig; sourceTree = ""; }; + 246D0A0225E97D5D0028B83F /* Blog+ObjcTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "Blog+ObjcTests.m"; sourceTree = ""; }; + 2481B17E260D4D4E00AE59DB /* WPAccount+Lookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPAccount+Lookup.swift"; sourceTree = ""; }; + 2481B1D4260D4E8B00AE59DB /* AccountBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBuilder.swift; sourceTree = ""; }; + 2481B1E7260D4EAC00AE59DB /* WPAccount+LookupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPAccount+LookupTests.swift"; sourceTree = ""; }; + 2481B20B260D8FED00AE59DB /* WPAccount+ObjCLookupTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "WPAccount+ObjCLookupTests.m"; sourceTree = ""; }; + 24A2948225D602710000A51E /* BlogTimeZoneTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BlogTimeZoneTests.m; sourceTree = ""; }; + 24AD66BE25FC25FD0056102C /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Sites.strings; sourceTree = ""; }; + 24AD66C025FC25FE0056102C /* sq */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sq; path = sq.lproj/Sites.strings; sourceTree = ""; }; + 24AD66C225FC26000056102C /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Sites.strings; sourceTree = ""; }; + 24AD66C425FC26010056102C /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Sites.strings; sourceTree = ""; }; + 24AD66C625FC26020056102C /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Sites.strings"; sourceTree = ""; }; + 24AD66C825FC26030056102C /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Sites.strings"; sourceTree = ""; }; + 24AD66CA25FC26040056102C /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Sites.strings; sourceTree = ""; }; + 24AD66CC25FC26050056102C /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Sites.strings; sourceTree = ""; }; + 24AD66CE25FC26060056102C /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Sites.strings; sourceTree = ""; }; + 24AD66F025FC260B0056102C /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Sites.strings; sourceTree = ""; }; + 24AD66F225FC260F0056102C /* en-AU */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-AU"; path = "en-AU.lproj/Sites.strings"; sourceTree = ""; }; + 24AD66F425FC26100056102C /* en-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-CA"; path = "en-CA.lproj/Sites.strings"; sourceTree = ""; }; + 24AD670625FC26150056102C /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Sites.strings"; sourceTree = ""; }; + 24AD670825FC26170056102C /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Sites.strings; sourceTree = ""; }; + 24AD670A25FC26170056102C /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Sites.strings; sourceTree = ""; }; + 24AD670C25FC26180056102C /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Sites.strings; sourceTree = ""; }; + 24AD670E25FC261A0056102C /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Sites.strings; sourceTree = ""; }; + 24AD671025FC261B0056102C /* is */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = is; path = is.lproj/Sites.strings; sourceTree = ""; }; + 24AD671225FC261C0056102C /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Sites.strings; sourceTree = ""; }; + 24AD671425FC261D0056102C /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Sites.strings; sourceTree = ""; }; + 24AD671625FC261E0056102C /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Sites.strings; sourceTree = ""; }; + 24AD671825FC26200056102C /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Sites.strings; sourceTree = ""; }; + 24AD672A25FC26220056102C /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Sites.strings; sourceTree = ""; }; + 24AD672C25FC26240056102C /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Sites.strings; sourceTree = ""; }; + 24AD672E25FC26250056102C /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Sites.strings; sourceTree = ""; }; + 24AD673025FC26260056102C /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Sites.strings"; sourceTree = ""; }; + 24AD673225FC26270056102C /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Sites.strings; sourceTree = ""; }; + 24AD674425FC26280056102C /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Sites.strings; sourceTree = ""; }; + 24AD674625FC26290056102C /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Sites.strings; sourceTree = ""; }; + 24AD674825FC262B0056102C /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Sites.strings; sourceTree = ""; }; + 24AD674A25FC262C0056102C /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Sites.strings; sourceTree = ""; }; + 24AD674C25FC262D0056102C /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Sites.strings; sourceTree = ""; }; + 24AD674E25FC262E0056102C /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Sites.strings; sourceTree = ""; }; + 24AD675025FC262F0056102C /* cy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cy; path = cy.lproj/Sites.strings; sourceTree = ""; }; + 24ADA24B24F9A4CB001B5DAE /* RemoteFeatureFlagStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeatureFlagStore.swift; sourceTree = ""; }; + 24AE9E66264B34E500AC7F15 /* Secrets-example.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Secrets-example.swift"; sourceTree = ""; }; + 24B1AE3024FEC79900B9F334 /* RemoteFeatureFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeatureFlagTests.swift; sourceTree = ""; }; + 24B54FAD2624F8350041B18E /* JetpackRelease-Internal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "JetpackRelease-Internal.entitlements"; sourceTree = ""; }; + 24B54FAE2624F8430041B18E /* JetpackRelease-Alpha.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "JetpackRelease-Alpha.entitlements"; sourceTree = ""; }; + 24B54FAF2624F84C0041B18E /* JetpackRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = JetpackRelease.entitlements; sourceTree = ""; }; + 24B54FB02624F8690041B18E /* JetpackDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = JetpackDebug.entitlements; sourceTree = ""; }; + 24C69A8A2612421900312D9A /* UserSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsTests.swift; sourceTree = ""; }; + 24C69AC12612467C00312D9A /* UserSettingsTestsObjc.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UserSettingsTestsObjc.m; sourceTree = ""; }; + 24D40491253F6CEE002843AC /* WordPressStatsWidgetsRelease-Internal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "WordPressStatsWidgetsRelease-Internal.entitlements"; sourceTree = ""; }; + 24D40492253F6D01002843AC /* WordPressStatsWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WordPressStatsWidgets.entitlements; sourceTree = ""; }; + 24F3789725E6E62100A27BB7 /* NSManagedObject+Lookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObject+Lookup.swift"; sourceTree = ""; }; + 26AC7B7EB4454FA8E268624D /* Pods_JetpackStatsWidgets.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JetpackStatsWidgets.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 28A0AAE50D9B0CCF005BE974 /* WordPress_Prefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WordPress_Prefix.pch; sourceTree = ""; }; 2906F80F110CDA8900169D56 /* EditCommentViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = EditCommentViewController.h; sourceTree = ""; }; 2906F810110CDA8900169D56 /* EditCommentViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = EditCommentViewController.m; sourceTree = ""; }; 292CECFE1027259000BD407D /* SFHFKeychainUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SFHFKeychainUtils.h; sourceTree = ""; }; 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SFHFKeychainUtils.m; sourceTree = ""; }; + 293E283D7339E7B6D13F6E09 /* Pods-JetpackShareExtension.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackShareExtension.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackShareExtension/Pods-JetpackShareExtension.release-internal.xcconfig"; sourceTree = ""; }; 296890770FE971DC00770264 /* Security.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; 2F08ECFB2283A4FB000F8E11 /* PostService+UnattachedMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostService+UnattachedMedia.swift"; sourceTree = ""; }; + 2F09D133245223D300956257 /* HeaderDetailsContentStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderDetailsContentStyles.swift; sourceTree = ""; }; 2F161B0522CC2DC70066A5C5 /* LoadingStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingStatusView.swift; sourceTree = ""; }; + 2F605FA7251430C200F99544 /* PostCategoriesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCategoriesViewController.swift; sourceTree = ""; }; + 2F605FA925145F7200F99544 /* WPCategoryTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPCategoryTree.swift; sourceTree = ""; }; + 2F668B5E255DD11400D0038A /* JetpackSpeedUpSiteSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackSpeedUpSiteSettingsViewController.swift; sourceTree = ""; }; + 2F668B5F255DD11400D0038A /* JetpackSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackSettingsViewController.swift; sourceTree = ""; }; + 2F668B60255DD11400D0038A /* JetpackConnectionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackConnectionViewController.swift; sourceTree = ""; }; 2F970F970DF929B8006BD934 /* Constants.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = ""; }; 2FA37B19215724E900C80377 /* LongPressGestureLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LongPressGestureLabel.swift; sourceTree = ""; }; - 2FA6511221F26949009AA935 /* SiteVerticalsPromptService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteVerticalsPromptService.swift; sourceTree = ""; }; - 2FA6511421F269A6009AA935 /* VerticalsTableViewProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerticalsTableViewProvider.swift; sourceTree = ""; }; 2FA6511621F26A24009AA935 /* ChangePasswordViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChangePasswordViewController.swift; sourceTree = ""; }; - 2FA6511821F26A57009AA935 /* InlineErrorTableViewProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InlineErrorTableViewProvider.swift; sourceTree = ""; }; 2FA6511921F26A57009AA935 /* InlineErrorRetryTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InlineErrorRetryTableViewCell.swift; sourceTree = ""; }; - 2FA6511C21F26A7C009AA935 /* WebAddressTableViewProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAddressTableViewProvider.swift; sourceTree = ""; }; 2FAE97040E33B21600CA8540 /* defaultPostTemplate_old.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = defaultPostTemplate_old.html; path = Resources/HTML/defaultPostTemplate_old.html; sourceTree = ""; }; 2FAE97070E33B21600CA8540 /* xhtml1-transitional.dtd */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; name = "xhtml1-transitional.dtd"; path = "Resources/HTML/xhtml1-transitional.dtd"; sourceTree = ""; }; 2FAE97080E33B21600CA8540 /* xhtmlValidatorTemplate.xhtml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; name = xhtmlValidatorTemplate.xhtml; path = Resources/HTML/xhtmlValidatorTemplate.xhtml; sourceTree = ""; }; @@ -2630,56 +6302,212 @@ 30EABE0818A5903400B73A9C /* WPBlogTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPBlogTableViewCell.m; sourceTree = ""; }; 310186691A373B01008F7DF6 /* WPTabBarController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPTabBarController.h; sourceTree = ""; }; 3101866A1A373B01008F7DF6 /* WPTabBarController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPTabBarController.m; sourceTree = ""; }; - 313AE49B19E3F20400AAFABE /* CommentViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CommentViewController.h; sourceTree = ""; }; - 313AE49C19E3F20400AAFABE /* CommentViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CommentViewController.m; sourceTree = ""; }; - 315FC2C31A2CB29300E7CDA2 /* MeHeaderView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MeHeaderView.h; sourceTree = ""; }; 315FC2C41A2CB29300E7CDA2 /* MeHeaderView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MeHeaderView.m; sourceTree = ""; }; 316B99031B205AFB007963EF /* WordPress 32.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 32.xcdatamodel"; sourceTree = ""; }; - 319D6E7919E447500013871C /* Suggestion.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Suggestion.h; sourceTree = ""; }; - 319D6E7A19E447500013871C /* Suggestion.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Suggestion.m; sourceTree = ""; }; - 319D6E7C19E447C80013871C /* SuggestionService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SuggestionService.h; sourceTree = ""; }; - 319D6E7D19E447C80013871C /* SuggestionService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SuggestionService.m; sourceTree = ""; }; 319D6E7F19E44C680013871C /* SuggestionsTableView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SuggestionsTableView.h; path = Suggestions/SuggestionsTableView.h; sourceTree = ""; }; 319D6E8019E44C680013871C /* SuggestionsTableView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = SuggestionsTableView.m; path = Suggestions/SuggestionsTableView.m; sourceTree = ""; }; 319D6E8319E44F7F0013871C /* SuggestionsTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = SuggestionsTableViewCell.h; path = Suggestions/SuggestionsTableViewCell.h; sourceTree = ""; }; 319D6E8419E44F7F0013871C /* SuggestionsTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = SuggestionsTableViewCell.m; path = Suggestions/SuggestionsTableViewCell.m; sourceTree = ""; }; - 31C9F82C1A2368A2008BB945 /* BlogDetailHeaderView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlogDetailHeaderView.h; sourceTree = ""; }; - 31C9F82D1A2368A2008BB945 /* BlogDetailHeaderView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; lineEnding = 0; path = BlogDetailHeaderView.m; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objc; }; 31CCB9FD1A52ED0A00BA0733 /* WordPress 26.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 26.xcdatamodel"; sourceTree = ""; }; 31EC15061A5B6675009FC8B3 /* WPStyleGuide+Suggestions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "WPStyleGuide+Suggestions.h"; sourceTree = ""; }; 31EC15071A5B6675009FC8B3 /* WPStyleGuide+Suggestions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WPStyleGuide+Suggestions.m"; sourceTree = ""; }; 31FA16CC1A49B3C0003E1887 /* WordPress 25.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 25.xcdatamodel"; sourceTree = ""; }; + 32110546250BFC3E0048446F /* ImageDimensionParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDimensionParserTests.swift; sourceTree = ""; }; + 32110552250C027B0048446F /* invalid-jpeg-header.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "invalid-jpeg-header.jpg"; sourceTree = ""; }; + 32110553250C027B0048446F /* valid-jpeg-header.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "valid-jpeg-header.jpg"; sourceTree = ""; }; + 32110554250C027B0048446F /* valid-gif-header.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = "valid-gif-header.gif"; sourceTree = ""; }; + 32110555250C027C0048446F /* 100x100.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = 100x100.gif; sourceTree = ""; }; + 32110558250C027C0048446F /* invalid-gif.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = "invalid-gif.gif"; sourceTree = ""; }; + 32110559250C027C0048446F /* 100x100.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = 100x100.jpg; sourceTree = ""; }; + 32110568250C0E960048446F /* 100x100-png */ = {isa = PBXFileReference; lastKnownFileType = file; path = "100x100-png"; sourceTree = ""; }; + 3211056A250C0F750048446F /* valid-png-header */ = {isa = PBXFileReference; lastKnownFileType = file; path = "valid-png-header"; sourceTree = ""; }; + 321955BE24BE234C00E3F316 /* ReaderInterestsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderInterestsCoordinator.swift; sourceTree = ""; }; + 321955C024BE4EBF00E3F316 /* ReaderSelectInterestsCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSelectInterestsCoordinatorTests.swift; sourceTree = ""; }; + 321955C224BF77E400E3F316 /* ReaderTopicService+FollowedInterests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderTopicService+FollowedInterests.swift"; sourceTree = ""; }; 3221278523A0BD27002CA183 /* SiteCreationRotatingMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteCreationRotatingMessageView.swift; sourceTree = ""; }; + 3223393B24FEC18000BDD4BF /* ReaderDetailFeaturedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailFeaturedImageView.swift; sourceTree = ""; }; + 3223393D24FEC2A700BDD4BF /* ReaderDetailFeaturedImageView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderDetailFeaturedImageView.xib; sourceTree = ""; }; 32282CF82390B614003378A7 /* WordPress 94.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 94.xcdatamodel"; sourceTree = ""; }; + 3234B8E6252FA0930068DA40 /* ReaderSitesCardCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderSitesCardCell.swift; sourceTree = ""; }; + 3234BB072530D7DC0068DA40 /* ReaderTopicService+SiteInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderTopicService+SiteInfo.swift"; sourceTree = ""; }; + 3234BB162530DFCA0068DA40 /* ReaderTableCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTableCardCell.swift; sourceTree = ""; }; + 3234BB322530EA980068DA40 /* ReaderRecommendedSiteCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderRecommendedSiteCardCell.swift; sourceTree = ""; }; + 3234BB332530EA980068DA40 /* ReaderRecommendedSiteCardCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderRecommendedSiteCardCell.xib; sourceTree = ""; }; + 3236F77124ABB6C90088E8F3 /* ReaderInterestsDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderInterestsDataSource.swift; sourceTree = ""; }; + 3236F77324ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderInterestsCollectionViewCell.swift; sourceTree = ""; }; + 3236F77424ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderInterestsCollectionViewCell.xib; sourceTree = ""; }; + 3236F79D24AE75790088E8F3 /* ReaderTopicService+Interests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderTopicService+Interests.swift"; sourceTree = ""; }; + 3236F7A024B61B950088E8F3 /* ReaderInterestsDataSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderInterestsDataSourceTests.swift; sourceTree = ""; }; + 32387A1D541851E82ED957CE /* Pods-WordPressShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressShareExtension.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension.release.xcconfig"; sourceTree = ""; }; + 324780E0247F2E2A00987525 /* NoResultsViewController+FollowedSites.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NoResultsViewController+FollowedSites.swift"; sourceTree = ""; }; 3249615023F20111004C7733 /* PostSignUpInterstitialViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSignUpInterstitialViewController.swift; sourceTree = ""; }; 3249615123F20111004C7733 /* PostSignUpInterstitialViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PostSignUpInterstitialViewController.xib; sourceTree = ""; }; + 3250490624F988220036B47F /* Interpolation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interpolation.swift; sourceTree = ""; }; + 3254366B24ABA82100B2C5F5 /* ReaderInterestsStyleGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderInterestsStyleGuide.swift; sourceTree = ""; }; 325D3B3C23A8376400766DF6 /* FullScreenCommentReplyViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCommentReplyViewControllerTests.swift; sourceTree = ""; }; + 326E2819250AC4A50029EBF0 /* ImageDimensionFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageDimensionFetcher.swift; sourceTree = ""; }; + 326E281A250AC4A50029EBF0 /* ImageDimensionParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageDimensionParser.swift; sourceTree = ""; }; + 327282732538BC0900C8076D /* WordPress 101.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 101.xcdatamodel"; sourceTree = ""; }; 328CEC5D23A532BA00A6899E /* FullScreenCommentReplyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenCommentReplyViewController.swift; sourceTree = ""; }; + 329F8E5524DDAC61002A5311 /* DynamicHeightCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicHeightCollectionView.swift; sourceTree = ""; }; + 329F8E5724DDBD11002A5311 /* ReaderTopicCollectionViewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTopicCollectionViewCoordinator.swift; sourceTree = ""; }; + 32A218D7251109DB00D1AE6C /* ReaderReportPostAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderReportPostAction.swift; sourceTree = ""; }; 32A29A16236BC8BC009488C2 /* WordPress 93.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 93.xcdatamodel"; sourceTree = ""; }; 32C6CDDA23A1FF0D002556FF /* SiteCreationRotatingMessageViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteCreationRotatingMessageViewTests.swift; sourceTree = ""; }; 32CA6EBF2390C61F00B51347 /* PostListEditorPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostListEditorPresenter.swift; sourceTree = ""; }; - 32F1A6CE23F7083500AB8CA9 /* PostSignUpInterstitialCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSignUpInterstitialCoordinator.swift; sourceTree = ""; }; - 32F1A6D323F7111800AB8CA9 /* PostSignUpInterstitialCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostSignUpInterstitialCoordinatorTests.swift; sourceTree = ""; }; + 32E1BFD924A66F2A007A08F0 /* ReaderInterestsCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderInterestsCollectionViewFlowLayout.swift; sourceTree = ""; }; + 32E1BFFB24AB9D28007A08F0 /* ReaderSelectInterestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSelectInterestsViewController.swift; sourceTree = ""; }; + 32E1BFFC24AB9D28007A08F0 /* ReaderSelectInterestsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderSelectInterestsViewController.xib; sourceTree = ""; }; + 32F2565F25012D3F006B8BC4 /* LinearGradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinearGradientView.swift; sourceTree = ""; }; 33D5016BDA00B45DFCAF3818 /* Pods-WordPressNotificationServiceExtension.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressNotificationServiceExtension.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressNotificationServiceExtension/Pods-WordPressNotificationServiceExtension.release-internal.xcconfig"; sourceTree = ""; }; - 368127CE6F1CA15EC198147D /* Pods-WordPressTodayWidget.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressTodayWidget.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressTodayWidget/Pods-WordPressTodayWidget.debug.xcconfig"; sourceTree = ""; }; + 33E5165A9AB08C676380FA34 /* Pods-JetpackNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackNotificationServiceExtension.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackNotificationServiceExtension/Pods-JetpackNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; 37022D8F1981BF9200F322B7 /* VerticallyStackedButton.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VerticallyStackedButton.h; sourceTree = ""; }; 37022D901981BF9200F322B7 /* VerticallyStackedButton.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VerticallyStackedButton.m; sourceTree = ""; }; 374CB16115B93C0800DD0EBC /* AudioToolbox.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = System/Library/Frameworks/AudioToolbox.framework; sourceTree = SDKROOT; }; - 37E3F5EA2ED7005A5E0BBEC3 /* Pods-WordPressTodayWidget.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressTodayWidget.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressTodayWidget/Pods-WordPressTodayWidget.release.xcconfig"; sourceTree = ""; }; 37EAAF4C1A11799A006D6306 /* CircularImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularImageView.swift; sourceTree = ""; }; - 3B28C7D4D65D0CA6AB9FCFDC /* Pods-WordPressDraftActionExtension.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressDraftActionExtension.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension.release-alpha.xcconfig"; sourceTree = ""; }; + 3AB6A3B516053EA8D0BC3B17 /* Pods-JetpackStatsWidgets.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackStatsWidgets.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackStatsWidgets/Pods-JetpackStatsWidgets.release-alpha.xcconfig"; sourceTree = ""; }; + 3C8DE270EF0498A2129349B0 /* Pods-JetpackNotificationServiceExtension.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackNotificationServiceExtension.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackNotificationServiceExtension/Pods-JetpackNotificationServiceExtension.release-alpha.xcconfig"; sourceTree = ""; }; + 3F015D7F253F9CDB00991CCB /* WordPressStatsWidgetsRelease-Alpha.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = "WordPressStatsWidgetsRelease-Alpha.entitlements"; sourceTree = ""; }; + 3F09CCA72428FF3300D00A8C /* ReaderTabViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTabViewController.swift; sourceTree = ""; }; + 3F09CCA92428FF8300D00A8C /* ReaderTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTabView.swift; sourceTree = ""; }; + 3F09CCAD24292EFD00D00A8C /* ReaderTabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTabItem.swift; sourceTree = ""; }; + 3F107B1829B6F7E0009B3658 /* XCTestCase+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+Utils.swift"; sourceTree = ""; }; + 3F170E232655917400F6F670 /* UIView+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+SwiftUI.swift"; sourceTree = ""; }; 3F1AD48023FC87A400BB1375 /* BlogDetailsViewController+MeButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogDetailsViewController+MeButtonTests.swift"; sourceTree = ""; }; 3F1B66A223A2F54B0075F09E /* ReaderReblogActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderReblogActionTests.swift; sourceTree = ""; }; + 3F1FD31C2548B30D0060C53A /* WordPressStatsWidgets-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WordPressStatsWidgets-Bridging-Header.h"; sourceTree = ""; }; + 3F20FDF4276BF21000DA3CAD /* WordPressFlux */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = WordPressFlux; path = ../WordPressFlux; sourceTree = ""; }; + 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLocalizedString.swift; sourceTree = ""; }; + 3F26DFD124930B5900B5EBD1 /* WordPress 96.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 96.xcdatamodel"; sourceTree = ""; }; 3F29EB7124042276005313DE /* MeViewController+UIViewControllerRestoration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MeViewController+UIViewControllerRestoration.swift"; sourceTree = ""; }; - 3F30E50823FB362700225013 /* WPTabBarController+MeNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPTabBarController+MeNavigation.swift"; sourceTree = ""; }; + 3F2ABE15277037A9005D8916 /* VideoLimitsAlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoLimitsAlertPresenter.swift; sourceTree = ""; }; + 3F2ABE1727704EE2005D8916 /* WPMediaAsset+VideoLimits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPMediaAsset+VideoLimits.swift"; sourceTree = ""; }; + 3F2ABE192770EF3E005D8916 /* Blog+VideoLimits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+VideoLimits.swift"; sourceTree = ""; }; + 3F2F0C15256C6B2C003351C7 /* StatsWidgetsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsWidgetsService.swift; sourceTree = ""; }; + 3F3087C324EDB7040087B548 /* AnnouncementCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementCell.swift; sourceTree = ""; }; + 3F30A6AF299B412E0004452F /* PeopleScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleScreen.swift; sourceTree = ""; }; + 3F39C93427A09927001EC300 /* WordPressLibraryLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressLibraryLogger.swift; sourceTree = ""; }; + 3F3CA64F25D3003C00642A89 /* StatsWidgetsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsWidgetsStore.swift; sourceTree = ""; }; + 3F3D854A251E6418001CA4D2 /* AnnouncementsDataStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsDataStoreTests.swift; sourceTree = ""; }; + 3F3DD0AE26FCDA3100F5F121 /* PresentationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationButton.swift; sourceTree = ""; }; + 3F3DD0B126FD176800F5F121 /* PresentationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentationCard.swift; sourceTree = ""; }; + 3F3DD0B526FD18EB00F5F121 /* Blog+DomainsDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+DomainsDashboardView.swift"; sourceTree = ""; }; + 3F421DF424A3EC2B00CA9B9E /* Spotlightable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Spotlightable.swift; sourceTree = ""; }; 3F43602E23F31D48001DEE70 /* ScenePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScenePresenter.swift; sourceTree = ""; }; 3F43603023F31E09001DEE70 /* MeScenePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeScenePresenter.swift; sourceTree = ""; }; 3F43603223F36515001DEE70 /* BlogListViewController+BlogDetailsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogListViewController+BlogDetailsFactory.swift"; sourceTree = ""; }; + 3F43703E2893201400475B6E /* JetpackOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackOverlayViewController.swift; sourceTree = ""; }; + 3F4370402893207C00475B6E /* JetpackOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackOverlayView.swift; sourceTree = ""; }; + 3F43704328932F0100475B6E /* JetpackBrandingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBrandingCoordinator.swift; sourceTree = ""; }; + 3F46AB0125BF5D6300CE2E98 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Base.lproj/Sites.intentdefinition; sourceTree = ""; }; + 3F46EEC628BC4935004F02B2 /* JetpackPrompt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackPrompt.swift; sourceTree = ""; }; + 3F46EECB28BC4962004F02B2 /* JetpackLandingScreenView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackLandingScreenView.swift; sourceTree = ""; }; + 3F46EED028BFF339004F02B2 /* JetpackPromptsConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackPromptsConfiguration.swift; sourceTree = ""; }; + 3F4D034F28A56F9B00F0A4FD /* CircularImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularImageButton.swift; sourceTree = ""; }; + 3F4D035228A5BFCE00F0A4FD /* JetpackWordPressLogoAnimation_ltr.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackWordPressLogoAnimation_ltr.json; sourceTree = ""; }; + 3F4EB39128AC561600B8DD86 /* JetpackWordPressLogoAnimation_rtl.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackWordPressLogoAnimation_rtl.json; sourceTree = ""; }; + 3F50945A2454ECA000C4470B /* ReaderTabItemsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTabItemsStoreTests.swift; sourceTree = ""; }; + 3F50945E245537A700C4470B /* ReaderTabViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTabViewModelTests.swift; sourceTree = ""; }; + 3F526C4C2538CF2A0069706C /* WordPressStatsWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WordPressStatsWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 3F526C4D2538CF2A0069706C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 3F526C4F2538CF2A0069706C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 3F526C522538CF2A0069706C /* WordPressHomeWidgetToday.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressHomeWidgetToday.swift; sourceTree = ""; }; + 3F526C552538CF2B0069706C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 3F526C572538CF2B0069706C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3F526D562539FAC60069706C /* StatsWidgetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsWidgetsView.swift; sourceTree = ""; }; + 3F5689EF254209790048A9E4 /* SingleStatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStatView.swift; sourceTree = ""; }; + 3F5689FF25420DE80048A9E4 /* MultiStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiStatsView.swift; sourceTree = ""; }; + 3F568A1E254213B60048A9E4 /* VerticalCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalCard.swift; sourceTree = ""; }; + 3F568A2E254216550048A9E4 /* FlexibleCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlexibleCard.swift; sourceTree = ""; }; 3F5B3EAE23A851330060FF1F /* ReaderReblogPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderReblogPresenter.swift; sourceTree = ""; }; 3F5B3EB023A851480060FF1F /* ReaderReblogFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderReblogFormatter.swift; sourceTree = ""; }; - 3F8CB103239E025F007627BF /* ReaderDetailViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailViewControllerTests.swift; sourceTree = ""; }; + 3F5B9B42288AFE4B001D17E9 /* DashboardBadgeCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardBadgeCell.swift; sourceTree = ""; }; + 3F5C861925C9EA2500BABE64 /* HomeWidgetAllTimeData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetAllTimeData.swift; sourceTree = ""; }; + 3F5C86BF25CA197500BABE64 /* WordPressHomeWidgetAllTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressHomeWidgetAllTime.swift; sourceTree = ""; }; + 3F63B93B258179D100F581BE /* StatsWidgetEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsWidgetEntry.swift; sourceTree = ""; }; + 3F662C4924DC9FAC00CAEA95 /* WhatIsNewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatIsNewViewController.swift; sourceTree = ""; }; + 3F6975FE242D941E001F1807 /* ReaderTabViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTabViewModel.swift; sourceTree = ""; }; + 3F6A7E91251BC1DC005B6A61 /* RootViewCoordinator+WhatIsNew.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RootViewCoordinator+WhatIsNew.swift"; sourceTree = ""; }; + 3F6AD0552502A91400080F3B /* AnnouncementsCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsCache.swift; sourceTree = ""; }; + 3F6DA04025646F96002AB88F /* HomeWidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetData.swift; sourceTree = ""; }; + 3F720C2028899DD900519938 /* JetpackBrandingVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBrandingVisibility.swift; sourceTree = ""; }; + 3F73388126C1CE9B0075D1DD /* TimeSelectionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSelectionButton.swift; sourceTree = ""; }; + 3F73BE5C24EB38E200BE99FF /* WhatIsNewScenePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatIsNewScenePresenter.swift; sourceTree = ""; }; + 3F73BE5E24EB3B4400BE99FF /* WhatIsNewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatIsNewView.swift; sourceTree = ""; }; + 3F751D452491A93D0008A2B1 /* ZendeskUtilsTests+Plans.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ZendeskUtilsTests+Plans.swift"; sourceTree = ""; }; + 3F758FD424F6FB4900BBA2FC /* AnnouncementsStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnouncementsStore.swift; sourceTree = ""; }; + 3F762E9226784A950088CD45 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + 3F762E9426784B540088CD45 /* WireMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireMock.swift; sourceTree = ""; }; + 3F762E9626784BED0088CD45 /* FancyAlertComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FancyAlertComponent.swift; sourceTree = ""; }; + 3F762E9826784CC90088CD45 /* XCUIElementQuery+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElementQuery+Utils.swift"; sourceTree = ""; }; + 3F762E9A26784D2A0088CD45 /* XCUIElement+Utils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElement+Utils.swift"; sourceTree = ""; }; + 3F762E9C26784DB40088CD45 /* Globals.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Globals.swift; sourceTree = ""; }; + 3F810A592616870C00ADDCC2 /* UnifiedPrologueIntroContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedPrologueIntroContentView.swift; sourceTree = ""; }; + 3F82310E24564A870086E9B8 /* ReaderTabViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTabViewTests.swift; sourceTree = ""; }; + 3F8513DE260D091500A4B938 /* RoundRectangleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundRectangleView.swift; sourceTree = ""; }; + 3F851414260D0A3300A4B938 /* UnifiedPrologueEditorContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedPrologueEditorContentView.swift; sourceTree = ""; }; + 3F851427260D1EA300A4B938 /* CircledIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircledIcon.swift; sourceTree = ""; }; + 3F86A83629D19C15005D20C0 /* SignupEpilogueTableViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupEpilogueTableViewControllerTests.swift; sourceTree = ""; }; + 3F88065A26C30F2A0074DD21 /* TimeSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSelectionViewController.swift; sourceTree = ""; }; + 3F8B136C25D08F34004FAC0A /* HomeWidgetThisWeekData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetThisWeekData.swift; sourceTree = ""; }; + 3F8B138E25D09AA5004FAC0A /* WordPressHomeWidgetThisWeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressHomeWidgetThisWeek.swift; sourceTree = ""; }; + 3F8B459F29283D6C00730FA4 /* DashboardMigrationSuccessCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardMigrationSuccessCell.swift; sourceTree = ""; }; + 3F8B45A6292C1A2300730FA4 /* MigrationSuccessCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationSuccessCardView.swift; sourceTree = ""; }; + 3F8B45AA292C42CC00730FA4 /* MigrationSuccessCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationSuccessCell.swift; sourceTree = ""; }; 3F8CB10523A07B17007627BF /* ReaderReblogAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderReblogAction.swift; sourceTree = ""; }; + 3F8CBE0A24EEB0EA00F71234 /* AnnouncementsDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsDataSource.swift; sourceTree = ""; }; + 3F8CBE0C24EED2CB00F71234 /* FindOutMoreCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindOutMoreCell.swift; sourceTree = ""; }; + 3F8D988826153484003619E5 /* UnifiedPrologueBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedPrologueBackgroundView.swift; sourceTree = ""; }; + 3F8EEC4D25B4817000EC9DAE /* StatsWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsWidgets.swift; sourceTree = ""; }; + 3F8EEC6F25B4849A00EC9DAE /* SiteListProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteListProvider.swift; sourceTree = ""; }; + 3F946C582684DD8E00B946F6 /* BloggingRemindersActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersActions.swift; sourceTree = ""; }; + 3FA53E9B256571D800F4D9A2 /* HomeWidgetCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetCache.swift; sourceTree = ""; }; + 3FA59B99258289E30073772F /* StatsValueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsValueView.swift; sourceTree = ""; }; + 3FA62FD226FE2E4B0020793A /* ShapeWithTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShapeWithTextView.swift; sourceTree = ""; }; + 3FA640572670CCD40064401E /* UITestsFoundation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = UITestsFoundation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3FA640592670CCD40064401E /* UITestsFoundation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UITestsFoundation.h; sourceTree = ""; }; + 3FA6405A2670CCD40064401E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 3FA640652670CEFE0064401E /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; + 3FAA18CB25797B85002B1911 /* UnconfiguredView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnconfiguredView.swift; sourceTree = ""; }; + 3FAE0651287C8FC500F46508 /* JPScrollViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JPScrollViewDelegate.swift; sourceTree = ""; }; + 3FAF9CC126D01CFE00268EA2 /* DomainsDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainsDashboardView.swift; sourceTree = ""; }; + 3FAF9CC426D03C7400268EA2 /* DomainSuggestionViewControllerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainSuggestionViewControllerWrapper.swift; sourceTree = ""; }; + 3FB1928F26C6109F000F5AA3 /* TimeSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSelectionView.swift; sourceTree = ""; }; + 3FB1929426C79EC6000F5AA3 /* Date+TimeStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeStrings.swift"; sourceTree = ""; }; + 3FB34ACA25672A90001A74A6 /* HomeWidgetTodayData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetTodayData.swift; sourceTree = ""; }; + 3FB5C2B227059AC8007D0ECE /* XCUIElement+Scroll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIElement+Scroll.swift"; sourceTree = ""; }; + 3FBB2D2A27FB6CB200C57BBF /* SiteNameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteNameViewController.swift; sourceTree = ""; }; + 3FBB2D2D27FB715900C57BBF /* SiteNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteNameView.swift; sourceTree = ""; }; + 3FBF21B6267AA17A0098335F /* BloggingRemindersAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersAnimator.swift; sourceTree = ""; }; + 3FC7F89D2612341900FD8728 /* UnifiedPrologueStatsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnifiedPrologueStatsContentView.swift; sourceTree = ""; }; + 3FC8D19A244F43B500495820 /* ReaderTabItemsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTabItemsStore.swift; sourceTree = ""; }; 3FCCAA1423F4A1A3004064C0 /* UIBarButtonItem+MeBarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem+MeBarButton.swift"; sourceTree = ""; }; + 3FCF66E825CAF8C50047F337 /* ListStatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListStatsView.swift; sourceTree = ""; }; + 3FCF66FA25CAF8E00047F337 /* ListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRow.swift; sourceTree = ""; }; + 3FD0316E24201E08005C0993 /* GravatarButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GravatarButtonView.swift; sourceTree = ""; }; + 3FD272DF24CF8F270021F0C8 /* UIColor+Notice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Notice.swift"; sourceTree = ""; }; + 3FD83CBE246C751800381999 /* CoreDataIterativeMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataIterativeMigrator.swift; sourceTree = ""; }; + 3FDDFE9527C8178C00606933 /* SiteStatsInformationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStatsInformationTests.swift; sourceTree = ""; }; + 3FE20C1425CF165700A15525 /* GroupedViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedViewData.swift; sourceTree = ""; }; + 3FE20C3625CF211F00A15525 /* ListViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewData.swift; sourceTree = ""; }; + 3FE77C8225B0CA89007DE9E5 /* LocalizableStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizableStrings.swift; sourceTree = ""; }; + 3FEC241425D73E8B007AFE63 /* ConfettiView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfettiView.swift; sourceTree = ""; }; + 3FF15A55291B4EEA00E1B4E5 /* MigrationCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationCenterView.swift; sourceTree = ""; }; + 3FF15A5B291ED21100E1B4E5 /* MigrationNotificationsCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationNotificationsCenterView.swift; sourceTree = ""; }; + 3FF1A852242D5FCB00373F5D /* WPTabBarController+ReaderTabNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPTabBarController+ReaderTabNavigation.swift"; sourceTree = ""; }; + 3FF717FE291F07AB00323614 /* MigrationCenterViewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationCenterViewConfiguration.swift; sourceTree = ""; }; + 3FFA5ED12876152E00830E28 /* JetpackButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackButton.swift; sourceTree = ""; }; + 3FFDEF7729177D7500B625CE /* MigrationNotificationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationNotificationsViewModel.swift; sourceTree = ""; }; + 3FFDEF7929177D8C00B625CE /* MigrationNotificationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationNotificationsViewController.swift; sourceTree = ""; }; + 3FFDEF7E29177FB100B625CE /* MigrationStepConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationStepConfiguration.swift; sourceTree = ""; }; + 3FFDEF802917882800B625CE /* MigrationNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationNavigationController.swift; sourceTree = ""; }; + 3FFDEF8229179CD000B625CE /* MigrationDependencyContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationDependencyContainer.swift; sourceTree = ""; }; + 3FFDEF842918215700B625CE /* MigrationStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationStepView.swift; sourceTree = ""; }; + 3FFDEF872918596B00B625CE /* MigrationDoneViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationDoneViewModel.swift; sourceTree = ""; }; + 3FFDEF892918597700B625CE /* MigrationDoneViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationDoneViewController.swift; sourceTree = ""; }; + 3FFDEF8E29187F1200B625CE /* MigrationHeaderConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationHeaderConfiguration.swift; sourceTree = ""; }; + 3FFDEF9029187F2100B625CE /* MigrationActionsConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationActionsConfiguration.swift; sourceTree = ""; }; + 3FFE3C0728FE00D10021BB96 /* StatsSegmentedControlDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsSegmentedControlDataTests.swift; sourceTree = ""; }; 400199AA222590E100EB0906 /* AllTimeStatsRecordValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllTimeStatsRecordValueTests.swift; sourceTree = ""; }; 400199AC22259FF300EB0906 /* AnnualAndMostPopularTimeStatsRecordValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnualAndMostPopularTimeStatsRecordValueTests.swift; sourceTree = ""; }; 400A2C752217A8A0000A8A59 /* VisitsSummaryStatsRecordValue+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisitsSummaryStatsRecordValue+CoreDataClass.swift"; sourceTree = ""; }; @@ -2698,7 +6526,7 @@ 400A2C942217B68D000A8A59 /* TopViewedVideoStatsRecordValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopViewedVideoStatsRecordValueTests.swift; sourceTree = ""; }; 400A2C962217B883000A8A59 /* VisitsSummaryStatsRecordValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisitsSummaryStatsRecordValueTests.swift; sourceTree = ""; }; 400F4624201E74EE000CFD9E /* CollectionViewContainerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewContainerRow.swift; sourceTree = ""; }; - 4019B27020885AB900A0C7EB /* Activity.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Activity.storyboard; sourceTree = ""; }; + 4019B27020885AB900A0C7EB /* ActivityDetailViewController.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = ActivityDetailViewController.storyboard; sourceTree = ""; }; 401A3D012027DBD80099A127 /* PluginListCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PluginListCell.xib; sourceTree = ""; }; 401AC82622DD2387006D78D4 /* Blog+Plans.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+Plans.swift"; sourceTree = ""; }; 4020B2BC2007AC850002C963 /* WPStyleGuide+Gridicon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Gridicon.swift"; sourceTree = ""; }; @@ -2706,7 +6534,7 @@ 40232A9D230A6A740036B0B6 /* AbstractPost+HashHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "AbstractPost+HashHelpers.m"; sourceTree = ""; }; 40247E012120FE3600AE1C3C /* AutomatedTransferHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomatedTransferHelper.swift; sourceTree = ""; }; 402B2A7820ACD7690027C1DC /* ActivityStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityStore.swift; sourceTree = ""; }; - 402FFB1B218C27C100FF4A0B /* RegisterDomain.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = RegisterDomain.storyboard; path = Register/RegisterDomain.storyboard; sourceTree = ""; }; + 402FFB1B218C27C100FF4A0B /* RegisterDomain.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = RegisterDomain.storyboard; sourceTree = ""; }; 402FFB20218C33BF00FF4A0B /* WordPress 84.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 84.xcdatamodel"; sourceTree = ""; }; 402FFB23218C36CF00FF4A0B /* AztecVerificationPromptHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AztecVerificationPromptHelper.swift; sourceTree = ""; }; 403269912027719C00608441 /* PluginDirectoryAccessoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginDirectoryAccessoryItem.swift; sourceTree = ""; }; @@ -2771,7 +6599,6 @@ 40EE947D2213213F00CD264F /* PublicizeConnectionStatsRecordValue+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicizeConnectionStatsRecordValue+CoreDataClass.swift"; sourceTree = ""; }; 40EE947E2213213F00CD264F /* PublicizeConnectionStatsRecordValue+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicizeConnectionStatsRecordValue+CoreDataProperties.swift"; sourceTree = ""; }; 40EE948122132F5800CD264F /* PublicizeConectionStatsRecordValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicizeConectionStatsRecordValueTests.swift; sourceTree = ""; }; - 40F031E19B32BAF8654838C0 /* Pods-WordPressThisWeekWidget.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressThisWeekWidget.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressThisWeekWidget/Pods-WordPressThisWeekWidget.release-alpha.xcconfig"; sourceTree = ""; }; 40F46B6822121BA800A2143B /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnnualAndMostPopularTimeStatsRecordValue+CoreDataClass.swift"; sourceTree = ""; }; 40F46B6922121BA800A2143B /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnnualAndMostPopularTimeStatsRecordValue+CoreDataProperties.swift"; sourceTree = ""; }; 40F50B7B22130E6C00CBBB73 /* FollowersStatsRecordValue+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowersStatsRecordValue+CoreDataClass.swift"; sourceTree = ""; }; @@ -2779,20 +6606,15 @@ 40F50B7F221310D400CBBB73 /* FollowersStatsRecordValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersStatsRecordValueTests.swift; sourceTree = ""; }; 40F50B81221310F000CBBB73 /* StatsTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsTestCase.swift; sourceTree = ""; }; 40FC6B7E2072E3EB00B9A1CD /* ActivityDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityDetailViewController.swift; sourceTree = ""; }; - 41341F0000A9A65A3326F2EC /* Pods-WordPressAllTimeWidget.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressAllTimeWidget.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressAllTimeWidget/Pods-WordPressAllTimeWidget.release-internal.xcconfig"; sourceTree = ""; }; 430693731DD25F31009398A2 /* PostPost.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = PostPost.storyboard; sourceTree = ""; }; 430D50BD212B7AAE008F15F4 /* NoticeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeStyle.swift; sourceTree = ""; }; - 43134378217954F100DA2176 /* QuickStartSkipAllCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickStartSkipAllCell.xib; sourceTree = ""; }; - 4313437A217956DB00DA2176 /* QuickStartSkipAllCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartSkipAllCell.swift; sourceTree = ""; }; + 430F7B409FE22699ADB1A724 /* Pods_JetpackDraftActionExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JetpackDraftActionExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4319AADD2090F00C0025D68E /* FancyAlertViewController+NotificationPrimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FancyAlertViewController+NotificationPrimer.swift"; sourceTree = ""; }; - 431EF35921F7D4000017BE16 /* QuickStartListTitleCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickStartListTitleCell.xib; sourceTree = ""; }; 4322A20C203E1885004EA740 /* SignupUsernameTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupUsernameTableViewController.swift; sourceTree = ""; }; 4326191422FCB9DC003C7642 /* MurielColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MurielColor.swift; sourceTree = ""; }; - 43290CF3214F755400F6B398 /* FancyAlertViewController+QuickStart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FancyAlertViewController+QuickStart.swift"; sourceTree = ""; }; 43290D012159652800F6B398 /* WordPress 81.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 81.xcdatamodel"; sourceTree = ""; }; 43290D03215C28D800F6B398 /* Blog+QuickStart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+QuickStart.swift"; sourceTree = ""; }; 43290D09215E8B1200F6B398 /* QuickStartSpotlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartSpotlightView.swift; sourceTree = ""; }; - 432A5ADF21F9222A00603959 /* QuickStartListTitleCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartListTitleCell.swift; sourceTree = ""; }; 433432511E9ED18900915988 /* LoginEpilogueViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginEpilogueViewController.swift; sourceTree = ""; }; 433840C622C2BA5B00CB13F8 /* AppImages.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = AppImages.xcassets; path = Resources/AppImages.xcassets; sourceTree = ""; }; 4348C88221002FBD00735DC0 /* QuickStartTourGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartTourGuide.swift; sourceTree = ""; }; @@ -2813,7 +6635,6 @@ 436D55EF2115CB6800CEAA33 /* RegisterDomainDetailsSectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterDomainDetailsSectionTests.swift; sourceTree = ""; }; 436D55F4211632B700CEAA33 /* RegisterDomainDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterDomainDetailsViewModelTests.swift; sourceTree = ""; }; 436D560C2117312600CEAA33 /* RegisterDomainSuggestionsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RegisterDomainSuggestionsViewController.swift; sourceTree = ""; }; - 436D560D2117312600CEAA33 /* RegisterDomainSuggestionsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RegisterDomainSuggestionsTableViewController.swift; sourceTree = ""; }; 436D56102117312700CEAA33 /* RegisterDomainDetailsViewModel+SectionDefinitions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "RegisterDomainDetailsViewModel+SectionDefinitions.swift"; sourceTree = ""; }; 436D56112117312700CEAA33 /* RegisterDomainDetailsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RegisterDomainDetailsViewModel.swift; sourceTree = ""; }; 436D56122117312700CEAA33 /* RegisterDomainDetailsViewModel+RowDefinitions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "RegisterDomainDetailsViewModel+RowDefinitions.swift"; sourceTree = ""; }; @@ -2832,6 +6653,7 @@ 437542E21DD4E19E00D6B727 /* EditPostViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditPostViewController.swift; sourceTree = ""; }; 4388FEFD20A4E0B900783948 /* NotificationsViewController+AppRatings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationsViewController+AppRatings.swift"; sourceTree = ""; }; 4388FEFF20A4E19C00783948 /* NotificationsViewController+PushPrimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationsViewController+PushPrimer.swift"; sourceTree = ""; }; + 4391027D80CFEDF45B8712A3 /* Pods-JetpackIntents.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackIntents.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackIntents/Pods-JetpackIntents.release-internal.xcconfig"; sourceTree = ""; }; 4395A1582106389800844E8E /* QuickStartTours.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartTours.swift; sourceTree = ""; }; 4395A15C2106718900844E8E /* QuickStartChecklistCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartChecklistCell.swift; sourceTree = ""; }; 439F4F332196537500F8D0C7 /* RevisionDiffViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RevisionDiffViewController.swift; sourceTree = ""; }; @@ -2844,28 +6666,110 @@ 43C8831B211A47DB003125C7 /* WordPress 78.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 78.xcdatamodel"; sourceTree = ""; }; 43C9908D21067E22009EFFEB /* QuickStartChecklistViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartChecklistViewController.swift; sourceTree = ""; }; 43D54D121DCAA070007F575F /* PostPostViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostPostViewController.swift; sourceTree = ""; }; - 43D74AC720F8D17A004AD934 /* DomainSuggestionsButtonViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainSuggestionsButtonViewPresenter.swift; sourceTree = ""; }; 43D74ACD20F906DD004AD934 /* InlineEditableNameValueCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InlineEditableNameValueCell.xib; sourceTree = ""; }; 43D74ACF20F906EE004AD934 /* InlineEditableNameValueCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineEditableNameValueCell.swift; sourceTree = ""; }; 43D74AD320FB5ABA004AD934 /* RegisterDomainSectionHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RegisterDomainSectionHeaderView.xib; sourceTree = ""; }; 43D74AD520FB5AD5004AD934 /* RegisterDomainSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterDomainSectionHeaderView.swift; sourceTree = ""; }; 43DC0EF02040B23200896C9C /* SignupUsernameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupUsernameViewController.swift; sourceTree = ""; }; 43DDFE8B21715ADD008BE72F /* WPTabBarController+QuickStart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPTabBarController+QuickStart.swift"; sourceTree = ""; }; - 43DDFE8F21785EAC008BE72F /* QuickStartCongratulationsCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickStartCongratulationsCell.xib; sourceTree = ""; }; - 43DDFE912178635D008BE72F /* QuickStartCongratulationsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartCongratulationsCell.swift; sourceTree = ""; }; 43EE90ED223B1028006A33E9 /* TextBundleWrapper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TextBundleWrapper.h; sourceTree = ""; }; 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TextBundleWrapper.m; sourceTree = ""; }; 43FB3F401EBD215C00FC8A62 /* LoginEpilogueBlogCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginEpilogueBlogCell.swift; sourceTree = ""; }; 43FB3F461EC10F1E00FC8A62 /* LoginEpilogueTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginEpilogueTableViewController.swift; sourceTree = ""; }; 43FF64EE20DAA0840060A69A /* GravatarUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GravatarUploader.swift; sourceTree = ""; }; + 46122253268E0416001134D7 /* WordPress 127.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 127.xcdatamodel"; sourceTree = ""; }; + 46183CF1251BD5F1004F9AFD /* WordPress 99.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 99.xcdatamodel"; sourceTree = ""; }; + 46183CF2251BD658004F9AFD /* PageTemplateLayout+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PageTemplateLayout+CoreDataClass.swift"; sourceTree = ""; }; + 46183CF3251BD658004F9AFD /* PageTemplateLayout+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PageTemplateLayout+CoreDataProperties.swift"; sourceTree = ""; }; + 46183D1D251BD6A0004F9AFD /* PageTemplateCategory+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PageTemplateCategory+CoreDataClass.swift"; sourceTree = ""; }; + 46183D1E251BD6A0004F9AFD /* PageTemplateCategory+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PageTemplateCategory+CoreDataProperties.swift"; sourceTree = ""; }; + 46241C0E2540BD01002B8A12 /* SiteDesignStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteDesignStep.swift; sourceTree = ""; }; + 46241C3A2540D483002B8A12 /* SiteDesignContentCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteDesignContentCollectionViewController.swift; sourceTree = ""; }; + 4625B555253789C000C04AAD /* CollapsableHeaderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsableHeaderViewController.swift; sourceTree = ""; }; + 4625B6332538B53700C04AAD /* CollapsableHeaderViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CollapsableHeaderViewController.xib; sourceTree = ""; }; + 4625BC26253E285700C04AAD /* WordPress 102.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 102.xcdatamodel"; sourceTree = ""; }; + 4629E4202440C5B20002E15C /* GutenbergCoverUploadProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergCoverUploadProcessor.swift; sourceTree = ""; }; + 4629E4222440C8160002E15C /* GutenbergCoverUploadProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergCoverUploadProcessorTests.swift; sourceTree = ""; }; 462F4E0618369F0B0028D2F8 /* BlogDetailsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlogDetailsViewController.h; sourceTree = ""; }; 462F4E0718369F0B0028D2F8 /* BlogDetailsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; lineEnding = 0; path = BlogDetailsViewController.m; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objc; }; + 4631359024AD013F0017E65C /* PageCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageCoordinator.swift; sourceTree = ""; }; + 4631359524AD068B0017E65C /* GutenbergLayoutPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergLayoutPickerViewController.swift; sourceTree = ""; }; + 46365555260E1DE5006398E4 /* WordPress 118.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 118.xcdatamodel"; sourceTree = ""; }; + 464688D6255C71D200ECA61C /* SiteDesignPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteDesignPreviewViewController.swift; sourceTree = ""; }; + 464688D7255C71D200ECA61C /* TemplatePreviewViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TemplatePreviewViewController.xib; sourceTree = ""; }; + 465B097924C877E500336B6C /* GutenbergLightNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GutenbergLightNavigationController.swift; sourceTree = ""; }; + 465F89F6263B690C00F4C950 /* wp-block-editor-v1-settings-success-NotThemeJSON.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "wp-block-editor-v1-settings-success-NotThemeJSON.json"; sourceTree = ""; }; + 465F8A09263B692600F4C950 /* wp-block-editor-v1-settings-success-ThemeJSON.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "wp-block-editor-v1-settings-success-ThemeJSON.json"; sourceTree = ""; }; + 46638DF5244904A3006E8439 /* GutenbergBlockProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergBlockProcessor.swift; sourceTree = ""; }; + 466653492501552A00165DD4 /* LayoutPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutPreviewViewController.swift; sourceTree = ""; }; + 467D3DF925E4436000EB9CB0 /* SitePromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitePromptView.swift; sourceTree = ""; }; + 467D3E0B25E4436D00EB9CB0 /* SitePromptView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SitePromptView.xib; sourceTree = ""; }; + 4688E6CB26AB571D00A5D894 /* RequestAuthenticatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestAuthenticatorTests.swift; sourceTree = ""; }; + 469CE06B24BCED75003BDC8B /* CategorySectionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategorySectionTableViewCell.swift; sourceTree = ""; }; + 469CE06C24BCED75003BDC8B /* CategorySectionTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CategorySectionTableViewCell.xib; sourceTree = ""; }; + 469CE06F24BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsableHeaderCollectionViewCell.swift; sourceTree = ""; }; + 469CE07024BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CollapsableHeaderCollectionViewCell.xib; sourceTree = ""; }; + 469EB16324D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollabsableHeaderFilterCollectionViewCell.swift; sourceTree = ""; }; + 469EB16424D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CollabsableHeaderFilterCollectionViewCell.xib; sourceTree = ""; }; + 469EB16724D9AD8B00C764CB /* CollapsableHeaderFilterBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsableHeaderFilterBar.swift; sourceTree = ""; }; + 46B1A16A26A774E500F058AE /* CollapsableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsableHeaderView.swift; sourceTree = ""; }; + 46B30B772582C7DD00A25E66 /* SiteAddressServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteAddressServiceTests.swift; sourceTree = ""; }; + 46B30B862582CA2200A25E66 /* domain-suggestions.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "domain-suggestions.json"; sourceTree = ""; }; + 46C984672527863E00988BB9 /* LayoutPickerAnalyticsEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutPickerAnalyticsEvent.swift; sourceTree = ""; }; + 46CFA7BE262745F70077BAD9 /* get_wp_v2_themes_twentytwentyone.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = get_wp_v2_themes_twentytwentyone.json; sourceTree = ""; }; + 46CFA7E2262746940077BAD9 /* get_wp_v2_themes_twentytwenty.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = get_wp_v2_themes_twentytwenty.json; sourceTree = ""; }; + 46D6114E2555DAED00B0B7BB /* SiteCreationAnalyticsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteCreationAnalyticsHelper.swift; sourceTree = ""; }; + 46E327D024E705C7000944B3 /* PageLayoutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageLayoutService.swift; sourceTree = ""; }; + 46F583A42624C8FA0010A723 /* WordPress 120.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 120.xcdatamodel"; sourceTree = ""; }; + 46F583A52624CE790010A723 /* BlockEditorSettings+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlockEditorSettings+CoreDataClass.swift"; sourceTree = ""; }; + 46F583A62624CE790010A723 /* BlockEditorSettings+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlockEditorSettings+CoreDataProperties.swift"; sourceTree = ""; }; + 46F583A72624CE790010A723 /* BlockEditorSettingElement+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlockEditorSettingElement+CoreDataClass.swift"; sourceTree = ""; }; + 46F583A82624CE790010A723 /* BlockEditorSettingElement+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlockEditorSettingElement+CoreDataProperties.swift"; sourceTree = ""; }; + 46F583D32624D0BC0010A723 /* Blog+BlockEditorSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+BlockEditorSettings.swift"; sourceTree = ""; }; + 46F584812624DCC80010A723 /* BlockEditorSettingsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockEditorSettingsService.swift; sourceTree = ""; }; + 46F584B72624E6380010A723 /* BlockEditorSettings+GutenbergEditorSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlockEditorSettings+GutenbergEditorSettings.swift"; sourceTree = ""; }; + 46F58500262605930010A723 /* BlockEditorSettingsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockEditorSettingsServiceTests.swift; sourceTree = ""; }; 46F84612185A8B7E009D0DA5 /* PostContentProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PostContentProvider.h; sourceTree = ""; }; - 48690E659987FD4472EEDE5F /* Pods-WordPressNotificationContentExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressNotificationContentExtension.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressNotificationContentExtension/Pods-WordPressNotificationContentExtension.release.xcconfig"; sourceTree = ""; }; - 4D520D4E22972BC9002F5924 /* acknowledgements.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = acknowledgements.html; path = "../Pods/Target Support Files/Pods-WordPress/acknowledgements.html"; sourceTree = ""; }; - 4F943DB9A7237917709622D5 /* Pods-WordPressNotificationContentExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressNotificationContentExtension.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressNotificationContentExtension/Pods-WordPressNotificationContentExtension.debug.xcconfig"; sourceTree = ""; }; - 51A5F017948878F7E26979A0 /* Pods-WordPress.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPress.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPress/Pods-WordPress.release.xcconfig"; sourceTree = ""; }; - 556E3A9C1600564F6A3CADF6 /* Pods_WordPress.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPress.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 49E3445F1B568603958DA79D /* Pods-JetpackNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackNotificationServiceExtension.release.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackNotificationServiceExtension/Pods-JetpackNotificationServiceExtension.release.xcconfig"; sourceTree = ""; }; + 4A072CD129093704006235BE /* AsyncBlockOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncBlockOperation.swift; sourceTree = ""; }; + 4A17C1A3281A823E0001FFE5 /* NSManagedObject+Fixture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObject+Fixture.swift"; sourceTree = ""; }; + 4A1E77C5298897F6006281CC /* SharingSyncService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SharingSyncService.swift; sourceTree = ""; }; + 4A1E77C82988997C006281CC /* PublicizeConnection+Creation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicizeConnection+Creation.swift"; sourceTree = ""; }; + 4A1E77CB2989F2F7006281CC /* WPAccount+DeduplicateBlogs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPAccount+DeduplicateBlogs.swift"; sourceTree = ""; }; + 4A2172F728EAACFF0006F4F1 /* BlogQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogQuery.swift; sourceTree = ""; }; + 4A2172FD28F688890006F4F1 /* Blog+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+Media.swift"; sourceTree = ""; }; + 4A266B8E282B05210089CF3D /* JSONObjectTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONObjectTests.swift; sourceTree = ""; }; + 4A266B90282B13A70089CF3D /* CoreDataTestCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataTestCase.swift; sourceTree = ""; }; + 4A358DE529B5EB8D00BFCEBE /* PublicizeService+Lookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PublicizeService+Lookup.swift"; sourceTree = ""; }; + 4A358DE829B5F14C00BFCEBE /* SharingButton+Lookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SharingButton+Lookup.swift"; sourceTree = ""; }; + 4A526BDD296BE9A50007B5BA /* CoreDataService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CoreDataService.m; sourceTree = ""; }; + 4A526BDE296BE9A50007B5BA /* CoreDataService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CoreDataService.h; sourceTree = ""; }; + 4A76A4BA29D4381000AABF4B /* CommentService+LikesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CommentService+LikesTests.swift"; sourceTree = ""; }; + 4A76A4BC29D43BFD00AABF4B /* CommentService+MorderationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CommentService+MorderationTests.swift"; sourceTree = ""; }; + 4A76A4BE29D4F0A500AABF4B /* reader-post-comments-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "reader-post-comments-success.json"; sourceTree = ""; }; + 4A82C43028D321A300486CFF /* Blog+Post.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Blog+Post.swift"; sourceTree = ""; }; + 4A87854F290F2C7D0083AB78 /* Media+Sync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Media+Sync.swift"; sourceTree = ""; }; + 4A9314DB297790C300360232 /* PeopleServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeopleServiceTests.swift; sourceTree = ""; }; + 4A9314E32979FA4700360232 /* PostCategory+Creation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostCategory+Creation.swift"; sourceTree = ""; }; + 4A9314E6297A0C5000360232 /* PostCategory+Lookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostCategory+Lookup.swift"; sourceTree = ""; }; + 4A9948E129714EF1006282A9 /* AccountSettingsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingsServiceTests.swift; sourceTree = ""; }; + 4A9948E3297624EF006282A9 /* Blog+Creation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+Creation.swift"; sourceTree = ""; }; + 4A9B81E22921AE02007A05D1 /* ContextManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContextManager.swift; sourceTree = ""; }; + 4AA33EF729963ABE005B6E23 /* ReaderAbstractTopic+Lookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderAbstractTopic+Lookup.swift"; sourceTree = ""; }; + 4AA33EFA2999AE3B005B6E23 /* ReaderListTopic+Creation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderListTopic+Creation.swift"; sourceTree = ""; }; + 4AA33F002999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderSiteTopic+Lookup.swift"; sourceTree = ""; }; + 4AA33F03299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderTagTopic+Lookup.swift"; sourceTree = ""; }; + 4AD5656B28E3D0670054C676 /* ReaderPost+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderPost+Helper.swift"; sourceTree = ""; }; + 4AD5656E28E413160054C676 /* Blog+History.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+History.swift"; sourceTree = ""; }; + 4AD5657128E543A30054C676 /* BlogQueryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogQueryTests.swift; sourceTree = ""; }; + 4AEF2DD829A84B2C00345734 /* ReaderSiteServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSiteServiceTests.swift; sourceTree = ""; }; + 4AFB8FBE2824999400A2F4B2 /* ContextManager+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ContextManager+Helpers.swift"; sourceTree = ""; }; + 4D520D4E22972BC9002F5924 /* acknowledgements.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = acknowledgements.html; path = "../Pods/Target Support Files/Pods-Apps-WordPress/acknowledgements.html"; sourceTree = ""; }; + 4D670B9448DF9991366CF42D /* Pods_WordPressStatsWidgets.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressStatsWidgets.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 51A5F017948878F7E26979A0 /* Pods-Apps-WordPress.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Apps-WordPress.release.xcconfig"; path = "../Pods/Target Support Files/Pods-Apps-WordPress/Pods-Apps-WordPress.release.xcconfig"; sourceTree = ""; }; + 528B9926294302CD0A4EB5C4 /* Pods-WordPressScreenshotGeneration.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressScreenshotGeneration.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressScreenshotGeneration/Pods-WordPressScreenshotGeneration.release-alpha.xcconfig"; sourceTree = ""; }; + 549D51B99FF59CBE21A37CBF /* Pods-JetpackIntents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackIntents.release.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackIntents/Pods-JetpackIntents.release.xcconfig"; sourceTree = ""; }; + 56FEDB6A28783D8F00E1EA93 /* WordPress 145.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 145.xcdatamodel"; sourceTree = ""; }; 570265142298921800F2214C /* PostListTableViewHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostListTableViewHandler.swift; sourceTree = ""; }; 570265162298960B00F2214C /* PostListTableViewHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostListTableViewHandlerTests.swift; sourceTree = ""; }; 5703A4C522C003DC0028A343 /* WPStyleGuide+Posts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Posts.swift"; sourceTree = ""; }; @@ -2903,11 +6807,11 @@ 57D6C83D22945A10003DDC7E /* PostCompactCellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCompactCellTests.swift; sourceTree = ""; }; 57D6C83F229498C4003DDC7E /* InteractivePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePostView.swift; sourceTree = ""; }; 57DF04C0231489A200CC93D6 /* PostCardStatusViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCardStatusViewModelTests.swift; sourceTree = ""; }; + 57E15BC2269B6B7419464B6F /* Pods_Apps_Jetpack.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Apps_Jetpack.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5903AE1A19B60A98009D5354 /* WPButtonForNavigationBar.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPButtonForNavigationBar.m; sourceTree = ""; usesTabs = 0; }; 5903AE1C19B60AB9009D5354 /* WPButtonForNavigationBar.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WPButtonForNavigationBar.h; sourceTree = ""; usesTabs = 0; }; 590E873A1CB8205700D1B734 /* PostListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostListViewController.swift; sourceTree = ""; }; 591232681CCEAA5100B86207 /* AbstractPostListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AbstractPostListViewController.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - 591252291A38AE9C00468279 /* TodayWidgetPrefix.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TodayWidgetPrefix.pch; sourceTree = ""; }; 591A428D1A6DC6F2003807A6 /* WPGUIConstants.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPGUIConstants.h; sourceTree = ""; }; 591A428E1A6DC6F2003807A6 /* WPGUIConstants.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPGUIConstants.m; sourceTree = ""; }; 591AA4FF1CEF9BF20074934F /* Post.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; @@ -2932,7 +6836,7 @@ 59A9AB331B4C33A500A433DC /* ThemeService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ThemeService.h; sourceTree = ""; }; 59A9AB341B4C33A500A433DC /* ThemeService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThemeService.m; sourceTree = ""; }; 59A9AB391B4C3ECD00A433DC /* LocalCoreDataServiceTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = LocalCoreDataServiceTests.m; sourceTree = ""; }; - 59B48B611B99E132008EBB84 /* JSONLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = JSONLoader.swift; path = TestUtilities/JSONLoader.swift; sourceTree = ""; }; + 59B48B611B99E132008EBB84 /* JSONObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = JSONObject.swift; path = TestUtilities/JSONObject.swift; sourceTree = ""; }; 59DCA5201CC68AF3000F245F /* PageListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PageListViewController.swift; sourceTree = ""; }; 59DD94321AC479ED0032DD6B /* WPLogger.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPLogger.h; sourceTree = ""; }; 59DD94331AC479ED0032DD6B /* WPLogger.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPLogger.m; sourceTree = ""; }; @@ -2940,12 +6844,11 @@ 59E1D46D1CEF77B500126697 /* Page+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Page+CoreDataProperties.swift"; sourceTree = ""; }; 59ECF87A1CB7061D00E68F25 /* PostSharingControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = PostSharingControllerTests.swift; path = Posts/PostSharingControllerTests.swift; sourceTree = ""; }; 59FBD5611B5684F300734466 /* ThemeServiceTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ThemeServiceTests.m; sourceTree = ""; }; + 5C1CEB34870A8BA1ED1E502B /* Pods-WordPressUITests.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressUITests.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressUITests/Pods-WordPressUITests.release-alpha.xcconfig"; sourceTree = ""; }; 5D1181E61B4D6DEB003F3084 /* WPStyleGuide+Reader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Reader.swift"; sourceTree = ""; }; 5D13FA561AF99C2100F06492 /* PageListSectionHeaderView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = PageListSectionHeaderView.xib; sourceTree = ""; }; 5D146EB9189857ED0068FDC6 /* FeaturedImageViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FeaturedImageViewController.h; sourceTree = ""; usesTabs = 0; }; 5D146EBA189857ED0068FDC6 /* FeaturedImageViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FeaturedImageViewController.m; sourceTree = ""; usesTabs = 0; }; - 5D17F0BC1A1D4C5F0087CCB8 /* PrivateSiteURLProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PrivateSiteURLProtocol.h; sourceTree = ""; }; - 5D17F0BD1A1D4C5F0087CCB8 /* PrivateSiteURLProtocol.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PrivateSiteURLProtocol.m; sourceTree = ""; }; 5D18FE9C1AFBB17400EFEED0 /* RestorePageTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RestorePageTableViewCell.h; sourceTree = ""; }; 5D18FE9D1AFBB17400EFEED0 /* RestorePageTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RestorePageTableViewCell.m; sourceTree = ""; }; 5D18FE9E1AFBB17400EFEED0 /* RestorePageTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = RestorePageTableViewCell.xib; sourceTree = ""; }; @@ -2956,7 +6859,6 @@ 5D2C05541AD2F56200A753FE /* NavBarTitleDropdownButton.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NavBarTitleDropdownButton.h; sourceTree = ""; }; 5D2C05551AD2F56200A753FE /* NavBarTitleDropdownButton.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; lineEnding = 0; path = NavBarTitleDropdownButton.m; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objc; }; 5D2FB2821AE98C4600F1D4ED /* RestorePostTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = RestorePostTableViewCell.xib; sourceTree = ""; }; - 5D35F7581A042255004E7B0D /* WPCommentContentViewProvider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WPCommentContentViewProvider.h; sourceTree = ""; }; 5D3D559518F88C3500782892 /* ReaderPostService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ReaderPostService.h; sourceTree = ""; }; 5D3D559618F88C3500782892 /* ReaderPostService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ReaderPostService.m; sourceTree = ""; }; 5D3E334C15EEBB6B005FC6F2 /* ReachabilityUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ReachabilityUtils.h; sourceTree = ""; }; @@ -2975,14 +6877,8 @@ 5D4E30CF1AA4B41A000D9904 /* WPStyleGuide+Pages.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "WPStyleGuide+Pages.h"; path = "../Post/WPStyleGuide+Pages.h"; sourceTree = ""; }; 5D4E30D01AA4B41A000D9904 /* WPStyleGuide+Pages.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "WPStyleGuide+Pages.m"; path = "../Post/WPStyleGuide+Pages.m"; sourceTree = ""; }; 5D51ADAE19A832AF00539C0B /* WordPress-20-21.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = "WordPress-20-21.xcmappingmodel"; sourceTree = ""; }; - 5D577D31189127BE00B964C3 /* PostGeolocationViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PostGeolocationViewController.h; sourceTree = ""; }; - 5D577D32189127BE00B964C3 /* PostGeolocationViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostGeolocationViewController.m; sourceTree = ""; }; - 5D577D341891360900B964C3 /* PostGeolocationView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PostGeolocationView.h; sourceTree = ""; }; - 5D577D351891360900B964C3 /* PostGeolocationView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostGeolocationView.m; sourceTree = ""; }; 5D5A6E911B613CA400DAF819 /* ReaderPostCardCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderPostCardCell.swift; sourceTree = ""; }; 5D5A6E921B613CA400DAF819 /* ReaderPostCardCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ReaderPostCardCell.xib; sourceTree = ""; }; - 5D5D0025187DA9D30027CEF6 /* PostCategoriesViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = PostCategoriesViewController.h; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objcpp; }; - 5D5D0026187DA9D30027CEF6 /* PostCategoriesViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; lineEnding = 0; path = PostCategoriesViewController.m; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objc; }; 5D62BAD518AA88210044E5F7 /* PageSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PageSettingsViewController.h; sourceTree = ""; }; 5D62BAD618AA88210044E5F7 /* PageSettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PageSettingsViewController.m; sourceTree = ""; }; 5D62BAD818AAAE9B0044E5F7 /* PostSettingsViewController_Internal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PostSettingsViewController_Internal.h; sourceTree = ""; usesTabs = 0; }; @@ -3004,8 +6900,6 @@ 5D7DEA2819D488DD0032EE77 /* WPStyleGuide+ReaderComments.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+ReaderComments.swift"; sourceTree = ""; }; 5D839AA6187F0D6B00811F4A /* PostFeaturedImageCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PostFeaturedImageCell.h; sourceTree = ""; }; 5D839AA7187F0D6B00811F4A /* PostFeaturedImageCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostFeaturedImageCell.m; sourceTree = ""; }; - 5D839AA9187F0D8000811F4A /* PostGeolocationCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PostGeolocationCell.h; sourceTree = ""; }; - 5D839AAA187F0D8000811F4A /* PostGeolocationCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostGeolocationCell.m; sourceTree = ""; }; 5D8D53ED19250412003C8859 /* BlogSelectorViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlogSelectorViewController.h; sourceTree = ""; }; 5D8D53EE19250412003C8859 /* BlogSelectorViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogSelectorViewController.m; sourceTree = ""; }; 5D91D9021AE1AD12000BF163 /* WordPress 29.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 29.xcdatamodel"; sourceTree = ""; }; @@ -3016,8 +6910,6 @@ 5DA5BF3318E32DCF005F11F9 /* Theme.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Theme.h; sourceTree = ""; }; 5DA5BF3418E32DCF005F11F9 /* Theme.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Theme.m; sourceTree = ""; }; 5DA5BF4B18E331D8005F11F9 /* WordPress 16.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 16.xcdatamodel"; sourceTree = ""; }; - 5DAE40AB19EC70930011A0AE /* ReaderPostHeaderView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ReaderPostHeaderView.h; sourceTree = ""; }; - 5DAE40AC19EC70930011A0AE /* ReaderPostHeaderView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ReaderPostHeaderView.m; sourceTree = ""; }; 5DAFEAB61AF2CA6E00B3E1D7 /* PostMetaButton.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PostMetaButton.h; sourceTree = ""; }; 5DAFEAB71AF2CA6E00B3E1D7 /* PostMetaButton.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostMetaButton.m; sourceTree = ""; }; 5DB3BA0318D0E7B600F3F3E9 /* WPPickerView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPPickerView.h; sourceTree = ""; usesTabs = 0; }; @@ -3044,14 +6936,12 @@ 5DFA7EC41AF814E40072023B /* PageListTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PageListTableViewCell.h; sourceTree = ""; }; 5DFA7EC51AF814E40072023B /* PageListTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PageListTableViewCell.m; sourceTree = ""; }; 5DFA7EC61AF814E40072023B /* PageListTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = PageListTableViewCell.xib; sourceTree = ""; }; - 61923FEDAFD6503928030311 /* Pods-WordPressDraftActionExtension.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressDraftActionExtension.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension.release-internal.xcconfig"; sourceTree = ""; }; - 659BB95E3808B6781E2D8D85 /* Pods-WordPressScreenshotGeneration.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressScreenshotGeneration.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressScreenshotGeneration/Pods-WordPressScreenshotGeneration.debug.xcconfig"; sourceTree = ""; }; - 6A9F41AAF18FB527262CC57C /* Pods-WordPressAllTimeWidget.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressAllTimeWidget.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressAllTimeWidget/Pods-WordPressAllTimeWidget.release.xcconfig"; sourceTree = ""; }; - 6CC2CF274BA2F245C464D562 /* Pods_WordPressNotificationContentExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressNotificationContentExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5E48AA7F709A5B0F2318A7E3 /* Pods-JetpackDraftActionExtension.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackDraftActionExtension.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackDraftActionExtension/Pods-JetpackDraftActionExtension.release-internal.xcconfig"; sourceTree = ""; }; + 67832AB9D81652460A80BE66 /* Pods-Apps-Jetpack.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Apps-Jetpack.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-Apps-Jetpack/Pods-Apps-Jetpack.release-internal.xcconfig"; sourceTree = ""; }; + 6C1B070FAD875CA331772B57 /* Pods-WordPressStatsWidgets.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressStatsWidgets.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressStatsWidgets/Pods-WordPressStatsWidgets.release-alpha.xcconfig"; sourceTree = ""; }; + 6E5BA46826A59D620043A6F2 /* SupportScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportScreenTests.swift; sourceTree = ""; }; + 6EC71EC22689A67400ACC0A0 /* SupportScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportScreen.swift; sourceTree = ""; }; 6EDC0E8E105881A800F68A1D /* iTunesArtwork */ = {isa = PBXFileReference; lastKnownFileType = file; path = iTunesArtwork; sourceTree = ""; }; - 7059CD1F0F332B6500A0660B /* WPCategoryTree.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = WPCategoryTree.h; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objcpp; }; - 7059CD200F332B6500A0660B /* WPCategoryTree.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; lineEnding = 0; path = WPCategoryTree.m; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objc; }; - 712D0A17BC83B9730BD7CCC0 /* Pods-WordPressDraftActionExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressDraftActionExtension.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension.debug.xcconfig"; sourceTree = ""; }; 730354B921C867E500CD18C2 /* SiteCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteCreatorTests.swift; sourceTree = ""; }; 7305138221C031FC006BD0A1 /* AssembledSiteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssembledSiteView.swift; sourceTree = ""; }; 730D290E22976F1A0004BB1E /* BottomScrollAnalyticsTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomScrollAnalyticsTracker.swift; sourceTree = ""; }; @@ -3067,8 +6957,6 @@ 731E88C721C9A10A0055C014 /* ErrorStateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorStateView.swift; sourceTree = ""; }; 731E88C821C9A10A0055C014 /* ErrorStateViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorStateViewController.swift; sourceTree = ""; }; 7320C8BC2190C9FC0082FED5 /* UITextView+SummaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "UITextView+SummaryTests.swift"; path = "Aztec/UITextView+SummaryTests.swift"; sourceTree = ""; }; - 7326718E210F75F0001FA866 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; - 7326718F210F7601001FA866 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 7326A4A7221C8F4100B4EB8C /* UIStackView+Subviews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIStackView+Subviews.swift"; sourceTree = ""; }; 732A473C218787500015DA74 /* WPRichTextFormatterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WPRichTextFormatterTests.swift; sourceTree = ""; }; 732A473E21878EB10015DA74 /* WPRichContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPRichContentViewTests.swift; sourceTree = ""; }; @@ -3120,8 +7008,6 @@ 73CB13962289BEFB00265F49 /* Charts+LargeValueFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Charts+LargeValueFormatter.swift"; sourceTree = ""; }; 73CE3E0D21F7F9D3007C9C85 /* TableViewOffsetCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewOffsetCoordinator.swift; sourceTree = ""; }; 73D5AC5C212622B200ADDDD2 /* NotificationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; - 73D5AC5F212622B200ADDDD2 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - 73D5AC60212622B200ADDDD2 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; 73D5AC662126236600ADDDD2 /* Info-Alpha.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Alpha.plist"; sourceTree = ""; }; 73D5AC672126236600ADDDD2 /* Info-Internal.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Internal.plist"; sourceTree = ""; }; 73D86968223AF4040064920F /* StatsChartLegendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsChartLegendView.swift; sourceTree = ""; }; @@ -3181,7 +7067,6 @@ 748BD8841F19234300813F9A /* notifications-mark-as-read.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "notifications-mark-as-read.json"; sourceTree = ""; }; 748BD8861F19238600813F9A /* notifications-load-all.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "notifications-load-all.json"; sourceTree = ""; }; 748BD8881F1923D500813F9A /* notifications-last-seen.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "notifications-last-seen.json"; sourceTree = ""; }; - 749197ED209B9A2E006F5E66 /* ReaderCardContent+PostInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderCardContent+PostInformation.swift"; sourceTree = ""; }; 74989B8B2088E3650054290B /* BlogDetailsViewController+Activity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogDetailsViewController+Activity.swift"; sourceTree = ""; }; 74AC1DA0200D0CC300973CAD /* UINavigationController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+Extensions.swift"; sourceTree = ""; }; 74AF4D6D1FE417D200E3EBFE /* MediaUploadOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadOperation.swift; sourceTree = ""; }; @@ -3198,40 +7083,6 @@ 74D6DA92202B669100A0E1FE /* WordPressDraftActionExtension-Alpha.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = "WordPressDraftActionExtension-Alpha.entitlements"; sourceTree = ""; }; 74E44AD72031ED2300556205 /* WordPressDraft-Lumberjack.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WordPressDraft-Lumberjack.m"; sourceTree = ""; }; 74E44AD82031ED2300556205 /* WordPressDraftPrefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WordPressDraftPrefix.pch; sourceTree = ""; }; - 74E44ADF2031EFF700556205 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AE02031F00C00556205 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AE12031F00F00556205 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AE22031F02A00556205 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AE32031F02E00556205 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AE42031F02F00556205 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AE52031F03000556205 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AE62031F03200556205 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AE72031F03300556205 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; - 74E44AE82031F03500556205 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AE92031F03700556205 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AEA2031F03900556205 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AEB2031F03A00556205 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; - 74E44AEC2031F03D00556205 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AED2031F03F00556205 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AEE2031F04000556205 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AEF2031F04100556205 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AF02031F04200556205 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AF12031F04400556205 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AF22031F04600556205 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; - 74E44AF32031F04B00556205 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AF42031F05000556205 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AF52031F05100556205 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AF62031F05200556205 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AF72031F05400556205 /* is */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = is; path = is.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AF82031F05600556205 /* sq */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sq; path = sq.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AF92031F05800556205 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AFA2031F05A00556205 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AFB2031F05C00556205 /* cy */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cy; path = cy.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AFC2031F05D00556205 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AFD2031F05E00556205 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; - 74E44AFE2031F07800556205 /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; - 74E44AFF2031F07900556205 /* en-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-CA"; path = "en-CA.lproj/Localizable.strings"; sourceTree = ""; }; - 74E44B002031F07B00556205 /* en-AU */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-AU"; path = "en-AU.lproj/Localizable.strings"; sourceTree = ""; }; 74EA3B87202A0462004F802D /* ShareNoticeConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareNoticeConstants.swift; sourceTree = ""; }; 74EFB5C7208674250070BD4E /* BlogListViewController+Activity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogListViewController+Activity.swift"; sourceTree = ""; }; 74F5CD371FE0646F00764E7C /* ShareExtension.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = ShareExtension.storyboard; sourceTree = ""; }; @@ -3239,8 +7090,9 @@ 74F89406202A1965008610FA /* ExtensionNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionNotificationManager.swift; sourceTree = ""; }; 74FA2EE3200E8A6C001DDC13 /* AppExtensionsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppExtensionsService.swift; sourceTree = ""; }; 74FA4BE41FBFA0660031EAAD /* Extensions.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Extensions.xcdatamodel; sourceTree = ""; }; - 75305C06D345590B757E3890 /* Pods-WordPress.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPress.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPress/Pods-WordPress.debug.xcconfig"; sourceTree = ""; }; - 7DFD69454E24EC0A1A0BACCD /* Pods-WordPressThisWeekWidget.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressThisWeekWidget.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressThisWeekWidget/Pods-WordPressThisWeekWidget.release.xcconfig"; sourceTree = ""; }; + 75305C06D345590B757E3890 /* Pods-Apps-WordPress.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Apps-WordPress.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-Apps-WordPress/Pods-Apps-WordPress.debug.xcconfig"; sourceTree = ""; }; + 7D21280C251CF0850086DD2C /* EditPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPageViewController.swift; sourceTree = ""; }; + 7D4D980C25FFE7E600C282E6 /* WordPress 116.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 116.xcdatamodel"; sourceTree = ""; }; 7E14635620B3BEAB00B95F41 /* WPStyleGuide+Loader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Loader.swift"; sourceTree = ""; }; 7E21C760202BBC8D00837CF5 /* iAd.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = iAd.framework; path = System/Library/Frameworks/iAd.framework; sourceTree = SDKROOT; }; 7E21C764202BBF4400837CF5 /* SearchAdsAttribution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SearchAdsAttribution.swift; path = iAds/SearchAdsAttribution.swift; sourceTree = ""; }; @@ -3299,7 +7151,6 @@ 7E58879920FE8D9300DB6F80 /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; 7E58879F20FE956100DB6F80 /* AppRatingUtilityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRatingUtilityType.swift; sourceTree = ""; }; 7E729C27209A087200F76599 /* ImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoader.swift; sourceTree = ""; }; - 7E729C29209A241100F76599 /* AbstractPost+PostInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AbstractPost+PostInformation.swift"; sourceTree = ""; }; 7E7947A8210BAC1D005BB851 /* NotificationContentRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentRange.swift; sourceTree = ""; }; 7E7947AA210BAC5E005BB851 /* NotificationCommentRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCommentRange.swift; sourceTree = ""; }; 7E7947AC210BAC7B005BB851 /* FormattableNoticonRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattableNoticonRange.swift; sourceTree = ""; }; @@ -3317,22 +7168,115 @@ 7E987F552108017B00CAFB88 /* NotificationContentRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentRouter.swift; sourceTree = ""; }; 7E987F57210811CC00CAFB88 /* NotificationContentRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentRouterTests.swift; sourceTree = ""; }; 7E987F592108122A00CAFB88 /* NotificationUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationUtility.swift; sourceTree = ""; }; - 7E9B90F721127CA400AF83E6 /* ContextManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextManagerType.swift; sourceTree = ""; }; 7EA30DB321ADA20F0092F894 /* EditorMediaUtility.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditorMediaUtility.swift; sourceTree = ""; }; 7EA30DB421ADA20F0092F894 /* AztecAttachmentDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AztecAttachmentDelegate.swift; sourceTree = ""; }; 7EAA66EE22CB36FD00869038 /* TestAnalyticsTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAnalyticsTracker.swift; sourceTree = ""; }; 7EAD7CCF206D761200BEDCFD /* MediaExternalExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaExternalExporter.swift; sourceTree = ""; }; 7EB5824620EC41B200002702 /* NotificationContentFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContentFactory.swift; sourceTree = ""; }; 7EBB4125206C388100012D98 /* StockPhotosService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StockPhotosService.swift; sourceTree = ""; }; + 7EC2116478565023EDB57703 /* Pods-JetpackShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackShareExtension.release.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackShareExtension/Pods-JetpackShareExtension.release.xcconfig"; sourceTree = ""; }; 7EC9FE0A22C627DB00C5A888 /* PostEditorAnalyticsSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostEditorAnalyticsSessionTests.swift; sourceTree = ""; }; 7ECD5B8020C4D823001AEBC5 /* MediaPreviewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewHelper.swift; sourceTree = ""; }; - 7ED3695420A9F091007B0D56 /* Blog+ImageSourceInformation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+ImageSourceInformation.swift"; sourceTree = ""; }; 7EDAB3F320B046FE002D1A76 /* CircularProgressView+ActivityIndicatorType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CircularProgressView+ActivityIndicatorType.swift"; sourceTree = ""; }; 7EF2EE9F210A67B60007A76B /* notifications-unapproved-comment.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "notifications-unapproved-comment.json"; sourceTree = ""; }; 7EF9F65622F03C9200F79BBF /* SiteSettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteSettingsScreen.swift; sourceTree = ""; }; 7EFF208520EAD918009C4699 /* FormattableUserContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattableUserContent.swift; sourceTree = ""; }; 7EFF208920EADCB6009C4699 /* NotificationTextContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTextContent.swift; sourceTree = ""; }; 7EFF208B20EADF68009C4699 /* FormattableCommentContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattableCommentContent.swift; sourceTree = ""; }; + 800035BC291DD0D7007D2D26 /* JetpackFullscreenOverlayGeneralViewModel+Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JetpackFullscreenOverlayGeneralViewModel+Analytics.swift"; sourceTree = ""; }; + 800035C0292307E8007D2D26 /* ExtensionConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionConfiguration.swift; sourceTree = ""; }; + 800035C229230A0B007D2D26 /* ExtensionConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionConfiguration.swift; sourceTree = ""; }; + 8000361C292468D4007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackFullscreenOverlaySiteCreationViewModel.swift; sourceTree = ""; }; + 8000361F29246956007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift"; sourceTree = ""; }; + 801D94EE2919E7D70051993E /* JetpackFullscreenOverlayGeneralViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackFullscreenOverlayGeneralViewModel.swift; sourceTree = ""; }; + 801D9507291AB3CD0051993E /* JetpackReaderLogoAnimation_ltr.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackReaderLogoAnimation_ltr.json; sourceTree = ""; }; + 801D9508291AB3CD0051993E /* JetpackReaderLogoAnimation_rtl.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackReaderLogoAnimation_rtl.json; sourceTree = ""; }; + 801D9509291AB3CE0051993E /* JetpackStatsLogoAnimation_ltr.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackStatsLogoAnimation_ltr.json; sourceTree = ""; }; + 801D950A291AB3CE0051993E /* JetpackStatsLogoAnimation_rtl.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackStatsLogoAnimation_rtl.json; sourceTree = ""; }; + 801D950B291AB3CE0051993E /* JetpackNotificationsLogoAnimation_rtl.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackNotificationsLogoAnimation_rtl.json; sourceTree = ""; }; + 801D950C291AB3CF0051993E /* JetpackNotificationsLogoAnimation_ltr.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackNotificationsLogoAnimation_ltr.json; sourceTree = ""; }; + 801D9519291AC0B00051993E /* OverlayFrequencyTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayFrequencyTracker.swift; sourceTree = ""; }; + 801D951C291ADB7E0051993E /* OverlayFrequencyTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayFrequencyTrackerTests.swift; sourceTree = ""; }; + 80293CF6284450AD0083F946 /* WordPress-Swift.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WordPress-Swift.h"; sourceTree = ""; }; + 803BB9782959543D00B3F6D6 /* RootViewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewCoordinator.swift; sourceTree = ""; }; + 803BB97B2959559500B3F6D6 /* RootViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewPresenter.swift; sourceTree = ""; }; + 803BB97F295957CF00B3F6D6 /* WPTabBarController+RootViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPTabBarController+RootViewPresenter.swift"; sourceTree = ""; }; + 803BB982295957F600B3F6D6 /* MySitesCoordinator+RootViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MySitesCoordinator+RootViewPresenter.swift"; sourceTree = ""; }; + 803BB985295A873800B3F6D6 /* RootViewPresenter+MeNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RootViewPresenter+MeNavigation.swift"; sourceTree = ""; }; + 803BB988295B80D300B3F6D6 /* RootViewPresenter+EditorNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RootViewPresenter+EditorNavigation.swift"; sourceTree = ""; }; + 803BB98B29637AFC00B3F6D6 /* BlurredEmptyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurredEmptyViewController.swift; sourceTree = ""; }; + 803BB98E29667BAF00B3F6D6 /* JetpackBrandingTextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBrandingTextProvider.swift; sourceTree = ""; }; + 803BB99329667CF700B3F6D6 /* JetpackBrandingTextProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBrandingTextProviderTests.swift; sourceTree = ""; }; + 803C493A283A7C0C00003E9B /* QuickStartChecklistHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartChecklistHeader.swift; sourceTree = ""; }; + 803C493D283A7C2200003E9B /* QuickStartChecklistHeader.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickStartChecklistHeader.xib; sourceTree = ""; }; + 803D90F6292F0188007CC0D0 /* JetpackRedirector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRedirector.swift; sourceTree = ""; }; + 803DE81228FFAE36007D4E9C /* RemoteConfigStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigStore.swift; sourceTree = ""; }; + 803DE81528FFAEF2007D4E9C /* RemoteConfigParameter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigParameter.swift; sourceTree = ""; }; + 803DE81828FFB7B5007D4E9C /* RemoteParameterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteParameterTests.swift; sourceTree = ""; }; + 803DE81E290636A4007D4E9C /* JetpackFeaturesRemovalCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackFeaturesRemovalCoordinatorTests.swift; sourceTree = ""; }; + 803DE820290642B4007D4E9C /* JetpackFeaturesRemovalCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackFeaturesRemovalCoordinator.swift; sourceTree = ""; }; + 80535DB72946C79700873161 /* JetpackBrandingMenuCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBrandingMenuCardCell.swift; sourceTree = ""; }; + 80535DB9294ABBEF00873161 /* JetpackAllFeaturesLogosAnimation_rtl.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackAllFeaturesLogosAnimation_rtl.json; sourceTree = ""; }; + 80535DBA294ABBEF00873161 /* JetpackAllFeaturesLogosAnimation_ltr.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = JetpackAllFeaturesLogosAnimation_ltr.json; sourceTree = ""; }; + 80535DBD294AC89200873161 /* JetpackBrandingMenuCardPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBrandingMenuCardPresenter.swift; sourceTree = ""; }; + 80535DBF294B7D3200873161 /* BlogDetailsViewController+JetpackBrandingMenuCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogDetailsViewController+JetpackBrandingMenuCard.swift"; sourceTree = ""; }; + 80535DC4294BF4BE00873161 /* JetpackBrandingMenuCardPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBrandingMenuCardPresenterTests.swift; sourceTree = ""; }; + 8058730C28F7B70B00340C11 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; + 805CC0B6296680CF002941DC /* RemoteFeatureFlagStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeatureFlagStoreMock.swift; sourceTree = ""; }; + 805CC0B8296680F7002941DC /* RemoteConfigStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigStoreMock.swift; sourceTree = ""; }; + 805CC0BA29668918002941DC /* JetpackBrandedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBrandedScreen.swift; sourceTree = ""; }; + 805CC0BE29668A97002941DC /* MockCurrentDateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCurrentDateProvider.swift; sourceTree = ""; }; + 805CC0C0296CF3B3002941DC /* JetpackNewUsersOverlaySecondaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackNewUsersOverlaySecondaryView.swift; sourceTree = ""; }; + 8067340827E3A50900ABC95E /* UIViewController+RemoveQuickStart.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIViewController+RemoveQuickStart.h"; sourceTree = ""; }; + 8067340927E3A50900ABC95E /* UIViewController+RemoveQuickStart.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIViewController+RemoveQuickStart.m"; sourceTree = ""; }; + 806E53E027E01C7F0064315E /* DashboardStatsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardStatsViewModel.swift; sourceTree = ""; }; + 806E53E327E01CFE0064315E /* DashboardStatsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardStatsViewModelTests.swift; sourceTree = ""; }; + 8070EB3D28D807CB005C6513 /* InMemoryUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryUserDefaults.swift; sourceTree = ""; }; + 8071390627D039E70012DB21 /* DashboardSingleStatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardSingleStatView.swift; sourceTree = ""; }; + 808C578E27C7FB1A0099A92C /* ButtonScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonScrollView.swift; sourceTree = ""; }; + 8091019129078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackFullscreenOverlayViewController.swift; sourceTree = ""; }; + 8091019229078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = JetpackFullscreenOverlayViewController.xib; sourceTree = ""; }; + 809101972908DE8500FCB4EA /* JetpackFullscreenOverlayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackFullscreenOverlayViewModel.swift; sourceTree = ""; }; + 8096212328E540D700940A5D /* JetpackShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = JetpackShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 8096212928E553A500940A5D /* Info-Alpha.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Alpha.plist"; sourceTree = ""; }; + 8096212A28E553A500940A5D /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 8096212B28E553A500940A5D /* Info-Internal.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Internal.plist"; sourceTree = ""; }; + 8096218528E55C9400940A5D /* JetpackDraftActionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = JetpackDraftActionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 8096218828E55D2400940A5D /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 8096218928E55D2400940A5D /* Info-Internal.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Internal.plist"; sourceTree = ""; }; + 8096218A28E55D2400940A5D /* Info-Alpha.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Alpha.plist"; sourceTree = ""; }; + 80A2153C29C35197002FE8EB /* StaticScreensTabBarWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticScreensTabBarWrapper.swift; sourceTree = ""; }; + 80A2153F29CA68D5002FE8EB /* RemoteFeatureFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteFeatureFlag.swift; sourceTree = ""; }; + 80A2154229D1177A002FE8EB /* RemoteConfigDebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigDebugViewController.swift; sourceTree = ""; }; + 80A2154529D15B88002FE8EB /* RemoteConfigOverrideStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigOverrideStore.swift; sourceTree = ""; }; + 80B016CE27FEBDC900D15566 /* DashboardCardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCardTests.swift; sourceTree = ""; }; + 80B016D02803AB9F00D15566 /* DashboardPostsListCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPostsListCardCell.swift; sourceTree = ""; }; + 80C523A329959DE000B1C14B /* BlazeWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeWebViewController.swift; sourceTree = ""; }; + 80C523A62995D73C00B1C14B /* BlazeWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeWebViewModel.swift; sourceTree = ""; }; + 80C523AA29AE6C2200B1C14B /* BlazeWebViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeWebViewModelTests.swift; sourceTree = ""; }; + 80C740FA2989FC4600199027 /* PostStatsTableViewController+JetpackBannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostStatsTableViewController+JetpackBannerViewController.swift"; sourceTree = ""; }; + 80D65C1129CC0813008E69D5 /* JetpackUITests-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "JetpackUITests-Info.plist"; sourceTree = ""; }; + 80D9CFF329DCA53E00FE3400 /* DashboardPagesListCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPagesListCardCell.swift; sourceTree = ""; }; + 80D9CFF629E5010300FE3400 /* PagesCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagesCardViewModel.swift; sourceTree = ""; }; + 80D9CFF929E5E6FE00FE3400 /* DashboardCardTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCardTableView.swift; sourceTree = ""; }; + 80D9CFFC29E711E200FE3400 /* DashboardPageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPageCell.swift; sourceTree = ""; }; + 80D9CFFF29E85EBF00FE3400 /* PageEditorPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageEditorPresenter.swift; sourceTree = ""; }; + 80D9D00229EF4C7F00FE3400 /* DashboardPageCreationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPageCreationCell.swift; sourceTree = ""; }; + 80D9D04529F760C400FE3400 /* FailableDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailableDecodable.swift; sourceTree = ""; }; + 80EF671E27F135EB0063B138 /* WhatIsNewViewAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatIsNewViewAppearance.swift; sourceTree = ""; }; + 80EF672127F160720063B138 /* DashboardCustomAnnouncementCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCustomAnnouncementCell.swift; sourceTree = ""; }; + 80EF672427F3D63B0063B138 /* DashboardStatsStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardStatsStackView.swift; sourceTree = ""; }; + 80EF9283280CFEB60064A971 /* DashboardPostsSyncManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPostsSyncManagerTests.swift; sourceTree = ""; }; + 80EF9285280D272E0064A971 /* DashboardPostsSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPostsSyncManager.swift; sourceTree = ""; }; + 80EF9289280D28140064A971 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; + 80EF928C280E83110064A971 /* QuickStartToursCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartToursCollection.swift; sourceTree = ""; }; + 80EF928F28105CFA0064A971 /* QuickStartFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartFactory.swift; sourceTree = ""; }; + 80EF92922810FA5A0064A971 /* QuickStartFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartFactoryTests.swift; sourceTree = ""; }; + 80F6D05428EE866A00953C1A /* JetpackNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = JetpackNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 80F6D05728EE86F800953C1A /* Info-Alpha.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Alpha.plist"; sourceTree = ""; }; + 80F6D05828EE86F800953C1A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 80F6D05928EE86F800953C1A /* Info-Internal.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Internal.plist"; sourceTree = ""; }; + 80F8DAC0282B6546007434A0 /* WPAnalytics+QuickStart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPAnalytics+QuickStart.swift"; sourceTree = ""; }; 820ADD6C1F3A0DA0002D7F93 /* WordPress 64.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 64.xcdatamodel"; sourceTree = ""; }; 820ADD6F1F3A1F88002D7F93 /* ThemeBrowserSectionHeaderView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ThemeBrowserSectionHeaderView.xib; sourceTree = ""; }; 820ADD711F3A226E002D7F93 /* ThemeBrowserSectionHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeBrowserSectionHeaderView.swift; sourceTree = ""; }; @@ -3343,17 +7287,14 @@ 821EB18D1EDF391800E4CD8C /* WordPress 60.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 60.xcdatamodel"; sourceTree = ""; }; 82270C8E1E3FBF72005F697D /* WordPress 56.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 56.xcdatamodel"; sourceTree = ""; }; 822876F01E929CFD00696BF7 /* ReachabilityUtils+OnlineActions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReachabilityUtils+OnlineActions.swift"; sourceTree = ""; }; - 822D60B01F4C747E0016C46D /* JetpackSecuritySettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackSecuritySettingsViewController.swift; sourceTree = ""; }; 822D60B81F4CCC7A0016C46D /* BlogJetpackSettingsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlogJetpackSettingsService.swift; sourceTree = ""; }; 82301B8E1E787420009C9C4E /* AppRatingUtilityTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppRatingUtilityTests.swift; sourceTree = ""; }; - 8236EB4A20248FF0007C7CF9 /* JetpackSpeedUpSiteSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackSpeedUpSiteSettingsViewController.swift; sourceTree = ""; }; 8236EB4E2024CBCD007C7CF9 /* WordPress 71.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 71.xcdatamodel"; sourceTree = ""; }; 8236EB4F2024ED8C007C7CF9 /* NotificationsViewController+JetpackPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationsViewController+JetpackPrompt.swift"; sourceTree = ""; }; 825327571FBF7CD600B8B7D2 /* ActivityUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityUtils.swift; sourceTree = ""; }; 8261B4CA1EA8E13700668298 /* SVProgressHUD+Dismiss.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "SVProgressHUD+Dismiss.m"; sourceTree = ""; }; 8261B4CB1EA8E13700668298 /* SVProgressHUD+Dismiss.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "SVProgressHUD+Dismiss.h"; sourceTree = ""; }; 827482C81E966E32001A2B62 /* WordPress 58.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 58.xcdatamodel"; sourceTree = ""; }; - 827704F01F607C0E002E8A03 /* JetpackConnectionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackConnectionViewController.swift; sourceTree = ""; }; 8298F38E1EEF2B15008EB7F0 /* AppFeedbackPromptView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppFeedbackPromptView.swift; sourceTree = ""; }; 8298F3911EEF3BA7008EB7F0 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; 82A062DB2017BC220084CE7C /* ActivityListSectionHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ActivityListSectionHeaderView.xib; sourceTree = ""; }; @@ -3363,7 +7304,7 @@ 82C420751FE44BD900CFB15B /* SiteSettingsViewController+Swift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SiteSettingsViewController+Swift.swift"; sourceTree = ""; }; 82FA39771EB132090058A2D0 /* WordPress 59.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 59.xcdatamodel"; sourceTree = ""; }; 82FC611C1FA8ADAC00A1757E /* ActivityTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityTableViewCell.swift; sourceTree = ""; }; - 82FC611D1FA8ADAC00A1757E /* ActivityListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityListViewController.swift; sourceTree = ""; }; + 82FC611D1FA8ADAC00A1757E /* BaseActivityListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseActivityListViewController.swift; sourceTree = ""; }; 82FC611E1FA8ADAC00A1757E /* ActivityTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ActivityTableViewCell.xib; sourceTree = ""; }; 82FC611F1FA8ADAC00A1757E /* WPStyleGuide+Activity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Activity.swift"; sourceTree = ""; }; 82FC61291FA8B6F000A1757E /* ActivityListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityListViewModel.swift; sourceTree = ""; }; @@ -3371,10 +7312,14 @@ 82FFBF4C1F434BDA00F4573F /* ThemeIdHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeIdHelper.swift; sourceTree = ""; }; 82FFBF4E1F45E03D00F4573F /* WordPress 66.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 66.xcdatamodel"; sourceTree = ""; }; 83043E54126FA31400EC9953 /* MessageUI.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = MessageUI.framework; path = System/Library/Frameworks/MessageUI.framework; sourceTree = SDKROOT; }; + 830A58D72793AB4400CDE94F /* LoginEpilogueAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginEpilogueAnimator.swift; sourceTree = ""; }; + 8313B9ED298B1ACD000AF26E /* SiteSettingsViewController+Blogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SiteSettingsViewController+Blogging.swift"; sourceTree = ""; }; + 8313B9F92995A03C000AF26E /* JetpackRemoteInstallCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRemoteInstallCardView.swift; sourceTree = ""; }; + 8320BDE4283D9359009DF2DE /* BlogService+BloggingPrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogService+BloggingPrompts.swift"; sourceTree = ""; }; + 8332DD2329259AE300802F7D /* DataMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataMigrator.swift; sourceTree = ""; }; + 8332DD2729259BEB00802F7D /* DataMigratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataMigratorTests.swift; sourceTree = ""; }; 833AF259114575A50016DE8F /* PostAnnotation.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PostAnnotation.h; sourceTree = ""; }; 833AF25A114575A50016DE8F /* PostAnnotation.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostAnnotation.m; sourceTree = ""; }; - 83418AA811C9FA6E00ACF00C /* Comment.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Comment.h; sourceTree = ""; }; - 83418AA911C9FA6E00ACF00C /* Comment.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Comment.m; sourceTree = ""; }; 834CE7331256D0DE0046A4A3 /* CFNetwork.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = CFNetwork.framework; path = System/Library/Frameworks/CFNetwork.framework; sourceTree = SDKROOT; }; 834CE7371256D0F60046A4A3 /* CoreGraphics.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; 8350E15911D28B4A00A7B073 /* WordPress.xcdatamodel */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = wrapper.xcdatamodel; path = WordPress.xcdatamodel; sourceTree = ""; }; @@ -3383,8 +7328,27 @@ 8355D67D11D13EAD00A61362 /* MobileCoreServices.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = System/Library/Frameworks/MobileCoreServices.framework; sourceTree = SDKROOT; }; 8355D7D811D260AA00A61362 /* CoreData.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = System/Library/Frameworks/CoreData.framework; sourceTree = SDKROOT; }; 835E2402126E66E50085940B /* AssetsLibrary.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = AssetsLibrary.framework; path = System/Library/Frameworks/AssetsLibrary.framework; sourceTree = SDKROOT; }; + 836498C728172C5900A2C170 /* WPStyleGuide+BloggingPrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+BloggingPrompts.swift"; sourceTree = ""; }; + 836498CA2817301800A2C170 /* BloggingPromptsHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BloggingPromptsHeaderView.xib; sourceTree = ""; }; + 836498CD281735CC00A2C170 /* BloggingPromptsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptsHeaderView.swift; sourceTree = ""; }; 8370D10811FA499A009D650F /* WPTableViewActivityCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPTableViewActivityCell.h; sourceTree = ""; }; 8370D10911FA499A009D650F /* WPTableViewActivityCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPTableViewActivityCell.m; sourceTree = ""; }; + 83796698299C048E004A92B9 /* DashboardJetpackInstallCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardJetpackInstallCardCell.swift; sourceTree = ""; }; + 8379669B299C0C44004A92B9 /* JetpackRemoteInstallTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRemoteInstallTableViewCell.swift; sourceTree = ""; }; + 8379669E299D51EC004A92B9 /* JetpackPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackPlugin.swift; sourceTree = ""; }; + 837966A1299E9C85004A92B9 /* JetpackInstallPluginHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackInstallPluginHelper.swift; sourceTree = ""; }; + 837B49C6283C28730061A657 /* WordPress 141.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 141.xcdatamodel"; sourceTree = ""; }; + 837B49D3283C2AE80061A657 /* BloggingPromptSettings+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BloggingPromptSettings+CoreDataClass.swift"; sourceTree = ""; }; + 837B49D4283C2AE80061A657 /* BloggingPromptSettings+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BloggingPromptSettings+CoreDataProperties.swift"; sourceTree = ""; }; + 837B49D5283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BloggingPromptSettingsReminderDays+CoreDataClass.swift"; sourceTree = ""; }; + 837B49D6283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BloggingPromptSettingsReminderDays+CoreDataProperties.swift"; sourceTree = ""; }; + 8384C64028AAC82600EABE26 /* KeychainUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainUtils.swift; sourceTree = ""; }; + 8384C64328AAC85F00EABE26 /* KeychainUtilsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainUtilsTests.swift; sourceTree = ""; }; + 839435922847F2200019A94F /* WordPress 143.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 143.xcdatamodel"; sourceTree = ""; }; + 839B150A2795DEE0009F5E77 /* UIView+Margins.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Margins.swift"; sourceTree = ""; }; + 83B1D036282C62620061D911 /* BloggingPromptsAttribution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptsAttribution.swift; sourceTree = ""; }; + 83C972DF281C45AB0049E1FE /* Post+BloggingPrompts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post+BloggingPrompts.swift"; sourceTree = ""; }; + 83EF3D7C2937E969000AF9BF /* SharedDataIssueSolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedDataIssueSolverTests.swift; sourceTree = ""; }; 83F3E25F11275E07004CD686 /* MapKit.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = MapKit.framework; path = System/Library/Frameworks/MapKit.framework; sourceTree = SDKROOT; }; 83F3E2D211276371004CD686 /* CoreLocation.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = CoreLocation.framework; path = System/Library/Frameworks/CoreLocation.framework; sourceTree = SDKROOT; }; 83FB4D3E122C38F700DB9506 /* MediaPlayer.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; }; @@ -3395,10 +7359,8 @@ 8511CFC41C60884400B7CEED /* SnapshotHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = ""; }; 8511CFC61C60894200B7CEED /* WordPressScreenshotGeneration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WordPressScreenshotGeneration.swift; sourceTree = ""; }; 8527B15717CE98C5001CBA2E /* Accelerate.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Accelerate.framework; path = System/Library/Frameworks/Accelerate.framework; sourceTree = SDKROOT; }; - 8546B44A1BEAD3B300193C07 /* Info-Alpha.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Alpha.plist"; sourceTree = ""; }; 8546B44C1BEAD3EC00193C07 /* Wordpress-Alpha-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Wordpress-Alpha-Info.plist"; sourceTree = ""; }; 8546B44E1BEAD48900193C07 /* WordPress-Alpha.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = "WordPress-Alpha.entitlements"; sourceTree = ""; }; - 8546B4501BEAD4D100193C07 /* WordPressTodayWidget-Alpha.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = "WordPressTodayWidget-Alpha.entitlements"; sourceTree = ""; }; 855408851A6F105700DDBD79 /* app-review-prompt-all-enabled.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "app-review-prompt-all-enabled.json"; sourceTree = ""; }; 855408871A6F106800DDBD79 /* app-review-prompt-notifications-disabled.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "app-review-prompt-notifications-disabled.json"; sourceTree = ""; }; 855408891A6F107D00DDBD79 /* app-review-prompt-global-disable.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "app-review-prompt-global-disable.json"; sourceTree = ""; }; @@ -3413,38 +7375,135 @@ 85DA8C4318F3F29A0074C8A4 /* WPAnalyticsTrackerWPCom.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPAnalyticsTrackerWPCom.m; sourceTree = ""; }; 85ED98AA17DFB17200090D0B /* iTunesArtwork@2x */ = {isa = PBXFileReference; lastKnownFileType = file; path = "iTunesArtwork@2x"; sourceTree = ""; }; 85F8E19C1B018698000859BB /* PushAuthenticationServiceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushAuthenticationServiceTests.swift; sourceTree = ""; }; + 87A8AC8362510EB42708E5B3 /* Pods-JetpackIntents.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackIntents.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackIntents/Pods-JetpackIntents.debug.xcconfig"; sourceTree = ""; }; + 8A21014FBE43ADE551F4ECB4 /* Pods-WordPressIntents.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressIntents.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressIntents/Pods-WordPressIntents.release-alpha.xcconfig"; sourceTree = ""; }; 8B05D29023A9417E0063B9AA /* WPMediaEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPMediaEditor.swift; sourceTree = ""; }; 8B05D29223AA572A0063B9AA /* GutenbergMediaEditorImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergMediaEditorImage.swift; sourceTree = ""; }; + 8B065CC527BD5452005BA7AB /* DashboardEmptyPostsCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardEmptyPostsCardCell.swift; sourceTree = ""; }; + 8B0732E6242B9C5200E7FBD3 /* PrepublishingHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PrepublishingHeaderView.xib; sourceTree = ""; }; + 8B0732E8242BA1F000E7FBD3 /* PrepublishingHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrepublishingHeaderView.swift; sourceTree = ""; }; + 8B0732EE242BF6EA00E7FBD3 /* Blog+Title.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+Title.swift"; sourceTree = ""; }; + 8B0732F1242BF97B00E7FBD3 /* PrepublishingNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrepublishingNavigationController.swift; sourceTree = ""; }; + 8B074A4F27AC3A64003A2EB8 /* BlogDashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardViewModel.swift; sourceTree = ""; }; + 8B0CE7D02481CFE8004C4799 /* ReaderDetailHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailHeaderView.swift; sourceTree = ""; }; + 8B0CE7D22481CFF8004C4799 /* ReaderDetailHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderDetailHeaderView.xib; sourceTree = ""; }; + 8B15CDAA27EB89AC00A75749 /* BlogDashboardPostsParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPostsParser.swift; sourceTree = ""; }; + 8B15D27328009EBF0076628A /* BlogDashboardAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardAnalytics.swift; sourceTree = ""; }; + 8B16CE9925251C89007BE5A9 /* ReaderPostStreamService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPostStreamService.swift; sourceTree = ""; }; + 8B1CF00E2433902700578582 /* PasswordAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordAlertController.swift; sourceTree = ""; }; + 8B1CF0102433E61C00578582 /* AbstractPost+TitleForVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AbstractPost+TitleForVisibility.swift"; sourceTree = ""; }; + 8B1E62D525758AAF009A0F80 /* ActivityTypeSelectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityTypeSelectorViewController.swift; sourceTree = ""; }; + 8B24C4E2249A4C3E0005E8A5 /* OfflineReaderWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineReaderWebView.swift; sourceTree = ""; }; + 8B25F8D924B7683A009DD4C9 /* ReaderCSSTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCSSTests.swift; sourceTree = ""; }; + 8B260D7D2444FC9D0010F756 /* PostVisibilitySelectorViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostVisibilitySelectorViewController.swift; sourceTree = ""; }; + 8B2D4F5227ECE089009B085C /* dashboard-200-without-posts.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "dashboard-200-without-posts.json"; sourceTree = ""; }; + 8B2D4F5427ECE376009B085C /* BlogDashboardPostsParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPostsParserTests.swift; sourceTree = ""; }; + 8B33BC9427A0C14C00DB5985 /* BlogDetailsViewController+QuickActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogDetailsViewController+QuickActions.swift"; sourceTree = ""; }; + 8B36256525A60CCA00D7CCE3 /* BackupListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupListViewController.swift; sourceTree = ""; }; + 8B3626F825A665E500D7CCE3 /* UIApplication+mainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+mainWindow.swift"; sourceTree = ""; }; 8B3DECAA2388506400A459C2 /* SentryStartupEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryStartupEvent.swift; sourceTree = ""; }; + 8B45C12527B2A27400EA3257 /* dashboard-200-with-drafts-only.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "dashboard-200-with-drafts-only.json"; sourceTree = ""; }; + 8B4EDADC27DF9D5E004073B6 /* Blog+MySite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+MySite.swift"; sourceTree = ""; }; + 8B51844425893F140085488D /* FilterBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterBarView.swift; sourceTree = ""; }; + 8B5E1DD727EA5929002EBEE3 /* PostCoordinator+Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostCoordinator+Dashboard.swift"; sourceTree = ""; }; + 8B6214DF27B1AD9D001DF7B6 /* DashboardStatsCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardStatsCardCell.swift; sourceTree = ""; }; + 8B6214E227B1B2F3001DF7B6 /* BlogDashboardService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardService.swift; sourceTree = ""; }; + 8B6214E527B1B446001DF7B6 /* BlogDashboardServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardServiceTests.swift; sourceTree = ""; }; + 8B64B4B1247EC3A2009A1229 /* reader.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = reader.css; sourceTree = ""; }; + 8B69F0E3255C2C3F006B1CEF /* ActivityListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityListViewModelTests.swift; sourceTree = ""; }; + 8B69F0FF255C4870006B1CEF /* ActivityStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityStoreTests.swift; sourceTree = ""; }; + 8B69F19E255D67E7006B1CEF /* CalendarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarViewController.swift; sourceTree = ""; }; + 8B6BD54F24293FBE00DB8F28 /* PrepublishingNudgesViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrepublishingNudgesViewControllerTests.swift; sourceTree = ""; }; 8B6EA62223FDE50B004BA312 /* PostServiceUploadingList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostServiceUploadingList.swift; sourceTree = ""; }; + 8B749E7125AF522900023F03 /* JetpackCapabilitiesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackCapabilitiesService.swift; sourceTree = ""; }; + 8B749E8F25AF8D2E00023F03 /* JetpackCapabilitiesServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackCapabilitiesServiceTests.swift; sourceTree = ""; }; + 8B74A9A7268E3C68003511CE /* RewindStatus+multiSite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RewindStatus+multiSite.swift"; sourceTree = ""; }; 8B7623372384373E00AB3EE7 /* PageListViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageListViewControllerTests.swift; sourceTree = ""; }; + 8B7C97E225A8BFA2004A3373 /* JetpackActivityLogViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackActivityLogViewController.swift; sourceTree = ""; }; + 8B7F25A624E6EDB4007D82CC /* TopicsCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicsCollectionView.swift; sourceTree = ""; }; + 8B7F51C824EED804008CF5B5 /* ReaderTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTracker.swift; sourceTree = ""; }; + 8B7F51CA24EED8A8008CF5B5 /* ReaderTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTrackerTests.swift; sourceTree = ""; }; 8B821F3B240020E2006B697E /* PostServiceUploadingListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostServiceUploadingListTests.swift; sourceTree = ""; }; + 8B85AED9259230FC00ADBEC9 /* ABTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ABTest.swift; sourceTree = ""; }; 8B8C814C2318073300A0E620 /* BasePostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasePostTests.swift; sourceTree = ""; }; + 8B8E50B527A4692000C89979 /* DashboardPostListErrorCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPostListErrorCell.swift; sourceTree = ""; }; 8B8FE8562343952B00F9AD2E /* PostAutoUploadMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostAutoUploadMessages.swift; sourceTree = ""; }; + 8B92D69527CD51FA001F5371 /* DashboardGhostCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardGhostCardCell.swift; sourceTree = ""; }; + 8B93412E257029F50097D0AC /* FilterChipButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterChipButton.swift; sourceTree = ""; }; 8B93856D22DC08060010BF02 /* PageListSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageListSectionHeaderView.swift; sourceTree = ""; }; 8B939F4223832E5D00ACCB0F /* PostListViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostListViewControllerTests.swift; sourceTree = ""; }; + 8B9E15DAF3E1A369E9BE3407 /* Pods-WordPressUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressUITests.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressUITests/Pods-WordPressUITests.release.xcconfig"; sourceTree = ""; }; + 8BA125EA27D8F5E4008B779F /* UIView+PinSubviewPriority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+PinSubviewPriority.swift"; sourceTree = ""; }; + 8BA77BCA2482C52A00E1EBBF /* ReaderCardDiscoverAttributionView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderCardDiscoverAttributionView.xib; sourceTree = ""; }; + 8BA77BCC248340CE00E1EBBF /* ReaderDetailToolbar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderDetailToolbar.xib; sourceTree = ""; }; + 8BA77BCE2483415400E1EBBF /* ReaderDetailToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailToolbar.swift; sourceTree = ""; }; + 8BAC9D9D27BAB97E008EA44C /* BlogDashboardRemoteEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardRemoteEntity.swift; sourceTree = ""; }; + 8BAD272B241FEF3300E9D105 /* PrepublishingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrepublishingViewController.swift; sourceTree = ""; }; + 8BAD53D5241922B900230F4B /* WPAnalyticsEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPAnalyticsEvent.swift; sourceTree = ""; }; + 8BADF16424801BCE005AD038 /* ReaderWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderWebView.swift; sourceTree = ""; }; + 8BB185C524B5FB8500A4CCE8 /* ReaderCardService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCardService.swift; sourceTree = ""; }; + 8BB185CB24B6058600A4CCE8 /* reader-cards-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "reader-cards-success.json"; sourceTree = ""; }; + 8BB185CD24B62CE100A4CCE8 /* ReaderCardServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCardServiceTests.swift; sourceTree = ""; }; + 8BB185D024B63D1600A4CCE8 /* ReaderCardsStreamViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCardsStreamViewController.swift; sourceTree = ""; }; + 8BB185D324B66FE500A4CCE8 /* ReaderCard+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReaderCard+CoreDataProperties.swift"; sourceTree = ""; }; + 8BB185D424B66FE600A4CCE8 /* ReaderCard+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReaderCard+CoreDataClass.swift"; sourceTree = ""; }; + 8BBBCE6F2717651200B277AC /* JetpackModuleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackModuleHelper.swift; sourceTree = ""; }; + 8BBBEBB124B8F8C0005E358E /* ReaderCardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCardTests.swift; sourceTree = ""; }; + 8BBC778A27B5531700DBA087 /* BlogDashboardPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersistence.swift; sourceTree = ""; }; 8BC12F71231FEBA1004DDA72 /* PostCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCoordinatorTests.swift; sourceTree = ""; }; - 8BC12F732320181E004DDA72 /* PostService+MarkAsFailedAndDraftIfNeeded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostService+MarkAsFailedAndDraftIfNeeded.swift"; sourceTree = ""; }; + 8BC12F732320181E004DDA72 /* AbstractPost+MarkAsFailedAndDraftIfNeeded.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AbstractPost+MarkAsFailedAndDraftIfNeeded.swift"; sourceTree = ""; }; 8BC12F7623201B86004DDA72 /* PostService+MarkAsFailedAndDraftIfNeededTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostService+MarkAsFailedAndDraftIfNeededTests.swift"; sourceTree = ""; }; 8BC6020823900D8400EFE3D0 /* NullBlogPropertySanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullBlogPropertySanitizer.swift; sourceTree = ""; }; 8BC6020C2390412000EFE3D0 /* NullBlogPropertySanitizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullBlogPropertySanitizerTests.swift; sourceTree = ""; }; + 8BC81D6427CFC0DA0057F790 /* BlogDashboardState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardState.swift; sourceTree = ""; }; + 8BCB83D024C21063001581BD /* ReaderStreamViewController+Ghost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderStreamViewController+Ghost.swift"; sourceTree = ""; }; + 8BCF957924C6044000712056 /* ReaderTopicsCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTopicsCardCell.swift; sourceTree = ""; }; + 8BD34F0827D144FF005E931C /* BlogDashboardStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardStateTests.swift; sourceTree = ""; }; + 8BD34F0A27D14B3C005E931C /* Blog+DashboardState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+DashboardState.swift"; sourceTree = ""; }; 8BD36E012395CAEA00EFFF1C /* MediaEditorOperation+Description.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaEditorOperation+Description.swift"; sourceTree = ""; }; 8BD36E052395CC4400EFFF1C /* MediaEditorOperation+DescriptionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaEditorOperation+DescriptionTests.swift"; sourceTree = ""; }; + 8BD66ED32787530C00CCD95A /* PostsCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsCardViewModel.swift; sourceTree = ""; }; + 8BD8201724BC93B500FF25FD /* WordPress 98.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 98.xcdatamodel"; sourceTree = ""; }; + 8BD8201824BCCE8600FF25FD /* ReaderWelcomeBanner.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderWelcomeBanner.xib; sourceTree = ""; }; + 8BD8201A24BCDBFF00FF25FD /* ReaderWelcomeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderWelcomeBanner.swift; sourceTree = ""; }; + 8BD8201C24BF9E5200FF25FD /* ReaderWelcomeBannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderWelcomeBannerTests.swift; sourceTree = ""; }; + 8BDA5A6A247C2EAF00AB124C /* ReaderDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailViewController.swift; sourceTree = ""; }; + 8BDA5A6C247C2F8400AB124C /* ReaderDetailViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailViewControllerTests.swift; sourceTree = ""; }; + 8BDA5A6E247C308300AB124C /* ReaderDetailViewController.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = ReaderDetailViewController.storyboard; sourceTree = ""; }; + 8BDA5A71247C5E5800AB124C /* ReaderDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailCoordinator.swift; sourceTree = ""; }; + 8BDA5A73247C5EAA00AB124C /* ReaderDetailCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailCoordinatorTests.swift; sourceTree = ""; }; + 8BDC4C38249BA5CA00DE0A2D /* ReaderCSS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCSS.swift; sourceTree = ""; }; + 8BE69511243E674300FF492F /* PrepublishingHeaderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PrepublishingHeaderViewTests.swift; path = WordPressTest/PrepublishingHeaderViewTests.swift; sourceTree = SOURCE_ROOT; }; + 8BE6F92927EE26D30008BDC7 /* BlogDashboardPostCardGhostCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BlogDashboardPostCardGhostCell.xib; sourceTree = ""; }; + 8BE6F92B27EE27DB0008BDC7 /* BlogDashboardPostCardGhostCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPostCardGhostCell.swift; sourceTree = ""; }; 8BE7C84023466927006EDE70 /* I18n.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = I18n.swift; sourceTree = ""; }; + 8BE9AB8727B6B5A300708E45 /* BlogDashboardPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardPersistenceTests.swift; sourceTree = ""; }; + 8BEE845927B1DC9D0001A93C /* dashboard-200-with-drafts-and-scheduled.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "dashboard-200-with-drafts-and-scheduled.json"; sourceTree = ""; }; + 8BEE846027B1DE0E0001A93C /* DashboardCardModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCardModel.swift; sourceTree = ""; }; + 8BF0B606247D88EB009A7457 /* UITableViewCell+enableDisable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableViewCell+enableDisable.swift"; sourceTree = ""; }; + 8BF1C81927BC00AF00F1C203 /* BlogDashboardCardFrameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardCardFrameView.swift; sourceTree = ""; }; + 8BF281F827CE8E4100AF8CF3 /* DashboardGhostCardContent.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DashboardGhostCardContent.xib; sourceTree = ""; }; + 8BF281FB27CEB69C00AF8CF3 /* DashboardFailureCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardFailureCardCell.swift; sourceTree = ""; }; + 8BF9E03227B1A8A800915B27 /* DashboardCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCard.swift; sourceTree = ""; }; 8BFE36FC230F16580061EBA8 /* AbstractPost+fixLocalMediaURLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AbstractPost+fixLocalMediaURLs.swift"; sourceTree = ""; }; 8BFE36FE230F1C850061EBA8 /* AbstractPost+fixLocalMediaURLsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AbstractPost+fixLocalMediaURLsTests.swift"; sourceTree = ""; }; - 8CE5BBD00FF1470AC4B88247 /* Pods_WordPressTodayWidget.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressTodayWidget.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8C6A22E325783D2000A79950 /* JetpackScanService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScanService.swift; sourceTree = ""; }; 8D1107310486CEB800E47090 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 8DCE7542239FBC709B90EA85 /* Pods_WordPressUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 8E65034AFB7DC85D70CCC064 /* Pods-WordPressThisWeekWidget.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressThisWeekWidget.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressThisWeekWidget/Pods-WordPressThisWeekWidget.release-internal.xcconfig"; sourceTree = ""; }; + 8DE205D2AC15F16289E7D21A /* Pods-WordPressDraftActionExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressDraftActionExtension.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension.release.xcconfig"; sourceTree = ""; }; + 8F2283367263B37B0681F988 /* TimeZoneSelectorViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeZoneSelectorViewController.swift; sourceTree = ""; }; + 8F228848D5DEACE6798CE7E2 /* TimeZoneSearchHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeZoneSearchHeaderView.swift; sourceTree = ""; }; + 8F228AE62B771552F0F971BE /* TimeZoneSearchHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TimeZoneSearchHeaderView.xib; sourceTree = ""; }; 91138454228373EB00FB02B7 /* GutenbergVideoUploadProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergVideoUploadProcessor.swift; sourceTree = ""; }; 912347182213484300BD9F97 /* GutenbergViewController+InformativeDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GutenbergViewController+InformativeDialog.swift"; sourceTree = ""; }; 9123471A221449E200BD9F97 /* GutenbergInformativeDialogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergInformativeDialogTests.swift; sourceTree = ""; }; 912347752216E27200BD9F97 /* GutenbergViewController+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GutenbergViewController+Localization.swift"; sourceTree = ""; }; + 9149D34BF5182F360C84EDB9 /* Pods-JetpackDraftActionExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackDraftActionExtension.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackDraftActionExtension/Pods-JetpackDraftActionExtension.debug.xcconfig"; sourceTree = ""; }; 91D8364021946EFB008340B2 /* GutenbergMediaPickerHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergMediaPickerHelper.swift; sourceTree = ""; }; 91DCE84321A6A7840062F134 /* PostEditor+BlogPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostEditor+BlogPicker.swift"; sourceTree = ""; }; 91DCE84521A6A7F50062F134 /* PostEditor+MoreOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostEditor+MoreOptions.swift"; sourceTree = ""; }; 91DCE84721A6C58C0062F134 /* PostEditor+Publish.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PostEditor+Publish.swift"; sourceTree = ""; }; + 92B40A77F0765C1E93B11727 /* Pods_WordPressDraftActionExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressDraftActionExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 930284B618EAF7B600CB0BF4 /* LocalCoreDataService.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = LocalCoreDataService.h; sourceTree = ""; }; 93069F54176237A4000C966D /* ActivityLogViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ActivityLogViewController.h; sourceTree = ""; }; 93069F55176237A4000C966D /* ActivityLogViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ActivityLogViewController.m; sourceTree = ""; }; @@ -3453,8 +7512,15 @@ 930C6374182BD86400976C21 /* WordPress-Internal-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "WordPress-Internal-Info.plist"; sourceTree = ""; }; 930F09161C7D110E00995926 /* ShareExtensionService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareExtensionService.swift; sourceTree = ""; }; 930FD0A519882742000CC81D /* BlogServiceTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogServiceTest.m; sourceTree = ""; }; - 931D26FC19EDA10D00114F17 /* ALIterativeMigrator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALIterativeMigrator.h; sourceTree = ""; }; - 931D26FD19EDA10D00114F17 /* ALIterativeMigrator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALIterativeMigrator.m; sourceTree = ""; }; + 931215E0267DE1C0008C3B69 /* StatsTotalRowDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsTotalRowDataTests.swift; sourceTree = ""; }; + 931215E3267F5003008C3B69 /* ReferrerDetailsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferrerDetailsTableViewController.swift; sourceTree = ""; }; + 931215E5267F5192008C3B69 /* ReferrerDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferrerDetailsViewModel.swift; sourceTree = ""; }; + 931215E7267F52A6008C3B69 /* ReferrerDetailsHeaderRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferrerDetailsHeaderRow.swift; sourceTree = ""; }; + 931215E9267F59CB008C3B69 /* ReferrerDetailsHeaderCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferrerDetailsHeaderCell.swift; sourceTree = ""; }; + 931215EB267F5F45008C3B69 /* ReferrerDetailsRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferrerDetailsRow.swift; sourceTree = ""; }; + 931215ED267F6799008C3B69 /* ReferrerDetailsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferrerDetailsCell.swift; sourceTree = ""; }; + 931215F1267FE162008C3B69 /* ReferrerDetailsSpamActionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferrerDetailsSpamActionRow.swift; sourceTree = ""; }; + 931215F3267FE177008C3B69 /* ReferrerDetailsSpamActionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferrerDetailsSpamActionCell.swift; sourceTree = ""; }; 931D26FF19EDAE8600114F17 /* CoreDataMigrationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CoreDataMigrationTests.m; sourceTree = ""; }; 931DF4D718D09A2F00540BDD /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; 931DF4D918D09A9B00540BDD /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -3464,16 +7530,17 @@ 931DF4DD18D09B1900540BDD /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = ""; }; 931DF4DE18D09B2600540BDD /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; 931DF4DF18D09B3900540BDD /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/InfoPlist.strings; sourceTree = ""; }; + 931F312B2714302A0075433B /* PublicizeServicesState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicizeServicesState.swift; sourceTree = ""; }; 932225A71C7CE50300443B02 /* WordPressShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WordPressShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 932645A31E7C206600134988 /* GutenbergSettingsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GutenbergSettingsTests.swift; sourceTree = ""; }; - 93267A6019B896CD00997EB8 /* Info-Internal.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Internal.plist"; sourceTree = ""; }; 933D1F451EA64108009FB462 /* TestingAppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TestingAppDelegate.h; sourceTree = ""; }; 933D1F461EA64108009FB462 /* TestingAppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TestingAppDelegate.m; sourceTree = ""; }; 933D1F6B1EA7A3AB009FB462 /* TestingMode.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = TestingMode.storyboard; sourceTree = ""; }; 933D1F6D1EA7A402009FB462 /* TestAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = TestAssets.xcassets; sourceTree = ""; }; + 934098BF2719577D00B3E77E /* InsightType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsightType.swift; sourceTree = ""; }; + 934098C2271957A600B3E77E /* SiteStatsInsightsDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStatsInsightsDelegate.swift; sourceTree = ""; }; 93414DE41E2D25AE003143A3 /* PostEditorState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = PostEditorState.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; 93460A36189D5091000E26CE /* WordPress 14.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 14.xcdatamodel"; sourceTree = ""; }; - 934884AC19B78723004028D8 /* WordPressTodayWidget-Internal.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = "WordPressTodayWidget-Internal.entitlements"; sourceTree = ""; }; 934884AE19B7875C004028D8 /* WordPress-Internal.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = "WordPress-Internal.entitlements"; sourceTree = ""; }; 934F1B3119ACCE5600E9E63E /* WordPress.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = WordPress.entitlements; sourceTree = ""; }; 93594BD4191D2F5A0079E6B2 /* stats-batch.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "stats-batch.json"; sourceTree = ""; }; @@ -3491,12 +7558,14 @@ 9371F2641E4A213300BF26A0 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; 9371F2651E4A213300BF26A0 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; 9371F2691E4A23A200BF26A0 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; + 937250ED267A492D0086075F /* StatsPeriodStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsPeriodStoreTests.swift; sourceTree = ""; }; 937D9A0C19F83744007B9D5F /* WordPress 22.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 22.xcdatamodel"; sourceTree = ""; }; 937D9A0E19F83812007B9D5F /* WordPress-22-23.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = "WordPress-22-23.xcmappingmodel"; sourceTree = ""; }; 937D9A1019F838C2007B9D5F /* AccountToAccount22to23.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountToAccount22to23.swift; sourceTree = ""; }; 937E3AB51E3EBE1600CDA01A /* PostEditorStateTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostEditorStateTests.swift; sourceTree = ""; }; 937F3E301AD6FDA7006BA498 /* WPAnalyticsTrackerAutomatticTracks.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = WPAnalyticsTrackerAutomatticTracks.h; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objcpp; }; 937F3E311AD6FDA7006BA498 /* WPAnalyticsTrackerAutomatticTracks.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPAnalyticsTrackerAutomatticTracks.m; sourceTree = ""; }; + 938466B82683CA0E00A538DC /* ReferrerDetailsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReferrerDetailsViewModelTests.swift; sourceTree = ""; }; 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CocoaLumberjack.swift; sourceTree = ""; }; 93A379EB19FFBF7900415023 /* KeychainTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = KeychainTest.m; sourceTree = ""; }; 93A3F7DD1843F6F00082FEEA /* CoreTelephony.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreTelephony.framework; path = System/Library/Frameworks/CoreTelephony.framework; sourceTree = SDKROOT; }; @@ -3505,7 +7574,6 @@ 93C1147E18EC5DD500DAC95C /* AccountService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AccountService.m; sourceTree = ""; }; 93C1148318EDF6E100DAC95C /* BlogService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlogService.h; sourceTree = ""; }; 93C1148418EDF6E100DAC95C /* BlogService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogService.m; sourceTree = ""; }; - 93C2075C1CC7FFC800C94D04 /* Tracks+TodayWidget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Tracks+TodayWidget.swift"; sourceTree = ""; }; 93C3C2561CAB031E0092F837 /* Info-Internal.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = "Info-Internal.plist"; path = "WordPressShareExtension/Info-Internal.plist"; sourceTree = SOURCE_ROOT; }; 93C3C2581CAB032C0092F837 /* Info-Alpha.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = "Info-Alpha.plist"; path = "WordPressShareExtension/Info-Alpha.plist"; sourceTree = SOURCE_ROOT; }; 93C882981EEB18D700227A59 /* html_page_with_link_to_rsd_non_standard.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = html_page_with_link_to_rsd_non_standard.html; sourceTree = ""; }; @@ -3513,39 +7581,39 @@ 93C8829A1EEB18D700227A59 /* plugin_redirect.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = plugin_redirect.html; sourceTree = ""; }; 93C8829B1EEB18D700227A59 /* rsd.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = rsd.xml; sourceTree = ""; }; 93CD939219099BE70049096E /* authtoken.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = authtoken.json; sourceTree = ""; }; + 93CDC72026CD342900C8A3A8 /* DestructiveAlertHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveAlertHelper.swift; sourceTree = ""; }; 93D86B931C63EC31003D8E3E /* en-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-CA"; path = "en-CA.lproj/InfoPlist.strings"; sourceTree = ""; }; 93D86B941C63EC31003D8E3E /* en-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-CA"; path = "en-CA.lproj/Localizable.strings"; sourceTree = ""; }; 93DEB88019E5BF7100F9546D /* TodayExtensionService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TodayExtensionService.h; sourceTree = ""; }; 93DEB88119E5BF7100F9546D /* TodayExtensionService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TodayExtensionService.m; sourceTree = ""; }; - 93E5283A19A7741A003A1A9C /* WordPressTodayWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WordPressTodayWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 93E5283B19A7741A003A1A9C /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; - 93E5283F19A7741A003A1A9C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 93E5284019A7741A003A1A9C /* TodayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayViewController.swift; sourceTree = ""; }; - 93E5284219A7741A003A1A9C /* MainInterface.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = MainInterface.storyboard; sourceTree = ""; }; - 93E5284F19A77824003A1A9C /* WordPressTodayWidget-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WordPressTodayWidget-Bridging-Header.h"; sourceTree = ""; }; - 93E5285719A7AA5C003A1A9C /* WordPressTodayWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = WordPressTodayWidget.entitlements; sourceTree = ""; }; + 93E63368272AC532009DACF8 /* LoginEpilogueChooseSiteTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginEpilogueChooseSiteTableViewCell.swift; sourceTree = ""; }; + 93E6336B272AF504009DACF8 /* LoginEpilogueDividerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginEpilogueDividerView.swift; sourceTree = ""; }; + 93E6336E272C1074009DACF8 /* LoginEpilogueCreateNewSiteCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginEpilogueCreateNewSiteCell.swift; sourceTree = ""; }; 93E9050219E6F240005513C9 /* WordPressTest-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WordPressTest-Bridging-Header.h"; sourceTree = ""; }; 93E9050319E6F242005513C9 /* ContextManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContextManagerTests.swift; sourceTree = ""; }; - 93E9050519E6F3D8005513C9 /* TestContextManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = TestContextManager.h; sourceTree = ""; }; - 93E9050619E6F3D8005513C9 /* TestContextManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = TestContextManager.m; sourceTree = ""; }; - 93EF094B19ED4F1100C89770 /* ContextManager-Internals.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ContextManager-Internals.h"; sourceTree = ""; }; 93F2E53F1E9E5A180050D489 /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = usr/lib/libsqlite3.tbd; sourceTree = SDKROOT; }; 93F2E5411E9E5A350050D489 /* QuickLook.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLook.framework; path = System/Library/Frameworks/QuickLook.framework; sourceTree = SDKROOT; }; 93F2E5431E9E5A570050D489 /* CoreSpotlight.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreSpotlight.framework; path = System/Library/Frameworks/CoreSpotlight.framework; sourceTree = SDKROOT; }; 93F2E54E1E9E5CB70050D489 /* CoreImage.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreImage.framework; path = System/Library/Frameworks/CoreImage.framework; sourceTree = SDKROOT; }; 93F2E5501E9E5CCD0050D489 /* CoreVideo.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreVideo.framework; path = System/Library/Frameworks/CoreVideo.framework; sourceTree = SDKROOT; }; 93F2E5521E9E5CF00050D489 /* VideoToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VideoToolbox.framework; path = System/Library/Frameworks/VideoToolbox.framework; sourceTree = SDKROOT; }; + 93F7214E271831820021A09F /* SiteStatsPinnedItemStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStatsPinnedItemStore.swift; sourceTree = ""; }; 93FA0F0118E451A80007903B /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = ""; }; 93FA0F0218E451A80007903B /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = README.md; path = ../README.md; sourceTree = ""; }; 93FA59DB18D88C1C001446BC /* PostCategoryService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = PostCategoryService.h; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objcpp; }; 93FA59DC18D88C1C001446BC /* PostCategoryService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; lineEnding = 0; path = PostCategoryService.m; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objc; }; - 968136B9BC83DFA8E463D5E4 /* Pods-WordPressScreenshotGeneration.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressScreenshotGeneration.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressScreenshotGeneration/Pods-WordPressScreenshotGeneration.release.xcconfig"; sourceTree = ""; }; - 979B445A45E13F3289F2E99E /* Pods_WordPressThisWeekWidget.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressThisWeekWidget.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9801E681274EEBC4002FDDB6 /* ReaderDetailCommentsHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailCommentsHeader.swift; sourceTree = ""; }; + 9801E684274EEC19002FDDB6 /* ReaderDetailCommentsHeader.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ReaderDetailCommentsHeader.xib; sourceTree = ""; }; + 98035B7225C49CC1002C0EB4 /* WordPress 112.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 112.xcdatamodel"; sourceTree = ""; }; + 9804A096263780B400354097 /* LikeUserHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LikeUserHelpers.swift; sourceTree = ""; }; + 9804E0B42639D88C00532095 /* WordPress 123.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 123.xcdatamodel"; sourceTree = ""; }; 98077B592075561800109F95 /* SupportTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportTableViewController.swift; sourceTree = ""; }; 98086559203D075D00D58786 /* EpilogueUserInfoCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = EpilogueUserInfoCell.xib; sourceTree = ""; }; 9808655B203D079A00D58786 /* EpilogueUserInfoCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpilogueUserInfoCell.swift; sourceTree = ""; }; 9813512D22F0FC2700F7425D /* FileDownloadsStatsRecordValueTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileDownloadsStatsRecordValueTests.swift; sourceTree = ""; }; 9813512F22F1050B00F7425D /* WordPress 89.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 89.xcdatamodel"; sourceTree = ""; }; + 9815D0B226B49A0600DF7226 /* Comment+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Comment+CoreDataProperties.swift"; sourceTree = ""; }; 981676D4221B7A4300B81C3F /* CountriesCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountriesCell.swift; sourceTree = ""; }; 981676D5221B7A4300B81C3F /* CountriesCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CountriesCell.xib; sourceTree = ""; }; 981C348F2183871100FC2683 /* SiteStatsDashboard.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = SiteStatsDashboard.storyboard; sourceTree = ""; }; @@ -3555,6 +7623,9 @@ 981C986A21B9BF6000A7C0C8 /* PostingActivityViewController.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = PostingActivityViewController.storyboard; sourceTree = ""; }; 981C986C21B9D71400A7C0C8 /* PostingActivityCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingActivityCollectionViewCell.swift; sourceTree = ""; }; 981D0929211259840014ECAF /* NoResultsViewController+MediaLibrary.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NoResultsViewController+MediaLibrary.swift"; sourceTree = ""; }; + 981D464725B0D4E7000AA65C /* ReaderSeenAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderSeenAction.swift; sourceTree = ""; }; + 9822A8402624CFB900FD8A03 /* UserProfileSiteCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileSiteCell.swift; sourceTree = ""; }; + 9822A8542624D01800FD8A03 /* UserProfileSiteCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = UserProfileSiteCell.xib; sourceTree = ""; }; 9826AE8021B5C6A700C851FA /* LatestPostSummaryCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LatestPostSummaryCell.swift; sourceTree = ""; }; 9826AE8121B5C6A700C851FA /* LatestPostSummaryCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = LatestPostSummaryCell.xib; sourceTree = ""; }; 9826AE8521B5C72300C851FA /* PostingActivityDay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingActivityDay.swift; sourceTree = ""; }; @@ -3565,8 +7636,19 @@ 9826AE8F21B5D3CD00C851FA /* PostingActivityCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PostingActivityCell.xib; sourceTree = ""; }; 9829162E2224BC1C008736C0 /* SiteStatsDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStatsDetailsViewModel.swift; sourceTree = ""; }; 982A4C3420227D6700B5518E /* NoResultsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoResultsViewController.swift; sourceTree = ""; }; - 983002A722FA05D600F03DBB /* AddInsightTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddInsightTableViewController.swift; sourceTree = ""; }; - 983AE8502399B19200E5B7F6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; + 982D261E2788DDF200A41286 /* ReaderCommentsFollowPresenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderCommentsFollowPresenter.swift; sourceTree = ""; }; + 982D99FC26F922C100AA794C /* InlineEditableMultiLineCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InlineEditableMultiLineCell.swift; sourceTree = ""; }; + 982D99FD26F922C100AA794C /* InlineEditableMultiLineCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = InlineEditableMultiLineCell.xib; sourceTree = ""; }; + 982DA9A6263B1E2F00E5743B /* CommentService+Likes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentService+Likes.swift"; sourceTree = ""; }; + 982DDF8C263238A6002B3904 /* LikeUser+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LikeUser+CoreDataClass.swift"; sourceTree = ""; }; + 982DDF8D263238A6002B3904 /* LikeUser+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LikeUser+CoreDataProperties.swift"; sourceTree = ""; }; + 982DDF8E263238A6002B3904 /* LikeUserPreferredBlog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LikeUserPreferredBlog+CoreDataClass.swift"; sourceTree = ""; }; + 982DDF8F263238A6002B3904 /* LikeUserPreferredBlog+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LikeUserPreferredBlog+CoreDataProperties.swift"; sourceTree = ""; }; + 983002A722FA05D600F03DBB /* InsightsManagementViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsightsManagementViewController.swift; sourceTree = ""; }; + 9833A29B257AE7CF006B8234 /* WordPress 105.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 105.xcdatamodel"; sourceTree = ""; }; + 9835F16D25E492EE002EFF23 /* CommentsList.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = CommentsList.storyboard; sourceTree = ""; }; + 98390AC2254C984700868F0A /* Tracks+StatsWidgets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Tracks+StatsWidgets.swift"; sourceTree = ""; }; + 9839CEB926FAA0510097406E /* CommentModerationBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentModerationBar.swift; sourceTree = ""; }; 983DBBA822125DD300753988 /* StatsTableFooter.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = StatsTableFooter.xib; sourceTree = ""; }; 983DBBA922125DD300753988 /* StatsTableFooter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsTableFooter.swift; sourceTree = ""; }; 98458CB721A39D350025D232 /* StatsNoDataRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsNoDataRow.swift; sourceTree = ""; }; @@ -3581,14 +7663,21 @@ 984B139321F66B2D0004B6A2 /* StatsPeriodStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsPeriodStore.swift; sourceTree = ""; }; 984B4EF220742FCC00F87888 /* ZendeskUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ZendeskUtils.swift; sourceTree = ""; }; 984F86FA21DEDB060070E0E3 /* TopTotalsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopTotalsCell.swift; sourceTree = ""; }; + 984FB2B22646001E00878DE0 /* WordPress 124.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 124.xcdatamodel"; sourceTree = ""; }; + 98517E5828220411001FFD45 /* BloggingPromptTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptTableViewCell.swift; sourceTree = ""; }; + 98517E5B28220474001FFD45 /* BloggingPromptTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = BloggingPromptTableViewCell.xib; sourceTree = ""; }; 98563DDB21BF30C40006F5E9 /* TabbedTotalsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedTotalsCell.swift; sourceTree = ""; }; 98563DDC21BF30C40006F5E9 /* TabbedTotalsCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TabbedTotalsCell.xib; sourceTree = ""; }; + 9856A388261FC206008D6354 /* UserProfileUserInfoCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = UserProfileUserInfoCell.xib; sourceTree = ""; }; + 9856A39C261FC21E008D6354 /* UserProfileUserInfoCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileUserInfoCell.swift; sourceTree = ""; }; + 9856A3E3261FD27A008D6354 /* UserProfileSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileSectionHeader.swift; sourceTree = ""; }; 985793C622F23D7000643DBF /* CustomizeInsightsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomizeInsightsCell.swift; sourceTree = ""; }; 985793C722F23D7000643DBF /* CustomizeInsightsCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CustomizeInsightsCell.xib; sourceTree = ""; }; 98579BC5203DD86D004086E4 /* EpilogueSectionHeaderFooter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpilogueSectionHeaderFooter.swift; sourceTree = ""; }; 98579BC6203DD86E004086E4 /* EpilogueSectionHeaderFooter.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = EpilogueSectionHeaderFooter.xib; sourceTree = ""; }; 985ED0E323C6950600B8D06A /* WidgetStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetStyles.swift; sourceTree = ""; }; 985F06B42303866200949733 /* WelcomeScreenLoginComponent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeScreenLoginComponent.swift; sourceTree = ""; }; + 98622E9E274C59A400061A5F /* ReaderDetailCommentsTableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailCommentsTableViewDelegate.swift; sourceTree = ""; }; 9865257C2194D77E0078B916 /* SiteStatsInsightsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteStatsInsightsViewModel.swift; sourceTree = ""; }; 98656BD72037A1770079DE67 /* SignupEpilogueViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupEpilogueViewController.swift; sourceTree = ""; }; 986C908322319EFF00FC31E1 /* PostStatsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostStatsTableViewController.swift; sourceTree = ""; }; @@ -3596,51 +7685,46 @@ 986C90872231AD6200FC31E1 /* PostStatsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostStatsViewModel.swift; sourceTree = ""; }; 986CC4D120E1B2F6004F300E /* CustomLogFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomLogFormatter.swift; sourceTree = ""; }; 986DD19B218D002500D28061 /* WPStyleGuide+Stats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Stats.swift"; sourceTree = ""; }; - 98712D1923DA1C7E00555316 /* WidgetNoConnectionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetNoConnectionCell.swift; sourceTree = ""; }; - 98712D1A23DA1C7E00555316 /* WidgetNoConnectionCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = WidgetNoConnectionCell.xib; sourceTree = ""; }; + 987044AD268A9A5300BD0571 /* WordPress 126.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 126.xcdatamodel"; sourceTree = ""; }; 9872CB2F203B8A730066A293 /* SignupEpilogueTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupEpilogueTableViewController.swift; sourceTree = ""; }; + 9872EA6826B9EE38009F795B /* WordPress 130.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 130.xcdatamodel"; sourceTree = ""; }; 9874766E219630240080967F /* SiteStatsTableViewCells.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteStatsTableViewCells.swift; sourceTree = ""; }; 9874767221963D320080967F /* SiteStatsInformation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteStatsInformation.swift; sourceTree = ""; }; 987535612282682D001661B4 /* DetailDataCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailDataCell.swift; sourceTree = ""; }; 987535622282682D001661B4 /* DetailDataCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = DetailDataCell.xib; sourceTree = ""; }; + 9878876E258823EB0099AD53 /* WordPress 108.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 108.xcdatamodel"; sourceTree = ""; }; 98797DBA222F434500128C21 /* OverviewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewCell.swift; sourceTree = ""; }; 98797DBB222F434500128C21 /* OverviewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = OverviewCell.xib; sourceTree = ""; }; + 987C40C525E590BE002A0955 /* CommentsViewController+Filters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentsViewController+Filters.swift"; sourceTree = ""; }; + 987E79CA261F8857000192B7 /* UserProfileSheetViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserProfileSheetViewController.swift; sourceTree = ""; }; 988056022183CCE50083B643 /* SiteStatsInsightsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStatsInsightsTableViewController.swift; sourceTree = ""; }; 98812964219CE42A0075FF33 /* StatsTotalRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsTotalRow.swift; sourceTree = ""; }; 98812965219CE42A0075FF33 /* StatsTotalRow.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = StatsTotalRow.xib; sourceTree = ""; }; 9881296C219CF1300075FF33 /* StatsCellHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsCellHeader.swift; sourceTree = ""; }; 9881296D219CF1310075FF33 /* StatsCellHeader.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = StatsCellHeader.xib; sourceTree = ""; }; + 98830A912747043B0061A87C /* BorderedButtonTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BorderedButtonTableViewCell.swift; sourceTree = ""; }; 9884B143236224F80021D8E9 /* WordPressUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = WordPressUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9884B145236225230021D8E9 /* WordPressUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = WordPressUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9887560B2810BA7A00AD7589 /* BloggingPromptsIntroductionPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptsIntroductionPresenter.swift; sourceTree = ""; }; 98880A4822B2E5E400464538 /* TwoColumnCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwoColumnCell.swift; sourceTree = ""; }; 98880A4922B2E5E400464538 /* TwoColumnCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TwoColumnCell.xib; sourceTree = ""; }; 988AC37422F10DD900BC1433 /* FileDownloadsStatsRecordValue+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileDownloadsStatsRecordValue+CoreDataProperties.swift"; sourceTree = ""; }; 988AC37822F10E2C00BC1433 /* FileDownloadsStatsRecordValue+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileDownloadsStatsRecordValue+CoreDataClass.swift"; sourceTree = ""; }; - 988F073323D0CE8800AC67A6 /* WidgetUrlCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetUrlCell.swift; sourceTree = ""; }; - 988F073423D0CE8800AC67A6 /* WidgetUrlCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = WidgetUrlCell.xib; sourceTree = ""; }; - 989064FB237CC1DE00218CD2 /* WidgetUnconfiguredCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WidgetUnconfiguredCell.swift; sourceTree = ""; }; - 989064FC237CC1DE00218CD2 /* WidgetUnconfiguredCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = WidgetUnconfiguredCell.xib; sourceTree = ""; }; - 989064FD237CC1DE00218CD2 /* WidgetTwoColumnCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WidgetTwoColumnCell.swift; sourceTree = ""; }; - 989064FE237CC1DE00218CD2 /* WidgetTwoColumnCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = WidgetTwoColumnCell.xib; sourceTree = ""; }; + 988AD63726B089CE003552B4 /* WordPress 128.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 128.xcdatamodel"; sourceTree = ""; }; + 988FD749279B75A400C7E814 /* NotificationCommentDetailCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCommentDetailCoordinator.swift; sourceTree = ""; }; 98921EF621372E12004949AA /* MediaCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaCoordinator.swift; sourceTree = ""; }; - 98921EF821372E30004949AA /* LocalNewsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalNewsService.swift; sourceTree = ""; }; - 98921EFA21372E57004949AA /* NewsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsService.swift; sourceTree = ""; }; + 9895401026C1F39300EDEB5A /* EditCommentTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCommentTableViewController.swift; sourceTree = ""; }; 9895B6DF21ED49160053D370 /* TopTotalsCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = TopTotalsCell.xib; sourceTree = ""; }; - 989643EA23A0437B0070720A /* WidgetDifferenceCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDifferenceCell.swift; sourceTree = ""; }; - 989643EB23A0437B0070720A /* WidgetDifferenceCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = WidgetDifferenceCell.xib; sourceTree = ""; }; + 98991A1125AE653D00B3BBAC /* WordPress 111.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 111.xcdatamodel"; sourceTree = ""; }; + 98A047712821CEBF001B4E2D /* BloggingPromptsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptsViewController.swift; sourceTree = ""; }; + 98A047742821D069001B4E2D /* BloggingPromptsViewController.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = BloggingPromptsViewController.storyboard; sourceTree = ""; }; 98A25BD0203CB25F006A5807 /* SignupEpilogueCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignupEpilogueCell.swift; sourceTree = ""; }; 98A25BD2203CB278006A5807 /* SignupEpilogueCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = SignupEpilogueCell.xib; sourceTree = ""; }; - 98A3C2EF239997DA0048D38D /* WordPressThisWeekWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WordPressThisWeekWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - 98A3C2F7239997DA0048D38D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 98A3C3002399A1850048D38D /* MainInterface.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = MainInterface.storyboard; sourceTree = ""; }; - 98A3C3032399A2370048D38D /* WordPressThisWeekWidget-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WordPressThisWeekWidget-Bridging-Header.h"; sourceTree = ""; }; - 98A3C3042399A2370048D38D /* ThisWeekViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThisWeekViewController.swift; sourceTree = ""; }; - 98A3C3072399A57D0048D38D /* Info-Alpha.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Alpha.plist"; sourceTree = ""; }; - 98A3C3082399A57D0048D38D /* Info-Internal.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Info-Internal.plist"; sourceTree = ""; }; - 98A3C30B2399A6570048D38D /* WordPressThisWeekWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WordPressThisWeekWidget.entitlements; sourceTree = ""; }; - 98A3C30C2399A6570048D38D /* WordPressThisWeekWidget-Alpha.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "WordPressThisWeekWidget-Alpha.entitlements"; sourceTree = ""; }; - 98A3C30D2399A6580048D38D /* WordPressThisWeekWidget-Internal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "WordPressThisWeekWidget-Internal.entitlements"; sourceTree = ""; }; - 98A437AD20069097004A8A57 /* DomainSuggestionsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DomainSuggestionsTableViewController.swift; sourceTree = ""; }; + 98AA22BC25082A86005CCC13 /* PrologueScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrologueScreen.swift; sourceTree = ""; }; + 98AA22BE25082B1E005CCC13 /* GetStartedScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetStartedScreen.swift; sourceTree = ""; }; + 98AA6D1026B8CE7200920C8B /* Comment+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Comment+CoreDataClass.swift"; sourceTree = ""; }; + 98AA9F2027EA890800B3A98C /* FeatureIntroductionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroductionViewController.swift; sourceTree = ""; }; + 98ADDDD925083CA9008FF6EE /* PasswordScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasswordScreen.swift; sourceTree = ""; }; 98AE3DF4219A1788003C0E24 /* StatsInsightsStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsInsightsStore.swift; sourceTree = ""; }; 98B11B882216535100B7F2D7 /* StatsChildRowsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsChildRowsView.swift; sourceTree = ""; }; 98B11B8A2216536C00B7F2D7 /* StatsChildRowsView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatsChildRowsView.xib; sourceTree = ""; }; @@ -3648,40 +7732,41 @@ 98B3FA8321C05BDC00148DD4 /* ViewMoreRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewMoreRow.swift; sourceTree = ""; }; 98B3FA8521C05BF000148DD4 /* ViewMoreRow.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ViewMoreRow.xib; sourceTree = ""; }; 98B52AE021F7AF4A006FF6B4 /* StatsDataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDataHelper.swift; sourceTree = ""; }; + 98B88451261E4E09007ED7F8 /* LikeUserTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikeUserTableViewCell.swift; sourceTree = ""; }; + 98B88465261E4E4E007ED7F8 /* LikeUserTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LikeUserTableViewCell.xib; sourceTree = ""; }; + 98BAA7BF26F925F60073A2F9 /* InlineEditableSingleLineCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = InlineEditableSingleLineCell.xib; sourceTree = ""; }; + 98BAA7C026F925F70073A2F9 /* InlineEditableSingleLineCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InlineEditableSingleLineCell.swift; sourceTree = ""; }; + 98BBB642258047DD0084FF72 /* WordPress 107.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 107.xcdatamodel"; sourceTree = ""; }; + 98BC522327F6245700D6E8C2 /* BloggingPromptsFeatureIntroduction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BloggingPromptsFeatureIntroduction.swift; sourceTree = ""; }; + 98BC522927F6259700D6E8C2 /* BloggingPromptsFeatureDescriptionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BloggingPromptsFeatureDescriptionView.swift; sourceTree = ""; }; + 98BC522C27F62B6300D6E8C2 /* BloggingPromptsFeatureDescriptionView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BloggingPromptsFeatureDescriptionView.xib; sourceTree = ""; }; 98BDFF6A20D0732900C72C58 /* SupportTableViewController+Activity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SupportTableViewController+Activity.swift"; sourceTree = ""; }; 98BFF57D23984344008A1DCB /* AllTimeWidgetStats.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AllTimeWidgetStats.swift; sourceTree = ""; }; 98CAD295221B4ED1003E8F45 /* StatSection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatSection.swift; sourceTree = ""; }; - 98D31B8E2396ED7E009CFF43 /* WordPressAllTimeWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WordPressAllTimeWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - 98D31B962396ED7F009CFF43 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 98D31BA02396F417009CFF43 /* Info-Alpha.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Alpha.plist"; sourceTree = ""; }; - 98D31BA12396F417009CFF43 /* Info-Internal.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Info-Internal.plist"; sourceTree = ""; }; - 98D31BA22396F5C1009CFF43 /* WordPressAllTimeWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WordPressAllTimeWidget.entitlements; sourceTree = ""; }; - 98D31BA32396F5C1009CFF43 /* WordPressAllTimeWidget-Alpha.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "WordPressAllTimeWidget-Alpha.entitlements"; sourceTree = ""; }; - 98D31BA42396F5C2009CFF43 /* WordPressAllTimeWidget-Internal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "WordPressAllTimeWidget-Internal.entitlements"; sourceTree = ""; }; - 98D31BA52396F7BF009CFF43 /* AllTimeViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AllTimeViewController.swift; sourceTree = ""; }; - 98D31BA82396FAD2009CFF43 /* WordPressAllTimeWidget-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "WordPressAllTimeWidget-Bridging-Header.h"; sourceTree = ""; }; - 98D31BA92396FBDC009CFF43 /* AllTimeWidgetPrefix.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AllTimeWidgetPrefix.pch; sourceTree = ""; }; - 98D31BAB23970078009CFF43 /* Tracks+AllTimeWidget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Tracks+AllTimeWidget.swift"; sourceTree = ""; }; - 98D31BBC23971F4B009CFF43 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; - 98D31BBF239720E4009CFF43 /* MainInterface.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = MainInterface.storyboard; path = WordPressAllTimeWidget/MainInterface.storyboard; sourceTree = ""; }; 98D52C3022B1CFEB00831529 /* StatsTwoColumnRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsTwoColumnRow.swift; sourceTree = ""; }; 98D52C3122B1CFEC00831529 /* StatsTwoColumnRow.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = StatsTwoColumnRow.xib; sourceTree = ""; }; - 98DE9A9E239835C800A88D01 /* MainInterface.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = MainInterface.storyboard; sourceTree = ""; }; - 98E419DD2399B5A700D8C822 /* Tracks+ThisWeekWidget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Tracks+ThisWeekWidget.swift"; sourceTree = ""; }; - 98E419E02399B6C300D8C822 /* ThisWeekWidgetPrefix.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ThisWeekWidgetPrefix.pch; sourceTree = ""; }; + 98DCF4A3275945DF0008630F /* ReaderDetailNoCommentCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderDetailNoCommentCell.swift; sourceTree = ""; }; + 98DCF4A4275945DF0008630F /* ReaderDetailNoCommentCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ReaderDetailNoCommentCell.xib; sourceTree = ""; }; + 98DD1EF2263337C400CF0440 /* WordPress 122.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 122.xcdatamodel"; sourceTree = ""; }; + 98E0829E2637545C00537BF1 /* PostService+Likes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PostService+Likes.swift"; sourceTree = ""; }; + 98E14A3B27C9712D007B0896 /* NotificationCommentDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCommentDetailViewController.swift; sourceTree = ""; }; + 98E54FF1265C972900B4BE9A /* ReaderDetailLikesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderDetailLikesView.swift; sourceTree = ""; }; + 98E55018265C977E00B4BE9A /* ReaderDetailLikesView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderDetailLikesView.xib; sourceTree = ""; }; 98E58A2E2360D23400E5534B /* TodayWidgetStats.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TodayWidgetStats.swift; sourceTree = ""; }; - 98E58A412361017500E5534B /* Pods_WordPressTodayWidget.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Pods_WordPressTodayWidget.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 98E58A432361019300E5534B /* Pods_WordPressTodayWidget.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Pods_WordPressTodayWidget.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 98E5D4912620C2B40074A56A /* UserProfileSectionHeader.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = UserProfileSectionHeader.xib; sourceTree = ""; }; 98EB126920D2DC2500D2D5B5 /* NoResultsViewController+Model.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NoResultsViewController+Model.swift"; sourceTree = ""; }; + 98ED5962265EBD0000A0B33E /* ReaderDetailLikesListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderDetailLikesListController.swift; sourceTree = ""; }; 98F1B1292111017900139493 /* NoResultsStockPhotosConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoResultsStockPhotosConfiguration.swift; sourceTree = ""; }; + 98F4044E26BB69A000BBD8B9 /* WordPress 131.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 131.xcdatamodel"; sourceTree = ""; }; 98F537A622496CF300B334F9 /* SiteStatsTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStatsTableHeaderView.swift; sourceTree = ""; }; 98F537A822496D0D00B334F9 /* SiteStatsTableHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SiteStatsTableHeaderView.xib; sourceTree = ""; }; 98F93181239AF64800E4E96E /* ThisWeekWidgetStats.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThisWeekWidgetStats.swift; sourceTree = ""; }; - 98FB6E9F23074CE5002DDC8D /* SDKVersions.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = SDKVersions.xcconfig; sourceTree = ""; }; + 98F9FB2D270282C100ADF552 /* CommentModerationBar.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = CommentModerationBar.xib; sourceTree = ""; }; + 98FB6E9F23074CE5002DDC8D /* Common.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Common.xcconfig; sourceTree = ""; }; + 98FBA05426B228CB004E610A /* WordPress 129.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 129.xcdatamodel"; sourceTree = ""; }; 98FCFC212231DF43006ECDD4 /* PostStatsTitleCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostStatsTitleCell.swift; sourceTree = ""; }; 98FCFC222231DF43006ECDD4 /* PostStatsTitleCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = PostStatsTitleCell.xib; sourceTree = ""; }; 98FF6A3D23A30A240025FD72 /* QuickStartNavigationSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuickStartNavigationSettings.swift; sourceTree = ""; }; - 99D675225C3282AC88B89F04 /* Pods-WordPressTodayWidget.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressTodayWidget.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressTodayWidget/Pods-WordPressTodayWidget.release-internal.xcconfig"; sourceTree = ""; }; 9A034CEA237DB8660047B41B /* StatsForegroundObservable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsForegroundObservable.swift; sourceTree = ""; }; 9A09F914230C3E9700F42AB7 /* StoreFetchingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreFetchingStatus.swift; sourceTree = ""; }; 9A09F91A230C49FD00F42AB7 /* StatsStackViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatsStackViewCell.xib; sourceTree = ""; }; @@ -3718,8 +7803,6 @@ 9A4A8F4A235758EF00088CE4 /* StatsStore+Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatsStore+Cache.swift"; sourceTree = ""; }; 9A4E215921F7565A00EFF212 /* QuickStartChecklistCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = QuickStartChecklistCell.xib; sourceTree = ""; }; 9A4E215B21F75BBE00EFF212 /* QuickStartChecklistManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartChecklistManager.swift; sourceTree = ""; }; - 9A4E215F21F87AE200EFF212 /* QuickStartChecklistHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartChecklistHeader.swift; sourceTree = ""; }; - 9A4E216121F87AF300EFF212 /* QuickStartChecklistHeader.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickStartChecklistHeader.xib; sourceTree = ""; }; 9A4E271922EF0C78001F6A6B /* ChangeUsernameViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeUsernameViewModel.swift; sourceTree = ""; }; 9A4E271C22EF33F5001F6A6B /* AccountSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSettingsStore.swift; sourceTree = ""; }; 9A4E61F721A2C3BC0017A925 /* RevisionDiff+CoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RevisionDiff+CoreData.swift"; sourceTree = ""; }; @@ -3762,20 +7845,51 @@ 9F8E38BD209C6DE200454E3C /* NotificationSiteSubscriptionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSiteSubscriptionViewController.swift; sourceTree = ""; }; A01C542D0E24E88400D411F2 /* SystemConfiguration.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; A01C55470E25E0D000D411F2 /* defaultPostTemplate.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = defaultPostTemplate.html; path = Resources/HTML/defaultPostTemplate.html; sourceTree = ""; }; + A0D83E08D5D2573348DE8926 /* Pods-WordPressIntents.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressIntents.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressIntents/Pods-WordPressIntents.release-internal.xcconfig"; sourceTree = ""; }; A0E293EF0E21027E00C6919C /* WPAddPostCategoryViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPAddPostCategoryViewController.h; sourceTree = ""; }; A0E293F00E21027E00C6919C /* WPAddPostCategoryViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; lineEnding = 0; path = WPAddPostCategoryViewController.m; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objc; }; + A1DD7BB9C25967442493CC19 /* Pods_WordPressIntents.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressIntents.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A20971B419B0BC390058F395 /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; A20971B519B0BC390058F395 /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/InfoPlist.strings"; sourceTree = ""; }; A20971B719B0BC570058F395 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; A20971B819B0BC570058F395 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = ""; }; A27DA63A5ECD1D0262C589EC /* Pods-WordPressNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressNotificationServiceExtension.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressNotificationServiceExtension/Pods-WordPressNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; A284044518BFE7F300D982B6 /* WordPress 15.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 15.xcdatamodel"; sourceTree = ""; }; + AB2211D125ED68E300BF72FC /* CommentServiceRemoteFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentServiceRemoteFactory.swift; sourceTree = ""; }; + AB2211F325ED6E7A00BF72FC /* CommentServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentServiceTests.swift; sourceTree = ""; }; AB390AA9C94F16E78184E9D1 /* Pods_WordPressScreenshotGeneration.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressScreenshotGeneration.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - AC6024D92F44AE4CB4A8C1B3 /* Pods-WordPressScreenshotGeneration.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressScreenshotGeneration.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressScreenshotGeneration/Pods-WordPressScreenshotGeneration.release-alpha.xcconfig"; sourceTree = ""; }; + AB758D9D25EFDF9C00961C0B /* LikesListController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikesListController.swift; sourceTree = ""; }; + AC68C9C928E5DF14009030A9 /* NotificationsViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsViewControllerTests.swift; sourceTree = ""; }; + ACACE3AD28D729FA000992F9 /* NoResultsViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoResultsViewControllerTests.swift; sourceTree = ""; }; ACBAB5FC0E121C7300F38795 /* PostSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PostSettingsViewController.h; sourceTree = ""; usesTabs = 0; }; ACBAB5FD0E121C7300F38795 /* PostSettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PostSettingsViewController.m; sourceTree = ""; usesTabs = 0; }; + ADE06D6829F9044164BBA5AB /* Pods-WordPressIntents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressIntents.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressIntents/Pods-WordPressIntents.release.xcconfig"; sourceTree = ""; }; ADF544C0195A0F620092213D /* CustomHighlightButton.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CustomHighlightButton.h; sourceTree = ""; }; ADF544C1195A0F620092213D /* CustomHighlightButton.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CustomHighlightButton.m; sourceTree = ""; }; + AE2F3124270B6DA000B2A9C2 /* Scanner+QuotedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Scanner+QuotedText.swift"; sourceTree = ""; }; + AE2F3127270B6DE200B2A9C2 /* NSMutableAttributedString+ApplyAttributesToQuotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMutableAttributedString+ApplyAttributesToQuotes.swift"; sourceTree = ""; }; + AE3047A9270B66D300FE9266 /* Scanner+QuotedTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Scanner+QuotedTextTests.swift"; sourceTree = ""; }; + AEE082892681C23C00DCF54B /* GutenbergRefactoredGalleryUploadProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergRefactoredGalleryUploadProcessorTests.swift; sourceTree = ""; }; + B030FE0927EBF0BC000F6F5E /* SiteCreationIntentTracksEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteCreationIntentTracksEventTests.swift; sourceTree = ""; }; + B03B9233250BC593000A40AF /* SuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionService.swift; sourceTree = ""; }; + B03B9235250BC5FD000A40AF /* Suggestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Suggestion.swift; sourceTree = ""; }; + B0637526253E7CEB00FD45D2 /* SuggestionsTableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SuggestionsTableView.swift; path = Suggestions/SuggestionsTableView.swift; sourceTree = ""; }; + B0637542253E7E7A00FD45D2 /* GutenbergSuggestionsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GutenbergSuggestionsViewController.swift; sourceTree = ""; }; + B06378AE253F619500FD45D2 /* WordPress 103.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 103.xcdatamodel"; sourceTree = ""; }; + B06378BE253F639D00FD45D2 /* SiteSuggestion+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SiteSuggestion+CoreDataClass.swift"; sourceTree = ""; }; + B06378BF253F639D00FD45D2 /* SiteSuggestion+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SiteSuggestion+CoreDataProperties.swift"; sourceTree = ""; }; + B089140C27E1255D00CF468B /* SiteIntentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteIntentViewController.swift; sourceTree = ""; }; + B0960C8627D14BD400BC9717 /* SiteIntentStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteIntentStep.swift; sourceTree = ""; }; + B0A6DEBE2626335F00B5B8EF /* AztecPostViewController+MenuTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "AztecPostViewController+MenuTests.swift"; path = "Aztec/AztecPostViewController+MenuTests.swift"; sourceTree = ""; }; + B0AC50DC251E96270039E022 /* ReaderCommentsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCommentsViewController.swift; sourceTree = ""; }; + B0B68A9A252FA91E0001B28C /* UserSuggestion+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserSuggestion+CoreDataClass.swift"; sourceTree = ""; }; + B0B68A9B252FA91E0001B28C /* UserSuggestion+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserSuggestion+CoreDataProperties.swift"; sourceTree = ""; }; + B0CD27CE286F8858009500BF /* JetpackBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBannerView.swift; sourceTree = ""; }; + B0DDC2EB252F7C4F002BAFB3 /* WordPress 100.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 100.xcdatamodel"; sourceTree = ""; }; + B0F2EFBE259378E600C7EB6D /* SiteSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteSuggestionService.swift; sourceTree = ""; }; + B124AFFFB3F0204107FD33D0 /* Pods-JetpackIntents.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackIntents.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackIntents/Pods-JetpackIntents.release-alpha.xcconfig"; sourceTree = ""; }; + B3694C30079615C927D26E9F /* Pods-JetpackStatsWidgets.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackStatsWidgets.release.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackStatsWidgets/Pods-JetpackStatsWidgets.release.xcconfig"; sourceTree = ""; }; + B4CAFF307BEC7FD77CCF573C /* Pods-JetpackStatsWidgets.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackStatsWidgets.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackStatsWidgets/Pods-JetpackStatsWidgets.release-internal.xcconfig"; sourceTree = ""; }; B5015C571D4FDBB300C9449E /* NotificationActionsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationActionsService.swift; sourceTree = ""; }; B50248AE1C96FF6200AFBDED /* WPStyleGuide+Share.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "WPStyleGuide+Share.swift"; path = "WordPressShareExtension/WPStyleGuide+Share.swift"; sourceTree = SOURCE_ROOT; }; B50248B81C96FFB000AFBDED /* WordPressShare-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "WordPressShare-Bridging-Header.h"; path = "WordPressShareExtension/WordPressShare-Bridging-Header.h"; sourceTree = SOURCE_ROOT; }; @@ -3785,8 +7899,6 @@ B50248BC1C96FFCC00AFBDED /* WordPressShare-Lumberjack.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "WordPressShare-Lumberjack.m"; path = "WordPressShareExtension/WordPressShare-Lumberjack.m"; sourceTree = SOURCE_ROOT; }; B50248BD1C96FFCC00AFBDED /* WordPressShare.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; name = WordPressShare.entitlements; path = WordPressShareExtension/WordPressShare.entitlements; sourceTree = SOURCE_ROOT; }; B50248BE1C96FFCC00AFBDED /* WordPressSharePrefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = WordPressSharePrefix.pch; path = WordPressShareExtension/WordPressSharePrefix.pch; sourceTree = SOURCE_ROOT; }; - B50421E61B680839008EEA82 /* NoteUndoOverlayView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NoteUndoOverlayView.xib; sourceTree = ""; }; - B50421E81B68170F008EEA82 /* NoteUndoOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoteUndoOverlayView.swift; sourceTree = ""; }; B504F5F41C9C2BD000F8B1C6 /* Tracks+ShareExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Tracks+ShareExtension.swift"; sourceTree = SOURCE_ROOT; }; B50C0C591EF42A2600372C65 /* MediaProgressCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaProgressCoordinator.swift; sourceTree = ""; }; B50C0C5C1EF42A4A00372C65 /* AztecAttachmentViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AztecAttachmentViewController.swift; sourceTree = ""; }; @@ -3806,7 +7918,6 @@ B526DC271B1E47FC002A8C5F /* WPStyleGuide+WebView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "WPStyleGuide+WebView.h"; sourceTree = ""; }; B526DC281B1E47FC002A8C5F /* WPStyleGuide+WebView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WPStyleGuide+WebView.m"; sourceTree = ""; }; B52C4C7C199D4CD3009FD823 /* NoteBlockUserTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = NoteBlockUserTableViewCell.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; - B52C4C7E199D74AE009FD823 /* NoteTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = NoteTableViewCell.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; B52F8CD71B43260C00D36025 /* NotificationSettingStreamsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationSettingStreamsViewController.swift; sourceTree = ""; }; B5326E6E203F554C007392C3 /* WordPressSupportSourceTag+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WordPressSupportSourceTag+Helpers.swift"; sourceTree = ""; }; B532ACCE1DC3AB8E00FFFA57 /* NotificationSyncMediatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationSyncMediatorTests.swift; sourceTree = ""; }; @@ -3837,7 +7948,6 @@ B54866C91A0D7042004AC79D /* NSAttributedString+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = "NSAttributedString+Helpers.swift"; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; B549BA671CF7447E0086C608 /* InvitePersonViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InvitePersonViewController.swift; sourceTree = ""; }; B54C02231F38F50100574572 /* String+RegEx.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+RegEx.swift"; sourceTree = ""; }; - B54E1DED1A0A7BAA00807537 /* ReplyBezierView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplyBezierView.swift; sourceTree = ""; }; B54E1DEE1A0A7BAA00807537 /* ReplyTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplyTextView.swift; sourceTree = ""; }; B54E1DEF1A0A7BAA00807537 /* ReplyTextView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ReplyTextView.xib; sourceTree = ""; }; B54E1DF31A0A7BBF00807537 /* NotificationMediaDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationMediaDownloader.swift; sourceTree = ""; }; @@ -3848,10 +7958,6 @@ B555E1131C04A1DD00CEC81B /* WordPress 42.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 42.xcdatamodel"; sourceTree = ""; }; B555E1141C04A68D00CEC81B /* WordPress-41-42.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = "WordPress-41-42.xcmappingmodel"; sourceTree = ""; }; B556EFCA1DCA374200728F93 /* DictionaryHelpersTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DictionaryHelpersTests.swift; sourceTree = ""; }; - B55853F11962337500FAF6C3 /* NSScanner+Helpers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSScanner+Helpers.h"; sourceTree = ""; }; - B55853F21962337500FAF6C3 /* NSScanner+Helpers.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSScanner+Helpers.m"; sourceTree = ""; }; - B55853F519630D5400FAF6C3 /* NSAttributedString+Util.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSAttributedString+Util.h"; sourceTree = ""; }; - B55853F619630D5400FAF6C3 /* NSAttributedString+Util.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSAttributedString+Util.m"; sourceTree = ""; }; B558541019631A1000FAF6C3 /* Notifications.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Notifications.storyboard; sourceTree = ""; }; B55F1AA11C107CE200FD04D4 /* BlogSettingsDiscussionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlogSettingsDiscussionTests.swift; sourceTree = ""; }; B55F1AA71C10936600FD04D4 /* BlogSettings+Discussion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BlogSettings+Discussion.swift"; sourceTree = ""; }; @@ -3860,7 +7966,6 @@ B56695AF1D411EEB007E342F /* KeyboardDismissHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardDismissHelper.swift; sourceTree = ""; }; B566DE701BFE46D9002B9DBB /* WordPress 41.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 41.xcdatamodel"; sourceTree = ""; }; B566EC741B83867800278395 /* NSMutableAttributedStringTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSMutableAttributedStringTests.swift; sourceTree = ""; }; - B5683DB71B6C03810043447C /* NoteTableHeaderView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NoteTableHeaderView.xib; sourceTree = ""; }; B56994441B7A7EF200FF26FA /* WPStyleGuide+Comments.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Comments.swift"; sourceTree = ""; }; B56A70171B5040B9001D5815 /* SwitchTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchTableViewCell.swift; sourceTree = ""; }; B56D96B4202C9B9300485233 /* WordPressUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = WordPressUI.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -3877,7 +7982,6 @@ B5772AC51C9C84900031F97E /* GravatarServiceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GravatarServiceTests.swift; sourceTree = ""; }; B57AF5F91ACDC73D0075A7D2 /* NoteBlockActionsTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoteBlockActionsTableViewCell.swift; sourceTree = ""; }; B57B92BC1B73B08100DFF00B /* SeparatorsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SeparatorsView.swift; sourceTree = ""; }; - B57B99D419A2C20200506504 /* NoteTableHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoteTableHeaderView.swift; sourceTree = ""; }; B57B99DC19A2DBF200506504 /* NSObject+Helpers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSObject+Helpers.h"; sourceTree = ""; }; B57B99DD19A2DBF200506504 /* NSObject+Helpers.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSObject+Helpers.m"; sourceTree = ""; }; B587798419B799EB00E57C5A /* Notification+Interface.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Notification+Interface.swift"; sourceTree = ""; }; @@ -3898,6 +8002,7 @@ B5AEEC751ACACFDA008BF2A4 /* notifications-like.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "notifications-like.json"; sourceTree = ""; }; B5AEEC771ACACFDA008BF2A4 /* notifications-new-follower.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "notifications-new-follower.json"; sourceTree = ""; }; B5AEEC781ACACFDA008BF2A4 /* notifications-replied-comment.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "notifications-replied-comment.json"; sourceTree = ""; }; + B5AF0C63305888C3424155D6 /* Pods-WordPressScreenshotGeneration.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressScreenshotGeneration.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressScreenshotGeneration/Pods-WordPressScreenshotGeneration.release-internal.xcconfig"; sourceTree = ""; }; B5B410B51B1772B000CFCF8D /* NavigationTitleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationTitleView.swift; sourceTree = ""; }; B5B56D3019AFB68800B4E29B /* WPStyleGuide+Reply.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Reply.swift"; sourceTree = ""; }; B5B56D3119AFB68800B4E29B /* WPStyleGuide+Notifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = "WPStyleGuide+Notifications.swift"; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; @@ -3915,8 +8020,6 @@ B5C940191DB900DC0079D4FF /* AccountHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountHelper.swift; sourceTree = ""; }; B5CABB161C0E382C0050AB9F /* PickerTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PickerTableViewCell.swift; sourceTree = ""; }; B5CC05F51962150600975CAC /* Constants.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Constants.m; sourceTree = ""; }; - B5CEEB8D1B7920BE00E7B7B0 /* CommentsTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommentsTableViewCell.swift; sourceTree = ""; }; - B5CEEB8F1B79244D00E7B7B0 /* CommentsTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = CommentsTableViewCell.xib; sourceTree = ""; }; B5D607D71B55E1E900C65DF9 /* WordPress 35.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 35.xcdatamodel"; sourceTree = ""; }; B5D889401BEBE30A007C4684 /* BlogSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlogSettings.swift; sourceTree = ""; }; B5DA8A5E20ADAA1C00D5BDE1 /* plugin-directory-jetpack.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "plugin-directory-jetpack.json"; sourceTree = ""; }; @@ -3924,7 +8027,6 @@ B5DBE4FD1D21A700002E81D3 /* NotificationsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = NotificationsViewController.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; B5DD04731CD3DAB00003DF89 /* NSFetchedResultsController+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSFetchedResultsController+Helpers.swift"; sourceTree = ""; }; B5E167F319C08D18009535AA /* NSCalendar+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSCalendar+Helpers.swift"; sourceTree = ""; }; - B5E23BDE19AD0D00000D6879 /* NoteTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NoteTableViewCell.xib; sourceTree = ""; }; B5E51B7A203477DF00151ECD /* WordPressAuthenticationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressAuthenticationManager.swift; sourceTree = ""; }; B5E94D141FE04815000E7C20 /* UIImageView+SiteIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImageView+SiteIcon.swift"; sourceTree = ""; }; B5EB19EB20C6DACC008372B9 /* ImageDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDownloader.swift; sourceTree = ""; }; @@ -3939,24 +8041,21 @@ B5F641B21E37C36700B7819F /* AdaptiveNavigationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdaptiveNavigationController.swift; sourceTree = ""; }; B5F67AC61DB7D81300482C62 /* NotificationSyncMediator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationSyncMediator.swift; sourceTree = ""; }; B5F94DBF1DB8035200DD505D /* WordPress 53.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 53.xcdatamodel"; sourceTree = ""; }; - B5F9959F1B59708C00AB0B3E /* NotificationSettingsViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NotificationSettingsViewController.xib; sourceTree = ""; }; - B5FA22821C99F6180016CA7C /* Tracks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Tracks.swift; path = WordPressShareExtension/Tracks.swift; sourceTree = SOURCE_ROOT; }; + B5FA22821C99F6180016CA7C /* Tracks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Tracks.swift; path = WordPressShareExtension/Tracks.swift; sourceTree = SOURCE_ROOT; wrapsLines = 0; }; B5FA868A1D10A41600AB5F7E /* UIImage+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "UIImage+Extensions.swift"; path = "WordPressShareExtension/UIImage+Extensions.swift"; sourceTree = SOURCE_ROOT; }; B5FD4520199D0C9A00286FBB /* WordPress-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = "WordPress-Bridging-Header.h"; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objcpp; }; B5FDF9F220D842D2006D14E3 /* AztecNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AztecNavigationController.swift; sourceTree = ""; }; B5FF3BE61CAD881100C1D597 /* ImageCropOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCropOverlayView.swift; sourceTree = ""; }; - B60BB199C4D84FFF05C0F97E /* Pods-WordPressDraftActionExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressDraftActionExtension.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension.release.xcconfig"; sourceTree = ""; }; B7556D1D8CFA5CEAEAC481B9 /* Pods.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - B787A850C6163034630E7AF2 /* Pods-WordPressUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressUITests.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressUITests/Pods-WordPressUITests.release.xcconfig"; sourceTree = ""; }; + B8EED8FA87452C1FD113FD04 /* Pods-WordPressUITests.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressUITests.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressUITests/Pods-WordPressUITests.release-internal.xcconfig"; sourceTree = ""; }; B921F5DD9A1F257C792EC225 /* Pods_WordPressTest.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressTest.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - BBA98A42A5503D734AC9C936 /* Pods-WordPress.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPress.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPress/Pods-WordPress.release-internal.xcconfig"; sourceTree = ""; }; + BBA98A42A5503D734AC9C936 /* Pods-Apps-WordPress.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Apps-WordPress.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-Apps-WordPress/Pods-Apps-WordPress.release-internal.xcconfig"; sourceTree = ""; }; BE1071FB1BC75E7400906AFF /* WPStyleGuide+Blog.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Blog.swift"; sourceTree = ""; }; BE1071FE1BC75FFA00906AFF /* WPStyleGuide+BlogTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "WPStyleGuide+BlogTests.swift"; path = "ViewRelated/Blog/Style/WPStyleGuide+BlogTests.swift"; sourceTree = ""; }; BE13B3E41B2B58D800A4211D /* BlogListViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlogListViewController.h; sourceTree = ""; }; BE13B3E51B2B58D800A4211D /* BlogListViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogListViewController.m; sourceTree = ""; }; BE2B4E9A1FD66423007AE3E4 /* WelcomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeScreen.swift; sourceTree = ""; }; BE2B4E9E1FD664F5007AE3E4 /* BaseScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseScreen.swift; sourceTree = ""; }; - BE2B4EA11FD6654A007AE3E4 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; BE2B4EA31FD6659B007AE3E4 /* LoginEmailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginEmailScreen.swift; sourceTree = ""; }; BE6787F41FFF2886005D9F01 /* ShareModularViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareModularViewController.swift; sourceTree = ""; }; BE6DD3271FD6705200E55192 /* LoginPasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginPasswordScreen.swift; sourceTree = ""; }; @@ -3975,15 +8074,165 @@ BED4D8321FF11E3800A11345 /* LoginFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginFlow.swift; sourceTree = ""; }; BED4D8341FF1208400A11345 /* AztecEditorScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AztecEditorScreen.swift; sourceTree = ""; }; BED4D83A1FF13B8A00A11345 /* EditorPublishEpilogueScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorPublishEpilogueScreen.swift; sourceTree = ""; }; + C2988A406A3D5697C2984F3E /* Pods-WordPressStatsWidgets.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressStatsWidgets.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressStatsWidgets/Pods-WordPressStatsWidgets.release-internal.xcconfig"; sourceTree = ""; }; + C314543A262770BE005B216B /* BlogServiceAuthorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogServiceAuthorTests.swift; sourceTree = ""; }; + C31466CB2939950900D62FC7 /* MigrationLoadWordPressViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationLoadWordPressViewController.swift; sourceTree = ""; }; + C31852A029670F8100A78BE9 /* JetpackScanViewController+JetpackBannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JetpackScanViewController+JetpackBannerViewController.swift"; sourceTree = ""; }; + C31B6D5A28BFB6F300E64FEB /* SplashPrologueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashPrologueView.swift; sourceTree = ""; }; + C31B6D7728BFE8B100E64FEB /* EBGaramond-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "EBGaramond-Regular.ttf"; sourceTree = ""; }; + C31B6D7928BFEFD500E64FEB /* OFL - EBGaramond-Regular.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "OFL - EBGaramond-Regular.txt"; sourceTree = ""; }; + C3234F5327EBBACA004ADB29 /* SiteIntentVertical.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteIntentVertical.swift; sourceTree = ""; }; + C324D7A828C26F3F00310DEF /* SplashPrologueStyleGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashPrologueStyleGuide.swift; sourceTree = ""; }; + C32A6A2B2832BF02002E9394 /* SiteDesignCategoryThumbnailSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteDesignCategoryThumbnailSize.swift; sourceTree = ""; }; + C3302CC427EB67D0004229D3 /* IntentCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IntentCell.xib; sourceTree = ""; }; + C3302CC527EB67D0004229D3 /* IntentCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentCell.swift; sourceTree = ""; }; + C33A5ADB2935848F00961E3A /* MigrationAppDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationAppDetection.swift; sourceTree = ""; }; + C3439B5E27FE3A3C0058DA55 /* SiteCreationWizardLauncherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteCreationWizardLauncherTests.swift; sourceTree = ""; }; + C34E94B928EDF7D900D27A16 /* InfiniteScrollerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfiniteScrollerView.swift; sourceTree = ""; }; + C34E94BB28EDF80700D27A16 /* InfiniteScrollerViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfiniteScrollerViewDelegate.swift; sourceTree = ""; }; + C352870427FDD35C004E2E51 /* SiteNameStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteNameStep.swift; sourceTree = ""; }; + C35D4FF0280077F100DB90B5 /* SiteCreationStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteCreationStep.swift; sourceTree = ""; }; + C3643ACE28AC049D00FC5FD3 /* SharingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingViewController.swift; sourceTree = ""; }; + C373D6E628045281008F8C26 /* SiteIntentData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteIntentData.swift; sourceTree = ""; }; + C373D6E9280452F6008F8C26 /* SiteIntentDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteIntentDataTests.swift; sourceTree = ""; }; + C3835558288B02B00062E402 /* JetpackBannerWrapperViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBannerWrapperViewController.swift; sourceTree = ""; }; + C38C5D8027F61D2C002F517E /* MenuItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MenuItemTests.swift; path = Menus/MenuItemTests.swift; sourceTree = ""; }; + C395FB222821FE4400AE7C11 /* SiteDesignSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteDesignSection.swift; sourceTree = ""; }; + C395FB252821FE7B00AE7C11 /* RemoteSiteDesign+Thumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteSiteDesign+Thumbnail.swift"; sourceTree = ""; }; + C396C80A280F2401006FE7AC /* SiteDesignTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteDesignTests.swift; sourceTree = ""; }; + C39ABBAD294BE84000F6F278 /* BackupListViewController+JetpackBannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BackupListViewController+JetpackBannerViewController.swift"; sourceTree = ""; }; + C3A1166829807E3F00B0CB6E /* ReaderBlockUserAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderBlockUserAction.swift; sourceTree = ""; }; + C3AB4878292F114A001F7AF8 /* UIApplication+AppAvailability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+AppAvailability.swift"; sourceTree = ""; }; + C3ABE791263099F7009BD402 /* WordPress 121.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 121.xcdatamodel"; sourceTree = ""; }; + C3B554502965C32A00A04753 /* MenusViewController+JetpackBannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MenusViewController+JetpackBannerViewController.swift"; sourceTree = ""; }; + C3B5545229661F2C00A04753 /* ThemeBrowserViewController+JetpackBannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ThemeBrowserViewController+JetpackBannerViewController.swift"; sourceTree = ""; }; + C3BC86F529528997005D1A01 /* PeopleViewController+JetpackBannerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PeopleViewController+JetpackBannerViewController.swift"; sourceTree = ""; }; + C3C21EB828385EC8002296E2 /* RemoteSiteDesigns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteSiteDesigns.swift; sourceTree = ""; }; + C3C2F84528AC8BC700937E45 /* JetpackBannerScrollVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBannerScrollVisibilityTests.swift; sourceTree = ""; }; + C3C2F84728AC8EBF00937E45 /* JetpackBannerScrollVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBannerScrollVisibility.swift; sourceTree = ""; }; + C3C39B0626F50D3900B1238D /* WordPressSupportSourceTag+Editor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WordPressSupportSourceTag+Editor.swift"; sourceTree = ""; }; + C3C70C552835C5BB00DD2546 /* SiteDesignSectionLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteDesignSectionLoaderTests.swift; sourceTree = ""; }; + C3DA0EDF2807062600DA3250 /* SiteCreationNameTracksEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteCreationNameTracksEventTests.swift; sourceTree = ""; }; + C3DD4DCD28BE5D4D0046C68E /* SplashPrologueViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashPrologueViewController.swift; sourceTree = ""; }; + C3E2462826277B7700B99EA6 /* PostAuthorSelectorViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostAuthorSelectorViewController.swift; sourceTree = ""; }; + C3E42AAF27F4D30E00546706 /* MenuItemsViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemsViewControllerTests.swift; sourceTree = ""; }; + C3E77F88293A4EA10034AE5A /* MigrationLoadWordPressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationLoadWordPressViewModel.swift; sourceTree = ""; }; + C3FBF4E728AFEDF8003797DF /* JetpackBrandingAnalyticsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBrandingAnalyticsHelper.swift; sourceTree = ""; }; + C3FF78E728354A91008FA600 /* SiteDesignSectionLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteDesignSectionLoader.swift; sourceTree = ""; }; C52812131832E071008931FD /* WordPress 13.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 13.xcdatamodel"; sourceTree = ""; }; C533CF330E6D3ADA000C3DE8 /* CommentsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CommentsViewController.h; sourceTree = ""; }; C533CF340E6D3ADA000C3DE8 /* CommentsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CommentsViewController.m; sourceTree = ""; }; - C545E0A01811B9880020844C /* ContextManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ContextManager.h; sourceTree = ""; }; - C545E0A11811B9880020844C /* ContextManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ContextManager.m; sourceTree = ""; }; + C545E0A01811B9880020844C /* CoreDataStack.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CoreDataStack.h; sourceTree = ""; }; C56636E61868D0CE00226AAB /* StatsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = StatsViewController.h; sourceTree = ""; }; C56636E71868D0CE00226AAB /* StatsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; lineEnding = 0; path = StatsViewController.m; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objc; }; + C5E82422F47D9BF7E682262B /* Pods-JetpackDraftActionExtension.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackDraftActionExtension.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackDraftActionExtension/Pods-JetpackDraftActionExtension.release-alpha.xcconfig"; sourceTree = ""; }; + C700F9D0257FD63A0090938E /* JetpackScanViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = JetpackScanViewController.xib; sourceTree = ""; }; + C700F9ED257FD64E0090938E /* JetpackScanViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScanViewController.swift; sourceTree = ""; }; + C700FAB0258020DB0090938E /* JetpackScanThreatCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScanThreatCell.swift; sourceTree = ""; }; + C700FAB1258020DB0090938E /* JetpackScanThreatCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = JetpackScanThreatCell.xib; sourceTree = ""; }; + C7124E4C2638528F00929318 /* JetpackPrologueViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = JetpackPrologueViewController.xib; sourceTree = ""; }; + C7124E4D2638528F00929318 /* JetpackPrologueViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackPrologueViewController.swift; sourceTree = ""; }; + C7124E912638905B00929318 /* StarFieldView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StarFieldView.swift; sourceTree = ""; }; + C7192ECE25E8432D00C3020D /* ReaderTopicsCardCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderTopicsCardCell.xib; sourceTree = ""; }; + C71AF532281064DE00F9E99E /* OnboardingQuestionsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingQuestionsCoordinator.swift; sourceTree = ""; }; + C71BC73E25A652410023D789 /* JetpackScanStatusViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScanStatusViewModel.swift; sourceTree = ""; }; + C7234A392832BA240045C63F /* QRLoginCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginCoordinator.swift; sourceTree = ""; }; + C7234A402832C2BA0045C63F /* QRLoginScanningViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginScanningViewController.swift; sourceTree = ""; }; + C7234A412832C2BA0045C63F /* QRLoginScanningViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QRLoginScanningViewController.xib; sourceTree = ""; }; + C7234A4C2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginVerifyAuthorizationViewController.swift; sourceTree = ""; }; + C7234A4D2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QRLoginVerifyAuthorizationViewController.xib; sourceTree = ""; }; + C72A4F67264088E4009CA633 /* JetpackNotFoundErrorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackNotFoundErrorViewModel.swift; sourceTree = ""; }; + C72A4F7A26408943009CA633 /* JetpackNotWPErrorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackNotWPErrorViewModel.swift; sourceTree = ""; }; + C72A4F8D26408C73009CA633 /* JetpackNoSitesErrorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackNoSitesErrorViewModel.swift; sourceTree = ""; }; + C72A52CE2649B157009CA633 /* JetpackWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackWindowManager.swift; sourceTree = ""; }; + C737553D27C80DD500C6E9A1 /* String+CondenseWhitespace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+CondenseWhitespace.swift"; sourceTree = ""; }; + C73868C425C9F9820072532C /* JetpackScanThreatSectionGrouping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackScanThreatSectionGrouping.swift; sourceTree = ""; }; + C738CB0A28623CED001BE107 /* QRLoginCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginCoordinatorTests.swift; sourceTree = ""; }; + C738CB0C28623F07001BE107 /* QRLoginURLParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginURLParserTests.swift; sourceTree = ""; }; + C738CB0E28626466001BE107 /* QRLoginScanningCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginScanningCoordinatorTests.swift; sourceTree = ""; }; + C738CB1028626606001BE107 /* QRLoginVerifyCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginVerifyCoordinatorTests.swift; sourceTree = ""; }; + C743535527BD7144008C2644 /* AnimatedGifAttachmentViewProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedGifAttachmentViewProvider.swift; sourceTree = ""; }; + C768B5B225828A5D00556E75 /* JetpackScanStatusCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScanStatusCell.swift; sourceTree = ""; }; + C768B5B325828A5D00556E75 /* JetpackScanStatusCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = JetpackScanStatusCell.xib; sourceTree = ""; }; + C76F48DB25BA202600BFEC87 /* JetpackScanHistoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScanHistoryViewController.swift; sourceTree = ""; }; + C76F48ED25BA20EF00BFEC87 /* JetpackScanHistoryCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScanHistoryCoordinator.swift; sourceTree = ""; }; + C76F48FF25BA23B000BFEC87 /* JetpackScanHistoryViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = JetpackScanHistoryViewController.xib; sourceTree = ""; }; + C77FC90728009C7000726F00 /* OnboardingQuestionsPromptViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingQuestionsPromptViewController.swift; sourceTree = ""; }; + C77FC90828009C7000726F00 /* OnboardingQuestionsPromptViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = OnboardingQuestionsPromptViewController.xib; sourceTree = ""; }; + C77FC90D2800CAC100726F00 /* OnboardingEnableNotificationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingEnableNotificationsViewController.swift; sourceTree = ""; }; + C77FC90E2800CAC100726F00 /* OnboardingEnableNotificationsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = OnboardingEnableNotificationsViewController.xib; sourceTree = ""; }; + C78543D125B889CC006CEAFB /* JetpackScanThreatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScanThreatViewModel.swift; sourceTree = ""; }; + C789952425816F96001B7B43 /* JetpackScanCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScanCoordinator.swift; sourceTree = ""; }; + C7A09A4928401B7B003096ED /* QRLoginService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginService.swift; sourceTree = ""; }; + C7A09A4C28403A34003096ED /* QRLoginURLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginURLParser.swift; sourceTree = ""; }; + C7AEA9D1F1AC3F501B6DE0C8 /* Pods-JetpackShareExtension.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackShareExtension.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackShareExtension/Pods-JetpackShareExtension.release-alpha.xcconfig"; sourceTree = ""; }; + C7AFF873283C0ADC000E01DF /* UIApplication+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Helpers.swift"; sourceTree = ""; }; + C7AFF876283C2623000E01DF /* QRLoginScanningCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginScanningCoordinator.swift; sourceTree = ""; }; + C7AFF87B283D5CF4000E01DF /* QRLoginVerifyCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginVerifyCoordinator.swift; sourceTree = ""; }; + C7B7CC6F2812FDCE007B9807 /* MySiteViewController+OnboardingPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MySiteViewController+OnboardingPrompt.swift"; sourceTree = ""; }; + C7B7CC7228134347007B9807 /* OnboardingQuestionsPromptScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingQuestionsPromptScreen.swift; sourceTree = ""; }; + C7BB60152863609C00748FD9 /* QRLoginInternetConnectionChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginInternetConnectionChecker.swift; sourceTree = ""; }; + C7BB60182863AF9700748FD9 /* QRLoginProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginProtocols.swift; sourceTree = ""; }; + C7BB601B2863B3D600748FD9 /* QRLoginCameraPermissionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginCameraPermissionsHandler.swift; sourceTree = ""; }; + C7BB601E2863B9E800748FD9 /* QRLoginCameraSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRLoginCameraSession.swift; sourceTree = ""; }; + C7D30C642638B07A00A1695B /* JetpackPrologueStyleGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackPrologueStyleGuide.swift; sourceTree = ""; }; + C7E5F24D2799BD52009BC263 /* cool-blue-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cool-blue-icon-app-76x76.png"; sourceTree = ""; }; + C7E5F24E2799BD52009BC263 /* cool-blue-icon-app-60x60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cool-blue-icon-app-60x60@3x.png"; sourceTree = ""; }; + C7E5F24F2799BD52009BC263 /* cool-blue-icon-app-60x60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cool-blue-icon-app-60x60@2x.png"; sourceTree = ""; }; + C7E5F2502799BD52009BC263 /* cool-blue-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cool-blue-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + C7E5F2512799BD52009BC263 /* cool-blue-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cool-blue-icon-app-76x76@2x.png"; sourceTree = ""; }; + C7E5F2572799C2B0009BC263 /* blue-icon-app-76x76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-icon-app-76x76.png"; sourceTree = ""; }; + C7E5F2582799C2B0009BC263 /* blue-icon-app-76x76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-icon-app-76x76@2x.png"; sourceTree = ""; }; + C7E5F2592799C2B0009BC263 /* blue-icon-app-83.5x83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-icon-app-83.5x83.5@2x.png"; sourceTree = ""; }; + C7F1EB4425A4B845009D1AA2 /* WordPress 110.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 110.xcdatamodel"; sourceTree = ""; }; + C7F7ABD5261CED7A00CE547F /* JetpackAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackAuthenticationManager.swift; sourceTree = ""; }; + C7F7AC73261CF1F300CE547F /* JetpackLoginErrorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackLoginErrorViewController.swift; sourceTree = ""; }; + C7F7AC74261CF1F300CE547F /* JetpackLoginErrorViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = JetpackLoginErrorViewController.xib; sourceTree = ""; }; + C7F7ACBD261E4F0600CE547F /* JetpackErrorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackErrorViewModel.swift; sourceTree = ""; }; + C7F7BDBC26262A1B00CE547F /* AppDependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDependency.swift; sourceTree = ""; }; + C7F7BDCF26262A4C00CE547F /* AppDependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDependency.swift; sourceTree = ""; }; + C7F7BE0626262B9900CE547F /* AuthenticationHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationHandler.swift; sourceTree = ""; }; + C81CCD5E243AECA000A83E27 /* TenorClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorClient.swift; sourceTree = ""; }; + C81CCD5F243AECA000A83E27 /* TenorMediaObject.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorMediaObject.swift; sourceTree = ""; }; + C81CCD60243AECA100A83E27 /* TenorGIF.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorGIF.swift; sourceTree = ""; }; + C81CCD61243AECA100A83E27 /* TenorResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorResponse.swift; sourceTree = ""; }; + C81CCD62243AECA100A83E27 /* TenorGIFCollection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorGIFCollection.swift; sourceTree = ""; }; + C81CCD69243AEE1100A83E27 /* TenorAPIResponseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorAPIResponseTests.swift; sourceTree = ""; }; + C81CCD6B243AEFBF00A83E27 /* TenorReponseData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorReponseData.swift; sourceTree = ""; }; + C81CCD6D243AF09900A83E27 /* TenorReponseParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorReponseParser.swift; sourceTree = ""; }; + C81CCD71243BF7A500A83E27 /* TenorStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorStrings.swift; sourceTree = ""; }; + C81CCD72243BF7A500A83E27 /* TenorPageable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorPageable.swift; sourceTree = ""; }; + C81CCD73243BF7A500A83E27 /* TenorDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorDataSource.swift; sourceTree = ""; }; + C81CCD74243BF7A500A83E27 /* TenorMedia.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorMedia.swift; sourceTree = ""; }; + C81CCD75243BF7A500A83E27 /* TenorMediaGroup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorMediaGroup.swift; sourceTree = ""; }; + C81CCD76243BF7A600A83E27 /* TenorPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorPicker.swift; sourceTree = ""; }; + C81CCD77243BF7A600A83E27 /* TenorService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorService.swift; sourceTree = ""; }; + C81CCD78243BF7A600A83E27 /* TenorResultsPage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorResultsPage.swift; sourceTree = ""; }; + C81CCD79243BF7A600A83E27 /* TenorDataLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TenorDataLoader.swift; sourceTree = ""; }; + C81CCD7A243BF7A600A83E27 /* NoResultsTenorConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoResultsTenorConfiguration.swift; sourceTree = ""; }; + C81CCD85243C00E000A83E27 /* TenorPageableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorPageableTests.swift; sourceTree = ""; }; + C82B4C5ECF11C9FEE39CD9A0 /* Pods-WordPressShareExtension.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressShareExtension.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension.release-internal.xcconfig"; sourceTree = ""; }; + C856748E243EF177001A995E /* GutenbergTenorMediaPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergTenorMediaPicker.swift; sourceTree = ""; }; + C8567491243F3751001A995E /* tenor-search-response.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "tenor-search-response.json"; sourceTree = ""; }; + C8567493243F388F001A995E /* tenor-invalid-search-reponse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "tenor-invalid-search-reponse.json"; sourceTree = ""; }; + C8567495243F3D37001A995E /* TenorResultsPageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorResultsPageTests.swift; sourceTree = ""; }; + C8567497243F41CA001A995E /* MockTenorService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTenorService.swift; sourceTree = ""; }; + C8567499243F4292001A995E /* TenorMockDataHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorMockDataHelper.swift; sourceTree = ""; }; + C856749B243F462F001A995E /* TenorDataSouceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenorDataSouceTests.swift; sourceTree = ""; }; + C8FC2DE857126670AE377B5D /* Pods-WordPressScreenshotGeneration.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressScreenshotGeneration.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressScreenshotGeneration/Pods-WordPressScreenshotGeneration.debug.xcconfig"; sourceTree = ""; }; C9264D275F6288F66C33D2CE /* Pods-WordPressTest.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressTest.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressTest/Pods-WordPressTest.release-internal.xcconfig"; sourceTree = ""; }; - C9BDC428A69177C2CBF0C247 /* Pods-WordPressShareExtension.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressShareExtension.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension.release-internal.xcconfig"; sourceTree = ""; }; + C94C0B1A25DCFA0100F2F69B /* FilterableCategoriesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterableCategoriesViewController.swift; sourceTree = ""; }; + C957C20526DCC1770037628F /* LandInTheEditorHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandInTheEditorHelper.swift; sourceTree = ""; }; + C99B039B2602F3CB00CA71EB /* WordPress 117.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 117.xcdatamodel"; sourceTree = ""; }; + C99B08FB26081AD600CA71EB /* TemplatePreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplatePreviewViewController.swift; sourceTree = ""; }; + C9D7DDBF2613B84500104E95 /* WordPress 119.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 119.xcdatamodel"; sourceTree = ""; }; + C9F1D4B62706ED7C00BDF917 /* EditHomepageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditHomepageViewController.swift; sourceTree = ""; }; + C9F1D4B92706EEEB00BDF917 /* HomepageEditorNavigationBarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomepageEditorNavigationBarManager.swift; sourceTree = ""; }; + CB1DAFB7DE085F2FF0314622 /* Pods-WordPressShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressShareExtension.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension.debug.xcconfig"; sourceTree = ""; }; + CB1FD8D926E605CF00EDAF06 /* Extensions 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Extensions 4.xcdatamodel"; sourceTree = ""; }; + CB48172926E0D93D008C2D9B /* SharePostTypePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharePostTypePickerViewController.swift; sourceTree = ""; }; + CB72288DBD93471DA0AD878A /* Pods-WordPressUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressUITests.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressUITests/Pods-WordPressUITests.debug.xcconfig"; sourceTree = ""; }; + CBF6201226E8FB520061A1F8 /* RemotePost+ShareData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemotePost+ShareData.swift"; sourceTree = ""; }; CC19BE05223FECAC00CAB3E1 /* EditorPostSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorPostSettings.swift; sourceTree = ""; }; CC24E5F01577DBC300A6D5B5 /* AddressBook.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = AddressBook.framework; path = System/Library/Frameworks/AddressBook.framework; sourceTree = SDKROOT; }; CC24E5F21577DFF400A6D5B5 /* Twitter.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = Twitter.framework; path = System/Library/Frameworks/Twitter.framework; sourceTree = SDKROOT; }; @@ -4001,7 +8250,6 @@ CC8498D22241477F00DB490A /* TagsComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsComponent.swift; sourceTree = ""; }; CC8A5EAA22159FA6001B7874 /* WPUITestCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPUITestCredentials.swift; sourceTree = ""; }; CC94FC67221452A4002E5825 /* EditorNoticeComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorNoticeComponent.swift; sourceTree = ""; }; - CC94FC692214532D002E5825 /* FancyAlertComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FancyAlertComponent.swift; sourceTree = ""; }; CCA44DF422C4C4D6006E294F /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; CCCF53BC237B13760035E9CA /* WordPressUnitTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = WordPressUnitTests.xctestplan; sourceTree = ""; }; CCCF53BD237B13EA0035E9CA /* WordPressUITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = WordPressUITests.xctestplan; sourceTree = ""; }; @@ -4009,27 +8257,22 @@ CCE911BB221D8497007E1D4E /* LoginSiteAddressScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginSiteAddressScreen.swift; sourceTree = ""; }; CCE911BD221D85E4007E1D4E /* LoginUsernamePasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUsernamePasswordScreen.swift; sourceTree = ""; }; CCF6ACE6221ED73900D0C5BE /* LoginCheckMagicLinkScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginCheckMagicLinkScreen.swift; sourceTree = ""; }; - CDF46FFC7F78DB8FEB56E6F9 /* Pods-WordPressShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressShareExtension.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension.release.xcconfig"; sourceTree = ""; }; + CDA9AED50FDA27959A5CD1B2 /* Pods-WordPressDraftActionExtension.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressDraftActionExtension.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension.release-internal.xcconfig"; sourceTree = ""; }; + CE1392A4258AA9E700B0F945 /* WordPress 109.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 109.xcdatamodel"; sourceTree = ""; }; CE1CCB2C204DDD18000EE3AC /* MyProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileHeaderView.swift; sourceTree = ""; }; CE1CCB2E2050502B000EE3AC /* MyProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MyProfileHeaderView.xib; sourceTree = ""; }; + CE35F637257EB533007BC329 /* WordPress 106.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 106.xcdatamodel"; sourceTree = ""; }; CE39E17120CB117B00CABA05 /* RemoteBlog+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteBlog+Capabilities.swift"; sourceTree = ""; }; CE46018A21139E8300F242B6 /* FooterTextContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FooterTextContent.swift; sourceTree = ""; }; + CE5249687F020581B14F4172 /* Pods-JetpackDraftActionExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackDraftActionExtension.release.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackDraftActionExtension/Pods-JetpackDraftActionExtension.release.xcconfig"; sourceTree = ""; }; + CE907AFD25F97D2A007E7654 /* WordPress 115.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 115.xcdatamodel"; sourceTree = ""; }; CEBD3EA90FF1BA3B00C1396E /* Blog.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Blog.h; sourceTree = ""; }; CEBD3EAA0FF1BA3B00C1396E /* Blog.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Blog.m; sourceTree = ""; }; - D1216A69ECC61E7772AB8C41 /* Pods-WordPressThisWeekWidget.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressThisWeekWidget.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressThisWeekWidget/Pods-WordPressThisWeekWidget.debug.xcconfig"; sourceTree = ""; }; - D61CEAC1CB25AE65B26BDC68 /* Pods-WordPressShareExtension.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressShareExtension.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension.release-alpha.xcconfig"; sourceTree = ""; }; - D800D86320997BA100E7C7E5 /* ReaderMenuItemCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderMenuItemCreator.swift; sourceTree = ""; }; - D800D86520997C6E00E7C7E5 /* FollowingMenuItemCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingMenuItemCreator.swift; sourceTree = ""; }; - D800D86720997D5000E7C7E5 /* DiscoverMenuItemCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverMenuItemCreator.swift; sourceTree = ""; }; - D800D86920997E0C00E7C7E5 /* LikedMenuItemCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikedMenuItemCreator.swift; sourceTree = ""; }; - D800D86B20997EB400E7C7E5 /* OtherMenuItemCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherMenuItemCreator.swift; sourceTree = ""; }; - D800D86D2099857000E7C7E5 /* SearchMenuItemCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchMenuItemCreator.swift; sourceTree = ""; }; - D800D86F20998A7300E7C7E5 /* SavedForLaterMenuItemCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedForLaterMenuItemCreator.swift; sourceTree = ""; }; - D800D873209997DB00E7C7E5 /* FollowingMenuItemCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingMenuItemCreatorTests.swift; sourceTree = ""; }; - D800D87520999AE700E7C7E5 /* DiscoverMenuItemCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoverMenuItemCreatorTests.swift; sourceTree = ""; }; - D800D87720999B6D00E7C7E5 /* LikedMenuItemCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikedMenuItemCreatorTests.swift; sourceTree = ""; }; - D800D87920999C0500E7C7E5 /* OtherMenuItemCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherMenuItemCreatorTests.swift; sourceTree = ""; }; - D800D87B20999CA200E7C7E5 /* SearchMenuItemCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchMenuItemCreatorTests.swift; sourceTree = ""; }; + CECEEB542823164800A28ADE /* MediaCacheSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaCacheSettingsViewController.swift; sourceTree = ""; }; + D01FA8A28AD63D2600800134 /* Pods-JetpackNotificationServiceExtension.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackNotificationServiceExtension.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackNotificationServiceExtension/Pods-JetpackNotificationServiceExtension.release-internal.xcconfig"; sourceTree = ""; }; + D3B8D9C4DCD93C57C2B98CDC /* Pods_WordPressNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D42A30853435E728881904E8 /* Pods_JetpackIntents.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_JetpackIntents.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + D67306CD28F2440FF6B0065C /* Pods-WordPressIntents.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressIntents.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressIntents/Pods-WordPressIntents.debug.xcconfig"; sourceTree = ""; }; D8071630203DA23700B32FD9 /* Accessible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Accessible.swift; sourceTree = ""; }; D809E685203F0215001AA0DE /* ReaderPostCardCellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPostCardCellTests.swift; sourceTree = ""; }; D80BC79B207464D200614A59 /* MediaLibraryMediaPickingCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryMediaPickingCoordinator.swift; sourceTree = ""; }; @@ -4037,20 +8280,9 @@ D80BC79F2074722000614A59 /* CameraCaptureCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraCaptureCoordinator.swift; sourceTree = ""; }; D80BC7A12074739300614A59 /* MediaLibraryStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryStrings.swift; sourceTree = ""; }; D80BC7A3207487F200614A59 /* MediaLibraryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaLibraryPicker.swift; sourceTree = ""; }; - D80EE639203DEE1B0094C34C /* ReaderFollowedSitesStreamHeaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderFollowedSitesStreamHeaderTests.swift; sourceTree = ""; }; D81322B22050F9110067714D /* NotificationName+Names.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationName+Names.swift"; sourceTree = ""; }; D813D67E21AA8BBF0055CCA1 /* ShadowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowView.swift; sourceTree = ""; }; D8160441209C1B0F00ABAFFA /* ReaderSaveForLaterAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSaveForLaterAction.swift; sourceTree = ""; }; - D816B8BD2112CC000052CE4D /* NewsItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsItem.swift; sourceTree = ""; }; - D816B8BF2112CC930052CE4D /* NewsItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsItemTests.swift; sourceTree = ""; }; - D816B8C92112D2FD0052CE4D /* LocalNewsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalNewsServiceTests.swift; sourceTree = ""; }; - D816B8CC2112D4AA0052CE4D /* NewsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsManager.swift; sourceTree = ""; }; - D816B8CE2112D4F90052CE4D /* DefaultNewsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultNewsManager.swift; sourceTree = ""; }; - D816B8D02112D5960052CE4D /* DefaultNewsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultNewsManagerTests.swift; sourceTree = ""; }; - D816B8D22112D6E70052CE4D /* NewsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsCard.swift; sourceTree = ""; }; - D816B8D32112D6E70052CE4D /* NewsCard.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NewsCard.xib; sourceTree = ""; }; - D816B8D62112D75C0052CE4D /* NewsCardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsCardTests.swift; sourceTree = ""; }; - D816B8D82112D85F0052CE4D /* News.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = News.swift; sourceTree = ""; }; D816C1E820E0880400C4D82F /* NotificationAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAction.swift; sourceTree = ""; }; D816C1EB20E0887C00C4D82F /* ApproveComment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApproveComment.swift; sourceTree = ""; }; D816C1ED20E0892200C4D82F /* Follow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Follow.swift; sourceTree = ""; }; @@ -4061,9 +8293,6 @@ D817798F20ABF26800330998 /* ReaderCellConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCellConfiguration.swift; sourceTree = ""; }; D817799320ABFDB300330998 /* ReaderPostCellActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPostCellActions.swift; sourceTree = ""; }; D81879D820ABC647000CFA95 /* ReaderTableConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTableConfiguration.swift; sourceTree = ""; }; - D818FFD321915586000E5FEE /* VerticalsStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalsStep.swift; sourceTree = ""; }; - D818FFD52191566B000E5FEE /* VerticalsWizardContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalsWizardContent.swift; sourceTree = ""; }; - D818FFD62191566B000E5FEE /* VerticalsWizardContent.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = VerticalsWizardContent.xib; sourceTree = ""; }; D81C2F5320F85DB1002AE1F1 /* ApproveCommentActionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApproveCommentActionTests.swift; sourceTree = ""; }; D81C2F5720F86CEA002AE1F1 /* NetworkStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkStatus.swift; sourceTree = ""; }; D81C2F5920F86E94002AE1F1 /* LikeCommentActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikeCommentActionTests.swift; sourceTree = ""; }; @@ -4088,23 +8317,14 @@ D821C816210036D9002ED995 /* ActivityContentFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityContentFactoryTests.swift; sourceTree = ""; }; D821C818210037F8002ED995 /* activity-log-activity-content.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "activity-log-activity-content.json"; sourceTree = ""; }; D821C81A21003AE9002ED995 /* FormattableContentGroupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattableContentGroupTests.swift; sourceTree = ""; }; - D82247F82113EF5C00918CEB /* News.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = News.strings; sourceTree = ""; }; - D82247FA2113F50600918CEB /* News.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = News.strings; sourceTree = ""; }; D82253DB2199411F0014D0E2 /* SiteAddressService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteAddressService.swift; sourceTree = ""; }; D82253DD2199418B0014D0E2 /* WebAddressWizardContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAddressWizardContent.swift; sourceTree = ""; }; - D82253DE2199418B0014D0E2 /* WebAddressWizardContent.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = WebAddressWizardContent.xib; sourceTree = ""; }; - D82253E3219956540014D0E2 /* AddressCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCell.swift; sourceTree = ""; }; - D82253E4219956540014D0E2 /* AddressCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AddressCell.xib; sourceTree = ""; }; - D82253E9219A8A720014D0E2 /* SiteInformationStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteInformationStep.swift; sourceTree = ""; }; - D82253EB219A8A960014D0E2 /* SiteInformationWizardContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteInformationWizardContent.swift; sourceTree = ""; }; - D82253EC219A8A960014D0E2 /* SiteInformationWizardContent.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SiteInformationWizardContent.xib; sourceTree = ""; }; + D82253E3219956540014D0E2 /* AddressTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressTableViewCell.swift; sourceTree = ""; }; D8225407219AB0520014D0E2 /* SiteInformation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteInformation.swift; sourceTree = ""; }; D826D67E211D21C700A5D8FE /* NullMockUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullMockUserDefaults.swift; sourceTree = ""; }; - D826D681211D51E300A5D8FE /* ReaderNewsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderNewsCard.swift; sourceTree = ""; }; - D8281CF0212AB34C00D09098 /* NewsStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsStats.swift; sourceTree = ""; }; D829C33A21B12EFE00B09F12 /* UIView+Borders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Borders.swift"; sourceTree = ""; }; - D8380CA22192E77F00250609 /* VerticalsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalsCell.swift; sourceTree = ""; }; - D8380CA32192E77F00250609 /* VerticalsCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = VerticalsCell.xib; sourceTree = ""; }; + D82E087429EEB0B00098F500 /* DashboardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardTests.swift; sourceTree = ""; }; + D82E087729EEB7AF0098F500 /* DomainsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainsScreen.swift; sourceTree = ""; }; D83CA3A420842CAF0060E310 /* Pageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pageable.swift; sourceTree = ""; }; D83CA3A620842CD90060E310 /* ResultsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultsPage.swift; sourceTree = ""; }; D83CA3A820842D190060E310 /* StockPhotosPageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StockPhotosPageable.swift; sourceTree = ""; }; @@ -4139,12 +8359,10 @@ D86572162186C3600023A99C /* WizardDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardDelegate.swift; path = Classes/ViewRelated/Wizards/WizardDelegate.swift; sourceTree = SOURCE_ROOT; }; D865722C2186F96B0023A99C /* SiteSegmentsWizardContent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteSegmentsWizardContent.swift; sourceTree = ""; }; D865722D2186F96C0023A99C /* SiteSegmentsWizardContent.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = SiteSegmentsWizardContent.xib; sourceTree = ""; }; - D871F98B214235C9002849B0 /* NewsBadFormed.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = NewsBadFormed.strings; sourceTree = ""; }; D87A329520ABD60700F4726F /* ReaderTableContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTableContent.swift; sourceTree = ""; }; D88106F520C0C9A8001D2F00 /* ReaderSavedPostUndoCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSavedPostUndoCell.swift; sourceTree = ""; }; D88106F620C0C9A8001D2F00 /* ReaderSavedPostUndoCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderSavedPostUndoCell.xib; sourceTree = ""; }; D88106F920C0CFEE001D2F00 /* ReaderSaveForLaterRemovedPosts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSaveForLaterRemovedPosts.swift; sourceTree = ""; }; - D88106FB20C0D4A4001D2F00 /* ReaderSavedPostCellActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSavedPostCellActions.swift; sourceTree = ""; }; D88A6491208D7A0A008AE9BC /* MockStockPhotosService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStockPhotosService.swift; sourceTree = ""; }; D88A6493208D7AD0008AE9BC /* DefaultStockPhotosService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultStockPhotosService.swift; sourceTree = ""; }; D88A6495208D7B0B008AE9BC /* NullStockPhotosService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NullStockPhotosService.swift; sourceTree = ""; }; @@ -4167,12 +8385,8 @@ D8A3A5B4206A4C7800992576 /* StockPhotosPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StockPhotosPicker.swift; sourceTree = ""; }; D8A468DF2181C6450094B82F /* site-segment.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "site-segment.json"; sourceTree = ""; }; D8A468E421828D940094B82F /* SiteVerticalsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteVerticalsService.swift; sourceTree = ""; }; - D8AEA54821C21BEB00AB4DCB /* NewVerticalCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewVerticalCell.swift; sourceTree = ""; }; - D8AEA54921C21BEC00AB4DCB /* NewVerticalCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NewVerticalCell.xib; sourceTree = ""; }; - D8AEA54C21C2216300AB4DCB /* SiteVerticalPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteVerticalPresenter.swift; sourceTree = ""; }; D8B6BEB6203E11F2007C8A19 /* Bundle+LoadFromNib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+LoadFromNib.swift"; sourceTree = ""; }; D8B9B58E204F4EA1003C6042 /* NetworkAware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkAware.swift; sourceTree = ""; }; - D8B9B590204F658A003C6042 /* CommentsViewController+NetworkAware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentsViewController+NetworkAware.swift"; sourceTree = ""; }; D8B9B592204F6C93003C6042 /* CommentsViewController+Network.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "CommentsViewController+Network.h"; sourceTree = ""; }; D8BA274C20FDEA2E007A5C77 /* NotificationTextContentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTextContentTests.swift; sourceTree = ""; }; D8C31CC42188490000A33B35 /* SiteSegmentsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteSegmentsCell.swift; sourceTree = ""; }; @@ -4180,6 +8394,37 @@ D8CB561F2181A8CE00554EAE /* SiteSegmentsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteSegmentsService.swift; sourceTree = ""; }; D8EB1FD021900810002AE1C4 /* BlogListViewController+SiteCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogListViewController+SiteCreation.swift"; sourceTree = ""; }; DA67DF58196D8F6A005B5BC8 /* WordPress 20.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 20.xcdatamodel"; sourceTree = ""; }; + DB915AD54243A8AE0039B0C7 /* Pods-WordPressScreenshotGeneration.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressScreenshotGeneration.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressScreenshotGeneration/Pods-WordPressScreenshotGeneration.release.xcconfig"; sourceTree = ""; }; + DC06DFF827BD52BE00969974 /* WeeklyRoundupBackgroundTaskTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklyRoundupBackgroundTaskTests.swift; sourceTree = ""; }; + DC06DFFB27BD679700969974 /* BlogTitleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogTitleTests.swift; sourceTree = ""; }; + DC13DB7D293FD09F00E33561 /* StatsInsightsStoreTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsInsightsStoreTests.swift; sourceTree = ""; }; + DC2CA0842837B9070037E17E /* SiteStatsInsightsDetailsViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteStatsInsightsDetailsViewModelTests.swift; sourceTree = ""; }; + DC3B9B2B27739760003F7249 /* TimeZoneSelectorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeZoneSelectorViewModel.swift; sourceTree = ""; }; + DC3B9B2E27739887003F7249 /* TimeZoneSelectorViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeZoneSelectorViewModelTests.swift; sourceTree = ""; }; + DC76668126FD9AC8009254DD /* TimeZoneRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeZoneRow.swift; sourceTree = ""; }; + DC76668226FD9AC9009254DD /* TimeZoneTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeZoneTableViewCell.swift; sourceTree = ""; }; + DC772AEE282009B900664C02 /* InsightsLineChart.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InsightsLineChart.swift; sourceTree = ""; }; + DC772AEF282009BA00664C02 /* StatsLineChartConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsLineChartConfiguration.swift; sourceTree = ""; }; + DC772AF0282009BA00664C02 /* StatsLineChartView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsLineChartView.swift; sourceTree = ""; }; + DC772AFD28200A3600664C02 /* stats-visits-day-4.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "stats-visits-day-4.json"; sourceTree = ""; }; + DC772AFE28200A3600664C02 /* stats-visits-day-14.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "stats-visits-day-14.json"; sourceTree = ""; }; + DC772AFF28200A3600664C02 /* SiteStatsInsightViewModelTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteStatsInsightViewModelTests.swift; sourceTree = ""; }; + DC772B0028200A3600664C02 /* stats-visits-day-11.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "stats-visits-day-11.json"; sourceTree = ""; }; + DC772B0528201F5200664C02 /* ViewsVisitorsLineChartCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ViewsVisitorsLineChartCell.xib; sourceTree = ""; }; + DC772B0628201F5200664C02 /* ViewsVisitorsChartMarker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewsVisitorsChartMarker.swift; sourceTree = ""; }; + DC772B0728201F5300664C02 /* ViewsVisitorsLineChartCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewsVisitorsLineChartCell.swift; sourceTree = ""; }; + DC8F61F627032B3F0087AC5D /* TimeZoneFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeZoneFormatter.swift; sourceTree = ""; }; + DC8F61FB2703321F0087AC5D /* TimeZoneFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeZoneFormatterTests.swift; sourceTree = ""; }; + DC9AF768285DF8A300EA2A0D /* StatsFollowersChartViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsFollowersChartViewModel.swift; sourceTree = ""; }; + DCC662502810915D00962D0C /* BlogVideoLimitsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogVideoLimitsTests.swift; sourceTree = ""; }; + DCCDF75A283BEFEA00AA347E /* SiteStatsInsightsDetailsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStatsInsightsDetailsTableViewController.swift; sourceTree = ""; }; + DCCDF75D283BF02D00AA347E /* SiteStatsInsightsDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStatsInsightsDetailsViewModel.swift; sourceTree = ""; }; + DCF892C8282FA37100BB71E1 /* SiteStatsBaseTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStatsBaseTableViewController.swift; sourceTree = ""; }; + DCF892CB282FA3BB00BB71E1 /* SiteStatsImmuTableRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStatsImmuTableRows.swift; sourceTree = ""; }; + DCF892CF282FA42A00BB71E1 /* SiteStatsImmuTableRowsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteStatsImmuTableRowsTests.swift; sourceTree = ""; }; + DCF892D1282FA45500BB71E1 /* StatsMockDataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsMockDataLoader.swift; sourceTree = ""; }; + DCFC097229D3549C00277ECB /* DashboardDomainsCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardDomainsCardCell.swift; sourceTree = ""; }; + DCFC6A28292523D20062D65B /* SiteStatsPinnedItemStoreTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteStatsPinnedItemStoreTests.swift; sourceTree = ""; }; E100C6BA1741472F00AE48D8 /* WordPress-11-12.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = "WordPress-11-12.xcmappingmodel"; sourceTree = ""; }; E10290731F30615A00DAC588 /* Role.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Role.swift; sourceTree = ""; }; E102B78F1E714F24007928E8 /* RecentSitesService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentSitesService.swift; sourceTree = ""; }; @@ -4193,7 +8438,7 @@ E10F3DA01E5C2CE0008FAADA /* PostListFilterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostListFilterTests.swift; sourceTree = ""; }; E11000981CDB5F1E00E33887 /* KeychainTools.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainTools.swift; sourceTree = ""; }; E11330501A13BAA300D36D84 /* me-sites-with-jetpack.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "me-sites-with-jetpack.json"; sourceTree = ""; }; - E11450DE1C4E47E600A6BD0F /* NoticeAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoticeAnimator.swift; sourceTree = ""; }; + E11450DE1C4E47E600A6BD0F /* MessageAnimator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageAnimator.swift; sourceTree = ""; }; E114D798153D85A800984182 /* WPError.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPError.h; sourceTree = ""; }; E114D799153D85A800984182 /* WPError.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPError.m; sourceTree = ""; }; E115F2D116776A2900CCF00D /* WordPress 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 8.xcdatamodel"; sourceTree = ""; }; @@ -4226,35 +8471,6 @@ E131CB5716CACFB4004B0314 /* get-user-blogs_doesnt-have-blog.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "get-user-blogs_doesnt-have-blog.json"; sourceTree = ""; }; E133DB40137AE180003C0AF9 /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; E135965C1E7152D1006C6606 /* RecentSitesServiceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecentSitesServiceTests.swift; sourceTree = ""; }; - E135CD6E1E54BC0A0021E79F /* fr */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; - E135CD6F1E54BC0C0021E79F /* de */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - E135CD711E54BCA70021E79F /* bg */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; - E135CD721E54BD300021E79F /* cs */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; - E135CD731E54BD350021E79F /* cy */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = cy; path = cy.lproj/Localizable.strings; sourceTree = ""; }; - E135CD741E54BD410021E79F /* da */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; - E135CD751E54BD4C0021E79F /* en-AU */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "en-AU"; path = "en-AU.lproj/Localizable.strings"; sourceTree = ""; }; - E135CD761E54BD4F0021E79F /* en-CA */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "en-CA"; path = "en-CA.lproj/Localizable.strings"; sourceTree = ""; }; - E135CD771E54BD510021E79F /* en-GB */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; - E135CD781E54BD530021E79F /* pt-BR */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; - E135CD791E54BD590021E79F /* zh-Hans */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; - E135CD7A1E54BD5B0021E79F /* zh-Hant */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; - E135CD7B1E54BD620021E79F /* it */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; - E135CD7C1E54BD6D0021E79F /* ko */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; - E135CD7D1E54BD730021E79F /* nb */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; - E135CD7E1E54BD7B0021E79F /* nl */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; - E135CD7F1E54BD810021E79F /* pl */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; - E135CD801E54BD860021E79F /* pt */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; }; - E135CD811E54BD8A0021E79F /* sv */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; - E135CD821E54BD910021E79F /* ro */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; - E135CD831E54BD980021E79F /* ru */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; - E135CD841E54BD9D0021E79F /* sq */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = sq; path = sq.lproj/Localizable.strings; sourceTree = ""; }; - E135CD851E54BDA30021E79F /* th */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; - E135CD861E54BDAC0021E79F /* tr */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; - E135CD871E54BDE40021E79F /* he */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; - E135CD881E54BDE60021E79F /* hr */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; - E135CD891E54BDEB0021E79F /* hu */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; - E135CD8A1E54BDED0021E79F /* id */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; - E135CD8B1E54BDEF0021E79F /* is */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = is; path = is.lproj/Localizable.strings; sourceTree = ""; }; E136F3981F1F6FF800AD2DD3 /* WordPress 62.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 62.xcdatamodel"; sourceTree = ""; }; E137B1651F8B77D4006AC7FC /* WebNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebNavigationDelegate.swift; sourceTree = ""; }; E1389ADA1C59F7C200FB2466 /* PlanListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlanListViewController.swift; sourceTree = ""; }; @@ -4282,7 +8498,6 @@ E150275F1E03E51500B847E3 /* notes-action-push.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "notes-action-push.json"; sourceTree = ""; }; E15027601E03E51500B847E3 /* notes-action-unsupported.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "notes-action-unsupported.json"; sourceTree = ""; }; E15027641E03E54100B847E3 /* PinghubTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PinghubTests.swift; sourceTree = ""; }; - E150520B16CAC5C400D3DDDC /* BlogJetpackTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogJetpackTest.m; sourceTree = ""; }; E151C0C51F3889DF00710A83 /* PluginListRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PluginListRow.swift; sourceTree = ""; }; E151C0C71F388A2000710A83 /* PluginListViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PluginListViewModel.swift; sourceTree = ""; }; E1556CF0193F6FE900FC52EA /* CommentService.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CommentService.h; sourceTree = ""; }; @@ -4300,8 +8515,6 @@ E16273E01B2ACEB600088AF7 /* BlogToBlog32to33.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlogToBlog32to33.swift; sourceTree = ""; }; E16273E21B2AD89A00088AF7 /* MIGRATIONS.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = MIGRATIONS.md; path = ../MIGRATIONS.md; sourceTree = ""; }; E163AF9E1ED45B100035317E /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; - E163AF9F1ED45B110035317E /* sk */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; - E163AFA01ED45B110035317E /* sk */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = sk; path = sk.lproj/Localizable.strings; sourceTree = ""; }; E166FA1A1BB0656B00374B5B /* PeopleCellViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeopleCellViewModel.swift; sourceTree = ""; }; E167745A1377F24300EE44DD /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; E167745B1377F25500EE44DD /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; @@ -4326,13 +8539,11 @@ E1823E6B1E42231C00C19F53 /* UIEdgeInsets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIEdgeInsets.swift; sourceTree = ""; }; E185042E1EE6ABD9005C234C /* Restorer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Restorer.swift; sourceTree = ""; }; E185474D1DED8D8800D875D7 /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; - E18549D8230EED73003C620E /* BlogService+Deduplicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogService+Deduplicate.swift"; sourceTree = ""; }; E18549DA230FBFEF003C620E /* BlogServiceDeduplicationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogServiceDeduplicationTests.swift; sourceTree = ""; }; E1863F9A1355E0AB0031BBC8 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; }; E1874BFE161C5DBC0058BDC4 /* WordPress 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 7.xcdatamodel"; sourceTree = ""; }; E18D8AE21397C51A00000861 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; E18D8AE41397C54E00000861 /* nb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; - E1928B2D1F8369F100E076C8 /* WebViewAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewAuthenticatorTests.swift; sourceTree = ""; }; E192E78B22EF453C008D725D /* WordPress-87-88.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = "WordPress-87-88.xcmappingmodel"; sourceTree = ""; }; E1939C671B15B4D2001AFEF7 /* WordPress 30.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 30.xcdatamodel"; sourceTree = ""; }; E19853331755E461001CC6D5 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; @@ -4354,19 +8565,12 @@ E1AB5A061E0BF17500574B4E /* Array.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; E1AB5A081E0BF31E00574B4E /* ArrayTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArrayTests.swift; sourceTree = ""; }; E1AB5A391E0C464700574B4E /* DelayTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DelayTests.swift; sourceTree = ""; }; - E1AC8CB91E54B891006FD056 /* ar */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; E1ADE0EA20A9EF6200D6AADC /* PrivacySettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacySettingsViewController.swift; sourceTree = ""; }; E1AFA8C21E8E34230004A323 /* WordPressShare.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; name = WordPressShare.js; path = WordPressShareExtension/WordPressShare.js; sourceTree = SOURCE_ROOT; }; E1B23B071BFB3B370006559B /* MyProfileViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MyProfileViewController.swift; sourceTree = ""; }; - E1B34C0A1CCDFFCE00889709 /* gencredentials.rb */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.ruby; path = gencredentials.rb; sourceTree = ""; }; - E1B34C0B1CCDFFCE00889709 /* ApiCredentials.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ApiCredentials.h; sourceTree = ""; }; - E1B34C0C1CCDFFCE00889709 /* ApiCredentials.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ApiCredentials.m; sourceTree = ""; }; E1B62A7913AA61A100A6FCA4 /* WPWebViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPWebViewController.h; sourceTree = ""; }; E1B62A7A13AA61A100A6FCA4 /* WPWebViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPWebViewController.m; sourceTree = ""; }; E1B642121EFA5113001DC6D7 /* ModelTestHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelTestHelper.swift; sourceTree = ""; }; - E1B6A9CD1E54B6B2008FD47E /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; - E1B6AA571E54B83D008FD47E /* ja */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; - E1B6AA581E54B840008FD47E /* es */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; E1B84EFF1E02E94D00BF6434 /* PingHubManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PingHubManager.swift; sourceTree = ""; }; E1B912801BB00EFD003C25B9 /* People.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = People.storyboard; sourceTree = ""; }; E1B912821BB01047003C25B9 /* PeopleRoleBadgeLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeopleRoleBadgeLabel.swift; sourceTree = ""; }; @@ -4374,54 +8578,19 @@ E1B912881BB01288003C25B9 /* PeopleViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PeopleViewController.swift; sourceTree = ""; }; E1B9128A1BB0129C003C25B9 /* WPStyleGuide+People.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+People.swift"; sourceTree = ""; }; E1B921BB1C0ED5A3003EA3CB /* MediaSizeSliderCellTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaSizeSliderCellTest.swift; sourceTree = ""; }; - E1BB85971F82459800797050 /* WebViewAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewAuthenticator.swift; sourceTree = ""; }; E1BB92311FDAAFFA00F2D817 /* TextWithAccessoryButtonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextWithAccessoryButtonCell.swift; sourceTree = ""; }; E1BCFBC51C0626C5004BDADF /* WordPress 43.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 43.xcdatamodel"; sourceTree = ""; }; E1BEEC621C4E35A8000B4FA0 /* Animator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Animator.swift; sourceTree = ""; }; E1BEEC641C4E3978000B4FA0 /* PaddedLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaddedLabel.swift; sourceTree = ""; }; + E1C2260623901AAD0021D03C /* WordPressOrgRestApi+WordPress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WordPressOrgRestApi+WordPress.swift"; sourceTree = ""; }; E1C5457D1C6B962D001CEB0E /* MediaSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaSettings.swift; sourceTree = ""; }; E1C5457F1C6C79BB001CEB0E /* MediaSettingsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaSettingsTests.swift; sourceTree = ""; }; - E1C5B2121E54C28C00052319 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = Base; path = Base.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2141E54C37800052319 /* ar */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2151E54C37C00052319 /* bg */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2161E54C37F00052319 /* cs */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = cs; path = cs.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2171E54C38200052319 /* cy */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = cy; path = cy.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2181E54C3A200052319 /* ja */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2191E54C3A300052319 /* fr */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B21A1E54C3A500052319 /* de */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = de; path = de.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B21B1E54C3A700052319 /* es */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B21C1E54C3A900052319 /* it */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B21D1E54C3AB00052319 /* pt */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B21E1E54C3AC00052319 /* sv */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = sv; path = sv.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B21F1E54C3AE00052319 /* zh-Hans */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; - E1C5B2201E54C3AF00052319 /* nb */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = nb; path = nb.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2211E54C3B100052319 /* tr */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2221E54C3B200052319 /* id */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2231E54C3B300052319 /* zh-Hant */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; - E1C5B2241E54C3B500052319 /* hu */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = hu; path = hu.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2251E54C3B600052319 /* pl */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2261E54C3B700052319 /* ru */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2271E54C3B900052319 /* da */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2281E54C3BB00052319 /* th */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2291E54C3BC00052319 /* nl */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = nl; path = nl.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B22A1E54C3C400052319 /* pt-BR */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "pt-BR"; path = "pt-BR.lproj/Localizable.strings"; sourceTree = ""; }; - E1C5B22B1E54C3C500052319 /* hr */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = hr; path = hr.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B22C1E54C3C700052319 /* he */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = he; path = he.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B22D1E54C3C800052319 /* en-CA */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "en-CA"; path = "en-CA.lproj/Localizable.strings"; sourceTree = ""; }; - E1C5B22E1E54C3CA00052319 /* ro */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = ro; path = ro.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B22F1E54C3CC00052319 /* sq */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = sq; path = sq.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2301E54C3CD00052319 /* is */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = is; path = is.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2311E54C3CF00052319 /* en-AU */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "en-AU"; path = "en-AU.lproj/Localizable.strings"; sourceTree = ""; }; - E1C5B2321E54C3D000052319 /* ko */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; - E1C5B2331E54C3E500052319 /* en-GB */ = {isa = PBXFileReference; explicitFileType = file.bplist; name = "en-GB"; path = "en-GB.lproj/Localizable.strings"; sourceTree = ""; }; E1C807471696F72E00E545A6 /* WordPress 9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 9.xcdatamodel"; sourceTree = ""; }; E1C9AA501C10419200732665 /* Math.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = Math.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; E1C9AA551C10427100732665 /* MathTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MathTest.swift; sourceTree = ""; }; E1CA0A6B1FA73053004C4BBE /* PluginStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginStore.swift; sourceTree = ""; }; E1CB6DA2200F376400945457 /* TimeZoneStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeZoneStore.swift; sourceTree = ""; }; - E1CB6DA6200F661900945457 /* TimeZoneSelectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeZoneSelectorViewController.swift; sourceTree = ""; }; E1CE41641E8D101A000CF5A4 /* ShareExtractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ShareExtractor.swift; path = WordPressShareExtension/ShareExtractor.swift; sourceTree = SOURCE_ROOT; }; - E1CECE041E6F01CE009C6695 /* PostPreviewGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostPreviewGenerator.swift; sourceTree = ""; }; E1CFC1561E0AC8FF001DF9E9 /* Pattern.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Pattern.swift; sourceTree = ""; }; E1D0D81416D3B86800E33F4C /* SafariActivity.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SafariActivity.h; sourceTree = ""; }; E1D0D81516D3B86800E33F4C /* SafariActivity.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SafariActivity.m; sourceTree = ""; }; @@ -4457,19 +8626,14 @@ E1F5A1BA1771C90A00E0495F /* WPTableImageSource.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPTableImageSource.h; sourceTree = ""; }; E1F5A1BB1771C90A00E0495F /* WPTableImageSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPTableImageSource.m; sourceTree = ""; }; E1FD45DF1C030B3800750F4C /* AccountSettingsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountSettingsService.swift; sourceTree = ""; }; - E1FD527F8A6BBCC512ECAAC9 /* Pods-WordPressNotificationContentExtension.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressNotificationContentExtension.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressNotificationContentExtension/Pods-WordPressNotificationContentExtension.release-alpha.xcconfig"; sourceTree = ""; }; E240859A183D82AE002EB0EF /* WPAnimatedBox.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPAnimatedBox.h; sourceTree = ""; }; E240859B183D82AE002EB0EF /* WPAnimatedBox.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPAnimatedBox.m; sourceTree = ""; }; - E2A297CCB53FCF6851D79331 /* Pods_WordPressNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E2AA87A318523E5300886693 /* UIView+Subviews.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIView+Subviews.h"; sourceTree = ""; }; E2AA87A418523E5300886693 /* UIView+Subviews.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIView+Subviews.m"; sourceTree = ""; }; E2E7EB44185FB140004F5E72 /* WPBlogSelectorButton.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPBlogSelectorButton.h; sourceTree = ""; }; E2E7EB45185FB140004F5E72 /* WPBlogSelectorButton.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = WPBlogSelectorButton.m; sourceTree = ""; }; - E5B3F5F559826C230DB0DCDD /* Pods-WordPressAllTimeWidget.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressAllTimeWidget.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressAllTimeWidget/Pods-WordPressAllTimeWidget.release-alpha.xcconfig"; sourceTree = ""; }; E603C76F1BC94AED00AD49D7 /* WordPress-37-38.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = "WordPress-37-38.xcmappingmodel"; sourceTree = ""; }; E60BD230230A3DD400727E82 /* KeyringAccountHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyringAccountHelper.swift; sourceTree = ""; }; - E60C2ED41DE5048200488630 /* ReaderCommentCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ReaderCommentCell.xib; sourceTree = ""; }; - E60C2ED61DE5075100488630 /* ReaderCommentCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderCommentCell.swift; sourceTree = ""; }; E61084B91B9B47BA008050C5 /* ReaderAbstractTopic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAbstractTopic.swift; sourceTree = ""; }; E61084BA1B9B47BA008050C5 /* ReaderDefaultTopic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderDefaultTopic.swift; sourceTree = ""; }; E61084BB1B9B47BA008050C5 /* ReaderListTopic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderListTopic.swift; sourceTree = ""; }; @@ -4488,6 +8652,8 @@ E62AFB671DC8E593007484FC /* WPRichTextFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = WPRichTextFormatter.swift; path = WPRichText/WPRichTextFormatter.swift; sourceTree = ""; }; E62AFB681DC8E593007484FC /* WPTextAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = WPTextAttachment.swift; path = WPRichText/WPTextAttachment.swift; sourceTree = ""; }; E62AFB691DC8E593007484FC /* WPTextAttachmentManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = WPTextAttachmentManager.swift; path = WPRichText/WPTextAttachmentManager.swift; sourceTree = ""; }; + E62CE58D26B1D14200C9D147 /* AccountService+Cookies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccountService+Cookies.swift"; sourceTree = ""; }; + E62D4A2425E7FE6600B99550 /* WordPress 114.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 114.xcdatamodel"; sourceTree = ""; }; E6311C401EC9FF4A00122529 /* UsersService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UsersService.swift; sourceTree = ""; }; E6311C421ECA017E00122529 /* UserProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; E6374DBD1C444D8B00F24720 /* PublicizeConnection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PublicizeConnection.swift; sourceTree = ""; }; @@ -4505,6 +8671,7 @@ E6431DE41C4E892900FD8D90 /* SharingViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SharingViewController.m; sourceTree = ""; }; E64384821C628FCC0052ADB5 /* WPStyleGuide+Sharing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Sharing.swift"; sourceTree = ""; }; E6452B7C1FEAF4DC00D21BF1 /* WordPress 69.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 69.xcdatamodel"; sourceTree = ""; }; + E64595EF256B5D7800F7F90C /* CommentAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentAnalytics.swift; sourceTree = ""; }; E64ECA4C1CE62041000188A0 /* ReaderSearchViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderSearchViewController.swift; sourceTree = ""; }; E65219F81B8D10C2000B1217 /* ReaderBlockedSiteCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ReaderBlockedSiteCell.xib; sourceTree = ""; }; E65219FA1B8D10DA000B1217 /* ReaderBlockedSiteCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderBlockedSiteCell.swift; sourceTree = ""; }; @@ -4531,19 +8698,20 @@ E684383D221F535900752258 /* LoadMoreCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreCounter.swift; sourceTree = ""; }; E684383F221F5A2200752258 /* PostListExcessiveLoadMoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostListExcessiveLoadMoreTests.swift; sourceTree = ""; }; E68580F51E0D91470090EE63 /* WPHorizontalRuleAttachment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WPHorizontalRuleAttachment.swift; sourceTree = ""; }; + E687A0AE249AC02400C8BA18 /* WordPress 97.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 97.xcdatamodel"; sourceTree = ""; }; + E690F6EC25E04EAA0015A777 /* WordPress 113.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 113.xcdatamodel"; sourceTree = ""; }; + E690F6ED25E05D170015A777 /* InviteLinks+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "InviteLinks+CoreDataClass.swift"; sourceTree = ""; }; + E690F6EE25E05D180015A777 /* InviteLinks+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "InviteLinks+CoreDataProperties.swift"; sourceTree = ""; }; E69551F51B8B6AE200CB8E4F /* ReaderStreamViewController+Helper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ReaderStreamViewController+Helper.swift"; sourceTree = ""; }; E699E530210BB719008ED8A7 /* WordPress 77.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 77.xcdatamodel"; sourceTree = ""; }; E69BA1961BB5D7D300078740 /* WPStyleGuide+ReadableMargins.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "WPStyleGuide+ReadableMargins.h"; sourceTree = ""; }; E69BA1971BB5D7D300078740 /* WPStyleGuide+ReadableMargins.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "WPStyleGuide+ReadableMargins.m"; sourceTree = ""; }; - E69EF9D21BFA539F00ED0554 /* ReaderDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderDetailViewController.swift; sourceTree = ""; }; - E6A2158D1D10627500DE5270 /* ReaderMenuViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderMenuViewModel.swift; sourceTree = ""; }; E6A2158F1D1065F200DE5270 /* AbstractPostTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; lineEnding = 0; path = AbstractPostTest.swift; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.swift; }; E6A3384A1BB08E3F00371587 /* ReaderGapMarker.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ReaderGapMarker.h; sourceTree = ""; }; E6A3384B1BB08E3F00371587 /* ReaderGapMarker.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ReaderGapMarker.m; sourceTree = ""; }; E6A3384D1BB0A50900371587 /* ReaderGapMarkerCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ReaderGapMarkerCell.xib; sourceTree = ""; }; E6A3384F1BB0A70F00371587 /* ReaderGapMarkerCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderGapMarkerCell.swift; sourceTree = ""; }; E6B2101A1C444CB50063E271 /* WordPress 44.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 44.xcdatamodel"; sourceTree = ""; }; - E6B42CBE1D9DA6270043E228 /* Noticons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Noticons.ttf; sourceTree = ""; }; E6B9B8A91B94E1FE0001B92F /* ReaderPostTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ReaderPostTest.m; sourceTree = ""; }; E6B9B8AE1B94FA1C0001B92F /* ReaderStreamViewControllerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderStreamViewControllerTests.swift; sourceTree = ""; }; E6BDEA721CE4824300682885 /* ReaderSearchTopic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderSearchTopic.swift; sourceTree = ""; }; @@ -4561,31 +8729,53 @@ E6D3B1421D1C702600008D4B /* ReaderFollowedSitesViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderFollowedSitesViewController.swift; sourceTree = ""; }; E6D3E8481BEBD871002692E8 /* ReaderCrossPostCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderCrossPostCell.swift; sourceTree = ""; }; E6D3E84A1BEBD888002692E8 /* ReaderCrossPostCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ReaderCrossPostCell.xib; sourceTree = ""; }; + E6D6A12F2683ABE6004C24A7 /* ReaderSubscribeCommentsAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSubscribeCommentsAction.swift; sourceTree = ""; }; E6DAABDC1CF632EC0069D933 /* ReaderSearchSuggestionsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderSearchSuggestionsViewController.swift; sourceTree = ""; }; E6DE44661B90D251000FA7EF /* ReaderHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderHelpers.swift; sourceTree = ""; }; E6E27D611C6144DB0063F821 /* SharingButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SharingButton.swift; sourceTree = ""; }; - E6E57CD41D0F08B200C22E3E /* ReaderMenuViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderMenuViewController.swift; sourceTree = ""; }; - E6ED09081D46AD29003283C4 /* ReaderFollowedSitesStreamHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderFollowedSitesStreamHeader.swift; sourceTree = ""; }; - E6ED090A1D46AFAF003283C4 /* ReaderFollowedSitesStreamHeader.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ReaderFollowedSitesStreamHeader.xib; sourceTree = ""; }; - E6F058011C1A122B008000F9 /* ReaderPostMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderPostMenu.swift; sourceTree = ""; }; + E6EE8807266ABE9F009BC219 /* WordPress 125.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 125.xcdatamodel"; sourceTree = ""; }; E6F2787921BC179B008B4DB5 /* WordPress 86.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 86.xcdatamodel"; sourceTree = ""; }; E6F2787A21BC1A48008B4DB5 /* Plan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Plan.swift; sourceTree = ""; }; E6F2787B21BC1A48008B4DB5 /* PlanGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PlanGroup.swift; path = Classes/Models/PlanGroup.swift; sourceTree = SOURCE_ROOT; }; E6F2787E21BC1A49008B4DB5 /* PlanFeature.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlanFeature.swift; sourceTree = ""; }; E6FACB1D1EC675E300284AC7 /* GravatarProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GravatarProfile.swift; sourceTree = ""; }; - E9BAA39DBCC9B24D1C785074 /* Pods-WordPressScreenshotGeneration.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressScreenshotGeneration.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressScreenshotGeneration/Pods-WordPressScreenshotGeneration.release-internal.xcconfig"; sourceTree = ""; }; + E850CD4B77CF21E683104B5A /* Pods-WordPressStatsWidgets.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressStatsWidgets.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressStatsWidgets/Pods-WordPressStatsWidgets.debug.xcconfig"; sourceTree = ""; }; + EA14534229AD874C001F3143 /* JetpackUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JetpackUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + EA14534629AEF479001F3143 /* JetpackUITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = JetpackUITests.xctestplan; sourceTree = ""; }; + EA78189327596B2F00554DFA /* ContactUsScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactUsScreen.swift; sourceTree = ""; }; + EAB10E3F27487F5D000DA4C1 /* ReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTests.swift; sourceTree = ""; }; + EAD08D0D29D45E23001A72F9 /* CommentsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentsScreen.swift; sourceTree = ""; }; + EAD2BF4127594DAB00A847BB /* StatsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatsTests.swift; sourceTree = ""; }; EEF80689364FA9CAE10405E8 /* Pods-WordPressNotificationServiceExtension.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressNotificationServiceExtension.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressNotificationServiceExtension/Pods-WordPressNotificationServiceExtension.release-alpha.xcconfig"; sourceTree = ""; }; EF379F0A70B6AC45330EE287 /* Pods-WordPressTest.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressTest.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressTest/Pods-WordPressTest.release.xcconfig"; sourceTree = ""; }; + F10465132554260600655194 /* BindableTapGestureRecognizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BindableTapGestureRecognizer.swift; sourceTree = ""; }; + F10D634D26F0B66E00E46CC7 /* WordPress 133.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 133.xcdatamodel"; sourceTree = ""; }; + F10D634E26F0B78E00E46CC7 /* Blog+Organization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+Organization.swift"; sourceTree = ""; }; F10E654F21B06139007AB2EE /* GutenbergViewController+MoreActions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "GutenbergViewController+MoreActions.swift"; sourceTree = ""; }; F110239A2318479000C4E84A /* Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Media.swift; sourceTree = ""; }; F11023A0231863CE00C4E84A /* MediaServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaServiceTests.swift; sourceTree = ""; }; F11023A223186BCA00C4E84A /* MediaBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaBuilder.swift; sourceTree = ""; }; + F1112AB1255C2D4600F1F746 /* BlogDetailHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlogDetailHeaderView.swift; sourceTree = ""; }; + F111B87726580FCE00057942 /* BloggingRemindersStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersStore.swift; sourceTree = ""; }; + F111B88B2658102700057942 /* Combine.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Combine.framework; path = System/Library/Frameworks/Combine.framework; sourceTree = SDKROOT; }; F115308021B17E65002F1D65 /* EditorFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditorFactory.swift; sourceTree = ""; }; + F117B11F265C53AB00D2CAA9 /* BloggingRemindersScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersScheduler.swift; sourceTree = ""; }; F117E5841F7AA8BF003D9ACB /* WordPress 67.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 67.xcdatamodel"; sourceTree = ""; }; + F11C9F73243B3C3E00921DDC /* MediaHost+Blog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaHost+Blog.swift"; sourceTree = ""; }; + F11C9F75243B3C5E00921DDC /* MediaHost+AbstractPost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaHost+AbstractPost.swift"; sourceTree = ""; }; + F11C9F77243B3C9600921DDC /* MediaHost+ReaderPostContentProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaHost+ReaderPostContentProvider.swift"; sourceTree = ""; }; F126FDFD20A33BDB0010EB6E /* VideoUploadProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoUploadProcessor.swift; sourceTree = ""; }; F126FDFE20A33BDB0010EB6E /* ImgUploadProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImgUploadProcessor.swift; sourceTree = ""; }; F126FDFF20A33BDB0010EB6E /* DocumentUploadProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DocumentUploadProcessor.swift; sourceTree = ""; }; + F127FFD724213B5600B9D41A /* atomic-get-authentication-cookie-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "atomic-get-authentication-cookie-success.json"; sourceTree = ""; }; F12E500223C7C5330068CB5E /* WKWebView+UserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKWebView+UserAgent.swift"; sourceTree = ""; }; + F12FA5D82428FA8F0054DA21 /* AuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationService.swift; sourceTree = ""; }; + F13E7FDD2566B0AB007D420A /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; + F1450CF22437DA3E00A28BFE /* MediaRequestAuthenticator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaRequestAuthenticator.swift; sourceTree = ""; }; + F1450CF42437E1A600A28BFE /* MediaHost.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaHost.swift; sourceTree = ""; }; + F1450CF62437E8F800A28BFE /* MediaHostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaHostTests.swift; sourceTree = ""; }; + F1450CF82437EEBB00A28BFE /* MediaRequestAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaRequestAuthenticatorTests.swift; sourceTree = ""; }; + F1482CDF2575BDA4007E4DD6 /* SitesDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitesDataProvider.swift; sourceTree = ""; }; F14B5F70208E648200439554 /* WordPress.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = WordPress.debug.xcconfig; sourceTree = ""; }; F14B5F71208E648200439554 /* WordPress.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = WordPress.release.xcconfig; sourceTree = ""; }; F14B5F72208E648300439554 /* WordPress.internal.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = WordPress.internal.xcconfig; sourceTree = ""; }; @@ -4593,6 +8783,12 @@ F14B5F74208E64F900439554 /* Version.public.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.public.xcconfig; sourceTree = ""; }; F14B5F75208E64F900439554 /* Version.internal.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.internal.xcconfig; sourceTree = ""; }; F14E844C2317252200D0C63E /* WordPress 90.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 90.xcdatamodel"; sourceTree = ""; }; + F151EC822665271200AEA89E /* BloggingRemindersSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersSchedulerTests.swift; sourceTree = ""; }; + F15272FC243B27BC00C8DC7A /* AbstractPost+Local.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AbstractPost+Local.swift"; sourceTree = ""; }; + F15272FE243B28B600C8DC7A /* RequestAuthenticator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestAuthenticator.swift; sourceTree = ""; }; + F1527300243B290D00C8DC7A /* AbstractPost+Autosave.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AbstractPost+Autosave.swift"; sourceTree = ""; }; + F15D1FB9265C41A900854EE5 /* BloggingRemindersStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersStoreTests.swift; sourceTree = ""; }; + F163541526DE2ECE008B625B /* NotificationEventTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationEventTracker.swift; sourceTree = ""; }; F1655B4722A6C2FA00227BFB /* Routes+Mbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Routes+Mbar.swift"; sourceTree = ""; }; F16601C323E9E783007950AE /* SharingAuthorizationWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingAuthorizationWebViewController.swift; sourceTree = ""; }; F16C35D523F33DE400C81331 /* PageAutoUploadMessageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PageAutoUploadMessageProvider.swift; path = ../Post/PageAutoUploadMessageProvider.swift; sourceTree = ""; }; @@ -4600,61 +8796,407 @@ F16C35DB23F3F78E00C81331 /* AutoUploadMessageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoUploadMessageProvider.swift; sourceTree = ""; }; F17A2A1D23BFBD72001E96AC /* UIView+ExistingConstraints.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+ExistingConstraints.swift"; sourceTree = ""; }; F17A2A1F23BFBD84001E96AC /* UIView+ExistingConstraints.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+ExistingConstraints.swift"; sourceTree = ""; }; + F181EDE426B2AC7200C61241 /* BackgroundTasksCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTasksCoordinator.swift; sourceTree = ""; }; + F1863715253E49B8003D4BEF /* AddSiteAlertFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddSiteAlertFactory.swift; sourceTree = ""; }; F18B43771F849F580089B817 /* PostAttachmentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PostAttachmentTests.swift; path = Posts/PostAttachmentTests.swift; sourceTree = ""; }; + F18CB8952642E58700B90794 /* FixedSizeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FixedSizeImageView.swift; sourceTree = ""; }; + F19153BC2549ED0800629EC4 /* UITextField+WorkaroundContinueIssue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextField+WorkaroundContinueIssue.swift"; sourceTree = ""; }; + F198FF6E256D4914001266EB /* WordPressIntents.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WordPressIntents.entitlements; sourceTree = ""; }; + F198FF7F256D498A001266EB /* WordPressIntentsRelease-Internal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "WordPressIntentsRelease-Internal.entitlements"; sourceTree = ""; }; + F198FFB1256D4AB2001266EB /* WordPressIntentsRelease-Alpha.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "WordPressIntentsRelease-Alpha.entitlements"; sourceTree = ""; }; + F1A38F202678C4DA00849843 /* BloggingRemindersFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersFlow.swift; sourceTree = ""; }; + F1A75B9A2732EF3700784A70 /* AboutScreenTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutScreenTracker.swift; sourceTree = ""; }; + F1ACDF4A256D6B2B0005AE9B /* WordPressIntents-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WordPressIntents-Bridging-Header.h"; sourceTree = ""; }; + F1ADCAF6241FEF0C00F150D2 /* AtomicAuthenticationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtomicAuthenticationService.swift; sourceTree = ""; }; F1B1E7A224098FA100549E2A /* BlogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogTests.swift; sourceTree = ""; }; + F1BB660B274E704D00A319BE /* LikeUserHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikeUserHelperTests.swift; sourceTree = ""; }; + F1BBA95E243BEFC500E9E5E6 /* WordPress 95.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 95.xcdatamodel"; sourceTree = ""; }; + F1BC842D27035A1800C39993 /* BlogService+Domains.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogService+Domains.swift"; sourceTree = ""; }; + F1C197A52670DDB100DE1FF7 /* BloggingRemindersTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingRemindersTracker.swift; sourceTree = ""; }; + F1C740BE26B18E42005D0809 /* StoreSandboxSecretScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreSandboxSecretScreen.swift; sourceTree = ""; }; F1D690141F828FF000200E30 /* BuildConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildConfiguration.swift; sourceTree = ""; }; F1D690151F828FF000200E30 /* FeatureFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlag.swift; sourceTree = ""; }; + F1D8C6E826BA94DF002E3323 /* WordPressBackgroundTaskEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressBackgroundTaskEventHandler.swift; sourceTree = ""; }; + F1D8C6EA26BABE3E002E3323 /* WeeklyRoundupDebugScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklyRoundupDebugScreen.swift; sourceTree = ""; }; + F1D8C6EF26C17A6C002E3323 /* WeeklyRoundupBackgroundTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklyRoundupBackgroundTask.swift; sourceTree = ""; }; F1DB8D282288C14400906E2F /* Uploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Uploader.swift; sourceTree = ""; }; F1DB8D2A2288C24500906E2F /* UploadsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadsManager.swift; sourceTree = ""; }; - F262DFCA1F94418CE76D1D78 /* Pods-WordPressNotificationContentExtension.release-internal.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressNotificationContentExtension.release-internal.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressNotificationContentExtension/Pods-WordPressNotificationContentExtension.release-internal.xcconfig"; sourceTree = ""; }; - F373612EEEEF10E500093FF3 /* Pods-WordPress.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPress.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPress/Pods-WordPress.release-alpha.xcconfig"; sourceTree = ""; }; - F47DB4A8EC2E6844E213A3FA /* Pods_WordPressShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F1E3536A25B9F74C00992E3A /* WindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowManager.swift; sourceTree = ""; }; + F1E72EB9267790100066FF91 /* UIViewController+Dismissal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIViewController+Dismissal.swift"; sourceTree = ""; }; + F1F083F5241FFE930056D3B1 /* AtomicAuthenticationServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtomicAuthenticationServiceTests.swift; sourceTree = ""; }; + F1F163BE25658B4D003DC13B /* WordPressIntents.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WordPressIntents.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + F1F163C025658B4D003DC13B /* IntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentHandler.swift; sourceTree = ""; }; + F1F163C225658B4D003DC13B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F1F163C825658B4D003DC13B /* IntentsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IntentsUI.framework; path = System/Library/Frameworks/IntentsUI.framework; sourceTree = SDKROOT; }; + F373612EEEEF10E500093FF3 /* Pods-Apps-WordPress.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Apps-WordPress.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-Apps-WordPress/Pods-Apps-WordPress.release-alpha.xcconfig"; sourceTree = ""; }; + F40CC35C2954991C00D75A95 /* WordPress 146.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 146.xcdatamodel"; sourceTree = ""; }; + F41BDD72290BBDCA00B7F2B0 /* MigrationActionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationActionsView.swift; sourceTree = ""; }; + F41BDD782910AFCA00B7F2B0 /* MigrationFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationFlowCoordinator.swift; sourceTree = ""; }; + F41BDD7A29114E2400B7F2B0 /* MigrationStep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationStep.swift; sourceTree = ""; }; + F41E32FD287B47A500F89082 /* SuggestionsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionsListViewModel.swift; sourceTree = ""; }; + F41E3300287B5FE500F89082 /* SuggestionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionViewModel.swift; sourceTree = ""; }; + F41E4E8B28F18B7B001880C6 /* AppIconListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconListViewModelTests.swift; sourceTree = ""; }; + F41E4E9028F20801001880C6 /* white-on-pink-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-pink-icon-app-76.png"; sourceTree = ""; }; + F41E4E9128F20801001880C6 /* white-on-pink-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-pink-icon-app-83.5@2x.png"; sourceTree = ""; }; + F41E4E9228F20801001880C6 /* white-on-pink-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-pink-icon-app-76@2x.png"; sourceTree = ""; }; + F41E4E9328F20802001880C6 /* white-on-pink-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-pink-icon-app-60@3x.png"; sourceTree = ""; }; + F41E4E9428F20802001880C6 /* white-on-pink-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-pink-icon-app-60@2x.png"; sourceTree = ""; }; + F41E4E9A28F20AB7001880C6 /* white-on-celadon-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-celadon-icon-app-76@2x.png"; sourceTree = ""; }; + F41E4E9B28F20AB7001880C6 /* white-on-celadon-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-celadon-icon-app-60@3x.png"; sourceTree = ""; }; + F41E4E9C28F20AB7001880C6 /* white-on-celadon-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-celadon-icon-app-83.5@2x.png"; sourceTree = ""; }; + F41E4E9D28F20AB7001880C6 /* white-on-celadon-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-celadon-icon-app-76.png"; sourceTree = ""; }; + F41E4E9E28F20AB8001880C6 /* white-on-celadon-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-celadon-icon-app-60@2x.png"; sourceTree = ""; }; + F41E4EA528F20DF9001880C6 /* stroke-light-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "stroke-light-icon-app-76.png"; sourceTree = ""; }; + F41E4EA628F20DF9001880C6 /* stroke-light-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "stroke-light-icon-app-60@2x.png"; sourceTree = ""; }; + F41E4EA728F20DF9001880C6 /* stroke-light-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "stroke-light-icon-app-60@3x.png"; sourceTree = ""; }; + F41E4EA828F20DF9001880C6 /* stroke-light-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "stroke-light-icon-app-83.5@2x.png"; sourceTree = ""; }; + F41E4EA928F20DF9001880C6 /* stroke-light-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "stroke-light-icon-app-76@2x.png"; sourceTree = ""; }; + F41E4EB028F225DB001880C6 /* stroke-dark-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "stroke-dark-icon-app-76.png"; sourceTree = ""; }; + F41E4EB128F225DB001880C6 /* stroke-dark-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "stroke-dark-icon-app-83.5@2x.png"; sourceTree = ""; }; + F41E4EB228F225DB001880C6 /* stroke-dark-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "stroke-dark-icon-app-60@2x.png"; sourceTree = ""; }; + F41E4EB328F225DB001880C6 /* stroke-dark-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "stroke-dark-icon-app-60@3x.png"; sourceTree = ""; }; + F41E4EB428F225DB001880C6 /* stroke-dark-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "stroke-dark-icon-app-76@2x.png"; sourceTree = ""; }; + F41E4EBB28F22931001880C6 /* spectrum-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-icon-app-83.5@2x.png"; sourceTree = ""; }; + F41E4EBC28F22931001880C6 /* spectrum-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-icon-app-76.png"; sourceTree = ""; }; + F41E4EBD28F22931001880C6 /* spectrum-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-icon-app-76@2x.png"; sourceTree = ""; }; + F41E4EBE28F22931001880C6 /* spectrum-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-icon-app-60@3x.png"; sourceTree = ""; }; + F41E4EBF28F22931001880C6 /* spectrum-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-icon-app-60@2x.png"; sourceTree = ""; }; + F41E4EC628F23E00001880C6 /* green-on-white-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "green-on-white-icon-app-60@3x.png"; sourceTree = ""; }; + F41E4EC728F23E00001880C6 /* green-on-white-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "green-on-white-icon-app-60@2x.png"; sourceTree = ""; }; + F41E4EC828F23E00001880C6 /* green-on-white-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "green-on-white-icon-app-76.png"; sourceTree = ""; }; + F41E4EC928F23E00001880C6 /* green-on-white-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "green-on-white-icon-app-76@2x.png"; sourceTree = ""; }; + F41E4ECA28F23E00001880C6 /* green-on-white-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "green-on-white-icon-app-83.5@2x.png"; sourceTree = ""; }; + F41E4ED128F2424B001880C6 /* dark-glow-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "dark-glow-icon-app-83.5@2x.png"; sourceTree = ""; }; + F41E4ED228F2424B001880C6 /* dark-glow-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "dark-glow-icon-app-76.png"; sourceTree = ""; }; + F41E4ED328F2424B001880C6 /* dark-glow-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "dark-glow-icon-app-60@3x.png"; sourceTree = ""; }; + F41E4ED428F2424B001880C6 /* dark-glow-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "dark-glow-icon-app-60@2x.png"; sourceTree = ""; }; + F41E4ED528F2424B001880C6 /* dark-glow-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "dark-glow-icon-app-76@2x.png"; sourceTree = ""; }; + F41E4EDC28F24623001880C6 /* 3d-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "3d-icon-app-76@2x.png"; sourceTree = ""; }; + F41E4EDD28F24623001880C6 /* 3d-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "3d-icon-app-83.5@2x.png"; sourceTree = ""; }; + F41E4EDE28F24623001880C6 /* 3d-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "3d-icon-app-60@3x.png"; sourceTree = ""; }; + F41E4EDF28F24623001880C6 /* 3d-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "3d-icon-app-60@2x.png"; sourceTree = ""; }; + F41E4EE028F24623001880C6 /* 3d-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "3d-icon-app-76.png"; sourceTree = ""; }; + F41E4EE728F247D2001880C6 /* white-on-green-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-green-icon-app-83.5@2x.png"; sourceTree = ""; }; + F41E4EE828F247D3001880C6 /* white-on-green-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-green-icon-app-60@3x.png"; sourceTree = ""; }; + F41E4EE928F247D3001880C6 /* white-on-green-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-green-icon-app-76.png"; sourceTree = ""; }; + F41E4EEA28F247D3001880C6 /* white-on-green-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-green-icon-app-60@2x.png"; sourceTree = ""; }; + F41E4EEB28F247D3001880C6 /* white-on-green-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-green-icon-app-76@2x.png"; sourceTree = ""; }; + F42A1D9629928B360059CC70 /* BlockedAuthor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedAuthor.swift; sourceTree = ""; }; + F432964A287752690089C4F7 /* WordPress 144.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 144.xcdatamodel"; sourceTree = ""; }; + F4426FD2287E08C300218003 /* SuggestionServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionServiceMock.swift; sourceTree = ""; }; + F4426FD8287F02FD00218003 /* SiteSuggestionsServiceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteSuggestionsServiceMock.swift; sourceTree = ""; }; + F4426FDA287F066400218003 /* site-suggestions.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "site-suggestions.json"; sourceTree = ""; }; + F44293D128E3B18E00D340AF /* AppIconListViewModelType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconListViewModelType.swift; sourceTree = ""; }; + F44293D528E3BA1700D340AF /* AppIconListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconListViewModel.swift; sourceTree = ""; }; + F44F6ABD2937428B00DC94A2 /* MigrationEmailService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationEmailService.swift; sourceTree = ""; }; + F44FB6CA287895AF0001E3CE /* SuggestionsListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionsListViewModelTests.swift; sourceTree = ""; }; + F44FB6CC287897F90001E3CE /* SuggestionsTableViewMockDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionsTableViewMockDelegate.swift; sourceTree = ""; }; + F44FB6D02878A1020001E3CE /* user-suggestions.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "user-suggestions.json"; sourceTree = ""; }; + F45326D729F6B8A6005F9F31 /* SiteCreationAnalyticsEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteCreationAnalyticsEvent.swift; sourceTree = ""; }; + F465976928E4669200D5F49A /* cool-green-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cool-green-icon-app-76@2x.png"; sourceTree = ""; }; + F465976A28E4669200D5F49A /* cool-green-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cool-green-icon-app-76.png"; sourceTree = ""; }; + F465976B28E4669200D5F49A /* cool-green-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cool-green-icon-app-60@3x.png"; sourceTree = ""; }; + F465976C28E4669200D5F49A /* cool-green-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cool-green-icon-app-60@2x.png"; sourceTree = ""; }; + F465976D28E4669200D5F49A /* cool-green-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "cool-green-icon-app-83.5@2x.png"; sourceTree = ""; }; + F465977428E6598800D5F49A /* black-on-white-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-on-white-icon-app-76.png"; sourceTree = ""; }; + F465977528E6598800D5F49A /* black-on-white-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-on-white-icon-app-60@3x.png"; sourceTree = ""; }; + F465977628E6598900D5F49A /* black-on-white-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-on-white-icon-app-76@2x.png"; sourceTree = ""; }; + F465977728E6598900D5F49A /* black-on-white-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-on-white-icon-app-60@2x.png"; sourceTree = ""; }; + F465977828E6598900D5F49A /* black-on-white-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "black-on-white-icon-app-83.5@2x.png"; sourceTree = ""; }; + F465977F28E65E1600D5F49A /* blue-on-white-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-on-white-icon-app-76@2x.png"; sourceTree = ""; }; + F465978028E65E1700D5F49A /* blue-on-white-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-on-white-icon-app-60@2x.png"; sourceTree = ""; }; + F465978128E65E1700D5F49A /* blue-on-white-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-on-white-icon-app-83.5@2x.png"; sourceTree = ""; }; + F465978228E65E1700D5F49A /* blue-on-white-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-on-white-icon-app-76.png"; sourceTree = ""; }; + F465978328E65E1700D5F49A /* blue-on-white-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blue-on-white-icon-app-60@3x.png"; sourceTree = ""; }; + F465978A28E65F8900D5F49A /* celadon-on-white-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-on-white-icon-app-60@2x.png"; sourceTree = ""; }; + F465978B28E65F8900D5F49A /* celadon-on-white-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-on-white-icon-app-76@2x.png"; sourceTree = ""; }; + F465978C28E65F8900D5F49A /* celadon-on-white-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-on-white-icon-app-76.png"; sourceTree = ""; }; + F465978D28E65F8900D5F49A /* celadon-on-white-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-on-white-icon-app-60@3x.png"; sourceTree = ""; }; + F465978E28E65F8A00D5F49A /* celadon-on-white-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "celadon-on-white-icon-app-83.5@2x.png"; sourceTree = ""; }; + F465979528E65FC700D5F49A /* dark-green-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "dark-green-icon-app-60@3x.png"; sourceTree = ""; }; + F465979628E65FC700D5F49A /* dark-green-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "dark-green-icon-app-76@2x.png"; sourceTree = ""; }; + F465979728E65FC800D5F49A /* dark-green-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "dark-green-icon-app-60@2x.png"; sourceTree = ""; }; + F465979828E65FC800D5F49A /* dark-green-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "dark-green-icon-app-83.5@2x.png"; sourceTree = ""; }; + F465979928E65FC800D5F49A /* dark-green-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "dark-green-icon-app-76.png"; sourceTree = ""; }; + F46597A028E6600700D5F49A /* jetpack-light-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack-light-icon-app-60@3x.png"; sourceTree = ""; }; + F46597A128E6600700D5F49A /* jetpack-light-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack-light-icon-app-83.5@2x.png"; sourceTree = ""; }; + F46597A228E6600700D5F49A /* jetpack-light-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack-light-icon-app-76.png"; sourceTree = ""; }; + F46597A328E6600800D5F49A /* jetpack-light-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack-light-icon-app-60@2x.png"; sourceTree = ""; }; + F46597A428E6600800D5F49A /* jetpack-light-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "jetpack-light-icon-app-76@2x.png"; sourceTree = ""; }; + F46597AB28E6605C00D5F49A /* neu-green-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neu-green-icon-app-76.png"; sourceTree = ""; }; + F46597AC28E6605D00D5F49A /* neu-green-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neu-green-icon-app-76@2x.png"; sourceTree = ""; }; + F46597AD28E6605D00D5F49A /* neu-green-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neu-green-icon-app-83.5@2x.png"; sourceTree = ""; }; + F46597AE28E6605D00D5F49A /* neu-green-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neu-green-icon-app-60@2x.png"; sourceTree = ""; }; + F46597AF28E6605D00D5F49A /* neu-green-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neu-green-icon-app-60@3x.png"; sourceTree = ""; }; + F46597B628E6687700D5F49A /* neumorphic-dark-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neumorphic-dark-icon-app-76@2x.png"; sourceTree = ""; }; + F46597B728E6687700D5F49A /* neumorphic-dark-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neumorphic-dark-icon-app-60@2x.png"; sourceTree = ""; }; + F46597B828E6687700D5F49A /* neumorphic-dark-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neumorphic-dark-icon-app-76.png"; sourceTree = ""; }; + F46597B928E6687700D5F49A /* neumorphic-dark-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neumorphic-dark-icon-app-60@3x.png"; sourceTree = ""; }; + F46597BA28E6687700D5F49A /* neumorphic-dark-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neumorphic-dark-icon-app-83.5@2x.png"; sourceTree = ""; }; + F46597C128E668B800D5F49A /* neumorphic-light-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neumorphic-light-icon-app-83.5@2x.png"; sourceTree = ""; }; + F46597C228E668B800D5F49A /* neumorphic-light-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neumorphic-light-icon-app-76@2x.png"; sourceTree = ""; }; + F46597C328E668B900D5F49A /* neumorphic-light-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neumorphic-light-icon-app-60@3x.png"; sourceTree = ""; }; + F46597C428E668B900D5F49A /* neumorphic-light-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neumorphic-light-icon-app-76.png"; sourceTree = ""; }; + F46597C528E668B900D5F49A /* neumorphic-light-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "neumorphic-light-icon-app-60@2x.png"; sourceTree = ""; }; + F46597D728E6694100D5F49A /* pink-on-white-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-on-white-icon-app-60@3x.png"; sourceTree = ""; }; + F46597D828E6694100D5F49A /* pink-on-white-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-on-white-icon-app-60@2x.png"; sourceTree = ""; }; + F46597D928E6694100D5F49A /* pink-on-white-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-on-white-icon-app-76@2x.png"; sourceTree = ""; }; + F46597DA28E6694100D5F49A /* pink-on-white-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-on-white-icon-app-83.5@2x.png"; sourceTree = ""; }; + F46597DB28E6694200D5F49A /* pink-on-white-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "pink-on-white-icon-app-76.png"; sourceTree = ""; }; + F46597E228E6698C00D5F49A /* spectrum-on-black-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-on-black-icon-app-76@2x.png"; sourceTree = ""; }; + F46597E328E6698C00D5F49A /* spectrum-on-black-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-on-black-icon-app-60@2x.png"; sourceTree = ""; }; + F46597E428E6698C00D5F49A /* spectrum-on-black-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-on-black-icon-app-83.5@2x.png"; sourceTree = ""; }; + F46597E528E6698C00D5F49A /* spectrum-on-black-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-on-black-icon-app-76.png"; sourceTree = ""; }; + F46597E628E6698D00D5F49A /* spectrum-on-black-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-on-black-icon-app-60@3x.png"; sourceTree = ""; }; + F46597ED28E669D300D5F49A /* spectrum-on-white-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-on-white-icon-app-76.png"; sourceTree = ""; }; + F46597EE28E669D300D5F49A /* spectrum-on-white-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-on-white-icon-app-83.5@2x.png"; sourceTree = ""; }; + F46597EF28E669D400D5F49A /* spectrum-on-white-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-on-white-icon-app-60@3x.png"; sourceTree = ""; }; + F46597F028E669D400D5F49A /* spectrum-on-white-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-on-white-icon-app-76@2x.png"; sourceTree = ""; }; + F46597F128E669D400D5F49A /* spectrum-on-white-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "spectrum-on-white-icon-app-60@2x.png"; sourceTree = ""; }; + F46597F828E66A1000D5F49A /* white-on-black-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-black-icon-app-60@2x.png"; sourceTree = ""; }; + F46597F928E66A1000D5F49A /* white-on-black-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-black-icon-app-76.png"; sourceTree = ""; }; + F46597FA28E66A1100D5F49A /* white-on-black-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-black-icon-app-76@2x.png"; sourceTree = ""; }; + F46597FB28E66A1100D5F49A /* white-on-black-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-black-icon-app-83.5@2x.png"; sourceTree = ""; }; + F46597FC28E66A1100D5F49A /* white-on-black-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-black-icon-app-60@3x.png"; sourceTree = ""; }; + F465980328E66A5A00D5F49A /* white-on-blue-icon-app-76@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-blue-icon-app-76@2x.png"; sourceTree = ""; }; + F465980428E66A5A00D5F49A /* white-on-blue-icon-app-76.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-blue-icon-app-76.png"; sourceTree = ""; }; + F465980528E66A5A00D5F49A /* white-on-blue-icon-app-60@3x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-blue-icon-app-60@3x.png"; sourceTree = ""; }; + F465980628E66A5A00D5F49A /* white-on-blue-icon-app-60@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-blue-icon-app-60@2x.png"; sourceTree = ""; }; + F465980728E66A5B00D5F49A /* white-on-blue-icon-app-83.5@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "white-on-blue-icon-app-83.5@2x.png"; sourceTree = ""; }; + F478B151292FC1BC00AA8645 /* MigrationAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationAppearance.swift; sourceTree = ""; }; + F47E154929E84A9300B6E426 /* DomainPurchasingWebFlowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainPurchasingWebFlowController.swift; sourceTree = ""; }; + F48D44B5298992C30051EAA6 /* BlockedSite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedSite.swift; sourceTree = ""; }; + F48D44B7298993900051EAA6 /* WordPress 147.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 147.xcdatamodel"; sourceTree = ""; }; + F48D44B92989A58C0051EAA6 /* ReaderSiteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSiteService.swift; sourceTree = ""; }; + F49B99FE2937C9B4000CEFCE /* MigrationEmailService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MigrationEmailService.swift; sourceTree = ""; }; + F49B9A05293A21BF000CEFCE /* MigrationAnalyticsTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationAnalyticsTracker.swift; sourceTree = ""; }; + F49B9A07293A21F4000CEFCE /* MigrationEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationEvent.swift; sourceTree = ""; }; + F49D7BEA29DF329500CB93A5 /* UIPopoverPresentationController+PopoverAnchor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIPopoverPresentationController+PopoverAnchor.swift"; sourceTree = ""; }; + F4BECD1A288EE5220078391A /* SuggestionsViewModelType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SuggestionsViewModelType.swift; sourceTree = ""; }; + F4CBE3D329258AD6004FFBB6 /* MeHeaderView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MeHeaderView.h; sourceTree = ""; }; + F4CBE3D5292597E3004FFBB6 /* SupportTableViewControllerConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportTableViewControllerConfiguration.swift; sourceTree = ""; }; + F4CBE3D829265BC8004FFBB6 /* LogOutActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogOutActionHandler.swift; sourceTree = ""; }; + F4D36AD4298498E600E6B84C /* ReaderPostBlockingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPostBlockingController.swift; sourceTree = ""; }; + F4D829612930E9F300038726 /* MigrationDeleteWordPressViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationDeleteWordPressViewController.swift; sourceTree = ""; }; + F4D829632930EA4C00038726 /* MigrationDeleteWordPressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationDeleteWordPressViewModel.swift; sourceTree = ""; }; + F4D829652931046F00038726 /* UIButton+Dismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIButton+Dismiss.swift"; sourceTree = ""; }; + F4D829672931059000038726 /* MigrationSuccessActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationSuccessActionHandler.swift; sourceTree = ""; }; + F4D829692931083000038726 /* MigrationSuccessCell+WordPress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MigrationSuccessCell+WordPress.swift"; sourceTree = ""; }; + F4D8296B2931087100038726 /* MigrationSuccessCell+Jetpack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MigrationSuccessCell+Jetpack.swift"; sourceTree = ""; }; + F4D8296F2931097900038726 /* DashboardMigrationSuccessCell+WordPress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DashboardMigrationSuccessCell+WordPress.swift"; sourceTree = ""; }; + F4D82971293109A600038726 /* DashboardMigrationSuccessCell+Jetpack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DashboardMigrationSuccessCell+Jetpack.swift"; sourceTree = ""; }; + F4D9188529D78C9100974A71 /* BlogDetailsViewController+Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogDetailsViewController+Strings.swift"; sourceTree = ""; }; + F4D9AF4E288AD2E300803D40 /* SuggestionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionViewModelTests.swift; sourceTree = ""; }; + F4D9AF50288AE23500803D40 /* SuggestionTableViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTableViewTests.swift; sourceTree = ""; }; + F4D9AF52288AE2BA00803D40 /* SuggestionsTableViewDelegateMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionsTableViewDelegateMock.swift; sourceTree = ""; }; + F4DDE2C129C92F0D00C02A76 /* CrashLogging+Singleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CrashLogging+Singleton.swift"; sourceTree = ""; }; + F4E79300296EEE320025E8E0 /* MigrationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationState.swift; sourceTree = ""; }; + F4EDAA4B29A516E900622D3D /* ReaderPostService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderPostService.swift; sourceTree = ""; }; + F4EF4BAA291D3D4700147B61 /* SiteIconTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteIconTests.swift; sourceTree = ""; }; + F4F09CCB2989BF5B00A5F420 /* ReaderSiteService_Internal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ReaderSiteService_Internal.h; sourceTree = ""; }; + F4F9D5E92909622E00502576 /* MigrationWelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationWelcomeViewController.swift; sourceTree = ""; }; + F4F9D5EB29096CF500502576 /* MigrationHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationHeaderView.swift; sourceTree = ""; }; + F4F9D5F1290993D400502576 /* MigrationWelcomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationWelcomeViewModel.swift; sourceTree = ""; }; + F4F9D5F32909B7C100502576 /* MigrationWelcomeBlogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationWelcomeBlogTableViewCell.swift; sourceTree = ""; }; + F4FB0ACC292587D500F651F9 /* MeHeaderViewConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeHeaderViewConfiguration.swift; sourceTree = ""; }; + F4FE743329C3767300AC2729 /* AddressTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AddressTableViewCell+ViewModel.swift"; sourceTree = ""; }; + F504D2AA25D60C5900A2764C /* StoryPoster.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoryPoster.swift; sourceTree = ""; }; + F504D2AB25D60C5900A2764C /* StoryMediaLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoryMediaLoader.swift; sourceTree = ""; }; + F50B0E7A246212B8006601DD /* NoticeAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeAnimator.swift; sourceTree = ""; }; F511F8A32356A4F400895E73 /* PublishSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishSettingsViewController.swift; sourceTree = ""; }; - F53FF3A023E2377E001AD596 /* NewBlogDetailHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewBlogDetailHeaderView.swift; sourceTree = ""; }; - F53FF3A223EA3E45001AD596 /* BlogDetailsViewController+Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogDetailsViewController+Header.swift"; sourceTree = ""; }; + F52CACC9244FA7AA00661380 /* ReaderManageScenePresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderManageScenePresenter.swift; sourceTree = ""; }; + F52CACCB24512EA700661380 /* EmptyActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyActionView.swift; sourceTree = ""; }; + F532AD60253B81320013B42E /* StoriesIntroDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoriesIntroDataSource.swift; sourceTree = ""; }; + F532AE1B253E55D40013B42E /* CreateButtonActionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateButtonActionSheet.swift; sourceTree = ""; }; F53FF3A723EA723D001AD596 /* ActionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionRow.swift; sourceTree = ""; }; F53FF3A923EA725C001AD596 /* SiteIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteIconView.swift; sourceTree = ""; }; F543AF5623A84E4D0022F595 /* PublishSettingsControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishSettingsControllerTests.swift; sourceTree = ""; }; - F543AF5823A84F200022F595 /* SchedulingCalendarViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchedulingCalendarViewControllerTests.swift; sourceTree = ""; }; F551E7F423F6EA3100751212 /* FloatingActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingActionButton.swift; sourceTree = ""; }; F551E7F623FC9A5C00751212 /* Collection+RotateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+RotateTests.swift"; sourceTree = ""; }; F565190223CF6D1D003FACAF /* WKCookieJarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKCookieJarTests.swift; sourceTree = ""; }; - F5660CFF235CE82100020B1E /* SchedulingCalendarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchedulingCalendarViewController.swift; sourceTree = ""; }; F5660D06235D114500020B1E /* CalendarCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarCollectionView.swift; sourceTree = ""; }; F5660D08235D1CDD00020B1E /* CalendarMonthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarMonthView.swift; sourceTree = ""; }; + F56A33322538C0ED00E2AEF3 /* MySiteViewController+FAB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MySiteViewController+FAB.swift"; sourceTree = ""; }; F57402A6235FF9C300374346 /* SchedulingDate+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SchedulingDate+Helpers.swift"; sourceTree = ""; }; + F574416C2425697D00E150A8 /* Route+Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Route+Page.swift"; sourceTree = ""; }; F580C3C023D22E2D0038E243 /* PreviewDeviceLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewDeviceLabel.swift; sourceTree = ""; }; F580C3CA23D8F9B40038E243 /* AbstractPost+Dates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AbstractPost+Dates.swift"; sourceTree = ""; }; F582060123A85495005159A9 /* SiteDateFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteDateFormatters.swift; sourceTree = ""; }; - F582060323A88379005159A9 /* TimePickerViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimePickerViewControllerTests.swift; sourceTree = ""; }; - F5844B6A235EAF3D007C6557 /* HalfScreenPresentationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HalfScreenPresentationController.swift; sourceTree = ""; }; + F5844B6A235EAF3D007C6557 /* PartScreenPresentationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PartScreenPresentationController.swift; sourceTree = ""; }; F59AAC0F235E430E00385EE6 /* ChosenValueRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChosenValueRow.swift; sourceTree = ""; }; F59AAC15235EA46D00385EE6 /* LightNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LightNavigationController.swift; sourceTree = ""; }; + F5A34A9825DEF47D00C9654B /* WPMediaPicker+MediaPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPMediaPicker+MediaPicker.swift"; sourceTree = ""; }; + F5A34BC925DF244F00C9654B /* KanvasCameraAnalyticsHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KanvasCameraAnalyticsHandler.swift; sourceTree = ""; }; + F5A34BCA25DF244F00C9654B /* KanvasCameraCustomUI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KanvasCameraCustomUI.swift; sourceTree = ""; }; + F5A34D0625DF2F7700C9654B /* oswald_upper.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = oswald_upper.ttf; sourceTree = ""; }; + F5A34D0725DF2F7700C9654B /* Nunito-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Nunito-Bold.ttf"; sourceTree = ""; }; + F5A34D0825DF2F7700C9654B /* SpaceMono-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SpaceMono-Bold.ttf"; sourceTree = ""; }; + F5A34D0925DF2F7700C9654B /* LibreBaskerville-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "LibreBaskerville-Regular.ttf"; sourceTree = ""; }; + F5A34D0A25DF2F7700C9654B /* Shrikhand-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Shrikhand-Regular.ttf"; sourceTree = ""; }; + F5A34D0B25DF2F7700C9654B /* Pacifico-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Pacifico-Regular.ttf"; sourceTree = ""; }; + F5A34D0C25DF2F7700C9654B /* Noticons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Noticons.ttf; sourceTree = ""; }; + F5A738BC244DF75400EDE065 /* OffsetTableViewHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffsetTableViewHandler.swift; sourceTree = ""; }; + F5A738BE244DF7E400EDE065 /* ReaderTagsTableViewController+Cells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderTagsTableViewController+Cells.swift"; sourceTree = ""; }; + F5A738C2244E7A6F00EDE065 /* ReaderTagsTableViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTagsTableViewModel.swift; sourceTree = ""; }; + F5AE43E325DD02C0003675F4 /* StoryEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoryEditor.swift; sourceTree = ""; }; + F5AE440525DD0345003675F4 /* CameraHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraHandler.swift; sourceTree = ""; }; + F5B390E92537E30B0097049E /* GridCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridCell.swift; sourceTree = ""; }; F5B8A60E23CE56A1001B7359 /* PreviewDeviceSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewDeviceSelectionViewController.swift; sourceTree = ""; }; + F5B9151E244653C100179876 /* TabbedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedViewController.swift; sourceTree = ""; }; + F5B9152024465FB400179876 /* ReaderTagsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTagsTableViewController.swift; sourceTree = ""; }; + F5B9D7EF245BA938002BB2C7 /* FancyAlertViewController+CreateButtonAnnouncement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FancyAlertViewController+CreateButtonAnnouncement.swift"; sourceTree = ""; }; + F5C00EAD242179780047846F /* WeekdaysHeaderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeekdaysHeaderViewTests.swift; sourceTree = ""; }; + F5CFB8F424216DFC00E58B69 /* CalendarHeaderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarHeaderViewTests.swift; sourceTree = ""; }; F5D0A64823C8FA1500B20D27 /* LinkBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkBehavior.swift; sourceTree = ""; }; F5D0A64D23CC159400B20D27 /* PreviewWebKitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWebKitViewController.swift; sourceTree = ""; }; F5D0A64F23CC15A800B20D27 /* PreviewNonceHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewNonceHandler.swift; sourceTree = ""; }; F5D0A65123CCD3B600B20D27 /* PreviewWebKitViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWebKitViewControllerTests.swift; sourceTree = ""; }; + F5D3992F2541F25B0058D0AB /* SheetActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetActions.swift; sourceTree = ""; }; F5E032D5240889EB003AF350 /* CreateButtonCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateButtonCoordinator.swift; sourceTree = ""; }; F5E032DA24088F44003AF350 /* UIView+SpringAnimations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+SpringAnimations.swift"; sourceTree = ""; }; - F5E032DE2408D1F1003AF350 /* WPTabBarController+ShowTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPTabBarController+ShowTab.swift"; sourceTree = ""; }; - F7E3CC306AECBBCB71D2E19C /* Pods_WordPressDraftActionExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_WordPressDraftActionExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F5E032E32408D537003AF350 /* ActionSheetViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionSheetViewController.swift; sourceTree = ""; }; + F5E032E52408D537003AF350 /* BottomSheetPresentationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BottomSheetPresentationController.swift; sourceTree = ""; }; + F5E032EB240D49FF003AF350 /* ActionSheetComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionSheetComponent.swift; sourceTree = ""; }; + F5E1577E25DE04E200EEEDFB /* GutenbergMediaFilesUploadProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergMediaFilesUploadProcessor.swift; sourceTree = ""; }; + F5E1BA9A253A0A5E0091E9A6 /* StoriesIntroViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoriesIntroViewController.swift; sourceTree = ""; }; + F5E1BBDF253B74240091E9A6 /* URLQueryItem+Parameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLQueryItem+Parameters.swift"; sourceTree = ""; }; + F5E29035243E4F5F00C19CA5 /* FilterProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterProvider.swift; sourceTree = ""; }; + F5E29037243FAB0300C19CA5 /* FilterTableData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterTableData.swift; sourceTree = ""; }; + F5E63128243BC8190088229D /* FilterSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterSheetView.swift; sourceTree = ""; }; + F5E6312A243BC83E0088229D /* FilterSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterSheetViewController.swift; sourceTree = ""; }; + F75F3A68DCE524B4BAFCE76E /* Pods-WordPressDraftActionExtension.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressDraftActionExtension.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension.release-alpha.xcconfig"; sourceTree = ""; }; + F85B762A18D018C22DF2A40D /* Pods-JetpackShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-JetpackShareExtension.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-JetpackShareExtension/Pods-JetpackShareExtension.debug.xcconfig"; sourceTree = ""; }; + F913BB0D24B3C58B00C19032 /* EventLoggingDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoggingDelegate.swift; sourceTree = ""; }; + F913BB0F24B3C5CE00C19032 /* EventLoggingDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoggingDataProvider.swift; sourceTree = ""; }; F928EDA2226140620030D451 /* WPCrashLoggingProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPCrashLoggingProvider.swift; sourceTree = ""; }; F93735F022D534FE00A3C312 /* LoggingURLRedactor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggingURLRedactor.swift; sourceTree = ""; }; F93735F722D53C3B00A3C312 /* LoggingURLRedactorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoggingURLRedactorTests.swift; sourceTree = ""; }; F9463A7221C05EE90081F11E /* ScreenshotCredentials.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScreenshotCredentials.swift; sourceTree = ""; }; F97DA41F23D67B820050E791 /* MediaScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaScreen.swift; sourceTree = ""; }; F9941D1722A805F600788F33 /* UIImage+XCAssetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+XCAssetTests.swift"; sourceTree = ""; }; + F9B862C82478170A008B093C /* EncryptedLogTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedLogTableViewController.swift; sourceTree = ""; }; F9C47A8B238C801600AAD9ED /* PostsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsScreen.swift; sourceTree = ""; }; F9C47A8E238C9D6400AAD9ED /* StatsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsScreen.swift; sourceTree = ""; }; + FA00863C24EB68B100C863F2 /* FollowCommentsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowCommentsService.swift; sourceTree = ""; }; + FA1A543D25A6E2F60033967D /* RestoreWarningView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreWarningView.swift; sourceTree = ""; }; + FA1A543F25A6E3080033967D /* RestoreWarningView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RestoreWarningView.xib; sourceTree = ""; }; + FA1A55EE25A6F0740033967D /* RestoreStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreStatusView.swift; sourceTree = ""; }; + FA1A55FE25A6F07F0033967D /* RestoreStatusView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RestoreStatusView.xib; sourceTree = ""; }; FA1ACAA11BC6E45D00DDDCE2 /* WPStyleGuide+Themes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Themes.swift"; sourceTree = ""; }; + FA1CEAC125CA9C2A005E7038 /* RestoreStatusFailedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreStatusFailedView.swift; sourceTree = ""; }; + FA1CEAD325CA9C40005E7038 /* RestoreStatusFailedView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RestoreStatusFailedView.xib; sourceTree = ""; }; + FA20751327A86B73001A644D /* UIScrollView+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScrollView+Helpers.swift"; sourceTree = ""; }; + FA25F9FD2609AA830005E08F /* AppConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = ""; }; + FA25FA332609AAAA0005E08F /* AppConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = ""; }; FA2D12891BCED0AD006F2A15 /* WordPress 40.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 40.xcdatamodel"; sourceTree = ""; }; + FA332ACF29C1F97A00182FBB /* MovedToJetpackViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovedToJetpackViewController.swift; sourceTree = ""; }; + FA332AD329C1FC7A00182FBB /* MovedToJetpackViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovedToJetpackViewModel.swift; sourceTree = ""; }; + FA347AEB26EB6E300096604B /* GrowAudienceCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GrowAudienceCell.swift; sourceTree = ""; }; + FA347AEC26EB6E300096604B /* GrowAudienceCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = GrowAudienceCell.xib; sourceTree = ""; }; + FA347AF126EB7A420096604B /* StatsGhostGrowAudienceCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = StatsGhostGrowAudienceCell.xib; sourceTree = ""; }; + FA3536F425B01A2C0005A3A0 /* JetpackRestoreCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRestoreCompleteViewController.swift; sourceTree = ""; }; + FA41044C263932AC00E90EBF /* ActivityLogScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityLogScreen.swift; sourceTree = ""; }; + FA4104732639393700E90EBF /* JetpackScanScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScanScreen.swift; sourceTree = ""; }; + FA4104BE26393F1A00E90EBF /* JetpackBackupScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBackupScreen.swift; sourceTree = ""; }; + FA41070D263957C000E90EBF /* JetpackBackupOptionsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBackupOptionsScreen.swift; sourceTree = ""; }; FA4ADAD71C50687400F858D7 /* SiteManagementService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteManagementService.swift; sourceTree = ""; }; FA4ADAD91C509FE400F858D7 /* SiteManagementServiceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SiteManagementServiceTests.swift; sourceTree = ""; }; + FA4B202E29A619130089FE68 /* BlazeFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeFlowCoordinator.swift; sourceTree = ""; }; + FA4B203429A786460089FE68 /* BlazeEventsTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeEventsTracker.swift; sourceTree = ""; }; + FA4B203729A8C48F0089FE68 /* AbstractPost+Blaze.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AbstractPost+Blaze.swift"; sourceTree = ""; }; + FA4B203A29AE62C00089FE68 /* BlazeOverlayViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeOverlayViewController.swift; sourceTree = ""; }; + FA4BC0CF2996A589005EB077 /* BlazeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeService.swift; sourceTree = ""; }; + FA4F383527D766020068AAF5 /* MySiteSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MySiteSettings.swift; sourceTree = ""; }; + FA4F65A62594337300EAA9F5 /* JetpackRestoreOptionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRestoreOptionsViewController.swift; sourceTree = ""; }; + FA4F660425946B5F00EAA9F5 /* JetpackRestoreHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRestoreHeaderView.swift; sourceTree = ""; }; + FA4F661325946B8500EAA9F5 /* JetpackRestoreHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = JetpackRestoreHeaderView.xib; sourceTree = ""; }; FA5C740E1C599BA7000B528C /* TableViewHeaderDetailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableViewHeaderDetailView.swift; sourceTree = ""; }; + FA612DDE274E9F730002B03A /* QuickStartPromptScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartPromptScreen.swift; sourceTree = ""; }; + FA6402D029C325C1007A235C /* MovedToJetpackEventsTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovedToJetpackEventsTracker.swift; sourceTree = ""; }; + FA681F8825CA946B00DAA544 /* BaseRestoreStatusFailedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseRestoreStatusFailedViewController.swift; sourceTree = ""; }; + FA6FAB3425EF7C5700666CED /* ReaderRelatedPostsSectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderRelatedPostsSectionHeaderView.swift; sourceTree = ""; }; + FA6FAB4625EF7C6A00666CED /* ReaderRelatedPostsSectionHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderRelatedPostsSectionHeaderView.xib; sourceTree = ""; }; + FA70024B29DC3B5500E874FD /* DashboardActivityLogCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardActivityLogCardCell.swift; sourceTree = ""; }; + FA73D7D5278D9E5D00DF24B3 /* BlogDashboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogDashboardViewController.swift; sourceTree = ""; }; + FA73D7E42798765B00DF24B3 /* SitePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitePickerViewController.swift; sourceTree = ""; }; + FA73D7E827987BA500DF24B3 /* SitePickerViewController+SiteIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SitePickerViewController+SiteIcon.swift"; sourceTree = ""; }; + FA73D7EB27987E4500DF24B3 /* SitePickerViewController+QuickStart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SitePickerViewController+QuickStart.swift"; sourceTree = ""; }; FA77E0291BE17CFC006D45E0 /* ThemeBrowserHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeBrowserHeaderView.swift; sourceTree = ""; }; + FA7AA45225BFD9BC005E7200 /* JetpackScanThreatDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScanThreatDetailsViewController.swift; sourceTree = ""; }; + FA7AA4A625BFE0A9005E7200 /* JetpackScanThreatDetailsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = JetpackScanThreatDetailsViewController.xib; sourceTree = ""; }; + FA7F92B725E61C7E00502D2A /* ReaderTagsFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTagsFooter.swift; sourceTree = ""; }; + FA7F92C925E61C9300502D2A /* ReaderTagsFooter.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderTagsFooter.xib; sourceTree = ""; }; + FA8E1F7625EEFA7300063673 /* ReaderPostService+RelatedPosts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ReaderPostService+RelatedPosts.swift"; sourceTree = ""; }; + FA8E2FDF27C6377000DA0982 /* DashboardQuickStartCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardQuickStartCardCell.swift; sourceTree = ""; }; + FA8E2FE427C6AE4500DA0982 /* QuickStartChecklistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartChecklistView.swift; sourceTree = ""; }; + FA90EFEE262E74210055AB22 /* JetpackWebViewControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = JetpackWebViewControllerFactory.swift; path = Classes/Services/JetpackWebViewControllerFactory.swift; sourceTree = SOURCE_ROOT; }; + FA9276AC286C951200C323BB /* FeatureIntroductionScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureIntroductionScreen.swift; sourceTree = ""; }; + FA9276AE2888557500C323BB /* SiteIntentScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteIntentScreen.swift; sourceTree = ""; }; + FA9276B02889550E00C323BB /* ChooseLayoutScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseLayoutScreen.swift; sourceTree = ""; }; + FA978DDA26CEB37E009FB14F /* WordPress 132.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 132.xcdatamodel"; sourceTree = ""; }; + FA98A24C2832A5E9003B9233 /* NewQuickStartChecklistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewQuickStartChecklistView.swift; sourceTree = ""; }; + FA98A24F2833F1DC003B9233 /* QuickStartChecklistConfigurable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartChecklistConfigurable.swift; sourceTree = ""; }; + FA98B61329A39DA80071AAE8 /* WordPress 148.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 148.xcdatamodel"; sourceTree = ""; }; + FA98B61529A3B76A0071AAE8 /* DashboardBlazeCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardBlazeCardCell.swift; sourceTree = ""; }; + FA98B61829A3BF050071AAE8 /* BlazeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCardView.swift; sourceTree = ""; }; + FA98B61B29A3DB840071AAE8 /* BlazeHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeHelper.swift; sourceTree = ""; }; + FAA4012C27B405DB009E1137 /* DashboardQuickActionsCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardQuickActionsCardCell.swift; sourceTree = ""; }; + FAA4013327B52455009E1137 /* QuickActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickActionButton.swift; sourceTree = ""; }; + FAA9084B27BD60710093FFA8 /* MySiteViewController+QuickStart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MySiteViewController+QuickStart.swift"; sourceTree = ""; }; + FAADE3F02615996E00BF29FE /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; + FAADE42726159B1300BF29FE /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; + FAB37D4527ED84BC00CA993C /* DashboardStatsNudgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardStatsNudgeView.swift; sourceTree = ""; }; + FAB4F32624EDE12A00F259BA /* FollowCommentsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowCommentsServiceTests.swift; sourceTree = ""; }; + FAB8004825AEDC2300D5D54A /* JetpackBackupCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBackupCompleteViewController.swift; sourceTree = ""; }; + FAB800B125AEE3C600D5D54A /* RestoreCompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoreCompleteView.swift; sourceTree = ""; }; + FAB800C125AEE3D200D5D54A /* RestoreCompleteView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RestoreCompleteView.xib; sourceTree = ""; }; + FAB8AA2125AF031200F9F8A0 /* BaseRestoreCompleteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseRestoreCompleteViewController.swift; sourceTree = ""; }; + FAB8AB5E25AFFD0600F9F8A0 /* JetpackRestoreStatusCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRestoreStatusCoordinator.swift; sourceTree = ""; }; + FAB8AB8A25AFFE7500F9F8A0 /* JetpackRestoreService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRestoreService.swift; sourceTree = ""; }; + FAB8F74F25AD72CE00D5D54A /* BaseRestoreOptionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseRestoreOptionsViewController.swift; sourceTree = ""; }; + FAB8F76D25AD73C000D5D54A /* JetpackBackupOptionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBackupOptionsViewController.swift; sourceTree = ""; }; + FAB8F78B25AD785400D5D54A /* BaseRestoreStatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseRestoreStatusViewController.swift; sourceTree = ""; }; + FAB8F7A925AD792500D5D54A /* JetpackBackupStatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBackupStatusViewController.swift; sourceTree = ""; }; + FAB8FD4F25AEB0F500D5D54A /* JetpackBackupStatusCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBackupStatusCoordinator.swift; sourceTree = ""; }; + FAB8FD6D25AEB23600D5D54A /* JetpackBackupService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBackupService.swift; sourceTree = ""; }; + FAB9826D2697038700B172A3 /* StatsViewController+JetpackSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatsViewController+JetpackSettings.swift"; sourceTree = ""; }; + FAB985C02697550C00B172A3 /* NoResultsViewController+StatsModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NoResultsViewController+StatsModule.swift"; sourceTree = ""; }; + FABB26522602FC2C00C8785C /* Jetpack.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Jetpack.app; sourceTree = BUILT_PRODUCTS_DIR; }; + FABB26872602FCCA00C8785C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FABB28462603067C00C8785C /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = ""; }; + FABB286B2603086900C8785C /* AppImages.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = AppImages.xcassets; sourceTree = ""; }; + FAC086D525EDFB1E00B94F2A /* ReaderRelatedPostsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderRelatedPostsCell.swift; sourceTree = ""; }; + FAC086D625EDFB1E00B94F2A /* ReaderRelatedPostsCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReaderRelatedPostsCell.xib; sourceTree = ""; }; + FAC1B81D29B0C2AC00E0C542 /* BlazeOverlayViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeOverlayViewModel.swift; sourceTree = ""; }; + FAC1B82629B1F1EE00E0C542 /* BlazePostPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazePostPreviewView.swift; sourceTree = ""; }; FACB36F01C5C2BF800C6DF4E /* ThemeWebNavigationDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeWebNavigationDelegate.swift; sourceTree = ""; }; + FAD2538E26116A1600EDAF88 /* AppStyleGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStyleGuide.swift; sourceTree = ""; }; + FAD2544126116CEA00EDAF88 /* AppStyleGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStyleGuide.swift; sourceTree = ""; }; + FAD256922611B01700EDAF88 /* UIColor+WordPressColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+WordPressColors.swift"; sourceTree = ""; }; + FAD257112611B04D00EDAF88 /* UIColor+JetpackColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+JetpackColors.swift"; sourceTree = ""; }; + FAD7625A29ED780B00C09583 /* JSONDecoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONDecoderExtension.swift; sourceTree = ""; }; + FAD7626329F1480E00C09583 /* DashboardActivityLogCardCell+ActivityPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DashboardActivityLogCardCell+ActivityPresenter.swift"; sourceTree = ""; }; + FAD9457D25B5647B00F011B5 /* JetpackBackupOptionsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBackupOptionsCoordinator.swift; sourceTree = ""; }; + FAD9458D25B5678700F011B5 /* JetpackRestoreWarningCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRestoreWarningCoordinator.swift; sourceTree = ""; }; + FAD951A325B6CB3600F011B5 /* JetpackRestoreFailedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRestoreFailedViewController.swift; sourceTree = ""; }; + FAD954B725B7A99900F011B5 /* JetpackBackupStatusFailedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackBackupStatusFailedViewController.swift; sourceTree = ""; }; + FAD95D2625B91BCF00F011B5 /* JetpackRestoreStatusFailedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRestoreStatusFailedViewController.swift; sourceTree = ""; }; + FADFBD25265F580500039C41 /* MultilineButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineButton.swift; sourceTree = ""; }; + FADFBD3A265F5B2E00039C41 /* WPStyleGuide+Jetpack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+Jetpack.swift"; sourceTree = ""; }; FAE420191C5AEFE100C1D036 /* StartOverViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StartOverViewController.swift; sourceTree = ""; }; + FAE4327325874D140039EB8C /* ReaderSavedPostCellActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSavedPostCellActions.swift; sourceTree = ""; }; + FAE4CA662732C094003BFDFE /* QuickStartPromptViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartPromptViewController.swift; sourceTree = ""; }; + FAE4CA672732C094003BFDFE /* QuickStartPromptViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickStartPromptViewController.xib; sourceTree = ""; }; + FAE8EE98273AC06F00A65307 /* QuickStartSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartSettings.swift; sourceTree = ""; }; + FAE8EE9B273AD0A800A65307 /* QuickStartSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartSettingsTests.swift; sourceTree = ""; }; + FAF13C5225A57ABD003EE470 /* JetpackRestoreWarningViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRestoreWarningViewController.swift; sourceTree = ""; }; + FAF13E2F25A59240003EE470 /* JetpackRestoreStatusViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackRestoreStatusViewController.swift; sourceTree = ""; }; + FAF64BA22637DEEC00E8A1DF /* JetpackScreenshotGeneration.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JetpackScreenshotGeneration.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FAF64BDC2637DF7500E8A1DF /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FAF64E4E2637E85800E8A1DF /* JetpackScreenshotGeneration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackScreenshotGeneration.swift; sourceTree = ""; }; + FAFC064A27D22E4C002F0483 /* QuickStartTourStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartTourStateView.swift; sourceTree = ""; }; + FAFC064D27D2360B002F0483 /* QuickStartCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickStartCell.swift; sourceTree = ""; }; + FAFC065027D27241002F0483 /* BlogDetailsViewController+Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BlogDetailsViewController+Dashboard.swift"; sourceTree = ""; }; FAFF153C1C98962E007D1C90 /* SiteSettingsViewController+SiteManagement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SiteSettingsViewController+SiteManagement.swift"; sourceTree = ""; }; FD0D42C11499F31700F5E115 /* WordPress 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 4.xcdatamodel"; sourceTree = ""; }; FD21397E13128C5300099582 /* libiconv.dylib */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = "compiled.mach-o.dylib"; name = libiconv.dylib; path = usr/lib/libiconv.dylib; sourceTree = SDKROOT; }; @@ -4662,6 +9204,71 @@ FD3D6D2B1349F5D30061136A /* ImageIO.framework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.framework; name = ImageIO.framework; path = System/Library/Frameworks/ImageIO.framework; sourceTree = SDKROOT; }; FDCB9A89134B75B900E5C776 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; FDFB011916B1EA1C00F589A8 /* WordPress 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 10.xcdatamodel"; sourceTree = ""; }; + FE003F54282D48E4006F8D1D /* WordPress 140.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 140.xcdatamodel"; sourceTree = ""; }; + FE003F5B282D61B9006F8D1D /* BloggingPrompt+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "BloggingPrompt+CoreDataClass.swift"; sourceTree = ""; }; + FE003F5C282D61B9006F8D1D /* BloggingPrompt+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BloggingPrompt+CoreDataProperties.swift"; sourceTree = ""; }; + FE003F61282E73E6006F8D1D /* blogging-prompts-fetch-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "blogging-prompts-fetch-success.json"; sourceTree = ""; }; + FE02F95E269DC14A00752A44 /* Comment+Interface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Comment+Interface.swift"; sourceTree = ""; }; + FE06AC8226C3BD0900B69DE4 /* ShareAppContentPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAppContentPresenter.swift; sourceTree = ""; }; + FE06AC8426C3C2F800B69DE4 /* ShareAppTextActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAppTextActivityItemSource.swift; sourceTree = ""; }; + FE18495727F5ACBA00D26879 /* DashboardPromptsCardCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPromptsCardCell.swift; sourceTree = ""; }; + FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = richCommentTemplate.html; path = Resources/HTML/richCommentTemplate.html; sourceTree = ""; }; + FE23EB4826E7C91F005A1698 /* richCommentStyle.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; name = richCommentStyle.css; path = Resources/HTML/richCommentStyle.css; sourceTree = ""; }; + FE25C234271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCommentsNotificationSheetViewController.swift; sourceTree = ""; }; + FE29EFCC29A91160007CE034 /* WPAdminConvertibleRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPAdminConvertibleRouter.swift; sourceTree = ""; }; + FE2E3728281C839C00A1E82A /* BloggingPromptsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptsServiceTests.swift; sourceTree = ""; }; + FE320CC4294705990046899B /* ReaderPostBackupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPostBackupTests.swift; sourceTree = ""; }; + FE32E7F02844971000744D80 /* ReminderScheduleCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderScheduleCoordinatorTests.swift; sourceTree = ""; }; + FE32E7F32846A68800744D80 /* WordPress 142.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 142.xcdatamodel"; sourceTree = ""; }; + FE32EFFE275914390040BE67 /* MenuSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSheetViewController.swift; sourceTree = ""; }; + FE32F001275F602E0040BE67 /* CommentContentRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentContentRenderer.swift; sourceTree = ""; }; + FE32F005275F62620040BE67 /* WebCommentContentRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebCommentContentRenderer.swift; sourceTree = ""; }; + FE341704275FA157005D5CA7 /* RichCommentContentRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RichCommentContentRenderer.swift; sourceTree = ""; }; + FE39C133269C37C900EFB827 /* ListTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ListTableViewCell.xib; sourceTree = ""; }; + FE39C134269C37C900EFB827 /* ListTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListTableViewCell.swift; sourceTree = ""; }; + FE3D057D26C3D5C1002A51B0 /* ShareAppContentPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAppContentPresenterTests.swift; sourceTree = ""; }; + FE3D057F26C3E0F2002A51B0 /* share-app-link-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "share-app-link-success.json"; sourceTree = ""; }; + FE3D058126C40E66002A51B0 /* ShareAppContentPresenter+TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ShareAppContentPresenter+TableView.swift"; sourceTree = ""; }; + FE3E83E326A58646008CE851 /* ListSimpleOverlayView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListSimpleOverlayView.swift; sourceTree = ""; }; + FE3E83E426A58646008CE851 /* ListSimpleOverlayView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ListSimpleOverlayView.xib; sourceTree = ""; }; + FE43DAAD26DFAD1C00CFF595 /* CommentContentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentContentTableViewCell.swift; sourceTree = ""; }; + FE43DAAE26DFAD1C00CFF595 /* CommentContentTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CommentContentTableViewCell.xib; sourceTree = ""; }; + FE4DC5A2293A75FC008F322F /* MigrationDeepLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationDeepLinkRouter.swift; sourceTree = ""; }; + FE4DC5A6293A79F1008F322F /* WordPressExportRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressExportRoute.swift; sourceTree = ""; }; + FE59DA9527D1FD0700624D26 /* WordPress 138.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 138.xcdatamodel"; sourceTree = ""; }; + FE6BB142293227AC001E5F7A /* ContentMigrationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMigrationCoordinator.swift; sourceTree = ""; }; + FE6BB1452932289B001E5F7A /* ContentMigrationCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentMigrationCoordinatorTests.swift; sourceTree = ""; }; + FE7FAABA299A36570032A6F2 /* WPComJetpackRemoteInstallViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPComJetpackRemoteInstallViewModelTests.swift; sourceTree = ""; }; + FE7FAABC299A98B90032A6F2 /* EventTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTracker.swift; sourceTree = ""; }; + FE97BC13274FCE7A00CF08F9 /* WordPress 137.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 137.xcdatamodel"; sourceTree = ""; }; + FE9CC71926D7A2A40026AEF3 /* CommentDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentDetailViewController.swift; sourceTree = ""; }; + FEA088002696E7F600193358 /* ListTableHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTableHeaderView.swift; sourceTree = ""; }; + FEA088022696E81F00193358 /* ListTableHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ListTableHeaderView.xib; sourceTree = ""; }; + FEA088042696F7AA00193358 /* WPStyleGuide+List.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+List.swift"; sourceTree = ""; }; + FEA1123B29944896008097B0 /* SelfHostedJetpackRemoteInstallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfHostedJetpackRemoteInstallViewModel.swift; sourceTree = ""; }; + FEA1123E29964BCA008097B0 /* WPComJetpackRemoteInstallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WPComJetpackRemoteInstallViewModel.swift; sourceTree = ""; }; + FEA312832987FB0100FFD405 /* BlogJetpackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlogJetpackTests.swift; sourceTree = ""; }; + FEA6517A281C491C002EA086 /* BloggingPromptsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptsService.swift; sourceTree = ""; }; + FEA7948C26DD136700CEC520 /* CommentHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentHeaderTableViewCell.swift; sourceTree = ""; }; + FEA7948F26DF7F3700CEC520 /* WPStyleGuide+CommentDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPStyleGuide+CommentDetail.swift"; sourceTree = ""; }; + FEAA6F78298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginJetpackProxyServiceTests.swift; sourceTree = ""; }; + FEAC916D28001FC4005026E7 /* AvatarTrainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarTrainView.swift; sourceTree = ""; }; + FEB7A8922718852A00A8CF85 /* WordPress 134.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 134.xcdatamodel"; sourceTree = ""; }; + FEC2602F283FBA1A003D886A /* BloggingPromptCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptCoordinator.swift; sourceTree = ""; }; + FEC26032283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RootViewCoordinator+BloggingPrompt.swift"; sourceTree = ""; }; + FECA442E28350B7800D01F15 /* PromptRemindersScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptRemindersScheduler.swift; sourceTree = ""; }; + FECA44312836647100D01F15 /* PromptRemindersSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptRemindersSchedulerTests.swift; sourceTree = ""; }; + FED65D78293511E4008071BF /* SharedDataIssueSolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedDataIssueSolver.swift; sourceTree = ""; }; + FED77257298BC5B300C2346E /* PluginJetpackProxyService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginJetpackProxyService.swift; sourceTree = ""; }; + FEDA1AD7269D475D0038EC98 /* ListTableViewCell+Comments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ListTableViewCell+Comments.swift"; sourceTree = ""; }; + FEDDD46E26A03DE900F8942B /* ListTableViewCell+Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ListTableViewCell+Notifications.swift"; sourceTree = ""; }; + FEF4DC5428439357003806BE /* ReminderScheduleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderScheduleCoordinator.swift; sourceTree = ""; }; + FEFA263D26C58427009CCB7E /* ShareAppTextActivityItemSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAppTextActivityItemSourceTests.swift; sourceTree = ""; }; + FEFC0F872730510F001F7F1D /* WordPress 136.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 136.xcdatamodel"; sourceTree = ""; }; + FEFC0F882731182C001F7F1D /* CommentService+Replies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentService+Replies.swift"; sourceTree = ""; }; + FEFC0F8B273131A6001F7F1D /* CommentService+RepliesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CommentService+RepliesTests.swift"; sourceTree = ""; }; + FEFC0F8D27313DCF001F7F1D /* comments-v2-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "comments-v2-success.json"; sourceTree = ""; }; + FEFC0F8F27315634001F7F1D /* empty-array.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "empty-array.json"; sourceTree = ""; }; FF00889A204DF3ED007CCE66 /* Blog+Quota.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Blog+Quota.swift"; sourceTree = ""; }; FF00889C204DFF77007CCE66 /* MediaQuotaCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MediaQuotaCell.xib; sourceTree = ""; }; FF00889E204E01AE007CCE66 /* MediaQuotaCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaQuotaCell.swift; sourceTree = ""; }; @@ -4671,7 +9278,7 @@ FF0AAE0B1A16550D0089841D /* WPMediaProgressTableViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPMediaProgressTableViewController.h; sourceTree = ""; }; FF0B2566237A023C004E255F /* GutenbergVideoUploadProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergVideoUploadProcessorTests.swift; sourceTree = ""; }; FF0D8145205809C8000EE505 /* PostCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PostCoordinator.swift; sourceTree = ""; }; - FF0F722B206E5345000DAAB5 /* PostService+RefreshStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PostService+RefreshStatus.swift"; sourceTree = ""; }; + FF0F722B206E5345000DAAB5 /* Post+RefreshStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Post+RefreshStatus.swift"; sourceTree = ""; }; FF1933FD1BB17DA3006825B8 /* RelatedPostsPreviewTableViewCell.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RelatedPostsPreviewTableViewCell.h; sourceTree = ""; }; FF1933FE1BB17DA3006825B8 /* RelatedPostsPreviewTableViewCell.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RelatedPostsPreviewTableViewCell.m; sourceTree = ""; }; FF1B11E4238FDFE70038B93E /* GutenbergGalleryUploadProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GutenbergGalleryUploadProcessor.swift; sourceTree = ""; }; @@ -4681,11 +9288,9 @@ FF254D62202B15F300F01DA7 /* WordPress 72.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 72.xcdatamodel"; sourceTree = ""; }; FF27168F1CAAC87A0006E2D4 /* WordPressUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WordPressUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; FF2716911CAAC87B0006E2D4 /* LoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginTests.swift; sourceTree = ""; }; - FF2716931CAAC87B0006E2D4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + FF2716931CAAC87B0006E2D4 /* WordPressUITests-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "WordPressUITests-Info.plist"; sourceTree = ""; }; FF2716A01CABC7D40006E2D4 /* XCTest+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCTest+Extensions.swift"; sourceTree = ""; }; FF286C751DE70A4500383A62 /* NSURL+Exporters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSURL+Exporters.swift"; sourceTree = ""; }; - FF28B3EF1AEB251200E11AAE /* InfoPListTranslator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = InfoPListTranslator.h; sourceTree = ""; }; - FF28B3F01AEB251200E11AAE /* InfoPListTranslator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = InfoPListTranslator.m; sourceTree = ""; }; FF2EC3BF2209A144006176E1 /* GutenbergImgUploadProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergImgUploadProcessor.swift; sourceTree = ""; }; FF2EC3C12209AC19006176E1 /* GutenbergImgUploadProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = GutenbergImgUploadProcessorTests.swift; path = Gutenberg/GutenbergImgUploadProcessorTests.swift; sourceTree = ""; }; FF355D971FB492DD00244E6D /* ExportableAsset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportableAsset.swift; sourceTree = ""; }; @@ -4693,6 +9298,7 @@ FF42584E1BA092EE00580C68 /* RelatedPostsSettingsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RelatedPostsSettingsViewController.h; sourceTree = ""; }; FF42584F1BA092EE00580C68 /* RelatedPostsSettingsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RelatedPostsSettingsViewController.m; sourceTree = ""; }; FF4C069E206560E500E0B2BC /* MediaThumbnailCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaThumbnailCoordinator.swift; sourceTree = ""; }; + FF4DEAD7244B56E200ACA032 /* CoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreServices.framework; path = System/Library/Frameworks/CoreServices.framework; sourceTree = SDKROOT; }; FF5371621FDFF64F00619A3F /* MediaService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaService.swift; sourceTree = ""; }; FF54D4631D6F3FA900A0DC4D /* GutenbergSettings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GutenbergSettings.swift; sourceTree = ""; }; FF619DD41C75246900903B65 /* CLPlacemark+Formatting.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CLPlacemark+Formatting.swift"; sourceTree = ""; }; @@ -4713,7 +9319,6 @@ FF945F6F1B28242300FB8AC4 /* MediaLibraryPickerDataSource.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MediaLibraryPickerDataSource.m; sourceTree = ""; }; FF947A8C1BBE89A100B27B6A /* WordPress 39.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 39.xcdatamodel"; sourceTree = ""; }; FF9A6E7021F9361700D36D14 /* MediaUploadHashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MediaUploadHashTests.swift; path = Gutenberg/MediaUploadHashTests.swift; sourceTree = ""; }; - FF9C81C32375BA8100DC4B2F /* GutenbergBlockProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergBlockProcessor.swift; sourceTree = ""; }; FFA0B7D61CAC1F9F00533B9D /* MainNavigationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainNavigationTests.swift; sourceTree = ""; }; FFA162301CB7031A00E2E110 /* AppSettingsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppSettingsViewController.swift; sourceTree = ""; }; FFA40D4D1CB3EDD5001CB1FB /* WordPress 48.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 48.xcdatamodel"; sourceTree = ""; }; @@ -4722,16 +9327,13 @@ FFB1FA9D1BF0EB840090C761 /* UIImage+Exporters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Exporters.swift"; sourceTree = ""; }; FFB1FA9F1BF0EC4E0090C761 /* PHAsset+Exporters.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "PHAsset+Exporters.swift"; sourceTree = ""; }; FFB3132E204D59F400C177E7 /* WordPress 73.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 73.xcdatamodel"; sourceTree = ""; }; - FFB7B81D1A0012E80032E723 /* ApiCredentials.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ApiCredentials.m; sourceTree = ""; }; FFC02B82222687BF00E64FDE /* GutenbergImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergImageLoader.swift; sourceTree = ""; }; - FFC245D91D8C2033007F7518 /* wpcom_app_credentials-example */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "wpcom_app_credentials-example"; sourceTree = ""; }; - FFC6ADD21B56F295002F3C84 /* AboutViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = ""; }; - FFC6ADD31B56F295002F3C84 /* AboutViewController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AboutViewController.xib; sourceTree = ""; }; FFC6ADD91B56F366002F3C84 /* LocalCoreDataService.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = LocalCoreDataService.m; sourceTree = ""; }; FFC6ADE11B56F58B002F3C84 /* WordPress 36.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 36.xcdatamodel"; sourceTree = ""; }; FFCB9F4922A125BD0080A45F /* WPException.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WPException.h; sourceTree = ""; }; FFCB9F4A22A125BD0080A45F /* WPException.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = WPException.m; sourceTree = ""; }; FFD12D5D1FE1998D00F20A00 /* Progress+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Progress+Helpers.swift"; sourceTree = ""; }; + FFD47BDE2474228C00F00660 /* FeaturedImageScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedImageScreen.swift; sourceTree = ""; }; FFDA7E4E1B8DF6E500B83C56 /* BlogSiteVisibilityHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlogSiteVisibilityHelper.h; sourceTree = ""; }; FFDA7E4F1B8DF6E500B83C56 /* BlogSiteVisibilityHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = BlogSiteVisibilityHelper.m; sourceTree = ""; }; FFE3B2C51B2E651400E9F1E0 /* WPAndDeviceMediaLibraryDataSource.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WPAndDeviceMediaLibraryDataSource.h; sourceTree = ""; }; @@ -4755,10 +9357,29 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 0107E0DD28F97D5000DE87DB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0107E0DE28F97D5000DE87DB /* SwiftUI.framework in Frameworks */, + 0107E0DF28F97D5000DE87DB /* WidgetKit.framework in Frameworks */, + 35BBACD2917117A95B6F3046 /* Pods_JetpackStatsWidgets.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0107E14A28FE9DB200DE87DB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C649C66318E8B5EF92B8F196 /* Pods_JetpackIntents.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 1D60588F0D05DD3D006BFB54 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F111B88D2658103C00057942 /* Combine.framework in Frameworks */, 7E21C761202BBC8E00837CF5 /* iAd.framework in Frameworks */, 8298F3921EEF3BA7008EB7F0 /* StoreKit.framework in Frameworks */, 93F2E5441E9E5A570050D489 /* CoreSpotlight.framework in Frameworks */, @@ -4768,8 +9389,8 @@ FF75933B1BE2423800814D3B /* Photos.framework in Frameworks */, 93E5285619A77BAC003A1A9C /* NotificationCenter.framework in Frameworks */, 93A3F7DE1843F6F00082FEEA /* CoreTelephony.framework in Frameworks */, - 8355D67E11D13EAD00A61362 /* MobileCoreServices.framework in Frameworks */, A01C542E0E24E88400D411F2 /* SystemConfiguration.framework in Frameworks */, + 3F411B6F28987E3F002513AE /* Lottie in Frameworks */, 374CB16215B93C0800DD0EBC /* AudioToolbox.framework in Frameworks */, E10B3655158F2D7800419A93 /* CoreGraphics.framework in Frameworks */, E10B3654158F2D4500419A93 /* UIKit.framework in Frameworks */, @@ -4781,17 +9402,40 @@ 296890780FE971DC00770264 /* Security.framework in Frameworks */, 83F3E26011275E07004CD686 /* MapKit.framework in Frameworks */, 83F3E2D311276371004CD686 /* CoreLocation.framework in Frameworks */, + 24CE2EB1258D687A0000C297 /* WordPressFlux in Frameworks */, 8355D7D911D260AA00A61362 /* CoreData.framework in Frameworks */, 834CE7341256D0DE0046A4A3 /* CFNetwork.framework in Frameworks */, - 835E2403126E66E50085940B /* AssetsLibrary.framework in Frameworks */, 83043E55126FA31400EC9953 /* MessageUI.framework in Frameworks */, FD3D6D2C1349F5D30061136A /* ImageIO.framework in Frameworks */, B5AA54D51A8E7510003BDD12 /* WebKit.framework in Frameworks */, - E19FED0A1FBD85F300D77FAB /* WordPressFlux.framework in Frameworks */, 93F2E5401E9E5A180050D489 /* libsqlite3.tbd in Frameworks */, FD21397F13128C5300099582 /* libiconv.dylib in Frameworks */, + 3F2B62DC284F4E0B0008CD59 /* Charts in Frameworks */, E19DF741141F7BDD000002F3 /* libz.dylib in Frameworks */, - 1D36FCB53C05724865D41F7A /* Pods_WordPress.framework in Frameworks */, + 17A8858D2757B97F0071FCA3 /* AutomatticAbout in Frameworks */, + FF4DEAD8244B56E300ACA032 /* CoreServices.framework in Frameworks */, + A1C54EBE8C34FFD5015F8FC9 /* Pods_Apps_WordPress.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3F526C492538CF2A0069706C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F526C502538CF2A0069706C /* SwiftUI.framework in Frameworks */, + 3F526C4E2538CF2A0069706C /* WidgetKit.framework in Frameworks */, + EB6DF027AF96D801F280E805 /* Pods_WordPressStatsWidgets.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FA640542670CCD40064401E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3FA640662670CEFF0064401E /* XCTest.framework in Frameworks */, + 3F338B73289BD5970014ADC5 /* Nimble in Frameworks */, + 3FC2C33D26C4CF0A00C6D98F /* XCUITestHelpers in Frameworks */, + 3F95FF4026C4F385007731D3 /* ScreenObject in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4801,7 +9445,6 @@ files = ( 733F36062126197800988727 /* UserNotificationsUI.framework in Frameworks */, 733F36042126197800988727 /* UserNotifications.framework in Frameworks */, - 04936A8B3D936CD1FC4AB1EF /* Pods_WordPressNotificationContentExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4809,7 +9452,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 660A036E6EE9D353F2712081 /* Pods_WordPressNotificationServiceExtension.framework in Frameworks */, + DF6D9E10C4CEE05331B4DAE5 /* Pods_WordPressNotificationServiceExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4817,7 +9460,31 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 6867C0633E597372235CB5E0 /* Pods_WordPressDraftActionExtension.framework in Frameworks */, + A2C95CCF203760D9372C5857 /* Pods_WordPressDraftActionExtension.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8096211428E540D700940A5D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4BB2296498BE66D515E3D610 /* Pods_JetpackShareExtension.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8096217528E55C9400940A5D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 223EA61E212A7C26A456C32C /* Pods_JetpackDraftActionExtension.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 80F6D04A28EE866A00953C1A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DCAD9FCC94B311DCE8988D91 /* Pods_JetpackNotificationServiceExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4825,6 +9492,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3FA640622670CE260064401E /* UITestsFoundation.framework in Frameworks */, 2611CC62A62F9E6BC25350FE /* Pods_WordPressScreenshotGeneration.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -4833,67 +9501,84 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4C8A715EBCE7E73AEE216293 /* Pods_WordPressShareExtension.framework in Frameworks */, + D0E2AA7C4D4CB1679173958E /* Pods_WordPressShareExtension.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E16AB92614D978240047A2E5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F338B71289BD3040014ADC5 /* Nimble in Frameworks */, + 3F3B23C22858A1B300CACE60 /* BuildkiteTestCollector in Frameworks */, + E8DEE110E4BC3FA1974AB1BB /* Pods_WordPressTest.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 93E5283719A7741A003A1A9C /* Frameworks */ = { + EA14533729AD874C001F3143 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 98E58A442361019300E5534B /* Pods_WordPressTodayWidget.framework in Frameworks */, - 93E5283C19A7741A003A1A9C /* NotificationCenter.framework in Frameworks */, + EA14533829AD874C001F3143 /* UITestsFoundation.framework in Frameworks */, + EA14533929AD874C001F3143 /* BuildkiteTestCollector in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 98A3C2EC239997DA0048D38D /* Frameworks */ = { + F1F163BB25658B4D003DC13B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 98A3C2F0239997DA0048D38D /* NotificationCenter.framework in Frameworks */, - 26D66DEC36ACF7442186B07D /* Pods_WordPressThisWeekWidget.framework in Frameworks */, + F99B8B0DFEA7B43FAB6DEC03 /* Pods_WordPressIntents.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 98D31B8B2396ED7E009CFF43 /* Frameworks */ = { + FABB261F2602FC2C00C8785C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 98D31B8F2396ED7F009CFF43 /* NotificationCenter.framework in Frameworks */, - 638E2F3D4DFE9E93AF660CED /* Pods_WordPressAllTimeWidget.framework in Frameworks */, + FABB26202602FC2C00C8785C /* iAd.framework in Frameworks */, + FABB26212602FC2C00C8785C /* StoreKit.framework in Frameworks */, + FABB26222602FC2C00C8785C /* CoreSpotlight.framework in Frameworks */, + FABB26232602FC2C00C8785C /* QuickLook.framework in Frameworks */, + FABB26242602FC2C00C8785C /* CoreText.framework in Frameworks */, + FABB26252602FC2C00C8785C /* UserNotifications.framework in Frameworks */, + FABB26262602FC2C00C8785C /* Photos.framework in Frameworks */, + FABB26272602FC2C00C8785C /* NotificationCenter.framework in Frameworks */, + FABB26282602FC2C00C8785C /* CoreTelephony.framework in Frameworks */, + FABB26292602FC2C00C8785C /* SystemConfiguration.framework in Frameworks */, + FABB262A2602FC2C00C8785C /* AudioToolbox.framework in Frameworks */, + FABB262B2602FC2C00C8785C /* CoreGraphics.framework in Frameworks */, + FABB262C2602FC2C00C8785C /* UIKit.framework in Frameworks */, + FABB262D2602FC2C00C8785C /* QuartzCore.framework in Frameworks */, + FABB262E2602FC2C00C8785C /* MediaPlayer.framework in Frameworks */, + 3F44DD58289C379C006334CD /* Lottie in Frameworks */, + FABB262F2602FC2C00C8785C /* CoreMedia.framework in Frameworks */, + FABB26302602FC2C00C8785C /* AVFoundation.framework in Frameworks */, + FABB26312602FC2C00C8785C /* Foundation.framework in Frameworks */, + FABB26322602FC2C00C8785C /* Security.framework in Frameworks */, + FABB26332602FC2C00C8785C /* MapKit.framework in Frameworks */, + FABB26342602FC2C00C8785C /* CoreLocation.framework in Frameworks */, + FABB26352602FC2C00C8785C /* WordPressFlux in Frameworks */, + FABB26362602FC2C00C8785C /* CoreData.framework in Frameworks */, + FABB26372602FC2C00C8785C /* CFNetwork.framework in Frameworks */, + FABB26382602FC2C00C8785C /* MessageUI.framework in Frameworks */, + FABB26392602FC2C00C8785C /* ImageIO.framework in Frameworks */, + 17A8858F2757BEC00071FCA3 /* AutomatticAbout in Frameworks */, + FABB263A2602FC2C00C8785C /* WebKit.framework in Frameworks */, + FABB263B2602FC2C00C8785C /* libsqlite3.tbd in Frameworks */, + FABB263C2602FC2C00C8785C /* libiconv.dylib in Frameworks */, + FABB263D2602FC2C00C8785C /* libz.dylib in Frameworks */, + 3F2B62DE284F4E310008CD59 /* Charts in Frameworks */, + FABB263F2602FC2C00C8785C /* CoreServices.framework in Frameworks */, + 9C86CF3E1EAC13181A593D00 /* Pods_Apps_Jetpack.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - E16AB92614D978240047A2E5 /* Frameworks */ = { + FAF64B9A2637DEEC00E8A1DF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E131CB5416CACB05004B0314 /* libxml2.dylib in Frameworks */, - E183EC9D16B2160200C2EB11 /* MobileCoreServices.framework in Frameworks */, - E183EC9C16B215FE00C2EB11 /* SystemConfiguration.framework in Frameworks */, - E131CB5216CACA6B004B0314 /* CoreText.framework in Frameworks */, - E183ECA216B2179B00C2EB11 /* Accounts.framework in Frameworks */, - E183ECA316B2179B00C2EB11 /* AddressBook.framework in Frameworks */, - E183ECA416B2179B00C2EB11 /* AssetsLibrary.framework in Frameworks */, - E183ECA516B2179B00C2EB11 /* AudioToolbox.framework in Frameworks */, - E183ECA616B2179B00C2EB11 /* AVFoundation.framework in Frameworks */, - E183ECA716B2179B00C2EB11 /* CFNetwork.framework in Frameworks */, - E183ECA816B2179B00C2EB11 /* CoreData.framework in Frameworks */, - 00F2E3F8166EEF9800D0527C /* CoreGraphics.framework in Frameworks */, - E183ECA916B2179B00C2EB11 /* CoreLocation.framework in Frameworks */, - E183ECAA16B2179B00C2EB11 /* CoreMedia.framework in Frameworks */, - E16AB92E14D978240047A2E5 /* Foundation.framework in Frameworks */, - E183ECAB16B2179B00C2EB11 /* ImageIO.framework in Frameworks */, - E183ECAC16B2179B00C2EB11 /* libiconv.dylib in Frameworks */, - E183ECAD16B2179B00C2EB11 /* libz.dylib in Frameworks */, - E183ECAE16B2179B00C2EB11 /* MapKit.framework in Frameworks */, - E183ECAF16B2179B00C2EB11 /* MediaPlayer.framework in Frameworks */, - E183ECB016B2179B00C2EB11 /* MessageUI.framework in Frameworks */, - 00F2E3FB166EEFE100D0527C /* QuartzCore.framework in Frameworks */, - E183ECB116B2179B00C2EB11 /* Security.framework in Frameworks */, - E183ECB216B2179B00C2EB11 /* Twitter.framework in Frameworks */, - 00F2E3FA166EEFBE00D0527C /* UIKit.framework in Frameworks */, - E8DEE110E4BC3FA1974AB1BB /* Pods_WordPressTest.framework in Frameworks */, + 3FA640672670D1290064401E /* UITestsFoundation.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4901,13 +9586,69 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3FA640612670CE210064401E /* UITestsFoundation.framework in Frameworks */, + 3F3B23C42858A1D800CACE60 /* BuildkiteTestCollector in Frameworks */, 4B2DD0F29CD6AC353C056D41 /* Pods_WordPressUITests.framework in Frameworks */, + 365FDEB78647AB79DDCC4533 /* Pods_WordPressUITests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0107E0F128FD6A3100DE87DB /* Constants */ = { + isa = PBXGroup; + children = ( + 0107E10928FD6F1F00DE87DB /* Jetpack */, + 2F970F970DF929B8006BD934 /* Constants.h */, + B5CC05F51962150600975CAC /* Constants.m */, + ); + name = Constants; + sourceTree = ""; + }; + 0107E10928FD6F1F00DE87DB /* Jetpack */ = { + isa = PBXGroup; + children = ( + ); + name = Jetpack; + sourceTree = ""; + }; + 0107E1802900043200DE87DB /* JetpackIntents */ = { + isa = PBXGroup; + children = ( + 0107E1812900043200DE87DB /* Info.plist */, + ); + path = JetpackIntents; + sourceTree = ""; + }; + 0107E1822900043200DE87DB /* JetpackStatsWidgets */ = { + isa = PBXGroup; + children = ( + 0107E1832900043200DE87DB /* Info.plist */, + 0107E1842900059300DE87DB /* LocalizationConfiguration.swift */, + ); + path = JetpackStatsWidgets; + sourceTree = ""; + }; + 0118968D29D1EAC900D34BA9 /* Domains */ = { + isa = PBXGroup; + children = ( + DCFC097229D3549C00277ECB /* DashboardDomainsCardCell.swift */, + 0118968E29D1EB5E00D34BA9 /* DomainsDashboardCardHelper.swift */, + 0118969E29D30CAD00D34BA9 /* DomainsDashboardCardTracker.swift */, + 01E61E5929F03DEC002E544E /* DashboardDomainsCardSearchView.swift */, + ); + path = Domains; + sourceTree = ""; + }; + 0141929E2983F5D900CAEDB0 /* Support */ = { + isa = PBXGroup; + children = ( + 0141929F2983F5E800CAEDB0 /* SupportConfigurationTests.swift */, + ); + path = Support; + sourceTree = ""; + }; 027AC51F2278982D0033E56E /* DomainCredit */ = { isa = PBXGroup; children = ( @@ -4916,24 +9657,26 @@ path = DomainCredit; sourceTree = ""; }; - 02BF30512271D76F00616558 /* Domain Credit */ = { + 02BF30512271D76F00616558 /* Domain credit */ = { isa = PBXGroup; children = ( 027AC51C227896540033E56E /* DomainCreditEligibilityChecker.swift */, 02BF30522271D7F000616558 /* DomainCreditRedemptionSuccessViewController.swift */, ); - path = "Domain Credit"; + path = "Domain credit"; sourceTree = ""; }; 031662E60FFB14C60045D052 /* Views */ = { isa = PBXGroup; children = ( + FEA087FB2696DDE900193358 /* List */, B572735C1B66CCEF000D1C4F /* AlertView */, 5D6C4B0D1B604190005E3C43 /* RichTextView */, E62AFB641DC8E56B007484FC /* WPRichText */, 37EAAF4C1A11799A006D6306 /* CircularImageView.swift */, ADF544C0195A0F620092213D /* CustomHighlightButton.h */, ADF544C1195A0F620092213D /* CustomHighlightButton.m */, + FE32EFFE275914390040BE67 /* MenuSheetViewController.swift */, B5B410B51B1772B000CFCF8D /* NavigationTitleView.swift */, 982A4C3420227D6700B5518E /* NoResultsViewController.swift */, 98B33C87202283860071E1E2 /* NoResults.storyboard */, @@ -4951,6 +9694,10 @@ 1702BBDB1CEDEA6B00766A33 /* BadgeLabel.swift */, 177076201EA206C000705A4A /* PlayIconView.swift */, 2F161B0522CC2DC70066A5C5 /* LoadingStatusView.swift */, + 32F2565F25012D3F006B8BC4 /* LinearGradientView.swift */, + F18CB8952642E58700B90794 /* FixedSizeImageView.swift */, + FADFBD25265F580500039C41 /* MultilineButton.swift */, + 808C578E27C7FB1A0099A92C /* ButtonScrollView.swift */, ); path = Views; sourceTree = ""; @@ -4962,6 +9709,7 @@ B587796C19B799D800E57C5A /* Extensions */, 2F706A870DFB229B00B43086 /* Models */, 850BD4531922F95C0032F3AD /* Networking */, + 80EF9288280D27F20064A971 /* PropertyWrappers */, 93FA59DA18D88BDB001446BC /* Services */, E1CA0A6A1FA73039004C4BBE /* Stores */, 8584FDB719243E550019C02E /* System */, @@ -4972,12 +9720,32 @@ path = Classes; sourceTree = ""; }; + 081E4B4A281BFB520085E89C /* Feature Highlight */ = { + isa = PBXGroup; + children = ( + 08D553652821286300AA1E8D /* Tooltip.swift */, + 081E4B4B281C019A0085E89C /* TooltipAnchor.swift */, + 088CC593282BEC41007B9421 /* TooltipPresenter.swift */, + 084A07052848E1820054508A /* FeatureHighlightStore.swift */, + ); + path = "Feature Highlight"; + sourceTree = ""; + }; + 084FC3B529913B0A00A17BCF /* JetpackOverlay */ = { + isa = PBXGroup; + children = ( + 084FC3B629913B1B00A17BCF /* JetpackPluginOverlayViewModelTests.swift */, + ); + name = JetpackOverlay; + sourceTree = ""; + }; 086C4D0C1E81F7920011D960 /* Media */ = { isa = PBXGroup; children = ( 7EDAB3F320B046FE002D1A76 /* CircularProgressView+ActivityIndicatorType.swift */, FF355D971FB492DD00244E6D /* ExportableAsset.swift */, 086C4D0F1E81F9240011D960 /* Media+Blog.swift */, + 4A87854F290F2C7D0083AB78 /* Media+Sync.swift */, FF286C751DE70A4500383A62 /* NSURL+Exporters.swift */, FFB1FA9F1BF0EC4E0090C761 /* PHAsset+Exporters.swift */, FFB1FA9D1BF0EB840090C761 /* UIImage+Exporters.swift */, @@ -4990,6 +9758,7 @@ children = ( 086E1FDE1BBB35D2002D86CA /* MenusViewController.h */, 086E1FDF1BBB35D2002D86CA /* MenusViewController.m */, + C3B554502965C32A00A04753 /* MenusViewController+JetpackBannerViewController.swift */, 08216FA81CDBF95100304BA7 /* MenuItemEditingViewController.h */, 08216FA91CDBF95100304BA7 /* MenuItemEditingViewController.m */, 08D345541CD7FBA900358E8C /* MenuHeaderViewController.h */, @@ -5057,6 +9826,15 @@ name = Views; sourceTree = ""; }; + 08A250FA28D9EDF600F50420 /* CommentDetailInfo */ = { + isa = PBXGroup; + children = ( + 08A250F728D9E87600F50420 /* CommentDetailInfoViewController.swift */, + 08A250FB28D9F0E200F50420 /* CommentDetailInfoViewModel.swift */, + ); + name = CommentDetailInfo; + sourceTree = ""; + }; 08AFB8351CE1300500000E1C /* Categories */ = { isa = PBXGroup; children = ( @@ -5090,9 +9868,21 @@ path = Menus; sourceTree = ""; }; + 08EA036529C9B50500B72A87 /* DesignSystem */ = { + isa = PBXGroup; + children = ( + 08EA036629C9B51200B72A87 /* Color+DesignSystem.swift */, + 08EA036829C9B53000B72A87 /* Colors.xcassets */, + 088D58A429E724F300E6C0F4 /* ColorGallery.swift */, + 0880BADB29ED6FF3002D3AB0 /* UIColor+DesignSystem.swift */, + ); + path = DesignSystem; + sourceTree = ""; + }; 08F8CD281EBD22EF0049D0C0 /* Media */ = { isa = PBXGroup; children = ( + 3F2ABE192770EF3E005D8916 /* Blog+VideoLimits.swift */, 08B6E5191F036CAD00268F57 /* MediaFileManager.swift */, 08F8CD291EBD22EF0049D0C0 /* MediaExporter.swift */, 08C388651ED7705E0057BE49 /* MediaAssetExporter.swift */, @@ -5104,6 +9894,8 @@ 7E729C27209A087200F76599 /* ImageLoader.swift */, 742B7F39209CB2B6002E3CC9 /* GIFPlaybackStrategy.swift */, B5EB19EB20C6DACC008372B9 /* ImageDownloader.swift */, + 3F2ABE15277037A9005D8916 /* VideoLimitsAlertPresenter.swift */, + 3F2ABE1727704EE2005D8916 /* WPMediaAsset+VideoLimits.swift */, ); path = Media; sourceTree = ""; @@ -5117,326 +9909,507 @@ FF8CD624214184EE00A33A8D /* MediaAssetExporterTests.swift */, 08F8CD3A1EBD2D020049D0C0 /* MediaURLExporterTests.swift */, 08E77F461EE9D72F006F9515 /* MediaThumbnailExporterTests.swift */, + DCC662502810915D00962D0C /* BlogVideoLimitsTests.swift */, ); name = Media; sourceTree = ""; }; - 1719633E1D378D1E00898E8B /* Search */ = { + 0A9687BA28B4075A009DCD2F /* Mocks */ = { isa = PBXGroup; children = ( - 1719633F1D378D5100898E8B /* SearchWrapperView.swift */, + 0A9687BB28B40771009DCD2F /* FullScreenCommentReplyViewModelMock.swift */, ); - path = Search; + path = Mocks; sourceTree = ""; }; - 173BCE711CEB365400AE8817 /* Domains */ = { + 0CB4056F29C8DCD7008EED0A /* BlogPersonalization */ = { isa = PBXGroup; children = ( - 402FFB1B218C27C100FF4A0B /* RegisterDomain.storyboard */, - 43D74AD220FB5A82004AD934 /* Views */, - 437EF0C820F79C0C0086129B /* Register */, - 173BCE721CEB368A00AE8817 /* DomainsListViewController.swift */, - 173BCE741CEB369900AE8817 /* Domains.storyboard */, + 0CB4057229C8DD01008EED0A /* BlogDashboardPersonalizationView.swift */, + 0CB4057029C8DCF4008EED0A /* BlogDashboardPersonalizationViewModel.swift */, ); - path = Domains; + path = BlogPersonalization; sourceTree = ""; }; - 1759F17E1FE145F90003EC81 /* Notices */ = { + 1705CEE6260A57F900F23763 /* ContentViews */ = { isa = PBXGroup; children = ( - 172E27D21FD98135003EA321 /* NoticePresenter.swift */, - 1759F17F1FE1460C0003EC81 /* NoticeView.swift */, - 430D50BD212B7AAE008F15F4 /* NoticeStyle.swift */, - 4353BFB121A376BF0009CED3 /* UntouchableWindow.swift */, + 3F8D989C26153491003619E5 /* Background */, + 3FC7F89C261233FC00FD8728 /* Stats */, + 3F851413260D0A1D00A4B938 /* Editor */, + 3F8513DD260D090000A4B938 /* Components */, + 3F810A592616870C00ADDCC2 /* UnifiedPrologueIntroContentView.swift */, ); - path = Notices; + path = ContentViews; sourceTree = ""; }; - 177B4C232123152D00CF8084 /* Giphy */ = { + 1716AEFA25F2926C00CF49EC /* My Site */ = { isa = PBXGroup; children = ( - 177B4C242123161900CF8084 /* GiphyPicker.swift */, - 173F6DFD212352D000A4C8E2 /* GiphyService.swift */, - 177B4C3021236F0F00CF8084 /* GiphyDataLoader.swift */, - 177B4C3221236F5C00CF8084 /* GiphyPageable.swift */, - 177B4C342123706400CF8084 /* GiphyResultsPage.swift */, - 177B4C26212316DC00CF8084 /* GiphyStrings.swift */, - 177B4C282123181400CF8084 /* GiphyDataSource.swift */, - 177B4C2A2123185300CF8084 /* GiphyMediaGroup.swift */, - 177B4C2C2123192200CF8084 /* GiphyMedia.swift */, - 173F6DFB21232F2A00A4C8E2 /* NoResultsGiphyConfiguration.swift */, + 1716AEFB25F2927600CF49EC /* MySiteViewController.swift */, + FAA9084B27BD60710093FFA8 /* MySiteViewController+QuickStart.swift */, + F56A33322538C0ED00E2AEF3 /* MySiteViewController+FAB.swift */, + FA4F383527D766020068AAF5 /* MySiteSettings.swift */, + C7B7CC6F2812FDCE007B9807 /* MySiteViewController+OnboardingPrompt.swift */, ); - path = Giphy; + path = "My Site"; sourceTree = ""; }; - 1797373920EBDA1100377B4E /* Universal Links */ = { + 1717139D265FE56A00F3A022 /* Reusable SwiftUI Views */ = { isa = PBXGroup; children = ( - 17B7C89F20EC1D6A0042E260 /* Route.swift */, - 17BD4A0720F76A4700975AC3 /* Routes+Banners.swift */, - F1655B4722A6C2FA00227BFB /* Routes+Mbar.swift */, - 17F0E1D920EBDC0A001E9514 /* Routes+Me.swift */, - 17E24F5320FCF1D900BD70A3 /* Routes+MySites.swift */, - 17B7C8C020EE2A870042E260 /* Routes+Notifications.swift */, - 1703D04B20ECD93800D292E9 /* Routes+Post.swift */, - 17A4A36820EE51870071C2CA /* Routes+Reader.swift */, - 1715179120F4B2EB002C4A38 /* Routes+Stats.swift */, - 17BD4A182101D31B00975AC3 /* NavigationActionHelpers.swift */, - 1714F8CF20E6DA8900226DCB /* RouteMatcher.swift */, - 17B7C89D20EC1D0D0042E260 /* UniversalLinkRouter.swift */, + 1717139E265FE59700F3A022 /* ButtonStyles.swift */, ); - path = "Universal Links"; + path = "Reusable SwiftUI Views"; sourceTree = ""; }; - 17A4A36A20EE551F0071C2CA /* Coordinators */ = { + 1719633E1D378D1E00898E8B /* Search */ = { isa = PBXGroup; children = ( - 17A4A36B20EE55320071C2CA /* ReaderCoordinator.swift */, - 1715179320F4B5CD002C4A38 /* MySitesCoordinator.swift */, + 1719633F1D378D5100898E8B /* SearchWrapperView.swift */, ); - path = Coordinators; + path = Search; sourceTree = ""; }; - 17D4153D22C24B67006378EF /* Icons */ = { + 17222D44261DDDF10047B163 /* celadon-classic */ = { isa = PBXGroup; children = ( - 17E363C922C422AE000E0C79 /* WordPress */, - 17E363C522C42264000E0C79 /* Pride */, - 17E363C622C42275000E0C79 /* Hot Pink */, - 17E363C722C4228D000E0C79 /* WordPress Dark */, - 17E363C822C4229D000E0C79 /* Jetpack Green */, - 17DC4C1E22C5E6910059CA11 /* Open Source Dark */, - 17DC4C3D22C5E6B00059CA11 /* Open Source */, + 17222D45261DDDF10047B163 /* celadon-classic-icon-app-76x76.png */, + 17222D46261DDDF10047B163 /* celadon-classic-icon-app-76x76@2x.png */, + 17222D47261DDDF10047B163 /* celadon-classic-icon-app-83.5x83.5@2x.png */, + 17222D48261DDDF10047B163 /* celadon-classic-icon-app-60x60@2x.png */, + 17222D49261DDDF10047B163 /* celadon-classic-icon-app-60x60@3x.png */, ); - name = Icons; - path = Resources/Icons; + path = "celadon-classic"; sourceTree = ""; }; - 17D433581EFD2ED500CAB602 /* Fancy Alerts */ = { + 17222D4A261DDDF30047B163 /* celadon */ = { isa = PBXGroup; children = ( - 405B53FA1F83C369002E19BF /* FancyAlerts+VerificationPrompt.swift */, - 171909E3206CFFCD0054DF0B /* FancyAlertViewController+Async.swift */, - 176BB87E20D0068500751DCE /* FancyAlertViewController+SavedPosts.swift */, - 4319AADD2090F00C0025D68E /* FancyAlertViewController+NotificationPrimer.swift */, - 43290CF3214F755400F6B398 /* FancyAlertViewController+QuickStart.swift */, + 17222D4B261DDDF30047B163 /* celadon-icon-app-83.5x83.5@2x.png */, + 17222D4C261DDDF30047B163 /* celadon-icon-app-76x76.png */, + 17222D4D261DDDF30047B163 /* celadon-icon-app-76x76@2x.png */, + 17222D4E261DDDF30047B163 /* celadon-icon-app-60x60@2x.png */, + 17222D4F261DDDF30047B163 /* celadon-icon-app-60x60@3x.png */, ); - name = "Fancy Alerts"; + path = celadon; sourceTree = ""; }; - 17DC4C1E22C5E6910059CA11 /* Open Source Dark */ = { - isa = PBXGroup; - children = ( - 17DC4C2122C5E6910059CA11 /* open_source_dark_icon_20pt.png */, - 17DC4C1F22C5E6910059CA11 /* open_source_dark_icon_20pt@2x.png */, - 17DC4C2322C5E6910059CA11 /* open_source_dark_icon_20pt@3x.png */, - 17DC4C2622C5E6910059CA11 /* open_source_dark_icon_29pt.png */, - 17DC4C2A22C5E6910059CA11 /* open_source_dark_icon_29pt@2x.png */, - 17DC4C2822C5E6910059CA11 /* open_source_dark_icon_29pt@3x.png */, - 17DC4C2422C5E6910059CA11 /* open_source_dark_icon_40pt.png */, - 17DC4C2222C5E6910059CA11 /* open_source_dark_icon_40pt@2x.png */, - 17DC4C2022C5E6910059CA11 /* open_source_dark_icon_40pt@3x.png */, - 17DC4C2B22C5E6910059CA11 /* open_source_dark_icon_60pt@2x.png */, - 17DC4C2722C5E6910059CA11 /* open_source_dark_icon_60pt@3x.png */, - 17DC4C2C22C5E6910059CA11 /* open_source_dark_icon_76pt.png */, - 17DC4C2522C5E6910059CA11 /* open_source_dark_icon_76pt@2x.png */, - 17DC4C2922C5E6910059CA11 /* open_source_dark_icon_83.5@2x.png */, - ); - path = "Open Source Dark"; - sourceTree = ""; - }; - 17DC4C3D22C5E6B00059CA11 /* Open Source */ = { - isa = PBXGroup; - children = ( - 17DC4C3F22C5E6D30059CA11 /* open_source_icon_20pt.png */, - 17DC4C4022C5E6D30059CA11 /* open_source_icon_20pt@2x.png */, - 17DC4C4922C5E6D50059CA11 /* open_source_icon_20pt@3x.png */, - 17DC4C4C22C5E6D60059CA11 /* open_source_icon_29pt.png */, - 17DC4C4322C5E6D40059CA11 /* open_source_icon_29pt@2x.png */, - 17DC4C4722C5E6D50059CA11 /* open_source_icon_29pt@3x.png */, - 17DC4C4122C5E6D40059CA11 /* open_source_icon_40pt.png */, - 17DC4C4622C5E6D50059CA11 /* open_source_icon_40pt@2x.png */, - 17DC4C4A22C5E6D60059CA11 /* open_source_icon_40pt@3x.png */, - 17DC4C4822C5E6D50059CA11 /* open_source_icon_60pt@2x.png */, - 17DC4C4422C5E6D40059CA11 /* open_source_icon_60pt@3x.png */, - 17DC4C4B22C5E6D60059CA11 /* open_source_icon_76pt.png */, - 17DC4C3E22C5E6D30059CA11 /* open_source_icon_76pt@2x.png */, - 17DC4C4522C5E6D50059CA11 /* open_source_icon_83.5@2x.png */, + 17222D50261DDDF40047B163 /* black-classic */ = { + isa = PBXGroup; + children = ( + 17222D51261DDDF40047B163 /* black-classic-icon-app-76x76@2x.png */, + 17222D52261DDDF40047B163 /* black-classic-icon-app-60x60@3x.png */, + 17222D53261DDDF40047B163 /* black-classic-icon-app-83.5x83.5@2x.png */, + 17222D54261DDDF40047B163 /* black-classic-icon-app-60x60@2x.png */, + 17222D55261DDDF40047B163 /* black-classic-icon-app-76x76.png */, ); - path = "Open Source"; + path = "black-classic"; sourceTree = ""; }; - 17E363C522C42264000E0C79 /* Pride */ = { + 17222D56261DDDF40047B163 /* blue-classic */ = { isa = PBXGroup; children = ( - 17E363CE22C42808000E0C79 /* pride_icon_20pt.png */, - 17E363CD22C42808000E0C79 /* pride_icon_20pt@2x.png */, - 17E363D722C42809000E0C79 /* pride_icon_20pt@3x.png */, - 17E363D822C42809000E0C79 /* pride_icon_29pt.png */, - 17E363CF22C42808000E0C79 /* pride_icon_29pt@2x.png */, - 17E363D422C42809000E0C79 /* pride_icon_29pt@3x.png */, - 17E363D622C42809000E0C79 /* pride_icon_40pt.png */, - 17E363D522C42809000E0C79 /* pride_icon_40pt@2x.png */, - 17E363CB22C42807000E0C79 /* pride_icon_40pt@3x.png */, - 17E363D022C42808000E0C79 /* pride_icon_60pt.png */, - 17E363CC22C42807000E0C79 /* pride_icon_60pt@2x.png */, - 17E363D922C4280A000E0C79 /* pride_icon_60pt@3x.png */, - 17E363D222C42808000E0C79 /* pride_icon_76pt.png */, - 17E363D122C42808000E0C79 /* pride_icon_76pt@2x.png */, - 17E363D322C42808000E0C79 /* pride_icon_83.5@2x.png */, + 17222D57261DDDF40047B163 /* blue-classic-icon-app-60x60@3x.png */, + 17222D58261DDDF40047B163 /* blue-classic-icon-app-60x60@2x.png */, + 17222D59261DDDF40047B163 /* blue-classic-icon-app-76x76@2x.png */, + 17222D5A261DDDF40047B163 /* blue-classic-icon-app-83.5x83.5@2x.png */, + 17222D5B261DDDF40047B163 /* blue-classic-icon-app-76x76.png */, ); - path = Pride; + path = "blue-classic"; sourceTree = ""; }; - 17E363C622C42275000E0C79 /* Hot Pink */ = { + 17222D5C261DDDF50047B163 /* pink */ = { isa = PBXGroup; children = ( - 17E3639022C41DB4000E0C79 /* hot_pink_icon_20pt.png */, - 17E3638C22C41DB1000E0C79 /* hot_pink_icon_20pt@2x.png */, - 17E3638B22C41DB1000E0C79 /* hot_pink_icon_20pt@3x.png */, - 17E3639322C41DB7000E0C79 /* hot_pink_icon_29pt.png */, - 17E3638822C41DB0000E0C79 /* hot_pink_icon_29pt@2x.png */, - 17E3639222C41DB6000E0C79 /* hot_pink_icon_29pt@3x.png */, - 17E3638D22C41DB2000E0C79 /* hot_pink_icon_40pt.png */, - 17E3639522C41DB9000E0C79 /* hot_pink_icon_40pt@2x.png */, - 17E3638E22C41DB3000E0C79 /* hot_pink_icon_40pt@3x.png */, - 17E3639122C41DB5000E0C79 /* hot_pink_icon_60pt@2x.png */, - 17E3638922C41DB0000E0C79 /* hot_pink_icon_60pt@3x.png */, - 17E3638A22C41DB1000E0C79 /* hot_pink_icon_76pt.png */, - 17E3638F22C41DB3000E0C79 /* hot_pink_icon_76pt@2x.png */, - 17E3639422C41DB8000E0C79 /* hot_pink_icon_83.5@2x.png */, + 17222D5D261DDDF50047B163 /* pink-icon-app-76x76@2x.png */, + 17222D5E261DDDF50047B163 /* pink-icon-app-76x76.png */, + 17222D5F261DDDF50047B163 /* pink-icon-app-60x60@3x.png */, + 17222D60261DDDF50047B163 /* pink-icon-app-60x60@2x.png */, + 17222D61261DDDF50047B163 /* pink-icon-app-83.5x83.5@2x.png */, ); - path = "Hot Pink"; + path = pink; sourceTree = ""; }; - 17E363C722C4228D000E0C79 /* WordPress Dark */ = { + 17222D62261DDDF50047B163 /* black */ = { isa = PBXGroup; children = ( - 17E362F322C41722000E0C79 /* wordpress_dark_icon_20pt.png */, - 17E362F122C41721000E0C79 /* wordpress_dark_icon_20pt@2x.png */, - 17E362FD22C41725000E0C79 /* wordpress_dark_icon_20pt@3x.png */, - 17E362FA22C41724000E0C79 /* wordpress_dark_icon_29pt.png */, - 17E362F522C41723000E0C79 /* wordpress_dark_icon_29pt@2x.png */, - 17E362F922C41724000E0C79 /* wordpress_dark_icon_29pt@3x.png */, - 17E362FC22C41724000E0C79 /* wordpress_dark_icon_40pt.png */, - 17E362FB22C41724000E0C79 /* wordpress_dark_icon_40pt@2x.png */, - 17E362F222C41722000E0C79 /* wordpress_dark_icon_40pt@3x.png */, - 17E362F722C41723000E0C79 /* wordpress_dark_icon_60pt@2x.png */, - 17E362F622C41723000E0C79 /* wordpress_dark_icon_60pt@3x.png */, - 17E362FE22C41725000E0C79 /* wordpress_dark_icon_76pt.png */, - 17E362FF22C41725000E0C79 /* wordpress_dark_icon_76pt@2x.png */, - 17E362F422C41722000E0C79 /* wordpress_dark_icon_83.5@2x.png */, + 17222D63261DDDF50047B163 /* black-icon-app-60x60@2x.png */, + 17222D64261DDDF50047B163 /* black-icon-app-60x60@3x.png */, + 17222D65261DDDF50047B163 /* black-icon-app-76x76.png */, + 17222D66261DDDF60047B163 /* black-icon-app-76x76@2x.png */, + 17222D67261DDDF60047B163 /* black-icon-app-83.5x83.5@2x.png */, ); - path = "WordPress Dark"; + path = black; sourceTree = ""; }; - 17E363C822C4229D000E0C79 /* Jetpack Green */ = { + 17222D68261DDDF60047B163 /* pink-classic */ = { isa = PBXGroup; children = ( - 17E3633922C417ED000E0C79 /* jetpack_green_icon_20pt.png */, - 17E3633622C417EB000E0C79 /* jetpack_green_icon_20pt@2x.png */, - 17E3632E22C417E7000E0C79 /* jetpack_green_icon_20pt@3x.png */, - 17E3633122C417E8000E0C79 /* jetpack_green_icon_29pt.png */, - 17E3633422C417EA000E0C79 /* jetpack_green_icon_29pt@2x.png */, - 17E3633022C417E8000E0C79 /* jetpack_green_icon_29pt@3x.png */, - 17E3632D22C417E7000E0C79 /* jetpack_green_icon_40pt.png */, - 17E3632F22C417E7000E0C79 /* jetpack_green_icon_40pt@2x.png */, - 17E3633A22C417EE000E0C79 /* jetpack_green_icon_40pt@3x.png */, - 17E3633B22C417EF000E0C79 /* jetpack_green_icon_60pt@2x.png */, - 17E3633522C417EB000E0C79 /* jetpack_green_icon_60pt@3x.png */, - 17E3633822C417EC000E0C79 /* jetpack_green_icon_76pt.png */, - 17E3633722C417EC000E0C79 /* jetpack_green_icon_76pt@2x.png */, - 17E3633322C417E9000E0C79 /* jetpack_green_icon_83.5@2x.png */, + 17222D69261DDDF60047B163 /* pink-classic-icon-app-83.5x83.5@2x.png */, + 17222D6A261DDDF60047B163 /* pink-classic-icon-app-76x76@2x.png */, + 17222D6B261DDDF60047B163 /* pink-classic-icon-app-60x60@2x.png */, + 17222D6C261DDDF60047B163 /* pink-classic-icon-app-76x76.png */, + 17222D6D261DDDF60047B163 /* pink-classic-icon-app-60x60@3x.png */, ); - path = "Jetpack Green"; + path = "pink-classic"; sourceTree = ""; }; - 17E363C922C422AE000E0C79 /* WordPress */ = { + 17222D6E261DDDF70047B163 /* spectrum */ = { isa = PBXGroup; children = ( - 17E362ED22C41275000E0C79 /* wordpress_icon_40pt@2x.png */, - 17E362EE22C41275000E0C79 /* wordpress_icon_40pt@3x.png */, + 17222D6F261DDDF70047B163 /* spectrum-icon-app-76x76@2x.png */, + 17222D70261DDDF70047B163 /* spectrum-icon-app-83.5x83.5@2x.png */, + 17222D71261DDDF70047B163 /* spectrum-icon-app-60x60@3x.png */, + 17222D72261DDDF70047B163 /* spectrum-icon-app-76x76.png */, + 17222D73261DDDF70047B163 /* spectrum-icon-app-60x60@2x.png */, ); - path = WordPress; + path = spectrum; sourceTree = ""; }; - 19C28FACFE9D520D11CA2CBB /* Products */ = { + 17222D74261DDDF70047B163 /* blue */ = { isa = PBXGroup; children = ( - 1D6058910D05DD3D006BFB54 /* WordPress.app */, - E16AB92A14D978240047A2E5 /* WordPressTest.xctest */, - 93E5283A19A7741A003A1A9C /* WordPressTodayWidget.appex */, - 8511CFB61C607A7000B7CEED /* WordPressScreenshotGeneration.xctest */, - 932225A71C7CE50300443B02 /* WordPressShareExtension.appex */, - FF27168F1CAAC87A0006E2D4 /* WordPressUITests.xctest */, - 74576672202B558C00F42E40 /* WordPressDraftActionExtension.appex */, - 7358E6B8210BD318002323EB /* WordPressNotificationServiceExtension.appex */, - 733F36032126197800988727 /* WordPressNotificationContentExtension.appex */, - 98A3C2EF239997DA0048D38D /* WordPressThisWeekWidget.appex */, - 98D31B8E2396ED7E009CFF43 /* WordPressAllTimeWidget.appex */, + 17222D79261DDDF70047B163 /* blue-icon-app-60x60@2x.png */, + 17222D78261DDDF70047B163 /* blue-icon-app-60x60@3x.png */, + C7E5F2572799C2B0009BC263 /* blue-icon-app-76x76.png */, + C7E5F2582799C2B0009BC263 /* blue-icon-app-76x76@2x.png */, + C7E5F2592799C2B0009BC263 /* blue-icon-app-83.5x83.5@2x.png */, ); - name = Products; + path = blue; sourceTree = ""; }; - 1A433B1B2254CBC300AE7910 /* Networking */ = { + 17222D7A261DDDF80047B163 /* spectrum-classic */ = { isa = PBXGroup; children = ( - 1A433B1C2254CBEE00AE7910 /* WordPressComRestApi+Defaults.swift */, + 17222D7B261DDDF80047B163 /* spectrum-classic-icon-app-76x76@2x.png */, + 17222D7C261DDDF80047B163 /* spectrum-classic-icon-app-76x76.png */, + 17222D7D261DDDF80047B163 /* spectrum-classic-icon-app-60x60@3x.png */, + 17222D7E261DDDF80047B163 /* spectrum-classic-icon-app-83.5x83.5@2x.png */, + 17222D7F261DDDF80047B163 /* spectrum-classic-icon-app-60x60@2x.png */, ); - path = Networking; + path = "spectrum-classic"; sourceTree = ""; }; - 29B97314FDCFA39411CA2CEA /* CustomTemplate */ = { + 173BCE711CEB365400AE8817 /* Domains */ = { isa = PBXGroup; children = ( - 98D31BBF239720E4009CFF43 /* MainInterface.storyboard */, - F14B5F6F208E648200439554 /* config */, - E1B34C091CCDFFCE00889709 /* Credentials */, - E1756E661694AA1500D9EC00 /* Derived Sources */, - 080E96DDFE201D6D7F000001 /* Classes */, - E12F55F714A1F2640060A510 /* Vendor */, - 29B97315FDCFA39411CA2CEA /* Other Sources */, - 29B97317FDCFA39411CA2CEA /* Resources */, - 45C73C23113C36F50024D0D2 /* Resources-iPad */, - E125F1E21E8E594C00320B67 /* Shared */, - 74576673202B558C00F42E40 /* WordPressDraftActionExtension */, - 733F36072126197800988727 /* WordPressNotificationContentExtension */, - 7358E6B9210BD318002323EB /* WordPressNotificationServiceExtension */, - 932225A81C7CE50300443B02 /* WordPressShareExtension */, - 93E5283D19A7741A003A1A9C /* WordPressTodayWidget */, - 98D31B902396ED7F009CFF43 /* WordPressAllTimeWidget */, - 98A3C2F1239997DA0048D38D /* WordPressThisWeekWidget */, - E16AB92F14D978240047A2E5 /* WordPressTest */, - FF2716901CAAC87B0006E2D4 /* WordPressUITests */, - 8511CFB71C607A7000B7CEED /* WordPressScreenshotGeneration */, - 29B97323FDCFA39411CA2CEA /* Frameworks */, - 19C28FACFE9D520D11CA2CBB /* Products */, - 93FA0F0118E451A80007903B /* LICENSE */, - 93FA0F0218E451A80007903B /* README.md */, - E16273E21B2AD89A00088AF7 /* MIGRATIONS.md */, - FF37F90822385C9F00AFA3DB /* RELEASE-NOTES.txt */, - B565D41C3DB27630CD503F9A /* Pods */, + 02BF30512271D76F00616558 /* Domain credit */, + 437EF0C820F79C0C0086129B /* Domain registration */, + 3F3DD0B426FD18D400F5F121 /* Utility */, + 3F55A01826FCB0BD0049D379 /* Views */, ); - name = CustomTemplate; + path = Domains; sourceTree = ""; - usesTabs = 0; }; - 29B97315FDCFA39411CA2CEA /* Other Sources */ = { + 174C116D2624601000346EC6 /* Deep Linking */ = { isa = PBXGroup; children = ( - 934F1B3119ACCE5600E9E63E /* WordPress.entitlements */, - 934884AE19B7875C004028D8 /* WordPress-Internal.entitlements */, - 8546B44E1BEAD48900193C07 /* WordPress-Alpha.entitlements */, - 28A0AAE50D9B0CCF005BE974 /* WordPress_Prefix.pch */, + 1797373620EBAA4100377B4E /* RouteMatcherTests.swift */, + 174C116E2624603400346EC6 /* MBarRouteTests.swift */, ); - name = "Other Sources"; + name = "Deep Linking"; sourceTree = ""; }; - 29B97317FDCFA39411CA2CEA /* Resources */ = { + 1759F17E1FE145F90003EC81 /* Notices */ = { isa = PBXGroup; children = ( + 172E27D21FD98135003EA321 /* NoticePresenter.swift */, + 1759F17F1FE1460C0003EC81 /* NoticeView.swift */, + 430D50BD212B7AAE008F15F4 /* NoticeStyle.swift */, + 4353BFB121A376BF0009CED3 /* UntouchableWindow.swift */, + F50B0E7A246212B8006601DD /* NoticeAnimator.swift */, + ); + path = Notices; + sourceTree = ""; + }; + 175CC17327205BDC00622FB4 /* Domains */ = { + isa = PBXGroup; + children = ( + 175CC17427205BFB00622FB4 /* DomainExpiryDateFormatterTests.swift */, + ); + path = Domains; + sourceTree = ""; + }; + 1761F14D26209AEC000815EF /* open-source-dark */ = { + isa = PBXGroup; + children = ( + 1761F14E26209AEC000815EF /* open-source-dark-icon-app-83.5x83.5@2x.png */, + 1761F14F26209AEC000815EF /* open-source-dark-icon-app-60x60@3x.png */, + 1761F15026209AEC000815EF /* open-source-dark-icon-app-60x60@2x.png */, + 1761F15126209AEC000815EF /* open-source-dark-icon-app-76x76.png */, + 1761F15226209AEC000815EF /* open-source-dark-icon-app-76x76@2x.png */, + ); + name = "open-source-dark"; + path = "Resources/Icons/open-source-dark"; + sourceTree = SOURCE_ROOT; + }; + 1761F15326209AEC000815EF /* wordpress-dark */ = { + isa = PBXGroup; + children = ( + 1761F15426209AEC000815EF /* wordpress-dark-icon-app-76x76@2x.png */, + 1761F15526209AEC000815EF /* wordpress-dark-icon-app-76x76.png */, + 1761F15626209AEC000815EF /* wordpress-dark-icon-app-83.5x83.5@2x.png */, + 1761F15726209AEC000815EF /* wordpress-dark-icon-app-60x60@2x.png */, + 1761F15826209AEC000815EF /* wordpress-dark-icon-app-60x60@3x.png */, + ); + name = "wordpress-dark"; + path = "Resources/Icons/wordpress-dark"; + sourceTree = SOURCE_ROOT; + }; + 1761F15926209AED000815EF /* open-source */ = { + isa = PBXGroup; + children = ( + 1761F15A26209AED000815EF /* open-source-icon-app-60x60@2x.png */, + 1761F15B26209AED000815EF /* open-source-icon-app-60x60@3x.png */, + 1761F15C26209AED000815EF /* open-source-icon-app-83.5x83.5@2x.png */, + 1761F15D26209AED000815EF /* open-source-icon-app-76x76.png */, + 1761F15E26209AED000815EF /* open-source-icon-app-76x76@2x.png */, + ); + name = "open-source"; + path = "Resources/Icons/open-source"; + sourceTree = SOURCE_ROOT; + }; + 1761F15F26209AED000815EF /* jetpack-green */ = { + isa = PBXGroup; + children = ( + 1761F16026209AED000815EF /* jetpack-green-icon-app-76x76.png */, + 1761F16126209AED000815EF /* jetpack-green-icon-app-60x60@2x.png */, + 1761F16226209AED000815EF /* jetpack-green-icon-app-60x60@3x.png */, + 1761F16326209AED000815EF /* jetpack-green-icon-app-83.5x83.5@2x.png */, + 1761F16426209AED000815EF /* jetpack-green-icon-app-76x76@2x.png */, + ); + name = "jetpack-green"; + path = "Resources/Icons/jetpack-green"; + sourceTree = SOURCE_ROOT; + }; + 1761F16526209AED000815EF /* pride */ = { + isa = PBXGroup; + children = ( + 1761F16626209AED000815EF /* pride-icon-app-60x60@3x.png */, + 1761F16726209AED000815EF /* pride-icon-app-60x60@2x.png */, + 1761F16826209AED000815EF /* pride-icon-app-83.5x83.5@2x.png */, + 1761F16926209AED000815EF /* pride-icon-app-76x76.png */, + 1761F16A26209AED000815EF /* pride-icon-app-76x76@2x.png */, + ); + name = pride; + path = Resources/Icons/pride; + sourceTree = SOURCE_ROOT; + }; + 1761F16B26209AEE000815EF /* hot-pink */ = { + isa = PBXGroup; + children = ( + 1761F16C26209AEE000815EF /* hot-pink-icon-app-60x60@2x.png */, + 1761F16D26209AEE000815EF /* hot-pink-icon-app-83.5x83.5@2x.png */, + 1761F16E26209AEE000815EF /* hot-pink-icon-app-60x60@3x.png */, + 1761F16F26209AEE000815EF /* hot-pink-icon-app-76x76.png */, + 1761F17026209AEE000815EF /* hot-pink-icon-app-76x76@2x.png */, + ); + name = "hot-pink"; + path = "Resources/Icons/hot-pink"; + sourceTree = SOURCE_ROOT; + }; + 176E194525C465E00058F1C5 /* UnifiedPrologue */ = { + isa = PBXGroup; + children = ( + 1705CEE6260A57F900F23763 /* ContentViews */, + 176E194625C465F70058F1C5 /* UnifiedPrologueViewController.swift */, + 17C2FF0825D4852400CDB712 /* UnifiedProloguePages.swift */, + ); + name = UnifiedPrologue; + sourceTree = ""; + }; + 1797373920EBDA1100377B4E /* Universal Links */ = { + isa = PBXGroup; + children = ( + FE4DC5A1293A75DA008F322F /* Migration */, + 17BD4A182101D31B00975AC3 /* NavigationActionHelpers.swift */, + 17B7C89F20EC1D6A0042E260 /* Route.swift */, + F574416C2425697D00E150A8 /* Route+Page.swift */, + 1714F8CF20E6DA8900226DCB /* RouteMatcher.swift */, + 17BD4A0720F76A4700975AC3 /* Routes+Banners.swift */, + 082A645A291C2DD700668D2C /* Routes+Jetpack.swift */, + F1655B4722A6C2FA00227BFB /* Routes+Mbar.swift */, + 17F0E1D920EBDC0A001E9514 /* Routes+Me.swift */, + 17E24F5320FCF1D900BD70A3 /* Routes+MySites.swift */, + 17B7C8C020EE2A870042E260 /* Routes+Notifications.swift */, + 1703D04B20ECD93800D292E9 /* Routes+Post.swift */, + 17A4A36820EE51870071C2CA /* Routes+Reader.swift */, + 174C11922624C78900346EC6 /* Routes+Start.swift */, + 1715179120F4B2EB002C4A38 /* Routes+Stats.swift */, + 17B7C89D20EC1D0D0042E260 /* UniversalLinkRouter.swift */, + ); + path = "Universal Links"; + sourceTree = ""; + }; + 17A4A36A20EE551F0071C2CA /* Coordinators */ = { + isa = PBXGroup; + children = ( + 17A4A36B20EE55320071C2CA /* ReaderCoordinator.swift */, + 1715179320F4B5CD002C4A38 /* MySitesCoordinator.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; + 17D4153D22C24B67006378EF /* Icons */ = { + isa = PBXGroup; + children = ( + C7E5F2422799BB1E009BC263 /* cool-blue */, + 17222D62261DDDF50047B163 /* black */, + 17222D50261DDDF40047B163 /* black-classic */, + 17222D74261DDDF70047B163 /* blue */, + 17222D56261DDDF40047B163 /* blue-classic */, + 17222D4A261DDDF30047B163 /* celadon */, + 17222D44261DDDF10047B163 /* celadon-classic */, + 17222D5C261DDDF50047B163 /* pink */, + 17222D68261DDDF60047B163 /* pink-classic */, + B02D1EC12835348900F20359 /* spectrum-2022 */, + 17222D6E261DDDF70047B163 /* spectrum */, + 17222D7A261DDDF80047B163 /* spectrum-classic */, + 1761F16B26209AEE000815EF /* hot-pink */, + 1761F15F26209AED000815EF /* jetpack-green */, + 1761F15926209AED000815EF /* open-source */, + 1761F14D26209AEC000815EF /* open-source-dark */, + 1761F16526209AED000815EF /* pride */, + 1761F15326209AEC000815EF /* wordpress-dark */, + ); + name = Icons; + path = Resources/Icons; + sourceTree = ""; + }; + 17D433581EFD2ED500CAB602 /* Fancy Alerts */ = { + isa = PBXGroup; + children = ( + 405B53FA1F83C369002E19BF /* FancyAlerts+VerificationPrompt.swift */, + 176BB87E20D0068500751DCE /* FancyAlertViewController+SavedPosts.swift */, + 175F99B42625FDE100F2687E /* FancyAlertViewController+AppIcons.swift */, + 4319AADD2090F00C0025D68E /* FancyAlertViewController+NotificationPrimer.swift */, + ); + name = "Fancy Alerts"; + sourceTree = ""; + }; + 19C28FACFE9D520D11CA2CBB /* Products */ = { + isa = PBXGroup; + children = ( + 1D6058910D05DD3D006BFB54 /* WordPress.app */, + E16AB92A14D978240047A2E5 /* WordPressTest.xctest */, + 8511CFB61C607A7000B7CEED /* WordPressScreenshotGeneration.xctest */, + 932225A71C7CE50300443B02 /* WordPressShareExtension.appex */, + FF27168F1CAAC87A0006E2D4 /* WordPressUITests.xctest */, + 74576672202B558C00F42E40 /* WordPressDraftActionExtension.appex */, + 7358E6B8210BD318002323EB /* WordPressNotificationServiceExtension.appex */, + 733F36032126197800988727 /* WordPressNotificationContentExtension.appex */, + 3F526C4C2538CF2A0069706C /* WordPressStatsWidgets.appex */, + F1F163BE25658B4D003DC13B /* WordPressIntents.appex */, + FABB26522602FC2C00C8785C /* Jetpack.app */, + FAF64BA22637DEEC00E8A1DF /* JetpackScreenshotGeneration.xctest */, + 3FA640572670CCD40064401E /* UITestsFoundation.framework */, + 8096212328E540D700940A5D /* JetpackShareExtension.appex */, + 8096218528E55C9400940A5D /* JetpackDraftActionExtension.appex */, + 80F6D05428EE866A00953C1A /* JetpackNotificationServiceExtension.appex */, + 0107E0EA28F97D5000DE87DB /* JetpackStatsWidgets.appex */, + 0107E15428FE9DB200DE87DB /* JetpackIntents.appex */, + EA14534229AD874C001F3143 /* JetpackUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 1A433B1B2254CBC300AE7910 /* Networking */ = { + isa = PBXGroup; + children = ( + F15272FE243B28B600C8DC7A /* RequestAuthenticator.swift */, + 1E485A8F249B61440000A253 /* GutenbergRequestAuthenticator.swift */, + 1A433B1C2254CBEE00AE7910 /* WordPressComRestApi+Defaults.swift */, + ); + path = Networking; + sourceTree = ""; + }; + 1E5D00152493FC3A0004B708 /* Views */ = { + isa = PBXGroup; + children = ( + 1E5D000F2493CE240004B708 /* GutenGhostView.swift */, + 1E5D00132493E8C90004B708 /* GutenGhostView.xib */, + ); + path = Views; + sourceTree = ""; + }; + 1ED046CE244F26B1008F6365 /* GutenbergWeb */ = { + isa = PBXGroup; + children = ( + 1E0FF01D242BC572008DA898 /* GutenbergWebViewController.swift */, + 1E4F2E702458AF8500EB73E7 /* GutenbergWebNavigationViewController.swift */, + ); + path = GutenbergWeb; + sourceTree = ""; + }; + 24AE9EF5264B3D8E00AC7F15 /* Derived Sources */ = { + isa = PBXGroup; + children = ( + 24351253264DCA08009BB2B6 /* Secrets.swift */, + ); + name = "Derived Sources"; + path = Classes; + sourceTree = ""; + }; + 29B97314FDCFA39411CA2CEA = { + isa = PBXGroup; + children = ( + 3F20FDF3276BF21000DA3CAD /* Packages */, + F14B5F6F208E648200439554 /* config */, + E1B34C091CCDFFCE00889709 /* Credentials */, + 080E96DDFE201D6D7F000001 /* Classes */, + 29B97315FDCFA39411CA2CEA /* Other Sources */, + 29B97317FDCFA39411CA2CEA /* Resources */, + 45C73C23113C36F50024D0D2 /* Resources-iPad */, + E125F1E21E8E594C00320B67 /* Shared */, + FABB1F8E2602FC0100C8785C /* Jetpack */, + 8096218728E55CB800940A5D /* JetpackDraftActionExtension */, + 80F6D05628EE869900953C1A /* JetpackNotificationServiceExtension */, + 0107E1802900043200DE87DB /* JetpackIntents */, + 0107E1822900043200DE87DB /* JetpackStatsWidgets */, + 8096212828E5535E00940A5D /* JetpackShareExtension */, + 74576673202B558C00F42E40 /* WordPressDraftActionExtension */, + 733F36072126197800988727 /* WordPressNotificationContentExtension */, + 7358E6B9210BD318002323EB /* WordPressNotificationServiceExtension */, + 932225A81C7CE50300443B02 /* WordPressShareExtension */, + 3F526C512538CF2A0069706C /* WordPressStatsWidgets */, + F1F163BF25658B4D003DC13B /* WordPressIntents */, + E16AB92F14D978240047A2E5 /* WordPressTest */, + FF2716901CAAC87B0006E2D4 /* UITests */, + 8511CFB71C607A7000B7CEED /* WordPressScreenshotGeneration */, + FAF64BC82637DF0600E8A1DF /* JetpackScreenshotGeneration */, + 3FA640582670CCD40064401E /* UITestsFoundation */, + 29B97323FDCFA39411CA2CEA /* Frameworks */, + 19C28FACFE9D520D11CA2CBB /* Products */, + 93FA0F0118E451A80007903B /* LICENSE */, + 93FA0F0218E451A80007903B /* README.md */, + E16273E21B2AD89A00088AF7 /* MIGRATIONS.md */, + FF37F90822385C9F00AFA3DB /* RELEASE-NOTES.txt */, + B565D41C3DB27630CD503F9A /* Pods */, + F5E156B425DDD53A00EEEDFB /* Recovered References */, + ); + name = CustomTemplate; + sourceTree = ""; + usesTabs = 0; + }; + 29B97315FDCFA39411CA2CEA /* Other Sources */ = { + isa = PBXGroup; + children = ( + 24AE9EF5264B3D8E00AC7F15 /* Derived Sources */, + 934F1B3119ACCE5600E9E63E /* WordPress.entitlements */, + 934884AE19B7875C004028D8 /* WordPress-Internal.entitlements */, + 8546B44E1BEAD48900193C07 /* WordPress-Alpha.entitlements */, + 28A0AAE50D9B0CCF005BE974 /* WordPress_Prefix.pch */, + ); + name = "Other Sources"; + sourceTree = ""; + }; + 29B97317FDCFA39411CA2CEA /* Resources */ = { + isa = PBXGroup; + children = ( + 801D94F4291AB36A0051993E /* Lottie Animations */, 433840C622C2BA5B00CB13F8 /* AppImages.xcassets */, - 858DE3FF172F9991000AC628 /* Fonts */, + F5A34D0525DF2F7700C9654B /* Fonts */, 17D4153D22C24B67006378EF /* Icons */, CC098B8116A9EB0400450976 /* HTML */, B5DCD0C21C69328E00C9B431 /* Settings */, @@ -5444,6 +10417,7 @@ B51535D31BBB16AA0029B84B /* Launch Screen.storyboard */, 406A0EEF224D39C50016AD6A /* Flags.xcassets */, 435B76292297484200511813 /* ColorPalette.xcassets */, + 17C1D6F426711ED0006C8970 /* Emoji.txt */, 6EDC0E8E105881A800F68A1D /* iTunesArtwork */, 85ED98AA17DFB17200090D0B /* iTunesArtwork@2x */, 8D1107310486CEB800E47090 /* Info.plist */, @@ -5452,7 +10426,7 @@ 930C6374182BD86400976C21 /* WordPress-Internal-Info.plist */, E125443B12BF5A7200D87A0A /* WordPress.xcdatamodeld */, 8546B44C1BEAD3EC00193C07 /* Wordpress-Alpha-Info.plist */, - D82247F82113EF5C00918CEB /* News.strings */, + 80293CF6284450AD0083F946 /* WordPress-Swift.h */, ); name = Resources; sourceTree = ""; @@ -5460,10 +10434,12 @@ 29B97323FDCFA39411CA2CEA /* Frameworks */ = { isa = PBXGroup; children = ( + 3FA640652670CEFE0064401E /* XCTest.framework */, + F111B88B2658102700057942 /* Combine.framework */, + F13E7FDD2566B0AB007D420A /* Intents.framework */, + FF4DEAD7244B56E200ACA032 /* CoreServices.framework */, 9884B145236225230021D8E9 /* WordPressUI.framework */, 9884B143236224F80021D8E9 /* WordPressUI.framework */, - 98E58A432361019300E5534B /* Pods_WordPressTodayWidget.framework */, - 98E58A412361017500E5534B /* Pods_WordPressTodayWidget.framework */, 7E21C760202BBC8D00837CF5 /* iAd.framework */, 8527B15717CE98C5001CBA2E /* Accelerate.framework */, CC24E5F41577E16B00A6D5B5 /* Accounts.framework */, @@ -5514,25 +10490,44 @@ E131CB5316CACB05004B0314 /* libxml2.dylib */, E19DF740141F7BDD000002F3 /* libz.dylib */, B7556D1D8CFA5CEAEAC481B9 /* Pods.framework */, - 556E3A9C1600564F6A3CADF6 /* Pods_WordPress.framework */, - F47DB4A8EC2E6844E213A3FA /* Pods_WordPressShareExtension.framework */, B921F5DD9A1F257C792EC225 /* Pods_WordPressTest.framework */, - 8CE5BBD00FF1470AC4B88247 /* Pods_WordPressTodayWidget.framework */, - F7E3CC306AECBBCB71D2E19C /* Pods_WordPressDraftActionExtension.framework */, - E2A297CCB53FCF6851D79331 /* Pods_WordPressNotificationServiceExtension.framework */, 733F36052126197800988727 /* UserNotificationsUI.framework */, - 6CC2CF274BA2F245C464D562 /* Pods_WordPressNotificationContentExtension.framework */, AB390AA9C94F16E78184E9D1 /* Pods_WordPressScreenshotGeneration.framework */, 8DCE7542239FBC709B90EA85 /* Pods_WordPressUITests.framework */, - 979B445A45E13F3289F2E99E /* Pods_WordPressThisWeekWidget.framework */, - 0075A07B739C4EDB5EC613B8 /* Pods_WordPressAllTimeWidget.framework */, + 3F526C4D2538CF2A0069706C /* WidgetKit.framework */, + 3F526C4F2538CF2A0069706C /* SwiftUI.framework */, + F1F163C825658B4D003DC13B /* IntentsUI.framework */, + 57E15BC2269B6B7419464B6F /* Pods_Apps_Jetpack.framework */, + 1E826CD5B4B116AF78FF391C /* Pods_Apps_WordPress.framework */, + 23052F0F1F9B2503E33D0A26 /* Pods_JetpackShareExtension.framework */, + 213A62FF811EBDB969FA7669 /* Pods_WordPressShareExtension.framework */, + 430F7B409FE22699ADB1A724 /* Pods_JetpackDraftActionExtension.framework */, + 92B40A77F0765C1E93B11727 /* Pods_WordPressDraftActionExtension.framework */, + 23F18781EEBE5551D6B4992C /* Pods_JetpackNotificationServiceExtension.framework */, + D3B8D9C4DCD93C57C2B98CDC /* Pods_WordPressNotificationServiceExtension.framework */, + 26AC7B7EB4454FA8E268624D /* Pods_JetpackStatsWidgets.framework */, + 4D670B9448DF9991366CF42D /* Pods_WordPressStatsWidgets.framework */, + D42A30853435E728881904E8 /* Pods_JetpackIntents.framework */, + A1DD7BB9C25967442493CC19 /* Pods_WordPressIntents.framework */, ); name = Frameworks; sourceTree = ""; }; + 2F668B5D255DD11400D0038A /* Jetpack Settings */ = { + isa = PBXGroup; + children = ( + 2F668B5E255DD11400D0038A /* JetpackSpeedUpSiteSettingsViewController.swift */, + 2F668B5F255DD11400D0038A /* JetpackSettingsViewController.swift */, + 2F668B60255DD11400D0038A /* JetpackConnectionViewController.swift */, + ); + path = "Jetpack Settings"; + sourceTree = ""; + }; 2F706A870DFB229B00B43086 /* Models */ = { isa = PBXGroup; children = ( + F48D44B4298992A90051EAA6 /* Blocking */, + 46963F5724649509000D356D /* Gutenberg */, 40A71C5F220E1941002E3D25 /* Stats */, 9A38DC63218899E4006A409B /* Revisions */, D8CB56212181A93F00554EAE /* Site Creation */, @@ -5542,17 +10537,33 @@ B5176CBF1CDCE14F0083CF2D /* People Management */, 5D42A3D6175E7452005CFF05 /* AbstractPost.h */, 5D42A3D7175E7452005CFF05 /* AbstractPost.m */, + F1527300243B290D00C8DC7A /* AbstractPost+Autosave.swift */, + FA4B203729A8C48F0089FE68 /* AbstractPost+Blaze.swift */, 40232A9C230A6A740036B0B6 /* AbstractPost+HashHelpers.h */, 40232A9D230A6A740036B0B6 /* AbstractPost+HashHelpers.m */, + F15272FC243B27BC00C8DC7A /* AbstractPost+Local.swift */, + 8BC12F732320181E004DDA72 /* AbstractPost+MarkAsFailedAndDraftIfNeeded.swift */, E19B17B11E5C8F36007517C6 /* AbstractPost.swift */, 74729CAD205722E300D1394D /* AbstractPost+Searchable.swift */, + 8B1CF0102433E61C00578582 /* AbstractPost+TitleForVisibility.swift */, + 0A9610F828B2E56300076EBA /* UserSuggestion+Comparable.swift */, + B0B68A9A252FA91E0001B28C /* UserSuggestion+CoreDataClass.swift */, + B0B68A9B252FA91E0001B28C /* UserSuggestion+CoreDataProperties.swift */, 5D42A3D8175E7452005CFF05 /* BasePost.h */, 5D42A3D9175E7452005CFF05 /* BasePost.m */, E19B17AD1E5C6944007517C6 /* BasePost.swift */, - 83418AA811C9FA6E00ACF00C /* Comment.h */, - 83418AA911C9FA6E00ACF00C /* Comment.m */, + FE003F5B282D61B9006F8D1D /* BloggingPrompt+CoreDataClass.swift */, + FE003F5C282D61B9006F8D1D /* BloggingPrompt+CoreDataProperties.swift */, + 837B49D3283C2AE80061A657 /* BloggingPromptSettings+CoreDataClass.swift */, + 837B49D4283C2AE80061A657 /* BloggingPromptSettings+CoreDataProperties.swift */, + 837B49D5283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataClass.swift */, + 837B49D6283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataProperties.swift */, + 98AA6D1026B8CE7200920C8B /* Comment+CoreDataClass.swift */, + 9815D0B226B49A0600DF7226 /* Comment+CoreDataProperties.swift */, E14932B4130427B300154804 /* Coordinate.h */, E14932B5130427B300154804 /* Coordinate.m */, + E690F6ED25E05D170015A777 /* InviteLinks+CoreDataClass.swift */, + E690F6EE25E05D180015A777 /* InviteLinks+CoreDataProperties.swift */, E16A76F01FC4758300A661E3 /* JetpackSiteRef.swift */, E11C4B71201096EF00A6619C /* JetpackState.swift */, 8350E49411D2C71E00A7B073 /* Media.h */, @@ -5572,56 +10583,71 @@ E1E89C6B1FD80E74006E7A33 /* Plugin.swift */, 591AA4FF1CEF9BF20074934F /* Post.swift */, 591AA5001CEF9BF20074934F /* Post+CoreDataProperties.swift */, + FF0F722B206E5345000DAAB5 /* Post+RefreshStatus.swift */, E148362E1C6DF7D8005ACF53 /* Product.swift */, 833AF259114575A50016DE8F /* PostAnnotation.h */, 833AF25A114575A50016DE8F /* PostAnnotation.m */, E125445412BF5B3900D87A0A /* PostCategory.h */, E125445512BF5B3900D87A0A /* PostCategory.m */, + 4A9314E32979FA4700360232 /* PostCategory+Creation.swift */, + 4A9314E6297A0C5000360232 /* PostCategory+Lookup.swift */, 082AB9DB1C4F035E000CA523 /* PostTag.h */, 082AB9DC1C4F035E000CA523 /* PostTag.m */, 08B6D6F01C8F7DCE0052C52B /* PostType.h */, 08B6D6F11C8F7DCE0052C52B /* PostType.m */, E6374DBD1C444D8B00F24720 /* PublicizeConnection.swift */, + 4A1E77C82988997C006281CC /* PublicizeConnection+Creation.swift */, E6374DBE1C444D8B00F24720 /* PublicizeService.swift */, + 4A358DE529B5EB8D00BFCEBE /* PublicizeService+Lookup.swift */, + 8BB185D424B66FE600A4CCE8 /* ReaderCard+CoreDataClass.swift */, + 8BB185D324B66FE500A4CCE8 /* ReaderCard+CoreDataProperties.swift */, 5D42A3DC175E7452005CFF05 /* ReaderPost.h */, 5D42A3DD175E7452005CFF05 /* ReaderPost.m */, 745A41AF2065405600299D75 /* ReaderPost+Searchable.swift */, + 4AD5656B28E3D0670054C676 /* ReaderPost+Helper.swift */, E6C09B3D1BF0FDEB003074CB /* ReaderCrossPostMeta.swift */, 5DE471B71B4C710E00665C44 /* ReaderPostContentProvider.h */, - 749197ED209B9A2E006F5E66 /* ReaderCardContent+PostInformation.swift */, E6A3384A1BB08E3F00371587 /* ReaderGapMarker.h */, E6A3384B1BB08E3F00371587 /* ReaderGapMarker.m */, E61084B91B9B47BA008050C5 /* ReaderAbstractTopic.swift */, + 4AA33EF729963ABE005B6E23 /* ReaderAbstractTopic+Lookup.swift */, E62079DE1CF79FC200F5CD46 /* ReaderSearchSuggestion.swift */, E61084BA1B9B47BA008050C5 /* ReaderDefaultTopic.swift */, E61084BB1B9B47BA008050C5 /* ReaderListTopic.swift */, + 4AA33EFA2999AE3B005B6E23 /* ReaderListTopic+Creation.swift */, E6BDEA721CE4824300682885 /* ReaderSearchTopic.swift */, E61084BC1B9B47BA008050C5 /* ReaderSiteTopic.swift */, + 4AA33F002999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift */, 9F3EFC9D208E2E8A00268758 /* ReaderSiteInfoSubscriptions.swift */, E61084BD1B9B47BA008050C5 /* ReaderTagTopic.swift */, + 4AA33F03299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift */, E1DB326B1D9ACD4A00C8FEBC /* ReaderTeamTopic.swift */, E10290731F30615A00DAC588 /* Role.swift */, E6E27D611C6144DB0063F821 /* SharingButton.swift */, + 4A358DE829B5F14C00BFCEBE /* SharingButton+Lookup.swift */, E6D170361EF9D8D10046D433 /* SiteInfo.swift */, + B06378BE253F639D00FD45D2 /* SiteSuggestion+CoreDataClass.swift */, + B06378BF253F639D00FD45D2 /* SiteSuggestion+CoreDataProperties.swift */, 5DED0E161B432E0400431FCD /* SourcePostAttribution.h */, 5DED0E171B432E0400431FCD /* SourcePostAttribution.m */, - 319D6E7919E447500013871C /* Suggestion.h */, - 319D6E7A19E447500013871C /* Suggestion.m */, + B03B9235250BC5FD000A40AF /* Suggestion.swift */, 5DA5BF3318E32DCF005F11F9 /* Theme.h */, 5DA5BF3418E32DCF005F11F9 /* Theme.m */, 82FFBF4C1F434BDA00F4573F /* ThemeIdHelper.swift */, E105E9CD1726955600C0D9E7 /* WPAccount.h */, E105E9CE1726955600C0D9E7 /* WPAccount.m */, + 2481B17E260D4D4E00AE59DB /* WPAccount+Lookup.swift */, E12DB07A1C48D1C200A6C1D4 /* WPAccount+AccountSettings.swift */, 73FEC870220B358500CEF791 /* WPAccount+RestApi.swift */, + 4A1E77CB2989F2F7006281CC /* WPAccount+DeduplicateBlogs.swift */, 46F84612185A8B7E009D0DA5 /* PostContentProvider.h */, - 5D35F7581A042255004E7B0D /* WPCommentContentViewProvider.h */, 173BCE781CEB780800AE8817 /* Domain.swift */, E125F1E61E8E59C800320B67 /* SharePost+UIActivityItemSource.swift */, E6311C421ECA017E00122529 /* UserProfile.swift */, E6FACB1D1EC675E300284AC7 /* GravatarProfile.swift */, 43AF2F962107D3800069C012 /* QuickStartTourState.swift */, - D816B8BD2112CC000052CE4D /* NewsItem.swift */, + 17EFD3732578201100AB753C /* ValueTransformers.swift */, + 241E60B225CA0D2900912CEB /* UserSettings.swift */, ); path = Models; sourceTree = ""; @@ -5629,10 +10655,14 @@ 319D6E8219E44C7B0013871C /* Suggestions */ = { isa = PBXGroup; children = ( + F4BECD1A288EE5220078391A /* SuggestionsViewModelType.swift */, 319D6E7F19E44C680013871C /* SuggestionsTableView.h */, 319D6E8019E44C680013871C /* SuggestionsTableView.m */, + B0637526253E7CEB00FD45D2 /* SuggestionsTableView.swift */, 319D6E8319E44F7F0013871C /* SuggestionsTableViewCell.h */, 319D6E8419E44F7F0013871C /* SuggestionsTableViewCell.m */, + F41E32FD287B47A500F89082 /* SuggestionsListViewModel.swift */, + F41E3300287B5FE500F89082 /* SuggestionViewModel.swift */, ); name = Suggestions; sourceTree = ""; @@ -5640,6 +10670,7 @@ 31F4F6641A13858F00196A98 /* Me */ = { isa = PBXGroup; children = ( + F4FB0ACB2925878E00F651F9 /* Views */, 3F29EB70240421F6005313DE /* Account Settings */, 3F29EB6E240420C3005313DE /* App Settings */, 3F29EB6F2404218E005313DE /* Help & Support */, @@ -5649,10 +10680,42 @@ path = Me; sourceTree = ""; }; + 32110548250BFC5A0048446F /* Image Dimension Parser */ = { + isa = PBXGroup; + children = ( + 32110549250BFD4E0048446F /* Test Images */, + 32110546250BFC3E0048446F /* ImageDimensionParserTests.swift */, + ); + name = "Image Dimension Parser"; + sourceTree = ""; + }; + 32110549250BFD4E0048446F /* Test Images */ = { + isa = PBXGroup; + children = ( + 32110568250C0E960048446F /* 100x100-png */, + 32110555250C027C0048446F /* 100x100.gif */, + 32110559250C027C0048446F /* 100x100.jpg */, + 32110558250C027C0048446F /* invalid-gif.gif */, + 32110552250C027B0048446F /* invalid-jpeg-header.jpg */, + 32110554250C027B0048446F /* valid-gif-header.gif */, + 32110553250C027B0048446F /* valid-jpeg-header.jpg */, + 3211056A250C0F750048446F /* valid-png-header */, + ); + path = "Test Images"; + sourceTree = ""; + }; + 3236F79F24B61B780088E8F3 /* Select Interests */ = { + isa = PBXGroup; + children = ( + 3236F7A024B61B950088E8F3 /* ReaderInterestsDataSourceTests.swift */, + 321955C024BE4EBF00E3F316 /* ReaderSelectInterestsCoordinatorTests.swift */, + ); + name = "Select Interests"; + sourceTree = ""; + }; 3249615523F2013B004C7733 /* Post Signup Interstitial */ = { isa = PBXGroup; children = ( - 32F1A6CE23F7083500AB8CA9 /* PostSignUpInterstitialCoordinator.swift */, 3249615023F20111004C7733 /* PostSignUpInterstitialViewController.swift */, 3249615123F20111004C7733 /* PostSignUpInterstitialViewController.xib */, ); @@ -5670,25 +10733,44 @@ 325D3B3B23A8373E00766DF6 /* Controllers */ = { isa = PBXGroup; children = ( + 0A9687BA28B4075A009DCD2F /* Mocks */, + 0A69300A28B5AA5E00E98DE1 /* FullScreenCommentReplyViewModelTests.swift */, 325D3B3C23A8376400766DF6 /* FullScreenCommentReplyViewControllerTests.swift */, ); path = Controllers; sourceTree = ""; }; - 32F1A6D123F710A300AB8CA9 /* NUX */ = { + 326E2818250AC4610029EBF0 /* Image Dimension Parser */ = { isa = PBXGroup; children = ( - 32F1A6D223F710A900AB8CA9 /* Post Signup Interstitial */, + 326E2819250AC4A50029EBF0 /* ImageDimensionFetcher.swift */, + 326E281A250AC4A50029EBF0 /* ImageDimensionParser.swift */, ); - path = NUX; + path = "Image Dimension Parser"; sourceTree = ""; }; - 32F1A6D223F710A900AB8CA9 /* Post Signup Interstitial */ = { + 329F8E4C24DD74F0002A5311 /* Tags View */ = { isa = PBXGroup; children = ( - 32F1A6D323F7111800AB8CA9 /* PostSignUpInterstitialCoordinatorTests.swift */, + 329F8E5524DDAC61002A5311 /* DynamicHeightCollectionView.swift */, + 329F8E5724DDBD11002A5311 /* ReaderTopicCollectionViewCoordinator.swift */, + 8B7F25A624E6EDB4007D82CC /* TopicsCollectionView.swift */, ); - path = "Post Signup Interstitial"; + path = "Tags View"; + sourceTree = ""; + }; + 32E1BFD824A66801007A08F0 /* Select Interests */ = { + isa = PBXGroup; + children = ( + 3236F77124ABB6C90088E8F3 /* ReaderInterestsDataSource.swift */, + 3254366B24ABA82100B2C5F5 /* ReaderInterestsStyleGuide.swift */, + 32E1BFD924A66F2A007A08F0 /* ReaderInterestsCollectionViewFlowLayout.swift */, + 32E1BFFB24AB9D28007A08F0 /* ReaderSelectInterestsViewController.swift */, + 32E1BFFC24AB9D28007A08F0 /* ReaderSelectInterestsViewController.xib */, + 3236F77324ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.swift */, + 3236F77424ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.xib */, + ); + path = "Select Interests"; sourceTree = ""; }; 3792259E12F6DBCC00F2176A /* Stats */ = { @@ -5700,6 +10782,7 @@ 98747674219644E40080967F /* Helpers */, 98747671219638BF0080967F /* Insights */, 984B138C21F65F680004B6A2 /* Period Stats */, + 931215E2267F4F92008C3B69 /* Referrer Details */, 98F89D47219E008600190EE6 /* Shared Views */, 98E58A2D2360D1FD00E5534B /* Today Widgets */, 981C348F2183871100FC2683 /* SiteStatsDashboard.storyboard */, @@ -5713,6 +10796,47 @@ path = Stats; sourceTree = ""; }; + 3F00739D2915BAA100A37FD1 /* Notifications Permission */ = { + isa = PBXGroup; + children = ( + 3FF15A5B291ED21100E1B4E5 /* MigrationNotificationsCenterView.swift */, + 3FFDEF7929177D8C00B625CE /* MigrationNotificationsViewController.swift */, + 3FFDEF7729177D7500B625CE /* MigrationNotificationsViewModel.swift */, + ); + path = "Notifications Permission"; + sourceTree = ""; + }; + 3F09CCA62428FE8600D00A8C /* Tab Navigation */ = { + isa = PBXGroup; + children = ( + 3F09CCAD24292EFD00D00A8C /* ReaderTabItem.swift */, + 3FC8D19A244F43B500495820 /* ReaderTabItemsStore.swift */, + 3F09CCA92428FF8300D00A8C /* ReaderTabView.swift */, + 3F09CCA72428FF3300D00A8C /* ReaderTabViewController.swift */, + 3FF1A852242D5FCB00373F5D /* WPTabBarController+ReaderTabNavigation.swift */, + 3F6975FE242D941E001F1807 /* ReaderTabViewModel.swift */, + ); + path = "Tab Navigation"; + sourceTree = ""; + }; + 3F170D2A2654615900F6F670 /* Blogging Reminders */ = { + isa = PBXGroup; + children = ( + 3F946C582684DD8E00B946F6 /* BloggingRemindersActions.swift */, + 3FBF21B6267AA17A0098335F /* BloggingRemindersAnimator.swift */, + 17171373265FAA8A00F3A022 /* BloggingRemindersNavigationController.swift */, + F1A38F202678C4DA00849843 /* BloggingRemindersFlow.swift */, + 178DDD05266D68A3006C68C4 /* BloggingRemindersFlowIntroViewController.swift */, + 178DDD1A266D7523006C68C4 /* BloggingRemindersFlowSettingsViewController.swift */, + 1770BD0C267A368100D5F8C0 /* BloggingRemindersPushPromptViewController.swift */, + 178DDD2F266D7576006C68C4 /* BloggingRemindersFlowCompletionViewController.swift */, + F1C197A52670DDB100DE1FF7 /* BloggingRemindersTracker.swift */, + 178DDD56266E4165006C68C4 /* CalendarDayToggleButton.swift */, + 3FB1928E26C6106C000F5AA3 /* Time Selector */, + ); + path = "Blogging Reminders"; + sourceTree = ""; + }; 3F1B66A123A2F52A0075F09E /* ReaderPostActions */ = { isa = PBXGroup; children = ( @@ -5721,6 +10845,23 @@ name = ReaderPostActions; sourceTree = ""; }; + 3F20FDF3276BF21000DA3CAD /* Packages */ = { + isa = PBXGroup; + children = ( + 3F20FDF4276BF21000DA3CAD /* WordPressFlux */, + ); + name = Packages; + sourceTree = ""; + }; + 3F26569F25AF4DBF0073A832 /* Localization */ = { + isa = PBXGroup; + children = ( + 0107E1862900065400DE87DB /* LocalizationConfiguration.swift */, + 3FE77C8225B0CA89007DE9E5 /* LocalizableStrings.swift */, + ); + path = Localization; + sourceTree = ""; + }; 3F29EB6724041F47005313DE /* Activity Logs */ = { isa = PBXGroup; children = ( @@ -5734,8 +10875,8 @@ 3F29EB6824041F6D005313DE /* About */ = { isa = PBXGroup; children = ( - FFC6ADD21B56F295002F3C84 /* AboutViewController.swift */, - FFC6ADD31B56F295002F3C84 /* AboutViewController.xib */, + F1A75B9A2732EF3700784A70 /* AboutScreenTracker.swift */, + 173DF290274522A1007C64B5 /* AppAboutScreenConfiguration.swift */, ); path = About; sourceTree = ""; @@ -5763,7 +10904,6 @@ children = ( E1A8CACA1C22FF7C0038689E /* MeViewController.swift */, 3F29EB7124042276005313DE /* MeViewController+UIViewControllerRestoration.swift */, - 3F29EB6C24042038005313DE /* Header */, 3F43602D23F31C06001DEE70 /* Presenter */, ); path = "Me Main"; @@ -5772,8 +10912,9 @@ 3F29EB6C24042038005313DE /* Header */ = { isa = PBXGroup; children = ( - 315FC2C31A2CB29300E7CDA2 /* MeHeaderView.h */, 315FC2C41A2CB29300E7CDA2 /* MeHeaderView.m */, + F4FB0ACC292587D500F651F9 /* MeHeaderViewConfiguration.swift */, + F4CBE3D329258AD6004FFBB6 /* MeHeaderView.h */, ); path = Header; sourceTree = ""; @@ -5793,11 +10934,14 @@ 3F29EB6E240420C3005313DE /* App Settings */ = { isa = PBXGroup; children = ( + F44293D428E3B39400D340AF /* App Icons */, 3F29EB6824041F6D005313DE /* About */, - 17E362EB22C40BE8000E0C79 /* AppIconViewController.swift */, FFA162301CB7031A00E2E110 /* AppSettingsViewController.swift */, 17E4CD0B238C33F300C56916 /* DebugMenuViewController.swift */, E1ADE0EA20A9EF6200D6AADC /* PrivacySettingsViewController.swift */, + F9B862C82478170A008B093C /* EncryptedLogTableViewController.swift */, + CECEEB542823164800A28ADE /* MediaCacheSettingsViewController.swift */, + 80A2154229D1177A002FE8EB /* RemoteConfigDebugViewController.swift */, ); path = "App Settings"; sourceTree = ""; @@ -5820,47 +10964,130 @@ path = "Account Settings"; sourceTree = ""; }; - 3F37609B23FD803300F0D87F /* Blog + Me */ = { + 3F2F0C06256C68E1003351C7 /* Remote service */ = { isa = PBXGroup; children = ( - 3FCCAA1423F4A1A3004064C0 /* UIBarButtonItem+MeBarButton.swift */, + 3F2F0C15256C6B2C003351C7 /* StatsWidgetsService.swift */, ); - path = "Blog + Me"; + path = "Remote service"; sourceTree = ""; }; - 3F43602D23F31C06001DEE70 /* Presenter */ = { + 3F2F854126FAEA2D000FCDA5 /* Jetpack */ = { isa = PBXGroup; children = ( - 3F43603023F31E09001DEE70 /* MeScenePresenter.swift */, + FA41070D263957C000E90EBF /* JetpackBackupOptionsScreen.swift */, + FA4104BE26393F1A00E90EBF /* JetpackBackupScreen.swift */, + FA4104732639393700E90EBF /* JetpackScanScreen.swift */, ); - path = Presenter; + path = Jetpack; sourceTree = ""; }; - 3F43603423F368BF001DEE70 /* Blog Details */ = { + 3F2F854626FAEBBA000FCDA5 /* Media */ = { isa = PBXGroup; children = ( - 31C9F82C1A2368A2008BB945 /* BlogDetailHeaderView.h */, - 31C9F82D1A2368A2008BB945 /* BlogDetailHeaderView.m */, - 462F4E0618369F0B0028D2F8 /* BlogDetailsViewController.h */, - 462F4E0718369F0B0028D2F8 /* BlogDetailsViewController.m */, - 74989B8B2088E3650054290B /* BlogDetailsViewController+Activity.swift */, - 02AC3091226FFFAA0018D23B /* BlogDetailsViewController+DomainCredit.swift */, - 435D10192130C2AB00BB2AA8 /* BlogDetailsViewController+FancyAlerts.swift */, - F53FF3A223EA3E45001AD596 /* BlogDetailsViewController+Header.swift */, - 02761EBF2270072F009BAF0F /* BlogDetailsViewController+SectionHelpers.swift */, - 4349BFF6221205740084F200 /* BlogDetailsSectionHeaderView.swift */, - 02D75D9822793EA2003FF09A /* BlogDetailsSectionFooterView.swift */, - 4349BFF4221205540084F200 /* BlogDetailsSectionHeaderView.xib */, - F53FF3A623EA722F001AD596 /* Detail Header */, + CC5218992279CF3B008998CE /* MediaPickerAlbumListScreen.swift */, + CC52189B2279D295008998CE /* MediaPickerAlbumScreen.swift */, ); - path = "Blog Details"; + path = Media; sourceTree = ""; }; - 3F43603523F368CA001DEE70 /* Blog List */ = { + 3F2F854926FAEF9C000FCDA5 /* Editor */ = { isa = PBXGroup; children = ( - BE13B3E41B2B58D800A4211D /* BlogListViewController.h */, - BE13B3E51B2B58D800A4211D /* BlogListViewController.m */, + BED4D8341FF1208400A11345 /* AztecEditorScreen.swift */, + CC2BB0C92289CC3B0034F9AB /* BlockEditorScreen.swift */, + CC94FC67221452A4002E5825 /* EditorNoticeComponent.swift */, + CC19BE05223FECAC00CAB3E1 /* EditorPostSettings.swift */, + BED4D83A1FF13B8A00A11345 /* EditorPublishEpilogueScreen.swift */, + CC8498CF2241473400DB490A /* EditorSettingsComponents */, + FFD47BDE2474228C00F00660 /* FeaturedImageScreen.swift */, + FA9276B02889550E00C323BB /* ChooseLayoutScreen.swift */, + ); + path = Editor; + sourceTree = ""; + }; + 3F3087C024EDB6900087B548 /* Views */ = { + isa = PBXGroup; + children = ( + 3F3087C324EDB7040087B548 /* AnnouncementCell.swift */, + 80EF672127F160720063B138 /* DashboardCustomAnnouncementCell.swift */, + 3F8CBE0C24EED2CB00F71234 /* FindOutMoreCell.swift */, + 3F73BE5E24EB3B4400BE99FF /* WhatIsNewView.swift */, + 3F662C4924DC9FAC00CAEA95 /* WhatIsNewViewController.swift */, + F5B390E92537E30B0097049E /* GridCell.swift */, + F5E1BA9A253A0A5E0091E9A6 /* StoriesIntroViewController.swift */, + 80EF671E27F135EB0063B138 /* WhatIsNewViewAppearance.swift */, + ); + path = Views; + sourceTree = ""; + }; + 3F37609B23FD803300F0D87F /* Blog + Me */ = { + isa = PBXGroup; + children = ( + 3FCCAA1423F4A1A3004064C0 /* UIBarButtonItem+MeBarButton.swift */, + 3FD0316E24201E08005C0993 /* GravatarButtonView.swift */, + ); + path = "Blog + Me"; + sourceTree = ""; + }; + 3F3D8548251E63DF001CA4D2 /* What's New */ = { + isa = PBXGroup; + children = ( + 3F3D8549251E6406001CA4D2 /* Data store */, + ); + path = "What's New"; + sourceTree = ""; + }; + 3F3D8549251E6406001CA4D2 /* Data store */ = { + isa = PBXGroup; + children = ( + 3F3D854A251E6418001CA4D2 /* AnnouncementsDataStoreTests.swift */, + ); + path = "Data store"; + sourceTree = ""; + }; + 3F3DD0B426FD18D400F5F121 /* Utility */ = { + isa = PBXGroup; + children = ( + 3F3DD0B526FD18EB00F5F121 /* Blog+DomainsDashboardView.swift */, + 175CC16F2720548700622FB4 /* DomainExpiryDateFormatter.swift */, + ); + path = Utility; + sourceTree = ""; + }; + 3F43602D23F31C06001DEE70 /* Presenter */ = { + isa = PBXGroup; + children = ( + 3F43603023F31E09001DEE70 /* MeScenePresenter.swift */, + ); + path = Presenter; + sourceTree = ""; + }; + 3F43603423F368BF001DEE70 /* Blog Details */ = { + isa = PBXGroup; + children = ( + 462F4E0618369F0B0028D2F8 /* BlogDetailsViewController.h */, + 462F4E0718369F0B0028D2F8 /* BlogDetailsViewController.m */, + 74989B8B2088E3650054290B /* BlogDetailsViewController+Activity.swift */, + 02AC3091226FFFAA0018D23B /* BlogDetailsViewController+DomainCredit.swift */, + 8B33BC9427A0C14C00DB5985 /* BlogDetailsViewController+QuickActions.swift */, + 435D10192130C2AB00BB2AA8 /* BlogDetailsViewController+FancyAlerts.swift */, + 02761EBF2270072F009BAF0F /* BlogDetailsViewController+SectionHelpers.swift */, + FAFC065027D27241002F0483 /* BlogDetailsViewController+Dashboard.swift */, + 4349BFF6221205740084F200 /* BlogDetailsSectionHeaderView.swift */, + 02D75D9822793EA2003FF09A /* BlogDetailsSectionFooterView.swift */, + 4349BFF4221205540084F200 /* BlogDetailsSectionHeaderView.xib */, + F53FF3A623EA722F001AD596 /* Detail Header */, + F4D9188529D78C9100974A71 /* BlogDetailsViewController+Strings.swift */, + ); + path = "Blog Details"; + sourceTree = ""; + }; + 3F43603523F368CA001DEE70 /* Blog List */ = { + isa = PBXGroup; + children = ( + BE13B3E41B2B58D800A4211D /* BlogListViewController.h */, + BE13B3E51B2B58D800A4211D /* BlogListViewController.m */, 74EFB5C7208674250070BD4E /* BlogListViewController+Activity.swift */, 3F43603223F36515001DEE70 /* BlogListViewController+BlogDetailsFactory.swift */, D8EB1FD021900810002AE1C4 /* BlogListViewController+SiteCreation.swift */, @@ -5896,5842 +11123,9782 @@ FFDA7E4F1B8DF6E500B83C56 /* BlogSiteVisibilityHelper.m */, 821738081FE04A9E00BEC94C /* DateAndTimeFormatSettingsViewController.swift */, B5AC00671BE3C4E100F8E7C3 /* DiscussionSettingsViewController.swift */, + 17523380246C4F9200870B4A /* HomepageSettingsViewController.swift */, B54346951C6A707D0010B3AD /* LanguageViewController.swift */, - 827704F01F607C0E002E8A03 /* JetpackConnectionViewController.swift */, - 822D60B01F4C747E0016C46D /* JetpackSecuritySettingsViewController.swift */, - 8236EB4A20248FF0007C7CF9 /* JetpackSpeedUpSiteSettingsViewController.swift */, E1468DE61E794A4D0044D80F /* LanguageSelectorViewController.swift */, FF8DDCDD1B5DB1C10098826F /* SettingTableViewCell.h */, FF8DDCDE1B5DB1C10098826F /* SettingTableViewCell.m */, 82B85DF81EDDB807004FD510 /* SiteIconPickerPresenter.swift */, 83FEFC7311FF6C5A0078B462 /* SiteSettingsViewController.h */, 83FEFC7411FF6C5A0078B462 /* SiteSettingsViewController.m */, + 8313B9ED298B1ACD000AF26E /* SiteSettingsViewController+Blogging.swift */, 82C420751FE44BD900CFB15B /* SiteSettingsViewController+Swift.swift */, 3F43603723F369A9001DEE70 /* Related Posts */, ); path = "Site Settings"; sourceTree = ""; }; - 400A2C8D2217AAA1000A8A59 /* Time-based data */ = { + 3F43703D28931FF800475B6E /* Overlay */ = { isa = PBXGroup; children = ( - 400A2C812217A985000A8A59 /* ClicksStatsRecordValue+CoreDataClass.swift */, - 400A2C822217A985000A8A59 /* ClicksStatsRecordValue+CoreDataProperties.swift */, - 400A2C7E2217A985000A8A59 /* CountryStatsRecordValue+CoreDataClass.swift */, - 400A2C7F2217A985000A8A59 /* CountryStatsRecordValue+CoreDataProperties.swift */, - 40EC1F0D2249CA8400F6785E /* OtherAndTotalViewsCount+CoreDataClass.swift */, - 40EC1F0E2249CA8400F6785E /* OtherAndTotalViewsCount+CoreDataProperties.swift */, - 400A2C7C2217A985000A8A59 /* ReferrerStatsRecordValue+CoreDataClass.swift */, - 400A2C7D2217A985000A8A59 /* ReferrerStatsRecordValue+CoreDataProperties.swift */, - 40C403E92215CD1300E8C894 /* SearchResultsStatsRecordValue+CoreDataClass.swift */, - 40C403EA2215CD1300E8C894 /* SearchResultsStatsRecordValue+CoreDataProperties.swift */, - 40C403EF2215D66A00E8C894 /* TopViewedAuthorStatsRecordValue+CoreDataClass.swift */, - 40C403F02215D66A00E8C894 /* TopViewedAuthorStatsRecordValue+CoreDataProperties.swift */, - 40C403F12215D66A00E8C894 /* TopViewedPostStatsRecordValue+CoreDataClass.swift */, - 40C403F22215D66A00E8C894 /* TopViewedPostStatsRecordValue+CoreDataProperties.swift */, - 400A2C792217A985000A8A59 /* TopViewedVideoStatsRecordValue+CoreDataClass.swift */, - 400A2C7A2217A985000A8A59 /* TopViewedVideoStatsRecordValue+CoreDataProperties.swift */, - 400A2C752217A8A0000A8A59 /* VisitsSummaryStatsRecordValue+CoreDataClass.swift */, - 400A2C762217A8A0000A8A59 /* VisitsSummaryStatsRecordValue+CoreDataProperties.swift */, - 988AC37422F10DD900BC1433 /* FileDownloadsStatsRecordValue+CoreDataProperties.swift */, - 988AC37822F10E2C00BC1433 /* FileDownloadsStatsRecordValue+CoreDataClass.swift */, + 3F43703E2893201400475B6E /* JetpackOverlayViewController.swift */, + 3F4370402893207C00475B6E /* JetpackOverlayView.swift */, ); - name = "Time-based data"; + path = Overlay; sourceTree = ""; }; - 40247E002120FE2300AE1C3C /* Automated Transfer */ = { + 3F43704228932EE900475B6E /* Coordinator */ = { isa = PBXGroup; children = ( - 40247E012120FE3600AE1C3C /* AutomatedTransferHelper.swift */, + 3F43704328932F0100475B6E /* JetpackBrandingCoordinator.swift */, + 803DE820290642B4007D4E9C /* JetpackFeaturesRemovalCoordinator.swift */, + 803D90F6292F0188007CC0D0 /* JetpackRedirector.swift */, + 084FC3B829914BD700A17BCF /* JetpackOverlayCoordinator.swift */, + 084FC3BA29914C7F00A17BCF /* JetpackPluginOverlayCoordinator.swift */, + 0857BB3F299275760011CBD1 /* JetpackDefaultOverlayCoordinator.swift */, ); - path = "Automated Transfer"; + path = Coordinator; sourceTree = ""; }; - 402FFB1D218C2B8D00FF4A0B /* Style */ = { + 3F46EEC028BC48D1004F02B2 /* New Landing Screen */ = { isa = PBXGroup; children = ( - B5B56D3019AFB68800B4E29B /* WPStyleGuide+Reply.swift */, - B574CE101B5D8F8600A84FFD /* WPStyleGuide+AlertView.swift */, - B5B56D3119AFB68800B4E29B /* WPStyleGuide+Notifications.swift */, + 3F46EEC328BC4913004F02B2 /* Model */, + 3F46EEC528BC4922004F02B2 /* ViewModel */, + 3F46EEC428BC491B004F02B2 /* Views */, ); - path = Style; + path = "New Landing Screen"; sourceTree = ""; }; - 402FFB22218C36CF00FF4A0B /* Helpers */ = { + 3F46EEC328BC4913004F02B2 /* Model */ = { isa = PBXGroup; children = ( - 402FFB23218C36CF00FF4A0B /* AztecVerificationPromptHelper.swift */, + 3F46EEC628BC4935004F02B2 /* JetpackPrompt.swift */, ); - path = Helpers; + path = Model; sourceTree = ""; }; - 40A71C5F220E1941002E3D25 /* Stats */ = { + 3F46EEC428BC491B004F02B2 /* Views */ = { isa = PBXGroup; children = ( - 400A2C8D2217AAA1000A8A59 /* Time-based data */, - 40F46B6C22121BBC00A2143B /* Insights */, - 40A71C64220E1951002E3D25 /* StatsRecord+CoreDataClass.swift */, - 40A71C60220E1950002E3D25 /* StatsRecord+CoreDataProperties.swift */, - 40A71C62220E1950002E3D25 /* StatsRecordValue+CoreDataClass.swift */, - 40A71C65220E1951002E3D25 /* StatsRecordValue+CoreDataProperties.swift */, + 3F46EECB28BC4962004F02B2 /* JetpackLandingScreenView.swift */, + C34E94B928EDF7D900D27A16 /* InfiniteScrollerView.swift */, + C34E94BB28EDF80700D27A16 /* InfiniteScrollerViewDelegate.swift */, ); - name = Stats; + path = Views; sourceTree = ""; }; - 40E7FEC32211DF490032834E /* Stats */ = { + 3F46EEC528BC4922004F02B2 /* ViewModel */ = { isa = PBXGroup; children = ( - 400199AA222590E100EB0906 /* AllTimeStatsRecordValueTests.swift */, - 400199AC22259FF300EB0906 /* AnnualAndMostPopularTimeStatsRecordValueTests.swift */, - 400A2C8E2217AD7F000A8A59 /* ClicksStatsRecordValueTests.swift */, - 400A2C902217B308000A8A59 /* CountryStatsRecordValueTests.swift */, - 9813512D22F0FC2700F7425D /* FileDownloadsStatsRecordValueTests.swift */, - 40ACCF13224E167900190713 /* FlagsTest.swift */, - 40F50B7F221310D400CBBB73 /* FollowersStatsRecordValueTests.swift */, - 40E7FEC72211EEC00032834E /* LastPostStatsRecordValueTests.swift */, - 40EE948122132F5800CD264F /* PublicizeConectionStatsRecordValueTests.swift */, - 400A2C922217B463000A8A59 /* ReferrerStatsRecordValueTests.swift */, - 40C403ED2215CE9500E8C894 /* SearchResultsStatsRecordValueTests.swift */, - 40E7FEC42211DF790032834E /* StatsRecordTests.swift */, - 40F50B81221310F000CBBB73 /* StatsTestCase.swift */, - 4054F4632214F94D00D261AB /* StreakStatsRecordValueTests.swift */, - 4054F4582214E6FE00D261AB /* TagsCategoriesStatsRecordValueTests.swift */, - 4089C51322371EE30031CE78 /* TodayStatsTests.swift */, - 4054F43D221357B600D261AB /* TopCommentedPostStatsRecordValueTests.swift */, - 40C403F72215D88100E8C894 /* TopViewedStatsTests.swift */, - 400A2C942217B68D000A8A59 /* TopViewedVideoStatsRecordValueTests.swift */, - 400A2C962217B883000A8A59 /* VisitsSummaryStatsRecordValueTests.swift */, + 3F46EED028BFF339004F02B2 /* JetpackPromptsConfiguration.swift */, ); - name = Stats; + path = ViewModel; sourceTree = ""; }; - 40F46B6C22121BBC00A2143B /* Insights */ = { + 3F5094592454EC7A00C4470B /* Tabbed Reader */ = { isa = PBXGroup; children = ( - 40E7FECB2211FFA60032834E /* AllTimeStatsRecordValue+CoreDataClass.swift */, - 40E7FECC2211FFA70032834E /* AllTimeStatsRecordValue+CoreDataProperties.swift */, - 40F46B6822121BA800A2143B /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataClass.swift */, - 40F46B6922121BA800A2143B /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataProperties.swift */, - 405BFB1E223B37A000CD5BEA /* FollowersCountStatsRecordValue+CoreDataClass.swift */, - 405BFB1F223B37A000CD5BEA /* FollowersCountStatsRecordValue+CoreDataProperties.swift */, - 40F50B7B22130E6C00CBBB73 /* FollowersStatsRecordValue+CoreDataClass.swift */, - 40F50B7C22130E6C00CBBB73 /* FollowersStatsRecordValue+CoreDataProperties.swift */, - 40A71C63220E1951002E3D25 /* LastPostStatsRecordValue+CoreDataClass.swift */, - 40A71C61220E1950002E3D25 /* LastPostStatsRecordValue+CoreDataProperties.swift */, - 40EE947D2213213F00CD264F /* PublicizeConnectionStatsRecordValue+CoreDataClass.swift */, - 40EE947E2213213F00CD264F /* PublicizeConnectionStatsRecordValue+CoreDataProperties.swift */, - 4054F45B2214F50300D261AB /* StreakInsightStatsRecordValue+CoreDataClass.swift */, - 4054F45C2214F50300D261AB /* StreakInsightStatsRecordValue+CoreDataProperties.swift */, - 4054F45D2214F50300D261AB /* StreakStatsRecordValue+CoreDataClass.swift */, - 4054F45E2214F50300D261AB /* StreakStatsRecordValue+CoreDataProperties.swift */, - 4054F4542214E3C800D261AB /* TagsCategoriesStatsRecordValue+CoreDataClass.swift */, - 4054F4552214E3C800D261AB /* TagsCategoriesStatsRecordValue+CoreDataProperties.swift */, - 4089C50E22371B120031CE78 /* TodayStatsRecordValue+CoreDataClass.swift */, - 4089C50F22371B120031CE78 /* TodayStatsRecordValue+CoreDataProperties.swift */, - 4054F439221354E000D261AB /* TopCommentedPostStatsRecordValue+CoreDataClass.swift */, - 4054F43A221354E000D261AB /* TopCommentedPostStatsRecordValue+CoreDataProperties.swift */, - 4054F4402213635000D261AB /* TopCommentsAuthorStatsRecordValue+CoreDataClass.swift */, - 4054F4412213635000D261AB /* TopCommentsAuthorStatsRecordValue+CoreDataProperties.swift */, + 3F50945A2454ECA000C4470B /* ReaderTabItemsStoreTests.swift */, + 3F82310E24564A870086E9B8 /* ReaderTabViewTests.swift */, + 3F50945E245537A700C4470B /* ReaderTabViewModelTests.swift */, ); - name = Insights; + name = "Tabbed Reader"; sourceTree = ""; }; - 4326191322FCB8BE003C7642 /* Colors and Styles */ = { + 3F526C512538CF2A0069706C /* WordPressStatsWidgets */ = { isa = PBXGroup; children = ( - 4326191422FCB9DC003C7642 /* MurielColor.swift */, - 435B762122973D0600511813 /* UIColor+MurielColors.swift */, - 436110DF22C4241A000773AD /* UIColor+MurielColorsObjC.swift */, - E678FC141C76241000F55F55 /* WPStyleGuide+ApplicationStyles.swift */, - 17D975AE1EF7F6F100303D63 /* WPStyleGuide+Aztec.swift */, - 4020B2BC2007AC850002C963 /* WPStyleGuide+Gridicon.swift */, - 17AD36D41D36C1A60044B10D /* WPStyleGuide+Search.swift */, - 17F52DB62315233300164966 /* WPStyleGuide+FilterTabBar.swift */, + 3F8EEC4D25B4817000EC9DAE /* StatsWidgets.swift */, + 3F8EEC6F25B4849A00EC9DAE /* SiteListProvider.swift */, + 3FFDDCB925B8A65F008D5BDD /* Widgets */, + 3FB34ABB25672A59001A74A6 /* Model */, + 3FFDDC0325B89F0C008D5BDD /* Cache */, + 3F2F0C06256C68E1003351C7 /* Remote service */, + 3FA59DCC2582E53F0073772F /* Tracks */, + 3F526D2B2539F9D60069706C /* Views */, + 3F526CA82538E0ED0069706C /* Supporting Files */, + 3F526C552538CF2B0069706C /* Assets.xcassets */, ); - path = "Colors and Styles"; + path = WordPressStatsWidgets; sourceTree = ""; }; - 4349B0A6218A2E810034118A /* Revisions */ = { + 3F526CA82538E0ED0069706C /* Supporting Files */ = { isa = PBXGroup; children = ( - 9A2B28E7219046ED00458F2A /* ShowRevisionsListManger.swift */, - 4349B0AB218A45270034118A /* RevisionsTableViewController.swift */, - 9A4E61FC21A2CC4C0017A925 /* Browser */, - 9A2B28F2219211B900458F2A /* Views */, + 3F526C572538CF2B0069706C /* Info.plist */, + 24D40492253F6D01002843AC /* WordPressStatsWidgets.entitlements */, + 3F015D7F253F9CDB00991CCB /* WordPressStatsWidgetsRelease-Alpha.entitlements */, + 24D40491253F6CEE002843AC /* WordPressStatsWidgetsRelease-Internal.entitlements */, + 3F1FD31C2548B30D0060C53A /* WordPressStatsWidgets-Bridging-Header.h */, ); - path = Revisions; + path = "Supporting Files"; sourceTree = ""; }; - 436D55D7210F85C200CEAA33 /* ViewLoading */ = { + 3F526D2B2539F9D60069706C /* Views */ = { isa = PBXGroup; children = ( - 436D55D8210F85DD00CEAA33 /* NibLoadable.swift */, - 436D55DA210F862A00CEAA33 /* NibReusable.swift */, - 436D55DE210F866900CEAA33 /* StoryboardLoadable.swift */, + 3FCF66E825CAF8C50047F337 /* ListStatsView.swift */, + 3F5689FF25420DE80048A9E4 /* MultiStatsView.swift */, + 3F5689EF254209790048A9E4 /* SingleStatView.swift */, + 3F526D562539FAC60069706C /* StatsWidgetsView.swift */, + 3FAA18CB25797B85002B1911 /* UnconfiguredView.swift */, + 3F568A1D254210950048A9E4 /* Cards */, + 3F26569F25AF4DBF0073A832 /* Localization */, ); - name = ViewLoading; + path = Views; sourceTree = ""; }; - 436D55EE2115CB3D00CEAA33 /* RegisterDomain */ = { + 3F55A01826FCB0BD0049D379 /* Views */ = { isa = PBXGroup; children = ( - 436D55EF2115CB6800CEAA33 /* RegisterDomainDetailsSectionTests.swift */, - 436D55F4211632B700CEAA33 /* RegisterDomainDetailsViewModelTests.swift */, - 436D5654212209D600CEAA33 /* RegisterDomainDetailsServiceProxyMock.swift */, - 02BE5CBF2281B53F00E351BA /* RegisterDomainDetailsViewModelLoadingStateTests.swift */, + 3FAF9CC126D01CFE00268EA2 /* DomainsDashboardView.swift */, + 3F3DD0B126FD176800F5F121 /* PresentationCard.swift */, + 3F3DD0AE26FCDA3100F5F121 /* PresentationButton.swift */, + 3FA62FD226FE2E4B0020793A /* ShapeWithTextView.swift */, + 011896A429D5B72500D34BA9 /* DomainsDashboardCoordinator.swift */, + 011896A729D5BBB400D34BA9 /* DomainsDashboardFactory.swift */, ); - name = RegisterDomain; + path = Views; sourceTree = ""; }; - 436D560B2117312600CEAA33 /* RegisterDomainSuggestions */ = { + 3F568A1D254210950048A9E4 /* Cards */ = { isa = PBXGroup; children = ( - 436D560C2117312600CEAA33 /* RegisterDomainSuggestionsViewController.swift */, - 436D560D2117312600CEAA33 /* RegisterDomainSuggestionsTableViewController.swift */, + 3FCF66FA25CAF8E00047F337 /* ListRow.swift */, + 3F568A2E254216550048A9E4 /* FlexibleCard.swift */, + 3F568A1E254213B60048A9E4 /* VerticalCard.swift */, + 3FA59B99258289E30073772F /* StatsValueView.swift */, ); - path = RegisterDomainSuggestions; + path = Cards; sourceTree = ""; }; - 436D560E2117312700CEAA33 /* RegisterDomainDetails */ = { + 3F5B9B44288B0521001D17E9 /* Badge */ = { isa = PBXGroup; children = ( - 436D560F2117312700CEAA33 /* ViewModel */, - 436D56142117312700CEAA33 /* ViewController */, - 436D561A2117312700CEAA33 /* Views */, + 3F5B9B42288AFE4B001D17E9 /* DashboardBadgeCell.swift */, ); - path = RegisterDomainDetails; + path = Badge; sourceTree = ""; }; - 436D560F2117312700CEAA33 /* ViewModel */ = { + 3F662C4824DC9F8300CAEA95 /* What's New */ = { isa = PBXGroup; children = ( - 436D56112117312700CEAA33 /* RegisterDomainDetailsViewModel.swift */, - 404B35D222E9BA0800AD0B37 /* RegisterDomainDetailsViewModel+CountryDialCodes.swift */, - 436D562F2117410C00CEAA33 /* RegisterDomainDetailsViewModel+CellIndex.swift */, - 436D56122117312700CEAA33 /* RegisterDomainDetailsViewModel+RowDefinitions.swift */, - 436D56132117312700CEAA33 /* RegisterDomainDetailsViewModel+RowList.swift */, - 436D56102117312700CEAA33 /* RegisterDomainDetailsViewModel+SectionDefinitions.swift */, - 436D564E211E122D00CEAA33 /* RegisterDomainDetailsServiceProxy.swift */, + 3F6A7E90251BC1C4005B6A61 /* Dependency container */, + 3F8CBE1124EF955700F71234 /* Data store */, + 3F8CBE1224EF958700F71234 /* Presenter */, + 3F3087C024EDB6900087B548 /* Views */, ); - path = ViewModel; + path = "What's New"; sourceTree = ""; }; - 436D56142117312700CEAA33 /* ViewController */ = { + 3F6A7E90251BC1C4005B6A61 /* Dependency container */ = { isa = PBXGroup; children = ( - 436D56182117312700CEAA33 /* RegisterDomainDetailsViewController+LocalizedStrings.swift */, - 436D56192117312700CEAA33 /* RegisterDomainDetailsViewController.swift */, - 436D56152117312700CEAA33 /* RegisterDomainDetailsViewController+Cells.swift */, - 436D56162117312700CEAA33 /* RegisterDomainDetailsViewController+HeaderFooter.swift */, + 3F6A7E91251BC1DC005B6A61 /* RootViewCoordinator+WhatIsNew.swift */, ); - path = ViewController; + path = "Dependency container"; sourceTree = ""; }; - 436D561A2117312700CEAA33 /* Views */ = { + 3F6AD0542502A8ED00080F3B /* Cache */ = { isa = PBXGroup; children = ( - 436D561C2117312700CEAA33 /* RegisterDomainDetailsErrorSectionFooter.swift */, - 436D561E2117312700CEAA33 /* RegisterDomainDetailsErrorSectionFooter.xib */, - 436D561D2117312700CEAA33 /* RegisterDomainDetailsFooterView.swift */, - 436D561B2117312700CEAA33 /* RegisterDomainDetailsFooterView.xib */, + 3F6AD0552502A91400080F3B /* AnnouncementsCache.swift */, ); - path = Views; + path = Cache; sourceTree = ""; }; - 437EF0C820F79C0C0086129B /* Register */ = { + 3F751D442491A8B20008A2B1 /* Zendesk */ = { isa = PBXGroup; children = ( - 436D560E2117312700CEAA33 /* RegisterDomainDetails */, - 436D560B2117312600CEAA33 /* RegisterDomainSuggestions */, + 3F751D452491A93D0008A2B1 /* ZendeskUtilsTests+Plans.swift */, ); - path = Register; + name = Zendesk; sourceTree = ""; }; - 4395A15E210672C900844E8E /* QuickStart */ = { + 3F8513DD260D090000A4B938 /* Components */ = { isa = PBXGroup; children = ( - 9A4E215B21F75BBE00EFF212 /* QuickStartChecklistManager.swift */, - 43C9908D21067E22009EFFEB /* QuickStartChecklistViewController.swift */, - 432A5ADF21F9222A00603959 /* QuickStartListTitleCell.swift */, - 431EF35921F7D4000017BE16 /* QuickStartListTitleCell.xib */, - 98FF6A3D23A30A240025FD72 /* QuickStartNavigationSettings.swift */, - 4348C88221002FBD00735DC0 /* QuickStartTourGuide.swift */, - 4395A1582106389800844E8E /* QuickStartTours.swift */, - 9A4E215D21F87A8500EFF212 /* Cells */, - 9A4E215E21F87AC300EFF212 /* Views */, + 3F8513DE260D091500A4B938 /* RoundRectangleView.swift */, + 3F851427260D1EA300A4B938 /* CircledIcon.swift */, + 178810B42611D25600A98BD8 /* Text+BoldSubString.swift */, ); - name = QuickStart; + path = Components; sourceTree = ""; }; - 43D74AD220FB5A82004AD934 /* Views */ = { + 3F851413260D0A1D00A4B938 /* Editor */ = { isa = PBXGroup; children = ( - 43D74AD520FB5AD5004AD934 /* RegisterDomainSectionHeaderView.swift */, - 43D74AD320FB5ABA004AD934 /* RegisterDomainSectionHeaderView.xib */, + 3F851414260D0A3300A4B938 /* UnifiedPrologueEditorContentView.swift */, + 1788106E260E488B00A98BD8 /* UnifiedPrologueNotificationsContentView.swift */, + 178810D82612037800A98BD8 /* UnifiedPrologueReaderContentView.swift */, ); - path = Views; + path = Editor; sourceTree = ""; }; - 45C73C23113C36F50024D0D2 /* Resources-iPad */ = { + 3F86A83829D19C1C005D20C0 /* NUX */ = { isa = PBXGroup; children = ( + 3F86A83629D19C15005D20C0 /* SignupEpilogueTableViewControllerTests.swift */, ); - name = "Resources-iPad"; + path = NUX; sourceTree = ""; }; - 5703A4C722C0214C0028A343 /* Style */ = { + 3F8B459E29283D3800730FA4 /* Success card */ = { isa = PBXGroup; children = ( - 5D4E30CF1AA4B41A000D9904 /* WPStyleGuide+Pages.h */, - 5D4E30D01AA4B41A000D9904 /* WPStyleGuide+Pages.m */, + F4D8296E2931092800038726 /* Collection View */, + F4D8296D2931091B00038726 /* Table View */, + 3F8B45A6292C1A2300730FA4 /* MigrationSuccessCardView.swift */, + F4D829672931059000038726 /* MigrationSuccessActionHandler.swift */, ); - name = Style; + path = "Success card"; sourceTree = ""; }; - 572FB3FE223A800500933C76 /* Classes */ = { + 3F8CBE1124EF955700F71234 /* Data store */ = { isa = PBXGroup; children = ( - 572FB3FF223A802900933C76 /* Stores */, + 3F6AD0542502A8ED00080F3B /* Cache */, + 3F8CBE0A24EEB0EA00F71234 /* AnnouncementsDataSource.swift */, + 3F758FD424F6FB4900BBA2FC /* AnnouncementsStore.swift */, + F532AD60253B81320013B42E /* StoriesIntroDataSource.swift */, ); - path = Classes; + path = "Data store"; sourceTree = ""; }; - 572FB3FF223A802900933C76 /* Stores */ = { + 3F8CBE1224EF958700F71234 /* Presenter */ = { isa = PBXGroup; children = ( - 572FB400223A806000933C76 /* NoticeStoreTests.swift */, + 3F73BE5C24EB38E200BE99FF /* WhatIsNewScenePresenter.swift */, ); - path = Stores; + path = Presenter; sourceTree = ""; }; - 5749984522FA0EB900CE86ED /* Utils */ = { + 3F8D989C26153491003619E5 /* Background */ = { isa = PBXGroup; children = ( - 5749984622FA0F2E00CE86ED /* PostNoticeViewModelTests.swift */, + 3F8D988826153484003619E5 /* UnifiedPrologueBackgroundView.swift */, ); - name = Utils; - path = ViewRelated/Post/Utils; + path = Background; sourceTree = ""; }; - 57DF04BF2314895E00CC93D6 /* Views */ = { + 3FA59DCC2582E53F0073772F /* Tracks */ = { isa = PBXGroup; children = ( - 57DF04C0231489A200CC93D6 /* PostCardStatusViewModelTests.swift */, + 98390AC2254C984700868F0A /* Tracks+StatsWidgets.swift */, ); - name = Views; - path = ViewRelated/Post/Views; - sourceTree = ""; - }; - 57E3C98223835A57004741DB /* Controllers */ = { - isa = PBXGroup; - children = ( - 8B939F4223832E5D00ACCB0F /* PostListViewControllerTests.swift */, - F543AF5623A84E4D0022F595 /* PublishSettingsControllerTests.swift */, - F543AF5823A84F200022F595 /* SchedulingCalendarViewControllerTests.swift */, - F582060323A88379005159A9 /* TimePickerViewControllerTests.swift */, - F5D0A65123CCD3B600B20D27 /* PreviewWebKitViewControllerTests.swift */, - ); - name = Controllers; - path = ViewRelated/Post/Controllers; + path = Tracks; sourceTree = ""; }; - 59B48B601B99E0B0008EBB84 /* TestUtilities */ = { + 3FA640582670CCD40064401E /* UITestsFoundation */ = { isa = PBXGroup; children = ( - 59B48B611B99E132008EBB84 /* JSONLoader.swift */, - E157D5DF1C690A6C00F04FB9 /* ImmuTableTestUtils.swift */, - 570BFD8F2282418A007859A8 /* PostBuilder.swift */, - 57B71D4D230DB5F200789A68 /* BlogBuilder.swift */, - F11023A223186BCA00C4E84A /* MediaBuilder.swift */, - 57889AB723589DF100DAE56D /* PageBuilder.swift */, + 3F762E9626784BED0088CD45 /* FancyAlertComponent.swift */, + 3F762E9C26784DB40088CD45 /* Globals.swift */, + 3FA6405A2670CCD40064401E /* Info.plist */, + 3F762E9226784A950088CD45 /* Logger.swift */, + 3FE39A4326F8391D006E2B3A /* Screens */, + 3FA640592670CCD40064401E /* UITestsFoundation.h */, + 3F762E9426784B540088CD45 /* WireMock.swift */, + 3F107B1829B6F7E0009B3658 /* XCTestCase+Utils.swift */, + 3FB5C2B227059AC8007D0ECE /* XCUIElement+Scroll.swift */, + 3F762E9A26784D2A0088CD45 /* XCUIElement+Utils.swift */, + 3F762E9826784CC90088CD45 /* XCUIElementQuery+Utils.swift */, ); - name = TestUtilities; + path = UITestsFoundation; sourceTree = ""; }; - 59DD94311AC479DC0032DD6B /* Logging */ = { + 3FB1928E26C6106C000F5AA3 /* Time Selector */ = { isa = PBXGroup; children = ( - F928EDA2226140620030D451 /* WPCrashLoggingProvider.swift */, - 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */, - 59DD94321AC479ED0032DD6B /* WPLogger.h */, - 59DD94331AC479ED0032DD6B /* WPLogger.m */, - F93735F022D534FE00A3C312 /* LoggingURLRedactor.swift */, - 986CC4D120E1B2F6004F300E /* CustomLogFormatter.swift */, - 8B3DECAA2388506400A459C2 /* SentryStartupEvent.swift */, + 3F73388126C1CE9B0075D1DD /* TimeSelectionButton.swift */, + 3F88065A26C30F2A0074DD21 /* TimeSelectionViewController.swift */, + 3FB1928F26C6109F000F5AA3 /* TimeSelectionView.swift */, ); - path = Logging; + path = "Time Selector"; sourceTree = ""; }; - 59ECF8791CB705EB00E68F25 /* Posts */ = { + 3FB34ABB25672A59001A74A6 /* Model */ = { isa = PBXGroup; children = ( - 59ECF87A1CB7061D00E68F25 /* PostSharingControllerTests.swift */, - F18B43771F849F580089B817 /* PostAttachmentTests.swift */, + 3F6DA04025646F96002AB88F /* HomeWidgetData.swift */, + 3F5C861925C9EA2500BABE64 /* HomeWidgetAllTimeData.swift */, + 3F8B136C25D08F34004FAC0A /* HomeWidgetThisWeekData.swift */, + 3FB34ACA25672A90001A74A6 /* HomeWidgetTodayData.swift */, + 3F63B93B258179D100F581BE /* StatsWidgetEntry.swift */, + 3FE20C1425CF165700A15525 /* GroupedViewData.swift */, + 3FE20C3625CF211F00A15525 /* ListViewData.swift */, ); - name = Posts; + path = Model; sourceTree = ""; }; - 5D08B8FC19647C0300D5B381 /* Views */ = { + 3FBB2D2927FB6BFA00C57BBF /* Site Name */ = { isa = PBXGroup; children = ( - E60C2ED61DE5075100488630 /* ReaderCommentCell.swift */, - E60C2ED41DE5048200488630 /* ReaderCommentCell.xib */, - 088B89881DA6F93B000E8DEF /* ReaderPostCardContentLabel.swift */, - 5DAE40AB19EC70930011A0AE /* ReaderPostHeaderView.h */, - 5DAE40AC19EC70930011A0AE /* ReaderPostHeaderView.m */, - E66EB6F81C1B7A76003DABC5 /* ReaderSpacerView.swift */, + 3FBB2D2A27FB6CB200C57BBF /* SiteNameViewController.swift */, + 3FBB2D2D27FB715900C57BBF /* SiteNameView.swift */, + C352870427FDD35C004E2E51 /* SiteNameStep.swift */, ); - name = Views; + path = "Site Name"; sourceTree = ""; }; - 5D08B8FD19647C0800D5B381 /* Controllers */ = { + 3FC7F89C261233FC00FD8728 /* Stats */ = { isa = PBXGroup; children = ( - D800D86220997B6400E7C7E5 /* ReaderMenuItems */, - D816043E209C1AD300ABAFFA /* ReaderPostActions */, - D817798F20ABF26800330998 /* ReaderCellConfiguration.swift */, - 5DF8D25F19E82B1000A2CD95 /* ReaderCommentsViewController.h */, - 5DF8D26019E82B1000A2CD95 /* ReaderCommentsViewController.m */, - E69EF9D21BFA539F00ED0554 /* ReaderDetailViewController.swift */, - E6D3B1421D1C702600008D4B /* ReaderFollowedSitesViewController.swift */, - E6DE44661B90D251000FA7EF /* ReaderHelpers.swift */, - E6E57CD41D0F08B200C22E3E /* ReaderMenuViewController.swift */, - E6A2158D1D10627500DE5270 /* ReaderMenuViewModel.swift */, - D817799320ABFDB300330998 /* ReaderPostCellActions.swift */, - E6F058011C1A122B008000F9 /* ReaderPostMenu.swift */, - D88106FB20C0D4A4001D2F00 /* ReaderSavedPostCellActions.swift */, - 1705E54F20A5DA5700EF1C9D /* ReaderSavedPostsViewController.swift */, - D88106F920C0CFEE001D2F00 /* ReaderSaveForLaterRemovedPosts.swift */, - E6DAABDC1CF632EC0069D933 /* ReaderSearchSuggestionsViewController.swift */, - E64ECA4C1CE62041000188A0 /* ReaderSearchViewController.swift */, - 17CE77EE20C6CDAA001DEA5A /* ReaderSiteSearchViewController.swift */, - 5D1D04741B7A50B100CDE646 /* ReaderStreamViewController.swift */, - E69551F51B8B6AE200CB8E4F /* ReaderStreamViewController+Helper.swift */, - 7371E6D121FA730700596C0A /* ReaderStreamViewController+Sharing.swift */, - D81879D820ABC647000CFA95 /* ReaderTableConfiguration.swift */, - D87A329520ABD60700F4726F /* ReaderTableContent.swift */, - 5D42A401175E76A1005CFF05 /* WPImageViewController.h */, - 5D42A402175E76A2005CFF05 /* WPImageViewController.m */, + 3FC7F89D2612341900FD8728 /* UnifiedPrologueStatsContentView.swift */, ); - name = Controllers; + path = Stats; sourceTree = ""; }; - 5D09CBA61ACDE532007A23BD /* Utils */ = { + 3FD83CBD246C74B800381999 /* Migrator */ = { isa = PBXGroup; children = ( - 595CB3751D2317D50082C7E9 /* PostListFilter.swift */, - 43AB7C5D1D3E70510066CB6A /* PostListFilterSettings.swift */, - E1CECE041E6F01CE009C6695 /* PostPreviewGenerator.swift */, - 174C9696205A846E00CEEF6E /* PostNoticeViewModel.swift */, - 170CE73F2064478600A48191 /* PostNoticeNavigationCoordinator.swift */, - 570BFD8A22823D7B007859A8 /* PostActionSheet.swift */, - 570265142298921800F2214C /* PostListTableViewHandler.swift */, - 57047A4E22A961BC00B461DF /* PostSearchHeader.swift */, - F16C35DB23F3F78E00C81331 /* AutoUploadMessageProvider.swift */, - F16C35D923F3F76C00C81331 /* PostAutoUploadMessageProvider.swift */, - 8B8FE8562343952B00F9AD2E /* PostAutoUploadMessages.swift */, - 7E92A1FA233CB1B7006D281B /* Autosaver.swift */, + 3FD83CBE246C751800381999 /* CoreDataIterativeMigrator.swift */, ); - name = Utils; + path = Migrator; sourceTree = ""; }; - 5D1EBF56187C9B95003393F8 /* Categories */ = { + 3FE39A4326F8391D006E2B3A /* Screens */ = { isa = PBXGroup; children = ( - A0E293EF0E21027E00C6919C /* WPAddPostCategoryViewController.h */, - A0E293F00E21027E00C6919C /* WPAddPostCategoryViewController.m */, - 7059CD1F0F332B6500A0660B /* WPCategoryTree.h */, - 7059CD200F332B6500A0660B /* WPCategoryTree.m */, - 5D5D0025187DA9D30027CEF6 /* PostCategoriesViewController.h */, - 5D5D0026187DA9D30027CEF6 /* PostCategoriesViewController.m */, + F5E032EB240D49FF003AF350 /* ActionSheetComponent.swift */, + FA41044C263932AC00E90EBF /* ActivityLogScreen.swift */, + 3F2F854926FAEF9C000FCDA5 /* Editor */, + 3F2F854126FAEA2D000FCDA5 /* Jetpack */, + BED4D83D1FF13C7300A11345 /* Login */, + CC2BB0CC2289D0F20034F9AB /* Me */, + 3F2F854626FAEBBA000FCDA5 /* Media */, + F97DA41F23D67B820050E791 /* MediaScreen.swift */, + BE6DD3311FD6803700E55192 /* MeTabScreen.swift */, + BE8707162006B774004FB5A4 /* MySiteScreen.swift */, + BE6DD32D1FD67EDA00E55192 /* MySitesScreen.swift */, + BE87071A2006E65C004FB5A4 /* NotificationsScreen.swift */, + 3F30A6AF299B412E0004452F /* PeopleScreen.swift */, + F9C47A8B238C801600AAD9ED /* PostsScreen.swift */, + BE8707182006E48E004FB5A4 /* ReaderScreen.swift */, + CC7CB97422B159FE00642EE9 /* Signup */, + FA9276AE2888557500C323BB /* SiteIntentScreen.swift */, + 7EF9F65622F03C9200F79BBF /* SiteSettingsScreen.swift */, + F9C47A8E238C9D6400AAD9ED /* StatsScreen.swift */, + BE6DD32F1FD67F3B00E55192 /* TabNavComponent.swift */, + EAD08D0D29D45E23001A72F9 /* CommentsScreen.swift */, + D82E087729EEB7AF0098F500 /* DomainsScreen.swift */, ); - path = Categories; + path = Screens; sourceTree = ""; }; - 5D49B03519BE37CC00703A9B /* 20-21 */ = { + 3FEC241325D73C53007AFE63 /* Milestone Notifications */ = { isa = PBXGroup; children = ( - B5A6CEA519FA800E009F07DE /* AccountToAccount20to21.swift */, - E66969CB1B9E2EBF00EC9C00 /* SafeReaderTopicToReaderTopic.h */, - E66969CC1B9E2EBF00EC9C00 /* SafeReaderTopicToReaderTopic.m */, + 3FEC241425D73E8B007AFE63 /* ConfettiView.swift */, ); - name = "20-21"; + path = "Milestone Notifications"; sourceTree = ""; }; - 5D577D301891278D00B964C3 /* Geolocation */ = { + 3FFA5ECF2876129900830E28 /* Branding */ = { isa = PBXGroup; children = ( - 5D577D31189127BE00B964C3 /* PostGeolocationViewController.h */, - 5D577D32189127BE00B964C3 /* PostGeolocationViewController.m */, - 5D577D341891360900B964C3 /* PostGeolocationView.h */, - 5D577D351891360900B964C3 /* PostGeolocationView.m */, + 3F720C2028899DD900519938 /* JetpackBrandingVisibility.swift */, + C3FBF4E728AFEDF8003797DF /* JetpackBrandingAnalyticsHelper.swift */, + 803BB98E29667BAF00B3F6D6 /* JetpackBrandingTextProvider.swift */, + 805CC0BA29668918002941DC /* JetpackBrandedScreen.swift */, + 3FFA5ED0287612AD00830E28 /* Banner */, + 3F5B9B44288B0521001D17E9 /* Badge */, + 800035BF291DDF57007D2D26 /* Fullscreen Overlay */, + 3F43703D28931FF800475B6E /* Overlay */, + 3FFA5ED32876216700830E28 /* Button */, + 3F43704228932EE900475B6E /* Coordinator */, + 80535DB62946C74B00873161 /* Menu Card */, + FA332AD229C1F98000182FBB /* Moved To Jetpack */, ); - path = Geolocation; + path = Branding; sourceTree = ""; }; - 5D5A6E901B613C1800DAF819 /* Cards */ = { + 3FFA5ED0287612AD00830E28 /* Banner */ = { isa = PBXGroup; children = ( - D816B8CB2112D4960052CE4D /* News Card */, - E65219FA1B8D10DA000B1217 /* ReaderBlockedSiteCell.swift */, - E65219F81B8D10C2000B1217 /* ReaderBlockedSiteCell.xib */, - 5D2B30B81B7411C700DA15F3 /* ReaderCardDiscoverAttributionView.swift */, - E6D3E8481BEBD871002692E8 /* ReaderCrossPostCell.swift */, - E6D3E84A1BEBD888002692E8 /* ReaderCrossPostCell.xib */, - E6A3384F1BB0A70F00371587 /* ReaderGapMarkerCell.swift */, - E6A3384D1BB0A50900371587 /* ReaderGapMarkerCell.xib */, - 5D5A6E911B613CA400DAF819 /* ReaderPostCardCell.swift */, - 5D5A6E921B613CA400DAF819 /* ReaderPostCardCell.xib */, - D88106F520C0C9A8001D2F00 /* ReaderSavedPostUndoCell.swift */, - D88106F620C0C9A8001D2F00 /* ReaderSavedPostUndoCell.xib */, + B0CD27CE286F8858009500BF /* JetpackBannerView.swift */, + C3C2F84728AC8EBF00937E45 /* JetpackBannerScrollVisibility.swift */, + C3835558288B02B00062E402 /* JetpackBannerWrapperViewController.swift */, + 3FAE0651287C8FC500F46508 /* JPScrollViewDelegate.swift */, ); - name = Cards; + path = Banner; sourceTree = ""; }; - 5D6651461637324000EBDA7D /* Sounds */ = { + 3FFA5ED32876216700830E28 /* Button */ = { isa = PBXGroup; children = ( - 5D69DBC3165428CA00A2D1F7 /* n.caf */, + 3FFA5ED12876152E00830E28 /* JetpackButton.swift */, + 3F4D034F28A56F9B00F0A4FD /* CircularImageButton.swift */, ); - name = Sounds; + path = Button; sourceTree = ""; }; - 5D6C4B0D1B604190005E3C43 /* RichTextView */ = { + 3FFDDC0325B89F0C008D5BDD /* Cache */ = { isa = PBXGroup; children = ( - 5D6C4B0E1B604190005E3C43 /* NSAttributedString+RichTextView.swift */, - 5D6C4B0F1B604190005E3C43 /* RichTextView.swift */, - 5D6C4B101B604190005E3C43 /* UITextView+RichTextView.swift */, + 3FA53E9B256571D800F4D9A2 /* HomeWidgetCache.swift */, ); - path = RichTextView; + path = Cache; sourceTree = ""; }; - 5D7A577D1AFBFD7C0097C028 /* Models */ = { + 3FFDDCB925B8A65F008D5BDD /* Widgets */ = { isa = PBXGroup; children = ( - F1B1E7A224098FA100549E2A /* BlogTests.swift */, - D816B8D62112D75C0052CE4D /* NewsCardTests.swift */, - D816B8D02112D5960052CE4D /* DefaultNewsManagerTests.swift */, - D816B8C92112D2FD0052CE4D /* LocalNewsServiceTests.swift */, - D816B8BF2112CC930052CE4D /* NewsItemTests.swift */, - D848CC1620FF38EA00A9038F /* FormattableCommentRangeTests.swift */, - D848CC1420FF33FC00A9038F /* NotificationContentRangeTests.swift */, - D800D87B20999CA200E7C7E5 /* SearchMenuItemCreatorTests.swift */, - D800D87920999C0500E7C7E5 /* OtherMenuItemCreatorTests.swift */, - D800D87720999B6D00E7C7E5 /* LikedMenuItemCreatorTests.swift */, - D800D87520999AE700E7C7E5 /* DiscoverMenuItemCreatorTests.swift */, - D800D873209997DB00E7C7E5 /* FollowingMenuItemCreatorTests.swift */, - E6B9B8A91B94E1FE0001B92F /* ReaderPostTest.m */, - B55F1AA11C107CE200FD04D4 /* BlogSettingsDiscussionTests.swift */, - E6A2158F1D1065F200DE5270 /* AbstractPostTest.swift */, - 5960967E1CF7959300848496 /* PostTests.swift */, - 0879FC151E9301DD00E1EFC8 /* MediaTests.swift */, - D826D67E211D21C700A5D8FE /* NullMockUserDefaults.swift */, - 8B8C814C2318073300A0E620 /* BasePostTests.swift */, + 3F526C522538CF2A0069706C /* WordPressHomeWidgetToday.swift */, + 3F8B138E25D09AA5004FAC0A /* WordPressHomeWidgetThisWeek.swift */, + 3F5C86BF25CA197500BABE64 /* WordPressHomeWidgetAllTime.swift */, ); - name = Models; + path = Widgets; sourceTree = ""; }; - 5D98A1491B6C09730085E904 /* Style */ = { + 3FFDEF7D29177F4200B625CE /* Common */ = { isa = PBXGroup; children = ( - 5D1181E61B4D6DEB003F3084 /* WPStyleGuide+Reader.swift */, - 5D7DEA2819D488DD0032EE77 /* WPStyleGuide+ReaderComments.swift */, + F49B9A04293A21A7000CEFCE /* Analytics */, + F49B99FE2937C9B4000CEFCE /* MigrationEmailService.swift */, + 3FFDEF8229179CD000B625CE /* MigrationDependencyContainer.swift */, + F41BDD772910AFB900B7F2B0 /* Navigation */, + F4F9D5EE29096D0400502576 /* Views */, + F478B151292FC1BC00AA8645 /* MigrationAppearance.swift */, + C33A5ADB2935848F00961E3A /* MigrationAppDetection.swift */, + F4E79300296EEE320025E8E0 /* MigrationState.swift */, ); - name = Style; + path = Common; sourceTree = ""; }; - 5DA5BF4918E32DDB005F11F9 /* Themes */ = { + 3FFDEF862918595200B625CE /* All done */ = { isa = PBXGroup; children = ( - 596C035F1B84F24000899EEB /* ThemeBrowser.storyboard */, - 820ADD6F1F3A1F88002D7F93 /* ThemeBrowserSectionHeaderView.xib */, - 820ADD711F3A226E002D7F93 /* ThemeBrowserSectionHeaderView.swift */, - FA77E0291BE17CFC006D45E0 /* ThemeBrowserHeaderView.swift */, - 596C035D1B84F21D00899EEB /* ThemeBrowserViewController.swift */, - 598DD1701B97985700146967 /* ThemeBrowserCell.swift */, - FACB36F01C5C2BF800C6DF4E /* ThemeWebNavigationDelegate.swift */, - FA1ACAA11BC6E45D00DDDCE2 /* WPStyleGuide+Themes.swift */, - 1767494D1D3633A000B8D1D1 /* ThemeBrowserSearchHeaderView.swift */, + 3FFDEF892918597700B625CE /* MigrationDoneViewController.swift */, + 3FFDEF872918596B00B625CE /* MigrationDoneViewModel.swift */, ); - path = Themes; + path = "All done"; sourceTree = ""; }; - 5DA5BF4A18E32DE2005F11F9 /* Media */ = { + 3FFDEF8D2918625600B625CE /* Configuration */ = { isa = PBXGroup; children = ( - 177B4C232123152D00CF8084 /* Giphy */, - D8A3A5AD206A059100992576 /* StockPhotos */, - FF8A04DF1D9BFE7400523BC4 /* CachedAnimatedImageView.swift */, - 7435CE7220A4B9170075A1B9 /* AnimatedImageCache.swift */, - B5FF3BE61CAD881100C1D597 /* ImageCropOverlayView.swift */, - B5A05AD81CA48601002EC787 /* ImageCropViewController.swift */, - B5EEB19E1CA96D19004B6540 /* ImageCropViewController.xib */, - FF945F6E1B28242300FB8AC4 /* MediaLibraryPickerDataSource.h */, - FF945F6F1B28242300FB8AC4 /* MediaLibraryPickerDataSource.m */, - FFE3B2C51B2E651400E9F1E0 /* WPAndDeviceMediaLibraryDataSource.h */, - FFE3B2C61B2E651400E9F1E0 /* WPAndDeviceMediaLibraryDataSource.m */, - 17BB26AD1E6D8321008CD031 /* MediaLibraryViewController.swift */, - 1782BE831E70063100A91E7D /* MediaItemViewController.swift */, - 177074841FB209F100951A4A /* CircularProgressView.swift */, - 981D0929211259840014ECAF /* NoResultsViewController+MediaLibrary.swift */, - 17D5C3F61FFCF2D800EB70FF /* MediaProgressCoordinatorNoticeViewModel.swift */, - 1750BD6C201144DB0050F13A /* MediaNoticeNavigationCoordinator.swift */, - D80BC79B207464D200614A59 /* MediaLibraryMediaPickingCoordinator.swift */, - D80BC79F2074722000614A59 /* CameraCaptureCoordinator.swift */, - D80BC7A12074739300614A59 /* MediaLibraryStrings.swift */, - D80BC7A3207487F200614A59 /* MediaLibraryPicker.swift */, - 7E14635620B3BEAB00B95F41 /* WPStyleGuide+Loader.swift */, - 7ECD5B8020C4D823001AEBC5 /* MediaPreviewHelper.swift */, + 3FFDEF9029187F2100B625CE /* MigrationActionsConfiguration.swift */, + 3FF717FE291F07AB00323614 /* MigrationCenterViewConfiguration.swift */, + 3FFDEF8E29187F1200B625CE /* MigrationHeaderConfiguration.swift */, + 3FFDEF7E29177FB100B625CE /* MigrationStepConfiguration.swift */, ); - path = Media; + path = Configuration; sourceTree = ""; }; - 5DF3DD691A9377220051A229 /* Controllers */ = { + 400A2C8D2217AAA1000A8A59 /* Time-based data */ = { isa = PBXGroup; children = ( - 591232681CCEAA5100B86207 /* AbstractPostListViewController.swift */, - 590E873A1CB8205700D1B734 /* PostListViewController.swift */, - E684383D221F535900752258 /* LoadMoreCounter.swift */, + 400A2C812217A985000A8A59 /* ClicksStatsRecordValue+CoreDataClass.swift */, + 400A2C822217A985000A8A59 /* ClicksStatsRecordValue+CoreDataProperties.swift */, + 400A2C7E2217A985000A8A59 /* CountryStatsRecordValue+CoreDataClass.swift */, + 400A2C7F2217A985000A8A59 /* CountryStatsRecordValue+CoreDataProperties.swift */, + 40EC1F0D2249CA8400F6785E /* OtherAndTotalViewsCount+CoreDataClass.swift */, + 40EC1F0E2249CA8400F6785E /* OtherAndTotalViewsCount+CoreDataProperties.swift */, + 400A2C7C2217A985000A8A59 /* ReferrerStatsRecordValue+CoreDataClass.swift */, + 400A2C7D2217A985000A8A59 /* ReferrerStatsRecordValue+CoreDataProperties.swift */, + 40C403E92215CD1300E8C894 /* SearchResultsStatsRecordValue+CoreDataClass.swift */, + 40C403EA2215CD1300E8C894 /* SearchResultsStatsRecordValue+CoreDataProperties.swift */, + 40C403EF2215D66A00E8C894 /* TopViewedAuthorStatsRecordValue+CoreDataClass.swift */, + 40C403F02215D66A00E8C894 /* TopViewedAuthorStatsRecordValue+CoreDataProperties.swift */, + 40C403F12215D66A00E8C894 /* TopViewedPostStatsRecordValue+CoreDataClass.swift */, + 40C403F22215D66A00E8C894 /* TopViewedPostStatsRecordValue+CoreDataProperties.swift */, + 400A2C792217A985000A8A59 /* TopViewedVideoStatsRecordValue+CoreDataClass.swift */, + 400A2C7A2217A985000A8A59 /* TopViewedVideoStatsRecordValue+CoreDataProperties.swift */, + 400A2C752217A8A0000A8A59 /* VisitsSummaryStatsRecordValue+CoreDataClass.swift */, + 400A2C762217A8A0000A8A59 /* VisitsSummaryStatsRecordValue+CoreDataProperties.swift */, + 988AC37422F10DD900BC1433 /* FileDownloadsStatsRecordValue+CoreDataProperties.swift */, + 988AC37822F10E2C00BC1433 /* FileDownloadsStatsRecordValue+CoreDataClass.swift */, ); - name = Controllers; + name = "Time-based data"; sourceTree = ""; }; - 5DF3DD6A1A93772D0051A229 /* Views */ = { + 40247E002120FE2300AE1C3C /* Automated Transfer */ = { isa = PBXGroup; children = ( - 597421B01CEB6874005D5F38 /* ConfigurablePostView.h */, - 57D6C83F229498C4003DDC7E /* InteractivePostView.swift */, - 575E126E229779E70041B3EB /* RestorePostTableViewCell.swift */, - 5D2FB2821AE98C4600F1D4ED /* RestorePostTableViewCell.xib */, - 5D2C05541AD2F56200A753FE /* NavBarTitleDropdownButton.h */, - 5D2C05551AD2F56200A753FE /* NavBarTitleDropdownButton.m */, - 5D732F951AE84E3C00CD89E7 /* PostListFooterView.h */, - 5D732F961AE84E3C00CD89E7 /* PostListFooterView.m */, - 5D732F981AE84E5400CD89E7 /* PostListFooterView.xib */, - 5DAFEAB61AF2CA6E00B3E1D7 /* PostMetaButton.h */, - 5DAFEAB71AF2CA6E00B3E1D7 /* PostMetaButton.m */, - 17F67C55203D81430072001E /* PostCardStatusViewModel.swift */, - 17A28DC42050404C00EA6D9E /* AuthorFilterButton.swift */, - 17A28DCA2052FB5D00EA6D9E /* AuthorFilterViewController.swift */, - 5727EAF72284F5AC00822104 /* InteractivePostViewDelegate.swift */, - 57AA848E228715DA00D3C2A2 /* PostCardCell.swift */, - 57AA8490228715E700D3C2A2 /* PostCardCell.xib */, - 577C2AB322943FEC00AD1F03 /* PostCompactCell.swift */, - 577C2AB52294401800AD1F03 /* PostCompactCell.xib */, + 40247E012120FE3600AE1C3C /* AutomatedTransferHelper.swift */, ); - name = Views; + path = "Automated Transfer"; sourceTree = ""; }; - 5DF3DD6B1A93773B0051A229 /* Style */ = { + 402FFB1D218C2B8D00FF4A0B /* Style */ = { isa = PBXGroup; children = ( - 5703A4C522C003DC0028A343 /* WPStyleGuide+Posts.swift */, + B5B56D3019AFB68800B4E29B /* WPStyleGuide+Reply.swift */, + B574CE101B5D8F8600A84FFD /* WPStyleGuide+AlertView.swift */, + B5B56D3119AFB68800B4E29B /* WPStyleGuide+Notifications.swift */, ); - name = Style; + path = Style; sourceTree = ""; }; - 5DF7F7751B223895003A05C8 /* 30-31 */ = { + 402FFB22218C36CF00FF4A0B /* Helpers */ = { isa = PBXGroup; children = ( - 5DF7F7761B223916003A05C8 /* PostToPost30To31.h */, - 5DF7F7771B223916003A05C8 /* PostToPost30To31.m */, + 402FFB23218C36CF00FF4A0B /* AztecVerificationPromptHelper.swift */, ); - name = "30-31"; + path = Helpers; sourceTree = ""; }; - 5DFA7EBD1AF7CB2E0072023B /* Controllers */ = { + 40A71C5F220E1941002E3D25 /* Stats */ = { isa = PBXGroup; children = ( - 59DCA5201CC68AF3000F245F /* PageListViewController.swift */, - 9AF724EE2146813C00F63A61 /* ParentPageSettingsViewController.swift */, + 400A2C8D2217AAA1000A8A59 /* Time-based data */, + 40F46B6C22121BBC00A2143B /* Insights */, + 40A71C64220E1951002E3D25 /* StatsRecord+CoreDataClass.swift */, + 40A71C60220E1950002E3D25 /* StatsRecord+CoreDataProperties.swift */, + 40A71C62220E1950002E3D25 /* StatsRecordValue+CoreDataClass.swift */, + 40A71C65220E1951002E3D25 /* StatsRecordValue+CoreDataProperties.swift */, ); - name = Controllers; + name = Stats; sourceTree = ""; }; - 5DFA7EBE1AF7CB3A0072023B /* Views */ = { + 40E7FEC32211DF490032834E /* Stats */ = { isa = PBXGroup; children = ( - 59A3CADB1CD2FF0C009BFA1B /* BasePageListCell.h */, - 59A3CADC1CD2FF0C009BFA1B /* BasePageListCell.m */, - 5DFA7EC41AF814E40072023B /* PageListTableViewCell.h */, - 5DFA7EC51AF814E40072023B /* PageListTableViewCell.m */, - 5DFA7EC61AF814E40072023B /* PageListTableViewCell.xib */, - 5D13FA561AF99C2100F06492 /* PageListSectionHeaderView.xib */, - 5D18FE9C1AFBB17400EFEED0 /* RestorePageTableViewCell.h */, - 5D18FE9D1AFBB17400EFEED0 /* RestorePageTableViewCell.m */, - 5D18FE9E1AFBB17400EFEED0 /* RestorePageTableViewCell.xib */, - 8B93856D22DC08060010BF02 /* PageListSectionHeaderView.swift */, + 400199AA222590E100EB0906 /* AllTimeStatsRecordValueTests.swift */, + 400199AC22259FF300EB0906 /* AnnualAndMostPopularTimeStatsRecordValueTests.swift */, + 400A2C8E2217AD7F000A8A59 /* ClicksStatsRecordValueTests.swift */, + 400A2C902217B308000A8A59 /* CountryStatsRecordValueTests.swift */, + 9813512D22F0FC2700F7425D /* FileDownloadsStatsRecordValueTests.swift */, + 40ACCF13224E167900190713 /* FlagsTest.swift */, + 40F50B7F221310D400CBBB73 /* FollowersStatsRecordValueTests.swift */, + 40E7FEC72211EEC00032834E /* LastPostStatsRecordValueTests.swift */, + 40EE948122132F5800CD264F /* PublicizeConectionStatsRecordValueTests.swift */, + 938466B82683CA0E00A538DC /* ReferrerDetailsViewModelTests.swift */, + 400A2C922217B463000A8A59 /* ReferrerStatsRecordValueTests.swift */, + 40C403ED2215CE9500E8C894 /* SearchResultsStatsRecordValueTests.swift */, + 3FDDFE9527C8178C00606933 /* SiteStatsInformationTests.swift */, + 40E7FEC42211DF790032834E /* StatsRecordTests.swift */, + 3FFE3C0728FE00D10021BB96 /* StatsSegmentedControlDataTests.swift */, + 40F50B81221310F000CBBB73 /* StatsTestCase.swift */, + 931215E0267DE1C0008C3B69 /* StatsTotalRowDataTests.swift */, + 4054F4632214F94D00D261AB /* StreakStatsRecordValueTests.swift */, + 4054F4582214E6FE00D261AB /* TagsCategoriesStatsRecordValueTests.swift */, + 4089C51322371EE30031CE78 /* TodayStatsTests.swift */, + 4054F43D221357B600D261AB /* TopCommentedPostStatsRecordValueTests.swift */, + 40C403F72215D88100E8C894 /* TopViewedStatsTests.swift */, + 400A2C942217B68D000A8A59 /* TopViewedVideoStatsRecordValueTests.swift */, + 400A2C962217B883000A8A59 /* VisitsSummaryStatsRecordValueTests.swift */, + 01E78D1C296EA54F00FB6863 /* StatsPeriodHelperTests.swift */, + 015BA4EA29A788A300920F4B /* StatsTotalInsightsCellTests.swift */, ); - name = Views; + name = Stats; sourceTree = ""; }; - 73178C2021BEE09300E37C9A /* SiteCreation */ = { + 40F46B6C22121BBC00A2143B /* Insights */ = { isa = PBXGroup; children = ( - D842EA3F21FABB1700210E96 /* SiteSegmentTests.swift */, - 73B6693921CAD960008456C3 /* ErrorStateViewTests.swift */, - 73178C3221BEE94700E37C9A /* SiteAssemblyServiceTests.swift */, - 73C8F06521BEF76B00DDDF7E /* SiteAssemblyViewTests.swift */, - 73178C2221BEE09300E37C9A /* SiteCreationDataCoordinatorTests.swift */, - 73178C2621BEE09300E37C9A /* SiteCreationHeaderDataTests.swift */, - 730354B921C867E500CD18C2 /* SiteCreatorTests.swift */, - 73178C2421BEE09300E37C9A /* SiteSegmentsCellTests.swift */, - 73178C2321BEE09300E37C9A /* SiteSegmentsStepTests.swift */, - 73178C3421BEE9AC00E37C9A /* TitleSubtitleHeaderTests.swift */, - 32C6CDDA23A1FF0D002556FF /* SiteCreationRotatingMessageViewTests.swift */, + 40E7FECB2211FFA60032834E /* AllTimeStatsRecordValue+CoreDataClass.swift */, + 40E7FECC2211FFA70032834E /* AllTimeStatsRecordValue+CoreDataProperties.swift */, + 40F46B6822121BA800A2143B /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataClass.swift */, + 40F46B6922121BA800A2143B /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataProperties.swift */, + 405BFB1E223B37A000CD5BEA /* FollowersCountStatsRecordValue+CoreDataClass.swift */, + 405BFB1F223B37A000CD5BEA /* FollowersCountStatsRecordValue+CoreDataProperties.swift */, + 40F50B7B22130E6C00CBBB73 /* FollowersStatsRecordValue+CoreDataClass.swift */, + 40F50B7C22130E6C00CBBB73 /* FollowersStatsRecordValue+CoreDataProperties.swift */, + 40A71C63220E1951002E3D25 /* LastPostStatsRecordValue+CoreDataClass.swift */, + 40A71C61220E1950002E3D25 /* LastPostStatsRecordValue+CoreDataProperties.swift */, + 40EE947D2213213F00CD264F /* PublicizeConnectionStatsRecordValue+CoreDataClass.swift */, + 40EE947E2213213F00CD264F /* PublicizeConnectionStatsRecordValue+CoreDataProperties.swift */, + 4054F45B2214F50300D261AB /* StreakInsightStatsRecordValue+CoreDataClass.swift */, + 4054F45C2214F50300D261AB /* StreakInsightStatsRecordValue+CoreDataProperties.swift */, + 4054F45D2214F50300D261AB /* StreakStatsRecordValue+CoreDataClass.swift */, + 4054F45E2214F50300D261AB /* StreakStatsRecordValue+CoreDataProperties.swift */, + 4054F4542214E3C800D261AB /* TagsCategoriesStatsRecordValue+CoreDataClass.swift */, + 4054F4552214E3C800D261AB /* TagsCategoriesStatsRecordValue+CoreDataProperties.swift */, + 4089C50E22371B120031CE78 /* TodayStatsRecordValue+CoreDataClass.swift */, + 4089C50F22371B120031CE78 /* TodayStatsRecordValue+CoreDataProperties.swift */, + 4054F439221354E000D261AB /* TopCommentedPostStatsRecordValue+CoreDataClass.swift */, + 4054F43A221354E000D261AB /* TopCommentedPostStatsRecordValue+CoreDataProperties.swift */, + 4054F4402213635000D261AB /* TopCommentsAuthorStatsRecordValue+CoreDataClass.swift */, + 4054F4412213635000D261AB /* TopCommentsAuthorStatsRecordValue+CoreDataProperties.swift */, ); - path = SiteCreation; + name = Insights; sourceTree = ""; }; - 73178C2D21BEE13E00E37C9A /* FinalAssembly */ = { + 4326191322FCB8BE003C7642 /* Colors and Styles */ = { isa = PBXGroup; children = ( - 7305138221C031FC006BD0A1 /* AssembledSiteView.swift */, - 73C8F06721BF1A5E00DDDF7E /* SiteAssemblyContentView.swift */, - 73C8F05F21BEED9100DDDF7E /* SiteAssemblyStep.swift */, - 73C8F06121BEEEDE00DDDF7E /* SiteAssemblyWizardContent.swift */, - 73856E5A21E1602400773CD9 /* SiteCreationRequest+Validation.swift */, - 3221278523A0BD27002CA183 /* SiteCreationRotatingMessageView.swift */, + 4326191422FCB9DC003C7642 /* MurielColor.swift */, + 435B762122973D0600511813 /* UIColor+MurielColors.swift */, + 436110DF22C4241A000773AD /* UIColor+MurielColorsObjC.swift */, + E678FC141C76241000F55F55 /* WPStyleGuide+ApplicationStyles.swift */, + 17D975AE1EF7F6F100303D63 /* WPStyleGuide+Aztec.swift */, + 4020B2BC2007AC850002C963 /* WPStyleGuide+Gridicon.swift */, + 17AD36D41D36C1A60044B10D /* WPStyleGuide+Search.swift */, + 17F52DB62315233300164966 /* WPStyleGuide+FilterTabBar.swift */, + 3FD272DF24CF8F270021F0C8 /* UIColor+Notice.swift */, + FAD256922611B01700EDAF88 /* UIColor+WordPressColors.swift */, + FADFBD3A265F5B2E00039C41 /* WPStyleGuide+Jetpack.swift */, ); - path = FinalAssembly; + path = "Colors and Styles"; sourceTree = ""; }; - 731E88C521C9A10A0055C014 /* ErrorStates */ = { + 4349B0A6218A2E810034118A /* Revisions */ = { isa = PBXGroup; children = ( - 2FA6511921F26A57009AA935 /* InlineErrorRetryTableViewCell.swift */, - 2FA6511821F26A57009AA935 /* InlineErrorTableViewProvider.swift */, - 731E88C721C9A10A0055C014 /* ErrorStateView.swift */, - 731E88C621C9A10A0055C014 /* ErrorStateViewConfiguration.swift */, - 731E88C821C9A10A0055C014 /* ErrorStateViewController.swift */, + 9A2B28E7219046ED00458F2A /* ShowRevisionsListManger.swift */, + 4349B0AB218A45270034118A /* RevisionsTableViewController.swift */, + 9A4E61FC21A2CC4C0017A925 /* Browser */, + 9A2B28F2219211B900458F2A /* Views */, ); - path = ErrorStates; + path = Revisions; sourceTree = ""; }; - 732A4738218786F30015DA74 /* Views */ = { + 436D55D7210F85C200CEAA33 /* ViewLoading */ = { isa = PBXGroup; children = ( - 732A473B218787500015DA74 /* WPRichText */, + 436D55D8210F85DD00CEAA33 /* NibLoadable.swift */, + 436D55DA210F862A00CEAA33 /* NibReusable.swift */, + 436D55DE210F866900CEAA33 /* StoryboardLoadable.swift */, ); - name = Views; - path = ViewRelated/Views; + name = ViewLoading; sourceTree = ""; }; - 732A473B218787500015DA74 /* WPRichText */ = { + 436D55EE2115CB3D00CEAA33 /* RegisterDomain */ = { isa = PBXGroup; children = ( - 732A473E21878EB10015DA74 /* WPRichContentViewTests.swift */, - 732A473C218787500015DA74 /* WPRichTextFormatterTests.swift */, + 436D55EF2115CB6800CEAA33 /* RegisterDomainDetailsSectionTests.swift */, + 436D55F4211632B700CEAA33 /* RegisterDomainDetailsViewModelTests.swift */, + 436D5654212209D600CEAA33 /* RegisterDomainDetailsServiceProxyMock.swift */, + 02BE5CBF2281B53F00E351BA /* RegisterDomainDetailsViewModelLoadingStateTests.swift */, ); - path = WPRichText; + name = RegisterDomain; sourceTree = ""; }; - 7335AC55212202E30012EF2D /* FormattableContent */ = { + 436D560B2117312600CEAA33 /* RegisterDomainSuggestions */ = { isa = PBXGroup; children = ( - 7335AC5F21220D550012EF2D /* RemoteNotificationActionParser.swift */, - 73EDC709212E5D6700E5E3ED /* RemoteNotificationStyles.swift */, - 73F6DD41212BA54700CE447D /* RichNotificationContentFormatter.swift */, + 3FAF9CC426D03C7400268EA2 /* DomainSuggestionViewControllerWrapper.swift */, + 171096CA270F01EA001BCDD6 /* DomainSuggestionsTableViewController.swift */, + 436D560C2117312600CEAA33 /* RegisterDomainSuggestionsViewController.swift */, ); - path = FormattableContent; + path = RegisterDomainSuggestions; sourceTree = ""; }; - 733F36072126197800988727 /* WordPressNotificationContentExtension */ = { + 436D560E2117312700CEAA33 /* RegisterDomainDetails */ = { isa = PBXGroup; children = ( - 73D5AC5D212622B200ADDDD2 /* Resources */, - 73D5AC5B212622B200ADDDD2 /* Sources */, - 73D5AC5A2126225300ADDDD2 /* Supporting Files */, + 436D560F2117312700CEAA33 /* ViewModel */, + 436D56142117312700CEAA33 /* ViewController */, + 436D561A2117312700CEAA33 /* Views */, ); - path = WordPressNotificationContentExtension; + path = RegisterDomainDetails; sourceTree = ""; }; - 7347407C2114C29B007FDDFF /* Supporting Files */ = { + 436D560F2117312700CEAA33 /* ViewModel */ = { isa = PBXGroup; children = ( - 734740762114C296007FDDFF /* Info.plist */, - 734740772114C296007FDDFF /* Info-Alpha.plist */, - 734740782114C296007FDDFF /* Info-Internal.plist */, - 7347407D2114C4DC007FDDFF /* WordPressNotificationServiceExtension.entitlements */, - 7347407F2114C5F0007FDDFF /* WordPressNotificationServiceExtension-Alpha.entitlements */, - 7347407E2114C5EF007FDDFF /* WordPressNotificationServiceExtension-Internal.entitlements */, + 436D56112117312700CEAA33 /* RegisterDomainDetailsViewModel.swift */, + 404B35D222E9BA0800AD0B37 /* RegisterDomainDetailsViewModel+CountryDialCodes.swift */, + 436D562F2117410C00CEAA33 /* RegisterDomainDetailsViewModel+CellIndex.swift */, + 436D56122117312700CEAA33 /* RegisterDomainDetailsViewModel+RowDefinitions.swift */, + 436D56132117312700CEAA33 /* RegisterDomainDetailsViewModel+RowList.swift */, + 436D56102117312700CEAA33 /* RegisterDomainDetailsViewModel+SectionDefinitions.swift */, + 436D564E211E122D00CEAA33 /* RegisterDomainDetailsServiceProxy.swift */, ); - name = "Supporting Files"; + path = ViewModel; sourceTree = ""; }; - 7358E6B9210BD318002323EB /* WordPressNotificationServiceExtension */ = { + 436D56142117312700CEAA33 /* ViewController */ = { isa = PBXGroup; children = ( - 7396FE63210F72C600496D0D /* Resources */, - 7396FE64210F72D400496D0D /* Sources */, - 7347407C2114C29B007FDDFF /* Supporting Files */, + 436D56182117312700CEAA33 /* RegisterDomainDetailsViewController+LocalizedStrings.swift */, + 436D56192117312700CEAA33 /* RegisterDomainDetailsViewController.swift */, + 436D56152117312700CEAA33 /* RegisterDomainDetailsViewController+Cells.swift */, + 436D56162117312700CEAA33 /* RegisterDomainDetailsViewController+HeaderFooter.swift */, ); - path = WordPressNotificationServiceExtension; + path = ViewController; sourceTree = ""; }; - 736584E72137533A0029C9A4 /* Views */ = { + 436D561A2117312700CEAA33 /* Views */ = { isa = PBXGroup; children = ( - 736584E82137533A0029C9A4 /* NotificationContentView.swift */, + 436D561C2117312700CEAA33 /* RegisterDomainDetailsErrorSectionFooter.swift */, + 436D561E2117312700CEAA33 /* RegisterDomainDetailsErrorSectionFooter.xib */, + 436D561D2117312700CEAA33 /* RegisterDomainDetailsFooterView.swift */, + 436D561B2117312700CEAA33 /* RegisterDomainDetailsFooterView.xib */, ); path = Views; sourceTree = ""; }; - 736584E92137533A0029C9A4 /* Tracks */ = { + 437EF0C820F79C0C0086129B /* Domain registration */ = { isa = PBXGroup; children = ( - 736584EA2137533A0029C9A4 /* Tracks+ContentExtension.swift */, + 402FFB1B218C27C100FF4A0B /* RegisterDomain.storyboard */, + 436D560E2117312700CEAA33 /* RegisterDomainDetails */, + 436D560B2117312600CEAA33 /* RegisterDomainSuggestions */, + 43D74AD220FB5A82004AD934 /* Views */, + F47E154929E84A9300B6E426 /* DomainPurchasingWebFlowController.swift */, ); - path = Tracks; + path = "Domain registration"; sourceTree = ""; }; - 73768B69212B4DE8005136A1 /* NotificationContent */ = { + 4395A15E210672C900844E8E /* QuickStart */ = { isa = PBXGroup; children = ( - 73F6DD43212C714F00CE447D /* RichNotificationViewModel.swift */, - 73768B6A212B4E4F005136A1 /* UNNotificationContent+RemoteNotification.swift */, + 9A4E215B21F75BBE00EFF212 /* QuickStartChecklistManager.swift */, + 43C9908D21067E22009EFFEB /* QuickStartChecklistViewController.swift */, + 98FF6A3D23A30A240025FD72 /* QuickStartNavigationSettings.swift */, + 4348C88221002FBD00735DC0 /* QuickStartTourGuide.swift */, + 4395A1582106389800844E8E /* QuickStartTours.swift */, + FAE8EE98273AC06F00A65307 /* QuickStartSettings.swift */, + 80EF928C280E83110064A971 /* QuickStartToursCollection.swift */, + 9A4E215D21F87A8500EFF212 /* Cells */, + 9A4E215E21F87AC300EFF212 /* Views */, + 80EF928F28105CFA0064A971 /* QuickStartFactory.swift */, ); - path = NotificationContent; + name = QuickStart; sourceTree = ""; }; - 738B9A3F21B85CF20005062B /* Wizard */ = { + 43D74AD220FB5A82004AD934 /* Views */ = { isa = PBXGroup; children = ( - 738B9A4021B85CF20005062B /* SiteCreationWizard.swift */, - 738B9A4421B85CF20005062B /* SiteCreationWizardLauncher.swift */, - 738B9A4121B85CF20005062B /* SiteCreator.swift */, - 738B9A4621B85CF20005062B /* WizardNavigation.swift */, + 43D74AD520FB5AD5004AD934 /* RegisterDomainSectionHeaderView.swift */, + 43D74AD320FB5ABA004AD934 /* RegisterDomainSectionHeaderView.xib */, ); - path = Wizard; + path = Views; sourceTree = ""; }; - 738B9A4721B85CF20005062B /* Shared */ = { + 45C73C23113C36F50024D0D2 /* Resources-iPad */ = { isa = PBXGroup; children = ( - 731E88C521C9A10A0055C014 /* ErrorStates */, - 738B9A4C21B85CF20005062B /* KeyboardInfo.swift */, - 738B9A4921B85CF20005062B /* ModelSettableCell.swift */, - 738B9A4D21B85CF20005062B /* SiteCreationHeaderData.swift */, - 738B9A4A21B85CF20005062B /* TableDataCoordinator.swift */, - 73CE3E0D21F7F9D3007C9C85 /* TableViewOffsetCoordinator.swift */, - 738B9A4B21B85CF20005062B /* TitleSubtitleHeader.swift */, - 738B9A4821B85CF20005062B /* TitleSubtitleTextfieldHeader.swift */, - 738B9A5D21B8632E0005062B /* UITableView+Header.swift */, - 738B9A5B21B85EB00005062B /* UIView+ContentLayout.swift */, ); - path = Shared; + name = "Resources-iPad"; sourceTree = ""; }; - 7396FE63210F72C600496D0D /* Resources */ = { + 46241BD72540B403002B8A12 /* Design Selection */ = { isa = PBXGroup; children = ( - 7326718D210F75D2001FA866 /* Localizable.strings */, + C3C21EB728385EB4002296E2 /* Extensions */, + 464688D5255C719500ECA61C /* Preview */, + C395FB222821FE4400AE7C11 /* SiteDesignSection.swift */, + 46241C0E2540BD01002B8A12 /* SiteDesignStep.swift */, + 46241C3A2540D483002B8A12 /* SiteDesignContentCollectionViewController.swift */, + C395FB252821FE7B00AE7C11 /* RemoteSiteDesign+Thumbnail.swift */, + C32A6A2B2832BF02002E9394 /* SiteDesignCategoryThumbnailSize.swift */, + C3FF78E728354A91008FA600 /* SiteDesignSectionLoader.swift */, ); - path = Resources; + path = "Design Selection"; sourceTree = ""; }; - 7396FE64210F72D400496D0D /* Sources */ = { + 462422DE2549AD11002B8A12 /* Collapsable Header Collection View Cell */ = { isa = PBXGroup; children = ( - 7335AC55212202E30012EF2D /* FormattableContent */, - 73768B69212B4DE8005136A1 /* NotificationContent */, - 73E40D8A21238C400012ABA6 /* Tracks */, - 7396FE65210F730600496D0D /* NotificationService.swift */, - 73ACDF9F2118B03700233AD4 /* WordPressNotificationServiceExtension-Bridging-Header.h */, + 469CE06F24BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.swift */, + 469CE07024BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.xib */, ); - path = Sources; + path = "Collapsable Header Collection View Cell"; sourceTree = ""; }; - 73D5AC5A2126225300ADDDD2 /* Supporting Files */ = { + 4625B5472537875E00C04AAD /* Collapsable Header */ = { isa = PBXGroup; children = ( - 733F360D2126197800988727 /* Info.plist */, - 73D5AC662126236600ADDDD2 /* Info-Alpha.plist */, - 73D5AC672126236600ADDDD2 /* Info-Internal.plist */, - 732D4E1D2126253900BF7F11 /* WordPressNotificationContentExtension.entitlements */, - 732D4E1E212625A100BF7F11 /* WordPressNotificationContentExtension-Alpha.entitlements */, - 732D4E1F212625A100BF7F11 /* WordPressNotificationContentExtension-Internal.entitlements */, + 4625B70A2539F35000C04AAD /* Collabsable Header Filter Bar */, + 462422DE2549AD11002B8A12 /* Collapsable Header Collection View Cell */, + 4625B555253789C000C04AAD /* CollapsableHeaderViewController.swift */, + 46B1A16A26A774E500F058AE /* CollapsableHeaderView.swift */, + 4625B6332538B53700C04AAD /* CollapsableHeaderViewController.xib */, ); - name = "Supporting Files"; + path = "Collapsable Header"; sourceTree = ""; }; - 73D5AC5B212622B200ADDDD2 /* Sources */ = { + 4625B70A2539F35000C04AAD /* Collabsable Header Filter Bar */ = { isa = PBXGroup; children = ( - 736584E92137533A0029C9A4 /* Tracks */, - 736584E72137533A0029C9A4 /* Views */, - 73D5AC5C212622B200ADDDD2 /* NotificationViewController.swift */, - 73B05D2A21374FE50073ECAA /* WordPressNotificationContentExtension-Bridging-Header.h */, + 469EB16724D9AD8B00C764CB /* CollapsableHeaderFilterBar.swift */, + 469EB16324D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.swift */, + 469EB16424D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.xib */, ); - path = Sources; + path = "Collabsable Header Filter Bar"; sourceTree = ""; }; - 73D5AC5D212622B200ADDDD2 /* Resources */ = { + 4631359224AD057E0017E65C /* Layout Picker */ = { isa = PBXGroup; children = ( - 73D5AC5E212622B200ADDDD2 /* Localizable.strings */, + 4631359524AD068B0017E65C /* GutenbergLayoutPickerViewController.swift */, + 465B097924C877E500336B6C /* GutenbergLightNavigationController.swift */, + 469CE06B24BCED75003BDC8B /* CategorySectionTableViewCell.swift */, + 469CE06C24BCED75003BDC8B /* CategorySectionTableViewCell.xib */, + 466653492501552A00165DD4 /* LayoutPreviewViewController.swift */, + 46C984672527863E00988BB9 /* LayoutPickerAnalyticsEvent.swift */, + C94C0B1A25DCFA0100F2F69B /* FilterableCategoriesViewController.swift */, ); - path = Resources; + path = "Layout Picker"; sourceTree = ""; }; - 73E40D8A21238C400012ABA6 /* Tracks */ = { + 464688D5255C719500ECA61C /* Preview */ = { isa = PBXGroup; children = ( - 73E40D8B21238C520012ABA6 /* Tracks+ServiceExtension.swift */, + 464688D6255C71D200ECA61C /* SiteDesignPreviewViewController.swift */, + 464688D7255C71D200ECA61C /* TemplatePreviewViewController.xib */, + C99B08FB26081AD600CA71EB /* TemplatePreviewViewController.swift */, ); - path = Tracks; + path = Preview; sourceTree = ""; }; - 73FF702D221F43BB00541798 /* Charts */ = { + 46963F5724649509000D356D /* Gutenberg */ = { isa = PBXGroup; children = ( - 73F76E1D222851E300FDDAD2 /* Charts+AxisFormatters.swift */, - 73CB13962289BEFB00265F49 /* Charts+LargeValueFormatter.swift */, - 73FF7031221F469100541798 /* Charts+Support.swift */, - 733195822284FE9F0007D904 /* PeriodChart.swift */, - 73E4E375227A033A0007D752 /* PostChart.swift */, - 735A9680228E421F00461135 /* StatsBarChartConfiguration.swift */, - 73FF702F221F43CD00541798 /* StatsBarChartView.swift */, - 73D86968223AF4040064920F /* StatsChartLegendView.swift */, + 46F583A72624CE790010A723 /* BlockEditorSettingElement+CoreDataClass.swift */, + 46F583A82624CE790010A723 /* BlockEditorSettingElement+CoreDataProperties.swift */, + 46F583A52624CE790010A723 /* BlockEditorSettings+CoreDataClass.swift */, + 46F583A62624CE790010A723 /* BlockEditorSettings+CoreDataProperties.swift */, + 46F584B72624E6380010A723 /* BlockEditorSettings+GutenbergEditorSettings.swift */, + 46183D1D251BD6A0004F9AFD /* PageTemplateCategory+CoreDataClass.swift */, + 46183D1E251BD6A0004F9AFD /* PageTemplateCategory+CoreDataProperties.swift */, + 46183CF2251BD658004F9AFD /* PageTemplateLayout+CoreDataClass.swift */, + 46183CF3251BD658004F9AFD /* PageTemplateLayout+CoreDataProperties.swift */, ); - path = Charts; + name = Gutenberg; sourceTree = ""; }; - 740C7C51202F50A3001C31B0 /* Shared UI */ = { + 46CFA7BD262745A50077BAD9 /* BlockEditorSettings and Styles */ = { isa = PBXGroup; children = ( - 74402F2F2005346100A1D4A2 /* Presentation */, - 74F5CD371FE0646F00764E7C /* ShareExtension.storyboard */, - 745EAF462003FDAA0066F415 /* ShareExtensionAbstractViewController.swift */, - 74F5CD391FE0653500764E7C /* ShareExtensionEditorViewController.swift */, - BE6787F41FFF2886005D9F01 /* ShareModularViewController.swift */, - 7414A140203CBADF005A7D9B /* ShareCategoriesPickerViewController.swift */, - 747F88C0203778E000523C7C /* ShareTagsPickerViewController.swift */, - 745EAF4920040B220066F415 /* ShareData.swift */, + 46CFA7BE262745F70077BAD9 /* get_wp_v2_themes_twentytwentyone.json */, + 46CFA7E2262746940077BAD9 /* get_wp_v2_themes_twentytwenty.json */, + 465F89F6263B690C00F4C950 /* wp-block-editor-v1-settings-success-NotThemeJSON.json */, + 465F8A09263B692600F4C950 /* wp-block-editor-v1-settings-success-ThemeJSON.json */, ); - name = "Shared UI"; + name = "BlockEditorSettings and Styles"; sourceTree = ""; }; - 741AF3A3202F3DFC00C771A5 /* Tracks */ = { + 46E327D524E71B2F000944B3 /* Page Layouts */ = { isa = PBXGroup; children = ( - 741AF3A4202F3E2A00C771A5 /* Tracks+DraftAction.swift */, + 46E327D024E705C7000944B3 /* PageLayoutService.swift */, ); - name = Tracks; + name = "Page Layouts"; sourceTree = ""; }; - 741D1478200D5533003DFD30 /* Style */ = { + 5703A4C722C0214C0028A343 /* Style */ = { isa = PBXGroup; children = ( - B50248AE1C96FF6200AFBDED /* WPStyleGuide+Share.swift */, + 5D4E30CF1AA4B41A000D9904 /* WPStyleGuide+Pages.h */, + 5D4E30D01AA4B41A000D9904 /* WPStyleGuide+Pages.m */, ); name = Style; sourceTree = ""; }; - 7430C4471F97F0DA00E2673E /* UI */ = { + 572FB3FE223A800500933C76 /* Classes */ = { isa = PBXGroup; children = ( - 740C7C51202F50A3001C31B0 /* Shared UI */, - 740C7C4E202F4CD6001C31B0 /* MainInterface.storyboard */, - 74337EDC20054D5500777997 /* MainShareViewController.swift */, + 572FB3FF223A802900933C76 /* Stores */, ); - name = UI; + path = Classes; sourceTree = ""; }; - 74402F2F2005346100A1D4A2 /* Presentation */ = { + 572FB3FF223A802900933C76 /* Stores */ = { isa = PBXGroup; children = ( - 74402F2B2005337D00A1D4A2 /* ExtensionTransitioningManager.swift */, - 74402F29200528F200A1D4A2 /* ExtensionPresentationController.swift */, - 74402F2D2005344700A1D4A2 /* ExtensionPresentationAnimator.swift */, + 8B69F0FF255C4870006B1CEF /* ActivityStoreTests.swift */, + 572FB400223A806000933C76 /* NoticeStoreTests.swift */, + DC13DB7D293FD09F00E33561 /* StatsInsightsStoreTests.swift */, + 937250ED267A492D0086075F /* StatsPeriodStoreTests.swift */, + 0148CC282859127F00CF5D96 /* StatsWidgetsStoreTests.swift */, + 08A4E12E289D2795001D9EC7 /* UserPersistentStoreTests.swift */, + 0147D650294B6EA600AA6410 /* StatsRevampStoreTests.swift */, ); - name = Presentation; + path = Stores; sourceTree = ""; }; - 74576673202B558C00F42E40 /* WordPressDraftActionExtension */ = { + 5749984522FA0EB900CE86ED /* Utils */ = { isa = PBXGroup; children = ( - 741AF3A3202F3DFC00C771A5 /* Tracks */, - 74576686202B571700F42E40 /* Supporting Files */, - 74869D82202BA971007A0454 /* WordPressDraftActionExtension-Bridging-Header.h */, + 5749984622FA0F2E00CE86ED /* PostNoticeViewModelTests.swift */, ); - path = WordPressDraftActionExtension; + name = Utils; + path = ViewRelated/Post/Utils; sourceTree = ""; }; - 74576686202B571700F42E40 /* Supporting Files */ = { + 57DF04BF2314895E00CC93D6 /* Views */ = { isa = PBXGroup; children = ( - 74CC4313202B5AA4000DAE1A /* Info.plist */, - 74CC4312202B5AA4000DAE1A /* Info-Internal.plist */, - 74CC4311202B5AA4000DAE1A /* Info-Alpha.plist */, - 74D6DA90202B651300A0E1FE /* WordPressDraftActionExtension.entitlements */, - 74D6DA92202B669100A0E1FE /* WordPressDraftActionExtension-Alpha.entitlements */, - 74D6DA91202B669100A0E1FE /* WordPressDraftActionExtension-Internal.entitlements */, - 74E44AD72031ED2300556205 /* WordPressDraft-Lumberjack.m */, - 74E44AD82031ED2300556205 /* WordPressDraftPrefix.pch */, - 74E44ADE2031EFD600556205 /* Localizable.strings */, + 57DF04C0231489A200CC93D6 /* PostCardStatusViewModelTests.swift */, + F5CFB8F424216DFC00E58B69 /* CalendarHeaderViewTests.swift */, + F5C00EAD242179780047846F /* WeekdaysHeaderViewTests.swift */, ); - name = "Supporting Files"; + name = Views; + path = ViewRelated/Post/Views; sourceTree = ""; }; - 745EAF432003FC930066F415 /* Protocols */ = { + 57E3C98223835A57004741DB /* Controllers */ = { isa = PBXGroup; children = ( - 745EAF442003FD050066F415 /* ShareSegueHandler.swift */, + 8B939F4223832E5D00ACCB0F /* PostListViewControllerTests.swift */, + F543AF5623A84E4D0022F595 /* PublishSettingsControllerTests.swift */, + F5D0A65123CCD3B600B20D27 /* PreviewWebKitViewControllerTests.swift */, ); - name = Protocols; + name = Controllers; + path = ViewRelated/Post/Controllers; sourceTree = ""; }; - 7462BFD22028CD0500B552D8 /* Notices */ = { + 59B48B601B99E0B0008EBB84 /* TestUtilities */ = { isa = PBXGroup; children = ( - 74F89406202A1965008610FA /* ExtensionNotificationManager.swift */, - 74EA3B87202A0462004F802D /* ShareNoticeConstants.swift */, - 7462BFCF2028C49800B552D8 /* ShareNoticeViewModel.swift */, - 7462BFD32028CD4400B552D8 /* ShareNoticeNavigationCoordinator.swift */, + 59B48B611B99E132008EBB84 /* JSONObject.swift */, + E157D5DF1C690A6C00F04FB9 /* ImmuTableTestUtils.swift */, + 570BFD8F2282418A007859A8 /* PostBuilder.swift */, + 57B71D4D230DB5F200789A68 /* BlogBuilder.swift */, + F11023A223186BCA00C4E84A /* MediaBuilder.swift */, + 57889AB723589DF100DAE56D /* PageBuilder.swift */, + 2481B1D4260D4E8B00AE59DB /* AccountBuilder.swift */, ); - name = Notices; + name = TestUtilities; sourceTree = ""; }; - 74729CA12056F9DB00D1394D /* Spotlight */ = { + 59DD94311AC479DC0032DD6B /* Logging */ = { isa = PBXGroup; children = ( - 74729CA820570FE100D1394D /* Utils */, - 74729CA72056FE6500D1394D /* Protocols */, - 74729CA22056FA0900D1394D /* SearchManager.swift */, + F928EDA2226140620030D451 /* WPCrashLoggingProvider.swift */, + F913BB0F24B3C5CE00C19032 /* EventLoggingDataProvider.swift */, + F913BB0D24B3C58B00C19032 /* EventLoggingDelegate.swift */, + 938CF3DB1EF1BE6800AF838E /* CocoaLumberjack.swift */, + 59DD94321AC479ED0032DD6B /* WPLogger.h */, + 59DD94331AC479ED0032DD6B /* WPLogger.m */, + F93735F022D534FE00A3C312 /* LoggingURLRedactor.swift */, + 986CC4D120E1B2F6004F300E /* CustomLogFormatter.swift */, + 8B3DECAA2388506400A459C2 /* SentryStartupEvent.swift */, + 3F39C93427A09927001EC300 /* WordPressLibraryLogger.swift */, + F4DDE2C129C92F0D00C02A76 /* CrashLogging+Singleton.swift */, ); - path = Spotlight; + path = Logging; sourceTree = ""; }; - 74729CA72056FE6500D1394D /* Protocols */ = { + 59ECF8791CB705EB00E68F25 /* Posts */ = { isa = PBXGroup; children = ( - 74729CA52056FE6000D1394D /* SearchableItemConvertable.swift */, - 740516882087B73400252FD0 /* SearchableActivityConvertable.swift */, + 8BE69514243E676C00FF492F /* Prepublishing Nudges */, + 59ECF87A1CB7061D00E68F25 /* PostSharingControllerTests.swift */, + F18B43771F849F580089B817 /* PostAttachmentTests.swift */, + 8B6BD54F24293FBE00DB8F28 /* PrepublishingNudgesViewControllerTests.swift */, ); - name = Protocols; + name = Posts; sourceTree = ""; }; - 74729CA820570FE100D1394D /* Utils */ = { + 5D08B8FC19647C0300D5B381 /* Views */ = { isa = PBXGroup; children = ( - 74729CA92057100200D1394D /* SearchIdentifierGenerator.swift */, + 088B89881DA6F93B000E8DEF /* ReaderPostCardContentLabel.swift */, + E66EB6F81C1B7A76003DABC5 /* ReaderSpacerView.swift */, ); - name = Utils; + name = Views; sourceTree = ""; }; - 74FA2EE2200E8A1D001DDC13 /* Services */ = { + 5D08B8FD19647C0800D5B381 /* Controllers */ = { isa = PBXGroup; children = ( - 74FA2EE3200E8A6C001DDC13 /* AppExtensionsService.swift */, + D816043E209C1AD300ABAFFA /* ReaderPostActions */, + D817798F20ABF26800330998 /* ReaderCellConfiguration.swift */, + E6D3B1421D1C702600008D4B /* ReaderFollowedSitesViewController.swift */, + E6DE44661B90D251000FA7EF /* ReaderHelpers.swift */, + D817799320ABFDB300330998 /* ReaderPostCellActions.swift */, + D88106F920C0CFEE001D2F00 /* ReaderSaveForLaterRemovedPosts.swift */, + E6DAABDC1CF632EC0069D933 /* ReaderSearchSuggestionsViewController.swift */, + E64ECA4C1CE62041000188A0 /* ReaderSearchViewController.swift */, + 17CE77EE20C6CDAA001DEA5A /* ReaderSiteSearchViewController.swift */, + 5D1D04741B7A50B100CDE646 /* ReaderStreamViewController.swift */, + 8BCB83D024C21063001581BD /* ReaderStreamViewController+Ghost.swift */, + 8BB185D024B63D1600A4CCE8 /* ReaderCardsStreamViewController.swift */, + 3234BB162530DFCA0068DA40 /* ReaderTableCardCell.swift */, + 8BCF957924C6044000712056 /* ReaderTopicsCardCell.swift */, + 3234B8E6252FA0930068DA40 /* ReaderSitesCardCell.swift */, + E69551F51B8B6AE200CB8E4F /* ReaderStreamViewController+Helper.swift */, + 7371E6D121FA730700596C0A /* ReaderStreamViewController+Sharing.swift */, + D81879D820ABC647000CFA95 /* ReaderTableConfiguration.swift */, + D87A329520ABD60700F4726F /* ReaderTableContent.swift */, + 5D42A401175E76A1005CFF05 /* WPImageViewController.h */, + 5D42A402175E76A2005CFF05 /* WPImageViewController.m */, + C7192ECE25E8432D00C3020D /* ReaderTopicsCardCell.xib */, + F4D36AD4298498E600E6B84C /* ReaderPostBlockingController.swift */, ); - name = Services; + name = Controllers; sourceTree = ""; }; - 74FA4BDB1FBF994F0031EAAD /* Data */ = { + 5D09CBA61ACDE532007A23BD /* Utils */ = { isa = PBXGroup; children = ( - 741E22441FC0CC55007967AB /* UploadOperation.swift */, - 74AF4D711FE417D200E3EBFE /* PostUploadOperation.swift */, - 74AF4D6D1FE417D200E3EBFE /* MediaUploadOperation.swift */, - 746D6B241FBF701F003C45BE /* SharedCoreDataStack.swift */, - 740C7DB220386E5700FF0229 /* EXTENSION_MIGRATIONS.md */, - 74FA4BE31FBFA0660031EAAD /* Extensions.xcdatamodeld */, + 595CB3751D2317D50082C7E9 /* PostListFilter.swift */, + 43AB7C5D1D3E70510066CB6A /* PostListFilterSettings.swift */, + 174C9696205A846E00CEEF6E /* PostNoticeViewModel.swift */, + 170CE73F2064478600A48191 /* PostNoticeNavigationCoordinator.swift */, + 570BFD8A22823D7B007859A8 /* PostActionSheet.swift */, + 570265142298921800F2214C /* PostListTableViewHandler.swift */, + 57047A4E22A961BC00B461DF /* PostSearchHeader.swift */, + F16C35DB23F3F78E00C81331 /* AutoUploadMessageProvider.swift */, + F16C35D923F3F76C00C81331 /* PostAutoUploadMessageProvider.swift */, + 8B8FE8562343952B00F9AD2E /* PostAutoUploadMessages.swift */, + 7E92A1FA233CB1B7006D281B /* Autosaver.swift */, ); - name = Data; + name = Utils; sourceTree = ""; }; - 7E21C763202BBE9F00837CF5 /* iAds */ = { + 5D1EBF56187C9B95003393F8 /* Categories */ = { isa = PBXGroup; children = ( - 7E21C764202BBF4400837CF5 /* SearchAdsAttribution.swift */, + A0E293EF0E21027E00C6919C /* WPAddPostCategoryViewController.h */, + A0E293F00E21027E00C6919C /* WPAddPostCategoryViewController.m */, + 2F605FA7251430C200F99544 /* PostCategoriesViewController.swift */, + 2F605FA925145F7200F99544 /* WPCategoryTree.swift */, ); - name = iAds; + path = Categories; sourceTree = ""; }; - 7E3E7A5120E44B060075D159 /* FormattableContent */ = { + 5D49B03519BE37CC00703A9B /* 20-21 */ = { isa = PBXGroup; children = ( - 7EFF207F20EAC59D009C4699 /* Groups */, - 7E7947A7210BABF5005BB851 /* Ranges */, - 7E3E7A5E20E44E110075D159 /* Styles */, - CE46018A21139E8300F242B6 /* FooterTextContent.swift */, - 7EFF208B20EADF68009C4699 /* FormattableCommentContent.swift */, - 7EFF208520EAD918009C4699 /* FormattableUserContent.swift */, - 73BFDA89211D054800907245 /* Notifiable.swift */, - 7EB5824620EC41B200002702 /* NotificationContentFactory.swift */, - 7EFF208920EADCB6009C4699 /* NotificationTextContent.swift */, + B5A6CEA519FA800E009F07DE /* AccountToAccount20to21.swift */, + E66969CB1B9E2EBF00EC9C00 /* SafeReaderTopicToReaderTopic.h */, + E66969CC1B9E2EBF00EC9C00 /* SafeReaderTopicToReaderTopic.m */, ); - path = FormattableContent; + name = "20-21"; sourceTree = ""; }; - 7E3E7A5E20E44E110075D159 /* Styles */ = { + 5D5A6E901B613C1800DAF819 /* Cards */ = { isa = PBXGroup; children = ( - 7E3E7A5C20E44DB00075D159 /* BadgeContentStyles.swift */, - 7E3E7A5820E44D2F0075D159 /* FooterContentStyles.swift */, - 7E3E7A5620E44D130075D159 /* HeaderContentStyles.swift */, - 7E3E7A5A20E44D950075D159 /* RichTextContentStyles.swift */, - 7E3E7A5420E44B4B0075D159 /* SnippetsContentStyles.swift */, - 7E3E7A5220E44B260075D159 /* SubjectContentStyles.swift */, + 329F8E4C24DD74F0002A5311 /* Tags View */, + E65219FA1B8D10DA000B1217 /* ReaderBlockedSiteCell.swift */, + E65219F81B8D10C2000B1217 /* ReaderBlockedSiteCell.xib */, + 5D2B30B81B7411C700DA15F3 /* ReaderCardDiscoverAttributionView.swift */, + 8BA77BCA2482C52A00E1EBBF /* ReaderCardDiscoverAttributionView.xib */, + E6D3E8481BEBD871002692E8 /* ReaderCrossPostCell.swift */, + E6D3E84A1BEBD888002692E8 /* ReaderCrossPostCell.xib */, + E6A3384F1BB0A70F00371587 /* ReaderGapMarkerCell.swift */, + E6A3384D1BB0A50900371587 /* ReaderGapMarkerCell.xib */, + 5D5A6E911B613CA400DAF819 /* ReaderPostCardCell.swift */, + 5D5A6E921B613CA400DAF819 /* ReaderPostCardCell.xib */, + D88106F520C0C9A8001D2F00 /* ReaderSavedPostUndoCell.swift */, + D88106F620C0C9A8001D2F00 /* ReaderSavedPostUndoCell.xib */, + 8BD8201824BCCE8600FF25FD /* ReaderWelcomeBanner.xib */, + 8BD8201A24BCDBFF00FF25FD /* ReaderWelcomeBanner.swift */, + 3234BB322530EA980068DA40 /* ReaderRecommendedSiteCardCell.swift */, + 3234BB332530EA980068DA40 /* ReaderRecommendedSiteCardCell.xib */, ); - path = Styles; + name = Cards; sourceTree = ""; }; - 7E3E9B6E2177C9C300FD5797 /* Gutenberg */ = { + 5D6651461637324000EBDA7D /* Sounds */ = { isa = PBXGroup; children = ( - FF2EC3BE2209A105006176E1 /* Processors */, - 7E3E9B6F2177C9DC00FD5797 /* GutenbergViewController.swift */, - 7E40716123741375003627FA /* GutenbergNetworking.swift */, - F10E654F21B06139007AB2EE /* GutenbergViewController+MoreActions.swift */, - 91D8364021946EFB008340B2 /* GutenbergMediaPickerHelper.swift */, - FF8C54AC21F677260003ABCF /* GutenbergMediaInserterHelper.swift */, - 7EA30DB421ADA20F0092F894 /* AztecAttachmentDelegate.swift */, - 7EA30DB321ADA20F0092F894 /* EditorMediaUtility.swift */, - 912347182213484300BD9F97 /* GutenbergViewController+InformativeDialog.swift */, - 912347752216E27200BD9F97 /* GutenbergViewController+Localization.swift */, - FFC02B82222687BF00E64FDE /* GutenbergImageLoader.swift */, - 7E407122237163C3003627FA /* Utils */, + 5D69DBC3165428CA00A2D1F7 /* n.caf */, ); - path = Gutenberg; + name = Sounds; sourceTree = ""; }; - 7E407122237163C3003627FA /* Utils */ = { + 5D6C4B0D1B604190005E3C43 /* RichTextView */ = { isa = PBXGroup; children = ( - 8B05D29223AA572A0063B9AA /* GutenbergMediaEditorImage.swift */, - 7E407120237163B8003627FA /* GutenbergStockPhotos.swift */, - 7E4071392372AD54003627FA /* GutenbergFilesAppMediaSource.swift */, + 5D6C4B0E1B604190005E3C43 /* NSAttributedString+RichTextView.swift */, + 5D6C4B0F1B604190005E3C43 /* RichTextView.swift */, + 5D6C4B101B604190005E3C43 /* UITextView+RichTextView.swift */, + C743535527BD7144008C2644 /* AnimatedGifAttachmentViewProvider.swift */, ); - path = Utils; + path = RichTextView; sourceTree = ""; }; - 7E4123AB20F4096200DF8486 /* FormattableContent */ = { + 5D7A577D1AFBFD7C0097C028 /* Models */ = { isa = PBXGroup; children = ( - 7E7947A6210BAB9F005BB851 /* Actions */, - 7E4123B220F4097A00DF8486 /* FormattableContent.swift */, - 7E4123AC20F4097900DF8486 /* FormattableContentFactory.swift */, - 7E4123B520F4097B00DF8486 /* FormattableContentFormatter.swift */, - 7E4123AD20F4097900DF8486 /* FormattableContentGroup.swift */, - 7E4123B420F4097A00DF8486 /* FormattableContentRange.swift */, - 7E4123B620F4097B00DF8486 /* FormattableContentStyles.swift */, - 7E4123B020F4097A00DF8486 /* FormattableMediaContent.swift */, - 7E4123B320F4097A00DF8486 /* FormattableTextContent.swift */, - 7E53AB0120FE5EAE005796FE /* ContentRouter.swift */, - 7E929CD02110D4F200BCAD88 /* FormattableRangesFactory.swift */, + F1B1E7A224098FA100549E2A /* BlogTests.swift */, + 246D0A0225E97D5D0028B83F /* Blog+ObjcTests.m */, + 4AD5657128E543A30054C676 /* BlogQueryTests.swift */, + D848CC1620FF38EA00A9038F /* FormattableCommentRangeTests.swift */, + D848CC1420FF33FC00A9038F /* NotificationContentRangeTests.swift */, + 8BBBEBB124B8F8C0005E358E /* ReaderCardTests.swift */, + E6B9B8A91B94E1FE0001B92F /* ReaderPostTest.m */, + B55F1AA11C107CE200FD04D4 /* BlogSettingsDiscussionTests.swift */, + E6A2158F1D1065F200DE5270 /* AbstractPostTest.swift */, + 5960967E1CF7959300848496 /* PostTests.swift */, + 0879FC151E9301DD00E1EFC8 /* MediaTests.swift */, + D826D67E211D21C700A5D8FE /* NullMockUserDefaults.swift */, + 8B8C814C2318073300A0E620 /* BasePostTests.swift */, + 24A2948225D602710000A51E /* BlogTimeZoneTests.m */, + 24C69A8A2612421900312D9A /* UserSettingsTests.swift */, + 24C69AC12612467C00312D9A /* UserSettingsTestsObjc.m */, + 2481B1E7260D4EAC00AE59DB /* WPAccount+LookupTests.swift */, + 2481B20B260D8FED00AE59DB /* WPAccount+ObjCLookupTests.m */, + C38C5D8027F61D2C002F517E /* MenuItemTests.swift */, ); - path = FormattableContent; + name = Models; sourceTree = ""; }; - 7E4123C620F4178E00DF8486 /* FormattableContent */ = { + 5D98A1491B6C09730085E904 /* Style */ = { isa = PBXGroup; children = ( - 7E4123C720F417EF00DF8486 /* FormattableActivity.swift */, - 7E4123C920F4184200DF8486 /* ActivityContentGroup.swift */, - 7E4123CB20F418A500DF8486 /* ActivityActionsParser.swift */, - 7E3AB3DA20F52654001F33B6 /* ActivityContentStyles.swift */, - 7E53AAFF20FE55A9005796FE /* ActivityContentRouter.swift */, - 7E4A773D20F80625001C706D /* Factory */, - 7E4A773020F80063001C706D /* Ranges */, + 5D1181E61B4D6DEB003F3084 /* WPStyleGuide+Reader.swift */, + 5D7DEA2819D488DD0032EE77 /* WPStyleGuide+ReaderComments.swift */, ); - path = FormattableContent; + name = Style; sourceTree = ""; }; - 7E442FC520F677A300DEACA5 /* ActivityLog */ = { + 5DA5BF4918E32DDB005F11F9 /* Themes */ = { isa = PBXGroup; children = ( - 7E4A772E20F7FDF8001C706D /* ActivityLogTestData.swift */, - 7E442FC620F677CB00DEACA5 /* ActivityLogRangesTest.swift */, - 7E442FCE20F6C19000DEACA5 /* ActivityLogFormattableContentTests.swift */, - 7E53AB0320FE6681005796FE /* ActivityContentRouterTests.swift */, - 7E53AB0920FE83A9005796FE /* MockContentCoordinator.swift */, - ); - name = ActivityLog; - sourceTree = ""; - }; - 7E442FC820F6783600DEACA5 /* ActivityLog */ = { - isa = PBXGroup; - children = ( - 7E442FC920F678D100DEACA5 /* activity-log-pingback-content.json */, - 7E4A772020F7BBBD001C706D /* activity-log-post-content.json */, - 7E4A772220F7BE94001C706D /* activity-log-comment-content.json */, - 7E4A772420F7C5E5001C706D /* activity-log-theme-content.json */, - 7E4A772620F7CDD5001C706D /* activity-log-settings-content.json */, - 7E4A772A20F7E5FD001C706D /* activity-log-site-content.json */, - 7E4A772C20F7E8D8001C706D /* activity-log-plugin-content.json */, - 7E53AB0520FE6905005796FE /* activity-log-comment.json */, - 7E53AB0720FE6C9C005796FE /* activity-log-post.json */, - D821C818210037F8002ED995 /* activity-log-activity-content.json */, + 596C035F1B84F24000899EEB /* ThemeBrowser.storyboard */, + 820ADD6F1F3A1F88002D7F93 /* ThemeBrowserSectionHeaderView.xib */, + 820ADD711F3A226E002D7F93 /* ThemeBrowserSectionHeaderView.swift */, + FA77E0291BE17CFC006D45E0 /* ThemeBrowserHeaderView.swift */, + 596C035D1B84F21D00899EEB /* ThemeBrowserViewController.swift */, + C3B5545229661F2C00A04753 /* ThemeBrowserViewController+JetpackBannerViewController.swift */, + 598DD1701B97985700146967 /* ThemeBrowserCell.swift */, + FACB36F01C5C2BF800C6DF4E /* ThemeWebNavigationDelegate.swift */, + FA1ACAA11BC6E45D00DDDCE2 /* WPStyleGuide+Themes.swift */, + 1767494D1D3633A000B8D1D1 /* ThemeBrowserSearchHeaderView.swift */, ); - name = ActivityLog; + path = Themes; sourceTree = ""; }; - 7E4A773020F80063001C706D /* Ranges */ = { + 5DA5BF4A18E32DE2005F11F9 /* Media */ = { isa = PBXGroup; children = ( - 7E442FCB20F6AACB00DEACA5 /* ActivityRange.swift */, - 7E4A773120F800CB001C706D /* ActivityPostRange.swift */, - 7E4A773320F800ED001C706D /* ActivityPluginRange.swift */, - 7E846FF220FD37BD00881F5A /* ActivityCommentRange.swift */, + C87501EF243AEC290002CD60 /* Tenor */, + D8A3A5AD206A059100992576 /* StockPhotos */, + FF8A04DF1D9BFE7400523BC4 /* CachedAnimatedImageView.swift */, + 7435CE7220A4B9170075A1B9 /* AnimatedImageCache.swift */, + B5FF3BE61CAD881100C1D597 /* ImageCropOverlayView.swift */, + B5A05AD81CA48601002EC787 /* ImageCropViewController.swift */, + B5EEB19E1CA96D19004B6540 /* ImageCropViewController.xib */, + FF945F6E1B28242300FB8AC4 /* MediaLibraryPickerDataSource.h */, + FF945F6F1B28242300FB8AC4 /* MediaLibraryPickerDataSource.m */, + FFE3B2C51B2E651400E9F1E0 /* WPAndDeviceMediaLibraryDataSource.h */, + FFE3B2C61B2E651400E9F1E0 /* WPAndDeviceMediaLibraryDataSource.m */, + 17BB26AD1E6D8321008CD031 /* MediaLibraryViewController.swift */, + 173B215427875E0600D4DD6B /* DeviceMediaPermissionsHeader.swift */, + 1782BE831E70063100A91E7D /* MediaItemViewController.swift */, + 177074841FB209F100951A4A /* CircularProgressView.swift */, + 981D0929211259840014ECAF /* NoResultsViewController+MediaLibrary.swift */, + 17D5C3F61FFCF2D800EB70FF /* MediaProgressCoordinatorNoticeViewModel.swift */, + 1750BD6C201144DB0050F13A /* MediaNoticeNavigationCoordinator.swift */, + D80BC79B207464D200614A59 /* MediaLibraryMediaPickingCoordinator.swift */, + D80BC79F2074722000614A59 /* CameraCaptureCoordinator.swift */, + D80BC7A12074739300614A59 /* MediaLibraryStrings.swift */, + D80BC7A3207487F200614A59 /* MediaLibraryPicker.swift */, + 7E14635620B3BEAB00B95F41 /* WPStyleGuide+Loader.swift */, + 7ECD5B8020C4D823001AEBC5 /* MediaPreviewHelper.swift */, ); - path = Ranges; + path = Media; sourceTree = ""; }; - 7E4A773D20F80625001C706D /* Factory */ = { + 5DF3DD691A9377220051A229 /* Controllers */ = { isa = PBXGroup; children = ( - 7E4A773A20F8058F001C706D /* ActivityContentFactory.swift */, - 7E4A773520F80146001C706D /* ActivityRangesFactory.swift */, + 591232681CCEAA5100B86207 /* AbstractPostListViewController.swift */, + 590E873A1CB8205700D1B734 /* PostListViewController.swift */, + E684383D221F535900752258 /* LoadMoreCounter.swift */, ); - path = Factory; + name = Controllers; sourceTree = ""; }; - 7E58879820FE8D8300DB6F80 /* Environment */ = { + 5DF3DD6A1A93772D0051A229 /* Views */ = { isa = PBXGroup; children = ( - 7E58879D20FE954600DB6F80 /* Protocols */, - 7E58879920FE8D9300DB6F80 /* Environment.swift */, + 597421B01CEB6874005D5F38 /* ConfigurablePostView.h */, + 57D6C83F229498C4003DDC7E /* InteractivePostView.swift */, + 575E126E229779E70041B3EB /* RestorePostTableViewCell.swift */, + 5D2FB2821AE98C4600F1D4ED /* RestorePostTableViewCell.xib */, + 5D2C05541AD2F56200A753FE /* NavBarTitleDropdownButton.h */, + 5D2C05551AD2F56200A753FE /* NavBarTitleDropdownButton.m */, + 5D732F951AE84E3C00CD89E7 /* PostListFooterView.h */, + 5D732F961AE84E3C00CD89E7 /* PostListFooterView.m */, + 5D732F981AE84E5400CD89E7 /* PostListFooterView.xib */, + 5DAFEAB61AF2CA6E00B3E1D7 /* PostMetaButton.h */, + 5DAFEAB71AF2CA6E00B3E1D7 /* PostMetaButton.m */, + 17F67C55203D81430072001E /* PostCardStatusViewModel.swift */, + 17A28DC42050404C00EA6D9E /* AuthorFilterButton.swift */, + 17A28DCA2052FB5D00EA6D9E /* AuthorFilterViewController.swift */, + 5727EAF72284F5AC00822104 /* InteractivePostViewDelegate.swift */, + 57AA848E228715DA00D3C2A2 /* PostCardCell.swift */, + 57AA8490228715E700D3C2A2 /* PostCardCell.xib */, + 577C2AB322943FEC00AD1F03 /* PostCompactCell.swift */, + 577C2AB52294401800AD1F03 /* PostCompactCell.xib */, ); - path = Environment; + name = Views; sourceTree = ""; }; - 7E58879D20FE954600DB6F80 /* Protocols */ = { + 5DF3DD6B1A93773B0051A229 /* Style */ = { isa = PBXGroup; children = ( - 7E58879F20FE956100DB6F80 /* AppRatingUtilityType.swift */, - 7E9B90F721127CA400AF83E6 /* ContextManagerType.swift */, + 5703A4C522C003DC0028A343 /* WPStyleGuide+Posts.swift */, ); - path = Protocols; + name = Style; sourceTree = ""; }; - 7E7947A6210BAB9F005BB851 /* Actions */ = { + 5DF7F7751B223895003A05C8 /* 30-31 */ = { isa = PBXGroup; children = ( - 7E4123B820F4097B00DF8486 /* FormattableContentAction.swift */, - 7E4123B720F4097B00DF8486 /* FormattableContentActionCommand.swift */, - 7E4123AF20F4097A00DF8486 /* DefaultFormattableContentAction.swift */, + 5DF7F7761B223916003A05C8 /* PostToPost30To31.h */, + 5DF7F7771B223916003A05C8 /* PostToPost30To31.m */, ); - path = Actions; + name = "30-31"; sourceTree = ""; }; - 7E7947A7210BABF5005BB851 /* Ranges */ = { + 5DFA7EBD1AF7CB2E0072023B /* Controllers */ = { isa = PBXGroup; children = ( - 7E7947AC210BAC7B005BB851 /* FormattableNoticonRange.swift */, - 7E7947AA210BAC5E005BB851 /* NotificationCommentRange.swift */, - 7E7947A8210BAC1D005BB851 /* NotificationContentRange.swift */, - 7E4123B120F4097A00DF8486 /* NotificationContentRangeFactory.swift */, + 59DCA5201CC68AF3000F245F /* PageListViewController.swift */, + 9AF724EE2146813C00F63A61 /* ParentPageSettingsViewController.swift */, + 7D21280C251CF0850086DD2C /* EditPageViewController.swift */, ); - path = Ranges; + name = Controllers; sourceTree = ""; }; - 7E8980CB22E8C81B00C567B0 /* 87-88 */ = { + 5DFA7EBE1AF7CB3A0072023B /* Views */ = { isa = PBXGroup; children = ( - 7E8980C922E8C7A600C567B0 /* BlogToBlogMigration87to88.swift */, + 59A3CADB1CD2FF0C009BFA1B /* BasePageListCell.h */, + 59A3CADC1CD2FF0C009BFA1B /* BasePageListCell.m */, + 5DFA7EC41AF814E40072023B /* PageListTableViewCell.h */, + 5DFA7EC51AF814E40072023B /* PageListTableViewCell.m */, + 5DFA7EC61AF814E40072023B /* PageListTableViewCell.xib */, + 5D13FA561AF99C2100F06492 /* PageListSectionHeaderView.xib */, + 5D18FE9C1AFBB17400EFEED0 /* RestorePageTableViewCell.h */, + 5D18FE9D1AFBB17400EFEED0 /* RestorePageTableViewCell.m */, + 5D18FE9E1AFBB17400EFEED0 /* RestorePageTableViewCell.xib */, + 8B93856D22DC08060010BF02 /* PageListSectionHeaderView.swift */, ); - name = "87-88"; + name = Views; sourceTree = ""; }; - 7EAA66ED22CB36DA00869038 /* Utils */ = { + 73178C2021BEE09300E37C9A /* SiteCreation */ = { isa = PBXGroup; children = ( - 7EAA66EE22CB36FD00869038 /* TestAnalyticsTracker.swift */, + D842EA3F21FABB1700210E96 /* SiteSegmentTests.swift */, + 73B6693921CAD960008456C3 /* ErrorStateViewTests.swift */, + 73178C3221BEE94700E37C9A /* SiteAssemblyServiceTests.swift */, + 73C8F06521BEF76B00DDDF7E /* SiteAssemblyViewTests.swift */, + 73178C2221BEE09300E37C9A /* SiteCreationDataCoordinatorTests.swift */, + 73178C2621BEE09300E37C9A /* SiteCreationHeaderDataTests.swift */, + 730354B921C867E500CD18C2 /* SiteCreatorTests.swift */, + C396C80A280F2401006FE7AC /* SiteDesignTests.swift */, + C3C70C552835C5BB00DD2546 /* SiteDesignSectionLoaderTests.swift */, + 73178C2421BEE09300E37C9A /* SiteSegmentsCellTests.swift */, + 73178C2321BEE09300E37C9A /* SiteSegmentsStepTests.swift */, + 73178C3421BEE9AC00E37C9A /* TitleSubtitleHeaderTests.swift */, + 32C6CDDA23A1FF0D002556FF /* SiteCreationRotatingMessageViewTests.swift */, + C373D6E9280452F6008F8C26 /* SiteIntentDataTests.swift */, + B030FE0927EBF0BC000F6F5E /* SiteCreationIntentTracksEventTests.swift */, + C3DA0EDF2807062600DA3250 /* SiteCreationNameTracksEventTests.swift */, + C3439B5E27FE3A3C0058DA55 /* SiteCreationWizardLauncherTests.swift */, ); - path = Utils; + path = SiteCreation; sourceTree = ""; }; - 7EC9FE0822C6275900C5A888 /* Analytics */ = { + 73178C2D21BEE13E00E37C9A /* Final Assembly */ = { isa = PBXGroup; children = ( - 7EAA66ED22CB36DA00869038 /* Utils */, - 7EC9FE0922C6276B00C5A888 /* EditorAnalytics */, + 7305138221C031FC006BD0A1 /* AssembledSiteView.swift */, + 73C8F06721BF1A5E00DDDF7E /* SiteAssemblyContentView.swift */, + 73C8F05F21BEED9100DDDF7E /* SiteAssemblyStep.swift */, + 73C8F06121BEEEDE00DDDF7E /* SiteAssemblyWizardContent.swift */, + 73856E5A21E1602400773CD9 /* SiteCreationRequest+Validation.swift */, + 3221278523A0BD27002CA183 /* SiteCreationRotatingMessageView.swift */, + C957C20526DCC1770037628F /* LandInTheEditorHelper.swift */, ); - path = Analytics; + path = "Final Assembly"; sourceTree = ""; }; - 7EC9FE0922C6276B00C5A888 /* EditorAnalytics */ = { + 731E88C521C9A10A0055C014 /* ErrorStates */ = { isa = PBXGroup; children = ( - 7EC9FE0A22C627DB00C5A888 /* PostEditorAnalyticsSessionTests.swift */, + 2FA6511921F26A57009AA935 /* InlineErrorRetryTableViewCell.swift */, + 731E88C721C9A10A0055C014 /* ErrorStateView.swift */, + 731E88C621C9A10A0055C014 /* ErrorStateViewConfiguration.swift */, + 731E88C821C9A10A0055C014 /* ErrorStateViewController.swift */, ); - path = EditorAnalytics; + path = ErrorStates; sourceTree = ""; }; - 7EFF207F20EAC59D009C4699 /* Groups */ = { + 732A4738218786F30015DA74 /* Views */ = { isa = PBXGroup; children = ( - 7E3E7A6120E44E6A0075D159 /* BodyContentGroup.swift */, - 7E3E7A5F20E44E490075D159 /* FooterContentGroup.swift */, - 7E3E7A6520E44F200075D159 /* HeaderContentGroup.swift */, - 7E3E7A6320E44ED60075D159 /* SubjectContentGroup.swift */, + ACACE3AF28D76A62000992F9 /* Controllers */, + 732A473B218787500015DA74 /* WPRichText */, ); - path = Groups; + name = Views; + path = ViewRelated/Views; sourceTree = ""; }; - 8298F38D1EEF2B15008EB7F0 /* Ratings */ = { + 732A473B218787500015DA74 /* WPRichText */ = { isa = PBXGroup; children = ( - 8298F38E1EEF2B15008EB7F0 /* AppFeedbackPromptView.swift */, + 732A473E21878EB10015DA74 /* WPRichContentViewTests.swift */, + 732A473C218787500015DA74 /* WPRichTextFormatterTests.swift */, ); - path = Ratings; + path = WPRichText; sourceTree = ""; }; - 82FC61181FA8ADAC00A1757E /* Activity */ = { + 7335AC55212202E30012EF2D /* FormattableContent */ = { isa = PBXGroup; children = ( - 7E4123C620F4178E00DF8486 /* FormattableContent */, - 82FC612B1FA8B7FC00A1757E /* ActivityListRow.swift */, - 403F57BB20E5CA6A004E889A /* RewindStatusRow.swift */, - 82A062DD2017BCBA0084CE7C /* ActivityListSectionHeaderView.swift */, - 82A062DB2017BC220084CE7C /* ActivityListSectionHeaderView.xib */, - 82FC611D1FA8ADAC00A1757E /* ActivityListViewController.swift */, - 82FC61291FA8B6F000A1757E /* ActivityListViewModel.swift */, - 40FC6B7E2072E3EB00B9A1CD /* ActivityDetailViewController.swift */, - 82FC611C1FA8ADAC00A1757E /* ActivityTableViewCell.swift */, - 82FC611E1FA8ADAC00A1757E /* ActivityTableViewCell.xib */, - 825327571FBF7CD600B8B7D2 /* ActivityUtils.swift */, - 82B67B351FC726CD006FB593 /* Memoize.swift */, - 82FC611F1FA8ADAC00A1757E /* WPStyleGuide+Activity.swift */, - 4070D75D20E6B4E4007CEBDA /* ActivityDateFormatting.swift */, - 4019B27020885AB900A0C7EB /* Activity.storyboard */, - 4070D75B20E5F55A007CEBDA /* RewindStatusTableViewCell.xib */, + 7335AC5F21220D550012EF2D /* RemoteNotificationActionParser.swift */, + 73EDC709212E5D6700E5E3ED /* RemoteNotificationStyles.swift */, + 73F6DD41212BA54700CE447D /* RichNotificationContentFormatter.swift */, ); - path = Activity; + path = FormattableContent; sourceTree = ""; }; - 8320B5CF11FCA3EA00607422 /* Cells */ = { + 733F36072126197800988727 /* WordPressNotificationContentExtension */ = { isa = PBXGroup; children = ( - 40640045200ED30300106789 /* TextWithAccessoryButtonCell.xib */, - E14977171C0DC0770057CD60 /* MediaSizeSliderCell.swift */, - E14977191C0DCB6F0057CD60 /* MediaSizeSliderCell.xib */, - FF00889E204E01AE007CCE66 /* MediaQuotaCell.swift */, - FF00889C204DFF77007CCE66 /* MediaQuotaCell.xib */, - B5CABB161C0E382C0050AB9F /* PickerTableViewCell.swift */, - 5D839AA6187F0D6B00811F4A /* PostFeaturedImageCell.h */, - 5D839AA7187F0D6B00811F4A /* PostFeaturedImageCell.m */, - 5D839AA9187F0D8000811F4A /* PostGeolocationCell.h */, - 5D839AAA187F0D8000811F4A /* PostGeolocationCell.m */, - B56A70171B5040B9001D5815 /* SwitchTableViewCell.swift */, - 9F74696A209EFD0C0074D52B /* CheckmarkTableViewCell.swift */, - E1BB92311FDAAFFA00F2D817 /* TextWithAccessoryButtonCell.swift */, - E66E2A671FE432B900788F22 /* TitleBadgeDisclosureCell.swift */, - 4034FDE92007C42400153B87 /* ExpandableCell.swift */, - 4034FDED2007D4F700153B87 /* ExpandableCell.xib */, - E66E2A681FE432BB00788F22 /* TitleBadgeDisclosureCell.xib */, - 74558368201A1FD3007809BB /* WPReusableTableViewCells.swift */, - 30EABE0718A5903400B73A9C /* WPBlogTableViewCell.h */, - 30EABE0818A5903400B73A9C /* WPBlogTableViewCell.m */, - FF0AAE081A1509C50089841D /* WPProgressTableViewCell.h */, - FF0AAE091A150A560089841D /* WPProgressTableViewCell.m */, - 5D6C4AF51B603CA3005E3C43 /* WPTableViewActivityCell.xib */, - 8370D10811FA499A009D650F /* WPTableViewActivityCell.h */, - 8370D10911FA499A009D650F /* WPTableViewActivityCell.m */, - 1730D4A21E97E3E400326B7C /* MediaItemTableViewCells.swift */, - 43D74ACD20F906DD004AD934 /* InlineEditableNameValueCell.xib */, - 43D74ACF20F906EE004AD934 /* InlineEditableNameValueCell.swift */, + 73D5AC5B212622B200ADDDD2 /* Sources */, + 73D5AC5A2126225300ADDDD2 /* Supporting Files */, ); - path = Cells; + path = WordPressNotificationContentExtension; sourceTree = ""; }; - 850BD4531922F95C0032F3AD /* Networking */ = { + 7347407C2114C29B007FDDFF /* Supporting Files */ = { isa = PBXGroup; children = ( - E11DA4921E03E03F00CF07A8 /* Pinghub.swift */, + 734740762114C296007FDDFF /* Info.plist */, + 734740772114C296007FDDFF /* Info-Alpha.plist */, + 734740782114C296007FDDFF /* Info-Internal.plist */, + 7347407D2114C4DC007FDDFF /* WordPressNotificationServiceExtension.entitlements */, + 7347407F2114C5F0007FDDFF /* WordPressNotificationServiceExtension-Alpha.entitlements */, + 7347407E2114C5EF007FDDFF /* WordPressNotificationServiceExtension-Internal.entitlements */, ); - path = Networking; + name = "Supporting Files"; sourceTree = ""; }; - 850D22B21729EE8600EC6A16 /* NUX */ = { + 7358E6B9210BD318002323EB /* WordPressNotificationServiceExtension */ = { isa = PBXGroup; children = ( - 3249615523F2013B004C7733 /* Post Signup Interstitial */, - B51AD77A2056C31100A6C545 /* LoginEpilogue.storyboard */, - B59F34A0207678480069992D /* SignupEpilogue.storyboard */, - E6417B961CA07B060084050A /* Controllers */, - E6417B9A1CA07C0A0084050A /* Helpers */, - E6417B951CA07AFE0084050A /* Views */, + 7396FE64210F72D400496D0D /* Sources */, + 7347407C2114C29B007FDDFF /* Supporting Files */, ); - path = NUX; + path = WordPressNotificationServiceExtension; sourceTree = ""; }; - 8511CFB71C607A7000B7CEED /* WordPressScreenshotGeneration */ = { + 736584E72137533A0029C9A4 /* Views */ = { isa = PBXGroup; children = ( - 8511CFBA1C607A7000B7CEED /* Info.plist */, - 8511CFC41C60884400B7CEED /* SnapshotHelper.swift */, - 8511CFC61C60894200B7CEED /* WordPressScreenshotGeneration.swift */, - F9463A7221C05EE90081F11E /* ScreenshotCredentials.swift */, + 736584E82137533A0029C9A4 /* NotificationContentView.swift */, ); - path = WordPressScreenshotGeneration; + path = Views; sourceTree = ""; }; - 852416CC1A12EAF70030700C /* Ratings */ = { + 736584E92137533A0029C9A4 /* Tracks */ = { isa = PBXGroup; children = ( - E14A52361E39F43E00EE203E /* AppRatingsUtility.swift */, + 736584EA2137533A0029C9A4 /* Tracks+ContentExtension.swift */, ); - path = Ratings; + path = Tracks; sourceTree = ""; }; - 852416D01A12ED2D0030700C /* Utility */ = { + 73768B69212B4DE8005136A1 /* NotificationContent */ = { isa = PBXGroup; children = ( - F93735F422D53C1800A3C312 /* Logging */, - 93B853211B44165B0064FE72 /* Analytics */, - F198533B21ADAD0700DCDAE7 /* Editor */, - 08F8CD2B1EBD243A0049D0C0 /* Media */, - 82301B8E1E787420009C9C4E /* AppRatingUtilityTests.swift */, - 17AF92241C46634000A99CFB /* BlogSiteVisibilityHelperTest.m */, - E180BD4B1FB462FF00D0D781 /* CookieJarTests.swift */, - F565190223CF6D1D003FACAF /* WKCookieJarTests.swift */, - E1AB5A391E0C464700574B4E /* DelayTests.swift */, - E1EBC3721C118ED200F638E0 /* ImmuTableTest.swift */, - 93A379EB19FFBF7900415023 /* KeychainTest.m */, - 1759F1711FE017F20003EC81 /* QueueTests.swift */, - 1797373620EBAA4100377B4E /* RouteMatcherTests.swift */, - E1928B2D1F8369F100E076C8 /* WebViewAuthenticatorTests.swift */, - 5948AD101AB73D19006E8882 /* WPAppAnalyticsTests.m */, - E1E4CE0C177439D100430844 /* WPAvatarSourceTest.m */, - 5981FE041AB8A89A0009E080 /* WPUserAgentTests.m */, - 1ABA150722AE5F870039311A /* WordPressUIBundleTests.swift */, - 173D82E6238EE2A7008432DA /* FeatureFlagTests.swift */, - F551E7F623FC9A5C00751212 /* Collection+RotateTests.swift */, + 73F6DD43212C714F00CE447D /* RichNotificationViewModel.swift */, + 73768B6A212B4E4F005136A1 /* UNNotificationContent+RemoteNotification.swift */, ); - name = Utility; + path = NotificationContent; sourceTree = ""; }; - 8584FDB31923EF4F0019C02E /* ViewRelated */ = { + 738B9A3F21B85CF20005062B /* Wizard */ = { isa = PBXGroup; children = ( - 7E3E9B6E2177C9C300FD5797 /* Gutenberg */, - 98077B58207555E400109F95 /* Support */, - D80EE638203DBB7E0094C34C /* Accessibility */, - 82FC61181FA8ADAC00A1757E /* Activity */, - B50C0C441EF429D500372C65 /* Aztec */, - AC34397B0E11443300E5D79B /* Blog */, - 8320B5CF11FCA3EA00607422 /* Cells */, - C533CF320E6D3AB3000C3DE8 /* Comments */, - 173BCE711CEB365400AE8817 /* Domains */, - E1F391ED1FF25DEC00DB32A3 /* Jetpack */, - 31F4F6641A13858F00196A98 /* Me */, - 5DA5BF4A18E32DE2005F11F9 /* Media */, - 08D978491CD2AF7D0054F19A /* Menus */, - CC1D800D1656D8B2002A542F /* Notifications */, - 850D22B21729EE8600EC6A16 /* NUX */, - EC4696A80EA74DAC0040EE8E /* Pages */, - E1B9127F1BB00EF1003C25B9 /* People */, - E1389AD91C59F78500FB2466 /* Plans */, - E14694041F3459A9004052C8 /* Plugins */, - AC3439790E11434600E5D79B /* Post */, - 8298F38D1EEF2B15008EB7F0 /* Ratings */, - CCB3A03814C8DD5100D43C3F /* Reader */, - 1719633E1D378D1E00898E8B /* Search */, - 3792259E12F6DBCC00F2176A /* Stats */, - D865720F21869C380023A99C /* Site Creation */, - 319D6E8219E44C7B0013871C /* Suggestions */, - 8584FDB619243AC40019C02E /* System */, - 5DA5BF4918E32DDB005F11F9 /* Themes */, - B53AD9B31BE9560F009AB87E /* Tools */, - 031662E60FFB14C60045D052 /* Views */, - D86572182186C36E0023A99C /* Wizards */, + 738B9A4021B85CF20005062B /* SiteCreationWizard.swift */, + 738B9A4421B85CF20005062B /* SiteCreationWizardLauncher.swift */, + C35D4FF0280077F100DB90B5 /* SiteCreationStep.swift */, + 738B9A4121B85CF20005062B /* SiteCreator.swift */, + 738B9A4621B85CF20005062B /* WizardNavigation.swift */, ); - path = ViewRelated; + path = Wizard; sourceTree = ""; }; - 8584FDB4192437160019C02E /* Utility */ = { + 738B9A4721B85CF20005062B /* Shared */ = { isa = PBXGroup; children = ( - 1A433B1B2254CBC300AE7910 /* Networking */, - 40247E002120FE2300AE1C3C /* Automated Transfer */, - F198533821ADAA4E00DCDAE7 /* Editor */, - 7E58879820FE8D8300DB6F80 /* Environment */, - 7E4123AB20F4096200DF8486 /* FormattableContent */, - 436D55D7210F85C200CEAA33 /* ViewLoading */, - 1797373920EBDA1100377B4E /* Universal Links */, - 984B4EF120742FB900F87888 /* Zendesk */, - 7E21C763202BBE9F00837CF5 /* iAds */, - B5C9401B1DB901120079D4FF /* Account */, - 85A1B6721742E7DB00BA5E35 /* Analytics */, - F1D690131F828FF000200E30 /* BuildInformation */, - B5ECA6CB1DBAA0110062D7E0 /* CoreData */, - B5DB8AF51C949DC70059196A /* ImmuTable */, - 59DD94311AC479DC0032DD6B /* Logging */, - 08F8CD281EBD22EF0049D0C0 /* Media */, - E159D1011309AAF200F498E2 /* Migrations */, - B53520991AF7BB9600B33BA8 /* Notifications */, - 852416CC1A12EAF70030700C /* Ratings */, - B5C9401D1DB901F30079D4FF /* Reachability */, - E1523EB216D3B2EE002C5A36 /* Sharing */, - 74729CA12056F9DB00D1394D /* Spotlight */, - B526DC241B1E473B002A8C5F /* WebViewController */, - E1BEEC621C4E35A8000B4FA0 /* Animator.swift */, - E14BCABA1E0BC817002E0603 /* Delay.swift */, - 40D78238206ABD970015A3A1 /* Scheduler.swift */, - E11000981CDB5F1E00E33887 /* KeychainTools.swift */, - 5DB4683918A2E718004A89A9 /* LocationService.h */, - 5DB4683A18A2E718004A89A9 /* LocationService.m */, - E11450DE1C4E47E600A6BD0F /* NoticeAnimator.swift */, - E1B84EFF1E02E94D00BF6434 /* PingHubManager.swift */, - 1759F16F1FE017BF0003EC81 /* Queue.swift */, - 292CECFE1027259000BD407D /* SFHFKeychainUtils.h */, - 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */, - 594399911B45091000539E21 /* WPAuthTokenIssueSolver.h */, - 594399921B45091000539E21 /* WPAuthTokenIssueSolver.m */, - E1E4CE091773C59B00430844 /* WPAvatarSource.h */, - E1E4CE0A1773C59B00430844 /* WPAvatarSource.m */, - 0807CB711CE670A800CDBDAC /* WPContentSearchHelper.swift */, - 5D6C4B051B603E03005E3C43 /* WPContentSyncHelper.swift */, - E114D798153D85A800984182 /* WPError.h */, - E114D799153D85A800984182 /* WPError.m */, - 57D5812C2228526C002BAAD7 /* WPError+Swift.swift */, - 8B05D29023A9417E0063B9AA /* WPMediaEditor.swift */, - 5D6C4B061B603E03005E3C43 /* WPTableViewHandler.h */, - 5D6C4B071B603E03005E3C43 /* WPTableViewHandler.m */, - E1F5A1BA1771C90A00E0495F /* WPTableImageSource.h */, - E1F5A1BB1771C90A00E0495F /* WPTableImageSource.m */, - 594DB2931AB891A200E2E456 /* WPUserAgent.h */, - 594DB2941AB891A200E2E456 /* WPUserAgent.m */, - FF28B3EF1AEB251200E11AAE /* InfoPListTranslator.h */, - FF28B3F01AEB251200E11AAE /* InfoPListTranslator.m */, - 85B125431B02937E008A3D95 /* UIAlertControllerProxy.h */, - 85B125441B02937E008A3D95 /* UIAlertControllerProxy.m */, - 7E846FF020FD0A0500881F5A /* ContentCoordinator.swift */, - FFCB9F4922A125BD0080A45F /* WPException.h */, - FFCB9F4A22A125BD0080A45F /* WPException.m */, - 57C3D392235DFD8E00FE9CE6 /* ActionDispatcherFacade.swift */, - F582060123A85495005159A9 /* SiteDateFormatters.swift */, + 731E88C521C9A10A0055C014 /* ErrorStates */, + 738B9A4C21B85CF20005062B /* KeyboardInfo.swift */, + 738B9A4921B85CF20005062B /* ModelSettableCell.swift */, + D813D67E21AA8BBF0055CCA1 /* ShadowView.swift */, + 738B9A4D21B85CF20005062B /* SiteCreationHeaderData.swift */, + 738B9A4A21B85CF20005062B /* TableDataCoordinator.swift */, + 73CE3E0D21F7F9D3007C9C85 /* TableViewOffsetCoordinator.swift */, + 738B9A4B21B85CF20005062B /* TitleSubtitleHeader.swift */, + 738B9A4821B85CF20005062B /* TitleSubtitleTextfieldHeader.swift */, + 738B9A5D21B8632E0005062B /* UITableView+Header.swift */, + 738B9A5B21B85EB00005062B /* UIView+ContentLayout.swift */, + 46D6114E2555DAED00B0B7BB /* SiteCreationAnalyticsHelper.swift */, + F45326D729F6B8A6005F9F31 /* SiteCreationAnalyticsEvent.swift */, ); - path = Utility; + path = Shared; sourceTree = ""; }; - 8584FDB619243AC40019C02E /* System */ = { + 7396FE64210F72D400496D0D /* Sources */ = { isa = PBXGroup; children = ( - F551E7F323F6EA1400751212 /* Floating Create Button */, - 17A4A36A20EE551F0071C2CA /* Coordinators */, - 1759F17E1FE145F90003EC81 /* Notices */, - 17D433581EFD2ED500CAB602 /* Fancy Alerts */, - B5F641B21E37C36700B7819F /* AdaptiveNavigationController.swift */, - 1746D7761D2165AE00B11D77 /* ForcePopoverPresenter.swift */, - E1DD4CCA1CAE41B800C3863E /* PagedViewController.swift */, - E1DD4CCC1CAE41C800C3863E /* PagedViewController.xib */, - 5DBFC8AA1A9C0EEF00E00DE4 /* WPScrollableViewController.h */, - 176DEEE81D4615FE00331F30 /* WPSplitViewController.swift */, - 310186691A373B01008F7DF6 /* WPTabBarController.h */, - 3101866A1A373B01008F7DF6 /* WPTabBarController.m */, - 17A28DC2205001A900EA6D9E /* FilterTabBar.swift */, - D8B9B58E204F4EA1003C6042 /* NetworkAware.swift */, - F5E032DE2408D1F1003AF350 /* WPTabBarController+ShowTab.swift */, - 3F30E50823FB362700225013 /* WPTabBarController+MeNavigation.swift */, - 43DDFE8B21715ADD008BE72F /* WPTabBarController+QuickStart.swift */, - 57BAD50B225CCE1A006139EC /* WPTabBarController+Swift.swift */, + 7335AC55212202E30012EF2D /* FormattableContent */, + 73768B69212B4DE8005136A1 /* NotificationContent */, + 73E40D8A21238C400012ABA6 /* Tracks */, + 7396FE65210F730600496D0D /* NotificationService.swift */, + 73ACDF9F2118B03700233AD4 /* WordPressNotificationServiceExtension-Bridging-Header.h */, ); - path = System; + path = Sources; sourceTree = ""; }; - 8584FDB719243E550019C02E /* System */ = { + 73D5AC5A2126225300ADDDD2 /* Supporting Files */ = { isa = PBXGroup; children = ( - BE87E19E1BD4052F0075D45B /* 3DTouch */, - 2F970F970DF929B8006BD934 /* Constants.h */, - B5CC05F51962150600975CAC /* Constants.m */, - B5FD4520199D0C9A00286FBB /* WordPress-Bridging-Header.h */, - 1749965E2271BF08007021BD /* WordPressAppDelegate.swift */, - 43B0BA952229927F00328C69 /* WordPressAppDelegate+openURL.swift */, - 591A428D1A6DC6F2003807A6 /* WPGUIConstants.h */, - 591A428E1A6DC6F2003807A6 /* WPGUIConstants.m */, - 17F7C24822770B68002E5C2E /* main.swift */, + 733F360D2126197800988727 /* Info.plist */, + 73D5AC662126236600ADDDD2 /* Info-Alpha.plist */, + 73D5AC672126236600ADDDD2 /* Info-Internal.plist */, + 732D4E1D2126253900BF7F11 /* WordPressNotificationContentExtension.entitlements */, + 732D4E1E212625A100BF7F11 /* WordPressNotificationContentExtension-Alpha.entitlements */, + 732D4E1F212625A100BF7F11 /* WordPressNotificationContentExtension-Internal.entitlements */, ); - path = System; + name = "Supporting Files"; sourceTree = ""; }; - 858DE3FF172F9991000AC628 /* Fonts */ = { + 73D5AC5B212622B200ADDDD2 /* Sources */ = { isa = PBXGroup; children = ( - E6B42CBE1D9DA6270043E228 /* Noticons.ttf */, + 736584E92137533A0029C9A4 /* Tracks */, + 736584E72137533A0029C9A4 /* Views */, + 73D5AC5C212622B200ADDDD2 /* NotificationViewController.swift */, + 73B05D2A21374FE50073ECAA /* WordPressNotificationContentExtension-Bridging-Header.h */, ); - name = Fonts; + path = Sources; sourceTree = ""; }; - 85A1B6721742E7DB00BA5E35 /* Analytics */ = { + 73E40D8A21238C400012ABA6 /* Tracks */ = { isa = PBXGroup; children = ( - 937F3E301AD6FDA7006BA498 /* WPAnalyticsTrackerAutomatticTracks.h */, - 937F3E311AD6FDA7006BA498 /* WPAnalyticsTrackerAutomatticTracks.m */, - 85DA8C4218F3F29A0074C8A4 /* WPAnalyticsTrackerWPCom.h */, - 85DA8C4318F3F29A0074C8A4 /* WPAnalyticsTrackerWPCom.m */, - 5948AD0C1AB734F2006E8882 /* WPAppAnalytics.h */, - 5948AD0D1AB734F2006E8882 /* WPAppAnalytics.m */, - 0828D7F91E6E09AE00C7C7D4 /* WPAppAnalytics+Media.swift */, + 73E40D8B21238C520012ABA6 /* Tracks+ServiceExtension.swift */, ); - path = Analytics; + path = Tracks; sourceTree = ""; }; - 85F8E1991B017A8E000859BB /* Networking */ = { + 73FF702D221F43BB00541798 /* Charts */ = { isa = PBXGroup; children = ( - E15027641E03E54100B847E3 /* PinghubTests.swift */, + 73F76E1D222851E300FDDAD2 /* Charts+AxisFormatters.swift */, + 73CB13962289BEFB00265F49 /* Charts+LargeValueFormatter.swift */, + 73FF7031221F469100541798 /* Charts+Support.swift */, + 733195822284FE9F0007D904 /* PeriodChart.swift */, + DC772AEE282009B900664C02 /* InsightsLineChart.swift */, + 73E4E375227A033A0007D752 /* PostChart.swift */, + 735A9680228E421F00461135 /* StatsBarChartConfiguration.swift */, + 73FF702F221F43CD00541798 /* StatsBarChartView.swift */, + DC772AEF282009BA00664C02 /* StatsLineChartConfiguration.swift */, + DC772AF0282009BA00664C02 /* StatsLineChartView.swift */, + 73D86968223AF4040064920F /* StatsChartLegendView.swift */, + 1756F1DE2822BB6F00CD0915 /* SparklineView.swift */, + 1756DBDE28328B76006E6DB9 /* DonutChartView.swift */, ); - name = Networking; + path = Charts; sourceTree = ""; }; - 8B7623352384372200AB3EE7 /* Pages */ = { + 740C7C51202F50A3001C31B0 /* Shared UI */ = { isa = PBXGroup; children = ( - 8B7623362384372F00AB3EE7 /* Controllers */, + 74402F2F2005346100A1D4A2 /* Presentation */, + 74F5CD371FE0646F00764E7C /* ShareExtension.storyboard */, + 745EAF462003FDAA0066F415 /* ShareExtensionAbstractViewController.swift */, + 74F5CD391FE0653500764E7C /* ShareExtensionEditorViewController.swift */, + BE6787F41FFF2886005D9F01 /* ShareModularViewController.swift */, + 7414A140203CBADF005A7D9B /* ShareCategoriesPickerViewController.swift */, + 747F88C0203778E000523C7C /* ShareTagsPickerViewController.swift */, + CB48172926E0D93D008C2D9B /* SharePostTypePickerViewController.swift */, + 745EAF4920040B220066F415 /* ShareData.swift */, ); - path = Pages; + name = "Shared UI"; sourceTree = ""; }; - 8B7623362384372F00AB3EE7 /* Controllers */ = { + 741AF3A3202F3DFC00C771A5 /* Tracks */ = { isa = PBXGroup; children = ( - 8B7623372384373E00AB3EE7 /* PageListViewControllerTests.swift */, + 741AF3A4202F3E2A00C771A5 /* Tracks+DraftAction.swift */, ); - path = Controllers; + name = Tracks; sourceTree = ""; }; - 8BD36E042395CC2F00EFFF1C /* Aztec */ = { + 741D1478200D5533003DFD30 /* Style */ = { isa = PBXGroup; children = ( - 8BD36E052395CC4400EFFF1C /* MediaEditorOperation+DescriptionTests.swift */, + B50248AE1C96FF6200AFBDED /* WPStyleGuide+Share.swift */, ); - name = Aztec; + name = Style; sourceTree = ""; }; - 931D26FB19EDA0D000114F17 /* ALIterativeMigrator */ = { + 7430C4471F97F0DA00E2673E /* UI */ = { isa = PBXGroup; children = ( - 931D26FC19EDA10D00114F17 /* ALIterativeMigrator.h */, - 931D26FD19EDA10D00114F17 /* ALIterativeMigrator.m */, + 740C7C51202F50A3001C31B0 /* Shared UI */, + 740C7C4E202F4CD6001C31B0 /* MainInterface.storyboard */, + 74337EDC20054D5500777997 /* MainShareViewController.swift */, ); - name = ALIterativeMigrator; + name = UI; sourceTree = ""; }; - 932225A81C7CE50300443B02 /* WordPressShareExtension */ = { + 74402F2F2005346100A1D4A2 /* Presentation */ = { isa = PBXGroup; children = ( - 74FA4BDB1FBF994F0031EAAD /* Data */, - B5FA22851C99F63B0016CA7C /* Extensions */, - 7462BFD22028CD0500B552D8 /* Notices */, - 745EAF432003FC930066F415 /* Protocols */, - 74FA2EE2200E8A1D001DDC13 /* Services */, - 741D1478200D5533003DFD30 /* Style */, - B542DEBB1D119683004CA6AE /* Tools */, - B5FA22841C99F6340016CA7C /* Tracks */, - 7430C4471F97F0DA00E2673E /* UI */, - B5BEA5661C7CEB4400C8035B /* Supporting Files */, - B50248B81C96FFB000AFBDED /* WordPressShare-Bridging-Header.h */, - E1AFA8C21E8E34230004A323 /* WordPressShare.js */, + 74402F2B2005337D00A1D4A2 /* ExtensionTransitioningManager.swift */, + 74402F29200528F200A1D4A2 /* ExtensionPresentationController.swift */, + 74402F2D2005344700A1D4A2 /* ExtensionPresentationAnimator.swift */, ); - path = WordPressShareExtension; + name = Presentation; sourceTree = ""; }; - 937D9A0D19F837ED007B9D5F /* 22-23 */ = { + 74576673202B558C00F42E40 /* WordPressDraftActionExtension */ = { isa = PBXGroup; children = ( - 937D9A1019F838C2007B9D5F /* AccountToAccount22to23.swift */, + 741AF3A3202F3DFC00C771A5 /* Tracks */, + 74576686202B571700F42E40 /* Supporting Files */, + 74869D82202BA971007A0454 /* WordPressDraftActionExtension-Bridging-Header.h */, ); - name = "22-23"; + path = WordPressDraftActionExtension; sourceTree = ""; }; - 937E3AB41E3EBDC900CDA01A /* Post */ = { + 74576686202B571700F42E40 /* Supporting Files */ = { isa = PBXGroup; children = ( - 57E3C98223835A57004741DB /* Controllers */, - 57DF04BF2314895E00CC93D6 /* Views */, - 5749984522FA0EB900CE86ED /* Utils */, - 937E3AB51E3EBE1600CDA01A /* PostEditorStateTests.swift */, - E10F3DA01E5C2CE0008FAADA /* PostListFilterTests.swift */, - E684383F221F5A2200752258 /* PostListExcessiveLoadMoreTests.swift */, - 570BFD8C22823DE5007859A8 /* PostActionSheetTests.swift */, - 57AA8492228790AA00D3C2A2 /* PostCardCellTests.swift */, - 577C2AAA22936DCB00AD1F03 /* PostCardCellGhostableTests.swift */, - 57D6C83D22945A10003DDC7E /* PostCompactCellTests.swift */, - 575E126222973EBB0041B3EB /* PostCompactCellGhostableTests.swift */, - 570265162298960B00F2214C /* PostListTableViewHandlerTests.swift */, + 099D768127D14B8E00F77EDE /* InfoPlist.strings */, + 74CC4313202B5AA4000DAE1A /* Info.plist */, + 74CC4312202B5AA4000DAE1A /* Info-Internal.plist */, + 74CC4311202B5AA4000DAE1A /* Info-Alpha.plist */, + 74D6DA90202B651300A0E1FE /* WordPressDraftActionExtension.entitlements */, + 74D6DA92202B669100A0E1FE /* WordPressDraftActionExtension-Alpha.entitlements */, + 74D6DA91202B669100A0E1FE /* WordPressDraftActionExtension-Internal.entitlements */, + 74E44AD72031ED2300556205 /* WordPressDraft-Lumberjack.m */, + 74E44AD82031ED2300556205 /* WordPressDraftPrefix.pch */, ); - name = Post; + name = "Supporting Files"; sourceTree = ""; }; - 93B853211B44165B0064FE72 /* Analytics */ = { + 745EAF432003FC930066F415 /* Protocols */ = { isa = PBXGroup; children = ( - 93B853221B4416A30064FE72 /* WPAnalyticsTrackerAutomatticTracksTests.m */, + 745EAF442003FD050066F415 /* ShareSegueHandler.swift */, ); - name = Analytics; + name = Protocols; sourceTree = ""; }; - 93C2075B1CC7FFB100C94D04 /* Tracks */ = { + 7462BFD22028CD0500B552D8 /* Notices */ = { isa = PBXGroup; children = ( - 93C2075C1CC7FFC800C94D04 /* Tracks+TodayWidget.swift */, + 74F89406202A1965008610FA /* ExtensionNotificationManager.swift */, + 74EA3B87202A0462004F802D /* ShareNoticeConstants.swift */, + 7462BFCF2028C49800B552D8 /* ShareNoticeViewModel.swift */, + 7462BFD32028CD4400B552D8 /* ShareNoticeNavigationCoordinator.swift */, ); - name = Tracks; + name = Notices; sourceTree = ""; }; - 93E5283D19A7741A003A1A9C /* WordPressTodayWidget */ = { + 74729CA12056F9DB00D1394D /* Spotlight */ = { isa = PBXGroup; children = ( - 93E5284219A7741A003A1A9C /* MainInterface.storyboard */, - 93E5284019A7741A003A1A9C /* TodayViewController.swift */, - 93E5283E19A7741A003A1A9C /* Supporting Files */, - 93C2075B1CC7FFB100C94D04 /* Tracks */, - 93E5284F19A77824003A1A9C /* WordPressTodayWidget-Bridging-Header.h */, + 74729CA820570FE100D1394D /* Utils */, + 74729CA72056FE6500D1394D /* Protocols */, + 74729CA22056FA0900D1394D /* SearchManager.swift */, ); - path = WordPressTodayWidget; + path = Spotlight; sourceTree = ""; }; - 93E5283E19A7741A003A1A9C /* Supporting Files */ = { + 74729CA72056FE6500D1394D /* Protocols */ = { isa = PBXGroup; children = ( - 8546B44A1BEAD3B300193C07 /* Info-Alpha.plist */, - 93267A6019B896CD00997EB8 /* Info-Internal.plist */, - 93E5283F19A7741A003A1A9C /* Info.plist */, - E1B6A9CE1E54B6B2008FD47E /* Localizable.strings */, - 591252291A38AE9C00468279 /* TodayWidgetPrefix.pch */, - 8546B4501BEAD4D100193C07 /* WordPressTodayWidget-Alpha.entitlements */, - 934884AC19B78723004028D8 /* WordPressTodayWidget-Internal.entitlements */, - 93E5285719A7AA5C003A1A9C /* WordPressTodayWidget.entitlements */, + 74729CA52056FE6000D1394D /* SearchableItemConvertable.swift */, + 740516882087B73400252FD0 /* SearchableActivityConvertable.swift */, + 3F421DF424A3EC2B00CA9B9E /* Spotlightable.swift */, ); - name = "Supporting Files"; + name = Protocols; sourceTree = ""; }; - 93FA59DA18D88BDB001446BC /* Services */ = { + 74729CA820570FE100D1394D /* Utils */ = { isa = PBXGroup; children = ( - 2FA6511221F26949009AA935 /* SiteVerticalsPromptService.swift */, - B5EFB1C31B31B99D007608A3 /* Facades */, - 93C1147D18EC5DD500DAC95C /* AccountService.h */, - 93C1147E18EC5DD500DAC95C /* AccountService.m */, - E6C0ED3A231DA23400A08B57 /* AccountService+MergeDuplicates.swift */, - E1FD45DF1C030B3800750F4C /* AccountSettingsService.swift */, - 822D60B81F4CCC7A0016C46D /* BlogJetpackSettingsService.swift */, - 93C1148318EDF6E100DAC95C /* BlogService.h */, - 93C1148418EDF6E100DAC95C /* BlogService.m */, - 9A341E5221997A1E0036662E /* BlogService+BlogAuthors.swift */, - E18549D8230EED73003C620E /* BlogService+Deduplicate.swift */, - 9A2D0B22225CB92B009E585F /* BlogService+JetpackConvenience.swift */, - 7E7BEF7222E1DD27009A880D /* EditorSettingsService.swift */, - E1556CF0193F6FE900FC52EA /* CommentService.h */, - E1556CF1193F6FE900FC52EA /* CommentService.m */, - E16A76F21FC4766900A661E3 /* CredentialsService.swift */, - 1702BBDF1CF3034E00766A33 /* DomainsService.swift */, - B5772AC31C9C7A070031F97E /* GravatarService.swift */, - 9A2D0B35225E2511009E585F /* JetpackService.swift */, - 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */, - 930284B618EAF7B600CB0BF4 /* LocalCoreDataService.h */, - FFC6ADD91B56F366002F3C84 /* LocalCoreDataService.m */, - 98921EF821372E30004949AA /* LocalNewsService.swift */, - 98921EF621372E12004949AA /* MediaCoordinator.swift */, - 0815CF451E96F22600069916 /* MediaImportService.swift */, - 5DA3EE141925090A00294E0B /* MediaService.h */, - 5DA3EE151925090A00294E0B /* MediaService.m */, - FF5371621FDFF64F00619A3F /* MediaService.swift */, - FF8791BA1FBAF4B400AD86E6 /* MediaService+Swift.swift */, - E1C5457D1C6B962D001CEB0E /* MediaSettings.swift */, - FF4C069E206560E500E0B2BC /* MediaThumbnailCoordinator.swift */, - 087EBFA71F02313E001F7ACE /* MediaThumbnailService.swift */, - 08AAD69D1CBEA47D002B2418 /* MenusService.h */, - 08AAD69E1CBEA47D002B2418 /* MenusService.m */, - 98921EFA21372E57004949AA /* NewsService.swift */, - B5015C571D4FDBB300C9449E /* NotificationActionsService.swift */, - B5EFB1C11B31B98E007608A3 /* NotificationSettingsService.swift */, - 73ACDF982114FE4500233AD4 /* NotificationSupportService.swift */, - B5F67AC61DB7D81300482C62 /* NotificationSyncMediator.swift */, - 8BC6020823900D8400EFE3D0 /* NullBlogPropertySanitizer.swift */, - E1209FA31BB4978B00D69778 /* PeopleService.swift */, - E1D7FF371C74EB0E00E7E5E5 /* PlanService.swift */, - 93FA59DB18D88C1C001446BC /* PostCategoryService.h */, - 93FA59DC18D88C1C001446BC /* PostCategoryService.m */, - FF0D8145205809C8000EE505 /* PostCoordinator.swift */, - E1A6DBE319DC7D230071AC1E /* PostService.h */, - E1A6DBE419DC7D230071AC1E /* PostService.m */, - 8B6EA62223FDE50B004BA312 /* PostServiceUploadingList.swift */, - FF0F722B206E5345000DAAB5 /* PostService+RefreshStatus.swift */, - 2F08ECFB2283A4FB000F8E11 /* PostService+UnattachedMedia.swift */, - 9A4F8F55218B88E000EEDCCC /* PostService+Revisions.swift */, - 08472A1E1C7273FA0040769D /* PostServiceOptions.h */, - 08472A1F1C727E020040769D /* PostServiceOptions.m */, - 082AB9D71C4EEEF4000CA523 /* PostTagService.h */, - 082AB9D81C4EEEF4000CA523 /* PostTagService.m */, - B535209C1AF7EB9F00B33BA8 /* PushAuthenticationService.swift */, - 5D3D559518F88C3500782892 /* ReaderPostService.h */, - 5D3D559618F88C3500782892 /* ReaderPostService.m */, - E62079E01CF7A61200F5CD46 /* ReaderSearchSuggestionService.swift */, - 17CE77EC20C6C2F3001DEA5A /* ReaderSiteSearchService.swift */, - 5D44EB361986D8BA008B7175 /* ReaderSiteService.h */, - 5D44EB371986D8BA008B7175 /* ReaderSiteService.m */, - 5DBCD9D318F35D7500B32229 /* ReaderTopicService.h */, - 5DBCD9D418F35D7500B32229 /* ReaderTopicService.m */, - 9F3EFCA0208E305D00268758 /* ReaderTopicService+Subscriptions.swift */, - E102B78F1E714F24007928E8 /* RecentSitesService.swift */, - E1D28E921F2F6EB500A5DAFD /* RoleService.swift */, - 930F09161C7D110E00995926 /* ShareExtensionService.swift */, - E616E4B21C480896002C024E /* SharingService.swift */, - D82253DB2199411F0014D0E2 /* SiteAddressService.swift */, - 73178C3021BEE45300E37C9A /* SiteAssembly.swift */, - 73178C2E21BEE1F500E37C9A /* SiteAssemblyService.swift */, - FA4ADAD71C50687400F858D7 /* SiteManagementService.swift */, - D8CB561F2181A8CE00554EAE /* SiteSegmentsService.swift */, - D8A468E421828D940094B82F /* SiteVerticalsService.swift */, - 319D6E7C19E447C80013871C /* SuggestionService.h */, - 319D6E7D19E447C80013871C /* SuggestionService.m */, - 59A9AB331B4C33A500A433DC /* ThemeService.h */, - 59A9AB341B4C33A500A433DC /* ThemeService.m */, - 93DEB88019E5BF7100F9546D /* TodayExtensionService.h */, - 93DEB88119E5BF7100F9546D /* TodayExtensionService.m */, - E6311C401EC9FF4A00122529 /* UsersService.swift */, - B543D2B420570B5A00D3D4CC /* WordPressComSyncService.swift */, - 57C2331722FE0EC900A3863B /* PostAutoUploadInteractor.swift */, - 57D66B99234BB206005A2D74 /* PostServiceRemoteFactory.swift */, - 8BC12F732320181E004DDA72 /* PostService+MarkAsFailedAndDraftIfNeeded.swift */, + 74729CA92057100200D1394D /* SearchIdentifierGenerator.swift */, ); - path = Services; + name = Utils; sourceTree = ""; }; - 98077B58207555E400109F95 /* Support */ = { + 74FA2EE2200E8A1D001DDC13 /* Services */ = { isa = PBXGroup; children = ( - 98077B592075561800109F95 /* SupportTableViewController.swift */, + 74FA2EE3200E8A6C001DDC13 /* AppExtensionsService.swift */, ); - path = Support; + name = Services; sourceTree = ""; }; - 981676D3221B7A2C00B81C3F /* Countries */ = { + 74FA4BDB1FBF994F0031EAAD /* Data */ = { isa = PBXGroup; children = ( - 9A76C32D22AFD9EB00F5D819 /* Map */, - 981676D4221B7A4300B81C3F /* CountriesCell.swift */, - 981676D5221B7A4300B81C3F /* CountriesCell.xib */, + 741E22441FC0CC55007967AB /* UploadOperation.swift */, + 74AF4D711FE417D200E3EBFE /* PostUploadOperation.swift */, + 74AF4D6D1FE417D200E3EBFE /* MediaUploadOperation.swift */, + 746D6B241FBF701F003C45BE /* SharedCoreDataStack.swift */, + 740C7DB220386E5700FF0229 /* EXTENSION_MIGRATIONS.md */, + 74FA4BE31FBFA0660031EAAD /* Extensions.xcdatamodeld */, ); - path = Countries; + name = Data; sourceTree = ""; }; - 9826AE7F21B5C63800C851FA /* Latest Post Summary */ = { + 7E21C763202BBE9F00837CF5 /* iAds */ = { isa = PBXGroup; children = ( - 9826AE8021B5C6A700C851FA /* LatestPostSummaryCell.swift */, - 9826AE8121B5C6A700C851FA /* LatestPostSummaryCell.xib */, + 7E21C764202BBF4400837CF5 /* SearchAdsAttribution.swift */, ); - path = "Latest Post Summary"; + name = iAds; sourceTree = ""; }; - 9826AE8421B5C6D500C851FA /* Posting Activity */ = { + 7E3E7A5120E44B060075D159 /* FormattableContent */ = { isa = PBXGroup; children = ( - 9826AE8E21B5D3CD00C851FA /* PostingActivityCell.swift */, - 9826AE8F21B5D3CD00C851FA /* PostingActivityCell.xib */, - 981C986C21B9D71400A7C0C8 /* PostingActivityCollectionViewCell.swift */, - 9826AE8521B5C72300C851FA /* PostingActivityDay.swift */, - 9826AE8721B5C73400C851FA /* PostingActivityDay.xib */, - 9848DF8021B8BB5600B99DA4 /* PostingActivityLegend.swift */, - 9848DF8221B8BB6900B99DA4 /* PostingActivityLegend.xib */, - 9826AE8921B5CC7300C851FA /* PostingActivityMonth.swift */, - 9826AE8B21B5CC8D00C851FA /* PostingActivityMonth.xib */, - 981C986A21B9BF6000A7C0C8 /* PostingActivityViewController.storyboard */, - 981C986621B9BDF300A7C0C8 /* PostingActivityViewController.swift */, + 7EFF207F20EAC59D009C4699 /* Groups */, + 7E7947A7210BABF5005BB851 /* Ranges */, + 7E3E7A5E20E44E110075D159 /* Styles */, + CE46018A21139E8300F242B6 /* FooterTextContent.swift */, + 7EFF208B20EADF68009C4699 /* FormattableCommentContent.swift */, + 7EFF208520EAD918009C4699 /* FormattableUserContent.swift */, + 73BFDA89211D054800907245 /* Notifiable.swift */, + 7EB5824620EC41B200002702 /* NotificationContentFactory.swift */, + 7EFF208920EADCB6009C4699 /* NotificationTextContent.swift */, ); - path = "Posting Activity"; + path = FormattableContent; sourceTree = ""; }; - 98467A3B221CD30600DF51AE /* Stats Detail */ = { + 7E3E7A5E20E44E110075D159 /* Styles */ = { isa = PBXGroup; children = ( - 98467A42221CD74D00DF51AE /* SiteStatsDetailTableViewController.storyboard */, - 98467A3E221CD48500DF51AE /* SiteStatsDetailTableViewController.swift */, - 9829162E2224BC1C008736C0 /* SiteStatsDetailsViewModel.swift */, - 987535612282682D001661B4 /* DetailDataCell.swift */, - 987535622282682D001661B4 /* DetailDataCell.xib */, + 7E3E7A5C20E44DB00075D159 /* BadgeContentStyles.swift */, + 7E3E7A5820E44D2F0075D159 /* FooterContentStyles.swift */, + 7E3E7A5620E44D130075D159 /* HeaderContentStyles.swift */, + 2F09D133245223D300956257 /* HeaderDetailsContentStyles.swift */, + 7E3E7A5A20E44D950075D159 /* RichTextContentStyles.swift */, + 7E3E7A5420E44B4B0075D159 /* SnippetsContentStyles.swift */, + 7E3E7A5220E44B260075D159 /* SubjectContentStyles.swift */, ); - path = "Stats Detail"; + path = Styles; sourceTree = ""; }; - 984B138C21F65F680004B6A2 /* Period Stats */ = { + 7E3E9B6E2177C9C300FD5797 /* Gutenberg */ = { isa = PBXGroup; children = ( - 98797DB9222F431100128C21 /* Overview */, - 981676D3221B7A2C00B81C3F /* Countries */, - 984B138D21F65F860004B6A2 /* SiteStatsPeriodTableViewController.swift */, - 984B139121F66AC50004B6A2 /* SiteStatsPeriodViewModel.swift */, + B0637542253E7E7A00FD45D2 /* GutenbergSuggestionsViewController.swift */, + FF2EC3BE2209A105006176E1 /* Processors */, + 1ED046CE244F26B1008F6365 /* GutenbergWeb */, + 4631359224AD057E0017E65C /* Layout Picker */, + 1E5D00152493FC3A0004B708 /* Views */, + 7E407122237163C3003627FA /* Utils */, + 069A4AA52664448F00413FA9 /* GutenbergFeaturedImageHelper.swift */, + 7E3E9B6F2177C9DC00FD5797 /* GutenbergViewController.swift */, + 7E40716123741375003627FA /* GutenbergNetworking.swift */, + F10E654F21B06139007AB2EE /* GutenbergViewController+MoreActions.swift */, + 91D8364021946EFB008340B2 /* GutenbergMediaPickerHelper.swift */, + FF8C54AC21F677260003ABCF /* GutenbergMediaInserterHelper.swift */, + 7EA30DB421ADA20F0092F894 /* AztecAttachmentDelegate.swift */, + 7EA30DB321ADA20F0092F894 /* EditorMediaUtility.swift */, + 912347182213484300BD9F97 /* GutenbergViewController+InformativeDialog.swift */, + 912347752216E27200BD9F97 /* GutenbergViewController+Localization.swift */, + FFC02B82222687BF00E64FDE /* GutenbergImageLoader.swift */, + 4625B5472537875E00C04AAD /* Collapsable Header */, + C9F1D4B62706ED7C00BDF917 /* EditHomepageViewController.swift */, ); - path = "Period Stats"; + path = Gutenberg; sourceTree = ""; }; - 984B4EF120742FB900F87888 /* Zendesk */ = { + 7E407122237163C3003627FA /* Utils */ = { isa = PBXGroup; children = ( - 984B4EF220742FCC00F87888 /* ZendeskUtils.swift */, + 8B05D29223AA572A0063B9AA /* GutenbergMediaEditorImage.swift */, + 7E407120237163B8003627FA /* GutenbergStockPhotos.swift */, + 7E4071392372AD54003627FA /* GutenbergFilesAppMediaSource.swift */, + C856748E243EF177001A995E /* GutenbergTenorMediaPicker.swift */, ); - name = Zendesk; + path = Utils; sourceTree = ""; }; - 985793C522F23CD800643DBF /* Insights Management */ = { + 7E4123AB20F4096200DF8486 /* FormattableContent */ = { isa = PBXGroup; children = ( - 985793C622F23D7000643DBF /* CustomizeInsightsCell.swift */, - 985793C722F23D7000643DBF /* CustomizeInsightsCell.xib */, - 983002A722FA05D600F03DBB /* AddInsightTableViewController.swift */, - ); - path = "Insights Management"; - sourceTree = ""; + 7E7947A6210BAB9F005BB851 /* Actions */, + 7E4123B220F4097A00DF8486 /* FormattableContent.swift */, + 7E4123AC20F4097900DF8486 /* FormattableContentFactory.swift */, + 7E4123B520F4097B00DF8486 /* FormattableContentFormatter.swift */, + 7E4123AD20F4097900DF8486 /* FormattableContentGroup.swift */, + 7E4123B420F4097A00DF8486 /* FormattableContentRange.swift */, + 7E4123B620F4097B00DF8486 /* FormattableContentStyles.swift */, + 7E4123B020F4097A00DF8486 /* FormattableMediaContent.swift */, + 7E4123B320F4097A00DF8486 /* FormattableTextContent.swift */, + 7E53AB0120FE5EAE005796FE /* ContentRouter.swift */, + 7E929CD02110D4F200BCAD88 /* FormattableRangesFactory.swift */, + ); + path = FormattableContent; + sourceTree = ""; }; - 986C908222319ECF00FC31E1 /* Post Stats */ = { + 7E4123C620F4178E00DF8486 /* FormattableContent */ = { isa = PBXGroup; children = ( - 986C908522319F2600FC31E1 /* PostStatsTableViewController.storyboard */, - 986C908322319EFF00FC31E1 /* PostStatsTableViewController.swift */, - 986C90872231AD6200FC31E1 /* PostStatsViewModel.swift */, - 98FCFC212231DF43006ECDD4 /* PostStatsTitleCell.swift */, - 98FCFC222231DF43006ECDD4 /* PostStatsTitleCell.xib */, + 7E4123C720F417EF00DF8486 /* FormattableActivity.swift */, + 7E4123C920F4184200DF8486 /* ActivityContentGroup.swift */, + 7E4123CB20F418A500DF8486 /* ActivityActionsParser.swift */, + 7E3AB3DA20F52654001F33B6 /* ActivityContentStyles.swift */, + 7E53AAFF20FE55A9005796FE /* ActivityContentRouter.swift */, + 7E4A773D20F80625001C706D /* Factory */, + 7E4A773020F80063001C706D /* Ranges */, ); - path = "Post Stats"; + path = FormattableContent; sourceTree = ""; }; - 9872CB36203BB53F0066A293 /* Epilogues */ = { + 7E442FC520F677A300DEACA5 /* ActivityLog */ = { isa = PBXGroup; children = ( - 98579BC5203DD86D004086E4 /* EpilogueSectionHeaderFooter.swift */, - 98579BC6203DD86E004086E4 /* EpilogueSectionHeaderFooter.xib */, - 9808655B203D079A00D58786 /* EpilogueUserInfoCell.swift */, - 98086559203D075D00D58786 /* EpilogueUserInfoCell.xib */, - 98A25BD0203CB25F006A5807 /* SignupEpilogueCell.swift */, - 98A25BD2203CB278006A5807 /* SignupEpilogueCell.xib */, + 7E4A772E20F7FDF8001C706D /* ActivityLogTestData.swift */, + 7E442FC620F677CB00DEACA5 /* ActivityLogRangesTest.swift */, + 7E442FCE20F6C19000DEACA5 /* ActivityLogFormattableContentTests.swift */, + 7E53AB0320FE6681005796FE /* ActivityContentRouterTests.swift */, + 7E53AB0920FE83A9005796FE /* MockContentCoordinator.swift */, ); - name = Epilogues; + name = ActivityLog; sourceTree = ""; }; - 98747670219638990080967F /* Extensions */ = { + 7E442FC820F6783600DEACA5 /* ActivityLog */ = { isa = PBXGroup; children = ( - 981C82B52193A7B900A06E84 /* Double+Stats.swift */, - 986DD19B218D002500D28061 /* WPStyleGuide+Stats.swift */, - 98487E3921EE8FB500352B4E /* UITableViewCell+Stats.swift */, + 7E442FC920F678D100DEACA5 /* activity-log-pingback-content.json */, + 7E4A772020F7BBBD001C706D /* activity-log-post-content.json */, + 7E4A772220F7BE94001C706D /* activity-log-comment-content.json */, + 7E4A772420F7C5E5001C706D /* activity-log-theme-content.json */, + 7E4A772620F7CDD5001C706D /* activity-log-settings-content.json */, + 7E4A772A20F7E5FD001C706D /* activity-log-site-content.json */, + 7E4A772C20F7E8D8001C706D /* activity-log-plugin-content.json */, + 7E53AB0520FE6905005796FE /* activity-log-comment.json */, + 7E53AB0720FE6C9C005796FE /* activity-log-post.json */, + D821C818210037F8002ED995 /* activity-log-activity-content.json */, ); - path = Extensions; + name = ActivityLog; sourceTree = ""; }; - 98747671219638BF0080967F /* Insights */ = { + 7E4A773020F80063001C706D /* Ranges */ = { isa = PBXGroup; children = ( - 985793C522F23CD800643DBF /* Insights Management */, - 9826AE7F21B5C63800C851FA /* Latest Post Summary */, - 9826AE8421B5C6D500C851FA /* Posting Activity */, - 98880A4722B2E3FC00464538 /* Two Column Stats */, - 988056022183CCE50083B643 /* SiteStatsInsightsTableViewController.swift */, - 9865257C2194D77E0078B916 /* SiteStatsInsightsViewModel.swift */, - 98563DDB21BF30C40006F5E9 /* TabbedTotalsCell.swift */, - 98563DDC21BF30C40006F5E9 /* TabbedTotalsCell.xib */, + 7E442FCB20F6AACB00DEACA5 /* ActivityRange.swift */, + 7E4A773120F800CB001C706D /* ActivityPostRange.swift */, + 7E4A773320F800ED001C706D /* ActivityPluginRange.swift */, + 7E846FF220FD37BD00881F5A /* ActivityCommentRange.swift */, ); - path = Insights; + path = Ranges; sourceTree = ""; }; - 98747674219644E40080967F /* Helpers */ = { + 7E4A773D20F80625001C706D /* Factory */ = { isa = PBXGroup; children = ( - 730D290E22976F1A0004BB1E /* BottomScrollAnalyticsTracker.swift */, - 9874767221963D320080967F /* SiteStatsInformation.swift */, - 98B52AE021F7AF4A006FF6B4 /* StatsDataHelper.swift */, - 98CAD295221B4ED1003E8F45 /* StatSection.swift */, - 17D4153B22C2308D006378EF /* StatsPeriodHelper.swift */, + 7E4A773A20F8058F001C706D /* ActivityContentFactory.swift */, + 7E4A773520F80146001C706D /* ActivityRangesFactory.swift */, ); - path = Helpers; + path = Factory; sourceTree = ""; }; - 98797DB9222F431100128C21 /* Overview */ = { + 7E58879820FE8D8300DB6F80 /* Environment */ = { isa = PBXGroup; children = ( - 98797DBA222F434500128C21 /* OverviewCell.swift */, - 98797DBB222F434500128C21 /* OverviewCell.xib */, + 7E58879D20FE954600DB6F80 /* Protocols */, + 7E58879920FE8D9300DB6F80 /* Environment.swift */, ); - path = Overview; + path = Environment; sourceTree = ""; }; - 98880A4722B2E3FC00464538 /* Two Column Stats */ = { + 7E58879D20FE954600DB6F80 /* Protocols */ = { isa = PBXGroup; children = ( - 98D52C3022B1CFEB00831529 /* StatsTwoColumnRow.swift */, - 98D52C3122B1CFEC00831529 /* StatsTwoColumnRow.xib */, - 98880A4822B2E5E400464538 /* TwoColumnCell.swift */, - 98880A4922B2E5E400464538 /* TwoColumnCell.xib */, + 7E58879F20FE956100DB6F80 /* AppRatingUtilityType.swift */, ); - path = "Two Column Stats"; + path = Protocols; sourceTree = ""; }; - 989064F9237CC1A300218CD2 /* Data */ = { + 7E7947A6210BAB9F005BB851 /* Actions */ = { isa = PBXGroup; children = ( - 98BFF57D23984344008A1DCB /* AllTimeWidgetStats.swift */, - 98F93181239AF64800E4E96E /* ThisWeekWidgetStats.swift */, - 98E58A2E2360D23400E5534B /* TodayWidgetStats.swift */, + 7E4123B820F4097B00DF8486 /* FormattableContentAction.swift */, + 7E4123B720F4097B00DF8486 /* FormattableContentActionCommand.swift */, + 7E4123AF20F4097A00DF8486 /* DefaultFormattableContentAction.swift */, ); - path = Data; + path = Actions; sourceTree = ""; }; - 9890650C237CC1E700218CD2 /* Shared Views */ = { + 7E7947A7210BABF5005BB851 /* Ranges */ = { isa = PBXGroup; children = ( - 989643EA23A0437B0070720A /* WidgetDifferenceCell.swift */, - 989643EB23A0437B0070720A /* WidgetDifferenceCell.xib */, - 989064FD237CC1DE00218CD2 /* WidgetTwoColumnCell.swift */, - 989064FE237CC1DE00218CD2 /* WidgetTwoColumnCell.xib */, - 989064FB237CC1DE00218CD2 /* WidgetUnconfiguredCell.swift */, - 989064FC237CC1DE00218CD2 /* WidgetUnconfiguredCell.xib */, - 988F073323D0CE8800AC67A6 /* WidgetUrlCell.swift */, - 988F073423D0CE8800AC67A6 /* WidgetUrlCell.xib */, - 98712D1923DA1C7E00555316 /* WidgetNoConnectionCell.swift */, - 98712D1A23DA1C7E00555316 /* WidgetNoConnectionCell.xib */, + 7E7947AC210BAC7B005BB851 /* FormattableNoticonRange.swift */, + 7E7947AA210BAC5E005BB851 /* NotificationCommentRange.swift */, + 7E7947A8210BAC1D005BB851 /* NotificationContentRange.swift */, + 7E4123B120F4097A00DF8486 /* NotificationContentRangeFactory.swift */, ); - path = "Shared Views"; + path = Ranges; sourceTree = ""; }; - 98A3C2F1239997DA0048D38D /* WordPressThisWeekWidget */ = { + 7E8980CB22E8C81B00C567B0 /* 87-88 */ = { isa = PBXGroup; children = ( - 98A3C3002399A1850048D38D /* MainInterface.storyboard */, - 98A3C3042399A2370048D38D /* ThisWeekViewController.swift */, - 98A3C3032399A2370048D38D /* WordPressThisWeekWidget-Bridging-Header.h */, - 98A3C3062399A3040048D38D /* Supporting Files */, - 98E419DC2399B57F00D8C822 /* Tracks */, + 7E8980C922E8C7A600C567B0 /* BlogToBlogMigration87to88.swift */, ); - path = WordPressThisWeekWidget; + name = "87-88"; sourceTree = ""; }; - 98A3C3062399A3040048D38D /* Supporting Files */ = { + 7EAA66ED22CB36DA00869038 /* Utils */ = { isa = PBXGroup; children = ( - 98A3C3072399A57D0048D38D /* Info-Alpha.plist */, - 98A3C3082399A57D0048D38D /* Info-Internal.plist */, - 98A3C2F7239997DA0048D38D /* Info.plist */, - 983AE84F2399B19200E5B7F6 /* Localizable.strings */, - 98E419E02399B6C300D8C822 /* ThisWeekWidgetPrefix.pch */, - 98A3C30C2399A6570048D38D /* WordPressThisWeekWidget-Alpha.entitlements */, - 98A3C30D2399A6580048D38D /* WordPressThisWeekWidget-Internal.entitlements */, - 98A3C30B2399A6570048D38D /* WordPressThisWeekWidget.entitlements */, + 7EAA66EE22CB36FD00869038 /* TestAnalyticsTracker.swift */, ); - name = "Supporting Files"; + path = Utils; sourceTree = ""; }; - 98C43E841FE98041006FEF54 /* Social Signup */ = { + 7EC9FE0822C6275900C5A888 /* Analytics */ = { isa = PBXGroup; children = ( - 98656BD72037A1770079DE67 /* SignupEpilogueViewController.swift */, - 9872CB2F203B8A730066A293 /* SignupEpilogueTableViewController.swift */, - 4322A20C203E1885004EA740 /* SignupUsernameTableViewController.swift */, - 43DC0EF02040B23200896C9C /* SignupUsernameViewController.swift */, + 7EAA66ED22CB36DA00869038 /* Utils */, + 7EC9FE0922C6276B00C5A888 /* EditorAnalytics */, ); - name = "Social Signup"; + path = Analytics; sourceTree = ""; }; - 98D31B902396ED7F009CFF43 /* WordPressAllTimeWidget */ = { + 7EC9FE0922C6276B00C5A888 /* EditorAnalytics */ = { isa = PBXGroup; children = ( - 98D31BA52396F7BF009CFF43 /* AllTimeViewController.swift */, - 98DE9A9E239835C800A88D01 /* MainInterface.storyboard */, - 98D31BA82396FAD2009CFF43 /* WordPressAllTimeWidget-Bridging-Header.h */, - 98D31B9F2396EFAD009CFF43 /* Supporting Files */, - 98D31BAA23970060009CFF43 /* Tracks */, + 7EC9FE0A22C627DB00C5A888 /* PostEditorAnalyticsSessionTests.swift */, ); - path = WordPressAllTimeWidget; + path = EditorAnalytics; sourceTree = ""; }; - 98D31B9F2396EFAD009CFF43 /* Supporting Files */ = { + 7EFF207F20EAC59D009C4699 /* Groups */ = { isa = PBXGroup; children = ( - 98D31BA92396FBDC009CFF43 /* AllTimeWidgetPrefix.pch */, - 98D31BA02396F417009CFF43 /* Info-Alpha.plist */, - 98D31BA12396F417009CFF43 /* Info-Internal.plist */, - 98D31B962396ED7F009CFF43 /* Info.plist */, - 98D31BBB23971F4B009CFF43 /* Localizable.strings */, - 98D31BA32396F5C1009CFF43 /* WordPressAllTimeWidget-Alpha.entitlements */, - 98D31BA42396F5C2009CFF43 /* WordPressAllTimeWidget-Internal.entitlements */, - 98D31BA22396F5C1009CFF43 /* WordPressAllTimeWidget.entitlements */, + 7E3E7A6120E44E6A0075D159 /* BodyContentGroup.swift */, + 7E3E7A5F20E44E490075D159 /* FooterContentGroup.swift */, + 7E3E7A6520E44F200075D159 /* HeaderContentGroup.swift */, + 7E3E7A6320E44ED60075D159 /* SubjectContentGroup.swift */, ); - name = "Supporting Files"; + path = Groups; sourceTree = ""; }; - 98D31BAA23970060009CFF43 /* Tracks */ = { + 800035BF291DDF57007D2D26 /* Fullscreen Overlay */ = { isa = PBXGroup; children = ( - 98D31BAB23970078009CFF43 /* Tracks+AllTimeWidget.swift */, + 801D94EE2919E7D70051993E /* JetpackFullscreenOverlayGeneralViewModel.swift */, + 800035BC291DD0D7007D2D26 /* JetpackFullscreenOverlayGeneralViewModel+Analytics.swift */, + 8000361C292468D4007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel.swift */, + 8000361F29246956007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift */, + 8091019129078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.swift */, + 8091019229078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.xib */, + 809101972908DE8500FCB4EA /* JetpackFullscreenOverlayViewModel.swift */, + 805CC0C0296CF3B3002941DC /* JetpackNewUsersOverlaySecondaryView.swift */, + 08A7343E298AB68000F925C7 /* JetpackPluginOverlayViewModel.swift */, ); - name = Tracks; + path = "Fullscreen Overlay"; sourceTree = ""; }; - 98E419DC2399B57F00D8C822 /* Tracks */ = { + 801D94F4291AB36A0051993E /* Lottie Animations */ = { isa = PBXGroup; children = ( - 98E419DD2399B5A700D8C822 /* Tracks+ThisWeekWidget.swift */, + 08BA4BC6298A9AD400015BD2 /* JetpackInstallPluginLogoAnimation_ltr.json */, + 08BA4BC5298A9AD400015BD2 /* JetpackInstallPluginLogoAnimation_rtl.json */, + 801D950C291AB3CF0051993E /* JetpackNotificationsLogoAnimation_ltr.json */, + 801D950B291AB3CE0051993E /* JetpackNotificationsLogoAnimation_rtl.json */, + 801D9507291AB3CD0051993E /* JetpackReaderLogoAnimation_ltr.json */, + 801D9508291AB3CD0051993E /* JetpackReaderLogoAnimation_rtl.json */, + 801D9509291AB3CE0051993E /* JetpackStatsLogoAnimation_ltr.json */, + 801D950A291AB3CE0051993E /* JetpackStatsLogoAnimation_rtl.json */, + 3F4EB39128AC561600B8DD86 /* JetpackWordPressLogoAnimation_rtl.json */, + 3F4D035228A5BFCE00F0A4FD /* JetpackWordPressLogoAnimation_ltr.json */, + 80535DBA294ABBEF00873161 /* JetpackAllFeaturesLogosAnimation_ltr.json */, + 80535DB9294ABBEF00873161 /* JetpackAllFeaturesLogosAnimation_rtl.json */, ); - name = Tracks; + name = "Lottie Animations"; sourceTree = ""; }; - 98E58A2D2360D1FD00E5534B /* Today Widgets */ = { + 803BB97E295957A200B3F6D6 /* Root View */ = { isa = PBXGroup; children = ( - 989064F9237CC1A300218CD2 /* Data */, - 9890650C237CC1E700218CD2 /* Shared Views */, - 985ED0E323C6950600B8D06A /* WidgetStyles.swift */, + 803BB9782959543D00B3F6D6 /* RootViewCoordinator.swift */, + 803BB97B2959559500B3F6D6 /* RootViewPresenter.swift */, + 803BB985295A873800B3F6D6 /* RootViewPresenter+MeNavigation.swift */, + 803BB988295B80D300B3F6D6 /* RootViewPresenter+EditorNavigation.swift */, + 803BB97F295957CF00B3F6D6 /* WPTabBarController+RootViewPresenter.swift */, + 803BB982295957F600B3F6D6 /* MySitesCoordinator+RootViewPresenter.swift */, + 80A2153C29C35197002FE8EB /* StaticScreensTabBarWrapper.swift */, ); - path = "Today Widgets"; + name = "Root View"; sourceTree = ""; }; - 98F537A522496C7300B334F9 /* Date Chooser */ = { + 803DE81D29063689007D4E9C /* Jetpack */ = { isa = PBXGroup; children = ( - 98F537A622496CF300B334F9 /* SiteStatsTableHeaderView.swift */, - 98F537A822496D0D00B334F9 /* SiteStatsTableHeaderView.xib */, + FE7FAAB9299A35D40032A6F2 /* Install */, + 084FC3B529913B0A00A17BCF /* JetpackOverlay */, + 8332DD2629259B9700802F7D /* Utility */, + 803DE81E290636A4007D4E9C /* JetpackFeaturesRemovalCoordinatorTests.swift */, + 801D951C291ADB7E0051993E /* OverlayFrequencyTrackerTests.swift */, + 80535DC4294BF4BE00873161 /* JetpackBrandingMenuCardPresenterTests.swift */, + 803BB99329667CF700B3F6D6 /* JetpackBrandingTextProviderTests.swift */, ); - path = "Date Chooser"; + name = Jetpack; sourceTree = ""; }; - 98F89D47219E008600190EE6 /* Shared Views */ = { + 80535DB62946C74B00873161 /* Menu Card */ = { isa = PBXGroup; children = ( - 9A09F916230C489600F42AB7 /* GhostViews */, - 98F537A522496C7300B334F9 /* Date Chooser */, - 986C908222319ECF00FC31E1 /* Post Stats */, - 98467A3B221CD30600DF51AE /* Stats Detail */, - 9A9E3FA2230D5F0A00909BC4 /* StatsStackViewCell.swift */, - 9A09F91A230C49FD00F42AB7 /* StatsStackViewCell.xib */, - 9881296C219CF1300075FF33 /* StatsCellHeader.swift */, - 9881296D219CF1310075FF33 /* StatsCellHeader.xib */, - 98B11B882216535100B7F2D7 /* StatsChildRowsView.swift */, - 98B11B8A2216536C00B7F2D7 /* StatsChildRowsView.xib */, - 98458CB721A39D350025D232 /* StatsNoDataRow.swift */, - 98458CB921A39D7A0025D232 /* StatsNoDataRow.xib */, - 983DBBA922125DD300753988 /* StatsTableFooter.swift */, - 983DBBA822125DD300753988 /* StatsTableFooter.xib */, - 98812964219CE42A0075FF33 /* StatsTotalRow.swift */, - 98812965219CE42A0075FF33 /* StatsTotalRow.xib */, - 984F86FA21DEDB060070E0E3 /* TopTotalsCell.swift */, - 9895B6DF21ED49160053D370 /* TopTotalsCell.xib */, - 98B3FA8321C05BDC00148DD4 /* ViewMoreRow.swift */, - 98B3FA8521C05BF000148DD4 /* ViewMoreRow.xib */, + 80535DB72946C79700873161 /* JetpackBrandingMenuCardCell.swift */, + 80535DBD294AC89200873161 /* JetpackBrandingMenuCardPresenter.swift */, + 80535DBF294B7D3200873161 /* BlogDetailsViewController+JetpackBrandingMenuCard.swift */, ); - path = "Shared Views"; + path = "Menu Card"; sourceTree = ""; }; - 9A09F916230C489600F42AB7 /* GhostViews */ = { + 8096212828E5535E00940A5D /* JetpackShareExtension */ = { isa = PBXGroup; children = ( - 9A09F91D230C4C0200F42AB7 /* StatsGhostTableViewRows.swift */, - 9A9E3FAB230E9DD000909BC4 /* StatsGhostCells.swift */, - 9A9E3FB3230EC4F700909BC4 /* StatsGhostPostingActivityCell.xib */, - 9A9E3FB1230EB74300909BC4 /* StatsGhostTabbedCell.xib */, - 9A9E3FAF230EA7A300909BC4 /* StatsGhostTopCell.xib */, - 9A19D440236C7C7500D393E5 /* StatsGhostTopHeaderCell.xib */, - 9A9E3FAC230E9DD000909BC4 /* StatsGhostTwoColumnCell.xib */, - 9AC3C69A231543C2007933CD /* StatsGhostChartCell.xib */, - 9A73CB072350DE4C002EF20C /* StatsGhostSingleRowCell.xib */, - 9AB36B83236B25D900FAD72A /* StatsGhostTitleCell.xib */, + 8096212928E553A500940A5D /* Info-Alpha.plist */, + 8096212B28E553A500940A5D /* Info-Internal.plist */, + 8096212A28E553A500940A5D /* Info.plist */, ); - path = GhostViews; + path = JetpackShareExtension; sourceTree = ""; }; - 9A162F2621C2713B00FDC035 /* Diffs */ = { + 8096218728E55CB800940A5D /* JetpackDraftActionExtension */ = { isa = PBXGroup; children = ( - 9A4697B121B002AD00468B64 /* RevisionDiffsPageManager.swift */, - 439F4F332196537500F8D0C7 /* RevisionDiffViewController.swift */, + 8096218A28E55D2400940A5D /* Info-Alpha.plist */, + 8096218928E55D2400940A5D /* Info-Internal.plist */, + 8096218828E55D2400940A5D /* Info.plist */, + 8058730D28F7B70B00340C11 /* InfoPlist.strings */, ); - path = Diffs; + path = JetpackDraftActionExtension; sourceTree = ""; }; - 9A162F2721C2715100FDC035 /* Preview */ = { + 80C5239F2995977100B1C14B /* Blaze */ = { isa = PBXGroup; children = ( - 9A162F2A21C2A21A00FDC035 /* RevisionPreviewTextViewManager.swift */, - 9A162F2221C26D7500FDC035 /* RevisionPreviewViewController.swift */, + FAC1B82329B1F0E800E0C542 /* Overlay */, + FAC1B82429B1F13400E0C542 /* Webview */, + FAC1B82529B1F14D00E0C542 /* Helpers */, ); - path = Preview; + path = Blaze; sourceTree = ""; }; - 9A22D9BE214A6B9800BAEAF2 /* Utils */ = { + 80C523A929AE6BF300B1C14B /* Blaze */ = { isa = PBXGroup; children = ( - 9A22D9BF214A6BCA00BAEAF2 /* PageListTableViewHandler.swift */, - F16C35D523F33DE400C81331 /* PageAutoUploadMessageProvider.swift */, + 80C523AA29AE6C2200B1C14B /* BlazeWebViewModelTests.swift */, ); - name = Utils; + path = Blaze; sourceTree = ""; }; - 9A2B28EC2191B4E600458F2A /* Footer */ = { + 80D9CFF229DCA51C00FE3400 /* Pages */ = { isa = PBXGroup; children = ( - 9A2B28ED2191B50500458F2A /* RevisionsTableViewFooter.swift */, + 80D9CFF329DCA53E00FE3400 /* DashboardPagesListCardCell.swift */, + 80D9CFF629E5010300FE3400 /* PagesCardViewModel.swift */, + 80D9CFFC29E711E200FE3400 /* DashboardPageCell.swift */, + 80D9D00229EF4C7F00FE3400 /* DashboardPageCreationCell.swift */, ); - path = Footer; + path = Pages; sourceTree = ""; }; - 9A2B28F2219211B900458F2A /* Views */ = { + 80D9D04429F7609F00FE3400 /* Generics */ = { isa = PBXGroup; children = ( - 9A2B28F3219211E700458F2A /* Operation */, - 9A2B28EC2191B4E600458F2A /* Footer */, - 9A5CF43E218C54A60060F81B /* Cell */, + 80D9D04529F760C400FE3400 /* FailableDecodable.swift */, ); - path = Views; + path = Generics; sourceTree = ""; }; - 9A2B28F3219211E700458F2A /* Operation */ = { + 80EF9288280D27F20064A971 /* PropertyWrappers */ = { isa = PBXGroup; children = ( - 9A2B28F42192121400458F2A /* RevisionOperation.swift */, - 9A2B28F62192121F00458F2A /* RevisionOperation.xib */, - 4353BFA8219E0E820009CED3 /* RevisionOperationViewController.swift */, + 80EF9289280D28140064A971 /* Atomic.swift */, ); - path = Operation; + path = PropertyWrappers; sourceTree = ""; }; - 9A35B08D225F9E2200A293E0 /* State */ = { + 80F6D05628EE869900953C1A /* JetpackNotificationServiceExtension */ = { isa = PBXGroup; children = ( - 9A8ECE0A2254A3260043C8DA /* JetpackRemoteInstallState.swift */, + 80F6D05728EE86F800953C1A /* Info-Alpha.plist */, + 80F6D05928EE86F800953C1A /* Info-Internal.plist */, + 80F6D05828EE86F800953C1A /* Info.plist */, ); - path = State; + path = JetpackNotificationServiceExtension; sourceTree = ""; }; - 9A38DC63218899E4006A409B /* Revisions */ = { + 8298F38D1EEF2B15008EB7F0 /* Ratings */ = { isa = PBXGroup; children = ( - 9A38DC64218899FA006A409B /* Revision.swift */, - 9A38DC67218899FB006A409B /* RevisionDiff.swift */, - 9A4E61F721A2C3BC0017A925 /* RevisionDiff+CoreData.swift */, - 9A38DC68218899FB006A409B /* DiffAbstractValue.swift */, - 9AF9551721A1D7970057827C /* DiffAbstractValue+Attributes.swift */, - 9A38DC66218899FB006A409B /* DiffContentValue.swift */, - 9A38DC65218899FA006A409B /* DiffTitleValue.swift */, + 8298F38E1EEF2B15008EB7F0 /* AppFeedbackPromptView.swift */, ); - name = Revisions; + path = Ratings; sourceTree = ""; }; - 9A4E215D21F87A8500EFF212 /* Cells */ = { + 82FC61181FA8ADAC00A1757E /* Activity */ = { isa = PBXGroup; children = ( - 9A4E215921F7565A00EFF212 /* QuickStartChecklistCell.xib */, - 4395A15C2106718900844E8E /* QuickStartChecklistCell.swift */, - 43DDFE8F21785EAC008BE72F /* QuickStartCongratulationsCell.xib */, - 43DDFE912178635D008BE72F /* QuickStartCongratulationsCell.swift */, - 43134378217954F100DA2176 /* QuickStartSkipAllCell.xib */, - 4313437A217956DB00DA2176 /* QuickStartSkipAllCell.swift */, + 8B36256425A60CAB00D7CCE3 /* Backup */, + 8B93412D257029E30097D0AC /* Filter */, + 7E4123C620F4178E00DF8486 /* FormattableContent */, + 82FC612B1FA8B7FC00A1757E /* ActivityListRow.swift */, + 403F57BB20E5CA6A004E889A /* RewindStatusRow.swift */, + 82A062DD2017BCBA0084CE7C /* ActivityListSectionHeaderView.swift */, + 82A062DB2017BC220084CE7C /* ActivityListSectionHeaderView.xib */, + 82FC611D1FA8ADAC00A1757E /* BaseActivityListViewController.swift */, + 8B1E62D525758AAF009A0F80 /* ActivityTypeSelectorViewController.swift */, + 8B69F19E255D67E7006B1CEF /* CalendarViewController.swift */, + 82FC61291FA8B6F000A1757E /* ActivityListViewModel.swift */, + 40FC6B7E2072E3EB00B9A1CD /* ActivityDetailViewController.swift */, + 82FC611C1FA8ADAC00A1757E /* ActivityTableViewCell.swift */, + 82FC611E1FA8ADAC00A1757E /* ActivityTableViewCell.xib */, + 825327571FBF7CD600B8B7D2 /* ActivityUtils.swift */, + 82B67B351FC726CD006FB593 /* Memoize.swift */, + 82FC611F1FA8ADAC00A1757E /* WPStyleGuide+Activity.swift */, + 4070D75D20E6B4E4007CEBDA /* ActivityDateFormatting.swift */, + 4019B27020885AB900A0C7EB /* ActivityDetailViewController.storyboard */, + 4070D75B20E5F55A007CEBDA /* RewindStatusTableViewCell.xib */, + 8B7C97E225A8BFA2004A3373 /* JetpackActivityLogViewController.swift */, + 8B74A9A7268E3C68003511CE /* RewindStatus+multiSite.swift */, ); - name = Cells; + path = Activity; sourceTree = ""; }; - 9A4E215E21F87AC300EFF212 /* Views */ = { + 8320B5CF11FCA3EA00607422 /* Cells */ = { isa = PBXGroup; children = ( - 43290D09215E8B1200F6B398 /* QuickStartSpotlightView.swift */, - 9A4E215F21F87AE200EFF212 /* QuickStartChecklistHeader.swift */, - 9A4E216121F87AF300EFF212 /* QuickStartChecklistHeader.xib */, + 98830A912747043B0061A87C /* BorderedButtonTableViewCell.swift */, + 9F74696A209EFD0C0074D52B /* CheckmarkTableViewCell.swift */, + 4034FDE92007C42400153B87 /* ExpandableCell.swift */, + 4034FDED2007D4F700153B87 /* ExpandableCell.xib */, + 982D99FC26F922C100AA794C /* InlineEditableMultiLineCell.swift */, + 982D99FD26F922C100AA794C /* InlineEditableMultiLineCell.xib */, + 43D74ACF20F906EE004AD934 /* InlineEditableNameValueCell.swift */, + 43D74ACD20F906DD004AD934 /* InlineEditableNameValueCell.xib */, + 98BAA7C026F925F70073A2F9 /* InlineEditableSingleLineCell.swift */, + 98BAA7BF26F925F60073A2F9 /* InlineEditableSingleLineCell.xib */, + 1730D4A21E97E3E400326B7C /* MediaItemTableViewCells.swift */, + FF00889E204E01AE007CCE66 /* MediaQuotaCell.swift */, + FF00889C204DFF77007CCE66 /* MediaQuotaCell.xib */, + E14977171C0DC0770057CD60 /* MediaSizeSliderCell.swift */, + E14977191C0DCB6F0057CD60 /* MediaSizeSliderCell.xib */, + B5CABB161C0E382C0050AB9F /* PickerTableViewCell.swift */, + 5D839AA6187F0D6B00811F4A /* PostFeaturedImageCell.h */, + 5D839AA7187F0D6B00811F4A /* PostFeaturedImageCell.m */, + B56A70171B5040B9001D5815 /* SwitchTableViewCell.swift */, + E1BB92311FDAAFFA00F2D817 /* TextWithAccessoryButtonCell.swift */, + 40640045200ED30300106789 /* TextWithAccessoryButtonCell.xib */, + E66E2A671FE432B900788F22 /* TitleBadgeDisclosureCell.swift */, + E66E2A681FE432BB00788F22 /* TitleBadgeDisclosureCell.xib */, + 30EABE0718A5903400B73A9C /* WPBlogTableViewCell.h */, + 30EABE0818A5903400B73A9C /* WPBlogTableViewCell.m */, + FF0AAE081A1509C50089841D /* WPProgressTableViewCell.h */, + FF0AAE091A150A560089841D /* WPProgressTableViewCell.m */, + 74558368201A1FD3007809BB /* WPReusableTableViewCells.swift */, + 8370D10811FA499A009D650F /* WPTableViewActivityCell.h */, + 8370D10911FA499A009D650F /* WPTableViewActivityCell.m */, + 5D6C4AF51B603CA3005E3C43 /* WPTableViewActivityCell.xib */, ); - name = Views; + path = Cells; sourceTree = ""; }; - 9A4E271822EF0C57001F6A6B /* View Model */ = { + 8332DD2229259ABA00802F7D /* Utility */ = { isa = PBXGroup; children = ( - 9A4E271922EF0C78001F6A6B /* ChangeUsernameViewModel.swift */, + 8332DD2329259AE300802F7D /* DataMigrator.swift */, + FED65D78293511E4008071BF /* SharedDataIssueSolver.swift */, ); - path = "View Model"; + path = Utility; sourceTree = ""; }; - 9A4E61FC21A2CC4C0017A925 /* Browser */ = { + 8332DD2629259B9700802F7D /* Utility */ = { isa = PBXGroup; children = ( - 439F4F37219B636500F8D0C7 /* Revisions.storyboard */, - 439F4F39219B715300F8D0C7 /* RevisionsNavigationController.swift */, - 9A162F2821C271D300FDC035 /* RevisionBrowserState.swift */, - 439F4F3B219B78B500F8D0C7 /* RevisionDiffsBrowserViewController.swift */, - 9A162F2621C2713B00FDC035 /* Diffs */, - 9A162F2721C2715100FDC035 /* Preview */, + 8332DD2729259BEB00802F7D /* DataMigratorTests.swift */, + 83EF3D7C2937E969000AF9BF /* SharedDataIssueSolverTests.swift */, ); - path = Browser; + name = Utility; sourceTree = ""; }; - 9A51DA2422EB23F5005CC335 /* Change Username */ = { + 850BD4531922F95C0032F3AD /* Networking */ = { isa = PBXGroup; children = ( - 9A51DA1322E9E8C7005CC335 /* ChangeUsernameViewController.swift */, - 9A4E271822EF0C57001F6A6B /* View Model */, + F1450CF42437E1A600A28BFE /* MediaHost.swift */, + F11C9F75243B3C5E00921DDC /* MediaHost+AbstractPost.swift */, + F11C9F73243B3C3E00921DDC /* MediaHost+Blog.swift */, + F11C9F77243B3C9600921DDC /* MediaHost+ReaderPostContentProvider.swift */, + F1450CF22437DA3E00A28BFE /* MediaRequestAuthenticator.swift */, + E11DA4921E03E03F00CF07A8 /* Pinghub.swift */, + E1C2260623901AAD0021D03C /* WordPressOrgRestApi+WordPress.swift */, ); - path = "Change Username"; + path = Networking; sourceTree = ""; }; - 9A5CF43E218C54A60060F81B /* Cell */ = { + 850D22B21729EE8600EC6A16 /* NUX */ = { isa = PBXGroup; children = ( - 4349B0AD218A477F0034118A /* RevisionsTableViewCell.swift */, - 4349B0AE218A477F0034118A /* RevisionsTableViewCell.xib */, + 3249615523F2013B004C7733 /* Post Signup Interstitial */, + B51AD77A2056C31100A6C545 /* LoginEpilogue.storyboard */, + B59F34A0207678480069992D /* SignupEpilogue.storyboard */, + E6417B961CA07B060084050A /* Controllers */, + E6417B9A1CA07C0A0084050A /* Helpers */, + E6417B951CA07AFE0084050A /* Views */, ); - path = Cell; + path = NUX; sourceTree = ""; }; - 9A76C32D22AFD9EB00F5D819 /* Map */ = { + 8511CFB71C607A7000B7CEED /* WordPressScreenshotGeneration */ = { isa = PBXGroup; children = ( - 9A1A67A522B2AD4E00FF8422 /* CountriesMap.swift */, - 9A76C32E22AFDA2100F5D819 /* world-map.svg */, - 9A3BDA0D22944F3500FBF510 /* CountriesMapView.swift */, - 9A3BDA0F22944F4D00FBF510 /* CountriesMapView.xib */, - 9A5C854622B3E42800BEE7A3 /* CountriesMapCell.swift */, - 9A5C854722B3E42800BEE7A3 /* CountriesMapCell.xib */, + 8511CFBA1C607A7000B7CEED /* Info.plist */, + 8511CFC41C60884400B7CEED /* SnapshotHelper.swift */, + 8511CFC61C60894200B7CEED /* WordPressScreenshotGeneration.swift */, + F9463A7221C05EE90081F11E /* ScreenshotCredentials.swift */, ); - path = Map; + path = WordPressScreenshotGeneration; sourceTree = ""; }; - 9A8ECE002254A3250043C8DA /* Login */ = { + 852416CC1A12EAF70030700C /* Ratings */ = { isa = PBXGroup; children = ( - 9A8ECE012254A3250043C8DA /* JetpackLoginViewController.swift */, - 9A8ECE022254A3250043C8DA /* JetpackLoginViewController.xib */, + E14A52361E39F43E00EE203E /* AppRatingsUtility.swift */, ); - path = Login; + path = Ratings; sourceTree = ""; }; - 9A8ECE032254A3260043C8DA /* Install */ = { + 852416D01A12ED2D0030700C /* Utility */ = { isa = PBXGroup; children = ( - 9A8ECE062254A3260043C8DA /* JetpackRemoteInstallViewController.swift */, - 9A8ECE1F22550A210043C8DA /* Error */, - 9A8ECE1A2254ADFA0043C8DA /* View */, - 9A8ECE082254A3260043C8DA /* ViewModel */, - 9A8ECE042254A3260043C8DA /* Webview */, + 17AF92241C46634000A99CFB /* BlogSiteVisibilityHelperTest.m */, + 93A379EB19FFBF7900415023 /* KeychainTest.m */, + 5948AD101AB73D19006E8882 /* WPAppAnalyticsTests.m */, + E1E4CE0C177439D100430844 /* WPAvatarSourceTest.m */, + 5981FE041AB8A89A0009E080 /* WPUserAgentTests.m */, + 82301B8E1E787420009C9C4E /* AppRatingUtilityTests.swift */, + F551E7F623FC9A5C00751212 /* Collection+RotateTests.swift */, + E180BD4B1FB462FF00D0D781 /* CookieJarTests.swift */, + 4A266B90282B13A70089CF3D /* CoreDataTestCase.swift */, + E1AB5A391E0C464700574B4E /* DelayTests.swift */, + 173D82E6238EE2A7008432DA /* FeatureFlagTests.swift */, + E1EBC3721C118ED200F638E0 /* ImmuTableTest.swift */, + 4A266B8E282B05210089CF3D /* JSONObjectTests.swift */, + 8384C64328AAC85F00EABE26 /* KeychainUtilsTests.swift */, + 179501CC27A01D4100882787 /* PublicizeAuthorizationURLComponentsTests.swift */, + 1759F1711FE017F20003EC81 /* QueueTests.swift */, + 80EF92922810FA5A0064A971 /* QuickStartFactoryTests.swift */, + FAE8EE9B273AD0A800A65307 /* QuickStartSettingsTests.swift */, + 803DE81828FFB7B5007D4E9C /* RemoteParameterTests.swift */, + 24B1AE3024FEC79900B9F334 /* RemoteFeatureFlagTests.swift */, + F4EF4BAA291D3D4700147B61 /* SiteIconTests.swift */, + F565190223CF6D1D003FACAF /* WKCookieJarTests.swift */, + 1ABA150722AE5F870039311A /* WordPressUIBundleTests.swift */, + FE6BB1452932289B001E5F7A /* ContentMigrationCoordinatorTests.swift */, + 93B853211B44165B0064FE72 /* Analytics */, + DC06DFF727BD52A100969974 /* BackgroundTasks */, + FE32E7EF284496F500744D80 /* Blogging Prompts */, + F15D1FB8265C40EA00854EE5 /* Blogging Reminders */, + 174C116D2624601000346EC6 /* Deep Linking */, + F198533B21ADAD0700DCDAE7 /* Editor */, + F93735F422D53C1800A3C312 /* Logging */, + 08F8CD2B1EBD243A0049D0C0 /* Media */, + 3F751D442491A8B20008A2B1 /* Zendesk */, ); - path = Install; + name = Utility; sourceTree = ""; }; - 9A8ECE042254A3260043C8DA /* Webview */ = { + 8584FDB31923EF4F0019C02E /* ViewRelated */ = { isa = PBXGroup; children = ( - 9A8ECE052254A3260043C8DA /* JetpackConnectionWebViewController.swift */, + D80EE638203DBB7E0094C34C /* Accessibility */, + 82FC61181FA8ADAC00A1757E /* Activity */, + B50C0C441EF429D500372C65 /* Aztec */, + 80C5239F2995977100B1C14B /* Blaze */, + AC34397B0E11443300E5D79B /* Blog */, + 8320B5CF11FCA3EA00607422 /* Cells */, + C533CF320E6D3AB3000C3DE8 /* Comments */, + F1C740BD26B18DEA005D0809 /* Developer */, + 173BCE711CEB365400AE8817 /* Domains */, + 081E4B4A281BFB520085E89C /* Feature Highlight */, + 98AA9F1F27EA888C00B3A98C /* Feature Introduction */, + 7E3E9B6E2177C9C300FD5797 /* Gutenberg */, + E1F391ED1FF25DEC00DB32A3 /* Jetpack */, + AB758D9C25EFDF8400961C0B /* Likes */, + 31F4F6641A13858F00196A98 /* Me */, + 5DA5BF4A18E32DE2005F11F9 /* Media */, + 08D978491CD2AF7D0054F19A /* Menus */, + CC1D800D1656D8B2002A542F /* Notifications */, + 850D22B21729EE8600EC6A16 /* NUX */, + EC4696A80EA74DAC0040EE8E /* Pages */, + E1B9127F1BB00EF1003C25B9 /* People */, + E1389AD91C59F78500FB2466 /* Plans */, + E14694041F3459A9004052C8 /* Plugins */, + AC3439790E11434600E5D79B /* Post */, + C7234A382832B55F0045C63F /* QR Login */, + 8298F38D1EEF2B15008EB7F0 /* Ratings */, + CCB3A03814C8DD5100D43C3F /* Reader */, + 1717139D265FE56A00F3A022 /* Reusable SwiftUI Views */, + 1719633E1D378D1E00898E8B /* Search */, + FE06AC8126C3BCD200B69DE4 /* Sharing */, + D865720F21869C380023A99C /* Site Creation */, + 3792259E12F6DBCC00F2176A /* Stats */, + 319D6E8219E44C7B0013871C /* Suggestions */, + 98077B58207555E400109F95 /* Support */, + 8584FDB619243AC40019C02E /* System */, + 5DA5BF4918E32DDB005F11F9 /* Themes */, + B53AD9B31BE9560F009AB87E /* Tools */, + 987E79C9261F8857000192B7 /* User Profile Sheet */, + 031662E60FFB14C60045D052 /* Views */, + 3F662C4824DC9F8300CAEA95 /* What's New */, + D86572182186C36E0023A99C /* Wizards */, ); - path = Webview; + path = ViewRelated; sourceTree = ""; }; - 9A8ECE082254A3260043C8DA /* ViewModel */ = { + 8584FDB4192437160019C02E /* Utility */ = { isa = PBXGroup; children = ( - 9A8ECE092254A3260043C8DA /* JetpackRemoteInstallViewModel.swift */, + FE6BB14129322798001E5F7A /* Migration */, + 8B85AED8259230C500ADBEC9 /* AB Testing */, + FA25FB242609B98E0005E08F /* App Configuration */, + F181EDE326B2AC3100C61241 /* BackgroundTasks */, + F15D1FB7265C40C300854EE5 /* Blogging Reminders */, + FEC2602D283FB9D4003D886A /* Blogging Prompts */, + 80D9D04429F7609F00FE3400 /* Generics */, + F10465122554260600655194 /* Gesture Recognizer */, + 326E2818250AC4610029EBF0 /* Image Dimension Parser */, + 1A433B1B2254CBC300AE7910 /* Networking */, + 40247E002120FE2300AE1C3C /* Automated Transfer */, + F198533821ADAA4E00DCDAE7 /* Editor */, + 7E58879820FE8D8300DB6F80 /* Environment */, + 7E4123AB20F4096200DF8486 /* FormattableContent */, + 436D55D7210F85C200CEAA33 /* ViewLoading */, + 1797373920EBDA1100377B4E /* Universal Links */, + 984B4EF120742FB900F87888 /* Zendesk */, + 7E21C763202BBE9F00837CF5 /* iAds */, + B5C9401B1DB901120079D4FF /* Account */, + 85A1B6721742E7DB00BA5E35 /* Analytics */, + F1D690131F828FF000200E30 /* BuildInformation */, + B5ECA6CB1DBAA0110062D7E0 /* CoreData */, + B5DB8AF51C949DC70059196A /* ImmuTable */, + F5A34BC825DF244F00C9654B /* Kanvas */, + 59DD94311AC479DC0032DD6B /* Logging */, + 08F8CD281EBD22EF0049D0C0 /* Media */, + E159D1011309AAF200F498E2 /* Migrations */, + B53520991AF7BB9600B33BA8 /* Notifications */, + 852416CC1A12EAF70030700C /* Ratings */, + B5C9401D1DB901F30079D4FF /* Reachability */, + E1523EB216D3B2EE002C5A36 /* Sharing */, + 74729CA12056F9DB00D1394D /* Spotlight */, + B526DC241B1E473B002A8C5F /* WebViewController */, + E1BEEC621C4E35A8000B4FA0 /* Animator.swift */, + 3FB1929426C79EC6000F5AA3 /* Date+TimeStrings.swift */, + E14BCABA1E0BC817002E0603 /* Delay.swift */, + 40D78238206ABD970015A3A1 /* Scheduler.swift */, + 8384C64028AAC82600EABE26 /* KeychainUtils.swift */, + E11000981CDB5F1E00E33887 /* KeychainTools.swift */, + 5DB4683918A2E718004A89A9 /* LocationService.h */, + 5DB4683A18A2E718004A89A9 /* LocationService.m */, + E11450DE1C4E47E600A6BD0F /* MessageAnimator.swift */, + E1B84EFF1E02E94D00BF6434 /* PingHubManager.swift */, + 1759F16F1FE017BF0003EC81 /* Queue.swift */, + 292CECFE1027259000BD407D /* SFHFKeychainUtils.h */, + 292CECFF1027259000BD407D /* SFHFKeychainUtils.m */, + 594399911B45091000539E21 /* WPAuthTokenIssueSolver.h */, + 594399921B45091000539E21 /* WPAuthTokenIssueSolver.m */, + E1E4CE091773C59B00430844 /* WPAvatarSource.h */, + E1E4CE0A1773C59B00430844 /* WPAvatarSource.m */, + 0807CB711CE670A800CDBDAC /* WPContentSearchHelper.swift */, + 5D6C4B051B603E03005E3C43 /* WPContentSyncHelper.swift */, + E114D798153D85A800984182 /* WPError.h */, + E114D799153D85A800984182 /* WPError.m */, + 57D5812C2228526C002BAAD7 /* WPError+Swift.swift */, + 8B05D29023A9417E0063B9AA /* WPMediaEditor.swift */, + 5D6C4B061B603E03005E3C43 /* WPTableViewHandler.h */, + 5D6C4B071B603E03005E3C43 /* WPTableViewHandler.m */, + E1F5A1BA1771C90A00E0495F /* WPTableImageSource.h */, + E1F5A1BB1771C90A00E0495F /* WPTableImageSource.m */, + 594DB2931AB891A200E2E456 /* WPUserAgent.h */, + 594DB2941AB891A200E2E456 /* WPUserAgent.m */, + 3F2656A025AF4DFA0073A832 /* AppLocalizedString.swift */, + 85B125431B02937E008A3D95 /* UIAlertControllerProxy.h */, + 85B125441B02937E008A3D95 /* UIAlertControllerProxy.m */, + 7E846FF020FD0A0500881F5A /* ContentCoordinator.swift */, + FFCB9F4922A125BD0080A45F /* WPException.h */, + FFCB9F4A22A125BD0080A45F /* WPException.m */, + 57C3D392235DFD8E00FE9CE6 /* ActionDispatcherFacade.swift */, + F582060123A85495005159A9 /* SiteDateFormatters.swift */, + 17C64BD1248E26A200AF09D7 /* AppAppearance.swift */, + 175721152754D31F00DE38BC /* AppIcon.swift */, + 4A2172F728EAACFF0006F4F1 /* BlogQuery.swift */, + 801D9519291AC0B00051993E /* OverlayFrequencyTracker.swift */, ); - path = ViewModel; + path = Utility; sourceTree = ""; }; - 9A8ECE1A2254ADFA0043C8DA /* View */ = { + 8584FDB619243AC40019C02E /* System */ = { isa = PBXGroup; children = ( - 9A35B08D225F9E2200A293E0 /* State */, - 9A8ECE1B2254AE4E0043C8DA /* JetpackRemoteInstallStateView.swift */, - 9A8ECE1C2254AE4E0043C8DA /* JetpackRemoteInstallStateView.xib */, + F5E032E22408D537003AF350 /* Action Sheet */, + F551E7F323F6EA1400751212 /* Floating Create Button */, + 17A4A36A20EE551F0071C2CA /* Coordinators */, + 1759F17E1FE145F90003EC81 /* Notices */, + 17D433581EFD2ED500CAB602 /* Fancy Alerts */, + B5F641B21E37C36700B7819F /* AdaptiveNavigationController.swift */, + 1746D7761D2165AE00B11D77 /* ForcePopoverPresenter.swift */, + E1DD4CCA1CAE41B800C3863E /* PagedViewController.swift */, + E1DD4CCC1CAE41C800C3863E /* PagedViewController.xib */, + 5DBFC8AA1A9C0EEF00E00DE4 /* WPScrollableViewController.h */, + 176DEEE81D4615FE00331F30 /* WPSplitViewController.swift */, + 310186691A373B01008F7DF6 /* WPTabBarController.h */, + 3101866A1A373B01008F7DF6 /* WPTabBarController.m */, + 17A28DC2205001A900EA6D9E /* FilterTabBar.swift */, + D8B9B58E204F4EA1003C6042 /* NetworkAware.swift */, + 43DDFE8B21715ADD008BE72F /* WPTabBarController+QuickStart.swift */, + 57BAD50B225CCE1A006139EC /* WPTabBarController+Swift.swift */, + 803BB98B29637AFC00B3F6D6 /* BlurredEmptyViewController.swift */, ); - path = View; + path = System; sourceTree = ""; }; - 9A8ECE1F22550A210043C8DA /* Error */ = { + 8584FDB719243E550019C02E /* System */ = { isa = PBXGroup; children = ( - 9A8ECE0B2254A3260043C8DA /* JetpackInstallError+Blocking.swift */, + 0107E0F128FD6A3100DE87DB /* Constants */, + 803BB97E295957A200B3F6D6 /* Root View */, + BE87E19E1BD4052F0075D45B /* 3DTouch */, + B5FD4520199D0C9A00286FBB /* WordPress-Bridging-Header.h */, + 1749965E2271BF08007021BD /* WordPressAppDelegate.swift */, + 43B0BA952229927F00328C69 /* WordPressAppDelegate+openURL.swift */, + F1E3536A25B9F74C00992E3A /* WindowManager.swift */, + 591A428D1A6DC6F2003807A6 /* WPGUIConstants.h */, + 591A428E1A6DC6F2003807A6 /* WPGUIConstants.m */, + 17F7C24822770B68002E5C2E /* main.swift */, ); - path = Error; + path = System; sourceTree = ""; }; - 9A9D34FB23607C8400BC95A3 /* Operations */ = { + 85A1B6721742E7DB00BA5E35 /* Analytics */ = { isa = PBXGroup; children = ( - 9A9D34FC23607CCC00BC95A3 /* AsyncOperationTests.swift */, - 9A9D34FE2360A4E200BC95A3 /* StatsPeriodAsyncOperationTests.swift */, + 937F3E301AD6FDA7006BA498 /* WPAnalyticsTrackerAutomatticTracks.h */, + 937F3E311AD6FDA7006BA498 /* WPAnalyticsTrackerAutomatticTracks.m */, + 85DA8C4218F3F29A0074C8A4 /* WPAnalyticsTrackerWPCom.h */, + 85DA8C4318F3F29A0074C8A4 /* WPAnalyticsTrackerWPCom.m */, + 5948AD0C1AB734F2006E8882 /* WPAppAnalytics.h */, + 5948AD0D1AB734F2006E8882 /* WPAppAnalytics.m */, + 8BAD53D5241922B900230F4B /* WPAnalyticsEvent.swift */, + 0828D7F91E6E09AE00C7C7D4 /* WPAppAnalytics+Media.swift */, + 175CC17B2723103000622FB4 /* WPAnalytics+Domains.swift */, + 80F8DAC0282B6546007434A0 /* WPAnalytics+QuickStart.swift */, + FE7FAABC299A98B90032A6F2 /* EventTracker.swift */, ); - name = Operations; + path = Analytics; sourceTree = ""; }; - 9AA0ADAF235F11500027AB5D /* Operation */ = { + 85F8E1991B017A8E000859BB /* Networking */ = { isa = PBXGroup; children = ( - 9AA0ADB0235F116F0027AB5D /* AsyncOperation.swift */, - 9AA0ADB2235F11DC0027AB5D /* StatsPeriodAsyncOperation.swift */, + F1450CF62437E8F800A28BFE /* MediaHostTests.swift */, + F1450CF82437EEBB00A28BFE /* MediaRequestAuthenticatorTests.swift */, + 4688E6CB26AB571D00A5D894 /* RequestAuthenticatorTests.swift */, + E15027641E03E54100B847E3 /* PinghubTests.swift */, ); - path = Operation; + name = Networking; sourceTree = ""; }; - AC3439790E11434600E5D79B /* Post */ = { + 8B0732EA242BEF1900E7FBD3 /* Prepublishing Nudge */ = { isa = PBXGroup; children = ( - F5D0A64C23CC157100B20D27 /* Preview */, - F57402A5235FF71F00374346 /* Scheduling */, - 4349B0A6218A2E810034118A /* Revisions */, - 5D1EBF56187C9B95003393F8 /* Categories */, - 5DF3DD691A9377220051A229 /* Controllers */, - 5D577D301891278D00B964C3 /* Geolocation */, - 5DF3DD6B1A93773B0051A229 /* Style */, - 5D09CBA61ACDE532007A23BD /* Utils */, - 5DF3DD6A1A93772D0051A229 /* Views */, - E13ACCD31EE5672100CCE985 /* PostEditor.swift */, - E17FEAD7221490F7006E1D2D /* PostEditorAnalyticsSession.swift */, - 91DCE84321A6A7840062F134 /* PostEditor+BlogPicker.swift */, - 91DCE84521A6A7F50062F134 /* PostEditor+MoreOptions.swift */, - 91DCE84721A6C58C0062F134 /* PostEditor+Publish.swift */, - 7E504D4921A5B8D400E341A8 /* PostEditorNavigationBarManager.swift */, - 437542E21DD4E19E00D6B727 /* EditPostViewController.swift */, - 5D146EB9189857ED0068FDC6 /* FeaturedImageViewController.h */, - 5D146EBA189857ED0068FDC6 /* FeaturedImageViewController.m */, - 93414DE41E2D25AE003143A3 /* PostEditorState.swift */, - 32CA6EBF2390C61F00B51347 /* PostListEditorPresenter.swift */, - 430693731DD25F31009398A2 /* PostPost.storyboard */, - 43D54D121DCAA070007F575F /* PostPostViewController.swift */, - 5DBFC8A81A9BE07B00E00DE4 /* Posts.storyboard */, - 5D62BAD818AAAE9B0044E5F7 /* PostSettingsViewController_Internal.h */, - ACBAB5FC0E121C7300F38795 /* PostSettingsViewController.h */, - ACBAB5FD0E121C7300F38795 /* PostSettingsViewController.m */, - FFEECFFB2084DE2B009B8CDB /* PostSettingsViewController+FeaturedImageUpload.swift */, - 593F26601CAB00CA00F14073 /* PostSharingController.swift */, - E155EC711E9B7DCE009D7F63 /* PostTagPickerViewController.swift */, - 5D17F0BC1A1D4C5F0087CCB8 /* PrivateSiteURLProtocol.h */, - 5D17F0BD1A1D4C5F0087CCB8 /* PrivateSiteURLProtocol.m */, - 5903AE1C19B60AB9009D5354 /* WPButtonForNavigationBar.h */, - 5903AE1A19B60A98009D5354 /* WPButtonForNavigationBar.m */, - FF0AAE0B1A16550D0089841D /* WPMediaProgressTableViewController.h */, - 5DB3BA0318D0E7B600F3F3E9 /* WPPickerView.h */, - 5DB3BA0418D0E7B600F3F3E9 /* WPPickerView.m */, + 8B0732E6242B9C5200E7FBD3 /* PrepublishingHeaderView.xib */, + 8B0732E8242BA1F000E7FBD3 /* PrepublishingHeaderView.swift */, + 8B0732EE242BF6EA00E7FBD3 /* Blog+Title.swift */, + 8B0732F1242BF97B00E7FBD3 /* PrepublishingNavigationController.swift */, + 8B1CF00E2433902700578582 /* PasswordAlertController.swift */, ); - path = Post; + path = "Prepublishing Nudge"; sourceTree = ""; }; - AC34397B0E11443300E5D79B /* Blog */ = { + 8B36256425A60CAB00D7CCE3 /* Backup */ = { isa = PBXGroup; children = ( - 3F43603423F368BF001DEE70 /* Blog Details */, - 3F43603523F368CA001DEE70 /* Blog List */, - 3F43603623F36962001DEE70 /* Blog Selector */, - 3F37609B23FD803300F0D87F /* Blog + Me */, - 02BF30512271D76F00616558 /* Domain Credit */, - 4395A15E210672C900844E8E /* QuickStart */, - E6431DDE1C4E890B00FD8D90 /* Sharing */, - FA5C74091C596E69000B528C /* Site Management */, - 3F43603823F36A76001DEE70 /* Site Settings */, - BE6AB7FB1BC62E0B00D980FC /* Style */, + 8B36256525A60CCA00D7CCE3 /* BackupListViewController.swift */, + C39ABBAD294BE84000F6F278 /* BackupListViewController+JetpackBannerViewController.swift */, ); - path = Blog; + path = Backup; sourceTree = ""; }; - B50C0C441EF429D500372C65 /* Aztec */ = { + 8B4DDF23278F3AED0022494D /* Cards */ = { isa = PBXGroup; children = ( - 402FFB22218C36CF00FF4A0B /* Helpers */, - B50C0C601EF42ADE00372C65 /* Extensions */, - B50C0C581EF42A2600372C65 /* Media */, - F126FDFC20A33BDB0010EB6E /* Processors */, - B50C0C5B1EF42A4A00372C65 /* ViewControllers */, + FA70024E29DC3B6100E874FD /* Activity Log */, + 0118968D29D1EAC900D34BA9 /* Domains */, + FA98B61429A3B71E0071AAE8 /* Blaze */, + 80D9CFF229DCA51C00FE3400 /* Pages */, + FE18495627F5ACA400D26879 /* Prompts */, + 8B92D69427CD51CE001F5371 /* Ghost */, + FAA4012F27B405DF009E1137 /* Quick Actions */, + FA8E2FE327C6377D00DA0982 /* Quick Start */, + 8BF9E03627B1AD2100915B27 /* Stats */, + 8B4DDF24278F3AF60022494D /* Posts */, + 83796698299C048E004A92B9 /* DashboardJetpackInstallCardCell.swift */, + 0CB4057B29C8DEE1008EED0A /* BlogDashboardPersonalizeCardCell.swift */, + 011896A129D5AF0700D34BA9 /* BlogDashboardCardConfigurable.swift */, + 0C35FFF529CBB5DE00D224EB /* BlogDashboardEmptyStateCell.swift */, ); - path = Aztec; + path = Cards; sourceTree = ""; }; - B50C0C581EF42A2600372C65 /* Media */ = { + 8B4DDF24278F3AF60022494D /* Posts */ = { isa = PBXGroup; children = ( - B50C0C591EF42A2600372C65 /* MediaProgressCoordinator.swift */, - 8BD36E012395CAEA00EFFF1C /* MediaEditorOperation+Description.swift */, + 8BE6F92827EE26AF0008BDC7 /* Views */, + 8BD66ED32787530C00CCD95A /* PostsCardViewModel.swift */, + 8BF1C81927BC00AF00F1C203 /* BlogDashboardCardFrameView.swift */, + 8B065CC527BD5452005BA7AB /* DashboardEmptyPostsCardCell.swift */, + 80B016D02803AB9F00D15566 /* DashboardPostsListCardCell.swift */, ); - path = Media; + path = Posts; sourceTree = ""; }; - B50C0C5B1EF42A4A00372C65 /* ViewControllers */ = { + 8B6214E127B1B2D6001DF7B6 /* Service */ = { isa = PBXGroup; children = ( - B50C0C5C1EF42A4A00372C65 /* AztecAttachmentViewController.swift */, - B5FDF9F220D842D2006D14E3 /* AztecNavigationController.swift */, - B50C0C5D1EF42A4A00372C65 /* AztecPostViewController.swift */, - B538F3881EF46EC8001003D5 /* UnknownEditorViewController.swift */, - FFABD7FF213423F1003C65B6 /* LinkSettingsViewController.swift */, - FFABD80721370496003C65B6 /* SelectPostViewController.swift */, + 8B6214E227B1B2F3001DF7B6 /* BlogDashboardService.swift */, + 8BBC778A27B5531700DBA087 /* BlogDashboardPersistence.swift */, + 0CB4056A29C78F06008EED0A /* BlogDashboardPersonalizationService.swift */, + 8BAC9D9D27BAB97E008EA44C /* BlogDashboardRemoteEntity.swift */, + 8B15CDAA27EB89AC00A75749 /* BlogDashboardPostsParser.swift */, ); - path = ViewControllers; + path = Service; sourceTree = ""; }; - B50C0C601EF42ADE00372C65 /* Extensions */ = { + 8B6214E427B1B420001DF7B6 /* Dashboard */ = { isa = PBXGroup; children = ( - B50C0C611EF42AF200372C65 /* TextList+WordPress.swift */, - B50C0C631EF42B3A00372C65 /* Header+WordPress.swift */, - B50C0C651EF42B6400372C65 /* FormatBarItemProviders.swift */, - FF1FD0232091268900186384 /* URL+LinkNormalization.swift */, + 8B6214E527B1B446001DF7B6 /* BlogDashboardServiceTests.swift */, + 0CB4056D29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift */, + 8BE9AB8727B6B5A300708E45 /* BlogDashboardPersistenceTests.swift */, + 8B2D4F5427ECE376009B085C /* BlogDashboardPostsParserTests.swift */, + 8BD34F0827D144FF005E931C /* BlogDashboardStateTests.swift */, + 806E53E327E01CFE0064315E /* DashboardStatsViewModelTests.swift */, + 80B016CE27FEBDC900D15566 /* DashboardCardTests.swift */, + 80EF9283280CFEB60064A971 /* DashboardPostsSyncManagerTests.swift */, + 0118969029D1F2FE00D34BA9 /* DomainsDashboardCardHelperTests.swift */, + 0C35FFF329CBA6DA00D224EB /* BlogDashboardPersonalizationViewModelTests.swift */, ); - path = Extensions; + path = Dashboard; sourceTree = ""; }; - B5176CBF1CDCE14F0083CF2D /* People Management */ = { + 8B69F0E2255C2BC0006B1CEF /* Activity */ = { isa = PBXGroup; children = ( - B5176CC01CDCE1B90083CF2D /* ManagedPerson.swift */, - B5176CC21CDCE1C30083CF2D /* ManagedPerson+CoreDataProperties.swift */, + 8B69F0E3255C2C3F006B1CEF /* ActivityListViewModelTests.swift */, ); - name = "People Management"; + path = Activity; sourceTree = ""; }; - B5176CC41CDCE3C80083CF2D /* Account Settings */ = { + 8B7623352384372200AB3EE7 /* Pages */ = { isa = PBXGroup; children = ( - E14200771C117A2E00B3B115 /* ManagedAccountSettings.swift */, - E14200791C117A3B00B3B115 /* ManagedAccountSettings+CoreDataProperties.swift */, + 8B7623362384372F00AB3EE7 /* Controllers */, ); - name = "Account Settings"; + path = Pages; sourceTree = ""; }; - B5176CC61CDCE47F0083CF2D /* Blog */ = { + 8B7623362384372F00AB3EE7 /* Controllers */ = { isa = PBXGroup; children = ( - CEBD3EA90FF1BA3B00C1396E /* Blog.h */, - CEBD3EAA0FF1BA3B00C1396E /* Blog.m */, - E17FEAD9221494B2006E1D2D /* Blog+Analytics.swift */, - 9A341E5521997A330036662E /* Blog+BlogAuthors.swift */, - 9A341E5421997A330036662E /* BlogAuthor.swift */, - B518E1641CCAA19200ADFE75 /* Blog+Capabilities.swift */, - 7E7BEF6F22E1AED8009A880D /* Blog+Editor.swift */, - 73713582208EA4B900CCDFC8 /* Blog+Files.swift */, - B5EEDB961C91F10400676B2B /* Blog+Interface.swift */, - E11C4B6F2010930B00A6619C /* Blog+Jetpack.swift */, - B5D889401BEBE30A007C4684 /* BlogSettings.swift */, - B55F1AA71C10936600FD04D4 /* BlogSettings+Discussion.swift */, - 8217380A1FE05EE600BEC94C /* BlogSettings+DateAndTimeFormat.swift */, - FF00889A204DF3ED007CCE66 /* Blog+Quota.swift */, - 43290D03215C28D800F6B398 /* Blog+QuickStart.swift */, - 401AC82622DD2387006D78D4 /* Blog+Plans.swift */, + 8B7623372384373E00AB3EE7 /* PageListViewControllerTests.swift */, ); - name = Blog; + path = Controllers; sourceTree = ""; }; - B526DC241B1E473B002A8C5F /* WebViewController */ = { + 8B7F51C724EED488008CF5B5 /* Analytics */ = { isa = PBXGroup; children = ( - E161B7EB1F839345000FDF0B /* CookieJar.swift */, - E137B1651F8B77D4006AC7FC /* WebNavigationDelegate.swift */, - E16FB7E21F8B61030004DD9F /* WebKitViewController.swift */, - F5D0A64823C8FA1500B20D27 /* LinkBehavior.swift */, - E1222B621F877FD700D23173 /* WebProgressView.swift */, - E1BB85971F82459800797050 /* WebViewAuthenticator.swift */, - E16FB7E01F8B5D7D0004DD9F /* WebViewControllerConfiguration.swift */, - E1222B661F878E4700D23173 /* WebViewControllerFactory.swift */, - B526DC271B1E47FC002A8C5F /* WPStyleGuide+WebView.h */, - B526DC281B1E47FC002A8C5F /* WPStyleGuide+WebView.m */, - E1B62A7913AA61A100A6FCA4 /* WPWebViewController.h */, - E1B62A7A13AA61A100A6FCA4 /* WPWebViewController.m */, - 5D6C4B011B603D1F005E3C43 /* WPWebViewController.xib */, + 8B7F51C824EED804008CF5B5 /* ReaderTracker.swift */, ); - name = WebViewController; + path = Analytics; sourceTree = ""; }; - B532ACD41DC3AE1F00FFFA57 /* Extensions */ = { + 8B85AED8259230C500ADBEC9 /* AB Testing */ = { isa = PBXGroup; children = ( - B532ACD21DC3AE1200FFFA57 /* OHHTTPStubs+Helpers.swift */, - D8B6BEB6203E11F2007C8A19 /* Bundle+LoadFromNib.swift */, + 8B85AED9259230FC00ADBEC9 /* ABTest.swift */, ); - name = Extensions; + path = "AB Testing"; sourceTree = ""; }; - B53520991AF7BB9600B33BA8 /* Notifications */ = { + 8B92D69427CD51CE001F5371 /* Ghost */ = { isa = PBXGroup; children = ( - B535209A1AF7BBB800B33BA8 /* PushAuthenticationManager.swift */, - B5416CF41C171D7100006DD8 /* PushNotificationsManager.swift */, - B5B68BD31C19AAED00EB59E0 /* InteractiveNotificationsManager.swift */, + 8B92D69527CD51FA001F5371 /* DashboardGhostCardCell.swift */, + 8BF281F827CE8E4100AF8CF3 /* DashboardGhostCardContent.xib */, + 8BF281FB27CEB69C00AF8CF3 /* DashboardFailureCardCell.swift */, ); - name = Notifications; + path = Ghost; sourceTree = ""; }; - B53AD9B31BE9560F009AB87E /* Tools */ = { + 8B93412D257029E30097D0AC /* Filter */ = { isa = PBXGroup; children = ( - E1EEFAD91CC4CC5700126533 /* Confirmable.h */, - B55086201CC15CCB004EADB4 /* PromptViewController.swift */, - E185042E1EE6ABD9005C234C /* Restorer.swift */, - 3F43602E23F31D48001DEE70 /* ScenePresenter.swift */, - E14B40FE1C58B93F005046F6 /* SettingsCommon.swift */, - B59D994E1C0790CC0003D795 /* SettingsListEditorViewController.swift */, - B54127691C0F7D610015CA80 /* SettingsMultiTextViewController.h */, - B541276A1C0F7D610015CA80 /* SettingsMultiTextViewController.m */, - B50EED781C0E5B2400D278CA /* SettingsPickerViewController.swift */, - B53AD9BD1BE9584A009AB87E /* SettingsSelectionViewController.h */, - B53AD9BE1BE9584A009AB87E /* SettingsSelectionViewController.m */, - B53AD9B81BE95687009AB87E /* SettingsTextViewController.h */, - B53AD9B91BE95687009AB87E /* SettingsTextViewController.m */, - E15632CB1EB9ECF40035A099 /* TableViewKeyboardObserver.swift */, - E1CB6DA6200F661900945457 /* TimeZoneSelectorViewController.swift */, + 8B93412E257029F50097D0AC /* FilterChipButton.swift */, + 8B51844425893F140085488D /* FilterBarView.swift */, ); - path = Tools; + path = Filter; sourceTree = ""; }; - B5416CF81C17542900006DD8 /* Notifications */ = { + 8BADF16324801B4B005AD038 /* Detail */ = { isa = PBXGroup; children = ( - 7E987F57210811CC00CAFB88 /* NotificationContentRouterTests.swift */, - D8BA274C20FDEA2E007A5C77 /* NotificationTextContentTests.swift */, - D821C81A21003AE9002ED995 /* FormattableContentGroupTests.swift */, - D821C816210036D9002ED995 /* ActivityContentFactoryTests.swift */, - D848CC1820FF3A2400A9038F /* FormattableNotIconTests.swift */, - D848CC0620FF2BE200A9038F /* NotificationContentRangeFactoryTests.swift */, - D848CC0220FF04FA00A9038F /* FormattableUserContentTests.swift */, - D848CBFE20FF010F00A9038F /* FormattableCommentContentTests.swift */, - D848CBF820FEF82100A9038F /* NotificationsContentFactoryTests.swift */, - D81C2F6920F8B449002AE1F1 /* NotificationActionParserTest.swift */, - D81C2F6520F8ACCD002AE1F1 /* FormattableContentFormatterTests.swift */, - D81C2F6120F89632002AE1F1 /* EditCommentActionTests.swift */, - D81C2F5F20F891C4002AE1F1 /* TrashCommentActionTests.swift */, - D81C2F5D20F88CE5002AE1F1 /* MarkAsSpamActionTests.swift */, - D81C2F5B20F872C2002AE1F1 /* ReplyToCommentActionTests.swift */, - D81C2F5920F86E94002AE1F1 /* LikeCommentActionTests.swift */, - D81C2F5320F85DB1002AE1F1 /* ApproveCommentActionTests.swift */, - B5882C461D5297D1008E0EAA /* NotificationTests.swift */, - B5C0CF3E204DB92F00DB0362 /* NotificationReplyStoreTests.swift */, - 85B125401B028E34008A3D95 /* PushAuthenticationManagerTests.swift */, - 85F8E19C1B018698000859BB /* PushAuthenticationServiceTests.swift */, - B5416CFD1C1756B900006DD8 /* PushNotificationsManagerTests.m */, - 7E987F592108122A00CAFB88 /* NotificationUtility.swift */, + 8BD89F2C24D9E73600341C90 /* Views */, + 8BD89F2B24D9E70A00341C90 /* WebView */, + 8BDA5A6A247C2EAF00AB124C /* ReaderDetailViewController.swift */, + 8BDA5A71247C5E5800AB124C /* ReaderDetailCoordinator.swift */, + 8BDA5A6E247C308300AB124C /* ReaderDetailViewController.storyboard */, ); - name = Notifications; + path = Detail; sourceTree = ""; }; - B542DEBB1D119683004CA6AE /* Tools */ = { + 8BC81D6327CFC0C60057F790 /* Helpers */ = { isa = PBXGroup; children = ( - 43EE90ED223B1028006A33E9 /* TextBundleWrapper.h */, - 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */, - E1CE41641E8D101A000CF5A4 /* ShareExtractor.swift */, - 7430C4481F97F23600E2673E /* ShareMediaFileManager.swift */, - 74CEF0761F9AA0F700B729CA /* ShareExtensionSessionManager.swift */, - 74448F532044BC7600BD4CDA /* CategoryTree.swift */, + 8B15D27328009EBF0076628A /* BlogDashboardAnalytics.swift */, + 0C35FFF029CB81F700D224EB /* BlogDashboardHelpers.swift */, + 8BC81D6427CFC0DA0057F790 /* BlogDashboardState.swift */, + 80EF9285280D272E0064A971 /* DashboardPostsSyncManager.swift */, ); - name = Tools; + path = Helpers; sourceTree = ""; }; - B545186718E9E08000AC3A54 /* Notifications */ = { + 8BD36E042395CC2F00EFFF1C /* Aztec */ = { isa = PBXGroup; children = ( - D816C1EA20E0884100C4D82F /* Actions */, - B5722E401D51A28100F40C5E /* Notification.swift */, - B5899AE31B422D990075A3D6 /* NotificationSettings.swift */, + 8BD36E052395CC4400EFFF1C /* MediaEditorOperation+DescriptionTests.swift */, ); - path = Notifications; + name = Aztec; sourceTree = ""; }; - B54E1DEC1A0A7BAA00807537 /* ReplyTextView */ = { + 8BD89F2B24D9E70A00341C90 /* WebView */ = { isa = PBXGroup; children = ( - B54E1DED1A0A7BAA00807537 /* ReplyBezierView.swift */, - B54E1DEE1A0A7BAA00807537 /* ReplyTextView.swift */, - B54E1DEF1A0A7BAA00807537 /* ReplyTextView.xib */, + 8BADF16424801BCE005AD038 /* ReaderWebView.swift */, + 8B24C4E2249A4C3E0005E8A5 /* OfflineReaderWebView.swift */, + 8BDC4C38249BA5CA00DE0A2D /* ReaderCSS.swift */, + 8B64B4B1247EC3A2009A1229 /* reader.css */, ); - path = ReplyTextView; + path = WebView; sourceTree = ""; }; - B565D41C3DB27630CD503F9A /* Pods */ = { + 8BD89F2C24D9E73600341C90 /* Views */ = { isa = PBXGroup; children = ( - 75305C06D345590B757E3890 /* Pods-WordPress.debug.xcconfig */, - 51A5F017948878F7E26979A0 /* Pods-WordPress.release.xcconfig */, - BBA98A42A5503D734AC9C936 /* Pods-WordPress.release-internal.xcconfig */, - F373612EEEEF10E500093FF3 /* Pods-WordPress.release-alpha.xcconfig */, - 2262D835FA89938EBF63EADF /* Pods-WordPressShareExtension.debug.xcconfig */, - CDF46FFC7F78DB8FEB56E6F9 /* Pods-WordPressShareExtension.release.xcconfig */, - C9BDC428A69177C2CBF0C247 /* Pods-WordPressShareExtension.release-internal.xcconfig */, - D61CEAC1CB25AE65B26BDC68 /* Pods-WordPressShareExtension.release-alpha.xcconfig */, - 084FF460C7742309671B3A86 /* Pods-WordPressTest.debug.xcconfig */, - EF379F0A70B6AC45330EE287 /* Pods-WordPressTest.release.xcconfig */, - C9264D275F6288F66C33D2CE /* Pods-WordPressTest.release-internal.xcconfig */, - 131D0EE49695795ECEDAA446 /* Pods-WordPressTest.release-alpha.xcconfig */, - 368127CE6F1CA15EC198147D /* Pods-WordPressTodayWidget.debug.xcconfig */, - 37E3F5EA2ED7005A5E0BBEC3 /* Pods-WordPressTodayWidget.release.xcconfig */, - 99D675225C3282AC88B89F04 /* Pods-WordPressTodayWidget.release-internal.xcconfig */, - 0F01F606071B0B9BD9DB34DE /* Pods-WordPressTodayWidget.release-alpha.xcconfig */, - 712D0A17BC83B9730BD7CCC0 /* Pods-WordPressDraftActionExtension.debug.xcconfig */, - B60BB199C4D84FFF05C0F97E /* Pods-WordPressDraftActionExtension.release.xcconfig */, - 61923FEDAFD6503928030311 /* Pods-WordPressDraftActionExtension.release-internal.xcconfig */, - 3B28C7D4D65D0CA6AB9FCFDC /* Pods-WordPressDraftActionExtension.release-alpha.xcconfig */, - A27DA63A5ECD1D0262C589EC /* Pods-WordPressNotificationServiceExtension.debug.xcconfig */, - 73FEFF1991AE9912FB2DA9BC /* Pods-WordPressNotificationServiceExtension.release.xcconfig */, - 33D5016BDA00B45DFCAF3818 /* Pods-WordPressNotificationServiceExtension.release-internal.xcconfig */, - EEF80689364FA9CAE10405E8 /* Pods-WordPressNotificationServiceExtension.release-alpha.xcconfig */, - 4F943DB9A7237917709622D5 /* Pods-WordPressNotificationContentExtension.debug.xcconfig */, - 48690E659987FD4472EEDE5F /* Pods-WordPressNotificationContentExtension.release.xcconfig */, - F262DFCA1F94418CE76D1D78 /* Pods-WordPressNotificationContentExtension.release-internal.xcconfig */, - E1FD527F8A6BBCC512ECAAC9 /* Pods-WordPressNotificationContentExtension.release-alpha.xcconfig */, - 659BB95E3808B6781E2D8D85 /* Pods-WordPressScreenshotGeneration.debug.xcconfig */, - 968136B9BC83DFA8E463D5E4 /* Pods-WordPressScreenshotGeneration.release.xcconfig */, - E9BAA39DBCC9B24D1C785074 /* Pods-WordPressScreenshotGeneration.release-internal.xcconfig */, - AC6024D92F44AE4CB4A8C1B3 /* Pods-WordPressScreenshotGeneration.release-alpha.xcconfig */, - 27AE66536E0C5378203F9312 /* Pods-WordPressUITests.debug.xcconfig */, - B787A850C6163034630E7AF2 /* Pods-WordPressUITests.release.xcconfig */, - 10BBFF34EBDA2F9B8CB48DF6 /* Pods-WordPressUITests.release-internal.xcconfig */, - 1B77149F6C65D343E7E3AD09 /* Pods-WordPressUITests.release-alpha.xcconfig */, - D1216A69ECC61E7772AB8C41 /* Pods-WordPressThisWeekWidget.debug.xcconfig */, - 7DFD69454E24EC0A1A0BACCD /* Pods-WordPressThisWeekWidget.release.xcconfig */, - 8E65034AFB7DC85D70CCC064 /* Pods-WordPressThisWeekWidget.release-internal.xcconfig */, - 40F031E19B32BAF8654838C0 /* Pods-WordPressThisWeekWidget.release-alpha.xcconfig */, - 0EAD25F1E69B6B91783C4963 /* Pods-WordPressAllTimeWidget.debug.xcconfig */, - 6A9F41AAF18FB527262CC57C /* Pods-WordPressAllTimeWidget.release.xcconfig */, - 41341F0000A9A65A3326F2EC /* Pods-WordPressAllTimeWidget.release-internal.xcconfig */, - E5B3F5F559826C230DB0DCDD /* Pods-WordPressAllTimeWidget.release-alpha.xcconfig */, + 9801E681274EEBC4002FDDB6 /* ReaderDetailCommentsHeader.swift */, + 9801E684274EEC19002FDDB6 /* ReaderDetailCommentsHeader.xib */, + 98622E9E274C59A400061A5F /* ReaderDetailCommentsTableViewDelegate.swift */, + 3223393B24FEC18000BDD4BF /* ReaderDetailFeaturedImageView.swift */, + 3223393D24FEC2A700BDD4BF /* ReaderDetailFeaturedImageView.xib */, + 8B0CE7D02481CFE8004C4799 /* ReaderDetailHeaderView.swift */, + 8B0CE7D22481CFF8004C4799 /* ReaderDetailHeaderView.xib */, + 98ED5962265EBD0000A0B33E /* ReaderDetailLikesListController.swift */, + 98E54FF1265C972900B4BE9A /* ReaderDetailLikesView.swift */, + 98E55018265C977E00B4BE9A /* ReaderDetailLikesView.xib */, + 98DCF4A3275945DF0008630F /* ReaderDetailNoCommentCell.swift */, + 98DCF4A4275945DF0008630F /* ReaderDetailNoCommentCell.xib */, + 8BA77BCE2483415400E1EBBF /* ReaderDetailToolbar.swift */, + 8BA77BCC248340CE00E1EBBF /* ReaderDetailToolbar.xib */, + FAC086D525EDFB1E00B94F2A /* ReaderRelatedPostsCell.swift */, + FAC086D625EDFB1E00B94F2A /* ReaderRelatedPostsCell.xib */, + FA6FAB3425EF7C5700666CED /* ReaderRelatedPostsSectionHeaderView.swift */, + FA6FAB4625EF7C6A00666CED /* ReaderRelatedPostsSectionHeaderView.xib */, ); - name = Pods; + path = Views; sourceTree = ""; }; - B56994461B7A82A300FF26FA /* Style */ = { + 8BE69514243E676C00FF492F /* Prepublishing Nudges */ = { isa = PBXGroup; children = ( - B56994441B7A7EF200FF26FA /* WPStyleGuide+Comments.swift */, + 8BE69511243E674300FF492F /* PrepublishingHeaderViewTests.swift */, ); - name = Style; + name = "Prepublishing Nudges"; sourceTree = ""; }; - B56994471B7A82CD00FF26FA /* Controllers */ = { + 8BE6F92827EE26AF0008BDC7 /* Views */ = { isa = PBXGroup; children = ( - C533CF330E6D3ADA000C3DE8 /* CommentsViewController.h */, - C533CF340E6D3ADA000C3DE8 /* CommentsViewController.m */, - 313AE49B19E3F20400AAFABE /* CommentViewController.h */, - 313AE49C19E3F20400AAFABE /* CommentViewController.m */, - 5D6C4AFD1B603CE9005E3C43 /* EditCommentViewController.xib */, - 2906F80F110CDA8900169D56 /* EditCommentViewController.h */, - 2906F810110CDA8900169D56 /* EditCommentViewController.m */, - D8B9B590204F658A003C6042 /* CommentsViewController+NetworkAware.swift */, - D8B9B592204F6C93003C6042 /* CommentsViewController+Network.h */, - 328CEC5D23A532BA00A6899E /* FullScreenCommentReplyViewController.swift */, + 8BE6F92927EE26D30008BDC7 /* BlogDashboardPostCardGhostCell.xib */, + 8BE6F92B27EE27DB0008BDC7 /* BlogDashboardPostCardGhostCell.swift */, + 8B8E50B527A4692000C89979 /* DashboardPostListErrorCell.swift */, ); - name = Controllers; + path = Views; sourceTree = ""; }; - B56994481B7A82D400FF26FA /* Views */ = { + 8BEE845627B1DC5E0001A93C /* Dashboard */ = { isa = PBXGroup; children = ( - B5CEEB8D1B7920BE00E7B7B0 /* CommentsTableViewCell.swift */, - B5CEEB8F1B79244D00E7B7B0 /* CommentsTableViewCell.xib */, + 8BEE845927B1DC9D0001A93C /* dashboard-200-with-drafts-and-scheduled.json */, + 8B45C12527B2A27400EA3257 /* dashboard-200-with-drafts-only.json */, + 8B2D4F5227ECE089009B085C /* dashboard-200-without-posts.json */, ); - name = Views; + path = Dashboard; sourceTree = ""; }; - B572735C1B66CCEF000D1C4F /* AlertView */ = { + 8BEE845B27B1DD8E0001A93C /* ViewModel */ = { isa = PBXGroup; children = ( - B572735D1B66CCEF000D1C4F /* AlertInternalView.swift */, - B572735E1B66CCEF000D1C4F /* AlertView.swift */, - B572735F1B66CCEF000D1C4F /* AlertView.xib */, + 8B074A4F27AC3A64003A2EB8 /* BlogDashboardViewModel.swift */, + 8BEE846027B1DE0E0001A93C /* DashboardCardModel.swift */, + 806E53E027E01C7F0064315E /* DashboardStatsViewModel.swift */, ); - path = AlertView; + path = ViewModel; sourceTree = ""; }; - B587796C19B799D800E57C5A /* Extensions */ = { + 8BF9E03627B1AD2100915B27 /* Stats */ = { isa = PBXGroup; children = ( - 4326191322FCB8BE003C7642 /* Colors and Styles */, - 086C4D0C1E81F7920011D960 /* Media */, - B587798319B799EB00E57C5A /* Notifications */, - 7E729C29209A241100F76599 /* AbstractPost+PostInformation.swift */, - F580C3CA23D8F9B40038E243 /* AbstractPost+Dates.swift */, - 8BFE36FC230F16580061EBA8 /* AbstractPost+fixLocalMediaURLs.swift */, - E1AB5A061E0BF17500574B4E /* Array.swift */, - 9A2CD5362146B8C700AE5055 /* Array+Page.swift */, - 7ED3695420A9F091007B0D56 /* Blog+ImageSourceInformation.swift */, - FF619DD41C75246900903B65 /* CLPlacemark+Formatting.swift */, - 177CBE4F1DA3A3AC009F951E /* CollectionType+Helpers.swift */, - E14DFAFA1E07E7C400494688 /* Data.swift */, - E1C9AA501C10419200732665 /* Math.swift */, - 98EB126920D2DC2500D2D5B5 /* NoResultsViewController+Model.swift */, - E14DF70520C922F200959BA9 /* NotificationCenter+ObserveOnce.swift */, - B54866C91A0D7042004AC79D /* NSAttributedString+Helpers.swift */, - 1751E5921CE23801000CA08D /* NSAttributedString+StyledHTML.swift */, - B56F25871FBDE502005C33E4 /* NSAttributedStringKey+Conversion.swift */, - B5E167F319C08D18009535AA /* NSCalendar+Helpers.swift */, - B5DD04731CD3DAB00003DF89 /* NSFetchedResultsController+Helpers.swift */, - FF0148E41DFABBC9001AD265 /* NSFileManager+FolderSize.swift */, - E19B17AF1E5C69A5007517C6 /* NSManagedObject.swift */, - B574CE141B5E8EA800A84FFD /* NSMutableAttributedString+Helpers.swift */, - B5BE31C31CB825A100BDF770 /* NSURLCache+Helpers.swift */, - E1CFC1561E0AC8FF001DF9E9 /* Pattern.swift */, - FF70A3201FD5840500BC270D /* PHAsset+Metadata.swift */, - FFD12D5D1FE1998D00F20A00 /* Progress+Helpers.swift */, - E177807F1C97FA9500FA7E14 /* StoreKit+Debug.swift */, - 7360018E20A265C7001E5E31 /* String+Files.swift */, - B55FFCF91F034F1A0070812C /* String+Ranges.swift */, - B54C02231F38F50100574572 /* String+RegEx.swift */, - B5969E2120A49E86005E9DF1 /* UIAlertController+Helpers.swift */, - 1707CE411F3121750020B7FE /* UICollectionViewCell+Tint.swift */, - E1823E6B1E42231C00C19F53 /* UIEdgeInsets.swift */, - B58C4EC9207C5E1900E32E4D /* UIImage+Assets.swift */, - FF70A3211FD5840500BC270D /* UIImage+Export.swift */, - B5E94D141FE04815000E7C20 /* UIImageView+SiteIcon.swift */, - 1790A4521E28F0ED00AE54C2 /* UINavigationController+Helpers.swift */, - 177E7DAC1DD0D1E600890467 /* UINavigationController+SplitViewFullscreen.swift */, - 7326A4A7221C8F4100B4EB8C /* UIStackView+Subviews.swift */, - D829C33A21B12EFE00B09F12 /* UIView+Borders.swift */, - F17A2A1D23BFBD72001E96AC /* UIView+ExistingConstraints.swift */, - 9A162F2421C26F5F00FDC035 /* UIViewController+ChildViewController.swift */, - 9F3EFCA2208E308900268758 /* UIViewController+Notice.swift */, - 9A4E939E2268D9B400E14823 /* UIViewController+NoResults.swift */, - 0845B8C51E833C56001BA771 /* URL+Helpers.swift */, - 17E60E08220DBD6E00848F89 /* WKWebView+Preview.swift */, - F12E500223C7C5330068CB5E /* WKWebView+UserAgent.swift */, - 57276E70239BDFD200515BE2 /* NotificationCenter+ObserveMultiple.swift */, + 8B6214DF27B1AD9D001DF7B6 /* DashboardStatsCardCell.swift */, + 8071390627D039E70012DB21 /* DashboardSingleStatView.swift */, + FAB37D4527ED84BC00CA993C /* DashboardStatsNudgeView.swift */, + 80EF672427F3D63B0063B138 /* DashboardStatsStackView.swift */, ); - path = Extensions; + path = Stats; sourceTree = ""; }; - B587798319B799EB00E57C5A /* Notifications */ = { + 8F228031B2964AE9A667C735 /* Views */ = { isa = PBXGroup; children = ( - B587798419B799EB00E57C5A /* Notification+Interface.swift */, - D81322B22050F9110067714D /* NotificationName+Names.swift */, + DC76668126FD9AC8009254DD /* TimeZoneRow.swift */, + DC76668226FD9AC9009254DD /* TimeZoneTableViewCell.swift */, + 8F228848D5DEACE6798CE7E2 /* TimeZoneSearchHeaderView.swift */, + 8F228AE62B771552F0F971BE /* TimeZoneSearchHeaderView.xib */, ); - path = Notifications; + path = Views; sourceTree = ""; }; - B58CE5DD1DC1284C004AA81D /* Notifications */ = { + 931215E2267F4F92008C3B69 /* Referrer Details */ = { isa = PBXGroup; children = ( - E150275E1E03E51500B847E3 /* notes-action-delete.json */, - E150275F1E03E51500B847E3 /* notes-action-push.json */, - E15027601E03E51500B847E3 /* notes-action-unsupported.json */, - 7E92828821090E9A00BBD8A3 /* notifications-pingback.json */, - B5AEEC741ACACFDA008BF2A4 /* notifications-badge.json */, - 748BD8881F1923D500813F9A /* notifications-last-seen.json */, - B5AEEC751ACACFDA008BF2A4 /* notifications-like.json */, - 748BD8861F19238600813F9A /* notifications-load-all.json */, - 748BD8841F19234300813F9A /* notifications-mark-as-read.json */, - B5AEEC771ACACFDA008BF2A4 /* notifications-new-follower.json */, - B5AEEC781ACACFDA008BF2A4 /* notifications-replied-comment.json */, - 7EF2EE9F210A67B60007A76B /* notifications-unapproved-comment.json */, - B5EFB1D01B33630C007608A3 /* notifications-settings.json */, - D848CBF620FEEE7F00A9038F /* notifications-text-content.json */, - D848CBFA20FEFA4800A9038F /* notifications-comment-content.json */, - D848CC0020FF030C00A9038F /* notifications-comment-meta.json */, - D848CBFC20FEFB4900A9038F /* notifications-user-content.json */, - D848CC0420FF062100A9038F /* notifications-user-content-meta.json */, - D848CC0820FF2D4400A9038F /* notifications-icon-range.json */, - D848CC0A20FF2D5D00A9038F /* notifications-user-range.json */, - D848CC0C20FF2D7B00A9038F /* notifications-post-range.json */, - D848CC0E20FF2D9B00A9038F /* notifications-comment-range.json */, - D848CC1020FF310400A9038F /* notifications-site-range.json */, - D848CC1220FF31BB00A9038F /* notifications-blockquote-range.json */, + 931215ED267F6799008C3B69 /* ReferrerDetailsCell.swift */, + 931215E9267F59CB008C3B69 /* ReferrerDetailsHeaderCell.swift */, + 931215E7267F52A6008C3B69 /* ReferrerDetailsHeaderRow.swift */, + 931215EB267F5F45008C3B69 /* ReferrerDetailsRow.swift */, + 931215F3267FE177008C3B69 /* ReferrerDetailsSpamActionCell.swift */, + 931215F1267FE162008C3B69 /* ReferrerDetailsSpamActionRow.swift */, + 931215E3267F5003008C3B69 /* ReferrerDetailsTableViewController.swift */, + 931215E5267F5192008C3B69 /* ReferrerDetailsViewModel.swift */, ); - name = Notifications; + path = "Referrer Details"; sourceTree = ""; }; - B59B18761CC7FC070055EB7C /* Style */ = { + 932225A81C7CE50300443B02 /* WordPressShareExtension */ = { isa = PBXGroup; children = ( - E1B9128A1BB0129C003C25B9 /* WPStyleGuide+People.swift */, + 74FA4BDB1FBF994F0031EAAD /* Data */, + B5FA22851C99F63B0016CA7C /* Extensions */, + 7462BFD22028CD0500B552D8 /* Notices */, + 745EAF432003FC930066F415 /* Protocols */, + 74FA2EE2200E8A1D001DDC13 /* Services */, + 741D1478200D5533003DFD30 /* Style */, + B542DEBB1D119683004CA6AE /* Tools */, + B5FA22841C99F6340016CA7C /* Tracks */, + 7430C4471F97F0DA00E2673E /* UI */, + B5BEA5661C7CEB4400C8035B /* Supporting Files */, + B50248B81C96FFB000AFBDED /* WordPressShare-Bridging-Header.h */, + E1AFA8C21E8E34230004A323 /* WordPressShare.js */, ); - name = Style; + path = WordPressShareExtension; sourceTree = ""; }; - B59B18771CC7FC190055EB7C /* Controllers */ = { + 937D9A0D19F837ED007B9D5F /* 22-23 */ = { isa = PBXGroup; children = ( - E1B912881BB01288003C25B9 /* PeopleViewController.swift */, - B59B18741CC7FB8D0055EB7C /* PersonViewController.swift */, - B56FEB781CD8E13C00E621F9 /* RoleViewController.swift */, - B549BA671CF7447E0086C608 /* InvitePersonViewController.swift */, + 937D9A1019F838C2007B9D5F /* AccountToAccount22to23.swift */, ); - name = Controllers; + name = "22-23"; sourceTree = ""; }; - B59B18781CC7FC230055EB7C /* Views */ = { + 937E3AB41E3EBDC900CDA01A /* Post */ = { isa = PBXGroup; children = ( - E1B912841BB01266003C25B9 /* PeopleCell.swift */, - E1B912821BB01047003C25B9 /* PeopleRoleBadgeLabel.swift */, - 7E7B4CF720459E21001463D6 /* PersonHeaderCell.swift */, + DC06DFFA27BD678100969974 /* Prepublishing Nudge */, + 57E3C98223835A57004741DB /* Controllers */, + 57DF04BF2314895E00CC93D6 /* Views */, + 5749984522FA0EB900CE86ED /* Utils */, + 937E3AB51E3EBE1600CDA01A /* PostEditorStateTests.swift */, + E10F3DA01E5C2CE0008FAADA /* PostListFilterTests.swift */, + E684383F221F5A2200752258 /* PostListExcessiveLoadMoreTests.swift */, + 570BFD8C22823DE5007859A8 /* PostActionSheetTests.swift */, + 57AA8492228790AA00D3C2A2 /* PostCardCellTests.swift */, + 577C2AAA22936DCB00AD1F03 /* PostCardCellGhostableTests.swift */, + 57D6C83D22945A10003DDC7E /* PostCompactCellTests.swift */, + 575E126222973EBB0041B3EB /* PostCompactCellGhostableTests.swift */, + 570265162298960B00F2214C /* PostListTableViewHandlerTests.swift */, ); - name = Views; + name = Post; sourceTree = ""; }; - B59B18791CC7FC330055EB7C /* ViewModels */ = { + 93B853211B44165B0064FE72 /* Analytics */ = { isa = PBXGroup; children = ( - E166FA1A1BB0656B00374B5B /* PeopleCellViewModel.swift */, + 93B853221B4416A30064FE72 /* WPAnalyticsTrackerAutomatticTracksTests.m */, ); - name = ViewModels; + name = Analytics; sourceTree = ""; }; - B5AEEC731ACACF3B008BF2A4 /* Core Data */ = { + 93FA59DA18D88BDB001446BC /* Services */ = { isa = PBXGroup; children = ( - 93E9050319E6F242005513C9 /* ContextManagerTests.swift */, - B5ECA6CC1DBAAD510062D7E0 /* CoreDataHelperTests.swift */, - 931D26FF19EDAE8600114F17 /* CoreDataMigrationTests.m */, + F4EDAA4F29A66DA900622D3D /* Reader Post */, + 5D44EB361986D8BA008B7175 /* ReaderSiteService.h */, + F4F09CCB2989BF5B00A5F420 /* ReaderSiteService_Internal.h */, + 5D44EB371986D8BA008B7175 /* ReaderSiteService.m */, + F48D44B92989A58C0051EAA6 /* ReaderSiteService.swift */, + B5EFB1C31B31B99D007608A3 /* Facades */, + 46E327D524E71B2F000944B3 /* Page Layouts */, + F504D2A925D60C5900A2764C /* Stories */, + 93C1147D18EC5DD500DAC95C /* AccountService.h */, + 93C1147E18EC5DD500DAC95C /* AccountService.m */, + E62CE58D26B1D14200C9D147 /* AccountService+Cookies.swift */, + E6C0ED3A231DA23400A08B57 /* AccountService+MergeDuplicates.swift */, + E1FD45DF1C030B3800750F4C /* AccountSettingsService.swift */, + F1ADCAF6241FEF0C00F150D2 /* AtomicAuthenticationService.swift */, + F12FA5D82428FA8F0054DA21 /* AuthenticationService.swift */, + FA4BC0CF2996A589005EB077 /* BlazeService.swift */, + 46F584812624DCC80010A723 /* BlockEditorSettingsService.swift */, + FEA6517A281C491C002EA086 /* BloggingPromptsService.swift */, + 822D60B81F4CCC7A0016C46D /* BlogJetpackSettingsService.swift */, + 93C1148318EDF6E100DAC95C /* BlogService.h */, + 93C1148418EDF6E100DAC95C /* BlogService.m */, + 9A341E5221997A1E0036662E /* BlogService+BlogAuthors.swift */, + 8320BDE4283D9359009DF2DE /* BlogService+BloggingPrompts.swift */, + F1BC842D27035A1800C39993 /* BlogService+Domains.swift */, + 9A2D0B22225CB92B009E585F /* BlogService+JetpackConvenience.swift */, + 176BA53A268266DE0025E4A3 /* BlogService+Reminders.swift */, + E1556CF0193F6FE900FC52EA /* CommentService.h */, + E1556CF1193F6FE900FC52EA /* CommentService.m */, + 982DA9A6263B1E2F00E5743B /* CommentService+Likes.swift */, + FEFC0F882731182C001F7F1D /* CommentService+Replies.swift */, + AB2211D125ED68E300BF72FC /* CommentServiceRemoteFactory.swift */, + E16A76F21FC4766900A661E3 /* CredentialsService.swift */, + 1702BBDF1CF3034E00766A33 /* DomainsService.swift */, + 7E7BEF7222E1DD27009A880D /* EditorSettingsService.swift */, + FA00863C24EB68B100C863F2 /* FollowCommentsService.swift */, + B5772AC31C9C7A070031F97E /* GravatarService.swift */, + 17D9362624769579008B2205 /* HomepageSettingsService.swift */, + FAB8FD6D25AEB23600D5D54A /* JetpackBackupService.swift */, + 8B749E7125AF522900023F03 /* JetpackCapabilitiesService.swift */, + FAB8AB8A25AFFE7500F9F8A0 /* JetpackRestoreService.swift */, + 8C6A22E325783D2000A79950 /* JetpackScanService.swift */, + C7A09A4928401B7B003096ED /* QRLoginService.swift */, + 9A2D0B35225E2511009E585F /* JetpackService.swift */, + 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */, + 9804A096263780B400354097 /* LikeUserHelpers.swift */, + 930284B618EAF7B600CB0BF4 /* LocalCoreDataService.h */, + FFC6ADD91B56F366002F3C84 /* LocalCoreDataService.m */, + 4A526BDE296BE9A50007B5BA /* CoreDataService.h */, + 4A526BDD296BE9A50007B5BA /* CoreDataService.m */, + 98921EF621372E12004949AA /* MediaCoordinator.swift */, + 0815CF451E96F22600069916 /* MediaImportService.swift */, + 5DA3EE141925090A00294E0B /* MediaService.h */, + 5DA3EE151925090A00294E0B /* MediaService.m */, + FF5371621FDFF64F00619A3F /* MediaService.swift */, + FF8791BA1FBAF4B400AD86E6 /* MediaService+Swift.swift */, + E1C5457D1C6B962D001CEB0E /* MediaSettings.swift */, + FF4C069E206560E500E0B2BC /* MediaThumbnailCoordinator.swift */, + 087EBFA71F02313E001F7ACE /* MediaThumbnailService.swift */, + 08AAD69D1CBEA47D002B2418 /* MenusService.h */, + 08AAD69E1CBEA47D002B2418 /* MenusService.m */, + B5015C571D4FDBB300C9449E /* NotificationActionsService.swift */, + B5EFB1C11B31B98E007608A3 /* NotificationSettingsService.swift */, + 73ACDF982114FE4500233AD4 /* NotificationSupportService.swift */, + B5F67AC61DB7D81300482C62 /* NotificationSyncMediator.swift */, + 8BC6020823900D8400EFE3D0 /* NullBlogPropertySanitizer.swift */, + 4631359024AD013F0017E65C /* PageCoordinator.swift */, + E1209FA31BB4978B00D69778 /* PeopleService.swift */, + E1D7FF371C74EB0E00E7E5E5 /* PlanService.swift */, + 57C2331722FE0EC900A3863B /* PostAutoUploadInteractor.swift */, + 93FA59DB18D88C1C001446BC /* PostCategoryService.h */, + 93FA59DC18D88C1C001446BC /* PostCategoryService.m */, + FF0D8145205809C8000EE505 /* PostCoordinator.swift */, + 8B5E1DD727EA5929002EBEE3 /* PostCoordinator+Dashboard.swift */, + E1A6DBE319DC7D230071AC1E /* PostService.h */, + E1A6DBE419DC7D230071AC1E /* PostService.m */, + 98E0829E2637545C00537BF1 /* PostService+Likes.swift */, + 9A4F8F55218B88E000EEDCCC /* PostService+Revisions.swift */, + 2F08ECFB2283A4FB000F8E11 /* PostService+UnattachedMedia.swift */, + 08472A1E1C7273FA0040769D /* PostServiceOptions.h */, + 08472A1F1C727E020040769D /* PostServiceOptions.m */, + 57D66B99234BB206005A2D74 /* PostServiceRemoteFactory.swift */, + 8B6EA62223FDE50B004BA312 /* PostServiceUploadingList.swift */, + 082AB9D71C4EEEF4000CA523 /* PostTagService.h */, + 082AB9D81C4EEEF4000CA523 /* PostTagService.m */, + B535209C1AF7EB9F00B33BA8 /* PushAuthenticationService.swift */, + 8BB185C524B5FB8500A4CCE8 /* ReaderCardService.swift */, + 8B16CE9925251C89007BE5A9 /* ReaderPostStreamService.swift */, + FA8E1F7625EEFA7300063673 /* ReaderPostService+RelatedPosts.swift */, + E62079E01CF7A61200F5CD46 /* ReaderSearchSuggestionService.swift */, + 17CE77EC20C6C2F3001DEA5A /* ReaderSiteSearchService.swift */, + 5DBCD9D318F35D7500B32229 /* ReaderTopicService.h */, + 5DBCD9D418F35D7500B32229 /* ReaderTopicService.m */, + 321955C224BF77E400E3F316 /* ReaderTopicService+FollowedInterests.swift */, + 3236F79D24AE75790088E8F3 /* ReaderTopicService+Interests.swift */, + 3234BB072530D7DC0068DA40 /* ReaderTopicService+SiteInfo.swift */, + 9F3EFCA0208E305D00268758 /* ReaderTopicService+Subscriptions.swift */, + E102B78F1E714F24007928E8 /* RecentSitesService.swift */, + E1D28E921F2F6EB500A5DAFD /* RoleService.swift */, + 930F09161C7D110E00995926 /* ShareExtensionService.swift */, + E616E4B21C480896002C024E /* SharingService.swift */, + 4A1E77C5298897F6006281CC /* SharingSyncService.swift */, + D82253DB2199411F0014D0E2 /* SiteAddressService.swift */, + 73178C3021BEE45300E37C9A /* SiteAssembly.swift */, + 73178C2E21BEE1F500E37C9A /* SiteAssemblyService.swift */, + FA4ADAD71C50687400F858D7 /* SiteManagementService.swift */, + D8CB561F2181A8CE00554EAE /* SiteSegmentsService.swift */, + B0F2EFBE259378E600C7EB6D /* SiteSuggestionService.swift */, + D8A468E421828D940094B82F /* SiteVerticalsService.swift */, + B03B9233250BC593000A40AF /* SuggestionService.swift */, + 59A9AB331B4C33A500A433DC /* ThemeService.h */, + 59A9AB341B4C33A500A433DC /* ThemeService.m */, + 93DEB88019E5BF7100F9546D /* TodayExtensionService.h */, + 93DEB88119E5BF7100F9546D /* TodayExtensionService.m */, + E6311C401EC9FF4A00122529 /* UsersService.swift */, + B543D2B420570B5A00D3D4CC /* WordPressComSyncService.swift */, + 010459E529153FFF000C7778 /* JetpackNotificationMigrationService.swift */, + FED77257298BC5B300C2346E /* PluginJetpackProxyService.swift */, ); - name = "Core Data"; + path = Services; sourceTree = ""; }; - B5AEEC7E1ACAD088008BF2A4 /* Services */ = { + 98077B58207555E400109F95 /* Support */ = { isa = PBXGroup; children = ( - 9363113E19FA996700B0C739 /* AccountServiceTests.swift */, - E150520B16CAC5C400D3DDDC /* BlogJetpackTest.m */, - 930FD0A519882742000CC81D /* BlogServiceTest.m */, - E18549DA230FBFEF003C620E /* BlogServiceDeduplicationTests.swift */, - 74585B981F0D58F300E7E667 /* DomainsServiceTests.swift */, - B5772AC51C9C84900031F97E /* GravatarServiceTests.swift */, - 59A9AB391B4C3ECD00A433DC /* LocalCoreDataServiceTests.m */, - F11023A0231863CE00C4E84A /* MediaServiceTests.swift */, - E1C5457F1C6C79BB001CEB0E /* MediaSettingsTests.swift */, - 08AAD6A01CBEA610002B2418 /* MenusServiceTests.m */, - E135965C1E7152D1006C6606 /* RecentSitesServiceTests.swift */, - B5EFB1C81B333C5A007608A3 /* NotificationSettingsServiceTests.swift */, - B532ACCE1DC3AB8E00FFFA57 /* NotificationSyncMediatorTests.swift */, - 8BC6020C2390412000EFE3D0 /* NullBlogPropertySanitizerTests.swift */, - 08A2AD7A1CCED8E500E84454 /* PostCategoryServiceTests.m */, - 8BC12F71231FEBA1004DDA72 /* PostCoordinatorTests.swift */, - 8B821F3B240020E2006B697E /* PostServiceUploadingListTests.swift */, - 8BC12F7623201B86004DDA72 /* PostService+MarkAsFailedAndDraftIfNeededTests.swift */, - 08A2AD781CCED2A800E84454 /* PostTagServiceTests.m */, - 5DE8A0401912D95B00B2FF59 /* ReaderPostServiceTest.m */, - E66969C71B9E0A6800EC9C00 /* ReaderTopicServiceTest.swift */, - FA4ADAD91C509FE400F858D7 /* SiteManagementServiceTests.swift */, - 59FBD5611B5684F300734466 /* ThemeServiceTests.m */, - 40E4698E2017E0700030DB5F /* PluginDirectoryEntryStateTests.swift */, - 7E8980B822E73F4000C567B0 /* EditorSettingsServiceTests.swift */, - 570B037622F1FFF6009D8411 /* PostCoordinatorFailedPostsFetcherTests.swift */, - 57569CF1230485680052EE14 /* PostAutoUploadInteractorTests.swift */, - 57D66B9C234BB78B005A2D74 /* PostServiceWPComTests.swift */, - 57240223234E5BE200227067 /* PostServiceSelfHostedTests.swift */, - 575802122357C41200E4C63C /* MediaCoordinatorTests.swift */, + 98077B592075561800109F95 /* SupportTableViewController.swift */, + F4CBE3D5292597E3004FFBB6 /* SupportTableViewControllerConfiguration.swift */, + F4CBE3D829265BC8004FFBB6 /* LogOutActionHandler.swift */, + 0141929B2983F0A300CAEDB0 /* SupportConfiguration.swift */, ); - name = Services; + path = Support; sourceTree = ""; }; - B5AEEC7F1ACAD099008BF2A4 /* Categories */ = { + 981676D3221B7A2C00B81C3F /* Countries */ = { isa = PBXGroup; children = ( - B59D40A51DB522DF003D2D79 /* NSAttributedStringTests.swift */, - B566EC741B83867800278395 /* NSMutableAttributedStringTests.swift */, + 9A76C32D22AFD9EB00F5D819 /* Map */, + 981676D4221B7A4300B81C3F /* CountriesCell.swift */, + 981676D5221B7A4300B81C3F /* CountriesCell.xib */, ); - name = Categories; + path = Countries; sourceTree = ""; }; - B5BEA5661C7CEB4400C8035B /* Supporting Files */ = { + 9826AE7F21B5C63800C851FA /* Latest Post Summary */ = { isa = PBXGroup; children = ( - B50248B91C96FFCC00AFBDED /* Info.plist */, - 93C3C2581CAB032C0092F837 /* Info-Alpha.plist */, - 93C3C2561CAB031E0092F837 /* Info-Internal.plist */, - B50248BD1C96FFCC00AFBDED /* WordPressShare.entitlements */, - B50248BA1C96FFCC00AFBDED /* WordPressShare-Alpha.entitlements */, - B50248BB1C96FFCC00AFBDED /* WordPressShare-Internal.entitlements */, - B50248BC1C96FFCC00AFBDED /* WordPressShare-Lumberjack.m */, - B50248BE1C96FFCC00AFBDED /* WordPressSharePrefix.pch */, - E1C5B2131E54C28C00052319 /* Localizable.strings */, + 9826AE8021B5C6A700C851FA /* LatestPostSummaryCell.swift */, + 9826AE8121B5C6A700C851FA /* LatestPostSummaryCell.xib */, ); - name = "Supporting Files"; + path = "Latest Post Summary"; sourceTree = ""; }; - B5C9401B1DB901120079D4FF /* Account */ = { + 9826AE8421B5C6D500C851FA /* Posting Activity */ = { isa = PBXGroup; children = ( - B5C940191DB900DC0079D4FF /* AccountHelper.swift */, + 9826AE8E21B5D3CD00C851FA /* PostingActivityCell.swift */, + 9826AE8F21B5D3CD00C851FA /* PostingActivityCell.xib */, + 981C986C21B9D71400A7C0C8 /* PostingActivityCollectionViewCell.swift */, + 9826AE8521B5C72300C851FA /* PostingActivityDay.swift */, + 9826AE8721B5C73400C851FA /* PostingActivityDay.xib */, + 9848DF8021B8BB5600B99DA4 /* PostingActivityLegend.swift */, + 9848DF8221B8BB6900B99DA4 /* PostingActivityLegend.xib */, + 9826AE8921B5CC7300C851FA /* PostingActivityMonth.swift */, + 9826AE8B21B5CC8D00C851FA /* PostingActivityMonth.xib */, + 981C986A21B9BF6000A7C0C8 /* PostingActivityViewController.storyboard */, + 981C986621B9BDF300A7C0C8 /* PostingActivityViewController.swift */, ); - name = Account; + path = "Posting Activity"; sourceTree = ""; }; - B5C9401D1DB901F30079D4FF /* Reachability */ = { + 982DDE1226320B4A002B3904 /* Likes */ = { isa = PBXGroup; children = ( - 5D3E334C15EEBB6B005FC6F2 /* ReachabilityUtils.h */, - 5D3E334D15EEBB6B005FC6F2 /* ReachabilityUtils.m */, - 822876F01E929CFD00696BF7 /* ReachabilityUtils+OnlineActions.swift */, + 982DDF8C263238A6002B3904 /* LikeUser+CoreDataClass.swift */, + 982DDF8D263238A6002B3904 /* LikeUser+CoreDataProperties.swift */, + 982DDF8E263238A6002B3904 /* LikeUserPreferredBlog+CoreDataClass.swift */, + 982DDF8F263238A6002B3904 /* LikeUserPreferredBlog+CoreDataProperties.swift */, ); - name = Reachability; + path = Likes; sourceTree = ""; }; - B5DB8AF51C949DC70059196A /* ImmuTable */ = { + 98467A3B221CD30600DF51AE /* Stats Detail */ = { isa = PBXGroup; children = ( - E1EBC36E1C118EA500F638E0 /* ImmuTable.swift */, - E1E49CE31C4902EE002393A4 /* ImmuTableViewController.swift */, - E12FE0731FA0CEE000F28710 /* ImmuTable+Optional.swift */, - E13A8C9A1C3E6EF2005BB1C1 /* ImmuTable+WordPress.swift */, - B5DB8AF31C949DC20059196A /* WPImmuTableRows.swift */, + 98467A42221CD74D00DF51AE /* SiteStatsDetailTableViewController.storyboard */, + 98467A3E221CD48500DF51AE /* SiteStatsDetailTableViewController.swift */, + 9829162E2224BC1C008736C0 /* SiteStatsDetailsViewModel.swift */, + DCCDF75A283BEFEA00AA347E /* SiteStatsInsightsDetailsTableViewController.swift */, + DCCDF75D283BF02D00AA347E /* SiteStatsInsightsDetailsViewModel.swift */, + 987535612282682D001661B4 /* DetailDataCell.swift */, + 987535622282682D001661B4 /* DetailDataCell.xib */, + DC9AF768285DF8A300EA2A0D /* StatsFollowersChartViewModel.swift */, + 1762B6DB2845510400F270A5 /* StatsReferrersChartViewModel.swift */, ); - name = ImmuTable; + path = "Stats Detail"; sourceTree = ""; }; - B5DCD0C21C69328E00C9B431 /* Settings */ = { + 984B138C21F65F680004B6A2 /* Period Stats */ = { isa = PBXGroup; children = ( + 98797DB9222F431100128C21 /* Overview */, + 981676D3221B7A2C00B81C3F /* Countries */, + 984B138D21F65F860004B6A2 /* SiteStatsPeriodTableViewController.swift */, + 984B139121F66AC50004B6A2 /* SiteStatsPeriodViewModel.swift */, ); - name = Settings; + path = "Period Stats"; sourceTree = ""; }; - B5E23BD919AD0CED000D6879 /* Tools */ = { + 984B4EF120742FB900F87888 /* Zendesk */ = { isa = PBXGroup; children = ( - B54E1DF31A0A7BBF00807537 /* NotificationMediaDownloader.swift */, - B54075D31D3D7D5B0095C318 /* IntrinsicTableView.swift */, - B56695AF1D411EEB007E342F /* KeyboardDismissHelper.swift */, - B5C0CF3C204DA41000DB0362 /* NotificationReplyStore.swift */, + 984B4EF220742FCC00F87888 /* ZendeskUtils.swift */, ); - path = Tools; + name = Zendesk; sourceTree = ""; }; - B5ECA6CB1DBAA0110062D7E0 /* CoreData */ = { + 985793C522F23CD800643DBF /* Insights Management */ = { isa = PBXGroup; children = ( - C545E0A01811B9880020844C /* ContextManager.h */, - 93EF094B19ED4F1100C89770 /* ContextManager-Internals.h */, - C545E0A11811B9880020844C /* ContextManager.m */, - E1E5EE36231E47A80018E9E3 /* ContextManager+ErrorHandling.swift */, - B5ECA6C91DBAA0020062D7E0 /* CoreDataHelper.swift */, + 985793C622F23D7000643DBF /* CustomizeInsightsCell.swift */, + 985793C722F23D7000643DBF /* CustomizeInsightsCell.xib */, + 983002A722FA05D600F03DBB /* InsightsManagementViewController.swift */, ); - name = CoreData; + path = "Insights Management"; sourceTree = ""; }; - B5EFB1C31B31B99D007608A3 /* Facades */ = { + 986C908222319ECF00FC31E1 /* Post Stats */ = { isa = PBXGroup; children = ( - 85D239A11AE5A5FC0074768D /* BlogSyncFacade.h */, - 85D239A21AE5A5FC0074768D /* BlogSyncFacade.m */, + 986C908522319F2600FC31E1 /* PostStatsTableViewController.storyboard */, + 986C908322319EFF00FC31E1 /* PostStatsTableViewController.swift */, + 80C740FA2989FC4600199027 /* PostStatsTableViewController+JetpackBannerViewController.swift */, + 986C90872231AD6200FC31E1 /* PostStatsViewModel.swift */, + 98FCFC212231DF43006ECDD4 /* PostStatsTitleCell.swift */, + 98FCFC222231DF43006ECDD4 /* PostStatsTitleCell.xib */, ); - name = Facades; + path = "Post Stats"; sourceTree = ""; }; - B5FA22841C99F6340016CA7C /* Tracks */ = { + 9870EF7827866DCE00F3BB54 /* Comments */ = { isa = PBXGroup; children = ( - B5FA22821C99F6180016CA7C /* Tracks.swift */, - B504F5F41C9C2BD000F8B1C6 /* Tracks+ShareExtension.swift */, + 982D261E2788DDF200A41286 /* ReaderCommentsFollowPresenter.swift */, + FE25C234271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift */, + 5DF8D25F19E82B1000A2CD95 /* ReaderCommentsViewController.h */, + 5DF8D26019E82B1000A2CD95 /* ReaderCommentsViewController.m */, + B0AC50DC251E96270039E022 /* ReaderCommentsViewController.swift */, ); - name = Tracks; + path = Comments; sourceTree = ""; }; - B5FA22851C99F63B0016CA7C /* Extensions */ = { + 9872CB36203BB53F0066A293 /* Epilogues */ = { isa = PBXGroup; children = ( - B5552D7D1CD101A600B26DF6 /* NSExtensionContext+Extensions.swift */, - B5552D7F1CD1028C00B26DF6 /* String+Extensions.swift */, - B5FA868A1D10A41600AB5F7E /* UIImage+Extensions.swift */, - 74AC1DA0200D0CC300973CAD /* UINavigationController+Extensions.swift */, - 74BC35B720499EEB00AC1525 /* RemotePostCategory+Extensions.swift */, - CE39E17120CB117B00CABA05 /* RemoteBlog+Capabilities.swift */, + 98579BC5203DD86D004086E4 /* EpilogueSectionHeaderFooter.swift */, + 98579BC6203DD86E004086E4 /* EpilogueSectionHeaderFooter.xib */, + 9808655B203D079A00D58786 /* EpilogueUserInfoCell.swift */, + 98086559203D075D00D58786 /* EpilogueUserInfoCell.xib */, + 98A25BD0203CB25F006A5807 /* SignupEpilogueCell.swift */, + 98A25BD2203CB278006A5807 /* SignupEpilogueCell.xib */, ); - name = Extensions; + name = Epilogues; sourceTree = ""; }; - B5FD4523199D0F1100286FBB /* Views */ = { + 98747670219638990080967F /* Extensions */ = { isa = PBXGroup; children = ( - 2FA37B19215724E900C80377 /* LongPressGestureLabel.swift */, - B57B99D419A2C20200506504 /* NoteTableHeaderView.swift */, - B5683DB71B6C03810043447C /* NoteTableHeaderView.xib */, - B52C4C7E199D74AE009FD823 /* NoteTableViewCell.swift */, - B5E23BDE19AD0D00000D6879 /* NoteTableViewCell.xib */, - B50421E81B68170F008EEA82 /* NoteUndoOverlayView.swift */, - B50421E61B680839008EEA82 /* NoteUndoOverlayView.xib */, - B532D4E7199D4357006E4DF6 /* NoteBlockTableViewCell.swift */, - B532D4E6199D4357006E4DF6 /* NoteBlockHeaderTableViewCell.swift */, - B5C66B6F1ACF06CA00F68370 /* NoteBlockHeaderTableViewCell.xib */, - B532D4E8199D4357006E4DF6 /* NoteBlockTextTableViewCell.swift */, - B5C66B711ACF071000F68370 /* NoteBlockTextTableViewCell.xib */, - B57AF5F91ACDC73D0075A7D2 /* NoteBlockActionsTableViewCell.swift */, - B5C66B731ACF071F00F68370 /* NoteBlockActionsTableViewCell.xib */, - B532D4E5199D4357006E4DF6 /* NoteBlockCommentTableViewCell.swift */, - B5C66B751ACF072C00F68370 /* NoteBlockCommentTableViewCell.xib */, - B532D4ED199D4418006E4DF6 /* NoteBlockImageTableViewCell.swift */, - B5C66B771ACF073900F68370 /* NoteBlockImageTableViewCell.xib */, - B52C4C7C199D4CD3009FD823 /* NoteBlockUserTableViewCell.swift */, - B5C66B791ACF074600F68370 /* NoteBlockUserTableViewCell.xib */, + 981C82B52193A7B900A06E84 /* Double+Stats.swift */, + 986DD19B218D002500D28061 /* WPStyleGuide+Stats.swift */, + 98487E3921EE8FB500352B4E /* UITableViewCell+Stats.swift */, + FAB9826D2697038700B172A3 /* StatsViewController+JetpackSettings.swift */, + FAB985C02697550C00B172A3 /* NoResultsViewController+StatsModule.swift */, ); - path = Views; + path = Extensions; sourceTree = ""; }; - B5FD453E199D0F2800286FBB /* Controllers */ = { + 98747671219638BF0080967F /* Insights */ = { isa = PBXGroup; children = ( - B5DBE4FD1D21A700002E81D3 /* NotificationsViewController.swift */, - 4388FEFD20A4E0B900783948 /* NotificationsViewController+AppRatings.swift */, - B5120B371D47CC6C0059361A /* NotificationDetailsViewController.swift */, - 7E987F552108017B00CAFB88 /* NotificationContentRouter.swift */, - B522C4F71B3DA79B00E47B59 /* NotificationSettingsViewController.swift */, - B52F8CD71B43260C00D36025 /* NotificationSettingStreamsViewController.swift */, - B5899ADD1B419C560075A3D6 /* NotificationSettingDetailsViewController.swift */, - 9F8E38BD209C6DE200454E3C /* NotificationSiteSubscriptionViewController.swift */, - B5F9959F1B59708C00AB0B3E /* NotificationSettingsViewController.xib */, - 8236EB4F2024ED8C007C7CF9 /* NotificationsViewController+JetpackPrompt.swift */, - 4388FEFF20A4E19C00783948 /* NotificationsViewController+PushPrimer.swift */, + 985793C522F23CD800643DBF /* Insights Management */, + 9826AE7F21B5C63800C851FA /* Latest Post Summary */, + 9826AE8421B5C6D500C851FA /* Posting Activity */, + 98880A4722B2E3FC00464538 /* Two Column Stats */, + DC772AF7282009FC00664C02 /* ViewsVisitors */, + DCF892C8282FA37100BB71E1 /* SiteStatsBaseTableViewController.swift */, + 988056022183CCE50083B643 /* SiteStatsInsightsTableViewController.swift */, + 9865257C2194D77E0078B916 /* SiteStatsInsightsViewModel.swift */, + 93F7214E271831820021A09F /* SiteStatsPinnedItemStore.swift */, + 934098C2271957A600B3E77E /* SiteStatsInsightsDelegate.swift */, + 934098BF2719577D00B3E77E /* InsightType.swift */, + 98563DDB21BF30C40006F5E9 /* TabbedTotalsCell.swift */, + 98563DDC21BF30C40006F5E9 /* TabbedTotalsCell.xib */, + 176CE91527FB44C100F1E32B /* StatsBaseCell.swift */, + 17ABD3512811A48900B1E9CB /* StatsMostPopularTimeInsightsCell.swift */, + 17870A6F2816F2A000D1C627 /* StatsLatestPostSummaryInsightsCell.swift */, + 17870A73281FBEC000D1C627 /* StatsTotalInsightsCell.swift */, ); - path = Controllers; + path = Insights; sourceTree = ""; }; - BE1071FD1BC75FCF00906AFF /* Style */ = { + 98747674219644E40080967F /* Helpers */ = { isa = PBXGroup; children = ( - BE1071FE1BC75FFA00906AFF /* WPStyleGuide+BlogTests.swift */, + 730D290E22976F1A0004BB1E /* BottomScrollAnalyticsTracker.swift */, + 9874767221963D320080967F /* SiteStatsInformation.swift */, + 98B52AE021F7AF4A006FF6B4 /* StatsDataHelper.swift */, + 98CAD295221B4ED1003E8F45 /* StatSection.swift */, + 17D4153B22C2308D006378EF /* StatsPeriodHelper.swift */, + DCF892CB282FA3BB00BB71E1 /* SiteStatsImmuTableRows.swift */, ); - name = Style; + path = Helpers; sourceTree = ""; }; - BE20F5E11B2F738E0020694C /* ViewRelated */ = { + 98797DB9222F431100128C21 /* Overview */ = { isa = PBXGroup; children = ( - 32F1A6D123F710A300AB8CA9 /* NUX */, - 8BD36E042395CC2F00EFFF1C /* Aztec */, - 325D3B3A23A8372500766DF6 /* Comments */, - BEC8A3FD1B4BAA08001CB8C3 /* Blog */, - E1B921BA1C0ED481003EA3CB /* Cells */, - 8B7623352384372200AB3EE7 /* Pages */, - 937E3AB41E3EBDC900CDA01A /* Post */, - BE20F5E21B2F739F0020694C /* System */, - 732A4738218786F30015DA74 /* Views */, + 98797DBA222F434500128C21 /* OverviewCell.swift */, + 98797DBB222F434500128C21 /* OverviewCell.xib */, ); - name = ViewRelated; + path = Overview; sourceTree = ""; }; - BE20F5E21B2F739F0020694C /* System */ = { + 987E79C9261F8857000192B7 /* User Profile Sheet */ = { isa = PBXGroup; children = ( + 9856A3E3261FD27A008D6354 /* UserProfileSectionHeader.swift */, + 98E5D4912620C2B40074A56A /* UserProfileSectionHeader.xib */, + 987E79CA261F8857000192B7 /* UserProfileSheetViewController.swift */, + 9822A8402624CFB900FD8A03 /* UserProfileSiteCell.swift */, + 9822A8542624D01800FD8A03 /* UserProfileSiteCell.xib */, + 9856A39C261FC21E008D6354 /* UserProfileUserInfoCell.swift */, + 9856A388261FC206008D6354 /* UserProfileUserInfoCell.xib */, ); - name = System; + path = "User Profile Sheet"; sourceTree = ""; }; - BE2B4E991FD6640E007AE3E4 /* Screens */ = { + 98880A4722B2E3FC00464538 /* Two Column Stats */ = { isa = PBXGroup; children = ( - CC5218982279CF06008998CE /* Media */, - BED4D83D1FF13C7300A11345 /* Login */, - CC7CB97422B159FE00642EE9 /* Signup */, - BED4D83C1FF13C6200A11345 /* Editor */, - CC2BB0CC2289D0F20034F9AB /* Me */, - BE2B4E9E1FD664F5007AE3E4 /* BaseScreen.swift */, - BE6DD32D1FD67EDA00E55192 /* MySitesScreen.swift */, - BE6DD32F1FD67F3B00E55192 /* TabNavComponent.swift */, - BE6DD3311FD6803700E55192 /* MeTabScreen.swift */, - BE8707162006B774004FB5A4 /* MySiteScreen.swift */, - BE8707182006E48E004FB5A4 /* ReaderScreen.swift */, - 7EF9F65622F03C9200F79BBF /* SiteSettingsScreen.swift */, - BE87071A2006E65C004FB5A4 /* NotificationsScreen.swift */, - CC94FC692214532D002E5825 /* FancyAlertComponent.swift */, - F9C47A8B238C801600AAD9ED /* PostsScreen.swift */, - F97DA41F23D67B820050E791 /* MediaScreen.swift */, - F9C47A8E238C9D6400AAD9ED /* StatsScreen.swift */, + 98D52C3022B1CFEB00831529 /* StatsTwoColumnRow.swift */, + 98D52C3122B1CFEC00831529 /* StatsTwoColumnRow.xib */, + 98880A4822B2E5E400464538 /* TwoColumnCell.swift */, + 98880A4922B2E5E400464538 /* TwoColumnCell.xib */, ); - path = Screens; + path = "Two Column Stats"; sourceTree = ""; }; - BE2B4EA01FD6653E007AE3E4 /* Utils */ = { + 989064F9237CC1A300218CD2 /* Data */ = { isa = PBXGroup; children = ( - FF2716A01CABC7D40006E2D4 /* XCTest+Extensions.swift */, - BE2B4EA11FD6654A007AE3E4 /* Logger.swift */, - 1AE0F2B02297F7E9000BDD7F /* WireMock.swift */, + 98BFF57D23984344008A1DCB /* AllTimeWidgetStats.swift */, + 98F93181239AF64800E4E96E /* ThisWeekWidgetStats.swift */, + 98E58A2E2360D23400E5534B /* TodayWidgetStats.swift */, ); - path = Utils; + path = Data; sourceTree = ""; }; - BE6AB7FB1BC62E0B00D980FC /* Style */ = { + 98A047702821BFB6001B4E2D /* Blogging Prompts */ = { isa = PBXGroup; children = ( - BE1071FB1BC75E7400906AFF /* WPStyleGuide+Blog.swift */, + 98A047712821CEBF001B4E2D /* BloggingPromptsViewController.swift */, + 98A047742821D069001B4E2D /* BloggingPromptsViewController.storyboard */, + 98517E5828220411001FFD45 /* BloggingPromptTableViewCell.swift */, + 98517E5B28220474001FFD45 /* BloggingPromptTableViewCell.xib */, + FEC2602F283FBA1A003D886A /* BloggingPromptCoordinator.swift */, + FEC26032283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift */, ); - name = Style; + path = "Blogging Prompts"; sourceTree = ""; }; - BE87E19E1BD4052F0075D45B /* 3DTouch */ = { + 98A9E48527F6238B00ECA96D /* Blogging Prompts */ = { isa = PBXGroup; children = ( - BE87E1A11BD405790075D45B /* WP3DTouchShortcutHandler.swift */, - BE87E19F1BD4054F0075D45B /* WP3DTouchShortcutCreator.swift */, + 98BC522327F6245700D6E8C2 /* BloggingPromptsFeatureIntroduction.swift */, + 98BC522927F6259700D6E8C2 /* BloggingPromptsFeatureDescriptionView.swift */, + 98BC522C27F62B6300D6E8C2 /* BloggingPromptsFeatureDescriptionView.xib */, + 9887560B2810BA7A00AD7589 /* BloggingPromptsIntroductionPresenter.swift */, ); - name = 3DTouch; + path = "Blogging Prompts"; sourceTree = ""; }; - BEA0E4821BD8353B000AEE81 /* System */ = { + 98AA22B925082A5B005CCC13 /* Unified */ = { isa = PBXGroup; children = ( - BEA0E4831BD83545000AEE81 /* 3DTouch */, + 98AA22BC25082A86005CCC13 /* PrologueScreen.swift */, + 98AA22BE25082B1E005CCC13 /* GetStartedScreen.swift */, + 98ADDDD925083CA9008FF6EE /* PasswordScreen.swift */, ); - name = System; + path = Unified; sourceTree = ""; }; - BEA0E4831BD83545000AEE81 /* 3DTouch */ = { + 98AA9F1F27EA888C00B3A98C /* Feature Introduction */ = { isa = PBXGroup; children = ( - BEA0E4841BD83565000AEE81 /* WP3DTouchShortcutCreatorTests.swift */, + 98AA9F2027EA890800B3A98C /* FeatureIntroductionViewController.swift */, + B0088A8F283D68B1008C9676 /* Stats Revamp v2 */, + 98A9E48527F6238B00ECA96D /* Blogging Prompts */, ); - name = 3DTouch; + path = "Feature Introduction"; sourceTree = ""; }; - BEA101B61FF13F0500CE5C7D /* Tests */ = { + 98C43E841FE98041006FEF54 /* Social Signup */ = { isa = PBXGroup; children = ( - FF2716911CAAC87B0006E2D4 /* LoginTests.swift */, - CC7CB97222B1510900642EE9 /* SignupTests.swift */, - FFA0B7D61CAC1F9F00533B9D /* MainNavigationTests.swift */, - BED4D82F1FF11DEF00A11345 /* EditorAztecTests.swift */, - CC2BB0CF228ACF710034F9AB /* EditorGutenbergTests.swift */, + 98656BD72037A1770079DE67 /* SignupEpilogueViewController.swift */, + 9872CB2F203B8A730066A293 /* SignupEpilogueTableViewController.swift */, + 4322A20C203E1885004EA740 /* SignupUsernameTableViewController.swift */, + 43DC0EF02040B23200896C9C /* SignupUsernameViewController.swift */, ); - path = Tests; + name = "Social Signup"; sourceTree = ""; }; - BEC8A3FD1B4BAA08001CB8C3 /* Blog */ = { + 98E58A2D2360D1FD00E5534B /* Today Widgets */ = { isa = PBXGroup; children = ( - 027AC51F2278982D0033E56E /* DomainCredit */, - BE1071FD1BC75FCF00906AFF /* Style */, - 02761EC122700A9C009BAF0F /* BlogDetailsSubsectionToSectionCategoryTests.swift */, - 02761EC3227010BC009BAF0F /* BlogDetailsSectionIndexTests.swift */, - 3F1AD48023FC87A400BB1375 /* BlogDetailsViewController+MeButtonTests.swift */, + 989064F9237CC1A300218CD2 /* Data */, + 985ED0E323C6950600B8D06A /* WidgetStyles.swift */, ); - name = Blog; + path = "Today Widgets"; sourceTree = ""; }; - BED4D8311FF11E2B00A11345 /* Flows */ = { + 98F537A522496C7300B334F9 /* Date Chooser */ = { isa = PBXGroup; children = ( - BED4D8321FF11E3800A11345 /* LoginFlow.swift */, - CC52188B2278C622008998CE /* EditorFlow.swift */, + 98F537A622496CF300B334F9 /* SiteStatsTableHeaderView.swift */, + 98F537A822496D0D00B334F9 /* SiteStatsTableHeaderView.xib */, ); - path = Flows; + path = "Date Chooser"; sourceTree = ""; }; - BED4D83C1FF13C6200A11345 /* Editor */ = { + 98F89D47219E008600190EE6 /* Shared Views */ = { isa = PBXGroup; children = ( - CC8498CF2241473400DB490A /* EditorSettingsComponents */, - BED4D83A1FF13B8A00A11345 /* EditorPublishEpilogueScreen.swift */, - BED4D8341FF1208400A11345 /* AztecEditorScreen.swift */, - CC2BB0C92289CC3B0034F9AB /* BlockEditorScreen.swift */, - CC94FC67221452A4002E5825 /* EditorNoticeComponent.swift */, - CC19BE05223FECAC00CAB3E1 /* EditorPostSettings.swift */, + 9A09F916230C489600F42AB7 /* GhostViews */, + 98F537A522496C7300B334F9 /* Date Chooser */, + 986C908222319ECF00FC31E1 /* Post Stats */, + 98467A3B221CD30600DF51AE /* Stats Detail */, + 9A9E3FA2230D5F0A00909BC4 /* StatsStackViewCell.swift */, + 9A09F91A230C49FD00F42AB7 /* StatsStackViewCell.xib */, + 9881296C219CF1300075FF33 /* StatsCellHeader.swift */, + 9881296D219CF1310075FF33 /* StatsCellHeader.xib */, + 98B11B882216535100B7F2D7 /* StatsChildRowsView.swift */, + 98B11B8A2216536C00B7F2D7 /* StatsChildRowsView.xib */, + 98458CB721A39D350025D232 /* StatsNoDataRow.swift */, + 98458CB921A39D7A0025D232 /* StatsNoDataRow.xib */, + 983DBBA922125DD300753988 /* StatsTableFooter.swift */, + 983DBBA822125DD300753988 /* StatsTableFooter.xib */, + 98812964219CE42A0075FF33 /* StatsTotalRow.swift */, + 98812965219CE42A0075FF33 /* StatsTotalRow.xib */, + 984F86FA21DEDB060070E0E3 /* TopTotalsCell.swift */, + 9895B6DF21ED49160053D370 /* TopTotalsCell.xib */, + 98B3FA8321C05BDC00148DD4 /* ViewMoreRow.swift */, + 98B3FA8521C05BF000148DD4 /* ViewMoreRow.xib */, + FA347AEB26EB6E300096604B /* GrowAudienceCell.swift */, + FA347AEC26EB6E300096604B /* GrowAudienceCell.xib */, ); - path = Editor; + path = "Shared Views"; sourceTree = ""; }; - BED4D83D1FF13C7300A11345 /* Login */ = { + 9A09F916230C489600F42AB7 /* GhostViews */ = { isa = PBXGroup; children = ( - 985F06B42303866200949733 /* WelcomeScreenLoginComponent.swift */, - BE2B4E9A1FD66423007AE3E4 /* WelcomeScreen.swift */, - BE2B4EA31FD6659B007AE3E4 /* LoginEmailScreen.swift */, - BE6DD3271FD6705200E55192 /* LoginPasswordScreen.swift */, - BE6DD3291FD6708900E55192 /* LinkOrPasswordScreen.swift */, - CCF6ACE6221ED73900D0C5BE /* LoginCheckMagicLinkScreen.swift */, - CCE911BB221D8497007E1D4E /* LoginSiteAddressScreen.swift */, - CCE911BD221D85E4007E1D4E /* LoginUsernamePasswordScreen.swift */, - BE6DD32B1FD6782A00E55192 /* LoginEpilogueScreen.swift */, + 9A09F91D230C4C0200F42AB7 /* StatsGhostTableViewRows.swift */, + 9A9E3FAB230E9DD000909BC4 /* StatsGhostCells.swift */, + 9A9E3FB3230EC4F700909BC4 /* StatsGhostPostingActivityCell.xib */, + 9A9E3FB1230EB74300909BC4 /* StatsGhostTabbedCell.xib */, + 9A9E3FAF230EA7A300909BC4 /* StatsGhostTopCell.xib */, + 9A19D440236C7C7500D393E5 /* StatsGhostTopHeaderCell.xib */, + 9A9E3FAC230E9DD000909BC4 /* StatsGhostTwoColumnCell.xib */, + 9AC3C69A231543C2007933CD /* StatsGhostChartCell.xib */, + 9A73CB072350DE4C002EF20C /* StatsGhostSingleRowCell.xib */, + 9AB36B83236B25D900FAD72A /* StatsGhostTitleCell.xib */, + FA347AF126EB7A420096604B /* StatsGhostGrowAudienceCell.xib */, ); - path = Login; + path = GhostViews; sourceTree = ""; }; - C533CF320E6D3AB3000C3DE8 /* Comments */ = { + 9A162F2621C2713B00FDC035 /* Diffs */ = { isa = PBXGroup; children = ( - B56994461B7A82A300FF26FA /* Style */, - B56994471B7A82CD00FF26FA /* Controllers */, - B56994481B7A82D400FF26FA /* Views */, + 9A4697B121B002AD00468B64 /* RevisionDiffsPageManager.swift */, + 439F4F332196537500F8D0C7 /* RevisionDiffViewController.swift */, ); - path = Comments; + path = Diffs; sourceTree = ""; }; - C59D3D480E6410BC00AA591D /* Categories */ = { + 9A162F2721C2715100FDC035 /* Preview */ = { isa = PBXGroup; children = ( - 08C388681ED78EE70057BE49 /* Media+WPMediaAsset.h */, - 08C388691ED78EE70057BE49 /* Media+WPMediaAsset.m */, - B55853F519630D5400FAF6C3 /* NSAttributedString+Util.h */, - B55853F619630D5400FAF6C3 /* NSAttributedString+Util.m */, - B57B99DC19A2DBF200506504 /* NSObject+Helpers.h */, - B57B99DD19A2DBF200506504 /* NSObject+Helpers.m */, - B55853F11962337500FAF6C3 /* NSScanner+Helpers.h */, - B55853F21962337500FAF6C3 /* NSScanner+Helpers.m */, - 8261B4CB1EA8E13700668298 /* SVProgressHUD+Dismiss.h */, - 8261B4CA1EA8E13700668298 /* SVProgressHUD+Dismiss.m */, - B5416CFF1C17693B00006DD8 /* UIApplication+Helpers.h */, - B5416D001C17693B00006DD8 /* UIApplication+Helpers.m */, - 5D97C2F115CAF8D8009B44DD /* UINavigationController+KeyboardFix.h */, - 5D97C2F215CAF8D8009B44DD /* UINavigationController+KeyboardFix.m */, - E2AA87A318523E5300886693 /* UIView+Subviews.h */, - E2AA87A418523E5300886693 /* UIView+Subviews.m */, - E69BA1961BB5D7D300078740 /* WPStyleGuide+ReadableMargins.h */, - E69BA1971BB5D7D300078740 /* WPStyleGuide+ReadableMargins.m */, - 31EC15061A5B6675009FC8B3 /* WPStyleGuide+Suggestions.h */, - 31EC15071A5B6675009FC8B3 /* WPStyleGuide+Suggestions.m */, + 9A162F2A21C2A21A00FDC035 /* RevisionPreviewTextViewManager.swift */, + 9A162F2221C26D7500FDC035 /* RevisionPreviewViewController.swift */, ); - path = Categories; + path = Preview; sourceTree = ""; }; - CC098B8116A9EB0400450976 /* HTML */ = { + 9A22D9BE214A6B9800BAEAF2 /* Utils */ = { isa = PBXGroup; children = ( - 4D520D4E22972BC9002F5924 /* acknowledgements.html */, - 5DB767401588F64D00EBE36C /* postPreview.html */, - E18165FC14E4428B006CE885 /* loader.html */, - A01C55470E25E0D000D411F2 /* defaultPostTemplate.html */, - 2FAE97040E33B21600CA8540 /* defaultPostTemplate_old.html */, - 2FAE97070E33B21600CA8540 /* xhtml1-transitional.dtd */, - 2FAE97080E33B21600CA8540 /* xhtmlValidatorTemplate.xhtml */, - E61507E12220A0FE00213D33 /* richEmbedTemplate.html */, - E61507E32220A13B00213D33 /* richEmbedScript.js */, + 9A22D9BF214A6BCA00BAEAF2 /* PageListTableViewHandler.swift */, + F16C35D523F33DE400C81331 /* PageAutoUploadMessageProvider.swift */, ); - name = HTML; + name = Utils; sourceTree = ""; }; - CC1D800D1656D8B2002A542F /* Notifications */ = { + 9A2B28EC2191B4E600458F2A /* Footer */ = { isa = PBXGroup; children = ( - 402FFB1D218C2B8D00FF4A0B /* Style */, - B5FD453E199D0F2800286FBB /* Controllers */, - 7E3E7A5120E44B060075D159 /* FormattableContent */, - B54E1DEC1A0A7BAA00807537 /* ReplyTextView */, - B5E23BD919AD0CED000D6879 /* Tools */, - B5FD4523199D0F1100286FBB /* Views */, - B558541019631A1000FAF6C3 /* Notifications.storyboard */, + 9A2B28ED2191B50500458F2A /* RevisionsTableViewFooter.swift */, ); - path = Notifications; + path = Footer; sourceTree = ""; }; - CC2BB0CC2289D0F20034F9AB /* Me */ = { + 9A2B28F2219211B900458F2A /* Views */ = { isa = PBXGroup; children = ( + 9A2B28F3219211E700458F2A /* Operation */, + 9A2B28EC2191B4E600458F2A /* Footer */, + 9A5CF43E218C54A60060F81B /* Cell */, ); - path = Me; + path = Views; sourceTree = ""; }; - CC5218982279CF06008998CE /* Media */ = { + 9A2B28F3219211E700458F2A /* Operation */ = { isa = PBXGroup; children = ( - CC5218992279CF3B008998CE /* MediaPickerAlbumListScreen.swift */, - CC52189B2279D295008998CE /* MediaPickerAlbumScreen.swift */, + 9A2B28F42192121400458F2A /* RevisionOperation.swift */, + 9A2B28F62192121F00458F2A /* RevisionOperation.xib */, + 4353BFA8219E0E820009CED3 /* RevisionOperationViewController.swift */, ); - path = Media; + path = Operation; sourceTree = ""; }; - CC7CB97422B159FE00642EE9 /* Signup */ = { + 9A35B08D225F9E2200A293E0 /* State */ = { isa = PBXGroup; children = ( - CC7CB98622B28F4600642EE9 /* WelcomeScreenSignupComponent.swift */, - CC7CB97522B15A2900642EE9 /* SignupEmailScreen.swift */, - CC7CB97722B15B2C00642EE9 /* SignupCheckMagicLinkScreen.swift */, - CC7CB97922B15C1000642EE9 /* SignupEpilogueScreen.swift */, + 9A8ECE0A2254A3260043C8DA /* JetpackRemoteInstallState.swift */, ); - path = Signup; + path = State; sourceTree = ""; }; - CC8498CF2241473400DB490A /* EditorSettingsComponents */ = { + 9A38DC63218899E4006A409B /* Revisions */ = { isa = PBXGroup; children = ( - CCE55E982242715C002A9634 /* CategoriesComponent.swift */, - CC8498D22241477F00DB490A /* TagsComponent.swift */, + 9A38DC64218899FA006A409B /* Revision.swift */, + 9A38DC67218899FB006A409B /* RevisionDiff.swift */, + 9A4E61F721A2C3BC0017A925 /* RevisionDiff+CoreData.swift */, + 9A38DC68218899FB006A409B /* DiffAbstractValue.swift */, + 9AF9551721A1D7970057827C /* DiffAbstractValue+Attributes.swift */, + 9A38DC66218899FB006A409B /* DiffContentValue.swift */, + 9A38DC65218899FA006A409B /* DiffTitleValue.swift */, ); - path = EditorSettingsComponents; + name = Revisions; sourceTree = ""; }; - CCB3A03814C8DD5100D43C3F /* Reader */ = { + 9A4E215D21F87A8500EFF212 /* Cells */ = { isa = PBXGroup; children = ( - 5D1D04731B7A50B100CDE646 /* Reader.storyboard */, - 5D5A6E901B613C1800DAF819 /* Cards */, - 5D08B8FD19647C0800D5B381 /* Controllers */, - E6D2E16A1B8B41AC0000ED14 /* Headers */, - 5D98A1491B6C09730085E904 /* Style */, - 5D08B8FC19647C0300D5B381 /* Views */, + FAFC064D27D2360B002F0483 /* QuickStartCell.swift */, + 9A4E215921F7565A00EFF212 /* QuickStartChecklistCell.xib */, + 4395A15C2106718900844E8E /* QuickStartChecklistCell.swift */, ); - path = Reader; + name = Cells; sourceTree = ""; }; - D800D86220997B6400E7C7E5 /* ReaderMenuItems */ = { + 9A4E215E21F87AC300EFF212 /* Views */ = { isa = PBXGroup; children = ( - D800D86720997D5000E7C7E5 /* DiscoverMenuItemCreator.swift */, - D800D86520997C6E00E7C7E5 /* FollowingMenuItemCreator.swift */, - D800D86920997E0C00E7C7E5 /* LikedMenuItemCreator.swift */, - D800D86B20997EB400E7C7E5 /* OtherMenuItemCreator.swift */, - D800D86320997BA100E7C7E5 /* ReaderMenuItemCreator.swift */, - D800D86F20998A7300E7C7E5 /* SavedForLaterMenuItemCreator.swift */, - D800D86D2099857000E7C7E5 /* SearchMenuItemCreator.swift */, + FAE4CA662732C094003BFDFE /* QuickStartPromptViewController.swift */, + FAE4CA672732C094003BFDFE /* QuickStartPromptViewController.xib */, + 43290D09215E8B1200F6B398 /* QuickStartSpotlightView.swift */, + 803C493A283A7C0C00003E9B /* QuickStartChecklistHeader.swift */, + 803C493D283A7C2200003E9B /* QuickStartChecklistHeader.xib */, ); - name = ReaderMenuItems; + name = Views; sourceTree = ""; }; - D80EE638203DBB7E0094C34C /* Accessibility */ = { + 9A4E271822EF0C57001F6A6B /* View Model */ = { isa = PBXGroup; children = ( - D8071630203DA23700B32FD9 /* Accessible.swift */, + 9A4E271922EF0C78001F6A6B /* ChangeUsernameViewModel.swift */, ); - path = Accessibility; + path = "View Model"; sourceTree = ""; }; - D816043E209C1AD300ABAFFA /* ReaderPostActions */ = { + 9A4E61FC21A2CC4C0017A925 /* Browser */ = { isa = PBXGroup; children = ( - D8212CB820AA77AD008E8AE8 /* ReaderActionHelpers.swift */, - D8212CC220AA7F57008E8AE8 /* ReaderBlockSiteAction.swift */, - D8212CC420AA83F9008E8AE8 /* ReaderCommentAction.swift */, - D8212CB220AA6861008E8AE8 /* ReaderFollowAction.swift */, - D8212CC620AA85C1008E8AE8 /* ReaderHeaderAction.swift */, - D8212CB020AA64E1008E8AE8 /* ReaderLikeAction.swift */, - D8212CC820AA87E5008E8AE8 /* ReaderMenuAction.swift */, - 3F8CB10523A07B17007627BF /* ReaderReblogAction.swift */, - 3F5B3EB023A851480060FF1F /* ReaderReblogFormatter.swift */, - 3F5B3EAE23A851330060FF1F /* ReaderReblogPresenter.swift */, - 175A650B20B6F7280023E71B /* ReaderSaveForLater+Analytics.swift */, - D8160441209C1B0F00ABAFFA /* ReaderSaveForLaterAction.swift */, - D8212CB620AA7703008E8AE8 /* ReaderShareAction.swift */, - D8212CBE20AA7B7F008E8AE8 /* ReaderShowAttributionAction.swift */, - D8212CC020AA7C58008E8AE8 /* ReaderShowMenuAction.swift */, - D8212CB420AA68D5008E8AE8 /* ReaderSubscribingNotificationAction.swift */, - D8212CBC20AA7A7A008E8AE8 /* ReaderVisitSiteAction.swift */, + 439F4F37219B636500F8D0C7 /* Revisions.storyboard */, + 439F4F39219B715300F8D0C7 /* RevisionsNavigationController.swift */, + 9A162F2821C271D300FDC035 /* RevisionBrowserState.swift */, + 439F4F3B219B78B500F8D0C7 /* RevisionDiffsBrowserViewController.swift */, + 9A162F2621C2713B00FDC035 /* Diffs */, + 9A162F2721C2715100FDC035 /* Preview */, ); - name = ReaderPostActions; + path = Browser; sourceTree = ""; }; - D816B8CB2112D4960052CE4D /* News Card */ = { + 9A51DA2422EB23F5005CC335 /* Change Username */ = { isa = PBXGroup; children = ( - D816B8CE2112D4F90052CE4D /* DefaultNewsManager.swift */, - D816B8D82112D85F0052CE4D /* News.swift */, - D816B8D22112D6E70052CE4D /* NewsCard.swift */, - D816B8D32112D6E70052CE4D /* NewsCard.xib */, - D816B8CC2112D4AA0052CE4D /* NewsManager.swift */, - D8281CF0212AB34C00D09098 /* NewsStats.swift */, - D826D681211D51E300A5D8FE /* ReaderNewsCard.swift */, + 9A51DA1322E9E8C7005CC335 /* ChangeUsernameViewController.swift */, + 9A4E271822EF0C57001F6A6B /* View Model */, ); - name = "News Card"; + path = "Change Username"; sourceTree = ""; }; - D816C1EA20E0884100C4D82F /* Actions */ = { + 9A5CF43E218C54A60060F81B /* Cell */ = { isa = PBXGroup; children = ( - D816C1E820E0880400C4D82F /* NotificationAction.swift */, - D816C1EB20E0887C00C4D82F /* ApproveComment.swift */, - D816C1ED20E0892200C4D82F /* Follow.swift */, - D816C1EF20E0893A00C4D82F /* LikeComment.swift */, - D816C1F120E0894D00C4D82F /* ReplyToComment.swift */, - D816C1F320E0895E00C4D82F /* MarkAsSpam.swift */, - D816C1F520E0896F00C4D82F /* TrashComment.swift */, - D858F2FC20E1F09F007E8A1C /* NotificationActionParser.swift */, - D858F30020E20106007E8A1C /* LikePost.swift */, - D858F30220E201F4007E8A1C /* EditComment.swift */, + 4349B0AD218A477F0034118A /* RevisionsTableViewCell.swift */, + 4349B0AE218A477F0034118A /* RevisionsTableViewCell.xib */, ); - path = Actions; + path = Cell; sourceTree = ""; }; - D818FFD22191556A000E5FEE /* Verticals */ = { + 9A76C32D22AFD9EB00F5D819 /* Map */ = { isa = PBXGroup; children = ( - 2FA6511421F269A6009AA935 /* VerticalsTableViewProvider.swift */, - D8AEA54821C21BEB00AB4DCB /* NewVerticalCell.swift */, - D8AEA54921C21BEC00AB4DCB /* NewVerticalCell.xib */, - D8380CA22192E77F00250609 /* VerticalsCell.swift */, - D8380CA32192E77F00250609 /* VerticalsCell.xib */, - D818FFD321915586000E5FEE /* VerticalsStep.swift */, - D818FFD52191566B000E5FEE /* VerticalsWizardContent.swift */, - D818FFD62191566B000E5FEE /* VerticalsWizardContent.xib */, - D8AEA54C21C2216300AB4DCB /* SiteVerticalPresenter.swift */, + 9A1A67A522B2AD4E00FF8422 /* CountriesMap.swift */, + 9A76C32E22AFDA2100F5D819 /* world-map.svg */, + 9A3BDA0D22944F3500FBF510 /* CountriesMapView.swift */, + 9A3BDA0F22944F4D00FBF510 /* CountriesMapView.xib */, + 9A5C854622B3E42800BEE7A3 /* CountriesMapCell.swift */, + 9A5C854722B3E42800BEE7A3 /* CountriesMapCell.xib */, ); - path = Verticals; + path = Map; sourceTree = ""; }; - D82253E8219A8A5F0014D0E2 /* SiteInfo */ = { + 9A8ECE002254A3250043C8DA /* Login */ = { isa = PBXGroup; children = ( - D813D67E21AA8BBF0055CCA1 /* ShadowView.swift */, - D82253E9219A8A720014D0E2 /* SiteInformationStep.swift */, - D82253EB219A8A960014D0E2 /* SiteInformationWizardContent.swift */, - D82253EC219A8A960014D0E2 /* SiteInformationWizardContent.xib */, + 9A8ECE012254A3250043C8DA /* JetpackLoginViewController.swift */, + 9A8ECE022254A3250043C8DA /* JetpackLoginViewController.xib */, ); - path = SiteInfo; + path = Login; sourceTree = ""; }; - D8380CA72194287B00250609 /* WebAddress */ = { + 9A8ECE032254A3260043C8DA /* Install */ = { isa = PBXGroup; children = ( - 2FA6511C21F26A7C009AA935 /* WebAddressTableViewProvider.swift */, - D82253E3219956540014D0E2 /* AddressCell.swift */, - D82253E4219956540014D0E2 /* AddressCell.xib */, - D853723921952DAF0076F461 /* WebAddressStep.swift */, - D82253DD2199418B0014D0E2 /* WebAddressWizardContent.swift */, - D82253DE2199418B0014D0E2 /* WebAddressWizardContent.xib */, + 837966A1299E9C85004A92B9 /* JetpackInstallPluginHelper.swift */, + 8379669E299D51EC004A92B9 /* JetpackPlugin.swift */, + 9A8ECE062254A3260043C8DA /* JetpackRemoteInstallViewController.swift */, + 9A8ECE1F22550A210043C8DA /* Error */, + 9A8ECE1A2254ADFA0043C8DA /* View */, + 9A8ECE082254A3260043C8DA /* ViewModel */, + 9A8ECE042254A3260043C8DA /* Webview */, + 01DBFD8629BDCBF200F3720F /* JetpackNativeConnectionService.swift */, ); - path = WebAddress; + path = Install; sourceTree = ""; }; - D865720F21869C380023A99C /* Site Creation */ = { + 9A8ECE042254A3260043C8DA /* Webview */ = { isa = PBXGroup; children = ( - 73178C2D21BEE13E00E37C9A /* FinalAssembly */, - 738B9A4721B85CF20005062B /* Shared */, - D82253E8219A8A5F0014D0E2 /* SiteInfo */, - D8C31CBF2188442200A33B35 /* SiteSegments */, - D818FFD22191556A000E5FEE /* Verticals */, - D8380CA72194287B00250609 /* WebAddress */, - 738B9A3F21B85CF20005062B /* Wizard */, + 9A8ECE052254A3260043C8DA /* JetpackConnectionWebViewController.swift */, ); - path = "Site Creation"; + path = Webview; sourceTree = ""; }; - D86572182186C36E0023A99C /* Wizards */ = { + 9A8ECE082254A3260043C8DA /* ViewModel */ = { isa = PBXGroup; children = ( - D865721121869C590023A99C /* Wizard.swift */, - D86572162186C3600023A99C /* WizardDelegate.swift */, - D865721021869C590023A99C /* WizardStep.swift */, + 9A8ECE092254A3260043C8DA /* JetpackRemoteInstallViewModel.swift */, + FEA1123B29944896008097B0 /* SelfHostedJetpackRemoteInstallViewModel.swift */, + FEA1123E29964BCA008097B0 /* WPComJetpackRemoteInstallViewModel.swift */, ); - path = Wizards; + path = ViewModel; sourceTree = ""; }; - D88A6490208D79F1008AE9BC /* Stock Photos */ = { + 9A8ECE1A2254ADFA0043C8DA /* View */ = { isa = PBXGroup; children = ( - D88A64AF208DA093008AE9BC /* StockPhotosResultsPageTests.swift */, - D88A64AB208D9B09008AE9BC /* StockPhotosPageableTests.swift */, - D88A64A7208D9733008AE9BC /* ThumbnailCollectionTests.swift */, - D88A64A1208D8F05008AE9BC /* StockPhotosMediaTests.swift */, - D88A649F208D8B7D008AE9BC /* StockPhotosMediaGroupTests.swift */, - D88A649B208D7D81008AE9BC /* StockPhotosDataSourceTests.swift */, - D88A6491208D7A0A008AE9BC /* MockStockPhotosService.swift */, + 9A35B08D225F9E2200A293E0 /* State */, + 8313B9F92995A03C000AF26E /* JetpackRemoteInstallCardView.swift */, + 9A8ECE1B2254AE4E0043C8DA /* JetpackRemoteInstallStateView.swift */, + 9A8ECE1C2254AE4E0043C8DA /* JetpackRemoteInstallStateView.xib */, + 8379669B299C0C44004A92B9 /* JetpackRemoteInstallTableViewCell.swift */, ); - name = "Stock Photos"; + path = View; sourceTree = ""; }; - D8A3A5AD206A059100992576 /* StockPhotos */ = { + 9A8ECE1F22550A210043C8DA /* Error */ = { isa = PBXGroup; children = ( - D8A3A5A92069E53900992576 /* AztecMediaPickingCoordinator.swift */, - D8A3A5AB2069FE5B00992576 /* StockPhotosStrings.swift */, - D8A3A5AE206A442800992576 /* StockPhotosDataSource.swift */, - 7EBB4125206C388100012D98 /* StockPhotosService.swift */, - D88A6493208D7AD0008AE9BC /* DefaultStockPhotosService.swift */, - D88A6495208D7B0B008AE9BC /* NullStockPhotosService.swift */, - D8A3A5B0206A49A100992576 /* StockPhotosMediaGroup.swift */, - D8A3A5B2206A49BF00992576 /* StockPhotosMedia.swift */, - D8A3A5B4206A4C7800992576 /* StockPhotosPicker.swift */, - 98F1B1292111017900139493 /* NoResultsStockPhotosConfiguration.swift */, - D80BC79D20746B4100614A59 /* MediaPickingContext.swift */, - D83CA3A420842CAF0060E310 /* Pageable.swift */, - D83CA3A620842CD90060E310 /* ResultsPage.swift */, - D83CA3A820842D190060E310 /* StockPhotosPageable.swift */, - D83CA3AA20842E5F0060E310 /* StockPhotosResultsPage.swift */, - D83CA3AF2084CAAF0060E310 /* StockPhotosDataLoader.swift */, + 9A8ECE0B2254A3260043C8DA /* JetpackInstallError+Blocking.swift */, ); - path = StockPhotos; + path = Error; sourceTree = ""; }; - D8A468DE2181C5B50094B82F /* Site Creation */ = { + 9A9D34FB23607C8400BC95A3 /* Operations */ = { isa = PBXGroup; children = ( - D8A468DF2181C6450094B82F /* site-segment.json */, + 9A9D34FC23607CCC00BC95A3 /* AsyncOperationTests.swift */, + 9A9D34FE2360A4E200BC95A3 /* StatsPeriodAsyncOperationTests.swift */, ); - name = "Site Creation"; + name = Operations; sourceTree = ""; }; - D8C31CBF2188442200A33B35 /* SiteSegments */ = { + 9AA0ADAF235F11500027AB5D /* Operation */ = { isa = PBXGroup; children = ( - D8C31CC42188490000A33B35 /* SiteSegmentsCell.swift */, - D8C31CC52188490000A33B35 /* SiteSegmentsCell.xib */, - D853723B21952DC90076F461 /* SiteSegmentsStep.swift */, - D865722C2186F96B0023A99C /* SiteSegmentsWizardContent.swift */, - D865722D2186F96C0023A99C /* SiteSegmentsWizardContent.xib */, + 9AA0ADB0235F116F0027AB5D /* AsyncOperation.swift */, + 4A072CD129093704006235BE /* AsyncBlockOperation.swift */, + 9AA0ADB2235F11DC0027AB5D /* StatsPeriodAsyncOperation.swift */, ); - path = SiteSegments; + path = Operation; sourceTree = ""; }; - D8CB56212181A93F00554EAE /* Site Creation */ = { + AB758D9C25EFDF8400961C0B /* Likes */ = { isa = PBXGroup; children = ( - D8225407219AB0520014D0E2 /* SiteInformation.swift */, + AB758D9D25EFDF9C00961C0B /* LikesListController.swift */, ); - name = "Site Creation"; + path = Likes; sourceTree = ""; }; - E10520591F2B1CD400A948F6 /* 61-62 */ = { + AC3439790E11434600E5D79B /* Post */ = { isa = PBXGroup; children = ( - E105205A1F2B1CF400A948F6 /* BlogToBlogMigration_61_62.swift */, + F5D0A64C23CC157100B20D27 /* Preview */, + 8B0732EA242BEF1900E7FBD3 /* Prepublishing Nudge */, + F57402A5235FF71F00374346 /* Scheduling */, + 4349B0A6218A2E810034118A /* Revisions */, + 5D1EBF56187C9B95003393F8 /* Categories */, + 5DF3DD691A9377220051A229 /* Controllers */, + 5DF3DD6B1A93773B0051A229 /* Style */, + 5D09CBA61ACDE532007A23BD /* Utils */, + 5DF3DD6A1A93772D0051A229 /* Views */, + C3E2462826277B7700B99EA6 /* PostAuthorSelectorViewController.swift */, + E13ACCD31EE5672100CCE985 /* PostEditor.swift */, + E17FEAD7221490F7006E1D2D /* PostEditorAnalyticsSession.swift */, + 91DCE84321A6A7840062F134 /* PostEditor+BlogPicker.swift */, + 91DCE84521A6A7F50062F134 /* PostEditor+MoreOptions.swift */, + 91DCE84721A6C58C0062F134 /* PostEditor+Publish.swift */, + 7E504D4921A5B8D400E341A8 /* PostEditorNavigationBarManager.swift */, + 437542E21DD4E19E00D6B727 /* EditPostViewController.swift */, + 5D146EB9189857ED0068FDC6 /* FeaturedImageViewController.h */, + 5D146EBA189857ED0068FDC6 /* FeaturedImageViewController.m */, + 93414DE41E2D25AE003143A3 /* PostEditorState.swift */, + 32CA6EBF2390C61F00B51347 /* PostListEditorPresenter.swift */, + 430693731DD25F31009398A2 /* PostPost.storyboard */, + 43D54D121DCAA070007F575F /* PostPostViewController.swift */, + 5DBFC8A81A9BE07B00E00DE4 /* Posts.storyboard */, + 5D62BAD818AAAE9B0044E5F7 /* PostSettingsViewController_Internal.h */, + ACBAB5FC0E121C7300F38795 /* PostSettingsViewController.h */, + ACBAB5FD0E121C7300F38795 /* PostSettingsViewController.m */, + FFEECFFB2084DE2B009B8CDB /* PostSettingsViewController+FeaturedImageUpload.swift */, + 8B260D7D2444FC9D0010F756 /* PostVisibilitySelectorViewController.swift */, + 593F26601CAB00CA00F14073 /* PostSharingController.swift */, + E155EC711E9B7DCE009D7F63 /* PostTagPickerViewController.swift */, + 8BAD272B241FEF3300E9D105 /* PrepublishingViewController.swift */, + 5903AE1C19B60AB9009D5354 /* WPButtonForNavigationBar.h */, + 5903AE1A19B60A98009D5354 /* WPButtonForNavigationBar.m */, + FF0AAE0B1A16550D0089841D /* WPMediaProgressTableViewController.h */, + 5DB3BA0318D0E7B600F3F3E9 /* WPPickerView.h */, + 5DB3BA0418D0E7B600F3F3E9 /* WPPickerView.m */, + C9F1D4B92706EEEB00BDF917 /* HomepageEditorNavigationBarManager.swift */, ); - path = "61-62"; + path = Post; sourceTree = ""; }; - E1239B7B176A2E0F00D37220 /* Tests */ = { + AC34397B0E11443300E5D79B /* Blog */ = { isa = PBXGroup; children = ( - 7EC9FE0822C6275900C5A888 /* Analytics */, - 572FB3FE223A800500933C76 /* Classes */, - FF9A6E6F21F9359200D36D14 /* Gutenberg */, - 7E442FC520F677A300DEACA5 /* ActivityLog */, - FF7691661EE06CF500713F4B /* Aztec */, - B5AEEC7F1ACAD099008BF2A4 /* Categories */, - B5AEEC731ACACF3B008BF2A4 /* Core Data */, - E1C9AA541C1041E600732665 /* Extensions */, - FF7C89A11E3A1029000472A8 /* MediaPicker */, - 5D7A577D1AFBFD7C0097C028 /* Models */, - 85F8E1991B017A8E000859BB /* Networking */, - B5416CF81C17542900006DD8 /* Notifications */, - 9A9D34FB23607C8400BC95A3 /* Operations */, - 59ECF8791CB705EB00E68F25 /* Posts */, - 436D55EE2115CB3D00CEAA33 /* RegisterDomain */, - E6B9B8AB1B94EA710001B92F /* Reader */, - B5AEEC7E1ACAD088008BF2A4 /* Services */, - 73178C2021BEE09300E37C9A /* SiteCreation */, - 40E7FEC32211DF490032834E /* Stats */, - D88A6490208D79F1008AE9BC /* Stock Photos */, - BEA0E4821BD8353B000AEE81 /* System */, - 59B48B601B99E0B0008EBB84 /* TestUtilities */, - 852416D01A12ED2D0030700C /* Utility */, - BE20F5E11B2F738E0020694C /* ViewRelated */, - FF9839A71CD3960600E85258 /* WordPressAPI */, + F1863714253E49B8003D4BEF /* Add Site */, + 3F37609B23FD803300F0D87F /* Blog + Me */, + FA73D7D7278D9E6300DF24B3 /* Blog Dashboard */, + 0CB4056F29C8DCD7008EED0A /* BlogPersonalization */, + 3F43603423F368BF001DEE70 /* Blog Details */, + 3F43603523F368CA001DEE70 /* Blog List */, + 3F43603623F36962001DEE70 /* Blog Selector */, + 98A047702821BFB6001B4E2D /* Blogging Prompts */, + 3F170D2A2654615900F6F670 /* Blogging Reminders */, + 1716AEFA25F2926C00CF49EC /* My Site */, + C77FC90628009C3C00726F00 /* Onboarding Questions Prompt */, + 4395A15E210672C900844E8E /* QuickStart */, + E6431DDE1C4E890B00FD8D90 /* Sharing */, + FA5C74091C596E69000B528C /* Site Management */, + FA73D7E72798766300DF24B3 /* Site Picker */, + 3F43603823F36A76001DEE70 /* Site Settings */, + BE6AB7FB1BC62E0B00D980FC /* Style */, ); - name = Tests; + path = Blog; sourceTree = ""; }; - E125F1E21E8E594C00320B67 /* Shared */ = { + AC68C9C728E5DEB1009030A9 /* Notification */ = { isa = PBXGroup; children = ( - E125F1E31E8E595E00320B67 /* SharePost.swift */, + AC68C9C828E5DEE0009030A9 /* Controllers */, ); - path = Shared; + path = Notification; sourceTree = ""; }; - E12F55F714A1F2640060A510 /* Vendor */ = { + AC68C9C828E5DEE0009030A9 /* Controllers */ = { isa = PBXGroup; children = ( - 931D26FB19EDA0D000114F17 /* ALIterativeMigrator */, + AC68C9C928E5DF14009030A9 /* NotificationsViewControllerTests.swift */, ); - path = Vendor; + path = Controllers; sourceTree = ""; }; - E131CB5B16CAD638004B0314 /* Helpers */ = { + ACACE3AF28D76A62000992F9 /* Controllers */ = { isa = PBXGroup; children = ( - E180BD4D1FB4681E00D0D781 /* MockCookieJar.swift */, - E1B642121EFA5113001DC6D7 /* ModelTestHelper.swift */, - 93E9050519E6F3D8005513C9 /* TestContextManager.h */, - 93E9050619E6F3D8005513C9 /* TestContextManager.m */, - 933D1F451EA64108009FB462 /* TestingAppDelegate.h */, - 933D1F461EA64108009FB462 /* TestingAppDelegate.m */, - 933D1F6B1EA7A3AB009FB462 /* TestingMode.storyboard */, - 933D1F6D1EA7A402009FB462 /* TestAssets.xcassets */, - D81C2F5720F86CEA002AE1F1 /* NetworkStatus.swift */, - 8BE7C84023466927006EDE70 /* I18n.swift */, + ACACE3AD28D729FA000992F9 /* NoResultsViewControllerTests.swift */, ); - name = Helpers; + path = Controllers; sourceTree = ""; }; - E1389AD91C59F78500FB2466 /* Plans */ = { + B0088A8F283D68B1008C9676 /* Stats Revamp v2 */ = { isa = PBXGroup; children = ( - E15644E51CE0E43700D96E64 /* Controllers */, - E15644E71CE0E44700D96E64 /* ViewModels */, - E15644E61CE0E43F00D96E64 /* Views */, - 1724DDCB1C6121D00099D273 /* Plans.storyboard */, ); - path = Plans; + path = "Stats Revamp v2"; sourceTree = ""; }; - E14694041F3459A9004052C8 /* Plugins */ = { + B02D1EC12835348900F20359 /* spectrum-2022 */ = { isa = PBXGroup; children = ( - E1F47D4B1FE028F800C1D44E /* Views */, - E151C0C41F3889CA00710A83 /* ViewModels */, - E14694051F3459CE004052C8 /* Controllers */, + 172F06B82865C04F00C78FD4 /* spectrum-'22-icon-app-60x60@2x.png */, + 172F06B52865C04E00C78FD4 /* spectrum-'22-icon-app-60x60@3x.png */, + 172F06B42865C04E00C78FD4 /* spectrum-'22-icon-app-76x76.png */, + 172F06B62865C04E00C78FD4 /* spectrum-'22-icon-app-76x76@2x.png */, + 172F06B72865C04F00C78FD4 /* spectrum-'22-icon-app-83.5x83.5@2x.png */, ); - path = Plugins; + path = "spectrum-2022"; sourceTree = ""; }; - E14694051F3459CE004052C8 /* Controllers */ = { + B0960C8527D14B9200BC9717 /* Site Intent */ = { isa = PBXGroup; children = ( - 40E469922017F3D20030DB5F /* PluginDirectoryViewController.swift */, - E14694061F3459E2004052C8 /* PluginListViewController.swift */, - E126C81E1F95FC1B00A5F464 /* PluginViewController.swift */, + C3302CC527EB67D0004229D3 /* IntentCell.swift */, + C3302CC427EB67D0004229D3 /* IntentCell.xib */, + B0960C8627D14BD400BC9717 /* SiteIntentStep.swift */, + B089140C27E1255D00CF468B /* SiteIntentViewController.swift */, + C3234F5327EBBACA004ADB29 /* SiteIntentVertical.swift */, + C373D6E628045281008F8C26 /* SiteIntentData.swift */, ); - name = Controllers; + path = "Site Intent"; sourceTree = ""; }; - E151C0C41F3889CA00710A83 /* ViewModels */ = { + B50C0C441EF429D500372C65 /* Aztec */ = { isa = PBXGroup; children = ( - 40E469942017FB1F0030DB5F /* PluginDirectoryViewModel.swift */, - E151C0C51F3889DF00710A83 /* PluginListRow.swift */, - E151C0C71F388A2000710A83 /* PluginListViewModel.swift */, - E17E67021FA22C93009BDC9A /* PluginViewModel.swift */, + 402FFB22218C36CF00FF4A0B /* Helpers */, + B50C0C601EF42ADE00372C65 /* Extensions */, + B50C0C581EF42A2600372C65 /* Media */, + F126FDFC20A33BDB0010EB6E /* Processors */, + B50C0C5B1EF42A4A00372C65 /* ViewControllers */, ); - name = ViewModels; + path = Aztec; sourceTree = ""; }; - E1523EB216D3B2EE002C5A36 /* Sharing */ = { + B50C0C581EF42A2600372C65 /* Media */ = { isa = PBXGroup; children = ( - E1D0D81416D3B86800E33F4C /* SafariActivity.h */, - E1D0D81516D3B86800E33F4C /* SafariActivity.m */, - E1D95EB617A28F5E00A3E9F3 /* WPActivityDefaults.h */, - E1D95EB717A28F5E00A3E9F3 /* WPActivityDefaults.m */, + B50C0C591EF42A2600372C65 /* MediaProgressCoordinator.swift */, + 8BD36E012395CAEA00EFFF1C /* MediaEditorOperation+Description.swift */, ); - path = Sharing; + path = Media; sourceTree = ""; }; - E15644E51CE0E43700D96E64 /* Controllers */ = { + B50C0C5B1EF42A4A00372C65 /* ViewControllers */ = { isa = PBXGroup; children = ( - 17D2FDC11C6A468A00944265 /* PlanComparisonViewController.swift */, - 1724DDC71C60F1200099D273 /* PlanDetailViewController.swift */, - E1389ADA1C59F7C200FB2466 /* PlanListViewController.swift */, + B50C0C5C1EF42A4A00372C65 /* AztecAttachmentViewController.swift */, + B5FDF9F220D842D2006D14E3 /* AztecNavigationController.swift */, + B50C0C5D1EF42A4A00372C65 /* AztecPostViewController.swift */, + B538F3881EF46EC8001003D5 /* UnknownEditorViewController.swift */, + FFABD7FF213423F1003C65B6 /* LinkSettingsViewController.swift */, + FFABD80721370496003C65B6 /* SelectPostViewController.swift */, ); - name = Controllers; + path = ViewControllers; sourceTree = ""; }; - E15644E61CE0E43F00D96E64 /* Views */ = { + B50C0C601EF42ADE00372C65 /* Extensions */ = { isa = PBXGroup; children = ( - E15644F01CE0E56600D96E64 /* FeatureItemCell.swift */, - 172797D81CE5D0CD00CB8057 /* PlansLoadingIndicatorView.swift */, + B50C0C611EF42AF200372C65 /* TextList+WordPress.swift */, + B50C0C631EF42B3A00372C65 /* Header+WordPress.swift */, + B50C0C651EF42B6400372C65 /* FormatBarItemProviders.swift */, + FF1FD0232091268900186384 /* URL+LinkNormalization.swift */, ); - name = Views; + path = Extensions; sourceTree = ""; }; - E15644E71CE0E44700D96E64 /* ViewModels */ = { + B5176CBF1CDCE14F0083CF2D /* People Management */ = { isa = PBXGroup; children = ( - E15644EA1CE0E4C500D96E64 /* FeatureItemRow.swift */, - E15644F21CE0E5A500D96E64 /* PlanDetailViewModel.swift */, - E15644EC1CE0E4FE00D96E64 /* PlanListRow.swift */, - E15644EE1CE0E53B00D96E64 /* PlanListViewModel.swift */, + B5176CC01CDCE1B90083CF2D /* ManagedPerson.swift */, + B5176CC21CDCE1C30083CF2D /* ManagedPerson+CoreDataProperties.swift */, ); - name = ViewModels; + name = "People Management"; sourceTree = ""; }; - E159D1011309AAF200F498E2 /* Migrations */ = { + B5176CC41CDCE3C80083CF2D /* Account Settings */ = { isa = PBXGroup; children = ( - E1A03EDF17422DBC0085D192 /* 10-11 */, - 5D49B03519BE37CC00703A9B /* 20-21 */, - 937D9A0D19F837ED007B9D5F /* 22-23 */, - 5DF7F7751B223895003A05C8 /* 30-31 */, - E1C4F4E61B29D5B900DAAB8E /* 32-33 */, - E66969D01B9E3D5000EC9C00 /* 37-38 */, - E10520591F2B1CD400A948F6 /* 61-62 */, - 7E8980CB22E8C81B00C567B0 /* 87-88 */, - E100C6BA1741472F00AE48D8 /* WordPress-11-12.xcmappingmodel */, - 5D51ADAE19A832AF00539C0B /* WordPress-20-21.xcmappingmodel */, - 937D9A0E19F83812007B9D5F /* WordPress-22-23.xcmappingmodel */, - E1D86E681B2B414300DD2192 /* WordPress-32-33.xcmappingmodel */, - 5DF7F7731B22337C003A05C8 /* WordPress-30-31.xcmappingmodel */, - E603C76F1BC94AED00AD49D7 /* WordPress-37-38.xcmappingmodel */, - B555E1141C04A68D00CEC81B /* WordPress-41-42.xcmappingmodel */, - E10520571F2B1CC900A948F6 /* WordPress-61-62.xcmappingmodel */, - E192E78B22EF453C008D725D /* WordPress-87-88.xcmappingmodel */, - 57CCB3802358ED07003ECD0C /* WordPress-91-92.xcmappingmodel */, + E14200771C117A2E00B3B115 /* ManagedAccountSettings.swift */, + E14200791C117A3B00B3B115 /* ManagedAccountSettings+CoreDataProperties.swift */, ); - path = Migrations; + name = "Account Settings"; sourceTree = ""; }; - E16AB92F14D978240047A2E5 /* WordPressTest */ = { + B5176CC61CDCE47F0083CF2D /* Blog */ = { isa = PBXGroup; children = ( - E16AB93014D978240047A2E5 /* Supporting Files */, - B532ACD41DC3AE1F00FFFA57 /* Extensions */, - E16AB94414D9A13A0047A2E5 /* Mock Data */, - E131CB5B16CAD638004B0314 /* Helpers */, - E1239B7B176A2E0F00D37220 /* Tests */, - CCCF53BC237B13760035E9CA /* WordPressUnitTests.xctestplan */, + CEBD3EA90FF1BA3B00C1396E /* Blog.h */, + CEBD3EAA0FF1BA3B00C1396E /* Blog.m */, + E17FEAD9221494B2006E1D2D /* Blog+Analytics.swift */, + 46F583D32624D0BC0010A723 /* Blog+BlockEditorSettings.swift */, + 9A341E5521997A330036662E /* Blog+BlogAuthors.swift */, + 9A341E5421997A330036662E /* BlogAuthor.swift */, + B518E1641CCAA19200ADFE75 /* Blog+Capabilities.swift */, + 8BD34F0A27D14B3C005E931C /* Blog+DashboardState.swift */, + 7E7BEF6F22E1AED8009A880D /* Blog+Editor.swift */, + 73713582208EA4B900CCDFC8 /* Blog+Files.swift */, + B5EEDB961C91F10400676B2B /* Blog+Interface.swift */, + E11C4B6F2010930B00A6619C /* Blog+Jetpack.swift */, + 17D9362224729FB6008B2205 /* Blog+HomepageSettings.swift */, + 4A2172FD28F688890006F4F1 /* Blog+Media.swift */, + B5D889401BEBE30A007C4684 /* BlogSettings.swift */, + B55F1AA71C10936600FD04D4 /* BlogSettings+Discussion.swift */, + 8217380A1FE05EE600BEC94C /* BlogSettings+DateAndTimeFormat.swift */, + FF00889A204DF3ED007CCE66 /* Blog+Quota.swift */, + 43290D03215C28D800F6B398 /* Blog+QuickStart.swift */, + 401AC82622DD2387006D78D4 /* Blog+Plans.swift */, + 4A82C43028D321A300486CFF /* Blog+Post.swift */, + 2420BEF025D8DAB300966129 /* Blog+Lookup.swift */, + 4A9948E3297624EF006282A9 /* Blog+Creation.swift */, + F10D634E26F0B78E00E46CC7 /* Blog+Organization.swift */, + 8B4EDADC27DF9D5E004073B6 /* Blog+MySite.swift */, + 4AD5656E28E413160054C676 /* Blog+History.swift */, ); - path = WordPressTest; + name = Blog; sourceTree = ""; }; - E16AB93014D978240047A2E5 /* Supporting Files */ = { + B526DC241B1E473B002A8C5F /* WebViewController */ = { isa = PBXGroup; children = ( - 93E9050219E6F240005513C9 /* WordPressTest-Bridging-Header.h */, - E16AB93114D978240047A2E5 /* WordPressTest-Info.plist */, - E16AB93814D978240047A2E5 /* WordPressTest-Prefix.pch */, + E161B7EB1F839345000FDF0B /* CookieJar.swift */, + E137B1651F8B77D4006AC7FC /* WebNavigationDelegate.swift */, + E16FB7E21F8B61030004DD9F /* WebKitViewController.swift */, + F5D0A64823C8FA1500B20D27 /* LinkBehavior.swift */, + E1222B621F877FD700D23173 /* WebProgressView.swift */, + E16FB7E01F8B5D7D0004DD9F /* WebViewControllerConfiguration.swift */, + FA90EFEE262E74210055AB22 /* JetpackWebViewControllerFactory.swift */, + E1222B661F878E4700D23173 /* WebViewControllerFactory.swift */, + B526DC271B1E47FC002A8C5F /* WPStyleGuide+WebView.h */, + B526DC281B1E47FC002A8C5F /* WPStyleGuide+WebView.m */, + E1B62A7913AA61A100A6FCA4 /* WPWebViewController.h */, + E1B62A7A13AA61A100A6FCA4 /* WPWebViewController.m */, + 5D6C4B011B603D1F005E3C43 /* WPWebViewController.xib */, ); - name = "Supporting Files"; + name = WebViewController; sourceTree = ""; }; - E16AB94414D9A13A0047A2E5 /* Mock Data */ = { + B532ACD41DC3AE1F00FFFA57 /* Extensions */ = { isa = PBXGroup; children = ( - D8A468DE2181C5B50094B82F /* Site Creation */, - 7E442FC820F6783600DEACA5 /* ActivityLog */, - B5DA8A5E20ADAA1C00D5BDE1 /* plugin-directory-jetpack.json */, - 855408851A6F105700DDBD79 /* app-review-prompt-all-enabled.json */, - 855408891A6F107D00DDBD79 /* app-review-prompt-global-disable.json */, - 855408871A6F106800DDBD79 /* app-review-prompt-notifications-disabled.json */, - 93CD939219099BE70049096E /* authtoken.json */, - 74585B9A1F0D591D00E7E667 /* domain-service-valid-domains.json */, - 74585B9B1F0D591D00E7E667 /* domain-service-all-domain-types.json */, - 748437E91F1D4A4800E8DDAF /* gallery-reader-post-private.json */, - 748437EA1F1D4A4800E8DDAF /* gallery-reader-post-public.json */, - E12BE5ED1C5235DB000FD5CA /* get-me-settings-v1.1.json */, - E131CB5716CACFB4004B0314 /* get-user-blogs_doesnt-have-blog.json */, - E131CB5516CACF1E004B0314 /* get-user-blogs_has-blog.json */, - 93C882981EEB18D700227A59 /* html_page_with_link_to_rsd_non_standard.html */, - 93C882991EEB18D700227A59 /* html_page_with_link_to_rsd.html */, - E1EBC3741C118EDE00F638E0 /* ImmuTableTestViewCellWithNib.xib */, - E11330501A13BAA300D36D84 /* me-sites-with-jetpack.json */, - E1E4CE0E1774531500430844 /* misteryman.jpg */, - B58CE5DD1DC1284C004AA81D /* Notifications */, - 93C8829A1EEB18D700227A59 /* plugin_redirect.html */, - 93C8829B1EEB18D700227A59 /* rsd.xml */, - 93594BD4191D2F5A0079E6B2 /* stats-batch.json */, - 08B832411EC130D60079808D /* test-gif.gif */, - 08F8CD331EBD2AA80049D0C0 /* test-image-device-photo-gps-portrait.jpg */, - 08F8CD341EBD2AA80049D0C0 /* test-image-device-photo-gps.jpg */, - 08DF9C431E8475530058678C /* test-image-portrait.jpg */, - E1E4CE0517739FAB00430844 /* test-image.jpg */, - 084D94AE1EDF842600C385A6 /* test-video-device-gps.m4v */, - D88A64A3208D8FB6008AE9BC /* stock-photos-search-response.json */, - D88A64A5208D92B1008AE9BC /* stock-photos-media.json */, - D88A64A9208D974D008AE9BC /* thumbnail-collection.json */, - D88A64AD208D9CF5008AE9BC /* stock-photos-pageable.json */, - D82247FA2113F50600918CEB /* News.strings */, - D871F98B214235C9002849B0 /* NewsBadFormed.strings */, + B532ACD21DC3AE1200FFFA57 /* OHHTTPStubs+Helpers.swift */, + D8B6BEB6203E11F2007C8A19 /* Bundle+LoadFromNib.swift */, ); - name = "Mock Data"; - path = "Test Data"; + name = Extensions; sourceTree = ""; }; - E1756E661694AA1500D9EC00 /* Derived Sources */ = { + B53520991AF7BB9600B33BA8 /* Notifications */ = { isa = PBXGroup; children = ( - FFB7B81D1A0012E80032E723 /* ApiCredentials.m */, + F163541526DE2ECE008B625B /* NotificationEventTracker.swift */, + B535209A1AF7BBB800B33BA8 /* PushAuthenticationManager.swift */, + B5416CF41C171D7100006DD8 /* PushNotificationsManager.swift */, + B5B68BD31C19AAED00EB59E0 /* InteractiveNotificationsManager.swift */, ); - name = "Derived Sources"; - path = "../Derived Sources"; - sourceTree = BUILT_PRODUCTS_DIR; + name = Notifications; + sourceTree = ""; }; - E1A03EDF17422DBC0085D192 /* 10-11 */ = { + B53AD9B31BE9560F009AB87E /* Tools */ = { isa = PBXGroup; children = ( - E1A03EE017422DCD0085D192 /* BlogToAccount.h */, - E1A03EE117422DCE0085D192 /* BlogToAccount.m */, - E1A03F46174283DF0085D192 /* BlogToJetpackAccount.h */, - E1A03F47174283E00085D192 /* BlogToJetpackAccount.m */, + DC590CFE26F205C400EB0F73 /* Time Zone */, + E1EEFAD91CC4CC5700126533 /* Confirmable.h */, + B55086201CC15CCB004EADB4 /* PromptViewController.swift */, + E185042E1EE6ABD9005C234C /* Restorer.swift */, + 3F43602E23F31D48001DEE70 /* ScenePresenter.swift */, + E14B40FE1C58B93F005046F6 /* SettingsCommon.swift */, + B59D994E1C0790CC0003D795 /* SettingsListEditorViewController.swift */, + B54127691C0F7D610015CA80 /* SettingsMultiTextViewController.h */, + B541276A1C0F7D610015CA80 /* SettingsMultiTextViewController.m */, + B50EED781C0E5B2400D278CA /* SettingsPickerViewController.swift */, + B53AD9BD1BE9584A009AB87E /* SettingsSelectionViewController.h */, + B53AD9BE1BE9584A009AB87E /* SettingsSelectionViewController.m */, + B53AD9B81BE95687009AB87E /* SettingsTextViewController.h */, + B53AD9B91BE95687009AB87E /* SettingsTextViewController.m */, + E15632CB1EB9ECF40035A099 /* TableViewKeyboardObserver.swift */, + 3F170E232655917400F6F670 /* UIView+SwiftUI.swift */, ); - path = "10-11"; + path = Tools; sourceTree = ""; }; - E1B34C091CCDFFCE00889709 /* Credentials */ = { + B5416CF81C17542900006DD8 /* Notifications */ = { isa = PBXGroup; children = ( - FFC245D91D8C2033007F7518 /* wpcom_app_credentials-example */, - E1B34C0A1CCDFFCE00889709 /* gencredentials.rb */, - E1B34C0B1CCDFFCE00889709 /* ApiCredentials.h */, - E1B34C0C1CCDFFCE00889709 /* ApiCredentials.m */, + 7E987F57210811CC00CAFB88 /* NotificationContentRouterTests.swift */, + D8BA274C20FDEA2E007A5C77 /* NotificationTextContentTests.swift */, + D821C81A21003AE9002ED995 /* FormattableContentGroupTests.swift */, + D821C816210036D9002ED995 /* ActivityContentFactoryTests.swift */, + D848CC1820FF3A2400A9038F /* FormattableNotIconTests.swift */, + D848CC0620FF2BE200A9038F /* NotificationContentRangeFactoryTests.swift */, + D848CC0220FF04FA00A9038F /* FormattableUserContentTests.swift */, + D848CBFE20FF010F00A9038F /* FormattableCommentContentTests.swift */, + D848CBF820FEF82100A9038F /* NotificationsContentFactoryTests.swift */, + D81C2F6920F8B449002AE1F1 /* NotificationActionParserTest.swift */, + D81C2F6520F8ACCD002AE1F1 /* FormattableContentFormatterTests.swift */, + D81C2F6120F89632002AE1F1 /* EditCommentActionTests.swift */, + D81C2F5F20F891C4002AE1F1 /* TrashCommentActionTests.swift */, + D81C2F5D20F88CE5002AE1F1 /* MarkAsSpamActionTests.swift */, + D81C2F5B20F872C2002AE1F1 /* ReplyToCommentActionTests.swift */, + D81C2F5920F86E94002AE1F1 /* LikeCommentActionTests.swift */, + D81C2F5320F85DB1002AE1F1 /* ApproveCommentActionTests.swift */, + B5882C461D5297D1008E0EAA /* NotificationTests.swift */, + B5C0CF3E204DB92F00DB0362 /* NotificationReplyStoreTests.swift */, + 85B125401B028E34008A3D95 /* PushAuthenticationManagerTests.swift */, + 85F8E19C1B018698000859BB /* PushAuthenticationServiceTests.swift */, + B5416CFD1C1756B900006DD8 /* PushNotificationsManagerTests.m */, + 7E987F592108122A00CAFB88 /* NotificationUtility.swift */, ); - path = Credentials; + name = Notifications; sourceTree = ""; }; - E1B9127F1BB00EF1003C25B9 /* People */ = { + B542DEBB1D119683004CA6AE /* Tools */ = { isa = PBXGroup; children = ( - B59B18771CC7FC190055EB7C /* Controllers */, - B59B18761CC7FC070055EB7C /* Style */, - B59B18781CC7FC230055EB7C /* Views */, - B59B18791CC7FC330055EB7C /* ViewModels */, - E1B912801BB00EFD003C25B9 /* People.storyboard */, + 43EE90ED223B1028006A33E9 /* TextBundleWrapper.h */, + 43EE90EE223B1028006A33E9 /* TextBundleWrapper.m */, + E1CE41641E8D101A000CF5A4 /* ShareExtractor.swift */, + 7430C4481F97F23600E2673E /* ShareMediaFileManager.swift */, + 74CEF0761F9AA0F700B729CA /* ShareExtensionSessionManager.swift */, + 74448F532044BC7600BD4CDA /* CategoryTree.swift */, ); - path = People; + name = Tools; sourceTree = ""; }; - E1B921BA1C0ED481003EA3CB /* Cells */ = { + B545186718E9E08000AC3A54 /* Notifications */ = { isa = PBXGroup; children = ( - E1B921BB1C0ED5A3003EA3CB /* MediaSizeSliderCellTest.swift */, + D816C1EA20E0884100C4D82F /* Actions */, + 982DDE1226320B4A002B3904 /* Likes */, + B5722E401D51A28100F40C5E /* Notification.swift */, + B5899AE31B422D990075A3D6 /* NotificationSettings.swift */, ); - name = Cells; - path = ViewRelated/Cells; + path = Notifications; sourceTree = ""; }; - E1C4F4E61B29D5B900DAAB8E /* 32-33 */ = { + B54E1DEC1A0A7BAA00807537 /* ReplyTextView */ = { isa = PBXGroup; children = ( - E16273E01B2ACEB600088AF7 /* BlogToBlog32to33.swift */, + B54E1DEE1A0A7BAA00807537 /* ReplyTextView.swift */, + B54E1DEF1A0A7BAA00807537 /* ReplyTextView.xib */, ); - name = "32-33"; + path = ReplyTextView; sourceTree = ""; }; - E1C9AA541C1041E600732665 /* Extensions */ = { + B565D41C3DB27630CD503F9A /* Pods */ = { isa = PBXGroup; children = ( - B556EFCA1DCA374200728F93 /* DictionaryHelpersTests.swift */, - E1C9AA551C10427100732665 /* MathTest.swift */, - E63C897B1CB9A0D700649C8F /* UITextFieldTextHelperTests.swift */, - B5552D811CD1061F00B26DF6 /* StringExtensionsTests.swift */, - E1AB5A081E0BF31E00574B4E /* ArrayTests.swift */, - 0885A3661E837AFE00619B4D /* URLIncrementalFilenameTests.swift */, - D88A649D208D82D2008AE9BC /* XCTestCase+Wait.swift */, - E11DF3E320C97F0A00C0B07C /* NotificationCenterObserveOnceTests.swift */, - F17A2A1F23BFBD84001E96AC /* UIView+ExistingConstraints.swift */, - F9941D1722A805F600788F33 /* UIImage+XCAssetTests.swift */, - 8BFE36FE230F1C850061EBA8 /* AbstractPost+fixLocalMediaURLsTests.swift */, + 75305C06D345590B757E3890 /* Pods-Apps-WordPress.debug.xcconfig */, + 51A5F017948878F7E26979A0 /* Pods-Apps-WordPress.release.xcconfig */, + BBA98A42A5503D734AC9C936 /* Pods-Apps-WordPress.release-internal.xcconfig */, + F373612EEEEF10E500093FF3 /* Pods-Apps-WordPress.release-alpha.xcconfig */, + 084FF460C7742309671B3A86 /* Pods-WordPressTest.debug.xcconfig */, + EF379F0A70B6AC45330EE287 /* Pods-WordPressTest.release.xcconfig */, + C9264D275F6288F66C33D2CE /* Pods-WordPressTest.release-internal.xcconfig */, + 131D0EE49695795ECEDAA446 /* Pods-WordPressTest.release-alpha.xcconfig */, + A27DA63A5ECD1D0262C589EC /* Pods-WordPressNotificationServiceExtension.debug.xcconfig */, + 73FEFF1991AE9912FB2DA9BC /* Pods-WordPressNotificationServiceExtension.release.xcconfig */, + 33D5016BDA00B45DFCAF3818 /* Pods-WordPressNotificationServiceExtension.release-internal.xcconfig */, + EEF80689364FA9CAE10405E8 /* Pods-WordPressNotificationServiceExtension.release-alpha.xcconfig */, + D67306CD28F2440FF6B0065C /* Pods-WordPressIntents.debug.xcconfig */, + ADE06D6829F9044164BBA5AB /* Pods-WordPressIntents.release.xcconfig */, + A0D83E08D5D2573348DE8926 /* Pods-WordPressIntents.release-internal.xcconfig */, + 8A21014FBE43ADE551F4ECB4 /* Pods-WordPressIntents.release-alpha.xcconfig */, + E850CD4B77CF21E683104B5A /* Pods-WordPressStatsWidgets.debug.xcconfig */, + 02BF978AFC1EFE50CFD558C2 /* Pods-WordPressStatsWidgets.release.xcconfig */, + C2988A406A3D5697C2984F3E /* Pods-WordPressStatsWidgets.release-internal.xcconfig */, + 6C1B070FAD875CA331772B57 /* Pods-WordPressStatsWidgets.release-alpha.xcconfig */, + 152F25D5C232985E30F56CAC /* Pods-Apps-Jetpack.debug.xcconfig */, + 011A2815DB0DE7E3973CBC0E /* Pods-Apps-Jetpack.release.xcconfig */, + 67832AB9D81652460A80BE66 /* Pods-Apps-Jetpack.release-internal.xcconfig */, + 150B6590614A28DF9AD25491 /* Pods-Apps-Jetpack.release-alpha.xcconfig */, + C8FC2DE857126670AE377B5D /* Pods-WordPressScreenshotGeneration.debug.xcconfig */, + DB915AD54243A8AE0039B0C7 /* Pods-WordPressScreenshotGeneration.release.xcconfig */, + B5AF0C63305888C3424155D6 /* Pods-WordPressScreenshotGeneration.release-internal.xcconfig */, + 528B9926294302CD0A4EB5C4 /* Pods-WordPressScreenshotGeneration.release-alpha.xcconfig */, + 09F367D2BE684EDE2E4A40E3 /* Pods-WordPressDraftActionExtension.debug.xcconfig */, + 8DE205D2AC15F16289E7D21A /* Pods-WordPressDraftActionExtension.release.xcconfig */, + CDA9AED50FDA27959A5CD1B2 /* Pods-WordPressDraftActionExtension.release-internal.xcconfig */, + CB1DAFB7DE085F2FF0314622 /* Pods-WordPressShareExtension.debug.xcconfig */, + 32387A1D541851E82ED957CE /* Pods-WordPressShareExtension.release.xcconfig */, + C82B4C5ECF11C9FEE39CD9A0 /* Pods-WordPressShareExtension.release-internal.xcconfig */, + 1BC96E982E9B1A6DD86AF491 /* Pods-WordPressShareExtension.release-alpha.xcconfig */, + F85B762A18D018C22DF2A40D /* Pods-JetpackShareExtension.debug.xcconfig */, + 7EC2116478565023EDB57703 /* Pods-JetpackShareExtension.release.xcconfig */, + 293E283D7339E7B6D13F6E09 /* Pods-JetpackShareExtension.release-internal.xcconfig */, + C7AEA9D1F1AC3F501B6DE0C8 /* Pods-JetpackShareExtension.release-alpha.xcconfig */, + 9149D34BF5182F360C84EDB9 /* Pods-JetpackDraftActionExtension.debug.xcconfig */, + CE5249687F020581B14F4172 /* Pods-JetpackDraftActionExtension.release.xcconfig */, + 5E48AA7F709A5B0F2318A7E3 /* Pods-JetpackDraftActionExtension.release-internal.xcconfig */, + C5E82422F47D9BF7E682262B /* Pods-JetpackDraftActionExtension.release-alpha.xcconfig */, + 33E5165A9AB08C676380FA34 /* Pods-JetpackNotificationServiceExtension.debug.xcconfig */, + 49E3445F1B568603958DA79D /* Pods-JetpackNotificationServiceExtension.release.xcconfig */, + D01FA8A28AD63D2600800134 /* Pods-JetpackNotificationServiceExtension.release-internal.xcconfig */, + 3C8DE270EF0498A2129349B0 /* Pods-JetpackNotificationServiceExtension.release-alpha.xcconfig */, + F75F3A68DCE524B4BAFCE76E /* Pods-WordPressDraftActionExtension.release-alpha.xcconfig */, + 18B1A53E374E22C490A08F23 /* Pods-JetpackStatsWidgets.debug.xcconfig */, + B3694C30079615C927D26E9F /* Pods-JetpackStatsWidgets.release.xcconfig */, + B4CAFF307BEC7FD77CCF573C /* Pods-JetpackStatsWidgets.release-internal.xcconfig */, + 3AB6A3B516053EA8D0BC3B17 /* Pods-JetpackStatsWidgets.release-alpha.xcconfig */, + 87A8AC8362510EB42708E5B3 /* Pods-JetpackIntents.debug.xcconfig */, + 549D51B99FF59CBE21A37CBF /* Pods-JetpackIntents.release.xcconfig */, + 4391027D80CFEDF45B8712A3 /* Pods-JetpackIntents.release-internal.xcconfig */, + B124AFFFB3F0204107FD33D0 /* Pods-JetpackIntents.release-alpha.xcconfig */, + CB72288DBD93471DA0AD878A /* Pods-WordPressUITests.debug.xcconfig */, + 8B9E15DAF3E1A369E9BE3407 /* Pods-WordPressUITests.release.xcconfig */, + B8EED8FA87452C1FD113FD04 /* Pods-WordPressUITests.release-internal.xcconfig */, + 5C1CEB34870A8BA1ED1E502B /* Pods-WordPressUITests.release-alpha.xcconfig */, ); - path = Extensions; + name = Pods; sourceTree = ""; }; - E1CA0A6A1FA73039004C4BBE /* Stores */ = { + B56994461B7A82A300FF26FA /* Style */ = { isa = PBXGroup; children = ( - E1ECE34E1FA88DA2007FA37A /* StoreContainer.swift */, - 402B2A7820ACD7690027C1DC /* ActivityStore.swift */, - 17FCA6801FD84B4600DBA9C8 /* NoticeStore.swift */, - E1CA0A6B1FA73053004C4BBE /* PluginStore.swift */, - 40ADB15420686870009A9161 /* PluginStore+Persistence.swift */, - 98AE3DF4219A1788003C0E24 /* StatsInsightsStore.swift */, - 984B139321F66B2D0004B6A2 /* StatsPeriodStore.swift */, - 9A4A8F4A235758EF00088CE4 /* StatsStore+Cache.swift */, - E1CB6DA2200F376400945457 /* TimeZoneStore.swift */, - 9A2D0B24225CB97F009E585F /* JetpackInstallStore.swift */, - 9A4E271C22EF33F5001F6A6B /* AccountSettingsStore.swift */, - 9A09F914230C3E9700F42AB7 /* StoreFetchingStatus.swift */, + B56994441B7A7EF200FF26FA /* WPStyleGuide+Comments.swift */, + FEA7948F26DF7F3700CEC520 /* WPStyleGuide+CommentDetail.swift */, ); - path = Stores; + name = Style; sourceTree = ""; }; - E1F391ED1FF25DEC00DB32A3 /* Jetpack */ = { + B56994471B7A82CD00FF26FA /* Controllers */ = { isa = PBXGroup; children = ( - 9A8ECE002254A3250043C8DA /* Login */, - 9A8ECE032254A3260043C8DA /* Install */, + 08A250FA28D9EDF600F50420 /* CommentDetailInfo */, + C533CF330E6D3ADA000C3DE8 /* CommentsViewController.h */, + C533CF340E6D3ADA000C3DE8 /* CommentsViewController.m */, + 987C40C525E590BE002A0955 /* CommentsViewController+Filters.swift */, + D8B9B592204F6C93003C6042 /* CommentsViewController+Network.h */, + 9895401026C1F39300EDEB5A /* EditCommentTableViewController.swift */, + 2906F80F110CDA8900169D56 /* EditCommentViewController.h */, + 2906F810110CDA8900169D56 /* EditCommentViewController.m */, + 0A3FCA1C28B71CBC00499A15 /* FullScreenCommentReplyViewModel.swift */, + 328CEC5D23A532BA00A6899E /* FullScreenCommentReplyViewController.swift */, + FE9CC71926D7A2A40026AEF3 /* CommentDetailViewController.swift */, ); - path = Jetpack; + name = Controllers; sourceTree = ""; }; - E1F47D4B1FE028F800C1D44E /* Views */ = { + B56994481B7A82D400FF26FA /* Views */ = { isa = PBXGroup; children = ( - 401A3D012027DBD80099A127 /* PluginListCell.xib */, - 403269912027719C00608441 /* PluginDirectoryAccessoryItem.swift */, - 400F4624201E74EE000CFD9E /* CollectionViewContainerRow.swift */, - 40A2778020191B5E00D078D5 /* PluginDirectoryCollectionViewCell.xib */, - 40A2777E20191AA500D078D5 /* PluginDirectoryCollectionViewCell.swift */, - E1F47D4C1FE0290C00C1D44E /* PluginListCell.swift */, - 40E728841FF3D9070010E7C9 /* PluginDetailViewHeaderCell.swift */, - 4058F4191FF40EE1000D5559 /* PluginDetailViewHeaderCell.xib */, + FEA7948B26DD134400CEC520 /* Detail */, + 9835F16D25E492EE002EFF23 /* CommentsList.storyboard */, + 5D6C4AFD1B603CE9005E3C43 /* EditCommentViewController.xib */, + FEDA1AD7269D475D0038EC98 /* ListTableViewCell+Comments.swift */, ); name = Views; sourceTree = ""; }; - E62AFB641DC8E56B007484FC /* WPRichText */ = { + B572735C1B66CCEF000D1C4F /* AlertView */ = { isa = PBXGroup; children = ( - E6805D2D1DCD399600168E4F /* WPRichTextEmbed.swift */, - E6805D2E1DCD399600168E4F /* WPRichTextImage.swift */, - E6805D2F1DCD399600168E4F /* WPRichTextMediaAttachment.swift */, - E62AFB651DC8E593007484FC /* NSAttributedString+WPRichText.swift */, - E62AFB661DC8E593007484FC /* WPRichContentView.swift */, - E62AFB671DC8E593007484FC /* WPRichTextFormatter.swift */, - E62AFB681DC8E593007484FC /* WPTextAttachment.swift */, - E68580F51E0D91470090EE63 /* WPHorizontalRuleAttachment.swift */, - E62AFB691DC8E593007484FC /* WPTextAttachmentManager.swift */, + B572735D1B66CCEF000D1C4F /* AlertInternalView.swift */, + B572735E1B66CCEF000D1C4F /* AlertView.swift */, + B572735F1B66CCEF000D1C4F /* AlertView.xib */, ); - name = WPRichText; + path = AlertView; sourceTree = ""; }; - E6417B951CA07AFE0084050A /* Views */ = { + B587796C19B799D800E57C5A /* Extensions */ = { isa = PBXGroup; children = ( - 9872CB36203BB53F0066A293 /* Epilogues */, - 43FB3F401EBD215C00FC8A62 /* LoginEpilogueBlogCell.swift */, + F407AF1529BA835B008BA5B9 /* Font */, + 4326191322FCB8BE003C7642 /* Colors and Styles */, + 086C4D0C1E81F7920011D960 /* Media */, + B587798319B799EB00E57C5A /* Notifications */, + F580C3CA23D8F9B40038E243 /* AbstractPost+Dates.swift */, + 8BFE36FC230F16580061EBA8 /* AbstractPost+fixLocalMediaURLs.swift */, + E1AB5A061E0BF17500574B4E /* Array.swift */, + 9A2CD5362146B8C700AE5055 /* Array+Page.swift */, + 175CC17827230DC900622FB4 /* Bool+StringRepresentation.swift */, + FF619DD41C75246900903B65 /* CLPlacemark+Formatting.swift */, + E14DFAFA1E07E7C400494688 /* Data.swift */, + FAD7625A29ED780B00C09583 /* JSONDecoderExtension.swift */, + E1C9AA501C10419200732665 /* Math.swift */, + 98EB126920D2DC2500D2D5B5 /* NoResultsViewController+Model.swift */, + 324780E0247F2E2A00987525 /* NoResultsViewController+FollowedSites.swift */, + E14DF70520C922F200959BA9 /* NotificationCenter+ObserveOnce.swift */, + B54866C91A0D7042004AC79D /* NSAttributedString+Helpers.swift */, + 1751E5921CE23801000CA08D /* NSAttributedString+StyledHTML.swift */, + B56F25871FBDE502005C33E4 /* NSAttributedStringKey+Conversion.swift */, + B5E167F319C08D18009535AA /* NSCalendar+Helpers.swift */, + B5DD04731CD3DAB00003DF89 /* NSFetchedResultsController+Helpers.swift */, + FF0148E41DFABBC9001AD265 /* NSFileManager+FolderSize.swift */, + E19B17AF1E5C69A5007517C6 /* NSManagedObject.swift */, + AE2F3127270B6DE200B2A9C2 /* NSMutableAttributedString+ApplyAttributesToQuotes.swift */, + B574CE141B5E8EA800A84FFD /* NSMutableAttributedString+Helpers.swift */, + B5BE31C31CB825A100BDF770 /* NSURLCache+Helpers.swift */, + E1CFC1561E0AC8FF001DF9E9 /* Pattern.swift */, + FF70A3201FD5840500BC270D /* PHAsset+Metadata.swift */, + 83C972DF281C45AB0049E1FE /* Post+BloggingPrompts.swift */, + FFD12D5D1FE1998D00F20A00 /* Progress+Helpers.swift */, + AE2F3124270B6DA000B2A9C2 /* Scanner+QuotedText.swift */, + E177807F1C97FA9500FA7E14 /* StoreKit+Debug.swift */, + 7360018E20A265C7001E5E31 /* String+Files.swift */, + C737553D27C80DD500C6E9A1 /* String+CondenseWhitespace.swift */, + B55FFCF91F034F1A0070812C /* String+Ranges.swift */, + B54C02231F38F50100574572 /* String+RegEx.swift */, + B5969E2120A49E86005E9DF1 /* UIAlertController+Helpers.swift */, + 1707CE411F3121750020B7FE /* UICollectionViewCell+Tint.swift */, + E1823E6B1E42231C00C19F53 /* UIEdgeInsets.swift */, + B58C4EC9207C5E1900E32E4D /* UIImage+Assets.swift */, + FF70A3211FD5840500BC270D /* UIImage+Export.swift */, + B5E94D141FE04815000E7C20 /* UIImageView+SiteIcon.swift */, + 1790A4521E28F0ED00AE54C2 /* UINavigationController+Helpers.swift */, + 177E7DAC1DD0D1E600890467 /* UINavigationController+SplitViewFullscreen.swift */, + 171CC15724FCEBF7008B7180 /* UINavigationBar+Appearance.swift */, + 7326A4A7221C8F4100B4EB8C /* UIStackView+Subviews.swift */, + 8BF0B606247D88EB009A7457 /* UITableViewCell+enableDisable.swift */, + F19153BC2549ED0800629EC4 /* UITextField+WorkaroundContinueIssue.swift */, + D829C33A21B12EFE00B09F12 /* UIView+Borders.swift */, + F17A2A1D23BFBD72001E96AC /* UIView+ExistingConstraints.swift */, + 8BA125EA27D8F5E4008B779F /* UIView+PinSubviewPriority.swift */, + 9A162F2421C26F5F00FDC035 /* UIViewController+ChildViewController.swift */, + F1E72EB9267790100066FF91 /* UIViewController+Dismissal.swift */, + 9F3EFCA2208E308900268758 /* UIViewController+Notice.swift */, + 9A4E939E2268D9B400E14823 /* UIViewController+NoResults.swift */, + 0845B8C51E833C56001BA771 /* URL+Helpers.swift */, + F5E1BBDF253B74240091E9A6 /* URLQueryItem+Parameters.swift */, + F12E500223C7C5330068CB5E /* WKWebView+UserAgent.swift */, + 57276E70239BDFD200515BE2 /* NotificationCenter+ObserveMultiple.swift */, + 3250490624F988220036B47F /* Interpolation.swift */, + 8B3626F825A665E500D7CCE3 /* UIApplication+mainWindow.swift */, + FE02F95E269DC14A00752A44 /* Comment+Interface.swift */, + C3C39B0626F50D3900B1238D /* WordPressSupportSourceTag+Editor.swift */, + 179A70EF2729834B006DAC0A /* Binding+OnChange.swift */, + FA20751327A86B73001A644D /* UIScrollView+Helpers.swift */, + C7AFF873283C0ADC000E01DF /* UIApplication+Helpers.swift */, + C3AB4878292F114A001F7AF8 /* UIApplication+AppAvailability.swift */, + F49D7BEA29DF329500CB93A5 /* UIPopoverPresentationController+PopoverAnchor.swift */, ); - name = Views; + path = Extensions; sourceTree = ""; }; - E6417B961CA07B060084050A /* Controllers */ = { + B587798319B799EB00E57C5A /* Notifications */ = { isa = PBXGroup; children = ( - 98A437AD20069097004A8A57 /* DomainSuggestionsTableViewController.swift */, - E6C1E8431EF1D21F00D139D9 /* Epilogue */, - 98C43E841FE98041006FEF54 /* Social Signup */, - ); - name = Controllers; + B587798419B799EB00E57C5A /* Notification+Interface.swift */, + D81322B22050F9110067714D /* NotificationName+Names.swift */, + ); + path = Notifications; sourceTree = ""; }; - E6417B9A1CA07C0A0084050A /* Helpers */ = { + B58CE5DD1DC1284C004AA81D /* Notifications */ = { isa = PBXGroup; children = ( - B560914B208A671E00399AE4 /* WPStyleGuide+SiteCreation.swift */, - E6158AC91ECDF518005FA441 /* LoginEpilogueUserInfo.swift */, - B5E51B7A203477DF00151ECD /* WordPressAuthenticationManager.swift */, - B5326E6E203F554C007392C3 /* WordPressSupportSourceTag+Helpers.swift */, - 43D74AC720F8D17A004AD934 /* DomainSuggestionsButtonViewPresenter.swift */, + E150275E1E03E51500B847E3 /* notes-action-delete.json */, + E150275F1E03E51500B847E3 /* notes-action-push.json */, + E15027601E03E51500B847E3 /* notes-action-unsupported.json */, + 7E92828821090E9A00BBD8A3 /* notifications-pingback.json */, + B5AEEC741ACACFDA008BF2A4 /* notifications-badge.json */, + 748BD8881F1923D500813F9A /* notifications-last-seen.json */, + B5AEEC751ACACFDA008BF2A4 /* notifications-like.json */, + 748BD8861F19238600813F9A /* notifications-load-all.json */, + 748BD8841F19234300813F9A /* notifications-mark-as-read.json */, + B5AEEC771ACACFDA008BF2A4 /* notifications-new-follower.json */, + B5AEEC781ACACFDA008BF2A4 /* notifications-replied-comment.json */, + 7EF2EE9F210A67B60007A76B /* notifications-unapproved-comment.json */, + B5EFB1D01B33630C007608A3 /* notifications-settings.json */, + 17C3F8BE25E4438100EFFE12 /* notifications-button-text-content.json */, + D848CBF620FEEE7F00A9038F /* notifications-text-content.json */, + D848CBFA20FEFA4800A9038F /* notifications-comment-content.json */, + D848CC0020FF030C00A9038F /* notifications-comment-meta.json */, + D848CBFC20FEFB4900A9038F /* notifications-user-content.json */, + D848CC0420FF062100A9038F /* notifications-user-content-meta.json */, + D848CC0820FF2D4400A9038F /* notifications-icon-range.json */, + D848CC0A20FF2D5D00A9038F /* notifications-user-range.json */, + D848CC0C20FF2D7B00A9038F /* notifications-post-range.json */, + D848CC0E20FF2D9B00A9038F /* notifications-comment-range.json */, + D848CC1020FF310400A9038F /* notifications-site-range.json */, + D848CC1220FF31BB00A9038F /* notifications-blockquote-range.json */, ); - name = Helpers; + name = Notifications; sourceTree = ""; }; - E6431DDE1C4E890B00FD8D90 /* Sharing */ = { + B59B18761CC7FC070055EB7C /* Style */ = { isa = PBXGroup; children = ( - E6C892D51C601D55007AD612 /* SharingButtonsViewController.swift */, - E6431DE31C4E892900FD8D90 /* SharingViewController.h */, - E6431DE41C4E892900FD8D90 /* SharingViewController.m */, - E6431DDF1C4E892900FD8D90 /* SharingConnectionsViewController.h */, - E6431DE01C4E892900FD8D90 /* SharingConnectionsViewController.m */, - E63BBC941C5168BE00598BE8 /* SharingAuthorizationHelper.h */, - E63BBC951C5168BE00598BE8 /* SharingAuthorizationHelper.m */, - F16601C323E9E783007950AE /* SharingAuthorizationWebViewController.swift */, - E663D18F1C65383E0017F109 /* SharingAccountViewController.swift */, - E6431DE11C4E892900FD8D90 /* SharingDetailViewController.h */, - E6431DE21C4E892900FD8D90 /* SharingDetailViewController.m */, - E64384821C628FCC0052ADB5 /* WPStyleGuide+Sharing.swift */, - E60BD230230A3DD400727E82 /* KeyringAccountHelper.swift */, + E1B9128A1BB0129C003C25B9 /* WPStyleGuide+People.swift */, ); - name = Sharing; + name = Style; sourceTree = ""; }; - E66969D01B9E3D5000EC9C00 /* 37-38 */ = { + B59B18771CC7FC190055EB7C /* Controllers */ = { isa = PBXGroup; children = ( - E66969D91B9E55AB00EC9C00 /* ReaderTopicToReaderTagTopic37to38.swift */, - E66969DB1B9E55C300EC9C00 /* ReaderTopicToReaderListTopic37to38.swift */, - E66969DF1B9E648100EC9C00 /* ReaderTopicToReaderDefaultTopic37to38.swift */, - E66969E11B9E67A000EC9C00 /* ReaderTopicToReaderSiteTopic37to38.swift */, - E66969E31B9E68B200EC9C00 /* ReaderPostToReaderPost37to38.swift */, + E1B912881BB01288003C25B9 /* PeopleViewController.swift */, + C3BC86F529528997005D1A01 /* PeopleViewController+JetpackBannerViewController.swift */, + B59B18741CC7FB8D0055EB7C /* PersonViewController.swift */, + B56FEB781CD8E13C00E621F9 /* RoleViewController.swift */, + B549BA671CF7447E0086C608 /* InvitePersonViewController.swift */, ); - name = "37-38"; + name = Controllers; sourceTree = ""; }; - E6B9B8AB1B94EA710001B92F /* Reader */ = { + B59B18781CC7FC230055EB7C /* Views */ = { isa = PBXGroup; children = ( - 3F8CB103239E025F007627BF /* ReaderDetailViewControllerTests.swift */, - D809E685203F0215001AA0DE /* ReaderPostCardCellTests.swift */, - D80EE639203DEE1B0094C34C /* ReaderFollowedSitesStreamHeaderTests.swift */, - 748437ED1F1D4A7300E8DDAF /* RichContentFormatterTests.swift */, - E6B9B8AE1B94FA1C0001B92F /* ReaderStreamViewControllerTests.swift */, - 3F1B66A123A2F52A0075F09E /* ReaderPostActions */, + E1B912841BB01266003C25B9 /* PeopleCell.swift */, + E1B912821BB01047003C25B9 /* PeopleRoleBadgeLabel.swift */, + 7E7B4CF720459E21001463D6 /* PersonHeaderCell.swift */, ); - name = Reader; + name = Views; sourceTree = ""; }; - E6C1E8431EF1D21F00D139D9 /* Epilogue */ = { + B59B18791CC7FC330055EB7C /* ViewModels */ = { isa = PBXGroup; children = ( - 433432511E9ED18900915988 /* LoginEpilogueViewController.swift */, - 43FB3F461EC10F1E00FC8A62 /* LoginEpilogueTableViewController.swift */, + E166FA1A1BB0656B00374B5B /* PeopleCellViewModel.swift */, ); - name = Epilogue; + name = ViewModels; sourceTree = ""; }; - E6D2E16A1B8B41AC0000ED14 /* Headers */ = { + B5AEEC731ACACF3B008BF2A4 /* Core Data */ = { isa = PBXGroup; children = ( - E6ED09081D46AD29003283C4 /* ReaderFollowedSitesStreamHeader.swift */, - E6ED090A1D46AFAF003283C4 /* ReaderFollowedSitesStreamHeader.xib */, - E6D2E1681B8AAD9B0000ED14 /* ReaderListStreamHeader.swift */, - E6D2E1621B8AAA340000ED14 /* ReaderListStreamHeader.xib */, - E6D2E1641B8AAD7E0000ED14 /* ReaderSiteStreamHeader.swift */, - E6D2E15E1B8A9C830000ED14 /* ReaderSiteStreamHeader.xib */, - E6D2E16B1B8B423B0000ED14 /* ReaderStreamHeader.swift */, - E6D2E1661B8AAD8C0000ED14 /* ReaderTagStreamHeader.swift */, - E6D2E1601B8AA4410000ED14 /* ReaderTagStreamHeader.xib */, + 93E9050319E6F242005513C9 /* ContextManagerTests.swift */, + B5ECA6CC1DBAAD510062D7E0 /* CoreDataHelperTests.swift */, + 931D26FF19EDAE8600114F17 /* CoreDataMigrationTests.m */, + FE320CC4294705990046899B /* ReaderPostBackupTests.swift */, ); - name = Headers; + name = "Core Data"; sourceTree = ""; }; - EC4696A80EA74DAC0040EE8E /* Pages */ = { + B5AEEC7E1ACAD088008BF2A4 /* Services */ = { isa = PBXGroup; children = ( - 5DFA7EC21AF7CB910072023B /* Pages.storyboard */, - 5703A4C722C0214C0028A343 /* Style */, - 9A22D9BE214A6B9800BAEAF2 /* Utils */, - 5DFA7EBD1AF7CB2E0072023B /* Controllers */, - 5DFA7EBE1AF7CB3A0072023B /* Views */, - 5D62BAD518AA88210044E5F7 /* PageSettingsViewController.h */, - 5D62BAD618AA88210044E5F7 /* PageSettingsViewController.m */, + 9363113E19FA996700B0C739 /* AccountServiceTests.swift */, + 4A9948E129714EF1006282A9 /* AccountSettingsServiceTests.swift */, + F1F083F5241FFE930056D3B1 /* AtomicAuthenticationServiceTests.swift */, + 46F58500262605930010A723 /* BlockEditorSettingsServiceTests.swift */, + FEA312832987FB0100FFD405 /* BlogJetpackTests.swift */, + 930FD0A519882742000CC81D /* BlogServiceTest.m */, + E18549DA230FBFEF003C620E /* BlogServiceDeduplicationTests.swift */, + C314543A262770BE005B216B /* BlogServiceAuthorTests.swift */, + AB2211F325ED6E7A00BF72FC /* CommentServiceTests.swift */, + 4A76A4BA29D4381000AABF4B /* CommentService+LikesTests.swift */, + 4A76A4BC29D43BFD00AABF4B /* CommentService+MorderationTests.swift */, + FEFC0F8B273131A6001F7F1D /* CommentService+RepliesTests.swift */, + 74585B981F0D58F300E7E667 /* DomainsServiceTests.swift */, + FAB4F32624EDE12A00F259BA /* FollowCommentsServiceTests.swift */, + B5772AC51C9C84900031F97E /* GravatarServiceTests.swift */, + 8B749E8F25AF8D2E00023F03 /* JetpackCapabilitiesServiceTests.swift */, + F1BB660B274E704D00A319BE /* LikeUserHelperTests.swift */, + 59A9AB391B4C3ECD00A433DC /* LocalCoreDataServiceTests.m */, + F11023A0231863CE00C4E84A /* MediaServiceTests.swift */, + E1C5457F1C6C79BB001CEB0E /* MediaSettingsTests.swift */, + 08AAD6A01CBEA610002B2418 /* MenusServiceTests.m */, + E135965C1E7152D1006C6606 /* RecentSitesServiceTests.swift */, + B5EFB1C81B333C5A007608A3 /* NotificationSettingsServiceTests.swift */, + B532ACCE1DC3AB8E00FFFA57 /* NotificationSyncMediatorTests.swift */, + 8BC6020C2390412000EFE3D0 /* NullBlogPropertySanitizerTests.swift */, + 08A2AD7A1CCED8E500E84454 /* PostCategoryServiceTests.m */, + 8BC12F71231FEBA1004DDA72 /* PostCoordinatorTests.swift */, + 8B821F3B240020E2006B697E /* PostServiceUploadingListTests.swift */, + 8BC12F7623201B86004DDA72 /* PostService+MarkAsFailedAndDraftIfNeededTests.swift */, + 08A2AD781CCED2A800E84454 /* PostTagServiceTests.m */, + 8BB185CD24B62CE100A4CCE8 /* ReaderCardServiceTests.swift */, + 5DE8A0401912D95B00B2FF59 /* ReaderPostServiceTest.m */, + 4AEF2DD829A84B2C00345734 /* ReaderSiteServiceTests.swift */, + E66969C71B9E0A6800EC9C00 /* ReaderTopicServiceTest.swift */, + FA4ADAD91C509FE400F858D7 /* SiteManagementServiceTests.swift */, + 59FBD5611B5684F300734466 /* ThemeServiceTests.m */, + 40E4698E2017E0700030DB5F /* PluginDirectoryEntryStateTests.swift */, + 7E8980B822E73F4000C567B0 /* EditorSettingsServiceTests.swift */, + 570B037622F1FFF6009D8411 /* PostCoordinatorFailedPostsFetcherTests.swift */, + 57569CF1230485680052EE14 /* PostAutoUploadInteractorTests.swift */, + 57D66B9C234BB78B005A2D74 /* PostServiceWPComTests.swift */, + 57240223234E5BE200227067 /* PostServiceSelfHostedTests.swift */, + 575802122357C41200E4C63C /* MediaCoordinatorTests.swift */, + 46B30B772582C7DD00A25E66 /* SiteAddressServiceTests.swift */, + 17FC0031264D728E00FCBD37 /* SharingServiceTests.swift */, + FE2E3728281C839C00A1E82A /* BloggingPromptsServiceTests.swift */, + 010459EC2915519C000C7778 /* JetpackNotificationMigrationServiceTests.swift */, + 4A9314DB297790C300360232 /* PeopleServiceTests.swift */, + FEAA6F78298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift */, + 1D91080629F847A2003F9A5E /* MediaServiceUpdateTests.m */, ); - path = Pages; + name = Services; sourceTree = ""; }; - F126FDFC20A33BDB0010EB6E /* Processors */ = { + B5AEEC7F1ACAD099008BF2A4 /* Categories */ = { isa = PBXGroup; children = ( - F126FDFD20A33BDB0010EB6E /* VideoUploadProcessor.swift */, - F126FDFE20A33BDB0010EB6E /* ImgUploadProcessor.swift */, - F126FDFF20A33BDB0010EB6E /* DocumentUploadProcessor.swift */, + B59D40A51DB522DF003D2D79 /* NSAttributedStringTests.swift */, + B566EC741B83867800278395 /* NSMutableAttributedStringTests.swift */, ); - path = Processors; + name = Categories; sourceTree = ""; }; - F14B5F6F208E648200439554 /* config */ = { + B5BEA5661C7CEB4400C8035B /* Supporting Files */ = { isa = PBXGroup; children = ( - 98FB6E9F23074CE5002DDC8D /* SDKVersions.xcconfig */, - F14B5F75208E64F900439554 /* Version.internal.xcconfig */, - F14B5F74208E64F900439554 /* Version.public.xcconfig */, - F14B5F70208E648200439554 /* WordPress.debug.xcconfig */, - F14B5F71208E648200439554 /* WordPress.release.xcconfig */, - F14B5F72208E648300439554 /* WordPress.internal.xcconfig */, - F14B5F73208E648300439554 /* WordPress.alpha.xcconfig */, + B50248B91C96FFCC00AFBDED /* Info.plist */, + 93C3C2581CAB032C0092F837 /* Info-Alpha.plist */, + 93C3C2561CAB031E0092F837 /* Info-Internal.plist */, + B50248BD1C96FFCC00AFBDED /* WordPressShare.entitlements */, + B50248BA1C96FFCC00AFBDED /* WordPressShare-Alpha.entitlements */, + B50248BB1C96FFCC00AFBDED /* WordPressShare-Internal.entitlements */, + B50248BC1C96FFCC00AFBDED /* WordPressShare-Lumberjack.m */, + B50248BE1C96FFCC00AFBDED /* WordPressSharePrefix.pch */, ); - name = config; - path = ../config; + name = "Supporting Files"; sourceTree = ""; }; - F198533821ADAA4E00DCDAE7 /* Editor */ = { + B5C9401B1DB901120079D4FF /* Account */ = { isa = PBXGroup; children = ( - F115308021B17E65002F1D65 /* EditorFactory.swift */, - FF54D4631D6F3FA900A0DC4D /* GutenbergSettings.swift */, - 1E9D544C23C4C56300F6A9E0 /* GutenbergRollout.swift */, + B5C940191DB900DC0079D4FF /* AccountHelper.swift */, ); - path = Editor; + name = Account; sourceTree = ""; }; - F198533B21ADAD0700DCDAE7 /* Editor */ = { + B5C9401D1DB901F30079D4FF /* Reachability */ = { isa = PBXGroup; children = ( - 932645A31E7C206600134988 /* GutenbergSettingsTests.swift */, + 5D3E334C15EEBB6B005FC6F2 /* ReachabilityUtils.h */, + 5D3E334D15EEBB6B005FC6F2 /* ReachabilityUtils.m */, + 822876F01E929CFD00696BF7 /* ReachabilityUtils+OnlineActions.swift */, ); - name = Editor; + name = Reachability; sourceTree = ""; }; - F1D690131F828FF000200E30 /* BuildInformation */ = { + B5DB8AF51C949DC70059196A /* ImmuTable */ = { isa = PBXGroup; children = ( - F1D690141F828FF000200E30 /* BuildConfiguration.swift */, - F1D690151F828FF000200E30 /* FeatureFlag.swift */, - 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */, + E1EBC36E1C118EA500F638E0 /* ImmuTable.swift */, + E1E49CE31C4902EE002393A4 /* ImmuTableViewController.swift */, + E12FE0731FA0CEE000F28710 /* ImmuTable+Optional.swift */, + E13A8C9A1C3E6EF2005BB1C1 /* ImmuTable+WordPress.swift */, + B5DB8AF31C949DC20059196A /* WPImmuTableRows.swift */, ); - path = BuildInformation; + name = ImmuTable; sourceTree = ""; }; - F1DB8D272288C12A00906E2F /* Uploads */ = { + B5DCD0C21C69328E00C9B431 /* Settings */ = { isa = PBXGroup; children = ( - F1DB8D282288C14400906E2F /* Uploader.swift */, - F1DB8D2A2288C24500906E2F /* UploadsManager.swift */, ); - path = Uploads; + name = Settings; sourceTree = ""; }; - F53FF3A623EA722F001AD596 /* Detail Header */ = { + B5E23BD919AD0CED000D6879 /* Tools */ = { isa = PBXGroup; children = ( - F53FF3A723EA723D001AD596 /* ActionRow.swift */, - F53FF3A023E2377E001AD596 /* NewBlogDetailHeaderView.swift */, - F53FF3A923EA725C001AD596 /* SiteIconView.swift */, + B54075D31D3D7D5B0095C318 /* IntrinsicTableView.swift */, + B56695AF1D411EEB007E342F /* KeyboardDismissHelper.swift */, + 988FD749279B75A400C7E814 /* NotificationCommentDetailCoordinator.swift */, + B54E1DF31A0A7BBF00807537 /* NotificationMediaDownloader.swift */, + B5C0CF3C204DA41000DB0362 /* NotificationReplyStore.swift */, ); - path = "Detail Header"; + path = Tools; sourceTree = ""; }; - F551E7F323F6EA1400751212 /* Floating Create Button */ = { + B5ECA6CB1DBAA0110062D7E0 /* CoreData */ = { isa = PBXGroup; children = ( - F551E7F423F6EA3100751212 /* FloatingActionButton.swift */, - F5E032DA24088F44003AF350 /* UIView+SpringAnimations.swift */, - F5E032D5240889EB003AF350 /* CreateButtonCoordinator.swift */, + 3FD83CBD246C74B800381999 /* Migrator */, + C545E0A01811B9880020844C /* CoreDataStack.h */, + 4A9B81E22921AE02007A05D1 /* ContextManager.swift */, + E1E5EE36231E47A80018E9E3 /* ContextManager+ErrorHandling.swift */, + B5ECA6C91DBAA0020062D7E0 /* CoreDataHelper.swift */, + 24F3789725E6E62100A27BB7 /* NSManagedObject+Lookup.swift */, ); - path = "Floating Create Button"; + name = CoreData; sourceTree = ""; }; - F57402A5235FF71F00374346 /* Scheduling */ = { + B5EFB1C31B31B99D007608A3 /* Facades */ = { isa = PBXGroup; children = ( - F511F8A32356A4F400895E73 /* PublishSettingsViewController.swift */, - F5660D06235D114500020B1E /* CalendarCollectionView.swift */, - F59AAC0F235E430E00385EE6 /* ChosenValueRow.swift */, - F5660D08235D1CDD00020B1E /* CalendarMonthView.swift */, - F5660CFF235CE82100020B1E /* SchedulingCalendarViewController.swift */, - F5844B6A235EAF3D007C6557 /* HalfScreenPresentationController.swift */, - F59AAC15235EA46D00385EE6 /* LightNavigationController.swift */, - F57402A6235FF9C300374346 /* SchedulingDate+Helpers.swift */, + 85D239A11AE5A5FC0074768D /* BlogSyncFacade.h */, + 85D239A21AE5A5FC0074768D /* BlogSyncFacade.m */, ); - path = Scheduling; + name = Facades; sourceTree = ""; }; - F5D0A64C23CC157100B20D27 /* Preview */ = { + B5FA22841C99F6340016CA7C /* Tracks */ = { isa = PBXGroup; children = ( - F580C3C023D22E2D0038E243 /* PreviewDeviceLabel.swift */, - F5D0A64D23CC159400B20D27 /* PreviewWebKitViewController.swift */, - F5D0A64F23CC15A800B20D27 /* PreviewNonceHandler.swift */, - F5B8A60E23CE56A1001B7359 /* PreviewDeviceSelectionViewController.swift */, + B5FA22821C99F6180016CA7C /* Tracks.swift */, + B504F5F41C9C2BD000F8B1C6 /* Tracks+ShareExtension.swift */, + 01CE5006290A889F00A9C2E0 /* TracksConfiguration.swift */, ); - path = Preview; + name = Tracks; sourceTree = ""; }; - F93735F422D53C1800A3C312 /* Logging */ = { + B5FA22851C99F63B0016CA7C /* Extensions */ = { isa = PBXGroup; children = ( - F93735F722D53C3B00A3C312 /* LoggingURLRedactorTests.swift */, + B5552D7D1CD101A600B26DF6 /* NSExtensionContext+Extensions.swift */, + B5552D7F1CD1028C00B26DF6 /* String+Extensions.swift */, + B5FA868A1D10A41600AB5F7E /* UIImage+Extensions.swift */, + 74AC1DA0200D0CC300973CAD /* UINavigationController+Extensions.swift */, + 74BC35B720499EEB00AC1525 /* RemotePostCategory+Extensions.swift */, + CE39E17120CB117B00CABA05 /* RemoteBlog+Capabilities.swift */, + CBF6201226E8FB520061A1F8 /* RemotePost+ShareData.swift */, ); - path = Logging; + name = Extensions; sourceTree = ""; }; - FA5C74091C596E69000B528C /* Site Management */ = { + B5FD4523199D0F1100286FBB /* Views */ = { isa = PBXGroup; children = ( - E66E2A631FE4311200788F22 /* SettingsTitleSubtitleController.swift */, - E66E2A641FE4311200788F22 /* SiteTagsViewController.swift */, - FAFF153C1C98962E007D1C90 /* SiteSettingsViewController+SiteManagement.swift */, - FAE420191C5AEFE100C1D036 /* StartOverViewController.swift */, - 742C79971E5F511C00DB1608 /* DeleteSiteViewController.swift */, - 746A6F561E71C691003B67E3 /* DeleteSite.storyboard */, - FA5C740E1C599BA7000B528C /* TableViewHeaderDetailView.swift */, + 98B88451261E4E09007ED7F8 /* LikeUserTableViewCell.swift */, + 98B88465261E4E4E007ED7F8 /* LikeUserTableViewCell.xib */, + 2FA37B19215724E900C80377 /* LongPressGestureLabel.swift */, + B57AF5F91ACDC73D0075A7D2 /* NoteBlockActionsTableViewCell.swift */, + B5C66B731ACF071F00F68370 /* NoteBlockActionsTableViewCell.xib */, + 176CF39925E0005F00E1E598 /* NoteBlockButtonTableViewCell.swift */, + 176CF3AB25E0079600E1E598 /* NoteBlockButtonTableViewCell.xib */, + B532D4E5199D4357006E4DF6 /* NoteBlockCommentTableViewCell.swift */, + B5C66B751ACF072C00F68370 /* NoteBlockCommentTableViewCell.xib */, + B532D4E6199D4357006E4DF6 /* NoteBlockHeaderTableViewCell.swift */, + B5C66B6F1ACF06CA00F68370 /* NoteBlockHeaderTableViewCell.xib */, + B532D4ED199D4418006E4DF6 /* NoteBlockImageTableViewCell.swift */, + B5C66B771ACF073900F68370 /* NoteBlockImageTableViewCell.xib */, + B532D4E7199D4357006E4DF6 /* NoteBlockTableViewCell.swift */, + B532D4E8199D4357006E4DF6 /* NoteBlockTextTableViewCell.swift */, + B5C66B711ACF071000F68370 /* NoteBlockTextTableViewCell.xib */, + B52C4C7C199D4CD3009FD823 /* NoteBlockUserTableViewCell.swift */, + B5C66B791ACF074600F68370 /* NoteBlockUserTableViewCell.xib */, + FEDDD46E26A03DE900F8942B /* ListTableViewCell+Notifications.swift */, ); - path = "Site Management"; + path = Views; sourceTree = ""; }; - FF2716901CAAC87B0006E2D4 /* WordPressUITests */ = { + B5FD453E199D0F2800286FBB /* Controllers */ = { isa = PBXGroup; children = ( - BEA101B61FF13F0500CE5C7D /* Tests */, - BED4D8311FF11E2B00A11345 /* Flows */, - BE2B4EA01FD6653E007AE3E4 /* Utils */, - BE2B4E991FD6640E007AE3E4 /* Screens */, - FF2716931CAAC87B0006E2D4 /* Info.plist */, - CC8A5EAA22159FA6001B7874 /* WPUITestCredentials.swift */, - CCA44DF422C4C4D6006E294F /* README.md */, - CCCF53BD237B13EA0035E9CA /* WordPressUITests.xctestplan */, + 98E14A3B27C9712D007B0896 /* NotificationCommentDetailViewController.swift */, + 7E987F552108017B00CAFB88 /* NotificationContentRouter.swift */, + B5120B371D47CC6C0059361A /* NotificationDetailsViewController.swift */, + B5899ADD1B419C560075A3D6 /* NotificationSettingDetailsViewController.swift */, + B52F8CD71B43260C00D36025 /* NotificationSettingStreamsViewController.swift */, + B522C4F71B3DA79B00E47B59 /* NotificationSettingsViewController.swift */, + 9F8E38BD209C6DE200454E3C /* NotificationSiteSubscriptionViewController.swift */, + B5DBE4FD1D21A700002E81D3 /* NotificationsViewController.swift */, + 4388FEFD20A4E0B900783948 /* NotificationsViewController+AppRatings.swift */, + 8236EB4F2024ED8C007C7CF9 /* NotificationsViewController+JetpackPrompt.swift */, + 4388FEFF20A4E19C00783948 /* NotificationsViewController+PushPrimer.swift */, ); - path = WordPressUITests; + path = Controllers; sourceTree = ""; }; - FF2EC3BE2209A105006176E1 /* Processors */ = { + BE1071FD1BC75FCF00906AFF /* Style */ = { isa = PBXGroup; children = ( - FF2EC3BF2209A144006176E1 /* GutenbergImgUploadProcessor.swift */, - FF1B11E4238FDFE70038B93E /* GutenbergGalleryUploadProcessor.swift */, - 91138454228373EB00FB02B7 /* GutenbergVideoUploadProcessor.swift */, - FF9C81C32375BA8100DC4B2F /* GutenbergBlockProcessor.swift */, + BE1071FE1BC75FFA00906AFF /* WPStyleGuide+BlogTests.swift */, ); - path = Processors; + name = Style; sourceTree = ""; }; - FF7691661EE06CF500713F4B /* Aztec */ = { + BE20F5E11B2F738E0020694C /* ViewRelated */ = { isa = PBXGroup; children = ( - FF8032651EE9E22200861F28 /* MediaProgressCoordinatorTests.swift */, - FF1FD02520912AA900186384 /* URL+LinkNormalizationTests.swift */, - 7320C8BC2190C9FC0082FED5 /* UITextView+SummaryTests.swift */, - 5789E5C722D7D40800333698 /* AztecPostViewControllerAttachmentTests.swift */, + 0141929E2983F5D900CAEDB0 /* Support */, + F41E4E8F28F1949D001880C6 /* App Icons */, + 80C523A929AE6BF300B1C14B /* Blaze */, + AC68C9C728E5DEB1009030A9 /* Notification */, + C3C2F84428AC8B9E00937E45 /* Jetpack */, + F44FB6C92878957E0001E3CE /* Mention */, + 8B69F0E2255C2BC0006B1CEF /* Activity */, + 8BD36E042395CC2F00EFFF1C /* Aztec */, + 325D3B3A23A8372500766DF6 /* Comments */, + BEC8A3FD1B4BAA08001CB8C3 /* Blog */, + E1B921BA1C0ED481003EA3CB /* Cells */, + C3E42AAD27F4D2CF00546706 /* Menus */, + 3F86A83829D19C1C005D20C0 /* NUX */, + 8B7623352384372200AB3EE7 /* Pages */, + 937E3AB41E3EBDC900CDA01A /* Post */, + FE3D057C26C3D5A1002A51B0 /* Sharing */, + DC772AFB28200A3600664C02 /* Stats */, + BE20F5E21B2F739F0020694C /* System */, + DC8F61F9270331DF0087AC5D /* Tools */, + 732A4738218786F30015DA74 /* Views */, ); - name = Aztec; + name = ViewRelated; sourceTree = ""; }; - FF7C89A11E3A1029000472A8 /* MediaPicker */ = { + BE20F5E21B2F739F0020694C /* System */ = { isa = PBXGroup; children = ( - FF7C89A21E3A1029000472A8 /* MediaLibraryPickerDataSourceTests.swift */, ); - path = MediaPicker; + name = System; sourceTree = ""; }; - FF9839A71CD3960600E85258 /* WordPressAPI */ = { + BE2B4E991FD6640E007AE3E4 /* Screens */ = { isa = PBXGroup; children = ( - 74B335EB1F06F9520053A184 /* MockWordPressComRestApi.swift */, + BE2B4E9E1FD664F5007AE3E4 /* BaseScreen.swift */, ); - name = WordPressAPI; + path = Screens; sourceTree = ""; }; - FF9A6E6F21F9359200D36D14 /* Gutenberg */ = { + BE2B4EA01FD6653E007AE3E4 /* Utils */ = { isa = PBXGroup; children = ( - FF9A6E7021F9361700D36D14 /* MediaUploadHashTests.swift */, - FF2EC3C12209AC19006176E1 /* GutenbergImgUploadProcessorTests.swift */, - FF1B11E6238FE27A0038B93E /* GutenbergGalleryUploadProcessorTests.swift */, - 9123471A221449E200BD9F97 /* GutenbergInformativeDialogTests.swift */, - FF0B2566237A023C004E255F /* GutenbergVideoUploadProcessorTests.swift */, + FF2716A01CABC7D40006E2D4 /* XCTest+Extensions.swift */, ); - name = Gutenberg; + path = Utils; sourceTree = ""; }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 1D6058900D05DD3D006BFB54 /* WordPress */ = { - isa = PBXNativeTarget; - buildConfigurationList = 1D6058960D05DD3E006BFB54 /* Build configuration list for PBXNativeTarget "WordPress" */; - buildPhases = ( - E00F6488DE2D86BDC84FBB0B /* [CP] Check Pods Manifest.lock */, - E1756E61169493AD00D9EC00 /* Generate API credentials */, - 825F0EBF1F7EBF7C00321528 /* App Icons: Add Version For Internal Releases */, - 1D60588D0D05DD3D006BFB54 /* Resources */, - F9C5CF0222CD5DB0007CEF56 /* Copy Alternate Internal Icons (if needed) */, - 832D4F01120A6F7C001708D4 /* CopyFiles */, - 1D60588E0D05DD3D006BFB54 /* Sources */, - 1D60588F0D05DD3D006BFB54 /* Frameworks */, - 93E5284E19A7741A003A1A9C /* Embed App Extensions */, - 37399571B0D91BBEAE911024 /* [CP] Embed Pods Frameworks */, - 920B9A6DAD47189622A86A9C /* [CP] Copy Pods Resources */, - 9879533A2135D77500743763 /* Zendesk Strip Frameworks */, - E1C5456F219F10E000896227 /* Copy Gutenberg JS */, - ); - buildRules = ( + BE6AB7FB1BC62E0B00D980FC /* Style */ = { + isa = PBXGroup; + children = ( + BE1071FB1BC75E7400906AFF /* WPStyleGuide+Blog.swift */, + 836498C728172C5900A2C170 /* WPStyleGuide+BloggingPrompts.swift */, ); - dependencies = ( - FFC3F6FC1B0DBF7200EFC359 /* PBXTargetDependency */, - 93E5284519A7741A003A1A9C /* PBXTargetDependency */, - 93E5284819A7741A003A1A9C /* PBXTargetDependency */, - 932225B01C7CE50300443B02 /* PBXTargetDependency */, - 7457667B202B558C00F42E40 /* PBXTargetDependency */, - 7358E6BE210BD318002323EB /* PBXTargetDependency */, - 733F360F2126197800988727 /* PBXTargetDependency */, - 98A3C2F9239997DA0048D38D /* PBXTargetDependency */, - 98D31B982396ED7F009CFF43 /* PBXTargetDependency */, + name = Style; + sourceTree = ""; + }; + BE87E19E1BD4052F0075D45B /* 3DTouch */ = { + isa = PBXGroup; + children = ( + BE87E1A11BD405790075D45B /* WP3DTouchShortcutHandler.swift */, + BE87E19F1BD4054F0075D45B /* WP3DTouchShortcutCreator.swift */, ); - name = WordPress; - productName = WordPress; - productReference = 1D6058910D05DD3D006BFB54 /* WordPress.app */; - productType = "com.apple.product-type.application"; + name = 3DTouch; + sourceTree = ""; }; - 733F36022126197800988727 /* WordPressNotificationContentExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 733F36152126197800988727 /* Build configuration list for PBXNativeTarget "WordPressNotificationContentExtension" */; - buildPhases = ( - 2D007B2B2850683878B2D4F1 /* [CP] Check Pods Manifest.lock */, - 733F35FF2126197800988727 /* Sources */, - 733F36002126197800988727 /* Frameworks */, - 733F36012126197800988727 /* Resources */, - C8C0D3005B2024B01347CCEF /* [CP] Copy Pods Resources */, + BEA0E4821BD8353B000AEE81 /* System */ = { + isa = PBXGroup; + children = ( + BEA0E4831BD83545000AEE81 /* 3DTouch */, ); - buildRules = ( + name = System; + sourceTree = ""; + }; + BEA0E4831BD83545000AEE81 /* 3DTouch */ = { + isa = PBXGroup; + children = ( + BEA0E4841BD83565000AEE81 /* WP3DTouchShortcutCreatorTests.swift */, ); - dependencies = ( + name = 3DTouch; + sourceTree = ""; + }; + BEA101B61FF13F0500CE5C7D /* Tests */ = { + isa = PBXGroup; + children = ( + EAB10E3F27487F5D000DA4C1 /* ReaderTests.swift */, + 6E5BA46826A59D620043A6F2 /* SupportScreenTests.swift */, + FF2716911CAAC87B0006E2D4 /* LoginTests.swift */, + CC7CB97222B1510900642EE9 /* SignupTests.swift */, + FFA0B7D61CAC1F9F00533B9D /* MainNavigationTests.swift */, + BED4D82F1FF11DEF00A11345 /* EditorAztecTests.swift */, + CC2BB0CF228ACF710034F9AB /* EditorGutenbergTests.swift */, + EAD2BF4127594DAB00A847BB /* StatsTests.swift */, + D82E087429EEB0B00098F500 /* DashboardTests.swift */, ); - name = WordPressNotificationContentExtension; - productName = WordPressNotificationContentExtension; - productReference = 733F36032126197800988727 /* WordPressNotificationContentExtension.appex */; - productType = "com.apple.product-type.app-extension"; + path = Tests; + sourceTree = ""; }; - 7358E6B7210BD318002323EB /* WordPressNotificationServiceExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 7358E6C4210BD318002323EB /* Build configuration list for PBXNativeTarget "WordPressNotificationServiceExtension" */; - buildPhases = ( - BAE780768320204E29A6FE5B /* [CP] Check Pods Manifest.lock */, - 7358E6B4210BD318002323EB /* Sources */, - 7358E6B5210BD318002323EB /* Frameworks */, - 7358E6B6210BD318002323EB /* Resources */, - 552DBF4AE076B0EE1EC2D94D /* [CP] Copy Pods Resources */, + BEC8A3FD1B4BAA08001CB8C3 /* Blog */ = { + isa = PBXGroup; + children = ( + 027AC51F2278982D0033E56E /* DomainCredit */, + BE1071FD1BC75FCF00906AFF /* Style */, + 02761EC122700A9C009BAF0F /* BlogDetailsSubsectionToSectionCategoryTests.swift */, + 02761EC3227010BC009BAF0F /* BlogDetailsSectionIndexTests.swift */, + 3F1AD48023FC87A400BB1375 /* BlogDetailsViewController+MeButtonTests.swift */, ); - buildRules = ( + name = Blog; + sourceTree = ""; + }; + BED4D8311FF11E2B00A11345 /* Flows */ = { + isa = PBXGroup; + children = ( + BED4D8321FF11E3800A11345 /* LoginFlow.swift */, + CC52188B2278C622008998CE /* EditorFlow.swift */, ); - dependencies = ( + path = Flows; + sourceTree = ""; + }; + BED4D83D1FF13C7300A11345 /* Login */ = { + isa = PBXGroup; + children = ( + 98AA22B925082A5B005CCC13 /* Unified */, + 985F06B42303866200949733 /* WelcomeScreenLoginComponent.swift */, + BE2B4E9A1FD66423007AE3E4 /* WelcomeScreen.swift */, + BE2B4EA31FD6659B007AE3E4 /* LoginEmailScreen.swift */, + BE6DD3271FD6705200E55192 /* LoginPasswordScreen.swift */, + BE6DD3291FD6708900E55192 /* LinkOrPasswordScreen.swift */, + CCF6ACE6221ED73900D0C5BE /* LoginCheckMagicLinkScreen.swift */, + CCE911BB221D8497007E1D4E /* LoginSiteAddressScreen.swift */, + CCE911BD221D85E4007E1D4E /* LoginUsernamePasswordScreen.swift */, + BE6DD32B1FD6782A00E55192 /* LoginEpilogueScreen.swift */, + FA612DDE274E9F730002B03A /* QuickStartPromptScreen.swift */, + C7B7CC7228134347007B9807 /* OnboardingQuestionsPromptScreen.swift */, + FA9276AC286C951200C323BB /* FeatureIntroductionScreen.swift */, ); - name = WordPressNotificationServiceExtension; - productName = WordPressNotificationServiceExtension; - productReference = 7358E6B8210BD318002323EB /* WordPressNotificationServiceExtension.appex */; - productType = "com.apple.product-type.app-extension"; + path = Login; + sourceTree = ""; }; - 74576671202B558C00F42E40 /* WordPressDraftActionExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 74576681202B558C00F42E40 /* Build configuration list for PBXNativeTarget "WordPressDraftActionExtension" */; - buildPhases = ( - 74CC431A202B5C73000DAE1A /* [CP] Check Pods Manifest.lock */, - 7457666E202B558C00F42E40 /* Sources */, - 7457666F202B558C00F42E40 /* Frameworks */, - 74576670202B558C00F42E40 /* Resources */, - 9D186898B0632AA1273C9DE2 /* [CP] Copy Pods Resources */, + C31466CA293993F300D62FC7 /* Load WordPress */ = { + isa = PBXGroup; + children = ( + C31466CB2939950900D62FC7 /* MigrationLoadWordPressViewController.swift */, + C3E77F88293A4EA10034AE5A /* MigrationLoadWordPressViewModel.swift */, ); - buildRules = ( + path = "Load WordPress"; + sourceTree = ""; + }; + C3C21EB728385EB4002296E2 /* Extensions */ = { + isa = PBXGroup; + children = ( + C3C21EB828385EC8002296E2 /* RemoteSiteDesigns.swift */, ); - dependencies = ( + path = Extensions; + sourceTree = ""; + }; + C3C2F84428AC8B9E00937E45 /* Jetpack */ = { + isa = PBXGroup; + children = ( + C3C2F84528AC8BC700937E45 /* JetpackBannerScrollVisibilityTests.swift */, ); - name = WordPressDraftActionExtension; - productName = WordPressDraftActionExtension; - productReference = 74576672202B558C00F42E40 /* WordPressDraftActionExtension.appex */; - productType = "com.apple.product-type.app-extension"; + path = Jetpack; + sourceTree = ""; }; - 8511CFB51C607A7000B7CEED /* WordPressScreenshotGeneration */ = { - isa = PBXNativeTarget; - buildConfigurationList = 8511CFC21C607A7000B7CEED /* Build configuration list for PBXNativeTarget "WordPressScreenshotGeneration" */; - buildPhases = ( - 2DF08408C90B90D744C56E02 /* [CP] Check Pods Manifest.lock */, - 8511CFB21C607A7000B7CEED /* Sources */, - 8511CFB31C607A7000B7CEED /* Frameworks */, - 8511CFB41C607A7000B7CEED /* Resources */, + C3DD4DCC28BE5D300046C68E /* SplashPrologue */ = { + isa = PBXGroup; + children = ( + C3DD4DCD28BE5D4D0046C68E /* SplashPrologueViewController.swift */, + C324D7A828C26F3F00310DEF /* SplashPrologueStyleGuide.swift */, + C31B6D5A28BFB6F300E64FEB /* SplashPrologueView.swift */, ); - buildRules = ( + name = SplashPrologue; + sourceTree = ""; + }; + C3E42AAD27F4D2CF00546706 /* Menus */ = { + isa = PBXGroup; + children = ( + C3E42AAE27F4D2EC00546706 /* Controllers */, ); - dependencies = ( - 8511CFBC1C607A7000B7CEED /* PBXTargetDependency */, + path = Menus; + sourceTree = ""; + }; + C3E42AAE27F4D2EC00546706 /* Controllers */ = { + isa = PBXGroup; + children = ( + C3E42AAF27F4D30E00546706 /* MenuItemsViewControllerTests.swift */, ); - name = WordPressScreenshotGeneration; - productName = WordPressScreenshotGeneration; - productReference = 8511CFB61C607A7000B7CEED /* WordPressScreenshotGeneration.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; + path = Controllers; + sourceTree = ""; }; - 932225A61C7CE50300443B02 /* WordPressShareExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 932225B71C7CE50400443B02 /* Build configuration list for PBXNativeTarget "WordPressShareExtension" */; - buildPhases = ( - 4F4D5C2BB6478A3E90ADC3C5 /* [CP] Check Pods Manifest.lock */, - 932225A31C7CE50300443B02 /* Sources */, - 932225A41C7CE50300443B02 /* Frameworks */, - 932225A51C7CE50300443B02 /* Resources */, - 83D79708413A3DA10638659F /* [CP] Copy Pods Resources */, + C533CF320E6D3AB3000C3DE8 /* Comments */ = { + isa = PBXGroup; + children = ( + E64595EE256B5D5000F7F90C /* Analytics */, + B56994461B7A82A300FF26FA /* Style */, + B56994471B7A82CD00FF26FA /* Controllers */, + B56994481B7A82D400FF26FA /* Views */, ); - buildRules = ( + path = Comments; + sourceTree = ""; + }; + C59D3D480E6410BC00AA591D /* Categories */ = { + isa = PBXGroup; + children = ( + 08C388681ED78EE70057BE49 /* Media+WPMediaAsset.h */, + 08C388691ED78EE70057BE49 /* Media+WPMediaAsset.m */, + B57B99DC19A2DBF200506504 /* NSObject+Helpers.h */, + B57B99DD19A2DBF200506504 /* NSObject+Helpers.m */, + 8261B4CB1EA8E13700668298 /* SVProgressHUD+Dismiss.h */, + 8261B4CA1EA8E13700668298 /* SVProgressHUD+Dismiss.m */, + B5416CFF1C17693B00006DD8 /* UIApplication+Helpers.h */, + B5416D001C17693B00006DD8 /* UIApplication+Helpers.m */, + 5D97C2F115CAF8D8009B44DD /* UINavigationController+KeyboardFix.h */, + 5D97C2F215CAF8D8009B44DD /* UINavigationController+KeyboardFix.m */, + E2AA87A318523E5300886693 /* UIView+Subviews.h */, + E2AA87A418523E5300886693 /* UIView+Subviews.m */, + E69BA1961BB5D7D300078740 /* WPStyleGuide+ReadableMargins.h */, + E69BA1971BB5D7D300078740 /* WPStyleGuide+ReadableMargins.m */, + 31EC15061A5B6675009FC8B3 /* WPStyleGuide+Suggestions.h */, + 31EC15071A5B6675009FC8B3 /* WPStyleGuide+Suggestions.m */, + 8067340827E3A50900ABC95E /* UIViewController+RemoveQuickStart.h */, + 8067340927E3A50900ABC95E /* UIViewController+RemoveQuickStart.m */, ); - dependencies = ( + path = Categories; + sourceTree = ""; + }; + C700F9CD257FD5AA0090938E /* Jetpack Scan */ = { + isa = PBXGroup; + children = ( + C73868C425C9F9820072532C /* JetpackScanThreatSectionGrouping.swift */, + C789952425816F96001B7B43 /* JetpackScanCoordinator.swift */, + C76F48ED25BA20EF00BFEC87 /* JetpackScanHistoryCoordinator.swift */, + C76F48DB25BA202600BFEC87 /* JetpackScanHistoryViewController.swift */, + C76F48FF25BA23B000BFEC87 /* JetpackScanHistoryViewController.xib */, + C768B5B225828A5D00556E75 /* JetpackScanStatusCell.swift */, + C768B5B325828A5D00556E75 /* JetpackScanStatusCell.xib */, + FA7AA45225BFD9BC005E7200 /* JetpackScanThreatDetailsViewController.swift */, + FA7AA4A625BFE0A9005E7200 /* JetpackScanThreatDetailsViewController.xib */, + C700FAB0258020DB0090938E /* JetpackScanThreatCell.swift */, + C700FAB1258020DB0090938E /* JetpackScanThreatCell.xib */, + C700F9ED257FD64E0090938E /* JetpackScanViewController.swift */, + C31852A029670F8100A78BE9 /* JetpackScanViewController+JetpackBannerViewController.swift */, + C700F9D0257FD63A0090938E /* JetpackScanViewController.xib */, + C71BC73D25A6522D0023D789 /* View Models */, ); - name = WordPressShareExtension; - productName = WordPressShare; - productReference = 932225A71C7CE50300443B02 /* WordPressShareExtension.appex */; - productType = "com.apple.product-type.app-extension"; + path = "Jetpack Scan"; + sourceTree = ""; }; - 93E5283919A7741A003A1A9C /* WordPressTodayWidget */ = { - isa = PBXNativeTarget; - buildConfigurationList = 93E5284D19A7741A003A1A9C /* Build configuration list for PBXNativeTarget "WordPressTodayWidget" */; - buildPhases = ( - 4CB8AA817C9BD74F3416B27C /* [CP] Check Pods Manifest.lock */, - 93E5283619A7741A003A1A9C /* Sources */, - 93E5283719A7741A003A1A9C /* Frameworks */, - 93E5283819A7741A003A1A9C /* Resources */, - 59B02D1583A9AC2ACCFFD153 /* [CP] Copy Pods Resources */, + C7124E4B2638527D00929318 /* NUX */ = { + isa = PBXGroup; + children = ( + 3F46EEC028BC48D1004F02B2 /* New Landing Screen */, + C7124E912638905B00929318 /* StarFieldView.swift */, + C7124E4D2638528F00929318 /* JetpackPrologueViewController.swift */, + C7124E4C2638528F00929318 /* JetpackPrologueViewController.xib */, + C7D30C642638B07A00A1695B /* JetpackPrologueStyleGuide.swift */, ); - buildRules = ( + path = NUX; + sourceTree = ""; + }; + C71BC73D25A6522D0023D789 /* View Models */ = { + isa = PBXGroup; + children = ( + C71BC73E25A652410023D789 /* JetpackScanStatusViewModel.swift */, + C78543D125B889CC006CEAFB /* JetpackScanThreatViewModel.swift */, ); - dependencies = ( + path = "View Models"; + sourceTree = ""; + }; + C7234A382832B55F0045C63F /* QR Login */ = { + isa = PBXGroup; + children = ( + C7234A3C2832C2800045C63F /* Coordinators */, + C7BB60212863C89400748FD9 /* Helpers */, + C7BB60222863C8B800748FD9 /* View Controllers */, ); - name = WordPressTodayWidget; - productName = WordPressTodayWidget; - productReference = 93E5283A19A7741A003A1A9C /* WordPressTodayWidget.appex */; - productType = "com.apple.product-type.app-extension"; + path = "QR Login"; + sourceTree = ""; }; - 98A3C2EE239997DA0048D38D /* WordPressThisWeekWidget */ = { - isa = PBXNativeTarget; - buildConfigurationList = 98A3C2FF239997DB0048D38D /* Build configuration list for PBXNativeTarget "WordPressThisWeekWidget" */; - buildPhases = ( - 591AAEE0843D274DDFF16F69 /* [CP] Check Pods Manifest.lock */, - 98A3C2EB239997DA0048D38D /* Sources */, - 98A3C2EC239997DA0048D38D /* Frameworks */, - 98A3C2ED239997DA0048D38D /* Resources */, - 3E7D1BBC0746F969F0997C43 /* [CP] Copy Pods Resources */, + C7234A3C2832C2800045C63F /* Coordinators */ = { + isa = PBXGroup; + children = ( + C7234A392832BA240045C63F /* QRLoginCoordinator.swift */, + C7AFF876283C2623000E01DF /* QRLoginScanningCoordinator.swift */, + C7AFF87B283D5CF4000E01DF /* QRLoginVerifyCoordinator.swift */, ); - buildRules = ( + path = Coordinators; + sourceTree = ""; + }; + C72A4F66264088D1009CA633 /* View Models */ = { + isa = PBXGroup; + children = ( + C7F7ACBD261E4F0600CE547F /* JetpackErrorViewModel.swift */, + C72A4F67264088E4009CA633 /* JetpackNotFoundErrorViewModel.swift */, + C72A4F7A26408943009CA633 /* JetpackNotWPErrorViewModel.swift */, + C72A4F8D26408C73009CA633 /* JetpackNoSitesErrorViewModel.swift */, ); - dependencies = ( + path = "View Models"; + sourceTree = ""; + }; + C72A4FB12641837A009CA633 /* Login Error */ = { + isa = PBXGroup; + children = ( + C72A4F66264088D1009CA633 /* View Models */, + C7F7AC73261CF1F300CE547F /* JetpackLoginErrorViewController.swift */, + C7F7AC74261CF1F300CE547F /* JetpackLoginErrorViewController.xib */, ); - name = WordPressThisWeekWidget; - productName = WordPressThisWeekWidget; - productReference = 98A3C2EF239997DA0048D38D /* WordPressThisWeekWidget.appex */; - productType = "com.apple.product-type.app-extension"; + path = "Login Error"; + sourceTree = ""; }; - 98D31B8D2396ED7E009CFF43 /* WordPressAllTimeWidget */ = { - isa = PBXNativeTarget; - buildConfigurationList = 98D31B9E2396ED7F009CFF43 /* Build configuration list for PBXNativeTarget "WordPressAllTimeWidget" */; - buildPhases = ( - FF1C536C9FA7489B5AAA0FC2 /* [CP] Check Pods Manifest.lock */, - 98D31B8A2396ED7E009CFF43 /* Sources */, - 98D31B8B2396ED7E009CFF43 /* Frameworks */, - 98D31B8C2396ED7E009CFF43 /* Resources */, - 61CF58FC6EB9AB58632C4770 /* [CP] Copy Pods Resources */, + C72A52CD2649B14B009CA633 /* System */ = { + isa = PBXGroup; + children = ( + C72A52CE2649B157009CA633 /* JetpackWindowManager.swift */, ); - buildRules = ( + path = System; + sourceTree = ""; + }; + C738CB0928623CD6001BE107 /* QRLogin */ = { + isa = PBXGroup; + children = ( + C738CB0A28623CED001BE107 /* QRLoginCoordinatorTests.swift */, + C738CB0C28623F07001BE107 /* QRLoginURLParserTests.swift */, + C738CB0E28626466001BE107 /* QRLoginScanningCoordinatorTests.swift */, + C738CB1028626606001BE107 /* QRLoginVerifyCoordinatorTests.swift */, ); - dependencies = ( + path = QRLogin; + sourceTree = ""; + }; + C77FC90628009C3C00726F00 /* Onboarding Questions Prompt */ = { + isa = PBXGroup; + children = ( + C77FC90728009C7000726F00 /* OnboardingQuestionsPromptViewController.swift */, + C77FC90828009C7000726F00 /* OnboardingQuestionsPromptViewController.xib */, + C77FC90D2800CAC100726F00 /* OnboardingEnableNotificationsViewController.swift */, + C77FC90E2800CAC100726F00 /* OnboardingEnableNotificationsViewController.xib */, + C71AF532281064DE00F9E99E /* OnboardingQuestionsCoordinator.swift */, ); - name = WordPressAllTimeWidget; - productName = WordPressAllTimeWidget; - productReference = 98D31B8E2396ED7E009CFF43 /* WordPressAllTimeWidget.appex */; - productType = "com.apple.product-type.app-extension"; + path = "Onboarding Questions Prompt"; + sourceTree = ""; }; - E16AB92914D978240047A2E5 /* WordPressTest */ = { - isa = PBXNativeTarget; - buildConfigurationList = E16AB93D14D978240047A2E5 /* Build configuration list for PBXNativeTarget "WordPressTest" */; - buildPhases = ( - E0E31D6E60F2BCE2D0A53E39 /* [CP] Check Pods Manifest.lock */, - E16AB92514D978240047A2E5 /* Sources */, - E16AB92614D978240047A2E5 /* Frameworks */, - E16AB92714D978240047A2E5 /* Resources */, + C7BB60212863C89400748FD9 /* Helpers */ = { + isa = PBXGroup; + children = ( + C7A09A4C28403A34003096ED /* QRLoginURLParser.swift */, + C7BB60152863609C00748FD9 /* QRLoginInternetConnectionChecker.swift */, + C7BB60182863AF9700748FD9 /* QRLoginProtocols.swift */, + C7BB601B2863B3D600748FD9 /* QRLoginCameraPermissionsHandler.swift */, + C7BB601E2863B9E800748FD9 /* QRLoginCameraSession.swift */, ); - buildRules = ( + path = Helpers; + sourceTree = ""; + }; + C7BB60222863C8B800748FD9 /* View Controllers */ = { + isa = PBXGroup; + children = ( + C7234A402832C2BA0045C63F /* QRLoginScanningViewController.swift */, + C7234A412832C2BA0045C63F /* QRLoginScanningViewController.xib */, + C7234A4C2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.swift */, + C7234A4D2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.xib */, ); - dependencies = ( - E16AB93F14D978520047A2E5 /* PBXTargetDependency */, + path = "View Controllers"; + sourceTree = ""; + }; + C7E5F2422799BB1E009BC263 /* cool-blue */ = { + isa = PBXGroup; + children = ( + C7E5F24F2799BD52009BC263 /* cool-blue-icon-app-60x60@2x.png */, + C7E5F24E2799BD52009BC263 /* cool-blue-icon-app-60x60@3x.png */, + C7E5F24D2799BD52009BC263 /* cool-blue-icon-app-76x76.png */, + C7E5F2512799BD52009BC263 /* cool-blue-icon-app-76x76@2x.png */, + C7E5F2502799BD52009BC263 /* cool-blue-icon-app-83.5x83.5@2x.png */, ); - name = WordPressTest; - productName = WordPressTest; - productReference = E16AB92A14D978240047A2E5 /* WordPressTest.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; + path = "cool-blue"; + sourceTree = ""; }; - FF27168E1CAAC87A0006E2D4 /* WordPressUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = FF27169A1CAAC87B0006E2D4 /* Build configuration list for PBXNativeTarget "WordPressUITests" */; - buildPhases = ( - 37F48D4CB364EA4BCC1EAE82 /* [CP] Check Pods Manifest.lock */, - FF27168B1CAAC87A0006E2D4 /* Sources */, - FF27168C1CAAC87A0006E2D4 /* Frameworks */, - FF27168D1CAAC87A0006E2D4 /* Resources */, - CC875D93233BCEC800595CC8 /* Set Up Simulator */, + C7F7AC71261CF1BD00CE547F /* Classes */ = { + isa = PBXGroup; + children = ( + C72A52CD2649B14B009CA633 /* System */, + C7124E4B2638527D00929318 /* NUX */, + C7F7ABD5261CED7A00CE547F /* JetpackAuthenticationManager.swift */, + 8332DD2229259ABA00802F7D /* Utility */, + C7F7AC72261CF1C900CE547F /* ViewRelated */, ); - buildRules = ( + path = Classes; + sourceTree = ""; + }; + C7F7AC72261CF1C900CE547F /* ViewRelated */ = { + isa = PBXGroup; + children = ( + F4F9D5E82909615D00502576 /* WordPress-to-Jetpack Migration */, + C72A4FB12641837A009CA633 /* Login Error */, ); - dependencies = ( - FF2716951CAAC87B0006E2D4 /* PBXTargetDependency */, + path = ViewRelated; + sourceTree = ""; + }; + C81CCD5D243AEC8200A83E27 /* TenorAPI */ = { + isa = PBXGroup; + children = ( + C81CCD5E243AECA000A83E27 /* TenorClient.swift */, + C81CCD60243AECA100A83E27 /* TenorGIF.swift */, + C81CCD62243AECA100A83E27 /* TenorGIFCollection.swift */, + C81CCD5F243AECA000A83E27 /* TenorMediaObject.swift */, + C81CCD61243AECA100A83E27 /* TenorResponse.swift */, + C81CCD6D243AF09900A83E27 /* TenorReponseParser.swift */, ); - name = WordPressUITests; - productName = WordPressUITests; - productReference = FF27168F1CAAC87A0006E2D4 /* WordPressUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; + path = TenorAPI; + sourceTree = ""; }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 29B97313FDCFA39411CA2CEA /* Project object */ = { - isa = PBXProject; - attributes = { - LastSwiftUpdateCheck = 1120; - LastUpgradeCheck = 0930; - ORGANIZATIONNAME = WordPress; - TargetAttributes = { - 1D6058900D05DD3D006BFB54 = { - DevelopmentTeam = PZYM8XX95Q; - LastSwiftMigration = 1000; - SystemCapabilities = { - com.apple.ApplicationGroups.iOS = { - enabled = 1; - }; - com.apple.Keychain = { - enabled = 1; - }; - com.apple.Push = { - enabled = 1; - }; - com.apple.SafariKeychain = { - enabled = 1; - }; - com.apple.iCloud = { - enabled = 1; - }; - }; - }; - 733F36022126197800988727 = { - CreatedOnToolsVersion = 9.4.1; - LastSwiftMigration = 1000; - ProvisioningStyle = Manual; - SystemCapabilities = { - com.apple.ApplicationGroups.iOS = { - enabled = 1; - }; - com.apple.Keychain = { - enabled = 1; - }; - }; - }; - 7358E6B7210BD318002323EB = { - CreatedOnToolsVersion = 9.4.1; - LastSwiftMigration = 1000; - ProvisioningStyle = Manual; - SystemCapabilities = { - com.apple.ApplicationGroups.iOS = { - enabled = 1; - }; - com.apple.Keychain = { - enabled = 1; - }; - }; - }; - 74576671202B558C00F42E40 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1000; - ProvisioningStyle = Manual; - SystemCapabilities = { - com.apple.ApplicationGroups.iOS = { - enabled = 1; - }; - com.apple.Keychain = { - enabled = 1; - }; - }; - }; - 8511CFB51C607A7000B7CEED = { - CreatedOnToolsVersion = 7.2; - DevelopmentTeam = PZYM8XX95Q; - LastSwiftMigration = 0820; - TestTargetID = 1D6058900D05DD3D006BFB54; - }; - 932225A61C7CE50300443B02 = { - CreatedOnToolsVersion = 7.2.1; - DevelopmentTeam = PZYM8XX95Q; - LastSwiftMigration = 1000; - SystemCapabilities = { - com.apple.ApplicationGroups.iOS = { - enabled = 1; - }; - com.apple.Keychain = { - enabled = 1; - }; - }; - }; - 93E5283919A7741A003A1A9C = { - CreatedOnToolsVersion = 6.0; - DevelopmentTeam = PZYM8XX95Q; - LastSwiftMigration = 1000; - SystemCapabilities = { - com.apple.ApplicationGroups.iOS = { - enabled = 1; - }; - com.apple.Keychain = { - enabled = 1; - }; - }; - }; - 98A3C2EE239997DA0048D38D = { - CreatedOnToolsVersion = 11.2.1; - LastSwiftMigration = 1120; - ProvisioningStyle = Manual; - }; - 98D31B8D2396ED7E009CFF43 = { - CreatedOnToolsVersion = 11.2.1; - ProvisioningStyle = Manual; - }; - A2795807198819DE0031C6A3 = { - DevelopmentTeam = PZYM8XX95Q; - }; - E16AB92914D978240047A2E5 = { - DevelopmentTeam = PZYM8XX95Q; - LastSwiftMigration = 1000; - TestTargetID = 1D6058900D05DD3D006BFB54; - }; - FF27168E1CAAC87A0006E2D4 = { - CreatedOnToolsVersion = 7.3; - DevelopmentTeam = PZYM8XX95Q; - LastSwiftMigration = 0800; - TestTargetID = 1D6058900D05DD3D006BFB54; - }; - FFA8E22A1F94E3DE0002170F = { - CreatedOnToolsVersion = 9.0; - ProvisioningStyle = Automatic; - }; - FFC3F6F41B0DBF0900EFC359 = { - CreatedOnToolsVersion = 6.3.1; - DevelopmentTeam = PZYM8XX95Q; - }; - }; - }; - buildConfigurationList = C01FCF4E08A954540054247B /* Build configuration list for PBXProject "WordPress" */; - compatibilityVersion = "Xcode 8.0"; - developmentRegion = en; - hasScannedForEncodings = 1; - knownRegions = ( - en, - es, - it, - ja, - pt, - sv, - "zh-Hans", - nb, - tr, - id, - "zh-Hant", - hu, - pl, - ru, - da, - th, - fr, - nl, - de, - "en-GB", - "pt-BR", - hr, - he, - cy, - "en-CA", - Base, - cs, - ro, - sq, - is, - "en-AU", - ko, - ar, - bg, - sk, + C81CCD68243AEDEC00A83E27 /* Tenor */ = { + isa = PBXGroup; + children = ( + C81CCD69243AEE1100A83E27 /* TenorAPIResponseTests.swift */, + C81CCD6B243AEFBF00A83E27 /* TenorReponseData.swift */, + C81CCD85243C00E000A83E27 /* TenorPageableTests.swift */, + C8567495243F3D37001A995E /* TenorResultsPageTests.swift */, + C8567497243F41CA001A995E /* MockTenorService.swift */, + C8567499243F4292001A995E /* TenorMockDataHelper.swift */, + C856749B243F462F001A995E /* TenorDataSouceTests.swift */, ); - mainGroup = 29B97314FDCFA39411CA2CEA /* CustomTemplate */; - productRefGroup = 19C28FACFE9D520D11CA2CBB /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 1D6058900D05DD3D006BFB54 /* WordPress */, - 932225A61C7CE50300443B02 /* WordPressShareExtension */, - 74576671202B558C00F42E40 /* WordPressDraftActionExtension */, - 93E5283919A7741A003A1A9C /* WordPressTodayWidget */, - 98D31B8D2396ED7E009CFF43 /* WordPressAllTimeWidget */, - 98A3C2EE239997DA0048D38D /* WordPressThisWeekWidget */, - 733F36022126197800988727 /* WordPressNotificationContentExtension */, - 7358E6B7210BD318002323EB /* WordPressNotificationServiceExtension */, - E16AB92914D978240047A2E5 /* WordPressTest */, - FF27168E1CAAC87A0006E2D4 /* WordPressUITests */, - 8511CFB51C607A7000B7CEED /* WordPressScreenshotGeneration */, - A2795807198819DE0031C6A3 /* OCLint */, - FFC3F6F41B0DBF0900EFC359 /* UpdatePlistPreprocessor */, - FFA8E22A1F94E3DE0002170F /* SwiftLint */, + path = Tenor; + sourceTree = ""; + }; + C8567490243F371D001A995E /* Tenor */ = { + isa = PBXGroup; + children = ( + C8567491243F3751001A995E /* tenor-search-response.json */, + C8567493243F388F001A995E /* tenor-invalid-search-reponse.json */, ); + name = Tenor; + sourceTree = ""; }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 1D60588D0D05DD3D006BFB54 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - E6ED090B1D46AFAF003283C4 /* ReaderFollowedSitesStreamHeader.xib in Resources */, - 9826AE9121B5D3CD00C851FA /* PostingActivityCell.xib in Resources */, - 17DC4C5422C5E6D60059CA11 /* open_source_icon_83.5@2x.png in Resources */, - 43DDFE9021785EAC008BE72F /* QuickStartCongratulationsCell.xib in Resources */, - 17E3634422C417F0000E0C79 /* jetpack_green_icon_60pt@3x.png in Resources */, - 98A25BD3203CB278006A5807 /* SignupEpilogueCell.xib in Resources */, - 17E3634922C417F0000E0C79 /* jetpack_green_icon_40pt@3x.png in Resources */, - B5C66B741ACF071F00F68370 /* NoteBlockActionsTableViewCell.xib in Resources */, - 17DC4C3A22C5E6910059CA11 /* open_source_dark_icon_60pt@2x.png in Resources */, - 17DC4C5022C5E6D60059CA11 /* open_source_icon_40pt.png in Resources */, - 17E3634522C417F0000E0C79 /* jetpack_green_icon_20pt@2x.png in Resources */, - 17E363A422C41DBA000E0C79 /* hot_pink_icon_40pt@2x.png in Resources */, - B5C66B701ACF06CA00F68370 /* NoteBlockHeaderTableViewCell.xib in Resources */, - 17DC4C3822C5E6910059CA11 /* open_source_dark_icon_83.5@2x.png in Resources */, - 17DC4C3522C5E6910059CA11 /* open_source_dark_icon_29pt.png in Resources */, - 5D6C4AF61B603CA3005E3C43 /* WPTableViewActivityCell.xib in Resources */, - 17E363DC22C4280A000E0C79 /* pride_icon_60pt@2x.png in Resources */, - A01C55480E25E0D000D411F2 /* defaultPostTemplate.html in Resources */, - 17E363A222C41DBA000E0C79 /* hot_pink_icon_29pt.png in Resources */, - 2FAE97090E33B21600CA8540 /* defaultPostTemplate_old.html in Resources */, - 9A19D441236C7C7500D393E5 /* StatsGhostTopHeaderCell.xib in Resources */, - 17DC4C4D22C5E6D60059CA11 /* open_source_icon_76pt@2x.png in Resources */, - 436D562A2117312700CEAA33 /* RegisterDomainDetailsFooterView.xib in Resources */, - 98579BC8203DD86F004086E4 /* EpilogueSectionHeaderFooter.xib in Resources */, - 17E3633F22C417F0000E0C79 /* jetpack_green_icon_29pt@3x.png in Resources */, - B57273621B66CCEF000D1C4F /* AlertView.xib in Resources */, - 98F537A922496D0D00B334F9 /* SiteStatsTableHeaderView.xib in Resources */, - 5DBFC8A91A9BE07B00E00DE4 /* Posts.storyboard in Resources */, - 98B11B8B2216536C00B7F2D7 /* StatsChildRowsView.xib in Resources */, - 17E363E022C4280A000E0C79 /* pride_icon_60pt.png in Resources */, - 2FAE970C0E33B21600CA8540 /* xhtml1-transitional.dtd in Resources */, - 17DC4C5822C5E6D60059CA11 /* open_source_icon_20pt@3x.png in Resources */, - 4058F41A1FF40EE1000D5559 /* PluginDetailViewHeaderCell.xib in Resources */, - 406A0EF0224D39C50016AD6A /* Flags.xcassets in Resources */, - 9A9E3FB0230EA7A300909BC4 /* StatsGhostTopCell.xib in Resources */, - 985793C922F23D7000643DBF /* CustomizeInsightsCell.xib in Resources */, - 98906508237CC1DF00218CD2 /* WidgetTwoColumnCell.xib in Resources */, - D8380CA52192E77F00250609 /* VerticalsCell.xib in Resources */, - 17E363A122C41DBA000E0C79 /* hot_pink_icon_29pt@3x.png in Resources */, - 17E3630222C41725000E0C79 /* wordpress_dark_icon_20pt.png in Resources */, - 2FAE970D0E33B21600CA8540 /* xhtmlValidatorTemplate.xhtml in Resources */, - 9826AE8821B5C73400C851FA /* PostingActivityDay.xib in Resources */, - 17E3639B22C41DBA000E0C79 /* hot_pink_icon_20pt@2x.png in Resources */, - 17DC4C3922C5E6910059CA11 /* open_source_dark_icon_29pt@2x.png in Resources */, - 17DC4C5522C5E6D60059CA11 /* open_source_icon_40pt@2x.png in Resources */, - 17E363E922C4280A000E0C79 /* pride_icon_60pt@3x.png in Resources */, - 17E3630D22C41725000E0C79 /* wordpress_dark_icon_76pt.png in Resources */, - 17DC4C3222C5E6910059CA11 /* open_source_dark_icon_20pt@3x.png in Resources */, - 9A2B28F72192121F00458F2A /* RevisionOperation.xib in Resources */, - 17E3633D22C417F0000E0C79 /* jetpack_green_icon_20pt@3x.png in Resources */, - 17E363E422C4280A000E0C79 /* pride_icon_29pt@3x.png in Resources */, - E66E2A6A1FE432BC00788F22 /* TitleBadgeDisclosureCell.xib in Resources */, - 40640046200ED30300106789 /* TextWithAccessoryButtonCell.xib in Resources */, - 98467A43221CD75200DF51AE /* SiteStatsDetailTableViewController.storyboard in Resources */, - 17DC4C3022C5E6910059CA11 /* open_source_dark_icon_20pt.png in Resources */, - 17E3634222C417F0000E0C79 /* jetpack_green_icon_83.5@2x.png in Resources */, - 43D74ACE20F906DD004AD934 /* InlineEditableNameValueCell.xib in Resources */, - 433840C722C2BA5B00CB13F8 /* AppImages.xcassets in Resources */, - FF00889D204DFF77007CCE66 /* MediaQuotaCell.xib in Resources */, - 931DF4D618D09A2F00540BDD /* InfoPlist.strings in Resources */, - 17E3630A22C41725000E0C79 /* wordpress_dark_icon_40pt@2x.png in Resources */, - D88106F820C0C9A8001D2F00 /* ReaderSavedPostUndoCell.xib in Resources */, - 5D5A6E941B613CA400DAF819 /* ReaderPostCardCell.xib in Resources */, - 17E3634622C417F0000E0C79 /* jetpack_green_icon_76pt@2x.png in Resources */, - 4349B0B0218A477F0034118A /* RevisionsTableViewCell.xib in Resources */, - 17DC4C3322C5E6910059CA11 /* open_source_dark_icon_40pt.png in Resources */, - 439F4F38219B636500F8D0C7 /* Revisions.storyboard in Resources */, - 4019B27120885AB900A0C7EB /* Activity.storyboard in Resources */, - 9848DF8321B8BB6900B99DA4 /* PostingActivityLegend.xib in Resources */, - 981C986B21B9BF6000A7C0C8 /* PostingActivityViewController.storyboard in Resources */, - 98880A4B22B2E5E400464538 /* TwoColumnCell.xib in Resources */, - E6B42CBF1D9DA6270043E228 /* Noticons.ttf in Resources */, - B558541419631A1000FAF6C3 /* Notifications.storyboard in Resources */, - 17DC4C2E22C5E6910059CA11 /* open_source_dark_icon_20pt@2x.png in Resources */, - 17E3639E22C41DBA000E0C79 /* hot_pink_icon_76pt@2x.png in Resources */, - 17E3630022C41725000E0C79 /* wordpress_dark_icon_20pt@2x.png in Resources */, - 983DBBAA22125DD500753988 /* StatsTableFooter.xib in Resources */, - B5F995A01B59708C00AB0B3E /* NotificationSettingsViewController.xib in Resources */, - 17E3630622C41725000E0C79 /* wordpress_dark_icon_60pt@2x.png in Resources */, - 820ADD701F3A1F88002D7F93 /* ThemeBrowserSectionHeaderView.xib in Resources */, - 17E3633E22C417F0000E0C79 /* jetpack_green_icon_40pt@2x.png in Resources */, - 746A6F571E71C691003B67E3 /* DeleteSite.storyboard in Resources */, - 17E3639A22C41DBA000E0C79 /* hot_pink_icon_20pt@3x.png in Resources */, - B51AD77B2056C31100A6C545 /* LoginEpilogue.storyboard in Resources */, - D82253E6219956540014D0E2 /* AddressCell.xib in Resources */, - 17DC4C5222C5E6D60059CA11 /* open_source_icon_29pt@2x.png in Resources */, - 17E3630122C41725000E0C79 /* wordpress_dark_icon_40pt@3x.png in Resources */, - 9895B6E021ED49160053D370 /* TopTotalsCell.xib in Resources */, - 430693741DD25F31009398A2 /* PostPost.storyboard in Resources */, - 17DC4C5922C5E6D60059CA11 /* open_source_icon_40pt@3x.png in Resources */, - 17E363DB22C4280A000E0C79 /* pride_icon_40pt@3x.png in Resources */, - 98563DDE21BF30C40006F5E9 /* TabbedTotalsCell.xib in Resources */, - 5DFA7EC81AF814E40072023B /* PageListTableViewCell.xib in Resources */, - B5C66B781ACF073900F68370 /* NoteBlockImageTableViewCell.xib in Resources */, - 17E3630E22C41725000E0C79 /* wordpress_dark_icon_76pt@2x.png in Resources */, - 17E363A322C41DBA000E0C79 /* hot_pink_icon_83.5@2x.png in Resources */, - B59F34A1207678480069992D /* SignupEpilogue.storyboard in Resources */, - B5683DB81B6C03810043447C /* NoteTableHeaderView.xib in Resources */, - 98FCFC242231DF43006ECDD4 /* PostStatsTitleCell.xib in Resources */, - 17E363E222C4280A000E0C79 /* pride_icon_76pt.png in Resources */, - 17E3633C22C417F0000E0C79 /* jetpack_green_icon_40pt.png in Resources */, - 17E3639C22C41DBA000E0C79 /* hot_pink_icon_40pt.png in Resources */, - 4070D75C20E5F55A007CEBDA /* RewindStatusTableViewCell.xib in Resources */, - 5D13FA571AF99C2100F06492 /* PageListSectionHeaderView.xib in Resources */, - B54E1DF21A0A7BAA00807537 /* ReplyTextView.xib in Resources */, - 9881296F219CF1310075FF33 /* StatsCellHeader.xib in Resources */, - 3249615323F20111004C7733 /* PostSignUpInterstitialViewController.xib in Resources */, - 9826AE8C21B5CC8D00C851FA /* PostingActivityMonth.xib in Resources */, - 08D499671CDD20450004809A /* Menus.storyboard in Resources */, - 9A3BDA1022944F4D00FBF510 /* CountriesMapView.xib in Resources */, - 98797DBD222F434500128C21 /* OverviewCell.xib in Resources */, - 17E3639D22C41DBA000E0C79 /* hot_pink_icon_40pt@3x.png in Resources */, - 9A8ECE1E2254AE4E0043C8DA /* JetpackRemoteInstallStateView.xib in Resources */, - 17E3634022C417F0000E0C79 /* jetpack_green_icon_29pt.png in Resources */, - 17DC4C2F22C5E6910059CA11 /* open_source_dark_icon_40pt@3x.png in Resources */, - 17E363DE22C4280A000E0C79 /* pride_icon_20pt.png in Resources */, - 98B33C88202283860071E1E2 /* NoResults.storyboard in Resources */, - B50421E71B680839008EEA82 /* NoteUndoOverlayView.xib in Resources */, - D82253EE219A8A960014D0E2 /* SiteInformationWizardContent.xib in Resources */, - 82A062DC2017BC220084CE7C /* ActivityListSectionHeaderView.xib in Resources */, - 17E363E522C4280A000E0C79 /* pride_icon_40pt@2x.png in Resources */, - FFC6ADD51B56F295002F3C84 /* AboutViewController.xib in Resources */, - 17E3639822C41DBA000E0C79 /* hot_pink_icon_60pt@3x.png in Resources */, - E1D91456134A853D0089019C /* Localizable.strings in Resources */, - 431EF35A21F7D4000017BE16 /* QuickStartListTitleCell.xib in Resources */, - B5EEB19F1CA96D19004B6540 /* ImageCropViewController.xib in Resources */, - 5D1D04751B7A50B100CDE646 /* Reader.storyboard in Resources */, - 17E363DF22C4280A000E0C79 /* pride_icon_29pt@2x.png in Resources */, - 9A8ECE0D2254A3260043C8DA /* JetpackLoginViewController.xib in Resources */, - 17DC4C3722C5E6910059CA11 /* open_source_dark_icon_29pt@3x.png in Resources */, - 98458CBA21A39D7A0025D232 /* StatsNoDataRow.xib in Resources */, - 82FC61261FA8ADAD00A1757E /* ActivityTableViewCell.xib in Resources */, - B5E23BDF19AD0D00000D6879 /* NoteTableViewCell.xib in Resources */, - 9AB36B84236B25D900FAD72A /* StatsGhostTitleCell.xib in Resources */, - 17DC4C3422C5E6910059CA11 /* open_source_dark_icon_76pt@2x.png in Resources */, - B5C66B761ACF072C00F68370 /* NoteBlockCommentTableViewCell.xib in Resources */, - 57AA8491228715E700D3C2A2 /* PostCardCell.xib in Resources */, - 17E3630422C41725000E0C79 /* wordpress_dark_icon_29pt@2x.png in Resources */, - 17E362F022C41275000E0C79 /* wordpress_icon_40pt@3x.png in Resources */, - D8AEA54B21C21BEC00AB4DCB /* NewVerticalCell.xib in Resources */, - 17DC4C4F22C5E6D60059CA11 /* open_source_icon_20pt@2x.png in Resources */, - 17DC4C5722C5E6D60059CA11 /* open_source_icon_60pt@2x.png in Resources */, - E18165FD14E4428B006CE885 /* loader.html in Resources */, - 17E363E622C4280A000E0C79 /* pride_icon_40pt.png in Resources */, - 17E3634A22C417F0000E0C79 /* jetpack_green_icon_60pt@2x.png in Resources */, - E1DD4CCD1CAE41C800C3863E /* PagedViewController.xib in Resources */, - 17E3634722C417F0000E0C79 /* jetpack_green_icon_76pt.png in Resources */, - 9AC3C69B231543C2007933CD /* StatsGhostChartCell.xib in Resources */, - E6A3384E1BB0A50900371587 /* ReaderGapMarkerCell.xib in Resources */, - 5DB767411588F64D00EBE36C /* postPreview.html in Resources */, - 989643EE23A0437B0070720A /* WidgetDifferenceCell.xib in Resources */, - E149771A1C0DCB6F0057CD60 /* MediaSizeSliderCell.xib in Resources */, - 17E3630822C41725000E0C79 /* wordpress_dark_icon_29pt@3x.png in Resources */, - E65219F91B8D10C2000B1217 /* ReaderBlockedSiteCell.xib in Resources */, - 9A4E215A21F7565A00EFF212 /* QuickStartChecklistCell.xib in Resources */, - 17E363E322C4280A000E0C79 /* pride_icon_83.5@2x.png in Resources */, - D8C31CC72188490000A33B35 /* SiteSegmentsCell.xib in Resources */, - E6D2E15F1B8A9C830000ED14 /* ReaderSiteStreamHeader.xib in Resources */, - 17E3630522C41725000E0C79 /* wordpress_dark_icon_60pt@3x.png in Resources */, - 17E3630C22C41725000E0C79 /* wordpress_dark_icon_20pt@3x.png in Resources */, - 17E3630322C41725000E0C79 /* wordpress_dark_icon_83.5@2x.png in Resources */, - D816B8D52112D6E70052CE4D /* NewsCard.xib in Resources */, - E61507E22220A0FE00213D33 /* richEmbedTemplate.html in Resources */, - 17E363E822C4280A000E0C79 /* pride_icon_29pt.png in Resources */, - B5C66B7A1ACF074600F68370 /* NoteBlockUserTableViewCell.xib in Resources */, - 17E363E122C4280A000E0C79 /* pride_icon_76pt@2x.png in Resources */, - 17E3630B22C41725000E0C79 /* wordpress_dark_icon_40pt.png in Resources */, - E6D2E1631B8AAA340000ED14 /* ReaderListStreamHeader.xib in Resources */, - 17E3630922C41725000E0C79 /* wordpress_dark_icon_29pt.png in Resources */, - CE1CCB2F2050502B000EE3AC /* MyProfileHeaderView.xib in Resources */, - 17DC4C3122C5E6910059CA11 /* open_source_dark_icon_40pt@2x.png in Resources */, - 5D18FEA01AFBB17400EFEED0 /* RestorePageTableViewCell.xib in Resources */, - 43134379217954F100DA2176 /* QuickStartSkipAllCell.xib in Resources */, - 747D09862034837C0085EABF /* WordPressShare.js in Resources */, - 9A76C32F22AFDA2100F5D819 /* world-map.svg in Resources */, - B5C66B721ACF071100F68370 /* NoteBlockTextTableViewCell.xib in Resources */, - 17DC4C3622C5E6910059CA11 /* open_source_dark_icon_60pt@3x.png in Resources */, - 17E3634822C417F0000E0C79 /* jetpack_green_icon_20pt.png in Resources */, - 17E3634322C417F0000E0C79 /* jetpack_green_icon_29pt@2x.png in Resources */, - E60C2ED51DE5048200488630 /* ReaderCommentCell.xib in Resources */, - FF37F90922385CA000AFA3DB /* RELEASE-NOTES.txt in Resources */, - 9808655A203D075E00D58786 /* EpilogueUserInfoCell.xib in Resources */, - E6D2E1611B8AA4410000ED14 /* ReaderTagStreamHeader.xib in Resources */, - B5CEEB901B79244D00E7B7B0 /* CommentsTableViewCell.xib in Resources */, - 577C2AB62294401800AD1F03 /* PostCompactCell.xib in Resources */, - 17E363DD22C4280A000E0C79 /* pride_icon_20pt@2x.png in Resources */, - 98906504237CC1DF00218CD2 /* WidgetUnconfiguredCell.xib in Resources */, - 17DC4C5322C5E6D60059CA11 /* open_source_icon_60pt@3x.png in Resources */, - 9A4E216221F87AF300EFF212 /* QuickStartChecklistHeader.xib in Resources */, - 5D69DBC4165428CA00A2D1F7 /* n.caf in Resources */, - 17E363A022C41DBA000E0C79 /* hot_pink_icon_60pt@2x.png in Resources */, - E1B912811BB00EFD003C25B9 /* People.storyboard in Resources */, - 17DC4C4E22C5E6D60059CA11 /* open_source_icon_20pt.png in Resources */, - 98712D1C23DA1C7E00555316 /* WidgetNoConnectionCell.xib in Resources */, - 9826AE8321B5C6A700C851FA /* LatestPostSummaryCell.xib in Resources */, - 17E3639722C41DBA000E0C79 /* hot_pink_icon_29pt@2x.png in Resources */, - 5D2FB2831AE98C4600F1D4ED /* RestorePostTableViewCell.xib in Resources */, - D82247F92113EF5C00918CEB /* News.strings in Resources */, - 1724DDCC1C6121D00099D273 /* Plans.storyboard in Resources */, - 40A2778120191B5E00D078D5 /* PluginDirectoryCollectionViewCell.xib in Resources */, - D82253E02199418B0014D0E2 /* WebAddressWizardContent.xib in Resources */, - 17DC4C5A22C5E6D60059CA11 /* open_source_icon_76pt.png in Resources */, - 17DC4C5622C5E6D60059CA11 /* open_source_icon_29pt@3x.png in Resources */, - 17DC4C3B22C5E6910059CA11 /* open_source_dark_icon_76pt.png in Resources */, - 173BCE751CEB369900AE8817 /* Domains.storyboard in Resources */, - 9A9E3FB4230EC4F700909BC4 /* StatsGhostPostingActivityCell.xib in Resources */, - 435B762A2297484200511813 /* ColorPalette.xcassets in Resources */, - 17E3639922C41DBA000E0C79 /* hot_pink_icon_76pt.png in Resources */, - 402FFB1C218C27C100FF4A0B /* RegisterDomain.storyboard in Resources */, - 5D732F991AE84E5400CD89E7 /* PostListFooterView.xib in Resources */, - 4D520D4F22972BC9002F5924 /* acknowledgements.html in Resources */, - 5D6C4AFF1B603CE9005E3C43 /* EditCommentViewController.xib in Resources */, - B51535D41BBB16AA0029B84B /* Launch Screen.storyboard in Resources */, - 17E363E722C4280A000E0C79 /* pride_icon_20pt@3x.png in Resources */, - 08216FAA1CDBF95100304BA7 /* MenuItemEditing.storyboard in Resources */, - 981676D7221B7A4300B81C3F /* CountriesCell.xib in Resources */, - 43D74AD420FB5ABA004AD934 /* RegisterDomainSectionHeaderView.xib in Resources */, - D818FFD82191566B000E5FEE /* VerticalsWizardContent.xib in Resources */, - 4349BFF5221205540084F200 /* BlogDetailsSectionHeaderView.xib in Resources */, - 9A5C854922B3E42800BEE7A3 /* CountriesMapCell.xib in Resources */, - 5D6C4B021B603D1F005E3C43 /* WPWebViewController.xib in Resources */, - E6D3E84B1BEBD888002692E8 /* ReaderCrossPostCell.xib in Resources */, - 9A09F91C230C49FD00F42AB7 /* StatsStackViewCell.xib in Resources */, - 17E362EF22C41275000E0C79 /* wordpress_icon_40pt@2x.png in Resources */, - 436D562D2117312700CEAA33 /* RegisterDomainDetailsErrorSectionFooter.xib in Resources */, - 5DFA7EC31AF7CB910072023B /* Pages.storyboard in Resources */, - 9A73CB082350DE4C002EF20C /* StatsGhostSingleRowCell.xib in Resources */, - 596C03601B84F24000899EEB /* ThemeBrowser.storyboard in Resources */, - 9A9E3FB2230EB74300909BC4 /* StatsGhostTabbedCell.xib in Resources */, - 17E3639F22C41DBA000E0C79 /* hot_pink_icon_20pt.png in Resources */, - 4034FDEE2007D4F700153B87 /* ExpandableCell.xib in Resources */, - 17DC4C5B22C5E6D60059CA11 /* open_source_icon_29pt.png in Resources */, - 988F073623D0CE8800AC67A6 /* WidgetUrlCell.xib in Resources */, - E61507E42220A13B00213D33 /* richEmbedScript.js in Resources */, - 401A3D022027DBD80099A127 /* PluginListCell.xib in Resources */, - 981C34912183871200FC2683 /* SiteStatsDashboard.storyboard in Resources */, - 986C908622319F2600FC31E1 /* PostStatsTableViewController.storyboard in Resources */, - 98D52C3322B1CFEC00831529 /* StatsTwoColumnRow.xib in Resources */, - 98812967219CE42A0075FF33 /* StatsTotalRow.xib in Resources */, - 98B3FA8621C05BF000148DD4 /* ViewMoreRow.xib in Resources */, - D865722F2186F96D0023A99C /* SiteSegmentsWizardContent.xib in Resources */, - 987535642282682D001661B4 /* DetailDataCell.xib in Resources */, - 9A9E3FAE230E9DD000909BC4 /* StatsGhostTwoColumnCell.xib in Resources */, + C87501EF243AEC290002CD60 /* Tenor */ = { + isa = PBXGroup; + children = ( + C81CCD5D243AEC8200A83E27 /* TenorAPI */, + C81CCD7A243BF7A600A83E27 /* NoResultsTenorConfiguration.swift */, + C81CCD79243BF7A600A83E27 /* TenorDataLoader.swift */, + C81CCD73243BF7A500A83E27 /* TenorDataSource.swift */, + C81CCD74243BF7A500A83E27 /* TenorMedia.swift */, + C81CCD75243BF7A500A83E27 /* TenorMediaGroup.swift */, + C81CCD72243BF7A500A83E27 /* TenorPageable.swift */, + C81CCD76243BF7A600A83E27 /* TenorPicker.swift */, + C81CCD78243BF7A600A83E27 /* TenorResultsPage.swift */, + C81CCD77243BF7A600A83E27 /* TenorService.swift */, + C81CCD71243BF7A500A83E27 /* TenorStrings.swift */, ); - runOnlyForDeploymentPostprocessing = 0; + path = Tenor; + sourceTree = ""; }; - 733F36012126197800988727 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 73D5AC64212622B200ADDDD2 /* Localizable.strings in Resources */, - 4328FED12314788C000EC32A /* ColorPalette.xcassets in Resources */, - 73C31907212F214200769485 /* Noticons.ttf in Resources */, + CC098B8116A9EB0400450976 /* HTML */ = { + isa = PBXGroup; + children = ( + 4D520D4E22972BC9002F5924 /* acknowledgements.html */, + 5DB767401588F64D00EBE36C /* postPreview.html */, + E18165FC14E4428B006CE885 /* loader.html */, + A01C55470E25E0D000D411F2 /* defaultPostTemplate.html */, + 2FAE97040E33B21600CA8540 /* defaultPostTemplate_old.html */, + 2FAE97070E33B21600CA8540 /* xhtml1-transitional.dtd */, + 2FAE97080E33B21600CA8540 /* xhtmlValidatorTemplate.xhtml */, + E61507E12220A0FE00213D33 /* richEmbedTemplate.html */, + E61507E32220A13B00213D33 /* richEmbedScript.js */, + FE23EB4826E7C91F005A1698 /* richCommentStyle.css */, + FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */, ); - runOnlyForDeploymentPostprocessing = 0; + name = HTML; + sourceTree = ""; }; - 7358E6B6210BD318002323EB /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 7326718B210F75D2001FA866 /* Localizable.strings in Resources */, - 73E3238B212CE0D7001B735C /* Noticons.ttf in Resources */, + CC1D800D1656D8B2002A542F /* Notifications */ = { + isa = PBXGroup; + children = ( + 3FEC241325D73C53007AFE63 /* Milestone Notifications */, + 402FFB1D218C2B8D00FF4A0B /* Style */, + B5FD453E199D0F2800286FBB /* Controllers */, + 7E3E7A5120E44B060075D159 /* FormattableContent */, + B54E1DEC1A0A7BAA00807537 /* ReplyTextView */, + B5E23BD919AD0CED000D6879 /* Tools */, + B5FD4523199D0F1100286FBB /* Views */, + B558541019631A1000FAF6C3 /* Notifications.storyboard */, ); - runOnlyForDeploymentPostprocessing = 0; + path = Notifications; + sourceTree = ""; }; - 74576670202B558C00F42E40 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F5EF481823ABCAE0004C3532 /* MainInterface.storyboard in Resources */, - 7484D94D20320DFE006E94B4 /* WordPressShare.js in Resources */, - 741AF3A2202F3DC400C771A5 /* ShareExtension.storyboard in Resources */, - 74E44ADC2031EFD600556205 /* Localizable.strings in Resources */, - 986FF29C214196A5005B28EC /* NoResults.storyboard in Resources */, - 433840C922C2BA6400CB13F8 /* AppImages.xcassets in Resources */, - 435B762C2297484200511813 /* ColorPalette.xcassets in Resources */, + CC2BB0CC2289D0F20034F9AB /* Me */ = { + isa = PBXGroup; + children = ( + EA78189327596B2F00554DFA /* ContactUsScreen.swift */, + 6EC71EC22689A67400ACC0A0 /* SupportScreen.swift */, ); - runOnlyForDeploymentPostprocessing = 0; + path = Me; + sourceTree = ""; }; - 8511CFB41C607A7000B7CEED /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( + CC7CB97422B159FE00642EE9 /* Signup */ = { + isa = PBXGroup; + children = ( + CC7CB98622B28F4600642EE9 /* WelcomeScreenSignupComponent.swift */, + CC7CB97522B15A2900642EE9 /* SignupEmailScreen.swift */, + CC7CB97722B15B2C00642EE9 /* SignupCheckMagicLinkScreen.swift */, + CC7CB97922B15C1000642EE9 /* SignupEpilogueScreen.swift */, ); - runOnlyForDeploymentPostprocessing = 0; + path = Signup; + sourceTree = ""; }; - 932225A51C7CE50300443B02 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 740C7C4F202F4CD6001C31B0 /* MainInterface.storyboard in Resources */, - E1AFA8C31E8E34230004A323 /* WordPressShare.js in Resources */, - 74F5CD381FE0646F00764E7C /* ShareExtension.storyboard in Resources */, - 986FF29B214196A4005B28EC /* NoResults.storyboard in Resources */, - 931430201E68B9C50014B6C6 /* Localizable.strings in Resources */, - 433840C822C2BA6300CB13F8 /* AppImages.xcassets in Resources */, - 435B762B2297484200511813 /* ColorPalette.xcassets in Resources */, + CC8498CF2241473400DB490A /* EditorSettingsComponents */ = { + isa = PBXGroup; + children = ( + CCE55E982242715C002A9634 /* CategoriesComponent.swift */, + CC8498D22241477F00DB490A /* TagsComponent.swift */, ); - runOnlyForDeploymentPostprocessing = 0; + path = EditorSettingsComponents; + sourceTree = ""; }; - 93E5283819A7741A003A1A9C /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 93E5284319A7741A003A1A9C /* MainInterface.storyboard in Resources */, - 98906509237CC1DF00218CD2 /* WidgetTwoColumnCell.xib in Resources */, - 9A144AC822FD6A650069DD71 /* ColorPalette.xcassets in Resources */, - 98712D2023DA1D1000555316 /* WidgetNoConnectionCell.xib in Resources */, - 98906505237CC1DF00218CD2 /* WidgetUnconfiguredCell.xib in Resources */, - E1B6A9CC1E54B6B2008FD47E /* Localizable.strings in Resources */, - 988F073723D0D15D00AC67A6 /* WidgetUrlCell.xib in Resources */, + CCB3A03814C8DD5100D43C3F /* Reader */ = { + isa = PBXGroup; + children = ( + 9870EF7827866DCE00F3BB54 /* Comments */, + 8B7F51C724EED488008CF5B5 /* Analytics */, + 5D1D04731B7A50B100CDE646 /* Reader.storyboard */, + 321955BE24BE234C00E3F316 /* ReaderInterestsCoordinator.swift */, + FAE4327325874D140039EB8C /* ReaderSavedPostCellActions.swift */, + 8BADF16324801B4B005AD038 /* Detail */, + 32E1BFD824A66801007A08F0 /* Select Interests */, + F5A738C1244DF92300EDE065 /* Manage */, + F597768C243E1E140062BAD1 /* Filter */, + 5D5A6E901B613C1800DAF819 /* Cards */, + 5D08B8FD19647C0800D5B381 /* Controllers */, + E6D2E16A1B8B41AC0000ED14 /* Headers */, + 5D98A1491B6C09730085E904 /* Style */, + 3F09CCA62428FE8600D00A8C /* Tab Navigation */, + 5D08B8FC19647C0300D5B381 /* Views */, ); - runOnlyForDeploymentPostprocessing = 0; + path = Reader; + sourceTree = ""; }; - 98A3C2ED239997DA0048D38D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 989643EF23A0437B0070720A /* WidgetDifferenceCell.xib in Resources */, - 9880150623A840200003BD11 /* ColorPalette.xcassets in Resources */, - 98A3C3022399A19F0048D38D /* MainInterface.storyboard in Resources */, - 98712D2223DA1D1200555316 /* WidgetNoConnectionCell.xib in Resources */, - 989643E823A031CD0070720A /* WidgetUnconfiguredCell.xib in Resources */, - 983AE8512399B19200E5B7F6 /* Localizable.strings in Resources */, - 988F073923D0D15E00AC67A6 /* WidgetUrlCell.xib in Resources */, + D80EE638203DBB7E0094C34C /* Accessibility */ = { + isa = PBXGroup; + children = ( + D8071630203DA23700B32FD9 /* Accessible.swift */, ); - runOnlyForDeploymentPostprocessing = 0; + path = Accessibility; + sourceTree = ""; }; - 98D31B8C2396ED7E009CFF43 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 98A6B9942398821B0031AEBD /* WidgetTwoColumnCell.xib in Resources */, - 985ED0E223C686CA00B8D06A /* ColorPalette.xcassets in Resources */, - 98D31BBD23971F4B009CFF43 /* Localizable.strings in Resources */, - 98712D2123DA1D1100555316 /* WidgetNoConnectionCell.xib in Resources */, - 98A6B996239882350031AEBD /* WidgetUnconfiguredCell.xib in Resources */, - 98D7ECCE23983D0800B87710 /* MainInterface.storyboard in Resources */, - 988F073823D0D15D00AC67A6 /* WidgetUrlCell.xib in Resources */, + D816043E209C1AD300ABAFFA /* ReaderPostActions */ = { + isa = PBXGroup; + children = ( + D8212CB820AA77AD008E8AE8 /* ReaderActionHelpers.swift */, + D8212CC220AA7F57008E8AE8 /* ReaderBlockSiteAction.swift */, + C3A1166829807E3F00B0CB6E /* ReaderBlockUserAction.swift */, + D8212CC420AA83F9008E8AE8 /* ReaderCommentAction.swift */, + D8212CB220AA6861008E8AE8 /* ReaderFollowAction.swift */, + D8212CC620AA85C1008E8AE8 /* ReaderHeaderAction.swift */, + D8212CB020AA64E1008E8AE8 /* ReaderLikeAction.swift */, + D8212CC820AA87E5008E8AE8 /* ReaderMenuAction.swift */, + 3F8CB10523A07B17007627BF /* ReaderReblogAction.swift */, + 3F5B3EB023A851480060FF1F /* ReaderReblogFormatter.swift */, + 3F5B3EAE23A851330060FF1F /* ReaderReblogPresenter.swift */, + 32A218D7251109DB00D1AE6C /* ReaderReportPostAction.swift */, + 175A650B20B6F7280023E71B /* ReaderSaveForLater+Analytics.swift */, + D8160441209C1B0F00ABAFFA /* ReaderSaveForLaterAction.swift */, + 981D464725B0D4E7000AA65C /* ReaderSeenAction.swift */, + D8212CB620AA7703008E8AE8 /* ReaderShareAction.swift */, + D8212CBE20AA7B7F008E8AE8 /* ReaderShowAttributionAction.swift */, + D8212CC020AA7C58008E8AE8 /* ReaderShowMenuAction.swift */, + E6D6A12F2683ABE6004C24A7 /* ReaderSubscribeCommentsAction.swift */, + D8212CB420AA68D5008E8AE8 /* ReaderSubscribingNotificationAction.swift */, + D8212CBC20AA7A7A008E8AE8 /* ReaderVisitSiteAction.swift */, ); - runOnlyForDeploymentPostprocessing = 0; + name = ReaderPostActions; + sourceTree = ""; }; - E16AB92714D978240047A2E5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - E1EBC3751C118EDE00F638E0 /* ImmuTableTestViewCellWithNib.xib in Resources */, - E15027631E03E51500B847E3 /* notes-action-unsupported.json in Resources */, - 93C882A21EEB18D700227A59 /* html_page_with_link_to_rsd.html in Resources */, - D848CC0520FF062100A9038F /* notifications-user-content-meta.json in Resources */, - 7E4A772320F7BE94001C706D /* activity-log-comment-content.json in Resources */, - E12BE5EE1C5235DB000FD5CA /* get-me-settings-v1.1.json in Resources */, - 933D1F6C1EA7A3AB009FB462 /* TestingMode.storyboard in Resources */, - 08F8CD371EBD2AA80049D0C0 /* test-image-device-photo-gps.jpg in Resources */, - D88A64A6208D92B1008AE9BC /* stock-photos-media.json in Resources */, - 7E4A772520F7C5E5001C706D /* activity-log-theme-content.json in Resources */, - D848CBF720FEEE7F00A9038F /* notifications-text-content.json in Resources */, - D848CC0F20FF2D9B00A9038F /* notifications-comment-range.json in Resources */, - E15027621E03E51500B847E3 /* notes-action-push.json in Resources */, - 748BD8851F19234300813F9A /* notifications-mark-as-read.json in Resources */, - 7E442FCA20F678D100DEACA5 /* activity-log-pingback-content.json in Resources */, - D848CC0120FF030C00A9038F /* notifications-comment-meta.json in Resources */, - 93594BD5191D2F5A0079E6B2 /* stats-batch.json in Resources */, - 74585B9C1F0D591D00E7E667 /* domain-service-valid-domains.json in Resources */, - 7E4A772D20F7E8D8001C706D /* activity-log-plugin-content.json in Resources */, - 93CD939319099BE70049096E /* authtoken.json in Resources */, - E1E4CE0F1774563F00430844 /* misteryman.jpg in Resources */, - 855408881A6F106800DDBD79 /* app-review-prompt-notifications-disabled.json in Resources */, - D88A64A4208D8FB6008AE9BC /* stock-photos-search-response.json in Resources */, - D88A64AE208D9CF5008AE9BC /* stock-photos-pageable.json in Resources */, - 7E92828921090E9A00BBD8A3 /* notifications-pingback.json in Resources */, - 93C882A11EEB18D700227A59 /* html_page_with_link_to_rsd_non_standard.html in Resources */, - 8554088A1A6F107D00DDBD79 /* app-review-prompt-global-disable.json in Resources */, - D8A468E02181C6450094B82F /* site-segment.json in Resources */, - D82247FB2113F50600918CEB /* News.strings in Resources */, - 7E4A772B20F7E5FD001C706D /* activity-log-site-content.json in Resources */, - 7EF2EEA0210A67B60007A76B /* notifications-unapproved-comment.json in Resources */, - D871F98C214235C9002849B0 /* NewsBadFormed.strings in Resources */, - 748437EC1F1D4A4800E8DDAF /* gallery-reader-post-public.json in Resources */, - 7E4A772720F7CDD5001C706D /* activity-log-settings-content.json in Resources */, - B5AEEC7A1ACACFDA008BF2A4 /* notifications-like.json in Resources */, - D88A64AA208D974D008AE9BC /* thumbnail-collection.json in Resources */, - 08B832421EC130D60079808D /* test-gif.gif in Resources */, - D821C819210037F8002ED995 /* activity-log-activity-content.json in Resources */, - 748437EB1F1D4A4800E8DDAF /* gallery-reader-post-private.json in Resources */, - D848CBFB20FEFA4800A9038F /* notifications-comment-content.json in Resources */, - E1E4CE0617739FAB00430844 /* test-image.jpg in Resources */, - 7E4A772120F7BBBD001C706D /* activity-log-post-content.json in Resources */, - 748437F01F1D4E9E00E8DDAF /* notifications-load-all.json in Resources */, - E11330511A13BAA300D36D84 /* me-sites-with-jetpack.json in Resources */, - 7E53AB0820FE6C9C005796FE /* activity-log-post.json in Resources */, - D848CC0B20FF2D5D00A9038F /* notifications-user-range.json in Resources */, - 855408861A6F105700DDBD79 /* app-review-prompt-all-enabled.json in Resources */, - D848CC1320FF31BB00A9038F /* notifications-blockquote-range.json in Resources */, - D848CC0D20FF2D7C00A9038F /* notifications-post-range.json in Resources */, - 748437F11F1D4ECC00E8DDAF /* notifications-last-seen.json in Resources */, - 74585B9D1F0D591D00E7E667 /* domain-service-all-domain-types.json in Resources */, - E131CB5616CACF1E004B0314 /* get-user-blogs_has-blog.json in Resources */, - 93C882A31EEB18D700227A59 /* plugin_redirect.html in Resources */, - B5DA8A5F20ADAA1D00D5BDE1 /* plugin-directory-jetpack.json in Resources */, - 084D94AF1EDF842F00C385A6 /* test-video-device-gps.m4v in Resources */, - D848CBFD20FEFB4900A9038F /* notifications-user-content.json in Resources */, - 933D1F6E1EA7A402009FB462 /* TestAssets.xcassets in Resources */, - B5EFB1D11B33630C007608A3 /* notifications-settings.json in Resources */, - E15027611E03E51500B847E3 /* notes-action-delete.json in Resources */, - 08F8CD361EBD2AA80049D0C0 /* test-image-device-photo-gps-portrait.jpg in Resources */, - B5AEEC7D1ACACFDA008BF2A4 /* notifications-replied-comment.json in Resources */, - D848CC0920FF2D4400A9038F /* notifications-icon-range.json in Resources */, - E131CB5816CACFB4004B0314 /* get-user-blogs_doesnt-have-blog.json in Resources */, - 08DF9C441E8475530058678C /* test-image-portrait.jpg in Resources */, - B5AEEC7C1ACACFDA008BF2A4 /* notifications-new-follower.json in Resources */, - B5AEEC791ACACFDA008BF2A4 /* notifications-badge.json in Resources */, - D848CC1120FF310400A9038F /* notifications-site-range.json in Resources */, - 7E53AB0620FE6905005796FE /* activity-log-comment.json in Resources */, - 93C882A41EEB18D700227A59 /* rsd.xml in Resources */, + D816C1EA20E0884100C4D82F /* Actions */ = { + isa = PBXGroup; + children = ( + D816C1E820E0880400C4D82F /* NotificationAction.swift */, + D816C1EB20E0887C00C4D82F /* ApproveComment.swift */, + D816C1ED20E0892200C4D82F /* Follow.swift */, + D816C1EF20E0893A00C4D82F /* LikeComment.swift */, + D816C1F120E0894D00C4D82F /* ReplyToComment.swift */, + D816C1F320E0895E00C4D82F /* MarkAsSpam.swift */, + D816C1F520E0896F00C4D82F /* TrashComment.swift */, + D858F2FC20E1F09F007E8A1C /* NotificationActionParser.swift */, + D858F30020E20106007E8A1C /* LikePost.swift */, + D858F30220E201F4007E8A1C /* EditComment.swift */, ); - runOnlyForDeploymentPostprocessing = 0; + path = Actions; + sourceTree = ""; }; - FF27168D1CAAC87A0006E2D4 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( + D8380CA72194287B00250609 /* Web Address */ = { + isa = PBXGroup; + children = ( + 08EA036529C9B50500B72A87 /* DesignSystem */, + D82253E3219956540014D0E2 /* AddressTableViewCell.swift */, + D853723921952DAF0076F461 /* WebAddressStep.swift */, + D82253DD2199418B0014D0E2 /* WebAddressWizardContent.swift */, + 467D3DF925E4436000EB9CB0 /* SitePromptView.swift */, + 467D3E0B25E4436D00EB9CB0 /* SitePromptView.xib */, + 08CBC77829AE6CC4000026E7 /* SiteCreationEmptySiteTemplate.swift */, + F4FE743329C3767300AC2729 /* AddressTableViewCell+ViewModel.swift */, ); - runOnlyForDeploymentPostprocessing = 0; + path = "Web Address"; + sourceTree = ""; }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 2D007B2B2850683878B2D4F1 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-WordPressNotificationContentExtension-checkManifestLockResult.txt", + D865720F21869C380023A99C /* Site Creation */ = { + isa = PBXGroup; + children = ( + 46241BD72540B403002B8A12 /* Design Selection */, + 73178C2D21BEE13E00E37C9A /* Final Assembly */, + 738B9A4721B85CF20005062B /* Shared */, + B0960C8527D14B9200BC9717 /* Site Intent */, + 3FBB2D2927FB6BFA00C57BBF /* Site Name */, + D8C31CBF2188442200A33B35 /* Site Segments */, + D8380CA72194287B00250609 /* Web Address */, + 738B9A3F21B85CF20005062B /* Wizard */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + path = "Site Creation"; + sourceTree = ""; }; - 2DF08408C90B90D744C56E02 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( + D86572182186C36E0023A99C /* Wizards */ = { + isa = PBXGroup; + children = ( + D865721121869C590023A99C /* Wizard.swift */, + D86572162186C3600023A99C /* WizardDelegate.swift */, + D865721021869C590023A99C /* WizardStep.swift */, ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-WordPressScreenshotGeneration-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + path = Wizards; + sourceTree = ""; }; - 37399571B0D91BBEAE911024 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-WordPress/Pods-WordPress-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/Sentry/Sentry.framework", - "${PODS_ROOT}/ZendeskCommonUISDK/CommonUISDK.framework", - "${PODS_ROOT}/ZendeskCoreSDK/ZendeskCoreSDK.framework", - "${PODS_ROOT}/ZendeskMessagingAPISDK/MessagingAPI.framework", - "${PODS_ROOT}/ZendeskMessagingSDK/MessagingSDK.framework", - "${PODS_ROOT}/ZendeskSDKConfigurationsSDK/SDKConfigurations.framework", - "${PODS_ROOT}/ZendeskSupportProvidersSDK/SupportProvidersSDK.framework", - "${PODS_ROOT}/ZendeskSupportSDK/SupportSDK.framework", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CommonUISDK.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZendeskCoreSDK.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MessagingAPI.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MessagingSDK.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDKConfigurations.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SupportProvidersSDK.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SupportSDK.framework", + D88A6490208D79F1008AE9BC /* Stock Photos */ = { + isa = PBXGroup; + children = ( + D88A64AF208DA093008AE9BC /* StockPhotosResultsPageTests.swift */, + D88A64AB208D9B09008AE9BC /* StockPhotosPageableTests.swift */, + D88A64A7208D9733008AE9BC /* ThumbnailCollectionTests.swift */, + D88A64A1208D8F05008AE9BC /* StockPhotosMediaTests.swift */, + D88A649F208D8B7D008AE9BC /* StockPhotosMediaGroupTests.swift */, + D88A649B208D7D81008AE9BC /* StockPhotosDataSourceTests.swift */, + D88A6491208D7A0A008AE9BC /* MockStockPhotosService.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPress/Pods-WordPress-frameworks.sh\"\n"; - showEnvVarsInLog = 0; + name = "Stock Photos"; + sourceTree = ""; }; - 37F48D4CB364EA4BCC1EAE82 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-WordPressUITests-checkManifestLockResult.txt", + D8A3A5AD206A059100992576 /* StockPhotos */ = { + isa = PBXGroup; + children = ( + D8A3A5A92069E53900992576 /* AztecMediaPickingCoordinator.swift */, + D8A3A5AB2069FE5B00992576 /* StockPhotosStrings.swift */, + D8A3A5AE206A442800992576 /* StockPhotosDataSource.swift */, + 7EBB4125206C388100012D98 /* StockPhotosService.swift */, + D88A6493208D7AD0008AE9BC /* DefaultStockPhotosService.swift */, + D88A6495208D7B0B008AE9BC /* NullStockPhotosService.swift */, + D8A3A5B0206A49A100992576 /* StockPhotosMediaGroup.swift */, + D8A3A5B2206A49BF00992576 /* StockPhotosMedia.swift */, + D8A3A5B4206A4C7800992576 /* StockPhotosPicker.swift */, + 98F1B1292111017900139493 /* NoResultsStockPhotosConfiguration.swift */, + D80BC79D20746B4100614A59 /* MediaPickingContext.swift */, + D83CA3A420842CAF0060E310 /* Pageable.swift */, + D83CA3A620842CD90060E310 /* ResultsPage.swift */, + D83CA3A820842D190060E310 /* StockPhotosPageable.swift */, + D83CA3AA20842E5F0060E310 /* StockPhotosResultsPage.swift */, + D83CA3AF2084CAAF0060E310 /* StockPhotosDataLoader.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + path = StockPhotos; + sourceTree = ""; }; - 3E7D1BBC0746F969F0997C43 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-WordPressThisWeekWidget/Pods-WordPressThisWeekWidget-resources.sh", - "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + D8A468DE2181C5B50094B82F /* Site Creation */ = { + isa = PBXGroup; + children = ( + D8A468DF2181C6450094B82F /* site-segment.json */, + 46B30B862582CA2200A25E66 /* domain-suggestions.json */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressThisWeekWidget/Pods-WordPressThisWeekWidget-resources.sh\"\n"; - showEnvVarsInLog = 0; + name = "Site Creation"; + sourceTree = ""; }; - 4CB8AA817C9BD74F3416B27C /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-WordPressTodayWidget-checkManifestLockResult.txt", + D8C31CBF2188442200A33B35 /* Site Segments */ = { + isa = PBXGroup; + children = ( + D8C31CC42188490000A33B35 /* SiteSegmentsCell.swift */, + D8C31CC52188490000A33B35 /* SiteSegmentsCell.xib */, + D853723B21952DC90076F461 /* SiteSegmentsStep.swift */, + D865722C2186F96B0023A99C /* SiteSegmentsWizardContent.swift */, + D865722D2186F96C0023A99C /* SiteSegmentsWizardContent.xib */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + path = "Site Segments"; + sourceTree = ""; }; - 4F4D5C2BB6478A3E90ADC3C5 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-WordPressShareExtension-checkManifestLockResult.txt", + D8CB56212181A93F00554EAE /* Site Creation */ = { + isa = PBXGroup; + children = ( + D8225407219AB0520014D0E2 /* SiteInformation.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + name = "Site Creation"; + sourceTree = ""; }; - 552DBF4AE076B0EE1EC2D94D /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-WordPressNotificationServiceExtension/Pods-WordPressNotificationServiceExtension-resources.sh", - "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + DC06DFF727BD52A100969974 /* BackgroundTasks */ = { + isa = PBXGroup; + children = ( + DC06DFF827BD52BE00969974 /* WeeklyRoundupBackgroundTaskTests.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressNotificationServiceExtension/Pods-WordPressNotificationServiceExtension-resources.sh\"\n"; - showEnvVarsInLog = 0; + path = BackgroundTasks; + sourceTree = ""; }; - 591AAEE0843D274DDFF16F69 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", + DC06DFFA27BD678100969974 /* Prepublishing Nudge */ = { + isa = PBXGroup; + children = ( + DC06DFFB27BD679700969974 /* BlogTitleTests.swift */, ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( + name = "Prepublishing Nudge"; + sourceTree = ""; + }; + DC2CA0822837B9070037E17E /* Shared Views */ = { + isa = PBXGroup; + children = ( + DC2CA0832837B9070037E17E /* Stats Detail */, ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-WordPressThisWeekWidget-checkManifestLockResult.txt", + path = "Shared Views"; + sourceTree = ""; + }; + DC2CA0832837B9070037E17E /* Stats Detail */ = { + isa = PBXGroup; + children = ( + DC2CA0842837B9070037E17E /* SiteStatsInsightsDetailsViewModelTests.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + path = "Stats Detail"; + sourceTree = ""; }; - 59B02D1583A9AC2ACCFFD153 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + DC590CFE26F205C400EB0F73 /* Time Zone */ = { + isa = PBXGroup; + children = ( + 8F228031B2964AE9A667C735 /* Views */, + 8F2283367263B37B0681F988 /* TimeZoneSelectorViewController.swift */, + DC8F61F627032B3F0087AC5D /* TimeZoneFormatter.swift */, + DC3B9B2B27739760003F7249 /* TimeZoneSelectorViewModel.swift */, ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-WordPressTodayWidget/Pods-WordPressTodayWidget-resources.sh", - "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + path = "Time Zone"; + sourceTree = ""; + }; + DC772AF7282009FC00664C02 /* ViewsVisitors */ = { + isa = PBXGroup; + children = ( + DC772B0628201F5200664C02 /* ViewsVisitorsChartMarker.swift */, + DC772B0728201F5300664C02 /* ViewsVisitorsLineChartCell.swift */, + DC772B0528201F5200664C02 /* ViewsVisitorsLineChartCell.xib */, ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + path = ViewsVisitors; + sourceTree = ""; + }; + DC772AFB28200A3600664C02 /* Stats */ = { + isa = PBXGroup; + children = ( + DC2CA0822837B9070037E17E /* Shared Views */, + DCF892CE282FA40000BB71E1 /* Helpers */, + DC772AFC28200A3600664C02 /* Insights */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressTodayWidget/Pods-WordPressTodayWidget-resources.sh\"\n"; - showEnvVarsInLog = 0; + name = Stats; + path = ViewRelated/Stats; + sourceTree = ""; }; - 61CF58FC6EB9AB58632C4770 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + DC772AFC28200A3600664C02 /* Insights */ = { + isa = PBXGroup; + children = ( + DC772AFD28200A3600664C02 /* stats-visits-day-4.json */, + DC772AFE28200A3600664C02 /* stats-visits-day-14.json */, + DC772AFF28200A3600664C02 /* SiteStatsInsightViewModelTests.swift */, + DCFC6A28292523D20062D65B /* SiteStatsPinnedItemStoreTests.swift */, + DC772B0028200A3600664C02 /* stats-visits-day-11.json */, ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-WordPressAllTimeWidget/Pods-WordPressAllTimeWidget-resources.sh", - "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + path = Insights; + sourceTree = ""; + }; + DC8F61F9270331DF0087AC5D /* Tools */ = { + isa = PBXGroup; + children = ( + DC8F61FA270331DF0087AC5D /* Time Zone */, ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + name = Tools; + path = ViewRelated/Tools; + sourceTree = ""; + }; + DC8F61FA270331DF0087AC5D /* Time Zone */ = { + isa = PBXGroup; + children = ( + DC8F61FB2703321F0087AC5D /* TimeZoneFormatterTests.swift */, + DC3B9B2E27739887003F7249 /* TimeZoneSelectorViewModelTests.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressAllTimeWidget/Pods-WordPressAllTimeWidget-resources.sh\"\n"; - showEnvVarsInLog = 0; + path = "Time Zone"; + sourceTree = ""; }; - 74CC431A202B5C73000DAE1A /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + DCF892CE282FA40000BB71E1 /* Helpers */ = { + isa = PBXGroup; + children = ( + DCF892D1282FA45500BB71E1 /* StatsMockDataLoader.swift */, + DCF892CF282FA42A00BB71E1 /* SiteStatsImmuTableRowsTests.swift */, ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-WordPressDraftActionExtension-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + path = Helpers; + sourceTree = ""; }; - 825F0EBF1F7EBF7C00321528 /* App Icons: Add Version For Internal Releases */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "App Icons: Add Version For Internal Releases"; - outputPaths = ( + E10520591F2B1CD400A948F6 /* 61-62 */ = { + isa = PBXGroup; + children = ( + E105205A1F2B1CF400A948F6 /* BlogToBlogMigration_61_62.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "#!/bin/sh\n\n# This script adds the version number to the icon of internal releases.\n\nexport PATH=/opt/local/bin/:/opt/local/sbin:$PATH:/usr/local/bin:\nif [ \"${CONFIGURATION}\" != \"Release-Internal\" ]; then\nexit 0;\nfi\n\nsh ../Scripts/BuildPhases/AddVersionToIcons.sh\n"; + path = "61-62"; + sourceTree = ""; }; - 83D79708413A3DA10638659F /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension-resources.sh", - "${PODS_ROOT}/Down/Resources/DownView.bundle", - "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPress-Aztec-iOS/WordPress-Aztec-iOS.bundle", - "${PODS_ROOT}/WordPress-Editor-iOS/WordPressEditor/WordPressEditor/Assets/aztec.png", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DownView.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPress-Aztec-iOS.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/aztec.png", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + E1239B7B176A2E0F00D37220 /* Tests */ = { + isa = PBXGroup; + children = ( + 7E442FC520F677A300DEACA5 /* ActivityLog */, + 7EC9FE0822C6275900C5A888 /* Analytics */, + FF7691661EE06CF500713F4B /* Aztec */, + B5AEEC7F1ACAD099008BF2A4 /* Categories */, + 572FB3FE223A800500933C76 /* Classes */, + B5AEEC731ACACF3B008BF2A4 /* Core Data */, + 8B6214E427B1B420001DF7B6 /* Dashboard */, + 175CC17327205BDC00622FB4 /* Domains */, + E1C9AA541C1041E600732665 /* Extensions */, + FF9A6E6F21F9359200D36D14 /* Gutenberg */, + 32110548250BFC5A0048446F /* Image Dimension Parser */, + 803DE81D29063689007D4E9C /* Jetpack */, + FF7C89A11E3A1029000472A8 /* MediaPicker */, + 5D7A577D1AFBFD7C0097C028 /* Models */, + 85F8E1991B017A8E000859BB /* Networking */, + B5416CF81C17542900006DD8 /* Notifications */, + 9A9D34FB23607C8400BC95A3 /* Operations */, + 59ECF8791CB705EB00E68F25 /* Posts */, + C738CB0928623CD6001BE107 /* QRLogin */, + E6B9B8AB1B94EA710001B92F /* Reader */, + 436D55EE2115CB3D00CEAA33 /* RegisterDomain */, + B5AEEC7E1ACAD088008BF2A4 /* Services */, + 73178C2021BEE09300E37C9A /* SiteCreation */, + 40E7FEC32211DF490032834E /* Stats */, + D88A6490208D79F1008AE9BC /* Stock Photos */, + BEA0E4821BD8353B000AEE81 /* System */, + 59B48B601B99E0B0008EBB84 /* TestUtilities */, + 852416D01A12ED2D0030700C /* Utility */, + BE20F5E11B2F738E0020694C /* ViewRelated */, + 3F3D8548251E63DF001CA4D2 /* What's New */, + FF9839A71CD3960600E85258 /* WordPressAPI */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension-resources.sh\"\n"; - showEnvVarsInLog = 0; + name = Tests; + sourceTree = ""; }; - 920B9A6DAD47189622A86A9C /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-WordPress/Pods-WordPress-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/1PasswordExtension/OnePasswordExtensionResources.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/Automattic-Tracks-iOS/DataModel.bundle", - "${PODS_ROOT}/Down/Resources/DownView.bundle", - "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", - "${PODS_ROOT}/GoogleSignIn/Resources/GoogleSignIn.bundle", - "${PODS_ROOT}/MediaEditor/Sources/Capabilities/Filters/MediaEditorFilters.storyboard", - "${PODS_ROOT}/MediaEditor/Sources/MediaEditorHub.storyboard", - "${PODS_CONFIGURATION_BUILD_DIR}/MediaEditor/MediaEditor.bundle", - "${PODS_ROOT}/SVProgressHUD/SVProgressHUD/SVProgressHUD.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/TOCropViewController/TOCropViewControllerBundle.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WPMediaPicker/WPMediaPicker.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPress-Aztec-iOS/WordPress-Aztec-iOS.bundle", - "${PODS_ROOT}/WordPress-Editor-iOS/WordPressEditor/WordPressEditor/Assets/aztec.png", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressAuthenticator/WordPressAuthenticatorResources.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/AppCenter/AppCenterDistributeResources.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/OnePasswordExtensionResources.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DataModel.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DownView.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MediaEditorFilters.storyboardc", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MediaEditorHub.storyboardc", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MediaEditor.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SVProgressHUD.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/TOCropViewControllerBundle.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WPMediaPicker.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPress-Aztec-iOS.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/aztec.png", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressAuthenticatorResources.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AppCenterDistributeResources.bundle", + E125F1E21E8E594C00320B67 /* Shared */ = { + isa = PBXGroup; + children = ( + E125F1E31E8E595E00320B67 /* SharePost.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPress/Pods-WordPress-resources.sh\"\n"; - showEnvVarsInLog = 0; + path = Shared; + sourceTree = ""; }; - 9879533A2135D77500743763 /* Zendesk Strip Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( + E131CB5B16CAD638004B0314 /* Helpers */ = { + isa = PBXGroup; + children = ( + E180BD4D1FB4681E00D0D781 /* MockCookieJar.swift */, + E1B642121EFA5113001DC6D7 /* ModelTestHelper.swift */, + 4A17C1A3281A823E0001FFE5 /* NSManagedObject+Fixture.swift */, + 4AFB8FBE2824999400A2F4B2 /* ContextManager+Helpers.swift */, + 933D1F451EA64108009FB462 /* TestingAppDelegate.h */, + 933D1F461EA64108009FB462 /* TestingAppDelegate.m */, + 933D1F6B1EA7A3AB009FB462 /* TestingMode.storyboard */, + 933D1F6D1EA7A402009FB462 /* TestAssets.xcassets */, + D81C2F5720F86CEA002AE1F1 /* NetworkStatus.swift */, + 8BE7C84023466927006EDE70 /* I18n.swift */, + 0148CC2A2859C87000CF5D96 /* BlogServiceMock.swift */, + F4426FD2287E08C300218003 /* SuggestionServiceMock.swift */, + F4426FD8287F02FD00218003 /* SiteSuggestionsServiceMock.swift */, + 8070EB3D28D807CB005C6513 /* InMemoryUserDefaults.swift */, + 805CC0B6296680CF002941DC /* RemoteFeatureFlagStoreMock.swift */, + 805CC0B8296680F7002941DC /* RemoteConfigStoreMock.swift */, + 805CC0BE29668A97002941DC /* MockCurrentDateProvider.swift */, ); - name = "Zendesk Strip Frameworks"; - outputPaths = ( + name = Helpers; + sourceTree = ""; + }; + E1389AD91C59F78500FB2466 /* Plans */ = { + isa = PBXGroup; + children = ( + E15644E51CE0E43700D96E64 /* Controllers */, + E15644E71CE0E44700D96E64 /* ViewModels */, + E15644E61CE0E43F00D96E64 /* Views */, + 1724DDCB1C6121D00099D273 /* Plans.storyboard */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# Per Zendesk documentation (https://developer.zendesk.com/embeddables/docs/ios_support_sdk/sdk_add):\n# This script should be the last step in your projects \"Build Phases\".\n# This step is required to work around an App store submission bug when archiving universal binaries.\n\nbash \"${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/SupportSDK.framework/strip-frameworks.sh\"\n"; + path = Plans; + sourceTree = ""; }; - 9D186898B0632AA1273C9DE2 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + E14694041F3459A9004052C8 /* Plugins */ = { + isa = PBXGroup; + children = ( + E1F47D4B1FE028F800C1D44E /* Views */, + E151C0C41F3889CA00710A83 /* ViewModels */, + E14694051F3459CE004052C8 /* Controllers */, ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension-resources.sh", - "${PODS_ROOT}/Down/Resources/DownView.bundle", - "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPress-Aztec-iOS/WordPress-Aztec-iOS.bundle", - "${PODS_ROOT}/WordPress-Editor-iOS/WordPressEditor/WordPressEditor/Assets/aztec.png", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + path = Plugins; + sourceTree = ""; + }; + E14694051F3459CE004052C8 /* Controllers */ = { + isa = PBXGroup; + children = ( + 40E469922017F3D20030DB5F /* PluginDirectoryViewController.swift */, + E14694061F3459E2004052C8 /* PluginListViewController.swift */, + E126C81E1F95FC1B00A5F464 /* PluginViewController.swift */, ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DownView.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPress-Aztec-iOS.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/aztec.png", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + name = Controllers; + sourceTree = ""; + }; + E151C0C41F3889CA00710A83 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 40E469942017FB1F0030DB5F /* PluginDirectoryViewModel.swift */, + E151C0C51F3889DF00710A83 /* PluginListRow.swift */, + E151C0C71F388A2000710A83 /* PluginListViewModel.swift */, + E17E67021FA22C93009BDC9A /* PluginViewModel.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension-resources.sh\"\n"; - showEnvVarsInLog = 0; + name = ViewModels; + sourceTree = ""; }; - A279580D198819F50031C6A3 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + E1523EB216D3B2EE002C5A36 /* Sharing */ = { + isa = PBXGroup; + children = ( + E1D0D81416D3B86800E33F4C /* SafariActivity.h */, + E1D0D81516D3B86800E33F4C /* SafariActivity.m */, + E1D95EB617A28F5E00A3E9F3 /* WPActivityDefaults.h */, + E1D95EB717A28F5E00A3E9F3 /* WPActivityDefaults.m */, ); - inputPaths = ( + path = Sharing; + sourceTree = ""; + }; + E15644E51CE0E43700D96E64 /* Controllers */ = { + isa = PBXGroup; + children = ( + 17D2FDC11C6A468A00944265 /* PlanComparisonViewController.swift */, + 1724DDC71C60F1200099D273 /* PlanDetailViewController.swift */, + E1389ADA1C59F7C200FB2466 /* PlanListViewController.swift */, ); - outputPaths = ( + name = Controllers; + sourceTree = ""; + }; + E15644E61CE0E43F00D96E64 /* Views */ = { + isa = PBXGroup; + children = ( + E15644F01CE0E56600D96E64 /* FeatureItemCell.swift */, + 172797D81CE5D0CD00CB8057 /* PlansLoadingIndicatorView.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# sh ../run-oclint.sh \nsh ../Scripts/run-oclint.sh"; - showEnvVarsInLog = 0; + name = Views; + sourceTree = ""; }; - BAE780768320204E29A6FE5B /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + E15644E71CE0E44700D96E64 /* ViewModels */ = { + isa = PBXGroup; + children = ( + E15644EA1CE0E4C500D96E64 /* FeatureItemRow.swift */, + E15644F21CE0E5A500D96E64 /* PlanDetailViewModel.swift */, + E15644EC1CE0E4FE00D96E64 /* PlanListRow.swift */, + E15644EE1CE0E53B00D96E64 /* PlanListViewModel.swift */, ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-WordPressNotificationServiceExtension-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + name = ViewModels; + sourceTree = ""; }; - C8C0D3005B2024B01347CCEF /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-WordPressNotificationContentExtension/Pods-WordPressNotificationContentExtension-resources.sh", - "${PODS_ROOT}/FormatterKit/FormatterKit/FormatterKit.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FormatterKit.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + E159D1011309AAF200F498E2 /* Migrations */ = { + isa = PBXGroup; + children = ( + E1A03EDF17422DBC0085D192 /* 10-11 */, + 5D49B03519BE37CC00703A9B /* 20-21 */, + 937D9A0D19F837ED007B9D5F /* 22-23 */, + 5DF7F7751B223895003A05C8 /* 30-31 */, + E1C4F4E61B29D5B900DAAB8E /* 32-33 */, + E66969D01B9E3D5000EC9C00 /* 37-38 */, + E10520591F2B1CD400A948F6 /* 61-62 */, + 7E8980CB22E8C81B00C567B0 /* 87-88 */, + E100C6BA1741472F00AE48D8 /* WordPress-11-12.xcmappingmodel */, + 5D51ADAE19A832AF00539C0B /* WordPress-20-21.xcmappingmodel */, + 937D9A0E19F83812007B9D5F /* WordPress-22-23.xcmappingmodel */, + E1D86E681B2B414300DD2192 /* WordPress-32-33.xcmappingmodel */, + 5DF7F7731B22337C003A05C8 /* WordPress-30-31.xcmappingmodel */, + E603C76F1BC94AED00AD49D7 /* WordPress-37-38.xcmappingmodel */, + B555E1141C04A68D00CEC81B /* WordPress-41-42.xcmappingmodel */, + E10520571F2B1CC900A948F6 /* WordPress-61-62.xcmappingmodel */, + E192E78B22EF453C008D725D /* WordPress-87-88.xcmappingmodel */, + 57CCB3802358ED07003ECD0C /* WordPress-91-92.xcmappingmodel */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressNotificationContentExtension/Pods-WordPressNotificationContentExtension-resources.sh\"\n"; - showEnvVarsInLog = 0; + path = Migrations; + sourceTree = ""; }; - CC875D93233BCEC800595CC8 /* Set Up Simulator */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Set Up Simulator"; - outputFileListPaths = ( - ); - outputPaths = ( + E16AB92F14D978240047A2E5 /* WordPressTest */ = { + isa = PBXGroup; + children = ( + E16AB93014D978240047A2E5 /* Supporting Files */, + B532ACD41DC3AE1F00FFFA57 /* Extensions */, + E16AB94414D9A13A0047A2E5 /* Mock Data */, + E131CB5B16CAD638004B0314 /* Helpers */, + E1239B7B176A2E0F00D37220 /* Tests */, + CCCF53BC237B13760035E9CA /* WordPressUnitTests.xctestplan */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# Close all simulators so on next launch they use the settings below\nxcrun simctl shutdown all\n\n# Disable the hardware keyboard in the simulator\ndefaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false\n"; + path = WordPressTest; + sourceTree = ""; }; - E00F6488DE2D86BDC84FBB0B /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-WordPress-checkManifestLockResult.txt", + E16AB93014D978240047A2E5 /* Supporting Files */ = { + isa = PBXGroup; + children = ( + 93E9050219E6F240005513C9 /* WordPressTest-Bridging-Header.h */, + E16AB93114D978240047A2E5 /* WordPressTest-Info.plist */, + E16AB93814D978240047A2E5 /* WordPressTest-Prefix.pch */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + name = "Supporting Files"; + sourceTree = ""; }; - E0E31D6E60F2BCE2D0A53E39 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + E16AB94414D9A13A0047A2E5 /* Mock Data */ = { + isa = PBXGroup; + children = ( + F44FB6CF2878A0F70001E3CE /* Suggestions */, + 46CFA7BD262745A50077BAD9 /* BlockEditorSettings and Styles */, + C8567490243F371D001A995E /* Tenor */, + D8A468DE2181C5B50094B82F /* Site Creation */, + 7E442FC820F6783600DEACA5 /* ActivityLog */, + 8BEE845627B1DC5E0001A93C /* Dashboard */, + B5DA8A5E20ADAA1C00D5BDE1 /* plugin-directory-jetpack.json */, + 855408851A6F105700DDBD79 /* app-review-prompt-all-enabled.json */, + 855408891A6F107D00DDBD79 /* app-review-prompt-global-disable.json */, + 855408871A6F106800DDBD79 /* app-review-prompt-notifications-disabled.json */, + F127FFD724213B5600B9D41A /* atomic-get-authentication-cookie-success.json */, + 93CD939219099BE70049096E /* authtoken.json */, + FE003F61282E73E6006F8D1D /* blogging-prompts-fetch-success.json */, + FEFC0F8D27313DCF001F7F1D /* comments-v2-success.json */, + 4A76A4BE29D4F0A500AABF4B /* reader-post-comments-success.json */, + FEFC0F8F27315634001F7F1D /* empty-array.json */, + 74585B9A1F0D591D00E7E667 /* domain-service-valid-domains.json */, + 74585B9B1F0D591D00E7E667 /* domain-service-all-domain-types.json */, + 175CC1762721814B00622FB4 /* domain-service-updated-domains.json */, + 748437E91F1D4A4800E8DDAF /* gallery-reader-post-private.json */, + 748437EA1F1D4A4800E8DDAF /* gallery-reader-post-public.json */, + E12BE5ED1C5235DB000FD5CA /* get-me-settings-v1.1.json */, + E131CB5716CACFB4004B0314 /* get-user-blogs_doesnt-have-blog.json */, + E131CB5516CACF1E004B0314 /* get-user-blogs_has-blog.json */, + 93C882981EEB18D700227A59 /* html_page_with_link_to_rsd_non_standard.html */, + 93C882991EEB18D700227A59 /* html_page_with_link_to_rsd.html */, + E1EBC3741C118EDE00F638E0 /* ImmuTableTestViewCellWithNib.xib */, + E11330501A13BAA300D36D84 /* me-sites-with-jetpack.json */, + E1E4CE0E1774531500430844 /* misteryman.jpg */, + B58CE5DD1DC1284C004AA81D /* Notifications */, + 93C8829A1EEB18D700227A59 /* plugin_redirect.html */, + 8BB185CB24B6058600A4CCE8 /* reader-cards-success.json */, + 93C8829B1EEB18D700227A59 /* rsd.xml */, + 93594BD4191D2F5A0079E6B2 /* stats-batch.json */, + 08B832411EC130D60079808D /* test-gif.gif */, + 08F8CD331EBD2AA80049D0C0 /* test-image-device-photo-gps-portrait.jpg */, + 08F8CD341EBD2AA80049D0C0 /* test-image-device-photo-gps.jpg */, + 08DF9C431E8475530058678C /* test-image-portrait.jpg */, + E1E4CE0517739FAB00430844 /* test-image.jpg */, + 084D94AE1EDF842600C385A6 /* test-video-device-gps.m4v */, + D88A64A3208D8FB6008AE9BC /* stock-photos-search-response.json */, + D88A64A5208D92B1008AE9BC /* stock-photos-media.json */, + D88A64A9208D974D008AE9BC /* thumbnail-collection.json */, + D88A64AD208D9CF5008AE9BC /* stock-photos-pageable.json */, + FE3D057F26C3E0F2002A51B0 /* share-app-link-success.json */, ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", + name = "Mock Data"; + path = "Test Data"; + sourceTree = ""; + }; + E1A03EDF17422DBC0085D192 /* 10-11 */ = { + isa = PBXGroup; + children = ( + E1A03EE017422DCD0085D192 /* BlogToAccount.h */, + E1A03EE117422DCE0085D192 /* BlogToAccount.m */, + E1A03F46174283DF0085D192 /* BlogToJetpackAccount.h */, + E1A03F47174283E00085D192 /* BlogToJetpackAccount.m */, ); - name = "[CP] Check Pods Manifest.lock"; - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-WordPressTest-checkManifestLockResult.txt", + path = "10-11"; + sourceTree = ""; + }; + E1B34C091CCDFFCE00889709 /* Credentials */ = { + isa = PBXGroup; + children = ( + 24AE9E66264B34E500AC7F15 /* Secrets-example.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + path = Credentials; + sourceTree = ""; }; - E1756E61169493AD00D9EC00 /* Generate API credentials */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + E1B9127F1BB00EF1003C25B9 /* People */ = { + isa = PBXGroup; + children = ( + B59B18771CC7FC190055EB7C /* Controllers */, + B59B18761CC7FC070055EB7C /* Style */, + B59B18781CC7FC230055EB7C /* Views */, + B59B18791CC7FC330055EB7C /* ViewModels */, + E1B912801BB00EFD003C25B9 /* People.storyboard */, ); - inputPaths = ( - "$(SRCROOT)/Credentials/gencredentials.rb", - "$(WPCOM_CONFIG)", + path = People; + sourceTree = ""; + }; + E1B921BA1C0ED481003EA3CB /* Cells */ = { + isa = PBXGroup; + children = ( + E1B921BB1C0ED5A3003EA3CB /* MediaSizeSliderCellTest.swift */, ); - name = "Generate API credentials"; - outputPaths = ( - "$(BUILT_PRODUCTS_DIR)/../Derived Sources/ApiCredentials.m", + name = Cells; + path = ViewRelated/Cells; + sourceTree = ""; + }; + E1C4F4E61B29D5B900DAAB8E /* 32-33 */ = { + isa = PBXGroup; + children = ( + E16273E01B2ACEB600088AF7 /* BlogToBlog32to33.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# Delete the in-repo 'Derived Sources'\nrm -rf \"${SOURCE_ROOT}/Derived Sources\"\n\n# Place 'Derived Sources' relative to BUILT_PRODUCTS_DIR \nDERIVED_SOURCES_DIR=\"${BUILT_PRODUCTS_DIR}/../Derived Sources\"\n\nmkdir -p \"${DERIVED_SOURCES_DIR}\"\ncp \"${SOURCE_ROOT}/Credentials/ApiCredentials.m\" \"${DERIVED_SOURCES_DIR}/ApiCredentials.m\"\n\necho \"Checking for WordPress.com Oauth App Secret in $WPCOM_CONFIG\"\nif [ -a $WPCOM_CONFIG ]\nthen\necho \"Config found\"\nsource $WPCOM_CONFIG\nelse\necho \"No config found\"\nexit 0\nfi\n\nif [ -z $WPCOM_APP_ID ]\nthen\necho \"warning: Missing WPCOM_APP_ID\"\nexit 1\nfi\nif [ -z $WPCOM_APP_SECRET ]\nthen\necho \"warning: Missing WPCOM_APP_SECRET\"\nexit 1\nfi\n\necho \"Generating credentials file in ${DERIVED_SOURCES_DIR}/ApiCredentials.m\"\nruby \"${SOURCE_ROOT}/Credentials/gencredentials.rb\" > \"${DERIVED_SOURCES_DIR}/ApiCredentials.m\"\n"; - showEnvVarsInLog = 0; + name = "32-33"; + sourceTree = ""; }; - E1C5456F219F10E000896227 /* Copy Gutenberg JS */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + E1C9AA541C1041E600732665 /* Extensions */ = { + isa = PBXGroup; + children = ( + B556EFCA1DCA374200728F93 /* DictionaryHelpersTests.swift */, + E1C9AA551C10427100732665 /* MathTest.swift */, + E63C897B1CB9A0D700649C8F /* UITextFieldTextHelperTests.swift */, + B5552D811CD1061F00B26DF6 /* StringExtensionsTests.swift */, + E1AB5A081E0BF31E00574B4E /* ArrayTests.swift */, + 0885A3661E837AFE00619B4D /* URLIncrementalFilenameTests.swift */, + D88A649D208D82D2008AE9BC /* XCTestCase+Wait.swift */, + E11DF3E320C97F0A00C0B07C /* NotificationCenterObserveOnceTests.swift */, + F17A2A1F23BFBD84001E96AC /* UIView+ExistingConstraints.swift */, + F9941D1722A805F600788F33 /* UIImage+XCAssetTests.swift */, + 8BFE36FE230F1C850061EBA8 /* AbstractPost+fixLocalMediaURLsTests.swift */, + AE3047A9270B66D300FE9266 /* Scanner+QuotedTextTests.swift */, ); - inputPaths = ( + path = Extensions; + sourceTree = ""; + }; + E1CA0A6A1FA73039004C4BBE /* Stores */ = { + isa = PBXGroup; + children = ( + E1ECE34E1FA88DA2007FA37A /* StoreContainer.swift */, + 402B2A7820ACD7690027C1DC /* ActivityStore.swift */, + 17FCA6801FD84B4600DBA9C8 /* NoticeStore.swift */, + E1CA0A6B1FA73053004C4BBE /* PluginStore.swift */, + 40ADB15420686870009A9161 /* PluginStore+Persistence.swift */, + 98AE3DF4219A1788003C0E24 /* StatsInsightsStore.swift */, + 984B139321F66B2D0004B6A2 /* StatsPeriodStore.swift */, + 9A4A8F4A235758EF00088CE4 /* StatsStore+Cache.swift */, + E1CB6DA2200F376400945457 /* TimeZoneStore.swift */, + 9A2D0B24225CB97F009E585F /* JetpackInstallStore.swift */, + 9A4E271C22EF33F5001F6A6B /* AccountSettingsStore.swift */, + 9A09F914230C3E9700F42AB7 /* StoreFetchingStatus.swift */, + 24ADA24B24F9A4CB001B5DAE /* RemoteFeatureFlagStore.swift */, + 3F3CA64F25D3003C00642A89 /* StatsWidgetsStore.swift */, + 08A4E128289D202F001D9EC7 /* UserPersistentStore.swift */, + 08A4E12B289D2337001D9EC7 /* UserPersistentRepository.swift */, + 08E39B4428A3DEB200874CB8 /* UserPersistentStoreFactory.swift */, + 0878580228B4CF950069F96C /* UserPersistentRepositoryUtility.swift */, + 803DE81228FFAE36007D4E9C /* RemoteConfigStore.swift */, + 0147D64D294B1E1600AA6410 /* StatsRevampStore.swift */, ); - name = "Copy Gutenberg JS"; - outputPaths = ( + path = Stores; + sourceTree = ""; + }; + E1F391ED1FF25DEC00DB32A3 /* Jetpack */ = { + isa = PBXGroup; + children = ( + 3FFA5ECF2876129900830E28 /* Branding */, + C700F9CD257FD5AA0090938E /* Jetpack Scan */, + 2F668B5D255DD11400D0038A /* Jetpack Settings */, + 9A8ECE002254A3250043C8DA /* Login */, + 9A8ECE032254A3260043C8DA /* Install */, + FA4F65B52594589C00EAA9F5 /* Jetpack Restore */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# Print commands before executing them (useful for troubleshooting)\nset -x\nDEST=$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH\n\nif [[ \"$CONFIGURATION\" = *Debug* && ! \"$PLATFORM_NAME\" == *simulator ]]; then\nIP=$(ipconfig getifaddr en0)\nif [ -z \"$IP\" ]; then\nIP=$(ifconfig | grep 'inet ' | grep -v ' 127.' | cut -d\\ -f2 | awk 'NR==1{print $1}')\nfi\n\necho \"$IP\" > \"$DEST/ip.txt\"\nfi\n\nBUNDLE_FILE=\"$DEST/main.jsbundle\"\ncp ${PODS_ROOT}/Gutenberg/bundle/ios/App.js \"${BUNDLE_FILE}\"\n"; + path = Jetpack; + sourceTree = ""; }; - F9C5CF0222CD5DB0007CEF56 /* Copy Alternate Internal Icons (if needed) */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + E1F47D4B1FE028F800C1D44E /* Views */ = { + isa = PBXGroup; + children = ( + 401A3D012027DBD80099A127 /* PluginListCell.xib */, + 403269912027719C00608441 /* PluginDirectoryAccessoryItem.swift */, + 400F4624201E74EE000CFD9E /* CollectionViewContainerRow.swift */, + 40A2778020191B5E00D078D5 /* PluginDirectoryCollectionViewCell.xib */, + 40A2777E20191AA500D078D5 /* PluginDirectoryCollectionViewCell.swift */, + E1F47D4C1FE0290C00C1D44E /* PluginListCell.swift */, + 40E728841FF3D9070010E7C9 /* PluginDetailViewHeaderCell.swift */, + 4058F4191FF40EE1000D5559 /* PluginDetailViewHeaderCell.xib */, ); - inputFileListPaths = ( + name = Views; + sourceTree = ""; + }; + E62AFB641DC8E56B007484FC /* WPRichText */ = { + isa = PBXGroup; + children = ( + E6805D2D1DCD399600168E4F /* WPRichTextEmbed.swift */, + E6805D2E1DCD399600168E4F /* WPRichTextImage.swift */, + E6805D2F1DCD399600168E4F /* WPRichTextMediaAttachment.swift */, + E62AFB651DC8E593007484FC /* NSAttributedString+WPRichText.swift */, + E62AFB661DC8E593007484FC /* WPRichContentView.swift */, + E62AFB671DC8E593007484FC /* WPRichTextFormatter.swift */, + E62AFB681DC8E593007484FC /* WPTextAttachment.swift */, + E68580F51E0D91470090EE63 /* WPHorizontalRuleAttachment.swift */, + E62AFB691DC8E593007484FC /* WPTextAttachmentManager.swift */, ); - inputPaths = ( + name = WPRichText; + sourceTree = ""; + }; + E6417B951CA07AFE0084050A /* Views */ = { + isa = PBXGroup; + children = ( + 9872CB36203BB53F0066A293 /* Epilogues */, + 43FB3F401EBD215C00FC8A62 /* LoginEpilogueBlogCell.swift */, ); - name = "Copy Alternate Internal Icons (if needed)"; - outputFileListPaths = ( + name = Views; + sourceTree = ""; + }; + E6417B961CA07B060084050A /* Controllers */ = { + isa = PBXGroup; + children = ( + C3DD4DCC28BE5D300046C68E /* SplashPrologue */, + 176E194525C465E00058F1C5 /* UnifiedPrologue */, + E6C1E8431EF1D21F00D139D9 /* Epilogue */, + 98C43E841FE98041006FEF54 /* Social Signup */, ); - outputPaths = ( + name = Controllers; + sourceTree = ""; + }; + E6417B9A1CA07C0A0084050A /* Helpers */ = { + isa = PBXGroup; + children = ( + B560914B208A671E00399AE4 /* WPStyleGuide+SiteCreation.swift */, + 830A58D72793AB4400CDE94F /* LoginEpilogueAnimator.swift */, + E6158AC91ECDF518005FA441 /* LoginEpilogueUserInfo.swift */, + B5E51B7A203477DF00151ECD /* WordPressAuthenticationManager.swift */, + B5326E6E203F554C007392C3 /* WordPressSupportSourceTag+Helpers.swift */, + 839B150A2795DEE0009F5E77 /* UIView+Margins.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# Overwrite the icons in the bundle with the updated internal icons\nif [ \"${CONFIGURATION}\" != \"Release-Internal\" ]; then\nexit 0;\nfi\n\ncp -R ${PROJECT_DIR}/Resources/Icons-Internal/*.png \"${CONFIGURATION_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n"; + name = Helpers; + sourceTree = ""; }; - FF1C536C9FA7489B5AAA0FC2 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + E6431DDE1C4E890B00FD8D90 /* Sharing */ = { + isa = PBXGroup; + children = ( + E6C892D51C601D55007AD612 /* SharingButtonsViewController.swift */, + E6431DE31C4E892900FD8D90 /* SharingViewController.h */, + E6431DE41C4E892900FD8D90 /* SharingViewController.m */, + C3643ACE28AC049D00FC5FD3 /* SharingViewController.swift */, + 8BBBCE6F2717651200B277AC /* JetpackModuleHelper.swift */, + E6431DDF1C4E892900FD8D90 /* SharingConnectionsViewController.h */, + E6431DE01C4E892900FD8D90 /* SharingConnectionsViewController.m */, + E63BBC941C5168BE00598BE8 /* SharingAuthorizationHelper.h */, + E63BBC951C5168BE00598BE8 /* SharingAuthorizationHelper.m */, + F16601C323E9E783007950AE /* SharingAuthorizationWebViewController.swift */, + 175507B227A062980038ED28 /* PublicizeConnectionURLMatcher.swift */, + E663D18F1C65383E0017F109 /* SharingAccountViewController.swift */, + E6431DE11C4E892900FD8D90 /* SharingDetailViewController.h */, + E6431DE21C4E892900FD8D90 /* SharingDetailViewController.m */, + E64384821C628FCC0052ADB5 /* WPStyleGuide+Sharing.swift */, + E60BD230230A3DD400727E82 /* KeyringAccountHelper.swift */, + 931F312B2714302A0075433B /* PublicizeServicesState.swift */, ); - inputFileListPaths = ( + name = Sharing; + sourceTree = ""; + }; + E64595EE256B5D5000F7F90C /* Analytics */ = { + isa = PBXGroup; + children = ( + E64595EF256B5D7800F7F90C /* CommentAnalytics.swift */, ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", + name = Analytics; + sourceTree = ""; + }; + E66969D01B9E3D5000EC9C00 /* 37-38 */ = { + isa = PBXGroup; + children = ( + E66969D91B9E55AB00EC9C00 /* ReaderTopicToReaderTagTopic37to38.swift */, + E66969DB1B9E55C300EC9C00 /* ReaderTopicToReaderListTopic37to38.swift */, + E66969DF1B9E648100EC9C00 /* ReaderTopicToReaderDefaultTopic37to38.swift */, + E66969E11B9E67A000EC9C00 /* ReaderTopicToReaderSiteTopic37to38.swift */, + E66969E31B9E68B200EC9C00 /* ReaderPostToReaderPost37to38.swift */, ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( + name = "37-38"; + sourceTree = ""; + }; + E6B9B8AB1B94EA710001B92F /* Reader */ = { + isa = PBXGroup; + children = ( + 8B25F8D924B7683A009DD4C9 /* ReaderCSSTests.swift */, + 3236F79F24B61B780088E8F3 /* Select Interests */, + 8BDA5A6C247C2F8400AB124C /* ReaderDetailViewControllerTests.swift */, + 8BDA5A73247C5EAA00AB124C /* ReaderDetailCoordinatorTests.swift */, + D809E685203F0215001AA0DE /* ReaderPostCardCellTests.swift */, + 748437ED1F1D4A7300E8DDAF /* RichContentFormatterTests.swift */, + E6B9B8AE1B94FA1C0001B92F /* ReaderStreamViewControllerTests.swift */, + 8B7F51CA24EED8A8008CF5B5 /* ReaderTrackerTests.swift */, + 8BD8201C24BF9E5200FF25FD /* ReaderWelcomeBannerTests.swift */, + 3F1B66A123A2F52A0075F09E /* ReaderPostActions */, + 3F5094592454EC7A00C4470B /* Tabbed Reader */, + 08C42C30281807880034720B /* ReaderSubscribeCommentsActionTests.swift */, + 08B954F228535EE800B07185 /* FeatureHighlightStoreTests.swift */, ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-WordPressAllTimeWidget-checkManifestLockResult.txt", + name = Reader; + sourceTree = ""; + }; + E6C1E8431EF1D21F00D139D9 /* Epilogue */ = { + isa = PBXGroup; + children = ( + 433432511E9ED18900915988 /* LoginEpilogueViewController.swift */, + 93E63368272AC532009DACF8 /* LoginEpilogueChooseSiteTableViewCell.swift */, + 93E6336E272C1074009DACF8 /* LoginEpilogueCreateNewSiteCell.swift */, + 93E6336B272AF504009DACF8 /* LoginEpilogueDividerView.swift */, + 43FB3F461EC10F1E00FC8A62 /* LoginEpilogueTableViewController.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; + name = Epilogue; + sourceTree = ""; }; - FFA8E2301F94E3EF0002170F /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + E6D2E16A1B8B41AC0000ED14 /* Headers */ = { + isa = PBXGroup; + children = ( + E6D2E1681B8AAD9B0000ED14 /* ReaderListStreamHeader.swift */, + E6D2E1621B8AAA340000ED14 /* ReaderListStreamHeader.xib */, + E6D2E1641B8AAD7E0000ED14 /* ReaderSiteStreamHeader.swift */, + E6D2E15E1B8A9C830000ED14 /* ReaderSiteStreamHeader.xib */, + E6D2E16B1B8B423B0000ED14 /* ReaderStreamHeader.swift */, + E6D2E1661B8AAD8C0000ED14 /* ReaderTagStreamHeader.swift */, + E6D2E1601B8AA4410000ED14 /* ReaderTagStreamHeader.xib */, ); - inputPaths = ( + name = Headers; + sourceTree = ""; + }; + EC4696A80EA74DAC0040EE8E /* Pages */ = { + isa = PBXGroup; + children = ( + 5DFA7EC21AF7CB910072023B /* Pages.storyboard */, + 5703A4C722C0214C0028A343 /* Style */, + 9A22D9BE214A6B9800BAEAF2 /* Utils */, + 5DFA7EBD1AF7CB2E0072023B /* Controllers */, + 5DFA7EBE1AF7CB3A0072023B /* Views */, + 5D62BAD518AA88210044E5F7 /* PageSettingsViewController.h */, + 5D62BAD618AA88210044E5F7 /* PageSettingsViewController.m */, + 80D9CFFF29E85EBF00FE3400 /* PageEditorPresenter.swift */, ); - outputPaths = ( + path = Pages; + sourceTree = ""; + }; + F10465122554260600655194 /* Gesture Recognizer */ = { + isa = PBXGroup; + children = ( + F10465132554260600655194 /* BindableTapGestureRecognizer.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "if which swiftlint >/dev/null; then\nrake lint\nelse\necho \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + path = "Gesture Recognizer"; + sourceTree = ""; }; - FFC3F6FA1B0DBF1E00EFC359 /* Update Plist Preprocessor file */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( + F126FDFC20A33BDB0010EB6E /* Processors */ = { + isa = PBXGroup; + children = ( + F126FDFD20A33BDB0010EB6E /* VideoUploadProcessor.swift */, + F126FDFE20A33BDB0010EB6E /* ImgUploadProcessor.swift */, + F126FDFF20A33BDB0010EB6E /* DocumentUploadProcessor.swift */, ); - inputPaths = ( + path = Processors; + sourceTree = ""; + }; + F14B5F6F208E648200439554 /* config */ = { + isa = PBXGroup; + children = ( + 98FB6E9F23074CE5002DDC8D /* Common.xcconfig */, + F14B5F75208E64F900439554 /* Version.internal.xcconfig */, + F14B5F74208E64F900439554 /* Version.public.xcconfig */, + F14B5F70208E648200439554 /* WordPress.debug.xcconfig */, + F14B5F71208E648200439554 /* WordPress.release.xcconfig */, + F14B5F72208E648300439554 /* WordPress.internal.xcconfig */, + 24350E7C264DB76E009BB2B6 /* Jetpack.debug.xcconfig */, + 243511A4264DC2D9009BB2B6 /* Jetpack.internal.xcconfig */, + 24351059264DC1E2009BB2B6 /* Jetpack.release.xcconfig */, + 2439B1DB264ECBDF00239130 /* Jetpack.alpha.xcconfig */, + F14B5F73208E648300439554 /* WordPress.alpha.xcconfig */, ); - name = "Update Plist Preprocessor file"; - outputPaths = ( + name = config; + path = ../config; + sourceTree = ""; + }; + F15D1FB7265C40C300854EE5 /* Blogging Reminders */ = { + isa = PBXGroup; + children = ( + F117B11F265C53AB00D2CAA9 /* BloggingRemindersScheduler.swift */, + F111B87726580FCE00057942 /* BloggingRemindersStore.swift */, + 17F11EDA268623BA00D1BBA7 /* BloggingRemindersScheduleFormatter.swift */, ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "[ -a $WPCOM_CONFIG ] && source $WPCOM_CONFIG\n touch Info.plist\n echo \"\" > InfoPlist.h\n echo \"\" > InfoPlist-alpha.h\n echo \"\" > InfoPlist-internal.h\n if [ \"x$GOOGLE_LOGIN_SCHEME_ID\" != \"x\" ]; then\n echo \"#define GOOGLE_LOGIN_SCHEME_ID $GOOGLE_LOGIN_SCHEME_ID\" >> InfoPlist.h\n echo \"#define GOOGLE_LOGIN_SCHEME_ID $GOOGLE_LOGIN_SCHEME_ID\" >> InfoPlist-alpha.h\n echo \"#define GOOGLE_LOGIN_SCHEME_ID $GOOGLE_LOGIN_SCHEME_ID\" >> InfoPlist-internal.h\n else\n echo \"warning: Google Client ID not found\"\n fi\n"; - showEnvVarsInLog = 0; + path = "Blogging Reminders"; + sourceTree = ""; }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 1D60588E0D05DD3D006BFB54 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - F551E7F523F6EA3100751212 /* FloatingActionButton.swift in Sources */, - D816B8D42112D6E70052CE4D /* NewsCard.swift in Sources */, - B50C0C621EF42AF200372C65 /* TextList+WordPress.swift in Sources */, - B5722E421D51A28100F40C5E /* Notification.swift in Sources */, - 1759F1801FE1460C0003EC81 /* NoticeView.swift in Sources */, - 177B4C3321236F5C00CF8084 /* GiphyPageable.swift in Sources */, - 40C403F42215D66A00E8C894 /* TopViewedAuthorStatsRecordValue+CoreDataProperties.swift in Sources */, - 7E4A773920F80417001C706D /* ActivityPluginRange.swift in Sources */, - 7462BFD02028C49800B552D8 /* ShareNoticeViewModel.swift in Sources */, - 17A4A36920EE51870071C2CA /* Routes+Reader.swift in Sources */, - 0807CB721CE670A800CDBDAC /* WPContentSearchHelper.swift in Sources */, - 9A73B7152362FBAE004624A8 /* SiteStatsViewModel+AsyncBlock.swift in Sources */, - 74AF4D7C1FE417D200E3EBFE /* PostUploadOperation.swift in Sources */, - 37022D931981C19000F322B7 /* VerticallyStackedButton.m in Sources */, - 59DCA5211CC68AF3000F245F /* PageListViewController.swift in Sources */, - B5ECA6CA1DBAA0020062D7E0 /* CoreDataHelper.swift in Sources */, - FFC6ADDA1B56F366002F3C84 /* LocalCoreDataService.m in Sources */, - B5FDF9F320D842D2006D14E3 /* AztecNavigationController.swift in Sources */, - B587798619B799EB00E57C5A /* Notification+Interface.swift in Sources */, - 175A650C20B6F7280023E71B /* ReaderSaveForLater+Analytics.swift in Sources */, - 4054F4572214E3C800D261AB /* TagsCategoriesStatsRecordValue+CoreDataProperties.swift in Sources */, - 9A2B28F52192121400458F2A /* RevisionOperation.swift in Sources */, - 984B4EF320742FCC00F87888 /* ZendeskUtils.swift in Sources */, - F580C3CB23D8F9B40038E243 /* AbstractPost+Dates.swift in Sources */, - 1E9D544D23C4C56300F6A9E0 /* GutenbergRollout.swift in Sources */, - 17E24F5420FCF1D900BD70A3 /* Routes+MySites.swift in Sources */, - 0828D7FA1E6E09AE00C7C7D4 /* WPAppAnalytics+Media.swift in Sources */, - 7ED3695520A9F091007B0D56 /* Blog+ImageSourceInformation.swift in Sources */, - 591AA5011CEF9BF20074934F /* Post.swift in Sources */, - D82253EA219A8A720014D0E2 /* SiteInformationStep.swift in Sources */, - C545E0A21811B9880020844C /* ContextManager.m in Sources */, - E161B7EC1F839345000FDF0B /* CookieJar.swift in Sources */, - D853723A21952DAF0076F461 /* WebAddressStep.swift in Sources */, - B5F67AC71DB7D81300482C62 /* NotificationSyncMediator.swift in Sources */, - 177CBE501DA3A3AC009F951E /* CollectionType+Helpers.swift in Sources */, - 5D577D33189127BE00B964C3 /* PostGeolocationViewController.m in Sources */, - FA77E02A1BE17CFC006D45E0 /* ThemeBrowserHeaderView.swift in Sources */, - 9874767321963D330080967F /* SiteStatsInformation.swift in Sources */, - 738B9A5821B85CF20005062B /* TitleSubtitleHeader.swift in Sources */, - 43290CF4214F755400F6B398 /* FancyAlertViewController+QuickStart.swift in Sources */, - 7E4123CC20F418A500DF8486 /* ActivityActionsParser.swift in Sources */, - 40F46B6B22121BA800A2143B /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataProperties.swift in Sources */, - 17F0E1DA20EBDC0A001E9514 /* Routes+Me.swift in Sources */, - D83CA3AB20842E5F0060E310 /* StockPhotosResultsPage.swift in Sources */, - 4348C88321002FBD00735DC0 /* QuickStartTourGuide.swift in Sources */, - E102B7901E714F24007928E8 /* RecentSitesService.swift in Sources */, - E1D7FF381C74EB0E00E7E5E5 /* PlanService.swift in Sources */, - F1655B4822A6C2FA00227BFB /* Routes+Mbar.swift in Sources */, - 32CA6EC02390C61F00B51347 /* PostListEditorPresenter.swift in Sources */, - F580C3C123D22E2D0038E243 /* PreviewDeviceLabel.swift in Sources */, - 5D7DEA2919D488DD0032EE77 /* WPStyleGuide+ReaderComments.swift in Sources */, - 40C403F62215D66A00E8C894 /* TopViewedPostStatsRecordValue+CoreDataProperties.swift in Sources */, - 82FC61251FA8ADAD00A1757E /* ActivityListViewController.swift in Sources */, - E66E2A651FE4311300788F22 /* SettingsTitleSubtitleController.swift in Sources */, - E1222B671F878E4700D23173 /* WebViewControllerFactory.swift in Sources */, - E1D86E691B2B414300DD2192 /* WordPress-32-33.xcmappingmodel in Sources */, - 9A2B28E8219046ED00458F2A /* ShowRevisionsListManger.swift in Sources */, + F15D1FB8265C40EA00854EE5 /* Blogging Reminders */ = { + isa = PBXGroup; + children = ( + F15D1FB9265C41A900854EE5 /* BloggingRemindersStoreTests.swift */, + F151EC822665271200AEA89E /* BloggingRemindersSchedulerTests.swift */, + ); + name = "Blogging Reminders"; + sourceTree = ""; + }; + F181EDE326B2AC3100C61241 /* BackgroundTasks */ = { + isa = PBXGroup; + children = ( + F181EDE426B2AC7200C61241 /* BackgroundTasksCoordinator.swift */, + F1D8C6EF26C17A6C002E3323 /* WeeklyRoundupBackgroundTask.swift */, + F1D8C6E826BA94DF002E3323 /* WordPressBackgroundTaskEventHandler.swift */, + ); + path = BackgroundTasks; + sourceTree = ""; + }; + F1863714253E49B8003D4BEF /* Add Site */ = { + isa = PBXGroup; + children = ( + F1863715253E49B8003D4BEF /* AddSiteAlertFactory.swift */, + ); + path = "Add Site"; + sourceTree = ""; + }; + F198533821ADAA4E00DCDAE7 /* Editor */ = { + isa = PBXGroup; + children = ( + F115308021B17E65002F1D65 /* EditorFactory.swift */, + FF54D4631D6F3FA900A0DC4D /* GutenbergSettings.swift */, + 1E9D544C23C4C56300F6A9E0 /* GutenbergRollout.swift */, + ); + path = Editor; + sourceTree = ""; + }; + F198533B21ADAD0700DCDAE7 /* Editor */ = { + isa = PBXGroup; + children = ( + 932645A31E7C206600134988 /* GutenbergSettingsTests.swift */, + ); + name = Editor; + sourceTree = ""; + }; + F198FFB2256D4AB7001266EB /* Supporting Files */ = { + isa = PBXGroup; + children = ( + F1F163C225658B4D003DC13B /* Info.plist */, + F198FFB1256D4AB2001266EB /* WordPressIntentsRelease-Alpha.entitlements */, + F198FF7F256D498A001266EB /* WordPressIntentsRelease-Internal.entitlements */, + F198FF6E256D4914001266EB /* WordPressIntents.entitlements */, + F1ACDF4A256D6B2B0005AE9B /* WordPressIntents-Bridging-Header.h */, + ); + path = "Supporting Files"; + sourceTree = ""; + }; + F1C740BD26B18DEA005D0809 /* Developer */ = { + isa = PBXGroup; + children = ( + F1C740BE26B18E42005D0809 /* StoreSandboxSecretScreen.swift */, + F1D8C6EA26BABE3E002E3323 /* WeeklyRoundupDebugScreen.swift */, + ); + path = Developer; + sourceTree = ""; + }; + F1D690131F828FF000200E30 /* BuildInformation */ = { + isa = PBXGroup; + children = ( + F1D690141F828FF000200E30 /* BuildConfiguration.swift */, + F1D690151F828FF000200E30 /* FeatureFlag.swift */, + 17A09B98238FE13B0022AE0D /* FeatureFlagOverrideStore.swift */, + 803DE81528FFAEF2007D4E9C /* RemoteConfigParameter.swift */, + 80A2153F29CA68D5002FE8EB /* RemoteFeatureFlag.swift */, + 80A2154529D15B88002FE8EB /* RemoteConfigOverrideStore.swift */, + ); + path = BuildInformation; + sourceTree = ""; + }; + F1DB8D272288C12A00906E2F /* Uploads */ = { + isa = PBXGroup; + children = ( + F1DB8D282288C14400906E2F /* Uploader.swift */, + F1DB8D2A2288C24500906E2F /* UploadsManager.swift */, + ); + path = Uploads; + sourceTree = ""; + }; + F1F163BF25658B4D003DC13B /* WordPressIntents */ = { + isa = PBXGroup; + children = ( + F1F163C025658B4D003DC13B /* IntentHandler.swift */, + F1482CDF2575BDA4007E4DD6 /* SitesDataProvider.swift */, + 3F46AB0225BF5D6300CE2E98 /* Sites.intentdefinition */, + F198FFB2256D4AB7001266EB /* Supporting Files */, + ); + path = WordPressIntents; + sourceTree = ""; + }; + F407AF1529BA835B008BA5B9 /* Font */ = { + isa = PBXGroup; + children = ( + 17C3FA6E25E591D200EFFE12 /* UIFont+Weight.swift */, + 17C1D6902670E4A2006C8970 /* UIFont+Fitting.swift */, + ); + path = Font; + sourceTree = ""; + }; + F41BDD772910AFB900B7F2B0 /* Navigation */ = { + isa = PBXGroup; + children = ( + F41BDD782910AFCA00B7F2B0 /* MigrationFlowCoordinator.swift */, + 3FFDEF802917882800B625CE /* MigrationNavigationController.swift */, + F41BDD7A29114E2400B7F2B0 /* MigrationStep.swift */, + ); + path = Navigation; + sourceTree = ""; + }; + F41E4E8F28F1949D001880C6 /* App Icons */ = { + isa = PBXGroup; + children = ( + F41E4E8B28F18B7B001880C6 /* AppIconListViewModelTests.swift */, + ); + path = "App Icons"; + sourceTree = ""; + }; + F41E4EA428F20DF9001880C6 /* stroke-light */ = { + isa = PBXGroup; + children = ( + F41E4EA528F20DF9001880C6 /* stroke-light-icon-app-76.png */, + F41E4EA628F20DF9001880C6 /* stroke-light-icon-app-60@2x.png */, + F41E4EA728F20DF9001880C6 /* stroke-light-icon-app-60@3x.png */, + F41E4EA828F20DF9001880C6 /* stroke-light-icon-app-83.5@2x.png */, + F41E4EA928F20DF9001880C6 /* stroke-light-icon-app-76@2x.png */, + ); + path = "stroke-light"; + sourceTree = ""; + }; + F41E4EAF28F225DB001880C6 /* stroke-dark */ = { + isa = PBXGroup; + children = ( + F41E4EB028F225DB001880C6 /* stroke-dark-icon-app-76.png */, + F41E4EB128F225DB001880C6 /* stroke-dark-icon-app-83.5@2x.png */, + F41E4EB228F225DB001880C6 /* stroke-dark-icon-app-60@2x.png */, + F41E4EB328F225DB001880C6 /* stroke-dark-icon-app-60@3x.png */, + F41E4EB428F225DB001880C6 /* stroke-dark-icon-app-76@2x.png */, + ); + path = "stroke-dark"; + sourceTree = ""; + }; + F41E4EBA28F22931001880C6 /* spectrum */ = { + isa = PBXGroup; + children = ( + F41E4EBB28F22931001880C6 /* spectrum-icon-app-83.5@2x.png */, + F41E4EBC28F22931001880C6 /* spectrum-icon-app-76.png */, + F41E4EBD28F22931001880C6 /* spectrum-icon-app-76@2x.png */, + F41E4EBE28F22931001880C6 /* spectrum-icon-app-60@3x.png */, + F41E4EBF28F22931001880C6 /* spectrum-icon-app-60@2x.png */, + ); + path = spectrum; + sourceTree = ""; + }; + F41E4EC528F23E00001880C6 /* green-on-white */ = { + isa = PBXGroup; + children = ( + F41E4EC628F23E00001880C6 /* green-on-white-icon-app-60@3x.png */, + F41E4EC728F23E00001880C6 /* green-on-white-icon-app-60@2x.png */, + F41E4EC828F23E00001880C6 /* green-on-white-icon-app-76.png */, + F41E4EC928F23E00001880C6 /* green-on-white-icon-app-76@2x.png */, + F41E4ECA28F23E00001880C6 /* green-on-white-icon-app-83.5@2x.png */, + ); + path = "green-on-white"; + sourceTree = ""; + }; + F41E4ED028F2424B001880C6 /* dark-glow */ = { + isa = PBXGroup; + children = ( + F41E4ED128F2424B001880C6 /* dark-glow-icon-app-83.5@2x.png */, + F41E4ED228F2424B001880C6 /* dark-glow-icon-app-76.png */, + F41E4ED328F2424B001880C6 /* dark-glow-icon-app-60@3x.png */, + F41E4ED428F2424B001880C6 /* dark-glow-icon-app-60@2x.png */, + F41E4ED528F2424B001880C6 /* dark-glow-icon-app-76@2x.png */, + ); + path = "dark-glow"; + sourceTree = ""; + }; + F41E4EDB28F24623001880C6 /* 3D */ = { + isa = PBXGroup; + children = ( + F41E4EDC28F24623001880C6 /* 3d-icon-app-76@2x.png */, + F41E4EDD28F24623001880C6 /* 3d-icon-app-83.5@2x.png */, + F41E4EDE28F24623001880C6 /* 3d-icon-app-60@3x.png */, + F41E4EDF28F24623001880C6 /* 3d-icon-app-60@2x.png */, + F41E4EE028F24623001880C6 /* 3d-icon-app-76.png */, + ); + path = 3D; + sourceTree = ""; + }; + F41E4EE628F24783001880C6 /* white-on-green */ = { + isa = PBXGroup; + children = ( + F41E4EEA28F247D3001880C6 /* white-on-green-icon-app-60@2x.png */, + F41E4EE828F247D3001880C6 /* white-on-green-icon-app-60@3x.png */, + F41E4EE928F247D3001880C6 /* white-on-green-icon-app-76.png */, + F41E4EEB28F247D3001880C6 /* white-on-green-icon-app-76@2x.png */, + F41E4EE728F247D2001880C6 /* white-on-green-icon-app-83.5@2x.png */, + ); + path = "white-on-green"; + sourceTree = ""; + }; + F44293D428E3B39400D340AF /* App Icons */ = { + isa = PBXGroup; + children = ( + 17E362EB22C40BE8000E0C79 /* AppIconViewController.swift */, + F44293D128E3B18E00D340AF /* AppIconListViewModelType.swift */, + F44293D528E3BA1700D340AF /* AppIconListViewModel.swift */, + ); + path = "App Icons"; + sourceTree = ""; + }; + F44FB6C92878957E0001E3CE /* Mention */ = { + isa = PBXGroup; + children = ( + F44FB6CA287895AF0001E3CE /* SuggestionsListViewModelTests.swift */, + F44FB6CC287897F90001E3CE /* SuggestionsTableViewMockDelegate.swift */, + F4D9AF4E288AD2E300803D40 /* SuggestionViewModelTests.swift */, + F4D9AF50288AE23500803D40 /* SuggestionTableViewTests.swift */, + F4D9AF52288AE2BA00803D40 /* SuggestionsTableViewDelegateMock.swift */, + ); + path = Mention; + sourceTree = ""; + }; + F44FB6CF2878A0F70001E3CE /* Suggestions */ = { + isa = PBXGroup; + children = ( + F4426FDA287F066400218003 /* site-suggestions.json */, + F44FB6D02878A1020001E3CE /* user-suggestions.json */, + ); + name = Suggestions; + sourceTree = ""; + }; + F465976528E464DE00D5F49A /* Icons */ = { + isa = PBXGroup; + children = ( + F41E4EDB28F24623001880C6 /* 3D */, + F465977328E6595300D5F49A /* black-on-white */, + F465977E28E65DC600D5F49A /* blue-on-white */, + F465978928E65F7500D5F49A /* celadon-on-white */, + F465976828E4669200D5F49A /* cool-green */, + F41E4ED028F2424B001880C6 /* dark-glow */, + F465979428E65FB500D5F49A /* dark-green */, + F41E4EC528F23E00001880C6 /* green-on-white */, + F465979F28E65FF600D5F49A /* jetpack-light */, + F46597AA28E6603D00D5F49A /* neu-green */, + F46597B528E6686200D5F49A /* neumorphic-dark */, + F46597C028E668A600D5F49A /* neumorphic-light */, + F46597D628E6692D00D5F49A /* pink-on-white */, + F41E4EBA28F22931001880C6 /* spectrum */, + F46597E128E6697E00D5F49A /* spectrum-on-black */, + F46597EC28E669C200D5F49A /* spectrum-on-white */, + F41E4EAF28F225DB001880C6 /* stroke-dark */, + F41E4EA428F20DF9001880C6 /* stroke-light */, + F46597F728E669F800D5F49A /* white-on-black */, + F465980228E66A4200D5F49A /* white-on-blue */, + F465980D28E66A8700D5F49A /* white-on-celadon */, + F41E4EE628F24783001880C6 /* white-on-green */, + F465981928E66B2C00D5F49A /* white-on-pink */, + ); + path = Icons; + sourceTree = ""; + }; + F465976828E4669200D5F49A /* cool-green */ = { + isa = PBXGroup; + children = ( + F465976928E4669200D5F49A /* cool-green-icon-app-76@2x.png */, + F465976A28E4669200D5F49A /* cool-green-icon-app-76.png */, + F465976B28E4669200D5F49A /* cool-green-icon-app-60@3x.png */, + F465976C28E4669200D5F49A /* cool-green-icon-app-60@2x.png */, + F465976D28E4669200D5F49A /* cool-green-icon-app-83.5@2x.png */, + ); + path = "cool-green"; + sourceTree = ""; + }; + F465977328E6595300D5F49A /* black-on-white */ = { + isa = PBXGroup; + children = ( + F465977728E6598900D5F49A /* black-on-white-icon-app-60@2x.png */, + F465977528E6598800D5F49A /* black-on-white-icon-app-60@3x.png */, + F465977428E6598800D5F49A /* black-on-white-icon-app-76.png */, + F465977628E6598900D5F49A /* black-on-white-icon-app-76@2x.png */, + F465977828E6598900D5F49A /* black-on-white-icon-app-83.5@2x.png */, + ); + path = "black-on-white"; + sourceTree = ""; + }; + F465977E28E65DC600D5F49A /* blue-on-white */ = { + isa = PBXGroup; + children = ( + F465978028E65E1700D5F49A /* blue-on-white-icon-app-60@2x.png */, + F465978328E65E1700D5F49A /* blue-on-white-icon-app-60@3x.png */, + F465978228E65E1700D5F49A /* blue-on-white-icon-app-76.png */, + F465977F28E65E1600D5F49A /* blue-on-white-icon-app-76@2x.png */, + F465978128E65E1700D5F49A /* blue-on-white-icon-app-83.5@2x.png */, + ); + path = "blue-on-white"; + sourceTree = ""; + }; + F465978928E65F7500D5F49A /* celadon-on-white */ = { + isa = PBXGroup; + children = ( + F465978A28E65F8900D5F49A /* celadon-on-white-icon-app-60@2x.png */, + F465978D28E65F8900D5F49A /* celadon-on-white-icon-app-60@3x.png */, + F465978C28E65F8900D5F49A /* celadon-on-white-icon-app-76.png */, + F465978B28E65F8900D5F49A /* celadon-on-white-icon-app-76@2x.png */, + F465978E28E65F8A00D5F49A /* celadon-on-white-icon-app-83.5@2x.png */, + ); + path = "celadon-on-white"; + sourceTree = ""; + }; + F465979428E65FB500D5F49A /* dark-green */ = { + isa = PBXGroup; + children = ( + F465979728E65FC800D5F49A /* dark-green-icon-app-60@2x.png */, + F465979528E65FC700D5F49A /* dark-green-icon-app-60@3x.png */, + F465979928E65FC800D5F49A /* dark-green-icon-app-76.png */, + F465979628E65FC700D5F49A /* dark-green-icon-app-76@2x.png */, + F465979828E65FC800D5F49A /* dark-green-icon-app-83.5@2x.png */, + ); + path = "dark-green"; + sourceTree = ""; + }; + F465979F28E65FF600D5F49A /* jetpack-light */ = { + isa = PBXGroup; + children = ( + F46597A328E6600800D5F49A /* jetpack-light-icon-app-60@2x.png */, + F46597A028E6600700D5F49A /* jetpack-light-icon-app-60@3x.png */, + F46597A228E6600700D5F49A /* jetpack-light-icon-app-76.png */, + F46597A428E6600800D5F49A /* jetpack-light-icon-app-76@2x.png */, + F46597A128E6600700D5F49A /* jetpack-light-icon-app-83.5@2x.png */, + ); + path = "jetpack-light"; + sourceTree = ""; + }; + F46597AA28E6603D00D5F49A /* neu-green */ = { + isa = PBXGroup; + children = ( + F46597AE28E6605D00D5F49A /* neu-green-icon-app-60@2x.png */, + F46597AF28E6605D00D5F49A /* neu-green-icon-app-60@3x.png */, + F46597AB28E6605C00D5F49A /* neu-green-icon-app-76.png */, + F46597AC28E6605D00D5F49A /* neu-green-icon-app-76@2x.png */, + F46597AD28E6605D00D5F49A /* neu-green-icon-app-83.5@2x.png */, + ); + path = "neu-green"; + sourceTree = ""; + }; + F46597B528E6686200D5F49A /* neumorphic-dark */ = { + isa = PBXGroup; + children = ( + F46597B728E6687700D5F49A /* neumorphic-dark-icon-app-60@2x.png */, + F46597B928E6687700D5F49A /* neumorphic-dark-icon-app-60@3x.png */, + F46597B828E6687700D5F49A /* neumorphic-dark-icon-app-76.png */, + F46597B628E6687700D5F49A /* neumorphic-dark-icon-app-76@2x.png */, + F46597BA28E6687700D5F49A /* neumorphic-dark-icon-app-83.5@2x.png */, + ); + path = "neumorphic-dark"; + sourceTree = ""; + }; + F46597C028E668A600D5F49A /* neumorphic-light */ = { + isa = PBXGroup; + children = ( + F46597C528E668B900D5F49A /* neumorphic-light-icon-app-60@2x.png */, + F46597C328E668B900D5F49A /* neumorphic-light-icon-app-60@3x.png */, + F46597C428E668B900D5F49A /* neumorphic-light-icon-app-76.png */, + F46597C228E668B800D5F49A /* neumorphic-light-icon-app-76@2x.png */, + F46597C128E668B800D5F49A /* neumorphic-light-icon-app-83.5@2x.png */, + ); + path = "neumorphic-light"; + sourceTree = ""; + }; + F46597D628E6692D00D5F49A /* pink-on-white */ = { + isa = PBXGroup; + children = ( + F46597D828E6694100D5F49A /* pink-on-white-icon-app-60@2x.png */, + F46597D728E6694100D5F49A /* pink-on-white-icon-app-60@3x.png */, + F46597DB28E6694200D5F49A /* pink-on-white-icon-app-76.png */, + F46597D928E6694100D5F49A /* pink-on-white-icon-app-76@2x.png */, + F46597DA28E6694100D5F49A /* pink-on-white-icon-app-83.5@2x.png */, + ); + path = "pink-on-white"; + sourceTree = ""; + }; + F46597E128E6697E00D5F49A /* spectrum-on-black */ = { + isa = PBXGroup; + children = ( + F46597E328E6698C00D5F49A /* spectrum-on-black-icon-app-60@2x.png */, + F46597E628E6698D00D5F49A /* spectrum-on-black-icon-app-60@3x.png */, + F46597E528E6698C00D5F49A /* spectrum-on-black-icon-app-76.png */, + F46597E228E6698C00D5F49A /* spectrum-on-black-icon-app-76@2x.png */, + F46597E428E6698C00D5F49A /* spectrum-on-black-icon-app-83.5@2x.png */, + ); + path = "spectrum-on-black"; + sourceTree = ""; + }; + F46597EC28E669C200D5F49A /* spectrum-on-white */ = { + isa = PBXGroup; + children = ( + F46597F128E669D400D5F49A /* spectrum-on-white-icon-app-60@2x.png */, + F46597EF28E669D400D5F49A /* spectrum-on-white-icon-app-60@3x.png */, + F46597ED28E669D300D5F49A /* spectrum-on-white-icon-app-76.png */, + F46597F028E669D400D5F49A /* spectrum-on-white-icon-app-76@2x.png */, + F46597EE28E669D300D5F49A /* spectrum-on-white-icon-app-83.5@2x.png */, + ); + path = "spectrum-on-white"; + sourceTree = ""; + }; + F46597F728E669F800D5F49A /* white-on-black */ = { + isa = PBXGroup; + children = ( + F46597F828E66A1000D5F49A /* white-on-black-icon-app-60@2x.png */, + F46597FC28E66A1100D5F49A /* white-on-black-icon-app-60@3x.png */, + F46597F928E66A1000D5F49A /* white-on-black-icon-app-76.png */, + F46597FA28E66A1100D5F49A /* white-on-black-icon-app-76@2x.png */, + F46597FB28E66A1100D5F49A /* white-on-black-icon-app-83.5@2x.png */, + ); + path = "white-on-black"; + sourceTree = ""; + }; + F465980228E66A4200D5F49A /* white-on-blue */ = { + isa = PBXGroup; + children = ( + F465980628E66A5A00D5F49A /* white-on-blue-icon-app-60@2x.png */, + F465980528E66A5A00D5F49A /* white-on-blue-icon-app-60@3x.png */, + F465980428E66A5A00D5F49A /* white-on-blue-icon-app-76.png */, + F465980328E66A5A00D5F49A /* white-on-blue-icon-app-76@2x.png */, + F465980728E66A5B00D5F49A /* white-on-blue-icon-app-83.5@2x.png */, + ); + path = "white-on-blue"; + sourceTree = ""; + }; + F465980D28E66A8700D5F49A /* white-on-celadon */ = { + isa = PBXGroup; + children = ( + F41E4E9E28F20AB8001880C6 /* white-on-celadon-icon-app-60@2x.png */, + F41E4E9B28F20AB7001880C6 /* white-on-celadon-icon-app-60@3x.png */, + F41E4E9D28F20AB7001880C6 /* white-on-celadon-icon-app-76.png */, + F41E4E9A28F20AB7001880C6 /* white-on-celadon-icon-app-76@2x.png */, + F41E4E9C28F20AB7001880C6 /* white-on-celadon-icon-app-83.5@2x.png */, + ); + path = "white-on-celadon"; + sourceTree = ""; + }; + F465981928E66B2C00D5F49A /* white-on-pink */ = { + isa = PBXGroup; + children = ( + F41E4E9428F20802001880C6 /* white-on-pink-icon-app-60@2x.png */, + F41E4E9328F20802001880C6 /* white-on-pink-icon-app-60@3x.png */, + F41E4E9028F20801001880C6 /* white-on-pink-icon-app-76.png */, + F41E4E9228F20801001880C6 /* white-on-pink-icon-app-76@2x.png */, + F41E4E9128F20801001880C6 /* white-on-pink-icon-app-83.5@2x.png */, + ); + path = "white-on-pink"; + sourceTree = ""; + }; + F48D44B4298992A90051EAA6 /* Blocking */ = { + isa = PBXGroup; + children = ( + F48D44B5298992C30051EAA6 /* BlockedSite.swift */, + F42A1D9629928B360059CC70 /* BlockedAuthor.swift */, + ); + path = Blocking; + sourceTree = ""; + }; + F49B9A04293A21A7000CEFCE /* Analytics */ = { + isa = PBXGroup; + children = ( + F49B9A05293A21BF000CEFCE /* MigrationAnalyticsTracker.swift */, + F49B9A07293A21F4000CEFCE /* MigrationEvent.swift */, + ); + path = Analytics; + sourceTree = ""; + }; + F4D829602930E9DD00038726 /* Delete WordPress */ = { + isa = PBXGroup; + children = ( + F4D829612930E9F300038726 /* MigrationDeleteWordPressViewController.swift */, + F4D829632930EA4C00038726 /* MigrationDeleteWordPressViewModel.swift */, + F4D829652931046F00038726 /* UIButton+Dismiss.swift */, + ); + path = "Delete WordPress"; + sourceTree = ""; + }; + F4D8296D2931091B00038726 /* Table View */ = { + isa = PBXGroup; + children = ( + 3F8B45AA292C42CC00730FA4 /* MigrationSuccessCell.swift */, + F4D829692931083000038726 /* MigrationSuccessCell+WordPress.swift */, + F4D8296B2931087100038726 /* MigrationSuccessCell+Jetpack.swift */, + ); + path = "Table View"; + sourceTree = ""; + }; + F4D8296E2931092800038726 /* Collection View */ = { + isa = PBXGroup; + children = ( + 3F8B459F29283D6C00730FA4 /* DashboardMigrationSuccessCell.swift */, + F4D8296F2931097900038726 /* DashboardMigrationSuccessCell+WordPress.swift */, + F4D82971293109A600038726 /* DashboardMigrationSuccessCell+Jetpack.swift */, + ); + path = "Collection View"; + sourceTree = ""; + }; + F4EDAA4F29A66DA900622D3D /* Reader Post */ = { + isa = PBXGroup; + children = ( + 5D3D559518F88C3500782892 /* ReaderPostService.h */, + 5D3D559618F88C3500782892 /* ReaderPostService.m */, + F4EDAA4B29A516E900622D3D /* ReaderPostService.swift */, + ); + path = "Reader Post"; + sourceTree = ""; + }; + F4F9D5E82909615D00502576 /* WordPress-to-Jetpack Migration */ = { + isa = PBXGroup; + children = ( + C31466CA293993F300D62FC7 /* Load WordPress */, + F4D829602930E9DD00038726 /* Delete WordPress */, + 3FFDEF7D29177F4200B625CE /* Common */, + F4F9D5ED29096CFC00502576 /* Welcome */, + 3F00739D2915BAA100A37FD1 /* Notifications Permission */, + 3FFDEF862918595200B625CE /* All done */, + 3F8B459E29283D3800730FA4 /* Success card */, + ); + path = "WordPress-to-Jetpack Migration"; + sourceTree = ""; + }; + F4F9D5ED29096CFC00502576 /* Welcome */ = { + isa = PBXGroup; + children = ( + F4F9D5E92909622E00502576 /* MigrationWelcomeViewController.swift */, + F4F9D5F1290993D400502576 /* MigrationWelcomeViewModel.swift */, + F4F9D5F32909B7C100502576 /* MigrationWelcomeBlogTableViewCell.swift */, + ); + path = Welcome; + sourceTree = ""; + }; + F4F9D5EE29096D0400502576 /* Views */ = { + isa = PBXGroup; + children = ( + F41BDD72290BBDCA00B7F2B0 /* MigrationActionsView.swift */, + 3FF15A55291B4EEA00E1B4E5 /* MigrationCenterView.swift */, + F4F9D5EB29096CF500502576 /* MigrationHeaderView.swift */, + 3FFDEF842918215700B625CE /* MigrationStepView.swift */, + 3FFDEF8D2918625600B625CE /* Configuration */, + ); + path = Views; + sourceTree = ""; + }; + F4FB0ACB2925878E00F651F9 /* Views */ = { + isa = PBXGroup; + children = ( + 3F29EB6C24042038005313DE /* Header */, + ); + path = Views; + sourceTree = ""; + }; + F504D2A925D60C5900A2764C /* Stories */ = { + isa = PBXGroup; + children = ( + F5A34A9825DEF47D00C9654B /* WPMediaPicker+MediaPicker.swift */, + F5AE440525DD0345003675F4 /* CameraHandler.swift */, + F5AE43E325DD02C0003675F4 /* StoryEditor.swift */, + F504D2AA25D60C5900A2764C /* StoryPoster.swift */, + F504D2AB25D60C5900A2764C /* StoryMediaLoader.swift */, + ); + path = Stories; + sourceTree = ""; + }; + F53FF3A623EA722F001AD596 /* Detail Header */ = { + isa = PBXGroup; + children = ( + F53FF3A723EA723D001AD596 /* ActionRow.swift */, + F1112AB1255C2D4600F1F746 /* BlogDetailHeaderView.swift */, + F53FF3A923EA725C001AD596 /* SiteIconView.swift */, + ); + path = "Detail Header"; + sourceTree = ""; + }; + F551E7F323F6EA1400751212 /* Floating Create Button */ = { + isa = PBXGroup; + children = ( + F551E7F423F6EA3100751212 /* FloatingActionButton.swift */, + F5E032DA24088F44003AF350 /* UIView+SpringAnimations.swift */, + F5E032D5240889EB003AF350 /* CreateButtonCoordinator.swift */, + F5D3992F2541F25B0058D0AB /* SheetActions.swift */, + F532AE1B253E55D40013B42E /* CreateButtonActionSheet.swift */, + F5B9D7EF245BA938002BB2C7 /* FancyAlertViewController+CreateButtonAnnouncement.swift */, + ); + path = "Floating Create Button"; + sourceTree = ""; + }; + F57402A5235FF71F00374346 /* Scheduling */ = { + isa = PBXGroup; + children = ( + F511F8A32356A4F400895E73 /* PublishSettingsViewController.swift */, + F5660D06235D114500020B1E /* CalendarCollectionView.swift */, + F59AAC0F235E430E00385EE6 /* ChosenValueRow.swift */, + F5660D08235D1CDD00020B1E /* CalendarMonthView.swift */, + 03216EC5279946CA00D444CA /* SchedulingDatePickerViewController.swift */, + 03216ECB27995F3500D444CA /* SchedulingViewControllerPresenter.swift */, + F5844B6A235EAF3D007C6557 /* PartScreenPresentationController.swift */, + F59AAC15235EA46D00385EE6 /* LightNavigationController.swift */, + F57402A6235FF9C300374346 /* SchedulingDate+Helpers.swift */, + ); + path = Scheduling; + sourceTree = ""; + }; + F597768C243E1E140062BAD1 /* Filter */ = { + isa = PBXGroup; + children = ( + F5E63128243BC8190088229D /* FilterSheetView.swift */, + F5E6312A243BC83E0088229D /* FilterSheetViewController.swift */, + F5E29035243E4F5F00C19CA5 /* FilterProvider.swift */, + F5E29037243FAB0300C19CA5 /* FilterTableData.swift */, + F52CACCB24512EA700661380 /* EmptyActionView.swift */, + ); + path = Filter; + sourceTree = ""; + }; + F5A34BC825DF244F00C9654B /* Kanvas */ = { + isa = PBXGroup; + children = ( + F5A34BC925DF244F00C9654B /* KanvasCameraAnalyticsHandler.swift */, + F5A34BCA25DF244F00C9654B /* KanvasCameraCustomUI.swift */, + ); + path = Kanvas; + sourceTree = ""; + }; + F5A34D0525DF2F7700C9654B /* Fonts */ = { + isa = PBXGroup; + children = ( + F5A34D0625DF2F7700C9654B /* oswald_upper.ttf */, + F5A34D0725DF2F7700C9654B /* Nunito-Bold.ttf */, + F5A34D0825DF2F7700C9654B /* SpaceMono-Bold.ttf */, + F5A34D0925DF2F7700C9654B /* LibreBaskerville-Regular.ttf */, + F5A34D0A25DF2F7700C9654B /* Shrikhand-Regular.ttf */, + F5A34D0B25DF2F7700C9654B /* Pacifico-Regular.ttf */, + F5A34D0C25DF2F7700C9654B /* Noticons.ttf */, + C31B6D7728BFE8B100E64FEB /* EBGaramond-Regular.ttf */, + C31B6D7928BFEFD500E64FEB /* OFL - EBGaramond-Regular.txt */, + ); + path = Fonts; + sourceTree = ""; + }; + F5A738C1244DF92300EDE065 /* Manage */ = { + isa = PBXGroup; + children = ( + F5B9152024465FB400179876 /* ReaderTagsTableViewController.swift */, + F5A738BE244DF7E400EDE065 /* ReaderTagsTableViewController+Cells.swift */, + F5A738BC244DF75400EDE065 /* OffsetTableViewHandler.swift */, + F5B9151E244653C100179876 /* TabbedViewController.swift */, + F5A738C2244E7A6F00EDE065 /* ReaderTagsTableViewModel.swift */, + F52CACC9244FA7AA00661380 /* ReaderManageScenePresenter.swift */, + FA7F92B725E61C7E00502D2A /* ReaderTagsFooter.swift */, + FA7F92C925E61C9300502D2A /* ReaderTagsFooter.xib */, + ); + path = Manage; + sourceTree = ""; + }; + F5D0A64C23CC157100B20D27 /* Preview */ = { + isa = PBXGroup; + children = ( + F580C3C023D22E2D0038E243 /* PreviewDeviceLabel.swift */, + F5D0A64D23CC159400B20D27 /* PreviewWebKitViewController.swift */, + F5D0A64F23CC15A800B20D27 /* PreviewNonceHandler.swift */, + F5B8A60E23CE56A1001B7359 /* PreviewDeviceSelectionViewController.swift */, + ); + path = Preview; + sourceTree = ""; + }; + F5E032E22408D537003AF350 /* Action Sheet */ = { + isa = PBXGroup; + children = ( + F5E032E32408D537003AF350 /* ActionSheetViewController.swift */, + F5E032E52408D537003AF350 /* BottomSheetPresentationController.swift */, + 836498CD281735CC00A2C170 /* BloggingPromptsHeaderView.swift */, + 836498CA2817301800A2C170 /* BloggingPromptsHeaderView.xib */, + ); + path = "Action Sheet"; + sourceTree = ""; + }; + F5E156B425DDD53A00EEEDFB /* Recovered References */ = { + isa = PBXGroup; + children = ( + F44F6ABD2937428B00DC94A2 /* MigrationEmailService.swift */, + ); + name = "Recovered References"; + sourceTree = ""; + }; + F93735F422D53C1800A3C312 /* Logging */ = { + isa = PBXGroup; + children = ( + F93735F722D53C3B00A3C312 /* LoggingURLRedactorTests.swift */, + ); + path = Logging; + sourceTree = ""; + }; + FA1A563825A708BF0033967D /* Restore Options */ = { + isa = PBXGroup; + children = ( + FAD947A225B56A2C00F011B5 /* Coordinators */, + FAD954D525B7AD6800F011B5 /* Views */, + FAB8F74F25AD72CE00D5D54A /* BaseRestoreOptionsViewController.swift */, + FA4F65A62594337300EAA9F5 /* JetpackRestoreOptionsViewController.swift */, + FAB8F76D25AD73C000D5D54A /* JetpackBackupOptionsViewController.swift */, + ); + path = "Restore Options"; + sourceTree = ""; + }; + FA1A564725A708C90033967D /* Restore Warning */ = { + isa = PBXGroup; + children = ( + FAD947A125B56A1E00F011B5 /* Coordinators */, + FAD954D625B7AD7600F011B5 /* Views */, + FAF13C5225A57ABD003EE470 /* JetpackRestoreWarningViewController.swift */, + ); + path = "Restore Warning"; + sourceTree = ""; + }; + FA1A564825A708D30033967D /* Restore Status */ = { + isa = PBXGroup; + children = ( + FAB8AB5D25AFFCE900F9F8A0 /* Coordinators */, + FAD954E525B7AD8300F011B5 /* Views */, + FAB8F78B25AD785400D5D54A /* BaseRestoreStatusViewController.swift */, + FAF13E2F25A59240003EE470 /* JetpackRestoreStatusViewController.swift */, + FAB8F7A925AD792500D5D54A /* JetpackBackupStatusViewController.swift */, + ); + path = "Restore Status"; + sourceTree = ""; + }; + FA1CEAE525CA9C56005E7038 /* Views */ = { + isa = PBXGroup; + children = ( + FA1CEAC125CA9C2A005E7038 /* RestoreStatusFailedView.swift */, + FA1CEAD325CA9C40005E7038 /* RestoreStatusFailedView.xib */, + ); + path = Views; + sourceTree = ""; + }; + FA25FB242609B98E0005E08F /* App Configuration */ = { + isa = PBXGroup; + children = ( + C7F7BE0626262B9900CE547F /* AuthenticationHandler.swift */, + FA25F9FD2609AA830005E08F /* AppConfiguration.swift */, + FAD2538E26116A1600EDAF88 /* AppStyleGuide.swift */, + FAADE42726159B1300BF29FE /* AppConstants.swift */, + C7F7BDCF26262A4C00CE547F /* AppDependency.swift */, + 0107E16328FFED1800DE87DB /* WidgetConfiguration.swift */, + 800035C0292307E8007D2D26 /* ExtensionConfiguration.swift */, + ); + path = "App Configuration"; + sourceTree = ""; + }; + FA25FB8C2609B9CB0005E08F /* App Configuration */ = { + isa = PBXGroup; + children = ( + FA25FA332609AAAA0005E08F /* AppConfiguration.swift */, + FAD2544126116CEA00EDAF88 /* AppStyleGuide.swift */, + FAADE3F02615996E00BF29FE /* AppConstants.swift */, + C7F7BDBC26262A1B00CE547F /* AppDependency.swift */, + 0107E15C28FFE99300DE87DB /* WidgetConfiguration.swift */, + 01CE5010290A890300A9C2E0 /* TracksConfiguration.swift */, + 800035C229230A0B007D2D26 /* ExtensionConfiguration.swift */, + ); + name = "App Configuration"; + sourceTree = ""; + }; + FA332AD229C1F98000182FBB /* Moved To Jetpack */ = { + isa = PBXGroup; + children = ( + FA332ACF29C1F97A00182FBB /* MovedToJetpackViewController.swift */, + FA332AD329C1FC7A00182FBB /* MovedToJetpackViewModel.swift */, + FA6402D029C325C1007A235C /* MovedToJetpackEventsTracker.swift */, + ); + path = "Moved To Jetpack"; + sourceTree = ""; + }; + FA4F65B52594589C00EAA9F5 /* Jetpack Restore */ = { + isa = PBXGroup; + children = ( + FA1A563825A708BF0033967D /* Restore Options */, + FA1A564725A708C90033967D /* Restore Warning */, + FA1A564825A708D30033967D /* Restore Status */, + FA681F7725CA943600DAA544 /* Restore Status Failed */, + FAB8007625AEDDCE00D5D54A /* Restore Complete */, + ); + path = "Jetpack Restore"; + sourceTree = ""; + }; + FA5C74091C596E69000B528C /* Site Management */ = { + isa = PBXGroup; + children = ( + E66E2A631FE4311200788F22 /* SettingsTitleSubtitleController.swift */, + E66E2A641FE4311200788F22 /* SiteTagsViewController.swift */, + FAFF153C1C98962E007D1C90 /* SiteSettingsViewController+SiteManagement.swift */, + FAE420191C5AEFE100C1D036 /* StartOverViewController.swift */, + 742C79971E5F511C00DB1608 /* DeleteSiteViewController.swift */, + 746A6F561E71C691003B67E3 /* DeleteSite.storyboard */, + FA5C740E1C599BA7000B528C /* TableViewHeaderDetailView.swift */, + 93CDC72026CD342900C8A3A8 /* DestructiveAlertHelper.swift */, + ); + path = "Site Management"; + sourceTree = ""; + }; + FA681F7725CA943600DAA544 /* Restore Status Failed */ = { + isa = PBXGroup; + children = ( + FA1CEAE525CA9C56005E7038 /* Views */, + FA681F8825CA946B00DAA544 /* BaseRestoreStatusFailedViewController.swift */, + FAD954B725B7A99900F011B5 /* JetpackBackupStatusFailedViewController.swift */, + FAD95D2625B91BCF00F011B5 /* JetpackRestoreStatusFailedViewController.swift */, + ); + path = "Restore Status Failed"; + sourceTree = ""; + }; + FA70024E29DC3B6100E874FD /* Activity Log */ = { + isa = PBXGroup; + children = ( + FA70024B29DC3B5500E874FD /* DashboardActivityLogCardCell.swift */, + FAD7626329F1480E00C09583 /* DashboardActivityLogCardCell+ActivityPresenter.swift */, + ); + path = "Activity Log"; + sourceTree = ""; + }; + FA73D7D7278D9E6300DF24B3 /* Blog Dashboard */ = { + isa = PBXGroup; + children = ( + 8B4DDF23278F3AED0022494D /* Cards */, + 8B6214E127B1B2D6001DF7B6 /* Service */, + 8BEE845B27B1DD8E0001A93C /* ViewModel */, + 8BC81D6327CFC0C60057F790 /* Helpers */, + FA73D7D5278D9E5D00DF24B3 /* BlogDashboardViewController.swift */, + 8BF9E03227B1A8A800915B27 /* DashboardCard.swift */, + 80D9CFF929E5E6FE00FE3400 /* DashboardCardTableView.swift */, + ); + path = "Blog Dashboard"; + sourceTree = ""; + }; + FA73D7E72798766300DF24B3 /* Site Picker */ = { + isa = PBXGroup; + children = ( + FA73D7E42798765B00DF24B3 /* SitePickerViewController.swift */, + FA73D7E827987BA500DF24B3 /* SitePickerViewController+SiteIcon.swift */, + FA73D7EB27987E4500DF24B3 /* SitePickerViewController+QuickStart.swift */, + 17C1D67B2670E3DC006C8970 /* SiteIconPickerView.swift */, + 17C1D7DB26735631006C8970 /* EmojiRenderer.swift */, + ); + path = "Site Picker"; + sourceTree = ""; + }; + FA8E2FE327C6377D00DA0982 /* Quick Start */ = { + isa = PBXGroup; + children = ( + FA8E2FDF27C6377000DA0982 /* DashboardQuickStartCardCell.swift */, + FAFC064A27D22E4C002F0483 /* QuickStartTourStateView.swift */, + FA8E2FE427C6AE4500DA0982 /* QuickStartChecklistView.swift */, + FA98A24C2832A5E9003B9233 /* NewQuickStartChecklistView.swift */, + FA98A24F2833F1DC003B9233 /* QuickStartChecklistConfigurable.swift */, + ); + path = "Quick Start"; + sourceTree = ""; + }; + FA98B61429A3B71E0071AAE8 /* Blaze */ = { + isa = PBXGroup; + children = ( + FA98B61529A3B76A0071AAE8 /* DashboardBlazeCardCell.swift */, + FA98B61829A3BF050071AAE8 /* BlazeCardView.swift */, + ); + path = Blaze; + sourceTree = ""; + }; + FAA4012F27B405DF009E1137 /* Quick Actions */ = { + isa = PBXGroup; + children = ( + FAA4012C27B405DB009E1137 /* DashboardQuickActionsCardCell.swift */, + FAA4013327B52455009E1137 /* QuickActionButton.swift */, + ); + path = "Quick Actions"; + sourceTree = ""; + }; + FAB8007625AEDDCE00D5D54A /* Restore Complete */ = { + isa = PBXGroup; + children = ( + FAD954F425B7AD9200F011B5 /* Views */, + FAB8AA2125AF031200F9F8A0 /* BaseRestoreCompleteViewController.swift */, + FA3536F425B01A2C0005A3A0 /* JetpackRestoreCompleteViewController.swift */, + FAB8004825AEDC2300D5D54A /* JetpackBackupCompleteViewController.swift */, + FAD951A325B6CB3600F011B5 /* JetpackRestoreFailedViewController.swift */, + ); + path = "Restore Complete"; + sourceTree = ""; + }; + FAB8AB5D25AFFCE900F9F8A0 /* Coordinators */ = { + isa = PBXGroup; + children = ( + FAB8AB5E25AFFD0600F9F8A0 /* JetpackRestoreStatusCoordinator.swift */, + FAB8FD4F25AEB0F500D5D54A /* JetpackBackupStatusCoordinator.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; + FABB1F8E2602FC0100C8785C /* Jetpack */ = { + isa = PBXGroup; + children = ( + C7F7AC71261CF1BD00CE547F /* Classes */, + 24B54FB02624F8690041B18E /* JetpackDebug.entitlements */, + 24B54FAF2624F84C0041B18E /* JetpackRelease.entitlements */, + 24B54FAE2624F8430041B18E /* JetpackRelease-Alpha.entitlements */, + 24B54FAD2624F8350041B18E /* JetpackRelease-Internal.entitlements */, + FABB28482603068B00C8785C /* Resources */, + FABB268A2602FCD400C8785C /* Supporting Files */, + FA25FB8C2609B9CB0005E08F /* App Configuration */, + FAD257262611B05D00EDAF88 /* Extensions */, + ); + path = Jetpack; + sourceTree = ""; + }; + FABB268A2602FCD400C8785C /* Supporting Files */ = { + isa = PBXGroup; + children = ( + FABB26872602FCCA00C8785C /* Info.plist */, + ); + name = "Supporting Files"; + sourceTree = ""; + }; + FABB28482603068B00C8785C /* Resources */ = { + isa = PBXGroup; + children = ( + F465976528E464DE00D5F49A /* Icons */, + FABB286B2603086900C8785C /* AppImages.xcassets */, + FABB28462603067C00C8785C /* Launch Screen.storyboard */, + ); + name = Resources; + sourceTree = ""; + }; + FAC1B82329B1F0E800E0C542 /* Overlay */ = { + isa = PBXGroup; + children = ( + FA4B203A29AE62C00089FE68 /* BlazeOverlayViewController.swift */, + FAC1B81D29B0C2AC00E0C542 /* BlazeOverlayViewModel.swift */, + FAC1B82629B1F1EE00E0C542 /* BlazePostPreviewView.swift */, + ); + path = Overlay; + sourceTree = ""; + }; + FAC1B82429B1F13400E0C542 /* Webview */ = { + isa = PBXGroup; + children = ( + FA4B202E29A619130089FE68 /* BlazeFlowCoordinator.swift */, + 80C523A329959DE000B1C14B /* BlazeWebViewController.swift */, + 80C523A62995D73C00B1C14B /* BlazeWebViewModel.swift */, + ); + path = Webview; + sourceTree = ""; + }; + FAC1B82529B1F14D00E0C542 /* Helpers */ = { + isa = PBXGroup; + children = ( + FA98B61B29A3DB840071AAE8 /* BlazeHelper.swift */, + FA4B203429A786460089FE68 /* BlazeEventsTracker.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + FAD257252611B05300EDAF88 /* Colors and Styles */ = { + isa = PBXGroup; + children = ( + FAD257112611B04D00EDAF88 /* UIColor+JetpackColors.swift */, + ); + name = "Colors and Styles"; + sourceTree = ""; + }; + FAD257262611B05D00EDAF88 /* Extensions */ = { + isa = PBXGroup; + children = ( + FAD257252611B05300EDAF88 /* Colors and Styles */, + ); + name = Extensions; + sourceTree = ""; + }; + FAD947A125B56A1E00F011B5 /* Coordinators */ = { + isa = PBXGroup; + children = ( + FAD9458D25B5678700F011B5 /* JetpackRestoreWarningCoordinator.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; + FAD947A225B56A2C00F011B5 /* Coordinators */ = { + isa = PBXGroup; + children = ( + FAD9457D25B5647B00F011B5 /* JetpackBackupOptionsCoordinator.swift */, + ); + path = Coordinators; + sourceTree = ""; + }; + FAD954D525B7AD6800F011B5 /* Views */ = { + isa = PBXGroup; + children = ( + FA4F660425946B5F00EAA9F5 /* JetpackRestoreHeaderView.swift */, + FA4F661325946B8500EAA9F5 /* JetpackRestoreHeaderView.xib */, + ); + path = Views; + sourceTree = ""; + }; + FAD954D625B7AD7600F011B5 /* Views */ = { + isa = PBXGroup; + children = ( + FA1A543D25A6E2F60033967D /* RestoreWarningView.swift */, + FA1A543F25A6E3080033967D /* RestoreWarningView.xib */, + ); + path = Views; + sourceTree = ""; + }; + FAD954E525B7AD8300F011B5 /* Views */ = { + isa = PBXGroup; + children = ( + FA1A55EE25A6F0740033967D /* RestoreStatusView.swift */, + FA1A55FE25A6F07F0033967D /* RestoreStatusView.xib */, + ); + path = Views; + sourceTree = ""; + }; + FAD954F425B7AD9200F011B5 /* Views */ = { + isa = PBXGroup; + children = ( + FAB800B125AEE3C600D5D54A /* RestoreCompleteView.swift */, + FAB800C125AEE3D200D5D54A /* RestoreCompleteView.xib */, + ); + path = Views; + sourceTree = ""; + }; + FAF64BC82637DF0600E8A1DF /* JetpackScreenshotGeneration */ = { + isa = PBXGroup; + children = ( + FAF64BDC2637DF7500E8A1DF /* Info.plist */, + FAF64E4E2637E85800E8A1DF /* JetpackScreenshotGeneration.swift */, + ); + path = JetpackScreenshotGeneration; + sourceTree = ""; + }; + FE06AC8126C3BCD200B69DE4 /* Sharing */ = { + isa = PBXGroup; + children = ( + FE06AC8226C3BD0900B69DE4 /* ShareAppContentPresenter.swift */, + FE06AC8426C3C2F800B69DE4 /* ShareAppTextActivityItemSource.swift */, + FE3D058126C40E66002A51B0 /* ShareAppContentPresenter+TableView.swift */, + ); + path = Sharing; + sourceTree = ""; + }; + FE18495627F5ACA400D26879 /* Prompts */ = { + isa = PBXGroup; + children = ( + FEAC916D28001FC4005026E7 /* AvatarTrainView.swift */, + 83B1D036282C62620061D911 /* BloggingPromptsAttribution.swift */, + FE18495727F5ACBA00D26879 /* DashboardPromptsCardCell.swift */, + ); + path = Prompts; + sourceTree = ""; + }; + FE32E7EF284496F500744D80 /* Blogging Prompts */ = { + isa = PBXGroup; + children = ( + FECA44312836647100D01F15 /* PromptRemindersSchedulerTests.swift */, + FE32E7F02844971000744D80 /* ReminderScheduleCoordinatorTests.swift */, + ); + name = "Blogging Prompts"; + sourceTree = ""; + }; + FE32F004275F60360040BE67 /* ContentRenderer */ = { + isa = PBXGroup; + children = ( + FE32F001275F602E0040BE67 /* CommentContentRenderer.swift */, + FE32F005275F62620040BE67 /* WebCommentContentRenderer.swift */, + FE341704275FA157005D5CA7 /* RichCommentContentRenderer.swift */, + ); + path = ContentRenderer; + sourceTree = ""; + }; + FE3D057C26C3D5A1002A51B0 /* Sharing */ = { + isa = PBXGroup; + children = ( + FE3D057D26C3D5C1002A51B0 /* ShareAppContentPresenterTests.swift */, + FEFA263D26C58427009CCB7E /* ShareAppTextActivityItemSourceTests.swift */, + ); + name = Sharing; + sourceTree = ""; + }; + FE4DC5A1293A75DA008F322F /* Migration */ = { + isa = PBXGroup; + children = ( + FE4DC5A2293A75FC008F322F /* MigrationDeepLinkRouter.swift */, + FE4DC5A6293A79F1008F322F /* WordPressExportRoute.swift */, + FE29EFCC29A91160007CE034 /* WPAdminConvertibleRouter.swift */, + ); + path = Migration; + sourceTree = ""; + }; + FE6BB14129322798001E5F7A /* Migration */ = { + isa = PBXGroup; + children = ( + FE6BB142293227AC001E5F7A /* ContentMigrationCoordinator.swift */, + ); + path = Migration; + sourceTree = ""; + }; + FE7FAAB9299A35D40032A6F2 /* Install */ = { + isa = PBXGroup; + children = ( + FE7FAABA299A36570032A6F2 /* WPComJetpackRemoteInstallViewModelTests.swift */, + ); + name = Install; + sourceTree = ""; + }; + FEA087FB2696DDE900193358 /* List */ = { + isa = PBXGroup; + children = ( + FEA088002696E7F600193358 /* ListTableHeaderView.swift */, + FEA088022696E81F00193358 /* ListTableHeaderView.xib */, + FE39C134269C37C900EFB827 /* ListTableViewCell.swift */, + FE39C133269C37C900EFB827 /* ListTableViewCell.xib */, + FE3E83E326A58646008CE851 /* ListSimpleOverlayView.swift */, + FE3E83E426A58646008CE851 /* ListSimpleOverlayView.xib */, + FEA088042696F7AA00193358 /* WPStyleGuide+List.swift */, + ); + path = List; + sourceTree = ""; + }; + FEA7948B26DD134400CEC520 /* Detail */ = { + isa = PBXGroup; + children = ( + FE32F004275F60360040BE67 /* ContentRenderer */, + FE43DAAD26DFAD1C00CFF595 /* CommentContentTableViewCell.swift */, + FE43DAAE26DFAD1C00CFF595 /* CommentContentTableViewCell.xib */, + FEA7948C26DD136700CEC520 /* CommentHeaderTableViewCell.swift */, + 9839CEB926FAA0510097406E /* CommentModerationBar.swift */, + 98F9FB2D270282C100ADF552 /* CommentModerationBar.xib */, + ); + name = Detail; + sourceTree = ""; + }; + FEC2602D283FB9D4003D886A /* Blogging Prompts */ = { + isa = PBXGroup; + children = ( + FECA442E28350B7800D01F15 /* PromptRemindersScheduler.swift */, + FEF4DC5428439357003806BE /* ReminderScheduleCoordinator.swift */, + ); + path = "Blogging Prompts"; + sourceTree = ""; + }; + FF2716901CAAC87B0006E2D4 /* UITests */ = { + isa = PBXGroup; + children = ( + BEA101B61FF13F0500CE5C7D /* Tests */, + BED4D8311FF11E2B00A11345 /* Flows */, + BE2B4EA01FD6653E007AE3E4 /* Utils */, + BE2B4E991FD6640E007AE3E4 /* Screens */, + CC8A5EAA22159FA6001B7874 /* WPUITestCredentials.swift */, + CCA44DF422C4C4D6006E294F /* README.md */, + CCCF53BD237B13EA0035E9CA /* WordPressUITests.xctestplan */, + EA14534629AEF479001F3143 /* JetpackUITests.xctestplan */, + 80D65C1129CC0813008E69D5 /* JetpackUITests-Info.plist */, + FF2716931CAAC87B0006E2D4 /* WordPressUITests-Info.plist */, + ); + path = UITests; + sourceTree = ""; + }; + FF2EC3BE2209A105006176E1 /* Processors */ = { + isa = PBXGroup; + children = ( + FF2EC3BF2209A144006176E1 /* GutenbergImgUploadProcessor.swift */, + 1E0462152566938300EB98EF /* GutenbergFileUploadProcessor.swift */, + 1E672D94257663CE00421F13 /* GutenbergAudioUploadProcessor.swift */, + FF1B11E4238FDFE70038B93E /* GutenbergGalleryUploadProcessor.swift */, + 91138454228373EB00FB02B7 /* GutenbergVideoUploadProcessor.swift */, + 4629E4202440C5B20002E15C /* GutenbergCoverUploadProcessor.swift */, + F5E1577E25DE04E200EEEDFB /* GutenbergMediaFilesUploadProcessor.swift */, + 1D19C56229C9D9A700FB0087 /* GutenbergVideoPressUploadProcessor.swift */, + 46638DF5244904A3006E8439 /* GutenbergBlockProcessor.swift */, + ); + path = Processors; + sourceTree = ""; + }; + FF7691661EE06CF500713F4B /* Aztec */ = { + isa = PBXGroup; + children = ( + FF8032651EE9E22200861F28 /* MediaProgressCoordinatorTests.swift */, + FF1FD02520912AA900186384 /* URL+LinkNormalizationTests.swift */, + 7320C8BC2190C9FC0082FED5 /* UITextView+SummaryTests.swift */, + 5789E5C722D7D40800333698 /* AztecPostViewControllerAttachmentTests.swift */, + B0A6DEBE2626335F00B5B8EF /* AztecPostViewController+MenuTests.swift */, + ); + name = Aztec; + sourceTree = ""; + }; + FF7C89A11E3A1029000472A8 /* MediaPicker */ = { + isa = PBXGroup; + children = ( + C81CCD68243AEDEC00A83E27 /* Tenor */, + FF7C89A21E3A1029000472A8 /* MediaLibraryPickerDataSourceTests.swift */, + ); + path = MediaPicker; + sourceTree = ""; + }; + FF9839A71CD3960600E85258 /* WordPressAPI */ = { + isa = PBXGroup; + children = ( + 74B335EB1F06F9520053A184 /* MockWordPressComRestApi.swift */, + ); + name = WordPressAPI; + sourceTree = ""; + }; + FF9A6E6F21F9359200D36D14 /* Gutenberg */ = { + isa = PBXGroup; + children = ( + FF9A6E7021F9361700D36D14 /* MediaUploadHashTests.swift */, + FF2EC3C12209AC19006176E1 /* GutenbergImgUploadProcessorTests.swift */, + FF1B11E6238FE27A0038B93E /* GutenbergGalleryUploadProcessorTests.swift */, + 9123471A221449E200BD9F97 /* GutenbergInformativeDialogTests.swift */, + 1D19C56529C9DB0A00FB0087 /* GutenbergVideoPressUploadProcessorTests.swift */, + FF0B2566237A023C004E255F /* GutenbergVideoUploadProcessorTests.swift */, + 4629E4222440C8160002E15C /* GutenbergCoverUploadProcessorTests.swift */, + AEE082892681C23C00DCF54B /* GutenbergRefactoredGalleryUploadProcessorTests.swift */, + ); + name = Gutenberg; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 3FA640522670CCD40064401E /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + 3FA6405B2670CCD40064401E /* UITestsFoundation.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 0107E0B128F97D5000DE87DB /* JetpackStatsWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0107E0E528F97D5000DE87DB /* Build configuration list for PBXNativeTarget "JetpackStatsWidgets" */; + buildPhases = ( + 0107E0B228F97D5000DE87DB /* [CP] Check Pods Manifest.lock */, + 0107E0B328F97D5000DE87DB /* Sources */, + 0107E0DD28F97D5000DE87DB /* Frameworks */, + 0107E0E128F97D5000DE87DB /* Resources */, + 0107E0E428F97D5000DE87DB /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = JetpackStatsWidgets; + productName = WordPressHomeWidgetTodayExtension; + productReference = 0107E0EA28F97D5000DE87DB /* JetpackStatsWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 0107E13828FE9DB200DE87DB /* JetpackIntents */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0107E14F28FE9DB200DE87DB /* Build configuration list for PBXNativeTarget "JetpackIntents" */; + buildPhases = ( + 0107E13928FE9DB200DE87DB /* [CP] Check Pods Manifest.lock */, + 0107E13A28FE9DB200DE87DB /* Sources */, + 0107E14A28FE9DB200DE87DB /* Frameworks */, + 0107E14C28FE9DB200DE87DB /* Resources */, + 0107E14E28FE9DB200DE87DB /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = JetpackIntents; + productName = WordPressIntents; + productReference = 0107E15428FE9DB200DE87DB /* JetpackIntents.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 1D6058900D05DD3D006BFB54 /* WordPress */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1D6058960D05DD3E006BFB54 /* Build configuration list for PBXNativeTarget "WordPress" */; + buildPhases = ( + E00F6488DE2D86BDC84FBB0B /* [CP] Check Pods Manifest.lock */, + 09607CE7281C9CA6002D2E5A /* [Lint] Check AppLocalizedString usage */, + 825F0EBF1F7EBF7C00321528 /* App Icons: Add Version For Internal Releases */, + 1D60588D0D05DD3D006BFB54 /* Resources */, + F9C5CF0222CD5DB0007CEF56 /* Copy Alternate Internal Icons (if needed) */, + 1D60588E0D05DD3D006BFB54 /* Sources */, + 1D60588F0D05DD3D006BFB54 /* Frameworks */, + 93E5284E19A7741A003A1A9C /* Embed Foundation Extensions */, + 37399571B0D91BBEAE911024 /* [CP] Embed Pods Frameworks */, + 920B9A6DAD47189622A86A9C /* [CP] Copy Pods Resources */, + E1C5456F219F10E000896227 /* Copy Gutenberg JS */, + ); + buildRules = ( + ); + dependencies = ( + 3FCFFAFE2994A949002840C9 /* PBXTargetDependency */, + 096A92FD26E2A0AE00448C68 /* PBXTargetDependency */, + 932225B01C7CE50300443B02 /* PBXTargetDependency */, + 7457667B202B558C00F42E40 /* PBXTargetDependency */, + 7358E6BE210BD318002323EB /* PBXTargetDependency */, + 3F526C5B2538CF2B0069706C /* PBXTargetDependency */, + F1F163D525658B4D003DC13B /* PBXTargetDependency */, + ); + name = WordPress; + packageProductDependencies = ( + 24CE2EB0258D687A0000C297 /* WordPressFlux */, + 17A8858C2757B97F0071FCA3 /* AutomatticAbout */, + 3F2B62DB284F4E0B0008CD59 /* Charts */, + 3F411B6E28987E3F002513AE /* Lottie */, + ); + productName = WordPress; + productReference = 1D6058910D05DD3D006BFB54 /* WordPress.app */; + productType = "com.apple.product-type.application"; + }; + 3F526C4B2538CF2A0069706C /* WordPressStatsWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3F526C612538CF2C0069706C /* Build configuration list for PBXNativeTarget "WordPressStatsWidgets" */; + buildPhases = ( + 0AA1A8899C01FEF3599F6FCF /* [CP] Check Pods Manifest.lock */, + 3F526C482538CF2A0069706C /* Sources */, + 3F526C492538CF2A0069706C /* Frameworks */, + 3F526C4A2538CF2A0069706C /* Resources */, + 196E4B8866CD8E865DB892D9 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WordPressStatsWidgets; + productName = WordPressHomeWidgetTodayExtension; + productReference = 3F526C4C2538CF2A0069706C /* WordPressStatsWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 3FA640562670CCD40064401E /* UITestsFoundation */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3FA640602670CCD40064401E /* Build configuration list for PBXNativeTarget "UITestsFoundation" */; + buildPhases = ( + 3FA640522670CCD40064401E /* Headers */, + 3FA640532670CCD40064401E /* Sources */, + 3FA640542670CCD40064401E /* Frameworks */, + 3FA640552670CCD40064401E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = UITestsFoundation; + packageProductDependencies = ( + 3FC2C33C26C4CF0A00C6D98F /* XCUITestHelpers */, + 3FC2C34226C4E8B700C6D98F /* ScreenObject */, + 3F338B72289BD5970014ADC5 /* Nimble */, + ); + productName = UITestsFoundation; + productReference = 3FA640572670CCD40064401E /* UITestsFoundation.framework */; + productType = "com.apple.product-type.framework"; + }; + 733F36022126197800988727 /* WordPressNotificationContentExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 733F36152126197800988727 /* Build configuration list for PBXNativeTarget "WordPressNotificationContentExtension" */; + buildPhases = ( + 733F35FF2126197800988727 /* Sources */, + 733F36002126197800988727 /* Frameworks */, + 733F36012126197800988727 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WordPressNotificationContentExtension; + productName = WordPressNotificationContentExtension; + productReference = 733F36032126197800988727 /* WordPressNotificationContentExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 7358E6B7210BD318002323EB /* WordPressNotificationServiceExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7358E6C4210BD318002323EB /* Build configuration list for PBXNativeTarget "WordPressNotificationServiceExtension" */; + buildPhases = ( + BAE780768320204E29A6FE5B /* [CP] Check Pods Manifest.lock */, + 7358E6B4210BD318002323EB /* Sources */, + 7358E6B5210BD318002323EB /* Frameworks */, + 7358E6B6210BD318002323EB /* Resources */, + 552DBF4AE076B0EE1EC2D94D /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WordPressNotificationServiceExtension; + productName = WordPressNotificationServiceExtension; + productReference = 7358E6B8210BD318002323EB /* WordPressNotificationServiceExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 74576671202B558C00F42E40 /* WordPressDraftActionExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 74576681202B558C00F42E40 /* Build configuration list for PBXNativeTarget "WordPressDraftActionExtension" */; + buildPhases = ( + 74CC431A202B5C73000DAE1A /* [CP] Check Pods Manifest.lock */, + 7457666E202B558C00F42E40 /* Sources */, + 7457666F202B558C00F42E40 /* Frameworks */, + 74576670202B558C00F42E40 /* Resources */, + 9D186898B0632AA1273C9DE2 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WordPressDraftActionExtension; + productName = WordPressDraftActionExtension; + productReference = 74576672202B558C00F42E40 /* WordPressDraftActionExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 809620CF28E540D700940A5D /* JetpackShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8096211E28E540D700940A5D /* Build configuration list for PBXNativeTarget "JetpackShareExtension" */; + buildPhases = ( + 72BB9EE3CC91D92F3735F4F3 /* [CP] Check Pods Manifest.lock */, + 809620D128E540D700940A5D /* Sources */, + 8096211428E540D700940A5D /* Frameworks */, + 8096211628E540D700940A5D /* Resources */, + 4C304224F0F810A17D96A402 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = JetpackShareExtension; + productName = WordPressShare; + productReference = 8096212328E540D700940A5D /* JetpackShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 8096213028E55C9400940A5D /* JetpackDraftActionExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8096218028E55C9400940A5D /* Build configuration list for PBXNativeTarget "JetpackDraftActionExtension" */; + buildPhases = ( + 36FB55DCF44141E140E108F8 /* [CP] Check Pods Manifest.lock */, + 8096213228E55C9400940A5D /* Sources */, + 8096217528E55C9400940A5D /* Frameworks */, + 8096217728E55C9400940A5D /* Resources */, + D880C306E1943EA76DA53078 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = JetpackDraftActionExtension; + productName = WordPressDraftActionExtension; + productReference = 8096218528E55C9400940A5D /* JetpackDraftActionExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 80F6D01F28EE866A00953C1A /* JetpackNotificationServiceExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 80F6D04F28EE866A00953C1A /* Build configuration list for PBXNativeTarget "JetpackNotificationServiceExtension" */; + buildPhases = ( + 80F6D02028EE866A00953C1A /* [CP] Check Pods Manifest.lock */, + 80F6D02128EE866A00953C1A /* Sources */, + 80F6D04A28EE866A00953C1A /* Frameworks */, + 80F6D04C28EE866A00953C1A /* Resources */, + 80F6D04E28EE866A00953C1A /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = JetpackNotificationServiceExtension; + productName = WordPressNotificationServiceExtension; + productReference = 80F6D05428EE866A00953C1A /* JetpackNotificationServiceExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + 8511CFB51C607A7000B7CEED /* WordPressScreenshotGeneration */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8511CFC21C607A7000B7CEED /* Build configuration list for PBXNativeTarget "WordPressScreenshotGeneration" */; + buildPhases = ( + 2DF08408C90B90D744C56E02 /* [CP] Check Pods Manifest.lock */, + 8511CFB21C607A7000B7CEED /* Sources */, + 8511CFB31C607A7000B7CEED /* Frameworks */, + 8511CFB41C607A7000B7CEED /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 8511CFBC1C607A7000B7CEED /* PBXTargetDependency */, + ); + name = WordPressScreenshotGeneration; + productName = WordPressScreenshotGeneration; + productReference = 8511CFB61C607A7000B7CEED /* WordPressScreenshotGeneration.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 932225A61C7CE50300443B02 /* WordPressShareExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 932225B71C7CE50400443B02 /* Build configuration list for PBXNativeTarget "WordPressShareExtension" */; + buildPhases = ( + 4F4D5C2BB6478A3E90ADC3C5 /* [CP] Check Pods Manifest.lock */, + 932225A31C7CE50300443B02 /* Sources */, + 932225A41C7CE50300443B02 /* Frameworks */, + 932225A51C7CE50300443B02 /* Resources */, + 83D79708413A3DA10638659F /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WordPressShareExtension; + productName = WordPressShare; + productReference = 932225A71C7CE50300443B02 /* WordPressShareExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + E16AB92914D978240047A2E5 /* WordPressTest */ = { + isa = PBXNativeTarget; + buildConfigurationList = E16AB93D14D978240047A2E5 /* Build configuration list for PBXNativeTarget "WordPressTest" */; + buildPhases = ( + E0E31D6E60F2BCE2D0A53E39 /* [CP] Check Pods Manifest.lock */, + E16AB92514D978240047A2E5 /* Sources */, + E16AB92614D978240047A2E5 /* Frameworks */, + E16AB92714D978240047A2E5 /* Resources */, + 42C1BDE416A90FA718A28797 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + E16AB93F14D978520047A2E5 /* PBXTargetDependency */, + ); + name = WordPressTest; + packageProductDependencies = ( + 3F3B23C12858A1B300CACE60 /* BuildkiteTestCollector */, + 3F338B70289BD3040014ADC5 /* Nimble */, + ); + productName = WordPressTest; + productReference = E16AB92A14D978240047A2E5 /* WordPressTest.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + EA14532229AD874C001F3143 /* JetpackUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EA14533D29AD874C001F3143 /* Build configuration list for PBXNativeTarget "JetpackUITests" */; + buildPhases = ( + EA14532829AD874C001F3143 /* Sources */, + EA14533729AD874C001F3143 /* Frameworks */, + EA14533B29AD874C001F3143 /* Resources */, + EA14533C29AD874C001F3143 /* Set Up Simulator */, + ); + buildRules = ( + ); + dependencies = ( + EA14534529AD877C001F3143 /* PBXTargetDependency */, + ); + name = JetpackUITests; + packageProductDependencies = ( + EA14532529AD874C001F3143 /* BuildkiteTestCollector */, + ); + productName = WordPressUITests; + productReference = EA14534229AD874C001F3143 /* JetpackUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + F1F163BD25658B4D003DC13B /* WordPressIntents */ = { + isa = PBXNativeTarget; + buildConfigurationList = F1F163DC25658B4D003DC13B /* Build configuration list for PBXNativeTarget "WordPressIntents" */; + buildPhases = ( + CE51D1C75430FDDD21B27F64 /* [CP] Check Pods Manifest.lock */, + F1F163BA25658B4D003DC13B /* Sources */, + F1F163BB25658B4D003DC13B /* Frameworks */, + F1F163BC25658B4D003DC13B /* Resources */, + 2ACEBC2718E9AC320D2B2858 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WordPressIntents; + productName = WordPressIntents; + productReference = F1F163BE25658B4D003DC13B /* WordPressIntents.appex */; + productType = "com.apple.product-type.app-extension"; + }; + FABB1F8F2602FC2C00C8785C /* Jetpack */ = { + isa = PBXNativeTarget; + buildConfigurationList = FABB264D2602FC2C00C8785C /* Build configuration list for PBXNativeTarget "Jetpack" */; + buildPhases = ( + FABB1FA72602FC2C00C8785C /* [CP] Check Pods Manifest.lock */, + 09607CE8281C9D0F002D2E5A /* [Lint] Check AppLocalizedString usage */, + 3F32E4AE270EAF5100A33D51 /* Generate Credentials */, + FABB1FA92602FC2C00C8785C /* App Icons: Add Version For Internal Releases */, + FABB1FAA2602FC2C00C8785C /* Resources */, + FABB20C12602FC2C00C8785C /* Copy Alternate Internal Icons (if needed) */, + FABB20C22602FC2C00C8785C /* Sources */, + FABB261F2602FC2C00C8785C /* Frameworks */, + FABB26402602FC2C00C8785C /* Embed Foundation Extensions */, + FABB264A2602FC2C00C8785C /* [CP] Embed Pods Frameworks */, + FABB264B2602FC2C00C8785C /* [CP] Copy Pods Resources */, + FABB264C2602FC2C00C8785C /* Copy Gutenberg JS */, + ); + buildRules = ( + ); + dependencies = ( + 3FCFFB002994AB25002840C9 /* PBXTargetDependency */, + 0107E15728FEA03800DE87DB /* PBXTargetDependency */, + 0107E0ED28F97E6100DE87DB /* PBXTargetDependency */, + 8096212728E5411400940A5D /* PBXTargetDependency */, + 8096219028E55F8600940A5D /* PBXTargetDependency */, + 80F6D05F28EE88FC00953C1A /* PBXTargetDependency */, + ); + name = Jetpack; + packageProductDependencies = ( + FABB1FA62602FC2C00C8785C /* WordPressFlux */, + 17A8858E2757BEC00071FCA3 /* AutomatticAbout */, + 3F2B62DD284F4E310008CD59 /* Charts */, + 3F44DD57289C379C006334CD /* Lottie */, + ); + productName = WordPress; + productReference = FABB26522602FC2C00C8785C /* Jetpack.app */; + productType = "com.apple.product-type.application"; + }; + FAF64B662637DEEC00E8A1DF /* JetpackScreenshotGeneration */ = { + isa = PBXNativeTarget; + buildConfigurationList = FAF64B9D2637DEEC00E8A1DF /* Build configuration list for PBXNativeTarget "JetpackScreenshotGeneration" */; + buildPhases = ( + FAF64B6A2637DEEC00E8A1DF /* Sources */, + FAF64B9A2637DEEC00E8A1DF /* Frameworks */, + FAF64B9C2637DEEC00E8A1DF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + FAF64C3A2637E02700E8A1DF /* PBXTargetDependency */, + ); + name = JetpackScreenshotGeneration; + productName = WordPressScreenshotGeneration; + productReference = FAF64BA22637DEEC00E8A1DF /* JetpackScreenshotGeneration.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + FF27168E1CAAC87A0006E2D4 /* WordPressUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = FF27169A1CAAC87B0006E2D4 /* Build configuration list for PBXNativeTarget "WordPressUITests" */; + buildPhases = ( + 37F48D4CB364EA4BCC1EAE82 /* [CP] Check Pods Manifest.lock */, + FF27168B1CAAC87A0006E2D4 /* Sources */, + FF27168C1CAAC87A0006E2D4 /* Frameworks */, + FF27168D1CAAC87A0006E2D4 /* Resources */, + CC875D93233BCEC800595CC8 /* Set Up Simulator */, + ); + buildRules = ( + ); + dependencies = ( + FF2716951CAAC87B0006E2D4 /* PBXTargetDependency */, + ); + name = WordPressUITests; + packageProductDependencies = ( + 3F3B23C32858A1D800CACE60 /* BuildkiteTestCollector */, + ); + productName = WordPressUITests; + productReference = FF27168F1CAAC87A0006E2D4 /* WordPressUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 29B97313FDCFA39411CA2CEA /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1220; + LastUpgradeCheck = 1400; + ORGANIZATIONNAME = WordPress; + TargetAttributes = { + 096A92F526E29FFF00448C68 = { + CreatedOnToolsVersion = 12.5; + }; + 1D6058900D05DD3D006BFB54 = { + DevelopmentTeam = PZYM8XX95Q; + LastSwiftMigration = 1000; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.Keychain = { + enabled = 1; + }; + com.apple.Push = { + enabled = 1; + }; + com.apple.SafariKeychain = { + enabled = 1; + }; + com.apple.iCloud = { + enabled = 1; + }; + }; + }; + 3F526C4B2538CF2A0069706C = { + CreatedOnToolsVersion = 12.0.1; + ProvisioningStyle = Manual; + }; + 3FA640562670CCD40064401E = { + CreatedOnToolsVersion = 12.5; + LastSwiftMigration = 1250; + }; + 733F36022126197800988727 = { + CreatedOnToolsVersion = 9.4.1; + LastSwiftMigration = 1000; + ProvisioningStyle = Manual; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.Keychain = { + enabled = 1; + }; + }; + }; + 7358E6B7210BD318002323EB = { + CreatedOnToolsVersion = 9.4.1; + LastSwiftMigration = 1000; + ProvisioningStyle = Manual; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.Keychain = { + enabled = 1; + }; + }; + }; + 74576671202B558C00F42E40 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1000; + ProvisioningStyle = Manual; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.Keychain = { + enabled = 1; + }; + }; + }; + 8511CFB51C607A7000B7CEED = { + CreatedOnToolsVersion = 7.2; + LastSwiftMigration = 0820; + TestTargetID = 1D6058900D05DD3D006BFB54; + }; + 932225A61C7CE50300443B02 = { + CreatedOnToolsVersion = 7.2.1; + DevelopmentTeam = PZYM8XX95Q; + LastSwiftMigration = 1000; + SystemCapabilities = { + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.Keychain = { + enabled = 1; + }; + }; + }; + A2795807198819DE0031C6A3 = { + DevelopmentTeam = PZYM8XX95Q; + }; + E16AB92914D978240047A2E5 = { + DevelopmentTeam = PZYM8XX95Q; + LastSwiftMigration = 1000; + TestTargetID = 1D6058900D05DD3D006BFB54; + }; + EA14532229AD874C001F3143 = { + TestTargetID = FABB1F8F2602FC2C00C8785C; + }; + F1F163BD25658B4D003DC13B = { + CreatedOnToolsVersion = 12.2; + ProvisioningStyle = Manual; + }; + FAF64B662637DEEC00E8A1DF = { + TestTargetID = FABB1F8F2602FC2C00C8785C; + }; + FF27168E1CAAC87A0006E2D4 = { + CreatedOnToolsVersion = 7.3; + LastSwiftMigration = 0800; + TestTargetID = 1D6058900D05DD3D006BFB54; + }; + FFA8E22A1F94E3DE0002170F = { + CreatedOnToolsVersion = 9.0; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = C01FCF4E08A954540054247B /* Build configuration list for PBXProject "WordPress" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 1; + knownRegions = ( + en, + es, + it, + ja, + pt, + sv, + "zh-Hans", + nb, + tr, + id, + "zh-Hant", + hu, + pl, + ru, + da, + th, + fr, + nl, + de, + "en-GB", + "pt-BR", + hr, + he, + cy, + "en-CA", + Base, + cs, + ro, + sq, + is, + "en-AU", + ko, + ar, + bg, + sk, + ); + mainGroup = 29B97314FDCFA39411CA2CEA; + packageReferences = ( + 3FF1442E266F3C2400138163 /* XCRemoteSwiftPackageReference "ScreenObject" */, + 3FC2C33B26C4CF0A00C6D98F /* XCRemoteSwiftPackageReference "XCUITestHelpers" */, + 17A8858B2757B97F0071FCA3 /* XCRemoteSwiftPackageReference "AutomatticAbout-swift" */, + 3F2B62DA284F4E0B0008CD59 /* XCRemoteSwiftPackageReference "Charts" */, + 3F3B23C02858A1B300CACE60 /* XCRemoteSwiftPackageReference "test-collector-swift" */, + 3F411B6D28987E3F002513AE /* XCRemoteSwiftPackageReference "lottie-ios.git" */, + 3F338B6F289BD3040014ADC5 /* XCRemoteSwiftPackageReference "Nimble" */, + ); + productRefGroup = 19C28FACFE9D520D11CA2CBB /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1D6058900D05DD3D006BFB54 /* WordPress */, + 932225A61C7CE50300443B02 /* WordPressShareExtension */, + 74576671202B558C00F42E40 /* WordPressDraftActionExtension */, + 3F526C4B2538CF2A0069706C /* WordPressStatsWidgets */, + 733F36022126197800988727 /* WordPressNotificationContentExtension */, + 7358E6B7210BD318002323EB /* WordPressNotificationServiceExtension */, + F1F163BD25658B4D003DC13B /* WordPressIntents */, + E16AB92914D978240047A2E5 /* WordPressTest */, + FF27168E1CAAC87A0006E2D4 /* WordPressUITests */, + 8511CFB51C607A7000B7CEED /* WordPressScreenshotGeneration */, + 096A92F526E29FFF00448C68 /* GenerateCredentials */, + A2795807198819DE0031C6A3 /* OCLint */, + FFA8E22A1F94E3DE0002170F /* SwiftLint */, + FABB1F8F2602FC2C00C8785C /* Jetpack */, + FAF64B662637DEEC00E8A1DF /* JetpackScreenshotGeneration */, + 3FA640562670CCD40064401E /* UITestsFoundation */, + 809620CF28E540D700940A5D /* JetpackShareExtension */, + 8096213028E55C9400940A5D /* JetpackDraftActionExtension */, + 80F6D01F28EE866A00953C1A /* JetpackNotificationServiceExtension */, + 0107E0B128F97D5000DE87DB /* JetpackStatsWidgets */, + 0107E13828FE9DB200DE87DB /* JetpackIntents */, + EA14532229AD874C001F3143 /* JetpackUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0107E0E128F97D5000DE87DB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0107E0E228F97D5000DE87DB /* Assets.xcassets in Resources */, + 0107E0E328F97D5000DE87DB /* ColorPalette.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0107E14C28FE9DB200DE87DB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0107E14D28FE9DB200DE87DB /* AppImages.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1D60588D0D05DD3D006BFB54 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8BA77BCB2482C52A00E1EBBF /* ReaderCardDiscoverAttributionView.xib in Resources */, + 9826AE9121B5D3CD00C851FA /* PostingActivityCell.xib in Resources */, + C7E5F25C2799C2B0009BC263 /* blue-icon-app-83.5x83.5@2x.png in Resources */, + 98A25BD3203CB278006A5807 /* SignupEpilogueCell.xib in Resources */, + 17222D88261DDDF90047B163 /* celadon-icon-app-60x60@2x.png in Resources */, + C700F9D2257FD63A0090938E /* JetpackScanViewController.xib in Resources */, + B5C66B741ACF071F00F68370 /* NoteBlockActionsTableViewCell.xib in Resources */, + FAE4CA6A2732C094003BFDFE /* QuickStartPromptViewController.xib in Resources */, + 98DCF4A7275945E00008630F /* ReaderDetailNoCommentCell.xib in Resources */, + 98A047752821D069001B4E2D /* BloggingPromptsViewController.storyboard in Resources */, + F5A34D1125DF2F7F00C9654B /* Pacifico-Regular.ttf in Resources */, + FA1CEAD425CA9C40005E7038 /* RestoreStatusFailedView.xib in Resources */, + 1761F17B26209AEE000815EF /* open-source-icon-app-60x60@2x.png in Resources */, + B5C66B701ACF06CA00F68370 /* NoteBlockHeaderTableViewCell.xib in Resources */, + 1761F18126209AEE000815EF /* jetpack-green-icon-app-60x60@2x.png in Resources */, + 801D9513291AB3CF0051993E /* JetpackStatsLogoAnimation_rtl.json in Resources */, + 17222DA2261DDDF90047B163 /* pink-classic-icon-app-60x60@3x.png in Resources */, + FE39C135269C37C900EFB827 /* ListTableViewCell.xib in Resources */, + 469CE07224BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.xib in Resources */, + 17222D9B261DDDF90047B163 /* black-icon-app-76x76.png in Resources */, + 8B0732E7242B9C5200E7FBD3 /* PrepublishingHeaderView.xib in Resources */, + DC772B0828201F5300664C02 /* ViewsVisitorsLineChartCell.xib in Resources */, + 5D6C4AF61B603CA3005E3C43 /* WPTableViewActivityCell.xib in Resources */, + A01C55480E25E0D000D411F2 /* defaultPostTemplate.html in Resources */, + 17222D89261DDDF90047B163 /* celadon-icon-app-60x60@3x.png in Resources */, + 08BA4BC9298A9AD500015BD2 /* JetpackInstallPluginLogoAnimation_ltr.json in Resources */, + 2FAE97090E33B21600CA8540 /* defaultPostTemplate_old.html in Resources */, + 3236F77624ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.xib in Resources */, + 9A19D441236C7C7500D393E5 /* StatsGhostTopHeaderCell.xib in Resources */, + 436D562A2117312700CEAA33 /* RegisterDomainDetailsFooterView.xib in Resources */, + 98579BC8203DD86F004086E4 /* EpilogueSectionHeaderFooter.xib in Resources */, + 8B64B4B2247EC3A2009A1229 /* reader.css in Resources */, + 17222DA1261DDDF90047B163 /* pink-classic-icon-app-76x76.png in Resources */, + 803C493E283A7C2200003E9B /* QuickStartChecklistHeader.xib in Resources */, + B57273621B66CCEF000D1C4F /* AlertView.xib in Resources */, + 1761F17726209AEE000815EF /* wordpress-dark-icon-app-76x76.png in Resources */, + 08BA4BC7298A9AD500015BD2 /* JetpackInstallPluginLogoAnimation_rtl.json in Resources */, + 98F537A922496D0D00B334F9 /* SiteStatsTableHeaderView.xib in Resources */, + 5DBFC8A91A9BE07B00E00DE4 /* Posts.storyboard in Resources */, + 1761F18426209AEE000815EF /* jetpack-green-icon-app-76x76@2x.png in Resources */, + 98B11B8B2216536C00B7F2D7 /* StatsChildRowsView.xib in Resources */, + 2FAE970C0E33B21600CA8540 /* xhtml1-transitional.dtd in Resources */, + C31B6D7828BFE8B100E64FEB /* EBGaramond-Regular.ttf in Resources */, + FA1A55FF25A6F07F0033967D /* RestoreStatusView.xib in Resources */, + 4058F41A1FF40EE1000D5559 /* PluginDetailViewHeaderCell.xib in Resources */, + 1761F17226209AEE000815EF /* open-source-dark-icon-app-60x60@3x.png in Resources */, + 406A0EF0224D39C50016AD6A /* Flags.xcassets in Resources */, + 9A9E3FB0230EA7A300909BC4 /* StatsGhostTopCell.xib in Resources */, + 985793C922F23D7000643DBF /* CustomizeInsightsCell.xib in Resources */, + 1761F17926209AEE000815EF /* wordpress-dark-icon-app-60x60@2x.png in Resources */, + F5A34D0F25DF2F7F00C9654B /* oswald_upper.ttf in Resources */, + 2FAE970D0E33B21600CA8540 /* xhtmlValidatorTemplate.xhtml in Resources */, + 9826AE8821B5C73400C851FA /* PostingActivityDay.xib in Resources */, + 8BB185CC24B6058600A4CCE8 /* reader-cards-success.json in Resources */, + 17222D8C261DDDF90047B163 /* black-classic-icon-app-83.5x83.5@2x.png in Resources */, + 172F06BA2865C04F00C78FD4 /* spectrum-'22-icon-app-60x60@3x.png in Resources */, + FAC086D825EDFB1E00B94F2A /* ReaderRelatedPostsCell.xib in Resources */, + 9A2B28F72192121F00458F2A /* RevisionOperation.xib in Resources */, + 80535DBB294ABBF000873161 /* JetpackAllFeaturesLogosAnimation_rtl.json in Resources */, + 9822A8552624D01800FD8A03 /* UserProfileSiteCell.xib in Resources */, + E66E2A6A1FE432BC00788F22 /* TitleBadgeDisclosureCell.xib in Resources */, + 17222D87261DDDF90047B163 /* celadon-icon-app-76x76@2x.png in Resources */, + 40640046200ED30300106789 /* TextWithAccessoryButtonCell.xib in Resources */, + C7234A442832C2BA0045C63F /* QRLoginScanningViewController.xib in Resources */, + 17222D99261DDDF90047B163 /* black-icon-app-60x60@2x.png in Resources */, + 9856A389261FC206008D6354 /* UserProfileUserInfoCell.xib in Resources */, + 98B88466261E4E4E007ED7F8 /* LikeUserTableViewCell.xib in Resources */, + 98467A43221CD75200DF51AE /* SiteStatsDetailTableViewController.storyboard in Resources */, + FE43DAB126DFAD1C00CFF595 /* CommentContentTableViewCell.xib in Resources */, + 43D74ACE20F906DD004AD934 /* InlineEditableNameValueCell.xib in Resources */, + 433840C722C2BA5B00CB13F8 /* AppImages.xcassets in Resources */, + 3F4EB39228AC561600B8DD86 /* JetpackWordPressLogoAnimation_rtl.json in Resources */, + FF00889D204DFF77007CCE66 /* MediaQuotaCell.xib in Resources */, + 17222D81261DDDF90047B163 /* celadon-classic-icon-app-76x76@2x.png in Resources */, + 931DF4D618D09A2F00540BDD /* InfoPlist.strings in Resources */, + D88106F820C0C9A8001D2F00 /* ReaderSavedPostUndoCell.xib in Resources */, + 801D9511291AB3CF0051993E /* JetpackStatsLogoAnimation_ltr.json in Resources */, + FE23EB4926E7C91F005A1698 /* richCommentTemplate.html in Resources */, + 1761F18826209AEE000815EF /* pride-icon-app-76x76.png in Resources */, + 17222D8A261DDDF90047B163 /* black-classic-icon-app-76x76@2x.png in Resources */, + 5D5A6E941B613CA400DAF819 /* ReaderPostCardCell.xib in Resources */, + 1761F17F26209AEE000815EF /* open-source-icon-app-76x76@2x.png in Resources */, + 17222DA7261DDDF90047B163 /* spectrum-icon-app-60x60@2x.png in Resources */, + 1761F18C26209AEE000815EF /* hot-pink-icon-app-60x60@3x.png in Resources */, + 4349B0B0218A477F0034118A /* RevisionsTableViewCell.xib in Resources */, + 17222D97261DDDF90047B163 /* pink-icon-app-60x60@2x.png in Resources */, + C7E5F2532799BD54009BC263 /* cool-blue-icon-app-60x60@3x.png in Resources */, + 1761F17E26209AEE000815EF /* open-source-icon-app-76x76.png in Resources */, + 17222DAE261DDDF90047B163 /* spectrum-classic-icon-app-76x76.png in Resources */, + C77FC9112800CAC100726F00 /* OnboardingEnableNotificationsViewController.xib in Resources */, + 17222D9F261DDDF90047B163 /* pink-classic-icon-app-76x76@2x.png in Resources */, + 439F4F38219B636500F8D0C7 /* Revisions.storyboard in Resources */, + 4019B27120885AB900A0C7EB /* ActivityDetailViewController.storyboard in Resources */, + 1761F17826209AEE000815EF /* wordpress-dark-icon-app-83.5x83.5@2x.png in Resources */, + 8B0CE7D32481CFF8004C4799 /* ReaderDetailHeaderView.xib in Resources */, + FA7F92CA25E61C9300502D2A /* ReaderTagsFooter.xib in Resources */, + 9848DF8321B8BB6900B99DA4 /* PostingActivityLegend.xib in Resources */, + 981C986B21B9BF6000A7C0C8 /* PostingActivityViewController.storyboard in Resources */, + 98880A4B22B2E5E400464538 /* TwoColumnCell.xib in Resources */, + B558541419631A1000FAF6C3 /* Notifications.storyboard in Resources */, + 983DBBAA22125DD500753988 /* StatsTableFooter.xib in Resources */, + 820ADD701F3A1F88002D7F93 /* ThemeBrowserSectionHeaderView.xib in Resources */, + 467D3E0C25E4436D00EB9CB0 /* SitePromptView.xib in Resources */, + 17222D8F261DDDF90047B163 /* blue-classic-icon-app-60x60@3x.png in Resources */, + 746A6F571E71C691003B67E3 /* DeleteSite.storyboard in Resources */, + 8091019529078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.xib in Resources */, + F5A34D1225DF2F7F00C9654B /* Noticons.ttf in Resources */, + B51AD77B2056C31100A6C545 /* LoginEpilogue.storyboard in Resources */, + 9895B6E021ED49160053D370 /* TopTotalsCell.xib in Resources */, + 430693741DD25F31009398A2 /* PostPost.storyboard in Resources */, + 17222DA3261DDDF90047B163 /* spectrum-icon-app-76x76@2x.png in Resources */, + 1761F18726209AEE000815EF /* pride-icon-app-83.5x83.5@2x.png in Resources */, + F5A34D0E25DF2F7F00C9654B /* SpaceMono-Bold.ttf in Resources */, + 9835F16E25E492EE002EFF23 /* CommentsList.storyboard in Resources */, + 17222D80261DDDF90047B163 /* celadon-classic-icon-app-76x76.png in Resources */, + 1761F17A26209AEE000815EF /* wordpress-dark-icon-app-60x60@3x.png in Resources */, + 80535DBC294ABBF000873161 /* JetpackAllFeaturesLogosAnimation_ltr.json in Resources */, + 98563DDE21BF30C40006F5E9 /* TabbedTotalsCell.xib in Resources */, + 5DFA7EC81AF814E40072023B /* PageListTableViewCell.xib in Resources */, + B5C66B781ACF073900F68370 /* NoteBlockImageTableViewCell.xib in Resources */, + FEA088032696E81F00193358 /* ListTableHeaderView.xib in Resources */, + B59F34A1207678480069992D /* SignupEpilogue.storyboard in Resources */, + 98FCFC242231DF43006ECDD4 /* PostStatsTitleCell.xib in Resources */, + 8BA77BCD248340CE00E1EBBF /* ReaderDetailToolbar.xib in Resources */, + 4070D75C20E5F55A007CEBDA /* RewindStatusTableViewCell.xib in Resources */, + 5D13FA571AF99C2100F06492 /* PageListSectionHeaderView.xib in Resources */, + 1761F18D26209AEE000815EF /* hot-pink-icon-app-76x76.png in Resources */, + FA7AA4A725BFE0A9005E7200 /* JetpackScanThreatDetailsViewController.xib in Resources */, + 469EB16624D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.xib in Resources */, + B54E1DF21A0A7BAA00807537 /* ReplyTextView.xib in Resources */, + F5A34D1325DF2F7F00C9654B /* LibreBaskerville-Regular.ttf in Resources */, + 17222D84261DDDF90047B163 /* celadon-classic-icon-app-60x60@3x.png in Resources */, + 9881296F219CF1310075FF33 /* StatsCellHeader.xib in Resources */, + C76F490025BA23B000BFEC87 /* JetpackScanHistoryViewController.xib in Resources */, + 3249615323F20111004C7733 /* PostSignUpInterstitialViewController.xib in Resources */, + 4625B6342538B53700C04AAD /* CollapsableHeaderViewController.xib in Resources */, + FA6FAB4725EF7C6A00666CED /* ReaderRelatedPostsSectionHeaderView.xib in Resources */, + 17222DA5261DDDF90047B163 /* spectrum-icon-app-60x60@3x.png in Resources */, + 1761F18926209AEE000815EF /* pride-icon-app-76x76@2x.png in Resources */, + 9826AE8C21B5CC8D00C851FA /* PostingActivityMonth.xib in Resources */, + 08D499671CDD20450004809A /* Menus.storyboard in Resources */, + 9A3BDA1022944F4D00FBF510 /* CountriesMapView.xib in Resources */, + 801D9515291AB3CF0051993E /* JetpackNotificationsLogoAnimation_rtl.json in Resources */, + 98797DBD222F434500128C21 /* OverviewCell.xib in Resources */, + 9A8ECE1E2254AE4E0043C8DA /* JetpackRemoteInstallStateView.xib in Resources */, + 1761F18626209AEE000815EF /* pride-icon-app-60x60@2x.png in Resources */, + FA347AF226EB7A420096604B /* StatsGhostGrowAudienceCell.xib in Resources */, + 464688D9255C71D200ECA61C /* TemplatePreviewViewController.xib in Resources */, + 98B33C88202283860071E1E2 /* NoResults.storyboard in Resources */, + 82A062DC2017BC220084CE7C /* ActivityListSectionHeaderView.xib in Resources */, + E1D91456134A853D0089019C /* Localizable.strings in Resources */, + 3234BB352530EA980068DA40 /* ReaderRecommendedSiteCardCell.xib in Resources */, + 17222D9C261DDDF90047B163 /* black-icon-app-76x76@2x.png in Resources */, + F5A34D0D25DF2F7F00C9654B /* Nunito-Bold.ttf in Resources */, + B5EEB19F1CA96D19004B6540 /* ImageCropViewController.xib in Resources */, + C7234A502832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.xib in Resources */, + 17222D93261DDDF90047B163 /* blue-classic-icon-app-76x76.png in Resources */, + 5D1D04751B7A50B100CDE646 /* Reader.storyboard in Resources */, + C7E5F25B2799C2B0009BC263 /* blue-icon-app-76x76@2x.png in Resources */, + 982D9A0026F922C100AA794C /* InlineEditableMultiLineCell.xib in Resources */, + C7E5F2522799BD54009BC263 /* cool-blue-icon-app-76x76.png in Resources */, + 801D950F291AB3CF0051993E /* JetpackReaderLogoAnimation_rtl.json in Resources */, + FA1A544025A6E3080033967D /* RestoreWarningView.xib in Resources */, + C7E5F2542799BD54009BC263 /* cool-blue-icon-app-60x60@2x.png in Resources */, + 17222DAF261DDDF90047B163 /* spectrum-classic-icon-app-60x60@3x.png in Resources */, + 1761F17D26209AEE000815EF /* open-source-icon-app-83.5x83.5@2x.png in Resources */, + 1761F17126209AEE000815EF /* open-source-dark-icon-app-83.5x83.5@2x.png in Resources */, + 9A8ECE0D2254A3260043C8DA /* JetpackLoginViewController.xib in Resources */, + FA347AEF26EB6E300096604B /* GrowAudienceCell.xib in Resources */, + 98458CBA21A39D7A0025D232 /* StatsNoDataRow.xib in Resources */, + 172F06BC2865C04F00C78FD4 /* spectrum-'22-icon-app-83.5x83.5@2x.png in Resources */, + 17222D82261DDDF90047B163 /* celadon-classic-icon-app-83.5x83.5@2x.png in Resources */, + C700FAB3258020DB0090938E /* JetpackScanThreatCell.xib in Resources */, + C768B5B525828A5D00556E75 /* JetpackScanStatusCell.xib in Resources */, + 82FC61261FA8ADAD00A1757E /* ActivityTableViewCell.xib in Resources */, + 17222DAD261DDDF90047B163 /* spectrum-classic-icon-app-76x76@2x.png in Resources */, + 17222D9A261DDDF90047B163 /* black-icon-app-60x60@3x.png in Resources */, + 9AB36B84236B25D900FAD72A /* StatsGhostTitleCell.xib in Resources */, + 17222D8D261DDDF90047B163 /* black-classic-icon-app-60x60@2x.png in Resources */, + 176CF3AC25E0079600E1E598 /* NoteBlockButtonTableViewCell.xib in Resources */, + 17222D9D261DDDF90047B163 /* black-icon-app-83.5x83.5@2x.png in Resources */, + 17C1D6F526711ED0006C8970 /* Emoji.txt in Resources */, + B5C66B761ACF072C00F68370 /* NoteBlockCommentTableViewCell.xib in Resources */, + 17222D94261DDDF90047B163 /* pink-icon-app-76x76@2x.png in Resources */, + 57AA8491228715E700D3C2A2 /* PostCardCell.xib in Resources */, + 3F4D035328A5BFCE00F0A4FD /* JetpackWordPressLogoAnimation_ltr.json in Resources */, + 3223393E24FEC2A700BDD4BF /* ReaderDetailFeaturedImageView.xib in Resources */, + E18165FD14E4428B006CE885 /* loader.html in Resources */, + 98BAA7C126F925F70073A2F9 /* InlineEditableSingleLineCell.xib in Resources */, + FA4F661425946B8500EAA9F5 /* JetpackRestoreHeaderView.xib in Resources */, + E1DD4CCD1CAE41C800C3863E /* PagedViewController.xib in Resources */, + 9AC3C69B231543C2007933CD /* StatsGhostChartCell.xib in Resources */, + 17222D8B261DDDF90047B163 /* black-classic-icon-app-60x60@3x.png in Resources */, + E6A3384E1BB0A50900371587 /* ReaderGapMarkerCell.xib in Resources */, + 5DB767411588F64D00EBE36C /* postPreview.html in Resources */, + 17222D9E261DDDF90047B163 /* pink-classic-icon-app-83.5x83.5@2x.png in Resources */, + 1761F17326209AEE000815EF /* open-source-dark-icon-app-60x60@2x.png in Resources */, + 1761F18B26209AEE000815EF /* hot-pink-icon-app-83.5x83.5@2x.png in Resources */, + C7E5F25A2799C2B0009BC263 /* blue-icon-app-76x76.png in Resources */, + C77FC90B28009C7000726F00 /* OnboardingQuestionsPromptViewController.xib in Resources */, + 17222DA6261DDDF90047B163 /* spectrum-icon-app-76x76.png in Resources */, + E149771A1C0DCB6F0057CD60 /* MediaSizeSliderCell.xib in Resources */, + 17222D98261DDDF90047B163 /* pink-icon-app-83.5x83.5@2x.png in Resources */, + 17222DB1261DDDF90047B163 /* spectrum-classic-icon-app-60x60@2x.png in Resources */, + E65219F91B8D10C2000B1217 /* ReaderBlockedSiteCell.xib in Resources */, + 9A4E215A21F7565A00EFF212 /* QuickStartChecklistCell.xib in Resources */, + 801D950D291AB3CF0051993E /* JetpackReaderLogoAnimation_ltr.json in Resources */, + 1761F17426209AEE000815EF /* open-source-dark-icon-app-76x76.png in Resources */, + D8C31CC72188490000A33B35 /* SiteSegmentsCell.xib in Resources */, + 1761F18326209AEE000815EF /* jetpack-green-icon-app-83.5x83.5@2x.png in Resources */, + 9801E685274EEC19002FDDB6 /* ReaderDetailCommentsHeader.xib in Resources */, + E6D2E15F1B8A9C830000ED14 /* ReaderSiteStreamHeader.xib in Resources */, + C7192ECF25E8432D00C3020D /* ReaderTopicsCardCell.xib in Resources */, + 17222D8E261DDDF90047B163 /* black-classic-icon-app-76x76.png in Resources */, + E61507E22220A0FE00213D33 /* richEmbedTemplate.html in Resources */, + B5C66B7A1ACF074600F68370 /* NoteBlockUserTableViewCell.xib in Resources */, + E6D2E1631B8AAA340000ED14 /* ReaderListStreamHeader.xib in Resources */, + 8BE6F92A27EE26D30008BDC7 /* BlogDashboardPostCardGhostCell.xib in Resources */, + CE1CCB2F2050502B000EE3AC /* MyProfileHeaderView.xib in Resources */, + 98BC522D27F62B6300D6E8C2 /* BloggingPromptsFeatureDescriptionView.xib in Resources */, + 5D18FEA01AFBB17400EFEED0 /* RestorePageTableViewCell.xib in Resources */, + 17222DAB261DDDF90047B163 /* blue-icon-app-60x60@3x.png in Resources */, + 747D09862034837C0085EABF /* WordPressShare.js in Resources */, + 9A76C32F22AFDA2100F5D819 /* world-map.svg in Resources */, + 17222DAC261DDDF90047B163 /* blue-icon-app-60x60@2x.png in Resources */, + B5C66B721ACF071100F68370 /* NoteBlockTextTableViewCell.xib in Resources */, + 172F06BD2865C04F00C78FD4 /* spectrum-'22-icon-app-60x60@2x.png in Resources */, + 98E55019265C977E00B4BE9A /* ReaderDetailLikesView.xib in Resources */, + FF37F90922385CA000AFA3DB /* RELEASE-NOTES.txt in Resources */, + C3234F4C27EB96A3004ADB29 /* IntentCell.xib in Resources */, + 9808655A203D075E00D58786 /* EpilogueUserInfoCell.xib in Resources */, + C7E5F2552799BD54009BC263 /* cool-blue-icon-app-83.5x83.5@2x.png in Resources */, + 17222D92261DDDF90047B163 /* blue-classic-icon-app-83.5x83.5@2x.png in Resources */, + 836498CB2817301800A2C170 /* BloggingPromptsHeaderView.xib in Resources */, + 17222D90261DDDF90047B163 /* blue-classic-icon-app-60x60@2x.png in Resources */, + E6D2E1611B8AA4410000ED14 /* ReaderTagStreamHeader.xib in Resources */, + 577C2AB62294401800AD1F03 /* PostCompactCell.xib in Resources */, + 17222D86261DDDF90047B163 /* celadon-icon-app-76x76.png in Resources */, + 5D69DBC4165428CA00A2D1F7 /* n.caf in Resources */, + 17222D85261DDDF90047B163 /* celadon-icon-app-83.5x83.5@2x.png in Resources */, + 801D9517291AB3CF0051993E /* JetpackNotificationsLogoAnimation_ltr.json in Resources */, + E1B912811BB00EFD003C25B9 /* People.storyboard in Resources */, + 9826AE8321B5C6A700C851FA /* LatestPostSummaryCell.xib in Resources */, + 8BF281F927CE8E4100AF8CF3 /* DashboardGhostCardContent.xib in Resources */, + 5D2FB2831AE98C4600F1D4ED /* RestorePostTableViewCell.xib in Resources */, + 1724DDCC1C6121D00099D273 /* Plans.storyboard in Resources */, + 40A2778120191B5E00D078D5 /* PluginDirectoryCollectionViewCell.xib in Resources */, + 8BD8201924BCCE8600FF25FD /* ReaderWelcomeBanner.xib in Resources */, + 1761F17526209AEE000815EF /* open-source-dark-icon-app-76x76@2x.png in Resources */, + 1761F18A26209AEE000815EF /* hot-pink-icon-app-60x60@2x.png in Resources */, + 9A9E3FB4230EC4F700909BC4 /* StatsGhostPostingActivityCell.xib in Resources */, + FE23EB4B26E7C91F005A1698 /* richCommentStyle.css in Resources */, + 435B762A2297484200511813 /* ColorPalette.xcassets in Resources */, + FAB800C225AEE3D200D5D54A /* RestoreCompleteView.xib in Resources */, + 1761F18526209AEE000815EF /* pride-icon-app-60x60@3x.png in Resources */, + 402FFB1C218C27C100FF4A0B /* RegisterDomain.storyboard in Resources */, + 5D732F991AE84E5400CD89E7 /* PostListFooterView.xib in Resources */, + 17222DB0261DDDF90047B163 /* spectrum-classic-icon-app-83.5x83.5@2x.png in Resources */, + 4D520D4F22972BC9002F5924 /* acknowledgements.html in Resources */, + 17222D91261DDDF90047B163 /* blue-classic-icon-app-76x76@2x.png in Resources */, + 8BDA5A70247C36C100AB124C /* ReaderDetailViewController.storyboard in Resources */, + 08EA036929C9B53000B72A87 /* Colors.xcassets in Resources */, + 17222DA0261DDDF90047B163 /* pink-classic-icon-app-60x60@2x.png in Resources */, + 1761F17C26209AEE000815EF /* open-source-icon-app-60x60@3x.png in Resources */, + 5D6C4AFF1B603CE9005E3C43 /* EditCommentViewController.xib in Resources */, + 1E5D00142493E8C90004B708 /* GutenGhostView.xib in Resources */, + 172F06BB2865C04F00C78FD4 /* spectrum-'22-icon-app-76x76@2x.png in Resources */, + B51535D41BBB16AA0029B84B /* Launch Screen.storyboard in Resources */, + 98517E5C28220475001FFD45 /* BloggingPromptTableViewCell.xib in Resources */, + 08216FAA1CDBF95100304BA7 /* MenuItemEditing.storyboard in Resources */, + 981676D7221B7A4300B81C3F /* CountriesCell.xib in Resources */, + 43D74AD420FB5ABA004AD934 /* RegisterDomainSectionHeaderView.xib in Resources */, + 4349BFF5221205540084F200 /* BlogDetailsSectionHeaderView.xib in Resources */, + 98E5D4922620C2B40074A56A /* UserProfileSectionHeader.xib in Resources */, + 9A5C854922B3E42800BEE7A3 /* CountriesMapCell.xib in Resources */, + 5D6C4B021B603D1F005E3C43 /* WPWebViewController.xib in Resources */, + E6D3E84B1BEBD888002692E8 /* ReaderCrossPostCell.xib in Resources */, + 9A09F91C230C49FD00F42AB7 /* StatsStackViewCell.xib in Resources */, + 436D562D2117312700CEAA33 /* RegisterDomainDetailsErrorSectionFooter.xib in Resources */, + 5DFA7EC31AF7CB910072023B /* Pages.storyboard in Resources */, + F5A34D1025DF2F7F00C9654B /* Shrikhand-Regular.ttf in Resources */, + 9A73CB082350DE4C002EF20C /* StatsGhostSingleRowCell.xib in Resources */, + 1761F18E26209AEE000815EF /* hot-pink-icon-app-76x76@2x.png in Resources */, + C7E5F2562799BD54009BC263 /* cool-blue-icon-app-76x76@2x.png in Resources */, + 596C03601B84F24000899EEB /* ThemeBrowser.storyboard in Resources */, + 17222DA4261DDDF90047B163 /* spectrum-icon-app-83.5x83.5@2x.png in Resources */, + 9A9E3FB2230EB74300909BC4 /* StatsGhostTabbedCell.xib in Resources */, + 469CE06E24BCED75003BDC8B /* CategorySectionTableViewCell.xib in Resources */, + 4034FDEE2007D4F700153B87 /* ExpandableCell.xib in Resources */, + 1761F18026209AEE000815EF /* jetpack-green-icon-app-76x76.png in Resources */, + 98F9FB2E270282C200ADF552 /* CommentModerationBar.xib in Resources */, + E61507E42220A13B00213D33 /* richEmbedScript.js in Resources */, + 17222D96261DDDF90047B163 /* pink-icon-app-60x60@3x.png in Resources */, + 1761F18226209AEE000815EF /* jetpack-green-icon-app-60x60@3x.png in Resources */, + FE3E83E626A58646008CE851 /* ListSimpleOverlayView.xib in Resources */, + 401A3D022027DBD80099A127 /* PluginListCell.xib in Resources */, + 17222D83261DDDF90047B163 /* celadon-classic-icon-app-60x60@2x.png in Resources */, + 981C34912183871200FC2683 /* SiteStatsDashboard.storyboard in Resources */, + 986C908622319F2600FC31E1 /* PostStatsTableViewController.storyboard in Resources */, + 32E1BFFE24AB9D28007A08F0 /* ReaderSelectInterestsViewController.xib in Resources */, + 98D52C3322B1CFEC00831529 /* StatsTwoColumnRow.xib in Resources */, + 98812967219CE42A0075FF33 /* StatsTotalRow.xib in Resources */, + 98B3FA8621C05BF000148DD4 /* ViewMoreRow.xib in Resources */, + D865722F2186F96D0023A99C /* SiteSegmentsWizardContent.xib in Resources */, + 987535642282682D001661B4 /* DetailDataCell.xib in Resources */, + 1761F17626209AEE000815EF /* wordpress-dark-icon-app-76x76@2x.png in Resources */, + 9A9E3FAE230E9DD000909BC4 /* StatsGhostTwoColumnCell.xib in Resources */, + 17222D95261DDDF90047B163 /* pink-icon-app-76x76.png in Resources */, + 172F06B92865C04F00C78FD4 /* spectrum-'22-icon-app-76x76.png in Resources */, + 8F2281EE1C11E49A2B1FE337 /* TimeZoneSearchHeaderView.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3F526C4A2538CF2A0069706C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F526C562538CF2B0069706C /* Assets.xcassets in Resources */, + 3F8A087D253E4337000F35ED /* ColorPalette.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FA640552670CCD40064401E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 733F36012126197800988727 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F515E9672654312200848251 /* Noticons.ttf in Resources */, + 4328FED12314788C000EC32A /* ColorPalette.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7358E6B6210BD318002323EB /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F515E9662654312200848251 /* Noticons.ttf in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 74576670202B558C00F42E40 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F5EF481823ABCAE0004C3532 /* MainInterface.storyboard in Resources */, + 099D768327D14B8E00F77EDE /* InfoPlist.strings in Resources */, + 7484D94D20320DFE006E94B4 /* WordPressShare.js in Resources */, + 741AF3A2202F3DC400C771A5 /* ShareExtension.storyboard in Resources */, + 986FF29C214196A5005B28EC /* NoResults.storyboard in Resources */, + 433840C922C2BA6400CB13F8 /* AppImages.xcassets in Resources */, + 435B762C2297484200511813 /* ColorPalette.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8096211628E540D700940A5D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8096219328E5613700940A5D /* AppImages.xcassets in Resources */, + 8096212F28E555F100940A5D /* ColorPalette.xcassets in Resources */, + 8096211728E540D700940A5D /* MainInterface.storyboard in Resources */, + 8096211828E540D700940A5D /* WordPressShare.js in Resources */, + 8096211928E540D700940A5D /* ShareExtension.storyboard in Resources */, + 8096211A28E540D700940A5D /* NoResults.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8096217728E55C9400940A5D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8096219428E561A800940A5D /* AppImages.xcassets in Resources */, + 8096217828E55C9400940A5D /* MainInterface.storyboard in Resources */, + 8096217A28E55C9400940A5D /* WordPressShare.js in Resources */, + 8096217B28E55C9400940A5D /* ShareExtension.storyboard in Resources */, + 8096217C28E55C9400940A5D /* NoResults.storyboard in Resources */, + 8096217D28E55C9400940A5D /* AppImages.xcassets in Resources */, + 8058730B28F7B70B00340C11 /* InfoPlist.strings in Resources */, + 8096217E28E55C9400940A5D /* ColorPalette.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 80F6D04C28EE866A00953C1A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 80F6D04D28EE866A00953C1A /* Noticons.ttf in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8511CFB41C607A7000B7CEED /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 932225A51C7CE50300443B02 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 740C7C4F202F4CD6001C31B0 /* MainInterface.storyboard in Resources */, + E1AFA8C31E8E34230004A323 /* WordPressShare.js in Resources */, + 74F5CD381FE0646F00764E7C /* ShareExtension.storyboard in Resources */, + 986FF29B214196A4005B28EC /* NoResults.storyboard in Resources */, + 433840C822C2BA6300CB13F8 /* AppImages.xcassets in Resources */, + 435B762B2297484200511813 /* ColorPalette.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E16AB92714D978240047A2E5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E1EBC3751C118EDE00F638E0 /* ImmuTableTestViewCellWithNib.xib in Resources */, + E15027631E03E51500B847E3 /* notes-action-unsupported.json in Resources */, + F4426FDB287F066400218003 /* site-suggestions.json in Resources */, + DC772B0128200A3700664C02 /* stats-visits-day-4.json in Resources */, + 93C882A21EEB18D700227A59 /* html_page_with_link_to_rsd.html in Resources */, + D848CC0520FF062100A9038F /* notifications-user-content-meta.json in Resources */, + 8BB185CF24B62D7600A4CCE8 /* reader-cards-success.json in Resources */, + 7E4A772320F7BE94001C706D /* activity-log-comment-content.json in Resources */, + E12BE5EE1C5235DB000FD5CA /* get-me-settings-v1.1.json in Resources */, + 933D1F6C1EA7A3AB009FB462 /* TestingMode.storyboard in Resources */, + 08F8CD371EBD2AA80049D0C0 /* test-image-device-photo-gps.jpg in Resources */, + D88A64A6208D92B1008AE9BC /* stock-photos-media.json in Resources */, + C8567492243F3751001A995E /* tenor-search-response.json in Resources */, + 7E4A772520F7C5E5001C706D /* activity-log-theme-content.json in Resources */, + D848CBF720FEEE7F00A9038F /* notifications-text-content.json in Resources */, + D848CC0F20FF2D9B00A9038F /* notifications-comment-range.json in Resources */, + 3211055A250C027D0048446F /* invalid-jpeg-header.jpg in Resources */, + E15027621E03E51500B847E3 /* notes-action-push.json in Resources */, + 3211056B250C0F750048446F /* valid-png-header in Resources */, + 748BD8851F19234300813F9A /* notifications-mark-as-read.json in Resources */, + 7E442FCA20F678D100DEACA5 /* activity-log-pingback-content.json in Resources */, + F127FFD824213B5600B9D41A /* atomic-get-authentication-cookie-success.json in Resources */, + FEFC0F9027315634001F7F1D /* empty-array.json in Resources */, + D848CC0120FF030C00A9038F /* notifications-comment-meta.json in Resources */, + 93594BD5191D2F5A0079E6B2 /* stats-batch.json in Resources */, + 74585B9C1F0D591D00E7E667 /* domain-service-valid-domains.json in Resources */, + 7E4A772D20F7E8D8001C706D /* activity-log-plugin-content.json in Resources */, + 93CD939319099BE70049096E /* authtoken.json in Resources */, + E1E4CE0F1774563F00430844 /* misteryman.jpg in Resources */, + 855408881A6F106800DDBD79 /* app-review-prompt-notifications-disabled.json in Resources */, + D88A64A4208D8FB6008AE9BC /* stock-photos-search-response.json in Resources */, + D88A64AE208D9CF5008AE9BC /* stock-photos-pageable.json in Resources */, + DC772B0228200A3700664C02 /* stats-visits-day-14.json in Resources */, + 7E92828921090E9A00BBD8A3 /* notifications-pingback.json in Resources */, + 93C882A11EEB18D700227A59 /* html_page_with_link_to_rsd_non_standard.html in Resources */, + 8554088A1A6F107D00DDBD79 /* app-review-prompt-global-disable.json in Resources */, + D8A468E02181C6450094B82F /* site-segment.json in Resources */, + 7E4A772B20F7E5FD001C706D /* activity-log-site-content.json in Resources */, + 8BEE845A27B1DC9D0001A93C /* dashboard-200-with-drafts-and-scheduled.json in Resources */, + 7EF2EEA0210A67B60007A76B /* notifications-unapproved-comment.json in Resources */, + 175CC1772721814C00622FB4 /* domain-service-updated-domains.json in Resources */, + 748437EC1F1D4A4800E8DDAF /* gallery-reader-post-public.json in Resources */, + 7E4A772720F7CDD5001C706D /* activity-log-settings-content.json in Resources */, + B5AEEC7A1ACACFDA008BF2A4 /* notifications-like.json in Resources */, + D88A64AA208D974D008AE9BC /* thumbnail-collection.json in Resources */, + 08B832421EC130D60079808D /* test-gif.gif in Resources */, + D821C819210037F8002ED995 /* activity-log-activity-content.json in Resources */, + 3211055D250C027D0048446F /* 100x100.gif in Resources */, + 748437EB1F1D4A4800E8DDAF /* gallery-reader-post-private.json in Resources */, + 465F8A0A263B692600F4C950 /* wp-block-editor-v1-settings-success-ThemeJSON.json in Resources */, + D848CBFB20FEFA4800A9038F /* notifications-comment-content.json in Resources */, + E1E4CE0617739FAB00430844 /* test-image.jpg in Resources */, + 7E4A772120F7BBBD001C706D /* activity-log-post-content.json in Resources */, + 748437F01F1D4E9E00E8DDAF /* notifications-load-all.json in Resources */, + E11330511A13BAA300D36D84 /* me-sites-with-jetpack.json in Resources */, + 4A76A4BF29D4F0A500AABF4B /* reader-post-comments-success.json in Resources */, + 7E53AB0820FE6C9C005796FE /* activity-log-post.json in Resources */, + 32110560250C027D0048446F /* invalid-gif.gif in Resources */, + D848CC0B20FF2D5D00A9038F /* notifications-user-range.json in Resources */, + 855408861A6F105700DDBD79 /* app-review-prompt-all-enabled.json in Resources */, + 32110569250C0E960048446F /* 100x100-png in Resources */, + DC772B0428200A3700664C02 /* stats-visits-day-11.json in Resources */, + D848CC1320FF31BB00A9038F /* notifications-blockquote-range.json in Resources */, + D848CC0D20FF2D7C00A9038F /* notifications-post-range.json in Resources */, + 8B45C12627B2A27400EA3257 /* dashboard-200-with-drafts-only.json in Resources */, + 32110561250C027D0048446F /* 100x100.jpg in Resources */, + 748437F11F1D4ECC00E8DDAF /* notifications-last-seen.json in Resources */, + 74585B9D1F0D591D00E7E667 /* domain-service-all-domain-types.json in Resources */, + E131CB5616CACF1E004B0314 /* get-user-blogs_has-blog.json in Resources */, + 93C882A31EEB18D700227A59 /* plugin_redirect.html in Resources */, + B5DA8A5F20ADAA1D00D5BDE1 /* plugin-directory-jetpack.json in Resources */, + 084D94AF1EDF842F00C385A6 /* test-video-device-gps.m4v in Resources */, + D848CBFD20FEFB4900A9038F /* notifications-user-content.json in Resources */, + 46B30B872582CA2200A25E66 /* domain-suggestions.json in Resources */, + FE3D058026C3E0F2002A51B0 /* share-app-link-success.json in Resources */, + 933D1F6E1EA7A402009FB462 /* TestAssets.xcassets in Resources */, + B5EFB1D11B33630C007608A3 /* notifications-settings.json in Resources */, + 17C3F8BF25E4438200EFFE12 /* notifications-button-text-content.json in Resources */, + E15027611E03E51500B847E3 /* notes-action-delete.json in Resources */, + 08F8CD361EBD2AA80049D0C0 /* test-image-device-photo-gps-portrait.jpg in Resources */, + B5AEEC7D1ACACFDA008BF2A4 /* notifications-replied-comment.json in Resources */, + 8B2D4F5327ECE089009B085C /* dashboard-200-without-posts.json in Resources */, + FEFC0F8E27313DD0001F7F1D /* comments-v2-success.json in Resources */, + FE003F62282E73E6006F8D1D /* blogging-prompts-fetch-success.json in Resources */, + 46CFA7BF262745F70077BAD9 /* get_wp_v2_themes_twentytwentyone.json in Resources */, + 465F89F7263B690C00F4C950 /* wp-block-editor-v1-settings-success-NotThemeJSON.json in Resources */, + 3211055C250C027D0048446F /* valid-gif-header.gif in Resources */, + D848CC0920FF2D4400A9038F /* notifications-icon-range.json in Resources */, + E131CB5816CACFB4004B0314 /* get-user-blogs_doesnt-have-blog.json in Resources */, + 08DF9C441E8475530058678C /* test-image-portrait.jpg in Resources */, + B5AEEC7C1ACACFDA008BF2A4 /* notifications-new-follower.json in Resources */, + C8567494243F388F001A995E /* tenor-invalid-search-reponse.json in Resources */, + 3211055B250C027D0048446F /* valid-jpeg-header.jpg in Resources */, + B5AEEC791ACACFDA008BF2A4 /* notifications-badge.json in Resources */, + 46CFA7E3262746940077BAD9 /* get_wp_v2_themes_twentytwenty.json in Resources */, + D848CC1120FF310400A9038F /* notifications-site-range.json in Resources */, + 7E53AB0620FE6905005796FE /* activity-log-comment.json in Resources */, + 93C882A41EEB18D700227A59 /* rsd.xml in Resources */, + F44FB6D12878A1020001E3CE /* user-suggestions.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EA14533B29AD874C001F3143 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1F163BC25658B4D003DC13B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FEA5CE7F2701DC8000B41F2A /* AppImages.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FABB1FAA2602FC2C00C8785C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F465978728E65E1800D5F49A /* blue-on-white-icon-app-76.png in Resources */, + F41E4EC128F22932001880C6 /* spectrum-icon-app-76.png in Resources */, + FABB1FAB2602FC2C00C8785C /* ReaderCardDiscoverAttributionView.xib in Resources */, + F46597A728E6600800D5F49A /* jetpack-light-icon-app-76.png in Resources */, + F465980128E66A1100D5F49A /* white-on-black-icon-app-60@3x.png in Resources */, + F465979028E65F8A00D5F49A /* celadon-on-white-icon-app-76@2x.png in Resources */, + F46597DF28E6694200D5F49A /* pink-on-white-icon-app-83.5@2x.png in Resources */, + FABB1FAC2602FC2C00C8785C /* PostingActivityCell.xib in Resources */, + 08BA4BCA298A9AD500015BD2 /* JetpackInstallPluginLogoAnimation_ltr.json in Resources */, + F41E4EE528F24623001880C6 /* 3d-icon-app-76.png in Resources */, + FABB1FB02602FC2C00C8785C /* SignupEpilogueCell.xib in Resources */, + F465979C28E65FC800D5F49A /* dark-green-icon-app-60@2x.png in Resources */, + FABB1FB12602FC2C00C8785C /* JetpackScanViewController.xib in Resources */, + FABB1FB32602FC2C00C8785C /* NoteBlockActionsTableViewCell.xib in Resources */, + F46597FF28E66A1100D5F49A /* white-on-black-icon-app-76@2x.png in Resources */, + FABB1FB42602FC2C00C8785C /* Pacifico-Regular.ttf in Resources */, + FABB286C2603086900C8785C /* AppImages.xcassets in Resources */, + F46597C828E668B900D5F49A /* neumorphic-light-icon-app-60@3x.png in Resources */, + FABB1FB92602FC2C00C8785C /* RestoreStatusFailedView.xib in Resources */, + FABB1FBA2602FC2C00C8785C /* NoteBlockHeaderTableViewCell.xib in Resources */, + FABB1FBB2602FC2C00C8785C /* CollapsableHeaderCollectionViewCell.xib in Resources */, + FE43DAB226DFAD1C00CFF595 /* CommentContentTableViewCell.xib in Resources */, + C7234A452832C2BA0045C63F /* QRLoginScanningViewController.xib in Resources */, + FABB1FBC2602FC2C00C8785C /* PrepublishingHeaderView.xib in Resources */, + FABB1FBF2602FC2C00C8785C /* WPTableViewActivityCell.xib in Resources */, + FABB1FC12602FC2C00C8785C /* defaultPostTemplate.html in Resources */, + FABB1FC32602FC2C00C8785C /* defaultPostTemplate_old.html in Resources */, + F465977A28E6598900D5F49A /* black-on-white-icon-app-60@3x.png in Resources */, + F46597BC28E6687800D5F49A /* neumorphic-dark-icon-app-60@2x.png in Resources */, + FABB1FC42602FC2C00C8785C /* ReaderInterestsCollectionViewCell.xib in Resources */, + F41E4EC228F22932001880C6 /* spectrum-icon-app-76@2x.png in Resources */, + F41E4E9528F20802001880C6 /* white-on-pink-icon-app-76.png in Resources */, + F465980828E66A5B00D5F49A /* white-on-blue-icon-app-76@2x.png in Resources */, + 836498CC2817301800A2C170 /* BloggingPromptsHeaderView.xib in Resources */, + FABB1FC52602FC2C00C8785C /* StatsGhostTopHeaderCell.xib in Resources */, + F46597BE28E6687800D5F49A /* neumorphic-dark-icon-app-60@3x.png in Resources */, + F41E4ECB28F23E00001880C6 /* green-on-white-icon-app-60@3x.png in Resources */, + 9856A38A261FC206008D6354 /* UserProfileUserInfoCell.xib in Resources */, + FABB1FC72602FC2C00C8785C /* RegisterDomainDetailsFooterView.xib in Resources */, + FABB1FC82602FC2C00C8785C /* EpilogueSectionHeaderFooter.xib in Resources */, + FE23EB4C26E7C91F005A1698 /* richCommentStyle.css in Resources */, + 98517E5D28220475001FFD45 /* BloggingPromptTableViewCell.xib in Resources */, + FABB1FC92602FC2C00C8785C /* reader.css in Resources */, + FABB1FCB2602FC2C00C8785C /* AlertView.xib in Resources */, + F41E4EE428F24623001880C6 /* 3d-icon-app-60@2x.png in Resources */, + F46597F328E669D400D5F49A /* spectrum-on-white-icon-app-83.5@2x.png in Resources */, + FABB1FCC2602FC2C00C8785C /* SiteStatsTableHeaderView.xib in Resources */, + F41E4EEF28F247D3001880C6 /* white-on-green-icon-app-60@2x.png in Resources */, + FABB1FCD2602FC2C00C8785C /* Posts.storyboard in Resources */, + F41E4EB628F225DB001880C6 /* stroke-dark-icon-app-83.5@2x.png in Resources */, + FABB1FCE2602FC2C00C8785C /* StatsChildRowsView.xib in Resources */, + F41E4EEE28F247D3001880C6 /* white-on-green-icon-app-76.png in Resources */, + FABB1FD02602FC2C00C8785C /* xhtml1-transitional.dtd in Resources */, + 8BF281FA27CE8E4100AF8CF3 /* DashboardGhostCardContent.xib in Resources */, + F465979A28E65FC800D5F49A /* dark-green-icon-app-60@3x.png in Resources */, + FABB1FD22602FC2C00C8785C /* RestoreStatusView.xib in Resources */, + FABB1FD32602FC2C00C8785C /* PluginDetailViewHeaderCell.xib in Resources */, + FABB1FD42602FC2C00C8785C /* Flags.xcassets in Resources */, + FE3E427826A868E300C596CE /* ListTableViewCell.xib in Resources */, + FABB1FD52602FC2C00C8785C /* StatsGhostTopCell.xib in Resources */, + C7124E4E2638528F00929318 /* JetpackPrologueViewController.xib in Resources */, + F465978628E65E1800D5F49A /* blue-on-white-icon-app-83.5@2x.png in Resources */, + FABB1FD62602FC2C00C8785C /* CustomizeInsightsCell.xib in Resources */, + FABB1FD92602FC2C00C8785C /* oswald_upper.ttf in Resources */, + F46597F528E669D400D5F49A /* spectrum-on-white-icon-app-76@2x.png in Resources */, + FABB1FDB2602FC2C00C8785C /* xhtmlValidatorTemplate.xhtml in Resources */, + FABB1FDC2602FC2C00C8785C /* PostingActivityDay.xib in Resources */, + FABB1FDD2602FC2C00C8785C /* reader-cards-success.json in Resources */, + FABB1FDF2602FC2C00C8785C /* ReaderRelatedPostsCell.xib in Resources */, + FABB1FE52602FC2C00C8785C /* RevisionOperation.xib in Resources */, + FABB1FE82602FC2C00C8785C /* TitleBadgeDisclosureCell.xib in Resources */, + FABB1FE92602FC2C00C8785C /* TextWithAccessoryButtonCell.xib in Resources */, + F465976E28E4669200D5F49A /* cool-green-icon-app-76@2x.png in Resources */, + F41E4EC028F22932001880C6 /* spectrum-icon-app-83.5@2x.png in Resources */, + FABB1FEA2602FC2C00C8785C /* SiteStatsDetailTableViewController.storyboard in Resources */, + FABB1FED2602FC2C00C8785C /* InlineEditableNameValueCell.xib in Resources */, + 08BA4BC8298A9AD500015BD2 /* JetpackInstallPluginLogoAnimation_rtl.json in Resources */, + FABB1FEE2602FC2C00C8785C /* AppImages.xcassets in Resources */, + F41E4EC328F22932001880C6 /* spectrum-icon-app-60@3x.png in Resources */, + FABB1FEF2602FC2C00C8785C /* MediaQuotaCell.xib in Resources */, + FABB1FF02602FC2C00C8785C /* InfoPlist.strings in Resources */, + FABB1FF22602FC2C00C8785C /* ReaderSavedPostUndoCell.xib in Resources */, + F41E4EAB28F20DF9001880C6 /* stroke-light-icon-app-60@2x.png in Resources */, + FABB1FF32602FC2C00C8785C /* ReaderPostCardCell.xib in Resources */, + FABB1FF52602FC2C00C8785C /* RevisionsTableViewCell.xib in Resources */, + FABB1FF72602FC2C00C8785C /* Revisions.storyboard in Resources */, + FABB1FF82602FC2C00C8785C /* ActivityDetailViewController.storyboard in Resources */, + FABB1FF92602FC2C00C8785C /* ReaderDetailHeaderView.xib in Resources */, + FABB1FFA2602FC2C00C8785C /* ReaderTagsFooter.xib in Resources */, + FABB1FFB2602FC2C00C8785C /* PostingActivityLegend.xib in Resources */, + FABB1FFC2602FC2C00C8785C /* PostingActivityViewController.storyboard in Resources */, + FABB1FFD2602FC2C00C8785C /* TwoColumnCell.xib in Resources */, + FABB1FFE2602FC2C00C8785C /* Notifications.storyboard in Resources */, + FABB20022602FC2C00C8785C /* StatsTableFooter.xib in Resources */, + F41E4EB728F225DB001880C6 /* stroke-dark-icon-app-60@2x.png in Resources */, + F41E4EB928F225DB001880C6 /* stroke-dark-icon-app-76@2x.png in Resources */, + C7F7AC76261CF1F300CE547F /* JetpackLoginErrorViewController.xib in Resources */, + F46597F628E669D400D5F49A /* spectrum-on-white-icon-app-60@2x.png in Resources */, + F46597A828E6600800D5F49A /* jetpack-light-icon-app-60@2x.png in Resources */, + FABB20052602FC2C00C8785C /* ThemeBrowserSectionHeaderView.xib in Resources */, + FABB20072602FC2C00C8785C /* SitePromptView.xib in Resources */, + FABB20082602FC2C00C8785C /* DeleteSite.storyboard in Resources */, + FABB200A2602FC2C00C8785C /* Noticons.ttf in Resources */, + FAE4CA6B2732C094003BFDFE /* QuickStartPromptViewController.xib in Resources */, + FABB200B2602FC2C00C8785C /* LoginEpilogue.storyboard in Resources */, + F41E4E9828F20802001880C6 /* white-on-pink-icon-app-60@3x.png in Resources */, + 98B88467261E4E4E007ED7F8 /* LikeUserTableViewCell.xib in Resources */, + FABB200F2602FC2C00C8785C /* TopTotalsCell.xib in Resources */, + 08EA036B29C9C3A000B72A87 /* Colors.xcassets in Resources */, + FABB20102602FC2C00C8785C /* PostPost.storyboard in Resources */, + FABB20112602FC2C00C8785C /* SpaceMono-Bold.ttf in Resources */, + F46597E828E6698D00D5F49A /* spectrum-on-black-icon-app-60@2x.png in Resources */, + F41E4EE128F24623001880C6 /* 3d-icon-app-76@2x.png in Resources */, + F41E4E9628F20802001880C6 /* white-on-pink-icon-app-83.5@2x.png in Resources */, + FABB20142602FC2C00C8785C /* CommentsList.storyboard in Resources */, + F46597B328E6605E00D5F49A /* neu-green-icon-app-60@2x.png in Resources */, + F46597E728E6698D00D5F49A /* spectrum-on-black-icon-app-76@2x.png in Resources */, + FABB20162602FC2C00C8785C /* TabbedTotalsCell.xib in Resources */, + FABB20172602FC2C00C8785C /* PageListTableViewCell.xib in Resources */, + 8BE6F92E27EE27E10008BDC7 /* BlogDashboardPostCardGhostCell.xib in Resources */, + FABB20182602FC2C00C8785C /* NoteBlockImageTableViewCell.xib in Resources */, + FABB201B2602FC2C00C8785C /* SignupEpilogue.storyboard in Resources */, + F465980B28E66A5B00D5F49A /* white-on-blue-icon-app-60@2x.png in Resources */, + FABB201D2602FC2C00C8785C /* PostStatsTitleCell.xib in Resources */, + F46597FD28E66A1100D5F49A /* white-on-black-icon-app-60@2x.png in Resources */, + FABB20212602FC2C00C8785C /* ReaderDetailToolbar.xib in Resources */, + FABB20222602FC2C00C8785C /* RewindStatusTableViewCell.xib in Resources */, + FABB20232602FC2C00C8785C /* PageListSectionHeaderView.xib in Resources */, + 8091019629078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.xib in Resources */, + 98DCF4A8275945E00008630F /* ReaderDetailNoCommentCell.xib in Resources */, + FABB20242602FC2C00C8785C /* JetpackScanThreatDetailsViewController.xib in Resources */, + F41E4E9F28F20AB8001880C6 /* white-on-celadon-icon-app-76@2x.png in Resources */, + FABB20252602FC2C00C8785C /* CollabsableHeaderFilterCollectionViewCell.xib in Resources */, + FABB20262602FC2C00C8785C /* ReplyTextView.xib in Resources */, + F41E4EB528F225DB001880C6 /* stroke-dark-icon-app-76.png in Resources */, + FABB20272602FC2C00C8785C /* LibreBaskerville-Regular.ttf in Resources */, + F41E4EC428F22932001880C6 /* spectrum-icon-app-60@2x.png in Resources */, + FABB20282602FC2C00C8785C /* StatsCellHeader.xib in Resources */, + FABB20292602FC2C00C8785C /* JetpackScanHistoryViewController.xib in Resources */, + F46597CA28E668B900D5F49A /* neumorphic-light-icon-app-60@2x.png in Resources */, + F41E4EA328F20AB8001880C6 /* white-on-celadon-icon-app-60@2x.png in Resources */, + F46597DC28E6694200D5F49A /* pink-on-white-icon-app-60@3x.png in Resources */, + FABB202A2602FC2C00C8785C /* PostSignUpInterstitialViewController.xib in Resources */, + FABB202B2602FC2C00C8785C /* CollapsableHeaderViewController.xib in Resources */, + F465977228E4669200D5F49A /* cool-green-icon-app-83.5@2x.png in Resources */, + F46597A528E6600800D5F49A /* jetpack-light-icon-app-60@3x.png in Resources */, + F41E4EE228F24623001880C6 /* 3d-icon-app-83.5@2x.png in Resources */, + FABB202C2602FC2C00C8785C /* ReaderRelatedPostsSectionHeaderView.xib in Resources */, + C77FC90C28009C7000726F00 /* OnboardingQuestionsPromptViewController.xib in Resources */, + FABB202D2602FC2C00C8785C /* PostingActivityMonth.xib in Resources */, + F41E4EE328F24623001880C6 /* 3d-icon-app-60@3x.png in Resources */, + FABB202E2602FC2C00C8785C /* Menus.storyboard in Resources */, + FABB202F2602FC2C00C8785C /* CountriesMapView.xib in Resources */, + F465978528E65E1800D5F49A /* blue-on-white-icon-app-60@2x.png in Resources */, + FABB20302602FC2C00C8785C /* OverviewCell.xib in Resources */, + F46597E928E6698D00D5F49A /* spectrum-on-black-icon-app-83.5@2x.png in Resources */, + FABB20322602FC2C00C8785C /* JetpackRemoteInstallStateView.xib in Resources */, + FABB20352602FC2C00C8785C /* TemplatePreviewViewController.xib in Resources */, + F46597BB28E6687800D5F49A /* neumorphic-dark-icon-app-76@2x.png in Resources */, + FABB20372602FC2C00C8785C /* NoResults.storyboard in Resources */, + FABB20392602FC2C00C8785C /* ActivityListSectionHeaderView.xib in Resources */, + FABB203D2602FC2C00C8785C /* Localizable.strings in Resources */, + 17039226282E6D2F00F602E9 /* ViewsVisitorsLineChartCell.xib in Resources */, + FABB28472603067C00C8785C /* Launch Screen.storyboard in Resources */, + F465978F28E65F8A00D5F49A /* celadon-on-white-icon-app-60@2x.png in Resources */, + FABB203F2602FC2C00C8785C /* ReaderRecommendedSiteCardCell.xib in Resources */, + FE23EB4A26E7C91F005A1698 /* richCommentTemplate.html in Resources */, + FABB20402602FC2C00C8785C /* Nunito-Bold.ttf in Resources */, + F41E4EAA28F20DF9001880C6 /* stroke-light-icon-app-76.png in Resources */, + F46597FE28E66A1100D5F49A /* white-on-black-icon-app-76.png in Resources */, + FABB20412602FC2C00C8785C /* ImageCropViewController.xib in Resources */, + FABB20422602FC2C00C8785C /* Reader.storyboard in Resources */, + FABB20442602FC2C00C8785C /* RestoreWarningView.xib in Resources */, + F41E4ED628F2424B001880C6 /* dark-glow-icon-app-83.5@2x.png in Resources */, + FABB20452602FC2C00C8785C /* JetpackLoginViewController.xib in Resources */, + C77FC9122800CAC100726F00 /* OnboardingEnableNotificationsViewController.xib in Resources */, + FABB20472602FC2C00C8785C /* StatsNoDataRow.xib in Resources */, + FABB20482602FC2C00C8785C /* JetpackScanThreatCell.xib in Resources */, + FABB20492602FC2C00C8785C /* JetpackScanStatusCell.xib in Resources */, + F465977028E4669200D5F49A /* cool-green-icon-app-60@3x.png in Resources */, + F465979228E65F8A00D5F49A /* celadon-on-white-icon-app-60@3x.png in Resources */, + FABB204A2602FC2C00C8785C /* ActivityTableViewCell.xib in Resources */, + FABB204C2602FC2C00C8785C /* StatsGhostTitleCell.xib in Resources */, + FABB204D2602FC2C00C8785C /* NoteBlockButtonTableViewCell.xib in Resources */, + FABB204F2602FC2C00C8785C /* NoteBlockCommentTableViewCell.xib in Resources */, + F465978828E65E1800D5F49A /* blue-on-white-icon-app-60@3x.png in Resources */, + F41E4EF028F247D3001880C6 /* white-on-green-icon-app-76@2x.png in Resources */, + FABB20502602FC2C00C8785C /* PostCardCell.xib in Resources */, + FABB20552602FC2C00C8785C /* ReaderDetailFeaturedImageView.xib in Resources */, + F41E4EA028F20AB8001880C6 /* white-on-celadon-icon-app-60@3x.png in Resources */, + F465979328E65F8A00D5F49A /* celadon-on-white-icon-app-83.5@2x.png in Resources */, + FABB20562602FC2C00C8785C /* loader.html in Resources */, + FABB20582602FC2C00C8785C /* JetpackRestoreHeaderView.xib in Resources */, + F41E4ECD28F23E00001880C6 /* green-on-white-icon-app-76.png in Resources */, + FABB205A2602FC2C00C8785C /* PagedViewController.xib in Resources */, + F41E4EAE28F20DF9001880C6 /* stroke-light-icon-app-76@2x.png in Resources */, + F41E4EEC28F247D3001880C6 /* white-on-green-icon-app-83.5@2x.png in Resources */, + FABB205C2602FC2C00C8785C /* StatsGhostChartCell.xib in Resources */, + FABB205D2602FC2C00C8785C /* ReaderGapMarkerCell.xib in Resources */, + F46597E028E6694200D5F49A /* pink-on-white-icon-app-76.png in Resources */, + FABB205E2602FC2C00C8785C /* postPreview.html in Resources */, + FABB20602602FC2C00C8785C /* MediaSizeSliderCell.xib in Resources */, + FABB20622602FC2C00C8785C /* ReaderBlockedSiteCell.xib in Resources */, + FABB20632602FC2C00C8785C /* QuickStartChecklistCell.xib in Resources */, + FABB20652602FC2C00C8785C /* SiteSegmentsCell.xib in Resources */, + FABB20662602FC2C00C8785C /* ReaderSiteStreamHeader.xib in Resources */, + FABB20692602FC2C00C8785C /* ReaderTopicsCardCell.xib in Resources */, + F465980928E66A5B00D5F49A /* white-on-blue-icon-app-76.png in Resources */, + FABB206B2602FC2C00C8785C /* richEmbedTemplate.html in Resources */, + FABB206D2602FC2C00C8785C /* NoteBlockUserTableViewCell.xib in Resources */, + FABB20702602FC2C00C8785C /* ReaderListStreamHeader.xib in Resources */, + F465979B28E65FC800D5F49A /* dark-green-icon-app-76@2x.png in Resources */, + FABB20722602FC2C00C8785C /* MyProfileHeaderView.xib in Resources */, + FABB20742602FC2C00C8785C /* RestorePageTableViewCell.xib in Resources */, + FABB20762602FC2C00C8785C /* WordPressShare.js in Resources */, + C7234A512832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.xib in Resources */, + FE3E427926A868EC00C596CE /* ListTableHeaderView.xib in Resources */, + F465979128E65F8A00D5F49A /* celadon-on-white-icon-app-76.png in Resources */, + F46597C628E668B900D5F49A /* neumorphic-light-icon-app-83.5@2x.png in Resources */, + FABB20772602FC2C00C8785C /* world-map.svg in Resources */, + FABB20782602FC2C00C8785C /* NoteBlockTextTableViewCell.xib in Resources */, + C3234F4D27EB96A5004ADB29 /* IntentCell.xib in Resources */, + 9822A8562624D01800FD8A03 /* UserProfileSiteCell.xib in Resources */, + 803C493F283A7C2200003E9B /* QuickStartChecklistHeader.xib in Resources */, + FABB207D2602FC2C00C8785C /* RELEASE-NOTES.txt in Resources */, + F46597BD28E6687800D5F49A /* neumorphic-dark-icon-app-76.png in Resources */, + FABB207E2602FC2C00C8785C /* EpilogueUserInfoCell.xib in Resources */, + FABB207F2602FC2C00C8785C /* ReaderTagStreamHeader.xib in Resources */, + F465978428E65E1800D5F49A /* blue-on-white-icon-app-76@2x.png in Resources */, + F46597EB28E6698D00D5F49A /* spectrum-on-black-icon-app-60@3x.png in Resources */, + FABB20812602FC2C00C8785C /* PostCompactCell.xib in Resources */, + FABB20862602FC2C00C8785C /* n.caf in Resources */, + F465977D28E6598900D5F49A /* black-on-white-icon-app-83.5@2x.png in Resources */, + F41E4EDA28F2424B001880C6 /* dark-glow-icon-app-76@2x.png in Resources */, + F465979D28E65FC800D5F49A /* dark-green-icon-app-83.5@2x.png in Resources */, + FABB20882602FC2C00C8785C /* People.storyboard in Resources */, + F41E4ED728F2424B001880C6 /* dark-glow-icon-app-76.png in Resources */, + F46597B128E6605E00D5F49A /* neu-green-icon-app-76@2x.png in Resources */, + F41E4E9728F20802001880C6 /* white-on-pink-icon-app-76@2x.png in Resources */, + F41E4EB828F225DB001880C6 /* stroke-dark-icon-app-60@3x.png in Resources */, + F46597EA28E6698D00D5F49A /* spectrum-on-black-icon-app-76.png in Resources */, + 98A047762821D069001B4E2D /* BloggingPromptsViewController.storyboard in Resources */, + FABB208B2602FC2C00C8785C /* LatestPostSummaryCell.xib in Resources */, + F41E4ECF28F23E00001880C6 /* green-on-white-icon-app-83.5@2x.png in Resources */, + F46597C728E668B900D5F49A /* neumorphic-light-icon-app-76@2x.png in Resources */, + FABB208D2602FC2C00C8785C /* RestorePostTableViewCell.xib in Resources */, + FABB208E2602FC2C00C8785C /* Plans.storyboard in Resources */, + FABB208F2602FC2C00C8785C /* PluginDirectoryCollectionViewCell.xib in Resources */, + F41E4ECE28F23E00001880C6 /* green-on-white-icon-app-76@2x.png in Resources */, + FABB20902602FC2C00C8785C /* ReaderWelcomeBanner.xib in Resources */, + FE3E427B26A8690A00C596CE /* ListSimpleOverlayView.xib in Resources */, + FABB20952602FC2C00C8785C /* StatsGhostPostingActivityCell.xib in Resources */, + FABB20962602FC2C00C8785C /* ColorPalette.xcassets in Resources */, + FABB20972602FC2C00C8785C /* RestoreCompleteView.xib in Resources */, + F465977B28E6598900D5F49A /* black-on-white-icon-app-76@2x.png in Resources */, + FABB20992602FC2C00C8785C /* RegisterDomain.storyboard in Resources */, + FABB209A2602FC2C00C8785C /* PostListFooterView.xib in Resources */, + 98E5501A265C977E00B4BE9A /* ReaderDetailLikesView.xib in Resources */, + FABB209B2602FC2C00C8785C /* acknowledgements.html in Resources */, + F46597BF28E6687800D5F49A /* neumorphic-dark-icon-app-83.5@2x.png in Resources */, + F465980C28E66A5B00D5F49A /* white-on-blue-icon-app-83.5@2x.png in Resources */, + F41E4E9928F20802001880C6 /* white-on-pink-icon-app-60@2x.png in Resources */, + F465977928E6598900D5F49A /* black-on-white-icon-app-76.png in Resources */, + FABB209C2602FC2C00C8785C /* ReaderDetailViewController.storyboard in Resources */, + FA347AF026EB6E300096604B /* GrowAudienceCell.xib in Resources */, + F46597DD28E6694200D5F49A /* pink-on-white-icon-app-60@2x.png in Resources */, + FABB209D2602FC2C00C8785C /* EditCommentViewController.xib in Resources */, + FA347AF326EB7A420096604B /* StatsGhostGrowAudienceCell.xib in Resources */, + F465979E28E65FC800D5F49A /* dark-green-icon-app-76.png in Resources */, + FABB209E2602FC2C00C8785C /* GutenGhostView.xib in Resources */, + FABB20A12602FC2C00C8785C /* MenuItemEditing.storyboard in Resources */, + F46597B028E6605E00D5F49A /* neu-green-icon-app-76.png in Resources */, + F41E4EAC28F20DF9001880C6 /* stroke-light-icon-app-60@3x.png in Resources */, + FABB20A22602FC2C00C8785C /* CountriesCell.xib in Resources */, + FABB20A32602FC2C00C8785C /* RegisterDomainSectionHeaderView.xib in Resources */, + FABB20A42602FC2C00C8785C /* BlogDetailsSectionHeaderView.xib in Resources */, + F46597DE28E6694200D5F49A /* pink-on-white-icon-app-76@2x.png in Resources */, + FABB20A52602FC2C00C8785C /* CountriesMapCell.xib in Resources */, + FABB20A62602FC2C00C8785C /* WPWebViewController.xib in Resources */, + FABB20A72602FC2C00C8785C /* ReaderCrossPostCell.xib in Resources */, + F46597B428E6605E00D5F49A /* neu-green-icon-app-60@3x.png in Resources */, + FABB20A82602FC2C00C8785C /* StatsStackViewCell.xib in Resources */, + F41E4EA128F20AB8001880C6 /* white-on-celadon-icon-app-83.5@2x.png in Resources */, + FABB20AA2602FC2C00C8785C /* RegisterDomainDetailsErrorSectionFooter.xib in Resources */, + 98BC522E27F62B6300D6E8C2 /* BloggingPromptsFeatureDescriptionView.xib in Resources */, + F46597A928E6600800D5F49A /* jetpack-light-icon-app-76@2x.png in Resources */, + 98BAA7C226F925F70073A2F9 /* InlineEditableSingleLineCell.xib in Resources */, + FABB20AB2602FC2C00C8785C /* Pages.storyboard in Resources */, + F46597F228E669D400D5F49A /* spectrum-on-white-icon-app-76.png in Resources */, + FABB20AC2602FC2C00C8785C /* Shrikhand-Regular.ttf in Resources */, + FABB20AD2602FC2C00C8785C /* StatsGhostSingleRowCell.xib in Resources */, + F46597F428E669D400D5F49A /* spectrum-on-white-icon-app-60@3x.png in Resources */, + 9801E686274EEC19002FDDB6 /* ReaderDetailCommentsHeader.xib in Resources */, + F41E4ED828F2424B001880C6 /* dark-glow-icon-app-60@3x.png in Resources */, + F46597A628E6600800D5F49A /* jetpack-light-icon-app-83.5@2x.png in Resources */, + FABB20AE2602FC2C00C8785C /* ThemeBrowser.storyboard in Resources */, + FABB20AF2602FC2C00C8785C /* StatsGhostTabbedCell.xib in Resources */, + F41E4ED928F2424B001880C6 /* dark-glow-icon-app-60@2x.png in Resources */, + FABB20B02602FC2C00C8785C /* CategorySectionTableViewCell.xib in Resources */, + FABB20B22602FC2C00C8785C /* ExpandableCell.xib in Resources */, + F465977C28E6598900D5F49A /* black-on-white-icon-app-60@2x.png in Resources */, + F41E4EED28F247D3001880C6 /* white-on-green-icon-app-60@3x.png in Resources */, + FABB20B52602FC2C00C8785C /* richEmbedScript.js in Resources */, + 98E5D4932620C2B40074A56A /* UserProfileSectionHeader.xib in Resources */, + FABB20B72602FC2C00C8785C /* PluginListCell.xib in Resources */, + FABB20B82602FC2C00C8785C /* SiteStatsDashboard.storyboard in Resources */, + FABB20B92602FC2C00C8785C /* PostStatsTableViewController.storyboard in Resources */, + F41E4EA228F20AB8001880C6 /* white-on-celadon-icon-app-76.png in Resources */, + FABB20BA2602FC2C00C8785C /* ReaderSelectInterestsViewController.xib in Resources */, + F465980A28E66A5B00D5F49A /* white-on-blue-icon-app-60@3x.png in Resources */, + FABB20BB2602FC2C00C8785C /* StatsTwoColumnRow.xib in Resources */, + F465977128E4669200D5F49A /* cool-green-icon-app-60@2x.png in Resources */, + FABB20BC2602FC2C00C8785C /* StatsTotalRow.xib in Resources */, + FABB20BD2602FC2C00C8785C /* ViewMoreRow.xib in Resources */, + FABB20BE2602FC2C00C8785C /* SiteSegmentsWizardContent.xib in Resources */, + F41E4EAD28F20DF9001880C6 /* stroke-light-icon-app-83.5@2x.png in Resources */, + F46597B228E6605E00D5F49A /* neu-green-icon-app-83.5@2x.png in Resources */, + 98F9FB2F270282C200ADF552 /* CommentModerationBar.xib in Resources */, + 982D9A0126F922C100AA794C /* InlineEditableMultiLineCell.xib in Resources */, + FABB20BF2602FC2C00C8785C /* DetailDataCell.xib in Resources */, + F465980028E66A1100D5F49A /* white-on-black-icon-app-83.5@2x.png in Resources */, + F41E4ECC28F23E00001880C6 /* green-on-white-icon-app-60@2x.png in Resources */, + FABB20C02602FC2C00C8785C /* StatsGhostTwoColumnCell.xib in Resources */, + 17C1D6F626711ED0006C8970 /* Emoji.txt in Resources */, + F465976F28E4669200D5F49A /* cool-green-icon-app-76.png in Resources */, + F46597C928E668B900D5F49A /* neumorphic-light-icon-app-76.png in Resources */, + 8F228614F588222E57F2E8DE /* TimeZoneSearchHeaderView.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FAF64B9C2637DEEC00E8A1DF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FF27168D1CAAC87A0006E2D4 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 0107E0B228F97D5000DE87DB /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-JetpackStatsWidgets-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 0107E0E428F97D5000DE87DB /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-JetpackStatsWidgets/Pods-JetpackStatsWidgets-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/Gridicons/Gridicons.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Gridicons.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JetpackStatsWidgets/Pods-JetpackStatsWidgets-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 0107E13928FE9DB200DE87DB /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-JetpackIntents-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 0107E14E28FE9DB200DE87DB /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-JetpackIntents/Pods-JetpackIntents-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JetpackIntents/Pods-JetpackIntents-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 09607CE7281C9CA6002D2E5A /* [Lint] Check AppLocalizedString usage */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "[Lint] Check AppLocalizedString usage"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash -eu"; + shellScript = "\"$SRCROOT/../Scripts/BuildPhases/LintAppLocalizedStringsUsage.sh\"\n"; + showEnvVarsInLog = 0; + }; + 09607CE8281C9D0F002D2E5A /* [Lint] Check AppLocalizedString usage */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "[Lint] Check AppLocalizedString usage"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = "/bin/bash -eu"; + shellScript = "\"$SRCROOT/../Scripts/BuildPhases/LintAppLocalizedStringsUsage.sh\"\n"; + showEnvVarsInLog = 0; + }; + 096A92FB26E2A05400448C68 /* Generate Secrets / Credentials */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "$(SRCROOT)/../Scripts/BuildPhases/GenerateCredentials.xcfilelist", + ); + inputPaths = ( + ); + name = "Generate Secrets / Credentials"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(BUILD_DIR)/Secrets/Secrets.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "$SRCROOT/../Scripts/BuildPhases/GenerateCredentials.sh\n"; + showEnvVarsInLog = 0; + }; + 0AA1A8899C01FEF3599F6FCF /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WordPressStatsWidgets-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 196E4B8866CD8E865DB892D9 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-WordPressStatsWidgets/Pods-WordPressStatsWidgets-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/Gridicons/Gridicons.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Gridicons.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressStatsWidgets/Pods-WordPressStatsWidgets-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 2ACEBC2718E9AC320D2B2858 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-WordPressIntents/Pods-WordPressIntents-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressIntents/Pods-WordPressIntents-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 2DF08408C90B90D744C56E02 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WordPressScreenshotGeneration-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 36FB55DCF44141E140E108F8 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-JetpackDraftActionExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 37399571B0D91BBEAE911024 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Apps-WordPress/Pods-Apps-WordPress-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/Sentry/Sentry.framework", + "${BUILT_PRODUCTS_DIR}/SentryPrivate/SentryPrivate.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskCommonUISDK/CommonUISDK.framework/CommonUISDK", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskCoreSDK/ZendeskCoreSDK.framework/ZendeskCoreSDK", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskMessagingAPISDK/MessagingAPI.framework/MessagingAPI", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskMessagingSDK/MessagingSDK.framework/MessagingSDK", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskSDKConfigurationsSDK/SDKConfigurations.framework/SDKConfigurations", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskSupportProvidersSDK/SupportProvidersSDK.framework/SupportProvidersSDK", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskSupportSDK/SupportSDK.framework/SupportSDK", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SentryPrivate.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CommonUISDK.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZendeskCoreSDK.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MessagingAPI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MessagingSDK.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDKConfigurations.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SupportProvidersSDK.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SupportSDK.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Apps-WordPress/Pods-Apps-WordPress-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 37F48D4CB364EA4BCC1EAE82 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WordPressUITests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3F32E4AE270EAF5100A33D51 /* Generate Credentials */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "$(SRCROOT)/../Scripts/BuildPhases/GenerateCredentials.xcfilelist", + ); + inputPaths = ( + ); + name = "Generate Credentials"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(BUILD_DIR)/Secrets/Secrets.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "$SRCROOT/../Scripts/BuildPhases/GenerateCredentials.sh\n"; + }; + 42C1BDE416A90FA718A28797 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-WordPressTest/Pods-WordPressTest-resources.sh", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/content-functions.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/editor-behavior-overrides.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/editor-style-overrides.css", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/gutenberg-observer.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/inject-css.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/insert-block.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/local-storage-overrides.json", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/prevent-autosaves.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/wp-bar-override.css", + "${PODS_ROOT}/Gutenberg/src/block-support/supported-blocks.json", + "${PODS_ROOT}/Gutenberg/resources/unsupported-block-editor/external-style-overrides.css", + "${PODS_ROOT}/Gutenberg/resources/unsupported-block-editor/extra-localstorage-entries.js", + "${PODS_ROOT}/Gutenberg/resources/unsupported-block-editor/remove-nux.js", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPress-Aztec-iOS/WordPress-Aztec-iOS.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/content-functions.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/editor-behavior-overrides.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/editor-style-overrides.css", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/gutenberg-observer.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/inject-css.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/insert-block.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/local-storage-overrides.json", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/prevent-autosaves.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/wp-bar-override.css", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/supported-blocks.json", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/external-style-overrides.css", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/extra-localstorage-entries.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/remove-nux.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPress-Aztec-iOS.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressTest/Pods-WordPressTest-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 4C304224F0F810A17D96A402 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-JetpackShareExtension/Pods-JetpackShareExtension-resources.sh", + "${PODS_ROOT}/Down/Resources/DownView.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/Gridicons/Gridicons.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPress-Aztec-iOS/WordPress-Aztec-iOS.bundle", + "${PODS_ROOT}/WordPress-Editor-iOS/WordPressEditor/WordPressEditor/Assets/aztec.png", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DownView.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Gridicons.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPress-Aztec-iOS.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/aztec.png", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JetpackShareExtension/Pods-JetpackShareExtension-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 4F4D5C2BB6478A3E90ADC3C5 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WordPressShareExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 552DBF4AE076B0EE1EC2D94D /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-WordPressNotificationServiceExtension/Pods-WordPressNotificationServiceExtension-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressNotificationServiceExtension/Pods-WordPressNotificationServiceExtension-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 72BB9EE3CC91D92F3735F4F3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-JetpackShareExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 74CC431A202B5C73000DAE1A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WordPressDraftActionExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 80F6D02028EE866A00953C1A /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-JetpackNotificationServiceExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 80F6D04E28EE866A00953C1A /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-JetpackNotificationServiceExtension/Pods-JetpackNotificationServiceExtension-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JetpackNotificationServiceExtension/Pods-JetpackNotificationServiceExtension-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 825F0EBF1F7EBF7C00321528 /* App Icons: Add Version For Internal Releases */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "App Icons: Add Version For Internal Releases"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "#!/bin/sh\n\n# This script adds the version number to the icon of internal releases.\n\nexport PATH=/opt/local/bin/:/opt/local/sbin:$PATH:/usr/local/bin:\nif [ \"${CONFIGURATION}\" != \"Release-Internal\" ]; then\nexit 0;\nfi\n\nsh ../Scripts/BuildPhases/AddVersionToIcons.sh\n"; + }; + 83D79708413A3DA10638659F /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension-resources.sh", + "${PODS_ROOT}/Down/Resources/DownView.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/Gridicons/Gridicons.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPress-Aztec-iOS/WordPress-Aztec-iOS.bundle", + "${PODS_ROOT}/WordPress-Editor-iOS/WordPressEditor/WordPressEditor/Assets/aztec.png", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DownView.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Gridicons.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPress-Aztec-iOS.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/aztec.png", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressShareExtension/Pods-WordPressShareExtension-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 920B9A6DAD47189622A86A9C /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Apps-WordPress/Pods-Apps-WordPress-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/Automattic-Tracks-iOS/DataModel.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/CropViewController/TOCropViewControllerBundle.bundle", + "${PODS_ROOT}/Down/Resources/DownView.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/Gridicons/Gridicons.bundle", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/content-functions.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/editor-behavior-overrides.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/editor-style-overrides.css", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/gutenberg-observer.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/inject-css.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/insert-block.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/local-storage-overrides.json", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/prevent-autosaves.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/wp-bar-override.css", + "${PODS_ROOT}/Gutenberg/src/block-support/supported-blocks.json", + "${PODS_ROOT}/Gutenberg/resources/unsupported-block-editor/external-style-overrides.css", + "${PODS_ROOT}/Gutenberg/resources/unsupported-block-editor/extra-localstorage-entries.js", + "${PODS_ROOT}/Gutenberg/resources/unsupported-block-editor/remove-nux.js", + "${PODS_CONFIGURATION_BUILD_DIR}/Kanvas/Kanvas.bundle", + "${BUILT_PRODUCTS_DIR}/MediaEditor/MediaEditor.framework/MediaEditorDrawing.storyboardc", + "${BUILT_PRODUCTS_DIR}/MediaEditor/MediaEditor.framework/MediaEditorFilters.storyboardc", + "${BUILT_PRODUCTS_DIR}/MediaEditor/MediaEditor.framework/MediaEditorHub.storyboardc", + "${PODS_CONFIGURATION_BUILD_DIR}/MediaEditor/MediaEditor.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + "${PODS_ROOT}/SVProgressHUD/SVProgressHUD/SVProgressHUD.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WPMediaPicker/WPMediaPicker.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPress-Aztec-iOS/WordPress-Aztec-iOS.bundle", + "${PODS_ROOT}/WordPress-Editor-iOS/WordPressEditor/WordPressEditor/Assets/aztec.png", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressAuthenticator/WordPressAuthenticatorResources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/AppCenter/AppCenterDistributeResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DataModel.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/TOCropViewControllerBundle.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DownView.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Gridicons.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/content-functions.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/editor-behavior-overrides.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/editor-style-overrides.css", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/gutenberg-observer.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/inject-css.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/insert-block.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/local-storage-overrides.json", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/prevent-autosaves.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/wp-bar-override.css", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/supported-blocks.json", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/external-style-overrides.css", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/extra-localstorage-entries.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/remove-nux.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Kanvas.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MediaEditorDrawing.storyboardc", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MediaEditorFilters.storyboardc", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MediaEditorHub.storyboardc", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MediaEditor.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SVProgressHUD.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WPMediaPicker.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPress-Aztec-iOS.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/aztec.png", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressAuthenticatorResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AppCenterDistributeResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Apps-WordPress/Pods-Apps-WordPress-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9D186898B0632AA1273C9DE2 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension-resources.sh", + "${PODS_ROOT}/Down/Resources/DownView.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/Gridicons/Gridicons.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPress-Aztec-iOS/WordPress-Aztec-iOS.bundle", + "${PODS_ROOT}/WordPress-Editor-iOS/WordPressEditor/WordPressEditor/Assets/aztec.png", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DownView.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Gridicons.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPress-Aztec-iOS.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/aztec.png", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-WordPressDraftActionExtension/Pods-WordPressDraftActionExtension-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + A279580D198819F50031C6A3 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# sh ../run-oclint.sh \nsh ../Scripts/run-oclint.sh"; + showEnvVarsInLog = 0; + }; + BAE780768320204E29A6FE5B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WordPressNotificationServiceExtension-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + CC875D93233BCEC800595CC8 /* Set Up Simulator */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Set Up Simulator"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Close all simulators so on next launch they use the settings below\nxcrun simctl shutdown all\n\n# Disable the hardware keyboard in the simulator\ndefaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false\n"; + }; + CE51D1C75430FDDD21B27F64 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WordPressIntents-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + D880C306E1943EA76DA53078 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-JetpackDraftActionExtension/Pods-JetpackDraftActionExtension-resources.sh", + "${PODS_ROOT}/Down/Resources/DownView.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/Gridicons/Gridicons.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPress-Aztec-iOS/WordPress-Aztec-iOS.bundle", + "${PODS_ROOT}/WordPress-Editor-iOS/WordPressEditor/WordPressEditor/Assets/aztec.png", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DownView.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Gridicons.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPress-Aztec-iOS.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/aztec.png", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-JetpackDraftActionExtension/Pods-JetpackDraftActionExtension-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + E00F6488DE2D86BDC84FBB0B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Apps-WordPress-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + E0E31D6E60F2BCE2D0A53E39 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-WordPressTest-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + E1C5456F219F10E000896227 /* Copy Gutenberg JS */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Copy Gutenberg JS"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Print commands before executing them (useful for troubleshooting)\nset -x\nDEST=$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH\n\nif [[ \"$CONFIGURATION\" = *Debug* && ! \"$PLATFORM_NAME\" == *simulator ]]; then\nIP=$(ipconfig getifaddr en0)\nif [ -z \"$IP\" ]; then\nIP=$(ifconfig | grep 'inet ' | grep -v ' 127.' | cut -d\\ -f2 | awk 'NR==1{print $1}')\nfi\n\necho \"$IP\" > \"$DEST/ip.txt\"\nfi\n\nBUNDLE_FILE=\"$DEST/main.jsbundle\"\ncp ${PODS_ROOT}/Gutenberg/bundle/ios/App.js \"${BUNDLE_FILE}\"\n\nBUNDLE_ASSETS=\"$DEST/assets/\"\ncp -r ${PODS_ROOT}/Gutenberg/bundle/ios/assets/ \"${BUNDLE_ASSETS}\"\n"; + }; + EA14533C29AD874C001F3143 /* Set Up Simulator */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Set Up Simulator"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Close all simulators so on next launch they use the settings below\nxcrun simctl shutdown all\n\n# Disable the hardware keyboard in the simulator\ndefaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false\n"; + }; + F9C5CF0222CD5DB0007CEF56 /* Copy Alternate Internal Icons (if needed) */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy Alternate Internal Icons (if needed)"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Overwrite the icons in the bundle with the updated internal icons\nif [ \"${CONFIGURATION}\" != \"Release-Internal\" ]; then\nexit 0;\nfi\n\ncp -R ${PROJECT_DIR}/Resources/Icons-Internal/*.png \"${CONFIGURATION_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n"; + }; + FABB1FA72602FC2C00C8785C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Apps-Jetpack-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FABB1FA92602FC2C00C8785C /* App Icons: Add Version For Internal Releases */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "App Icons: Add Version For Internal Releases"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "#!/bin/sh\n\n# This script adds the version number to the icon of internal releases.\n\nexport PATH=/opt/local/bin/:/opt/local/sbin:$PATH:/usr/local/bin:\nif [ \"${CONFIGURATION}\" != \"Release-Internal\" ]; then\nexit 0;\nfi\n\nsh ../Scripts/BuildPhases/AddVersionToIcons.sh\n"; + }; + FABB20C12602FC2C00C8785C /* Copy Alternate Internal Icons (if needed) */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy Alternate Internal Icons (if needed)"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Overwrite the icons in the bundle with the updated internal icons\nif [ \"${CONFIGURATION}\" != \"Release-Internal\" ]; then\nexit 0;\nfi\n\ncp -R ${PROJECT_DIR}/Resources/Icons-Internal/*.png \"${CONFIGURATION_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n"; + }; + FABB264A2602FC2C00C8785C /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Apps-Jetpack/Pods-Apps-Jetpack-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/Sentry/Sentry.framework", + "${BUILT_PRODUCTS_DIR}/SentryPrivate/SentryPrivate.framework", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskCommonUISDK/CommonUISDK.framework/CommonUISDK", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskCoreSDK/ZendeskCoreSDK.framework/ZendeskCoreSDK", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskMessagingAPISDK/MessagingAPI.framework/MessagingAPI", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskMessagingSDK/MessagingSDK.framework/MessagingSDK", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskSDKConfigurationsSDK/SDKConfigurations.framework/SDKConfigurations", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskSupportProvidersSDK/SupportProvidersSDK.framework/SupportProvidersSDK", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ZendeskSupportSDK/SupportSDK.framework/SupportSDK", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SentryPrivate.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CommonUISDK.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ZendeskCoreSDK.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MessagingAPI.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MessagingSDK.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDKConfigurations.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SupportProvidersSDK.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SupportSDK.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Apps-Jetpack/Pods-Apps-Jetpack-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + FABB264B2602FC2C00C8785C /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Apps-Jetpack/Pods-Apps-Jetpack-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/Automattic-Tracks-iOS/DataModel.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/CropViewController/TOCropViewControllerBundle.bundle", + "${PODS_ROOT}/Down/Resources/DownView.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/Gridicons/Gridicons.bundle", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/content-functions.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/editor-behavior-overrides.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/editor-style-overrides.css", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/gutenberg-observer.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/inject-css.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/insert-block.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/local-storage-overrides.json", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/prevent-autosaves.js", + "${PODS_ROOT}/Gutenberg/gutenberg/packages/react-native-bridge/common/gutenberg-web-single-block/wp-bar-override.css", + "${PODS_ROOT}/Gutenberg/src/block-support/supported-blocks.json", + "${PODS_ROOT}/Gutenberg/resources/unsupported-block-editor/external-style-overrides.css", + "${PODS_ROOT}/Gutenberg/resources/unsupported-block-editor/extra-localstorage-entries.js", + "${PODS_ROOT}/Gutenberg/resources/unsupported-block-editor/remove-nux.js", + "${PODS_CONFIGURATION_BUILD_DIR}/Kanvas/Kanvas.bundle", + "${BUILT_PRODUCTS_DIR}/MediaEditor/MediaEditor.framework/MediaEditorDrawing.storyboardc", + "${BUILT_PRODUCTS_DIR}/MediaEditor/MediaEditor.framework/MediaEditorFilters.storyboardc", + "${BUILT_PRODUCTS_DIR}/MediaEditor/MediaEditor.framework/MediaEditorHub.storyboardc", + "${PODS_CONFIGURATION_BUILD_DIR}/MediaEditor/MediaEditor.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + "${PODS_ROOT}/SVProgressHUD/SVProgressHUD/SVProgressHUD.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WPMediaPicker/WPMediaPicker.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPress-Aztec-iOS/WordPress-Aztec-iOS.bundle", + "${PODS_ROOT}/WordPress-Editor-iOS/WordPressEditor/WordPressEditor/Assets/aztec.png", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressAuthenticator/WordPressAuthenticatorResources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressShared/WordPressShared.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/WordPressUI/WordPressUIResources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/AppCenter/AppCenterDistributeResources.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DataModel.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/TOCropViewControllerBundle.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/DownView.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Gridicons.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/content-functions.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/editor-behavior-overrides.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/editor-style-overrides.css", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/gutenberg-observer.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/inject-css.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/insert-block.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/local-storage-overrides.json", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/prevent-autosaves.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/wp-bar-override.css", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/supported-blocks.json", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/external-style-overrides.css", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/extra-localstorage-entries.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/remove-nux.js", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Kanvas.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MediaEditorDrawing.storyboardc", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MediaEditorFilters.storyboardc", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MediaEditorHub.storyboardc", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MediaEditor.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SVProgressHUD.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WPMediaPicker.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPress-Aztec-iOS.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/aztec.png", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressAuthenticatorResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressShared.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/WordPressUIResources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AppCenterDistributeResources.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Apps-Jetpack/Pods-Apps-Jetpack-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + FABB264C2602FC2C00C8785C /* Copy Gutenberg JS */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Copy Gutenberg JS"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Print commands before executing them (useful for troubleshooting)\nset -x\nDEST=$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH\n\nif [[ \"$CONFIGURATION\" = *Debug* && ! \"$PLATFORM_NAME\" == *simulator ]]; then\nIP=$(ipconfig getifaddr en0)\nif [ -z \"$IP\" ]; then\nIP=$(ifconfig | grep 'inet ' | grep -v ' 127.' | cut -d\\ -f2 | awk 'NR==1{print $1}')\nfi\n\necho \"$IP\" > \"$DEST/ip.txt\"\nfi\n\nBUNDLE_FILE=\"$DEST/main.jsbundle\"\ncp ${PODS_ROOT}/Gutenberg/bundle/ios/App.js \"${BUNDLE_FILE}\"\n\nBUNDLE_ASSETS=\"$DEST/assets/\"\ncp -r ${PODS_ROOT}/Gutenberg/bundle/ios/assets/ \"${BUNDLE_ASSETS}\"\n"; + }; + FFA8E2301F94E3EF0002170F /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "rake lint\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0107E0B328F97D5000DE87DB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0107E0B428F97D5000DE87DB /* Constants.m in Sources */, + 01CE5012290A890B00A9C2E0 /* TracksConfiguration.swift in Sources */, + 0107E0B528F97D5000DE87DB /* StatsWidgetEntry.swift in Sources */, + 0107E0B628F97D5000DE87DB /* HomeWidgetCache.swift in Sources */, + 0107E18C29000E2A00DE87DB /* AppStyleGuide.swift in Sources */, + 0107E0B728F97D5000DE87DB /* ListStatsView.swift in Sources */, + 0107E0B828F97D5000DE87DB /* KeychainUtils.swift in Sources */, + 0107E0B928F97D5000DE87DB /* BuildConfiguration.swift in Sources */, + 0107E0BA28F97D5000DE87DB /* TodayWidgetStats.swift in Sources */, + 0107E16128FFE99300DE87DB /* WidgetConfiguration.swift in Sources */, + 0107E0BB28F97D5000DE87DB /* StatsWidgetsService.swift in Sources */, + 0107E0BC28F97D5000DE87DB /* StatsWidgetsView.swift in Sources */, + 0107E0BD28F97D5000DE87DB /* AppLocalizedString.swift in Sources */, + 0107E0BE28F97D5000DE87DB /* MultiStatsView.swift in Sources */, + 0107E0BF28F97D5000DE87DB /* FeatureFlagOverrideStore.swift in Sources */, + 0107E11528FD7FE500DE87DB /* AppConfiguration.swift in Sources */, + 0107E0C028F97D5000DE87DB /* WordPressHomeWidgetToday.swift in Sources */, + 0107E0C128F97D5000DE87DB /* FlexibleCard.swift in Sources */, + 0107E0C228F97D5000DE87DB /* VerticalCard.swift in Sources */, + 0107E0C328F97D5000DE87DB /* ThisWeekWidgetStats.swift in Sources */, + 0107E0C428F97D5000DE87DB /* HomeWidgetAllTimeData.swift in Sources */, + 0107E0C528F97D5000DE87DB /* GroupedViewData.swift in Sources */, + 0107E0C628F97D5000DE87DB /* FeatureFlag.swift in Sources */, + 0107E0C728F97D5000DE87DB /* StatsWidgets.swift in Sources */, + 0107E18F29000EA200DE87DB /* UIColor+MurielColors.swift in Sources */, + 0107E0C828F97D5000DE87DB /* StatsValueView.swift in Sources */, + 0107E0C928F97D5000DE87DB /* SiteListProvider.swift in Sources */, + 0107E0CA28F97D5000DE87DB /* HomeWidgetThisWeekData.swift in Sources */, + 0107E0CB28F97D5000DE87DB /* WordPressHomeWidgetAllTime.swift in Sources */, + 0107E0CC28F97D5000DE87DB /* KeyValueDatabase.swift in Sources */, + 0107E0CD28F97D5000DE87DB /* CocoaLumberjack.swift in Sources */, + 0107E0CE28F97D5000DE87DB /* ListRow.swift in Sources */, + 0107E18A29000E1500DE87DB /* MurielColor.swift in Sources */, + 0107E0D028F97D5000DE87DB /* WordPressHomeWidgetThisWeek.swift in Sources */, + 0107E0D128F97D5000DE87DB /* SingleStatView.swift in Sources */, + 0107E0D228F97D5000DE87DB /* UnconfiguredView.swift in Sources */, + 0107E1852900059300DE87DB /* LocalizationConfiguration.swift in Sources */, + 0107E0D328F97D5000DE87DB /* Tracks+StatsWidgets.swift in Sources */, + 0107E0D428F97D5000DE87DB /* HomeWidgetData.swift in Sources */, + 0107E0D528F97D5000DE87DB /* HomeWidgetTodayData.swift in Sources */, + 0107E0D628F97D5000DE87DB /* AllTimeWidgetStats.swift in Sources */, + 0107E0D728F97D5000DE87DB /* Sites.intentdefinition in Sources */, + 0107E0D828F97D5000DE87DB /* LocalizableStrings.swift in Sources */, + 0107E0D928F97D5000DE87DB /* SFHFKeychainUtils.m in Sources */, + 0107E0DA28F97D5000DE87DB /* ListViewData.swift in Sources */, + 0107E0DB28F97D5000DE87DB /* Double+Stats.swift in Sources */, + 8031F34C29302A2500E8F95E /* ExtensionConfiguration.swift in Sources */, + 0107E0DC28F97D5000DE87DB /* Tracks.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 0107E13A28FE9DB200DE87DB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0107E16228FFE99300DE87DB /* WidgetConfiguration.swift in Sources */, + 0107E13B28FE9DB200DE87DB /* Sites.intentdefinition in Sources */, + 0107E13C28FE9DB200DE87DB /* ThisWeekWidgetStats.swift in Sources */, + 0107E16F28FFEF4500DE87DB /* AppConfiguration.swift in Sources */, + 0107E13D28FE9DB200DE87DB /* HomeWidgetAllTimeData.swift in Sources */, + 0107E13E28FE9DB200DE87DB /* SitesDataProvider.swift in Sources */, + 0107E13F28FE9DB200DE87DB /* HomeWidgetTodayData.swift in Sources */, + 0107E14028FE9DB200DE87DB /* CocoaLumberjack.swift in Sources */, + 0107E14128FE9DB200DE87DB /* HomeWidgetCache.swift in Sources */, + 0107E14228FE9DB200DE87DB /* AllTimeWidgetStats.swift in Sources */, + 0107E14328FE9DB200DE87DB /* TodayWidgetStats.swift in Sources */, + 0107E14428FE9DB200DE87DB /* HomeWidgetData.swift in Sources */, + 0107E14528FE9DB200DE87DB /* HomeWidgetThisWeekData.swift in Sources */, + 0107E14728FE9DB200DE87DB /* Constants.m in Sources */, + 0107E14828FE9DB200DE87DB /* SFHFKeychainUtils.m in Sources */, + 0107E14928FE9DB200DE87DB /* IntentHandler.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 1D60588E0D05DD3D006BFB54 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F551E7F523F6EA3100751212 /* FloatingActionButton.swift in Sources */, + B50C0C621EF42AF200372C65 /* TextList+WordPress.swift in Sources */, + B5722E421D51A28100F40C5E /* Notification.swift in Sources */, + 1759F1801FE1460C0003EC81 /* NoticeView.swift in Sources */, + 40C403F42215D66A00E8C894 /* TopViewedAuthorStatsRecordValue+CoreDataProperties.swift in Sources */, + 175CC17927230DC900622FB4 /* Bool+StringRepresentation.swift in Sources */, + 7E4A773920F80417001C706D /* ActivityPluginRange.swift in Sources */, + FA98A2502833F1DC003B9233 /* QuickStartChecklistConfigurable.swift in Sources */, + 7462BFD02028C49800B552D8 /* ShareNoticeViewModel.swift in Sources */, + 17A4A36920EE51870071C2CA /* Routes+Reader.swift in Sources */, + 0807CB721CE670A800CDBDAC /* WPContentSearchHelper.swift in Sources */, + C395FB262821FE7B00AE7C11 /* RemoteSiteDesign+Thumbnail.swift in Sources */, + 9A73B7152362FBAE004624A8 /* SiteStatsViewModel+AsyncBlock.swift in Sources */, + 74AF4D7C1FE417D200E3EBFE /* PostUploadOperation.swift in Sources */, + 0857BB40299275760011CBD1 /* JetpackDefaultOverlayCoordinator.swift in Sources */, + 37022D931981C19000F322B7 /* VerticallyStackedButton.m in Sources */, + 59DCA5211CC68AF3000F245F /* PageListViewController.swift in Sources */, + B5ECA6CA1DBAA0020062D7E0 /* CoreDataHelper.swift in Sources */, + FFC6ADDA1B56F366002F3C84 /* LocalCoreDataService.m in Sources */, + B5FDF9F320D842D2006D14E3 /* AztecNavigationController.swift in Sources */, + B587798619B799EB00E57C5A /* Notification+Interface.swift in Sources */, + 175A650C20B6F7280023E71B /* ReaderSaveForLater+Analytics.swift in Sources */, + 4054F4572214E3C800D261AB /* TagsCategoriesStatsRecordValue+CoreDataProperties.swift in Sources */, + 9A2B28F52192121400458F2A /* RevisionOperation.swift in Sources */, + F4FB0ACD292587D500F651F9 /* MeHeaderViewConfiguration.swift in Sources */, + 80D9CFFA29E5E6FE00FE3400 /* DashboardCardTableView.swift in Sources */, + 984B4EF320742FCC00F87888 /* ZendeskUtils.swift in Sources */, + F580C3CB23D8F9B40038E243 /* AbstractPost+Dates.swift in Sources */, + 982DDF90263238A6002B3904 /* LikeUser+CoreDataClass.swift in Sources */, + 17D9362324729FB6008B2205 /* Blog+HomepageSettings.swift in Sources */, + 1E9D544D23C4C56300F6A9E0 /* GutenbergRollout.swift in Sources */, + F1C197A62670DDB100DE1FF7 /* BloggingRemindersTracker.swift in Sources */, + 17E24F5420FCF1D900BD70A3 /* Routes+MySites.swift in Sources */, + 0828D7FA1E6E09AE00C7C7D4 /* WPAppAnalytics+Media.swift in Sources */, + 80C523A72995D73C00B1C14B /* BlazeWebViewModel.swift in Sources */, + C7A09A4D28403A34003096ED /* QRLoginURLParser.swift in Sources */, + 591AA5011CEF9BF20074934F /* Post.swift in Sources */, + 1E0FF01E242BC572008DA898 /* GutenbergWebViewController.swift in Sources */, + E161B7EC1F839345000FDF0B /* CookieJar.swift in Sources */, + C7F7BDD026262A4C00CE547F /* AppDependency.swift in Sources */, + C7AFF874283C0ADC000E01DF /* UIApplication+Helpers.swift in Sources */, + D853723A21952DAF0076F461 /* WebAddressStep.swift in Sources */, + B5F67AC71DB7D81300482C62 /* NotificationSyncMediator.swift in Sources */, + 8091019329078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.swift in Sources */, + FA77E02A1BE17CFC006D45E0 /* ThemeBrowserHeaderView.swift in Sources */, + 9874767321963D330080967F /* SiteStatsInformation.swift in Sources */, + 738B9A5821B85CF20005062B /* TitleSubtitleHeader.swift in Sources */, + FA70024C29DC3B5500E874FD /* DashboardActivityLogCardCell.swift in Sources */, + 17C64BD2248E26A200AF09D7 /* AppAppearance.swift in Sources */, + 3F09CCA82428FF3300D00A8C /* ReaderTabViewController.swift in Sources */, + 8B1E62D625758AAF009A0F80 /* ActivityTypeSelectorViewController.swift in Sources */, + 7E4123CC20F418A500DF8486 /* ActivityActionsParser.swift in Sources */, + 40F46B6B22121BA800A2143B /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataProperties.swift in Sources */, + FAB37D4627ED84BC00CA993C /* DashboardStatsNudgeView.swift in Sources */, + 17F0E1DA20EBDC0A001E9514 /* Routes+Me.swift in Sources */, + D83CA3AB20842E5F0060E310 /* StockPhotosResultsPage.swift in Sources */, + 80EF928A280D28140064A971 /* Atomic.swift in Sources */, + 4348C88321002FBD00735DC0 /* QuickStartTourGuide.swift in Sources */, + 8BA77BCF2483415400E1EBBF /* ReaderDetailToolbar.swift in Sources */, + E102B7901E714F24007928E8 /* RecentSitesService.swift in Sources */, + E1D7FF381C74EB0E00E7E5E5 /* PlanService.swift in Sources */, + F1655B4822A6C2FA00227BFB /* Routes+Mbar.swift in Sources */, + 32CA6EC02390C61F00B51347 /* PostListEditorPresenter.swift in Sources */, + 80D9CFF429DCA53E00FE3400 /* DashboardPagesListCardCell.swift in Sources */, + 0147D64E294B1E1600AA6410 /* StatsRevampStore.swift in Sources */, + F580C3C123D22E2D0038E243 /* PreviewDeviceLabel.swift in Sources */, + 5D7DEA2919D488DD0032EE77 /* WPStyleGuide+ReaderComments.swift in Sources */, + 40C403F62215D66A00E8C894 /* TopViewedPostStatsRecordValue+CoreDataProperties.swift in Sources */, + 803DE81628FFAEF2007D4E9C /* RemoteConfigParameter.swift in Sources */, + 82FC61251FA8ADAD00A1757E /* BaseActivityListViewController.swift in Sources */, + F15272FF243B28B700C8DC7A /* RequestAuthenticator.swift in Sources */, + E66E2A651FE4311300788F22 /* SettingsTitleSubtitleController.swift in Sources */, + E1222B671F878E4700D23173 /* WebViewControllerFactory.swift in Sources */, + 0CB4057729C8DDE8008EED0A /* BlogDashboardPersonalizationView.swift in Sources */, + E1D86E691B2B414300DD2192 /* WordPress-32-33.xcmappingmodel in Sources */, + 9A2B28E8219046ED00458F2A /* ShowRevisionsListManger.swift in Sources */, + FAA4012D27B405DB009E1137 /* DashboardQuickActionsCardCell.swift in Sources */, 985ED0E423C6950600B8D06A /* WidgetStyles.swift in Sources */, 57CCB3812358ED07003ECD0C /* WordPress-91-92.xcmappingmodel in Sources */, 5D2B30B91B7411C700DA15F3 /* ReaderCardDiscoverAttributionView.swift in Sources */, + FE02F95F269DC14A00752A44 /* Comment+Interface.swift in Sources */, + 3F88065B26C30F2A0074DD21 /* TimeSelectionViewController.swift in Sources */, E1E89C6C1FD80E74006E7A33 /* Plugin.swift in Sources */, + 8B7C97E325A8BFA2004A3373 /* JetpackActivityLogViewController.swift in Sources */, 7E4123BD20F4097B00DF8486 /* FormattableMediaContent.swift in Sources */, + 469EB16524D8B12700C764CB /* CollabsableHeaderFilterCollectionViewCell.swift in Sources */, 40C403EB2215CD1300E8C894 /* SearchResultsStatsRecordValue+CoreDataClass.swift in Sources */, D87A329620ABD60700F4726F /* ReaderTableContent.swift in Sources */, 738B9A5A21B85CF20005062B /* SiteCreationHeaderData.swift in Sources */, - 319D6E7B19E447500013871C /* Suggestion.m in Sources */, 594399931B45091000539E21 /* WPAuthTokenIssueSolver.m in Sources */, F16C35D623F33DE400C81331 /* PageAutoUploadMessageProvider.swift in Sources */, + 0CB4057929C8DDEC008EED0A /* BlogDashboardPersonalizationViewModel.swift in Sources */, + 8BDA5A6B247C2EAF00AB124C /* ReaderDetailViewController.swift in Sources */, + 3F8B313025D1D652005A2903 /* HomeWidgetThisWeekData.swift in Sources */, + 17ABD3522811A48900B1E9CB /* StatsMostPopularTimeInsightsCell.swift in Sources */, 7E7B4CF820459E21001463D6 /* PersonHeaderCell.swift in Sources */, + B03B9234250BC593000A40AF /* SuggestionService.swift in Sources */, B555E1151C04A68D00CEC81B /* WordPress-41-42.xcmappingmodel in Sources */, + F56A33332538C0ED00E2AEF3 /* MySiteViewController+FAB.swift in Sources */, + FAB8F75025AD72CE00D5D54A /* BaseRestoreOptionsViewController.swift in Sources */, + F1863716253E49B8003D4BEF /* AddSiteAlertFactory.swift in Sources */, + F12FA5D92428FA8F0054DA21 /* AuthenticationService.swift in Sources */, E16A76F11FC4758300A661E3 /* JetpackSiteRef.swift in Sources */, B5B56D3219AFB68800B4E29B /* WPStyleGuide+Reply.swift in Sources */, + 4A358DE629B5EB8D00BFCEBE /* PublicizeService+Lookup.swift in Sources */, + C7234A422832C2BA0045C63F /* QRLoginScanningViewController.swift in Sources */, + 801D94EF2919E7D70051993E /* JetpackFullscreenOverlayGeneralViewModel.swift in Sources */, 30EABE0918A5903400B73A9C /* WPBlogTableViewCell.m in Sources */, + FAB8F76E25AD73C000D5D54A /* JetpackBackupOptionsViewController.swift in Sources */, + 175CC17C2723103000622FB4 /* WPAnalytics+Domains.swift in Sources */, 400A2C842217A985000A8A59 /* TopViewedVideoStatsRecordValue+CoreDataProperties.swift in Sources */, 7E4123BE20F4097B00DF8486 /* NotificationContentRangeFactory.swift in Sources */, + FA332AD429C1FC7A00182FBB /* MovedToJetpackViewModel.swift in Sources */, E6D170371EF9D8D10046D433 /* SiteInfo.swift in Sources */, - 822D60B11F4C747E0016C46D /* JetpackSecuritySettingsViewController.swift in Sources */, E16A76F31FC4766900A661E3 /* CredentialsService.swift in Sources */, - 177B4C292123181400CF8084 /* GiphyDataSource.swift in Sources */, + F9B862C92478170A008B093C /* EncryptedLogTableViewController.swift in Sources */, 4070D75E20E6B4E4007CEBDA /* ActivityDateFormatting.swift in Sources */, E2AA87A518523E5300886693 /* UIView+Subviews.m in Sources */, + FEFC0F892731182C001F7F1D /* CommentService+Replies.swift in Sources */, + 803BB98F29667BAF00B3F6D6 /* JetpackBrandingTextProvider.swift in Sources */, 5D51ADAF19A832AF00539C0B /* WordPress-20-21.xcmappingmodel in Sources */, + C81CCD63243AECA100A83E27 /* TenorClient.swift in Sources */, + F4D9188629D78C9100974A71 /* BlogDetailsViewController+Strings.swift in Sources */, 43FB3F471EC10F1E00FC8A62 /* LoginEpilogueTableViewController.swift in Sources */, 9A8ECE112254A3260043C8DA /* JetpackRemoteInstallViewModel.swift in Sources */, 5727EAF82284F5AC00822104 /* InteractivePostViewDelegate.swift in Sources */, + F504D43725D717EF00A2764C /* PostEditor+BlogPicker.swift in Sources */, 93C486511810445D00A24725 /* ActivityLogViewController.m in Sources */, 9A162F2521C26F5F00FDC035 /* UIViewController+ChildViewController.swift in Sources */, 086C4D101E81F9240011D960 /* Media+Blog.swift in Sources */, + 088D58A529E724F300E6C0F4 /* ColorGallery.swift in Sources */, 08216FCB1CDBF96000304BA7 /* MenuItemEditingHeaderView.m in Sources */, 17BD4A0820F76A4700975AC3 /* Routes+Banners.swift in Sources */, 1702BBDC1CEDEA6B00766A33 /* BadgeLabel.swift in Sources */, + F5B9152124465FB400179876 /* ReaderTagsTableViewController.swift in Sources */, 4388FEFE20A4E0B900783948 /* NotificationsViewController+AppRatings.swift in Sources */, 93C1148518EDF6E100DAC95C /* BlogService.m in Sources */, + 2F668B62255DD11400D0038A /* JetpackSettingsViewController.swift in Sources */, 981C986821B9BDF300A7C0C8 /* PostingActivityViewController.swift in Sources */, - E69EF9D51BFA539F00ED0554 /* ReaderDetailViewController.swift in Sources */, + FA4B203529A786460089FE68 /* BlazeEventsTracker.swift in Sources */, + FAD2538F26116A1600EDAF88 /* AppStyleGuide.swift in Sources */, 17F67C56203D81430072001E /* PostCardStatusViewModel.swift in Sources */, + C3835559288B02B00062E402 /* JetpackBannerWrapperViewController.swift in Sources */, E63BBC961C5168BE00598BE8 /* SharingAuthorizationHelper.m in Sources */, - B55853F719630D5400FAF6C3 /* NSAttributedString+Util.m in Sources */, 98880A4A22B2E5E400464538 /* TwoColumnCell.swift in Sources */, + 4A2172FE28F688890006F4F1 /* Blog+Media.swift in Sources */, ACBAB5FE0E121C7300F38795 /* PostSettingsViewController.m in Sources */, - 2FA6511521F269A6009AA935 /* VerticalsTableViewProvider.swift in Sources */, 08CC677E1C49B65A00153AD7 /* MenuItem.m in Sources */, E62AFB6C1DC8E593007484FC /* WPRichTextFormatter.swift in Sources */, 40A71C67220E1952002E3D25 /* LastPostStatsRecordValue+CoreDataProperties.swift in Sources */, + F50B0E7B246212B8006601DD /* NoticeAnimator.swift in Sources */, + 175F99B52625FDE100F2687E /* FancyAlertViewController+AppIcons.swift in Sources */, 74CEF0781F9AA10100B729CA /* ShareExtensionSessionManager.swift in Sources */, + 8BD8201B24BCDBFF00FF25FD /* ReaderWelcomeBanner.swift in Sources */, + C737553E27C80DD500C6E9A1 /* String+CondenseWhitespace.swift in Sources */, 74FA4BE51FBFA0660031EAAD /* Extensions.xcdatamodeld in Sources */, 986CC4D220E1B2F6004F300E /* CustomLogFormatter.swift in Sources */, + 3236F77524ABB7770088E8F3 /* ReaderInterestsCollectionViewCell.swift in Sources */, 40A71C6A220E1952002E3D25 /* StatsRecord+CoreDataClass.swift in Sources */, 9826AE8221B5C6A700C851FA /* LatestPostSummaryCell.swift in Sources */, + F47E154A29E84A9300B6E426 /* DomainPurchasingWebFlowController.swift in Sources */, 433432521E9ED18900915988 /* LoginEpilogueViewController.swift in Sources */, 4395A1592106389800844E8E /* QuickStartTours.swift in Sources */, 9A2B28EE2191B50500458F2A /* RevisionsTableViewFooter.swift in Sources */, + 80EF671F27F135EB0063B138 /* WhatIsNewViewAppearance.swift in Sources */, + F5A34BCB25DF244F00C9654B /* KanvasCameraAnalyticsHandler.swift in Sources */, 8BD36E022395CAEA00EFFF1C /* MediaEditorOperation+Description.swift in Sources */, - FF9C81C42375BA8100DC4B2F /* GutenbergBlockProcessor.swift in Sources */, + 839B150B2795DEE0009F5E77 /* UIView+Margins.swift in Sources */, 17F52DB72315233300164966 /* WPStyleGuide+FilterTabBar.swift in Sources */, E1DD4CCB1CAE41B800C3863E /* PagedViewController.swift in Sources */, B549BA681CF7447E0086C608 /* InvitePersonViewController.swift in Sources */, B535209B1AF7BBB800B33BA8 /* PushAuthenticationManager.swift in Sources */, + AB2211D225ED68E300BF72FC /* CommentServiceRemoteFactory.swift in Sources */, + 8BBBCE702717651200B277AC /* JetpackModuleHelper.swift in Sources */, 319D6E8119E44C680013871C /* SuggestionsTableView.m in Sources */, + 98622E9F274C59A400061A5F /* ReaderDetailCommentsTableViewDelegate.swift in Sources */, 40E728851FF3D9070010E7C9 /* PluginDetailViewHeaderCell.swift in Sources */, + 46183D1F251BD6A0004F9AFD /* PageTemplateCategory+CoreDataClass.swift in Sources */, 40E7FED02211FFBC0032834E /* AllTimeStatsRecordValue+CoreDataProperties.swift in Sources */, - D826D682211D51E300A5D8FE /* ReaderNewsCard.swift in Sources */, 4054F45F2214F50300D261AB /* StreakInsightStatsRecordValue+CoreDataClass.swift in Sources */, 174C9697205A846E00CEEF6E /* PostNoticeViewModel.swift in Sources */, 9A2D0B25225CB980009E585F /* JetpackInstallStore.swift in Sources */, @@ -11739,18 +20906,29 @@ F1D690171F82914200200E30 /* BuildConfiguration.swift in Sources */, E1BEEC651C4E3978000B4FA0 /* PaddedLabel.swift in Sources */, E1A6DBE519DC7D230071AC1E /* PostService.m in Sources */, + C3FBF4E828AFEDF8003797DF /* JetpackBrandingAnalyticsHelper.swift in Sources */, + F1D8C6F026C17A6C002E3323 /* WeeklyRoundupBackgroundTask.swift in Sources */, E62AFB6E1DC8E593007484FC /* WPTextAttachmentManager.swift in Sources */, 9F3EFC9E208E2E8A00268758 /* ReaderSiteInfoSubscriptions.swift in Sources */, D8160442209C1B0F00ABAFFA /* ReaderSaveForLaterAction.swift in Sources */, E68580F61E0D91470090EE63 /* WPHorizontalRuleAttachment.swift in Sources */, E12FE0741FA0CEE000F28710 /* ImmuTable+Optional.swift in Sources */, 1A433B1D2254CBEE00AE7910 /* WordPressComRestApi+Defaults.swift in Sources */, + 3F662C4A24DC9FAC00CAEA95 /* WhatIsNewViewController.swift in Sources */, 7EFF208C20EADF68009C4699 /* FormattableCommentContent.swift in Sources */, + 17EFD3742578201100AB753C /* ValueTransformers.swift in Sources */, + FE6BB143293227AC001E5F7A /* ContentMigrationCoordinator.swift in Sources */, + 8B85AEDA259230FC00ADBEC9 /* ABTest.swift in Sources */, + 178DDD06266D68A3006C68C4 /* BloggingRemindersFlowIntroViewController.swift in Sources */, E1E5EE37231E47A80018E9E3 /* ContextManager+ErrorHandling.swift in Sources */, + F52CACCC24512EA700661380 /* EmptyActionView.swift in Sources */, D8212CBF20AA7B7F008E8AE8 /* ReaderShowAttributionAction.swift in Sources */, FFABD800213423F1003C65B6 /* LinkSettingsViewController.swift in Sources */, + 8313B9EE298B1ACD000AF26E /* SiteSettingsViewController+Blogging.swift in Sources */, E15644F11CE0E56600D96E64 /* FeatureItemCell.swift in Sources */, + 8BAC9D9E27BAB97E008EA44C /* BlogDashboardRemoteEntity.swift in Sources */, E62AFB6B1DC8E593007484FC /* WPRichContentView.swift in Sources */, + 9856A39D261FC21E008D6354 /* UserProfileUserInfoCell.swift in Sources */, 08B6E51A1F036CAD00268F57 /* MediaFileManager.swift in Sources */, 594DB2951AB891A200E2E456 /* WPUserAgent.m in Sources */, 4093FE40218C384200B1D224 /* AztecVerificationPromptHelper.swift in Sources */, @@ -11758,245 +20936,372 @@ D8A3A5AC2069FE5B00992576 /* StockPhotosStrings.swift in Sources */, C56636E91868D0CE00226AAB /* StatsViewController.m in Sources */, E6805D301DCD399600168E4F /* WPRichTextEmbed.swift in Sources */, - D8AEA54A21C21BEC00AB4DCB /* NewVerticalCell.swift in Sources */, 3249615223F20111004C7733 /* PostSignUpInterstitialViewController.swift in Sources */, 9A09F91E230C4C0200F42AB7 /* StatsGhostTableViewRows.swift in Sources */, 7E53AB0020FE55A9005796FE /* ActivityContentRouter.swift in Sources */, + 4A9314E7297A0C5000360232 /* PostCategory+Lookup.swift in Sources */, 738B9A5C21B85EB00005062B /* UIView+ContentLayout.swift in Sources */, E1B9128F1BB05B1D003C25B9 /* PeopleCell.swift in Sources */, + F15272FD243B27BC00C8DC7A /* AbstractPost+Local.swift in Sources */, 3F43603123F31E09001DEE70 /* MeScenePresenter.swift in Sources */, - 313AE4A019E3F20400AAFABE /* CommentViewController.m in Sources */, + FA98B61629A3B76A0071AAE8 /* DashboardBlazeCardCell.swift in Sources */, + C71BC73F25A652410023D789 /* JetpackScanStatusViewModel.swift in Sources */, 7E3E7A6620E44F200075D159 /* HeaderContentGroup.swift in Sources */, + F5AE440625DD0345003675F4 /* CameraHandler.swift in Sources */, + 2F668B63255DD11400D0038A /* JetpackConnectionViewController.swift in Sources */, B5015C581D4FDBB300C9449E /* NotificationActionsService.swift in Sources */, + 3F2ABE1A2770EF3E005D8916 /* Blog+VideoLimits.swift in Sources */, + 8C6A22E425783D2000A79950 /* JetpackScanService.swift in Sources */, + FA6402D129C325C1007A235C /* MovedToJetpackEventsTracker.swift in Sources */, + F48D44BA2989A58C0051EAA6 /* ReaderSiteService.swift in Sources */, + F5E63129243BC8190088229D /* FilterSheetView.swift in Sources */, A0E293F10E21027E00C6919C /* WPAddPostCategoryViewController.m in Sources */, + 803DE821290642B4007D4E9C /* JetpackFeaturesRemovalCoordinator.swift in Sources */, + 17C1D67C2670E3DC006C8970 /* SiteIconPickerView.swift in Sources */, E6BDEA731CE4824300682885 /* ReaderSearchTopic.swift in Sources */, + FED77258298BC5B300C2346E /* PluginJetpackProxyService.swift in Sources */, + FEA7949026DF7F3700CEC520 /* WPStyleGuide+CommentDetail.swift in Sources */, + 8B074A5027AC3A64003A2EB8 /* BlogDashboardViewModel.swift in Sources */, + F5A34A9925DEF47D00C9654B /* WPMediaPicker+MediaPicker.swift in Sources */, 9A8ECE132254A3260043C8DA /* JetpackInstallError+Blocking.swift in Sources */, 43AF2F972107D3810069C012 /* QuickStartTourState.swift in Sources */, + FA4B202F29A619130089FE68 /* BlazeFlowCoordinator.swift in Sources */, 5D5A6E931B613CA400DAF819 /* ReaderPostCardCell.swift in Sources */, 9A38DC6B218899FB006A409B /* DiffContentValue.swift in Sources */, 7E4123C020F4097B00DF8486 /* FormattableTextContent.swift in Sources */, E10520581F2B1CC900A948F6 /* WordPress-61-62.xcmappingmodel in Sources */, + 3F170E242655917400F6F670 /* UIView+SwiftUI.swift in Sources */, + 3F73BE5F24EB3B4400BE99FF /* WhatIsNewView.swift in Sources */, + FADFBD26265F580500039C41 /* MultilineButton.swift in Sources */, B5772AC41C9C7A070031F97E /* GravatarService.swift in Sources */, - 1705E55020A5DA5700EF1C9D /* ReaderSavedPostsViewController.swift in Sources */, 981676D6221B7A4300B81C3F /* CountriesCell.swift in Sources */, 5D2C05561AD2F56200A753FE /* NavBarTitleDropdownButton.m in Sources */, - FF0F722C206E5345000DAAB5 /* PostService+RefreshStatus.swift in Sources */, + 8BCF957A24C6044000712056 /* ReaderTopicsCardCell.swift in Sources */, + FF0F722C206E5345000DAAB5 /* Post+RefreshStatus.swift in Sources */, + 326E281C250AC4A50029EBF0 /* ImageDimensionParser.swift in Sources */, 405BFB20223B37A000CD5BEA /* FollowersCountStatsRecordValue+CoreDataClass.swift in Sources */, F126FE0220A33BDB0010EB6E /* DocumentUploadProcessor.swift in Sources */, + 3236F79E24AE75790088E8F3 /* ReaderTopicService+Interests.swift in Sources */, 9A2D0B36225E2511009E585F /* JetpackService.swift in Sources */, - 177B4C3121236F0F00CF8084 /* GiphyDataLoader.swift in Sources */, - 8236EB4B20248FF1007C7CF9 /* JetpackSpeedUpSiteSettingsViewController.swift in Sources */, 40F50B7E22130E6C00CBBB73 /* FollowersStatsRecordValue+CoreDataProperties.swift in Sources */, + FAD95D2725B91BCF00F011B5 /* JetpackRestoreStatusFailedViewController.swift in Sources */, E66969DC1B9E55C300EC9C00 /* ReaderTopicToReaderListTopic37to38.swift in Sources */, - 31C9F82E1A2368A2008BB945 /* BlogDetailHeaderView.m in Sources */, 9A2D0B23225CB92B009E585F /* BlogService+JetpackConvenience.swift in Sources */, + C856748F243EF177001A995E /* GutenbergTenorMediaPicker.swift in Sources */, + 83C972E0281C45AB0049E1FE /* Post+BloggingPrompts.swift in Sources */, B50C0C5F1EF42A4A00372C65 /* AztecPostViewController.swift in Sources */, - 2FA6511321F26949009AA935 /* SiteVerticalsPromptService.swift in Sources */, - FF28B3F11AEB251200E11AAE /* InfoPListTranslator.m in Sources */, 086E1FE01BBB35D2002D86CA /* MenusViewController.m in Sources */, + FAFC064B27D22E4C002F0483 /* QuickStartTourStateView.swift in Sources */, 9F3EFCA3208E308A00268758 /* UIViewController+Notice.swift in Sources */, + C3B554512965C32A00A04753 /* MenusViewController+JetpackBannerViewController.swift in Sources */, + 1E0462162566938300EB98EF /* GutenbergFileUploadProcessor.swift in Sources */, + F913BB1024B3C5CE00C19032 /* EventLoggingDataProvider.swift in Sources */, 8B93856E22DC08060010BF02 /* PageListSectionHeaderView.swift in Sources */, + E690F6EF25E05D180015A777 /* InviteLinks+CoreDataClass.swift in Sources */, 59E1D46F1CEF77B500126697 /* Page+CoreDataProperties.swift in Sources */, 40F50B7D22130E6C00CBBB73 /* FollowersStatsRecordValue+CoreDataClass.swift in Sources */, + C373D6E728045281008F8C26 /* SiteIntentData.swift in Sources */, 43D74AD620FB5AD5004AD934 /* RegisterDomainSectionHeaderView.swift in Sources */, 986DD19C218D002500D28061 /* WPStyleGuide+Stats.swift in Sources */, + F5B9151F244653C100179876 /* TabbedViewController.swift in Sources */, FF00889B204DF3ED007CCE66 /* Blog+Quota.swift in Sources */, C533CF350E6D3ADA000C3DE8 /* CommentsViewController.m in Sources */, FAFF153D1C98962E007D1C90 /* SiteSettingsViewController+SiteManagement.swift in Sources */, D816C1EE20E0892200C4D82F /* Follow.swift in Sources */, + 3F3DD0B226FD176800F5F121 /* PresentationCard.swift in Sources */, 822D60B91F4CCC7A0016C46D /* BlogJetpackSettingsService.swift in Sources */, + F446B843296F2DED008B94B7 /* MigrationState.swift in Sources */, + 830A58D82793AB4500CDE94F /* LoginEpilogueAnimator.swift in Sources */, 08216FD51CDBF96000304BA7 /* MenuItemTypeViewController.m in Sources */, B526DC291B1E47FC002A8C5F /* WPStyleGuide+WebView.m in Sources */, + F49B9A0029393049000CEFCE /* MigrationAppDetection.swift in Sources */, + 3F6DA04125646F96002AB88F /* HomeWidgetData.swift in Sources */, + B089140D27E1255D00CF468B /* SiteIntentViewController.swift in Sources */, 82FC61271FA8ADAD00A1757E /* WPStyleGuide+Activity.swift in Sources */, 98BFF57E23984345008A1DCB /* AllTimeWidgetStats.swift in Sources */, 7E40713A2372AD54003627FA /* GutenbergFilesAppMediaSource.swift in Sources */, B532D4EC199D4357006E4DF6 /* NoteBlockTextTableViewCell.swift in Sources */, + 8B15CDAB27EB89AD00A75749 /* BlogDashboardPostsParser.swift in Sources */, B52F8CD81B43260C00D36025 /* NotificationSettingStreamsViewController.swift in Sources */, + 464688D8255C71D200ECA61C /* SiteDesignPreviewViewController.swift in Sources */, + 3F39C93527A09927001EC300 /* WordPressLibraryLogger.swift in Sources */, 3F5B3EB123A851480060FF1F /* ReaderReblogFormatter.swift in Sources */, E1AB5A071E0BF17500574B4E /* Array.swift in Sources */, D858F2FD20E1F09F007E8A1C /* NotificationActionParser.swift in Sources */, E66E2A661FE4311300788F22 /* SiteTagsViewController.swift in Sources */, + 3F720C2128899DD900519938 /* JetpackBrandingVisibility.swift in Sources */, FFC02B83222687BF00E64FDE /* GutenbergImageLoader.swift in Sources */, E6C09B3E1BF0FDEB003074CB /* ReaderCrossPostMeta.swift in Sources */, 7E3E7A5520E44B4B0075D159 /* SnippetsContentStyles.swift in Sources */, + 8B7F25A724E6EDB4007D82CC /* TopicsCollectionView.swift in Sources */, D8CB56202181A8CE00554EAE /* SiteSegmentsService.swift in Sources */, E11E775A1E72932F0072AD40 /* BlogListDataSource.swift in Sources */, 1730D4A31E97E3E400326B7C /* MediaItemTableViewCells.swift in Sources */, 8BFE36FD230F16580061EBA8 /* AbstractPost+fixLocalMediaURLs.swift in Sources */, 1750BD6D201144DB0050F13A /* MediaNoticeNavigationCoordinator.swift in Sources */, - E6ED09091D46AD29003283C4 /* ReaderFollowedSitesStreamHeader.swift in Sources */, E66969E41B9E68B200EC9C00 /* ReaderPostToReaderPost37to38.swift in Sources */, - 98712D1B23DA1C7E00555316 /* WidgetNoConnectionCell.swift in Sources */, + FA6FAB3525EF7C5700666CED /* ReaderRelatedPostsSectionHeaderView.swift in Sources */, + CECEEB552823164800A28ADE /* MediaCacheSettingsViewController.swift in Sources */, 7E5887A020FE956100DB6F80 /* AppRatingUtilityType.swift in Sources */, 08216FD01CDBF96000304BA7 /* MenuItemSourceFooterView.m in Sources */, + FAD951A425B6CB3600F011B5 /* JetpackRestoreFailedViewController.swift in Sources */, + C3E2462926277B7700B99EA6 /* PostAuthorSelectorViewController.swift in Sources */, + F5E032E62408D537003AF350 /* ActionSheetViewController.swift in Sources */, 9A4F8F56218B88E000EEDCCC /* PostService+Revisions.swift in Sources */, + 3F73388226C1CE9B0075D1DD /* TimeSelectionButton.swift in Sources */, 5DED0E181B432E0400431FCD /* SourcePostAttribution.m in Sources */, 1715179420F4B5CD002C4A38 /* MySitesCoordinator.swift in Sources */, 08D978561CD2AF7D0054F19A /* MenuItem+ViewDesign.m in Sources */, - B52C4C7F199D74AE009FD823 /* NoteTableViewCell.swift in Sources */, + 3254366C24ABA82100B2C5F5 /* ReaderInterestsStyleGuide.swift in Sources */, 988056032183CCE50083B643 /* SiteStatsInsightsTableViewController.swift in Sources */, 577C2AB422943FEC00AD1F03 /* PostCompactCell.swift in Sources */, 315FC2C51A2CB29300E7CDA2 /* MeHeaderView.m in Sources */, E1222B631F877FD700D23173 /* WebProgressView.swift in Sources */, + 4A072CD229093704006235BE /* AsyncBlockOperation.swift in Sources */, E185042F1EE6ABD9005C234C /* Restorer.swift in Sources */, - D800D86A20997E0C00E7C7E5 /* LikedMenuItemCreator.swift in Sources */, 02761EC02270072F009BAF0F /* BlogDetailsViewController+SectionHelpers.swift in Sources */, 984B138E21F65F870004B6A2 /* SiteStatsPeriodTableViewController.swift in Sources */, 433ADC1D223B2A7F00ED9DE1 /* TextBundleWrapper.m in Sources */, + 2F605FAA25145F7200F99544 /* WPCategoryTree.swift in Sources */, 5DFA7EC71AF814E40072023B /* PageListTableViewCell.m in Sources */, 5D62BAD718AA88210044E5F7 /* PageSettingsViewController.m in Sources */, + 46241C3C2540D483002B8A12 /* SiteDesignContentCollectionViewController.swift in Sources */, 17D2FDC21C6A468A00944265 /* PlanComparisonViewController.swift in Sources */, 1715179220F4B2EB002C4A38 /* Routes+Stats.swift in Sources */, 3F29EB7224042276005313DE /* MeViewController+UIViewControllerRestoration.swift in Sources */, 74729CAA2057100200D1394D /* SearchIdentifierGenerator.swift in Sources */, 98D52C3222B1CFEC00831529 /* StatsTwoColumnRow.swift in Sources */, B57273601B66CCEF000D1C4F /* AlertInternalView.swift in Sources */, - D800D86E2099857000E7C7E5 /* SearchMenuItemCreator.swift in Sources */, + 9839CEBB26FAA0530097406E /* CommentModerationBar.swift in Sources */, D88106FA20C0CFEE001D2F00 /* ReaderSaveForLaterRemovedPosts.swift in Sources */, 1751E5931CE23801000CA08D /* NSAttributedString+StyledHTML.swift in Sources */, + 3F8513DF260D091500A4B938 /* RoundRectangleView.swift in Sources */, + 8B4EDADD27DF9D5E004073B6 /* Blog+MySite.swift in Sources */, D8071631203DA23700B32FD9 /* Accessible.swift in Sources */, 9826AE9021B5D3CD00C851FA /* PostingActivityCell.swift in Sources */, + 934098C02719577D00B3E77E /* InsightType.swift in Sources */, 98077B5A2075561800109F95 /* SupportTableViewController.swift in Sources */, 987535632282682D001661B4 /* DetailDataCell.swift in Sources */, E17E67031FA22C93009BDC9A /* PluginViewModel.swift in Sources */, - 7059CD210F332B6500A0660B /* WPCategoryTree.m in Sources */, B5E167F419C08D18009535AA /* NSCalendar+Helpers.swift in Sources */, + 80A2154629D15B88002FE8EB /* RemoteConfigOverrideStore.swift in Sources */, + F4DDE2C229C92F0D00C02A76 /* CrashLogging+Singleton.swift in Sources */, + 4629E4212440C5B20002E15C /* GutenbergCoverUploadProcessor.swift in Sources */, + F45326D829F6B8A6005F9F31 /* SiteCreationAnalyticsEvent.swift in Sources */, FF00889F204E01AE007CCE66 /* MediaQuotaCell.swift in Sources */, - F5844B6B235EAF3D007C6557 /* HalfScreenPresentationController.swift in Sources */, + 982DDF94263238A6002B3904 /* LikeUserPreferredBlog+CoreDataClass.swift in Sources */, + F5844B6B235EAF3D007C6557 /* PartScreenPresentationController.swift in Sources */, E6805D311DCD399600168E4F /* WPRichTextImage.swift in Sources */, 7E4123C120F4097B00DF8486 /* FormattableContentRange.swift in Sources */, 08D978581CD2AF7D0054F19A /* MenuItemSourceHeaderView.m in Sources */, + FA1A543E25A6E2F60033967D /* RestoreWarningView.swift in Sources */, + 1E4F2E712458AF8500EB73E7 /* GutenbergWebNavigationViewController.swift in Sources */, + 98E0829F2637545C00537BF1 /* PostService+Likes.swift in Sources */, + 3FF1A853242D5FCB00373F5D /* WPTabBarController+ReaderTabNavigation.swift in Sources */, + 4A526BDF296BE9A50007B5BA /* CoreDataService.m in Sources */, + 98AA9F2127EA890800B3A98C /* FeatureIntroductionViewController.swift in Sources */, BE1071FC1BC75E7400906AFF /* WPStyleGuide+Blog.swift in Sources */, B56695B01D411EEB007E342F /* KeyboardDismissHelper.swift in Sources */, + F5B9D7F0245BA938002BB2C7 /* FancyAlertViewController+CreateButtonAnnouncement.swift in Sources */, FF54D4641D6F3FA900A0DC4D /* GutenbergSettings.swift in Sources */, + 3FAF9CC526D03C7400268EA2 /* DomainSuggestionViewControllerWrapper.swift in Sources */, + FE4DC5A7293A79F1008F322F /* WordPressExportRoute.swift in Sources */, D80BC79E20746B4100614A59 /* MediaPickingContext.swift in Sources */, E6D2E16C1B8B423B0000ED14 /* ReaderStreamHeader.swift in Sources */, FFD12D5E1FE1998D00F20A00 /* Progress+Helpers.swift in Sources */, + FAFC065127D27241002F0483 /* BlogDetailsViewController+Dashboard.swift in Sources */, + 0A9610F928B2E56300076EBA /* UserSuggestion+Comparable.swift in Sources */, 170CE7402064478600A48191 /* PostNoticeNavigationCoordinator.swift in Sources */, 740516892087B73400252FD0 /* SearchableActivityConvertable.swift in Sources */, + F4D36AD5298498E600E6B84C /* ReaderPostBlockingController.swift in Sources */, + F5B390EA2537E30B0097049E /* GridCell.swift in Sources */, + C31B6D5B28BFB6F300E64FEB /* SplashPrologueView.swift in Sources */, B5F641B31E37C36700B7819F /* AdaptiveNavigationController.swift in Sources */, 74BC35B820499EEB00AC1525 /* RemotePostCategory+Extensions.swift in Sources */, 8B8FE8582343955500F9AD2E /* PostAutoUploadMessages.swift in Sources */, 57D5812D2228526C002BAAD7 /* WPError+Swift.swift in Sources */, 988AC37922F10E2C00BC1433 /* FileDownloadsStatsRecordValue+CoreDataClass.swift in Sources */, + FADFBD3B265F5B2E00039C41 /* WPStyleGuide+Jetpack.swift in Sources */, 825327581FBF7CD600B8B7D2 /* ActivityUtils.swift in Sources */, + FE341705275FA157005D5CA7 /* RichCommentContentRenderer.swift in Sources */, 59DD94341AC479ED0032DD6B /* WPLogger.m in Sources */, - E1CB6DA7200F661900945457 /* TimeZoneSelectorViewController.swift in Sources */, + FAB8FD5025AEB0F500D5D54A /* JetpackBackupStatusCoordinator.swift in Sources */, 9A38DC6C218899FB006A409B /* RevisionDiff.swift in Sources */, FF4C069F206560E500E0B2BC /* MediaThumbnailCoordinator.swift in Sources */, 9A4697B221B002AD00468B64 /* RevisionDiffsPageManager.swift in Sources */, + 4A9314E42979FA4700360232 /* PostCategory+Creation.swift in Sources */, F5660D07235D114500020B1E /* CalendarCollectionView.swift in Sources */, 7E3E7A6420E44ED60075D159 /* SubjectContentGroup.swift in Sources */, E151C0C61F3889DF00710A83 /* PluginListRow.swift in Sources */, 8217380B1FE05EE600BEC94C /* BlogSettings+DateAndTimeFormat.swift in Sources */, 57047A4F22A961BC00B461DF /* PostSearchHeader.swift in Sources */, + FA4BC0D02996A589005EB077 /* BlazeService.swift in Sources */, 088B89891DA6F93B000E8DEF /* ReaderPostCardContentLabel.swift in Sources */, + C3C2F84828AC8EBF00937E45 /* JetpackBannerScrollVisibility.swift in Sources */, + F181EDE526B2AC7200C61241 /* BackgroundTasksCoordinator.swift in Sources */, + 8B93412F257029F60097D0AC /* FilterChipButton.swift in Sources */, 40A71C68220E1952002E3D25 /* StatsRecordValue+CoreDataClass.swift in Sources */, + 4A1E77C6298897F6006281CC /* SharingSyncService.swift in Sources */, CEBD3EAB0FF1BA3B00C1396E /* Blog.m in Sources */, + FA90EFEF262E74210055AB22 /* JetpackWebViewControllerFactory.swift in Sources */, 9A8ECE1D2254AE4E0043C8DA /* JetpackRemoteInstallStateView.swift in Sources */, F511F8A42356A4F400895E73 /* PublishSettingsViewController.swift in Sources */, + F11C9F76243B3C5E00921DDC /* MediaHost+AbstractPost.swift in Sources */, + 46183D20251BD6A0004F9AFD /* PageTemplateCategory+CoreDataProperties.swift in Sources */, CE1CCB2D204DDD18000EE3AC /* MyProfileHeaderView.swift in Sources */, - 98906502237CC1DF00218CD2 /* WidgetUnconfiguredCell.swift in Sources */, - 2FA6511D21F26A7C009AA935 /* WebAddressTableViewProvider.swift in Sources */, + C7F7BE4C2626301500CE547F /* AuthenticationHandler.swift in Sources */, FFDA7E501B8DF6E500B83C56 /* BlogSiteVisibilityHelper.m in Sources */, E66E2A691FE432BC00788F22 /* TitleBadgeDisclosureCell.swift in Sources */, 7E4123BF20F4097B00DF8486 /* FormattableContent.swift in Sources */, D82253DF2199418B0014D0E2 /* WebAddressWizardContent.swift in Sources */, + 8BD34F0B27D14B3C005E931C /* Blog+DashboardState.swift in Sources */, 400A2C782217A8A0000A8A59 /* VisitsSummaryStatsRecordValue+CoreDataProperties.swift in Sources */, D853723C21952DC90076F461 /* SiteSegmentsStep.swift in Sources */, + 32E1BFDA24A66F2A007A08F0 /* ReaderInterestsCollectionViewFlowLayout.swift in Sources */, + 836498C828172C5900A2C170 /* WPStyleGuide+BloggingPrompts.swift in Sources */, + FE003F5E282D61BA006F8D1D /* BloggingPrompt+CoreDataClass.swift in Sources */, FF70A3221FD5840500BC270D /* PHAsset+Metadata.swift in Sources */, 5DA5BF4418E32DCF005F11F9 /* Theme.m in Sources */, D8A3A5B1206A49A100992576 /* StockPhotosMediaGroup.swift in Sources */, 7E4123BC20F4097B00DF8486 /* DefaultFormattableContentAction.swift in Sources */, 7E504D4A21A5B8D400E341A8 /* PostEditorNavigationBarManager.swift in Sources */, + F49B9A0A293A3249000CEFCE /* MigrationAnalyticsTracker.swift in Sources */, 73CB13972289BEFB00265F49 /* Charts+LargeValueFormatter.swift in Sources */, ADF544C2195A0F620092213D /* CustomHighlightButton.m in Sources */, + 084A07062848E1820054508A /* FeatureHighlightStore.swift in Sources */, 17B7C89E20EC1D0D0042E260 /* UniversalLinkRouter.swift in Sources */, - 177B4C352123706400CF8084 /* GiphyResultsPage.swift in Sources */, F59AAC16235EA46D00385EE6 /* LightNavigationController.swift in Sources */, 40A2777F20191AA500D078D5 /* PluginDirectoryCollectionViewCell.swift in Sources */, + 175721162754D31F00DE38BC /* AppIcon.swift in Sources */, 7E40716223741376003627FA /* GutenbergNetworking.swift in Sources */, + 3FBB2D2B27FB6CB200C57BBF /* SiteNameViewController.swift in Sources */, 17BD4A192101D31B00975AC3 /* NavigationActionHelpers.swift in Sources */, + 3F4D035028A56F9B00F0A4FD /* CircularImageButton.swift in Sources */, B55FFCFA1F034F1A0070812C /* String+Ranges.swift in Sources */, 7E846FF320FD37BD00881F5A /* ActivityCommentRange.swift in Sources */, + 1E672D95257663CE00421F13 /* GutenbergAudioUploadProcessor.swift in Sources */, 9AA0ADB1235F11700027AB5D /* AsyncOperation.swift in Sources */, + C73868C525C9F9820072532C /* JetpackScanThreatSectionGrouping.swift in Sources */, + F48D44B6298992C30051EAA6 /* BlockedSite.swift in Sources */, FF8791BB1FBAF4B500AD86E6 /* MediaService+Swift.swift in Sources */, D8212CB920AA77AD008E8AE8 /* ReaderActionHelpers.swift in Sources */, B52C4C7D199D4CD3009FD823 /* NoteBlockUserTableViewCell.swift in Sources */, E64384831C628FCC0052ADB5 /* WPStyleGuide+Sharing.swift in Sources */, + F504D2B025D60C5900A2764C /* StoryPoster.swift in Sources */, 981C82B62193A7B900A06E84 /* Double+Stats.swift in Sources */, + 175507B327A062980038ED28 /* PublicizeConnectionURLMatcher.swift in Sources */, 177074851FB209F100951A4A /* CircularProgressView.swift in Sources */, + 3F8B45A9292C1F2C00730FA4 /* DashboardMigrationSuccessCell.swift in Sources */, + 8B5E1DD827EA5929002EBEE3 /* PostCoordinator+Dashboard.swift in Sources */, 98458CB821A39D350025D232 /* StatsNoDataRow.swift in Sources */, - D818FFD421915586000E5FEE /* VerticalsStep.swift in Sources */, + 3234BB172530DFCA0068DA40 /* ReaderTableCardCell.swift in Sources */, 7462BFD42028CD4400B552D8 /* ShareNoticeNavigationCoordinator.swift in Sources */, + 803BB97C2959559500B3F6D6 /* RootViewPresenter.swift in Sources */, D80BC7A02074722000614A59 /* CameraCaptureCoordinator.swift in Sources */, + 178810B52611D25600A98BD8 /* Text+BoldSubString.swift in Sources */, + 3FA53E9C256571D800F4D9A2 /* HomeWidgetCache.swift in Sources */, + FAB8AB5F25AFFD0600F9F8A0 /* JetpackRestoreStatusCoordinator.swift in Sources */, + F913BB0E24B3C58B00C19032 /* EventLoggingDelegate.swift in Sources */, 4089C51022371B120031CE78 /* TodayStatsRecordValue+CoreDataClass.swift in Sources */, - 988F073523D0CE8800AC67A6 /* WidgetUrlCell.swift in Sources */, + 3F8B45A8292C1F2500730FA4 /* MigrationSuccessCardView.swift in Sources */, + DC772AF5282009BA00664C02 /* StatsLineChartView.swift in Sources */, 74729CAE205722E300D1394D /* AbstractPost+Searchable.swift in Sources */, 2906F812110CDA8900169D56 /* EditCommentViewController.m in Sources */, 98F93182239AF64800E4E96E /* ThisWeekWidgetStats.swift in Sources */, F16C35DA23F3F76C00C81331 /* PostAutoUploadMessageProvider.swift in Sources */, 91D8364121946EFB008340B2 /* GutenbergMediaPickerHelper.swift in Sources */, F1D690161F82913F00200E30 /* FeatureFlag.swift in Sources */, - 4313437B217956DB00DA2176 /* QuickStartSkipAllCell.swift in Sources */, 74989B8C2088E3650054290B /* BlogDetailsViewController+Activity.swift in Sources */, B532D4EB199D4357006E4DF6 /* NoteBlockTableViewCell.swift in Sources */, 43DDFE8C21715ADD008BE72F /* WPTabBarController+QuickStart.swift in Sources */, 74FA4BED1FBFA2350031EAAD /* SharedCoreDataStack.swift in Sources */, + 3F8B45AC292C455800730FA4 /* MigrationSuccessCell.swift in Sources */, 4054F4562214E3C800D261AB /* TagsCategoriesStatsRecordValue+CoreDataClass.swift in Sources */, E11000991CDB5F1E00E33887 /* KeychainTools.swift in Sources */, - D800D87020998A7300E7C7E5 /* SavedForLaterMenuItemCreator.swift in Sources */, E6D2E1671B8AAD8C0000ED14 /* ReaderTagStreamHeader.swift in Sources */, 321E292623A5F10900588610 /* FullScreenCommentReplyViewController.swift in Sources */, E6D3B1431D1C702600008D4B /* ReaderFollowedSitesViewController.swift in Sources */, + 8BEE846427B1E05B0001A93C /* DashboardCardModel.swift in Sources */, + 3F6AD0562502A91400080F3B /* AnnouncementsCache.swift in Sources */, 43FF64EF20DAA0840060A69A /* GravatarUploader.swift in Sources */, B5899ADE1B419C560075A3D6 /* NotificationSettingDetailsViewController.swift in Sources */, D8A3A5AA2069E53900992576 /* AztecMediaPickingCoordinator.swift in Sources */, + B06378C0253F639D00FD45D2 /* SiteSuggestion+CoreDataClass.swift in Sources */, + C7AFF87C283D5CF4000E01DF /* QRLoginVerifyCoordinator.swift in Sources */, + FA25FA212609AA9C0005E08F /* AppConfiguration.swift in Sources */, + 83EF3D7B2937D703000AF9BF /* SharedDataIssueSolver.swift in Sources */, 436D55DB210F862A00CEAA33 /* NibReusable.swift in Sources */, + F49B9A09293A3243000CEFCE /* MigrationEvent.swift in Sources */, 98563DDD21BF30C40006F5E9 /* TabbedTotalsCell.swift in Sources */, 82FC61241FA8ADAD00A1757E /* ActivityTableViewCell.swift in Sources */, 435D101A2130C2AB00BB2AA8 /* BlogDetailsViewController+FancyAlerts.swift in Sources */, + 8000361D292468D4007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel.swift in Sources */, + F1E72EBA267790110066FF91 /* UIViewController+Dismissal.swift in Sources */, + F532AD61253B81320013B42E /* StoriesIntroDataSource.swift in Sources */, E1ECE34F1FA88DA2007FA37A /* StoreContainer.swift in Sources */, 40232A9E230A6A740036B0B6 /* AbstractPost+HashHelpers.m in Sources */, FAE4201A1C5AEFE100C1D036 /* StartOverViewController.swift in Sources */, - B57B99D519A2C20200506504 /* NoteTableHeaderView.swift in Sources */, + 0A3FCA1D28B71CBD00499A15 /* FullScreenCommentReplyViewModel.swift in Sources */, 1790A4531E28F0ED00AE54C2 /* UINavigationController+Helpers.swift in Sources */, + FEA088052696F7AA00193358 /* WPStyleGuide+List.swift in Sources */, + 17C2FF0925D4852400CDB712 /* UnifiedProloguePages.swift in Sources */, 400A2C882217A985000A8A59 /* CountryStatsRecordValue+CoreDataClass.swift in Sources */, 08D345561CD7FBA900358E8C /* MenuHeaderViewController.m in Sources */, + 088CC594282BEC41007B9421 /* TooltipPresenter.swift in Sources */, B5120B381D47CC6C0059361A /* NotificationDetailsViewController.swift in Sources */, - 83418AAA11C9FA6E00ACF00C /* Comment.m in Sources */, 08F8CD391EBD2C970049D0C0 /* MediaURLExporter.swift in Sources */, E125443C12BF5A7200D87A0A /* WordPress.xcdatamodeld in Sources */, + 4A82C43128D321A300486CFF /* Blog+Post.swift in Sources */, B574CE111B5D8F8600A84FFD /* WPStyleGuide+AlertView.swift in Sources */, 731E88C921C9A10B0055C014 /* ErrorStateViewConfiguration.swift in Sources */, + 17C3FA6F25E591D200EFFE12 /* UIFont+Weight.swift in Sources */, 73E4E376227A033A0007D752 /* PostChart.swift in Sources */, + 4631359124AD013F0017E65C /* PageCoordinator.swift in Sources */, 43290D04215C28D800F6B398 /* Blog+QuickStart.swift in Sources */, - D82253E5219956540014D0E2 /* AddressCell.swift in Sources */, + 987C40C625E590BE002A0955 /* CommentsViewController+Filters.swift in Sources */, + D82253E5219956540014D0E2 /* AddressTableViewCell.swift in Sources */, FF1FD0242091268900186384 /* URL+LinkNormalization.swift in Sources */, + 3234BB082530D7DC0068DA40 /* ReaderTopicService+SiteInfo.swift in Sources */, B5326E6F203F554D007392C3 /* WordPressSupportSourceTag+Helpers.swift in Sources */, + FA4F383627D766020068AAF5 /* MySiteSettings.swift in Sources */, + F44293D228E3B18E00D340AF /* AppIconListViewModelType.swift in Sources */, 9AF724EF2146813C00F63A61 /* ParentPageSettingsViewController.swift in Sources */, 1759F1701FE017BF0003EC81 /* Queue.swift in Sources */, B56A70181B5040B9001D5815 /* SwitchTableViewCell.swift in Sources */, + 3F5B9B43288AFE4B001D17E9 /* DashboardBadgeCell.swift in Sources */, + C743535627BD7144008C2644 /* AnimatedGifAttachmentViewProvider.swift in Sources */, E66969E21B9E67A000EC9C00 /* ReaderTopicToReaderSiteTopic37to38.swift in Sources */, + 80535DC0294B7D3200873161 /* BlogDetailsViewController+JetpackBrandingMenuCard.swift in Sources */, B543D2B520570B5A00D3D4CC /* WordPressComSyncService.swift in Sources */, E14A52371E39F43E00EE203E /* AppRatingsUtility.swift in Sources */, + 46638DF6244904A3006E8439 /* GutenbergBlockProcessor.swift in Sources */, + 46241C0F2540BD01002B8A12 /* SiteDesignStep.swift in Sources */, 8350E49611D2C71E00A7B073 /* Media.m in Sources */, D8B9B58F204F4EA1003C6042 /* NetworkAware.swift in Sources */, B54346961C6A707D0010B3AD /* LanguageViewController.swift in Sources */, + FAB9826E2697038700B172A3 /* StatsViewController+JetpackSettings.swift in Sources */, 43D74AD020F906EE004AD934 /* InlineEditableNameValueCell.swift in Sources */, 4089C51122371B120031CE78 /* TodayStatsRecordValue+CoreDataProperties.swift in Sources */, F928EDA3226140620030D451 /* WPCrashLoggingProvider.swift in Sources */, D8A3A5B3206A49BF00992576 /* StockPhotosMedia.swift in Sources */, 176BB87F20D0068500751DCE /* FancyAlertViewController+SavedPosts.swift in Sources */, E6805D321DCD399600168E4F /* WPRichTextMediaAttachment.swift in Sources */, + 8071390727D039E70012DB21 /* DashboardSingleStatView.swift in Sources */, + C81CCD64243AECA100A83E27 /* TenorMediaObject.swift in Sources */, B5C0CF3D204DA41000DB0362 /* NotificationReplyStore.swift in Sources */, + AE2F3128270B6DE200B2A9C2 /* NSMutableAttributedString+ApplyAttributesToQuotes.swift in Sources */, B53AD9BC1BE95687009AB87E /* SettingsTextViewController.m in Sources */, E1389ADB1C59F7C200FB2466 /* PlanListViewController.swift in Sources */, + 80A2153D29C35197002FE8EB /* StaticScreensTabBarWrapper.swift in Sources */, + 1E485A90249B61440000A253 /* GutenbergRequestAuthenticator.swift in Sources */, + 084FC3BC299155C900A17BCF /* JetpackOverlayCoordinator.swift in Sources */, 8370D10A11FA499A009D650F /* WPTableViewActivityCell.m in Sources */, - 173BCE731CEB368A00AE8817 /* DomainsListViewController.swift in Sources */, E1B912891BB01288003C25B9 /* PeopleViewController.swift in Sources */, + 3FEC241525D73E8B007AFE63 /* ConfettiView.swift in Sources */, 436D561F2117312700CEAA33 /* RegisterDomainSuggestionsViewController.swift in Sources */, - 8BC12F7523201917004DDA72 /* PostService+MarkAsFailedAndDraftIfNeeded.swift in Sources */, + 8BC12F7523201917004DDA72 /* AbstractPost+MarkAsFailedAndDraftIfNeeded.swift in Sources */, + B0B68A9D252FA91E0001B28C /* UserSuggestion+CoreDataProperties.swift in Sources */, + DC772AF3282009BA00664C02 /* StatsLineChartConfiguration.swift in Sources */, 405BFB21223B37A000CD5BEA /* FollowersCountStatsRecordValue+CoreDataProperties.swift in Sources */, D8212CC720AA85C1008E8AE8 /* ReaderHeaderAction.swift in Sources */, 405B53FB1F83C369002E19BF /* FancyAlerts+VerificationPrompt.swift in Sources */, @@ -12005,78 +21310,144 @@ E166FA1B1BB0656B00374B5B /* PeopleCellViewModel.swift in Sources */, 73CE3E0E21F7F9D3007C9C85 /* TableViewOffsetCoordinator.swift in Sources */, 436D56292117312700CEAA33 /* RegisterDomainDetailsViewController.swift in Sources */, + F4EDAA4C29A516EA00622D3D /* ReaderPostService.swift in Sources */, + F5E29036243E4F5F00C19CA5 /* FilterProvider.swift in Sources */, + F4FE743429C3767300AC2729 /* AddressTableViewCell+ViewModel.swift in Sources */, 027AC51D227896540033E56E /* DomainCreditEligibilityChecker.swift in Sources */, 83FEFC7611FF6C5A0078B462 /* SiteSettingsViewController.m in Sources */, - 91DCE84421A6A7840062F134 /* PostEditor+BlogPicker.swift in Sources */, B56994451B7A7EF200FF26FA /* WPStyleGuide+Comments.swift in Sources */, 8B05D29123A9417E0063B9AA /* WPMediaEditor.swift in Sources */, + 987E79CB261F8858000192B7 /* UserProfileSheetViewController.swift in Sources */, D816C1EC20E0887C00C4D82F /* ApproveComment.swift in Sources */, 74AF4D741FE417D200E3EBFE /* MediaUploadOperation.swift in Sources */, 981C986E21B9D71400A7C0C8 /* PostingActivityCollectionViewCell.swift in Sources */, 436D55D9210F85DD00CEAA33 /* NibLoadable.swift in Sources */, + 8B36256625A60CCA00D7CCE3 /* BackupListViewController.swift in Sources */, + FAA4013427B52455009E1137 /* QuickActionButton.swift in Sources */, + 32A218D8251109DB00D1AE6C /* ReaderReportPostAction.swift in Sources */, + FA681F8A25CA946B00DAA544 /* BaseRestoreStatusFailedViewController.swift in Sources */, + 081E4B4C281C019A0085E89C /* TooltipAnchor.swift in Sources */, + 3F851415260D0A3300A4B938 /* UnifiedPrologueEditorContentView.swift in Sources */, + 4AD5656F28E413160054C676 /* Blog+History.swift in Sources */, + 179A70F02729834B006DAC0A /* Binding+OnChange.swift in Sources */, D8D7DF5A20AD18A400B40A2D /* ImgUploadProcessor.swift in Sources */, 436D56222117312700CEAA33 /* RegisterDomainDetailsViewModel.swift in Sources */, + DC76668326FD9AC9009254DD /* TimeZoneRow.swift in Sources */, + C76F48DC25BA202600BFEC87 /* JetpackScanHistoryViewController.swift in Sources */, 7E407121237163B8003627FA /* GutenbergStockPhotos.swift in Sources */, + FA4B203B29AE62C00089FE68 /* BlazeOverlayViewController.swift in Sources */, + F11C9F74243B3C3E00921DDC /* MediaHost+Blog.swift in Sources */, + 8B6214E027B1AD9D001DF7B6 /* DashboardStatsCardCell.swift in Sources */, + 837B49DD283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataProperties.swift in Sources */, E15644ED1CE0E4FE00D96E64 /* PlanListRow.swift in Sources */, 738B9A4E21B85CF20005062B /* SiteCreationWizard.swift in Sources */, + 8313B9FA2995A03C000AF26E /* JetpackRemoteInstallCardView.swift in Sources */, 4054F4432213635000D261AB /* TopCommentsAuthorStatsRecordValue+CoreDataProperties.swift in Sources */, + DC772B0A28201F5300664C02 /* ViewsVisitorsLineChartCell.swift in Sources */, 98656BD82037A1770079DE67 /* SignupEpilogueViewController.swift in Sources */, + C81CCD80243BF7A600A83E27 /* TenorPicker.swift in Sources */, 74EFB5C8208674250070BD4E /* BlogListViewController+Activity.swift in Sources */, 738B9A5721B85CF20005062B /* TableDataCoordinator.swift in Sources */, + 80D9D00029E85EBF00FE3400 /* PageEditorPresenter.swift in Sources */, + FA73D7E52798765B00DF24B3 /* SitePickerViewController.swift in Sources */, B541276B1C0F7D610015CA80 /* SettingsMultiTextViewController.m in Sources */, + 3F810A5A2616870C00ADDCC2 /* UnifiedPrologueIntroContentView.swift in Sources */, + F5E6312B243BC83E0088229D /* FilterSheetViewController.swift in Sources */, + 8BC81D6527CFC0DA0057F790 /* BlogDashboardState.swift in Sources */, E6F2788021BC1A4A008B4DB5 /* Plan.swift in Sources */, B5AC00681BE3C4E100F8E7C3 /* DiscussionSettingsViewController.swift in Sources */, + B0637543253E7E7A00FD45D2 /* GutenbergSuggestionsViewController.swift in Sources */, D816C1F620E0896F00C4D82F /* TrashComment.swift in Sources */, + 8384C64128AAC82600EABE26 /* KeychainUtils.swift in Sources */, 08AAD69F1CBEA47D002B2418 /* MenusService.m in Sources */, - 9A4E216021F87AE200EFF212 /* QuickStartChecklistHeader.swift in Sources */, + 3F4370412893207C00475B6E /* JetpackOverlayView.swift in Sources */, + 1788106F260E488B00A98BD8 /* UnifiedPrologueNotificationsContentView.swift in Sources */, + 3223393C24FEC18100BDD4BF /* ReaderDetailFeaturedImageView.swift in Sources */, 98B52AE121F7AF4A006FF6B4 /* StatsDataHelper.swift in Sources */, + 8BAD272C241FEF3300E9D105 /* PrepublishingViewController.swift in Sources */, + 4A1E77C92988997C006281CC /* PublicizeConnection+Creation.swift in Sources */, 9A22D9C0214A6BCA00BAEAF2 /* PageListTableViewHandler.swift in Sources */, + F10D634F26F0B78E00E46CC7 /* Blog+Organization.swift in Sources */, + DCCDF75B283BEFEA00AA347E /* SiteStatsInsightsDetailsTableViewController.swift in Sources */, + 80A2154029CA68D5002FE8EB /* RemoteFeatureFlag.swift in Sources */, 74729CA62056FE6000D1394D /* SearchableItemConvertable.swift in Sources */, FFE3B2C71B2E651400E9F1E0 /* WPAndDeviceMediaLibraryDataSource.m in Sources */, - D82253ED219A8A960014D0E2 /* SiteInformationWizardContent.swift in Sources */, B59D994F1C0790CC0003D795 /* SettingsListEditorViewController.swift in Sources */, 9A4E215C21F75BBE00EFF212 /* QuickStartChecklistManager.swift in Sources */, + FAC1B81E29B0C2AC00E0C542 /* BlazeOverlayViewModel.swift in Sources */, + C81CCD84243BF7A600A83E27 /* NoResultsTenorConfiguration.swift in Sources */, 9A8ECE0F2254A3260043C8DA /* JetpackRemoteInstallViewController.swift in Sources */, 1724DDC81C60F1200099D273 /* PlanDetailViewController.swift in Sources */, 9A2CD5372146B8C700AE5055 /* Array+Page.swift in Sources */, 5D6C4B091B603E03005E3C43 /* WPTableViewHandler.m in Sources */, + C78543D225B889CC006CEAFB /* JetpackScanThreatViewModel.swift in Sources */, 40EE947F2213213F00CD264F /* PublicizeConnectionStatsRecordValue+CoreDataClass.swift in Sources */, - F5660D03235CF73800020B1E /* SchedulingCalendarViewController.swift in Sources */, 1702BBE01CF3034E00766A33 /* DomainsService.swift in Sources */, + 8B1CF00F2433902700578582 /* PasswordAlertController.swift in Sources */, + 1D19C56329C9D9A700FB0087 /* GutenbergVideoPressUploadProcessor.swift in Sources */, + 1770BD0D267A368100D5F8C0 /* BloggingRemindersPushPromptViewController.swift in Sources */, + FA7F92B825E61C7E00502D2A /* ReaderTagsFooter.swift in Sources */, + 80535DB82946C79700873161 /* JetpackBrandingMenuCardCell.swift in Sources */, D817799420ABFDB300330998 /* ReaderPostCellActions.swift in Sources */, 402B2A7920ACD7690027C1DC /* ActivityStore.swift in Sources */, E62AFB6A1DC8E593007484FC /* NSAttributedString+WPRichText.swift in Sources */, + 8B33BC9527A0C14C00DB5985 /* BlogDetailsViewController+QuickActions.swift in Sources */, 98812966219CE42A0075FF33 /* StatsTotalRow.swift in Sources */, + 46D6114F2555DAED00B0B7BB /* SiteCreationAnalyticsHelper.swift in Sources */, + 98A047722821CEBF001B4E2D /* BloggingPromptsViewController.swift in Sources */, B54866CA1A0D7042004AC79D /* NSAttributedString+Helpers.swift in Sources */, + 011896A229D5AF0700D34BA9 /* BlogDashboardCardConfigurable.swift in Sources */, + C81CCD81243BF7A600A83E27 /* TenorService.swift in Sources */, + 8B51844525893F140085488D /* FilterBarView.swift in Sources */, E105205B1F2B1CF400A948F6 /* BlogToBlogMigration_61_62.swift in Sources */, + FE3E83E526A58646008CE851 /* ListSimpleOverlayView.swift in Sources */, + 98B88452261E4E09007ED7F8 /* LikeUserTableViewCell.swift in Sources */, E16FB7E31F8B61040004DD9F /* WebKitViewController.swift in Sources */, + 8067340A27E3A50900ABC95E /* UIViewController+RemoveQuickStart.m in Sources */, + 3F8CBE0B24EEB0EA00F71234 /* AnnouncementsDataSource.swift in Sources */, + FE7FAABE299A998E0032A6F2 /* EventTracker.swift in Sources */, 7305138321C031FC006BD0A1 /* AssembledSiteView.swift in Sources */, + 321955C324BF77E400E3F316 /* ReaderTopicService+FollowedInterests.swift in Sources */, + 3234BB342530EA980068DA40 /* ReaderRecommendedSiteCardCell.swift in Sources */, 93414DE51E2D25AE003143A3 /* PostEditorState.swift in Sources */, 1707CE421F3121750020B7FE /* UICollectionViewCell+Tint.swift in Sources */, B5176CC31CDCE1C30083CF2D /* ManagedPerson+CoreDataProperties.swift in Sources */, + 17C1D7DC26735631006C8970 /* EmojiRenderer.swift in Sources */, + C81CCD7B243BF7A600A83E27 /* TenorStrings.swift in Sources */, 91DCE84621A6A7F50062F134 /* PostEditor+MoreOptions.swift in Sources */, + 3FB1929526C79EC6000F5AA3 /* Date+TimeStrings.swift in Sources */, + 178DDD57266E4165006C68C4 /* CalendarDayToggleButton.swift in Sources */, 73ACDF992114FE4500233AD4 /* NotificationSupportService.swift in Sources */, 08D345531CD7F50900358E8C /* MenusSelectionView.m in Sources */, 5D6C4B131B604190005E3C43 /* UITextView+RichTextView.swift in Sources */, E15644E91CE0E47C00D96E64 /* RoundedButton.swift in Sources */, - FFC6ADD41B56F295002F3C84 /* AboutViewController.swift in Sources */, + 08A250FC28D9F0E200F50420 /* CommentDetailInfoViewModel.swift in Sources */, 08CC67801C49B65A00153AD7 /* MenuLocation.m in Sources */, + FA73D7D6278D9E5D00DF24B3 /* BlogDashboardViewController.swift in Sources */, 4349B0AF218A477F0034118A /* RevisionsTableViewCell.swift in Sources */, 738B9A5921B85CF20005062B /* KeyboardInfo.swift in Sources */, E15644F31CE0E5A500D96E64 /* PlanDetailViewModel.swift in Sources */, + FA8E2FE527C6AE4500DA0982 /* QuickStartChecklistView.swift in Sources */, + 80D9D04629F760C400FE3400 /* FailableDecodable.swift in Sources */, 17E4CD0C238C33F300C56916 /* DebugMenuViewController.swift in Sources */, 08D978571CD2AF7D0054F19A /* MenuItemCheckButtonView.m in Sources */, 08216FCC1CDBF96000304BA7 /* MenuItemLinkViewController.m in Sources */, F5D0A64E23CC159400B20D27 /* PreviewWebKitViewController.swift in Sources */, - 173F6DFE212352D000A4C8E2 /* GiphyService.swift in Sources */, 733195832284FE9F0007D904 /* PeriodChart.swift in Sources */, + 3FBB2D2E27FB715900C57BBF /* SiteNameView.swift in Sources */, + F1450CF52437E1A600A28BFE /* MediaHost.swift in Sources */, 08216FC81CDBF96000304BA7 /* MenuItemAbstractPostsViewController.m in Sources */, + F1ADCAF7241FEF0C00F150D2 /* AtomicAuthenticationService.swift in Sources */, 5703A4C622C003DC0028A343 /* WPStyleGuide+Posts.swift in Sources */, + DC8F61F727032B3F0087AC5D /* TimeZoneFormatter.swift in Sources */, + 0118969F29D30CAD00D34BA9 /* DomainsDashboardCardTracker.swift in Sources */, 91138455228373EB00FB02B7 /* GutenbergVideoUploadProcessor.swift in Sources */, + FA73D7E927987BA500DF24B3 /* SitePickerViewController+SiteIcon.swift in Sources */, E125445612BF5B3900D87A0A /* PostCategory.m in Sources */, 08C388661ED7705E0057BE49 /* MediaAssetExporter.swift in Sources */, E6C892D61C601D55007AD612 /* SharingButtonsViewController.swift in Sources */, F115308121B17E66002F1D65 /* EditorFactory.swift in Sources */, + 982DDF92263238A6002B3904 /* LikeUser+CoreDataProperties.swift in Sources */, 9F8E38BE209C6DE200454E3C /* NotificationSiteSubscriptionViewController.swift in Sources */, 9826AE8621B5C72300C851FA /* PostingActivityDay.swift in Sources */, E1DB326C1D9ACD4A00C8FEBC /* ReaderTeamTopic.swift in Sources */, @@ -12087,31 +21458,52 @@ 17A28DC3205001A900EA6D9E /* FilterTabBar.swift in Sources */, 400A2C8C2217A985000A8A59 /* ClicksStatsRecordValue+CoreDataProperties.swift in Sources */, D858F30320E201F4007E8A1C /* EditComment.swift in Sources */, + FEA1123F29964BCA008097B0 /* WPComJetpackRemoteInstallViewModel.swift in Sources */, + FAE8EE99273AC06F00A65307 /* QuickStartSettings.swift in Sources */, B538F3891EF46EC8001003D5 /* UnknownEditorViewController.swift in Sources */, 937D9A1119F838C2007B9D5F /* AccountToAccount22to23.swift in Sources */, 400A2C772217A8A0000A8A59 /* VisitsSummaryStatsRecordValue+CoreDataClass.swift in Sources */, + 0C35FFF129CB81F700D224EB /* BlogDashboardHelpers.swift in Sources */, D8212CB320AA6861008E8AE8 /* ReaderFollowAction.swift in Sources */, + F5D399302541F25B0058D0AB /* SheetActions.swift in Sources */, + 93F7214F271831820021A09F /* SiteStatsPinnedItemStore.swift in Sources */, + 9895401126C1F39300EDEB5A /* EditCommentTableViewController.swift in Sources */, + FE29EFCD29A91160007CE034 /* WPAdminConvertibleRouter.swift in Sources */, + 8B7F51C924EED804008CF5B5 /* ReaderTracker.swift in Sources */, E6F2788421BC1A4A008B4DB5 /* PlanFeature.swift in Sources */, + 931215E4267F5003008C3B69 /* ReferrerDetailsTableViewController.swift in Sources */, + 3F421DF524A3EC2B00CA9B9E /* Spotlightable.swift in Sources */, D8A3A5AF206A442800992576 /* StockPhotosDataSource.swift in Sources */, - F53FF3A323EA3E45001AD596 /* BlogDetailsViewController+Header.swift in Sources */, - E6F058021C1A122B008000F9 /* ReaderPostMenu.swift in Sources */, + 8B24C4E3249A4C3E0005E8A5 /* OfflineReaderWebView.swift in Sources */, + 931215E6267F5192008C3B69 /* ReferrerDetailsViewModel.swift in Sources */, + FE003F60282D61BA006F8D1D /* BloggingPrompt+CoreDataProperties.swift in Sources */, 9A5C854822B3E42800BEE7A3 /* CountriesMapCell.swift in Sources */, + C7A09A4A28401B7B003096ED /* QRLoginService.swift in Sources */, B5B410B61B1772B000CFCF8D /* NavigationTitleView.swift in Sources */, + DC772B0928201F5300664C02 /* ViewsVisitorsChartMarker.swift in Sources */, + 8BAD53D6241922B900230F4B /* WPAnalyticsEvent.swift in Sources */, E1D458691309589C00BF0235 /* Coordinate.m in Sources */, 323F8F3123A22C8F000BA49C /* SiteCreationRotatingMessageView.swift in Sources */, 43DC0EF12040B23200896C9C /* SignupUsernameViewController.swift in Sources */, 1782BE841E70063100A91E7D /* MediaItemViewController.swift in Sources */, + F41E32FE287B47A500F89082 /* SuggestionsListViewModel.swift in Sources */, + FA4B203829A8C48F0089FE68 /* AbstractPost+Blaze.swift in Sources */, E10A2E9B134E8AD3007643F9 /* PostAnnotation.m in Sources */, + 2481B17F260D4D4E00AE59DB /* WPAccount+Lookup.swift in Sources */, 7E3E9B702177C9DC00FD5797 /* GutenbergViewController.swift in Sources */, + 800035C1292307E8007D2D26 /* ExtensionConfiguration.swift in Sources */, + FE32EFFF275914390040BE67 /* MenuSheetViewController.swift in Sources */, + 3FC7F89E2612341900FD8728 /* UnifiedPrologueStatsContentView.swift in Sources */, + 7D21280D251CF0850086DD2C /* EditPageViewController.swift in Sources */, 738B9A4F21B85CF20005062B /* SiteCreator.swift in Sources */, F5660D09235D1CDD00020B1E /* CalendarMonthView.swift in Sources */, - FFB7B8201A0012E80032E723 /* ApiCredentials.m in Sources */, + 08A250F828D9E87600F50420 /* CommentDetailInfoViewController.swift in Sources */, + FA1CEAC225CA9C2A005E7038 /* RestoreStatusFailedView.swift in Sources */, 9848DF8121B8BB5700B99DA4 /* PostingActivityLegend.swift in Sources */, B5DB8AF41C949DC20059196A /* WPImmuTableRows.swift in Sources */, E192E78C22EF453C008D725D /* WordPress-87-88.xcmappingmodel in Sources */, E6311C411EC9FF4A00122529 /* UsersService.swift in Sources */, E125F1E71E8E59C800320B67 /* SharePost+UIActivityItemSource.swift in Sources */, - D800D86820997D5000E7C7E5 /* DiscoverMenuItemCreator.swift in Sources */, E1B62A7B13AA61A100A6FCA4 /* WPWebViewController.m in Sources */, D813D67F21AA8BBF0055CCA1 /* ShadowView.swift in Sources */, D865721321869C590023A99C /* Wizard.swift in Sources */, @@ -12122,11 +21514,15 @@ E6F2788121BC1A4A008B4DB5 /* PlanGroup.swift in Sources */, 08216FCE1CDBF96000304BA7 /* MenuItemPostsViewController.m in Sources */, 4322A20D203E1885004EA740 /* SignupUsernameTableViewController.swift in Sources */, + 098B8576275E76FE004D299F /* AppLocalizedString.swift in Sources */, 7E7BEF7022E1AED8009A880D /* Blog+Editor.swift in Sources */, + 1756F1DF2822BB6F00CD0915 /* SparklineView.swift in Sources */, + 8BB185C624B5FB8500A4CCE8 /* ReaderCardService.swift in Sources */, 98487E3A21EE8FB500352B4E /* UITableViewCell+Stats.swift in Sources */, E14BCABB1E0BC817002E0603 /* Delay.swift in Sources */, D83CA3A520842CAF0060E310 /* Pageable.swift in Sources */, 17E362EC22C40BE8000E0C79 /* AppIconViewController.swift in Sources */, + 3FFA5ED22876152E00830E28 /* JetpackButton.swift in Sources */, 598DD1711B97985700146967 /* ThemeBrowserCell.swift in Sources */, B5A05AD91CA48601002EC787 /* ImageCropViewController.swift in Sources */, F93735F122D534FE00A3C312 /* LoggingURLRedactor.swift in Sources */, @@ -12134,33 +21530,49 @@ E6E27D621C6144DB0063F821 /* SharingButton.swift in Sources */, 570BFD8B22823D7B007859A8 /* PostActionSheet.swift in Sources */, E6374DC01C444D8B00F24720 /* PublicizeConnection.swift in Sources */, + C81CCD7C243BF7A600A83E27 /* TenorPageable.swift in Sources */, E6C0ED3B231DA23400A08B57 /* AccountService+MergeDuplicates.swift in Sources */, 0845B8C61E833C56001BA771 /* URL+Helpers.swift in Sources */, 17D975AF1EF7F6F100303D63 /* WPStyleGuide+Aztec.swift in Sources */, - 5D17F0BE1A1D4C5F0087CCB8 /* PrivateSiteURLProtocol.m in Sources */, E14B40FF1C58B93F005046F6 /* SettingsCommon.swift in Sources */, B50C0C661EF42B6400372C65 /* FormatBarItemProviders.swift in Sources */, + F117B120265C53AB00D2CAA9 /* BloggingRemindersScheduler.swift in Sources */, 439F4F352196537500F8D0C7 /* RevisionDiffViewController.swift in Sources */, 02D75D9922793EA2003FF09A /* BlogDetailsSectionFooterView.swift in Sources */, - 432A5AE021F9222A00603959 /* QuickStartListTitleCell.swift in Sources */, + 08E39B4528A3DEB200874CB8 /* UserPersistentStoreFactory.swift in Sources */, + C81CCD67243AECA200A83E27 /* TenorGIFCollection.swift in Sources */, + 4AD5656C28E3D0670054C676 /* ReaderPost+Helper.swift in Sources */, + 0118968F29D1EB5E00D34BA9 /* DomainsDashboardCardHelper.swift in Sources */, E137B1661F8B77D4006AC7FC /* WebNavigationDelegate.swift in Sources */, 937D9A0F19F83812007B9D5F /* WordPress-22-23.xcmappingmodel in Sources */, + 803DE81328FFAE36007D4E9C /* RemoteConfigStore.swift in Sources */, 9A1A67A622B2AD4E00FF8422 /* CountriesMap.swift in Sources */, 439F4F3A219B715300F8D0C7 /* RevisionsNavigationController.swift in Sources */, 40F46B6A22121BA800A2143B /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataClass.swift in Sources */, 17D5C3F71FFCF2D800EB70FF /* MediaProgressCoordinatorNoticeViewModel.swift in Sources */, + 46F584822624DCC80010A723 /* BlockEditorSettingsService.swift in Sources */, 984B139221F66AC60004B6A2 /* SiteStatsPeriodViewModel.swift in Sources */, - 98A437AE20069098004A8A57 /* DomainSuggestionsTableViewController.swift in Sources */, + 80D9CFFD29E711E200FE3400 /* DashboardPageCell.swift in Sources */, + C7AFF877283C2623000E01DF /* QRLoginScanningCoordinator.swift in Sources */, + DC9AF769285DF8A300EA2A0D /* StatsFollowersChartViewModel.swift in Sources */, + 4A9948E4297624EF006282A9 /* Blog+Creation.swift in Sources */, + 3F3087C424EDB7040087B548 /* AnnouncementCell.swift in Sources */, 5D1D04761B7A50B100CDE646 /* ReaderStreamViewController.swift in Sources */, B57B92BD1B73B08100DFF00B /* SeparatorsView.swift in Sources */, + C789952525816F96001B7B43 /* JetpackScanCoordinator.swift in Sources */, E69BA1981BB5D7D300078740 /* WPStyleGuide+ReadableMargins.m in Sources */, 7E4123C320F4097B00DF8486 /* FormattableContentStyles.swift in Sources */, 735A9681228E421F00461135 /* StatsBarChartConfiguration.swift in Sources */, + C3643ACF28AC049D00FC5FD3 /* SharingViewController.swift in Sources */, 2F161B0622CC2DC70066A5C5 /* LoadingStatusView.swift in Sources */, 080C44A91CE14A9F00B3A02F /* MenuDetailsViewController.m in Sources */, + DCF892C9282FA37100BB71E1 /* SiteStatsBaseTableViewController.swift in Sources */, 2FA6511B21F26A57009AA935 /* InlineErrorRetryTableViewCell.swift in Sources */, 08216FCF1CDBF96000304BA7 /* MenuItemSourceCell.m in Sources */, 462F4E0A18369F0B0028D2F8 /* BlogDetailsViewController.m in Sources */, + 178DDD1B266D7523006C68C4 /* BloggingRemindersFlowSettingsViewController.swift in Sources */, + FE9CC71A26D7A2A40026AEF3 /* CommentDetailViewController.swift in Sources */, + C3B5545329661F2C00A04753 /* ThemeBrowserViewController+JetpackBannerViewController.swift in Sources */, 3FCCAA1523F4A1A3004064C0 /* UIBarButtonItem+MeBarButton.swift in Sources */, FFEECFFC2084DE2B009B8CDB /* PostSettingsViewController+FeaturedImageUpload.swift in Sources */, D8212CB120AA64E1008E8AE8 /* ReaderLikeAction.swift in Sources */, @@ -12171,135 +21583,204 @@ 7360018F20A265C7001E5E31 /* String+Files.swift in Sources */, FF70A3231FD5840500BC270D /* UIImage+Export.swift in Sources */, D81322B32050F9110067714D /* NotificationName+Names.swift in Sources */, + F4BECD1B288EE5220078391A /* SuggestionsViewModelType.swift in Sources */, B5DD04741CD3DAB00003DF89 /* NSFetchedResultsController+Helpers.swift in Sources */, B55F1AA81C10936600FD04D4 /* BlogSettings+Discussion.swift in Sources */, E155EC721E9B7DCE009D7F63 /* PostTagPickerViewController.swift in Sources */, 98A25BD1203CB25F006A5807 /* SignupEpilogueCell.swift in Sources */, B57AF5FA1ACDC73D0075A7D2 /* NoteBlockActionsTableViewCell.swift in Sources */, + 8BA125EB27D8F5E4008B779F /* UIView+PinSubviewPriority.swift in Sources */, + FEAC916E28001FC4005026E7 /* AvatarTrainView.swift in Sources */, + 8BB185D224B63D5F00A4CCE8 /* ReaderCardsStreamViewController.swift in Sources */, 742C79981E5F511C00DB1608 /* DeleteSiteViewController.swift in Sources */, 85DA8C4418F3F29A0074C8A4 /* WPAnalyticsTrackerWPCom.m in Sources */, B50C0C5E1EF42A4A00372C65 /* AztecAttachmentViewController.swift in Sources */, - F53FF3A123E2377E001AD596 /* NewBlogDetailHeaderView.swift in Sources */, - E1BB85981F82459800797050 /* WebViewAuthenticator.swift in Sources */, 43FB3F411EBD215C00FC8A62 /* LoginEpilogueBlogCell.swift in Sources */, 57D66B9A234BB206005A2D74 /* PostServiceRemoteFactory.swift in Sources */, + 3FC8D19B244F43B500495820 /* ReaderTabItemsStore.swift in Sources */, 98EB126A20D2DC2500D2D5B5 /* NoResultsViewController+Model.swift in Sources */, + 3F2ABE1827704EE2005D8916 /* WPMediaAsset+VideoLimits.swift in Sources */, 570265152298921800F2214C /* PostListTableViewHandler.swift in Sources */, B5969E2220A49E86005E9DF1 /* UIAlertController+Helpers.swift in Sources */, 9A4E61F821A2C3BC0017A925 /* RevisionDiff+CoreData.swift in Sources */, 7E7947AB210BAC5E005BB851 /* NotificationCommentRange.swift in Sources */, + FE25C235271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift in Sources */, + C32A6A2C2832BF02002E9394 /* SiteDesignCategoryThumbnailSize.swift in Sources */, D816C1E920E0880400C4D82F /* NotificationAction.swift in Sources */, E19B17B01E5C69A5007517C6 /* NSManagedObject.swift in Sources */, - 749197EE209B9A2E006F5E66 /* ReaderCardContent+PostInformation.swift in Sources */, FF355D981FB492DD00244E6D /* ExportableAsset.swift in Sources */, E15644EF1CE0E53B00D96E64 /* PlanListViewModel.swift in Sources */, - 983002A822FA05D600F03DBB /* AddInsightTableViewController.swift in Sources */, + 983002A822FA05D600F03DBB /* InsightsManagementViewController.swift in Sources */, 98CAD296221B4ED2003E8F45 /* StatSection.swift in Sources */, + 4A878550290F2C7D0083AB78 /* Media+Sync.swift in Sources */, + 931F312C2714302A0075433B /* PublicizeServicesState.swift in Sources */, + 8BDA5A75247C63F300AB124C /* ReaderDetailCoordinator.swift in Sources */, + FE39C136269C37C900EFB827 /* ListTableViewCell.swift in Sources */, + 01E61E5A29F03DEC002E544E /* DashboardDomainsCardSearchView.swift in Sources */, + 803C493B283A7C0C00003E9B /* QuickStartChecklistHeader.swift in Sources */, 93C1147F18EC5DD500DAC95C /* AccountService.m in Sources */, FF945F701B28242300FB8AC4 /* MediaLibraryPickerDataSource.m in Sources */, 986C90882231AD6200FC31E1 /* PostStatsViewModel.swift in Sources */, + FAFC064E27D2360B002F0483 /* QuickStartCell.swift in Sources */, 9A4A8F4B235758EF00088CE4 /* StatsStore+Cache.swift in Sources */, BE87E1A21BD405790075D45B /* WP3DTouchShortcutHandler.swift in Sources */, 7E3E7A5D20E44DB00075D159 /* BadgeContentStyles.swift in Sources */, 5D4E30D11AA4B41A000D9904 /* WPStyleGuide+Pages.m in Sources */, + 3236F77224ABB6C90088E8F3 /* ReaderInterestsDataSource.swift in Sources */, 7E4123CA20F4184200DF8486 /* ActivityContentGroup.swift in Sources */, - D818FFD72191566B000E5FEE /* VerticalsWizardContent.swift in Sources */, 08F8CD2F1EBD29440049D0C0 /* MediaImageExporter.swift in Sources */, 912347192213484300BD9F97 /* GutenbergViewController+InformativeDialog.swift in Sources */, 2FA6511721F26A24009AA935 /* ChangePasswordViewController.swift in Sources */, D816C1F020E0893A00C4D82F /* LikeComment.swift in Sources */, + 982DA9A7263B1E2F00E5743B /* CommentService+Likes.swift in Sources */, + 80F8DAC1282B6546007434A0 /* WPAnalytics+QuickStart.swift in Sources */, 59E1D46E1CEF77B500126697 /* Page.swift in Sources */, + 321955BF24BE234C00E3F316 /* ReaderInterestsCoordinator.swift in Sources */, + FAE4327425874D140039EB8C /* ReaderSavedPostCellActions.swift in Sources */, 7E4123C220F4097B00DF8486 /* FormattableContentFormatter.swift in Sources */, + 9887560C2810BA7A00AD7589 /* BloggingPromptsIntroductionPresenter.swift in Sources */, 7E21C765202BBF4400837CF5 /* SearchAdsAttribution.swift in Sources */, 5DF8D26119E82B1000A2CD95 /* ReaderCommentsViewController.m in Sources */, + C3DD4DCE28BE5D4D0046C68E /* SplashPrologueViewController.swift in Sources */, + 1716AEFC25F2927600CF49EC /* MySiteViewController.swift in Sources */, + F18CB8962642E58700B90794 /* FixedSizeImageView.swift in Sources */, 7E3E7A6020E44E490075D159 /* FooterContentGroup.swift in Sources */, + 46F584B82624E6380010A723 /* BlockEditorSettings+GutenbergEditorSettings.swift in Sources */, 59A3CADD1CD2FF0C009BFA1B /* BasePageListCell.m in Sources */, - 436D56202117312700CEAA33 /* RegisterDomainSuggestionsTableViewController.swift in Sources */, E114D79A153D85A800984182 /* WPError.m in Sources */, 7E53AB0220FE5EAE005796FE /* ContentRouter.swift in Sources */, E16273E11B2ACEB600088AF7 /* BlogToBlog32to33.swift in Sources */, E678FC151C76241000F55F55 /* WPStyleGuide+ApplicationStyles.swift in Sources */, 7EFF208620EAD918009C4699 /* FormattableUserContent.swift in Sources */, + F1D8C6EB26BABE3E002E3323 /* WeeklyRoundupDebugScreen.swift in Sources */, 436D56262117312700CEAA33 /* RegisterDomainDetailsViewController+HeaderFooter.swift in Sources */, + 931215F2267FE162008C3B69 /* ReferrerDetailsSpamActionRow.swift in Sources */, E66EB6F91C1B7A76003DABC5 /* ReaderSpacerView.swift in Sources */, - B55853F31962337500FAF6C3 /* NSScanner+Helpers.m in Sources */, + AE2F3125270B6DA000B2A9C2 /* Scanner+QuotedText.swift in Sources */, B560914C208A671F00399AE4 /* WPStyleGuide+SiteCreation.swift in Sources */, + B0960C8727D14BD400BC9717 /* SiteIntentStep.swift in Sources */, + 9801E682274EEBC4002FDDB6 /* ReaderDetailCommentsHeader.swift in Sources */, + C352870527FDD35C004E2E51 /* SiteNameStep.swift in Sources */, B5EFB1C21B31B98E007608A3 /* NotificationSettingsService.swift in Sources */, + 011896A829D5BBB400D34BA9 /* DomainsDashboardFactory.swift in Sources */, 5903AE1B19B60A98009D5354 /* WPButtonForNavigationBar.m in Sources */, - 989643EC23A0437B0070720A /* WidgetDifferenceCell.swift in Sources */, - 98906506237CC1DF00218CD2 /* WidgetTwoColumnCell.swift in Sources */, + 1717139F265FE59700F3A022 /* ButtonStyles.swift in Sources */, + C3FF78E828354A91008FA600 /* SiteDesignSectionLoader.swift in Sources */, + 803BB989295B80D300B3F6D6 /* RootViewPresenter+EditorNavigation.swift in Sources */, B58C4ECA207C5E1A00E32E4D /* UIImage+Assets.swift in Sources */, - 5D5D0027187DA9D30027CEF6 /* PostCategoriesViewController.m in Sources */, E684383E221F535900752258 /* LoadMoreCounter.swift in Sources */, E6158ACA1ECDF518005FA441 /* LoginEpilogueUserInfo.swift in Sources */, E1E4CE0B1773C59B00430844 /* WPAvatarSource.m in Sources */, + C3234F5427EBBACA004ADB29 /* SiteIntentVertical.swift in Sources */, 983DBBAB22125DD500753988 /* StatsTableFooter.swift in Sources */, 85D239AE1AE5A5FC0074768D /* BlogSyncFacade.m in Sources */, + 8B0732F3242BF99B00E7FBD3 /* PrepublishingNavigationController.swift in Sources */, 5D97C2F315CAF8D8009B44DD /* UINavigationController+KeyboardFix.m in Sources */, - D8B9B591204F658A003C6042 /* CommentsViewController+NetworkAware.swift in Sources */, 4034FDEA2007C42400153B87 /* ExpandableCell.swift in Sources */, F5D0A65023CC15A800B20D27 /* PreviewNonceHandler.swift in Sources */, + 2F668B61255DD11400D0038A /* JetpackSpeedUpSiteSettingsViewController.swift in Sources */, 4054F43C221354E000D261AB /* TopCommentedPostStatsRecordValue+CoreDataProperties.swift in Sources */, 7E4123B920F4097B00DF8486 /* FormattableContentFactory.swift in Sources */, + FEC26030283FBA1A003D886A /* BloggingPromptCoordinator.swift in Sources */, 40E469932017F4070030DB5F /* PluginDirectoryViewController.swift in Sources */, E1D28E931F2F6EB500A5DAFD /* RoleService.swift in Sources */, B5C9401A1DB900DC0079D4FF /* AccountHelper.swift in Sources */, - E6E57CD51D0F08B200C22E3E /* ReaderMenuViewController.swift in Sources */, + 3F46AAFE25BF5D6300CE2E98 /* Sites.intentdefinition in Sources */, 08D978591CD2AF7D0054F19A /* MenuItemSourceTextBar.m in Sources */, 40E469952017FB1F0030DB5F /* PluginDirectoryViewModel.swift in Sources */, + F1527301243B290E00C8DC7A /* AbstractPost+Autosave.swift in Sources */, 745A41B02065405600299D75 /* ReaderPost+Searchable.swift in Sources */, E1A8CACB1C22FF7C0038689E /* MeViewController.swift in Sources */, + 178810D92612037900A98BD8 /* UnifiedPrologueReaderContentView.swift in Sources */, 9A341E5321997A1F0036662E /* BlogService+BlogAuthors.swift in Sources */, 17A28DC52050404C00EA6D9E /* AuthorFilterButton.swift in Sources */, 08216FD31CDBF96000304BA7 /* MenuItemTagsViewController.m in Sources */, 5D3E334E15EEBB6B005FC6F2 /* ReachabilityUtils.m in Sources */, + C81CCD7D243BF7A600A83E27 /* TenorDataSource.swift in Sources */, 9F3EFCA1208E305E00268758 /* ReaderTopicService+Subscriptions.swift in Sources */, 4054F43B221354E000D261AB /* TopCommentedPostStatsRecordValue+CoreDataClass.swift in Sources */, E14DFAFB1E07E7C400494688 /* Data.swift in Sources */, E14B40FD1C58B806005046F6 /* AccountSettingsViewController.swift in Sources */, 98B11B892216535100B7F2D7 /* StatsChildRowsView.swift in Sources */, + 4A358DE929B5F14C00BFCEBE /* SharingButton+Lookup.swift in Sources */, 08CC677F1C49B65A00153AD7 /* Menu.m in Sources */, BE13B3E71B2B58D800A4211D /* BlogListViewController.m in Sources */, + C768B5B425828A5D00556E75 /* JetpackScanStatusCell.swift in Sources */, B532D4E9199D4357006E4DF6 /* NoteBlockCommentTableViewCell.swift in Sources */, + 4AA33EF829963ABE005B6E23 /* ReaderAbstractTopic+Lookup.swift in Sources */, 73BFDA8A211D054800907245 /* Notifiable.swift in Sources */, 404B35D322E9BA0800AD0B37 /* RegisterDomainDetailsViewModel+CountryDialCodes.swift in Sources */, D8EB1FD121900810002AE1C4 /* BlogListViewController+SiteCreation.swift in Sources */, + 80B016D12803AB9F00D15566 /* DashboardPostsListCardCell.swift in Sources */, + E64595F0256B5D7800F7F90C /* CommentAnalytics.swift in Sources */, + 4A2172F828EAACFF0006F4F1 /* BlogQuery.swift in Sources */, FA5C740F1C599BA7000B528C /* TableViewHeaderDetailView.swift in Sources */, + FA73D7EC27987E4500DF24B3 /* SitePickerViewController+QuickStart.swift in Sources */, 0857C2771CE5375F0014AE99 /* MenuItemAbstractView.m in Sources */, - D800D86420997BA100E7C7E5 /* ReaderMenuItemCreator.swift in Sources */, 5DB4683B18A2E718004A89A9 /* LocationService.m in Sources */, 173BCE791CEB780800AE8817 /* Domain.swift in Sources */, + C324D7A928C26F3F00310DEF /* SplashPrologueStyleGuide.swift in Sources */, + 4AA33F012999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift in Sources */, + 46B1A16B26A774E500F058AE /* CollapsableHeaderView.swift in Sources */, 590E873B1CB8205700D1B734 /* PostListViewController.swift in Sources */, E15644EB1CE0E4C500D96E64 /* FeatureItemRow.swift in Sources */, + 0839F88C2993C1B500415038 /* JetpackPluginOverlayCoordinator.swift in Sources */, + F5A738BF244DF7E400EDE065 /* ReaderTagsTableViewController+Cells.swift in Sources */, + FAA9084C27BD60710093FFA8 /* MySiteViewController+QuickStart.swift in Sources */, + 8B0732E9242BA1F000E7FBD3 /* PrepublishingHeaderView.swift in Sources */, + 3F2ABE16277037A9005D8916 /* VideoLimitsAlertPresenter.swift in Sources */, + 173DF291274522A1007C64B5 /* AppAboutScreenConfiguration.swift in Sources */, + 3F3CA65025D3003C00642A89 /* StatsWidgetsStore.swift in Sources */, 0857C2791CE5375F0014AE99 /* MenuItemsVisualOrderingView.m in Sources */, + 1756DBDF28328B76006E6DB9 /* DonutChartView.swift in Sources */, + C700F9EE257FD64E0090938E /* JetpackScanViewController.swift in Sources */, D8212CB520AA68D5008E8AE8 /* ReaderSubscribingNotificationAction.swift in Sources */, FF8C54AD21F677260003ABCF /* GutenbergMediaInserterHelper.swift in Sources */, + 176BA53B268266E70025E4A3 /* BlogService+Reminders.swift in Sources */, 40C403F32215D66A00E8C894 /* TopViewedAuthorStatsRecordValue+CoreDataClass.swift in Sources */, + 08A7343F298AB68000F925C7 /* JetpackPluginOverlayViewModel.swift in Sources */, E11C4B72201096EF00A6619C /* JetpackState.swift in Sources */, 437542E31DD4E19E00D6B727 /* EditPostViewController.swift in Sources */, 595CB3761D2317D50082C7E9 /* PostListFilter.swift in Sources */, + 08EA036729C9B51200B72A87 /* Color+DesignSystem.swift in Sources */, 08E77F451EE87FCF006F9515 /* MediaThumbnailExporter.swift in Sources */, 400A2C862217A985000A8A59 /* ReferrerStatsRecordValue+CoreDataClass.swift in Sources */, + F1112AB2255C2D4600F1F746 /* BlogDetailHeaderView.swift in Sources */, B5EB19EC20C6DACC008372B9 /* ImageDownloader.swift in Sources */, + 8B0CE7D12481CFE8004C4799 /* ReaderDetailHeaderView.swift in Sources */, E240859C183D82AE002EB0EF /* WPAnimatedBox.m in Sources */, + 4A1E77CC2989F2F7006281CC /* WPAccount+DeduplicateBlogs.swift in Sources */, 08472A201C727E020040769D /* PostServiceOptions.m in Sources */, 9A38DC6A218899FB006A409B /* DiffTitleValue.swift in Sources */, + B0AC50DD251E96270039E022 /* ReaderCommentsViewController.swift in Sources */, 73856E5B21E1602400773CD9 /* SiteCreationRequest+Validation.swift in Sources */, + 17870A74281FBEC100D1C627 /* StatsTotalInsightsCell.swift in Sources */, 7E4123BA20F4097B00DF8486 /* FormattableContentGroup.swift in Sources */, 08216FD21CDBF96000304BA7 /* MenuItemSourceViewController.m in Sources */, 74558369201A1FD3007809BB /* WPReusableTableViewCells.swift in Sources */, + 17D9362724769579008B2205 /* HomepageSettingsService.swift in Sources */, E6DAABDD1CF632EC0069D933 /* ReaderSearchSuggestionsViewController.swift in Sources */, 8B3DECAB2388506400A459C2 /* SentryStartupEvent.swift in Sources */, + 3FBF21B7267AA17A0098335F /* BloggingRemindersAnimator.swift in Sources */, + C700FAB2258020DB0090938E /* JetpackScanThreatCell.swift in Sources */, D8C31CC62188490000A33B35 /* SiteSegmentsCell.swift in Sources */, - 98921EFB21372E57004949AA /* NewsService.swift in Sources */, + 83796699299C048E004A92B9 /* DashboardJetpackInstallCardCell.swift in Sources */, E6431DE51C4E892900FD8D90 /* SharingConnectionsViewController.m in Sources */, 5D8D53F119250412003C8859 /* BlogSelectorViewController.m in Sources */, + 17523381246C4F9200870B4A /* HomepageSettingsViewController.swift in Sources */, + E62CE58E26B1D14200C9D147 /* AccountService+Cookies.swift in Sources */, + C81CCD7F243BF7A600A83E27 /* TenorMediaGroup.swift in Sources */, + B0F2EFBF259378E600C7EB6D /* SiteSuggestionService.swift in Sources */, 4388FF0020A4E19C00783948 /* NotificationsViewController+PushPrimer.swift in Sources */, + 800035BD291DD0D7007D2D26 /* JetpackFullscreenOverlayGeneralViewModel+Analytics.swift in Sources */, + 98517E5928220411001FFD45 /* BloggingPromptTableViewCell.swift in Sources */, 5D3D559718F88C3500782892 /* ReaderPostService.m in Sources */, 7EA30DB521ADA20F0092F894 /* EditorMediaUtility.swift in Sources */, + C35D4FF1280077F100DB90B5 /* SiteCreationStep.swift in Sources */, 7492F78E1F9BD94500B5A04A /* ShareMediaFileManager.swift in Sources */, - 32C765BA23F715E4000A7F11 /* PostSignUpInterstitialCoordinator.swift in Sources */, + 8379669C299C0C44004A92B9 /* JetpackRemoteInstallTableViewCell.swift in Sources */, 17A4A36C20EE55320071C2CA /* ReaderCoordinator.swift in Sources */, + FAB800B225AEE3C600D5D54A /* RestoreCompleteView.swift in Sources */, B532D4EE199D4418006E4DF6 /* NoteBlockImageTableViewCell.swift in Sources */, 93FA59DD18D88C1C001446BC /* PostCategoryService.m in Sources */, 436D564F211E122D00CEAA33 /* RegisterDomainDetailsServiceProxy.swift in Sources */, @@ -12311,265 +21792,428 @@ E1BEEC631C4E35A8000B4FA0 /* Animator.swift in Sources */, 981C3494218388CA00FC2683 /* SiteStatsDashboardViewController.swift in Sources */, E1C5457E1C6B962D001CEB0E /* MediaSettings.swift in Sources */, + 173B215527875E0600D4DD6B /* DeviceMediaPermissionsHeader.swift in Sources */, 02BF30532271D7F000616558 /* DomainCreditRedemptionSuccessViewController.swift in Sources */, 93C4864F181043D700A24725 /* ActivityLogDetailViewController.m in Sources */, B57B99DE19A2DBF200506504 /* NSObject+Helpers.m in Sources */, E1E49CE41C4902EE002393A4 /* ImmuTableViewController.swift in Sources */, D88A6496208D7B0B008AE9BC /* NullStockPhotosService.swift in Sources */, 9A162F2321C26D7500FDC035 /* RevisionPreviewViewController.swift in Sources */, - 3F30E50923FB362700225013 /* WPTabBarController+MeNavigation.swift in Sources */, + 467D3DFA25E4436000EB9CB0 /* SitePromptView.swift in Sources */, + FAB8AA2225AF031200F9F8A0 /* BaseRestoreCompleteViewController.swift in Sources */, E616E4B31C480896002C024E /* SharingService.swift in Sources */, - 43D74AC820F8D17A004AD934 /* DomainSuggestionsButtonViewPresenter.swift in Sources */, + F5E032E82408D537003AF350 /* BottomSheetPresentationController.swift in Sources */, + FA98A24D2832A5E9003B9233 /* NewQuickStartChecklistView.swift in Sources */, + FA98B61929A3BF050071AAE8 /* BlazeCardView.swift in Sources */, + AB758D9E25EFDF9C00961C0B /* LikesListController.swift in Sources */, E1FD45E01C030B3800750F4C /* AccountSettingsService.swift in Sources */, + 8BDC4C39249BA5CA00DE0A2D /* ReaderCSS.swift in Sources */, + 80EF672527F3D63B0063B138 /* DashboardStatsStackView.swift in Sources */, E1D0D81616D3B86800E33F4C /* SafariActivity.m in Sources */, E603C7701BC94AED00AD49D7 /* WordPress-37-38.xcmappingmodel in Sources */, 741E22461FC0CC55007967AB /* UploadOperation.swift in Sources */, + 46C984682527863E00988BB9 /* LayoutPickerAnalyticsEvent.swift in Sources */, 7E442FCD20F6AB9C00DEACA5 /* ActivityRange.swift in Sources */, 9AF9551821A1D7970057827C /* DiffAbstractValue+Attributes.swift in Sources */, + 8BD66ED42787530C00CCD95A /* PostsCardViewModel.swift in Sources */, + 80EF928D280E83110064A971 /* QuickStartToursCollection.swift in Sources */, 7E58879A20FE8D9300DB6F80 /* Environment.swift in Sources */, 591232691CCEAA5100B86207 /* AbstractPostListViewController.swift in Sources */, E14200781C117A2E00B3B115 /* ManagedAccountSettings.swift in Sources */, - F5E032DF2408D1F1003AF350 /* WPTabBarController+ShowTab.swift in Sources */, 401AC82722DD2387006D78D4 /* Blog+Plans.swift in Sources */, 08B6D6F31C8F7DCE0052C52B /* PostType.m in Sources */, + FE06AC8526C3C2F800B69DE4 /* ShareAppTextActivityItemSource.swift in Sources */, 59A9AB351B4C33A500A433DC /* ThemeService.m in Sources */, 5D6C4B111B604190005E3C43 /* NSAttributedString+RichTextView.swift in Sources */, - 43DDFE922178635D008BE72F /* QuickStartCongratulationsCell.swift in Sources */, + 176CF39A25E0005F00E1E598 /* NoteBlockButtonTableViewCell.swift in Sources */, 820ADD721F3A226E002D7F93 /* ThemeBrowserSectionHeaderView.swift in Sources */, + 8B065CC627BD5452005BA7AB /* DashboardEmptyPostsCardCell.swift in Sources */, + 9804A097263780B500354097 /* LikeUserHelpers.swift in Sources */, 82B85DF91EDDB807004FD510 /* SiteIconPickerPresenter.swift in Sources */, - B50421E91B68170F008EEA82 /* NoteUndoOverlayView.swift in Sources */, + C99B08FC26081AD600CA71EB /* TemplatePreviewViewController.swift in Sources */, B5D889411BEBE30A007C4684 /* BlogSettings.swift in Sources */, F12E500323C7C5330068CB5E /* WKWebView+UserAgent.swift in Sources */, + 8320BDE5283D9359009DF2DE /* BlogService+BloggingPrompts.swift in Sources */, + 8B749E7225AF522900023F03 /* JetpackCapabilitiesService.swift in Sources */, 5DF7F7781B223916003A05C8 /* PostToPost30To31.m in Sources */, + 1E5D00102493CE240004B708 /* GutenGhostView.swift in Sources */, 738B9A5621B85CF20005062B /* ModelSettableCell.swift in Sources */, + FA00863D24EB68B100C863F2 /* FollowCommentsService.swift in Sources */, 1746D7771D2165AE00B11D77 /* ForcePopoverPresenter.swift in Sources */, D858F30120E20106007E8A1C /* LikePost.swift in Sources */, 1703D04C20ECD93800D292E9 /* Routes+Post.swift in Sources */, E11C4B702010930B00A6619C /* Blog+Jetpack.swift in Sources */, + 082A645B291C2DD700668D2C /* Routes+Jetpack.swift in Sources */, + DC3B9B2C27739760003F7249 /* TimeZoneSelectorViewModel.swift in Sources */, F1DB8D292288C14400906E2F /* Uploader.swift in Sources */, + 3FB1929026C6109F000F5AA3 /* TimeSelectionView.swift in Sources */, + FAB8AB8B25AFFE7500F9F8A0 /* JetpackRestoreService.swift in Sources */, 821738091FE04A9E00BEC94C /* DateAndTimeFormatSettingsViewController.swift in Sources */, 5D6C4B081B603E03005E3C43 /* WPContentSyncHelper.swift in Sources */, - E6A2158E1D10627500DE5270 /* ReaderMenuViewModel.swift in Sources */, 73C8F06021BEED9100DDDF7E /* SiteAssemblyStep.swift in Sources */, + 17870A702816F2A000D1C627 /* StatsLatestPostSummaryInsightsCell.swift in Sources */, 82C420761FE44BD900CFB15B /* SiteSettingsViewController+Swift.swift in Sources */, + 803BB983295957F600B3F6D6 /* MySitesCoordinator+RootViewPresenter.swift in Sources */, + 8BBC778B27B5531700DBA087 /* BlogDashboardPersistence.swift in Sources */, + F5E29038243FAB0300C19CA5 /* FilterTableData.swift in Sources */, E6A3384C1BB08E3F00371587 /* ReaderGapMarker.m in Sources */, - D8281CF1212AB34C00D09098 /* NewsStats.swift in Sources */, + 011896A529D5B72500D34BA9 /* DomainsDashboardCoordinator.swift in Sources */, E1CFC1571E0AC8FF001DF9E9 /* Pattern.swift in Sources */, 930F09171C7D110E00995926 /* ShareExtensionService.swift in Sources */, + F5A34BCC25DF244F00C9654B /* KanvasCameraCustomUI.swift in Sources */, + C3A1166A29807E3F00B0CB6E /* ReaderBlockUserAction.swift in Sources */, 7ECD5B8120C4D823001AEBC5 /* MediaPreviewHelper.swift in Sources */, + FE06AC8326C3BD0900B69DE4 /* ShareAppContentPresenter.swift in Sources */, + F5A738BD244DF75400EDE065 /* OffsetTableViewHandler.swift in Sources */, 177E7DAD1DD0D1E600890467 /* UINavigationController+SplitViewFullscreen.swift in Sources */, + 8B74A9A8268E3C68003511CE /* RewindStatus+multiSite.swift in Sources */, 3F5B3EAF23A851330060FF1F /* ReaderReblogPresenter.swift in Sources */, 7E3E7A6220E44E6A0075D159 /* BodyContentGroup.swift in Sources */, + FEA088012696E7F600193358 /* ListTableHeaderView.swift in Sources */, 17AD36D51D36C1A60044B10D /* WPStyleGuide+Search.swift in Sources */, + F1E3536B25B9F74C00992E3A /* WindowManager.swift in Sources */, FF0148E51DFABBC9001AD265 /* NSFileManager+FolderSize.swift in Sources */, 731E88CB21C9A10B0055C014 /* ErrorStateViewController.swift in Sources */, BE87E1A01BD4054F0075D45B /* WP3DTouchShortcutCreator.swift in Sources */, - D800D86C20997EB400E7C7E5 /* OtherMenuItemCreator.swift in Sources */, 742B7F3A209CB2B6002E3CC9 /* GIFPlaybackStrategy.swift in Sources */, + 8B6214E327B1B2F3001DF7B6 /* BlogDashboardService.swift in Sources */, + F49D7BEB29DF329500CB93A5 /* UIPopoverPresentationController+PopoverAnchor.swift in Sources */, + F1D8C6E926BA94DF002E3323 /* WordPressBackgroundTaskEventHandler.swift in Sources */, D8212CC520AA83F9008E8AE8 /* ReaderCommentAction.swift in Sources */, + FAD954B825B7A99900F011B5 /* JetpackBackupStatusFailedViewController.swift in Sources */, 37EAAF4D1A11799A006D6306 /* CircularImageView.swift in Sources */, + 837966A2299E9C85004A92B9 /* JetpackInstallPluginHelper.swift in Sources */, + 3FD0316F24201E08005C0993 /* GravatarButtonView.swift in Sources */, 7EBB4126206C388100012D98 /* StockPhotosService.swift in Sources */, E17FEADA221494B2006E1D2D /* Blog+Analytics.swift in Sources */, E69551F61B8B6AE200CB8E4F /* ReaderStreamViewController+Helper.swift in Sources */, E61084BE1B9B47BA008050C5 /* ReaderAbstractTopic.swift in Sources */, + 8379669F299D51EC004A92B9 /* JetpackPlugin.swift in Sources */, 2F08ECFC2283A4FB000F8E11 /* PostService+UnattachedMedia.swift in Sources */, E61084BF1B9B47BA008050C5 /* ReaderDefaultTopic.swift in Sources */, + 8BB185D624B66FE600A4CCE8 /* ReaderCard+CoreDataClass.swift in Sources */, D816C1F220E0894D00C4D82F /* ReplyToComment.swift in Sources */, E1BB92321FDAAFFA00F2D817 /* TextWithAccessoryButtonCell.swift in Sources */, + C81CCD70243AFAE600A83E27 /* TenorResponse.swift in Sources */, + FAB8F78C25AD785400D5D54A /* BaseRestoreStatusViewController.swift in Sources */, + F4D8296A2931083000038726 /* MigrationSuccessCell+WordPress.swift in Sources */, 9872CB30203B8A730066A293 /* SignupEpilogueTableViewController.swift in Sources */, E1B23B081BFB3B370006559B /* MyProfileViewController.swift in Sources */, + F532AE1C253E55D40013B42E /* CreateButtonActionSheet.swift in Sources */, + 8BCB83D124C21063001581BD /* ReaderStreamViewController+Ghost.swift in Sources */, + 174C11932624C78900346EC6 /* Routes+Start.swift in Sources */, + 4AA33F04299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift in Sources */, + DCFC097329D3549C00277ECB /* DashboardDomainsCardCell.swift in Sources */, + FA347AED26EB6E300096604B /* GrowAudienceCell.swift in Sources */, + FE32F006275F62620040BE67 /* WebCommentContentRenderer.swift in Sources */, B5CC05F61962150600975CAC /* Constants.m in Sources */, - 177B4C27212316DC00CF8084 /* GiphyStrings.swift in Sources */, 4326191522FCB9DC003C7642 /* MurielColor.swift in Sources */, 436D562B2117312700CEAA33 /* RegisterDomainDetailsErrorSectionFooter.swift in Sources */, 7E729C28209A087300F76599 /* ImageLoader.swift in Sources */, + 3FAF9CC226D01CFE00268EA2 /* DomainsDashboardView.swift in Sources */, 73C8F06821BF1A5E00DDDF7E /* SiteAssemblyContentView.swift in Sources */, + 808C578F27C7FB1A0099A92C /* ButtonScrollView.swift in Sources */, + 178DDD30266D7576006C68C4 /* BloggingRemindersFlowCompletionViewController.swift in Sources */, 5D146EBB189857ED0068FDC6 /* FeaturedImageViewController.m in Sources */, + 98BAA7C326F925F70073A2F9 /* InlineEditableSingleLineCell.swift in Sources */, 57276E71239BDFD200515BE2 /* NotificationCenter+ObserveMultiple.swift in Sources */, D817799020ABF26800330998 /* ReaderCellConfiguration.swift in Sources */, - D88106FC20C0D4A4001D2F00 /* ReaderSavedPostCellActions.swift in Sources */, FF1B11E5238FDFE70038B93E /* GutenbergGalleryUploadProcessor.swift in Sources */, + F5E1BA9B253A0A5E0091E9A6 /* StoriesIntroViewController.swift in Sources */, 31EC15081A5B6675009FC8B3 /* WPStyleGuide+Suggestions.m in Sources */, 9865257D2194D77F0078B916 /* SiteStatsInsightsViewModel.swift in Sources */, 9F74696B209EFD0C0074D52B /* CheckmarkTableViewCell.swift in Sources */, + 03216ECC27995F3500D444CA /* SchedulingViewControllerPresenter.swift in Sources */, + F52CACCA244FA7AA00661380 /* ReaderManageScenePresenter.swift in Sources */, FF5371631FDFF64F00619A3F /* MediaService.swift in Sources */, 40C403F52215D66A00E8C894 /* TopViewedPostStatsRecordValue+CoreDataClass.swift in Sources */, 5DAFEAB81AF2CA6E00B3E1D7 /* PostMetaButton.m in Sources */, 40ADB15520686870009A9161 /* PluginStore+Persistence.swift in Sources */, + FE4DC5A3293A75FC008F322F /* MigrationDeepLinkRouter.swift in Sources */, D83CA3A720842CD90060E310 /* ResultsPage.swift in Sources */, 7E929CD12110D4F200BCAD88 /* FormattableRangesFactory.swift in Sources */, + 9815D0B326B49A0600DF7226 /* Comment+CoreDataProperties.swift in Sources */, 08D978551CD2AF7D0054F19A /* Menu+ViewDesign.m in Sources */, + F44293D628E3BA1700D340AF /* AppIconListViewModel.swift in Sources */, 02AC3092226FFFAA0018D23B /* BlogDetailsViewController+DomainCredit.swift in Sources */, + 80D9CFF729E5010300FE3400 /* PagesCardViewModel.swift in Sources */, E60BD231230A3DD400727E82 /* KeyringAccountHelper.swift in Sources */, + FEDA1AD8269D475D0038EC98 /* ListTableViewCell+Comments.swift in Sources */, E142007A1C117A3B00B3B115 /* ManagedAccountSettings+CoreDataProperties.swift in Sources */, B535209D1AF7EB9F00B33BA8 /* PushAuthenticationService.swift in Sources */, + F41E3301287B5FE500F89082 /* SuggestionViewModel.swift in Sources */, + 1762B6DC2845510400F270A5 /* StatsReferrersChartViewModel.swift in Sources */, B57273611B66CCEF000D1C4F /* AlertView.swift in Sources */, + 3F851428260D1EA300A4B938 /* CircledIcon.swift in Sources */, 3101866B1A373B01008F7DF6 /* WPTabBarController.m in Sources */, + 803BB98C29637AFC00B3F6D6 /* BlurredEmptyViewController.swift in Sources */, 85B125461B0294F6008A3D95 /* UIAlertControllerProxy.m in Sources */, 73FF7030221F43CD00541798 /* StatsBarChartView.swift in Sources */, + F5E1BBE0253B74240091E9A6 /* URLQueryItem+Parameters.swift in Sources */, 0857C27A1CE5375F0014AE99 /* MenuItemView.m in Sources */, B5E51B7B203477DF00151ECD /* WordPressAuthenticationManager.swift in Sources */, FA4ADAD81C50687400F858D7 /* SiteManagementService.swift in Sources */, 7E4A773820F80414001C706D /* ActivityPostRange.swift in Sources */, + 32E1BFFD24AB9D28007A08F0 /* ReaderSelectInterestsViewController.swift in Sources */, D8212CC120AA7C58008E8AE8 /* ReaderShowMenuAction.swift in Sources */, B5BE31C41CB825A100BDF770 /* NSURLCache+Helpers.swift in Sources */, B5552D831CD1062400B26DF6 /* String+Extensions.swift in Sources */, + 806E53E127E01C7F0064315E /* DashboardStatsViewModel.swift in Sources */, + 469EB16824D9AD8B00C764CB /* CollapsableHeaderFilterBar.swift in Sources */, 40247E022120FE3600AE1C3C /* AutomatedTransferHelper.swift in Sources */, + 8BF281FC27CEB69C00AF8CF3 /* DashboardFailureCardCell.swift in Sources */, 082AB9D91C4EEEF4000CA523 /* PostTagService.m in Sources */, + 3F6975FF242D941E001F1807 /* ReaderTabViewModel.swift in Sources */, E62079E11CF7A61200F5CD46 /* ReaderSearchSuggestionService.swift in Sources */, + 3F6A7E92251BC1DC005B6A61 /* RootViewCoordinator+WhatIsNew.swift in Sources */, + 3FAE0652287C8FC500F46508 /* JPScrollViewDelegate.swift in Sources */, FF8A04E01D9BFE7400523BC4 /* CachedAnimatedImageView.swift in Sources */, B5A6CEA619FA800E009F07DE /* AccountToAccount20to21.swift in Sources */, F16C35DC23F3F78E00C81331 /* AutoUploadMessageProvider.swift in Sources */, 08216FAB1CDBF95100304BA7 /* MenuItemEditingViewController.m in Sources */, 984F86FB21DEDB070070E0E3 /* TopTotalsCell.swift in Sources */, 436D56282117312700CEAA33 /* RegisterDomainDetailsViewController+LocalizedStrings.swift in Sources */, + 4631359624AD068B0017E65C /* GutenbergLayoutPickerViewController.swift in Sources */, D8225409219AB2030014D0E2 /* SiteInformation.swift in Sources */, D83CA3A920842D190060E310 /* StockPhotosPageable.swift in Sources */, + FAF13E3025A59240003EE470 /* JetpackRestoreStatusViewController.swift in Sources */, + 3FA62FD326FE2E4B0020793A /* ShapeWithTextView.swift in Sources */, + F10465142554260600655194 /* BindableTapGestureRecognizer.swift in Sources */, E62079DF1CF79FC200F5CD46 /* ReaderSearchSuggestion.swift in Sources */, + 0CB4057E29C8DF84008EED0A /* BlogDashboardPersonalizeCardCell.swift in Sources */, E64ECA4D1CE62041000188A0 /* ReaderSearchViewController.swift in Sources */, + 803BB9792959543D00B3F6D6 /* RootViewCoordinator.swift in Sources */, 98F537A722496CF300B334F9 /* SiteStatsTableHeaderView.swift in Sources */, E14977181C0DC0770057CD60 /* MediaSizeSliderCell.swift in Sources */, + C7BB60162863609C00748FD9 /* QRLoginInternetConnectionChecker.swift in Sources */, 7E987F562108017B00CAFB88 /* NotificationContentRouter.swift in Sources */, + 46E327D124E705C7000944B3 /* PageLayoutService.swift in Sources */, + F574416E242569CA00E150A8 /* Route+Page.swift in Sources */, + 0C35FFF629CBB5DE00D224EB /* BlogDashboardEmptyStateCell.swift in Sources */, 177076211EA206C000705A4A /* PlayIconView.swift in Sources */, 57BAD50C225CCE1A006139EC /* WPTabBarController+Swift.swift in Sources */, E1468DE71E794A4D0044D80F /* LanguageSelectorViewController.swift in Sources */, E14694071F3459E2004052C8 /* PluginListViewController.swift in Sources */, FF0D8146205809C8000EE505 /* PostCoordinator.swift in Sources */, 7EA30DB621ADA20F0092F894 /* AztecAttachmentDelegate.swift in Sources */, + 931215F4267FE177008C3B69 /* ReferrerDetailsSpamActionCell.swift in Sources */, + FEDDD46F26A03DE900F8942B /* ListTableViewCell+Notifications.swift in Sources */, 82B67B361FC726CD006FB593 /* Memoize.swift in Sources */, 9AA0ADB3235F11DC0027AB5D /* StatsPeriodAsyncOperation.swift in Sources */, B5DBE4FE1D21A700002E81D3 /* NotificationsViewController.swift in Sources */, E1B84F001E02E94D00BF6434 /* PingHubManager.swift in Sources */, - E1CECE051E6F01CE009C6695 /* PostPreviewGenerator.swift in Sources */, 086103961EE09C91004D7C01 /* MediaVideoExporter.swift in Sources */, E126C81F1F95FC1B00A5F464 /* PluginViewController.swift in Sources */, E61084C11B9B47BA008050C5 /* ReaderSiteTopic.swift in Sources */, - D816B8CD2112D4AA0052CE4D /* NewsManager.swift in Sources */, + 0878580328B4CF950069F96C /* UserPersistentRepositoryUtility.swift in Sources */, + FE18495827F5ACBA00D26879 /* DashboardPromptsCardCell.swift in Sources */, + FAB8004925AEDC2300D5D54A /* JetpackBackupCompleteViewController.swift in Sources */, 9A8ECE0C2254A3260043C8DA /* JetpackLoginViewController.swift in Sources */, - 5DAE40AD19EC70930011A0AE /* ReaderPostHeaderView.m in Sources */, + 46F583A92624CE790010A723 /* BlockEditorSettings+CoreDataClass.swift in Sources */, 4054F4622214F50300D261AB /* StreakStatsRecordValue+CoreDataProperties.swift in Sources */, B50C0C641EF42B3A00372C65 /* Header+WordPress.swift in Sources */, + F504D2B125D60C5900A2764C /* StoryMediaLoader.swift in Sources */, 7E7BEF7322E1DD27009A880D /* EditorSettingsService.swift in Sources */, + C76F48EE25BA20EF00BFEC87 /* JetpackScanHistoryCoordinator.swift in Sources */, 17FCA6811FD84B4600DBA9C8 /* NoticeStore.swift in Sources */, 9A162F2921C271D300FDC035 /* RevisionBrowserState.swift in Sources */, + 8B8E50B627A4692000C89979 /* DashboardPostListErrorCell.swift in Sources */, + FAADE43A26159B2800BF29FE /* AppConstants.swift in Sources */, 1751E5911CE0E552000CA08D /* KeyValueDatabase.swift in Sources */, + B0CD27CF286F8858009500BF /* JetpackBannerView.swift in Sources */, + 8BF0B607247D88EB009A7457 /* UITableViewCell+enableDisable.swift in Sources */, 937F3E321AD6FDA7006BA498 /* WPAnalyticsTrackerAutomatticTracks.m in Sources */, + 171096CB270F01EA001BCDD6 /* DomainSuggestionsTableViewController.swift in Sources */, + 469CE07124BCFB04003BDC8B /* CollapsableHeaderCollectionViewCell.swift in Sources */, 7EB5824720EC41B200002702 /* NotificationContentFactory.swift in Sources */, + 80535DBE294AC89200873161 /* JetpackBrandingMenuCardPresenter.swift in Sources */, F53FF3AA23EA725C001AD596 /* SiteIconView.swift in Sources */, + FA3536F525B01A2C0005A3A0 /* JetpackRestoreCompleteViewController.swift in Sources */, 7E7947AD210BAC7B005BB851 /* FormattableNoticonRange.swift in Sources */, + 46F583AB2624CE790010A723 /* BlockEditorSettings+CoreDataProperties.swift in Sources */, + DCF892CC282FA3BB00BB71E1 /* SiteStatsImmuTableRows.swift in Sources */, + 08D553662821286300AA1E8D /* Tooltip.swift in Sources */, + FAB985C12697550C00B172A3 /* NoResultsViewController+StatsModule.swift in Sources */, B55086211CC15CCB004EADB4 /* PromptViewController.swift in Sources */, 40C403EC2215CD1300E8C894 /* SearchResultsStatsRecordValue+CoreDataProperties.swift in Sources */, + FE3D058326C419C4002A51B0 /* ShareAppContentPresenter+TableView.swift in Sources */, 82FC612C1FA8B7FC00A1757E /* ActivityListRow.swift in Sources */, 436110E022C4241A000773AD /* UIColor+MurielColorsObjC.swift in Sources */, B5B68BD41C19AAED00EB59E0 /* InteractiveNotificationsManager.swift in Sources */, - B54E1DF01A0A7BAA00807537 /* ReplyBezierView.swift in Sources */, + 46183CF5251BD658004F9AFD /* PageTemplateLayout+CoreDataProperties.swift in Sources */, + FA20751427A86B73001A644D /* UIScrollView+Helpers.swift in Sources */, + 981D464825B0D4E7000AA65C /* ReaderSeenAction.swift in Sources */, F57402A7235FF9C300374346 /* SchedulingDate+Helpers.swift in Sources */, 4395A15D2106718900844E8E /* QuickStartChecklistCell.swift in Sources */, 57C2331822FE0EC900A3863B /* PostAutoUploadInteractor.swift in Sources */, 9829162F2224BC1C008736C0 /* SiteStatsDetailsViewModel.swift in Sources */, + F11C9F78243B3C9600921DDC /* MediaHost+ReaderPostContentProvider.swift in Sources */, + FA1A55EF25A6F0740033967D /* RestoreStatusView.swift in Sources */, + 175CC1702720548700622FB4 /* DomainExpiryDateFormatter.swift in Sources */, 985793C822F23D7000643DBF /* CustomizeInsightsCell.swift in Sources */, + 93E6336F272C1074009DACF8 /* LoginEpilogueCreateNewSiteCell.swift in Sources */, 8261B4CC1EA8E13700668298 /* SVProgressHUD+Dismiss.m in Sources */, + 329F8E5624DDAC61002A5311 /* DynamicHeightCollectionView.swift in Sources */, + FECA442F28350B7800D01F15 /* PromptRemindersScheduler.swift in Sources */, 436D56242117312700CEAA33 /* RegisterDomainDetailsViewModel+RowList.swift in Sources */, + C81CCD65243AECA200A83E27 /* TenorGIF.swift in Sources */, 596C035E1B84F21D00899EEB /* ThemeBrowserViewController.swift in Sources */, 4353BFA9219E0E820009CED3 /* RevisionOperationViewController.swift in Sources */, E2E7EB46185FB140004F5E72 /* WPBlogSelectorButton.m in Sources */, + 803D90F7292F0188007CC0D0 /* JetpackRedirector.swift in Sources */, + 3F09CCAA2428FF8300D00A8C /* ReaderTabView.swift in Sources */, 9808655C203D079B00D58786 /* EpilogueUserInfoCell.swift in Sources */, 08216FD41CDBF96000304BA7 /* MenuItemTypeSelectionView.m in Sources */, 43B0BA962229927F00328C69 /* WordPressAppDelegate+openURL.swift in Sources */, + 803BB980295957CF00B3F6D6 /* WPTabBarController+RootViewPresenter.swift in Sources */, 176DEEE91D4615FE00331F30 /* WPSplitViewController.swift in Sources */, 400F4625201E74EE000CFD9E /* CollectionViewContainerRow.swift in Sources */, 9A8ECE0E2254A3260043C8DA /* JetpackConnectionWebViewController.swift in Sources */, + B06378C1253F639D00FD45D2 /* SiteSuggestion+CoreDataProperties.swift in Sources */, 4353BFB221A376BF0009CED3 /* UntouchableWindow.swift in Sources */, + FE32F002275F602E0040BE67 /* CommentContentRenderer.swift in Sources */, FF0AAE0A1A150A560089841D /* WPProgressTableViewCell.m in Sources */, D8A3A5B5206A4C7800992576 /* StockPhotosPicker.swift in Sources */, - E60C2ED71DE5075100488630 /* ReaderCommentCell.swift in Sources */, + 17F11EDB268623BA00D1BBA7 /* BloggingRemindersScheduleFormatter.swift in Sources */, + 98DCF4A5275945E00008630F /* ReaderDetailNoCommentCell.swift in Sources */, + 176E194725C465F70058F1C5 /* UnifiedPrologueViewController.swift in Sources */, + 8B92D69627CD51FA001F5371 /* DashboardGhostCardCell.swift in Sources */, + 4625B556253789C000C04AAD /* CollapsableHeaderViewController.swift in Sources */, + 2420BEF125D8DAB300966129 /* Blog+Lookup.swift in Sources */, 5DBCD9D518F35D7500B32229 /* ReaderTopicService.m in Sources */, + 2F09D134245223D300956257 /* HeaderDetailsContentStyles.swift in Sources */, + 01DBFD8729BDCBF200F3720F /* JetpackNativeConnectionService.swift in Sources */, 403F57BC20E5CA6A004E889A /* RewindStatusRow.swift in Sources */, + 4A9B81E32921AE03007A05D1 /* ContextManager.swift in Sources */, F5D0A64923C8FA1500B20D27 /* LinkBehavior.swift in Sources */, + 80C523A429959DE000B1C14B /* BlazeWebViewController.swift in Sources */, E6DE44671B90D251000FA7EF /* ReaderHelpers.swift in Sources */, + C7BB601C2863B3D600748FD9 /* QRLoginCameraPermissionsHandler.swift in Sources */, 7E3E7A5320E44B260075D159 /* SubjectContentStyles.swift in Sources */, 5D42A3DF175E7452005CFF05 /* AbstractPost.m in Sources */, 986C908422319EFF00FC31E1 /* PostStatsTableViewController.swift in Sources */, - E18549D9230EED73003C620E /* BlogService+Deduplicate.swift in Sources */, + C81CCD82243BF7A600A83E27 /* TenorResultsPage.swift in Sources */, FACB36F11C5C2BF800C6DF4E /* ThemeWebNavigationDelegate.swift in Sources */, E1F47D4D1FE0290C00C1D44E /* PluginListCell.swift in Sources */, + 3F5C865D25C9EBEF00BABE64 /* HomeWidgetAllTimeData.swift in Sources */, D829C33B21B12EFE00B09F12 /* UIView+Borders.swift in Sources */, + F1A75B9B2732EF3700784A70 /* AboutScreenTracker.swift in Sources */, 5D42A3E0175E7452005CFF05 /* BasePost.m in Sources */, 17F7C24922770B68002E5C2E /* main.swift in Sources */, 9A4E939F2268D9B400E14823 /* UIViewController+NoResults.swift in Sources */, E1CB6DA3200F376400945457 /* TimeZoneStore.swift in Sources */, + B0B68A9C252FA91E0001B28C /* UserSuggestion+CoreDataClass.swift in Sources */, + 46F583D42624D0BC0010A723 /* Blog+BlockEditorSettings.swift in Sources */, D80BC7A4207487F200614A59 /* MediaLibraryPicker.swift in Sources */, E663D1901C65383E0017F109 /* SharingAccountViewController.swift in Sources */, 93DEB88219E5BF7100F9546D /* TodayExtensionService.m in Sources */, + 8B3626F925A665E500D7CCE3 /* UIApplication+mainWindow.swift in Sources */, + 80D9D00329EF4C7F00FE3400 /* DashboardPageCreationCell.swift in Sources */, + 3FD83CBF246C751800381999 /* CoreDataIterativeMigrator.swift in Sources */, 17CE77EF20C6CDAA001DEA5A /* ReaderSiteSearchViewController.swift in Sources */, 400A2C832217A985000A8A59 /* TopViewedVideoStatsRecordValue+CoreDataClass.swift in Sources */, + DCCDF75E283BF02D00AA347E /* SiteStatsInsightsDetailsViewModel.swift in Sources */, + F1C740BF26B18E42005D0809 /* StoreSandboxSecretScreen.swift in Sources */, 5D42A3E2175E7452005CFF05 /* ReaderPost.m in Sources */, - 2FA6511A21F26A57009AA935 /* InlineErrorTableViewProvider.swift in Sources */, + FEA7948D26DD136700CEC520 /* CommentHeaderTableViewCell.swift in Sources */, + C3C39B0726F50D3900B1238D /* WordPressSupportSourceTag+Editor.swift in Sources */, + 80A2154329D1177A002FE8EB /* RemoteConfigDebugViewController.swift in Sources */, D80BC79C207464D200614A59 /* MediaLibraryMediaPickingCoordinator.swift in Sources */, + 46183CF4251BD658004F9AFD /* PageTemplateLayout+CoreDataClass.swift in Sources */, 982A4C3520227D6700B5518E /* NoResultsViewController.swift in Sources */, 57C3D393235DFD8E00FE9CE6 /* ActionDispatcherFacade.swift in Sources */, D88106F720C0C9A8001D2F00 /* ReaderSavedPostUndoCell.swift in Sources */, + B03B9236250BC5FD000A40AF /* Suggestion.swift in Sources */, 74EA3B88202A0462004F802D /* ShareNoticeConstants.swift in Sources */, 17A09B99238FE13B0022AE0D /* FeatureFlagOverrideStore.swift in Sources */, FF1933FF1BB17DA3006825B8 /* RelatedPostsPreviewTableViewCell.m in Sources */, B54075D41D3D7D5B0095C318 /* IntrinsicTableView.swift in Sources */, + 08A4E12C289D2337001D9EC7 /* UserPersistentRepository.swift in Sources */, + 3FB34ADA25672AA5001A74A6 /* HomeWidgetTodayData.swift in Sources */, 172E27D31FD98135003EA321 /* NoticePresenter.swift in Sources */, + 3F43704428932F0100475B6E /* JetpackBrandingCoordinator.swift in Sources */, + 010459E629153FFF000C7778 /* JetpackNotificationMigrationService.swift in Sources */, + FAC1B82729B1F1EE00E0C542 /* BlazePostPreviewView.swift in Sources */, E61084C21B9B47BA008050C5 /* ReaderTagTopic.swift in Sources */, F110239B2318479000C4E84A /* Media.swift in Sources */, + 176CE91627FB44C100F1E32B /* StatsBaseCell.swift in Sources */, + E1C2260723901AAD0021D03C /* WordPressOrgRestApi+WordPress.swift in Sources */, B53B02B31CAC3AAC003190A0 /* GravatarPickerViewController.swift in Sources */, D81879D920ABC647000CFA95 /* ReaderTableConfiguration.swift in Sources */, + 3F758FD524F6FB4900BBA2FC /* AnnouncementsStore.swift in Sources */, E6D2E1691B8AAD9B0000ED14 /* ReaderListStreamHeader.swift in Sources */, 981D092A211259840014ECAF /* NoResultsViewController+MediaLibrary.swift in Sources */, D83CA3B02084CAAF0060E310 /* StockPhotosDataLoader.swift in Sources */, + F4CBE3D929265BC8004FFBB6 /* LogOutActionHandler.swift in Sources */, E66969DA1B9E55AB00EC9C00 /* ReaderTopicToReaderTagTopic37to38.swift in Sources */, 9A4E271A22EF0C78001F6A6B /* ChangeUsernameViewModel.swift in Sources */, - D800D86620997C6E00E7C7E5 /* FollowingMenuItemCreator.swift in Sources */, 172797D91CE5D0CD00CB8057 /* PlansLoadingIndicatorView.swift in Sources */, + FA7AA45325BFD9BC005E7200 /* JetpackScanThreatDetailsViewController.swift in Sources */, + 931215EA267F59CB008C3B69 /* ReferrerDetailsHeaderCell.swift in Sources */, 7EFF208A20EADCB6009C4699 /* NotificationTextContent.swift in Sources */, - D816B8CF2112D4F90052CE4D /* DefaultNewsManager.swift in Sources */, + F1A38F212678C4DA00849843 /* BloggingRemindersFlow.swift in Sources */, 91DCE84821A6C58C0062F134 /* PostEditor+Publish.swift in Sources */, 98921EF721372E12004949AA /* MediaCoordinator.swift in Sources */, 9A9E3FA3230D5F0A00909BC4 /* StatsStackViewCell.swift in Sources */, + F5A738C3244E7A6F00EDE065 /* ReaderTagsTableViewModel.swift in Sources */, + 837B49DB283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataClass.swift in Sources */, 73C8F06421BEEF3400DDDF7E /* SiteAssemblyService.swift in Sources */, B5176CC11CDCE1B90083CF2D /* ManagedPerson.swift in Sources */, F59AAC10235E430F00385EE6 /* ChosenValueRow.swift in Sources */, - 177B4C2B2123185300CF8084 /* GiphyMediaGroup.swift in Sources */, + FA98B61C29A3DB840071AAE8 /* BlazeHelper.swift in Sources */, B54E1DF11A0A7BAA00807537 /* ReplyTextView.swift in Sources */, 40EE94802213213F00CD264F /* PublicizeConnectionStatsRecordValue+CoreDataProperties.swift in Sources */, + 931215EE267F6799008C3B69 /* ReferrerDetailsCell.swift in Sources */, 08216FCA1CDBF96000304BA7 /* MenuItemEditingFooterView.m in Sources */, E6D3E8491BEBD871002692E8 /* ReaderCrossPostCell.swift in Sources */, - D8AEA54D21C2216300AB4DCB /* SiteVerticalPresenter.swift in Sources */, E10290741F30615A00DAC588 /* Role.swift in Sources */, + B0637527253E7CEC00FD45D2 /* SuggestionsTableView.swift in Sources */, 08216FD11CDBF96000304BA7 /* MenuItemSourceResultsViewController.m in Sources */, 435B762222973D0600511813 /* UIColor+MurielColors.swift in Sources */, + 3F3DD0B626FD18EB00F5F121 /* Blog+DomainsDashboardView.swift in Sources */, + 4666534A2501552A00165DD4 /* LayoutPreviewViewController.swift in Sources */, 7326A4A8221C8F4100B4EB8C /* UIStackView+Subviews.swift in Sources */, 4349B0AC218A45270034118A /* RevisionsTableViewController.swift in Sources */, + 98BC522427F6245700D6E8C2 /* BloggingPromptsFeatureIntroduction.swift in Sources */, + 8BF1C81A27BC00AF00F1C203 /* BlogDashboardCardFrameView.swift in Sources */, E1209FA41BB4978B00D69778 /* PeopleService.swift in Sources */, 984B139421F66B2D0004B6A2 /* StatsPeriodStore.swift in Sources */, + DC772AF1282009BA00664C02 /* InsightsLineChart.swift in Sources */, E13A8C9B1C3E6EF2005BB1C1 /* ImmuTable+WordPress.swift in Sources */, 430D50BE212B7AAE008F15F4 /* NoticeStyle.swift in Sources */, B5899AE41B422D990075A3D6 /* NotificationSettings.swift in Sources */, 7E3E7A5720E44D130075D159 /* HeaderContentStyles.swift in Sources */, 9A38DC69218899FB006A409B /* Revision.swift in Sources */, 403269922027719C00608441 /* PluginDirectoryAccessoryItem.swift in Sources */, + 931215EC267F5F45008C3B69 /* ReferrerDetailsRow.swift in Sources */, FFB1FAA01BF0EC4E0090C761 /* PHAsset+Exporters.swift in Sources */, - D816B8D92112D85F0052CE4D /* News.swift in Sources */, + 465B097A24C877E500336B6C /* GutenbergLightNavigationController.swift in Sources */, 8298F38F1EEF2B15008EB7F0 /* AppFeedbackPromptView.swift in Sources */, + 931215E8267F52A6008C3B69 /* ReferrerDetailsHeaderRow.swift in Sources */, F17A2A1E23BFBD72001E96AC /* UIView+ExistingConstraints.swift in Sources */, 400A2C892217A985000A8A59 /* CountryStatsRecordValue+CoreDataProperties.swift in Sources */, + F1450CF32437DA3E00A28BFE /* MediaRequestAuthenticator.swift in Sources */, 9881296E219CF1310075FF33 /* StatsCellHeader.swift in Sources */, E1823E6C1E42231C00C19F53 /* UIEdgeInsets.swift in Sources */, - 98921EF921372E30004949AA /* LocalNewsService.swift in Sources */, 5D18FE9F1AFBB17400EFEED0 /* RestorePageTableViewCell.m in Sources */, + F42A1D9729928B360059CC70 /* BlockedAuthor.swift in Sources */, + 069A4AA62664448F00413FA9 /* GutenbergFeaturedImageHelper.swift in Sources */, B54C02241F38F50100574572 /* String+RegEx.swift in Sources */, FFB1FA9E1BF0EB840090C761 /* UIImage+Exporters.swift in Sources */, + F48D44BD2989AA8C0051EAA6 /* ReaderSiteService.m in Sources */, 98FCFC232231DF43006ECDD4 /* PostStatsTitleCell.swift in Sources */, E1556CF2193F6FE900FC52EA /* CommentService.m in Sources */, D80BC7A22074739400614A59 /* MediaLibraryStrings.swift in Sources */, 9A09F915230C3E9700F42AB7 /* StoreFetchingStatus.swift in Sources */, F582060223A85495005159A9 /* SiteDateFormatters.swift in Sources */, + F504D44825D717F600A2764C /* PostEditor.swift in Sources */, + 837B49D7283C2AE80061A657 /* BloggingPromptSettings+CoreDataClass.swift in Sources */, + 98BC522A27F6259700D6E8C2 /* BloggingPromptsFeatureDescriptionView.swift in Sources */, 400A2C8B2217A985000A8A59 /* ClicksStatsRecordValue+CoreDataClass.swift in Sources */, + FAD9458E25B5678700F011B5 /* JetpackRestoreWarningCoordinator.swift in Sources */, 5DB3BA0518D0E7B600F3F3E9 /* WPPickerView.m in Sources */, + 0880BADC29ED6FF3002D3AB0 /* UIColor+DesignSystem.swift in Sources */, + 805CC0C1296CF3B3002941DC /* JetpackNewUsersOverlaySecondaryView.swift in Sources */, B5E94D151FE04815000E7C20 /* UIImageView+SiteIcon.swift in Sources */, 17CE77ED20C6C2F3001DEA5A /* ReaderSiteSearchService.swift in Sources */, 98FF6A3E23A30A250025FD72 /* QuickStartNavigationSettings.swift in Sources */, @@ -12577,44 +22221,68 @@ E61084C01B9B47BA008050C5 /* ReaderListTopic.swift in Sources */, E6374DC11C444D8B00F24720 /* PublicizeService.swift in Sources */, 7E4A773C20F80598001C706D /* ActivityContentFactory.swift in Sources */, + 988FD74A279B75A400C7E814 /* NotificationCommentDetailCoordinator.swift in Sources */, + FEA1123C29944896008097B0 /* SelfHostedJetpackRemoteInstallViewModel.swift in Sources */, + FAD7625B29ED780B00C09583 /* JSONDecoderExtension.swift in Sources */, + C81CCD83243BF7A600A83E27 /* TenorDataLoader.swift in Sources */, 98797DBC222F434500128C21 /* OverviewCell.swift in Sources */, - 931D26FE19EDA10D00114F17 /* ALIterativeMigrator.m in Sources */, + C3BC86F629528997005D1A01 /* PeopleViewController+JetpackBannerViewController.swift in Sources */, 1749965F2271BF08007021BD /* WordPressAppDelegate.swift in Sources */, 5DA3EE161925090A00294E0B /* MediaService.m in Sources */, + 8BF9E03327B1A8A800915B27 /* DashboardCard.swift in Sources */, 7E4123C520F4097B00DF8486 /* FormattableContentAction.swift in Sources */, D8A468E521828D940094B82F /* SiteVerticalsService.swift in Sources */, + 3F8D988926153484003619E5 /* UnifiedPrologueBackgroundView.swift in Sources */, E65219FB1B8D10DA000B1217 /* ReaderBlockedSiteCell.swift in Sources */, + FAF13C5325A57ABD003EE470 /* JetpackRestoreWarningViewController.swift in Sources */, + FAE4CA682732C094003BFDFE /* QuickStartPromptViewController.swift in Sources */, 73C8F06321BEEF2F00DDDF7E /* SiteAssembly.swift in Sources */, + 326E281B250AC4A50029EBF0 /* ImageDimensionFetcher.swift in Sources */, 7435CE7320A4B9170075A1B9 /* AnimatedImageCache.swift in Sources */, 43AB7C5E1D3E70510066CB6A /* PostListFilterSettings.swift in Sources */, CE46018B21139E8300F242B6 /* FooterTextContent.swift in Sources */, + E6D6A1302683ABE6004C24A7 /* ReaderSubscribeCommentsAction.swift in Sources */, + F1BC842E27035A1800C39993 /* BlogService+Domains.swift in Sources */, + 80C740FB2989FC4600199027 /* PostStatsTableViewController+JetpackBannerViewController.swift in Sources */, B5EEDB971C91F10400676B2B /* Blog+Interface.swift in Sources */, + 982DDF96263238A6002B3904 /* LikeUserPreferredBlog+CoreDataProperties.swift in Sources */, 5D839AA8187F0D6B00811F4A /* PostFeaturedImageCell.m in Sources */, - 171909E4206CFFCD0054DF0B /* FancyAlertViewController+Async.swift in Sources */, + C7BB601F2863B9E800748FD9 /* QRLoginCameraSession.swift in Sources */, FFCB9F4B22A125BD0080A45F /* WPException.m in Sources */, 9A9E3FAD230E9DD000909BC4 /* StatsGhostCells.swift in Sources */, E14DF70620C922F200959BA9 /* NotificationCenter+ObserveOnce.swift in Sources */, E125F1E41E8E595E00320B67 /* SharePost.swift in Sources */, B56FEB791CD8E13C00E621F9 /* RoleViewController.swift in Sources */, D82253DC2199411F0014D0E2 /* SiteAddressService.swift in Sources */, - 17E60E09220DBD6E00848F89 /* WKWebView+Preview.swift in Sources */, + 83B1D037282C62620061D911 /* BloggingPromptsAttribution.swift in Sources */, B5B56D3319AFB68800B4E29B /* WPStyleGuide+Notifications.swift in Sources */, FF8DDCDF1B5DB1C10098826F /* SettingTableViewCell.m in Sources */, 9A3BDA0E22944F3500FBF510 /* CountriesMapView.swift in Sources */, 17BB26AE1E6D8321008CD031 /* MediaLibraryViewController.swift in Sources */, 98E58A2F2360D23400E5534B /* TodayWidgetStats.swift in Sources */, + F5E1577F25DE04E200EEEDFB /* GutenbergMediaFilesUploadProcessor.swift in Sources */, + 80EF672227F160720063B138 /* DashboardCustomAnnouncementCell.swift in Sources */, + 805CC0BC29668986002941DC /* JetpackBrandedScreen.swift in Sources */, 5DF7F7741B22337C003A05C8 /* WordPress-30-31.xcmappingmodel in Sources */, 436D562C2117312700CEAA33 /* RegisterDomainDetailsFooterView.swift in Sources */, 73F76E1E222851E300FDDAD2 /* Charts+AxisFormatters.swift in Sources */, 40EC1F0F2249CA8400F6785E /* OtherAndTotalViewsCount+CoreDataClass.swift in Sources */, 98579BC7203DD86F004086E4 /* EpilogueSectionHeaderFooter.swift in Sources */, + 8B15D27428009EBF0076628A /* BlogDashboardAnalytics.swift in Sources */, + 2F605FA8251430C200F99544 /* PostCategoriesViewController.swift in Sources */, + 24F3789825E6E62100A27BB7 /* NSManagedObject+Lookup.swift in Sources */, + C9F1D4BA2706EEEB00BDF917 /* HomepageEditorNavigationBarManager.swift in Sources */, 17D4153C22C2308D006378EF /* StatsPeriodHelper.swift in Sources */, 7E3E7A5920E44D2F0075D159 /* FooterContentStyles.swift in Sources */, + C81CCD7E243BF7A600A83E27 /* TenorMedia.swift in Sources */, E19B17B21E5C8F36007517C6 /* AbstractPost.swift in Sources */, + 93E6336C272AF504009DACF8 /* LoginEpilogueDividerView.swift in Sources */, + 3F73BE5D24EB38E200BE99FF /* WhatIsNewScenePresenter.swift in Sources */, + 8B69F19F255D67E7006B1CEF /* CalendarViewController.swift in Sources */, + DC76668526FD9AC9009254DD /* TimeZoneTableViewCell.swift in Sources */, 912347762216E27200BD9F97 /* GutenbergViewController+Localization.swift in Sources */, D816C1F420E0895E00C4D82F /* MarkAsSpam.swift in Sources */, 4349BFF7221205740084F200 /* BlogDetailsSectionHeaderView.swift in Sources */, - 5D577D361891360900B964C3 /* PostGeolocationView.m in Sources */, E1C9AA511C10419200732665 /* Math.swift in Sources */, B50C0C5A1EF42A2600372C65 /* MediaProgressCoordinator.swift in Sources */, B574CE151B5E8EA800A84FFD /* NSMutableAttributedString+Helpers.swift in Sources */, @@ -12622,99 +22290,156 @@ 98AE3DF5219A1789003C0E24 /* StatsInsightsStore.swift in Sources */, 08216FCD1CDBF96000304BA7 /* MenuItemPagesViewController.m in Sources */, 40A71C6B220E1952002E3D25 /* StatsRecordValue+CoreDataProperties.swift in Sources */, - 177B4C2D2123192200CF8084 /* GiphyMedia.swift in Sources */, 57D6C840229498C5003DDC7E /* InteractivePostView.swift in Sources */, - 7E9B90F821127CA400AF83E6 /* ContextManagerType.swift in Sources */, + 32F2566025012D3F006B8BC4 /* LinearGradientView.swift in Sources */, D865721221869C590023A99C /* WizardStep.swift in Sources */, 43D54D131DCAA070007F575F /* PostPostViewController.swift in Sources */, 43290D0A215E8B1200F6B398 /* QuickStartSpotlightView.swift in Sources */, 3F8CB10623A07B17007627BF /* ReaderReblogAction.swift in Sources */, + 4AA33EFB2999AE3B005B6E23 /* ReaderListTopic+Creation.swift in Sources */, + 934098C3271957A600B3E77E /* SiteStatsInsightsDelegate.swift in Sources */, 73C8F06221BEEEDE00DDDF7E /* SiteAssemblyWizardContent.swift in Sources */, 738B9A5E21B8632E0005062B /* UITableView+Header.swift in Sources */, + C9F1D4B72706ED7C00BDF917 /* EditHomepageViewController.swift in Sources */, + 80EF9286280D272E0064A971 /* DashboardPostsSyncManager.swift in Sources */, 7E3E7A5B20E44D950075D159 /* RichTextContentStyles.swift in Sources */, 40A71C66220E1952002E3D25 /* StatsRecord+CoreDataProperties.swift in Sources */, 7E846FF120FD0A0500881F5A /* ContentCoordinator.swift in Sources */, + FA332AD029C1F97A00182FBB /* MovedToJetpackViewController.swift in Sources */, + E690F6F025E05D180015A777 /* InviteLinks+CoreDataProperties.swift in Sources */, B54E1DF41A0A7BBF00807537 /* NotificationMediaDownloader.swift in Sources */, D86572172186C3600023A99C /* WizardDelegate.swift in Sources */, 73713583208EA4B900CCDFC8 /* Blog+Files.swift in Sources */, 439F4F3C219B78B500F8D0C7 /* RevisionDiffsBrowserViewController.swift in Sources */, + 46F583AD2624CE790010A723 /* BlockEditorSettingElement+CoreDataClass.swift in Sources */, + FAD256932611B01700EDAF88 /* UIColor+WordPressColors.swift in Sources */, E174F6E6172A73960004F23A /* WPAccount.m in Sources */, FF619DD51C75246900903B65 /* CLPlacemark+Formatting.swift in Sources */, 98467A3F221CD48500DF51AE /* SiteStatsDetailTableViewController.swift in Sources */, - E11450DF1C4E47E600A6BD0F /* NoticeAnimator.swift in Sources */, + E11450DF1C4E47E600A6BD0F /* MessageAnimator.swift in Sources */, + C77FC90F2800CAC100726F00 /* OnboardingEnableNotificationsViewController.swift in Sources */, + FA4F65A72594337300EAA9F5 /* JetpackRestoreOptionsViewController.swift in Sources */, 7E14635720B3BEAB00B95F41 /* WPStyleGuide+Loader.swift in Sources */, 087EBFA81F02313E001F7ACE /* MediaThumbnailService.swift in Sources */, 08F8CD2A1EBD22EF0049D0C0 /* MediaExporter.swift in Sources */, + FAC086D725EDFB1E00B94F2A /* ReaderRelatedPostsCell.swift in Sources */, + 324780E1247F2E2A00987525 /* NoResultsViewController+FollowedSites.swift in Sources */, E100C6BB1741473000AE48D8 /* WordPress-11-12.xcmappingmodel in Sources */, + 9822A8412624CFB900FD8A03 /* UserProfileSiteCell.swift in Sources */, 9A38DC6D218899FB006A409B /* DiffAbstractValue.swift in Sources */, - 177B4C252123161900CF8084 /* GiphyPicker.swift in Sources */, 17B7C8C120EE2A870042E260 /* Routes+Notifications.swift in Sources */, 7E92A1FB233CB1B7006D281B /* Autosaver.swift in Sources */, + FE76C5E0293A63A800573C92 /* UIApplication+AppAvailability.swift in Sources */, 9A341E5621997A340036662E /* BlogAuthor.swift in Sources */, + C7234A3A2832BA240045C63F /* QRLoginCoordinator.swift in Sources */, 9A341E5721997A340036662E /* Blog+BlogAuthors.swift in Sources */, + F4D829702931097900038726 /* DashboardMigrationSuccessCell+WordPress.swift in Sources */, 7E3AB3DB20F52654001F33B6 /* ActivityContentStyles.swift in Sources */, + 469CE06D24BCED75003BDC8B /* CategorySectionTableViewCell.swift in Sources */, + 809101982908DE8500FCB4EA /* JetpackFullscreenOverlayViewModel.swift in Sources */, 9A034CEB237DB8660047B41B /* StatsForegroundObservable.swift in Sources */, + 8B1CF0112433E61C00578582 /* AbstractPost+TitleForVisibility.swift in Sources */, + 0141929C2983F0A300CAEDB0 /* SupportConfiguration.swift in Sources */, E6431DE71C4E892900FD8D90 /* SharingViewController.m in Sources */, 7E4123C820F417EF00DF8486 /* FormattableActivity.swift in Sources */, 9A162F2B21C2A21A00FDC035 /* RevisionPreviewTextViewManager.swift in Sources */, 73D86969223AF4040064920F /* StatsChartLegendView.swift in Sources */, + F163541626DE2ECE008B625B /* NotificationEventTracker.swift in Sources */, + 3F8CBE0D24EED2CB00F71234 /* FindOutMoreCell.swift in Sources */, FF286C761DE70A4500383A62 /* NSURL+Exporters.swift in Sources */, + 3F3DD0AF26FCDA3100F5F121 /* PresentationButton.swift in Sources */, 988AC37522F10DD900BC1433 /* FileDownloadsStatsRecordValue+CoreDataProperties.swift in Sources */, E1A03EE217422DCF0085D192 /* BlogToAccount.m in Sources */, + C77FC90928009C7000726F00 /* OnboardingQuestionsPromptViewController.swift in Sources */, + 8BE6F92C27EE27DB0008BDC7 /* BlogDashboardPostCardGhostCell.swift in Sources */, + C395FB232821FE4400AE7C11 /* SiteDesignSection.swift in Sources */, + FA4F660525946B5F00EAA9F5 /* JetpackRestoreHeaderView.swift in Sources */, + FEA6517B281C491C002EA086 /* BloggingPromptsService.swift in Sources */, 436D55DF210F866900CEAA33 /* StoryboardLoadable.swift in Sources */, D8212CC320AA7F57008E8AE8 /* ReaderBlockSiteAction.swift in Sources */, - 5D44EB381986D8BA008B7175 /* ReaderSiteService.m in Sources */, + FA8E1F7725EEFA7300063673 /* ReaderPostService+RelatedPosts.swift in Sources */, B518E1651CCAA19200ADFE75 /* Blog+Capabilities.swift in Sources */, 575E126F229779E70041B3EB /* RestorePostTableViewCell.swift in Sources */, 08216FC91CDBF96000304BA7 /* MenuItemCategoriesViewController.m in Sources */, + 171CC15824FCEBF7008B7180 /* UINavigationBar+Appearance.swift in Sources */, 43C9908E21067E22009EFFEB /* QuickStartChecklistViewController.swift in Sources */, + 80EF929028105CFA0064A971 /* QuickStartFactory.swift in Sources */, 082635BB1CEA69280088030C /* MenuItemsViewController.m in Sources */, 822876F11E929CFD00696BF7 /* ReachabilityUtils+OnlineActions.swift in Sources */, E15632CC1EB9ECF40035A099 /* TableViewKeyboardObserver.swift in Sources */, E1A03F48174283E10085D192 /* BlogToJetpackAccount.m in Sources */, B522C4F81B3DA79B00E47B59 /* NotificationSettingsViewController.swift in Sources */, + FEC26033283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift in Sources */, + 93E63369272AC532009DACF8 /* LoginEpilogueChooseSiteTableViewCell.swift in Sources */, 9A51DA1522E9E8C7005CC335 /* ChangeUsernameViewController.swift in Sources */, 5D6C4B121B604190005E3C43 /* RichTextView.swift in Sources */, 3F43602F23F31D48001DEE70 /* ScenePresenter.swift in Sources */, 9A4E271D22EF33F5001F6A6B /* AccountSettingsStore.swift in Sources */, + 8000362029246956007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift in Sources */, 4319AADE2090F00C0025D68E /* FancyAlertViewController+NotificationPrimer.swift in Sources */, 73FF7032221F469100541798 /* Charts+Support.swift in Sources */, F16601C423E9E783007950AE /* SharingAuthorizationWebViewController.swift in Sources */, 171963401D378D5100898E8B /* SearchWrapperView.swift in Sources */, - 173F6DFC21232F2A00A4C8E2 /* NoResultsGiphyConfiguration.swift in Sources */, + 241E60B325CA0D2900912CEB /* UserSettings.swift in Sources */, 0815CF461E96F22600069916 /* MediaImportService.swift in Sources */, B56F25881FBDE502005C33E4 /* NSAttributedStringKey+Conversion.swift in Sources */, + 98E54FF2265C972900B4BE9A /* ReaderDetailLikesView.swift in Sources */, FFABD80821370496003C65B6 /* SelectPostViewController.swift in Sources */, D8212CBD20AA7A7A008E8AE8 /* ReaderVisitSiteAction.swift in Sources */, + C957C20626DCC1770037628F /* LandInTheEditorHelper.swift in Sources */, + 8BB185D524B66FE600A4CCE8 /* ReaderCard+CoreDataProperties.swift in Sources */, F10E655021B0613A007AB2EE /* GutenbergViewController+MoreActions.swift in Sources */, E148362F1C6DF7D8005ACF53 /* Product.swift in Sources */, 082AB9DD1C4F035E000CA523 /* PostTag.m in Sources */, + 0107E16428FFED1800DE87DB /* WidgetConfiguration.swift in Sources */, 82FC612A1FA8B6F000A1757E /* ActivityListViewModel.swift in Sources */, + 0CB4056B29C78F06008EED0A /* BlogDashboardPersonalizationService.swift in Sources */, + F19153BD2549ED0800629EC4 /* UITextField+WorkaroundContinueIssue.swift in Sources */, E17780801C97FA9500FA7E14 /* StoreKit+Debug.swift in Sources */, E1ADE0EB20A9EF6200D6AADC /* PrivacySettingsViewController.swift in Sources */, E6431DE61C4E892900FD8D90 /* SharingDetailViewController.m in Sources */, + FA8E2FE027C6377000DA0982 /* DashboardQuickStartCardCell.swift in Sources */, 40EC1F102249CA8400F6785E /* OtherAndTotalViewsCount+CoreDataProperties.swift in Sources */, B50EED791C0E5B2400D278CA /* SettingsPickerViewController.swift in Sources */, E1F5A1BC1771C90A00E0495F /* WPTableImageSource.m in Sources */, + 17C1D6912670E4A2006C8970 /* UIFont+Fitting.swift in Sources */, + 329F8E5824DDBD11002A5311 /* ReaderTopicCollectionViewCoordinator.swift in Sources */, 5948AD0E1AB734F2006E8882 /* WPAppAnalytics.m in Sources */, B53AD9BF1BE9584B009AB87E /* SettingsSelectionViewController.m in Sources */, + FAD7626429F1480E00C09583 /* DashboardActivityLogCardCell+ActivityPresenter.swift in Sources */, + 24351254264DCA08009BB2B6 /* Secrets.swift in Sources */, + 03216EC6279946CA00D444CA /* SchedulingDatePickerViewController.swift in Sources */, 7E4123C420F4097B00DF8486 /* FormattableContentActionCommand.swift in Sources */, 17A28DCB2052FB5D00EA6D9E /* AuthorFilterViewController.swift in Sources */, + 3F946C592684DD8E00B946F6 /* BloggingRemindersActions.swift in Sources */, 08D345521CD7F50900358E8C /* MenusSelectionItemView.m in Sources */, + C7B7CC702812FDCE007B9807 /* MySiteViewController+OnboardingPrompt.swift in Sources */, + C81CCD6F243AF7D700A83E27 /* TenorReponseParser.swift in Sources */, 4020B2BD2007AC850002C963 /* WPStyleGuide+Gridicon.swift in Sources */, FF4258501BA092EE00580C68 /* RelatedPostsSettingsViewController.m in Sources */, + 982D261F2788DDF200A41286 /* ReaderCommentsFollowPresenter.swift in Sources */, F5E032D6240889EB003AF350 /* CreateButtonCoordinator.swift in Sources */, 74729CA32056FA0900D1394D /* SearchManager.swift in Sources */, 7E8980CA22E8C7A600C567B0 /* BlogToBlogMigration87to88.swift in Sources */, FA1ACAA21BC6E45D00DDDCE2 /* WPStyleGuide+Themes.swift in Sources */, 5D1181E71B4D6DEB003F3084 /* WPStyleGuide+Reader.swift in Sources */, + 3250490724F988220036B47F /* Interpolation.swift in Sources */, E17FEAD8221490F7006E1D2D /* PostEditorAnalyticsSession.swift in Sources */, 738B9A5221B85CF20005062B /* SiteCreationWizardLauncher.swift in Sources */, + 46F583AF2624CE790010A723 /* BlockEditorSettingElement+CoreDataProperties.swift in Sources */, + 3234B8E7252FA0930068DA40 /* ReaderSitesCardCell.swift in Sources */, + C7234A4E2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.swift in Sources */, + 08CBC77929AE6CC4000026E7 /* SiteCreationEmptySiteTemplate.swift in Sources */, D865722E2186F96D0023A99C /* SiteSegmentsWizardContent.swift in Sources */, E66969E01B9E648100EC9C00 /* ReaderTopicToReaderDefaultTopic37to38.swift in Sources */, F1DB8D2B2288C24500906E2F /* UploadsManager.swift in Sources */, + 9856A3E4261FD27A008D6354 /* UserProfileSectionHeader.swift in Sources */, + 801D951A291AC0B00051993E /* OverlayFrequencyTracker.swift in Sources */, + FEF4DC5528439357003806BE /* ReminderScheduleCoordinator.swift in Sources */, 82A062DE2017BCBA0084CE7C /* ActivityListSectionHeaderView.swift in Sources */, 73FEC871220B358500CEF791 /* WPAccount+RestApi.swift in Sources */, + 93CDC72126CD342900C8A3A8 /* DestructiveAlertHelper.swift in Sources */, + 982D99FE26F922C100AA794C /* InlineEditableMultiLineCell.swift in Sources */, B5FF3BE71CAD881100C1D597 /* ImageCropOverlayView.swift in Sources */, F5E032DB24088F44003AF350 /* UIView+SpringAnimations.swift in Sources */, E6A338501BB0A70F00371587 /* ReaderGapMarkerCell.swift in Sources */, @@ -12723,63 +22448,210 @@ 730D290F22976F1A0004BB1E /* BottomScrollAnalyticsTracker.swift in Sources */, E6311C431ECA017E00122529 /* UserProfile.swift in Sources */, B5CABB171C0E382C0050AB9F /* PickerTableViewCell.swift in Sources */, - E13ACCD41EE5672100CCE985 /* PostEditor.swift in Sources */, + F5AE43E425DD02C1003675F4 /* StoryEditor.swift in Sources */, + 08A4E129289D202F001D9EC7 /* UserPersistentStore.swift in Sources */, E1D95EB817A28F5E00A3E9F3 /* WPActivityDefaults.m in Sources */, + C31852A129670F8100A78BE9 /* JetpackScanViewController+JetpackBannerViewController.swift in Sources */, 436D56302117410C00CEAA33 /* RegisterDomainDetailsViewModel+CellIndex.swift in Sources */, + C39ABBAE294BE84000F6F278 /* BackupListViewController+JetpackBannerViewController.swift in Sources */, 1714F8D020E6DA8900226DCB /* RouteMatcher.swift in Sources */, 591A428F1A6DC6F2003807A6 /* WPGUIConstants.m in Sources */, + 3FD272E024CF8F270021F0C8 /* UIColor+Notice.swift in Sources */, + 98830A922747043B0061A87C /* BorderedButtonTableViewCell.swift in Sources */, + FAD9457E25B5647B00F011B5 /* JetpackBackupOptionsCoordinator.swift in Sources */, E1CA0A6C1FA73053004C4BBE /* PluginStore.swift in Sources */, + FAB8F7AA25AD792500D5D54A /* JetpackBackupStatusViewController.swift in Sources */, E11DA4931E03E03F00CF07A8 /* Pinghub.swift in Sources */, E1B912831BB01047003C25B9 /* PeopleRoleBadgeLabel.swift in Sources */, 40FC6B7F2072E3EC00B9A1CD /* ActivityDetailViewController.swift in Sources */, 3F43603323F36515001DEE70 /* BlogListViewController+BlogDetailsFactory.swift in Sources */, E16FB7E11F8B5D7D0004DD9F /* WebViewControllerConfiguration.swift in Sources */, 98B3FA8421C05BDC00148DD4 /* ViewMoreRow.swift in Sources */, - D816B8BE2112CC000052CE4D /* NewsItem.swift in Sources */, - D8380CA42192E77F00250609 /* VerticalsCell.swift in Sources */, + 8BADF16524801BCE005AD038 /* ReaderWebView.swift in Sources */, + 8B260D7E2444FC9D0010F756 /* PostVisibilitySelectorViewController.swift in Sources */, + C3C21EB928385EC8002296E2 /* RemoteSiteDesigns.swift in Sources */, B5416D011C17693B00006DD8 /* UIApplication+Helpers.m in Sources */, 731E88CA21C9A10B0055C014 /* ErrorStateView.swift in Sources */, 08D345501CD7F50900358E8C /* MenusSelectionDetailView.m in Sources */, + 8B0732F0242BF7E800E7FBD3 /* Blog+Title.swift in Sources */, + C94C0B1B25DCFA0100F2F69B /* FilterableCategoriesViewController.swift in Sources */, 591AA5021CEF9BF20074934F /* Post+CoreDataProperties.swift in Sources */, E19B17AE1E5C6944007517C6 /* BasePost.swift in Sources */, + C71AF533281064DE00F9E99E /* OnboardingQuestionsCoordinator.swift in Sources */, 7E7947A9210BAC1D005BB851 /* NotificationContentRange.swift in Sources */, 8B6EA62323FDE50B004BA312 /* PostServiceUploadingList.swift in Sources */, E1EBC36F1C118EA500F638E0 /* ImmuTable.swift in Sources */, B59B18751CC7FB8D0055EB7C /* PersonViewController.swift in Sources */, 4054F4602214F50300D261AB /* StreakInsightStatsRecordValue+CoreDataProperties.swift in Sources */, + 98E14A3C27C9712D007B0896 /* NotificationCommentDetailViewController.swift in Sources */, 9A8ECE122254A3260043C8DA /* JetpackRemoteInstallState.swift in Sources */, 40D7823A206AEA880015A3A1 /* Scheduler.swift in Sources */, 436D562E2117347C00CEAA33 /* RegisterDomainDetailsViewModel+RowDefinitions.swift in Sources */, E6FACB1E1EC675E300284AC7 /* GravatarProfile.swift in Sources */, D8212CB720AA7703008E8AE8 /* ReaderShareAction.swift in Sources */, - 827704F11F607C0E002E8A03 /* JetpackConnectionViewController.swift in Sources */, + C7BB60192863AF9700748FD9 /* QRLoginProtocols.swift in Sources */, 9826AE8A21B5CC7300C851FA /* PostingActivityMonth.swift in Sources */, 4054F4612214F50300D261AB /* StreakStatsRecordValue+CoreDataClass.swift in Sources */, - 5D839AAB187F0D8000811F4A /* PostGeolocationCell.m in Sources */, + 3F09CCAE24292EFD00D00A8C /* ReaderTabItem.swift in Sources */, 4054F4422213635000D261AB /* TopCommentsAuthorStatsRecordValue+CoreDataClass.swift in Sources */, - 7E729C2A209A241100F76599 /* AbstractPost+PostInformation.swift in Sources */, + FAB8FD6E25AEB23600D5D54A /* JetpackBackupService.swift in Sources */, + 17171374265FAA8A00F3A022 /* BloggingRemindersNavigationController.swift in Sources */, 8236EB502024ED8C007C7CF9 /* NotificationsViewController+JetpackPrompt.swift in Sources */, + C3234F4E27EB96A9004ADB29 /* IntentCell.swift in Sources */, 5D732F971AE84E3C00CD89E7 /* PostListFooterView.m in Sources */, 1767494E1D3633A000B8D1D1 /* ThemeBrowserSearchHeaderView.swift in Sources */, 0857C2781CE5375F0014AE99 /* MenuItemInsertionView.m in Sources */, 40E7FECF2211FFB90032834E /* AllTimeStatsRecordValue+CoreDataClass.swift in Sources */, + 98ED5963265EBD0000A0B33E /* ReaderDetailLikesListController.swift in Sources */, 8B05D29323AA572A0063B9AA /* GutenbergMediaEditorImage.swift in Sources */, B532D4EA199D4357006E4DF6 /* NoteBlockHeaderTableViewCell.swift in Sources */, + 24ADA24C24F9A4CB001B5DAE /* RemoteFeatureFlagStore.swift in Sources */, + 836498CE281735CC00A2C170 /* BloggingPromptsHeaderView.swift in Sources */, + 3F43703F2893201400475B6E /* JetpackOverlayViewController.swift in Sources */, 319D6E8519E44F7F0013871C /* SuggestionsTableViewCell.m in Sources */, 40A71C69220E1952002E3D25 /* LastPostStatsRecordValue+CoreDataClass.swift in Sources */, + 98AA6D1126B8CE7200920C8B /* Comment+CoreDataClass.swift in Sources */, 7E4A773720F802A8001C706D /* ActivityRangesFactory.swift in Sources */, E1AC282D18282423004D394C /* SFHFKeychainUtils.m in Sources */, 7EDAB3F420B046FE002D1A76 /* CircularProgressView+ActivityIndicatorType.swift in Sources */, 938CF3DC1EF1BE6800AF838E /* CocoaLumberjack.swift in Sources */, 740BD8351A0D4C3600F04D18 /* WPUploadStatusButton.m in Sources */, 7EAD7CD0206D761200BEDCFD /* MediaExternalExporter.swift in Sources */, - 319D6E7E19E447C80013871C /* SuggestionService.m in Sources */, + 837B49D9283C2AE80061A657 /* BloggingPromptSettings+CoreDataProperties.swift in Sources */, 436D56212117312700CEAA33 /* RegisterDomainDetailsViewModel+SectionDefinitions.swift in Sources */, F53FF3A823EA723D001AD596 /* ActionRow.swift in Sources */, FFA162311CB7031A00E2E110 /* AppSettingsViewController.swift in Sources */, + F111B87826580FCE00057942 /* BloggingRemindersStore.swift in Sources */, + 8B16CE9A25251C89007BE5A9 /* ReaderPostStreamService.swift in Sources */, 400A2C872217A985000A8A59 /* ReferrerStatsRecordValue+CoreDataProperties.swift in Sources */, - B5CEEB8E1B7920BE00E7B7B0 /* CommentsTableViewCell.swift in Sources */, 57AA848F228715DA00D3C2A2 /* PostCardCell.swift in Sources */, + FE43DAAF26DFAD1C00CFF595 /* CommentContentTableViewCell.swift in Sources */, + 803BB986295A873800B3F6D6 /* RootViewPresenter+MeNavigation.swift in Sources */, + F4CBE3D6292597E3004FFBB6 /* SupportTableViewControllerConfiguration.swift in Sources */, + 8F22804451E5812433733348 /* TimeZoneSearchHeaderView.swift in Sources */, + 8332DD2429259AE300802F7D /* DataMigrator.swift in Sources */, + 8F228F2923045666AE456D2C /* TimeZoneSelectorViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3F526C482538CF2A0069706C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F1FD30D2548B0A80060C53A /* Constants.m in Sources */, + 01CE500C290A88BF00A9C2E0 /* TracksConfiguration.swift in Sources */, + 3F63B93C258179D100F581BE /* StatsWidgetEntry.swift in Sources */, + 3FA53E9D256571D800F4D9A2 /* HomeWidgetCache.swift in Sources */, + 0107E18D29000E3300DE87DB /* AppStyleGuide.swift in Sources */, + 3FCF66E925CAF8C50047F337 /* ListStatsView.swift in Sources */, + 83A1B19A28AFE47C00E737AC /* KeychainUtils.swift in Sources */, + 3F6BC06D25B24787007369D3 /* BuildConfiguration.swift in Sources */, + 3F1FD2502548AD8B0060C53A /* TodayWidgetStats.swift in Sources */, + 0107E16A28FFED1800DE87DB /* WidgetConfiguration.swift in Sources */, + 3F2F0C16256C6B2C003351C7 /* StatsWidgetsService.swift in Sources */, + 3F526D572539FAC60069706C /* StatsWidgetsView.swift in Sources */, + 3F2656A125AF4DFA0073A832 /* AppLocalizedString.swift in Sources */, + 3F568A0025420DE80048A9E4 /* MultiStatsView.swift in Sources */, + 3F6BC05C25B24773007369D3 /* FeatureFlagOverrideStore.swift in Sources */, + 3FD675EA25C87A25009AB3C1 /* WordPressHomeWidgetToday.swift in Sources */, + 3F568A2F254216550048A9E4 /* FlexibleCard.swift in Sources */, + 3F568A1F254213B60048A9E4 /* VerticalCard.swift in Sources */, + 3F8B306825D1D4B8005A2903 /* ThisWeekWidgetStats.swift in Sources */, + 3F5C861A25C9EA2500BABE64 /* HomeWidgetAllTimeData.swift in Sources */, + 3FE20C1525CF165700A15525 /* GroupedViewData.swift in Sources */, + 3F6BC04B25B2474C007369D3 /* FeatureFlag.swift in Sources */, + 3F8EEC4E25B4817000EC9DAE /* StatsWidgets.swift in Sources */, + 3FA59B9A258289E30073772F /* StatsValueView.swift in Sources */, + 0107E18E29000EA100DE87DB /* UIColor+MurielColors.swift in Sources */, + 3F8EEC7025B4849A00EC9DAE /* SiteListProvider.swift in Sources */, + 3F8B136D25D08F34004FAC0A /* HomeWidgetThisWeekData.swift in Sources */, + 3F5C86C025CA197500BABE64 /* WordPressHomeWidgetAllTime.swift in Sources */, + 3F6BC07E25B247A4007369D3 /* KeyValueDatabase.swift in Sources */, + 3F1FD27B2548AE900060C53A /* CocoaLumberjack.swift in Sources */, + 3FCF66FB25CAF8E00047F337 /* ListRow.swift in Sources */, + 8323789828526E6D003F4443 /* AppConfiguration.swift in Sources */, + 0107E18B29000E1700DE87DB /* MurielColor.swift in Sources */, + 3F8B138F25D09AA5004FAC0A /* WordPressHomeWidgetThisWeek.swift in Sources */, + 3F5689F0254209790048A9E4 /* SingleStatView.swift in Sources */, + 3FAA18CC25797B85002B1911 /* UnconfiguredView.swift in Sources */, + 0107E1872900065500DE87DB /* LocalizationConfiguration.swift in Sources */, + 98390AC3254C984700868F0A /* Tracks+StatsWidgets.swift in Sources */, + 3FA53ED62565860900F4D9A2 /* HomeWidgetData.swift in Sources */, + 3FB34ACB25672A90001A74A6 /* HomeWidgetTodayData.swift in Sources */, + 3F5C863B25C9EA8200BABE64 /* AllTimeWidgetStats.swift in Sources */, + 3FD675D925C87A15009AB3C1 /* Sites.intentdefinition in Sources */, + 3FE77C8325B0CA89007DE9E5 /* LocalizableStrings.swift in Sources */, + 3FCC8FD9256C911300810295 /* SFHFKeychainUtils.m in Sources */, + 3FE20C3725CF211F00A15525 /* ListViewData.swift in Sources */, + 3F71D5302548C2B200A4BA93 /* Double+Stats.swift in Sources */, + 8031F34D29302C8100E8F95E /* ExtensionConfiguration.swift in Sources */, + 98390AD2254C985F00868F0A /* Tracks.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3FA640532670CCD40064401E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F762E9726784BED0088CD45 /* FancyAlertComponent.swift in Sources */, + 3F2F854226FAEA50000FCDA5 /* JetpackScanScreen.swift in Sources */, + 3F2F855A26FAF227000FCDA5 /* EditorNoticeComponent.swift in Sources */, + 3F2F855D26FAF227000FCDA5 /* LoginCheckMagicLinkScreen.swift in Sources */, + EA78189427596B2F00554DFA /* ContactUsScreen.swift in Sources */, + D82E087829EEB7AF0098F500 /* DomainsScreen.swift in Sources */, + 3F2F855626FAF227000FCDA5 /* LoginEmailScreen.swift in Sources */, + FA9276AF2888557500C323BB /* SiteIntentScreen.swift in Sources */, + 3FE39A3926F837E1006E2B3A /* ActivityLogScreen.swift in Sources */, + FA9276AD286C951200C323BB /* FeatureIntroductionScreen.swift in Sources */, + 3F2F855B26FAF227000FCDA5 /* LoginPasswordScreen.swift in Sources */, + 3F30A6B0299B412E0004452F /* PeopleScreen.swift in Sources */, + 3F2F854A26FAF132000FCDA5 /* FeaturedImageScreen.swift in Sources */, + 3F762E9B26784D2A0088CD45 /* XCUIElement+Utils.swift in Sources */, + 3FE39A3E26F8383E006E2B3A /* MediaScreen.swift in Sources */, + 3F2F855F26FAF235000FCDA5 /* MeTabScreen.swift in Sources */, + 3FE39A4226F838A0006E2B3A /* ActionSheetComponent.swift in Sources */, + FA612DDF274E9F730002B03A /* QuickStartPromptScreen.swift in Sources */, + 3F2F854526FAEB86000FCDA5 /* MediaPickerAlbumScreen.swift in Sources */, + 3F2F855526FAF227000FCDA5 /* TagsComponent.swift in Sources */, + 3F2F855426FAF227000FCDA5 /* AztecEditorScreen.swift in Sources */, + 3FE39A3326F836D7006E2B3A /* GetStartedScreen.swift in Sources */, + FA9276B12889550E00C323BB /* ChooseLayoutScreen.swift in Sources */, + 3FE39A3426F836E9006E2B3A /* PasswordScreen.swift in Sources */, + 3F2F855926FAF227000FCDA5 /* CategoriesComponent.swift in Sources */, + 3FE39A4126F8388E006E2B3A /* TabNavComponent.swift in Sources */, + 3F2F854826FAEEEC000FCDA5 /* EditorPublishEpilogueScreen.swift in Sources */, + 3F2F854326FAEA50000FCDA5 /* JetpackBackupScreen.swift in Sources */, + EAD08D0E29D45E23001A72F9 /* CommentsScreen.swift in Sources */, + 3F762E9526784B540088CD45 /* WireMock.swift in Sources */, + C7B7CC7328134347007B9807 /* OnboardingQuestionsPromptScreen.swift in Sources */, + 3FE39A3F26F8384E006E2B3A /* StatsScreen.swift in Sources */, + 3FE39A3126F836A5006E2B3A /* LoginSiteAddressScreen.swift in Sources */, + 3F2F855126FAF227000FCDA5 /* WelcomeScreenLoginComponent.swift in Sources */, + 3F2F855026FAF227000FCDA5 /* SignupEmailScreen.swift in Sources */, + 3F2F855826FAF227000FCDA5 /* SignupEpilogueScreen.swift in Sources */, + 3F2F855726FAF227000FCDA5 /* WelcomeScreenSignupComponent.swift in Sources */, + 3F107B1929B6F7E0009B3658 /* XCTestCase+Utils.swift in Sources */, + 3F2F855326FAF227000FCDA5 /* EditorPostSettings.swift in Sources */, + 3F2F856026FAF235000FCDA5 /* NotificationsScreen.swift in Sources */, + 3F2F854026FAE9DC000FCDA5 /* BlockEditorScreen.swift in Sources */, + 3FB5C2B327059AC8007D0ECE /* XCUIElement+Scroll.swift in Sources */, + 3F2F854726FAED51000FCDA5 /* MediaPickerAlbumListScreen.swift in Sources */, + 3FE39A3626F8370D006E2B3A /* MySitesScreen.swift in Sources */, + 3F762E9926784CC90088CD45 /* XCUIElementQuery+Utils.swift in Sources */, + 3FE39A3226F836C7006E2B3A /* LoginUsernamePasswordScreen.swift in Sources */, + 3F2F855E26FAF227000FCDA5 /* PrologueScreen.swift in Sources */, + 3F762E9D26784DB40088CD45 /* Globals.swift in Sources */, + 3FE39A3726F83748006E2B3A /* SupportScreen.swift in Sources */, + 3F2F856126FAF235000FCDA5 /* ReaderScreen.swift in Sources */, + 3F2F854426FAEA82000FCDA5 /* JetpackBackupOptionsScreen.swift in Sources */, + 3F2F854F26FAF227000FCDA5 /* WelcomeScreen.swift in Sources */, + 3F762E9326784A950088CD45 /* Logger.swift in Sources */, + 3FE39A3B26F837FF006E2B3A /* PostsScreen.swift in Sources */, + 3F2F855C26FAF227000FCDA5 /* LinkOrPasswordScreen.swift in Sources */, + 3F2F855226FAF227000FCDA5 /* SignupCheckMagicLinkScreen.swift in Sources */, + 3FE39A4026F8386A006E2B3A /* SiteSettingsScreen.swift in Sources */, + 3FE39A3526F83701006E2B3A /* LoginEpilogueScreen.swift in Sources */, + 3FE39A3826F837CB006E2B3A /* MySiteScreen.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -12787,7 +22659,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8323789928526E6E003F4443 /* AppConfiguration.swift in Sources */, 7345EAC4212DD49400607EC9 /* CircularImageView.swift in Sources */, + 83A1B19B28AFE47D00E737AC /* KeychainUtils.swift in Sources */, 436110DD22C41AFD000773AD /* FeatureFlag.swift in Sources */, 436110DC22C41ADB000773AD /* UIColor+MurielColors.swift in Sources */, 170BEC8A239153160017AEC1 /* FeatureFlagOverrideStore.swift in Sources */, @@ -12797,10 +22671,14 @@ 736584EB2137533A0029C9A4 /* NotificationContentView.swift in Sources */, 73D5AC63212622B200ADDDD2 /* NotificationViewController.swift in Sources */, 436110DE22C41B02000773AD /* BuildConfiguration.swift in Sources */, + 01CE500F290A88C100A9C2E0 /* TracksConfiguration.swift in Sources */, + FAD257F52611B54200EDAF88 /* UIColor+WordPressColors.swift in Sources */, + 0107E16B28FFED1800DE87DB /* WidgetConfiguration.swift in Sources */, 73F6DD44212C714F00CE447D /* RichNotificationViewModel.swift in Sources */, 736584E6213752730029C9A4 /* SFHFKeychainUtils.m in Sources */, 73B05D2621374B960073ECAA /* Tracks.swift in Sources */, 1752D4FC238D703A002B79E7 /* KeyValueDatabase.swift in Sources */, + FAD2566F2611AEC700EDAF88 /* AppStyleGuide.swift in Sources */, 736584EC2137533A0029C9A4 /* Tracks+ContentExtension.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -12809,9 +22687,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8031F34B292FF46E00E8F95E /* ExtensionConfiguration.swift in Sources */, 73ACDF9D2118AF7D00233AD4 /* Constants.m in Sources */, 7335AC6021220D550012EF2D /* RemoteNotificationActionParser.swift in Sources */, 7335AC6621220EA60012EF2D /* NotificationContentRange.swift in Sources */, + 83A1B1A028AFE89700E737AC /* AppConfiguration.swift in Sources */, 7335AC5B21220ADD0012EF2D /* FormattableContentStyles.swift in Sources */, 7335AC6A21220ED90012EF2D /* FormattableRangesFactory.swift in Sources */, 7335AC5C21220AF40012EF2D /* FormattableContentAction.swift in Sources */, @@ -12820,12 +22700,15 @@ 7335AC6521220E940012EF2D /* FormattableTextContent.swift in Sources */, 7335AC5D21220AFE0012EF2D /* FormattableContentActionCommand.swift in Sources */, 7335AC5A21220AD10012EF2D /* FormattableContentRange.swift in Sources */, + 01CE500E290A88C100A9C2E0 /* TracksConfiguration.swift in Sources */, 7335AC6821220EC10012EF2D /* FormattableCommentContent.swift in Sources */, 7335AC6921220ECE0012EF2D /* NotificationCommentRange.swift in Sources */, 7335AC6121220E580012EF2D /* NotificationContentFactory.swift in Sources */, 170BEC8B239153160017AEC1 /* FeatureFlagOverrideStore.swift in Sources */, + C737554027C80F1300C6E9A1 /* String+CondenseWhitespace.swift in Sources */, 7335AC6D21220F0F0012EF2D /* FormattableUserContent.swift in Sources */, 7335AC6221220E690012EF2D /* FormattableContentFactory.swift in Sources */, + 09DBEA55281336E10019724E /* AppLocalizedString.swift in Sources */, 433ADC1A223B2A7D00ED9DE1 /* TextBundleWrapper.m in Sources */, 7335AC6C21220F050012EF2D /* FormattableNoticonRange.swift in Sources */, 73F6DD42212BA54700CE447D /* RichNotificationContentFormatter.swift in Sources */, @@ -12835,15 +22718,18 @@ 7396FE66210F730600496D0D /* NotificationService.swift in Sources */, 73E40D8921238BF50012ABA6 /* Tracks.swift in Sources */, 7335AC5821220AB40012EF2D /* FormattableContentGroup.swift in Sources */, + 83A1B19C28AFE47D00E737AC /* KeychainUtils.swift in Sources */, 170BEC8C2391533D0017AEC1 /* KeyValueDatabase.swift in Sources */, 7335AC5E21220C630012EF2D /* Notifiable.swift in Sources */, 7335AC6321220E6E0012EF2D /* NotificationTextContent.swift in Sources */, + 0107E16C28FFED1800DE87DB /* WidgetConfiguration.swift in Sources */, + 83A1B19E28AFE86A00E737AC /* FeatureFlag.swift in Sources */, 73E8E592212CD635000B26A5 /* UIImage+Assets.swift in Sources */, + 83A1B1A328AFE89F00E737AC /* BuildConfiguration.swift in Sources */, 73E40D8C21238C520012ABA6 /* Tracks+ServiceExtension.swift in Sources */, 73768B6B212B4E4F005136A1 /* UNNotificationContent+RemoteNotification.swift in Sources */, 73F6DD45212C714F00CE447D /* RichNotificationViewModel.swift in Sources */, 73EDC70A212E5D6700E5E3ED /* RemoteNotificationStyles.swift in Sources */, - 73E32389212CD8B5001B735C /* NSAttributedString+Helpers.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -12857,6 +22743,7 @@ 74021A1C202E158F006CC39F /* ShareExtensionService.swift in Sources */, 747F88C2203778E000523C7C /* ShareTagsPickerViewController.swift in Sources */, 7414A142203CD066005A7D9B /* ShareCategoriesPickerViewController.swift in Sources */, + C7F7936A260D14C200CE547F /* AppConfiguration.swift in Sources */, 74021A1D202E15AF006CC39F /* Constants.m in Sources */, 740219FE202E12F4006CC39F /* UploadOperation.swift in Sources */, 4352211722B1B68F00D45849 /* WPStyleGuide+ApplicationStyles.swift in Sources */, @@ -12864,6 +22751,7 @@ 74021A03202E1307006CC39F /* NSExtensionContext+Extensions.swift in Sources */, 74021A22202E1740006CC39F /* Header+WordPress.swift in Sources */, 74021A02202E12FF006CC39F /* Extensions.xcdatamodeld in Sources */, + FAD256B82611B01B00EDAF88 /* UIColor+WordPressColors.swift in Sources */, 740219FF202E12F4006CC39F /* PostUploadOperation.swift in Sources */, 9822D8DE214194EB0092CBD1 /* NoResultsViewController.swift in Sources */, 74021A1A202E1502006CC39F /* WPStyleGuide+Gridicon.swift in Sources */, @@ -12875,15 +22763,22 @@ 74021A17202E13C6006CC39F /* SharePost.swift in Sources */, 74021A05202E1307006CC39F /* UIImage+Extensions.swift in Sources */, 74021A0D202E133D006CC39F /* ShareMediaFileManager.swift in Sources */, + CB1FD8D826E4BBAA00EDAF06 /* SharePostTypePickerViewController.swift in Sources */, + 098B8579275FFB21004D299F /* AppLocalizedString.swift in Sources */, 74021A1E202E164D006CC39F /* CocoaLumberjack.swift in Sources */, 74E44AD52031E83C00556205 /* ExtensionNotificationManager.swift in Sources */, 74448F552044BC7600BD4CDA /* CategoryTree.swift in Sources */, + FAD2539126116A1600EDAF88 /* AppStyleGuide.swift in Sources */, 74021A09202E1323006CC39F /* ShareSegueHandler.swift in Sources */, 74021A01202E12F4006CC39F /* SharedCoreDataStack.swift in Sources */, 170BEC88239153110017AEC1 /* FeatureFlagOverrideStore.swift in Sources */, 74021A14202E1393006CC39F /* ShareExtensionEditorViewController.swift in Sources */, + CBF6201426E8FB8A0061A1F8 /* RemotePost+ShareData.swift in Sources */, + 83A1B19628AFE47A00E737AC /* KeychainUtils.swift in Sources */, CE39E17320CB117B00CABA05 /* RemoteBlog+Capabilities.swift in Sources */, 74021A0F202E1370006CC39F /* ExtensionTransitioningManager.swift in Sources */, + 8031F34A292FF46B00E8F95E /* ExtensionConfiguration.swift in Sources */, + 01CE5008290A88BD00A9C2E0 /* TracksConfiguration.swift in Sources */, 74E44AD92031ED2300556205 /* WordPressDraft-Lumberjack.m in Sources */, 741AF3A5202F3E2A00C771A5 /* Tracks+DraftAction.swift in Sources */, 74021A1F202E16DC006CC39F /* SFHFKeychainUtils.m in Sources */, @@ -12904,59 +22799,229 @@ 986FF2A0214198D9005B28EC /* String+Ranges.swift in Sources */, F15A230520A3ECC500625EA2 /* ImgUploadProcessor.swift in Sources */, 74021A0C202E1338006CC39F /* ShareExtractor.swift in Sources */, + E696542025A8ED7C000E2A52 /* UIApplication+mainWindow.swift in Sources */, 1752D4F9238D702D002B79E7 /* KeyValueDatabase.swift in Sources */, 74021A23202E1743006CC39F /* FormatBarItemProviders.swift in Sources */, + 0107E16628FFED1800DE87DB /* WidgetConfiguration.swift in Sources */, 435B762422973D0600511813 /* UIColor+MurielColors.swift in Sources */, 74021A0A202E1329006CC39F /* AppExtensionsService.swift in Sources */, 74021A11202E1370006CC39F /* ExtensionPresentationAnimator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + 809620D128E540D700940A5D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 809620D228E540D700940A5D /* UploadOperation.swift in Sources */, + 809620D328E540D700940A5D /* TextBundleWrapper.m in Sources */, + 809620D428E540D700940A5D /* ShareNoticeConstants.swift in Sources */, + 809620D528E540D700940A5D /* WPStyleGuide+Share.swift in Sources */, + 809620D628E540D700940A5D /* ShareExtractor.swift in Sources */, + 809620D728E540D700940A5D /* ShareExtensionService.swift in Sources */, + 809620D928E540D700940A5D /* ShareTagsPickerViewController.swift in Sources */, + 809620DA28E540D700940A5D /* ShareCategoriesPickerViewController.swift in Sources */, + 809620DB28E540D700940A5D /* WPStyleGuide+ApplicationStyles.swift in Sources */, + 809620DC28E540D700940A5D /* ShareExtensionEditorViewController.swift in Sources */, + 809620DD28E540D700940A5D /* UIImage+Extensions.swift in Sources */, + 809620DE28E540D700940A5D /* WordPressShare-Lumberjack.m in Sources */, + 809620DF28E540D700940A5D /* Extensions.xcdatamodeld in Sources */, + 809620E028E540D700940A5D /* UIColor+WordPressColors.swift in Sources */, + 809620E128E540D700940A5D /* TextList+WordPress.swift in Sources */, + 809620E228E540D700940A5D /* AppExtensionsService.swift in Sources */, + 809620E328E540D700940A5D /* ExtensionNotificationManager.swift in Sources */, + 809620E428E540D700940A5D /* NoResultsViewController.swift in Sources */, + 809620E528E540D700940A5D /* NSAttributedStringKey+Conversion.swift in Sources */, + 809620E628E540D700940A5D /* SharePost.swift in Sources */, + 809620E728E540D700940A5D /* ShareData.swift in Sources */, + 809620E828E540D700940A5D /* BuildConfiguration.swift in Sources */, + 809620E928E540D700940A5D /* TableViewKeyboardObserver.swift in Sources */, + 809620EA28E540D700940A5D /* ExtensionTransitioningManager.swift in Sources */, + 809620EB28E540D700940A5D /* MainShareViewController.swift in Sources */, + 809620EC28E540D700940A5D /* SharePostTypePickerViewController.swift in Sources */, + 809620ED28E540D700940A5D /* AppLocalizedString.swift in Sources */, + 809620EE28E540D700940A5D /* WPStyleGuide+Gridicon.swift in Sources */, + 809620EF28E540D700940A5D /* ExtensionPresentationAnimator.swift in Sources */, + 809620F028E540D700940A5D /* String+RegEx.swift in Sources */, + 809620F128E540D700940A5D /* AppStyleGuide.swift in Sources */, + 809620F228E540D700940A5D /* CocoaLumberjack.swift in Sources */, + 809620F328E540D700940A5D /* SharedCoreDataStack.swift in Sources */, + 809620F428E540D700940A5D /* ShareExtensionAbstractViewController.swift in Sources */, + 809620F528E540D700940A5D /* FeatureFlagOverrideStore.swift in Sources */, + 809620F628E540D700940A5D /* RemotePost+ShareData.swift in Sources */, + 809620F728E540D700940A5D /* KeychainUtils.swift in Sources */, + 809620F828E540D700940A5D /* CategoryTree.swift in Sources */, + 809620F928E540D700940A5D /* ExtensionPresentationController.swift in Sources */, + 809620FA28E540D700940A5D /* Constants.m in Sources */, + 8031F346292FF46100E8F95E /* ExtensionConfiguration.swift in Sources */, + 01CE5015290A890F00A9C2E0 /* TracksConfiguration.swift in Sources */, + 809620FB28E540D700940A5D /* RemoteBlog+Capabilities.swift in Sources */, + 809620FC28E540D700940A5D /* ShareModularViewController.swift in Sources */, + 809620FD28E540D700940A5D /* NSExtensionContext+Extensions.swift in Sources */, + 809620FE28E540D700940A5D /* String+Extensions.swift in Sources */, + 809620FF28E540D700940A5D /* LightNavigationController.swift in Sources */, + 8096210028E540D700940A5D /* UINavigationController+Extensions.swift in Sources */, + 8096210128E540D700940A5D /* PostUploadOperation.swift in Sources */, + 8096210228E540D700940A5D /* Header+WordPress.swift in Sources */, + 8096210328E540D700940A5D /* SFHFKeychainUtils.m in Sources */, + 8096210428E540D700940A5D /* MurielColor.swift in Sources */, + 8096210528E540D700940A5D /* FeatureFlag.swift in Sources */, + 8096210628E540D700940A5D /* Tracks+ShareExtension.swift in Sources */, + 8096210728E540D700940A5D /* FormatBarItemProviders.swift in Sources */, + 0107E11228FD7FE200DE87DB /* AppConfiguration.swift in Sources */, + 8096210828E540D700940A5D /* ShareSegueHandler.swift in Sources */, + 8096210928E540D700940A5D /* RemotePostCategory+Extensions.swift in Sources */, + 8096210A28E540D700940A5D /* WPReusableTableViewCells.swift in Sources */, + 8096210B28E540D700940A5D /* WPAnimatedBox.m in Sources */, + 8096210C28E540D700940A5D /* String+Ranges.swift in Sources */, + 8096210D28E540D700940A5D /* ImgUploadProcessor.swift in Sources */, + 8096210E28E540D700940A5D /* UIApplication+mainWindow.swift in Sources */, + 8096210F28E540D700940A5D /* KeyValueDatabase.swift in Sources */, + 8096211028E540D700940A5D /* MediaUploadOperation.swift in Sources */, + 0107E15E28FFE99300DE87DB /* WidgetConfiguration.swift in Sources */, + 8096211128E540D700940A5D /* UIColor+MurielColors.swift in Sources */, + 8096211228E540D700940A5D /* ShareMediaFileManager.swift in Sources */, + 8096211328E540D700940A5D /* Tracks.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8096213228E55C9400940A5D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8096213328E55C9400940A5D /* TextList+WordPress.swift in Sources */, + 8096213428E55C9400940A5D /* TextBundleWrapper.m in Sources */, + 8096213528E55C9400940A5D /* UINavigationController+Extensions.swift in Sources */, + 8096213628E55C9400940A5D /* ShareExtensionService.swift in Sources */, + 8096213728E55C9400940A5D /* ShareTagsPickerViewController.swift in Sources */, + 8096213828E55C9400940A5D /* ShareCategoriesPickerViewController.swift in Sources */, + 8096213A28E55C9400940A5D /* Constants.m in Sources */, + 8096213B28E55C9400940A5D /* UploadOperation.swift in Sources */, + 8096213C28E55C9400940A5D /* WPStyleGuide+ApplicationStyles.swift in Sources */, + 8096213D28E55C9400940A5D /* NSAttributedStringKey+Conversion.swift in Sources */, + 8096213E28E55C9400940A5D /* NSExtensionContext+Extensions.swift in Sources */, + 8096213F28E55C9400940A5D /* Header+WordPress.swift in Sources */, + 8096214028E55C9400940A5D /* Extensions.xcdatamodeld in Sources */, + 8096214128E55C9400940A5D /* UIColor+WordPressColors.swift in Sources */, + 8096214228E55C9400940A5D /* PostUploadOperation.swift in Sources */, + 8096214328E55C9400940A5D /* NoResultsViewController.swift in Sources */, + 8096214428E55C9400940A5D /* WPStyleGuide+Gridicon.swift in Sources */, + 8096214528E55C9400940A5D /* String+Extensions.swift in Sources */, + 8096214628E55C9400940A5D /* TableViewKeyboardObserver.swift in Sources */, + 8096214728E55C9400940A5D /* BuildConfiguration.swift in Sources */, + 8096214828E55C9400940A5D /* ShareExtensionAbstractViewController.swift in Sources */, + 8096214928E55C9400940A5D /* ExtensionPresentationController.swift in Sources */, + 8096214A28E55C9400940A5D /* SharePost.swift in Sources */, + 8096214B28E55C9400940A5D /* UIImage+Extensions.swift in Sources */, + 8096214C28E55C9400940A5D /* ShareMediaFileManager.swift in Sources */, + 8096214D28E55C9400940A5D /* SharePostTypePickerViewController.swift in Sources */, + 8096214E28E55C9400940A5D /* AppLocalizedString.swift in Sources */, + 8096214F28E55C9400940A5D /* CocoaLumberjack.swift in Sources */, + 8096215028E55C9400940A5D /* ExtensionNotificationManager.swift in Sources */, + 8096215128E55C9400940A5D /* CategoryTree.swift in Sources */, + 8096215228E55C9400940A5D /* AppStyleGuide.swift in Sources */, + 8096215328E55C9400940A5D /* ShareSegueHandler.swift in Sources */, + 8096215428E55C9400940A5D /* SharedCoreDataStack.swift in Sources */, + 8096215528E55C9400940A5D /* FeatureFlagOverrideStore.swift in Sources */, + 8096215628E55C9400940A5D /* ShareExtensionEditorViewController.swift in Sources */, + 8096215728E55C9400940A5D /* RemotePost+ShareData.swift in Sources */, + 8096215828E55C9400940A5D /* KeychainUtils.swift in Sources */, + 8096215928E55C9400940A5D /* RemoteBlog+Capabilities.swift in Sources */, + 8096215A28E55C9400940A5D /* ExtensionTransitioningManager.swift in Sources */, + 8096215B28E55C9400940A5D /* WordPressDraft-Lumberjack.m in Sources */, + 8031F347292FF46200E8F95E /* ExtensionConfiguration.swift in Sources */, + 01CE5014290A890E00A9C2E0 /* TracksConfiguration.swift in Sources */, + 8096215C28E55C9400940A5D /* Tracks+DraftAction.swift in Sources */, + 8096215D28E55C9400940A5D /* SFHFKeychainUtils.m in Sources */, + 8096215E28E55C9400940A5D /* WPStyleGuide+Share.swift in Sources */, + 8096215F28E55C9400940A5D /* LightNavigationController.swift in Sources */, + 8096216028E55C9400940A5D /* String+RegEx.swift in Sources */, + 8096216128E55C9400940A5D /* ShareData.swift in Sources */, + 8096216228E55C9400940A5D /* WPReusableTableViewCells.swift in Sources */, + 8096216328E55C9400940A5D /* MainShareViewController.swift in Sources */, + 8096216428E55C9400940A5D /* MurielColor.swift in Sources */, + 8096216528E55C9400940A5D /* FeatureFlag.swift in Sources */, + 8096216628E55C9400940A5D /* ShareModularViewController.swift in Sources */, + 8096216728E55C9400940A5D /* MediaUploadOperation.swift in Sources */, + 8096216828E55C9400940A5D /* Tracks.swift in Sources */, + 0107E11328FD7FE300DE87DB /* AppConfiguration.swift in Sources */, + 8096216928E55C9400940A5D /* RemotePostCategory+Extensions.swift in Sources */, + 8096216A28E55C9400940A5D /* ShareNoticeConstants.swift in Sources */, + 8096216B28E55C9400940A5D /* WPAnimatedBox.m in Sources */, + 8096216C28E55C9400940A5D /* String+Ranges.swift in Sources */, + 8096216D28E55C9400940A5D /* ImgUploadProcessor.swift in Sources */, + 8096216E28E55C9400940A5D /* ShareExtractor.swift in Sources */, + 8096216F28E55C9400940A5D /* UIApplication+mainWindow.swift in Sources */, + 8096217028E55C9400940A5D /* KeyValueDatabase.swift in Sources */, + 8096217128E55C9400940A5D /* FormatBarItemProviders.swift in Sources */, + 0107E15F28FFE99300DE87DB /* WidgetConfiguration.swift in Sources */, + 8096217228E55C9400940A5D /* UIColor+MurielColors.swift in Sources */, + 8096217328E55C9400940A5D /* AppExtensionsService.swift in Sources */, + 8096217428E55C9400940A5D /* ExtensionPresentationAnimator.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 80F6D02128EE866A00953C1A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8031F348292FF46400E8F95E /* ExtensionConfiguration.swift in Sources */, + 80F6D02228EE866A00953C1A /* Constants.m in Sources */, + 80F6D02328EE866A00953C1A /* RemoteNotificationActionParser.swift in Sources */, + 80F6D02428EE866A00953C1A /* NotificationContentRange.swift in Sources */, + 80F6D02628EE866A00953C1A /* FormattableContentStyles.swift in Sources */, + 80F6D02728EE866A00953C1A /* FormattableRangesFactory.swift in Sources */, + 0107E11428FD7FE300DE87DB /* AppConfiguration.swift in Sources */, + 80F6D02828EE866A00953C1A /* FormattableContentAction.swift in Sources */, + 80F6D02928EE866A00953C1A /* DefaultFormattableContentAction.swift in Sources */, + 80F6D02A28EE866A00953C1A /* FormattableMediaContent.swift in Sources */, + 80F6D02B28EE866A00953C1A /* FormattableTextContent.swift in Sources */, + 80F6D02C28EE866A00953C1A /* FormattableContentActionCommand.swift in Sources */, + 80F6D02D28EE866A00953C1A /* FormattableContentRange.swift in Sources */, + 01CE5013290A890E00A9C2E0 /* TracksConfiguration.swift in Sources */, + 80F6D02E28EE866A00953C1A /* FormattableCommentContent.swift in Sources */, + 80F6D02F28EE866A00953C1A /* NotificationCommentRange.swift in Sources */, + 80F6D03028EE866A00953C1A /* NotificationContentFactory.swift in Sources */, + 80F6D03128EE866A00953C1A /* FeatureFlagOverrideStore.swift in Sources */, + 80F6D03228EE866A00953C1A /* String+CondenseWhitespace.swift in Sources */, + 80F6D03328EE866A00953C1A /* FormattableUserContent.swift in Sources */, + 80F6D03428EE866A00953C1A /* FormattableContentFactory.swift in Sources */, + 80F6D03528EE866A00953C1A /* AppLocalizedString.swift in Sources */, + 80F6D03628EE866A00953C1A /* TextBundleWrapper.m in Sources */, + 80F6D03728EE866A00953C1A /* FormattableNoticonRange.swift in Sources */, + 80F6D03828EE866A00953C1A /* RichNotificationContentFormatter.swift in Sources */, + 80F6D03928EE866A00953C1A /* SFHFKeychainUtils.m in Sources */, + 80F6D03A28EE866A00953C1A /* FormattableContent.swift in Sources */, + 80F6D03B28EE866A00953C1A /* NotificationContentRangeFactory.swift in Sources */, + 80F6D03C28EE866A00953C1A /* NotificationService.swift in Sources */, + 80F6D03D28EE866A00953C1A /* Tracks.swift in Sources */, + 80F6D03E28EE866A00953C1A /* FormattableContentGroup.swift in Sources */, + 80F6D03F28EE866A00953C1A /* KeychainUtils.swift in Sources */, + 80F6D04028EE866A00953C1A /* KeyValueDatabase.swift in Sources */, + 80F6D04128EE866A00953C1A /* Notifiable.swift in Sources */, + 80F6D04228EE866A00953C1A /* NotificationTextContent.swift in Sources */, + 80F6D04328EE866A00953C1A /* FeatureFlag.swift in Sources */, + 0107E16028FFE99300DE87DB /* WidgetConfiguration.swift in Sources */, + 80F6D04428EE866A00953C1A /* UIImage+Assets.swift in Sources */, + 80F6D04528EE866A00953C1A /* BuildConfiguration.swift in Sources */, + 80F6D04628EE866A00953C1A /* Tracks+ServiceExtension.swift in Sources */, + 80F6D04728EE866A00953C1A /* UNNotificationContent+RemoteNotification.swift in Sources */, + 80F6D04828EE866A00953C1A /* RichNotificationViewModel.swift in Sources */, + 80F6D04928EE866A00953C1A /* RemoteNotificationStyles.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 8511CFB21C607A7000B7CEED /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F9C47A7D238C7DAC00AAD9ED /* FancyAlertComponent.swift in Sources */, - F9C47A71238C7D8800AAD9ED /* LoginEmailScreen.swift in Sources */, - 1A82EC9F229EBAFB000F141E /* Logger.swift in Sources */, F9C47A78238C7DAC00AAD9ED /* BaseScreen.swift in Sources */, - F9C47A80238C7DC100AAD9ED /* BlockEditorScreen.swift in Sources */, - F9C47A82238C7DC100AAD9ED /* EditorPostSettings.swift in Sources */, F98C58192228849E0073D752 /* XCTest+Extensions.swift in Sources */, - F9C47A84238C7DC500AAD9ED /* SignupEmailScreen.swift in Sources */, - F9C47A87238C7DF100AAD9ED /* MediaPickerAlbumListScreen.swift in Sources */, - F97DA42123D67BBB0050E791 /* MediaScreen.swift in Sources */, - F9C47A77238C7D8800AAD9ED /* LoginEpilogueScreen.swift in Sources */, - F9C47A7C238C7DAC00AAD9ED /* NotificationsScreen.swift in Sources */, + CCBC9EB4251258FB008E1D5F /* WPUITestCredentials.swift in Sources */, F9C47A6B238C7CFD00AAD9ED /* LoginFlow.swift in Sources */, - F9C47A8D238C809700AAD9ED /* PostsScreen.swift in Sources */, - F9C47A6F238C7D8800AAD9ED /* WelcomeScreenLoginComponent.swift in Sources */, - F9C47A70238C7D8800AAD9ED /* WelcomeScreen.swift in Sources */, - F9C47A73238C7D8800AAD9ED /* LinkOrPasswordScreen.swift in Sources */, - F9C47A90238C9D6700AAD9ED /* StatsScreen.swift in Sources */, - F9C47A6E238C7D7D00AAD9ED /* TabNavComponent.swift in Sources */, - F9C47A79238C7DAC00AAD9ED /* MeTabScreen.swift in Sources */, - F9C47A6D238C7D7500AAD9ED /* MySiteScreen.swift in Sources */, - F9C47A72238C7D8800AAD9ED /* LoginPasswordScreen.swift in Sources */, - F9C47A7F238C7DC100AAD9ED /* AztecEditorScreen.swift in Sources */, - F9C47A75238C7D8800AAD9ED /* LoginSiteAddressScreen.swift in Sources */, - 1AED7239229D2E260036C5B8 /* WireMock.swift in Sources */, - F9C47A8A238C7E2600AAD9ED /* TagsComponent.swift in Sources */, - F9C47A81238C7DC100AAD9ED /* EditorNoticeComponent.swift in Sources */, - F9C47A74238C7D8800AAD9ED /* LoginCheckMagicLinkScreen.swift in Sources */, - F9C47A89238C7E2600AAD9ED /* CategoriesComponent.swift in Sources */, - F9C47A7B238C7DAC00AAD9ED /* SiteSettingsScreen.swift in Sources */, - F9C47A6C238C7D6A00AAD9ED /* MySitesScreen.swift in Sources */, - F9C47A76238C7D8800AAD9ED /* LoginUsernamePasswordScreen.swift in Sources */, - F9C47A83238C7DC500AAD9ED /* WelcomeScreenSignupComponent.swift in Sources */, 8511CFC71C60894200B7CEED /* WordPressScreenshotGeneration.swift in Sources */, 8511CFC51C60884400B7CEED /* SnapshotHelper.swift in Sources */, - F9C47A85238C7DC500AAD9ED /* SignupCheckMagicLinkScreen.swift in Sources */, - F9C47A86238C7DC500AAD9ED /* SignupEpilogueScreen.swift in Sources */, - F9C47A7E238C7DC100AAD9ED /* EditorPublishEpilogueScreen.swift in Sources */, - F9C47A7A238C7DAC00AAD9ED /* ReaderScreen.swift in Sources */, F9463A7321C05EE90081F11E /* ScreenshotCredentials.swift in Sources */, - F9C47A88238C7DF100AAD9ED /* MediaPickerAlbumScreen.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -12970,6 +23035,7 @@ B50248AF1C96FF6200AFBDED /* WPStyleGuide+Share.swift in Sources */, E1CE41661E8D1026000CF5A4 /* ShareExtractor.swift in Sources */, 930F09191C7E1C1E00995926 /* ShareExtensionService.swift in Sources */, + C7F79369260D14C100CE547F /* AppConfiguration.swift in Sources */, 747F88C1203778E000523C7C /* ShareTagsPickerViewController.swift in Sources */, 7414A141203CBAE0005A7D9B /* ShareCategoriesPickerViewController.swift in Sources */, 4352211622B1B68E00D45849 /* WPStyleGuide+ApplicationStyles.swift in Sources */, @@ -12977,6 +23043,7 @@ B5FA868C1D10A4C400AB5F7E /* UIImage+Extensions.swift in Sources */, B50248C21C96FFCC00AFBDED /* WordPressShare-Lumberjack.m in Sources */, 74FA4BE61FBFA0660031EAAD /* Extensions.xcdatamodeld in Sources */, + FAD256A62611B01A00EDAF88 /* UIColor+WordPressColors.swift in Sources */, 74D06FEB1FE0788500AF1788 /* TextList+WordPress.swift in Sources */, 74FA2EE4200E8A6C001DDC13 /* AppExtensionsService.swift in Sources */, 74F89407202A1965008610FA /* ExtensionNotificationManager.swift in Sources */, @@ -12988,15 +23055,22 @@ 747F88C32037791B00523C7C /* TableViewKeyboardObserver.swift in Sources */, 74402F2C2005337D00A1D4A2 /* ExtensionTransitioningManager.swift in Sources */, 74337EDD20054D5500777997 /* MainShareViewController.swift in Sources */, + CB48172A26E0D93D008C2D9B /* SharePostTypePickerViewController.swift in Sources */, + 098B8578275FF975004D299F /* AppLocalizedString.swift in Sources */, 4020B2BE2007AC850002C963 /* WPStyleGuide+Gridicon.swift in Sources */, 74402F2E2005344700A1D4A2 /* ExtensionPresentationAnimator.swift in Sources */, 74D06FE91FE0782000AF1788 /* String+RegEx.swift in Sources */, + FAD2539026116A1600EDAF88 /* AppStyleGuide.swift in Sources */, 938CF3DD1EF1BE7F00AF838E /* CocoaLumberjack.swift in Sources */, 746D6B251FBF701F003C45BE /* SharedCoreDataStack.swift in Sources */, 745EAF472003FDAA0066F415 /* ShareExtensionAbstractViewController.swift in Sources */, 170BEC872391530D0017AEC1 /* FeatureFlagOverrideStore.swift in Sources */, + CBF6201326E8FB520061A1F8 /* RemotePost+ShareData.swift in Sources */, + 83A1B19528AFE47900E737AC /* KeychainUtils.swift in Sources */, 74448F542044BC7600BD4CDA /* CategoryTree.swift in Sources */, 74402F2A200528F200A1D4A2 /* ExtensionPresentationController.swift in Sources */, + 8031F349292FF46A00E8F95E /* ExtensionConfiguration.swift in Sources */, + 01CE5007290A889F00A9C2E0 /* TracksConfiguration.swift in Sources */, B5BEA55F1C7CE6D100C8035B /* Constants.m in Sources */, CE39E17220CB117B00CABA05 /* RemoteBlog+Capabilities.swift in Sources */, BE6787F51FFF2886005D9F01 /* ShareModularViewController.swift in Sources */, @@ -13017,91 +23091,25 @@ 986FF29E2141971D005B28EC /* WPAnimatedBox.m in Sources */, 986FF29F214198D9005B28EC /* String+Ranges.swift in Sources */, F15A230420A3EBE300625EA2 /* ImgUploadProcessor.swift in Sources */, + E696541F25A8ED7C000E2A52 /* UIApplication+mainWindow.swift in Sources */, 1752D4FA238D702E002B79E7 /* KeyValueDatabase.swift in Sources */, 74AF4D751FE417D200E3EBFE /* MediaUploadOperation.swift in Sources */, + 0107E16528FFED1800DE87DB /* WidgetConfiguration.swift in Sources */, 435B762322973D0600511813 /* UIColor+MurielColors.swift in Sources */, 7430C4491F97F23600E2673E /* ShareMediaFileManager.swift in Sources */, B5FA22831C99F6180016CA7C /* Tracks.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 93E5283619A7741A003A1A9C /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 98906507237CC1DF00218CD2 /* WidgetTwoColumnCell.swift in Sources */, - 433ADC1C223B2A7E00ED9DE1 /* TextBundleWrapper.m in Sources */, - 93E5284119A7741A003A1A9C /* TodayViewController.swift in Sources */, - 938CF3DE1EF1BE8000AF838E /* CocoaLumberjack.swift in Sources */, - 98712D1D23DA1D0800555316 /* WidgetNoConnectionCell.swift in Sources */, - 98C0CE9C23C559C800D0F27C /* Double+Stats.swift in Sources */, - 93C2075A1CC7FF9C00C94D04 /* Tracks.swift in Sources */, - 436110DA22C3ED44000773AD /* FeatureFlag.swift in Sources */, - 436110DB22C3ED4C000773AD /* BuildConfiguration.swift in Sources */, - 98906503237CC1DF00218CD2 /* WidgetUnconfiguredCell.swift in Sources */, - 93C2075D1CC7FFC800C94D04 /* Tracks+TodayWidget.swift in Sources */, - 98E58A302360D23400E5534B /* TodayWidgetStats.swift in Sources */, - 4326191722FCB9F9003C7642 /* MurielColor.swift in Sources */, - 436110D922C3ED18000773AD /* UIColor+MurielColors.swift in Sources */, - 988F073A23D0D16700AC67A6 /* WidgetUrlCell.swift in Sources */, - 1752D4FB238D702F002B79E7 /* KeyValueDatabase.swift in Sources */, - 170BEC89239153120017AEC1 /* FeatureFlagOverrideStore.swift in Sources */, - 985ED0E723C6964500B8D06A /* WidgetStyles.swift in Sources */, - 93E3D3C819ACE8E300B1C509 /* SFHFKeychainUtils.m in Sources */, - 934884AB19B73BA6004028D8 /* Constants.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 98A3C2EB239997DA0048D38D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 984BE91523CE72D600B37D90 /* Double+Stats.swift in Sources */, - 983AE84C2399AC5B00E5B7F6 /* SFHFKeychainUtils.m in Sources */, - 98F93184239AF76900E4E96E /* CocoaLumberjack.swift in Sources */, - 98E419DF2399B62A00D8C822 /* Tracks.swift in Sources */, - 989643E423A02F4E0070720A /* UIColor+MurielColors.swift in Sources */, - 989643E223A02F080070720A /* WidgetUnconfiguredCell.swift in Sources */, - 980D3B1B23C925F40060A890 /* WidgetStyles.swift in Sources */, - 988F073C23D0D16800AC67A6 /* WidgetUrlCell.swift in Sources */, - 98E419DE2399B5A700D8C822 /* Tracks+ThisWeekWidget.swift in Sources */, - 98712D1F23DA1D0A00555316 /* WidgetNoConnectionCell.swift in Sources */, - 98A3C3052399A2370048D38D /* ThisWeekViewController.swift in Sources */, - 989643E523A02F640070720A /* MurielColor.swift in Sources */, - 983AE84D2399AC6B00E5B7F6 /* Constants.m in Sources */, - 989643ED23A0437B0070720A /* WidgetDifferenceCell.swift in Sources */, - 98F93183239AF64800E4E96E /* ThisWeekWidgetStats.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 98D31B8A2396ED7E009CFF43 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 98A6B98F2398807F0031AEBD /* WidgetTwoColumnCell.swift in Sources */, - 98D31BAD2397015C009CFF43 /* Tracks.swift in Sources */, - 98BFF57C2398406A008A1DCB /* CocoaLumberjack.swift in Sources */, - 98BFF57F23984345008A1DCB /* AllTimeWidgetStats.swift in Sources */, - 98A6B992239881630031AEBD /* UIColor+MurielColors.swift in Sources */, - 98A6B9902398808B0031AEBD /* WidgetUnconfiguredCell.swift in Sources */, - 98D31BAC23970078009CFF43 /* Tracks+AllTimeWidget.swift in Sources */, - 988F073B23D0D16800AC67A6 /* WidgetUrlCell.swift in Sources */, - 98D31BA72396F7E2009CFF43 /* AllTimeViewController.swift in Sources */, - 98712D1E23DA1D0900555316 /* WidgetNoConnectionCell.swift in Sources */, - 98A6B993239881860031AEBD /* MurielColor.swift in Sources */, - 98D31BC223972A79009CFF43 /* SFHFKeychainUtils.m in Sources */, - 98D31BAE239708FB009CFF43 /* Constants.m in Sources */, - 98C0CE9B23C559C800D0F27C /* Double+Stats.swift in Sources */, - 985ED0E823C6964600B8D06A /* WidgetStyles.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; E16AB92514D978240047A2E5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C3DA0EE02807062600DA3250 /* SiteCreationNameTracksEventTests.swift in Sources */, + 803DE81928FFB7B5007D4E9C /* RemoteParameterTests.swift in Sources */, 73C8F06621BEF76B00DDDF7E /* SiteAssemblyViewTests.swift in Sources */, 73178C2821BEE09300E37C9A /* SiteCreationDataCoordinatorTests.swift in Sources */, + 4A266B8F282B05210089CF3D /* JSONObjectTests.swift in Sources */, D81C2F6020F891C4002AE1F1 /* TrashCommentActionTests.swift in Sources */, 570BFD8D22823DE5007859A8 /* PostActionSheetTests.swift in Sources */, FA4ADADA1C509FE400F858D7 /* SiteManagementServiceTests.swift in Sources */, @@ -13115,73 +23123,118 @@ 08A2AD7B1CCED8E500E84454 /* PostCategoryServiceTests.m in Sources */, D88A64A8208D9733008AE9BC /* ThumbnailCollectionTests.swift in Sources */, 572FB401223A806000933C76 /* NoticeStoreTests.swift in Sources */, - D816B8D12112D5960052CE4D /* DefaultNewsManagerTests.swift in Sources */, + F5CFB8F524216DFC00E58B69 /* CalendarHeaderViewTests.swift in Sources */, 748437EE1F1D4A7300E8DDAF /* RichContentFormatterTests.swift in Sources */, + C81CCD6A243AEE1100A83E27 /* TenorAPIResponseTests.swift in Sources */, 8BE7C84123466927006EDE70 /* I18n.swift in Sources */, + C396C80B280F2401006FE7AC /* SiteDesignTests.swift in Sources */, + 806E53E427E01CFE0064315E /* DashboardStatsViewModelTests.swift in Sources */, D88A649E208D82D2008AE9BC /* XCTestCase+Wait.swift in Sources */, + C38C5D8127F61D2C002F517E /* MenuItemTests.swift in Sources */, E18549DB230FBFEF003C620E /* BlogServiceDeduplicationTests.swift in Sources */, 400A2C8F2217AD7F000A8A59 /* ClicksStatsRecordValueTests.swift in Sources */, + 0148CC292859127F00CF5D96 /* StatsWidgetsStoreTests.swift in Sources */, 7320C8BD2190C9FC0082FED5 /* UITextView+SummaryTests.swift in Sources */, + 24A2948325D602710000A51E /* BlogTimeZoneTests.m in Sources */, + 80B016CF27FEBDC900D15566 /* DashboardCardTests.swift in Sources */, 7E53AB0420FE6681005796FE /* ActivityContentRouterTests.swift in Sources */, F11023A323186BCA00C4E84A /* MediaBuilder.swift in Sources */, + 803BB99429667CF700B3F6D6 /* JetpackBrandingTextProviderTests.swift in Sources */, 17AF92251C46634000A99CFB /* BlogSiteVisibilityHelperTest.m in Sources */, 73B6693A21CAD960008456C3 /* ErrorStateViewTests.swift in Sources */, + 8BD34F0927D144FF005E931C /* BlogDashboardStateTests.swift in Sources */, 1759F1721FE017F20003EC81 /* QueueTests.swift in Sources */, + DCC662512810915D00962D0C /* BlogVideoLimitsTests.swift in Sources */, 3F1AD48123FC87A400BB1375 /* BlogDetailsViewController+MeButtonTests.swift in Sources */, 08F8CD3B1EBD2D020049D0C0 /* MediaURLExporterTests.swift in Sources */, D81C2F6220F89632002AE1F1 /* EditCommentActionTests.swift in Sources */, + AEE0828A2681C23C00DCF54B /* GutenbergRefactoredGalleryUploadProcessorTests.swift in Sources */, + F4426FD9287F02FD00218003 /* SiteSuggestionsServiceMock.swift in Sources */, E66969CD1B9E2EBF00EC9C00 /* SafeReaderTopicToReaderTopic.m in Sources */, + DC3B9B2F27739887003F7249 /* TimeZoneSelectorViewModelTests.swift in Sources */, E6A215901D1065F200DE5270 /* AbstractPostTest.swift in Sources */, D8BA274D20FDEA2E007A5C77 /* NotificationTextContentTests.swift in Sources */, + 2481B1D5260D4E8B00AE59DB /* AccountBuilder.swift in Sources */, E66969C81B9E0A6800EC9C00 /* ReaderTopicServiceTest.swift in Sources */, - 931D26F519ED7E6D00114F17 /* BlogJetpackTest.m in Sources */, 570265172298960B00F2214C /* PostListTableViewHandlerTests.swift in Sources */, E1EBC3731C118ED200F638E0 /* ImmuTableTest.swift in Sources */, + F5C00EAE242179780047846F /* WeekdaysHeaderViewTests.swift in Sources */, + 24C69AC22612467C00312D9A /* UserSettingsTestsObjc.m in Sources */, E1AB5A091E0BF31E00574B4E /* ArrayTests.swift in Sources */, 570B037722F1FFF6009D8411 /* PostCoordinatorFailedPostsFetcherTests.swift in Sources */, 4054F4592214E6FE00D261AB /* TagsCategoriesStatsRecordValueTests.swift in Sources */, + C8567496243F3D37001A995E /* TenorResultsPageTests.swift in Sources */, + B0A6DEBF2626335F00B5B8EF /* AztecPostViewController+MenuTests.swift in Sources */, 4054F43E221357B600D261AB /* TopCommentedPostStatsRecordValueTests.swift in Sources */, 93B853231B4416A30064FE72 /* WPAnalyticsTrackerAutomatticTracksTests.m in Sources */, + C738CB0B28623CED001BE107 /* QRLoginCoordinatorTests.swift in Sources */, + FE2E3729281C839C00A1E82A /* BloggingPromptsServiceTests.swift in Sources */, D848CC0720FF2BE200A9038F /* NotificationContentRangeFactoryTests.swift in Sources */, 732A473F21878EB10015DA74 /* WPRichContentViewTests.swift in Sources */, + 8BDA5A6D247C2F8400AB124C /* ReaderDetailViewControllerTests.swift in Sources */, 82301B8F1E787420009C9C4E /* AppRatingUtilityTests.swift in Sources */, D81C2F5420F85DB1002AE1F1 /* ApproveCommentActionTests.swift in Sources */, + 8B69F0E4255C2C3F006B1CEF /* ActivityListViewModelTests.swift in Sources */, 8BC12F7723201B86004DDA72 /* PostService+MarkAsFailedAndDraftIfNeededTests.swift in Sources */, + 803DE81F290636A4007D4E9C /* JetpackFeaturesRemovalCoordinatorTests.swift in Sources */, + 937250EE267A492D0086075F /* StatsPeriodStoreTests.swift in Sources */, + 801D951D291ADB7E0051993E /* OverlayFrequencyTrackerTests.swift in Sources */, F1B1E7A324098FA100549E2A /* BlogTests.swift in Sources */, 57889AB823589DF100DAE56D /* PageBuilder.swift in Sources */, + 3FDDFE9627C8178C00606933 /* SiteStatsInformationTests.swift in Sources */, 74B335EC1F06F9520053A184 /* MockWordPressComRestApi.swift in Sources */, + 80EF92932810FA5A0064A971 /* QuickStartFactoryTests.swift in Sources */, + 80C523AB29AE6C2200B1C14B /* BlazeWebViewModelTests.swift in Sources */, D848CBFF20FF010F00A9038F /* FormattableCommentContentTests.swift in Sources */, 9123471B221449E200BD9F97 /* GutenbergInformativeDialogTests.swift in Sources */, + 8332DD2829259BEB00802F7D /* DataMigratorTests.swift in Sources */, + C80512FE243FFD4B00B6B04D /* TenorDataSouceTests.swift in Sources */, 323F8F3023A22C4C000BA49C /* SiteCreationRotatingMessageViewTests.swift in Sources */, FF1FD02620912AA900186384 /* URL+LinkNormalizationTests.swift in Sources */, + 4A76A4BD29D43BFD00AABF4B /* CommentService+MorderationTests.swift in Sources */, 57AA8493228790AA00D3C2A2 /* PostCardCellTests.swift in Sources */, 7E442FCF20F6C19000DEACA5 /* ActivityLogFormattableContentTests.swift in Sources */, 400A2C952217B68D000A8A59 /* TopViewedVideoStatsRecordValueTests.swift in Sources */, + F151EC832665271200AEA89E /* BloggingRemindersSchedulerTests.swift in Sources */, D81C2F6620F8ACCD002AE1F1 /* FormattableContentFormatterTests.swift in Sources */, + 83EF3D7F2937F08C000AF9BF /* SharedDataIssueSolverTests.swift in Sources */, + 8070EB3E28D807CB005C6513 /* InMemoryUserDefaults.swift in Sources */, F93735F822D53C3B00A3C312 /* LoggingURLRedactorTests.swift in Sources */, + C738CB1128626606001BE107 /* QRLoginVerifyCoordinatorTests.swift in Sources */, FF0B2567237A023C004E255F /* GutenbergVideoUploadProcessorTests.swift in Sources */, FF1B11E7238FE27A0038B93E /* GutenbergGalleryUploadProcessorTests.swift in Sources */, + FE320CC5294705990046899B /* ReaderPostBackupTests.swift in Sources */, + F4D9AF51288AE23500803D40 /* SuggestionTableViewTests.swift in Sources */, 8BC12F72231FEBA1004DDA72 /* PostCoordinatorTests.swift in Sources */, + C3C2F84628AC8BC700937E45 /* JetpackBannerScrollVisibilityTests.swift in Sources */, D8B6BEB7203E11F2007C8A19 /* Bundle+LoadFromNib.swift in Sources */, + 8B2D4F5527ECE376009B085C /* BlogDashboardPostsParserTests.swift in Sources */, + 4A266B91282B13A70089CF3D /* CoreDataTestCase.swift in Sources */, + 24B1AE3124FEC79900B9F334 /* RemoteFeatureFlagTests.swift in Sources */, E135965D1E7152D1006C6606 /* RecentSitesServiceTests.swift in Sources */, + DCFC6A29292523D20062D65B /* SiteStatsPinnedItemStoreTests.swift in Sources */, D88A64AC208D9B09008AE9BC /* StockPhotosPageableTests.swift in Sources */, 40C403F82215D88100E8C894 /* TopViewedStatsTests.swift in Sources */, + 0148CC2B2859C87000CF5D96 /* BlogServiceMock.swift in Sources */, 59ECF87B1CB7061D00E68F25 /* PostSharingControllerTests.swift in Sources */, E157D5E01C690A6C00F04FB9 /* ImmuTableTestUtils.swift in Sources */, 40C403EE2215CE9500E8C894 /* SearchResultsStatsRecordValueTests.swift in Sources */, - 3F8CB104239E025F007627BF /* ReaderDetailViewControllerTests.swift in Sources */, FF7C89A31E3A1029000472A8 /* MediaLibraryPickerDataSourceTests.swift in Sources */, + C81CCD86243C00E000A83E27 /* TenorPageableTests.swift in Sources */, + 46F58501262605930010A723 /* BlockEditorSettingsServiceTests.swift in Sources */, + 8BBBEBB224B8F8C0005E358E /* ReaderCardTests.swift in Sources */, D81C2F5E20F88CE5002AE1F1 /* MarkAsSpamActionTests.swift in Sources */, D848CC1520FF33FC00A9038F /* NotificationContentRangeTests.swift in Sources */, + 084FC3B729913B1B00A17BCF /* JetpackPluginOverlayViewModelTests.swift in Sources */, D826D67F211D21C700A5D8FE /* NullMockUserDefaults.swift in Sources */, - F582060423A88379005159A9 /* TimePickerViewControllerTests.swift in Sources */, 7E987F58210811CC00CAFB88 /* NotificationContentRouterTests.swift in Sources */, E1C9AA561C10427100732665 /* MathTest.swift in Sources */, 93A379EC19FFBF7900415023 /* KeychainTest.m in Sources */, - D800D874209997DB00E7C7E5 /* FollowingMenuItemCreatorTests.swift in Sources */, + F1BB660C274E704D00A319BE /* LikeUserHelperTests.swift in Sources */, 436D5655212209D600CEAA33 /* RegisterDomainDetailsServiceProxyMock.swift in Sources */, + F1450CF92437EEBB00A28BFE /* MediaRequestAuthenticatorTests.swift in Sources */, + FE6BB1462932289B001E5F7A /* ContentMigrationCoordinatorTests.swift in Sources */, D821C817210036D9002ED995 /* ActivityContentFactoryTests.swift in Sources */, - F543AF5923A84F200022F595 /* SchedulingCalendarViewControllerTests.swift in Sources */, 931D26F719ED7F7500114F17 /* ReaderPostServiceTest.m in Sources */, B5772AC61C9C84900031F97E /* GravatarServiceTests.swift in Sources */, 937E3AB61E3EBE1600CDA01A /* PostEditorStateTests.swift in Sources */, @@ -13191,71 +23244,101 @@ 40E7FEC82211EEC00032834E /* LastPostStatsRecordValueTests.swift in Sources */, 57240224234E5BE200227067 /* PostServiceSelfHostedTests.swift in Sources */, B59D40A61DB522DF003D2D79 /* NSAttributedStringTests.swift in Sources */, - 93E9050719E6F3D8005513C9 /* TestContextManager.m in Sources */, - D816B8D72112D75C0052CE4D /* NewsCardTests.swift in Sources */, F565190323CF6D1D003FACAF /* WKCookieJarTests.swift in Sources */, + C738CB0D28623F07001BE107 /* QRLoginURLParserTests.swift in Sources */, D809E686203F0215001AA0DE /* ReaderPostCardCellTests.swift in Sources */, + FEFC0F8C273131A6001F7F1D /* CommentService+RepliesTests.swift in Sources */, 40E4698F2017E0700030DB5F /* PluginDirectoryEntryStateTests.swift in Sources */, 8BC6020D2390412000EFE3D0 /* NullBlogPropertySanitizerTests.swift in Sources */, 57B71D4E230DB5F200789A68 /* BlogBuilder.swift in Sources */, D848CC1920FF3A2400A9038F /* FormattableNotIconTests.swift in Sources */, + 32110547250BFC3E0048446F /* ImageDimensionParserTests.swift in Sources */, E1AB5A3A1E0C464700574B4E /* DelayTests.swift in Sources */, + 8B7F51CB24EED8A8008CF5B5 /* ReaderTrackerTests.swift in Sources */, D848CC0320FF04FA00A9038F /* FormattableUserContentTests.swift in Sources */, 5948AD111AB73D19006E8882 /* WPAppAnalyticsTests.m in Sources */, + 0A69300B28B5AA5E00E98DE1 /* FullScreenCommentReplyViewModelTests.swift in Sources */, FF8032661EE9E22200861F28 /* MediaProgressCoordinatorTests.swift in Sources */, 173D82E7238EE2A7008432DA /* FeatureFlagTests.swift in Sources */, E63C897C1CB9A0D700649C8F /* UITextFieldTextHelperTests.swift in Sources */, FF2EC3C22209AC19006176E1 /* GutenbergImgUploadProcessorTests.swift in Sources */, D81C2F5A20F86E94002AE1F1 /* LikeCommentActionTests.swift in Sources */, 8B8C814D2318073300A0E620 /* BasePostTests.swift in Sources */, + F1F083F6241FFE930056D3B1 /* AtomicAuthenticationServiceTests.swift in Sources */, FF8CD625214184EE00A33A8D /* MediaAssetExporterTests.swift in Sources */, + C373D6EA280452F6008F8C26 /* SiteIntentDataTests.swift in Sources */, + 8B69F100255C4870006B1CEF /* ActivityStoreTests.swift in Sources */, B5C0CF3F204DB92F00DB0362 /* NotificationReplyStoreTests.swift in Sources */, 575E126322973EBB0041B3EB /* PostCompactCellGhostableTests.swift in Sources */, + 2481B20C260D8FED00AE59DB /* WPAccount+ObjCLookupTests.m in Sources */, 40ACCF14224E167900190713 /* FlagsTest.swift in Sources */, - D800D87620999AE700E7C7E5 /* DiscoverMenuItemCreatorTests.swift in Sources */, E15027651E03E54100B847E3 /* PinghubTests.swift in Sources */, E1B642131EFA5113001DC6D7 /* ModelTestHelper.swift in Sources */, + FAB4F32724EDE12A00F259BA /* FollowCommentsServiceTests.swift in Sources */, + 3F3D854B251E6418001CA4D2 /* AnnouncementsDataStoreTests.swift in Sources */, F11023A1231863CE00C4E84A /* MediaServiceTests.swift in Sources */, 730354BA21C867E500CD18C2 /* SiteCreatorTests.swift in Sources */, B566EC751B83867800278395 /* NSMutableAttributedStringTests.swift in Sources */, + DC772B0328200A3700664C02 /* SiteStatsInsightViewModelTests.swift in Sources */, E6B9B8AF1B94FA1C0001B92F /* ReaderStreamViewControllerTests.swift in Sources */, + 4629E4232440C8160002E15C /* GutenbergCoverUploadProcessorTests.swift in Sources */, + 8BE69512243E674300FF492F /* PrepublishingHeaderViewTests.swift in Sources */, 40F50B82221310F000CBBB73 /* StatsTestCase.swift in Sources */, 02BE5CC02281B53F00E351BA /* RegisterDomainDetailsViewModelLoadingStateTests.swift in Sources */, + FEFA263E26C58427009CCB7E /* ShareAppTextActivityItemSourceTests.swift in Sources */, 40EE948222132F5800CD264F /* PublicizeConectionStatsRecordValueTests.swift in Sources */, 577C2AAB22936DCB00AD1F03 /* PostCardCellGhostableTests.swift in Sources */, + 08B954F328535EE800B07185 /* FeatureHighlightStoreTests.swift in Sources */, + AE3047AA270B66D300FE9266 /* Scanner+QuotedTextTests.swift in Sources */, 02761EC4227010BC009BAF0F /* BlogDetailsSectionIndexTests.swift in Sources */, 732A473D218787500015DA74 /* WPRichTextFormatterTests.swift in Sources */, 1ABA150822AE5F870039311A /* WordPressUIBundleTests.swift in Sources */, + FEA312842987FB0100FFD405 /* BlogJetpackTests.swift in Sources */, + 0C35FFF429CBA6DA00D224EB /* BlogDashboardPersonalizationViewModelTests.swift in Sources */, 57569CF2230485680052EE14 /* PostAutoUploadInteractorTests.swift in Sources */, 7E8980B922E73F4000C567B0 /* EditorSettingsServiceTests.swift in Sources */, 1797373720EBAA4100377B4E /* RouteMatcherTests.swift in Sources */, 73178C2A21BEE09300E37C9A /* SiteSegmentsCellTests.swift in Sources */, + 175CC17527205BFB00622FB4 /* DomainExpiryDateFormatterTests.swift in Sources */, B5416CFE1C1756B900006DD8 /* PushNotificationsManagerTests.m in Sources */, - 59B48B621B99E132008EBB84 /* JSONLoader.swift in Sources */, + 321955C124BE4EBF00E3F316 /* ReaderSelectInterestsCoordinatorTests.swift in Sources */, + F4EF4BAB291D3D4700147B61 /* SiteIconTests.swift in Sources */, + 59B48B621B99E132008EBB84 /* JSONObject.swift in Sources */, + 3F82310F24564A870086E9B8 /* ReaderTabViewTests.swift in Sources */, + C3E42AB027F4D30E00546706 /* MenuItemsViewControllerTests.swift in Sources */, D842EA4021FABB1800210E96 /* SiteSegmentTests.swift in Sources */, + C3C70C562835C5BB00DD2546 /* SiteDesignSectionLoaderTests.swift in Sources */, + 08A4E12F289D2795001D9EC7 /* UserPersistentStoreTests.swift in Sources */, 436D55F5211632B700CEAA33 /* RegisterDomainDetailsViewModelTests.swift in Sources */, E180BD4C1FB462FF00D0D781 /* CookieJarTests.swift in Sources */, 9813512E22F0FC2700F7425D /* FileDownloadsStatsRecordValueTests.swift in Sources */, 9363113F19FA996700B0C739 /* AccountServiceTests.swift in Sources */, + 17FC0032264D728E00FCBD37 /* SharingServiceTests.swift in Sources */, D88A649C208D7D81008AE9BC /* StockPhotosDataSourceTests.swift in Sources */, + 3236F7A124B61B950088E8F3 /* ReaderInterestsDataSourceTests.swift in Sources */, 40E7FEC52211DF790032834E /* StatsRecordTests.swift in Sources */, + 8BDA5A74247C5EAA00AB124C /* ReaderDetailCoordinatorTests.swift in Sources */, 74585B991F0D58F300E7E667 /* DomainsServiceTests.swift in Sources */, 8B7623382384373E00AB3EE7 /* PageListViewControllerTests.swift in Sources */, D88A64B0208DA093008AE9BC /* StockPhotosResultsPageTests.swift in Sources */, 0879FC161E9301DD00E1EFC8 /* MediaTests.swift in Sources */, B556EFCB1DCA374200728F93 /* DictionaryHelpersTests.swift in Sources */, + DC06DFF927BD52BE00969974 /* WeeklyRoundupBackgroundTaskTests.swift in Sources */, + 24C69A8B2612421900312D9A /* UserSettingsTests.swift in Sources */, + 8B6BD55024293FBE00DB8F28 /* PrepublishingNudgesViewControllerTests.swift in Sources */, + DC13DB7E293FD09F00E33561 /* StatsInsightsStoreTests.swift in Sources */, + ACACE3AE28D729FA000992F9 /* NoResultsViewControllerTests.swift in Sources */, 8BFE36FF230F1C850061EBA8 /* AbstractPost+fixLocalMediaURLsTests.swift in Sources */, 08A2AD791CCED2A800E84454 /* PostTagServiceTests.m in Sources */, F543AF5723A84E4D0022F595 /* PublishSettingsControllerTests.swift in Sources */, 027AC5212278983F0033E56E /* DomainCreditEligibilityTests.swift in Sources */, F551E7F723FC9A5C00751212 /* Collection+RotateTests.swift in Sources */, + 2481B1E8260D4EAC00AE59DB /* WPAccount+LookupTests.swift in Sources */, E10F3DA11E5C2CE0008FAADA /* PostListFilterTests.swift in Sources */, 57D66B9D234BB78B005A2D74 /* PostServiceWPComTests.swift in Sources */, + DCF892D0282FA42A00BB71E1 /* SiteStatsImmuTableRowsTests.swift in Sources */, 8B939F4323832E5D00ACCB0F /* PostListViewControllerTests.swift in Sources */, 93EF094C19ED533500C89770 /* ContextManagerTests.swift in Sources */, - D800D87820999B6D00E7C7E5 /* LikedMenuItemCreatorTests.swift in Sources */, - D816B8CA2112D2FD0052CE4D /* LocalNewsServiceTests.swift in Sources */, - D800D87A20999C0500E7C7E5 /* OtherMenuItemCreatorTests.swift in Sources */, F17A2A2023BFBD84001E96AC /* UIView+ExistingConstraints.swift in Sources */, 9A9D34FD23607CCC00BC95A3 /* AsyncOperationTests.swift in Sources */, B5552D821CD1061F00B26DF6 /* StringExtensionsTests.swift in Sources */, @@ -13263,123 +23346,2081 @@ 73178C3521BEE9AC00E37C9A /* TitleSubtitleHeaderTests.swift in Sources */, 08AAD6A11CBEA610002B2418 /* MenusServiceTests.m in Sources */, BEA0E4851BD83565000AEE81 /* WP3DTouchShortcutCreatorTests.swift in Sources */, + 174C116F2624603400346EC6 /* MBarRouteTests.swift in Sources */, + 46B30B782582C7DD00A25E66 /* SiteAddressServiceTests.swift in Sources */, + 1D91080729F847A2003F9A5E /* MediaServiceUpdateTests.m in Sources */, + C81CCD6C243AEFBF00A83E27 /* TenorReponseData.swift in Sources */, + 805CC0BF29668A97002941DC /* MockCurrentDateProvider.swift in Sources */, 570BFD902282418A007859A8 /* PostBuilder.swift in Sources */, + C738CB0F28626466001BE107 /* QRLoginScanningCoordinatorTests.swift in Sources */, + 3F86A83729D19C15005D20C0 /* SignupEpilogueTableViewControllerTests.swift in Sources */, + 010459ED2915519C000C7778 /* JetpackNotificationMigrationServiceTests.swift in Sources */, + 8B6214E627B1B446001DF7B6 /* BlogDashboardServiceTests.swift in Sources */, + C856749A243F4292001A995E /* TenorMockDataHelper.swift in Sources */, + 01E78D1D296EA54F00FB6863 /* StatsPeriodHelperTests.swift in Sources */, + 3FFE3C0828FE00D10021BB96 /* StatsSegmentedControlDataTests.swift in Sources */, + 015BA4EB29A788A300920F4B /* StatsTotalInsightsCellTests.swift in Sources */, D81C2F5820F86CEA002AE1F1 /* NetworkStatus.swift in Sources */, E1C545801C6C79BB001CEB0E /* MediaSettingsTests.swift in Sources */, - D816B8C02112CC930052CE4D /* NewsItemTests.swift in Sources */, + C3439B5F27FE3A3C0058DA55 /* SiteCreationWizardLauncherTests.swift in Sources */, 7E987F5A2108122A00CAFB88 /* NotificationUtility.swift in Sources */, + FE32E7F12844971000744D80 /* ReminderScheduleCoordinatorTests.swift in Sources */, + 4688E6CC26AB571D00A5D894 /* RequestAuthenticatorTests.swift in Sources */, 7E442FC720F677CB00DEACA5 /* ActivityLogRangesTest.swift in Sources */, - D80EE63A203DEE1B0094C34C /* ReaderFollowedSitesStreamHeaderTests.swift in Sources */, + 938466B92683CA0E00A538DC /* ReferrerDetailsViewModelTests.swift in Sources */, + FECA44322836647100D01F15 /* PromptRemindersSchedulerTests.swift in Sources */, + FE3D057E26C3D5C1002A51B0 /* ShareAppContentPresenterTests.swift in Sources */, + 4A9948E229714EF1006282A9 /* AccountSettingsServiceTests.swift in Sources */, + 8B25F8DA24B7683A009DD4C9 /* ReaderCSSTests.swift in Sources */, + AB2211F425ED6E7A00BF72FC /* CommentServiceTests.swift in Sources */, 08E77F471EE9D72F006F9515 /* MediaThumbnailExporterTests.swift in Sources */, 59FBD5621B5684F300734466 /* ThemeServiceTests.m in Sources */, + 0CB4056E29C7BA63008EED0A /* BlogDashboardPersonalizationServiceTests.swift in Sources */, 85F8E19D1B018698000859BB /* PushAuthenticationServiceTests.swift in Sources */, + 931215E1267DE1C0008C3B69 /* StatsTotalRowDataTests.swift in Sources */, + F41E4E8C28F18B7B001880C6 /* AppIconListViewModelTests.swift in Sources */, + FE7FAABB299A36570032A6F2 /* WPComJetpackRemoteInstallViewModelTests.swift in Sources */, 08F8CD2D1EBD24600049D0C0 /* MediaExporterTests.swift in Sources */, + 805CC0B7296680CF002941DC /* RemoteFeatureFlagStoreMock.swift in Sources */, 73178C3321BEE94700E37C9A /* SiteAssemblyServiceTests.swift in Sources */, D88A6492208D7A0A008AE9BC /* MockStockPhotosService.swift in Sources */, F5D0A65223CCD3B600B20D27 /* PreviewWebKitViewControllerTests.swift in Sources */, B5ECA6CD1DBAAD510062D7E0 /* CoreDataHelperTests.swift in Sources */, 931D270019EDAE8600114F17 /* CoreDataMigrationTests.m in Sources */, E6B9B8AA1B94E1FE0001B92F /* ReaderPostTest.m in Sources */, + 80EF9284280CFEB60064A971 /* DashboardPostsSyncManagerTests.swift in Sources */, 7EC9FE0B22C627DB00C5A888 /* PostEditorAnalyticsSessionTests.swift in Sources */, D88A64A0208D8B7D008AE9BC /* StockPhotosMediaGroupTests.swift in Sources */, + 4AEF2DD929A84B2C00345734 /* ReaderSiteServiceTests.swift in Sources */, + 80535DC5294BF4BE00873161 /* JetpackBrandingMenuCardPresenterTests.swift in Sources */, 7EAA66EF22CB36FD00869038 /* TestAnalyticsTracker.swift in Sources */, 931D26F619ED7F7000114F17 /* BlogServiceTest.m in Sources */, + 014192A02983F5E800CAEDB0 /* SupportConfigurationTests.swift in Sources */, B5882C471D5297D1008E0EAA /* NotificationTests.swift in Sources */, + DC2CA0852837B9080037E17E /* SiteStatsInsightsDetailsViewModelTests.swift in Sources */, + F4D9AF53288AE2BA00803D40 /* SuggestionsTableViewDelegateMock.swift in Sources */, B532ACD31DC3AE1200FFFA57 /* OHHTTPStubs+Helpers.swift in Sources */, E11DF3E420C97F0A00C0B07C /* NotificationCenterObserveOnceTests.swift in Sources */, 5960967F1CF7959300848496 /* PostTests.swift in Sources */, + 4AFB8FBF2824999500A2F4B2 /* ContextManager+Helpers.swift in Sources */, + 3F751D462491A93D0008A2B1 /* ZendeskUtilsTests+Plans.swift in Sources */, + F44FB6CB287895AF0001E3CE /* SuggestionsListViewModelTests.swift in Sources */, 8BD36E062395CC4400EFFF1C /* MediaEditorOperation+DescriptionTests.swift in Sources */, + 8B5FEC0225A750CB000CBFF7 /* UIApplication+mainWindow.swift in Sources */, E6843840221F5A2200752258 /* PostListExcessiveLoadMoreTests.swift in Sources */, 325D3B3D23A8376400766DF6 /* FullScreenCommentReplyViewControllerTests.swift in Sources */, + 805CC0B9296680F7002941DC /* RemoteConfigStoreMock.swift in Sources */, + 0147D651294B6EA600AA6410 /* StatsRevampStoreTests.swift in Sources */, 57D6C83E22945A10003DDC7E /* PostCompactCellTests.swift in Sources */, + B030FE0A27EBF0BC000F6F5E /* SiteCreationIntentTracksEventTests.swift in Sources */, D81C2F5C20F872C2002AE1F1 /* ReplyToCommentActionTests.swift in Sources */, + 08C42C31281807880034720B /* ReaderSubscribeCommentsActionTests.swift in Sources */, D848CC1720FF38EA00A9038F /* FormattableCommentRangeTests.swift in Sources */, + 246D0A0325E97D5D0028B83F /* Blog+ObjcTests.m in Sources */, + 4AD5657228E543A30054C676 /* BlogQueryTests.swift in Sources */, 9A9D34FF2360A4E200BC95A3 /* StatsPeriodAsyncOperationTests.swift in Sources */, + 8BE9AB8827B6B5A300708E45 /* BlogDashboardPersistenceTests.swift in Sources */, B5EFB1C91B333C5A007608A3 /* NotificationSettingsServiceTests.swift in Sources */, + AC68C9CA28E5DF14009030A9 /* NotificationsViewControllerTests.swift in Sources */, + F44FB6CD287897F90001E3CE /* SuggestionsTableViewMockDelegate.swift in Sources */, + 179501CD27A01D4100882787 /* PublicizeAuthorizationURLComponentsTests.swift in Sources */, 4089C51422371EE30031CE78 /* TodayStatsTests.swift in Sources */, 7E53AB0A20FE83A9005796FE /* MockContentCoordinator.swift in Sources */, BE1071FF1BC75FFA00906AFF /* WPStyleGuide+BlogTests.swift in Sources */, 932645A41E7C206600134988 /* GutenbergSettingsTests.swift in Sources */, 933D1F471EA64108009FB462 /* TestingAppDelegate.m in Sources */, 5789E5C822D7D40800333698 /* AztecPostViewControllerAttachmentTests.swift in Sources */, + 0A9687BC28B40771009DCD2F /* FullScreenCommentReplyViewModelMock.swift in Sources */, + 0118969129D1F2FE00D34BA9 /* DomainsDashboardCardHelperTests.swift in Sources */, 400199AB222590E100EB0906 /* AllTimeStatsRecordValueTests.swift in Sources */, - 32C765BB23F7170C000A7F11 /* PostSignUpInterstitialCoordinatorTests.swift in Sources */, + C8567498243F41CA001A995E /* MockTenorService.swift in Sources */, + 1D19C56629C9DB0A00FB0087 /* GutenbergVideoPressUploadProcessorTests.swift in Sources */, + 4A9314DC297790C300360232 /* PeopleServiceTests.swift in Sources */, 4054F4642214F94D00D261AB /* StreakStatsRecordValueTests.swift in Sources */, 85B125411B028E34008A3D95 /* PushAuthenticationManagerTests.swift in Sources */, + 4A76A4BB29D4381100AABF4B /* CommentService+LikesTests.swift in Sources */, + 8BB185CE24B62CE100A4CCE8 /* ReaderCardServiceTests.swift in Sources */, 57DF04C1231489A200CC93D6 /* PostCardStatusViewModelTests.swift in Sources */, D821C81B21003AE9002ED995 /* FormattableContentGroupTests.swift in Sources */, 93D86B981C691E71003D8E3E /* LocalCoreDataServiceTests.m in Sources */, 400A2C932217B463000A8A59 /* ReferrerStatsRecordValueTests.swift in Sources */, - E1928B2E1F8369F100E076C8 /* WebViewAuthenticatorTests.swift in Sources */, + 3F50945B2454ECA000C4470B /* ReaderTabItemsStoreTests.swift in Sources */, + 8384C64428AAC85F00EABE26 /* KeychainUtilsTests.swift in Sources */, 73178C2921BEE09300E37C9A /* SiteSegmentsStepTests.swift in Sources */, + FEAA6F79298CE4A600ADB44C /* PluginJetpackProxyServiceTests.swift in Sources */, 08F8CD311EBD2A960049D0C0 /* MediaImageExporterTests.swift in Sources */, + DC06DFFC27BD679700969974 /* BlogTitleTests.swift in Sources */, + FAE8EE9C273AD0A800A65307 /* QuickStartSettingsTests.swift in Sources */, 400A2C912217B308000A8A59 /* CountryStatsRecordValueTests.swift in Sources */, + 8BD8201D24BF9E5200FF25FD /* ReaderWelcomeBannerTests.swift in Sources */, + DCF892D2282FA45500BB71E1 /* StatsMockDataLoader.swift in Sources */, 02761EC222700A9C009BAF0F /* BlogDetailsSubsectionToSectionCategoryTests.swift in Sources */, - D800D87C20999CA200E7C7E5 /* SearchMenuItemCreatorTests.swift in Sources */, + 8B749E9025AF8D2E00023F03 /* JetpackCapabilitiesServiceTests.swift in Sources */, + F1450CF72437E8F800A28BFE /* MediaHostTests.swift in Sources */, + F4426FD3287E08C300218003 /* SuggestionServiceMock.swift in Sources */, 436D55F02115CB6800CEAA33 /* RegisterDomainDetailsSectionTests.swift in Sources */, + 4A17C1A4281A823E0001FFE5 /* NSManagedObject+Fixture.swift in Sources */, E1B921BC1C0ED5A3003EA3CB /* MediaSizeSliderCellTest.swift in Sources */, + C314543B262770BE005B216B /* BlogServiceAuthorTests.swift in Sources */, 40F50B80221310D400CBBB73 /* FollowersStatsRecordValueTests.swift in Sources */, + 3F50945F245537A700C4470B /* ReaderTabViewModelTests.swift in Sources */, 0885A3671E837AFE00619B4D /* URLIncrementalFilenameTests.swift in Sources */, D848CBF920FEF82100A9038F /* NotificationsContentFactoryTests.swift in Sources */, 575802132357C41200E4C63C /* MediaCoordinatorTests.swift in Sources */, F18B43781F849F580089B817 /* PostAttachmentTests.swift in Sources */, 400A2C972217B883000A8A59 /* VisitsSummaryStatsRecordValueTests.swift in Sources */, + DC8F61FC2703321F0087AC5D /* TimeZoneFormatterTests.swift in Sources */, E180BD4E1FB4681E00D0D781 /* MockCookieJar.swift in Sources */, + F4D9AF4F288AD2E300803D40 /* SuggestionViewModelTests.swift in Sources */, D81C2F6A20F8B449002AE1F1 /* NotificationActionParserTest.swift in Sources */, + F15D1FBA265C41A900854EE5 /* BloggingRemindersStoreTests.swift in Sources */, E1E4CE0D177439D100430844 /* WPAvatarSourceTest.m in Sources */, D88A64A2208D8F05008AE9BC /* StockPhotosMediaTests.swift in Sources */, B55F1AA21C107CE200FD04D4 /* BlogSettingsDiscussionTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + EA14532829AD874C001F3143 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EA14532929AD874C001F3143 /* MainNavigationTests.swift in Sources */, + EA14532A29AD874C001F3143 /* ReaderTests.swift in Sources */, + EA14532B29AD874C001F3143 /* EditorAztecTests.swift in Sources */, + D82E087629EEB0B00098F500 /* DashboardTests.swift in Sources */, + EA14532C29AD874C001F3143 /* EditorGutenbergTests.swift in Sources */, + EA14532D29AD874C001F3143 /* LoginTests.swift in Sources */, + EA14532E29AD874C001F3143 /* BaseScreen.swift in Sources */, + EA14532F29AD874C001F3143 /* SupportScreenTests.swift in Sources */, + EA14533029AD874C001F3143 /* StatsTests.swift in Sources */, + EA14533129AD874C001F3143 /* WPUITestCredentials.swift in Sources */, + EA14533229AD874C001F3143 /* SignupTests.swift in Sources */, + EA14533329AD874C001F3143 /* EditorFlow.swift in Sources */, + EA14533429AD874C001F3143 /* UIApplication+mainWindow.swift in Sources */, + EA14533529AD874C001F3143 /* LoginFlow.swift in Sources */, + EA14533629AD874C001F3143 /* XCTest+Extensions.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F1F163BA25658B4D003DC13B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0107E16D28FFED1800DE87DB /* WidgetConfiguration.swift in Sources */, + 3F46AB0025BF5D6300CE2E98 /* Sites.intentdefinition in Sources */, + 3F8B310E25D1D60C005A2903 /* ThisWeekWidgetStats.swift in Sources */, + 0107E16E28FFEF3700DE87DB /* AppConfiguration.swift in Sources */, + 3F5C866E25C9EBF200BABE64 /* HomeWidgetAllTimeData.swift in Sources */, + F1482CE02575BDA4007E4DD6 /* SitesDataProvider.swift in Sources */, + F17196FC257556020051AA98 /* HomeWidgetTodayData.swift in Sources */, + F1ACDF6B256D6C120005AE9B /* CocoaLumberjack.swift in Sources */, + F198FF5D256D4877001266EB /* HomeWidgetCache.swift in Sources */, + 3F5C864C25C9EA8400BABE64 /* AllTimeWidgetStats.swift in Sources */, + F198FF4C256D483D001266EB /* TodayWidgetStats.swift in Sources */, + F198FF3B256D47AB001266EB /* HomeWidgetData.swift in Sources */, + 3F8B311F25D1D610005A2903 /* HomeWidgetThisWeekData.swift in Sources */, + F1ACDF7C256D6C290005AE9B /* Constants.m in Sources */, + F177986725755F2200AD3836 /* SFHFKeychainUtils.m in Sources */, + F1F163C125658B4D003DC13B /* IntentHandler.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FABB20C22602FC2C00C8785C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FABB20C32602FC2C00C8785C /* FloatingActionButton.swift in Sources */, + FABB20C42602FC2C00C8785C /* TextList+WordPress.swift in Sources */, + FABB20C52602FC2C00C8785C /* Notification.swift in Sources */, + FABB20C62602FC2C00C8785C /* NoticeView.swift in Sources */, + FABB20C72602FC2C00C8785C /* TopViewedAuthorStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB20C82602FC2C00C8785C /* ActivityPluginRange.swift in Sources */, + FABB20C92602FC2C00C8785C /* ShareNoticeViewModel.swift in Sources */, + FABB20CA2602FC2C00C8785C /* Routes+Reader.swift in Sources */, + FABB20CB2602FC2C00C8785C /* WPContentSearchHelper.swift in Sources */, + FABB20CC2602FC2C00C8785C /* SiteStatsViewModel+AsyncBlock.swift in Sources */, + FABB20CD2602FC2C00C8785C /* PostUploadOperation.swift in Sources */, + FABB20CE2602FC2C00C8785C /* VerticallyStackedButton.m in Sources */, + FABB20CF2602FC2C00C8785C /* PageListViewController.swift in Sources */, + FABB20D02602FC2C00C8785C /* CoreDataHelper.swift in Sources */, + FABB20D12602FC2C00C8785C /* LocalCoreDataService.m in Sources */, + FE29EFCE29A91160007CE034 /* WPAdminConvertibleRouter.swift in Sources */, + FABB20D22602FC2C00C8785C /* AztecNavigationController.swift in Sources */, + FABB20D32602FC2C00C8785C /* Notification+Interface.swift in Sources */, + FABB20D42602FC2C00C8785C /* ReaderSaveForLater+Analytics.swift in Sources */, + FABB20D52602FC2C00C8785C /* TagsCategoriesStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB20D62602FC2C00C8785C /* RevisionOperation.swift in Sources */, + FABB20D72602FC2C00C8785C /* ZendeskUtils.swift in Sources */, + FABB20D82602FC2C00C8785C /* AbstractPost+Dates.swift in Sources */, + FABB20D92602FC2C00C8785C /* Blog+HomepageSettings.swift in Sources */, + C7BB601D2863B3D600748FD9 /* QRLoginCameraPermissionsHandler.swift in Sources */, + FABB20DA2602FC2C00C8785C /* GutenbergRollout.swift in Sources */, + FABB20DB2602FC2C00C8785C /* Routes+MySites.swift in Sources */, + FABB20DC2602FC2C00C8785C /* WPAppAnalytics+Media.swift in Sources */, + FE6BB144293227AC001E5F7A /* ContentMigrationCoordinator.swift in Sources */, + FABB20DD2602FC2C00C8785C /* Post.swift in Sources */, + FA8E2FE127C6377000DA0982 /* DashboardQuickStartCardCell.swift in Sources */, + FABB20DE2602FC2C00C8785C /* GutenbergWebViewController.swift in Sources */, + FABB20E02602FC2C00C8785C /* CookieJar.swift in Sources */, + C7F7BDBD26262A1B00CE547F /* AppDependency.swift in Sources */, + FAB37D4727ED84BC00CA993C /* DashboardStatsNudgeView.swift in Sources */, + FABB20E12602FC2C00C8785C /* WebAddressStep.swift in Sources */, + FABB20E22602FC2C00C8785C /* NotificationSyncMediator.swift in Sources */, + FABB20E42602FC2C00C8785C /* ThemeBrowserHeaderView.swift in Sources */, + FABB20E52602FC2C00C8785C /* SiteStatsInformation.swift in Sources */, + FABB20E62602FC2C00C8785C /* TitleSubtitleHeader.swift in Sources */, + FABB20E82602FC2C00C8785C /* AppAppearance.swift in Sources */, + FABB20E92602FC2C00C8785C /* ReaderTabViewController.swift in Sources */, + FE4DC5A8293A84E6008F322F /* MigrationDeepLinkRouter.swift in Sources */, + FA4B203629A786460089FE68 /* BlazeEventsTracker.swift in Sources */, + FABB20EA2602FC2C00C8785C /* ActivityTypeSelectorViewController.swift in Sources */, + FABB20EB2602FC2C00C8785C /* ActivityActionsParser.swift in Sources */, + FABB20EC2602FC2C00C8785C /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB20ED2602FC2C00C8785C /* Routes+Me.swift in Sources */, + FABB20EE2602FC2C00C8785C /* StockPhotosResultsPage.swift in Sources */, + FABB20EF2602FC2C00C8785C /* QuickStartTourGuide.swift in Sources */, + FABB20F02602FC2C00C8785C /* ReaderDetailToolbar.swift in Sources */, + 80A2154429D1177A002FE8EB /* RemoteConfigDebugViewController.swift in Sources */, + FABB20F12602FC2C00C8785C /* RecentSitesService.swift in Sources */, + FA332AD129C1F97A00182FBB /* MovedToJetpackViewController.swift in Sources */, + 8BD66ED52787530C00CCD95A /* PostsCardViewModel.swift in Sources */, + FABB20F22602FC2C00C8785C /* PlanService.swift in Sources */, + FABB20F32602FC2C00C8785C /* Routes+Mbar.swift in Sources */, + FEFA264026C5AE9E009CCB7E /* ShareAppTextActivityItemSource.swift in Sources */, + FABB20F42602FC2C00C8785C /* PostListEditorPresenter.swift in Sources */, + C7BB60202863B9E800748FD9 /* QRLoginCameraSession.swift in Sources */, + FABB20F52602FC2C00C8785C /* PreviewDeviceLabel.swift in Sources */, + FABB20F62602FC2C00C8785C /* WPStyleGuide+ReaderComments.swift in Sources */, + FABB20F72602FC2C00C8785C /* TopViewedPostStatsRecordValue+CoreDataProperties.swift in Sources */, + C3C39B0826F50D3900B1238D /* WordPressSupportSourceTag+Editor.swift in Sources */, + FEFC0F8A2731182C001F7F1D /* CommentService+Replies.swift in Sources */, + 803D90F8292F0188007CC0D0 /* JetpackRedirector.swift in Sources */, + FABB20F82602FC2C00C8785C /* BaseActivityListViewController.swift in Sources */, + 98E54FF3265C972900B4BE9A /* ReaderDetailLikesView.swift in Sources */, + FABB20F92602FC2C00C8785C /* RequestAuthenticator.swift in Sources */, + FABB20FA2602FC2C00C8785C /* SettingsTitleSubtitleController.swift in Sources */, + FABB20FB2602FC2C00C8785C /* WebViewControllerFactory.swift in Sources */, + FABB20FC2602FC2C00C8785C /* WordPress-32-33.xcmappingmodel in Sources */, + FABB20FD2602FC2C00C8785C /* ShowRevisionsListManger.swift in Sources */, + FABB20FF2602FC2C00C8785C /* WidgetStyles.swift in Sources */, + FABB21002602FC2C00C8785C /* WordPress-91-92.xcmappingmodel in Sources */, + FABB21012602FC2C00C8785C /* ReaderCardDiscoverAttributionView.swift in Sources */, + 088D58A629E724F300E6C0F4 /* ColorGallery.swift in Sources */, + FABB21022602FC2C00C8785C /* Plugin.swift in Sources */, + FABB21032602FC2C00C8785C /* JetpackActivityLogViewController.swift in Sources */, + 17870A712816F2A000D1C627 /* StatsLatestPostSummaryInsightsCell.swift in Sources */, + C33A5ADC2935848F00961E3A /* MigrationAppDetection.swift in Sources */, + 80D9D00429EF4C7F00FE3400 /* DashboardPageCreationCell.swift in Sources */, + DC772AF6282009BA00664C02 /* StatsLineChartView.swift in Sources */, + FABB21042602FC2C00C8785C /* FormattableMediaContent.swift in Sources */, + FABB21052602FC2C00C8785C /* CollabsableHeaderFilterCollectionViewCell.swift in Sources */, + FABB21062602FC2C00C8785C /* SearchResultsStatsRecordValue+CoreDataClass.swift in Sources */, + 8B55FA122614D989007D618E /* UnifiedPrologueReaderContentView.swift in Sources */, + C34E94BC28EDF80700D27A16 /* InfiniteScrollerViewDelegate.swift in Sources */, + FABB21072602FC2C00C8785C /* ReaderTableContent.swift in Sources */, + FABB21082602FC2C00C8785C /* SiteCreationHeaderData.swift in Sources */, + FABB21092602FC2C00C8785C /* WPAuthTokenIssueSolver.m in Sources */, + FABB210A2602FC2C00C8785C /* PageAutoUploadMessageProvider.swift in Sources */, + FABB210B2602FC2C00C8785C /* ReaderDetailViewController.swift in Sources */, + FABB210C2602FC2C00C8785C /* HomeWidgetThisWeekData.swift in Sources */, + FABB210D2602FC2C00C8785C /* PersonHeaderCell.swift in Sources */, + FABB210E2602FC2C00C8785C /* SuggestionService.swift in Sources */, + FABB210F2602FC2C00C8785C /* WordPress-41-42.xcmappingmodel in Sources */, + 24351255264DCA08009BB2B6 /* Secrets.swift in Sources */, + 98A047732821CEBF001B4E2D /* BloggingPromptsViewController.swift in Sources */, + FABB21102602FC2C00C8785C /* MySiteViewController+FAB.swift in Sources */, + 803BB981295957CF00B3F6D6 /* WPTabBarController+RootViewPresenter.swift in Sources */, + FABB21112602FC2C00C8785C /* BaseRestoreOptionsViewController.swift in Sources */, + 8B4DDF25278F44CC0022494D /* BlogDashboardViewController.swift in Sources */, + FABB21122602FC2C00C8785C /* AddSiteAlertFactory.swift in Sources */, + 80EF672027F135EB0063B138 /* WhatIsNewViewAppearance.swift in Sources */, + FABB21132602FC2C00C8785C /* AuthenticationService.swift in Sources */, + FABB21142602FC2C00C8785C /* JetpackSiteRef.swift in Sources */, + FABB21152602FC2C00C8785C /* WPStyleGuide+Reply.swift in Sources */, + 836498CF281735CC00A2C170 /* BloggingPromptsHeaderView.swift in Sources */, + FABB21162602FC2C00C8785C /* WPBlogTableViewCell.m in Sources */, + FABB21172602FC2C00C8785C /* JetpackBackupOptionsViewController.swift in Sources */, + F4D829682931059000038726 /* MigrationSuccessActionHandler.swift in Sources */, + FABB21182602FC2C00C8785C /* TopViewedVideoStatsRecordValue+CoreDataProperties.swift in Sources */, + 803BB97D2959559500B3F6D6 /* RootViewPresenter.swift in Sources */, + FABB21192602FC2C00C8785C /* NotificationContentRangeFactory.swift in Sources */, + FABB211A2602FC2C00C8785C /* SiteInfo.swift in Sources */, + FABB211B2602FC2C00C8785C /* CredentialsService.swift in Sources */, + 3F5AAC242877791900AEF5DD /* JetpackButton.swift in Sources */, + FABB211C2602FC2C00C8785C /* EncryptedLogTableViewController.swift in Sources */, + FABB211D2602FC2C00C8785C /* ActivityDateFormatting.swift in Sources */, + FABB211E2602FC2C00C8785C /* UIView+Subviews.m in Sources */, + FABB211F2602FC2C00C8785C /* WordPress-20-21.xcmappingmodel in Sources */, + 8B55FA002614D980007D618E /* UnifiedPrologueNotificationsContentView.swift in Sources */, + FABB21202602FC2C00C8785C /* TenorClient.swift in Sources */, + FABB21212602FC2C00C8785C /* LoginEpilogueTableViewController.swift in Sources */, + FABB21222602FC2C00C8785C /* JetpackRemoteInstallViewModel.swift in Sources */, + FABB21232602FC2C00C8785C /* InteractivePostViewDelegate.swift in Sources */, + FABB21242602FC2C00C8785C /* PostEditor+BlogPicker.swift in Sources */, + FABB21252602FC2C00C8785C /* ActivityLogViewController.m in Sources */, + FABB21262602FC2C00C8785C /* UIViewController+ChildViewController.swift in Sources */, + FABB21272602FC2C00C8785C /* Media+Blog.swift in Sources */, + FABB21282602FC2C00C8785C /* MenuItemEditingHeaderView.m in Sources */, + 3F8B45AB292C42CC00730FA4 /* MigrationSuccessCell.swift in Sources */, + 803BB984295957F600B3F6D6 /* MySitesCoordinator+RootViewPresenter.swift in Sources */, + FABB21292602FC2C00C8785C /* Routes+Banners.swift in Sources */, + 8B5E1DD927EA5929002EBEE3 /* PostCoordinator+Dashboard.swift in Sources */, + 93F72150271831820021A09F /* SiteStatsPinnedItemStore.swift in Sources */, + FABB212A2602FC2C00C8785C /* BadgeLabel.swift in Sources */, + FABB212B2602FC2C00C8785C /* ReaderTagsTableViewController.swift in Sources */, + FABB212C2602FC2C00C8785C /* NotificationsViewController+AppRatings.swift in Sources */, + FABB212D2602FC2C00C8785C /* BlogService.m in Sources */, + DC9AF76A285DF8A300EA2A0D /* StatsFollowersChartViewModel.swift in Sources */, + FABB212E2602FC2C00C8785C /* JetpackSettingsViewController.swift in Sources */, + 982DDF95263238A6002B3904 /* LikeUserPreferredBlog+CoreDataClass.swift in Sources */, + FABB212F2602FC2C00C8785C /* PostingActivityViewController.swift in Sources */, + 4AD5656D28E3D0670054C676 /* ReaderPost+Helper.swift in Sources */, + FABB21302602FC2C00C8785C /* PostCardStatusViewModel.swift in Sources */, + 03216EC7279946CA00D444CA /* SchedulingDatePickerViewController.swift in Sources */, + FAD2544226116CEA00EDAF88 /* AppStyleGuide.swift in Sources */, + FABB21312602FC2C00C8785C /* SharingAuthorizationHelper.m in Sources */, + FABB21332602FC2C00C8785C /* TwoColumnCell.swift in Sources */, + C79C308326EA9A2300E88514 /* ReferrerDetailsCell.swift in Sources */, + FABB21342602FC2C00C8785C /* PostSettingsViewController.m in Sources */, + FABB21352602FC2C00C8785C /* MenuItem.m in Sources */, + FABB21362602FC2C00C8785C /* WPRichTextFormatter.swift in Sources */, + 175F99B62625FDE100F2687E /* FancyAlertViewController+AppIcons.swift in Sources */, + FABB21372602FC2C00C8785C /* LastPostStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB21382602FC2C00C8785C /* NoticeAnimator.swift in Sources */, + F18CB8972642E58700B90794 /* FixedSizeImageView.swift in Sources */, + F4FE743529C3767300AC2729 /* AddressTableViewCell+ViewModel.swift in Sources */, + FABB21392602FC2C00C8785C /* ShareExtensionSessionManager.swift in Sources */, + FABB213A2602FC2C00C8785C /* ReaderWelcomeBanner.swift in Sources */, + FABB213B2602FC2C00C8785C /* Extensions.xcdatamodeld in Sources */, + FABB213C2602FC2C00C8785C /* CustomLogFormatter.swift in Sources */, + FABB213D2602FC2C00C8785C /* ReaderInterestsCollectionViewCell.swift in Sources */, + FABB213E2602FC2C00C8785C /* StatsRecord+CoreDataClass.swift in Sources */, + FABB213F2602FC2C00C8785C /* LatestPostSummaryCell.swift in Sources */, + FABB21402602FC2C00C8785C /* LoginEpilogueViewController.swift in Sources */, + 8B45C12827B2B0D500EA3257 /* BlogDashboardService.swift in Sources */, + C3B5545429661F2C00A04753 /* ThemeBrowserViewController+JetpackBannerViewController.swift in Sources */, + FABB21412602FC2C00C8785C /* QuickStartTours.swift in Sources */, + FABB21422602FC2C00C8785C /* RevisionsTableViewFooter.swift in Sources */, + FABB21432602FC2C00C8785C /* KanvasCameraAnalyticsHandler.swift in Sources */, + C957C20726DCC1770037628F /* LandInTheEditorHelper.swift in Sources */, + FABB21442602FC2C00C8785C /* MediaEditorOperation+Description.swift in Sources */, + FABB21452602FC2C00C8785C /* WPStyleGuide+FilterTabBar.swift in Sources */, + FABB21462602FC2C00C8785C /* PagedViewController.swift in Sources */, + FABB21472602FC2C00C8785C /* InvitePersonViewController.swift in Sources */, + FABB21482602FC2C00C8785C /* PushAuthenticationManager.swift in Sources */, + FABB21492602FC2C00C8785C /* CommentServiceRemoteFactory.swift in Sources */, + FABB214A2602FC2C00C8785C /* SuggestionsTableView.m in Sources */, + FABB214B2602FC2C00C8785C /* PluginDetailViewHeaderCell.swift in Sources */, + B084E62027E3B7A4007BF7A8 /* SiteIntentViewController.swift in Sources */, + FABB214C2602FC2C00C8785C /* PageTemplateCategory+CoreDataClass.swift in Sources */, + FABB214D2602FC2C00C8785C /* AllTimeStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB214E2602FC2C00C8785C /* StreakInsightStatsRecordValue+CoreDataClass.swift in Sources */, + FABB214F2602FC2C00C8785C /* PostNoticeViewModel.swift in Sources */, + FABB21502602FC2C00C8785C /* JetpackInstallStore.swift in Sources */, + FABB21512602FC2C00C8785C /* WizardNavigation.swift in Sources */, + 93E63370272C1074009DACF8 /* LoginEpilogueCreateNewSiteCell.swift in Sources */, + FABB21522602FC2C00C8785C /* BuildConfiguration.swift in Sources */, + FABB21532602FC2C00C8785C /* PaddedLabel.swift in Sources */, + FABB21542602FC2C00C8785C /* PostService.m in Sources */, + FABB21552602FC2C00C8785C /* WPTextAttachmentManager.swift in Sources */, + FABB21562602FC2C00C8785C /* ReaderSiteInfoSubscriptions.swift in Sources */, + FABB21572602FC2C00C8785C /* ReaderSaveForLaterAction.swift in Sources */, + 982D99FF26F922C100AA794C /* InlineEditableMultiLineCell.swift in Sources */, + FABB21582602FC2C00C8785C /* WPHorizontalRuleAttachment.swift in Sources */, + FABB21592602FC2C00C8785C /* ImmuTable+Optional.swift in Sources */, + FABB215A2602FC2C00C8785C /* WordPressComRestApi+Defaults.swift in Sources */, + FABB215B2602FC2C00C8785C /* WhatIsNewViewController.swift in Sources */, + FABB215C2602FC2C00C8785C /* FormattableCommentContent.swift in Sources */, + C3E77F89293A4EA10034AE5A /* MigrationLoadWordPressViewModel.swift in Sources */, + 4A2172FF28F688890006F4F1 /* Blog+Media.swift in Sources */, + FABB215D2602FC2C00C8785C /* ValueTransformers.swift in Sources */, + FABB215E2602FC2C00C8785C /* ABTest.swift in Sources */, + FABB215F2602FC2C00C8785C /* ContextManager+ErrorHandling.swift in Sources */, + C383555A288B02B00062E402 /* JetpackBannerWrapperViewController.swift in Sources */, + FABB21602602FC2C00C8785C /* EmptyActionView.swift in Sources */, + FABB21612602FC2C00C8785C /* ReaderShowAttributionAction.swift in Sources */, + FABB21622602FC2C00C8785C /* LinkSettingsViewController.swift in Sources */, + 9856A39E261FC21E008D6354 /* UserProfileUserInfoCell.swift in Sources */, + FE32F000275914390040BE67 /* MenuSheetViewController.swift in Sources */, + FABB21632602FC2C00C8785C /* FeatureItemCell.swift in Sources */, + C3FBF4E928AFEDF8003797DF /* JetpackBrandingAnalyticsHelper.swift in Sources */, + 803C493C283A7C0C00003E9B /* QuickStartChecklistHeader.swift in Sources */, + FABB21642602FC2C00C8785C /* WPRichContentView.swift in Sources */, + FABB21652602FC2C00C8785C /* MediaFileManager.swift in Sources */, + FABB21662602FC2C00C8785C /* WPUserAgent.m in Sources */, + FABB21672602FC2C00C8785C /* AztecVerificationPromptHelper.swift in Sources */, + FABB21682602FC2C00C8785C /* NullBlogPropertySanitizer.swift in Sources */, + FABB21692602FC2C00C8785C /* StockPhotosStrings.swift in Sources */, + FABB216A2602FC2C00C8785C /* StatsViewController.m in Sources */, + DC8F61F827032B3F0087AC5D /* TimeZoneFormatter.swift in Sources */, + FABB216B2602FC2C00C8785C /* WPRichTextEmbed.swift in Sources */, + 80EF928E280E83110064A971 /* QuickStartToursCollection.swift in Sources */, + FABB216C2602FC2C00C8785C /* PostSignUpInterstitialViewController.swift in Sources */, + FABB216D2602FC2C00C8785C /* StatsGhostTableViewRows.swift in Sources */, + FABB216E2602FC2C00C8785C /* ActivityContentRouter.swift in Sources */, + FABB216F2602FC2C00C8785C /* UIView+ContentLayout.swift in Sources */, + FABB21702602FC2C00C8785C /* PeopleCell.swift in Sources */, + FABB21712602FC2C00C8785C /* AbstractPost+Local.swift in Sources */, + FABB21722602FC2C00C8785C /* MeScenePresenter.swift in Sources */, + FABB21732602FC2C00C8785C /* JetpackScanStatusViewModel.swift in Sources */, + F195C42C26DFBE21000EC884 /* WeeklyRoundupBackgroundTask.swift in Sources */, + FABB21752602FC2C00C8785C /* HeaderContentGroup.swift in Sources */, + FABB21762602FC2C00C8785C /* CameraHandler.swift in Sources */, + FA73D7EA27987BA500DF24B3 /* SitePickerViewController+SiteIcon.swift in Sources */, + FABB21772602FC2C00C8785C /* JetpackConnectionViewController.swift in Sources */, + 803BB97A2959543D00B3F6D6 /* RootViewCoordinator.swift in Sources */, + FABB21782602FC2C00C8785C /* NotificationActionsService.swift in Sources */, + FABB21792602FC2C00C8785C /* JetpackScanService.swift in Sources */, + FABB217A2602FC2C00C8785C /* FilterSheetView.swift in Sources */, + FABB217B2602FC2C00C8785C /* WPAddPostCategoryViewController.m in Sources */, + FABB217C2602FC2C00C8785C /* ReaderSearchTopic.swift in Sources */, + 3F2ABE1B277118C4005D8916 /* Blog+VideoLimits.swift in Sources */, + FABB217D2602FC2C00C8785C /* WPMediaPicker+MediaPicker.swift in Sources */, + 805CC0BD29668987002941DC /* JetpackBrandedScreen.swift in Sources */, + FABB217E2602FC2C00C8785C /* JetpackInstallError+Blocking.swift in Sources */, + FABB217F2602FC2C00C8785C /* QuickStartTourState.swift in Sources */, + FABB21802602FC2C00C8785C /* ReaderPostCardCell.swift in Sources */, + 0878580428B4CF950069F96C /* UserPersistentRepositoryUtility.swift in Sources */, + F1585419267D3B5700A2E966 /* BloggingRemindersScheduler.swift in Sources */, + FABB21812602FC2C00C8785C /* DiffContentValue.swift in Sources */, + FABB21822602FC2C00C8785C /* FormattableTextContent.swift in Sources */, + 3FFDEF7A29177D8C00B625CE /* MigrationNotificationsViewController.swift in Sources */, + FABB21832602FC2C00C8785C /* WordPress-61-62.xcmappingmodel in Sources */, + FABB21842602FC2C00C8785C /* WhatIsNewView.swift in Sources */, + FABB21852602FC2C00C8785C /* GravatarService.swift in Sources */, + FABB21862602FC2C00C8785C /* CountriesCell.swift in Sources */, + FABB21872602FC2C00C8785C /* NavBarTitleDropdownButton.m in Sources */, + FABB21882602FC2C00C8785C /* ReaderTopicsCardCell.swift in Sources */, + FA25FA342609AAAA0005E08F /* AppConfiguration.swift in Sources */, + 83C972E1281C45AB0049E1FE /* Post+BloggingPrompts.swift in Sources */, + 3FE3D1FC26A6F2AC00F3CD10 /* ListTableViewCell.swift in Sources */, + FABB21892602FC2C00C8785C /* Post+RefreshStatus.swift in Sources */, + F1585405267D3B5000A2E966 /* BloggingRemindersFlowIntroViewController.swift in Sources */, + FABB218A2602FC2C00C8785C /* ImageDimensionParser.swift in Sources */, + 8379669D299C0C44004A92B9 /* JetpackRemoteInstallTableViewCell.swift in Sources */, + FABB218B2602FC2C00C8785C /* FollowersCountStatsRecordValue+CoreDataClass.swift in Sources */, + FABB218C2602FC2C00C8785C /* DocumentUploadProcessor.swift in Sources */, + FABB218D2602FC2C00C8785C /* ReaderTopicService+Interests.swift in Sources */, + FABB218E2602FC2C00C8785C /* JetpackService.swift in Sources */, + FABB218F2602FC2C00C8785C /* FollowersStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB21902602FC2C00C8785C /* JetpackRestoreStatusFailedViewController.swift in Sources */, + FABB21912602FC2C00C8785C /* ReaderTopicToReaderListTopic37to38.swift in Sources */, + FABB21922602FC2C00C8785C /* BlogService+JetpackConvenience.swift in Sources */, + 8B55F9EE2614D977007D618E /* UnifiedPrologueStatsContentView.swift in Sources */, + FABB21932602FC2C00C8785C /* GutenbergTenorMediaPicker.swift in Sources */, + 3F8B45A029283D6C00730FA4 /* DashboardMigrationSuccessCell.swift in Sources */, + F45326D929F6B8A6005F9F31 /* SiteCreationAnalyticsEvent.swift in Sources */, + FA98B61A29A3BF050071AAE8 /* BlazeCardView.swift in Sources */, + FABB21942602FC2C00C8785C /* AztecPostViewController.swift in Sources */, + F1585442267D3BF900A2E966 /* CalendarDayToggleButton.swift in Sources */, + FE4C46FF27FAE61700285F35 /* DashboardPromptsCardCell.swift in Sources */, + FABB21972602FC2C00C8785C /* MenusViewController.m in Sources */, + FABB21982602FC2C00C8785C /* UIViewController+Notice.swift in Sources */, + F4D8296C2931087100038726 /* MigrationSuccessCell+Jetpack.swift in Sources */, + F4CBE3D7292597E3004FFBB6 /* SupportTableViewControllerConfiguration.swift in Sources */, + FABB21992602FC2C00C8785C /* GutenbergFileUploadProcessor.swift in Sources */, + FABB219A2602FC2C00C8785C /* EventLoggingDataProvider.swift in Sources */, + FABB219B2602FC2C00C8785C /* PageListSectionHeaderView.swift in Sources */, + FABB219C2602FC2C00C8785C /* InviteLinks+CoreDataClass.swift in Sources */, + 809101992908DE8500FCB4EA /* JetpackFullscreenOverlayViewModel.swift in Sources */, + FABB219D2602FC2C00C8785C /* Page+CoreDataProperties.swift in Sources */, + FABB219E2602FC2C00C8785C /* FollowersStatsRecordValue+CoreDataClass.swift in Sources */, + FABB219F2602FC2C00C8785C /* RegisterDomainSectionHeaderView.swift in Sources */, + FA98A24E2832A5E9003B9233 /* NewQuickStartChecklistView.swift in Sources */, + FABB21A02602FC2C00C8785C /* WPStyleGuide+Stats.swift in Sources */, + FABB21A12602FC2C00C8785C /* TabbedViewController.swift in Sources */, + FABB21A22602FC2C00C8785C /* Blog+Quota.swift in Sources */, + FABB21A32602FC2C00C8785C /* CommentsViewController.m in Sources */, + FABB21A42602FC2C00C8785C /* SiteSettingsViewController+SiteManagement.swift in Sources */, + FABB21A52602FC2C00C8785C /* Follow.swift in Sources */, + FABB21A62602FC2C00C8785C /* BlogJetpackSettingsService.swift in Sources */, + FABB21A72602FC2C00C8785C /* MenuItemTypeViewController.m in Sources */, + 80535DC3294BDE2B00873161 /* BlogDetailsViewController+JetpackBrandingMenuCard.swift in Sources */, + FABB21A82602FC2C00C8785C /* WPStyleGuide+WebView.m in Sources */, + FABB21A92602FC2C00C8785C /* HomeWidgetData.swift in Sources */, + FABB21AA2602FC2C00C8785C /* WPStyleGuide+Activity.swift in Sources */, + FABB21AB2602FC2C00C8785C /* AllTimeWidgetStats.swift in Sources */, + F1A75B9C2732EF3700784A70 /* AboutScreenTracker.swift in Sources */, + FABB21AC2602FC2C00C8785C /* GutenbergFilesAppMediaSource.swift in Sources */, + F478B152292FC1BC00AA8645 /* MigrationAppearance.swift in Sources */, + FABB21AD2602FC2C00C8785C /* NoteBlockTextTableViewCell.swift in Sources */, + FABB21AE2602FC2C00C8785C /* NotificationSettingStreamsViewController.swift in Sources */, + FABB21AF2602FC2C00C8785C /* SiteDesignPreviewViewController.swift in Sources */, + FABB21B02602FC2C00C8785C /* ReaderReblogFormatter.swift in Sources */, + 803DE822290642B4007D4E9C /* JetpackFeaturesRemovalCoordinator.swift in Sources */, + FABB21B12602FC2C00C8785C /* Array.swift in Sources */, + FABB21B22602FC2C00C8785C /* NotificationActionParser.swift in Sources */, + FABB21B32602FC2C00C8785C /* SiteTagsViewController.swift in Sources */, + FABB21B42602FC2C00C8785C /* GutenbergImageLoader.swift in Sources */, + 806E53E227E01C7F0064315E /* DashboardStatsViewModel.swift in Sources */, + FABB21B52602FC2C00C8785C /* ReaderCrossPostMeta.swift in Sources */, + FABB21B62602FC2C00C8785C /* SnippetsContentStyles.swift in Sources */, + FABB21B72602FC2C00C8785C /* TopicsCollectionView.swift in Sources */, + 08A250FD28D9F0E200F50420 /* CommentDetailInfoViewModel.swift in Sources */, + FABB21B82602FC2C00C8785C /* SiteSegmentsService.swift in Sources */, + FABB21B92602FC2C00C8785C /* BlogListDataSource.swift in Sources */, + FABB21BA2602FC2C00C8785C /* MediaItemTableViewCells.swift in Sources */, + F158541A267D3B6000A2E966 /* BloggingRemindersStore.swift in Sources */, + FABB21BB2602FC2C00C8785C /* AbstractPost+fixLocalMediaURLs.swift in Sources */, + FABB21BC2602FC2C00C8785C /* MediaNoticeNavigationCoordinator.swift in Sources */, + FABB21BD2602FC2C00C8785C /* ReaderPostToReaderPost37to38.swift in Sources */, + FABB21BE2602FC2C00C8785C /* ReaderRelatedPostsSectionHeaderView.swift in Sources */, + 9804A098263780B500354097 /* LikeUserHelpers.swift in Sources */, + FABB21C02602FC2C00C8785C /* AppRatingUtilityType.swift in Sources */, + FABB21C12602FC2C00C8785C /* MenuItemSourceFooterView.m in Sources */, + 8000361E292468D4007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel.swift in Sources */, + FABB21C22602FC2C00C8785C /* JetpackRestoreFailedViewController.swift in Sources */, + 8067340B27E3A50900ABC95E /* UIViewController+RemoveQuickStart.m in Sources */, + FABB21C32602FC2C00C8785C /* ActionSheetViewController.swift in Sources */, + 4A358DE729B5EB8D00BFCEBE /* PublicizeService+Lookup.swift in Sources */, + C737553F27C80DD500C6E9A1 /* String+CondenseWhitespace.swift in Sources */, + FABB21C42602FC2C00C8785C /* PostService+Revisions.swift in Sources */, + FABB21C52602FC2C00C8785C /* SourcePostAttribution.m in Sources */, + FABB21C62602FC2C00C8785C /* MySitesCoordinator.swift in Sources */, + 3F46EED128BFF339004F02B2 /* JetpackPromptsConfiguration.swift in Sources */, + FABB21C72602FC2C00C8785C /* MenuItem+ViewDesign.m in Sources */, + FABB21C92602FC2C00C8785C /* ReaderInterestsStyleGuide.swift in Sources */, + FABB21CA2602FC2C00C8785C /* SiteStatsInsightsTableViewController.swift in Sources */, + FABB21CB2602FC2C00C8785C /* PostCompactCell.swift in Sources */, + C3BC86F729528997005D1A01 /* PeopleViewController+JetpackBannerViewController.swift in Sources */, + FABB21CC2602FC2C00C8785C /* MeHeaderView.m in Sources */, + FABB21CD2602FC2C00C8785C /* WebProgressView.swift in Sources */, + FABB21CE2602FC2C00C8785C /* Restorer.swift in Sources */, + FABB21CF2602FC2C00C8785C /* BlogDetailsViewController+SectionHelpers.swift in Sources */, + 4A9314E8297A0C5000360232 /* PostCategory+Lookup.swift in Sources */, + FEC26031283FBA1A003D886A /* BloggingPromptCoordinator.swift in Sources */, + FABB21D02602FC2C00C8785C /* SiteStatsPeriodTableViewController.swift in Sources */, + FABB21D12602FC2C00C8785C /* TextBundleWrapper.m in Sources */, + FABB21D22602FC2C00C8785C /* WPCategoryTree.swift in Sources */, + FABB21D32602FC2C00C8785C /* PageListTableViewCell.m in Sources */, + FABB21D42602FC2C00C8785C /* PageSettingsViewController.m in Sources */, + FA73D7E62798765B00DF24B3 /* SitePickerViewController.swift in Sources */, + FABB21D52602FC2C00C8785C /* SiteDesignContentCollectionViewController.swift in Sources */, + C31466CC2939950900D62FC7 /* MigrationLoadWordPressViewController.swift in Sources */, + FABB21D62602FC2C00C8785C /* PlanComparisonViewController.swift in Sources */, + FABB21D72602FC2C00C8785C /* Routes+Stats.swift in Sources */, + FABB21D82602FC2C00C8785C /* MeViewController+UIViewControllerRestoration.swift in Sources */, + 8BF1C81B27BC00AF00F1C203 /* BlogDashboardCardFrameView.swift in Sources */, + FABB21D92602FC2C00C8785C /* SearchIdentifierGenerator.swift in Sources */, + C7124E922638905B00929318 /* StarFieldView.swift in Sources */, + FABB21DA2602FC2C00C8785C /* StatsTwoColumnRow.swift in Sources */, + FABB21DB2602FC2C00C8785C /* AlertInternalView.swift in Sources */, + 982DDF91263238A6002B3904 /* LikeUser+CoreDataClass.swift in Sources */, + FABB21DC2602FC2C00C8785C /* ReaderSaveForLaterRemovedPosts.swift in Sources */, + FABB21DD2602FC2C00C8785C /* NSAttributedString+StyledHTML.swift in Sources */, + FABB21DE2602FC2C00C8785C /* Accessible.swift in Sources */, + FABB21DF2602FC2C00C8785C /* PostingActivityCell.swift in Sources */, + DCFC097429D3549C00277ECB /* DashboardDomainsCardCell.swift in Sources */, + F44293DD28E45DBA00D340AF /* AppIconListViewModel.swift in Sources */, + 8B45C12727B2B08900EA3257 /* DashboardStatsCardCell.swift in Sources */, + FABB21E02602FC2C00C8785C /* SupportTableViewController.swift in Sources */, + FABB21E12602FC2C00C8785C /* DetailDataCell.swift in Sources */, + 83B1D038282C62620061D911 /* BloggingPromptsAttribution.swift in Sources */, + C7F7AC75261CF1F300CE547F /* JetpackLoginErrorViewController.swift in Sources */, + FABB21E22602FC2C00C8785C /* PluginViewModel.swift in Sources */, + FABB21E32602FC2C00C8785C /* NSCalendar+Helpers.swift in Sources */, + FABB21E42602FC2C00C8785C /* GutenbergCoverUploadProcessor.swift in Sources */, + FABB21E52602FC2C00C8785C /* MediaQuotaCell.swift in Sources */, + FABB21E62602FC2C00C8785C /* PartScreenPresentationController.swift in Sources */, + 086F2484284F52E100032F39 /* FeatureHighlightStore.swift in Sources */, + FABB21E72602FC2C00C8785C /* WPRichTextImage.swift in Sources */, + 17870A75281FBEC100D1C627 /* StatsTotalInsightsCell.swift in Sources */, + FABB21E82602FC2C00C8785C /* FormattableContentRange.swift in Sources */, + FABB21E92602FC2C00C8785C /* MenuItemSourceHeaderView.m in Sources */, + 98622EA0274C59A400061A5F /* ReaderDetailCommentsTableViewDelegate.swift in Sources */, + FABB21EA2602FC2C00C8785C /* RestoreWarningView.swift in Sources */, + 17039225282E6D2800F602E9 /* ViewsVisitorsLineChartCell.swift in Sources */, + 80535DC2294BDE2500873161 /* JetpackBrandingMenuCardPresenter.swift in Sources */, + C7D30C652638B07A00A1695B /* JetpackPrologueStyleGuide.swift in Sources */, + FABB21EB2602FC2C00C8785C /* GutenbergWebNavigationViewController.swift in Sources */, + F4F9D5EC29096CF500502576 /* MigrationHeaderView.swift in Sources */, + FABB21ED2602FC2C00C8785C /* WPTabBarController+ReaderTabNavigation.swift in Sources */, + FABB21EE2602FC2C00C8785C /* WPStyleGuide+Blog.swift in Sources */, + FABB21EF2602FC2C00C8785C /* KeyboardDismissHelper.swift in Sources */, + FABB21F02602FC2C00C8785C /* FancyAlertViewController+CreateButtonAnnouncement.swift in Sources */, + FABB21F12602FC2C00C8785C /* GutenbergSettings.swift in Sources */, + FABB21F22602FC2C00C8785C /* MediaPickingContext.swift in Sources */, + C7234A432832C2BA0045C63F /* QRLoginScanningViewController.swift in Sources */, + 176BA53C268266E70025E4A3 /* BlogService+Reminders.swift in Sources */, + FE25C236271F23000084E1DB /* ReaderCommentsNotificationSheetViewController.swift in Sources */, + FABB21F32602FC2C00C8785C /* ReaderStreamHeader.swift in Sources */, + FABB21F42602FC2C00C8785C /* Progress+Helpers.swift in Sources */, + FABB21F52602FC2C00C8785C /* PostNoticeNavigationCoordinator.swift in Sources */, + FABB21F62602FC2C00C8785C /* SearchableActivityConvertable.swift in Sources */, + FABB21F72602FC2C00C8785C /* GridCell.swift in Sources */, + FA4F383727D766020068AAF5 /* MySiteSettings.swift in Sources */, + C72A4F68264088E4009CA633 /* JetpackNotFoundErrorViewModel.swift in Sources */, + FABB21F82602FC2C00C8785C /* AdaptiveNavigationController.swift in Sources */, + FABB21F92602FC2C00C8785C /* RemotePostCategory+Extensions.swift in Sources */, + FABB21FA2602FC2C00C8785C /* PostAutoUploadMessages.swift in Sources */, + FABB21FB2602FC2C00C8785C /* WPError+Swift.swift in Sources */, + FABB21FC2602FC2C00C8785C /* FileDownloadsStatsRecordValue+CoreDataClass.swift in Sources */, + FABB21FD2602FC2C00C8785C /* ActivityUtils.swift in Sources */, + FABB21FE2602FC2C00C8785C /* WPLogger.m in Sources */, + FABB21FF2602FC2C00C8785C /* JetpackBackupStatusCoordinator.swift in Sources */, + 3F720C222889B65B00519938 /* JetpackBrandingVisibility.swift in Sources */, + 0C35FFF729CBB5DE00D224EB /* BlogDashboardEmptyStateCell.swift in Sources */, + FABB22012602FC2C00C8785C /* RevisionDiff.swift in Sources */, + FABB22022602FC2C00C8785C /* MediaThumbnailCoordinator.swift in Sources */, + FABB22032602FC2C00C8785C /* RevisionDiffsPageManager.swift in Sources */, + FABB22042602FC2C00C8785C /* CalendarCollectionView.swift in Sources */, + 3F946C5A2684DD8E00B946F6 /* BloggingRemindersActions.swift in Sources */, + F48D44BC2989AA8A0051EAA6 /* ReaderSiteService.m in Sources */, + C387B7A22638D66F00BDEF86 /* PostAuthorSelectorViewController.swift in Sources */, + 171096CC270F01EA001BCDD6 /* DomainSuggestionsTableViewController.swift in Sources */, + 3FFDEF7F29177FB100B625CE /* MigrationStepConfiguration.swift in Sources */, + FABB22052602FC2C00C8785C /* SubjectContentGroup.swift in Sources */, + FABB22062602FC2C00C8785C /* PluginListRow.swift in Sources */, + FABB22072602FC2C00C8785C /* BlogSettings+DateAndTimeFormat.swift in Sources */, + FABB22082602FC2C00C8785C /* PostSearchHeader.swift in Sources */, + FABB22092602FC2C00C8785C /* ReaderPostCardContentLabel.swift in Sources */, + FA90EFF0262E74210055AB22 /* JetpackWebViewControllerFactory.swift in Sources */, + 80A2153E29C35197002FE8EB /* StaticScreensTabBarWrapper.swift in Sources */, + FABB220A2602FC2C00C8785C /* FilterChipButton.swift in Sources */, + FABB220B2602FC2C00C8785C /* StatsRecordValue+CoreDataClass.swift in Sources */, + FABB220C2602FC2C00C8785C /* Blog.m in Sources */, + FABB220D2602FC2C00C8785C /* JetpackRemoteInstallStateView.swift in Sources */, + F4552086299D147B00D9F6A8 /* BlockedSite.swift in Sources */, + FABB220E2602FC2C00C8785C /* PublishSettingsViewController.swift in Sources */, + FABB220F2602FC2C00C8785C /* MediaHost+AbstractPost.swift in Sources */, + FABB22102602FC2C00C8785C /* PageTemplateCategory+CoreDataProperties.swift in Sources */, + FABB22112602FC2C00C8785C /* MyProfileHeaderView.swift in Sources */, + FABB22132602FC2C00C8785C /* BlogSiteVisibilityHelper.m in Sources */, + FABB22142602FC2C00C8785C /* TitleBadgeDisclosureCell.swift in Sources */, + FABB22152602FC2C00C8785C /* FormattableContent.swift in Sources */, + 4A358DEA29B5F14C00BFCEBE /* SharingButton+Lookup.swift in Sources */, + FABB22162602FC2C00C8785C /* WebAddressWizardContent.swift in Sources */, + C7AFF87D283D5CF4000E01DF /* QRLoginVerifyCoordinator.swift in Sources */, + FABB22172602FC2C00C8785C /* VisitsSummaryStatsRecordValue+CoreDataProperties.swift in Sources */, + 3F8D988A26153484003619E5 /* UnifiedPrologueBackgroundView.swift in Sources */, + FABB22182602FC2C00C8785C /* SiteSegmentsStep.swift in Sources */, + FABB22192602FC2C00C8785C /* ReaderInterestsCollectionViewFlowLayout.swift in Sources */, + FABB221A2602FC2C00C8785C /* PHAsset+Metadata.swift in Sources */, + FABB221B2602FC2C00C8785C /* Theme.m in Sources */, + FABB221C2602FC2C00C8785C /* StockPhotosMediaGroup.swift in Sources */, + FABB221D2602FC2C00C8785C /* DefaultFormattableContentAction.swift in Sources */, + 80D9CFF529DD314600FE3400 /* DashboardPagesListCardCell.swift in Sources */, + FABB221E2602FC2C00C8785C /* PostEditorNavigationBarManager.swift in Sources */, + FABB221F2602FC2C00C8785C /* Charts+LargeValueFormatter.swift in Sources */, + FABB22202602FC2C00C8785C /* CustomHighlightButton.m in Sources */, + C77FC9102800CAC100726F00 /* OnboardingEnableNotificationsViewController.swift in Sources */, + FABB22212602FC2C00C8785C /* UniversalLinkRouter.swift in Sources */, + FABB22222602FC2C00C8785C /* LightNavigationController.swift in Sources */, + FA20751527A86B73001A644D /* UIScrollView+Helpers.swift in Sources */, + FABB22232602FC2C00C8785C /* PluginDirectoryCollectionViewCell.swift in Sources */, + FABB22242602FC2C00C8785C /* GutenbergNetworking.swift in Sources */, + FABB22252602FC2C00C8785C /* NavigationActionHelpers.swift in Sources */, + C9F1D4BB2706EEEB00BDF917 /* HomepageEditorNavigationBarManager.swift in Sources */, + FABB22262602FC2C00C8785C /* String+Ranges.swift in Sources */, + FABB22272602FC2C00C8785C /* ActivityCommentRange.swift in Sources */, + FABB22282602FC2C00C8785C /* GutenbergAudioUploadProcessor.swift in Sources */, + FABB22292602FC2C00C8785C /* AsyncOperation.swift in Sources */, + FABB222A2602FC2C00C8785C /* JetpackScanThreatSectionGrouping.swift in Sources */, + 0839F88D2993C1B600415038 /* JetpackPluginOverlayCoordinator.swift in Sources */, + FABB222B2602FC2C00C8785C /* MediaService+Swift.swift in Sources */, + FABB222C2602FC2C00C8785C /* ReaderActionHelpers.swift in Sources */, + FABB222D2602FC2C00C8785C /* NoteBlockUserTableViewCell.swift in Sources */, + FABB222E2602FC2C00C8785C /* WPStyleGuide+Sharing.swift in Sources */, + FABB222F2602FC2C00C8785C /* StoryPoster.swift in Sources */, + 8B074A5127AC3A64003A2EB8 /* BlogDashboardViewModel.swift in Sources */, + FEAC916F28001FC4005026E7 /* AvatarTrainView.swift in Sources */, + FA4BC0D12996A589005EB077 /* BlazeService.swift in Sources */, + FABB22302602FC2C00C8785C /* Double+Stats.swift in Sources */, + FABB22312602FC2C00C8785C /* CircularProgressView.swift in Sources */, + FABB22322602FC2C00C8785C /* StatsNoDataRow.swift in Sources */, + FABB22332602FC2C00C8785C /* ReaderTableCardCell.swift in Sources */, + FABB22342602FC2C00C8785C /* ShareNoticeNavigationCoordinator.swift in Sources */, + FABB22352602FC2C00C8785C /* CameraCaptureCoordinator.swift in Sources */, + C77FC90A28009C7000726F00 /* OnboardingQuestionsPromptViewController.swift in Sources */, + FABB22362602FC2C00C8785C /* HomeWidgetCache.swift in Sources */, + 0839F88B2993C0C000415038 /* JetpackDefaultOverlayCoordinator.swift in Sources */, + FABB22372602FC2C00C8785C /* JetpackRestoreStatusCoordinator.swift in Sources */, + FABB22382602FC2C00C8785C /* EventLoggingDelegate.swift in Sources */, + FABB22392602FC2C00C8785C /* TodayStatsRecordValue+CoreDataClass.swift in Sources */, + FABB223B2602FC2C00C8785C /* AbstractPost+Searchable.swift in Sources */, + FAA9084D27BD60710093FFA8 /* MySiteViewController+QuickStart.swift in Sources */, + FABB223C2602FC2C00C8785C /* EditCommentViewController.m in Sources */, + FABB223D2602FC2C00C8785C /* ThisWeekWidgetStats.swift in Sources */, + FABB223E2602FC2C00C8785C /* PostAutoUploadMessageProvider.swift in Sources */, + FABB223F2602FC2C00C8785C /* GutenbergMediaPickerHelper.swift in Sources */, + FABB22402602FC2C00C8785C /* FeatureFlag.swift in Sources */, + 3F170E252655917400F6F670 /* UIView+SwiftUI.swift in Sources */, + 80EF929128105CFA0064A971 /* QuickStartFactory.swift in Sources */, + FABB22422602FC2C00C8785C /* BlogDetailsViewController+Activity.swift in Sources */, + FABB22432602FC2C00C8785C /* NoteBlockTableViewCell.swift in Sources */, + FABB22442602FC2C00C8785C /* WPTabBarController+QuickStart.swift in Sources */, + FABB22452602FC2C00C8785C /* SharedCoreDataStack.swift in Sources */, + FABB22462602FC2C00C8785C /* TagsCategoriesStatsRecordValue+CoreDataClass.swift in Sources */, + FABB22472602FC2C00C8785C /* KeychainTools.swift in Sources */, + FABB22482602FC2C00C8785C /* ReaderTagStreamHeader.swift in Sources */, + 8BC81D6627CFFC310057F790 /* BlogDashboardState.swift in Sources */, + FABB22492602FC2C00C8785C /* FullScreenCommentReplyViewController.swift in Sources */, + 0A3FCA1E28B71CBD00499A15 /* FullScreenCommentReplyViewModel.swift in Sources */, + FABB224A2602FC2C00C8785C /* ReaderFollowedSitesViewController.swift in Sources */, + FABB224B2602FC2C00C8785C /* AnnouncementsCache.swift in Sources */, + 803BB987295A873800B3F6D6 /* RootViewPresenter+MeNavigation.swift in Sources */, + 8B74A9A9268E3C68003511CE /* RewindStatus+multiSite.swift in Sources */, + FABB224C2602FC2C00C8785C /* GravatarUploader.swift in Sources */, + FABB224D2602FC2C00C8785C /* NotificationSettingDetailsViewController.swift in Sources */, + 0CB4057D29C8DF83008EED0A /* BlogDashboardPersonalizeCardCell.swift in Sources */, + FABB224E2602FC2C00C8785C /* AztecMediaPickingCoordinator.swift in Sources */, + FABB224F2602FC2C00C8785C /* SiteSuggestion+CoreDataClass.swift in Sources */, + FABB22502602FC2C00C8785C /* NibReusable.swift in Sources */, + FABB22512602FC2C00C8785C /* TabbedTotalsCell.swift in Sources */, + B084E61F27E3B79F007BF7A8 /* SiteIntentStep.swift in Sources */, + FABB22522602FC2C00C8785C /* ActivityTableViewCell.swift in Sources */, + F49D7BEC29DF329500CB93A5 /* UIPopoverPresentationController+PopoverAnchor.swift in Sources */, + FABB22532602FC2C00C8785C /* BlogDetailsViewController+FancyAlerts.swift in Sources */, + FABB22542602FC2C00C8785C /* StoriesIntroDataSource.swift in Sources */, + FABB22552602FC2C00C8785C /* StoreContainer.swift in Sources */, + FABB22562602FC2C00C8785C /* AbstractPost+HashHelpers.m in Sources */, + FABB22572602FC2C00C8785C /* StartOverViewController.swift in Sources */, + 934098C4271957A600B3E77E /* SiteStatsInsightsDelegate.swift in Sources */, + FABB22592602FC2C00C8785C /* UINavigationController+Helpers.swift in Sources */, + FABB225A2602FC2C00C8785C /* UnifiedProloguePages.swift in Sources */, + FABB225B2602FC2C00C8785C /* CountryStatsRecordValue+CoreDataClass.swift in Sources */, + FABB225C2602FC2C00C8785C /* MenuHeaderViewController.m in Sources */, + C31852A229670F8100A78BE9 /* JetpackScanViewController+JetpackBannerViewController.swift in Sources */, + FABB225D2602FC2C00C8785C /* NotificationDetailsViewController.swift in Sources */, + C7BB60172863609C00748FD9 /* QRLoginInternetConnectionChecker.swift in Sources */, + C3234F5527EBBACA004ADB29 /* SiteIntentVertical.swift in Sources */, + FEF4DC5628439357003806BE /* ReminderScheduleCoordinator.swift in Sources */, + FABB225F2602FC2C00C8785C /* MediaURLExporter.swift in Sources */, + FABB22602602FC2C00C8785C /* WordPress.xcdatamodeld in Sources */, + 011896A629D5B72500D34BA9 /* DomainsDashboardCoordinator.swift in Sources */, + 93CDC72226CD342900C8A3A8 /* DestructiveAlertHelper.swift in Sources */, + FABB22612602FC2C00C8785C /* WPStyleGuide+AlertView.swift in Sources */, + DC76668426FD9AC9009254DD /* TimeZoneRow.swift in Sources */, + 8B8E50B727A4692000C89979 /* DashboardPostListErrorCell.swift in Sources */, + FABB22622602FC2C00C8785C /* ErrorStateViewConfiguration.swift in Sources */, + 46B1A16C26A774E500F058AE /* CollapsableHeaderView.swift in Sources */, + FABB22632602FC2C00C8785C /* UIFont+Weight.swift in Sources */, + FABB22642602FC2C00C8785C /* PostChart.swift in Sources */, + FE4DC5AA293A84F8008F322F /* WordPressExportRoute.swift in Sources */, + FABB22652602FC2C00C8785C /* PageCoordinator.swift in Sources */, + FABB22662602FC2C00C8785C /* Blog+QuickStart.swift in Sources */, + FABB22672602FC2C00C8785C /* CommentsViewController+Filters.swift in Sources */, + FABB22682602FC2C00C8785C /* AddressTableViewCell.swift in Sources */, + FABB22692602FC2C00C8785C /* URL+LinkNormalization.swift in Sources */, + 1762B6DD2845510400F270A5 /* StatsReferrersChartViewModel.swift in Sources */, + FEA1123D29944896008097B0 /* SelfHostedJetpackRemoteInstallViewModel.swift in Sources */, + FABB226A2602FC2C00C8785C /* ReaderTopicService+SiteInfo.swift in Sources */, + FABB226B2602FC2C00C8785C /* WordPressSupportSourceTag+Helpers.swift in Sources */, + FABB226C2602FC2C00C8785C /* ParentPageSettingsViewController.swift in Sources */, + FABB226D2602FC2C00C8785C /* Queue.swift in Sources */, + FABB226E2602FC2C00C8785C /* SwitchTableViewCell.swift in Sources */, + 3FBB2D2C27FB6CB200C57BBF /* SiteNameViewController.swift in Sources */, + 17171375265FAA8A00F3A022 /* BloggingRemindersNavigationController.swift in Sources */, + FABB226F2602FC2C00C8785C /* ReaderTopicToReaderSiteTopic37to38.swift in Sources */, + FABB22702602FC2C00C8785C /* WordPressComSyncService.swift in Sources */, + FABB22712602FC2C00C8785C /* AppRatingsUtility.swift in Sources */, + F41BDD792910AFCA00B7F2B0 /* MigrationFlowCoordinator.swift in Sources */, + FABB22722602FC2C00C8785C /* GutenbergBlockProcessor.swift in Sources */, + FABB22732602FC2C00C8785C /* SiteDesignStep.swift in Sources */, + FABB22742602FC2C00C8785C /* Media.m in Sources */, + FABB22752602FC2C00C8785C /* NetworkAware.swift in Sources */, + FABB22762602FC2C00C8785C /* LanguageViewController.swift in Sources */, + FABB22772602FC2C00C8785C /* InlineEditableNameValueCell.swift in Sources */, + FABB22782602FC2C00C8785C /* TodayStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB22792602FC2C00C8785C /* WPCrashLoggingProvider.swift in Sources */, + FA332AD529C1FC7A00182FBB /* MovedToJetpackViewModel.swift in Sources */, + FABB227A2602FC2C00C8785C /* StockPhotosMedia.swift in Sources */, + FABB227B2602FC2C00C8785C /* FancyAlertViewController+SavedPosts.swift in Sources */, + FABB227C2602FC2C00C8785C /* WPRichTextMediaAttachment.swift in Sources */, + FABB227D2602FC2C00C8785C /* TenorMediaObject.swift in Sources */, + FABB227E2602FC2C00C8785C /* NotificationReplyStore.swift in Sources */, + FABB227F2602FC2C00C8785C /* SettingsTextViewController.m in Sources */, + FABB22802602FC2C00C8785C /* PlanListViewController.swift in Sources */, + FABB22812602FC2C00C8785C /* GutenbergRequestAuthenticator.swift in Sources */, + FAE8EE9A273AC06F00A65307 /* QuickStartSettings.swift in Sources */, + 8BA125EC27D8F5E4008B779F /* UIView+PinSubviewPriority.swift in Sources */, + 3FB1929626C79EC6000F5AA3 /* Date+TimeStrings.swift in Sources */, + FABB22822602FC2C00C8785C /* WPTableViewActivityCell.m in Sources */, + FABB22842602FC2C00C8785C /* PeopleViewController.swift in Sources */, + FABB22852602FC2C00C8785C /* ConfettiView.swift in Sources */, + FABB22862602FC2C00C8785C /* RegisterDomainSuggestionsViewController.swift in Sources */, + FABB22872602FC2C00C8785C /* AbstractPost+MarkAsFailedAndDraftIfNeeded.swift in Sources */, + FABB22882602FC2C00C8785C /* UserSuggestion+CoreDataProperties.swift in Sources */, + C743535727BD7144008C2644 /* AnimatedGifAttachmentViewProvider.swift in Sources */, + FABB22892602FC2C00C8785C /* FollowersCountStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB228A2602FC2C00C8785C /* ReaderHeaderAction.swift in Sources */, + C72A4F8E26408C73009CA633 /* JetpackNoSitesErrorViewModel.swift in Sources */, + FABB228B2602FC2C00C8785C /* FancyAlerts+VerificationPrompt.swift in Sources */, + FABB228C2602FC2C00C8785C /* ReaderSiteStreamHeader.swift in Sources */, + FABB228D2602FC2C00C8785C /* VideoUploadProcessor.swift in Sources */, + FABB228E2602FC2C00C8785C /* PeopleCellViewModel.swift in Sources */, + FABB228F2602FC2C00C8785C /* TableViewOffsetCoordinator.swift in Sources */, + FABB22902602FC2C00C8785C /* RegisterDomainDetailsViewController.swift in Sources */, + 1770BD0E267A368100D5F8C0 /* BloggingRemindersPushPromptViewController.swift in Sources */, + C7BB601A2863AF9700748FD9 /* QRLoginProtocols.swift in Sources */, + FABB22912602FC2C00C8785C /* FilterProvider.swift in Sources */, + FABB22922602FC2C00C8785C /* DomainCreditEligibilityChecker.swift in Sources */, + FABB22932602FC2C00C8785C /* SiteSettingsViewController.m in Sources */, + 987E79CC261F8858000192B7 /* UserProfileSheetViewController.swift in Sources */, + FABB22942602FC2C00C8785C /* WPStyleGuide+Comments.swift in Sources */, + FABB22952602FC2C00C8785C /* WPMediaEditor.swift in Sources */, + FABB22962602FC2C00C8785C /* ApproveComment.swift in Sources */, + FABB22972602FC2C00C8785C /* MediaUploadOperation.swift in Sources */, + FABB22982602FC2C00C8785C /* PostingActivityCollectionViewCell.swift in Sources */, + FABB22992602FC2C00C8785C /* NibLoadable.swift in Sources */, + FAA4013527B52455009E1137 /* QuickActionButton.swift in Sources */, + FABB229A2602FC2C00C8785C /* BackupListViewController.swift in Sources */, + FABB229B2602FC2C00C8785C /* ReaderReportPostAction.swift in Sources */, + 8B55F9B82614D819007D618E /* UnifiedPrologueEditorContentView.swift in Sources */, + 80D9CFFB29E5E6FE00FE3400 /* DashboardCardTableView.swift in Sources */, + 837B49DA283C2AE80061A657 /* BloggingPromptSettings+CoreDataProperties.swift in Sources */, + FABB229C2602FC2C00C8785C /* BaseRestoreStatusFailedViewController.swift in Sources */, + FABB229D2602FC2C00C8785C /* ImgUploadProcessor.swift in Sources */, + FABB229E2602FC2C00C8785C /* RegisterDomainDetailsViewModel.swift in Sources */, + FABB229F2602FC2C00C8785C /* JetpackScanHistoryViewController.swift in Sources */, + FABB22A02602FC2C00C8785C /* GutenbergStockPhotos.swift in Sources */, + FABB22A12602FC2C00C8785C /* MediaHost+Blog.swift in Sources */, + FABB22A22602FC2C00C8785C /* PlanListRow.swift in Sources */, + FABB22A32602FC2C00C8785C /* SiteCreationWizard.swift in Sources */, + FA4B203029A619130089FE68 /* BlazeFlowCoordinator.swift in Sources */, + FABB22A42602FC2C00C8785C /* TopCommentsAuthorStatsRecordValue+CoreDataProperties.swift in Sources */, + C7AFF875283C0ADC000E01DF /* UIApplication+Helpers.swift in Sources */, + FABB22A52602FC2C00C8785C /* SignupEpilogueViewController.swift in Sources */, + FABB22A62602FC2C00C8785C /* TenorPicker.swift in Sources */, + FAB985C22697550C00B172A3 /* NoResultsViewController+StatsModule.swift in Sources */, + FABB22A72602FC2C00C8785C /* BlogListViewController+Activity.swift in Sources */, + 3F810A5B2616870C00ADDCC2 /* UnifiedPrologueIntroContentView.swift in Sources */, + FABB22A82602FC2C00C8785C /* TableDataCoordinator.swift in Sources */, + FABB22A92602FC2C00C8785C /* SettingsMultiTextViewController.m in Sources */, + FABB22AA2602FC2C00C8785C /* FilterSheetViewController.swift in Sources */, + FABB22AB2602FC2C00C8785C /* Plan.swift in Sources */, + FABB22AC2602FC2C00C8785C /* DiscussionSettingsViewController.swift in Sources */, + FABB22AD2602FC2C00C8785C /* GutenbergSuggestionsViewController.swift in Sources */, + FABB22AE2602FC2C00C8785C /* TrashComment.swift in Sources */, + FABB22AF2602FC2C00C8785C /* MenusService.m in Sources */, + FABB22B12602FC2C00C8785C /* ReaderDetailFeaturedImageView.swift in Sources */, + FEA7948E26DD136700CEC520 /* CommentHeaderTableViewCell.swift in Sources */, + FABB22B22602FC2C00C8785C /* StatsDataHelper.swift in Sources */, + FABB22B32602FC2C00C8785C /* PrepublishingViewController.swift in Sources */, + FABB22B42602FC2C00C8785C /* PageListTableViewHandler.swift in Sources */, + FABB22B52602FC2C00C8785C /* SearchableItemConvertable.swift in Sources */, + 4A9948E5297624EF006282A9 /* Blog+Creation.swift in Sources */, + 8BBBCE712717651200B277AC /* JetpackModuleHelper.swift in Sources */, + FABB22B62602FC2C00C8785C /* WPAndDeviceMediaLibraryDataSource.m in Sources */, + 011896A329D5AF0700D34BA9 /* BlogDashboardCardConfigurable.swift in Sources */, + FABB22B72602FC2C00C8785C /* SettingsListEditorViewController.swift in Sources */, + 80C523A82995D73C00B1C14B /* BlazeWebViewModel.swift in Sources */, + 982DDF97263238A6002B3904 /* LikeUserPreferredBlog+CoreDataProperties.swift in Sources */, + FABB22B82602FC2C00C8785C /* QuickStartChecklistManager.swift in Sources */, + FABB22B92602FC2C00C8785C /* NoResultsTenorConfiguration.swift in Sources */, + C3234F4F27EB96AB004ADB29 /* IntentCell.swift in Sources */, + 98AA6D1226B8CE7200920C8B /* Comment+CoreDataClass.swift in Sources */, + FABB22BA2602FC2C00C8785C /* JetpackRemoteInstallViewController.swift in Sources */, + FABB22BB2602FC2C00C8785C /* PlanDetailViewController.swift in Sources */, + 837966A0299D51EC004A92B9 /* JetpackPlugin.swift in Sources */, + FABB22BC2602FC2C00C8785C /* Array+Page.swift in Sources */, + FABB22BD2602FC2C00C8785C /* WPTableViewHandler.m in Sources */, + FABB22BE2602FC2C00C8785C /* JetpackScanThreatViewModel.swift in Sources */, + FABB22BF2602FC2C00C8785C /* PublicizeConnectionStatsRecordValue+CoreDataClass.swift in Sources */, + FABB22C12602FC2C00C8785C /* DomainsService.swift in Sources */, + 98BAA7C426F925F70073A2F9 /* InlineEditableSingleLineCell.swift in Sources */, + FABB22C22602FC2C00C8785C /* PasswordAlertController.swift in Sources */, + 4A1E77CD2989F2F7006281CC /* WPAccount+DeduplicateBlogs.swift in Sources */, + FA98B61D29A3DB840071AAE8 /* BlazeHelper.swift in Sources */, + FABB22C32602FC2C00C8785C /* ReaderTagsFooter.swift in Sources */, + 98DCF4A6275945E00008630F /* ReaderDetailNoCommentCell.swift in Sources */, + FABB22C42602FC2C00C8785C /* ReaderPostCellActions.swift in Sources */, + FABB22C52602FC2C00C8785C /* ActivityStore.swift in Sources */, + FABB22C62602FC2C00C8785C /* NSAttributedString+WPRichText.swift in Sources */, + 4AA33EF929963ABE005B6E23 /* ReaderAbstractTopic+Lookup.swift in Sources */, + FABB22C72602FC2C00C8785C /* StatsTotalRow.swift in Sources */, + 836498C928172C5900A2C170 /* WPStyleGuide+BloggingPrompts.swift in Sources */, + C79C308226EA99D500E88514 /* ReferrerDetailsSpamActionCell.swift in Sources */, + 80EF672327F160720063B138 /* DashboardCustomAnnouncementCell.swift in Sources */, + FABB22C82602FC2C00C8785C /* SiteCreationAnalyticsHelper.swift in Sources */, + FABB22C92602FC2C00C8785C /* NSAttributedString+Helpers.swift in Sources */, + FABB22CA2602FC2C00C8785C /* TenorService.swift in Sources */, + FABB22CB2602FC2C00C8785C /* FilterBarView.swift in Sources */, + 98B88453261E4E09007ED7F8 /* LikeUserTableViewCell.swift in Sources */, + FABB22CC2602FC2C00C8785C /* BlogToBlogMigration_61_62.swift in Sources */, + FABB22CD2602FC2C00C8785C /* WebKitViewController.swift in Sources */, + FABB22CE2602FC2C00C8785C /* AnnouncementsDataSource.swift in Sources */, + FABB22CF2602FC2C00C8785C /* AssembledSiteView.swift in Sources */, + FABB22D02602FC2C00C8785C /* ReaderTopicService+FollowedInterests.swift in Sources */, + FABB22D12602FC2C00C8785C /* ReaderRecommendedSiteCardCell.swift in Sources */, + FABB22D22602FC2C00C8785C /* PostEditorState.swift in Sources */, + FABB22D32602FC2C00C8785C /* UICollectionViewCell+Tint.swift in Sources */, + FABB22D42602FC2C00C8785C /* ManagedPerson+CoreDataProperties.swift in Sources */, + FE32F003275F602E0040BE67 /* CommentContentRenderer.swift in Sources */, + 3FAE0653287C8FC500F46508 /* JPScrollViewDelegate.swift in Sources */, + FABB22D52602FC2C00C8785C /* TenorStrings.swift in Sources */, + FAFC064F27D2360B002F0483 /* QuickStartCell.swift in Sources */, + 086F2482284F52DD00032F39 /* TooltipAnchor.swift in Sources */, + FABB22D62602FC2C00C8785C /* PostEditor+MoreOptions.swift in Sources */, + FABB22D72602FC2C00C8785C /* NotificationSupportService.swift in Sources */, + FABB22D82602FC2C00C8785C /* MenusSelectionView.m in Sources */, + FABB22D92602FC2C00C8785C /* UITextView+RichTextView.swift in Sources */, + FABB22DA2602FC2C00C8785C /* RoundedButton.swift in Sources */, + FABB22DC2602FC2C00C8785C /* MenuLocation.m in Sources */, + FED77259298BC5B300C2346E /* PluginJetpackProxyService.swift in Sources */, + 3FD9CB7E28998ADB00CF76DE /* JetpackOverlayView.swift in Sources */, + FABB22DD2602FC2C00C8785C /* RevisionsTableViewCell.swift in Sources */, + E6D6A1312683ABE6004C24A7 /* ReaderSubscribeCommentsAction.swift in Sources */, + F49B9A06293A21BF000CEFCE /* MigrationAnalyticsTracker.swift in Sources */, + E62CE58F26B1D14200C9D147 /* AccountService+Cookies.swift in Sources */, + FABB22DE2602FC2C00C8785C /* KeyboardInfo.swift in Sources */, + FABB22DF2602FC2C00C8785C /* PlanDetailViewModel.swift in Sources */, + FABB22E02602FC2C00C8785C /* DebugMenuViewController.swift in Sources */, + 9801E683274EEBC4002FDDB6 /* ReaderDetailCommentsHeader.swift in Sources */, + FABB22E12602FC2C00C8785C /* MenuItemCheckButtonView.m in Sources */, + FABB22E22602FC2C00C8785C /* MenuItemLinkViewController.m in Sources */, + FABB22E32602FC2C00C8785C /* PreviewWebKitViewController.swift in Sources */, + FABB22E42602FC2C00C8785C /* PeriodChart.swift in Sources */, + 98BC522B27F6259700D6E8C2 /* BloggingPromptsFeatureDescriptionView.swift in Sources */, + 3F2ABE1C277118C9005D8916 /* VideoLimitsAlertPresenter.swift in Sources */, + C7F7BE0726262B9A00CE547F /* AuthenticationHandler.swift in Sources */, + FABB22E52602FC2C00C8785C /* MediaHost.swift in Sources */, + 0CB4057629C8DDE5008EED0A /* BlogDashboardPersonalizationView.swift in Sources */, + FABB22E62602FC2C00C8785C /* MenuItemAbstractPostsViewController.m in Sources */, + FABB22E72602FC2C00C8785C /* AtomicAuthenticationService.swift in Sources */, + FABB22E82602FC2C00C8785C /* WPStyleGuide+Posts.swift in Sources */, + F49B99FF2937C9B4000CEFCE /* MigrationEmailService.swift in Sources */, + FABB22E92602FC2C00C8785C /* GutenbergVideoUploadProcessor.swift in Sources */, + FABB22EA2602FC2C00C8785C /* PostCategory.m in Sources */, + 3F685B6A26D431FA001C6808 /* DomainSuggestionViewControllerWrapper.swift in Sources */, + FABB22EB2602FC2C00C8785C /* MediaAssetExporter.swift in Sources */, + FABB22EC2602FC2C00C8785C /* SharingButtonsViewController.swift in Sources */, + F4F9D5F2290993D400502576 /* MigrationWelcomeViewModel.swift in Sources */, + FABB22ED2602FC2C00C8785C /* EditorFactory.swift in Sources */, + FABB22EE2602FC2C00C8785C /* NotificationSiteSubscriptionViewController.swift in Sources */, + FABB22EF2602FC2C00C8785C /* PostingActivityDay.swift in Sources */, + C7A09A4B28401B7B003096ED /* QRLoginService.swift in Sources */, + FABB22F02602FC2C00C8785C /* ReaderTeamTopic.swift in Sources */, + FABB22F12602FC2C00C8785C /* WPAccount+AccountSettings.swift in Sources */, + FABB22F22602FC2C00C8785C /* Route.swift in Sources */, + FABB22F32602FC2C00C8785C /* RegisterDomainDetailsViewController+Cells.swift in Sources */, + 4A526BE0296BE9A50007B5BA /* CoreDataService.m in Sources */, + 3F46EEC728BC4935004F02B2 /* JetpackPrompt.swift in Sources */, + 175CC1712720548700622FB4 /* DomainExpiryDateFormatter.swift in Sources */, + FABB22F42602FC2C00C8785C /* ReaderStreamViewController+Sharing.swift in Sources */, + FABB22F52602FC2C00C8785C /* FilterTabBar.swift in Sources */, + FABB22F62602FC2C00C8785C /* ClicksStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB22F72602FC2C00C8785C /* EditComment.swift in Sources */, + FABB22F82602FC2C00C8785C /* UnknownEditorViewController.swift in Sources */, + FABB22F92602FC2C00C8785C /* AccountToAccount22to23.swift in Sources */, + FABB22FA2602FC2C00C8785C /* VisitsSummaryStatsRecordValue+CoreDataClass.swift in Sources */, + FABB22FB2602FC2C00C8785C /* ReaderFollowAction.swift in Sources */, + FABB22FC2602FC2C00C8785C /* SheetActions.swift in Sources */, + FABB22FD2602FC2C00C8785C /* ReaderTracker.swift in Sources */, + FABB22FE2602FC2C00C8785C /* PlanFeature.swift in Sources */, + 17C1D7DD26735631006C8970 /* EmojiRenderer.swift in Sources */, + FABB22FF2602FC2C00C8785C /* Spotlightable.swift in Sources */, + FABB23002602FC2C00C8785C /* StockPhotosDataSource.swift in Sources */, + F4BECD1C288EE5220078391A /* SuggestionsViewModelType.swift in Sources */, + C3A1166929807E3F00B0CB6E /* ReaderBlockUserAction.swift in Sources */, + FABB23022602FC2C00C8785C /* OfflineReaderWebView.swift in Sources */, + FABB23032602FC2C00C8785C /* CountriesMapCell.swift in Sources */, + FABB23042602FC2C00C8785C /* NavigationTitleView.swift in Sources */, + 3FE3D1FE26A6F4AC00F3CD10 /* ListTableHeaderView.swift in Sources */, + 8B15D27528009EBF0076628A /* BlogDashboardAnalytics.swift in Sources */, + FABB23052602FC2C00C8785C /* WPAnalyticsEvent.swift in Sources */, + FABB23062602FC2C00C8785C /* Coordinate.m in Sources */, + F1D8C6EC26BABE3E002E3323 /* WeeklyRoundupDebugScreen.swift in Sources */, + FABB23072602FC2C00C8785C /* SiteCreationRotatingMessageView.swift in Sources */, + FABB23082602FC2C00C8785C /* SignupUsernameViewController.swift in Sources */, + FABB23092602FC2C00C8785C /* MediaItemViewController.swift in Sources */, + FABB230A2602FC2C00C8785C /* PostAnnotation.m in Sources */, + FABB230B2602FC2C00C8785C /* GutenbergViewController.swift in Sources */, + FABB230C2602FC2C00C8785C /* EditPageViewController.swift in Sources */, + FABB230D2602FC2C00C8785C /* SiteCreator.swift in Sources */, + 3FFDEF8329179CD000B625CE /* MigrationDependencyContainer.swift in Sources */, + 3F5B9B45288B0761001D17E9 /* DashboardBadgeCell.swift in Sources */, + FABB230E2602FC2C00C8785C /* CalendarMonthView.swift in Sources */, + FABB230F2602FC2C00C8785C /* RestoreStatusFailedView.swift in Sources */, + FEFA263F26C5AE9A009CCB7E /* ShareAppContentPresenter.swift in Sources */, + FABB23112602FC2C00C8785C /* PostingActivityLegend.swift in Sources */, + 8B92D69727CD51FA001F5371 /* DashboardGhostCardCell.swift in Sources */, + FABB23122602FC2C00C8785C /* WPImmuTableRows.swift in Sources */, + 800035BE291DD0D7007D2D26 /* JetpackFullscreenOverlayGeneralViewModel+Analytics.swift in Sources */, + 08CBC77A29AE6CC4000026E7 /* SiteCreationEmptySiteTemplate.swift in Sources */, + FABB23132602FC2C00C8785C /* WordPress-87-88.xcmappingmodel in Sources */, + FABB23142602FC2C00C8785C /* UsersService.swift in Sources */, + 80D9D04729F765C900FE3400 /* FailableDecodable.swift in Sources */, + FABB23152602FC2C00C8785C /* SharePost+UIActivityItemSource.swift in Sources */, + FABB23162602FC2C00C8785C /* WPWebViewController.m in Sources */, + FABB23172602FC2C00C8785C /* ShadowView.swift in Sources */, + FABB23182602FC2C00C8785C /* Wizard.swift in Sources */, + 98E14A3D27C9712D007B0896 /* NotificationCommentDetailViewController.swift in Sources */, + FABB23192602FC2C00C8785C /* Media+WPMediaAsset.m in Sources */, + FABB231A2602FC2C00C8785C /* GutenbergImgUploadProcessor.swift in Sources */, + FABB231B2602FC2C00C8785C /* PluginListViewModel.swift in Sources */, + FABB231C2602FC2C00C8785C /* WPStyleGuide+People.swift in Sources */, + FA98B61729A3B76A0071AAE8 /* DashboardBlazeCardCell.swift in Sources */, + FABB231D2602FC2C00C8785C /* PlanGroup.swift in Sources */, + 8320BDE6283D9359009DF2DE /* BlogService+BloggingPrompts.swift in Sources */, + FABB231E2602FC2C00C8785C /* MenuItemPostsViewController.m in Sources */, + FABB231F2602FC2C00C8785C /* SignupUsernameTableViewController.swift in Sources */, + FABB23202602FC2C00C8785C /* Blog+Editor.swift in Sources */, + FABB23212602FC2C00C8785C /* ReaderCardService.swift in Sources */, + FABB23222602FC2C00C8785C /* UITableViewCell+Stats.swift in Sources */, + FABB23232602FC2C00C8785C /* Delay.swift in Sources */, + FABB23242602FC2C00C8785C /* Pageable.swift in Sources */, + FABB23252602FC2C00C8785C /* AppIconViewController.swift in Sources */, + FABB23262602FC2C00C8785C /* ThemeBrowserCell.swift in Sources */, + FA8E2FE627C6AE4500DA0982 /* QuickStartChecklistView.swift in Sources */, + FABB23272602FC2C00C8785C /* ImageCropViewController.swift in Sources */, + FABB23282602FC2C00C8785C /* LoggingURLRedactor.swift in Sources */, + FABB23292602FC2C00C8785C /* SiteStatsTableViewCells.swift in Sources */, + FABB232A2602FC2C00C8785C /* SharingButton.swift in Sources */, + FABB232B2602FC2C00C8785C /* PostActionSheet.swift in Sources */, + FABB232C2602FC2C00C8785C /* PublicizeConnection.swift in Sources */, + FABB232D2602FC2C00C8785C /* TenorPageable.swift in Sources */, + FABB232E2602FC2C00C8785C /* AccountService+MergeDuplicates.swift in Sources */, + FABB232F2602FC2C00C8785C /* URL+Helpers.swift in Sources */, + FABB23302602FC2C00C8785C /* WPStyleGuide+Aztec.swift in Sources */, + FABB23312602FC2C00C8785C /* SettingsCommon.swift in Sources */, + 17C1D6922670E4A2006C8970 /* UIFont+Fitting.swift in Sources */, + FABB23322602FC2C00C8785C /* FormatBarItemProviders.swift in Sources */, + 082A645C291C2DD700668D2C /* Routes+Jetpack.swift in Sources */, + FABB23332602FC2C00C8785C /* RevisionDiffViewController.swift in Sources */, + FABB23342602FC2C00C8785C /* BlogDetailsSectionFooterView.swift in Sources */, + 3F435221289B2B5100CE19ED /* JetpackOverlayViewController.swift in Sources */, + FABB23362602FC2C00C8785C /* TenorGIFCollection.swift in Sources */, + FAC1B82829B1F1EE00E0C542 /* BlazePostPreviewView.swift in Sources */, + 3FFDEF7829177D7500B625CE /* MigrationNotificationsViewModel.swift in Sources */, + FABB23372602FC2C00C8785C /* WebNavigationDelegate.swift in Sources */, + FAFC065227D27241002F0483 /* BlogDetailsViewController+Dashboard.swift in Sources */, + 8B55FAAD2614FC87007D618E /* Text+BoldSubString.swift in Sources */, + FABB23382602FC2C00C8785C /* WordPress-22-23.xcmappingmodel in Sources */, + FED65D79293511E4008071BF /* SharedDataIssueSolver.swift in Sources */, + FABB23392602FC2C00C8785C /* CountriesMap.swift in Sources */, + 98BC522527F6245700D6E8C2 /* BloggingPromptsFeatureIntroduction.swift in Sources */, + FABB233A2602FC2C00C8785C /* RevisionsNavigationController.swift in Sources */, + FE3D058426C419C7002A51B0 /* ShareAppContentPresenter+TableView.swift in Sources */, + FABB233B2602FC2C00C8785C /* AnnualAndMostPopularTimeStatsRecordValue+CoreDataClass.swift in Sources */, + FABB233C2602FC2C00C8785C /* MediaProgressCoordinatorNoticeViewModel.swift in Sources */, + 46F584832624DCC80010A723 /* BlockEditorSettingsService.swift in Sources */, + FABB233D2602FC2C00C8785C /* SiteStatsPeriodViewModel.swift in Sources */, + FABB233F2602FC2C00C8785C /* AnnouncementCell.swift in Sources */, + F4DDE2C329C92F0D00C02A76 /* CrashLogging+Singleton.swift in Sources */, + FABB23402602FC2C00C8785C /* ReaderStreamViewController.swift in Sources */, + 98ED5964265EBD0000A0B33E /* ReaderDetailLikesListController.swift in Sources */, + FABB23412602FC2C00C8785C /* SeparatorsView.swift in Sources */, + FABB23422602FC2C00C8785C /* JetpackScanCoordinator.swift in Sources */, + FABB23432602FC2C00C8785C /* WPStyleGuide+ReadableMargins.m in Sources */, + FABB23442602FC2C00C8785C /* FormattableContentStyles.swift in Sources */, + FABB23452602FC2C00C8785C /* StatsBarChartConfiguration.swift in Sources */, + FABB23462602FC2C00C8785C /* LoadingStatusView.swift in Sources */, + FABB23472602FC2C00C8785C /* MenuDetailsViewController.m in Sources */, + FABB23482602FC2C00C8785C /* InlineErrorRetryTableViewCell.swift in Sources */, + FABB23492602FC2C00C8785C /* MenuItemSourceCell.m in Sources */, + FABB234A2602FC2C00C8785C /* BlogDetailsViewController.m in Sources */, + FABB234B2602FC2C00C8785C /* UIBarButtonItem+MeBarButton.swift in Sources */, + 80D9D00129E85EBF00FE3400 /* PageEditorPresenter.swift in Sources */, + FABB234C2602FC2C00C8785C /* PostSettingsViewController+FeaturedImageUpload.swift in Sources */, + F158542E267D3B8A00A2E966 /* BloggingRemindersFlowSettingsViewController.swift in Sources */, + 8332DD2529259AE300802F7D /* DataMigrator.swift in Sources */, + FABB234D2602FC2C00C8785C /* ReaderLikeAction.swift in Sources */, + DCF892CD282FA3BB00BB71E1 /* SiteStatsImmuTableRows.swift in Sources */, + FABB234E2602FC2C00C8785C /* ReaderMenuAction.swift in Sources */, + FABB234F2602FC2C00C8785C /* PostSharingController.swift in Sources */, + FABB23502602FC2C00C8785C /* LongPressGestureLabel.swift in Sources */, + FABB23512602FC2C00C8785C /* NoResultsStockPhotosConfiguration.swift in Sources */, + FABB23522602FC2C00C8785C /* String+Files.swift in Sources */, + FABB23532602FC2C00C8785C /* UIImage+Export.swift in Sources */, + FABB23542602FC2C00C8785C /* NotificationName+Names.swift in Sources */, + FABB23552602FC2C00C8785C /* NSFetchedResultsController+Helpers.swift in Sources */, + FABB23562602FC2C00C8785C /* BlogSettings+Discussion.swift in Sources */, + FABB23572602FC2C00C8785C /* PostTagPickerViewController.swift in Sources */, + FABB23582602FC2C00C8785C /* SignupEpilogueCell.swift in Sources */, + FABB23592602FC2C00C8785C /* NoteBlockActionsTableViewCell.swift in Sources */, + FABB235A2602FC2C00C8785C /* ReaderCardsStreamViewController.swift in Sources */, + FABB235B2602FC2C00C8785C /* DeleteSiteViewController.swift in Sources */, + FABB235C2602FC2C00C8785C /* WPAnalyticsTrackerWPCom.m in Sources */, + C3C2F84928AC8EBF00937E45 /* JetpackBannerScrollVisibility.swift in Sources */, + FABB235D2602FC2C00C8785C /* AztecAttachmentViewController.swift in Sources */, + FABB235F2602FC2C00C8785C /* LoginEpilogueBlogCell.swift in Sources */, + FABB23602602FC2C00C8785C /* PostServiceRemoteFactory.swift in Sources */, + FABB23612602FC2C00C8785C /* ReaderTabItemsStore.swift in Sources */, + FABB23622602FC2C00C8785C /* NoResultsViewController+Model.swift in Sources */, + 3F435220289B2B2B00CE19ED /* JetpackBrandingCoordinator.swift in Sources */, + FABB23632602FC2C00C8785C /* PostListTableViewHandler.swift in Sources */, + FABB23642602FC2C00C8785C /* UIAlertController+Helpers.swift in Sources */, + 3F4D035128A56F9B00F0A4FD /* CircularImageButton.swift in Sources */, + FABB23652602FC2C00C8785C /* RevisionDiff+CoreData.swift in Sources */, + C79C307F26EA970800E88514 /* ReferrerDetailsSpamActionRow.swift in Sources */, + 800035C329230A0B007D2D26 /* ExtensionConfiguration.swift in Sources */, + FABB23662602FC2C00C8785C /* NotificationCommentRange.swift in Sources */, + FABB23672602FC2C00C8785C /* NotificationAction.swift in Sources */, + 3F39C93627A09927001EC300 /* WordPressLibraryLogger.swift in Sources */, + FABB23682602FC2C00C8785C /* NSManagedObject.swift in Sources */, + FABB23692602FC2C00C8785C /* ExportableAsset.swift in Sources */, + FABB236A2602FC2C00C8785C /* PlanListViewModel.swift in Sources */, + FABB236B2602FC2C00C8785C /* InsightsManagementViewController.swift in Sources */, + FABB236C2602FC2C00C8785C /* StatSection.swift in Sources */, + FABB236D2602FC2C00C8785C /* ReaderDetailCoordinator.swift in Sources */, + 9887560D2810BA7A00AD7589 /* BloggingPromptsIntroductionPresenter.swift in Sources */, + FABB236E2602FC2C00C8785C /* AccountService.m in Sources */, + FABB236F2602FC2C00C8785C /* MediaLibraryPickerDataSource.m in Sources */, + FABB23702602FC2C00C8785C /* PostStatsViewModel.swift in Sources */, + FABB23712602FC2C00C8785C /* StatsStore+Cache.swift in Sources */, + FABB23722602FC2C00C8785C /* WP3DTouchShortcutHandler.swift in Sources */, + 80A2154129CA68D5002FE8EB /* RemoteFeatureFlag.swift in Sources */, + FABB23732602FC2C00C8785C /* BadgeContentStyles.swift in Sources */, + FABB23742602FC2C00C8785C /* WPStyleGuide+Pages.m in Sources */, + FABB23752602FC2C00C8785C /* ReaderInterestsDataSource.swift in Sources */, + FABB23762602FC2C00C8785C /* ActivityContentGroup.swift in Sources */, + C9F1D4B82706ED7C00BDF917 /* EditHomepageViewController.swift in Sources */, + FABB23772602FC2C00C8785C /* MediaImageExporter.swift in Sources */, + FABB23782602FC2C00C8785C /* GutenbergViewController+InformativeDialog.swift in Sources */, + 3FFDEF8A2918597700B625CE /* MigrationDoneViewController.swift in Sources */, + 8B55F9DC2614D902007D618E /* CircledIcon.swift in Sources */, + FABB23792602FC2C00C8785C /* ChangePasswordViewController.swift in Sources */, + 3FE3D1FD26A6F34900F3CD10 /* WPStyleGuide+List.swift in Sources */, + 175CC17D2723103000622FB4 /* WPAnalytics+Domains.swift in Sources */, + FABB237A2602FC2C00C8785C /* LikeComment.swift in Sources */, + FABB237B2602FC2C00C8785C /* Page.swift in Sources */, + FABB237C2602FC2C00C8785C /* ReaderInterestsCoordinator.swift in Sources */, + FABB237D2602FC2C00C8785C /* ReaderSavedPostCellActions.swift in Sources */, + C79C308126EA975900E88514 /* ReferrerDetailsHeaderCell.swift in Sources */, + 8BF281FD27CEB69C00AF8CF3 /* DashboardFailureCardCell.swift in Sources */, + FABB237E2602FC2C00C8785C /* FormattableContentFormatter.swift in Sources */, + FABB237F2602FC2C00C8785C /* SearchAdsAttribution.swift in Sources */, + FABB23802602FC2C00C8785C /* ReaderCommentsViewController.m in Sources */, + FABB23812602FC2C00C8785C /* MySiteViewController.swift in Sources */, + 46F584B92624E6380010A723 /* BlockEditorSettings+GutenbergEditorSettings.swift in Sources */, + F4D829622930E9F300038726 /* MigrationDeleteWordPressViewController.swift in Sources */, + 4AA33F05299A1F93005B6E23 /* ReaderTagTopic+Lookup.swift in Sources */, + FABB23822602FC2C00C8785C /* FooterContentGroup.swift in Sources */, + FABB23832602FC2C00C8785C /* BasePageListCell.m in Sources */, + F163541726DE2ECE008B625B /* NotificationEventTracker.swift in Sources */, + 0A9610FA28B2E56300076EBA /* UserSuggestion+Comparable.swift in Sources */, + 80C740FC2989FC4600199027 /* PostStatsTableViewController+JetpackBannerViewController.swift in Sources */, + FABB23852602FC2C00C8785C /* WPError.m in Sources */, + FABB23862602FC2C00C8785C /* ContentRouter.swift in Sources */, + FABB23872602FC2C00C8785C /* BlogToBlog32to33.swift in Sources */, + FABB23882602FC2C00C8785C /* WPStyleGuide+ApplicationStyles.swift in Sources */, + FABB23892602FC2C00C8785C /* FormattableUserContent.swift in Sources */, + FABB238A2602FC2C00C8785C /* RegisterDomainDetailsViewController+HeaderFooter.swift in Sources */, + FABB238B2602FC2C00C8785C /* ReaderSpacerView.swift in Sources */, + 3FFDEF852918215700B625CE /* MigrationStepView.swift in Sources */, + FABB238D2602FC2C00C8785C /* WPStyleGuide+SiteCreation.swift in Sources */, + F41E32FF287B47A500F89082 /* SuggestionsListViewModel.swift in Sources */, + F4EDAA5129A795C600622D3D /* BlockedAuthor.swift in Sources */, + FABB238E2602FC2C00C8785C /* NotificationSettingsService.swift in Sources */, + FABB238F2602FC2C00C8785C /* WPButtonForNavigationBar.m in Sources */, + 8091019429078CFE00FCB4EA /* JetpackFullscreenOverlayViewController.swift in Sources */, + FA347AEE26EB6E300096604B /* GrowAudienceCell.swift in Sources */, + FABB23922602FC2C00C8785C /* UIImage+Assets.swift in Sources */, + FABB23932602FC2C00C8785C /* LoadMoreCounter.swift in Sources */, + FABB23942602FC2C00C8785C /* LoginEpilogueUserInfo.swift in Sources */, + 086F2483284F52DF00032F39 /* Tooltip.swift in Sources */, + FABB23952602FC2C00C8785C /* WPAvatarSource.m in Sources */, + 8313B9FB2995A03C000AF26E /* JetpackRemoteInstallCardView.swift in Sources */, + FABB23962602FC2C00C8785C /* StatsTableFooter.swift in Sources */, + FABB23972602FC2C00C8785C /* BlogSyncFacade.m in Sources */, + FABB23982602FC2C00C8785C /* PrepublishingNavigationController.swift in Sources */, + 8384C64228AAC82600EABE26 /* KeychainUtils.swift in Sources */, + FABB23992602FC2C00C8785C /* UINavigationController+KeyboardFix.m in Sources */, + FABB239B2602FC2C00C8785C /* ExpandableCell.swift in Sources */, + FABB239C2602FC2C00C8785C /* PreviewNonceHandler.swift in Sources */, + FABB239D2602FC2C00C8785C /* JetpackSpeedUpSiteSettingsViewController.swift in Sources */, + FABB239E2602FC2C00C8785C /* TopCommentedPostStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB239F2602FC2C00C8785C /* FormattableContentFactory.swift in Sources */, + FABB23A02602FC2C00C8785C /* PluginDirectoryViewController.swift in Sources */, + FABB23A12602FC2C00C8785C /* RoleService.swift in Sources */, + FABB23A22602FC2C00C8785C /* AccountHelper.swift in Sources */, + FABB23A32602FC2C00C8785C /* Sites.intentdefinition in Sources */, + FABB23A42602FC2C00C8785C /* MenuItemSourceTextBar.m in Sources */, + FABB23A52602FC2C00C8785C /* PluginDirectoryViewModel.swift in Sources */, + F4F9D5EA2909622E00502576 /* MigrationWelcomeViewController.swift in Sources */, + FABB23A62602FC2C00C8785C /* AbstractPost+Autosave.swift in Sources */, + FABB23A72602FC2C00C8785C /* ReaderPost+Searchable.swift in Sources */, + FABB23A82602FC2C00C8785C /* MeViewController.swift in Sources */, + FABB23A92602FC2C00C8785C /* BlogService+BlogAuthors.swift in Sources */, + C324D7AC28C2F73F00310DEF /* SplashPrologueViewController.swift in Sources */, + FABB23AA2602FC2C00C8785C /* AuthorFilterButton.swift in Sources */, + FABB23AB2602FC2C00C8785C /* MenuItemTagsViewController.m in Sources */, + 0CB4057A29C8DDEE008EED0A /* BlogDashboardPersonalizationViewModel.swift in Sources */, + FABB23AC2602FC2C00C8785C /* ReachabilityUtils.m in Sources */, + FABB23AD2602FC2C00C8785C /* TenorDataSource.swift in Sources */, + FABB23AE2602FC2C00C8785C /* ReaderTopicService+Subscriptions.swift in Sources */, + 98517E5A28220411001FFD45 /* BloggingPromptTableViewCell.swift in Sources */, + FAC1B81F29B0C2AC00E0C542 /* BlazeOverlayViewModel.swift in Sources */, + FABB23AF2602FC2C00C8785C /* TopCommentedPostStatsRecordValue+CoreDataClass.swift in Sources */, + FABB23B02602FC2C00C8785C /* Data.swift in Sources */, + FABB23B12602FC2C00C8785C /* AccountSettingsViewController.swift in Sources */, + FABB23B22602FC2C00C8785C /* StatsChildRowsView.swift in Sources */, + FABB23B32602FC2C00C8785C /* Menu.m in Sources */, + FABB23B42602FC2C00C8785C /* BlogListViewController.m in Sources */, + FABB23B52602FC2C00C8785C /* JetpackScanStatusCell.swift in Sources */, + FABB23B62602FC2C00C8785C /* NoteBlockCommentTableViewCell.swift in Sources */, + 801D951B291AC0B00051993E /* OverlayFrequencyTracker.swift in Sources */, + C7F7ACBE261E4F0600CE547F /* JetpackErrorViewModel.swift in Sources */, + FABB23B72602FC2C00C8785C /* Notifiable.swift in Sources */, + 80EF672627F3D63B0063B138 /* DashboardStatsStackView.swift in Sources */, + FABB23B82602FC2C00C8785C /* RegisterDomainDetailsViewModel+CountryDialCodes.swift in Sources */, + FABB23B92602FC2C00C8785C /* BlogListViewController+SiteCreation.swift in Sources */, + FABB23BA2602FC2C00C8785C /* CommentAnalytics.swift in Sources */, + FABB23BB2602FC2C00C8785C /* TableViewHeaderDetailView.swift in Sources */, + 3F46EECE28BC4962004F02B2 /* JetpackLandingScreenView.swift in Sources */, + FABB23BC2602FC2C00C8785C /* MenuItemAbstractView.m in Sources */, + FABB23BD2602FC2C00C8785C /* LocationService.m in Sources */, + 808C579027C7FB1A0099A92C /* ButtonScrollView.swift in Sources */, + FABB23BE2602FC2C00C8785C /* Domain.swift in Sources */, + FABB23BF2602FC2C00C8785C /* PostListViewController.swift in Sources */, + FABB23C02602FC2C00C8785C /* FeatureItemRow.swift in Sources */, + FABB23C12602FC2C00C8785C /* ReaderTagsTableViewController+Cells.swift in Sources */, + FABB23C22602FC2C00C8785C /* PrepublishingHeaderView.swift in Sources */, + 3FB1929126C6C56E000F5AA3 /* TimeSelectionButton.swift in Sources */, + FABB23C32602FC2C00C8785C /* StatsWidgetsStore.swift in Sources */, + 175507B427A062980038ED28 /* PublicizeConnectionURLMatcher.swift in Sources */, + FABB23C42602FC2C00C8785C /* MenuItemsVisualOrderingView.m in Sources */, + FABB23C52602FC2C00C8785C /* JetpackScanViewController.swift in Sources */, + FABB23C62602FC2C00C8785C /* ReaderSubscribingNotificationAction.swift in Sources */, + FABB23C72602FC2C00C8785C /* GutenbergMediaInserterHelper.swift in Sources */, + FABB23C82602FC2C00C8785C /* TopViewedAuthorStatsRecordValue+CoreDataClass.swift in Sources */, + FABB23C92602FC2C00C8785C /* JetpackState.swift in Sources */, + FABB23CA2602FC2C00C8785C /* EditPostViewController.swift in Sources */, + 0141929D2983F0A300CAEDB0 /* SupportConfiguration.swift in Sources */, + FABB23CB2602FC2C00C8785C /* PostListFilter.swift in Sources */, + FABB23CC2602FC2C00C8785C /* MediaThumbnailExporter.swift in Sources */, + C79C307C26EA915100E88514 /* ReferrerDetailsTableViewController.swift in Sources */, + FABB23CD2602FC2C00C8785C /* ReferrerStatsRecordValue+CoreDataClass.swift in Sources */, + FABB23CE2602FC2C00C8785C /* BlogDetailHeaderView.swift in Sources */, + FABB23CF2602FC2C00C8785C /* ImageDownloader.swift in Sources */, + FABB23D02602FC2C00C8785C /* ReaderDetailHeaderView.swift in Sources */, + FABB23D12602FC2C00C8785C /* WPAnimatedBox.m in Sources */, + FABB23D22602FC2C00C8785C /* PostServiceOptions.m in Sources */, + FABB23D32602FC2C00C8785C /* DiffTitleValue.swift in Sources */, + FABB23D42602FC2C00C8785C /* ReaderCommentsViewController.swift in Sources */, + FABB23D52602FC2C00C8785C /* SiteCreationRequest+Validation.swift in Sources */, + FABB23D62602FC2C00C8785C /* FormattableContentGroup.swift in Sources */, + FABB23D72602FC2C00C8785C /* MenuItemSourceViewController.m in Sources */, + FABB23D82602FC2C00C8785C /* WPReusableTableViewCells.swift in Sources */, + FABB23D92602FC2C00C8785C /* HomepageSettingsService.swift in Sources */, + FABB23DA2602FC2C00C8785C /* ReaderSearchSuggestionsViewController.swift in Sources */, + FABB23DB2602FC2C00C8785C /* SentryStartupEvent.swift in Sources */, + FABB23DC2602FC2C00C8785C /* JetpackScanThreatCell.swift in Sources */, + FABB23DD2602FC2C00C8785C /* SiteSegmentsCell.swift in Sources */, + FABB23DE2602FC2C00C8785C /* SharingConnectionsViewController.m in Sources */, + 803BB98A295B80D300B3F6D6 /* RootViewPresenter+EditorNavigation.swift in Sources */, + FABB23DF2602FC2C00C8785C /* BlogSelectorViewController.m in Sources */, + FABB23E02602FC2C00C8785C /* HomepageSettingsViewController.swift in Sources */, + 93E6336D272AF504009DACF8 /* LoginEpilogueDividerView.swift in Sources */, + FABB23E12602FC2C00C8785C /* TenorMediaGroup.swift in Sources */, + FABB23E22602FC2C00C8785C /* SiteSuggestionService.swift in Sources */, + FABB23E32602FC2C00C8785C /* NotificationsViewController+PushPrimer.swift in Sources */, + FABB23E42602FC2C00C8785C /* ReaderPostService.m in Sources */, + FABB23E52602FC2C00C8785C /* EditorMediaUtility.swift in Sources */, + FABB23E62602FC2C00C8785C /* ShareMediaFileManager.swift in Sources */, + FABB23E82602FC2C00C8785C /* ReaderCoordinator.swift in Sources */, + FABB23E92602FC2C00C8785C /* RestoreCompleteView.swift in Sources */, + FABB23EA2602FC2C00C8785C /* NoteBlockImageTableViewCell.swift in Sources */, + FABB23EB2602FC2C00C8785C /* PostCategoryService.m in Sources */, + FABB23EC2602FC2C00C8785C /* RegisterDomainDetailsServiceProxy.swift in Sources */, + FABB23ED2602FC2C00C8785C /* PreviewDeviceSelectionViewController.swift in Sources */, + FABB23EE2602FC2C00C8785C /* MenusSelectionItem.m in Sources */, + FABB23EF2602FC2C00C8785C /* WPTextAttachment.swift in Sources */, + FABB23F02602FC2C00C8785C /* TitleSubtitleTextfieldHeader.swift in Sources */, + FABB23F12602FC2C00C8785C /* DefaultStockPhotosService.swift in Sources */, + FABB23F22602FC2C00C8785C /* Animator.swift in Sources */, + 03216ECD27995F3500D444CA /* SchedulingViewControllerPresenter.swift in Sources */, + FABB23F32602FC2C00C8785C /* SiteStatsDashboardViewController.swift in Sources */, + 9895401226C1F39300EDEB5A /* EditCommentTableViewController.swift in Sources */, + FABB23F42602FC2C00C8785C /* MediaSettings.swift in Sources */, + FABB23F52602FC2C00C8785C /* DomainCreditRedemptionSuccessViewController.swift in Sources */, + FABB23F62602FC2C00C8785C /* ActivityLogDetailViewController.m in Sources */, + FABB23F72602FC2C00C8785C /* NSObject+Helpers.m in Sources */, + FABB23F82602FC2C00C8785C /* ImmuTableViewController.swift in Sources */, + FABB23F92602FC2C00C8785C /* NullStockPhotosService.swift in Sources */, + FABB23FA2602FC2C00C8785C /* RevisionPreviewViewController.swift in Sources */, + 3F2ABE1D277118CC005D8916 /* WPMediaAsset+VideoLimits.swift in Sources */, + 80EF928B280D28140064A971 /* Atomic.swift in Sources */, + 3FFDEF9129187F2100B625CE /* MigrationActionsConfiguration.swift in Sources */, + FABB23FC2602FC2C00C8785C /* SitePromptView.swift in Sources */, + 8B4EDADE27DF9D5E004073B6 /* Blog+MySite.swift in Sources */, + FABB23FD2602FC2C00C8785C /* BaseRestoreCompleteViewController.swift in Sources */, + FAA4012E27B405DB009E1137 /* DashboardQuickActionsCardCell.swift in Sources */, + FABB23FE2602FC2C00C8785C /* SharingService.swift in Sources */, + FABB24002602FC2C00C8785C /* BottomSheetPresentationController.swift in Sources */, + FABB24012602FC2C00C8785C /* LikesListController.swift in Sources */, + FABB24022602FC2C00C8785C /* AccountSettingsService.swift in Sources */, + FABB24032602FC2C00C8785C /* ReaderCSS.swift in Sources */, + FABB24042602FC2C00C8785C /* SafariActivity.m in Sources */, + FABB24052602FC2C00C8785C /* WordPress-37-38.xcmappingmodel in Sources */, + FABB24062602FC2C00C8785C /* UploadOperation.swift in Sources */, + FABB24072602FC2C00C8785C /* LayoutPickerAnalyticsEvent.swift in Sources */, + FABB24082602FC2C00C8785C /* ActivityRange.swift in Sources */, + FABB24092602FC2C00C8785C /* DiffAbstractValue+Attributes.swift in Sources */, + FABB240A2602FC2C00C8785C /* Environment.swift in Sources */, + FABB240B2602FC2C00C8785C /* AbstractPostListViewController.swift in Sources */, + FABB240C2602FC2C00C8785C /* ManagedAccountSettings.swift in Sources */, + FA88EAD6260AE69C001D232B /* TemplatePreviewViewController.swift in Sources */, + F41BDD7B29114E2400B7F2B0 /* MigrationStep.swift in Sources */, + FABB240E2602FC2C00C8785C /* Blog+Plans.swift in Sources */, + FABB240F2602FC2C00C8785C /* PostType.m in Sources */, + F41BDD73290BBDCA00B7F2B0 /* MigrationActionsView.swift in Sources */, + FABB24102602FC2C00C8785C /* ThemeService.m in Sources */, + FABB24112602FC2C00C8785C /* NSAttributedString+RichTextView.swift in Sources */, + FABB24122602FC2C00C8785C /* NoteBlockButtonTableViewCell.swift in Sources */, + 084FC3BD299155CA00A17BCF /* JetpackOverlayCoordinator.swift in Sources */, + C7F7ABD6261CED7A00CE547F /* JetpackAuthenticationManager.swift in Sources */, + FABB24142602FC2C00C8785C /* ThemeBrowserSectionHeaderView.swift in Sources */, + 8B065CC727BD5452005BA7AB /* DashboardEmptyPostsCardCell.swift in Sources */, + FABB24152602FC2C00C8785C /* SiteIconPickerPresenter.swift in Sources */, + C79C307D26EA919F00E88514 /* ReferrerDetailsViewModel.swift in Sources */, + 8BBC778C27B5531700DBA087 /* BlogDashboardPersistence.swift in Sources */, + FABB24172602FC2C00C8785C /* BlogSettings.swift in Sources */, + FABB24182602FC2C00C8785C /* WKWebView+UserAgent.swift in Sources */, + FABB24192602FC2C00C8785C /* JetpackCapabilitiesService.swift in Sources */, + FABB241A2602FC2C00C8785C /* PostToPost30To31.m in Sources */, + FABB241B2602FC2C00C8785C /* GutenGhostView.swift in Sources */, + FABB241C2602FC2C00C8785C /* ModelSettableCell.swift in Sources */, + FABB241D2602FC2C00C8785C /* FollowCommentsService.swift in Sources */, + 4AA33F022999D11A005B6E23 /* ReaderSiteTopic+Lookup.swift in Sources */, + FABB241E2602FC2C00C8785C /* ForcePopoverPresenter.swift in Sources */, + FABB241F2602FC2C00C8785C /* LikePost.swift in Sources */, + FABB24202602FC2C00C8785C /* Routes+Post.swift in Sources */, + FABB24212602FC2C00C8785C /* Blog+Jetpack.swift in Sources */, + FABB24222602FC2C00C8785C /* Uploader.swift in Sources */, + FABB24232602FC2C00C8785C /* JetpackRestoreService.swift in Sources */, + FABB24242602FC2C00C8785C /* DateAndTimeFormatSettingsViewController.swift in Sources */, + FABB24252602FC2C00C8785C /* WPContentSyncHelper.swift in Sources */, + 830A58D92793AB4500CDE94F /* LoginEpilogueAnimator.swift in Sources */, + FABB24262602FC2C00C8785C /* SiteAssemblyStep.swift in Sources */, + FABB24272602FC2C00C8785C /* SiteSettingsViewController+Swift.swift in Sources */, + 3FFDEF8F29187F1200B625CE /* MigrationHeaderConfiguration.swift in Sources */, + FABB24282602FC2C00C8785C /* FilterTableData.swift in Sources */, + FABB24292602FC2C00C8785C /* ReaderGapMarker.m in Sources */, + FABB242A2602FC2C00C8785C /* Pattern.swift in Sources */, + FABB242B2602FC2C00C8785C /* ShareExtensionService.swift in Sources */, + F4F9D5F42909B7C100502576 /* MigrationWelcomeBlogTableViewCell.swift in Sources */, + FABB242C2602FC2C00C8785C /* KanvasCameraCustomUI.swift in Sources */, + FABB242D2602FC2C00C8785C /* MediaPreviewHelper.swift in Sources */, + F1C197A72670DDB100DE1FF7 /* BloggingRemindersTracker.swift in Sources */, + FABB242E2602FC2C00C8785C /* OffsetTableViewHandler.swift in Sources */, + FABB242F2602FC2C00C8785C /* UINavigationController+SplitViewFullscreen.swift in Sources */, + 803BB99029667BAF00B3F6D6 /* JetpackBrandingTextProvider.swift in Sources */, + FABB24302602FC2C00C8785C /* ReaderReblogPresenter.swift in Sources */, + FABB24312602FC2C00C8785C /* BodyContentGroup.swift in Sources */, + FABB24322602FC2C00C8785C /* WPStyleGuide+Search.swift in Sources */, + FABB24332602FC2C00C8785C /* WindowManager.swift in Sources */, + FABB24342602FC2C00C8785C /* NSFileManager+FolderSize.swift in Sources */, + FABB24352602FC2C00C8785C /* ErrorStateViewController.swift in Sources */, + FABB24362602FC2C00C8785C /* WP3DTouchShortcutCreator.swift in Sources */, + FABB24372602FC2C00C8785C /* GIFPlaybackStrategy.swift in Sources */, + FE003F5F282D61BA006F8D1D /* BloggingPrompt+CoreDataProperties.swift in Sources */, + FABB24382602FC2C00C8785C /* ReaderCommentAction.swift in Sources */, + FABB24392602FC2C00C8785C /* JetpackBackupStatusFailedViewController.swift in Sources */, + FABB243A2602FC2C00C8785C /* CircularImageView.swift in Sources */, + F4E79301296EEE320025E8E0 /* MigrationState.swift in Sources */, + 803DE81728FFAEF2007D4E9C /* RemoteConfigParameter.swift in Sources */, + FABB243B2602FC2C00C8785C /* GravatarButtonView.swift in Sources */, + B0CD27D0286F8858009500BF /* JetpackBannerView.swift in Sources */, + AE2F3126270B6DA000B2A9C2 /* Scanner+QuotedText.swift in Sources */, + C7AFF878283C2623000E01DF /* QRLoginScanningCoordinator.swift in Sources */, + F4D829642930EA4C00038726 /* MigrationDeleteWordPressViewModel.swift in Sources */, + FABB243C2602FC2C00C8785C /* StockPhotosService.swift in Sources */, + 086F2481284F52DB00032F39 /* TooltipPresenter.swift in Sources */, + 08EA036A29C9C39A00B72A87 /* Color+DesignSystem.swift in Sources */, + FABB243D2602FC2C00C8785C /* Blog+Analytics.swift in Sources */, + 08A250F928D9E87600F50420 /* CommentDetailInfoViewController.swift in Sources */, + FABB243E2602FC2C00C8785C /* ReaderStreamViewController+Helper.swift in Sources */, + FABB243F2602FC2C00C8785C /* ReaderAbstractTopic.swift in Sources */, + FABB24402602FC2C00C8785C /* PostService+UnattachedMedia.swift in Sources */, + 173B215627875E0600D4DD6B /* DeviceMediaPermissionsHeader.swift in Sources */, + FABB24412602FC2C00C8785C /* ReaderDefaultTopic.swift in Sources */, + FABB24422602FC2C00C8785C /* ReaderCard+CoreDataClass.swift in Sources */, + FABB24432602FC2C00C8785C /* ReplyToComment.swift in Sources */, + FABB24442602FC2C00C8785C /* TextWithAccessoryButtonCell.swift in Sources */, + FABB24452602FC2C00C8785C /* TenorResponse.swift in Sources */, + FABB24462602FC2C00C8785C /* BaseRestoreStatusViewController.swift in Sources */, + FABB24472602FC2C00C8785C /* SignupEpilogueTableViewController.swift in Sources */, + FABB24482602FC2C00C8785C /* MyProfileViewController.swift in Sources */, + FAD7626529F1480E00C09583 /* DashboardActivityLogCardCell+ActivityPresenter.swift in Sources */, + 3FE3D1FF26A6F56700F3CD10 /* Comment+Interface.swift in Sources */, + FABB24492602FC2C00C8785C /* CreateButtonActionSheet.swift in Sources */, + FABB244A2602FC2C00C8785C /* ReaderStreamViewController+Ghost.swift in Sources */, + 174C11942624C78900346EC6 /* Routes+Start.swift in Sources */, + FABB244B2602FC2C00C8785C /* Constants.m in Sources */, + FAB9826F2697038700B172A3 /* StatsViewController+JetpackSettings.swift in Sources */, + FABB244C2602FC2C00C8785C /* MurielColor.swift in Sources */, + FABB244D2602FC2C00C8785C /* RegisterDomainDetailsErrorSectionFooter.swift in Sources */, + AE2F3129270B6DE200B2A9C2 /* NSMutableAttributedString+ApplyAttributesToQuotes.swift in Sources */, + FABB244E2602FC2C00C8785C /* ImageLoader.swift in Sources */, + FABB244F2602FC2C00C8785C /* SiteAssemblyContentView.swift in Sources */, + FABB24502602FC2C00C8785C /* FeaturedImageViewController.m in Sources */, + FABB24512602FC2C00C8785C /* NotificationCenter+ObserveMultiple.swift in Sources */, + FABB24522602FC2C00C8785C /* ReaderCellConfiguration.swift in Sources */, + FABB24532602FC2C00C8785C /* GutenbergGalleryUploadProcessor.swift in Sources */, + FABB24542602FC2C00C8785C /* StoriesIntroViewController.swift in Sources */, + FABB24552602FC2C00C8785C /* WPStyleGuide+Suggestions.m in Sources */, + FABB24562602FC2C00C8785C /* SiteStatsInsightsViewModel.swift in Sources */, + FABB24572602FC2C00C8785C /* CheckmarkTableViewCell.swift in Sources */, + FABB24582602FC2C00C8785C /* ReaderManageScenePresenter.swift in Sources */, + FABB24592602FC2C00C8785C /* MediaService.swift in Sources */, + 839B150C2795DEE0009F5E77 /* UIView+Margins.swift in Sources */, + FABB245A2602FC2C00C8785C /* TopViewedPostStatsRecordValue+CoreDataClass.swift in Sources */, + FABB245B2602FC2C00C8785C /* PostMetaButton.m in Sources */, + FABB245C2602FC2C00C8785C /* PluginStore+Persistence.swift in Sources */, + FABB245D2602FC2C00C8785C /* ResultsPage.swift in Sources */, + FABB245E2602FC2C00C8785C /* FormattableRangesFactory.swift in Sources */, + FABB245F2602FC2C00C8785C /* Menu+ViewDesign.m in Sources */, + FABB24602602FC2C00C8785C /* BlogDetailsViewController+DomainCredit.swift in Sources */, + 17C1D67D2670E3DC006C8970 /* SiteIconPickerView.swift in Sources */, + 8BAC9D9F27BAB97E008EA44C /* BlogDashboardRemoteEntity.swift in Sources */, + 80EF9287280D272E0064A971 /* DashboardPostsSyncManager.swift in Sources */, + FABB24612602FC2C00C8785C /* KeyringAccountHelper.swift in Sources */, + FABB24622602FC2C00C8785C /* ManagedAccountSettings+CoreDataProperties.swift in Sources */, + FABB24632602FC2C00C8785C /* PushAuthenticationService.swift in Sources */, + FABB24642602FC2C00C8785C /* AlertView.swift in Sources */, + FE003F5D282D61BA006F8D1D /* BloggingPrompt+CoreDataClass.swift in Sources */, + FABB24652602FC2C00C8785C /* WPTabBarController.m in Sources */, + 8379669A299C048E004A92B9 /* DashboardJetpackInstallCardCell.swift in Sources */, + FABB24662602FC2C00C8785C /* UIAlertControllerProxy.m in Sources */, + FABB24672602FC2C00C8785C /* StatsBarChartView.swift in Sources */, + FABB24682602FC2C00C8785C /* URLQueryItem+Parameters.swift in Sources */, + FABB24692602FC2C00C8785C /* MenuItemView.m in Sources */, + FA4B203C29AE62C00089FE68 /* BlazeOverlayViewController.swift in Sources */, + C3895DDD28C65435004E7C9B /* SplashPrologueView.swift in Sources */, + FABB246A2602FC2C00C8785C /* WordPressAuthenticationManager.swift in Sources */, + FABB246B2602FC2C00C8785C /* SiteManagementService.swift in Sources */, + FABB246C2602FC2C00C8785C /* ActivityPostRange.swift in Sources */, + 3FF717FF291F07AB00323614 /* MigrationCenterViewConfiguration.swift in Sources */, + FABB246D2602FC2C00C8785C /* ReaderSelectInterestsViewController.swift in Sources */, + FABB246E2602FC2C00C8785C /* ReaderShowMenuAction.swift in Sources */, + 3FFDEF882918596B00B625CE /* MigrationDoneViewModel.swift in Sources */, + 01DBFD8829BDCBF200F3720F /* JetpackNativeConnectionService.swift in Sources */, + 08A73440298AB68000F925C7 /* JetpackPluginOverlayViewModel.swift in Sources */, + FABB246F2602FC2C00C8785C /* NSURLCache+Helpers.swift in Sources */, + FABB24702602FC2C00C8785C /* String+Extensions.swift in Sources */, + FABB24712602FC2C00C8785C /* CollapsableHeaderFilterBar.swift in Sources */, + FABB24722602FC2C00C8785C /* AutomatedTransferHelper.swift in Sources */, + FABB24732602FC2C00C8785C /* PostTagService.m in Sources */, + DC772AF4282009BA00664C02 /* StatsLineChartConfiguration.swift in Sources */, + FABB24742602FC2C00C8785C /* ReaderTabViewModel.swift in Sources */, + FADFBD27265F580500039C41 /* MultilineButton.swift in Sources */, + C71AF534281064DE00F9E99E /* OnboardingQuestionsCoordinator.swift in Sources */, + FABB24752602FC2C00C8785C /* ReaderSearchSuggestionService.swift in Sources */, + FABB24762602FC2C00C8785C /* RootViewCoordinator+WhatIsNew.swift in Sources */, + FABB24772602FC2C00C8785C /* CachedAnimatedImageView.swift in Sources */, + FABB24782602FC2C00C8785C /* AccountToAccount20to21.swift in Sources */, + FABB24792602FC2C00C8785C /* AutoUploadMessageProvider.swift in Sources */, + FABB247A2602FC2C00C8785C /* MenuItemEditingViewController.m in Sources */, + FABB247B2602FC2C00C8785C /* TopTotalsCell.swift in Sources */, + 837B49DC283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataClass.swift in Sources */, + FABB247C2602FC2C00C8785C /* RegisterDomainDetailsViewController+LocalizedStrings.swift in Sources */, + FABB247D2602FC2C00C8785C /* GutenbergLayoutPickerViewController.swift in Sources */, + 011896A929D5BBB400D34BA9 /* DomainsDashboardFactory.swift in Sources */, + FABB247E2602FC2C00C8785C /* SiteInformation.swift in Sources */, + FABB247F2602FC2C00C8785C /* StockPhotosPageable.swift in Sources */, + FABB24802602FC2C00C8785C /* JetpackRestoreStatusViewController.swift in Sources */, + FABB24812602FC2C00C8785C /* BindableTapGestureRecognizer.swift in Sources */, + FABB24822602FC2C00C8785C /* ReaderSearchSuggestion.swift in Sources */, + DCF892CA282FA37100BB71E1 /* SiteStatsBaseTableViewController.swift in Sources */, + FABB24832602FC2C00C8785C /* ReaderSearchViewController.swift in Sources */, + FABB24842602FC2C00C8785C /* SiteStatsTableHeaderView.swift in Sources */, + FABB24852602FC2C00C8785C /* MediaSizeSliderCell.swift in Sources */, + 80B016D22803AB9F00D15566 /* DashboardPostsListCardCell.swift in Sources */, + FABB24862602FC2C00C8785C /* NotificationContentRouter.swift in Sources */, + 178DDD31266D7576006C68C4 /* BloggingRemindersFlowCompletionViewController.swift in Sources */, + FABB24872602FC2C00C8785C /* PageLayoutService.swift in Sources */, + FABB24882602FC2C00C8785C /* Route+Page.swift in Sources */, + FABB24892602FC2C00C8785C /* PlayIconView.swift in Sources */, + FABB248A2602FC2C00C8785C /* WPTabBarController+Swift.swift in Sources */, + 805CC0C2296CF3B3002941DC /* JetpackNewUsersOverlaySecondaryView.swift in Sources */, + FABB248B2602FC2C00C8785C /* LanguageSelectorViewController.swift in Sources */, + FABB248C2602FC2C00C8785C /* PluginListViewController.swift in Sources */, + FABB248D2602FC2C00C8785C /* PostCoordinator.swift in Sources */, + FABB248E2602FC2C00C8785C /* AztecAttachmentDelegate.swift in Sources */, + FABB248F2602FC2C00C8785C /* Memoize.swift in Sources */, + 803DE81428FFAE36007D4E9C /* RemoteConfigStore.swift in Sources */, + FA98A2512833F1DC003B9233 /* QuickStartChecklistConfigurable.swift in Sources */, + 931F312D2714302A0075433B /* PublicizeServicesState.swift in Sources */, + FABB24902602FC2C00C8785C /* StatsPeriodAsyncOperation.swift in Sources */, + FABB24912602FC2C00C8785C /* NotificationsViewController.swift in Sources */, + FABB24922602FC2C00C8785C /* PingHubManager.swift in Sources */, + FABB24932602FC2C00C8785C /* MediaVideoExporter.swift in Sources */, + FABB24942602FC2C00C8785C /* PluginViewController.swift in Sources */, + 179A70F12729834B006DAC0A /* Binding+OnChange.swift in Sources */, + 17F11EDC268623BA00D1BBA7 /* BloggingRemindersScheduleFormatter.swift in Sources */, + FABB24952602FC2C00C8785C /* ReaderSiteTopic.swift in Sources */, + FABB24962602FC2C00C8785C /* JetpackBackupCompleteViewController.swift in Sources */, + FABB24972602FC2C00C8785C /* JetpackLoginViewController.swift in Sources */, + DC772AF2282009BA00664C02 /* InsightsLineChart.swift in Sources */, + 176CE91727FB44C100F1E32B /* StatsBaseCell.swift in Sources */, + 934098C12719577D00B3E77E /* InsightType.swift in Sources */, + FABB24992602FC2C00C8785C /* StreakStatsRecordValue+CoreDataProperties.swift in Sources */, + 46F583AA2624CE790010A723 /* BlockEditorSettings+CoreDataClass.swift in Sources */, + FABB249A2602FC2C00C8785C /* Header+WordPress.swift in Sources */, + FABB249B2602FC2C00C8785C /* StoryMediaLoader.swift in Sources */, + FABB249C2602FC2C00C8785C /* EditorSettingsService.swift in Sources */, + FABB249D2602FC2C00C8785C /* JetpackScanHistoryCoordinator.swift in Sources */, + FABB249E2602FC2C00C8785C /* NoticeStore.swift in Sources */, + 80A2154729D15B88002FE8EB /* RemoteConfigOverrideStore.swift in Sources */, + FABB249F2602FC2C00C8785C /* RevisionBrowserState.swift in Sources */, + 982DDF93263238A6002B3904 /* LikeUser+CoreDataProperties.swift in Sources */, + FAADE42626159AFE00BF29FE /* AppConstants.swift in Sources */, + C352870627FDD35C004E2E51 /* SiteNameStep.swift in Sources */, + FABB24A02602FC2C00C8785C /* KeyValueDatabase.swift in Sources */, + FABB24A12602FC2C00C8785C /* UITableViewCell+enableDisable.swift in Sources */, + FABB24A22602FC2C00C8785C /* WPAnalyticsTrackerAutomatticTracks.m in Sources */, + FABB24A32602FC2C00C8785C /* CollapsableHeaderCollectionViewCell.swift in Sources */, + C3643AD028AC049D00FC5FD3 /* SharingViewController.swift in Sources */, + FABB24A42602FC2C00C8785C /* NotificationContentFactory.swift in Sources */, + FABB24A52602FC2C00C8785C /* SiteIconView.swift in Sources */, + 3FF15A56291B4EEA00E1B4E5 /* MigrationCenterView.swift in Sources */, + FABB24A62602FC2C00C8785C /* JetpackRestoreCompleteViewController.swift in Sources */, + FABB24A72602FC2C00C8785C /* FormattableNoticonRange.swift in Sources */, + FABB24A82602FC2C00C8785C /* PromptViewController.swift in Sources */, + 46F583AC2624CE790010A723 /* BlockEditorSettings+CoreDataProperties.swift in Sources */, + FABB24A92602FC2C00C8785C /* SearchResultsStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB24AA2602FC2C00C8785C /* ActivityListRow.swift in Sources */, + FABB24AB2602FC2C00C8785C /* UIColor+MurielColorsObjC.swift in Sources */, + FABB24AC2602FC2C00C8785C /* InteractiveNotificationsManager.swift in Sources */, + FABB24AD2602FC2C00C8785C /* PageTemplateLayout+CoreDataProperties.swift in Sources */, + F195C42D26DFBE3A000EC884 /* WordPressBackgroundTaskEventHandler.swift in Sources */, + 3F3DD0B726FD18EB00F5F121 /* Blog+DomainsDashboardView.swift in Sources */, + FABB24AF2602FC2C00C8785C /* ReaderSeenAction.swift in Sources */, + FABB24B02602FC2C00C8785C /* SchedulingDate+Helpers.swift in Sources */, + 08E39B4628A3DEB200874CB8 /* UserPersistentStoreFactory.swift in Sources */, + FABB24B12602FC2C00C8785C /* QuickStartChecklistCell.swift in Sources */, + FABB24B22602FC2C00C8785C /* PostAutoUploadInteractor.swift in Sources */, + FABB24B32602FC2C00C8785C /* SiteStatsDetailsViewModel.swift in Sources */, + FABB24B42602FC2C00C8785C /* MediaHost+ReaderPostContentProvider.swift in Sources */, + FABB24B52602FC2C00C8785C /* RestoreStatusView.swift in Sources */, + FABB24B62602FC2C00C8785C /* CustomizeInsightsCell.swift in Sources */, + FABB24B72602FC2C00C8785C /* SVProgressHUD+Dismiss.m in Sources */, + F4D9188729D78C9100974A71 /* BlogDetailsViewController+Strings.swift in Sources */, + FABB24B82602FC2C00C8785C /* DynamicHeightCollectionView.swift in Sources */, + FABB24B92602FC2C00C8785C /* RegisterDomainDetailsViewModel+RowList.swift in Sources */, + FABB24BA2602FC2C00C8785C /* TenorGIF.swift in Sources */, + FABB24BB2602FC2C00C8785C /* ThemeBrowserViewController.swift in Sources */, + FA73D7ED27987E4500DF24B3 /* SitePickerViewController+QuickStart.swift in Sources */, + FABB24BC2602FC2C00C8785C /* RevisionOperationViewController.swift in Sources */, + 4A9B81E42921AE03007A05D1 /* ContextManager.swift in Sources */, + FABB24BD2602FC2C00C8785C /* WPBlogSelectorButton.m in Sources */, + FABB24BE2602FC2C00C8785C /* ReaderTabView.swift in Sources */, + F49B9A08293A21F4000CEFCE /* MigrationEvent.swift in Sources */, + FABB24BF2602FC2C00C8785C /* EpilogueUserInfoCell.swift in Sources */, + F4EDAA4D29A516EA00622D3D /* ReaderPostService.swift in Sources */, + FABB24C02602FC2C00C8785C /* MenuItemTypeSelectionView.m in Sources */, + FABB24C12602FC2C00C8785C /* WordPressAppDelegate+openURL.swift in Sources */, + F4CBE3DA29265BC8004FFBB6 /* LogOutActionHandler.swift in Sources */, + FABB24C22602FC2C00C8785C /* WPSplitViewController.swift in Sources */, + FABB24C32602FC2C00C8785C /* CollectionViewContainerRow.swift in Sources */, + FABB24C42602FC2C00C8785C /* JetpackConnectionWebViewController.swift in Sources */, + FABB24C52602FC2C00C8785C /* SiteSuggestion+CoreDataProperties.swift in Sources */, + F47E154B29E84A9300B6E426 /* DomainPurchasingWebFlowController.swift in Sources */, + FABB24C62602FC2C00C8785C /* UntouchableWindow.swift in Sources */, + FABB24C72602FC2C00C8785C /* WPProgressTableViewCell.m in Sources */, + FEA7949126DF7F3700CEC520 /* WPStyleGuide+CommentDetail.swift in Sources */, + 011896A029D30CAD00D34BA9 /* DomainsDashboardCardTracker.swift in Sources */, + FABB24C82602FC2C00C8785C /* StockPhotosPicker.swift in Sources */, + FABB24CA2602FC2C00C8785C /* UnifiedPrologueViewController.swift in Sources */, + FABB24CC2602FC2C00C8785C /* CollapsableHeaderViewController.swift in Sources */, + FABB24CD2602FC2C00C8785C /* Blog+Lookup.swift in Sources */, + FABB24CE2602FC2C00C8785C /* ReaderTopicService.m in Sources */, + 80F8DAC2282B6546007434A0 /* WPAnalytics+QuickStart.swift in Sources */, + FABB24CF2602FC2C00C8785C /* HeaderDetailsContentStyles.swift in Sources */, + FABB24D02602FC2C00C8785C /* RewindStatusRow.swift in Sources */, + 8BD34F0C27D14B3C005E931C /* Blog+DashboardState.swift in Sources */, + FABB24D12602FC2C00C8785C /* LinkBehavior.swift in Sources */, + FABB24D22602FC2C00C8785C /* ReaderHelpers.swift in Sources */, + FABB24D32602FC2C00C8785C /* SubjectContentStyles.swift in Sources */, + 4AD5657028E413160054C676 /* Blog+History.swift in Sources */, + FABB24D42602FC2C00C8785C /* AbstractPost.m in Sources */, + FABB24D52602FC2C00C8785C /* PostStatsTableViewController.swift in Sources */, + FABB24D72602FC2C00C8785C /* TenorResultsPage.swift in Sources */, + FABB24D82602FC2C00C8785C /* ThemeWebNavigationDelegate.swift in Sources */, + FABB24D92602FC2C00C8785C /* PluginListCell.swift in Sources */, + C373D6E828045281008F8C26 /* SiteIntentData.swift in Sources */, + FABB24DA2602FC2C00C8785C /* HomeWidgetAllTimeData.swift in Sources */, + DC3B9B2D27739760003F7249 /* TimeZoneSelectorViewModel.swift in Sources */, + FABB24DB2602FC2C00C8785C /* UIView+Borders.swift in Sources */, + FABB24DC2602FC2C00C8785C /* BasePost.m in Sources */, + FABB24DD2602FC2C00C8785C /* main.swift in Sources */, + FABB24DE2602FC2C00C8785C /* UIViewController+NoResults.swift in Sources */, + FABB24DF2602FC2C00C8785C /* TimeZoneStore.swift in Sources */, + FABB24E02602FC2C00C8785C /* UserSuggestion+CoreDataClass.swift in Sources */, + 069A4AA72664448F00413FA9 /* GutenbergFeaturedImageHelper.swift in Sources */, + 8313B9EF298B1ACD000AF26E /* SiteSettingsViewController+Blogging.swift in Sources */, + 982DA9A8263B1E2F00E5743B /* CommentService+Likes.swift in Sources */, + FABB24E12602FC2C00C8785C /* MediaLibraryPicker.swift in Sources */, + 3F435222289B2B5A00CE19ED /* JetpackOverlayView.swift in Sources */, + 46F583D52624D0BC0010A723 /* Blog+BlockEditorSettings.swift in Sources */, + FABB24E22602FC2C00C8785C /* SharingAccountViewController.swift in Sources */, + FABB24E32602FC2C00C8785C /* TodayExtensionService.m in Sources */, + FABB24E42602FC2C00C8785C /* UIApplication+mainWindow.swift in Sources */, + FABB24E52602FC2C00C8785C /* CoreDataIterativeMigrator.swift in Sources */, + 8B55F9CA2614D8BC007D618E /* RoundRectangleView.swift in Sources */, + FABB24E62602FC2C00C8785C /* ReaderSiteSearchViewController.swift in Sources */, + F4D82972293109A600038726 /* DashboardMigrationSuccessCell+Jetpack.swift in Sources */, + FABB24E72602FC2C00C8785C /* TopViewedVideoStatsRecordValue+CoreDataClass.swift in Sources */, + FABB24E82602FC2C00C8785C /* ReaderPost.m in Sources */, + FABB24E92602FC2C00C8785C /* MediaLibraryMediaPickingCoordinator.swift in Sources */, + CECEEB562823164800A28ADE /* MediaCacheSettingsViewController.swift in Sources */, + FABB24EA2602FC2C00C8785C /* PageTemplateLayout+CoreDataClass.swift in Sources */, + FABB24EB2602FC2C00C8785C /* NoResultsViewController.swift in Sources */, + FAD7625C29ED780B00C09583 /* JSONDecoderExtension.swift in Sources */, + FABB24EC2602FC2C00C8785C /* ActionDispatcherFacade.swift in Sources */, + FABB24ED2602FC2C00C8785C /* ReaderSavedPostUndoCell.swift in Sources */, + FABB24EE2602FC2C00C8785C /* Suggestion.swift in Sources */, + FABB24EF2602FC2C00C8785C /* ShareNoticeConstants.swift in Sources */, + FABB24F02602FC2C00C8785C /* FeatureFlagOverrideStore.swift in Sources */, + FABB24F12602FC2C00C8785C /* RelatedPostsPreviewTableViewCell.m in Sources */, + FABB24F22602FC2C00C8785C /* IntrinsicTableView.swift in Sources */, + FABB24F32602FC2C00C8785C /* HomeWidgetTodayData.swift in Sources */, + FABB24F42602FC2C00C8785C /* NoticePresenter.swift in Sources */, + FABB24F52602FC2C00C8785C /* ReaderTagTopic.swift in Sources */, + FABB24F62602FC2C00C8785C /* Media.swift in Sources */, + 9839CEBC26FAA0530097406E /* CommentModerationBar.swift in Sources */, + 837966A3299E9C85004A92B9 /* JetpackInstallPluginHelper.swift in Sources */, + 17039227282E6DF500F602E9 /* ViewsVisitorsChartMarker.swift in Sources */, + FABB24F72602FC2C00C8785C /* WordPressOrgRestApi+WordPress.swift in Sources */, + FABB24F82602FC2C00C8785C /* GravatarPickerViewController.swift in Sources */, + FABB24F92602FC2C00C8785C /* ReaderTableConfiguration.swift in Sources */, + FABB24FA2602FC2C00C8785C /* AnnouncementsStore.swift in Sources */, + FABB24FB2602FC2C00C8785C /* ReaderListStreamHeader.swift in Sources */, + FABB24FC2602FC2C00C8785C /* NoResultsViewController+MediaLibrary.swift in Sources */, + FA4B203929A8C48F0089FE68 /* AbstractPost+Blaze.swift in Sources */, + FABB24FD2602FC2C00C8785C /* StockPhotosDataLoader.swift in Sources */, + 010459E729153FFF000C7778 /* JetpackNotificationMigrationService.swift in Sources */, + FABB24FE2602FC2C00C8785C /* ReaderTopicToReaderTagTopic37to38.swift in Sources */, + FABB24FF2602FC2C00C8785C /* ChangeUsernameViewModel.swift in Sources */, + C31852A329673BFC00A78BE9 /* MenusViewController+JetpackBannerViewController.swift in Sources */, + FABB25002602FC2C00C8785C /* PlansLoadingIndicatorView.swift in Sources */, + FABB25012602FC2C00C8785C /* JetpackScanThreatDetailsViewController.swift in Sources */, + C72A4F7B26408943009CA633 /* JetpackNotWPErrorViewModel.swift in Sources */, + FABB25022602FC2C00C8785C /* NotificationTextContent.swift in Sources */, + FABB25032602FC2C00C8785C /* PostEditor+Publish.swift in Sources */, + FABB25042602FC2C00C8785C /* MediaCoordinator.swift in Sources */, + FABB25052602FC2C00C8785C /* StatsStackViewCell.swift in Sources */, + C79C307E26EA970200E88514 /* ReferrerDetailsHeaderRow.swift in Sources */, + 98AA9F2227EA890800B3A98C /* FeatureIntroductionViewController.swift in Sources */, + FAFC064C27D22E4C002F0483 /* QuickStartTourStateView.swift in Sources */, + C72A52CF2649B158009CA633 /* JetpackWindowManager.swift in Sources */, + FABB25062602FC2C00C8785C /* ReaderTagsTableViewModel.swift in Sources */, + 17ABD3532811A48900B1E9CB /* StatsMostPopularTimeInsightsCell.swift in Sources */, + C324D7AB28C2F73100310DEF /* SplashPrologueStyleGuide.swift in Sources */, + 3F8B45A7292C1A2300730FA4 /* MigrationSuccessCardView.swift in Sources */, + FABB25072602FC2C00C8785C /* SiteAssemblyService.swift in Sources */, + FABB25082602FC2C00C8785C /* ManagedPerson.swift in Sources */, + FABB25092602FC2C00C8785C /* ChosenValueRow.swift in Sources */, + FABB250A2602FC2C00C8785C /* ReplyTextView.swift in Sources */, + FABB250B2602FC2C00C8785C /* PublicizeConnectionStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB250C2602FC2C00C8785C /* MenuItemEditingFooterView.m in Sources */, + FABB250D2602FC2C00C8785C /* ReaderCrossPostCell.swift in Sources */, + 93E6336A272AC532009DACF8 /* LoginEpilogueChooseSiteTableViewCell.swift in Sources */, + C7234A4F2832C47D0045C63F /* QRLoginVerifyAuthorizationViewController.swift in Sources */, + FABB250E2602FC2C00C8785C /* Role.swift in Sources */, + FABB250F2602FC2C00C8785C /* SuggestionsTableView.swift in Sources */, + FABB25102602FC2C00C8785C /* MenuItemSourceResultsViewController.m in Sources */, + 803BB98D29637AFC00B3F6D6 /* BlurredEmptyViewController.swift in Sources */, + FABB25112602FC2C00C8785C /* UIColor+MurielColors.swift in Sources */, + FABB25122602FC2C00C8785C /* LayoutPreviewViewController.swift in Sources */, + FABB25132602FC2C00C8785C /* UIStackView+Subviews.swift in Sources */, + FABB25142602FC2C00C8785C /* RevisionsTableViewController.swift in Sources */, + FABB25152602FC2C00C8785C /* PeopleService.swift in Sources */, + FABB25162602FC2C00C8785C /* StatsPeriodStore.swift in Sources */, + FABB25172602FC2C00C8785C /* ImmuTable+WordPress.swift in Sources */, + FABB25182602FC2C00C8785C /* NoticeStyle.swift in Sources */, + FABB25192602FC2C00C8785C /* NotificationSettings.swift in Sources */, + FABB251A2602FC2C00C8785C /* HeaderContentStyles.swift in Sources */, + F4D36AD6298498E600E6B84C /* ReaderPostBlockingController.swift in Sources */, + 8071390827D039E70012DB21 /* DashboardSingleStatView.swift in Sources */, + FABB251B2602FC2C00C8785C /* Revision.swift in Sources */, + FABB251C2602FC2C00C8785C /* PluginDirectoryAccessoryItem.swift in Sources */, + FEDA1AD9269D475D0038EC98 /* ListTableViewCell+Comments.swift in Sources */, + FABB251D2602FC2C00C8785C /* PHAsset+Exporters.swift in Sources */, + FABB251E2602FC2C00C8785C /* GutenbergLightNavigationController.swift in Sources */, + FABB251F2602FC2C00C8785C /* AppFeedbackPromptView.swift in Sources */, + FABB25202602FC2C00C8785C /* UIView+ExistingConstraints.swift in Sources */, + 3FB1929226C6C575000F5AA3 /* TimeSelectionViewController.swift in Sources */, + FABB25212602FC2C00C8785C /* CountryStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB25222602FC2C00C8785C /* MediaRequestAuthenticator.swift in Sources */, + FABB25232602FC2C00C8785C /* StatsCellHeader.swift in Sources */, + DCCDF75C283BEFEA00AA347E /* SiteStatsInsightsDetailsTableViewController.swift in Sources */, + FABB25242602FC2C00C8785C /* UIEdgeInsets.swift in Sources */, + FABB25252602FC2C00C8785C /* RestorePageTableViewCell.m in Sources */, + C7234A3B2832BA240045C63F /* QRLoginCoordinator.swift in Sources */, + FABB25262602FC2C00C8785C /* String+RegEx.swift in Sources */, + 175CC17A27230DC900622FB4 /* Bool+StringRepresentation.swift in Sources */, + F1BC842F27035A1800C39993 /* BlogService+Domains.swift in Sources */, + FE7FAABF299A998F0032A6F2 /* EventTracker.swift in Sources */, + 8B33BC9627A0C14C00DB5985 /* BlogDetailsViewController+QuickActions.swift in Sources */, + FABB25272602FC2C00C8785C /* UIImage+Exporters.swift in Sources */, + FABB25282602FC2C00C8785C /* PostStatsTitleCell.swift in Sources */, + FABB25292602FC2C00C8785C /* CommentService.m in Sources */, + 0880BADD29ED6FF3002D3AB0 /* UIColor+DesignSystem.swift in Sources */, + FABB252A2602FC2C00C8785C /* MediaLibraryStrings.swift in Sources */, + FABB252B2602FC2C00C8785C /* StoreFetchingStatus.swift in Sources */, + FABB252C2602FC2C00C8785C /* SiteDateFormatters.swift in Sources */, + FABB252D2602FC2C00C8785C /* PostEditor.swift in Sources */, + FABB252E2602FC2C00C8785C /* ClicksStatsRecordValue+CoreDataClass.swift in Sources */, + FABB252F2602FC2C00C8785C /* JetpackRestoreWarningCoordinator.swift in Sources */, + FABB25302602FC2C00C8785C /* WPPickerView.m in Sources */, + FABB25312602FC2C00C8785C /* UIImageView+SiteIcon.swift in Sources */, + FABB25322602FC2C00C8785C /* ReaderSiteSearchService.swift in Sources */, + FABB25332602FC2C00C8785C /* QuickStartNavigationSettings.swift in Sources */, + FABB25342602FC2C00C8785C /* WPImageViewController.m in Sources */, + FABB25352602FC2C00C8785C /* ReaderListTopic.swift in Sources */, + FABB25362602FC2C00C8785C /* PublicizeService.swift in Sources */, + FABB25372602FC2C00C8785C /* ActivityContentFactory.swift in Sources */, + 1D19C56429C9D9A700FB0087 /* GutenbergVideoPressUploadProcessor.swift in Sources */, + FABB25382602FC2C00C8785C /* TenorDataLoader.swift in Sources */, + FABB25392602FC2C00C8785C /* OverviewCell.swift in Sources */, + F4D829662931046F00038726 /* UIButton+Dismiss.swift in Sources */, + FABB253A2602FC2C00C8785C /* WordPressAppDelegate.swift in Sources */, + 098B8577275E9765004D299F /* AppLocalizedString.swift in Sources */, + FABB253B2602FC2C00C8785C /* MediaService.m in Sources */, + C39ABBAF294BE84000F6F278 /* BackupListViewController+JetpackBannerViewController.swift in Sources */, + FEA6517C281C491C002EA086 /* BloggingPromptsService.swift in Sources */, + FABB253C2602FC2C00C8785C /* FormattableContentAction.swift in Sources */, + 3F3DD0B326FD176800F5F121 /* PresentationCard.swift in Sources */, + FABB253D2602FC2C00C8785C /* SiteVerticalsService.swift in Sources */, + FABB253E2602FC2C00C8785C /* ReaderBlockedSiteCell.swift in Sources */, + FE32F007275F62620040BE67 /* WebCommentContentRenderer.swift in Sources */, + FABB253F2602FC2C00C8785C /* JetpackRestoreWarningViewController.swift in Sources */, + FABB25402602FC2C00C8785C /* SiteAssembly.swift in Sources */, + FABB25412602FC2C00C8785C /* ImageDimensionFetcher.swift in Sources */, + 8BEE846527B1E05C0001A93C /* DashboardCardModel.swift in Sources */, + FABB25422602FC2C00C8785C /* AnimatedImageCache.swift in Sources */, + FABB25432602FC2C00C8785C /* PostListFilterSettings.swift in Sources */, + FABB25442602FC2C00C8785C /* FooterTextContent.swift in Sources */, + 4A072CD329093704006235BE /* AsyncBlockOperation.swift in Sources */, + FABB25452602FC2C00C8785C /* Blog+Interface.swift in Sources */, + FABB25462602FC2C00C8785C /* PostFeaturedImageCell.m in Sources */, + FABB25472602FC2C00C8785C /* WPException.m in Sources */, + FABB25482602FC2C00C8785C /* StatsGhostCells.swift in Sources */, + FABB25492602FC2C00C8785C /* NotificationCenter+ObserveOnce.swift in Sources */, + FABB254A2602FC2C00C8785C /* SharePost.swift in Sources */, + FABB254B2602FC2C00C8785C /* RoleViewController.swift in Sources */, + FABB254C2602FC2C00C8785C /* SiteAddressService.swift in Sources */, + FABB254D2602FC2C00C8785C /* WPStyleGuide+Notifications.swift in Sources */, + FABB254E2602FC2C00C8785C /* SettingTableViewCell.m in Sources */, + FABB254F2602FC2C00C8785C /* CountriesMapView.swift in Sources */, + FAD257132611B04D00EDAF88 /* UIColor+JetpackColors.swift in Sources */, + 4A1E77C7298897F6006281CC /* SharingSyncService.swift in Sources */, + FABB25502602FC2C00C8785C /* MediaLibraryViewController.swift in Sources */, + FABB25512602FC2C00C8785C /* TodayWidgetStats.swift in Sources */, + FABB25522602FC2C00C8785C /* GutenbergMediaFilesUploadProcessor.swift in Sources */, + FABB25532602FC2C00C8785C /* WordPress-30-31.xcmappingmodel in Sources */, + FABB25542602FC2C00C8785C /* RegisterDomainDetailsFooterView.swift in Sources */, + FABB25552602FC2C00C8785C /* Charts+AxisFormatters.swift in Sources */, + 80D9CFFE29E711E200FE3400 /* DashboardPageCell.swift in Sources */, + FABB25562602FC2C00C8785C /* OtherAndTotalViewsCount+CoreDataClass.swift in Sources */, + FABB25572602FC2C00C8785C /* EpilogueSectionHeaderFooter.swift in Sources */, + FABB25582602FC2C00C8785C /* PostCategoriesViewController.swift in Sources */, + FABB25592602FC2C00C8785C /* NSManagedObject+Lookup.swift in Sources */, + FABB255A2602FC2C00C8785C /* StatsPeriodHelper.swift in Sources */, + FABB255B2602FC2C00C8785C /* FooterContentStyles.swift in Sources */, + FABB255C2602FC2C00C8785C /* TenorMedia.swift in Sources */, + FABB255D2602FC2C00C8785C /* AbstractPost.swift in Sources */, + 171713A0265FE59700F3A022 /* ButtonStyles.swift in Sources */, + FABB255E2602FC2C00C8785C /* WhatIsNewScenePresenter.swift in Sources */, + FABB255F2602FC2C00C8785C /* CalendarViewController.swift in Sources */, + FABB25602602FC2C00C8785C /* GutenbergViewController+Localization.swift in Sources */, + FABB25612602FC2C00C8785C /* MarkAsSpam.swift in Sources */, + FABB25622602FC2C00C8785C /* BlogDetailsSectionHeaderView.swift in Sources */, + FABB25642602FC2C00C8785C /* Math.swift in Sources */, + F1A38F222678C4DA00849843 /* BloggingRemindersFlow.swift in Sources */, + FABB25652602FC2C00C8785C /* MediaProgressCoordinator.swift in Sources */, + FABB25662602FC2C00C8785C /* NSMutableAttributedString+Helpers.swift in Sources */, + FABB25672602FC2C00C8785C /* PushNotificationsManager.swift in Sources */, + FABB25682602FC2C00C8785C /* StatsInsightsStore.swift in Sources */, + FABB25692602FC2C00C8785C /* MenuItemPagesViewController.m in Sources */, + FABB256A2602FC2C00C8785C /* StatsRecordValue+CoreDataProperties.swift in Sources */, + 4A9314E52979FA4700360232 /* PostCategory+Creation.swift in Sources */, + FA6402D229C325C1007A235C /* MovedToJetpackEventsTracker.swift in Sources */, + FABB256B2602FC2C00C8785C /* InteractivePostView.swift in Sources */, + FABB256C2602FC2C00C8785C /* LinearGradientView.swift in Sources */, + FABB256D2602FC2C00C8785C /* WizardStep.swift in Sources */, + 0C35FFF229CB81F700D224EB /* BlogDashboardHelpers.swift in Sources */, + C395FB242821FE4B00AE7C11 /* SiteDesignSection.swift in Sources */, + 837B49D8283C2AE80061A657 /* BloggingPromptSettings+CoreDataClass.swift in Sources */, + FABB256E2602FC2C00C8785C /* PostPostViewController.swift in Sources */, + FABB256F2602FC2C00C8785C /* QuickStartSpotlightView.swift in Sources */, + FABB25702602FC2C00C8785C /* ReaderReblogAction.swift in Sources */, + FABB25712602FC2C00C8785C /* SiteAssemblyWizardContent.swift in Sources */, + 08A4E12A289D202F001D9EC7 /* UserPersistentStore.swift in Sources */, + FEDDD47026A03DE900F8942B /* ListTableViewCell+Notifications.swift in Sources */, + FABB25722602FC2C00C8785C /* UITableView+Header.swift in Sources */, + FABB25732602FC2C00C8785C /* RichTextContentStyles.swift in Sources */, + FABB25742602FC2C00C8785C /* StatsRecord+CoreDataProperties.swift in Sources */, + F1E72EBB267790110066FF91 /* UIViewController+Dismissal.swift in Sources */, + FABB25752602FC2C00C8785C /* ContentCoordinator.swift in Sources */, + FABB25762602FC2C00C8785C /* InviteLinks+CoreDataProperties.swift in Sources */, + FABB25772602FC2C00C8785C /* NotificationMediaDownloader.swift in Sources */, + FABB25782602FC2C00C8785C /* WizardDelegate.swift in Sources */, + FABB25792602FC2C00C8785C /* Blog+Files.swift in Sources */, + FABB257A2602FC2C00C8785C /* RevisionDiffsBrowserViewController.swift in Sources */, + 46F583AE2624CE790010A723 /* BlockEditorSettingElement+CoreDataClass.swift in Sources */, + 80D9CFF829E5010300FE3400 /* PagesCardViewModel.swift in Sources */, + FADFBD3C265F5B2E00039C41 /* WPStyleGuide+Jetpack.swift in Sources */, + FABB257B2602FC2C00C8785C /* WPAccount.m in Sources */, + 80C523A529959DE000B1C14B /* BlazeWebViewController.swift in Sources */, + FABB257C2602FC2C00C8785C /* CLPlacemark+Formatting.swift in Sources */, + 0CB4056C29C78F06008EED0A /* BlogDashboardPersonalizationService.swift in Sources */, + FABB257D2602FC2C00C8785C /* SiteStatsDetailTableViewController.swift in Sources */, + FABB257E2602FC2C00C8785C /* MessageAnimator.swift in Sources */, + C3AB4879292F114A001F7AF8 /* UIApplication+AppAvailability.swift in Sources */, + FABB257F2602FC2C00C8785C /* JetpackRestoreOptionsViewController.swift in Sources */, + FABB25802602FC2C00C8785C /* WPStyleGuide+Loader.swift in Sources */, + FABB25812602FC2C00C8785C /* MediaThumbnailService.swift in Sources */, + FABB25822602FC2C00C8785C /* MediaExporter.swift in Sources */, + 1756DBE028328B76006E6DB9 /* DonutChartView.swift in Sources */, + 8B15CDAC27EB89AD00A75749 /* BlogDashboardPostsParser.swift in Sources */, + 8BF9E03427B1A8A800915B27 /* DashboardCard.swift in Sources */, + FABB25832602FC2C00C8785C /* ReaderRelatedPostsCell.swift in Sources */, + FABB25842602FC2C00C8785C /* NoResultsViewController+FollowedSites.swift in Sources */, + F41E3302287B5FE500F89082 /* SuggestionViewModel.swift in Sources */, + DC76668626FD9AC9009254DD /* TimeZoneTableViewCell.swift in Sources */, + FABB25862602FC2C00C8785C /* WordPress-11-12.xcmappingmodel in Sources */, + C3C21EBA28385EC8002296E2 /* RemoteSiteDesigns.swift in Sources */, + FABB25872602FC2C00C8785C /* DiffAbstractValue.swift in Sources */, + 9822A8422624CFB900FD8A03 /* UserProfileSiteCell.swift in Sources */, + FABB25882602FC2C00C8785C /* Routes+Notifications.swift in Sources */, + FABB25892602FC2C00C8785C /* Autosaver.swift in Sources */, + 982D26202788DDF200A41286 /* ReaderCommentsFollowPresenter.swift in Sources */, + FABB258A2602FC2C00C8785C /* BlogAuthor.swift in Sources */, + FABB258B2602FC2C00C8785C /* Blog+BlogAuthors.swift in Sources */, + FABB258C2602FC2C00C8785C /* ActivityContentStyles.swift in Sources */, + FABB258D2602FC2C00C8785C /* CategorySectionTableViewCell.swift in Sources */, + FAE4CA692732C094003BFDFE /* QuickStartPromptViewController.swift in Sources */, + 175721172754D31F00DE38BC /* AppIcon.swift in Sources */, + FABB258E2602FC2C00C8785C /* StatsForegroundObservable.swift in Sources */, + FABB258F2602FC2C00C8785C /* AbstractPost+TitleForVisibility.swift in Sources */, + FABB25902602FC2C00C8785C /* SharingViewController.m in Sources */, + FABB25912602FC2C00C8785C /* FormattableActivity.swift in Sources */, + FABB25922602FC2C00C8785C /* RevisionPreviewTextViewManager.swift in Sources */, + FABB25932602FC2C00C8785C /* StatsChartLegendView.swift in Sources */, + C7B7CC712812FDCE007B9807 /* MySiteViewController+OnboardingPrompt.swift in Sources */, + C3FF78E928354A91008FA600 /* SiteDesignSectionLoader.swift in Sources */, + 80535DC1294BDE1900873161 /* JetpackBrandingMenuCardCell.swift in Sources */, + FABB25942602FC2C00C8785C /* FindOutMoreCell.swift in Sources */, + FABB25952602FC2C00C8785C /* NSURL+Exporters.swift in Sources */, + FABB25962602FC2C00C8785C /* FileDownloadsStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB25972602FC2C00C8785C /* BlogToAccount.m in Sources */, + FECA443028350B7800D01F15 /* PromptRemindersScheduler.swift in Sources */, + FABB25982602FC2C00C8785C /* JetpackRestoreHeaderView.swift in Sources */, + FABB25992602FC2C00C8785C /* StoryboardLoadable.swift in Sources */, + FABB259A2602FC2C00C8785C /* ReaderBlockSiteAction.swift in Sources */, + FABB259C2602FC2C00C8785C /* ReaderPostService+RelatedPosts.swift in Sources */, + FABB259D2602FC2C00C8785C /* Blog+Capabilities.swift in Sources */, + FABB259E2602FC2C00C8785C /* RestorePostTableViewCell.swift in Sources */, + 1756F1E02822BB6F00CD0915 /* SparklineView.swift in Sources */, + FABB259F2602FC2C00C8785C /* MenuItemCategoriesViewController.m in Sources */, + FABB25A02602FC2C00C8785C /* UINavigationBar+Appearance.swift in Sources */, + FABB25A12602FC2C00C8785C /* QuickStartChecklistViewController.swift in Sources */, + FABB25A22602FC2C00C8785C /* MenuItemsViewController.m in Sources */, + FABB25A32602FC2C00C8785C /* ReachabilityUtils+OnlineActions.swift in Sources */, + FABB25A42602FC2C00C8785C /* TableViewKeyboardObserver.swift in Sources */, + FABB25A52602FC2C00C8785C /* BlogToJetpackAccount.m in Sources */, + FABB25A62602FC2C00C8785C /* NotificationSettingsViewController.swift in Sources */, + FABB25A72602FC2C00C8785C /* ChangeUsernameViewController.swift in Sources */, + 173DF292274522A1007C64B5 /* AppAboutScreenConfiguration.swift in Sources */, + FABB25A82602FC2C00C8785C /* RichTextView.swift in Sources */, + FABB25A92602FC2C00C8785C /* ScenePresenter.swift in Sources */, + FABB25AA2602FC2C00C8785C /* AccountSettingsStore.swift in Sources */, + FABB25AB2602FC2C00C8785C /* FancyAlertViewController+NotificationPrimer.swift in Sources */, + FABB25AC2602FC2C00C8785C /* Charts+Support.swift in Sources */, + FABB25AD2602FC2C00C8785C /* SharingAuthorizationWebViewController.swift in Sources */, + 3FB1929326C6C57A000F5AA3 /* TimeSelectionView.swift in Sources */, + FABB25AE2602FC2C00C8785C /* SearchWrapperView.swift in Sources */, + 98830A932747043B0061A87C /* BorderedButtonTableViewCell.swift in Sources */, + FABB25AF2602FC2C00C8785C /* UserSettings.swift in Sources */, + FABB25B02602FC2C00C8785C /* MediaImportService.swift in Sources */, + FABB25B12602FC2C00C8785C /* NSAttributedStringKey+Conversion.swift in Sources */, + FABB25B22602FC2C00C8785C /* SelectPostViewController.swift in Sources */, + 8BE6F92D27EE27DB0008BDC7 /* BlogDashboardPostCardGhostCell.swift in Sources */, + 3FF15A5C291ED21100E1B4E5 /* MigrationNotificationsCenterView.swift in Sources */, + FABB25B32602FC2C00C8785C /* ReaderVisitSiteAction.swift in Sources */, + FABB25B42602FC2C00C8785C /* ReaderCard+CoreDataProperties.swift in Sources */, + FABB25B52602FC2C00C8785C /* GutenbergViewController+MoreActions.swift in Sources */, + FABB25B62602FC2C00C8785C /* Product.swift in Sources */, + FABB25B72602FC2C00C8785C /* PostTag.m in Sources */, + FABB25B82602FC2C00C8785C /* ActivityListViewModel.swift in Sources */, + FABB25B92602FC2C00C8785C /* UITextField+WorkaroundContinueIssue.swift in Sources */, + FABB25BA2602FC2C00C8785C /* StoreKit+Debug.swift in Sources */, + FABB25BB2602FC2C00C8785C /* PrivacySettingsViewController.swift in Sources */, + 3FFDEF812917882800B625CE /* MigrationNavigationController.swift in Sources */, + FABB25BC2602FC2C00C8785C /* SharingDetailViewController.m in Sources */, + 837B49DE283C2AE80061A657 /* BloggingPromptSettingsReminderDays+CoreDataProperties.swift in Sources */, + FABB25BD2602FC2C00C8785C /* OtherAndTotalViewsCount+CoreDataProperties.swift in Sources */, + 3FAF9CC326D02FC500268EA2 /* DomainsDashboardView.swift in Sources */, + FA70024D29DC3B5500E874FD /* DashboardActivityLogCardCell.swift in Sources */, + F48D44BB2989A9070051EAA6 /* ReaderSiteService.swift in Sources */, + FABB25BE2602FC2C00C8785C /* SettingsPickerViewController.swift in Sources */, + FABB25BF2602FC2C00C8785C /* WPTableImageSource.m in Sources */, + 4A878551290F2C7D0083AB78 /* Media+Sync.swift in Sources */, + 3FBB2D2F27FB715900C57BBF /* SiteNameView.swift in Sources */, + FABB25C02602FC2C00C8785C /* ReaderTopicCollectionViewCoordinator.swift in Sources */, + FABB25C12602FC2C00C8785C /* WPAppAnalytics.m in Sources */, + FABB25C22602FC2C00C8785C /* SettingsSelectionViewController.m in Sources */, + FABB25C32602FC2C00C8785C /* FormattableContentActionCommand.swift in Sources */, + F44293D328E3B18E00D340AF /* AppIconListViewModelType.swift in Sources */, + FABB25C42602FC2C00C8785C /* AuthorFilterViewController.swift in Sources */, + FABB25C52602FC2C00C8785C /* MenusSelectionItemView.m in Sources */, + FABB25C62602FC2C00C8785C /* TenorReponseParser.swift in Sources */, + FABB25C72602FC2C00C8785C /* WPStyleGuide+Gridicon.swift in Sources */, + FABB25C82602FC2C00C8785C /* RelatedPostsSettingsViewController.m in Sources */, + FABB25C92602FC2C00C8785C /* CreateButtonCoordinator.swift in Sources */, + 3FBF21B8267AA17A0098335F /* BloggingRemindersAnimator.swift in Sources */, + FABB25CA2602FC2C00C8785C /* SearchManager.swift in Sources */, + 4A1E77CA2988997C006281CC /* PublicizeConnection+Creation.swift in Sources */, + FE9CC71B26D7A2A40026AEF3 /* CommentDetailViewController.swift in Sources */, + F195C42B26DFBDC2000EC884 /* BackgroundTasksCoordinator.swift in Sources */, + FABB25CB2602FC2C00C8785C /* BlogToBlogMigration87to88.swift in Sources */, + FABB25CC2602FC2C00C8785C /* WPStyleGuide+Themes.swift in Sources */, + FABB25CD2602FC2C00C8785C /* WPStyleGuide+Reader.swift in Sources */, + FABB25CE2602FC2C00C8785C /* Interpolation.swift in Sources */, + FE43DAB026DFAD1C00CFF595 /* CommentContentTableViewCell.swift in Sources */, + FABB25CF2602FC2C00C8785C /* PostEditorAnalyticsSession.swift in Sources */, + FABB25D02602FC2C00C8785C /* SiteCreationWizardLauncher.swift in Sources */, + FABB25D12602FC2C00C8785C /* ReaderSitesCardCell.swift in Sources */, + 46F583B02624CE790010A723 /* BlockEditorSettingElement+CoreDataProperties.swift in Sources */, + FABB25D22602FC2C00C8785C /* SiteSegmentsWizardContent.swift in Sources */, + FABB25D32602FC2C00C8785C /* ReaderTopicToReaderDefaultTopic37to38.swift in Sources */, + FEC26034283FC902003D886A /* RootViewCoordinator+BloggingPrompt.swift in Sources */, + FABB25D42602FC2C00C8785C /* UploadsManager.swift in Sources */, + 9856A3E5261FD27A008D6354 /* UserProfileSectionHeader.swift in Sources */, + FABB25D62602FC2C00C8785C /* ActivityListSectionHeaderView.swift in Sources */, + FABB25D72602FC2C00C8785C /* WPAccount+RestApi.swift in Sources */, + FABB25D82602FC2C00C8785C /* ImageCropOverlayView.swift in Sources */, + FABB25D92602FC2C00C8785C /* UIView+SpringAnimations.swift in Sources */, + FABB25DA2602FC2C00C8785C /* ReaderGapMarkerCell.swift in Sources */, + C7A09A4E28403A34003096ED /* QRLoginURLParser.swift in Sources */, + DCCDF75F283BF02D00AA347E /* SiteStatsInsightsDetailsViewModel.swift in Sources */, + FABB25DB2602FC2C00C8785C /* SupportTableViewController+Activity.swift in Sources */, + FABB25DC2602FC2C00C8785C /* ThemeIdHelper.swift in Sources */, + FABB25DD2602FC2C00C8785C /* BottomScrollAnalyticsTracker.swift in Sources */, + FABB25DE2602FC2C00C8785C /* UserProfile.swift in Sources */, + FABB25DF2602FC2C00C8785C /* PickerTableViewCell.swift in Sources */, + FABB25E02602FC2C00C8785C /* StoryEditor.swift in Sources */, + 2481B180260D4D4E00AE59DB /* WPAccount+Lookup.swift in Sources */, + FABB25E12602FC2C00C8785C /* WPActivityDefaults.m in Sources */, + FABB25E22602FC2C00C8785C /* RegisterDomainDetailsViewModel+CellIndex.swift in Sources */, + FABB25E32602FC2C00C8785C /* RouteMatcher.swift in Sources */, + FABB25E42602FC2C00C8785C /* WPGUIConstants.m in Sources */, + FABB25E52602FC2C00C8785C /* UIColor+Notice.swift in Sources */, + 0147D64F294B1E1600AA6410 /* StatsRevampStore.swift in Sources */, + FABB25E62602FC2C00C8785C /* JetpackBackupOptionsCoordinator.swift in Sources */, + FABB25E72602FC2C00C8785C /* PluginStore.swift in Sources */, + FABB25E82602FC2C00C8785C /* JetpackBackupStatusViewController.swift in Sources */, + FABB25E92602FC2C00C8785C /* Pinghub.swift in Sources */, + FABB25EA2602FC2C00C8785C /* PeopleRoleBadgeLabel.swift in Sources */, + FABB25EB2602FC2C00C8785C /* ActivityDetailViewController.swift in Sources */, + FABB25EC2602FC2C00C8785C /* BlogListViewController+BlogDetailsFactory.swift in Sources */, + F4CBE3D429258AE1004FFBB6 /* MeHeaderViewConfiguration.swift in Sources */, + C32A6A2D2832BF02002E9394 /* SiteDesignCategoryThumbnailSize.swift in Sources */, + FABB25ED2602FC2C00C8785C /* WebViewControllerConfiguration.swift in Sources */, + FABB25EE2602FC2C00C8785C /* ViewMoreRow.swift in Sources */, + FABB25EF2602FC2C00C8785C /* ReaderWebView.swift in Sources */, + FABB25F02602FC2C00C8785C /* PostVisibilitySelectorViewController.swift in Sources */, + FABB25F12602FC2C00C8785C /* UIApplication+Helpers.m in Sources */, + 3F3DD0B026FCDA3100F5F121 /* PresentationButton.swift in Sources */, + FABB25F22602FC2C00C8785C /* ErrorStateView.swift in Sources */, + FABB25F32602FC2C00C8785C /* MenusSelectionDetailView.m in Sources */, + FABB25F42602FC2C00C8785C /* Blog+Title.swift in Sources */, + C34E94BA28EDF7D900D27A16 /* InfiniteScrollerView.swift in Sources */, + FABB25F52602FC2C00C8785C /* FilterableCategoriesViewController.swift in Sources */, + FABB25F62602FC2C00C8785C /* Post+CoreDataProperties.swift in Sources */, + FABB25F72602FC2C00C8785C /* BasePost.swift in Sources */, + FABB25F82602FC2C00C8785C /* NotificationContentRange.swift in Sources */, + FABB25F92602FC2C00C8785C /* PostServiceUploadingList.swift in Sources */, + FABB25FA2602FC2C00C8785C /* ImmuTable.swift in Sources */, + FABB25FB2602FC2C00C8785C /* PersonViewController.swift in Sources */, + 8000362129246956007D2D26 /* JetpackFullscreenOverlaySiteCreationViewModel+Analytics.swift in Sources */, + FABB25FC2602FC2C00C8785C /* StreakInsightStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB25FD2602FC2C00C8785C /* JetpackRemoteInstallState.swift in Sources */, + FABB25FE2602FC2C00C8785C /* Scheduler.swift in Sources */, + 4A2172F928EAACFF0006F4F1 /* BlogQuery.swift in Sources */, + F1C740C026B1D4D2005D0809 /* StoreSandboxSecretScreen.swift in Sources */, + 801D94F02919E7D70051993E /* JetpackFullscreenOverlayGeneralViewModel.swift in Sources */, + FABB25FF2602FC2C00C8785C /* RegisterDomainDetailsViewModel+RowDefinitions.swift in Sources */, + 0118969229D2CA6F00D34BA9 /* DomainsDashboardCardHelper.swift in Sources */, + 98E082A02637545C00537BF1 /* PostService+Likes.swift in Sources */, + FABB26002602FC2C00C8785C /* GravatarProfile.swift in Sources */, + FABB26012602FC2C00C8785C /* ReaderShareAction.swift in Sources */, + C7124E4F2638528F00929318 /* JetpackPrologueViewController.swift in Sources */, + FE341706275FA157005D5CA7 /* RichCommentContentRenderer.swift in Sources */, + 01E61E5B29F03DEC002E544E /* DashboardDomainsCardSearchView.swift in Sources */, + FABB26022602FC2C00C8785C /* PostingActivityMonth.swift in Sources */, + 0107E15D28FFE99300DE87DB /* WidgetConfiguration.swift in Sources */, + FABB26032602FC2C00C8785C /* StreakStatsRecordValue+CoreDataClass.swift in Sources */, + FABB26042602FC2C00C8785C /* ReaderTabItem.swift in Sources */, + FABB26062602FC2C00C8785C /* TopCommentsAuthorStatsRecordValue+CoreDataClass.swift in Sources */, + FABB26072602FC2C00C8785C /* JetpackBackupService.swift in Sources */, + FABB26082602FC2C00C8785C /* NotificationsViewController+JetpackPrompt.swift in Sources */, + FABB26092602FC2C00C8785C /* PostListFooterView.m in Sources */, + FABB260A2602FC2C00C8785C /* ThemeBrowserSearchHeaderView.swift in Sources */, + FABB260B2602FC2C00C8785C /* MenuItemInsertionView.m in Sources */, + 988FD74B279B75A400C7E814 /* NotificationCommentDetailCoordinator.swift in Sources */, + FABB260C2602FC2C00C8785C /* AllTimeStatsRecordValue+CoreDataClass.swift in Sources */, + FABB260D2602FC2C00C8785C /* GutenbergMediaEditorImage.swift in Sources */, + FABB260E2602FC2C00C8785C /* NoteBlockHeaderTableViewCell.swift in Sources */, + FABB260F2602FC2C00C8785C /* RemoteFeatureFlagStore.swift in Sources */, + FABB26102602FC2C00C8785C /* SuggestionsTableViewCell.m in Sources */, + 4AA33EFC2999AE3B005B6E23 /* ReaderListTopic+Creation.swift in Sources */, + F10D635026F0B78E00E46CC7 /* Blog+Organization.swift in Sources */, + FABB26112602FC2C00C8785C /* LastPostStatsRecordValue+CoreDataClass.swift in Sources */, + FABB26122602FC2C00C8785C /* ActivityRangesFactory.swift in Sources */, + 08A4E12D289D2337001D9EC7 /* UserPersistentRepository.swift in Sources */, + FEA1124029964BCA008097B0 /* WPComJetpackRemoteInstallViewModel.swift in Sources */, + C35D4FF2280077F100DB90B5 /* SiteCreationStep.swift in Sources */, + C79C308026EA975000E88514 /* ReferrerDetailsRow.swift in Sources */, + FABB26132602FC2C00C8785C /* SFHFKeychainUtils.m in Sources */, + FABB26142602FC2C00C8785C /* CircularProgressView+ActivityIndicatorType.swift in Sources */, + FE3E427A26A8690100C596CE /* ListSimpleOverlayView.swift in Sources */, + FABB26152602FC2C00C8785C /* CocoaLumberjack.swift in Sources */, + 3FA62FD426FE2E4B0020793A /* ShapeWithTextView.swift in Sources */, + FABB26162602FC2C00C8785C /* WPUploadStatusButton.m in Sources */, + FABB26172602FC2C00C8785C /* MediaExternalExporter.swift in Sources */, + FABB26182602FC2C00C8785C /* RegisterDomainDetailsViewModel+SectionDefinitions.swift in Sources */, + FABB26192602FC2C00C8785C /* ActionRow.swift in Sources */, + C395FB272822148400AE7C11 /* RemoteSiteDesign+Thumbnail.swift in Sources */, + 9815D0B426B49A0600DF7226 /* Comment+CoreDataProperties.swift in Sources */, + FABB261A2602FC2C00C8785C /* AppSettingsViewController.swift in Sources */, + 4A82C43228D321A300486CFF /* Blog+Post.swift in Sources */, + FABB261B2602FC2C00C8785C /* ReaderPostStreamService.swift in Sources */, + FABB261C2602FC2C00C8785C /* ReferrerStatsRecordValue+CoreDataProperties.swift in Sources */, + FABB261E2602FC2C00C8785C /* PostCardCell.swift in Sources */, + 8F228B22E190FF92D05E53DB /* TimeZoneSearchHeaderView.swift in Sources */, + 8F2289EDA1886BF77687D72D /* TimeZoneSelectorViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FAF64B6A2637DEEC00E8A1DF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FAF64B6F2637DEEC00E8A1DF /* BaseScreen.swift in Sources */, + FAF64B722637DEEC00E8A1DF /* XCTest+Extensions.swift in Sources */, + 0107E11628FD7FE800DE87DB /* AppConfiguration.swift in Sources */, + FAF64E4F2637E85800E8A1DF /* JetpackScreenshotGeneration.swift in Sources */, + FAF64B742637DEEC00E8A1DF /* WPUITestCredentials.swift in Sources */, + FAF64B7B2637DEEC00E8A1DF /* LoginFlow.swift in Sources */, + FAF64B922637DEEC00E8A1DF /* SnapshotHelper.swift in Sources */, + 0107E17028FFEF4F00DE87DB /* WidgetConfiguration.swift in Sources */, + FAF64B982637DEEC00E8A1DF /* ScreenshotCredentials.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FF27168B1CAAC87A0006E2D4 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - CC52189A2279CF3B008998CE /* MediaPickerAlbumListScreen.swift in Sources */, - CC94FC6A2214532D002E5825 /* FancyAlertComponent.swift in Sources */, - BE2B4E9B1FD66423007AE3E4 /* WelcomeScreen.swift in Sources */, - CCE911BC221D8497007E1D4E /* LoginSiteAddressScreen.swift in Sources */, - CC7CB98722B28F4600642EE9 /* WelcomeScreenSignupComponent.swift in Sources */, - CC19BE06223FECAC00CAB3E1 /* EditorPostSettings.swift in Sources */, FFA0B7D71CAC1F9F00533B9D /* MainNavigationTests.swift in Sources */, - CC7CB97622B15A2900642EE9 /* SignupEmailScreen.swift in Sources */, - CCE911BE221D85E4007E1D4E /* LoginUsernamePasswordScreen.swift in Sources */, - BE6DD32E1FD67EDA00E55192 /* MySitesScreen.swift in Sources */, - BED4D8351FF1208400A11345 /* AztecEditorScreen.swift in Sources */, - BE6DD32C1FD6782A00E55192 /* LoginEpilogueScreen.swift in Sources */, - BE87071B2006E65C004FB5A4 /* NotificationsScreen.swift in Sources */, - BE6DD3321FD6803700E55192 /* MeTabScreen.swift in Sources */, + EAB10E4027487F5D000DA4C1 /* ReaderTests.swift in Sources */, BED4D8301FF11DEF00A11345 /* EditorAztecTests.swift in Sources */, - BE8707172006B774004FB5A4 /* MySiteScreen.swift in Sources */, - CCE55E992242715C002A9634 /* CategoriesComponent.swift in Sources */, - 7EF9F65722F03C9200F79BBF /* SiteSettingsScreen.swift in Sources */, - BE2B4EA21FD6654A007AE3E4 /* Logger.swift in Sources */, - CCF6ACE7221ED73900D0C5BE /* LoginCheckMagicLinkScreen.swift in Sources */, + D82E087529EEB0B00098F500 /* DashboardTests.swift in Sources */, + 3F2F856326FAF612000FCDA5 /* EditorGutenbergTests.swift in Sources */, FF2716921CAAC87B0006E2D4 /* LoginTests.swift in Sources */, - F97DA42023D67B820050E791 /* MediaScreen.swift in Sources */, - CC8498D32241477F00DB490A /* TagsComponent.swift in Sources */, BE2B4E9F1FD664F5007AE3E4 /* BaseScreen.swift in Sources */, - CC7CB97A22B15C1000642EE9 /* SignupEpilogueScreen.swift in Sources */, - 1AE0F2B12297F7E9000BDD7F /* WireMock.swift in Sources */, - BE6DD32A1FD6708900E55192 /* LinkOrPasswordScreen.swift in Sources */, - CC2BB0CA2289CC3B0034F9AB /* BlockEditorScreen.swift in Sources */, - CC2BB0D0228ACF710034F9AB /* EditorGutenbergTests.swift in Sources */, + 6E5BA46926A59D620043A6F2 /* SupportScreenTests.swift in Sources */, + EAD2BF4227594DAB00A847BB /* StatsTests.swift in Sources */, CC8A5EAB22159FA6001B7874 /* WPUITestCredentials.swift in Sources */, - BE2B4EA41FD6659B007AE3E4 /* LoginEmailScreen.swift in Sources */, - BE6DD3281FD6705200E55192 /* LoginPasswordScreen.swift in Sources */, - F9C47A8F238C9D6400AAD9ED /* StatsScreen.swift in Sources */, - BE6DD3301FD67F3B00E55192 /* TabNavComponent.swift in Sources */, - F9C47A8C238C801600AAD9ED /* PostsScreen.swift in Sources */, CC7CB97322B1510900642EE9 /* SignupTests.swift in Sources */, - CC7CB97822B15B2C00642EE9 /* SignupCheckMagicLinkScreen.swift in Sources */, - CC94FC68221452A4002E5825 /* EditorNoticeComponent.swift in Sources */, - BED4D83B1FF13B8A00A11345 /* EditorPublishEpilogueScreen.swift in Sources */, CC52188C2278C622008998CE /* EditorFlow.swift in Sources */, - CC52189C2279D295008998CE /* MediaPickerAlbumScreen.swift in Sources */, + 8B5FEAF125A746CB000CBFF7 /* UIApplication+mainWindow.swift in Sources */, BED4D8331FF11E3800A11345 /* LoginFlow.swift in Sources */, - BE8707192006E48E004FB5A4 /* ReaderScreen.swift in Sources */, - 985F06B52303866200949733 /* WelcomeScreenLoginComponent.swift in Sources */, FF2716A11CABC7D40006E2D4 /* XCTest+Extensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -13387,366 +25428,1316 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 733F360F2126197800988727 /* PBXTargetDependency */ = { + 0107E0ED28F97E6100DE87DB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0107E0B128F97D5000DE87DB /* JetpackStatsWidgets */; + targetProxy = 0107E0EC28F97E6100DE87DB /* PBXContainerItemProxy */; + }; + 0107E15728FEA03800DE87DB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 0107E13828FE9DB200DE87DB /* JetpackIntents */; + targetProxy = 0107E15628FEA03800DE87DB /* PBXContainerItemProxy */; + }; + 096A92FD26E2A0AE00448C68 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 096A92F526E29FFF00448C68 /* GenerateCredentials */; + targetProxy = 096A92FC26E2A0AE00448C68 /* PBXContainerItemProxy */; + }; + 3F526C5B2538CF2B0069706C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3F526C4B2538CF2A0069706C /* WordPressStatsWidgets */; + targetProxy = 3F526C5A2538CF2B0069706C /* PBXContainerItemProxy */; + }; + 3FCFFAFE2994A949002840C9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FFA8E22A1F94E3DE0002170F /* SwiftLint */; + targetProxy = 3FCFFAFD2994A949002840C9 /* PBXContainerItemProxy */; + }; + 3FCFFB002994AB25002840C9 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 733F36022126197800988727 /* WordPressNotificationContentExtension */; - targetProxy = 733F360E2126197800988727 /* PBXContainerItemProxy */; + target = FFA8E22A1F94E3DE0002170F /* SwiftLint */; + targetProxy = 3FCFFAFF2994AB25002840C9 /* PBXContainerItemProxy */; }; 7358E6BE210BD318002323EB /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 7358E6B7210BD318002323EB /* WordPressNotificationServiceExtension */; targetProxy = 7358E6BD210BD318002323EB /* PBXContainerItemProxy */; }; - 7457667B202B558C00F42E40 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 74576671202B558C00F42E40 /* WordPressDraftActionExtension */; - targetProxy = 7457667A202B558C00F42E40 /* PBXContainerItemProxy */; + 7457667B202B558C00F42E40 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 74576671202B558C00F42E40 /* WordPressDraftActionExtension */; + targetProxy = 7457667A202B558C00F42E40 /* PBXContainerItemProxy */; + }; + 8096212728E5411400940A5D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 809620CF28E540D700940A5D /* JetpackShareExtension */; + targetProxy = 8096212628E5411400940A5D /* PBXContainerItemProxy */; + }; + 8096219028E55F8600940A5D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 8096213028E55C9400940A5D /* JetpackDraftActionExtension */; + targetProxy = 8096218F28E55F8600940A5D /* PBXContainerItemProxy */; + }; + 80F6D05F28EE88FC00953C1A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 80F6D01F28EE866A00953C1A /* JetpackNotificationServiceExtension */; + targetProxy = 80F6D05E28EE88FC00953C1A /* PBXContainerItemProxy */; + }; + 8511CFBC1C607A7000B7CEED /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1D6058900D05DD3D006BFB54 /* WordPress */; + targetProxy = 8511CFBB1C607A7000B7CEED /* PBXContainerItemProxy */; + }; + 932225B01C7CE50300443B02 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 932225A61C7CE50300443B02 /* WordPressShareExtension */; + targetProxy = 932225AF1C7CE50300443B02 /* PBXContainerItemProxy */; + }; + E16AB93F14D978520047A2E5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1D6058900D05DD3D006BFB54 /* WordPress */; + targetProxy = E16AB93E14D978520047A2E5 /* PBXContainerItemProxy */; + }; + EA14534529AD877C001F3143 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FABB1F8F2602FC2C00C8785C /* Jetpack */; + targetProxy = EA14534429AD877C001F3143 /* PBXContainerItemProxy */; + }; + F1F163D525658B4D003DC13B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F1F163BD25658B4D003DC13B /* WordPressIntents */; + targetProxy = F1F163D425658B4D003DC13B /* PBXContainerItemProxy */; + }; + FAF64C3A2637E02700E8A1DF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = FABB1F8F2602FC2C00C8785C /* Jetpack */; + targetProxy = FAF64C392637E02700E8A1DF /* PBXContainerItemProxy */; + }; + FF2716951CAAC87B0006E2D4 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 1D6058900D05DD3D006BFB54 /* WordPress */; + targetProxy = FF2716941CAAC87B0006E2D4 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 099D768127D14B8E00F77EDE /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 099D768227D14B8E00F77EDE /* en */, + 099D768427D14BAE00F77EDE /* sq */, + 099D768527D14BB300F77EDE /* ar */, + 099D768627D14BB700F77EDE /* bg */, + 099D768727D14BBA00F77EDE /* zh-Hans */, + 099D768827D14BBE00F77EDE /* zh-Hant */, + 099D768927D14BC000F77EDE /* hr */, + 099D768A27D14BC200F77EDE /* cs */, + 099D768B27D14BC400F77EDE /* da */, + 099D768C27D14BC600F77EDE /* nl */, + 099D768D27D14BC800F77EDE /* en-AU */, + 099D768E27D14BCA00F77EDE /* en-CA */, + 099D768F27D14BCC00F77EDE /* en-GB */, + 099D769027D14BCE00F77EDE /* fr */, + 099D769127D14BD000F77EDE /* de */, + 099D769227D14BD500F77EDE /* he */, + 099D769327D14BD700F77EDE /* hu */, + 099D769427D14BD900F77EDE /* is */, + 099D769527D14BDB00F77EDE /* id */, + 099D769627D14BDC00F77EDE /* it */, + 099D769727D14BDE00F77EDE /* ja */, + 099D769827D14BE000F77EDE /* ko */, + 099D769927D14BE200F77EDE /* nb */, + 099D769A27D14BE500F77EDE /* pl */, + 099D769B27D14BE800F77EDE /* pt */, + 099D769C27D14BEA00F77EDE /* pt-BR */, + 099D769D27D14BEC00F77EDE /* ro */, + 099D769E27D14BED00F77EDE /* ru */, + 099D769F27D14BEF00F77EDE /* sk */, + 099D76A027D14BF100F77EDE /* es */, + 099D76A127D14BF200F77EDE /* sv */, + 099D76A227D14BF400F77EDE /* th */, + 099D76A327D14BF600F77EDE /* tr */, + 099D76A427D14BF800F77EDE /* cy */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + 3F46AB0225BF5D6300CE2E98 /* Sites.intentdefinition */ = { + isa = PBXVariantGroup; + children = ( + 3F46AB0125BF5D6300CE2E98 /* Base */, + 24AD66BE25FC25FD0056102C /* en */, + 24AD66C025FC25FE0056102C /* sq */, + 24AD66C225FC26000056102C /* ar */, + 24AD66C425FC26010056102C /* bg */, + 24AD66C625FC26020056102C /* zh-Hans */, + 24AD66C825FC26030056102C /* zh-Hant */, + 24AD66CA25FC26040056102C /* hr */, + 24AD66CC25FC26050056102C /* cs */, + 24AD66CE25FC26060056102C /* da */, + 24AD66F025FC260B0056102C /* nl */, + 24AD66F225FC260F0056102C /* en-AU */, + 24AD66F425FC26100056102C /* en-CA */, + 24AD670625FC26150056102C /* en-GB */, + 24AD670825FC26170056102C /* fr */, + 24AD670A25FC26170056102C /* de */, + 24AD670C25FC26180056102C /* he */, + 24AD670E25FC261A0056102C /* hu */, + 24AD671025FC261B0056102C /* is */, + 24AD671225FC261C0056102C /* id */, + 24AD671425FC261D0056102C /* it */, + 24AD671625FC261E0056102C /* ja */, + 24AD671825FC26200056102C /* ko */, + 24AD672A25FC26220056102C /* nb */, + 24AD672C25FC26240056102C /* pl */, + 24AD672E25FC26250056102C /* pt */, + 24AD673025FC26260056102C /* pt-BR */, + 24AD673225FC26270056102C /* ro */, + 24AD674425FC26280056102C /* ru */, + 24AD674625FC26290056102C /* sk */, + 24AD674825FC262B0056102C /* es */, + 24AD674A25FC262C0056102C /* sv */, + 24AD674C25FC262D0056102C /* th */, + 24AD674E25FC262E0056102C /* tr */, + 24AD675025FC262F0056102C /* cy */, + ); + name = Sites.intentdefinition; + sourceTree = ""; + }; + 8058730D28F7B70B00340C11 /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 8058730C28F7B70B00340C11 /* en */, + ); + name = InfoPlist.strings; + sourceTree = ""; + }; + 931DF4D818D09A2F00540BDD /* InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 931DF4D718D09A2F00540BDD /* en */, + 931DF4D918D09A9B00540BDD /* pt */, + 931DF4DA18D09AE100540BDD /* fr */, + 931DF4DB18D09AF600540BDD /* nl */, + 931DF4DC18D09B0100540BDD /* it */, + 931DF4DD18D09B1900540BDD /* th */, + 931DF4DE18D09B2600540BDD /* de */, + 931DF4DF18D09B3900540BDD /* id */, + A20971B519B0BC390058F395 /* en-GB */, + A20971B819B0BC570058F395 /* pt-BR */, + FFE69A1E1B1BD4F10073C2EB /* es */, + FFE69A1F1B1BD6D60073C2EB /* ja */, + FFE69A201B1BD79F0073C2EB /* sv */, + FFE69A211B1BD9710073C2EB /* hr */, + FFE69A221B1BD9D20073C2EB /* he */, + FFE69A231B1BDA8B0073C2EB /* zh-Hans */, + FFE69A241B1BDB170073C2EB /* nb */, + FFE69A251B1BDB7A0073C2EB /* tr */, + FFE69A261B1BE0490073C2EB /* zh-Hant */, + FFE69A271B1BFDC40073C2EB /* hu */, + FFE69A281B1BFDE50073C2EB /* pl */, + FFE69A291B1BFE050073C2EB /* ru */, + FFE69A2A1B1BFE200073C2EB /* da */, + 8574469C1BF154E1007FDB5F /* cy */, + 93D86B931C63EC31003D8E3E /* en-CA */, + 9371F2611E4A211900BF26A0 /* ko */, + 9371F2641E4A213300BF26A0 /* ar */, + 09C8BB7E27DFF9BE00974175 /* sq */, + 09C8BB7F27DFF9C000974175 /* bg */, + 09C8BB8027DFF9C600974175 /* en-AU */, + 09C8BB8127DFF9C800974175 /* cs */, + 09C8BB8227DFF9C900974175 /* is */, + 09C8BB8327DFF9CB00974175 /* ro */, + 09C8BB8427DFF9CC00974175 /* sk */, + ); + name = InfoPlist.strings; + path = Resources; + sourceTree = ""; + }; + E1D91454134A853D0089019C /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + E1D91455134A853D0089019C /* en */, + E1D91457134A854A0089019C /* es */, + FDCB9A89134B75B900E5C776 /* it */, + E17BE7A9134DEC12007285FD /* ja */, + E1863F9A1355E0AB0031BBC8 /* pt */, + E1457202135EC85700C7BAD2 /* sv */, + E167745A1377F24300EE44DD /* fr */, + E167745B1377F25500EE44DD /* nl */, + E167745C1377F26400EE44DD /* de */, + E167745D1377F26D00EE44DD /* hr */, + E133DB40137AE180003C0AF9 /* he */, + E18D8AE21397C51A00000861 /* zh-Hans */, + E18D8AE41397C54E00000861 /* nb */, + E1225A4C147E6D2400B4F3A0 /* tr */, + E1225A4D147E6D2C00B4F3A0 /* id */, + E12F95A51557C9C20067A653 /* zh-Hant */, + E12F95A61557CA210067A653 /* hu */, + E12F95A71557CA400067A653 /* pl */, + E12963A8174654B2002E7744 /* ru */, + E19853331755E461001CC6D5 /* da */, + E1E977BC17B0FA9A00AFB867 /* th */, + A20971B419B0BC390058F395 /* en-GB */, + A20971B719B0BC570058F395 /* pt-BR */, + 8574469D1BF154E2007FDB5F /* cy */, + 93D86B941C63EC31003D8E3E /* en-CA */, + 9371F25C1E4A207F00BF26A0 /* cs */, + 9371F25D1E4A208E00BF26A0 /* ro */, + 9371F25E1E4A20A100BF26A0 /* sq */, + 9371F25F1E4A20B000BF26A0 /* is */, + 9371F2601E4A20D700BF26A0 /* en-AU */, + 9371F2621E4A211900BF26A0 /* ko */, + 9371F2651E4A213300BF26A0 /* ar */, + 9371F2691E4A23A200BF26A0 /* bg */, + E163AF9E1ED45B100035317E /* sk */, + ); + name = Localizable.strings; + path = Resources; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 0107E0E628F97D5000DE87DB /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 18B1A53E374E22C490A08F23 /* Pods-JetpackStatsWidgets.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEVELOPMENT_TEAM = PZYM8XX95Q; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PZYM8XX95Q; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = JetpackStatsWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackStatsWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "Jetpack iOS Development Stats Widget"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Jetpack iOS Development Stats Widget"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jpdebug; + }; + name = Debug; + }; + 0107E0E728F97D5000DE87DB /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B3694C30079615C927D26E9F /* Pods-JetpackStatsWidgets.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets.entitlements"; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = PZYM8XX95Q; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = JetpackStatsWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackStatsWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.automattic.jetpack.JetpackStatsWidgets"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jetpack; + }; + name = Release; }; - 8511CFBC1C607A7000B7CEED /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 1D6058900D05DD3D006BFB54 /* WordPress */; - targetProxy = 8511CFBB1C607A7000B7CEED /* PBXContainerItemProxy */; + 0107E0E828F97D5000DE87DB /* Release-Internal */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B4CAFF307BEC7FD77CCF573C /* Pods-JetpackStatsWidgets.release-internal.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgetsRelease-Internal.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + INTERNAL_BUILD, + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = JetpackStatsWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.internal.JetpackStatsWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.internal.JetpackStatsWidgets"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jpinternal; + }; + name = "Release-Internal"; }; - 932225B01C7CE50300443B02 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 932225A61C7CE50300443B02 /* WordPressShareExtension */; - targetProxy = 932225AF1C7CE50300443B02 /* PBXContainerItemProxy */; + 0107E0E928F97D5000DE87DB /* Release-Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3AB6A3B516053EA8D0BC3B17 /* Pods-JetpackStatsWidgets.release-alpha.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgetsRelease-Alpha.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + ALPHA_BUILD, + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = JetpackStatsWidgets/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.alpha.JetpackStatsWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.alpha.JetpackStatsWidgets"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jpalpha; + }; + name = "Release-Alpha"; }; - 93E5284519A7741A003A1A9C /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 93E5283919A7741A003A1A9C /* WordPressTodayWidget */; - targetProxy = 93E5284419A7741A003A1A9C /* PBXContainerItemProxy */; + 0107E15028FE9DB200DE87DB /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 87A8AC8362510EB42708E5B3 /* Pods-JetpackIntents.debug.xcconfig */; + buildSettings = { + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressIntents/Supporting Files/WordPressIntents.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEVELOPMENT_TEAM = PZYM8XX95Q; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PZYM8XX95Q; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = JetpackIntents/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackIntents; + PRODUCT_NAME = "$(TARGET_NAME)"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Jetpack iOS Development Intents"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressIntents/Supporting Files/WordPressIntents-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jpdebug; + }; + name = Debug; }; - 93E5284819A7741A003A1A9C /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 93E5283919A7741A003A1A9C /* WordPressTodayWidget */; - targetProxy = 93E5284719A7741A003A1A9C /* PBXContainerItemProxy */; + 0107E15128FE9DB200DE87DB /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 549D51B99FF59CBE21A37CBF /* Pods-JetpackIntents.release.xcconfig */; + buildSettings = { + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressIntents/Supporting Files/WordPressIntents.entitlements"; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = PZYM8XX95Q; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = JetpackIntents/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackIntents; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.automattic.jetpack.JetpackIntents"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressIntents/Supporting Files/WordPressIntents-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jetpack; + }; + name = Release; }; - 98A3C2F9239997DA0048D38D /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 98A3C2EE239997DA0048D38D /* WordPressThisWeekWidget */; - targetProxy = 98A3C2F8239997DA0048D38D /* PBXContainerItemProxy */; + 0107E15228FE9DB200DE87DB /* Release-Internal */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4391027D80CFEDF45B8712A3 /* Pods-JetpackIntents.release-internal.xcconfig */; + buildSettings = { + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressIntents/Supporting Files/WordPressIntentsRelease-Internal.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + INTERNAL_BUILD, + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = JetpackIntents/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.internal.JetpackIntents; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.internal.JetpackIntents"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressIntents/Supporting Files/WordPressIntents-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jpinternal; + }; + name = "Release-Internal"; }; - 98D31B982396ED7F009CFF43 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 98D31B8D2396ED7E009CFF43 /* WordPressAllTimeWidget */; - targetProxy = 98D31B972396ED7F009CFF43 /* PBXContainerItemProxy */; + 0107E15328FE9DB200DE87DB /* Release-Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B124AFFFB3F0204107FD33D0 /* Pods-JetpackIntents.release-alpha.xcconfig */; + buildSettings = { + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressIntents/Supporting Files/WordPressIntentsRelease-Alpha.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + ALPHA_BUILD, + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = JetpackIntents/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.alpha.JetpackIntents; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.alpha.JetpackIntents"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressIntents/Supporting Files/WordPressIntents-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jpalpha; + }; + name = "Release-Alpha"; }; - E16AB93F14D978520047A2E5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 1D6058900D05DD3D006BFB54 /* WordPress */; - targetProxy = E16AB93E14D978520047A2E5 /* PBXContainerItemProxy */; + 096A92F626E2A00000448C68 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; }; - FF2716951CAAC87B0006E2D4 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 1D6058900D05DD3D006BFB54 /* WordPress */; - targetProxy = FF2716941CAAC87B0006E2D4 /* PBXContainerItemProxy */; + 096A92F726E2A00000448C68 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; }; - FFC3F6FC1B0DBF7200EFC359 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = FFC3F6F41B0DBF0900EFC359 /* UpdatePlistPreprocessor */; - targetProxy = FFC3F6FB1B0DBF7200EFC359 /* PBXContainerItemProxy */; + 096A92F826E2A00000448C68 /* Release-Internal */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = "Release-Internal"; }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 7326718D210F75D2001FA866 /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - 7326718E210F75F0001FA866 /* Base */, - 7326718F210F7601001FA866 /* en */, - ); - name = Localizable.strings; - sourceTree = ""; + 096A92F926E2A00000448C68 /* Release-Alpha */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = "Release-Alpha"; }; - 73D5AC5E212622B200ADDDD2 /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - 73D5AC5F212622B200ADDDD2 /* en */, - 73D5AC60212622B200ADDDD2 /* Base */, - ); - name = Localizable.strings; - sourceTree = ""; + 1D6058940D05DD3E006BFB54 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 75305C06D345590B757E3890 /* Pods-Apps-WordPress.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + APPLICATION_EXTENSION_API_ONLY = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = "$(inherited)"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN__ARC_BRIDGE_CAST_NONARC = NO; + CODE_SIGN_ENTITLEMENTS = WordPress.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = PZYM8XX95Q; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREFIX_HEADER = WordPress_Prefix.pch; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + "NSLOGGER_BUILD_USERNAME=\"${USER}\"", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_THUMB_SUPPORT = NO; + GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + /usr/include/libxml2, + ); + INFOPLIST_FILE = Info.plist; + INFOPLIST_PREPROCESS = YES; + JP_SCHEME = jpdebug; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = "$(VERSION_SHORT)"; + OTHER_CFLAGS = ( + "$(inherited)", + "-Wno-format-security", + "-DDEBUG", + "-Wno-format", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-l\"c++\"", + "-l\"iconv\"", + "-l\"sqlite3.0\"", + "-l\"z\"", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress; + PRODUCT_NAME = WordPress; + PROVISIONING_PROFILE_SPECIFIER = "WordPress Development"; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/System/WordPress-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "$(SWIFT_MODULE_NAME)-Swift-XcodeGenerated.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wpdebug; + }; + name = Debug; }; - 74E44ADE2031EFD600556205 /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - 74E44ADF2031EFF700556205 /* Base */, - 74E44AE02031F00C00556205 /* ja */, - 74E44AE12031F00F00556205 /* fr */, - 74E44AE22031F02A00556205 /* de */, - 74E44AE32031F02E00556205 /* es */, - 74E44AE42031F02F00556205 /* it */, - 74E44AE52031F03000556205 /* pt */, - 74E44AE62031F03200556205 /* sv */, - 74E44AE72031F03300556205 /* zh-Hans */, - 74E44AE82031F03500556205 /* nb */, - 74E44AE92031F03700556205 /* tr */, - 74E44AEA2031F03900556205 /* id */, - 74E44AEB2031F03A00556205 /* zh-Hant */, - 74E44AEC2031F03D00556205 /* hu */, - 74E44AED2031F03F00556205 /* pl */, - 74E44AEE2031F04000556205 /* ru */, - 74E44AEF2031F04100556205 /* da */, - 74E44AF02031F04200556205 /* th */, - 74E44AF12031F04400556205 /* nl */, - 74E44AF22031F04600556205 /* pt-BR */, - 74E44AF32031F04B00556205 /* sk */, - 74E44AF42031F05000556205 /* bg */, - 74E44AF52031F05100556205 /* ar */, - 74E44AF62031F05200556205 /* ko */, - 74E44AF72031F05400556205 /* is */, - 74E44AF82031F05600556205 /* sq */, - 74E44AF92031F05800556205 /* ro */, - 74E44AFA2031F05A00556205 /* cs */, - 74E44AFB2031F05C00556205 /* cy */, - 74E44AFC2031F05D00556205 /* he */, - 74E44AFD2031F05E00556205 /* hr */, - 74E44AFE2031F07800556205 /* en-GB */, - 74E44AFF2031F07900556205 /* en-CA */, - 74E44B002031F07B00556205 /* en-AU */, - ); - name = Localizable.strings; - sourceTree = ""; + 1D6058950D05DD3E006BFB54 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 51A5F017948878F7E26979A0 /* Pods-Apps-WordPress.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + APPLICATION_EXTENSION_API_ONLY = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = "$(inherited)"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN__ARC_BRIDGE_CAST_NONARC = NO; + CODE_SIGN_ENTITLEMENTS = WordPress.entitlements; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; + COPY_PHASE_STRIP = NO; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = PZYM8XX95Q; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + GCC_PREFIX_HEADER = WordPress_Prefix.pch; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + NS_BLOCK_ASSERTIONS, + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_THUMB_SUPPORT = NO; + GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + /usr/include/libxml2, + ); + INFOPLIST_FILE = Info.plist; + INFOPLIST_PREPROCESS = YES; + JP_SCHEME = jetpack; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = "$(VERSION_SHORT)"; + OTHER_CFLAGS = ( + "$(inherited)", + "-Wno-format-security", + "-Wno-format", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-l\"c++\"", + "-l\"iconv\"", + "-l\"sqlite3.0\"", + "-l\"z\"", + ); + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress; + PRODUCT_NAME = WordPress; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress"; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/System/WordPress-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "$(SWIFT_MODULE_NAME)-Swift-XcodeGenerated.h"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wordpress; + }; + name = Release; }; - 931DF4D818D09A2F00540BDD /* InfoPlist.strings */ = { - isa = PBXVariantGroup; - children = ( - 931DF4D718D09A2F00540BDD /* en */, - 931DF4D918D09A9B00540BDD /* pt */, - 931DF4DA18D09AE100540BDD /* fr */, - 931DF4DB18D09AF600540BDD /* nl */, - 931DF4DC18D09B0100540BDD /* it */, - 931DF4DD18D09B1900540BDD /* th */, - 931DF4DE18D09B2600540BDD /* de */, - 931DF4DF18D09B3900540BDD /* id */, - A20971B519B0BC390058F395 /* en-GB */, - A20971B819B0BC570058F395 /* pt-BR */, - FFE69A1E1B1BD4F10073C2EB /* es */, - FFE69A1F1B1BD6D60073C2EB /* ja */, - FFE69A201B1BD79F0073C2EB /* sv */, - FFE69A211B1BD9710073C2EB /* hr */, - FFE69A221B1BD9D20073C2EB /* he */, - FFE69A231B1BDA8B0073C2EB /* zh-Hans */, - FFE69A241B1BDB170073C2EB /* nb */, - FFE69A251B1BDB7A0073C2EB /* tr */, - FFE69A261B1BE0490073C2EB /* zh-Hant */, - FFE69A271B1BFDC40073C2EB /* hu */, - FFE69A281B1BFDE50073C2EB /* pl */, - FFE69A291B1BFE050073C2EB /* ru */, - FFE69A2A1B1BFE200073C2EB /* da */, - 8574469C1BF154E1007FDB5F /* cy */, - 93D86B931C63EC31003D8E3E /* en-CA */, - 9371F2611E4A211900BF26A0 /* ko */, - 9371F2641E4A213300BF26A0 /* ar */, - ); - name = InfoPlist.strings; - path = Resources; - sourceTree = ""; + 3F526C5D2538CF2C0069706C /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E850CD4B77CF21E683104B5A /* Pods-WordPressStatsWidgets.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEVELOPMENT_TEAM = PZYM8XX95Q; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = "WordPressStatsWidgets/Supporting Files/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressStatsWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "WordPress Stats Widgets Development"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wpdebug; + }; + name = Debug; }; - 983AE84F2399B19200E5B7F6 /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - 983AE8502399B19200E5B7F6 /* Base */, - ); - name = Localizable.strings; - sourceTree = ""; + 3F526C5E2538CF2C0069706C /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 02BF978AFC1EFE50CFD558C2 /* Pods-WordPressStatsWidgets.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets.entitlements"; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = PZYM8XX95Q; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = "WordPressStatsWidgets/Supporting Files/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressStatsWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress.WordPressStatsWidgets"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wordpress; + }; + name = Release; }; - 98D31BBB23971F4B009CFF43 /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - 98D31BBC23971F4B009CFF43 /* Base */, - ); - name = Localizable.strings; - sourceTree = ""; + 3F526C5F2538CF2C0069706C /* Release-Internal */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C2988A406A3D5697C2984F3E /* Pods-WordPressStatsWidgets.release-internal.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgetsRelease-Internal.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + INTERNAL_BUILD, + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = "WordPressStatsWidgets/Supporting Files/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.internal.WordPressStatsWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressStatsWidgets"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wpinternal; + }; + name = "Release-Internal"; }; - E1B6A9CE1E54B6B2008FD47E /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - E1B6A9CD1E54B6B2008FD47E /* Base */, - E1B6AA571E54B83D008FD47E /* ja */, - E1B6AA581E54B840008FD47E /* es */, - E1AC8CB91E54B891006FD056 /* ar */, - E135CD6E1E54BC0A0021E79F /* fr */, - E135CD6F1E54BC0C0021E79F /* de */, - E135CD711E54BCA70021E79F /* bg */, - E135CD721E54BD300021E79F /* cs */, - E135CD731E54BD350021E79F /* cy */, - E135CD741E54BD410021E79F /* da */, - E135CD751E54BD4C0021E79F /* en-AU */, - E135CD761E54BD4F0021E79F /* en-CA */, - E135CD771E54BD510021E79F /* en-GB */, - E135CD781E54BD530021E79F /* pt-BR */, - E135CD791E54BD590021E79F /* zh-Hans */, - E135CD7A1E54BD5B0021E79F /* zh-Hant */, - E135CD7B1E54BD620021E79F /* it */, - E135CD7C1E54BD6D0021E79F /* ko */, - E135CD7D1E54BD730021E79F /* nb */, - E135CD7E1E54BD7B0021E79F /* nl */, - E135CD7F1E54BD810021E79F /* pl */, - E135CD801E54BD860021E79F /* pt */, - E135CD811E54BD8A0021E79F /* sv */, - E135CD821E54BD910021E79F /* ro */, - E135CD831E54BD980021E79F /* ru */, - E135CD841E54BD9D0021E79F /* sq */, - E135CD851E54BDA30021E79F /* th */, - E135CD861E54BDAC0021E79F /* tr */, - E135CD871E54BDE40021E79F /* he */, - E135CD881E54BDE60021E79F /* hr */, - E135CD891E54BDEB0021E79F /* hu */, - E135CD8A1E54BDED0021E79F /* id */, - E135CD8B1E54BDEF0021E79F /* is */, - E163AFA01ED45B110035317E /* sk */, - ); - name = Localizable.strings; - sourceTree = ""; + 3F526C602538CF2C0069706C /* Release-Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C1B070FAD875CA331772B57 /* Pods-WordPressStatsWidgets.release-alpha.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgetsRelease-Alpha.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + ALPHA_BUILD, + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = "WordPressStatsWidgets/Supporting Files/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.alpha.WordPressStatsWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressStatsWidgets"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wpalpha; + }; + name = "Release-Alpha"; }; - E1C5B2131E54C28C00052319 /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - E1C5B2121E54C28C00052319 /* Base */, - E1C5B2141E54C37800052319 /* ar */, - E1C5B2151E54C37C00052319 /* bg */, - E1C5B2161E54C37F00052319 /* cs */, - E1C5B2171E54C38200052319 /* cy */, - E1C5B2181E54C3A200052319 /* ja */, - E1C5B2191E54C3A300052319 /* fr */, - E1C5B21A1E54C3A500052319 /* de */, - E1C5B21B1E54C3A700052319 /* es */, - E1C5B21C1E54C3A900052319 /* it */, - E1C5B21D1E54C3AB00052319 /* pt */, - E1C5B21E1E54C3AC00052319 /* sv */, - E1C5B21F1E54C3AE00052319 /* zh-Hans */, - E1C5B2201E54C3AF00052319 /* nb */, - E1C5B2211E54C3B100052319 /* tr */, - E1C5B2221E54C3B200052319 /* id */, - E1C5B2231E54C3B300052319 /* zh-Hant */, - E1C5B2241E54C3B500052319 /* hu */, - E1C5B2251E54C3B600052319 /* pl */, - E1C5B2261E54C3B700052319 /* ru */, - E1C5B2271E54C3B900052319 /* da */, - E1C5B2281E54C3BB00052319 /* th */, - E1C5B2291E54C3BC00052319 /* nl */, - E1C5B22A1E54C3C400052319 /* pt-BR */, - E1C5B22B1E54C3C500052319 /* hr */, - E1C5B22C1E54C3C700052319 /* he */, - E1C5B22D1E54C3C800052319 /* en-CA */, - E1C5B22E1E54C3CA00052319 /* ro */, - E1C5B22F1E54C3CC00052319 /* sq */, - E1C5B2301E54C3CD00052319 /* is */, - E1C5B2311E54C3CF00052319 /* en-AU */, - E1C5B2321E54C3D000052319 /* ko */, - E1C5B2331E54C3E500052319 /* en-GB */, - E163AF9F1ED45B110035317E /* sk */, - ); - name = Localizable.strings; - path = WordPressShareExtension; - sourceTree = SOURCE_ROOT; + 3FA6405C2670CCD40064401E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = PZYM8XX95Q; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = UITestsFoundation/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.UITestsFoundation; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; }; - E1D91454134A853D0089019C /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - E1D91455134A853D0089019C /* en */, - E1D91457134A854A0089019C /* es */, - FDCB9A89134B75B900E5C776 /* it */, - E17BE7A9134DEC12007285FD /* ja */, - E1863F9A1355E0AB0031BBC8 /* pt */, - E1457202135EC85700C7BAD2 /* sv */, - E167745A1377F24300EE44DD /* fr */, - E167745B1377F25500EE44DD /* nl */, - E167745C1377F26400EE44DD /* de */, - E167745D1377F26D00EE44DD /* hr */, - E133DB40137AE180003C0AF9 /* he */, - E18D8AE21397C51A00000861 /* zh-Hans */, - E18D8AE41397C54E00000861 /* nb */, - E1225A4C147E6D2400B4F3A0 /* tr */, - E1225A4D147E6D2C00B4F3A0 /* id */, - E12F95A51557C9C20067A653 /* zh-Hant */, - E12F95A61557CA210067A653 /* hu */, - E12F95A71557CA400067A653 /* pl */, - E12963A8174654B2002E7744 /* ru */, - E19853331755E461001CC6D5 /* da */, - E1E977BC17B0FA9A00AFB867 /* th */, - A20971B419B0BC390058F395 /* en-GB */, - A20971B719B0BC570058F395 /* pt-BR */, - 8574469D1BF154E2007FDB5F /* cy */, - 93D86B941C63EC31003D8E3E /* en-CA */, - 9371F25C1E4A207F00BF26A0 /* cs */, - 9371F25D1E4A208E00BF26A0 /* ro */, - 9371F25E1E4A20A100BF26A0 /* sq */, - 9371F25F1E4A20B000BF26A0 /* is */, - 9371F2601E4A20D700BF26A0 /* en-AU */, - 9371F2621E4A211900BF26A0 /* ko */, - 9371F2651E4A213300BF26A0 /* ar */, - 9371F2691E4A23A200BF26A0 /* bg */, - E163AF9E1ED45B100035317E /* sk */, - ); - name = Localizable.strings; - path = Resources; - sourceTree = ""; + 3FA6405D2670CCD40064401E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = PZYM8XX95Q; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = UITestsFoundation/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.UITestsFoundation; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 1D6058940D05DD3E006BFB54 /* Debug */ = { + 3FA6405E2670CCD40064401E /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 75305C06D345590B757E3890 /* Pods-WordPress.debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; - APPLICATION_EXTENSION_API_ONLY = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = "$(inherited)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN__ARC_BRIDGE_CAST_NONARC = NO; - CODE_SIGN_ENTITLEMENTS = WordPress.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = PZYM8XX95Q; - ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = "$(inherited)"; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREFIX_HEADER = WordPress_Prefix.pch; - GCC_PREPROCESSOR_DEFINITIONS = ( + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = UITestsFoundation/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "COCOAPODS=1", - "NSLOGGER_BUILD_USERNAME=\"${USER}\"", - "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + "@executable_path/Frameworks", + "@loader_path/Frameworks", ); - GCC_SYMBOLS_PRIVATE_EXTERN = NO; - GCC_THUMB_SUPPORT = NO; - GCC_TREAT_WARNINGS_AS_ERRORS = NO; - GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; - HEADER_SEARCH_PATHS = ( + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.UITestsFoundation; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = "Release-Internal"; + }; + 3FA6405F2670CCD40064401E /* Release-Alpha */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = PZYM8XX95Q; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = UITestsFoundation/Info.plist; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - /usr/include/libxml2, + "@executable_path/Frameworks", + "@loader_path/Frameworks", ); - INFOPLIST_FILE = Info.plist; - INFOPLIST_PREFIX_HEADER = InfoPlist.h; - INFOPLIST_PREPROCESS = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = "$(inherited)"; - OTHER_CFLAGS = ( + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.UITestsFoundation; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = "Release-Alpha"; + }; + 733F36112126197800988727 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WordPressNotificationContentExtension/WordPressNotificationContentExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - "-Wno-format-security", - "-DDEBUG", - "-Wno-format", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + "DEBUG=1", ); - OTHER_LDFLAGS = ( + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = WordPressNotificationContentExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "-ObjC", - "-l\"c++\"", - "-l\"iconv\"", - "-l\"sqlite3.0\"", - "-l\"z\"", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", ); - OTHER_SWIFT_FLAGS = "$(inherited) -D DEBUG"; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress; - PRODUCT_NAME = WordPress; - PROVISIONING_PROFILE_SPECIFIER = "WPiOS Development Profile"; - SWIFT_INCLUDE_PATHS = ""; - SWIFT_OBJC_BRIDGING_HEADER = "Classes/System/WordPress-Bridging-Header.h"; + MTL_ENABLE_DEBUG_INFO = YES; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "WordPress Notification Content Extension Dev"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationContentExtension/Sources/WordPressNotificationContentExtension-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -13754,70 +26745,147 @@ }; name = Debug; }; - 1D6058950D05DD3E006BFB54 /* Release */ = { + 733F36122126197800988727 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 51A5F017948878F7E26979A0 /* Pods-WordPress.release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; - APPLICATION_EXTENSION_API_ONLY = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = "$(inherited)"; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN__ARC_BRIDGE_CAST_NONARC = NO; - CODE_SIGN_ENTITLEMENTS = WordPress.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WordPressNotificationContentExtension/WordPressNotificationContentExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; COPY_PHASE_STRIP = NO; - DEFINES_MODULE = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = PZYM8XX95Q; - ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = "$(inherited)"; - GCC_PREFIX_HEADER = WordPress_Prefix.pch; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - NS_BLOCK_ASSERTIONS, + "COCOAPODS=1", "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); - GCC_SYMBOLS_PRIVATE_EXTERN = NO; - GCC_THUMB_SUPPORT = NO; - GCC_TREAT_WARNINGS_AS_ERRORS = NO; - GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; - HEADER_SEARCH_PATHS = ( + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = WordPressNotificationContentExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - /usr/include/libxml2, + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", ); - INFOPLIST_FILE = Info.plist; - INFOPLIST_PREFIX_HEADER = InfoPlist.h; - INFOPLIST_PREPROCESS = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = "$(inherited)"; - OTHER_CFLAGS = ( + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress.WordPressNotificationContentExtension"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationContentExtension/Sources/WordPressNotificationContentExtension-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wordpress; + }; + name = Release; + }; + 733F36132126197800988727 /* Release-Internal */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressNotificationContentExtension/WordPressNotificationContentExtension-Internal.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = "WordPressNotificationContentExtension/Info-Internal.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.internal.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressNotificationContentExtension"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationContentExtension/Sources/WordPressNotificationContentExtension-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wpinternal; + }; + name = "Release-Internal"; + }; + 733F36142126197800988727 /* Release-Alpha */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressNotificationContentExtension/WordPressNotificationContentExtension-Alpha.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - "-Wno-format-security", - "-Wno-format", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); - OTHER_LDFLAGS = ( + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = "WordPressNotificationContentExtension/Info-Alpha.plist"; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "-ObjC", - "-l\"c++\"", - "-l\"iconv\"", - "-l\"sqlite3.0\"", - "-l\"z\"", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress; - PRODUCT_NAME = WordPress; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress"; - SWIFT_INCLUDE_PATHS = ""; - SWIFT_OBJC_BRIDGING_HEADER = "Classes/System/WordPress-Bridging-Header.h"; + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.alpha.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressNotificationContentExtension"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationContentExtension/Sources/WordPressNotificationContentExtension-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wordpress; + WPCOM_SCHEME = wpalpha; }; - name = Release; + name = "Release-Alpha"; }; - 733F36112126197800988727 /* Debug */ = { + 7358E6C0210BD318002323EB /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 4F943DB9A7237917709622D5 /* Pods-WordPressNotificationContentExtension.debug.xcconfig */; + baseConfigurationReference = A27DA63A5ECD1D0262C589EC /* Pods-WordPressNotificationServiceExtension.debug.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -13830,7 +26898,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = WordPressNotificationContentExtension/WordPressNotificationContentExtension.entitlements; + CODE_SIGN_ENTITLEMENTS = WordPressNotificationServiceExtension/WordPressNotificationServiceExtension.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -13844,16 +26912,19 @@ ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = WordPressNotificationContentExtension/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + INFOPLIST_FILE = WordPressNotificationServiceExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "WordPress Notification Content Extension Dev"; + PROVISIONING_PROFILE_SPECIFIER = "WordPress Notification Service Extension Dev"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationContentExtension/Sources/WordPressNotificationContentExtension-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -13861,9 +26932,9 @@ }; name = Debug; }; - 733F36122126197800988727 /* Release */ = { + 7358E6C1210BD318002323EB /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 48690E659987FD4472EEDE5F /* Pods-WordPressNotificationContentExtension.release.xcconfig */; + baseConfigurationReference = 73FEFF1991AE9912FB2DA9BC /* Pods-WordPressNotificationServiceExtension.release.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -13876,8 +26947,8 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = WordPressNotificationContentExtension/WordPressNotificationContentExtension.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CODE_SIGN_ENTITLEMENTS = WordPressNotificationServiceExtension/WordPressNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = PZYM8XX95Q; @@ -13890,24 +26961,27 @@ ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = WordPressNotificationContentExtension/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + INFOPLIST_FILE = WordPressNotificationServiceExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress.WordPressNotificationContentExtension"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress.WordPressNotificationServiceExtension"; SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationContentExtension/Sources/WordPressNotificationContentExtension-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; WPCOM_SCHEME = wordpress; }; name = Release; }; - 733F36132126197800988727 /* Release-Internal */ = { + 7358E6C2210BD318002323EB /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F262DFCA1F94418CE76D1D78 /* Pods-WordPressNotificationContentExtension.release-internal.xcconfig */; + baseConfigurationReference = 33D5016BDA00B45DFCAF3818 /* Pods-WordPressNotificationServiceExtension.release-internal.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -13920,7 +26994,7 @@ CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "WordPressNotificationContentExtension/WordPressNotificationContentExtension-Internal.entitlements"; + CODE_SIGN_ENTITLEMENTS = "WordPressNotificationServiceExtension/WordPressNotificationServiceExtension-Internal.entitlements"; CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -13934,250 +27008,576 @@ ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = "WordPressNotificationContentExtension/Info-Internal.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + INFOPLIST_FILE = "WordPressNotificationServiceExtension/Info-Internal.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.internal.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressNotificationContentExtension"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressNotificationServiceExtension"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wpinternal; + }; + name = "Release-Internal"; + }; + 7358E6C3210BD318002323EB /* Release-Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = EEF80689364FA9CAE10405E8 /* Pods-WordPressNotificationServiceExtension.release-alpha.xcconfig */; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressNotificationServiceExtension/WordPressNotificationServiceExtension-Alpha.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = "WordPressNotificationServiceExtension/Info-Alpha.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.alpha.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressNotificationServiceExtension"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wpalpha; + }; + name = "Release-Alpha"; + }; + 7457667D202B558C00F42E40 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 09F367D2BE684EDE2E4A40E3 /* Pods-WordPressDraftActionExtension.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = "extension-icon"; + CLANG_ANALYZER_NONNULL = YES_NONAGGRESSIVE; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = NO; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES; + CODE_SIGN_ENTITLEMENTS = WordPressDraftActionExtension/WordPressDraftActionExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEVELOPMENT_TEAM = PZYM8XX95Q; + ENABLE_BITCODE = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = WordPressDraftActionExtension/WordPressDraftPrefix.pch; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + INFOPLIST_FILE = WordPressDraftActionExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = YES; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressDraftAction; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = "03b7c1c5-4846-447e-a75c-4005a8e52a68"; + PROVISIONING_PROFILE_SPECIFIER = "WordPress Draft Action Development"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressDraftActionExtension/WordPressDraftActionExtension-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wpdebug; + }; + name = Debug; + }; + 7457667E202B558C00F42E40 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8DE205D2AC15F16289E7D21A /* Pods-WordPressDraftActionExtension.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = "extension-icon"; + CLANG_ANALYZER_NONNULL = YES_NONAGGRESSIVE; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = NO; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES; + CODE_SIGN_ENTITLEMENTS = WordPressDraftActionExtension/WordPressDraftActionExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = PZYM8XX95Q; + ENABLE_BITCODE = NO; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = WordPressDraftActionExtension/WordPressDraftPrefix.pch; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + INFOPLIST_FILE = WordPressDraftActionExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressDraftAction; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = "3dd880ae-7e5a-4858-ab4e-6e482e856041"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress.WordPressDraftAction"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressDraftActionExtension/WordPressDraftActionExtension-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wordpress; + }; + name = Release; + }; + 7457667F202B558C00F42E40 /* Release-Internal */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CDA9AED50FDA27959A5CD1B2 /* Pods-WordPressDraftActionExtension.release-internal.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = "extension-icon"; + CLANG_ANALYZER_NONNULL = YES_NONAGGRESSIVE; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = NO; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES; + CODE_SIGN_ENTITLEMENTS = "WordPressDraftActionExtension/WordPressDraftActionExtension-Internal.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_BITCODE = NO; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = WordPressDraftActionExtension/WordPressDraftPrefix.pch; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + INTERNAL_BUILD, + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + INFOPLIST_FILE = "WordPressDraftActionExtension/Info-Internal.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.internal.WordPressDraftAction; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = "0bba6da4-494e-4f1b-b195-dd5f6e31c8b4"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressDraftAction"; SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationContentExtension/Sources/WordPressNotificationContentExtension-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressDraftActionExtension/WordPressDraftActionExtension-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; WPCOM_SCHEME = wpinternal; }; name = "Release-Internal"; }; - 733F36142126197800988727 /* Release-Alpha */ = { + 74576680202B558C00F42E40 /* Release-Alpha */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E1FD527F8A6BBCC512ECAAC9 /* Pods-WordPressNotificationContentExtension.release-alpha.xcconfig */; + baseConfigurationReference = F75F3A68DCE524B4BAFCE76E /* Pods-WordPressDraftActionExtension.release-alpha.xcconfig */; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = "extension-icon"; + CLANG_ANALYZER_NONNULL = YES_NONAGGRESSIVE; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = NO; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "WordPressNotificationContentExtension/WordPressNotificationContentExtension-Alpha.entitlements"; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES; + CODE_SIGN_ENTITLEMENTS = "WordPressDraftActionExtension/WordPressDraftActionExtension-Alpha.entitlements"; CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_BITCODE = NO; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = WordPressDraftActionExtension/WordPressDraftPrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - "COCOAPODS=1", + ALPHA_BUILD, "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = "WordPressNotificationContentExtension/Info-Alpha.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + INFOPLIST_FILE = "WordPressDraftActionExtension/Info-Alpha.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.alpha.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.alpha.WordPressDraftAction; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressNotificationContentExtension"; + PROVISIONING_PROFILE = "81837878-384c-45d9-909a-8c2c56a2ce58"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressDraftAction"; SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationContentExtension/Sources/WordPressNotificationContentExtension-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressDraftActionExtension/WordPressDraftActionExtension-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; WPCOM_SCHEME = wpalpha; }; name = "Release-Alpha"; }; - 7358E6C0210BD318002323EB /* Debug */ = { + 8096211F28E540D700940A5D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A27DA63A5ECD1D0262C589EC /* Pods-WordPressNotificationServiceExtension.debug.xcconfig */; + baseConfigurationReference = F85B762A18D018C22DF2A40D /* Pods-JetpackShareExtension.debug.xcconfig */; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + BUILD_SCHEME = Jetpack; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = WordPressNotificationServiceExtension/WordPressNotificationServiceExtension.entitlements; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = WordPressShareExtension/WordPressShare.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; + DEVELOPMENT_TEAM = PZYM8XX95Q; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PZYM8XX95Q; + ENABLE_BITCODE = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = WordPressShareExtension/WordPressSharePrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - "COCOAPODS=1", - "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", "DEBUG=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = WordPressNotificationServiceExtension/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/Headers/Public\"", + "\"${PODS_ROOT}/Headers/Public/Crashlytics\"", + "\"${PODS_ROOT}/Headers/Public/Fabric\"", + "\"${PODS_ROOT}/Headers/Public/GoogleSignIn\"", + "\"${PODS_ROOT}/Headers/Public/HockeySDK\"", + "\"$(SDKROOT)/usr/include/libxml2/\"", + ); + INFOPLIST_FILE = JetpackShareExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "$(inherited)", + ); MTL_ENABLE_DEBUG_INFO = YES; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.$(PRODUCT_NAME:rfc1034identifier)"; + OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackShare; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "WordPress Notification Service Extension Dev"; + PROVISIONING_PROFILE_SPECIFIER = "Jetpack iOS Development Share Extension"; SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressShareExtension/WordPressShare-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpdebug; + WPCOM_SCHEME = jpdebug; }; name = Debug; }; - 7358E6C1210BD318002323EB /* Release */ = { + 8096212028E540D700940A5D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 73FEFF1991AE9912FB2DA9BC /* Pods-WordPressNotificationServiceExtension.release.xcconfig */; + baseConfigurationReference = 7EC2116478565023EDB57703 /* Pods-JetpackShareExtension.release.xcconfig */; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + BUILD_SCHEME = Jetpack; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = WordPressNotificationServiceExtension/WordPressNotificationServiceExtension.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = WordPressShareExtension/WordPressShare.entitlements; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = PZYM8XX95Q; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = PZYM8XX95Q; + ENABLE_BITCODE = NO; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = WordPressShareExtension/WordPressSharePrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - "COCOAPODS=1", "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = WordPressNotificationServiceExtension/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/Headers/Public\"", + "\"${PODS_ROOT}/Headers/Public/Crashlytics\"", + "\"${PODS_ROOT}/Headers/Public/Fabric\"", + "\"${PODS_ROOT}/Headers/Public/GoogleSignIn\"", + "\"${PODS_ROOT}/Headers/Public/HockeySDK\"", + "\"$(SDKROOT)/usr/include/libxml2/\"", + ); + INFOPLIST_FILE = JetpackShareExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "$(inherited)", + ); MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackShare; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress.WordPressNotificationServiceExtension"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.automattic.jetpack.JetpackShare"; SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressShareExtension/WordPressShare-Bridging-Header.h"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wordpress; + WPCOM_SCHEME = jetpack; }; name = Release; }; - 7358E6C2210BD318002323EB /* Release-Internal */ = { + 8096212128E540D700940A5D /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 33D5016BDA00B45DFCAF3818 /* Pods-WordPressNotificationServiceExtension.release-internal.xcconfig */; + baseConfigurationReference = 293E283D7339E7B6D13F6E09 /* Pods-JetpackShareExtension.release-internal.xcconfig */; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + BUILD_SCHEME = Jetpack; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "WordPressNotificationServiceExtension/WordPressNotificationServiceExtension-Internal.entitlements"; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = "WordPressShareExtension/WordPressShare-Internal.entitlements"; CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99KV9Z6BKV; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 99KV9Z6BKV; + ENABLE_BITCODE = NO; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = WordPressShareExtension/WordPressSharePrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - "COCOAPODS=1", + INTERNAL_BUILD, "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = "WordPressNotificationServiceExtension/Info-Internal.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/Headers/Public\"", + "\"${PODS_ROOT}/Headers/Public/Crashlytics\"", + "\"${PODS_ROOT}/Headers/Public/Fabric\"", + "\"${PODS_ROOT}/Headers/Public/GoogleSignIn\"", + "\"${PODS_ROOT}/Headers/Public/HockeySDK\"", + "\"$(SDKROOT)/usr/include/libxml2/\"", + ); + INFOPLIST_FILE = "JetpackShareExtension/Info-Internal.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "$(inherited)", + ); MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.internal.$(PRODUCT_NAME:rfc1034identifier)"; + OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D INTERNAL_BUILD"; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.internal.JetpackShare; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressNotificationServiceExtension"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.internal.JetpackShare"; SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressShareExtension/WordPressShare-Bridging-Header.h"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpinternal; + WPCOM_SCHEME = jpinternal; }; name = "Release-Internal"; }; - 7358E6C3210BD318002323EB /* Release-Alpha */ = { + 8096212228E540D700940A5D /* Release-Alpha */ = { isa = XCBuildConfiguration; - baseConfigurationReference = EEF80689364FA9CAE10405E8 /* Pods-WordPressNotificationServiceExtension.release-alpha.xcconfig */; + baseConfigurationReference = C7AEA9D1F1AC3F501B6DE0C8 /* Pods-JetpackShareExtension.release-alpha.xcconfig */; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + BUILD_SCHEME = Jetpack; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "WordPressNotificationServiceExtension/WordPressNotificationServiceExtension-Alpha.entitlements"; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ENTITLEMENTS = "WordPressShareExtension/WordPressShare-Alpha.entitlements"; CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99KV9Z6BKV; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 99KV9Z6BKV; + ENABLE_BITCODE = NO; ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = WordPressShareExtension/WordPressSharePrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - "COCOAPODS=1", + ALPHA_BUILD, "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = "WordPressNotificationServiceExtension/Info-Alpha.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/Headers/Public\"", + "\"${PODS_ROOT}/Headers/Public/Crashlytics\"", + "\"${PODS_ROOT}/Headers/Public/Fabric\"", + "\"${PODS_ROOT}/Headers/Public/GoogleSignIn\"", + "\"${PODS_ROOT}/Headers/Public/HockeySDK\"", + "\"$(SDKROOT)/usr/include/libxml2/\"", + ); + INFOPLIST_FILE = "JetpackShareExtension/Info-Alpha.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "$(inherited)", + ); MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.alpha.$(PRODUCT_NAME:rfc1034identifier)"; + OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D ALPHA_BUILD -D INTERNAL_BUILD"; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.alpha.JetpackShare; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressNotificationServiceExtension"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.alpha.JetpackShare"; SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressShareExtension/WordPressShare-Bridging-Header.h"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpalpha; + WPCOM_SCHEME = jpalpha; }; name = "Release-Alpha"; }; - 7457667D202B558C00F42E40 /* Debug */ = { + 8096218128E55C9400940A5D /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 712D0A17BC83B9730BD7CCC0 /* Pods-WordPressDraftActionExtension.debug.xcconfig */; + baseConfigurationReference = 9149D34BF5182F360C84EDB9 /* Pods-JetpackDraftActionExtension.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - APPLICATION_EXTENSION_API_ONLY = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = "extension-icon"; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = "jp-extension-icon"; + BUILD_SCHEME = Jetpack; CLANG_ANALYZER_NONNULL = YES_NONAGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; @@ -14206,31 +27606,35 @@ ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES; - INFOPLIST_FILE = WordPressDraftActionExtension/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + INFOPLIST_FILE = JetpackDraftActionExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressDraftAction; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackDraftAction; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = "03b7c1c5-4846-447e-a75c-4005a8e52a68"; - PROVISIONING_PROFILE_SPECIFIER = "WordPress Draft Action Development"; + PROVISIONING_PROFILE_SPECIFIER = "Jetpack iOS Development Draft Action Extensions"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OBJC_BRIDGING_HEADER = "WordPressDraftActionExtension/WordPressDraftActionExtension-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpdebug; + WPCOM_SCHEME = jpdebug; }; name = Debug; }; - 7457667E202B558C00F42E40 /* Release */ = { + 8096218228E55C9400940A5D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = B60BB199C4D84FFF05C0F97E /* Pods-WordPressDraftActionExtension.release.xcconfig */; + baseConfigurationReference = CE5249687F020581B14F4172 /* Pods-JetpackDraftActionExtension.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - APPLICATION_EXTENSION_API_ONLY = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = "extension-icon"; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = "jp-extension-icon"; + BUILD_SCHEME = Jetpack; CLANG_ANALYZER_NONNULL = YES_NONAGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; @@ -14242,7 +27646,7 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNGUARDED_AVAILABILITY = YES; CODE_SIGN_ENTITLEMENTS = WordPressDraftActionExtension/WordPressDraftActionExtension.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -14258,29 +27662,33 @@ ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES; - INFOPLIST_FILE = WordPressDraftActionExtension/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + INFOPLIST_FILE = JetpackDraftActionExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressDraftAction; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackDraftAction; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = "3dd880ae-7e5a-4858-ab4e-6e482e856041"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress.WordPressDraftAction"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.automattic.jetpack.JetpackDraftAction"; SKIP_INSTALL = YES; SWIFT_OBJC_BRIDGING_HEADER = "WordPressDraftActionExtension/WordPressDraftActionExtension-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wordpress; + WPCOM_SCHEME = jetpack; }; name = Release; }; - 7457667F202B558C00F42E40 /* Release-Internal */ = { + 8096218328E55C9400940A5D /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 61923FEDAFD6503928030311 /* Pods-WordPressDraftActionExtension.release-internal.xcconfig */; + baseConfigurationReference = 5E48AA7F709A5B0F2318A7E3 /* Pods-JetpackDraftActionExtension.release-internal.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - APPLICATION_EXTENSION_API_ONLY = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = "extension-icon"; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = "jp-extension-icon"; + BUILD_SCHEME = Jetpack; CLANG_ANALYZER_NONNULL = YES_NONAGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; @@ -14309,29 +27717,33 @@ ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES; - INFOPLIST_FILE = "WordPressDraftActionExtension/Info-Internal.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + INFOPLIST_FILE = "JetpackDraftActionExtension/Info-Internal.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.internal.WordPressDraftAction; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.internal.JetpackDraftAction; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = "0bba6da4-494e-4f1b-b195-dd5f6e31c8b4"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressDraftAction"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.internal.JetpackDraftAction"; SKIP_INSTALL = YES; SWIFT_OBJC_BRIDGING_HEADER = "WordPressDraftActionExtension/WordPressDraftActionExtension-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpinternal; + WPCOM_SCHEME = jpinternal; }; name = "Release-Internal"; }; - 74576680202B558C00F42E40 /* Release-Alpha */ = { + 8096218428E55C9400940A5D /* Release-Alpha */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 3B28C7D4D65D0CA6AB9FCFDC /* Pods-WordPressDraftActionExtension.release-alpha.xcconfig */; + baseConfigurationReference = C5E82422F47D9BF7E682262B /* Pods-JetpackDraftActionExtension.release-alpha.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - APPLICATION_EXTENSION_API_ONLY = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = "extension-icon"; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = "jp-extension-icon"; + BUILD_SCHEME = Jetpack; CLANG_ANALYZER_NONNULL = YES_NONAGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; @@ -14355,30 +27767,227 @@ GCC_PREFIX_HEADER = WordPressDraftActionExtension/WordPressDraftPrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - INTERNAL_BUILD, + ALPHA_BUILD, "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES; - INFOPLIST_FILE = "WordPressDraftActionExtension/Info-Alpha.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + INFOPLIST_FILE = "JetpackDraftActionExtension/Info-Alpha.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.alpha.WordPressDraftAction; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.alpha.JetpackDraftAction; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = "81837878-384c-45d9-909a-8c2c56a2ce58"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressDraftAction"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.alpha.JetpackDraftAction"; SKIP_INSTALL = YES; SWIFT_OBJC_BRIDGING_HEADER = "WordPressDraftActionExtension/WordPressDraftActionExtension-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpalpha; + WPCOM_SCHEME = jpalpha; + }; + name = "Release-Alpha"; + }; + 80F6D05028EE866A00953C1A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5165A9AB08C676380FA34 /* Pods-JetpackNotificationServiceExtension.debug.xcconfig */; + buildSettings = { + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WordPressNotificationServiceExtension/WordPressNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + "DEBUG=1", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = JetpackNotificationServiceExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = YES; + PRODUCT_BUNDLE_IDENTIFIER = "com.automattic.jetpack.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "Jetpack iOS Development Notification Service Ext."; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jpdebug; + }; + name = Debug; + }; + 80F6D05128EE866A00953C1A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 49E3445F1B568603958DA79D /* Pods-JetpackNotificationServiceExtension.release.xcconfig */; + buildSettings = { + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WordPressNotificationServiceExtension/WordPressNotificationServiceExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = PZYM8XX95Q; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = JetpackNotificationServiceExtension/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = "com.automattic.jetpack.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.automattic.jetpack.$(PRODUCT_NAME:rfc1034identifier)"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jetpack; + }; + name = Release; + }; + 80F6D05228EE866A00953C1A /* Release-Internal */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D01FA8A28AD63D2600800134 /* Pods-JetpackNotificationServiceExtension.release-internal.xcconfig */; + buildSettings = { + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressNotificationServiceExtension/WordPressNotificationServiceExtension-Internal.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = "JetpackNotificationServiceExtension/Info-Internal.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = "com.jetpack.internal.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.internal.$(PRODUCT_NAME:rfc1034identifier)"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jpinternal; + }; + name = "Release-Internal"; + }; + 80F6D05328EE866A00953C1A /* Release-Alpha */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3C8DE270EF0498A2129349B0 /* Pods-JetpackNotificationServiceExtension.release-alpha.xcconfig */; + buildSettings = { + BUILD_SCHEME = Jetpack; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = "WordPressNotificationServiceExtension/WordPressNotificationServiceExtension-Alpha.entitlements"; + CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 99KV9Z6BKV; + ENABLE_NS_ASSERTIONS = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + INFOPLIST_FILE = "JetpackNotificationServiceExtension/Info-Alpha.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = "com.jetpack.alpha.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.alpha.$(PRODUCT_NAME:rfc1034identifier)"; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressNotificationServiceExtension/Sources/WordPressNotificationServiceExtension-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = jpalpha; }; name = "Release-Alpha"; }; 8511CFBD1C607A7000B7CEED /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 659BB95E3808B6781E2D8D85 /* Pods-WordPressScreenshotGeneration.debug.xcconfig */; + baseConfigurationReference = C8FC2DE857126670AE377B5D /* Pods-WordPressScreenshotGeneration.debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; @@ -14394,8 +28003,10 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; @@ -14411,23 +28022,26 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; INFOPLIST_FILE = WordPressScreenshotGeneration/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressScreenshotGeneration; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = WordPress; USES_XCTRUNNER = YES; - WIREMOCK_HOST = localhost; - WIREMOCK_PORT = 8282; }; name = Debug; }; 8511CFBE1C607A7000B7CEED /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 968136B9BC83DFA8E463D5E4 /* Pods-WordPressScreenshotGeneration.release.xcconfig */; + baseConfigurationReference = DB915AD54243A8AE0039B0C7 /* Pods-WordPressScreenshotGeneration.release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; @@ -14443,8 +28057,10 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; @@ -14455,22 +28071,25 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; INFOPLIST_FILE = WordPressScreenshotGeneration/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressScreenshotGeneration; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = WordPress; USES_XCTRUNNER = YES; - WIREMOCK_HOST = localhost; - WIREMOCK_PORT = 8282; }; name = Release; }; 8511CFBF1C607A7000B7CEED /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E9BAA39DBCC9B24D1C785074 /* Pods-WordPressScreenshotGeneration.release-internal.xcconfig */; + baseConfigurationReference = B5AF0C63305888C3424155D6 /* Pods-WordPressScreenshotGeneration.release-internal.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; @@ -14498,22 +28117,23 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; INFOPLIST_FILE = WordPressScreenshotGeneration/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressScreenshotGeneration; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = WordPress; USES_XCTRUNNER = YES; - WIREMOCK_HOST = localhost; - WIREMOCK_PORT = 8282; }; name = "Release-Internal"; }; 8511CFC01C607A7000B7CEED /* Release-Alpha */ = { isa = XCBuildConfiguration; - baseConfigurationReference = AC6024D92F44AE4CB4A8C1B3 /* Pods-WordPressScreenshotGeneration.release-alpha.xcconfig */; + baseConfigurationReference = 528B9926294302CD0A4EB5C4 /* Pods-WordPressScreenshotGeneration.release-alpha.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; APPLICATION_EXTENSION_API_ONLY = NO; @@ -14542,16 +28162,17 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; INFOPLIST_FILE = WordPressScreenshotGeneration/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressScreenshotGeneration; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = WordPress; USES_XCTRUNNER = YES; - WIREMOCK_HOST = localhost; - WIREMOCK_PORT = 8282; }; name = "Release-Alpha"; }; @@ -14574,6 +28195,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -14585,13 +28207,13 @@ GCC_C_LANGUAGE_STANDARD = c99; GCC_NO_COMMON_BLOCKS = YES; GCC_THUMB_SUPPORT = NO; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; OTHER_CFLAGS = ( "-Wno-incomplete-umbrella", "-Wno-format-security", @@ -14602,7 +28224,9 @@ ); PRODUCT_MODULE_NAME = "$(PRODUCT_NAME:c99extidentifier)"; SDKROOT = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; WPCOM_CONFIG = "$(SRCROOT)/../.configure-files/wpcom_alpha_app_credentials"; @@ -14611,7 +28235,7 @@ }; 8546B4441BEAD39700193C07 /* Release-Alpha */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F373612EEEEF10E500093FF3 /* Pods-WordPress.release-alpha.xcconfig */; + baseConfigurationReference = F373612EEEEF10E500093FF3 /* Pods-Apps-WordPress.release-alpha.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; APPLICATION_EXTENSION_API_ONLY = NO; @@ -14629,24 +28253,25 @@ GCC_PREFIX_HEADER = WordPress_Prefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - INTERNAL_BUILD, "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ALPHA_BUILD, ); GCC_SYMBOLS_PRIVATE_EXTERN = NO; GCC_THUMB_SUPPORT = NO; - GCC_TREAT_WARNINGS_AS_ERRORS = NO; GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; HEADER_SEARCH_PATHS = ( "$(inherited)", /usr/include/libxml2, ); INFOPLIST_FILE = "WordPress-Alpha-Info.plist"; - INFOPLIST_PREFIX_HEADER = "InfoPlist-alpha.h"; INFOPLIST_PREPROCESS = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + JP_SCHEME = jpalpha; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = "$(VERSION_SHORT)"; OTHER_CFLAGS = ( "$(inherited)", "-Wno-format-security", @@ -14664,8 +28289,9 @@ PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.alpha; PRODUCT_NAME = WordPress; PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha"; - SWIFT_INCLUDE_PATHS = ""; SWIFT_OBJC_BRIDGING_HEADER = "Classes/System/WordPress-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "$(SWIFT_MODULE_NAME)-Swift-XcodeGenerated.h"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; WPCOM_SCHEME = wpalpha; @@ -14688,106 +28314,41 @@ GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES; HEADER_SEARCH_PATHS = ( "$(inherited)", - "\"${PODS_ROOT}/Headers/Public\"", - "\"${PODS_ROOT}/Headers/Public/Crashlytics\"", - "\"${PODS_ROOT}/Headers/Public/Fabric\"", - "\"${PODS_ROOT}/Headers/Public/HockeySDK\"", - "$(SDKROOT)/usr/include/libxml2", - "$(TARGET_TEMP_DIR)/../$(PROJECT_NAME).build/DerivedSources", - ); - INFOPLIST_FILE = "WordPressTest/WordPressTest-Info.plist"; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.${PRODUCT_NAME:rfc1034identifier}"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressTest/WordPressTest-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUNDLE_LOADER)"; - }; - name = "Release-Alpha"; - }; - 8546B4461BEAD39700193C07 /* Release-Alpha */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = YES; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = "Release-Alpha"; - }; - 8546B4471BEAD39700193C07 /* Release-Alpha */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 0F01F606071B0B9BD9DB34DE /* Pods-WordPressTodayWidget.release-alpha.xcconfig */; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - ALWAYS_SEARCH_USER_PATHS = NO; - APPLICATION_EXTENSION_API_ONLY = NO; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = "$(inherited)"; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_ENTITLEMENTS = "WordPressTodayWidget/WordPressTodayWidget-Alpha.entitlements"; - CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; - COPY_PHASE_STRIP = NO; - DEVELOPMENT_TEAM = 99KV9Z6BKV; - ENABLE_BITCODE = NO; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - FRAMEWORK_SEARCH_PATHS = "$(inherited)"; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressTodayWidget/TodayWidgetPrefix.pch; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "COCOAPODS=1", - INTERNAL_BUILD, - "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + "\"${PODS_ROOT}/Headers/Public\"", + "\"${PODS_ROOT}/Headers/Public/Crashlytics\"", + "\"${PODS_ROOT}/Headers/Public/Fabric\"", + "\"${PODS_ROOT}/Headers/Public/HockeySDK\"", + "$(SDKROOT)/usr/include/libxml2", + "$(TARGET_TEMP_DIR)/../$(PROJECT_NAME).build/DerivedSources", ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - INFOPLIST_FILE = "WordPressTodayWidget/Info-Alpha.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.alpha.$(PRODUCT_NAME:rfc1034identifier)"; + INFOPLIST_FILE = "WordPressTest/WordPressTest-Info.plist"; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.${PRODUCT_NAME:rfc1034identifier}"; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressTodayWidget"; - SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressTodayWidget/WordPressTodayWidget-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressTest/WordPressTest-Bridging-Header.h"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpalpha; + TEST_HOST = "$(BUNDLE_LOADER)"; }; name = "Release-Alpha"; }; - 8546B4491BEAD39700193C07 /* Release-Alpha */ = { + 8546B4461BEAD39700193C07 /* Release-Alpha */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; CLANG_ENABLE_OBJC_WEAK = YES; + GCC_GENERATE_TEST_COVERAGE_FILES = NO; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = "Release-Alpha"; }; 932225B21C7CE50400443B02 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 2262D835FA89938EBF63EADF /* Pods-WordPressShareExtension.debug.xcconfig */; + baseConfigurationReference = CB1DAFB7DE085F2FF0314622 /* Pods-WordPressShareExtension.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; ALWAYS_SEARCH_USER_PATHS = NO; - APPLICATION_EXTENSION_API_ONLY = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = ""; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; @@ -14804,6 +28365,7 @@ CODE_SIGN_ENTITLEMENTS = WordPressShareExtension/WordPressShare.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; + DEVELOPMENT_TEAM = PZYM8XX95Q; ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; @@ -14832,12 +28394,15 @@ "\"$(SDKROOT)/usr/include/libxml2/\"", ); INFOPLIST_FILE = WordPressShareExtension/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks $(inherited)"; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "$(inherited)", + ); MTL_ENABLE_DEBUG_INFO = YES; + OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressShare; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "WordPress Share Development"; + PROVISIONING_PROFILE_SPECIFIER = "WordPress Share Extension Development"; SKIP_INSTALL = YES; SWIFT_OBJC_BRIDGING_HEADER = "WordPressShareExtension/WordPressShare-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -14848,12 +28413,12 @@ }; 932225B31C7CE50400443B02 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = CDF46FFC7F78DB8FEB56E6F9 /* Pods-WordPressShareExtension.release.xcconfig */; + baseConfigurationReference = 32387A1D541851E82ED957CE /* Pods-WordPressShareExtension.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; ALWAYS_SEARCH_USER_PATHS = NO; - APPLICATION_EXTENSION_API_ONLY = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = ""; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; @@ -14868,7 +28433,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_ENTITLEMENTS = WordPressShareExtension/WordPressShare.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; COPY_PHASE_STRIP = NO; DEVELOPMENT_TEAM = PZYM8XX95Q; ENABLE_BITCODE = NO; @@ -14897,8 +28462,10 @@ "\"$(SDKROOT)/usr/include/libxml2/\"", ); INFOPLIST_FILE = WordPressShareExtension/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks $(inherited)"; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "$(inherited)", + ); MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressShare; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -14912,12 +28479,12 @@ }; 932225B41C7CE50400443B02 /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C9BDC428A69177C2CBF0C247 /* Pods-WordPressShareExtension.release-internal.xcconfig */; + baseConfigurationReference = C82B4C5ECF11C9FEE39CD9A0 /* Pods-WordPressShareExtension.release-internal.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; ALWAYS_SEARCH_USER_PATHS = NO; - APPLICATION_EXTENSION_API_ONLY = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = ""; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; @@ -14962,9 +28529,12 @@ "\"$(SDKROOT)/usr/include/libxml2/\"", ); INFOPLIST_FILE = "WordPressShareExtension/Info-Internal.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks $(inherited)"; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "$(inherited)", + ); MTL_ENABLE_DEBUG_INFO = NO; + OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D INTERNAL_BUILD"; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.internal.WordPressShare; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressShare"; @@ -14977,12 +28547,12 @@ }; 932225B51C7CE50400443B02 /* Release-Alpha */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D61CEAC1CB25AE65B26BDC68 /* Pods-WordPressShareExtension.release-alpha.xcconfig */; + baseConfigurationReference = 1BC96E982E9B1A6DD86AF491 /* Pods-WordPressShareExtension.release-alpha.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; ALWAYS_SEARCH_USER_PATHS = NO; - APPLICATION_EXTENSION_API_ONLY = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + APPLICATION_EXTENSION_API_ONLY = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = ""; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; @@ -15009,7 +28579,7 @@ GCC_PREFIX_HEADER = WordPressShareExtension/WordPressSharePrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", - INTERNAL_BUILD, + ALPHA_BUILD, "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -15027,9 +28597,12 @@ "\"$(SDKROOT)/usr/include/libxml2/\"", ); INFOPLIST_FILE = "WordPressShareExtension/Info-Alpha.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks $(inherited)"; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "$(inherited)", + ); MTL_ENABLE_DEBUG_INFO = NO; + OTHER_SWIFT_FLAGS = "$(inherited) -D COCOAPODS -D ALPHA_BUILD -D INTERNAL_BUILD"; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.alpha.WordPressShare; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressShare"; @@ -15059,6 +28632,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; @@ -15070,13 +28644,13 @@ GCC_C_LANGUAGE_STANDARD = c99; GCC_NO_COMMON_BLOCKS = YES; GCC_THUMB_SUPPORT = NO; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; OTHER_CFLAGS = ( "-Wno-incomplete-umbrella", "-Wno-format-security", @@ -15087,7 +28661,9 @@ ); PRODUCT_MODULE_NAME = "$(PRODUCT_NAME:c99extidentifier)"; SDKROOT = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; WPCOM_CONFIG = "$(SRCROOT)/../.configure-files/wpcom_internal_app_credentials"; @@ -15096,11 +28672,11 @@ }; 93DEAA9E182D567A004E34D1 /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = BBA98A42A5503D734AC9C936 /* Pods-WordPress.release-internal.xcconfig */; + baseConfigurationReference = BBA98A42A5503D734AC9C936 /* Pods-Apps-WordPress.release-internal.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; APPLICATION_EXTENSION_API_ONLY = NO; - ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-Internal"; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = "$(inherited)"; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN__ARC_BRIDGE_CAST_NONARC = NO; @@ -15120,46 +28696,268 @@ ); GCC_SYMBOLS_PRIVATE_EXTERN = NO; GCC_THUMB_SUPPORT = NO; - GCC_TREAT_WARNINGS_AS_ERRORS = NO; GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; HEADER_SEARCH_PATHS = ( "$(inherited)", /usr/include/libxml2, ); INFOPLIST_FILE = "WordPress-Internal-Info.plist"; - INFOPLIST_PREFIX_HEADER = "InfoPlist-internal.h"; INFOPLIST_PREPROCESS = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + JP_SCHEME = jpinternal; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = "$(VERSION_SHORT)"; + OTHER_CFLAGS = ( + "$(inherited)", + "-Wno-format-security", + "-Wno-format", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-l\"c++\"", + "-l\"iconv\"", + "-l\"sqlite3.0\"", + "-l\"z\"", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D INTERNAL_BUILD -D APPCENTER_ENABLED"; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.internal; + PRODUCT_NAME = WordPress; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal"; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/System/WordPress-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "$(SWIFT_MODULE_NAME)-Swift-XcodeGenerated.h"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + WPCOM_SCHEME = wpinternal; + }; + name = "Release-Internal"; + }; + 93DEAAA0182D567A004E34D1 /* Release-Internal */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C9264D275F6288F66C33D2CE /* Pods-WordPressTest.release-internal.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/WordPress.app/WordPress"; + CLANG_ENABLE_MODULES = "$(inherited)"; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = "WordPressTest/WordPressTest-Prefix.pch"; + GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; + GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "\"${PODS_ROOT}/Headers/Public\"", + "\"${PODS_ROOT}/Headers/Public/Crashlytics\"", + "\"${PODS_ROOT}/Headers/Public/Fabric\"", + "\"${PODS_ROOT}/Headers/Public/HockeySDK\"", + "$(SDKROOT)/usr/include/libxml2", + "$(TARGET_TEMP_DIR)/../$(PROJECT_NAME).build/DerivedSources", + ); + INFOPLIST_FILE = "WordPressTest/WordPressTest-Info.plist"; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.${PRODUCT_NAME:rfc1034identifier}"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressTest/WordPressTest-Bridging-Header.h"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUNDLE_LOADER)"; + }; + name = "Release-Internal"; + }; + A2795808198819DE0031C6A3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = YES; + GCC_GENERATE_TEST_COVERAGE_FILES = NO; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + A2795809198819DE0031C6A3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = YES; + GCC_GENERATE_TEST_COVERAGE_FILES = NO; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; + A279580A198819DE0031C6A3 /* Release-Internal */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ENABLE_OBJC_WEAK = YES; + GCC_GENERATE_TEST_COVERAGE_FILES = NO; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = "Release-Internal"; + }; + C01FCF4F08A954540054247B /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F14B5F70208E648200439554 /* WordPress.debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = PZYM8XX95Q; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = c99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_THUMB_SUPPORT = NO; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = ( + "-Wno-incomplete-umbrella", + "-Wno-format-security", + "-DDEBUG", + ); + OTHER_LDFLAGS = ( + "-lxml2", + "-licucore", + ); + PRODUCT_MODULE_NAME = "$(PRODUCT_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 5.0; + WPCOM_CONFIG = "$(SRCROOT)/../.configure-files/wpcom_app_credentials"; + }; + name = Debug; + }; + C01FCF5008A954540054247B /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F14B5F71208E648200439554 /* WordPress.release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = PZYM8XX95Q; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = c99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_THUMB_SUPPORT = NO; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; OTHER_CFLAGS = ( + "-Wno-incomplete-umbrella", + "-Wno-format-security", + ); + OTHER_LDFLAGS = ( + "-lxml2", + "-licucore", + ); + PRODUCT_MODULE_NAME = "$(PRODUCT_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + WPCOM_CONFIG = "$(SRCROOT)/../.configure-files/wpcom_app_credentials"; + }; + name = Release; + }; + E16AB93914D978240047A2E5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 084FF460C7742309671B3A86 /* Pods-WordPressTest.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/WordPress.app/WordPress"; + CLANG_ENABLE_MODULES = "$(inherited)"; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PRECOMPILE_PREFIX_HEADER = YES; + GCC_PREFIX_HEADER = "WordPressTest/WordPressTest-Prefix.pch"; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", "$(inherited)", - "-Wno-format-security", - "-Wno-format", ); - OTHER_LDFLAGS = ( + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; + GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES; + HEADER_SEARCH_PATHS = ( "$(inherited)", - "-ObjC", - "-l\"c++\"", - "-l\"iconv\"", - "-l\"sqlite3.0\"", - "-l\"z\"", + "\"${PODS_ROOT}/Headers/Public\"", + "\"${PODS_ROOT}/Headers/Public/Crashlytics\"", + "\"${PODS_ROOT}/Headers/Public/Fabric\"", + "\"${PODS_ROOT}/Headers/Public/HockeySDK\"", + "$(SDKROOT)/usr/include/libxml2", + "$(TARGET_TEMP_DIR)/../$(PROJECT_NAME).build/DerivedSources", ); - OTHER_SWIFT_FLAGS = "$(inherited) -D INTERNAL_BUILD -D APPCENTER_ENABLED"; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.internal; - PRODUCT_NAME = WordPress; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal"; - SWIFT_INCLUDE_PATHS = ""; - SWIFT_OBJC_BRIDGING_HEADER = "Classes/System/WordPress-Bridging-Header.h"; + INFOPLIST_FILE = "WordPressTest/WordPressTest-Info.plist"; + PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.${PRODUCT_NAME:rfc1034identifier}"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressTest/WordPressTest-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpinternal; + TEST_HOST = "$(BUNDLE_LOADER)"; }; - name = "Release-Internal"; + name = Debug; }; - 93DEAAA0182D567A004E34D1 /* Release-Internal */ = { + E16AB93A14D978240047A2E5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C9264D275F6288F66C33D2CE /* Pods-WordPressTest.release-internal.xcconfig */; + baseConfigurationReference = EF379F0A70B6AC45330EE287 /* Pods-WordPressTest.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/WordPress.app/WordPress"; @@ -15184,21 +28982,20 @@ PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.${PRODUCT_NAME:rfc1034identifier}"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "WordPressTest/WordPressTest-Bridging-Header.h"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUNDLE_LOADER)"; }; - name = "Release-Internal"; + name = Release; }; - 93E5284919A7741A003A1A9C /* Debug */ = { + EA14533E29AD874C001F3143 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 368127CE6F1CA15EC198147D /* Pods-WordPressTodayWidget.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - ALWAYS_SEARCH_USER_PATHS = NO; - APPLICATION_EXTENSION_API_ONLY = NO; + CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = "$(inherited)"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; @@ -15209,54 +29006,49 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_ENTITLEMENTS = WordPressTodayWidget/WordPressTodayWidget.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; - ENABLE_BITCODE = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; ENABLE_STRICT_OBJC_MSGSEND = YES; - FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressTodayWidget/TodayWidgetPrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", - "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); - GCC_SYMBOLS_PRIVATE_EXTERN = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; - INFOPLIST_FILE = WordPressTodayWidget/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + INFOPLIST_FILE = "UITests/JetpackUITests-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = YES; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackUITests; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "WordPress Today Widget Development"; - SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressTodayWidget/WordPressTodayWidget-Bridging-Header.h"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpdebug; + TEST_TARGET_NAME = Jetpack; }; name = Debug; }; - 93E5284A19A7741A003A1A9C /* Release */ = { + EA14533F29AD874C001F3143 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 37E3F5EA2ED7005A5E0BBEC3 /* Pods-WordPressTodayWidget.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - ALWAYS_SEARCH_USER_PATHS = NO; - APPLICATION_EXTENSION_API_ONLY = NO; + CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = "$(inherited)"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; @@ -15267,52 +29059,43 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_ENTITLEMENTS = WordPressTodayWidget/WordPressTodayWidget.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; - DEVELOPMENT_TEAM = PZYM8XX95Q; - ENABLE_BITCODE = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressTodayWidget/TodayWidgetPrefix.pch; - GCC_PREPROCESSOR_DEFINITIONS = ( - "$(inherited)", - "COCOAPODS=1", - "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", - ); + GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; - INFOPLIST_FILE = WordPressTodayWidget/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + INFOPLIST_FILE = "UITests/JetpackUITests-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackUITests; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress.WordPressTodayWidget"; - SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressTodayWidget/WordPressTodayWidget-Bridging-Header.h"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wordpress; + TEST_TARGET_NAME = Jetpack; }; name = Release; }; - 93E5284B19A7741A003A1A9C /* Release-Internal */ = { + EA14534029AD874C001F3143 /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 99D675225C3282AC88B89F04 /* Pods-WordPressTodayWidget.release-internal.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - ALWAYS_SEARCH_USER_PATHS = NO; - APPLICATION_EXTENSION_API_ONLY = NO; + CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = "$(inherited)"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; @@ -15323,59 +29106,90 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_ENTITLEMENTS = "WordPressTodayWidget/WordPressTodayWidget-Internal.entitlements"; - CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; COPY_PHASE_STRIP = NO; - DEVELOPMENT_TEAM = 99KV9Z6BKV; - ENABLE_BITCODE = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressTodayWidget/TodayWidgetPrefix.pch; - GCC_PREPROCESSOR_DEFINITIONS = ( + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + INFOPLIST_FILE = "UITests/JetpackUITests-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "COCOAPODS=1", - INTERNAL_BUILD, - "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + "@executable_path/Frameworks", + "@loader_path/Frameworks", ); + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Jetpack; + }; + name = "Release-Internal"; + }; + EA14534129AD874C001F3143 /* Release-Alpha */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; - INFOPLIST_FILE = "WordPressTodayWidget/Info-Internal.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; + INFOPLIST_FILE = "UITests/JetpackUITests-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.internal.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack.JetpackUITests; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressTodayWidget"; - SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressTodayWidget/WordPressTodayWidget-Bridging-Header.h"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpinternal; + TEST_TARGET_NAME = Jetpack; }; - name = "Release-Internal"; + name = "Release-Alpha"; }; - 98A3C2FB239997DB0048D38D /* Debug */ = { + F1F163DD25658B4D003DC13B /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D1216A69ECC61E7772AB8C41 /* Pods-WordPressThisWeekWidget.debug.xcconfig */; + baseConfigurationReference = D67306CD28F2440FF6B0065C /* Pods-WordPressIntents.debug.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = WordPressThisWeekWidget/WordPressThisWeekWidget.entitlements; + CODE_SIGN_ENTITLEMENTS = "WordPressIntents/Supporting Files/WordPressIntents.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; @@ -15383,8 +29197,6 @@ GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressThisWeekWidget/ThisWeekWidgetPrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", @@ -15392,17 +29204,20 @@ ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = WordPressThisWeekWidget/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + INFOPLIST_FILE = "WordPressIntents/Supporting Files/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressThisWeekWidget; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressIntents; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "WordPress This Week Widget Development"; + PROVISIONING_PROFILE_SPECIFIER = "WordPress Intents Extension Development"; SKIP_INSTALL = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressThisWeekWidget/WordPressThisWeekWidget-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressIntents/Supporting Files/WordPressIntents-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -15410,69 +29225,71 @@ }; name = Debug; }; - 98A3C2FC239997DB0048D38D /* Release */ = { + F1F163DE25658B4D003DC13B /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7DFD69454E24EC0A1A0BACCD /* Pods-WordPressThisWeekWidget.release.xcconfig */; + baseConfigurationReference = ADE06D6829F9044164BBA5AB /* Pods-WordPressIntents.release.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = WordPressThisWeekWidget/WordPressThisWeekWidget.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc. (PZYM8XX95Q)"; + CODE_SIGN_ENTITLEMENTS = "WordPressIntents/Supporting Files/WordPressIntents.entitlements"; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = PZYM8XX95Q; ENABLE_NS_ASSERTIONS = NO; GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressThisWeekWidget/ThisWeekWidgetPrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( - "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", "$(inherited)", + "COCOAPODS=1", + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = WordPressThisWeekWidget/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + INFOPLIST_FILE = "WordPressIntents/Supporting Files/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressThisWeekWidget; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressIntents; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress.WordPressThisWeekWidget"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress.WordPressIntents"; SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressThisWeekWidget/WordPressThisWeekWidget-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressIntents/Supporting Files/WordPressIntents-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; WPCOM_SCHEME = wordpress; }; name = Release; }; - 98A3C2FD239997DB0048D38D /* Release-Internal */ = { + F1F163DF25658B4D003DC13B /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 8E65034AFB7DC85D70CCC064 /* Pods-WordPressThisWeekWidget.release-internal.xcconfig */; + baseConfigurationReference = A0D83E08D5D2573348DE8926 /* Pods-WordPressIntents.release-internal.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "WordPressThisWeekWidget/WordPressThisWeekWidget-Internal.entitlements"; + CODE_SIGN_ENTITLEMENTS = "WordPressIntents/Supporting Files/WordPressIntentsRelease-Internal.entitlements"; CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; @@ -15480,47 +29297,49 @@ DEVELOPMENT_TEAM = 99KV9Z6BKV; ENABLE_NS_ASSERTIONS = NO; GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressThisWeekWidget/ThisWeekWidgetPrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( - "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", - INTERNAL_BUILD, "$(inherited)", + "COCOAPODS=1", + INTERNAL_BUILD, + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = WordPressThisWeekWidget/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + INFOPLIST_FILE = "WordPressIntents/Supporting Files/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.internal.WordPressThisWeekWidget; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.internal.WordPressIntents; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressThisWeekWidget"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressIntents"; SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressThisWeekWidget/WordPressThisWeekWidget-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressIntents/Supporting Files/WordPressIntents-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; WPCOM_SCHEME = wpinternal; }; name = "Release-Internal"; }; - 98A3C2FE239997DB0048D38D /* Release-Alpha */ = { + F1F163E025658B4D003DC13B /* Release-Alpha */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 40F031E19B32BAF8654838C0 /* Pods-WordPressThisWeekWidget.release-alpha.xcconfig */; + baseConfigurationReference = 8A21014FBE43ADE551F4ECB4 /* Pods-WordPressIntents.release-alpha.xcconfig */; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "WordPressThisWeekWidget/WordPressThisWeekWidget-Alpha.entitlements"; + CODE_SIGN_ENTITLEMENTS = "WordPressIntents/Supporting Files/WordPressIntentsRelease-Alpha.entitlements"; CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; @@ -15528,440 +29347,500 @@ DEVELOPMENT_TEAM = 99KV9Z6BKV; ENABLE_NS_ASSERTIONS = NO; GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressThisWeekWidget/ThisWeekWidgetPrefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( - "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", - INTERNAL_BUILD, "$(inherited)", + "COCOAPODS=1", + ALPHA_BUILD, + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", ); GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = WordPressThisWeekWidget/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + INFOPLIST_FILE = "WordPressIntents/Supporting Files/Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.alpha.WordPressThisWeekWidget; + PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.alpha.WordPressIntents; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressThisWeekWidget"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressIntents"; SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressThisWeekWidget/WordPressThisWeekWidget-Bridging-Header.h"; + SWIFT_OBJC_BRIDGING_HEADER = "WordPressIntents/Supporting Files/WordPressIntents-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; WPCOM_SCHEME = wpalpha; }; name = "Release-Alpha"; }; - 98D31B9A2396ED7F009CFF43 /* Debug */ = { + FABB264E2602FC2C00C8785C /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 0EAD25F1E69B6B91783C4963 /* Pods-WordPressAllTimeWidget.debug.xcconfig */; + baseConfigurationReference = 24350E7C264DB76E009BB2B6 /* Jetpack.debug.xcconfig */; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + APPLICATION_EXTENSION_API_ONLY = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = "$(inherited)"; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = WordPressAllTimeWidget/WordPressAllTimeWidget.entitlements; + CLANG_WARN__ARC_BRIDGE_CAST_NONARC = NO; + CODE_SIGN_ENTITLEMENTS = Jetpack/JetpackDebug.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = "${VERSION_LONG}"; + DEFINES_MODULE = YES; DEVELOPMENT_TEAM = PZYM8XX95Q; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; GCC_OPTIMIZATION_LEVEL = 0; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressAllTimeWidget/AllTimeWidgetPrefix.pch; + GCC_PREFIX_HEADER = WordPress_Prefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", "$(inherited)", + "COCOAPODS=1", + "NSLOGGER_BUILD_USERNAME=\"${USER}\"", "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + "JETPACK=1", ); - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = WordPressAllTimeWidget/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressAllTimeWidget; + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_THUMB_SUPPORT = NO; + GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + /usr/include/libxml2, + ); + INFOPLIST_FILE = Jetpack/Info.plist; + INFOPLIST_PREPROCESS = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = "${VERSION_SHORT}"; + OTHER_CFLAGS = ( + "$(inherited)", + "-Wno-format-security", + "-DDEBUG", + "-Wno-format", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-l\"c++\"", + "-l\"iconv\"", + "-l\"sqlite3.0\"", + "-l\"z\"", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D DEBUG -D JETPACK"; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack; + PRODUCT_MODULE_NAME = WordPress; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "WordPress All Time Widget Development"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressAllTimeWidget/WordPressAllTimeWidget-Bridging-Header.h"; + PROVISIONING_PROFILE_SPECIFIER = "Jetpack iOS Development"; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/System/WordPress-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "$(SWIFT_MODULE_NAME)-Swift-XcodeGenerated.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpdebug; + WPCOM_SCHEME = jpdebug; }; name = Debug; }; - 98D31B9B2396ED7F009CFF43 /* Release */ = { + FABB264F2602FC2C00C8785C /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 6A9F41AAF18FB527262CC57C /* Pods-WordPressAllTimeWidget.release.xcconfig */; + baseConfigurationReference = 24351059264DC1E2009BB2B6 /* Jetpack.release.xcconfig */; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + APPLICATION_EXTENSION_API_ONLY = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = "$(inherited)"; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = WordPressAllTimeWidget/WordPressAllTimeWidget.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc. (PZYM8XX95Q)"; - CODE_SIGN_STYLE = Manual; + CLANG_WARN__ARC_BRIDGE_CAST_NONARC = NO; + CODE_SIGN_ENTITLEMENTS = Jetpack/JetpackRelease.entitlements; + CODE_SIGN_IDENTITY = "Apple Distribution: Automattic, Inc. (PZYM8XX95Q)"; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + CURRENT_PROJECT_VERSION = "${VERSION_LONG}"; + DEFINES_MODULE = YES; DEVELOPMENT_TEAM = PZYM8XX95Q; - ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressAllTimeWidget/AllTimeWidgetPrefix.pch; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + GCC_PREFIX_HEADER = WordPress_Prefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", + NS_BLOCK_ASSERTIONS, "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + "JETPACK=1", ); - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = WordPressAllTimeWidget/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressAllTimeWidget; + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_THUMB_SUPPORT = NO; + GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + /usr/include/libxml2, + ); + INFOPLIST_FILE = Jetpack/Info.plist; + INFOPLIST_PREPROCESS = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = "${VERSION_SHORT}"; + OTHER_CFLAGS = ( + "$(inherited)", + "-Wno-format-security", + "-Wno-format", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-l\"c++\"", + "-l\"iconv\"", + "-l\"sqlite3.0\"", + "-l\"z\"", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D JETPACK"; + PRODUCT_BUNDLE_IDENTIFIER = com.automattic.jetpack; + PRODUCT_MODULE_NAME = WordPress; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match AppStore org.wordpress.WordPressAllTimeWidget"; - SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressAllTimeWidget/WordPressAllTimeWidget-Bridging-Header.h"; + PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.automattic.jetpack"; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/System/WordPress-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "$(SWIFT_MODULE_NAME)-Swift-XcodeGenerated.h"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wordpress; + WPCOM_SCHEME = jetpack; }; name = Release; }; - 98D31B9C2396ED7F009CFF43 /* Release-Internal */ = { + FABB26502602FC2C00C8785C /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 41341F0000A9A65A3326F2EC /* Pods-WordPressAllTimeWidget.release-internal.xcconfig */; + baseConfigurationReference = 243511A4264DC2D9009BB2B6 /* Jetpack.internal.xcconfig */; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + APPLICATION_EXTENSION_API_ONLY = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = "$(inherited)"; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "WordPressAllTimeWidget/WordPressAllTimeWidget-Internal.entitlements"; + CLANG_WARN__ARC_BRIDGE_CAST_NONARC = NO; + CODE_SIGN_ENTITLEMENTS = "Jetpack/JetpackRelease-Internal.entitlements"; CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; - CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + CURRENT_PROJECT_VERSION = "${VERSION_LONG}"; + DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 99KV9Z6BKV; - ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressAllTimeWidget/AllTimeWidgetPrefix.pch; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + GCC_PREFIX_HEADER = WordPress_Prefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( - "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + "$(inherited)", INTERNAL_BUILD, + "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + APPCENTER_ENABLED, + "JETPACK=1", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_THUMB_SUPPORT = NO; + GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; + HEADER_SEARCH_PATHS = ( "$(inherited)", + /usr/include/libxml2, ); - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = "WordPressAllTimeWidget/Info-Internal.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.internal.WordPressAllTimeWidget; + INFOPLIST_FILE = Jetpack/Info.plist; + INFOPLIST_PREPROCESS = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = "${VERSION_SHORT}"; + OTHER_CFLAGS = ( + "$(inherited)", + "-Wno-format-security", + "-Wno-format", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-l\"c++\"", + "-l\"iconv\"", + "-l\"sqlite3.0\"", + "-l\"z\"", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D INTERNAL_BUILD -D APPCENTER_ENABLED -D JETPACK"; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.internal; + PRODUCT_MODULE_NAME = WordPress; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.internal.WordPressAllTimeWidget"; - SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressAllTimeWidget/WordPressAllTimeWidget-Bridging-Header.h"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.internal"; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/System/WordPress-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "$(SWIFT_MODULE_NAME)-Swift-XcodeGenerated.h"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpinternal; + WPCOM_SCHEME = jpinternal; }; name = "Release-Internal"; }; - 98D31B9D2396ED7F009CFF43 /* Release-Alpha */ = { + FABB26512602FC2C00C8785C /* Release-Alpha */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E5B3F5F559826C230DB0DCDD /* Pods-WordPressAllTimeWidget.release-alpha.xcconfig */; + baseConfigurationReference = 2439B1DB264ECBDF00239130 /* Jetpack.alpha.xcconfig */; buildSettings = { - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; + APPLICATION_EXTENSION_API_ONLY = NO; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = "$(inherited)"; CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "WordPressAllTimeWidget/WordPressAllTimeWidget-Alpha.entitlements"; + CLANG_WARN__ARC_BRIDGE_CAST_NONARC = NO; + CODE_SIGN_ENTITLEMENTS = "Jetpack/JetpackRelease-Alpha.entitlements"; CODE_SIGN_IDENTITY = "iPhone Distribution: Automattic, Inc."; - CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + CURRENT_PROJECT_VERSION = "${VERSION_LONG}"; + DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 99KV9Z6BKV; - ENABLE_NS_ASSERTIONS = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = WordPressAllTimeWidget/AllTimeWidgetPrefix.pch; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + GCC_PREFIX_HEADER = WordPress_Prefix.pch; GCC_PREPROCESSOR_DEFINITIONS = ( - INTERNAL_BUILD, + "$(inherited)", "WPCOM_SCHEME=\\@\\\"${WPCOM_SCHEME}\\\"", + ALPHA_BUILD, + "JETPACK=1", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_THUMB_SUPPORT = NO; + GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; + HEADER_SEARCH_PATHS = ( "$(inherited)", + /usr/include/libxml2, ); - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - INFOPLIST_FILE = "WordPressAllTimeWidget/Info-Alpha.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.alpha.WordPressAllTimeWidget; + INFOPLIST_FILE = Jetpack/Info.plist; + INFOPLIST_PREPROCESS = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + MARKETING_VERSION = "${VERSION_SHORT}"; + OTHER_CFLAGS = ( + "$(inherited)", + "-Wno-format-security", + "-Wno-format", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-l\"c++\"", + "-l\"iconv\"", + "-l\"sqlite3.0\"", + "-l\"z\"", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D ALPHA_BUILD -D INTERNAL_BUILD -D JETPACK"; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.alpha; + PRODUCT_MODULE_NAME = WordPress; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "match InHouse org.wordpress.alpha.WordPressAllTimeWidget"; - SKIP_INSTALL = YES; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressAllTimeWidget/WordPressAllTimeWidget-Bridging-Header.h"; + PROVISIONING_PROFILE_SPECIFIER = "match InHouse com.jetpack.alpha"; + SWIFT_OBJC_BRIDGING_HEADER = "Classes/System/WordPress-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "$(SWIFT_MODULE_NAME)-Swift-XcodeGenerated.h"; + SWIFT_TREAT_WARNINGS_AS_ERRORS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - WPCOM_SCHEME = wpalpha; + WPCOM_SCHEME = jpalpha; }; name = "Release-Alpha"; }; - A2795808198819DE0031C6A3 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = YES; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - A2795809198819DE0031C6A3 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = YES; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; - A279580A198819DE0031C6A3 /* Release-Internal */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = YES; - GCC_GENERATE_TEST_COVERAGE_FILES = NO; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = "Release-Internal"; - }; - C01FCF4F08A954540054247B /* Debug */ = { + FAF64B9E2637DEEC00E8A1DF /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F14B5F70208E648200439554 /* WordPress.debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = PZYM8XX95Q; + DEVELOPMENT_TEAM = ""; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = c99; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; - GCC_PREPROCESSOR_DEFINITIONS = ""; - GCC_THUMB_SUPPORT = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - ONLY_ACTIVE_ARCH = YES; - OTHER_CFLAGS = ( - "-Wno-incomplete-umbrella", - "-Wno-format-security", - "-DDEBUG", - ); - OTHER_LDFLAGS = ( - "-lxml2", - "-licucore", + INFOPLIST_FILE = JetpackScreenshotGeneration/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", ); - PRODUCT_MODULE_NAME = "$(PRODUCT_NAME:c99extidentifier)"; - SDKROOT = iphoneos; + MTL_ENABLE_DEBUG_INFO = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.JetpackScreenshotGeneration; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - WPCOM_CONFIG = "$(SRCROOT)/../.configure-files/wpcom_app_credentials"; + TEST_TARGET_NAME = Jetpack; + USES_XCTRUNNER = YES; }; name = Debug; }; - C01FCF5008A954540054247B /* Release */ = { + FAF64B9F2637DEEC00E8A1DF /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F14B5F71208E648200439554 /* WordPress.release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = PZYM8XX95Q; + CODE_SIGN_STYLE = Manual; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; + ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = c99; + GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; - GCC_THUMB_SUPPORT = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; - OTHER_CFLAGS = ( - "-Wno-incomplete-umbrella", - "-Wno-format-security", - ); - OTHER_LDFLAGS = ( - "-lxml2", - "-licucore", + INFOPLIST_FILE = JetpackScreenshotGeneration/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", ); - PRODUCT_MODULE_NAME = "$(PRODUCT_NAME:c99extidentifier)"; - SDKROOT = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.JetpackScreenshotGeneration; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE = ""; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; SWIFT_VERSION = 5.0; - VALIDATE_PRODUCT = YES; - WPCOM_CONFIG = "$(SRCROOT)/../.configure-files/wpcom_app_credentials"; + TEST_TARGET_NAME = Jetpack; + USES_XCTRUNNER = YES; }; name = Release; }; - E16AB93914D978240047A2E5 /* Debug */ = { + FAF64BA02637DEEC00E8A1DF /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 084FF460C7742309671B3A86 /* Pods-WordPressTest.debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/WordPress.app/WordPress"; - CLANG_ENABLE_MODULES = "$(inherited)"; + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = "WordPressTest/WordPressTest-Prefix.pch"; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_SYMBOLS_PRIVATE_EXTERN = NO; - GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; - GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES; - HEADER_SEARCH_PATHS = ( + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + INFOPLIST_FILE = JetpackScreenshotGeneration/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "\"${PODS_ROOT}/Headers/Public\"", - "\"${PODS_ROOT}/Headers/Public/Crashlytics\"", - "\"${PODS_ROOT}/Headers/Public/Fabric\"", - "\"${PODS_ROOT}/Headers/Public/HockeySDK\"", - "$(SDKROOT)/usr/include/libxml2", - "$(TARGET_TEMP_DIR)/../$(PROJECT_NAME).build/DerivedSources", + "@executable_path/Frameworks", + "@loader_path/Frameworks", ); - INFOPLIST_FILE = "WordPressTest/WordPressTest-Info.plist"; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.${PRODUCT_NAME:rfc1034identifier}"; + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.JetpackScreenshotGeneration; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressTest/WordPressTest-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + PROVISIONING_PROFILE = ""; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUNDLE_LOADER)"; + TEST_TARGET_NAME = Jetpack; + USES_XCTRUNNER = YES; }; - name = Debug; + name = "Release-Internal"; }; - E16AB93A14D978240047A2E5 /* Release */ = { + FAF64BA12637DEEC00E8A1DF /* Release-Alpha */ = { isa = XCBuildConfiguration; - baseConfigurationReference = EF379F0A70B6AC45330EE287 /* Pods-WordPressTest.release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; - BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/WordPress.app/WordPress"; - CLANG_ENABLE_MODULES = "$(inherited)"; + ALWAYS_SEARCH_USER_PATHS = NO; + APPLICATION_EXTENSION_API_ONLY = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; - COPY_PHASE_STRIP = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = "WordPressTest/WordPressTest-Prefix.pch"; - GCC_WARN_ABOUT_DEPRECATED_FUNCTIONS = YES; - GCC_WARN_ABOUT_MISSING_PROTOTYPES = YES; - HEADER_SEARCH_PATHS = ( + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + INFOPLIST_FILE = JetpackScreenshotGeneration/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", - "\"${PODS_ROOT}/Headers/Public\"", - "\"${PODS_ROOT}/Headers/Public/Crashlytics\"", - "\"${PODS_ROOT}/Headers/Public/Fabric\"", - "\"${PODS_ROOT}/Headers/Public/HockeySDK\"", - "$(SDKROOT)/usr/include/libxml2", - "$(TARGET_TEMP_DIR)/../$(PROJECT_NAME).build/DerivedSources", + "@executable_path/Frameworks", + "@loader_path/Frameworks", ); - INFOPLIST_FILE = "WordPressTest/WordPressTest-Info.plist"; - PRODUCT_BUNDLE_IDENTIFIER = "org.wordpress.${PRODUCT_NAME:rfc1034identifier}"; + MTL_ENABLE_DEBUG_INFO = NO; + PRODUCT_BUNDLE_IDENTIFIER = com.jetpack.JetpackScreenshotGeneration; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "WordPressTest/WordPressTest-Bridging-Header.h"; + PROVISIONING_PROFILE = ""; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUNDLE_LOADER)"; + TEST_TARGET_NAME = Jetpack; + USES_XCTRUNNER = YES; }; - name = Release; + name = "Release-Alpha"; }; FF2716961CAAC87B0006E2D4 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 27AE66536E0C5378203F9312 /* Pods-WordPressUITests.debug.xcconfig */; + baseConfigurationReference = CB72288DBD93471DA0AD878A /* Pods-WordPressUITests.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; CLANG_ANALYZER_NONNULL = YES; @@ -15978,8 +29857,10 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ""; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; @@ -15994,23 +29875,26 @@ GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; - INFOPLIST_FILE = WordPressUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + INFOPLIST_FILE = "UITests/WordPressUITests-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressUITests; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = WordPress; - WIREMOCK_HOST = localhost; - WIREMOCK_PORT = 8282; }; name = Debug; }; FF2716971CAAC87B0006E2D4 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = B787A850C6163034630E7AF2 /* Pods-WordPressUITests.release.xcconfig */; + baseConfigurationReference = 8B9E15DAF3E1A369E9BE3407 /* Pods-WordPressUITests.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; CLANG_ANALYZER_NONNULL = YES; @@ -16027,8 +29911,10 @@ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ""; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; @@ -16038,22 +29924,25 @@ GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; - INFOPLIST_FILE = WordPressUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + INFOPLIST_FILE = "UITests/WordPressUITests-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressUITests; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = WordPress; - WIREMOCK_HOST = localhost; - WIREMOCK_PORT = 8282; }; name = Release; }; FF2716981CAAC87B0006E2D4 /* Release-Internal */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 10BBFF34EBDA2F9B8CB48DF6 /* Pods-WordPressUITests.release-internal.xcconfig */; + baseConfigurationReference = B8EED8FA87452C1FD113FD04 /* Pods-WordPressUITests.release-internal.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; CLANG_ANALYZER_NONNULL = YES; @@ -16081,22 +29970,23 @@ GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; - INFOPLIST_FILE = WordPressUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + INFOPLIST_FILE = "UITests/WordPressUITests-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = WordPress; - WIREMOCK_HOST = localhost; - WIREMOCK_PORT = 8282; }; name = "Release-Internal"; }; FF2716991CAAC87B0006E2D4 /* Release-Alpha */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1B77149F6C65D343E7E3AD09 /* Pods-WordPressUITests.release-alpha.xcconfig */; + baseConfigurationReference = 5C1CEB34870A8BA1ED1E502B /* Pods-WordPressUITests.release-alpha.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; CLANG_ANALYZER_NONNULL = YES; @@ -16124,16 +30014,17 @@ GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; - INFOPLIST_FILE = WordPressUITests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; + INFOPLIST_FILE = "UITests/WordPressUITests-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = org.wordpress.WordPressUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_TARGET_NAME = WordPress; - WIREMOCK_HOST = localhost; - WIREMOCK_PORT = 8282; }; name = "Release-Alpha"; }; @@ -16173,36 +30064,42 @@ }; name = "Release-Alpha"; }; - FFC3F6F61B0DBF1000EFC359 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - FFC3F6F71B0DBF1000EFC359 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; - FFC3F6F81B0DBF1000EFC359 /* Release-Internal */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO; - CLANG_ENABLE_OBJC_WEAK = YES; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = "Release-Internal"; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 0107E0E528F97D5000DE87DB /* Build configuration list for PBXNativeTarget "JetpackStatsWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0107E0E628F97D5000DE87DB /* Debug */, + 0107E0E728F97D5000DE87DB /* Release */, + 0107E0E828F97D5000DE87DB /* Release-Internal */, + 0107E0E928F97D5000DE87DB /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 0107E14F28FE9DB200DE87DB /* Build configuration list for PBXNativeTarget "JetpackIntents" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0107E15028FE9DB200DE87DB /* Debug */, + 0107E15128FE9DB200DE87DB /* Release */, + 0107E15228FE9DB200DE87DB /* Release-Internal */, + 0107E15328FE9DB200DE87DB /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 096A92FA26E2A00000448C68 /* Build configuration list for PBXAggregateTarget "GenerateCredentials" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 096A92F626E2A00000448C68 /* Debug */, + 096A92F726E2A00000448C68 /* Release */, + 096A92F826E2A00000448C68 /* Release-Internal */, + 096A92F926E2A00000448C68 /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 1D6058960D05DD3E006BFB54 /* Build configuration list for PBXNativeTarget "WordPress" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -16214,6 +30111,28 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 3F526C612538CF2C0069706C /* Build configuration list for PBXNativeTarget "WordPressStatsWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3F526C5D2538CF2C0069706C /* Debug */, + 3F526C5E2538CF2C0069706C /* Release */, + 3F526C5F2538CF2C0069706C /* Release-Internal */, + 3F526C602538CF2C0069706C /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3FA640602670CCD40064401E /* Build configuration list for PBXNativeTarget "UITestsFoundation" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3FA6405C2670CCD40064401E /* Debug */, + 3FA6405D2670CCD40064401E /* Release */, + 3FA6405E2670CCD40064401E /* Release-Internal */, + 3FA6405F2670CCD40064401E /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 733F36152126197800988727 /* Build configuration list for PBXNativeTarget "WordPressNotificationContentExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -16247,57 +30166,57 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 8511CFC21C607A7000B7CEED /* Build configuration list for PBXNativeTarget "WordPressScreenshotGeneration" */ = { + 8096211E28E540D700940A5D /* Build configuration list for PBXNativeTarget "JetpackShareExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( - 8511CFBD1C607A7000B7CEED /* Debug */, - 8511CFBE1C607A7000B7CEED /* Release */, - 8511CFBF1C607A7000B7CEED /* Release-Internal */, - 8511CFC01C607A7000B7CEED /* Release-Alpha */, + 8096211F28E540D700940A5D /* Debug */, + 8096212028E540D700940A5D /* Release */, + 8096212128E540D700940A5D /* Release-Internal */, + 8096212228E540D700940A5D /* Release-Alpha */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 932225B71C7CE50400443B02 /* Build configuration list for PBXNativeTarget "WordPressShareExtension" */ = { + 8096218028E55C9400940A5D /* Build configuration list for PBXNativeTarget "JetpackDraftActionExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( - 932225B21C7CE50400443B02 /* Debug */, - 932225B31C7CE50400443B02 /* Release */, - 932225B41C7CE50400443B02 /* Release-Internal */, - 932225B51C7CE50400443B02 /* Release-Alpha */, + 8096218128E55C9400940A5D /* Debug */, + 8096218228E55C9400940A5D /* Release */, + 8096218328E55C9400940A5D /* Release-Internal */, + 8096218428E55C9400940A5D /* Release-Alpha */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 93E5284D19A7741A003A1A9C /* Build configuration list for PBXNativeTarget "WordPressTodayWidget" */ = { + 80F6D04F28EE866A00953C1A /* Build configuration list for PBXNativeTarget "JetpackNotificationServiceExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( - 93E5284919A7741A003A1A9C /* Debug */, - 93E5284A19A7741A003A1A9C /* Release */, - 93E5284B19A7741A003A1A9C /* Release-Internal */, - 8546B4471BEAD39700193C07 /* Release-Alpha */, + 80F6D05028EE866A00953C1A /* Debug */, + 80F6D05128EE866A00953C1A /* Release */, + 80F6D05228EE866A00953C1A /* Release-Internal */, + 80F6D05328EE866A00953C1A /* Release-Alpha */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 98A3C2FF239997DB0048D38D /* Build configuration list for PBXNativeTarget "WordPressThisWeekWidget" */ = { + 8511CFC21C607A7000B7CEED /* Build configuration list for PBXNativeTarget "WordPressScreenshotGeneration" */ = { isa = XCConfigurationList; buildConfigurations = ( - 98A3C2FB239997DB0048D38D /* Debug */, - 98A3C2FC239997DB0048D38D /* Release */, - 98A3C2FD239997DB0048D38D /* Release-Internal */, - 98A3C2FE239997DB0048D38D /* Release-Alpha */, + 8511CFBD1C607A7000B7CEED /* Debug */, + 8511CFBE1C607A7000B7CEED /* Release */, + 8511CFBF1C607A7000B7CEED /* Release-Internal */, + 8511CFC01C607A7000B7CEED /* Release-Alpha */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 98D31B9E2396ED7F009CFF43 /* Build configuration list for PBXNativeTarget "WordPressAllTimeWidget" */ = { + 932225B71C7CE50400443B02 /* Build configuration list for PBXNativeTarget "WordPressShareExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( - 98D31B9A2396ED7F009CFF43 /* Debug */, - 98D31B9B2396ED7F009CFF43 /* Release */, - 98D31B9C2396ED7F009CFF43 /* Release-Internal */, - 98D31B9D2396ED7F009CFF43 /* Release-Alpha */, + 932225B21C7CE50400443B02 /* Debug */, + 932225B31C7CE50400443B02 /* Release */, + 932225B41C7CE50400443B02 /* Release-Internal */, + 932225B51C7CE50400443B02 /* Release-Alpha */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -16335,6 +30254,50 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + EA14533D29AD874C001F3143 /* Build configuration list for PBXNativeTarget "JetpackUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EA14533E29AD874C001F3143 /* Debug */, + EA14533F29AD874C001F3143 /* Release */, + EA14534029AD874C001F3143 /* Release-Internal */, + EA14534129AD874C001F3143 /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + F1F163DC25658B4D003DC13B /* Build configuration list for PBXNativeTarget "WordPressIntents" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F1F163DD25658B4D003DC13B /* Debug */, + F1F163DE25658B4D003DC13B /* Release */, + F1F163DF25658B4D003DC13B /* Release-Internal */, + F1F163E025658B4D003DC13B /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FABB264D2602FC2C00C8785C /* Build configuration list for PBXNativeTarget "Jetpack" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FABB264E2602FC2C00C8785C /* Debug */, + FABB264F2602FC2C00C8785C /* Release */, + FABB26502602FC2C00C8785C /* Release-Internal */, + FABB26512602FC2C00C8785C /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + FAF64B9D2637DEEC00E8A1DF /* Build configuration list for PBXNativeTarget "JetpackScreenshotGeneration" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FAF64B9E2637DEEC00E8A1DF /* Debug */, + FAF64B9F2637DEEC00E8A1DF /* Release */, + FAF64BA02637DEEC00E8A1DF /* Release-Internal */, + FAF64BA12637DEEC00E8A1DF /* Release-Alpha */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; FF27169A1CAAC87B0006E2D4 /* Build configuration list for PBXNativeTarget "WordPressUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -16357,28 +30320,161 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - FFC3F6F51B0DBF1000EFC359 /* Build configuration list for PBXAggregateTarget "UpdatePlistPreprocessor" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - FFC3F6F61B0DBF1000EFC359 /* Debug */, - FFC3F6F71B0DBF1000EFC359 /* Release */, - FFC3F6F81B0DBF1000EFC359 /* Release-Internal */, - 8546B4491BEAD39700193C07 /* Release-Alpha */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 17A8858B2757B97F0071FCA3 /* XCRemoteSwiftPackageReference "AutomatticAbout-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/automattic/AutomatticAbout-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.2; + }; + }; + 3F2B62DA284F4E0B0008CD59 /* XCRemoteSwiftPackageReference "Charts" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/danielgindi/Charts"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 4.0.3; + }; + }; + 3F338B6F289BD3040014ADC5 /* XCRemoteSwiftPackageReference "Nimble" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Quick/Nimble"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 10.0.0; + }; + }; + 3F3B23C02858A1B300CACE60 /* XCRemoteSwiftPackageReference "test-collector-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/buildkite/test-collector-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.3.0; + }; + }; + 3F411B6D28987E3F002513AE /* XCRemoteSwiftPackageReference "lottie-ios.git" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/airbnb/lottie-ios.git"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 3.4.0; + }; + }; + 3FC2C33B26C4CF0A00C6D98F /* XCRemoteSwiftPackageReference "XCUITestHelpers" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Automattic/XCUITestHelpers"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.4.0; + }; + }; + 3FF1442E266F3C2400138163 /* XCRemoteSwiftPackageReference "ScreenObject" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Automattic/ScreenObject"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.2.2; + }; + }; + EA14532629AD874C001F3143 /* XCRemoteSwiftPackageReference "test-collector-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/buildkite/test-collector-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.3.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 17A8858C2757B97F0071FCA3 /* AutomatticAbout */ = { + isa = XCSwiftPackageProductDependency; + package = 17A8858B2757B97F0071FCA3 /* XCRemoteSwiftPackageReference "AutomatticAbout-swift" */; + productName = AutomatticAbout; + }; + 17A8858E2757BEC00071FCA3 /* AutomatticAbout */ = { + isa = XCSwiftPackageProductDependency; + package = 17A8858B2757B97F0071FCA3 /* XCRemoteSwiftPackageReference "AutomatticAbout-swift" */; + productName = AutomatticAbout; + }; + 24CE2EB0258D687A0000C297 /* WordPressFlux */ = { + isa = XCSwiftPackageProductDependency; + productName = WordPressFlux; + }; + 3F2B62DB284F4E0B0008CD59 /* Charts */ = { + isa = XCSwiftPackageProductDependency; + package = 3F2B62DA284F4E0B0008CD59 /* XCRemoteSwiftPackageReference "Charts" */; + productName = Charts; + }; + 3F2B62DD284F4E310008CD59 /* Charts */ = { + isa = XCSwiftPackageProductDependency; + package = 3F2B62DA284F4E0B0008CD59 /* XCRemoteSwiftPackageReference "Charts" */; + productName = Charts; + }; + 3F338B70289BD3040014ADC5 /* Nimble */ = { + isa = XCSwiftPackageProductDependency; + package = 3F338B6F289BD3040014ADC5 /* XCRemoteSwiftPackageReference "Nimble" */; + productName = Nimble; + }; + 3F338B72289BD5970014ADC5 /* Nimble */ = { + isa = XCSwiftPackageProductDependency; + package = 3F338B6F289BD3040014ADC5 /* XCRemoteSwiftPackageReference "Nimble" */; + productName = Nimble; + }; + 3F3B23C12858A1B300CACE60 /* BuildkiteTestCollector */ = { + isa = XCSwiftPackageProductDependency; + package = 3F3B23C02858A1B300CACE60 /* XCRemoteSwiftPackageReference "test-collector-swift" */; + productName = BuildkiteTestCollector; + }; + 3F3B23C32858A1D800CACE60 /* BuildkiteTestCollector */ = { + isa = XCSwiftPackageProductDependency; + package = 3F3B23C02858A1B300CACE60 /* XCRemoteSwiftPackageReference "test-collector-swift" */; + productName = BuildkiteTestCollector; + }; + 3F411B6E28987E3F002513AE /* Lottie */ = { + isa = XCSwiftPackageProductDependency; + package = 3F411B6D28987E3F002513AE /* XCRemoteSwiftPackageReference "lottie-ios.git" */; + productName = Lottie; + }; + 3F44DD57289C379C006334CD /* Lottie */ = { + isa = XCSwiftPackageProductDependency; + package = 3F411B6D28987E3F002513AE /* XCRemoteSwiftPackageReference "lottie-ios.git" */; + productName = Lottie; + }; + 3FC2C33C26C4CF0A00C6D98F /* XCUITestHelpers */ = { + isa = XCSwiftPackageProductDependency; + package = 3FC2C33B26C4CF0A00C6D98F /* XCRemoteSwiftPackageReference "XCUITestHelpers" */; + productName = XCUITestHelpers; + }; + 3FC2C34226C4E8B700C6D98F /* ScreenObject */ = { + isa = XCSwiftPackageProductDependency; + package = 3FF1442E266F3C2400138163 /* XCRemoteSwiftPackageReference "ScreenObject" */; + productName = ScreenObject; + }; + EA14532529AD874C001F3143 /* BuildkiteTestCollector */ = { + isa = XCSwiftPackageProductDependency; + package = EA14532629AD874C001F3143 /* XCRemoteSwiftPackageReference "test-collector-swift" */; + productName = BuildkiteTestCollector; + }; + FABB1FA62602FC2C00C8785C /* WordPressFlux */ = { + isa = XCSwiftPackageProductDependency; + productName = WordPressFlux; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin XCVersionGroup section */ 74FA4BE31FBFA0660031EAAD /* Extensions.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + CB1FD8D926E605CF00EDAF06 /* Extensions 4.xcdatamodel */, 745F7A69204460A10058BF3E /* Extensions 3.xcdatamodel */, 740C7DB420386E7C00FF0229 /* Extensions 2.xcdatamodel */, 74FA4BE41FBFA0660031EAAD /* Extensions.xcdatamodel */, ); - currentVersion = 745F7A69204460A10058BF3E /* Extensions 3.xcdatamodel */; + currentVersion = CB1FD8D926E605CF00EDAF06 /* Extensions 4.xcdatamodel */; path = Extensions.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; @@ -16386,6 +30482,60 @@ E125443B12BF5A7200D87A0A /* WordPress.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + FA98B61329A39DA80071AAE8 /* WordPress 148.xcdatamodel */, + F48D44B7298993900051EAA6 /* WordPress 147.xcdatamodel */, + F40CC35C2954991C00D75A95 /* WordPress 146.xcdatamodel */, + 56FEDB6A28783D8F00E1EA93 /* WordPress 145.xcdatamodel */, + F432964A287752690089C4F7 /* WordPress 144.xcdatamodel */, + 839435922847F2200019A94F /* WordPress 143.xcdatamodel */, + FE32E7F32846A68800744D80 /* WordPress 142.xcdatamodel */, + 837B49C6283C28730061A657 /* WordPress 141.xcdatamodel */, + FE003F54282D48E4006F8D1D /* WordPress 140.xcdatamodel */, + 17870A72281847D500D1C627 /* WordPress 139.xcdatamodel */, + FE59DA9527D1FD0700624D26 /* WordPress 138.xcdatamodel */, + FE97BC13274FCE7A00CF08F9 /* WordPress 137.xcdatamodel */, + FEFC0F872730510F001F7F1D /* WordPress 136.xcdatamodel */, + 17E204C5271DB7620038BC90 /* WordPress 135.xcdatamodel */, + FEB7A8922718852A00A8CF85 /* WordPress 134.xcdatamodel */, + F10D634D26F0B66E00E46CC7 /* WordPress 133.xcdatamodel */, + FA978DDA26CEB37E009FB14F /* WordPress 132.xcdatamodel */, + 98F4044E26BB69A000BBD8B9 /* WordPress 131.xcdatamodel */, + 9872EA6826B9EE38009F795B /* WordPress 130.xcdatamodel */, + 98FBA05426B228CB004E610A /* WordPress 129.xcdatamodel */, + 988AD63726B089CE003552B4 /* WordPress 128.xcdatamodel */, + 46122253268E0416001134D7 /* WordPress 127.xcdatamodel */, + 987044AD268A9A5300BD0571 /* WordPress 126.xcdatamodel */, + E6EE8807266ABE9F009BC219 /* WordPress 125.xcdatamodel */, + 984FB2B22646001E00878DE0 /* WordPress 124.xcdatamodel */, + 9804E0B42639D88C00532095 /* WordPress 123.xcdatamodel */, + 98DD1EF2263337C400CF0440 /* WordPress 122.xcdatamodel */, + C3ABE791263099F7009BD402 /* WordPress 121.xcdatamodel */, + 46F583A42624C8FA0010A723 /* WordPress 120.xcdatamodel */, + C9D7DDBF2613B84500104E95 /* WordPress 119.xcdatamodel */, + 46365555260E1DE5006398E4 /* WordPress 118.xcdatamodel */, + C99B039B2602F3CB00CA71EB /* WordPress 117.xcdatamodel */, + 7D4D980C25FFE7E600C282E6 /* WordPress 116.xcdatamodel */, + CE907AFD25F97D2A007E7654 /* WordPress 115.xcdatamodel */, + E62D4A2425E7FE6600B99550 /* WordPress 114.xcdatamodel */, + E690F6EC25E04EAA0015A777 /* WordPress 113.xcdatamodel */, + 98035B7225C49CC1002C0EB4 /* WordPress 112.xcdatamodel */, + 98991A1125AE653D00B3BBAC /* WordPress 111.xcdatamodel */, + C7F1EB4425A4B845009D1AA2 /* WordPress 110.xcdatamodel */, + CE1392A4258AA9E700B0F945 /* WordPress 109.xcdatamodel */, + 9878876E258823EB0099AD53 /* WordPress 108.xcdatamodel */, + 98BBB642258047DD0084FF72 /* WordPress 107.xcdatamodel */, + CE35F637257EB533007BC329 /* WordPress 106.xcdatamodel */, + 9833A29B257AE7CF006B8234 /* WordPress 105.xcdatamodel */, + 17EFD2D82577B61900AB753C /* WordPress 104.xcdatamodel */, + B06378AE253F619500FD45D2 /* WordPress 103.xcdatamodel */, + 4625BC26253E285700C04AAD /* WordPress 102.xcdatamodel */, + 327282732538BC0900C8076D /* WordPress 101.xcdatamodel */, + B0DDC2EB252F7C4F002BAFB3 /* WordPress 100.xcdatamodel */, + 46183CF1251BD5F1004F9AFD /* WordPress 99.xcdatamodel */, + 8BD8201724BC93B500FF25FD /* WordPress 98.xcdatamodel */, + E687A0AE249AC02400C8BA18 /* WordPress 97.xcdatamodel */, + 3F26DFD124930B5900B5EBD1 /* WordPress 96.xcdatamodel */, + F1BBA95E243BEFC500E9E5E6 /* WordPress 95.xcdatamodel */, 32282CF82390B614003378A7 /* WordPress 94.xcdatamodel */, 32A29A16236BC8BC009488C2 /* WordPress 93.xcdatamodel */, 57CCB37E2358E5DC003ECD0C /* WordPress 92.xcdatamodel */, @@ -16481,7 +30631,7 @@ 8350E15911D28B4A00A7B073 /* WordPress.xcdatamodel */, E125443D12BF5A7200D87A0A /* WordPress 2.xcdatamodel */, ); - currentVersion = 32282CF82390B614003378A7 /* WordPress 94.xcdatamodel */; + currentVersion = FA98B61329A39DA80071AAE8 /* WordPress 148.xcdatamodel */; name = WordPress.xcdatamodeld; path = Classes/WordPress.xcdatamodeld; sourceTree = ""; diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Jetpack.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Jetpack.xcscheme new file mode 100644 index 000000000000..d9bda1c00fd0 --- /dev/null +++ b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/Jetpack.xcscheme @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/JetpackIntents.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/JetpackIntents.xcscheme new file mode 100644 index 000000000000..7d5dba063600 --- /dev/null +++ b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/JetpackIntents.xcscheme @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/JetpackScreenshotGeneration.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/JetpackScreenshotGeneration.xcscheme new file mode 100644 index 000000000000..858124c23309 --- /dev/null +++ b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/JetpackScreenshotGeneration.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/JetpackUITests.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/JetpackUITests.xcscheme new file mode 100644 index 000000000000..140189131499 --- /dev/null +++ b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/JetpackUITests.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/OCLint.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/OCLint.xcscheme index 1c5766f4b088..9a30d38a5164 100644 --- a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/OCLint.xcscheme +++ b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/OCLint.xcscheme @@ -1,6 +1,6 @@ - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPress Alpha.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPress Alpha.xcscheme index 488a7462777a..90ca282ee99e 100644 --- a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPress Alpha.xcscheme +++ b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPress Alpha.xcscheme @@ -1,6 +1,6 @@ + + + + @@ -43,48 +52,7 @@ - - - - - - - - - - - - - - - - - - - - + + + + @@ -43,28 +52,7 @@ - - - - - - - - - - - - - - - - + reference = "container:UITests/WordPressUITests.xctestplan"> @@ -70,66 +56,6 @@ ReferencedContainer = "container:WordPress.xcodeproj"> - - - - - - - - - - - - - - - - - - - - - - - - + + + + @@ -177,6 +111,23 @@ isEnabled = "NO"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressScreenshotGeneration.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressScreenshotGeneration.xcscheme index ed3d72d5b44a..351e277f907c 100644 --- a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressScreenshotGeneration.xcscheme +++ b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressScreenshotGeneration.xcscheme @@ -1,6 +1,6 @@ - - - - diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressShareExtension.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressShareExtension.xcscheme index 302731813000..2bf75da58d54 100644 --- a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressShareExtension.xcscheme +++ b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressShareExtension.xcscheme @@ -1,6 +1,6 @@ + + + + @@ -54,23 +63,13 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressTodayWidget.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressTodayWidget.xcscheme deleted file mode 100644 index 2ba4d70f8bc4..000000000000 --- a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressTodayWidget.xcscheme +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressUITests.xcscheme b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressUITests.xcscheme index f36afdcd2d5a..eafa352a4ed5 100644 --- a/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressUITests.xcscheme +++ b/WordPress/WordPress.xcodeproj/xcshareddata/xcschemes/WordPressUITests.xcscheme @@ -1,6 +1,6 @@ - - - - - - - - Void - private var widgetCompletionBlock: WidgetCompletionBlock? - - // MARK: - View - - override func viewDidLoad() { - super.viewDidLoad() - retrieveSiteConfiguration() - registerTableCells() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - loadSavedData() - setupReachability() - resizeView() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - reachability.stopNotifier() - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - let updatedRowCount = numberOfRowsToDisplay() - - // If the number of rows has not changed, do nothing. - guard updatedRowCount != tableView.numberOfRows(inSection: 0) else { - return - } - - coordinator.animate(alongsideTransition: { _ in - self.tableView.performBatchUpdates({ - - var indexPathsToInsert = [IndexPath]() - var indexPathsToDelete = [IndexPath]() - - // If no connection was displayed, then rows are just being added. - // Otherwise, a data row is being inserted/deleted. - if self.tableView.visibleCells.first is WidgetNoConnectionCell { - let indexRange = (1.. self.minRowsToDisplay() ? - self.tableView.insertRows(at: indexPathsToInsert, with: .fade) : - self.tableView.deleteRows(at: indexPathsToDelete, with: .fade) - }) - }) - } - -} - -// MARK: - Widget Updating - -extension AllTimeViewController: NCWidgetProviding { - - func widgetPerformUpdate(completionHandler: (@escaping (NCUpdateResult) -> Void)) { - widgetCompletionBlock = completionHandler - retrieveSiteConfiguration() - isReachable = reachability.isReachable() - - if !isConfigured || !isReachable { - DDLogError("All Time Widget: unable to update. Configured: \(isConfigured) Reachable: \(isReachable)") - - DispatchQueue.main.async { [weak self] in - self?.tableView.reloadData() - } - - completionHandler(.failed) - return - } - - fetchData(completionHandler: completionHandler) - } - - func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) { - tracks.trackDisplayModeChanged(properties: ["expanded": activeDisplayMode == .expanded]) - resizeView(withMaximumSize: maxSize) - } - -} - -// MARK: - Table View Methods - -extension AllTimeViewController: UITableViewDelegate, UITableViewDataSource { - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return numberOfRowsToDisplay() - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if showNoConnection { - return noConnectionCellFor(indexPath: indexPath) - } - - if !isConfigured || loadingFailed { - return unconfiguredCellFor(indexPath: indexPath) - } - - return statCellFor(indexPath: indexPath) - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - - if failedState, - let maxCompactSize = extensionContext?.widgetMaximumSize(for: .compact) { - // Use the max compact height for unconfigured view. - return maxCompactSize.height - } - - if showUrl() && indexPath.row == numberOfRowsToDisplay() - 1 { - return WidgetUrlCell.height - } - - return UITableView.automaticDimension - } - -} - -// MARK: - Private Extension - -private extension AllTimeViewController { - - // MARK: - Tap Gesture Handling - - @IBAction func handleTapGesture() { - - // If showing the loading failed view, reload the widget. - if loadingFailed, - let completionHandler = widgetCompletionBlock { - widgetPerformUpdate(completionHandler: completionHandler) - return - } - - // Otherwise, open the app. - guard isReachable, - let extensionContext = extensionContext, - let containingAppURL = appURL() else { - DDLogError("All Time Widget: Unable to get extensionContext or appURL.") - return - } - - trackAppLaunch() - extensionContext.open(containingAppURL, completionHandler: nil) - } - - func appURL() -> URL? { - let urlString = (siteID != nil) ? (Constants.statsUrl + siteID!.stringValue) : Constants.baseUrl - return URL(string: urlString) - } - - func trackAppLaunch() { - guard let siteID = siteID else { - tracks.trackExtensionConfigureLaunched() - return - } - - tracks.trackExtensionStatsLaunched(siteID.intValue) - } - - // MARK: - Site Configuration - - func retrieveSiteConfiguration() { - guard let sharedDefaults = UserDefaults(suiteName: WPAppGroupName) else { - DDLogError("All Time Widget: Unable to get sharedDefaults.") - isConfigured = false - return - } - - siteID = sharedDefaults.object(forKey: WPStatsTodayWidgetUserDefaultsSiteIdKey) as? NSNumber - siteUrl = sharedDefaults.string(forKey: WPStatsTodayWidgetUserDefaultsSiteUrlKey) ?? Constants.noDataLabel - oauthToken = fetchOAuthBearerToken() - - if let timeZoneName = sharedDefaults.string(forKey: WPStatsTodayWidgetUserDefaultsSiteTimeZoneKey) { - timeZone = TimeZone(identifier: timeZoneName) - } - - isConfigured = siteID != nil && timeZone != nil && oauthToken != nil - } - - func fetchOAuthBearerToken() -> String? { - let oauth2Token = try? SFHFKeychainUtils.getPasswordForUsername(WPStatsTodayWidgetKeychainTokenKey, andServiceName: WPStatsTodayWidgetKeychainServiceName, accessGroup: WPAppKeychainAccessGroup) - - return oauth2Token as String? - } - - // MARK: - Data Management - - func loadSavedData() { - statsValues = AllTimeWidgetStats.loadSavedData() - } - - func saveData() { - statsValues?.saveData() - } - - func fetchData(completionHandler: (@escaping (NCUpdateResult) -> Void)) { - guard let statsRemote = statsRemote() else { - return - } - - statsRemote.getInsight { [weak self] (allTimesStats: StatsAllTimesInsight?, error) in - self?.loadingFailed = (error != nil) - - if error != nil { - DDLogError("All Time Widget: Error fetching StatsAllTimesInsight: \(String(describing: error?.localizedDescription))") - completionHandler(.failed) - return - } - - DDLogDebug("All Time Widget: Fetched StatsAllTimesInsight data.") - - DispatchQueue.main.async { [weak self] in - let updatedStats = AllTimeWidgetStats(views: allTimesStats?.viewsCount, - visitors: allTimesStats?.visitorsCount, - posts: allTimesStats?.postsCount, - bestViews: allTimesStats?.bestViewsPerDayCount) - - // Update the widget only if the data has changed. - guard updatedStats != self?.statsValues else { - completionHandler(.noData) - return - } - - self?.statsValues = updatedStats - completionHandler(.newData) - self?.saveData() - } - } - } - - func statsRemote() -> StatsServiceRemoteV2? { - guard - let siteID = siteID, - let timeZone = timeZone, - let oauthToken = oauthToken - else { - DDLogError("All Time Widget: Missing site ID, timeZone or oauth2Token") - return nil - } - - let wpApi = WordPressComRestApi(oAuthToken: oauthToken) - return StatsServiceRemoteV2(wordPressComRestApi: wpApi, siteID: siteID.intValue, siteTimezone: timeZone) - } - - // MARK: - Table Helpers - - func registerTableCells() { - let twoColumnCellNib = UINib(nibName: String(describing: WidgetTwoColumnCell.self), bundle: Bundle(for: WidgetTwoColumnCell.self)) - tableView.register(twoColumnCellNib, forCellReuseIdentifier: WidgetTwoColumnCell.reuseIdentifier) - - let unconfiguredCellNib = UINib(nibName: String(describing: WidgetUnconfiguredCell.self), bundle: Bundle(for: WidgetUnconfiguredCell.self)) - tableView.register(unconfiguredCellNib, forCellReuseIdentifier: WidgetUnconfiguredCell.reuseIdentifier) - - let urlCellNib = UINib(nibName: String(describing: WidgetUrlCell.self), bundle: Bundle(for: WidgetUrlCell.self)) - tableView.register(urlCellNib, forCellReuseIdentifier: WidgetUrlCell.reuseIdentifier) - - let noConnectionCellNib = UINib(nibName: String(describing: WidgetNoConnectionCell.self), bundle: Bundle(for: WidgetNoConnectionCell.self)) - tableView.register(noConnectionCellNib, forCellReuseIdentifier: WidgetNoConnectionCell.reuseIdentifier) - } - - func noConnectionCellFor(indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: WidgetNoConnectionCell.reuseIdentifier, for: indexPath) as? WidgetNoConnectionCell else { - return UITableViewCell() - } - - return cell - } - - func unconfiguredCellFor(indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: WidgetUnconfiguredCell.reuseIdentifier, for: indexPath) as? WidgetUnconfiguredCell else { - return UITableViewCell() - } - - loadingFailed ? cell.configure(for: .loadingFailed) : cell.configure(for: .allTime) - return cell - } - - func statCellFor(indexPath: IndexPath) -> UITableViewCell { - - // URL Cell - if showUrl() && indexPath.row == numberOfRowsToDisplay() - 1 { - guard let urlCell = tableView.dequeueReusableCell(withIdentifier: WidgetUrlCell.reuseIdentifier, for: indexPath) as? WidgetUrlCell else { - return UITableViewCell() - } - - urlCell.configure(siteUrl: siteUrl) - return urlCell - } - - // Data Cells - guard let cell = tableView.dequeueReusableCell(withIdentifier: WidgetTwoColumnCell.reuseIdentifier, for: indexPath) as? WidgetTwoColumnCell else { - return UITableViewCell() - } - - if indexPath.row == 0 { - cell.configure(leftItemName: LocalizedText.views, - leftItemData: viewCount, - rightItemName: LocalizedText.visitors, - rightItemData: visitorCount) - } else { - cell.configure(leftItemName: LocalizedText.posts, - leftItemData: postCount, - rightItemName: LocalizedText.bestViews, - rightItemData: bestCount) - } - - return cell - } - - func showUrl() -> Bool { - return (isConfigured && haveSiteUrl) - } - - // MARK: - Expand / Compact View Helpers - - func setAvailableDisplayMode() { - // If something went wrong, don't allow the widget to be expanded. - extensionContext?.widgetLargestAvailableDisplayMode = failedState ? .compact : .expanded - } - - func minRowsToDisplay() -> Int { - return showUrl() ? 2 : 1 - } - - func maxRowsToDisplay() -> Int { - return showUrl() ? 3 : 2 - } - - func numberOfRowsToDisplay() -> Int { - return extensionContext?.widgetActiveDisplayMode == .compact ? minRowsToDisplay() : maxRowsToDisplay() - } - - func resizeView(withMaximumSize size: CGSize? = nil) { - guard let maxSize = size ?? extensionContext?.widgetMaximumSize(for: .compact) else { - return - } - - let expanded = extensionContext?.widgetActiveDisplayMode == .expanded - preferredContentSize = expanded ? CGSize(width: maxSize.width, height: expandedHeight()) : maxSize - } - - func expandedHeight() -> CGFloat { - var height: CGFloat = 0 - let dataRowHeight: CGFloat - - // This method is called before the rows are updated. - // So if a no connection cell was displayed, use the default height for data rows. - // Otherwise, use the actual height from the first data row. - if tableView.visibleCells.first is WidgetNoConnectionCell { - dataRowHeight = WidgetTwoColumnCell.defaultHeight - } else { - dataRowHeight = tableView.rectForRow(at: IndexPath(row: 0, section: 0)).height - } - - let numRows = numberOfRowsToDisplay() - - if showUrl() { - height += WidgetUrlCell.height - height += (dataRowHeight * CGFloat(numRows - 1)) - } else { - height += (dataRowHeight * CGFloat(numRows)) - } - - return height - } - // MARK: - Reachability - - func setupReachability() { - isReachable = reachability.isReachable() - - NotificationCenter.default.addObserver(self, - selector: #selector(reachabilityChanged), - name: NSNotification.Name.reachabilityChanged, - object: nil) - reachability.startNotifier() - } - - @objc func reachabilityChanged() { - isReachable = reachability.isReachable() - } - - // MARK: - Helpers - - func updateStatsLabels() { - guard let values = statsValues else { - return - } - - viewCount = values.views.abbreviatedString() - visitorCount = values.visitors.abbreviatedString() - postCount = values.posts.abbreviatedString() - bestCount = values.bestViews.abbreviatedString() - } - - // MARK: - Constants - - enum LocalizedText { - static let visitors = NSLocalizedString("Visitors", comment: "Stats Visitors Label") - static let views = NSLocalizedString("Views", comment: "Stats Views Label") - static let posts = NSLocalizedString("Posts", comment: "Stats Posts Label") - static let bestViews = NSLocalizedString("Best views ever", comment: "Stats 'Best views ever' Label") - } - - enum Constants { - static let noDataLabel = "-" - static let baseUrl: String = "\(WPComScheme)://" - static let statsUrl: String = Constants.baseUrl + "viewstats?siteId=" - } - -} diff --git a/WordPress/WordPressAllTimeWidget/AllTimeWidgetPrefix.pch b/WordPress/WordPressAllTimeWidget/AllTimeWidgetPrefix.pch deleted file mode 100644 index eb1f13aa03b1..000000000000 --- a/WordPress/WordPressAllTimeWidget/AllTimeWidgetPrefix.pch +++ /dev/null @@ -1,8 +0,0 @@ -#ifdef __OBJC__ - -#ifndef WPCOM_SCHEME -#warning WPCOM_SCHEME is not defined for this target configuration! Defaulting to "wordpress". -#define WPCOM_SCHEME @"wordpress" -#endif - -#endif diff --git a/WordPress/WordPressAllTimeWidget/Base.lproj/Localizable.strings b/WordPress/WordPressAllTimeWidget/Base.lproj/Localizable.strings deleted file mode 100644 index a8521721851a..000000000000 Binary files a/WordPress/WordPressAllTimeWidget/Base.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressAllTimeWidget/Info-Alpha.plist b/WordPress/WordPressAllTimeWidget/Info-Alpha.plist deleted file mode 100644 index ce19037c41dd..000000000000 --- a/WordPress/WordPressAllTimeWidget/Info-Alpha.plist +++ /dev/null @@ -1,33 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - WordPress All-Time (Alpha) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleSignature - ???? - CFBundleShortVersionString - ${VERSION_SHORT} - CFBundleVersion - ${VERSION_LONG} - NSExtension - - NSExtensionMainStoryboard - MainInterface - NSExtensionPointIdentifier - com.apple.widget-extension - - - diff --git a/WordPress/WordPressAllTimeWidget/Info-Internal.plist b/WordPress/WordPressAllTimeWidget/Info-Internal.plist deleted file mode 100644 index 8cbe937b2004..000000000000 --- a/WordPress/WordPressAllTimeWidget/Info-Internal.plist +++ /dev/null @@ -1,33 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - WordPress All-Time (Internal) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleSignature - ???? - CFBundleShortVersionString - ${VERSION_SHORT} - CFBundleVersion - ${VERSION_LONG} - NSExtension - - NSExtensionMainStoryboard - MainInterface - NSExtensionPointIdentifier - com.apple.widget-extension - - - diff --git a/WordPress/WordPressAllTimeWidget/Info.plist b/WordPress/WordPressAllTimeWidget/Info.plist deleted file mode 100644 index 3f9ac883dfc1..000000000000 --- a/WordPress/WordPressAllTimeWidget/Info.plist +++ /dev/null @@ -1,33 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - WordPress All-Time - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleSignature - ???? - CFBundleShortVersionString - ${VERSION_SHORT} - CFBundleVersion - ${VERSION_LONG} - NSExtension - - NSExtensionMainStoryboard - MainInterface - NSExtensionPointIdentifier - com.apple.widget-extension - - - diff --git a/WordPress/WordPressAllTimeWidget/MainInterface.storyboard b/WordPress/WordPressAllTimeWidget/MainInterface.storyboard deleted file mode 100644 index 142c2c92e912..000000000000 --- a/WordPress/WordPressAllTimeWidget/MainInterface.storyboard +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/WordPressAllTimeWidget/Tracks+AllTimeWidget.swift b/WordPress/WordPressAllTimeWidget/Tracks+AllTimeWidget.swift deleted file mode 100644 index 37b74650a2b5..000000000000 --- a/WordPress/WordPressAllTimeWidget/Tracks+AllTimeWidget.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation - - -/// This extension implements helper tracking methods, meant for Today Widget Usage. -/// -extension Tracks { - - // MARK: - Public Methods - - public func trackExtensionStatsLaunched(_ siteID: Int) { - let properties = ["site_id": siteID] - trackExtensionEvent(.statsLaunched, properties: properties as [String: AnyObject]?) - } - - public func trackExtensionConfigureLaunched() { - trackExtensionEvent(.configureLaunched) - } - - public func trackDisplayModeChanged(properties: [String: Bool]) { - trackExtensionEvent(.displayModeChanged, properties: properties as [String: AnyObject]) - } - - // MARK: - Private Helpers - - fileprivate func trackExtensionEvent(_ event: ExtensionEvents, properties: [String: AnyObject]? = nil) { - track(event.rawValue, properties: properties) - } - - - // MARK: - Private Enums - - fileprivate enum ExtensionEvents: String { - case statsLaunched = "wpios_all_time_extension_stats_launched" - case configureLaunched = "wpios_all_time_extension_configure_launched" - case displayModeChanged = "wpios_all_time_extension_display_mode_changed" - } -} diff --git a/WordPress/WordPressAllTimeWidget/WordPressAllTimeWidget-Alpha.entitlements b/WordPress/WordPressAllTimeWidget/WordPressAllTimeWidget-Alpha.entitlements deleted file mode 100644 index dca30c5b176d..000000000000 --- a/WordPress/WordPressAllTimeWidget/WordPressAllTimeWidget-Alpha.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.application-groups - - group.org.wordpress.alpha - - keychain-access-groups - - 99KV9Z6BKV.org.wordpress.alpha - - - diff --git a/WordPress/WordPressAllTimeWidget/WordPressAllTimeWidget-Internal.entitlements b/WordPress/WordPressAllTimeWidget/WordPressAllTimeWidget-Internal.entitlements deleted file mode 100644 index 54ecf75e3680..000000000000 --- a/WordPress/WordPressAllTimeWidget/WordPressAllTimeWidget-Internal.entitlements +++ /dev/null @@ -1,14 +0,0 @@ - - - - - com.apple.security.application-groups - - group.org.wordpress.internal - - keychain-access-groups - - 99KV9Z6BKV.org.wordpress.internal - - - diff --git a/WordPress/WordPressDraftActionExtension/Base.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/Base.lproj/InfoPlist.strings deleted file mode 100644 index 7f7a3009224b..000000000000 --- a/WordPress/WordPressDraftActionExtension/Base.lproj/InfoPlist.strings +++ /dev/null @@ -1,2 +0,0 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; diff --git a/WordPress/WordPressDraftActionExtension/Tracks+DraftAction.swift b/WordPress/WordPressDraftActionExtension/Tracks+DraftAction.swift index cde34c4c730a..823d5c555fe2 100644 --- a/WordPress/WordPressDraftActionExtension/Tracks+DraftAction.swift +++ b/WordPress/WordPressDraftActionExtension/Tracks+DraftAction.swift @@ -7,17 +7,17 @@ extension Tracks { public func trackExtensionLaunched(_ wpcomAvailable: Bool) { let properties = ["is_configured_dotcom": wpcomAvailable] - trackExtensionEvent(.launched, properties: properties as [String: AnyObject]?) + trackExtensionEvent(.launched, properties: properties as [String: AnyObject]) } public func trackExtensionPosted(_ status: String) { let properties = ["post_status": status] - trackExtensionEvent(.posted, properties: properties as [String: AnyObject]?) + trackExtensionEvent(.posted, properties: properties as [String: AnyObject]) } public func trackExtensionError(_ error: NSError) { let properties = ["error_code": String(error.code), "error_domain": error.domain, "error_description": error.description] - trackExtensionEvent(.error, properties: properties as [String: AnyObject]?) + trackExtensionEvent(.error, properties: properties as [String: AnyObject]) } public func trackExtensionCancelled() { @@ -30,7 +30,7 @@ extension Tracks { public func trackExtensionTagsSelected(_ tags: String) { let properties = ["selected_tags": tags] - trackExtensionEvent(.tagsSelected, properties: properties as [String: AnyObject]?) + trackExtensionEvent(.tagsSelected, properties: properties as [String: AnyObject]) } public func trackExtensionCategoriesOpened() { @@ -39,7 +39,16 @@ extension Tracks { public func trackExtensionCategoriesSelected(_ categories: String) { let properties = ["categories_tags": categories] - trackExtensionEvent(.categoriesSelected, properties: properties as [String: AnyObject]?) + trackExtensionEvent(.categoriesSelected, properties: properties as [String: AnyObject]) + } + + public func trackExtensionPostTypeOpened() { + trackExtensionEvent(.postTypeOpened) + } + + public func trackExtensionPostTypeSelected(_ postType: String) { + let properties = ["post_type": postType] + trackExtensionEvent(.postTypeSelected, properties: properties as [String: AnyObject]) } // MARK: - Private Helpers @@ -51,13 +60,15 @@ extension Tracks { // MARK: - Private Enums fileprivate enum ExtensionEvents: String { - case launched = "wpios_draft_extension_launched" - case posted = "wpios_draft_extension_posted" - case tagsOpened = "wpios_draft_extension_tags_opened" - case tagsSelected = "wpios_draft_extension_tags_selected" - case canceled = "wpios_draft_extension_canceled" - case error = "wpios_draft_extension_error" - case categoriesOpened = "wpios_draft_extension_categories_opened" - case categoriesSelected = "wpios_draft_extension_categories_selected" + case launched = "draft_extension_launched" + case posted = "draft_extension_posted" + case tagsOpened = "draft_extension_tags_opened" + case tagsSelected = "draft_extension_tags_selected" + case canceled = "draft_extension_canceled" + case error = "draft_extension_error" + case categoriesOpened = "draft_extension_categories_opened" + case categoriesSelected = "draft_extension_categories_selected" + case postTypeOpened = "draft_extension_post_type_opened" + case postTypeSelected = "draft_extension_post_type_selected" } } diff --git a/WordPress/WordPressDraftActionExtension/ar.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/ar.lproj/InfoPlist.strings index 7f7a3009224b..607e08b6f873 100644 --- a/WordPress/WordPressDraftActionExtension/ar.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/ar.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + حفظ كمسودة + CFBundleName + حفظ كمسودة + + diff --git a/WordPress/WordPressDraftActionExtension/bg.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/bg.lproj/InfoPlist.strings index 7f7a3009224b..6b6139952ec6 100644 --- a/WordPress/WordPressDraftActionExtension/bg.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/bg.lproj/InfoPlist.strings @@ -1,2 +1,6 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + diff --git a/WordPress/WordPressDraftActionExtension/cs.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/cs.lproj/InfoPlist.strings index 7f7a3009224b..17e52fd7b9a3 100644 --- a/WordPress/WordPressDraftActionExtension/cs.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/cs.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Uložit jako koncept + CFBundleName + Uložit jako koncept + + diff --git a/WordPress/WordPressDraftActionExtension/cy.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/cy.lproj/InfoPlist.strings index 7f7a3009224b..6b6139952ec6 100644 --- a/WordPress/WordPressDraftActionExtension/cy.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/cy.lproj/InfoPlist.strings @@ -1,2 +1,6 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + diff --git a/WordPress/WordPressDraftActionExtension/da.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/da.lproj/InfoPlist.strings index 7f7a3009224b..6b6139952ec6 100644 --- a/WordPress/WordPressDraftActionExtension/da.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/da.lproj/InfoPlist.strings @@ -1,2 +1,6 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + diff --git a/WordPress/WordPressDraftActionExtension/de.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/de.lproj/InfoPlist.strings index 7f7a3009224b..1c84fcd0b709 100644 --- a/WordPress/WordPressDraftActionExtension/de.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/de.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Als Entwurf speichern + CFBundleName + Als Entwurf speichern + + diff --git a/WordPress/WordPressDraftActionExtension/en-AU.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/en-AU.lproj/InfoPlist.strings index 7f7a3009224b..6b6139952ec6 100644 --- a/WordPress/WordPressDraftActionExtension/en-AU.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/en-AU.lproj/InfoPlist.strings @@ -1,2 +1,6 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + diff --git a/WordPress/WordPressDraftActionExtension/en-CA.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/en-CA.lproj/InfoPlist.strings index 7f7a3009224b..80bc9e224780 100644 --- a/WordPress/WordPressDraftActionExtension/en-CA.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/en-CA.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Save as Draft + CFBundleName + Save as Draft + + diff --git a/WordPress/WordPressDraftActionExtension/en-GB.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/en-GB.lproj/InfoPlist.strings index 7f7a3009224b..80bc9e224780 100644 --- a/WordPress/WordPressDraftActionExtension/en-GB.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/en-GB.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Save as Draft + CFBundleName + Save as Draft + + diff --git a/WordPress/WordPressDraftActionExtension/en.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/en.lproj/InfoPlist.strings index 7f7a3009224b..43ade1c6bc50 100644 --- a/WordPress/WordPressDraftActionExtension/en.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/en.lproj/InfoPlist.strings @@ -1,2 +1,5 @@ +/* Name of the "Save as Draft" action as it should appear in the iOS Share Sheet when sharing content from other apps to WordPress */ CFBundleDisplayName = "Save as Draft"; + +/* Name of the "Save as Draft" action as it should appear in the iOS Share Sheet when sharing content from other apps to WordPress. Must be less than 16 characters long. Typically the same text as CFBundleDisplayName, but could be shorter if needed. */ CFBundleName = "Save as Draft"; diff --git a/WordPress/WordPressDraftActionExtension/es.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/es.lproj/InfoPlist.strings index fd58d80767f3..0484d274a926 100644 --- a/WordPress/WordPressDraftActionExtension/es.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/es.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Guardar como Borrador"; -CFBundleName = "Guardar como Borrador"; + + + + + + CFBundleDisplayName + Guardar como borrador + CFBundleName + Guardar como borrador + + diff --git a/WordPress/WordPressDraftActionExtension/fr.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/fr.lproj/InfoPlist.strings index 7f7a3009224b..c88e6c9c10a8 100644 --- a/WordPress/WordPressDraftActionExtension/fr.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/fr.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Enregistrer comme brouillon + CFBundleName + Enregistrer comme brouillon + + diff --git a/WordPress/WordPressDraftActionExtension/he.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/he.lproj/InfoPlist.strings index 7f7a3009224b..48a2463ce85b 100644 --- a/WordPress/WordPressDraftActionExtension/he.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/he.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + לשמור כטיוטה + CFBundleName + לשמור כטיוטה + + diff --git a/WordPress/WordPressDraftActionExtension/hr.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/hr.lproj/InfoPlist.strings index 7f7a3009224b..6b6139952ec6 100644 --- a/WordPress/WordPressDraftActionExtension/hr.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/hr.lproj/InfoPlist.strings @@ -1,2 +1,6 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + diff --git a/WordPress/WordPressDraftActionExtension/hu.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/hu.lproj/InfoPlist.strings index 7f7a3009224b..6b6139952ec6 100644 --- a/WordPress/WordPressDraftActionExtension/hu.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/hu.lproj/InfoPlist.strings @@ -1,2 +1,6 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + diff --git a/WordPress/WordPressDraftActionExtension/id.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/id.lproj/InfoPlist.strings index 7f7a3009224b..0320de01cc58 100644 --- a/WordPress/WordPressDraftActionExtension/id.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/id.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Simpan sebagai Konsep + CFBundleName + Simpan sebagai Konsep + + diff --git a/WordPress/WordPressDraftActionExtension/is.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/is.lproj/InfoPlist.strings index 7f7a3009224b..6b6139952ec6 100644 --- a/WordPress/WordPressDraftActionExtension/is.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/is.lproj/InfoPlist.strings @@ -1,2 +1,6 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + diff --git a/WordPress/WordPressDraftActionExtension/it.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/it.lproj/InfoPlist.strings index 7f7a3009224b..74552e38aee5 100644 --- a/WordPress/WordPressDraftActionExtension/it.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/it.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Salva come bozza + CFBundleName + Salva come bozza + + diff --git a/WordPress/WordPressDraftActionExtension/ja.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/ja.lproj/InfoPlist.strings index 7f7a3009224b..aaac4de8a3b3 100644 --- a/WordPress/WordPressDraftActionExtension/ja.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/ja.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + 下書きとして保存 + CFBundleName + 下書きとして保存 + + diff --git a/WordPress/WordPressDraftActionExtension/ko.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/ko.lproj/InfoPlist.strings index 7f7a3009224b..87ab0a91082f 100644 --- a/WordPress/WordPressDraftActionExtension/ko.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/ko.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + 임시글로 저장 + CFBundleName + 임시글로 저장 + + diff --git a/WordPress/WordPressDraftActionExtension/nb.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/nb.lproj/InfoPlist.strings index 7f7a3009224b..6b6139952ec6 100644 --- a/WordPress/WordPressDraftActionExtension/nb.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/nb.lproj/InfoPlist.strings @@ -1,2 +1,6 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + diff --git a/WordPress/WordPressDraftActionExtension/nl.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/nl.lproj/InfoPlist.strings index 7f7a3009224b..4d72c12702ad 100644 --- a/WordPress/WordPressDraftActionExtension/nl.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/nl.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Opslaan als concept + CFBundleName + Opslaan als concept + + diff --git a/WordPress/WordPressDraftActionExtension/pl.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/pl.lproj/InfoPlist.strings index 7f7a3009224b..fcc6b3e31bf0 100644 --- a/WordPress/WordPressDraftActionExtension/pl.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/pl.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Zapisz jako szkic + CFBundleName + Zapisz jako szkic + + diff --git a/WordPress/WordPressDraftActionExtension/pt-BR.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/pt-BR.lproj/InfoPlist.strings index 7f7a3009224b..3134bedcab34 100644 --- a/WordPress/WordPressDraftActionExtension/pt-BR.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/pt-BR.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Salvar como rascunho + CFBundleName + Salvar como rascunho + + diff --git a/WordPress/WordPressDraftActionExtension/pt.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/pt.lproj/InfoPlist.strings index 7f7a3009224b..6b6139952ec6 100644 --- a/WordPress/WordPressDraftActionExtension/pt.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/pt.lproj/InfoPlist.strings @@ -1,2 +1,6 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + diff --git a/WordPress/WordPressDraftActionExtension/ro.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/ro.lproj/InfoPlist.strings index 7f7a3009224b..fb538aea8f51 100644 --- a/WordPress/WordPressDraftActionExtension/ro.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/ro.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Salvează ca ciornă + CFBundleName + Salvează ca ciornă + + diff --git a/WordPress/WordPressDraftActionExtension/ru.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/ru.lproj/InfoPlist.strings index 7f7a3009224b..4b705eaf6d62 100644 --- a/WordPress/WordPressDraftActionExtension/ru.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/ru.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Сохранить как черновик + CFBundleName + Сохранить как черновик + + diff --git a/WordPress/WordPressDraftActionExtension/sk.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/sk.lproj/InfoPlist.strings index 7f7a3009224b..6b6139952ec6 100644 --- a/WordPress/WordPressDraftActionExtension/sk.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/sk.lproj/InfoPlist.strings @@ -1,2 +1,6 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + diff --git a/WordPress/WordPressDraftActionExtension/sk.lproj/Localizable.strings b/WordPress/WordPressDraftActionExtension/sk.lproj/Localizable.strings deleted file mode 100644 index 8b137891791f..000000000000 --- a/WordPress/WordPressDraftActionExtension/sk.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/WordPress/WordPressDraftActionExtension/sq.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/sq.lproj/InfoPlist.strings index 7f7a3009224b..a23d77dede7e 100644 --- a/WordPress/WordPressDraftActionExtension/sq.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/sq.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Ruaje si Skicë + CFBundleName + Ruaje si Skicë + + diff --git a/WordPress/WordPressDraftActionExtension/sq.lproj/Localizable.strings b/WordPress/WordPressDraftActionExtension/sq.lproj/Localizable.strings deleted file mode 100644 index 8b137891791f..000000000000 --- a/WordPress/WordPressDraftActionExtension/sq.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/WordPress/WordPressDraftActionExtension/sv.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/sv.lproj/InfoPlist.strings index 7f7a3009224b..8d9c71b6edaa 100644 --- a/WordPress/WordPressDraftActionExtension/sv.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/sv.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Spara som utkast + CFBundleName + Spara som utkast + + diff --git a/WordPress/WordPressDraftActionExtension/sv.lproj/Localizable.strings b/WordPress/WordPressDraftActionExtension/sv.lproj/Localizable.strings deleted file mode 100644 index 8b137891791f..000000000000 --- a/WordPress/WordPressDraftActionExtension/sv.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/WordPress/WordPressDraftActionExtension/th.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/th.lproj/InfoPlist.strings index 7f7a3009224b..6b6139952ec6 100644 --- a/WordPress/WordPressDraftActionExtension/th.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/th.lproj/InfoPlist.strings @@ -1,2 +1,6 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + diff --git a/WordPress/WordPressDraftActionExtension/th.lproj/Localizable.strings b/WordPress/WordPressDraftActionExtension/th.lproj/Localizable.strings deleted file mode 100644 index 8b137891791f..000000000000 --- a/WordPress/WordPressDraftActionExtension/th.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/WordPress/WordPressDraftActionExtension/tr.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/tr.lproj/InfoPlist.strings index 7f7a3009224b..f079af2dea90 100644 --- a/WordPress/WordPressDraftActionExtension/tr.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/tr.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + Taslak olarak kaydet + CFBundleName + Taslak olarak kaydet + + diff --git a/WordPress/WordPressDraftActionExtension/tr.lproj/Localizable.strings b/WordPress/WordPressDraftActionExtension/tr.lproj/Localizable.strings deleted file mode 100644 index 8b137891791f..000000000000 --- a/WordPress/WordPressDraftActionExtension/tr.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/WordPress/WordPressDraftActionExtension/zh-Hans.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/zh-Hans.lproj/InfoPlist.strings index 7f7a3009224b..eaefa69961ba 100644 --- a/WordPress/WordPressDraftActionExtension/zh-Hans.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/zh-Hans.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + 保存为草稿 + CFBundleName + 保存为草稿 + + diff --git a/WordPress/WordPressDraftActionExtension/zh-Hans.lproj/Localizable.strings b/WordPress/WordPressDraftActionExtension/zh-Hans.lproj/Localizable.strings deleted file mode 100644 index 8b137891791f..000000000000 --- a/WordPress/WordPressDraftActionExtension/zh-Hans.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/WordPress/WordPressDraftActionExtension/zh-Hant.lproj/InfoPlist.strings b/WordPress/WordPressDraftActionExtension/zh-Hant.lproj/InfoPlist.strings index 7f7a3009224b..9bb61b62cc30 100644 --- a/WordPress/WordPressDraftActionExtension/zh-Hant.lproj/InfoPlist.strings +++ b/WordPress/WordPressDraftActionExtension/zh-Hant.lproj/InfoPlist.strings @@ -1,2 +1,11 @@ -CFBundleDisplayName = "Save as Draft"; -CFBundleName = "Save as Draft"; + + + + + + CFBundleDisplayName + 儲存為草稿 + CFBundleName + 儲存為草稿 + + diff --git a/WordPress/WordPressDraftActionExtension/zh-Hant.lproj/Localizable.strings b/WordPress/WordPressDraftActionExtension/zh-Hant.lproj/Localizable.strings deleted file mode 100644 index 8b137891791f..000000000000 --- a/WordPress/WordPressDraftActionExtension/zh-Hant.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/WordPress/WordPressIntents/Base.lproj/Sites.intentdefinition b/WordPress/WordPressIntents/Base.lproj/Sites.intentdefinition new file mode 100644 index 000000000000..0ca59c04507b --- /dev/null +++ b/WordPress/WordPressIntents/Base.lproj/Sites.intentdefinition @@ -0,0 +1,193 @@ + + + + + INEnums + + INIntentDefinitionModelVersion + 1.2 + INIntentDefinitionNamespace + 88xZPY + INIntentDefinitionSystemVersion + 20C69 + INIntentDefinitionToolsBuildVersion + 12C33 + INIntentDefinitionToolsVersion + 12.3 + INIntents + + + INIntentCategory + information + INIntentDescription + Select Site Intent + INIntentDescriptionID + tVvJ9c + INIntentEligibleForWidgets + + INIntentIneligibleForSuggestions + + INIntentLastParameterTag + 2 + INIntentName + SelectSite + INIntentParameters + + + INIntentParameterConfigurable + + INIntentParameterCustomDisambiguation + + INIntentParameterDisplayName + Site + INIntentParameterDisplayNameID + ILcGmf + INIntentParameterDisplayPriority + 1 + INIntentParameterName + site + INIntentParameterObjectType + Site + INIntentParameterObjectTypeNamespace + 88xZPY + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + There are ${count} options matching ‘${site}’. + INIntentParameterPromptDialogFormatStringID + BOl9KQ + INIntentParameterPromptDialogType + DisambiguationIntroduction + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Just to confirm, you wanted ‘${site}’? + INIntentParameterPromptDialogFormatStringID + s4dJhx + INIntentParameterPromptDialogType + Confirmation + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterSupportsResolution + + INIntentParameterTag + 2 + INIntentParameterType + Object + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeName + failure + + + INIntentResponseLastParameterTag + 2 + + INIntentTitle + Select Site + INIntentTitleID + gpCwrM + INIntentType + Custom + INIntentVerb + View + + + INTypes + + + INTypeDisplayName + Site + INTypeDisplayNameID + cyajMn + INTypeLastPropertyTag + 100 + INTypeName + Site + INTypeProperties + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 1 + INTypePropertyName + identifier + INTypePropertyTag + 1 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 2 + INTypePropertyName + displayString + INTypePropertyTag + 2 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 3 + INTypePropertyName + pronunciationHint + INTypePropertyTag + 3 + INTypePropertyType + String + + + INTypePropertyDefault + + INTypePropertyDisplayPriority + 4 + INTypePropertyName + alternativeSpeakableMatches + INTypePropertySupportsMultipleValues + + INTypePropertyTag + 4 + INTypePropertyType + SpeakableString + + + + + + diff --git a/WordPress/WordPressIntents/IntentHandler.swift b/WordPress/WordPressIntents/IntentHandler.swift new file mode 100644 index 000000000000..d385cc8c0bef --- /dev/null +++ b/WordPress/WordPressIntents/IntentHandler.swift @@ -0,0 +1,28 @@ +import Intents + +class IntentHandler: INExtension, SelectSiteIntentHandling { + + let sitesDataProvider = SitesDataProvider() + + // MARK: - INIntentHandlerProviding + + override func handler(for intent: INIntent) -> Any { + return self + } + + // MARK: - SelectSiteIntentHandling + + func defaultSite(for intent: SelectSiteIntent) -> Site? { + return sitesDataProvider.defaultSite + } + + func resolveSite(for intent: SelectSiteIntent, with completion: @escaping (SiteResolutionResult) -> Void) { + /// - TODO: I have to test if this method can be called by interacting with Siri, and define an implementation. This is probably called whenever you ask the selected site, since the value can theoretically be requested through Siri. Check out Widgets.intentdefinition. + } + + func provideSiteOptionsCollection(for intent: SelectSiteIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) { + let sitesCollection = INObjectCollection(items: sitesDataProvider.sites) + + completion(sitesCollection, nil) + } +} diff --git a/WordPress/WordPressIntents/SitesDataProvider.swift b/WordPress/WordPressIntents/SitesDataProvider.swift new file mode 100644 index 000000000000..b27a045dd44c --- /dev/null +++ b/WordPress/WordPressIntents/SitesDataProvider.swift @@ -0,0 +1,74 @@ +import Intents + +class SitesDataProvider { + private(set) var sites = [Site]() + + init() { + initializeSites() + } + + // MARK: - Init Support + + private func initializeSites() { + guard let data = HomeWidgetTodayData.read() else { + sites = [] + return + } + + sites = data.map { (key: Int, data: HomeWidgetTodayData) -> Site in + + // Note: the image for the site was being set through: + // + // icon(from: data) + // + // Unfortunately, this had to be turned off for now since images aren't working very well in the + // customizer as reported here: https://github.com/wordpress-mobile/WordPress-iOS/pull/15397#pullrequestreview-539474644 + + let siteDomain: String? + + if let urlComponents = URLComponents(string: data.url), + let host = urlComponents.host { + + siteDomain = host + } else { + siteDomain = nil + } + + return Site( + identifier: String(key), + display: data.siteName, + subtitle: siteDomain, + image: nil) + }.sorted(by: { (firstSite, secondSite) -> Bool in + let firstTitle = firstSite.displayString.lowercased() + let secondTitle = secondSite.displayString.lowercased() + + guard firstTitle != secondTitle else { + let firstSubtitle = firstSite.subtitleString?.lowercased() ?? "" + let secondSubtitle = secondSite.subtitleString?.lowercased() ?? "" + + return firstSubtitle <= secondSubtitle + } + + return firstTitle < secondTitle + + }) + } + + // MARK: - Default Site + + private var defaultSiteID: Int? { + + return UserDefaults(suiteName: WPAppGroupName)?.object(forKey: AppConfiguration.Widget.Stats.userDefaultsSiteIdKey) as? Int + } + + var defaultSite: Site? { + guard let defaultSiteID = self.defaultSiteID else { + return nil + } + + return sites.first { site in + return site.identifier == String(defaultSiteID) + } + } +} diff --git a/WordPress/WordPressIntents/Supporting Files/Info.plist b/WordPress/WordPressIntents/Supporting Files/Info.plist new file mode 100644 index 000000000000..4c709f9eddac --- /dev/null +++ b/WordPress/WordPressIntents/Supporting Files/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + WordPressIntents + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionAttributes + + IntentsRestrictedWhileLocked + + IntentsRestrictedWhileProtectedDataUnavailable + + IntentsSupported + + SelectSiteIntent + + + NSExtensionPointIdentifier + com.apple.intents-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).IntentHandler + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/WordPress/WordPressIntents/Supporting Files/WordPressIntents-Bridging-Header.h b/WordPress/WordPressIntents/Supporting Files/WordPressIntents-Bridging-Header.h new file mode 100644 index 000000000000..4bda21fed193 --- /dev/null +++ b/WordPress/WordPressIntents/Supporting Files/WordPressIntents-Bridging-Header.h @@ -0,0 +1 @@ +#import "Constants.h" diff --git a/WordPress/WordPressIntents/Supporting Files/WordPressIntents.entitlements b/WordPress/WordPressIntents/Supporting Files/WordPressIntents.entitlements new file mode 100644 index 000000000000..82da242fbdbe --- /dev/null +++ b/WordPress/WordPressIntents/Supporting Files/WordPressIntents.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.wordpress + + + diff --git a/WordPress/WordPressIntents/Supporting Files/WordPressIntentsRelease-Alpha.entitlements b/WordPress/WordPressIntents/Supporting Files/WordPressIntentsRelease-Alpha.entitlements new file mode 100644 index 000000000000..008824899c59 --- /dev/null +++ b/WordPress/WordPressIntents/Supporting Files/WordPressIntentsRelease-Alpha.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.wordpress.alpha + + + diff --git a/WordPress/WordPressIntents/Supporting Files/WordPressIntentsRelease-Internal.entitlements b/WordPress/WordPressIntents/Supporting Files/WordPressIntentsRelease-Internal.entitlements new file mode 100644 index 000000000000..34c60fc89467 --- /dev/null +++ b/WordPress/WordPressIntents/Supporting Files/WordPressIntentsRelease-Internal.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.wordpress.internal + + + diff --git a/WordPress/WordPressIntents/WordPressIntentsDebug.entitlements b/WordPress/WordPressIntents/WordPressIntentsDebug.entitlements new file mode 100644 index 000000000000..2eb7e333a6f6 --- /dev/null +++ b/WordPress/WordPressIntents/WordPressIntentsDebug.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.application-groups + + + diff --git a/WordPress/WordPressIntents/ar.lproj/Sites.strings b/WordPress/WordPressIntents/ar.lproj/Sites.strings new file mode 100644 index 000000000000..266a57d89a73 --- /dev/null +++ b/WordPress/WordPressIntents/ar.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + توجد ${count} من الخيارات تطابق "${site}". + ILcGmf + الموقع + cyajMn + الموقع + gpCwrM + تحديد موقع + s4dJhx + للتأكيد فقط، هل أردت "${site}"؟ + tVvJ9c + تحديد هدف الموقع + + diff --git a/WordPress/WordPressIntents/bg.lproj/Sites.strings b/WordPress/WordPressIntents/bg.lproj/Sites.strings new file mode 100644 index 000000000000..edd8ef15d7be --- /dev/null +++ b/WordPress/WordPressIntents/bg.lproj/Sites.strings @@ -0,0 +1,11 @@ + + + + + + gpCwrM + Select Site + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/cs.lproj/Sites.strings b/WordPress/WordPressIntents/cs.lproj/Sites.strings new file mode 100644 index 000000000000..515c8556577d --- /dev/null +++ b/WordPress/WordPressIntents/cs.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + „${site}“ odpovídá ${count} možností. + ILcGmf + Web + cyajMn + Web + gpCwrM + Vybrat web + s4dJhx + Jen pro potvrzení, chtěli jste „${site}“? + tVvJ9c + Vyberte záměr webu + + diff --git a/WordPress/WordPressIntents/cy.lproj/Sites.strings b/WordPress/WordPressIntents/cy.lproj/Sites.strings new file mode 100644 index 000000000000..edd8ef15d7be --- /dev/null +++ b/WordPress/WordPressIntents/cy.lproj/Sites.strings @@ -0,0 +1,11 @@ + + + + + + gpCwrM + Select Site + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/da.lproj/Sites.strings b/WordPress/WordPressIntents/da.lproj/Sites.strings new file mode 100644 index 000000000000..edd8ef15d7be --- /dev/null +++ b/WordPress/WordPressIntents/da.lproj/Sites.strings @@ -0,0 +1,11 @@ + + + + + + gpCwrM + Select Site + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/de.lproj/Sites.strings b/WordPress/WordPressIntents/de.lproj/Sites.strings new file mode 100644 index 000000000000..ad2a5eb4b27d --- /dev/null +++ b/WordPress/WordPressIntents/de.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + Es gibt ${count} Optionen, die zu „${site}“ passen. + ILcGmf + Website + cyajMn + Website + gpCwrM + Website auswählen + s4dJhx + Nur um sicherzugehen, du willst „${site}“? + tVvJ9c + Website-Intent auswählen + + diff --git a/WordPress/WordPressIntents/en-AU.lproj/Sites.strings b/WordPress/WordPressIntents/en-AU.lproj/Sites.strings new file mode 100644 index 000000000000..9956b6ef6fa6 --- /dev/null +++ b/WordPress/WordPressIntents/en-AU.lproj/Sites.strings @@ -0,0 +1,17 @@ + + + + + + ILcGmf + Site + cyajMn + Site + gpCwrM + Select Site + s4dJhx + Just to confirm, you wanted ‘${site}’? + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/en-CA.lproj/Sites.strings b/WordPress/WordPressIntents/en-CA.lproj/Sites.strings new file mode 100644 index 000000000000..318bb8365ff2 --- /dev/null +++ b/WordPress/WordPressIntents/en-CA.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + There are ${count} options matching ‘${site}’. + ILcGmf + Site + cyajMn + Site + gpCwrM + Select Site + s4dJhx + Just to confirm, you wanted ‘${site}’? + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/en-GB.lproj/Sites.strings b/WordPress/WordPressIntents/en-GB.lproj/Sites.strings new file mode 100644 index 000000000000..318bb8365ff2 --- /dev/null +++ b/WordPress/WordPressIntents/en-GB.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + There are ${count} options matching ‘${site}’. + ILcGmf + Site + cyajMn + Site + gpCwrM + Select Site + s4dJhx + Just to confirm, you wanted ‘${site}’? + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/en.lproj/Sites.strings b/WordPress/WordPressIntents/en.lproj/Sites.strings new file mode 100644 index 000000000000..0de34e087277 --- /dev/null +++ b/WordPress/WordPressIntents/en.lproj/Sites.strings @@ -0,0 +1,17 @@ +/* This text is used when the user is configuring the iOS widget and selecting which WordPress site they want the widget to be for */ +"BOl9KQ" = "There are ${count} options matching ‘${site}’."; + +/* This text is used when the user is configuring the iOS widget, as a label for the dropdown to select the site to configure it for */ +"ILcGmf" = "Site"; + +/* This text is used when the user is configuring the iOS widget, as the title for the modal when selecting the site to configure it for */ +"cyajMn" = "Site"; + +/* This text is used when the user is configuring the iOS widget to suggest them to select the site to configure the widget for */ +"gpCwrM" = "Select Site"; + +/* This text is required by Apple to be part of the translations but is not shown to the users. You can use the English version verbatim without translation for this entry. */ +"tVvJ9c" = "Select Site Intent"; + +/* This text is used when configuring the iOS widget and confirming the WordPress site the user wants the widget to be for */ +"s4dJhx" = "Just to confirm, you wanted ‘${site}’?"; diff --git a/WordPress/WordPressIntents/es.lproj/Sites.strings b/WordPress/WordPressIntents/es.lproj/Sites.strings new file mode 100644 index 000000000000..98e22adf36f0 --- /dev/null +++ b/WordPress/WordPressIntents/es.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + ${count} opciones coinciden con «${site}». + ILcGmf + Sitio + cyajMn + Sitio + gpCwrM + Seleccionar sitio + s4dJhx + Solo por confirmar, ¿querías ‘${site}’? + tVvJ9c + Seleccionar intención del sitio + + diff --git a/WordPress/WordPressIntents/fr.lproj/Sites.strings b/WordPress/WordPressIntents/fr.lproj/Sites.strings new file mode 100644 index 000000000000..0fb8392700d8 --- /dev/null +++ b/WordPress/WordPressIntents/fr.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + Il existe ${count} options correspondant à « ${site} ». + ILcGmf + Site + cyajMn + Site + gpCwrM + Sélectionnez le site + s4dJhx + Vous cherchiez bien « ${site} » ? + tVvJ9c + Sélectionner l’intention du site + + diff --git a/WordPress/WordPressIntents/he.lproj/Sites.strings b/WordPress/WordPressIntents/he.lproj/Sites.strings new file mode 100644 index 000000000000..df24c01136d8 --- /dev/null +++ b/WordPress/WordPressIntents/he.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + יש ${count} אפשרויות שתואמות לנתון ‘${site}’. + ILcGmf + אתר + cyajMn + אתר + gpCwrM + יש לבחור אתר + s4dJhx + אנחנו רוצים לוודא, האם התכוונת לרשום ‘${site}’? + tVvJ9c + יש לבחור את כוונת האתר + + diff --git a/WordPress/WordPressIntents/hr.lproj/Sites.strings b/WordPress/WordPressIntents/hr.lproj/Sites.strings new file mode 100644 index 000000000000..edd8ef15d7be --- /dev/null +++ b/WordPress/WordPressIntents/hr.lproj/Sites.strings @@ -0,0 +1,11 @@ + + + + + + gpCwrM + Select Site + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/hu.lproj/Sites.strings b/WordPress/WordPressIntents/hu.lproj/Sites.strings new file mode 100644 index 000000000000..edd8ef15d7be --- /dev/null +++ b/WordPress/WordPressIntents/hu.lproj/Sites.strings @@ -0,0 +1,11 @@ + + + + + + gpCwrM + Select Site + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/id.lproj/Sites.strings b/WordPress/WordPressIntents/id.lproj/Sites.strings new file mode 100644 index 000000000000..4e0aec915c57 --- /dev/null +++ b/WordPress/WordPressIntents/id.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + Ada pilihan ${count} yang cocok dengan ‘${site}’. + ILcGmf + Situs + cyajMn + Situs + gpCwrM + Pilih Situs + s4dJhx + Hanya untuk mengonfirmasi, Anda menginginkan '${site}'? + tVvJ9c + Pilih Kegunaan Situs + + diff --git a/WordPress/WordPressIntents/is.lproj/Sites.strings b/WordPress/WordPressIntents/is.lproj/Sites.strings new file mode 100644 index 000000000000..edd8ef15d7be --- /dev/null +++ b/WordPress/WordPressIntents/is.lproj/Sites.strings @@ -0,0 +1,11 @@ + + + + + + gpCwrM + Select Site + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/it.lproj/Sites.strings b/WordPress/WordPressIntents/it.lproj/Sites.strings new file mode 100644 index 000000000000..598306914c33 --- /dev/null +++ b/WordPress/WordPressIntents/it.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + Esistono le opzioni ${count} che corrispondono a ‘${site}’. + ILcGmf + Sito + cyajMn + Sito + gpCwrM + Seleziona sito + s4dJhx + Giusto per conferma, hai voluto ‘${site}’? + tVvJ9c + Seleziona lo scopo del sito + + diff --git a/WordPress/WordPressIntents/ja.lproj/Sites.strings b/WordPress/WordPressIntents/ja.lproj/Sites.strings new file mode 100644 index 000000000000..6cd452485e78 --- /dev/null +++ b/WordPress/WordPressIntents/ja.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + 「${site}」に一致するオプションが${count}件あります + ILcGmf + サイト + cyajMn + サイト + gpCwrM + サイトを選択 + s4dJhx + 「${site}」でよろしいですか? + tVvJ9c + サイトの目的を選択 + + diff --git a/WordPress/WordPressIntents/ko.lproj/Sites.strings b/WordPress/WordPressIntents/ko.lproj/Sites.strings new file mode 100644 index 000000000000..6f408d82cd0f --- /dev/null +++ b/WordPress/WordPressIntents/ko.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + ‘${site}’과(와) 일치하는 옵션이 ${count}개 있습니다. + ILcGmf + 사이트 + cyajMn + 사이트 + gpCwrM + 사이트 선택 + s4dJhx + ‘${site}’을(를) 원하시나요? + tVvJ9c + 사이트 의도 선택 + + diff --git a/WordPress/WordPressIntents/nb.lproj/Sites.strings b/WordPress/WordPressIntents/nb.lproj/Sites.strings new file mode 100644 index 000000000000..edd8ef15d7be --- /dev/null +++ b/WordPress/WordPressIntents/nb.lproj/Sites.strings @@ -0,0 +1,11 @@ + + + + + + gpCwrM + Select Site + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/nl.lproj/Sites.strings b/WordPress/WordPressIntents/nl.lproj/Sites.strings new file mode 100644 index 000000000000..cb07cc2ed12c --- /dev/null +++ b/WordPress/WordPressIntents/nl.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + Er zijn ${count} opties die overeenkomen met '${site}'. + ILcGmf + Site + cyajMn + Site + gpCwrM + Selecteer site + s4dJhx + Even ter bevestiging, je wilt '${site}'? + tVvJ9c + Selecteer site doel + + diff --git a/WordPress/WordPressIntents/pl.lproj/Sites.strings b/WordPress/WordPressIntents/pl.lproj/Sites.strings new file mode 100644 index 000000000000..e78b35e7aefe --- /dev/null +++ b/WordPress/WordPressIntents/pl.lproj/Sites.strings @@ -0,0 +1,15 @@ + + + + + + ILcGmf + Witryna + cyajMn + Witryna + gpCwrM + Select Site + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/pt-BR.lproj/Sites.strings b/WordPress/WordPressIntents/pt-BR.lproj/Sites.strings new file mode 100644 index 000000000000..81432d83583f --- /dev/null +++ b/WordPress/WordPressIntents/pt-BR.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + Existem ${count} opções correspondentes a ‘${site}’. + ILcGmf + Site + cyajMn + Site + gpCwrM + Selecionar site + s4dJhx + Só para confirmar, você queria ‘${site}’? + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/pt.lproj/Sites.strings b/WordPress/WordPressIntents/pt.lproj/Sites.strings new file mode 100644 index 000000000000..edd8ef15d7be --- /dev/null +++ b/WordPress/WordPressIntents/pt.lproj/Sites.strings @@ -0,0 +1,11 @@ + + + + + + gpCwrM + Select Site + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/ro.lproj/Sites.strings b/WordPress/WordPressIntents/ro.lproj/Sites.strings new file mode 100644 index 000000000000..e8f58fb3570e --- /dev/null +++ b/WordPress/WordPressIntents/ro.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + Există ${count} opțiuni care se potrivesc cu „${site}”. + ILcGmf + Site + cyajMn + Site + gpCwrM + Selectează site-ul + s4dJhx + Trebuie să confirmi, ai vrut „${site}”? + tVvJ9c + Selectează scopul site-ului + + diff --git a/WordPress/WordPressIntents/ru.lproj/Sites.strings b/WordPress/WordPressIntents/ru.lproj/Sites.strings new file mode 100644 index 000000000000..22a16e3eb8cc --- /dev/null +++ b/WordPress/WordPressIntents/ru.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + Есть опции (${count}), соответствующие "${site}". + ILcGmf + Сайт + cyajMn + Сайт + gpCwrM + Выбрать сайт + s4dJhx + Вы действительно хотели "${site}"? + tVvJ9c + Выберите назначение сайта + + diff --git a/WordPress/WordPressIntents/sk.lproj/Sites.strings b/WordPress/WordPressIntents/sk.lproj/Sites.strings new file mode 100644 index 000000000000..edd8ef15d7be --- /dev/null +++ b/WordPress/WordPressIntents/sk.lproj/Sites.strings @@ -0,0 +1,11 @@ + + + + + + gpCwrM + Select Site + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/sq.lproj/Sites.strings b/WordPress/WordPressIntents/sq.lproj/Sites.strings new file mode 100644 index 000000000000..cd112a88020f --- /dev/null +++ b/WordPress/WordPressIntents/sq.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + Ka ${count} mundësi me përputhje me ‘${site}’. + ILcGmf + Sajt + cyajMn + Sajt + gpCwrM + Përzgjidhni Sajt + s4dJhx + Sa për ripohim, donit ‘${site}’? + tVvJ9c + Përzgjidhni Synim Sajti + + diff --git a/WordPress/WordPressIntents/sv.lproj/Sites.strings b/WordPress/WordPressIntents/sv.lproj/Sites.strings new file mode 100644 index 000000000000..05a7f74af6a6 --- /dev/null +++ b/WordPress/WordPressIntents/sv.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + Det finns ${count} alternativ som matchar "${site}". + ILcGmf + Webbplats + cyajMn + Webbplats + gpCwrM + Välj webbplats + s4dJhx + Bara för att bekräfta, du önskade "${site}"? + tVvJ9c + Välj syfte med webbplats + + diff --git a/WordPress/WordPressIntents/th.lproj/Sites.strings b/WordPress/WordPressIntents/th.lproj/Sites.strings new file mode 100644 index 000000000000..edd8ef15d7be --- /dev/null +++ b/WordPress/WordPressIntents/th.lproj/Sites.strings @@ -0,0 +1,11 @@ + + + + + + gpCwrM + Select Site + tVvJ9c + Select Site Intent + + diff --git a/WordPress/WordPressIntents/tr.lproj/Sites.strings b/WordPress/WordPressIntents/tr.lproj/Sites.strings new file mode 100644 index 000000000000..5ea8f168ff2e --- /dev/null +++ b/WordPress/WordPressIntents/tr.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + ‘${site}’ ile eşleşen '${count}' seçenekleri var. + ILcGmf + Site + cyajMn + Site + gpCwrM + Site seçin + s4dJhx + ‘${site}’ istediğinizden emin misiniz? + tVvJ9c + Site Amacı Seçin + + diff --git a/WordPress/WordPressIntents/zh-Hans.lproj/Sites.strings b/WordPress/WordPressIntents/zh-Hans.lproj/Sites.strings new file mode 100644 index 000000000000..6126863d700b --- /dev/null +++ b/WordPress/WordPressIntents/zh-Hans.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + 有 ${count} 个选项与“${site}”匹配。 + ILcGmf + 站点 + cyajMn + 站点 + gpCwrM + 选择站点 + s4dJhx + 再确认一下,您是想要“${site}”吗? + tVvJ9c + 选择“站点意图” + + diff --git a/WordPress/WordPressIntents/zh-Hant.lproj/Sites.strings b/WordPress/WordPressIntents/zh-Hant.lproj/Sites.strings new file mode 100644 index 000000000000..1a3d9a2a3ea7 --- /dev/null +++ b/WordPress/WordPressIntents/zh-Hant.lproj/Sites.strings @@ -0,0 +1,19 @@ + + + + + + BOl9KQ + 有 ${count} 個選項和「${site}」相符。 + ILcGmf + 網站 + cyajMn + 網站 + gpCwrM + 選取網站 + s4dJhx + 確認一下,你想要的是「${site}」嗎? + tVvJ9c + 選擇網站用途 + + diff --git a/WordPress/WordPressNotificationContentExtension/Info-Alpha.plist b/WordPress/WordPressNotificationContentExtension/Info-Alpha.plist index 44dd5a8e02b9..9b65e5a08d68 100644 --- a/WordPress/WordPressNotificationContentExtension/Info-Alpha.plist +++ b/WordPress/WordPressNotificationContentExtension/Info-Alpha.plist @@ -31,8 +31,9 @@ replyto-comment replyto-like-comment automattcher - comment_like - like + comment_like + like + push_auth UNNotificationExtensionDefaultContentHidden diff --git a/WordPress/WordPressNotificationContentExtension/Info-Internal.plist b/WordPress/WordPressNotificationContentExtension/Info-Internal.plist index 54b1fc79f7ae..b850cf8cfeff 100644 --- a/WordPress/WordPressNotificationContentExtension/Info-Internal.plist +++ b/WordPress/WordPressNotificationContentExtension/Info-Internal.plist @@ -31,8 +31,9 @@ replyto-comment replyto-like-comment automattcher - comment_like - like + comment_like + like + push_auth UNNotificationExtensionDefaultContentHidden diff --git a/WordPress/WordPressNotificationContentExtension/Info.plist b/WordPress/WordPressNotificationContentExtension/Info.plist index 87b0f7bead5a..72eb81521d44 100644 --- a/WordPress/WordPressNotificationContentExtension/Info.plist +++ b/WordPress/WordPressNotificationContentExtension/Info.plist @@ -33,6 +33,7 @@ automattcher comment_like like + push_auth UNNotificationExtensionDefaultContentHidden diff --git a/WordPress/WordPressNotificationContentExtension/Resources/Base.lproj/Localizable.strings b/WordPress/WordPressNotificationContentExtension/Resources/Base.lproj/Localizable.strings deleted file mode 100644 index 8b137891791f..000000000000 --- a/WordPress/WordPressNotificationContentExtension/Resources/Base.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/WordPress/WordPressNotificationContentExtension/Resources/en.lproj/Localizable.strings b/WordPress/WordPressNotificationContentExtension/Resources/en.lproj/Localizable.strings deleted file mode 100644 index 8b137891791f..000000000000 --- a/WordPress/WordPressNotificationContentExtension/Resources/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/WordPress/WordPressNotificationContentExtension/Sources/NotificationViewController.swift b/WordPress/WordPressNotificationContentExtension/Sources/NotificationViewController.swift index 1125c6be053f..e024027cda27 100644 --- a/WordPress/WordPressNotificationContentExtension/Sources/NotificationViewController.swift +++ b/WordPress/WordPressNotificationContentExtension/Sources/NotificationViewController.swift @@ -131,9 +131,9 @@ private extension NotificationViewController { /// - Returns: the token if found; `nil` otherwise /// func readExtensionToken() -> String? { - guard let oauthToken = try? SFHFKeychainUtils.getPasswordForUsername(WPNotificationContentExtensionKeychainTokenKey, - andServiceName: WPNotificationContentExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup) else { + guard let oauthToken = try? KeychainUtils.shared.getPasswordForUsername(WPNotificationContentExtensionKeychainTokenKey, + serviceName: WPNotificationContentExtensionKeychainServiceName, + accessGroup: WPAppKeychainAccessGroup) else { debugPrint("Unable to retrieve Notification Content Extension OAuth token") return nil } @@ -146,9 +146,9 @@ private extension NotificationViewController { /// - Returns: the username if found; `nil` otherwise /// func readExtensionUsername() -> String? { - guard let username = try? SFHFKeychainUtils.getPasswordForUsername(WPNotificationContentExtensionKeychainUsernameKey, - andServiceName: WPNotificationContentExtensionKeychainServiceName, - accessGroup: WPAppKeychainAccessGroup) else { + guard let username = try? KeychainUtils.shared.getPasswordForUsername(WPNotificationContentExtensinoKeychainUsernameKey, + serviceName: WPNotificationContentExtensionKeychainServiceName, + accessGroup: WPAppKeychainAccessGroup) else { debugPrint("Unable to retrieve Notification Content Extension username") return nil } diff --git a/WordPress/WordPressNotificationContentExtension/Sources/Tracks/Tracks+ContentExtension.swift b/WordPress/WordPressNotificationContentExtension/Sources/Tracks/Tracks+ContentExtension.swift index 6eaffa657c8b..ba05a1ecb64e 100644 --- a/WordPress/WordPressNotificationContentExtension/Sources/Tracks/Tracks+ContentExtension.swift +++ b/WordPress/WordPressNotificationContentExtension/Sources/Tracks/Tracks+ContentExtension.swift @@ -6,8 +6,8 @@ import Foundation /// - launched: the content extension was successfully entered & launched /// private enum ContentExtensionEvents: String { - case launched = "wpios_notification_content_extension_launched" - case failedToMarkAsRead = "wpios_notification_content_extension_failed_mark_as_read" + case launched = "notification_content_extension_launched" + case failedToMarkAsRead = "notification_content_extension_failed_mark_as_read" } // MARK: - Supports tracking notification content extension events. diff --git a/WordPress/WordPressNotificationContentExtension/Sources/Views/NotificationContentView.swift b/WordPress/WordPressNotificationContentExtension/Sources/Views/NotificationContentView.swift index bcd801da900f..11586b65d884 100644 --- a/WordPress/WordPressNotificationContentExtension/Sources/Views/NotificationContentView.swift +++ b/WordPress/WordPressNotificationContentExtension/Sources/Views/NotificationContentView.swift @@ -23,10 +23,7 @@ class NotificationContentView: UIView { } private struct Styles { - // NB: Matches `noticonUnreadColor` in NoteTableViewCell static let noticonInnerBackgroundColor = UIColor(red: 0x25/255.0, green: 0x9C/255.0, blue: 0xCF/255.0, alpha: 0xFF/255.0) - - // NB: Matches `noteBackgroundReadColor` in `NoteTableViewCell` static let noticonOuterBackgroundColor = UIColor.white } @@ -43,6 +40,8 @@ class NotificationContentView: UIView { view.heightAnchor.constraint(equalToConstant: Metrics.avatarDimension) ]) + view.isHidden = self.viewModel.gravatarURLString == nil + return view }() @@ -61,6 +60,8 @@ class NotificationContentView: UIView { noticonView.centerYAnchor.constraint(equalTo: view.centerYAnchor), ]) + view.isHidden = self.viewModel.gravatarURLString == nil + return view }() @@ -181,8 +182,6 @@ class NotificationContentView: UIView { } } -// MARK: - Adapted from NoteTableViewCell for this extension - extension NotificationContentView { func downloadGravatar() { guard let specifiedGravatar = viewModel.gravatarURLString, diff --git a/WordPress/WordPressNotificationServiceExtension/Resources/Base.lproj/Localizable.strings b/WordPress/WordPressNotificationServiceExtension/Resources/Base.lproj/Localizable.strings deleted file mode 100644 index 8b137891791f..000000000000 --- a/WordPress/WordPressNotificationServiceExtension/Resources/Base.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/WordPress/WordPressNotificationServiceExtension/Resources/en.lproj/Localizable.strings b/WordPress/WordPressNotificationServiceExtension/Resources/en.lproj/Localizable.strings deleted file mode 100644 index 8b137891791f..000000000000 --- a/WordPress/WordPressNotificationServiceExtension/Resources/en.lproj/Localizable.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/WordPress/WordPressNotificationServiceExtension/Sources/FormattableContent/RichNotificationContentFormatter.swift b/WordPress/WordPressNotificationServiceExtension/Sources/FormattableContent/RichNotificationContentFormatter.swift index 53bf5caa25a1..3a8402bdf9f1 100644 --- a/WordPress/WordPressNotificationServiceExtension/Sources/FormattableContent/RichNotificationContentFormatter.swift +++ b/WordPress/WordPressNotificationServiceExtension/Sources/FormattableContent/RichNotificationContentFormatter.swift @@ -24,6 +24,9 @@ class RichNotificationContentFormatter { /// The attributed-text representation of the notification subject, suitable for Long Look presentation var attributedSubject: NSAttributedString? + /// The URL of the first non-Emoji media object for notification + var mediaURL: URL? + /// Creates a notification content formatter. /// /// - Parameters: @@ -88,6 +91,14 @@ private extension RichNotificationContentFormatter { self.body = formattedBody.string self.attributedBody = formattedBody + + // Grab the first media attachment that is not an Emoji so it can be displayed as a + // push notification media attachment + if let notificationTextContent = formattableContent as? NotificationTextContent { + self.mediaURL = notificationTextContent.media.first(where: { + !($0.mediaURL?.isEmojiURL() ?? false) + })?.mediaURL + } } /// Attempts to format the attributed subject of the notification content. @@ -146,3 +157,10 @@ private extension RichNotificationContentFormatter { return newString } } + +// MARK: - Emoji checker +private extension URL { + func isEmojiURL() -> Bool { + return self.absoluteString.contains("i/emoji") + } +} diff --git a/WordPress/WordPressNotificationServiceExtension/Sources/NotificationContent/RichNotificationViewModel.swift b/WordPress/WordPressNotificationServiceExtension/Sources/NotificationContent/RichNotificationViewModel.swift index e03f5230c1c6..4308e5966638 100644 --- a/WordPress/WordPressNotificationServiceExtension/Sources/NotificationContent/RichNotificationViewModel.swift +++ b/WordPress/WordPressNotificationServiceExtension/Sources/NotificationContent/RichNotificationViewModel.swift @@ -61,23 +61,24 @@ private extension CodingUserInfoKey { } convenience init?(data: Data) { - let decoder = NSKeyedUnarchiver(forReadingWith: data) - decoder.requiresSecureCoding = true + do { + let decoder = try NSKeyedUnarchiver(forReadingFrom: data) + decoder.requiresSecureCoding = true - self.init(coder: decoder) + self.init(coder: decoder) - decoder.finishDecoding() + decoder.finishDecoding() + } catch { + return nil + } } var data: Data { - let data = NSMutableData() - - let encoder = NSKeyedArchiver(forWritingWith: data) - encoder.requiresSecureCoding = true + let encoder = NSKeyedArchiver(requiringSecureCoding: true) encode(with: encoder) encoder.finishEncoding() - return data as Data + return encoder.encodedData } // MARK: NSSecureCoding diff --git a/WordPress/WordPressNotificationServiceExtension/Sources/NotificationService.swift b/WordPress/WordPressNotificationServiceExtension/Sources/NotificationService.swift index 3cbd350c63b7..510db40c259c 100644 --- a/WordPress/WordPressNotificationServiceExtension/Sources/NotificationService.swift +++ b/WordPress/WordPressNotificationServiceExtension/Sources/NotificationService.swift @@ -30,22 +30,30 @@ class NotificationService: UNNotificationServiceExtension { let username = readExtensionUsername() tracks.wpcomUsername = username + let userID = readExtensionUserID() + tracks.wpcomUserID = userID + let token = readExtensionToken() tracks.trackExtensionLaunched(token != nil) guard let notificationContent = self.bestAttemptContent, let apsAlert = notificationContent.apsAlert, - let noteID = notificationContent.noteID, let notificationType = notificationContent.type, let notificationKind = NotificationKind(rawValue: notificationType), token != nil else { - tracks.trackNotificationMalformed(properties: ["have_token": (token != nil) as AnyObject, - "content": request.content]) + + let hasToken = token != nil + tracks.trackNotificationMalformed(hasToken: hasToken, notificationBody: request.content.body) contentHandler(request.content) return } + guard !NotificationKind.isViewMilestone(notificationKind) else { + contentHandler(makeViewMilestoneContent(notificationContent)) + return + } + guard NotificationKind.isSupportedByRichNotifications(notificationKind) else { tracks.trackNotificationDiscarded(notificationType: notificationType) @@ -69,6 +77,37 @@ class NotificationService: UNNotificationServiceExtension { notificationContent.body = "" } + // If this notification is for 2fa login there won't be a noteID and there + // is no need to query the notification service. Just return the formatted + // content. + if notificationKind == .login { + let preferredFont = UIFont.preferredFont(forTextStyle: .body) + let descriptor = preferredFont.fontDescriptor.withSymbolicTraits(.traitBold) ?? preferredFont.fontDescriptor + let boldFont = UIFont(descriptor: descriptor, size: preferredFont.pointSize) + let attributes: [NSAttributedString.Key: Any] = [ + .font: boldFont + ] + + let viewModel = RichNotificationViewModel( + attributedBody: NSAttributedString(string: notificationContent.body, attributes: attributes), + attributedSubject: NSAttributedString(string: notificationContent.title, attributes: attributes), + gravatarURLString: nil, + notificationIdentifier: nil, + notificationReadStatus: true, + noticon: nil) + + notificationContent.userInfo[CodingUserInfoKey.richNotificationViewModel.rawValue] = viewModel.data + contentHandler(notificationContent) + return + } + + // Make sure we have a noteID before proceeding. + guard let noteID = notificationContent.noteID else { + tracks.trackNotificationMalformed(hasToken: true, notificationBody: request.content.body) + contentHandler(request.content) + return + } + let api = WordPressComRestApi(oAuthToken: token) let service = NotificationSyncServiceRemote(wordPressComRestApi: api) self.notificationService = service @@ -100,13 +139,44 @@ class NotificationService: UNNotificationServiceExtension { // Only populate title / body for notification kinds with rich body content if !NotificationKind.omitsRichNotificationBody(notificationKind) { notificationContent.title = contentFormatter.attributedSubject?.string ?? apsAlert - notificationContent.body = contentFormatter.body ?? "" + + // Improve the notification body by trimming whitespace and reducing any multiple blank lines + notificationContent.body = contentFormatter.body?.condenseWhitespace() ?? "" } + notificationContent.userInfo[CodingUserInfoKey.richNotificationViewModel.rawValue] = viewModel.data tracks.trackNotificationAssembled() - contentHandler(notificationContent) + // If the notification contains any image media, download it and attach it to the notification + guard let mediaURL = contentFormatter.mediaURL else { + contentHandler(notificationContent) + return + } + + self.getMediaAttachment(for: mediaURL) { [weak self] data, fileExtension in + defer { + contentHandler(notificationContent) + } + + let identifier = UUID().uuidString + + guard + let self = self, let data = data, let fileExtension = fileExtension, + let fileURL = self.saveMediaAttachment(data: data, fileName: String(format: "%@.%@", identifier, fileExtension)) + else { + return + } + + let imageAttachment = try? UNNotificationAttachment( + identifier: identifier, + url: fileURL, + options: nil) + + if let imageAttachment = imageAttachment { + notificationContent.attachments = [imageAttachment] + } + } } } @@ -123,6 +193,84 @@ class NotificationService: UNNotificationServiceExtension { } } +// MARK: - Media Attachment Support +private extension NotificationService { + + /// Attempts to download the image + /// - Parameters: + /// - url: The URL for the image being downloaded + /// - completion: Returns the image data and the file extension derived from the returned mime type or nil if the request fails + private func getMediaAttachment(for url: URL, completion: @escaping (Data?, String?) -> Void) { + var request = URLRequest(url: url) + request.addValue("image/*", forHTTPHeaderField: "Accept") + + // Allow private images to pulled from WordPress sites. + if isWPComSite(url: url), let token = self.readExtensionToken() { + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + print("AuthorizationAuthorizationAuthorizationAuthorizationAuthorizationAuthorization") + } + + let session = URLSession(configuration: .default) + let task = session.dataTask(with: request) { data, response, error in + guard let data = data, let mimeType = response?.mimeType else { + completion(nil, nil) + return + } + + let fileExtension: String + switch mimeType { + case "image/gif": + fileExtension = "gif" + case "image/png": + fileExtension = "png" + case "image/jpeg": + fileExtension = "jpg" + default: + fileExtension = "png" + } + + completion(data, fileExtension) + } + + task.resume() + } + + + /// Save the downloaded media data with a unique identifier + /// - Parameters: + /// - data: The media attachment data + /// - fileName: The filename to use for the file + /// - Returns: The file URL to the media attachment, or nil if writing failed for any reason + private func saveMediaAttachment(data: Data, fileName: String) -> URL? { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + let directoryPath = directory.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString, isDirectory: true) + + do { + try FileManager.default.createDirectory(at: directoryPath, withIntermediateDirectories: true, attributes: nil) + let fileURL = directoryPath.appendingPathComponent(fileName) + try data.write(to: fileURL) + return fileURL + } + catch { + return nil + } + } + + /// Perform a simple check to see if the URL is a WP.com site + /// This isn't meant to be extensive and has a few flaws, but since we don't know + /// much information about the URL and if it's a blog without having to do another request + /// this works for the current usecases. + /// + /// - Parameter url: The URL to check + /// - Returns: True if it's a WP.com site, False if not. + private func isWPComSite(url: URL) -> Bool { + guard let host = url.host else { + return false + } + + return host.contains("wordpress.com") || host.contains("wp.com") + } +} // MARK: - Keychain support private extension NotificationService { @@ -131,8 +279,8 @@ private extension NotificationService { /// - Returns: the token if found; `nil` otherwise /// func readExtensionToken() -> String? { - guard let oauthToken = try? SFHFKeychainUtils.getPasswordForUsername(WPNotificationServiceExtensionKeychainTokenKey, - andServiceName: WPNotificationServiceExtensionKeychainServiceName, + guard let oauthToken = try? SFHFKeychainUtils.getPasswordForUsername(AppConfiguration.Extension.NotificationsService.keychainTokenKey, + andServiceName: AppConfiguration.Extension.NotificationsService.keychainServiceName, accessGroup: WPAppKeychainAccessGroup) else { debugPrint("Unable to retrieve Notification Service Extension OAuth token") return nil @@ -146,8 +294,8 @@ private extension NotificationService { /// - Returns: the username if found; `nil` otherwise /// func readExtensionUsername() -> String? { - guard let username = try? SFHFKeychainUtils.getPasswordForUsername(WPNotificationServiceExtensionKeychainUsernameKey, - andServiceName: WPNotificationServiceExtensionKeychainServiceName, + guard let username = try? SFHFKeychainUtils.getPasswordForUsername(AppConfiguration.Extension.NotificationsService.keychainUsernameKey, + andServiceName: AppConfiguration.Extension.NotificationsService.keychainServiceName, accessGroup: WPAppKeychainAccessGroup) else { debugPrint("Unable to retrieve Notification Service Extension username") return nil @@ -155,4 +303,31 @@ private extension NotificationService { return username } + + /// Retrieves the WPCOM userID, meant for Extension usage. + /// + /// - Returns: the userID if found; `nil` otherwise + /// + func readExtensionUserID() -> String? { + guard let userID = try? SFHFKeychainUtils.getPasswordForUsername(AppConfiguration.Extension.NotificationsService.keychainUserIDKey, + andServiceName: AppConfiguration.Extension.NotificationsService.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup) else { + debugPrint("Unable to retrieve Notification Service Extension userID") + return nil + } + + return userID + } +} + + +// MARK: - View Milestone notifications support +private extension NotificationService { + + func makeViewMilestoneContent(_ content: UNMutableNotificationContent) -> UNNotificationContent { + content.title = Self.viewMilestoneTitle + return content + } + + static let viewMilestoneTitle = AppLocalizedString("You hit a milestone 🚀", comment: "Title for a view milestone push notification") } diff --git a/WordPress/WordPressNotificationServiceExtension/Sources/Tracks/Tracks+ServiceExtension.swift b/WordPress/WordPressNotificationServiceExtension/Sources/Tracks/Tracks+ServiceExtension.swift index 9c4011212d6d..beed2b669fc1 100644 --- a/WordPress/WordPressNotificationServiceExtension/Sources/Tracks/Tracks+ServiceExtension.swift +++ b/WordPress/WordPressNotificationServiceExtension/Sources/Tracks/Tracks+ServiceExtension.swift @@ -9,12 +9,12 @@ import Foundation /// - assembled: the service extension successfully prepared content /// private enum ServiceExtensionEvents: String { - case launched = "wpios_notification_service_extension_launched" - case discarded = "wpios_notification_service_extension_discarded" - case failed = "wpios_notification_service_extension_failed" - case assembled = "wpios_notification_service_extension_assembled" - case malformed = "wpios_notification_service_extension_malformed_payload" - case timedOut = "wpios_notification_service_extension_timed_out" + case launched = "notification_service_extension_launched" + case discarded = "notification_service_extension_discarded" + case failed = "notification_service_extension_failed" + case assembled = "notification_service_extension_assembled" + case malformed = "notification_service_extension_malformed_payload" + case timedOut = "notification_service_extension_timed_out" } // MARK: - Supports tracking notification service extension events. @@ -60,7 +60,12 @@ extension Tracks { } /// Tracks the unsuccessful unwrapping of push notification payload data. - func trackNotificationMalformed(properties: [String: AnyObject]? = nil) { + func trackNotificationMalformed(hasToken: Bool, notificationBody: String) { + let properties: [String: AnyObject] = [ + "have_token": hasToken as AnyObject, + "content": notificationBody as AnyObject + ] + trackEvent(ServiceExtensionEvents.malformed, properties: properties) } diff --git a/WordPress/WordPressScreenshotGeneration/Info.plist b/WordPress/WordPressScreenshotGeneration/Info.plist index 4ac9d619b377..ba72822e8728 100644 --- a/WordPress/WordPressScreenshotGeneration/Info.plist +++ b/WordPress/WordPressScreenshotGeneration/Info.plist @@ -20,9 +20,5 @@ ???? CFBundleVersion 1 - WIREMOCK_HOST - $(WIREMOCK_HOST) - WIREMOCK_PORT - $(WIREMOCK_PORT) diff --git a/WordPress/WordPressScreenshotGeneration/SnapshotHelper.swift b/WordPress/WordPressScreenshotGeneration/SnapshotHelper.swift index aaa2a9a9234f..0046aaa68364 100644 --- a/WordPress/WordPressScreenshotGeneration/SnapshotHelper.swift +++ b/WordPress/WordPressScreenshotGeneration/SnapshotHelper.swift @@ -38,22 +38,13 @@ func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { } enum SnapshotError: Error, CustomDebugStringConvertible { - case cannotDetectUser - case cannotFindHomeDirectory case cannotFindSimulatorHomeDirectory - case cannotAccessSimulatorHomeDirectory(String) case cannotRunOnPhysicalDevice var debugDescription: String { switch self { - case .cannotDetectUser: - return "Couldn't find Snapshot configuration files - can't detect current user " - case .cannotFindHomeDirectory: - return "Couldn't find Snapshot configuration files - can't detect `Users` dir" case .cannotFindSimulatorHomeDirectory: return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable." - case .cannotAccessSimulatorHomeDirectory(let simulatorHostHome): - return "Can't prepare environment. Simulator home location is inaccessible. Does \(simulatorHostHome) exist?" case .cannotRunOnPhysicalDevice: return "Can't use Snapshot on a physical device." } @@ -75,7 +66,7 @@ open class Snapshot: NSObject { Snapshot.waitForAnimations = waitForAnimations do { - let cacheDir = try pathPrefix() + let cacheDir = try getCacheDirectory() Snapshot.cacheDirectory = cacheDir setLanguage(app) setLocale(app) @@ -174,6 +165,12 @@ open class Snapshot: NSObject { } let screenshot = XCUIScreen.main.screenshot() + #if os(iOS) && !targetEnvironment(macCatalyst) + let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image + #else + let image = screenshot.image + #endif + guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return } do { @@ -183,7 +180,11 @@ open class Snapshot: NSObject { simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "") let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png") - try screenshot.pngRepresentation.write(to: path) + #if swift(<5.0) + UIImagePNGRepresentation(image)?.write(to: path, options: .atomic) + #else + try image.pngData()?.write(to: path, options: .atomic) + #endif } catch let error { NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png") NSLog(error.localizedDescription) @@ -191,6 +192,23 @@ open class Snapshot: NSObject { #endif } + class func fixLandscapeOrientation(image: UIImage) -> UIImage { + #if os(watchOS) + return image + #else + if #available(iOS 10.0, *) { + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + let renderer = UIGraphicsImageRenderer(size: image.size, format: format) + return renderer.image { context in + image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) + } + } else { + return image + } + #endif + } + class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) { #if os(tvOS) return @@ -206,40 +224,28 @@ open class Snapshot: NSObject { _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout) } - class func pathPrefix() throws -> URL? { - let homeDir: URL + class func getCacheDirectory() throws -> URL { + let cachePath = "Library/Caches/tools.fastlane" // on OSX config is stored in /Users//Library // and on iOS/tvOS/WatchOS it's in simulator's home dir #if os(OSX) - guard let user = ProcessInfo().environment["USER"] else { - throw SnapshotError.cannotDetectUser - } - - guard let usersDir = FileManager.default.urls(for: .userDirectory, in: .localDomainMask).first else { - throw SnapshotError.cannotFindHomeDirectory + let homeDir = URL(fileURLWithPath: NSHomeDirectory()) + return homeDir.appendingPathComponent(cachePath) + #elseif arch(i386) || arch(x86_64) || arch(arm64) + guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else { + throw SnapshotError.cannotFindSimulatorHomeDirectory } - - homeDir = usersDir.appendingPathComponent(user) + let homeDir = URL(fileURLWithPath: simulatorHostHome) + return homeDir.appendingPathComponent(cachePath) #else - #if arch(i386) || arch(x86_64) - guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else { - throw SnapshotError.cannotFindSimulatorHomeDirectory - } - guard let homeDirUrl = URL(string: simulatorHostHome) else { - throw SnapshotError.cannotAccessSimulatorHomeDirectory(simulatorHostHome) - } - homeDir = URL(fileURLWithPath: homeDirUrl.path) - #else - throw SnapshotError.cannotRunOnPhysicalDevice - #endif + throw SnapshotError.cannotRunOnPhysicalDevice #endif - return homeDir.appendingPathComponent("Library/Caches/tools.fastlane") } } private extension XCUIElementAttributes { var isNetworkLoadingIndicator: Bool { - if hasWhiteListedIdentifier { return false } + if hasAllowListedIdentifier { return false } let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20) let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3) @@ -247,10 +253,10 @@ private extension XCUIElementAttributes { return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize } - var hasWhiteListedIdentifier: Bool { - let whiteListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"] + var hasAllowListedIdentifier: Bool { + let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"] - return whiteListedIdentifiers.contains(identifier) + return allowListedIdentifiers.contains(identifier) } func isStatusBar(_ deviceWidth: CGFloat) -> Bool { @@ -300,4 +306,4 @@ private extension CGFloat { // Please don't remove the lines below // They are used to detect outdated configuration files -// SnapshotHelperVersion [1.21] +// SnapshotHelperVersion [1.28] diff --git a/WordPress/WordPressScreenshotGeneration/WordPressScreenshotGeneration.swift b/WordPress/WordPressScreenshotGeneration/WordPressScreenshotGeneration.swift index ea2595a8556c..379e0109d919 100644 --- a/WordPress/WordPressScreenshotGeneration/WordPressScreenshotGeneration.swift +++ b/WordPress/WordPressScreenshotGeneration/WordPressScreenshotGeneration.swift @@ -1,88 +1,125 @@ +import ScreenObject import UIKit +import UITestsFoundation import XCTest -import SimulatorStatusMagic class WordPressScreenshotGeneration: XCTestCase { let imagesWaitTime: UInt32 = 10 - override func setUp() { + override func setUpWithError() throws { super.setUp() - // Put setup code here. This method is called before the invocation of each test method in the class. - SDStatusBarManager.sharedInstance()?.enableOverrides() + let app = XCUIApplication(bundleIdentifier: "org.wordpress") // This does the shared setup including injecting mocks and launching the app - setUpTestSuite() + setUpTestSuite(for: app, removeBeforeLaunching: true) // The app is already launched so we can set it up for screenshots here - let app = XCUIApplication() setupSnapshot(app) - if isIpad { + if XCUIDevice.isPad { XCUIDevice.shared.orientation = UIDeviceOrientation.landscapeLeft } else { XCUIDevice.shared.orientation = UIDeviceOrientation.portrait } - LoginFlow.login(siteUrl: "WordPress.com", username: ScreenshotCredentials.username, password: ScreenshotCredentials.password) + try LoginFlow.login(siteUrl: "WordPress.com", username: ScreenshotCredentials.username, password: ScreenshotCredentials.password) } override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. - SDStatusBarManager.sharedInstance()?.disableOverrides() - super.tearDown() + removeApp() } - func testGenerateScreenshots() { + func testGenerateScreenshots() throws { - let mySite = MySiteScreen() + // Get post editor screenshot + let postList = try MySiteScreen() .showSiteSwitcher() - .switchToSite(withTitle: "infocusphotographers.com") - - let postList = mySite + .switchToSite(withTitle: "fourpawsdoggrooming.wordpress.com") .gotoPostsScreen() .showOnly(.drafts) - let firstPostEditorScreenshot = postList.selectPost(withSlug: "summer-band-jam") - snapshot("1-PostEditor") - firstPostEditorScreenshot.close() - - // Get a screenshot of the drafts feature - let secondPostEditorScreenshot = postList.selectPost(withSlug: "ideas") - snapshot("5-DraftEditor") - secondPostEditorScreenshot.close() - - // Get a screenshot of the full-screen editor - if isIpad { - let ipadScreenshot = postList.selectPost(withSlug: "now-booking-summer-sessions") - snapshot("6-No-Keyboard-Editor") - ipadScreenshot.close() + let postEditorScreenshot = try postList.selectPost(withSlug: "our-services") + sleep(imagesWaitTime) // wait for post images to load + if XCUIDevice.isPad { + try BlockEditorScreen() + .thenTakeScreenshot(1, named: "Editor") + } else { + try BlockEditorScreen() + .openBlockPicker() + .thenTakeScreenshot(1, named: "Editor-With-BlockPicker") + .closeBlockPicker() } - - if !isIpad { + postEditorScreenshot.close() + + // Get a screenshot of the editor with keyboard (iPad only) + if XCUIDevice.isPad { + let ipadScreenshot = try MySiteScreen() + .showSiteSwitcher() + .switchToSite(withTitle: "yourjetpack.blog") + .gotoPostsScreen() + .showOnly(.drafts) + .selectPost(withSlug: "easy-blueberry-muffins") + try BlockEditorScreen().selectBlock(containingText: "Ingredients") + sleep(imagesWaitTime) // wait for post images to load + try BlockEditorScreen().thenTakeScreenshot(7, named: "Editor-With-Keyboard") + ipadScreenshot.close() + } else { postList.pop() } - _ = mySite.gotoMediaScreen() + // Get My Site screenshot + let mySite = try MySiteScreen() + .showSiteSwitcher() + .switchToSite(withTitle: "tricountyrealestate.wordpress.com") + .thenTakeScreenshot(4, named: "MySite") + + // Get Media screenshot + _ = try mySite.goToMediaScreen() sleep(imagesWaitTime) // wait for post images to load - snapshot("4-Media") + mySite.thenTakeScreenshot(6, named: "Media") - if !isIpad { + if !XCUIDevice.isPad { postList.pop() } + // Get Stats screenshot - let statsScreen = mySite.gotoStatsScreen() + let statsScreen = try mySite.goToStatsScreen() statsScreen .dismissCustomizeInsightsNotice() - .switchTo(mode: .years) + .switchTo(mode: .months) + .thenTakeScreenshot(3, named: "Stats") + + // Get Discover screenshot + // Currently, the view includes the "You Might Like" section + try TabNavComponent() + .goToReaderScreen() + .openDiscover() + .thenTakeScreenshot(2, named: "Discover") + + // Get Notifications screenshot + let notificationList = try TabNavComponent() + .goToNotificationsScreen() + .dismissNotificationAlertIfNeeded() + if XCUIDevice.isPad { + notificationList + .openNotification(withText: "Reyansh Pawar commented on My Top 10 Pastry Recipes") + } + notificationList.thenTakeScreenshot(5, named: "Notifications") + } +} + +extension ScreenObject { - snapshot("2-Stats") + @discardableResult + func thenTakeScreenshot(_ index: Int, named title: String) -> Self { + let mode = XCUIDevice.inDarkMode ? "dark" : "light" + let filename = "\(index)-\(mode)-\(title)" - TabNavComponent() - .gotoNotificationsScreen() - .dismissNotificationMessageIfNeeded() + snapshot(filename) - snapshot("3-Notifications") + return self } } diff --git a/WordPress/WordPressShareExtension/AppExtensionsService.swift b/WordPress/WordPressShareExtension/AppExtensionsService.swift index e99661f948f6..4d49a4f3fa94 100644 --- a/WordPress/WordPressShareExtension/AppExtensionsService.swift +++ b/WordPress/WordPressShareExtension/AppExtensionsService.swift @@ -95,7 +95,13 @@ extension AppExtensionsService { /// func fetchSites(onSuccess: @escaping ([RemoteBlog]?) -> (), onFailure: @escaping FailureBlock) { let remote = AccountServiceRemoteREST(wordPressComRestApi: simpleRestAPI) - remote.getBlogsWithSuccess({ blogs in + + var filterJetpackSites = false + #if JETPACK + filterJetpackSites = true + #endif + + remote.getBlogs(filterJetpackSites, success: { blogs in guard let blogs = blogs as? [RemoteBlog] else { DDLogError("Error parsing returned sites.") onFailure() @@ -231,30 +237,16 @@ extension AppExtensionsService { /// WILL schedule a local notification to fire upon success or failure. /// /// - Parameters: - /// - title: Post title - /// - body: Post content body - /// - tags: Post tags - /// - categories: Post categories - /// - status: Post status + /// - shareData: The shareData with which to create the post /// - siteID: Site ID the post will be uploaded to /// - onComplete: Completion handler executed after a post is uploaded to the server /// - onFailure: The (optional) failure handler. /// - func saveAndUploadPost(title: String, body: String, tags: String?, categories: String?, status: String, siteID: Int, onComplete: CompletionBlock?, onFailure: FailureBlock?) { - guard let remotePost = RemotePost(siteID: NSNumber(value: siteID), status: status, title: title, content: body) else { - DDLogError("Unable to create the post object required for uploading.") - onFailure?() - return - } - - if let tags = tags { - remotePost.tags = tags.arrayOfTags() - } - - if let remoteCategories = RemotePostCategory.remotePostCategoriesFromString(categories) { - remotePost.categories = remoteCategories - } - + func saveAndUploadPost(shareData: ShareData, + siteID: Int, + onComplete: CompletionBlock?, + onFailure: FailureBlock?) { + let remotePost = RemotePost(shareData: shareData, siteID: siteID) let uploadPostOpID = coreDataStack.savePostOperation(remotePost, groupIdentifier: groupIdentifier, with: .pending) uploadPost(forUploadOpWithObjectID: uploadPostOpID, onComplete: { // Schedule a local success notification @@ -279,47 +271,26 @@ extension AppExtensionsService { }) } - /// Saves a new post + media items to the shared container db and then uploads it in the background. + /// Saves a new post + media items to the shared container db and then uploads it in the background. /// /// - Parameters: - /// - title: Post title - /// - body: Post content body - /// - tags: Post tags - /// - categories: Post categories - /// - status: Post status + /// - shareData: The shareData with which to create the post /// - siteID: Site ID the post will be uploaded to /// - localMediaFileURLs: An array of local URLs containing the media files to upload /// - requestEnqueued: Completion handler executed when the media has been processed and background upload is scheduled. /// - onFailure: The failure handler. /// - func uploadPostWithMedia(title: String, - body: String, - tags: String?, - categories: String?, - status: String, - siteID: Int, - localMediaFileURLs: [URL], - requestEnqueued: @escaping CompletionBlock, - onFailure: @escaping FailureBlock) { + func saveAndUploadPostWithMedia(shareData: ShareData, + siteID: Int, + localMediaFileURLs: [URL], + requestEnqueued: @escaping CompletionBlock, + onFailure: @escaping FailureBlock) { guard !localMediaFileURLs.isEmpty else { DDLogError("No media is attached to this upload request") onFailure() return } - guard let remotePost = RemotePost(siteID: NSNumber(value: siteID), status: status, title: title, content: body) else { - DDLogError("Unable to create the post object required for uploading.") - onFailure() - return - } - - if let tags = tags { - remotePost.tags = tags.arrayOfTags() - } - - if let remoteCategories = RemotePostCategory.remotePostCategoriesFromString(categories) { - remotePost.categories = remoteCategories - } - + let remotePost = RemotePost(shareData: shareData, siteID: siteID) // Create the post & media upload ops let uploadPostOpID = coreDataStack.savePostOperation(remotePost, groupIdentifier: groupIdentifier, with: .pending) let (uploadMediaOpIDs, allRemoteMedia) = createAndSaveRemoteMediaWithLocalURLs(localMediaFileURLs, siteID: NSNumber(value: siteID)) diff --git a/WordPress/WordPressShareExtension/Base.lproj/Localizable.strings b/WordPress/WordPressShareExtension/Base.lproj/Localizable.strings deleted file mode 100644 index 6b96292aa4b7..000000000000 Binary files a/WordPress/WordPressShareExtension/Base.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/EXTENSION_MIGRATIONS.md b/WordPress/WordPressShareExtension/EXTENSION_MIGRATIONS.md index cb230852cd29..06edb87330f6 100644 --- a/WordPress/WordPressShareExtension/EXTENSION_MIGRATIONS.md +++ b/WordPress/WordPressShareExtension/EXTENSION_MIGRATIONS.md @@ -3,6 +3,12 @@ This file documents changes in the extensions data model (located in the shared container) . Please explain any changes to the data model as well as any custom migrations. + +## Extensions 4 + +- @autoblork 2021-09-06 +- `PostUploadOperation` added `postType` string property. + ## Extensions 3 - @bummytime 2018-02-26 diff --git a/WordPress/WordPressShareExtension/ExtensionNotificationManager.swift b/WordPress/WordPressShareExtension/ExtensionNotificationManager.swift index 3685a9ffd74c..8f37af19d229 100644 --- a/WordPress/WordPressShareExtension/ExtensionNotificationManager.swift +++ b/WordPress/WordPressShareExtension/ExtensionNotificationManager.swift @@ -15,7 +15,7 @@ class ExtensionNotificationManager { static func scheduleSuccessNotification(postUploadOpID: String, postID: String, blogID: String, mediaItemCount: Int = 0, notificationDate: Date = Date(), postStatus: String) { let userInfo = makeUserInfoDict(postUploadOpID: postUploadOpID, postID: postID, blogID: blogID) let title = ShareNoticeText.successTitle(mediaItemCount: mediaItemCount, postStatus: postStatus) - let body = notificationDate.mediumString() + let body = notificationDate.toMediumString() scheduleLocalNotification(title: title, body: body, category: ShareNoticeConstants.categorySuccessIdentifier, userInfo: userInfo) } @@ -30,7 +30,7 @@ class ExtensionNotificationManager { static func scheduleFailureNotification(postUploadOpID: String, postID: String, blogID: String, mediaItemCount: Int = 0, notificationDate: Date = Date(), postStatus: String) { let userInfo = makeUserInfoDict(postUploadOpID: postUploadOpID, postID: postID, blogID: blogID) let title = ShareNoticeText.successTitle(mediaItemCount: mediaItemCount, postStatus: postStatus) - let body = notificationDate.mediumString() + let body = notificationDate.toMediumString() scheduleLocalNotification(title: title, body: body, category: ShareNoticeConstants.categoryFailureIdentifier, userInfo: userInfo) } diff --git a/WordPress/WordPressShareExtension/ExtensionPresentationController.swift b/WordPress/WordPressShareExtension/ExtensionPresentationController.swift index d82ec27a8ca2..80a128dfceec 100644 --- a/WordPress/WordPressShareExtension/ExtensionPresentationController.swift +++ b/WordPress/WordPressShareExtension/ExtensionPresentationController.swift @@ -63,10 +63,6 @@ class ExtensionPresentationController: UIPresentationController { presentedView?.layer.cornerRadius = Appearance.cornerRadius presentedView?.clipsToBounds = true } - guard #available(iOS 13, *) else { - presentedView?.frame = frameOfPresentedViewInContainerView - return - } presentedView?.frame = viewFrame } diff --git a/WordPress/WordPressShareExtension/Extensions.xcdatamodeld/.xccurrentversion b/WordPress/WordPressShareExtension/Extensions.xcdatamodeld/.xccurrentversion index aca1a2342d6b..008fe8f1ac5a 100644 --- a/WordPress/WordPressShareExtension/Extensions.xcdatamodeld/.xccurrentversion +++ b/WordPress/WordPressShareExtension/Extensions.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Extensions 3.xcdatamodel + Extensions 4.xcdatamodel diff --git a/WordPress/WordPressShareExtension/Extensions.xcdatamodeld/Extensions 4.xcdatamodel/contents b/WordPress/WordPressShareExtension/Extensions.xcdatamodeld/Extensions 4.xcdatamodel/contents new file mode 100644 index 000000000000..ce5c519c9abf --- /dev/null +++ b/WordPress/WordPressShareExtension/Extensions.xcdatamodeld/Extensions 4.xcdatamodel/contents @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WordPress/WordPressShareExtension/MainShareViewController.swift b/WordPress/WordPressShareExtension/MainShareViewController.swift index 181443843aa7..95524a2b7ce6 100644 --- a/WordPress/WordPressShareExtension/MainShareViewController.swift +++ b/WordPress/WordPressShareExtension/MainShareViewController.swift @@ -13,6 +13,10 @@ fileprivate extension OriginatingExtension { } } +enum SharingErrors: Error { + case canceled +} + class MainShareViewController: UIViewController { fileprivate let extensionTransitioningManager: ExtensionTransitioningManager = { @@ -36,7 +40,7 @@ class MainShareViewController: UIViewController { // The problem with the original expression is that it didn't account for the bundle ID being different in // different build configurations (debug, internal, release). // - let isSaveAsDraftAction = Bundle.main.bundleIdentifier?.hasSuffix("WordPressDraftAction") ?? false + let isSaveAsDraftAction = Bundle.main.bundleIdentifier?.contains("DraftAction") ?? false if isSaveAsDraftAction { origination = .saveToDraft @@ -68,34 +72,51 @@ class MainShareViewController: UIViewController { private extension MainShareViewController { func setupAppearance() { - if editorController.originatingExtension == .saveToDraft { - // This should probably be showing over current context but this just matches previous behavior - view.backgroundColor = .basicBackground - } - + // Notice that this will set the apparence of _all_ `UINavigationBar` instances. + // + // Such a catch-all approach wouldn't be good in the context of a fully fledged application, + // but is acceptable here, given we are in an app extension. let navigationBarAppearace = UINavigationBar.appearance() navigationBarAppearace.isTranslucent = false - navigationBarAppearace.tintColor = .white - navigationBarAppearace.barTintColor = .appBar + navigationBarAppearace.tintColor = .appBarTint + navigationBarAppearace.barTintColor = .appBarBackground navigationBarAppearace.barStyle = .default + + // Extension-specif settings + // + // This view controller is shared via target membership by multiple extensions, resulting + // in the need to apply some extension-specific settings. + // + // If we had the time, it would be great to extract all this logic in a standalone + // framework or package, and then make the individual extensions import it, and instantiate + // and configure the view controller to their liking, without making the code more complex + // with branch-logic such as this. + switch editorController.originatingExtension { + case .saveToDraft: + // This should probably be showing over current context but this just matches previous + // behavior. + view.backgroundColor = .basicBackground + case .share: + // Without this, the modal view controller will have a semi-transparent bar with a + // very low alpha, making it close to fully transparent. + navigationBarAppearace.backgroundColor = .basicBackground + } } func loadAndPresentNavigationVC() { - editorController.context = self.extensionContext - editorController.dismissalCompletionBlock = { - self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + editorController.context = extensionContext + editorController.dismissalCompletionBlock = { [weak self] (exitSharing) in + if exitSharing { + self?.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) + } else { + self?.extensionContext?.cancelRequest(withError: SharingErrors.canceled) + } } let shareNavController = UINavigationController(rootViewController: editorController) - if #available(iOS 13, *), editorController.originatingExtension == .saveToDraft { - // iOS 13 has proper animations and presentations for share and action sheets. So the `else` case should be removed when iOS 13 is minimum. - // We need to make sure we don't end up with stacked modal view controllers by using this: - shareNavController.modalPresentationStyle = .overFullScreen - } else { - shareNavController.transitioningDelegate = extensionTransitioningManager - shareNavController.modalPresentationStyle = .custom - } + // We need to make sure we don't end up with stacked modal view controllers by using this: + shareNavController.modalPresentationStyle = .overCurrentContext present(shareNavController, animated: true) } diff --git a/WordPress/WordPressShareExtension/NSExtensionContext+Extensions.swift b/WordPress/WordPressShareExtension/NSExtensionContext+Extensions.swift index 84beb9e093f2..5698fd6cc30b 100644 --- a/WordPress/WordPressShareExtension/NSExtensionContext+Extensions.swift +++ b/WordPress/WordPressShareExtension/NSExtensionContext+Extensions.swift @@ -3,7 +3,7 @@ import Foundation /// Encapsulates NSExtensionContext Helper Methods. /// extension NSExtensionContext { - /// Returns all the NSItemProvider attachments on the first NSItemProvider + /// Returns all the NSItemProvider attachments on the first NSItemProvider /// that conform to a specific type identifier /// func itemProviders(ofType type: String) -> [NSItemProvider] { diff --git a/WordPress/WordPressShareExtension/PostUploadOperation.swift b/WordPress/WordPressShareExtension/PostUploadOperation.swift index 4b7b1c8f2f53..d8e3c9b633e7 100644 --- a/WordPress/WordPressShareExtension/PostUploadOperation.swift +++ b/WordPress/WordPressShareExtension/PostUploadOperation.swift @@ -24,6 +24,10 @@ public class PostUploadOperation: UploadOperation { /// @NSManaged public var postCategories: String? + /// Post type for this upload op + /// + @NSManaged public var postType: String? + /// Post status for this upload op — e.g. "Draft" or "Publish" (Not used if `isMedia` is True) /// @NSManaged public var postStatus: String? @@ -43,6 +47,7 @@ extension PostUploadOperation { remotePost.siteID = NSNumber(value: siteID) remotePost.tags = postTags?.arrayOfTags() ?? [] remotePost.categories = RemotePostCategory.remotePostCategoriesFromString(postCategories) ?? [] + remotePost.type = postType return remotePost } @@ -72,5 +77,7 @@ extension PostUploadOperation { if let categories = remote.categories as? [RemotePostCategory], !categories.isEmpty { postCategories = categories.map({ $0.categoryID.stringValue }).joined(separator: ", ") } + + postType = remote.type } } diff --git a/WordPress/WordPressShareExtension/RemoteBlog+Capabilities.swift b/WordPress/WordPressShareExtension/RemoteBlog+Capabilities.swift index 170dcf0d2022..14ab85d327f8 100644 --- a/WordPress/WordPressShareExtension/RemoteBlog+Capabilities.swift +++ b/WordPress/WordPressShareExtension/RemoteBlog+Capabilities.swift @@ -26,7 +26,7 @@ extension RemoteBlog { /// Returns true if a given capability is enabled. False otherwise /// public func isUserCapableOf(_ capability: Capability) -> Bool { - return capabilities?[capability.rawValue] as? Bool ?? false + return capabilities[capability.rawValue, default: false] } /// Returns true if the current user is allowed to list a Blog's Users diff --git a/WordPress/WordPressShareExtension/RemotePost+ShareData.swift b/WordPress/WordPressShareExtension/RemotePost+ShareData.swift new file mode 100644 index 000000000000..47ea66c0b9ae --- /dev/null +++ b/WordPress/WordPressShareExtension/RemotePost+ShareData.swift @@ -0,0 +1,30 @@ +import Foundation +import WordPressKit + +extension RemotePost { + + /// Create a new Remote Post from the data provided by the Share and Action extensions. + /// - Parameters: + /// - shareData: The data from which to create the Remote Post + /// - siteID: Site ID the post will be uploaded to + convenience init(shareData: ShareData, siteID: Int) { + self.init(siteID: NSNumber(value: siteID), + status: shareData.postStatus.rawValue, + title: shareData.title, + content: shareData.contentBody) + + switch shareData.postType { + case .post: + type = "post" + if let remoteTags = shareData.tags { + tags = remoteTags.arrayOfTags() + } + + if let remoteCategories = RemotePostCategory.remotePostCategoriesFromString(shareData.selectedCategoriesIDString) { + categories = remoteCategories + } + case .page: + type = "page" + } + } +} diff --git a/WordPress/WordPressShareExtension/ShareCategoriesPickerViewController.swift b/WordPress/WordPressShareExtension/ShareCategoriesPickerViewController.swift index 5b8ad4725f16..c463dac4cb7b 100644 --- a/WordPress/WordPressShareExtension/ShareCategoriesPickerViewController.swift +++ b/WordPress/WordPressShareExtension/ShareCategoriesPickerViewController.swift @@ -41,7 +41,7 @@ class ShareCategoriesPickerViewController: UITableViewController { /// Apply Bar Button /// fileprivate lazy var selectButton: UIBarButtonItem = { - let applyTitle = NSLocalizedString("Select", comment: "Select action on the app extension category picker screen. Saves the selected categories for the post.") + let applyTitle = AppLocalizedString("Select", comment: "Select action on the app extension category picker screen. Saves the selected categories for the post.") let button = UIBarButtonItem(title: applyTitle, style: .plain, target: self, action: #selector(selectWasPressed)) button.accessibilityIdentifier = "Select Button" return button @@ -50,7 +50,7 @@ class ShareCategoriesPickerViewController: UITableViewController { /// Cancel Bar Button /// fileprivate lazy var cancelButton: UIBarButtonItem = { - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel action on the app extension category picker screen.") + let cancelTitle = AppLocalizedString("Cancel", comment: "Cancel action on the app extension category picker screen.") let button = UIBarButtonItem(title: cancelTitle, style: .plain, target: self, action: #selector(cancelWasPressed)) button.accessibilityIdentifier = "Cancel Button" return button diff --git a/WordPress/WordPressShareExtension/ShareData.swift b/WordPress/WordPressShareExtension/ShareData.swift index e81be25fe373..4948355fae77 100644 --- a/WordPress/WordPressShareExtension/ShareData.swift +++ b/WordPress/WordPressShareExtension/ShareData.swift @@ -6,6 +6,18 @@ enum PostStatus: String { case publish = "publish" } +enum PostType: String, CaseIterable { + case post = "post" + case page = "page" + + var title: String { + switch self { + case .post: return AppLocalizedString("Post", comment: "Title shown when selecting a post type of Post from the Share Extension.") + case .page: return AppLocalizedString("Page", comment: "Title shown when selecting a post type of Page from the Share Extension.") + } + } +} + /// ShareData is a state container for the share extension screens. /// @objc @@ -31,6 +43,10 @@ class ShareData: NSObject { /// var postStatus: PostStatus = .publish + /// Post's type, set to post by default + /// + var postType: PostType = .post + /// Dictionary of URLs mapped to attachment ID's /// var sharedImageDict = [URL: String]() diff --git a/WordPress/WordPressShareExtension/ShareExtensionAbstractViewController.swift b/WordPress/WordPressShareExtension/ShareExtensionAbstractViewController.swift index d96bb4e97592..faf62f8c054e 100644 --- a/WordPress/WordPressShareExtension/ShareExtensionAbstractViewController.swift +++ b/WordPress/WordPressShareExtension/ShareExtensionAbstractViewController.swift @@ -16,7 +16,7 @@ class ShareExtensionAbstractViewController: UIViewController, ShareSegueHandler case showModularSitePicker } - typealias CompletionBlock = () -> Void + typealias CompletionBlock = (_ exitSharing: Bool) -> Void // MARK: - Cache @@ -131,14 +131,14 @@ extension ShareExtensionAbstractViewController { return } - let title = NSLocalizedString("Sharing error", comment: "Share extension dialog title - displayed when user is missing a login token.") - let message = NSLocalizedString("Please launch the WordPress app, log in to WordPress.com and make sure you have at least one site, then try again.", comment: "Share extension dialog text - displayed when user is missing a login token.") - let accept = NSLocalizedString("Cancel sharing", comment: "Share extension dialog dismiss button label - displayed when user is missing a login token.") + let title = AppLocalizedString("Sharing error", comment: "Share extension dialog title - displayed when user is missing a login token.") + let message = AppLocalizedString("Please launch the WordPress app, log in to WordPress.com and make sure you have at least one site, then try again.", comment: "Share extension dialog text - displayed when user is missing a login token.") + let accept = AppLocalizedString("Cancel sharing", comment: "Share extension dialog dismiss button label - displayed when user is missing a login token.") let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) let alertAction = UIAlertAction(title: accept, style: .default) { (action) in self.cleanUpSharedContainerAndCache() - self.dismiss(animated: true, completion: self.dismissalCompletionBlock) + self.dismissalCompletionBlock?(false) } alertController.addAction(alertAction) diff --git a/WordPress/WordPressShareExtension/ShareExtensionEditorViewController.swift b/WordPress/WordPressShareExtension/ShareExtensionEditorViewController.swift index a4175da52035..ac11c5e1a405 100644 --- a/WordPress/WordPressShareExtension/ShareExtensionEditorViewController.swift +++ b/WordPress/WordPressShareExtension/ShareExtensionEditorViewController.swift @@ -12,7 +12,7 @@ class ShareExtensionEditorViewController: ShareExtensionAbstractViewController { /// Cancel Bar Button /// fileprivate lazy var cancelButton: UIBarButtonItem = { - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel action on share extension editor screen.") + let cancelTitle = AppLocalizedString("Cancel", comment: "Cancel action on share extension editor screen.") let button = UIBarButtonItem(title: cancelTitle, style: .plain, target: self, action: #selector(cancelWasPressed)) button.accessibilityIdentifier = "Cancel Button" return button @@ -21,7 +21,7 @@ class ShareExtensionEditorViewController: ShareExtensionAbstractViewController { /// Next Bar Button /// fileprivate lazy var nextButton: UIBarButtonItem = { - let nextButtonTitle = NSLocalizedString("Next", comment: "Next action on share extension editor screen.") + let nextButtonTitle = AppLocalizedString("Next", comment: "Next action on share extension editor screen.") let button = UIBarButtonItem(title: nextButtonTitle, style: .plain, target: self, action: #selector(nextWasPressed)) button.accessibilityIdentifier = "Next Button" return button @@ -48,7 +48,7 @@ class ShareExtensionEditorViewController: ShareExtensionAbstractViewController { textView.load(WordPressPlugin()) - let accessibilityLabel = NSLocalizedString("Rich Content", comment: "Post Rich content") + let accessibilityLabel = AppLocalizedString("Rich Content", comment: "Post Rich content") self.configureDefaultProperties(for: textView, accessibilityLabel: accessibilityLabel) let linkAttributes: [NSAttributedString.Key: Any] = [.underlineStyle: NSUnderlineStyle.single.rawValue, @@ -76,7 +76,7 @@ class ShareExtensionEditorViewController: ShareExtensionAbstractViewController { /// fileprivate(set) lazy var placeholderLabel: UILabel = { let label = UILabel() - label.text = NSLocalizedString("Share your story here...", comment: "Share Extension Content Body Text Placeholder") + label.text = AppLocalizedString("Share your story here...", comment: "Share Extension Content Body Text Placeholder") label.textColor = ShareColors.placeholder label.font = ShareFonts.regular label.isUserInteractionEnabled = false @@ -98,7 +98,7 @@ class ShareExtensionEditorViewController: ShareExtensionAbstractViewController { let textView = UITextView() - textView.accessibilityLabel = NSLocalizedString("Title", comment: "Post title") + textView.accessibilityLabel = AppLocalizedString("Title", comment: "Post title") textView.delegate = self textView.font = ShareFonts.title textView.returnKeyType = .next @@ -117,7 +117,7 @@ class ShareExtensionEditorViewController: ShareExtensionAbstractViewController { /// Placeholder Label /// fileprivate(set) lazy var titlePlaceholderLabel: UILabel = { - let placeholderText = NSLocalizedString("Title", comment: "Placeholder for the post title.") + let placeholderText = AppLocalizedString("Title", comment: "Placeholder for the post title.") let titlePlaceholderLabel = UILabel() let attributes: [NSAttributedString.Key: Any] = [.foregroundColor: ShareColors.title, .font: ShareFonts.title] @@ -472,7 +472,7 @@ class ShareExtensionEditorViewController: ShareExtensionAbstractViewController { toolbar.selectedTintColor = ShareColors.aztecFormatBarActiveColor toolbar.disabledTintColor = ShareColors.aztecFormatBarDisabledColor toolbar.dividerTintColor = ShareColors.aztecFormatBarDividerColor - toolbar.overflowToggleIcon = Gridicon.iconOfType(.ellipsis) + toolbar.overflowToggleIcon = .gridicon(.ellipsis) toolbar.overflowToolbar(expand: false) updateToolbar(toolbar) @@ -688,10 +688,10 @@ extension ShareExtensionEditorViewController { } func showLinkDialog(forURL url: URL?, title: String?, range: NSRange) { - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel button") - let removeTitle = NSLocalizedString("Remove Link", comment: "Label action for removing a link from the editor") - let insertTitle = NSLocalizedString("Insert Link", comment: "Label action for inserting a link on the editor") - let updateTitle = NSLocalizedString("Update Link", comment: "Label action for updating a link on the editor") + let cancelTitle = AppLocalizedString("Cancel", comment: "Cancel button") + let removeTitle = AppLocalizedString("Remove Link", comment: "Label action for removing a link from the editor") + let insertTitle = AppLocalizedString("Insert Link", comment: "Label action for inserting a link on the editor") + let updateTitle = AppLocalizedString("Update Link", comment: "Label action for updating a link on the editor") let isInsertingNewLink = (url == nil) var urlToUse = url @@ -710,7 +710,7 @@ extension ShareExtensionEditorViewController { // TextField: URL alertController.addTextField(configurationHandler: { [weak self] textField in textField.clearButtonMode = .always - textField.placeholder = NSLocalizedString("URL", comment: "URL text field placeholder") + textField.placeholder = AppLocalizedString("URL", comment: "URL text field placeholder") textField.text = urlToUse?.absoluteString textField.addTarget(self, @@ -721,7 +721,7 @@ extension ShareExtensionEditorViewController { // TextField: Link Name alertController.addTextField(configurationHandler: { textField in textField.clearButtonMode = .always - textField.placeholder = NSLocalizedString("Link Name", comment: "Link name field placeholder") + textField.placeholder = AppLocalizedString("Link Name", comment: "Link name field placeholder") textField.isSecureTextEntry = false textField.autocapitalizationType = .sentences textField.autocorrectionType = .default @@ -931,7 +931,7 @@ extension ShareExtensionEditorViewController { stopEditing() tracks.trackExtensionCancelled() cleanUpSharedContainerAndCache() - dismiss(animated: true, completion: self.dismissalCompletionBlock) + dismissalCompletionBlock?(false) } @objc func nextWasPressed() { @@ -944,23 +944,39 @@ extension ShareExtensionEditorViewController { func displayActions(forAttachment attachment: MediaAttachment, position: CGPoint) { let mediaID = attachment.identifier - let title: String = NSLocalizedString("Media Options", comment: "Title for action sheet with media options.") + let title: String = AppLocalizedString( + "shareExtension.editor.attachmentActions.title", + value: "Media Options", + comment: "Title for action sheet with media options." + ) let alertController = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) - alertController.addActionWithTitle(NSLocalizedString("Dismiss", comment: "User action to dismiss media options."), - style: .cancel, - handler: { (action) in - if attachment == self.currentSelectedAttachment { - self.currentSelectedAttachment = nil - self.resetMediaAttachmentOverlay(attachment) - self.richTextView.refresh(attachment) - } - }) + alertController.addActionWithTitle( + AppLocalizedString( + "shareExtension.editor.attachmentActions.dismiss", + value: "Dismiss", + comment: "User action to dismiss media options." + ), + style: .cancel, + handler: { (action) in + if attachment == self.currentSelectedAttachment { + self.currentSelectedAttachment = nil + self.resetMediaAttachmentOverlay(attachment) + self.richTextView.refresh(attachment) + } + } + ) if attachment is ImageAttachment { - alertController.addActionWithTitle(NSLocalizedString("Remove", comment: "User action to remove media."), - style: .destructive, - handler: { (action) in - self.richTextView.remove(attachmentID: mediaID) - }) + alertController.addActionWithTitle( + AppLocalizedString( + "shareExtension.editor.attachmentActions.remove", + value: "Remove", + comment: "User action to remove media." + ), + style: .destructive, + handler: { (action) in + self.richTextView.remove(attachmentID: mediaID) + } + ) } alertController.title = title @@ -969,7 +985,7 @@ extension ShareExtensionEditorViewController { alertController.popoverPresentationController?.sourceRect = CGRect(origin: position, size: CGSize(width: 1, height: 1)) alertController.popoverPresentationController?.permittedArrowDirections = .any present(alertController, animated: true, completion: { () in - UIMenuController.shared.setMenuVisible(false, animated: false) + UIMenuController.shared.hideMenu() }) } } @@ -1164,7 +1180,7 @@ extension ShareExtensionEditorViewController: TextViewAttachmentDelegate { } func textView(_ textView: TextView, placeholderFor attachment: NSTextAttachment) -> UIImage { - return Gridicon.iconOfType(.image, withSize: Constants.mediaPlaceholderImageSize) + return .gridicon(.image, size: Constants.mediaPlaceholderImageSize) } } @@ -1198,7 +1214,7 @@ private extension ShareExtensionEditorViewController { func refreshInsets(forKeyboardFrame keyboardFrame: CGRect) { let referenceView: UIScrollView = richTextView let bottomInset = (view.frame.maxY - (keyboardFrame.minY + self.view.layoutMargins.bottom) + Constants.insetBottomPadding) - let scrollInsets = UIEdgeInsets(top: referenceView.scrollIndicatorInsets.top, left: 0, bottom: bottomInset, right: 0) + let scrollInsets = UIEdgeInsets(top: referenceView.verticalScrollIndicatorInsets.top, left: 0, bottom: bottomInset, right: 0) let contentInsets = UIEdgeInsets(top: referenceView.contentInset.top, left: 0, bottom: bottomInset, right: 0) richTextView.scrollIndicatorInsets = scrollInsets @@ -1268,7 +1284,7 @@ private extension ShareExtensionEditorViewController { fileprivate extension ShareExtensionEditorViewController { struct Assets { - static let defaultMissingImage = Gridicon.iconOfType(.image) + static let defaultMissingImage = UIImage.gridicon(.image) } struct Constants { diff --git a/WordPress/WordPressShareExtension/ShareExtensionSessionManager.swift b/WordPress/WordPressShareExtension/ShareExtensionSessionManager.swift index e309809c80c0..20b2a89f6c07 100644 --- a/WordPress/WordPressShareExtension/ShareExtensionSessionManager.swift +++ b/WordPress/WordPressShareExtension/ShareExtensionSessionManager.swift @@ -88,8 +88,8 @@ import WordPressFlux } let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - guard let blog = blogService.blog(byBlogId: NSNumber(value: postUploadOp.siteID)) else { + + guard let blog = try? Blog.lookup(withID: postUploadOp.siteID, in: context) else { return } diff --git a/WordPress/WordPressShareExtension/ShareExtractor.swift b/WordPress/WordPressShareExtension/ShareExtractor.swift index 2b39e9357054..3b49f640e332 100644 --- a/WordPress/WordPressShareExtension/ShareExtractor.swift +++ b/WordPress/WordPressShareExtension/ShareExtractor.swift @@ -21,7 +21,7 @@ struct ExtractedShare { if let url = url { rawLink = url.absoluteString.stringWithAnchoredLinks() - let attributionText = NSLocalizedString("Read on", + let attributionText = AppLocalizedString("Read on", comment: "In the share extension, this is the text used right before attributing a quote to a website. Example: 'Read on www.site.com'. We are looking for the 'Read on' text in this situation.") readOnText = "
— \(attributionText) \(rawLink)" } @@ -276,7 +276,7 @@ private extension TypeBasedExtensionContentExtractor { } func saveToSharedContainer(image: UIImage) -> URL? { - guard let encodedMedia = image.resizeWithMaximumSize(maximumImageSize).JPEGEncoded(), + guard let encodedMedia = image.resizeWithMaximumSize(maximumImageSize)?.JPEGEncoded(), let fullPath = tempPath(for: "jpg") else { return nil } @@ -308,7 +308,7 @@ private extension TypeBasedExtensionContentExtractor { } func copyToSharedContainer(url: URL) -> URL? { - guard let newPath = tempPath(for: url.lastPathComponent) else { + guard let newPath = tempPath(for: url.pathExtension) else { return nil } diff --git a/WordPress/WordPressShareExtension/ShareModularViewController.swift b/WordPress/WordPressShareExtension/ShareModularViewController.swift index a495066d556a..f0d072231c08 100644 --- a/WordPress/WordPressShareExtension/ShareModularViewController.swift +++ b/WordPress/WordPressShareExtension/ShareModularViewController.swift @@ -28,7 +28,7 @@ class ShareModularViewController: ShareExtensionAbstractViewController { /// Back Bar Button /// fileprivate lazy var backButton: UIBarButtonItem = { - let backTitle = NSLocalizedString("Back", comment: "Back action on share extension site picker screen. Takes the user to the share extension editor screen.") + let backTitle = AppLocalizedString("Back", comment: "Back action on share extension site picker screen. Takes the user to the share extension editor screen.") let button = UIBarButtonItem(title: backTitle, style: .plain, target: self, action: #selector(backWasPressed)) button.accessibilityIdentifier = "Back Button" return button @@ -37,7 +37,7 @@ class ShareModularViewController: ShareExtensionAbstractViewController { /// Cancel Bar Button /// fileprivate lazy var cancelButton: UIBarButtonItem = { - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel action on the app extension modules screen.") + let cancelTitle = AppLocalizedString("Cancel", comment: "Cancel action on the app extension modules screen.") let button = UIBarButtonItem(title: cancelTitle, style: .plain, target: self, action: #selector(cancelWasPressed)) button.accessibilityIdentifier = "Cancel Button" return button @@ -48,9 +48,9 @@ class ShareModularViewController: ShareExtensionAbstractViewController { fileprivate lazy var publishButton: UIBarButtonItem = { let publishTitle: String if self.originatingExtension == .share { - publishTitle = NSLocalizedString("Publish", comment: "Publish post action on share extension site picker screen.") + publishTitle = AppLocalizedString("Publish", comment: "Publish post action on share extension site picker screen.") } else { - publishTitle = NSLocalizedString("Save", comment: "Save draft post action on share extension site picker screen.") + publishTitle = AppLocalizedString("Save", comment: "Save draft post action on share extension site picker screen.") } let button = UIBarButtonItem(title: publishTitle, style: .plain, target: self, action: #selector(publishWasPressed)) @@ -73,7 +73,7 @@ class ShareModularViewController: ShareExtensionAbstractViewController { /// Activity indicator used when loading categories /// - fileprivate lazy var categoryActivityIndicator = UIActivityIndicatorView(style: .gray) + fileprivate lazy var categoryActivityIndicator = UIActivityIndicatorView(style: .medium) /// No results view /// @@ -206,7 +206,9 @@ class ShareModularViewController: ShareExtensionAbstractViewController { // Update the height constraint to match the number of modules * row height let modulesTableHeight = modulesTableView.rectForRow(at: IndexPath(row: 0, section: 0)).height - modulesHeightConstraint.constant = (CGFloat(ModulesSection.count) * modulesTableHeight) + + let visibleModuleSections = ModulesSection.allCases.filter { !isModulesSectionEmpty($0.rawValue) }.count + modulesHeightConstraint.constant = (CGFloat(visibleModuleSections) * modulesTableHeight) } } @@ -214,17 +216,7 @@ class ShareModularViewController: ShareExtensionAbstractViewController { extension ShareModularViewController { fileprivate func dismiss() { - // In regular width size classes (iPad), action extensions are displayed - // in a small modal, which looks strange when this VC is dismissed - // before the main / presenting controller with its white background. - // This workaround simply dismisses the modular VC along with the main extension VC. - // See https://github.com/wordpress-mobile/WordPress-iOS/issues/8646 for more info. - guard UIDevice.isPad() == false && originatingExtension != .saveToDraft else { - dismissalCompletionBlock?() - return - } - - dismiss(animated: true, completion: dismissalCompletionBlock) + dismissalCompletionBlock?(true) } @objc func cancelWasPressed() { @@ -239,7 +231,7 @@ extension ShareModularViewController { editor.shareData = shareData editor.originatingExtension = originatingExtension } - _ = navigationController?.popViewController(animated: true) + navigationController?.popViewController(animated: true) } @objc func publishWasPressed() { @@ -254,6 +246,23 @@ extension ShareModularViewController { reloadSitesIfNeeded() } + func showPostTypePicker() { + guard isPublishingPost == false else { + return + } + + let typePicker = SharePostTypePickerViewController(postType: shareData.postType) + typePicker.onValueChanged = { [weak self] postType in + guard let self = self else { return } + self.tracks.trackExtensionPostTypeSelected(postType.rawValue) + self.shareData.postType = postType + self.refreshModulesTable() + } + + tracks.trackExtensionPostTypeOpened() + navigationController?.pushViewController(typePicker, animated: true) + } + func showTagsPicker() { guard let siteID = shareData.selectedSiteID, isPublishingPost == false else { return @@ -261,10 +270,11 @@ extension ShareModularViewController { let tagsPicker = ShareTagsPickerViewController(siteID: siteID, tags: shareData.tags) tagsPicker.onValueChanged = { [weak self] tagString in - if self?.shareData.tags != tagString { - self?.tracks.trackExtensionTagsSelected(tagString) - self?.shareData.tags = tagString - self?.refreshModulesTable() + guard let self = self else { return } + if self.shareData.tags != tagString { + self.tracks.trackExtensionTagsSelected(tagString) + self.shareData.tags = tagString + self.refreshModulesTable() } } @@ -283,10 +293,11 @@ extension ShareModularViewController { let categoryInfo = SiteCategories(siteID: siteID, allCategories: allSiteCategories, selectedCategories: shareData.userSelectedCategories, defaultCategoryID: shareData.defaultCategoryID) let categoriesPicker = ShareCategoriesPickerViewController(categoryInfo: categoryInfo) categoriesPicker.onValueChanged = { [weak self] categoryInfo in - self?.shareData.allCategoriesForSelectedSite = categoryInfo.allCategories - self?.shareData.userSelectedCategories = categoryInfo.selectedCategories - self?.tracks.trackExtensionCategoriesSelected(self?.shareData.selectedCategoriesNameString ?? "") - self?.refreshModulesTable() + guard let self = self else { return } + self.shareData.allCategoriesForSelectedSite = categoryInfo.allCategories + self.shareData.userSelectedCategories = categoryInfo.selectedCategories + self.tracks.trackExtensionCategoriesSelected(self.shareData.selectedCategoriesNameString) + self.refreshModulesTable() } tracks.trackExtensionCategoriesOpened() navigationController?.pushViewController(categoriesPicker, animated: true) @@ -298,7 +309,7 @@ extension ShareModularViewController { extension ShareModularViewController: UITableViewDataSource { func numberOfSections(in tableView: UITableView) -> Int { if tableView == modulesTableView { - return ModulesSection.count + return ModulesSection.allCases.count } else { // Only 1 section in the sites table return 1 @@ -307,14 +318,7 @@ extension ShareModularViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if tableView == modulesTableView { - switch ModulesSection(rawValue: section)! { - case .categories: - return 1 - case .tags: - return 1 - case .summary: - return 1 - } + return isModulesSectionEmpty(section) ? 0 : 1 } else { return rowCountForSites } @@ -399,10 +403,18 @@ extension ShareModularViewController: UITableViewDelegate { fileprivate extension ShareModularViewController { func configureModulesCell(_ cell: UITableViewCell, indexPath: IndexPath) { - switch indexPath.section { - case ModulesSection.categories.rawValue: + let moduleSection = ModulesSection(rawValue: indexPath.section) ?? .summary + switch moduleSection { + case .type: WPStyleGuide.Share.configureModuleCell(cell) - cell.textLabel?.text = NSLocalizedString("Category", comment: "Category menu item in share extension.") + cell.textLabel?.text = AppLocalizedString("Type", comment: "Type menu item in share extension.") + cell.accessoryType = .disclosureIndicator + cell.accessibilityLabel = "Type" + cell.isUserInteractionEnabled = true + cell.detailTextLabel?.text = shareData.postType.title + case .categories: + WPStyleGuide.Share.configureModuleCell(cell) + cell.textLabel?.text = AppLocalizedString("Category", comment: "Category menu item in share extension.") cell.accessibilityLabel = "Category" if isFetchingCategories { cell.isUserInteractionEnabled = false @@ -431,19 +443,20 @@ fileprivate extension ShareModularViewController { } else { cell.detailTextLabel?.textColor = .neutral(.shade70) } - case ModulesSection.tags.rawValue: + case .tags: WPStyleGuide.Share.configureModuleCell(cell) - cell.textLabel?.text = NSLocalizedString("Tags", comment: "Tags menu item in share extension.") + cell.textLabel?.text = AppLocalizedString("Tags", comment: "Tags menu item in share extension.") cell.accessoryType = .disclosureIndicator cell.accessibilityLabel = "Tags" + cell.isUserInteractionEnabled = true if let tags = shareData.tags, !tags.isEmpty { cell.detailTextLabel?.text = tags cell.detailTextLabel?.textColor = .neutral(.shade70) } else { - cell.detailTextLabel?.text = NSLocalizedString("Add tags", comment: "Placeholder text for tags module in share extension.") + cell.detailTextLabel?.text = AppLocalizedString("Add tags", comment: "Placeholder text for tags module in share extension.") cell.detailTextLabel?.textColor = .neutral(.shade30) } - default: + case .summary: // Summary section cell.textLabel?.text = summaryRowText() cell.textLabel?.textAlignment = .natural @@ -455,10 +468,12 @@ fileprivate extension ShareModularViewController { func isModulesSectionEmpty(_ sectionIndex: Int) -> Bool { switch ModulesSection(rawValue: sectionIndex)! { - case .categories: + case .type: return false + case .categories: + return shareData.postType == .page case .tags: - return false + return shareData.postType == .page case .summary: return false } @@ -466,6 +481,13 @@ fileprivate extension ShareModularViewController { func selectedModulesTableRowAt(_ indexPath: IndexPath) { switch ModulesSection(rawValue: indexPath.section)! { + case .type: + modulesTableView.flashRowAtIndexPath(indexPath, + scrollPosition: .none, + flashLength: Constants.flashAnimationLength, + completion: nil) + showPostTypePicker() + return case .categories: if shareData.categoryCountForSelectedSite > 1 { modulesTableView.flashRowAtIndexPath(indexPath, @@ -488,16 +510,31 @@ fileprivate extension ShareModularViewController { } func summaryRowText() -> String { - if originatingExtension == .share { - return SummaryText.summaryPublishing - } else if originatingExtension == .saveToDraft && shareData.sharedImageDict.isEmpty { - return SummaryText.summaryDraftDefault - } else if originatingExtension == .saveToDraft && !shareData.sharedImageDict.isEmpty { - return ShareNoticeText.pluralize(shareData.sharedImageDict.count, - singular: SummaryText.summaryDraftSingular, - plural: SummaryText.summaryDraftPlural) - } else { - return String() + switch shareData.postType { + case .post: + if originatingExtension == .share { + return SummaryText.summaryPostPublishing + } else if originatingExtension == .saveToDraft && shareData.sharedImageDict.isEmpty { + return SummaryText.summaryDraftPostDefault + } else if originatingExtension == .saveToDraft && !shareData.sharedImageDict.isEmpty { + return ShareNoticeText.pluralize(shareData.sharedImageDict.count, + singular: SummaryText.summaryDraftPostSingular, + plural: SummaryText.summaryDraftPostPlural) + } else { + return String() + } + case .page: + if originatingExtension == .share { + return SummaryText.summaryPagePublishing + } else if originatingExtension == .saveToDraft && shareData.sharedImageDict.isEmpty { + return SummaryText.summaryDraftPageDefault + } else if originatingExtension == .saveToDraft && !shareData.sharedImageDict.isEmpty { + return ShareNoticeText.pluralize(shareData.sharedImageDict.count, + singular: SummaryText.summaryDraftPageSingular, + plural: SummaryText.summaryDraftPagePlural) + } else { + return String() + } } } @@ -507,6 +544,7 @@ fileprivate extension ShareModularViewController { self.updatePublishButtonStatus() } modulesTableView.reloadData() + view.setNeedsUpdateConstraints() } func clearCategoriesAndRefreshModulesTable() { @@ -572,7 +610,7 @@ fileprivate extension ShareModularViewController { clearAllSelectedSiteRows() cell.accessoryType = .checkmark shareData.selectedSiteID = site.blogID.intValue - shareData.selectedSiteName = (site.name?.count)! > 0 ? site.name : URL(string: site.url)?.host + shareData.selectedSiteName = site.name.count > 0 ? site.name : URL(string: site.url)?.host fetchCategoriesForSelectedSite() updatePublishButtonStatus() self.refreshModulesTable() @@ -609,10 +647,16 @@ fileprivate extension ShareModularViewController { func showPublishingView() { let title: String = { - if self.originatingExtension == .share { - return StatusText.publishingTitle + switch (shareData.postType, originatingExtension) { + case (.post, .share): + return StatusText.publishingPostTitle + case (.page, .share): + return StatusText.publishingPageTitle + case (.post, .saveToDraft): + return StatusText.savingPostTitle + case (.page, .saveToDraft): + return StatusText.savingPageTitle } - return StatusText.savingTitle }() updatePublishButtonStatus() @@ -799,19 +843,15 @@ fileprivate extension ShareModularViewController { let service = AppExtensionsService() prepareForPublishing() - service.saveAndUploadPost(title: shareData.title, - body: shareData.contentBody, - tags: shareData.tags, - categories: shareData.selectedCategoriesIDString, - status: shareData.postStatus.rawValue, - siteID: siteID, - onComplete: { - self.dismiss() - }, onFailure: { - let error = self.createErrorWithDescription("Failed to save and upload post with no media.") - self.tracks.trackExtensionError(error) - self.showRetryAlert() - }) + service.saveAndUploadPost(shareData: shareData, + siteID: siteID, + onComplete: { + self.dismiss() + }, onFailure: { + let error = self.createErrorWithDescription("Failed to save and upload post with no media.") + self.tracks.trackExtensionError(error) + self.showRetryAlert() + }) } func uploadPostAndMedia(siteID: Int, localImageURLs: [URL]) { @@ -830,29 +870,41 @@ fileprivate extension ShareModularViewController { } prepareForPublishing() - service.uploadPostWithMedia(title: shareData.title, - body: shareData.contentBody, - tags: shareData.tags, - categories: shareData.selectedCategoriesIDString, - status: shareData.postStatus.rawValue, - siteID: siteID, - localMediaFileURLs: localImageURLs, - requestEnqueued: { - self.dismiss() - }, onFailure: { - let error = self.createErrorWithDescription("Failed to save and upload post with media.") - self.tracks.trackExtensionError(error) - self.showRetryAlert() - }) + service.saveAndUploadPostWithMedia(shareData: shareData, + siteID: siteID, + localMediaFileURLs: localImageURLs, + requestEnqueued: { + self.dismiss() + }, onFailure: { + let error = self.createErrorWithDescription("Failed to save and upload post with media.") + self.tracks.trackExtensionError(error) + self.showRetryAlert() + }) } func showRetryAlert() { - let title: String = NSLocalizedString("Sharing Error", comment: "Share extension error dialog title.") - let message: String = NSLocalizedString("Whoops, something went wrong while sharing. You can try again, maybe it was a glitch.", comment: "Share extension error dialog text.") - let dismiss: String = NSLocalizedString("Dismiss", comment: "Share extension error dialog cancel button label.") + let title: String = AppLocalizedString( + "shareModularViewController.retryAlert.title", + value: "Sharing Error", + comment: "Share extension error dialog title." + ) + let message: String = AppLocalizedString( + "shareModularViewController.retryAlert.message", + value: "Whoops, something went wrong while sharing. You can try again, maybe it was a glitch.", + comment: "Share extension error dialog text." + ) + let dismiss: String = AppLocalizedString( + "shareModularViewController.retryAlert.dismiss", + value: "Dismiss", + comment: "Share extension error dialog cancel button label." + ) let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - let acceptButtonText = NSLocalizedString("Try again", comment: "Share extension error dialog retry button label.") + let acceptButtonText = AppLocalizedString( + "shareModularViewController.retryAlert.accept", + value: "Try again", + comment: "Share extension error dialog retry button label." + ) let acceptAction = UIAlertAction(title: acceptButtonText, style: .default) { (action) in self.savePostToRemoteSite() } @@ -870,9 +922,9 @@ fileprivate extension ShareModularViewController { } func showPermissionsAlert() { - let title = NSLocalizedString("Sharing Error", comment: "Share extension error dialog title.") - let message = NSLocalizedString("Your account does not have permission to upload media to this site. The Site Administrator can change these permissions.", comment: "Share extension error dialog text.") - let dismiss = NSLocalizedString("Return to post", comment: "Share extension error dialog cancel button text") + let title = AppLocalizedString("Sharing Error", comment: "Share extension error dialog title.") + let message = AppLocalizedString("Your account does not have permission to upload media to this site. The Site Administrator can change these permissions.", comment: "Share extension error dialog text.") + let dismiss = AppLocalizedString("Return to post", comment: "Share extension error dialog cancel button text") let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) @@ -888,13 +940,16 @@ fileprivate extension ShareModularViewController { // MARK: - Table Sections fileprivate extension ShareModularViewController { - enum ModulesSection: Int { + enum ModulesSection: Int, CaseIterable { + case type case categories case tags case summary func headerText() -> String { switch self { + case .type: + return String() case .categories: return String() case .tags: @@ -906,6 +961,8 @@ fileprivate extension ShareModularViewController { func footerText() -> String { switch self { + case .type: + return String() case .categories: return String() case .tags: @@ -914,12 +971,6 @@ fileprivate extension ShareModularViewController { return String() } } - - static let count: Int = { - var max: Int = 0 - while let _ = ModulesSection(rawValue: max) { max += 1 } - return max - }() } } @@ -934,22 +985,28 @@ fileprivate extension ShareModularViewController { static let emptyCount = 0 static let flashAnimationLength = 0.2 static let unknownDefaultCategoryID = NSNumber(value: -1) - static let unknownDefaultCategoryName = NSLocalizedString("Default", comment: "Placeholder text displayed in the share extension's summary view. It lets the user know the default category will be used on their post.") + static let unknownDefaultCategoryName = AppLocalizedString("Default", comment: "Placeholder text displayed in the share extension's summary view. It lets the user know the default category will be used on their post.") } struct SummaryText { - static let summaryPublishing = NSLocalizedString("Publish post on:", comment: "Text displayed in the share extension's summary view. It describes the publish post action.") - static let summaryDraftDefault = NSLocalizedString("Save draft post on:", comment: "Text displayed in the share extension's summary view that describes the save draft post action.") - static let summaryDraftSingular = NSLocalizedString("Save 1 photo as a draft post on:", comment: "Text displayed in the share extension's summary view that describes the action of saving a single photo in a draft post.") - static let summaryDraftPlural = NSLocalizedString("Save %ld photos as a draft post on:", comment: "Text displayed in the share extension's summary view that describes the action of saving multiple photos in a draft post.") + static let summaryPostPublishing = AppLocalizedString("Publish post on:", comment: "Text displayed in the share extension's summary view. It describes the publish post action.") + static let summaryDraftPostDefault = AppLocalizedString("Save draft post on:", comment: "Text displayed in the share extension's summary view that describes the save draft post action.") + static let summaryDraftPostSingular = AppLocalizedString("Save 1 photo as a draft post on:", comment: "Text displayed in the share extension's summary view that describes the action of saving a single photo in a draft post.") + static let summaryDraftPostPlural = AppLocalizedString("Save %ld photos as a draft post on:", comment: "Text displayed in the share extension's summary view that describes the action of saving multiple photos in a draft post.") + static let summaryPagePublishing = AppLocalizedString("Publish page on:", comment: "Text displayed in the share extension's summary view. It describes the publish page action.") + static let summaryDraftPageDefault = AppLocalizedString("Save draft page on:", comment: "Text displayed in the share extension's summary view that describes the save draft page action.") + static let summaryDraftPageSingular = AppLocalizedString("Save 1 photo as a draft page on:", comment: "Text displayed in the share extension's summary view that describes the action of saving a single photo in a draft page.") + static let summaryDraftPagePlural = AppLocalizedString("Save %ld photos as a draft page on:", comment: "Text displayed in the share extension's summary view that describes the action of saving multiple photos in a draft page.") } struct StatusText { - static let loadingTitle = NSLocalizedString("Fetching sites...", comment: "A short message to inform the user data for their sites are being fetched.") - static let publishingTitle = NSLocalizedString("Publishing post...", comment: "A short message that informs the user a post is being published to the server from the share extension.") - static let savingTitle = NSLocalizedString("Saving post…", comment: "A short message that informs the user a draft post is being saved to the server from the share extension.") - static let cancellingTitle = NSLocalizedString("Canceling...", comment: "A short message that informs the user the share extension is being canceled.") - static let noSitesTitle = NSLocalizedString("No available sites", comment: "A short message that informs the user no sites could be loaded in the share extension.") + static let loadingTitle = AppLocalizedString("Fetching sites...", comment: "A short message to inform the user data for their sites are being fetched.") + static let publishingPostTitle = AppLocalizedString("Publishing post...", comment: "A short message that informs the user a post is being published to the server from the share extension.") + static let savingPostTitle = AppLocalizedString("Saving post…", comment: "A short message that informs the user a draft post is being saved to the server from the share extension.") + static let publishingPageTitle = AppLocalizedString("Publishing page...", comment: "A short message that informs the user a page is being published to the server from the share extension.") + static let savingPageTitle = AppLocalizedString("Saving page…", comment: "A short message that informs the user a draft page is being saved to the server from the share extension.") + static let cancellingTitle = AppLocalizedString("Canceling...", comment: "A short message that informs the user the share extension is being canceled.") + static let noSitesTitle = AppLocalizedString("No available sites", comment: "A short message that informs the user no sites could be loaded in the share extension.") } } diff --git a/WordPress/WordPressShareExtension/ShareNoticeConstants.swift b/WordPress/WordPressShareExtension/ShareNoticeConstants.swift index 7a9828c81ae4..2c139e66f915 100644 --- a/WordPress/WordPressShareExtension/ShareNoticeConstants.swift +++ b/WordPress/WordPressShareExtension/ShareNoticeConstants.swift @@ -15,21 +15,21 @@ enum ShareNoticeUserInfoKey { } struct ShareNoticeText { - static let actionEditPost = NSLocalizedString("Edit Post", comment: "Button title. Opens the editor to edit an existing post.") + static let actionEditPost = AppLocalizedString("Edit Post", comment: "Button title. Opens the editor to edit an existing post.") - static let successDraftTitleDefault = NSLocalizedString("1 draft post uploaded", comment: "Local notification displayed to the user when a single draft post has been successfully uploaded.") - static let successTitleDefault = NSLocalizedString("1 post uploaded", comment: "Alert displayed to the user when a single post has been successfully uploaded.") - static let successDraftTitleSingular = NSLocalizedString("Uploaded 1 draft post, 1 file", comment: "Local notification displayed to the user when a single draft post and 1 file has been uploaded successfully.") - static let successTitleSingular = NSLocalizedString("Uploaded 1 post, 1 file", comment: "System notification displayed to the user when a single post and 1 file has uploaded successfully.") - static let successDraftTitlePlural = NSLocalizedString("Uploaded 1 draft post, %ld files", comment: "Local notification displayed to the user when a single draft post and multiple files have uploaded successfully.") - static let successTitlePlural = NSLocalizedString("Uploaded 1 post, %ld files", comment: "System notification displayed to the user when a single post and multiple files have uploaded successfully.") + static let successDraftTitleDefault = AppLocalizedString("1 draft post uploaded", comment: "Local notification displayed to the user when a single draft post has been successfully uploaded.") + static let successTitleDefault = AppLocalizedString("1 post uploaded", comment: "Alert displayed to the user when a single post has been successfully uploaded.") + static let successDraftTitleSingular = AppLocalizedString("Uploaded 1 draft post, 1 file", comment: "Local notification displayed to the user when a single draft post and 1 file has been uploaded successfully.") + static let successTitleSingular = AppLocalizedString("Uploaded 1 post, 1 file", comment: "System notification displayed to the user when a single post and 1 file has uploaded successfully.") + static let successDraftTitlePlural = AppLocalizedString("Uploaded 1 draft post, %ld files", comment: "Local notification displayed to the user when a single draft post and multiple files have uploaded successfully.") + static let successTitlePlural = AppLocalizedString("Uploaded 1 post, %ld files", comment: "System notification displayed to the user when a single post and multiple files have uploaded successfully.") - static let failureDraftTitleDefault = NSLocalizedString("Unable to upload 1 draft post", comment: "Alert displayed to the user when a single post has failed to upload.") - static let failureTitleDefault = NSLocalizedString("Unable to upload 1 post", comment: "Alert displayed to the user when a single post has failed to upload.") - static let failureDraftTitleSingular = NSLocalizedString("Unable to upload 1 draft post, 1 file", comment: "Alert displayed to the user when a single post and 1 file has failed to upload.") - static let failureTitleSingular = NSLocalizedString("Unable to upload 1 post, 1 file", comment: "Alert displayed to the user when a single post and 1 file has failed to upload.") - static let failureDraftTitlePlural = NSLocalizedString("Unable to upload 1 draft post, %ld files", comment: "Alert displayed to the user when a single post and multiple files have failed to upload.") - static let failureTitlePlural = NSLocalizedString("Unable to upload 1 post, %ld files", comment: "Alert displayed to the user when a single post and multiple files have failed to upload.") + static let failureDraftTitleDefault = AppLocalizedString("Unable to upload 1 draft post", comment: "Alert displayed to the user when a single post has failed to upload.") + static let failureTitleDefault = AppLocalizedString("Unable to upload 1 post", comment: "Alert displayed to the user when a single post has failed to upload.") + static let failureDraftTitleSingular = AppLocalizedString("Unable to upload 1 draft post, 1 file", comment: "Alert displayed to the user when a single post and 1 file has failed to upload.") + static let failureTitleSingular = AppLocalizedString("Unable to upload 1 post, 1 file", comment: "Alert displayed to the user when a single post and 1 file has failed to upload.") + static let failureDraftTitlePlural = AppLocalizedString("Unable to upload 1 draft post, %ld files", comment: "Alert displayed to the user when a single post and multiple files have failed to upload.") + static let failureTitlePlural = AppLocalizedString("Unable to upload 1 post, %ld files", comment: "Alert displayed to the user when a single post and multiple files have failed to upload.") /// Helper method to provide the formatted version of a success title based on the media item count. /// diff --git a/WordPress/WordPressShareExtension/ShareNoticeNavigationCoordinator.swift b/WordPress/WordPressShareExtension/ShareNoticeNavigationCoordinator.swift index ae67972d4090..ed1ed9b94f76 100644 --- a/WordPress/WordPressShareExtension/ShareNoticeNavigationCoordinator.swift +++ b/WordPress/WordPressShareExtension/ShareNoticeNavigationCoordinator.swift @@ -19,13 +19,13 @@ class ShareNoticeNavigationCoordinator { let editor = EditPostViewController.init(post: post) editor.modalPresentationStyle = .fullScreen - WPTabBarController.sharedInstance().present(editor, animated: false) + RootViewCoordinator.sharedPresenter.rootViewController.present(editor, animated: false) } static func navigateToPostList(with userInfo: NSDictionary) { fetchPost(from: userInfo, onSuccess: { post in if let post = post { - WPTabBarController.sharedInstance().switchTabToPostsList(for: post) + RootViewCoordinator.sharedPresenter.showPosts(for: post.blog) } }, onFailure: { DDLogError("Could not fetch post from share notification.") @@ -35,7 +35,7 @@ class ShareNoticeNavigationCoordinator { static func navigateToBlogDetails(with userInfo: NSDictionary) { fetchBlog(from: userInfo, onSuccess: { blog in if let blog = blog { - WPTabBarController.sharedInstance().switchMySitesTabToBlogDetails(for: blog) + RootViewCoordinator.sharedPresenter.showBlogDetails(for: blog) } }, onFailure: { DDLogError("Could not fetch blog from share notification.") @@ -46,14 +46,13 @@ class ShareNoticeNavigationCoordinator { onSuccess: @escaping (_ post: Post?) -> Void, onFailure: @escaping () -> Void) { let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) let postService = PostService(managedObjectContext: context) guard let postIDString = userInfo[ShareNoticeUserInfoKey.postID] as? String, let postID = NumberFormatter().number(from: postIDString), let siteIDString = userInfo[ShareNoticeUserInfoKey.blogID] as? String, let siteID = NumberFormatter().number(from: siteIDString), - let blog = blogService.blog(byBlogId: siteID) else { + let blog = Blog.lookup(withID: siteID, in: context) else { onFailure() return } @@ -78,9 +77,7 @@ class ShareNoticeNavigationCoordinator { return } - let context = ContextManager.sharedInstance().mainContext - let blogService = BlogService(managedObjectContext: context) - let blog = blogService.blog(byBlogId: siteID) - onSuccess(blog) + let context = ContextManager.shared.mainContext + onSuccess(Blog.lookup(withID: siteID, in: context)) } } diff --git a/WordPress/WordPressShareExtension/ShareNoticeViewModel.swift b/WordPress/WordPressShareExtension/ShareNoticeViewModel.swift index abbf6b57522f..4b09fc7e402f 100644 --- a/WordPress/WordPressShareExtension/ShareNoticeViewModel.swift +++ b/WordPress/WordPressShareExtension/ShareNoticeViewModel.swift @@ -96,6 +96,6 @@ struct ShareNoticeViewModel { } private var notificationBody: String { - return postInContext?.dateForDisplay()?.mediumString() ?? Date().mediumString() + return postInContext?.dateForDisplay()?.toMediumString() ?? Date().toMediumString() } } diff --git a/WordPress/WordPressShareExtension/SharePostTypePickerViewController.swift b/WordPress/WordPressShareExtension/SharePostTypePickerViewController.swift new file mode 100644 index 000000000000..e5ef650f1b78 --- /dev/null +++ b/WordPress/WordPressShareExtension/SharePostTypePickerViewController.swift @@ -0,0 +1,195 @@ +import Foundation +import CocoaLumberjack +import WordPressKit +import WordPressShared + +class SharePostTypePickerViewController: UITableViewController { + + // MARK: - Public Properties + + var onValueChanged: ((PostType) -> Void)? + + // MARK: - Private Properties + + /// Originally selected post type + /// + fileprivate var originallySelectedPostType: PostType + + /// Selected post type + /// + fileprivate var selectedPostType: PostType + + /// Apply Bar Button + /// + fileprivate lazy var selectButton: UIBarButtonItem = { + let applyTitle = AppLocalizedString("Select", comment: "Select action on the app extension post type picker screen. Saves the selected post type for the post.") + let button = UIBarButtonItem(title: applyTitle, style: .plain, target: self, action: #selector(selectWasPressed)) + button.accessibilityIdentifier = "Select Button" + return button + }() + + /// Cancel Bar Button + /// + fileprivate lazy var cancelButton: UIBarButtonItem = { + let cancelTitle = AppLocalizedString("Cancel", comment: "Cancel action on the app extension post type picker screen.") + let button = UIBarButtonItem(title: cancelTitle, style: .plain, target: self, action: #selector(cancelWasPressed)) + button.accessibilityIdentifier = "Cancel Button" + return button + }() + + // MARK: - Initializers + + init(postType: PostType) { + self.originallySelectedPostType = postType + self.selectedPostType = postType + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + // Initialize Interface + setupNavigationBar() + setupTableView() + } + + // MARK: - Setup Helpers + + fileprivate func setupNavigationBar() { + navigationItem.hidesBackButton = true + navigationItem.leftBarButtonItem = cancelButton + navigationItem.rightBarButtonItem = selectButton + } + + fileprivate func setupTableView() { + WPStyleGuide.configureColors(view: view, tableView: tableView) + WPStyleGuide.configureAutomaticHeightRows(for: tableView) + tableView.register(UITableViewCell.self, forCellReuseIdentifier: Constants.cellReuseIdentifier) + + // Hide the separators, whenever the table is empty + tableView.tableFooterView = UIView() + + tableView.reloadData() + } + + // MARK: - UITableView Overrides + + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return rowCountForPostTypes + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: Constants.cellReuseIdentifier)! + configurePostTypeCell(cell, indexPath: indexPath) + return cell + } + + override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return Constants.defaultRowHeight + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return nil + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + return nil + } + + override func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) { + WPStyleGuide.configureTableViewSectionFooter(view) + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.flashRowAtIndexPath(indexPath, + scrollPosition: .none, + flashLength: Constants.flashAnimationLength, + completion: nil) + selectedPostTypeTableRowAt(indexPath) + } +} + +// MARK: - Post Type UITableView Helpers + +fileprivate extension SharePostTypePickerViewController { + func configurePostTypeCell(_ cell: UITableViewCell, indexPath: IndexPath) { + let postType = postTypeForRowAtIndexPath(indexPath) + cell.textLabel?.text = postType.title + cell.detailTextLabel?.isEnabled = false + cell.detailTextLabel?.text = nil + if selectedPostType == postType { + cell.accessoryType = .checkmark + } else { + cell.accessoryType = .none + } + WPStyleGuide.Share.configurePostTypeCell(cell) + } + + var rowCountForPostTypes: Int { + return PostType.allCases.count + } + + func selectedPostTypeTableRowAt(_ indexPath: IndexPath) { + guard let cell = tableView.cellForRow(at: indexPath) else { + return + } + + deselectedPostTypeTableRowAt(indexPathForPostType(selectedPostType)) + + let postType = postTypeForRowAtIndexPath(indexPath) + selectedPostType = postType + cell.accessoryType = .checkmark + } + + func deselectedPostTypeTableRowAt(_ indexPath: IndexPath) { + guard let cell = tableView.cellForRow(at: indexPath) else { + return + } + cell.accessoryType = .none + } + + func postTypeForRowAtIndexPath(_ indexPath: IndexPath) -> PostType { + return PostType.allCases[indexPath.row] + } + + func indexPathForPostType(_ postType: PostType) -> IndexPath { + let postTypeRow = PostType.allCases.firstIndex(of: postType)! + return IndexPath(row: postTypeRow, section: 0) + } +} + +// MARK: - Actions + +extension SharePostTypePickerViewController { + @objc func cancelWasPressed() { + navigationController?.popViewController(animated: true) + } + + @objc func selectWasPressed() { + if originallySelectedPostType != selectedPostType { + onValueChanged?(selectedPostType) + } + navigationController?.popViewController(animated: true) + } +} + +// MARK: - Constants + +fileprivate extension SharePostTypePickerViewController { + struct Constants { + static let cellReuseIdentifier = String(describing: SharePostTypePickerViewController.self) + static let defaultRowHeight = CGFloat(44.0) + static let flashAnimationLength = 0.2 + static let indentationMultiplier = 3 + } +} diff --git a/WordPress/WordPressShareExtension/ShareTagsPickerViewController.swift b/WordPress/WordPressShareExtension/ShareTagsPickerViewController.swift index 35a608dc1141..c1ca13ed7448 100644 --- a/WordPress/WordPressShareExtension/ShareTagsPickerViewController.swift +++ b/WordPress/WordPressShareExtension/ShareTagsPickerViewController.swift @@ -21,7 +21,7 @@ class ShareTagsPickerViewController: UIViewController { /// Apply Bar Button /// fileprivate lazy var applyButton: UIBarButtonItem = { - let applyTitle = NSLocalizedString("Apply", comment: "Apply action on the app extension tags picker screen. Saves the selected tags for the post.") + let applyTitle = AppLocalizedString("Apply", comment: "Apply action on the app extension tags picker screen. Saves the selected tags for the post.") let button = UIBarButtonItem(title: applyTitle, style: .plain, target: self, action: #selector(applyWasPressed)) button.accessibilityIdentifier = "Apply Button" return button @@ -30,7 +30,7 @@ class ShareTagsPickerViewController: UIViewController { /// Cancel Bar Button /// fileprivate lazy var cancelButton: UIBarButtonItem = { - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel action on the app extension tags picker screen.") + let cancelTitle = AppLocalizedString("Cancel", comment: "Cancel action on the app extension tags picker screen.") let button = UIBarButtonItem(title: cancelTitle, style: .plain, target: self, action: #selector(cancelWasPressed)) button.accessibilityIdentifier = "Cancel Button" return button @@ -384,7 +384,7 @@ private class LoadingDataSource: NSObject, PostTagPickerDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: LoadingDataSource.cellIdentifier, for: indexPath) WPStyleGuide.Share.configureLoadingTagCell(cell) - cell.textLabel?.text = NSLocalizedString("Loading...", comment: "Loading tags") + cell.textLabel?.text = AppLocalizedString("Loading...", comment: "Loading tags") cell.selectionStyle = .none return cell } @@ -408,7 +408,7 @@ private class FailureDataSource: NSObject, PostTagPickerDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: FailureDataSource.cellIdentifier, for: indexPath) WPStyleGuide.Share.configureLoadingTagCell(cell) - cell.textLabel?.text = NSLocalizedString("Couldn't load tags. Tap to retry.", comment: "Error message when tag loading failed") + cell.textLabel?.text = AppLocalizedString("Couldn't load tags. Tap to retry.", comment: "Error message when tag loading failed") return cell } } diff --git a/WordPress/WordPressShareExtension/String+Extensions.swift b/WordPress/WordPressShareExtension/String+Extensions.swift index 1ad7318a8777..0b03e29ac93c 100644 --- a/WordPress/WordPressShareExtension/String+Extensions.swift +++ b/WordPress/WordPressShareExtension/String+Extensions.swift @@ -88,6 +88,14 @@ extension String { return self + returnURLString } + /// Returns a Boolean value indicating if this String begins with the provided prefix. + /// - Parameter prefix: The prefix to check for. + /// - Parameter options: The string comparison options to use when checking for the prefix. + func hasPrefix(_ prefix: String, with options: CompareOptions) -> Bool { + let fullOptions = options.union([.anchored]) + return range(of: prefix, options: fullOptions) != nil + } + /// Returns true if this String consists of digits var isNumeric: Bool { return !isEmpty && rangeOfCharacter(from: CharacterSet.decimalDigits.inverted) == nil diff --git a/WordPress/WordPressShareExtension/TextBundleWrapper.m b/WordPress/WordPressShareExtension/TextBundleWrapper.m index bc4a6fd82822..3b33d5acf934 100644 --- a/WordPress/WordPressShareExtension/TextBundleWrapper.m +++ b/WordPress/WordPressShareExtension/TextBundleWrapper.m @@ -181,7 +181,7 @@ - (NSString *)textFileNameInFileWrapper:(NSFileWrapper*)fileWrapper { // Finding the text.* file inside the .textbundle __block NSString *filename = nil; - [[fileWrapper fileWrappers] enumerateKeysAndObjectsUsingBlock:^(NSString *key, NSFileWrapper * obj, BOOL *stop) + [[fileWrapper fileWrappers] enumerateKeysAndObjectsUsingBlock:^(NSString * __unused key, NSFileWrapper * obj, BOOL * __unused stop) { if([[obj.filename lowercaseString] hasPrefix:@"text"]) { filename = obj.filename; @@ -203,7 +203,7 @@ - (NSString *)textFilenameForType:(NSString *)type - (NSFileWrapper *)fileWrapperForAssetFilename:(NSString *)filename { __block NSFileWrapper *fileWrapper = nil; - [[self.assetsFileWrapper fileWrappers] enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSFileWrapper * _Nonnull obj, BOOL * _Nonnull stop) { + [[self.assetsFileWrapper fileWrappers] enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull __unused key, NSFileWrapper * _Nonnull __unused obj, BOOL * _Nonnull __unused stop) { if ([obj.filename isEqualToString:filename] || [obj.preferredFilename isEqualToString:filename]) { fileWrapper = obj; } diff --git a/WordPress/WordPressShareExtension/Tracks.swift b/WordPress/WordPressShareExtension/Tracks.swift index c569ce45da87..9ef803c2616c 100644 --- a/WordPress/WordPressShareExtension/Tracks.swift +++ b/WordPress/WordPressShareExtension/Tracks.swift @@ -3,6 +3,7 @@ import UIKit open class Tracks { // MARK: - Public Properties open var wpcomUsername: String? + open var wpcomUserID: String? // MARK: - Private Properties fileprivate let uploader: Uploader @@ -22,7 +23,8 @@ open class Tracks { // MARK: - Public Methods open func track(_ eventName: String, properties: [String: Any]? = nil) { - let payload = payloadWithEventName(eventName, properties: properties) + let prefixedEventName = "\(TracksConfiguration.eventNamePrefix)_\(eventName)" + let payload = payloadWithEventName(prefixedEventName, properties: properties) uploader.send(payload) } @@ -31,7 +33,7 @@ open class Tracks { // MARK: - Private Helpers fileprivate func payloadWithEventName(_ eventName: String, properties: [String: Any]?) -> [String: Any] { let timestamp = NSNumber(value: Int64(Date().timeIntervalSince1970 * 1000) as Int64) - let userID = UUID().uuidString + let anonUserID = UUID().uuidString let device = UIDevice.current let bundle = Bundle.main let appName = bundle.object(forInfoDictionaryKey: "CFBundleName") as? String @@ -55,8 +57,11 @@ open class Tracks { if let username = wpcomUsername { payload["_ul"] = username payload["_ut"] = "wpcom:user_id" + if let userID = wpcomUserID { + payload["_ui"] = userID + } } else { - payload["_ui"] = userID + payload["_ui"] = anonUserID payload["_ut"] = "anon" } diff --git a/WordPress/WordPressShareExtension/TracksConfiguration.swift b/WordPress/WordPressShareExtension/TracksConfiguration.swift new file mode 100644 index 000000000000..9ffde93c3914 --- /dev/null +++ b/WordPress/WordPressShareExtension/TracksConfiguration.swift @@ -0,0 +1,3 @@ +struct TracksConfiguration { + static let eventNamePrefix = "wpios" +} diff --git a/WordPress/WordPressShareExtension/UIImage+Extensions.swift b/WordPress/WordPressShareExtension/UIImage+Extensions.swift index d4b9a6d95a4c..3798519dadef 100644 --- a/WordPress/WordPressShareExtension/UIImage+Extensions.swift +++ b/WordPress/WordPressShareExtension/UIImage+Extensions.swift @@ -1,5 +1,5 @@ import Foundation - +import ImageIO extension UIImage { convenience init?(contentsOfURL url: URL) { @@ -10,8 +10,22 @@ extension UIImage { self.init(data: rawImage) } - func resizeWithMaximumSize(_ maximumSize: CGSize) -> UIImage { - return resizedImage(with: .scaleAspectFit, bounds: maximumSize, interpolationQuality: .high) + func resizeWithMaximumSize(_ maximumSize: CGSize) -> UIImage? { + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageIfAbsent: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maximumSize, + ] + + guard let imageData = pngData() as CFData?, + let imageSource = CGImageSourceCreateWithData(imageData, nil), + let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) else { + + return nil + } + + return UIImage(cgImage: image) } func JPEGEncoded(_ quality: CGFloat = 0.8) -> Data? { diff --git a/WordPress/WordPressShareExtension/WPStyleGuide+Share.swift b/WordPress/WordPressShareExtension/WPStyleGuide+Share.swift index 91fd3bc96f3c..7f78d30125bd 100644 --- a/WordPress/WordPressShareExtension/WPStyleGuide+Share.swift +++ b/WordPress/WordPressShareExtension/WPStyleGuide+Share.swift @@ -41,6 +41,17 @@ extension WPStyleGuide { cell.separatorInset = UIEdgeInsets.zero } + static func configurePostTypeCell(_ cell: UITableViewCell) { + cell.textLabel?.font = tableviewTextFont() + cell.textLabel?.sizeToFit() + cell.textLabel?.textColor = .text + cell.textLabel?.numberOfLines = 0 + + cell.backgroundColor = .listForeground + cell.separatorInset = UIEdgeInsets.zero + cell.tintColor = .primary + } + static func configureLoadingTagCell(_ cell: UITableViewCell) { cell.textLabel?.font = tableviewTextFont() cell.textLabel?.sizeToFit() diff --git a/WordPress/WordPressShareExtension/ar.lproj/Localizable.strings b/WordPress/WordPressShareExtension/ar.lproj/Localizable.strings deleted file mode 100644 index ad9932ead06f..000000000000 Binary files a/WordPress/WordPressShareExtension/ar.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/bg.lproj/Localizable.strings b/WordPress/WordPressShareExtension/bg.lproj/Localizable.strings deleted file mode 100644 index 510ab65203cc..000000000000 Binary files a/WordPress/WordPressShareExtension/bg.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/cs.lproj/Localizable.strings b/WordPress/WordPressShareExtension/cs.lproj/Localizable.strings deleted file mode 100644 index 9d54773ac87f..000000000000 Binary files a/WordPress/WordPressShareExtension/cs.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/cy.lproj/Localizable.strings b/WordPress/WordPressShareExtension/cy.lproj/Localizable.strings deleted file mode 100644 index db75af893981..000000000000 Binary files a/WordPress/WordPressShareExtension/cy.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/da.lproj/Localizable.strings b/WordPress/WordPressShareExtension/da.lproj/Localizable.strings deleted file mode 100644 index b56616025942..000000000000 Binary files a/WordPress/WordPressShareExtension/da.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/de.lproj/Localizable.strings b/WordPress/WordPressShareExtension/de.lproj/Localizable.strings deleted file mode 100644 index 6ee678698be7..000000000000 Binary files a/WordPress/WordPressShareExtension/de.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/en-AU.lproj/Localizable.strings b/WordPress/WordPressShareExtension/en-AU.lproj/Localizable.strings deleted file mode 100644 index 52ff5ba0e192..000000000000 Binary files a/WordPress/WordPressShareExtension/en-AU.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/en-CA.lproj/Localizable.strings b/WordPress/WordPressShareExtension/en-CA.lproj/Localizable.strings deleted file mode 100644 index 2631930ed248..000000000000 Binary files a/WordPress/WordPressShareExtension/en-CA.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/en-GB.lproj/Localizable.strings b/WordPress/WordPressShareExtension/en-GB.lproj/Localizable.strings deleted file mode 100644 index 52ff5ba0e192..000000000000 Binary files a/WordPress/WordPressShareExtension/en-GB.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/es.lproj/Localizable.strings b/WordPress/WordPressShareExtension/es.lproj/Localizable.strings deleted file mode 100644 index 3063aae9d04e..000000000000 Binary files a/WordPress/WordPressShareExtension/es.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/fr.lproj/Localizable.strings b/WordPress/WordPressShareExtension/fr.lproj/Localizable.strings deleted file mode 100644 index cb7701639724..000000000000 Binary files a/WordPress/WordPressShareExtension/fr.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/he.lproj/Localizable.strings b/WordPress/WordPressShareExtension/he.lproj/Localizable.strings deleted file mode 100644 index 0d00613e79d1..000000000000 Binary files a/WordPress/WordPressShareExtension/he.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/hr.lproj/Localizable.strings b/WordPress/WordPressShareExtension/hr.lproj/Localizable.strings deleted file mode 100644 index 29812581eab3..000000000000 Binary files a/WordPress/WordPressShareExtension/hr.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/hu.lproj/Localizable.strings b/WordPress/WordPressShareExtension/hu.lproj/Localizable.strings deleted file mode 100644 index 8252aef39aad..000000000000 Binary files a/WordPress/WordPressShareExtension/hu.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/id.lproj/Localizable.strings b/WordPress/WordPressShareExtension/id.lproj/Localizable.strings deleted file mode 100644 index b70af5a62d11..000000000000 Binary files a/WordPress/WordPressShareExtension/id.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/is.lproj/Localizable.strings b/WordPress/WordPressShareExtension/is.lproj/Localizable.strings deleted file mode 100644 index c522bce6c4b9..000000000000 Binary files a/WordPress/WordPressShareExtension/is.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/it.lproj/Localizable.strings b/WordPress/WordPressShareExtension/it.lproj/Localizable.strings deleted file mode 100644 index 754504db4700..000000000000 Binary files a/WordPress/WordPressShareExtension/it.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/ja.lproj/Localizable.strings b/WordPress/WordPressShareExtension/ja.lproj/Localizable.strings deleted file mode 100644 index ae58fa5f3cdb..000000000000 Binary files a/WordPress/WordPressShareExtension/ja.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/ko.lproj/Localizable.strings b/WordPress/WordPressShareExtension/ko.lproj/Localizable.strings deleted file mode 100644 index 9002be749865..000000000000 Binary files a/WordPress/WordPressShareExtension/ko.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/nb.lproj/Localizable.strings b/WordPress/WordPressShareExtension/nb.lproj/Localizable.strings deleted file mode 100644 index d8a222c4f371..000000000000 Binary files a/WordPress/WordPressShareExtension/nb.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/nl.lproj/Localizable.strings b/WordPress/WordPressShareExtension/nl.lproj/Localizable.strings deleted file mode 100644 index 939423b7c22b..000000000000 Binary files a/WordPress/WordPressShareExtension/nl.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/pl.lproj/Localizable.strings b/WordPress/WordPressShareExtension/pl.lproj/Localizable.strings deleted file mode 100644 index 26a0bb2e2754..000000000000 Binary files a/WordPress/WordPressShareExtension/pl.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/pt-BR.lproj/Localizable.strings b/WordPress/WordPressShareExtension/pt-BR.lproj/Localizable.strings deleted file mode 100644 index a0ad95e2f757..000000000000 Binary files a/WordPress/WordPressShareExtension/pt-BR.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/pt.lproj/Localizable.strings b/WordPress/WordPressShareExtension/pt.lproj/Localizable.strings deleted file mode 100644 index 2d5d5fd0e79a..000000000000 Binary files a/WordPress/WordPressShareExtension/pt.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/ro.lproj/Localizable.strings b/WordPress/WordPressShareExtension/ro.lproj/Localizable.strings deleted file mode 100644 index 08a7e5c08fcb..000000000000 Binary files a/WordPress/WordPressShareExtension/ro.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/ru.lproj/Localizable.strings b/WordPress/WordPressShareExtension/ru.lproj/Localizable.strings deleted file mode 100644 index c25f0df709d8..000000000000 Binary files a/WordPress/WordPressShareExtension/ru.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/sk.lproj/Localizable.strings b/WordPress/WordPressShareExtension/sk.lproj/Localizable.strings deleted file mode 100644 index 24d7c21365c1..000000000000 Binary files a/WordPress/WordPressShareExtension/sk.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/sq.lproj/Localizable.strings b/WordPress/WordPressShareExtension/sq.lproj/Localizable.strings deleted file mode 100644 index 0ae3ec2c65f7..000000000000 Binary files a/WordPress/WordPressShareExtension/sq.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/sv.lproj/Localizable.strings b/WordPress/WordPressShareExtension/sv.lproj/Localizable.strings deleted file mode 100644 index d5accbd660d2..000000000000 Binary files a/WordPress/WordPressShareExtension/sv.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/th.lproj/Localizable.strings b/WordPress/WordPressShareExtension/th.lproj/Localizable.strings deleted file mode 100644 index 66013d5174cd..000000000000 Binary files a/WordPress/WordPressShareExtension/th.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/tr.lproj/Localizable.strings b/WordPress/WordPressShareExtension/tr.lproj/Localizable.strings deleted file mode 100644 index 3959f95efcb6..000000000000 Binary files a/WordPress/WordPressShareExtension/tr.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/zh-Hans.lproj/Localizable.strings b/WordPress/WordPressShareExtension/zh-Hans.lproj/Localizable.strings deleted file mode 100644 index f24de2004325..000000000000 Binary files a/WordPress/WordPressShareExtension/zh-Hans.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressShareExtension/zh-Hant.lproj/Localizable.strings b/WordPress/WordPressShareExtension/zh-Hant.lproj/Localizable.strings deleted file mode 100644 index a9f0283a3f9d..000000000000 Binary files a/WordPress/WordPressShareExtension/zh-Hant.lproj/Localizable.strings and /dev/null differ diff --git a/WordPress/WordPressStatsWidgets/Assets.xcassets/AccentColor.colorset/Contents.json b/WordPress/WordPressStatsWidgets/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000000..eb8789700816 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/WordPressStatsWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json b/WordPress/WordPressStatsWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..9221b9bb1a35 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/WordPressStatsWidgets/Assets.xcassets/Contents.json b/WordPress/WordPressStatsWidgets/Assets.xcassets/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/WordPressStatsWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json b/WordPress/WordPressStatsWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 000000000000..eb8789700816 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPress/WordPressStatsWidgets/Cache/HomeWidgetCache.swift b/WordPress/WordPressStatsWidgets/Cache/HomeWidgetCache.swift new file mode 100644 index 000000000000..b8ca43b88446 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Cache/HomeWidgetCache.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Cache manager that stores `HomeWidgetData` values in a plist file, contained in the specified security application group and with the specified file name. +/// The corresponding dictionary is always in the form `[Int: T]`, where the `Int` key is the SiteID, and the `T` value is any `HomeWidgetData` instance. +struct HomeWidgetCache { + + let fileName: String + let appGroup: String + + private var fileURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)?.appendingPathComponent(fileName) + } + + func read() throws -> [Int: T]? { + + guard let fileURL = fileURL, + FileManager.default.fileExists(atPath: fileURL.path) else { + return nil + } + + let data = try Data(contentsOf: fileURL) + return try PropertyListDecoder().decode([Int: T].self, from: data) + } + + func write(items: [Int: T]) throws { + + guard let fileURL = fileURL else { + return + } + + let encodedData = try PropertyListEncoder().encode(items) + try encodedData.write(to: fileURL) + } + + func setItem(item: T) throws { + var cachedItems = try read() ?? [Int: T]() + + cachedItems[item.siteID] = item + + try write(items: cachedItems) + } + + func delete() throws { + + guard let fileURL = fileURL else { + return + } + try FileManager.default.removeItem(at: fileURL) + } +} diff --git a/WordPress/WordPressStatsWidgets/Model/GroupedViewData.swift b/WordPress/WordPressStatsWidgets/Model/GroupedViewData.swift new file mode 100644 index 000000000000..3c3f3afdcda6 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Model/GroupedViewData.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct GroupedViewData { + + let widgetTitle: LocalizedString + let siteName: String + let upperLeftTitle: LocalizedString + let upperLeftValue: Int + let upperRightTitle: LocalizedString + let upperRightValue: Int + let lowerLeftTitle: LocalizedString + let lowerLeftValue: Int + let lowerRightTitle: LocalizedString + let lowerRightValue: Int + + let statsURL: URL? +} diff --git a/WordPress/WordPressStatsWidgets/Model/HomeWidgetAllTimeData.swift b/WordPress/WordPressStatsWidgets/Model/HomeWidgetAllTimeData.swift new file mode 100644 index 000000000000..d87504ad0604 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Model/HomeWidgetAllTimeData.swift @@ -0,0 +1,11 @@ + +struct HomeWidgetAllTimeData: HomeWidgetData { + + let siteID: Int + let siteName: String + let url: String + let timeZone: TimeZone + let date: Date + let stats: AllTimeWidgetStats + static let filename = AppConfiguration.Widget.Stats.allTimeFilename +} diff --git a/WordPress/WordPressStatsWidgets/Model/HomeWidgetData.swift b/WordPress/WordPressStatsWidgets/Model/HomeWidgetData.swift new file mode 100644 index 000000000000..2d72a200866a --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Model/HomeWidgetData.swift @@ -0,0 +1,68 @@ +import WidgetKit + +protocol HomeWidgetData: Codable { + + var siteID: Int { get } + var siteName: String { get } + var url: String { get } + var timeZone: TimeZone { get } + var date: Date { get } + + static var filename: String { get } +} + + +// MARK: - Local cache +extension HomeWidgetData { + + static func read(from cache: HomeWidgetCache? = nil) -> [Int: Self]? { + + let cache = cache ?? HomeWidgetCache(fileName: Self.filename, + appGroup: WPAppGroupName) + do { + return try cache.read() + } catch { + DDLogError("HomeWidgetToday: Failed loading data: \(error.localizedDescription)") + return nil + } + } + + static func write(items: [Int: Self], to cache: HomeWidgetCache? = nil) { + + let cache = cache ?? HomeWidgetCache(fileName: Self.filename, + appGroup: WPAppGroupName) + + do { + try cache.write(items: items) + } catch { + DDLogError("HomeWidgetToday: Failed writing data: \(error.localizedDescription)") + } + } + + static func delete(cache: HomeWidgetCache? = nil) { + let cache = cache ?? HomeWidgetCache(fileName: Self.filename, + appGroup: WPAppGroupName) + + do { + try cache.delete() + } catch { + DDLogError("HomeWidgetToday: Failed deleting data: \(error.localizedDescription)") + } + } + + static func setItem(item: Self, to cache: HomeWidgetCache? = nil) { + let cache = cache ?? HomeWidgetCache(fileName: Self.filename, + appGroup: WPAppGroupName) + + do { + try cache.setItem(item: item) + } catch { + DDLogError("HomeWidgetToday: Failed writing data item: \(error.localizedDescription)") + } + } + + static func cacheDataExists() -> Bool { + let data = read() + return data != nil && data?.isEmpty == false + } +} diff --git a/WordPress/WordPressStatsWidgets/Model/HomeWidgetThisWeekData.swift b/WordPress/WordPressStatsWidgets/Model/HomeWidgetThisWeekData.swift new file mode 100644 index 000000000000..ec238ea8547c --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Model/HomeWidgetThisWeekData.swift @@ -0,0 +1,11 @@ + +struct HomeWidgetThisWeekData: HomeWidgetData { + + let siteID: Int + let siteName: String + let url: String + let timeZone: TimeZone + let date: Date + let stats: ThisWeekWidgetStats + static let filename = AppConfiguration.Widget.Stats.thisWeekFilename +} diff --git a/WordPress/WordPressStatsWidgets/Model/HomeWidgetTodayData.swift b/WordPress/WordPressStatsWidgets/Model/HomeWidgetTodayData.swift new file mode 100644 index 000000000000..15c7bd791994 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Model/HomeWidgetTodayData.swift @@ -0,0 +1,11 @@ + +struct HomeWidgetTodayData: HomeWidgetData { + + let siteID: Int + let siteName: String + let url: String + let timeZone: TimeZone + let date: Date + let stats: TodayWidgetStats + static let filename = AppConfiguration.Widget.Stats.todayFilename +} diff --git a/WordPress/WordPressStatsWidgets/Model/ListViewData.swift b/WordPress/WordPressStatsWidgets/Model/ListViewData.swift new file mode 100644 index 000000000000..8f484f19b16b --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Model/ListViewData.swift @@ -0,0 +1,10 @@ +import SwiftUI + +struct ListViewData { + + let widgetTitle: LocalizedString + let siteName: String + let items: [ThisWeekWidgetDay] + + let statsURL: URL? +} diff --git a/WordPress/WordPressStatsWidgets/Model/StatsWidgetEntry.swift b/WordPress/WordPressStatsWidgets/Model/StatsWidgetEntry.swift new file mode 100644 index 000000000000..526d6a344afc --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Model/StatsWidgetEntry.swift @@ -0,0 +1,18 @@ +import WidgetKit + +enum StatsWidgetEntry: TimelineEntry { + case siteSelected(HomeWidgetData, TimelineProviderContext) + case loggedOut(StatsWidgetKind) + case noSite(StatsWidgetKind) + case noData(StatsWidgetKind) + case disabled(StatsWidgetKind) + + var date: Date { + switch self { + case .siteSelected(let widgetData, _): + return widgetData.date + case .loggedOut, .noSite, .noData, .disabled: + return Date() + } + } +} diff --git a/WordPress/WordPressStatsWidgets/Remote service/StatsWidgetsService.swift b/WordPress/WordPressStatsWidgets/Remote service/StatsWidgetsService.swift new file mode 100644 index 000000000000..3ce038e032e3 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Remote service/StatsWidgetsService.swift @@ -0,0 +1,178 @@ +import WordPressKit + +/// Type that wraps the backend request for new stats +class StatsWidgetsService { + + private enum State { + case loading + case ready + case error + + var isLoading: Bool { + switch self { + case .loading: + return true + case .error, .ready: + return false + } + } + } + + private var state: State = .ready + + + func fetchStats(for widgetData: HomeWidgetData, + completion: @escaping (Result) -> Void) { + + guard !state.isLoading else { + return + } + state = .loading + + do { + let token = try SFHFKeychainUtils.getPasswordForUsername(AppConfiguration.Widget.Stats.keychainTokenKey, + andServiceName: AppConfiguration.Widget.Stats.keychainServiceName, + accessGroup: WPAppKeychainAccessGroup) + let wpApi = WordPressComRestApi(oAuthToken: token) + let service = StatsServiceRemoteV2(wordPressComRestApi: wpApi, siteID: widgetData.siteID, siteTimezone: widgetData.timeZone) + + // handle fetching depending on concrete type + // we need to do like this as there is no unique service call + if let widgetData = widgetData as? HomeWidgetTodayData { + fetchTodayStats(service: service, widgetData: widgetData, completion: completion) + } else if let widgetData = widgetData as? HomeWidgetAllTimeData { + fetchAllTimeStats(service: service, widgetData: widgetData, completion: completion) + } else if let widgetData = widgetData as? HomeWidgetThisWeekData { + fetchThisWeekStats(service: service, widgetData: widgetData, completion: completion) + } + } catch { + completion(.failure(error)) + self.state = .error + } + } + + private func fetchTodayStats(service: StatsServiceRemoteV2, + widgetData: HomeWidgetTodayData, + completion: @escaping (Result) -> Void) { + + service.getInsight { [weak self] (insight: StatsTodayInsight?, error) in + guard let self = self else { + return + } + + if let error = error { + completion(.failure(error)) + self.state = .error + return + } + + guard let insight = insight else { + completion(.failure(StatsWidgetsError.nilStats)) + self.state = .error + return + } + + let newWidgetData = HomeWidgetTodayData(siteID: widgetData.siteID, + siteName: widgetData.siteName, + url: widgetData.url, + timeZone: widgetData.timeZone, + date: Date(), + stats: TodayWidgetStats(views: insight.viewsCount, + visitors: insight.visitorsCount, + likes: insight.likesCount, + comments: insight.commentsCount)) + completion(.success(newWidgetData)) + DispatchQueue.main.async { + // update the item in the local cache + HomeWidgetTodayData.setItem(item: newWidgetData) + } + self.state = .ready + } + } + + private func fetchAllTimeStats(service: StatsServiceRemoteV2, + widgetData: HomeWidgetAllTimeData, + completion: @escaping (Result) -> Void) { + + service.getInsight { [weak self] (insight: StatsAllTimesInsight?, error) in + + guard let self = self else { + return + } + + if let error = error { + completion(.failure(error)) + self.state = .error + return + } + + let newWidgetData = HomeWidgetAllTimeData(siteID: widgetData.siteID, + siteName: widgetData.siteName, + url: widgetData.url, + timeZone: widgetData.timeZone, + date: Date(), + stats: AllTimeWidgetStats(views: + insight?.viewsCount, + visitors: insight?.visitorsCount, + posts: insight?.postsCount, + bestViews: insight?.bestViewsPerDayCount)) + completion(.success(newWidgetData)) + DispatchQueue.main.async { + // update the item in the local cache + HomeWidgetAllTimeData.setItem(item: newWidgetData) + } + self.state = .ready + } + } + + private func fetchThisWeekStats(service: StatsServiceRemoteV2, + widgetData: HomeWidgetThisWeekData, + completion: @escaping (Result) -> Void) { + + // Get the current date in the site's time zone. + let siteTimeZone = widgetData.timeZone + let weekEndingDate = Date().convert(from: siteTimeZone).normalizedDate() + + // Include an extra day. It's needed to get the dailyChange for the last day. + service.getData(for: .day, endingOn: weekEndingDate, + limit: ThisWeekWidgetStats.maxDaysToDisplay + 1) { [weak self] (summary: StatsSummaryTimeIntervalData?, error: Error?) in + + guard let self = self else { + return + } + + if let error = error { + DDLogError("This Week Widget: Error fetching summary: \(String(describing: error.localizedDescription))") + completion(.failure(error)) + self.state = .error + return + } + + let summaryData = summary?.summaryData.reversed() ?? [] + let newWidgetData = HomeWidgetThisWeekData(siteID: widgetData.siteID, + siteName: widgetData.siteName, + url: widgetData.url, + timeZone: widgetData.timeZone, + date: Date(), + stats: ThisWeekWidgetStats(days: ThisWeekWidgetStats.daysFrom(summaryData: summaryData))) + completion(.success(newWidgetData)) + + DispatchQueue.global().async { + HomeWidgetThisWeekData.setItem(item: newWidgetData) + } + self.state = .ready + } + } +} + +enum StatsWidgetsError: Error { + case nilStats +} + + +private extension Date { + func convert(from timeZone: TimeZone, comparedWith target: TimeZone = TimeZone.current) -> Date { + let delta = TimeInterval(timeZone.secondsFromGMT(for: self) - target.secondsFromGMT(for: self)) + return addingTimeInterval(delta) + } +} diff --git a/WordPress/WordPressStatsWidgets/SiteListProvider.swift b/WordPress/WordPressStatsWidgets/SiteListProvider.swift new file mode 100644 index 000000000000..86e9cc16a2e3 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/SiteListProvider.swift @@ -0,0 +1,130 @@ +import WidgetKit +import SwiftUI + +struct SiteListProvider: IntentTimelineProvider { + + let service: StatsWidgetsService + let placeholderContent: T + let widgetKind: StatsWidgetKind + + // refresh interval of the widget, in minutes + let refreshInterval = 30 + // minimum elapsed time, in minutes, before new data are fetched from the backend. + let minElapsedTimeToRefresh = 1 + + private var defaultSiteID: Int? { + + UserDefaults(suiteName: WPAppGroupName)?.object(forKey: AppConfiguration.Widget.Stats.userDefaultsSiteIdKey) as? Int + } + + func placeholder(in context: Context) -> StatsWidgetEntry { + StatsWidgetEntry.siteSelected(placeholderContent, context) + } + + func getSnapshot(for configuration: SelectSiteIntent, in context: Context, completion: @escaping (StatsWidgetEntry) -> Void) { + + guard let site = configuration.site, + let siteIdentifier = site.identifier, + let widgetData = widgetData(for: siteIdentifier) else { + + if let siteID = defaultSiteID, let content = T.read()?[siteID] { + completion(.siteSelected(content, context)) + } else { + completion(.siteSelected(placeholderContent, context)) + } + return + } + + completion(.siteSelected(widgetData, context)) + } + + func getTimeline(for configuration: SelectSiteIntent, in context: Context, completion: @escaping (Timeline) -> Void) { + guard let defaults = UserDefaults(suiteName: WPAppGroupName) else { + completion(Timeline(entries: [.noData(widgetKind)], policy: .never)) + return + } + guard !defaults.bool(forKey: AppConfiguration.Widget.Stats.userDefaultsJetpackFeaturesDisabledKey) else { + completion(Timeline(entries: [.disabled(widgetKind)], policy: .never)) + return + } + guard let defaultSiteID = defaultSiteID else { + let loggedIn = defaults.bool(forKey: AppConfiguration.Widget.Stats.userDefaultsLoggedInKey) + + if loggedIn { + completion(Timeline(entries: [.noSite(widgetKind)], policy: .never)) + } else { + completion(Timeline(entries: [.loggedOut(widgetKind)], policy: .never)) + } + return + } + + guard let widgetData = widgetData(for: configuration, defaultSiteID: defaultSiteID) else { + completion(Timeline(entries: [.noData(widgetKind)], policy: .never)) + return + } + + let date = Date() + let nextRefreshDate = Calendar.current.date(byAdding: .minute, value: refreshInterval, to: date) ?? date + let elapsedTime = abs(Calendar.current.dateComponents([.minute], from: widgetData.date, to: date).minute ?? 0) + + let privateCompletion = { (timelineEntry: StatsWidgetEntry) in + let timeline = Timeline(entries: [timelineEntry], policy: .after(nextRefreshDate)) + completion(timeline) + } + + // if cached data are "too old", refresh them from the backend, otherwise keep them + guard elapsedTime > minElapsedTimeToRefresh else { + + privateCompletion(.siteSelected(widgetData, context)) + return + } + + service.fetchStats(for: widgetData) { result in + + switch result { + case .failure(let error): + DDLogError("StatsWidgets: failed to fetch remote stats. Returned error: \(error.localizedDescription)") + + privateCompletion(.siteSelected(widgetData, context)) + + case .success(let newWidgetData): + + privateCompletion(.siteSelected(newWidgetData, context)) + } + } + } +} + +// MARK: - Widget Data + +private extension SiteListProvider { + /// Returns cached widget data based on the selected site when editing widget and the default site. + /// Configuration.site is nil until IntentHandler is initialized. + /// Configuration.site can have old value after logging in with a different account. No way to reset configuration when the user logs out. + /// Using defaultSiteID if both of these cases. + /// - Parameters: + /// - configuration: Configuration of the Widget Site Selection Intent + /// - defaultSiteID: ID of the default site in the account + /// - Returns: Widget data + func widgetData(for configuration: SelectSiteIntent, defaultSiteID: Int) -> T? { + + /// If configuration.site.identifier has value but there's no widgetData, it means that this identifier comes from previously logged in account + return widgetData(for: configuration.site?.identifier ?? String(defaultSiteID)) + ?? widgetData(for: String(defaultSiteID)) + } + + func widgetData(for siteID: String) -> T? { + /// - TODO: we should not really be needing to do this conversion. Maybe we can evaluate a better mechanism for site identification. + guard let siteID = Int(siteID) else { + return nil + } + + return T.read()?[siteID] + } +} + +enum StatsWidgetKind { + case today + case allTime + case thisWeek +} diff --git a/WordPress/WordPressStatsWidgets/StatsWidgets.swift b/WordPress/WordPressStatsWidgets/StatsWidgets.swift new file mode 100644 index 000000000000..748b8aad201d --- /dev/null +++ b/WordPress/WordPressStatsWidgets/StatsWidgets.swift @@ -0,0 +1,11 @@ +import SwiftUI +import WidgetKit + +@main +struct WordPressStatsWidgets: WidgetBundle { + var body: some Widget { + WordPressHomeWidgetToday() + WordPressHomeWidgetThisWeek() + WordPressHomeWidgetAllTime() + } +} diff --git a/WordPress/WordPressStatsWidgets/Supporting Files/Info.plist b/WordPress/WordPressStatsWidgets/Supporting Files/Info.plist new file mode 100644 index 000000000000..8101431c50af --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Supporting Files/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + WordPress Home Today + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + ${VERSION_SHORT} + CFBundleVersion + ${VERSION_LONG} + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/WordPress/WordPressAllTimeWidget/WordPressAllTimeWidget-Bridging-Header.h b/WordPress/WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets-Bridging-Header.h similarity index 100% rename from WordPress/WordPressAllTimeWidget/WordPressAllTimeWidget-Bridging-Header.h rename to WordPress/WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets-Bridging-Header.h diff --git a/WordPress/WordPressAllTimeWidget/WordPressAllTimeWidget.entitlements b/WordPress/WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets.entitlements similarity index 100% rename from WordPress/WordPressAllTimeWidget/WordPressAllTimeWidget.entitlements rename to WordPress/WordPressStatsWidgets/Supporting Files/WordPressStatsWidgets.entitlements diff --git a/WordPress/WordPressStatsWidgets/Supporting Files/WordPressStatsWidgetsRelease-Alpha.entitlements b/WordPress/WordPressStatsWidgets/Supporting Files/WordPressStatsWidgetsRelease-Alpha.entitlements new file mode 100644 index 000000000000..5517320d7801 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Supporting Files/WordPressStatsWidgetsRelease-Alpha.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.org.wordpress.alpha + + keychain-access-groups + + $(AppIdentifierPrefix)99KV9Z6BKV.org.wordpress.alpha + + + diff --git a/WordPress/WordPressStatsWidgets/Supporting Files/WordPressStatsWidgetsRelease-Internal.entitlements b/WordPress/WordPressStatsWidgets/Supporting Files/WordPressStatsWidgetsRelease-Internal.entitlements new file mode 100644 index 000000000000..a820b8b89266 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Supporting Files/WordPressStatsWidgetsRelease-Internal.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.org.wordpress.internal + + keychain-access-groups + + $(AppIdentifierPrefix)99KV9Z6BKV.org.wordpress.internal + + + diff --git a/WordPress/WordPressStatsWidgets/Tracks/Tracks+StatsWidgets.swift b/WordPress/WordPressStatsWidgets/Tracks/Tracks+StatsWidgets.swift new file mode 100644 index 000000000000..1545b2597209 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Tracks/Tracks+StatsWidgets.swift @@ -0,0 +1,90 @@ +import Foundation +import WidgetKit + +/// This extension implements helper tracking methods, meant for Today Home Widget usage. +/// +extension Tracks { + + func trackWidgetUpdatedIfNeeded(entry: StatsWidgetEntry, widgetKind: String, widgetCountKey: String) { + switch entry { + case .siteSelected(_, let context): + if !context.isPreview { + trackWidgetUpdated(widgetKind: widgetKind, + widgetCountKey: widgetCountKey) + } + + case .loggedOut, .noSite, .noData, .disabled: + trackWidgetUpdated(widgetKind: widgetKind, + widgetCountKey: widgetCountKey) + } + } + + func trackWidgetUpdated(widgetKind: String, widgetCountKey: String) { + + DispatchQueue.global().async { + WidgetCenter.shared.getCurrentConfigurations { result in + + switch result { + + case .success(let widgetInfo): + let widgetKindInfo = widgetInfo.filter { $0.kind == widgetKind } + self.trackUpdatedWidgetInfo(widgetInfo: widgetKindInfo, widgetPropertiesKey: widgetCountKey) + + case .failure(let error): + DDLogError("Home Widget Today error: unable to read widget information. \(error.localizedDescription)") + } + } + } + } + + private func trackUpdatedWidgetInfo(widgetInfo: [WidgetInfo], widgetPropertiesKey: String) { + + let properties = ["total_widgets": widgetInfo.count, + "small_widgets": widgetInfo.filter { $0.family == .systemSmall }.count, + "medium_widgets": widgetInfo.filter { $0.family == .systemMedium }.count, + "large_widgets": widgetInfo.filter { $0.family == .systemLarge }.count] + + let previousProperties = UserDefaults(suiteName: WPAppGroupName)?.object(forKey: widgetPropertiesKey) as? [String: Int] + + guard previousProperties != properties else { + return + } + + UserDefaults(suiteName: WPAppGroupName)?.set(properties, forKey: widgetPropertiesKey) + + trackExtensionEvent(ExtensionEvents.widgetUpdated(for: widgetPropertiesKey), properties: properties as [String: AnyObject]?) + } + + // MARK: - Private Helpers + + fileprivate func trackExtensionEvent(_ event: ExtensionEvents, properties: [String: AnyObject]? = nil) { + track(event.rawValue, properties: properties) + } + + + // MARK: - Private Enums + + fileprivate enum ExtensionEvents: String { + // User installs an instance of the today widget + case todayWidgetUpdated = "today_home_extension_widget_updated" + // User installs an instance of the all time widget + case allTimeWidgetUpdated = "alltime_home_extension_widget_updated" + // Users installs an instance of the this week widget + case thisWeekWidgetUpdated = "thisweek_home_extension_widget_updated" + + case noEvent + + static func widgetUpdated(for key: String) -> ExtensionEvents { + switch key { + case AppConfiguration.Widget.Stats.todayProperties: + return .todayWidgetUpdated + case AppConfiguration.Widget.Stats.allTimeProperties: + return .allTimeWidgetUpdated + case AppConfiguration.Widget.Stats.thisWeekProperties: + return .thisWeekWidgetUpdated + default: + return .noEvent + } + } + } +} diff --git a/WordPress/WordPressStatsWidgets/Views/Cards/FlexibleCard.swift b/WordPress/WordPressStatsWidgets/Views/Cards/FlexibleCard.swift new file mode 100644 index 000000000000..0a453b92cc9d --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Views/Cards/FlexibleCard.swift @@ -0,0 +1,81 @@ +import SwiftUI + +/// A card with a title and a numeric or string value that can be either vertically or horizontally stacked +struct FlexibleCard: View { + let axis: Axis + let title: LocalizedString + let value: Value + let lineLimit: Int + + init(axis: Axis, title: LocalizedString, value: Value, lineLimit: Int = 1) { + self.axis = axis + self.title = title + self.value = value + self.lineLimit = lineLimit + } + + enum Value { + case number(Int) + case description(String) + } + + @ViewBuilder + private var descriptionView: some View { + + switch value { + + case .number(let number): + + StatsValueView(value: number, + font: Appearance.textFont, + fontWeight: Appearance.textFontWeight, + foregroundColor: Appearance.textColor, + lineLimit: lineLimit) + + case .description(let description): + + Text(description) + .font(Appearance.textFont) + .fontWeight(Appearance.textFontWeight) + .foregroundColor(Appearance.textColor) + .lineLimit(lineLimit) + } + } + + private var titleView: some View { + Text(title) + .font(Appearance.titleFont) + .foregroundColor(Appearance.titleColor) + } + + var body: some View { + switch axis { + case .vertical: + VStack(alignment: .leading) { + descriptionView + titleView + } + + case .horizontal: + HStack { + descriptionView + Spacer() + titleView + } + } + } +} + +// MARK: - Appearance +extension FlexibleCard { + + private enum Appearance { + static let textFont = Font.footnote + static let textFontWeight = Font.Weight.semibold + static let textColor = Color(.label) + + static let titleFont = Font.caption + static let titleColor = Color(.secondaryLabel) + + } +} diff --git a/WordPress/WordPressStatsWidgets/Views/Cards/ListRow.swift b/WordPress/WordPressStatsWidgets/Views/Cards/ListRow.swift new file mode 100644 index 000000000000..b098837745ca --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Views/Cards/ListRow.swift @@ -0,0 +1,114 @@ + +import SwiftUI +import WidgetKit + +struct ListRow: View { + @Environment(\.widgetFamily) var family: WidgetFamily + + let date: Date + let percentValue: Float + let value: Int + + private var rowHeight: CGFloat { + switch family { + case .systemMedium: + return Constants.mediumRowHeight + case .systemLarge: + return Constants.largeRowHeight + default: + return 0 + } + } + + private var isToday: Bool { + NSCalendar.current.isDateInToday(date) + } + + private var isTomorrow: Bool { + NSCalendar.current.isDateInTomorrow(date) + } + + let percentFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .percent + formatter.positivePrefix = "+" + return formatter + }() + + let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy") + return formatter + }() + + private var differenceBackgroundColor: Color { + guard !(isToday || isTomorrow) else { + return Constants.neutralColor + } + return percentValue < 0 ? Constants.negativeColor : Constants.positiveColor + } + + private var differenceLabelText: LocalizedString { + guard !isToday else { + return LocalizableStrings.todayWidgetTitle + } + + return dateFormatter.string(from: date) + } + + var body: some View { + HStack(alignment: .center) { + Text(differenceLabelText) + .font(Constants.dateViewFont) + .fontWeight(Constants.dateViewFontWeight) + .foregroundColor(Constants.dateViewFontColor) + Spacer() + Text(value.abbreviatedString()) + .font(Constants.dataViewFont) + .foregroundColor(Constants.dataViewFontColor) + + Text(percentFormatter.string(for: percentValue) ?? "0") + + .frame(minWidth: Constants.differenceViewMinWidth, + alignment: Constants.differenceViewAlignment) + .padding(Constants.differenceViewInsets) + .font(Constants.differenceViewFont) + .foregroundColor(Constants.differenceTextColor) + .background(differenceBackgroundColor) + .cornerRadius(Constants.differenceCornerRadius) + } + .frame(height: rowHeight) + .offset(x: 0, y: Constants.verticalCenteringOffset) // each row isn't _quite_ centered vertically + // and we're not entirely sure why yet, but this fixes it + } +} + +private extension ListRow { + + enum Constants { + // difference view + static let positiveColor = Color("Green50") + static let negativeColor = Color("Red50") + static let neutralColor = Color(UIColor.systemGray) + static let differenceTextColor = Color.white + + static let differenceViewInsets = EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8) + + static let differenceViewMinWidth: CGFloat = 56.0 + static let largeRowHeight: CGFloat = 20.0 + static let mediumRowHeight: CGFloat = 16.0 + static let verticalCenteringOffset: CGFloat = 2.0 + static let differenceViewAlignment = Alignment.trailing + + static let differenceCornerRadius: CGFloat = 4.0 + + static let differenceViewFont = Font.footnote + // date view + static let dateViewFont = Font.subheadline + static let dateViewFontColor = Color(.label) + static let dateViewFontWeight = Font.Weight.regular + // data view + static let dataViewFont = Font.caption + static let dataViewFontColor = Color(.label) + } +} diff --git a/WordPress/WordPressStatsWidgets/Views/Cards/StatsValueView.swift b/WordPress/WordPressStatsWidgets/Views/Cards/StatsValueView.swift new file mode 100644 index 000000000000..3436559bd445 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Views/Cards/StatsValueView.swift @@ -0,0 +1,34 @@ + +import SwiftUI + +/// a Text containing a stats value, replaced by a placeholder when the placeholder condition is met +struct StatsValueView: View { + + let value: Int + let font: Font + let fontWeight: Font.Weight + let foregroundColor: Color + let lineLimit: Int? + + private var isPlaceholder: Bool { + value < 0 + } + + var body: some View { + + switch isPlaceholder { + case true: + textView.redacted(reason: .placeholder) + case false: + textView + } + } + + private var textView: some View { + Text(value.abbreviatedString()) + .font(font) + .fontWeight(fontWeight) + .foregroundColor(foregroundColor) + .lineLimit(lineLimit) + } +} diff --git a/WordPress/WordPressStatsWidgets/Views/Cards/VerticalCard.swift b/WordPress/WordPressStatsWidgets/Views/Cards/VerticalCard.swift new file mode 100644 index 000000000000..afef33c3743a --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Views/Cards/VerticalCard.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// A card with a title and a value stacked vertically +struct VerticalCard: View { + let title: LocalizedString + let value: Int + let largeText: Bool + + private var titleFont: Font { + largeText ? Appearance.largeTextFont : Appearance.textFont + } + + private var accessibilityLabel: Text { + // The colon makes VoiceOver pause between elements + Text(title) + Text(": ") + Text(value.abbreviatedString()) + } + + var body: some View { + VStack(alignment: .leading) { + Text(title) + .font(Appearance.titleFont) + .fontWeight(Appearance.titleFontWeight) + .foregroundColor(Appearance.titleColor) + .accessibility(hidden: true) + StatsValueView(value: value, + font: titleFont, + fontWeight: .regular, + foregroundColor: Appearance.textColor, + lineLimit: nil) + .accessibility(label: accessibilityLabel) + + } + } +} + +// MARK: - Appearance +extension VerticalCard { + + private enum Appearance { + + static let titleFont = Font.caption + static let titleFontWeight = Font.Weight.semibold + static let titleColor = Color(UIColor.primary) + + static let largeTextFont = Font.largeTitle + static let textFont = Font.title + static let textColor = Color(.label) + } +} diff --git a/WordPress/WordPressStatsWidgets/Views/ListStatsView.swift b/WordPress/WordPressStatsWidgets/Views/ListStatsView.swift new file mode 100644 index 000000000000..d96ecbde2e7c --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Views/ListStatsView.swift @@ -0,0 +1,46 @@ +import SwiftUI +import WidgetKit + +struct ListStatsView: View { + @Environment(\.widgetFamily) var family: WidgetFamily + + let viewData: ListViewData + + private var maxNumberOfLines: Int { + switch family { + case .systemMedium: + return Constants.mediumSizeRows + case .systemLarge: + return Constants.largeSizeRows + default: + return 0 + } + } + + private var displayData: [ThisWeekWidgetDay] { + + maxNumberOfLines < viewData.items.count ? Array(viewData.items.prefix(maxNumberOfLines)) : viewData.items + } + + var body: some View { + VStack { + FlexibleCard(axis: .horizontal, title: viewData.widgetTitle, value: .description(viewData.siteName)) + .padding(.bottom, Constants.titleBottomPadding) + ForEach(Array(displayData.enumerated()), id: \.element) { index, item in + ListRow(date: item.date, percentValue: item.dailyChangePercent, value: item.viewsCount) + if index != displayData.count - 1 { + Divider().padding(.top, 0) + } + } + } + } +} + +private extension ListStatsView { + enum Constants { + static let mediumSizeRows = 3 + static let largeSizeRows = 7 + + static let titleBottomPadding: CGFloat = 8.0 + } +} diff --git a/WordPress/WordPressStatsWidgets/Views/Localization/LocalizableStrings.swift b/WordPress/WordPressStatsWidgets/Views/Localization/LocalizableStrings.swift new file mode 100644 index 000000000000..4784056083a7 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Views/Localization/LocalizableStrings.swift @@ -0,0 +1,120 @@ +import SwiftUI + +enum LocalizableStrings { + // Today Widget title + static let todayWidgetTitle = AppLocalizedString("widget.today.title", + value: "Today", + comment: "Title of today widget") + + // All Time Widget title + static let allTimeWidgetTitle = AppLocalizedString("widget.alltime.title", + value: "All Time", + comment: "Title of all time widget") + + // This Week Widget title + static let thisWeekWidgetTitle = AppLocalizedString("widget.thisweek.title", + value: "This Week", + comment: "Title of this week widget") + + // Widgets content + static let viewsTitle = AppLocalizedString("widget.today.views.label", + value: "Views", + comment: "Title of views label in today widget") + + static let visitorsTitle = AppLocalizedString("widget.today.visitors.label", + value: "Visitors", + comment: "Title of visitors label in today widget") + + static let likesTitle = AppLocalizedString("widget.today.likes.label", + value: "Likes", + comment: "Title of likes label in today widget") + + static let commentsTitle = AppLocalizedString("widget.today.comments.label", + value: "Comments", + comment: "Title of comments label in today widget") + + static let postsTitle = AppLocalizedString("widget.alltime.posts.label", + value: "Posts", + comment: "Title of posts label in all time widget") + + static let bestViewsTitle = AppLocalizedString("widget.alltime.bestviews.label", + value: "Best views ever", + comment: "Title of best views ever label in all time widget") + // Unconfigured view + static let unconfiguredViewTodayTitle = AppLocalizedString("widget.today.unconfigured.view.title", + value: "Log in to WordPress to see today's stats.", + comment: "Title of the unconfigured view in today widget") + + static let unconfiguredViewAllTimeTitle = AppLocalizedString("widget.alltime.unconfigured.view.title", + value: "Log in to WordPress to see all time stats.", + comment: "Title of the unconfigured view in all time widget") + + static let unconfiguredViewThisWeekTitle = AppLocalizedString("widget.thisweek.unconfigured.view.title", + value: "Log in to WordPress to see this week's stats.", + comment: "Title of the unconfigured view in this week widget") + + static let unconfiguredViewJetpackTodayTitle = AppLocalizedString("widget.jetpack.today.unconfigured.view.title", + value: "Log in to Jetpack to see today's stats.", + comment: "Title of the unconfigured view in today widget") + + static let unconfiguredViewJetpackAllTimeTitle = AppLocalizedString("widget.jetpack.alltime.unconfigured.view.title", + value: "Log in to Jetpack to see all time stats.", + comment: "Title of the unconfigured view in all time widget") + + static let unconfiguredViewJetpackThisWeekTitle = AppLocalizedString("widget.jetpack.thisweek.unconfigured.view.title", + value: "Log in to Jetpack to see this week's stats.", + comment: "Title of the unconfigured view in this week widget") + // No data view + static let noDataViewTitle = AppLocalizedString("widget.today.nodata.view.fallbackTitle", + value: "Unable to load site stats.", + comment: "Fallback title of the no data view in the stats widget") + + static let noDataViewTodayTitle = AppLocalizedString("widget.today.nodata.view.title", + value: "Unable to load today's stats.", + comment: "Title of the no data view in today widget") + + static let noDataViewAllTimeTitle = AppLocalizedString("widget.alltime.nodata.view.title", + value: "Unable to load all time stats.", + comment: "Title of the no data view in all time widget") + + static let noDataViewThisWeekTitle = AppLocalizedString("widget.thisweek.nodata.view.title", + value: "Unable to load this week's stats.", + comment: "Title of the no data view in this week widget") + + // No site view + static let noSiteViewTodayTitle = AppLocalizedString("widget.today.nosite.view.title", + value: "Create or add a site to see today's stats.", + comment: "Title of the no site view in today widget") + + static let noSiteViewAllTimeTitle = AppLocalizedString("widget.alltime.nosite.view.title", + value: "Create or add a site to see all time stats.", + comment: "Title of the no site view in all time widget") + + static let noSiteViewThisWeekTitle = AppLocalizedString("widget.thisweek.nosite.view.title", + value: "Create or add a site to see this week's stats.", + comment: "Title of the no site view in this week widget") + + // Today Widget Preview + static let todayPreviewDescription = AppLocalizedString("widget.today.preview.description", + value: "Stay up to date with today's activity on your WordPress site.", + comment: "Description of today widget in the preview") + // All Time Widget preview + static let allTimePreviewDescription = AppLocalizedString("widget.alltime.preview.description", + value: "Stay up to date with all time activity on your WordPress site.", + comment: "Description of all time widget in the preview") + + // This Week Widget preview + static let thisWeekPreviewDescription = AppLocalizedString("widget.thisweek.preview.description", + value: "Stay up to date with this week activity on your WordPress site.", + comment: "Description of all time widget in the preview") + + // Errors + static let unavailableViewTitle = AppLocalizedString("widget.today.view.unavailable.title", + value: "View is unavailable", + comment: "Error message to show if a widget view is unavailable") + + // Stats disabled view + static let statsDisabledViewTitle = AppLocalizedString("widget.today.disabled.view.title", + value: "Stats have moved to the Jetpack app. Switching is free and only takes a minute.", + comment: "Title of the disabled view in today widget") +} diff --git a/WordPress/WordPressStatsWidgets/Views/Localization/LocalizationConfiguration.swift b/WordPress/WordPressStatsWidgets/Views/Localization/LocalizationConfiguration.swift new file mode 100644 index 000000000000..f454f5493abe --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Views/Localization/LocalizationConfiguration.swift @@ -0,0 +1,7 @@ +extension AppConfiguration.Widget { + struct Localization { + static let unconfiguredViewTodayTitle = LocalizableStrings.unconfiguredViewTodayTitle + static let unconfiguredViewThisWeekTitle = LocalizableStrings.unconfiguredViewThisWeekTitle + static let unconfiguredViewAllTimeTitle = LocalizableStrings.unconfiguredViewAllTimeTitle + } +} diff --git a/WordPress/WordPressStatsWidgets/Views/MultiStatsView.swift b/WordPress/WordPressStatsWidgets/Views/MultiStatsView.swift new file mode 100644 index 000000000000..86b86be89bdc --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Views/MultiStatsView.swift @@ -0,0 +1,39 @@ + import SwiftUI + + struct MultiStatsView: View { + let viewData: GroupedViewData + + var body: some View { + VStack(alignment: .leading) { + FlexibleCard(axis: .horizontal, + title: viewData.widgetTitle, + value: .description(viewData.siteName)) + Spacer() + HStack { + makeColumn(upperTitle: viewData.upperLeftTitle, + upperValue: viewData.upperLeftValue, + lowerTitle: viewData.lowerLeftTitle, + lowerValue: viewData.lowerLeftValue) + Spacer() + Spacer() + makeColumn(upperTitle: viewData.upperRightTitle, + upperValue: viewData.upperRightValue, + lowerTitle: viewData.lowerRightTitle, + lowerValue: viewData.lowerRightValue) + Spacer() + } + } + } + + /// Constructs a two-card column for the medium size Today widget + private func makeColumn(upperTitle: LocalizedString, + upperValue: Int, + lowerTitle: LocalizedString, + lowerValue: Int) -> some View { + VStack(alignment: .leading) { + VerticalCard(title: upperTitle, value: upperValue, largeText: false) + Spacer() + VerticalCard(title: lowerTitle, value: lowerValue, largeText: false) + } + } + } diff --git a/WordPress/WordPressStatsWidgets/Views/SingleStatView.swift b/WordPress/WordPressStatsWidgets/Views/SingleStatView.swift new file mode 100644 index 000000000000..580d755823b2 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Views/SingleStatView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct SingleStatView: View { + + let viewData: GroupedViewData + + + var body: some View { + HStack { + VStack(alignment: .leading) { + FlexibleCard(axis: .vertical, title: viewData.widgetTitle, value: .description(viewData.siteName), lineLimit: 2) + + Spacer() + VerticalCard(title: viewData.upperLeftTitle, value: viewData.upperLeftValue, largeText: true) + } + Spacer() + } + } +} diff --git a/WordPress/WordPressStatsWidgets/Views/StatsWidgetsView.swift b/WordPress/WordPressStatsWidgets/Views/StatsWidgetsView.swift new file mode 100644 index 000000000000..7ff90e846eae --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Views/StatsWidgetsView.swift @@ -0,0 +1,137 @@ +import SwiftUI +import WidgetKit + +struct StatsWidgetsView: View { + @Environment(\.widgetFamily) var family: WidgetFamily + + let timelineEntry: StatsWidgetEntry + + @ViewBuilder + var body: some View { + + switch timelineEntry { + + case .disabled(let kind): + UnconfiguredView(timelineEntry: timelineEntry) + .widgetURL(kind.statsURL) + case .loggedOut, .noSite, .noData: + UnconfiguredView(timelineEntry: timelineEntry) + .widgetURL(nil) + // This seems to prevent a bug where the URL for subsequent widget + // types is being triggered if one isn't specified here. + case .siteSelected(let content, _): + if let viewData = makeGroupedViewData(from: content) { + switch family { + + case .systemSmall: + SingleStatView(viewData: viewData) + .widgetURL(viewData.statsURL) + .padding() + + case .systemMedium: + MultiStatsView(viewData: viewData) + .widgetURL(viewData.statsURL) + .padding() + + default: + Text("View is unavailable") + } + } + + if let viewData = makeListViewData(from: content) { + let padding: CGFloat = family == .systemLarge ? 22 : 16 + ListStatsView(viewData: viewData) + .widgetURL(viewData.statsURL) + .padding(.all, padding) + } + } + } +} + +// MARK: - Helper methods +private extension StatsWidgetsView { + + func makeGroupedViewData(from widgetData: HomeWidgetData) -> GroupedViewData? { + + if let todayWidgetData = widgetData as? HomeWidgetTodayData { + + return GroupedViewData(widgetTitle: LocalizableStrings.todayWidgetTitle, + siteName: todayWidgetData.siteName, + upperLeftTitle: LocalizableStrings.viewsTitle, + upperLeftValue: todayWidgetData.stats.views, + upperRightTitle: LocalizableStrings.visitorsTitle, + upperRightValue: todayWidgetData.stats.visitors, + lowerLeftTitle: LocalizableStrings.likesTitle, + lowerLeftValue: todayWidgetData.stats.likes, + lowerRightTitle: LocalizableStrings.commentsTitle, + lowerRightValue: todayWidgetData.stats.comments, + statsURL: todayWidgetData.statsURL) + } + + if let allTimeWidgetData = widgetData as? HomeWidgetAllTimeData { + + return GroupedViewData(widgetTitle: LocalizableStrings.allTimeWidgetTitle, + siteName: allTimeWidgetData.siteName, + upperLeftTitle: LocalizableStrings.viewsTitle, + upperLeftValue: allTimeWidgetData.stats.views, + upperRightTitle: LocalizableStrings.visitorsTitle, + upperRightValue: allTimeWidgetData.stats.visitors, + lowerLeftTitle: LocalizableStrings.postsTitle, + lowerLeftValue: allTimeWidgetData.stats.posts, + lowerRightTitle: LocalizableStrings.bestViewsTitle, + lowerRightValue: allTimeWidgetData.stats.bestViews, + statsURL: allTimeWidgetData.statsURL) + } + return nil + } + + func makeListViewData(from widgetData: HomeWidgetData) -> ListViewData? { + guard let thisWeekWidgetData = widgetData as? HomeWidgetThisWeekData else { + return nil + } + return ListViewData(widgetTitle: LocalizableStrings.thisWeekWidgetTitle, + siteName: thisWeekWidgetData.siteName, + items: thisWeekWidgetData.stats.days, + statsURL: thisWeekWidgetData.statsURL) + } +} + + +private extension HomeWidgetTodayData { + static let statsUrl = "https://wordpress.com/stats/day/" + + var statsURL: URL? { + URL(string: Self.statsUrl + "\(siteID)?source=widget") + } +} + + +private extension HomeWidgetAllTimeData { + static let statsUrl = "https://wordpress.com/stats/insights/" + + var statsURL: URL? { + URL(string: Self.statsUrl + "\(siteID)?source=widget") + } +} + + +private extension HomeWidgetThisWeekData { + static let statsUrl = "https://wordpress.com/stats/week/" + + var statsURL: URL? { + URL(string: Self.statsUrl + "\(siteID)?source=widget") + } +} + +private extension StatsWidgetKind { + var statsURL: URL? { + switch self { + case .today: + return URL(string: "https://wordpress.com/stats/day/?source=widget") + case .allTime: + return URL(string: "https://wordpress.com/stats/insights/?source=widget") + case .thisWeek: + return URL(string: "https://wordpress.com/stats/week/?source=widget") + } + } +} diff --git a/WordPress/WordPressStatsWidgets/Views/UnconfiguredView.swift b/WordPress/WordPressStatsWidgets/Views/UnconfiguredView.swift new file mode 100644 index 000000000000..f515edd8008b --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Views/UnconfiguredView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct UnconfiguredView: View { + + var timelineEntry: StatsWidgetEntry + + var body: some View { + Text(unconfiguredMessage) + .font(.footnote) + .foregroundColor(Color(.secondaryLabel)) + .multilineTextAlignment(.center) + .padding() + } + + var unconfiguredMessage: LocalizedString { + switch timelineEntry { + case .loggedOut(let widgetKind): + switch widgetKind { + case .today: + return AppConfiguration.Widget.Localization.unconfiguredViewTodayTitle + case .allTime: + return AppConfiguration.Widget.Localization.unconfiguredViewAllTimeTitle + case .thisWeek: + return AppConfiguration.Widget.Localization.unconfiguredViewThisWeekTitle + } + case .noSite(let widgetKind): + switch widgetKind { + case .today: + return LocalizableStrings.noSiteViewTodayTitle + case .allTime: + return LocalizableStrings.noSiteViewAllTimeTitle + case .thisWeek: + return LocalizableStrings.noSiteViewThisWeekTitle + } + case .noData(let widgetKind): + switch widgetKind { + case .today: + return LocalizableStrings.noDataViewTodayTitle + case .allTime: + return LocalizableStrings.noDataViewAllTimeTitle + case .thisWeek: + return LocalizableStrings.noDataViewThisWeekTitle + } + case .disabled: + return LocalizableStrings.statsDisabledViewTitle + default: + return LocalizableStrings.noDataViewTitle + } + } +} + +struct PlaceholderView_Previews: PreviewProvider { + static var previews: some View { + UnconfiguredView(timelineEntry: .loggedOut(.today)) + } +} diff --git a/WordPress/WordPressStatsWidgets/Widgets/WordPressHomeWidgetAllTime.swift b/WordPress/WordPressStatsWidgets/Widgets/WordPressHomeWidgetAllTime.swift new file mode 100644 index 000000000000..8102d86a9d8f --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Widgets/WordPressHomeWidgetAllTime.swift @@ -0,0 +1,39 @@ +import WidgetKit +import SwiftUI + + +struct WordPressHomeWidgetAllTime: Widget { + private let tracks = Tracks(appGroupName: WPAppGroupName) + + private let placeholderContent = HomeWidgetAllTimeData(siteID: 0, + siteName: "My WordPress Site", + url: "", + timeZone: TimeZone.current, + date: Date(), + stats: AllTimeWidgetStats(views: 649, + visitors: 572, + posts: 16, + bestViews: 8)) + + var body: some WidgetConfiguration { + IntentConfiguration( + kind: AppConfiguration.Widget.Stats.allTimeKind, + intent: SelectSiteIntent.self, + provider: SiteListProvider(service: StatsWidgetsService(), + placeholderContent: placeholderContent, + widgetKind: .allTime) + ) { (entry: StatsWidgetEntry) -> StatsWidgetsView in + + defer { + tracks.trackWidgetUpdatedIfNeeded(entry: entry, + widgetKind: AppConfiguration.Widget.Stats.allTimeKind, + widgetCountKey: AppConfiguration.Widget.Stats.allTimeProperties) + } + + return StatsWidgetsView(timelineEntry: entry) + } + .configurationDisplayName(LocalizableStrings.allTimeWidgetTitle) + .description(LocalizableStrings.allTimePreviewDescription) + .supportedFamilies(FeatureFlag.todayWidget.enabled ? [.systemSmall, .systemMedium] : []) + } +} diff --git a/WordPress/WordPressStatsWidgets/Widgets/WordPressHomeWidgetThisWeek.swift b/WordPress/WordPressStatsWidgets/Widgets/WordPressHomeWidgetThisWeek.swift new file mode 100644 index 000000000000..5a49de22f567 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Widgets/WordPressHomeWidgetThisWeek.swift @@ -0,0 +1,58 @@ +import WidgetKit +import SwiftUI + + +struct WordPressHomeWidgetThisWeek: Widget { + private let tracks = Tracks(appGroupName: WPAppGroupName) + + static let secondsPerDay = 86400.0 + + private let placeholderContent = HomeWidgetThisWeekData(siteID: 0, + siteName: "My WordPress Site", + url: "", + timeZone: TimeZone.current, + date: Date(), + stats: ThisWeekWidgetStats(days: [ThisWeekWidgetDay(date: Date(), + viewsCount: 130, + dailyChangePercent: -0.22), + ThisWeekWidgetDay(date: Date(timeIntervalSinceNow: -Self.secondsPerDay), + viewsCount: 250, + dailyChangePercent: -0.06), + ThisWeekWidgetDay(date: Date(timeIntervalSinceNow: -(Self.secondsPerDay * 2)), + viewsCount: 260, + dailyChangePercent: 0.86), + ThisWeekWidgetDay(date: Date(timeIntervalSinceNow: -(Self.secondsPerDay * 3)), + viewsCount: 140, + dailyChangePercent: -0.3), + ThisWeekWidgetDay(date: Date(timeIntervalSinceNow: -(Self.secondsPerDay * 4)), + viewsCount: 200, + dailyChangePercent: -0.46), + ThisWeekWidgetDay(date: Date(timeIntervalSinceNow: -(Self.secondsPerDay * 5)), + viewsCount: 370, + dailyChangePercent: 0.19), + ThisWeekWidgetDay(date: Date(timeIntervalSinceNow: -(Self.secondsPerDay * 6)), + viewsCount: 310, + dailyChangePercent: 0.07)])) + + var body: some WidgetConfiguration { + IntentConfiguration( + kind: AppConfiguration.Widget.Stats.thisWeekKind, + intent: SelectSiteIntent.self, + provider: SiteListProvider(service: StatsWidgetsService(), + placeholderContent: placeholderContent, + widgetKind: .thisWeek) + ) { (entry: StatsWidgetEntry) -> StatsWidgetsView in + + defer { + tracks.trackWidgetUpdatedIfNeeded(entry: entry, + widgetKind: AppConfiguration.Widget.Stats.thisWeekKind, + widgetCountKey: AppConfiguration.Widget.Stats.thisWeekProperties) + } + + return StatsWidgetsView(timelineEntry: entry) + } + .configurationDisplayName(LocalizableStrings.thisWeekWidgetTitle) + .description(LocalizableStrings.thisWeekPreviewDescription) + .supportedFamilies(FeatureFlag.todayWidget.enabled ? [.systemMedium, .systemLarge] : []) + } +} diff --git a/WordPress/WordPressStatsWidgets/Widgets/WordPressHomeWidgetToday.swift b/WordPress/WordPressStatsWidgets/Widgets/WordPressHomeWidgetToday.swift new file mode 100644 index 000000000000..2659a29e9ee2 --- /dev/null +++ b/WordPress/WordPressStatsWidgets/Widgets/WordPressHomeWidgetToday.swift @@ -0,0 +1,39 @@ +import WidgetKit +import SwiftUI + + +struct WordPressHomeWidgetToday: Widget { + private let tracks = Tracks(appGroupName: WPAppGroupName) + + private let placeholderContent = HomeWidgetTodayData(siteID: 0, + siteName: "My WordPress Site", + url: "", + timeZone: TimeZone.current, + date: Date(), + stats: TodayWidgetStats(views: 649, + visitors: 572, + likes: 16, + comments: 8)) + + var body: some WidgetConfiguration { + IntentConfiguration( + kind: AppConfiguration.Widget.Stats.todayKind, + intent: SelectSiteIntent.self, + provider: SiteListProvider(service: StatsWidgetsService(), + placeholderContent: placeholderContent, + widgetKind: .today) + ) { (entry: StatsWidgetEntry) -> StatsWidgetsView in + + defer { + tracks.trackWidgetUpdatedIfNeeded(entry: entry, + widgetKind: AppConfiguration.Widget.Stats.todayKind, + widgetCountKey: AppConfiguration.Widget.Stats.todayProperties) + } + + return StatsWidgetsView(timelineEntry: entry) + } + .configurationDisplayName(LocalizableStrings.todayWidgetTitle) + .description(LocalizableStrings.todayPreviewDescription) + .supportedFamilies(FeatureFlag.todayWidget.enabled ? [.systemSmall, .systemMedium] : []) + } +} diff --git a/WordPress/WordPressTest/AccountBuilder.swift b/WordPress/WordPressTest/AccountBuilder.swift new file mode 100644 index 000000000000..d54f14dc6d16 --- /dev/null +++ b/WordPress/WordPressTest/AccountBuilder.swift @@ -0,0 +1,57 @@ +import Foundation + +@testable import WordPress + +/// Builds an Account for use with testing +/// +@objc +class AccountBuilder: NSObject { + private let coreDataStack: CoreDataStack + private var account: WPAccount + + @objc + init(_ coreDataStack: CoreDataStack) { + self.coreDataStack = coreDataStack + + account = NSEntityDescription.insertNewObject(forEntityName: WPAccount.entityName(), into: coreDataStack.mainContext) as! WPAccount + account.uuid = UUID().uuidString + + super.init() + } + + @objc + func with(id: Int64) -> AccountBuilder { + account.userID = NSNumber(value: id) + return self + } + + @objc + func with(uuid: String) -> AccountBuilder { + account.uuid = uuid + return self + } + + @objc + func with(username: String) -> AccountBuilder { + account.username = username + return self + } + + @objc + func with(email: String) -> AccountBuilder { + account.email = email + return self + } + + @objc + func with(blogs: [Blog]) -> AccountBuilder { + account.blogs = Set(blogs) + return self + } + + @objc + @discardableResult + func build() -> WPAccount { + account + } +} diff --git a/WordPress/WordPressTest/AccountServiceTests.swift b/WordPress/WordPressTest/AccountServiceTests.swift index d37876344052..d41409d8f705 100644 --- a/WordPress/WordPressTest/AccountServiceTests.swift +++ b/WordPress/WordPressTest/AccountServiceTests.swift @@ -1,45 +1,38 @@ import UIKit import XCTest +import OHHTTPStubs +import Nimble @testable import WordPress -class AccountServiceTests: XCTestCase { - var contextManager: TestContextManager! +class AccountServiceTests: CoreDataTestCase { var accountService: AccountService! override func setUp() { super.setUp() - contextManager = TestContextManager() - contextManager.requiresTestExpectation = false - accountService = AccountService(managedObjectContext: contextManager.mainContext) + stub(condition: isHost("public-api.wordpress.com")) { request in + NSLog("[Warning] Received an unexpected request sent to \(String(describing: request.url))") + return HTTPStubsResponse(error: URLError(.notConnectedToInternet)) + } + + contextManager.useAsSharedInstance(untilTestFinished: self) + accountService = AccountService(coreDataStack: contextManager) } override func tearDown() { super.tearDown() - deleteTestAccounts() - - ContextManager.overrideSharedInstance(nil) - contextManager.mainContext.reset() - contextManager = nil accountService = nil + HTTPStubs.removeAllStubs() } - func deleteTestAccounts() { - let context = contextManager.mainContext - for account in accountService.allAccounts() { - context.delete(account) - } - contextManager.save(context) - } - - func testCreateWordPressComAccountUUID() { - let account = accountService.createOrUpdateAccount(withUsername: "username", authToken: "authtoken") + func testCreateWordPressComAccountUUID() throws { + let account = try createAccount(withUsername: "username", authToken: "authtoken") XCTAssertNotNil(account.uuid, "UUID should be set") } - func testSetDefaultWordPressComAccountCheckUUID() { - let account = accountService.createOrUpdateAccount(withUsername: "username", authToken: "authtoken") + func testSetDefaultWordPressComAccountCheckUUID() throws { + let account = try createAccount(withUsername: "username", authToken: "authtoken") accountService.setDefaultWordPressComAccount(account) @@ -48,35 +41,35 @@ class AccountServiceTests: XCTestCase { XCTAssertEqual(uuid!, account.uuid, "UUID should be set as default") } - func testGetDefaultWordPressComAccountNoneSet() { - XCTAssertNil(accountService.defaultWordPressComAccount(), "No default account should be set") + func testGetDefaultWordPressComAccountNoneSet() throws { + XCTAssertNil(try WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext), "No default account should be set") } - func testGetDefaultWordPressComAccount() { - let account = accountService.createOrUpdateAccount(withUsername: "username", authToken: "authtoken") + func testGetDefaultWordPressComAccount() throws { + let account = try createAccount(withUsername: "username", authToken: "authtoken") accountService.setDefaultWordPressComAccount(account) - let defaultAccount = accountService.defaultWordPressComAccount() + let defaultAccount = try WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext) XCTAssertNotNil(defaultAccount, "Default account should be set") - XCTAssertEqual(defaultAccount, account, "Default account should the one created") + XCTAssertTrue(account.isDefaultWordPressComAccount, "Default account should the one created") } - func testNumberOfAccountsNoAccounts() { - XCTAssertTrue(0 == accountService.numberOfAccounts(), "There should be zero accounts") + func testNumberOfAccountsNoAccounts() throws { + XCTAssertEqual(try WPAccount.lookupNumberOfAccounts(in: contextManager.mainContext), 0, "There should be zero accounts") } - func testNumberOfAccountsOneAccount() { - _ = accountService.createOrUpdateAccount(withUsername: "username", authToken: "authtoken") + func testNumberOfAccountsOneAccount() throws { + _ = try createAccount(withUsername: "username", authToken: "authtoken") - XCTAssertTrue(1 == accountService.numberOfAccounts(), "There should be one account") + XCTAssertEqual(try WPAccount.lookupNumberOfAccounts(in: contextManager.mainContext), 1, "There should be one account") } - func testNumberOfAccountsTwoAccounts() { - _ = accountService.createOrUpdateAccount(withUsername: "username", authToken: "authtoken") - _ = accountService.createOrUpdateAccount(withUsername: "username2", authToken: "authtoken2") + func testNumberOfAccountsTwoAccounts() throws { + _ = try createAccount(withUsername: "username", authToken: "authtoken") + _ = try createAccount(withUsername: "username2", authToken: "authtoken2") - XCTAssertTrue(2 == accountService.numberOfAccounts(), "There should be two accounts") + try XCTAssertEqual(WPAccount.lookupNumberOfAccounts(in: mainContext), 2, "There should be two accounts") } func testRemoveDefaultWordPressComAccountNoAccount() { @@ -85,57 +78,57 @@ class AccountServiceTests: XCTestCase { XCTAssertTrue(true, "Test passes if no exception thrown") } - func testRemoveDefaultWordPressComAccountAccountSet() { + func testRemoveDefaultWordPressComAccountAccountSet() throws { accountService.removeDefaultWordPressComAccount() - let account = accountService.createOrUpdateAccount(withUsername: "username", authToken: "authtoken") + let account = try createAccount(withUsername: "username", authToken: "authtoken") accountService.setDefaultWordPressComAccount(account) accountService.removeDefaultWordPressComAccount() - XCTAssertNil(accountService.defaultWordPressComAccount(), "No default account should be set") - XCTAssertTrue(account.isFault, "Account should be deleted") + XCTAssertNil(try WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext), "No default account should be set") + try XCTAssertEqual(mainContext.count(for: WPAccount.fetchRequest()), 0) } - func testCreateAccountSetsDefaultAccount() { - XCTAssertNil(accountService.defaultWordPressComAccount()) + func testCreateAccountSetsDefaultAccount() throws { + XCTAssertNil(try WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext)) - let account = accountService.createOrUpdateAccount(withUsername: "username", authToken: "authtoken") - XCTAssertEqual(accountService.defaultWordPressComAccount(), account) + let account = try createAccount(withUsername: "username", authToken: "authtoken") + XCTAssertTrue(account.isDefaultWordPressComAccount) } - func testCreateAccountDoesntReplaceDefaultAccount() { - XCTAssertNil(accountService.defaultWordPressComAccount()) + func testCreateAccountDoesntReplaceDefaultAccount() throws { + XCTAssertNil(try WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext)) - let account = accountService.createOrUpdateAccount(withUsername: "username", authToken: "authtoken") - XCTAssertEqual(accountService.defaultWordPressComAccount(), account) + let account = try createAccount(withUsername: "username", authToken: "authtoken") + XCTAssertTrue(account.isDefaultWordPressComAccount) - _ = accountService.createOrUpdateAccount(withUsername: "another", authToken: "authtoken") - XCTAssertEqual(accountService.defaultWordPressComAccount(), account) + _ = try createAccount(withUsername: "another", authToken: "authtoken") + XCTAssertTrue(account.isDefaultWordPressComAccount) } - func testRestoreDefaultAccount() { - XCTAssertNil(accountService.defaultWordPressComAccount()) + func testRestoreDefaultAccount() throws { + XCTAssertNil(try WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext)) - let account = accountService.createOrUpdateAccount(withUsername: "username", authToken: "authtoken") - XCTAssertEqual(accountService.defaultWordPressComAccount(), account) + let account = try createAccount(withUsername: "username", authToken: "authtoken") + XCTAssertTrue(account.isDefaultWordPressComAccount) - UserDefaults.standard.removeObject(forKey: "AccountDefaultDotcomUUID") + UserSettings.defaultDotComUUID = nil accountService.restoreDisassociatedAccountIfNecessary() - XCTAssertEqual(accountService.defaultWordPressComAccount(), account) + XCTAssertTrue(account.isDefaultWordPressComAccount) } - func testAccountUsedForJetpackIsNotRestored() { - XCTAssertNil(accountService.defaultWordPressComAccount()) + func testAccountUsedForJetpackIsNotRestored() throws { + XCTAssertNil(try WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext)) - let account = accountService.createOrUpdateAccount(withUsername: "username", authToken: "authtoken") - XCTAssertEqual(accountService.defaultWordPressComAccount(), account) + let account = try createAccount(withUsername: "username", authToken: "authtoken") + XCTAssertTrue(account.isDefaultWordPressComAccount) let context = contextManager.mainContext - let jetpackAccount = accountService.createOrUpdateAccount(withUsername: "jetpack", authToken: "jetpack") + let jetpackAccount = try createAccount(withUsername: "jetpack", authToken: "jetpack") let blog = Blog(context: context) blog.xmlrpc = "http://test.blog/xmlrpc.php" blog.username = "admin" @@ -144,15 +137,15 @@ class AccountServiceTests: XCTestCase { blog.account = jetpackAccount contextManager.save(context) - UserDefaults.standard.removeObject(forKey: "AccountDefaultDotcomUUID") + UserSettings.defaultDotComUUID = nil accountService.restoreDisassociatedAccountIfNecessary() - XCTAssertEqual(accountService.defaultWordPressComAccount(), account) + XCTAssertTrue(account.isDefaultWordPressComAccount) accountService.removeDefaultWordPressComAccount() - XCTAssertNil(accountService.defaultWordPressComAccount()) + XCTAssertNil(try WPAccount.lookupDefaultWordPressComAccount(in: contextManager.mainContext)) } - func testMergeMultipleDuplicateAccounts() { + func testMergeMultipleDuplicateAccounts() throws { let context = contextManager.mainContext let account1 = WPAccount(context: context) account1.userID = 1 @@ -176,24 +169,23 @@ class AccountServiceTests: XCTestCase { account1.addBlogs(createMockBlogs(withIDs: [1, 2, 3, 4, 5, 6], in: context)) account2.addBlogs(createMockBlogs(withIDs: [1, 2, 3], in: context)) account3.addBlogs(createMockBlogs(withIDs: [4, 5, 6], in: context)) - contextManager.save(context) + contextManager.saveContextAndWait(context) + + try XCTAssertEqual(context.count(for: WPAccount.fetchRequest()), 3) accountService.setDefaultWordPressComAccount(account1) accountService.mergeDuplicatesIfNecessary() - contextManager.save(context) - XCTAssertFalse(account1.isDeleted) - XCTAssertTrue(account2.isDeleted) - XCTAssertTrue(account3.isDeleted) + try XCTAssertEqual(context.count(for: WPAccount.fetchRequest()), 1) + try XCTAssertEqual((context.fetch(WPAccount.fetchRequest()).first as? WPAccount)?.uuid, account1.uuid) - let service = BlogService(managedObjectContext: contextManager.mainContext) - let blogs = service.blogsForAllAccounts() - XCTAssertTrue(blogs.count == 6) + let blogs = (try? BlogQuery().blogs(in: contextManager.mainContext)) ?? [] + XCTAssertEqual(blogs.count, 6) XCTAssertTrue(account1.blogs.count == 6) } - func testMergeDuplicateAccountsKeepingNonDups() { + func testMergeDuplicateAccountsKeepingNonDups() throws { let context = contextManager.mainContext let account1 = WPAccount(context: context) account1.userID = 1 @@ -216,15 +208,21 @@ class AccountServiceTests: XCTestCase { account1.addBlogs(createMockBlogs(withIDs: [1, 2, 3, 4, 5, 6], in: context)) account2.addBlogs(createMockBlogs(withIDs: [1, 2, 3], in: context)) account3.addBlogs(createMockBlogs(withIDs: [4, 5, 6], in: context)) - contextManager.save(context) + contextManager.saveContextAndWait(context) accountService.setDefaultWordPressComAccount(account1) + try XCTAssertEqual(context.count(for: WPAccount.fetchRequest()), 3) accountService.mergeDuplicatesIfNecessary() - contextManager.save(context) + contextManager.saveContextAndWait(context) + try XCTAssertEqual(context.count(for: WPAccount.fetchRequest()), 2) + + let accountIds = try context.fetch(WPAccount.fetchRequest()).compactMap { ($0 as? WPAccount)?.uuid } + XCTAssertEqual(Set(accountIds), Set([account1.uuid, account3.uuid])) + } - XCTAssertFalse(account1.isDeleted) - XCTAssertTrue(account2.isDeleted) - XCTAssertFalse(account3.isDeleted) + func createAccount(withUsername username: String, authToken: String) throws -> WPAccount { + let id = accountService.createOrUpdateAccount(withUsername: username, authToken: authToken) + return try XCTUnwrap(mainContext.existingObject(with: id) as? WPAccount) } func createMockBlogs(withIDs IDs: [Int], in context: NSManagedObjectContext) -> Set { @@ -241,4 +239,73 @@ class AccountServiceTests: XCTestCase { return blogs } + func testPurgeAccount() throws { + let account1 = WPAccount(context: mainContext) + account1.userID = 1 + account1.username = "username" + account1.authToken = "authToken" + account1.uuid = UUID().uuidString + + let account2 = WPAccount(context: mainContext) + account2.userID = 1 + account2.username = "username" + account2.authToken = "authToken" + account2.uuid = UUID().uuidString + + contextManager.saveContextAndWait(mainContext) + try XCTAssertEqual(mainContext.count(for: WPAccount.fetchRequest()), 2) + + accountService.purgeAccountIfUnused(account1) + try XCTAssertEqual(mainContext.count(for: WPAccount.fetchRequest()), 1) + + try DispatchQueue.global().sync { + self.accountService.purgeAccountIfUnused(account2) + try XCTAssertEqual(mainContext.count(for: WPAccount.fetchRequest()), 0) + } + } + + func testUpdateUserDetails() throws { + stub(condition: isPath("/rest/v1.1/me")) { _ in + HTTPStubsResponse( + jsonObject: [ + "ID": 55511, + "display_name": "Jim Tester", + "username": "jimthetester", + "email": "jim@wptestaccounts.com", + "primary_blog": 55555551, + "primary_blog_url": "https://test1.wordpress.com", + ] as [String: Any], + statusCode: 200, + headers: nil + ) + } + + let account = try createAccount(withUsername: "username", authToken: "token") + waitUntil { done in + self.accountService.updateUserDetails(for: account, success: { done() }, failure: { _ in done() }) + } + + expect(account.username).toEventually(equal("jimthetester")) + expect(account.email).toEventually(equal("jim@wptestaccounts.com")) + } + + func testChangingBlogVisiblity() throws { + stub(condition: isPath("/rest/v1.1/me/sites") && isMethodPOST()) { _ in + HTTPStubsResponse(jsonObject: [String: Any](), statusCode: 200, headers: nil) + } + + let account = try createAccount(withUsername: "username", authToken: "token") + accountService.setDefaultWordPressComAccount(account) + + contextManager.performAndSave { context in + WPAccount.lookup(withObjectID: account.objectID, in: context)? + .addBlogs(self.createMockBlogs(withIDs: [1, 2, 3, 4, 5, 6], in: context)) + } + + let blog = try XCTUnwrap(Blog.lookup(withID: 1, in: mainContext)) + XCTAssertTrue(blog.visible) + self.accountService.setVisibility(false, forBlogs: [blog]) + expect(blog.visible).toEventually(beFalse()) + } + } diff --git a/WordPress/WordPressTest/AccountSettingsServiceTests.swift b/WordPress/WordPressTest/AccountSettingsServiceTests.swift new file mode 100644 index 000000000000..b365eb39fa6d --- /dev/null +++ b/WordPress/WordPressTest/AccountSettingsServiceTests.swift @@ -0,0 +1,102 @@ +import XCTest +import Nimble +import OHHTTPStubs + +@testable import WordPress + +class AccountSettingsServiceTests: CoreDataTestCase { + private var service: AccountSettingsService! + + override func setUp() { + let account = WPAccount(context: mainContext) + account.username = "test" + account.authToken = "token" + account.userID = 1 + account.uuid = UUID().uuidString + + let settings = ManagedAccountSettings(context: mainContext) + settings.account = account + settings.username = "Username" + settings.displayName = "Display Name" + settings.primarySiteID = 1 + settings.aboutMe = "" + settings.email = "test@email.com" + settings.firstName = "Test" + settings.lastName = "User" + settings.language = "en" + settings.webAddress = "https://test.wordpress.com" + + contextManager.saveContextAndWait(mainContext) + + service = AccountSettingsService( + userID: account.userID.intValue, + remote: AccountSettingsRemote(wordPressComRestApi: account.wordPressComRestApi), + coreDataStack: contextManager + ) + } + + private func managedAccountSettings() -> ManagedAccountSettings? { + contextManager.performQuery { context in + let request = NSFetchRequest(entityName: ManagedAccountSettings.entityName()) + request.predicate = NSPredicate(format: "account.userID = %d", self.service.userID) + request.fetchLimit = 1 + guard let results = (try? context.fetch(request)) as? [ManagedAccountSettings] else { + return nil + } + return results.first + } + } + + func testUpdateSuccess() throws { + stub(condition: isPath("/rest/v1.1/me/settings")) { _ in + HTTPStubsResponse(jsonObject: [String: Any](), statusCode: 200, headers: nil) + } + waitUntil { done in + self.service.saveChange(.firstName("Updated"), finished: { success in + expect(success).to(beTrue()) + done() + }) + } + expect(self.managedAccountSettings()?.firstName).to(equal("Updated")) + } + + func testUpdateFailure() throws { + stub(condition: isPath("/rest/v1.1/me/settings")) { _ in + HTTPStubsResponse(jsonObject: [String: Any](), statusCode: 500, headers: nil) + } + waitUntil { done in + self.service.saveChange(.firstName("Updated"), finished: { success in + expect(success).to(beFalse()) + done() + }) + } + expect(self.managedAccountSettings()?.firstName).to(equal("Test")) + } + + func testCancelGettingSettings() throws { + // This test performs steps described in the link below to reproduce a crash. + // https://github.com/wordpress-mobile/WordPress-iOS/issues/20379#issuecomment-1481995663 + + let apiFired = expectation(description: "Get settings API fired") + stub(condition: isPath("/rest/v1.1/me/settings")) { _ in + apiFired.fulfill() + // Simulate a slow HTTP response, so that the test code below has a chance to + // cancel this API request + sleep(2) + return HTTPStubsResponse(jsonObject: [String: Any](), statusCode: 500, headers: nil) + } + service.getSettingsAttempt() + + wait(for: [apiFired], timeout: 0.5) + + // Delete the logged in account, which would cause the URLSession used in the `service` to + // cancell all ongoing tasks, including the "get settings" one above. + let account = try XCTUnwrap(WPAccount.lookup(withUserID: 1, in: contextManager.mainContext)) + contextManager.mainContext.delete(account) + contextManager.saveContextAndWait(contextManager.mainContext) + + let notCrash = expectation(description: "Not crash") + notCrash.isInverted = true + wait(for: [notCrash], timeout: 0.5) + } +} diff --git a/WordPress/WordPressTest/Activity/ActivityListViewModelTests.swift b/WordPress/WordPressTest/Activity/ActivityListViewModelTests.swift new file mode 100644 index 000000000000..c27f4c744062 --- /dev/null +++ b/WordPress/WordPressTest/Activity/ActivityListViewModelTests.swift @@ -0,0 +1,148 @@ +import XCTest +import WordPressFlux + +@testable import WordPress + +class ActivityListViewModelTests: XCTestCase { + + let activityListConfiguration = ActivityListConfiguration( + identifier: "identifier", + title: "Title", + loadingTitle: "Loading Activities...", + noActivitiesTitle: "No activity yet", + noActivitiesSubtitle: "When you make changes to your site you'll be able to see your activity history here.", + noMatchingTitle: "No matching events found.", + noMatchingSubtitle: "Try adjusting your date range or activity type filters", + filterbarRangeButtonTapped: .activitylogFilterbarRangeButtonTapped, + filterbarSelectRange: .activitylogFilterbarSelectRange, + filterbarResetRange: .activitylogFilterbarResetRange, + numberOfItemsPerPage: 20 + ) + + // Check if `loadMore` dispatchs the correct action and params + // + func testLoadMore() { + let jetpackSiteRef = JetpackSiteRef.mock(siteID: 0, username: "") + let activityStoreMock = ActivityStoreMock() + let activityListViewModel = ActivityListViewModel(site: jetpackSiteRef, store: activityStoreMock, configuration: activityListConfiguration) + + activityListViewModel.loadMore() + + XCTAssertEqual(activityStoreMock.dispatchedAction, "loadMoreActivities") + XCTAssertEqual(activityStoreMock.quantity, 20) + XCTAssertEqual(activityStoreMock.offset, 20) + } + + // Check if `loadMore` dispatchs the correct offset + // + func testLoadMoreOffset() throws { + let jetpackSiteRef = JetpackSiteRef.mock(siteID: 0, username: "") + let activityStoreMock = ActivityStoreMock() + let activityListViewModel = ActivityListViewModel(site: jetpackSiteRef, store: activityStoreMock, configuration: activityListConfiguration) + activityStoreMock.state.activities[jetpackSiteRef] = try [Activity.mock(), Activity.mock(), Activity.mock()] + + activityListViewModel.loadMore() + activityListViewModel.loadMore() + + XCTAssertEqual(activityStoreMock.dispatchedAction, "loadMoreActivities") + XCTAssertEqual(activityStoreMock.quantity, 20) + XCTAssertEqual(activityStoreMock.offset, 40) + } + + // Check if `loadMore` dispatchs the correct after/before date and groups + // + func testLoadMoreAfterBeforeDate() throws { + let jetpackSiteRef = JetpackSiteRef.mock(siteID: 0, username: "") + let activityStoreMock = ActivityStoreMock() + let activityListViewModel = ActivityListViewModel(site: jetpackSiteRef, store: activityStoreMock, configuration: activityListConfiguration) + activityStoreMock.state.activities[jetpackSiteRef] = try [Activity.mock(), Activity.mock(), Activity.mock()] + let afterDate = Date() + let beforeDate = Date(timeIntervalSinceNow: 86400) + let activityGroup = ActivityGroup.mock() + activityListViewModel.refresh(after: afterDate, before: beforeDate, group: [activityGroup]) + + activityListViewModel.loadMore() + + XCTAssertEqual(activityStoreMock.dispatchedAction, "loadMoreActivities") + XCTAssertEqual(activityStoreMock.afterDate, afterDate) + XCTAssertEqual(activityStoreMock.beforeDate, beforeDate) + XCTAssertEqual(activityStoreMock.group, [activityGroup.key]) + } + + // Should not load more if already loading + // + func testLoadMoreDoesntTriggeredWhenAlreadyFetching() { + let jetpackSiteRef = JetpackSiteRef.mock(siteID: 0, username: "") + let activityStoreMock = ActivityStoreMock() + let activityListViewModel = ActivityListViewModel(site: jetpackSiteRef, store: activityStoreMock, configuration: activityListConfiguration) + activityStoreMock.isFetching = true + + activityListViewModel.loadMore() + + XCTAssertNil(activityStoreMock.dispatchedAction) + } + + // When filtering, remove all current activities + // + func testRefreshRemoveAllActivities() { + let jetpackSiteRef = JetpackSiteRef.mock(siteID: 0, username: "") + let activityStoreMock = ActivityStoreMock() + let activityListViewModel = ActivityListViewModel(site: jetpackSiteRef, store: activityStoreMock, configuration: activityListConfiguration) + activityStoreMock.isFetching = true + + activityListViewModel.refresh(after: Date(), before: Date()) + + XCTAssertEqual(activityStoreMock.dispatchedAction, "resetActivities") + } +} + +class ActivityStoreMock: ActivityStore { + var dispatchedAction: String? + var site: JetpackSiteRef? + var quantity: Int? + var offset: Int? + var isFetching = false + var afterDate: Date? + var beforeDate: Date? + var group: [String]? + + override func isFetchingActivities(site: JetpackSiteRef) -> Bool { + return isFetching + } + + override func onDispatch(_ action: Action) { + guard let activityAction = action as? ActivityAction else { + return + } + + switch activityAction { + case .loadMoreActivities(let site, let quantity, let offset, let afterDate, let beforeDate, let group): + dispatchedAction = "loadMoreActivities" + self.site = site + self.quantity = quantity + self.offset = offset + self.afterDate = afterDate + self.beforeDate = beforeDate + self.group = group + case .resetActivities: + dispatchedAction = "resetActivities" + default: + break + } + } +} + +extension Activity { + static func mock(isRewindable: Bool = false) throws -> Activity { + let dictionary = [ + "activity_id": "1", + "summary": "", + "is_rewindable": isRewindable, + "rewind_id": "1", + "content": ["text": ""], + "published": "2020-11-09T13:16:43.701+00:00" + ] as [String: AnyObject] + let data = try JSONSerialization.data(withJSONObject: dictionary, options: .prettyPrinted) + return try JSONDecoder().decode(Activity.self, from: data) + } +} diff --git a/WordPress/WordPressTest/ActivityContentFactoryTests.swift b/WordPress/WordPressTest/ActivityContentFactoryTests.swift index 5433a15a1ef6..7066dc1acdc1 100644 --- a/WordPress/WordPressTest/ActivityContentFactoryTests.swift +++ b/WordPress/WordPressTest/ActivityContentFactoryTests.swift @@ -2,19 +2,14 @@ import XCTest @testable import WordPress final class ActivityContentFactoryTests: XCTestCase { - private let contextManager = TestContextManager() - - func testActivityContentFactoryReturnsExpectedImplementationOfFormattableContent() { - let subject = ActivityContentFactory.content(from: [mockBlock()], actionsParser: ActivityActionsParser()).first as? FormattableTextContent + func testActivityContentFactoryReturnsExpectedImplementationOfFormattableContent() throws { + let subject = ActivityContentFactory.content(from: [try mockBlock()], actionsParser: ActivityActionsParser()).first as? FormattableTextContent XCTAssertNotNil(subject) } - private func mockBlock() -> [String: AnyObject] { - return getDictionaryFromFile(named: "activity-log-activity-content.json") + private func mockBlock() throws -> JSONObject { + return try JSONObject(fromFileNamed: "activity-log-activity-content.json") } - private func getDictionaryFromFile(named fileName: String) -> [String: AnyObject] { - return contextManager.object(withContentOfFile: fileName) as! [String: AnyObject] - } } diff --git a/WordPress/WordPressTest/ActivityContentRouterTests.swift b/WordPress/WordPressTest/ActivityContentRouterTests.swift index 260566a24fba..240232743b81 100644 --- a/WordPress/WordPressTest/ActivityContentRouterTests.swift +++ b/WordPress/WordPressTest/ActivityContentRouterTests.swift @@ -15,8 +15,8 @@ final class ActivityContentRouterTests: XCTestCase { super.tearDown() } - func testRouteToComment() { - let commentActivity = getCommentActivity() + func testRouteToComment() throws { + let commentActivity = try getCommentActivity() let router = ActivityContentRouter(activity: commentActivity, coordinator: testCoordinator) router.routeTo(commentURL) @@ -26,8 +26,8 @@ final class ActivityContentRouterTests: XCTestCase { XCTAssertEqual(testCoordinator.commentSiteID?.intValue, testData.testSiteID) } - func testRouteToPost() { - let activity = getPostActivity() + func testRouteToPost() throws { + let activity = try getPostActivity() let router = ActivityContentRouter(activity: activity, coordinator: testCoordinator) router.routeTo(postURL) @@ -49,13 +49,15 @@ extension ActivityContentRouterTests { return URL(string: testData.testCommentURL)! } - func getCommentActivity() -> FormattableActivity { - let activity = try! Activity(dictionary: testData.getCommentEventDictionary()) + func getCommentActivity() throws -> FormattableActivity { + let data = try JSONSerialization.data(withJSONObject: testData.getCommentEventDictionary()) + let activity = try JSONDecoder().decode(Activity.self, from: data) return FormattableActivity(with: activity) } - func getPostActivity() -> FormattableActivity { - let activity = try! Activity(dictionary: testData.getPostEventDictionary()) + func getPostActivity() throws -> FormattableActivity { + let data = try JSONSerialization.data(withJSONObject: testData.getPostEventDictionary()) + let activity = try JSONDecoder().decode(Activity.self, from: data) return FormattableActivity(with: activity) } } diff --git a/WordPress/WordPressTest/ActivityLogFormattableContentTests.swift b/WordPress/WordPressTest/ActivityLogFormattableContentTests.swift index e256746e4d14..bfa2d987fbd2 100644 --- a/WordPress/WordPressTest/ActivityLogFormattableContentTests.swift +++ b/WordPress/WordPressTest/ActivityLogFormattableContentTests.swift @@ -6,8 +6,8 @@ final class ActivityLogFormattableContentTests: XCTestCase { let testData = ActivityLogTestData() let actionsParser = ActivityActionsParser() - func testPingbackContentIsParsedCorrectly() { - let dictionary = testData.getPingbackDictionary() + func testPingbackContentIsParsedCorrectly() throws { + let dictionary = try testData.getPingbackDictionary() let pingbackContent = ActivityContentFactory.content(from: [dictionary], actionsParser: actionsParser) @@ -21,8 +21,8 @@ final class ActivityLogFormattableContentTests: XCTestCase { XCTAssertEqual(pingback.ranges.last?.kind, .post) } - func testPostContentIsParsedCorrectly() { - let dictionary = testData.getPostContentDictionary() + func testPostContentIsParsedCorrectly() throws { + let dictionary = try testData.getPostContentDictionary() let postContent = ActivityContentFactory.content(from: [dictionary], actionsParser: actionsParser) XCTAssertEqual(postContent.count, 1) @@ -34,8 +34,8 @@ final class ActivityLogFormattableContentTests: XCTestCase { XCTAssertEqual(post.ranges.first?.kind, .post) } - func testCommentContentIsParsedCorrectly() { - let dictionary = testData.getCommentContentDictionary() + func testCommentContentIsParsedCorrectly() throws { + let dictionary = try testData.getCommentContentDictionary() let commentContent = ActivityContentFactory.content(from: [dictionary], actionsParser: actionsParser) XCTAssertEqual(commentContent.count, 1) @@ -48,8 +48,8 @@ final class ActivityLogFormattableContentTests: XCTestCase { XCTAssertEqual(comment.ranges.last?.kind, .post) } - func testThemeContentIsParsedCorrectly() { - let dictionary = testData.getThemeContentDictionary() + func testThemeContentIsParsedCorrectly() throws { + let dictionary = try testData.getThemeContentDictionary() let themeContent = ActivityContentFactory.content(from: [dictionary], actionsParser: actionsParser) XCTAssertEqual(themeContent.count, 1) @@ -61,8 +61,8 @@ final class ActivityLogFormattableContentTests: XCTestCase { XCTAssertEqual(theme.ranges.first?.kind, .theme) } - func testSettingContentIsParsedCorrectly() { - let dictionary = testData.getSettingsContentDictionary() + func testSettingContentIsParsedCorrectly() throws { + let dictionary = try testData.getSettingsContentDictionary() let settingsContent = ActivityContentFactory.content(from: [dictionary], actionsParser: actionsParser) XCTAssertEqual(settingsContent.count, 1) @@ -75,8 +75,8 @@ final class ActivityLogFormattableContentTests: XCTestCase { XCTAssertEqual(settings.ranges.last?.kind, .italic) } - func testSiteContentIsParsedCorreclty() { - let dictionary = testData.getSiteContentDictionary() + func testSiteContentIsParsedCorreclty() throws { + let dictionary = try testData.getSiteContentDictionary() let siteContent = ActivityContentFactory.content(from: [dictionary], actionsParser: actionsParser) XCTAssertEqual(siteContent.count, 1) @@ -88,8 +88,8 @@ final class ActivityLogFormattableContentTests: XCTestCase { XCTAssertEqual(site.ranges.first?.kind, .site) } - func testPluginContentIsParsedCorreclty() { - let dictionary = testData.getPluginContentDictionary() + func testPluginContentIsParsedCorreclty() throws { + let dictionary = try testData.getPluginContentDictionary() let pluginContent = ActivityContentFactory.content(from: [dictionary], actionsParser: actionsParser) XCTAssertEqual(pluginContent.count, 1) diff --git a/WordPress/WordPressTest/ActivityLogRangesTest.swift b/WordPress/WordPressTest/ActivityLogRangesTest.swift index b668d9aba825..3ebaa672307a 100644 --- a/WordPress/WordPressTest/ActivityLogRangesTest.swift +++ b/WordPress/WordPressTest/ActivityLogRangesTest.swift @@ -46,8 +46,8 @@ final class ActivityLogRangesTests: XCTestCase { XCTAssertEqual(defaultRange.range, range) } - func testRangeFactoryCreatesCommentRange() { - let commentRangeRaw = testData.getCommentRangeDictionary() + func testRangeFactoryCreatesCommentRange() throws { + let commentRangeRaw = try testData.getCommentRangeDictionary() let range = ActivityRangesFactory.contentRange(from: commentRangeRaw) XCTAssertNotNil(range) @@ -59,8 +59,8 @@ final class ActivityLogRangesTests: XCTestCase { XCTAssertNotNil(commentRange?.url) } - func testRangeFactoryCreatesThemeRange() { - let themeRangeRaw = testData.getThemeRangeDictionary() + func testRangeFactoryCreatesThemeRange() throws { + let themeRangeRaw = try testData.getThemeRangeDictionary() let range = ActivityRangesFactory.contentRange(from: themeRangeRaw) XCTAssertNotNil(range) @@ -72,8 +72,8 @@ final class ActivityLogRangesTests: XCTestCase { XCTAssertNotNil(themeRange?.url) } - func testRangeFactoryCreatesPostRange() { - let postRangeRaw = testData.getPostRangeDictionary() + func testRangeFactoryCreatesPostRange() throws { + let postRangeRaw = try testData.getPostRangeDictionary() let range = ActivityRangesFactory.contentRange(from: postRangeRaw) XCTAssertNotNil(range) @@ -85,24 +85,24 @@ final class ActivityLogRangesTests: XCTestCase { XCTAssertEqual(postRange?.url?.absoluteString, testData.testPostUrl) } - func testRangeFactoryCreatesItalicRange() { - let italicRangeRaw = testData.getItalicRangeDictionary() + func testRangeFactoryCreatesItalicRange() throws { + let italicRangeRaw = try testData.getItalicRangeDictionary() let range = ActivityRangesFactory.contentRange(from: italicRangeRaw) XCTAssertNotNil(range) XCTAssertEqual(range?.kind, .italic) } - func testRangeFactoryCreatesSiteRange() { - let siteRangeRaw = testData.getSiteRangeDictionary() + func testRangeFactoryCreatesSiteRange() throws { + let siteRangeRaw = try testData.getSiteRangeDictionary() let range = ActivityRangesFactory.contentRange(from: siteRangeRaw) XCTAssertNotNil(range) XCTAssertEqual(range?.kind, .site) } - func testRangeFactoryCreatesPluginRange() { - let pluginRangeRaw = testData.getPluginRangeDictionary() + func testRangeFactoryCreatesPluginRange() throws { + let pluginRangeRaw = try testData.getPluginRangeDictionary() let range = ActivityRangesFactory.contentRange(from: pluginRangeRaw) XCTAssertNotNil(range) diff --git a/WordPress/WordPressTest/ActivityLogTestData.swift b/WordPress/WordPressTest/ActivityLogTestData.swift index d367ac5fe5e3..644db66ac7e7 100644 --- a/WordPress/WordPressTest/ActivityLogTestData.swift +++ b/WordPress/WordPressTest/ActivityLogTestData.swift @@ -1,7 +1,5 @@ class ActivityLogTestData { - let contextManager = TestContextManager() - let testPostID = 441 let testSiteID = 137726971 @@ -27,73 +25,69 @@ class ActivityLogTestData { return "https://wordpress.com/comment/137726971/7" } - private func getDictionaryFromFile(named fileName: String) -> [String: AnyObject] { - return contextManager.object(withContentOfFile: fileName) as! [String: AnyObject] - } - - func getCommentEventDictionary() -> [String: AnyObject] { - return getDictionaryFromFile(named: "activity-log-comment.json") + func getCommentEventDictionary() throws -> JSONObject { + return try JSONObject(fromFileNamed: "activity-log-comment.json") } - func getPostEventDictionary() -> [String: AnyObject] { - return getDictionaryFromFile(named: "activity-log-post.json") + func getPostEventDictionary() throws -> JSONObject { + return try JSONObject(fromFileNamed: "activity-log-post.json") } - func getPingbackDictionary() -> [String: AnyObject] { - return getDictionaryFromFile(named: "activity-log-pingback-content.json") + func getPingbackDictionary() throws -> JSONObject { + return try JSONObject(fromFileNamed: "activity-log-pingback-content.json") } - func getPostContentDictionary() -> [String: AnyObject] { - return getDictionaryFromFile(named: "activity-log-post-content.json") + func getPostContentDictionary() throws -> JSONObject { + return try JSONObject(fromFileNamed: "activity-log-post-content.json") } - func getCommentContentDictionary() -> [String: AnyObject] { - return getDictionaryFromFile(named: "activity-log-comment-content.json") + func getCommentContentDictionary() throws -> JSONObject { + return try JSONObject(fromFileNamed: "activity-log-comment-content.json") } - func getThemeContentDictionary() -> [String: AnyObject] { - return getDictionaryFromFile(named: "activity-log-theme-content.json") + func getThemeContentDictionary() throws -> JSONObject { + return try JSONObject(fromFileNamed: "activity-log-theme-content.json") } - func getSettingsContentDictionary() -> [String: AnyObject] { - return getDictionaryFromFile(named: "activity-log-settings-content.json") + func getSettingsContentDictionary() throws -> JSONObject { + return try JSONObject(fromFileNamed: "activity-log-settings-content.json") } - func getSiteContentDictionary() -> [String: AnyObject] { - return getDictionaryFromFile(named: "activity-log-site-content.json") + func getSiteContentDictionary() throws -> JSONObject { + return try JSONObject(fromFileNamed: "activity-log-site-content.json") } - func getPluginContentDictionary() -> [String: AnyObject] { - return getDictionaryFromFile(named: "activity-log-plugin-content.json") + func getPluginContentDictionary() throws -> JSONObject { + return try JSONObject(fromFileNamed: "activity-log-plugin-content.json") } - func getCommentRangeDictionary() -> [String: AnyObject] { - let dictionary = getCommentContentDictionary() + func getCommentRangeDictionary() throws -> JSONObject { + let dictionary = try getCommentContentDictionary() return getRange(at: 0, from: dictionary) } - func getPostRangeDictionary() -> [String: AnyObject] { - let dictionary = getPostContentDictionary() + func getPostRangeDictionary() throws -> JSONObject { + let dictionary = try getPostContentDictionary() return getRange(at: 0, from: dictionary) } - func getThemeRangeDictionary() -> [String: AnyObject] { - let dictionary = getThemeContentDictionary() + func getThemeRangeDictionary() throws -> JSONObject { + let dictionary = try getThemeContentDictionary() return getRange(at: 0, from: dictionary) } - func getItalicRangeDictionary() -> [String: AnyObject] { - let dictionary = getSettingsContentDictionary() + func getItalicRangeDictionary() throws -> JSONObject { + let dictionary = try getSettingsContentDictionary() return getRange(at: 0, from: dictionary) } - func getSiteRangeDictionary() -> [String: AnyObject] { - let dictionary = getSiteContentDictionary() + func getSiteRangeDictionary() throws -> JSONObject { + let dictionary = try getSiteContentDictionary() return getRange(at: 0, from: dictionary) } - func getPluginRangeDictionary() -> [String: AnyObject] { - let dictionary = getPluginContentDictionary() + func getPluginRangeDictionary() throws -> JSONObject { + let dictionary = try getPluginContentDictionary() return getRange(at: 0, from: dictionary) } diff --git a/WordPress/WordPressTest/AllTimeStatsRecordValueTests.swift b/WordPress/WordPressTest/AllTimeStatsRecordValueTests.swift index f60d465f2afa..7341bf1d3d86 100644 --- a/WordPress/WordPressTest/AllTimeStatsRecordValueTests.swift +++ b/WordPress/WordPressTest/AllTimeStatsRecordValueTests.swift @@ -1,3 +1,5 @@ +import XCTest + @testable import WordPress @testable import WordPressKit diff --git a/WordPress/WordPressTest/Analytics/EditorAnalytics/PostEditorAnalyticsSessionTests.swift b/WordPress/WordPressTest/Analytics/EditorAnalytics/PostEditorAnalyticsSessionTests.swift index e2edd834dbef..95e042964a22 100644 --- a/WordPress/WordPressTest/Analytics/EditorAnalytics/PostEditorAnalyticsSessionTests.swift +++ b/WordPress/WordPressTest/Analytics/EditorAnalytics/PostEditorAnalyticsSessionTests.swift @@ -1,7 +1,8 @@ import Foundation +import XCTest @testable import WordPress -class PostEditorAnalyticsSessionTests: XCTestCase { +class PostEditorAnalyticsSessionTests: CoreDataTestCase { enum PostContent { static let classic = """ Text bold italic @@ -14,14 +15,7 @@ class PostEditorAnalyticsSessionTests: XCTestCase { """ } - private var contextManager: TestContextManager! - private var context: NSManagedObjectContext! - - override func setUp() { - contextManager = TestContextManager() - context = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) - context.parent = contextManager.mainContext TestAnalyticsTracker.setup() } @@ -74,7 +68,8 @@ class PostEditorAnalyticsSessionTests: XCTestCase { let tracked = TestAnalyticsTracker.tracked.first XCTAssertEqual(tracked?.stat, WPAnalyticsStat.editorSessionStart) - XCTAssertEqual(tracked?.value(for: "unsupported_blocks"), unsupportedBlocks) + let serializedArray = String(data: try! JSONSerialization.data(withJSONObject: unsupportedBlocks, options: .fragmentsAllowed), encoding: .utf8) + XCTAssertEqual(tracked?.value(for: "unsupported_blocks"), serializedArray) XCTAssertEqual(tracked?.value(for: "has_unsupported_blocks"), "1") } @@ -87,7 +82,8 @@ class PostEditorAnalyticsSessionTests: XCTestCase { let tracked = TestAnalyticsTracker.tracked.first XCTAssertEqual(tracked?.stat, WPAnalyticsStat.editorSessionStart) - XCTAssertEqual(tracked?.value(for: "unsupported_blocks"), unsupportedBlocks) + let serializedArray = String(data: try! JSONSerialization.data(withJSONObject: unsupportedBlocks, options: .fragmentsAllowed), encoding: .utf8) + XCTAssertEqual(tracked?.value(for: "unsupported_blocks"), serializedArray) XCTAssertEqual(tracked?.value(for: "has_unsupported_blocks"), "0") } @@ -120,19 +116,56 @@ class PostEditorAnalyticsSessionTests: XCTestCase { let trackedUnsupportedBlocks: [String]? = tracked?.value(for: "unsupported_blocks") XCTAssertNil(trackedUnsupportedBlocks) } + + func testTrackBlogIdOnStart() { + startSession(editor: .gutenberg, blogID: 123) + + XCTAssertEqual(TestAnalyticsTracker.tracked.count, 1) + + let tracked = TestAnalyticsTracker.tracked.first + + XCTAssertEqual(tracked?.stat, WPAnalyticsStat.editorSessionStart) + XCTAssertEqual(tracked?.value(for: "blog_id"), "123") + } + + func testTrackBlogIdOnSwitch() { + var session = startSession(editor: .gutenberg, blogID: 456) + session.switch(editor: .gutenberg) + + XCTAssertEqual(TestAnalyticsTracker.tracked.count, 2) + + let tracked = TestAnalyticsTracker.tracked.last + + XCTAssertEqual(tracked?.stat, WPAnalyticsStat.editorSessionSwitchEditor) + XCTAssertEqual(tracked?.value(for: "blog_id"), "456") + } + + func testTrackBlogIdOnEnd() { + let session = startSession(editor: .gutenberg, blogID: 789) + session.end(outcome: .publish) + + XCTAssertEqual(TestAnalyticsTracker.tracked.count, 2) + + let tracked = TestAnalyticsTracker.tracked.last + + XCTAssertEqual(tracked?.stat, WPAnalyticsStat.editorSessionEnd) + XCTAssertEqual(tracked?.value(for: "blog_id"), "789") + } } extension PostEditorAnalyticsSessionTests { - func createPost(title: String? = nil, body: String? = nil) -> AbstractPost { - let post = AbstractPost(context: context) + func createPost(title: String? = nil, body: String? = nil, blogID: NSNumber? = nil) -> AbstractPost { + let post = AbstractPost(context: mainContext) post.postTitle = title post.content = body + post.blog = Blog(context: mainContext) + post.blog.dotComID = blogID return post } @discardableResult - func startSession(editor: PostEditorAnalyticsSession.Editor, postTitle: String? = nil, postContent: String? = nil, unsupportedBlocks: [String] = []) -> PostEditorAnalyticsSession { - let post = createPost(title: postTitle, body: postContent) + func startSession(editor: PostEditorAnalyticsSession.Editor, postTitle: String? = nil, postContent: String? = nil, unsupportedBlocks: [String] = [], blogID: NSNumber? = nil) -> PostEditorAnalyticsSession { + let post = createPost(title: postTitle, body: postContent, blogID: blogID) var session = PostEditorAnalyticsSession(editor: .gutenberg, post: post) session.start(unsupportedBlocks: unsupportedBlocks) return session diff --git a/WordPress/WordPressTest/Analytics/Utils/TestAnalyticsTracker.swift b/WordPress/WordPressTest/Analytics/Utils/TestAnalyticsTracker.swift index 9936bfb404c4..229382c33818 100644 --- a/WordPress/WordPressTest/Analytics/Utils/TestAnalyticsTracker.swift +++ b/WordPress/WordPressTest/Analytics/Utils/TestAnalyticsTracker.swift @@ -4,6 +4,7 @@ import Foundation class TestAnalyticsTracker: NSObject { struct Tracked { let stat: WPAnalyticsStat + let event: String let properties: [AnyHashable: Any] func value(for propertyName: String) -> T? { @@ -29,13 +30,21 @@ class TestAnalyticsTracker: NSObject { return tracked.count } - private static func track(_ stat: WPAnalyticsStat, with properties: [AnyHashable: Any]? = nil) { - let trackedStat = Tracked(stat: stat, properties: properties ?? [:]) + private static func track(_ stat: WPAnalyticsStat, event: String = "", with properties: [AnyHashable: Any]? = nil) { + let trackedStat = Tracked(stat: stat, event: event, properties: properties ?? [:]) _tracked.append(trackedStat) } } extension TestAnalyticsTracker: WPAnalyticsTracker { + func trackString(_ event: String) { + TestAnalyticsTracker.track(.noStat, event: event) + } + + func trackString(_ event: String, withProperties properties: [AnyHashable: Any]!) { + TestAnalyticsTracker.track(.noStat, event: event, with: properties) + } + func track(_ stat: WPAnalyticsStat) { TestAnalyticsTracker.track(stat) } diff --git a/WordPress/WordPressTest/AnnualAndMostPopularTimeStatsRecordValueTests.swift b/WordPress/WordPressTest/AnnualAndMostPopularTimeStatsRecordValueTests.swift index 2b41573d3a03..b21dac19099b 100644 --- a/WordPress/WordPressTest/AnnualAndMostPopularTimeStatsRecordValueTests.swift +++ b/WordPress/WordPressTest/AnnualAndMostPopularTimeStatsRecordValueTests.swift @@ -1,3 +1,5 @@ +import XCTest + @testable import WordPress @testable import WordPressKit diff --git a/WordPress/WordPressTest/App Icons/AppIconListViewModelTests.swift b/WordPress/WordPressTest/App Icons/AppIconListViewModelTests.swift new file mode 100644 index 000000000000..d63bb2f52f7e --- /dev/null +++ b/WordPress/WordPressTest/App Icons/AppIconListViewModelTests.swift @@ -0,0 +1,29 @@ +import XCTest + +@testable import WordPress + +final class AppIconListViewModelTests: XCTestCase { + + private var viewModel: AppIconListViewModel! + + // MARK: - Lifecycle + + override func setUp() async throws { + self.viewModel = AppIconListViewModel() + } + + // MARK: - Tests + + /// Tests exactly one primary icon exists. + func testExactlyOnePrimaryIcon() { + let icons = viewModel.icons + let primaryIcons = icons.flatMap { $0.items }.filter { $0.isPrimary } + XCTAssertTrue(primaryIcons.count == 1) + } + + /// Tests at least one custom icon exists. + func testAtLeastOneCustomIcon() { + let icons = viewModel.icons.flatMap { $0.items }.filter { !$0.isPrimary } + XCTAssertTrue(icons.count >= 1) + } +} diff --git a/WordPress/WordPressTest/AppRatingUtilityTests.swift b/WordPress/WordPressTest/AppRatingUtilityTests.swift index b69a7c9242d8..7167d229b583 100644 --- a/WordPress/WordPressTest/AppRatingUtilityTests.swift +++ b/WordPress/WordPressTest/AppRatingUtilityTests.swift @@ -1,4 +1,5 @@ import XCTest +import OHHTTPStubs @testable import WordPress class AppRatingUtilityTests: XCTestCase { diff --git a/WordPress/WordPressTest/ApproveCommentActionTests.swift b/WordPress/WordPressTest/ApproveCommentActionTests.swift index 03d416d73e2f..c98bf1cd2ef5 100644 --- a/WordPress/WordPressTest/ApproveCommentActionTests.swift +++ b/WordPress/WordPressTest/ApproveCommentActionTests.swift @@ -1,12 +1,18 @@ import XCTest @testable import WordPress -final class ApproveCommentActionTests: XCTestCase { +final class ApproveCommentActionTests: CoreDataTestCase { private class TestableApproveComment: ApproveComment { - let service = MockNotificationActionsService(managedObjectContext: TestContextManager.sharedInstance().mainContext) + let service: MockNotificationActionsService + override var actionsService: NotificationActionsService? { return service } + + init(on: Bool, coreDataStack: CoreDataStack) { + service = MockNotificationActionsService(coreDataStack: coreDataStack) + super.init(on: on) + } } private class MockNotificationActionsService: NotificationActionsService { @@ -25,24 +31,22 @@ final class ApproveCommentActionTests: XCTestCase { } private var action: ApproveComment? - private let utility = NotificationUtility() + private var utility: NotificationUtility! private struct Constants { static let initialStatus: Bool = false } override func setUp() { - super.setUp() - utility.setUp() - action = TestableApproveComment(on: Constants.initialStatus) + utility = NotificationUtility(coreDataStack: contextManager) + action = TestableApproveComment(on: Constants.initialStatus, coreDataStack: contextManager) makeNetworkAvailable() } override func tearDown() { action = nil makeNetworkUnavailable() - utility.tearDown() - super.tearDown() + utility = nil } func testStatusPassedInInitialiserIsPreserved() { @@ -65,10 +69,10 @@ final class ApproveCommentActionTests: XCTestCase { XCTAssertEqual(action?.actionTitle, ApproveComment.TitleStrings.approve) } - func testExecuteCallsUnapproveWhenActionIsOn() { + func testExecuteCallsUnapproveWhenActionIsOn() throws { action?.on = true - action?.execute(context: utility.mockCommentContext()) + action?.execute(context: try utility.mockCommentContext()) guard let mockService = action?.actionsService as? MockNotificationActionsService else { XCTFail() @@ -78,17 +82,17 @@ final class ApproveCommentActionTests: XCTestCase { XCTAssertTrue(mockService.unapproveWasCalled) } - func testExecuteUpdatesActionTitleWhenActionIsOn() { + func testExecuteUpdatesActionTitleWhenActionIsOn() throws { action?.on = true - action?.execute(context: utility.mockCommentContext()) + action?.execute(context: try utility.mockCommentContext()) XCTAssertEqual(action?.actionTitle, ApproveComment.TitleStrings.approve) } - func testExecuteCallsApproveWhenActionIsOff() { + func testExecuteCallsApproveWhenActionIsOff() throws { action?.on = false - action?.execute(context: utility.mockCommentContext()) + action?.execute(context: try utility.mockCommentContext()) guard let mockService = action?.actionsService as? MockNotificationActionsService else { XCTFail() @@ -98,10 +102,10 @@ final class ApproveCommentActionTests: XCTestCase { XCTAssertTrue(mockService.approveWasCalled) } - func testExecuteUpdatesActionTitleWhenActionIsOff() { + func testExecuteUpdatesActionTitleWhenActionIsOff() throws { action?.on = false - action?.execute(context: utility.mockCommentContext()) + action?.execute(context: try utility.mockCommentContext()) XCTAssertEqual(action?.actionTitle, ApproveComment.TitleStrings.unapprove) } } diff --git a/WordPress/WordPressTest/AtomicAuthenticationServiceTests.swift b/WordPress/WordPressTest/AtomicAuthenticationServiceTests.swift new file mode 100644 index 000000000000..48b837ffa53c --- /dev/null +++ b/WordPress/WordPressTest/AtomicAuthenticationServiceTests.swift @@ -0,0 +1,56 @@ +import OHHTTPStubs +import UIKit +import XCTest +@testable import WordPress + +class AtomicAuthenticationServiceTests: CoreDataTestCase { + var atomicService: AtomicAuthenticationService! + + override func setUp() { + super.setUp() + + let api = WordPressComRestApi(oAuthToken: "") + let remote = AtomicAuthenticationServiceRemote(wordPressComRestApi: api) + atomicService = AtomicAuthenticationService(remote: remote) + } + + override func tearDown() { + super.tearDown() + + atomicService = nil + } + + fileprivate func stubResponse(forEndpoint endpoint: String, responseFilename filename: String) { + stub(condition: { request in + return (request.url!.absoluteString as NSString).contains(endpoint) && request.httpMethod! == "GET" + }) { _ in + let stubPath = OHPathForFile(filename, type(of: self)) + return fixture(filePath: stubPath!, headers: ["Content-Type": "application/json"]) + } + } + + func testGetAuthCookie() { + let siteID = 55115566 + let endpoint = "sites/\(siteID)/atomic-auth-proxy/read-access-cookies" + let successExpectation = expectation(description: "We expect the cookie to be retrieved and decoded fine.") + + stubResponse(forEndpoint: endpoint, responseFilename: "atomic-get-authentication-cookie-success.json") + + atomicService.getAuthCookie(siteID: siteID, success: { cookie in + XCTAssertEqual(cookie.name, "wordpress_logged_in_39d5e8179c238764ac288442f27d091b") + + if cookie.name == "wordpress_logged_in_39d5e8179c238764ac288442f27d091b" + && cookie.value == "johndoe|1544455667|KwKSrAKJsqIWCTtt2QImT3hFTgHuzDOaMprlWWZXQeQ|7f0a75827e7f72ce645ec817ac9a2ab58735e95752494494cc463d1ad5853add" + && cookie.domain == "testingblog.wordpress.com" + && cookie.path == "/" + && cookie.expiresDate == Date(timeIntervalSince1970: 1584511597) { + + successExpectation.fulfill() + } + }) { _ in + XCTFail("Can't get the requested auth cookie.") + } + + waitForExpectations(timeout: TimeInterval(0.1)) + } +} diff --git a/WordPress/WordPressTest/Aztec/AztecPostViewController+MenuTests.swift b/WordPress/WordPressTest/Aztec/AztecPostViewController+MenuTests.swift new file mode 100644 index 000000000000..7f3cb001a8de --- /dev/null +++ b/WordPress/WordPressTest/Aztec/AztecPostViewController+MenuTests.swift @@ -0,0 +1,53 @@ +@testable import WordPress +import Aztec +import WordPressEditor +import Nimble +import UIKit +import XCTest + +class AztecPostViewController_MenuTests: CoreDataTestCase { + + class Mock: AztecPostViewController { + var callback: ((UIAlertController) -> Void)? + override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) { + print(viewControllerToPresent) + if let alertController = viewControllerToPresent as? UIAlertController { + callback?(alertController) + } + } + } + + private var aztecPostViewController: Mock! + + private func blogPost(with content: String?) -> Post { + let blog = ModelTestHelper.insertSelfHostedBlog(context: mainContext) + let post = NSEntityDescription.insertNewObject(forEntityName: Post.entityName(), into: mainContext) as! Post + post.blog = blog + post.content = content + let settings = GutenbergSettings(database: EphemeralKeyValueDatabase()) + settings.setGutenbergEnabled(true, for: blog) + return post + } + + func testMenuWillShowSwitchToBlockEditor() throws { + // Arrange + let post = blogPost(with: "") + + aztecPostViewController = Mock(post: post, replaceEditor: { (_, _) in }) + let exp = expectation(description: "Wait for alert controller") + aztecPostViewController.callback = { alertController in + + // Assert + expect(alertController.actions.contains(where: { action in + action.title == "Switch to block editor" + })).to(beTrue()) + + exp.fulfill() + } + + // Act + aztecPostViewController.moreWasPressed() + + wait(for: [exp], timeout: 2.0) + } +} diff --git a/WordPress/WordPressTest/Aztec/AztecPostViewControllerAttachmentTests.swift b/WordPress/WordPressTest/Aztec/AztecPostViewControllerAttachmentTests.swift index bc0330a0081e..98020b91fc04 100644 --- a/WordPress/WordPressTest/Aztec/AztecPostViewControllerAttachmentTests.swift +++ b/WordPress/WordPressTest/Aztec/AztecPostViewControllerAttachmentTests.swift @@ -4,28 +4,12 @@ import Aztec import WordPressEditor import Nimble -class AztecPostViewControllerAttachmentTests: XCTestCase { - - private var contextManager: TestContextManager! - private var context: NSManagedObjectContext! - - override func setUp() { - super.setUp() - - contextManager = TestContextManager() - context = contextManager.newDerivedContext() - } - - override func tearDown() { - super.tearDown() - context = nil - contextManager = nil - } +class AztecPostViewControllerAttachmentTests: CoreDataTestCase { func testMediaUploadErrorsWillShowAnErrorMessageAndOverlay() { // Arrange - let media = Media(context: context) - let post = Fixtures.createPost(context: context, with: media) + let media = Media(context: mainContext) + let post = Fixtures.createPost(context: mainContext, with: media) let vc = Fixtures.createAztecPostViewController(with: post) let attachment = vc.findAttachment(withUploadID: media.uploadID)! @@ -44,8 +28,8 @@ class AztecPostViewControllerAttachmentTests: XCTestCase { func testRestartingOfMediaUploadsWillClearErrorMessageAndOverlay() { // Arrange - let media = Media(context: context) - let post = Fixtures.createPost(context: context, with: media) + let media = Media(context: mainContext) + let post = Fixtures.createPost(context: mainContext, with: media) let vc = Fixtures.createAztecPostViewController(with: post) let attachment = vc.findAttachment(withUploadID: media.uploadID)! @@ -66,8 +50,8 @@ class AztecPostViewControllerAttachmentTests: XCTestCase { func testUpdatePostContentAfterAMediaThumbnailUpdate() { // Arrange - let media = Media(context: context) - let post = Fixtures.createPost(context: context, with: media) + let media = Media(context: mainContext) + let post = Fixtures.createPost(context: mainContext, with: media) let vc = Fixtures.createAztecPostViewController(with: post) // Act diff --git a/WordPress/WordPressTest/Aztec/VideoProcessorTests.swift b/WordPress/WordPressTest/Aztec/VideoProcessorTests.swift deleted file mode 100644 index 7a4848c69d31..000000000000 --- a/WordPress/WordPressTest/Aztec/VideoProcessorTests.swift +++ /dev/null @@ -1,42 +0,0 @@ -import XCTest -@testable import WordPress - -class VideoProcessorTests: XCTestCase { - - override func setUp() { - super.setUp() - } - - override func tearDown() { - super.tearDown() - } - - func testVideoPressPreProcessor() { - let shortcodeProcessor = VideoShortcodeProcessor.videoPressPreProcessor - let sampleText = "Before Text[wpvideo OcobLTqC w=640 h=400 autoplay=true html5only=true] After Text" - let parsedText = shortcodeProcessor.process(sampleText) - XCTAssertEqual(parsedText, "Before Text